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/.config/configuration.vsEnterprise.winget b/.config/configuration.vsEnterprise.winget index 84e05ed511..da062e121a 100644 --- a/.config/configuration.vsEnterprise.winget +++ b/.config/configuration.vsEnterprise.winget @@ -1,19 +1,23 @@ # yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 -# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#compiling-powertoys +# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#getting-started properties: resources: - - resource: Microsoft.Windows.Developer/DeveloperMode + - resource: Microsoft.Windows.Settings/WindowsSettings directives: description: Enable Developer Mode allowPrerelease: true + # Requires elevation for the set operation + securityContext: elevated settings: - Ensure: Present + DeveloperMode: true - resource: Microsoft.WinGet.DSC/WinGetPackage id: vsPackage directives: - description: Install Visual Studio 2022 Enterprise (Any edition will work) + description: Install Visual Studio 2026 Enterprise (Any edition will work) + # Requires elevation for the set operation + securityContext: elevated settings: - id: Microsoft.VisualStudio.2022.Enterprise + id: Microsoft.VisualStudio.Enterprise source: winget - resource: Microsoft.VisualStudio.DSC/VSComponents dependsOn: @@ -21,9 +25,11 @@ properties: directives: description: Install required VS workloads allowPrerelease: true + # Requires elevation for the get and set operations + securityContext: elevated settings: productId: Microsoft.VisualStudio.Product.Enterprise - channelId: VisualStudio.17.Release + channelId: VisualStudio.18.Release vsConfigFile: '${WinGetConfigRoot}\..\.vsconfig' configurationVersion: 0.2.0 diff --git a/.config/configuration.vsProfessional.winget b/.config/configuration.vsProfessional.winget index 6ac0babf9f..93eabc621d 100644 --- a/.config/configuration.vsProfessional.winget +++ b/.config/configuration.vsProfessional.winget @@ -1,19 +1,23 @@ # yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 -# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#compiling-powertoys +# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#getting-started properties: resources: - - resource: Microsoft.Windows.Developer/DeveloperMode + - resource: Microsoft.Windows.Settings/WindowsSettings directives: description: Enable Developer Mode allowPrerelease: true + # Requires elevation for the set operation + securityContext: elevated settings: - Ensure: Present + DeveloperMode: true - resource: Microsoft.WinGet.DSC/WinGetPackage id: vsPackage directives: - description: Install Visual Studio 2022 Professional (Any edition will work) + description: Install Visual Studio 2026 Professional (Any edition will work) + # Requires elevation for the set operation + securityContext: elevated settings: - id: Microsoft.VisualStudio.2022.Professional + id: Microsoft.VisualStudio.Professional source: winget - resource: Microsoft.VisualStudio.DSC/VSComponents dependsOn: @@ -21,9 +25,11 @@ properties: directives: description: Install required VS workloads allowPrerelease: true + # Requires elevation for the get and set operations + securityContext: elevated settings: productId: Microsoft.VisualStudio.Product.Professional - channelId: VisualStudio.17.Release + channelId: VisualStudio.18.Release vsConfigFile: '${WinGetConfigRoot}\..\.vsconfig' configurationVersion: 0.2.0 diff --git a/.config/configuration.winget b/.config/configuration.winget index df3eeea441..03aaf55e36 100644 --- a/.config/configuration.winget +++ b/.config/configuration.winget @@ -1,19 +1,23 @@ # yaml-language-server: $schema=https://aka.ms/configuration-dsc-schema/0.2 -# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#compiling-powertoys +# Reference: https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/readme.md#getting-started properties: resources: - - resource: Microsoft.Windows.Developer/DeveloperMode + - resource: Microsoft.Windows.Settings/WindowsSettings directives: description: Enable Developer Mode allowPrerelease: true + # Requires elevation for the set operation + securityContext: elevated settings: - Ensure: Present + DeveloperMode: true - resource: Microsoft.WinGet.DSC/WinGetPackage id: vsPackage directives: - description: Install Visual Studio 2022 Community (Any edition will work) + description: Install Visual Studio 2026 Community (Any edition will work) + # Requires elevation for the set operation + securityContext: elevated settings: - id: Microsoft.VisualStudio.2022.Community + id: Microsoft.VisualStudio.Community source: winget - resource: Microsoft.VisualStudio.DSC/VSComponents dependsOn: @@ -21,9 +25,11 @@ properties: directives: description: Install required VS workloads allowPrerelease: true + # Requires elevation for the get and set operations + securityContext: elevated settings: productId: Microsoft.VisualStudio.Product.Community - channelId: VisualStudio.17.Release + channelId: VisualStudio.18.Release vsConfigFile: '${WinGetConfigRoot}\..\.vsconfig' configurationVersion: 0.2.0 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3db8087042..1a85de1e06 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,5 @@ name: "🕷️ Bug report" description: Report errors or unexpected behavior -type: Bug labels: - Issue-Bug - Needs-Triage @@ -8,18 +7,27 @@ body: - type: markdown attributes: value: Please make sure to [search for existing issues](https://github.com/microsoft/PowerToys/issues) before filing a new one! -- type: input +- type: markdown + attributes: + value: | + We are aware of the following high-volume issues and are actively working on them. Please check if your issue is one of these before filing a new bug report: + * **PowerToys Run crash related to "Desktop composition is disabled"**: This may appear as `COMException: 0x80263001`. For more details, see issue [#31226](https://github.com/microsoft/PowerToys/issues/31226). + * **PowerToys Run crash with `COMException (0xD0000701)`**: For more details, see issue [#30769](https://github.com/microsoft/PowerToys/issues/30769). + * **PowerToys Run crash with a "Cyclic reference" error**: This `System.InvalidOperationException` is detailed in issue [#36451](https://github.com/microsoft/PowerToys/issues/36451). +- id: version + type: input attributes: label: Microsoft PowerToys version - placeholder: 0.70.0 - description: Hover over system tray icon or look at Settings + placeholder: X.XX.X + description: Hover over the system tray icon or look at Settings validations: required: true -- type: dropdown +- id: installed + type: dropdown attributes: label: Installation method - description: How / Where was PowerToys installed from? + description: How / where was PowerToys installed from? multiple: true options: - GitHub @@ -33,14 +41,6 @@ body: validations: required: true -- type: dropdown - attributes: - label: Running as admin - description: Are you running PowerToys as Admin? - options: - - "Yes" - - "No" - - type: dropdown attributes: label: Area(s) with issue? @@ -65,9 +65,10 @@ body: - Image Resizer - Installer - Keyboard Manager + - Light Switch - Mouse Utilities - Mouse Without Borders - - New+ + - New+ - Peek - PowerRename - PowerToys Run @@ -106,6 +107,19 @@ body: validations: required: false +- id: additionalInfo + type: textarea + attributes: + label: Additional Information + placeholder: | + OS version + .Net version + System Language + User or System Installation + Running as admin + validations: + required: false + - type: textarea attributes: label: Other Software @@ -116,3 +130,4 @@ body: My Cool Application v0.3 (include a code snippet if it would help!) validations: required: false + diff --git a/.github/ISSUE_TEMPLATE/documentation-issue.yml b/.github/ISSUE_TEMPLATE/documentation-issue.yml index 151fc5a1f7..583cf54811 100644 --- a/.github/ISSUE_TEMPLATE/documentation-issue.yml +++ b/.github/ISSUE_TEMPLATE/documentation-issue.yml @@ -6,7 +6,7 @@ labels: body: - type: textarea attributes: - label: Provide a description of requested docs changes + label: Describe the requested doc changes placeholder: Briefly describe which document needs to be corrected and why. validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index d7d092dbca..a2c7db9cc5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -13,7 +13,7 @@ body: - type: textarea attributes: label: Scenario when this would be used? - placeholder: What is the scenario this would be used? Why is this important to your workflow as a power user? + placeholder: What is the scenario this would be used in? Why is this important to your workflow as a power user? validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/translation_issue.yml b/.github/ISSUE_TEMPLATE/translation_issue.yml index ffddacb9aa..63b998822f 100644 --- a/.github/ISSUE_TEMPLATE/translation_issue.yml +++ b/.github/ISSUE_TEMPLATE/translation_issue.yml @@ -14,7 +14,7 @@ body: attributes: label: Microsoft PowerToys version placeholder: 0.70.0 - description: Hover over system tray icon or look at Settings + description: Hover over the system tray icon or look at Settings validations: required: true - type: dropdown @@ -38,6 +38,7 @@ body: - Image Resizer - Installer - Keyboard Manager + - Light Switch - Mouse Utilities - Mouse Without Borders - New+ @@ -65,7 +66,7 @@ body: - type: textarea attributes: label: ❌ Actual phrase(s) - placeholder: What is there? Please include a screenshot as that is extremely helpful. + placeholder: What is there? Please include a screenshot, as that is extremely helpful. validations: required: true - type: textarea diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt index 619a036b32..fee0314208 100644 --- a/.github/actions/spell-check/allow/code.txt +++ b/.github/actions/spell-check/allow/code.txt @@ -29,12 +29,16 @@ RUS AYUV bak Bcl +bgcode +Deflatealgorithm exa exabyte Gbits Gbps gcode +Heatshrink Mbits +Kbits MBs mkv msix @@ -53,6 +57,8 @@ YVU YVYU zipfolder CODEOWNERS +VNext +vnext # FONTS @@ -89,19 +95,34 @@ onefuzzingestionpreparationtool OTP Yubi Yubico +Perplexity +Groq svgl +devhome # KEYS altdown BUTTONUP +bafunctions +Baf +Bitness +BUILDARCHSHORT CTRLALTDEL Ctrls +CSilent +CBal +CREATEBAFUNCTIONS +CPrereq +dirutil +DUtil +Editbox EXSEL HOLDENTER HOLDESC HOLDSPACE HOLDBACKSPACE +IDIGNORE KBDLLHOOKSTRUCT keyevent LAlt @@ -113,12 +134,16 @@ LCONTROL LCtrl LEFTDOWN LEFTUP +locutil +logutil +msimg MBUTTON MBUTTONDBLCLK MBUTTONDOWN MBUTTONUP MIDDLEDOWN MIDDLEUP +memutil NCRBUTTONDBLCLK NCRBUTTONDOWN NCRBUTTONUP @@ -131,8 +156,18 @@ RCONTROL RCtrl RIGHTDOWN RIGHTUP +Richedit +rgwz +resrutil +srd +scz +shelutil +thmutil +uriutil VKTAB +wcautil winkey +wininet WMKEYDOWN WMKEYUP WMSYSKEYDOWN @@ -142,6 +177,7 @@ XBUTTONDBLCLK XBUTTONDOWN XBUTTONUP XDOWN +xmlutil # Prefix pcs @@ -271,6 +307,45 @@ mengyuanchen # DllName testhost +Testably #Tools OIP +xef +xes +PACKAGEVERSIONNUMBER +APPXMANIFESTVERSION + +# MRU lists +CACHEWRITE +MRUCMPPROC +MRUINFO +REGSTR + +# Misc Win32 APIs and PInvokes +INVOKEIDLIST +MEMORYSTATUSEX + +# PowerRename metadata pattern abbreviations (used in tests and regex patterns) +DDDD +FFF +HHH +riday +YYY + +# Unicode +precomposed + +# GitHub issue/PR commands +azp +feedbackhub +needinfo +reportbug + +#ffmpeg +crf +nostdin + +# Performance counter keys +engtype +Nonpaged diff --git a/.github/actions/spell-check/allow/names.txt b/.github/actions/spell-check/allow/names.txt index 84884b819c..4df6c5c3e1 100644 --- a/.github/actions/spell-check/allow/names.txt +++ b/.github/actions/spell-check/allow/names.txt @@ -46,8 +46,8 @@ betsegaw bricelam bsky CCcat -chenmy chemwolf +chenmy Chinh chrdavis Chrzan @@ -55,6 +55,7 @@ clayton Coplen craigloewen crutkas +Chubercik damienleroy daverayment davidegiacometti @@ -65,8 +66,8 @@ Deondre DHowett ductdo Essey -Feng ethanfangg +Feng ferraridavide foxmsft frankychen @@ -77,6 +78,7 @@ Galaxi Garside Gershaft Giordani +Gleb Gokce gordon Griese @@ -90,12 +92,15 @@ Hemmerlein hlaueriksson Horvalds Howett +hotkidfamily htcfreek Huynh Ionut jamrobot Jaswal +Jaylyn jefflord +Jeremic Jordi jyuwono kai @@ -105,11 +110,13 @@ Kantarci Karthick kaylacinnamon kevinguo +Khmyznikov Krigun Lambson Laute laviusmotileng Leilei +Loewen Luecking Mahalingam Markovic @@ -126,6 +133,8 @@ Naro nathancartlidge Nemeth nielslaute +Noraa +noraajunker oldnewthing onegreatworld palenshus @@ -145,11 +154,13 @@ ricardosantos riri ritchielawrence robmikh +ruslanlap Russinovich Rutkas ryanbodrug saahmedm sachaple +Sameerjs Santossio Schoen Sekan @@ -165,9 +176,11 @@ Tadele talynone Taras TBM +Teutsch tilovell Triet urnotdfs +vednig waaverecords wang Whuihuan @@ -179,6 +192,7 @@ ycv yeelam Yuniardi yuyoyuppe +zadjii Zeol Zhao Zhaopeng @@ -186,7 +200,6 @@ zhaopy zhaoqpcn Zoltan Zykova -Sameerjs # OTHERS @@ -196,6 +209,7 @@ capturevideosample cmdow Controlz cortana +devhints dlnilsson fancymouse firefox @@ -213,7 +227,10 @@ openai Quickime regedit roslyn +Skia Spotify +taskmgr +tldr Vanara wangyi WEX @@ -228,4 +245,3 @@ xamlstyler Xavalon Xbox Youdao -zadjii 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 13bcc3b29f..d530c32c7f 100644 --- a/.github/actions/spell-check/candidate.patterns +++ b/.github/actions/spell-check/candidate.patterns @@ -1,6 +1,9 @@ # D2D #D?2D +# Repeated letters +\b([a-z])\g{-1}{2,}\b + # marker to ignore all code on line ^.*/\* #no-spell-check-line \*/.*$ # marker to ignore all code on line @@ -10,6 +13,9 @@ # cspell inline ^.*\b[Cc][Ss][Pp][Ee][Ll]{2}:\s*[Dd][Ii][Ss][Aa][Bb][Ll][Ee]-[Ll][Ii][Nn][Ee]\b +# copyright +Copyright (?:\([Cc]\)|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+ + # patch hunk comments ^@@ -\d+(?:,\d+|) \+\d+(?:,\d+|) @@ .* # git index header @@ -18,6 +24,9 @@ index (?:[0-9a-z]{7,40},|)[0-9a-z]{7,40}\.\.[0-9a-z]{7,40} # file permissions ['"`\s][-bcdLlpsw](?:[-r][-w][-Ssx]){2}[-r][-w][-SsTtx]\+?['"`\s] +# css fonts +\bfont(?:-family|):[^;}]+ + # css url wrappings \burl\([^)]+\) @@ -29,7 +38,7 @@ index (?:[0-9a-z]{7,40},|)[0-9a-z]{7,40}\.\.[0-9a-z]{7,40} # data url in quotes ([`'"])data:(?:[^ `'"].*?|)(?:[A-Z]{3,}|[A-Z][a-z]{2,}|[a-z]{3,}).*\g{-1} # data url -\bdata:[-a-zA-Z=;:/0-9+]*,\S* +\bdata:[-a-zA-Z=;:/0-9+_]*,\S* # https/http/file urls #(?:\b(?:https?|ftp|file)://)[-A-Za-z0-9+&@#/*%?=~_|!:,.;]+[-A-Za-z0-9+&@#/*%=~_|] @@ -68,6 +77,8 @@ magnet:[?=:\w]+ # Amazon \bamazon\.com/[-\w]+/(?:dp/[0-9A-Z]+|) +# AWS ARN +arn:aws:[-/:\w]+ # AWS S3 \b\w*\.s3[^.]*\.amazonaws\.com/[-\w/&#%_?:=]* # AWS execute-api @@ -94,6 +105,8 @@ vpc-\w+ \bgoogle-analytics\.com/collect.[-0-9a-zA-Z?%=&_.~]* # Google APIs \bgoogleapis\.(?:com|dev)/[a-z]+/(?:v\d+/|)[a-z]+/[-@:./?=\w+|&]+ +# Google Artifact Registry +\.pkg\.dev(?:/[-\w]+)+(?::[-\w]+|) # Google Storage \b[-a-zA-Z0-9.]*\bstorage\d*\.googleapis\.com(?:/\S*|) # Google Calendar @@ -129,6 +142,8 @@ themes\.googleusercontent\.com/static/fonts/[^/\s"]+/v\d+/[^.]+. \bscholar\.google\.com/citations\?user=[A-Za-z0-9_]+ # Google Colab Research Drive \bcolab\.research\.google\.com/drive/[-0-9a-zA-Z_?=]* +# Google Cloud regions +(?:us|(?:north|south)america|europe|asia|australia|me|africa)-(?:north|south|east|west|central){1,2}\d+ # GitHub SHAs (api) \bapi.github\.com/repos(?:/[^/\s"]+){3}/[0-9a-f]+\b @@ -167,6 +182,12 @@ GHSA(?:-[0-9a-z]{4}){3} # GitLab commits \bgitlab\.[^/\s"]*/(?:[^/\s"]+/){2}commits?/[0-9a-f]+\b +# #includes +^\s*#include\s*(?:<.*?>|".*?") + +# #pragma lib +^\s*#pragma comment\(lib, ".*?"\) + # binance accounts\.binance\.com/[a-z/]*oauth/authorize\?[-0-9a-zA-Z&%]* @@ -219,7 +240,7 @@ accounts\.binance\.com/[a-z/]*oauth/authorize\?[-0-9a-zA-Z&%]* \bmedium\.com/@?[^/\s"]+/[-\w]+ # microsoft -\b(?:https?://|)(?:(?:download\.visualstudio|docs|msdn2?|research)\.microsoft|blogs\.msdn)\.com/[-_a-zA-Z0-9()=./%]* +\b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|docs|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%]* # powerbi \bapp\.powerbi\.com/reportEmbed/[^"' ]* # vs devops @@ -393,7 +414,7 @@ ipfs://[0-9a-zA-Z]{3,} \bgetopts\s+(?:"[^"]+"|'[^']+') # ANSI color codes -(?:\\(?:u00|x)1[Bb]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+|)m +(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m # URL escaped characters %[0-9A-F][A-F](?=[A-Za-z]) @@ -429,10 +450,14 @@ sha\d+:[0-9a-f]*?[a-f]{3,}[0-9a-f]* # pki (base64) LS0tLS1CRUdJT.* +# C# includes +^\s*using [^;]+; + # uuid: \b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b # hex digits including css/html color classes: -(?:[\\0][xX]|\\u|[uU]\+|#x?|%23)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|[iu]\d+)\b +(?:[\\0][xX]|\\u\{?|[uU]\+|#x?|%23|&H)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|[iu]\d+)\b + # integrity integrity=(['"])(?:\s*sha\d+-[-a-zA-Z=;:/0-9+]{40,})+\g{-1} @@ -450,7 +475,10 @@ integrity=(['"])(?:\s*sha\d+-[-a-zA-Z=;:/0-9+]{40,})+\g{-1} Name\[[^\]]+\]=.* # IServiceProvider / isAThing -(?:\b|_)(?:(?:ns|)I|isA)(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b)) +(?:(?:\b|_|(?<=[a-z]))I|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b)) + +# python +#\b(?i)py(?!gments|gmy|lon|ramid|ro|th)(?=[a-z]{2,}) # crypt (['"])\$2[ayb]\$.{56}\g{-1} @@ -464,17 +492,14 @@ Name\[[^\]]+\]=.* # machine learning (?) #\b(?i)ml(?=[a-z]{2,}) -# python -#\b(?i)py(?!gments|gmy|lon|ramid|ro|th)(?=[a-z]{2,}) - # scrypt / argon \$(?:scrypt|argon\d+[di]*)\$\S+ # go.sum \bh1:\S+ -# scala imports -^import (?:[\w.]|\{\w*?(?:,\s*(?:\w*|\*))+\})+ +# imports +^import\s+(?:(?:static|type)\s+|)(?:[\w.]|\{\s*\w*?(?:,\s*(?:\w*|\*))+\s*\})+ # scala modules #("[^"]+"\s*%%?\s*){2,3}"[^"]+" @@ -483,13 +508,13 @@ Name\[[^\]]+\]=.* image: [-\w./:@]+ # Docker images -^\s*FROM\s+\S+:\S+(?:\s+AS\s+\S+|) +^\s*(?i)FROM\s+\S+:\S+(?:\s+AS\s+\S+|) # `docker images` REPOSITORY TAG IMAGE ID CREATED SIZE \s*\S+/\S+\s+\S+\s+[0-9a-f]{8,}\s+\d+\s+(?:hour|day|week)s ago\s+[\d.]+[KMGT]B # Intel intrinsics -_mm_(?!dd)\w+ +_mm\d*_(?!dd)\w+ # Input to GitHub JSON content: (['"])[-a-zA-Z=;:/0-9+]*=\g{-1} @@ -523,7 +548,7 @@ content: (['"])[-a-zA-Z=;:/0-9+]*=\g{-1} # javascript replace regex \.replace\(/[^/\s"]{3,}/[gim]*\s*, # assign regex -= /[^*].*?(?:[a-z]{3,}|[A-Z]{3,}|[A-Z][a-z]{2,}).*/[gi]?(?=\W|$) += /[^*].*?(?:[a-z]{3,}|[A-Z]{3,}|[A-Z][a-z]{2,}).*/[gim]*(?=\W|$) # perl regex test [!=]~ (?:/.*/|m\{.*?\}|m<.*?>|m([|!/@#,;']).*?\g{-1}) @@ -537,10 +562,10 @@ perl(?:\s+-[a-zA-Z]\w*)+ #(?:\d|\bh)to(?!ken)(?=[a-z])|to(?=[adhiklpun]\() # Go regular expressions -regexp?\.MustCompile\(`[^`]*`\) +regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\) # regex choice -\(\?:[^)]+\|[^)]+\) +# \(\?:[^)]+\|[^)]+\) # proto ^\s*(\w+)\s\g{-1} = @@ -585,7 +610,7 @@ urn:shemas-jetbrains-com # xcode # xcodeproject scenes -(?:Controller|destination|ID|id)="\w{3}-\w{2}-\w{3}" +(?:Controller|destination|(?:first|second)Item|ID|id)="\w{3}-\w{2}-\w{3}" # xcode api botches customObjectInstantitationMethod @@ -600,27 +625,33 @@ PrependWithABINamepsace \.fa-[-a-z0-9]+ # bearer auth -(['"])[Bb]ear[e][r] .*?\g{-1} +(['"])[Bb]ear[e][r] .{3,}?\g{-1} # bearer auth -\b[Bb]ear[e][r]:? [-a-zA-Z=;:/0-9+.]+ +\b[Bb]ear[e][r]:? [-a-zA-Z=;:/0-9+.]{3,} # basic auth (['"])[Bb]asic [-a-zA-Z=;:/0-9+]{3,}\g{-1} +# basic auth +: [Bb]asic [-a-zA-Z=;:/0-9+.]{3,} + # base64 encoded content #([`'"])[-a-zA-Z=;:/0-9+]{3,}=\g{-1} # base64 encoded content in xml/sgml >[-a-zA-Z=;:/0-9+]{3,}=?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_]{40,} # DNS rr data -(?:\d+\s+){3}(?:[-+/=.\w]{2,}\s*){1,2} +#(?:\d+\s+){3}(?:[-+/=.\w]{2,}\s*){1,2} # encoded-word =\?[-a-zA-Z0-9"*%]+\?[BQ]\?[^?]{0,75}\?= @@ -629,7 +660,7 @@ PrependWithABINamepsace \bnumer\b(?=.*denom) # Time Zones -\b(?:Africa|Atlantic|America|Antarctica|Asia|Australia|Europe|Indian|Pacific)(?:/\w+)+ +\b(?:Africa|Atlantic|America|Antarctica|Arctic|Asia|Australia|Europe|Indian|Pacific)(?:/[-\w]+)+ # linux kernel info ^(?:bugs|flags|Features)\s+:.* @@ -669,13 +700,13 @@ systemd.*?running in system mode \([-+].*\)$ TeX/AMS # File extensions -\*\.[+\w]+, +#\*\.[+\w]+, # eslint "varsIgnorePattern": ".+" # nolint -nolint:\w+ +nolint:\s*[\w,]+ # Windows short paths [/\\][^/\\]{5,6}~\d{1,2}(?=[/\\]) @@ -683,6 +714,9 @@ nolint:\w+ # Windows Resources with accelerators \b[A-Z]&[a-z]+\b(?!;) +# signed off by +(?i)Signed-off-by: .* + # cygwin paths /cygdrive/[a-zA-Z]/(?:Program Files(?: \(.*?\)| ?)(?:/[-+.~\\/()\w ]+)*|[-+.~\\/()\w])+ @@ -715,29 +749,31 @@ W/"[^"]+" # Compiler flags (Unix, Java/Scala) # Use if you have things like `-Pdocker` and want to treat them as `docker` -#(?:^|[\t ,>"'`=(])-(?:(?:J-|)[DPWXY]|[Llf])(?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}) +#(?:^|[\t ,>"'`=(#])-(?:(?:J-|)[DPWXY]|[Llf])(?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,}) # Compiler flags (Windows / PowerShell) # This is a subset of the more general compiler flags pattern. # It avoids matching `-Path` to prevent it from being treated as `ath` -#(?:^|[\t ,"'`=(])-(?:[DPL](?=[A-Z]{2,})|[WXYlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,})) +#(?:^|[\t ,"'`=(#])-(?:[DPL](?=[A-Z]{2,})|[WXYlf](?=[A-Z]{2,}|[A-Z][a-z]|[a-z]{2,})) # Compiler flags (linker) ,-B -# libraries -(?:\b|_)lib(?:re(?=office)|)(?!era[lt]|ero|erty|rar(?:i(?:an|es)|y))(?=[a-z]) - -# WWNN/WWPN (NAA identifiers) -\b(?:0x)?10[0-9a-f]{14}\b|\b(?:0x|3)?[25][0-9a-f]{15}\b|\b(?:0x|3)?6[0-9a-f]{31}\b +# Library prefix +# e.g., `lib`+`archive`, `lib`+`raw`, `lib`+`unwind` +# (ignores some words that happen to start with `lib`) +(?:\b|_)[Ll]ib(?:re(?=office)|)(?!era[lt]|ero|erty|rar(?:i(?:an|es)|y))(?=[a-z]) # iSCSI iqn (approximate regex) \biqn\.[0-9]{4}-[0-9]{2}(?:[\.-][a-z][a-z0-9]*)*\b +# WWNN/WWPN (NAA identifiers) +\b(?:0x)?10[0-9a-f]{14}\b|\b(?:0x|3)?[25][0-9a-f]{15}\b|\b(?:0x|3)?6[0-9a-f]{31}\b + # curl arguments \b(?:\\n|)curl(?:\.exe|)(?:\s+-[a-zA-Z]{1,2}\b)*(?:\s+-[a-zA-Z]{3,})(?:\s+-[a-zA-Z]+)* # set arguments -\b(?:bash|sh|set)(?:\s+-[abefimouxE]{1,2})*\s+-[abefimouxE]{3,}(?:\s+-[abefimouxE]+)* +\b(?:bash|sh|set)(?:\s+[-+][abefimouxE]{1,2})*\s+[-+][abefimouxE]{3,}(?:\s+[-+][abefimouxE]+)* # tar arguments \b(?:\\n|)g?tar(?:\.exe|)(?:(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+ # tput arguments -- https://man7.org/linux/man-pages/man5/terminfo.5.html -- technically they can be more than 5 chars long... diff --git a/.github/actions/spell-check/excludes.txt b/.github/actions/spell-check/excludes.txt index 21d7da66b8..b029f1dbcb 100644 --- a/.github/actions/spell-check/excludes.txt +++ b/.github/actions/spell-check/excludes.txt @@ -18,6 +18,7 @@ /TestFiles/ [^/]\.cur$ [^/]\.gcode$ +[^/]\.bgcode$ [^/]\.rgs$ \.a$ \.ai$ @@ -73,7 +74,9 @@ \.qm$ \.s$ \.sig$ +\.snk$ \.so$ +\.stl$ \.svgz?$ \.sys$ \.tar$ @@ -90,37 +93,53 @@ \.xz$ \.zip$ ^\.github/actions/spell-check/ +^\.github/workflows/spelling\d*\.yml$ ^\.gitmodules$ -^\Q.github/workflows/spelling2.yml\E$ ^\Q.pipelines/ESRPSigning_core.json\E$ ^\Qdoc/devdocs/localization.md\E$ -^\Qsrc/common/ManagedCommon/ColorFormatHelper.cs\E$ -^\Qsrc/common/notifications/BackgroundActivatorDLL/cpp.hint\E$ -^\Qsrc/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002.pdn\E$ -^\Qsrc/modules/colorPicker/ColorPickerUI/Assets/ColorPicker/colorPicker.cur\E$ -^\Qsrc/modules/colorPicker/ColorPickerUI/Shaders/GridShader.cso\E$ ^\Qsrc/modules/MouseUtils/MouseJump.Common/NativeMethods/User32/UI/WindowsAndMessaging/User32.SYSTEM_METRICS_INDEX.cs\E$ -^\Qsrc/modules/MouseUtils/MouseJumpUI/MainForm.resx\E$ -^\Qsrc/modules/MouseWithoutBorders/App/Form/frmAbout.cs\E$ -^\Qsrc/modules/MouseWithoutBorders/App/Form/frmInputCallback.resx\E$ -^\Qsrc/modules/MouseWithoutBorders/App/Form/frmLogon.resx\E$ -^\Qsrc/modules/MouseWithoutBorders/App/Form/frmMatrix.resx\E$ -^\Qsrc/modules/MouseWithoutBorders/App/Form/frmMessage.resx\E$ -^\Qsrc/modules/MouseWithoutBorders/App/Form/frmMouseCursor.resx\E$ -^\Qsrc/modules/MouseWithoutBorders/App/Form/frmScreen.resx\E$ -^\Qsrc/modules/MouseWithoutBorders/ModuleInterface/generateSecurityDescriptor.h\E$ -^\Qsrc/modules/peek/Peek.Common/NativeMethods.txt\E$ -^\Qsrc/modules/previewpane/SvgPreviewHandler/SvgHTMLPreviewGenerator.cs\E$ -^\Qsrc/modules/previewpane/UnitTests-StlThumbnailProvider/HelperFiles/sample.stl\E$ -^\Qtools/project_template/ModuleTemplate/resource.h\E$ ^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/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/Text/.*\.cs$ +^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$ ^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/ +^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$ ^src/modules/MouseWithoutBorders/App/.*/NativeMethods\.cs$ ^src/modules/MouseWithoutBorders/App/Form/.*\.Designer\.cs$ ^src/modules/MouseWithoutBorders/App/Form/.*\.resx$ +^src/modules/MouseWithoutBorders/App/Form/frmAbout\.cs$ +^src/modules/MouseWithoutBorders/App/Form/frmInputCallback\.resx$ +^src/modules/MouseWithoutBorders/App/Form/frmLogon\.resx$ +^src/modules/MouseWithoutBorders/App/Form/frmMatrix\.resx$ +^src/modules/MouseWithoutBorders/App/Form/frmMessage\.resx$ +^src/modules/MouseWithoutBorders/App/Form/frmMouseCursor\.resx$ +^src/modules/MouseWithoutBorders/App/Form/frmScreen\.resx$ ^src/modules/MouseWithoutBorders/App/Helper/.*\.resx$ +^src/modules/MouseWithoutBorders/ModuleInterface/generateSecurityDescriptor\.h$ +^src/modules/peek/Peek.Common/NativeMethods\.txt$ +^src/modules/peek/Peek.UITests/TestAssets/4\.qoi$ +^src/modules/powerrename/PowerRenameUITest/testItems/folder1/testCase2\.txt$ +^src/modules/powerrename/PowerRenameUITest/testItems/folder2/SpecialCase\.txt$ +^src/modules/powerrename/PowerRenameUITest/testItems/testCase1\.txt$ +^src/modules/previewpane/SvgPreviewHandler/SvgHTMLPreviewGenerator\.cs$ ^src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HelperFiles/MarkdownWithHTMLImageTag\.txt$ +^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$ +^src/modules/ZoomIt/ZoomIt/ZoomIt\.idc$ ^src/Monaco/ -^src/common/sysinternals/Eula/ +^tools/project_template/ModuleTemplate/resource\.h$ ^tools/Verification scripts/Check preview handler registration\.ps1$ ignore$ +^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$ +^src/common/CalculatorEngineCommon/exprtk\.hpp$ +src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index f62b72bbfe..f6ffdc1ac9 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -373,6 +373,7 @@ dlib dllhost dllmain dnceng +Dmdo DNLEN DONOTROUND DONTVALIDATEPATH @@ -1992,4 +1993,4 @@ zoomit ZOOMITX ZXk ZXNs -zzz \ No newline at end of file +zzz diff --git a/.github/actions/spell-check/line_forbidden.patterns b/.github/actions/spell-check/line_forbidden.patterns index 24483dd6f5..d66198c855 100644 --- a/.github/actions/spell-check/line_forbidden.patterns +++ b/.github/actions/spell-check/line_forbidden.patterns @@ -8,6 +8,31 @@ # you might not want to check in code where you skip all the other tests. #\bfit\( +# English does not use a hyphen between adverbs and nouns +# https://twitter.com/nyttypos/status/1894815686192685239 +(?:^|\s)[A-Z]?[a-z]+ly-(?=[a-z]{3,})(?:[.,?!]?\s|$) + +# Smart quotes should match +\s’[^.?!‘’]+’[^.?!‘’]+‘[^.?!‘’]+’|\s‘[^.?!‘’]+’[^.?!‘’]+’[^.?!‘’]+’|\s”[^.?!“”]+”[^.?!“”]+“[^.?!“”]+”|\s“[^.?!“”]+”[^.?!“”]+”[^.?!“”]+” + +# Don't use `requires that` + `to be` +# https://twitter.com/nyttypos/status/1894816551435641027 +\brequires that \w+\b[^.]+to be\b + +# A fully parenthetical sentence’s period goes inside the parentheses, not outside. +# https://twitter.com/nyttypos/status/1898844061873639490 +\([A-Z][a-z]{2,}(?: [a-z]+){3,}\)\.\s + +# Complete sentences shouldn't be in the middle of another sentence as a parenthetical. +(? In formal writing and where contractions are frowned upon, use `cannot`. # > It is possible to write `can not`, but you generally find it only as part of some other construction, such as `not only . . . but also.` # - if you encounter such a case, add a pattern for that case to patterns.txt. -\b[Cc]an not\b +\b[Cc]an not\b(?! only\b) + +# Should be `chart` +(?i)\bhelm\b.*\bchard\b # Do not use `(click) here` links # For more information, see: @@ -56,19 +140,49 @@ # * https://heyoka.medium.com/dont-use-click-here-f32f445d1021 (?i)(?:>|\[)(?:(?:click |)here|this(?=\]\([^\)]+:/)|link|(?:read |)more(?!> /etc/apt/sources.list.d/something-distro.list +# ```` +\bapt-key add\b + +# Should be `nearby` +\bnear by\b + # Should probably be a person named `Nick` or the abbreviation `NIC` \bNic\b # Should be `not supposed` \bsupposed not\b +# Should be `Once this` or `On this` or even `One that`. Rarely `One, this` +[?!.] One this\b + # Should probably be `much more` \bmore much\b @@ -153,7 +307,10 @@ \bperform it's\b # Should be `opt-in` -(? below for the` +(?i)\bfind below the\b + +# Should be `then any` unless there's a comparison before the `,` +, than any\b + # Should be `did not exist` \bwere not existent\b @@ -197,9 +419,18 @@ # Should be `nonexistent` \b[Nn]o[nt][- ]existent\b +# Should be `our` +\bspending out time\b + # Should be `@brief` / `@details` / `@param` / `@return` / `@retval` (?:^\s*|(?:\*|//|/*)\s+`)[\\@](?:breif|(?:detail|detials)|(?:params(?!\.)|prama?)|ret(?:uns?)|retvl)\b +# Should be `more than` or `more, then` +\bmore then\b + +# Should be `Pipeline`/`pipeline` +(?:(?<=\b|[A-Z])p|P)ipeLine(?:\b|(?=[A-Z])) + # Should be `preexisting` [Pp]re[- ]existing @@ -215,6 +446,9 @@ # Should be `prerequisite` [Pp]re[- ]requisite +# Should be `QuickTime` +\bQuicktime\b + # Should be `recently changed` or `recent changes` [Rr]ecent changed @@ -224,14 +458,30 @@ # Should be `reentrant` [Rr]e[- ]entrant +# Should be `room for` +\brooms for (?!lease|rent|sale) + +# Should be `socioeconomic` +# https://dictionary.cambridge.org/us/dictionary/english/socioeconomic +socio-economic + # Should be `strong suit` \b(?:my|his|her|their) strong suite\b +# Should probably be `temperatures` unless actually talking about thermal drafts (things birds may fly on) +\bthermals\b + +# Should be `there are` or `they are` (or `they're`) +(?i)\btheir are\b + # Should be `understand` \bunder stand\b -# Should be `URI` or `uri` unless it refers to a person named `Uri` -#(?|".*?") +# marker to ignore all code on line +^.*/\* #no-spell-check-line \*/.*$ +# marker for ignoring a comment to the end of the line +// #no-spell-check.*$ -# #pragma lib -^\s*#pragma comment\(lib, ".*?"\) +# Gaelic +Gàidhlig + +Ov_erwrite # languageHashTable "\w+(?:-\w+|)"\s+=\s+@\(".*"\) -# wikipedia -\b\w\w\.wikipedia\.org/wiki/[-\w%.#]+ +# Regular expression with `\b` +\\b(?=[a-z]\S*\{) -# css fonts -\bfont-family:[^;}]+ - -# .github/policies/resourceManagement.yml -pattern: '.*' +# long lorem +L"Lorem.*" # tabs in c# \$"\\t # Hexadecimal character pattern in code -\\x[0-9a-fA-F][0-9a-fA-F] +\\x[0-9a-fA-F]{4} + +fontFamily": ".*" + +D[23]D(?=[A-Z][a-z]) +(?<=[a-z])3D(?=[A-Z]) + +\.monitorId = \{ .*\} + +json::value\(L"\S+" # windows line breaks in strings -\\r\\n +\\r\\n(?=[A-Za-z]) # power shell gallery website \bpowershellgallery.com/[-_a-zA-Z0-9()=./%]* @@ -35,9 +45,22 @@ L?(["']|[-<({>]|\b)[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{10,12}(?:\g{ (?:L"[abAB]+", ){3}L"[abAB]+" -# hit-count: 1 file-count: 1 -# marker to ignore all code on line -^.*/\* #no-spell-check-line \*/.*$ +\. (?: @[-A-Za-z\d]+\b(?!\.[A-Z]),?)+ + +auto deviceId = L".*" +deviceId(?:\.id|) = L".*" + +StringComparer.OrdinalIgnoreCase\) \{.*\} + +# namespaces +\b[a-z]+:: + +"Author": ".+" + +(?:Include|Link)=".*?" + +# You could ignore `xmlns`, but it's probably better to enforce rules about them... +#\s(?:xmlns:[a-z]+(?:[A-Z][a-z]+|)=|[a-z]+(?:[A-Z][a-z]+):(?=[a-z]+=)) # UnitTests \[DataRow\(.*\)\] @@ -50,142 +73,135 @@ L?(["']|[-<({>]|\b)[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{10,12}(?:\g{ # Automatically suggested patterns -# hit-count: 3715 file-count: 992 +# hit-count: 5402 file-count: 1339 # IServiceProvider / isAThing -(?:\b|_)(?:(?:ns|)I|isA)(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b)) +(?:(?:\b|_|(?<=[a-z]))[IT]|(?:\b|_)(?:nsI|isA))(?=(?:[A-Z][a-z]{2,})+(?:[A-Z\d]|\b)) -# hit-count: 404 file-count: 42 -# base64 encoded content, possibly wrapped in mime -(?:^|[\s=;:?])[-a-zA-Z=;:/0-9+]{50,}(?:[\s=;:?]|$) +# hit-count: 2073 file-count: 842 +# #includes +^\s*#include\s*(?:<.*?>|".*?") -# hit-count: 402 file-count: 160 +# hit-count: 1639 file-count: 855 +# C# includes +^\s*using [^;]+; + +# hit-count: 1491 file-count: 693 +# microsoft +\b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|docs|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%]* + +# hit-count: 398 file-count: 133 +# hex digits including css/html color classes: +(?:[\\0][xX]|\\u\{?|[uU]\+|#x?|%23|&H)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|[iu]\d+)\b + +# hit-count: 339 file-count: 146 # hex runs \b[0-9a-fA-F]{16,}\b -# hit-count: 337 file-count: 110 -# hex digits including css/html color classes: -(?:[\\0][xX]|\\u|[uU]\+|#x?|%23)[0-9_a-fA-FgGrR]*?[a-fA-FgGrR]{2,}[0-9_a-fA-FgGrR]*(?:[uUlL]{0,3}|[iu]\d+)\b - -# hit-count: 311 file-count: 43 -# D2D -D?2D(?!efault) - -# hit-count: 272 file-count: 75 +# hit-count: 253 file-count: 100 # GitHub SHAs (markdown) (?:\[`?[0-9a-f]+`?\]\(https:/|)/(?:www\.|)github\.com(?:/[^/\s"]+){2,}(?:/[^/\s")]+)(?:[0-9a-f]+(?:[-0-9a-zA-Z/#.]*|)\b|) -# hit-count: 146 file-count: 27 +# hit-count: 241 file-count: 37 # version suffix v# (?:(?<=[A-Z]{2})V|(?<=[a-z]{2}|[A-Z]{2})v)\d+(?:\b|(?=[a-zA-Z_])) -# hit-count: 105 file-count: 103 +# hit-count: 141 file-count: 6 +# Contributor / Project +\[[^\]\s]+\]\(https://github\.com/[^)]+\)(?: -(?: [A-Z]\S+)+|)|\[[^\]]+\]\(https://github\.com/(?:[^/\s"]+/?){1,2}\) + +https://github.com/(?:[-\w]+/?){1,2} + +# hit-count: 131 file-count: 125 +# Repeated letters +\b([a-z])\g{-1}{2,}\b + +# hit-count: 99 file-count: 97 # w3 \bw3\.org/[-0-9a-zA-Z/#.]+ -# hit-count: 94 file-count: 6 -# Contributor -\[[^\]]+\]\(https://github\.com/[^/\s"]+/?\) - -RegExp\(([`'"]).*?\g{-1}\)|(?:escapes|regEx):\s*(?:/.*/|([`'"]).*?\g{-1})|return/.*?/ - -# hit-count: 65 file-count: 38 -# regex choice -\(\?:[^)]+\|[^)]+\) - -# hit-count: 37 file-count: 14 +# hit-count: 59 file-count: 11 # Markdown anchor links \(#\S*?[a-zA-Z]\S*?\) -# hit-count: 33 file-count: 5 -# base64 encoded pkcs -\bMII[-a-zA-Z=;:/0-9+]+ - -# hit-count: 28 file-count: 22 +# hit-count: 29 file-count: 23 # stackexchange -- https://stackexchange.com/feeds/sites \b(?:askubuntu|serverfault|stack(?:exchange|overflow)|superuser).com/(?:questions/\w+/[-\w]+|a/) -# hit-count: 14 file-count: 3 -# node packages -(["'])@[^/'" ]+/[^/'" ]+\g{-1} +# hit-count: 24 file-count: 11 +# Library prefix +# e.g., `lib`+`archive`, `lib`+`raw`, `lib`+`unwind` +# (ignores some words that happen to start with `lib`) +(?:\b|_)[Ll]ib(?:re(?=office)|)(?!era[lt]|ero|erty|rar(?:i(?:an|es)|y))(?=[a-z]) -# hit-count: 13 file-count: 1 +# hit-count: 20 file-count: 2 # Intel intrinsics -_mm_(?!dd)\w+ +_mm\d*_(?!dd)\w+ -# hit-count: 11 file-count: 5 -# URL escaped characters -%[0-9A-F][A-F](?=[A-Za-z]) - -# hit-count: 9 file-count: 5 +# hit-count: 15 file-count: 8 # Wikipedia \ben\.wikipedia\.org/wiki/[-\w%.#]+ -# hit-count: 5 file-count: 4 +# hit-count: 14 file-count: 10 # vs devops \bvisualstudio.com(?::443|)/[-\w/?=%&.]* -# hit-count: 5 file-count: 4 -# Alternatively, if you're using check-spelling v0.0.25+, and you would like to _check_ the Non-English content for spelling errors, you can. For information on how to do so, see: -# https://docs.check-spelling.dev/Feature:-Configurable-word-characters.html#unicode -[a-zA-Z]*[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3}[a-zA-ZÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]*|[a-zA-Z]{3,}[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź]|[ÀÁÂÃÄÅÆČÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝßàáâãäåæčçèéêëìíîïðñòóôõöøùúûüýÿĀāŁłŃńŅņŒœŚśŠšŜŝŸŽžź][a-zA-Z]{3,} +# hit-count: 8 file-count: 2 +# copyright +Copyright (?:\([Cc]\)|)(?:[-\d, ]|and)+(?: [A-Z][a-z]+ [A-Z][a-z]+,?)+ -# hit-count: 4 file-count: 4 -# microsoft -\b(?:https?://|)(?:(?:(?:blogs|download\.visualstudio|developer|docs|learn|msdn2?|research)\.|)microsoft|blogs\.msdn)\.co(?:m|\.\w\w)/[-_a-zA-Z0-9()=./%#]* +# hit-count: 8 file-count: 1 +# css fonts +\bfont(?:-family|):[^;}]+ aka\.ms/[a-zA-Z0-9]+ +# hit-count: 8 file-count: 1 +# kubernetes crd patterns +^\s*pattern: .*$ + +# hit-count: 5 file-count: 3 +# URL escaped characters +%[0-9A-F][A-F](?=[A-Za-z]) + # hit-count: 3 file-count: 3 # githubusercontent /[-a-z0-9]+\.githubusercontent\.com/[-a-zA-Z0-9?&=_\/.]* -# hit-count: 3 file-count: 2 -# css url wrappings -\burl\([^)]+\) - -# hit-count: 3 file-count: 1 -# kubernetes crd patterns -^\s*pattern: .*$ - -# hit-count: 3 file-count: 1 -# Lorem -# Update Lorem based on your content (requires `ge` and `w` from https://github.com/jsoref/spelling; and `review` from https://github.com/check-spelling/check-spelling/wiki/Looking-for-items-locally ) -# grep '^[^#].*lorem' .github/actions/spelling/patterns.txt|perl -pne 's/.*i..\?://;s/\).*//' |tr '|' "\n"|sort -f |xargs -n1 ge|perl -pne 's/^[^:]*://'|sort -u|w|sed -e 's/ .*//'|w|review - -# Warning, while `(?i)` is very neat and fancy, if you have some binary files that aren't proper unicode, you might run into: -# ... Operation "substitution (s///)" returns its argument for non-Unicode code point 0x1C19AE (the code point will vary). -# ... You could manually change `(?i)X...` to use `[Xx]...` -# ... or you could add the files to your `excludes` file (a version after 0.0.19 should identify the file path) -(?:(?:\w|\s|[,.])*\b(?i)(?:amet|consectetur|cursus|dolor|eros|ipsum|lacus|libero|ligula|lorem|magna|neque|nulla|suscipit|tempus)\b(?:\w|\s|[,.])*) - -# hit-count: 3 file-count: 1 -# libraries -(?:\b|_)lib(?:re(?=office)|)(?!era[lt]|ero|erty|rar(?:i(?:an|es)|y))(?=[a-z]) +# hit-count: 2 file-count: 2 +# medium +\bmedium\.com/@?[^/\s"]+/[-\w:/*.]+ # hit-count: 2 file-count: 1 # While you could try to match `http://` and `https://` by using `s?` in `https?://`, sometimes there # YouTube url \b(?:(?:www\.|)youtube\.com|youtu.be)/(?:channel/|embed/|user/|playlist\?list=|watch\?v=|v/|)[-a-zA-Z0-9?&=_%]* -# hit-count: 1 file-count: 1 -# GHSA -GHSA(?:-[0-9a-z]{4}){3} - -# hit-count: 1 file-count: 1 +# hit-count: 2 file-count: 1 # GitHub actions \buses:\s+[-\w.]+/[-\w./]+@[-\w.]+ # hit-count: 1 file-count: 1 -# medium -\bmedium\.com/@?[^/\s"]+/[-\w]+ - -# hit-count: 1 file-count: 1 -# sha-... -- uses a fancy capture -(\\?['"]|")[0-9a-f]{40,}\g{-1} +# curl arguments +\b(?:\\n|)curl(?:\.exe|)(?:\s+-[a-zA-Z]{1,2}\b)*(?:\s+-[a-zA-Z]{3,})(?:\s+-[a-zA-Z]+)* # hit-count: 1 file-count: 1 # tar arguments \b(?:\\n|)g?tar(?:\.exe|)(?:(?:\s+--[-a-zA-Z]+|\s+-[a-zA-Z]+|\s[ABGJMOPRSUWZacdfh-pr-xz]+\b)(?:=[^ ]*|))+ +# #pragma lib +^\s*#pragma comment\(lib, ".*?"\) + +# UnitTests +\[DataRow\(.*\)\] + +# AdditionalDependencies +.*< + +# the last line of mimetype="application/x-microsoft.net.object.bytearray.base64" things in .resx files +^\s*[-a-zA-Z=;:/0-9+]*[-a-zA-Z;:/0-9+][-a-zA-Z=;:/0-9+]*=$ + +RegExp\(@?([`'"]).*?\g{-1}\)|(?:escapes|regEx):\s*(?:/.*/|([`'"]).*?\g{-1})|return/.*?/ + # Questionably acceptable forms of `in to` # Personally, I prefer `log into`, but people object # https://www.tprteaching.com/log-into-log-in-to-login/ @@ -194,13 +210,16 @@ GHSA(?:-[0-9a-z]{4}){3} # to opt in \bto opt in\b +# pass(ed|ing) in +\bpass(?:ed|ing) in\b + # acceptable duplicates # ls directory listings [-bcdlpsw](?:[-r][-w][-SsTtx]){3}[\.+*]?\s+\d+\s+\S+\s+\S+\s+[.\d]+(?:[KMGT]|)\s+ # mount \bmount\s+-t\s+(\w+)\s+\g{-1}\b # C types and repeated CSS values -\s(auto|buffalo|center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?: \g{-1})+\s +\s(auto|buffalo|center|div|inherit|long|LONG|none|normal|solid|thin|transparent|very)(?:\s\g{-1})+\s # C enum and struct \b(?:enum|struct)\s+(\w+)\s+\g{-1}\b # go templates @@ -232,9 +251,11 @@ _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING # ignore long runs of a single character: \b([A-Za-z])\g{-1}{3,}\b +# hit-count: 1 file-count: 1 # Amazon \bamazon\.com/[-\w]+/(?:dp/[0-9A-Z]+|) +# hit-count: 3 file-count: 3 # imgur \bimgur\.com/[^.]+ @@ -243,4 +264,28 @@ Process Process # ZoomIt menu items with accelerator keys E&xit -St&yle \ No newline at end of file +St&yle + +# This matches a relative clause where the relative pronoun "that" is omitted. +# Example: "Gets or sets the window the TitleBar should configure." +\bthe\s+\w+\s+the\b + +# Usernames with numbers +# 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/actions/spell-check/reject.txt b/.github/actions/spell-check/reject.txt index 5cc86ef80c..48a5833d12 100644 --- a/.github/actions/spell-check/reject.txt +++ b/.github/actions/spell-check/reject.txt @@ -1,8 +1,17 @@ ^attache$ -^bellow$ +^bellows?$ benefitting occurences? ^dependan.* +^develope$ +^developement$ +^developpe +^Devers?$ +^devex +^devide +^Devinn?[ae] +^devisal +^devisor ^diables?$ ^oer$ Sorce @@ -10,4 +19,5 @@ Sorce ^Teh$ ^untill$ ^untilling$ +^venders?$ ^wether.* diff --git a/.github/agents/FixIssue.agent.md b/.github/agents/FixIssue.agent.md new file mode 100644 index 0000000000..7d5bb81618 --- /dev/null +++ b/.github/agents/FixIssue.agent.md @@ -0,0 +1,92 @@ +--- +description: 'Implements fixes for GitHub issues based on implementation plans' +name: 'FixIssue' +tools: ['read', 'edit', 'search', 'execute', 'agent', 'usages', 'problems', 'changes', 'testFailure', 'github/*', 'github.vscode-pull-request-github/*'] +argument-hint: 'GitHub issue number (e.g., #12345)' +infer: true +--- + +# FixIssue Agent + +You are an **IMPLEMENTATION AGENT** specialized in executing implementation plans to fix GitHub issues. + +## Identity & Expertise + +- Expert at translating plans into working code +- Deep knowledge of PowerToys codebase patterns and conventions +- Skilled at writing tests, handling edge cases, and validating builds +- You follow plans precisely while handling ambiguity gracefully + +## Goal + +For the given **issue_number**, execute the implementation plan and produce: +1. Working code changes applied directly to the repository +2. `Generated Files/issueFix/{{issue_number}}/pr-description.md` — PR-ready description +3. `Generated Files/issueFix/{{issue_number}}/manual-steps.md` — Only if human action needed + +## Core Directive + +**Follow the implementation plan in `Generated Files/issueReview/{{issue_number}}/implementation-plan.md` as the single source of truth.** + +If the plan doesn't exist, invoke PlanIssue agent first via `runSubagent`. + +## Working Principles + +- **Plan First**: Read and understand the entire implementation plan before coding +- **Validate Always**: For each change: Edit → Build → Verify → Commit. Never proceed if build fails. +- **Atomic Commits**: Each commit must be self-contained, buildable, and meaningful +- **Ask, Don't Guess**: When uncertain, insert `// TODO(Human input needed): ` and document in manual-steps.md + +## Strategy + +**Core Loop** — For every unit of work: +1. **Edit**: Make focused changes to implement one logical piece +2. **Build**: Run `tools\build\build.cmd` and check for exit code 0 +3. **Verify**: Use `problems` tool for lint/compile errors; run relevant tests +4. **Commit**: Only after build passes — use `.github/prompts/create-commit-title.prompt.md` + +Never skip steps. Never commit broken code. Never proceed if build fails. + +**Feature-by-Feature E2E**: For big scenarios with multiple features, complete each feature end-to-end before moving to the next: +- Settings UI → Functionality → Logging → Tests (for Feature 1) +- Then repeat for Feature 2 +- Benefits: Each feature is self-contained, testable, easier to review, can ship incrementally + +**Large Changes** (3+ files or cross-module): +- Use `tools\build\New-WorktreeFromBranch.ps1` for isolated worktrees +- Create separate branches per feature (e.g., `issue/{{issue_number}}-export`, `issue/{{issue_number}}-import`) +- Merge feature branches back after each is validated + +**Recovery**: If implementation goes wrong: +- Create a checkpoint branch before risky changes +- On failure: branch from last known-good state, cherry-pick working changes, abandon broken branch +- For complex changes, consider multiple smaller PRs + +## Guidelines + +**DO**: +- Follow the plan exactly +- Validate build before every commit — **NEVER commit broken code** +- Use `.github/prompts/create-commit-title.prompt.md` for commit messages +- Add comprehensive tests for changed behavior +- Use worktrees for large changes (3+ files or cross-module) +- Document deviations from plan + +**DON'T**: +- Implement everything in a single massive commit +- Continue after a failed build without fixing +- Make drive-by refactors outside issue scope +- Skip tests for behavioral changes +- Add noisy logs in hot paths +- Break IPC/JSON contracts without updating both sides +- Introduce dependencies without documenting in NOTICE.md + +## References + +- [Build Guidelines](../../tools/build/BUILD-GUIDELINES.md) — Build commands and validation +- [Coding Style](../../doc/devdocs/development/style.md) — Formatting and conventions +- [AGENTS.md](../../AGENTS.md) — Full contributor guide + +## Parameter + +- **issue_number**: Extract from `#123`, `issue 123`, or plain number. If missing, ask user. diff --git a/.github/agents/PlanIssue.agent.md b/.github/agents/PlanIssue.agent.md new file mode 100644 index 0000000000..0c9e61cb9b --- /dev/null +++ b/.github/agents/PlanIssue.agent.md @@ -0,0 +1,65 @@ +--- +description: 'Analyzes GitHub issues to produce overview and implementation plans' +name: 'PlanIssue' +tools: ['execute', 'read', 'edit', 'search', 'web', 'github/*', 'agent', 'github-artifacts/*', 'todo'] +argument-hint: 'GitHub issue number (e.g., #12345)' +handoffs: + - label: Start Implementation + agent: FixIssue + prompt: 'Fix issue #{{issue_number}} using the implementation plan' + - label: Open Plan in Editor + agent: agent + prompt: 'Open Generated Files/issueReview/{{issue_number}}/overview.md and implementation-plan.md' + showContinueOn: false + send: true +infer: true +--- + +# PlanIssue Agent + +You are a **PLANNING AGENT** specialized in analyzing GitHub issues and producing comprehensive planning documentation. + +## Identity & Expertise + +- Expert at issue triage, priority scoring, and technical analysis +- Deep knowledge of PowerToys architecture and codebase patterns +- Skilled at breaking down problems into actionable implementation steps +- You research thoroughly before planning, gathering 80% confidence before drafting + +## Goal + +For the given **issue_number**, produce two deliverables: +1. `Generated Files/issueReview/{{issue_number}}/overview.md` — Issue analysis with scoring +2. `Generated Files/issueReview/{{issue_number}}/implementation-plan.md` — Technical implementation plan +Above is the core interaction with the end user. If you cannot produce the files above, you fail the task. Each time, you must check whether the files exist or have been modified by the end user, without assuming you know their contents. +3. `Generated Files/issueReview/{{issue_number}}/logs/**` — logs for your diagnostic of root cause, research steps, and reasoning + +## Core Directive + +**Follow the template in `.github/prompts/review-issue.prompt.md` exactly.** Read it first, then apply every section as specified. + +- Fetch issue details: reactions, comments, linked PRs, images, logs +- Search related code and similar past fixes +- Ask clarifying questions when ambiguous +- Identify subject matter experts via git history + + +You are a PLANNING agent, NOT an implementation agent. + +STOP if you catch yourself: +- Writing code or editing source files outside `Generated Files/issueReview/` +- Making assumptions without researching +- Skipping the scoring/assessment phase + +Plans describe what the USER or FixIssue agent will execute later. + + +## References + +- [Review Issue Prompt](../.github/prompts/review-issue.prompt.md) — Template for plan structure +- [Architecture Overview](../../doc/devdocs/core/architecture.md) — System design context +- [AGENTS.md](../../AGENTS.md) — Full contributor guide + +## Parameter + +- **issue_number**: Extract from `#123`, `issue 123`, or plain number. If missing, ask user. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..c87bd62b01 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,36 @@ +--- +description: 'PowerToys AI contributor guidance' +--- + +# PowerToys – Copilot Instructions + +Concise guidance for AI contributions. For complete details, see [AGENTS.md](../AGENTS.md). + +## Key Rules + +- Atomic PRs: one logical change, no drive-by refactors +- Add tests when changing behavior +- Keep hot paths quiet (no logging in hooks/tight loops) + +## Style Enforcement + +- C#: `src/.editorconfig`, StyleCop.Analyzers +- C++: `src/.clang-format` +- XAML: XamlStyler + +## When to Ask for Clarification + +- Ambiguous spec after scanning docs +- Cross-module impact unclear +- Security, elevation, or installer changes + +## Component-Specific Instructions + +These are auto-applied based on file location: +- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md) +- [Common Libraries](.github/instructions/common-libraries.instructions.md) + +## Detailed Documentation + +- [Architecture](../doc/devdocs/core/architecture.md) +- [Coding Style](../doc/devdocs/development/style.md) diff --git a/.github/instructions/agent-skills.instructions.md b/.github/instructions/agent-skills.instructions.md new file mode 100644 index 0000000000..7ac55980bf --- /dev/null +++ b/.github/instructions/agent-skills.instructions.md @@ -0,0 +1,261 @@ +--- +description: 'Guidelines for creating high-quality Agent Skills for GitHub Copilot' +applyTo: '**/.github/skills/**/SKILL.md, **/.claude/skills/**/SKILL.md' +--- + +# Agent Skills File Guidelines + +Instructions for creating effective and portable Agent Skills that enhance GitHub Copilot with specialized capabilities, workflows, and bundled resources. + +## What Are Agent Skills? + +Agent Skills are self-contained folders with instructions and bundled resources that teach AI agents specialized capabilities. Unlike custom instructions (which define coding standards), skills enable task-specific workflows that can include scripts, examples, templates, and reference data. + +Key characteristics: +- **Portable**: Works across VS Code, Copilot CLI, and Copilot coding agent +- **Progressive loading**: Only loaded when relevant to the user's request +- **Resource-bundled**: Can include scripts, templates, examples alongside instructions +- **On-demand**: Activated automatically based on prompt relevance + +## Directory Structure + +Skills are stored in specific locations: + +| Location | Scope | Recommendation | +|----------|-------|----------------| +| `.github/skills//` | Project/repository | Recommended for project skills | +| `.claude/skills//` | Project/repository | Legacy, for backward compatibility | +| `~/.github/skills//` | Personal (user-wide) | Recommended for personal skills | +| `~/.claude/skills//` | Personal (user-wide) | Legacy, for backward compatibility | + +Each skill **must** have its own subdirectory containing at minimum a `SKILL.md` file. + +## Required SKILL.md Format + +### Frontmatter (Required) + +```yaml +--- +name: webapp-testing +description: Toolkit for testing local web applications using Playwright. Use when asked to verify frontend functionality, debug UI behavior, capture browser screenshots, check for visual regressions, or view browser console logs. Supports Chrome, Firefox, and WebKit browsers. +license: Complete terms in LICENSE.txt +--- +``` + +| Field | Required | Constraints | +|-------|----------|-------------| +| `name` | Yes | Lowercase, hyphens for spaces, max 64 characters (e.g., `webapp-testing`) | +| `description` | Yes | Clear description of capabilities AND use cases, max 1024 characters | +| `license` | No | Reference to LICENSE.txt (e.g., `Complete terms in LICENSE.txt`) or SPDX identifier | + +### Description Best Practices + +**CRITICAL**: The `description` field is the PRIMARY mechanism for automatic skill discovery. Copilot reads ONLY the `name` and `description` to decide whether to load a skill. If your description is vague, the skill will never be activated. + +**What to include in description:** +1. **WHAT** the skill does (capabilities) +2. **WHEN** to use it (specific triggers, scenarios, file types, or user requests) +3. **Keywords** that users might mention in their prompts + +**Good description:** +```yaml +description: Toolkit for testing local web applications using Playwright. Use when asked to verify frontend functionality, debug UI behavior, capture browser screenshots, check for visual regressions, or view browser console logs. Supports Chrome, Firefox, and WebKit browsers. +``` + +**Poor description:** +```yaml +description: Web testing helpers +``` + +The poor description fails because: +- No specific triggers (when should Copilot load this?) +- No keywords (what user prompts would match?) +- No capabilities (what can it actually do?) + +### Body Content + +The body contains detailed instructions that Copilot loads AFTER the skill is activated. Recommended sections: + +| Section | Purpose | +|---------|---------| +| `# Title` | Brief overview of what this skill enables | +| `## When to Use This Skill` | List of scenarios (reinforces description triggers) | +| `## Prerequisites` | Required tools, dependencies, environment setup | +| `## Step-by-Step Workflows` | Numbered steps for common tasks | +| `## Troubleshooting` | Common issues and solutions table | +| `## References` | Links to bundled docs or external resources | + +## Bundling Resources + +Skills can include additional files that Copilot accesses on-demand: + +### Supported Resource Types + +| Folder | Purpose | Loaded into Context? | Example Files | +|--------|---------|---------------------|---------------| +| `scripts/` | Executable automation that performs specific operations | When executed | `helper.py`, `validate.sh`, `build.ts` | +| `references/` | Documentation the AI agent reads to inform decisions | Yes, when referenced | `api_reference.md`, `schema.md`, `workflow_guide.md` | +| `assets/` | **Static files used AS-IS** in output (not modified by the AI agent) | No | `logo.png`, `brand-template.pptx`, `custom-font.ttf` | +| `templates/` | **Starter code/scaffolds that the AI agent MODIFIES** and builds upon | Yes, when referenced | `viewer.html` (insert algorithm), `hello-world/` (extend) | + +### Directory Structure Example + +``` +.github/skills/my-skill/ +├── SKILL.md # Required: Main instructions +├── LICENSE.txt # Recommended: License terms (Apache 2.0 typical) +├── scripts/ # Optional: Executable automation +│ ├── helper.py # Python script +│ └── helper.ps1 # PowerShell script +├── references/ # Optional: Documentation loaded into context +│ ├── api_reference.md +│ ├── step1-setup.md # Detailed workflow (>3 steps) +│ └── step2-deployment.md +├── assets/ # Optional: Static files used AS-IS in output +│ ├── baseline.png # Reference image for comparison +│ └── report-template.html +└── templates/ # Optional: Starter code the AI agent modifies + ├── scaffold.py # Code scaffold the AI agent customizes + └── config.template # Config template the AI agent fills in +``` + +> **LICENSE.txt**: When creating a skill, download the Apache 2.0 license text from https://www.apache.org/licenses/LICENSE-2.0.txt and save as `LICENSE.txt`. Update the copyright year and owner in the appendix section. + +### Assets vs Templates: Key Distinction + +**Assets** are static resources **consumed unchanged** in the output: +- A `logo.png` that gets embedded into a generated document +- A `report-template.html` copied as output format +- A `custom-font.ttf` applied to text rendering + +**Templates** are starter code/scaffolds that **the AI agent actively modifies**: +- A `scaffold.py` where the AI agent inserts logic +- A `config.template` where the AI agent fills in values based on user requirements +- A `hello-world/` project directory that the AI agent extends with new features + +**Rule of thumb**: If the AI agent reads and builds upon the file content → `templates/`. If the file is used as-is in output → `assets/`. + +### Referencing Resources in SKILL.md + +Use relative paths to reference files within the skill directory: + +```markdown +## Available Scripts + +Run the [helper script](./scripts/helper.py) to automate common tasks. + +See [API reference](./references/api_reference.md) for detailed documentation. + +Use the [scaffold](./templates/scaffold.py) as a starting point. +``` + +## Progressive Loading Architecture + +Skills use three-level loading for efficiency: + +| Level | What Loads | When | +|-------|------------|------| +| 1. Discovery | `name` and `description` only | Always (lightweight metadata) | +| 2. Instructions | Full `SKILL.md` body | When request matches description | +| 3. Resources | Scripts, examples, docs | Only when Copilot references them | + +This means: +- Install many skills without consuming context +- Only relevant content loads per task +- Resources don't load until explicitly needed + +## Content Guidelines + +### Writing Style + +- Use imperative mood: "Run", "Create", "Configure" (not "You should run") +- Be specific and actionable +- Include exact commands with parameters +- Show expected outputs where helpful +- Keep sections focused and scannable + +### Script Requirements + +When including scripts, prefer cross-platform languages: + +| Language | Use Case | +|----------|----------| +| Python | Complex automation, data processing | +| pwsh | PowerShell Core scripting | +| Node.js | JavaScript-based tooling | +| Bash/Shell | Simple automation tasks | + +Best practices: +- Include help/usage documentation (`--help` flag) +- Handle errors gracefully with clear messages +- Avoid storing credentials or secrets +- Use relative paths where possible + +### When to Bundle Scripts + +Include scripts in your skill when: +- The same code would be rewritten repeatedly by the agent +- Deterministic reliability is critical (e.g., file manipulation, API calls) +- Complex logic benefits from being pre-tested rather than generated each time +- The operation has a self-contained purpose that can evolve independently +- Testability matters — scripts can be unit tested and validated +- Predictable behavior is preferred over dynamic generation + +Scripts enable evolution: even simple operations benefit from being implemented as scripts when they may grow in complexity, need consistent behavior across invocations, or require future extensibility. + +### Security Considerations + +- Scripts rely on existing credential helpers (no credential storage) +- Include `--force` flags only for destructive operations +- Warn users before irreversible actions +- Document any network operations or external calls + +## Common Patterns + +### Parameter Table Pattern + +Document parameters clearly: + +```markdown +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `--input` | Yes | - | Input file or URL to process | +| `--action` | Yes | - | Action to perform | +| `--verbose` | No | `false` | Enable verbose output | +``` + +## Validation Checklist + +Before publishing a skill: + +- [ ] `SKILL.md` has valid frontmatter with `name` and `description` +- [ ] `name` is lowercase with hyphens, ≤64 characters +- [ ] `description` clearly states **WHAT** it does, **WHEN** to use it, and relevant **KEYWORDS** +- [ ] Body includes when to use, prerequisites, and step-by-step workflows +- [ ] SKILL.md body kept under 500 lines (split large content into `references/` folder) +- [ ] Large workflows (>5 steps) split into `references/` folder with clear links from SKILL.md +- [ ] Scripts include help documentation and error handling +- [ ] Relative paths used for all resource references +- [ ] No hardcoded credentials or secrets + +## Workflow Execution Pattern + +When executing multi-step workflows, create a TODO list where each step references the relevant documentation: + +```markdown +## TODO +- [ ] Step 1: Configure environment - see [workflow-setup.md](./references/workflow-setup.md#environment) +- [ ] Step 2: Build project - see [workflow-setup.md](./references/workflow-setup.md#build) +- [ ] Step 3: Deploy to staging - see [workflow-deployment.md](./references/workflow-deployment.md#staging) +- [ ] Step 4: Run validation - see [workflow-deployment.md](./references/workflow-deployment.md#validation) +- [ ] Step 5: Deploy to production - see [workflow-deployment.md](./references/workflow-deployment.md#production) +``` + +This ensures traceability and allows resuming workflows if interrupted. + +## Related Resources + +- [Agent Skills Specification](https://agentskills.io/) +- [VS Code Agent Skills Documentation](https://code.visualstudio.com/docs/copilot/customization/agent-skills) +- [Reference Skills Repository](https://github.com/anthropics/skills) +- [Awesome Copilot Skills](https://github.com/github/awesome-copilot/blob/main/docs/README.skills.md) diff --git a/.github/instructions/agents.instructions.md b/.github/instructions/agents.instructions.md new file mode 100644 index 0000000000..8d602c8816 --- /dev/null +++ b/.github/instructions/agents.instructions.md @@ -0,0 +1,791 @@ +--- +description: 'Guidelines for creating custom agent files for GitHub Copilot' +applyTo: '**/*.agent.md' +--- + +# Custom Agent File Guidelines + +Instructions for creating effective and maintainable custom agent files that provide specialized expertise for specific development tasks in GitHub Copilot. + +## Project Context + +- Target audience: Developers creating custom agents for GitHub Copilot +- File format: Markdown with YAML frontmatter +- File naming convention: lowercase with hyphens (e.g., `test-specialist.agent.md`) +- Location: `.github/agents/` directory (repository-level) or `agents/` directory (organization/enterprise-level) +- Purpose: Define specialized agents with tailored expertise, tools, and instructions for specific tasks +- Official documentation: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-custom-agents + +## Required Frontmatter + +Every agent file must include YAML frontmatter with the following fields: + +```yaml +--- +description: 'Brief description of the agent purpose and capabilities' +name: 'Agent Display Name' +tools: ['read', 'edit', 'search'] +model: 'Claude Sonnet 4.5' +target: 'vscode' +infer: true +--- +``` + +### Core Frontmatter Properties + +#### **description** (REQUIRED) +- Single-quoted string, clearly stating the agent's purpose and domain expertise +- Should be concise (50-150 characters) and actionable +- Example: `'Focuses on test coverage, quality, and testing best practices'` + +#### **name** (OPTIONAL) +- Display name for the agent in the UI +- If omitted, defaults to filename (without `.md` or `.agent.md`) +- Use title case and be descriptive +- Example: `'Testing Specialist'` + +#### **tools** (OPTIONAL) +- List of tool names or aliases the agent can use +- Supports comma-separated string or YAML array format +- If omitted, agent has access to all available tools +- See "Tool Configuration" section below for details + +#### **model** (STRONGLY RECOMMENDED) +- Specifies which AI model the agent should use +- Supported in VS Code, JetBrains IDEs, Eclipse, and Xcode +- Example: `'Claude Sonnet 4.5'`, `'gpt-4'`, `'gpt-4o'` +- Choose based on agent complexity and required capabilities + +#### **target** (OPTIONAL) +- Specifies target environment: `'vscode'` or `'github-copilot'` +- If omitted, agent is available in both environments +- Use when agent has environment-specific features + +#### **infer** (OPTIONAL) +- Boolean controlling whether Copilot can automatically use this agent based on context +- Default: `true` if omitted +- Set to `false` to require manual agent selection + +#### **metadata** (OPTIONAL, GitHub.com only) +- Object with name-value pairs for agent annotation +- Example: `metadata: { category: 'testing', version: '1.0' }` +- Not supported in VS Code + +#### **mcp-servers** (OPTIONAL, Organization/Enterprise only) +- Configure MCP servers available only to this agent +- Only supported for organization/enterprise level agents +- See "MCP Server Configuration" section below + +## Tool Configuration + +### Tool Specification Strategies + +**Enable all tools** (default): +```yaml +# Omit tools property entirely, or use: +tools: ['*'] +``` + +**Enable specific tools**: +```yaml +tools: ['read', 'edit', 'search', 'execute'] +``` + +**Enable MCP server tools**: +```yaml +tools: ['read', 'edit', 'github/*', 'playwright/navigate'] +``` + +**Disable all tools**: +```yaml +tools: [] +``` + +### Standard Tool Aliases + +All aliases are case-insensitive: + +| Alias | Alternative Names | Category | Description | +|-------|------------------|----------|-------------| +| `execute` | shell, Bash, powershell | Shell execution | Execute commands in appropriate shell | +| `read` | Read, NotebookRead, view | File reading | Read file contents | +| `edit` | Edit, MultiEdit, Write, NotebookEdit | File editing | Edit and modify files | +| `search` | Grep, Glob, search | Code search | Search for files or text in files | +| `agent` | custom-agent, Task | Agent invocation | Invoke other custom agents | +| `web` | WebSearch, WebFetch | Web access | Fetch web content and search | +| `todo` | TodoWrite | Task management | Create and manage task lists (VS Code only) | + +### Built-in MCP Server Tools + +**GitHub MCP Server**: +```yaml +tools: ['github/*'] # All GitHub tools +tools: ['github/get_file_contents', 'github/search_repositories'] # Specific tools +``` +- All read-only tools available by default +- Token scoped to source repository + +**Playwright MCP Server**: +```yaml +tools: ['playwright/*'] # All Playwright tools +tools: ['playwright/navigate', 'playwright/screenshot'] # Specific tools +``` +- Configured to access localhost only +- Useful for browser automation and testing + +### Tool Selection Best Practices + +- **Principle of Least Privilege**: Only enable tools necessary for the agent's purpose +- **Security**: Limit `execute` access unless explicitly required +- **Focus**: Fewer tools = clearer agent purpose and better performance +- **Documentation**: Comment why specific tools are required for complex configurations + +## Sub-Agent Invocation (Agent Orchestration) + +Agents can invoke other agents using `runSubagent` to orchestrate multi-step workflows. + +### How It Works + +Include `agent` in tools list to enable sub-agent invocation: + +```yaml +tools: ['read', 'edit', 'search', 'agent'] +``` + +Then invoke other agents with `runSubagent`: + +```javascript +const result = await runSubagent({ + description: 'What this step does', + prompt: `You are the [Specialist] specialist. + +Context: +- Parameter: ${parameterValue} +- Input: ${inputPath} +- Output: ${outputPath} + +Task: +1. Do the specific work +2. Write results to output location +3. Return summary of completion` +}); +``` + +### Basic Pattern + +Structure each sub-agent call with: + +1. **description**: Clear one-line purpose of the sub-agent invocation +2. **prompt**: Detailed instructions with substituted variables + +The prompt should include: +- Who the sub-agent is (specialist role) +- What context it needs (parameters, paths) +- What to do (concrete tasks) +- Where to write output +- What to return (summary) + +### Example: Multi-Step Processing + +```javascript +// Step 1: Process data +const processing = await runSubagent({ + description: 'Transform raw input data', + prompt: `You are the Data Processor specialist. + +Project: ${projectName} +Input: ${basePath}/raw/ +Output: ${basePath}/processed/ + +Task: +1. Read all files from input directory +2. Apply transformations +3. Write processed files to output +4. Create summary: ${basePath}/processed/summary.md + +Return: Number of files processed and any issues found` +}); + +// Step 2: Analyze (depends on Step 1) +const analysis = await runSubagent({ + description: 'Analyze processed data', + prompt: `You are the Data Analyst specialist. + +Project: ${projectName} +Input: ${basePath}/processed/ +Output: ${basePath}/analysis/ + +Task: +1. Read processed files from input +2. Generate analysis report +3. Write to: ${basePath}/analysis/report.md + +Return: Key findings and identified patterns` +}); +``` + +### Key Points + +- **Pass variables in prompts**: Use `${variableName}` for all dynamic values +- **Keep prompts focused**: Clear, specific tasks for each sub-agent +- **Return summaries**: Each sub-agent should report what it accomplished +- **Sequential execution**: Use `await` to maintain order when steps depend on each other +- **Error handling**: Check results before proceeding to dependent steps + +### ⚠️ Tool Availability Requirement + +**Critical**: If a sub-agent requires specific tools (e.g., `edit`, `execute`, `search`), the orchestrator must include those tools in its own `tools` list. Sub-agents cannot access tools that aren't available to their parent orchestrator. + +**Example**: +```yaml +# If your sub-agents need to edit files, execute commands, or search code +tools: ['read', 'edit', 'search', 'execute', 'agent'] +``` + +The orchestrator's tool permissions act as a ceiling for all invoked sub-agents. Plan your tool list carefully to ensure all sub-agents have the tools they need. + +### ⚠️ Important Limitation + +**Sub-agent orchestration is NOT suitable for large-scale data processing.** Avoid using `runSubagent` when: +- Processing hundreds or thousands of files +- Handling large datasets +- Performing bulk transformations on big codebases +- Orchestrating more than 5-10 sequential steps + +Each sub-agent call adds latency and context overhead. For high-volume processing, implement logic directly in a single agent instead. Use orchestration only for coordinating specialized tasks on focused, manageable datasets. + +## Agent Prompt Structure + +The markdown content below the frontmatter defines the agent's behavior, expertise, and instructions. Well-structured prompts typically include: + +1. **Agent Identity and Role**: Who the agent is and its primary role +2. **Core Responsibilities**: What specific tasks the agent performs +3. **Approach and Methodology**: How the agent works to accomplish tasks +4. **Guidelines and Constraints**: What to do/avoid and quality standards +5. **Output Expectations**: Expected output format and quality + +### Prompt Writing Best Practices + +- **Be Specific and Direct**: Use imperative mood ("Analyze", "Generate"); avoid vague terms +- **Define Boundaries**: Clearly state scope limits and constraints +- **Include Context**: Explain domain expertise and reference relevant frameworks +- **Focus on Behavior**: Describe how the agent should think and work +- **Use Structured Format**: Headers, bullets, and lists make prompts scannable + +## Variable Definition and Extraction + +Agents can define dynamic parameters to extract values from user input and use them throughout the agent's behavior and sub-agent communications. This enables flexible, context-aware agents that adapt to user-provided data. + +### When to Use Variables + +**Use variables when**: +- Agent behavior depends on user input +- Need to pass dynamic values to sub-agents +- Want to make agents reusable across different contexts +- Require parameterized workflows +- Need to track or reference user-provided context + +**Examples**: +- Extract project name from user prompt +- Capture certification name for pipeline processing +- Identify file paths or directories +- Extract configuration options +- Parse feature names or module identifiers + +### Variable Declaration Pattern + +Define variables section early in the agent prompt to document expected parameters: + +```markdown +# Agent Name + +## Dynamic Parameters + +- **Parameter Name**: Description and usage +- **Another Parameter**: How it's extracted and used + +## Your Mission + +Process [PARAMETER_NAME] to accomplish [task]. +``` + +### Variable Extraction Methods + +#### 1. **Explicit User Input** +Ask the user to provide the variable if not detected in the prompt: + +```markdown +## Your Mission + +Process the project by analyzing your codebase. + +### Step 1: Identify Project +If no project name is provided, **ASK THE USER** for: +- Project name or identifier +- Base path or directory location +- Configuration type (if applicable) + +Use this information to contextualize all subsequent tasks. +``` + +#### 2. **Implicit Extraction from Prompt** +Automatically extract variables from the user's natural language input: + +```javascript +// Example: Extract certification name from user input +const userInput = "Process My Certification"; + +// Extract key information +const certificationName = extractCertificationName(userInput); +// Result: "My Certification" + +const basePath = `certifications/${certificationName}`; +// Result: "certifications/My Certification" +``` + +#### 3. **Contextual Variable Resolution** +Use file context or workspace information to derive variables: + +```markdown +## Variable Resolution Strategy + +1. **From User Prompt**: First, look for explicit mentions in user input +2. **From File Context**: Check current file name or path +3. **From Workspace**: Use workspace folder or active project +4. **From Settings**: Reference configuration files +5. **Ask User**: If all else fails, request missing information +``` + +### Using Variables in Agent Prompts + +#### Variable Substitution in Instructions + +Use template variables in agent prompts to make them dynamic: + +```markdown +# Agent Name + +## Dynamic Parameters +- **Project Name**: ${projectName} +- **Base Path**: ${basePath} +- **Output Directory**: ${outputDir} + +## Your Mission + +Process the **${projectName}** project located at `${basePath}`. + +## Process Steps + +1. Read input from: `${basePath}/input/` +2. Process files according to project configuration +3. Write results to: `${outputDir}/` +4. Generate summary report + +## Quality Standards + +- Maintain project-specific coding standards for **${projectName}** +- Follow directory structure: `${basePath}/[structure]` +``` + +#### Passing Variables to Sub-Agents + +When invoking a sub-agent, pass all context through template variables in the prompt: + +```javascript +// Extract and prepare variables +const basePath = `projects/${projectName}`; +const inputPath = `${basePath}/src/`; +const outputPath = `${basePath}/docs/`; + +// Pass to sub-agent with all variables substituted +const result = await runSubagent({ + description: 'Generate project documentation', + prompt: `You are the Documentation specialist. + +Project: ${projectName} +Input: ${inputPath} +Output: ${outputPath} + +Task: +1. Read source files from ${inputPath} +2. Generate comprehensive documentation +3. Write to ${outputPath}/index.md +4. Include code examples and usage guides + +Return: Summary of documentation generated (file count, word count)` +}); +``` + +The sub-agent receives all necessary context embedded in the prompt. Variables are resolved before sending the prompt, so the sub-agent works with concrete paths and values, not variable placeholders. + +### Real-World Example: Code Review Orchestrator + +Example of a simple orchestrator that validates code through multiple specialized agents: + +```javascript +async function reviewCodePipeline(repositoryName, prNumber) { + const basePath = `projects/${repositoryName}/pr-${prNumber}`; + + // Step 1: Security Review + const security = await runSubagent({ + description: 'Scan for security vulnerabilities', + prompt: `You are the Security Reviewer specialist. + +Repository: ${repositoryName} +PR: ${prNumber} +Code: ${basePath}/changes/ + +Task: +1. Scan code for OWASP Top 10 vulnerabilities +2. Check for injection attacks, auth flaws +3. Write findings to ${basePath}/security-review.md + +Return: List of critical, high, and medium issues found` + }); + + // Step 2: Test Coverage Check + const coverage = await runSubagent({ + description: 'Verify test coverage for changes', + prompt: `You are the Test Coverage specialist. + +Repository: ${repositoryName} +PR: ${prNumber} +Changes: ${basePath}/changes/ + +Task: +1. Analyze code coverage for modified files +2. Identify untested critical paths +3. Write report to ${basePath}/coverage-report.md + +Return: Current coverage percentage and gaps` + }); + + // Step 3: Aggregate Results + const finalReport = await runSubagent({ + description: 'Compile all review findings', + prompt: `You are the Review Aggregator specialist. + +Repository: ${repositoryName} +Reports: ${basePath}/*.md + +Task: +1. Read all review reports from ${basePath}/ +2. Synthesize findings into single report +3. Determine overall verdict (APPROVE/NEEDS_FIXES/BLOCK) +4. Write to ${basePath}/final-review.md + +Return: Final verdict and executive summary` + }); + + return finalReport; +} +``` + +This pattern applies to any orchestration scenario: extract variables, call sub-agents with clear context, await results. + + +### Variable Best Practices + +#### 1. **Clear Documentation** +Always document what variables are expected: + +```markdown +## Required Variables +- **projectName**: The name of the project (string, required) +- **basePath**: Root directory for project files (path, required) + +## Optional Variables +- **mode**: Processing mode - quick/standard/detailed (enum, default: standard) +- **outputFormat**: Output format - markdown/json/html (enum, default: markdown) + +## Derived Variables +- **outputDir**: Automatically set to ${basePath}/output +- **logFile**: Automatically set to ${basePath}/.log.md +``` + +#### 2. **Consistent Naming** +Use consistent variable naming conventions: + +```javascript +// Good: Clear, descriptive naming +const variables = { + projectName, // What project to work on + basePath, // Where project files are located + outputDirectory, // Where to save results + processingMode, // How to process (detail level) + configurationPath // Where config files are +}; + +// Avoid: Ambiguous or inconsistent +const bad_variables = { + name, // Too generic + path, // Unclear which path + mode, // Too short + config // Too vague +}; +``` + +#### 3. **Validation and Constraints** +Document valid values and constraints: + +```markdown +## Variable Constraints + +**projectName**: +- Type: string (alphanumeric, hyphens, underscores allowed) +- Length: 1-100 characters +- Required: yes +- Pattern: `/^[a-zA-Z0-9_-]+$/` + +**processingMode**: +- Type: enum +- Valid values: "quick" (< 5min), "standard" (5-15min), "detailed" (15+ min) +- Default: "standard" +- Required: no +``` + +## MCP Server Configuration (Organization/Enterprise Only) + +MCP servers extend agent capabilities with additional tools. Only supported for organization and enterprise-level agents. + +### Configuration Format + +```yaml +--- +name: my-custom-agent +description: 'Agent with MCP integration' +tools: ['read', 'edit', 'custom-mcp/tool-1'] +mcp-servers: + custom-mcp: + type: 'local' + command: 'some-command' + args: ['--arg1', '--arg2'] + tools: ["*"] + env: + ENV_VAR_NAME: ${{ secrets.API_KEY }} +--- +``` + +### MCP Server Properties + +- **type**: Server type (`'local'` or `'stdio'`) +- **command**: Command to start the MCP server +- **args**: Array of command arguments +- **tools**: Tools to enable from this server (`["*"]` for all) +- **env**: Environment variables (supports secrets) + +### Environment Variables and Secrets + +Secrets must be configured in repository settings under "copilot" environment. + +**Supported syntax**: +```yaml +env: + # Environment variable only + VAR_NAME: COPILOT_MCP_ENV_VAR_VALUE + + # Variable with header + VAR_NAME: $COPILOT_MCP_ENV_VAR_VALUE + VAR_NAME: ${COPILOT_MCP_ENV_VAR_VALUE} + + # GitHub Actions-style (YAML only) + VAR_NAME: ${{ secrets.COPILOT_MCP_ENV_VAR_VALUE }} + VAR_NAME: ${{ var.COPILOT_MCP_ENV_VAR_VALUE }} +``` + +## File Organization and Naming + +### Repository-Level Agents +- Location: `.github/agents/` +- Scope: Available only in the specific repository +- Access: Uses repository-configured MCP servers + +### Organization/Enterprise-Level Agents +- Location: `.github-private/agents/` (then move to `agents/` root) +- Scope: Available across all repositories in org/enterprise +- Access: Can configure dedicated MCP servers + +### Naming Conventions +- Use lowercase with hyphens: `test-specialist.agent.md` +- Name should reflect agent purpose +- Filename becomes default agent name (if `name` not specified) +- Allowed characters: `.`, `-`, `_`, `a-z`, `A-Z`, `0-9` + +## Agent Processing and Behavior + +### Versioning +- Based on Git commit SHAs for the agent file +- Create branches/tags for different agent versions +- Instantiated using latest version for repository/branch +- PR interactions use same agent version for consistency + +### Name Conflicts +Priority (highest to lowest): +1. Repository-level agent +2. Organization-level agent +3. Enterprise-level agent + +Lower-level configurations override higher-level ones with the same name. + +### Tool Processing +- `tools` list filters available tools (built-in and MCP) +- No tools specified = all tools enabled +- Empty list (`[]`) = all tools disabled +- Specific list = only those tools enabled +- Unrecognized tool names are ignored (allows environment-specific tools) + +### MCP Server Processing Order +1. Out-of-the-box MCP servers (e.g., GitHub MCP) +2. Custom agent MCP configuration (org/enterprise only) +3. Repository-level MCP configurations + +Each level can override settings from previous levels. + +## Agent Creation Checklist + +### Frontmatter +- [ ] `description` field present and descriptive (50-150 chars) +- [ ] `description` wrapped in single quotes +- [ ] `name` specified (optional but recommended) +- [ ] `tools` configured appropriately (or intentionally omitted) +- [ ] `model` specified for optimal performance +- [ ] `target` set if environment-specific +- [ ] `infer` set to `false` if manual selection required + +### Prompt Content +- [ ] Clear agent identity and role defined +- [ ] Core responsibilities listed explicitly +- [ ] Approach and methodology explained +- [ ] Guidelines and constraints specified +- [ ] Output expectations documented +- [ ] Examples provided where helpful +- [ ] Instructions are specific and actionable +- [ ] Scope and boundaries clearly defined +- [ ] Total content under 30,000 characters + +### File Structure +- [ ] Filename follows lowercase-with-hyphens convention +- [ ] File placed in correct directory (`.github/agents/` or `agents/`) +- [ ] Filename uses only allowed characters +- [ ] File extension is `.agent.md` + +### Quality Assurance +- [ ] Agent purpose is unique and not duplicative +- [ ] Tools are minimal and necessary +- [ ] Instructions are clear and unambiguous +- [ ] Agent has been tested with representative tasks +- [ ] Documentation references are current +- [ ] Security considerations addressed (if applicable) + +## Common Agent Patterns + +### Testing Specialist +**Purpose**: Focus on test coverage and quality +**Tools**: All tools (for comprehensive test creation) +**Approach**: Analyze, identify gaps, write tests, avoid production code changes + +### Implementation Planner +**Purpose**: Create detailed technical plans and specifications +**Tools**: Limited to `['read', 'search', 'edit']` +**Approach**: Analyze requirements, create documentation, avoid implementation + +### Code Reviewer +**Purpose**: Review code quality and provide feedback +**Tools**: `['read', 'search']` only +**Approach**: Analyze, suggest improvements, no direct modifications + +### Refactoring Specialist +**Purpose**: Improve code structure and maintainability +**Tools**: `['read', 'search', 'edit']` +**Approach**: Analyze patterns, propose refactorings, implement safely + +### Security Auditor +**Purpose**: Identify security issues and vulnerabilities +**Tools**: `['read', 'search', 'web']` +**Approach**: Scan code, check against OWASP, report findings + +## Common Mistakes to Avoid + +### Frontmatter Errors +- ❌ Missing `description` field +- ❌ Description not wrapped in quotes +- ❌ Invalid tool names without checking documentation +- ❌ Incorrect YAML syntax (indentation, quotes) + +### Tool Configuration Issues +- ❌ Granting excessive tool access unnecessarily +- ❌ Missing required tools for agent's purpose +- ❌ Not using tool aliases consistently +- ❌ Forgetting MCP server namespace (`server-name/tool`) + +### Prompt Content Problems +- ❌ Vague, ambiguous instructions +- ❌ Conflicting or contradictory guidelines +- ❌ Lack of clear scope definition +- ❌ Missing output expectations +- ❌ Overly verbose instructions (exceeding character limits) +- ❌ No examples or context for complex tasks + +### Organizational Issues +- ❌ Filename doesn't reflect agent purpose +- ❌ Wrong directory (confusing repo vs org level) +- ❌ Using spaces or special characters in filename +- ❌ Duplicate agent names causing conflicts + +## Testing and Validation + +### Manual Testing +1. Create the agent file with proper frontmatter +2. Reload VS Code or refresh GitHub.com +3. Select the agent from the dropdown in Copilot Chat +4. Test with representative user queries +5. Verify tool access works as expected +6. Confirm output meets expectations + +### Integration Testing +- Test agent with different file types in scope +- Verify MCP server connectivity (if configured) +- Check agent behavior with missing context +- Test error handling and edge cases +- Validate agent switching and handoffs + +### Quality Checks +- Run through agent creation checklist +- Review against common mistakes list +- Compare with example agents in repository +- Get peer review for complex agents +- Document any special configuration needs + +## Additional Resources + +### Official Documentation +- [Creating Custom Agents](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-custom-agents) +- [Custom Agents Configuration](https://docs.github.com/en/copilot/reference/custom-agents-configuration) +- [Custom Agents in VS Code](https://code.visualstudio.com/docs/copilot/customization/custom-agents) +- [MCP Integration](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/extend-coding-agent-with-mcp) + +### Community Resources +- [Awesome Copilot Agents Collection](https://github.com/github/awesome-copilot/tree/main/agents) +- [Customization Library Examples](https://docs.github.com/en/copilot/tutorials/customization-library/custom-agents) +- [Your First Custom Agent Tutorial](https://docs.github.com/en/copilot/tutorials/customization-library/custom-agents/your-first-custom-agent) + +### Related Files +- [Prompt Files Guidelines](./prompt.instructions.md) - For creating prompt files +- [Instructions Guidelines](./instructions.instructions.md) - For creating instruction files + +## Version Compatibility Notes + +### GitHub.com (Coding Agent) +- ✅ Fully supports all standard frontmatter properties +- ✅ Repository and org/enterprise level agents +- ✅ MCP server configuration (org/enterprise) +- ❌ Does not support `model`, `argument-hint`, `handoffs` properties + +### VS Code / JetBrains / Eclipse / Xcode +- ✅ Supports `model` property for AI model selection +- ✅ Supports `argument-hint` and `handoffs` properties +- ✅ User profile and workspace-level agents +- ❌ Cannot configure MCP servers at repository level +- ⚠️ Some properties may behave differently + +When creating agents for multiple environments, focus on common properties and test in all target environments. Use `target` property to create environment-specific agents when necessary. diff --git a/.github/instructions/azure-devops-pipelines.instructions.md b/.github/instructions/azure-devops-pipelines.instructions.md new file mode 100644 index 0000000000..c98d8bb1d4 --- /dev/null +++ b/.github/instructions/azure-devops-pipelines.instructions.md @@ -0,0 +1,187 @@ +--- +description: 'Best practices for Azure DevOps Pipeline YAML files' +applyTo: '**/azure-pipelines.yml, **/azure-pipelines*.yml, **/*.pipeline.yml' +--- + +# Azure DevOps Pipeline YAML Best Practices + +Guidelines for creating maintainable, secure, and efficient Azure DevOps pipelines in PowerToys. + +## General Guidelines + +- Use YAML syntax consistently with proper indentation (2 spaces) +- Always include meaningful names and display names for pipelines, stages, jobs, and steps +- Implement proper error handling and conditional execution +- Use variables and parameters to make pipelines reusable and maintainable +- Follow the principle of least privilege for service connections and permissions +- Include comprehensive logging and diagnostics for troubleshooting + +## Pipeline Structure + +- Organize complex pipelines using stages for better visualization and control +- Use jobs to group related steps and enable parallel execution when possible +- Implement proper dependencies between stages and jobs +- Use templates for reusable pipeline components +- Keep pipeline files focused and modular - split large pipelines into multiple files + +## Build Best Practices + +- Use specific agent pool versions and VM images for consistency +- Cache dependencies (npm, NuGet, Maven, etc.) to improve build performance +- Implement proper artifact management with meaningful names and retention policies +- Use build variables for version numbers and build metadata +- Include code quality gates (lint checks, testing, security scans) +- Ensure builds are reproducible and environment-independent + +## Testing Integration + +- Run unit tests as part of the build process +- Publish test results in standard formats (JUnit, VSTest, etc.) +- Include code coverage reporting and quality gates +- Implement integration and end-to-end tests in appropriate stages +- Use test impact analysis when available to optimize test execution +- Fail fast on test failures to provide quick feedback + +## Security Considerations + +- Use Azure Key Vault for sensitive configuration and secrets +- Implement proper secret management with variable groups +- Use service connections with minimal required permissions +- Enable security scans (dependency vulnerabilities, static analysis) +- Implement approval gates for production deployments +- Use managed identities when possible instead of service principals + +## Deployment Strategies + +- Implement proper environment promotion (dev → staging → production) +- Use deployment jobs with proper environment targeting +- Implement blue-green or canary deployment strategies when appropriate +- Include rollback mechanisms and health checks +- Use infrastructure as code (ARM, Bicep, Terraform) for consistent deployments +- Implement proper configuration management per environment + +## Variable and Parameter Management + +- Use variable groups for shared configuration across pipelines +- Implement runtime parameters for flexible pipeline execution +- Use conditional variables based on branches or environments +- Secure sensitive variables and mark them as secrets +- Document variable purposes and expected values +- Use variable templates for complex variable logic + +## Performance Optimization + +- Use parallel jobs and matrix strategies when appropriate +- Implement proper caching strategies for dependencies and build outputs +- Use shallow clone for Git operations when full history isn't needed +- Optimize Docker image builds with multi-stage builds and layer caching +- Monitor pipeline performance and optimize bottlenecks +- Use pipeline resource triggers efficiently + +## Monitoring and Observability + +- Include comprehensive logging throughout the pipeline +- Use Azure Monitor and Application Insights for deployment tracking +- Implement proper notification strategies for failures and successes +- Include deployment health checks and automated rollback triggers +- Use pipeline analytics to identify improvement opportunities +- Document pipeline behavior and troubleshooting steps + +## Template and Reusability + +- Create pipeline templates for common patterns +- Use extends templates for complete pipeline inheritance +- Implement step templates for reusable task sequences +- Use variable templates for complex variable logic +- Version templates appropriately for stability +- Document template parameters and usage examples + +## Branch and Trigger Strategy + +- Implement appropriate triggers for different branch types +- Use path filters to trigger builds only when relevant files change +- Configure proper CI/CD triggers for main/master branches +- Use pull request triggers for code validation +- Implement scheduled triggers for maintenance tasks +- Consider resource triggers for multi-repository scenarios + +## Example Structure + +```yaml +# azure-pipelines.yml +trigger: + branches: + include: + - main + - develop + paths: + exclude: + - docs/* + - README.md + +variables: + - group: shared-variables + - name: buildConfiguration + value: 'Release' + +stages: + - stage: Build + displayName: 'Build and Test' + jobs: + - job: Build + displayName: 'Build Application' + pool: + vmImage: 'ubuntu-latest' + steps: + - task: UseDotNet@2 + displayName: 'Use .NET SDK' + inputs: + version: '8.x' + + - task: DotNetCoreCLI@2 + displayName: 'Restore dependencies' + inputs: + command: 'restore' + projects: '**/*.csproj' + + - task: DotNetCoreCLI@2 + displayName: 'Build application' + inputs: + command: 'build' + projects: '**/*.csproj' + arguments: '--configuration $(buildConfiguration) --no-restore' + + - stage: Deploy + displayName: 'Deploy to Staging' + dependsOn: Build + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + jobs: + - deployment: DeployToStaging + displayName: 'Deploy to Staging Environment' + environment: 'staging' + strategy: + runOnce: + deploy: + steps: + - download: current + displayName: 'Download drop artifact' + artifact: drop + - task: AzureWebApp@1 + displayName: 'Deploy to Azure Web App' + inputs: + azureSubscription: 'staging-service-connection' + appType: 'webApp' + appName: 'myapp-staging' + package: '$(Pipeline.Workspace)/drop/**/*.zip' +``` + +## Common Anti-Patterns to Avoid + +- Hardcoding sensitive values directly in YAML files +- Using overly broad triggers that cause unnecessary builds +- Mixing build and deployment logic in a single stage +- Not implementing proper error handling and cleanup +- Using deprecated task versions without upgrade plans +- Creating monolithic pipelines that are difficult to maintain +- Not using proper naming conventions for clarity +- Ignoring pipeline security best practices diff --git a/.github/instructions/common-libraries.instructions.md b/.github/instructions/common-libraries.instructions.md new file mode 100644 index 0000000000..b016032fb9 --- /dev/null +++ b/.github/instructions/common-libraries.instructions.md @@ -0,0 +1,61 @@ +--- +description: 'Guidelines for shared libraries including logging, IPC, settings, DPI, telemetry, and utilities consumed by multiple modules' +applyTo: 'src/common/**' +--- + +# Common Libraries – Shared Code Guidance + +Guidelines for modifying shared code in `src/common/`. Changes here can have wide-reaching impact across the entire PowerToys codebase. + +## Scope + +- Logging infrastructure (`src/common/logger/`) +- IPC primitives and named pipe utilities +- Settings serialization and management +- DPI awareness and scaling utilities +- Telemetry helpers +- General utilities (JSON parsing, string helpers, etc.) + +## Guidelines + +### API Stability + +- Avoid breaking public headers/APIs; if changed, search & update all callers +- Coordinate ABI-impacting struct/class layout changes; keep binary compatibility +- When modifying public interfaces, grep the entire codebase for usages + +### Performance + +- Watch perf in hot paths (hooks, timers, serialization) +- Avoid avoidable allocations in frequently called code +- Profile changes that touch performance-sensitive areas + +### Dependencies + +- Ask before adding third-party deps or changing serialization formats +- New dependencies must be MIT-licensed or approved by PM team +- Add any new external packages to `NOTICE.md` + +### Logging + +- C++ logging uses spdlog (`Logger::info`, `Logger::warn`, `Logger::error`, `Logger::debug`) +- Initialize with `init_logger()` early in startup +- Keep hot paths quiet – no logging in tight loops or hooks + +## Acceptance Criteria + +- No unintended ABI breaks +- No noisy logs in hot paths +- New non-obvious symbols briefly commented +- All callers updated when interfaces change + +## Code Style + +- **C++**: Follow `.clang-format` in `src/`; use Modern C++ patterns per C++ Core Guidelines +- **C#**: Follow `src/.editorconfig`; enforce StyleCop.Analyzers + +## Validation + +- Build: `tools\build\build.cmd` from `src/common/` folder +- Verify no ABI breaks: grep for changed function/struct names across codebase +- Check logs: ensure no new logging in performance-critical paths diff --git a/.github/instructions/instructions.instructions.md b/.github/instructions/instructions.instructions.md new file mode 100644 index 0000000000..9de95659ab --- /dev/null +++ b/.github/instructions/instructions.instructions.md @@ -0,0 +1,256 @@ +--- +description: 'Guidelines for creating high-quality custom instruction files for GitHub Copilot' +applyTo: '**/*.instructions.md' +--- + +# Custom Instructions File Guidelines + +Instructions for creating effective and maintainable custom instruction files that guide GitHub Copilot in generating domain-specific code and following project conventions. + +## Project Context + +- Target audience: Developers and GitHub Copilot working with domain-specific code +- File format: Markdown with YAML frontmatter +- File naming convention: lowercase with hyphens (e.g., `react-best-practices.instructions.md`) +- Location: `.github/instructions/` directory +- Purpose: Provide context-aware guidance for code generation, review, and documentation + +## Required Frontmatter + +Every instruction file must include YAML frontmatter with the following fields: + +```yaml +--- +description: 'Brief description of the instruction purpose and scope' +applyTo: 'glob pattern for target files (e.g., **/*.ts, **/*.py)' +--- +``` + +### Frontmatter Guidelines + +- **description**: Single-quoted string, 1-500 characters, clearly stating the purpose +- **applyTo**: Glob pattern(s) specifying which files these instructions apply to + - Single pattern: `'**/*.ts'` + - Multiple patterns: `'**/*.ts, **/*.tsx, **/*.js'` + - Specific files: `'src/**/*.py'` + - All files: `'**'` + +## File Structure + +A well-structured instruction file should include the following sections: + +### 1. Title and Overview + +- Clear, descriptive title using `#` heading +- Brief introduction explaining the purpose and scope +- Optional: Project context section with key technologies and versions + +### 2. Core Sections + +Organize content into logical sections based on the domain: + +- **General Instructions**: High-level guidelines and principles +- **Best Practices**: Recommended patterns and approaches +- **Code Standards**: Naming conventions, formatting, style rules +- **Architecture/Structure**: Project organization and design patterns +- **Common Patterns**: Frequently used implementations +- **Security**: Security considerations (if applicable) +- **Performance**: Optimization guidelines (if applicable) +- **Testing**: Testing standards and approaches (if applicable) + +### 3. Examples and Code Snippets + +Provide concrete examples with clear labels: + +```markdown +### Good Example +\`\`\`language +// Recommended approach +code example here +\`\`\` + +### Bad Example +\`\`\`language +// Avoid this pattern +code example here +\`\`\` +``` + +### 4. Validation and Verification (Optional but Recommended) + +- Build commands to verify code +- Lint checks and formatting tools +- Testing requirements +- Verification steps + +## Content Guidelines + +### Writing Style + +- Use clear, concise language +- Write in imperative mood ("Use", "Implement", "Avoid") +- Be specific and actionable +- Avoid ambiguous terms like "should", "might", "possibly" +- Use bullet points and lists for readability +- Keep sections focused and scannable + +### Best Practices + +- **Be Specific**: Provide concrete examples rather than abstract concepts +- **Show Why**: Explain the reasoning behind recommendations when it adds value +- **Use Tables**: For comparing options, listing rules, or showing patterns +- **Include Examples**: Real code snippets are more effective than descriptions +- **Stay Current**: Reference current versions and best practices +- **Link Resources**: Include official documentation and authoritative sources + +### Common Patterns to Include + +1. **Naming Conventions**: How to name variables, functions, classes, files +2. **Code Organization**: File structure, module organization, import order +3. **Error Handling**: Preferred error handling patterns +4. **Dependencies**: How to manage and document dependencies +5. **Comments and Documentation**: When and how to document code +6. **Version Information**: Target language/framework versions + +## Patterns to Follow + +### Bullet Points and Lists + +```markdown +## Security Best Practices + +- Always validate user input before processing +- Use parameterized queries to prevent SQL injection +- Store secrets in environment variables, never in code +- Implement proper authentication and authorization +- Enable HTTPS for all production endpoints +``` + +### Tables for Structured Information + +```markdown +## Common Issues + +| Issue | Solution | Example | +| ---------------- | ------------------- | ----------------------------- | +| Magic numbers | Use named constants | `const MAX_RETRIES = 3` | +| Deep nesting | Extract functions | Refactor nested if statements | +| Hardcoded values | Use configuration | Store API URLs in config | +``` + +### Code Comparison + +```markdown +### Good Example - Using TypeScript interfaces +\`\`\`typescript +interface User { + id: string; + name: string; + email: string; +} + +function getUser(id: string): User { + // Implementation +} +\`\`\` + +### Bad Example - Using any type +\`\`\`typescript +function getUser(id: any): any { + // Loses type safety +} +\`\`\` +``` + +### Conditional Guidance + +```markdown +## Framework Selection + +- **For small projects**: Use Minimal API approach +- **For large projects**: Use controller-based architecture with clear separation +- **For microservices**: Consider domain-driven design patterns +``` + +## Patterns to Avoid + +- **Overly verbose explanations**: Keep it concise and scannable +- **Outdated information**: Always reference current versions and practices +- **Ambiguous guidelines**: Be specific about what to do or avoid +- **Missing examples**: Abstract rules without concrete code examples +- **Contradictory advice**: Ensure consistency throughout the file +- **Copy-paste from documentation**: Add value by distilling and providing context + +## Testing Your Instructions + +Before finalizing instruction files: + +1. **Test with Copilot**: Try the instructions with actual prompts in VS Code +2. **Verify Examples**: Ensure code examples are correct and run without errors +3. **Check Glob Patterns**: Confirm `applyTo` patterns match intended files + +## Example Structure + +Here's a minimal example structure for a new instruction file: + +```markdown +--- +description: 'Brief description of purpose' +applyTo: '**/*.ext' +--- + +# Technology Name Development + +Brief introduction and context. + +## General Instructions + +- High-level guideline 1 +- High-level guideline 2 + +## Best Practices + +- Specific practice 1 +- Specific practice 2 + +## Code Standards + +### Naming Conventions +- Rule 1 +- Rule 2 + +### File Organization +- Structure 1 +- Structure 2 + +## Common Patterns + +### Pattern 1 +Description and example + +\`\`\`language +code example +\`\`\` + +### Pattern 2 +Description and example + +## Validation + +- Build command: `command to verify` +- Lint checks: `command to lint` +- Testing: `command to test` +``` + +## Maintenance + +- Review instructions when dependencies or frameworks are updated +- Update examples to reflect current best practices +- Remove outdated patterns or deprecated features +- Add new patterns as they emerge in the community +- Keep glob patterns accurate as project structure evolves + +## Additional Resources + +- [Custom Instructions Documentation](https://code.visualstudio.com/docs/copilot/customization/custom-instructions) +- [Awesome Copilot Instructions](https://github.com/github/awesome-copilot/tree/main/instructions) diff --git a/.github/instructions/prompt.instructions.md b/.github/instructions/prompt.instructions.md new file mode 100644 index 0000000000..a232edb97d --- /dev/null +++ b/.github/instructions/prompt.instructions.md @@ -0,0 +1,88 @@ +--- +description: 'Guidelines for creating high-quality prompt files for GitHub Copilot' +applyTo: '**/*.prompt.md' +--- + +# Copilot Prompt Files Guidelines + +Instructions for creating effective and maintainable prompt files that guide GitHub Copilot in delivering consistent, high-quality outcomes across any repository. + +## Scope and Principles +- Target audience: maintainers and contributors authoring reusable prompts for Copilot Chat. +- Goals: predictable behaviour, clear expectations, minimal permissions, and portability across repositories. +- Primary references: VS Code documentation on prompt files and organization-specific conventions. + +## Frontmatter Requirements + +Every prompt file should include YAML frontmatter with the following fields: + +### Required/Recommended Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `description` | Recommended | A short description of the prompt (single sentence, actionable outcome) | +| `name` | Optional | The name shown after typing `/` in chat. Defaults to filename if not specified | +| `agent` | Recommended | The agent to use: `ask`, `edit`, `agent`, or a custom agent name. Defaults to current agent | +| `model` | Optional | The language model to use. Defaults to currently selected model | +| `tools` | Optional | List of tool/tool set names available for this prompt | +| `argument-hint` | Optional | Hint text shown in chat input to guide user interaction | + +### Guidelines + +- Use consistent quoting (single quotes recommended) and keep one field per line for readability and version control clarity +- If `tools` are specified and current agent is `ask` or `edit`, the default agent becomes `agent` +- Preserve any additional metadata (`language`, `tags`, `visibility`, etc.) required by your organization + +## File Naming and Placement +- Use kebab-case filenames ending with `.prompt.md` and store them under `.github/prompts/` unless your workspace standard specifies another directory. +- Provide a short filename that communicates the action (for example, `generate-readme.prompt.md` rather than `prompt1.prompt.md`). + +## Body Structure +- Start with an `#` level heading that matches the prompt intent so it surfaces well in Quick Pick search. +- Organize content with predictable sections. Recommended baseline: `Mission` or `Primary Directive`, `Scope & Preconditions`, `Inputs`, `Workflow` (step-by-step), `Output Expectations`, and `Quality Assurance`. +- Adjust section names to fit the domain, but retain the logical flow: why → context → inputs → actions → outputs → validation. +- Reference related prompts or instruction files using relative links to aid discoverability. + +## Input and Context Handling +- Use `${input:variableName[:placeholder]}` for required values and explain when the user must supply them. Provide defaults or alternatives where possible. +- Call out contextual variables such as `${selection}`, `${file}`, `${workspaceFolder}` only when they are essential, and describe how Copilot should interpret them. +- Document how to proceed when mandatory context is missing (for example, “Request the file path and stop if it remains undefined”). + +## Tool and Permission Guidance +- Limit `tools` to the smallest set that enables the task. List them in the preferred execution order when the sequence matters. +- If the prompt inherits tools from a chat mode, mention that relationship and state any critical tool behaviours or side effects. +- Warn about destructive operations (file creation, edits, terminal commands) and include guard rails or confirmation steps in the workflow. + +## Instruction Tone and Style +- Write in direct, imperative sentences targeted at Copilot (for example, “Analyze”, “Generate”, “Summarize”). +- Keep sentences short and unambiguous, following Google Developer Documentation translation best practices to support localization. +- Avoid idioms, humor, or culturally specific references; favor neutral, inclusive language. + +## Output Definition +- Specify the format, structure, and location of expected results (for example, “Create an architecture decision record file using the template below, such as `docs/architecture-decisions/record-XXXX.md`). +- Include success criteria and failure triggers so Copilot knows when to halt or retry. +- Provide validation steps—manual checks, automated commands, or acceptance criteria lists—that reviewers can execute after running the prompt. + +## Examples and Reusable Assets +- Embed Good/Bad examples or scaffolds (Markdown templates, JSON stubs) that the prompt should produce or follow. +- Maintain reference tables (capabilities, status codes, role descriptions) inline to keep the prompt self-contained. Update these tables when upstream resources change. +- Link to authoritative documentation instead of duplicating lengthy guidance. + +## Quality Assurance Checklist +- [ ] Frontmatter fields are complete, accurate, and least-privilege. +- [ ] Inputs include placeholders, default behaviours, and fallbacks. +- [ ] Workflow covers preparation, execution, and post-processing without gaps. +- [ ] Output expectations include formatting and storage details. +- [ ] Validation steps are actionable (commands, diff checks, review prompts). +- [ ] Security, compliance, and privacy policies referenced by the prompt are current. +- [ ] Prompt executes successfully in VS Code (`Chat: Run Prompt`) using representative scenarios. + +## Maintenance Guidance +- Version-control prompts alongside the code they affect; update them when dependencies, tooling, or review processes change. +- Review prompts periodically to ensure tool lists, model requirements, and linked documents remain valid. +- Coordinate with other repositories: when a prompt proves broadly useful, extract common guidance into instruction files or shared prompt packs. + +## Additional Resources +- [Prompt Files Documentation](https://code.visualstudio.com/docs/copilot/customization/prompt-files#_prompt-file-format) +- [Awesome Copilot Prompt Files](https://github.com/github/awesome-copilot/tree/main/prompts) +- [Tool Configuration](https://code.visualstudio.com/docs/copilot/chat/chat-agent-mode#_agent-mode-tools) diff --git a/.github/instructions/runner-settings-ui.instructions.md b/.github/instructions/runner-settings-ui.instructions.md new file mode 100644 index 0000000000..ebc2510a37 --- /dev/null +++ b/.github/instructions/runner-settings-ui.instructions.md @@ -0,0 +1,68 @@ +--- +description: 'Guidelines for Runner and Settings UI components that communicate via named pipes and manage module lifecycle' +applyTo: 'src/runner/**,src/settings-ui/**' +--- + +# Runner & Settings UI – Core Components Guidance + +Guidelines for modifying the Runner (tray/module loader) and Settings UI (configuration app). These components communicate via Windows Named Pipes using JSON messages. + +## Runner (`src/runner/`) + +### Scope + +- Module bootstrap, hotkey management, settings bridge, update/elevation handling + +### Guidelines + +- If IPC/JSON contracts change, mirror updates in `src/settings-ui/**` +- Keep module discovery in `src/runner/main.cpp` in sync when adding/removing modules +- Keep startup lean: avoid blocking/network calls in early init path +- Preserve GPO & elevation behaviors; confirm no regression in policy handling +- Ask before modifying update workflow or elevation logic + +### Acceptance Criteria + +- Stable startup, consistent contracts, no unnecessary logging noise + +## Settings UI (`src/settings-ui/`) + +### Scope + +- WinUI/WPF UI, communicates with Runner over named pipes; manages persisted settings schema + +### Guidelines + +- Don't break settings schema silently; add migration when shape changes +- If IPC/JSON contracts change, align with `src/runner/**` implementation +- Keep UI responsive: marshal to UI thread for UI-bound operations +- Reuse existing styles/resources; avoid duplicate theme keys +- Add/adjust migration or serialization tests when changing persisted settings + +### Acceptance Criteria + +- Schema integrity preserved, responsive UI, consistent contracts, no style duplication + +## Shared Concerns + +### IPC Contract Changes + +When modifying the JSON message format between Runner and Settings UI: + +1. Update both `src/runner/` and `src/settings-ui/` in the same PR +2. Preserve backward compatibility where possible +3. Add migration logic for settings schema changes +4. Test both directions of communication + +### Code Style + +- **C++ (Runner)**: Follow `.clang-format` in `src/` +- **C# (Settings UI)**: Follow `src/.editorconfig`, use StyleCop.Analyzers +- **XAML**: Use XamlStyler or run `.\.pipelines\applyXamlStyling.ps1 -Main` + +## Validation + +- Build Runner: `tools\build\build.cmd` from `src/runner/` +- Build Settings UI: `tools\build\build.cmd` from `src/settings-ui/` +- Test IPC: Launch both Runner and Settings UI, verify communication works +- Schema changes: Run serialization tests if settings shape changed diff --git a/.github/instructions/typescript-mcp-server.instructions.md b/.github/instructions/typescript-mcp-server.instructions.md new file mode 100644 index 0000000000..97185e6c05 --- /dev/null +++ b/.github/instructions/typescript-mcp-server.instructions.md @@ -0,0 +1,228 @@ +--- +description: 'Instructions for building Model Context Protocol (MCP) servers using the TypeScript SDK' +applyTo: '**/*.ts, **/*.js, **/package.json' +--- + +# TypeScript MCP Server Development + +## Instructions + +- Use the **@modelcontextprotocol/sdk** npm package: `npm install @modelcontextprotocol/sdk` +- Import from specific paths: `@modelcontextprotocol/sdk/server/mcp.js`, `@modelcontextprotocol/sdk/server/stdio.js`, etc. +- Use `McpServer` class for high-level server implementation with automatic protocol handling +- Use `Server` class for low-level control with manual request handlers +- Use **zod** for input/output schema validation: `npm install zod@3` +- Always provide `title` field for tools, resources, and prompts for better UI display +- Use `registerTool()`, `registerResource()`, and `registerPrompt()` methods (recommended over older APIs) +- Define schemas using zod: `{ inputSchema: { param: z.string() }, outputSchema: { result: z.string() } }` +- Return both `content` (for display) and `structuredContent` (for structured data) from tools +- For HTTP servers, use `StreamableHTTPServerTransport` with Express or similar frameworks +- For local integrations, use `StdioServerTransport` for stdio-based communication +- Create new transport instances per request to prevent request ID collisions (stateless mode) +- Use session management with `sessionIdGenerator` for stateful servers +- Enable DNS rebinding protection for local servers: `enableDnsRebindingProtection: true` +- Configure CORS headers and expose `Mcp-Session-Id` for browser-based clients +- Use `ResourceTemplate` for dynamic resources with URI parameters: `new ResourceTemplate('resource://{param}', { list: undefined })` +- Support completions for better UX using `completable()` wrapper from `@modelcontextprotocol/sdk/server/completable.js` +- Implement sampling with `server.server.createMessage()` to request LLM completions from clients +- Use `server.server.elicitInput()` to request additional user input during tool execution +- Enable notification debouncing for bulk updates: `debouncedNotificationMethods: ['notifications/tools/list_changed']` +- Dynamic updates: call `.enable()`, `.disable()`, `.update()`, or `.remove()` on registered items to emit `listChanged` notifications +- Use `getDisplayName()` from `@modelcontextprotocol/sdk/shared/metadataUtils.js` for UI display names +- Test servers with MCP Inspector: `npx @modelcontextprotocol/inspector` + +## Best Practices + +- Keep tool implementations focused on single responsibilities +- Provide clear, descriptive titles and descriptions for LLM understanding +- Use proper TypeScript types for all parameters and return values +- Implement comprehensive error handling with try-catch blocks +- Return `isError: true` in tool results for error conditions +- Use async/await for all asynchronous operations +- Close database connections and clean up resources properly +- Validate input parameters before processing +- Use structured logging for debugging without polluting stdout/stderr +- Consider security implications when exposing file system or network access +- Implement proper resource cleanup on transport close events +- Use environment variables for configuration (ports, API keys, etc.) +- Document tool capabilities and limitations clearly +- Test with multiple clients to ensure compatibility + +## Common Patterns + +### Basic Server Setup (HTTP) +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; + +const server = new McpServer({ + name: 'my-server', + version: '1.0.0' +}); + +const app = express(); +app.use(express.json()); + +app.post('/mcp', async (req, res) => { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + + res.on('close', () => transport.close()); + + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}); + +app.listen(3000); +``` + +### Basic Server Setup (stdio) +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +const server = new McpServer({ + name: 'my-server', + version: '1.0.0' +}); + +// ... register tools, resources, prompts ... + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +### Simple Tool +```typescript +import { z } from 'zod'; + +server.registerTool( + 'calculate', + { + title: 'Calculator', + description: 'Perform basic calculations', + inputSchema: { a: z.number(), b: z.number(), op: z.enum(['+', '-', '*', '/']) }, + outputSchema: { result: z.number() } + }, + async ({ a, b, op }) => { + const result = op === '+' ? a + b : op === '-' ? a - b : + op === '*' ? a * b : a / b; + const output = { result }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } +); +``` + +### Dynamic Resource +```typescript +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; + +server.registerResource( + 'user', + new ResourceTemplate('users://{userId}', { list: undefined }), + { + title: 'User Profile', + description: 'Fetch user profile data' + }, + async (uri, { userId }) => ({ + contents: [{ + uri: uri.href, + text: `User ${userId} data here` + }] + }) +); +``` + +### Tool with Sampling +```typescript +server.registerTool( + 'summarize', + { + title: 'Text Summarizer', + description: 'Summarize text using LLM', + inputSchema: { text: z.string() }, + outputSchema: { summary: z.string() } + }, + async ({ text }) => { + const response = await server.server.createMessage({ + messages: [{ + role: 'user', + content: { type: 'text', text: `Summarize: ${text}` } + }], + maxTokens: 500 + }); + + const summary = response.content.type === 'text' ? + response.content.text : 'Unable to summarize'; + const output = { summary }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } +); +``` + +### Prompt with Completion +```typescript +import { completable } from '@modelcontextprotocol/sdk/server/completable.js'; + +server.registerPrompt( + 'review', + { + title: 'Code Review', + description: 'Review code with specific focus', + argsSchema: { + language: completable(z.string(), value => + ['typescript', 'python', 'javascript', 'java'] + .filter(l => l.startsWith(value)) + ), + code: z.string() + } + }, + ({ language, code }) => ({ + messages: [{ + role: 'user', + content: { + type: 'text', + text: `Review this ${language} code:\n\n${code}` + } + }] + }) +); +``` + +### Error Handling +```typescript +server.registerTool( + 'risky-operation', + { + title: 'Risky Operation', + description: 'An operation that might fail', + inputSchema: { input: z.string() }, + outputSchema: { result: z.string() } + }, + async ({ input }) => { + try { + const result = await performRiskyOperation(input); + const output = { result }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } catch (err: unknown) { + const error = err as Error; + return { + content: [{ type: 'text', text: `Error: ${error.message}` }], + isError: true + }; + } + } +); +``` diff --git a/.github/prompts/create-commit-title.prompt.md b/.github/prompts/create-commit-title.prompt.md new file mode 100644 index 0000000000..f61285c304 --- /dev/null +++ b/.github/prompts/create-commit-title.prompt.md @@ -0,0 +1,49 @@ +--- +agent: 'agent' +description: 'Generate an 80-character git commit title for the local diff' +--- + +# Generate Commit Title + +## Purpose +Provide a single-line, ready-to-paste git commit title (<= 80 characters) that reflects the most important local changes since `HEAD`. + +## Input to collect +- Run exactly one command to view the local diff: + ```@terminal + git diff HEAD + ``` + +## How to decide the title +1. From the diff, find the dominant area (e.g., `src/modules/*`, `doc/devdocs/**`) and the change type (bug fix, docs update, config tweak). +2. Draft an imperative, plain-ASCII title that: + - Mentions the primary component when obvious (e.g., `FancyZones:` or `Docs:`) + - Stays within 80 characters and has no trailing punctuation + +## Final output +- Reply with only the commit title on a single line—no extra text. + +## PR title convention (when asked) +Use Conventional Commits style: + +`(): ` + +**Allowed types** +- feat, fix, docs, refactor, perf, test, build, ci, chore + +**Scope rules** +- Use a short, PowerToys-focused scope (one word preferred). Common scopes: + - Core: `runner`, `settings-ui`, `common`, `docs`, `build`, `ci`, `installer`, `gpo`, `dsc` + - Modules: `fancyzones`, `powerrename`, `awake`, `colorpicker`, `imageresizer`, `keyboardmanager`, `mouseutils`, `peek`, `hosts`, `file-locksmith`, `screen-ruler`, `text-extractor`, `cropandlock`, `paste`, `powerlauncher` +- If unclear, pick the closest module or subsystem; omit only if unavoidable + +**Summary rules** +- Imperative, present tense (“add”, “update”, “remove”, “fix”) +- Keep it <= 72 characters when possible; be specific, avoid “misc changes” + +**Examples** +- `feat(fancyzones): add canvas template duplication` +- `fix(mouseutils): guard crosshair toggle when dpi info missing` +- `docs(runner): document tray icon states` +- `build(installer): align wix v5 suffix flag` +- `ci(ci): cache pipeline artifacts for x64` diff --git a/.github/prompts/create-pr-summary.prompt.md b/.github/prompts/create-pr-summary.prompt.md new file mode 100644 index 0000000000..9e47c2fc3c --- /dev/null +++ b/.github/prompts/create-pr-summary.prompt.md @@ -0,0 +1,24 @@ +--- +agent: 'agent' +description: 'Generate a PowerToys-ready pull request description from the local diff' +--- + +# Generate PR Summary + +**Goal:** Produce a ready-to-paste PR title and description that follows PowerToys conventions by comparing the current branch against a user-selected target branch. + +**Repo guardrails:** +- Treat `.github/pull_request_template.md` as the single source of truth; load it at runtime instead of embedding hardcoded content in this prompt. +- Preserve section order from the template but only surface checklist lines that are relevant for the detected changes, filling them with `[x]`/`[ ]` as appropriate. +- Cite touched paths with inline backticks, matching the guidance in `.github/copilot-instructions.md`. +- Call out test coverage explicitly: list automated tests run (unit/UI) or state why they are not applicable. + +**Workflow:** +1. Determine the target branch from user context; default to `main` when no branch is supplied. +2. Run `git status --short` once to surface uncommitted files that may influence the summary. +3. Run `git diff ...HEAD` a single time to review the detailed changes. Only when confidence stays low dig deeper with focused calls such as `git diff ...HEAD -- `. +4. From the diff, capture impacted areas, key file changes, behavioral risks, migrations, and noteworthy edge cases. +5. Confirm validation: list tests executed with results or state why tests were skipped in line with repo guidance. +6. Load `.github/pull_request_template.md`, mirror its section order, and populate it with the gathered facts. Include only relevant checklist entries, marking them `[x]/[ ]` and noting any intentional omissions as "N/A". +7. Present the filled template inside a fenced ```markdown code block with no extra commentary so it is ready to paste into a PR, clearly flagging any placeholders that still need user input. +8. Prepend the PR title above the filled template, applying the Conventional Commit type/scope rules from `.github/prompts/create-commit-title.prompt.md`; pick the dominant component from the diff and keep the title concise and imperative. diff --git a/.github/prompts/fix-issue.prompt.md b/.github/prompts/fix-issue.prompt.md new file mode 100644 index 0000000000..9b758c4e8d --- /dev/null +++ b/.github/prompts/fix-issue.prompt.md @@ -0,0 +1,72 @@ +--- +agent: 'agent' +description: 'Execute the fix for a GitHub issue using the previously generated implementation plan' +--- + +# Fix GitHub Issue + +## Dependencies +Source review prompt (for generating the implementation plan if missing): +- .github/prompts/review-issue.prompt.md + +Required plan file (single source of truth): +- Generated Files/issueReview/{{issue_number}}/implementation-plan.md + +## Dependency Handling +1) If `implementation-plan.md` exists → proceed. +2) If missing → run the review prompt: + - Invoke: `.github/prompts/review-issue.prompt.md` + - Pass: `issue_number={{issue_number}}` + - Then re-check for `implementation-plan.md`. +3) If still missing → stop and generate: + - `Generated Files/issueFix/{{issue_number}}/manual-steps.md` containing: + “implementation-plan.md not found; please run .github/prompts/review-issue.prompt.md for #{{issue_number}}.” + +# GOAL +For **#{{issue_number}}**: +- Use implementation-plan.md as the single authority. +- Apply code and test changes directly in the repository. +- Produce a PR-ready description. + +# OUTPUT FILES +1) Generated Files/issueFix/{{issue_number}}/pr-description.md +2) Generated Files/issueFix/{{issue_number}}/manual-steps.md # only if human interaction or external setup is required + +# EXECUTION RULES +1) Read implementation-plan.md and execute: + - Layers & Files → edit/create as listed + - Pattern Choices → follow repository conventions + - Fundamentals (perf, security, compatibility, accessibility) + - Logging & Exceptions + - Telemetry (only if explicitly included in the plan) + - Risks & Mitigations + - Tests to Add +2) Locate affected files via `rg` or `git grep`. +3) Add/update tests to enforce the fixed behavior. +4) If any ambiguity exists, add: +// TODO(Human input needed): +5) Verify locally: build & tests run successfully. + +# pr-description.md should include: +- Title: `Fix: (#{{issue_number}})` +- What changed and why the fix works +- Files or modules touched +- Risks & mitigations (implemented) +- Tests added/updated and how to run them +- Telemetry behavior (if applicable) +- Validation / reproduction steps +- `Closes #{{issue_number}}` + +# manual-steps.md (only if needed) +- List required human actions: secrets, config, approvals, missing info, or code comments requiring human decisions. + +# IMPORTANT +- Apply code and tests directly; do not produce patch files. +- Follow implementation-plan.md as the source of truth. +- Insert comments for human review where a decision or input is required. +- Use repository conventions and deterministic, minimal changes. + +# FINALIZE +- Write pr-description.md +- Write manual-steps.md only if needed +- Print concise success message or note items requiring human interaction 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 new file mode 100644 index 0000000000..bd40c1feea --- /dev/null +++ b/.github/prompts/fix-spelling.prompt.md @@ -0,0 +1,24 @@ +--- +agent: 'agent' +description: 'Resolve Code scanning / check-spelling comments on the active PR' +--- + +# Fix Spelling Comments + +**Goal:** Clear every outstanding GitHub pull request comment created by the `Code scanning / check-spelling` workflow by explicitly allowing intentional terms. + +**Guardrails:** +- Update only discussion threads authored by `github-actions` or `github-actions[bot]` that mention `Code scanning results / check-spelling`. +- Prefer improving the wording in the originally flagged file when it clarifies intent without changing meaning; if the wording is already clear/standard for the context, handle it via `.github/actions/spell-check/expect.txt` and reuse existing entries. +- Limit edits to the flagged text and `.github/actions/spell-check/expect.txt`; leave all other files and topics untouched. + +**Prerequisites:** +- Install GitHub CLI if it is not present: `winget install GitHub.cli`. +- Run `gh auth login` once before the first CLI use. + +**Workflow:** +1. Determine the active pull request with a single `gh pr view --json number` call (default to the current branch). +2. Fetch all PR discussion data once via `gh pr view --json comments,reviews` and filter to check-spelling comments authored by `github-actions` or `github-actions[bot]` that are not minimized; when several remain, process only the most recent comment body. +3. For each flagged token, first consider tightening or rephrasing the original text to avoid the false positive while keeping the meaning intact; if the existing wording is already normal and professional for the context, proceed to allowlisting instead of changing it. +4. When allowlisting, review `.github/actions/spell-check/expect.txt` for an equivalent term (for example an existing lowercase variant); when found, reuse that normalized term rather than adding a new entry, even if the flagged token differs only by casing. Only add a new entry after confirming no equivalent already exists. +5. Add any remaining missing token to `.github/actions/spell-check/expect.txt`, keeping surrounding formatting intact. \ No newline at end of file diff --git a/.github/prompts/review-issue.prompt.md b/.github/prompts/review-issue.prompt.md new file mode 100644 index 0000000000..2ed4b9ef1f --- /dev/null +++ b/.github/prompts/review-issue.prompt.md @@ -0,0 +1,165 @@ +--- +agent: 'agent' +description: 'Review a GitHub issue, score it (0-100), and generate an implementation plan' +--- + +# Review GitHub Issue + +## Goal +For **#{{issue_number}}** produce: +1) `Generated Files/issueReview/{{issue_number}}/overview.md` +2) `Generated Files/issueReview/{{issue_number}}/implementation-plan.md` + +## Inputs +Figure out required inputs {{issue_number}} from the invocation context; if anything is missing, ask for the value or note it as a gap. + +# CONTEXT (brief) +Ground evidence using `gh issue view {{issue_number}} --json number,title,body,author,createdAt,updatedAt,state,labels,milestone,reactions,comments,linkedPullRequests`, download images via MCP `github_issue_images` to better understand the issue context. Finally, use MCP `github_issue_attachments` to download logs with parameter `extractFolder` as `Generated Files/issueReview/{{issue_number}}/logs`, and analyze the downloaded logs if available to identify relevant issues. Locate the source code in the current workspace (use `rg`/`git grep` as needed). Link related issues and PRs. + +## When to call MCP tools +If the following MCP "github-artifacts" tools are available in the environment, use them: +- `github_issue_images`: use when the issue/PR likely contains screenshots or other visual evidence (UI bugs, glitches, design problems). +- `github_issue_attachments`: use when the issue/PR mentions attached ZIPs (PowerToysReport_*.zip, logs.zip, debug.zip) or asks to analyze logs/diagnostics. Always provide `extractFolder` as `Generated Files/issueReview/{{issue_number}}/logs` + +If these tools are not available (not listed by the runtime), start the MCP server "github-artifacts" first. + +# OVERVIEW.MD +## Summary +Issue, state, milestone, labels. **Signals**: 👍/❤️/👎, comment count, last activity, linked PRs. + +## At-a-Glance Score Table +Present all ratings in a compact table for quick scanning: + +| Dimension | Score | Assessment | Key Drivers | +|-----------|-------|------------|-------------| +| **A) Business Importance** | X/100 | Low/Medium/High | Top 2 factors with scores | +| **B) Community Excitement** | X/100 | Low/Medium/High | Top 2 factors with scores | +| **C) Technical Feasibility** | X/100 | Low/Medium/High | Top 2 factors with scores | +| **D) Requirement Clarity** | X/100 | Low/Medium/High | Top 2 factors with scores | +| **Overall Priority** | X/100 | Low/Medium/High/Critical | Average or weighted summary | +| **Effort Estimate** | X days (T-shirt) | XS/S/M/L/XL/XXL/Epic | Type: bug/feature/chore | +| **Similar Issues Found** | X open, Y closed | — | Quick reference to related work | +| **Potential Assignees** | @username, @username | — | Top contributors to module | + +**Assessment bands**: 0-25 Low, 26-50 Medium, 51-75 High, 76-100 Critical + +## Ratings (0–100) — add evidence & short rationale +### A) Business Importance +- Labels (priority/security/regression): **≤35** +- Milestone/roadmap: **≤25** +- Customer/contract impact: **≤20** +- Unblocks/platform leverage: **≤20** +### B) Community Excitement +- 👍+❤️ normalized: **≤45** +- Comment volume & unique participants: **≤25** +- Recent activity (≤30d): **≤15** +- Duplicates/related issues: **≤15** +### C) Technical Feasibility +- Contained surface/clear seams: **≤30** +- Existing patterns/utilities: **≤25** +- Risk (perf/sec/compat) manageable: **≤25** +- Testability & CI support: **≤20** +### D) Requirement Clarity +- Behavior/repro/constraints: **≤60** +- Non-functionals (perf/sec/i18n/a11y): **≤25** +- Decision owners/acceptance signals: **≤15** + +## Effort +Days + **T-shirt** (XS 0.5–1d, S 1–2, M 2–4, L 4–7, XL 7–14, XXL 14–30, Epic >30). +Type/level: bug/feature/chore/docs/refactor/test-only; severity/value tier. + +## Suggested Actions +Provide actionable recommendations for issue triage and assignment: + +### A) Requirement Clarification (if Clarity score <50) +**When Requirement Clarity (Dimension D) is Medium or Low:** +- Identify specific gaps in issue description: missing repro steps, unclear expected behavior, undefined acceptance criteria, missing non-functional requirements +- Draft 3-5 clarifying questions to post as issue comment +- Suggest additional information needed: screenshots, logs, environment details, OS version, PowerToys version, error messages +- If behavior is ambiguous, propose 2-3 interpretation scenarios and ask reporter to confirm +- Example questions: + - "Can you provide exact steps to reproduce this issue?" + - "What is the expected behavior vs. what you're actually seeing?" + - "Does this happen on Windows 10, 11, or both?" + - "Can you attach a screenshot or screen recording?" + +### B) Correct Label Suggestions +- Analyze issue type, module, and severity to suggest missing or incorrect labels +- Recommend labels from: `Issue-Bug`, `Issue-Feature`, `Issue-Docs`, `Issue-Task`, `Priority-High`, `Priority-Medium`, `Priority-Low`, `Needs-Triage`, `Needs-Author-Feedback`, `Product-`, etc. +- If Requirement Clarity is low (<50), add `Needs-Author-Feedback` label +- If current labels are incorrect or incomplete, provide specific label changes with rationale + +### C) Find Similar Issues & Past Fixes +- Search for similar issues using `gh issue list --search "keywords" --state all --json number,title,state,closedAt` +- Identify patterns: duplicate issues, related bugs, or similar feature requests +- For closed issues, find linked PRs that fixed them: check `linkedPullRequests` in issue data +- Provide 3-5 examples of similar issues with format: `# - (closed by PR #<pr>)` or `(still open)` + +### D) Identify Subject Matter Experts +- Use git blame/log to find who fixed similar issues in the past +- Search for PR authors who touched relevant files: `git log --all --format='%aN' -- <file_paths> | sort | uniq -c | sort -rn | head -5` +- Check issue/PR history for frequent contributors to the affected module +- Suggest 2-3 potential assignees with context: `@<username> - <reason>` (e.g., "fixed similar rendering bug in #12345", "maintains FancyZones module") + +### E) Semantic Search for Related Work +- Use semantic_search tool to find similar issues, code patterns, or past discussions +- Search queries should include: issue keywords, module names, error messages, feature descriptions +- Cross-reference semantic results with GitHub issue search for comprehensive coverage + +**Output format for Suggested Actions section in overview.md:** +```markdown +## Suggested Actions + +### Clarifying Questions (if Clarity <50) +Post these questions as issue comment to gather missing information: +1. <question> +2. <question> +3. <question> + +**Recommended label**: `Needs-Author-Feedback` + +### Label Recommendations +- Add: `<label>` - <reason> +- Remove: `<label>` - <reason> +- Current labels are appropriate ✓ + +### Similar Issues Found +1. #<number> - <title> (<state>, closed by PR #<pr> on <date>) +2. #<number> - <title> (<state>) +... + +### Potential Assignees +- @<username> - <reason> +- @<username> - <reason> + +### Related Code/Discussions +- <semantic search findings> +``` + +# IMPLEMENTATION-PLAN.MD +1) **Problem Framing** — restate problem; current vs expected; scope boundaries. +2) **Layers & Files** — layers (UI/domain/data/infra/build). For each, list **files/dirs to modify** and **new files** (exact paths + why). Prefer repo patterns; cite examples/PRs. +3) **Pattern Choices** — reuse existing; if new, justify trade-offs & transition. +4) **Fundamentals** (brief plan or N/A + reason): +- Performance (hot paths, allocs, caching/streaming) +- Security (validation, authN/Z, secrets, SSRF/XSS/CSRF) +- G11N/L10N (resources, number/date, pluralization) +- Compatibility (public APIs, formats, OS/runtime/toolchain) +- Extensibility (DI seams, options/flags, plugin points) +- Accessibility (roles, labels, focus, keyboard, contrast) +- SOLID & repo conventions (naming, folders, dependency direction) +5) **Logging & Exception Handling** +- Where to log; levels; structured fields; correlation/traces. +- What to catch vs rethrow; retries/backoff; user-visible errors. +- **Privacy**: never log secrets/PII; redaction policy. +6) **Telemetry (optional — business metrics only)** +- Events/metrics (name, when, props); success signal; privacy/sampling; dashboards/alerts. +7) **Risks & Mitigations** — flags/canary/shadow-write/config guards. +8) **Task Breakdown (agent-ready)** — table (leave a blank line before the header so Markdown renders correctly): + +| Task | Intent | Files/Areas | Steps | Tests (brief) | Owner (Agent/Human) | Human interaction needed? (why) | +|---|---|---|---|---|---|---| + +9) **Tests to Add (only)** +- **Unit**: targets, cases (success/edge/error), mocks/fixtures, path, notes. +- **UI** (if applicable): flows, locator strategy, env/data/flags, path, flake mitigation. \ No newline at end of file diff --git a/.github/prompts/review-pr.prompt.md b/.github/prompts/review-pr.prompt.md new file mode 100644 index 0000000000..0f72b6171d --- /dev/null +++ b/.github/prompts/review-pr.prompt.md @@ -0,0 +1,198 @@ +--- +agent: 'agent' +description: 'Perform a comprehensive PR review with per-step Markdown and machine-readable outputs' +--- + +# Review Pull Request + +**Goal**: Given `{{pr_number}}`, run a *one-topic-per-step* review. Write files to `Generated Files/prReview/{{pr_number}}/` (replace `{{pr_number}}` with the integer). Emit machine‑readable blocks for a GitHub MCP to post review comments. + +## PR selection +Resolve the target PR using these fallbacks in order: +1. Parse the invocation text for an explicit identifier (first integer following patterns such as a leading hash and digits or the text `PR:` followed by digits). +2. If no PR is found yet, locate the newest `Generated Files/prReview/_batch/batch-overview-*.md` file (highest timestamp in filename, fallback newest mtime) and take the first entry in its `## PRs` list whose review folder is missing `00-OVERVIEW.md` or contains `__error.flag`. +3. If the batch file has no pending PRs, query assignments with `gh pr list --assignee @me --state open --json number,updatedAt --limit 20` and pick the most recently updated PR that does not already have a completed review folder. +4. If still unknown, run `gh pr view --json number` in the current branch and use that result when it is unambiguous. +5. If every step above fails, prompt the user for a PR number before proceeding. + +## Fetch PR data with `gh` +- `gh pr view {{pr_number}} --json number,baseRefName,headRefName,baseRefOid,headRefOid,changedFiles,files` +- `gh api repos/:owner/:repo/pulls/{{pr_number}}/files?per_page=250` # patches for line mapping + +### Incremental review workflow +1. **Check for existing review**: Read `Generated Files/prReview/{{pr_number}}/00-OVERVIEW.md` +2. **Extract state**: Parse `Last reviewed SHA:` from review metadata section +3. **Detect changes**: Run `Get-PrIncrementalChanges.ps1 -PullRequestNumber {{pr_number}} -LastReviewedCommitSha {{sha}}` +4. **Analyze result**: + - `NeedFullReview: true` → Review all files in the PR + - `NeedFullReview: false` and `IsIncremental: true` → Review only files in `ChangedFiles` array + - `ChangedFiles` is empty → No changes, skip review (update iteration history with "No changes since last review") +5. **Apply smart filtering**: Use the file patterns in smart step filtering table to skip irrelevant steps +6. **Update metadata**: After completing review, save current `headRefOid` as `Last reviewed SHA:` in `00-OVERVIEW.md` + +### Reusable PowerShell scripts +Scripts live in `.github/review-tools/` to avoid repeated manual approvals during PR reviews: + +| Script | Usage | +| --- | --- | +| `.github/review-tools/Get-GitHubRawFile.ps1` | Download a repository file at a given ref, optionally with line numbers. | +| `.github/review-tools/Get-GitHubPrFilePatch.ps1` | Fetch the unified diff for a specific file within a pull request via `gh api`. | +| `.github/review-tools/Get-PrIncrementalChanges.ps1` | Compare last reviewed SHA with current PR head to identify incremental changes. Returns JSON with changed files, new commits, and whether full review is needed. | +| `.github/review-tools/Test-IncrementalReview.ps1` | Test helper to preview incremental review detection for a PR. Use before running full review to see what changed. | + +Always prefer these scripts (or new ones added under `.github/review-tools/`) over raw `gh api` or similar shell commands so the review flow does not trigger interactive approval prompts. + +## Output files +Folder: `Generated Files/prReview/{{pr_number}}/` +Files: `00-OVERVIEW.md`, `01-functionality.md`, `02-compatibility.md`, `03-performance.md`, `04-accessibility.md`, `05-security.md`, `06-localization.md`, `07-globalization.md`, `08-extensibility.md`, `09-solid-design.md`, `10-repo-patterns.md`, `11-docs-automation.md`, `12-code-comments.md`, `13-copilot-guidance.md` *(only if guidance md exists).* +- **Write-after-step rule:** Immediately after completing each TODO step, persist that step's markdown file before proceeding to the next. Generate `00-OVERVIEW.md` only after every step file has been refreshed for the current run. + +## Iteration management +- Determine the current review iteration by reading `00-OVERVIEW.md` (look for `Review iteration:`). If missing, assume iteration `1`. +- Extract the last reviewed SHA from `00-OVERVIEW.md` (look for `Last reviewed SHA:` in the review metadata section). If missing, this is iteration 1. +- **Incremental review detection**: + 1. Call `.github/review-tools/Get-PrIncrementalChanges.ps1 -PullRequestNumber {{pr_number}} -LastReviewedCommitSha {{last_sha}}` to get delta analysis. + 2. Parse the JSON result to determine if incremental review is possible (`IsIncremental: true`, `NeedFullReview: false`). + 3. If force-push detected or first review, proceed with full review of all changed files. + 4. If incremental, review only the files listed in `ChangedFiles` array and apply smart step filtering (see below). +- Increment the iteration for each review run and propagate the new value to all step files and the overview. +- Preserve prior iteration notes by keeping/expanding an `## Iteration history` section in each markdown file, appending the newest summary under `### Iteration <N>`. +- Summaries should capture key deltas since the previous iteration so reruns can pick up context quickly. +- **After review completion**, update `Last reviewed SHA:` in `00-OVERVIEW.md` with the current `headRefOid` and update the timestamp. + +### Smart step filtering (incremental reviews only) +When performing incremental review, skip steps that are irrelevant based on changed file types: + +| File pattern | Required steps | Skippable steps | +| --- | --- | --- | +| `**/*.cs`, `**/*.cpp`, `**/*.h` | Functionality, Compatibility, Performance, Security, SOLID, Repo patterns, Code comments | (depends on files) | +| `**/*.resx`, `**/Resources/*.xaml` | Localization, Globalization | Most others | +| `**/*.md` (docs) | Docs & automation | Most others (unless copilot guidance) | +| `**/*copilot*.md`, `.github/prompts/*.md` | Copilot guidance, Docs & automation | Most others | +| `**/*.csproj`, `**/*.vcxproj`, `**/packages.config` | Compatibility, Security, Repo patterns | Localization, Globalization, Accessibility | +| `**/UI/**`, `**/*View.xaml` | Accessibility, Localization | Performance (unless perf-sensitive controls) | + +**Default**: If uncertain or files span multiple categories, run all applicable steps. When in doubt, be conservative and review more rather than less. + +## TODO steps (one concern each) +1) Functionality +2) Compatibility +3) Performance +4) Accessibility +5) Security +6) Localization +7) Globalization +8) Extensibility +9) SOLID principles +10) Repo patterns +11) Docs & automation coverage for the changes +12) Code comments +13) Copilot guidance (conditional): if changed folders contain `*copilot*.md` or `.github/prompts/*.md`, review diffs **against** that guidance and write `13-copilot-guidance.md` (omit if none). + +## Per-step file template (use verbatim) +```md +# <STEP TITLE> +**PR:** (populate with PR identifier) — Base:<baseRefName> Head:<headRefName> +**Review iteration:** ITERATION + +## Iteration history +- Maintain subsections titled `### Iteration N` in reverse chronological order (append the latest at the top) with 2–4 bullet highlights. + +### Iteration ITERATION +- <Latest key point 1> +- <Latest key point 2> + +## Checks executed +- List the concrete checks for *this step only* (5–10 bullets). + +## Findings +(If none, write **None**. Defaults have one or more blocks:) + +```mcp-review-comment +{"file":"relative/path.ext","start_line":123,"end_line":125,"severity":"high|medium|low|info","tags":["<step-slug>","pr-tag-here"],"related_files":["optional/other/file1"],"body":"Problem → Why it matters → Concrete fix. If spans multiple files, name them here."} +``` +Use the second tag to encode the PR number. + +``` +## Overview file (`00-OVERVIEW.md`) template +```md +# PR Review Overview — (populate with PR identifier) +**Review iteration:** ITERATION +**Changed files:** <n> | **High severity issues:** <count> + +## Review metadata +**Last reviewed SHA:** <headRefOid from gh pr view> +**Last review timestamp:** <ISO8601 timestamp> +**Review mode:** <Full|Incremental (N files changed since iteration X)> +**Base ref:** <baseRefName> +**Head ref:** <headRefName> + +## Step results +Write lines like: `01 Functionality — <OK|Issues|Skipped> (see 01-functionality.md)` … through step 13. +Mark steps as "Skipped" when using incremental review smart filtering. + +## Iteration history +- Maintain subsections titled `### Iteration N` mirroring the per-step convention with concise deltas and cross-links to the relevant step files. +- For incremental reviews, list the specific files that changed and which commits were added. +``` + +## Line numbers & multi‑file issues +- Map head‑side lines from `patch` hunks (`@@ -a,b +c,d @@` → new lines `+c..+c+d-1`). +- For cross‑file issues: set the primary `"file"`, list others in `"related_files"`, and name them in `"body"`. + +## Posting (for MCP) +- Parse all ```mcp-review-comment``` blocks across step files and post as PR review comments. +- If posting isn’t available, still write all files. + +## Constraint +Read/analyze only; don't modify code. Keep comments small, specific, and fix‑oriented. + +**Testing**: Use `.github/review-tools/Test-IncrementalReview.ps1 -PullRequestNumber 42374` to preview incremental detection before running full review. + +## Scratch cache for large PRs + +Create a local scratch workspace to progressively summarize diffs and reload state across runs. + +### Paths +- Root: `Generated Files/prReview/{{pr_number}}/__tmp/` +- Files: + - `index.jsonl` — append-only JSON Lines index of artifacts. + - `todo-queue.json` — pending items (files/chunks/steps). + - `rollup-<step>-v<N>.md` — iterative per-step aggregates. + - `file-<hash>.txt` — optional saved chunk text (when needed). + +### JSON schema (per line in `index.jsonl`) +```json +{"type":"chunk|summary|issue|crosslink", + "path":"relative/file.ext","chunk_id":"f-12","step":"functionality|compatibility|...", + "base_sha":"...", "head_sha":"...", "range":[start,end], "version":1, + "notes":"short text or key:value map", "created_utc":"ISO8601"} +``` + +### Phases (stateful; resume-safe) +0. **Discover** PR + SHAs: `gh pr view <PR> --json baseRefName,headRefName,baseRefOid,headRefOid,files`. +1. **Chunk** each changed file (head): split into ~300–600 LOC or ~4k chars; stable `chunk_id` = hash(path+start). + - Save `chunk` records. Optionally write `file-<hash>.txt` for expensive chunks. +2. **Summarize** per chunk: intent, APIs, risks per TODO step; emit `summary` records (≤600 tokens each). +3. **Issues**: convert findings to machine-readable blocks and emit `issue` records (later rendered to step MD). +4. **Rollups**: build/update `rollup-<step>-v<N>.md` from `summary`+`issue`. Keep prior versions. +5. **Finalize**: write per-step files + `00-OVERVIEW.md` from rollups. Post comments via MCP if available. + +### Re-use & token limits +- Always **reload** `index.jsonl` first; skip chunks with same `head_sha` and `range`. +- **Incremental review optimization**: When `Get-PrIncrementalChanges.ps1` returns a subset of changed files, load only chunks from those files. Reuse existing chunks/summaries for unchanged files. +- Prefer re-summarizing only changed chunks; merge chunk summaries → file summaries → step rollups. +- When context is tight, load only the minimal chunk text (or its saved `file-<hash>.txt`) needed for a comment. + +### Original vs diff +- Fetch base content when needed: prefer `git show <baseRefName>:<path>`; fallback `gh api repos/:owner/:repo/contents/<path>?ref=<base_sha>` (base64). +- Use patch hunks from `gh api .../pulls/<PR>/files` to compute **head** line numbers. + +### Queue-driven loop +- Seed `todo-queue.json` with all changed files. +- Process: chunk → summarize → detect issues → roll up. +- Append to `index.jsonl` after each step; never rewrite previous lines (append-only). + +### Hygiene +- `__tmp/` is implementation detail; do not include in final artifacts. +- It is safe to delete to force a clean pass; the next run rebuilds it. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0cff106acb..560b44b5a4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,10 +4,11 @@ <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist -- [ ] **Closes:** #xxx -- [ ] **Communication:** I've discussed this with core contributors already. If work hasn't been agreed, this work might be rejected +- [ ] Closes: #xxx +<!-- - [ ] Closes: #yyy (add separate lines for additional resolved issues) --> +- [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass -- [ ] **Localization:** All end user facing strings can be localized +- [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries @@ -16,7 +17,7 @@ - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx -<!-- Provide a more detailed description of the PR, other things fixed or any additional comments/features here --> +<!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> diff --git a/.github/review-tools/Get-GitHubPrFilePatch.ps1 b/.github/review-tools/Get-GitHubPrFilePatch.ps1 new file mode 100644 index 0000000000..1b20ea59f5 --- /dev/null +++ b/.github/review-tools/Get-GitHubPrFilePatch.ps1 @@ -0,0 +1,79 @@ +<# +.SYNOPSIS + Retrieves the unified diff patch for a specific file in a GitHub pull request. + +.DESCRIPTION + This script fetches the patch content (unified diff format) for a specified file + within a pull request. It uses the GitHub CLI (gh) to query the GitHub API and + retrieve file change information. + +.PARAMETER PullRequestNumber + The pull request number to query. + +.PARAMETER FilePath + The relative path to the file in the repository (e.g., "src/modules/main.cpp"). + +.PARAMETER RepositoryOwner + The GitHub repository owner. Defaults to "microsoft". + +.PARAMETER RepositoryName + The GitHub repository name. Defaults to "PowerToys". + +.EXAMPLE + .\Get-GitHubPrFilePatch.ps1 -PullRequestNumber 42374 -FilePath "src/modules/cmdpal/main.cpp" + Retrieves the patch for main.cpp in PR #42374. + +.EXAMPLE + .\Get-GitHubPrFilePatch.ps1 -PullRequestNumber 42374 -FilePath "README.md" -RepositoryOwner "myorg" -RepositoryName "myrepo" + Retrieves the patch from a different repository. + +.NOTES + Requires GitHub CLI (gh) to be installed and authenticated. + Run 'gh auth login' if not already authenticated. + +.LINK + https://cli.github.com/ +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, HelpMessage = "Pull request number")] + [int]$PullRequestNumber, + + [Parameter(Mandatory = $true, HelpMessage = "Relative path to the file in the repository")] + [string]$FilePath, + + [Parameter(Mandatory = $false, HelpMessage = "Repository owner")] + [string]$RepositoryOwner = "microsoft", + + [Parameter(Mandatory = $false, HelpMessage = "Repository name")] + [string]$RepositoryName = "PowerToys" +) + +# Construct GitHub API path for pull request files +$apiPath = "repos/$RepositoryOwner/$RepositoryName/pulls/$PullRequestNumber/files?per_page=250" + +# Query GitHub API to get all files in the pull request +try { + $pullRequestFiles = gh api $apiPath | ConvertFrom-Json +} catch { + Write-Error "Failed to query GitHub API for PR #$PullRequestNumber. Ensure gh CLI is authenticated. Details: $_" + exit 1 +} + +# Find the matching file in the pull request +$matchedFile = $pullRequestFiles | Where-Object { $_.filename -eq $FilePath } + +if (-not $matchedFile) { + Write-Error "File '$FilePath' not found in PR #$PullRequestNumber." + exit 1 +} + +# Check if patch content exists +if (-not $matchedFile.patch) { + Write-Warning "File '$FilePath' has no patch content (possibly binary or too large)." + return +} + +# Output the patch content +$matchedFile.patch diff --git a/.github/review-tools/Get-GitHubRawFile.ps1 b/.github/review-tools/Get-GitHubRawFile.ps1 new file mode 100644 index 0000000000..d75f519334 --- /dev/null +++ b/.github/review-tools/Get-GitHubRawFile.ps1 @@ -0,0 +1,91 @@ +<# +.SYNOPSIS + Downloads and displays the content of a file from a GitHub repository at a specific git reference. + +.DESCRIPTION + This script fetches the raw content of a file from a GitHub repository using GitHub's raw content API. + It can optionally display line numbers and supports any valid git reference (branch, tag, or commit SHA). + +.PARAMETER FilePath + The relative path to the file in the repository (e.g., "src/modules/main.cpp"). + +.PARAMETER GitReference + The git reference (branch name, tag, or commit SHA) to fetch the file from. Defaults to "main". + +.PARAMETER RepositoryOwner + The GitHub repository owner. Defaults to "microsoft". + +.PARAMETER RepositoryName + The GitHub repository name. Defaults to "PowerToys". + +.PARAMETER ShowLineNumbers + When specified, displays line numbers before each line of content. + +.PARAMETER StartLineNumber + The starting line number to use when ShowLineNumbers is enabled. Defaults to 1. + +.EXAMPLE + .\Get-GitHubRawFile.ps1 -FilePath "README.md" -GitReference "main" + Downloads and displays the README.md file from the main branch. + +.EXAMPLE + .\Get-GitHubRawFile.ps1 -FilePath "src/runner/main.cpp" -GitReference "dev/feature-branch" -ShowLineNumbers + Downloads main.cpp from a feature branch and displays it with line numbers. + +.EXAMPLE + .\Get-GitHubRawFile.ps1 -FilePath "LICENSE" -GitReference "abc123def" -ShowLineNumbers -StartLineNumber 10 + Downloads the LICENSE file from a specific commit and displays it with line numbers starting at 10. + +.NOTES + Requires internet connectivity to access GitHub's raw content API. + Does not require GitHub CLI authentication for public repositories. + +.LINK + https://docs.github.com/en/rest/repos/contents +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, HelpMessage = "Relative path to the file in the repository")] + [string]$FilePath, + + [Parameter(Mandatory = $false, HelpMessage = "Git reference (branch, tag, or commit SHA)")] + [string]$GitReference = "main", + + [Parameter(Mandatory = $false, HelpMessage = "Repository owner")] + [string]$RepositoryOwner = "microsoft", + + [Parameter(Mandatory = $false, HelpMessage = "Repository name")] + [string]$RepositoryName = "PowerToys", + + [Parameter(Mandatory = $false, HelpMessage = "Display line numbers before each line")] + [switch]$ShowLineNumbers, + + [Parameter(Mandatory = $false, HelpMessage = "Starting line number for display")] + [int]$StartLineNumber = 1 +) + +# Construct the raw content URL +$rawContentUrl = "https://raw.githubusercontent.com/$RepositoryOwner/$RepositoryName/$GitReference/$FilePath" + +# Fetch the file content from GitHub +try { + $response = Invoke-WebRequest -UseBasicParsing -Uri $rawContentUrl +} catch { + Write-Error "Failed to fetch file from $rawContentUrl. Details: $_" + exit 1 +} + +# Split content into individual lines +$contentLines = $response.Content -split "`n" + +# Display the content with or without line numbers +if ($ShowLineNumbers) { + $currentLineNumber = $StartLineNumber + foreach ($line in $contentLines) { + Write-Output ("{0:d4}: {1}" -f $currentLineNumber, $line) + $currentLineNumber++ + } +} else { + $contentLines | ForEach-Object { Write-Output $_ } +} diff --git a/.github/review-tools/Get-PrIncrementalChanges.ps1 b/.github/review-tools/Get-PrIncrementalChanges.ps1 new file mode 100644 index 0000000000..b9bcf8025e --- /dev/null +++ b/.github/review-tools/Get-PrIncrementalChanges.ps1 @@ -0,0 +1,173 @@ +<# +.SYNOPSIS + Detects changes between the last reviewed commit and current head of a pull request. + +.DESCRIPTION + This script compares a previously reviewed commit SHA with the current head of a pull request + to determine what has changed. It helps enable incremental reviews by identifying new commits + and modified files since the last review iteration. + + The script handles several scenarios: + - First review (no previous SHA provided) + - No changes (current SHA matches last reviewed SHA) + - Force-push detected (last reviewed SHA no longer in history) + - Incremental changes (new commits added since last review) + +.PARAMETER PullRequestNumber + The pull request number to analyze. + +.PARAMETER LastReviewedCommitSha + The commit SHA that was last reviewed. If omitted, this is treated as a first review. + +.PARAMETER RepositoryOwner + The GitHub repository owner. Defaults to "microsoft". + +.PARAMETER RepositoryName + The GitHub repository name. Defaults to "PowerToys". + +.OUTPUTS + JSON object containing: + - PullRequestNumber: The PR number being analyzed + - CurrentHeadSha: The current head commit SHA + - LastReviewedSha: The last reviewed commit SHA (if provided) + - BaseRefName: Base branch name + - HeadRefName: Head branch name + - IsIncremental: Boolean indicating if incremental review is possible + - NeedFullReview: Boolean indicating if a full review is required + - ChangedFiles: Array of files that changed (filename, status, additions, deletions) + - NewCommits: Array of commits added since last review (sha, message, author, date) + - Summary: Human-readable description of changes + +.EXAMPLE + .\Get-PrIncrementalChanges.ps1 -PullRequestNumber 42374 + Analyzes PR #42374 with no previous review (first review scenario). + +.EXAMPLE + .\Get-PrIncrementalChanges.ps1 -PullRequestNumber 42374 -LastReviewedCommitSha "abc123def456" + Compares current PR state against the last reviewed commit to identify incremental changes. + +.EXAMPLE + $changes = .\Get-PrIncrementalChanges.ps1 -PullRequestNumber 42374 -LastReviewedCommitSha "abc123" | ConvertFrom-Json + if ($changes.IsIncremental) { Write-Host "Can perform incremental review" } + Captures the output as a PowerShell object for further processing. + +.NOTES + Requires GitHub CLI (gh) to be installed and authenticated. + Run 'gh auth login' if not already authenticated. + +.LINK + https://cli.github.com/ +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, HelpMessage = "Pull request number")] + [int]$PullRequestNumber, + + [Parameter(Mandatory = $false, HelpMessage = "Commit SHA that was last reviewed")] + [string]$LastReviewedCommitSha, + + [Parameter(Mandatory = $false, HelpMessage = "Repository owner")] + [string]$RepositoryOwner = "microsoft", + + [Parameter(Mandatory = $false, HelpMessage = "Repository name")] + [string]$RepositoryName = "PowerToys" +) + +# Fetch current pull request state from GitHub +try { + $pullRequestData = gh pr view $PullRequestNumber --json headRefOid,headRefName,baseRefName,baseRefOid | ConvertFrom-Json +} catch { + Write-Error "Failed to fetch PR #$PullRequestNumber details. Details: $_" + exit 1 +} + +$currentHeadSha = $pullRequestData.headRefOid +$baseRefName = $pullRequestData.baseRefName +$headRefName = $pullRequestData.headRefName + +# Initialize result object +$analysisResult = @{ + PullRequestNumber = $PullRequestNumber + CurrentHeadSha = $currentHeadSha + BaseRefName = $baseRefName + HeadRefName = $headRefName + LastReviewedSha = $LastReviewedCommitSha + IsIncremental = $false + NeedFullReview = $true + ChangedFiles = @() + NewCommits = @() + Summary = "" +} + +# Scenario 1: First review (no previous SHA provided) +if ([string]::IsNullOrWhiteSpace($LastReviewedCommitSha)) { + $analysisResult.Summary = "Initial review - no previous iteration found" + $analysisResult.NeedFullReview = $true + return $analysisResult | ConvertTo-Json -Depth 10 +} + +# Scenario 2: No changes since last review +if ($currentHeadSha -eq $LastReviewedCommitSha) { + $analysisResult.Summary = "No changes since last review (SHA: $currentHeadSha)" + $analysisResult.NeedFullReview = $false + $analysisResult.IsIncremental = $true + return $analysisResult | ConvertTo-Json -Depth 10 +} + +# Scenario 3: Check for force-push (last reviewed SHA no longer exists in history) +try { + $null = gh api "repos/$RepositoryOwner/$RepositoryName/commits/$LastReviewedCommitSha" 2>&1 + if ($LASTEXITCODE -ne 0) { + # SHA not found - likely force-push or branch rewrite + $analysisResult.Summary = "Force-push detected - last reviewed SHA $LastReviewedCommitSha no longer exists. Full review required." + $analysisResult.NeedFullReview = $true + return $analysisResult | ConvertTo-Json -Depth 10 + } +} catch { + $analysisResult.Summary = "Cannot verify last reviewed SHA $LastReviewedCommitSha - assuming force-push. Full review required." + $analysisResult.NeedFullReview = $true + return $analysisResult | ConvertTo-Json -Depth 10 +} + +# Scenario 4: Get incremental changes between last reviewed SHA and current head +try { + $compareApiPath = "repos/$RepositoryOwner/$RepositoryName/compare/$LastReviewedCommitSha...$currentHeadSha" + $comparisonData = gh api $compareApiPath | ConvertFrom-Json + + # Extract new commits information + $analysisResult.NewCommits = $comparisonData.commits | ForEach-Object { + @{ + Sha = $_.sha.Substring(0, 7) + Message = $_.commit.message.Split("`n")[0] # First line only + Author = $_.commit.author.name + Date = $_.commit.author.date + } + } + + # Extract changed files information + $analysisResult.ChangedFiles = $comparisonData.files | ForEach-Object { + @{ + Filename = $_.filename + Status = $_.status # added, modified, removed, renamed + Additions = $_.additions + Deletions = $_.deletions + Changes = $_.changes + } + } + + $fileCount = $analysisResult.ChangedFiles.Count + $commitCount = $analysisResult.NewCommits.Count + + $analysisResult.IsIncremental = $true + $analysisResult.NeedFullReview = $false + $analysisResult.Summary = "Incremental review: $commitCount new commit(s), $fileCount file(s) changed since SHA $($LastReviewedCommitSha.Substring(0, 7))" + +} catch { + Write-Error "Failed to compare commits. Details: $_" + $analysisResult.Summary = "Error comparing commits - defaulting to full review" + $analysisResult.NeedFullReview = $true +} + +# Return the analysis result as JSON +return $analysisResult | ConvertTo-Json -Depth 10 diff --git a/.github/review-tools/Test-IncrementalReview.ps1 b/.github/review-tools/Test-IncrementalReview.ps1 new file mode 100644 index 0000000000..b03bbe7cd5 --- /dev/null +++ b/.github/review-tools/Test-IncrementalReview.ps1 @@ -0,0 +1,170 @@ +<# +.SYNOPSIS + Tests and previews incremental review detection for a pull request. + +.DESCRIPTION + This helper script validates the incremental review detection logic by analyzing an existing + PR review folder. It reads the last reviewed SHA from the overview file, compares it with + the current PR state, and displays detailed information about what has changed. + + This is useful for: + - Testing the incremental review system before running a full review + - Understanding what changed since the last review iteration + - Verifying that review metadata was properly recorded + +.PARAMETER PullRequestNumber + The pull request number to test incremental review detection for. + +.PARAMETER RepositoryOwner + The GitHub repository owner. Defaults to "microsoft". + +.PARAMETER RepositoryName + The GitHub repository name. Defaults to "PowerToys". + +.OUTPUTS + Colored console output displaying: + - Current and last reviewed commit SHAs + - Whether incremental review is possible + - List of new commits since last review + - List of changed files with status indicators + - Recommended review strategy + +.EXAMPLE + .\Test-IncrementalReview.ps1 -PullRequestNumber 42374 + Tests incremental review detection for PR #42374. + +.EXAMPLE + .\Test-IncrementalReview.ps1 -PullRequestNumber 42374 -RepositoryOwner "myorg" -RepositoryName "myrepo" + Tests incremental review for a PR in a different repository. + +.NOTES + Requires GitHub CLI (gh) to be installed and authenticated. + Run 'gh auth login' if not already authenticated. + + Prerequisites: + - PR review folder must exist at "Generated Files\prReview\{PRNumber}" + - 00-OVERVIEW.md must exist in the review folder + - For incremental detection, overview must contain "Last reviewed SHA" metadata + +.LINK + https://cli.github.com/ +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, HelpMessage = "Pull request number to test")] + [int]$PullRequestNumber, + + [Parameter(Mandatory = $false, HelpMessage = "Repository owner")] + [string]$RepositoryOwner = "microsoft", + + [Parameter(Mandatory = $false, HelpMessage = "Repository name")] + [string]$RepositoryName = "PowerToys" +) + +# Resolve paths to review folder and overview file +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$reviewFolderPath = Join-Path $repositoryRoot "Generated Files\prReview\$PullRequestNumber" +$overviewFilePath = Join-Path $reviewFolderPath "00-OVERVIEW.md" + +Write-Host "=== Testing Incremental Review for PR #$PullRequestNumber ===" -ForegroundColor Cyan +Write-Host "" + +# Check if review folder exists +if (-not (Test-Path $reviewFolderPath)) { + Write-Host "❌ Review folder not found: $reviewFolderPath" -ForegroundColor Red + Write-Host "This appears to be a new review (iteration 1)" -ForegroundColor Yellow + exit 0 +} + +# Check if overview file exists +if (-not (Test-Path $overviewFilePath)) { + Write-Host "❌ Overview file not found: $overviewFilePath" -ForegroundColor Red + Write-Host "This appears to be an incomplete review" -ForegroundColor Yellow + exit 0 +} + +# Read overview file and extract last reviewed SHA +Write-Host "📄 Reading overview file..." -ForegroundColor Green +$overviewFileContent = Get-Content $overviewFilePath -Raw + +if ($overviewFileContent -match '\*\*Last reviewed SHA:\*\*\s+(\w+)') { + $lastReviewedSha = $Matches[1] + Write-Host "✅ Found last reviewed SHA: $lastReviewedSha" -ForegroundColor Green +} else { + Write-Host "⚠️ No 'Last reviewed SHA' found in overview - this may be an old format" -ForegroundColor Yellow + Write-Host "Proceeding without incremental detection (full review will be needed)" -ForegroundColor Yellow + exit 0 +} + +Write-Host "" +Write-Host "🔍 Running incremental change detection..." -ForegroundColor Cyan + +# Call the incremental changes detection script +$incrementalChangesScriptPath = Join-Path $PSScriptRoot "Get-PrIncrementalChanges.ps1" +if (-not (Test-Path $incrementalChangesScriptPath)) { + Write-Host "❌ Script not found: $incrementalChangesScriptPath" -ForegroundColor Red + exit 1 +} + +try { + $analysisResult = & $incrementalChangesScriptPath ` + -PullRequestNumber $PullRequestNumber ` + -LastReviewedCommitSha $lastReviewedSha ` + -RepositoryOwner $RepositoryOwner ` + -RepositoryName $RepositoryName | ConvertFrom-Json + + # Display analysis results + Write-Host "" + Write-Host "=== Incremental Review Analysis ===" -ForegroundColor Cyan + Write-Host "Current HEAD SHA: $($analysisResult.CurrentHeadSha)" -ForegroundColor White + Write-Host "Last reviewed SHA: $($analysisResult.LastReviewedSha)" -ForegroundColor White + Write-Host "Base branch: $($analysisResult.BaseRefName)" -ForegroundColor White + Write-Host "Head branch: $($analysisResult.HeadRefName)" -ForegroundColor White + Write-Host "" + Write-Host "Is incremental? $($analysisResult.IsIncremental)" -ForegroundColor $(if ($analysisResult.IsIncremental) { "Green" } else { "Yellow" }) + Write-Host "Need full review? $($analysisResult.NeedFullReview)" -ForegroundColor $(if ($analysisResult.NeedFullReview) { "Yellow" } else { "Green" }) + Write-Host "" + Write-Host "Summary: $($analysisResult.Summary)" -ForegroundColor Cyan + Write-Host "" + + # Display new commits if any + if ($analysisResult.NewCommits -and $analysisResult.NewCommits.Count -gt 0) { + Write-Host "📝 New commits ($($analysisResult.NewCommits.Count)):" -ForegroundColor Green + foreach ($commit in $analysisResult.NewCommits) { + Write-Host " - $($commit.Sha): $($commit.Message)" -ForegroundColor Gray + } + Write-Host "" + } + + # Display changed files if any + if ($analysisResult.ChangedFiles -and $analysisResult.ChangedFiles.Count -gt 0) { + Write-Host "📁 Changed files ($($analysisResult.ChangedFiles.Count)):" -ForegroundColor Green + foreach ($file in $analysisResult.ChangedFiles) { + $statusDisplayColor = switch ($file.Status) { + "added" { "Green" } + "removed" { "Red" } + "modified" { "Yellow" } + "renamed" { "Cyan" } + default { "White" } + } + Write-Host " - [$($file.Status)] $($file.Filename) (+$($file.Additions)/-$($file.Deletions))" -ForegroundColor $statusDisplayColor + } + Write-Host "" + } + + # Suggest review strategy based on analysis + Write-Host "=== Recommended Review Strategy ===" -ForegroundColor Cyan + if ($analysisResult.NeedFullReview) { + Write-Host "🔄 Full review recommended" -ForegroundColor Yellow + } elseif ($analysisResult.IsIncremental -and ($analysisResult.ChangedFiles.Count -eq 0)) { + Write-Host "✅ No changes detected - no review needed" -ForegroundColor Green + } elseif ($analysisResult.IsIncremental) { + Write-Host "⚡ Incremental review possible - review only changed files" -ForegroundColor Green + Write-Host "💡 Consider applying smart step filtering based on file types" -ForegroundColor Cyan + } + +} catch { + Write-Host "❌ Error running incremental change detection: $_" -ForegroundColor Red + exit 1 +} diff --git a/.github/review-tools/review-tools.instructions.md b/.github/review-tools/review-tools.instructions.md new file mode 100644 index 0000000000..e70e0c02d6 --- /dev/null +++ b/.github/review-tools/review-tools.instructions.md @@ -0,0 +1,313 @@ +--- +description: PowerShell scripts for efficient PR reviews in PowerToys repository +applyTo: '**' +--- + +# PR Review Tools - Reference Guide + +PowerShell scripts to support efficient and incremental pull request reviews in the PowerToys repository. + +## Quick Start + +### Prerequisites +- PowerShell 7+ (or Windows PowerShell 5.1+) +- GitHub CLI (`gh`) installed and authenticated (`gh auth login`) +- Access to the PowerToys repository + +### Testing Your Setup + +Run the full test suite (recommended): +```powershell +cd "d:\PowerToys-00c1\.github\review-tools" +.\Run-ReviewToolsTests.ps1 +``` + +Expected: 9-10 tests passing + +### Individual Script Tests + +**Test incremental change detection:** +```powershell +.\Get-PrIncrementalChanges.ps1 -PullRequestNumber 42374 +``` +Expected: JSON output showing review analysis + +**Preview incremental review:** +```powershell +.\Test-IncrementalReview.ps1 -PullRequestNumber 42374 +``` +Expected: Analysis showing current vs last reviewed SHA + +**Fetch file content:** +```powershell +.\Get-GitHubRawFile.ps1 -FilePath "README.md" -GitReference "main" +``` +Expected: README content displayed + +**Get PR file patch:** +```powershell +.\Get-GitHubPrFilePatch.ps1 -PullRequestNumber 42374 -FilePath ".github/actions/spell-check/expect.txt" +``` +Expected: Unified diff output + +## Available Scripts + +### Get-GitHubRawFile.ps1 + +Downloads and displays file content from a GitHub repository at a specific git reference. + +**Purpose:** Retrieve baseline file content for comparison during PR reviews. + +**Parameters:** +- `FilePath` (required): Relative path to file in repository +- `GitReference` (optional): Git ref (branch, tag, SHA). Default: "main" +- `RepositoryOwner` (optional): Repository owner. Default: "microsoft" +- `RepositoryName` (optional): Repository name. Default: "PowerToys" +- `ShowLineNumbers` (switch): Prefix each line with line number +- `StartLineNumber` (optional): Starting line number when using `-ShowLineNumbers`. Default: 1 + +**Usage:** +```powershell +.\Get-GitHubRawFile.ps1 -FilePath "src/runner/main.cpp" -GitReference "main" -ShowLineNumbers +``` + +### Get-GitHubPrFilePatch.ps1 + +Fetches the unified diff (patch) for a specific file in a pull request. + +**Purpose:** Get the exact changes made to a file in a PR for detailed review. + +**Parameters:** +- `PullRequestNumber` (required): Pull request number +- `FilePath` (required): Relative path to file in the PR +- `RepositoryOwner` (optional): Repository owner. Default: "microsoft" +- `RepositoryName` (optional): Repository name. Default: "PowerToys" + +**Usage:** +```powershell +.\Get-GitHubPrFilePatch.ps1 -PullRequestNumber 42374 -FilePath "src/modules/cmdpal/main.cpp" +``` + +**Output:** Unified diff showing changes made to the file. + +### Get-PrIncrementalChanges.ps1 + +Compares the last reviewed commit with the current PR head to identify incremental changes. + +**Purpose:** Enable efficient incremental reviews by detecting what changed since the last review iteration. + +**Parameters:** +- `PullRequestNumber` (required): Pull request number +- `LastReviewedCommitSha` (optional): SHA of the commit that was last reviewed. If omitted, assumes first review. +- `RepositoryOwner` (optional): Repository owner. Default: "microsoft" +- `RepositoryName` (optional): Repository name. Default: "PowerToys" + +**Usage:** +```powershell +.\Get-PrIncrementalChanges.ps1 -PullRequestNumber 42374 -LastReviewedCommitSha "abc123def456" +``` + +**Output:** JSON object with detailed change analysis: +```json +{ + "PullRequestNumber": 42374, + "CurrentHeadSha": "xyz789abc123", + "LastReviewedSha": "abc123def456", + "IsIncremental": true, + "NeedFullReview": false, + "ChangedFiles": [ + { + "Filename": "src/modules/cmdpal/main.cpp", + "Status": "modified", + "Additions": 15, + "Deletions": 8, + "Changes": 23 + } + ], + "NewCommits": [ + { + "Sha": "def456", + "Message": "Fix memory leak", + "Author": "John Doe", + "Date": "2025-11-07T10:30:00Z" + } + ], + "Summary": "Incremental review: 1 new commit(s), 1 file(s) changed since SHA abc123d" +} +``` + +**Scenarios Handled:** +- **No LastReviewedCommitSha**: Returns `NeedFullReview: true` (first review) +- **SHA matches current HEAD**: Returns empty `ChangedFiles` (no changes) +- **Force-push detected**: Returns `NeedFullReview: true` (SHA not in history) +- **Incremental changes**: Returns list of changed files and new commits + +### Test-IncrementalReview.ps1 + +Helper script to test and preview incremental review detection before running the full review. + +**Purpose:** Validate incremental review functionality and preview what changed. + +**Parameters:** +- `PullRequestNumber` (required): Pull request number +- `RepositoryOwner` (optional): Repository owner. Default: "microsoft" +- `RepositoryName` (optional): Repository name. Default: "PowerToys" + +**Usage:** +```powershell +.\Test-IncrementalReview.ps1 -PullRequestNumber 42374 +``` + +**Output:** Colored console output showing: +- Current and last reviewed SHAs +- Whether incremental review is possible +- List of new commits and changed files +- Recommended review strategy + +## Workflow Integration + +These scripts integrate with the PR review prompt (`.github/prompts/review-pr.prompt.md`). + +### Typical Review Flow + +1. **Initial Review (Iteration 1)** + - Review prompt processes the PR + - Creates `Generated Files/prReview/{PR}/00-OVERVIEW.md` + - Includes review metadata section with current HEAD SHA + +2. **Subsequent Reviews (Iteration 2+)** + - Review prompt reads `00-OVERVIEW.md` to get last reviewed SHA + - Calls `Get-PrIncrementalChanges.ps1` to detect what changed + - If incremental: + - Reviews only changed files + - Skips irrelevant review steps (e.g., skip Localization if no `.resx` files changed) + - Uses `Get-GitHubPrFilePatch.ps1` to get patches for changed files + - Updates `00-OVERVIEW.md` with new SHA and iteration number + +### Manual Testing Workflow + +Preview changes before review: +```powershell +# Check what changed in PR #42374 since last review +.\Test-IncrementalReview.ps1 -PullRequestNumber 42374 + +# Get incremental changes programmatically +$changes = .\Get-PrIncrementalChanges.ps1 -PullRequestNumber 42374 -LastReviewedCommitSha "abc123" | ConvertFrom-Json + +if (-not $changes.NeedFullReview) { + Write-Host "Only need to review $($changes.ChangedFiles.Count) files" + + # Review each changed file + foreach ($file in $changes.ChangedFiles) { + Write-Host "Reviewing $($file.Filename)..." + .\Get-GitHubPrFilePatch.ps1 -PullRequestNumber 42374 -FilePath $file.Filename + } +} +``` + +## Error Handling and Troubleshooting + +### Common Requirements + +All scripts: +- Exit with code 1 on error +- Write detailed error messages to stderr +- Require `gh` CLI to be installed and authenticated + +### Common Issues + +**Error: "gh not found"** +- **Solution**: Install GitHub CLI from https://cli.github.com/ and run `gh auth login` + +**Error: "Failed to query GitHub API"** +- **Solution**: Verify `gh` authentication with `gh auth status` +- **Solution**: Check PR number exists and you have repository access + +**Error: "PR not found"** +- **Solution**: Verify the PR number is correct and still exists +- **Solution**: Ensure repository owner and name are correct + +**Error: "SHA not found" or "Force-push detected"** +- **Explanation**: Last reviewed SHA no longer exists in branch history (force-push occurred) +- **Solution**: A full review is required; incremental review not possible + +**Tests show "FAIL" but functionality works** +- **Explanation**: Some tests may show exit code failures even when logic is correct +- **Solution**: Check test output message - if it says "Correctly detected", functionality is working + +**Error: "Could not find insertion point"** +- **Explanation**: Overview file doesn't have expected "**Changed files:**" line +- **Solution**: Verify overview file format is correct or regenerate it + +### Verification Checklist + +After setup, verify: +- [ ] `Run-ReviewToolsTests.ps1` shows 9+ tests passing +- [ ] `Get-PrIncrementalChanges.ps1` returns valid JSON +- [ ] `Test-IncrementalReview.ps1` analyzes a PR without errors +- [ ] `Get-GitHubRawFile.ps1` downloads files correctly +- [ ] `Get-GitHubPrFilePatch.ps1` retrieves patches correctly + +## Best Practices + +### For Review Authors + +1. **Test before full review**: Use `Test-IncrementalReview.ps1` to preview changes +2. **Check for force-push**: Review the analysis output - force-pushes require full reviews +3. **Smart step filtering**: Skip review steps for file types that didn't change + +### For Script Users + +1. **Use absolute paths**: When specifying folders, use absolute paths to avoid ambiguity +2. **Check exit codes**: Scripts exit with code 1 on error - check `$LASTEXITCODE` in automation +3. **Parse JSON output**: Use `ConvertFrom-Json` to work with structured output from `Get-PrIncrementalChanges.ps1` +4. **Handle empty results**: Check `ChangedFiles.Count` before iterating + +### Performance Tips + +1. **Batch operations**: When reviewing multiple PRs, collect all PR numbers and process in batch +2. **Cache raw files**: Download baseline files once and reuse for multiple comparisons +3. **Filter early**: Use incremental detection to skip unnecessary file reviews +4. **Parallel processing**: Consider processing independent PRs in parallel + +## Integration with AI Review Systems + +These tools are designed to work with AI-powered review systems: + +1. **Copilot Instructions**: This file serves as reference documentation for GitHub Copilot +2. **Structured Output**: JSON output from scripts is easily parsed by AI systems +3. **Incremental Intelligence**: AI can focus on changed files for more efficient reviews +4. **Metadata Tracking**: Review iterations are tracked for context-aware suggestions + +### Example AI Integration + +```powershell +# Get incremental changes +$analysis = .\Get-PrIncrementalChanges.ps1 -PullRequestNumber $PR | ConvertFrom-Json + +# Feed to AI review system +$reviewPrompt = @" +Review the following changed files in PR #$PR: +$($analysis.ChangedFiles | ForEach-Object { "- $($_.Filename) ($($_.Status))" } | Out-String) + +Focus on incremental changes only. Previous review was at SHA $($analysis.LastReviewedSha). +"@ + +# Execute AI review with context +Invoke-AIReview -Prompt $reviewPrompt -Files $analysis.ChangedFiles +``` + +## Support and Further Information + +For detailed script documentation, use PowerShell's help system: +```powershell +Get-Help .\Get-PrIncrementalChanges.ps1 -Full +Get-Help .\Test-IncrementalReview.ps1 -Detailed +``` + +Related documentation: +- `.github/prompts/review-pr.prompt.md` - Complete review workflow guide +- `doc/devdocs/` - PowerToys development documentation +- GitHub CLI documentation: https://cli.github.com/manual/ + +For issues or questions, refer to the PowerToys contribution guidelines. diff --git a/.github/skills/release-note-generation/LICENSE.txt b/.github/skills/release-note-generation/LICENSE.txt new file mode 100644 index 0000000000..c9766a251f --- /dev/null +++ b/.github/skills/release-note-generation/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Microsoft Corporation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/.github/skills/release-note-generation/SKILL.md b/.github/skills/release-note-generation/SKILL.md new file mode 100644 index 0000000000..e27c347b4d --- /dev/null +++ b/.github/skills/release-note-generation/SKILL.md @@ -0,0 +1,132 @@ +--- +name: release-note-generation +description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, request Copilot reviews for PRs, update README for a new release, manage PR milestones, or collect PRs between commits/tags. Supports PR collection by milestone or commit range, milestone assignment, grouping by label, summarization with external contributor attribution, and README version bumping. +license: Complete terms in LICENSE.txt +--- + +# Release Note Generation Skill + +Generate professional release notes for PowerToys milestones by collecting merged PRs, requesting Copilot code reviews, grouping by label, and producing user-facing summaries. + +## Output Directory + +All generated artifacts are placed under `Generated Files/ReleaseNotes/` at the repository root (gitignored). + +``` +Generated Files/ReleaseNotes/ +├── milestone_prs.json # Raw PR data from GitHub +├── sorted_prs.csv # Sorted PR list with Copilot summaries +├── prs_with_milestone.csv # Milestone assignment tracking +├── grouped_csv/ # PRs grouped by label (one CSV per label) +├── grouped_md/ # Generated markdown summaries per label +└── v{VERSION}-release-notes.md # Final consolidated release notes +``` + +## When to Use This Skill + +- Generate release notes for a milestone +- Summarize PRs merged in a release +- Request Copilot reviews for milestone PRs +- Assign milestones to PRs missing them +- Collect PRs between two commits/tags +- Update README.md for a new version + +## Prerequisites + +- GitHub CLI (`gh`) installed and authenticated +- MCP Server: github-mcp-server installed +- GitHub Copilot code review enabled for the org/repo + +## Required Variables + +⚠️ **Before starting**, confirm `{{ReleaseVersion}}` with the user. If not provided, **ASK**: "What release version are we generating notes for? (e.g., 0.98)" + +| Variable | Description | Example | +|----------|-------------|---------| +| `{{ReleaseVersion}}` | Target release version | `0.98` | + +## Workflow Overview + +``` +┌────────────────────────────────┐ +│ 1.1 Collect PRs (stable range) │ +└────────────────────────────────┘ + ↓ +┌────────────────────────────────┐ +│ 1.2 Assign Milestones │ +└────────────────────────────────┘ + ↓ +┌────────────────────────────────┐ +│ 2.1–2.4 Label PRs (auto+human) │ +└────────────────────────────────┘ + ↓ +┌────────────────────────────────┐ +│ 3.1 Request Reviews (Copilot) │ +└────────────────────────────────┘ + ↓ +┌────────────────────────────────┐ +│ 3.2 Refresh PR data │ +│ (CopilotSummary) │ +└────────────────────────────────┘ + ↓ +┌────────────────────────────────┐ +│ 3.3 Group by label │ +│ (grouped_csv) │ +└────────────────────────────────┘ + ↓ +┌────────────────────────────────┐ +│ 4.1 Summarize (grouped_md) │ +└────────────────────────────────┘ + ↓ +┌────────────────────────────────┐ +│ 4.2 Final notes (v{VERSION}.md) │ +└────────────────────────────────┘ +``` + +| Step | Action | Details | +|------|--------|---------| +| 1.1 | Collect PRs | From previous release tag on `stable` branch → `sorted_prs.csv` | +| 1.2 | Assign Milestones | Ensure all PRs have correct milestone | +| 2.1–2.4 | Label PRs | Auto-suggest + human label low-confidence | +| 3.1–3.3 | Reviews & Grouping | Request Copilot reviews → refresh → group by label | +| 4.1–4.2 | Summaries & Final | Generate grouped summaries, then consolidate | + +## Detailed workflow docs + +Do not read all steps at once—only read the step you are executing. + +- [Step 1: Collection & Milestones](./references/step1-collection.md) +- [Step 2: Labeling PRs](./references/step2-labeling.md) +- [Step 3: Reviews & Grouping](./references/step3-review-grouping.md) +- [Step 4: Summarization](./references/step4-summarization.md) + + +## Available Scripts + +| Script | Purpose | +|--------|---------| +| [dump-prs-since-commit.ps1](./scripts/dump-prs-since-commit.ps1) | Fetch PRs between commits/tags | +| [group-prs-by-label.ps1](./scripts/group-prs-by-label.ps1) | Group PRs into CSVs | +| [collect-or-apply-milestones.ps1](./scripts/collect-or-apply-milestones.ps1) | Assign milestones | +| [diff_prs.ps1](./scripts/diff_prs.ps1) | Incremental PR diff | + +## References + +- [Sample Output](./references/SampleOutput.md) - Example summary formatting +- [Detailed Instructions](./references/Instruction.md) - Legacy full documentation + +## Conventions + +- **Terminal usage**: Disabled by default; only run scripts when user explicitly requests +- **Batch generation**: Generate ALL grouped_md files in one pass, then human reviews +- **PR order**: Preserve order from `sorted_prs.csv` in all outputs +- **Label filtering**: Keeps `Product-*`, `Area-*`, `GitHub*`, `*Plugin`, `Issue-*` + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| `gh` command not found | Install GitHub CLI and add to PATH | +| No PRs returned | Verify milestone title matches exactly | +| Empty CopilotSummary | Request Copilot reviews first, then re-run dump | +| Many unlabeled PRs | Return to labeling step before grouping | diff --git a/.github/skills/release-note-generation/references/SampleOutput.md b/.github/skills/release-note-generation/references/SampleOutput.md new file mode 100644 index 0000000000..f10c65ab0a --- /dev/null +++ b/.github/skills/release-note-generation/references/SampleOutput.md @@ -0,0 +1,9 @@ + - Added mouse button actions so you can choose what left, right, or middle click does. Thanks [@PesBandi](https://github.com/PesBandi)! + + - Aligned window styling with current Windows theme for a cleaner look. Thanks [@sadirano](https://github.com/sadirano)! + + - Ensured screen readers are notified when the selected item in the list changes for better accessibility. + + - Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours. + + - Fixed Alt+Left Arrow navigation not working when search box contains text. Thanks [@jiripolasek](https://github.com/jiripolasek)! \ No newline at end of file diff --git a/.github/skills/release-note-generation/references/step1-collection.md b/.github/skills/release-note-generation/references/step1-collection.md new file mode 100644 index 0000000000..a084b17f1d --- /dev/null +++ b/.github/skills/release-note-generation/references/step1-collection.md @@ -0,0 +1,143 @@ +# Step 1: Collection and Milestones + +## 1.0 To-do +- 1.0.1 Generate MemberList.md (REQUIRED) +- 1.1 Collect PRs +- 1.2 Assign Milestones (REQUIRED) + +## Required Variables + +⚠️ **Before starting**, confirm these values with the user: + +| Variable | Description | Example | +|----------|-------------|---------| +| `{{ReleaseVersion}}` | Target release version | `0.97` | +| `{{PreviousReleaseTag}}` | Previous release tag from releases page | `v0.96.1` | + +**If user hasn't specified `{{ReleaseVersion}}`, ASK:** "What release version are we generating notes for? (e.g., 0.97)" + +**`{{PreviousReleaseTag}}` is derived from the releases page, not user input.** Use the latest published release tag (top of the page). You will use its tag name and tag commit SHA in Step 1. + +--- + +## 1.0.1 Generate MemberList.md (REQUIRED) + +Create `Generated Files/ReleaseNotes/MemberList.md` from the **PowerToys core team** section in [COMMUNITY.md](../../../COMMUNITY.md). + +Rules: +- One GitHub username per line, **no** `@` prefix. +- Use the usernames exactly as listed in the core team section. +- Do not include former team members or other sections. + +Example (format only): +``` +example-user +another-user +``` + +--- + +## 1.1 Collect PRs + +### 1.1.1 Get the previous release commit + +1. Open the [PowerToys releases page](https://github.com/microsoft/PowerToys/releases/) +2. Find the latest release (e.g., v0.96.1, which should be at the top) +3. Set `{{PreviousReleaseTag}}` to that tag name (e.g., `v0.96.1`) +4. Copy the full tag commit SHA as `{{SHALastRelease}}` + + +**If the release SHA is not in your branch history:** Use the helper script to find an equivalent commit on the target branch by matching the commit title: + +```powershell +pwsh ./.github/skills/release-note-generation/scripts/find-commit-by-title.ps1 ` + -Commit '{{SHALastRelease}}' ` + -Branch 'stable' +``` + +### 1.1.2 Run collection script against stable branch + +```powershell +# Collect PRs from previous release to current HEAD of stable branch +pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 ` + -StartCommit '{{SHALastRelease}}' ` + -Branch 'stable' ` + -OutputDir 'Generated Files/ReleaseNotes' +``` + +**Parameters:** +- `-StartCommit` - Previous release tag or commit SHA (exclusive) +- `-Branch` - Always use `stable` branch, not `main` (script uses `origin/stable` as the end ref) +- `-EndCommit` - Optional override if you need a custom end ref +- `-OutputDir` - Output directory for generated files + +**Reliability check:** If the script reports “No commits found”, the stable branch has not moved since the last release. In that case, either: +- Confirm this is expected and stop (no new release notes), or +- Re-run against `main` to gather pending changes for the next release cycle. + +The script detects both merge commits (`Merge pull request #12345`) and squash commits (`Feature (#12345)`). + +**Output** (in `Generated Files/ReleaseNotes/`): +- `milestone_prs.json` - raw PR data +- `sorted_prs.csv` - sorted PR list with columns: Id, Title, Labels, Author, Url, Body, CopilotSummary, NeedThanks + +--- + +## 1.2 Assign Milestones (REQUIRED) + +**Before generating release notes**, ensure all collected PRs have the correct milestone assigned. + +⚠️ **CRITICAL:** Do NOT proceed to labeling until all PRs have milestones assigned. + +### 1.2.1 Check current milestone status (dry run) + +```powershell +# Dry run first to see what would be changed: +pwsh ./.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 ` + -InputCsv 'Generated Files/ReleaseNotes/sorted_prs.csv' ` + -OutputCsv 'Generated Files/ReleaseNotes/prs_with_milestone.csv' ` + -DefaultMilestone 'PowerToys {{ReleaseVersion}}' ` + -ApplyMissing -WhatIf +``` + +This queries GitHub for each PR's current milestone and shows which PRs would be updated. + +### 1.2.2 Apply milestones to PRs missing them + +```powershell +# Apply for real: +pwsh ./.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 ` + -InputCsv 'Generated Files/ReleaseNotes/sorted_prs.csv' ` + -OutputCsv 'Generated Files/ReleaseNotes/prs_with_milestone.csv' ` + -DefaultMilestone 'PowerToys {{ReleaseVersion}}' ` + -ApplyMissing +``` + +**Script Behavior:** +- Queries each PR's current milestone from GitHub +- PRs that already have a milestone are **skipped** (not overwritten) +- PRs missing a milestone get the default milestone applied +- Outputs `prs_with_milestone.csv` with (Id, Milestone) columns +- Produces summary: `Updated=X Skipped=Y Failed=Z` + +**Validation:** After assignment, all PRs in `prs_with_milestone.csv` should have the target milestone. + +--- + +## Additional Commands + +### Collect milestones only (no changes to GitHub) +```powershell +pwsh ./.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 ` + -InputCsv 'Generated Files/ReleaseNotes/sorted_prs.csv' ` + -OutputCsv 'Generated Files/ReleaseNotes/prs_with_milestone.csv' +``` + +### Local assignment only (fill blanks in CSV, no GitHub changes) +```powershell +pwsh ./.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 ` + -InputCsv 'Generated Files/ReleaseNotes/sorted_prs.csv' ` + -OutputCsv 'Generated Files/ReleaseNotes/prs_with_milestone.csv' ` + -DefaultMilestone 'PowerToys {{ReleaseVersion}}' ` + -LocalAssign +``` diff --git a/.github/skills/release-note-generation/references/step2-labeling.md b/.github/skills/release-note-generation/references/step2-labeling.md new file mode 100644 index 0000000000..125b9fb90a --- /dev/null +++ b/.github/skills/release-note-generation/references/step2-labeling.md @@ -0,0 +1,131 @@ +# Step 2: Label Unlabeled PRs + +## 2.0 To-do +- 2.1 Identify unlabeled PRs (Agent Mode) +- 2.2 Suggest labels (Agent Mode) +- 2.3 Human label low-confidence PRs +- 2.4 Recheck labels, delete Unlabeled.csv, and re-collect + +**Before grouping**, ensure all PRs have appropriate labels for categorization. + +⚠️ **CRITICAL:** Do NOT proceed to grouping until all PRs have labels assigned. PRs without labels will end up in `Unlabeled.csv` and won't appear in the correct release note sections. + +## 2.1 Identify unlabeled PRs (Agent Mode) + +Read `sorted_prs.csv` and identify PRs with empty or missing `Labels` column. + +For each unlabeled PR, analyze: +- **Title** - Often contains module name or feature +- **Body** - PR description with context +- **CopilotSummary** - AI-generated summary of changes + +## 2.2 Suggest labels (Agent Mode) + +For each unlabeled PR, suggest an appropriate label based on the content analysis. + +**Output:** Create `Generated Files/ReleaseNotes/prs_label_review.md` with the following format: + +```markdown +# PR Label Review + +Generated: YYYY-MM-DD HH:mm:ss + +## Summary +- Total unlabeled PRs: X +- High confidence: X +- Medium confidence: X +- Low confidence: X + +--- + +## PRs Needing Review (sorted by confidence, low first) + +| PR | Title | Suggested Label | Confidence | Reason | +|----|-------|-----------------|------------|--------| +| [#12347](url) | Some generic fix | ??? | Low | Unclear from content | +| [#12346](url) | Update dependencies | `Area-Build` | Medium | Body mentions NuGet packages | +``` + +Sort by confidence (low first) so human reviews uncertain ones first. + +After writing `prs_label_review.md`, **generate `prs_to_label.csv`, apply labels, and re-run collection** so the CSV/labels stay in sync: + +```powershell +# Generate CSV from suggestions (agent) +# Apply labels +pwsh ./.github/skills/release-note-generation/scripts/apply-labels.ps1 ` + -InputCsv 'Generated Files/ReleaseNotes/prs_to_label.csv' + +# Refresh collection +pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 ` + -StartCommit '{{PreviousReleaseTag}}' -Branch 'stable' ` + -OutputDir 'Generated Files/ReleaseNotes' +``` + +## 2.3 Human label low-confidence PRs + +Ask the human to label **low-confidence** PRs directly (in GitHub). Skip any they decide not to label. + +## 2.4 Recheck labels, delete Unlabeled.csv, and re-collect + +Recheck that all PRs now have labels. Delete `Unlabeled.csv` (if present), then re-run the collection script to update `sorted_prs.csv`: + +```powershell +# Remove stale unlabeled output if it exists +Remove-Item 'Generated Files/ReleaseNotes/Unlabeled.csv' -ErrorAction SilentlyContinue +``` + +```powershell +pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 ` + -StartCommit '{{PreviousReleaseTag}}' -Branch 'stable' ` + -OutputDir 'Generated Files/ReleaseNotes' +``` + +--- + +## Common Label Mappings + +| Keywords/Patterns | Suggested Label | +| ----------------- | --------------- | +| Advanced Paste, AP, clipboard, paste | `Product-Advanced Paste` | +| CmdPal, Command Palette, cmdpal | `Product-Command Palette` | +| FancyZones, zones, layout | `Product-FancyZones` | +| ZoomIt, zoom, screen annotation | `Product-ZoomIt` | +| Settings, settings-ui, Quick Access, flyout | `Product-Settings` | +| Installer, setup, MSI, MSIX, WiX | `Area-Setup/Install` | +| Build, pipeline, CI/CD, msbuild | `Area-Build` | +| Test, unit test, UI test, fuzz | `Area-Tests` | +| Localization, loc, translation, resw | `Area-Localization` | +| Foundry, AI, LLM | `Product-Advanced Paste` (AI features) | +| Mouse Without Borders, MWB | `Product-Mouse Without Borders` | +| PowerRename, rename, regex | `Product-PowerRename` | +| Peek, preview, file preview | `Product-Peek` | +| Image Resizer, resize | `Product-Image Resizer` | +| LightSwitch, theme, dark mode | `Product-LightSwitch` | +| Quick Accent, accent, diacritics | `Product-Quick Accent` | +| Awake, keep awake, caffeine | `Product-Awake` | +| ColorPicker, color picker, eyedropper | `Product-ColorPicker` | +| Hosts, hosts file | `Product-Hosts` | +| Keyboard Manager, remap | `Product-Keyboard Manager` | +| Mouse Highlighter | `Product-Mouse Highlighter` | +| Mouse Jump | `Product-Mouse Jump` | +| Find My Mouse | `Product-Find My Mouse` | +| Mouse Pointer Crosshairs | `Product-Mouse Pointer Crosshairs` | +| Shortcut Guide | `Product-Shortcut Guide` | +| Text Extractor, OCR, PowerOCR | `Product-Text Extractor` | +| Workspaces | `Product-Workspaces` | +| File Locksmith | `Product-File Locksmith` | +| Crop And Lock | `Product-CropAndLock` | +| Environment Variables | `Product-Environment Variables` | +| New+ | `Product-New+` | + +## Label Filtering Rules + +The grouping script keeps labels matching these patterns: +- `Product-*` +- `Area-*` +- `GitHub*` +- `*Plugin` +- `Issue-*` + +Other labels are ignored for grouping purposes. diff --git a/.github/skills/release-note-generation/references/step3-review-grouping.md b/.github/skills/release-note-generation/references/step3-review-grouping.md new file mode 100644 index 0000000000..14186114cd --- /dev/null +++ b/.github/skills/release-note-generation/references/step3-review-grouping.md @@ -0,0 +1,37 @@ +# Step 3: Copilot Reviews and Grouping + +## 3.0 To-do +- 3.1 Request Copilot Reviews (Agent Mode) +- 3.2 Refresh PR Data +- 3.3 Group PRs by Label + +## 3.1 Request Copilot Reviews (Agent Mode) + +Use MCP tools to request Copilot reviews for all PRs in `Generated Files/ReleaseNotes/sorted_prs.csv`: + +- Use `mcp_github_request_copilot_review` for each PR ID +- Do NOT generate or run scripts for this step + +--- + +## 3.2 Refresh PR Data + +Re-run the collection script to capture Copilot review summaries into the `CopilotSummary` column: + +```powershell +pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 ` + -StartCommit '{{PreviousReleaseTag}}' -Branch 'stable' ` + -OutputDir 'Generated Files/ReleaseNotes' +``` + +--- + +## 3.3 Group PRs by Label + +```powershell +pwsh ./.github/skills/release-note-generation/scripts/group-prs-by-label.ps1 -CsvPath 'Generated Files/ReleaseNotes/sorted_prs.csv' -OutDir 'Generated Files/ReleaseNotes/grouped_csv' +``` + +Creates `Generated Files/ReleaseNotes/grouped_csv/` with one CSV per label combination. + +**Validation:** The `Unlabeled.csv` file should be minimal (ideally empty). If many PRs remain unlabeled, return to Step 2 (see [step2-labeling.md](./step2-labeling.md)). diff --git a/.github/skills/release-note-generation/references/step4-summarization.md b/.github/skills/release-note-generation/references/step4-summarization.md new file mode 100644 index 0000000000..b14c47490f --- /dev/null +++ b/.github/skills/release-note-generation/references/step4-summarization.md @@ -0,0 +1,88 @@ +# Step 4: Summaries and Final Release Notes + +## 4.0 To-do +- 4.1 Generate Summary Markdown (Agent Mode) +- 4.2 Produce Final Release Notes File + +## 4.1 Generate Summary Markdown (Agent Mode) + +For each CSV in `Generated Files/ReleaseNotes/grouped_csv/`, create a markdown file in `Generated Files/ReleaseNotes/grouped_md/`. + +⚠️ **IMPORTANT:** Generate **ALL** markdown files first. Do NOT pause between files or ask for feedback during generation. Complete the entire batch, then human reviews afterwards. + +### Structure per file + +**1. Bullet list** - one concise, user-facing line per PR: +- Use the “Verb-ed + Scenario + Impact” sentence structure—make readers think, “That’s exactly what I need” or “Yes, that’s an awesome fix.”; The "impact" can be end-user focused (written to convey user excitement) or technical (performance/stability) when user-facing impact is minimal. +- If nothing special on impact or unclear impact, mark as needing human summary +- Source from Title, Body, and CopilotSummary (prefer CopilotSummary when available) +- If the column `NeedThanks` in CSV is `True`, append: `Thanks [@Author](https://github.com/Author)!` +- Do NOT include PR numbers in bullet lines +- Do NOT mention “security” or “privacy” issues, since these are not known and could be leveraged by attackers in earlier versions. Instead, describe the user-facing scenario, usage, or impact. +- If confidence < 70%, write: `Human Summary Needed: <PR full link>` + +**See [SampleOutput.md](./SampleOutput.md) for examples of well-written bullet summaries.** + +**2. Three-column table** (same PR order): +- Column 1: Concise summary (same as bullet) +- Column 2: PR link `[#ID](URL)` +- Column 3: Confidence level (High/Medium/Low) + +### Review Process (AFTER all files generated) + +- Human reviews each `grouped_md/*.md` file and requests rewrites as needed +- Human may say "rewrite Product-X" or "combine these bullets"—apply changes to that specific file +- Do NOT interrupt generation to ask for feedback + +--- + +## 4.2 Produce Final Release Notes File + +Once all `grouped_md/*.md` files are reviewed and approved, consolidate into a single release notes file. + +**Output:** `Generated Files/ReleaseNotes/v{{ReleaseVersion}}-release-notes.md` + +### Structure + +**1. Highlights section** (top): +- 8-12 bullets covering the most user-visible features and impactful fixes +- Pattern: `**Module**: brief description` +- Avoid internal refactors; focus on what users will notice + +**2. Module sections** (alphabetical order): +- One section per product (Advanced Paste, Awake, Command Palette, etc.) +- Migrate bullet summaries from the approved `grouped_md/Product-*.md` files +- One section 'Development' for all the rest summaries from the approved `grouped_md/Area-*.md` files +- Re-review E2E, group release improvements by section, and move the most important items to the top of each section. +Some items in the Development section may overlap and should be moved to the Module section where more applicable. + +### Example Final Structure + +```markdown +# PowerToys v{{ReleaseVersion}} Release Notes + +## Highlights + +- **Command Palette**: Added theme customization and drag-and-drop support +- **Advanced Paste**: Image input for AI, color detection in clipboard history +- **FancyZones**: New CLI tool for command-line layout management +... + +--- + +## Advanced Paste + +- Wrapped paste option lists in a single ScrollViewer +- Added image input handling for AI-powered transformations +... + +## Awake + +- Fixed timed mode expiration. Thanks [@daverayment](https://github.com/daverayment)! +... + +--- + +## Development +... +``` diff --git a/.github/skills/release-note-generation/scripts/apply-labels.ps1 b/.github/skills/release-note-generation/scripts/apply-labels.ps1 new file mode 100644 index 0000000000..ec53d29e4f --- /dev/null +++ b/.github/skills/release-note-generation/scripts/apply-labels.ps1 @@ -0,0 +1,90 @@ +<# +.SYNOPSIS + Apply labels to PRs from a CSV file. + +.DESCRIPTION + Reads a CSV with Id and Label columns and applies the specified label to each PR via GitHub CLI. + Supports dry-run mode to preview changes before applying. + +.PARAMETER InputCsv + CSV file with Id and Label columns. Default: prs_to_label.csv + +.PARAMETER Repo + GitHub repository (owner/name). Default: microsoft/PowerToys + +.PARAMETER WhatIf + Dry run - show what would be applied without making changes. + +.EXAMPLE + pwsh ./apply-labels.ps1 -InputCsv 'Generated Files/ReleaseNotes/prs_to_label.csv' + +.EXAMPLE + pwsh ./apply-labels.ps1 -InputCsv 'Generated Files/ReleaseNotes/prs_to_label.csv' -WhatIf + +.NOTES + Requires: gh CLI authenticated with repo write access. + + Input CSV format: + Id,Label + 12345,Product-Advanced Paste + 12346,Product-Settings +#> +[CmdletBinding()] param( + [Parameter(Mandatory=$false)][string]$InputCsv = 'prs_to_label.csv', + [Parameter(Mandatory=$false)][string]$Repo = 'microsoft/PowerToys', + [switch]$WhatIf +) + +$ErrorActionPreference = 'Stop' + +function Write-Info($m){ Write-Host "[info] $m" -ForegroundColor Cyan } +function Write-Warn($m){ Write-Host "[warn] $m" -ForegroundColor Yellow } +function Write-Err($m){ Write-Host "[error] $m" -ForegroundColor Red } +function Write-OK($m){ Write-Host "[ok] $m" -ForegroundColor Green } + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { Write-Err "GitHub CLI 'gh' not found in PATH"; exit 1 } +if (-not (Test-Path -LiteralPath $InputCsv)) { Write-Err "Input CSV not found: $InputCsv"; exit 1 } + +$rows = Import-Csv -LiteralPath $InputCsv +if (-not $rows) { Write-Info "No rows in CSV."; exit 0 } + +$firstCols = $rows[0].PSObject.Properties.Name +if (-not ($firstCols -contains 'Id' -and $firstCols -contains 'Label')) { + Write-Err "CSV must contain 'Id' and 'Label' columns"; exit 1 +} + +Write-Info "Processing $($rows.Count) label assignments..." +if ($WhatIf) { Write-Warn "DRY RUN - no changes will be made" } + +$applied = 0 +$skipped = 0 +$failed = 0 + +foreach ($row in $rows) { + $id = $row.Id + $label = $row.Label + + if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($label)) { + Write-Warn "Skipping row with empty Id or Label" + $skipped++ + continue + } + + if ($WhatIf) { + Write-Info "Would apply label '$label' to PR #$id" + $applied++ + continue + } + + try { + gh pr edit $id --repo $Repo --add-label $label 2>&1 | Out-Null + Write-OK "Applied '$label' to PR #$id" + $applied++ + } catch { + Write-Warn "Failed to apply label to PR #${id}: $_" + $failed++ + } +} + +Write-Info "" +Write-Info "Summary: Applied=$applied Skipped=$skipped Failed=$failed" diff --git a/.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 b/.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 new file mode 100644 index 0000000000..d35aa858fc --- /dev/null +++ b/.github/skills/release-note-generation/scripts/collect-or-apply-milestones.ps1 @@ -0,0 +1,172 @@ +<# +.SYNOPSIS + Collect existing PR milestones or (optionally) assign/apply a milestone to missing PRs in one script. + +.DESCRIPTION + This unified script merges the behaviors of the previous add-milestone-column (collector) and + set-milestones-missing (remote updater) scripts. + + Modes (controlled by switches): + 1. Collect (default) – For each PR Id in the input CSV, queries GitHub for the current milestone and + outputs a two-column CSV (Id,Milestone) leaving blanks where none are set. + 2. LocalAssign – Same as Collect, but for rows that end up blank assigns the value of -DefaultMilestone + in memory (does NOT touch GitHub). Useful for quickly preparing a fully populated CSV. + 3. ApplyMissing – After determining which PRs have no milestone, call GitHub API to set their milestone + to -DefaultMilestone. Requires milestone to already exist (open). Network + write. + + You can combine LocalAssign and ApplyMissing: the remote update uses the existing live state; LocalAssign only + affects the output CSV/pipeline objects. + +.PARAMETER InputCsv + Source CSV with at least an Id column. Default: sorted_prs.csv + +.PARAMETER OutputCsv + Destination CSV for collected (and optionally locally assigned) milestones. Default: prs_with_milestone.csv + +.PARAMETER Repo + GitHub repository (owner/name). Default: microsoft/PowerToys + +.PARAMETER DefaultMilestone + Milestone title used when -LocalAssign or -ApplyMissing is specified. Default: 'PowerToys 0.97' + +.PARAMETER Offline + Skip ALL GitHub lookups / updates. Implies Collect-only with all Milestone cells blank (unless LocalAssign). + +.PARAMETER LocalAssign + Populate empty Milestone cells in the output with -DefaultMilestone (does not modify GitHub). + +.PARAMETER ApplyMissing + For PRs which currently have no milestone (live on GitHub), set them to -DefaultMilestone via Issues API. + +.PARAMETER WhatIf + Dry run for ApplyMissing: show intended remote changes without performing PATCH requests. + +.EXAMPLE + # Collect only + pwsh ./collect-or-apply-milestones.ps1 + +.EXAMPLE + # Collect and fill blanks locally in the output only + pwsh ./collect-or-apply-milestones.ps1 -LocalAssign + +.EXAMPLE + # Collect and remotely apply milestone to missing PRs + pwsh ./collect-or-apply-milestones.ps1 -ApplyMissing + +.EXAMPLE + # Dry run remote application + pwsh ./collect-or-apply-milestones.ps1 -ApplyMissing -WhatIf + +.EXAMPLE + # Offline local assignment + pwsh ./collect-or-apply-milestones.ps1 -Offline -LocalAssign -DefaultMilestone 'PowerToys 0.96' + +.NOTES + Requires gh CLI unless -Offline AND -ApplyMissing not specified. + Remote apply path queries milestones to resolve numeric ID. +#> +[CmdletBinding()] param( + [Parameter(Mandatory=$false)][string]$InputCsv = 'sorted_prs.csv', + [Parameter(Mandatory=$false)][string]$OutputCsv = 'prs_with_milestone.csv', + [Parameter(Mandatory=$false)][string]$Repo = 'microsoft/PowerToys', + [Parameter(Mandatory=$false)][string]$DefaultMilestone = 'PowerToys 0.97', + [switch]$Offline, + [switch]$LocalAssign, + [switch]$ApplyMissing, + [switch]$WhatIf +) + +$ErrorActionPreference = 'Stop' +function Write-Info($m){ Write-Host "[info] $m" -ForegroundColor Cyan } +function Write-Warn($m){ Write-Host "[warn] $m" -ForegroundColor Yellow } +function Write-Err($m){ Write-Host "[error] $m" -ForegroundColor Red } + +if (-not (Test-Path -LiteralPath $InputCsv)) { Write-Err "Input CSV not found: $InputCsv"; exit 1 } +$rows = Import-Csv -LiteralPath $InputCsv +if (-not $rows) { Write-Warn "Input CSV has no rows."; @() | Export-Csv -NoTypeInformation -LiteralPath $OutputCsv; exit 0 } +if (-not ($rows[0].PSObject.Properties.Name -contains 'Id')) { Write-Err "Input CSV missing 'Id' column."; exit 1 } + +$needGh = (-not $Offline) -and ($ApplyMissing -or -not $Offline) +if ($needGh -and -not (Get-Command gh -ErrorAction SilentlyContinue)) { Write-Err "GitHub CLI 'gh' not found. Use -Offline or install gh."; exit 1 } + +# Step 1: Collect current milestone titles +$milestoneCache = @{} +$collected = New-Object System.Collections.Generic.List[object] +$idx = 0 +foreach ($row in $rows) { + $idx++ + $id = $row.Id + if (-not $id) { Write-Warn "Row $idx missing Id; skipping"; continue } + $ms = '' + if (-not $Offline) { + if ($milestoneCache.ContainsKey($id)) { $ms = $milestoneCache[$id] } + else { + try { + $json = gh pr view $id --repo $Repo --json milestone 2>$null | ConvertFrom-Json + if ($json -and $json.milestone -and $json.milestone.title) { $ms = $json.milestone.title } + } catch { + Write-Warn "Failed to fetch PR #$id milestone: $_" + } + $milestoneCache[$id] = $ms + } + } + $collected.Add([PSCustomObject]@{ Id = $id; Milestone = $ms }) | Out-Null +} + +# Step 2: Remote apply (if requested) +$applySummary = @() +if ($ApplyMissing) { + if ($Offline) { Write-Err "Cannot use -ApplyMissing with -Offline."; exit 1 } + Write-Info "Resolving milestone id for '$DefaultMilestone' ..." + $milestonesRaw = gh api repos/$Repo/milestones --paginate --jq '.[] | {number,title,state}' + $msObj = $milestonesRaw | ConvertFrom-Json | Where-Object { $_.title -eq $DefaultMilestone -and $_.state -eq 'open' } | Select-Object -First 1 + if (-not $msObj) { Write-Err "Milestone '$DefaultMilestone' not found/open."; exit 1 } + $msNumber = $msObj.number + $targets = $collected | Where-Object { [string]::IsNullOrWhiteSpace($_.Milestone) } + Write-Info ("ApplyMissing: {0} PR(s) without milestone." -f $targets.Count) + foreach ($t in $targets) { + $id = $t.Id + try { + # Verify still missing live + $current = gh pr view $id --repo $Repo --json milestone --jq '.milestone.title // ""' + if ($current) { + $applySummary += [PSCustomObject]@{ Id=$id; Action='Skip (already has)'; Milestone=$current; Status='OK' } + continue + } + if ($WhatIf) { + $applySummary += [PSCustomObject]@{ Id=$id; Action='Would set'; Milestone=$DefaultMilestone; Status='DRY RUN' } + continue + } + gh api -X PATCH -H 'Accept: application/vnd.github+json' repos/$Repo/issues/$id -f milestone=$msNumber | Out-Null + $applySummary += [PSCustomObject]@{ Id=$id; Action='Set'; Milestone=$DefaultMilestone; Status='OK' } + # Reflect in collected object for CSV output if not LocalAssign already doing so + $t.Milestone = $DefaultMilestone + } catch { + $errText = $_ | Out-String + $applySummary += [PSCustomObject]@{ Id=$id; Action='Failed'; Milestone=$DefaultMilestone; Status=$errText.Trim() } + Write-Warn ("Failed to set milestone for PR #{0}: {1}" -f $id, ($errText.Trim())) + } + } +} + +# Step 3: Local assignment (purely for output) AFTER remote so remote actual result not overwritten accidentally +if ($LocalAssign) { + foreach ($item in $collected) { + if ([string]::IsNullOrWhiteSpace($item.Milestone)) { $item.Milestone = $DefaultMilestone } + } +} + +# Step 4: Export CSV +$collected | Export-Csv -LiteralPath $OutputCsv -NoTypeInformation -Encoding UTF8 +Write-Info ("Wrote collected CSV -> {0}" -f (Resolve-Path -LiteralPath $OutputCsv)) + +# Step 5: Summaries +if ($ApplyMissing) { + $updated = ($applySummary | Where-Object { $_.Action -eq 'Set' }).Count + $skipped = ($applySummary | Where-Object { $_.Action -like 'Skip*' }).Count + $failed = ($applySummary | Where-Object { $_.Action -eq 'Failed' }).Count + Write-Info ("ApplyMissing summary: Updated={0} Skipped={1} Failed={2}" -f $updated, $skipped, $failed) +} + +# Emit objects (final collected set) +return $collected diff --git a/.github/skills/release-note-generation/scripts/diff_prs.ps1 b/.github/skills/release-note-generation/scripts/diff_prs.ps1 new file mode 100644 index 0000000000..27a1da3d08 --- /dev/null +++ b/.github/skills/release-note-generation/scripts/diff_prs.ps1 @@ -0,0 +1,100 @@ +<# +.SYNOPSIS + Produce an incremental PR CSV containing rows present in a newer full export but absent from a baseline export. + +.DESCRIPTION + Compares two previously generated sorted PR CSV files (same schema). Any row whose key column value + (defaults to 'Number') does not exist in the baseline file is emitted to a new incremental CSV, preserving + the original column order. If no new rows are found, an empty CSV (with headers when determinable) is written. + +.PARAMETER BaseCsv + Path to the baseline (earlier) PR CSV. + +.PARAMETER AllCsv + Path to the newer full PR CSV containing superset (or equal set) of rows. + +.PARAMETER OutCsv + Path to write the incremental CSV containing only new rows. + +.PARAMETER Key + Column name used as unique identifier (defaults to 'Number'). Must exist in both CSVs. + +.EXAMPLE + pwsh ./diff_prs.ps1 -BaseCsv sorted_prs_prev.csv -AllCsv sorted_prs.csv -OutCsv sorted_prs_incremental.csv + +.NOTES + Requires: PowerShell 7+, both CSVs with identical column schemas. + Exit code 0 on success (even if zero incremental rows). Throws on missing files. +#> + +[CmdletBinding()] param( + [Parameter(Mandatory=$false)][string]$BaseCsv = "./sorted_prs_93_round1.csv", + [Parameter(Mandatory=$false)][string]$AllCsv = "./sorted_prs.csv", + [Parameter(Mandatory=$false)][string]$OutCsv = "./sorted_prs_93_incremental.csv", + [Parameter(Mandatory=$false)][string]$Key = "Number" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Info($m) { Write-Host "[info] $m" -ForegroundColor Cyan } +function Write-Warn($m) { Write-Host "[warn] $m" -ForegroundColor Yellow } + +if (-not (Test-Path -LiteralPath $BaseCsv)) { throw "Base CSV not found: $BaseCsv" } +if (-not (Test-Path -LiteralPath $AllCsv)) { throw "All CSV not found: $AllCsv" } + +# Load CSVs +$baseRows = Import-Csv -LiteralPath $BaseCsv +$allRows = Import-Csv -LiteralPath $AllCsv + +if (-not $baseRows) { Write-Warn "Base CSV has no rows." } +if (-not $allRows) { Write-Warn "All CSV has no rows." } + +# Validate key presence +if ($baseRows -and -not ($baseRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in base CSV." } +if ($allRows -and -not ($allRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in all CSV." } + +# Build a set of existing keys from base +$set = New-Object 'System.Collections.Generic.HashSet[string]' +foreach ($row in $baseRows) { + $val = [string]($row.$Key) + if ($null -ne $val) { [void]$set.Add($val) } +} + +# Filter rows in AllCsv whose key is not in base (these are the new / incremental rows) +$incremental = @() +foreach ($row in $allRows) { + $val = [string]($row.$Key) + if (-not $set.Contains($val)) { $incremental += $row } +} + +# Preserve column order from the All CSV +$columns = @() +if ($allRows.Count -gt 0) { + $columns = $allRows[0].PSObject.Properties.Name +} + +try { + if ($incremental.Count -gt 0) { + if ($columns.Count -gt 0) { + $incremental | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8 + } else { + $incremental | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8 + } + } else { + # Write an empty CSV with headers if we know them (facilitates downstream tooling expecting header row) + if ($columns.Count -gt 0) { + $obj = [PSCustomObject]@{} + foreach ($c in $columns) { $obj | Add-Member -NotePropertyName $c -NotePropertyValue $null } + $obj | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8 + } else { + '' | Out-File -LiteralPath $OutCsv -Encoding UTF8 + } + } + Write-Info ("Incremental rows: {0}" -f $incremental.Count) + Write-Info ("Output: {0}" -f (Resolve-Path -LiteralPath $OutCsv)) +} +catch { + Write-Host "[error] Failed writing output CSV: $_" -ForegroundColor Red + exit 1 +} diff --git a/.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 b/.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 new file mode 100644 index 0000000000..011b37fdd1 --- /dev/null +++ b/.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 @@ -0,0 +1,344 @@ +<# +.SYNOPSIS + Export merged PR metadata between two commits (exclusive start, inclusive end) to JSON and CSV. + +.DESCRIPTION + Identifies merge/squash commits reachable from EndCommit but not StartCommit, extracts PR numbers, + queries GitHub for metadata plus (optionally) Copilot review/comment summaries, filters labels, then + emits a JSON artifact and a sorted CSV (first label alphabetical). + +.PARAMETER StartCommit + Exclusive starting commit (SHA, tag, or ref). Commits AFTER this one are considered. + +.PARAMETER EndCommit + Inclusive ending commit (SHA, tag, or ref). If not provided, uses origin/<Branch> when Branch is set; otherwise uses HEAD. + +.PARAMETER Repo + GitHub repository (owner/name). Default: microsoft/PowerToys. + +.PARAMETER OutputCsv + Destination CSV path. Default: sorted_prs.csv. + +.PARAMETER OutputJson + Destination JSON path containing raw PR objects. Default: milestone_prs.json. + +.EXAMPLE + pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -Branch stable + +.EXAMPLE + pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv delta.csv + +.NOTES + Requires: git, gh (authenticated). No Set-StrictMode to keep parity with existing release scripts. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$StartCommit, # exclusive start (commits AFTER this one) + [string]$EndCommit, + [string]$Branch, + [string]$Repo = "microsoft/PowerToys", + [string]$OutputDir, + [string]$OutputCsv = "sorted_prs.csv", + [string]$OutputJson = "milestone_prs.json" +) + +<# +.SYNOPSIS + Dump merged PR information whose merge commits are reachable from EndCommit but not from StartCommit. +.DESCRIPTION + Uses git rev-list to compute commits in the (StartCommit, EndCommit] range, extracts PR numbers from merge commit messages, + queries GitHub (gh CLI) for details, then outputs a CSV. + + PR merge commit messages in PowerToys generally contain patterns like: + Merge pull request #12345 from ... + +.EXAMPLE + pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -Branch stable + +.EXAMPLE + pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv changes.csv + +.NOTES + Requires: gh CLI authenticated; git available in working directory (must be inside PowerToys repo clone). + CopilotSummary behavior: + - Attempts to locate the latest GitHub Copilot authored review (preferred). + - If no review is found, lazily fetches PR comments to look for a Copilot-authored comment. + - Normalizes whitespace and strips newlines. Empty when no Copilot activity detected. + - Run with -Verbose to see whether the summary came from a 'review' or 'comment' source. +#> + +function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan } +function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow } +function Write-Err($msg) { Write-Host $msg -ForegroundColor Red } +function Write-DebugMsg($msg) { if ($PSBoundParameters.ContainsKey('Verbose') -or $VerbosePreference -eq 'Continue') { Write-Host "[VERBOSE] $msg" -ForegroundColor DarkGray } } + +# Load member list from Generated Files/ReleaseNotes/MemberList.md (internal team - no thanks needed) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..\..\..") +$defaultMemberListPath = Join-Path $repoRoot "Generated Files\ReleaseNotes\MemberList.md" +$memberListPath = $defaultMemberListPath +if ($OutputDir) { + $memberListFromOutputDir = Join-Path $OutputDir "MemberList.md" + if (Test-Path $memberListFromOutputDir) { + $memberListPath = $memberListFromOutputDir + } +} +$memberList = @() +if (Test-Path $memberListPath) { + $memberListContent = Get-Content $memberListPath -Raw + # Extract usernames - skip markdown code fence lines, get all non-empty lines + $memberList = ($memberListContent -split "`n") | Where-Object { $_ -notmatch '^\s*```' -and $_.Trim() -ne '' } | ForEach-Object { $_.Trim() } + if (-not $memberList -or $memberList.Count -eq 0) { + Write-Err "MemberList.md is empty at $memberListPath" + exit 1 + } + Write-DebugMsg "Loaded $($memberList.Count) members from MemberList.md" +} else { + Write-Err "MemberList.md not found at $memberListPath" + exit 1 +} + +# Validate we are in a git repo +#if (-not (Test-Path .git)) { +# Write-Err "Current directory does not appear to be the root of a git repository." +# exit 1 +#} + +# Resolve output directory (if specified) +if ($OutputDir) { + if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + } + if (-not [System.IO.Path]::IsPathRooted($OutputCsv)) { + $OutputCsv = Join-Path $OutputDir $OutputCsv + } + if (-not [System.IO.Path]::IsPathRooted($OutputJson)) { + $OutputJson = Join-Path $OutputDir $OutputJson + } +} + +# Resolve commits +try { + if ($Branch) { + Write-Info "Fetching latest '$Branch' from origin (with tags)..." + git fetch origin $Branch --tags | Out-Null + if ($LASTEXITCODE -ne 0) { throw "git fetch origin $Branch --tags failed" } + } + + $startSha = (git rev-parse --verify $StartCommit) 2>$null + if (-not $startSha) { throw "StartCommit '$StartCommit' not found" } + if ($Branch) { + $branchRef = $Branch + $branchSha = (git rev-parse --verify $branchRef) 2>$null + if (-not $branchSha) { + $branchRef = "origin/$Branch" + $branchSha = (git rev-parse --verify $branchRef) 2>$null + } + if (-not $branchSha) { throw "Branch '$Branch' not found" } + if (-not $PSBoundParameters.ContainsKey('EndCommit') -or [string]::IsNullOrWhiteSpace($EndCommit)) { + $EndCommit = $branchRef + } + } + if (-not $PSBoundParameters.ContainsKey('EndCommit') -or [string]::IsNullOrWhiteSpace($EndCommit)) { + $EndCommit = "HEAD" + } + $endSha = (git rev-parse --verify $EndCommit) 2>$null + if (-not $endSha) { throw "EndCommit '$EndCommit' not found" } +} +catch { + Write-Err $_ + exit 1 +} + +Write-Info "Collecting commits between $startSha..$endSha (excluding start, including end)." + # Get list of commits reachable from end but not from start. + # IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings, + # `$startSha..$endSha` must be passed as a single string argument. + $rangeArg = "$startSha..$endSha" + $commitList = git rev-list $rangeArg + +# Normalize list (filter out empty strings) +$normalizedCommits = $commitList | Where-Object { $_ -and $_.Trim() -ne '' } +$commitCount = ($normalizedCommits | Measure-Object).Count +Write-DebugMsg ("Raw commitList length (including blanks): {0}" -f (($commitList | Measure-Object).Count)) +Write-DebugMsg ("Normalized commit count: {0}" -f $commitCount) +if ($commitCount -eq 0) { + Write-Warn "No commits found in specified range ($startSha..$endSha)."; exit 0 +} +Write-DebugMsg ("First 5 commits: {0}" -f (($normalizedCommits | Select-Object -First 5) -join ', ')) + +<# + Extract PR numbers from commits. + Patterns handled: + 1. Merge commits: 'Merge pull request #12345 from ...' + 2. Squash commits: 'Some feature change (#12345)' (GitHub default squash format) + We collect both. If a commit matches both (unlikely), it's deduped later. +#> +# Extract PR numbers from merge or squash commits +$mergeCommits = @() +foreach ($c in $normalizedCommits) { + $subject = git show -s --format=%s $c + $matched = $false + # Pattern 1: Traditional merge commit + if ($subject -match 'Merge pull request #([0-9]+) ') { + $prNumber = [int]$matches[1] + $mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber; Subject = $subject; Pattern = 'merge' } + Write-DebugMsg "Matched merge PR #$prNumber in commit $c" + $matched = $true + } + # Pattern 2: Squash merge subject line with ' (#12345)' at end (allow possible whitespace before paren) + if ($subject -match '\(#([0-9]+)\)$') { + $prNumber2 = [int]$matches[1] + # Avoid duplicate object if pattern 1 already captured same number for same commit + if (-not ($mergeCommits | Where-Object { $_.Sha -eq $c -and $_.Pr -eq $prNumber2 })) { + $mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber2; Subject = $subject; Pattern = 'squash' } + Write-DebugMsg "Matched squash PR #$prNumber2 in commit $c" + } + $matched = $true + } + if (-not $matched) { + Write-DebugMsg "No PR pattern in commit $c : $subject" + } +} + +if (-not $mergeCommits -or $mergeCommits.Count -eq 0) { + Write-Warn "No merge commits with PR numbers found in range."; exit 0 +} + +# Deduplicate PR numbers (in case of revert or merges across branches) +$prNumbers = $mergeCommits | Select-Object -ExpandProperty Pr -Unique | Sort-Object +Write-Info ("Found {0} unique PRs: {1}" -f $prNumbers.Count, ($prNumbers -join ', ')) +Write-DebugMsg ("Total merge commits examined: {0}" -f $mergeCommits.Count) + +# Query GitHub for each PR +$prDetails = @() +function Get-CopilotSummaryFromPrJson { + param( + [Parameter(Mandatory=$true)]$PrJson, + [switch]$VerboseMode + ) + # Returns a hashtable with Summary and Source keys. + $result = @{ Summary = ""; Source = "" } + if (-not $PrJson) { return $result } + + $candidateAuthors = @( + 'github-copilot[bot]', 'github-copilot', 'copilot' + ) + + # 1. Reviews (preferred) – pick the LONGEST valid Copilot body, not the most recent + $reviews = $PrJson.reviews + if ($reviews) { + $copilotReviews = $reviews | Where-Object { + ($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne '' + } + if ($copilotReviews) { + $longest = $copilotReviews | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1 + if ($longest) { + $body = $longest.body + $norm = ($body -replace "`r", '') -replace "`n", ' ' + $norm = $norm -replace '\s+', ' ' + $result.Summary = $norm + $result.Source = 'review' + if ($VerboseMode) { Write-DebugMsg "Selected Copilot review length=$($body.Length) (longest)." } + return $result + } + } + } + + # 2. Comments fallback (some repos surface Copilot summaries as PR comments rather than review objects) + if ($null -eq $PrJson.comments) { + try { + # Lazy fetch comments only if needed + $commentsJson = gh pr view $PrJson.number --repo $Repo --json comments 2>$null | ConvertFrom-Json + if ($commentsJson -and $commentsJson.comments) { + $PrJson | Add-Member -NotePropertyName comments -NotePropertyValue $commentsJson.comments -Force + } + } catch { + if ($VerboseMode) { Write-DebugMsg "Failed to fetch comments for PR #$($PrJson.number): $_" } + } + } + if ($PrJson.comments) { + $copilotComments = $PrJson.comments | Where-Object { + ($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne '' + } + if ($copilotComments) { + $longestC = $copilotComments | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1 + if ($longestC) { + $body = $longestC.body + $norm = ($body -replace "`r", '') -replace "`n", ' ' + $norm = $norm -replace '\s+', ' ' + $result.Summary = $norm + $result.Source = 'comment' + if ($VerboseMode) { Write-DebugMsg "Selected Copilot comment length=$($body.Length) (longest)." } + return $result + } + } + } + + return $result +} + +foreach ($pr in $prNumbers) { + Write-Info "Fetching PR #$pr ..." + try { + # Include comments only if Verbose asked; if not, we lazily pull when reviews are missing + $fields = 'number,title,labels,author,url,body,reviews' + if ($PSBoundParameters.ContainsKey('Verbose')) { $fields += ',comments' } + $json = gh pr view $pr --repo $Repo --json $fields 2>$null | ConvertFrom-Json + if ($null -eq $json) { throw "Empty response" } + + $copilot = Get-CopilotSummaryFromPrJson -PrJson $json -VerboseMode:($PSBoundParameters.ContainsKey('Verbose')) + if ($copilot.Summary -and $copilot.Source -and $PSBoundParameters.ContainsKey('Verbose')) { + Write-DebugMsg "Copilot summary source=$($copilot.Source) chars=$($copilot.Summary.Length)" + } elseif (-not $copilot.Summary) { + Write-DebugMsg "No Copilot summary found for PR #$pr" + } + + # Filter labels + $filteredLabels = $json.labels | Where-Object { + ($_.name -like "Product-*") -or + ($_.name -like "Area-*") -or + ($_.name -like "GitHub*") -or + ($_.name -like "*Plugin") -or + ($_.name -like "Issue-*") + } + $labelNames = ($filteredLabels | ForEach-Object { $_.name }) -join ", " + + $bodyValue = if ($json.body) { ($json.body -replace "`r", '') -replace "`n", ' ' } else { '' } + $bodyValue = $bodyValue -replace '\s+', ' ' + + # Determine if author needs thanks (not in member list) + $authorLogin = $json.author.login + $needThanks = $true + if ($memberList.Count -gt 0 -and $authorLogin) { + $needThanks = -not ($memberList -contains $authorLogin) + } + + $prDetails += [PSCustomObject]@{ + Id = $json.number + Title = $json.title + Labels = $labelNames + Author = $authorLogin + Url = $json.url + Body = $bodyValue + CopilotSummary = $copilot.Summary + NeedThanks = $needThanks + } + } + catch { + $err = $_ + Write-Warn ("Failed to fetch PR #{0}: {1}" -f $pr, $err) + } +} + +if (-not $prDetails) { Write-Warn "No PR details fetched."; exit 0 } + +# Sort by Labels like original script (first label alphabetical) +$sorted = $prDetails | Sort-Object { ($_.Labels -split ',')[0] } + +# Output JSON raw (optional) +$sorted | ConvertTo-Json -Depth 6 | Out-File -Encoding UTF8 $OutputJson + +Write-Info "Saving CSV to $OutputCsv ..." +$sorted | Export-Csv $OutputCsv -NoTypeInformation +Write-Host "✅ Done. Generated $($prDetails.Count) PR rows." -ForegroundColor Green diff --git a/.github/skills/release-note-generation/scripts/find-commit-by-title.ps1 b/.github/skills/release-note-generation/scripts/find-commit-by-title.ps1 new file mode 100644 index 0000000000..31b5c99fad --- /dev/null +++ b/.github/skills/release-note-generation/scripts/find-commit-by-title.ps1 @@ -0,0 +1,80 @@ +<# +.SYNOPSIS + Find a commit on a branch that has the same subject line as a reference commit. + +.DESCRIPTION + Given a commit SHA (often from a release tag) and a branch name, this script + resolves the reference commit's subject, then searches the branch history for + commits with the exact same subject line. Useful when the release tag commit + is not reachable from your current branch history. + +.PARAMETER Commit + The reference commit SHA or ref (e.g., v0.96.1 or a full SHA). + +.PARAMETER Branch + The branch to search (e.g., stable or main). Defaults to stable. + +.PARAMETER RepoPath + Path to the local repo. Defaults to current directory. + +.EXAMPLE + pwsh ./find-commit-by-title.ps1 -Commit b62f6421845f7e5c92b8186868d98f46720db442 -Branch stable +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Commit, + [string]$Branch = "stable", + [string]$RepoPath = "." +) + +function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan } +function Write-Err($msg) { Write-Host $msg -ForegroundColor Red } + +Push-Location $RepoPath +try { + Write-Info "Fetching latest '$Branch' from origin (with tags)..." + git fetch origin $Branch --tags | Out-Null + if ($LASTEXITCODE -ne 0) { throw "git fetch origin $Branch --tags failed" } + + $commitSha = (git rev-parse --verify $Commit) 2>$null + if (-not $commitSha) { throw "Commit '$Commit' not found" } + + $subject = (git show -s --format=%s $commitSha) 2>$null + if (-not $subject) { throw "Unable to read subject for '$commitSha'" } + + $branchRef = $Branch + $branchSha = (git rev-parse --verify $branchRef) 2>$null + if (-not $branchSha) { + $branchRef = "origin/$Branch" + $branchSha = (git rev-parse --verify $branchRef) 2>$null + } + if (-not $branchSha) { throw "Branch '$Branch' not found" } + + Write-Info "Reference commit: $commitSha" + Write-Info "Reference title: $subject" + Write-Info "Searching branch: $branchRef" + + $matches = git log $branchRef --format="%H|%s" | Where-Object { $_ -match '\|' } + $results = @() + foreach ($line in $matches) { + $parts = $line -split '\|', 2 + if ($parts.Count -eq 2 -and $parts[1] -eq $subject) { + $results += [PSCustomObject]@{ Sha = $parts[0]; Title = $parts[1] } + } + } + + if (-not $results -or $results.Count -eq 0) { + Write-Info "No matching commit found on $branchRef for the given title." + exit 0 + } + + Write-Info ("Found {0} matching commit(s):" -f $results.Count) + $results | ForEach-Object { Write-Host ("{0} {1}" -f $_.Sha, $_.Title) } +} +catch { + Write-Err $_ + exit 1 +} +finally { + Pop-Location +} diff --git a/.github/skills/release-note-generation/scripts/group-prs-by-label.ps1 b/.github/skills/release-note-generation/scripts/group-prs-by-label.ps1 new file mode 100644 index 0000000000..89be57bd6a --- /dev/null +++ b/.github/skills/release-note-generation/scripts/group-prs-by-label.ps1 @@ -0,0 +1,85 @@ +<# +.SYNOPSIS + Group PR rows by their Labels column and emit per-label CSV files. + +.DESCRIPTION + Reads a milestone PR CSV (usually produced by dump-prs-information / dump-prs-since-commit scripts), + splits rows by label list, normalizes/sorts individual labels, and writes one CSV per unique label combination. + Each output preserves the original row ordering within that subset and column order from the source. + +.PARAMETER CsvPath + Input CSV containing PR rows with a 'Labels' column (comma-separated list). + +.PARAMETER OutDir + Output directory to place grouped CSVs (created if missing). Default: 'grouped_csv'. + +.NOTES + Label combinations are joined using ' | ' when multiple labels present. Filenames are sanitized (invalid characters, + whitespace collapsed) and truncated to <= 120 characters. +#> +param( + [string]$CsvPath = "sorted_prs.csv", + [string]$OutDir = "grouped_csv" +) + +$ErrorActionPreference = 'Stop' + +function Write-Info($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan } +function Write-Warn($msg) { Write-Host "[warn] $msg" -ForegroundColor Yellow } + +if (-not (Test-Path -LiteralPath $CsvPath)) { throw "CSV not found: $CsvPath" } + +Write-Info "Reading CSV: $CsvPath" +$rows = Import-Csv -LiteralPath $CsvPath +Write-Info ("Loaded {0} rows" -f $rows.Count) + +function ConvertTo-SafeFileName { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$Name + ) + if ([string]::IsNullOrWhiteSpace($Name)) { return 'Unnamed' } + $s = $Name -replace '[<>:"/\\|?*]', '-' # invalid path chars + $s = $s -replace '\s+', '-' # spaces to dashes + $s = $s -replace '-{2,}', '-' # collapse dashes + $s = $s.Trim('-') + if ($s.Length -gt 120) { $s = $s.Substring(0,120).Trim('-') } + if ([string]::IsNullOrWhiteSpace($s)) { return 'Unnamed' } + return $s +} + +# Build groups keyed by normalized, sorted label combinations. Preserve original CSV row order. +$groups = @{} +foreach ($row in $rows) { + $labelsRaw = $row.Labels + if ([string]::IsNullOrWhiteSpace($labelsRaw)) { + $labelParts = @('Unlabeled') + } else { + $parts = $labelsRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + if (-not $parts -or $parts.Count -eq 0) { $labelParts = @('Unlabeled') } + else { $labelParts = $parts | Sort-Object } + } + + $key = ($labelParts -join ' | ') + if (-not $groups.ContainsKey($key)) { $groups[$key] = New-Object System.Collections.ArrayList } + [void]$groups[$key].Add($row) +} + +if (-not (Test-Path -LiteralPath $OutDir)) { + Write-Info "Creating output directory: $OutDir" + New-Item -ItemType Directory -Path $OutDir | Out-Null +} + +Write-Info ("Generating {0} grouped CSV file(s) into: {1}" -f $groups.Count, $OutDir) + +foreach ($key in $groups.Keys) { + $labelParts = if ($key -eq 'Unlabeled') { @('Unlabeled') } else { $key -split '\s\|\s' } + $safeName = ($labelParts | ForEach-Object { ConvertTo-SafeFileName -Name $_ }) -join '-' + $filePath = Join-Path $OutDir ("$safeName.csv") + + # Keep same columns and order + $groups[$key] | Export-Csv -LiteralPath $filePath -NoTypeInformation -Encoding UTF8 +} + +Write-Info "Done. Sample output files:" +Get-ChildItem -LiteralPath $OutDir | Select-Object -First 10 Name | Format-Table -HideTableHeaders diff --git a/.github/workflows/automatic-issue-deduplication.yml b/.github/workflows/automatic-issue-deduplication.yml new file mode 100644 index 0000000000..88ec3e2f23 --- /dev/null +++ b/.github/workflows/automatic-issue-deduplication.yml @@ -0,0 +1,19 @@ +name: Automatic New Issue Deduplication +on: + issues: + types: [opened, reopened] +permissions: + models: read + issues: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true +jobs: + deduplicate: + runs-on: ubuntu-latest + steps: + - name: Run Deduplicate Action + uses: pelikhan/action-genai-issue-dedup@v0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + label_as_duplicate: true diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000000..be07e7facc --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,26 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, +# surfacing known-vulnerable versions of the packages declared or updated in the PR. +# Once installed, if the workflow run is marked as required, +# PRs introducing known-vulnerable packages will be blocked from merging. +# +# As recommended by Microsoft's security guidelines (https://docs.opensource.microsoft.com/security/tsg/actions/#requirements-for-security-hardening-your-own-github-actions), +# 3rd-party actions should be pinned to a specific commit hash to prevent supply chain attacks. +# This update aligns with best practices; 1st/2nd-party actions is not required hash pinning. +# +# Source repository: https://github.com/actions/dependency-review-action +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v6 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 \ No newline at end of file diff --git a/.github/workflows/manual-batch-issue-deduplication.yml b/.github/workflows/manual-batch-issue-deduplication.yml new file mode 100644 index 0000000000..616e2244f0 --- /dev/null +++ b/.github/workflows/manual-batch-issue-deduplication.yml @@ -0,0 +1,38 @@ +name: Manual Batch Issue Deduplication + +on: + workflow_dispatch: + inputs: + issue_numbers: + description: "JSON array of issue numbers to deduplicate (e.g. [101,102,103])" + required: true + since: + description: "Only compare against issues created after this date (ISO 8601, e.g. 2019-05-05T00:00:00Z)" + required: false + default: "2019-05-05T00:00:00Z" + label_as_duplicate: + description: "Apply duplicate label if duplicates are found (true/false)" + required: false + default: "true" + +permissions: + models: read + issues: write + +jobs: + deduplicate: + runs-on: ubuntu-latest + strategy: + matrix: + issue: ${{ fromJson(github.event.inputs.issue_numbers) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run GenAI Issue Deduplicator + uses: pelikhan/action-genai-issue-dedup@v0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + github_issue: ${{ matrix.issue }} + label_as_duplicate: ${{ github.event.inputs.label_as_duplicate }} + diff --git a/.github/workflows/msstore-submissions.yml b/.github/workflows/msstore-submissions.yml index 97379b91f0..36dfc4d785 100644 --- a/.github/workflows/msstore-submissions.yml +++ b/.github/workflows/msstore-submissions.yml @@ -17,7 +17,7 @@ jobs: steps: - name: BODGY - Set up Gnome Keyring for future Cert Auth run: |- - sudo apt-get install -y gnome-keyring + sudo apt-get update && sudo apt-get install -y gnome-keyring export $(dbus-launch --sh-syntax) export $(echo 'anypass_just_to_unlock' | gnome-keyring-daemon --unlock) export $(echo 'anypass_just_to_unlock' | gnome-keyring-daemon --start --components=gpg,pkcs11,secrets,ssh) @@ -39,6 +39,11 @@ jobs: echo powerToysInstallerX64Url=$(jq -n "$powerToysSetup" | jq -r '[.[]|select(.name | contains("x64"))][0].browser_download_url') >> $GITHUB_OUTPUT echo powerToysInstallerArm64Url=$(jq -n "$powerToysSetup" | jq -r '[.[]|select(.name | contains("arm64"))][0].browser_download_url') >> $GITHUB_OUTPUT + - name: Setup .NET 9.0 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '9.0.x' + - uses: microsoft/setup-msstore-cli@v1 - name: Fetch Store Credential diff --git a/.github/workflows/package-submissions.yml b/.github/workflows/package-submissions.yml index b03c18b78b..a2d401faa4 100644 --- a/.github/workflows/package-submissions.yml +++ b/.github/workflows/package-submissions.yml @@ -1,5 +1,4 @@ name: WinGet submission on release -# based off of https://github.com/nushell/nushell/blob/main/.github/workflows/winget-submission.yml on: workflow_dispatch: @@ -9,23 +8,31 @@ on: jobs: winget: name: Publish winget package + + # winget-create is only supported on Windows runs-on: windows-latest + + # winget-create will read the following environment variable to access the GitHub token needed for submitting a PR + # See https://aka.ms/winget-create-token + env: + WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.PT_WINGET }} + + # Only submit stable releases + if: ${{ !github.event.release.prerelease }} steps: - name: Submit Microsoft.PowerToys package to Windows Package Manager Community Repository run: | + # Get installer info from GitHub release event + $assets = '${{ toJSON(github.event.release.assets) }}' | ConvertFrom-Json + $x64UserInstallerUrl = $assets | Where-Object -Property name -match 'PowerToysUserSetup.*x64' | Select -ExpandProperty browser_download_url + $x64MachineInstallerUrl = $assets | Where-Object -Property name -match 'PowerToysSetup.*x64' | Select -ExpandProperty browser_download_url + $arm64UserInstallerUrl = $assets | Where-Object -Property name -match 'PowerToysUserSetup.*arm64' | Select -ExpandProperty browser_download_url + $arm64MachineInstallerUrl = $assets | Where-Object -Property name -match 'PowerToysSetup.*arm64' | Select -ExpandProperty browser_download_url + $packageVersion = (${{ toJSON(github.event.release.tag_name) }}).Trim('v') - $wingetPackage = "Microsoft.PowerToys" - $gitToken = "${{ secrets.PT_WINGET }}" - - $github = Invoke-RestMethod -uri "https://api.github.com/repos/Microsoft/PowerToys/releases" - - $targetRelease = $github | Where-Object -Property name -match 'Release'| Select -First 1 - $installerUserX64Url = $targetRelease | Select -ExpandProperty assets -First 1 | Where-Object -Property name -match 'PowerToysUserSetup.*x64' | Select -ExpandProperty browser_download_url - $installerMachineX64Url = $targetRelease | Select -ExpandProperty assets -First 1 | Where-Object -Property name -match 'PowerToysSetup.*x64' | Select -ExpandProperty browser_download_url - $installerUserArmUrl = $targetRelease | Select -ExpandProperty assets -First 1 | Where-Object -Property name -match 'PowerToysUserSetup.*arm64' | Select -ExpandProperty browser_download_url - $installerMachineArmUrl = $targetRelease | Select -ExpandProperty assets -First 1 | Where-Object -Property name -match 'PowerToysSetup.*arm64' | Select -ExpandProperty browser_download_url - $ver = $targetRelease.tag_name -ireplace '^v' - - # getting latest wingetcreate file - iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe - .\wingetcreate.exe update $wingetPackage -s -v $ver -u "$installerUserX64Url|user" "$installerMachineX64Url|machine" "$installerUserArmUrl|user" "$installerMachineArmUrl|machine" -t $gitToken + # Update package using wingetcreate + curl.exe -JLO https://aka.ms/wingetcreate/latest + .\wingetcreate.exe update Microsoft.PowerToys ` + --version $packageVersion ` + --urls "$x64UserInstallerUrl|user" "$x64MachineInstallerUrl|machine" "$arm64UserInstallerUrl|user" "$arm64MachineInstallerUrl|machine" ` + --submit diff --git a/.github/workflows/spelling2.yml b/.github/workflows/spelling2.yml index e79708399f..a88d620f24 100644 --- a/.github/workflows/spelling2.yml +++ b/.github/workflows/spelling2.yml @@ -93,7 +93,7 @@ jobs: steps: - name: check-spelling id: spelling - uses: check-spelling/check-spelling@v0.0.24 + uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25 with: config: .github/actions/spell-check suppress_push_for_open_pull_request: ${{ github.actor != 'dependabot[bot]' && 1 }} @@ -105,7 +105,7 @@ jobs: report-timing: 1 warnings: bad-regex,binary-file,deprecated-feature,ignored-expect-variant,large-file,limited-references,no-newline-at-eof,noisy-file,non-alpha-in-dictionary,token-is-substring,unexpected-line-ending,whitespace-in-dictionary,minified-file,unsupported-configuration,no-files-to-check,unclosed-block-ignore-begin,unclosed-block-ignore-end experimental_apply_changes_via_bot: 1 - use_sarif: ${{ (!github.event.pull_request || (github.event.pull_request.head.repo.full_name == github.repository)) && 1 }} + use_sarif: 1 check_extra_dictionaries: "" dictionary_source_prefixes: > { @@ -113,37 +113,28 @@ jobs: } extra_dictionaries: | cspell:software-terms/softwareTerms.txt + cspell:cpp/stdlib-c.txt cspell:cpp/stdlib-cpp.txt cspell:filetypes/filetypes.txt - cspell:cpp/stdlib-c.txt - cspell:lorem-ipsum/dictionary.txt - cspell:python/python/python-lib.txt cspell:php/php.txt - cspell:fullstack/fullstack.txt - cspell:dotnet/dotnet.txt - cspell:swift/swift.txt - cspell:node/node.txt cspell:dart/dart.txt - cspell:django/django.txt - cspell:python/python/python.txt + cspell:dotnet/dotnet.txt cspell:powershell/powershell.txt - cspell:npm/npm.txt - cspell:golang/go.txt - cspell:cpp/compiler-msvc.txt cspell:csharp/csharp.txt - cspell:html/html.txt + cspell:python/python/python-lib.txt + cspell:node/node.txt + cspell:golang/go.txt + cspell:npm/npm.txt + cspell:fullstack/fullstack.txt + cspell:css/css.txt cspell:java/java.txt - cspell:aws/aws.txt cspell:typescript/typescript.txt - cspell:cpp/lang-keywords.txt + cspell:html/html.txt + cspell:r/r.txt + cspell:aws/aws.txt + cspell:cpp/compiler-msvc.txt cspell:python/common/extra.txt cspell:scala/scala.txt - cspell:shell/shell-all-words.txt - cspell:css/css.txt - cspell:r/r.txt - cspell:java/java-terms.txt - cspell:cpp/stdlib-cerrno.txt - cspell:k8s/k8s.txt comment-push: name: Report (Push) @@ -156,7 +147,7 @@ jobs: if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push' steps: - name: comment - uses: check-spelling/check-spelling@v0.0.24 + uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25 with: config: .github/actions/spell-check checkout: true @@ -175,7 +166,7 @@ jobs: if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request') steps: - name: comment - uses: check-spelling/check-spelling@v0.0.24 + uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25 with: config: .github/actions/spell-check checkout: true @@ -202,7 +193,7 @@ jobs: cancel-in-progress: false steps: - name: apply spelling updates - uses: check-spelling/check-spelling@v0.0.24 + uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25 with: experimental_apply_changes_via_bot: ${{ github.repository_owner != 'microsoft' && 1 }} checkout: true diff --git a/.gitignore b/.gitignore index 66532cc074..001a59ebae 100644 --- a/.gitignore +++ b/.gitignore @@ -349,7 +349,14 @@ src/common/Telemetry/*.etl /src/modules/powerrename/ui/RCb24464 # Generated installer file for Monaco source files. -/installer/PowerToysSetup/MonacoSRC.wxs +/installer/PowerToysSetupVNext/MonacoSRC.wxs # MSBuildCache /MSBuildCacheLogs/ + +# PowerToys Settings generated search index (legacy location) and obj outputs +/src/settings-ui/Settings.UI/Assets/Settings/search.index.json + +# PowerToysInstaller Build Temp Files +installer/*/*.wxs.bk +/src/modules/awake/.claude diff --git a/.gitmodules b/.gitmodules index 1601291341..f878c1a9e3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,3 @@ [submodule "deps/expected-lite"] path = deps/expected-lite url = https://github.com/martinmoene/expected-lite.git -[submodule "deps/cziplib"] - path = deps/cziplib - url = https://github.com/kuba--/zip.git diff --git a/.pipelines/272MSSharedLibSN2048.snk b/.pipelines/272MSSharedLibSN2048.snk new file mode 100644 index 0000000000..bd766f84a2 Binary files /dev/null and b/.pipelines/272MSSharedLibSN2048.snk differ diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 1b78856032..6c51889d77 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -3,225 +3,265 @@ "UseMinimatch": false, "SignBatches": [ { - "MatchedPath": [ - "*.resources.dll", - - "WinUI3Apps\\Assets\\Settings\\Scripts\\*.ps1", + "MatchedPath": [ + "*.resources.dll", + "WinUI3Apps\\Assets\\Settings\\Scripts\\*.ps1", - "PowerToys.ActionRunner.exe", - "PowerToys.Update.exe", - "PowerToys.BackgroundActivatorDLL.dll", - "Notifications.dll", - "os-detection.dll", - "PowerToys.exe", - "PowerToys.FilePreviewCommon.dll", - "PowerToys.Interop.dll", - "Tools\\PowerToys.BugReportTool.exe", - "StylesReportTool\\PowerToys.StylesReportTool.exe", - "Telemetry.dll", - "PowerToys.ManagedTelemetry.dll", - "PowerToys.ManagedCommon.dll", - "PowerToys.Common.UI.dll", - "PowerToys.Settings.UI.Lib.dll", - "PowerToys.GPOWrapper.dll", - "PowerToys.GPOWrapperProjection.dll", - "PowerToys.AllExperiments.dll", + "PowerToys.ActionRunner.exe", + "PowerToys.Update.exe", + "PowerToys.BackgroundActivatorDLL.dll", - "PowerToys.AlwaysOnTop.exe", - "PowerToys.AlwaysOnTopModuleInterface.dll", + "PowerToys.exe", + "PowerToys.FilePreviewCommon.dll", + "PowerToys.Interop.dll", + "Tools\\PowerToys.BugReportTool.exe", + "StylesReportTool\\PowerToys.StylesReportTool.exe", - "PowerToys.CmdNotFoundModuleInterface.dll", - "PowerToys.CmdNotFound.dll", + "CalculatorEngineCommon.dll", + "PowerToys.ManagedTelemetry.dll", + "PowerToys.ManagedCommon.dll", + "PowerToys.ManagedCsWin32.dll", + "PowerToys.Common.UI.dll", + "PowerToys.Settings.UI.Lib.dll", + "PowerToys.GPOWrapper.dll", + "PowerToys.GPOWrapperProjection.dll", + "PowerToys.AllExperiments.dll", + "LanguageModelProvider.dll", - "PowerToys.ColorPicker.dll", - "PowerToys.ColorPickerUI.dll", - "PowerToys.ColorPickerUI.exe", + "Common.Search.dll", - "PowerToys.CropAndLockModuleInterface.dll", - "PowerToys.CropAndLock.exe", + "PowerToys.AlwaysOnTop.exe", + "PowerToys.AlwaysOnTopModuleInterface.dll", - "PowerToys.PowerOCRModuleInterface.dll", - "PowerToys.PowerOCR.dll", - "PowerToys.PowerOCR.exe", + "PowerToys.CmdNotFoundModuleInterface.dll", - "PowerToys.AdvancedPasteModuleInterface.dll", - "WinUI3Apps\\PowerToys.AdvancedPaste.exe", - "WinUI3Apps\\PowerToys.AdvancedPaste.dll", - "PowerToys.AwakeModuleInterface.dll", - "PowerToys.Awake.exe", - "PowerToys.Awake.dll", + "PowerToys.ColorPicker.dll", + "PowerToys.ColorPickerUI.dll", + "PowerToys.ColorPickerUI.exe", - "fancyzones.dll", - "PowerToys.FancyZonesEditor.exe", - "PowerToys.FancyZonesEditor.dll", - "PowerToys.FancyZonesEditorCommon.dll", - "PowerToys.FancyZonesModuleInterface.dll", - "PowerToys.FancyZones.exe", + "PowerToys.CropAndLockModuleInterface.dll", + "PowerToys.CropAndLock.exe", - "PowerToys.GcodePreviewHandler.dll", - "PowerToys.GcodePreviewHandler.exe", - "PowerToys.GcodePreviewHandlerCpp.dll", - "PowerToys.GcodeThumbnailProvider.dll", - "PowerToys.GcodeThumbnailProvider.exe", - "PowerToys.GcodeThumbnailProviderCpp.dll", - "PowerToys.ManagedTelemetry.dll", - "PowerToys.MarkdownPreviewHandler.dll", - "PowerToys.MarkdownPreviewHandler.exe", - "PowerToys.MarkdownPreviewHandlerCpp.dll", - "PowerToys.MonacoPreviewHandler.dll", - "PowerToys.MonacoPreviewHandler.exe", - "PowerToys.MonacoPreviewHandlerCpp.dll", - "PowerToys.PdfPreviewHandler.dll", - "PowerToys.PdfPreviewHandler.exe", - "PowerToys.PdfPreviewHandlerCpp.dll", - "PowerToys.PdfThumbnailProvider.dll", - "PowerToys.PdfThumbnailProvider.exe", - "PowerToys.PdfThumbnailProviderCpp.dll", - "PowerToys.powerpreview.dll", - "PowerToys.PreviewHandlerCommon.dll", - "PowerToys.QoiPreviewHandler.dll", - "PowerToys.QoiPreviewHandler.exe", - "PowerToys.QoiPreviewHandlerCpp.dll", - "PowerToys.QoiThumbnailProvider.dll", - "PowerToys.QoiThumbnailProvider.exe", - "PowerToys.QoiThumbnailProviderCpp.dll", - "PowerToys.StlThumbnailProvider.dll", - "PowerToys.StlThumbnailProvider.exe", - "PowerToys.StlThumbnailProviderCpp.dll", - "PowerToys.SvgPreviewHandler.dll", - "PowerToys.SvgPreviewHandler.exe", - "PowerToys.SvgPreviewHandlerCpp.dll", - "PowerToys.SvgThumbnailProvider.dll", - "PowerToys.SvgThumbnailProvider.exe", - "PowerToys.SvgThumbnailProviderCpp.dll", + "PowerToys.PowerOCRModuleInterface.dll", + "PowerToys.PowerOCR.dll", + "PowerToys.PowerOCR.exe", - "WinUI3Apps\\PowerToys.HostsModuleInterface.dll", - "WinUI3Apps\\PowerToys.HostsUILib.dll", - "WinUI3Apps\\PowerToys.Hosts.dll", - "WinUI3Apps\\PowerToys.Hosts.exe", + "PowerToys.AdvancedPasteModuleInterface.dll", + "WinUI3Apps\\PowerToys.AdvancedPaste.exe", + "WinUI3Apps\\PowerToys.AdvancedPaste.dll", - "WinUI3Apps\\PowerToys.FileLocksmithLib.Interop.dll", - "WinUI3Apps\\PowerToys.FileLocksmithExt.dll", - "WinUI3Apps\\PowerToys.FileLocksmithUI.exe", - "WinUI3Apps\\PowerToys.FileLocksmithUI.dll", - "WinUI3Apps\\PowerToys.FileLocksmithContextMenu.dll", - "FileLocksmithContextMenuPackage.msix", + "PowerToys.AwakeModuleInterface.dll", + "PowerToys.Awake.exe", + "PowerToys.Awake.dll", - "WinUI3Apps\\Peek.Common.dll", - "WinUI3Apps\\Peek.FilePreviewer.dll", - "WinUI3Apps\\Powertoys.Peek.UI.dll", - "WinUI3Apps\\Powertoys.Peek.UI.exe", - "WinUI3Apps\\Powertoys.Peek.dll", + "PowerToys.FancyZonesEditor.exe", + "PowerToys.FancyZonesEditor.dll", + "PowerToys.FancyZonesEditorCommon.dll", + "PowerToys.FancyZonesModuleInterface.dll", + "PowerToys.FancyZones.exe", + "FancyZonesCLI.exe", + "FancyZonesCLI.dll", - "WinUI3Apps\\PowerToys.EnvironmentVariablesModuleInterface.dll", - "WinUI3Apps\\PowerToys.EnvironmentVariablesUILib.dll", - "WinUI3Apps\\PowerToys.EnvironmentVariables.dll", - "WinUI3Apps\\PowerToys.EnvironmentVariables.exe", + "PowerToys.GcodePreviewHandler.dll", + "PowerToys.GcodePreviewHandler.exe", + "PowerToys.GcodePreviewHandlerCpp.dll", + "PowerToys.GcodeThumbnailProvider.dll", + "PowerToys.GcodeThumbnailProvider.exe", + "PowerToys.GcodeThumbnailProviderCpp.dll", + "PowerToys.BgcodePreviewHandler.dll", + "PowerToys.BgcodePreviewHandler.exe", + "PowerToys.BgcodePreviewHandlerCpp.dll", + "PowerToys.BgcodeThumbnailProvider.dll", + "PowerToys.BgcodeThumbnailProvider.exe", + "PowerToys.BgcodeThumbnailProviderCpp.dll", + "PowerToys.ManagedTelemetry.dll", + "PowerToys.MarkdownPreviewHandler.dll", + "PowerToys.MarkdownPreviewHandler.exe", + "PowerToys.MarkdownPreviewHandlerCpp.dll", + "PowerToys.MonacoPreviewHandler.dll", + "PowerToys.MonacoPreviewHandler.exe", + "PowerToys.MonacoPreviewHandlerCpp.dll", + "PowerToys.PdfPreviewHandler.dll", + "PowerToys.PdfPreviewHandler.exe", + "PowerToys.PdfPreviewHandlerCpp.dll", + "PowerToys.PdfThumbnailProvider.dll", + "PowerToys.PdfThumbnailProvider.exe", + "PowerToys.PdfThumbnailProviderCpp.dll", + "PowerToys.powerpreview.dll", + "PowerToys.PreviewHandlerCommon.dll", + "PowerToys.QoiPreviewHandler.dll", + "PowerToys.QoiPreviewHandler.exe", + "PowerToys.QoiPreviewHandlerCpp.dll", + "PowerToys.QoiThumbnailProvider.dll", + "PowerToys.QoiThumbnailProvider.exe", + "PowerToys.QoiThumbnailProviderCpp.dll", + "PowerToys.StlThumbnailProvider.dll", + "PowerToys.StlThumbnailProvider.exe", + "PowerToys.StlThumbnailProviderCpp.dll", + "PowerToys.SvgPreviewHandler.dll", + "PowerToys.SvgPreviewHandler.exe", + "PowerToys.SvgPreviewHandlerCpp.dll", + "PowerToys.SvgThumbnailProvider.dll", + "PowerToys.SvgThumbnailProvider.exe", + "PowerToys.SvgThumbnailProviderCpp.dll", - "PowerToys.ImageResizer.exe", - "PowerToys.ImageResizer.dll", - "PowerToys.ImageResizerExt.dll", - "PowerToys.ImageResizerContextMenu.dll", - "ImageResizerContextMenuPackage.msix", + "WinUI3Apps\\PowerToys.HostsModuleInterface.dll", + "WinUI3Apps\\PowerToys.HostsUILib.dll", + "WinUI3Apps\\PowerToys.Hosts.dll", + "WinUI3Apps\\PowerToys.Hosts.exe", - "PowerToys.KeyboardManager.dll", - "KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe", - "KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe", - "PowerToys.KeyboardManagerEditorLibraryWrapper.dll", + "WinUI3Apps\\PowerToys.FileLocksmithLib.Interop.dll", + "WinUI3Apps\\PowerToys.FileLocksmithExt.dll", + "WinUI3Apps\\PowerToys.FileLocksmithUI.exe", + "WinUI3Apps\\PowerToys.FileLocksmithUI.dll", + "WinUI3Apps\\PowerToys.FileLocksmithContextMenu.dll", + "FileLocksmithContextMenuPackage.msix", + "FileLocksmithCLI.exe", - "PowerToys.Launcher.dll", - "PowerToys.PowerLauncher.dll", - "PowerToys.PowerLauncher.exe", - "PowerToys.PowerLauncher.Telemetry.dll", - "Wox.dll", - "Wox.Infrastructure.dll", - "Wox.Plugin.dll", - "RunPlugins\\Calculator\\Microsoft.PowerToys.Run.Plugin.Calculator.dll", - "RunPlugins\\Folder\\Microsoft.Plugin.Folder.dll", - "RunPlugins\\Indexer\\Microsoft.Plugin.Indexer.dll", - "RunPlugins\\OneNote\\Microsoft.PowerToys.Run.Plugin.OneNote.dll", - "RunPlugins\\History\\Microsoft.PowerToys.Run.Plugin.History.dll", - "RunPlugins\\PowerToys\\Microsoft.PowerToys.Run.Plugin.PowerToys.dll", - "RunPlugins\\Program\\Microsoft.Plugin.Program.dll", - "RunPlugins\\Registry\\Microsoft.PowerToys.Run.Plugin.Registry.dll", - "RunPlugins\\WindowsSettings\\Microsoft.PowerToys.Run.Plugin.WindowsSettings.dll", - "RunPlugins\\Shell\\Microsoft.Plugin.Shell.dll", - "RunPlugins\\Uri\\Microsoft.Plugin.Uri.dll", - "RunPlugins\\WindowWalker\\Microsoft.Plugin.WindowWalker.dll", - "RunPlugins\\UnitConverter\\Community.PowerToys.Run.Plugin.UnitConverter.dll", - "RunPlugins\\VSCodeWorkspaces\\Community.PowerToys.Run.Plugin.VSCodeWorkspaces.dll", - "RunPlugins\\Service\\Microsoft.PowerToys.Run.Plugin.Service.dll", - "RunPlugins\\System\\Microsoft.PowerToys.Run.Plugin.System.dll", - "RunPlugins\\TimeDate\\Microsoft.PowerToys.Run.Plugin.TimeDate.dll", - "RunPlugins\\ValueGenerator\\Community.PowerToys.Run.Plugin.ValueGenerator.dll", - "RunPlugins\\WebSearch\\Community.PowerToys.Run.Plugin.WebSearch.dll", - "RunPlugins\\WindowsTerminal\\Microsoft.PowerToys.Run.Plugin.WindowsTerminal.dll", - - "WinUI3Apps\\PowerToys.MeasureToolModuleInterface.dll", - "WinUI3Apps\\PowerToys.MeasureToolCore.dll", - "WinUI3Apps\\PowerToys.MeasureToolUI.dll", - "WinUI3Apps\\PowerToys.MeasureToolUI.exe", + "WinUI3Apps\\Peek.Common.dll", + "WinUI3Apps\\Peek.FilePreviewer.dll", + "WinUI3Apps\\Powertoys.Peek.UI.dll", + "WinUI3Apps\\Powertoys.Peek.UI.exe", + "WinUI3Apps\\Powertoys.Peek.dll", - "PowerToys.FindMyMouse.dll", - "PowerToys.MouseHighlighter.dll", - "PowerToys.MouseJump.dll", - "PowerToys.MouseJump.Common.dll", - "PowerToys.MousePointerCrosshairs.dll", - "PowerToys.MouseJumpUI.dll", - "PowerToys.MouseJumpUI.exe", + "WinUI3Apps\\PowerToys.QuickAccess.dll", + "WinUI3Apps\\PowerToys.QuickAccess.exe", + "WinUI3Apps\\PowerToys.Settings.UI.Controls.dll", - "PowerToys.MouseWithoutBorders.dll", - "PowerToys.MouseWithoutBorders.exe", - "PowerToys.MouseWithoutBordersModuleInterface.dll", - "PowerToys.MouseWithoutBordersService.dll", - "PowerToys.MouseWithoutBordersService.exe", - "PowerToys.MouseWithoutBordersHelper.dll", - "PowerToys.MouseWithoutBordersHelper.exe", + "WinUI3Apps\\PowerToys.EnvironmentVariablesModuleInterface.dll", + "WinUI3Apps\\PowerToys.EnvironmentVariablesUILib.dll", + "WinUI3Apps\\PowerToys.EnvironmentVariables.dll", + "WinUI3Apps\\PowerToys.EnvironmentVariables.exe", - "WinUI3Apps\\PowerToys.NewPlus.ShellExtension.dll", - "WinUI3Apps\\NewPlusPackage.msix", - "WinUI3Apps\\PowerToys.NewPlus.ShellExtension.win10.dll", + "PowerToys.ImageResizer.exe", + "PowerToys.ImageResizer.dll", + "WinUI3Apps\\PowerToys.ImageResizerCLI.exe", + "WinUI3Apps\\PowerToys.ImageResizerCLI.dll", + "PowerToys.ImageResizerExt.dll", + "PowerToys.ImageResizerContextMenu.dll", + "ImageResizerContextMenuPackage.msix", - "PowerAccent.Core.dll", - "PowerToys.PowerAccent.dll", - "PowerToys.PowerAccent.exe", - "PowerToys.PowerAccentModuleInterface.dll", - "PowerToys.PowerAccentKeyboardService.dll", + "PowerToys.LightSwitchModuleInterface.dll", + "LightSwitchService\\PowerToys.LightSwitchService.exe", - "WinUI3Apps\\PowerToys.PowerRenameExt.dll", - "WinUI3Apps\\PowerToys.PowerRename.exe", - "WinUI3Apps\\PowerToys.PowerRenameContextMenu.dll", - "WinUI3Apps\\PowerRenameContextMenuPackage.msix", + "PowerToys.KeyboardManager.dll", + "KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe", + "KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe", + "PowerToys.KeyboardManagerEditorLibraryWrapper.dll", - "PowerToys.WorkspacesSnapshotTool.exe", - "PowerToys.WorkspacesLauncher.exe", - "PowerToys.WorkspacesWindowArranger.exe", - "PowerToys.WorkspacesEditor.exe", - "PowerToys.WorkspacesEditor.dll", - "PowerToys.WorkspacesLauncherUI.exe", - "PowerToys.WorkspacesLauncherUI.dll", - "PowerToys.WorkspacesModuleInterface.dll", - "PowerToys.WorkspacesCsharpLibrary.dll", + "PowerToys.Launcher.dll", + "PowerToys.PowerLauncher.dll", + "PowerToys.PowerLauncher.exe", + "PowerToys.PowerLauncher.Telemetry.dll", - "WinUI3Apps\\PowerToys.RegistryPreviewExt.dll", - "WinUI3Apps\\PowerToys.RegistryPreviewUILib.dll", - "WinUI3Apps\\PowerToys.RegistryPreview.dll", - "WinUI3Apps\\PowerToys.RegistryPreview.exe", + "Wox.Infrastructure.dll", + "Wox.Plugin.dll", + "RunPlugins\\Calculator\\Microsoft.PowerToys.Run.Plugin.Calculator.dll", + "RunPlugins\\Folder\\Microsoft.Plugin.Folder.dll", + "RunPlugins\\Indexer\\Microsoft.Plugin.Indexer.dll", + "RunPlugins\\OneNote\\Microsoft.PowerToys.Run.Plugin.OneNote.dll", + "RunPlugins\\History\\Microsoft.PowerToys.Run.Plugin.History.dll", + "RunPlugins\\PowerToys\\Microsoft.PowerToys.Run.Plugin.PowerToys.dll", + "RunPlugins\\Program\\Microsoft.Plugin.Program.dll", + "RunPlugins\\Registry\\Microsoft.PowerToys.Run.Plugin.Registry.dll", + "RunPlugins\\WindowsSettings\\Microsoft.PowerToys.Run.Plugin.WindowsSettings.dll", + "RunPlugins\\Shell\\Microsoft.Plugin.Shell.dll", + "RunPlugins\\Uri\\Microsoft.Plugin.Uri.dll", + "RunPlugins\\WindowWalker\\Microsoft.Plugin.WindowWalker.dll", + "RunPlugins\\UnitConverter\\Community.PowerToys.Run.Plugin.UnitConverter.dll", + "RunPlugins\\VSCodeWorkspaces\\Community.PowerToys.Run.Plugin.VSCodeWorkspaces.dll", + "RunPlugins\\Service\\Microsoft.PowerToys.Run.Plugin.Service.dll", + "RunPlugins\\System\\Microsoft.PowerToys.Run.Plugin.System.dll", + "RunPlugins\\TimeDate\\Microsoft.PowerToys.Run.Plugin.TimeDate.dll", + "RunPlugins\\ValueGenerator\\Community.PowerToys.Run.Plugin.ValueGenerator.dll", + "RunPlugins\\WebSearch\\Community.PowerToys.Run.Plugin.WebSearch.dll", + "RunPlugins\\WindowsTerminal\\Microsoft.PowerToys.Run.Plugin.WindowsTerminal.dll", - "PowerToys.ShortcutGuide.exe", - "PowerToys.ShortcutGuideModuleInterface.dll", + "WinUI3Apps\\PowerToys.MeasureToolModuleInterface.dll", + "WinUI3Apps\\PowerToys.MeasureToolCore.dll", + "WinUI3Apps\\PowerToys.MeasureToolUI.dll", + "WinUI3Apps\\PowerToys.MeasureToolUI.exe", - "PowerToys.ZoomIt.exe", - "PowerToys.ZoomItModuleInterface.dll", - "PowerToys.ZoomItSettingsInterop.dll", + "PowerToys.FindMyMouse.dll", + "PowerToys.MouseHighlighter.dll", + "PowerToys.MouseJump.dll", + "PowerToys.MouseJump.Common.dll", + "PowerToys.MousePointerCrosshairs.dll", + "PowerToys.MouseJumpUI.dll", + "PowerToys.MouseJumpUI.exe", + "PowerToys.CursorWrap.dll", - "WinUI3Apps\\PowerToys.Settings.dll", - "WinUI3Apps\\PowerToys.Settings.exe", + "PowerToys.MouseWithoutBorders.dll", + "PowerToys.MouseWithoutBorders.exe", + "PowerToys.MouseWithoutBordersModuleInterface.dll", + "PowerToys.MouseWithoutBordersService.dll", + "PowerToys.MouseWithoutBordersService.exe", + "PowerToys.MouseWithoutBordersHelper.dll", + "PowerToys.MouseWithoutBordersHelper.exe", - "PowerToys.CmdPalModuleInterface.dll", - "*Microsoft.CmdPal.UI_*.msix" - ], + "WinUI3Apps\\PowerToys.NewPlus.ShellExtension.dll", + "WinUI3Apps\\NewPlusPackage.msix", + "WinUI3Apps\\PowerToys.NewPlus.ShellExtension.win10.dll", + + "PowerAccent.Core.dll", + "PowerToys.PowerAccent.dll", + "PowerToys.PowerAccent.exe", + "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", + "WinUI3Apps\\PowerRenameContextMenuPackage.msix", + + "PowerToys.WorkspacesSnapshotTool.exe", + "PowerToys.WorkspacesLauncher.exe", + "PowerToys.WorkspacesWindowArranger.exe", + "PowerToys.WorkspacesEditor.exe", + "PowerToys.WorkspacesEditor.dll", + "PowerToys.WorkspacesLauncherUI.exe", + "PowerToys.WorkspacesLauncherUI.dll", + "PowerToys.WorkspacesModuleInterface.dll", + "PowerToys.WorkspacesCsharpLibrary.dll", + + "WinUI3Apps\\PowerToys.RegistryPreviewExt.dll", + "WinUI3Apps\\PowerToys.RegistryPreviewUILib.dll", + "WinUI3Apps\\PowerToys.RegistryPreview.dll", + "WinUI3Apps\\PowerToys.RegistryPreview.exe", + + "PowerToys.ShortcutGuide.exe", + "PowerToys.ShortcutGuideModuleInterface.dll", + + "PowerToys.ZoomIt.exe", + "PowerToys.ZoomItModuleInterface.dll", + "PowerToys.ZoomItSettingsInterop.dll", + + "WinUI3Apps\\PowerToys.Settings.dll", + "WinUI3Apps\\PowerToys.Settings.exe", + + "PowerToys.CmdPalModuleInterface.dll", + "CmdPalKeyboardService.dll", + "PowerToys.ModuleContracts.dll", + "Awake.ModuleServices.dll", + "ColorPicker.ModuleServices.dll", + "Workspaces.ModuleServices.dll", + "Microsoft.CommandPalette.Extensions.dll", + "Microsoft.CommandPalette.Extensions.Toolkit.dll", + "Microsoft.CmdPal.Ext.PowerToys.dll", + "Microsoft.CmdPal.Ext.PowerToys.exe", + "*Microsoft.CmdPal.UI_*.msix", + + "PowerToys.DSC.dll", + "PowerToys.DSC.exe", + + "PowerToysSparse.msix" + ], "SigningInfo": { "Operations": [ { @@ -272,41 +312,32 @@ "Mono.Cecil.Pdb.dll", "Mono.Cecil.Rocks.dll", "Newtonsoft.Json.dll", - "Newtonsoft.Json.Bson.dll", + "CommunityToolkit.WinUI.Controls.TitleBar.dll", + "CommunityToolkit.WinUI.Controls.OpacityMaskView.dll", + "NLog.dll", "HtmlAgilityPack.dll", "Markdig.Signed.dll", "HelixToolkit.dll", "HelixToolkit.Core.Wpf.dll", "Mages.Core.dll", - "JetBrains.Annotations.dll", + "NLog.Extensions.Logging.dll", - "getfilesiginforedist.dll", + "concrt140_app.dll", "msvcp140_1_app.dll", "msvcp140_2_app.dll", "msvcp140_app.dll", + "Namotion.Reflection.dll", + "NJsonSchema.Annotations.dll", + "NJsonSchema.dll", "vcamp140_app.dll", "vccorlib140_app.dll", "vcomp140_app.dll", "vcruntime140_1_app.dll", "vcruntime140_app.dll", - "WinUI3Apps\\CommunityToolkit.Labs.WinUI.SettingsControls.dll", + "UnicodeInformation.dll", - "Vanara.Core.dll", - "Vanara.PInvoke.ComCtl32.dll", - "Vanara.PInvoke.Cryptography.dll", - "Vanara.PInvoke.Gdi32.dll", - "Vanara.PInvoke.Kernel32.dll", - "Vanara.PInvoke.Ole.dll", - "Vanara.PInvoke.Rpc.dll", - "Vanara.PInvoke.Security.dll", - "Vanara.PInvoke.Shared.dll", - "Vanara.PInvoke.Shell32.dll", - "Vanara.PInvoke.ShlwApi.dll", - "Vanara.PInvoke.User32.dll", - "WinUI3Apps\\clrcompression.dll", - "WinUI3Apps\\Microsoft.Graphics.Canvas.Interop.dll", "Microsoft.Web.WebView2.Core.dll", "Microsoft.Web.WebView2.WinForms.dll", "Microsoft.Web.WebView2.Wpf.dll", @@ -325,16 +356,37 @@ "WinUI3Apps\\ReverseMarkdown.dll", "WinUI3Apps\\SharpCompress.dll", "WinUI3Apps\\ZstdSharp.dll", + "CommunityToolkit.WinUI.Controls.MarkdownTextBlock.dll", + "WinUI3Apps\\CommunityToolkit.WinUI.Controls.MarkdownTextBlock.dll", + "Markdig.dll", + "WinUI3Apps\\Markdig.dll", + "RomanNumerals.dll", + "WinUI3Apps\\RomanNumerals.dll", "TestableIO.System.IO.Abstractions.dll", "WinUI3Apps\\TestableIO.System.IO.Abstractions.dll", "TestableIO.System.IO.Abstractions.Wrappers.dll", "WinUI3Apps\\TestableIO.System.IO.Abstractions.Wrappers.dll", "WinUI3Apps\\OpenAI.dll", + "Testably.Abstractions.FileSystem.Interface.dll", + "WinUI3Apps\\Testably.Abstractions.FileSystem.Interface.dll", "ColorCode.Core.dll", - "ColorCode.UWP.dll", + "Microsoft.SemanticKernel.Connectors.Ollama.dll", + "OllamaSharp.dll", + + "boost_regex-vc143-mt-gd-x32-1_87.dll", + "boost_regex-vc143-mt-gd-x64-1_87.dll", + "boost_regex-vc143-mt-x32-1_87.dll", + "boost_regex-vc143-mt-x64-1_87.dll", + + "Microsoft.ML.OnnxRuntime.dll", + "UnitsNet.dll", "UtfUnknown.dll", - "Wpf.Ui.dll" + "Wpf.Ui.dll", + "WmiLight.dll", + "WmiLight.Native.dll", + "Shmuelie.WinRTServer.dll", + "ToolGood.Words.Pinyin.dll" ], "SigningInfo": { "Operations": [ diff --git a/.pipelines/ESRPSigning_installer.json b/.pipelines/ESRPSigning_installer.json deleted file mode 100644 index b20e2cdc82..0000000000 --- a/.pipelines/ESRPSigning_installer.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "Version": "1.0.0", - "UseMinimatch": false, - "SignBatches": [ - { - "MatchedPath": [ - "PowerToysSetupCustomActions.dll", - "PowerToys*Setup-*.exe", - "PowerToys*Setup-*.msi" - ], - "SigningInfo": { - "Operations": [ - { - "KeyCode": "CP-230012", - "OperationSetCode": "SigntoolSign", - "Parameters": [ - { - "parameterName": "OpusName", - "parameterValue": "Microsoft" - }, - { - "parameterName": "OpusInfo", - "parameterValue": "http://www.microsoft.com" - }, - { - "parameterName": "FileDigest", - "parameterValue": "/fd \"SHA256\"" - }, - { - "parameterName": "PageHash", - "parameterValue": "/NPH" - }, - { - "parameterName": "TimeStamp", - "parameterValue": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" - } - ], - "ToolName": "sign", - "ToolVersion": "1.0" - }, - { - "KeyCode": "CP-230012", - "OperationSetCode": "SigntoolVerify", - "Parameters": [], - "ToolName": "sign", - "ToolVersion": "1.0" - } - ] - } - } - ] -} diff --git a/.pipelines/ESRPSigning_sdk.json b/.pipelines/ESRPSigning_sdk.json index 066acf9e4e..e2e2db7701 100644 --- a/.pipelines/ESRPSigning_sdk.json +++ b/.pipelines/ESRPSigning_sdk.json @@ -4,9 +4,66 @@ "SignBatches": [ { "MatchedPath": [ - "Microsoft.CommandPalette.Extensions.dll", "Microsoft.CommandPalette.Extensions.Toolkit.dll" ], + "SigningInfo": { + "Operations": [ + { + "KeyCode": "CP-233904-SN", + "OperationSetCode": "StrongNameSign", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": [] + }, + { + "KeyCode": "CP-233904-SN", + "OperationSetCode": "StrongNameVerify", + "ToolName": "sign", + "ToolVersion": "1.0", + "Parameters": [] + }, + { + "KeyCode": "CP-230012", + "OperationSetCode": "SigntoolSign", + "Parameters": [ + { + "parameterName": "OpusName", + "parameterValue": "Microsoft" + }, + { + "parameterName": "OpusInfo", + "parameterValue": "http://www.microsoft.com" + }, + { + "parameterName": "FileDigest", + "parameterValue": "/fd \"SHA256\"" + }, + { + "parameterName": "PageHash", + "parameterValue": "/NPH" + }, + { + "parameterName": "TimeStamp", + "parameterValue": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + } + ], + "ToolName": "sign", + "ToolVersion": "1.0" + }, + { + "KeyCode": "CP-230012", + "OperationSetCode": "SigntoolVerify", + "Parameters": [], + "ToolName": "sign", + "ToolVersion": "1.0" + } + ] + } + }, + { + "MatchedPath": [ + "Microsoft.CommandPalette.Extensions.dll" + ], "SigningInfo": { "Operations": [ { diff --git a/.pipelines/UpdateVersions.ps1 b/.pipelines/UpdateVersions.ps1 index c19dfb5dec..4e68663236 100644 --- a/.pipelines/UpdateVersions.ps1 +++ b/.pipelines/UpdateVersions.ps1 @@ -1,41 +1,176 @@ Param( - # Using the default value of 1.6 for winAppSdkVersionNumber and useExperimentalVersion as false + # Using the default value of 1.7 for winAppSdkVersionNumber and useExperimentalVersion as false [Parameter(Mandatory=$False,Position=1)] - [string]$winAppSdkVersionNumber = "1.6", + [string]$winAppSdkVersionNumber = "1.8", # When the pipeline calls the PS1 file, the passed parameters are converted to string type [Parameter(Mandatory=$False,Position=2)] - [boolean]$useExperimentalVersion = $False + [boolean]$useExperimentalVersion = $False, + + # Root folder Path for processing + [Parameter(Mandatory=$False,Position=3)] + [string]$rootPath = $(Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)), + + # Root folder Path for processing + [Parameter(Mandatory=$False,Position=4)] + [string]$sourceLink = "https://microsoft.pkgs.visualstudio.com/ProjectReunion/_packaging/Project.Reunion.nuget.internal/nuget/v3/index.json" ) -function Update-NugetConfig { + + +function Read-FileWithEncoding { param ( - [string]$filePath = "nuget.config" + [string]$Path ) - Write-Host "Updating nuget.config file" - [xml]$xml = Get-Content -Path $filePath + $reader = New-Object System.IO.StreamReader($Path, $true) # auto-detect encoding + $content = $reader.ReadToEnd() + $encoding = $reader.CurrentEncoding + $reader.Close() - # Add localpackages source into nuget.config - $packageSourcesNode = $xml.configuration.packageSources - $addNode = $xml.CreateElement("add") - $addNode.SetAttribute("key", "localpackages") - $addNode.SetAttribute("value", "localpackages") - $packageSourcesNode.AppendChild($addNode) | Out-Null - - # Remove <packageSourceMapping> tag and its content - $packageSourceMappingNode = $xml.configuration.packageSourceMapping - if ($packageSourceMappingNode) { - $xml.configuration.RemoveChild($packageSourceMappingNode) | Out-Null + return [PSCustomObject]@{ + Content = $content + Encoding = $encoding } - - # print nuget.config after modification - $xml.OuterXml - # Save the modified nuget.config file - $xml.Save($filePath) } -$sourceLink = "https://microsoft.pkgs.visualstudio.com/ProjectReunion/_packaging/Project.Reunion.nuget.internal/nuget/v3/index.json" +function Write-FileWithEncoding { + param ( + [string]$Path, + [string]$Content, + [System.Text.Encoding]$Encoding + ) + + $writer = New-Object System.IO.StreamWriter($Path, $false, $Encoding) + $writer.Write($Content) + $writer.Close() +} + + +function Add-NuGetSourceAndMapping { + param ( + [xml]$Xml, + [string]$Key, + [string]$Value, + [string[]]$Patterns + ) + + # Ensure packageSources exists + if (-not $Xml.configuration.packageSources) { + $Xml.configuration.AppendChild($Xml.CreateElement("packageSources")) | Out-Null + } + $sources = $Xml.configuration.packageSources + + # Add/Update Source + $sourceNode = $sources.SelectSingleNode("add[@key='$Key']") + if (-not $sourceNode) { + $sourceNode = $Xml.CreateElement("add") + $sourceNode.SetAttribute("key", $Key) + $sources.AppendChild($sourceNode) | Out-Null + } + $sourceNode.SetAttribute("value", $Value) + + # Ensure packageSourceMapping exists + if (-not $Xml.configuration.packageSourceMapping) { + $Xml.configuration.AppendChild($Xml.CreateElement("packageSourceMapping")) | Out-Null + } + $mapping = $Xml.configuration.packageSourceMapping + + # Remove invalid packageSource nodes (missing key or empty key) + $invalidNodes = $mapping.SelectNodes("packageSource[not(@key) or @key='']") + if ($invalidNodes) { + foreach ($node in $invalidNodes) { + $mapping.RemoveChild($node) | Out-Null + } + } + + # Add/Update Mapping Source + $mappingSource = $mapping.SelectSingleNode("packageSource[@key='$Key']") + if (-not $mappingSource) { + $mappingSource = $Xml.CreateElement("packageSource") + $mappingSource.SetAttribute("key", $Key) + # Insert at top for priority + if ($mapping.HasChildNodes) { + $mapping.InsertBefore($mappingSource, $mapping.FirstChild) | Out-Null + } else { + $mapping.AppendChild($mappingSource) | Out-Null + } + } + + # Double check and force attribute + if (-not $mappingSource.HasAttribute("key")) { + $mappingSource.SetAttribute("key", $Key) + } + + # Update Patterns + # RemoveAll() removes all child nodes AND attributes, so we must re-set the key afterwards + $mappingSource.RemoveAll() + $mappingSource.SetAttribute("key", $Key) + + foreach ($pattern in $Patterns) { + $pkg = $Xml.CreateElement("package") + $pkg.SetAttribute("pattern", $pattern) + $mappingSource.AppendChild($pkg) | Out-Null + } +} + +function Resolve-WinAppSdkSplitDependencies { + Write-Host "Version $WinAppSDKVersion detected. Resolving split dependencies..." + $installDir = Join-Path $rootPath "localpackages\output" + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + + # Create a temporary nuget.config to avoid interference from the repo's config + $tempConfig = Join-Path $env:TEMP "nuget_$(Get-Random).config" + Set-Content -Path $tempConfig -Value "<?xml version='1.0' encoding='utf-8'?><configuration><packageSources><clear /><add key='TempSource' value='$sourceLink' /></packageSources></configuration>" + + try { + # Extract BuildTools version from Directory.Packages.props to ensure we have the required version + $dirPackagesProps = Join-Path $rootPath "Directory.Packages.props" + if (Test-Path $dirPackagesProps) { + $propsContent = Get-Content $dirPackagesProps -Raw + if ($propsContent -match '<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="([^"]+)"') { + $buildToolsVersion = $Matches[1] + Write-Host "Downloading Microsoft.Windows.SDK.BuildTools version $buildToolsVersion..." + $nugetArgsBuildTools = "install Microsoft.Windows.SDK.BuildTools -Version $buildToolsVersion -ConfigFile $tempConfig -OutputDirectory $installDir -NonInteractive -NoCache" + Invoke-Expression "nuget $nugetArgsBuildTools" | Out-Null + } + } + + # Download package to inspect nuspec and keep it for the build + $nugetArgs = "install Microsoft.WindowsAppSDK -Version $WinAppSDKVersion -ConfigFile $tempConfig -OutputDirectory $installDir -NonInteractive -NoCache" + Invoke-Expression "nuget $nugetArgs" | Out-Null + + # Parse dependencies from the installed folders + # Folder structure is typically {PackageId}.{Version} + $directories = Get-ChildItem -Path $installDir -Directory + $allLocalPackages = @() + foreach ($dir in $directories) { + # Match any package pattern: PackageId.Version + if ($dir.Name -match "^(.+?)\.(\d+\..*)$") { + $pkgId = $Matches[1] + $pkgVer = $Matches[2] + $allLocalPackages += $pkgId + + $packageVersions[$pkgId] = $pkgVer + Write-Host "Found dependency: $pkgId = $pkgVer" + } + } + + # Update repo's nuget.config to use localpackages + $nugetConfig = Join-Path $rootPath "nuget.config" + $configData = Read-FileWithEncoding -Path $nugetConfig + [xml]$xml = $configData.Content + + Add-NuGetSourceAndMapping -Xml $xml -Key "localpackages" -Value $installDir -Patterns $allLocalPackages + + $xml.Save($nugetConfig) + Write-Host "Updated nuget.config with localpackages mapping." + } catch { + Write-Warning "Failed to resolve dependencies: $_" + } finally { + Remove-Item $tempConfig -Force -ErrorAction SilentlyContinue + } +} # Execute nuget list and capture the output if ($useExperimentalVersion) { @@ -78,53 +213,36 @@ if ($latestVersion) { exit 1 } -# Update packages.config files -Get-ChildItem -Recurse packages.config | ForEach-Object { - $content = Get-Content $_.FullName -Raw - if ($content -match 'package id="Microsoft.WindowsAppSDK"') { - $newVersionString = 'package id="Microsoft.WindowsAppSDK" version="' + $WinAppSDKVersion + '"' - $oldVersionString = 'package id="Microsoft.WindowsAppSDK" version="[-.0-9a-zA-Z]*"' - $content = $content -replace $oldVersionString, $newVersionString - Set-Content -Path $_.FullName -Value $content - Write-Host "Modified " $_.FullName - } -} +# Resolve dependencies for 1.8+ +$packageVersions = @{ "Microsoft.WindowsAppSDK" = $WinAppSDKVersion } + +Resolve-WinAppSdkSplitDependencies # Update Directory.Packages.props file -$propsFile = "Directory.Packages.props" -if (Test-Path $propsFile) { - $content = Get-Content $propsFile -Raw - if ($content -match '<PackageVersion Include="Microsoft.WindowsAppSDK"') { - $newVersionString = '<PackageVersion Include="Microsoft.WindowsAppSDK" Version="' + $WinAppSDKVersion + '" />' - $oldVersionString = '<PackageVersion Include="Microsoft.WindowsAppSDK" Version="[-.0-9a-zA-Z]*" />' - $content = $content -replace $oldVersionString, $newVersionString - Set-Content -Path $propsFile -Value $content - Write-Host "Modified " $propsFile - } -} +Get-ChildItem -Path $rootPath -Recurse "Directory.Packages.props" | ForEach-Object { + $file = Read-FileWithEncoding -Path $_.FullName + $content = $file.Content + $isModified = $false + + foreach ($pkgId in $packageVersions.Keys) { + $ver = $packageVersions[$pkgId] + # Escape dots in package ID for regex + $pkgIdRegex = $pkgId -replace '\.', '\.' + + $newVersionString = "<PackageVersion Include=""$pkgId"" Version=""$ver"" />" + $oldVersionString = "<PackageVersion Include=""$pkgIdRegex"" Version=""[-.0-9a-zA-Z]*"" />" -# Update .vcxproj files -Get-ChildItem -Recurse *.vcxproj | ForEach-Object { - $content = Get-Content $_.FullName -Raw - if ($content -match '\\Microsoft.WindowsAppSDK.') { - $newVersionString = '\Microsoft.WindowsAppSDK.' + $WinAppSDKVersion + '\' - $oldVersionString = '\\Microsoft.WindowsAppSDK.[-.0-9a-zA-Z]*\\' - $content = $content -replace $oldVersionString, $newVersionString - Set-Content -Path $_.FullName -Value $content + if ($content -match "<PackageVersion Include=""$pkgIdRegex""") { + # Update existing package + if ($content -notmatch [regex]::Escape($newVersionString)) { + $content = $content -replace $oldVersionString, $newVersionString + $isModified = $true + } + } + } + + if ($isModified) { + Write-FileWithEncoding -Path $_.FullName -Content $content -Encoding $file.encoding Write-Host "Modified " $_.FullName } } - -# Update .csproj files -Get-ChildItem -Recurse *.csproj | ForEach-Object { - $content = Get-Content $_.FullName -Raw - if ($content -match 'PackageReference Include="Microsoft.WindowsAppSDK"') { - $newVersionString = 'PackageReference Include="Microsoft.WindowsAppSDK" Version="'+ $WinAppSDKVersion + '"' - $oldVersionString = 'PackageReference Include="Microsoft.WindowsAppSDK" Version="[-.0-9a-zA-Z]*"' - $content = $content -replace $oldVersionString, $newVersionString - Set-Content -Path $_.FullName -Value $content - Write-Host "Modified " $_.FullName - } -} - -Update-NugetConfig diff --git a/.pipelines/applyXamlStyling.ps1 b/.pipelines/applyXamlStyling.ps1 index 7cb7b4a4b0..1facedc569 100644 --- a/.pipelines/applyXamlStyling.ps1 +++ b/.pipelines/applyXamlStyling.ps1 @@ -41,6 +41,9 @@ Write-Output "" Write-Output "Restoring dotnet tools..." dotnet tool restore --disable-parallel --no-cache +# Use Regex syntax +$PathExcludes = "(\\obj\\)|(\\bin\\)|(\\x64\\)|(\\Generated Files\\PowerRenameXAML\\)|(\\RegistryPreviewUILib\\Controls\\HexBox\\)" + if (-not $Passive) { # Look for unstaged changed files by default @@ -87,7 +90,7 @@ if (-not $Passive) } Write-Output "Running Git Diff: $gitDiffCommand" - $files = Invoke-Expression $gitDiffCommand | Select-String -Pattern "\.xaml$" + $files = Invoke-Expression $gitDiffCommand | Select-String -Pattern "\.xaml$" | Where-Object { $_ -notmatch $PathExcludes } if (-not $Passive -and -not $Main -and -not $Unstaged -and -not $Staged -and -not $LastCommit) { @@ -107,7 +110,7 @@ if (-not $Passive) else { Write-Output "Checking all files (passively)" - $files = Get-ChildItem -Path "$PSScriptRoot\..\src\*.xaml" -Recurse | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch "(\\obj\\)|(\\bin\\)|(\\x64\\)|(\\Generated Files\\PowerRenameXAML\\)" } + $files = Get-ChildItem -Path "$PSScriptRoot\..\src\*.xaml" -Recurse | Select-Object -ExpandProperty FullName | Where-Object { $_ -notmatch $PathExcludes } if ($files.count -gt 0) { diff --git a/.pipelines/generateDscManifests.ps1 b/.pipelines/generateDscManifests.ps1 new file mode 100644 index 0000000000..e0a2f463af --- /dev/null +++ b/.pipelines/generateDscManifests.ps1 @@ -0,0 +1,95 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BuildPlatform, + + [Parameter(Mandatory = $true)] + [string]$BuildConfiguration, + + [Parameter()] + [string]$RepoRoot = (Get-Location).Path +) + +$ErrorActionPreference = 'Stop' + +function Resolve-PlatformDirectory { + param( + [string]$Root, + [string]$Platform + ) + + $normalized = $Platform.Trim() + $candidates = @() + $candidates += Join-Path $Root $normalized + $candidates += Join-Path $Root ($normalized.ToUpperInvariant()) + $candidates += Join-Path $Root ($normalized.ToLowerInvariant()) + $candidates = $candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique + + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + return $candidates[0] +} + +Write-Host "Repo root: $RepoRoot" +Write-Host "Requested build platform: $BuildPlatform" +Write-Host "Requested configuration: $BuildConfiguration" + +# Always use x64 PowerToys.DSC.exe since CI/CD machines are x64 +$exePlatform = 'x64' +$exeRoot = Resolve-PlatformDirectory -Root $RepoRoot -Platform $exePlatform +$exeOutputDir = Join-Path $exeRoot $BuildConfiguration +$exePath = Join-Path $exeOutputDir 'PowerToys.DSC.exe' + +Write-Host "Using x64 PowerToys.DSC.exe to generate DSC manifests for $BuildPlatform build" + +if (-not (Test-Path $exePath)) { + throw "PowerToys.DSC.exe not found at '$exePath'. Make sure it has been built first." +} + +Write-Host "Using PowerToys.DSC.exe at '$exePath'." + +# Output DSC manifests to the target build platform directory (x64, ARM64, etc.) +$outputRoot = Resolve-PlatformDirectory -Root $RepoRoot -Platform $BuildPlatform +if (-not (Test-Path $outputRoot)) { + Write-Host "Creating missing platform output root at '$outputRoot'." + New-Item -Path $outputRoot -ItemType Directory -Force | Out-Null +} + +$outputDir = Join-Path $outputRoot $BuildConfiguration +if (-not (Test-Path $outputDir)) { + Write-Host "Creating missing configuration output directory at '$outputDir'." + New-Item -Path $outputDir -ItemType Directory -Force | Out-Null +} + +# DSC v3 manifests go to DSCModules subfolder +$dscOutputDir = Join-Path $outputDir 'DSCModules' +if (-not (Test-Path $dscOutputDir)) { + Write-Host "Creating DSCModules subfolder at '$dscOutputDir'." + New-Item -Path $dscOutputDir -ItemType Directory -Force | Out-Null +} + +Write-Host "DSC manifests will be generated to: '$dscOutputDir'" + +Write-Host "Cleaning previously generated DSC manifest files from '$dscOutputDir'." +Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction SilentlyContinue | Remove-Item -Force + +$arguments = @('manifest', '--resource', 'settings', '--outputDir', $dscOutputDir) +Write-Host "Invoking DSC manifest generator: '$exePath' $($arguments -join ' ')" +& $exePath @arguments +if ($LASTEXITCODE -ne 0) { + throw "PowerToys.DSC.exe exited with code $LASTEXITCODE" +} + +$generatedFiles = Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction Stop +if ($generatedFiles.Count -eq 0) { + throw "No DSC manifest files were generated in '$dscOutputDir'." +} + +Write-Host "Generated $($generatedFiles.Count) DSC manifest file(s):" +foreach ($file in $generatedFiles) { + Write-Host " - $($file.FullName)" +} diff --git a/.pipelines/installPowertoys.ps1 b/.pipelines/installPowertoys.ps1 new file mode 100644 index 0000000000..26abc5271f --- /dev/null +++ b/.pipelines/installPowertoys.ps1 @@ -0,0 +1,46 @@ +param( + [Parameter()] + [ValidateSet("Machine", "PerUser")] + [string]$InstallMode = "Machine" +) + +$ProgressPreference = 'SilentlyContinue' + +# Get artifact path +$ArtifactPath = $ENV:BUILD_ARTIFACTSTAGINGDIRECTORY +if (-not $ArtifactPath) { + throw "BUILD_ARTIFACTSTAGINGDIRECTORY environment variable not set" +} + +# Since we only download PowerToysSetup-*.exe files, we can directly find it +$Installer = Get-ChildItem -Path $ArtifactPath -Filter 'PowerToys*.exe' | Select-Object -First 1 + +if (-not $Installer) { + throw "PowerToys installer not found" +} + +Write-Host "Installing PowerToys: $($Installer.Name)" + +# Install PowerToys +$Process = Start-Process -Wait -FilePath $Installer.FullName -ArgumentList "/passive", "/norestart" -PassThru -NoNewWindow + +if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) { + Write-Host "✅ PowerToys installation completed successfully" +} else { + throw "PowerToys installation failed with exit code: $($Process.ExitCode)" +} + +# Verify installation +if ($InstallMode -eq "PerUser") { + if (Test-Path "${env:LOCALAPPDATA}\PowerToys\PowerToys.exe") { + Write-Host "✅ PowerToys verified at: ${env:LOCALAPPDATA}\PowerToys\PowerToys.exe" + } else { + throw "PowerToys installation verification failed" + } +} else { + if (Test-Path "${env:ProgramFiles}\PowerToys\PowerToys.exe") { + Write-Host "✅ PowerToys verified at: ${env:ProgramFiles}\PowerToys\PowerToys.exe" + } else { + throw "PowerToys installation verification failed" + } +} diff --git a/.pipelines/installWiX.ps1 b/.pipelines/installWiX.ps1 deleted file mode 100644 index 3b6d783c85..0000000000 --- a/.pipelines/installWiX.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -$ProgressPreference = 'SilentlyContinue' - -$WixDownloadUrl = "https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe" -$WixBinariesDownloadUrl = "https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip" - -# Download WiX binaries and verify their hash sums -Invoke-WebRequest -Uri $WixDownloadUrl -OutFile "$($ENV:Temp)\wix314.exe" -$Hash = (Get-FileHash -Algorithm SHA256 "$($ENV:Temp)\wix314.exe").Hash -if ($Hash -ne '6BF6D03D6923D9EF827AE1D943B90B42B8EBB1B0F68EF6D55F868FA34C738A29') -{ - Write-Error "$WixHash" - throw "wix314.exe has unexpected SHA256 hash: $Hash" -} -Invoke-WebRequest -Uri $WixBinariesDownloadUrl -OutFile "$($ENV:Temp)\wix314-binaries.zip" -$Hash = (Get-FileHash -Algorithm SHA256 "$($ENV:Temp)\wix314-binaries.zip").Hash -if($Hash -ne '6AC824E1642D6F7277D0ED7EA09411A508F6116BA6FAE0AA5F2C7DAA2FF43D31') -{ - throw "wix314-binaries.zip has unexpected SHA256 hash: $Hash" -} - -# Install WiX -Start-Process -Wait -FilePath "$($ENV:Temp)\wix314.exe" -ArgumentList "/install /quiet" - -# Extract WiX binaries and copy wix.targets to the installed dir -Expand-Archive -Path "$($ENV:Temp)\wix314-binaries.zip" -Force -DestinationPath "$($ENV:Temp)" -Copy-Item -Path "$($ENV:Temp)\wix.targets" -Destination "C:\Program Files (x86)\WiX Toolset v3.14\" \ No newline at end of file diff --git a/.pipelines/loc/loc.yml b/.pipelines/loc/loc.yml index 8d582c4830..2abc298652 100644 --- a/.pipelines/loc/loc.yml +++ b/.pipelines/loc/loc.yml @@ -25,12 +25,12 @@ steps: fetchDepth: 1 # Don't need a deep checkout for loc files! persistCredentials: true -- task: MicrosoftTDBuild.tdbuild-task.tdbuild-task.TouchdownBuildTask@3 +- task: MicrosoftTDBuild.tdbuild-task.tdbuild-task.TouchdownBuildTask@5 displayName: 'Touchdown Build - 37400, PRODEXT' inputs: teamId: 37400 - TDBuildServiceConnection: $(TouchdownServiceConnection) - authType: SubjectNameIssuer + FederatedIdentityTDBuildServiceConnection: $(TouchdownServiceConnection) + authType: FederatedIdentityTDBuild resourceFilePath: | src\**\Resources.resx src\**\Resource.resx diff --git a/.pipelines/packages.config b/.pipelines/packages.config index 43fa34c91c..1e9b92d3b7 100644 --- a/.pipelines/packages.config +++ b/.pipelines/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.PowerToys.Telemetry" version="2.0.2" /> + <package id="Microsoft.PowerToys.Telemetry" version="2.0.4" /> </packages> diff --git a/.pipelines/tsa.json b/.pipelines/tsa.json index 351545613f..558515675c 100644 --- a/.pipelines/tsa.json +++ b/.pipelines/tsa.json @@ -3,5 +3,5 @@ "notificationAliases": ["powertoys@microsoft.com"], "instanceUrl": "https://microsoft.visualstudio.com", "projectName": "OS", - "areaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\SHINE\\PowerToys" + "areaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\PowerToys" } diff --git a/.pipelines/v2/ci-nightly.yml b/.pipelines/v2/ci-nightly.yml new file mode 100644 index 0000000000..1f49359f66 --- /dev/null +++ b/.pipelines/v2/ci-nightly.yml @@ -0,0 +1,38 @@ +# .pipelines/v2/nightly-prewarm.yml +# Nightly pre-warm that reuses your existing ci.yml as-is + +trigger: none +pr: none + +# (18:00 UTC) — adjust as you like +schedules: + - cron: "0 18 * * *" # UTC + displayName: Nightly pre-warm (main) + branches: + include: + - main + always: true + +name: $(BuildDefinitionName)_$(date:yyMM).$(date:dd)$(rev:rrr) + +parameters: + - name: buildPlatforms + type: object + default: + - x64 + - arm64 + - name: enableMsBuildCaching + type: boolean + displayName: "Enable MSBuild Caching" + default: true + - name: msBuildCacheIsReadOnly + type: boolean + displayName: "MSBuild Cache Read Only" + default: false + +extends: + template: templates/pipeline-ci-build.yml + parameters: + buildPlatforms: ${{ parameters.buildPlatforms }} + enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} + msBuildCacheIsReadOnly: ${{ parameters.msBuildCacheIsReadOnly }} \ No newline at end of file diff --git a/.pipelines/v2/ci-test-with-canary-webview2.yml b/.pipelines/v2/ci-test-with-canary-webview2.yml index 01f0454186..1efb4d4abf 100644 --- a/.pipelines/v2/ci-test-with-canary-webview2.yml +++ b/.pipelines/v2/ci-test-with-canary-webview2.yml @@ -19,7 +19,7 @@ parameters: - name: enableMsBuildCaching type: boolean displayName: "Enable MSBuild Caching" - default: false + default: true - name: runTests type: boolean displayName: "Run Tests" @@ -36,7 +36,8 @@ extends: template: templates/pipeline-ci-build.yml parameters: buildPlatforms: ${{ parameters.buildPlatforms }} - enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} + ${{ if eq(variables['System.PullRequest.IsFork'], 'False') }}: + enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} runTests: ${{ parameters.runTests }} useVSPreview: ${{ parameters.useVSPreview }} useLatestWebView2: ${{ parameters.useLatestWebView2 }} diff --git a/.pipelines/v2/ci-using-the-latest-winappsdk.yml b/.pipelines/v2/ci-using-the-latest-winappsdk.yml index cc9f00f80d..16639f44c0 100644 --- a/.pipelines/v2/ci-using-the-latest-winappsdk.yml +++ b/.pipelines/v2/ci-using-the-latest-winappsdk.yml @@ -33,7 +33,7 @@ parameters: default: true - name: winAppSDKVersionNumber type: string - default: 1.6 + default: 1.8 - name: useExperimentalVersion type: boolean default: false @@ -42,7 +42,8 @@ extends: template: templates/pipeline-ci-build.yml parameters: buildPlatforms: ${{ parameters.buildPlatforms }} - enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} + ${{ if eq(variables['System.PullRequest.IsFork'], 'False') }}: + enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} runTests: ${{ parameters.runTests }} useVSPreview: ${{ parameters.useVSPreview }} useLatestWinAppSDK: ${{ parameters.useLatestWinAppSDK }} diff --git a/.pipelines/v2/ci.yml b/.pipelines/v2/ci.yml index 0b8daa76d6..297c268757 100644 --- a/.pipelines/v2/ci.yml +++ b/.pipelines/v2/ci.yml @@ -46,6 +46,7 @@ extends: template: templates/pipeline-ci-build.yml parameters: buildPlatforms: ${{ parameters.buildPlatforms }} - enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} + ${{ if eq(variables['System.PullRequest.IsFork'], 'False') }}: + enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} runTests: ${{ parameters.runTests }} useVSPreview: ${{ parameters.useVSPreview }} diff --git a/.pipelines/v2/oneFuzz.yml b/.pipelines/v2/oneFuzz.yml index 2bcbf36050..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] @@ -43,6 +45,7 @@ stages: enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} runTests: true useVSPreview: ${{ parameters.useVSPreview }} + timeoutInMinutes: 90 - stage: OneFuzz displayName: Fuzz ${{ parameters.platform }} diff --git a/.pipelines/v2/release.yml b/.pipelines/v2/release.yml index 26819c5d14..d3adc45f04 100644 --- a/.pipelines/v2/release.yml +++ b/.pipelines/v2/release.yml @@ -20,16 +20,6 @@ parameters: type: string default: '0.0.1' - - name: cmdPalVersionNumber - displayName: "Command Palette Version Number" - type: string - default: '0.0.1' - - - name: cmdPalSdkVersionNumber - displayName: "Command Palette SDK Version Number" - type: string - default: '0.0.1' - - name: buildConfigurations displayName: "Build Configurations" type: object @@ -50,6 +40,9 @@ parameters: name: $(BuildDefinitionName)_$(date:yyMM).$(date:dd)$(rev:rrr) +variables: + - template: templates/variables-nuget-package-version.yml + extends: template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates parameters: @@ -58,14 +51,18 @@ extends: pool: name: SHINE-INT-S ${{ if eq(parameters.useVSPreview, true) }}: - demands: ImageOverride -equals SHINE-VS17-Preview + demands: ImageOverride -equals SHINE-VS18-Preview ${{ else }}: - image: SHINE-VS17-Latest + demands: ImageOverride -equals SHINE-VS18-Latest os: windows sdl: tsa: enabled: true configFile: '$(Build.SourcesDirectory)\.pipelines\tsa.json' + binskim: + enabled: true + # Exclude every dll/exe in tests/*, as well as all msdia*, covrun* and vcruntime* + analyzeTargetGlob: +:file|$(Build.ArtifactStagingDirectory)/**/*.dll;+:file|$(Build.ArtifactStagingDirectory)/**/*.exe;-:file:regex|tests.*\.(dll|exe)$;-:file:regex|(covrun.*)\.dll$;-:file:regex|(msdia.*)\.dll$;-:file:regex|(vcruntime.*)\.dll$ stages: - stage: Build @@ -76,10 +73,12 @@ extends: parameters: pool: name: SHINE-INT-L - ${{ if eq(parameters.useVSPreview, true) }}: - demands: ImageOverride -equals SHINE-VS17-Preview - ${{ else }}: - image: SHINE-VS17-Latest + demands: + # Our INT agents have a large disk mounted at P:\ + - ${{ if eq(parameters.useVSPreview, true) }}: + - 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 @@ -88,10 +87,11 @@ extends: buildPlatforms: ${{ parameters.buildPlatforms }} buildConfigurations: ${{ parameters.buildConfigurations }} versionNumber: ${{ parameters.versionNumber }} - cmdPalVersionNumber: ${{ parameters.cmdPalVersionNumber }} publishArtifacts: false # 1ES PT handles publication for us. + official: true codeSign: true runTests: false + buildTests: false signingIdentity: serviceName: $(SigningServiceName) appId: $(SigningAppId) @@ -102,20 +102,22 @@ extends: useManagedIdentity: $(SigningUseManagedIdentity) clientId: $(SigningOriginalClientId) # Have msbuild use the release nuget config profile - additionalBuildOptions: /p:RestoreConfigFile="$(Build.SourcesDirectory)\.pipelines\release-nuget.config" + additionalBuildOptions: /p:RestoreConfigFile="$(Build.SourcesDirectory)\.pipelines\release-nuget.config" /p:EnableCmdPalAOT=true beforeBuildSteps: # Sets versions for all PowerToy created DLLs - pwsh: |- - .pipelines/versionSetting.ps1 -versionNumber '${{ parameters.versionNumber }}' -DevEnvironment '' -cmdPalVersionNumber '${{ parameters.cmdPalVersionNumber }}' + .pipelines/versionSetting.ps1 -versionNumber '${{ parameters.versionNumber }}' -DevEnvironment '' displayName: Prepare versioning # Prepare the localizations and telemetry config before the release build - template: .pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml@self - - script: | - call nuget.exe restore -configFile .pipelines/release-nuget.config -PackagesDirectory . .pipelines/packages.config || exit /b 1 - move /Y "Microsoft.PowerToys.Telemetry.2.0.2\build\include\TraceLoggingDefines.h" "src\common\Telemetry\TraceLoggingDefines.h" || exit /b 1 - move /Y "Microsoft.PowerToys.Telemetry.2.0.2\build\include\TelemetryBase.cs" "src\common\Telemetry\TelemetryBase.cs" || exit /b 1 + - pwsh: |- + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + & nuget.exe restore -configFile .pipelines/release-nuget.config -PackagesDirectory . .pipelines/packages.config + Move-Item -Force -Verbose "Microsoft.PowerToys.Telemetry.*\build\include\TraceLoggingDefines.h" "src\common\Telemetry\TraceLoggingDefines.h" + Move-Item -Force -Verbose "Microsoft.PowerToys.Telemetry.*\build\include\TelemetryBase.cs" "src\common\Telemetry\TelemetryBase.cs" displayName: Emplace telemetry files - stage: Build_SDK @@ -126,10 +128,9 @@ extends: parameters: pool: name: SHINE-INT-L - image: SHINE-VS17-Latest os: windows + official: true codeSign: true - sdkVersionNumber: ${{ parameters.cmdPalSdkVersionNumber }} signingIdentity: serviceName: $(SigningServiceName) appId: $(SigningAppId) @@ -148,5 +149,7 @@ extends: parameters: versionNumber: ${{ parameters.versionNumber }} includePublicSymbolServer: ${{ parameters.publishSymbolsToPublic }} + ${{ if ne(parameters.publishSymbolsToPublic, true) }}: + symbolExpiryTime: 10 # For private builds, expire symbols within 10 days. The default is 100 years. subscription: $(SymbolPublishingServiceConnection) symbolProject: $(SymbolPublishingProject) diff --git a/.pipelines/v2/templates/job-build-project.yml b/.pipelines/v2/templates/job-build-project.yml index 80ec52d9d4..e41bfbc0ad 100644 --- a/.pipelines/v2/templates/job-build-project.yml +++ b/.pipelines/v2/templates/job-build-project.yml @@ -11,6 +11,9 @@ parameters: default: - x64 - arm64 + - name: official + type: boolean + default: false - name: codeSign type: boolean default: false @@ -47,18 +50,21 @@ parameters: - name: enableMsBuildCaching type: boolean default: false + - name: msBuildCacheIsReadOnly + type: boolean + default: true - name: runTests type: boolean default: true + - name: buildTests + type: boolean + default: true - name: useVSPreview type: boolean default: false - name: versionNumber type: string default: '0.0.1' - - name: cmdPalVersionNumber - type: string - default: '0.0.1' - name: useLatestWinAppSDK type: boolean default: false @@ -78,6 +84,12 @@ parameters: - 'src/modules/previewpane/SvgPreviewHandler/SvgPreviewHandler.csproj' - 'src/modules/previewpane/SvgThumbnailProvider/SvgThumbnailProvider.csproj' - 'src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithUI.csproj' + - name: timeoutInMinutes + type: number + default: 240 + - name: cancelTimeoutInMinutes + type: number + default: 1 jobs: - job: ${{ parameters.jobName }} @@ -99,7 +111,8 @@ jobs: ${{ else }}: OutputBuildPlatform: ${{ platform }} variables: - MakeAppxPath: 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x86\MakeAppx.exe' + NUGET_PACKAGES: 'C:\NuGetPackages' # Some of our build steps cache these here... and it was apparently part of the global environment + MakeAppxPath: 'C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x86\MakeAppx.exe' # Azure DevOps abhors a vacuum # If these are blank, expansion will fail later on... which will result in direct substitution of the variable *names* # later on. We'll just... set them to a single space and if we need to, check IsNullOrWhiteSpace. @@ -110,23 +123,27 @@ jobs: JobOutputArtifactName: build-$(BuildPlatform)-$(BuildConfiguration)${{ parameters.artifactStem }} NUGET_RESTORE_MSBUILD_ARGS: /p:Platform=$(BuildPlatform) # Required for nuget to work due to self contained NODE_OPTIONS: --max_old_space_size=16384 - ${{ if eq(parameters.runTests, true) }}: + ${{ if or(eq(parameters.runTests, true), eq(parameters.buildTests, true)) }}: MSBuildMainBuildTargets: Build;Test ${{ else }}: MSBuildMainBuildTargets: Build ${{ insert }}: ${{ parameters.variables }} ${{ if eq(parameters.useLatestWinAppSDK, true) }}: - RestoreAdditionalProjectSourcesArg: '/p:RestoreAdditionalProjectSources="$(Build.SourcesDirectory)\localpackages\NugetPackages"' + RestoreAdditionalProjectSourcesArg: '/p:RestoreAdditionalProjectSources="$(Build.SourcesDirectory)\localpackages\NugetPackages" /p:IgnoreExperimentalWarnings=true' ${{ else }}: RestoreAdditionalProjectSourcesArg: '' displayName: Build - timeoutInMinutes: 240 - cancelTimeoutInMinutes: 1 + timeoutInMinutes: ${{ parameters.timeoutInMinutes }} + cancelTimeoutInMinutes: ${{ parameters.cancelTimeoutInMinutes }} templateContext: # Required when this template is hosted in 1ES PT outputs: - output: pipelineArtifact artifactName: $(JobOutputArtifactName) targetPath: $(Build.ArtifactStagingDirectory) + - output: pipelineArtifact + artifactName: $(JobOutputArtifactName)-failure-$(System.JobAttempt) + targetPath: $(LogOutputDirectory) + condition: or(failed(), canceled()) steps: - checkout: self clean: true @@ -142,6 +159,11 @@ jobs: $MSBuildCacheParameters += " -reportfileaccesses" $MSBuildCacheParameters += " -p:MSBuildCacheEnabled=true" $MSBuildCacheParameters += " -p:MSBuildCacheLogDirectory=$(LogOutputDirectory)\MSBuildCacheLogs" + # Cache read-only policy controlled by parameter + $cacheIsReadOnly = "${{ parameters.msBuildCacheIsReadOnly }}" + if ($cacheIsReadOnly -eq "True") { + $MSBuildCacheParameters += " /p:MSBuildCacheRemoteCacheIsReadOnly=true" + } Write-Host "MSBuildCacheParameters: $MSBuildCacheParameters" Write-Host "##vso[task.setvariable variable=MSBuildCacheParameters]$MSBuildCacheParameters" displayName: Prepare MSBuildCache variables @@ -170,14 +192,14 @@ jobs: displayName: Verify XAML formatting - pwsh: |- - & '.pipelines/verifyNugetPackages.ps1' -solution '$(build.sourcesdirectory)\PowerToys.sln' - displayName: Verify Nuget package versions for PowerToys.sln + & '.pipelines/verifyNugetPackages.ps1' -solution '$(build.sourcesdirectory)\PowerToys.slnx' + displayName: Verify Nuget package versions for PowerToys.slnx - pwsh: |- - & '.pipelines/verifyArm64Configuration.ps1' -solution '$(build.sourcesdirectory)\PowerToys.sln' + & '.pipelines/verifyArm64Configuration.ps1' -solution '$(build.sourcesdirectory)\PowerToys.slnx' & '.pipelines/verifyArm64Configuration.ps1' -solution '$(build.sourcesdirectory)\tools\BugReportTool\BugReportTool.sln' & '.pipelines/verifyArm64Configuration.ps1' -solution '$(build.sourcesdirectory)\tools\StylesReportTool\StylesReportTool.sln' - & '.pipelines/verifyArm64Configuration.ps1' -solution '$(build.sourcesdirectory)\installer\PowerToysSetup.sln' + & '.pipelines/verifyArm64Configuration.ps1' -solution '$(build.sourcesdirectory)\installer\PowerToysSetup.slnx' displayName: Verify ARM64 configurations - ${{ if eq(parameters.enablePackageCaching, true) }}: @@ -215,9 +237,12 @@ jobs: env: VCWhereExtraVersionTarget: '-prerelease' - - pwsh: |- - & "$(build.sourcesdirectory)\.pipelines\installWiX.ps1" - displayName: Download and install WiX 3.14 development build + - ${{ if eq(parameters.official, true) }}: + - template: .\steps-setup-versioning.yml + parameters: + directory: $(build.sourcesdirectory)\src\modules\cmdpal + + - ${{ parameters.beforeBuildSteps }} @@ -227,12 +252,13 @@ jobs: ${{ else }}: displayName: Build PowerToys main project inputs: - solution: 'PowerToys.sln' - vsVersion: 17.0 + solution: 'PowerToys.slnx' + 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) @@ -246,6 +272,43 @@ jobs: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + - task: VSBuild@1 + displayName: Generate DSC artifacts for ARM64 + condition: and(succeeded(), eq(variables['BuildPlatform'], 'arm64')) + inputs: + solution: PowerToys.slnx + vsVersion: 18.0 + msbuildArgs: >- + -restore + /p:Configuration=$(BuildConfiguration) + /p:Platform=x64 + /t:DSC\PowerToys_Settings_DSC_Schema_Generator + /bl:$(LogOutputDirectory)\build-dsc-generator.binlog + ${{ parameters.additionalBuildOptions }} + $(MSBuildCacheParameters) + $(RestoreAdditionalProjectSourcesArg) + platform: x64 + configuration: $(BuildConfiguration) + msbuildArchitecture: x64 + maximumCpuCount: true + + # Build PowerToys.DSC.exe for ARM64 (x64 uses existing binary from previous build) + - task: VSBuild@1 + displayName: Build PowerToys.DSC.exe (x64 for generating manifests) + condition: and(succeeded(), ne(variables['BuildPlatform'], 'x64')) + inputs: + solution: src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj + msbuildArgs: /t:Build /m /restore + platform: x64 + configuration: $(BuildConfiguration) + msbuildArchitecture: x64 + maximumCpuCount: true + + # Generate DSC manifests using PowerToys.DSC.exe + - pwsh: |- + & '.pipelines/generateDscManifests.ps1' -BuildPlatform '$(BuildPlatform)' -BuildConfiguration '$(BuildConfiguration)' -RepoRoot '$(Build.SourcesDirectory)' + displayName: Generate DSC manifests + - task: CopyFiles@2 displayName: Stage SDK/build inputs: @@ -276,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 @@ -297,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 @@ -319,7 +382,7 @@ jobs: displayName: Publish ${{ project }} for Packaging inputs: solution: ${{ project }} - vsVersion: 17.0 + vsVersion: 18.0 msbuildArgs: >- /target:Publish /graph @@ -327,6 +390,7 @@ jobs: /p:VCRTForwarders-IncludeDebugCRT=false /p:PowerToysRoot=$(Build.SourcesDirectory) /p:PublishProfile=InstallationPublishProfile.pubxml + /p:TargetFramework=net9.0-windows10.0.26100.0 /bl:$(LogOutputDirectory)\publish-${{ join('_',split(project, '/')) }}.binlog $(RestoreAdditionalProjectSourcesArg) platform: $(BuildPlatform) @@ -337,13 +401,18 @@ jobs: ### HACK: On ARM64 builds, building an app with Windows App SDK copies the x64 WebView2 dll instead of the ARM64 one. This task makes sure the right dll is used. - task: CopyFiles@2 displayName: HACK Copy core WebView2 ARM64 dll to output directory - condition: eq(variables['BuildPlatform'],'arm64') + condition: and(succeeded(), eq(variables['BuildPlatform'], 'arm64')) inputs: contents: packages/Microsoft.Web.WebView2.1.0.2903.40/runtimes/win-ARM64/native_uap/Microsoft.Web.WebView2.Core.dll targetFolder: $(Build.SourcesDirectory)/ARM64/Release/WinUI3Apps/ flattenFolders: True OverWrite: True + # Check if all projects (located in src sub-folder) import common props + - pwsh: |- + & '.pipelines/verifyCommonProps.ps1' -sourceDir '$(build.sourcesdirectory)\src' + displayName: Audit shared common props for CSharp projects in src sub-folder + # Check if deps.json files don't reference different dll versions. - pwsh: |- & '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\$(BuildPlatform)\$(BuildConfiguration)' @@ -371,11 +440,11 @@ jobs: inputs: testResultsFormat: VSTest testResultsFiles: '**/*.trx' - condition: ne(variables['BuildPlatform'],'arm64') + condition: and(succeeded(), ne(variables['BuildPlatform'], 'arm64')) # Native dlls - task: VSTest@2 - condition: ne(variables['BuildPlatform'],'arm64') # No arm64 agents to run the tests. + condition: and(succeeded(), ne(variables['BuildPlatform'], 'arm64')) # No arm64 agents to run the tests. displayName: 'Native Tests' inputs: platform: '$(BuildPlatform)' @@ -384,18 +453,35 @@ jobs: testAssemblyVer2: | **\KeyboardManagerEngineTest.dll **\KeyboardManagerEditorTest.dll - **\UnitTests-CommonLib.dll - **\PowerRenameUnitTests.dll - **\UnitTests-FancyZones.dll + **\*UnitTest*.dll !**\obj\** - - ${{ if eq(parameters.codeSign, true) }}: - - pwsh: |- - $Package = (Get-ChildItem -Recurse -Filter "Microsoft.CmdPal.UI_*.msix" | Select -First 1) - $PackageFilename = $Package.FullName - Write-Host "##vso[task.setvariable variable=CmdPalPackagePath]${PackageFilename}" - displayName: Locate the MSIX + - pwsh: |- + $Packages = Get-ChildItem -Recurse -Filter "Microsoft.CmdPal.UI_*.msix" + Write-Host "Found $($Packages.Count) CmdPal MSIX package(s):" + foreach ($pkg in $Packages) { + Write-Host " - $($pkg.FullName)" + } + + if ($Packages.Count -gt 0) { + # Priority: Look for platform-specific MSIX (x64/arm64) first, then fall back to any + $PlatformPackage = $Packages | Where-Object { $_.Name -match "Microsoft\.CmdPal\.UI_.*_(x64|arm64)\.msix$" } | Select-Object -First 1 + if ($PlatformPackage) { + $Package = $PlatformPackage + Write-Host "Using platform-specific package: $($Package.FullName)" + } else { + $Package = $Packages | Select-Object -First 1 + Write-Host "Using first available package: $($Package.FullName)" + } + + $PackageFilename = $Package.FullName + Write-Host "##vso[task.setvariable variable=CmdPalPackagePath]${PackageFilename}" + } else { + Write-Warning "No CmdPal MSIX packages found!" + } + displayName: Locate the CmdPal MSIX + - ${{ if eq(parameters.codeSign, true) }}: - pwsh: |- & "$(MakeAppxPath)" unpack /p "$(CmdPalPackagePath)" /d "$(JobOutputDirectory)/CmdPalPackageContents" displayName: Unpack the MSIX for signing @@ -415,8 +501,18 @@ jobs: $PackageFilename = Join-Path $outDir.FullName (Split-Path -Leaf "$(CmdPalPackagePath)") & "$(MakeAppxPath)" pack /h SHA256 /o /p $PackageFilename /d "$(JobOutputDirectory)/CmdPalPackageContents" Copy-Item -Force $PackageFilename "$(CmdPalPackagePath)" + Remove-Item -Force -Recurse "$(JobOutputDirectory)/CmdPalPackageContents" -ErrorAction:Ignore + Remove-Item -Force -Recurse "$(JobOutputDirectory)/_appx" -ErrorAction:Ignore displayName: Re-pack the new CmdPal package after signing + - pwsh: | + $testsPath = "$(Build.SourcesDirectory)/$(BuildPlatform)/$(BuildConfiguration)/tests" + if (Test-Path $testsPath) { + Remove-Item -Path $testsPath -Recurse -Force + Write-Host "Removed tests folder to reduce signing workload: $testsPath" + } + displayName: Remove tests folder before signing + - template: steps-esrp-signing.yml parameters: displayName: Sign Core PowerToys @@ -437,24 +533,22 @@ jobs: batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_DSC.json' ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' - - template: steps-build-installer.yml - parameters: - codeSign: ${{ parameters.codeSign }} - signingIdentity: ${{ parameters.signingIdentity }} - versionNumber: ${{ parameters.versionNumber }} - additionalBuildOptions: ${{ parameters.additionalBuildOptions }} + - pwsh: |- + Copy-Item -Verbose -Force "$(CmdPalPackagePath)" "$(JobOutputDirectory)" + displayName: Stage the final CmdPal package - - template: steps-build-installer.yml + + + - template: steps-build-installer-vnext.yml parameters: codeSign: ${{ parameters.codeSign }} signingIdentity: ${{ parameters.signingIdentity }} versionNumber: ${{ parameters.versionNumber }} additionalBuildOptions: ${{ parameters.additionalBuildOptions }} - buildUserInstaller: true # NOTE: This is the distinction between the above and below rules # This saves ~1GiB per architecture. We won't need these later. # Removes: - # - All .pdbs from any static libs .libs (which were only used during linking) + # - All .pdb files from any static libs .libs (which were only used during linking) - pwsh: |- $binDir = '$(Build.SourcesDirectory)' $ImportLibs = Get-ChildItem $binDir -Recurse -File -Filter '*.exp' | ForEach-Object { $_.FullName -Replace "exp$","lib" } @@ -470,60 +564,73 @@ jobs: - task: CopyFiles@2 displayName: Stage Installers inputs: - contents: "**/PowerToys*Setup-*.exe" + contents: |- + **/PowerToys*Setup-*.exe + !**/PowerToysSetupVNext/obj/** flattenFolders: True targetFolder: $(JobOutputDirectory) - - task: CopyFiles@2 - displayName: Stage Symbols - inputs: - contents: |- - **\*.pdb - !**\vc143.pdb - !**\*test*.pdb - flattenFolders: True - targetFolder: $(JobOutputDirectory)/symbols-$(BuildPlatform)/ + - pwsh: |- + $Symbols = Get-ChildItem "$(BuildPlatform)" -Recurse -Filter *.pdb -Exclude "vc143.pdb","*test*.pdb" | + Group-Object Name | ForEach-Object { $_.Group[0] } + $OutDir = "$(JobOutputDirectory)/symbols-$(BuildPlatform)" + New-Item -Type Directory $OutDir -EA:Ignore + Write-Host "Linking $($Symbols.Length) symbols into place at $OutDir" + ForEach($s in $Symbols) { + New-Item -Type HardLink -Target $s.FullName (Join-Path $OutDir $s.Name) + } + displayName: Stage Unique Symbols (as hard links) - pwsh: |- $p = "$(JobOutputDirectory)\" - $userHash = ((Get-Item $p\PowerToysUserSetup*.exe | Get-FileHash).Hash); - $machineHash = ((Get-Item $p\PowerToysSetup*.exe | Get-FileHash).Hash); - $userPlat = "hash_user_$(BuildPlatform).txt"; - $machinePlat = "hash_machine_$(BuildPlatform).txt"; - $combinedUserPath = $p + $userPlat; - $combinedMachinePath = $p + $machinePlat; - - echo $p - - echo $userPlat - echo $userHash - echo $combinedUserPath - - echo $machinePlat - echo $machineHash - echo $combinedMachinePath - - $userHash | out-file -filepath $combinedUserPath - $machineHash | out-file -filepath $combinedMachinePath - displayName: Calculate file hashes + + # Calculate hashes for installers + $userSetupFiles = Get-ChildItem -Path $p -Filter "PowerToysUserSetup*.exe" + $machineSetupFiles = Get-ChildItem -Path $p -Filter "PowerToysSetup*.exe" | Where-Object { $_.Name -notmatch "PowerToysUserSetup" } + + if ($userSetupFiles.Count -gt 0) { + $userHash = ($userSetupFiles[0] | Get-FileHash).Hash; + $userPlat = "hash_user_$(BuildPlatform).txt"; + $combinedUserPath = $p + $userPlat; + echo "User: $userHash" + $userHash | out-file -filepath $combinedUserPath + } + + if ($machineSetupFiles.Count -gt 0) { + $machineHash = ($machineSetupFiles[0] | Get-FileHash).Hash; + $machinePlat = "hash_machine_$(BuildPlatform).txt"; + $combinedMachinePath = $p + $machinePlat; + echo "Machine: $machineHash" + $machineHash | out-file -filepath $combinedMachinePath + } + displayName: Calculate file hashes for all installers # Publishing the GPO files - pwsh: |- - New-Item "$(JobOutputDirectory)/gpo" -Type Directory - Copy-Item src\gpo\assets\* "$(JobOutputDirectory)/gpo" -Recurse + $GpoArchive = "$(JobOutputDirectory)\GroupPolicyObjectFiles-${{ parameters.versionNumber }}.zip" + tar -c -v --format=zip -C .\src\gpo\assets -f $GpoArchive * displayName: Stage GPO files - # Running the tests may result in future jobs consuming artifacts out of this build - - ${{ if eq(parameters.runTests, true) }}: - - task: CopyFiles@2 - displayName: Stage entire build output - inputs: - sourceFolder: '$(Build.SourcesDirectory)' - contents: '$(BuildPlatform)/$(BuildConfiguration)/**/*' - targetFolder: '$(JobOutputDirectory)\$(BuildPlatform)\$(BuildConfiguration)' + - ${{ if or(eq(parameters.runTests, true), eq(parameters.buildTests, true)) }}: + # Running the tests may result in future jobs consuming artifacts out of this build + # Instead of running an expensive file copy step, move everything over since the build is totally done. + - pwsh: |- + # It seems weird, but this is for compatibility. Our artifacts historically contained the folder x64/Release/x64/Release (for example). + $FinalOutputRoot = "$(JobOutputDirectory)\$(BuildPlatform)\$(BuildConfiguration)\$(BuildPlatform)" + $ProjectBuildRoot = "$(Build.SourcesDirectory)\$(BuildPlatform)" + $ProjectBuildDirectory = "$ProjectBuildRoot\$(BuildConfiguration)" + + New-Item -Type Directory $FinalOutputRoot -EA:Ignore + Move-Item $ProjectBuildDirectory $FinalOutputRoot + displayName: Move entire output directory into artifacts - ${{ if eq(parameters.publishArtifacts, true) }}: - publish: $(JobOutputDirectory) artifact: $(JobOutputArtifactName) displayName: Publish all outputs - condition: always() + condition: succeeded() + + - publish: $(JobOutputDirectory) + artifact: $(JobOutputArtifactName)-failure-$(System.JobAttempt) + displayName: Publish failure logs + condition: or(failed(), canceled()) \ No newline at end of file diff --git a/.pipelines/v2/templates/job-build-sdk.yml b/.pipelines/v2/templates/job-build-sdk.yml index f8aa5dca3e..f8cb9c930a 100644 --- a/.pipelines/v2/templates/job-build-sdk.yml +++ b/.pipelines/v2/templates/job-build-sdk.yml @@ -3,6 +3,9 @@ parameters: type: object default: - Release + - name: official + type: boolean + default: false - name: codeSign type: boolean default: false @@ -12,9 +15,6 @@ parameters: - name: signingIdentity type: object default: {} - - name: sdkVersionNumber - type: string - default: '0.0.1' jobs: - job: "BuildSDK" @@ -36,8 +36,17 @@ jobs: fetchTags: false fetchDepth: 1 + - template: .\steps-ensure-nuget-version.yml + + - task: NuGetAuthenticate@1 + + - ${{ if eq(parameters.official, true) }}: + - template: .\steps-setup-versioning.yml + parameters: + directory: $(build.sourcesdirectory)\src\modules\cmdpal + - pwsh: |- - & "$(build.sourcesdirectory)\src\modules\cmdpal\extensionsdk\nuget\BuildSDKHelper.ps1" -Configuration "Release" -VersionOfSDK ${{ parameters.sdkVersionNumber }} -BuildStep "build" -IsAzurePipelineBuild + & "$(build.sourcesdirectory)\src\modules\cmdpal\extensionsdk\nuget\BuildSDKHelper.ps1" -Configuration "Release" -BuildStep "build" -IsAzurePipelineBuild displayName: Build SDK - ${{ if eq(parameters.codeSign, true) }}: @@ -52,7 +61,7 @@ jobs: ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' - pwsh: |- - & "$(build.sourcesdirectory)\src\modules\cmdpal\extensionsdk\nuget\BuildSDKHelper.ps1" -Configuration "Release" -VersionOfSDK ${{ parameters.sdkVersionNumber }} -BuildStep "pack" -IsAzurePipelineBuild + & "$(build.sourcesdirectory)\src\modules\cmdpal\extensionsdk\nuget\BuildSDKHelper.ps1" -Configuration "Release" -BuildStep "pack" -IsAzurePipelineBuild displayName: Pack SDK - task: CopyFiles@2 diff --git a/.pipelines/v2/templates/job-build-ui-tests.yml b/.pipelines/v2/templates/job-build-ui-tests.yml new file mode 100644 index 0000000000..61e3b93436 --- /dev/null +++ b/.pipelines/v2/templates/job-build-ui-tests.yml @@ -0,0 +1,131 @@ +# Minimal UI Tests Build Template +# This template only builds UI test projects and stages their test DLLs for consumption by test pipelines + +parameters: + - name: buildConfigurations + type: object + default: + - Release + - name: buildPlatforms + type: object + default: + - x64 + - name: condition + type: string + default: '' + - name: dependsOn + type: object + default: [] + - name: pool + type: object + default: [] + - name: variables + type: object + default: {} + - name: uiTestModules + type: object + default: [] + +jobs: +- job: BuildUITests + ${{ if ne(length(parameters.pool), 0) }}: + pool: ${{ parameters.pool }} + dependsOn: ${{ parameters.dependsOn }} + condition: ${{ parameters.condition }} + strategy: + matrix: + ${{ each config in parameters.buildConfigurations }}: + ${{ each platform in parameters.buildPlatforms }}: + ${{ config }}_${{ platform }}: + BuildConfiguration: ${{ config }} + BuildPlatform: ${{ platform }} + variables: + JobOutputDirectory: $(Build.ArtifactStagingDirectory) + LogOutputDirectory: $(Build.ArtifactStagingDirectory)\logs + JobOutputArtifactName: build-$(BuildPlatform)-$(BuildConfiguration) + NUGET_RESTORE_MSBUILD_ARGS: /p:Platform=$(BuildPlatform) + ${{ insert }}: ${{ parameters.variables }} + displayName: Build UI Tests Only + timeoutInMinutes: 60 + cancelTimeoutInMinutes: 1 + templateContext: + outputs: + - output: pipelineArtifact + artifactName: $(JobOutputArtifactName) + targetPath: $(Build.ArtifactStagingDirectory) + steps: + - checkout: self + clean: true + submodules: true + persistCredentials: True + fetchTags: false + fetchDepth: 1 + + - template: steps-ensure-dotnet-version.yml + parameters: + sdk: true + version: '9.0' + + - template: .\steps-restore-nuget.yml + + - task: MSBuild@1 + displayName: Restore solution-level NuGet packages + inputs: + solution: PowerToys.slnx + msbuildArguments: '/t:restore /p:RestorePackagesConfig=true' + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + + # Build all UI test projects if no specific modules are specified + - ${{ if eq(length(parameters.uiTestModules), 0) }}: + - task: VSBuild@1 + displayName: Build UI Test Projects + inputs: + solution: '**/*UITest*.csproj' + vsVersion: 18.0 + msbuildArgs: >- + -restore + -graph + /p:RestorePackagesConfig=true + /p:BuildProjectReferences=true + /p:CIBuild=true + /bl:$(LogOutputDirectory)\build-all-uitests.binlog + $(NUGET_RESTORE_MSBUILD_ARGS) + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + msbuildArchitecture: x64 + maximumCpuCount: true + + # Build specific UI test modules + - ${{ if ne(length(parameters.uiTestModules), 0) }}: + - ${{ each module in parameters.uiTestModules }}: + - task: VSBuild@1 + displayName: 'Build UI Test Module: ${{ module }}' + inputs: + solution: '**/*${{ module }}*.csproj' + vsVersion: 18.0 + msbuildArgs: >- + -restore + -graph + /p:RestorePackagesConfig=true + /p:BuildProjectReferences=true + /p:CIBuild=true + /bl:$(LogOutputDirectory)\build-${{ module }}.binlog + $(NUGET_RESTORE_MSBUILD_ARGS) + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + msbuildArchitecture: x64 + maximumCpuCount: true + + # Stage test project outputs with directory structure + - task: CopyFiles@2 + displayName: Stage UI Test Build Outputs + inputs: + sourceFolder: '$(Build.SourcesDirectory)' + contents: '**/$(BuildPlatform)/$(BuildConfiguration)/tests/**/*' + targetFolder: '$(JobOutputDirectory)\$(BuildPlatform)\$(BuildConfiguration)' + + - publish: $(JobOutputDirectory) + artifact: $(JobOutputArtifactName) + displayName: Publish UI Test artifacts + condition: always() diff --git a/.pipelines/v2/templates/job-publish-symbols-using-symbolrequestprod-api.yml b/.pipelines/v2/templates/job-publish-symbols-using-symbolrequestprod-api.yml index 967b7ba4eb..6b214be612 100644 --- a/.pipelines/v2/templates/job-publish-symbols-using-symbolrequestprod-api.yml +++ b/.pipelines/v2/templates/job-publish-symbols-using-symbolrequestprod-api.yml @@ -68,7 +68,7 @@ jobs: pwsh: true ScriptType: InlineScript Inline: |- - $AzToken = (Get-AzAccessToken -ResourceUrl api://30471ccf-0966-45b9-a979-065dbedb24c1).Token + $AzToken = (Get-AzAccessToken -AsSecureString -ResourceUrl api://30471ccf-0966-45b9-a979-065dbedb24c1).Token | ConvertFrom-SecureString -AsPlainText Write-Host "##vso[task.setvariable variable=SymbolAccessToken;issecret=true]$AzToken" diff --git a/.pipelines/v2/templates/job-test-project.yml b/.pipelines/v2/templates/job-test-project.yml index 16af4f66ea..0112738499 100644 --- a/.pipelines/v2/templates/job-test-project.yml +++ b/.pipelines/v2/templates/job-test-project.yml @@ -11,24 +11,53 @@ parameters: - name: useLatestWebView2 type: boolean default: false + - name: buildSource + type: string + default: "latestMainOfficialBuild" + displayName: "Build Source" + - name: specificBuildId + type: string + default: "xxxx" + displayName: "Build ID (for specific builds)" + - name: uiTestModules + type: object + default: [] + - name: installMode + type: string + default: 'machine' + values: + - 'machine' + - 'peruser' + - name: jobSuffix + type: string + default: '' jobs: -- job: Test${{ parameters.platform }}${{ parameters.configuration }} - displayName: Test ${{ parameters.platform }} ${{ parameters.configuration }} +- job: Test${{ parameters.platform }}${{ parameters.configuration }}${{ parameters.jobSuffix }} + displayName: Test ${{ parameters.platform }} ${{ parameters.configuration }}${{ parameters.jobSuffix }} + timeoutInMinutes: 300 variables: - BuildPlatform: ${{ parameters.platform }} + ${{ if or(eq(parameters.platform, 'x64Win10'), eq(parameters.platform, 'x64Win11')) }}: + BuildPlatform: x64 + ${{ else }}: + BuildPlatform: ${{ parameters.platform }} + TestPlatform: ${{ parameters.platform }} BuildConfiguration: ${{ parameters.configuration }} SrcPath: $(Build.Repository.LocalPath) - TestArtifactsName: build-${{ parameters.platform }}-${{ parameters.configuration }}${{ parameters.inputArtifactStem }} + TestArtifactsName: build-${{ variables.BuildPlatform }}-${{ parameters.configuration }}${{ parameters.inputArtifactStem }} pool: ${{ if eq(variables['System.CollectionId'], 'cb55739e-4afe-46a3-970f-1b49d8ee7564') }}: ${{ if ne(parameters.platform, 'ARM64') }}: name: SHINE-INT-Testing-x64 + ${{ if eq(parameters.platform, 'x64Win11') }}: + demands: ImageOverride -equals SHINE-W11-Testing ${{ else }}: name: SHINE-INT-Testing-arm64 ${{ else }}: ${{ if ne(parameters.platform, 'ARM64') }}: name: SHINE-OSS-Testing-x64 + ${{ if eq(parameters.platform, 'x64Win11') }}: + demands: ImageOverride -equals SHINE-W11-Testing ${{ else }}: name: SHINE-OSS-Testing-arm64 steps: @@ -83,12 +112,44 @@ jobs: & '$(build.sourcesdirectory)\.pipelines\InstallWinAppDriver.ps1' displayName: Download and install WinAppDriver + - ${{ if ne(parameters.buildSource, 'buildNow') }}: + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'specific' + project: 'Dart' + definition: '76541' + ${{ if eq(parameters.buildSource, 'specificBuildId') }}: + buildVersionToDownload: 'specific' + buildId: '${{ parameters.specificBuildId }}' + ${{ else }}: + buildVersionToDownload: 'latestFromBranch' + branchName: 'refs/heads/main' + artifactName: 'build-$(BuildPlatform)-Release' + targetPath: '$(Build.ArtifactStagingDirectory)' + ${{ if eq(parameters.installMode, 'peruser') }}: + patterns: | + **/PowerToysUserSetup*.exe + ${{ else }}: + patterns: | + **/PowerToysSetup*.exe + + - ${{ if ne(parameters.buildSource, 'buildNow') }}: + - ${{ if eq(parameters.installMode, 'peruser') }}: + - pwsh: |- + & "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "PerUser" + displayName: Install PowerToys (Per-User) + + - ${{ if eq(parameters.installMode, 'machine') }}: + - pwsh: |- + & "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "Machine" + displayName: Install PowerToys (Machine-Level) + - ${{ if ne(parameters.platform, 'arm64') }}: - task: ScreenResolutionUtility@1 inputs: displaySettings: 'optimal' - script: | - dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\UITests-FancyZones\UITests-FancyZones.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform) - dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\UITests-FancyZonesEditor\UITests-FancyZonesEditor.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform) + dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\FancyZones.UITests\FancyZones.UITests.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform) + dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\FancyZonesEditor.UITests\FancyZonesEditor.UITests.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform) displayName: "Run UI Tests" diff --git a/.pipelines/v2/templates/pipeline-ci-build.yml b/.pipelines/v2/templates/pipeline-ci-build.yml index 37e06ae4f2..a56c575399 100644 --- a/.pipelines/v2/templates/pipeline-ci-build.yml +++ b/.pipelines/v2/templates/pipeline-ci-build.yml @@ -3,9 +3,6 @@ variables: value: false - name: EnablePipelineCache value: true - - ${{ if eq(parameters.enableMsBuildCaching, true) }}: - - name: EnablePipelineCache - value: true parameters: - name: buildPlatforms @@ -16,6 +13,9 @@ parameters: - name: enableMsBuildCaching type: boolean default: false + - name: msBuildCacheIsReadOnly + type: boolean + default: true - name: runTests type: boolean default: true @@ -49,27 +49,39 @@ 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] enablePackageCaching: true enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} + msBuildCacheIsReadOnly: ${{ parameters.msBuildCacheIsReadOnly }} runTests: ${{ parameters.runTests }} + buildTests: true useVSPreview: ${{ parameters.useVSPreview }} useLatestWinAppSDK: ${{ parameters.useLatestWinAppSDK }} ${{ if eq(parameters.useLatestWinAppSDK, true) }}: winAppSDKVersionNumber: ${{ parameters.winAppSDKVersionNumber }} useExperimentalVersion: ${{ parameters.useExperimentalVersion }} + timeoutInMinutes: 90 - - ${{ if and(eq(parameters.runTests, true), not(and(eq(platform, 'arm64'), eq(variables['System.PullRequest.IsFork'], true)))) }}: - - stage: Test_${{ platform }} - displayName: Test ${{ platform }} - dependsOn: - - Build_${{platform}} - jobs: - - template: job-test-project.yml - parameters: - platform: ${{ platform }} - configuration: Release - useLatestWebView2: ${{ parameters.useLatestWebView2 }} + - stage: Build_SDK + displayName: Build Command Palette Toolkit SDK + dependsOn: [] + jobs: + - template: job-build-sdk.yml + parameters: + pool: + ${{ if eq(variables['System.CollectionId'], 'cb55739e-4afe-46a3-970f-1b49d8ee7564') }}: + name: SHINE-INT-L + ${{ else }}: + name: SHINE-OSS-L + ${{ if eq(parameters.useVSPreview, true) }}: + 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-automation.yml b/.pipelines/v2/templates/pipeline-ui-tests-automation.yml new file mode 100644 index 0000000000..0682cc5e32 --- /dev/null +++ b/.pipelines/v2/templates/pipeline-ui-tests-automation.yml @@ -0,0 +1,58 @@ +variables: + - name: runCodesignValidationInjectionBG + value: false + - name: EnablePipelineCache + value: true + +parameters: + - name: buildPlatforms + type: object + default: + - x64 + - arm64 + - name: enableMsBuildCaching + type: boolean + default: false + - name: useVSPreview + type: boolean + default: false + - name: useLatestWebView2 + type: boolean + default: false + - name: buildSource + type: string + default: "latestMainOfficialBuild" + displayName: "Build Source" + values: + - latestMainOfficialBuild + - buildNow + - specificBuildId + - name: specificBuildId + type: string + default: 'xxxx' + displayName: "Build ID (only used when Build Source = specificBuildId)" + - name: uiTestModules + type: object + default: [] + +stages: + - ${{ each platform in parameters.buildPlatforms }}: + # Full build path: build PowerToys + UI tests + run tests + - ${{ if eq(parameters.buildSource, 'buildNow') }}: + - template: pipeline-ui-tests-full-build.yml + parameters: + platform: ${{ platform }} + enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} + useVSPreview: ${{ parameters.useVSPreview }} + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + uiTestModules: ${{ parameters.uiTestModules }} + + # Official build path: build UI tests only + download official build + run tests + - ${{ if ne(parameters.buildSource, 'buildNow') }}: + - template: pipeline-ui-tests-official-build.yml + parameters: + platform: ${{ platform }} + buildSource: ${{ parameters.buildSource }} + specificBuildId: ${{ parameters.specificBuildId }} + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + uiTestModules: ${{ parameters.uiTestModules }} diff --git a/.pipelines/v2/templates/pipeline-ui-tests-full-build.yml b/.pipelines/v2/templates/pipeline-ui-tests-full-build.yml new file mode 100644 index 0000000000..30de78a335 --- /dev/null +++ b/.pipelines/v2/templates/pipeline-ui-tests-full-build.yml @@ -0,0 +1,82 @@ +# Template for full build path: Build PowerToys + Build UI Tests + Run Tests +parameters: + - name: platform + type: string + - name: enableMsBuildCaching + type: boolean + default: false + - name: useVSPreview + type: boolean + default: false + - name: useLatestWebView2 + type: boolean + default: false + - name: uiTestModules + type: object + default: [] + +stages: + # Stage 1: Build full PowerToys project + - stage: Build_${{ parameters.platform }} + displayName: Build PowerToys ${{ parameters.platform }} + dependsOn: [] + jobs: + - template: job-build-project.yml + parameters: + pool: + ${{ if eq(variables['System.CollectionId'], 'cb55739e-4afe-46a3-970f-1b49d8ee7564') }}: + name: SHINE-INT-L + ${{ else }}: + name: SHINE-OSS-L + ${{ if eq(parameters.useVSPreview, true) }}: + demands: ImageOverride -equals SHINE-VS18-Preview + ${{ else }}: + demands: ImageOverride -equals SHINE-VS18-Latest + buildPlatforms: + - ${{ parameters.platform }} + buildConfigurations: [Release] + enablePackageCaching: true + enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }} + runTests: false + buildTests: true + useVSPreview: ${{ parameters.useVSPreview }} + timeoutInMinutes: 90 + + # Stage 2: Run UI Tests + - ${{ if eq(parameters.platform, 'x64') }}: + - stage: Test_x64Win10_FullBuild + displayName: Test x64Win10 (Full Build) + dependsOn: Build_${{ parameters.platform }} + jobs: + - template: job-test-project.yml + parameters: + platform: x64Win10 + configuration: Release + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + buildSource: 'buildNow' + uiTestModules: ${{ parameters.uiTestModules }} + + - stage: Test_x64Win11_FullBuild + displayName: Test x64Win11 (Full Build) + dependsOn: Build_${{ parameters.platform }} + jobs: + - template: job-test-project.yml + parameters: + platform: x64Win11 + configuration: Release + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + buildSource: 'buildNow' + uiTestModules: ${{ parameters.uiTestModules }} + + - ${{ if ne(parameters.platform, 'x64') }}: + - stage: Test_${{ parameters.platform }}_FullBuild + displayName: Test ${{ parameters.platform }} (Full Build) + dependsOn: Build_${{ parameters.platform }} + jobs: + - template: job-test-project.yml + parameters: + platform: ${{ parameters.platform }} + configuration: Release + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + buildSource: 'buildNow' + uiTestModules: ${{ parameters.uiTestModules }} diff --git a/.pipelines/v2/templates/pipeline-ui-tests-official-build.yml b/.pipelines/v2/templates/pipeline-ui-tests-official-build.yml new file mode 100644 index 0000000000..1da11324fe --- /dev/null +++ b/.pipelines/v2/templates/pipeline-ui-tests-official-build.yml @@ -0,0 +1,110 @@ +# Template for official build path: Download Official Build + Build UI Tests Only + Run Tests +parameters: + - name: platform + type: string + - name: buildSource + type: string + - name: specificBuildId + type: string + default: 'xxxx' + - name: useLatestWebView2 + type: boolean + default: false + - name: uiTestModules + type: object + default: [] + +stages: + # Stage 1: Build UI Tests Only + - stage: BuildUITests_${{ parameters.platform }} + displayName: Build UI Tests Only ${{ parameters.platform }} + dependsOn: [] + jobs: + - template: job-build-ui-tests.yml + parameters: + pool: + ${{ if eq(variables['System.CollectionId'], 'cb55739e-4afe-46a3-970f-1b49d8ee7564') }}: + name: SHINE-INT-L + ${{ else }}: + name: SHINE-OSS-L + buildPlatforms: + - ${{ parameters.platform }} + uiTestModules: ${{ parameters.uiTestModules }} + + # Stage 2: Run UI Tests with Official Build + - ${{ if eq(parameters.platform, 'x64') }}: + - stage: Test_x64Win10_OfficialBuild + displayName: Test x64Win10 (Official Build) + dependsOn: BuildUITests_${{ parameters.platform }} + jobs: + - template: job-test-project.yml + parameters: + platform: x64Win10 + configuration: Release + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + buildSource: ${{ parameters.buildSource }} + specificBuildId: ${{ parameters.specificBuildId }} + uiTestModules: ${{ parameters.uiTestModules }} + + # Additional per-user installation test + - template: job-test-project.yml + parameters: + platform: x64Win10 + configuration: Release + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + buildSource: ${{ parameters.buildSource }} + specificBuildId: ${{ parameters.specificBuildId }} + uiTestModules: ${{ parameters.uiTestModules }} + installMode: 'peruser' + jobSuffix: '_PerUser' + + - stage: Test_x64Win11_OfficialBuild + displayName: Test x64Win11 (Official Build) + dependsOn: BuildUITests_${{ parameters.platform }} + jobs: + - template: job-test-project.yml + parameters: + platform: x64Win11 + configuration: Release + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + buildSource: ${{ parameters.buildSource }} + specificBuildId: ${{ parameters.specificBuildId }} + uiTestModules: ${{ parameters.uiTestModules }} + + # Additional per-user installation test + - template: job-test-project.yml + parameters: + platform: x64Win11 + configuration: Release + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + buildSource: ${{ parameters.buildSource }} + specificBuildId: ${{ parameters.specificBuildId }} + uiTestModules: ${{ parameters.uiTestModules }} + installMode: 'peruser' + jobSuffix: '_PerUser' + + - ${{ if ne(parameters.platform, 'x64') }}: + - stage: Test_${{ parameters.platform }}_OfficialBuild + displayName: Test ${{ parameters.platform }} (Official Build) + dependsOn: BuildUITests_${{ parameters.platform }} + jobs: + - template: job-test-project.yml + parameters: + platform: ${{ parameters.platform }} + configuration: Release + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + buildSource: ${{ parameters.buildSource }} + specificBuildId: ${{ parameters.specificBuildId }} + uiTestModules: ${{ parameters.uiTestModules }} + + # Additional per-user installation test + - template: job-test-project.yml + parameters: + platform: ${{ parameters.platform }} + configuration: Release + useLatestWebView2: ${{ parameters.useLatestWebView2 }} + buildSource: ${{ parameters.buildSource }} + specificBuildId: ${{ parameters.specificBuildId }} + uiTestModules: ${{ parameters.uiTestModules }} + installMode: 'peruser' + jobSuffix: '_PerUser' diff --git a/.pipelines/v2/templates/steps-build-installer-vnext.yml b/.pipelines/v2/templates/steps-build-installer-vnext.yml new file mode 100644 index 0000000000..933f2ab7fe --- /dev/null +++ b/.pipelines/v2/templates/steps-build-installer-vnext.yml @@ -0,0 +1,215 @@ +parameters: + - name: versionNumber + type: string + default: "0.0.1" + - name: codeSign + type: boolean + default: false + - name: signingIdentity + type: object + default: {} + - name: additionalBuildOptions + type: string + default: '' + +steps: + # Install WiX 5.0.2 tools needed for VNext installer (matching project SDK) + - task: DotNetCoreCLI@2 + displayName: Install WiX 5.0.2 tools + inputs: + command: 'custom' + custom: 'tool' + arguments: 'install --global wix --version 5.0.2' + + - pwsh: |- + Write-Host "##vso[task.setvariable variable=InstallerMachineRoot]installer\PowerToysSetupVNext\$(BuildPlatform)\$(BuildConfiguration)\MachineSetup" + Write-Host "##vso[task.setvariable variable=InstallerUserRoot]installer\PowerToysSetupVNext\$(BuildPlatform)\$(BuildConfiguration)\UserSetup" + Write-Host "##vso[task.setvariable variable=InstallerMachineBasename]PowerToysSetup-${{ parameters.versionNumber }}-$(BuildPlatform)" + Write-Host "##vso[task.setvariable variable=InstallerUserBasename]PowerToysUserSetup-${{ parameters.versionNumber }}-$(BuildPlatform)" + displayName: Prepare Installer variables + + # This dll needs to be built and signed before building the MSI. + # The Custom Actions project contains a pre-build event that prepares the .wxs files + # by filling them out with all our components. We pass RunBuildEvents=true to force + # that logic to run. + - task: VSBuild@1 + displayName: Build Shared Support DLLs + inputs: + solution: "**/installer/PowerToysSetup.slnx" + vsVersion: 18.0 + msbuildArgs: >- + /t:PowerToysSetupCustomActionsVNext;SilentFilesInUseBAFunction + /p:RunBuildEvents=true;RestorePackagesConfig=true;CIBuild=true + -restore -graph + /bl:$(LogOutputDirectory)\installer-actions.binlog + ${{ parameters.additionalBuildOptions }} + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + clean: true + msbuildArchitecture: x64 + maximumCpuCount: true + + - ${{ if eq(parameters.codeSign, true) }}: + - template: steps-esrp-sign-files-authenticode.yml + parameters: + displayName: Sign Shared Support DLLs + signingIdentity: ${{ parameters.signingIdentity }} + folder: 'installer' + pattern: |- + **/PowerToysSetupCustomActionsVNext.dll + **/SilentFilesInUseBAFunction.dll + + ## INSTALLER START + #### MSI BUILDING AND SIGNING + # + # The MSI build contains code that reverts the .wxs files to their in-tree versions. + # This is only supposed to happen during local builds. Since this build system is + # supposed to run side by side--machine and then user--we do NOT want to destroy + # the .wxs files. Therefore, we pass RunBuildEvents=false to suppress all of that + # logic. + # + # We pass BuildProjectReferences=false so that it does not recompile the DLLs we just built. + # We only pass -restore on the first one because the second run should already have all + # of the dependencies. + - task: VSBuild@1 + displayName: 💻 Build VNext MSI + inputs: + solution: "**/installer/PowerToysSetup.slnx" + vsVersion: 18.0 + msbuildArgs: >- + -restore + /t:PowerToysInstallerVNext + /p:RunBuildEvents=false;PerUser=false;BuildProjectReferences=false;CIBuild=true + /bl:$(LogOutputDirectory)\installer-machine-msi.binlog + ${{ parameters.additionalBuildOptions }} + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + clean: false # don't undo our hard work above by deleting the CustomActions dll + msbuildArchitecture: x64 + maximumCpuCount: true + + - task: VSBuild@1 + displayName: 👤 Build VNext MSI + inputs: + solution: "**/installer/PowerToysSetup.slnx" + vsVersion: 18.0 + msbuildArgs: >- + /t:PowerToysInstallerVNext + /p:RunBuildEvents=false;PerUser=true;BuildProjectReferences=false;CIBuild=true + /bl:$(LogOutputDirectory)\installer-user-msi.binlog + ${{ parameters.additionalBuildOptions }} + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + clean: false # don't undo our hard work above by deleting the CustomActions dll + msbuildArchitecture: x64 + maximumCpuCount: true + + - script: |- + wix msi decompile $(InstallerMachineRoot)\$(InstallerMachineBasename).msi -x $(build.sourcesdirectory)\extractedMachineMsi + wix msi decompile $(InstallerUserRoot)\$(InstallerUserBasename).msi -x $(build.sourcesdirectory)\extractedUserMsi + dir $(build.sourcesdirectory)\extractedMachineMsi + dir $(build.sourcesdirectory)\extractedUserMsi + displayName: "WiX5: Extract and verify MSIs" + + # Check if deps.json files don't reference different dll versions. + - pwsh: |- + & '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\extractedMachineMsi\File' + & '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\extractedUserMsi\File' + displayName: Audit deps.json in MSI extracted files + + - ${{ if eq(parameters.codeSign, true) }}: + - pwsh: |- + & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMachineMsi\File' + & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMachineMsi\Binary' + & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedUserMsi\File' + & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedUserMsi\Binary' + git clean -xfd ./extractedMachineMsi ./extractedUserMsi + displayName: Verify all binaries are signed and versioned + + - template: steps-esrp-sign-files-authenticode.yml + parameters: + displayName: Sign VNext MSIs + signingIdentity: ${{ parameters.signingIdentity }} + folder: 'installer' + pattern: '**/PowerToys*Setup-*.msi' + + #### END MSI + + #### BOOTSTRAP BUILDING AND SIGNING + # We pass BuildProjectReferences=false so that it does not recompile the DLLs we just built. + # We only pass -restore on the first one because the second run should already have all + # of the dependencies. + - task: VSBuild@1 + displayName: 💻 Build VNext Bootstrapper + inputs: + solution: "**/installer/PowerToysSetup.slnx" + vsVersion: 18.0 + msbuildArgs: >- + -restore + /t:PowerToysBootstrapperVNext + /p:PerUser=false;BuildProjectReferences=false;CIBuild=true + /bl:$(LogOutputDirectory)\installer-machine-bootstrapper.binlog + ${{ parameters.additionalBuildOptions }} + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + clean: false # don't undo our hard work above by deleting the MSI nor SilentFilesInUseBAFunction + msbuildArchitecture: x64 + maximumCpuCount: true + + - task: VSBuild@1 + displayName: 👤 Build VNext Bootstrapper + inputs: + solution: "**/installer/PowerToysSetup.slnx" + vsVersion: 18.0 + msbuildArgs: >- + /t:PowerToysBootstrapperVNext + /p:PerUser=true;BuildProjectReferences=false;CIBuild=true + /bl:$(LogOutputDirectory)\installer-user-bootstrapper.binlog + ${{ parameters.additionalBuildOptions }} + platform: $(BuildPlatform) + configuration: $(BuildConfiguration) + clean: false # don't undo our hard work above by deleting the MSI nor SilentFilesInUseBAFunction + msbuildArchitecture: x64 + maximumCpuCount: true + + # The entirety of bundle unpacking/re-packing is unnecessary if we are not code signing it. + - ${{ if eq(parameters.codeSign, true) }}: + - script: |- + wix burn detach $(InstallerMachineRoot)\$(InstallerMachineBasename).exe -engine installer\machine-engine.exe + wix burn detach $(InstallerUserRoot)\$(InstallerUserBasename).exe -engine installer\user-engine.exe + displayName: "WiX5: Extract Engines from Bundles" + + - template: steps-esrp-sign-files-authenticode.yml + parameters: + displayName: Sign WiX Engines + signingIdentity: ${{ parameters.signingIdentity }} + folder: "installer" + pattern: '*-engine.exe' + + - script: |- + wix burn reattach $(InstallerMachineRoot)\$(InstallerMachineBasename).exe -engine installer\machine-engine.exe -o $(InstallerMachineRoot)\$(InstallerMachineBasename).exe + wix burn reattach $(InstallerUserRoot)\$(InstallerUserBasename).exe -engine installer\user-engine.exe -o $(InstallerUserRoot)\$(InstallerUserBasename).exe + displayName: "WiX5: Reattach Engines to Bundles" + + - pwsh: |- + & wix burn extract -oba installer\ba\m "$(InstallerMachineRoot)\$(InstallerMachineBasename).exe" + & wix burn extract -oba installer\ba\u "$(InstallerUserRoot)\$(InstallerUserBasename).exe" + Get-ChildItem installer\ba -Recurse -Include *.exe,*.dll | Get-AuthenticodeSignature | ForEach-Object { + If ($_.Status -Ne "Valid") { + Write-Error $_.StatusMessage + } Else { + Write-Host $_.StatusMessage + } + } + & git clean -fdx installer\ba + displayName: "WiX5: Verify Bootstrapper content is signed" + + - template: steps-esrp-sign-files-authenticode.yml + parameters: + displayName: Sign Final Bootstrappers + signingIdentity: ${{ parameters.signingIdentity }} + folder: 'installer' + pattern: '**/PowerToys*Setup-*.exe' + + #### END BOOTSTRAP + ## END INSTALLER diff --git a/.pipelines/v2/templates/steps-build-installer.yml b/.pipelines/v2/templates/steps-build-installer.yml deleted file mode 100644 index 8c3c89dbc0..0000000000 --- a/.pipelines/v2/templates/steps-build-installer.yml +++ /dev/null @@ -1,208 +0,0 @@ -parameters: - - name: versionNumber - type: string - default: "0.0.1" - - name: buildUserInstaller - type: boolean - default: false - - name: codeSign - type: boolean - default: false - - name: signingIdentity - type: object - default: {} - - name: additionalBuildOptions - type: string - default: '' - -steps: - - pwsh: |- - & git clean -xfd -e *exe -- .\installer\ - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Clean installer to reduce cross-contamination - - - pwsh: |- - $IsPerUser = $${{ parameters.buildUserInstaller }} - $InstallerBuildSlug = "MachineSetup" - $InstallerBasename = "PowerToysSetup" - If($IsPerUser) { - $InstallerBuildSlug = "UserSetup" - $InstallerBasename = "PowerToysUserSetup" - } - $InstallerBasename += "-${{ parameters.versionNumber }}-$(BuildPlatform)" - Write-Host "##vso[task.setvariable variable=InstallerBuildSlug]$InstallerBuildSlug" - Write-Host "##vso[task.setvariable variable=InstallerRelativePath]$(BuildPlatform)\$(BuildConfiguration)\$InstallerBuildSlug" - Write-Host "##vso[task.setvariable variable=InstallerBasename]$InstallerBasename" - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Prepare Installer variables - - # This dll needs to be built and signed before building the MSI. - - task: VSBuild@1 - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build PowerToysSetupCustomActions - inputs: - solution: "**/installer/PowerToysSetup.sln" - vsVersion: 17.0 - msbuildArgs: >- - /t:PowerToysSetupCustomActions - /p:RunBuildEvents=true;PerUser=${{parameters.buildUserInstaller}};RestorePackagesConfig=true;CIBuild=true - -restore -graph - /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-actions.binlog - ${{ parameters.additionalBuildOptions }} - platform: $(BuildPlatform) - configuration: $(BuildConfiguration) - clean: true - msbuildArchitecture: x64 - maximumCpuCount: true - - - ${{ if eq(parameters.codeSign, true) }}: - - template: steps-esrp-signing.yml - parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign PowerToysSetupCustomActions - signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: 'installer/PowerToysSetupCustomActions/$(InstallerRelativePath)' - signType: batchSigning - batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' - ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' - - ## INSTALLER START - #### MSI BUILDING AND SIGNING - - task: VSBuild@1 - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build MSI - inputs: - solution: "**/installer/PowerToysSetup.sln" - vsVersion: 17.0 - msbuildArgs: >- - -restore - /t:PowerToysInstaller - /p:RunBuildEvents=false;PerUser=${{parameters.buildUserInstaller}};BuildProjectReferences=false;CIBuild=true - /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-msi.binlog - ${{ parameters.additionalBuildOptions }} - platform: $(BuildPlatform) - configuration: $(BuildConfiguration) - clean: false # don't undo our hard work above by deleting the CustomActions dll - msbuildArchitecture: x64 - maximumCpuCount: true - - - script: |- - "C:\Program Files (x86)\WiX Toolset v3.14\bin\dark.exe" -x $(build.sourcesdirectory)\extractedMsi installer\PowerToysSetup\$(InstallerRelativePath)\$(InstallerBasename).msi - dir $(build.sourcesdirectory)\extractedMsi - displayName: "${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Extract and verify MSI" - - # Extract CmdPal msix package to check if its content is signed - - pwsh: |- - Write-Host "Extracting CmdPal MSIX package" - - # Define the directory to search - $searchDir = "extractedMsi\File" - - # Define the regex pattern for MSIX files - $pattern = '^Microsoft.CmdPal.UI.*\.msix$' - - # Get all files in the directory and subdirectories - $msixFile = Get-ChildItem -Path $searchDir -Recurse -File | Where-Object { - $_.Name -match $pattern - } - - Write-Host "MSIX file found: " $msixFile - - $destinationDir = "$(build.sourcesdirectory)\extractedMsi\File\extractedCmdPalMsix" - - Expand-Archive -Path $msixFile -DestinationPath $destinationDir - Get-ChildItem -Path $destinationDir -Recurse -File - - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Extract CmdPal MSIX package - - # Check if deps.json files don't reference different dll versions. - - pwsh: |- - & '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\extractedMsi\File' - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Audit deps.json in MSI extracted files - - - ${{ if eq(parameters.codeSign, true) }}: - - pwsh: |- - & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMsi\File' - & .pipelines/versionAndSignCheck.ps1 -targetDir '$(build.sourcesdirectory)\extractedMsi\Binary' - git clean -xfd ./extractedMsi - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Verify all binaries are signed and versioned - - - template: steps-esrp-signing.yml - parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign MSI - signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: 'installer/PowerToysSetup/$(InstallerRelativePath)' - signType: batchSigning - batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' - ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' - - #### END MSI - #### BOOTSTRAP BUILDING AND SIGNING - - - task: VSBuild@1 - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Build Bootstrapper - inputs: - solution: "**/installer/PowerToysSetup.sln" - vsVersion: 17.0 - msbuildArgs: >- - /t:PowerToysBootstrapper - /p:PerUser=${{parameters.buildUserInstaller}};CIBuild=true - /bl:$(LogOutputDirectory)\installer-$(InstallerBuildSlug)-bootstrapper.binlog - -restore -graph - ${{ parameters.additionalBuildOptions }} - platform: $(BuildPlatform) - configuration: $(BuildConfiguration) - clean: false # don't undo our hard work above by deleting the MSI - msbuildArchitecture: x64 - maximumCpuCount: true - - # The entirety of bundle unpacking/re-packing is unnecessary if we are not code signing it. - - ${{ if eq(parameters.codeSign, true) }}: - - script: |- - "C:\Program Files (x86)\WiX Toolset v3.14\bin\insignia.exe" -ib installer\PowerToysSetup\$(InstallerRelativePath)\$(InstallerBasename).exe -o installer\engine.exe - displayName: "${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Insignia: Extract Engine from Bundle" - - - template: steps-esrp-signing.yml - parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign WiX Engine - signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: "installer" - Pattern: engine.exe - signConfigType: inlineSignParams - inlineOperation: | - [ - { - "KeyCode": "CP-230012", - "OperationCode": "SigntoolSign", - "Parameters": { - "OpusName": "Microsoft", - "OpusInfo": "http://www.microsoft.com", - "FileDigest": "/fd \"SHA256\"", - "PageHash": "/NPH", - "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" - }, - "ToolName": "sign", - "ToolVersion": "1.0" - }, - { - "KeyCode": "CP-230012", - "OperationCode": "SigntoolVerify", - "Parameters": {}, - "ToolName": "sign", - "ToolVersion": "1.0" - } - ] - - - script: |- - "C:\Program Files (x86)\WiX Toolset v3.14\bin\insignia.exe" -ab installer\engine.exe installer\PowerToysSetup\$(InstallerRelativePath)\$(InstallerBasename).exe -o installer\PowerToysSetup\$(InstallerRelativePath)\$(InstallerBasename).exe - displayName: "${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Insignia: Merge Engine into Bundle" - - - template: steps-esrp-signing.yml - parameters: - displayName: ${{replace(replace(parameters.buildUserInstaller,'True','👤'),'False','💻')}} Sign Final Bootstrapper - signingIdentity: ${{ parameters.signingIdentity }} - inputs: - FolderPath: 'installer/PowerToysSetup/$(InstallerRelativePath)' - signType: batchSigning - batchSignPolicyFile: '$(build.sourcesdirectory)\.pipelines\ESRPSigning_installer.json' - ciPolicyFile: '$(build.sourcesdirectory)\.pipelines\CIPolicy.xml' - #### END BOOTSTRAP - ## END INSTALLER diff --git a/.pipelines/v2/templates/steps-esrp-sign-files-authenticode.yml b/.pipelines/v2/templates/steps-esrp-sign-files-authenticode.yml new file mode 100644 index 0000000000..5b9bbd2fce --- /dev/null +++ b/.pipelines/v2/templates/steps-esrp-sign-files-authenticode.yml @@ -0,0 +1,45 @@ +parameters: + - name: displayName + type: string + default: Sign Specific Files + - name: folder + type: string + - name: pattern + type: string + - name: signingIdentity + type: object + default: {} + +steps: + - template: steps-esrp-signing.yml + parameters: + displayName: ${{ parameters.displayName }} + signingIdentity: ${{ parameters.signingIdentity }} + inputs: + FolderPath: ${{ parameters.folder }} + Pattern: ${{ parameters.pattern }} + UseMinimatch: true + signConfigType: inlineSignParams + inlineOperation: |- + [ + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolSign", + "Parameters": { + "OpusName": "Microsoft", + "OpusInfo": "http://www.microsoft.com", + "FileDigest": "/fd \"SHA256\"", + "PageHash": "/NPH", + "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + }, + "ToolName": "sign", + "ToolVersion": "1.0" + }, + { + "KeyCode": "CP-230012", + "OperationCode": "SigntoolVerify", + "Parameters": {}, + "ToolName": "sign", + "ToolVersion": "1.0" + } + ] diff --git a/.pipelines/v2/templates/steps-esrp-signing.yml b/.pipelines/v2/templates/steps-esrp-signing.yml index 5f76f077e2..bff0bce0f1 100644 --- a/.pipelines/v2/templates/steps-esrp-signing.yml +++ b/.pipelines/v2/templates/steps-esrp-signing.yml @@ -10,7 +10,7 @@ parameters: default: {} steps: - - task: EsrpCodeSigning@5 + - task: EsrpCodeSigning@6 displayName: 🔏 ${{ parameters.displayName }} inputs: ConnectedServiceName: ${{ parameters.signingIdentity.serviceName }} diff --git a/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml b/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml index 30cf2b6f67..58f2fe6c47 100644 --- a/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml +++ b/.pipelines/v2/templates/steps-fetch-and-prepare-localizations.yml @@ -4,12 +4,12 @@ parameters: default: false steps: - - task: TouchdownBuildTask@3 + - task: TouchdownBuildTask@5 displayName: 'Download Localization Files -- PowerToys 37400' inputs: teamId: 37400 - TDBuildServiceConnection: $(TouchdownServiceConnection) - authType: SubjectNameIssuer + FederatedIdentityTDBuildServiceConnection: $(TouchdownServiceConnection) + authType: FederatedIdentityTDBuild resourceFilePath: | **\Resources.resx **\Resource.resx diff --git a/.pipelines/v2/templates/steps-setup-versioning.yml b/.pipelines/v2/templates/steps-setup-versioning.yml new file mode 100644 index 0000000000..6dc0e3ef92 --- /dev/null +++ b/.pipelines/v2/templates/steps-setup-versioning.yml @@ -0,0 +1,11 @@ +parameters: + - name: directory + type: string + default: $(Build.SourcesDirectory) + +steps: + - pwsh: |- + nuget install Microsoft.Windows.Terminal.Versioning -ConfigFile "$(Build.SourcesDirectory)\.pipelines\release-nuget.config" -OutputDirectory _versioning + $VersionRoot = (Get-Item _versioning\Microsoft.Windows.*).FullName + & "$VersionRoot\build\Setup.ps1" -ProjectDirectory "${{ parameters.directory }}" -Verbose + displayName: Set up versioning for ${{ parameters.directory }} via M.W.T.V diff --git a/.pipelines/v2/templates/steps-update-winappsdk-and-restore-nuget.yml b/.pipelines/v2/templates/steps-update-winappsdk-and-restore-nuget.yml index 1fccd6de74..566c8045c4 100644 --- a/.pipelines/v2/templates/steps-update-winappsdk-and-restore-nuget.yml +++ b/.pipelines/v2/templates/steps-update-winappsdk-and-restore-nuget.yml @@ -17,40 +17,22 @@ steps: arguments: > -winAppSdkVersionNumber ${{ parameters.versionNumber }} -useExperimentalVersion $${{ parameters.useExperimentalVersion }} + -rootPath "$(build.sourcesdirectory)" -- script: echo $(WinAppSDKVersion) - displayName: 'Display WinAppSDK Version Found' +# - task: NuGetCommand@2 +# displayName: 'Restore NuGet packages (slnx)' +# inputs: +# command: 'restore' +# feedsToUse: 'config' +# nugetConfigPath: '$(build.sourcesdirectory)\nuget.config' +# restoreSolution: '$(build.sourcesdirectory)\**\*.slnx' +# includeNuGetOrg: false -- task: DownloadPipelineArtifact@2 - displayName: 'Download WindowsAppSDK' - inputs: - buildType: 'specific' - project: '55e8140e-57ac-4e5f-8f9c-c7c15b51929d' - definition: '104083' - buildVersionToDownload: 'latestFromBranch' - branchName: 'refs/heads/release/${{ parameters.versionNumber }}-stable' - artifactName: 'WindowsAppSDK_Nuget_And_MSIX' - targetPath: '$(Build.SourcesDirectory)\localpackages' - -- script: dir $(Build.SourcesDirectory)\localpackages\NugetPackages - displayName: 'List downloaded packages' - -- task: NuGetCommand@2 - displayName: 'Install WindowsAppSDK' - inputs: - command: 'custom' - arguments: > - install "Microsoft.WindowsAppSDK" - -Source "$(Build.SourcesDirectory)\localpackages\NugetPackages" - -Version "$(WinAppSDKVersion)" - -OutputDirectory "$(Build.SourcesDirectory)\localpackages\output" - -FallbackSource "https://microsoft.pkgs.visualstudio.com/ProjectReunion/_packaging/Project.Reunion.nuget.internal/nuget/v3/index.json" - -- task: NuGetCommand@2 - displayName: 'Restore NuGet packages' +- task: DotNetCoreCLI@2 + displayName: 'Restore NuGet packages (dotnet)' inputs: command: 'restore' + projects: '$(build.sourcesdirectory)\**\*.slnx' feedsToUse: 'config' nugetConfigPath: '$(build.sourcesdirectory)\nuget.config' - restoreSolution: '$(build.sourcesdirectory)\**\*.sln' - includeNuGetOrg: false \ No newline at end of file + workingDirectory: '$(build.sourcesdirectory)' diff --git a/.pipelines/v2/templates/variables-nuget-package-version.yml b/.pipelines/v2/templates/variables-nuget-package-version.yml new file mode 100644 index 0000000000..460b7ceee0 --- /dev/null +++ b/.pipelines/v2/templates/variables-nuget-package-version.yml @@ -0,0 +1,17 @@ +variables: + # If we are building a branch called "stable*", hide the NuGet suffix. + # If we don't do that, XES will set the suffix to "stable". + # main is special, however. XES ignores main. Since we never produce actual + # shipping builds from main, we want to force it to have a beta label. + # + # In effect: + # BRANCH / BRANDING | Version | + # ------------------|----------------------------| + # stable | 0.2.250512001 | + # main | 0.2.250512001-experimental | + # all others | 0.2.250512001-branch | + ${{ if startsWith(variables['Build.SourceBranchName'], 'stable') }}: + NoNuGetPackBetaVersion: true + ${{ elseif eq(variables['Build.SourceBranchName'], 'main') }}: + NuGetPackBetaVersion: experimental + diff --git a/.pipelines/verifyAndSetLatestVCToolsVersion.ps1 b/.pipelines/verifyAndSetLatestVCToolsVersion.ps1 index a47e6a2292..7fef0fe296 100644 --- a/.pipelines/verifyAndSetLatestVCToolsVersion.ps1 +++ b/.pipelines/verifyAndSetLatestVCToolsVersion.ps1 @@ -1,7 +1,42 @@ -$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 = ($VSInstances.instances.instance.packages.package | ? { $_.id -eq "Microsoft.VisualCpp.Tools.Core" }) +$LatestVCPackage = ($VSPackages | ? { $_.id -eq "Microsoft.VisualCpp.Tools.Core" }) $LatestVCToolsVersion = $LatestVCPackage.version; + +$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 +# differs from the version on the files themselves. We might as well check +# whether the version we just found _actually exists_ before we use it. +# We'll use whichever highest version exists. +$PackageVCToolPath = Join-Path $VCToolsRoot $LatestVCToolsVersion +If ($Null -Eq (Get-Item $PackageVCToolPath -ErrorAction:Ignore)) { + $VCToolsVersions = Get-ChildItem $VCToolsRoot | ForEach-Object { + [Version]$_.Name + } | Sort -Descending + $LatestActualVCToolsVersion = $VCToolsVersions | Select -First 1 + + If ([Version]$LatestVCToolsVersion -Ne $LatestActualVCToolsVersion) { + Write-Output "VC Tools Mismatch: Directory = $LatestActualVCToolsVersion, Package = $LatestVCToolsVersion" + $LatestVCToolsVersion = $LatestActualVCToolsVersion.ToString(3) + } +} + 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/verifyCommonProps.ps1 b/.pipelines/verifyCommonProps.ps1 new file mode 100644 index 0000000000..7ed52f6bf1 --- /dev/null +++ b/.pipelines/verifyCommonProps.ps1 @@ -0,0 +1,62 @@ +[CmdletBinding()] +Param( + [Parameter(Mandatory = $True, Position = 1)] + [string]$sourceDir +) + +# scan all csharp project in the source directory +function Get-CSharpProjects { + param ( + [string]$path + ) + + # Get all .csproj files under the specified path + return Get-ChildItem -Path $path -Recurse -Filter *.csproj | Select-Object -ExpandProperty FullName +} + +# Check if the project file imports 'Common.Dotnet.CsWinRT.props' +function Test-ImportSharedCsWinRTProps { + param ( + [string]$filePath + ) + + # Load the XML content of the .csproj file + [xml]$csprojContent = Get-Content -Path $filePath + + + # Check if the Import element with Project attribute containing 'Common.Dotnet.CsWinRT.props' exists + return $csprojContent.Project.Import | Where-Object { $null -ne $_.Project -and $_.Project.EndsWith('Common.Dotnet.CsWinRT.props') } +} + +# Call the function with the provided source directory +$csprojFilesArray = Get-CSharpProjects -path $sourceDir + +$hasInvalidCsProj = $false + +# Enumerate the array of file paths and call Validate-ImportSharedCsWinRTProps for each file +foreach ($csprojFile in $csprojFilesArray) { + # Skip if the file ends with 'TemplateCmdPalExtension.csproj' + if ($csprojFile -like '*TemplateCmdPalExtension.csproj') { + continue + } + + # The CmdPal.Core projects use a common shared props file, so skip them + if ($csprojFile -like '*Microsoft.CmdPal.Core.*.csproj') { + continue + } + if ($csprojFile -like '*Microsoft.CmdPal.Ext.Shell.csproj') { + continue + } + + $importExists = Test-ImportSharedCsWinRTProps -filePath $csprojFile + if (!$importExists) { + Write-Output "$csprojFile need to import 'Common.Dotnet.CsWinRT.props'." + $hasInvalidCsProj = $true + } +} + +if ($hasInvalidCsProj) { + exit 1 +} + +exit 0 \ No newline at end of file diff --git a/.pipelines/verifyDepsJsonLibraryVersions.ps1 b/.pipelines/verifyDepsJsonLibraryVersions.ps1 index e85ca1d991..6123316b5f 100644 --- a/.pipelines/verifyDepsJsonLibraryVersions.ps1 +++ b/.pipelines/verifyDepsJsonLibraryVersions.ps1 @@ -19,7 +19,7 @@ Get-ChildItem $targetDir -Recurse -Filter *.deps.json -Exclude *UITest*,MouseJum # Temporarily exclude All UI-Test, Fuzzer-Test projects because of Appium.WebDriver dependencies $depsJsonFullFileName = $_.FullName - if ($depsJsonFullFileName -like "*CmdPal*") { + if ($depsJsonFullFileName -like "*CmdPal*" -or $depsJsonFullFileName -like "*CommandPalette*") { return } @@ -92,4 +92,3 @@ if ($totalFailures -gt 0) { Write-Host -ForegroundColor Green "All " $referencedFileVersionsPerDll.keys.Count " libraries are mentioned with the same version across the dependencies.`r`n" exit 0 - diff --git a/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 b/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 index ebef8412a7..a5cf73e6e9 100644 --- a/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 +++ b/.pipelines/verifyNoticeMdAgainstNugetPackages.ps1 @@ -57,12 +57,19 @@ $totalList = $projFiles | ForEach-Object -Parallel { $p = -split $p $p = $p[1, 2] - $tempString = $p[0] + " " + $p[1] + $tempString = $p[0] - if(![string]::IsNullOrWhiteSpace($tempString)) + if([string]::IsNullOrWhiteSpace($tempString)) { - echo "- $tempString"; + Continue } + + if($tempString.StartsWith("Microsoft.") -Or $tempString.StartsWith("System.")) + { + Continue + } + + echo "- $tempString" } $csproj = $null; } @@ -72,10 +79,84 @@ $returnList = [System.Collections.Generic.HashSet[string]]($totalList) -join "`r Write-Host $returnList +# Extract the current package list from NOTICE.md +$noticePattern = "## NuGet Packages used by PowerToys\s*((?:\r?\n- .+)+)" +$noticeMatch = [regex]::Match($noticeFile, $noticePattern) + +if ($noticeMatch.Success) { + $currentNoticePackageList = $noticeMatch.Groups[1].Value.Trim() +} else { + Write-Warning "Warning: Could not find 'NuGet Packages used by PowerToys' section in NOTICE.md" + $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." - exit 1 + 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 + $noticePackages = $currentNoticePackageList -split "`r`n|`n" | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() } | Sort-Object + + Write-Host "" + Write-Host -ForegroundColor Cyan "=== DETAILED DIFFERENCE ANALYSIS ===" + Write-Host "" + + # 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 (ERROR - these must be added to NOTICE.md):" + foreach ($pkg in $missingFromNotice) { + Write-Host -ForegroundColor Red " $pkg" + } + Write-Host "" + } + + # Find packages in NOTICE.md but not in proj file list + $extraInNotice = $noticePackages | Where-Object { $generatedPackages -notcontains $_ } + + # 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 "" + } + + # Show counts for summary + Write-Host -ForegroundColor Cyan "Summary:" + 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 (allowed): $($allowedExtra.Count) packages" + Write-Host " ExtraInNotice (unexpected): $($unexpectedExtra.Count) packages" + Write-Host "" + + # 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/.pipelines/verifyNugetPackages.ps1 b/.pipelines/verifyNugetPackages.ps1 index 54d0137121..c7cebbd383 100644 --- a/.pipelines/verifyNugetPackages.ps1 +++ b/.pipelines/verifyNugetPackages.ps1 @@ -21,4 +21,13 @@ if (-not $?) exit 1 } +# Ignore NU1503 on vcxproj files +dotnet restore $solution /nowarn:NU1503 +if ($lastExitCode -ne 0) +{ + $result = $lastExitCode + Write-Error "Error running dotnet restore, with the exit code $lastExitCode. Please verify logs on the nuget package versions." + exit $result +} + exit 0 diff --git a/.pipelines/versionAndSignCheck.ps1 b/.pipelines/versionAndSignCheck.ps1 index 89cfbeea1c..5b03250dd6 100644 --- a/.pipelines/versionAndSignCheck.ps1 +++ b/.pipelines/versionAndSignCheck.ps1 @@ -9,6 +9,7 @@ Param( $DirPath = $targetDir; #this file is in pipeline, we need root. $items = Get-ChildItem -Path $DirPath -File -Include *.exe, *.dll, *.ttf, PTCustomActions -Recurse -Force -ErrorAction SilentlyContinue $versionExceptions = @( + "AdaptiveCards.Templating.dll", "Microsoft.Windows.ApplicationModel.DynamicDependency.Projection.dll", "Microsoft.Windows.ApplicationModel.Resources.Projection.dll", "Microsoft.Windows.ApplicationModel.WindowsAppRuntime.Projection.dll", @@ -26,8 +27,11 @@ $versionExceptions = @( "WyHash.dll", "Microsoft.Recognizers.Text.DataTypes.TimexExpression.dll", "ObjectModelCsProjection.dll", - "RendererCsProjection.dll") -join '|'; + "RendererCsProjection.dll", + "Microsoft.ML.OnnxRuntime.dll") -join '|'; $nullVersionExceptions = @( + "SkiaSharp.Views.WinUI.Native.dll", + "libSkiaSharp.dll", "codicon.ttf", "e_sqlite3.dll", "getfilesiginforedist.dll", @@ -49,7 +53,12 @@ $nullVersionExceptions = @( "System.Diagnostics.EventLog.Messages.dll", "Microsoft.Windows.Widgets.dll", "AdaptiveCards.ObjectModel.WinUI3.dll", - "AdaptiveCards.Rendering.WinUI3.dll") -join '|'; + "AdaptiveCards.Rendering.WinUI3.dll", + "boost_regex_vc143_mt_gd_x32_1_87.dll", + "boost_regex_vc143_mt_gd_x64_1_87.dll", + "boost_regex_vc143_mt_x32_1_87.dll", + "boost_regex_vc143_mt_x64_1_87.dll" + ) -join '|'; $totalFailure = 0; Write-Host $DirPath; @@ -95,4 +104,4 @@ if ($totalFailure -gt 0) { exit 1 } -exit 0 +exit 0 \ No newline at end of file diff --git a/.pipelines/versionSetting.ps1 b/.pipelines/versionSetting.ps1 index bda3c47cc2..cf2d2595af 100644 --- a/.pipelines/versionSetting.ps1 +++ b/.pipelines/versionSetting.ps1 @@ -5,10 +5,7 @@ Param( [Parameter(Mandatory=$True,Position=2)] [AllowEmptyString()] - [string]$DevEnvironment = "Local", - - [Parameter(Mandatory=$True,Position=3)] - [string]$cmdPalVersionNumber = "0.0.1" + [string]$DevEnvironment = "Local" ) Write-Host $PSScriptRoot @@ -49,7 +46,6 @@ $verProps.Save($verPropWriteFileLocation); $verPropWriteFileLocation = $PSScriptRoot + '/../src/CmdPalVersion.props'; $verPropReadFileLocation = $verPropWriteFileLocation; [XML]$verProps = Get-Content $verPropReadFileLocation -$verProps.Project.PropertyGroup.CmdPalVersion = $cmdPalVersionNumber; $verProps.Project.PropertyGroup.DevEnvironment = $DevEnvironment; Write-Host "xml" $verProps.Project.PropertyGroup.Version $verProps.Save($verPropWriteFileLocation); @@ -90,12 +86,3 @@ $newPlusContextMenuAppManifestReadFileLocation = $newPlusContextMenuAppManifestW $newPlusContextMenuAppManifest.Package.Identity.Version = $versionNumber + '.0' Write-Host "NewPlusContextMenu version" $newPlusContextMenuAppManifest.Package.Identity.Version $newPlusContextMenuAppManifest.Save($newPlusContextMenuAppManifestWriteFileLocation); - -# Set package version in Package.appxmanifest -$cmdPalAppManifestWriteFileLocation = $PSScriptRoot + '/../src/modules/cmdpal/Microsoft.CmdPal.UI/Package.appxmanifest'; -$cmdPalAppManifestReadFileLocation = $cmdPalAppManifestWriteFileLocation; - -[XML]$cmdPalAppManifest = Get-Content $cmdPalAppManifestReadFileLocation -$cmdPalAppManifest.Package.Identity.Version = $cmdPalVersionNumber + '.0' -Write-Host "CmdPal Package version: " $cmdPalAppManifest.Package.Identity.Version -$cmdPalAppManifest.Save($cmdPalAppManifestWriteFileLocation); diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..940ff302de --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,54 @@ +{ + "version": "0.2.0", + "inputs": [ + { + "id": "arch", + "type": "pickString", + "description": "Select target architecture", + "options": ["x64", "arm64"], + "default": "x64" + } + ], + "configurations": [ + { + "name": "Run native executable (no build)", + "type": "cppvsdbg", + "request": "launch", + "program": "${workspaceFolder}\\${input:arch}\\Debug\\PowerToys.exe", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "console": "integratedTerminal" + }, + { + "name": "C/C++ Attach to PowerToys Process (native)", + "type": "cppvsdbg", + "request": "attach", + "processId": "${command:pickProcess}", + "symbolSearchPath": "${workspaceFolder}\\${input:arch}\\Debug;${workspaceFolder}\\Debug;${workspaceFolder}\\symbols" + }, + { + "name": "Run managed code (managed, no build, ARCH configurable)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}\\${input:arch}\\Debug\\WinUI3Apps\\PowerToys.Settings.exe", + "args": [], + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": "Run AdvancedPaste (managed, no build, ARCH configurable)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}\\${input:arch}\\Debug\\WinUI3Apps\\PowerToys.AdvancedPaste.exe", + "args": [], + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole", + "stopAtEntry": false + }, + ] +} \ No newline at end of file diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000000..3067e78889 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,13 @@ +{ + "servers": { + "github-artifacts": { + "command": "node", + "args": [ + "tools/mcp/github-artifacts/launch.js" + ], + "env": { + "GITHUB_TOKEN": "${env:GITHUB_TOKEN}" + } + } + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..868a34ff8e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "github.copilot.chat.reviewSelection.instructions": [ + { + "file": ".github/prompts/review-pr.prompt.md" + } + ], + "github.copilot.chat.commitMessageGeneration.instructions": [ + { + "file": ".github/prompts/create-commit-title.prompt.md" + } + ], + "github.copilot.chat.pullRequestDescriptionGeneration.instructions": [ + { + "file": ".github/prompts/create-pr-summary.prompt.md" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..832dafc7f5 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,106 @@ +{ + "version": "2.0.0", + "windows": { + "options": { + "shell": { + "executable": "cmd.exe", + "args": ["/d", "/c"] + } + } + }, + + "inputs": [ + { + "id": "config", + "type": "pickString", + "description": "Configuration", + "options": ["Debug", "Release"], + "default": "Debug" + }, + { + "id": "platform", + "type": "pickString", + "description": "Platform (leave empty to auto-detect host platform)", + "options": ["", "X64", "ARM64"], + "default": "X64" + }, + { + "id": "msbuildExtra", + "type": "promptString", + "description": "Extra MSBuild args (optional). Example: /p:CIBuild=true /m", + "default": "" + } + ], + + "tasks": [ + { + "label": "PT: Build (quick)", + "type": "shell", + "command": "\"${workspaceFolder}\\tools\\build\\build.cmd\"", + "args": [ + "-Path", + "${fileDirname}" + ], + "group": { "kind": "build", "isDefault": true }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": "$msCompile" + }, + + { + "label": "PT: Build (with options)", + "type": "shell", + "command": "\"${workspaceFolder}\\tools\\build\\build.cmd\"", + "args": [ + "-Path", + "${fileDirname}", + "-Platform", + "${input:platform}", + "-Configuration", + "${input:config}", + "${input:msbuildExtra}" + ], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": "$msCompile" + }, + + { + "label": "PT: Build Essentials (quick)", + "type": "shell", + "command": "\"${workspaceFolder}\\tools\\build\\build-essentials.cmd\"", + "args": [], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": "$msCompile" + }, + + { + "label": "PT: Build Essentials (with options)", + "type": "shell", + "command": "\"${workspaceFolder}\\tools\\build\\build-essentials.cmd\"", + "args": [ + "-Platform", + "${input:platform}", + "-Configuration", + "${input:config}", + "${input:msbuildExtra}" + ], + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": "$msCompile" + } + ] +} diff --git a/.vsconfig b/.vsconfig index 77ec8b0ffd..90abacd81c 100644 --- a/.vsconfig +++ b/.vsconfig @@ -9,6 +9,7 @@ "Microsoft.VisualStudio.Component.Windows10SDK.19041", "Microsoft.VisualStudio.Component.Windows10SDK.20348", "Microsoft.VisualStudio.Component.Windows10SDK.22621", + "Microsoft.VisualStudio.Component.Windows10SDK.26100", "Microsoft.VisualStudio.ComponentGroup.UWP.VC", "Microsoft.VisualStudio.Component.UWP.VC.ARM64", "Microsoft.VisualStudio.Component.VC.Runtimes.ARM64.Spectre", 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.<config>.<platform>.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.<configuration>.<platform>.errors.log` – errors only (check this first) +- `build.<configuration>.<platform>.all.log` – full log +- `build.<configuration>.<platform>.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 `<Product>*UnitTests` or `<Product>*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/COMMUNITY.md b/COMMUNITY.md index 8b3d7035da..dbbc413f68 100644 --- a/COMMUNITY.md +++ b/COMMUNITY.md @@ -6,9 +6,6 @@ Names are in alphabetical order based on first name. ## High impact community members -### [@Aaron-Junker](https://github.com/Aaron-Junker) - [Aaron Junker](https://aaron-junker.github.io) -Aaron has helped triaging, discussing, and creating a substantial number of issues and contributed features/fixes. Aaron was the primary person for helping build the File Explorer preview pane handler for developer files. - ### [@cgaarden](https://github.com/cgaarden) - [Christian Gaarden Gaardmark](https://www.onegreatworld.com) Christian contributed New+ utility @@ -16,12 +13,12 @@ Christian contributed New+ utility CleanCodeDeveloper helped do massive amounts of code stability and image resizer work. ### [@plante-msft](https://github.com/plante-msft) - Connor Plante -Connor was the creator of Workspaces and helped create PowerToys Run v2 +Connor was the creator of Workspaces and helped create Command Palette (PowerToys Run v2) ### [@damienleroy](https://github.com/damienleroy) - [Damien Leroy](https://www.linkedin.com/in/Damien-Leroy-b2734416a/) Damien has helped out by developing and contributing the Quick Accent utility. -### [@daverayment ](https://github.com/daverayment) - [David Rayment](https://www.linkedin.com/in/david-rayment-168b5251/) +### [@daverayment](https://github.com/daverayment) - [David Rayment](https://www.linkedin.com/in/david-rayment-168b5251/) Dave has helped improve the experience inside of Peek by adding in new features and fixing bugs. ### [@davidegiacometti](https://github.com/davidegiacometti) - [Davide Giacometti](https://www.linkedin.com/in/davidegiacometti/) @@ -42,11 +39,17 @@ Jay has helped triaging, discussing, creating a substantial number of issues and ### [@jefflord](https://github.com/Jjefflord) - Jeff Lord Jeff added in multiple new features into Keyboard manager, such as key chord support and launching apps. He also contributed multiple features/fixes to PowerToys. +### [@snickler](https://github.com/snickler) - [Jeremy Sinclair](http://sinclairinat0r.com) +Jeremy has helped drive large sums of the ARM64 support inside PowerToys + +### [@jiripolasek](https://github.com/jiripolasek) - [Jiří Polášek](https://github.com/jiripolasek) +Jiří has contributed a massive number of features and improvements to Command Palette, including drag & drop support, custom themes, Web Search enhancements, Remote Desktop extension fixes, and many UX improvements. + ### [@TheJoeFin](https://github.com/TheJoeFin) - [Joe Finney](https://joefinapps.com) Joe has helped triaging, discussing, issues as well as fixing bugs and building features for Text Extractor. ### [@joadoumie](https://github.com/joadoumie) - Jordi Adoumie -Jordi helped innovate amazing new features into Advanced Paste and helped create PowerToys Run v2 +Jordi helped innovate amazing new features into Advanced Paste and helped create Command Palette (PowerToys Run v2) ### [@jsoref](https://github.com/jsoref) - [Josh Soref](https://check-spelling.dev/) Helping keep our spelling correct :) @@ -57,6 +60,12 @@ Color Picker is from Martin. ### [@mikeclayton](https://github.com/mikeclayton) - [Michael Clayton](https://michael-clayton.com) Michael contributed the [initial version](https://github.com/microsoft/PowerToys/issues/23216) of the Mouse Jump tool and [a number of updates](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+author%3Amikeclayton) based on his FancyMouse utility. +### [@Noraa-Junker](https://github.com/Noraa-Junker) - [Noraa Junker](https://noraajunker.ch) +Noraa has helped triaging, discussing, and creating a substantial number of issues and contributed features/fixes. Noraa was the primary person for helping build the File Explorer preview pane handler for developer files. + +### [@pedrolamas](https://github.com/pedrolamas/) - Pedro Lamas +Pedro helped create the thumbnail and File Explorer previewers for 3D files like STL and GCode. If you like 3D printing, these are very helpful. + ### [@PesBandi](https://github.com/PesBandi/) - PesBandi PesBandi has helped do massive amounts of Quick Accent and bug fixes. @@ -66,15 +75,12 @@ Rafael has helped do the [upgrade from CppWinRT 1.x to 2.0](https://github.com/m ### [@royvou](https://github.com/royvou) Roy has helped out contributing multiple features to PowerToys Run -### [@snickler](https://github.com/snickler) - [Jeremy Sinclair](http://sinclairinat0r.com) -Jeremy has helped drive large sums of the ARM64 support inside PowerToys +### [@ThiefZero](https://github.com/ThiefZero) +ThiefZero has helped out contributing a features to PowerToys Run such as the unit converter plugin ### [@TobiasSekan](https://github.com/TobiasSekan) - Tobias Sekan Tobias Sekan has helped out contributing features to PowerToys Run such as Settings plugin, Registry plugin -### [@ThiefZero](https://github.com/ThiefZero) -ThiefZero has helped out contributing a features to PowerToys Run such as the unit converter plugin - ## Open source projects As PowerToys creates new utilities, some will be based off existing technology. We'll continue to do our best to contribute back to these projects but their efforts were the base of some of our projects. We want to be sure their work is directly recognized. @@ -114,14 +120,13 @@ PowerRename is from Chris's SmartRename and icon rendering for SVGs in File Expl PowerToys Awake is a tool to keep your computer awake. -### [@Niels9001](https://github.com/niels9001/) - [Niels Laute](https://nielslaute.com/) - -Niels has helped drive large sums of our update toward a new [consistent and modern UX](https://github.com/microsoft/PowerToys/issues/891). This includes the [launcher work](https://github.com/microsoft/PowerToys/issues/44), color picker UX update and [icon design](https://github.com/microsoft/PowerToys/issues/1118). - ### [@randyrants](https://github.com/randyrants) - [Randy Santossio](https://www.randyrants.com) Randy contributed Registry Preview and some very early conversations about keyboard remapping. +### [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon +Kayla was a former lead for PowerToys and helped create multiple utilities, maintained the GitHub repo, and collaborated with the community to improve the overall product + ### [@oldnewthing](https://github.com/oldnewthing) - Raymond Chen Find My Mouse is based on Raymond Chen's SuperSonar. @@ -181,21 +186,44 @@ ZoomIt source code was originally implemented by [Sysinternals](https://sysinter ## PowerToys core team -- [@crutkas](https://github.com/crutkas/) - Clint Rutkas - Lead -- [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon - Lead +- [@craigloewen-msft](https://github.com/craigloewen-msft) - Craig Loewen - Product Manager +- [@niels9001](https://github.com/niels9001/) - Niels Laute - Product Manager +- [@dhowett](https://github.com/dhowett) - Dustin Howett - Dev Lead +- [@yeelam-gordon](https://github.com/yeelam-gordon) - Gordon Lam - Dev Lead +- [@lei9444](https://github.com/lei9444) - Leilei Zhang - Dev +- [@shuaiyuanxx](https://github.com/shuaiyuanxx) - Shawn Yuan - Dev +- [@moooyo](https://github.com/moooyo) - Yu Leng - Dev +- [@haoliuu](https://github.com/haoliuu) - Hao Liu - Dev +- [@vanzue](https://github.com/vanzue) - Kai Tao - Dev +- [@zadjii-msft](https://github.com/zadjii-msft) - Mike Griese - Dev +- [@khmyznikov](https://github.com/khmyznikov) - Gleb Khmyznikov - Dev +- [@chatasweetie](https://github.com/chatasweetie) - Jessica Earley-Cha - Dev +- [@MichaelJolley](https://github.com/MichaelJolley) - Michael Jolley - Dev +- [@Jaylyn-Barbee](https://github.com/Jaylyn-Barbee) - Jaylyn Barbee - Dev +- [@zateutsch](https://github.com/zateutsch) - Zach Teutsch - Dev +- [@crutkas](https://github.com/crutkas/) - Clint Rutkas - Overhead + +## Former PowerToys core team members + +- [@indierawk2k2](https://github.com/indierawk2k2) - Mike Harsh - Product Manager +- [@cinnamon-msft](https://github.com/cinnamon-msft) - Kayla Cinnamon - Product Manager +- [@ethanfangg](https://github.com/ethanfangg) - Ethan Fang - Product Manager +- [@plante-msft](https://github.com/plante-msft) - Connor Plante - Product Manager +- [@joadoumie](https://github.com/joadoumie) - Jordi Adoumie - Product Manager - [@nguyen-dows](https://github.com/nguyen-dows) - Christopher Nguyen - Product Manager -- [@jaimecbernardo](https://github.com/jaimecbernardo) - Jaime Bernardo - Dev lead -- [@dhowett](https://github.com/dhowett) - Dustin Howett - Dev lead -- [@yeelam-gordon](https://github.com/yeelam-gordon) - Gordon Lam - Dev lead -- [@jamrobot](https://github.com/jamrobot) - Jerry Xu - Dev lead +- [@enricogior](https://github.com/enricogior) - Enrico Giordani - Dev Lead +- [@bzoz](https://github.com/bzoz) - Bartosz Sosnowski - Dev +- [@ivan100sic](https://github.com/ivan100sic) - Ivan Stošić - Dev +- [@mykhailopylyp](https://github.com/mykhailopylyp) - Mykhailo Pylyp - Dev +- [@taras-janea](https://github.com/taras-janea) - Taras Sich - Dev +- [@yuyoyuppe](https://github.com/yuyoyuppe) - Andrey Nekrasov - Dev +- [@gokcekantarci](https://github.com/gokcekantarci) - Gokce Kantarci - Dev - [@drawbyperpetual](https://github.com/drawbyperpetual) - Anirudha Shankar - Dev - [@mantaionut](https://github.com/mantaionut) - Ionut Manta - Dev - [@donlaci](https://github.com/donlaci) - Laszlo Nemeth - Dev - [@SeraphimaZykova](https://github.com/SeraphimaZykova) - Seraphima Zykova - Dev - [@stefansjfw](https://github.com/stefansjfw) - Stefan Markovic - Dev -- [@lei9444](https://github.com/lei9444) - Leilei Zhang - Dev -- [@shuaiyuanxx](https://github.com/shuaiyuanxx) - Shawn Yuan - Dev -- [@moooyo](https://github.com/moooyo) - Yu Leng - Dev +- [@jaimecbernardo](https://github.com/jaimecbernardo) - Jaime Bernardo - Dev Lead - [@haoliuu](https://github.com/haoliuu) - Hao Liu - Dev - [@chenmy77](https://github.com/chenmy77) - Mengyuan Chen - Dev - [@chemwolf6922](https://github.com/chemwolf6922) - Feng Wang - Dev @@ -204,18 +232,4 @@ ZoomIt source code was originally implemented by [Sysinternals](https://sysinter - [@urnotdfs](https://github.com/urnotdfs) - Xiaofeng Wang - Dev - [@zhaopy536](https://github.com/zhaopy536) - Peiyao Zhao - Dev - [@wang563681252](https://github.com/wang563681252) - Zhaopeng Wang - Dev -- [@vanzue](https://github.com/vanzue) - Kai Tao - Dev - -# Former PowerToys core team members - -- [@indierawk2k2](https://github.com/indierawk2k2) - Mike Harsh - Product Manager -- [@ethanfangg](https://github.com/ethanfangg) - Ethan Fang - Product Manager -- [@plante-msft](https://github.com/plante-msft) - Connor Plante - Product Manager -- [@joadoumie](https://github.com/joadoumie) - Jordi Adoumie - Product Manager -- [@enricogior](https://github.com/enricogior) - Enrico Giordani - Dev Lead -- [@bzoz](https://github.com/bzoz) - Bartosz Sosnowski - Dev -- [@ivan100sic](https://github.com/ivan100sic) - Ivan Stošić - Dev -- [@mykhailopylyp](https://github.com/mykhailopylyp) - Mykhailo Pylyp - Dev -- [@taras-janea](https://github.com/taras-janea) - Taras Sich - Dev -- [@yuyoyuppe](https://github.com/yuyoyuppe) - Andrey Nekrasov - Dev -- [@gokcekantarci](https://github.com/gokcekantarci) - Gokce Kantarci - Dev +- [@jamrobot](https://github.com/jamrobot) - Jerry Xu - Dev Lead diff --git a/Cpp.Build.props b/Cpp.Build.props index 06c22b45bf..b6fa0ddecd 100644 --- a/Cpp.Build.props +++ b/Cpp.Build.props @@ -2,6 +2,12 @@ <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <!-- Skip building C++ test projects when BuildTests=false --> + <PropertyGroup Condition="'$(_IsSkippedTestProject)' == 'true'"> + <UsePrecompiledHeaders>false</UsePrecompiledHeaders> + <RunCodeAnalysis>false</RunCodeAnalysis> + </PropertyGroup> + <!-- Project configurations --> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|x64"> @@ -26,6 +32,7 @@ <PropertyGroup Condition="'$(SkipCppCodeAnalysis)' == ''"> <RunCodeAnalysis>true</RunCodeAnalysis> <CodeAnalysisRuleSet>$(MsbuildThisFileDirectory)\CppRuleSet.ruleset</CodeAnalysisRuleSet> + <CAExcludePath>$(MSBuildThisFileDirectory)deps;$(MSBuildThisFileDirectory)packages;$(CAExcludePath)</CAExcludePath> </PropertyGroup> <!-- C++ source compile-specific things for all configurations --> @@ -34,25 +41,29 @@ <PreferredToolArchitecture Condition="'$(PROCESSOR_ARCHITECTURE)' == 'ARM64' or '$(PROCESSOR_ARCHITEW6432)' == 'ARM64'">arm64</PreferredToolArchitecture> <VcpkgEnabled>false</VcpkgEnabled> <ReplaceWildcardsInProjectItems>true</ReplaceWildcardsInProjectItems> - <ExternalIncludePath>$(MSBuildThisFileFullPath)\..\deps\;$(MSBuildThisFileFullPath)\..\packages\;$(ExternalIncludePath)</ExternalIncludePath> + <ExternalIncludePath>$(MSBuildThisFileDirectory)deps;$(MSBuildThisFileDirectory)packages;$(ExternalIncludePath)</ExternalIncludePath> <!-- Enable control flow guard for C++ projects that don't consume any C++ files --> <!-- This covers the case where a .dll exports a .lib, but doesn't have any ClCompile entries. --> <LinkControlFlowGuard>Guard</LinkControlFlowGuard> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> + <!-- Make angle-bracket includes external and turn off code analysis for them --> + <TreatAngleIncludeAsExternal>true</TreatAngleIncludeAsExternal> + <ExternalWarningLevel>TurnOffAllWarnings</ExternalWarningLevel> + <DisableAnalyzeExternal>true</DisableAnalyzeExternal> + <MultiProcessorCompilation>true</MultiProcessorCompilation> <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Use</PrecompiledHeader> <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> <WarningLevel>Level4</WarningLevel> - <DisableSpecificWarnings>4679;5271;%(DisableSpecificWarnings)</DisableSpecificWarnings> + <DisableSpecificWarnings>4679;4706;4874;5271;%(DisableSpecificWarnings)</DisableSpecificWarnings> <DisableAnalyzeExternal >true</DisableAnalyzeExternal> <ExternalWarningLevel>TurnOffAllWarnings</ExternalWarningLevel> <ConformanceMode>false</ConformanceMode> <TreatWarningAsError>true</TreatWarningAsError> <LanguageStandard>stdcpplatest</LanguageStandard> <BuildStlModules>false</BuildStlModules> - <AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions> <!-- TODO: _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING for compatibility with VS 17.8. Check if we can remove. --> <PreprocessorDefinitions>_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions> <!-- CLR + CFG are not compatible >:{ --> @@ -96,27 +107,26 @@ <!-- Global props OverrideWindowsTargetPlatformVersion--> <PropertyGroup Label="Globals"> - <WindowsTargetPlatformVersion>10.0.22621.0</WindowsTargetPlatformVersion> - <TargetPlatformVersion>10.0.22621.0</TargetPlatformVersion> + <WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion> + <TargetPlatformVersion>10.0.26100.0</TargetPlatformVersion> <WindowsTargetPlatformMinVersion>10.0.19041.0</WindowsTargetPlatformMinVersion> </PropertyGroup> <!-- Props that are constant for both Debug and Release configurations --> <PropertyGroup Label="Configuration"> <PlatformToolset>v143</PlatformToolset> + <PlatformToolset Condition="'$(VisualStudioVersion)' == '18.0'">v145</PlatformToolset> <CharacterSet>Unicode</CharacterSet> <DesktopCompatible>true</DesktopCompatible> <SpectreMitigation>Spectre</SpectreMitigation> </PropertyGroup> <!-- Debug/Release props --> - <PropertyGroup Condition="'$(Configuration)'=='Debug'" - Label="Configuration"> + <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <UseDebugLibraries>true</UseDebugLibraries> <LinkIncremental>true</LinkIncremental> </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)'=='Release'" - Label="Configuration"> + <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <UseDebugLibraries>false</UseDebugLibraries> <WholeProgramOptimization>true</WholeProgramOptimization> <LinkIncremental>false</LinkIncremental> diff --git a/DATA_AND_PRIVACY.md b/DATA_AND_PRIVACY.md index 92711f00dd..81dc855e63 100644 --- a/DATA_AND_PRIVACY.md +++ b/DATA_AND_PRIVACY.md @@ -30,7 +30,11 @@ _If you want to find diagnostic data events in the source code, these two links - [C++ events](https://github.com/search?q=repo%3Amicrosoft%2FPowerToys+ProjectTelemetryPrivacyDataTag&type=code) ### General -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -43,6 +47,18 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.GeneralSettingsChanged</td> <td>Logs changes made to general settings within PowerToys.</td> </tr> + <tr> + <td>Microsoft.PowerToys.Install_Fail</td> + <td>Triggered when the PowerToys installation process encounters an error and fails to complete.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.Repair_Cancel</td> + <td>Triggered when a PowerToys repair operation is cancelled by the user before completion.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.Repair_Fail</td> + <td>Triggered when the PowerToys repair operation fails to complete successfully due to an error.</td> + </tr> <tr> <td>Microsoft.PowerToys.Runner_Launch</td> <td>Indicates when the PowerToys Runner is launched.</td> @@ -59,6 +75,18 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.ScoobeStartedEvent</td> <td>Triggered when SCOOBE (Secondary Out-of-box experience) starts.</td> </tr> + <tr> + <td>Microsoft.PowerToys.ShortcutConflictControlClickedEvent</td> + <td>Triggered when a user clicks on the Shortcut Conflict Control button in the PowerToys Settings UI Dashboard.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.ShortcutConflictDetectedEvent</td> + <td>Triggered when keyboard shortcut conflicts are detected in the PowerToys Settings UI Dashboard.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.ShortcutConflictResolvedEvent</td> + <td>Triggered when a keyboard shortcut conflict is resolved in the PowerToys Settings UI.</td> + </tr> <tr> <td>Microsoft.PowerToys.TrayFlyoutActivatedEvent</td> <td>Indicates when the tray flyout menu is activated.</td> @@ -67,18 +95,42 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.TrayFlyoutModuleRunEvent</td> <td>Logs when a utility from the tray flyout menu is run.</td> </tr> + <tr> + <td>Microsoft.PowerToys.UnInstall_Cancel</td> + <td>Triggered when the PowerToys uninstallation process is cancelled by the user before completion.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.UnInstall_Fail</td> + <td>Triggered when the PowerToys uninstallation process fails to complete successfully due to an error. </td> + </tr> <tr> <td>Microsoft.PowerToys.Uninstall_Success</td> <td>Logs when PowerToys is successfully uninstalled (who would do such a thing!).</td> </tr> + <tr> + <td>Microsoft.PowerToys.UpdateCheck_Completed</td> + <td>Logs when an auto-update check completes, including success status, whether an update is available, and version information.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.UpdateDownload_Completed</td> + <td>Logs when an update download completes, including success status and version.</td> + </tr> </table> ### OOBE (Out-of-box experience) -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> </tr> + <tr> + <td>Microsoft.PowerToys.OobeModuleRunEvent</td> + <td>Triggered when a user clicks to run or launch a PowerToys module directly from the OOBE (out-of-box experience) interface.</td> + </tr> <tr> <td>Microsoft.PowerToys.OobeSectionEvent</td> <td>Occurs when OOBE is shown to the user.</td> @@ -91,10 +143,18 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.OobeStartedEvent</td> <td>Indicates when the out-of-box experience has been initiated.</td> </tr> + <tr> + <td>Microsoft.PowerToys.OobeVariantAssignmentEvent</td> + <td>This event logs A/B testing assignments for experimental features, helping track which users are in control or alternate groups for feature experiments. </td> + </tr> </table> ### Advanced Paste -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -147,10 +207,26 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.AdvancedPasteSemanticKernelFormatEvent</td> <td>Triggered when Advanced Paste leverages the Semantic Kernel.</td> </tr> + <tr> + <td>Microsoft.PowerToys.AdvancedPasteSemanticKernelErrorEvent</td> + <td>Occurs when the Semantic Kernel workflow encounters an error.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.AdvancedPasteEndpointUsageEvent</td> + <td>Logs the AI provider, model, and processing duration for each endpoint call.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.AdvancedPasteCustomActionErrorEvent</td> + <td>Records provider, model, and status details when a custom action fails.</td> + </tr> </table> ### Always on Top -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -170,7 +246,11 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### Awake -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -198,7 +278,11 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### Color Picker -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -215,29 +299,25 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.ColorPicker_Settings</td> <td>Triggered when the settings for the Color Picker are accessed or modified.</td> </tr> - <tr> - <td>Microsoft.PowerToys.ColorPickerCancelledEvent</td> - <td>Occurs when a color picking action is cancelled by the user.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.ColorPickerShowEvent</td> - <td>Triggered when the Color Picker UI is displayed on the screen.</td> - </tr> </table> ### Command Not Found -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> </tr> <tr> - <td>Microsoft.PowerToys.CmdNotFoundInstallEvent</td> - <td>Triggered when a Command Not Found is installed.</td> + <td>Microsoft.PowerToys.CmdNotFound_EnableCmdNotFound</td> + <td>Triggered when Command Not Found is enabled or disabled.</td> </tr> <tr> - <td>Microsoft.PowerToys.CmdNotFoundInstanceCreatedEvent</td> - <td>Occurs when an instance of a Command Not Found is created.</td> + <td>Microsoft.PowerToys.CmdNotFoundInstallEvent</td> + <td>Triggered when a Command Not Found is installed.</td> </tr> <tr> <td>Microsoft.PowerToys.CmdNotFoundUninstallEvent</td> @@ -245,8 +325,81 @@ _If you want to find diagnostic data events in the source code, these two links </tr> </table> +### Command Palette + +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> + <tr> + <th>Event Name</th> + <th>Description</th> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPal_BeginInvoke</td> + <td>Triggered when the Command Palette is launched by the user.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPal_ColdLaunch</td> + <td>Occurs when Command Palette starts for the first time (cold start).</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPal_OpenPage</td> + <td>Triggered when a page is opened within the Command Palette, tracking navigation depth.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPal_OpenUri</td> + <td>Occurs when a URI is opened through the Command Palette, including whether it's a web URL.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPal_ReactivateInstance</td> + <td>Triggered when an existing Command Palette instance is reactivated.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPal_RunCommand</td> + <td>Logs when a command is executed through the Command Palette, including admin elevation status.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPal_RunQuery</td> + <td>Triggered when a search query is performed, including result count and duration.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPalDismissedOnEsc</td> + <td>Occurs when the Command Palette is dismissed by pressing the Escape key.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPalDismissedOnLostFocus</td> + <td>Triggered when the Command Palette is dismissed due to losing focus.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPalHotkeySummoned</td> + <td>Logs when the Command Palette is summoned via hotkey, distinguishing between global and context-specific hotkeys.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPalInvokeResult</td> + <td>Records the result type of a Command Palette invocation.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPalProcessStarted</td> + <td>Triggered when the Command Palette process is started.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPal_ExtensionInvoked</td> + <td>Tracks extension usage including extension ID, command details, success status, and execution time.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CmdPal_SessionDuration</td> + <td>Logs session metrics from launch to dismissal including duration, commands executed, pages visited, search queries, navigation depth, and errors.</td> + </tr> +</table> + ### Crop And Lock -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -255,10 +408,26 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.CropAndLock_ActivateReparent</td> <td>Triggered when the cropping interface is activated for reparenting the cropped content.</td> </tr> + <tr> + <td>Microsoft.PowerToys.CropAndLock_ActivateScreenshot</td> + <td>Triggered when the screenshot mode is activated in Crop and Lock.</td> + </tr> <tr> <td>Microsoft.PowerToys.CropAndLock_ActivateThumbnail</td> <td>Occurs when the thumbnail view for cropped content is activated.</td> </tr> + <tr> + <td>Microsoft.PowerToys.CropAndLock_CreateReparentWindow</td> + <td>Triggered when a reparent window is created in Crop and Lock mode.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CropAndLock_CreateScreenshotWindow</td> + <td>Triggered when a screenshot window is created in Crop and Lock mode.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.CropAndLock_CreateThumbnailWindow</td> + <td>Triggered when a thumbnail window is created in Crop and Lock mode.<-/td> + </tr> <tr> <td>Microsoft.PowerToys.CropAndLock_EnableCropAndLock</td> <td>Triggered when Crop and Lock is enabled.</td> @@ -269,8 +438,28 @@ _If you want to find diagnostic data events in the source code, these two links </tr> </table> +### Cursor Wrap +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> + <tr> + <th>Event Name</th> + <th>Description</th> + </tr> + <tr> + <td>Microsoft.PowerToys.CursorWrap_EnableCursorWrap</td> + <td>Triggered when Cursor Wrap is enabled or disabled.</td> + </tr> +</table> + ### Environment Variables -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -298,7 +487,11 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### FancyZones -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -315,6 +508,10 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.FancyZones_EnableFancyZones</td> <td>Occurs when FancyZones is enabled.</td> </tr> + <tr> + <td>Microsoft.PowerToys.FancyZones_Error</td> + <td>Triggered when an error occurs within the FancyZones module. This event logs critical errors to help diagnose and troubleshoot issues with FancyZones functionality, such as failures to set up Windows hooks or other system-level operations required for window management.</td> + </tr> <tr> <td>Microsoft.PowerToys.FancyZones_KeyboardSnapWindowToZone</td> <td>Triggered when a window is snapped to a zone using the keyboard.</td> @@ -327,10 +524,6 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.FancyZones_MoveOrResizeStarted</td> <td>Triggered when a window move or resize action is initiated.</td> </tr> - <tr> - <td>Microsoft.PowerToys.FancyZones_MoveSizeEnd</td> - <td>Occurs when the moving or resizing of a window has ended.</td> - </tr> <tr> <td>Microsoft.PowerToys.FancyZones_OnKeyDown</td> <td>Triggered when a key is pressed down while interacting with zones.</td> @@ -343,10 +536,6 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.FancyZones_Settings</td> <td>Triggered when FancyZones settings are accessed or modified.</td> </tr> - <tr> - <td>Microsoft.PowerToys.FancyZones_SettingsChanged</td> - <td>Occurs when there is a change in the FancyZones settings.</td> - </tr> <tr> <td>Microsoft.PowerToys.FancyZones_SnapNewWindowIntoZone</td> <td>Triggered when a new window is snapped into a zone.</td> @@ -363,82 +552,26 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.FancyZones_ZoneWindowKeyUp</td> <td>Occurs when a key is released while interacting with zones.</td> </tr> -</table> - -### FileExplorerAddOns -<table style="width:100%"> <tr> - <th>Event Name</th> - <th>Description</th> + <td>Microsoft.PowerToys.FancyZones_CLICommand</td> + <td>Triggered when a FancyZones CLI command is executed, logging the command name and success status.</td> </tr> <tr> - <td>Microsoft.PowerToys.GcodeFileHandlerLoaded</td> - <td>Triggered when a G-code file handler is loaded.</td> + <td>Microsoft.PowerToys.FancyZonesEditorStartEvent</td> + <td>Triggered when the FancyZones Editor application starts. This logs the initialization of the editor UI, which is used to create and configure custom zone layouts.</td> </tr> <tr> - <td>Microsoft.PowerToys.GcodeFilePreviewed</td> - <td>Occurs when a G-code file is previewed in File Explorer.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.GcodeFilePreviewError</td> - <td>Triggered when there is an error previewing a G-code file.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.MarkdownFileHandlerLoaded</td> - <td>Occurs when a Markdown file handler is loaded.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.MarkdownFilePreviewed</td> - <td>Triggered when a Markdown file is previewed in File Explorer.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.PdfFileHandlerLoaded</td> - <td>Occurs when a PDF file handler is loaded.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.PdfFilePreviewed</td> - <td>Triggered when a PDF file is previewed in File Explorer.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.PowerPreview_Enabled</td> - <td>Occurs when preview is enabled.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.PowerPreview_TweakUISettings_Destroyed</td> - <td>Triggered when the Tweak UI settings for Power Preview are destroyed.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.PowerPreview_TweakUISettings_FailedUpdatingSettings</td> - <td>Occurs when updating Tweak UI settings fails.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.PowerPreview_TweakUISettings_InitSet__ErrorLoadingFile</td> - <td>Triggered when there is an error loading a file during Tweak UI settings initialization.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.PowerPreview_TweakUISettings_SuccessfullyUpdatedSettings</td> - <td>Occurs when the Tweak UI settings for Power Preview are successfully updated.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.QoiFilePreviewed</td> - <td>Triggered when a QOI file is previewed in File Explorer.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.SvgFileHandlerLoaded</td> - <td>Occurs when an SVG file handler is loaded.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.SvgFilePreviewed</td> - <td>Triggered when an SVG file is previewed in File Explorer.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.SvgFilePreviewError</td> - <td>Occurs when there is an error previewing an SVG file.</td> + <td>Microsoft.PowerToys.FancyZonesEditorStartFinishEvent</td> + <td>Triggered when the FancyZones Editor has completed loading and is ready for user interaction.</td> </tr> </table> ### File Locksmith -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -461,8 +594,116 @@ _If you want to find diagnostic data events in the source code, these two links </tr> </table> +### FileExplorerAddOns +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> + <tr> + <th>Event Name</th> + <th>Description</th> + </tr> + <tr> + <td>Microsoft.PowerToys.GcodeFileHandlerLoaded</td> + <td>Triggered when a G-code file handler is loaded.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.GcodeFilePreviewed</td> + <td>Occurs when a G-code file is previewed in File Explorer.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.GcodeFilePreviewError</td> + <td>Triggered when there is an error previewing a G-code file.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.BgcodeFileHandlerLoaded</td> + <td>Triggered when a Binary G-code file handler is loaded.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.BgcodeFilePreviewed</td> + <td>Occurs when a Binary G-code file is previewed in File Explorer.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.BgcodeFilePreviewError</td> + <td>Triggered when there is an error previewing a Binary G-code file.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.MarkdownFileHandlerLoaded</td> + <td>Occurs when a Markdown file handler is loaded.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.MarkdownFilePreviewed</td> + <td>Triggered when a Markdown file is previewed in File Explorer.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.MarkdownFilePreviewError</td> + <td>Triggered when there is an error previewing a Markdown file in File Explorer.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.PdfFileHandlerLoaded</td> + <td>Occurs when a PDF file handler is loaded.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.PdfFilePreviewed</td> + <td>Triggered when a PDF file is previewed in File Explorer.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.PdfFilePreviewError</td> + <td>Triggered when there is an error previewing a PDF file in File Explorer.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.PowerPreview_Enabled</td> + <td>Occurs when preview is enabled.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.PowerPreview_TweakUISettings_Destroyed</td> + <td>Triggered when the Tweak UI settings for Power Preview are destroyed.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.PowerPreview_TweakUISettings_FailedUpdatingSettings</td> + <td>Occurs when updating Tweak UI settings fails.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.PowerPreview_TweakUISettings_InitSet__ErrorLoadingFile</td> + <td>Triggered when there is an error loading a file during Tweak UI settings initialization.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.PowerPreview_TweakUISettings_SetConfig__InvalidJSONGiven</td> + <td>Triggered when invalid JSON is provided to the Power Preview settings configuration</td> + </tr> + <tr> + <td>Microsoft.PowerToys.PowerPreview_TweakUISettings_SuccessfullyUpdatedSettings</td> + <td>Occurs when the Tweak UI settings for Power Preview are successfully updated.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.QoiFilePreviewed</td> + <td>Triggered when a QOI file is previewed in File Explorer.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.QoiFilePreviewError</td> + <td>Triggered when there is an error previewing a QOI (Quite OK Image) file in File Explorer.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.SvgFileHandlerLoaded</td> + <td>Occurs when an SVG file handler is loaded.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.SvgFilePreviewed</td> + <td>Triggered when an SVG file is previewed in File Explorer.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.SvgFilePreviewError</td> + <td>Occurs when there is an error previewing an SVG file.</td> + </tr> +</table> + ### Find My Mouse -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -473,12 +714,16 @@ _If you want to find diagnostic data events in the source code, these two links </tr> <tr> <td>Microsoft.PowerToys.FindMyMouse_MousePointerFocused</td> - <td>Occurs when the mouse pointer is focused using Find My Mouse.</td> + <td>Occurs when the mouse pointer is focused using Find My Mouse, including the activation method (double-tap left/right Ctrl, shake mouse, or shortcut).</td> </tr> </table> ### Hosts File Editor -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -495,10 +740,22 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.HostsFileEditorOpenedEvent</td> <td>Fires when Hosts File Editor is opened.</td> </tr> + <tr> + <td>Microsoft.PowerToys.HostEditorStartEvent</td> + <td>Triggered when the Hosts File Editor application starts. This logs the initialization of the Hosts File Editor UI with a timestamp.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.HostEditorStartFinishEvent</td> + <td>Triggered when the Hosts File Editor has completed loading and is ready for user interaction.</td> + </tr> </table> ### Image Resizer -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -515,10 +772,18 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.ImageResizer_InvokedRet</td> <td>Fires when the Image Resizer operation is completed and returns a result.</td> </tr> + <tr> + <td>Microsoft.PowerToys.ImageResizer_QueryContextMenuError</td> + <td>Triggered when there is an error querying the context menu for Image Resizer.</td> + </tr> </table> ### Keyboard Manager -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -531,10 +796,22 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.KeyboardManager_AppSpecificShortcutRemapCount</td> <td>Logs the number of application-specific shortcut remaps configured by the user.</td> </tr> + <tr> + <td>Microsoft.PowerToys.KeyboardManager_AppSpecificShortcutToKeyRemapInvoked</td> + <td>Logs each instance when an application-specific shortcut-to-key remap is used.</td> + </tr> <tr> <td>Microsoft.PowerToys.KeyboardManager_AppSpecificShortcutToShortcutRemapInvoked</td> <td>Logs each instance when an application-specific shortcut-to-shortcut remap is used.</td> </tr> + <tr> + <td>Microsoft.PowerToys.KeyboardManager_Error</td> + <td>Triggered when an error occurs in Keyboard Manager. This logs the error code, error message, and the method name where the error occurred.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.KeyboardManager_ErrorSendingKeyAndShortcutRemapLoadedConfiguration</td> + <td>Triggered when there is an error sending remapping configuration telemetry. This occurs when Keyboard Manager fails to report the loaded key and shortcut remap configurations</td> + </tr> <tr> <td>Microsoft.PowerToys.KeyboardManager_DailyAppSpecificShortcutToKeyRemapInvoked</td> <td>Logs the daily count of application-specific shortcut-to-key remaps executed by the user.</td> @@ -597,8 +874,40 @@ _If you want to find diagnostic data events in the source code, these two links </tr> </table> +### Light Switch +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> + <tr> + <th>Event Name</th> + <th>Description</th> + </tr> + <tr> + <td>Microsoft.PowerToys.LightSwitch_EnableLightSwitch</td> + <td>Triggered when Light Switch is enabled or disabled.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.LightSwitch_ShortcutInvoked</td> + <td>Occurs when the shortcut for Light Switch is invoked.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.LightSwitch_ScheduleModeToggled</td> + <td>Occurs when a new schedule mode is selected for Light Switch.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.LightSwitch_ThemeTargetChanged</td> + <td>Occurs when the options for targeting the system or apps is updated.</td> + </tr> +</table> + ### Mouse Highlighter -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -611,10 +920,18 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.MouseHighlighter_StartHighlightingSession</td> <td>Occurs when a new highlighting session is started.</td> </tr> + <tr> + <td>Microsoft.PowerToys.MouseHighlighter_StartSpotlightSession</td> + <td>Triggered when a spotlight session is started in Mouse Highlighter. This occurs when the user activates the spotlight mode.</td> + </tr> </table> ### Mouse Jump -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -638,7 +955,11 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### Mouse Pointer Crosshairs -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -654,7 +975,11 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### Mouse Without Borders -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -706,11 +1031,19 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### New+ -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> </tr> + <tr> + <td>Microsoft.PowerToys.NewPlus_ChangedTemplateLocation</td> + <td>Triggered when the template folder location is changed.</td> + </tr> <tr> <td>Microsoft.PowerToys.NewPlus_EventCopyTemplate</td> <td>Triggered when an item from New+ is created (copied to the current directory).</td> @@ -719,6 +1052,10 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.NewPlus_EventCopyTemplateResult</td> <td>Logs the success of item creation (copying).</td> </tr> + <tr> + <td>Microsoft.PowerToys.NewPlus_EventOpenTemplates</td> + <td>Triggered when the templates folder is opened.</td> + </tr> <tr> <td>Microsoft.PowerToys.NewPlus_EventShowTemplateItems</td> <td>Triggered when the New+ context menu flyout is displayed.</td> @@ -730,7 +1067,11 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### Peek -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -763,10 +1104,18 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.Peek_Settings</td> <td>Triggered when the settings for Peek are modified.</td> </tr> + <tr> + <td>Microsoft.PowerToys.Peek_SpaceModeEnabled</td> + <td>Triggered when the Space key activation mode is enabled or disabled in Peek</td> + </tr> </table> ### PowerRename -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -798,7 +1147,11 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### PowerToys Run -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -839,14 +1192,14 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.RunPluginsSettingsEvent</td> <td>Triggered when the settings for PowerToys Run plugins are accessed or modified.</td> </tr> - <tr> - <td>Microsoft.PowerToys.WindowWalker_EnableWindowWalker</td> - <td>Triggered when the Window Walker plugin is enabled.</td> - </tr> </table> ### Quick Accent -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -862,7 +1215,11 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### Registry Preview -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -875,10 +1232,22 @@ _If you want to find diagnostic data events in the source code, these two links <td>Microsoft.PowerToys.RegistryPreview_EnableRegistryPreview</td> <td>Occurs when Registry Preview is enabled.</td> </tr> + <tr> + <td>Microsoft.PowerToys.RegistryPreviewEditorStartEvent</td> + <td>Triggered when the Registry Preview application starts. This logs the initialization of the Registry Preview UI with a timestamp.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.RegistryPreviewEditorStartFinishEvent</td> + <td>Triggered when the Registry Preview application has completed loading and is ready for user interaction.</td> + </tr> </table> ### Screen Ruler -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -898,18 +1267,18 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### Shortcut Guide -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> </tr> <tr> - <td>Microsoft.PowerToys.ShortcutGuide_EnableGuide</td> - <td>Triggered when Shortcut Guide is enabled.</td> - </tr> - <tr> - <td>Microsoft.PowerToys.ShortcutGuide_HideGuide</td> - <td>Occurs when Shortcut Guide is hidden from view.</td> + <td>Microsoft.PowerToys.ShortcutGuide_GuideSession</td> + <td>Logs a Shortcut Guide session including duration and how it was closed.</td> </tr> <tr> <td>Microsoft.PowerToys.ShortcutGuide_Settings</td> @@ -918,7 +1287,11 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### Text Extractor -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> @@ -942,15 +1315,15 @@ _If you want to find diagnostic data events in the source code, these two links </table> ### Workspaces -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> </tr> - <tr> - <td>Microsoft.PowerToys.Projects_CLIUsage</td> - <td>Logs usage of command-line arguments for launching apps.</td> - </tr> <tr> <td>Microsoft.PowerToys.Workspaces_CreateEvent</td> <td>Triggered when a new workspace is created.</td> @@ -972,13 +1345,21 @@ _If you want to find diagnostic data events in the source code, these two links <td>Triggered when a workspace is launched.</td> </tr> <tr> - <td>Microsoft.PowerToys.Workspaces_Settings</td> - <td>Logs changes to workspaces settings.</td> + <td>Microsoft.PowerToys.WorkspacesEditorStartEvent</td> + <td>Triggered when the Workspaces Editor application starts. This logs the initialization of the Workspaces Editor UI with a timestamp.</td> + </tr> + <tr> + <td>Microsoft.PowerToys.WorkspacesEditorStartFinishEvent</td> + <td>Triggered when the Workspaces Editor has completed loading and is ready for user interaction.</td> </tr> </table> ### ZoomIt -<table style="width:100%"> +<table style="width:100%; table-layout:fixed"> + <colgroup> + <col style="width:40%"> + <col style="width:60%"> + </colgroup> <tr> <th>Event Name</th> <th>Description</th> diff --git a/Directory.Build.props b/Directory.Build.props index 860d1093ca..8b54cf6b28 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,8 @@ <Project> - <Import Project="src\Version.props" /> + <PropertyGroup> + <RepoRoot>$(MSBuildThisFileDirectory)</RepoRoot> + </PropertyGroup> + <Import Project="$(RepoRoot)src\Version.props" /> <PropertyGroup> <Copyright>Copyright (C) Microsoft Corporation. All rights reserved.</Copyright> <AssemblyCopyright>Copyright (C) Microsoft Corporation. All rights reserved.</AssemblyCopyright> @@ -27,6 +30,39 @@ <TestingPlatformDisableCustomTestTarget Condition="'$(Platform)' == 'ARM64'">true</TestingPlatformDisableCustomTestTarget> </PropertyGroup> + <!-- + Completely skip building test projects when BuildTests=false (e.g., Release pipeline). + This avoids InternalsVisibleTo/signing issues by not compiling test code at all. + Match: projects ending in Test, Tests, UnitTests, UITests, FuzzTests, or in a folder named Tests. + Also matches projects starting with UnitTests- (e.g., UnitTests-CommonLib). + Also removes all PackageReference/ProjectReference to prevent NuGet restore and dependency builds. + Note: Checking both 'false' and 'False' to handle YAML boolean serialization. + --> + <PropertyGroup Condition="'$(BuildTests)' == 'false' or '$(BuildTests)' == 'False'"> + <_ProjectName>$(MSBuildProjectName)</_ProjectName> + <!-- Match any project ending with "Test" or "Tests" (covers UnitTests, UITests, FuzzTests, etc.) --> + <_IsSkippedTestProject Condition="$(_ProjectName.EndsWith('Test'))">true</_IsSkippedTestProject> + <_IsSkippedTestProject Condition="$(_ProjectName.EndsWith('Tests'))">true</_IsSkippedTestProject> + <!-- Match projects starting with UnitTests- or UITest- prefix --> + <_IsSkippedTestProject Condition="$(_ProjectName.StartsWith('UnitTests-'))">true</_IsSkippedTestProject> + <_IsSkippedTestProject Condition="$(_ProjectName.StartsWith('UITest-'))">true</_IsSkippedTestProject> + <!-- Match projects in a Tests folder --> + <_IsSkippedTestProject Condition="$(MSBuildProjectDirectory.Contains('\Tests\'))">true</_IsSkippedTestProject> + </PropertyGroup> + + <PropertyGroup Condition="'$(_IsSkippedTestProject)' == 'true'"> + <EnableDefaultItems>false</EnableDefaultItems> + <EnableDefaultCompileItems>false</EnableDefaultCompileItems> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <GenerateGlobalUsings>false</GenerateGlobalUsings> + <ImplicitUsings>disable</ImplicitUsings> + <!-- Disable all code analysis for skipped test projects --> + <EnableNETAnalyzers>false</EnableNETAnalyzers> + <RunAnalyzers>false</RunAnalyzers> + <RunAnalyzersDuringBuild>false</RunAnalyzersDuringBuild> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + </PropertyGroup> + <PropertyGroup Condition="'$(MSBuildProjectExtension)' == '.csproj'"> <Version>$(Version).0</Version> <RepositoryUrl>https://github.com/microsoft/PowerToys</RepositoryUrl> @@ -36,16 +72,17 @@ <PropertyGroup> <_PropertySheetDisplayName>PowerToys.Root.Props</_PropertySheetDisplayName> - <ForceImportBeforeCppProps>$(MsbuildThisFileDirectory)\Cpp.Build.props</ForceImportBeforeCppProps> + <ForceImportBeforeCppProps>$(RepoRoot)Cpp.Build.props</ForceImportBeforeCppProps> </PropertyGroup> - <ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj'"> + + <ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj' and '$(_IsSkippedTestProject)' != 'true'"> <PackageReference Include="StyleCop.Analyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <Compile Include="$(MSBuildThisFileDirectory)\src\codeAnalysis\GlobalSuppressions.cs" Link="GlobalSuppressions.cs" /> - <AdditionalFiles Include="$(MSBuildThisFileDirectory)\src\codeAnalysis\StyleCop.json" Link="StyleCop.json" /> + <Compile Include="$(RepoRoot)src\codeAnalysis\GlobalSuppressions.cs" Link="GlobalSuppressions.cs" /> + <AdditionalFiles Include="$(RepoRoot)src\codeAnalysis\StyleCop.json" Link="StyleCop.json" /> <PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers"> <PrivateAssets>all</PrivateAssets> @@ -63,7 +100,7 @@ <!-- Add ability to run tests via "msbuild /t:Test" using the RunVSTest SDK --> <!-- This is only needed for C++, as we use Microsoft.Testing.Platform for C# --> <!-- - Workaround an MSBuild bug where Microsoft.Common.Test.targets is missing from the Arm64 installation. + Work around an MSBuild bug where Microsoft.Common.Test.targets is missing from the Arm64 installation. See: https://github.com/dotnet/msbuild/pull/9984 NB 1: This means that using "/t:Test" is not supported for Arm64 builds and tests will need to be run in an alternate way, eg running tests in VS or invoking vstest.console directly. diff --git a/Directory.Build.targets b/Directory.Build.targets index cba7762d5f..9efab5a9a5 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -3,4 +3,66 @@ <Import Project="$(MSBuildCachePackageRoot)\build\$(MSBuildCachePackageName).targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" /> <Import Project="$(MSBuildCacheSharedCompilationPackageRoot)\build\Microsoft.MSBuildCache.SharedCompilation.targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" /> -</Project> \ No newline at end of file + + <!-- Override ManifestTool to the x64 host tool under WindowsSdkDir for all projects once the SDK path is known. --> + <PropertyGroup Label="ManifestToolOverride"> + <ManifestTool Condition="Exists('$(WindowsSdkDir)bin\x64\mt.exe')">$(WindowsSdkDir)bin\x64\mt.exe</ManifestTool> + </PropertyGroup> + + <!-- Auto-restore NuGet for native vcxproj (PackageReference) when building inside VS --> + <Target Name="EnsureNuGetRestoreForVcxproj" BeforeTargets="PrepareForBuild" Condition=" + '$(BuildingInsideVisualStudio)' == 'true' + and '$(DesignTimeBuild)' != 'true' + and '$(RestoreInProgress)' != 'true' + and '$(MSBuildProjectExtension)' == '.vcxproj' + and '$(RestoreProjectStyle)' == 'PackageReference' + and '$(MSBuildProjectExtensionsPath)' != '' + and !Exists('$(MSBuildProjectExtensionsPath)project.assets.json') + "> + + <Message Importance="normal" Text="NuGet assets missing for $(MSBuildProjectName); running Restore...; IntDir=$(IntDir); BaseIntermediateOutputPath=$(BaseIntermediateOutputPath)" /> + + <MSBuild Projects="$(MSBuildProjectFullPath)" Targets="Restore" Properties="RestoreInProgress=true" BuildInParallel="false" /> + </Target> + + <PropertyGroup Condition="'$(IgnoreExperimentalWarnings)' == 'true'"> + <NoWarn>$(NoWarn);CS8305;SA1500;CA1852</NoWarn> + </PropertyGroup> + + <!-- Skipped test projects when BuildTests=false: no-op build and remove references. + This must be in targets (not props) so it runs AFTER the project file adds its items. --> + <PropertyGroup Condition="'$(_IsSkippedTestProject)' == 'true'"> + <BuildDependsOn /> + <CoreBuildDependsOn /> + <RebuildDependsOn /> + </PropertyGroup> + + <!-- For C# projects: remove all items --> + <ItemGroup Condition="'$(_IsSkippedTestProject)' == 'true' and '$(MSBuildProjectExtension)' == '.csproj'"> + <PackageReference Remove="@(PackageReference)" /> + <ProjectReference Remove="@(ProjectReference)" /> + <Reference Remove="@(Reference)" /> + <Compile Remove="@(Compile)" /> + <Content Remove="@(Content)" /> + <EmbeddedResource Remove="@(EmbeddedResource)" /> + <None Remove="@(None)" /> + <Using Remove="@(Using)" /> + <GlobalUsing Remove="@(GlobalUsing)" /> + </ItemGroup> + + <!-- For C++ projects (vcxproj): remove all compile/link items to prevent build --> + <ItemGroup Condition="'$(_IsSkippedTestProject)' == 'true' and '$(MSBuildProjectExtension)' == '.vcxproj'"> + <ClCompile Remove="@(ClCompile)" /> + <ClInclude Remove="@(ClInclude)" /> + <Link Remove="@(Link)" /> + <Lib Remove="@(Lib)" /> + <ProjectReference Remove="@(ProjectReference)" /> + <None Remove="@(None)" /> + <ResourceCompile Remove="@(ResourceCompile)" /> + <Midl Remove="@(Midl)" /> + </ItemGroup> + + <!-- Note: For C++ skipped test projects, build is effectively skipped by removing all compile items above. + We don't define empty Build/Rebuild/Clean targets here because MSBuild Target definitions with Condition + on the Target element still override the default targets even when condition is false. --> +</Project> diff --git a/Directory.Packages.props b/Directory.Packages.props index 7182d9aeb0..6e7975d8a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,62 +1,87 @@ <Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> + <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled> <MSTestVersion>3.8.3</MSTestVersion> </PropertyGroup> <ItemGroup> <PackageVersion Include="AdaptiveCards.ObjectModel.WinUI3" Version="2.0.0-beta" /> <PackageVersion Include="AdaptiveCards.Rendering.WinUI3" Version="2.1.0-beta" /> - <PackageVersion Include="AdaptiveCards.Templating" Version="2.0.2" /> + <PackageVersion Include="AdaptiveCards.Templating" Version="2.0.5" /> + <PackageVersion Include="boost" Version="1.87.0" TargetFramework="native" /> + <PackageVersion Include="boost_regex-vc143" Version="1.87.0" TargetFramework="native" /> + <PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.OpacityMaskView" Version="0.1.251101-build.2372" /> + <PackageVersion Include="Microsoft.Bot.AdaptiveExpressions.Core" Version="4.23.0" /> <PackageVersion Include="Appium.WebDriver" Version="4.4.5" /> - <PackageVersion Include="Azure.AI.OpenAI" Version="1.0.0-beta.17" /> + <PackageVersion Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" /> + <!-- Pin the SixLabors.ImageSharp version (a transitive dependency of CoenM.ImageSharp.ImageHash) to restore functionality and apply patches. --> + <PackageVersion Include="SixLabors.ImageSharp" Version="2.1.12" /> <PackageVersion Include="CommunityToolkit.Common" Version="8.4.0" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" /> - <PackageVersion Include="CommunityToolkit.WinUI.Animations" Version="8.2.250129-preview2" /> - <PackageVersion Include="CommunityToolkit.WinUI.Collections" Version="8.2.250129-preview2" /> - <PackageVersion Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250129-preview2" /> - <PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.250129-preview2" /> - <PackageVersion Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.250129-preview2" /> - <PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.250129-preview2" /> - <PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.250129-preview2" /> - <PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250129-preview2" /> + <PackageVersion Include="CommunityToolkit.WinUI.Animations" Version="8.2.250402" /> + <PackageVersion Include="CommunityToolkit.WinUI.Collections" Version="8.2.250402" /> + <PackageVersion Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.250402" /> + <PackageVersion Include="CommunityToolkit.WinUI.Controls.SettingsControls" Version="8.2.250402" /> + <PackageVersion Include="CommunityToolkit.WinUI.Controls.Segmented" Version="8.2.250402" /> + <PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.250402" /> + <PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.250402" /> + <PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" /> <PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" /> - <PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.Markdown" Version="7.1.2" /> + <PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260116-build.2514" /> <PackageVersion Include="ControlzEx" Version="6.0.0" /> <PackageVersion Include="HelixToolkit" Version="2.24.0" /> <PackageVersion Include="HelixToolkit.Core.Wpf" Version="2.24.0" /> + <PackageVersion Include="HtmlAgilityPack" Version="1.12.3" /> <PackageVersion Include="hyjiacan.pinyin4net" Version="4.1.1" /> <PackageVersion Include="Interop.Microsoft.Office.Interop.OneNote" Version="1.1.0.2" /> <PackageVersion Include="LazyCache" Version="2.4.0" /> <PackageVersion Include="Mages" Version="3.0.0" /> <PackageVersion Include="Markdig.Signed" Version="0.34.0" /> <!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. --> - <PackageVersion Include="MessagePack" Version="2.5.187" /> + <PackageVersion Include="MessagePack" Version="3.1.3" /> <PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.3" /> + <PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" /> <!-- Including Microsoft.Bcl.AsyncInterfaces to force version, since it's used by Microsoft.SemanticKernel. --> - <PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.3" /> + <PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.3.2" /> + <PackageVersion Include="Microsoft.Windows.CppWinRT" Version="2.0.250303.1" /> <PackageVersion Include="Microsoft.Diagnostics.Tracing.TraceEvent" Version="3.1.16" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.3" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.3" /> - <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.3" /> - <PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.3" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.3" /> - <PackageVersion Include="Microsoft.SemanticKernel" Version="1.15.0" /> + <PackageVersion Include="Microsoft.Extensions.AI" Version="9.9.1" /> + <PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.9.1-preview.1.25474.6" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Hosting" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.10" /> + <PackageVersion Include="Microsoft.AI.Foundry.Local" Version="0.3.0" /> + <PackageVersion Include="Microsoft.SemanticKernel" Version="1.66.0" /> + <PackageVersion Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.66.0" /> + <PackageVersion Include="Microsoft.SemanticKernel.Connectors.AzureAIInference" Version="1.66.0-beta" /> + <PackageVersion Include="Microsoft.SemanticKernel.Connectors.Google" Version="1.66.0-alpha" /> + <PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.66.0-alpha" /> + <PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" /> <PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" /> - <PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" /> + <PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3179.45" /> <!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. --> - <PackageVersion Include="Microsoft.Win32.SystemEvents" Version="9.0.3" /> - <PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.120-preview" /> - <PackageVersion Include="Microsoft.Windows.Compatibility" Version="9.0.3" /> - <PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.2.46-beta" /> + <PackageVersion Include="Microsoft.Win32.SystemEvents" Version="9.0.10" /> + <PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" /> + <PackageVersion Include="Microsoft.Windows.Compatibility" Version="9.0.10" /> + <PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" /> <!-- CsWinRT version needs to be set to have a WinRT.Runtime.dll at the same version contained inside the NET SDK we're currently building on CI. --> <!-- TODO: in Common.Dotnet.CsWinRT.props, on upgrade, verify RemoveCsWinRTPackageAnalyzer is no longer needed. This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail. --> <PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" /> - <PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.22621.2428" /> - <PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.6.250205002" /> + <PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.231216.1"/> + <PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" /> + <PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.251106002" /> + <PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="1.8.251104000" /> + <PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="1.8.39" /> + <PackageVersion Include="Microsoft.WindowsAppSDK.Runtime" Version="1.8.251106002" /> <PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="2.0.9" /> <PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" /> <PackageVersion Include="ModernWpfUI" Version="0.9.4" /> @@ -64,46 +89,69 @@ <PackageVersion Include="Moq" Version="4.18.4" /> <PackageVersion Include="MSTest" Version="$(MSTestVersion)" /> <PackageVersion Include="MSTest.TestFramework" Version="$(MSTestVersion)" /> - <PackageVersion Include="NLog" Version="5.0.4" /> + <PackageVersion Include="NJsonSchema" Version="11.4.0" /> + <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> + <PackageVersion Include="NLog" Version="5.2.8" /> <PackageVersion Include="NLog.Extensions.Logging" Version="5.3.8" /> <PackageVersion Include="NLog.Schema" Version="5.2.8" /> - <PackageVersion Include="OpenAI" Version="2.0.0" /> + <PackageVersion Include="OpenAI" Version="2.5.0" /> + <PackageVersion Include="Polly.Core" Version="8.6.5" /> <PackageVersion Include="ReverseMarkdown" Version="4.1.0" /> + <PackageVersion Include="RtfPipe" Version="2.0.7677.4303" /> <PackageVersion Include="ScipBe.Common.Office.OneNote" Version="3.0.1" /> <PackageVersion Include="SharpCompress" Version="0.37.2" /> - <PackageVersion Include="StreamJsonRpc" Version="2.19.27" /> + <PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" /> + <!-- Don't update SkiaSharp.Views.WinUI to version 3.* branch as this brakes the HexBox control in Registry Preview. --> + <PackageVersion Include="SkiaSharp.Views.WinUI" Version="2.88.9" /> + <PackageVersion Include="StreamJsonRpc" Version="2.21.69" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /> <!-- Package System.CodeDom added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Management but the 8.0.1 version wasn't published to nuget. --> - <PackageVersion Include="System.CodeDom" Version="9.0.3" /> + <PackageVersion Include="System.CodeDom" Version="9.0.10" /> + <PackageVersion Include="System.Collections.Immutable" Version="9.0.0" /> <PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" /> - <PackageVersion Include="System.ComponentModel.Composition" Version="9.0.3" /> - <PackageVersion Include="System.Configuration.ConfigurationManager" Version="9.0.3" /> - <PackageVersion Include="System.Data.OleDb" Version="9.0.3" /> + <PackageVersion Include="System.ComponentModel.Composition" Version="9.0.10" /> + <PackageVersion Include="System.Configuration.ConfigurationManager" Version="9.0.10" /> + <PackageVersion Include="System.Data.OleDb" Version="9.0.10" /> <!-- Package System.Data.SqlClient added to force it as a dependency of Microsoft.Windows.Compatibility to the latest version available at this time. --> - <PackageVersion Include="System.Data.SqlClient" Version="4.8.6" /> + <PackageVersion Include="System.Data.SqlClient" Version="4.9.0" /> <!-- Package System.Diagnostics.EventLog added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Data.OleDb but the 8.0.1 version wasn't published to nuget. --> - <PackageVersion Include="System.Diagnostics.EventLog" Version="9.0.3" /> + <PackageVersion Include="System.Diagnostics.EventLog" Version="9.0.10" /> <!-- Package System.Diagnostics.PerformanceCounter added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.11. --> - <PackageVersion Include="System.Diagnostics.PerformanceCounter" Version="9.0.3" /> - <PackageVersion Include="System.Drawing.Common" Version="9.0.3" /> - <PackageVersion Include="System.IO.Abstractions" Version="21.0.29" /> - <PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" /> - <PackageVersion Include="System.Management" Version="9.0.3" /> + <PackageVersion Include="System.Diagnostics.PerformanceCounter" Version="9.0.10" /> + <PackageVersion Include="System.ClientModel" Version="1.7.0" /> + <PackageVersion Include="System.Drawing.Common" Version="9.0.10" /> + <PackageVersion Include="System.IO.Abstractions" Version="22.0.13" /> + <PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" /> + <PackageVersion Include="System.Management" Version="9.0.10" /> + <PackageVersion Include="System.Net.Http" Version="4.3.4" /> + <PackageVersion Include="System.Numerics.Tensors" Version="9.0.11" /> + <PackageVersion Include="System.Private.Uri" Version="4.3.2" /> <PackageVersion Include="System.Reactive" Version="6.0.1" /> - <PackageVersion Include="System.Runtime.Caching" Version="9.0.3" /> - <PackageVersion Include="System.ServiceProcess.ServiceController" Version="9.0.3" /> - <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.3" /> - <PackageVersion Include="System.Text.Json" Version="9.0.3" /> + <PackageVersion Include="System.Runtime.Caching" Version="9.0.10" /> + <PackageVersion Include="System.ServiceProcess.ServiceController" Version="9.0.10" /> + <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" /> + <PackageVersion Include="System.Text.Json" Version="9.0.10" /> + <PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" /> + <PackageVersion Include="ToolGood.Words.Pinyin" Version="3.1.0.3" /> <PackageVersion Include="UnicodeInformation" Version="2.6.0" /> <PackageVersion Include="UnitsNet" Version="5.56.0" /> - <PackageVersion Include="UTF.Unknown" Version="2.5.1" /> - <PackageVersion Include="WinUIEx" Version="2.2.0" /> + <PackageVersion Include="UTF.Unknown" Version="2.6.0" /> + <PackageVersion Include="WinUIEx" Version="2.8.0" /> + <PackageVersion Include="WmiLight" Version="6.14.0" /> <PackageVersion Include="WPF-UI" Version="3.0.5" /> <PackageVersion Include="WyHash" Version="1.0.5" /> + <PackageVersion Include="WixToolset.Heat" Version="5.0.2" /> + <PackageVersion Include="WixToolset.Firewall.wixext" Version="5.0.2" /> + <PackageVersion Include="WixToolset.Util.wixext" Version="5.0.2" /> + <PackageVersion Include="WixToolset.UI.wixext" Version="5.0.2" /> + <PackageVersion Include="WixToolset.NetFx.wixext" Version="5.0.2" /> + <PackageVersion Include="WixToolset.Bal.wixext" Version="5.0.2" /> + <PackageVersion Include="WixToolset.BootstrapperApplicationApi" Version="5.0.2" /> + <PackageVersion Include="WixToolset.WixStandardBootstrapperApplicationFunctionApi" Version="5.0.2" /> </ItemGroup> <ItemGroup Condition="'$(IsExperimentationLive)'!=''"> <!-- Additional dependencies used by experimentation --> <PackageVersion Include="Microsoft.VariantAssignment.Client" Version="2.4.17140001" /> <PackageVersion Include="Microsoft.VariantAssignment.Contract" Version="3.0.16990001" /> </ItemGroup> -</Project> \ No newline at end of file +</Project> diff --git a/NOTICE.md b/NOTICE.md index a44ac4dd65..73a3532096 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 @@ -75,6 +76,108 @@ OTHER DEALINGS IN THE SOFTWARE. For more information, please refer to <http://unlicense.org/> ``` +### ToolGood.Words.Pinyin + +We use the ToolGood.Words.Pinyin NuGet package for converting Chinese characters to pinyin. + +**Source**: [https://github.com/toolgood/ToolGood.Words.Pinyin](https://github.com/toolgood/ToolGood.Words.Pinyin) + +``` +MIT License + +Copyright (c) 2020 ToolGood + +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. +``` + + +## Utility: Command Palette Built-in Extensions + +### Calculator + +#### exprtk + +We use the exprtk library (exprtk.hpp) to evaluate mathematical expressions. + +**Source**: [https://github.com/ArashPartow/exprtk](https://github.com/ArashPartow/exprtk) + +``` +MIT License + +Copyright (c) 1999-2024 Arash Partow + +https://www.partow.net/programming/exprtk/index.html + +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. +``` + +## Utility: PowerToys Run Built-in Extensions + +### Calculator + +#### Mages + +We use the Mages NuGet package for calculating the result of expression. + +**Source**: [https://github.com/FlorianRappl/Mages](https://github.com/FlorianRappl/Mages) + +``` +The MIT License (MIT) + +Copyright (c) 2016 - 2025 Florian Rappl + +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. +``` + ## Utility: File Explorer Add-ins ### Monaco Editor @@ -773,30 +876,25 @@ DEALINGS IN THE SOFTWARE. **Source**: https://github.com/kuba--/zip -This is free and unencumbered software released into the public domain. +All Rights Reserved. -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +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: -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +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 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. - -For more information, please refer to <http://unlicense.org/> +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. ## Utility: Measure tool @@ -1361,6 +1459,37 @@ EXHIBIT A -Mozilla Public License. ## Utility: Registry Preview +### HexBox.WinUI + +We use HexBox.WinUI to show a preview of binary values. + +**Source**: https://github.com/hotkidfamily/HexBox.WinUI + +``` +MIT License + +Copyright (c) 2019 Filip Jeremic +Copyright (c) 2024~2025 hotkidfamily@gmail.com + +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. +``` + ### Monaco Editor **Source**: https://github.com/Microsoft/monaco-editor @@ -1391,90 +1520,87 @@ 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 - -- AdaptiveCards.ObjectModel.WinUI3 2.0.0-beta -- AdaptiveCards.Rendering.WinUI3 2.1.0-beta -- AdaptiveCards.Templating 2.0.2 -- Appium.WebDriver 4.4.5 -- Azure.AI.OpenAI 1.0.0-beta.17 -- CommunityToolkit.Common 8.4.0 -- CommunityToolkit.Mvvm 8.4.0 -- CommunityToolkit.WinUI.Animations 8.2.250129-preview2 -- CommunityToolkit.WinUI.Collections 8.2.250129-preview2 -- CommunityToolkit.WinUI.Controls.Primitives 8.2.250129-preview2 -- CommunityToolkit.WinUI.Controls.Segmented 8.2.250129-preview2 -- CommunityToolkit.WinUI.Controls.SettingsControls 8.2.250129-preview2 -- CommunityToolkit.WinUI.Controls.Sizers 8.2.250129-preview2 -- CommunityToolkit.WinUI.Converters 8.2.250129-preview2 -- CommunityToolkit.WinUI.Extensions 8.2.250129-preview2 -- CommunityToolkit.WinUI.UI.Controls.DataGrid 7.1.2 -- CommunityToolkit.WinUI.UI.Controls.Markdown 7.1.2 -- ControlzEx 6.0.0 -- HelixToolkit 2.24.0 -- HelixToolkit.Core.Wpf 2.24.0 -- hyjiacan.pinyin4net 4.1.1 -- Interop.Microsoft.Office.Interop.OneNote 1.1.0.2 -- LazyCache 2.4.0 -- Mages 3.0.0 -- Markdig.Signed 0.34.0 -- MessagePack 2.5.187 -- Microsoft.Bcl.AsyncInterfaces 9.0.3 -- Microsoft.CodeAnalysis.NetAnalyzers 9.0.0 -- Microsoft.Data.Sqlite 9.0.3 -- Microsoft.Diagnostics.Tracing.TraceEvent 3.1.16 -- Microsoft.DotNet.ILCompiler (A) -- Microsoft.Extensions.DependencyInjection 9.0.3 -- Microsoft.Extensions.Hosting 9.0.3 -- Microsoft.Extensions.Hosting.WindowsServices 9.0.3 -- Microsoft.Extensions.Logging 9.0.3 -- Microsoft.Extensions.Logging.Abstractions 9.0.3 -- Microsoft.NET.ILLink.Tasks (A) -- Microsoft.SemanticKernel 1.15.0 -- Microsoft.Toolkit.Uwp.Notifications 7.1.2 -- Microsoft.Web.WebView2 1.0.2903.40 -- Microsoft.Win32.SystemEvents 9.0.3 -- Microsoft.Windows.Compatibility 9.0.3 -- Microsoft.Windows.CsWin32 0.2.46-beta -- Microsoft.Windows.CsWinRT 2.2.0 -- Microsoft.Windows.SDK.BuildTools 10.0.22621.2428 -- Microsoft.WindowsAppSDK 1.6.250205002 -- Microsoft.WindowsPackageManager.ComInterop 1.10.120-preview -- Microsoft.Xaml.Behaviors.WinUI.Managed 2.0.9 -- Microsoft.Xaml.Behaviors.Wpf 1.1.39 -- ModernWpfUI 0.9.4 -- Moq 4.18.4 -- MSTest 3.9.0-preview.25124.5 -- MSTest.TestFramework 3.9.0-preview.25124.5 -- NLog.Extensions.Logging 5.3.8 -- NLog.Schema 5.2.8 -- OpenAI 2.0.0 -- ReverseMarkdown 4.1.0 -- ScipBe.Common.Office.OneNote 3.0.1 -- SharpCompress 0.37.2 -- StreamJsonRpc 2.19.27 -- StyleCop.Analyzers 1.2.0-beta.556 -- System.CodeDom 9.0.3 -- System.CommandLine 2.0.0-beta4.22272.1 -- System.ComponentModel.Composition 9.0.3 -- System.Configuration.ConfigurationManager 9.0.3 -- System.Data.OleDb 9.0.3 -- System.Data.SqlClient 4.8.6 -- System.Diagnostics.EventLog 9.0.3 -- System.Diagnostics.PerformanceCounter 9.0.3 -- System.Drawing.Common 9.0.3 -- System.IO.Abstractions 21.0.29 -- System.IO.Abstractions.TestingHelpers 21.0.29 -- System.Management 9.0.3 -- System.Reactive 6.0.1 -- System.Runtime.Caching 9.0.3 -- System.ServiceProcess.ServiceController 9.0.3 -- System.Text.Encoding.CodePages 9.0.3 -- System.Text.Json 9.0.3 -- UnicodeInformation 2.6.0 -- UnitsNet 5.56.0 -- UTF.Unknown 2.5.1 -- WinUIEx 2.2.0 -- WPF-UI 3.0.5 -- WyHash 1.0.5 +- AdaptiveCards.ObjectModel.WinUI3 +- AdaptiveCards.Rendering.WinUI3 +- AdaptiveCards.Templating +- Appium.WebDriver +- CoenM.ImageSharp.ImageHash +- CommunityToolkit.Common +- CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock +- CommunityToolkit.Labs.WinUI.Controls.OpacityMaskView +- CommunityToolkit.Mvvm +- CommunityToolkit.WinUI.Animations +- CommunityToolkit.WinUI.Collections +- CommunityToolkit.WinUI.Controls.Primitives +- CommunityToolkit.WinUI.Controls.Segmented +- CommunityToolkit.WinUI.Controls.SettingsControls +- CommunityToolkit.WinUI.Controls.Sizers +- CommunityToolkit.WinUI.Converters +- CommunityToolkit.WinUI.Extensions +- CommunityToolkit.WinUI.UI.Controls.DataGrid +- ControlzEx +- HelixToolkit +- HelixToolkit.Core.Wpf +- hyjiacan.pinyin4net +- Interop.Microsoft.Office.Interop.OneNote +- LazyCache +- Mages +- Markdig.Signed +- MessagePack +- ModernWpfUI +- Moq +- MSTest +- MSTest.TestFramework +- NJsonSchema +- NLog +- NLog.Extensions.Logging +- NLog.Schema +- OpenAI +- Polly.Core +- ReverseMarkdown +- ScipBe.Common.Office.OneNote +- SharpCompress +- Shmuelie.WinRTServer +- SkiaSharp.Views.WinUI +- StreamJsonRpc +- StyleCop.Analyzers +- ToolGood.Words.Pinyin +- UnicodeInformation +- UnitsNet +- UTF.Unknown +- WinUIEx +- WmiLight +- WPF-UI +- WyHash diff --git a/PowerToys.sln b/PowerToys.sln deleted file mode 100644 index 9b911b388b..0000000000 --- a/PowerToys.sln +++ /dev/null @@ -1,2859 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.32014.148 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "runner", "src\runner\runner.vcxproj", "{9412D5C6-2CF2-4FC2-A601-B55508EA9B27}" - ProjectSection(ProjectDependencies) = postProject - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73} = {031AC72E-FA28-4AB7-B690-6F7B9C28AA73} - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B} = {0B43679E-EDFA-4DA0-AD30-F4628B308B1B} - {0B593A6C-4143-4337-860E-DB5710FB87DB} = {0B593A6C-4143-4337-860E-DB5710FB87DB} - {17DA04DF-E393-4397-9CF0-84DABE11032E} = {17DA04DF-E393-4397-9CF0-84DABE11032E} - {217DF501-135C-4E38-BFC8-99D4821032EA} = {217DF501-135C-4E38-BFC8-99D4821032EA} - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34} = {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34} - {48804216-2A0E-4168-A6D8-9CD068D14227} = {48804216-2A0E-4168-A6D8-9CD068D14227} - {51920F1F-C28C-4ADF-8660-4238766796C2} = {51920F1F-C28C-4ADF-8660-4238766796C2} - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481} = {5CCC8468-DEC8-4D36-99D4-5C891BEBD481} - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A} = {5E7360A8-D048-4ED3-8F09-0BFD64C5529A} - {655C9AF2-18D3-4DA6-80E4-85504A7722BA} = {655C9AF2-18D3-4DA6-80E4-85504A7722BA} - {69E1EE8D-143A-4060-9129-4658ACF14AAF} = {69E1EE8D-143A-4060-9129-4658ACF14AAF} - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB} = {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB} - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9} = {89F34AF7-1C34-4A72-AA6E-534BCF972BD9} - {AF2349B8-E5B6-4004-9502-687C1C7730B1} = {AF2349B8-E5B6-4004-9502-687C1C7730B1} - {B25AC7A5-FB9F-4789-B392-D5C85E948670} = {B25AC7A5-FB9F-4789-B392-D5C85E948670} - {BA58206B-1493-4C75-BFEA-A85768A1E156} = {BA58206B-1493-4C75-BFEA-A85768A1E156} - {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D} = {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D} - {D940E07F-532C-4FF3-883F-790DA014F19A} = {D940E07F-532C-4FF3-883F-790DA014F19A} - {DA425894-6E13-404F-8DCB-78584EC0557A} = {DA425894-6E13-404F-8DCB-78584EC0557A} - {E364F67B-BB12-4E91-B639-355866EBCD8B} = {E364F67B-BB12-4E91-B639-355866EBCD8B} - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} = {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{4574FDD0-F61D-4376-98BF-E5A1262C11EC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "interface", "interface", "{3BB8493E-D18E-4485-A320-CB40F90F55AE}" - ProjectSection(SolutionItems) = preProject - src\modules\interface\powertoy_module_interface.h = src\modules\interface\powertoy_module_interface.h - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "fancyzones", "fancyzones", "{D1D6BC88-09AE-4FB4-AD24-5DED46A791DD}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "FancyZonesLib", "src\modules\fancyzones\FancyZonesLib\FancyZonesLib.vcxproj", "{F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "UnitTests-FancyZones", "src\modules\fancyzones\FancyZonesTests\UnitTests\UnitTests.vcxproj", "{9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}" - ProjectSection(ProjectDependencies) = postProject - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} = {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{1AFB6476-670D-4E80-A464-657E01DFF482}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "UnitTests-CommonLib", "src\common\UnitTests-CommonLib\UnitTests-CommonLib.vcxproj", "{1A066C63-64B3-45F8-92FE-664E1CCE8077}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FancyZonesEditor", "src\modules\fancyzones\editor\FancyZonesEditor\FancyZonesEditor.csproj", "{5CCC8468-DEC8-4D36-99D4-5C891BEBD481}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "powerrename", "powerrename", "{89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerRenameExt", "src\modules\powerrename\dll\PowerRenameExt.vcxproj", "{B25AC7A5-FB9F-4789-B392-D5C85E948670}" - ProjectSection(ProjectDependencies) = postProject - {51920F1F-C28C-4ADF-8660-4238766796C2} = {51920F1F-C28C-4ADF-8660-4238766796C2} - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerRenameLib", "src\modules\powerrename\lib\PowerRenameLib.vcxproj", "{51920F1F-C28C-4ADF-8660-4238766796C2}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerRenameTest", "src\modules\powerrename\testapp\PowerRenameTest.vcxproj", "{A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}" - ProjectSection(ProjectDependencies) = postProject - {51920F1F-C28C-4ADF-8660-4238766796C2} = {51920F1F-C28C-4ADF-8660-4238766796C2} - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerRenameUnitTests", "src\modules\powerrename\unittests\PowerRenameLibUnitTests.vcxproj", "{2151F984-E006-4A9F-92EF-C6DDE3DC8413}" - ProjectSection(ProjectDependencies) = postProject - {51920F1F-C28C-4ADF-8660-4238766796C2} = {51920F1F-C28C-4ADF-8660-4238766796C2} - {B25AC7A5-FB9F-4789-B392-D5C85E948670} = {B25AC7A5-FB9F-4789-B392-D5C85E948670} - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ModuleTemplateCompileTest", "tools\project_template\ModuleTemplate\ModuleTemplateCompileTest.vcxproj", "{64A80062-4D8B-4229-8A38-DFA1D7497749}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManager", "src\modules\keyboardmanager\dll\KeyboardManager.vcxproj", "{89F34AF7-1C34-4A72-AA6E-534BCF972BD9}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "imageresizer", "imageresizer", "{6C7F47CC-2151-44A3-A546-41C70025132C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageResizerUI", "src\modules\imageresizer\ui\ImageResizerUI.csproj", "{2BE46397-4DFA-414C-9BD4-41E4BBF8CB34}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ImageResizerExt", "src\modules\imageresizer\dll\ImageResizerExt.vcxproj", "{0B43679E-EDFA-4DA0-AD30-F4628B308B1B}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageResizerUITest", "src\modules\imageresizer\tests\ImageResizerUITest.csproj", "{E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerToys.ActionRunner", "src\ActionRunner\ActionRunner.vcxproj", "{D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}" - ProjectSection(ProjectDependencies) = postProject - {17DA04DF-E393-4397-9CF0-84DABE11032E} = {17DA04DF-E393-4397-9CF0-84DABE11032E} - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ApplicationUpdate", "src\common\updating\updating.vcxproj", "{17DA04DF-E393-4397-9CF0-84DABE11032E}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "keyboardmanager", "keyboardmanager", "{38BDB927-829B-4C65-9CD9-93FB05D66D65}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManagerCommon", "src\modules\keyboardmanager\common\KeyboardManagerCommon.vcxproj", "{8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "launcher", "launcher", "{C140A3EF-6DBF-4084-9D4C-4EB5A99FEE68}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wox.Infrastructure", "src\modules\launcher\Wox.Infrastructure\Wox.Infrastructure.csproj", "{4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wox.Plugin", "src\modules\launcher\Wox.Plugin\Wox.Plugin.csproj", "{8451ECDD-2EA4-4966-BB0A-7BBC40138E80}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wox.Test", "src\modules\launcher\Wox.Test\Wox.Test.csproj", "{FF742965-9A80-41A5-B042-D6C7D3A21708}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{4AFC9975-2456-4C70-94A4-84073C1CED93}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.Calculator", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.Calculator\Microsoft.PowerToys.Run.Plugin.Calculator.csproj", "{59BD9891-3837-438A-958D-ADC7F91F6F7E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Community.PowerToys.Run.Plugin.VSCodeWorkspaces", "src\modules\launcher\Plugins\Community.PowerToys.Run.Plugin.VSCodeWorkspaces\Community.PowerToys.Run.Plugin.VSCodeWorkspaces.csproj", "{4D971245-7A70-41D5-BAA0-DDB5684CAF51}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.WindowWalker", "src\modules\launcher\Plugins\Microsoft.Plugin.WindowWalker\Microsoft.Plugin.WindowWalker.csproj", "{74F1B9ED-F59C-4FE7-B473-7B453E30837E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Program", "src\modules\launcher\Plugins\Microsoft.Plugin.Program\Microsoft.Plugin.Program.csproj", "{FDB3555B-58EF-4AE6-B5F1-904719637AB4}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Shell", "src\modules\launcher\Plugins\Microsoft.Plugin.Shell\Microsoft.Plugin.Shell.csproj", "{C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Indexer", "src\modules\launcher\Plugins\Microsoft.Plugin.Indexer\Microsoft.Plugin.Indexer.csproj", "{F8B870EB-D5F5-45BA-9CF7-A5C459818820}" - ProjectSection(ProjectDependencies) = postProject - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80} = {8451ECDD-2EA4-4966-BB0A-7BBC40138E80} - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Microsoft.Launcher", "src\modules\launcher\Microsoft.Launcher\Microsoft.Launcher.vcxproj", "{E364F67B-BB12-4E91-B639-355866EBCD8B}" - ProjectSection(ProjectDependencies) = postProject - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2} = {F97E5003-F263-4D4A-A964-0F1F3C82DEF2} - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerLauncher", "src\modules\launcher\PowerLauncher\PowerLauncher.csproj", "{F97E5003-F263-4D4A-A964-0F1F3C82DEF2}" - ProjectSection(ProjectDependencies) = postProject - {03276A39-D4E9-417C-8FFD-200B0EE5E871} = {03276A39-D4E9-417C-8FFD-200B0EE5E871} - {0351ADA4-0C32-4652-9BA0-41F7B602372B} = {0351ADA4-0C32-4652-9BA0-41F7B602372B} - {4BABF3FE-3451-42FD-873F-3C332E18DCEF} = {4BABF3FE-3451-42FD-873F-3C332E18DCEF} - {4D971245-7A70-41D5-BAA0-DDB5684CAF51} = {4D971245-7A70-41D5-BAA0-DDB5684CAF51} - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D} = {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D} - {5043CECE-E6A7-4867-9CBE-02D27D83747A} = {5043CECE-E6A7-4867-9CBE-02D27D83747A} - {59BD9891-3837-438A-958D-ADC7F91F6F7E} = {59BD9891-3837-438A-958D-ADC7F91F6F7E} - {5A1DB2F0-0715-4B3B-98E6-79BC41540045} = {5A1DB2F0-0715-4B3B-98E6-79BC41540045} - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF} = {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF} - {74F1B9ED-F59C-4FE7-B473-7B453E30837E} = {74F1B9ED-F59C-4FE7-B473-7B453E30837E} - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4} = {787B8AA6-CA93-4C84-96FE-DF31110AD1C4} - {9F94B303-5E21-4364-9362-64426F8DB932} = {9F94B303-5E21-4364-9362-64426F8DB932} - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85} = {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85} - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4} = {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4} - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0} = {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0} - {D095BE44-1F2E-463E-A494-121892A75EA2} = {D095BE44-1F2E-463E-A494-121892A75EA2} - {F8B870EB-D5F5-45BA-9CF7-A5C459818820} = {F8B870EB-D5F5-45BA-9CF7-A5C459818820} - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B} = {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B} - {FDB3555B-58EF-4AE6-B5F1-904719637AB4} = {FDB3555B-58EF-4AE6-B5F1-904719637AB4} - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "previewpane", "previewpane", "{2F305555-C296-497E-AC20-5FA1B237996A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PreviewHandlerCommon", "src\modules\previewpane\Common\PreviewHandlerCommon.csproj", "{AF2349B8-E5B6-4004-9502-687C1C7730B1}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MarkdownPreviewHandler", "src\modules\previewpane\MarkdownPreviewHandler\MarkdownPreviewHandler.csproj", "{6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-MarkdownPreviewHandler", "src\modules\previewpane\UnitTests-MarkdownPreviewHandler\UnitTests-MarkdownPreviewHandler.csproj", "{A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SvgPreviewHandler", "src\modules\previewpane\SvgPreviewHandler\SvgPreviewHandler.csproj", "{DA425894-6E13-404F-8DCB-78584EC0557A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-SvgPreviewHandler", "src\modules\previewpane\UnitTests-SvgPreviewHandler\UnitTests-SvgPreviewHandler.csproj", "{060D75DA-2D1C-48E6-A4A1-6F0718B64661}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-PreviewHandlerCommon", "src\modules\previewpane\UnitTests-PreviewHandlerCommon\UnitTests-PreviewHandlerCommon.csproj", "{748417CA-F17E-487F-9411-CAFB6D3F4877}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "powerpreview", "src\modules\previewpane\powerpreview\powerpreview.vcxproj", "{217DF501-135C-4E38-BFC8-99D4821032EA}" - ProjectSection(ProjectDependencies) = postProject - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF} = {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF} - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "settings-ui", "settings-ui", "{C3081D9A-1586-441A-B5F4-ED815B3719C1}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4981CCD1-4CD9-4A49-B240-00AA46493FF8}" - ProjectSection(SolutionItems) = preProject - src\.editorconfig = src\.editorconfig - .vsconfig = .vsconfig - src\Common.Dotnet.AotCompatibility.props = src\Common.Dotnet.AotCompatibility.props - src\Common.Dotnet.CsWinRT.props = src\Common.Dotnet.CsWinRT.props - src\Common.SelfContained.props = src\Common.SelfContained.props - Cpp.Build.props = Cpp.Build.props - Directory.Build.props = Directory.Build.props - Directory.Build.targets = Directory.Build.targets - Directory.Packages.props = Directory.Packages.props - src\Monaco.props = src\Monaco.props - src\Solution.props = src\Solution.props - src\Version.props = src\Version.props - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Settings.UI.Library", "src\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj", "{B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Folder", "src\modules\launcher\Plugins\Microsoft.Plugin.Folder\Microsoft.Plugin.Folder.csproj", "{787B8AA6-CA93-4C84-96FE-DF31110AD1C4}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerLauncher.Telemetry", "src\modules\launcher\PowerLauncher.Telemetry\PowerLauncher.Telemetry.csproj", "{08C8C05F-0362-41BC-818C-724572DF8B06}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ManagedTelemetry", "src\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj", "{5D00D290-4016-4CFE-9E41-1E7C724509BA}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ManagedCommon", "src\common\ManagedCommon\ManagedCommon.csproj", "{4AED67B6-55FD-486F-B917-E543DEE2CB3C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Program.UnitTests", "src\modules\launcher\Plugins\Microsoft.Plugin.Program.UnitTests\Microsoft.Plugin.Program.UnitTests.csproj", "{42851751-CBC8-45A6-97F5-7A0753F7B4D1}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-SvgThumbnailProvider", "src\modules\previewpane\UnitTests-SvgThumbnailProvider\UnitTests-SvgThumbnailProvider.csproj", "{1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SvgThumbnailProvider", "src\modules\previewpane\SvgThumbnailProvider\SvgThumbnailProvider.csproj", "{8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ColorPicker", "src\modules\colorPicker\ColorPicker\ColorPicker.vcxproj", "{655C9AF2-18D3-4DA6-80E4-85504A7722BA}" - ProjectSection(ProjectDependencies) = postProject - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD} = {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD} - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ColorPickerUI", "src\modules\colorPicker\ColorPickerUI\ColorPickerUI.csproj", "{BA58206B-1493-4C75-BFEA-A85768A1E156}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "colorpicker", "colorpicker", "{1D78B84B-CA39-406C-98F4-71F7EC266CC0}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Uri", "src\modules\launcher\Plugins\Microsoft.Plugin.Uri\Microsoft.Plugin.Uri.csproj", "{03276A39-D4E9-417C-8FFD-200B0EE5E871}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Uri.UnitTests", "src\modules\launcher\Plugins\Microsoft.Plugin.Uri.UnitTests\Microsoft.Plugin.Uri.UnitTests.csproj", "{B81FB7B6-D30E-428F-908A-41422EFC1172}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Settings.UI.UnitTests", "src\settings-ui\Settings.UI.UnitTests\Settings.UI.UnitTests.csproj", "{0F85E674-34AE-443D-954C-8321EB8B93B1}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest\Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest.csproj", "{632BBE62-5421-49EA-835A-7FFA4F499BD6}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.Folder.UnitTests", "src\modules\launcher\Plugins\Microsoft.Plugin.Folder.UnitTests\Microsoft.Plugin.Folder.UnitTests.csproj", "{4FA206A5-F69F-4193-BF8F-F6EEB496734C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTest-ColorPickerUI", "src\modules\colorPicker\UnitTest-ColorPickerUI\UnitTest-ColorPickerUI.csproj", "{090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "spdlog", "src\logging\logging.vcxproj", "{7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.System", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.System\Microsoft.PowerToys.Run.Plugin.System.csproj", "{FD8EB419-FF9C-4D88-BB6F-BF6CED37747B}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.System.UnitTests", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.System.UnitTests\Microsoft.PowerToys.Run.Plugin.System.UnitTests.csproj", "{DA5A6FE9-0040-40CC-83CC-764AE5306590}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.Service", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.Service\Microsoft.PowerToys.Run.Plugin.Service.csproj", "{0351ADA4-0C32-4652-9BA0-41F7B602372B}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "logger", "src\common\logger\logger.vcxproj", "{D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}" - ProjectSection(ProjectDependencies) = postProject - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F} = {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F} - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "SettingsAPI", "src\common\SettingsAPI\SettingsAPI.vcxproj", "{6955446D-23F7-4023-9BB3-8657F904AF99}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Interop.Tests", "src\common\interop\interop-tests\Microsoft.Interop.Tests.csproj", "{58736667-1027-4AD7-BFDF-7A3A6474103A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "notifications", "notifications", "{D92131D6-7610-4D60-A7DB-1C169783F83B}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Notifications", "src\common\notifications\notifications.vcxproj", "{1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "BackgroundActivatorDLL", "src\common\notifications\BackgroundActivatorDLL\BackgroundActivatorDLL.vcxproj", "{031AC72E-FA28-4AB7-B690-6F7B9C28AA73}" - ProjectSection(ProjectDependencies) = postProject - {0B593A6C-4143-4337-860E-DB5710FB87DB} = {0B593A6C-4143-4337-860E-DB5710FB87DB} - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "BackgroundActivator", "src\common\notifications\BackgroundActivator\BackgroundActivator.vcxproj", "{0B593A6C-4143-4337-860E-DB5710FB87DB}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Version", "src\common\version\version.vcxproj", "{CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "interop", "interop", "{5A7818A8-109C-4E1C-850D-1A654E234B0E}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "log", "log", "{E4E03FE0-94FD-47C7-88C5-F17D0AA549D3}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "COMUtils", "src\common\COMUtils\COMUtils.vcxproj", "{7319089E-46D6-4400-BC65-E39BDF1416EE}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Display", "src\common\Display\Display.vcxproj", "{CABA8DFB-823B-4BF2-93AC-3F31984150D9}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Themes", "src\common\Themes\Themes.vcxproj", "{98537082-0FDB-40DE-ABD8-0DC5A4269BAB}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "utils", "utils", "{B39DC643-4663-475E-B329-03F0C9918D48}" - ProjectSection(SolutionItems) = preProject - src\common\utils\appMutex.h = src\common\utils\appMutex.h - src\common\utils\color.h = src\common\utils\color.h - src\common\utils\com_object_factory.h = src\common\utils\com_object_factory.h - src\common\utils\elevation.h = src\common\utils\elevation.h - src\common\utils\EventLocker.h = src\common\utils\EventLocker.h - src\common\utils\EventWaiter.h = src\common\utils\EventWaiter.h - src\common\utils\excluded_apps.h = src\common\utils\excluded_apps.h - src\common\utils\exec.h = src\common\utils\exec.h - src\common\utils\game_mode.h = src\common\utils\game_mode.h - src\common\utils\gpo.h = src\common\utils\gpo.h - src\common\utils\HDropIterator.h = src\common\utils\HDropIterator.h - src\common\utils\HttpClient.h = src\common\utils\HttpClient.h - src\common\utils\json.h = src\common\utils\json.h - src\common\utils\language_helper.h = src\common\utils\language_helper.h - src\common\utils\logger_helper.h = src\common\utils\logger_helper.h - src\common\utils\modulesRegistry.h = src\common\utils\modulesRegistry.h - src\common\utils\MsiUtils.h = src\common\utils\MsiUtils.h - src\common\utils\MsWindowsSettings.h = src\common\utils\MsWindowsSettings.h - src\common\utils\OnThreadExecutor.h = src\common\utils\OnThreadExecutor.h - src\common\utils\os-detect.h = src\common\utils\os-detect.h - src\common\utils\package.h = src\common\utils\package.h - src\common\utils\ProcessWaiter.h = src\common\utils\ProcessWaiter.h - src\common\utils\process_path.h = src\common\utils\process_path.h - src\common\utils\registry.h = src\common\utils\registry.h - src\common\utils\resources.h = src\common\utils\resources.h - src\common\utils\serialized.h = src\common\utils\serialized.h - src\common\utils\string_utils.h = src\common\utils\string_utils.h - src\common\utils\timeutil.h = src\common\utils\timeutil.h - src\common\utils\UnhandledExceptionHandler.h = src\common\utils\UnhandledExceptionHandler.h - src\common\utils\winapi_error.h = src\common\utils\winapi_error.h - src\common\utils\window.h = src\common\utils\window.h - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Telemetry", "Telemetry", "{8F62026A-294B-41C6-8839-87463613F216}" - ProjectSection(SolutionItems) = preProject - src\common\Telemetry\ProjectTelemetry.h = src\common\Telemetry\ProjectTelemetry.h - src\common\Telemetry\TelemetryBase.cs = src\common\Telemetry\TelemetryBase.cs - src\common\Telemetry\TraceBase.h = src\common\Telemetry\TraceBase.h - src\common\Telemetry\TraceLoggingDefines.h = src\common\Telemetry\TraceLoggingDefines.h - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common.UI", "src\common\Common.UI\Common.UI.csproj", "{C3A17DCA-217B-462C-BB0C-BE086AF80081}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PdfPreviewHandler", "src\modules\previewpane\PdfPreviewHandler\PdfPreviewHandler.csproj", "{69E1EE8D-143A-4060-9129-4658ACF14AAF}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-PdfPreviewHandler", "src\modules\previewpane\UnitTests-PdfPreviewHandler\UnitTests-PdfPreviewHandler.csproj", "{ECC20689-002A-4354-95A6-B58DF089C6FF}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.Registry", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.Registry\Microsoft.PowerToys.Run.Plugin.Registry.csproj", "{4BABF3FE-3451-42FD-873F-3C332E18DCEF}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.Registry.UnitTests", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.Registry.UnitTest\Microsoft.PowerToys.Run.Plugin.Registry.UnitTests.csproj", "{0648DF05-5DDA-4BE1-B5F2-584926EBDB65}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManagerEngine", "src\modules\keyboardmanager\KeyboardManagerEngine\KeyboardManagerEngine.vcxproj", "{BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManagerEngineLibrary", "src\modules\keyboardmanager\KeyboardManagerEngineLibrary\KeyboardManagerEngineLibrary.vcxproj", "{E496B7FC-1E99-4BAB-849B-0E8367040B02}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManagerEngineTest", "src\modules\keyboardmanager\KeyboardManagerEngineTest\KeyboardManagerEngineTest.vcxproj", "{7F4B3A60-BC27-45A7-8000-68B0B6EA7466}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManagerEditor", "src\modules\keyboardmanager\KeyboardManagerEditor\KeyboardManagerEditor.vcxproj", "{8DF78B53-200E-451F-9328-01EB907193AE}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManagerEditorLibrary", "src\modules\keyboardmanager\KeyboardManagerEditorLibrary\KeyboardManagerEditorLibrary.vcxproj", "{23D2070D-E4AD-4ADD-85A7-083D9C76AD49}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManagerEditorTest", "src\modules\keyboardmanager\KeyboardManagerEditorTest\KeyboardManagerEditorTest.vcxproj", "{62173D9A-6724-4C00-A1C8-FB646480A9EC}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "awake", "awake", "{127F38E0-40AA-4594-B955-5616BF206882}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "AwakeModuleInterface", "src\modules\awake\AwakeModuleInterface\AwakeModuleInterface.vcxproj", "{5E7360A8-D048-4ED3-8F09-0BFD64C5529A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Awake", "src\modules\awake\Awake\Awake.csproj", "{D940E07F-532C-4FF3-883F-790DA014F19A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Community.PowerToys.Run.Plugin.UnitConverter", "src\modules\launcher\Plugins\Community.PowerToys.Run.Plugin.UnitConverter\Community.PowerToys.Run.Plugin.UnitConverter.csproj", "{BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Community.PowerToys.Run.Plugin.UnitConverter.UnitTest", "src\modules\launcher\Plugins\Community.PowerToys.Run.Plugin.UnitConverter.UnitTest\Community.PowerToys.Run.Plugin.UnitConverter.UnitTest.csproj", "{3E424AD2-19E5-4AE6-B833-F53963EB5FC1}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "shortcutguide", "shortcutguide", "{106CBECA-0701-4FC3-838C-9DF816A19AE2}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ShortcutGuideModuleInterface", "src\modules\ShortcutGuide\ShortcutGuideModuleInterface\ShortcutGuideModuleInterface.vcxproj", "{2D604C07-51FC-46BB-9EB7-75AECC7F5E81}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ShortcutGuide", "src\modules\ShortcutGuide\ShortcutGuide\ShortcutGuide.vcxproj", "{2EDB3EB4-FA92-4BFF-B2D8-566584837231}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "FancyZonesModuleInterface", "src\modules\fancyzones\FancyZonesModuleInterface\FancyZonesModuleInterface.vcxproj", "{48804216-2A0E-4168-A6D8-9CD068D14227}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "FancyZones", "src\modules\fancyzones\FancyZones\FancyZones.vcxproj", "{FF1D7936-842A-4BBB-8BEA-E9FE796DE700}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerToys.Update", "src\Update\PowerToys.Update.vcxproj", "{44CE9AE1-4390-42C5-BACC-0FD6B40AA203}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.WindowsSettings", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.WindowsSettings\Microsoft.PowerToys.Run.Plugin.WindowsSettings.csproj", "{5043CECE-E6A7-4867-9CBE-02D27D83747A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PdfThumbnailProvider", "src\modules\previewpane\PdfThumbnailProvider\PdfThumbnailProvider.csproj", "{11491FD8-F921-48BF-880C-7FEA185B80A1}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-PdfThumbnailProvider", "src\modules\previewpane\UnitTests-PdfThumbnailProvider\UnitTests-PdfThumbnailProvider.csproj", "{F40C3397-1834-4530-B2D9-8F8B8456BCDF}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.WindowsTerminal", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.WindowsTerminal\Microsoft.PowerToys.Run.Plugin.WindowsTerminal.csproj", "{A2D583F0-B70C-4462-B1F0-8E81AFB7BA85}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.WindowsTerminal.UnitTests", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.WindowsTerminal.UnitTests\Microsoft.Plugin.WindowsTerminal.UnitTests.csproj", "{4ED320BC-BA04-4D42-8D15-CBE62151F08B}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MouseUtils", "MouseUtils", "{322566EF-20DC-43A6-B9F8-616AF942579A}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "FindMyMouse", "src\modules\MouseUtils\FindMyMouse\FindMyMouse.vcxproj", "{E94FD11C-0591-456F-899F-EFC0CA548336}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MouseHighlighter", "src\modules\MouseUtils\MouseHighlighter\MouseHighlighter.vcxproj", "{782A61BE-9D85-4081-B35C-1CCC9DCC1E88}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GcodeThumbnailProvider", "src\modules\previewpane\GcodeThumbnailProvider\GcodeThumbnailProvider.csproj", "{809AA252-E17A-4FA2-B0A1-0450976B763F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-GcodeThumbnailProvider", "src\modules\previewpane\UnitTests-GcodeThumbnailProvider\UnitTests-GcodeThumbnailProvider.csproj", "{133281D8-1BCE-4D07-B31E-796612A9609E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GcodePreviewHandler", "src\modules\previewpane\GcodePreviewHandler\GcodePreviewHandler.csproj", "{805306FF-A562-4415-8DEF-E493BDC45918}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-GcodePreviewHandler", "src\modules\previewpane\UnitTests-GcodePreviewHandler\UnitTests-GcodePreviewHandler.csproj", "{FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AlwaysOnTop", "AlwaysOnTop", "{60CD2D4F-C3B9-4897-9821-FCA5098B41CE}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "AlwaysOnTop", "src\modules\alwaysontop\AlwaysOnTop\AlwaysOnTop.vcxproj", "{1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "AlwaysOnTopModuleInterface", "src\modules\alwaysontop\AlwaysOnTopModuleInterface\AlwaysOnTopModuleInterface.vcxproj", "{48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Community.PowerToys.Run.Plugin.WebSearch", "src\modules\launcher\Plugins\Community.PowerToys.Run.Plugin.WebSearch\Community.PowerToys.Run.Plugin.WebSearch.csproj", "{9F94B303-5E21-4364-9362-64426F8DB932}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MousePointerCrosshairs", "src\modules\MouseUtils\MousePointerCrosshairs\MousePointerCrosshairs.vcxproj", "{EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StlThumbnailProvider", "src\modules\previewpane\StlThumbnailProvider\StlThumbnailProvider.csproj", "{F7C8C0F1-5431-4347-89D0-8E5354F93CF2}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-StlThumbnailProvider", "src\modules\previewpane\UnitTests-StlThumbnailProvider\UnitTests-StlThumbnailProvider.csproj", "{F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MonacoPreviewHandler", "src\modules\previewpane\MonacoPreviewHandler\MonacoPreviewHandler.csproj", "{04B193D7-3E21-46B8-A958-89B63A8A69DE}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.TimeDate", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.TimeDate\Microsoft.PowerToys.Run.Plugin.TimeDate.csproj", "{5BDBD6C9-A31F-4CEB-871A-5E9E709197EF}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests\Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests.csproj", "{FD464B4C-2F68-4D06-91E7-4208146C41F5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Plugin.WindowWalker.UnitTests", "src\modules\launcher\Plugins\Microsoft.Plugin.WindowWalker.UnitTests\Microsoft.Plugin.WindowWalker.UnitTests.csproj", "{8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerToys.Settings", "src\settings-ui\Settings.UI\PowerToys.Settings.csproj", "{020A7474-3601-4160-A159-D7B70B77B15F}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerRenameUI", "src\modules\powerrename\PowerRenameUILib\PowerRenameUI.vcxproj", "{27718999-C175-450A-861C-89F911E16A88}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerRenameContextMenu", "src\modules\powerrename\PowerRenameContextMenu\PowerRenameContextMenu.vcxproj", "{1DBBB112-4BB1-444B-8EBB-E66555C76BA6}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.OneNote", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.OneNote\Microsoft.PowerToys.Run.Plugin.OneNote.csproj", "{5A1DB2F0-0715-4B3B-98E6-79BC41540045}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ImageResizerContextMenu", "src\modules\imageresizer\ImageResizerContextMenu\ImageResizerContextMenu.vcxproj", "{93B72A06-C8BD-484F-A6F7-C9F280B150BF}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ImageResizerLib", "src\modules\imageresizer\ImageResizerLib\ImageResizerLib.vcxproj", "{18B3DB45-4FFE-4D01-97D6-5223FEEE1853}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PowerAccent", "PowerAccent", "{0F14491C-6369-4C45-AAA8-135814E66E6B}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerAccentModuleInterface", "src\modules\poweraccent\PowerAccentModuleInterface\PowerAccentModuleInterface.vcxproj", "{34A354C5-23C7-4343-916C-C52DAF4FC39D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerAccent.Core", "src\modules\poweraccent\PowerAccent.Core\PowerAccent.Core.csproj", "{3264DF53-C805-4B0C-867C-FCEAF7AEF762}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerAccent.UI", "src\modules\poweraccent\PowerAccent.UI\PowerAccent.UI.csproj", "{31CAD28E-778A-441C-85BC-40AB3EAA2A10}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PowerOCR", "PowerOCR", "{A50C70A6-2DA0-4027-B90E-B1A40755A8A5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerOCR", "src\modules\PowerOCR\PowerOCR\PowerOCR.csproj", "{25C91A4E-BA4E-467A-85CD-8B62545BF674}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerOCRModuleInterface", "src\modules\PowerOCR\PowerOCRModuleInterface\PowerOCRModuleInterface.vcxproj", "{6AB6A2D6-F859-4A82-9184-0BD29C9F07D1}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.History", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.History\Microsoft.PowerToys.Run.Plugin.History.csproj", "{212AD910-8488-4036-BE20-326931B75FB2}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MeasureTool", "MeasureTool", "{7AC943C9-52E8-44CF-9083-744D8049667B}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerToys.MeasureToolCore", "src\modules\MeasureTool\MeasureToolCore\PowerToys.MeasureToolCore.vcxproj", "{54A93AF7-60C7-4F6C-99D2-FBB1F75F853A}" - ProjectSection(ProjectDependencies) = postProject - {6955446D-23F7-4023-9BB3-8657F904AF99} = {6955446D-23F7-4023-9BB3-8657F904AF99} - {CABA8DFB-823B-4BF2-93AC-3F31984150D9} = {CABA8DFB-823B-4BF2-93AC-3F31984150D9} - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF} = {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF} - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MeasureToolModuleInterface", "src\modules\MeasureTool\MeasureToolModuleInterface\MeasureToolModuleInterface.vcxproj", "{92C39820-9F84-4529-BC7D-22AAE514D63B}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MeasureToolUI", "src\modules\MeasureTool\MeasureToolUI\MeasureToolUI.csproj", "{515554D1-D004-4F7F-A107-2211FC0F6B2C}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerAccentKeyboardService", "src\modules\poweraccent\PowerAccentKeyboardService\PowerAccentKeyboardService.vcxproj", "{C97D9A5D-206C-454E-997E-009E227D7F02}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostsUILib", "src\modules\Hosts\HostsUILib\HostsUILib.csproj", "{31D1C81D-765F-4446-AA62-E743F6325049}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hosts", "Hosts", "{F05E590D-AD46-42BE-9C25-6A63ADD2E3EA}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hosts.Tests", "src\modules\Hosts\Hosts.Tests\Hosts.Tests.csproj", "{E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "HostsModuleInterface", "src\modules\Hosts\HostsModuleInterface\HostsModuleInterface.vcxproj", "{B41B888C-7DB8-4747-B262-4062E05A230D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "FileLocksmith", "FileLocksmith", "{AB82E5DD-C32D-4F28-9746-2C780846188E}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "FileLocksmithExt", "src\modules\FileLocksmith\FileLocksmithExt\FileLocksmithExt.vcxproj", "{57175EC7-92A5-4C1E-8244-E3FBCA2A81DE}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLocksmithUI", "src\modules\FileLocksmith\FileLocksmithUI\FileLocksmithUI.csproj", "{E69B044A-2F8A-45AA-AD0B-256C59421807}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerToys.FileLocksmithLib.Interop", "src\modules\FileLocksmith\FileLocksmithLibInterop\FileLocksmithLibInterop.vcxproj", "{C604B37E-9D0E-4484-8778-E8B31B0E1B3A}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "GPOWrapper", "src\common\GPOWrapper\GPOWrapper.vcxproj", "{E599C30B-9DC8-4E5A-BF27-93D4CCEDE788}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GPOWrapperProjection", "src\common\GPOWrapperProjection\GPOWrapperProjection.csproj", "{00EE9BA6-4E8F-43CA-960D-D4882F0FBB97}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Peek", "Peek", "{17B4FA70-001E-4D33-BBBB-0D142DBC2E20}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Peek", "src\modules\peek\peek\peek.vcxproj", "{A1425B53-3D61-4679-8623-E64A0D3D0A48}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Peek.UI", "src\modules\peek\Peek.UI\Peek.UI.csproj", "{9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Peek.Common", "src\modules\peek\Peek.Common\Peek.Common.csproj", "{17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Peek.FilePreviewer", "src\modules\peek\Peek.FilePreviewer\Peek.FilePreviewer.csproj", "{AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MarkdownPreviewHandlerCpp", "src\modules\previewpane\MarkdownPreviewHandlerCpp\MarkdownPreviewHandlerCpp.vcxproj", "{ED9A1AC6-AEB0-4569-A6E9-E1696182B545}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "GcodePreviewHandlerCpp", "src\modules\previewpane\GcodePreviewHandlerCpp\GcodePreviewHandlerCpp.vcxproj", "{5A5DD09D-723A-44D3-8F2B-293584C3D731}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MonacoPreviewHandlerCpp", "src\modules\previewpane\MonacoPreviewHandlerCpp\MonacoPreviewHandlerCpp.vcxproj", "{B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PdfPreviewHandlerCpp", "src\modules\previewpane\PdfPreviewHandlerCpp\PdfPreviewHandlerCpp.vcxproj", "{54F7C616-FD41-4E62-BFF9-015686914F4D}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "SvgPreviewHandlerCpp", "src\modules\previewpane\SvgPreviewHandlerCpp\SvgPreviewHandlerCpp.vcxproj", "{143F13E3-D2E3-4D83-B035-356612D99956}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "GcodeThumbnailProviderCpp", "src\modules\previewpane\GcodeThumbnailProviderCpp\GcodeThumbnailProviderCpp.vcxproj", "{56CC2F10-6E41-453D-BE16-C593A5E58482}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PdfThumbnailProviderCpp", "src\modules\previewpane\PdfThumbnailProviderCpp\PdfThumbnailProviderCpp.vcxproj", "{CA5518ED-0458-4B09-8F53-4122B9888655}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "StlThumbnailProviderCpp", "src\modules\previewpane\StlThumbnailProviderCpp\StlThumbnailProviderCpp.vcxproj", "{D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "SvgThumbnailProviderCpp", "src\modules\previewpane\SvgThumbnailProviderCpp\SvgThumbnailProviderCpp.vcxproj", "{2BBC9E33-21EC-401C-84DA-BB6590A9B2AA}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MouseWithoutBorders", "MouseWithoutBorders", "{B6C42F16-73EB-477E-8B0D-4E6CF6C20AAC}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MouseWithoutBordersModuleInterface", "src\modules\MouseWithoutBorders\ModuleInterface\MouseWithoutBordersModuleInterface.vcxproj", "{2833C9C6-AB32-4048-A5C7-A70898337B57}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MouseWithoutBorders", "src\modules\MouseWithoutBorders\App\MouseWithoutBorders.csproj", "{50B82783-242F-42D2-BC03-B3430BF01354}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MouseWithoutBordersService", "src\modules\MouseWithoutBorders\App\Service\MouseWithoutBordersService.csproj", "{B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MouseWithoutBordersHelper", "src\modules\MouseWithoutBorders\App\Helper\MouseWithoutBordersHelper.csproj", "{A663E672-B26D-4EC0-BEAB-FE2E424AC46F}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "MouseJump", "src\modules\MouseUtils\MouseJump\MouseJump.vcxproj", "{8A08D663-4995-40E3-B42C-3F910625F284}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MouseJump.Common", "src\modules\MouseUtils\MouseJump.Common\MouseJump.Common.csproj", "{923DF87C-CA99-4D1C-B1D2-959174E95BFA}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MouseJump.Common.UnitTests", "src\modules\MouseUtils\MouseJump.Common.UnitTests\MouseJump.Common.UnitTests.csproj", "{D5E42C63-57C5-4EF6-AECE-1E2FCA725B77}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MouseJumpUI", "src\modules\MouseUtils\MouseJumpUI\MouseJumpUI.csproj", "{D962A009-834F-4EEC-AABB-430DF8F98E39}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AdvancedPaste", "AdvancedPaste", "{9873BA05-4C41-4819-9283-CF45D795431B}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "AdvancedPasteModuleInterface", "src\modules\AdvancedPaste\AdvancedPasteModuleInterface\AdvancedPasteModuleInterface.vcxproj", "{FC373B24-3293-453C-AAF5-CF2909DCEE6A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AllExperiments", "src\common\AllExperiments\AllExperiments.csproj", "{9CE59ED5-7087-4353-88EB-788038A73CEC}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RegistryPreviewUILib", "src\modules\registrypreview\RegistryPreviewUILib\RegistryPreviewUILib.csproj", "{FD86C06A-FB54-4D5E-9831-1CDADF60D45F}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "RegistryPreviewExt", "src\modules\registrypreview\RegistryPreviewExt\RegistryPreviewExt.vcxproj", "{697C6AF9-0A48-49A9-866C-67DA12384015}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RegistryPreview", "RegistryPreview", "{929C1324-22E8-4412-A9A8-80E85F3985A5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FilePreviewCommon", "src\common\FilePreviewCommon\FilePreviewCommon.csproj", "{9EBAA524-0EDA-470B-95D4-39383285CBB2}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.PowerToys.Run.Plugin.PowerToys", "src\modules\launcher\Plugins\Microsoft.PowerToys.Run.Plugin.PowerToys\Microsoft.PowerToys.Run.Plugin.PowerToys.csproj", "{500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Community.PowerToys.Run.Plugin.ValueGenerator", "src\modules\launcher\Plugins\Community.PowerToys.Run.Plugin.ValueGenerator\Community.PowerToys.Run.Plugin.ValueGenerator.csproj", "{D095BE44-1F2E-463E-A494-121892A75EA2}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests", "src\modules\launcher\Plugins\Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests\Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests.csproj", "{90F9FA90-2C20-4004-96E6-F3B78151F5A5}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CropAndLock", "CropAndLock", "{3B227528-4BA6-4CAF-B44A-A10C78A64849}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CropAndLock", "src\modules\CropAndLock\CropAndLock\CropAndLock.vcxproj", "{F5E1146E-B7B3-4E11-85FD-270A500BD78C}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CropAndLockModuleInterface", "src\modules\CropAndLock\CropAndLockModuleInterface\CropAndLockModuleInterface.vcxproj", "{3157FA75-86CF-4EE2-8F62-C43F776493C6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cmdNotFound", "cmdNotFound", "{4C0D0746-BE5B-49EE-BD5D-A7811628AE8B}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-FancyZonesEditor", "src\modules\fancyzones\UnitTests-FancyZonesEditor\UnitTests-FancyZonesEditor.csproj", "{FC8EB78F-F061-4BD9-A3F6-507BEA965E2B}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvironmentVariables", "EnvironmentVariables", "{538ED0BB-B863-4B20-98CC-BCDF7FA0B68A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvironmentVariablesUILib", "src\modules\EnvironmentVariables\EnvironmentVariablesUILib\EnvironmentVariablesUILib.csproj", "{51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EnvironmentVariablesModuleInterface", "src\modules\EnvironmentVariables\EnvironmentVariablesModuleInterface\EnvironmentVariablesModuleInterface.vcxproj", "{B9420661-B0E4-4241-ABD4-4A27A1F64250}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "QoiThumbnailProviderCpp", "src\modules\previewpane\QoiThumbnailProviderCpp\QoiThumbnailProviderCpp.vcxproj", "{CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QoiThumbnailProvider", "src\modules\previewpane\QoiThumbnailProvider\QoiThumbnailProvider.csproj", "{D949EC7D-48A9-4279-95D5-078E7FD1F048}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "QoiPreviewHandlerCpp", "src\modules\previewpane\QoiPreviewHandlerCpp\QoiPreviewHandlerCpp.vcxproj", "{3BAF9C81-A194-4925-A035-5E24A5D1E542}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QoiPreviewHandler", "src\modules\previewpane\QoiPreviewHandler\QoiPreviewHandler.csproj", "{6B04803D-B418-4833-A67E-B0FC966636A5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-QoiPreviewHandler", "src\modules\previewpane\UnitTests-QoiPreviewHandler\UnitTests-QoiPreviewHandler.csproj", "{3940AD4D-F748-4BE4-9083-85769CD553EF}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-QoiThumbnailProvider", "src\modules\previewpane\UnitTests-QoiThumbnailProvider\UnitTests-QoiThumbnailProvider.csproj", "{F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CmdNotFoundModuleInterface", "src\modules\cmdNotFound\CmdNotFoundModuleInterface\CmdNotFoundModuleInterface.vcxproj", "{0014D652-901F-4456-8D65-06FC5F997FB0}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "FileLocksmithContextMenu", "src\modules\FileLocksmith\FileLocksmithContextMenu\FileLocksmithContextMenu.vcxproj", "{799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "FileLocksmithLib", "src\modules\FileLocksmith\FileLocksmithLib\FileLocksmithLib.vcxproj", "{9D52FD25-EF90-4F9A-A015-91EFC5DAF54F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdvancedPaste", "src\modules\AdvancedPaste\AdvancedPaste\AdvancedPaste.csproj", "{C32D254F-7597-4CBE-BF74-D922D81CDF29}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hosts", "src\modules\Hosts\Hosts\Hosts.csproj", "{02DD46D3-F761-47D9-8894-2D6DA0124650}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RegistryPreview", "src\modules\registrypreview\RegistryPreview\RegistryPreview.csproj", "{8E23E173-7127-4A5F-9F93-3049F2B68047}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvironmentVariables", "src\modules\EnvironmentVariables\EnvironmentVariables\EnvironmentVariables.csproj", "{DFF88D16-D36F-40A4-A955-CDCAA76EF7B8}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UITests-FancyZones", "src\modules\fancyzones\UITests-FancyZones\UITests-FancyZones.csproj", "{FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UITests-FancyZonesEditor", "src\modules\fancyzones\UITests-FancyZonesEditor\UITests-FancyZonesEditor.csproj", "{3A9A791E-94A9-49F8-8401-C11CE288D5FB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FancyZonesEditorCommon", "src\modules\fancyzones\FancyZonesEditorCommon\FancyZonesEditorCommon.csproj", "{C0974915-8A1D-4BF0-977B-9587D3807AB7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DSC", "DSC", "{557C4636-D7E1-4838-A504-7D19B725EE95}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerToys.Settings.DSC.Schema.Generator", "src\dsc\PowerToys.Settings.DSC.Schema.Generator\PowerToys.Settings.DSC.Schema.Generator.csproj", "{1D6893CB-BC0C-46A8-A76C-9728706CA51A}" - ProjectSection(ProjectDependencies) = postProject - {020A7474-3601-4160-A159-D7B70B77B15F} = {020A7474-3601-4160-A159-D7B70B77B15F} - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "NewPlus.ShellExtension", "src\modules\NewPlus\NewShellExtensionContextMenu\NewShellExtensionContextMenu.vcxproj", "{8ACB33D9-C95B-47D4-8363-9731EE0930A0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "New+", "New+", "{CA716AE6-FE5C-40AC-BB8F-2C87912687AC}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerToys.Interop", "src\common\interop\PowerToys.Interop.vcxproj", "{F055103B-F80B-4D0C-BF48-057C55620033}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Workspaces", "Workspaces", "{A2221D7E-55E7-4BEA-90D1-4F162D670BBF}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workspaces-common", "workspaces-common", "{BE126CBB-AE12-406A-9837-A05ACFCA57A7}" - ProjectSection(SolutionItems) = preProject - src\modules\Workspaces\workspaces-common\GuidUtils.h = src\modules\Workspaces\workspaces-common\GuidUtils.h - src\modules\Workspaces\workspaces-common\InvokePoint.h = src\modules\Workspaces\workspaces-common\InvokePoint.h - src\modules\Workspaces\workspaces-common\MonitorEnumerator.h = src\modules\Workspaces\workspaces-common\MonitorEnumerator.h - src\modules\Workspaces\workspaces-common\MonitorUtils.h = src\modules\Workspaces\workspaces-common\MonitorUtils.h - src\modules\Workspaces\workspaces-common\VirtualDesktop.h = src\modules\Workspaces\workspaces-common\VirtualDesktop.h - src\modules\Workspaces\workspaces-common\WindowEnumerator.h = src\modules\Workspaces\workspaces-common\WindowEnumerator.h - src\modules\Workspaces\workspaces-common\WindowFilter.h = src\modules\Workspaces\workspaces-common\WindowFilter.h - src\modules\Workspaces\workspaces-common\WindowUtils.h = src\modules\Workspaces\workspaces-common\WindowUtils.h - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WindowProperties", "WindowProperties", "{14CB58B7-D280-4A7A-95DE-4B2DF14EA000}" - ProjectSection(SolutionItems) = preProject - src\modules\Workspaces\WindowProperties\WorkspacesWindowPropertyUtils.h = src\modules\Workspaces\WindowProperties\WorkspacesWindowPropertyUtils.h - EndProjectSection -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "WorkspacesLib", "src\modules\Workspaces\WorkspacesLib\WorkspacesLib.vcxproj", "{B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkspacesLauncherUI", "src\modules\Workspaces\WorkspacesLauncherUI\WorkspacesLauncherUI.csproj", "{9C53CC25-0623-4569-95BC-B05410675EE3}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "WorkspacesModuleInterface", "src\modules\Workspaces\WorkspacesModuleInterface\WorkspacesModuleInterface.vcxproj", "{45285DF2-9742-4ECA-9AC9-58951FC26489}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "WorkspacesSnapshotTool", "src\modules\Workspaces\WorkspacesSnapshotTool\WorkspacesSnapshotTool.vcxproj", "{3D63307B-9D27-44FD-B033-B26F39245B85}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkspacesEditor", "src\modules\Workspaces\WorkspacesEditor\WorkspacesEditor.csproj", "{367D7543-7DBA-4381-99F1-BF6142A996C4}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "WorkspacesLauncher", "src\modules\Workspaces\WorkspacesLauncher\WorkspacesLauncher.vcxproj", "{2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "WorkspacesWindowArranger", "src\modules\Workspaces\WorkspacesWindowArranger\WorkspacesWindowArranger.vcxproj", "{37D07516-4185-43A4-924F-3C7A5D95ECF6}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EtwTrace", "src\common\Telemetry\EtwTrace\EtwTrace.vcxproj", "{8F021B46-362B-485C-BFBA-CCF83E820CBD}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MouseWithoutBorders.UnitTests", "src\modules\MouseWithoutBorders\MouseWithoutBorders.UnitTests\MouseWithoutBorders.UnitTests.csproj", "{66614C26-314C-4B91-9071-76133422CFEF}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CommandPalette", "CommandPalette", "{3846508C-77EB-4034-A702-F8BB263C4F79}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Built-in Extensions", "Built-in Extensions", "{ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.Apps", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.Apps\Microsoft.CmdPal.Ext.Apps.csproj", "{6CE438DF-C245-4997-A360-0A0939E4BA34}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.Bookmarks", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.Bookmark\Microsoft.CmdPal.Ext.Bookmarks.csproj", "{E09AA983-C755-474F-83D6-A5CDF528C070}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.Calc", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.Calc\Microsoft.CmdPal.Ext.Calc.csproj", "{6D56B64D-FF1F-488F-AFED-9B9854A5D399}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.Registry", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.Registry\Microsoft.CmdPal.Ext.Registry.csproj", "{92EC89E4-9972-453A-8A1A-3A9E230C146A}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.WindowsServices", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.WindowsServices\Microsoft.CmdPal.Ext.WindowsServices.csproj", "{51939B4F-1F62-4BFF-A6A2-C08646E5BE95}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.WindowsSettings", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.WindowsSettings\Microsoft.CmdPal.Ext.WindowsSettings.csproj", "{D1160404-D3D1-497A-883A-4059C07C2273}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.WindowsTerminal", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.WindowsTerminal\Microsoft.CmdPal.Ext.WindowsTerminal.csproj", "{40F6D69D-E321-400F-A767-5628C7AE453D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extension SDK", "Extension SDK", "{F3D09629-59A2-4924-A4B9-D6BFAA2C1B49}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Microsoft.CommandPalette.Extensions", "src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.vcxproj", "{305DD37E-C85D-4B08-AAFE-7381FA890463}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CommandPalette.Extensions.Toolkit", "src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj", "{CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Common", "src\modules\cmdpal\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj", "{14E62033-58D0-4A7D-8990-52F50A08BBBD}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Microsoft.Terminal.UI", "src\modules\cmdpal\Microsoft.Terminal.UI\Microsoft.Terminal.UI.vcxproj", "{6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Sample Extensions", "Sample Extensions", "{071E18A4-A530-46B8-AB7D-B862EE55E24E}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProcessMonitorExtension", "src\modules\cmdpal\Exts\ProcessMonitorExtension\ProcessMonitorExtension.csproj", "{C846F7A7-792A-47D9-B0CB-417C900EE03D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SamplePagesExtension", "src\modules\cmdpal\Exts\SamplePagesExtension\SamplePagesExtension.csproj", "{C831231F-891C-4572-9694-45062534B42A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UI", "UI", "{7520A2FE-00A2-49B8-83ED-DB216E874C04}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.UI", "src\modules\cmdpal\Microsoft.CmdPal.UI\Microsoft.CmdPal.UI.csproj", "{8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.UI.ViewModels", "src\modules\cmdpal\Microsoft.CmdPal.UI.ViewModels\Microsoft.CmdPal.UI.ViewModels.csproj", "{C66020D1-CB10-4CF7-8715-84C97FD5E5E2}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.ClipboardHistory", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.ClipboardHistory\Microsoft.CmdPal.Ext.ClipboardHistory.csproj", "{79775343-7A3D-445D-9104-3DD5B2893DF9}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "CmdPalModuleInterface", "src\modules\cmdpal\CmdPalModuleInterface\CmdPalModuleInterface.vcxproj", "{0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkspacesCsharpLibrary", "src\modules\Workspaces\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj", "{89D0E199-B17A-418C-B2F8-7375B6708357}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "NewPlus.ShellExtension.win10", "src\modules\NewPlus\NewShellExtensionContextMenu.win10\NewPlus.ShellExtension.win10.vcxproj", "{0DB0F63A-D2F8-4DA3-A650-2D0B8724218E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Indexer", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj", "{453CBB73-A3CB-4D0B-8D24-6940B86FE21D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.Shell", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.Shell\Microsoft.CmdPal.Ext.Shell.csproj", "{C0CE3B5E-16D3-495D-B335-CA791B660162}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.CmdPal.Ext.WindowWalker", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.WindowWalker\Microsoft.CmdPal.Ext.WindowWalker.csproj", "{3A9A7297-92C4-4F16-B6F9-8D4AB652C86C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSearch", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.WebSearch\Microsoft.CmdPal.Ext.WebSearch.csproj", "{605E914B-7232-4789-AF46-BF5D3DDFC14E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WinGet", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.WinGet\Microsoft.CmdPal.Ext.WinGet.csproj", "{E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdvancedPaste.UnitTests", "src\modules\AdvancedPaste\AdvancedPaste.UnitTests\AdvancedPaste.UnitTests.csproj", "{D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdvancedPaste.FuzzTests", "src\modules\AdvancedPaste\AdvancedPaste.FuzzTests\AdvancedPaste.FuzzTests.csproj", "{7F5B9557-5878-4438-A721-3E28296BA193}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ZoomIt", "ZoomIt", "{DD6E12FE-5509-4ABC-ACC2-3D6DC98A238C}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ZoomIt", "src\modules\ZoomIt\ZoomIt\ZoomIt.vcxproj", "{0A84F764-3A88-44CD-AA96-41BDBD48627B}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ZoomItModuleInterface", "src\modules\ZoomIt\ZoomItModuleInterface\ZoomItModuleInterface.vcxproj", "{E4585179-2AC1-4D5F-A3FF-CFC5392F694C}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "ZoomItSettingsInterop", "src\modules\ZoomIt\ZoomItSettingsInterop\ZoomItSettingsInterop.vcxproj", "{CA7D8106-30B9-4AEC-9D05-B69B31B8C461}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.TimeDate", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.TimeDate\Microsoft.CmdPal.Ext.TimeDate.csproj", "{DCC6BD67-17BB-47AA-B507-FB0FE43A7449}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UITestAutomation", "src\common\UITestAutomation\UITestAutomation.csproj", "{A558C25D-2007-498E-8B6F-43405AFAE9E2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KeyboardManagerEditorUI", "src\modules\keyboardmanager\KeyboardManagerEditorUI\KeyboardManagerEditorUI.csproj", "{08F9155D-B6DC-46E5-9C83-AF60B655898B}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "KeyboardManagerEditorLibraryWrapper", "src\modules\keyboardmanager\KeyboardManagerEditorLibraryWrapper\KeyboardManagerEditorLibraryWrapper.vcxproj", "{4382A954-179A-4078-92AF-715187DFFF50}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hosts.FuzzTests", "src\modules\Hosts\Hosts.FuzzTests\Hosts.FuzzTests.csproj", "{EBED240C-8702-452D-B764-6DB9DA9179AF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hosts.UITests", "src\modules\Hosts\Hosts.UITests\Hosts.UITests.csproj", "{4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RegistryPreview.FuzzTests", "src\modules\registrypreview\RegistryPreview.FuzzTests\RegistryPreview.FuzzTests.csproj", "{5702B3CC-8575-48D5-83D8-15BB42269CD3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.System", "src\modules\cmdpal\Exts\Microsoft.CmdPal.Ext.System\Microsoft.CmdPal.Ext.System.csproj", "{64B88F02-CD88-4ED8-9624-989A800230F9}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|ARM64 = Debug|ARM64 - Debug|x64 = Debug|x64 - Release|ARM64 = Release|ARM64 - Release|x64 = Release|x64 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {9412D5C6-2CF2-4FC2-A601-B55508EA9B27}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {9412D5C6-2CF2-4FC2-A601-B55508EA9B27}.Debug|ARM64.Build.0 = Debug|ARM64 - {9412D5C6-2CF2-4FC2-A601-B55508EA9B27}.Debug|x64.ActiveCfg = Debug|x64 - {9412D5C6-2CF2-4FC2-A601-B55508EA9B27}.Debug|x64.Build.0 = Debug|x64 - {9412D5C6-2CF2-4FC2-A601-B55508EA9B27}.Release|ARM64.ActiveCfg = Release|ARM64 - {9412D5C6-2CF2-4FC2-A601-B55508EA9B27}.Release|ARM64.Build.0 = Release|ARM64 - {9412D5C6-2CF2-4FC2-A601-B55508EA9B27}.Release|x64.ActiveCfg = Release|x64 - {9412D5C6-2CF2-4FC2-A601-B55508EA9B27}.Release|x64.Build.0 = Release|x64 - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}.Debug|ARM64.Build.0 = Debug|ARM64 - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}.Debug|x64.ActiveCfg = Debug|x64 - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}.Debug|x64.Build.0 = Debug|x64 - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}.Release|ARM64.ActiveCfg = Release|ARM64 - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}.Release|ARM64.Build.0 = Release|ARM64 - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}.Release|x64.ActiveCfg = Release|x64 - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99}.Release|x64.Build.0 = Release|x64 - {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}.Debug|ARM64.Build.0 = Debug|ARM64 - {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}.Debug|x64.ActiveCfg = Debug|x64 - {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}.Debug|x64.Build.0 = Debug|x64 - {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}.Release|ARM64.ActiveCfg = Release|ARM64 - {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}.Release|ARM64.Build.0 = Release|ARM64 - {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}.Release|x64.ActiveCfg = Release|x64 - {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}.Release|x64.Build.0 = Release|x64 - {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Debug|ARM64.Build.0 = Debug|ARM64 - {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Debug|x64.ActiveCfg = Debug|x64 - {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Debug|x64.Build.0 = Debug|x64 - {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|ARM64.ActiveCfg = Release|ARM64 - {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|ARM64.Build.0 = Release|ARM64 - {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|x64.ActiveCfg = Release|x64 - {1A066C63-64B3-45F8-92FE-664E1CCE8077}.Release|x64.Build.0 = Release|x64 - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|ARM64.Build.0 = Debug|ARM64 - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|x64.ActiveCfg = Debug|x64 - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Debug|x64.Build.0 = Debug|x64 - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Release|ARM64.ActiveCfg = Release|ARM64 - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Release|ARM64.Build.0 = Release|ARM64 - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Release|x64.ActiveCfg = Release|x64 - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481}.Release|x64.Build.0 = Release|x64 - {B25AC7A5-FB9F-4789-B392-D5C85E948670}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {B25AC7A5-FB9F-4789-B392-D5C85E948670}.Debug|ARM64.Build.0 = Debug|ARM64 - {B25AC7A5-FB9F-4789-B392-D5C85E948670}.Debug|x64.ActiveCfg = Debug|x64 - {B25AC7A5-FB9F-4789-B392-D5C85E948670}.Debug|x64.Build.0 = Debug|x64 - {B25AC7A5-FB9F-4789-B392-D5C85E948670}.Release|ARM64.ActiveCfg = Release|ARM64 - {B25AC7A5-FB9F-4789-B392-D5C85E948670}.Release|ARM64.Build.0 = Release|ARM64 - {B25AC7A5-FB9F-4789-B392-D5C85E948670}.Release|x64.ActiveCfg = Release|x64 - {B25AC7A5-FB9F-4789-B392-D5C85E948670}.Release|x64.Build.0 = Release|x64 - {51920F1F-C28C-4ADF-8660-4238766796C2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {51920F1F-C28C-4ADF-8660-4238766796C2}.Debug|ARM64.Build.0 = Debug|ARM64 - {51920F1F-C28C-4ADF-8660-4238766796C2}.Debug|x64.ActiveCfg = Debug|x64 - {51920F1F-C28C-4ADF-8660-4238766796C2}.Debug|x64.Build.0 = Debug|x64 - {51920F1F-C28C-4ADF-8660-4238766796C2}.Release|ARM64.ActiveCfg = Release|ARM64 - {51920F1F-C28C-4ADF-8660-4238766796C2}.Release|ARM64.Build.0 = Release|ARM64 - {51920F1F-C28C-4ADF-8660-4238766796C2}.Release|x64.ActiveCfg = Release|x64 - {51920F1F-C28C-4ADF-8660-4238766796C2}.Release|x64.Build.0 = Release|x64 - {A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}.Debug|ARM64.Build.0 = Debug|ARM64 - {A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}.Debug|x64.ActiveCfg = Debug|x64 - {A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}.Debug|x64.Build.0 = Debug|x64 - {A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}.Release|ARM64.ActiveCfg = Release|ARM64 - {A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}.Release|ARM64.Build.0 = Release|ARM64 - {A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}.Release|x64.ActiveCfg = Release|x64 - {A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}.Release|x64.Build.0 = Release|x64 - {2151F984-E006-4A9F-92EF-C6DDE3DC8413}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {2151F984-E006-4A9F-92EF-C6DDE3DC8413}.Debug|ARM64.Build.0 = Debug|ARM64 - {2151F984-E006-4A9F-92EF-C6DDE3DC8413}.Debug|x64.ActiveCfg = Debug|x64 - {2151F984-E006-4A9F-92EF-C6DDE3DC8413}.Debug|x64.Build.0 = Debug|x64 - {2151F984-E006-4A9F-92EF-C6DDE3DC8413}.Release|ARM64.ActiveCfg = Release|ARM64 - {2151F984-E006-4A9F-92EF-C6DDE3DC8413}.Release|ARM64.Build.0 = Release|ARM64 - {2151F984-E006-4A9F-92EF-C6DDE3DC8413}.Release|x64.ActiveCfg = Release|x64 - {2151F984-E006-4A9F-92EF-C6DDE3DC8413}.Release|x64.Build.0 = Release|x64 - {64A80062-4D8B-4229-8A38-DFA1D7497749}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {64A80062-4D8B-4229-8A38-DFA1D7497749}.Debug|ARM64.Build.0 = Debug|ARM64 - {64A80062-4D8B-4229-8A38-DFA1D7497749}.Debug|x64.ActiveCfg = Debug|x64 - {64A80062-4D8B-4229-8A38-DFA1D7497749}.Debug|x64.Build.0 = Debug|x64 - {64A80062-4D8B-4229-8A38-DFA1D7497749}.Release|ARM64.ActiveCfg = Release|ARM64 - {64A80062-4D8B-4229-8A38-DFA1D7497749}.Release|ARM64.Build.0 = Release|ARM64 - {64A80062-4D8B-4229-8A38-DFA1D7497749}.Release|x64.ActiveCfg = Release|x64 - {64A80062-4D8B-4229-8A38-DFA1D7497749}.Release|x64.Build.0 = Release|x64 - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Debug|ARM64.Build.0 = Debug|ARM64 - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Debug|x64.ActiveCfg = Debug|x64 - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Debug|x64.Build.0 = Debug|x64 - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Release|ARM64.ActiveCfg = Release|ARM64 - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Release|ARM64.Build.0 = Release|ARM64 - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Release|x64.ActiveCfg = Release|x64 - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9}.Release|x64.Build.0 = Release|x64 - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34}.Debug|ARM64.Build.0 = Debug|ARM64 - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34}.Debug|x64.ActiveCfg = Debug|x64 - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34}.Debug|x64.Build.0 = Debug|x64 - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34}.Release|ARM64.ActiveCfg = Release|ARM64 - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34}.Release|ARM64.Build.0 = Release|ARM64 - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34}.Release|x64.ActiveCfg = Release|x64 - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34}.Release|x64.Build.0 = Release|x64 - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B}.Debug|ARM64.Build.0 = Debug|ARM64 - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B}.Debug|x64.ActiveCfg = Debug|x64 - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B}.Debug|x64.Build.0 = Debug|x64 - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B}.Release|ARM64.ActiveCfg = Release|ARM64 - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B}.Release|ARM64.Build.0 = Release|ARM64 - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B}.Release|x64.ActiveCfg = Release|x64 - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B}.Release|x64.Build.0 = Release|x64 - {E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}.Debug|ARM64.Build.0 = Debug|ARM64 - {E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}.Debug|x64.ActiveCfg = Debug|x64 - {E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}.Debug|x64.Build.0 = Debug|x64 - {E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}.Release|ARM64.ActiveCfg = Release|ARM64 - {E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}.Release|ARM64.Build.0 = Release|ARM64 - {E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}.Release|x64.ActiveCfg = Release|x64 - {E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}.Release|x64.Build.0 = Release|x64 - {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}.Debug|ARM64.Build.0 = Debug|ARM64 - {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}.Debug|x64.ActiveCfg = Debug|x64 - {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}.Debug|x64.Build.0 = Debug|x64 - {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}.Release|ARM64.ActiveCfg = Release|ARM64 - {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}.Release|ARM64.Build.0 = Release|ARM64 - {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}.Release|x64.ActiveCfg = Release|x64 - {D29DDD63-E2CF-4657-9FD5-2AEDE4257E5D}.Release|x64.Build.0 = Release|x64 - {17DA04DF-E393-4397-9CF0-84DABE11032E}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {17DA04DF-E393-4397-9CF0-84DABE11032E}.Debug|ARM64.Build.0 = Debug|ARM64 - {17DA04DF-E393-4397-9CF0-84DABE11032E}.Debug|x64.ActiveCfg = Debug|x64 - {17DA04DF-E393-4397-9CF0-84DABE11032E}.Debug|x64.Build.0 = Debug|x64 - {17DA04DF-E393-4397-9CF0-84DABE11032E}.Release|ARM64.ActiveCfg = Release|ARM64 - {17DA04DF-E393-4397-9CF0-84DABE11032E}.Release|ARM64.Build.0 = Release|ARM64 - {17DA04DF-E393-4397-9CF0-84DABE11032E}.Release|x64.ActiveCfg = Release|x64 - {17DA04DF-E393-4397-9CF0-84DABE11032E}.Release|x64.Build.0 = Release|x64 - {8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}.Debug|ARM64.Build.0 = Debug|ARM64 - {8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}.Debug|x64.ActiveCfg = Debug|x64 - {8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}.Debug|x64.Build.0 = Debug|x64 - {8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}.Release|ARM64.ActiveCfg = Release|ARM64 - {8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}.Release|ARM64.Build.0 = Release|ARM64 - {8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}.Release|x64.ActiveCfg = Release|x64 - {8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}.Release|x64.Build.0 = Release|x64 - {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|ARM64.Build.0 = Debug|ARM64 - {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|x64.ActiveCfg = Debug|x64 - {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Debug|x64.Build.0 = Debug|x64 - {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Release|ARM64.ActiveCfg = Release|ARM64 - {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Release|ARM64.Build.0 = Release|ARM64 - {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Release|x64.ActiveCfg = Release|x64 - {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}.Release|x64.Build.0 = Release|x64 - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Debug|ARM64.Build.0 = Debug|ARM64 - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Debug|x64.ActiveCfg = Debug|x64 - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Debug|x64.Build.0 = Debug|x64 - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|ARM64.ActiveCfg = Release|ARM64 - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|ARM64.Build.0 = Release|ARM64 - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|x64.ActiveCfg = Release|x64 - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80}.Release|x64.Build.0 = Release|x64 - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|ARM64.Build.0 = Debug|ARM64 - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x64.ActiveCfg = Debug|x64 - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Debug|x64.Build.0 = Debug|x64 - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Release|ARM64.ActiveCfg = Release|ARM64 - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Release|ARM64.Build.0 = Release|ARM64 - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Release|x64.ActiveCfg = Release|x64 - {FF742965-9A80-41A5-B042-D6C7D3A21708}.Release|x64.Build.0 = Release|x64 - {59BD9891-3837-438A-958D-ADC7F91F6F7E}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {59BD9891-3837-438A-958D-ADC7F91F6F7E}.Debug|ARM64.Build.0 = Debug|ARM64 - {59BD9891-3837-438A-958D-ADC7F91F6F7E}.Debug|x64.ActiveCfg = Debug|x64 - {59BD9891-3837-438A-958D-ADC7F91F6F7E}.Debug|x64.Build.0 = Debug|x64 - {59BD9891-3837-438A-958D-ADC7F91F6F7E}.Release|ARM64.ActiveCfg = Release|ARM64 - {59BD9891-3837-438A-958D-ADC7F91F6F7E}.Release|ARM64.Build.0 = Release|ARM64 - {59BD9891-3837-438A-958D-ADC7F91F6F7E}.Release|x64.ActiveCfg = Release|x64 - {59BD9891-3837-438A-958D-ADC7F91F6F7E}.Release|x64.Build.0 = Release|x64 - {4D971245-7A70-41D5-BAA0-DDB5684CAF51}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {4D971245-7A70-41D5-BAA0-DDB5684CAF51}.Debug|ARM64.Build.0 = Debug|ARM64 - {4D971245-7A70-41D5-BAA0-DDB5684CAF51}.Debug|x64.ActiveCfg = Debug|x64 - {4D971245-7A70-41D5-BAA0-DDB5684CAF51}.Debug|x64.Build.0 = Debug|x64 - {4D971245-7A70-41D5-BAA0-DDB5684CAF51}.Release|ARM64.ActiveCfg = Release|ARM64 - {4D971245-7A70-41D5-BAA0-DDB5684CAF51}.Release|ARM64.Build.0 = Release|ARM64 - {4D971245-7A70-41D5-BAA0-DDB5684CAF51}.Release|x64.ActiveCfg = Release|x64 - {4D971245-7A70-41D5-BAA0-DDB5684CAF51}.Release|x64.Build.0 = Release|x64 - {74F1B9ED-F59C-4FE7-B473-7B453E30837E}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {74F1B9ED-F59C-4FE7-B473-7B453E30837E}.Debug|ARM64.Build.0 = Debug|ARM64 - {74F1B9ED-F59C-4FE7-B473-7B453E30837E}.Debug|x64.ActiveCfg = Debug|x64 - {74F1B9ED-F59C-4FE7-B473-7B453E30837E}.Debug|x64.Build.0 = Debug|x64 - {74F1B9ED-F59C-4FE7-B473-7B453E30837E}.Release|ARM64.ActiveCfg = Release|ARM64 - {74F1B9ED-F59C-4FE7-B473-7B453E30837E}.Release|ARM64.Build.0 = Release|ARM64 - {74F1B9ED-F59C-4FE7-B473-7B453E30837E}.Release|x64.ActiveCfg = Release|x64 - {74F1B9ED-F59C-4FE7-B473-7B453E30837E}.Release|x64.Build.0 = Release|x64 - {FDB3555B-58EF-4AE6-B5F1-904719637AB4}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {FDB3555B-58EF-4AE6-B5F1-904719637AB4}.Debug|ARM64.Build.0 = Debug|ARM64 - {FDB3555B-58EF-4AE6-B5F1-904719637AB4}.Debug|x64.ActiveCfg = Debug|x64 - {FDB3555B-58EF-4AE6-B5F1-904719637AB4}.Debug|x64.Build.0 = Debug|x64 - {FDB3555B-58EF-4AE6-B5F1-904719637AB4}.Release|ARM64.ActiveCfg = Release|ARM64 - {FDB3555B-58EF-4AE6-B5F1-904719637AB4}.Release|ARM64.Build.0 = Release|ARM64 - {FDB3555B-58EF-4AE6-B5F1-904719637AB4}.Release|x64.ActiveCfg = Release|x64 - {FDB3555B-58EF-4AE6-B5F1-904719637AB4}.Release|x64.Build.0 = Release|x64 - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}.Debug|ARM64.Build.0 = Debug|ARM64 - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}.Debug|x64.ActiveCfg = Debug|x64 - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}.Debug|x64.Build.0 = Debug|x64 - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}.Release|ARM64.ActiveCfg = Release|ARM64 - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}.Release|ARM64.Build.0 = Release|ARM64 - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}.Release|x64.ActiveCfg = Release|x64 - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}.Release|x64.Build.0 = Release|x64 - {F8B870EB-D5F5-45BA-9CF7-A5C459818820}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {F8B870EB-D5F5-45BA-9CF7-A5C459818820}.Debug|ARM64.Build.0 = Debug|ARM64 - {F8B870EB-D5F5-45BA-9CF7-A5C459818820}.Debug|x64.ActiveCfg = Debug|x64 - {F8B870EB-D5F5-45BA-9CF7-A5C459818820}.Debug|x64.Build.0 = Debug|x64 - {F8B870EB-D5F5-45BA-9CF7-A5C459818820}.Release|ARM64.ActiveCfg = Release|ARM64 - {F8B870EB-D5F5-45BA-9CF7-A5C459818820}.Release|ARM64.Build.0 = Release|ARM64 - {F8B870EB-D5F5-45BA-9CF7-A5C459818820}.Release|x64.ActiveCfg = Release|x64 - {F8B870EB-D5F5-45BA-9CF7-A5C459818820}.Release|x64.Build.0 = Release|x64 - {E364F67B-BB12-4E91-B639-355866EBCD8B}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E364F67B-BB12-4E91-B639-355866EBCD8B}.Debug|ARM64.Build.0 = Debug|ARM64 - {E364F67B-BB12-4E91-B639-355866EBCD8B}.Debug|x64.ActiveCfg = Debug|x64 - {E364F67B-BB12-4E91-B639-355866EBCD8B}.Debug|x64.Build.0 = Debug|x64 - {E364F67B-BB12-4E91-B639-355866EBCD8B}.Release|ARM64.ActiveCfg = Release|ARM64 - {E364F67B-BB12-4E91-B639-355866EBCD8B}.Release|ARM64.Build.0 = Release|ARM64 - {E364F67B-BB12-4E91-B639-355866EBCD8B}.Release|x64.ActiveCfg = Release|x64 - {E364F67B-BB12-4E91-B639-355866EBCD8B}.Release|x64.Build.0 = Release|x64 - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2}.Debug|ARM64.Build.0 = Debug|ARM64 - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2}.Debug|x64.ActiveCfg = Debug|x64 - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2}.Debug|x64.Build.0 = Debug|x64 - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2}.Release|ARM64.ActiveCfg = Release|ARM64 - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2}.Release|ARM64.Build.0 = Release|ARM64 - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2}.Release|x64.ActiveCfg = Release|x64 - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2}.Release|x64.Build.0 = Release|x64 - {AF2349B8-E5B6-4004-9502-687C1C7730B1}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {AF2349B8-E5B6-4004-9502-687C1C7730B1}.Debug|ARM64.Build.0 = Debug|ARM64 - {AF2349B8-E5B6-4004-9502-687C1C7730B1}.Debug|x64.ActiveCfg = Debug|x64 - {AF2349B8-E5B6-4004-9502-687C1C7730B1}.Debug|x64.Build.0 = Debug|x64 - {AF2349B8-E5B6-4004-9502-687C1C7730B1}.Release|ARM64.ActiveCfg = Release|ARM64 - {AF2349B8-E5B6-4004-9502-687C1C7730B1}.Release|ARM64.Build.0 = Release|ARM64 - {AF2349B8-E5B6-4004-9502-687C1C7730B1}.Release|x64.ActiveCfg = Release|x64 - {AF2349B8-E5B6-4004-9502-687C1C7730B1}.Release|x64.Build.0 = Release|x64 - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB}.Debug|ARM64.Build.0 = Debug|ARM64 - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB}.Debug|x64.ActiveCfg = Debug|x64 - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB}.Debug|x64.Build.0 = Debug|x64 - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB}.Release|ARM64.ActiveCfg = Release|ARM64 - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB}.Release|ARM64.Build.0 = Release|ARM64 - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB}.Release|x64.ActiveCfg = Release|x64 - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB}.Release|x64.Build.0 = Release|x64 - {A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A}.Debug|ARM64.Build.0 = Debug|ARM64 - {A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A}.Debug|x64.ActiveCfg = Debug|x64 - {A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A}.Debug|x64.Build.0 = Debug|x64 - {A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A}.Release|ARM64.ActiveCfg = Release|ARM64 - {A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A}.Release|ARM64.Build.0 = Release|ARM64 - {A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A}.Release|x64.ActiveCfg = Release|x64 - {A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A}.Release|x64.Build.0 = Release|x64 - {DA425894-6E13-404F-8DCB-78584EC0557A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {DA425894-6E13-404F-8DCB-78584EC0557A}.Debug|ARM64.Build.0 = Debug|ARM64 - {DA425894-6E13-404F-8DCB-78584EC0557A}.Debug|x64.ActiveCfg = Debug|x64 - {DA425894-6E13-404F-8DCB-78584EC0557A}.Debug|x64.Build.0 = Debug|x64 - {DA425894-6E13-404F-8DCB-78584EC0557A}.Release|ARM64.ActiveCfg = Release|ARM64 - {DA425894-6E13-404F-8DCB-78584EC0557A}.Release|ARM64.Build.0 = Release|ARM64 - {DA425894-6E13-404F-8DCB-78584EC0557A}.Release|x64.ActiveCfg = Release|x64 - {DA425894-6E13-404F-8DCB-78584EC0557A}.Release|x64.Build.0 = Release|x64 - {060D75DA-2D1C-48E6-A4A1-6F0718B64661}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {060D75DA-2D1C-48E6-A4A1-6F0718B64661}.Debug|ARM64.Build.0 = Debug|ARM64 - {060D75DA-2D1C-48E6-A4A1-6F0718B64661}.Debug|x64.ActiveCfg = Debug|x64 - {060D75DA-2D1C-48E6-A4A1-6F0718B64661}.Debug|x64.Build.0 = Debug|x64 - {060D75DA-2D1C-48E6-A4A1-6F0718B64661}.Release|ARM64.ActiveCfg = Release|ARM64 - {060D75DA-2D1C-48E6-A4A1-6F0718B64661}.Release|ARM64.Build.0 = Release|ARM64 - {060D75DA-2D1C-48E6-A4A1-6F0718B64661}.Release|x64.ActiveCfg = Release|x64 - {060D75DA-2D1C-48E6-A4A1-6F0718B64661}.Release|x64.Build.0 = Release|x64 - {748417CA-F17E-487F-9411-CAFB6D3F4877}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {748417CA-F17E-487F-9411-CAFB6D3F4877}.Debug|ARM64.Build.0 = Debug|ARM64 - {748417CA-F17E-487F-9411-CAFB6D3F4877}.Debug|x64.ActiveCfg = Debug|x64 - {748417CA-F17E-487F-9411-CAFB6D3F4877}.Debug|x64.Build.0 = Debug|x64 - {748417CA-F17E-487F-9411-CAFB6D3F4877}.Release|ARM64.ActiveCfg = Release|ARM64 - {748417CA-F17E-487F-9411-CAFB6D3F4877}.Release|ARM64.Build.0 = Release|ARM64 - {748417CA-F17E-487F-9411-CAFB6D3F4877}.Release|x64.ActiveCfg = Release|x64 - {748417CA-F17E-487F-9411-CAFB6D3F4877}.Release|x64.Build.0 = Release|x64 - {217DF501-135C-4E38-BFC8-99D4821032EA}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {217DF501-135C-4E38-BFC8-99D4821032EA}.Debug|ARM64.Build.0 = Debug|ARM64 - {217DF501-135C-4E38-BFC8-99D4821032EA}.Debug|x64.ActiveCfg = Debug|x64 - {217DF501-135C-4E38-BFC8-99D4821032EA}.Debug|x64.Build.0 = Debug|x64 - {217DF501-135C-4E38-BFC8-99D4821032EA}.Release|ARM64.ActiveCfg = Release|ARM64 - {217DF501-135C-4E38-BFC8-99D4821032EA}.Release|ARM64.Build.0 = Release|ARM64 - {217DF501-135C-4E38-BFC8-99D4821032EA}.Release|x64.ActiveCfg = Release|x64 - {217DF501-135C-4E38-BFC8-99D4821032EA}.Release|x64.Build.0 = Release|x64 - {B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A}.Debug|ARM64.Build.0 = Debug|ARM64 - {B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A}.Debug|x64.ActiveCfg = Debug|x64 - {B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A}.Debug|x64.Build.0 = Debug|x64 - {B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A}.Release|ARM64.ActiveCfg = Release|ARM64 - {B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A}.Release|ARM64.Build.0 = Release|ARM64 - {B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A}.Release|x64.ActiveCfg = Release|x64 - {B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A}.Release|x64.Build.0 = Release|x64 - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4}.Debug|ARM64.Build.0 = Debug|ARM64 - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4}.Debug|x64.ActiveCfg = Debug|x64 - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4}.Debug|x64.Build.0 = Debug|x64 - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4}.Release|ARM64.ActiveCfg = Release|ARM64 - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4}.Release|ARM64.Build.0 = Release|ARM64 - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4}.Release|x64.ActiveCfg = Release|x64 - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4}.Release|x64.Build.0 = Release|x64 - {08C8C05F-0362-41BC-818C-724572DF8B06}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {08C8C05F-0362-41BC-818C-724572DF8B06}.Debug|ARM64.Build.0 = Debug|ARM64 - {08C8C05F-0362-41BC-818C-724572DF8B06}.Debug|x64.ActiveCfg = Debug|x64 - {08C8C05F-0362-41BC-818C-724572DF8B06}.Debug|x64.Build.0 = Debug|x64 - {08C8C05F-0362-41BC-818C-724572DF8B06}.Release|ARM64.ActiveCfg = Release|ARM64 - {08C8C05F-0362-41BC-818C-724572DF8B06}.Release|ARM64.Build.0 = Release|ARM64 - {08C8C05F-0362-41BC-818C-724572DF8B06}.Release|x64.ActiveCfg = Release|x64 - {08C8C05F-0362-41BC-818C-724572DF8B06}.Release|x64.Build.0 = Release|x64 - {5D00D290-4016-4CFE-9E41-1E7C724509BA}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {5D00D290-4016-4CFE-9E41-1E7C724509BA}.Debug|ARM64.Build.0 = Debug|ARM64 - {5D00D290-4016-4CFE-9E41-1E7C724509BA}.Debug|x64.ActiveCfg = Debug|x64 - {5D00D290-4016-4CFE-9E41-1E7C724509BA}.Debug|x64.Build.0 = Debug|x64 - {5D00D290-4016-4CFE-9E41-1E7C724509BA}.Release|ARM64.ActiveCfg = Release|ARM64 - {5D00D290-4016-4CFE-9E41-1E7C724509BA}.Release|ARM64.Build.0 = Release|ARM64 - {5D00D290-4016-4CFE-9E41-1E7C724509BA}.Release|x64.ActiveCfg = Release|x64 - {5D00D290-4016-4CFE-9E41-1E7C724509BA}.Release|x64.Build.0 = Release|x64 - {4AED67B6-55FD-486F-B917-E543DEE2CB3C}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {4AED67B6-55FD-486F-B917-E543DEE2CB3C}.Debug|ARM64.Build.0 = Debug|ARM64 - {4AED67B6-55FD-486F-B917-E543DEE2CB3C}.Debug|x64.ActiveCfg = Debug|x64 - {4AED67B6-55FD-486F-B917-E543DEE2CB3C}.Debug|x64.Build.0 = Debug|x64 - {4AED67B6-55FD-486F-B917-E543DEE2CB3C}.Release|ARM64.ActiveCfg = Release|ARM64 - {4AED67B6-55FD-486F-B917-E543DEE2CB3C}.Release|ARM64.Build.0 = Release|ARM64 - {4AED67B6-55FD-486F-B917-E543DEE2CB3C}.Release|x64.ActiveCfg = Release|x64 - {4AED67B6-55FD-486F-B917-E543DEE2CB3C}.Release|x64.Build.0 = Release|x64 - {42851751-CBC8-45A6-97F5-7A0753F7B4D1}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {42851751-CBC8-45A6-97F5-7A0753F7B4D1}.Debug|ARM64.Build.0 = Debug|ARM64 - {42851751-CBC8-45A6-97F5-7A0753F7B4D1}.Debug|x64.ActiveCfg = Debug|x64 - {42851751-CBC8-45A6-97F5-7A0753F7B4D1}.Debug|x64.Build.0 = Debug|x64 - {42851751-CBC8-45A6-97F5-7A0753F7B4D1}.Release|ARM64.ActiveCfg = Release|ARM64 - {42851751-CBC8-45A6-97F5-7A0753F7B4D1}.Release|ARM64.Build.0 = Release|ARM64 - {42851751-CBC8-45A6-97F5-7A0753F7B4D1}.Release|x64.ActiveCfg = Release|x64 - {42851751-CBC8-45A6-97F5-7A0753F7B4D1}.Release|x64.Build.0 = Release|x64 - {1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E}.Debug|ARM64.Build.0 = Debug|ARM64 - {1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E}.Debug|x64.ActiveCfg = Debug|x64 - {1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E}.Debug|x64.Build.0 = Debug|x64 - {1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E}.Release|ARM64.ActiveCfg = Release|ARM64 - {1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E}.Release|ARM64.Build.0 = Release|ARM64 - {1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E}.Release|x64.ActiveCfg = Release|x64 - {1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E}.Release|x64.Build.0 = Release|x64 - {8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD}.Debug|ARM64.Build.0 = Debug|ARM64 - {8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD}.Debug|x64.ActiveCfg = Debug|x64 - {8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD}.Debug|x64.Build.0 = Debug|x64 - {8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD}.Release|ARM64.ActiveCfg = Release|ARM64 - {8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD}.Release|ARM64.Build.0 = Release|ARM64 - {8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD}.Release|x64.ActiveCfg = Release|x64 - {8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD}.Release|x64.Build.0 = Release|x64 - {655C9AF2-18D3-4DA6-80E4-85504A7722BA}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {655C9AF2-18D3-4DA6-80E4-85504A7722BA}.Debug|ARM64.Build.0 = Debug|ARM64 - {655C9AF2-18D3-4DA6-80E4-85504A7722BA}.Debug|x64.ActiveCfg = Debug|x64 - {655C9AF2-18D3-4DA6-80E4-85504A7722BA}.Debug|x64.Build.0 = Debug|x64 - {655C9AF2-18D3-4DA6-80E4-85504A7722BA}.Release|ARM64.ActiveCfg = Release|ARM64 - {655C9AF2-18D3-4DA6-80E4-85504A7722BA}.Release|ARM64.Build.0 = Release|ARM64 - {655C9AF2-18D3-4DA6-80E4-85504A7722BA}.Release|x64.ActiveCfg = Release|x64 - {655C9AF2-18D3-4DA6-80E4-85504A7722BA}.Release|x64.Build.0 = Release|x64 - {BA58206B-1493-4C75-BFEA-A85768A1E156}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {BA58206B-1493-4C75-BFEA-A85768A1E156}.Debug|ARM64.Build.0 = Debug|ARM64 - {BA58206B-1493-4C75-BFEA-A85768A1E156}.Debug|x64.ActiveCfg = Debug|x64 - {BA58206B-1493-4C75-BFEA-A85768A1E156}.Debug|x64.Build.0 = Debug|x64 - {BA58206B-1493-4C75-BFEA-A85768A1E156}.Release|ARM64.ActiveCfg = Release|ARM64 - {BA58206B-1493-4C75-BFEA-A85768A1E156}.Release|ARM64.Build.0 = Release|ARM64 - {BA58206B-1493-4C75-BFEA-A85768A1E156}.Release|x64.ActiveCfg = Release|x64 - {BA58206B-1493-4C75-BFEA-A85768A1E156}.Release|x64.Build.0 = Release|x64 - {03276A39-D4E9-417C-8FFD-200B0EE5E871}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {03276A39-D4E9-417C-8FFD-200B0EE5E871}.Debug|ARM64.Build.0 = Debug|ARM64 - {03276A39-D4E9-417C-8FFD-200B0EE5E871}.Debug|x64.ActiveCfg = Debug|x64 - {03276A39-D4E9-417C-8FFD-200B0EE5E871}.Debug|x64.Build.0 = Debug|x64 - {03276A39-D4E9-417C-8FFD-200B0EE5E871}.Release|ARM64.ActiveCfg = Release|ARM64 - {03276A39-D4E9-417C-8FFD-200B0EE5E871}.Release|ARM64.Build.0 = Release|ARM64 - {03276A39-D4E9-417C-8FFD-200B0EE5E871}.Release|x64.ActiveCfg = Release|x64 - {03276A39-D4E9-417C-8FFD-200B0EE5E871}.Release|x64.Build.0 = Release|x64 - {B81FB7B6-D30E-428F-908A-41422EFC1172}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {B81FB7B6-D30E-428F-908A-41422EFC1172}.Debug|ARM64.Build.0 = Debug|ARM64 - {B81FB7B6-D30E-428F-908A-41422EFC1172}.Debug|x64.ActiveCfg = Debug|x64 - {B81FB7B6-D30E-428F-908A-41422EFC1172}.Debug|x64.Build.0 = Debug|x64 - {B81FB7B6-D30E-428F-908A-41422EFC1172}.Release|ARM64.ActiveCfg = Release|ARM64 - {B81FB7B6-D30E-428F-908A-41422EFC1172}.Release|ARM64.Build.0 = Release|ARM64 - {B81FB7B6-D30E-428F-908A-41422EFC1172}.Release|x64.ActiveCfg = Release|x64 - {B81FB7B6-D30E-428F-908A-41422EFC1172}.Release|x64.Build.0 = Release|x64 - {0F85E674-34AE-443D-954C-8321EB8B93B1}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {0F85E674-34AE-443D-954C-8321EB8B93B1}.Debug|ARM64.Build.0 = Debug|ARM64 - {0F85E674-34AE-443D-954C-8321EB8B93B1}.Debug|x64.ActiveCfg = Debug|x64 - {0F85E674-34AE-443D-954C-8321EB8B93B1}.Debug|x64.Build.0 = Debug|x64 - {0F85E674-34AE-443D-954C-8321EB8B93B1}.Release|ARM64.ActiveCfg = Release|ARM64 - {0F85E674-34AE-443D-954C-8321EB8B93B1}.Release|ARM64.Build.0 = Release|ARM64 - {0F85E674-34AE-443D-954C-8321EB8B93B1}.Release|x64.ActiveCfg = Release|x64 - {0F85E674-34AE-443D-954C-8321EB8B93B1}.Release|x64.Build.0 = Release|x64 - {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Debug|ARM64.Build.0 = Debug|ARM64 - {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Debug|x64.ActiveCfg = Debug|x64 - {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Debug|x64.Build.0 = Debug|x64 - {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Release|ARM64.ActiveCfg = Release|ARM64 - {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Release|ARM64.Build.0 = Release|ARM64 - {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Release|x64.ActiveCfg = Release|x64 - {632BBE62-5421-49EA-835A-7FFA4F499BD6}.Release|x64.Build.0 = Release|x64 - {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Debug|ARM64.Build.0 = Debug|ARM64 - {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Debug|x64.ActiveCfg = Debug|x64 - {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Debug|x64.Build.0 = Debug|x64 - {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Release|ARM64.ActiveCfg = Release|ARM64 - {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Release|ARM64.Build.0 = Release|ARM64 - {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Release|x64.ActiveCfg = Release|x64 - {4FA206A5-F69F-4193-BF8F-F6EEB496734C}.Release|x64.Build.0 = Release|x64 - {090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}.Debug|ARM64.Build.0 = Debug|ARM64 - {090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}.Debug|x64.ActiveCfg = Debug|x64 - {090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}.Debug|x64.Build.0 = Debug|x64 - {090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}.Release|ARM64.ActiveCfg = Release|ARM64 - {090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}.Release|ARM64.Build.0 = Release|ARM64 - {090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}.Release|x64.ActiveCfg = Release|x64 - {090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}.Release|x64.Build.0 = Release|x64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|ARM64.Build.0 = Debug|ARM64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|x64.ActiveCfg = Debug|x64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|x64.Build.0 = Debug|x64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Release|ARM64.ActiveCfg = Release|ARM64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Release|ARM64.Build.0 = Release|ARM64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Release|x64.ActiveCfg = Release|x64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Release|x64.Build.0 = Release|x64 - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B}.Debug|ARM64.Build.0 = Debug|ARM64 - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B}.Debug|x64.ActiveCfg = Debug|x64 - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B}.Debug|x64.Build.0 = Debug|x64 - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B}.Release|ARM64.ActiveCfg = Release|ARM64 - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B}.Release|ARM64.Build.0 = Release|ARM64 - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B}.Release|x64.ActiveCfg = Release|x64 - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B}.Release|x64.Build.0 = Release|x64 - {DA5A6FE9-0040-40CC-83CC-764AE5306590}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {DA5A6FE9-0040-40CC-83CC-764AE5306590}.Debug|ARM64.Build.0 = Debug|ARM64 - {DA5A6FE9-0040-40CC-83CC-764AE5306590}.Debug|x64.ActiveCfg = Debug|x64 - {DA5A6FE9-0040-40CC-83CC-764AE5306590}.Debug|x64.Build.0 = Debug|x64 - {DA5A6FE9-0040-40CC-83CC-764AE5306590}.Release|ARM64.ActiveCfg = Release|ARM64 - {DA5A6FE9-0040-40CC-83CC-764AE5306590}.Release|ARM64.Build.0 = Release|ARM64 - {DA5A6FE9-0040-40CC-83CC-764AE5306590}.Release|x64.ActiveCfg = Release|x64 - {DA5A6FE9-0040-40CC-83CC-764AE5306590}.Release|x64.Build.0 = Release|x64 - {0351ADA4-0C32-4652-9BA0-41F7B602372B}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {0351ADA4-0C32-4652-9BA0-41F7B602372B}.Debug|ARM64.Build.0 = Debug|ARM64 - {0351ADA4-0C32-4652-9BA0-41F7B602372B}.Debug|x64.ActiveCfg = Debug|x64 - {0351ADA4-0C32-4652-9BA0-41F7B602372B}.Debug|x64.Build.0 = Debug|x64 - {0351ADA4-0C32-4652-9BA0-41F7B602372B}.Release|ARM64.ActiveCfg = Release|ARM64 - {0351ADA4-0C32-4652-9BA0-41F7B602372B}.Release|ARM64.Build.0 = Release|ARM64 - {0351ADA4-0C32-4652-9BA0-41F7B602372B}.Release|x64.ActiveCfg = Release|x64 - {0351ADA4-0C32-4652-9BA0-41F7B602372B}.Release|x64.Build.0 = Release|x64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Debug|ARM64.Build.0 = Debug|ARM64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Debug|x64.ActiveCfg = Debug|x64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Debug|x64.Build.0 = Debug|x64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|ARM64.ActiveCfg = Release|ARM64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|ARM64.Build.0 = Release|ARM64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|x64.ActiveCfg = Release|x64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|x64.Build.0 = Release|x64 - {6955446D-23F7-4023-9BB3-8657F904AF99}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {6955446D-23F7-4023-9BB3-8657F904AF99}.Debug|ARM64.Build.0 = Debug|ARM64 - {6955446D-23F7-4023-9BB3-8657F904AF99}.Debug|x64.ActiveCfg = Debug|x64 - {6955446D-23F7-4023-9BB3-8657F904AF99}.Debug|x64.Build.0 = Debug|x64 - {6955446D-23F7-4023-9BB3-8657F904AF99}.Release|ARM64.ActiveCfg = Release|ARM64 - {6955446D-23F7-4023-9BB3-8657F904AF99}.Release|ARM64.Build.0 = Release|ARM64 - {6955446D-23F7-4023-9BB3-8657F904AF99}.Release|x64.ActiveCfg = Release|x64 - {6955446D-23F7-4023-9BB3-8657F904AF99}.Release|x64.Build.0 = Release|x64 - {58736667-1027-4AD7-BFDF-7A3A6474103A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {58736667-1027-4AD7-BFDF-7A3A6474103A}.Debug|ARM64.Build.0 = Debug|ARM64 - {58736667-1027-4AD7-BFDF-7A3A6474103A}.Debug|x64.ActiveCfg = Debug|x64 - {58736667-1027-4AD7-BFDF-7A3A6474103A}.Debug|x64.Build.0 = Debug|x64 - {58736667-1027-4AD7-BFDF-7A3A6474103A}.Release|ARM64.ActiveCfg = Release|ARM64 - {58736667-1027-4AD7-BFDF-7A3A6474103A}.Release|ARM64.Build.0 = Release|ARM64 - {58736667-1027-4AD7-BFDF-7A3A6474103A}.Release|x64.ActiveCfg = Release|x64 - {58736667-1027-4AD7-BFDF-7A3A6474103A}.Release|x64.Build.0 = Release|x64 - {1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}.Debug|ARM64.Build.0 = Debug|ARM64 - {1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}.Debug|x64.ActiveCfg = Debug|x64 - {1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}.Debug|x64.Build.0 = Debug|x64 - {1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}.Release|ARM64.ActiveCfg = Release|ARM64 - {1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}.Release|ARM64.Build.0 = Release|ARM64 - {1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}.Release|x64.ActiveCfg = Release|x64 - {1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}.Release|x64.Build.0 = Release|x64 - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73}.Debug|ARM64.Build.0 = Debug|ARM64 - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73}.Debug|x64.ActiveCfg = Debug|x64 - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73}.Debug|x64.Build.0 = Debug|x64 - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73}.Release|ARM64.ActiveCfg = Release|ARM64 - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73}.Release|ARM64.Build.0 = Release|ARM64 - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73}.Release|x64.ActiveCfg = Release|x64 - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73}.Release|x64.Build.0 = Release|x64 - {0B593A6C-4143-4337-860E-DB5710FB87DB}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {0B593A6C-4143-4337-860E-DB5710FB87DB}.Debug|ARM64.Build.0 = Debug|ARM64 - {0B593A6C-4143-4337-860E-DB5710FB87DB}.Debug|x64.ActiveCfg = Debug|x64 - {0B593A6C-4143-4337-860E-DB5710FB87DB}.Debug|x64.Build.0 = Debug|x64 - {0B593A6C-4143-4337-860E-DB5710FB87DB}.Release|ARM64.ActiveCfg = Release|ARM64 - {0B593A6C-4143-4337-860E-DB5710FB87DB}.Release|ARM64.Build.0 = Release|ARM64 - {0B593A6C-4143-4337-860E-DB5710FB87DB}.Release|x64.ActiveCfg = Release|x64 - {0B593A6C-4143-4337-860E-DB5710FB87DB}.Release|x64.Build.0 = Release|x64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|ARM64.Build.0 = Debug|ARM64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|x64.ActiveCfg = Debug|x64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|x64.Build.0 = Debug|x64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Release|ARM64.ActiveCfg = Release|ARM64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Release|ARM64.Build.0 = Release|ARM64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Release|x64.ActiveCfg = Release|x64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Release|x64.Build.0 = Release|x64 - {7319089E-46D6-4400-BC65-E39BDF1416EE}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {7319089E-46D6-4400-BC65-E39BDF1416EE}.Debug|ARM64.Build.0 = Debug|ARM64 - {7319089E-46D6-4400-BC65-E39BDF1416EE}.Debug|x64.ActiveCfg = Debug|x64 - {7319089E-46D6-4400-BC65-E39BDF1416EE}.Debug|x64.Build.0 = Debug|x64 - {7319089E-46D6-4400-BC65-E39BDF1416EE}.Release|ARM64.ActiveCfg = Release|ARM64 - {7319089E-46D6-4400-BC65-E39BDF1416EE}.Release|ARM64.Build.0 = Release|ARM64 - {7319089E-46D6-4400-BC65-E39BDF1416EE}.Release|x64.ActiveCfg = Release|x64 - {7319089E-46D6-4400-BC65-E39BDF1416EE}.Release|x64.Build.0 = Release|x64 - {CABA8DFB-823B-4BF2-93AC-3F31984150D9}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {CABA8DFB-823B-4BF2-93AC-3F31984150D9}.Debug|ARM64.Build.0 = Debug|ARM64 - {CABA8DFB-823B-4BF2-93AC-3F31984150D9}.Debug|x64.ActiveCfg = Debug|x64 - {CABA8DFB-823B-4BF2-93AC-3F31984150D9}.Debug|x64.Build.0 = Debug|x64 - {CABA8DFB-823B-4BF2-93AC-3F31984150D9}.Release|ARM64.ActiveCfg = Release|ARM64 - {CABA8DFB-823B-4BF2-93AC-3F31984150D9}.Release|ARM64.Build.0 = Release|ARM64 - {CABA8DFB-823B-4BF2-93AC-3F31984150D9}.Release|x64.ActiveCfg = Release|x64 - {CABA8DFB-823B-4BF2-93AC-3F31984150D9}.Release|x64.Build.0 = Release|x64 - {98537082-0FDB-40DE-ABD8-0DC5A4269BAB}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {98537082-0FDB-40DE-ABD8-0DC5A4269BAB}.Debug|ARM64.Build.0 = Debug|ARM64 - {98537082-0FDB-40DE-ABD8-0DC5A4269BAB}.Debug|x64.ActiveCfg = Debug|x64 - {98537082-0FDB-40DE-ABD8-0DC5A4269BAB}.Debug|x64.Build.0 = Debug|x64 - {98537082-0FDB-40DE-ABD8-0DC5A4269BAB}.Release|ARM64.ActiveCfg = Release|ARM64 - {98537082-0FDB-40DE-ABD8-0DC5A4269BAB}.Release|ARM64.Build.0 = Release|ARM64 - {98537082-0FDB-40DE-ABD8-0DC5A4269BAB}.Release|x64.ActiveCfg = Release|x64 - {98537082-0FDB-40DE-ABD8-0DC5A4269BAB}.Release|x64.Build.0 = Release|x64 - {C3A17DCA-217B-462C-BB0C-BE086AF80081}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C3A17DCA-217B-462C-BB0C-BE086AF80081}.Debug|ARM64.Build.0 = Debug|ARM64 - {C3A17DCA-217B-462C-BB0C-BE086AF80081}.Debug|x64.ActiveCfg = Debug|x64 - {C3A17DCA-217B-462C-BB0C-BE086AF80081}.Debug|x64.Build.0 = Debug|x64 - {C3A17DCA-217B-462C-BB0C-BE086AF80081}.Release|ARM64.ActiveCfg = Release|ARM64 - {C3A17DCA-217B-462C-BB0C-BE086AF80081}.Release|ARM64.Build.0 = Release|ARM64 - {C3A17DCA-217B-462C-BB0C-BE086AF80081}.Release|x64.ActiveCfg = Release|x64 - {C3A17DCA-217B-462C-BB0C-BE086AF80081}.Release|x64.Build.0 = Release|x64 - {69E1EE8D-143A-4060-9129-4658ACF14AAF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {69E1EE8D-143A-4060-9129-4658ACF14AAF}.Debug|ARM64.Build.0 = Debug|ARM64 - {69E1EE8D-143A-4060-9129-4658ACF14AAF}.Debug|x64.ActiveCfg = Debug|x64 - {69E1EE8D-143A-4060-9129-4658ACF14AAF}.Debug|x64.Build.0 = Debug|x64 - {69E1EE8D-143A-4060-9129-4658ACF14AAF}.Release|ARM64.ActiveCfg = Release|ARM64 - {69E1EE8D-143A-4060-9129-4658ACF14AAF}.Release|ARM64.Build.0 = Release|ARM64 - {69E1EE8D-143A-4060-9129-4658ACF14AAF}.Release|x64.ActiveCfg = Release|x64 - {69E1EE8D-143A-4060-9129-4658ACF14AAF}.Release|x64.Build.0 = Release|x64 - {ECC20689-002A-4354-95A6-B58DF089C6FF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {ECC20689-002A-4354-95A6-B58DF089C6FF}.Debug|ARM64.Build.0 = Debug|ARM64 - {ECC20689-002A-4354-95A6-B58DF089C6FF}.Debug|x64.ActiveCfg = Debug|x64 - {ECC20689-002A-4354-95A6-B58DF089C6FF}.Debug|x64.Build.0 = Debug|x64 - {ECC20689-002A-4354-95A6-B58DF089C6FF}.Release|ARM64.ActiveCfg = Release|ARM64 - {ECC20689-002A-4354-95A6-B58DF089C6FF}.Release|ARM64.Build.0 = Release|ARM64 - {ECC20689-002A-4354-95A6-B58DF089C6FF}.Release|x64.ActiveCfg = Release|x64 - {ECC20689-002A-4354-95A6-B58DF089C6FF}.Release|x64.Build.0 = Release|x64 - {4BABF3FE-3451-42FD-873F-3C332E18DCEF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {4BABF3FE-3451-42FD-873F-3C332E18DCEF}.Debug|ARM64.Build.0 = Debug|ARM64 - {4BABF3FE-3451-42FD-873F-3C332E18DCEF}.Debug|x64.ActiveCfg = Debug|x64 - {4BABF3FE-3451-42FD-873F-3C332E18DCEF}.Debug|x64.Build.0 = Debug|x64 - {4BABF3FE-3451-42FD-873F-3C332E18DCEF}.Release|ARM64.ActiveCfg = Release|ARM64 - {4BABF3FE-3451-42FD-873F-3C332E18DCEF}.Release|ARM64.Build.0 = Release|ARM64 - {4BABF3FE-3451-42FD-873F-3C332E18DCEF}.Release|x64.ActiveCfg = Release|x64 - {4BABF3FE-3451-42FD-873F-3C332E18DCEF}.Release|x64.Build.0 = Release|x64 - {0648DF05-5DDA-4BE1-B5F2-584926EBDB65}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {0648DF05-5DDA-4BE1-B5F2-584926EBDB65}.Debug|ARM64.Build.0 = Debug|ARM64 - {0648DF05-5DDA-4BE1-B5F2-584926EBDB65}.Debug|x64.ActiveCfg = Debug|x64 - {0648DF05-5DDA-4BE1-B5F2-584926EBDB65}.Debug|x64.Build.0 = Debug|x64 - {0648DF05-5DDA-4BE1-B5F2-584926EBDB65}.Release|ARM64.ActiveCfg = Release|ARM64 - {0648DF05-5DDA-4BE1-B5F2-584926EBDB65}.Release|ARM64.Build.0 = Release|ARM64 - {0648DF05-5DDA-4BE1-B5F2-584926EBDB65}.Release|x64.ActiveCfg = Release|x64 - {0648DF05-5DDA-4BE1-B5F2-584926EBDB65}.Release|x64.Build.0 = Release|x64 - {BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD}.Debug|ARM64.Build.0 = Debug|ARM64 - {BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD}.Debug|x64.ActiveCfg = Debug|x64 - {BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD}.Debug|x64.Build.0 = Debug|x64 - {BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD}.Release|ARM64.ActiveCfg = Release|ARM64 - {BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD}.Release|ARM64.Build.0 = Release|ARM64 - {BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD}.Release|x64.ActiveCfg = Release|x64 - {BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD}.Release|x64.Build.0 = Release|x64 - {E496B7FC-1E99-4BAB-849B-0E8367040B02}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E496B7FC-1E99-4BAB-849B-0E8367040B02}.Debug|ARM64.Build.0 = Debug|ARM64 - {E496B7FC-1E99-4BAB-849B-0E8367040B02}.Debug|x64.ActiveCfg = Debug|x64 - {E496B7FC-1E99-4BAB-849B-0E8367040B02}.Debug|x64.Build.0 = Debug|x64 - {E496B7FC-1E99-4BAB-849B-0E8367040B02}.Release|ARM64.ActiveCfg = Release|ARM64 - {E496B7FC-1E99-4BAB-849B-0E8367040B02}.Release|ARM64.Build.0 = Release|ARM64 - {E496B7FC-1E99-4BAB-849B-0E8367040B02}.Release|x64.ActiveCfg = Release|x64 - {E496B7FC-1E99-4BAB-849B-0E8367040B02}.Release|x64.Build.0 = Release|x64 - {7F4B3A60-BC27-45A7-8000-68B0B6EA7466}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {7F4B3A60-BC27-45A7-8000-68B0B6EA7466}.Debug|ARM64.Build.0 = Debug|ARM64 - {7F4B3A60-BC27-45A7-8000-68B0B6EA7466}.Debug|x64.ActiveCfg = Debug|x64 - {7F4B3A60-BC27-45A7-8000-68B0B6EA7466}.Debug|x64.Build.0 = Debug|x64 - {7F4B3A60-BC27-45A7-8000-68B0B6EA7466}.Release|ARM64.ActiveCfg = Release|ARM64 - {7F4B3A60-BC27-45A7-8000-68B0B6EA7466}.Release|ARM64.Build.0 = Release|ARM64 - {7F4B3A60-BC27-45A7-8000-68B0B6EA7466}.Release|x64.ActiveCfg = Release|x64 - {7F4B3A60-BC27-45A7-8000-68B0B6EA7466}.Release|x64.Build.0 = Release|x64 - {8DF78B53-200E-451F-9328-01EB907193AE}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8DF78B53-200E-451F-9328-01EB907193AE}.Debug|ARM64.Build.0 = Debug|ARM64 - {8DF78B53-200E-451F-9328-01EB907193AE}.Debug|x64.ActiveCfg = Debug|x64 - {8DF78B53-200E-451F-9328-01EB907193AE}.Debug|x64.Build.0 = Debug|x64 - {8DF78B53-200E-451F-9328-01EB907193AE}.Release|ARM64.ActiveCfg = Release|ARM64 - {8DF78B53-200E-451F-9328-01EB907193AE}.Release|ARM64.Build.0 = Release|ARM64 - {8DF78B53-200E-451F-9328-01EB907193AE}.Release|x64.ActiveCfg = Release|x64 - {8DF78B53-200E-451F-9328-01EB907193AE}.Release|x64.Build.0 = Release|x64 - {23D2070D-E4AD-4ADD-85A7-083D9C76AD49}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {23D2070D-E4AD-4ADD-85A7-083D9C76AD49}.Debug|ARM64.Build.0 = Debug|ARM64 - {23D2070D-E4AD-4ADD-85A7-083D9C76AD49}.Debug|x64.ActiveCfg = Debug|x64 - {23D2070D-E4AD-4ADD-85A7-083D9C76AD49}.Debug|x64.Build.0 = Debug|x64 - {23D2070D-E4AD-4ADD-85A7-083D9C76AD49}.Release|ARM64.ActiveCfg = Release|ARM64 - {23D2070D-E4AD-4ADD-85A7-083D9C76AD49}.Release|ARM64.Build.0 = Release|ARM64 - {23D2070D-E4AD-4ADD-85A7-083D9C76AD49}.Release|x64.ActiveCfg = Release|x64 - {23D2070D-E4AD-4ADD-85A7-083D9C76AD49}.Release|x64.Build.0 = Release|x64 - {62173D9A-6724-4C00-A1C8-FB646480A9EC}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {62173D9A-6724-4C00-A1C8-FB646480A9EC}.Debug|ARM64.Build.0 = Debug|ARM64 - {62173D9A-6724-4C00-A1C8-FB646480A9EC}.Debug|x64.ActiveCfg = Debug|x64 - {62173D9A-6724-4C00-A1C8-FB646480A9EC}.Debug|x64.Build.0 = Debug|x64 - {62173D9A-6724-4C00-A1C8-FB646480A9EC}.Release|ARM64.ActiveCfg = Release|ARM64 - {62173D9A-6724-4C00-A1C8-FB646480A9EC}.Release|ARM64.Build.0 = Release|ARM64 - {62173D9A-6724-4C00-A1C8-FB646480A9EC}.Release|x64.ActiveCfg = Release|x64 - {62173D9A-6724-4C00-A1C8-FB646480A9EC}.Release|x64.Build.0 = Release|x64 - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A}.Debug|ARM64.Build.0 = Debug|ARM64 - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A}.Debug|x64.ActiveCfg = Debug|x64 - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A}.Debug|x64.Build.0 = Debug|x64 - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A}.Release|ARM64.ActiveCfg = Release|ARM64 - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A}.Release|ARM64.Build.0 = Release|ARM64 - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A}.Release|x64.ActiveCfg = Release|x64 - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A}.Release|x64.Build.0 = Release|x64 - {D940E07F-532C-4FF3-883F-790DA014F19A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D940E07F-532C-4FF3-883F-790DA014F19A}.Debug|ARM64.Build.0 = Debug|ARM64 - {D940E07F-532C-4FF3-883F-790DA014F19A}.Debug|x64.ActiveCfg = Debug|x64 - {D940E07F-532C-4FF3-883F-790DA014F19A}.Debug|x64.Build.0 = Debug|x64 - {D940E07F-532C-4FF3-883F-790DA014F19A}.Release|ARM64.ActiveCfg = Release|ARM64 - {D940E07F-532C-4FF3-883F-790DA014F19A}.Release|ARM64.Build.0 = Release|ARM64 - {D940E07F-532C-4FF3-883F-790DA014F19A}.Release|x64.ActiveCfg = Release|x64 - {D940E07F-532C-4FF3-883F-790DA014F19A}.Release|x64.Build.0 = Release|x64 - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}.Debug|ARM64.Build.0 = Debug|ARM64 - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}.Debug|x64.ActiveCfg = Debug|x64 - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}.Debug|x64.Build.0 = Debug|x64 - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}.Release|ARM64.ActiveCfg = Release|ARM64 - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}.Release|ARM64.Build.0 = Release|ARM64 - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}.Release|x64.ActiveCfg = Release|x64 - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}.Release|x64.Build.0 = Release|x64 - {3E424AD2-19E5-4AE6-B833-F53963EB5FC1}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {3E424AD2-19E5-4AE6-B833-F53963EB5FC1}.Debug|ARM64.Build.0 = Debug|ARM64 - {3E424AD2-19E5-4AE6-B833-F53963EB5FC1}.Debug|x64.ActiveCfg = Debug|x64 - {3E424AD2-19E5-4AE6-B833-F53963EB5FC1}.Debug|x64.Build.0 = Debug|x64 - {3E424AD2-19E5-4AE6-B833-F53963EB5FC1}.Release|ARM64.ActiveCfg = Release|ARM64 - {3E424AD2-19E5-4AE6-B833-F53963EB5FC1}.Release|ARM64.Build.0 = Release|ARM64 - {3E424AD2-19E5-4AE6-B833-F53963EB5FC1}.Release|x64.ActiveCfg = Release|x64 - {3E424AD2-19E5-4AE6-B833-F53963EB5FC1}.Release|x64.Build.0 = Release|x64 - {2D604C07-51FC-46BB-9EB7-75AECC7F5E81}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {2D604C07-51FC-46BB-9EB7-75AECC7F5E81}.Debug|ARM64.Build.0 = Debug|ARM64 - {2D604C07-51FC-46BB-9EB7-75AECC7F5E81}.Debug|x64.ActiveCfg = Debug|x64 - {2D604C07-51FC-46BB-9EB7-75AECC7F5E81}.Debug|x64.Build.0 = Debug|x64 - {2D604C07-51FC-46BB-9EB7-75AECC7F5E81}.Release|ARM64.ActiveCfg = Release|ARM64 - {2D604C07-51FC-46BB-9EB7-75AECC7F5E81}.Release|ARM64.Build.0 = Release|ARM64 - {2D604C07-51FC-46BB-9EB7-75AECC7F5E81}.Release|x64.ActiveCfg = Release|x64 - {2D604C07-51FC-46BB-9EB7-75AECC7F5E81}.Release|x64.Build.0 = Release|x64 - {2EDB3EB4-FA92-4BFF-B2D8-566584837231}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {2EDB3EB4-FA92-4BFF-B2D8-566584837231}.Debug|ARM64.Build.0 = Debug|ARM64 - {2EDB3EB4-FA92-4BFF-B2D8-566584837231}.Debug|x64.ActiveCfg = Debug|x64 - {2EDB3EB4-FA92-4BFF-B2D8-566584837231}.Debug|x64.Build.0 = Debug|x64 - {2EDB3EB4-FA92-4BFF-B2D8-566584837231}.Release|ARM64.ActiveCfg = Release|ARM64 - {2EDB3EB4-FA92-4BFF-B2D8-566584837231}.Release|ARM64.Build.0 = Release|ARM64 - {2EDB3EB4-FA92-4BFF-B2D8-566584837231}.Release|x64.ActiveCfg = Release|x64 - {2EDB3EB4-FA92-4BFF-B2D8-566584837231}.Release|x64.Build.0 = Release|x64 - {48804216-2A0E-4168-A6D8-9CD068D14227}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {48804216-2A0E-4168-A6D8-9CD068D14227}.Debug|ARM64.Build.0 = Debug|ARM64 - {48804216-2A0E-4168-A6D8-9CD068D14227}.Debug|x64.ActiveCfg = Debug|x64 - {48804216-2A0E-4168-A6D8-9CD068D14227}.Debug|x64.Build.0 = Debug|x64 - {48804216-2A0E-4168-A6D8-9CD068D14227}.Release|ARM64.ActiveCfg = Release|ARM64 - {48804216-2A0E-4168-A6D8-9CD068D14227}.Release|ARM64.Build.0 = Release|ARM64 - {48804216-2A0E-4168-A6D8-9CD068D14227}.Release|x64.ActiveCfg = Release|x64 - {48804216-2A0E-4168-A6D8-9CD068D14227}.Release|x64.Build.0 = Release|x64 - {FF1D7936-842A-4BBB-8BEA-E9FE796DE700}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {FF1D7936-842A-4BBB-8BEA-E9FE796DE700}.Debug|ARM64.Build.0 = Debug|ARM64 - {FF1D7936-842A-4BBB-8BEA-E9FE796DE700}.Debug|x64.ActiveCfg = Debug|x64 - {FF1D7936-842A-4BBB-8BEA-E9FE796DE700}.Debug|x64.Build.0 = Debug|x64 - {FF1D7936-842A-4BBB-8BEA-E9FE796DE700}.Release|ARM64.ActiveCfg = Release|ARM64 - {FF1D7936-842A-4BBB-8BEA-E9FE796DE700}.Release|ARM64.Build.0 = Release|ARM64 - {FF1D7936-842A-4BBB-8BEA-E9FE796DE700}.Release|x64.ActiveCfg = Release|x64 - {FF1D7936-842A-4BBB-8BEA-E9FE796DE700}.Release|x64.Build.0 = Release|x64 - {44CE9AE1-4390-42C5-BACC-0FD6B40AA203}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {44CE9AE1-4390-42C5-BACC-0FD6B40AA203}.Debug|ARM64.Build.0 = Debug|ARM64 - {44CE9AE1-4390-42C5-BACC-0FD6B40AA203}.Debug|x64.ActiveCfg = Debug|x64 - {44CE9AE1-4390-42C5-BACC-0FD6B40AA203}.Debug|x64.Build.0 = Debug|x64 - {44CE9AE1-4390-42C5-BACC-0FD6B40AA203}.Release|ARM64.ActiveCfg = Release|ARM64 - {44CE9AE1-4390-42C5-BACC-0FD6B40AA203}.Release|ARM64.Build.0 = Release|ARM64 - {44CE9AE1-4390-42C5-BACC-0FD6B40AA203}.Release|x64.ActiveCfg = Release|x64 - {44CE9AE1-4390-42C5-BACC-0FD6B40AA203}.Release|x64.Build.0 = Release|x64 - {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Debug|ARM64.Build.0 = Debug|ARM64 - {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Debug|x64.ActiveCfg = Debug|x64 - {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Debug|x64.Build.0 = Debug|x64 - {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|ARM64.ActiveCfg = Release|ARM64 - {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|ARM64.Build.0 = Release|ARM64 - {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|x64.ActiveCfg = Release|x64 - {5043CECE-E6A7-4867-9CBE-02D27D83747A}.Release|x64.Build.0 = Release|x64 - {11491FD8-F921-48BF-880C-7FEA185B80A1}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {11491FD8-F921-48BF-880C-7FEA185B80A1}.Debug|ARM64.Build.0 = Debug|ARM64 - {11491FD8-F921-48BF-880C-7FEA185B80A1}.Debug|x64.ActiveCfg = Debug|x64 - {11491FD8-F921-48BF-880C-7FEA185B80A1}.Debug|x64.Build.0 = Debug|x64 - {11491FD8-F921-48BF-880C-7FEA185B80A1}.Release|ARM64.ActiveCfg = Release|ARM64 - {11491FD8-F921-48BF-880C-7FEA185B80A1}.Release|ARM64.Build.0 = Release|ARM64 - {11491FD8-F921-48BF-880C-7FEA185B80A1}.Release|x64.ActiveCfg = Release|x64 - {11491FD8-F921-48BF-880C-7FEA185B80A1}.Release|x64.Build.0 = Release|x64 - {F40C3397-1834-4530-B2D9-8F8B8456BCDF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {F40C3397-1834-4530-B2D9-8F8B8456BCDF}.Debug|ARM64.Build.0 = Debug|ARM64 - {F40C3397-1834-4530-B2D9-8F8B8456BCDF}.Debug|x64.ActiveCfg = Debug|x64 - {F40C3397-1834-4530-B2D9-8F8B8456BCDF}.Debug|x64.Build.0 = Debug|x64 - {F40C3397-1834-4530-B2D9-8F8B8456BCDF}.Release|ARM64.ActiveCfg = Release|ARM64 - {F40C3397-1834-4530-B2D9-8F8B8456BCDF}.Release|ARM64.Build.0 = Release|ARM64 - {F40C3397-1834-4530-B2D9-8F8B8456BCDF}.Release|x64.ActiveCfg = Release|x64 - {F40C3397-1834-4530-B2D9-8F8B8456BCDF}.Release|x64.Build.0 = Release|x64 - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85}.Debug|ARM64.Build.0 = Debug|ARM64 - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85}.Debug|x64.ActiveCfg = Debug|x64 - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85}.Debug|x64.Build.0 = Debug|x64 - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85}.Release|ARM64.ActiveCfg = Release|ARM64 - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85}.Release|ARM64.Build.0 = Release|ARM64 - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85}.Release|x64.ActiveCfg = Release|x64 - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85}.Release|x64.Build.0 = Release|x64 - {4ED320BC-BA04-4D42-8D15-CBE62151F08B}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {4ED320BC-BA04-4D42-8D15-CBE62151F08B}.Debug|ARM64.Build.0 = Debug|ARM64 - {4ED320BC-BA04-4D42-8D15-CBE62151F08B}.Debug|x64.ActiveCfg = Debug|x64 - {4ED320BC-BA04-4D42-8D15-CBE62151F08B}.Debug|x64.Build.0 = Debug|x64 - {4ED320BC-BA04-4D42-8D15-CBE62151F08B}.Release|ARM64.ActiveCfg = Release|ARM64 - {4ED320BC-BA04-4D42-8D15-CBE62151F08B}.Release|ARM64.Build.0 = Release|ARM64 - {4ED320BC-BA04-4D42-8D15-CBE62151F08B}.Release|x64.ActiveCfg = Release|x64 - {4ED320BC-BA04-4D42-8D15-CBE62151F08B}.Release|x64.Build.0 = Release|x64 - {E94FD11C-0591-456F-899F-EFC0CA548336}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E94FD11C-0591-456F-899F-EFC0CA548336}.Debug|ARM64.Build.0 = Debug|ARM64 - {E94FD11C-0591-456F-899F-EFC0CA548336}.Debug|x64.ActiveCfg = Debug|x64 - {E94FD11C-0591-456F-899F-EFC0CA548336}.Debug|x64.Build.0 = Debug|x64 - {E94FD11C-0591-456F-899F-EFC0CA548336}.Release|ARM64.ActiveCfg = Release|ARM64 - {E94FD11C-0591-456F-899F-EFC0CA548336}.Release|ARM64.Build.0 = Release|ARM64 - {E94FD11C-0591-456F-899F-EFC0CA548336}.Release|x64.ActiveCfg = Release|x64 - {E94FD11C-0591-456F-899F-EFC0CA548336}.Release|x64.Build.0 = Release|x64 - {782A61BE-9D85-4081-B35C-1CCC9DCC1E88}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {782A61BE-9D85-4081-B35C-1CCC9DCC1E88}.Debug|ARM64.Build.0 = Debug|ARM64 - {782A61BE-9D85-4081-B35C-1CCC9DCC1E88}.Debug|x64.ActiveCfg = Debug|x64 - {782A61BE-9D85-4081-B35C-1CCC9DCC1E88}.Debug|x64.Build.0 = Debug|x64 - {782A61BE-9D85-4081-B35C-1CCC9DCC1E88}.Release|ARM64.ActiveCfg = Release|ARM64 - {782A61BE-9D85-4081-B35C-1CCC9DCC1E88}.Release|ARM64.Build.0 = Release|ARM64 - {782A61BE-9D85-4081-B35C-1CCC9DCC1E88}.Release|x64.ActiveCfg = Release|x64 - {782A61BE-9D85-4081-B35C-1CCC9DCC1E88}.Release|x64.Build.0 = Release|x64 - {809AA252-E17A-4FA2-B0A1-0450976B763F}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {809AA252-E17A-4FA2-B0A1-0450976B763F}.Debug|ARM64.Build.0 = Debug|ARM64 - {809AA252-E17A-4FA2-B0A1-0450976B763F}.Debug|x64.ActiveCfg = Debug|x64 - {809AA252-E17A-4FA2-B0A1-0450976B763F}.Debug|x64.Build.0 = Debug|x64 - {809AA252-E17A-4FA2-B0A1-0450976B763F}.Release|ARM64.ActiveCfg = Release|ARM64 - {809AA252-E17A-4FA2-B0A1-0450976B763F}.Release|ARM64.Build.0 = Release|ARM64 - {809AA252-E17A-4FA2-B0A1-0450976B763F}.Release|x64.ActiveCfg = Release|x64 - {809AA252-E17A-4FA2-B0A1-0450976B763F}.Release|x64.Build.0 = Release|x64 - {133281D8-1BCE-4D07-B31E-796612A9609E}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {133281D8-1BCE-4D07-B31E-796612A9609E}.Debug|ARM64.Build.0 = Debug|ARM64 - {133281D8-1BCE-4D07-B31E-796612A9609E}.Debug|x64.ActiveCfg = Debug|x64 - {133281D8-1BCE-4D07-B31E-796612A9609E}.Debug|x64.Build.0 = Debug|x64 - {133281D8-1BCE-4D07-B31E-796612A9609E}.Release|ARM64.ActiveCfg = Release|ARM64 - {133281D8-1BCE-4D07-B31E-796612A9609E}.Release|ARM64.Build.0 = Release|ARM64 - {133281D8-1BCE-4D07-B31E-796612A9609E}.Release|x64.ActiveCfg = Release|x64 - {133281D8-1BCE-4D07-B31E-796612A9609E}.Release|x64.Build.0 = Release|x64 - {805306FF-A562-4415-8DEF-E493BDC45918}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {805306FF-A562-4415-8DEF-E493BDC45918}.Debug|ARM64.Build.0 = Debug|ARM64 - {805306FF-A562-4415-8DEF-E493BDC45918}.Debug|x64.ActiveCfg = Debug|x64 - {805306FF-A562-4415-8DEF-E493BDC45918}.Debug|x64.Build.0 = Debug|x64 - {805306FF-A562-4415-8DEF-E493BDC45918}.Release|ARM64.ActiveCfg = Release|ARM64 - {805306FF-A562-4415-8DEF-E493BDC45918}.Release|ARM64.Build.0 = Release|ARM64 - {805306FF-A562-4415-8DEF-E493BDC45918}.Release|x64.ActiveCfg = Release|x64 - {805306FF-A562-4415-8DEF-E493BDC45918}.Release|x64.Build.0 = Release|x64 - {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Debug|ARM64.Build.0 = Debug|ARM64 - {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Debug|x64.ActiveCfg = Debug|x64 - {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Debug|x64.Build.0 = Debug|x64 - {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Release|ARM64.ActiveCfg = Release|ARM64 - {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Release|ARM64.Build.0 = Release|ARM64 - {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Release|x64.ActiveCfg = Release|x64 - {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Release|x64.Build.0 = Release|x64 - {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Debug|ARM64.Build.0 = Debug|ARM64 - {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Debug|x64.ActiveCfg = Debug|x64 - {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Debug|x64.Build.0 = Debug|x64 - {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Release|ARM64.ActiveCfg = Release|ARM64 - {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Release|ARM64.Build.0 = Release|ARM64 - {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Release|x64.ActiveCfg = Release|x64 - {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Release|x64.Build.0 = Release|x64 - {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Debug|ARM64.Build.0 = Debug|ARM64 - {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Debug|x64.ActiveCfg = Debug|x64 - {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Debug|x64.Build.0 = Debug|x64 - {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Release|ARM64.ActiveCfg = Release|ARM64 - {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Release|ARM64.Build.0 = Release|ARM64 - {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Release|x64.ActiveCfg = Release|x64 - {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Release|x64.Build.0 = Release|x64 - {9F94B303-5E21-4364-9362-64426F8DB932}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {9F94B303-5E21-4364-9362-64426F8DB932}.Debug|ARM64.Build.0 = Debug|ARM64 - {9F94B303-5E21-4364-9362-64426F8DB932}.Debug|x64.ActiveCfg = Debug|x64 - {9F94B303-5E21-4364-9362-64426F8DB932}.Debug|x64.Build.0 = Debug|x64 - {9F94B303-5E21-4364-9362-64426F8DB932}.Release|ARM64.ActiveCfg = Release|ARM64 - {9F94B303-5E21-4364-9362-64426F8DB932}.Release|ARM64.Build.0 = Release|ARM64 - {9F94B303-5E21-4364-9362-64426F8DB932}.Release|x64.ActiveCfg = Release|x64 - {9F94B303-5E21-4364-9362-64426F8DB932}.Release|x64.Build.0 = Release|x64 - {EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E}.Debug|ARM64.Build.0 = Debug|ARM64 - {EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E}.Debug|x64.ActiveCfg = Debug|x64 - {EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E}.Debug|x64.Build.0 = Debug|x64 - {EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E}.Release|ARM64.ActiveCfg = Release|ARM64 - {EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E}.Release|ARM64.Build.0 = Release|ARM64 - {EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E}.Release|x64.ActiveCfg = Release|x64 - {EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E}.Release|x64.Build.0 = Release|x64 - {F7C8C0F1-5431-4347-89D0-8E5354F93CF2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {F7C8C0F1-5431-4347-89D0-8E5354F93CF2}.Debug|ARM64.Build.0 = Debug|ARM64 - {F7C8C0F1-5431-4347-89D0-8E5354F93CF2}.Debug|x64.ActiveCfg = Debug|x64 - {F7C8C0F1-5431-4347-89D0-8E5354F93CF2}.Debug|x64.Build.0 = Debug|x64 - {F7C8C0F1-5431-4347-89D0-8E5354F93CF2}.Release|ARM64.ActiveCfg = Release|ARM64 - {F7C8C0F1-5431-4347-89D0-8E5354F93CF2}.Release|ARM64.Build.0 = Release|ARM64 - {F7C8C0F1-5431-4347-89D0-8E5354F93CF2}.Release|x64.ActiveCfg = Release|x64 - {F7C8C0F1-5431-4347-89D0-8E5354F93CF2}.Release|x64.Build.0 = Release|x64 - {F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC}.Debug|ARM64.Build.0 = Debug|ARM64 - {F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC}.Debug|x64.ActiveCfg = Debug|x64 - {F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC}.Debug|x64.Build.0 = Debug|x64 - {F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC}.Release|ARM64.ActiveCfg = Release|ARM64 - {F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC}.Release|ARM64.Build.0 = Release|ARM64 - {F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC}.Release|x64.ActiveCfg = Release|x64 - {F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC}.Release|x64.Build.0 = Release|x64 - {04B193D7-3E21-46B8-A958-89B63A8A69DE}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {04B193D7-3E21-46B8-A958-89B63A8A69DE}.Debug|ARM64.Build.0 = Debug|ARM64 - {04B193D7-3E21-46B8-A958-89B63A8A69DE}.Debug|x64.ActiveCfg = Debug|x64 - {04B193D7-3E21-46B8-A958-89B63A8A69DE}.Debug|x64.Build.0 = Debug|x64 - {04B193D7-3E21-46B8-A958-89B63A8A69DE}.Release|ARM64.ActiveCfg = Release|ARM64 - {04B193D7-3E21-46B8-A958-89B63A8A69DE}.Release|ARM64.Build.0 = Release|ARM64 - {04B193D7-3E21-46B8-A958-89B63A8A69DE}.Release|x64.ActiveCfg = Release|x64 - {04B193D7-3E21-46B8-A958-89B63A8A69DE}.Release|x64.Build.0 = Release|x64 - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF}.Debug|ARM64.Build.0 = Debug|ARM64 - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF}.Debug|x64.ActiveCfg = Debug|x64 - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF}.Debug|x64.Build.0 = Debug|x64 - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF}.Release|ARM64.ActiveCfg = Release|ARM64 - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF}.Release|ARM64.Build.0 = Release|ARM64 - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF}.Release|x64.ActiveCfg = Release|x64 - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF}.Release|x64.Build.0 = Release|x64 - {FD464B4C-2F68-4D06-91E7-4208146C41F5}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {FD464B4C-2F68-4D06-91E7-4208146C41F5}.Debug|ARM64.Build.0 = Debug|ARM64 - {FD464B4C-2F68-4D06-91E7-4208146C41F5}.Debug|x64.ActiveCfg = Debug|x64 - {FD464B4C-2F68-4D06-91E7-4208146C41F5}.Debug|x64.Build.0 = Debug|x64 - {FD464B4C-2F68-4D06-91E7-4208146C41F5}.Release|ARM64.ActiveCfg = Release|ARM64 - {FD464B4C-2F68-4D06-91E7-4208146C41F5}.Release|ARM64.Build.0 = Release|ARM64 - {FD464B4C-2F68-4D06-91E7-4208146C41F5}.Release|x64.ActiveCfg = Release|x64 - {FD464B4C-2F68-4D06-91E7-4208146C41F5}.Release|x64.Build.0 = Release|x64 - {8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1}.Debug|ARM64.Build.0 = Debug|ARM64 - {8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1}.Debug|x64.ActiveCfg = Debug|x64 - {8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1}.Debug|x64.Build.0 = Debug|x64 - {8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1}.Release|ARM64.ActiveCfg = Release|ARM64 - {8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1}.Release|ARM64.Build.0 = Release|ARM64 - {8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1}.Release|x64.ActiveCfg = Release|x64 - {8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1}.Release|x64.Build.0 = Release|x64 - {020A7474-3601-4160-A159-D7B70B77B15F}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {020A7474-3601-4160-A159-D7B70B77B15F}.Debug|ARM64.Build.0 = Debug|ARM64 - {020A7474-3601-4160-A159-D7B70B77B15F}.Debug|x64.ActiveCfg = Debug|x64 - {020A7474-3601-4160-A159-D7B70B77B15F}.Debug|x64.Build.0 = Debug|x64 - {020A7474-3601-4160-A159-D7B70B77B15F}.Release|ARM64.ActiveCfg = Release|ARM64 - {020A7474-3601-4160-A159-D7B70B77B15F}.Release|ARM64.Build.0 = Release|ARM64 - {020A7474-3601-4160-A159-D7B70B77B15F}.Release|x64.ActiveCfg = Release|x64 - {020A7474-3601-4160-A159-D7B70B77B15F}.Release|x64.Build.0 = Release|x64 - {27718999-C175-450A-861C-89F911E16A88}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {27718999-C175-450A-861C-89F911E16A88}.Debug|ARM64.Build.0 = Debug|ARM64 - {27718999-C175-450A-861C-89F911E16A88}.Debug|x64.ActiveCfg = Debug|x64 - {27718999-C175-450A-861C-89F911E16A88}.Debug|x64.Build.0 = Debug|x64 - {27718999-C175-450A-861C-89F911E16A88}.Release|ARM64.ActiveCfg = Release|ARM64 - {27718999-C175-450A-861C-89F911E16A88}.Release|ARM64.Build.0 = Release|ARM64 - {27718999-C175-450A-861C-89F911E16A88}.Release|x64.ActiveCfg = Release|x64 - {27718999-C175-450A-861C-89F911E16A88}.Release|x64.Build.0 = Release|x64 - {1DBBB112-4BB1-444B-8EBB-E66555C76BA6}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {1DBBB112-4BB1-444B-8EBB-E66555C76BA6}.Debug|ARM64.Build.0 = Debug|ARM64 - {1DBBB112-4BB1-444B-8EBB-E66555C76BA6}.Debug|x64.ActiveCfg = Debug|x64 - {1DBBB112-4BB1-444B-8EBB-E66555C76BA6}.Debug|x64.Build.0 = Debug|x64 - {1DBBB112-4BB1-444B-8EBB-E66555C76BA6}.Release|ARM64.ActiveCfg = Release|ARM64 - {1DBBB112-4BB1-444B-8EBB-E66555C76BA6}.Release|ARM64.Build.0 = Release|ARM64 - {1DBBB112-4BB1-444B-8EBB-E66555C76BA6}.Release|x64.ActiveCfg = Release|x64 - {1DBBB112-4BB1-444B-8EBB-E66555C76BA6}.Release|x64.Build.0 = Release|x64 - {5A1DB2F0-0715-4B3B-98E6-79BC41540045}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {5A1DB2F0-0715-4B3B-98E6-79BC41540045}.Debug|ARM64.Build.0 = Debug|ARM64 - {5A1DB2F0-0715-4B3B-98E6-79BC41540045}.Debug|x64.ActiveCfg = Debug|x64 - {5A1DB2F0-0715-4B3B-98E6-79BC41540045}.Debug|x64.Build.0 = Debug|x64 - {5A1DB2F0-0715-4B3B-98E6-79BC41540045}.Release|ARM64.ActiveCfg = Release|ARM64 - {5A1DB2F0-0715-4B3B-98E6-79BC41540045}.Release|ARM64.Build.0 = Release|ARM64 - {5A1DB2F0-0715-4B3B-98E6-79BC41540045}.Release|x64.ActiveCfg = Release|x64 - {5A1DB2F0-0715-4B3B-98E6-79BC41540045}.Release|x64.Build.0 = Release|x64 - {93B72A06-C8BD-484F-A6F7-C9F280B150BF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {93B72A06-C8BD-484F-A6F7-C9F280B150BF}.Debug|ARM64.Build.0 = Debug|ARM64 - {93B72A06-C8BD-484F-A6F7-C9F280B150BF}.Debug|x64.ActiveCfg = Debug|x64 - {93B72A06-C8BD-484F-A6F7-C9F280B150BF}.Debug|x64.Build.0 = Debug|x64 - {93B72A06-C8BD-484F-A6F7-C9F280B150BF}.Release|ARM64.ActiveCfg = Release|ARM64 - {93B72A06-C8BD-484F-A6F7-C9F280B150BF}.Release|ARM64.Build.0 = Release|ARM64 - {93B72A06-C8BD-484F-A6F7-C9F280B150BF}.Release|x64.ActiveCfg = Release|x64 - {93B72A06-C8BD-484F-A6F7-C9F280B150BF}.Release|x64.Build.0 = Release|x64 - {18B3DB45-4FFE-4D01-97D6-5223FEEE1853}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {18B3DB45-4FFE-4D01-97D6-5223FEEE1853}.Debug|ARM64.Build.0 = Debug|ARM64 - {18B3DB45-4FFE-4D01-97D6-5223FEEE1853}.Debug|x64.ActiveCfg = Debug|x64 - {18B3DB45-4FFE-4D01-97D6-5223FEEE1853}.Debug|x64.Build.0 = Debug|x64 - {18B3DB45-4FFE-4D01-97D6-5223FEEE1853}.Release|ARM64.ActiveCfg = Release|ARM64 - {18B3DB45-4FFE-4D01-97D6-5223FEEE1853}.Release|ARM64.Build.0 = Release|ARM64 - {18B3DB45-4FFE-4D01-97D6-5223FEEE1853}.Release|x64.ActiveCfg = Release|x64 - {18B3DB45-4FFE-4D01-97D6-5223FEEE1853}.Release|x64.Build.0 = Release|x64 - {34A354C5-23C7-4343-916C-C52DAF4FC39D}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {34A354C5-23C7-4343-916C-C52DAF4FC39D}.Debug|ARM64.Build.0 = Debug|ARM64 - {34A354C5-23C7-4343-916C-C52DAF4FC39D}.Debug|x64.ActiveCfg = Debug|x64 - {34A354C5-23C7-4343-916C-C52DAF4FC39D}.Debug|x64.Build.0 = Debug|x64 - {34A354C5-23C7-4343-916C-C52DAF4FC39D}.Release|ARM64.ActiveCfg = Release|ARM64 - {34A354C5-23C7-4343-916C-C52DAF4FC39D}.Release|ARM64.Build.0 = Release|ARM64 - {34A354C5-23C7-4343-916C-C52DAF4FC39D}.Release|x64.ActiveCfg = Release|x64 - {34A354C5-23C7-4343-916C-C52DAF4FC39D}.Release|x64.Build.0 = Release|x64 - {3264DF53-C805-4B0C-867C-FCEAF7AEF762}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {3264DF53-C805-4B0C-867C-FCEAF7AEF762}.Debug|ARM64.Build.0 = Debug|ARM64 - {3264DF53-C805-4B0C-867C-FCEAF7AEF762}.Debug|x64.ActiveCfg = Debug|x64 - {3264DF53-C805-4B0C-867C-FCEAF7AEF762}.Debug|x64.Build.0 = Debug|x64 - {3264DF53-C805-4B0C-867C-FCEAF7AEF762}.Release|ARM64.ActiveCfg = Release|ARM64 - {3264DF53-C805-4B0C-867C-FCEAF7AEF762}.Release|ARM64.Build.0 = Release|ARM64 - {3264DF53-C805-4B0C-867C-FCEAF7AEF762}.Release|x64.ActiveCfg = Release|x64 - {3264DF53-C805-4B0C-867C-FCEAF7AEF762}.Release|x64.Build.0 = Release|x64 - {31CAD28E-778A-441C-85BC-40AB3EAA2A10}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {31CAD28E-778A-441C-85BC-40AB3EAA2A10}.Debug|ARM64.Build.0 = Debug|ARM64 - {31CAD28E-778A-441C-85BC-40AB3EAA2A10}.Debug|x64.ActiveCfg = Debug|x64 - {31CAD28E-778A-441C-85BC-40AB3EAA2A10}.Debug|x64.Build.0 = Debug|x64 - {31CAD28E-778A-441C-85BC-40AB3EAA2A10}.Release|ARM64.ActiveCfg = Release|ARM64 - {31CAD28E-778A-441C-85BC-40AB3EAA2A10}.Release|ARM64.Build.0 = Release|ARM64 - {31CAD28E-778A-441C-85BC-40AB3EAA2A10}.Release|x64.ActiveCfg = Release|x64 - {31CAD28E-778A-441C-85BC-40AB3EAA2A10}.Release|x64.Build.0 = Release|x64 - {25C91A4E-BA4E-467A-85CD-8B62545BF674}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {25C91A4E-BA4E-467A-85CD-8B62545BF674}.Debug|ARM64.Build.0 = Debug|ARM64 - {25C91A4E-BA4E-467A-85CD-8B62545BF674}.Debug|x64.ActiveCfg = Debug|x64 - {25C91A4E-BA4E-467A-85CD-8B62545BF674}.Debug|x64.Build.0 = Debug|x64 - {25C91A4E-BA4E-467A-85CD-8B62545BF674}.Release|ARM64.ActiveCfg = Release|ARM64 - {25C91A4E-BA4E-467A-85CD-8B62545BF674}.Release|ARM64.Build.0 = Release|ARM64 - {25C91A4E-BA4E-467A-85CD-8B62545BF674}.Release|x64.ActiveCfg = Release|x64 - {25C91A4E-BA4E-467A-85CD-8B62545BF674}.Release|x64.Build.0 = Release|x64 - {6AB6A2D6-F859-4A82-9184-0BD29C9F07D1}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {6AB6A2D6-F859-4A82-9184-0BD29C9F07D1}.Debug|ARM64.Build.0 = Debug|ARM64 - {6AB6A2D6-F859-4A82-9184-0BD29C9F07D1}.Debug|x64.ActiveCfg = Debug|x64 - {6AB6A2D6-F859-4A82-9184-0BD29C9F07D1}.Debug|x64.Build.0 = Debug|x64 - {6AB6A2D6-F859-4A82-9184-0BD29C9F07D1}.Release|ARM64.ActiveCfg = Release|ARM64 - {6AB6A2D6-F859-4A82-9184-0BD29C9F07D1}.Release|ARM64.Build.0 = Release|ARM64 - {6AB6A2D6-F859-4A82-9184-0BD29C9F07D1}.Release|x64.ActiveCfg = Release|x64 - {6AB6A2D6-F859-4A82-9184-0BD29C9F07D1}.Release|x64.Build.0 = Release|x64 - {212AD910-8488-4036-BE20-326931B75FB2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {212AD910-8488-4036-BE20-326931B75FB2}.Debug|ARM64.Build.0 = Debug|ARM64 - {212AD910-8488-4036-BE20-326931B75FB2}.Debug|x64.ActiveCfg = Debug|x64 - {212AD910-8488-4036-BE20-326931B75FB2}.Debug|x64.Build.0 = Debug|x64 - {212AD910-8488-4036-BE20-326931B75FB2}.Release|ARM64.ActiveCfg = Release|ARM64 - {212AD910-8488-4036-BE20-326931B75FB2}.Release|ARM64.Build.0 = Release|ARM64 - {212AD910-8488-4036-BE20-326931B75FB2}.Release|x64.ActiveCfg = Release|x64 - {212AD910-8488-4036-BE20-326931B75FB2}.Release|x64.Build.0 = Release|x64 - {54A93AF7-60C7-4F6C-99D2-FBB1F75F853A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {54A93AF7-60C7-4F6C-99D2-FBB1F75F853A}.Debug|ARM64.Build.0 = Debug|ARM64 - {54A93AF7-60C7-4F6C-99D2-FBB1F75F853A}.Debug|x64.ActiveCfg = Debug|x64 - {54A93AF7-60C7-4F6C-99D2-FBB1F75F853A}.Debug|x64.Build.0 = Debug|x64 - {54A93AF7-60C7-4F6C-99D2-FBB1F75F853A}.Release|ARM64.ActiveCfg = Release|ARM64 - {54A93AF7-60C7-4F6C-99D2-FBB1F75F853A}.Release|ARM64.Build.0 = Release|ARM64 - {54A93AF7-60C7-4F6C-99D2-FBB1F75F853A}.Release|x64.ActiveCfg = Release|x64 - {54A93AF7-60C7-4F6C-99D2-FBB1F75F853A}.Release|x64.Build.0 = Release|x64 - {92C39820-9F84-4529-BC7D-22AAE514D63B}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {92C39820-9F84-4529-BC7D-22AAE514D63B}.Debug|ARM64.Build.0 = Debug|ARM64 - {92C39820-9F84-4529-BC7D-22AAE514D63B}.Debug|x64.ActiveCfg = Debug|x64 - {92C39820-9F84-4529-BC7D-22AAE514D63B}.Debug|x64.Build.0 = Debug|x64 - {92C39820-9F84-4529-BC7D-22AAE514D63B}.Release|ARM64.ActiveCfg = Release|ARM64 - {92C39820-9F84-4529-BC7D-22AAE514D63B}.Release|ARM64.Build.0 = Release|ARM64 - {92C39820-9F84-4529-BC7D-22AAE514D63B}.Release|x64.ActiveCfg = Release|x64 - {92C39820-9F84-4529-BC7D-22AAE514D63B}.Release|x64.Build.0 = Release|x64 - {515554D1-D004-4F7F-A107-2211FC0F6B2C}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {515554D1-D004-4F7F-A107-2211FC0F6B2C}.Debug|ARM64.Build.0 = Debug|ARM64 - {515554D1-D004-4F7F-A107-2211FC0F6B2C}.Debug|x64.ActiveCfg = Debug|x64 - {515554D1-D004-4F7F-A107-2211FC0F6B2C}.Debug|x64.Build.0 = Debug|x64 - {515554D1-D004-4F7F-A107-2211FC0F6B2C}.Release|ARM64.ActiveCfg = Release|ARM64 - {515554D1-D004-4F7F-A107-2211FC0F6B2C}.Release|ARM64.Build.0 = Release|ARM64 - {515554D1-D004-4F7F-A107-2211FC0F6B2C}.Release|x64.ActiveCfg = Release|x64 - {515554D1-D004-4F7F-A107-2211FC0F6B2C}.Release|x64.Build.0 = Release|x64 - {C97D9A5D-206C-454E-997E-009E227D7F02}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C97D9A5D-206C-454E-997E-009E227D7F02}.Debug|ARM64.Build.0 = Debug|ARM64 - {C97D9A5D-206C-454E-997E-009E227D7F02}.Debug|x64.ActiveCfg = Debug|x64 - {C97D9A5D-206C-454E-997E-009E227D7F02}.Debug|x64.Build.0 = Debug|x64 - {C97D9A5D-206C-454E-997E-009E227D7F02}.Release|ARM64.ActiveCfg = Release|ARM64 - {C97D9A5D-206C-454E-997E-009E227D7F02}.Release|ARM64.Build.0 = Release|ARM64 - {C97D9A5D-206C-454E-997E-009E227D7F02}.Release|x64.ActiveCfg = Release|x64 - {C97D9A5D-206C-454E-997E-009E227D7F02}.Release|x64.Build.0 = Release|x64 - {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|ARM64.ActiveCfg = Debug|Any CPU - {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|ARM64.Build.0 = Debug|Any CPU - {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|x64.ActiveCfg = Debug|Any CPU - {31D1C81D-765F-4446-AA62-E743F6325049}.Debug|x64.Build.0 = Debug|Any CPU - {31D1C81D-765F-4446-AA62-E743F6325049}.Release|ARM64.ActiveCfg = Release|Any CPU - {31D1C81D-765F-4446-AA62-E743F6325049}.Release|ARM64.Build.0 = Release|Any CPU - {31D1C81D-765F-4446-AA62-E743F6325049}.Release|x64.ActiveCfg = Release|Any CPU - {31D1C81D-765F-4446-AA62-E743F6325049}.Release|x64.Build.0 = Release|Any CPU - {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|ARM64.Build.0 = Debug|ARM64 - {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|x64.ActiveCfg = Debug|x64 - {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Debug|x64.Build.0 = Debug|x64 - {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|ARM64.ActiveCfg = Release|ARM64 - {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|ARM64.Build.0 = Release|ARM64 - {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|x64.ActiveCfg = Release|x64 - {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A}.Release|x64.Build.0 = Release|x64 - {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|ARM64.Build.0 = Debug|ARM64 - {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|x64.ActiveCfg = Debug|x64 - {B41B888C-7DB8-4747-B262-4062E05A230D}.Debug|x64.Build.0 = Debug|x64 - {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|ARM64.ActiveCfg = Release|ARM64 - {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|ARM64.Build.0 = Release|ARM64 - {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|x64.ActiveCfg = Release|x64 - {B41B888C-7DB8-4747-B262-4062E05A230D}.Release|x64.Build.0 = Release|x64 - {57175EC7-92A5-4C1E-8244-E3FBCA2A81DE}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {57175EC7-92A5-4C1E-8244-E3FBCA2A81DE}.Debug|ARM64.Build.0 = Debug|ARM64 - {57175EC7-92A5-4C1E-8244-E3FBCA2A81DE}.Debug|x64.ActiveCfg = Debug|x64 - {57175EC7-92A5-4C1E-8244-E3FBCA2A81DE}.Debug|x64.Build.0 = Debug|x64 - {57175EC7-92A5-4C1E-8244-E3FBCA2A81DE}.Release|ARM64.ActiveCfg = Release|ARM64 - {57175EC7-92A5-4C1E-8244-E3FBCA2A81DE}.Release|ARM64.Build.0 = Release|ARM64 - {57175EC7-92A5-4C1E-8244-E3FBCA2A81DE}.Release|x64.ActiveCfg = Release|x64 - {57175EC7-92A5-4C1E-8244-E3FBCA2A81DE}.Release|x64.Build.0 = Release|x64 - {E69B044A-2F8A-45AA-AD0B-256C59421807}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E69B044A-2F8A-45AA-AD0B-256C59421807}.Debug|ARM64.Build.0 = Debug|ARM64 - {E69B044A-2F8A-45AA-AD0B-256C59421807}.Debug|x64.ActiveCfg = Debug|x64 - {E69B044A-2F8A-45AA-AD0B-256C59421807}.Debug|x64.Build.0 = Debug|x64 - {E69B044A-2F8A-45AA-AD0B-256C59421807}.Release|ARM64.ActiveCfg = Release|ARM64 - {E69B044A-2F8A-45AA-AD0B-256C59421807}.Release|ARM64.Build.0 = Release|ARM64 - {E69B044A-2F8A-45AA-AD0B-256C59421807}.Release|x64.ActiveCfg = Release|x64 - {E69B044A-2F8A-45AA-AD0B-256C59421807}.Release|x64.Build.0 = Release|x64 - {C604B37E-9D0E-4484-8778-E8B31B0E1B3A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C604B37E-9D0E-4484-8778-E8B31B0E1B3A}.Debug|ARM64.Build.0 = Debug|ARM64 - {C604B37E-9D0E-4484-8778-E8B31B0E1B3A}.Debug|x64.ActiveCfg = Debug|x64 - {C604B37E-9D0E-4484-8778-E8B31B0E1B3A}.Debug|x64.Build.0 = Debug|x64 - {C604B37E-9D0E-4484-8778-E8B31B0E1B3A}.Release|ARM64.ActiveCfg = Release|ARM64 - {C604B37E-9D0E-4484-8778-E8B31B0E1B3A}.Release|ARM64.Build.0 = Release|ARM64 - {C604B37E-9D0E-4484-8778-E8B31B0E1B3A}.Release|x64.ActiveCfg = Release|x64 - {C604B37E-9D0E-4484-8778-E8B31B0E1B3A}.Release|x64.Build.0 = Release|x64 - {E599C30B-9DC8-4E5A-BF27-93D4CCEDE788}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E599C30B-9DC8-4E5A-BF27-93D4CCEDE788}.Debug|ARM64.Build.0 = Debug|ARM64 - {E599C30B-9DC8-4E5A-BF27-93D4CCEDE788}.Debug|x64.ActiveCfg = Debug|x64 - {E599C30B-9DC8-4E5A-BF27-93D4CCEDE788}.Debug|x64.Build.0 = Debug|x64 - {E599C30B-9DC8-4E5A-BF27-93D4CCEDE788}.Release|ARM64.ActiveCfg = Release|ARM64 - {E599C30B-9DC8-4E5A-BF27-93D4CCEDE788}.Release|ARM64.Build.0 = Release|ARM64 - {E599C30B-9DC8-4E5A-BF27-93D4CCEDE788}.Release|x64.ActiveCfg = Release|x64 - {E599C30B-9DC8-4E5A-BF27-93D4CCEDE788}.Release|x64.Build.0 = Release|x64 - {00EE9BA6-4E8F-43CA-960D-D4882F0FBB97}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {00EE9BA6-4E8F-43CA-960D-D4882F0FBB97}.Debug|ARM64.Build.0 = Debug|ARM64 - {00EE9BA6-4E8F-43CA-960D-D4882F0FBB97}.Debug|x64.ActiveCfg = Debug|x64 - {00EE9BA6-4E8F-43CA-960D-D4882F0FBB97}.Debug|x64.Build.0 = Debug|x64 - {00EE9BA6-4E8F-43CA-960D-D4882F0FBB97}.Release|ARM64.ActiveCfg = Release|ARM64 - {00EE9BA6-4E8F-43CA-960D-D4882F0FBB97}.Release|ARM64.Build.0 = Release|ARM64 - {00EE9BA6-4E8F-43CA-960D-D4882F0FBB97}.Release|x64.ActiveCfg = Release|x64 - {00EE9BA6-4E8F-43CA-960D-D4882F0FBB97}.Release|x64.Build.0 = Release|x64 - {A1425B53-3D61-4679-8623-E64A0D3D0A48}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {A1425B53-3D61-4679-8623-E64A0D3D0A48}.Debug|ARM64.Build.0 = Debug|ARM64 - {A1425B53-3D61-4679-8623-E64A0D3D0A48}.Debug|x64.ActiveCfg = Debug|x64 - {A1425B53-3D61-4679-8623-E64A0D3D0A48}.Debug|x64.Build.0 = Debug|x64 - {A1425B53-3D61-4679-8623-E64A0D3D0A48}.Release|ARM64.ActiveCfg = Release|ARM64 - {A1425B53-3D61-4679-8623-E64A0D3D0A48}.Release|ARM64.Build.0 = Release|ARM64 - {A1425B53-3D61-4679-8623-E64A0D3D0A48}.Release|x64.ActiveCfg = Release|x64 - {A1425B53-3D61-4679-8623-E64A0D3D0A48}.Release|x64.Build.0 = Release|x64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Debug|ARM64.Build.0 = Debug|ARM64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Debug|ARM64.Deploy.0 = Debug|ARM64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Debug|x64.ActiveCfg = Debug|x64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Debug|x64.Build.0 = Debug|x64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Debug|x64.Deploy.0 = Debug|x64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Release|ARM64.ActiveCfg = Release|ARM64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Release|ARM64.Build.0 = Release|ARM64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Release|ARM64.Deploy.0 = Release|ARM64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Release|x64.ActiveCfg = Release|x64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Release|x64.Build.0 = Release|x64 - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03}.Release|x64.Deploy.0 = Release|x64 - {17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB}.Debug|ARM64.Build.0 = Debug|ARM64 - {17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB}.Debug|x64.ActiveCfg = Debug|x64 - {17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB}.Debug|x64.Build.0 = Debug|x64 - {17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB}.Release|ARM64.ActiveCfg = Release|ARM64 - {17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB}.Release|ARM64.Build.0 = Release|ARM64 - {17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB}.Release|x64.ActiveCfg = Release|x64 - {17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB}.Release|x64.Build.0 = Release|x64 - {AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC}.Debug|ARM64.Build.0 = Debug|ARM64 - {AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC}.Debug|x64.ActiveCfg = Debug|x64 - {AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC}.Debug|x64.Build.0 = Debug|x64 - {AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC}.Release|ARM64.ActiveCfg = Release|ARM64 - {AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC}.Release|ARM64.Build.0 = Release|ARM64 - {AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC}.Release|x64.ActiveCfg = Release|x64 - {AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC}.Release|x64.Build.0 = Release|x64 - {ED9A1AC6-AEB0-4569-A6E9-E1696182B545}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {ED9A1AC6-AEB0-4569-A6E9-E1696182B545}.Debug|ARM64.Build.0 = Debug|ARM64 - {ED9A1AC6-AEB0-4569-A6E9-E1696182B545}.Debug|x64.ActiveCfg = Debug|x64 - {ED9A1AC6-AEB0-4569-A6E9-E1696182B545}.Debug|x64.Build.0 = Debug|x64 - {ED9A1AC6-AEB0-4569-A6E9-E1696182B545}.Release|ARM64.ActiveCfg = Release|ARM64 - {ED9A1AC6-AEB0-4569-A6E9-E1696182B545}.Release|ARM64.Build.0 = Release|ARM64 - {ED9A1AC6-AEB0-4569-A6E9-E1696182B545}.Release|x64.ActiveCfg = Release|x64 - {ED9A1AC6-AEB0-4569-A6E9-E1696182B545}.Release|x64.Build.0 = Release|x64 - {5A5DD09D-723A-44D3-8F2B-293584C3D731}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {5A5DD09D-723A-44D3-8F2B-293584C3D731}.Debug|ARM64.Build.0 = Debug|ARM64 - {5A5DD09D-723A-44D3-8F2B-293584C3D731}.Debug|x64.ActiveCfg = Debug|x64 - {5A5DD09D-723A-44D3-8F2B-293584C3D731}.Debug|x64.Build.0 = Debug|x64 - {5A5DD09D-723A-44D3-8F2B-293584C3D731}.Release|ARM64.ActiveCfg = Release|ARM64 - {5A5DD09D-723A-44D3-8F2B-293584C3D731}.Release|ARM64.Build.0 = Release|ARM64 - {5A5DD09D-723A-44D3-8F2B-293584C3D731}.Release|x64.ActiveCfg = Release|x64 - {5A5DD09D-723A-44D3-8F2B-293584C3D731}.Release|x64.Build.0 = Release|x64 - {B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9}.Debug|ARM64.Build.0 = Debug|ARM64 - {B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9}.Debug|x64.ActiveCfg = Debug|x64 - {B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9}.Debug|x64.Build.0 = Debug|x64 - {B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9}.Release|ARM64.ActiveCfg = Release|ARM64 - {B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9}.Release|ARM64.Build.0 = Release|ARM64 - {B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9}.Release|x64.ActiveCfg = Release|x64 - {B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9}.Release|x64.Build.0 = Release|x64 - {54F7C616-FD41-4E62-BFF9-015686914F4D}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {54F7C616-FD41-4E62-BFF9-015686914F4D}.Debug|ARM64.Build.0 = Debug|ARM64 - {54F7C616-FD41-4E62-BFF9-015686914F4D}.Debug|x64.ActiveCfg = Debug|x64 - {54F7C616-FD41-4E62-BFF9-015686914F4D}.Debug|x64.Build.0 = Debug|x64 - {54F7C616-FD41-4E62-BFF9-015686914F4D}.Release|ARM64.ActiveCfg = Release|ARM64 - {54F7C616-FD41-4E62-BFF9-015686914F4D}.Release|ARM64.Build.0 = Release|ARM64 - {54F7C616-FD41-4E62-BFF9-015686914F4D}.Release|x64.ActiveCfg = Release|x64 - {54F7C616-FD41-4E62-BFF9-015686914F4D}.Release|x64.Build.0 = Release|x64 - {143F13E3-D2E3-4D83-B035-356612D99956}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {143F13E3-D2E3-4D83-B035-356612D99956}.Debug|ARM64.Build.0 = Debug|ARM64 - {143F13E3-D2E3-4D83-B035-356612D99956}.Debug|x64.ActiveCfg = Debug|x64 - {143F13E3-D2E3-4D83-B035-356612D99956}.Debug|x64.Build.0 = Debug|x64 - {143F13E3-D2E3-4D83-B035-356612D99956}.Release|ARM64.ActiveCfg = Release|ARM64 - {143F13E3-D2E3-4D83-B035-356612D99956}.Release|ARM64.Build.0 = Release|ARM64 - {143F13E3-D2E3-4D83-B035-356612D99956}.Release|x64.ActiveCfg = Release|x64 - {143F13E3-D2E3-4D83-B035-356612D99956}.Release|x64.Build.0 = Release|x64 - {56CC2F10-6E41-453D-BE16-C593A5E58482}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {56CC2F10-6E41-453D-BE16-C593A5E58482}.Debug|ARM64.Build.0 = Debug|ARM64 - {56CC2F10-6E41-453D-BE16-C593A5E58482}.Debug|x64.ActiveCfg = Debug|x64 - {56CC2F10-6E41-453D-BE16-C593A5E58482}.Debug|x64.Build.0 = Debug|x64 - {56CC2F10-6E41-453D-BE16-C593A5E58482}.Release|ARM64.ActiveCfg = Release|ARM64 - {56CC2F10-6E41-453D-BE16-C593A5E58482}.Release|ARM64.Build.0 = Release|ARM64 - {56CC2F10-6E41-453D-BE16-C593A5E58482}.Release|x64.ActiveCfg = Release|x64 - {56CC2F10-6E41-453D-BE16-C593A5E58482}.Release|x64.Build.0 = Release|x64 - {CA5518ED-0458-4B09-8F53-4122B9888655}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {CA5518ED-0458-4B09-8F53-4122B9888655}.Debug|ARM64.Build.0 = Debug|ARM64 - {CA5518ED-0458-4B09-8F53-4122B9888655}.Debug|x64.ActiveCfg = Debug|x64 - {CA5518ED-0458-4B09-8F53-4122B9888655}.Debug|x64.Build.0 = Debug|x64 - {CA5518ED-0458-4B09-8F53-4122B9888655}.Release|ARM64.ActiveCfg = Release|ARM64 - {CA5518ED-0458-4B09-8F53-4122B9888655}.Release|ARM64.Build.0 = Release|ARM64 - {CA5518ED-0458-4B09-8F53-4122B9888655}.Release|x64.ActiveCfg = Release|x64 - {CA5518ED-0458-4B09-8F53-4122B9888655}.Release|x64.Build.0 = Release|x64 - {D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D}.Debug|ARM64.Build.0 = Debug|ARM64 - {D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D}.Debug|x64.ActiveCfg = Debug|x64 - {D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D}.Debug|x64.Build.0 = Debug|x64 - {D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D}.Release|ARM64.ActiveCfg = Release|ARM64 - {D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D}.Release|ARM64.Build.0 = Release|ARM64 - {D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D}.Release|x64.ActiveCfg = Release|x64 - {D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D}.Release|x64.Build.0 = Release|x64 - {2BBC9E33-21EC-401C-84DA-BB6590A9B2AA}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {2BBC9E33-21EC-401C-84DA-BB6590A9B2AA}.Debug|ARM64.Build.0 = Debug|ARM64 - {2BBC9E33-21EC-401C-84DA-BB6590A9B2AA}.Debug|x64.ActiveCfg = Debug|x64 - {2BBC9E33-21EC-401C-84DA-BB6590A9B2AA}.Debug|x64.Build.0 = Debug|x64 - {2BBC9E33-21EC-401C-84DA-BB6590A9B2AA}.Release|ARM64.ActiveCfg = Release|ARM64 - {2BBC9E33-21EC-401C-84DA-BB6590A9B2AA}.Release|ARM64.Build.0 = Release|ARM64 - {2BBC9E33-21EC-401C-84DA-BB6590A9B2AA}.Release|x64.ActiveCfg = Release|x64 - {2BBC9E33-21EC-401C-84DA-BB6590A9B2AA}.Release|x64.Build.0 = Release|x64 - {2833C9C6-AB32-4048-A5C7-A70898337B57}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {2833C9C6-AB32-4048-A5C7-A70898337B57}.Debug|ARM64.Build.0 = Debug|ARM64 - {2833C9C6-AB32-4048-A5C7-A70898337B57}.Debug|x64.ActiveCfg = Debug|x64 - {2833C9C6-AB32-4048-A5C7-A70898337B57}.Debug|x64.Build.0 = Debug|x64 - {2833C9C6-AB32-4048-A5C7-A70898337B57}.Release|ARM64.ActiveCfg = Release|ARM64 - {2833C9C6-AB32-4048-A5C7-A70898337B57}.Release|ARM64.Build.0 = Release|ARM64 - {2833C9C6-AB32-4048-A5C7-A70898337B57}.Release|x64.ActiveCfg = Release|x64 - {2833C9C6-AB32-4048-A5C7-A70898337B57}.Release|x64.Build.0 = Release|x64 - {50B82783-242F-42D2-BC03-B3430BF01354}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {50B82783-242F-42D2-BC03-B3430BF01354}.Debug|ARM64.Build.0 = Debug|ARM64 - {50B82783-242F-42D2-BC03-B3430BF01354}.Debug|x64.ActiveCfg = Debug|x64 - {50B82783-242F-42D2-BC03-B3430BF01354}.Debug|x64.Build.0 = Debug|x64 - {50B82783-242F-42D2-BC03-B3430BF01354}.Release|ARM64.ActiveCfg = Release|ARM64 - {50B82783-242F-42D2-BC03-B3430BF01354}.Release|ARM64.Build.0 = Release|ARM64 - {50B82783-242F-42D2-BC03-B3430BF01354}.Release|x64.ActiveCfg = Release|x64 - {50B82783-242F-42D2-BC03-B3430BF01354}.Release|x64.Build.0 = Release|x64 - {B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2}.Debug|ARM64.Build.0 = Debug|ARM64 - {B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2}.Debug|x64.ActiveCfg = Debug|x64 - {B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2}.Debug|x64.Build.0 = Debug|x64 - {B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2}.Release|ARM64.ActiveCfg = Release|ARM64 - {B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2}.Release|ARM64.Build.0 = Release|ARM64 - {B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2}.Release|x64.ActiveCfg = Release|x64 - {B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2}.Release|x64.Build.0 = Release|x64 - {A663E672-B26D-4EC0-BEAB-FE2E424AC46F}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {A663E672-B26D-4EC0-BEAB-FE2E424AC46F}.Debug|ARM64.Build.0 = Debug|ARM64 - {A663E672-B26D-4EC0-BEAB-FE2E424AC46F}.Debug|x64.ActiveCfg = Debug|x64 - {A663E672-B26D-4EC0-BEAB-FE2E424AC46F}.Debug|x64.Build.0 = Debug|x64 - {A663E672-B26D-4EC0-BEAB-FE2E424AC46F}.Release|ARM64.ActiveCfg = Release|ARM64 - {A663E672-B26D-4EC0-BEAB-FE2E424AC46F}.Release|ARM64.Build.0 = Release|ARM64 - {A663E672-B26D-4EC0-BEAB-FE2E424AC46F}.Release|x64.ActiveCfg = Release|x64 - {A663E672-B26D-4EC0-BEAB-FE2E424AC46F}.Release|x64.Build.0 = Release|x64 - {8A08D663-4995-40E3-B42C-3F910625F284}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8A08D663-4995-40E3-B42C-3F910625F284}.Debug|ARM64.Build.0 = Debug|ARM64 - {8A08D663-4995-40E3-B42C-3F910625F284}.Debug|x64.ActiveCfg = Debug|x64 - {8A08D663-4995-40E3-B42C-3F910625F284}.Debug|x64.Build.0 = Debug|x64 - {8A08D663-4995-40E3-B42C-3F910625F284}.Release|ARM64.ActiveCfg = Release|ARM64 - {8A08D663-4995-40E3-B42C-3F910625F284}.Release|ARM64.Build.0 = Release|ARM64 - {8A08D663-4995-40E3-B42C-3F910625F284}.Release|x64.ActiveCfg = Release|x64 - {8A08D663-4995-40E3-B42C-3F910625F284}.Release|x64.Build.0 = Release|x64 - {923DF87C-CA99-4D1C-B1D2-959174E95BFA}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {923DF87C-CA99-4D1C-B1D2-959174E95BFA}.Debug|ARM64.Build.0 = Debug|ARM64 - {923DF87C-CA99-4D1C-B1D2-959174E95BFA}.Debug|x64.ActiveCfg = Debug|x64 - {923DF87C-CA99-4D1C-B1D2-959174E95BFA}.Debug|x64.Build.0 = Debug|x64 - {923DF87C-CA99-4D1C-B1D2-959174E95BFA}.Release|ARM64.ActiveCfg = Release|ARM64 - {923DF87C-CA99-4D1C-B1D2-959174E95BFA}.Release|ARM64.Build.0 = Release|ARM64 - {923DF87C-CA99-4D1C-B1D2-959174E95BFA}.Release|x64.ActiveCfg = Release|x64 - {923DF87C-CA99-4D1C-B1D2-959174E95BFA}.Release|x64.Build.0 = Release|x64 - {D5E42C63-57C5-4EF6-AECE-1E2FCA725B77}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D5E42C63-57C5-4EF6-AECE-1E2FCA725B77}.Debug|ARM64.Build.0 = Debug|ARM64 - {D5E42C63-57C5-4EF6-AECE-1E2FCA725B77}.Debug|x64.ActiveCfg = Debug|x64 - {D5E42C63-57C5-4EF6-AECE-1E2FCA725B77}.Debug|x64.Build.0 = Debug|x64 - {D5E42C63-57C5-4EF6-AECE-1E2FCA725B77}.Release|ARM64.ActiveCfg = Release|ARM64 - {D5E42C63-57C5-4EF6-AECE-1E2FCA725B77}.Release|ARM64.Build.0 = Release|ARM64 - {D5E42C63-57C5-4EF6-AECE-1E2FCA725B77}.Release|x64.ActiveCfg = Release|x64 - {D5E42C63-57C5-4EF6-AECE-1E2FCA725B77}.Release|x64.Build.0 = Release|x64 - {D962A009-834F-4EEC-AABB-430DF8F98E39}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D962A009-834F-4EEC-AABB-430DF8F98E39}.Debug|ARM64.Build.0 = Debug|ARM64 - {D962A009-834F-4EEC-AABB-430DF8F98E39}.Debug|x64.ActiveCfg = Debug|x64 - {D962A009-834F-4EEC-AABB-430DF8F98E39}.Debug|x64.Build.0 = Debug|x64 - {D962A009-834F-4EEC-AABB-430DF8F98E39}.Release|ARM64.ActiveCfg = Release|ARM64 - {D962A009-834F-4EEC-AABB-430DF8F98E39}.Release|ARM64.Build.0 = Release|ARM64 - {D962A009-834F-4EEC-AABB-430DF8F98E39}.Release|x64.ActiveCfg = Release|x64 - {D962A009-834F-4EEC-AABB-430DF8F98E39}.Release|x64.Build.0 = Release|x64 - {FC373B24-3293-453C-AAF5-CF2909DCEE6A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {FC373B24-3293-453C-AAF5-CF2909DCEE6A}.Debug|ARM64.Build.0 = Debug|ARM64 - {FC373B24-3293-453C-AAF5-CF2909DCEE6A}.Debug|x64.ActiveCfg = Debug|x64 - {FC373B24-3293-453C-AAF5-CF2909DCEE6A}.Debug|x64.Build.0 = Debug|x64 - {FC373B24-3293-453C-AAF5-CF2909DCEE6A}.Release|ARM64.ActiveCfg = Release|ARM64 - {FC373B24-3293-453C-AAF5-CF2909DCEE6A}.Release|ARM64.Build.0 = Release|ARM64 - {FC373B24-3293-453C-AAF5-CF2909DCEE6A}.Release|x64.ActiveCfg = Release|x64 - {FC373B24-3293-453C-AAF5-CF2909DCEE6A}.Release|x64.Build.0 = Release|x64 - {9CE59ED5-7087-4353-88EB-788038A73CEC}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {9CE59ED5-7087-4353-88EB-788038A73CEC}.Debug|ARM64.Build.0 = Debug|ARM64 - {9CE59ED5-7087-4353-88EB-788038A73CEC}.Debug|x64.ActiveCfg = Debug|x64 - {9CE59ED5-7087-4353-88EB-788038A73CEC}.Debug|x64.Build.0 = Debug|x64 - {9CE59ED5-7087-4353-88EB-788038A73CEC}.Release|ARM64.ActiveCfg = Release|ARM64 - {9CE59ED5-7087-4353-88EB-788038A73CEC}.Release|ARM64.Build.0 = Release|ARM64 - {9CE59ED5-7087-4353-88EB-788038A73CEC}.Release|x64.ActiveCfg = Release|x64 - {9CE59ED5-7087-4353-88EB-788038A73CEC}.Release|x64.Build.0 = Release|x64 - {FD86C06A-FB54-4D5E-9831-1CDADF60D45F}.Debug|ARM64.ActiveCfg = Debug|Any CPU - {FD86C06A-FB54-4D5E-9831-1CDADF60D45F}.Debug|ARM64.Build.0 = Debug|Any CPU - {FD86C06A-FB54-4D5E-9831-1CDADF60D45F}.Debug|x64.ActiveCfg = Debug|Any CPU - {FD86C06A-FB54-4D5E-9831-1CDADF60D45F}.Debug|x64.Build.0 = Debug|Any CPU - {FD86C06A-FB54-4D5E-9831-1CDADF60D45F}.Release|ARM64.ActiveCfg = Release|Any CPU - {FD86C06A-FB54-4D5E-9831-1CDADF60D45F}.Release|ARM64.Build.0 = Release|Any CPU - {FD86C06A-FB54-4D5E-9831-1CDADF60D45F}.Release|x64.ActiveCfg = Release|Any CPU - {FD86C06A-FB54-4D5E-9831-1CDADF60D45F}.Release|x64.Build.0 = Release|Any CPU - {697C6AF9-0A48-49A9-866C-67DA12384015}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {697C6AF9-0A48-49A9-866C-67DA12384015}.Debug|ARM64.Build.0 = Debug|ARM64 - {697C6AF9-0A48-49A9-866C-67DA12384015}.Debug|x64.ActiveCfg = Debug|x64 - {697C6AF9-0A48-49A9-866C-67DA12384015}.Debug|x64.Build.0 = Debug|x64 - {697C6AF9-0A48-49A9-866C-67DA12384015}.Release|ARM64.ActiveCfg = Release|ARM64 - {697C6AF9-0A48-49A9-866C-67DA12384015}.Release|ARM64.Build.0 = Release|ARM64 - {697C6AF9-0A48-49A9-866C-67DA12384015}.Release|x64.ActiveCfg = Release|x64 - {697C6AF9-0A48-49A9-866C-67DA12384015}.Release|x64.Build.0 = Release|x64 - {9EBAA524-0EDA-470B-95D4-39383285CBB2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {9EBAA524-0EDA-470B-95D4-39383285CBB2}.Debug|ARM64.Build.0 = Debug|ARM64 - {9EBAA524-0EDA-470B-95D4-39383285CBB2}.Debug|x64.ActiveCfg = Debug|x64 - {9EBAA524-0EDA-470B-95D4-39383285CBB2}.Debug|x64.Build.0 = Debug|x64 - {9EBAA524-0EDA-470B-95D4-39383285CBB2}.Release|ARM64.ActiveCfg = Release|ARM64 - {9EBAA524-0EDA-470B-95D4-39383285CBB2}.Release|ARM64.Build.0 = Release|ARM64 - {9EBAA524-0EDA-470B-95D4-39383285CBB2}.Release|x64.ActiveCfg = Release|x64 - {9EBAA524-0EDA-470B-95D4-39383285CBB2}.Release|x64.Build.0 = Release|x64 - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D}.Debug|ARM64.Build.0 = Debug|ARM64 - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D}.Debug|x64.ActiveCfg = Debug|x64 - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D}.Debug|x64.Build.0 = Debug|x64 - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D}.Release|ARM64.ActiveCfg = Release|ARM64 - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D}.Release|ARM64.Build.0 = Release|ARM64 - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D}.Release|x64.ActiveCfg = Release|x64 - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D}.Release|x64.Build.0 = Release|x64 - {D095BE44-1F2E-463E-A494-121892A75EA2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D095BE44-1F2E-463E-A494-121892A75EA2}.Debug|ARM64.Build.0 = Debug|ARM64 - {D095BE44-1F2E-463E-A494-121892A75EA2}.Debug|x64.ActiveCfg = Debug|x64 - {D095BE44-1F2E-463E-A494-121892A75EA2}.Debug|x64.Build.0 = Debug|x64 - {D095BE44-1F2E-463E-A494-121892A75EA2}.Release|ARM64.ActiveCfg = Release|ARM64 - {D095BE44-1F2E-463E-A494-121892A75EA2}.Release|ARM64.Build.0 = Release|ARM64 - {D095BE44-1F2E-463E-A494-121892A75EA2}.Release|x64.ActiveCfg = Release|x64 - {D095BE44-1F2E-463E-A494-121892A75EA2}.Release|x64.Build.0 = Release|x64 - {90F9FA90-2C20-4004-96E6-F3B78151F5A5}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {90F9FA90-2C20-4004-96E6-F3B78151F5A5}.Debug|ARM64.Build.0 = Debug|ARM64 - {90F9FA90-2C20-4004-96E6-F3B78151F5A5}.Debug|x64.ActiveCfg = Debug|x64 - {90F9FA90-2C20-4004-96E6-F3B78151F5A5}.Debug|x64.Build.0 = Debug|x64 - {90F9FA90-2C20-4004-96E6-F3B78151F5A5}.Release|ARM64.ActiveCfg = Release|ARM64 - {90F9FA90-2C20-4004-96E6-F3B78151F5A5}.Release|ARM64.Build.0 = Release|ARM64 - {90F9FA90-2C20-4004-96E6-F3B78151F5A5}.Release|x64.ActiveCfg = Release|x64 - {90F9FA90-2C20-4004-96E6-F3B78151F5A5}.Release|x64.Build.0 = Release|x64 - {F5E1146E-B7B3-4E11-85FD-270A500BD78C}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {F5E1146E-B7B3-4E11-85FD-270A500BD78C}.Debug|ARM64.Build.0 = Debug|ARM64 - {F5E1146E-B7B3-4E11-85FD-270A500BD78C}.Debug|x64.ActiveCfg = Debug|x64 - {F5E1146E-B7B3-4E11-85FD-270A500BD78C}.Debug|x64.Build.0 = Debug|x64 - {F5E1146E-B7B3-4E11-85FD-270A500BD78C}.Release|ARM64.ActiveCfg = Release|ARM64 - {F5E1146E-B7B3-4E11-85FD-270A500BD78C}.Release|ARM64.Build.0 = Release|ARM64 - {F5E1146E-B7B3-4E11-85FD-270A500BD78C}.Release|x64.ActiveCfg = Release|x64 - {F5E1146E-B7B3-4E11-85FD-270A500BD78C}.Release|x64.Build.0 = Release|x64 - {3157FA75-86CF-4EE2-8F62-C43F776493C6}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {3157FA75-86CF-4EE2-8F62-C43F776493C6}.Debug|ARM64.Build.0 = Debug|ARM64 - {3157FA75-86CF-4EE2-8F62-C43F776493C6}.Debug|x64.ActiveCfg = Debug|x64 - {3157FA75-86CF-4EE2-8F62-C43F776493C6}.Debug|x64.Build.0 = Debug|x64 - {3157FA75-86CF-4EE2-8F62-C43F776493C6}.Release|ARM64.ActiveCfg = Release|ARM64 - {3157FA75-86CF-4EE2-8F62-C43F776493C6}.Release|ARM64.Build.0 = Release|ARM64 - {3157FA75-86CF-4EE2-8F62-C43F776493C6}.Release|x64.ActiveCfg = Release|x64 - {3157FA75-86CF-4EE2-8F62-C43F776493C6}.Release|x64.Build.0 = Release|x64 - {FC8EB78F-F061-4BD9-A3F6-507BEA965E2B}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {FC8EB78F-F061-4BD9-A3F6-507BEA965E2B}.Debug|ARM64.Build.0 = Debug|ARM64 - {FC8EB78F-F061-4BD9-A3F6-507BEA965E2B}.Debug|x64.ActiveCfg = Debug|x64 - {FC8EB78F-F061-4BD9-A3F6-507BEA965E2B}.Debug|x64.Build.0 = Debug|x64 - {FC8EB78F-F061-4BD9-A3F6-507BEA965E2B}.Release|ARM64.ActiveCfg = Release|ARM64 - {FC8EB78F-F061-4BD9-A3F6-507BEA965E2B}.Release|ARM64.Build.0 = Release|ARM64 - {FC8EB78F-F061-4BD9-A3F6-507BEA965E2B}.Release|x64.ActiveCfg = Release|x64 - {FC8EB78F-F061-4BD9-A3F6-507BEA965E2B}.Release|x64.Build.0 = Release|x64 - {51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA}.Debug|ARM64.ActiveCfg = Debug|Any CPU - {51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA}.Debug|ARM64.Build.0 = Debug|Any CPU - {51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA}.Debug|x64.ActiveCfg = Debug|Any CPU - {51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA}.Debug|x64.Build.0 = Debug|Any CPU - {51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA}.Release|ARM64.ActiveCfg = Release|Any CPU - {51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA}.Release|ARM64.Build.0 = Release|Any CPU - {51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA}.Release|x64.ActiveCfg = Release|Any CPU - {51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA}.Release|x64.Build.0 = Release|Any CPU - {B9420661-B0E4-4241-ABD4-4A27A1F64250}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {B9420661-B0E4-4241-ABD4-4A27A1F64250}.Debug|ARM64.Build.0 = Debug|ARM64 - {B9420661-B0E4-4241-ABD4-4A27A1F64250}.Debug|x64.ActiveCfg = Debug|x64 - {B9420661-B0E4-4241-ABD4-4A27A1F64250}.Debug|x64.Build.0 = Debug|x64 - {B9420661-B0E4-4241-ABD4-4A27A1F64250}.Release|ARM64.ActiveCfg = Release|ARM64 - {B9420661-B0E4-4241-ABD4-4A27A1F64250}.Release|ARM64.Build.0 = Release|ARM64 - {B9420661-B0E4-4241-ABD4-4A27A1F64250}.Release|x64.ActiveCfg = Release|x64 - {B9420661-B0E4-4241-ABD4-4A27A1F64250}.Release|x64.Build.0 = Release|x64 - {CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}.Debug|ARM64.Build.0 = Debug|ARM64 - {CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}.Debug|x64.ActiveCfg = Debug|x64 - {CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}.Debug|x64.Build.0 = Debug|x64 - {CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}.Release|ARM64.ActiveCfg = Release|ARM64 - {CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}.Release|ARM64.Build.0 = Release|ARM64 - {CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}.Release|x64.ActiveCfg = Release|x64 - {CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}.Release|x64.Build.0 = Release|x64 - {D949EC7D-48A9-4279-95D5-078E7FD1F048}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D949EC7D-48A9-4279-95D5-078E7FD1F048}.Debug|ARM64.Build.0 = Debug|ARM64 - {D949EC7D-48A9-4279-95D5-078E7FD1F048}.Debug|x64.ActiveCfg = Debug|x64 - {D949EC7D-48A9-4279-95D5-078E7FD1F048}.Debug|x64.Build.0 = Debug|x64 - {D949EC7D-48A9-4279-95D5-078E7FD1F048}.Release|ARM64.ActiveCfg = Release|ARM64 - {D949EC7D-48A9-4279-95D5-078E7FD1F048}.Release|ARM64.Build.0 = Release|ARM64 - {D949EC7D-48A9-4279-95D5-078E7FD1F048}.Release|x64.ActiveCfg = Release|x64 - {D949EC7D-48A9-4279-95D5-078E7FD1F048}.Release|x64.Build.0 = Release|x64 - {3BAF9C81-A194-4925-A035-5E24A5D1E542}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {3BAF9C81-A194-4925-A035-5E24A5D1E542}.Debug|ARM64.Build.0 = Debug|ARM64 - {3BAF9C81-A194-4925-A035-5E24A5D1E542}.Debug|x64.ActiveCfg = Debug|x64 - {3BAF9C81-A194-4925-A035-5E24A5D1E542}.Debug|x64.Build.0 = Debug|x64 - {3BAF9C81-A194-4925-A035-5E24A5D1E542}.Release|ARM64.ActiveCfg = Release|ARM64 - {3BAF9C81-A194-4925-A035-5E24A5D1E542}.Release|ARM64.Build.0 = Release|ARM64 - {3BAF9C81-A194-4925-A035-5E24A5D1E542}.Release|x64.ActiveCfg = Release|x64 - {3BAF9C81-A194-4925-A035-5E24A5D1E542}.Release|x64.Build.0 = Release|x64 - {6B04803D-B418-4833-A67E-B0FC966636A5}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {6B04803D-B418-4833-A67E-B0FC966636A5}.Debug|ARM64.Build.0 = Debug|ARM64 - {6B04803D-B418-4833-A67E-B0FC966636A5}.Debug|x64.ActiveCfg = Debug|x64 - {6B04803D-B418-4833-A67E-B0FC966636A5}.Debug|x64.Build.0 = Debug|x64 - {6B04803D-B418-4833-A67E-B0FC966636A5}.Release|ARM64.ActiveCfg = Release|ARM64 - {6B04803D-B418-4833-A67E-B0FC966636A5}.Release|ARM64.Build.0 = Release|ARM64 - {6B04803D-B418-4833-A67E-B0FC966636A5}.Release|x64.ActiveCfg = Release|x64 - {6B04803D-B418-4833-A67E-B0FC966636A5}.Release|x64.Build.0 = Release|x64 - {3940AD4D-F748-4BE4-9083-85769CD553EF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {3940AD4D-F748-4BE4-9083-85769CD553EF}.Debug|ARM64.Build.0 = Debug|ARM64 - {3940AD4D-F748-4BE4-9083-85769CD553EF}.Debug|x64.ActiveCfg = Debug|x64 - {3940AD4D-F748-4BE4-9083-85769CD553EF}.Debug|x64.Build.0 = Debug|x64 - {3940AD4D-F748-4BE4-9083-85769CD553EF}.Release|ARM64.ActiveCfg = Release|ARM64 - {3940AD4D-F748-4BE4-9083-85769CD553EF}.Release|ARM64.Build.0 = Release|ARM64 - {3940AD4D-F748-4BE4-9083-85769CD553EF}.Release|x64.ActiveCfg = Release|x64 - {3940AD4D-F748-4BE4-9083-85769CD553EF}.Release|x64.Build.0 = Release|x64 - {F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38}.Debug|ARM64.Build.0 = Debug|ARM64 - {F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38}.Debug|x64.ActiveCfg = Debug|x64 - {F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38}.Debug|x64.Build.0 = Debug|x64 - {F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38}.Release|ARM64.ActiveCfg = Release|ARM64 - {F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38}.Release|ARM64.Build.0 = Release|ARM64 - {F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38}.Release|x64.ActiveCfg = Release|x64 - {F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38}.Release|x64.Build.0 = Release|x64 - {0014D652-901F-4456-8D65-06FC5F997FB0}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {0014D652-901F-4456-8D65-06FC5F997FB0}.Debug|ARM64.Build.0 = Debug|ARM64 - {0014D652-901F-4456-8D65-06FC5F997FB0}.Debug|x64.ActiveCfg = Debug|x64 - {0014D652-901F-4456-8D65-06FC5F997FB0}.Debug|x64.Build.0 = Debug|x64 - {0014D652-901F-4456-8D65-06FC5F997FB0}.Release|ARM64.ActiveCfg = Release|ARM64 - {0014D652-901F-4456-8D65-06FC5F997FB0}.Release|ARM64.Build.0 = Release|ARM64 - {0014D652-901F-4456-8D65-06FC5F997FB0}.Release|x64.ActiveCfg = Release|x64 - {0014D652-901F-4456-8D65-06FC5F997FB0}.Release|x64.Build.0 = Release|x64 - {799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA}.Debug|ARM64.Build.0 = Debug|ARM64 - {799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA}.Debug|x64.ActiveCfg = Debug|x64 - {799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA}.Debug|x64.Build.0 = Debug|x64 - {799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA}.Release|ARM64.ActiveCfg = Release|ARM64 - {799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA}.Release|ARM64.Build.0 = Release|ARM64 - {799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA}.Release|x64.ActiveCfg = Release|x64 - {799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA}.Release|x64.Build.0 = Release|x64 - {9D52FD25-EF90-4F9A-A015-91EFC5DAF54F}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {9D52FD25-EF90-4F9A-A015-91EFC5DAF54F}.Debug|ARM64.Build.0 = Debug|ARM64 - {9D52FD25-EF90-4F9A-A015-91EFC5DAF54F}.Debug|x64.ActiveCfg = Debug|x64 - {9D52FD25-EF90-4F9A-A015-91EFC5DAF54F}.Debug|x64.Build.0 = Debug|x64 - {9D52FD25-EF90-4F9A-A015-91EFC5DAF54F}.Release|ARM64.ActiveCfg = Release|ARM64 - {9D52FD25-EF90-4F9A-A015-91EFC5DAF54F}.Release|ARM64.Build.0 = Release|ARM64 - {9D52FD25-EF90-4F9A-A015-91EFC5DAF54F}.Release|x64.ActiveCfg = Release|x64 - {9D52FD25-EF90-4F9A-A015-91EFC5DAF54F}.Release|x64.Build.0 = Release|x64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Debug|ARM64.Build.0 = Debug|ARM64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Debug|ARM64.Deploy.0 = Debug|ARM64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Debug|x64.ActiveCfg = Debug|x64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Debug|x64.Build.0 = Debug|x64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Debug|x64.Deploy.0 = Debug|x64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Release|ARM64.ActiveCfg = Release|ARM64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Release|ARM64.Build.0 = Release|ARM64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Release|ARM64.Deploy.0 = Release|ARM64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Release|x64.ActiveCfg = Release|x64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Release|x64.Build.0 = Release|x64 - {C32D254F-7597-4CBE-BF74-D922D81CDF29}.Release|x64.Deploy.0 = Release|x64 - {02DD46D3-F761-47D9-8894-2D6DA0124650}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {02DD46D3-F761-47D9-8894-2D6DA0124650}.Debug|ARM64.Build.0 = Debug|ARM64 - {02DD46D3-F761-47D9-8894-2D6DA0124650}.Debug|x64.ActiveCfg = Debug|x64 - {02DD46D3-F761-47D9-8894-2D6DA0124650}.Debug|x64.Build.0 = Debug|x64 - {02DD46D3-F761-47D9-8894-2D6DA0124650}.Release|ARM64.ActiveCfg = Release|ARM64 - {02DD46D3-F761-47D9-8894-2D6DA0124650}.Release|ARM64.Build.0 = Release|ARM64 - {02DD46D3-F761-47D9-8894-2D6DA0124650}.Release|x64.ActiveCfg = Release|x64 - {02DD46D3-F761-47D9-8894-2D6DA0124650}.Release|x64.Build.0 = Release|x64 - {8E23E173-7127-4A5F-9F93-3049F2B68047}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8E23E173-7127-4A5F-9F93-3049F2B68047}.Debug|ARM64.Build.0 = Debug|ARM64 - {8E23E173-7127-4A5F-9F93-3049F2B68047}.Debug|x64.ActiveCfg = Debug|x64 - {8E23E173-7127-4A5F-9F93-3049F2B68047}.Debug|x64.Build.0 = Debug|x64 - {8E23E173-7127-4A5F-9F93-3049F2B68047}.Release|ARM64.ActiveCfg = Release|ARM64 - {8E23E173-7127-4A5F-9F93-3049F2B68047}.Release|ARM64.Build.0 = Release|ARM64 - {8E23E173-7127-4A5F-9F93-3049F2B68047}.Release|x64.ActiveCfg = Release|x64 - {8E23E173-7127-4A5F-9F93-3049F2B68047}.Release|x64.Build.0 = Release|x64 - {DFF88D16-D36F-40A4-A955-CDCAA76EF7B8}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {DFF88D16-D36F-40A4-A955-CDCAA76EF7B8}.Debug|ARM64.Build.0 = Debug|ARM64 - {DFF88D16-D36F-40A4-A955-CDCAA76EF7B8}.Debug|x64.ActiveCfg = Debug|x64 - {DFF88D16-D36F-40A4-A955-CDCAA76EF7B8}.Debug|x64.Build.0 = Debug|x64 - {DFF88D16-D36F-40A4-A955-CDCAA76EF7B8}.Release|ARM64.ActiveCfg = Release|ARM64 - {DFF88D16-D36F-40A4-A955-CDCAA76EF7B8}.Release|ARM64.Build.0 = Release|ARM64 - {DFF88D16-D36F-40A4-A955-CDCAA76EF7B8}.Release|x64.ActiveCfg = Release|x64 - {DFF88D16-D36F-40A4-A955-CDCAA76EF7B8}.Release|x64.Build.0 = Release|x64 - {FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}.Debug|ARM64.Build.0 = Debug|ARM64 - {FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}.Debug|x64.ActiveCfg = Debug|x64 - {FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}.Debug|x64.Build.0 = Debug|x64 - {FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}.Release|ARM64.ActiveCfg = Release|ARM64 - {FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}.Release|ARM64.Build.0 = Release|ARM64 - {FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}.Release|x64.ActiveCfg = Release|x64 - {FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}.Release|x64.Build.0 = Release|x64 - {3A9A791E-94A9-49F8-8401-C11CE288D5FB}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {3A9A791E-94A9-49F8-8401-C11CE288D5FB}.Debug|ARM64.Build.0 = Debug|ARM64 - {3A9A791E-94A9-49F8-8401-C11CE288D5FB}.Debug|x64.ActiveCfg = Debug|x64 - {3A9A791E-94A9-49F8-8401-C11CE288D5FB}.Debug|x64.Build.0 = Debug|x64 - {3A9A791E-94A9-49F8-8401-C11CE288D5FB}.Release|ARM64.ActiveCfg = Release|ARM64 - {3A9A791E-94A9-49F8-8401-C11CE288D5FB}.Release|ARM64.Build.0 = Release|ARM64 - {3A9A791E-94A9-49F8-8401-C11CE288D5FB}.Release|x64.ActiveCfg = Release|x64 - {3A9A791E-94A9-49F8-8401-C11CE288D5FB}.Release|x64.Build.0 = Release|x64 - {C0974915-8A1D-4BF0-977B-9587D3807AB7}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C0974915-8A1D-4BF0-977B-9587D3807AB7}.Debug|ARM64.Build.0 = Debug|ARM64 - {C0974915-8A1D-4BF0-977B-9587D3807AB7}.Debug|x64.ActiveCfg = Debug|x64 - {C0974915-8A1D-4BF0-977B-9587D3807AB7}.Debug|x64.Build.0 = Debug|x64 - {C0974915-8A1D-4BF0-977B-9587D3807AB7}.Release|ARM64.ActiveCfg = Release|ARM64 - {C0974915-8A1D-4BF0-977B-9587D3807AB7}.Release|ARM64.Build.0 = Release|ARM64 - {C0974915-8A1D-4BF0-977B-9587D3807AB7}.Release|x64.ActiveCfg = Release|x64 - {C0974915-8A1D-4BF0-977B-9587D3807AB7}.Release|x64.Build.0 = Release|x64 - {1D6893CB-BC0C-46A8-A76C-9728706CA51A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {1D6893CB-BC0C-46A8-A76C-9728706CA51A}.Debug|ARM64.Build.0 = Debug|ARM64 - {1D6893CB-BC0C-46A8-A76C-9728706CA51A}.Debug|x64.ActiveCfg = Debug|x64 - {1D6893CB-BC0C-46A8-A76C-9728706CA51A}.Debug|x64.Build.0 = Debug|x64 - {1D6893CB-BC0C-46A8-A76C-9728706CA51A}.Release|ARM64.ActiveCfg = Release|ARM64 - {1D6893CB-BC0C-46A8-A76C-9728706CA51A}.Release|ARM64.Build.0 = Release|ARM64 - {1D6893CB-BC0C-46A8-A76C-9728706CA51A}.Release|x64.ActiveCfg = Release|x64 - {1D6893CB-BC0C-46A8-A76C-9728706CA51A}.Release|x64.Build.0 = Release|x64 - {8ACB33D9-C95B-47D4-8363-9731EE0930A0}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8ACB33D9-C95B-47D4-8363-9731EE0930A0}.Debug|ARM64.Build.0 = Debug|ARM64 - {8ACB33D9-C95B-47D4-8363-9731EE0930A0}.Debug|x64.ActiveCfg = Debug|x64 - {8ACB33D9-C95B-47D4-8363-9731EE0930A0}.Debug|x64.Build.0 = Debug|x64 - {8ACB33D9-C95B-47D4-8363-9731EE0930A0}.Release|ARM64.ActiveCfg = Release|ARM64 - {8ACB33D9-C95B-47D4-8363-9731EE0930A0}.Release|ARM64.Build.0 = Release|ARM64 - {8ACB33D9-C95B-47D4-8363-9731EE0930A0}.Release|x64.ActiveCfg = Release|x64 - {8ACB33D9-C95B-47D4-8363-9731EE0930A0}.Release|x64.Build.0 = Release|x64 - {F055103B-F80B-4D0C-BF48-057C55620033}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {F055103B-F80B-4D0C-BF48-057C55620033}.Debug|ARM64.Build.0 = Debug|ARM64 - {F055103B-F80B-4D0C-BF48-057C55620033}.Debug|x64.ActiveCfg = Debug|x64 - {F055103B-F80B-4D0C-BF48-057C55620033}.Debug|x64.Build.0 = Debug|x64 - {F055103B-F80B-4D0C-BF48-057C55620033}.Release|ARM64.ActiveCfg = Release|ARM64 - {F055103B-F80B-4D0C-BF48-057C55620033}.Release|ARM64.Build.0 = Release|ARM64 - {F055103B-F80B-4D0C-BF48-057C55620033}.Release|x64.ActiveCfg = Release|x64 - {F055103B-F80B-4D0C-BF48-057C55620033}.Release|x64.Build.0 = Release|x64 - {B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332}.Debug|ARM64.Build.0 = Debug|ARM64 - {B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332}.Debug|x64.ActiveCfg = Debug|x64 - {B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332}.Debug|x64.Build.0 = Debug|x64 - {B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332}.Release|ARM64.ActiveCfg = Release|ARM64 - {B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332}.Release|ARM64.Build.0 = Release|ARM64 - {B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332}.Release|x64.ActiveCfg = Release|x64 - {B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332}.Release|x64.Build.0 = Release|x64 - {9C53CC25-0623-4569-95BC-B05410675EE3}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {9C53CC25-0623-4569-95BC-B05410675EE3}.Debug|ARM64.Build.0 = Debug|ARM64 - {9C53CC25-0623-4569-95BC-B05410675EE3}.Debug|x64.ActiveCfg = Debug|x64 - {9C53CC25-0623-4569-95BC-B05410675EE3}.Debug|x64.Build.0 = Debug|x64 - {9C53CC25-0623-4569-95BC-B05410675EE3}.Release|ARM64.ActiveCfg = Release|ARM64 - {9C53CC25-0623-4569-95BC-B05410675EE3}.Release|ARM64.Build.0 = Release|ARM64 - {9C53CC25-0623-4569-95BC-B05410675EE3}.Release|x64.ActiveCfg = Release|x64 - {9C53CC25-0623-4569-95BC-B05410675EE3}.Release|x64.Build.0 = Release|x64 - {45285DF2-9742-4ECA-9AC9-58951FC26489}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {45285DF2-9742-4ECA-9AC9-58951FC26489}.Debug|ARM64.Build.0 = Debug|ARM64 - {45285DF2-9742-4ECA-9AC9-58951FC26489}.Debug|x64.ActiveCfg = Debug|x64 - {45285DF2-9742-4ECA-9AC9-58951FC26489}.Debug|x64.Build.0 = Debug|x64 - {45285DF2-9742-4ECA-9AC9-58951FC26489}.Release|ARM64.ActiveCfg = Release|ARM64 - {45285DF2-9742-4ECA-9AC9-58951FC26489}.Release|ARM64.Build.0 = Release|ARM64 - {45285DF2-9742-4ECA-9AC9-58951FC26489}.Release|x64.ActiveCfg = Release|x64 - {45285DF2-9742-4ECA-9AC9-58951FC26489}.Release|x64.Build.0 = Release|x64 - {3D63307B-9D27-44FD-B033-B26F39245B85}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {3D63307B-9D27-44FD-B033-B26F39245B85}.Debug|ARM64.Build.0 = Debug|ARM64 - {3D63307B-9D27-44FD-B033-B26F39245B85}.Debug|x64.ActiveCfg = Debug|x64 - {3D63307B-9D27-44FD-B033-B26F39245B85}.Debug|x64.Build.0 = Debug|x64 - {3D63307B-9D27-44FD-B033-B26F39245B85}.Release|ARM64.ActiveCfg = Release|ARM64 - {3D63307B-9D27-44FD-B033-B26F39245B85}.Release|ARM64.Build.0 = Release|ARM64 - {3D63307B-9D27-44FD-B033-B26F39245B85}.Release|x64.ActiveCfg = Release|x64 - {3D63307B-9D27-44FD-B033-B26F39245B85}.Release|x64.Build.0 = Release|x64 - {367D7543-7DBA-4381-99F1-BF6142A996C4}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {367D7543-7DBA-4381-99F1-BF6142A996C4}.Debug|ARM64.Build.0 = Debug|ARM64 - {367D7543-7DBA-4381-99F1-BF6142A996C4}.Debug|x64.ActiveCfg = Debug|x64 - {367D7543-7DBA-4381-99F1-BF6142A996C4}.Debug|x64.Build.0 = Debug|x64 - {367D7543-7DBA-4381-99F1-BF6142A996C4}.Release|ARM64.ActiveCfg = Release|ARM64 - {367D7543-7DBA-4381-99F1-BF6142A996C4}.Release|ARM64.Build.0 = Release|ARM64 - {367D7543-7DBA-4381-99F1-BF6142A996C4}.Release|x64.ActiveCfg = Release|x64 - {367D7543-7DBA-4381-99F1-BF6142A996C4}.Release|x64.Build.0 = Release|x64 - {2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96}.Debug|ARM64.Build.0 = Debug|ARM64 - {2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96}.Debug|x64.ActiveCfg = Debug|x64 - {2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96}.Debug|x64.Build.0 = Debug|x64 - {2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96}.Release|ARM64.ActiveCfg = Release|ARM64 - {2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96}.Release|ARM64.Build.0 = Release|ARM64 - {2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96}.Release|x64.ActiveCfg = Release|x64 - {2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96}.Release|x64.Build.0 = Release|x64 - {37D07516-4185-43A4-924F-3C7A5D95ECF6}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {37D07516-4185-43A4-924F-3C7A5D95ECF6}.Debug|ARM64.Build.0 = Debug|ARM64 - {37D07516-4185-43A4-924F-3C7A5D95ECF6}.Debug|x64.ActiveCfg = Debug|x64 - {37D07516-4185-43A4-924F-3C7A5D95ECF6}.Debug|x64.Build.0 = Debug|x64 - {37D07516-4185-43A4-924F-3C7A5D95ECF6}.Release|ARM64.ActiveCfg = Release|ARM64 - {37D07516-4185-43A4-924F-3C7A5D95ECF6}.Release|ARM64.Build.0 = Release|ARM64 - {37D07516-4185-43A4-924F-3C7A5D95ECF6}.Release|x64.ActiveCfg = Release|x64 - {37D07516-4185-43A4-924F-3C7A5D95ECF6}.Release|x64.Build.0 = Release|x64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Debug|ARM64.Build.0 = Debug|ARM64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Debug|x64.ActiveCfg = Debug|x64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Debug|x64.Build.0 = Debug|x64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Release|ARM64.ActiveCfg = Release|ARM64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Release|ARM64.Build.0 = Release|ARM64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Release|x64.ActiveCfg = Release|x64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Release|x64.Build.0 = Release|x64 - {66614C26-314C-4B91-9071-76133422CFEF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {66614C26-314C-4B91-9071-76133422CFEF}.Debug|ARM64.Build.0 = Debug|ARM64 - {66614C26-314C-4B91-9071-76133422CFEF}.Debug|x64.ActiveCfg = Debug|x64 - {66614C26-314C-4B91-9071-76133422CFEF}.Debug|x64.Build.0 = Debug|x64 - {66614C26-314C-4B91-9071-76133422CFEF}.Release|ARM64.ActiveCfg = Release|ARM64 - {66614C26-314C-4B91-9071-76133422CFEF}.Release|ARM64.Build.0 = Release|ARM64 - {66614C26-314C-4B91-9071-76133422CFEF}.Release|x64.ActiveCfg = Release|x64 - {66614C26-314C-4B91-9071-76133422CFEF}.Release|x64.Build.0 = Release|x64 - {6CE438DF-C245-4997-A360-0A0939E4BA34}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {6CE438DF-C245-4997-A360-0A0939E4BA34}.Debug|ARM64.Build.0 = Debug|ARM64 - {6CE438DF-C245-4997-A360-0A0939E4BA34}.Debug|x64.ActiveCfg = Debug|x64 - {6CE438DF-C245-4997-A360-0A0939E4BA34}.Debug|x64.Build.0 = Debug|x64 - {6CE438DF-C245-4997-A360-0A0939E4BA34}.Release|ARM64.ActiveCfg = Release|ARM64 - {6CE438DF-C245-4997-A360-0A0939E4BA34}.Release|ARM64.Build.0 = Release|ARM64 - {6CE438DF-C245-4997-A360-0A0939E4BA34}.Release|x64.ActiveCfg = Release|x64 - {6CE438DF-C245-4997-A360-0A0939E4BA34}.Release|x64.Build.0 = Release|x64 - {E09AA983-C755-474F-83D6-A5CDF528C070}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E09AA983-C755-474F-83D6-A5CDF528C070}.Debug|ARM64.Build.0 = Debug|ARM64 - {E09AA983-C755-474F-83D6-A5CDF528C070}.Debug|x64.ActiveCfg = Debug|x64 - {E09AA983-C755-474F-83D6-A5CDF528C070}.Debug|x64.Build.0 = Debug|x64 - {E09AA983-C755-474F-83D6-A5CDF528C070}.Release|ARM64.ActiveCfg = Release|ARM64 - {E09AA983-C755-474F-83D6-A5CDF528C070}.Release|ARM64.Build.0 = Release|ARM64 - {E09AA983-C755-474F-83D6-A5CDF528C070}.Release|x64.ActiveCfg = Release|x64 - {E09AA983-C755-474F-83D6-A5CDF528C070}.Release|x64.Build.0 = Release|x64 - {6D56B64D-FF1F-488F-AFED-9B9854A5D399}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {6D56B64D-FF1F-488F-AFED-9B9854A5D399}.Debug|ARM64.Build.0 = Debug|ARM64 - {6D56B64D-FF1F-488F-AFED-9B9854A5D399}.Debug|x64.ActiveCfg = Debug|x64 - {6D56B64D-FF1F-488F-AFED-9B9854A5D399}.Debug|x64.Build.0 = Debug|x64 - {6D56B64D-FF1F-488F-AFED-9B9854A5D399}.Release|ARM64.ActiveCfg = Release|ARM64 - {6D56B64D-FF1F-488F-AFED-9B9854A5D399}.Release|ARM64.Build.0 = Release|ARM64 - {6D56B64D-FF1F-488F-AFED-9B9854A5D399}.Release|x64.ActiveCfg = Release|x64 - {6D56B64D-FF1F-488F-AFED-9B9854A5D399}.Release|x64.Build.0 = Release|x64 - {92EC89E4-9972-453A-8A1A-3A9E230C146A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {92EC89E4-9972-453A-8A1A-3A9E230C146A}.Debug|ARM64.Build.0 = Debug|ARM64 - {92EC89E4-9972-453A-8A1A-3A9E230C146A}.Debug|x64.ActiveCfg = Debug|x64 - {92EC89E4-9972-453A-8A1A-3A9E230C146A}.Debug|x64.Build.0 = Debug|x64 - {92EC89E4-9972-453A-8A1A-3A9E230C146A}.Release|ARM64.ActiveCfg = Release|ARM64 - {92EC89E4-9972-453A-8A1A-3A9E230C146A}.Release|ARM64.Build.0 = Release|ARM64 - {92EC89E4-9972-453A-8A1A-3A9E230C146A}.Release|x64.ActiveCfg = Release|x64 - {92EC89E4-9972-453A-8A1A-3A9E230C146A}.Release|x64.Build.0 = Release|x64 - {51939B4F-1F62-4BFF-A6A2-C08646E5BE95}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {51939B4F-1F62-4BFF-A6A2-C08646E5BE95}.Debug|ARM64.Build.0 = Debug|ARM64 - {51939B4F-1F62-4BFF-A6A2-C08646E5BE95}.Debug|x64.ActiveCfg = Debug|x64 - {51939B4F-1F62-4BFF-A6A2-C08646E5BE95}.Debug|x64.Build.0 = Debug|x64 - {51939B4F-1F62-4BFF-A6A2-C08646E5BE95}.Release|ARM64.ActiveCfg = Release|ARM64 - {51939B4F-1F62-4BFF-A6A2-C08646E5BE95}.Release|ARM64.Build.0 = Release|ARM64 - {51939B4F-1F62-4BFF-A6A2-C08646E5BE95}.Release|x64.ActiveCfg = Release|x64 - {51939B4F-1F62-4BFF-A6A2-C08646E5BE95}.Release|x64.Build.0 = Release|x64 - {D1160404-D3D1-497A-883A-4059C07C2273}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D1160404-D3D1-497A-883A-4059C07C2273}.Debug|ARM64.Build.0 = Debug|ARM64 - {D1160404-D3D1-497A-883A-4059C07C2273}.Debug|x64.ActiveCfg = Debug|x64 - {D1160404-D3D1-497A-883A-4059C07C2273}.Debug|x64.Build.0 = Debug|x64 - {D1160404-D3D1-497A-883A-4059C07C2273}.Release|ARM64.ActiveCfg = Release|ARM64 - {D1160404-D3D1-497A-883A-4059C07C2273}.Release|ARM64.Build.0 = Release|ARM64 - {D1160404-D3D1-497A-883A-4059C07C2273}.Release|x64.ActiveCfg = Release|x64 - {D1160404-D3D1-497A-883A-4059C07C2273}.Release|x64.Build.0 = Release|x64 - {40F6D69D-E321-400F-A767-5628C7AE453D}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {40F6D69D-E321-400F-A767-5628C7AE453D}.Debug|ARM64.Build.0 = Debug|ARM64 - {40F6D69D-E321-400F-A767-5628C7AE453D}.Debug|x64.ActiveCfg = Debug|x64 - {40F6D69D-E321-400F-A767-5628C7AE453D}.Debug|x64.Build.0 = Debug|x64 - {40F6D69D-E321-400F-A767-5628C7AE453D}.Release|ARM64.ActiveCfg = Release|ARM64 - {40F6D69D-E321-400F-A767-5628C7AE453D}.Release|ARM64.Build.0 = Release|ARM64 - {40F6D69D-E321-400F-A767-5628C7AE453D}.Release|x64.ActiveCfg = Release|x64 - {40F6D69D-E321-400F-A767-5628C7AE453D}.Release|x64.Build.0 = Release|x64 - {305DD37E-C85D-4B08-AAFE-7381FA890463}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {305DD37E-C85D-4B08-AAFE-7381FA890463}.Debug|ARM64.Build.0 = Debug|ARM64 - {305DD37E-C85D-4B08-AAFE-7381FA890463}.Debug|x64.ActiveCfg = Debug|x64 - {305DD37E-C85D-4B08-AAFE-7381FA890463}.Debug|x64.Build.0 = Debug|x64 - {305DD37E-C85D-4B08-AAFE-7381FA890463}.Release|ARM64.ActiveCfg = Release|ARM64 - {305DD37E-C85D-4B08-AAFE-7381FA890463}.Release|ARM64.Build.0 = Release|ARM64 - {305DD37E-C85D-4B08-AAFE-7381FA890463}.Release|x64.ActiveCfg = Release|x64 - {305DD37E-C85D-4B08-AAFE-7381FA890463}.Release|x64.Build.0 = Release|x64 - {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}.Debug|ARM64.Build.0 = Debug|ARM64 - {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}.Debug|x64.ActiveCfg = Debug|x64 - {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}.Debug|x64.Build.0 = Debug|x64 - {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}.Release|ARM64.ActiveCfg = Release|ARM64 - {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}.Release|ARM64.Build.0 = Release|ARM64 - {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}.Release|x64.ActiveCfg = Release|x64 - {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24}.Release|x64.Build.0 = Release|x64 - {14E62033-58D0-4A7D-8990-52F50A08BBBD}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {14E62033-58D0-4A7D-8990-52F50A08BBBD}.Debug|ARM64.Build.0 = Debug|ARM64 - {14E62033-58D0-4A7D-8990-52F50A08BBBD}.Debug|x64.ActiveCfg = Debug|x64 - {14E62033-58D0-4A7D-8990-52F50A08BBBD}.Debug|x64.Build.0 = Debug|x64 - {14E62033-58D0-4A7D-8990-52F50A08BBBD}.Release|ARM64.ActiveCfg = Release|ARM64 - {14E62033-58D0-4A7D-8990-52F50A08BBBD}.Release|ARM64.Build.0 = Release|ARM64 - {14E62033-58D0-4A7D-8990-52F50A08BBBD}.Release|x64.ActiveCfg = Release|x64 - {14E62033-58D0-4A7D-8990-52F50A08BBBD}.Release|x64.Build.0 = Release|x64 - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}.Debug|ARM64.Build.0 = Debug|ARM64 - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}.Debug|x64.ActiveCfg = Debug|x64 - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}.Debug|x64.Build.0 = Debug|x64 - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}.Release|ARM64.ActiveCfg = Release|ARM64 - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}.Release|ARM64.Build.0 = Release|ARM64 - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}.Release|x64.ActiveCfg = Release|x64 - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F}.Release|x64.Build.0 = Release|x64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Debug|ARM64.Build.0 = Debug|ARM64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Debug|ARM64.Deploy.0 = Debug|ARM64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Debug|x64.ActiveCfg = Debug|x64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Debug|x64.Build.0 = Debug|x64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Debug|x64.Deploy.0 = Debug|x64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Release|ARM64.ActiveCfg = Release|ARM64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Release|ARM64.Build.0 = Release|ARM64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Release|ARM64.Deploy.0 = Release|ARM64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Release|x64.ActiveCfg = Release|x64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Release|x64.Build.0 = Release|x64 - {C846F7A7-792A-47D9-B0CB-417C900EE03D}.Release|x64.Deploy.0 = Release|x64 - {C831231F-891C-4572-9694-45062534B42A}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C831231F-891C-4572-9694-45062534B42A}.Debug|ARM64.Build.0 = Debug|ARM64 - {C831231F-891C-4572-9694-45062534B42A}.Debug|ARM64.Deploy.0 = Debug|ARM64 - {C831231F-891C-4572-9694-45062534B42A}.Debug|x64.ActiveCfg = Debug|x64 - {C831231F-891C-4572-9694-45062534B42A}.Debug|x64.Build.0 = Debug|x64 - {C831231F-891C-4572-9694-45062534B42A}.Debug|x64.Deploy.0 = Debug|x64 - {C831231F-891C-4572-9694-45062534B42A}.Release|ARM64.ActiveCfg = Release|ARM64 - {C831231F-891C-4572-9694-45062534B42A}.Release|ARM64.Build.0 = Release|ARM64 - {C831231F-891C-4572-9694-45062534B42A}.Release|ARM64.Deploy.0 = Release|ARM64 - {C831231F-891C-4572-9694-45062534B42A}.Release|x64.ActiveCfg = Release|x64 - {C831231F-891C-4572-9694-45062534B42A}.Release|x64.Build.0 = Release|x64 - {C831231F-891C-4572-9694-45062534B42A}.Release|x64.Deploy.0 = Release|x64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Debug|ARM64.Build.0 = Debug|ARM64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Debug|ARM64.Deploy.0 = Debug|ARM64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Debug|x64.ActiveCfg = Debug|x64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Debug|x64.Build.0 = Debug|x64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Debug|x64.Deploy.0 = Debug|x64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Release|ARM64.ActiveCfg = Release|ARM64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Release|ARM64.Build.0 = Release|ARM64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Release|ARM64.Deploy.0 = Release|ARM64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Release|x64.ActiveCfg = Release|x64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Release|x64.Build.0 = Release|x64 - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90}.Release|x64.Deploy.0 = Release|x64 - {C66020D1-CB10-4CF7-8715-84C97FD5E5E2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C66020D1-CB10-4CF7-8715-84C97FD5E5E2}.Debug|ARM64.Build.0 = Debug|ARM64 - {C66020D1-CB10-4CF7-8715-84C97FD5E5E2}.Debug|x64.ActiveCfg = Debug|x64 - {C66020D1-CB10-4CF7-8715-84C97FD5E5E2}.Debug|x64.Build.0 = Debug|x64 - {C66020D1-CB10-4CF7-8715-84C97FD5E5E2}.Release|ARM64.ActiveCfg = Release|ARM64 - {C66020D1-CB10-4CF7-8715-84C97FD5E5E2}.Release|ARM64.Build.0 = Release|ARM64 - {C66020D1-CB10-4CF7-8715-84C97FD5E5E2}.Release|x64.ActiveCfg = Release|x64 - {C66020D1-CB10-4CF7-8715-84C97FD5E5E2}.Release|x64.Build.0 = Release|x64 - {79775343-7A3D-445D-9104-3DD5B2893DF9}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {79775343-7A3D-445D-9104-3DD5B2893DF9}.Debug|ARM64.Build.0 = Debug|ARM64 - {79775343-7A3D-445D-9104-3DD5B2893DF9}.Debug|x64.ActiveCfg = Debug|x64 - {79775343-7A3D-445D-9104-3DD5B2893DF9}.Debug|x64.Build.0 = Debug|x64 - {79775343-7A3D-445D-9104-3DD5B2893DF9}.Release|ARM64.ActiveCfg = Release|ARM64 - {79775343-7A3D-445D-9104-3DD5B2893DF9}.Release|ARM64.Build.0 = Release|ARM64 - {79775343-7A3D-445D-9104-3DD5B2893DF9}.Release|x64.ActiveCfg = Release|x64 - {79775343-7A3D-445D-9104-3DD5B2893DF9}.Release|x64.Build.0 = Release|x64 - {0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8}.Debug|ARM64.Build.0 = Debug|ARM64 - {0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8}.Debug|x64.ActiveCfg = Debug|x64 - {0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8}.Debug|x64.Build.0 = Debug|x64 - {0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8}.Release|ARM64.ActiveCfg = Release|ARM64 - {0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8}.Release|ARM64.Build.0 = Release|ARM64 - {0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8}.Release|x64.ActiveCfg = Release|x64 - {0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8}.Release|x64.Build.0 = Release|x64 - {89D0E199-B17A-418C-B2F8-7375B6708357}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {89D0E199-B17A-418C-B2F8-7375B6708357}.Debug|ARM64.Build.0 = Debug|ARM64 - {89D0E199-B17A-418C-B2F8-7375B6708357}.Debug|x64.ActiveCfg = Debug|x64 - {89D0E199-B17A-418C-B2F8-7375B6708357}.Debug|x64.Build.0 = Debug|x64 - {89D0E199-B17A-418C-B2F8-7375B6708357}.Release|ARM64.ActiveCfg = Release|ARM64 - {89D0E199-B17A-418C-B2F8-7375B6708357}.Release|ARM64.Build.0 = Release|ARM64 - {89D0E199-B17A-418C-B2F8-7375B6708357}.Release|x64.ActiveCfg = Release|x64 - {89D0E199-B17A-418C-B2F8-7375B6708357}.Release|x64.Build.0 = Release|x64 - {0DB0F63A-D2F8-4DA3-A650-2D0B8724218E}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {0DB0F63A-D2F8-4DA3-A650-2D0B8724218E}.Debug|ARM64.Build.0 = Debug|ARM64 - {0DB0F63A-D2F8-4DA3-A650-2D0B8724218E}.Debug|x64.ActiveCfg = Debug|x64 - {0DB0F63A-D2F8-4DA3-A650-2D0B8724218E}.Debug|x64.Build.0 = Debug|x64 - {0DB0F63A-D2F8-4DA3-A650-2D0B8724218E}.Release|ARM64.ActiveCfg = Release|ARM64 - {0DB0F63A-D2F8-4DA3-A650-2D0B8724218E}.Release|ARM64.Build.0 = Release|ARM64 - {0DB0F63A-D2F8-4DA3-A650-2D0B8724218E}.Release|x64.ActiveCfg = Release|x64 - {0DB0F63A-D2F8-4DA3-A650-2D0B8724218E}.Release|x64.Build.0 = Release|x64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Debug|ARM64.Build.0 = Debug|ARM64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Debug|ARM64.Deploy.0 = Debug|ARM64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Debug|x64.ActiveCfg = Debug|x64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Debug|x64.Build.0 = Debug|x64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Debug|x64.Deploy.0 = Debug|x64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Release|ARM64.ActiveCfg = Release|ARM64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Release|ARM64.Build.0 = Release|ARM64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Release|ARM64.Deploy.0 = Release|ARM64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Release|x64.ActiveCfg = Release|x64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Release|x64.Build.0 = Release|x64 - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D}.Release|x64.Deploy.0 = Release|x64 - {C0CE3B5E-16D3-495D-B335-CA791B660162}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {C0CE3B5E-16D3-495D-B335-CA791B660162}.Debug|ARM64.Build.0 = Debug|ARM64 - {C0CE3B5E-16D3-495D-B335-CA791B660162}.Debug|x64.ActiveCfg = Debug|x64 - {C0CE3B5E-16D3-495D-B335-CA791B660162}.Debug|x64.Build.0 = Debug|x64 - {C0CE3B5E-16D3-495D-B335-CA791B660162}.Release|ARM64.ActiveCfg = Release|ARM64 - {C0CE3B5E-16D3-495D-B335-CA791B660162}.Release|ARM64.Build.0 = Release|ARM64 - {C0CE3B5E-16D3-495D-B335-CA791B660162}.Release|x64.ActiveCfg = Release|x64 - {C0CE3B5E-16D3-495D-B335-CA791B660162}.Release|x64.Build.0 = Release|x64 - {3A9A7297-92C4-4F16-B6F9-8D4AB652C86C}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {3A9A7297-92C4-4F16-B6F9-8D4AB652C86C}.Debug|ARM64.Build.0 = Debug|ARM64 - {3A9A7297-92C4-4F16-B6F9-8D4AB652C86C}.Debug|x64.ActiveCfg = Debug|x64 - {3A9A7297-92C4-4F16-B6F9-8D4AB652C86C}.Debug|x64.Build.0 = Debug|x64 - {3A9A7297-92C4-4F16-B6F9-8D4AB652C86C}.Release|ARM64.ActiveCfg = Release|ARM64 - {3A9A7297-92C4-4F16-B6F9-8D4AB652C86C}.Release|ARM64.Build.0 = Release|ARM64 - {3A9A7297-92C4-4F16-B6F9-8D4AB652C86C}.Release|x64.ActiveCfg = Release|x64 - {3A9A7297-92C4-4F16-B6F9-8D4AB652C86C}.Release|x64.Build.0 = Release|x64 - {605E914B-7232-4789-AF46-BF5D3DDFC14E}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {605E914B-7232-4789-AF46-BF5D3DDFC14E}.Debug|ARM64.Build.0 = Debug|ARM64 - {605E914B-7232-4789-AF46-BF5D3DDFC14E}.Debug|x64.ActiveCfg = Debug|x64 - {605E914B-7232-4789-AF46-BF5D3DDFC14E}.Debug|x64.Build.0 = Debug|x64 - {605E914B-7232-4789-AF46-BF5D3DDFC14E}.Release|ARM64.ActiveCfg = Release|ARM64 - {605E914B-7232-4789-AF46-BF5D3DDFC14E}.Release|ARM64.Build.0 = Release|ARM64 - {605E914B-7232-4789-AF46-BF5D3DDFC14E}.Release|x64.ActiveCfg = Release|x64 - {605E914B-7232-4789-AF46-BF5D3DDFC14E}.Release|x64.Build.0 = Release|x64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Debug|ARM64.Build.0 = Debug|ARM64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Debug|ARM64.Deploy.0 = Debug|ARM64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Debug|x64.ActiveCfg = Debug|x64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Debug|x64.Build.0 = Debug|x64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Debug|x64.Deploy.0 = Debug|x64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Release|ARM64.ActiveCfg = Release|ARM64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Release|ARM64.Build.0 = Release|ARM64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Release|ARM64.Deploy.0 = Release|ARM64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Release|x64.ActiveCfg = Release|x64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Release|x64.Build.0 = Release|x64 - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9}.Release|x64.Deploy.0 = Release|x64 - {D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE}.Debug|ARM64.Build.0 = Debug|ARM64 - {D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE}.Debug|x64.ActiveCfg = Debug|x64 - {D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE}.Debug|x64.Build.0 = Debug|x64 - {D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE}.Release|ARM64.ActiveCfg = Release|ARM64 - {D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE}.Release|ARM64.Build.0 = Release|ARM64 - {D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE}.Release|x64.ActiveCfg = Release|x64 - {D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE}.Release|x64.Build.0 = Release|x64 - {7F5B9557-5878-4438-A721-3E28296BA193}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {7F5B9557-5878-4438-A721-3E28296BA193}.Debug|ARM64.Build.0 = Debug|ARM64 - {7F5B9557-5878-4438-A721-3E28296BA193}.Debug|x64.ActiveCfg = Debug|x64 - {7F5B9557-5878-4438-A721-3E28296BA193}.Debug|x64.Build.0 = Debug|x64 - {7F5B9557-5878-4438-A721-3E28296BA193}.Release|ARM64.ActiveCfg = Release|ARM64 - {7F5B9557-5878-4438-A721-3E28296BA193}.Release|ARM64.Build.0 = Release|ARM64 - {7F5B9557-5878-4438-A721-3E28296BA193}.Release|x64.ActiveCfg = Release|x64 - {7F5B9557-5878-4438-A721-3E28296BA193}.Release|x64.Build.0 = Release|x64 - {0A84F764-3A88-44CD-AA96-41BDBD48627B}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {0A84F764-3A88-44CD-AA96-41BDBD48627B}.Debug|ARM64.Build.0 = Debug|ARM64 - {0A84F764-3A88-44CD-AA96-41BDBD48627B}.Debug|x64.ActiveCfg = Debug|x64 - {0A84F764-3A88-44CD-AA96-41BDBD48627B}.Debug|x64.Build.0 = Debug|x64 - {0A84F764-3A88-44CD-AA96-41BDBD48627B}.Release|ARM64.ActiveCfg = Release|ARM64 - {0A84F764-3A88-44CD-AA96-41BDBD48627B}.Release|ARM64.Build.0 = Release|ARM64 - {0A84F764-3A88-44CD-AA96-41BDBD48627B}.Release|x64.ActiveCfg = Release|x64 - {0A84F764-3A88-44CD-AA96-41BDBD48627B}.Release|x64.Build.0 = Release|x64 - {E4585179-2AC1-4D5F-A3FF-CFC5392F694C}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {E4585179-2AC1-4D5F-A3FF-CFC5392F694C}.Debug|ARM64.Build.0 = Debug|ARM64 - {E4585179-2AC1-4D5F-A3FF-CFC5392F694C}.Debug|x64.ActiveCfg = Debug|x64 - {E4585179-2AC1-4D5F-A3FF-CFC5392F694C}.Debug|x64.Build.0 = Debug|x64 - {E4585179-2AC1-4D5F-A3FF-CFC5392F694C}.Release|ARM64.ActiveCfg = Release|ARM64 - {E4585179-2AC1-4D5F-A3FF-CFC5392F694C}.Release|ARM64.Build.0 = Release|ARM64 - {E4585179-2AC1-4D5F-A3FF-CFC5392F694C}.Release|x64.ActiveCfg = Release|x64 - {E4585179-2AC1-4D5F-A3FF-CFC5392F694C}.Release|x64.Build.0 = Release|x64 - {CA7D8106-30B9-4AEC-9D05-B69B31B8C461}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {CA7D8106-30B9-4AEC-9D05-B69B31B8C461}.Debug|ARM64.Build.0 = Debug|ARM64 - {CA7D8106-30B9-4AEC-9D05-B69B31B8C461}.Debug|x64.ActiveCfg = Debug|x64 - {CA7D8106-30B9-4AEC-9D05-B69B31B8C461}.Debug|x64.Build.0 = Debug|x64 - {CA7D8106-30B9-4AEC-9D05-B69B31B8C461}.Release|ARM64.ActiveCfg = Release|ARM64 - {CA7D8106-30B9-4AEC-9D05-B69B31B8C461}.Release|ARM64.Build.0 = Release|ARM64 - {CA7D8106-30B9-4AEC-9D05-B69B31B8C461}.Release|x64.ActiveCfg = Release|x64 - {CA7D8106-30B9-4AEC-9D05-B69B31B8C461}.Release|x64.Build.0 = Release|x64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Debug|ARM64.Build.0 = Debug|ARM64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Debug|ARM64.Deploy.0 = Debug|ARM64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Debug|x64.ActiveCfg = Debug|x64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Debug|x64.Build.0 = Debug|x64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Debug|x64.Deploy.0 = Debug|x64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Release|ARM64.ActiveCfg = Release|ARM64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Release|ARM64.Build.0 = Release|ARM64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Release|ARM64.Deploy.0 = Release|ARM64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Release|x64.ActiveCfg = Release|x64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Release|x64.Build.0 = Release|x64 - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449}.Release|x64.Deploy.0 = Release|x64 - {A558C25D-2007-498E-8B6F-43405AFAE9E2}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {A558C25D-2007-498E-8B6F-43405AFAE9E2}.Debug|ARM64.Build.0 = Debug|ARM64 - {A558C25D-2007-498E-8B6F-43405AFAE9E2}.Debug|x64.ActiveCfg = Debug|x64 - {A558C25D-2007-498E-8B6F-43405AFAE9E2}.Debug|x64.Build.0 = Debug|x64 - {A558C25D-2007-498E-8B6F-43405AFAE9E2}.Release|ARM64.ActiveCfg = Release|ARM64 - {A558C25D-2007-498E-8B6F-43405AFAE9E2}.Release|ARM64.Build.0 = Release|ARM64 - {A558C25D-2007-498E-8B6F-43405AFAE9E2}.Release|x64.ActiveCfg = Release|x64 - {A558C25D-2007-498E-8B6F-43405AFAE9E2}.Release|x64.Build.0 = Release|x64 - {08F9155D-B6DC-46E5-9C83-AF60B655898B}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {08F9155D-B6DC-46E5-9C83-AF60B655898B}.Debug|ARM64.Build.0 = Debug|ARM64 - {08F9155D-B6DC-46E5-9C83-AF60B655898B}.Debug|x64.ActiveCfg = Debug|x64 - {08F9155D-B6DC-46E5-9C83-AF60B655898B}.Debug|x64.Build.0 = Debug|x64 - {08F9155D-B6DC-46E5-9C83-AF60B655898B}.Release|ARM64.ActiveCfg = Release|ARM64 - {08F9155D-B6DC-46E5-9C83-AF60B655898B}.Release|ARM64.Build.0 = Release|ARM64 - {08F9155D-B6DC-46E5-9C83-AF60B655898B}.Release|x64.ActiveCfg = Release|x64 - {08F9155D-B6DC-46E5-9C83-AF60B655898B}.Release|x64.Build.0 = Release|x64 - {4382A954-179A-4078-92AF-715187DFFF50}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {4382A954-179A-4078-92AF-715187DFFF50}.Debug|ARM64.Build.0 = Debug|ARM64 - {4382A954-179A-4078-92AF-715187DFFF50}.Debug|x64.ActiveCfg = Debug|x64 - {4382A954-179A-4078-92AF-715187DFFF50}.Debug|x64.Build.0 = Debug|x64 - {4382A954-179A-4078-92AF-715187DFFF50}.Release|ARM64.ActiveCfg = Release|ARM64 - {4382A954-179A-4078-92AF-715187DFFF50}.Release|ARM64.Build.0 = Release|ARM64 - {4382A954-179A-4078-92AF-715187DFFF50}.Release|x64.ActiveCfg = Release|x64 - {4382A954-179A-4078-92AF-715187DFFF50}.Release|x64.Build.0 = Release|x64 - {EBED240C-8702-452D-B764-6DB9DA9179AF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {EBED240C-8702-452D-B764-6DB9DA9179AF}.Debug|ARM64.Build.0 = Debug|ARM64 - {EBED240C-8702-452D-B764-6DB9DA9179AF}.Debug|x64.ActiveCfg = Debug|x64 - {EBED240C-8702-452D-B764-6DB9DA9179AF}.Debug|x64.Build.0 = Debug|x64 - {EBED240C-8702-452D-B764-6DB9DA9179AF}.Release|ARM64.ActiveCfg = Release|ARM64 - {EBED240C-8702-452D-B764-6DB9DA9179AF}.Release|ARM64.Build.0 = Release|ARM64 - {EBED240C-8702-452D-B764-6DB9DA9179AF}.Release|x64.ActiveCfg = Release|x64 - {EBED240C-8702-452D-B764-6DB9DA9179AF}.Release|x64.Build.0 = Release|x64 - {4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}.Debug|ARM64.Build.0 = Debug|ARM64 - {4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}.Debug|x64.ActiveCfg = Debug|x64 - {4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}.Debug|x64.Build.0 = Debug|x64 - {4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}.Release|ARM64.ActiveCfg = Release|ARM64 - {4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}.Release|ARM64.Build.0 = Release|ARM64 - {4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}.Release|x64.ActiveCfg = Release|x64 - {4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}.Release|x64.Build.0 = Release|x64 - {5702B3CC-8575-48D5-83D8-15BB42269CD3}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {5702B3CC-8575-48D5-83D8-15BB42269CD3}.Debug|ARM64.Build.0 = Debug|ARM64 - {5702B3CC-8575-48D5-83D8-15BB42269CD3}.Debug|x64.ActiveCfg = Debug|x64 - {5702B3CC-8575-48D5-83D8-15BB42269CD3}.Debug|x64.Build.0 = Debug|x64 - {5702B3CC-8575-48D5-83D8-15BB42269CD3}.Release|ARM64.ActiveCfg = Release|ARM64 - {5702B3CC-8575-48D5-83D8-15BB42269CD3}.Release|ARM64.Build.0 = Release|ARM64 - {5702B3CC-8575-48D5-83D8-15BB42269CD3}.Release|x64.ActiveCfg = Release|x64 - {5702B3CC-8575-48D5-83D8-15BB42269CD3}.Release|x64.Build.0 = Release|x64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Debug|ARM64.Build.0 = Debug|ARM64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Debug|x64.ActiveCfg = Debug|x64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Debug|x64.Build.0 = Debug|x64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Debug|x86.ActiveCfg = Debug|x64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Debug|x86.Build.0 = Debug|x64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Release|ARM64.ActiveCfg = Release|ARM64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Release|ARM64.Build.0 = Release|ARM64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Release|x64.ActiveCfg = Release|x64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Release|x64.Build.0 = Release|x64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Release|x86.ActiveCfg = Release|x64 - {64B88F02-CD88-4ED8-9624-989A800230F9}.Release|x86.Build.0 = Release|x64 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {3BB8493E-D18E-4485-A320-CB40F90F55AE} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {F9C68EDF-AC74-4B77-9AF1-005D9C9F6A99} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} - {9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} - {1A066C63-64B3-45F8-92FE-664E1CCE8077} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {5CCC8468-DEC8-4D36-99D4-5C891BEBD481} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} - {89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {B25AC7A5-FB9F-4789-B392-D5C85E948670} = {89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3} - {51920F1F-C28C-4ADF-8660-4238766796C2} = {89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3} - {A3935CF4-46C5-4A88-84D3-6B12E16E6BA2} = {89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3} - {2151F984-E006-4A9F-92EF-C6DDE3DC8413} = {89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3} - {89F34AF7-1C34-4A72-AA6E-534BCF972BD9} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {6C7F47CC-2151-44A3-A546-41C70025132C} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {2BE46397-4DFA-414C-9BD4-41E4BBF8CB34} = {6C7F47CC-2151-44A3-A546-41C70025132C} - {0B43679E-EDFA-4DA0-AD30-F4628B308B1B} = {6C7F47CC-2151-44A3-A546-41C70025132C} - {E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8} = {6C7F47CC-2151-44A3-A546-41C70025132C} - {17DA04DF-E393-4397-9CF0-84DABE11032E} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {38BDB927-829B-4C65-9CD9-93FB05D66D65} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {8AFFA899-0B73-49EC-8C50-0FADDA57B2FC} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {C140A3EF-6DBF-4084-9D4C-4EB5A99FEE68} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {4FD29318-A8AB-4D8F-AA47-60BC241B8DA3} = {C140A3EF-6DBF-4084-9D4C-4EB5A99FEE68} - {8451ECDD-2EA4-4966-BB0A-7BBC40138E80} = {C140A3EF-6DBF-4084-9D4C-4EB5A99FEE68} - {FF742965-9A80-41A5-B042-D6C7D3A21708} = {C140A3EF-6DBF-4084-9D4C-4EB5A99FEE68} - {4AFC9975-2456-4C70-94A4-84073C1CED93} = {C140A3EF-6DBF-4084-9D4C-4EB5A99FEE68} - {59BD9891-3837-438A-958D-ADC7F91F6F7E} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {4D971245-7A70-41D5-BAA0-DDB5684CAF51} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {74F1B9ED-F59C-4FE7-B473-7B453E30837E} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {FDB3555B-58EF-4AE6-B5F1-904719637AB4} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {F8B870EB-D5F5-45BA-9CF7-A5C459818820} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {E364F67B-BB12-4E91-B639-355866EBCD8B} = {C140A3EF-6DBF-4084-9D4C-4EB5A99FEE68} - {F97E5003-F263-4D4A-A964-0F1F3C82DEF2} = {C140A3EF-6DBF-4084-9D4C-4EB5A99FEE68} - {2F305555-C296-497E-AC20-5FA1B237996A} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {AF2349B8-E5B6-4004-9502-687C1C7730B1} = {2F305555-C296-497E-AC20-5FA1B237996A} - {6A71162E-FC4C-4A2C-B90F-3CF94F59A9BB} = {2F305555-C296-497E-AC20-5FA1B237996A} - {A2B51B8B-8F90-424E-BC97-F9AB7D76CA1A} = {2F305555-C296-497E-AC20-5FA1B237996A} - {DA425894-6E13-404F-8DCB-78584EC0557A} = {2F305555-C296-497E-AC20-5FA1B237996A} - {060D75DA-2D1C-48E6-A4A1-6F0718B64661} = {2F305555-C296-497E-AC20-5FA1B237996A} - {748417CA-F17E-487F-9411-CAFB6D3F4877} = {2F305555-C296-497E-AC20-5FA1B237996A} - {217DF501-135C-4E38-BFC8-99D4821032EA} = {2F305555-C296-497E-AC20-5FA1B237996A} - {B1BCC8C6-46B5-4BFA-8F22-20F32D99EC6A} = {C3081D9A-1586-441A-B5F4-ED815B3719C1} - {787B8AA6-CA93-4C84-96FE-DF31110AD1C4} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {08C8C05F-0362-41BC-818C-724572DF8B06} = {C140A3EF-6DBF-4084-9D4C-4EB5A99FEE68} - {5D00D290-4016-4CFE-9E41-1E7C724509BA} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {4AED67B6-55FD-486F-B917-E543DEE2CB3C} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {42851751-CBC8-45A6-97F5-7A0753F7B4D1} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {1EF1EEF0-10F0-4F2E-8550-39B6D8044D3E} = {2F305555-C296-497E-AC20-5FA1B237996A} - {8FFE09DA-FA4F-4EE1-B3A2-AD5497FBD1AD} = {2F305555-C296-497E-AC20-5FA1B237996A} - {655C9AF2-18D3-4DA6-80E4-85504A7722BA} = {1D78B84B-CA39-406C-98F4-71F7EC266CC0} - {BA58206B-1493-4C75-BFEA-A85768A1E156} = {1D78B84B-CA39-406C-98F4-71F7EC266CC0} - {1D78B84B-CA39-406C-98F4-71F7EC266CC0} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {03276A39-D4E9-417C-8FFD-200B0EE5E871} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {B81FB7B6-D30E-428F-908A-41422EFC1172} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {0F85E674-34AE-443D-954C-8321EB8B93B1} = {C3081D9A-1586-441A-B5F4-ED815B3719C1} - {632BBE62-5421-49EA-835A-7FFA4F499BD6} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {4FA206A5-F69F-4193-BF8F-F6EEB496734C} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1} = {1D78B84B-CA39-406C-98F4-71F7EC266CC0} - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F} = {E4E03FE0-94FD-47C7-88C5-F17D0AA549D3} - {FD8EB419-FF9C-4D88-BB6F-BF6CED37747B} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {DA5A6FE9-0040-40CC-83CC-764AE5306590} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {0351ADA4-0C32-4652-9BA0-41F7B602372B} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD} = {E4E03FE0-94FD-47C7-88C5-F17D0AA549D3} - {6955446D-23F7-4023-9BB3-8657F904AF99} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {58736667-1027-4AD7-BFDF-7A3A6474103A} = {5A7818A8-109C-4E1C-850D-1A654E234B0E} - {D92131D6-7610-4D60-A7DB-1C169783F83B} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525} = {D92131D6-7610-4D60-A7DB-1C169783F83B} - {031AC72E-FA28-4AB7-B690-6F7B9C28AA73} = {D92131D6-7610-4D60-A7DB-1C169783F83B} - {0B593A6C-4143-4337-860E-DB5710FB87DB} = {D92131D6-7610-4D60-A7DB-1C169783F83B} - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {5A7818A8-109C-4E1C-850D-1A654E234B0E} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {E4E03FE0-94FD-47C7-88C5-F17D0AA549D3} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {7319089E-46D6-4400-BC65-E39BDF1416EE} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {CABA8DFB-823B-4BF2-93AC-3F31984150D9} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {98537082-0FDB-40DE-ABD8-0DC5A4269BAB} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {B39DC643-4663-475E-B329-03F0C9918D48} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {8F62026A-294B-41C6-8839-87463613F216} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {C3A17DCA-217B-462C-BB0C-BE086AF80081} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {69E1EE8D-143A-4060-9129-4658ACF14AAF} = {2F305555-C296-497E-AC20-5FA1B237996A} - {ECC20689-002A-4354-95A6-B58DF089C6FF} = {2F305555-C296-497E-AC20-5FA1B237996A} - {4BABF3FE-3451-42FD-873F-3C332E18DCEF} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {0648DF05-5DDA-4BE1-B5F2-584926EBDB65} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {BA661F5B-1D5A-4FFC-9BF1-FC39DF280BDD} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {E496B7FC-1E99-4BAB-849B-0E8367040B02} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {7F4B3A60-BC27-45A7-8000-68B0B6EA7466} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {8DF78B53-200E-451F-9328-01EB907193AE} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {23D2070D-E4AD-4ADD-85A7-083D9C76AD49} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {62173D9A-6724-4C00-A1C8-FB646480A9EC} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {127F38E0-40AA-4594-B955-5616BF206882} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {5E7360A8-D048-4ED3-8F09-0BFD64C5529A} = {127F38E0-40AA-4594-B955-5616BF206882} - {D940E07F-532C-4FF3-883F-790DA014F19A} = {127F38E0-40AA-4594-B955-5616BF206882} - {BB23A474-5058-4F75-8FA3-5FE3DE53CDF4} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {3E424AD2-19E5-4AE6-B833-F53963EB5FC1} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {106CBECA-0701-4FC3-838C-9DF816A19AE2} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {2D604C07-51FC-46BB-9EB7-75AECC7F5E81} = {106CBECA-0701-4FC3-838C-9DF816A19AE2} - {2EDB3EB4-FA92-4BFF-B2D8-566584837231} = {106CBECA-0701-4FC3-838C-9DF816A19AE2} - {48804216-2A0E-4168-A6D8-9CD068D14227} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} - {FF1D7936-842A-4BBB-8BEA-E9FE796DE700} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} - {5043CECE-E6A7-4867-9CBE-02D27D83747A} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {11491FD8-F921-48BF-880C-7FEA185B80A1} = {2F305555-C296-497E-AC20-5FA1B237996A} - {F40C3397-1834-4530-B2D9-8F8B8456BCDF} = {2F305555-C296-497E-AC20-5FA1B237996A} - {A2D583F0-B70C-4462-B1F0-8E81AFB7BA85} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {4ED320BC-BA04-4D42-8D15-CBE62151F08B} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {322566EF-20DC-43A6-B9F8-616AF942579A} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {E94FD11C-0591-456F-899F-EFC0CA548336} = {322566EF-20DC-43A6-B9F8-616AF942579A} - {782A61BE-9D85-4081-B35C-1CCC9DCC1E88} = {322566EF-20DC-43A6-B9F8-616AF942579A} - {809AA252-E17A-4FA2-B0A1-0450976B763F} = {2F305555-C296-497E-AC20-5FA1B237996A} - {133281D8-1BCE-4D07-B31E-796612A9609E} = {2F305555-C296-497E-AC20-5FA1B237996A} - {805306FF-A562-4415-8DEF-E493BDC45918} = {2F305555-C296-497E-AC20-5FA1B237996A} - {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3} = {2F305555-C296-497E-AC20-5FA1B237996A} - {60CD2D4F-C3B9-4897-9821-FCA5098B41CE} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2} = {60CD2D4F-C3B9-4897-9821-FCA5098B41CE} - {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9} = {60CD2D4F-C3B9-4897-9821-FCA5098B41CE} - {9F94B303-5E21-4364-9362-64426F8DB932} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {EAE14C0E-7A6B-45DA-9080-A7D8C077BA6E} = {322566EF-20DC-43A6-B9F8-616AF942579A} - {F7C8C0F1-5431-4347-89D0-8E5354F93CF2} = {2F305555-C296-497E-AC20-5FA1B237996A} - {F1F6B6B6-9F18-4A17-8B5C-97DF552C53DC} = {2F305555-C296-497E-AC20-5FA1B237996A} - {04B193D7-3E21-46B8-A958-89B63A8A69DE} = {2F305555-C296-497E-AC20-5FA1B237996A} - {5BDBD6C9-A31F-4CEB-871A-5E9E709197EF} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {FD464B4C-2F68-4D06-91E7-4208146C41F5} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {8FE5A5EE-1B59-401C-9FB3-B04ECD3E29C1} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {020A7474-3601-4160-A159-D7B70B77B15F} = {C3081D9A-1586-441A-B5F4-ED815B3719C1} - {27718999-C175-450A-861C-89F911E16A88} = {89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3} - {1DBBB112-4BB1-444B-8EBB-E66555C76BA6} = {89E20BCE-EB9C-46C8-8B50-E01A82E6FDC3} - {5A1DB2F0-0715-4B3B-98E6-79BC41540045} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {93B72A06-C8BD-484F-A6F7-C9F280B150BF} = {6C7F47CC-2151-44A3-A546-41C70025132C} - {18B3DB45-4FFE-4D01-97D6-5223FEEE1853} = {6C7F47CC-2151-44A3-A546-41C70025132C} - {0F14491C-6369-4C45-AAA8-135814E66E6B} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {34A354C5-23C7-4343-916C-C52DAF4FC39D} = {0F14491C-6369-4C45-AAA8-135814E66E6B} - {3264DF53-C805-4B0C-867C-FCEAF7AEF762} = {0F14491C-6369-4C45-AAA8-135814E66E6B} - {31CAD28E-778A-441C-85BC-40AB3EAA2A10} = {0F14491C-6369-4C45-AAA8-135814E66E6B} - {A50C70A6-2DA0-4027-B90E-B1A40755A8A5} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {25C91A4E-BA4E-467A-85CD-8B62545BF674} = {A50C70A6-2DA0-4027-B90E-B1A40755A8A5} - {6AB6A2D6-F859-4A82-9184-0BD29C9F07D1} = {A50C70A6-2DA0-4027-B90E-B1A40755A8A5} - {212AD910-8488-4036-BE20-326931B75FB2} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {7AC943C9-52E8-44CF-9083-744D8049667B} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {54A93AF7-60C7-4F6C-99D2-FBB1F75F853A} = {7AC943C9-52E8-44CF-9083-744D8049667B} - {92C39820-9F84-4529-BC7D-22AAE514D63B} = {7AC943C9-52E8-44CF-9083-744D8049667B} - {515554D1-D004-4F7F-A107-2211FC0F6B2C} = {7AC943C9-52E8-44CF-9083-744D8049667B} - {C97D9A5D-206C-454E-997E-009E227D7F02} = {0F14491C-6369-4C45-AAA8-135814E66E6B} - {31D1C81D-765F-4446-AA62-E743F6325049} = {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} - {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {E2D03E0F-7A75-4813-9F4B-D8763D43FD3A} = {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} - {B41B888C-7DB8-4747-B262-4062E05A230D} = {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} - {AB82E5DD-C32D-4F28-9746-2C780846188E} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {57175EC7-92A5-4C1E-8244-E3FBCA2A81DE} = {AB82E5DD-C32D-4F28-9746-2C780846188E} - {E69B044A-2F8A-45AA-AD0B-256C59421807} = {AB82E5DD-C32D-4F28-9746-2C780846188E} - {C604B37E-9D0E-4484-8778-E8B31B0E1B3A} = {AB82E5DD-C32D-4F28-9746-2C780846188E} - {E599C30B-9DC8-4E5A-BF27-93D4CCEDE788} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {00EE9BA6-4E8F-43CA-960D-D4882F0FBB97} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {17B4FA70-001E-4D33-BBBB-0D142DBC2E20} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {A1425B53-3D61-4679-8623-E64A0D3D0A48} = {17B4FA70-001E-4D33-BBBB-0D142DBC2E20} - {9D7A6DE0-7D27-424D-ABAE-41B2161F9A03} = {17B4FA70-001E-4D33-BBBB-0D142DBC2E20} - {17A99C7C-0BFF-45BB-A9FD-63A0DDC105BB} = {17B4FA70-001E-4D33-BBBB-0D142DBC2E20} - {AA9F0AF8-7924-4D59-BAA1-E36F1304E0DC} = {17B4FA70-001E-4D33-BBBB-0D142DBC2E20} - {ED9A1AC6-AEB0-4569-A6E9-E1696182B545} = {2F305555-C296-497E-AC20-5FA1B237996A} - {5A5DD09D-723A-44D3-8F2B-293584C3D731} = {2F305555-C296-497E-AC20-5FA1B237996A} - {B3E869C4-8210-4EBD-A621-FF4C4AFCBFA9} = {2F305555-C296-497E-AC20-5FA1B237996A} - {54F7C616-FD41-4E62-BFF9-015686914F4D} = {2F305555-C296-497E-AC20-5FA1B237996A} - {143F13E3-D2E3-4D83-B035-356612D99956} = {2F305555-C296-497E-AC20-5FA1B237996A} - {56CC2F10-6E41-453D-BE16-C593A5E58482} = {2F305555-C296-497E-AC20-5FA1B237996A} - {CA5518ED-0458-4B09-8F53-4122B9888655} = {2F305555-C296-497E-AC20-5FA1B237996A} - {D6DCC3AE-18C0-488A-B978-BAA9E3CFF09D} = {2F305555-C296-497E-AC20-5FA1B237996A} - {2BBC9E33-21EC-401C-84DA-BB6590A9B2AA} = {2F305555-C296-497E-AC20-5FA1B237996A} - {B6C42F16-73EB-477E-8B0D-4E6CF6C20AAC} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {2833C9C6-AB32-4048-A5C7-A70898337B57} = {B6C42F16-73EB-477E-8B0D-4E6CF6C20AAC} - {50B82783-242F-42D2-BC03-B3430BF01354} = {B6C42F16-73EB-477E-8B0D-4E6CF6C20AAC} - {B5EB9FE9-37EF-47C3-B8B8-81AD3C2972C2} = {B6C42F16-73EB-477E-8B0D-4E6CF6C20AAC} - {A663E672-B26D-4EC0-BEAB-FE2E424AC46F} = {B6C42F16-73EB-477E-8B0D-4E6CF6C20AAC} - {8A08D663-4995-40E3-B42C-3F910625F284} = {322566EF-20DC-43A6-B9F8-616AF942579A} - {923DF87C-CA99-4D1C-B1D2-959174E95BFA} = {322566EF-20DC-43A6-B9F8-616AF942579A} - {D5E42C63-57C5-4EF6-AECE-1E2FCA725B77} = {322566EF-20DC-43A6-B9F8-616AF942579A} - {D962A009-834F-4EEC-AABB-430DF8F98E39} = {322566EF-20DC-43A6-B9F8-616AF942579A} - {9873BA05-4C41-4819-9283-CF45D795431B} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {FC373B24-3293-453C-AAF5-CF2909DCEE6A} = {9873BA05-4C41-4819-9283-CF45D795431B} - {9CE59ED5-7087-4353-88EB-788038A73CEC} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {FD86C06A-FB54-4D5E-9831-1CDADF60D45F} = {929C1324-22E8-4412-A9A8-80E85F3985A5} - {697C6AF9-0A48-49A9-866C-67DA12384015} = {929C1324-22E8-4412-A9A8-80E85F3985A5} - {929C1324-22E8-4412-A9A8-80E85F3985A5} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {9EBAA524-0EDA-470B-95D4-39383285CBB2} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {500DED3E-CFB5-4ED5-ACC6-02B3D6DC336D} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {D095BE44-1F2E-463E-A494-121892A75EA2} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {90F9FA90-2C20-4004-96E6-F3B78151F5A5} = {4AFC9975-2456-4C70-94A4-84073C1CED93} - {3B227528-4BA6-4CAF-B44A-A10C78A64849} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {F5E1146E-B7B3-4E11-85FD-270A500BD78C} = {3B227528-4BA6-4CAF-B44A-A10C78A64849} - {3157FA75-86CF-4EE2-8F62-C43F776493C6} = {3B227528-4BA6-4CAF-B44A-A10C78A64849} - {4C0D0746-BE5B-49EE-BD5D-A7811628AE8B} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {FC8EB78F-F061-4BD9-A3F6-507BEA965E2B} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} - {538ED0BB-B863-4B20-98CC-BCDF7FA0B68A} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {51465DA1-C18B-4B99-93E1-ECF8E0FA0CBA} = {538ED0BB-B863-4B20-98CC-BCDF7FA0B68A} - {B9420661-B0E4-4241-ABD4-4A27A1F64250} = {538ED0BB-B863-4B20-98CC-BCDF7FA0B68A} - {CCB5E44F-84D9-4203-83C6-1C9EC9302BC7} = {2F305555-C296-497E-AC20-5FA1B237996A} - {D949EC7D-48A9-4279-95D5-078E7FD1F048} = {2F305555-C296-497E-AC20-5FA1B237996A} - {3BAF9C81-A194-4925-A035-5E24A5D1E542} = {2F305555-C296-497E-AC20-5FA1B237996A} - {6B04803D-B418-4833-A67E-B0FC966636A5} = {2F305555-C296-497E-AC20-5FA1B237996A} - {3940AD4D-F748-4BE4-9083-85769CD553EF} = {2F305555-C296-497E-AC20-5FA1B237996A} - {F8FFFC12-A31A-4AFA-B3DF-14DCF42B5E38} = {2F305555-C296-497E-AC20-5FA1B237996A} - {0014D652-901F-4456-8D65-06FC5F997FB0} = {4C0D0746-BE5B-49EE-BD5D-A7811628AE8B} - {799A50D8-DE89-4ED1-8FF8-AD5A9ED8C0CA} = {AB82E5DD-C32D-4F28-9746-2C780846188E} - {9D52FD25-EF90-4F9A-A015-91EFC5DAF54F} = {AB82E5DD-C32D-4F28-9746-2C780846188E} - {C32D254F-7597-4CBE-BF74-D922D81CDF29} = {9873BA05-4C41-4819-9283-CF45D795431B} - {02DD46D3-F761-47D9-8894-2D6DA0124650} = {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} - {8E23E173-7127-4A5F-9F93-3049F2B68047} = {929C1324-22E8-4412-A9A8-80E85F3985A5} - {DFF88D16-D36F-40A4-A955-CDCAA76EF7B8} = {538ED0BB-B863-4B20-98CC-BCDF7FA0B68A} - {FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} - {3A9A791E-94A9-49F8-8401-C11CE288D5FB} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} - {C0974915-8A1D-4BF0-977B-9587D3807AB7} = {D1D6BC88-09AE-4FB4-AD24-5DED46A791DD} - {1D6893CB-BC0C-46A8-A76C-9728706CA51A} = {557C4636-D7E1-4838-A504-7D19B725EE95} - {8ACB33D9-C95B-47D4-8363-9731EE0930A0} = {CA716AE6-FE5C-40AC-BB8F-2C87912687AC} - {CA716AE6-FE5C-40AC-BB8F-2C87912687AC} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {F055103B-F80B-4D0C-BF48-057C55620033} = {5A7818A8-109C-4E1C-850D-1A654E234B0E} - {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {BE126CBB-AE12-406A-9837-A05ACFCA57A7} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {14CB58B7-D280-4A7A-95DE-4B2DF14EA000} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {B31FCC55-B5A4-4EA7-B414-2DCEAE6AF332} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {9C53CC25-0623-4569-95BC-B05410675EE3} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {45285DF2-9742-4ECA-9AC9-58951FC26489} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {3D63307B-9D27-44FD-B033-B26F39245B85} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {367D7543-7DBA-4381-99F1-BF6142A996C4} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {2CAC093E-5FCF-4102-9C2C-AC7DD5D9EB96} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {37D07516-4185-43A4-924F-3C7A5D95ECF6} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {8F021B46-362B-485C-BFBA-CCF83E820CBD} = {8F62026A-294B-41C6-8839-87463613F216} - {66614C26-314C-4B91-9071-76133422CFEF} = {B6C42F16-73EB-477E-8B0D-4E6CF6C20AAC} - {3846508C-77EB-4034-A702-F8BB263C4F79} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} = {3846508C-77EB-4034-A702-F8BB263C4F79} - {6CE438DF-C245-4997-A360-0A0939E4BA34} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {E09AA983-C755-474F-83D6-A5CDF528C070} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {6D56B64D-FF1F-488F-AFED-9B9854A5D399} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {92EC89E4-9972-453A-8A1A-3A9E230C146A} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {51939B4F-1F62-4BFF-A6A2-C08646E5BE95} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {D1160404-D3D1-497A-883A-4059C07C2273} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {40F6D69D-E321-400F-A767-5628C7AE453D} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {F3D09629-59A2-4924-A4B9-D6BFAA2C1B49} = {3846508C-77EB-4034-A702-F8BB263C4F79} - {305DD37E-C85D-4B08-AAFE-7381FA890463} = {F3D09629-59A2-4924-A4B9-D6BFAA2C1B49} - {CA4D810F-C8F4-4B61-9DA9-71807E0B9F24} = {F3D09629-59A2-4924-A4B9-D6BFAA2C1B49} - {14E62033-58D0-4A7D-8990-52F50A08BBBD} = {7520A2FE-00A2-49B8-83ED-DB216E874C04} - {6515F03F-E56D-4DB4-B23D-AC4FB80DB36F} = {7520A2FE-00A2-49B8-83ED-DB216E874C04} - {071E18A4-A530-46B8-AB7D-B862EE55E24E} = {3846508C-77EB-4034-A702-F8BB263C4F79} - {C846F7A7-792A-47D9-B0CB-417C900EE03D} = {071E18A4-A530-46B8-AB7D-B862EE55E24E} - {C831231F-891C-4572-9694-45062534B42A} = {071E18A4-A530-46B8-AB7D-B862EE55E24E} - {7520A2FE-00A2-49B8-83ED-DB216E874C04} = {3846508C-77EB-4034-A702-F8BB263C4F79} - {8FBDABA4-40EE-4C0E-9BC8-2F6444A6EF90} = {7520A2FE-00A2-49B8-83ED-DB216E874C04} - {C66020D1-CB10-4CF7-8715-84C97FD5E5E2} = {7520A2FE-00A2-49B8-83ED-DB216E874C04} - {79775343-7A3D-445D-9104-3DD5B2893DF9} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {0ADEB797-C8C7-4FFA-ACD5-2AF6CAD7ECD8} = {3846508C-77EB-4034-A702-F8BB263C4F79} - {89D0E199-B17A-418C-B2F8-7375B6708357} = {A2221D7E-55E7-4BEA-90D1-4F162D670BBF} - {0DB0F63A-D2F8-4DA3-A650-2D0B8724218E} = {CA716AE6-FE5C-40AC-BB8F-2C87912687AC} - {453CBB73-A3CB-4D0B-8D24-6940B86FE21D} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {C0CE3B5E-16D3-495D-B335-CA791B660162} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {3A9A7297-92C4-4F16-B6F9-8D4AB652C86C} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {605E914B-7232-4789-AF46-BF5D3DDFC14E} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {E81A7D20-9862-ABDB-0AAE-9BC5B517A9F9} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {D5E5F5EA-1B6C-4A73-88BE-304F36C9E4EE} = {9873BA05-4C41-4819-9283-CF45D795431B} - {7F5B9557-5878-4438-A721-3E28296BA193} = {9873BA05-4C41-4819-9283-CF45D795431B} - {DD6E12FE-5509-4ABC-ACC2-3D6DC98A238C} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} - {0A84F764-3A88-44CD-AA96-41BDBD48627B} = {DD6E12FE-5509-4ABC-ACC2-3D6DC98A238C} - {E4585179-2AC1-4D5F-A3FF-CFC5392F694C} = {DD6E12FE-5509-4ABC-ACC2-3D6DC98A238C} - {CA7D8106-30B9-4AEC-9D05-B69B31B8C461} = {DD6E12FE-5509-4ABC-ACC2-3D6DC98A238C} - {DCC6BD67-17BB-47AA-B507-FB0FE43A7449} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - {A558C25D-2007-498E-8B6F-43405AFAE9E2} = {1AFB6476-670D-4E80-A464-657E01DFF482} - {08F9155D-B6DC-46E5-9C83-AF60B655898B} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {4382A954-179A-4078-92AF-715187DFFF50} = {38BDB927-829B-4C65-9CD9-93FB05D66D65} - {EBED240C-8702-452D-B764-6DB9DA9179AF} = {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} - {4E0AE3A4-2EE0-44D7-A2D0-8769977254A0} = {F05E590D-AD46-42BE-9C25-6A63ADD2E3EA} - {5702B3CC-8575-48D5-83D8-15BB42269CD3} = {929C1324-22E8-4412-A9A8-80E85F3985A5} - {64B88F02-CD88-4ED8-9624-989A800230F9} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} - EndGlobalSection -EndGlobal diff --git a/PowerToys.slnx b/PowerToys.slnx new file mode 100644 index 0000000000..4382ca5934 --- /dev/null +++ b/PowerToys.slnx @@ -0,0 +1,1109 @@ +<Solution> + <Configurations> + <Platform Name="ARM64" /> + <Platform Name="x64" /> + </Configurations> + <Folder Name="/common/"> + <Project Path="src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj" Id="2cf78cf7-8feb-4be1-9591-55fa25b48fc6" /> + <Project Path="src/common/Common.Search/Common.Search.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/Common.UI/Common.UI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/COMUtils/COMUtils.vcxproj" Id="7319089e-46d6-4400-bc65-e39bdf1416ee" /> + <Project Path="src/common/Display/Display.vcxproj" Id="caba8dfb-823b-4bf2-93ac-3f31984150d9" /> + <Project Path="src/common/FilePreviewCommon/FilePreviewCommon.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/GPOWrapper/GPOWrapper.vcxproj" Id="e599c30b-9dc8-4e5a-bf27-93d4ccede788" /> + <Project Path="src/common/GPOWrapperProjection/GPOWrapperProjection.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/LanguageModelProvider/LanguageModelProvider.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/ManagedCommon/ManagedCommon.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/ManagedCsWin32/ManagedCsWin32.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/ManagedTelemetry/Telemetry/ManagedTelemetry.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/PowerToys.ModuleContracts/PowerToys.ModuleContracts.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/SettingsAPI/SettingsAPI.vcxproj" Id="6955446d-23f7-4023-9bb3-8657f904af99" /> + <Project Path="src/common/Themes/Themes.vcxproj" Id="98537082-0fdb-40de-abd8-0dc5a4269bab" /> + <Project Path="src/common/UITestAutomation/UITestAutomation.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj" Id="1a066c63-64b3-45f8-92fe-664e1cce8077" /> + <Project Path="src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj" Id="8b5cfb38-ccba-40a8-ad7a-89c57b070884" /> + <Project Path="src/common/updating/updating.vcxproj" Id="17da04df-e393-4397-9cf0-84dabe11032e" /> + <Project Path="src/common/version/version.vcxproj" Id="cc6e41ac-8174-4e8a-8d22-85dd7f4851df" /> + </Folder> + <Folder Name="/common/interop/"> + <Project Path="src/common/interop/interop-tests/Common.Interop.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/common/interop/PowerToys.Interop.vcxproj" Id="f055103b-f80b-4d0c-bf48-057c55620033" /> + </Folder> + <Folder Name="/common/log/"> + <Project Path="src/common/logger/logger.vcxproj" Id="d9b8fc84-322a-4f9f-bbb9-20915c47ddfd"> + <BuildDependency Project="src/logging/logging.vcxproj" /> + </Project> + <Project Path="src/logging/logging.vcxproj" Id="7e1e3f13-2bd6-3f75-a6a7-873a2b55c60f" /> + </Folder> + <Folder Name="/common/notifications/"> + <Project Path="src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj" Id="0b593a6c-4143-4337-860e-db5710fb87db" /> + <Project Path="src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj" Id="031ac72e-fa28-4ab7-b690-6f7b9c28aa73"> + <BuildDependency Project="src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj" /> + </Project> + <Project Path="src/common/notifications/notifications.vcxproj" Id="1d5be09d-78c0-4fd7-af00-ae7c1af7c525" /> + </Folder> + <Folder Name="/common/Telemetry/"> + <File Path="src/common/Telemetry/ProjectTelemetry.h" /> + <File Path="src/common/Telemetry/TelemetryBase.cs" /> + <File Path="src/common/Telemetry/TraceBase.h" /> + <File Path="src/common/Telemetry/TraceLoggingDefines.h" /> + <Project Path="src/common/Telemetry/EtwTrace/EtwTrace.vcxproj" Id="8f021b46-362b-485c-bfba-ccf83e820cbd" /> + </Folder> + <Folder Name="/common/utils/"> + <File Path="src/common/utils/appMutex.h" /> + <File Path="src/common/utils/color.h" /> + <File Path="src/common/utils/com_object_factory.h" /> + <File Path="src/common/utils/elevation.h" /> + <File Path="src/common/utils/EventLocker.h" /> + <File Path="src/common/utils/EventWaiter.h" /> + <File Path="src/common/utils/excluded_apps.h" /> + <File Path="src/common/utils/exec.h" /> + <File Path="src/common/utils/game_mode.h" /> + <File Path="src/common/utils/gpo.h" /> + <File Path="src/common/utils/HDropIterator.h" /> + <File Path="src/common/utils/HttpClient.h" /> + <File Path="src/common/utils/json.h" /> + <File Path="src/common/utils/language_helper.h" /> + <File Path="src/common/utils/logger_helper.h" /> + <File Path="src/common/utils/modulesRegistry.h" /> + <File Path="src/common/utils/MsiUtils.h" /> + <File Path="src/common/utils/MsWindowsSettings.h" /> + <File Path="src/common/utils/OnThreadExecutor.h" /> + <File Path="src/common/utils/os-detect.h" /> + <File Path="src/common/utils/package.h" /> + <File Path="src/common/utils/ProcessWaiter.h" /> + <File Path="src/common/utils/process_path.h" /> + <File Path="src/common/utils/registry.h" /> + <File Path="src/common/utils/resources.h" /> + <File Path="src/common/utils/serialized.h" /> + <File Path="src/common/utils/shell_ext_registration.h" /> + <File Path="src/common/utils/string_utils.h" /> + <File Path="src/common/utils/timeutil.h" /> + <File Path="src/common/utils/UnhandledExceptionHandler.h" /> + <File Path="src/common/utils/winapi_error.h" /> + <File Path="src/common/utils/window.h" /> + </Folder> + <Folder Name="/DSC/"> + <Project Path="src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj"> + <BuildDependency Project="src/settings-ui/Settings.UI/PowerToys.Settings.csproj" /> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/DSC/v3/"> + <Project Path="src/dsc/v3/PowerToys.DSC.UnitTests/PowerToys.DSC.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/" /> + <Folder Name="/modules/AdvancedPaste/"> + <Project Path="src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + <Project Path="src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj" Id="fc373b24-3293-453c-aaf5-cf2909dcee6a" /> + </Folder> + <Folder Name="/modules/AdvancedPaste/Tests/"> + <Project Path="src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/AdvancedPaste.FuzzTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/AdvancedPaste/AdvancedPaste.UnitTests/AdvancedPaste.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/AlwaysOnTop/"> + <Project Path="src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj" Id="1dc3be92-ce89-43fb-8110-9c043a2fe7a2" /> + <Project Path="src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj" Id="48a0a19e-a0be-4256-acf8-cc3b80291af9" /> + </Folder> + <Folder Name="/modules/awake/"> + <Project Path="src/modules/awake/Awake.ModuleServices/Awake.ModuleServices.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/awake/Awake/Awake.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj" Id="5e7360a8-d048-4ed3-8f09-0bfd64c5529a" /> + </Folder> + <Folder Name="/modules/cmdNotFound/"> + <Project Path="src/modules/cmdNotFound/CmdNotFoundModuleInterface/CmdNotFoundModuleInterface.vcxproj" Id="0014d652-901f-4456-8d65-06fc5f997fb0" /> + </Folder> + <Folder Name="/modules/colorpicker/"> + <Project Path="src/modules/colorPicker/ColorPicker.ModuleServices/ColorPicker.ModuleServices.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/colorPicker/ColorPicker/ColorPicker.vcxproj" Id="655c9af2-18d3-4da6-80e4-85504a7722ba"> + <BuildDependency Project="src/common/logger/logger.vcxproj" /> + </Project> + <Project Path="src/modules/colorPicker/ColorPickerUI/ColorPickerUI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/colorpicker/Tests/"> + <Project Path="src/modules/colorPicker/ColorPickerUI.UnitTests/ColorPickerUI.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/CommandPalette/"> + <Project Path="src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj" Id="5f63c743-f6ce-4dba-a200-2b3f8a14e8c2" /> + <Project Path="src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj" Id="0adeb797-c8c7-4ffa-acd5-2af6cad7ecd8" /> + </Folder> + <Folder Name="/modules/CommandPalette/Built-in Extensions/"> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Microsoft.CmdPal.Ext.Calc.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Microsoft.CmdPal.Ext.PerformanceMonitor.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Microsoft.CmdPal.Ext.PowerToys.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Microsoft.CmdPal.Ext.Registry.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Microsoft.CmdPal.Ext.RemoteDesktop.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Microsoft.CmdPal.Ext.TimeDate.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Microsoft.CmdPal.Ext.WindowsServices.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Microsoft.CmdPal.Ext.WindowsTerminal.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Microsoft.CmdPal.Ext.WindowWalker.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + </Folder> + <Folder Name="/modules/CommandPalette/Core/"> + <Project Path="src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/CommandPalette/Extension SDK/"> + <Project Path="src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Microsoft.CommandPalette.Extensions.Toolkit.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj" Id="305dd37e-c85d-4b08-aafe-7381fa890463" /> + </Folder> + <Folder Name="/modules/CommandPalette/Sample Extensions/"> + <Project Path="src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorExtension.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + <Project Path="src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + </Folder> + <Folder Name="/modules/CommandPalette/Tests/"> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/Microsoft.CmdPal.Ext.Calc.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Microsoft.CmdPal.Ext.Registry.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Microsoft.CmdPal.Ext.System.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/Microsoft.CmdPal.Ext.TimeDate.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/Microsoft.CmdPal.Ext.UnitTestBase.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/Microsoft.CmdPal.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj" Id="2eca18b7-33b7-4829-88f1-439b20fd60f6"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/CommandPalette/UI/"> + <Project Path="src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + <Project Path="src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj" Id="6515f03f-e56d-4db4-b23d-ac4fb80db36f" /> + </Folder> + <Folder Name="/modules/CropAndLock/"> + <Project Path="src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj" Id="f5e1146e-b7b3-4e11-85fd-270a500bd78c" /> + <Project Path="src/modules/CropAndLock/CropAndLockModuleInterface/CropAndLockModuleInterface.vcxproj" Id="3157fa75-86cf-4ee2-8f62-c43f776493c6" /> + </Folder> + <Folder Name="/modules/EnvironmentVariables/"> + <Project Path="src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariables.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/EnvironmentVariablesModuleInterface.vcxproj" Id="b9420661-b0e4-4241-abd4-4a27a1f64250" /> + <Project Path="src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesUILib.csproj" /> + </Folder> + <Folder Name="/modules/fancyzones/"> + <Project Path="src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/fancyzones/FancyZones/FancyZones.vcxproj" Id="ff1d7936-842a-4bbb-8bea-e9fe796de700" /> + <Project Path="src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/fancyzones/FancyZonesEditorCommon/FancyZonesEditorCommon.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj" Id="f9c68edf-ac74-4b77-9af1-005d9c9f6a99" /> + <Project Path="src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj" Id="48804216-2a0e-4168-a6d8-9cd068d14227" /> + </Folder> + <Folder Name="/modules/fancyzones/Tests/"> + <Project Path="src/modules/fancyzones/FancyZones.FuzzTests/FancyZones.FuzzTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/fancyzones/FancyZones.UITests/FancyZones.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/fancyzones/FancyZonesEditor.UITests/FancyZonesEditor.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/fancyzones/FancyZonesEditor.UnitTests/FancyZonesEditor.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/fancyzones/FancyZonesTests/UnitTests/UnitTests.vcxproj" Id="9c6a7905-72d4-4bf5-b256-abfdaef68ae9"> + <BuildDependency Project="src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj" /> + </Project> + </Folder> + <Folder Name="/modules/FileLocksmith/"> + <Project Path="src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj" Id="49D456D3-F485-45AF-8875-45B44F193DDC" /> + <Project Path="src/modules/FileLocksmith/FileLocksmithContextMenu/FileLocksmithContextMenu.vcxproj" Id="799a50d8-de89-4ed1-8ff8-ad5a9ed8c0ca" /> + <Project Path="src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj" Id="57175ec7-92a5-4c1e-8244-e3fbca2a81de" /> + <Project Path="src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj" Id="9d52fd25-ef90-4f9a-a015-91efc5daf54f" /> + <Project Path="src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj" Id="c604b37e-9d0e-4484-8778-e8b31b0e1b3a" /> + <Project Path="src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithUI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/FileLocksmith/Tests/"> + <Project Path="src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj" Id="A1B2C3D4-E5F6-7890-1234-567890ABCDEF" /> + </Folder> + <Folder Name="/modules/Hosts/"> + <Project Path="src/modules/Hosts/Hosts/Hosts.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj" Id="b41b888c-7db8-4747-b262-4062e05a230d" /> + <Project Path="src/modules/Hosts/HostsUILib/HostsUILib.csproj" /> + </Folder> + <Folder Name="/modules/Hosts/Tests/"> + <Project Path="src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/Hosts/Hosts.Tests/HostsEditor.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/Hosts/Hosts.UITests/HostsEditor.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/imageresizer/"> + <Project Path="src/modules/imageresizer/dll/ImageResizerExt.vcxproj" Id="0b43679e-edfa-4da0-ad30-f4628b308b1b" /> + <Project Path="src/modules/imageresizer/ImageResizerContextMenu/ImageResizerContextMenu.vcxproj" Id="93b72a06-c8bd-484f-a6f7-c9f280b150bf" /> + <Project Path="src/modules/imageresizer/ImageResizerLib/ImageResizerLib.vcxproj" Id="18b3db45-4ffe-4d01-97d6-5223feee1853" /> + <Project Path="src/modules/imageresizer/ui/ImageResizerUI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/imageresizer/Tests/"> + <Project Path="src/modules/imageresizer/tests/ImageResizer.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/interface/"> + <File Path="src/modules/interface/powertoy_module_interface.h" /> + </Folder> + <Folder Name="/modules/keyboardmanager/"> + <Project Path="src/modules/keyboardmanager/common/KeyboardManagerCommon.vcxproj" Id="8affa899-0b73-49ec-8c50-0fadda57b2fc" /> + <Project Path="src/modules/keyboardmanager/dll/KeyboardManager.vcxproj" Id="89f34af7-1c34-4a72-aa6e-534bcf972bd9" /> + <Project Path="src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj" Id="8df78b53-200e-451f-9328-01eb907193ae" /> + <Project Path="src/modules/keyboardmanager/KeyboardManagerEditorLibrary/KeyboardManagerEditorLibrary.vcxproj" Id="23d2070d-e4ad-4add-85a7-083d9c76ad49" /> + <Project Path="src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.vcxproj" Id="4382a954-179a-4078-92af-715187dfff50" /> + <Project Path="src/modules/keyboardmanager/KeyboardManagerEditorUI/KeyboardManagerEditorUI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/keyboardmanager/KeyboardManagerEngine/KeyboardManagerEngine.vcxproj" Id="ba661f5b-1d5a-4ffc-9bf1-fc39df280bdd" /> + <Project Path="src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj" Id="e496b7fc-1e99-4bab-849b-0e8367040b02" /> + </Folder> + <Folder Name="/modules/keyboardmanager/Tests/"> + <Project Path="src/modules/keyboardmanager/KeyboardManagerEditorTest/KeyboardManagerEditorTest.vcxproj" Id="62173d9a-6724-4c00-a1c8-fb646480a9ec" /> + <Project Path="src/modules/keyboardmanager/KeyboardManagerEngineTest/KeyboardManagerEngineTest.vcxproj" Id="7f4b3a60-bc27-45a7-8000-68b0b6ea7466" /> + </Folder> + <Folder Name="/modules/launcher/"> + <Project Path="src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj" Id="e364f67b-bb12-4e91-b639-355866ebcd8b"> + <BuildDependency Project="src/modules/launcher/PowerLauncher/PowerLauncher.csproj" /> + </Project> + <Project Path="src/modules/launcher/PowerLauncher.Telemetry/PowerLauncher.Telemetry.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/PowerLauncher/PowerLauncher.csproj"> + <BuildDependency Project="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/Community.PowerToys.Run.Plugin.UnitConverter.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator/Community.PowerToys.Run.Plugin.ValueGenerator.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.VSCodeWorkspaces/Community.PowerToys.Run.Plugin.VSCodeWorkspaces.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.WebSearch/Community.PowerToys.Run.Plugin.WebSearch.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Microsoft.Plugin.Folder.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/Microsoft.Plugin.Indexer.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.Plugin.Program/Microsoft.Plugin.Program.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Microsoft.Plugin.Shell.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.Plugin.Uri/Microsoft.Plugin.Uri.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Microsoft.Plugin.WindowWalker.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/Microsoft.PowerToys.Run.Plugin.Calculator.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Microsoft.PowerToys.Run.Plugin.OneNote.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Microsoft.PowerToys.Run.Plugin.PowerToys.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Microsoft.PowerToys.Run.Plugin.Registry.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Service/Microsoft.PowerToys.Run.Plugin.Service.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Microsoft.PowerToys.Run.Plugin.System.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Microsoft.PowerToys.Run.Plugin.TimeDate.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Microsoft.PowerToys.Run.Plugin.WindowsSettings.csproj" /> + <BuildDependency Project="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.csproj" /> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Wox.Infrastructure/Wox.Infrastructure.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/launcher/Plugins/"> + <Project Path="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/Community.PowerToys.Run.Plugin.UnitConverter.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator/Community.PowerToys.Run.Plugin.ValueGenerator.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.VSCodeWorkspaces/Community.PowerToys.Run.Plugin.VSCodeWorkspaces.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.WebSearch/Community.PowerToys.Run.Plugin.WebSearch.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Microsoft.Plugin.Folder.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/Microsoft.Plugin.Indexer.csproj"> + <BuildDependency Project="src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj" /> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.Program/Microsoft.Plugin.Program.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Microsoft.Plugin.Shell.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.Uri/Microsoft.Plugin.Uri.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Microsoft.Plugin.WindowWalker.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/Microsoft.PowerToys.Run.Plugin.Calculator.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.History/Microsoft.PowerToys.Run.Plugin.History.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Microsoft.PowerToys.Run.Plugin.OneNote.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Microsoft.PowerToys.Run.Plugin.PowerToys.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Microsoft.PowerToys.Run.Plugin.Registry.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Service/Microsoft.PowerToys.Run.Plugin.Service.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Microsoft.PowerToys.Run.Plugin.System.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Microsoft.PowerToys.Run.Plugin.TimeDate.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Microsoft.PowerToys.Run.Plugin.WindowsSettings.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/launcher/Tests/"> + <File Path="src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj" /> + <Project Path="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.Program.UnitTests/Microsoft.Plugin.Program.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.Uri.UnitTests/Microsoft.Plugin.Uri.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker.UnitTests/Microsoft.Plugin.WindowWalker.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry.UnitTest/Microsoft.PowerToys.Run.Plugin.Registry.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/Microsoft.PowerToys.Run.Plugin.System.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.UnitTests/Microsoft.Plugin.WindowsTerminal.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/launcher/Wox.Test/Wox.Test.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/LightSwitch/"> + <Project Path="src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj" Id="79267138-2895-4346-9021-21408d65379f" /> + <Project Path="src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj" Id="38177d56-6ad1-4adf-88c9-2843a7932166" /> + <Project Path="src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj" Id="08e71c67-6a7e-4ca1-b04e-2fb336410bac" /> + </Folder> + <Folder Name="/modules/LightSwitch/Tests/"> + <Project Path="src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + </Folder> + <Folder Name="/modules/PowerDisplay/"> + <Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj" Id="d1234567-8901-2345-6789-abcdef012345" /> + </Folder> + <Folder Name="/modules/PowerDisplay/Tests/"> + <Project Path="src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/MeasureTool/"> + <Project Path="src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj" Id="54a93af7-60c7-4f6c-99d2-fbb1f75f853a"> + <BuildDependency Project="src/common/Display/Display.vcxproj" /> + <BuildDependency Project="src/common/SettingsAPI/SettingsAPI.vcxproj" /> + <BuildDependency Project="src/common/version/version.vcxproj" /> + </Project> + <Project Path="src/modules/MeasureTool/MeasureToolModuleInterface/MeasureToolModuleInterface.vcxproj" Id="92c39820-9f84-4529-bc7d-22aae514d63b" /> + <Project Path="src/modules/MeasureTool/MeasureToolUI/MeasureToolUI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/MeasureTool/Tests/"> + <Project Path="src/modules/MeasureTool/Tests/ScreenRuler.UITests/ScreenRuler.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/MouseUtils/"> + <Project Path="src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj" Id="48a1db8c-5df8-4fb3-9e14-2b67f3f2d8b5" /> + <Project Path="src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj" Id="e94fd11c-0591-456f-899f-efc0ca548336" /> + <Project Path="src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj" Id="782a61be-9d85-4081-b35c-1ccc9dcc1e88" /> + <Project Path="src/modules/MouseUtils/MouseJump.Common/MouseJump.Common.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/MouseUtils/MouseJump/MouseJump.vcxproj" Id="8a08d663-4995-40e3-b42c-3f910625f284" /> + <Project Path="src/modules/MouseUtils/MouseJumpUI/MouseJumpUI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj" Id="eae14c0e-7a6b-45da-9080-a7d8c077ba6e" /> + </Folder> + <Folder Name="/modules/MouseUtils/Tests/"> + <Project Path="src/modules/MouseUtils/MouseJump.Common.UnitTests/MouseJump.Common.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/MouseUtils/MouseUtils.UITests/MouseUtils.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/MouseWithoutBorders/"> + <Project Path="src/modules/MouseWithoutBorders/App/Helper/MouseWithoutBordersHelper.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/MouseWithoutBorders/App/MouseWithoutBorders.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/MouseWithoutBorders/App/Service/MouseWithoutBordersService.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/MouseWithoutBorders/ModuleInterface/MouseWithoutBordersModuleInterface.vcxproj" Id="2833c9c6-ab32-4048-a5c7-a70898337b57" /> + </Folder> + <Folder Name="/modules/MouseWithoutBorders/Tests/"> + <Project Path="src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/MouseWithoutBorders.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/New+/"> + <Project Path="src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj" Id="0db0f63a-d2f8-4da3-a650-2d0b8724218e" /> + <Project Path="src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj" Id="8acb33d9-c95b-47d4-8363-9731ee0930a0" /> + </Folder> + <Folder Name="/modules/Peek/"> + <Project Path="src/modules/peek/Peek.Common/Peek.Common.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/peek/Peek.UI/Peek.UI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + <Deploy /> + </Project> + <Project Path="src/modules/peek/Peek.UITests/Peek.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/peek/peek/peek.vcxproj" Id="a1425b53-3d61-4679-8623-e64a0d3d0a48" /> + </Folder> + <Folder Name="/modules/PowerAccent/"> + <Project Path="src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj" Id="c97d9a5d-206c-454e-997e-009e227d7f02" /> + <Project Path="src/modules/poweraccent/PowerAccentModuleInterface/PowerAccentModuleInterface.vcxproj" Id="34a354c5-23c7-4343-916c-c52daf4fc39d" /> + </Folder> + <Folder Name="/modules/PowerOCR/"> + <Project Path="src/modules/PowerOCR/PowerOCR/PowerOCR.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/PowerOCR/PowerOCRModuleInterface/PowerOCRModuleInterface.vcxproj" Id="6ab6a2d6-f859-4a82-9184-0bd29c9f07d1" /> + </Folder> + <Folder Name="/modules/PowerOCR/Tests/"> + <Project Path="src/modules/PowerOCR/PowerOCR-UITests/PowerOCR.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/powerrename/"> + <Project Path="src/modules/powerrename/dll/PowerRenameExt.vcxproj" Id="b25ac7a5-fb9f-4789-b392-d5c85e948670"> + <BuildDependency Project="src/modules/powerrename/lib/PowerRenameLib.vcxproj" /> + </Project> + <Project Path="src/modules/powerrename/lib/PowerRenameLib.vcxproj" Id="51920f1f-c28c-4adf-8660-4238766796c2" /> + <Project Path="src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj" Id="1dbbb112-4bb1-444b-8ebb-e66555c76ba6" /> + <Project Path="src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj" Id="27718999-c175-450a-861c-89f911e16a88" /> + <Project Path="src/modules/powerrename/testapp/PowerRenameTest.vcxproj" Id="a3935cf4-46c5-4a88-84d3-6b12e16e6ba2" /> + </Folder> + <Folder Name="/modules/powerrename/Tests/"> + <Project Path="src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj" Id="2694e2fb-dcd5-4bff-a418-b6c3c7ce3b8e"> + <Build Solution="*|ARM64" Project="false" /> + </Project> + <Project Path="src/modules/powerrename/PowerRenameUITest/PowerRename.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj" Id="2151f984-e006-4a9f-92ef-c6dde3dc8413"> + <BuildDependency Project="src/modules/powerrename/dll/PowerRenameExt.vcxproj" /> + <BuildDependency Project="src/modules/powerrename/lib/PowerRenameLib.vcxproj" /> + </Project> + </Folder> + <Folder Name="/modules/previewpane/"> + <Project Path="src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandler.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj" Id="f6088a11-1c9e-4420-aa90-cf7e78dd7f1c" /> + <Project Path="src/modules/previewpane/BgcodeThumbnailProvider/BgcodeThumbnailProvider.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj" Id="47b0678c-806b-4fe1-9f50-46ba88989532" /> + <Project Path="src/modules/previewpane/Common/PreviewHandlerCommon.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/GcodePreviewHandler/GcodePreviewHandler.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/GcodePreviewHandlerCpp/GcodePreviewHandlerCpp.vcxproj" Id="5a5dd09d-723a-44d3-8f2b-293584c3d731" /> + <Project Path="src/modules/previewpane/GcodeThumbnailProvider/GcodeThumbnailProvider.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/GcodeThumbnailProviderCpp/GcodeThumbnailProviderCpp.vcxproj" Id="56cc2f10-6e41-453d-be16-c593a5e58482" /> + <Project Path="src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/MarkdownPreviewHandlerCpp/MarkdownPreviewHandlerCpp.vcxproj" Id="ed9a1ac6-aeb0-4569-a6e9-e1696182b545" /> + <Project Path="src/modules/previewpane/MonacoPreviewHandler/MonacoPreviewHandler.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/MonacoPreviewHandlerCpp/MonacoPreviewHandlerCpp.vcxproj" Id="b3e869c4-8210-4ebd-a621-ff4c4afcbfa9" /> + <Project Path="src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandler.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/PdfPreviewHandlerCpp/PdfPreviewHandlerCpp.vcxproj" Id="54f7c616-fd41-4e62-bff9-015686914f4d" /> + <Project Path="src/modules/previewpane/PdfThumbnailProvider/PdfThumbnailProvider.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/PdfThumbnailProviderCpp/PdfThumbnailProviderCpp.vcxproj" Id="ca5518ed-0458-4b09-8f53-4122b9888655" /> + <Project Path="src/modules/previewpane/powerpreview/powerpreview.vcxproj" Id="217df501-135c-4e38-bfc8-99d4821032ea"> + <BuildDependency Project="src/common/version/version.vcxproj" /> + </Project> + <Project Path="src/modules/previewpane/QoiPreviewHandler/QoiPreviewHandler.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/QoiPreviewHandlerCpp/QoiPreviewHandlerCpp.vcxproj" Id="3baf9c81-a194-4925-a035-5e24a5d1e542" /> + <Project Path="src/modules/previewpane/QoiThumbnailProvider/QoiThumbnailProvider.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/QoiThumbnailProviderCpp/QoiThumbnailProviderCpp.vcxproj" Id="ccb5e44f-84d9-4203-83c6-1c9ec9302bc7" /> + <Project Path="src/modules/previewpane/StlThumbnailProvider/StlThumbnailProvider.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/StlThumbnailProviderCpp/StlThumbnailProviderCpp.vcxproj" Id="d6dcc3ae-18c0-488a-b978-baa9e3cff09d" /> + <Project Path="src/modules/previewpane/SvgPreviewHandler/SvgPreviewHandler.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/SvgPreviewHandlerCpp/SvgPreviewHandlerCpp.vcxproj" Id="143f13e3-d2e3-4d83-b035-356612d99956" /> + <Project Path="src/modules/previewpane/SvgThumbnailProvider/SvgThumbnailProvider.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj" Id="2bbc9e33-21ec-401c-84da-bb6590a9b2aa" /> + </Folder> + <Folder Name="/modules/previewpane/Tests/"> + <Project Path="src/modules/previewpane/UnitTests-BgcodePreviewHandler/Preview.BgcodePreviewHandler.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/Preview.BgcodeThumbnailProvider.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-GcodePreviewHandler/Preview.GcodePreviewHandler.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-GcodeThumbnailProvider/Preview.GcodeThumbnailProvider.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-MarkdownPreviewHandler/Preview.MarkdownPreviewHandler.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-PdfPreviewHandler/Preview.PdfPreviewHandler.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-PdfThumbnailProvider/Preview.PdfThumbnailProvider.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-PreviewHandlerCommon/Preview.PreviewHandlerCommon.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-QoiPreviewHandler/Preview.QoiPreviewHandler.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-QoiThumbnailProvider/Preview.QoiThumbnailProvider.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-StlThumbnailProvider/Preview.StlThumbnailProvider.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-SvgPreviewHandler/Preview.SvgPreviewHandler.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/previewpane/UnitTests-SvgThumbnailProvider/Preview.SvgThumbnailProvider.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/RegistryPreview/"> + <Project Path="src/modules/registrypreview/RegistryPreview/RegistryPreview.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/registrypreview/RegistryPreviewExt/RegistryPreviewExt.vcxproj" Id="697c6af9-0a48-49a9-866c-67da12384015" /> + <Project Path="src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewUILib.csproj" /> + </Folder> + <Folder Name="/modules/RegistryPreview/Test/"> + <Project Path="src/modules/registrypreview/RegistryPreview.FuzzTests/RegistryPreview.FuzzTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/modules/shortcutguide/"> + <Project Path="src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.vcxproj" Id="2edb3eb4-fa92-4bff-b2d8-566584837231" /> + <Project Path="src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj" Id="2d604c07-51fc-46bb-9eb7-75aecc7f5e81" /> + </Folder> + <Folder Name="/modules/Workspaces/"> + <Project Path="src/modules/Workspaces/Workspaces.ModuleServices/Workspaces.ModuleServices.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/Workspaces/WorkspacesCsharpLibrary/WorkspacesCsharpLibrary.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/Workspaces/WorkspacesEditor/WorkspacesEditor.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj" Id="2cac093e-5fcf-4102-9c2c-ac7dd5d9eb96" /> + <Project Path="src/modules/Workspaces/WorkspacesLauncherUI/WorkspacesLauncherUI.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj" Id="b31fcc55-b5a4-4ea7-b414-2dceae6af332" /> + <Project Path="src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj" Id="45285df2-9742-4eca-9ac9-58951fc26489" /> + <Project Path="src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj" Id="3d63307b-9d27-44fd-b033-b26f39245b85" /> + <Project Path="src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj" Id="37d07516-4185-43a4-924f-3c7a5d95ecf6" /> + </Folder> + <Folder Name="/modules/Workspaces/Tests/"> + <Project Path="src/modules/Workspaces/WorkspacesEditorUITest/Workspaces.Editor.UITests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj" Id="a85d4d9f-9a39-4b5d-8b5a-9f2d5c9a8b4c" /> + </Folder> + <Folder Name="/modules/Workspaces/WindowProperties/"> + <File Path="src/modules/Workspaces/WindowProperties/WorkspacesWindowPropertyUtils.h" /> + </Folder> + <Folder Name="/modules/Workspaces/workspaces-common/"> + <File Path="src/modules/Workspaces/workspaces-common/GuidUtils.h" /> + <File Path="src/modules/Workspaces/workspaces-common/InvokePoint.h" /> + <File Path="src/modules/Workspaces/workspaces-common/MonitorEnumerator.h" /> + <File Path="src/modules/Workspaces/workspaces-common/MonitorUtils.h" /> + <File Path="src/modules/Workspaces/workspaces-common/VirtualDesktop.h" /> + <File Path="src/modules/Workspaces/workspaces-common/WindowEnumerator.h" /> + <File Path="src/modules/Workspaces/workspaces-common/WindowFilter.h" /> + <File Path="src/modules/Workspaces/workspaces-common/WindowUtils.h" /> + </Folder> + <Folder Name="/modules/ZoomIt/"> + <Project Path="src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj" Id="0a84f764-3a88-44cd-aa96-41bdbd48627b" /> + <Project Path="src/modules/ZoomIt/ZoomItModuleInterface/ZoomItModuleInterface.vcxproj" Id="e4585179-2ac1-4d5f-a3ff-cfc5392f694c" /> + <Project Path="src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettingsInterop.vcxproj" Id="ca7d8106-30b9-4aec-9d05-b69b31b8c461" /> + </Folder> + <Folder Name="/settings-ui/"> + <Project Path="src/settings-ui/QuickAccess.UI/PowerToys.QuickAccess.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/settings-ui/Settings.UI.Controls/Settings.UI.Controls.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + <Project Path="src/settings-ui/Settings.UI/PowerToys.Settings.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/settings-ui/Tests/"> + <Project Path="src/settings-ui/Settings.UI.UnitTests/Settings.UI.UnitTests.csproj"> + <Platform Solution="*|ARM64" Project="ARM64" /> + <Platform Solution="*|x64" Project="x64" /> + </Project> + </Folder> + <Folder Name="/Solution Items/"> + <File Path=".vsconfig" /> + <File Path="Cpp.Build.props" /> + <File Path="Directory.Build.props" /> + <File Path="Directory.Build.targets" /> + <File Path="Directory.Packages.props" /> + <File Path="src/.editorconfig" /> + <File Path="src/Common.Dotnet.AotCompatibility.props" /> + <File Path="src/Common.Dotnet.CsWinRT.props" /> + <File Path="src/Common.SelfContained.props" /> + <File Path="src/Monaco.props" /> + <File Path="src/Solution.props" /> + <File Path="src/Version.props" /> + </Folder> + <Project Path="src/ActionRunner/ActionRunner.vcxproj" Id="d29ddd63-e2cf-4657-9fd5-2aede4257e5d"> + <BuildDependency Project="src/common/updating/updating.vcxproj" /> + </Project> + <Project Path="src/PackageIdentity/PackageIdentity.vcxproj" Id="e2a5a82e-1e5b-4c8d-9a4f-2b1a8f9e5c3d" /> + <Project Path="src/runner/runner.vcxproj" Id="9412d5c6-2cf2-4fc2-a601-b55508ea9b27"> + <BuildDependency Project="src/ActionRunner/ActionRunner.vcxproj" /> + <BuildDependency Project="src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj" /> + <BuildDependency Project="src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj" /> + <BuildDependency Project="src/common/updating/updating.vcxproj" /> + <BuildDependency Project="src/modules/awake/Awake/Awake.csproj" /> + <BuildDependency Project="src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj" /> + <BuildDependency Project="src/modules/colorPicker/ColorPicker/ColorPicker.vcxproj" /> + <BuildDependency Project="src/modules/colorPicker/ColorPickerUI/ColorPickerUI.csproj" /> + <BuildDependency Project="src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj" /> + <BuildDependency Project="src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj" /> + <BuildDependency Project="src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj" /> + <BuildDependency Project="src/modules/imageresizer/dll/ImageResizerExt.vcxproj" /> + <BuildDependency Project="src/modules/imageresizer/ui/ImageResizerUI.csproj" /> + <BuildDependency Project="src/modules/keyboardmanager/dll/KeyboardManager.vcxproj" /> + <BuildDependency Project="src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj" /> + <BuildDependency Project="src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj" /> + <BuildDependency Project="src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj" /> + <BuildDependency Project="src/modules/powerrename/dll/PowerRenameExt.vcxproj" /> + <BuildDependency Project="src/modules/powerrename/lib/PowerRenameLib.vcxproj" /> + <BuildDependency Project="src/modules/previewpane/Common/PreviewHandlerCommon.csproj" /> + <BuildDependency Project="src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj" /> + <BuildDependency Project="src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandler.csproj" /> + <BuildDependency Project="src/modules/previewpane/powerpreview/powerpreview.vcxproj" /> + <BuildDependency Project="src/modules/previewpane/SvgPreviewHandler/SvgPreviewHandler.csproj" /> + <BuildDependency Project="src/PackageIdentity/PackageIdentity.vcxproj" /> + </Project> + <Project Path="src/Update/PowerToys.Update.vcxproj" Id="44ce9ae1-4390-42c5-bacc-0fd6b40aa203" /> + <Project Path="tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj" Id="64a80062-4d8b-4229-8a38-dfa1d7497749" /> +</Solution> diff --git a/README.md b/README.md index 78bc997c10..6626eaa6a8 100644 --- a/README.md +++ b/README.md @@ -1,214 +1,363 @@ -# Microsoft PowerToys +<p align="center"> + <picture> + <source media="(prefers-color-scheme: light)" srcset="./doc/images/readme/pt-hero.light.png" /> + <img src="./doc/images/readme/pt-hero.dark.png" /> + </picture> +</p> +<h1 align="center"> + <span>Microsoft PowerToys</span> +</h1> +<p align="center"> + <span align="center">Microsoft PowerToys is a collection of utilities that help you customize Windows and streamline everyday tasks.</span> +</p> +<h3 align="center"> + <a href="#-installation">Installation</a> + <span> · </span> + <a href="https://aka.ms/powertoys-docs">Documentation</a> + <span> · </span> + <a href="https://aka.ms/powertoys-releaseblog">Blog</a> + <span> · </span> + <a href="#-whats-new">Release notes</a> +</h3> +<br/><br/> -![Hero image for Microsoft PowerToys](doc/images/overview/PT_hero_image.png) +## 🔨 Utilities -[How to use PowerToys][usingPowerToys-docs-link] | [Downloads & Release notes][github-release-link] | [Contributing to PowerToys](#contributing) | [What's Happening](#whats-happening) | [Roadmap](#powertoys-roadmap) +PowerToys includes over 25 utilities to help you customize and optimize your Windows experience: -## About +| | | | +|---|---|---| +| [<img src="doc/images/icons/AdvancedPaste.png" alt="Advanced Paste icon" height="16"> Advanced Paste](https://aka.ms/PowerToysOverview_AdvancedPaste) | [<img src="doc/images/icons/Always%20On%20Top.png" alt="Always on Top icon" height="16"> Always on Top](https://aka.ms/PowerToysOverview_AoT) | [<img src="doc/images/icons/Awake.png" alt="Awake icon" height="16"> Awake](https://aka.ms/PowerToysOverview_Awake) | +| [<img src="doc/images/icons/Color%20Picker.png" alt="Color Picker icon" height="16"> Color Picker](https://aka.ms/PowerToysOverview_ColorPicker) | [<img src="doc/images/icons/Command%20Not%20Found.png" alt="Command Not Found icon" height="16"> Command Not Found](https://aka.ms/PowerToysOverview_CmdNotFound) | [<img src="doc/images/icons/Command Palette.png" alt="Command Palette icon" height="16"> Command Palette](https://aka.ms/PowerToysOverview_CmdPal) | +| [<img src="doc/images/icons/Crop%20And%20Lock.png" alt="Crop and Lock icon" height="16"> Crop And Lock](https://aka.ms/PowerToysOverview_CropAndLock) | [<img src="doc/images/icons/Environment%20Manager.png" alt="Environment Variables icon" height="16"> Environment Variables](https://aka.ms/PowerToysOverview_EnvironmentVariables) | [<img src="doc/images/icons/FancyZones.png" alt="FancyZones icon" height="16"> FancyZones](https://aka.ms/PowerToysOverview_FancyZones) | +| [<img src="doc/images/icons/File%20Explorer%20Preview.png" alt="File Explorer Add-ons icon" height="16"> File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | [<img src="doc/images/icons/File%20Locksmith.png" alt="File Locksmith icon" height="16"> File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [<img src="doc/images/icons/Host%20File%20Editor.png" alt="Hosts File Editor icon" height="16"> Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) | +| [<img src="doc/images/icons/Image%20Resizer.png" alt="Image Resizer icon" height="16"> Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | [<img src="doc/images/icons/Keyboard%20Manager.png" alt="Keyboard Manager icon" height="16"> Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) | [<img src="doc/images/icons/Light Switch.png" alt="Light Switch icon" height="16"> Light Switch](https://aka.ms/PowerToysOverview_LightSwitch) | +| [<img src="doc/images/icons/Find My Mouse.png" alt="Mouse Utilities icon" height="16"> Mouse Utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | [<img src="doc/images/icons/MouseWithoutBorders.png" alt="Mouse Without Borders icon" height="16"> Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) | [<img src="doc/images/icons/NewPlus.png" alt="New+ icon" height="16"> New+](https://aka.ms/PowerToysOverview_NewPlus) | +| [<img src="doc/images/icons/Peek.png" alt="Peek icon" height="16"> Peek](https://aka.ms/PowerToysOverview_Peek) | [<img src="doc/images/icons/PowerRename.png" alt="PowerRename icon" height="16"> PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [<img src="doc/images/icons/PowerToys%20Run.png" alt="PowerToys Run icon" height="16"> PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) | +| [<img src="doc/images/icons/PowerAccent.png" alt="Quick Accent icon" height="16"> Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) | [<img src="doc/images/icons/Registry%20Preview.png" alt="Registry Preview icon" height="16"> Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [<img src="doc/images/icons/MeasureTool.png" alt="Screen Ruler icon" height="16"> Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) | +| [<img src="doc/images/icons/Shortcut%20Guide.png" alt="Shortcut Guide icon" height="16"> Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) | [<img src="doc/images/icons/PowerOCR.png" alt="Text Extractor icon" height="16"> Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [<img src="doc/images/icons/Workspaces.png" alt="Workspaces icon" height="16"> Workspaces](https://aka.ms/PowerToysOverview_Workspaces) | +| [<img src="doc/images/icons/ZoomIt.png" alt="ZoomIt icon" height="16"> ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) | | | -Microsoft PowerToys is a set of utilities for power users to tune and streamline their Windows experience for greater productivity. For more info on [PowerToys overviews and how to use the utilities][usingPowerToys-docs-link], or any other tools and resources for [Windows development environments](https://learn.microsoft.com/windows/dev-environment/overview), head over to [learn.microsoft.com][usingPowerToys-docs-link]! -| | Current utilities: | | -|--------------|--------------------|--------------| -| [Advanced Paste](https://aka.ms/PowerToysOverview_AdvancedPaste) | [Always on Top](https://aka.ms/PowerToysOverview_AoT) | [PowerToys Awake](https://aka.ms/PowerToysOverview_Awake) | -| [Command Not Found](https://aka.ms/PowerToysOverview_CmdNotFound) | [Color Picker](https://aka.ms/PowerToysOverview_ColorPicker) | [Crop And Lock](https://aka.ms/PowerToysOverview_CropAndLock) | -| [Environment Variables](https://aka.ms/PowerToysOverview_EnvironmentVariables) | [FancyZones](https://aka.ms/PowerToysOverview_FancyZones) | [File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | -| [File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) | [Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | -| [Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) | [Mouse utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | [Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) | -| [New+](https://aka.ms/PowerToysOverview_NewPlus) | [Peek](https://aka.ms/PowerToysOverview_Peek) | [Paste as Plain Text](https://aka.ms/PowerToysOverview_PastePlain) | -| [PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) | [Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) | -| [Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) | [Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) | -| [Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [Workspaces](https://aka.ms/PowerToysOverview_Workspaces) | [ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) | +## 📋 Installation -## Installing and running Microsoft PowerToys +For detailed installation instructions and system requirements, visit the [installation docs](https://learn.microsoft.com/windows/powertoys/install). -### Requirements - -- Windows 11 or Windows 10 version 2004 (code name 20H1 / build number 19041) or newer. -- x64 or ARM64 processor -- Our installer will install the following items: - - [Microsoft Edge WebView2 Runtime](https://go.microsoft.com/fwlink/p/?LinkId=2124703) bootstrapper. This will install the latest version. - -### Via GitHub with EXE [Recommended] - -Go to the [Microsoft PowerToys GitHub releases page][github-release-link] and click on `Assets` at the bottom to show the files available in the release. Please use the appropriate PowerToys installer that matches your machine's architecture and install scope. For most, it is `x64` and per-user. +But to get started quickly, choose one of the installation methods below: +<br/><br/> +<details open> +<summary><strong>Download .exe from GitHub</strong></summary> +<br/> +Go to the <a href="https://aka.ms/installPowerToys">PowerToys GitHub releases</a>, 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. <!-- items that need to be updated release to release --> -[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.90%22 -[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.89%22 -[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.89.0/PowerToysUserSetup-0.89.0-x64.exe -[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.89.0/PowerToysUserSetup-0.89.0-arm64.exe -[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.89.0/PowerToysSetup-0.89.0-x64.exe -[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.89.0/PowerToysSetup-0.89.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 | sha256 hash | -|----------------|----------|-------------| -| Per user - x64 | [PowerToysUserSetup-0.89.0-x64.exe][ptUserX64] | B4F130CC96F321024A257499247F6FF6DA56612215ED3882E868AAE26C689E33 | -| Per user - ARM64 | [PowerToysUserSetup-0.89.0-arm64.exe][ptUserArm64] | F69B00F4E520EB09FA0D1D1669E21910C5225FE7A2EEDC0FA7C283B201A5F9C6 | -| Machine wide - x64 | [PowerToysSetup-0.89.0-x64.exe][ptMachineX64] | E18AC8F9023E341CF7DAD35367FB9DDDB6565D83D8155DBCDDB40AE8A24AE731 | -| Machine wide - ARM64 | [PowerToysSetup-0.89.0-arm64.exe][ptMachineArm64] | 17DEADEC601D6061D7AF4F487595CC36D9191813003CC2ECE381017F0EC71FBB | +| Description | Filename | +|----------------|----------| +| 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] | -This is our preferred method. +</details> -### Via Microsoft Store +<details> +<summary><strong>Microsoft Store</strong></summary> +<br/> +You can easily install PowerToys from the Microsoft Store: +<p> + <a style="text-decoration:none" href="https://aka.ms/getPowertoys"> + <picture> + <source media="(prefers-color-scheme: light)" srcset="doc/images/readme/StoreBadge-dark.png" width="148" /> + <img src="doc/images/readme/StoreBadge-light.png" width="148" /> + </picture></a> +</p> +</details> -Install from the [Microsoft Store's PowerToys page][microsoft-store-link]. You must be using the [new Microsoft Store](https://blogs.windows.com/windowsExperience/2021/06/24/building-a-new-open-microsoft-store-on-windows-11/) which is available for both Windows 11 and Windows 10. +<details> +<summary><strong>WinGet</strong></summary> +<br/> +Download PowerToys from <a href="https://github.com/microsoft/winget-cli#installing-the-client">WinGet</a>. Updating PowerToys via winget will respect the current PowerToys installation scope. To install PowerToys, run the following command from the command line / PowerShell: -### Via WinGet -Download PowerToys from [WinGet][winget-link]. Updating PowerToys via winget will respect current PowerToys installation scope. To install PowerToys, run the following command from the command line / PowerShell: - -#### User scope installer [default] +*User scope installer [default]* ```powershell winget install Microsoft.PowerToys -s winget ``` -#### Machine-wide scope installer - +*Machine-wide scope installer* ```powershell winget install --scope machine Microsoft.PowerToys -s winget ``` +</details> -### Other install methods +<details> +<summary><strong>Other methods</strong></summary> +<br/> +There are <a href="https://learn.microsoft.com/windows/powertoys/install#community-driven-install-tools">community driven install methods</a> such as Chocolatey and Scoop. If these are your preferred install solutions, you can find the install instructions there. +</details> -There are [community driven install methods](./doc/unofficialInstallMethods.md) such as Chocolatey and Scoop. If these are your preferred install solutions, you can find the install instructions there. +## ✨ What's new -## Third-Party Run Plugins +**Version 0.97.2 (Feb 2026)** -There is a collection of [third-party plugins](./doc/thirdPartyRunPlugins.md) created by the community that aren't distributed with PowerToys. +This patch release fixes several important stability issues identified in v0.97.0 based on incoming reports. Check out the [v0.97.0](https://github.com/microsoft/PowerToys/releases/tag/v0.97.0) notes for the full list of changes. -## Contributing +## Advanced Paste +- #45207 Fixed a crash in the Advanced Paste settings page caused by null values during JSON deserialization. -This project welcomes contributions of all types. Besides coding features / bug fixes, other ways to assist include spec writing, design, documentation, and finding bugs. We are excited to work with the power user community to build a set of tools for helping you get the most out of Windows. +## Color Picker +- #45367 Fixed contrast issue in Color picker UI. -We ask that **before you start work on a feature that you would like to contribute**, please read our [Contributor's Guide](CONTRIBUTING.md). We would be happy to work with you to figure out the best approach, provide guidance and mentorship throughout feature development, and help avoid any wasted or duplicate effort. +## Command Palette +- #45194 Fixed an issue where some Command Palette PowerToys Extension strings were not localised. -Most contributions require you to agree to a [Contributor License Agreement (CLA)][oss-CLA] declaring that you grant us the rights to use your contribution and that you have permission to do so. +## Cursor Wrap +- #45210 Fixed "Automatically activate on utility startup" setting not persisting when disabled. Thanks [@ThanhNguyxn](https://github.com/ThanhNguyxn)! +- #45303 Added option to disable Cursor Wrapping when only a single monitor is connected. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! -For guidance on developing for PowerToys, please read the [developer docs](/doc/devdocs) for a detailed breakdown. This includes how to setup your computer to compile. +## Image Resizer +- #45184 Fixed Image Resizer not working after upgrading PowerToys on Windows 10 by properly cleaning up legacy sparse app packages. -## What's Happening +## LightSwitch +- #45304 Fixed Light Switch startup logic to correctly apply the appropriate theme on launch. -### PowerToys Roadmap +## Workspaces +- #45183 Fixed overlay positioning issue in workspace snapshot draw caused by DPI-aware coordinate mismatch. -Our [prioritized roadmap][roadmap] of features and utilities that the core team is focusing on. +## Quick Access and Measure Tool +- #45443 Fixed crash related to `IsShownInSwitchers` property when Explorer is not running. -### 0.89 - February 2025 Update +**Version 0.97.1 (January 2026)** -In this release, we focused on new features, stability, accessibility and automation. - -**✨Highlights** - - - Enhanced Advanced Paste by adding media transcoding support to convert different video and audio file formats! Thanks [@snickler](https://github.com/snickler) for your help! - - Fixed crashes when loading thumbnails after the .NET 9 update and resolved PowerLauncher.exe blocking other MSI installers from creating shortcuts! - - Fixed accessibility issues across FancyZones, Image Resizer, and Settings to improve screen reader support and clarity! - - Enhanced UI automation framework across modules and added new tests to cover manual checks, with more improvements coming! - -### General - - - Fixed an issue where updating PowerToys on Windows 11 did not properly update context menu entries, impacting New+, PowerRename, Image Resizer, and File Locksmith. - - Updated .NET Packages from 9.0.1 to 9.0.2. Thanks [@snickler](https://github.com/snickler) for this. - - Enabled compatibility with VS17.3 and later, for C++23. Thanks [@LNKLEO](https://github.com/LNKLEO) for this. +**Highlights** ### Advanced Paste +- #44862: Fixed Settings UI advanced paste page crash by using correct settings repository for null checking. - - Added media transcoding support to convert different video and audio file formats, improved UI layouts, refined clipboard handling, and integrated Semantic Kernel for smarter pasting. Thanks [@snickler](https://github.com/snickler) for your help! +### Command Palette +- #44886: Fixed personalization section not appearing by using latest MSIX for installation. +- #44938: Fixed loading of icons from internet shortcuts. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- #45076: Fixed potential deadlock from lazy-loading AppListItem details. Thanks [@jiripolasek](https://github.com/jiripolasek)! -### FancyZones +### Cursor Wrap +- #44936: Added improved multi-monitor support; Added laptop lid close detection for dynamic monitor topology updates. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! +- #44936: Added new settings dropdown to constrain wrapping to horizontal-only, vertical-only, or both directions. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! - - Fixed accessibility by improving the text for monitors, ensuring clearer naming and help text for screen readers. - -### Image Resizer - - Fixed issues with Width and Height fields in Image Resizer's Custom preset, ensuring empty values no longer cause errors, settings save correctly, and auto-scaling behaves as expected. Thanks [@daverayment](https://github.com/daverayment)! - - Fixed accessibility by ensuring screen readers announce selected image dimensions in the combo-box for better navigation. - -### Monaco Preview - - - Fixed open link in default browser rather than Microsoft Edge. Thanks [@OldUser101](https://github.com/OldUser101)! - -### Mouse Highlighter - - - Fixed a highlight released on an Administrator window will start fading, instead of staying on the screen indefinitely until the mouse button is pressed again on an unelevated window. - -### Mouse Without Borders - - Fixed an issue in service mode where copy-paste and drag-drop file transfers didn’t work, ensuring seamless file operations. - - Enabled GPO for enable/disable for Mouse Without Borders in Service Mode. Thanks [@htcfreek](https://github.com/htcfreek) for review and comments! - - Fixed code maintainability by refactoring the oversized 'Common' class in Mouse Without Borders into smaller, focused classes for better structure and clarity. Thanks [@mikeclayton](https://github.com/mikeclayton) and thanks [@htcfreek](https://github.com/htcfreek) for review! +### Peek +- #44995: Fixed Space key triggering Peek during file rename, search, or address bar typing. ### PowerRename - - Supported negative value as Start value in regular expression, e.g. ${start=-1314} - - Enhanced RegEx help by adding $, ^, quantifiers, and common patterns for better usability. Thanks [@PesBandi](https://github.com/PesBandi) and thanks [@htcfreek](https://github.com/htcfreek) for review. +- #44944: Fixed regex `$` not working, preventing users from adding text at the end of filenames. -### PowerToys Run - - Fixed crashes when loading thumbnails after the .NET 9 update by disabling CETCompat. - - Fixed PowerLauncher.exe blocking other MSI installers creating shortcuts. Thanks [@OneBlue](https://github.com/OneBlue)! - - Fixed Run’s dark mode detection to work reliably, preventing issues with incorrect theme detection and ensuring a smoother user experience. Thanks [@daverayment](https://github.com/daverayment)! - - Fixed list separator handling in Calculator, allowing functions with multiple arguments to work correctly across different locales. For example pow(2;3) would be replaced with pow(2,3). Thanks [@PesBandi](https://github.com/PesBandi) and thanks [@htcfreek](https://github.com/htcfreek) for review! - - Fixed angle unit conversions in the PowerToys Run calculator, allowing quick conversions between radians, degrees, and gradians. Thanks [@OldUser101](https://github.com/OldUser101)! +### Runner +- #44931: Monochrome tray icon now adapts to Windows system theme instead of app theme. +- #44982: Fixed right-click menu to dynamically update based on Quick Access enabled/disabled state. -### Quick Accent +### GPO / Enterprise +- #45028: Added CursorWrap policy definition to ADMX templates. Thanks [@htcfreek](https://github.com/htcfreek)! - - Added ǎ, ǒ and ǔ to the IPA character set. Thanks [@PesBandi](https://github.com/PesBandi)! - - Added ` (backtick) and ~ (tilde) to the VK_OEM_5 character set. Thanks [@xanatos](https://github.com/xanatos)! - - Added ς (final sigma) to the Greek character set. Thanks [@IamSmeagol](https://github.com/IamSmeagol)! +For the full list of v0.97 changes, visit the [Windows Command Line blog](https://aka.ms/powertoys-releaseblog). -### Settings +## Advanced Paste - - Enabled GPO for the "run at startup" setting. Thanks [@htcfreek](https://github.com/htcfreek) for review and comments! - - Fixed accessibility issue by allowing screen readers to announce the group name for secondary links in Settings pages, instead of reading link descriptions without context. - - Fixed an issue where the Color Picker shortcut was not displaying correctly in the Dashboard. +- Added hex color previews in clipboard history. Thanks [@crramirez](https://github.com/crramirez)! +- Added automatic placeholder endpoints when required fields are left empty. +- Fixed a grammar issue in the AI settings description. Thanks [@erik-anderson](https://github.com/erik-anderson)! +- Fixed loading order so custom action hotkeys are read correctly. +- Updated Advanced Paste descriptions to reflect support for online and local models. +- Fixed clipboard history item selection so it doesn’t duplicate entries. +- Prevented placeholder endpoints from being saved for providers that don’t need them. +- Added image input support for AI transforms and improved clipboard change tracking. -### Workspaces +## Awake - - Fixed if a window was last placed on a disconnected monitor, it launches minimized and repositions within the main monitor's visible area when restored, instead of remaining off-screen and invisible. - - Fixed on ARM64 to correctly display icons for packaged apps by resolving path mismatches. +- Fixed Awake CLI so help, errors, and logs appear correctly in the console. Thanks [@daverayment](https://github.com/daverayment)! -### ZoomIt +## Command Palette - - Fixed warning C4706 and related error C2220 during build. Thanks [@xanatos](https://github.com/xanatos)! +- Fixed background image loading in BlurImageControl. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Fixed SDK packaging paths and added a CI SDK build stage. +- Aligned naming and spell-checking with .NET conventions. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added drag-and-drop support for Command Palette items. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added a PowerToys Command Palette extension to discover and launch PowerToys utilities. +- Fixed grid view bindings and layout issues. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Fixed a line-break issue in RDC extension toast messages. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Made the Settings button text localizable. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Hid the RDC fallback on the home page and fixed MSTSC working directory handling. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Optimized result list merging for better performance. Thanks [@daverayment](https://github.com/daverayment)! +- Added Small/Medium/Large detail sizes in the extensions API. Thanks [@DevLGuilherme](https://github.com/DevLGuilherme)! +- Hid fallback commands on the home page when no query is entered. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added back navigation support in the Settings window. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added a Command Palette solution filter. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Updated Extension SDK documentation links to Microsoft Learn. Thanks [@RubenFricke](https://github.com/RubenFricke)! +- Added a custom search engine URL setting for Web Search. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added pinyin matching for Chinese input. Thanks [@frg2089](https://github.com/frg2089)! +- Bumped Command Palette version to 0.8. +- Removed subtitles from built-in top-level commands. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Refined separator styling in the details pane. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added a built-in Remote Desktop extension. +- Added a Peek command to the Indexer extension. +- Improved default browser detection using the Windows Shell API. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added Escape key behavior options. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added theme and background customization options. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Improved WinGet package app matching. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added an auto-return-home delay setting. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added fallback ranking and global results settings. +- Removed the selection indicator in the context menu list. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added a developer ribbon with build and log info. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Updated the “Learn more” string for Command Palette. Thanks [@pratnala](https://github.com/pratnala)! +- Added arrow-key navigation for grid views. Thanks [@samrueby](https://github.com/samrueby)! +- Fixed version display when running unpackaged. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added a native debugging launch profile. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Reduced redundant property change notifications in the SDK. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Improved section readability and accessibility. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Made gallery spacing uniform. Thanks [@jiripolasek](https://github.com/jiripolasek)! +- Added sections and separators for list and grid pages. Thanks [@DevLGuilherme](https://github.com/DevLGuilherme)! -### Documentation +## Crop & Lock - - Fixed runner-ipc.md doc on the broken link. Thanks [@daverayment](https://github.com/daverayment)! - - Fixed the new plugin checklist by updating the target framework, removing duplicates, and improving statement organization. Thanks [@hlaueriksson](https://github.com/hlaueriksson)! - - Updated runner documentation to align with the latest code structure. +- Added a screenshot mode that freezes a cropped region into its own window. Thanks [@fm-sys](https://github.com/fm-sys)! -### Development +## Cursor Wrap - - Stabilized pipeline on ARM64 and forked build. - - Added fuzz testing for HostUILib, added as part of pipeline for OneFuzz. - - Fixed and improved UI-Test automation framework, and added new test cases for the FancyZones and Hosts module. - - Optimized Logger function as AOT compatible, improving performance by 18%. - - Made Common.UI and Setting.UI to be AOT compatible. - -### What is being planned for version 0.90 +- Improved Cursor Wrap behavior on multi-monitor setups by wrapping only at outer edges. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! -For [v0.90][github-next-release-work], we'll work on the items below: +## FancyZones - - New module: PowerToys Run v2 - - New module: File Actions Menu - - Working on installer upgrades - - Upgrading keyboard manager's editor UI - - Stability / bug fixes +- Fixed editor overlay positioning on mixed-DPI multi-monitor setups. Thanks [@Memphizzz](https://github.com/Memphizzz)! +- Added a FancyZones CLI for command-line layout management. -## PowerToys Community +## File Locksmith -The PowerToys team is extremely grateful to have the [support of an amazing active community][community-link]. The work you do is incredibly important. PowerToys wouldn’t be nearly what it is today without your help filing bugs, updating documentation, guiding the design, or writing features. We want to say thank you and take time to recognize your work. Month by month, you directly help make PowerToys a better piece of software. +- Added a File Locksmith CLI for querying, waiting on, or killing file locks. -## Code of Conduct +## Find My Mouse -This project has adopted the [Microsoft Open Source Code of Conduct][oss-conduct-code]. +- Improved spotlight edge rendering for clearer Find My Mouse visuals. +- Added telemetry to track how Find My Mouse is triggered. -## Privacy Statement +## Image Resizer -The application logs basic diagnostic data (telemetry). For more information on privacy and what we collect, see our [PowerToys Data and Privacy documentation](https://aka.ms/powertoys-data-and-privacy-documentation). +- Fixed Fill mode cropping when Shrink Only is enabled. Thanks [@daverayment](https://github.com/daverayment)! +- Added a dedicated Image Resizer CLI for scripted resizing. -[oss-CLA]: https://cla.opensource.microsoft.com -[oss-conduct-code]: CODE_OF_CONDUCT.md -[community-link]: COMMUNITY.md -[github-release-link]: https://aka.ms/installPowerToys -[microsoft-store-link]: https://aka.ms/getPowertoys -[winget-link]: https://github.com/microsoft/winget-cli#installing-the-client +## Light Switch + +- Added telemetry events for Light Switch usage and settings changes. +- Added a Follow Night Light mode to sync theme changes with Night Light. +- Clarified LightSwitchService and LightSwitchStateManager roles in docs. +- Added a Quick Access dashboard button to toggle Light Switch quickly. +- Ensured Light Switch honors GPO policy states with clear status messaging. + +## Mouse Without Borders + +- Continued refactoring Mouse Without Borders by splitting the large Common class into focused components. Thanks [@mikeclayton](https://github.com/mikeclayton)! +- Completed the Common class refactor with Core and IPC helper extraction. Thanks [@mikeclayton](https://github.com/mikeclayton)! + +## Peek + +- Hardened Peek previews with strict resource filtering and safer external link warnings. +- Improved SVG preview compatibility by rendering via WebView2. + +## PowerRename + +- Added HEIF/AVIF EXIF metadata extraction and extension status guidance for related previews. +- Fixed undefined behavior in file time handling. Thanks [@safocl](https://github.com/safocl)! +- Optimized memory allocation for depth-based rename processing. +- Fixed Unicode normalization and non‑breaking space matching. Thanks [@daverayment](https://github.com/daverayment)! +- Fixed date token replacements followed by capital letters. Thanks [@daverayment](https://github.com/daverayment)! + +## PowerToys Run Plugins + +- Fixed a plugin name typo and added Project Launcher to the third‑party list. Thanks [@artickc](https://github.com/artickc)! +- Added the Open With Antigravity plugin to the third‑party list. Thanks [@artickc](https://github.com/artickc)! + +## PowerToys Run + +- Avoided unnecessary hotkey conflict checks when settings change. +- Added QuickAI to the third-party PowerToys Run plugin list. Thanks [@ruslanlap](https://github.com/ruslanlap)! + +## Quick Accent + +- Added localized quotation marks to Quick Accent. Thanks [@warquys](https://github.com/warquys)! +- Fixed duplicate and redundant characters in Quick Accent sets. Thanks [@noraa-junker](https://github.com/noraa-junker)! +- Fixed DPI positioning issues for Quick Accent on mixed-DPI setups. Thanks [@noraa-junker](https://github.com/noraa-junker)! + +## Settings + +- Added a new tray icon that adapts to theme changes. Thanks [@HO-COOH](https://github.com/HO-COOH)! +- Centralized module enable/disable logic for cleaner Settings UI updates. +- Simplified Settings utilities by removing ISettingsUtils/ISettingsPath interfaces. Thanks [@noraa-junker](https://github.com/noraa-junker)! +- Improved Settings UI consistency and disabled-state visuals. +- Added semantic headings to the Dashboard for better accessibility. +- Introduced Quick Access as a standalone host with updated Settings integration. +- Fixed Dashboard toggle flicker and sort menu checkmarks. Thanks [@daverayment](https://github.com/daverayment)! +- Added Native AOT-compatible settings serialization. +- Standardized mouse tool description text. Thanks [@daverayment](https://github.com/daverayment)! +- Added a global SettingsUtils singleton to reduce repeated initialization. + +## Development + +- Fixed broken devdocs links to the coding style guide. Thanks [@RubenFricke](https://github.com/RubenFricke)! +- Migrated main and installer solutions to .slnx for improved build tooling. +- Restored local installer builds after the WiX v5 upgrade with signing and versioning fixes. +- Added incremental review tooling and structured AI prompts for PR/issue reviews. +- Documented bot commands and cleaned up devdocs structure. Thanks [@noraa-junker](https://github.com/noraa-junker)! +- Updated WinAppSDK pipeline defaults to 1.8 and fixed restore handling. +- Updated the COMMUNITY list to reflect current roles. +- Maintained community member ordering and added a new entry. +- Re-enabled centralized PackageReference for native projects with VS auto-restore. +- Disabled MSBuild caching by default in CI to avoid build instability. +- Updated the latest WinAppSDK daily pipeline for split-dependency restores. +- Suppressed experimental build warnings and aligned WrapPanel stretch handling. +- Reordered the spell-check expect list for consistent automation. +- Migrated native projects to centralized PackageReference management. +- Cleaned spell-check dictionary entries and capitalization. +- Synced commit/PR prompts and wired VS Code to repo prompt files. +- Added VS Code build tasks and improved build script path handling. +- Updated Windows App SDK package versions in central package management. +- Migrated cmdpal extension native project to PackageReference and fixed outputs. +- Reverted PackageReference changes back to packages.config where needed. +- Bypassed a release version check for a failing DLL to keep pipelines green. +- Consolidated Copilot instructions and fixed prompt frontmatter. +- Added signing entries for new Quick Access binaries and CLI version metadata. +- Fixed install scope detection to avoid mixed per-user/per-machine installs. +- Added a Module Loader tool to quickly test PowerToys modules without full builds. Thanks [@mikehall-ms](https://github.com/mikehall-ms)! +- Added update telemetry to understand auto-update checks and downloads. +- Updated the telemetry package for new compliance requirements. Thanks [@carlos-zamora](https://github.com/carlos-zamora)! +- Documented missing telemetry events in DATA_AND_PRIVACY. +- Fixed UI test pipeline restores for .slnx solutions. +- Added UI automation coverage for Advanced Paste clipboard history flows. +- Stabilized FancyZones UI tests with more reliable selectors and screen recordings. + +## 🛣️ Roadmap +We are planning some nice new features and improvements for the next releases – PowerDisplay, Command Palette improvements and a brand-new Shortcut Guide experience! Stay tuned for [v0.98][github-next-release-work]! + +## ❤️ PowerToys Community +The PowerToys team is extremely grateful to have the [support of an amazing active community][community-link]. The work you do is incredibly important. PowerToys wouldn't be nearly what it is today without your help filing bugs, updating documentation, guiding the design, or writing features. We want to say thank you and take time to recognize your work. Your contributions and feedback improve PowerToys month after month! + +## Contributing +This project welcomes contributions of all types. Besides coding features / bug fixes, other ways to assist include spec writing, design, documentation, and finding bugs. We are excited to work with the power user community to build a set of tools for helping you get the most out of Windows. We ask that **before you start work on a feature that you would like to contribute**, please read our [Contributor's Guide](CONTRIBUTING.md). We would be happy to work with you to figure out the best approach, provide guidance and mentorship throughout feature development, and help avoid any wasted or duplicate effort. Most contributions require you to agree to a [Contributor License Agreement (CLA)][oss-CLA] declaring that you grant us the rights to use your contribution and that you have permission to do so. For guidance on developing for PowerToys, please read the [developer docs](./doc/devdocs) for a detailed breakdown. This includes how to setup your computer to compile. + +## Code of Conduct +This project has adopted the [Microsoft Open Source Code of Conduct][oss-conduct-code]. + +## Privacy Statement +The application logs basic diagnostic data (telemetry). For more privacy information and what we collect, see our [PowerToys Data and Privacy documentation](https://aka.ms/powertoys-data-and-privacy-documentation). + +[oss-CLA]: https://cla.opensource.microsoft.com +[oss-conduct-code]: CODE_OF_CONDUCT.md +[community-link]: COMMUNITY.md +[github-release-link]: https://aka.ms/installPowerToys +[microsoft-store-link]: https://aka.ms/getPowertoys +[winget-link]: https://github.com/microsoft/winget-cli#installing-the-client [roadmap]: https://github.com/microsoft/PowerToys/wiki/Roadmap -[privacy-link]: http://go.microsoft.com/fwlink/?LinkId=521839 -[loc-bug]: https://github.com/microsoft/PowerToys/issues/new?assignees=&labels=&template=translation_issue.md&title= +[privacy-link]: http://go.microsoft.com/fwlink/?LinkId=521839 +[loc-bug]: https://github.com/microsoft/PowerToys/issues/new?assignees=&labels=&template=translation_issue.md&title= [usingPowerToys-docs-link]: https://aka.ms/powertoys-docs diff --git a/deps/cziplib b/deps/cziplib deleted file mode 160000 index 7a57414261..0000000000 --- a/deps/cziplib +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7a57414261361ca991ff8053881343eb6bb6f205 diff --git a/doc/devdocs/UITests.md b/doc/devdocs/UITests.md deleted file mode 100644 index fea05f6b9e..0000000000 --- a/doc/devdocs/UITests.md +++ /dev/null @@ -1,91 +0,0 @@ -# UI tests framework - - A specialized UI test framework for PowerToys that makes it easy to write UI tests for PowerToys modules or settings. Let's start writing UI tests! - -## Before running tests - -- Install Windows Application Driver v1.2.1 from https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1 to the default directory (`C:\Program Files (x86)\Windows Application Driver`) - -- Enable Developer Mode in Windows settings - -## Running tests - -- Exit PowerToys if it's running. - -- Open `PowerToys.sln` in Visual Studio and build the solution. - -- Run tests in the Test Explorer (`Test > Test Explorer` or `Ctrl+E, T`). - - -## How to add the first UI tests for your modules - -- Create a new project and add the following references to the project file. Change the OutputPath to your own module's path. - ``` - <PropertyGroup> - <OutputType>Library</OutputType> - <!-- This is a UI test, so don't run as part of MSBuild --> - <RunVSTest>false</RunVSTest> - </PropertyGroup> - - <PropertyGroup> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\KeyboardManagerUITests\</OutputPath> - </PropertyGroup> - - <ItemGroup> - <PackageReference Include="MSTest" /> - <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> - <Folder Include="Properties\" /> - </ItemGroup> - ``` -- Inherit your test class from UITestBase. - >Set Scope: The default scope starts from the PowerToys settings UI. If you want to start from your own module, set the constructor as shown below: - - >Specify Scope: - ``` - [TestClass] - public class RunFancyZonesTest : UITestBase - { - public RunFancyZonesTest() - : base(PowerToysModule.FancyZone) - { - } - } - ``` - -- Then you can start using session to perform the UI operations. - -**Example** -``` -using Microsoft.PowerToys.UITest; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace UITests_KeyboardManager -{ - [TestClass] - public class RunKeyboardManagerUITests : UITestBase - { - [TestMethod] - public void OpenKeyboardManagerEditor() - { - // Open KeyboardManagerEditor - this.Session.Find<Button>(By.Name("Remap a key")).Click(); - this.Session.Attach("Remap keys"); - - // Maximize window - var window = Session.Find<Window>(By.Name("Remap keys")).Maximize(); - - // Add Key Remapping - this.Session.Find<Button>(By.Name("Add key remapping")).Click(); - window.Close(); - - // Back to Settings - this.Session.Attach(PowerToysModule.PowerToysSettings); - } - } -} -``` - -## Extra tools and information - - **Accessibility Tools**: -While working on tests, you may need a tool that helps you to view the element's accessibility data, e.g. for finding the button to click. For this purpose, you could use [AccessibilityInsights](https://accessibilityinsights.io/docs/windows/overview) \ No newline at end of file diff --git a/doc/devdocs/cli-conventions.md b/doc/devdocs/cli-conventions.md new file mode 100644 index 0000000000..a5bc4ec04b --- /dev/null +++ b/doc/devdocs/cli-conventions.md @@ -0,0 +1,93 @@ +# CLI Conventions + +This document describes the conventions for implementing command-line interfaces (CLI) in PowerToys modules. + +## Library + +Use the **System.CommandLine** library for CLI argument parsing. This is already defined in `Directory.Packages.props`: + +```xml +<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" /> +``` + +Add the reference to your project: + +```xml +<PackageReference Include="System.CommandLine" /> +``` + +## Option Naming and Definition + +- Use `--kebab-case` for long form (e.g., `--shrink-only`). +- Use single `-x` for short form (e.g., `-s`, `-w`). +- Define aliases as static readonly arrays: `["--silent", "-s"]`. +- Create options using `Option<T>` with descriptive help text. +- Add validators for options that require range or format checking. + +## RootCommand Setup + +- Create a `RootCommand` with a brief description. +- Add all options and arguments to the command. + +## Parsing + +- Use `Parser(rootCommand).Parse(args)` to parse CLI arguments. +- Extract option values using `parseResult.GetValueForOption()`. +- Note: Use `Parser` directly; `RootCommand.Parse()` may not be available with the pinned System.CommandLine version. + +### Parse/Validation Errors + +- On parse/validation errors, print error messages and usage, then exit with non-zero code. + +## Examples + +Reference implementations: +- Awake: `src/modules/Awake/Awake/Program.cs` +- ImageResizer: `src/modules/imageresizer/ui/Cli/` + +## Help Output + +- Provide a `PrintUsage()` method for custom help formatting if needed. + +## Best Practices + +1. **Consistency**: Follow existing module patterns. +2. **Documentation**: Always provide help text for each option. +3. **Validation**: Validate input and provide clear error messages. +4. **Atomicity**: Make one logical change per PR; avoid drive-by refactors. +5. **Build/Test Discipline**: Build and test synchronously, one terminal per operation. +6. **Style**: Follow repo analyzers (`.editorconfig`, StyleCop) and formatting rules. + +## Logging Requirements + +- Use `ManagedCommon.Logger` for consistent logging. +- Initialize logging early in `Main()`. +- Use dual output (console + log file) for errors and warnings to ensure visibility. +- Reference: `src/modules/imageresizer/ui/Cli/CliLogger.cs` + +## Error Handling + +### Exit Codes + +- `0`: Success +- `1`: General error (parsing, validation, runtime) +- `2`: Invalid arguments (optional) + +### Exception Handling + +- Always wrap `Main()` in try-catch for unhandled exceptions. +- Log exceptions before exiting with non-zero code. +- Display user-friendly error messages to stderr. +- Preserve detailed stack traces in log files only. + +## Testing Requirements + +- Include tests for argument parsing, validation, and edge cases. +- Place CLI tests in module-specific test projects (e.g., `src/modules/[module]/tests/*CliTests.cs`). + +## Signing and Deployment + +- CLI executables are signed automatically in CI/CD. +- **New CLI tools**: Add your executable and dll to `.pipelines/ESRPSigning_core.json` in the signing list. +- CLI executables are deployed alongside their parent module (e.g., `C:\Program Files\PowerToys\modules\[ModuleName]\`). +- Use self-contained deployment (import `Common.SelfContained.props`). diff --git a/doc/devdocs/commands.md b/doc/devdocs/commands.md new file mode 100644 index 0000000000..811625284e --- /dev/null +++ b/doc/devdocs/commands.md @@ -0,0 +1,34 @@ +# Issue/PR commands + +The PowerToys repository uses some special keywords to help manage issues and pull requests. Here is a list of the most important commands you can use in issue and PR descriptions or comments. + +| Command | Description | +|---------|-------------| +| `/azp run` | Triggers the Azure Pipelines CI build for the current PR. Useful if you want to re-run the build without creating a new commit. | +| `/bugreport` / `/reportbug` | Adds a comment with a manual for the Bug Report Tool, which helps users collect logs and system information for debugging purposes. It requests to upload this file and adds the `Needs-Author-Feedback` label. | +| `/feedbackhub` | Adds a comment with a link to the Feedback Hub app on Windows, where users can submit feedback about PowerToys. Closes the issue and adds the `Resolution-Please File on Feedback Hub` label. | +| `/dup #...` / `/duplicate #...` / `/dup https://...` / `/duplicate https://...` | Marks the current issue as a duplicate of another issue. It closes the current issue and applies the `Resolution-Duplicate` label. Replace `#...` with the issue number or a link to the issue. | +| `/needinfo` | Adds the `Needs-Author-Feedback` label to the issue or PR, indicating that more information is needed from the author. | +| `/helped` | Closes the issue and adds the `Resolution-Helped User` label. Furthermore a comment is added with a link to the PowerToys user documentation. | +| `/loc` | Adds a comment informing the user that the issue was forwarded to the localization team and will soon be fixed. It adds the `Loc-Sent To Team` label. | + +## Defining new commands + +Most of these commands are using the [Microsoft GitHub Policy Service](https://github.com/apps/microsoft-github-policy-service) bot. Its commands are defined in the [PowerToys policy configuration file](/.github/policies/resourceManagement.yml). + +## Other automated tasks + +### Automatic labeling + +The bot can automatically apply the correct `product-...` label for any opened issue. + +> [!NOTE] +> This feature is currently only available for the Workspaces module as a test. + +### The `Needs-Author-Feedback` label + +If an issue has this label and had no activity for 5 days, the bot will post a comment reminding the author to provide the needed information. It also adds the `Status-No recent activity` label. If no further activity occurs for another 5 days, the bot will close the issue. + +### Filtering users that want to contribute + +If a user utters their intention to contribute (e.g., by using the phrase "I want to contribute" in an issue or PR), the bot will add a comment with a link to the ["Would you like to contribute to PowerToys?" thread](https://github.com/microsoft/PowerToys/issues/28769). diff --git a/doc/devdocs/common/FilePreviewCommon.md b/doc/devdocs/common/FilePreviewCommon.md index 33c11aad28..43f7edf599 100644 --- a/doc/devdocs/common/FilePreviewCommon.md +++ b/doc/devdocs/common/FilePreviewCommon.md @@ -8,6 +8,8 @@ Monaco preview enables to display developer files. It is based on [Microsoft's M This previewer is used for the File Explorer Dev File Previewer, as well as PowerToys Peek. +For a general overview of how Monaco is used in PowerToys, see the [Monaco Editor documentation](monaco-editor.md). + ### Update Monaco Editor 1. Download Monaco editor with [npm](https://www.npmjs.com/): Run `npm i monaco-editor` in the command prompt. diff --git a/doc/devdocs/common/context-menus.md b/doc/devdocs/common/context-menus.md new file mode 100644 index 0000000000..5961ca7455 --- /dev/null +++ b/doc/devdocs/common/context-menus.md @@ -0,0 +1,102 @@ +# PowerToys Context Menu Handlers + +This document describes how context menu handlers are implemented in PowerToys, covering both Windows 10 and Windows 11 approaches. + +## Context Menu Implementation Types + +PowerToys implements two types of context menu handlers: + +1. **Old-Style Context Menu Handlers** + - Used for Windows 10 compatibility + - Registered via registry entries + - Implemented as COM objects exposing the `IContextMenu` interface + - Registered for specific file extensions + +2. **Windows 11 Context Menu Handlers** + - Implemented as sparse MSIX packages + - Exposing the `IExplorerCommand` interface + - Located in `PowerToys\x64\Debug\modules\<module>\<module>.msix` + - Registered for all file types and filtered in code + - Requires signing to be installed + +## Context Menu Handler Registration Approaches + +PowerToys modules use two different approaches for registering context menu handlers: + +### 1. Dual Registration (e.g., ImageResizer, PowerRename) + +- Both old-style and Windows 11 context menu handlers are registered +- Results in duplicate entries in Windows 11's expanded context menu +- Ensures functionality even if Windows 11 handler fails to appear +- Old-style handlers appear in the "Show more options" expanded menu + +### 2. Selective Registration (e.g., NewPlus) + +- Windows 10: Uses old-style context menu handler +- Windows 11: Uses new MSIX-based context menu handler +- Avoids duplicates but can cause issues if Windows 11 handler fails to register + +## Windows 11 Context Menu Handler Implementation + +### Package Registration + +- MSIX packages are defined in `AppManifest.xml` in each context menu project +- Registration happens in `DllMain` of the module interface DLL when the module is enabled +- Explorer restart may be required after registration for changes to take effect +- Registration can be verified with `Get-AppxPackage` PowerShell command: + ```powershell + Get-AppxPackage -Name *PowerToys* + ``` + +### Technical Implementation + +- Handlers implement the `IExplorerCommand` interface +- Key methods: + - `GetState`: Determines visibility based on file type + - `Invoke`: Handles the action when the menu item is clicked + - `GetTitle`: Provides the text to display in the context menu +- For selective filtering (showing only for certain file types), the logic is implemented in the `GetState` method + +### Example Implementation Flow + +1. Build generates an MSIX package from the context menu project +2. When the module is enabled, PowerToys installs the package using `PackageManager.AddPackageAsync` +3. The package references the DLL that implements the actual context menu handler +4. When the user right-clicks, Explorer loads the DLL and calls into its methods + +## Debugging Context Menu Handlers + +### Debugging Old-Style (Windows 10) Handlers + +1. Update the registry to point to your debug build +2. Restart Explorer +3. Attach the debugger to explorer.exe +4. Set breakpoints and test by right-clicking in File Explorer + +### Debugging Windows 11 Handlers + +1. Build PowerToys to get the MSIX packages +2. Sign the MSIX package with a self-signed certificate +3. Replace files in the PowerToys installation directory +4. Use PowerToys to install the package +5. Restart Explorer +6. Run Visual Studio as administrator +7. Set breakpoints in relevant code +8. Attach to DllHost.exe process when context menu is triggered + +### Debugging Challenges + +- Windows 11 handlers require signing and reinstalling for each code change +- DllHost loads the DLL only when context menu is triggered and unloads after +- For efficient development, use logging or message boxes instead of breakpoints +- Consider debugging the Windows 10 handler by removing OS version checks + +## Common Issues + +- Context menu entries not showing in Windows 11 + - Usually due to package not being removed/updated properly on PowerToys update + - Fix: Uninstall and reinstall the package or restart Explorer +- Registering packages requires signing + - For local testing, create and install a signing certificate +- Duplicate entries in Windows 11 context menu + - By design for some modules to ensure availability if Windows 11 handler fails diff --git a/doc/devdocs/common/monaco-editor.md b/doc/devdocs/common/monaco-editor.md new file mode 100644 index 0000000000..e121ec4a03 --- /dev/null +++ b/doc/devdocs/common/monaco-editor.md @@ -0,0 +1,77 @@ +# Monaco Editor in PowerToys + +## Overview + +Monaco is the text editor that powers Visual Studio Code. In PowerToys, Monaco is integrated as a component to provide advanced text editing capabilities with features like syntax highlighting, line numbering, and intelligent code editing. + +## Where Monaco is Used in PowerToys + +Monaco is primarily used in: +- Registry Preview module - For editing registry files +- File Preview handlers - For syntax highlighting when previewing code files +- Peek module - For preview a file + +## Technical Implementation + +Monaco is embedded into PowerToys' WinUI 3 applications using WebView2. This integration allows PowerToys to leverage Monaco's web-based capabilities within desktop applications. + +### Directory Structure + +The Monaco editor files are located in the relevant module directories. For example, in Registry Preview, Monaco files are bundled with the application resources. + +## Versioning and Updates + +### Current Version + +The current Monaco version can be found in the `loader.js` file, specifically in the variable named `versionMonaco`. + +### Update Process + +Updating Monaco requires several steps: + +1. Download the latest version of Monaco +2. Replace/override the main folder with the new version +3. Generate the new Monaco language JSON file +4. Override the existing JSON file + +For detailed step-by-step instructions, see the [FilePreviewCommon documentation](FilePreviewCommon.md#update-monaco-editor). + +#### Estimated Time for Update + +The Monaco update process typically takes approximately 30 minutes. + +#### Reference PRs + +When updating Monaco, you can refer to previous Monaco update PRs as examples, as they mostly involve copy-pasting the Monaco source code with minor adjustments. + +## Customizing Monaco + +### Adding New Language Definitions + +Monaco can be customized to support new language definitions for syntax highlighting: + +1. Identify the language you want to add +2. Create or modify the appropriate language definition files +3. Update the Monaco configuration to recognize the new language + +For detailed instructions on adding language definitions, see the [FilePreviewCommon documentation](FilePreviewCommon.md#add-a-new-language-definition). + +### Adding File Extensions to Existing Languages + +To make Monaco handle additional file extensions using existing language definitions: + +1. Locate the language mapping configuration +2. Add the new file extension to the appropriate language entry +3. Update the file extension registry + +For detailed instructions on adding file extensions, see the [FilePreviewCommon documentation](FilePreviewCommon.md#add-a-new-file-extension-to-an-existing-language). + +Example: If Monaco processes TXT files and you want it to preview LOG files the same way, you can add LOG extensions to the TXT language definition. + +## Installer Handling + +Monaco source files are managed via a script (`Generate-Monaco-wxs.ps1`) that: +1. Automatically generates the installer manifest to include all Monaco files +2. Avoids manually listing all Monaco files in the installer configuration + +This approach simplifies maintenance and updates of the Monaco editor within PowerToys. diff --git a/doc/devdocs/core/architecture.md b/doc/devdocs/core/architecture.md new file mode 100644 index 0000000000..99e1ade558 --- /dev/null +++ b/doc/devdocs/core/architecture.md @@ -0,0 +1,78 @@ +# PowerToys Architecture + +## Module Interface Overview + +Each PowerToys utility is defined by a module interface (DLL) that provides a standardized way for the PowerToys Runner to interact with it. The module interface defines: + +- Structure for hotkeys +- Name and key for the utility +- Configuration management +- Enable/disable functionality +- Telemetry settings +- Group Policy Object (GPO) configuration + +### Types of Modules + +1. **Simple Modules** (like Mouse Pointer Crosshairs, Find My Mouse) + - Entirely contained in the module interface + - No external application + - Example: Mouse Pointer Crosshairs implements the module interface directly + +2. **External Application Launchers** (like Color Picker) + - Start a separate application (e.g., WPF application in C#) + - Handle events when hotkeys are pressed + - Communication via named pipes or other IPC mechanisms + +3. **Context Handler Modules** (like Power Rename) + - Shell extensions for File Explorer + - Add right-click context menu entries + - Windows 11 context menu integration through MSIX + +4. **Registry-based Modules** (like Power Preview) + - Register preview handlers and thumbnail providers + - Modify registry keys during enable/disable operations + +## Common Dependencies and Libraries + +- SPD logs for C++ (centralized logging system) +- CPP Win RT (used by most utilities) +- Common utilities in `common` folder for reuse across modules +- Interop library for C++/C# communication (converted to C++ Win RT) +- Common.UI library has WPF and WinForms dependencies + +## Resource Management + +- For C++ applications and module interfaces: + - Resource files (.resx) need to be converted to .rc + - Use conversion tools before building + +- Different resource approaches: + - WPF applications use .resx files + - WinUI 3 apps use .resw files + +- PRI file naming requirements: + - Need to override default names to avoid conflicts during flattening + +## Implementation details + +### [`Runner`](runner.md) + +The PowerToys Runner contains the project for the PowerToys.exe executable. +It's responsible for: + +- Loading the individual PowerToys modules. +- Passing registered events to the PowerToys. +- Showing a system tray icon to manage the PowerToys. +- Bridging between the PowerToys modules and the Settings editor. + +### [`Interface`](../modules/interface.md) + +The definition of the interface used by the [`runner`](/src/runner) to manage the PowerToys. All PowerToys must implement this interface. + +### [`Common`](../common.md) + +The common lib, as the name suggests, contains code shared by multiple PowerToys components and modules, e.g. [json parsing](/src/common/utils/json.h) and [IPC primitives](/src/common/interop/two_way_pipe_message_ipc.h). + +### [`Settings`](settings/readme.md) + +Settings v2 is our current settings implementation. Please head over to the dev docs that describe the current settings system. diff --git a/doc/devdocs/core/installer.md b/doc/devdocs/core/installer.md new file mode 100644 index 0000000000..7d3c375b2c --- /dev/null +++ b/doc/devdocs/core/installer.md @@ -0,0 +1,156 @@ +# PowerToys Installer + +## Installer Architecture (WiX 5) + +- Uses a bootstrapper to check dependencies and close PowerToys +- MSI defined in product.wxs +- Custom actions in C++ for special operations: + - Getting install folder + - User impersonation + - PowerShell module path retrieval + - GPO checking + - Process termination + +### Installer Components + +- Separate builds for machine-wide and user-scope installation +- Supports x64 and ARM64 +- Custom actions DLL must be signed separately before installer build +- WXS files generated during build process for file components +- Localization handling for resource DLLs +- Firewall exceptions for certain modules + +### MSI Installer Build Process + +- First builds `PowerToysSetupCustomActionsVNext` DLL and signs it +- Then builds the installer without cleaning, to reuse the signed DLL +- Uses PowerShell scripts to modify .wxs files before build +- Restores original .wxs files after build completes +- Scripts (`applyBuildInfo.ps1` and `generateFileList.ps1`) dynamically update files list for installer + - Helps manage all self-contained dependencies (.NET, WinAppSDK DLLs, etc.) + - Avoids manual maintenance of file lists + +### Special Build Processes + +- .NET applications need publishing for correct WebView2 DLL inclusion +- WXS files backed up and regenerated during build +- Monaco UI components (JavaScript/HTML) generated during build +- Localization files downloaded from server during CI release builds + +## Per-User vs Per-Machine Installation + +- Functionality is identical +- Differences: + - Per-User: + - Installed to `%LOCALAPPDATA%\PowerToys` + - Registry entries in HKCU + - Different users can have different installations/settings + - Per-Machine: + - Installed to `Program Files\PowerToys` + - Registry entries in HKLM + - Single installation shared by all users +- Default is now Per-User installation +- Guards prevent installing both types simultaneously + +## MSIX Usage in PowerToys + +- Context menu handlers for Windows 11 use sparse MSIX packages +- Previous attempts to create full MSIX installers were abandoned +- Command Palette will use MSIX when merged into PowerToys +- The main PowerToys application still uses MSI for installation + +### MSIX Packaging and Extensions + +- MSIX packages for extensions (like context menus) are included in the PowerToys installer +- The MSIX files are built as part of the PowerToys build process +- MSIX files are saved directly into the root folder with base application files +- The installer includes MSIX files but doesn't install them automatically +- Packages are registered when a module is enabled +- Code in `package.h` checks if a package is registered and verifies the version +- Packages will be installed if a version mismatch is detected +- When uninstalling PowerToys, the system checks for installed packages with matching display names and attempts to uninstall them + +## GPO Files (Group Policy Objects) + +- GPO files for x64 and ARM64 are identical +- Only one set is needed +- GPO files in pipeline are copies of files in source + +## Installer Debugging + +- Can only build installer in Release mode +- Typically debug using logs and message boxes +- Logs located in: + - `%LOCALAPPDATA%\Temp\PowerToys_bootstrapper_*.log` - MSI tool logs + - `%LOCALAPPDATA%\Temp\PowerToys_*.log` - Custom installer logs +- Logs in Bug Reports are useful for troubleshooting installation issues + +### Building PowerToys Locally + +#### One stop script for building installer +1. Open `Developer PowerShell for VS`. +2. Run tools\build\build-installer.ps1 +> 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. + +The following manual steps will not install the MSIX apps (such as Command Palette) on your local installer. + +#### Prerequisites for building the MSI installer + +PowerToys uses WiX v5 for creating installers. The WiX v5 tools are automatically installed during the build process via dotnet tool. + +For manual installation of WiX v5 tools: +```powershell +dotnet tool install --global wix --version 5.0.2 +``` + +> **Note:** As of release 0.94, PowerToys has migrated from WiX v3 to WiX v5. The WiX v3 toolset is no longer required. + +#### Building prerequisite projects + +##### From the command line + +1. From the start menu, open a `Developer Command Prompt for VS` +1. Ensure `nuget.exe` is in your `%path%` +1. In the repo root, run these commands: + +``` +nuget restore .\tools\BugReportTool\BugReportTool.sln +msbuild -p:Platform=x64 -p:Configuration=Release .\tools\BugReportTool\BugReportTool.sln + +nuget restore .\tools\StylesReportTool\StylesReportTool.sln +msbuild -p:Platform=x64 -p:Configuration=Release .\tools\StylesReportTool\StylesReportTool.sln +``` + +##### From Visual Studio + +If you prefer, you can alternatively build prerequisite projects for the installer using the Visual Studio UI. + +1. Open `tools\BugReportTool\BugReportTool.sln` +1. In Visual Studio, in the `Solutions Configuration` drop-down menu select `Release` +1. From the `Build` menu, choose `Build Solution`. +1. Open `tools\StylesReportTool\StylesReportTool.sln` +1. In Visual Studio, in the `Solutions Configuration` drop-down menu select `Release` +1. From the `Build` menu, choose `Build Solution`. + +#### Locally compiling the installer + +1. Open `installer\PowerToysSetup.slnx` +1. In Visual Studio, in the `Solutions Configuration` drop-down menu select `Release` +1. From the `Build` menu choose `Build Solution`. + +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` 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\ +MSBuild -t:restore .\installer\PowerToysSetup.slnx -p:RestorePackagesConfig=true /p:Platform="x64" /p:Configuration=Release +MSBuild -t:Restore -m .\installer\PowerToysSetup.slnx /t:PowerToysInstallerVNext /p:Configuration=Release /p:Platform="x64" +MSBuild -t:Restore -m .\installer\PowerToysSetup.slnx /t:PowerToysBootstrapperVNext /p:Configuration=Release /p:Platform="x64" +``` + +### Supported arguments for the .EXE Bootstrapper installer + +Head over to the wiki to see the [full list of supported installer arguments][installerArgWiki]. + +[installerArgWiki]: https://github.com/microsoft/PowerToys/wiki/Installer-arguments diff --git a/doc/devdocs/core/runner.md b/doc/devdocs/core/runner.md new file mode 100644 index 0000000000..f337032d1d --- /dev/null +++ b/doc/devdocs/core/runner.md @@ -0,0 +1,197 @@ +# PowerToys Runner + +The PowerToys Runner is the main executable (`PowerToys.exe`) that loads and manages all PowerToys modules. + +## Runner Architecture + +The Runner is responsible for: +- Managing the tray icon +- Loading and managing module interfaces +- Handling enabling/disabling modules +- Processing global hotkeys +- Managing updates and settings + +### Key Components + +- Main CPP file manages the tray icon and modules +- Creates a list of modules with DLL paths +- Special handling for WinUI 3 apps (separated in different folder) +- DLLs flattening for consistent versions +- Runs as part of the Windows message loop +- Creates a window handle with a specific class name +- Registers itself as the window handler for components requiring a window handler + +### Process Flow + +1. Initialize logger +2. Create single instance application mutex +3. Initialize common utility code +4. Parse command line arguments +5. Start the tray icon +6. Initialize low-level keyboard hooks +7. Load module interfaces from DLLs +8. Start enabled modules +9. Enter Windows message loop +10. On exit, stop modules and clean up resources + +## System Tray Icon Implementation + +The system tray icon is one of the first components that starts when calling the `render_main` function: + +- Defined in `tray_icon.h` and `tray_icon.cpp` +- Creates a popup window and registers as window handler via `start_tray_icon()` +- Processes window messages through `tray_icon_window_proc()` +- Uses `WM_COMMAND` and tray icon notifications for handling menu options +- Handles left mouse clicks (distinguishes between single and double clicks) +- Monitors taskbar creation to re-register the icon if needed +- Uses `shell_notify_icon` to register with the system tray + +### Tray Icon Initialization and Message Processing + +- `start_tray_icon()` initializes the tray icon by: + - Creating a window with the specified class name + - Setting up the notification icon data structure (NOTIFYICONDATA) + - Registering for taskbar recreate messages + - Adding the icon to the system tray + +- `tray_icon_window_proc()` processes window messages, including: + - Handling `wm_icon_notify` messages from tray icon interactions + - Distinguishing between left-click, double-click, and right-click actions + - Showing context menus or opening Settings windows based on interaction type + +### Communication with Settings UI + +When the tray icon is clicked or a menu option is selected, the Runner communicates with the Settings UI: + +- For quick access flyout (left-click): + ```cpp + current_settings_ipc->send(L"{\"ShowYourself\":\"flyout\"}"); + ``` + +- For the main dashboard (menu option or double-click): + ```cpp + current_settings_ipc->send(L"{\"ShowYourself\":\"Dashboard\"}"); + ``` + +### IPC Communication Mechanism + +- The Runner and Settings UI communicate through Windows Named Pipes +- A two-way pipe (TwoWayPipeMessageIPC) is established between processes +- JSON messages are sent through this pipe to control UI behavior +- The Settings UI initializes the pipe connection on startup: + ```csharp + ipcmanager = new TwoWayPipeMessageIPCManaged(cmdArgs[(int)Arguments.SettingsPipeName], + cmdArgs[(int)Arguments.PTPipeName], + (string message) => { + if (IPCMessageReceivedCallback != null && message.Length > 0) { + IPCMessageReceivedCallback(message); + } + }); + ``` + +### Settings UI Message Processing + +The Settings UI processes incoming IPC messages through a callback chain: + +1. Messages from the Runner are received through the IPC callback +2. Messages are parsed as JSON objects +3. Registered handlers in `ShellPage.ShellHandler.IPCResponseHandleList` process the messages +4. The `ReceiveMessage` method in `ShellPage` interprets commands: + - For flyout display: `"ShowYourself": "flyout"` + - For main window: `"ShowYourself": "Dashboard"` or other page names + +When showing the flyout, the tray icon can also send position coordinates to place the flyout near the tray icon: +```json +{ + "ShowYourself": "flyout", + "x_position": 1234, + "y_position": 567 +} +``` + +The flyout window is then activated and brought to the foreground using native Windows APIs to ensure visibility. + +### Tray Icon Menu +- Menus are defined in `.RC` files (Resource files) +- `base.h` defines IDs +- `resources.resx` contains localized strings +- The tray icon window proc handles showing the popup menu + +## Centralized Keyboard Hook + +- Located in "centralized_keyboard_hook.cpp" +- Handles hotkeys for multiple modules to prevent performance issues +- Contains optimizations to exit early when possible: + - Ignores keystrokes generated by PowerToys itself + - Ignores when no keys are actually pressed + - Uses metadata to avoid re-processing modified inputs +- Performance consideration: handler must run very fast as it's called on every keystroke + +## Module Loading Process + +1. Scan module directory for DLLs +2. Create the module interface object for each module +3. Load settings for each module +4. Initialize each module +5. Check GPO policies to determine which modules can start +6. Start enabled modules that aren't disabled by policy + +## Finding and Messaging the Tray Icon + +The tray icon class is used when sending messages to the runner. For example, to close the runner: + +```cpp +// Find the window with the PowerToys tray icon class and send it a close message +WM_CLOSE +``` + +## Key Files and Their Purposes + +#### [`main.cpp`](/src/runner/main.cpp) +Contains the executable starting point, initialization code and the list of known PowerToys. All singletons are also initialized here at the start. Loads all the powertoys by scanning the `./modules` folder and `enable()`s those marked as enabled in `%LOCALAPPDATA%\Microsoft\PowerToys\settings.json` config. Then it runs [a message loop](https://learn.microsoft.com/windows/win32/winmsg/using-messages-and-message-queues) for the tray UI. Note that this message loop also [handles lowlevel_keyboard_hook events](https://github.com/microsoft/PowerToys/blob/1760af50c8803588cb575167baae0439af38a9c1/src/runner/lowlevel_keyboard_event.cpp#L24). + +#### [`powertoy_module.h`](/src/runner/powertoy_module.h) and [`powertoy_module.cpp`](/src/runner/powertoy_module.cpp) +Contains code for initializing and managing the PowerToy modules. `PowertoyModule` is a RAII-style holder for the `PowertoyModuleIface` pointer, which we got by [invoking module DLL's `powertoy_create` function](https://github.com/microsoft/PowerToys/blob/1760af50c8803588cb575167baae0439af38a9c1/src/runner/powertoy_module.cpp#L13-L24). + +#### [`tray_icon.cpp`](/src/runner/tray_icon.cpp) +Contains code for managing the PowerToys tray icon and its menu commands. Note that `dispatch_run_on_main_ui_thread` is used to +transfer received json message from the [Settings window](/doc/devdocs/settings.md) to the main thread, since we're communicating with it from [a dedicated thread](https://github.com/microsoft/PowerToys/blob/7357e40d3f54de51176efe54fda6d57028837b8c/src/runner/settings_window.cpp#L267-L271). + +#### [`settings_window.cpp`](/src/runner/settings_window.cpp) +Contains code for starting the PowerToys settings window and communicating with it. Settings window is a separate process, so we're using [Windows pipes](https://learn.microsoft.com/windows/win32/ipc/pipes) as a transport for json messages. + +#### [`general_settings.cpp`](/src/runner/general_settings.cpp) +Contains code for loading, saving and applying the general settings. + +#### [`auto_start_helper.cpp`](/src/runner/auto_start_helper.cpp) +Contains helper code for registering and unregistering PowerToys to run when the user logs in. + +#### [`unhandled_exception_handler.cpp`](/src/runner/unhandled_exception_handler.cpp) +Contains helper code to get stack traces in builds. Can be used by adding a call to `init_global_error_handlers` in [`WinMain`](./main.cpp). + +#### [`trace.cpp`](/src/runner/trace.cpp) +Contains code for telemetry. + +#### [`svgs`](/src/runner/svgs/) +Contains the SVG assets used by the PowerToys modules. + +#### [`bug_report.cpp`](/src/runner/bug_report.cpp) +Contains logic to start bug report tool. + +#### [`centralized_hotkeys.cpp`](/src/runner/centralized_hotkeys.cpp) +Contains hot key logic registration and un-registration. + +#### [`centralized_kb_hook.cpp`](/src/runner/centralized_kb_hook.cpp) +Contains logic to handle PowerToys' keyboard shortcut functionality. + +#### [`restart_elevated.cpp`](/src/runner/restart_elevated.cpp) +Contains logic for restarting the current process with different elevation levels. + +#### [`RestartManagement.cpp`](/src/runner/RestartManagement.cpp) +Contains code for restarting a process. + +#### [`settings_telemetry.cpp`](/src/runner/settings_telemetry.cpp) +Contains logic that periodically triggers module-specific setting's telemetry delivery and manages timing and error handling for the process. + +#### [`UpdateUtils.cpp`](/src/runner/UpdateUtils.cpp) +Contains code to handle the automatic update checking, notification, and installation process for PowerToys. diff --git a/doc/devdocs/settingsv2/communication-with-modules.md b/doc/devdocs/core/settings/communication-with-modules.md similarity index 100% rename from doc/devdocs/settingsv2/communication-with-modules.md rename to doc/devdocs/core/settings/communication-with-modules.md diff --git a/doc/devdocs/settingsv2/compatibility-legacy-settings.md b/doc/devdocs/core/settings/compatibility-legacy-settings.md similarity index 87% rename from doc/devdocs/settingsv2/compatibility-legacy-settings.md rename to doc/devdocs/core/settings/compatibility-legacy-settings.md index 9f859e3cd8..66333ce7f5 100644 --- a/doc/devdocs/settingsv2/compatibility-legacy-settings.md +++ b/doc/devdocs/core/settings/compatibility-legacy-settings.md @@ -9,4 +9,4 @@ The following must be kept in mind regarding compatibility with settings v1 and - The status of each of the modules is communicated with the runner in the form of a json object. The names of all the powerToys is set in the [`EnableModules.cs`](src/settings-ui/Settings.UI.Library/EnabledModules.cs) file. The `JsonPropertyName` must not be changed to ensure that the information is dispatched properly to all the modules by the runner. ### ImageResizer anomaly -All the powertoys have the same folder name as well as JsonPropertyName to communicate information with the runner. However that is not the case with ImageResizer. The folder name is `ImageResizer` whereas the JsonPropertyName is `Image Resizer`(Note the additional space). This should not be changed to ensure backward compatibility as well as proper functioning of the module. +All the powertoys have the same folder name as well as JsonPropertyName to communicate information with the runner. However that is not the case with ImageResizer. The folder name is `ImageResizer` whereas the JsonPropertyName has an additional space: `Image Resizer`. This should not be changed to ensure backward compatibility as well as proper functioning of the module. diff --git a/doc/devdocs/settingsv2/dsc-configure.md b/doc/devdocs/core/settings/dsc-configure.md similarity index 95% rename from doc/devdocs/settingsv2/dsc-configure.md rename to doc/devdocs/core/settings/dsc-configure.md index 70f116300d..4e6704f6ec 100644 --- a/doc/devdocs/settingsv2/dsc-configure.md +++ b/doc/devdocs/core/settings/dsc-configure.md @@ -1,6 +1,6 @@ # What is it -We would like to enable our users to use [`winget configure`](https://learn.microsoft.com/en-us/windows/package-manager/winget/configure) command to install PowerToys and configure its settings with a [Winget configuration file](https://learn.microsoft.com/en-us/windows/package-manager/configuration/create). For example: +We would like to enable our users to use [`winget configure`](https://learn.microsoft.com/en-us/windows/package-manager/winget/configure) command to install PowerToys and configure its settings with a [WinGet configuration file](https://learn.microsoft.com/en-us/windows/package-manager/configuration/create). For example: ```yaml properties: @@ -35,7 +35,7 @@ This should install PowerToys and make `PowerToysConfigure` resource available. PowerToys.Settings.exe set <ModuleName>.<SettingName> <SettingValue> ``` -So for the example the config above should perform 3 following invocations: +So for example the config above should perform 3 following invocations: ``` PowerToys.Settings.exe set ShortcutGuide.Enabled false PowerToys.Settings.exe set FancyZones.Enabled true diff --git a/doc/devdocs/core/settings/gpo-integration.md b/doc/devdocs/core/settings/gpo-integration.md new file mode 100644 index 0000000000..e3154137f0 --- /dev/null +++ b/doc/devdocs/core/settings/gpo-integration.md @@ -0,0 +1,64 @@ +# Group Policy Integration + +PowerToys settings can be controlled and enforced via Group Policy. This document describes how Group Policy integration is implemented in the settings system. + +## Overview + +Group Policy settings for PowerToys allow administrators to: + +- Enable or disable PowerToys entirely +- Control which modules are available +- Configure specific settings for individual modules +- Enforce settings across an organization + +## Implementation Details + +When a setting is controlled by Group Policy: + +1. The UI shows the setting as locked (disabled) +2. The module checks GPO settings before applying user settings +3. GPO settings take precedence over user settings + +## Group Policy Settings Detection + +The settings UI checks for Group Policy settings during initialization: + +```csharp +// Example code for checking if a setting is controlled by GPO +bool isControlledByPolicy = RegistryHelper.GetGPOValue("PolicyKeyPath", "PolicyValueName", out object value); +if (isControlledByPolicy) +{ + // Use the policy value and disable UI controls + setting.IsEnabled = false; + setting.Value = (bool)value; +} +``` + +## UI Indication for Managed Settings + +When a setting is managed by Group Policy, the UI indicates this to the user: + +- Controls are disabled (grayed out) +- A tooltip indicates the setting is managed by policy +- The actual policy value is displayed + +## Testing Group Policy Settings + +To test Group Policy integration: + +1. Create a test GPO using the PowerToys ADMX template +2. Apply settings in the Group Policy Editor +3. Verify that the settings UI correctly reflects the policy settings +4. Verify that the modules honor the policy settings + +## GPO Settings vs. User Settings + +The precedence order for settings is: + +1. Group Policy settings (highest priority) +2. User settings (lower priority) +3. Default settings (lowest priority) + +When a setting is controlled by Group Policy, attempts to modify it through the settings UI or programmatically will not persist, as the policy value will always take precedence. + +For more information on PowerToys Group Policy implementation, see the [GPO Implementation](/doc/devdocs/processes/gpo.md) documentation. diff --git a/doc/devdocs/settingsv2/hotkeycontrol.md b/doc/devdocs/core/settings/hotkeycontrol.md similarity index 100% rename from doc/devdocs/settingsv2/hotkeycontrol.md rename to doc/devdocs/core/settings/hotkeycontrol.md diff --git a/doc/devdocs/settingsv2/project-overview.md b/doc/devdocs/core/settings/project-overview.md similarity index 100% rename from doc/devdocs/settingsv2/project-overview.md rename to doc/devdocs/core/settings/project-overview.md diff --git a/doc/devdocs/core/settings/readme.md b/doc/devdocs/core/settings/readme.md new file mode 100644 index 0000000000..7421526ccb --- /dev/null +++ b/doc/devdocs/core/settings/readme.md @@ -0,0 +1,19 @@ +# PowerToys Settings System + +PowerToys provides a comprehensive settings system that allows users to configure various aspects of the application and its modules. This document provides an overview of the settings system architecture and links to more detailed documentation. + +# Table of Contents +1. [Settings overview](/doc/devdocs/core/settings/project-overview.md) +2. [UI Architecture](/doc/devdocs/core/settings/ui-architecture.md) +3. [ViewModels](/doc/devdocs/core/settings/viewmodels.md) +4. [Settings Implementation](/doc/devdocs/core/settings/settings-implementation.md) +5. [Group Policy Integration](/doc/devdocs/core/settings/gpo-integration.md) +6. Data flow + - [Inter-Process Communication with runner](/doc/devdocs/core/settings/runner-ipc.md) + - [Communication with modules](/doc/devdocs/core/settings/communication-with-modules.md) +7. [Settings Utilities](/doc/devdocs/core/settings/settings-utilities.md) +8. [Custom Hotkey control and keyboard hook handling](hotkeycontrol.md) +9. [Compatibility with legacy settings and runner](/doc/devdocs/core/settings/compatibility-legacy-settings.md) +10. [XAML Island tweaks](/doc/devdocs/core/settings/xaml-island-tweaks.md) +11. [Telemetry](/doc/devdocs/core/settings/telemetry.md) +12. [DSC Configuration](/doc/devdocs/core/settings/dsc-configure.md) \ No newline at end of file diff --git a/doc/devdocs/settingsv2/runner-ipc.md b/doc/devdocs/core/settings/runner-ipc.md similarity index 100% rename from doc/devdocs/settingsv2/runner-ipc.md rename to doc/devdocs/core/settings/runner-ipc.md diff --git a/doc/devdocs/core/settings/settings-implementation.md b/doc/devdocs/core/settings/settings-implementation.md new file mode 100644 index 0000000000..65d0d27c73 --- /dev/null +++ b/doc/devdocs/core/settings/settings-implementation.md @@ -0,0 +1,193 @@ +# Settings Implementation + +This document describes how settings are implemented in PowerToys modules, including code examples for C++ and C# modules, and details on debugging settings issues. + +## C++ Settings Implementation + +For C++ modules, the settings system is implemented in the following files: + +- `settings_objects.h` and `settings_objects.cpp`: Define the basic settings objects +- `settings_helpers.h` and `settings_helpers.cpp`: Helper functions for reading/writing settings +- `settings_manager.h` and `settings_manager.cpp`: Main interface for managing settings + +### Reading Settings in C++ + +```cpp +#include <common/settings_objects.h> +#include <common/settings_helpers.h> + +auto settings = PowerToysSettings::Settings::LoadSettings(L"ModuleName"); +bool enabled = settings.GetValue(L"enabled", true); +``` + +### Writing Settings in C++ + +```cpp +PowerToysSettings::Settings settings(L"ModuleName"); +settings.SetValue(L"setting_name", true); +settings.Save(); +``` + +## C# Settings Implementation + +For C# modules, the settings are accessed through the `SettingsUtils` class in the `Microsoft.PowerToys.Settings.UI.Library` namespace: + +### Reading Settings in C# + +```csharp +using Microsoft.PowerToys.Settings.UI.Library; + +// Read settings +var settings = SettingsUtils.Default.GetSettings<ModuleSettings>("ModuleName"); +bool enabled = settings.Enabled; +``` + +### Writing Settings in C# + +```csharp +using Microsoft.PowerToys.Settings.UI.Library; + +// Write settings +settings.Enabled = true; +SettingsUtils.Default.SaveSettings(settings.ToJsonString(), "ModuleName"); +``` + +## Settings Handling in Modules + +Each PowerToys module must implement settings-related functions in its module interface: + +```cpp +// Get the module's settings +virtual PowertoyModuleSettings get_settings() = 0; + +// Called when settings are changed +virtual void set_config(const wchar_t* config_string) = 0; +``` + +When the user changes settings in the UI: + +1. The settings UI serializes the settings to JSON +2. The JSON is sent to the PowerToys runner via IPC +3. The runner calls the `set_config` function on the appropriate module +4. The module parses the JSON and applies the new settings + +# Shortcut Conflict Detection + +Steps to enable conflict detection for a hotkey: + +### 1. Implement module interface for hotkeys +Ensure the module interface provides either `size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size)` or `std::optional<HotkeyEx> GetHotkeyEx()`. + +- If not yet implemented, you need to add it so that it returns all hotkeys used by the module. +- **Important**: The order of the returned hotkeys matters. This order is used as an index to uniquely identify each hotkey for conflict detection and lookup. +- For reference, see: `src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp` + +### 2. Implement IHotkeyConfig in the module settings (UI side) +Make sure the module’s settings file inherits from `IHotkeyConfig` and implements `HotkeyAccessor[] GetAllHotkeyAccessors()`. + +- This method should return all hotkeys used in the module. +- **Important**: The order of the returned hotkeys must be consistent with step 1 (`get_hotkeys()` or `GetHotkeyEx()`). +- For reference, see: `src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs` +- **_Note:_** `HotkeyAccessor` is a wrapper around HotkeySettings. +It provides both `getter` and `setter` methods to read and update the corresponding hotkey. +Additionally, each `HotkeyAccessor` requires a resource string that describes the purpose of the hotkey. +This string is typically defined in: `src/settings-ui/Settings.UI/Strings/en-us/Resources.resw` + +### 3. Update the module’s ViewModel +The corresponding ViewModel should inherit from `PageViewModelBase` and implement `Dictionary<string, HotkeySettings[]> GetAllHotkeySettings()`. + +- This method should return all hotkeys, maintaining the same order as in steps 1 and 2. +- For reference, see: `src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs` + +### 4. Ensure the module’s Views call `OnPageLoaded()` +Once the module’s view is loaded, make sure to invoke the ViewModel’s `OnPageLoaded()` method: +```cs +Loaded += (s, e) => ViewModel.OnPageLoaded(); +``` +- For reference, see: `src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs` + +## Debugging Settings + +To debug settings issues: + +1. Check the settings files in `%LOCALAPPDATA%\Microsoft\PowerToys\` +2. Ensure JSON is well-formed +3. Monitor IPC communication between settings UI and runner using debugger breakpoints at key points: + - In the Settings UI when sending configuration changes + - In the Runner when receiving and dispatching changes + - In the Module when applying changes +4. Look for log messages related to settings changes in the PowerToys logs + +### Common Issues + +- **Settings not saving**: Check file permissions or conflicts with other processes accessing the file +- **Settings not applied**: Verify IPC communication is working and the module is properly handling the configuration +- **Incorrect settings values**: Check JSON parsing and type conversion in the module code + +## Adding a New Module with Settings + +Adding a new module with settings requires changes across multiple projects. Here's a step-by-step guide with references to real implementation examples: + +### 1. Settings UI Library (Data Models) + +Define the data models for your module's settings in the Settings UI Library project. These data models will be serialized to JSON configuration files stored in `%LOCALAPPDATA%\Microsoft\PowerToys\`. + +Example: [Settings UI Library implementation](https://github.com/shuaiyuanxx/PowerToys/pull/3/files#diff-9be1cb88a52ce119e5ff990811e5fbb476c15d0d6b7d5de4877b1fd51d9241c3) + +### 2. Settings UI (User Interface) + +#### 2.1 Add a navigation item in ShellPage.xaml + +The ShellPage.xaml is the entry point for the PowerToys settings, providing a navigation view of all modules. Add a navigation item for your new module. + +Example: [Adding navigation item](https://github.com/shuaiyuanxx/PowerToys/pull/3/files#diff-5a06e6e7a5c99ae327c350c9dcc10036b49a2d66d66eac79a8364b4c99719c6b) + +#### 2.2 Create a settings page for your module + +Create a new XAML page that contains all the settings controls for your module. + +Example: [New settings page](https://github.com/shuaiyuanxx/PowerToys/pull/3/files#diff-310fd49eba464ddf6a876dcf61f06a6f000ca6744f3a1f915c48c58384d7bacb) + +#### 2.3 Implement the ViewModel + +Create a ViewModel class that handles the settings data and operations for your module. + +Example: [ViewModel implementation](https://github.com/shuaiyuanxx/PowerToys/pull/3/files#diff-409472a53326f2288c5b76b87c7ea8b5527c43ede12214a15b6caabe0403c1d0) + +### 3. Module Implementation + +#### 3.1 Implement PowertoyModuleIface in dllmain.cpp + +The module interface must implement the PowertoyModuleIface to allow the runner to interact with it. + +Reference: [PowertoyModuleIface definition](https://github.com/microsoft/PowerToys/blob/cc644b19982d09fcd2122fe7590c77496c4973b9/src/modules/interface/powertoy_module_interface.h#L6C1-L35C4) + +#### 3.2 Implement Module UI + +Create a UI for your module using either WPF (like ColorPicker) or WinUI3 (like Advanced Paste). + +### 4. Runner Integration + +Add your module to the known modules list in the runner so it can be brought up and initialized. + +Example: [Runner integration](https://github.com/shuaiyuanxx/PowerToys/pull/3/files#diff-c07e4e5e9ce3c371d4c47f496b5f66734978a3c4f355c7e446c1ef19e086a4d6) + +### 5. Testing and Debugging + +1. Test each component individually: + - Verify settings serialization/deserialization + - Test module activation/deactivation + - Test IPC communication + +2. For signal-related issues, ensure all modules work correctly before debugging signal handling. + +3. You can debug each module directly in Visual Studio or by attaching to running processes. + +### Recommended Implementation Order + +1. Module/ModuleUI implementation +2. Module interface (dllmain.cpp) +3. Runner integration +4. Settings UI implementation +5. OOBE (Out of Box Experience) integration +6. Other components diff --git a/doc/devdocs/settingsv2/settings-utilities.md b/doc/devdocs/core/settings/settings-utilities.md similarity index 100% rename from doc/devdocs/settingsv2/settings-utilities.md rename to doc/devdocs/core/settings/settings-utilities.md diff --git a/doc/devdocs/settingsv2/telemetry.md b/doc/devdocs/core/settings/telemetry.md similarity index 100% rename from doc/devdocs/settingsv2/telemetry.md rename to doc/devdocs/core/settings/telemetry.md diff --git a/doc/devdocs/settingsv2/ui-architecture.md b/doc/devdocs/core/settings/ui-architecture.md similarity index 100% rename from doc/devdocs/settingsv2/ui-architecture.md rename to doc/devdocs/core/settings/ui-architecture.md diff --git a/doc/devdocs/settingsv2/viewmodels.md b/doc/devdocs/core/settings/viewmodels.md similarity index 52% rename from doc/devdocs/settingsv2/viewmodels.md rename to doc/devdocs/core/settings/viewmodels.md index c3e17da46a..f9cd7045e4 100644 --- a/doc/devdocs/settingsv2/viewmodels.md +++ b/doc/devdocs/core/settings/viewmodels.md @@ -1,25 +1,25 @@ -# Viewmodels -The viewmodels are located within the [`Settings.UI.Library`](/src/settings-ui/Settings.UI.Library) project. +# View Models +The view models are located within the [`Settings.UI.Library`](/src/settings-ui/Settings.UI.Library) project. ## Components -- Each viewmodel takes in the general `settingsRepository`, the `moduleSettingsRepository` if it exists and the delegates for IPC communication. +- Each view model takes in the general `settingsRepository`, the `moduleSettingsRepository` if it exists and the delegates for IPC communication. - The general `settingsRepository` contains the general configurations of all powertoys whereas the `moduleSettingsRepository` is specific to the module. This is to ensure that the configuration details are shared amongst the viewmodels without having to re-open the `settings.json` file. -- Whenever there is a change in the UI, the `OnPropertyChanged` event is invoked and the viewmodel sends a corresponding IPC message to the runner which would perform the designated action such as dispatching the change to the modules or enabling/disabling the powertoy etc. +- Whenever there is a change in the UI, the `OnPropertyChanged` event is invoked and the view model sends a corresponding IPC message to the runner which would perform the designated action such as dispatching the change to the modules or enabling/disabling the powertoy, etc. -#### Difference between viewmodels +#### Difference between view models - The [`GeneralViewModel`](/src/settings-ui/Settings.UI.Library/ViewModels/GeneralViewModel.cs) is different from the rest of the view models with regard to the IPC communication wherein it sends special IPC messages to the runner to check for updates and to restart as admin. -- Each of the powerToy viewmodels have two types of IPC communications, one for the general status of the powerToy and the other for communication powerToy specific change in properties to the runner. +- Each of the powerToy view models have two types of IPC communications, one for the general status of the powerToy and the other for communication powerToy specific change in properties to the runner. ## [`SettingsRepository`](src/settings-ui/Settings.UI.Library/SettingsRepository`1.cs) -- The [`SettingsRepository`](src/settings-ui/Settings.UI.Library/SettingsRepository`1.cs) is a generic singleton which contains the configurations for each viewmodel. -- As it is a generic singleton, there can only be one instance of the settings repository of a particular type. This ensures that all the viewmodels are modifying a common object and a change made in one locations reflects everywhere. +- The [`SettingsRepository`](src/settings-ui/Settings.UI.Library/SettingsRepository`1.cs) is a generic singleton which contains the configurations for each view model. +- As it is a generic singleton, there can only be one instance of the settings repository of a particular type. This ensures that all the view models are modifying a common object and a change made in one locations reflects everywhere. - The singleton implementation is thread-safe. Unit tests have been added for the same. -### Settings viewmodel anomalies -- The reason behind using the `SettingsRepository` is to ensure that the settings process does not try to access the `settings.json` files directly but rather does it through this class which encapsulates all the file operations from the viewmodels. -- However, this could not be expanded to all the viewmodels directly for the following reasons. Some refactoring must be done to unify these cases and to bring them under the same model: - - The PowerRename viewmodel does not save the settings configurations in the same format as the rest of the powertoys, ie. {name, version, properties}. However, it only stores the properties directly. - - Some viewmodels expect the runner to create the file instead of creating the file themselves, like in keyboard manager. +### Settings view model anomalies +- The reason behind using the `SettingsRepository` is to ensure that the settings process does not try to access the `settings.json` files directly but rather does it through this class which encapsulates all the file operations from the view models. +- However, this could not be expanded to all the view models directly for the following reasons. Some refactoring must be done to unify these cases and to bring them under the same model: + - The PowerRename view model does not save the settings configurations in the same format as the rest of the powertoys, i.e. {name, version, properties}. However, it only stores the properties directly. + - Some view models expect the runner to create the file instead of creating the file themselves, like in keyboard manager. - The colorpicker powertoy creates the `settings.json` within the module. This must be taken care of when encapsulated within the settingsRepository. - Currently, all modules use the `SettingsRepository` to access the General Settings config. - However, only FancyZones, ShortcutGuide and PowerPreview use the `SettingsRepository` to access the module properties. diff --git a/doc/devdocs/development/debugging.md b/doc/devdocs/development/debugging.md new file mode 100644 index 0000000000..c3e254e0a2 --- /dev/null +++ b/doc/devdocs/development/debugging.md @@ -0,0 +1,135 @@ +# Debugging PowerToys + +This document covers techniques and tools for debugging PowerToys. + +## Pre-Debugging Setup + +Before you can start debugging PowerToys, you need to set up your development environment: + +1. Fork the repository and clone it to your machine +2. Navigate to the repository root directory +3. Run `git submodule update --init --recursive` to initialize all submodules +4. Change directory to `.config` and run `winget configure .\configuration.vsEnterprise.winget` (pick the configuration file that matches your Visual Studio distribution) + +### Optional: Building Outside Visual Studio + +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` +2. Navigate to the repository root directory +3. Run the following command(don't forget to set the correct platform): + ```pwsh + msbuild -restore -p:RestorePackagesConfig=true -p:Platform=ARM64 -m PowerToys.slnx /tl /p:NuGetInteractive="true" + ``` +4. This process should complete in approximately 13-14 minutes for a full build + +## Debugging Techniques + +### Visual Studio Debugging + +To debug the PowerToys application in Visual Studio, set the `runner` project as your start-up project, then start the debugger. + +Some PowerToys modules must be run with the highest permission level if the current user is a member of the Administrators group. The highest permission level is required to be able to perform some actions when an elevated application (e.g. Task Manager) is in the foreground or is the target of an action. Without elevated privileges some PowerToys modules will still work but with some limitations: + +- The `FancyZones` module will not be able to move an elevated window to a zone. +- The `Shortcut Guide` module will not appear if the foreground window belongs to an elevated application. + +Therefore, it is recommended to run Visual Studio with elevated privileges when debugging these scenarios. If you want to avoid running Visual Studio with elevated privileges and don't mind the limitations described above, you can do the following: open the `runner` project properties and navigate to the `Linker -> Manifest File` settings, edit the `UAC Execution Level` property and change it from `highestAvailable (level='highestAvailable')` to `asInvoker (/level='asInvoker'). + +### Shell Process Debugging Tool + +The Shell Process Debugging Tool is a Visual Studio extension that helps debug multiple processes, which is especially useful for PowerToys modules started by the runner. + +#### Debugging Setup Process + +1. Install ["Debug Child Processes"](https://marketplace.visualstudio.com/items?itemName=vsdbgplat.MicrosoftChildProcessDebuggingPowerTool2022) Visual Studio extension +2. Configure which processes to debug and what debugger to use for each +3. Start PowerToys from Visual Studio +4. The extension will automatically attach to specified child processes when launched + +#### Debugging Color Picker Example + +1. Set breakpoints in both ColorPicker and its module interface +2. Use Shell Process Debugging to attach to ColorPickerUI.exe +3. Debug .NET and native code together +4. Runner needs to be running to properly test activation + +#### Debugging DLL Main/Module Interface + +- Breakpoints in DLL code will be hit when loaded by runner +- No special setup needed as DLL is loaded into runner process + +#### Debugging Short-Lived Processes + +- For processes with short lifetimes (like in Workspaces) +- List all processes explicitly in debugging configuration +- Set correct debugger type (.NET debugger for C# code) + +### Finding Registered Events + +1. Run WinObj tool from SysInternals as administrator +2. Search for event name +3. Shows handles to the event (typically runner and module) + +### Common Debugging Usage Patterns + +#### Debugging with Bug Report +1. Check module-specific logs for exceptions/crashes +2. Copy user's settings to your AppData to reproduce their configuration +3. Check Event Viewer XML files if logs don't show crashes +4. Compare installation_folder_structure.txt to detect corrupted installations +5. Check installer logs for installation-related issues +6. Look at Windows version and language settings for patterns across users + +#### Installer Debugging +- Can only build installer in Release mode +- Typically debug using logs and message boxes +- Logs located in: + - `%LOCALAPPDATA%\Temp\PowerToys_bootstrapper_*.log` - MSI tool logs + - `%LOCALAPPDATA%\Temp\PowerToys_*.log` - Custom installer logs +- Logs in Bug Reports are useful for troubleshooting installation issues + +#### Settings UI Debugging +- Use shell process debugging to connect to newly created processes +- Debug the `PowerToys.Settings.exe` process +- Add breakpoints as needed for troubleshooting +- Logs are stored in the local app directory: `%LOCALAPPDATA%\Microsoft\PowerToys` +- Check Event Viewer for application crashes related to `PowerToys.Settings.exe` +- Crash dumps can be obtained from Event Viewer + +## Troubleshooting Build Errors + +### Missing Image Files or Corrupted Build State + +If you encounter build errors about missing image files (e.g., `.png`, `.ico`, or other assets), this typically indicates a corrupted build state. To resolve: + +1. **Clean the solution in Visual Studio**: Build > Clean Solution + + Or from the command line (`Developer Command Prompt for VS`): + ```pwsh + msbuild PowerToys.slnx /t:Clean /p:Platform=x64 /p:Configuration=Debug + ``` + +2. **Delete build output and package folders** from the repository root: + - `x64/` + - `ARM64/` + - `Debug/` + - `Release/` + - `packages/` + +3. **Rebuild the solution** + +#### Helper Script + +A PowerShell script is available to automate this cleanup: + +```pwsh +.\tools\build\clean-artifacts.ps1 +``` + +This script will run MSBuild Clean and remove the build folders listed above. Use `-SkipMSBuildClean` if you only want to delete the folders without running MSBuild Clean. + +After cleaning, rebuild with: +```pwsh +msbuild -restore -p:RestorePackagesConfig=true -p:Platform=x64 -m PowerToys.slnx +``` diff --git a/doc/devdocs/development/dev-with-vscode.md b/doc/devdocs/development/dev-with-vscode.md new file mode 100644 index 0000000000..877e4c04cb --- /dev/null +++ b/doc/devdocs/development/dev-with-vscode.md @@ -0,0 +1,149 @@ +## Developing PowerToys with Visual Studio Code + +This guide shows how to build, debug, and contribute to PowerToys using VS Code instead of (or alongside) full Visual Studio. It focuses on common inner‑loop tasks for C++, .NET, and mixed scenarios present in the solution. + +> PowerToys is a large mixed C++ / C# / WinAppSDK solution. VS Code works well for incremental development and quick module iterations, but occasionally you may still prefer full Visual Studio for designer tooling or specialized diagnostics. + +--- +VS Code extensions Needed: + +| Area | Extension | Notes | +|------|-----------|-------| +| C++ | ms-vscode.cpptools | IntelliSense, debugging (cppvsdbg) | +| C# | ms-dotnettools.csdevkit (or C#) | Language service / test explorer | + +--- + +## Building in VS Code +### Configure Developer PowerShell for VS for more convenient development experience in VS Code +1. Configure profile in settings, entry: `terminal.integrated.profiles.windows` +2. Add below config as entry (choose VS 2026 or VS 2022 based on your installation): + +**For Visual Studio 2026 (recommended):** +```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", + "}" + ] + }, +``` + +**For Visual Studio 2022:** +```json + "Developer PowerShell for VS 2022": { + // 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\\2022\\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 +```powershell +# Restore: +msbuild powertoys.slnx -t:restore -p:configuration=debug -p:platform=x64 -m + +# Build powertoys slnx +msbuild powertoys.slnx -p:configuration=debug -p:platform=x64 -m + +# dotnet project +msbuild src\settings-ui\Settings.UI\PowerToys.Settings.csproj -p:Platform=x64 -p:Configuration=Debug -m + +# native project +msbuild "src\modules\MouseUtils\FindMyMouse\FindMyMouse.vcxproj" -p:Configuration=Debug -p:Platform=x64 -m +``` + +--- + +## Debugging + +### Existing launch configuration + +The repo provides `.vscode/launch.json` with: + +- `Run PowerToys.exe (no build)`: Launches the already-built executable at `x64/Debug/PowerToys.exe` using `cppvsdbg`. + +Build first, then press F5. To switch configuration (Release / ARM64) either edit the path or create additional launch entries. + +### Attaching to a running instance + +If PowerToys is already running, you can attach to that process: + +2. VS Code command palette: “C/C++: (Windows) Attach to Process”. +3. Filter for `PowerToys.exe` / module-specific processes. + +### Debugging managed components + +Many modules have a managed component loaded into the PowerToys process. `cppvsdbg` can debug mixed mode, but if you need richer .NET inspection you can create a second configuration using `type: coreclr` and `processId` attachment after the native launch, or just attach separately: + +Similar for attach to managed code. +> Note: In arm64 machine, can only debug arm64 code. + +```jsonc +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run native executable (no build)", + "type": "cppvsdbg", + "request": "launch", + "program": "${workspaceFolder}\\x64\\Debug\\PowerToys.exe", + "args": [], + "stopAtEntry": false, + "cwd": "${workspaceFolder}", + "environment": [], + "console": "integratedTerminal" + }, + { + "name": "C/C++ Attach to PowerToys Process (native)", + "type": "cppvsdbg", + "request": "attach", + "processId": "${command:pickProcess}", + "symbolSearchPath": "${workspaceFolder}\\x64\\Debug;${workspaceFolder}\\Debug;${workspaceFolder}\\symbols" + }, + { + "name": "Run managed code (managed, no build)", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}\\arm64\\Debug\\WinUI3Apps\\PowerToys.Settings.exe", + "args": [], + "cwd": "${workspaceFolder}", + "env": {}, + "console": "internalConsole", + "stopAtEntry": false + } + ] +} +``` +--- + +## 6. Common tasks & tips + +| Task | Command / Action | Notes | +|------|------------------|-------| +| Clean | `git clean -xdf` (careful) or `msbuild /t:Clean PowerToys.slnx` | Deep clean removes packages & build outputs | +| Rebuild single project | `msbuild path\to\proj.vcxproj /t:Rebuild -p:Platform=x64 -p:Configuration=Debug` | Faster than whole solution | +| Generate installer (rare in inner loop) | See `tools\build\build-installer.ps1` | Usually not needed for local debug | +| Resource conversion errors | Re-run restore + build | Triggers custom PowerShell targets | diff --git a/doc/devdocs/development/guidelines.md b/doc/devdocs/development/guidelines.md new file mode 100644 index 0000000000..8d410f054d --- /dev/null +++ b/doc/devdocs/development/guidelines.md @@ -0,0 +1,146 @@ +# PowerToys Development Guidelines + +## Using Open Source Packages and Libraries + +### License Considerations +- MIT license is generally acceptable for inclusion in the project +- For any license other than MIT, double check with the PM team +- All external packages or projects must be mentioned in the `notice.md` file +- Even if a license permits free use, it's better to verify with the team + +### Safety and Quality Considerations +- Ensure the code being included is safe to use +- Avoid repositories or packages that are not widely used +- Check for packages with significant downloads/usage and good ratings +- Important because our pipeline signs external DLLs with Microsoft certificate +- Unsafe code signed with Microsoft certificate can cause serious issues + +## Code Signing + +### Signing JSON File +- Modifications to the signing JSON file are typically done manually +- When adding new DLLs (internal PowerToys modules or external libraries) +- When the release pipeline fails with a list of unsigned DLLs/executables: + - For PowerToys DLLs, manually add them to the list + - For external DLLs, verify they're safe to sign before including + +### File Signing Requirements +- All DLLs and executables must be signed +- New files need to be added to the signing configuration +- CI checks if all files are signed +- Even Microsoft-sourced dependencies are signed if they aren't already + +## Performance Measurement + +- Currently no built-in timers to measure PowerToys startup time +- Startup measurement could be added in the runner: + - At the start of the main method + - After all module interface DLLs are loaded +- Alternative: use profilers or Visual Studio profiler +- Startup currently takes some time due to: + - Approximately 20 module interface DLLs that need to be loaded + - Modules that are started during loading +- No dashboards or dedicated tools for performance measurement +- Uses System.Diagnostics.Stopwatch in code +- Performance data is logged to default PowerToys logs +- Can search logs for stopwatch-related messages to diagnose performance issues +- Some telemetry events contain performance information + +## Dependency Management + +### WinRT SDK and CS/WinRT +- Updates to WinRT SDK and CS/WinRT are done periodically +- WinRT SDK often requires higher versions of CS/WinRT or vice versa +- Check for new versions in NuGet.org or Visual Studio's NuGet Package Explorer +- Prefer stable versions over preview versions +- Best practice: Update early in the release cycle to catch potential regressions + +### WebView2 +- Used for components like monotone file preview +- WebView2 version is related to the WebView runtime in Windows +- Previous issues with Windows Update installing new WebView runtime versions +- WebView team now includes PowerToys testing in their release cycle +- When updating WebView2: + - Update the version + - Open a PR + - Perform sanity checks on components that use WebView2 + +### General Dependency Update Process +- When updating via Visual Studio, it will automatically update dependencies +- After updates, perform: + - Clean build + - Sanity check that all modules still work + - Open PR with changes + +## Testing Requirements + +### Multiple Computers +- **Mouse Without Borders**: Requires multiple physical computers for proper testing + - Testing with VMs is not recommended as it may cause confusion between host and guest mouse input + - At least 2 computers are needed, sometimes testing with 3 is done + - Testing is usually assigned to team members known to have multiple computers + +### Multiple Monitors +- Some utilities require multiple monitors for testing +- At least 2 monitors are recommended +- One monitor should be able to use different DPI settings + +### Fuzzing Testing +- Security team requires fuzzing testing for modules that handle file I/O or user input +- Helps identify vulnerabilities and bugs by feeding random, invalid, or unexpected data +- PowerToys integrates with Microsoft's OneFuzz service for automated testing +- Both .NET (C#) and C++ modules have different fuzzing implementation approaches +- New modules handling file I/O or user input should implement fuzzing tests +- For detailed setup instructions, see [Fuzzing Testing in PowerToys](../tools/fuzzingtesting.md) + +### Testing Process +- For reporting bugs during the release candidate testing: + 1. Discuss in team chat + 2. Determine if it's a regression (check if bug exists in previous version) + 3. Check if an issue is already open + 4. Open a new issue if needed + 5. Decide on criticality for the release (if regression) + +### Release Testing +- Team follows a release checklist +- Includes testing for WinGet configuration +- Sign-off process: + - Teams sign off on modules independently + - Regressions found in first release candidates lead to PRs + - Second release candidate verified fixes + - Command Palette needs separate sign-off + - Final verification ensures modules don't crash with Command Palette integration + +## PR Management and Release Process + +### PR Review Process +- PM team typically tags PRs with "need review" +- Small fixes from community that don't change much are usually accepted +- PM team adds tags like "need review" to highlight PRs +- PMs set priorities (sometimes using "info.90" tags) +- PMs decide which PRs to prioritize +- Team members can help get PRs merged when there's flexibility + +### PR Approval Requirements +- PRs need approval from code owners before merging +- New team members can approve PRs but final approval comes from code owners + +### PR Priority Handling +- Old PRs sometimes "slip through the cracks" if not high priority +- PMs tag important PRs with "priority one" to indicate they should be included in release +- Draft PRs are generally not prioritized + +### Specific PR Types +- CI-related PRs need review and code owner approval +- Feature additions (like GPO support) need PM decision on whether the feature is wanted +- Bug fixes related to Watson errors sometimes don't have corresponding issue links +- Command Palette is considered high priority for the upcoming release + +## Project Management Notes + +- Be careful about not merging incomplete features into main +- Feature branches should be used for work in progress +- PRs touching installer files should be carefully reviewed +- Incomplete features should not be merged into main +- Use feature branches (feature/name-of-feature) for work-in-progress features +- Only merge to main when complete or behind experimentation flags diff --git a/doc/devdocs/localization.md b/doc/devdocs/development/localization.md similarity index 82% rename from doc/devdocs/localization.md rename to doc/devdocs/development/localization.md index 322f90ab6d..8e6a52abf2 100644 --- a/doc/devdocs/localization.md +++ b/doc/devdocs/development/localization.md @@ -1,8 +1,5 @@ # Localization -> **NOTE**: THIS DOCUMENT IS OUTDATED. -> Follow [issue 15243](https://github.com/microsoft/PowerToys/issues/15243) for updates. - ## Table of Contents 1. [Localization on the pipeline (CDPX)](#localization-on-the-pipeline-cdpx) 1. [UWP Special case](#uwp-special-case) @@ -17,7 +14,7 @@ ## Localization on the pipeline (CDPX) [The localization step](https://github.com/microsoft/PowerToys/blob/86d77103e9c69686c297490acb04775d43ef8b76/.pipelines/pipeline.user.windows.yml#L45-L52) is run on the pipeline before the solution is built. This step runs the [build-localization](https://github.com/microsoft/PowerToys/blob/main/.pipelines/build-localization.cmd) script, which generates resx files for all the projects with localization enabled using the `Localization.XLoc` package. -The [`Localization.XLoc`](https://github.com/microsoft/PowerToys/blob/86d77103e9c69686c297490acb04775d43ef8b76/.pipelines/build-localization.cmd#L24-L25) tool is run on the repo root, and it checks for all occurrences of `LocProject.json`. Each localized project has a `LocProject.json` file in the project root, which contains the location of the English resx file, list of languages for localization, and the output path where the localized resx files are to be copied to. In addition to this, some other parameters can be set, such as whether the language ID should be added as a folder in the file path or in the file name. When the CDPX pipeline is run, the localization team is notified of changes in the English resx files. For each project with localization enabled, a `loc` folder (see [this](https://github.com/microsoft/PowerToys/tree/main/src/modules/launcher/Microsoft.Launcher/loc) for example) is created in the same directory as the `LocProject.json` file. The folder contains language specific folders which in turn have a nested folder path equivalent to `OutputPath` in the `LocProject.json`. Each of these folders contain one `lcl` file. The `lcl` files contain the English resources along with their translation for that language. These are described in more detail in the [Lcl files section](#lcl-files). Once the `.resx` files are generated, they will be used during the `Build PowerToys` step for localized versions of the modules. +The [`Localization.XLoc`](https://github.com/microsoft/PowerToys/blob/86d77103e9c69686c297490acb04775d43ef8b76/.pipelines/build-localization.cmd#L24-L25) tool is run on the repo root, and it checks for all occurrences of `LocProject.json`. Each localized project has a `LocProject.json` file in the project root, which contains the location of the English resx file, list of languages for localization, and the output path where the localized resx files are to be copied to. In addition to this, some other parameters can be set, such as whether the language ID should be added as a folder in the file path or in the file name. When the CDPX pipeline is run, the localization team is notified of changes in the English resx files. For each project with localization enabled, a `loc` folder (see [the loc folder in the Microsoft.Launcher module](https://github.com/microsoft/PowerToys/tree/main/src/modules/launcher/Microsoft.Launcher/loc) for example) is created in the same directory as the `LocProject.json` file. The folder contains language specific folders which in turn have a nested folder path equivalent to `OutputPath` in the `LocProject.json`. Each of these folders contain one `lcl` file. The `lcl` files contain the English resources along with their translation for that language. These are described in more detail in the [Lcl files section](#lcl-files). Once the `.resx` files are generated, they will be used during the `Build PowerToys` step for localized versions of the modules. Since the localization script requires certain nuget packages, the [`restore-localization`](https://github.com/microsoft/PowerToys/blob/main/.pipelines/restore-localization.cmd) script is run before running `build-localization` to install all the required packages. This script must [run in the `restore` step](https://github.com/microsoft/PowerToys/blob/86d77103e9c69686c297490acb04775d43ef8b76/.pipelines/pipeline.user.windows.yml#L37-L39) of pipeline because [the host is network isolated](https://onebranch.visualstudio.com/Pipeline/_wiki/wikis/Pipeline.wiki/2066/Consuming-Packages-in-a-CDPx-Pipeline?anchor=overview) at the `build` step. The [Toolset package source](https://github.com/microsoft/PowerToys/blob/86d77103e9c69686c297490acb04775d43ef8b76/.pipelines/pipeline.user.windows.yml#L23) is used for this. @@ -162,4 +159,65 @@ This can be done by adding the directory name of the project to [Product.wxs nea We should also ensure the new dlls are signed by the pipeline. Currently all dlls of the form [`*.resources.dll` are signed](https://github.com/microsoft/PowerToys/blob/f92bd6ffd38014c228544bb8d68d0937ce4c2b6d/.pipelines/pipeline.user.windows.yml#L68). -**Note:** The resource dlls should be added to the MSI project only after the initial commit with the lcl files has been done by the Localization team. Otherwise the pipeline will fail as there wouldn't be any resx files to generate the dlls. +**Note:** The resource dlls should be added to the MSI project only after the initial commit with the lcl files has been done by the Localization team. Otherwise, the pipeline will fail as there wouldn't be any resx files to generate the dlls. + +## Working With Strings + +In order to support localization **YOU SHOULD NOT** have hardcoded UI display strings in your code. Instead, use resource files to consume strings. + +### For CPP +Use [`StringTable` resource][String Table] to store the strings and resource header file(`resource.h`) to store Id's linked to the UI display string. Add the strings with Id's referenced from the header file to the resource-definition script file. You can use [Visual Studio Resource Editor][VS Resource Editor] to create and manage resource files. + +- `resource.h`: + +XXX must be a unique int in the list (mostly the int ID of the last string id plus one): + +```cpp +#define IDS_MODULE_DISPLAYNAME XXX +``` + +- `StringTable` in resource-definition script file `validmodulename.rc`: + +``` +STRINGTABLE +BEGIN + IDS_MODULE_DISPLAYNAME L"Module Name" +END +``` + +- Use the `GET_RESOURCE_STRING(UINT resource_id)` method to consume strings in your code. +```cpp +#include <common.h> + +std::wstring GET_RESOURCE_STRING(IDS_MODULE_DISPLAYNAME) +``` + +### For C# +Use [XML resource file(.resx)][Resx Files] to store the UI display strings and [`Resource Manager`][Resource Manager] to consume those strings in the code. You can use [Visual Studio][Resx Files VS] to create and manage XML resources files. + +- `Resources.resx` + +```xml + <data name="ValidUIDisplayString" xml:space="preserve"> + <value>Description to be displayed on UI.</value> + <comment>This text is displayed when XYZ button clicked.</comment> + </data> +``` + +- Use [`Resource Manager`][Resource Manager] to consume strings in code. +```csharp +System.Resources.ResourceManager manager = new System.Resources.ResourceManager(baseName, assembly); +string validUIDisplayString = manager.GetString("ValidUIDisplayString", resourceCulture); +``` + +In case of Visual Studio is used to create the resource file. Simply use the `Resources` class in auto-generated `Resources.Designer.cs` file to access the strings which encapsulate the [`Resource Manager`][Resource Manager] logic. + +```csharp +string validUIDisplayString = Resources.ValidUIDisplayString; +``` + +[VS Resource Editor]: https://learn.microsoft.com/cpp/windows/resource-editors?view=vs-2019 +[String Table]: https://learn.microsoft.com/windows/win32/menurc/stringtable-resource +[Resx Files VS]: https://learn.microsoft.com/dotnet/framework/resources/creating-resource-files-for-desktop-apps#resource-files-in-visual-studio +[Resx Files]: https://learn.microsoft.com/dotnet/framework/resources/creating-resource-files-for-desktop-apps#resources-in-resx-files +[Resource Manager]: https://learn.microsoft.com/dotnet/api/system.resources.resourcemanager?view=netframework-4.8 diff --git a/doc/devdocs/development/logging.md b/doc/devdocs/development/logging.md new file mode 100644 index 0000000000..514824a17d --- /dev/null +++ b/doc/devdocs/development/logging.md @@ -0,0 +1,147 @@ +# Logging and Telemetry in PowerToys + +## Logging Types in PowerToys + +PowerToys has several types of logging mechanisms: +1. Text file logs (application writes logs to files) +2. Telemetry/diagnostic data (sent to Microsoft servers) +3. Event Viewer logs (used by some utilities like Mouse Without Borders) +4. Watson reports (crash reports sent to Microsoft) + +## Log File Locations + +### Regular Logs +- Located at: `%LOCALAPPDATA%\Microsoft\PowerToys\Logs` +- Organized by utility and sometimes by version +- Examples: PowerToys Run logs, module interface logs +- C# and C++ components both write logs to these locations + +### Low-Privilege Logs +- Some components (like preview handlers and thumbnail providers) are started by Explorer and have low privileges +- These components write logs to: `%USERPROFILE%/AppData/LocalLow/Microsoft/PowerToys` +- Example: Monaco preview handler logs + +### Module Logs +- Logs always stored in user's AppData regardless of installation type +- Each module creates its own log +- Even with machine-wide installation, logs are per-user +- Different users can have different logs even with a machine-wide installation + +## Log Implementation + +### C++ Logging + +In C++ projects we use the awesome [spdlog](https://github.com/gabime/spdlog) library for logging as a git submodule under the `deps` directory. To use it in your project, just include [spdlog.props](/deps/spdlog.props) in a .vcxproj like this: + +```xml +<Import Project="..\..\..\deps\spdlog.props" /> +``` +It'll add the required include dirs and link the library binary itself. + +- Projects need to include the logging project as a dependency +- Uses a git submodule for the actual logging library +- Logs are initialized in the main file: + ```cpp + init_logger(); + ``` +- After initialization, any file can use the logger +- Logger settings contain constants like log file locations + +### C# Logging + +For C# projects there is a static logger class in Managed Common called `Logger`. + +To use it, add a project reference to `ManagedCommon` and add the following line of code to all the files using the logger: + +```Csharp +using ManagedCommon; +``` + +In the `Main` function (or a function with a similar meaning (like `App` in a `App.xaml.cs` file)) you have to call `InitializeLogger` and specify the location where the logs will be saved (always use a path scheme similar to this example): + +```Csharp +Logger.InitializeLogger("\\FancyZones\\Editor\\Logs"); +``` + +For a low-privilege process you have to set the optional second parameter to `true`: + +```Csharp +Logger.InitializeLogger("\\FileExplorer\\Monaco\\Logs", true); +``` + +The `Logger` class contains the following logging functions: + +```Csharp +// Logs an error that the utility encountered +Logger.LogError(string message); +Logger.LogError(string message, Exception ex); +// Logs an error that isn't that grave +Logger.LogWarning(string message); +// Logs what the app is doing at the moment +Logger.LogInfo(string message); +// Like LogInfo just with infos important for debugging +Logger.LogDebug(string message); +// Logs the current state of the utility. +Logger.LogTrace(); +``` + +## Log File Management +- Currently, most logs are not automatically cleaned up +- Some modules have community contributions to clean old logs, but not universally implemented +- By default, all info-level logs are written +- Debug and trace logs may not be written by default +- Log settings can be found in settings.json, but not all APIs honor these settings + +## Telemetry + +### Implementation +- Uses Event Tracing for Windows (ETW) for telemetry +- Different from the text file logging system +- Keys required to send telemetry to the right server + - Keys are not stored in the repository + - Obfuscated in public code + - Replaced during the release process + - Stored in private NuGet packages for release builds + +### C++ Telemetry +- Managed through trace_base.h which: + - Registers the provider + - Checks if user has disabled diagnostics + - Defines events +- Example from Always On Top: + ```cpp + Trace::AlwaysOnTop::Enable(true); + ``` + +### C# Telemetry +- Uses PowerToysTelemetry class +- WriteEvent method sends telemetry +- Projects add a reference to the PowerToys.Telemetry project +- Example: + ```csharp + PowerToysTelemetry.Log.WriteEvent(new LauncherShowEvent(hotKey)); + ``` + +### User Controls +- Settings page allows users to: + - Turn off/on sending telemetry + - Enable viewing of telemetry data + +### Viewing Telemetry Data +- When "Enable viewing" is turned on, PowerToys starts ETW tracing +- Saves ETL files for 28 days +- Located at: `%LOCALAPPDATA%\Microsoft\PowerToys\ETL` (for most utilities) +- Low-privilege components save to a different location +- Button in settings converts ETL to XML for user readability +- XML format chosen to follow approved compliance pattern from Windows Subsystem for Android +- Files older than 28 days are automatically deleted + +## Bug Report Tool + +The [BugReportTool](/tools/BugReportTool) can be triggered via: +- Right-click on PowerToys tray icon → Report Bug +- Left-click on tray icon → Open Settings → Bug Report Tool + +It creates a zip file on desktop named "PowerToys_Report_[date]_[time].zip" containing logs and system information. + +See [Bug Report Tool](../tools/bug-report-tool.md) for more detailed information about the tool. diff --git a/doc/devdocs/development/new-powertoy.md b/doc/devdocs/development/new-powertoy.md new file mode 100644 index 0000000000..33a2cd63f0 --- /dev/null +++ b/doc/devdocs/development/new-powertoy.md @@ -0,0 +1,300 @@ +# 🧭 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 + +Follow the [Getting Started](../readme.md#getting-started) guide to set up your development environment, then [validate that you are able to build and run](debugging.md) `PowerToys.slnx`. + +Optional: +- [WiX v5 toolset](https://github.com/microsoft/PowerToys/tree/main) for the installer + +### 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/<YourModule>` +- 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 `<TargetName>` +``` +<PropertyGroup> + <OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir> + <TargetName>PowerToys.LightSwitchService</TargetName> +</PropertyGroup> +``` +- 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\<module>\settings.json +``` + +### Implementation steps + +- In `src\settings-ui\Settings.UI.Library\` create `<module>Properties.cs` and `<module>Settings.cs` +- `<module>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. +- `<module>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 `<module>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 +<ComboBoxItem + x:Uid="LightSwitch_ModeOff" + AutomationProperties.AutomationId="OffCBItem_LightSwitch" + Tag="Off" /> + +// Resources.resw +<data name="LightSwitch_ModeOff.Content" xml:space="preserve"> + <value>Off</value> +</data> +``` +> [!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 <kbd>F5</kbd> 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\<version>` 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 `<!--ModuleNameFiles_Component_Def-->` which is a placeholder for code that will be generated by `generateFileComponents.ps1`. +5. Inside `Product.wxs` add a line item in the `<Feature Id="CoreFeature" ... >` section. It will look like a list of ` <ComponentGroupRef Id="ModuleComponentGroup" />` 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 <Module>Files` will match the string you set in `Module.wxs`, `<ModuleServiceName>` will match the name of your exe. +```bash +# Module Name +Generate-FileList -fileDepsJson "" -fileListName <Module>Files -wxsFilePath $PSScriptRoot\<Module>.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\<ModuleServiceName>" +Generate-FileComponents -fileListName "<Module>Files" -wxsFilePath $PSScriptRoot\<Module>.wxs -regroot $registryroot +``` +--- +## 8. Testing and validation + +### UI tests + +- Place under `/modules/<YourModule>/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<ModuleName>.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/style.md b/doc/devdocs/development/style.md similarity index 100% rename from doc/devdocs/style.md rename to doc/devdocs/development/style.md diff --git a/doc/devdocs/development/test-winget-install-locally.md b/doc/devdocs/development/test-winget-install-locally.md new file mode 100644 index 0000000000..a59d32c52d --- /dev/null +++ b/doc/devdocs/development/test-winget-install-locally.md @@ -0,0 +1,33 @@ +## If for any reason, you'd like to test winget install scenario, you can follow this doc: + +### Powertoys winget manifest definition: +[winget repository](https://github.com/microsoft/winget-pkgs/tree/master/manifests/m/Microsoft/PowerToys) + +### How to test a winget installation locally: +1. Get artifacts from release CI pipeline Pipelines - Runs for PowerToys Signed YAML Release Build, or you can build one yourself by execute the + 'tools\build\build-installer.ps1' script + +2. Get the artifact hash, this is required to define winget manifest +```powershell +cd /path/to/your/directory/contains/installer +Get-FileHash -Path ".\<Installer-name>.exe" -Algorithm SHA256 +``` + 3. Host your installer.exe - Attention: staged github release artifacts or artifacts in release pipeline is not OK in this step +You can self-host it or you can upload to a publicly available endpoint +**How to selfhost it** (A extremely simple way): +```powershell +python -m http.server 8000 +``` + +4. Download a version folder from wingetpkgs like: [version 0.92.1](https://github.com/microsoft/winget-pkgs/tree/master/manifests/m/Microsoft/PowerToys/0.92.1) +and you get **a folder contains 3 yml files** +>note: Do not put any files other than these three in this folder + +5. Modify the yml files based on your version and the self hosted artifact link, and modify the sha256 hash for the installer you'd like to use + +6. Start winget install: +```powershell +#execute as admin +winget settings --enable LocalManifestFiles +winget install --manifest "<folder_path_of_manifest_files>" --architecture x64 --scope user +``` \ No newline at end of file diff --git a/doc/devdocs/development/ui-tests.md b/doc/devdocs/development/ui-tests.md new file mode 100644 index 0000000000..941f9dacd4 --- /dev/null +++ b/doc/devdocs/development/ui-tests.md @@ -0,0 +1,148 @@ +# UI tests framework + + A specialized UI test framework for PowerToys that makes it easy to write UI tests for PowerToys modules or settings. Let's start writing UI tests! + +## Before running tests + +- Install Windows Application Driver v1.2.1 from https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1 to the default directory (`C:\Program Files (x86)\Windows Application Driver`) + +- Enable Developer Mode in Windows settings + +## Running tests + +- Exit PowerToys if it's running. + +- Open `PowerToys.slnx` in Visual Studio and build the solution. + +- Run tests in the Test Explorer (`Test > Test Explorer` or `Ctrl+E, T`). + +## Running tests in pipeline + +The PowerToys UI test pipeline provides flexible options for building and testing: + +### Pipeline Options + +- **buildSource**: Select the build type for testing: + - `latestMainOfficialBuild`: Downloads and uses the latest official PowerToys build from main branch + - `buildNow`: Builds PowerToys from current source code and uses it for testing + - `specificBuildId`: Downloads a specific PowerToys build using the build ID specified in `specificBuildId` parameter + + **Default value**: `latestMainOfficialBuild` + +- **specificBuildId**: When `buildSource` is set to `specificBuildId`, specify the exact PowerToys build ID to download and test against. + + **Default value**: `"xxxx"` (placeholder, enter actual build ID when using specificBuildId option) + + **When to use this**: + - Testing against a specific known build for reproducibility + - Regression testing against a particular build version + - Validating fixes in a specific build before release + + **Usage**: Enter the build ID number (e.g., `12345`) to download that specific build. Only used when `buildSource` is set to `specificBuildId`. + +- **uiTestModules**: Specify which UI test modules to build and run. This parameter controls both the `.csproj` projects to build and the `.dll` test assemblies to execute. Examples: + - `['UITests-FancyZones']` - Only FancyZones UI tests + - `['MouseUtils.UITests']` - Only MouseUtils UI tests + - `['UITests-FancyZones', 'MouseUtils.UITests']` - Multiple specific modules + - Leave empty to build and run all UI test modules + + **Important**: The `uiTestModules` parameter values must match both the test project names (for `.csproj` selection during build) and the test assembly names (for `.dll` execution during testing). + +### Build Modes + +1. **Official Build Testing** (`buildSource = latestMainOfficialBuild` or `specificBuildId`) + - Downloads and installs official PowerToys build (latest from main or specific build ID) + - Builds only UI test projects (all or specific based on `uiTestModules`) + - Runs UI tests against installed PowerToys + - Tests both machine-level and per-user installation modes automatically + +2. **Current Source Build Testing** (`buildSource = buildNow`) + - Builds entire PowerToys solution from current source code + - Builds UI test projects (all or specific based on `uiTestModules`) + - Runs UI tests against freshly built PowerToys + - Uses artifacts from current pipeline build + +> **Note**: All modes support the `uiTestModules` parameter to control which specific UI test modules to build and run. Both machine-level and per-user installation modes are tested automatically when using official builds. + +### Pipeline Access +- Pipeline: https://microsoft.visualstudio.com/Dart/_build?definitionId=161438&_a=summary + +## How to add the first UI tests for your modules +- Follow the naming convention: ![{ModuleFolder}/Tests/{ModuleName}-{TestType(Fuzz/UI/Unit)}Tests](images/uitests/naming.png) +- Create a new project and add the following references to the project file. Change the OutputPath to your own module's path. + ``` + <Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <ProjectGuid>{4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}</ProjectGuid> + <RootNamespace>PowerToys.Hosts.UITests</RootNamespace> + <AssemblyName>PowerToys.Hosts.UITests</AssemblyName> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + + <!-- This is a UI test, so don't run as part of MSBuild --> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + <PropertyGroup> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\Hosts.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> + </Project> + + ``` +- Inherit your test class from UITestBase. + >Set Scope: The default scope starts from the PowerToys settings UI. If you want to start from your own module, set the constructor as shown below: + + >Specify Scope: + ``` + [TestClass] + public class HostModuleTests : UITestBase + { + public HostModuleTests() + : base(PowerToysModule.Hosts, WindowSize.Small_Vertical) + { + } + } + ``` + +- Then you can start performing the UI operations. + +**Example** +``` +[TestMethod("Hosts.Basic.EmptyViewShouldWork")] +[TestCategory("Hosts File Editor #4")] +public void TestEmptyView() +{ + this.CloseWarningDialog(); + this.RemoveAllEntries(); + + // 'Add an entry' button (only show-up when list is empty) should be visible + Assert.IsTrue(this.HasOne<HyperlinkButton>("Add an entry"), "'Add an entry' button should be visible in the empty view"); + + VisualAssert.AreEqual(this.TestContext, this.Find("Entries"), "EmptyView"); + + // Click 'Add an entry' from empty-view for adding Host override rule + this.Find<HyperlinkButton>("Add an entry").Click(); + + this.AddEntry("192.168.0.1", "localhost", false, false); + + // Should have one row now and not more empty view + Assert.IsTrue(this.Has<Button>("Delete"), "Should have one row now"); + Assert.IsFalse(this.Has<HyperlinkButton>("Add an entry"), "'Add an entry' button should be invisible if not empty view"); + + VisualAssert.AreEqual(this.TestContext, this.Find("Entries"), "NonEmptyView"); +} +``` + +## Extra tools and information + + **Accessibility Tools**: +While working on tests, you may need a tool that helps you to view the element's accessibility data, e.g. for finding the button to click. For this purpose, you could use [AccessibilityInsights](https://accessibilityinsights.io/docs/windows/overview). diff --git a/doc/devdocs/events.md b/doc/devdocs/events.md new file mode 100644 index 0000000000..1bc2d5bba6 --- /dev/null +++ b/doc/devdocs/events.md @@ -0,0 +1,197 @@ +# Telemetry Events + +PowerToys collects limited telemetry to understand feature usage, reliability, and product quality. When adding a new telemetry event, follow the steps below to ensure the event is properly declared, documented, and available after release. + +**⚠️ Important**: Telemetry must never include personal information, file paths, or user‑generated content. + +## Developer Effort Overview (What to Expect) + +Adding a telemetry event is a **multi-step process** that typically spans several areas of the codebase and documentation. + +At a high level, developers should expect to: + +1. Within one PR: + 1. Add a new telemetry event(s) to module + 1. Add the new event(s) DATA_AND_PRIVACY.md +1. Reach out to @carlos-zamora or @chatasweetie so internal scripts can process new event(s) + +### Privacy Guidelines + +**NEVER** log: + +- User data (text, files, emails, etc.) +- File paths or filenames +- Personal information +- Sensitive system information +- Anything that could identify a specific user + +DO log: + +- Feature usage (which features, how often) +- Success/failure status +- Timing/performance metrics +- Error types (not error messages with user data) +- Aggregate counts + +### Event Naming Convention + +Follow this pattern: `UtilityName_EventDescription` + +Examples: + +- `ColorPicker_Session` +- `FancyZones_LayoutApplied` +- `PowerRename_Rename` +- `AdvancedPaste_FormatClicked` +- `CmdPal_ExtensionInvoked` + +## Adding Telemetry Events to PowerToys + +PowerToys uses ETW (Event Tracing for Windows) for telemetry in both C++ and C# modules. The telemetry system is: + +- Opt-in by default (disabled since v0.86) +- Privacy-focused - never logs personal info, file paths, or user-generated content +- Controlled by registry - HKEY_CURRENT_USER\Software\Classes\PowerToys\AllowDataDiagnostics + +### C++ Telemetry Implementation + +**Core Components** + +| File | Purpose | +| ------------- |:-------------:| +| [ProjectTelemetry.h](../../src/common/Telemetry/ProjectTelemetry.h) | Declares the global ETW provider g_hProvider | +| [TraceBase.h](../../src/common/Telemetry/TraceBase.h) | Base class with RegisterProvider(), UnregisterProvider(), and IsDataDiagnosticsEnabled() check | +| [TraceLoggingDefines.h](../../src/common/Telemetry/TraceLoggingDefines.h) | Privacy tags and telemetry option group macros + + +#### Pattern for C++ Modules + +1. Create a `Trace` class inheriting from `telemetry::TraceBase` (src/common/Telemetry/TraceBase.h): + + ```c + // trace.h + #pragma once + #include <common/Telemetry/TraceBase.h> + + class Trace : public telemetry::TraceBase + { + public: + static void MyEvent(/* parameters */); + }; + ``` + +2. Implement events using `TraceLoggingWriteWrapper`: + + ```cpp + // trace.cpp + #include "trace.h" + #include <common/Telemetry/TraceBase.h> + + TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + + void Trace::MyEvent(bool enabled) + { + TraceLoggingWriteWrapper( + g_hProvider, + "ModuleName_EventName", // Event name + TraceLoggingBoolean(enabled, "Enabled"), // Event data + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); + } + ``` + +**Key C++ Telemetry Macros** + +| Macro | Purpose | +| ------------- |:-------------:| +| `TraceLoggingWriteWrapper` [CustomAction.cpp](../../installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp) | Wraps `TraceLoggingWrite` with `IsDataDiagnosticsEnabled()` check | +| `ProjectTelemetryPrivacyDataTag(tag)` [TraceLoggingDefines.h](../../src/common/Telemetry/TraceLoggingDefines.h) | Sets privacy classification | + +### C# Telemetry Implementation + +**Core Components** + +| File | Purpose | +| ------------- |:-------------:| +| [PowerToysTelemetry.cs](../../src/common/ManagedTelemetry/Telemetry/PowerToysTelemetry.cs) | Singleton `Log` instance with `WriteEvent<T>()` method | +| [EventBase.cs](../../src/common/ManagedTelemetry/Telemetry/Events/EventBase.cs) | Base class for all events (provides `EventName`, `Version`) | +| [IEvent.cs](../../src/common/ManagedTelemetry/Telemetry/Events/IEvent.cs) | Interface requiring `PartA_PrivTags` property | +| [TelemetryBase.cs](../../src/common/Telemetry/TelemetryBase.cs) | Inherits from `EventSource`, defines ETW constants | +| [DataDiagnosticsSettings.cs](../../src/common/ManagedTelemetry/Telemetry/DataDiagnosticsSettings.cs) | Registry-based enable/disable check + +#### Pattern for C# Modules + +1. Create an event class inheriting from `EventBase` and implementing `IEvent`: + + ```csharp + using System.Diagnostics.CodeAnalysis; + using System.Diagnostics.Tracing; + using Microsoft.PowerToys.Telemetry; + using Microsoft.PowerToys.Telemetry.Events; + + namespace MyModule.Telemetry + { + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class MyModuleEvent : EventBase, IEvent + { + // Event properties (logged as telemetry data) + public string SomeProperty { get; set; } + public int SomeValue { get; set; } + + // Required: Privacy tag + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + // Optional: Set EventName in constructor (defaults to class name) + public MyModuleEvent(string prop, int val) + { + EventName = "MyModule_EventName"; + SomeProperty = prop; + SomeValue = val; + } + } + } + ``` + +2. Log the event: + +```csharp +PowerToysTelemetry.Log.WriteEvent(new MyModuleEvent("value", 42)); +``` + +**Privacy Tags (C#)** + +| Tag | Use Case | +| ------------- |:-------------:| +| `PartA_PrivTags.ProductAndServiceUsage` [TelemetryBase.cs](../../src/common/Telemetry/TelemetryBase.cs) | Feature usage events +| `PartA_PrivTags.ProductAndServicePerformance` [TelemetryBase.cs](../../src/common/Telemetry/TelemetryBase.cs) | Performance/timing events + +### Update DATA_AND_PRIVACY.md file + +Add your new event(s) to [DATA_AND_PRIVACY.md](../../DATA_AND_PRIVACY.md). + +## Launch Product Version Containing the new events + +Events do not become active until they ship in a released PowerToys version. After your PRs are merged: + +- The event will begin firing once users install the version that includes it +- In order for PowerToys to process these events, you must complete the next section + +## Next Steps + +Reach out to @carlos-zamora or @chatasweetie so internal scripts can process new event(s). + +## Summary + +Required steps: + +1. In one PR: + - Add the event(s) in code + - Document event(s) in DATA_AND_PRIVACY.md +1. Ship the change in a PowerToys release +1. Reach out for next steps diff --git a/doc/devdocs/guidance.md b/doc/devdocs/guidance.md index c3a5bc0830..c4500f5543 100644 --- a/doc/devdocs/guidance.md +++ b/doc/devdocs/guidance.md @@ -56,10 +56,10 @@ string validUIDisplayString = Resources.ValidUIDisplayString; ``` ## More On Coding Guidance -Please review these brief docs below relating to our coding standards etc. +Please review these brief docs below relating to our coding standards, etc. -* [Coding Style](./style.md) -* [Code Organization](./readme.md) +* [Coding Style](development/style.md) +* [Code Organization](readme.md) [VS Resource Editor]: https://learn.microsoft.com/cpp/windows/resource-editors?view=vs-2019 diff --git a/doc/devdocs/images/fancyzones/1.png b/doc/devdocs/images/fancyzones/1.png new file mode 100644 index 0000000000..6ae44344c5 Binary files /dev/null and b/doc/devdocs/images/fancyzones/1.png differ diff --git a/doc/devdocs/images/fancyzones/10.png b/doc/devdocs/images/fancyzones/10.png new file mode 100644 index 0000000000..41c3e1008b Binary files /dev/null and b/doc/devdocs/images/fancyzones/10.png differ diff --git a/doc/devdocs/images/fancyzones/11.png b/doc/devdocs/images/fancyzones/11.png new file mode 100644 index 0000000000..5f4d83d06b Binary files /dev/null and b/doc/devdocs/images/fancyzones/11.png differ diff --git a/doc/devdocs/images/fancyzones/12.png b/doc/devdocs/images/fancyzones/12.png new file mode 100644 index 0000000000..4a3f7870fd Binary files /dev/null and b/doc/devdocs/images/fancyzones/12.png differ diff --git a/doc/devdocs/images/fancyzones/13.png b/doc/devdocs/images/fancyzones/13.png new file mode 100644 index 0000000000..863f7fe9fc Binary files /dev/null and b/doc/devdocs/images/fancyzones/13.png differ diff --git a/doc/devdocs/images/fancyzones/14.png b/doc/devdocs/images/fancyzones/14.png new file mode 100644 index 0000000000..32b8491a35 Binary files /dev/null and b/doc/devdocs/images/fancyzones/14.png differ diff --git a/doc/devdocs/images/fancyzones/15.png b/doc/devdocs/images/fancyzones/15.png new file mode 100644 index 0000000000..b5d652ebb5 Binary files /dev/null and b/doc/devdocs/images/fancyzones/15.png differ diff --git a/doc/devdocs/images/fancyzones/16.png b/doc/devdocs/images/fancyzones/16.png new file mode 100644 index 0000000000..ef1389e088 Binary files /dev/null and b/doc/devdocs/images/fancyzones/16.png differ diff --git a/doc/devdocs/images/fancyzones/17.png b/doc/devdocs/images/fancyzones/17.png new file mode 100644 index 0000000000..2395a578d7 Binary files /dev/null and b/doc/devdocs/images/fancyzones/17.png differ diff --git a/doc/devdocs/images/fancyzones/18.png b/doc/devdocs/images/fancyzones/18.png new file mode 100644 index 0000000000..12585db5d9 Binary files /dev/null and b/doc/devdocs/images/fancyzones/18.png differ diff --git a/doc/devdocs/images/fancyzones/19.png b/doc/devdocs/images/fancyzones/19.png new file mode 100644 index 0000000000..6e1da3db47 Binary files /dev/null and b/doc/devdocs/images/fancyzones/19.png differ diff --git a/doc/devdocs/images/fancyzones/2.png b/doc/devdocs/images/fancyzones/2.png new file mode 100644 index 0000000000..ceb799557f Binary files /dev/null and b/doc/devdocs/images/fancyzones/2.png differ diff --git a/doc/devdocs/images/fancyzones/20.png b/doc/devdocs/images/fancyzones/20.png new file mode 100644 index 0000000000..015d5b5412 Binary files /dev/null and b/doc/devdocs/images/fancyzones/20.png differ diff --git a/doc/devdocs/images/fancyzones/3.png b/doc/devdocs/images/fancyzones/3.png new file mode 100644 index 0000000000..95d399984b Binary files /dev/null and b/doc/devdocs/images/fancyzones/3.png differ diff --git a/doc/devdocs/images/fancyzones/4.png b/doc/devdocs/images/fancyzones/4.png new file mode 100644 index 0000000000..bc381ddb93 Binary files /dev/null and b/doc/devdocs/images/fancyzones/4.png differ diff --git a/doc/devdocs/images/fancyzones/5.png b/doc/devdocs/images/fancyzones/5.png new file mode 100644 index 0000000000..68e21182c2 Binary files /dev/null and b/doc/devdocs/images/fancyzones/5.png differ diff --git a/doc/devdocs/images/fancyzones/6.png b/doc/devdocs/images/fancyzones/6.png new file mode 100644 index 0000000000..caf1afacda Binary files /dev/null and b/doc/devdocs/images/fancyzones/6.png differ diff --git a/doc/devdocs/images/fancyzones/7.png b/doc/devdocs/images/fancyzones/7.png new file mode 100644 index 0000000000..51d17fefe5 Binary files /dev/null and b/doc/devdocs/images/fancyzones/7.png differ diff --git a/doc/devdocs/images/fancyzones/8.png b/doc/devdocs/images/fancyzones/8.png new file mode 100644 index 0000000000..5573a61da2 Binary files /dev/null and b/doc/devdocs/images/fancyzones/8.png differ diff --git a/doc/devdocs/images/fancyzones/9.png b/doc/devdocs/images/fancyzones/9.png new file mode 100644 index 0000000000..7760dabde9 Binary files /dev/null and b/doc/devdocs/images/fancyzones/9.png differ diff --git a/doc/devdocs/images/fancyzones/editor_common_map.png b/doc/devdocs/images/fancyzones/editor_common_map.png new file mode 100644 index 0000000000..50bb951438 Binary files /dev/null and b/doc/devdocs/images/fancyzones/editor_common_map.png differ diff --git a/doc/devdocs/images/fancyzones/editor_map.png b/doc/devdocs/images/fancyzones/editor_map.png new file mode 100644 index 0000000000..3d1c2cf407 Binary files /dev/null and b/doc/devdocs/images/fancyzones/editor_map.png differ diff --git a/doc/devdocs/images/filelocksmith/debug.png b/doc/devdocs/images/filelocksmith/debug.png new file mode 100644 index 0000000000..727b25dac5 Binary files /dev/null and b/doc/devdocs/images/filelocksmith/debug.png differ diff --git a/doc/devdocs/images/filelocksmith/diagram.png b/doc/devdocs/images/filelocksmith/diagram.png new file mode 100644 index 0000000000..8ebd51c853 Binary files /dev/null and b/doc/devdocs/images/filelocksmith/diagram.png differ diff --git a/doc/devdocs/images/hostsfileeditor/diagram.png b/doc/devdocs/images/hostsfileeditor/diagram.png new file mode 100644 index 0000000000..08c09f7271 Binary files /dev/null and b/doc/devdocs/images/hostsfileeditor/diagram.png differ diff --git a/doc/devdocs/images/newplus/debug.png b/doc/devdocs/images/newplus/debug.png new file mode 100644 index 0000000000..5e4e592a08 Binary files /dev/null and b/doc/devdocs/images/newplus/debug.png differ diff --git a/doc/devdocs/images/newplus/wizard1.png b/doc/devdocs/images/newplus/wizard1.png new file mode 100644 index 0000000000..92d3adfbb3 Binary files /dev/null and b/doc/devdocs/images/newplus/wizard1.png differ diff --git a/doc/devdocs/images/newplus/wizard2.png b/doc/devdocs/images/newplus/wizard2.png new file mode 100644 index 0000000000..e3a860ce76 Binary files /dev/null and b/doc/devdocs/images/newplus/wizard2.png differ diff --git a/doc/devdocs/images/newplus/wizard3.png b/doc/devdocs/images/newplus/wizard3.png new file mode 100644 index 0000000000..a36502d8bb Binary files /dev/null and b/doc/devdocs/images/newplus/wizard3.png differ diff --git a/doc/devdocs/images/newplus/wizard4.png b/doc/devdocs/images/newplus/wizard4.png new file mode 100644 index 0000000000..3747696f64 Binary files /dev/null and b/doc/devdocs/images/newplus/wizard4.png differ diff --git a/doc/devdocs/images/shortcutguide/diagram.png b/doc/devdocs/images/shortcutguide/diagram.png new file mode 100644 index 0000000000..12d7256828 Binary files /dev/null and b/doc/devdocs/images/shortcutguide/diagram.png differ diff --git a/doc/devdocs/images/uitests/naming.png b/doc/devdocs/images/uitests/naming.png new file mode 100644 index 0000000000..6109bac59b Binary files /dev/null and b/doc/devdocs/images/uitests/naming.png differ diff --git a/doc/devdocs/images/zoomit/functions.png b/doc/devdocs/images/zoomit/functions.png new file mode 100644 index 0000000000..923929fc41 Binary files /dev/null and b/doc/devdocs/images/zoomit/functions.png differ diff --git a/doc/devdocs/images/zoomit/interop.png b/doc/devdocs/images/zoomit/interop.png new file mode 100644 index 0000000000..1eaff2a3e2 Binary files /dev/null and b/doc/devdocs/images/zoomit/interop.png differ diff --git a/doc/devdocs/modules/advancedpaste.md b/doc/devdocs/modules/advancedpaste.md new file mode 100644 index 0000000000..b2ab244432 --- /dev/null +++ b/doc/devdocs/modules/advancedpaste.md @@ -0,0 +1,46 @@ +# Advanced Paste + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/advanced-paste) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Advanced%20Paste%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Advanced%20Paste%22%20label%3AIssue-Bug)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen++label%3A%22Product-Advanced+Paste%22) + +## Overview + +Advanced Paste is a PowerToys module that provides enhanced clipboard pasting with formatting options and additional functionality. + +## Implementation Details + +[Source code](/src/modules/AdvancedPaste) + +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 + +| Setting | Description | +|---------|-------------| +| `ShowCustomPreview` | When enabled, shows AI-generated results in a preview window before pasting. Does not affect AI credit consumption. | + +## Future Improvements + +TODO: Add potential future improvements diff --git a/doc/devdocs/modules/alwaysontop.md b/doc/devdocs/modules/alwaysontop.md new file mode 100644 index 0000000000..d8948697f1 --- /dev/null +++ b/doc/devdocs/modules/alwaysontop.md @@ -0,0 +1,98 @@ +# Always on Top + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/always-on-top) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Always%20On%20Top%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20%20label%3A%22Product-Always%20On%20Top%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen++label%3A%22Product-Always+On+Top%22+) + +## Overview + +The Always on Top module allows users to pin windows on top of others, ensuring they remain visible even when switching between applications. The module provides visual indicators (customizable borders) to identify which windows are pinned. + +## Features + +- Pin any window to stay on top of other windows +- Customizable border color, opacity, and thickness around pinned windows +- User-defined keyboard shortcut for toggling window pinning +- Visual indicators to identify pinned windows + +## Architecture + +### Main Components + +- **Hotkey Listener**: Detects the user-defined hotkey to toggle the Always on Top state +- **AlwaysOnTop**: Manages the state of windows, ensuring the selected window stays on top +- **Settings**: Stores user preferences and configurations +- **WindowHook**: Hooks all window events + +### Data Flow + +1. The Hotkey Listener detects the hotkey press and notifies the AlwaysOnTop +2. The AlwaysOnTop updates the window state and interacts with the operating system to keep the window on top +3. User preferences are saved and loaded from the Settings + +## Code Structure + +### Key Files + +- **AlwaysOnTop.cpp**: Contains the core logic for the module, including initialization and event handling +- **Settings.cpp**: Defines the settings structure and provides methods to load and save settings +- **main.cpp**: Starts thread and initializes AlwaysOnTop + +### Initialization + +The module is initialized in the AlwaysOnTop class. During initialization, the following steps are performed: + +1. **LoadSettings**: The module loads user settings from a configuration file +2. **RegisterHotkey**: The HotkeyManager registers the keyboard shortcut for pinning/unpinning windows +3. **SubscribeToEvents**: Event handlers are attached to respond to user actions, such as pressing the hotkey + +### Pinning and Unpinning Windows + +The AlwaysOnTop class handles the pinning and unpinning of windows. Key methods include: + +- **PinTopmostWindow**: Pins the specified window on top of others and applies visual indicators +- **UnpinTopmostWindows**: Removes the pinning status and visual indicators from the specified window +- **AssignBorder**: Applies a colored border around the pinned window based on user settings + +### Settings Management + +The Settings class manages the module's settings. Key methods include: + +- **LoadSettings**: Loads settings from a configuration file +- **NotifyObservers**: Distributes the data for the settings +- **GetDefaultSettings**: Returns the default settings for the module + +## User Interface + +The module provides a user interface for configuring settings in the PowerToys Settings UI. This interface is implemented using XAML and includes options for customizing the: + +- Border color +- Border opacity +- Border thickness +- Keyboard shortcut + +## Development Environment Setup + +### Prerequisites + +- Visual Studio 2019 or later +- Windows 10 SDK +- PowerToys repository cloned from GitHub + +### Building and Testing + +1. Clone the repository: `git clone https://github.com/microsoft/PowerToys.git` +2. Open PowerToys.slnx in Visual Studio +3. Select the Release configuration and build the solution +4. Run PowerToys.exe from the output directory to test the module + +### Debug +1. build the entire project +2. launch the built Powertoys +3. select AlwaysOnTop as the startup project in VS +4. In the debug button, choose "Attach to process". ![image](https://github.com/user-attachments/assets/a7624ec2-63f1-4720-9540-a916b0ada282) +5. Attach to AlwaysOnTop.![image](https://github.com/user-attachments/assets/815c0f89-8fd1-48d6-b7fd-0e4a92e222d0) diff --git a/doc/devdocs/modules/awake.md b/doc/devdocs/modules/awake.md new file mode 100644 index 0000000000..fd22b33ec8 --- /dev/null +++ b/doc/devdocs/modules/awake.md @@ -0,0 +1,44 @@ +# Awake + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/awake) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-Awake)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20%20label%3AProduct-Awake)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen++label%3A%22Product-Awake%22+) + +## Overview +Awake is a PowerToys utility designed to keep your computer awake without permanently modifying system power settings. It prevents the computer from sleeping and can keep the monitor on, providing a convenient alternative to changing system power configurations. + +## Key Features +- Temporarily override system sleep settings +- Keep monitor on (prevent display from turning off) +- Set time intervals for keeping the system awake +- One-time setup with no need to revert power settings afterward + +## Advantages Over System Power Settings +- **Convenience**: Easy UI for quick toggling of sleep prevention +- **Flexibility**: Support for different time intervals (indefinitely, for specific duration) +- **Non-persistent**: Changes are temporary and don't require manual reversion +- **Quick Access**: Available directly from the system tray + +## Architecture + +### Components +- **System Tray UI**: Provides user interface for controlling Awake settings +- **Backend Threads**: Manages the power state prevention functionality +- **Command Line Interface**: Supports various commands for controlling Awake functionality programmatically + +## Technical Implementation +Awake works by preventing system sleep through Windows power management APIs. The module runs as a background process that interfaces with the Windows power management system to keep the device awake according to user preferences. + +## User Experience +Users can access Awake through the PowerToys system tray icon. From there, they can: +1. Toggle Awake on/off +2. Set a specific duration for keeping the system awake +3. Choose whether to keep the display on or allow it to turn off +4. Access additional configuration options + +## Command Line Support +Awake includes command-line functionality for power users and automation scenarios, allowing programmatic control of the utility's features. diff --git a/doc/devdocs/modules/colorpicker.md b/doc/devdocs/modules/colorpicker.md new file mode 100644 index 0000000000..39d8c48b90 --- /dev/null +++ b/doc/devdocs/modules/colorpicker.md @@ -0,0 +1,49 @@ +# Color Picker + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/color-picker) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Color%20Picker%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Color%20Picker%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen++label%3A%22Product-Color+Picker%22) + +## Overview +Color Picker is a system-wide color picking utility for Windows that allows users to pick colors from any screen and copy them to the clipboard in a configurable format. + +## Implementation Details + +### Color Capturing Mechanism +The Color Picker works by following these steps to capture the color at the current mouse position: + +1. Obtain the position of the mouse +2. Create a 1x1 size rectangle at that position +3. Create a Bitmap class and use it to initiate a Graphics object +4. Create an image associated with the Graphics object by leveraging the CopyFromScreen function, which captures the pixel information from the specified location + +### Core Color Picking Function +The following code snippet demonstrates the core functionality of how a color is picked from the screen: + +```csharp +private static Color GetPixelColor(System.Windows.Point mousePosition) +{ + var rect = new Rectangle((int)mousePosition.X, (int)mousePosition.Y, 1, 1); + using (var bmp = new Bitmap(rect.Width, rect.Height, PixelFormat.Format32bppArgb)) + { + var g = Graphics.FromImage(bmp); + g.CopyFromScreen(rect.Left, rect.Top, 0, 0, bmp.Size, CopyPixelOperation.SourceCopy); + + return bmp.GetPixel(0, 0); + } +} +``` + +## Features +- Pick colors from any pixel on the screen +- View color information in various formats (RGB, HEX, HSL, etc.) +- Copy color values to clipboard in configurable formats +- Color history for quick access to previously selected colors +- Keyboard shortcuts for quick activation and operation + +## User Experience +When activated, Color Picker displays a magnified view of the area around the cursor to allow for precise color selection. Once a color is selected, it can be copied to the clipboard in the user's preferred format for use in design tools, development environments, or other applications. diff --git a/doc/devdocs/modules/commandnotfound.md b/doc/devdocs/modules/commandnotfound.md new file mode 100644 index 0000000000..e938c711d3 --- /dev/null +++ b/doc/devdocs/modules/commandnotfound.md @@ -0,0 +1,43 @@ +# Command Not Found + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/cmd-not-found) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-CommandNotFound)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3AProduct-CommandNotFound)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-CommandNotFound) + +## Overview +Command Not Found is a PowerToys module that suggests package installations when you attempt to run a command that isn't available on your system. It integrates with the Windows command line to provide helpful suggestions for installing missing commands through package managers. + +## How it Works +When you attempt to execute a command in the terminal that isn't found, the Command Not Found module intercepts this error and checks if the command is available in known package repositories. If a match is found, it suggests the appropriate installation command. + +## Installation +The Command Not Found module requires the Microsoft.WinGet.CommandNotFound PowerShell module to function properly. When enabling the module through PowerToys, it automatically attempts to install this dependency. + +The installation is handled by the following script: +```powershell +# Located in PowerToys\src\settings-ui\Settings.UI\Assets\Settings\Scripts\EnableModule.ps1 +Install-Module -Name Microsoft.WinGet.CommandNotFound -Force +``` + +## Usage +1. Enable the Command Not Found module in PowerToys settings. +2. Open a terminal and try to run a command that isn't installed on your system. +3. If the command is available in a package, you'll see a suggestion for how to install it. + +Example: +``` +C:\> kubectl +'kubectl' is not recognized as an internal or external command, operable program, or batch file. + +Command 'kubectl' not found, but can be installed with: + winget install -e --id Kubernetes.kubectl +``` + +## Technical Details +The Command Not Found module leverages the Microsoft.WinGet.CommandNotFound PowerShell module, which is maintained in a separate repository: https://github.com/microsoft/winget-command-not-found + +The module works by registering a command-not-found handler that intercepts command execution failures and provides installation suggestions based on available packages in the WinGet repository. \ No newline at end of file diff --git a/doc/devdocs/modules/cropandlock.md b/doc/devdocs/modules/cropandlock.md new file mode 100644 index 0000000000..db5e9402cf --- /dev/null +++ b/doc/devdocs/modules/cropandlock.md @@ -0,0 +1,49 @@ +# Crop and Lock + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/crop-and-lock) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-CropAndLock)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3AProduct-CropAndLock)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-CropAndLock) + +## Overview + +The Crop and Lock module in PowerToys allows users to crop a current application into a smaller window or create a thumbnail. This utility enhances productivity by enabling users to focus on specific parts of an application window. + +## Features + +### Thumbnail Mode +Creates a window showing the selected area of the original window. Changes in the original window are reflected in the thumbnail. + +### Reparent Mode +Creates a window that replaces the original window, showing only the selected area. The application is controlled through the cropped window. + +### Screenshot Mode +Creates a window showing a freezed snapshot of the original window. + +## Code Structure + +### Project Layout +The Crop and Lock module is part of the PowerToys solution. All the logic-related settings are in the main.cpp. The main implementations are in ThumbnailCropAndLockWindow and ReparentCropAndLockWindow. ChildWindow and OverlayWindow distinguish the two different modes of windows implementations. + +### Key Files +- **ThumbnailCropAndLockWindow.cpp**: Defines the UI for the thumbnail mode. +- **OverlayWindow.cpp**: Thumbnail module type's window concrete implementation. +- **ReparentCropAndLockWindow.cpp**: Defines the UI for the reparent mode. +- **ChildWindow.cpp**: Reparent module type's window concrete implementation. +- **ScreenshotCropAndLockWindow.cpp**: Defines the UI for the screenshot mode. + +## Known Issues + +- Cropping maximized or full-screen windows in "Reparent" mode might not work properly. +- Some UWP apps may not respond well to being cropped in "Reparent" mode. +- Applications with sub-windows or tabs can have compatibility issues in "Reparent" mode. + +## Debug +1. build the entire project +2. launch the built Powertoys +3. select CropAndLock as the startup project in VS +4. In the debug button, choose "Attach to process". ![image](https://github.com/user-attachments/assets/a7624ec2-63f1-4720-9540-a916b0ada282) +5. Attach to CropAndLock.![image](https://github.com/user-attachments/assets/08aa0465-596c-4494-9daa-e96b234f9997) diff --git a/doc/devdocs/modules/environmentvariables.md b/doc/devdocs/modules/environmentvariables.md new file mode 100644 index 0000000000..8ba0c1e367 --- /dev/null +++ b/doc/devdocs/modules/environmentvariables.md @@ -0,0 +1,71 @@ +# Environment Variables + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/environment-variables) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Environment%20Variables%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Environment%20Variables%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Environment+Variables%22) +[Checklist](https://github.com/microsoft/PowerToys/blob/releaseChecklist/doc/releases/tests-checklist-template.md?plain=1#L744) + +## Overview + +Environment Variables is a PowerToys module that provides an easy and convenient way to manage Windows environment variables. It offers a modern user interface for viewing, editing, and managing both user and system environment variables. + +## Features + +- View and edit user and system environment variables in a unified interface +- Create profiles to group and manage sets of variables together +- Profile-based variable management with on/off toggles +- Automatic backup of existing variables when overridden by a profile +- Restoration of original values when profiles are disabled + +## How It Works + +### Profiles + +Profiles are collections of environment variables that can be enabled or disabled together. When a profile is enabled: + +1. Variables in the profile override existing User variables with the same name +2. Original values are automatically backed up for restoration when the profile is disabled +3. Only one profile can be active at a time + +### Variable Precedence + +The module follows this precedence order for environment variables: +1. Active profile variables (highest precedence) +2. User variables +3. System variables (lowest precedence) + +## Architecture + +The Environment Variables module is structured into three main components: + +### Project Structure + +``` +EnvironmentVariables/ # Contains assets, main windows, and telemetry +EnvironmentVariablesModuleInterface # Interface definitions and package configurations +EnvironmentVariableUILib # Abstracted UI methods and implementations +``` + +### Key Components + +- **Main Window Framework**: Builds the modern Windows desktop UI, handles Windows messages, resource loading, and window closing operations +- **Project Configuration**: Defines settings and configurations for the module +- **UI Implementation**: Contains the user interface components and the backend logic + +## Implementation Details + +### Key Functions + +- **OpenEnvironmentKeyIfExists**: Accesses environment information through registry keys +- **SetEnvironmentVariableFromRegistryWithoutNotify**: Sets variables directly to registry instead of using Environment API, avoiding the 1-second timeout for settings change notifications +- **GetVariables**: Reads variables directly from registry instead of using Environment API to prevent automatic variable expansion + +### Technical Notes + +- The module reads and writes variables directly to the registry instead of using the Environment API +- This direct registry access approach is used because the Environment API automatically expands variables and has a timeout for notifications +- When a profile variable has the same name as an existing User variable, a backup is created with the naming pattern: `VARIABLE_NAME_powertoys_PROFILE_NAME` diff --git a/doc/devdocs/modules/fancyzones-tools.md b/doc/devdocs/modules/fancyzones-tools.md new file mode 100644 index 0000000000..29ea00d9eb --- /dev/null +++ b/doc/devdocs/modules/fancyzones-tools.md @@ -0,0 +1,131 @@ +# FancyZones Debugging Tools + +## Overview + +FancyZones has several specialized debugging tools to help diagnose issues with window management, zone detection, and rendering. These tools are designed to isolate and test specific components of the FancyZones functionality. + +## Tools Summary + +| Tool | Purpose | Key Functionality | +|------|---------|-------------------| +| FancyZones_HitTest | Tests zone hit detection | Shows which zone is under cursor with detailed metrics | +| FancyZones_DrawLayoutTest | Tests layout drawing | Renders zone layouts to debug display issues | +| FancyZones_zonable_tester | Tests window zonability | Determines if windows can be placed in zones | +| StylesReportTool | Analyzes window properties | Generates window style reports for debugging | + +## FancyZones_HitTest + +![Image of the FancyZones hit test tool](/doc/images/tools/fancyzones-hit-test.png) + +### Purpose +Tests the FancyZones layout selection logic by displaying a window with zones and highlighting the zone under the mouse cursor. + +### Functionality +- Displays a window with 5 sample zones +- Highlights the zone under the mouse cursor +- Shows metrics used for zone detection in a sidebar +- Helps diagnose issues with zone positioning and hit testing + +### Usage +- Run the tool and move your mouse over the zones +- The currently detected zone will be highlighted +- The sidebar displays metrics used for determining the active zone +- Useful for debugging hit detection, positioning, and DPI issues + +## FancyZones_DrawLayoutTest + +### Purpose +Debug issues related to the drawing of zone layouts on screen. + +### Functionality +- Simulates zone layouts (currently only column layout supported) +- Tests rendering of zones with different configurations +- Helps diagnose display issues across monitor configurations + +### Usage +- Run the tool +- Press **W** key to toggle zone appearance on the primary screen +- Press **Q** key to exit the application +- The number of zones can be modified in the source code + +### Technical Notes +The application is DPI unaware, meaning it doesn't scale for DPI changes and always assumes a scale factor of 100% (96 DPI). Scaling is automatically performed by the system. + +## FancyZones_zonable_tester + +![Image of the FancyZones zonable tester](/doc/images/tools/fancyzones-zonable-tester.png) + +### Purpose +Tests if the window under the mouse cursor is "zonable" (can be placed in a FancyZones zone). + +### Functionality +- Analyzes the window under the cursor +- Provides detailed window information: + * HWND (window handle) + * Process ID + * HWND of foreground window + * Window style flags + * Extended style flags + * Window class + * Process path + +### Usage +- Run the command-line application +- Hover the mouse over a window to test +- Review the console output for detailed window information +- Check if the window is considered zonable by FancyZones + +### Limitations +Note that this tool may not be fully up-to-date with the latest zonable logic in the main FancyZones codebase. + +## StylesReportTool + +### Purpose +Generates detailed reports about window styles that affect zonability. + +### Functionality +- Creates comprehensive window style reports +- Focuses on style flags that determine if windows can be placed in zones +- Outputs report to "WindowStyles.txt" on the desktop + +### Usage +- Run the tool +- Focus the window you want to analyze +- Press **Ctrl+Alt+S** to generate a report +- Review WindowStyles.txt to understand why a window might not be zonable + +## Debugging Workflow + +For most effective debugging of FancyZones issues: + +1. Use **StylesReportTool** to analyze window properties of problematic windows +2. Use **FancyZones_zonable_tester** to check if specific windows can be zoned +3. Use **FancyZones_draw** for layout rendering issues on different monitors +4. Use **FancyZones_HitTest** for diagnosing zone detection problems + +## Testing Considerations + +When testing FancyZones with these tools, consider: + +- Testing on different Windows versions +- Testing with multiple monitors with different: + * Resolutions + * Scaling settings + * Physical arrangements +- Testing with various window types: + * Standard applications + * Legacy applications + * UWP/WinUI applications + * Administrative windows + * Special windows (like Task Manager) +- Testing various layouts: + * Grid layouts + * Custom layouts + * Overlapping zones + +## Initial Setup Issues + +If encountering JSON token errors on first run: +1. Launch FancyZones Editor through PowerToys Settings UI +2. This initializes required configuration files +3. Direct project execution won't initialize configs properly diff --git a/doc/devdocs/modules/fancyzones.md b/doc/devdocs/modules/fancyzones.md index 61a7ea58fa..b1bdf4b3d0 100644 --- a/doc/devdocs/modules/fancyzones.md +++ b/doc/devdocs/modules/fancyzones.md @@ -1,26 +1,489 @@ -# FancyZones UI tests +# FancyZones + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/fancyzones) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-FancyZones)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3AProduct-FancyZones)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-FancyZones) + +## Overview + +FancyZones is a window manager utility that allows users to create custom layouts for organizing windows on their screen. + +## Architecture Overview + +FancyZones consists of several interconnected components: + +### Directory Structure +- **src**: Contains the source code for FancyZones. + - **Editor**: Code for the zone editor. + - **Runner**: Code for the zone management and window snapping. + - **Settings**: Code for managing user settings. +- **tests**: Contains unit and integration tests for FancyZones and UI test code. + +### Project Structure +FancyZones is divided into several projects: + +- **FancyZones**: Used for thread starting and module initialization. +- **FancyZonesLib**: Contains the main backend logic, called by FancyZones (via COM). + - **FancyZonesData** folder: Contains classes and utilities for managing FancyZones data. +- **FancyZonesEditor**: Main UI implementation for creating and editing layouts. +- **FancyZonesEditorCommon**: Stores editor's data and provides shared functionality. +- **FancyZonesModuleInterface**: Interface layer between FancyZones and the PowerToys Runner. + +### Interface Layer: FancyZonesModuleInterface +- Exposes interface between FancyZones and the Runner +- Handles communication and configuration exchange +- Contains minimal code, most logic implemented in other modules + +### UI Layer: FancyZonesEditor and FancyZonesEditorCommon +- **FancyZonesEditor**: Main UI implementation with MainWindow.xaml as entry point +- **FancyZonesEditorCommon**: Provides data structures and I/O helpers for the Editor +- Acts as a visual config editor for layout configuration + +![Editor Code Map](../images/fancyzones/editor_map.png) +![Editor Common Code Map](../images/fancyzones/editor_common_map.png) + +### Backend Implementation: FancyZones and FancyZonesLib +- **FancyZonesLib**: Core logic implementation + - All drag-and-drop behavior + - Layout UI during dragging (generated in C++ via WorkArea.cpp, NewZonesOverlayWindow function) + - Core data structures +- **FancyZones**: Wrapper around FancyZonesLib + +### Data Flow +- User interactions with the Editor are saved in the Settings +- The Runner reads the Settings to apply the zones and manage window positions +- Editor sends update events, which trigger FancyZones to refresh memory data + +## Key Files + +### FancyZones and FancyZonesLib Projects + +- **FancyZonesApp.h/cpp**: + - **FancyZonesApp Class**: Initializes and manages the FancyZones application. + - **Constructor**: Initializes DPI awareness, sets up event hooks, creates the FancyZones instance. + - **Destructor**: Cleans up resources, destroys the FancyZones instance, unhooks event hooks. + - **Run Method**: Starts the FancyZones application. + - **InitHooks Method**: Sets up Windows event hooks to monitor system events. + - **DisableModule Method**: Posts a quit message to the main thread. + - **HandleWinHookEvent/HandleKeyboardHookEvent Methods**: Handle Windows event hooks. + +- **Data Management Files**: + - **AppliedLayouts.h/cpp**: Manages applied layouts for different monitors and virtual desktops. + - **AppZoneHistory.h/cpp**: Tracks history of app zones. + - **CustomLayouts.h/cpp**: Handles user-created layouts. + - **DefaultLayouts.h/cpp**: Manages default layouts for different monitor configurations. + - **LayoutHotkeys.h/cpp**: Manages hotkeys for switching layouts. + - **LayoutTemplates.h/cpp**: Handles layout templates. + +- **Core Functionality**: + - **FancyZonesDataTypes.h**: Defines data types used throughout FancyZones. + - **FancyZonesWindowProcessing.h/cpp**: Processes window events like moving and resizing. + - **FancyZonesWindowProperties.h/cpp**: Manages window properties like assigned zones. + - **JsonHelpers.h/cpp**: Utilities for JSON serialization/deserialization. + - **Layout.h/cpp**: Defines the Layout class for zone layout management. + - **LayoutConfigurator.h/cpp**: Configures different layout types (grid, rows, columns). + - **Settings.h/cpp**: Manages FancyZones module settings. + +### FancyZonesEditor and FancyZonesEditorCommon Projects + +- **UI Components**: + - **MainWindow.xaml/cs**: Main window of the FancyZones Editor. + - **EditorOverlay.xaml/cs**: Overlay window for editing zones. + - **EditorSettings.xaml/cs**: Settings window for the FancyZones Editor. + - **LayoutPreview.xaml/cs**: Provides layout preview. + - **ZoneSettings.xaml/cs**: Manages individual zone settings. + +- **Data Components**: + - **EditorParameters.cs**: Parameters used by the FancyZones Editor. + - **LayoutData.cs**: Manages data for individual layouts. + - **LayoutHotkeys.cs**: Manages hotkeys for switching layouts. + - **LayoutTemplates.cs**: Manages layout templates. + - **Zone.cs**: Represents an individual zone. + - **ZoneSet.cs**: Manages sets of zones within a layout. + +## Configuration Management + +### Configuration Files Location +- Path: `C:\Users\[username]\AppData\Local\Microsoft\PowerToys\FancyZones` +- Files: + - EditorParameters + - AppliedLayouts + - CustomLayouts + - DefaultLayouts + - LayoutHotkeys + - LayoutTemplates + - AppZoneHistory + +### Configuration Handling +- No central configuration handler +- Editor: Read/write handlers in FancyZonesEditorCommon project +- FancyZones: Read/write handlers in FancyZonesLib project +- Data synchronization: Editor sends update events, FancyZones refreshes memory data + +## Window Management + +### Monitor Detection and DPI Scaling +- Monitor detection handled in `FancyZones::MoveSizeUpdate` function +- DPI scaling: FancyZones retrieves window position without needing mouse DPI scaling info +- Window scaling uses system interface via `WindowMouseSnap::MoveSizeEnd()` function + +### Zone Tracking +- Window-to-zone tracking implemented in `FancyZones::MoveSizeUpdate` function +- Maintains history of which windows belong to which zones + +## Development History + +- FancyZones was originally developed as a proof of concept +- Many configuration options were added based on community feedback after initial development +- Some options were added to address specific issues: + - Options for child windows or pop-up windows + - Some options were removed later + - Community feedback led to more interactions being implemented + +## Admin Mode Considerations + +- FancyZones can't move admin windows unless running as admin +- By default, all utilities run as admin if PowerToys is running as admin + +## Development Environment Setup + +### Prerequisites +- Visual Studio 2026 (or 2022 17.4+): Required for building and debugging +- Windows 10 SDK: Ensure the latest version is installed +- PowerToys Repository: Clone from GitHub + +### Setup Steps +1. Clone the Repository: + ``` + git clone https://github.com/microsoft/PowerToys.git + ``` +2. Open `PowerToys.slnx` in Visual Studio +3. Select the Release configuration and build the solution +4. If you encounter build errors, try deleting the x64 output folder and rebuild + +## Getting Started with FancyZones Development + +### Step 1: Familiarize with the Feature +- Use the feature to understand its functionality +- Read the official documentation: [PowerToys FancyZones utility for Windows](https://learn.microsoft.com/en-us/windows/powertoys/fancyzones) + +### Step 2: Build and Debug +- Ensure you can successfully compile and debug the module +- First-time setup may require running the Editor through PowerToys Settings UI to initialize configuration files + +### Step 3: Learn through Bug Fixes +- Examine existing bugs and feature requests to understand code structure +- Use debugging to trace code execution for specific features +- Examine UI test code to understand how features are tested + +## Debugging + +### Setup for Debugging +1. In Visual Studio, set FancyZonesEditor as the startup project +2. Set breakpoints in the code where needed +3. Click Run to start debugging + +### During Active Development +- You can perform breakpoint debugging to troubleshoot issues +- Attach to running processes if needed to debug the module in context + +### Common Debugging Issues +- If encountering JSON errors on first run, launch the FancyZones Editor once through PowerToys Settings UI to initialize required configuration files +- For UI-related issues, use tools like AccessibilityInsights to inspect element properties + +## Deployment and Release Process + +### Deployment + +#### Local Testing +1. Build the solution in Visual Studio +2. Run PowerToys.exe from the output directory + +#### Packaging +- Use the MSIX packaging tool to create an installer +- Ensure all dependencies are included + +### Release + +#### Versioning +- Follow semantic versioning for releases + +#### Release Notes +- Document all changes, fixes, and new features + +#### Publishing +1. Create a new release on GitHub +2. Upload the installer and release notes + +## Troubleshooting + +### First Run JSON Error +**Error**: "The input does not contain any JSON tokens. Expected the input to start with a valid JSON token, when isFinalBlock is true. Path: $ | LineNumber: 0 | BytePositionInLine: 0." + +**Solution**: Launch the FancyZones Editor once through PowerToys Settings UI. Running the Editor directly within the project will not initialize the required configuration files. + +### Known Issues +- Potential undiscovered bugs related to data updates in the Editor +- Some automated tests pass in CI but fail on specific machines +- Complex testing requirements across different monitor configurations + +## FancyZones UI Testing UI tests are implemented using [Windows Application Driver](https://github.com/microsoft/WinAppDriver). -## Before running tests +### Before running tests - Install Windows Application Driver v1.2.1 from https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1. - Enable Developer Mode in Windows settings -## Running tests +### Running tests - Exit PowerToys if it's running - Run WinAppDriver.exe from the installation directory. Skip this step if installed in the default directory (`C:\Program Files (x86)\Windows Application Driver`); in this case, it'll be launched automatically during tests. - - Open `PowerToys.sln` in Visual Studio and build the solution. + - Open `PowerToys.slnx` in Visual Studio and build the solution. - Run tests in the Test Explorer (`Test > Test Explorer` or `Ctrl+E, T`). >Note: notifications or other application windows, that are shown above the window under test, can disrupt the testing process. +### UI Test Automation -## Extra tools and information +FancyZones is currently undergoing a UI Test migration process to improve automated testing coverage. You can track the progress of this migration at: + +[FancyZones UI Test Migration Progress](https://github.com/microsoft/PowerToys/blob/feature/UITestAutomation/src/modules/fancyzones/UITests-FancyZonesEditor/release-test-checklist.md) + +### Testing Strategy + +#### Unit Tests +- Build the unit test project +- Run using the Visual Studio Test Explorer (`Test > Test Explorer` or `Ctrl+E, T`) + +#### Integration Tests +- Ensure the entire FancyZones module works as expected +- Test different window layouts and snapping behaviors + +### Test Framework Structure + +#### UI Test Requirements +All test cases require pre-configured user data and must reset this data before each test. + +**Required User Data Files**: +- EditorParameters +- AppliedLayouts +- CustomLayouts +- DefaultLayouts +- LayoutHotkeys +- LayoutTemplates +- AppZoneHistory + +#### Editor Test Suite + +**ApplyLayoutTest.cs** +- Verifies layout application and selection per monitor +- Tests file updates and behavior under display switching +- Validates virtual desktop changes + +**CopyLayoutTests.cs** +- Tests copying various layout types +- Validates UI and file correctness + +**CreateLayoutTests.cs** +- Tests layout creation and cancellation +- Focuses on file correctness validation + +**CustomLayoutsTests.cs** +- Tests user-created layout operations +- Covers renaming, highlight line changes, zone count changes + +**DefaultLayoutsTest.cs** +- Validates default and user layout files + +**DeleteLayoutTests.cs** +- Tests layout deletion across types +- Checks both UI and file updates + +**EditLayoutTests.cs** +- Tests zone operations: add/delete/move/reset/split/merge + +**FirstLaunchTest.cs** +- Verifies Editor launches correctly on first run + +**LayoutHotkeysTests.cs** +- Tests hotkey configuration file correctness +- Note: Actual hotkey behavior tested in FancyZones backend + +**TemplateLayoutsTests.cs** +- Tests operations on built-in layouts +- Covers renaming, highlight changes, zone count changes + +#### FancyZones Backend Tests + +**LayoutApplyHotKeyTests.cs** +- Focuses on hotkey-related functionality +- Tests actual hotkey behavior implementation + +### UI Testing Tools + +While working on tests, you may need tools to view element accessibility data: +- [AccessibilityInsights](https://accessibilityinsights.io/docs/windows/overview) +- [WinAppDriver UI Recorder](https://github.com/microsoft/WinAppDriver/wiki/WinAppDriver-UI-Recorder) + +>Note: Close helper tools while running tests. Overlapping windows can affect test results. +2. FancyZones might have implemented certain screen resolution limits in the code that do not support such wide screens +3. User error — it can be seen that no layout has been applied to the screen, so it's normal that the far right is not displayed, as the user hasn't used the FancyZones feature +4. From the image, it appears the user is trying to maximize a game window, but some games may not support rendering windows at such high resolutions due to internal implementation + +The **optimal solution** for this bug is to first comment on the user's usage issue. Let them correctly use the FancyZones feature before making further judgments. If the issue persists after proper usage, then investigate whether it's a code issue or a problem with the game itself. + +To demonstrate a debugging example, I will assume it's a code issue, specifically an issue with the Editor. Please see the following debug process. + + + +Let's first locate the corresponding code. Since the error is in the Editor, we'll start by checking the FancyZonesEditor shown in the image. + +![Debug Step Image](../images/fancyzones/1.png) + +However, I currently don't know where the code for this specific UI element in the Editor is located. +![Debug Step Image](../images/fancyzones/2.png) + +We now have two approaches to find the exact code location. + +**First approach:** + +The main XAML page is usually named `App.xaml` or `MainWindow.xaml`. Let's start by locating these two files in the FancyZones Editor. Upon reviewing their contents, we find that `App.xaml` is primarily a wrapper file and doesn't contain much UI code. Therefore, it's highly likely that the UI code is located in `MainWindow.xaml`. In the preview of `MainWindow.xaml`, we can also see a rough outline of the UI elements.![Debug Step Image](../images/fancyzones/3.png) + +By searching for "monitor", we found that only lines 82 and 338 contain the string "monitor".![Debug Step Image](../images/fancyzones/4.png) + +Then, upon reviewing the code, we found that the line at 82 is part of a template. The UI element we're looking for is located within the code block around line 338. + +**Second approach:** + +We can use the **AccessibilityInsights** tool to inspect the specific information of the corresponding UI element. +![Debug Step Image](../images/fancyzones/5.png) + +However, the current UI element does not have an AutomationId. Let's check whether its parent or child nodes have an AutomationId value. (In fact, using ClassName could also help locate it, but elements with the same ClassName might be numerous, making AutomationId a more accurate option.) +![Debug Step Image](../images/fancyzones/6.png) +We found that the parent node "List View" has an AutomationId value. Copy this value and search for it in the code. +![Debug Step Image](../images/fancyzones/7.png) + +**Accurately located at line 338.** + +Now that we've found the code for the UI element, let's look at where the size data for this UI element comes from. First, the text of this `Text` element is bound within the `MonitorItemTemplate`. The name of this `Text` element is `ResolutionText`, and it binds to a data property named `Dimensions`. + +![Debug Step Image](../images/fancyzones/8.png) + +Search for code related to `Dimensions` across all projects in FancyZones. + +![Debug Step Image](../images/fancyzones/9.png) + +We found that this string corresponds to a variable. However, the return value differs in Debug mode, so let's first examine the logic in Release mode. + +We found that the variable `ScreenBoundsWidth` is located in the constructor of `MonitorInfoModel`. + +![Debug Step Image](../images/fancyzones/10.png) + +Then, by searching for `MonitorInfoModel`, we found that this class is instantiated in the constructor of the `MonitorViewModel` class. + +![Debug Step Image](../images/fancyzones/11.png) + +The width and height of the monitor, which are crucial, are also assigned at this point. Let's continue by checking where the data in `App.Overlay.Monitors` is initialized. + +My idea is to examine all references to the `Monitors` variable and identify the initialization point based on those references. + +![Debug Step Image](../images/fancyzones/12.png) + +Finally, by tracing the `Add` function of `Monitors`, we found the `AddMonitor()` method. This method is only called by `ParseParams()`, which confirms that the data originates from there. + However, by examining the context around the `AddMonitor()` function, we can see that the data comes from the `editor-parameters.json` file. Next, we will continue to investigate how this file is initialized and modified. + +![Debug Step Image](../images/fancyzones/13.png) + +By searching, we found that the `editor-parameters.json` file has write functions in both the Editor and FancyZones projects. + +![Debug Step Image](../images/fancyzones/14.png) + +**The display information is retrieved through the following call stack:** + `UpdateWorkAreas()` → `IdentifyMonitors()` → `GetDisplays()` → `EnumDisplayDevicesW()`. + +**How was the `UpdateWorkAreas()` function identified?** + It was discovered by searching for `EditorParameters` and noticing that when the `save` function is called on `EditorParameters`, the parameter passed is `m_workAreaConfiguration`. + +![Debug Step Image](../images/fancyzones/15.png) + +**Then, by checking the initialization location of the `m_workAreaConfiguration` variable, we found that it is initialized inside `UpdateWorkAreas`.** + With this, we have successfully identified the source of the monitor resolution data displayed in the Editor's `Monitors` section. + + + +### Step Four: + +Familiarize yourself with the module code through the current tasks at hand. + +Bug:[Issues · microsoftPowerToys](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue%20state%3Aopen%20type%3ABug%20label%3AProduct-FancyZones) + +UITest Code: + +[Task 57329836: PowerToys UI Test FancyZone UI Test Override Windows Snap-1 - Boards](https://microsoft.visualstudio.com/OS/_workitems/edit/57329836/) + +[Task 57329843: PowerToys UI Test FancyZone UI Test Override Windows Snap-2 - Boards](https://microsoft.visualstudio.com/OS/_workitems/edit/57329843/) + +[Task 57329845: PowerToys UI Test FancyZone UI Test Override Windows Snap-3 - Boards](https://microsoft.visualstudio.com/OS/_workitems/edit/57329845/) + +[Task 56940387: PowerToys UI Test FancyZone UI Test Override Windows Snap-4 - Boards](https://microsoft.visualstudio.com/OS/_workitems/edit/56940387/) + +UI Test Check List: + +PowerToys/doc/releases/tests-checklist-template.md at releaseChecklist · microsoft/PowerToys](https://github.com/microsoft/PowerToys/blob/releaseChecklist/doc/releases/tests-checklist-template.md) + + + +## Q&A + +- ### First Run FancyZones error +![Debug Step Image](../images/fancyzones/16.png) + +If you encounter this situation, you need to launch the FancyZones Editor once in the powertoys settings UI: + +![Debug Step Image](../images/fancyzones/17.png) + +The reason is that running the Editor directly within the project will not initialize various configuration files. + +- ### How are layouts stored and loaded? Is there a central configuration handler? + +There is no central configuration handler. + +Editor read/write config data handler is in FancyZonesEditorCommon project. + +![Debug Step Image](../images/fancyzones/18.png) + +FancyZones cpp project read/write config data handler is in FancyZonesLib project. + +![Debug Step Image](../images/fancyzones/19.png) +However, the files write and read those are C:\Users\“xxxxxx”\AppData\Local\Microsoft\PowerToys\FancyZones + +You can think of the editor as a visual config editor, which is most of its functionality. Another feature is used to set the layout for the monitor displays. + +When the Editor starts, it will load the config data, and when FancyZones starts, it will also load the config data. After the Editor updates the config data, it will send a data update event, and FancyZones will refresh the current data in memory upon receiving the event. + +![Debug Step Image](../images/fancyzones/20.png) + +- ### Which parts of the code are responsible for monitor detection and DPI scaling? + +About monitor detection you can find "FancyZones::MoveSizeUpdate" function. + +I believe that in the case without DPI scaling, FancyZones retrieves the window's position and does not need to know what the mouse's DPI scaling is like. If you are referring to window scaling, it is called through the system interface, and you can see the detailed code in "WindowMouseSnap::MoveSizeEnd()" function. + +- ### How does FancyZones track which windows belong to which zones? + +In "FancyZones::MoveSizeUpdate" function. + +### Extra tools and information **Test samples**: https://github.com/microsoft/WinAppDriver/tree/master/Samples While working on tests, you may need a tool that helps you to view the element's accessibility data, e.g. for finding the button to click. For this purpose, you could use [AccessibilityInsights](https://accessibilityinsights.io/docs/windows/overview) or [WinAppDriver UI Recorder](https://github.com/microsoft/WinAppDriver/wiki/WinAppDriver-UI-Recorder). ->Note: close helper tools while running tests. Overlapping windows can affect test results. \ No newline at end of file +>Note: close helper tools while running tests. Overlapping windows can affect test results. + diff --git a/doc/devdocs/modules/fileexploreraddons.md b/doc/devdocs/modules/fileexploreraddons.md new file mode 100644 index 0000000000..8d70af448d --- /dev/null +++ b/doc/devdocs/modules/fileexploreraddons.md @@ -0,0 +1,34 @@ +# File Explorer Add-ons + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/file-explorer) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-File%20Explorer%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-File%20Explorer%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-File+Explorer%22) + +## Overview + +File Explorer Add-ons are extensions that enhance Windows File Explorer functionality with additional features and context menu options. + +## Links + +- [Source code folder](https://github.com/microsoft/PowerToys/tree/main/src/modules/fileexplorerpreview) +- [Issue tracker](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+label%3A%22File+Explorer%22) + +## Implementation Details + +TODO: Add implementation details + +## Debugging + +TODO: Add debugging information + +## Settings + +TODO: Add settings documentation + +## Future Improvements + +TODO: Add potential future improvements diff --git a/doc/devdocs/modules/filelocksmith.md b/doc/devdocs/modules/filelocksmith.md new file mode 100644 index 0000000000..a678617821 --- /dev/null +++ b/doc/devdocs/modules/filelocksmith.md @@ -0,0 +1,202 @@ +# File Locksmith + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/file-locksmith) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-File%20Locksmith%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-File%20Locksmith%22%20label%3AIssue-Bug)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-File+Locksmith%22) + +## Overview + +File Locksmith is a utility in PowerToys that shows which processes are locking or using a specific file. This helps users identify what's preventing them from deleting, moving, or modifying files by revealing the processes that have handles to those files. + +## Architecture + +![Diagram](../images/filelocksmith/diagram.png) + +File Locksmith follows a similar architecture to the ImageResizer and NewPlus modules. It consists of: + +1. **Shell Extensions**: + - `FileLocksmithExt` - COM-based shell extension for Windows 10 and below + - `FileLocksmithContextMenu` - Shell extension for Windows 11 context menu + +2. **Core Components**: + - `FileLocksmithLib` - Handles IPC between shell extensions and UI + - `FileLocksmithLibInterop` - Core functionality for finding processes locking files + - `FileLocksmithUI` - WinUI 3 user interface component + +3. **Settings Integration**: + - Settings integration in the PowerToys settings application + +## Implementation Details + +### Shell Extensions + +The module adds "Unlock with File Locksmith" to the context menu in File Explorer: + +- For Windows 11, a context menu command is registered as a MSIX sparse package (compiled via appxmanifest.xml) +- For Windows 10 and below, a traditional shell extension is registered through registry keys during installation + +### Process Communication Flow + +1. User enables File Locksmith in PowerToys settings +2. User right-clicks on a file and selects "Unlock with File Locksmith" +3. The shell extension writes the selected file path to a temporary file (file-based IPC) +4. The shell extension launches `PowerToys.FileLocksmithUI.exe` +5. The UI reads the file path from the temporary file +6. The UI uses `FileLocksmithLibInterop` to scan for processes with handles to the file +7. Results are displayed in the UI, showing process information and allowing user action + +### Core Functionality + +The core functionality to find processes locking files is implemented in [FileLocksmith.cpp](/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmith.cpp), which: + +- Uses low-level Windows APIs via `NtdllExtensions` to iterate through file handles +- Examines all running processes to find handles to the specified files +- Maps process IDs to the files they're locking +- Retrieves process information such as name, user context, and file paths + +### User Interface + +The UI is built with WinUI 3 and uses MVVM architecture: +- View models handle process data and user interactions +- Converters transform raw data into UI-friendly formats +- The interface shows which processes are locking files, along with icons and process details + +## Code Structure + +### Shell Extensions +- [ClassFactory.cpp](/src/modules/FileLocksmith/FileLocksmithExt/ClassFactory.cpp): COM class factory that creates instances of shell extension objects +- [ExplorerCommand.cpp](/src/modules/FileLocksmith/FileLocksmithExt/ExplorerCommand.cpp): Implements Windows Explorer context menu command for Windows 10 and below +- [PowerToysModule.cpp](/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp): PowerToys module interface implementation with settings management +- [dllmain.cpp](/src/modules/FileLocksmith/FileLocksmithExt/dllmain.cpp): DLL entry point for Windows 10 shell extension +- [dllmain.cpp](/src/modules/FileLocksmith/FileLocksmithContextMenu/dllmain.cpp): Windows 11 context menu integration through MSIX package + +### Core Libraries +- [IPC.cpp](/src/modules/FileLocksmith/FileLocksmithLib/IPC.cpp): File-based inter-process communication between shell extensions and UI +- [Settings.cpp](/src/modules/FileLocksmith/FileLocksmithLib/Settings.cpp): Settings management for File Locksmith module +- [FileLocksmith.cpp](/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmith.cpp): Core process scanning implementation to find processes locking files +- [NativeMethods.cpp](/src/modules/FileLocksmith/FileLocksmithLibInterop/NativeMethods.cpp): Interop layer bridging native C++ with WinRT-based UI +- [NtdllBase.cpp](/src/modules/FileLocksmith/FileLocksmithLibInterop/NtdllBase.cpp): Interface to native Windows NT APIs +- [NtdllExtensions.cpp](/src/modules/FileLocksmith/FileLocksmithLibInterop/NtdllExtensions.cpp): Process and handle querying utilities using NtQuerySystemInformation +- [ProcessResult.cpp](/src/modules/FileLocksmith/FileLocksmithLibInterop/ProcessResult.cpp): Class for storing process information (name, PID, user, file list) + +### UI Components +- [FileCountConverter.cs](/src/modules/FileLocksmith/FileLocksmithUI/Converters/FileCountConverter.cs): Converts file counts for UI display +- [FileListToDescriptionConverter.cs](/src/modules/FileLocksmith/FileLocksmithUI/Converters/FileListToDescriptionConverter.cs): Formats file lists for display +- [PidToIconConverter.cs](/src/modules/FileLocksmith/FileLocksmithUI/Converters/PidToIconConverter.cs): Extracts icons for processes +- [UserToSystemWarningVisibilityConverter.cs](/src/modules/FileLocksmith/FileLocksmithUI/Converters/UserToSystemWarningVisibilityConverter.cs): Shows warnings for system processes +- [MainWindow.xaml.cs](/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml.cs): Main application window implementation +- [App.xaml.cs](/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/App.xaml.cs): Application entry point +- [ResourceLoaderInstance.cs](/src/modules/FileLocksmith/FileLocksmithUI/Helpers/ResourceLoaderInstance.cs): Localization resource helper +- [MainViewModel.cs](/src/modules/FileLocksmith/FileLocksmithUI/ViewModels/MainViewModel.cs): Main view model that handles loading processes asynchronously + +### Settings Integration +- [FileLocksmithViewModel.cs](/src/settings-ui/Settings.UI/ViewModels/FileLocksmithViewModel.cs): ViewModel for File Locksmith in PowerToys settings +- [FileLocksmithLocalProperties.cs](/src/settings-ui/Settings.UI.Library/FileLocksmithLocalProperties.cs): Machine-level settings storage +- [FileLocksmithProperties.cs](/src/settings-ui/Settings.UI.Library/FileLocksmithProperties.cs): User-level settings storage +- [FileLocksmithSettings.cs](/src/settings-ui/Settings.UI.Library/FileLocksmithSettings.cs): Module settings definitions + +## Debugging + +To build and debug the File Locksmith module: + +0. **Build FileLocksmith module** + - Shutdown the existing release builds of PowerToys + - Open the solution in Visual Studio + - Build the entire solution + - Build the `FileLocksmith` project + +1. **Create certificate and import to Root (if you don't already have)** + ```powershell + New-SelfSignedCertificate -Subject "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" ` + -KeyUsage DigitalSignature ` + -Type CodeSigningCert ` + -FriendlyName "PowerToys SelfCodeSigning" ` + -CertStoreLocation "Cert:\CurrentUser\My" + + $cert = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -like "*PowerToys*" } + + Export-Certificate -Cert $cert -FilePath "$env:TEMP\PowerToysCodeSigning.cer" + + # under admin Terminal: + Import-Certificate -FilePath "$env:TEMP\PowerToysCodeSigning.cer" -CertStoreLocation Cert:\LocalMachine\Root + + # get Thumbprint + Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -like "*PowerToys*" } + ``` + +2. **Sign the MSIX package** + ``` + SignTool sign /fd SHA256 /sha1 <CERTIFICATE THUMBPRINT> "C:\Users\$env:USERNAME\source\repos\PowerToys\x64\Debug\WinUI3Apps\FileLocksmithContextMenuPackage.msix" + ``` + SignTool might be not in your PATH, so you may need to specify the full path to it, e.g., `C:\Program Files (x86)\Windows Kits\10\bin\<version>\x64\signtool.exe`. + + **commands example**: + ```powershell + PS C:\Users\developer> New-SelfSignedCertificate -Subject "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" ` + >> -KeyUsage DigitalSignature ` + >> -Type CodeSigningCert ` + >> -FriendlyName "PowerToys SelfSigned" ` + >> -CertStoreLocation "Cert:\CurrentUser\My" + + PSParentPath: Microsoft.PowerShell.Security\Certificate::CurrentUser\My + + Thumbprint Subject EnhancedKeyUsageList + ---------- ------- -------------------- + 1AA018C2B06B60EAFEE452ADE403306F39058FF5 CN=Microsoft Corpor… Code Signing + + PS C:\Users\developer> Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -like "*PowerToys*" } + + PSParentPath: Microsoft.PowerShell.Security\Certificate::CurrentUser\My + + Thumbprint Subject EnhancedKeyUsageList + ---------- ------- -------------------- + 1AA018C2B06B60EAFEE452ADE403306F39058FF5 CN=Microsoft Corpor… Code Signing + + PS C:\Users\developer> & "C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe" sign /fd SHA256 /sha1 1AA018C2B06B60EAFEE452ADE403306F39058FF5 "%REPO_PATH%\PowerToys\x64\Debug\WinUI3Apps\FileLocksmithContextMenuPackage.msix" + Done Adding Additional Store + Successfully signed: C:\Users\developer\Develop\GitHub\PowerToys\x64\Debug\WinUI3Apps\FileLocksmithContextMenuPackage.msix + ``` + +3. **Remove old version** + ```powershell + Get-AppxPackage -Name Microsoft.PowerToys.FileLocksmithContextMenu* + Remove-AppxPackage Microsoft.PowerToys.FileLocksmithContextMenu_1.0.0.0_neutral__8wekyb3d8bbwe + ``` + +4. **Install new signed MSIX** + ```powershell + Add-AppxPackage -Path "%REPO_PATH%\PowerToys\x64\Debug\WinUI3Apps\FileLocksmithContextMenuPackage.msix" -ExternalLocation "%REPO_PATH%\PowerToys\x64\Debug\WinUI3Apps" + ``` + +5. **Restart Explorer** + - Go to Task Manager and restart explorer.exe + +6. **Debug Process** + - Set the breakpoint in [dllmain.cpp](/src/modules/FileLocksmith/FileLocksmithContextMenu/dllmain.cpp#L116) + - Open the **Attach to Process** dialog in Visual Studio + - Right-click a file in File Explorer + - Attach the debugger to `dllhost.exe` with **FileLocksmith** Title to debug the shell extension + ![Attach to Process](../images/filelocksmith/debug.png) + - Right-click (fast) a file again and select *"Unlock with File Locksmith"* + - Attach the debugger to `PowerToys.FileLocksmithUI.exe` to debug the UI + +7. **Alternative Debugging Method** + - You can set the `FileLocksmithUI` as startup project directly in Visual Studio, which will launch the UI without needing to go through the shell extension. This is useful for debugging the UI logic without the shell extension overhead. + +## Known Issues + +There is an open PR to change the IPC mechanism from file-based to pipe-based, but it has blockers: +- When restarting as admin, the context menu extension doesn't show +- The "Unlock with File Locksmith" option doesn't work when launched as admin + +## Settings Integration + +File Locksmith integrates with the PowerToys settings through: +- [FileLocksmithViewModel.cs](/src/settings-ui/Settings.UI/ViewModels/FileLocksmithViewModel.cs) +- [FileLocksmithLocalProperties.cs](/src/settings-ui/Settings.UI.Library/FileLocksmithLocalProperties.cs) +- [FileLocksmithProperties.cs](/src/settings-ui/Settings.UI.Library/FileLocksmithProperties.cs) +- [FileLocksmithSettings.cs](/src/settings-ui/Settings.UI.Library/FileLocksmithSettings.cs) diff --git a/doc/devdocs/modules/hostsfileeditor.md b/doc/devdocs/modules/hostsfileeditor.md new file mode 100644 index 0000000000..f8818b6dc0 --- /dev/null +++ b/doc/devdocs/modules/hostsfileeditor.md @@ -0,0 +1,114 @@ +# Hosts File Editor + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/hosts-file-editor) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Hosts%20File%20Editor%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Hosts%20File%20Editor%22%20label%3AIssue-Bug)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Hosts+File+Editor%22) + +## Overview + +The Hosts File Editor module provides a convenient way to edit the system's hosts file. The hosts file is a plain text file used by the operating system to map hostnames to IP addresses, allowing users to override DNS for specific domain names. + +## Code Structure + +![Diagram](../images/hostsfileeditor/diagram.png) + +The Hosts File Editor module is structured into three primary components: + +1. **Hosts** - Entry point for the Hosts File Editor. Manages core services and settings through helper utilities. +2. **HostsModuleInterface** - Interface for integrating the Hosts module with the PowerToys system. +3. **HostsUILib** - Implements the UI layer using WinUI 3. + +This structure is similar to the Environment Variables for Windows module. + +## Key Components + +### Main Entry Points + +- **Module Entry**: [Program.cs](/src/modules/Hosts/Program.cs) → [App.xaml.cs](/src/modules/Hosts/HostsXAML/App.xaml.cs) +- **Settings UI**: + - Main Window: [MainWindow.xaml.cs](/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml.cs) + - View: [HostsMainPage.xaml](/src/modules/Hosts/HostsUILib/HostsMainPage.xaml) + - ViewModel: [HostsMainPage.xaml.cs](/src/modules/Hosts/HostsUILib/HostsMainPage.xaml.cs) +- **Runner Integration**: [HostsModuleInterface](/src/modules/Hosts/HostsModuleInterface) + +### Runner Integration + +The module is loaded by the PowerToys runner from: +- [main.cpp](/src/runner/main.cpp) (Lines 183-184): Loads Hosts Module using `L"WinUI3Apps/PowerToys.HostsModuleInterface.dll"` + +### Settings Management + +- [HostsViewModel.cs](/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs): Hosts UI in PowerToys settings +- [HostsProperties.cs](/src/settings-ui/Settings.UI.Library/HostsProperties.cs): In settings UI +- [HostsSettings.cs](/src/settings-ui/Settings.UI.Library/HostsSettings.cs): Wrapper with HostsProperties + +### Module Components + +#### HostsModuleInterface + +- Defines the interface for integrating the Hosts module with the PowerToys system. + +#### Hosts (Main Project) + +- [Program.cs](/src/modules/Hosts/Hosts/Program.cs): Launch app +- [HostsXAML](/src/modules/Hosts/Hosts/HostsXAML): Initialize service and loads the main window +- [Host.cs](/src/modules/Hosts/Hosts/Helpers/Host.cs): Access to services register +- [NativeEventWaiter.cs](/src/modules/Hosts/Hosts/Helpers/NativeEventWaiter.cs): Gets the dispatcher queue for posting UI updates from a background thread +- [UserSettings.cs](/src/modules/Hosts/Hosts/Settings/UserSettings.cs): Manages reading, tracking, and updating user settings from settings.json + +#### HostsUILib + +- [HostsMainPage.xaml.cs](/src/modules/Hosts/HostsUILib/HostsMainPage.xaml.cs): Main page +- [ViewModels](/src/modules/Hosts/HostsUILib/ViewModels): Contains view models that manage state and logic +- [Models](/src/modules/Hosts/HostsUILib/Models): Models for managing host entries + - [AddressType.cs](/src/modules/Hosts/HostsUILib/Models/AddressType.cs): Specifies whether an address is IPv4, IPv6, or Invalid + - [Entry.cs](/src/modules/Hosts/HostsUILib/Models/Entry.cs): Represents a single hosts file entry (IP address, hostnames, comment, flags) + - [HostsData.cs](/src/modules/Hosts/HostsUILib/Models/HostsData.cs): Converts the list of entries into a read-only collection +- [Settings](/src/modules/Hosts/HostsUILib/Settings): Settings configuration +- [Consts.cs](/src/modules/Hosts/HostsUILib/Consts.cs): Defines constants like max hosts IP length +- [Helpers](/src/modules/Hosts/HostsUILib/Helpers): Utilities for dealing with hosts IP, filter features, and file loading + +## Call Flow + +1. **Enable app**: runner/main.cpp → settings.ui/settings.ui.library +2. **Start app**: Program.cs → HostsXAML → HostsMainPage +3. **Load hosts data**: ViewModel → HostsData → Helpers (load and parse file) +4. **User edits**: UI bound to ViewModel updates entries +5. **Save changes**: ViewModel triggers file write through Helpers +6. **Settings management**: UserSettings.cs persists user preferences + +## Key Features + +| Feature | Key Function | +|---------|--------------| +| Adding a new entry | `Add(Entry entry)` | +| Filtering host file entries | `ApplyFilters()` | +| Open Hosts File | `ReadHosts()` | +| Additional Lines | `UpdateAdditionalLines(string lines)` | + +## Settings + +| Setting | Implementation | +|---------|---------------| +| Open as administrator | `UserSettings()` | +| Additional lines position | `UserSettings()->AdditionalLinesPosition` | +| Consider loopback addresses as duplicates | `UserSettings()->LoopbackDuplicates` | +| Encoding Setting | `UserSettings()->Encoding` | + +## UI Test Automation + +Hosts File Editor is currently undergoing a UI Test migration process to improve automated testing coverage. You can track the progress of this migration at: + +[Hosts File Editor UI Test Migration Progress](https://github.com/microsoft/PowerToys/blob/feature/UITestAutomation/src/modules/Hosts/Hosts.UITests/Release-Test-Checklist-Migration-Progress.md) + +## How to Build and Debug + +1. Build PowerToys Project in debug mode +2. Set Hosts as the startup project +3. Launch Hosts File Editor in debug mode +4. Attach the debugger to PowerToys.Hosts.dll +5. Add breakpoints in the Hosts code diff --git a/doc/devdocs/modules/imageresizer.md b/doc/devdocs/modules/imageresizer.md new file mode 100644 index 0000000000..d62c8b5b2d --- /dev/null +++ b/doc/devdocs/modules/imageresizer.md @@ -0,0 +1,132 @@ +# Image Resizer + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/image-resizer) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Image%20Resizer%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Image%20Resizer%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Image+Resizer%22) + +Image Resizer is a Windows shell extension that enables batch resizing of images directly from File Explorer. + +## Overview + +Image Resizer provides a convenient way to resize images without opening a dedicated image editing application. It is accessible through the Windows context menu and allows users to: +- Resize single or multiple images at once +- Choose from predefined sizing presets or enter custom dimensions +- Maintain or modify aspect ratios +- Create copies or overwrite original files +- Apply custom filename formats to resized images +- Select different encoding quality settings + +## Architecture + +Image Resizer consists of multiple components: +- Shell Extension DLL (context menu integration) +- WinUI 3 UI application +- Core image processing library + +### Technology Stack +- C++/WinRT +- WPF (UI components) +- Windows Imaging Component (WIC) for image processing +- COM for shell integration + +## Context Menu Integration + +Image Resizer integrates with the Windows context menu following the [PowerToys Context Menu Handlers](../common/context-menus.md) pattern. It uses a dual registration approach to ensure compatibility with both Windows 10 and Windows 11. + +### Registration Process + +The context menu integration follows the same pattern as PowerRename, using: +- A traditional shell extension for Windows 10 +- A sparse MSIX package for Windows 11 context menus + +For more details on the implementation approach, see the [Dual Registration section](../common/context-menus.md#1-dual-registration-eg-imageresizer-powerrename) in the context menu documentation. + +### Context Menu Appearance Logic + +Image Resizer dynamically determines when to show the context menu option: +- `AppxManifest.xml` registers the extension for all file types (`Type="*"`) +- The shell extension checks if the selected files are images using `AssocGetPerceivedType()` +- The menu appears only for image files (returns `ECS_ENABLED`); otherwise, it remains hidden (returns `ECS_HIDDEN`) + +This approach provides flexibility to support additional file types by modifying only the detection logic without changing the system-level registration. + +## UI Implementation + +Image Resizer uses WPF for its user interface, as evidenced by the App.xaml.cs file. The UI allows users to: +- Select from predefined size presets or enter custom dimensions +- Configure filename format for the resized images +- Set encoding quality and format options +- Choose whether to replace or create copies of the original files + +From the App.xaml.cs file, we can see that the application: +- Supports localization through `LanguageHelper.LoadLanguage()` +- Processes command line arguments via `ResizeBatch.FromCommandLine()` +- Uses a view model pattern with `MainViewModel` +- Respects Group Policy settings via `GPOWrapper.GetConfiguredImageResizerEnabledValue()` + +## Debugging + +### Debugging the Context Menu + +See the [Debugging Context Menu Handlers](../common/context-menus.md#debugging-context-menu-handlers) section for general guidance on debugging PowerToys context menu extensions. + +For Image Resizer specifically, there are several approaches: + +#### Option 1: Manual Registration via Registry + +1. Create a registry file (e.g., `register.reg`) with the following content: +``` +Windows Registry Editor Version 5.00 + +[HKEY_CLASSES_ROOT\CLSID\{51B4D7E5-7568-4234-B4BB-47FB3C016A69}] +@="PowerToys Image Resizer Extension" + +[HKEY_CLASSES_ROOT\CLSID\{51B4D7E5-7568-4234-B4BB-47FB3C016A69}\InprocServer32] +@="D:\\PowerToys\\x64\\Debug\\PowerToys.ImageResizerExt.dll" +"ThreadingModel"="Apartment" + +[HKEY_CURRENT_USER\Software\Classes\SystemFileAssociations\.png\ShellEx\ContextMenuHandlers\ImageResizer] +@="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" +``` + +2. Import the registry file: +``` +reg import register.reg +``` + +3. Restart Explorer to apply changes: +``` +taskkill /f /im explorer.exe && start explorer.exe +``` + +4. Attach the debugger to `explorer.exe` + +5. Add breakpoints to relevant code in the Image Resizer shell extension + +#### Option 2: Using regsvr32 + +1. Register the shell extension DLL: +``` +regsvr32 "D:\PowerToys\x64\Debug\PowerToys.ImageResizerExt.dll" +``` + +2. Restart Explorer and attach the debugger as in Option 1 + +### Common Issues + +- Context menu not appearing: + - Ensure the extension is properly registered + - Verify you're right-clicking on supported image files + - Restart Explorer to clear context menu cache + +- For Windows 11, check AppX package registration: + - Use `get-appxpackage -Name *imageresizer*` to verify installation + - Use `Remove-AppxPackage` to remove problematic registrations + +- Missing UI or processing failures: + - Check Event Viewer for application errors + - Verify file permissions for both source and destination folders diff --git a/doc/devdocs/modules/interface.md b/doc/devdocs/modules/interface.md index 44a846cf5c..13e754a3cc 100644 --- a/doc/devdocs/modules/interface.md +++ b/doc/devdocs/modules/interface.md @@ -86,7 +86,7 @@ Returns true if successful. virtual void set_config(const wchar_t* config) ``` -After the user has changed the module settings in the Settings editor, the runner calls this method to pass to the module the updated values. It's a good place to save the settings as well. +After the user has changed the module settings in the Settings editor, the runner calls this method to pass the updated values to the module. It's a good place to save the settings as well. ## call_custom_action diff --git a/doc/devdocs/modules/keyboardmanager/README.md b/doc/devdocs/modules/keyboardmanager/README.md index 9e363a9468..651a7a1f63 100644 --- a/doc/devdocs/modules/keyboardmanager/README.md +++ b/doc/devdocs/modules/keyboardmanager/README.md @@ -1,6 +1,17 @@ # Table of Contents + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/keyboard-manager) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Keyboard%20Shortcut%20Manager%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Keyboard%20Shortcut%20Manager%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Keyboard+Shortcut+Manager%22+) + + The devdocs for Keyboard Manager have been divided into the following modules: 1. [Keyboard Manager Module](keyboardmanager.md) 2. [Keyboard Event Handlers](keyboardeventhandlers.md) 3. [Keyboard Manager UI](keyboardmanagerui.md) -4. [Keyboard Manager Common](keyboardmanagercommon.md) \ No newline at end of file +4. [Keyboard Manager Common](keyboardmanagercommon.md) +5. [Debugging Guide](debug.md) \ No newline at end of file diff --git a/doc/devdocs/modules/keyboardmanager/debug.md b/doc/devdocs/modules/keyboardmanager/debug.md new file mode 100644 index 0000000000..60b975da0f --- /dev/null +++ b/doc/devdocs/modules/keyboardmanager/debug.md @@ -0,0 +1,94 @@ +# Keyboard Manager Debugging Guide + +This document provides guidance on debugging the Keyboard Manager module in PowerToys. + +## Module Overview + +Keyboard Manager consists of two main components: +- **Keyboard Manager Editor**: UI application for configuring key and shortcut remappings +- **Keyboard Manager Engine**: Background process that intercepts and handles keyboard events + +## Development Environment Setup + +1. Clone the PowerToys repository +2. Open `PowerToys.slnx` in Visual Studio +3. Ensure all NuGet packages are restored +4. Build the entire solution in Debug configuration + +## Debugging the Editor (UI) + +### Setup + +1. In Visual Studio, right-click on the `KeyboardManagerEditor` project +2. Select "Set as Startup Project" + +### Common Debugging Scenarios + +#### UI Rendering Issues + +Breakpoints to consider: +- `EditKeyboardWindow.cpp`: `CreateWindow()` method +- `EditShortcutsWindow.cpp`: `CreateWindow()` method + +#### Configuration Changes + +When debugging configuration changes: +1. Set breakpoints in `KeyboardManagerState.cpp` around the `SetRemappedKeys()` or `SetRemappedShortcuts()` methods +2. Monitor the JSON serialization process in the save functions + +### Testing UI Behavior + +The `KeyboardManagerEditorTest` project contains tests for the UI functionality. Run these tests to validate UI changes. + +## Debugging the Engine (Remapping Logic) + +### Setup + +1. In Visual Studio, right-click on the `KeyboardManagerEngine` project +2. Select "Set as Startup Project" +3. Press F5 to start debugging + +### Key Event Flow + +The keyboard event processing follows this sequence: +1. Low-level keyboard hook captures an event +2. `KeyboardEventHandlers.cpp` processes the event +3. `KeyboardManager.cpp` applies remapping logic +4. Event is either suppressed, modified, or passed through + +### Breakpoints to Consider + +- `main.cpp`: `StartLowlevelKeyboardHook()` - Hook initialization +- `KeyboardEventHandlers.cpp`: `HandleKeyboardEvent()` - Entry point for each keyboard event +- `KeyboardManager.cpp`: `HandleKeyEvent()` - Processing individual key events +- `KeyboardManager.cpp`: `HandleShortcutRemapEvent()` - Processing shortcut remapping + +### Logging and Trace + +Enable detailed logging by setting the `_DEBUG` and `KBM_VERBOSE_LOGGING` preprocessor definitions. + +## Common Issues and Troubleshooting + +### Multiple Instances + +If you encounter issues with multiple instances, check the mutex logic in `KeyboardManagerEditor.cpp`. The editor uses `PowerToys_KBMEditor_InstanceMutex` to ensure single instance. + +### Key Events Not Being Intercepted + +1. Verify the hook is properly installed by setting a breakpoint in the hook procedure +2. Check if any other application is capturing keyboard events at a lower level +3. Ensure the correct configuration is being loaded from the settings JSON + +### UI Freezes or Crashes + +1. Check XAML Islands initialization in the Editor +2. Verify UI thread is not being blocked by IO operations +3. Look for exceptions in the event handling code + +## Advanced Debugging + +### Debugging Both Components Simultaneously + +To debug both the Editor and Engine: +1. Launch the Engine first in debug mode +2. Attach the debugger to the Editor process when it starts diff --git a/doc/devdocs/modules/keyboardmanager/keyboardeventhandlers.md b/doc/devdocs/modules/keyboardmanager/keyboardeventhandlers.md index a2ee097771..6c44e3c8c1 100644 --- a/doc/devdocs/modules/keyboardmanager/keyboardeventhandlers.md +++ b/doc/devdocs/modules/keyboardmanager/keyboardeventhandlers.md @@ -58,7 +58,7 @@ This file contains documentation for all the methods involved in key/shortcut re [This method](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/dll/KeyboardEventHandlers.cpp#L754-L809) is used for handling app-specific shortcut to shortcut and shortcut to key remaps. The general logic is as follows: - Check if the `dwExtraInfo` field is set to `KEYBOARDMANAGER_SHORTCUT_FLAG`. This indicates that the key event was generated by the KBM shortcut remap method using `SendInput`. This ensures that we don't read events generated by the shortcut remap method, but we still read events which are generated by the key remap method. - Get the name of the process in the foreground. This is done using `GetCurrentApplication` which uses `GetForegroundWindow` to get the window handle and `get_process_path` from the common lib. This approach can fail for UWP apps in full screen, so for that scenario we use the `GetGUIThreadInfo` approach to find the correct window handle, and hence the correct process name. This method is [described in more detail](keyboardmanagercommon.md#Foreground-app-detection) -- By checking `KeyboardManagerState.GetActivatedApp` we check if an app-specific shortcut is currently invoked. If so, we consider this application to be the activated app. This is required because some shortcut remaps could cause the current app to lose focus and hence until the shortcut is completely released we should allow that remap to continue, otherwise the user could end up in a state where some keys do not get released. For example: remap <kbd>Ctrl+A</kbd> to <kbd>Alt+Tab</kbd> for Edge, when a user presses <kbd>Ctrl+A</kbd> the window loses focus as <kbd>Alt+Tab</kbd> gets executed. +- By checking `KeyboardManagerState.GetActivatedApp` we check if an app-specific shortcut is currently invoked. If so, we consider this application to be the activated app. This is required because some shortcut remaps could cause the current app to lose focus and hence until the shortcut is completely released we should allow that remap to continue; otherwise, the user could end up in a state where some keys do not get released. For example: remap <kbd>Ctrl+A</kbd> to <kbd>Alt+Tab</kbd> for Edge, when a user presses <kbd>Ctrl+A</kbd> the window loses focus as <kbd>Alt+Tab</kbd> gets executed. - If there is no app-specific shortcut currently invoked, we check if the foreground process is present in the list of app-specific remaps, either with or without the file extension and case-insensitive. If it is, this is considered to be the activated app. - Call `HandleShortcutRemapEvent` with the `activatedApp` argument so that app-specific shortcut remapping takes place if it applies for the current key event. @@ -73,8 +73,8 @@ The [`MockedInput`](https://github.com/microsoft/PowerToys/blob/main/src/modules [To mock the `SendInput` method](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/test/MockedInput.cpp#L10-L110), the steps for processing the input are as follows. This implementation is based on public documentation for SendInput and the behavior of key messages and keyboard hooks: - Iterate over all the inputs in the `INPUT` vector argument. -- If the event is a key up event, then it is considered [`WM_SYSKEYUP`](https://learn.microsoft.com/windows/win32/inputdev/wm-syskeyup) if Alt is held down, otherwise it is `WM_KEYUP`. -- If the event is a key down event, then it is considered [`WM_SYSKEYDOWN`](https://learn.microsoft.com/windows/win32/inputdev/wm-syskeydown) if either Alt is held down or if it is F10, otherwise it is `WM_KEYDOWN`. +- If the event is a key up event, then it is considered [`WM_SYSKEYUP`](https://learn.microsoft.com/windows/win32/inputdev/wm-syskeyup) if Alt is held down; otherwise, it is `WM_KEYUP`. +- If the event is a key down event, then it is considered [`WM_SYSKEYDOWN`](https://learn.microsoft.com/windows/win32/inputdev/wm-syskeydown) if either Alt is held down or if it is F10; otherwise, it is `WM_KEYDOWN`. - An optional function which can be set on the `MockedInput` handler can be used to test for the number of times a key event is received by the system with a particular condition using [`sendVirtualInputCallCondition`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/test/MockedInput.cpp#L48-L52). - The hook logic for a low level hook which returns 0 or 1 can be set on the `MockedInput` handler such that it behaves like a low level hook would behave with actual keyboard input. If the method returns 1, then the keyboard state is not updated, and if it returns 0 the corresponding key event is used to update the key state. This works in the recursive way as well similar to low level hooks, as `SendVirtualInput` can be called from within the hook, thus simulating identical behavior to calling `SendInput` in a low level hook (as soon as SendInput is called, the low level hook is called for the new input event, and only after those are processed it returns back to the current event, check this [blog](https://devblogs.microsoft.com/oldnewthing/20140213-00/?p=1773) for more details). - For updating the keyboard state, KEYUP messages result in the state for that key code being set to false, and KEYDOWN result in the state for that key code being set to true. diff --git a/doc/devdocs/modules/keyboardmanager/keyboardmanager.md b/doc/devdocs/modules/keyboardmanager/keyboardmanager.md index c570a735e8..f5aee2c056 100644 --- a/doc/devdocs/modules/keyboardmanager/keyboardmanager.md +++ b/doc/devdocs/modules/keyboardmanager/keyboardmanager.md @@ -127,12 +127,12 @@ The [`HandleKeyboardHookEvent`](https://github.com/microsoft/PowerToys/blob/b805 **Note:** Single key remaps need to be executed before shortcut remaps, because otherwise there can be several logical issues. For example if a user has Ctrl remapped to X and Ctrl+A remapped to Y, we can't detect Ctrl+A because the moment Ctrl is pressed it would be remapped to X before the system ever sees Ctrl+A. This is why the design decision was made to separate Remap keys and Remap shortcuts, and all key remaps are reflected in the shortcut remaps. ## Custom Action to launch KBM UI -KBM uses the [`call_custom_action`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/dll/dllmain.cpp#L249-L280) method from the `PowertoyModuleIface` in order to launch the KBM UI when the user clicks the Remap a key or Remap a shortcut button from the KBM settings page. On clicking the button, we check if there is already any active KBM UI window, and if there is it is brought to the foreground. If not, the corresponding KBM UI window is launched on a separate detached thread. The UI is described in more detail in [Keyboard Manager UI](keyboardmanagerui.md). +KBM uses the [`call_custom_action`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/dll/dllmain.cpp#L249-L280) method from the `PowertoyModuleIface` in order to launch the KBM UI when the user clicks "Remap a key" or "Remap a shortcut" from the KBM settings page. On clicking the button, we check if there is already any active KBM UI window, and if there is it is brought to the foreground. If not, the corresponding KBM UI window is launched on a separate detached thread. The UI is described in more detail in [Keyboard Manager UI](keyboardmanagerui.md). ## SendInput Special Scenarios ### Extended keys -Certain keys such as the arrow keys, <kbd>right Ctrl/Alt</kbd>, and <kbd>Del/Home/Ins</kbd>, etc need to be sent with the `KEYEVENTF_EXTENDEDKEY` flag because otherwise the NumPad versions get sent, which can cause weird behavior when NumLock is on. The code can be found where [`SetKeyEvent` checks `IsExtendedKey(keyCode)`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/common/Helpers.cpp#L190-L194) and the list of extended keys in code can be found in [`IsExtendedKey`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/common/Helpers.cpp#L73-L98). Docs about extended keys can be found in [Keyboard Input Overview: Extended-Key Flag +Certain keys such as the arrow keys, <kbd>right Ctrl/Alt</kbd>, and <kbd>Del/Home/Ins</kbd>, etc. need to be sent with the `KEYEVENTF_EXTENDEDKEY` flag because otherwise the NumPad versions get sent, which can cause weird behavior when NumLock is on. The code can be found where [`SetKeyEvent` checks `IsExtendedKey(keyCode)`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/common/Helpers.cpp#L190-L194) and the list of extended keys in code can be found in [`IsExtendedKey`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/common/Helpers.cpp#L73-L98). Docs about extended keys can be found in [Keyboard Input Overview: Extended-Key Flag ](https://learn.microsoft.com/windows/win32/inputdev/about-keyboard-input#extended-key-flag). The weird behavior that is caused by this can be found at these issues: @@ -177,13 +177,13 @@ For example, while [remapping <kbd>Ctrl</kbd> to <kbd>Caps Lock</kbd>](https://g While the above work around fixes most of the cases, there are still some scenarios where the modifier can get stuck, mentioned at this [comment](https://github.com/microsoft/PowerToys/issues/3397#issuecomment-663729278), which is why the issue is still open. This occurs if a modifier is pressed after the remap has been invoked before releasing the remapped key and it is a harder scenario to solve which requires refactoring the single key remap code. ### UIPI Issues (not resolved) -`SendInput` does not work directly with certain key codes such as Play/Pause Media, Calculator key, etc as it requires UAC privileges to be injected to the OS and accordingly play the active media app or launch the Calculator app. In order to resolve this the correct approach is that the executable which calls `SendInput` needs to have the [UIAccess flag](https://learn.microsoft.com/windows/win32/winauto/uiauto-securityoverview) set to true, which will also avoid the requirement of KBM having to run as administrator to intercept key events when an elevated window is in focus. The UIAccess flag has many constraints such as it must be a signed executable and must be located in a protected path like Program Files. Since KBM currently runs out of the runner process, it would make more sense to do this work after KBM is moved to a separate executable, and it could be enabled by a separate toggle in settings only if PowerToys is installed in Program Files. [This comment](https://github.com/microsoft/PowerToys/issues/3192#issuecomment-646323661) has more details on this approach and (this)[https://github.com/microsoft/PowerToys/issues/3255] is the tracking issue. +`SendInput` does not work directly with certain key codes such as Play/Pause Media, Calculator key, etc. as it requires UAC privileges to be injected to the OS and accordingly play the active media app or launch the Calculator app. In order to resolve this the correct approach is that the executable which calls `SendInput` needs to have the [UIAccess flag](https://learn.microsoft.com/windows/win32/winauto/uiauto-securityoverview) set to true, which will also avoid the requirement of KBM having to run as administrator to intercept key events when an elevated window is in focus. The UIAccess flag has many constraints such as it must be a signed executable and must be located in a protected path like Program Files. Since KBM currently runs out of the runner process, it would make more sense to do this work after KBM is moved to a separate executable, and it could be enabled by a separate toggle in settings only if PowerToys is installed in Program Files. [This comment](https://github.com/microsoft/PowerToys/issues/3192#issuecomment-646323661) has more details on this approach and (this)[https://github.com/microsoft/PowerToys/issues/3255] is the tracking issue. ## Other remapping approaches Other approaches for remapping which were deprioritized are: ### Registry approach -This method is used by [SharpKeys](https://github.com/randyrants/sharpkeys) and involves using the [Microsoft Keyboard Scancode mapper registry key](https://github.com/randyrants/sharpkeys) to remap keys based on their scan codes. This has the advantage of being applied in all scenarios and not facing any elevation or UAC issues, however the disadvantages are that for modifying the settings the process must run elevated (as it modifies HKLM registry) and it requires a reboot to get applied. Another issue which is an advantage/disadvantage for users is that the process does not need to be running, so the remaps are applied all the time, including at the password prompt on logging into the user's Windows account, which could get a user stuck if they orphaned a key in their password. This registry doesn't have any support for remapping shortcuts either, so the hook approach was prioritized over this. +This method is used by [SharpKeys](https://github.com/randyrants/sharpkeys) and involves using the [Microsoft Keyboard Scancode mapper registry key](https://github.com/randyrants/sharpkeys) to remap keys based on their scan codes. This has the advantage of being applied in all scenarios and not facing any elevation or UAC issues, however the disadvantages are that for modifying the settings, the process must run elevated (as it modifies HKLM registry) and it requires a reboot to get applied. Another issue which is an advantage/disadvantage for users is that the process does not need to be running, so the remaps are applied all the time, including at the password prompt on logging into the user's Windows account, which could get a user stuck if they orphaned a key in their password. This registry doesn't have any support for remapping shortcuts either, so the hook approach was prioritized over this. ### Driver approach Using a driver approach has the benefit of not depending on precedence orders as KBM could always run before low level hooks, and it also has the benefit of differentiating between different keyboards, allowing [multi keyboard-specific remaps](https://github.com/microsoft/PowerToys/issues/1460). The disadvantages are however that any bug or crash could have system level consequences. [Interception](https://github.com/oblitum/Interception) is an open source driver that could be used for implementing this. The approach was deprioritized due to the potential side effects. @@ -191,7 +191,7 @@ Using a driver approach has the benefit of not depending on precedence orders as ## Telemetry Keyboard Manager emits the following telemetry events (implemented in [trace.h](https://github.com/microsoft/PowerToys/blob/main/src/modules/keyboardmanager/common/trace.h) and [trace.cpp](https://github.com/microsoft/PowerToys/blob/main/src/modules/keyboardmanager/common/trace.cpp)): - **`KeyboardManager_EnableKeyboardManager`:** Logs a `boolean` value storing the KBM toggle state. It is logged whenever KBM is enabled or disabled (emitted in [`enable`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/dll/dllmain.cpp#L305-L306) and [`disable`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/dll/dllmain.cpp#L315-L316)). -- **`KeyboardManager_KeyRemapCount`:** Logs the number of key to key and key to shortcut remaps (i.e. all the remaps on the Remap a key window). This gets logged on saving new settings in the Remap a key window (emitted at [the end of `ApplySingleKeyRemappings`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/LoadingAndSavingRemappingHelper.cpp#L159-L163)). -- **`KeyboardManager_OSLevelShortcutRemapCount`:** Logs the number of global shortcut to shortcut and shortcut to key remaps. This gets logged on saving new settings in the Remap a shortcut window (emitted at [the end of `ApplyShortcutRemappings`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/LoadingAndSavingRemappingHelper.cpp#L220)). -- **`KeyboardManager_AppSpecificShortcutRemapCount`:** Logs the number of app-specific shortcut to shortcut and shortcut to key remaps. This gets logged on saving new settings in the Remap a shortcut window (emitted [after calling `OSLevelShortcutRemapCount` in `ApplyShortcutRemappings`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/LoadingAndSavingRemappingHelper.cpp#L221)). +- **`KeyboardManager_KeyRemapCount`:** Logs the number of key to key and key to shortcut remaps (i.e. all the remaps on the "Remap a key" window). This gets logged on saving new settings in the "Remap a key" window (emitted at [the end of `ApplySingleKeyRemappings`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/LoadingAndSavingRemappingHelper.cpp#L159-L163)). +- **`KeyboardManager_OSLevelShortcutRemapCount`:** Logs the number of global shortcut to shortcut and shortcut to key remaps. This gets logged on saving new settings in the "Remap a shortcut" window (emitted at [the end of `ApplyShortcutRemappings`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/LoadingAndSavingRemappingHelper.cpp#L220)). +- **`KeyboardManager_AppSpecificShortcutRemapCount`:** Logs the number of app-specific shortcut to shortcut and shortcut to key remaps. This gets logged on saving new settings in the "Remap a shortcut" window (emitted [after calling `OSLevelShortcutRemapCount` in `ApplyShortcutRemappings`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/LoadingAndSavingRemappingHelper.cpp#L221)). - **`KeyboardManager_Error`:** Logs the occurrence of an error in KBM with the name of the method, error code and the corresponding error message. This is currently used only for logging `SetWindowsHookEx` failures (emitted [at the end of `start_lowlevel_keyboard_hook`](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/dll/dllmain.cpp#L364-L369)). diff --git a/doc/devdocs/modules/keyboardmanager/keyboardmanagerui.md b/doc/devdocs/modules/keyboardmanager/keyboardmanagerui.md index a1e67c2920..c6912d330d 100644 --- a/doc/devdocs/modules/keyboardmanager/keyboardmanagerui.md +++ b/doc/devdocs/modules/keyboardmanager/keyboardmanagerui.md @@ -32,7 +32,7 @@ The KBM UI consists of a [`Grid` with several columns](https://github.com/micros When the UI windows are activated the `KeyboardManagerState` object [sets the `UIState` variable](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/EditKeyboardWindow.cpp#L251-L252) which is used for distinguishing if the UI is up from the keyboard hook thread. The [states are also updated](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/SingleKeyRemapControl.cpp#L53) on opening and closing the Type window. -Clicking the Type Button [opens a content dialog](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/SingleKeyRemapControl.cpp#L206-L380) which registers key delays using the [`KeyDelay` class](keyboardmanagercommon.md#KeyDelay) for Enter and Esc keys and sets the UI states such that when a key event occurs the TextBlocks on the ContentDialog are updated accordingly. On accepting the dialog the selected keys are copied into the ComboBoxes from the TextBlocks, and on closing the window the key delays are unregistered and UI states are reset. +Clicking the Type Button [opens a content dialog](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/SingleKeyRemapControl.cpp#L206-L380) which registers key delays using the [`KeyDelay` class](keyboardmanagercommon.md#KeyDelay) for Enter and Esc keys and sets the UI states such that when a key event occurs the TextBlocks on the ContentDialog are updated accordingly. On accepting the dialog, the selected keys are copied into the ComboBoxes from the TextBlocks, and on closing the window, the key delays are unregistered and UI states are reset. Since ComboBoxes are added dynamically, handlers have been added which [update the accessible names for these controls](https://github.com/microsoft/PowerToys/blob/b80578b1b9a4b24c9945bddac33c771204280107/src/modules/keyboardmanager/ui/KeyDropDownControl.cpp#L69-L74), which get executed whenever a drop down is added or removed. diff --git a/doc/devdocs/modules/launcher/plugins/community.valuegenerator.md b/doc/devdocs/modules/launcher/plugins/community.valuegenerator.md index 9b94ae78f2..3c6969ae30 100644 --- a/doc/devdocs/modules/launcher/plugins/community.valuegenerator.md +++ b/doc/devdocs/modules/launcher/plugins/community.valuegenerator.md @@ -38,7 +38,7 @@ The Value Generator plugin is used to generate hashes for strings, to calculate - [`UuidCreateSequential`](https://learn.microsoft.com/en-us/windows/win32/api/rpcdce/nf-rpcdce-uuidcreatesequential) for version 1 - `System.Guid.NewGuid()` for version 4 - `System.Guid.CreateVersion7()` for version 7 -- Versions 3 and 5 take two parameters, a namespace and a name +- Versions 3 and 5 take two parameters: a namespace and a name - The namespace must be a valid GUID or one of the [predefined ones](https://datatracker.ietf.org/doc/html/rfc4122#appendix-C) - The `PredefinedNamespaces` dictionary contains aliases for the predefined namespaces - The name can be any string @@ -72,10 +72,10 @@ The Value Generator plugin is used to generate hashes for strings, to calculate ### [`InputParser`](/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator/InputParser.cs) - It is responsible only for parsing the query from the user - Based on the user query, the `ParseInput()` method must return an object that implements the `IComputeRequest` interface or it must throw one of `FormatException` or `ArgumentException` -- Throwing an `ArgumentException` should signal the fact the query contains a mistake that the user can fix (eg. an unsupported hash function, an invalid GUID version, an invalid namespace, etc) +- Throwing an `ArgumentException` should signal the fact that the query contains a mistake that the user can fix (e.g. an unsupported hash function, an invalid GUID version, an invalid namespace, etc.) > The error message will be shown to the user and no log message will be created - Throwing a `FormatException` should signal either: - - that the query may become valid, and so it does not make sense to show an error just yet (eg. the query does not contain a request yet, a hash request without a string to hash) + - that the query may become valid, and so it does not make sense to show an error just yet (e.g. the query does not contain a request yet, a hash request without a string to hash) - that the query is completely invalid > The error message will not be shown to the user but a log message will be created diff --git a/doc/devdocs/modules/launcher/plugins/history.md b/doc/devdocs/modules/launcher/plugins/history.md index 0855b39157..662a3fd93d 100644 --- a/doc/devdocs/modules/launcher/plugins/history.md +++ b/doc/devdocs/modules/launcher/plugins/history.md @@ -6,7 +6,7 @@ The History Plugin allows users to search or display results they have used (sel The plugin uses data that was already being captured which is, what results were clicked, and how many times. We do add a little more data to this set now. When this plugin is queried, it creates results based on this previously selected results data. -In order to make sure selected results in the history are still valid, we re-query the plugin the relevant plug using the PluginManager. If there are no results, +In order to make sure selected results in the history are still valid, we re-query the relevant plugin using the PluginManager. If there are no results, this history item is not included. This usually means that the result is no longer valid. For instance, if a file was deleted, but it's still in the selected history we don't want to show it as a selectable result. diff --git a/doc/devdocs/modules/launcher/plugins/onenote.md b/doc/devdocs/modules/launcher/plugins/onenote.md index 853a1d94f6..7e240de2a1 100644 --- a/doc/devdocs/modules/launcher/plugins/onenote.md +++ b/doc/devdocs/modules/launcher/plugins/onenote.md @@ -9,7 +9,7 @@ The code itself is very simple, basically just a call into OneNote interop via t var pages = OneNoteProvider.FindPages(query.Search); ``` -The query results will be cached for 1 day, and if cached results are found they'll be returned in the initial `Query()` call, otherwise OneNote itself will be queried in the `delayedExecution:true` overload. +The query results will be cached for 1 day, and if cached results are found they'll be returned in the initial `Query()` call; otherwise, OneNote itself will be queried in the `delayedExecution:true` overload. If the user actions on a result, it'll open it in the OneNote app, and restore and/or focus the app as well if necessary. diff --git a/doc/devdocs/modules/launcher/plugins/timedate.md b/doc/devdocs/modules/launcher/plugins/timedate.md index d7dece2ff0..0e1a7ea575 100644 --- a/doc/devdocs/modules/launcher/plugins/timedate.md +++ b/doc/devdocs/modules/launcher/plugins/timedate.md @@ -16,49 +16,70 @@ The 'Time and Date' plugin shows the date and time in different formats. For the ### Available formats **Remarks** -- The following formats requires a prefix in the query: +- The following formats requires a prefix in the query when using them as date input: - Unix Timestamp: `u` - Unix Timestamp in milliseconds: `ums` - Windows file time: `ft` + - OLE Automation date: `oa` + - Excel 1900 date value: `exc` + - Excel 1904 date value: `exf` - On invalid number inputs we show a warning that tells the user which prefixes are allowed/required. **List of available formats** The following formats are currently available: -| Format | Example (Based on default settings) | As result | As input | +| Format | Example (Based on default settings) | As result | As input | Result as custom format only |--------------|-----------|------------|------------| -| Time | 5:10 PM | x | x | -| Date | 3/5/2022 | x | x | -| Now | 3/5/2022 5:10 PM | x | x | -| Time UTC | 4:10 PM | x | x | -| Now UTC | 3/5/2022 4:10 PM | x | x | -| Unix Timestamp | 1646496622 | x | x | -| Unix Timestamp in milliseconds | 1646496622500 | x | x | -| Hour | 10 | x | | -| Minute | 30 | x | | -| Second | 45 | x | | -| Millisecond | 678 | x | | -| Day (Week day) | Saturday | x | | -| Day of the week | 6 | x | | -| Day of the month | 5 | x | | -| Day of the year | 64 | x | | -| Week of the month | 1 | x | | -| Week of the year (Calendar week, Week number) | 10 | x | | -| Month | March | x | | -| Month of the year | 3 | x | | -| Month and day | March 7 | x | x | -| Year | 2022 | x | | -| Era | AD | x | | -| Era abbreviation | A | x | | -| Month and year | March 2022 | x | x | -| Windows file time (Int64 number) | 637820976123938199 | x | x | -| Universal time format: YYYY-MM-DD hh:mm:ss| 2022-03-05 16:20:12Z | x | x | -| ISO 8601 | 2022-03-05T17:23:04 | x | x | -| ISO 8601 UTC | 2022-03-05T16:23:04 | x | x | -| ISO 8601 with time zone | 2022-03-05T17:23:04+01:00 | x | x | -| ISO 8601 UTC with time zone | 2022-03-05T16:23:04Z | x | x | -| RFC1123 | Sat, 05 Mar 2022 16:23:04 GMT | x | x | +| Time | 5:10 PM | x | x | | +| Date | 3/5/2022 | x | x | | +| Now | 3/5/2022 5:10 PM | x | x | | +| Time UTC | 4:10 PM | x | x | | +| Now UTC | 3/5/2022 4:10 PM | x | x | | +| Unix Timestamp | 1646496622 | x | x | | +| Unix Timestamp in milliseconds | 1646496622500 | x | x | | +| Hour | 10 | x | | | +| Minute | 30 | x | | | +| Second | 45 | x | | | +| Millisecond | 678 | x | | | +| Day (Week day) | Saturday | x | | | +| Day of the week | 6 | x | | | +| Day of the month | 5 | x | | | +| Day of the year | 64 | x | | | +| Week of the month | 1 | x | | | +| Week of the year (Calendar week, Week number) | 10 | x | | | +| Month | March | x | | | +| Month of the year | 3 | x | | | +| Month and day | March 7 | x | x | | +| Year | 2022 | x | | | +| Era | AD | x | | | +| Era abbreviation | A | x | | | +| Month and year | March 2022 | x | x | | +| Windows file time (Int64 number) | 637820976123938199 | x | x | | +| Universal time format: YYYY-MM-DD hh:mm:ss| 2022-03-05 16:20:12Z | x | x | | +| ISO 8601 | 2022-03-05T17:23:04 | x | x | | +| ISO 8601 UTC | 2022-03-05T16:23:04 | x | x | | +| ISO 8601 with time zone | 2022-03-05T17:23:04+01:00 | x | x | | +| ISO 8601 UTC with time zone | 2022-03-05T16:23:04Z | x | x | | +| RFC1123 | Sat, 05 Mar 2022 16:23:04 GMT | x | x | | +| OLE Automation date | 45723.44143763889 | | x | x | +| Excel's 1900 date value | 45723.44143763889 | | x | x | +| Excel's 1904 date value | 44261.44143763889 | | x | x | + +**Custom format definition** +The user can create its own formats. One per line in the settings text box. The format of each line is `<name>=<syntax pattern>`. +If the syntax pattern starting with `UTC:` then we use the UTC time instead of the local time. +As syntax pattern the pattern from `DateTime.ToString()` and the following custom pattern are available: +- DOW: Number of the day in the week. +- WOM: Number of week in the month. +- WOY: Number of the week in the year. +- EAB: Era abbreviation. +- WFT: Windows file time. +- UXT: Unix time stamp. +- UMS: Unix time stamp in milliseconds. +- OAD: OLE Automation date. +- EXC: Excel's 1900 based date value. +- EXF: Excel's 1904 based date value. ### Add new formats - To add a new formats you have to add them to the method `GetList()` of the [`AvailableResultsList`](/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/AvailableResultsList.cs) class. @@ -73,13 +94,13 @@ The following formats are currently available: | Key | Type | Default value | Name | Description | |--------------|--------------|-----------|------------|------------| - | `CalendarFirstWeekRule` | Combo box | `-1` (Use system settings) | First week of the year | Configure the calendar rule for the first week of the year. | - | `FirstDayOfWeek` | Combo box | `-1` (Use system settings) | First day of the week | | | `OnlyDateTimeNowGlobal` | Checkbox | `true` | Show only 'Time', 'Date', and 'Now' result for system time on global queries | Regardless of this setting, for global queries the first word of the query has to be a complete match. | | `TimeWithSeconds` | Checkbox | `false` | Show time with seconds | This setting applies to the 'Time' and 'Now' result. | | `DateWithWeekday` | Checkbox | `false` | Show date with weekday and name of month | This setting applies to the 'Date' and 'Now' result. | | `HideNumberMessageOnGlobalQuery` | Checkbox | `false` | Hide 'Invalid number input' error message on global queries | | - + | `CalendarFirstWeekRule` | Combo box | `-1` (Use system settings) | First week of the year | Configure the calendar rule for the first week of the year. | + | `FirstDayOfWeek` | Combo box | `-1` (Use system settings) | First day of the week | | + | `CustomFormats` | Multiline text box | `string.Empty` | Custom formats | Use date and time string format syntax and DOW (Day of Week), WOM (Week of Month), WOY (Week of the year), EAB (Era abbreviation), WFT (Windows File Time), UXT (Unix Time), UMS (Unix Time in milliseconds), OAD (OLE Automation date), EXC (Excel's 1900 based date value), EXF (Excel's 1904 based date value). If the format starts with UTC:, then Universal Time (UTC) is used. (Use a backslash to escape format sequences and the backslash character as text.) | ## Classes diff --git a/doc/devdocs/modules/launcher/plugins/windowwalker.md b/doc/devdocs/modules/launcher/plugins/windowwalker.md index c451d8103a..7f309f23c9 100644 --- a/doc/devdocs/modules/launcher/plugins/windowwalker.md +++ b/doc/devdocs/modules/launcher/plugins/windowwalker.md @@ -8,7 +8,7 @@ The user can switch to the found windows, close them or kill their process. ## Remarks ### UWP Apps -- The process of an UWP app can't be detected correctly for windows that are minimized while searching. At this time they are assigned to the generic process `ApplicationFrameHost.exe`. If the user searches for such an window while it is not minimized, then the process gets assigned correctly/updated. +- The process of an UWP app can't be detected correctly for windows that are minimized while searching. At this time they are assigned to the generic process `ApplicationFrameHost.exe`. If the user searches for such a window while it is not minimized, then the process gets assigned correctly/updated. ### Killing processes - Killing the Explorer process is only allowed, if each folder window is running in its own process. (See section `File Explorer setting` below.) @@ -17,7 +17,7 @@ The user can switch to the found windows, close them or kill their process. - Windows of UWP apps don't know their process, until they are searched in non-minimized state. ### File Explorer setting -- To kill the Process of an Explorer window, each window has to run in a separate process. Otherwise the process is the same one as the shell process and killing the shell process will crash the shell (Windows ui). +- To kill the Process of an Explorer window, each window has to run in a separate process. Otherwise, the process is the same one as the shell process and killing the shell process will crash the shell (Windows ui). - To enable this behavior the setting `Launch folder windows in a separate process` under `Folder Options > View` has to be enabled. - From PowerToys Run you can open the `Folder options` dialog by clicking the information message in the search results. The information message is only shown when searching with action keyword for explorer windows and can be hidden in the plugin settings. - Note: The folder option/process is evaluated in real time. After changing the setting it is enough to search again for the windows. diff --git a/doc/devdocs/modules/lightswitch.md b/doc/devdocs/modules/lightswitch.md new file mode 100644 index 0000000000..eb5e07aea6 --- /dev/null +++ b/doc/devdocs/modules/lightswitch.md @@ -0,0 +1,110 @@ +# Light Switch + +[Public Overview – Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/light-switch) + +## Quick Links + +* [All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue%20state%3Aopen%20label%3AProduct-LightSwitch) +* [Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue%20state%3Aopen%20label%3AProduct-LightSwitch%20label%3AIssue-Bug) +* [Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-LightSwitch) + +## Overview + +The **Light Switch** module lets users automatically transition between light and dark mode using a timed schedule or a keyboard shortcut. + +## Features + +* Set custom times to start and stop dark mode. +* Use geolocation to determine local sunrise and sunset times. +* Apply offsets in sunrise mode (e.g., 15 minutes before sunset). +* Quickly toggle between modes with a keyboard shortcut (`Ctrl+Shift+Win+D` by default). +* Choose whether theme changes apply to: + + * Apps only + * System only + * Both apps and system + +## Architecture + +### Main Components + +* **Shortcut/Hotkey** + Listens for a hotkey event. Calling `onHotkey()` flips the theme flags. + + > **Note:** Using the shortcut overrides the current schedule until the next transition event. + +* **LightSwitchService.cpp** + is the heart beat of the module. Controls ticking every minute and depending on user actions (manual override, settings changing, etc) triggers the state manager to perform the corresponding operation. + +* **LightSwitchStateManager.cpp** + handles updating the state based on the signals sent by LightSwitchService. + +* **SettingsXAML/LightSwitch** + Provides the settings UI for configuring schedules, syncing location, and customizing shortcuts. + +* **Settings.UI/ViewModels/LightSwitchViewModel.cs** + Handles updates to the settings file and communicates changes to the front end. + +* **modules/LightSwitch/Tests** + Contains UI tests that verify interactions between the settings UI, system state, and `settings.json`. + +### Data Flow + +1. User configures settings in the UI (default: manual mode, light mode from 06:00–18:00). +2. Every minute, the service checks the time. + + * If it’s not a threshold, the service sleeps until the next minute. + * If it matches a threshold, the service applies the theme based on settings and returns to sleep. +3. At **midnight**, when in *Sunrise to Sunset* mode, the service updates daily sunrise and sunset times. +4. If the machine was asleep during a scheduled event, the service applies the correct settings at the next check. + +## User Interface + +The module’s settings are exposed in the PowerToys Settings UI. Options include: + +* Shortcut customization +* Mode selection (Manual or Sunrise to Sunset) +* Manual start/stop times (manual mode only) +* Automatic sunrise/sunset calculation (location-based) +* Time offsets (sunrise mode) +* Target scope (system, apps, or both) + +## Development Environment Setup + +### Prerequisites + +* Visual Studio 2019 or later +* Windows 10 SDK +* PowerToys repository cloned from GitHub + +### Building and Testing + +1. Clone the repo: + + ```sh + git clone https://github.com/microsoft/PowerToys.git + ``` +2. Initialize submodules: + + ```sh + git submodule update --init --recursive + ``` +3. Build the solution: + + ```sh + msbuild -restore -p:RestorePackagesConfig=true -p:Platform=ARM64 -m PowerToys.slnx + ``` + + > Note: This may take some time. +4. Set `runner` as the startup project and press **F5**. +5. Enable Light Switch in PowerToys Settings. +6. To debug the service: + + * Press `Ctrl+Alt+P` or go to **Debug > Attach to Process**. + * Select `LightSwitchService.exe` and click **Attach**. + * You can now set breakpoints in the service files. +7. To debug the Settings UI: + + * Set the startup project to `PowerToys.Settings` and press **F5**. + * Note: Light Switch settings will not persist in this mode (they depend on the service executable). + * Alternatively, you can attach `PowerToys.Settings.exe` to the debugger while `runner` is running to test the full flow with breakpoints. diff --git a/doc/devdocs/modules/mouseutils/findmymouse.md b/doc/devdocs/modules/mouseutils/findmymouse.md new file mode 100644 index 0000000000..e4bc51a513 --- /dev/null +++ b/doc/devdocs/modules/mouseutils/findmymouse.md @@ -0,0 +1,95 @@ +# Find My Mouse + +Find My Mouse is a utility that helps users locate their mouse pointer by creating a spotlight effect when activated. It is based on Raymond Chen's SuperSonar utility. + +## Implementation + +Find My Mouse displays a spotlight effect centered on the cursor location when activated via a keyboard shortcut (typically a double-press of the Ctrl key). + +### Key Files +- `src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp` - Contains the main implementation +- Key function: `s_WndProc` - Handles window messages for the utility + +### Enabling Process + +When the utility is enabled: + +1. A background thread is created to run the Find My Mouse logic asynchronously: + ```cpp + // Enable the PowerToy + virtual void enable() + { + m_enabled = true; // Mark the module as enabled + Trace::EnableFindMyMouse(true); // Enable telemetry + std::thread([=]() { FindMyMouseMain(m_hModule, m_findMyMouseSettings); }).detach(); // Run main logic in background + } + ``` + +2. The `CompositionSpotlight` instance is initialized with user settings: + ```cpp + CompositionSpotlight sonar; + sonar.ApplySettings(settings, false); // Apply settings + if (!sonar.Initialize(hinst)) + { + Logger::error("Couldn't initialize a sonar instance."); + return 0; + } + + m_sonar = &sonar; + ``` + +3. The utility listens for raw input events using `WM_INPUT`, which provides more precise and responsive input detection than standard mouse events. + +### Activation Process + +The activation process works as follows: + +1. **Keyboard Hook Detects Shortcut** + - A global low-level keyboard hook is set up during initialization + - The hook monitors for the specific activation pattern (double Ctrl press) + - Once matched, it sends a `WM_PRIV_SHORTCUT` message to the sonar window: + ```cpp + virtual void OnHotkeyEx() override + { + Logger::trace("OnHotkeyEx()"); + HWND hwnd = GetSonarHwnd(); + if (hwnd != nullptr) + { + PostMessageW(hwnd, WM_PRIV_SHORTCUT, NULL, NULL); + } + } + ``` + +2. **Message Handler Triggers Action** + - The custom message is routed to `BaseWndProc()` + - The handler toggles the sonar animation: + ```cpp + if (message == WM_PRIV_SHORTCUT) + { + if (m_sonarStart == NoSonar) + StartSonar(); // Trigger sonar animation + else + StopSonar(); // Cancel if already running + } + ``` + +3. **Sonar Animation** + - `StartSonar()` uses `CompositionSpotlight` to display a highlight (ripple/pulse) centered on the mouse pointer + - The animation is temporary and fades automatically or can be cancelled by user input + +### Event Handling + +The Find My Mouse utility handles several types of events: + +- **Mouse Events**: Trigger sonar animations (e.g., after a shake or shortcut) +- **Keyboard Events**: May cancel or toggle the effect +- **Custom Shortcut Messages**: Handled to allow toggling Find My Mouse using a user-defined hotkey + +When the main window receives a `WM_DESTROY` message (on shutdown or disable), the sonar instance is properly cleaned up, and the message loop ends gracefully. + +## Debugging + +To debug Find My Mouse: +- Attach to the PowerToys Runner process directly +- Set breakpoints in the `FindMyMouse.cpp` file +- When debugging the spotlight effect, visual artifacts may occur due to the debugger's overhead diff --git a/doc/devdocs/modules/mouseutils/mousehighlighter.md b/doc/devdocs/modules/mouseutils/mousehighlighter.md new file mode 100644 index 0000000000..c9413f1bc8 --- /dev/null +++ b/doc/devdocs/modules/mouseutils/mousehighlighter.md @@ -0,0 +1,92 @@ +# Mouse Highlighter + +Mouse Highlighter is a utility that visualizes mouse clicks by displaying a highlight effect around the cursor when clicked. + +## Implementation + +Mouse Highlighter runs within the PowerToys Runner process and draws visual indicators (typically circles) around the mouse cursor when the user clicks. + +### Key Files +- `src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.cpp` - Contains the main implementation +- Key function: `WndProc` - Handles window messages and mouse events + +### Enabling Process + +When the utility is enabled: + +1. A background thread is created to run the mouse highlighter logic asynchronously: + ```cpp + std::thread([=]() { MouseHighlighterMain(m_hModule, m_highlightSettings); }).detach(); + ``` + +2. The Highlighter instance is initialized and configured with user settings: + ```cpp + Highlighter highlighter; + Highlighter::instance = &highlighter; + highlighter.ApplySettings(settings); + highlighter.MyRegisterClass(hInstance); + ``` + +3. A highlighter window is created: + ```cpp + instance->CreateHighlighter(); + ``` + +4. The utility: + - Registers a custom window class + - Creates a transparent window for drawing visuals + - Handles the `WM_CREATE` message to initialize the Windows Composition API (Compositor, visuals, and target) + +### Activation Process + +The activation process works as follows: + +1. **Shortcut Detection** + - The system detects when the activation shortcut is pressed + - A global hotkey listener (registered with `RegisterHotKey` or similar hook) detects the shortcut + +2. **Message Transmission** + - A message (like `WM_SWITCH_ACTIVATION_MODE`) is sent to the highlighter window via `PostMessage()` or `SendMessage()` + +3. **Window Procedure Handling** + - The `WndProc` of the highlighter window receives the message and toggles between start and stop drawing modes: + ```cpp + case WM_SWITCH_ACTIVATION_MODE: + if (instance->m_visible) + instance->StopDrawing(); + else + instance->StartDrawing(); + ``` + +4. **Drawing Activation** + - If turning ON, `StartDrawing()` is called, which: + - Moves the highlighter window to the topmost position + - Slightly offsets the size to avoid transparency bugs + - Shows the transparent drawing window + - Hooks into global mouse events + - Starts drawing visual feedback around the mouse + + - If turning OFF, `StopDrawing()` is called, which: + - Hides the drawing window + - Removes the mouse hook + - Stops rendering highlighter visuals + +### Drawing Process + +When the mouse highlighter is active: +1. A low-level mouse hook detects mouse button events +2. On click, the highlighter draws a circle (or other configured visual) at the cursor position +3. The visual effect fades over time according to user settings +4. Each click can be configured to show different colors based on the mouse button used + +## Debugging + +To debug Mouse Highlighter: +- Attach to the PowerToys Runner process directly +- Set breakpoints in the `MouseHighlighter.cpp` file +- Be aware that visual effects may appear different or stuttery during debugging due to the debugger's overhead + +## Known Issues + +- There is a reported bug where the highlight color stays on after toggling opacity to 0 +- This issue has been present for more than six months and can still be reproduced in recent PowerToys releases diff --git a/doc/devdocs/modules/mouseutils/mousejump.md b/doc/devdocs/modules/mouseutils/mousejump.md new file mode 100644 index 0000000000..6a362d5110 --- /dev/null +++ b/doc/devdocs/modules/mouseutils/mousejump.md @@ -0,0 +1,90 @@ +# Mouse Jump + +Mouse Jump is a utility that allows users to quickly move their cursor to any location on screen using a grid-based overlay interface. + +## Implementation + +Unlike the other Mouse Utilities that run within the PowerToys Runner process, Mouse Jump operates as a separate process that communicates with the Runner via events. + +### Key Files +- `src/modules/MouseUtils/MouseJump` - Contains the Runner interface for Mouse Jump +- `src/modules/MouseUtils/MouseJumpUI` - Contains the UI implementation +- `src/modules/MouseUtils/MouseJumpUI/MainForm.cs` - Main UI form implementation +- `src/modules/MouseUtils/MouseJump.Common` - Shared code between the Runner and UI components + +### Enabling Process + +When the utility is enabled: + +1. A separate UI process is launched for Mouse Jump: + ```cpp + void launch_process() + { + Logger::trace(L"Starting MouseJump process"); + unsigned long powertoys_pid = GetCurrentProcessId(); + + std::wstring executable_args = L""; + executable_args.append(std::to_wstring(powertoys_pid)); + + SHELLEXECUTEINFOW sei{ sizeof(sei) }; + sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI }; + sei.lpFile = L"PowerToys.MouseJumpUI.exe"; + sei.nShow = SW_SHOWNORMAL; + sei.lpParameters = executable_args.data(); + if (ShellExecuteExW(&sei)) + { + Logger::trace("Successfully started the Mouse Jump process"); + } + else + { + Logger::error(L"Mouse Jump failed to start. {}", get_last_error_or_default(GetLastError())); + } + + m_hProcess = sei.hProcess; + } + ``` + +2. The Runner creates shared events for communication with the UI process: + ```cpp + m_hInvokeEvent = CreateDefaultEvent(CommonSharedConstants::MOUSE_JUMP_SHOW_PREVIEW_EVENT); + m_hTerminateEvent = CreateDefaultEvent(CommonSharedConstants::TERMINATE_MOUSE_JUMP_SHARED_EVENT); + ``` + +### Activation Process + +The activation process works as follows: + +1. **Shortcut Detection** + - When the activation shortcut is pressed, the Runner signals the shared event `MOUSE_JUMP_SHOW_PREVIEW_EVENT` + +2. **UI Display** + - The MouseJumpUI process listens for this event and displays a screen overlay when triggered + - The overlay shows a grid or other visual aid to help select a destination point + +3. **Mouse Movement** + - User selects a destination point on the overlay + - The UI process moves the mouse cursor to the selected position + +4. **Termination** + - When the utility needs to be disabled or PowerToys is shutting down, the Runner signals the `TERMINATE_MOUSE_JUMP_SHARED_EVENT` + - The UI process responds by cleaning up and exiting + +### User Interface + +The Mouse Jump UI is implemented in C# using Windows Forms: +- Displays a semi-transparent overlay over the entire screen +- May include grid lines, quadrant divisions, or other visual aids to help with precision selection +- Captures mouse and keyboard input to allow for selection and cancellation +- Moves the mouse cursor to the selected location upon confirmation + +## Debugging + +To debug Mouse Jump: + +1. Start by debugging the Runner process directly +2. Then attach the debugger to the MouseJumpUI process when it launches +3. Note: Debugging MouseJumpUI directly is challenging because it requires the Runner's process ID to be passed as a parameter at launch + +## Community Contributions + +Mouse Jump was initially contributed by Michael Clayton (@mikeclayton) and is based on his FancyMouse utility. diff --git a/doc/devdocs/modules/mouseutils/mousepointer.md b/doc/devdocs/modules/mouseutils/mousepointer.md new file mode 100644 index 0000000000..37ae7ecf33 --- /dev/null +++ b/doc/devdocs/modules/mouseutils/mousepointer.md @@ -0,0 +1,114 @@ +# Mouse Pointer Crosshairs + +Mouse Pointer Crosshairs is a utility that displays horizontal and vertical lines that intersect at the mouse cursor position, making it easier to track the cursor location on screen. + +## Implementation + +Mouse Pointer Crosshairs runs within the PowerToys Runner process and draws crosshair lines that follow the cursor in real-time. + +### Key Files +- `src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp` - Contains the main implementation +- Key function: `WndProc` - Handles window messages and mouse events + +### Enabling Process + +When the utility is enabled: + +1. A background thread is created to run the crosshairs logic asynchronously: + ```cpp + std::thread([=]() { InclusiveCrosshairsMain(hInstance, settings); }).detach(); + ``` + +2. The InclusiveCrosshairs instance is initialized and configured with user settings: + ```cpp + InclusiveCrosshairs crosshairs; + InclusiveCrosshairs::instance = &crosshairs; + crosshairs.ApplySettings(settings, false); + crosshairs.MyRegisterClass(hInstance); + ``` + +3. The utility: + - Creates the crosshairs visuals using Windows Composition API inside `CreateInclusiveCrosshairs()` + - Handles the `WM_CREATE` message to initialize the Windows Composition API (Compositor, visuals, and target) + - Creates a transparent, layered window for drawing the crosshairs with specific extended window styles (e.g., `WS_EX_LAYERED`, `WS_EX_TRANSPARENT`) + +### Activation Process + +The activation process works as follows: + +1. **Shortcut Detection** + - When the activation shortcut is pressed, the window procedure (`WndProc`) receives a custom message `WM_SWITCH_ACTIVATION_MODE` + +2. **Toggle Drawing State** + ```cpp + case WM_SWITCH_ACTIVATION_MODE: + if (instance->m_drawing) + { + instance->StopDrawing(); + } + else + { + instance->StartDrawing(); + } + break; + ``` + +3. **Start Drawing Function** + - The `StartDrawing()` function is called to: + - Log the start of drawing + - Update the crosshairs position + - Check if the cursor should be auto-hidden, and set a timer for auto-hide if enabled + - Show the crosshairs window if the cursor is visible + - Set a low-level mouse hook to track mouse movements asynchronously + + ```cpp + void InclusiveCrosshairs::StartDrawing() + { + Logger::info("Start drawing crosshairs."); + UpdateCrosshairsPosition(); + + m_hiddenCursor = false; + if (m_crosshairs_auto_hide) + { + CURSORINFO cursorInfo{}; + cursorInfo.cbSize = sizeof(cursorInfo); + if (GetCursorInfo(&cursorInfo)) + { + m_hiddenCursor = !(cursorInfo.flags & CURSOR_SHOWING); + } + + SetAutoHideTimer(); + } + + if (!m_hiddenCursor) + { + ShowWindow(m_hwnd, SW_SHOWNOACTIVATE); + } + + m_drawing = true; + m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, m_hinstance, 0); + } + ``` + +4. **Stop Drawing Function** + - The `StopDrawing()` function is called to: + - Remove the mouse hook + - Kill the auto-hide timer + - Hide the crosshairs window + - Log the stop of drawing + +### Cursor Tracking + +While active, the utility: +1. Uses a low-level mouse hook (`WH_MOUSE_LL`) to track cursor movement +2. Updates crosshair positions in real-time as the mouse moves +3. Supports auto-hiding functionality when the cursor is inactive for a specified period + +## Debugging + +To debug Mouse Pointer Crosshairs: +- Attach to the PowerToys Runner process directly +- Set breakpoints in the `InclusiveCrosshairs.cpp` file +- Be aware that during debugging, moving the mouse may cause unexpected or "strange" visual behavior because: + - The mouse hook (`MouseHookProc`) updates the crosshairs position on every `WM_MOUSEMOVE` event + - This frequent update combined with the debugger's overhead or breakpoints can cause visual glitches or stutters diff --git a/doc/devdocs/modules/mouseutils/readme.md b/doc/devdocs/modules/mouseutils/readme.md new file mode 100644 index 0000000000..413e046367 --- /dev/null +++ b/doc/devdocs/modules/mouseutils/readme.md @@ -0,0 +1,129 @@ +# Mouse Utilities + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/mouse-utilities) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Mouse%20Utilities%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Mouse%20Utilities%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Mouse+Utilities%22) + +Mouse Utilities is a collection of tools designed to enhance mouse and cursor functionality on Windows. The module contains four sub-utilities that provide different mouse-related features. + +## Overview + +Mouse Utilities includes the following sub-modules: + +- **[Find My Mouse](findmymouse.md)**: Helps locate the mouse pointer by creating a visual spotlight effect when activated +- **[Mouse Highlighter](mousehighlighter.md)**: Visualizes mouse clicks with customizable highlights +- **[Mouse Jump](mousejump.md)**: Allows quick cursor movement to specific screen locations +- **[Mouse Pointer Crosshairs](mousepointer.md)**: Displays crosshair lines that follow the mouse cursor + +## Architecture + +Most of the sub-modules (Find My Mouse, Mouse Highlighter, and Mouse Pointer Crosshairs) run within the PowerToys Runner process as separate threads. Mouse Jump is more complex and runs as a separate process that communicates with the Runner via events. + +### Code Structure + +#### Settings UI +- [MouseUtilsPage.xaml](/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml) +- [MouseJumpPanel.xaml](/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml) +- [MouseJumpPanel.xaml.cs](/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml.cs) +- [MouseUtilsViewModel.cs](/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs) +- [MouseUtilsViewModel_MouseJump.cs](/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs) + +#### Runner and Module Implementation +- [FindMyMouse](/src/modules/MouseUtils/FindMyMouse) +- [MouseHighlighter](/src/modules/MouseUtils/MouseHighlighter) +- [MousePointerCrosshairs](/src/modules/MouseUtils/MousePointerCrosshairs) +- [MouseJump](/src/modules/MouseUtils/MouseJump) +- [MouseJumpUI](/src/modules/MouseUtils/MouseJumpUI) +- [MouseJump.Common](/src/modules/MouseUtils/MouseJump.Common) + +## Community Contributors + +- **Michael Clayton (@mikeclayton)**: Contributed the initial version of the Mouse Jump tool and several updates based on his FancyMouse utility +- **Raymond Chen (@oldnewthing)**: Find My Mouse is based on Raymond Chen's SuperSonar + +## Known Issues + +- Mouse Highlighter has a reported bug where the highlight color stays on after toggling opacity to 0 + +## UI Test Automation + +Mouse Utilities is currently undergoing a UI Test migration process to improve automated testing coverage. You can track the progress of this migration at: + +[Mouse Utils UI Test Migration Progress](https://github.com/microsoft/PowerToys/blob/feature/UITestAutomation/src/modules/MouseUtils/MouseUtils.UITests/Release-Test-Checklist-Migration-Progress.md) + +## See Also + +For more detailed implementation information, please refer to the individual utility documentation pages linked above. +#### Activation Process +1. A keyboard hook detects the activation shortcut (typically double-press of Ctrl) +2. A `WM_PRIV_SHORTCUT` message is sent to the sonar window +3. `StartSonar()` is called to display a spotlight animation centered on the mouse pointer +4. The animation automatically fades or can be cancelled by user input + +### Mouse Highlighter + +Mouse Highlighter visualizes mouse clicks by displaying a highlight effect around the cursor when clicked. + +#### Key Components +- Uses Windows Composition API for rendering +- Main implementation in `MouseHighlighter.cpp` +- Core logic handled by the `WndProc` function + +#### Activation Process +1. When activated, it creates a transparent overlay window +2. A mouse hook monitors for click events +3. On click detection, the highlighter draws a circle or other visual indicator +4. The highlight effect fades over time based on user settings + +### Mouse Pointer Crosshairs + +Displays horizontal and vertical lines that intersect at the mouse cursor position. + +#### Key Components +- Uses Windows Composition API for rendering +- Core implementation in `InclusiveCrosshairs.cpp` +- Main logic handled by the `WndProc` function + +#### Activation Process +1. Creates a transparent, layered window for drawing crosshairs +2. When activated via shortcut, calls `StartDrawing()` +3. Sets a low-level mouse hook to track cursor movement +4. Updates crosshairs position on every mouse movement +5. Includes auto-hide functionality for cursor inactivity + +### Mouse Jump + +Allows quick mouse cursor repositioning to any screen location through a grid-based UI. + +#### Key Components +- Runs as a separate process (`PowerToys.MouseJumpUI.exe`) +- Communicates with Runner process via events +- UI implemented in `MainForm.cs` + +#### Activation Process +1. When shortcut is pressed, Runner triggers the shared event `MOUSE_JUMP_SHOW_PREVIEW_EVENT` +2. The MouseJumpUI process displays a screen overlay +3. User selects a destination point on the overlay +4. Mouse cursor is moved to the selected position +5. The UI process can be terminated via the `TERMINATE_MOUSE_JUMP_SHARED_EVENT` + +## Debugging + +### Find My Mouse, Mouse Highlighter, and Mouse Pointer Crosshairs +- Debug by attaching to the Runner process directly +- Set breakpoints in the respective utility code files (e.g., `FindMyMouse.cpp`, `MouseHighlighter.cpp`, `InclusiveCrosshairs.cpp`) +- Call the respective utility by using the activation shortcut (e.g., double Ctrl press for Find My Mouse) +- During debugging, visual effects may appear glitchy due to the debugger's overhead + +### Mouse Jump +- Start by debugging the Runner process +- Then attach the debugger to the MouseJumpUI process +- Note: Debugging MouseJumpUI directly is challenging as it requires the Runner's process ID as a parameter + +## Known Issues + +- Mouse Highlighter has a reported bug where the highlight color stays on after toggling opacity to 0 diff --git a/doc/devdocs/modules/mousewithoutborders.md b/doc/devdocs/modules/mousewithoutborders.md index 3a8f981087..2cfb6c36d5 100644 --- a/doc/devdocs/modules/mousewithoutborders.md +++ b/doc/devdocs/modules/mousewithoutborders.md @@ -1,4 +1,13 @@ # Mouse Without Borders module + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/mouse-without-borders) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Mouse%20Without%20Borders%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Mouse%20Without%20Borders%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Mouse+Without+Borders%22) + This file contains the documentation for the Mouse Without Borders PowerToy module. ## Table of Contents: - [Mouse Without Borders module](#mouse-without-borders-module) diff --git a/doc/devdocs/modules/newplus.md b/doc/devdocs/modules/newplus.md new file mode 100644 index 0000000000..51aba0e913 --- /dev/null +++ b/doc/devdocs/modules/newplus.md @@ -0,0 +1,143 @@ +# NewPlus Module + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/newplus) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-New%2B)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3AProduct-New%2B)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-New%2B+) + +## Overview + +NewPlus is a PowerToys module that provides a context menu entry for creating new files directly from File Explorer. Unlike some other modules, NewPlus implements a different approach to context menu registration to avoid duplication issues in Windows 11. + +## Context Menu Implementation + +NewPlus implements two separate context menu handlers: + +1. **Windows 10 Handler** (`NewPlus.ShellExtension.win10.dll`) + - Implements "old-style" context menu handler for Windows 10 compatibility + - Not shown in Windows 11 (this is intentional and controlled by a condition in `QueryContextMenu`) + - Registered via registry keys + +2. **Windows 11 Handler** (`NewPlus.ShellExtension.dll`) + - Implemented as a sparse MSIX package for Windows 11's modern context menu + - Only registered and used on Windows 11 + +This implementation differs from some other modules like ImageResizer which register both handlers on Windows 11, resulting in duplicate menu entries. NewPlus uses selective registration to provide a cleaner user experience, though it can occasionally lead to issues if the Windows 11 handler fails to register properly. + +## Project Structure + +- **NewPlus.ShellExtension** - Windows 11 context menu handler implementation +- **NewPlus.ShellExtension.win10** - Windows 10 "old-style" context menu handler implementation + +## Debugging NewPlus Context Menu Handlers + +### Debugging the Windows 10 Handler + +1. Update the registry to point to your debug build: + ``` + Windows Registry Editor Version 5.00 + + [HKEY_CLASSES_ROOT\CLSID\{<NewPlus-CLSID>}] + @="PowerToys NewPlus Extension" + + [HKEY_CLASSES_ROOT\CLSID\{<NewPlus-CLSID>}\InprocServer32] + @="x:\GitHub\PowerToys\x64\Debug\PowerToys.NewPlusExt.win10.dll" + "ThreadingModel"="Apartment" + + [HKEY_CURRENT_USER\Software\Classes\Directory\Background\shellex\ContextMenuHandlers\NewPlus] + @="{<NewPlus-CLSID>}" + ``` + +2. Restart Explorer: + ``` + taskkill /f /im explorer.exe && start explorer.exe + ``` + +3. Attach the debugger to explorer.exe +4. Add breakpoints in the NewPlus code +5. Right-click in File Explorer to trigger the context menu handler + +### Debugging the Windows 11 Handler + +Debugging the Windows 11 handler requires signing the MSIX package: + +1. Build PowerToys to get the MSIX packages + +2. **Create certificate** (if you don't already have one): + ```powershell + New-SelfSignedCertificate -Subject "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" ` + -KeyUsage DigitalSignature ` + -Type CodeSigningCert ` + -FriendlyName "PowerToys SelfCodeSigning" ` + -CertStoreLocation "Cert:\CurrentUser\My" + ``` + +3. **Get the certificate thumbprint**: + ```powershell + $cert = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.FriendlyName -like "*PowerToys*" } + $cert.Thumbprint + ``` + +4. **Install the certificate in the Trusted Root** (requires admin Terminal): + ```powershell + Export-Certificate -Cert $cert -FilePath "$env:TEMP\PowerToysCodeSigning.cer" + Import-Certificate -FilePath "$env:TEMP\PowerToysCodeSigning.cer" -CertStoreLocation Cert:\LocalMachine\Root + ``` + + Alternatively, you can manually install the certificate using the Certificate Import Wizard: + + ![wizard 1](../images/newplus/wizard1.png) + ![wizard 2](../images/newplus/wizard2.png) + ![wizard 3](../images/newplus/wizard3.png) + ![wizard 4](../images/newplus/wizard4.png) + +5. Sign the MSIX package: + ```powershell + SignTool sign /fd SHA256 /sha1 <THUMBPRINT> "x:\GitHub\PowerToys\x64\Debug\WinUI3Apps\NewPlusPackage.msix" + ``` + + Note: SignTool might not be in your PATH, so you may need to specify the full path, e.g.: + ```powershell + & "C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64\signtool.exe" sign /fd SHA256 /sha1 <THUMBPRINT> "x:\GitHub\PowerToys\x64\Debug\WinUI3Apps\NewPlusPackage.msix" + ``` + +6. Check if the NewPlus package is already installed and remove it if necessary: + ```powershell + Get-AppxPackage -Name Microsoft.PowerToys.NewPlusContextMenu + Remove-AppxPackage Microsoft.PowerToys.NewPlusContextMenu_<VERSION>_neutral__8wekyb3d8bbwe + ``` + +7. Install the new signed MSIX package (optional if launching PowerToys settings first): + ```powershell + Add-AppxPackage -Path "x:\GitHub\PowerToys\x64\Debug\WinUI3Apps\NewPlusPackage.msix" -ExternalLocation "x:\GitHub\PowerToys\x64\Debug\WinUI3Apps" + ``` + + Note: If you prefer, you can simply launch PowerToys settings and enable the NewPlus module, which will install the MSIX package for you. + +8. Restart Explorer to ensure the new context menu handler is loaded: + ```powershell + taskkill /f /im explorer.exe && start explorer.exe + ``` + +9. Run Visual Studio as administrator (optional) + +10. Set breakpoints in the code (e.g., in [shell_context_menu.cpp#L45](/src/modules/NewPlus/NewShellExtensionContextMenu/shell_context_menu.cpp#L45)) + +11. Right-click in File Explorer and attach the debugger to the `DllHost.exe` process (with NewPlus title) that loads when the context menu is invoked +![alt text](../images/newplus/debug.png) + +12. Right-click again (quickly) after attaching the debugger to trigger the breakpoint + +Note: The DllHost process loads the DLL only when the context menu is triggered and unloads after, making debugging challenging. For easier development, consider using logging or message boxes instead of breakpoints. + +## Common Issues + +- If the Windows 11 context menu entry doesn't appear, it may be due to: + - The package not being properly registered + - Explorer not being restarted after registration + - A signature issue with the MSIX package + +- For development and testing, using the Windows 10 handler can be easier since it doesn't require signing. diff --git a/doc/devdocs/modules/peek/readme.md b/doc/devdocs/modules/peek/readme.md index 8bb73d62fc..64b4df85d7 100644 --- a/doc/devdocs/modules/peek/readme.md +++ b/doc/devdocs/modules/peek/readme.md @@ -1,5 +1,14 @@ # PowerToys Peek +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/peek) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-Peek)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3AProduct-Peek)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-Peek) + + > Documentation is currently under construction ## Dev file previewer 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<br/>(PowerDisplayModuleInterface.dll)"] + PowerDisplayApp["PowerDisplay App<br/>(PowerToys.PowerDisplay.exe)"] + PowerDisplayLib["PowerDisplay.Lib<br/>(Shared Library)"] + end + + subgraph External["External"] + Hardware["Display Hardware<br/>(External + Internal)"] + Storage["Persistent Storage<br/>(settings.json, profiles.json)"] + end + + Runner -->|"Loads DLL"| ModuleInterface + Runner -->|"Hotkey Events"| ModuleInterface + SettingsUI <-->|"Named Pipes"| Runner + SettingsUI -->|"Custom Actions<br/>(Launch, ApplyProfile)"| ModuleInterface + + ModuleInterface <-->|"Windows Events<br/>(Show/Toggle/Terminate)"| PowerDisplayApp + PowerDisplayApp -->|"RefreshMonitors Event"| SettingsUI + LightSwitch -->|"Theme Events<br/>(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<br/>(C++ DLL)"] + LightSwitch["LightSwitch Module"] + end + + subgraph WindowsEvents["Windows Events (IPC)"] + direction LR + ShowToggleEvents["Show/Toggle/Terminate<br/>Events"] + ThemeChangedEvent["ThemeChanged<br/>Events"] + end + + subgraph PowerDisplayModule["PowerDisplay Module"] + subgraph PowerDisplayApp["PowerDisplay App (WinUI 3)"] + MainViewModel + MonitorViewModel + MonitorManager + DisplayChangeWatcher["DisplayChangeWatcher<br/>(Hot-Plug Detection)"] + LightSwitchService["LightSwitchService<br/>(Theme Handler)"] + end + + subgraph PowerDisplayLib["PowerDisplay.Lib"] + subgraph Services + ProfileService + MonitorStateManager + DisplayRotationService + end + subgraph Drivers + DdcCiController + WmiController + end + subgraph Utils + PnpIdHelper["PnpIdHelper<br/>(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<br/>(P/Invoke)"] + PhysicalMonitorMgr["PhysicalMonitorHandleManager"] + MonitorDiscovery["MonitorDiscoveryHelper"] + CapParser["MccsCapabilitiesParser"] + + subgraph Win32["Win32 APIs"] + User32["User32.dll<br/>EnumDisplayMonitors<br/>GetMonitorInfo"] + Dxva2["Dxva2.dll<br/>GetVCPFeature<br/>SetVCPFeature<br/>Capabilities"] + end + end + + subgraph WMIStack["WMI Stack"] + WmiLight["WmiLight Library<br/>(Native AOT compatible,<br/>NuGet package)"] + PnpHelper["PnpIdHelper<br/>(Manufacturer name lookup)"] + + subgraph WMIClasses["WMI Classes (root\\WMI)"] + WmiMonBright["WmiMonitorBrightness"] + WmiMonBrightMethods["WmiMonitorBrightnessMethods"] + end + end + + subgraph Hardware["Hardware Layer"] + ExtMon["External Monitor<br/>(DDC/CI capable)"] + LaptopMon["Laptop Display<br/>(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 { + <<interface>> + +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 +<!-- PowerDisplay.Lib.csproj --> +<PropertyGroup> + <IsAotCompatible>true</IsAotCompatible> +</PropertyGroup> +<ItemGroup> + <PackageReference Include="WmiLight" /> +</ItemGroup> +``` + +#### 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<byte>("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<char>` 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<br/>(User32.dll)"] + QueryDisplayConfig["QueryDisplayConfig<br/>(User32.dll)"] + GetPhysicalMonitors["GetPhysicalMonitorsFromHMONITOR<br/>(Dxva2.dll)"] + WmiMonitor["WMI root\\WMI<br/>(WmiLight)"] + end + + subgraph Identifiers["Monitor Identifiers"] + HMONITOR["HMONITOR<br/>(Logical Monitor Handle)"] + GdiDeviceName["GDI Device Name<br/>(e.g., \\\\.\\DISPLAY1)"] + PhysicalHandle["Physical Monitor Handle<br/>(IntPtr for DDC/CI)"] + DevicePath["Device Path<br/>(Unique per target)"] + HardwareId["Hardware ID<br/>(e.g., DEL41B4)"] + InstanceName["WMI Instance Name<br/>(e.g., DISPLAY\\BOE0900\\...)"] + MonitorNumber["Monitor Number<br/>(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<br/>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:<br/>- Handle (Physical Monitor Handle)<br/>- MonitorNumber (from QueryDisplayConfig)<br/>- 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<br/>"DISPLAY\BOE0900\..." → "BOE0900" + + App->>QDC: GetAllMonitorDisplayInfo() + QDC-->>App: MonitorDisplayInfo[] (keyed by DevicePath) + + Note over App: Match WMI monitor to QueryDisplayConfig<br/>by comparing HardwareId + + App->>PnP: GetBuiltInDisplayName("BOE0900") + PnP-->>App: "BOE Built-in Display" + + Note over App: Create Monitor objects with:<br/>- InstanceName (for WMI queries)<br/>- MonitorNumber (from QueryDisplayConfig)<br/>- GdiDeviceName (for rotation APIs) +``` + +#### Key Relationships + +##### GDI Device Name ↔ Physical Monitors + +```mermaid +flowchart TB + HMON["HMONITOR (Logical)"] + + HMON --> GDI["GetMonitorInfo()<br/>→ GDI Device Name<br/>\.DISPLAY1"] + HMON --> GetPhys["GetPhysicalMonitorsFromHMONITOR()"] + + GetPhys --> PM0["Physical Monitor 0<br/>Handle: 0x0B14<br/>Desc: Dell U2722D"] + GetPhys --> PM1["Physical Monitor 1<br/>Handle: 0x0B18<br/>Desc: Dell U2722D<br/>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<br/>(Single DP Port)"] + MonA["Monitor A<br/>(MST Hub)"] + MonB["Monitor B<br/>(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<br/>PnP Manufacturer ID<br/>3 chars, EDID bytes 8-9"] + HardwareId --> ProductCode["41B4<br/>Product Code<br/>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:<br/>DISPLAY\BOE0900\4#amp;10fd3ab1#amp;0#amp;UID265988_0"] + + InstanceName --> Seg1["Segment 1: DISPLAY<br/>Constant prefix"] + InstanceName --> Seg2["Segment 2: BOE0900<br/>Hardware ID<br/>Used for matching with QueryDisplayConfig"] + InstanceName --> Seg3["Segment 3: Device instance<br/>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<br/>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<br/>(Time/System)"] + LightSwitchSettings["LightSwitchSettings"] + NotifyPD["NotifyPowerDisplay(isLight)"] + end + + subgraph PowerDisplayModule["PowerDisplay Module (C#)"] + subgraph App["PowerDisplay App"] + EventWaiter["NativeEventWaiter<br/>(Background Thread)"] + LightSwitchSvc["LightSwitchService<br/>(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<br/>{lightProfile, darkProfile}"] + PDProfilesJson["PowerDisplay/profiles.json<br/>{profiles: [...]}"] + end + + subgraph Hardware["Hardware"] + Monitors["Connected Monitors"] + end + + %% LightSwitch flow + ThemeEval -->|"Time boundary<br/>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()<br/>(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:<br/>FetchCapabilities<br/>~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:<br/>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<br/>from InstanceName"] + QDC["QueryDisplayConfig"] --> Match["Match by HardwareId"] + Extract --> Match + Match --> Name["Get Display Name<br/>via PnpIdHelper"] + Name --> Create["Create Monitor<br/>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<br/>(via hotkey or tray icon) + + Note over Flyout: Color temperature switcher visible<br/>(if enabled in Settings) + + User->>Flyout: Selects color temperature preset<br/>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<br/>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<br/>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<br/>(brightness, contrast, etc.) + User->>ProfileDialog: Clicks "Save" + + ProfileDialog->>ProfileDialog: Validate inputs + Note over ProfileDialog: Check name unique,<br/>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<br/>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<br/>on both Light and Dark events + EventWaiter->>WinEvent: WaitAny([lightEvent, darkEvent]) returns index + + Note over EventWaiter: Theme determined from event:<br/>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<char> +├── ApplySegment() → void +│ ├── ParseHexList() → List<byte> +│ ├── ParseVcpEntries() → Dictionary<byte, VcpCodeInfo> +│ └── ParseVcpNames() → void +│ +├── VcpEntryParser (sub-parser for vcp() content) +│ └── TryParseEntry() → VcpEntry +│ +├── VcpNameParser (sub-parser for vcpname() content) +│ └── TryParseEntry() → (byte code, string name) +│ +└── WindowParser (sub-parser for windowN() content) + ├── Parse() → WindowCapability + └── ParseSubSegment() → (name, content)? +``` + +### Design Principles + +1. **ref struct for Zero Allocation** + - Main parser uses `ref struct` to avoid heap allocation + - Works with `ReadOnlySpan<char>` for efficient string slicing + - No intermediate string allocations during parsing + +2. **Recursive Descent Pattern** + - Each grammar rule has a corresponding parse method + - Methods call each other recursively for nested structures + - Single-character lookahead via `Peek()` + +3. **Error Recovery** + - Errors are accumulated, not thrown + - Parser attempts to continue after errors + - Returns partial results when possible + +4. **Sub-parsers for Specialized Content** + - `VcpEntryParser` for VCP code entries + - `VcpNameParser` for custom VCP names + - Each sub-parser handles its own grammar subset + +## Parse Methods Detail + +### ParseCapabilities() +Entry point. Handles optional outer parentheses and iterates through segments. + +```csharp +private MccsParseResult ParseCapabilities() +{ + // Handle optional outer parens + // while (!IsAtEnd()) { ParseSegment() } + // Return result with accumulated errors +} +``` + +### ParseSegment() +Parses a single `identifier(content)` segment. + +```csharp +private ParsedSegment? ParseSegment() +{ + // 1. ParseIdentifier() + // 2. Expect '(' + // 3. ParseBalancedContent() + // 4. Expect ')' +} +``` + +### ParseBalancedContent() +Extracts content between balanced parentheses, handling nested parens. + +```csharp +private string ParseBalancedContent() +{ + int depth = 1; + while (depth > 0) { + if (char == '(') depth++; + if (char == ')') depth--; + } +} +``` + +### ParseVcpEntries() +Delegates to `VcpEntryParser` for the specialized VCP entry grammar. + +```csharp +vcp_entry ::= hex_byte [ '(' hex_list ')' ] + +Examples: +- "10" → code=0x10, values=[] +- "14(04 05 06)" → code=0x14, values=[4, 5, 6] +- "60(11 12 0F)" → code=0x60, values=[0x11, 0x12, 0x0F] +``` + +## Comparison with Other Approaches + +| Approach | Pros | Cons | +|----------|------|------| +| **Recursive Descent** (this) | Clear structure, handles nesting, extensible | More code | +| **Regex** (DDCSharp) | Concise | Hard to debug, limited nesting | +| **Mixed** (original) | Pragmatic | Inconsistent, hard to maintain | + +## Performance Characteristics + +- **Time Complexity**: O(n) where n = input length +- **Space Complexity**: O(1) for parsing + O(m) for output where m = number of VCP codes +- **Allocations**: Minimal - only for output structures + +## Supported Segments + +| Segment | Description | Parser | +|---------|-------------|--------| +| `prot(...)` | Protocol type | Direct assignment | +| `type(...)` | Display type (lcd/crt) | Direct assignment | +| `model(...)` | Model name | Direct assignment | +| `cmds(...)` | Supported commands | ParseHexList | +| `vcp(...)` | VCP code entries | VcpEntryParser | +| `mccs_ver(...)` | MCCS version | Direct assignment | +| `vcpname(...)` | Custom VCP names | VcpNameParser | +| `windowN(...)` | PIP/PBP window capabilities | WindowParser | + +### Window Segment Format + +The `windowN` segment (where N is 1, 2, 3, etc.) describes PIP/PBP window capabilities: + +``` +window1(type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10)) +``` + +| Sub-field | Format | Description | +|-----------|--------|-------------| +| `type` | `type(PIP)` or `type(PBP)` | Window type (Picture-in-Picture or Picture-by-Picture) | +| `area` | `area(x1 y1 x2 y2)` | Window area coordinates in pixels | +| `max` | `max(width height)` | Maximum window dimensions | +| `min` | `min(width height)` | Minimum window dimensions | +| `window` | `window(id)` | Window identifier | + +All sub-fields are optional; missing fields default to zero values. + +## Error Handling + +```csharp +public readonly struct ParseError +{ + public int Position { get; } // Character position + public string Message { get; } // Human-readable error +} + +public sealed class MccsParseResult +{ + public VcpCapabilities Capabilities { get; } + public IReadOnlyList<ParseError> Errors { get; } + public bool HasErrors => Errors.Count > 0; + public bool IsValid => !HasErrors && Capabilities.SupportedVcpCodes.Count > 0; +} +``` + +## Usage Example + +```csharp +// Parse capabilities string +var result = MccsCapabilitiesParser.Parse(capabilitiesString); + +if (result.IsValid) +{ + var caps = result.Capabilities; + Console.WriteLine($"Model: {caps.Model}"); + Console.WriteLine($"MCCS Version: {caps.MccsVersion}"); + Console.WriteLine($"VCP Codes: {caps.SupportedVcpCodes.Count}"); +} + +if (result.HasErrors) +{ + foreach (var error in result.Errors) + { + Console.WriteLine($"Parse error at {error.Position}: {error.Message}"); + } +} +``` + +## Edge Cases Handled + +1. **Missing outer parentheses** (Apple Cinema Display) +2. **No spaces between hex bytes** (`010203` vs `01 02 03`) +3. **Nested parentheses** in VCP values +4. **Unknown segments** (logged but not fatal) +5. **Malformed input** (partial results returned) diff --git a/doc/devdocs/modules/powerrename.md b/doc/devdocs/modules/powerrename.md index 79a72002f1..e7b773e70c 100644 --- a/doc/devdocs/modules/powerrename.md +++ b/doc/devdocs/modules/powerrename.md @@ -1,23 +1,108 @@ -#### [`dllmain.cpp`](/src/modules/powerrename/dll/dllmain.cpp) -TODO +# PowerRename -#### [`PowerRenameExt.cpp`](/src/modules/powerrename/dll/PowerRenameExt.cpp) -TODO +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/powerrename) -#### [`Helpers.cpp`](/src/modules/powerrename/lib/Helpers.cpp) -TODO +## Quick Links -#### [`PowerRenameItem.cpp`](/src/modules/powerrename/lib/PowerRenameItem.cpp) -TODO +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-PowerRename)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3AProduct-PowerRename)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-PowerRename) -#### [`PowerRenameManager.cpp`](/src/modules/powerrename/lib/PowerRenameManager.cpp) -TODO +PowerRename is a Windows shell extension that enables batch renaming of files using search and replace or regular expressions. -#### [`PowerRenameRegEx.cpp`](/src/modules/powerrename/lib/PowerRenameRegEx.cpp) -TODO +## Overview -#### [`Settings.cpp`](/src/modules/powerrename/lib/Settings.cpp) -TODO +PowerRename provides a powerful and flexible way to rename files in File Explorer. It is accessible through the Windows context menu and allows users to: +- Preview changes before applying them +- Use search and replace with regular expressions +- Filter items by type (files or folders) +- Apply case-sensitive or case-insensitive matching +- Save and reuse recent search/replace patterns -#### [`trace.cpp`](/src/modules/powerrename/lib/trace.cpp) -TODO +## Architecture + +PowerRename consists of multiple components: +- Shell Extension DLL (context menu integration) +- WinUI 3 UI application +- Core renaming library + +### Technology Stack +- C++/WinRT +- WinUI 3 +- COM for shell integration + +## Context Menu Integration + +PowerRename integrates with the Windows context menu following the [PowerToys Context Menu Handlers](../common/context-menus.md) pattern. It uses a dual registration approach to ensure compatibility with both Windows 10 and Windows 11. + +### Registration Process + +The context menu registration entry point is in `PowerRenameExt/dllmain.cpp::enable`, which registers: +- A traditional shell extension for Windows 10 +- A sparse MSIX package for Windows 11 context menus + +For more details on the implementation approach, see the [Dual Registration section](../common/context-menus.md#1-dual-registration-eg-imageresizer-powerrename) in the context menu documentation. + +## Code Components + +### [`dllmain.cpp`](/src/modules/powerrename/dll/dllmain.cpp) +Contains the DLL entry point and module activation/deactivation code. The key function `RunPowerRename` is called when the context menu option is invoked, which launches the PowerRenameUI. + +### [`PowerRenameExt.cpp`](/src/modules/powerrename/dll/PowerRenameExt.cpp) +Implements the shell extension COM interfaces required for context menu integration, including: +- `IShellExtInit` for initialization +- `IContextMenu` for traditional context menu support +- `IExplorerCommand` for Windows 11 context menu support + +### [`Helpers.cpp`](/src/modules/powerrename/lib/Helpers.cpp) +Utility functions used throughout the PowerRename module, including file system operations and string manipulation. + +### [`PowerRenameItem.cpp`](/src/modules/powerrename/lib/PowerRenameItem.cpp) +Represents a single item (file or folder) to be renamed. Tracks original and new names and maintains state. + +### [`PowerRenameManager.cpp`](/src/modules/powerrename/lib/PowerRenameManager.cpp) +Manages the collection of items to be renamed and coordinates the rename operation. + +### [`PowerRenameRegEx.cpp`](/src/modules/powerrename/lib/PowerRenameRegEx.cpp) +Implements the regular expression search and replace functionality used for renaming. + +### [`Settings.cpp`](/src/modules/powerrename/lib/Settings.cpp) +Manages user preferences and settings for the PowerRename module. + +### [`trace.cpp`](/src/modules/powerrename/lib/trace.cpp) +Implements telemetry and logging functionality. + +## UI Implementation + +PowerRename uses WinUI 3 for its user interface. The UI allows users to: +- Enter search and replace patterns +- Preview rename results in real-time +- Access previous search/replace patterns via MRU (Most Recently Used) lists +- Configure various options + +### Key UI Components + +- Search/Replace input fields with x:Bind to `SearchMRU`/`ReplaceMRU` collections +- Preview list showing original and new filenames +- Settings panel for configuring rename options +- Event handling for `SearchReplaceChanged` to update the preview in real-time + +## Debugging + +### Debugging the Context Menu + +See the [Debugging Context Menu Handlers](../common/context-menus.md#debugging-context-menu-handlers) section for general guidance on debugging PowerToys context menu extensions. + +### Debugging the UI + +To debug the PowerRename UI: + +1. Add file paths manually in `\src\modules\powerrename\PowerRenameUILib\PowerRenameXAML\App.xaml.cpp` +2. Set the PowerRenameUI project as the startup project +3. Run in debug mode to test with the manually specified files + +### Common Issues + +- Context menu not appearing: Ensure the extension is properly registered and Explorer has been restarted +- UI not launching: Check Event Viewer for errors related to WinUI 3 application activation +- Rename operations failing: Verify file permissions and check for locked files \ No newline at end of file diff --git a/doc/devdocs/modules/quickaccent.md b/doc/devdocs/modules/quickaccent.md new file mode 100644 index 0000000000..3a381961b6 --- /dev/null +++ b/doc/devdocs/modules/quickaccent.md @@ -0,0 +1,119 @@ +# Quick Accent + + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/quick-accent) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Quick%20Accent%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Quick%20Accent%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Quick+Accent%22) + +## Overview + +Quick Accent (formerly known as Power Accent) is a PowerToys module that allows users to quickly insert accented characters by holding a key and pressing an activation key (like the Space key or arrow keys). For example, holding 'a' might display options like 'à', 'á', 'â', etc. This tool enhances productivity by streamlining the input of special characters without the need to memorize keyboard shortcuts. + +## Architecture + +The Quick Accent module consists of four main components: + +``` +poweraccent/ +├── PowerAccent.Core/ # Core component containing Language Sets +├── PowerAccent.UI/ # The character selector UI +├── PowerAccentKeyboardService/ # Keyboard Hook +└── PowerAccentModuleInterface/ # DLL interface +``` + +### Module Interface (PowerAccentModuleInterface) + +The Module Interface, implemented in `PowerAccentModuleInterface/dllmain.cpp`, is responsible for: +- Handling communication between PowerToys Runner and the PowerAccent process +- Managing module lifecycle (enable/disable/settings) +- Launching and terminating the PowerToys.PowerAccent.exe process + +### Core Logic (PowerAccent.Core) + +The Core component contains: +- Main accent character logic +- Keyboard input detection +- Character mappings for different languages +- Management of language sets and special characters (currency, math symbols, etc.) +- Usage statistics for frequently used characters + +### UI Layer (PowerAccent.UI) + +The UI component is responsible for: +- Displaying the toolbar with accent options +- Handling user selection of accented characters +- Managing the visual positioning of the toolbar + +### Keyboard Service (PowerAccentKeyboardService) + +This component: +- Implements keyboard hooks to detect key presses +- Manages the trigger mechanism for displaying the accent toolbar +- Handles keyboard input processing + +## Implementation Details + +### Activation Mechanism + +The Quick Accent is activated when: +1. A user presses and holds a character key (e.g., 'a') +2. User presses the trigger key +3. After a brief delay (around 300ms per setting), the accent toolbar appears +4. The user can select an accented variant using the trigger key +5. Upon releasing the keys, the selected accented character is inserted + +### Character Sets + +The module includes multiple language-specific character sets and special character sets: +- Various language sets for different alphabets and writing systems +- Special character sets (currency symbols, mathematical notations, etc.) +- These sets are defined in the core component and can be extended + +### Known Behaviors + +- The module has a specific timing mechanism for activation that users have become accustomed to. Initially, this was considered a bug (where the toolbar would still appear even after quickly tapping and releasing keys), but it has been maintained as expected behavior since users rely on it. +- Multiple rapid key presses can trigger multiple background tasks. + +## Future Considerations + +- Potential refinements to the activation timing mechanism +- Additional language and special character sets +- Improved UI positioning in different application contexts + +## Debugging + +To debug the Quick Accent module via **runner** approach, follow these steps: + +0. Get familiar with the overall [Debugging Process](../development/debugging.md) for PowerToys. +1. **Build** the entire PowerToys solution in Visual Studio +2. Navigate to the **PowerAccent** folder in Solution Explorer +3. Open the file you want to debug and set **breakpoints** at the relevant locations +4. Find the **runner** project in the root of the solution +5. Right-click on the **runner** project and select "*Set as Startup Project*" +6. Start debugging by pressing `F5` or clicking the "*Start*" button +7. When the PowerToys Runner launches, **enable** the Quick Accent module in the UI +8. Use the Visual Studio Debug menu or press `Ctrl+Alt+P` to open "*Reattach to Process*" +9. Find and select "**PowerToys.PowerAccent.exe**" in the process list +10. Trigger the action in Quick Accent that should hit your breakpoint +11. Verify that the debugger breaks at your breakpoint and you can inspect variables and step through code + +This process allows you to debug the Quick Accent module while it's running as part of the full PowerToys application. + +### Alternative Debugging Approach + +To directly debug the Quick Accent UI component: + +0. Get familiar with the overall [Debugging Process](../development/debugging.md) for PowerToys. +1. **Build** the entire PowerToys solution in Visual Studio +2. Navigate to the **PowerAccent** folder in Solution Explorer +3. Open the file you want to debug and set **breakpoints** at the relevant locations +4. Right-click on the **PowerAccent.UI** project and select "*Set as Startup Project*" +5. Start debugging by pressing `F5` or clicking the "*Start*" button +6. Verify that the debugger breaks at your breakpoint and you can inspect variables and step through code + +**Known issue**: You may encounter approximately 78 errors during the start of debugging.<br> +**Solution**: If you encounter errors, right-click on the **PowerAccent** folder in Solution Explorer and select "*Rebuild*". After rebuilding, start debugging again. diff --git a/doc/devdocs/modules/readme.md b/doc/devdocs/modules/readme.md new file mode 100644 index 0000000000..0a2b48f098 --- /dev/null +++ b/doc/devdocs/modules/readme.md @@ -0,0 +1,43 @@ +# PowerToys Modules + +This section contains documentation for individual PowerToys modules, including their architecture, implementation details, and debugging tools. + +## Available Modules + +| Module | Description | +|--------|-------------| +| [Advanced Paste](advancedpaste.md) | Tool for enhanced clipboard pasting with formatting options | +| [Always on Top](alwaysontop.md) | Tool for pinning windows to stay on top of other windows | +| [Awake](awake.md) | Tool to keep your computer awake without modifying power settings | +| [Color Picker](colorpicker.md) | Tool for selecting and managing colors from the screen | +| [Command Not Found](commandnotfound.md) | Tool suggesting package installations for missing commands | +| [Crop and Lock](cropandlock.md) | Tool for cropping application windows into smaller windows or thumbnails | +| [Environment Variables](environmentvariables.md) | Tool for managing user and system environment variables | +| [FancyZones](fancyzones.md) ([debugging tools](fancyzones-tools.md)) | Window manager utility for custom window layouts | +| [File Explorer add-ons](fileexploreraddons.md) | Extensions for enhancing Windows File Explorer functionality | +| [File Locksmith](filelocksmith.md) | Tool for finding processes that lock files | +| [Hosts File Editor](hostsfileeditor.md) | Tool for managing the system hosts file | +| [Image Resizer](imageresizer.md) | Tool for quickly resizing images within File Explorer | +| [Keyboard Manager](keyboardmanager/README.md) | Tool for remapping keys and keyboard shortcuts | +| [Mouse Utilities](mouseutils/readme.md) | Collection of tools to enhance mouse and cursor functionality | +| [Mouse Without Borders](mousewithoutborders.md) | Tool for controlling multiple computers with a single mouse and keyboard | +| [NewPlus](newplus.md) | Context menu extension for creating new files in File Explorer | +| [Peek](peek/readme.md) | File preview utility for quick file content viewing | +| [Power Rename](powerrename.md) | Bulk file renaming tool with search and replace functionality | +| [PowerToys Run (deprecation soon)](launcher/readme.md) | Quick application launcher and search utility | +| [Quick Accent](quickaccent.md) | Tool for quickly inserting accented characters and special symbols | +| [Registry Preview](registrypreview.md) | Tool for visualizing and editing Registry files | +| [Screen Ruler](screenruler.md) | Tool for measuring pixel distances and color boundaries on screen | +| [Shortcut Guide](shortcut_guide.md) | Tool for displaying Windows keyboard shortcuts when holding the Windows key | +| [Text Extractor](textextractor.md) | Tool for extracting text from images and screenshots | +| [Workspaces](workspaces.md) | Tool for saving and restoring window layouts for different projects | +| [ZoomIt](zoomit.md) | Screen zoom and annotation tool | + +## Adding New Module Documentation + +When adding documentation for a new module: + +1. Create a dedicated markdown file for the module (e.g., `modulename.md`) +2. If the module has specialized debugging tools, consider creating a separate tools document (e.g., `modulename-tools.md`) +3. Update this index with links to the new documentation +4. Follow the existing documentation structure for consistency diff --git a/doc/devdocs/modules/registrypreview.md b/doc/devdocs/modules/registrypreview.md new file mode 100644 index 0000000000..1d0355a1c8 --- /dev/null +++ b/doc/devdocs/modules/registrypreview.md @@ -0,0 +1,85 @@ +# Registry Preview Module + + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/registry-preview) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Registry%20Preview%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Registry%20Preview%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Registry+Preview%22) +[CheckList](https://github.com/microsoft/PowerToys/blob/releaseChecklist/doc/releases/tests-checklist-template.md?plain=1#L641) + +## Overview + +Registry Preview simplifies the process of visualizing and editing complex Windows Registry files. It provides a powerful interface to preview, edit, and write changes to the Windows Registry. The module leverages the [Monaco Editor](../common/monaco-editor.md) to provide features like syntax highlighting and line numbering for registry files. + +## Technical Architecture + +Registry Preview is built using WinUI 3 with the [Monaco Editor](../common/monaco-editor.md) embedded for text editing capabilities. Monaco was originally designed for web environments but has been integrated into this desktop application to leverage its powerful editing features. + +The module consists of several key components: + +1. **Main Windows Interface** - Handles the UI interactions, window messaging, and resource loading +2. **Monaco Editor Integration** - Embeds the Monaco web-based editor into WinUI 3 (see [Monaco Editor documentation](../common/monaco-editor.md) for details) +3. **Registry Parser** - Parses registry files and builds a tree structure for visualization +4. **Editor Control** - Manages the editing capabilities and syntax highlighting + +## Code Structure + +The Registry Preview module is organized into the following projects: + +- **RegistryPreview** - Main window implementation, including Windows message handling, resource loading, and service injection +- **RegistryPreviewUILib** - UI implementation details and backend logic +- **RegistryPreviewExt** - Project configuration and setup +- **RegistryPreview.FuzzTests** - Fuzzing tests for the module + +Key files and components: + +1. **MonacoEditorControl** - Handles the embedding of [Monaco](../common/monaco-editor.md) into WinUI 3 and sets up the WebView container +2. **MainWindow** - Manages all event handling in one place +3. **Utilities** - Contains shared helper methods and utility classes + +## Main Functions + +- **MonacoEditorControl**: Controls editing in Monaco +- **GetRuntimeMonacoDirectory**: Gets the current directory path +- **OpenRegistryFile**: Opens and processes a registry file (first-time open) +- **RefreshRegistryFile**: Re-opens and processes an already opened file +- **ParseRegistryFile**: Parses text from the editor +- **AddTextToTree**: Creates TreeView nodes from registry keys +- **ShowMessageBox**: Wrapper method for displaying message boxes + +## Debugging Registry Preview + +### Setup Debugging Environment + +1. Set the PowerToys Runner as the parent process +2. Set the RegistryPreviewUILib project as the child process for debugging +3. Use the PowerToys Development Utility tool to configure debugging + +### Debugging Tips + +1. The main application logic is in the RegistryPreviewUILib project +2. Monaco-related issues may require debugging the WebView component (see [Monaco Editor documentation](../common/monaco-editor.md) for details) +3. For parsing issues, add breakpoints in the ParseRegistryFile method +4. UI issues are typically handled in the main RegistryPreview project + +## UI Automation + +Currently, Registry Preview does not have UI automation tests implemented. This is a potential area for future development. + +## Recent Updates + +Registry Preview has received community contributions, including: +- UI improvements +- New buttons and functionality +- Data preview enhancements +- Save button improvements + +## Future Considerations + +- Adding UI automation tests +- Further [Monaco editor](../common/monaco-editor.md) updates +- Enhanced registry parsing capabilities +- Improved visualization options diff --git a/doc/devdocs/modules/screenruler.md b/doc/devdocs/modules/screenruler.md new file mode 100644 index 0000000000..d43d1639ea --- /dev/null +++ b/doc/devdocs/modules/screenruler.md @@ -0,0 +1,68 @@ +# Screen Ruler + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/screen-ruler) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Screen%20Ruler%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Screen%20Ruler%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Screen+Ruler%22) + +## Overview + +Screen Ruler (project name: MeasureTool or Measure 2) is a PowerToys module that allows users to measure pixel distances and detect color boundaries on the screen. The tool renders an overlay UI using DirectX and provides several measurement utilities. + +## Features + +- **Bounce Utility**: Measure a rectangular zone by dragging with a left click +- **Spacing Tool**: Measure the length of a line with the same color with the same pixel value both horizontally and vertically +- **Horizontal Spacing**: Measure the line with the same color in the horizontal direction +- **Vertical Spacing**: Measure the line with the same color in the vertical direction + +## Architecture & Implementation + +The Screen Ruler module consists of several components: + +### MeasureToolModuleInterface + +- **Dllmain.cpp**: Provides functionality to start and stop the Measure Tool process based on hotkey events, manage settings, and handle events. + +### MeasureToolUI + +- **App.xaml.cs**: Main entrance of the app. Initializes MeasureToolCore and activates a new main window. +- **MainWindow.xaml.cs**: Sets properties and behaviors for the window, and handles user click interactions. +- **NativeMethods.cs**: Interacts with the Windows API to manipulate window properties, such as positioning and sizing. +- **Settings.cs**: Gets the default measure style from settings. + +### PowerToys.MeasureToolCore + +- **PowerToys.MeasureToolCore**: Handles initialization, state management, and starts the measure tool and bounds tool. +- **BGRATextureView.h**: Manages and interacts with BGRA textures in a Direct3D 11 context. +- **Measurement.cpp**: Defines a Measurement struct that represents a rectangular measurement area, including methods for converting and printing measurement details in various units. +- **Clipboard.cpp**: Copies measurement data to the clipboard. +- **D2DState.cpp**: Manages Direct2D rendering state and draws text boxes. +- **DxgiAPI.cpp**: Creates and manages Direct3D and Direct2D devices. +- **EdgeDetection.cpp**: Detects edges in a BGRA texture. +- **OverlayUI.cpp**: Creates and manages overlay windows for tools like MeasureTool and BoundsTool. +- **BoundsToolOverlayUI.cpp**: UI implementation for bounds feature. Handles mouse and touch events to draw measurement rectangles on the screen and display their pixels. +- **MeasureToolOverlayUI.cpp**: UI implementation for measure feature. Draws measurement lines on the screen and displays their pixels. +- **ScreenCapturing.cpp**: Continuously captures the screen, detects edges, and updates the measurement state for real-time drawing of measurement lines. +- **PerGlyphOpacityTextRender.cpp**: Renders text with varying opacity on a Direct2D render target. + +## Building & Debugging + +### Building + +1. Open PowerToys.slnx in Visual Studio +2. In the Solutions Configuration drop-down menu, select Release or Debug +3. From the Build menu, choose Build Solution +4. The executable app for Screen Ruler is named PowerToys.MeasureToolUI.exe + +### Debugging + +1. Right-click the project MeasureToolUI and click 'Set as Startup Project' +2. Right-click the project MeasureToolUI and click 'Debug' + +## Known Issues + +There are several open bugs for the Screen Ruler module, most of which are related to crashing issues. These can be found in the [PowerToys issues list](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue%20state%3Aopen%20Screen%20ruler%20type%3ABug). diff --git a/doc/devdocs/modules/shortcut_guide.md b/doc/devdocs/modules/shortcut_guide.md index 84afc87a07..f150a4456c 100644 --- a/doc/devdocs/modules/shortcut_guide.md +++ b/doc/devdocs/modules/shortcut_guide.md @@ -1,17 +1,91 @@ +# Shortcut Guide + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/shortcut-guide) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Shortcut%20Guide%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Shortcut%20Guide%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Shortcut+Guide%22+) + +## Overview +Shortcut Guide is a PowerToy that displays an overlay of available keyboard shortcuts when the Windows key is pressed and held. It provides a visual reference for Windows key combinations, helping users discover and utilize built-in Windows shortcuts. + +## Usage +- Press and hold the Windows key to display the overlay of available shortcuts +- Press the hotkey again to dismiss the overlay +- The overlay displays Windows shortcuts with their corresponding actions + +## Build and Debug Instructions + +### Build +1. Open PowerToys.slnx in Visual Studio +2. Select Release or Debug in the Solutions Configuration drop-down menu +3. From the Build menu, choose Build Solution +4. The executable is named PowerToys.ShortcutGuide.exe + +### Debug +1. Right-click the ShortcutGuide project and select 'Set as Startup Project' +2. Right-click the project again and select 'Debug' + +## Code Structure + +![Diagram](../images/shortcutguide/diagram.png) + +### Core Files + #### [`dllmain.cpp`](/src/modules/shortcut_guide/dllmain.cpp) -Contains DLL boilerplate code. +Contains DLL boilerplate code. Implements the PowertoyModuleIface, including enable/disable functionality and GPO policy handling. Captures hotkey events and starts the PowerToys.ShortcutGuide.exe process to display the shortcut guide window. #### [`shortcut_guide.cpp`](/src/modules/shortcut_guide/shortcut_guide.cpp) -Contains the module interface code. It initializes the settings values and the keyboard event listener. +Contains the module interface code. It initializes the settings values and the keyboard event listener. Defines the OverlayWindow class, which manages the overall logic and event handling for the PowerToys Shortcut Guide. #### [`overlay_window.cpp`](/src/modules/shortcut_guide/overlay_window.cpp) -Contains the code for loading the SVGs, creating and rendering of the overlay window. +Contains the code for loading the SVGs, creating and rendering of the overlay window. Manages and displays overlay windows with SVG graphics through two main classes: +- D2DOverlaySVG: Handles loading, resizing, and manipulation of SVG graphics +- D2DOverlayWindow: Manages the display and behavior of the overlay window #### [`keyboard_state.cpp`](/src/modules/shortcut_guide/keyboard_state.cpp) Contains helper methods for checking the current state of the keyboard. #### [`target_state.cpp`](/src/modules/shortcut_guide/target_state.cpp) -State machine that handles the keyboard events. It’s responsible for deciding when to show the overlay, when to suppress the Start menu (if the overlay is displayed long enough), etc. +State machine that handles the keyboard events. It's responsible for deciding when to show the overlay, when to suppress the Start menu (if the overlay is displayed long enough), etc. Handles state transitions and synchronization to ensure the overlay is shown or hidden appropriately based on user interactions. #### [`trace.cpp`](/src/modules/shortcut_guide/trace.cpp) Contains code for telemetry. + +### Supporting Files + +#### [`animation.cpp`](/src/modules/shortcut_guide/animation.cpp) +Handles the timing and interpolation of animations. Calculates the current value of an animation based on elapsed time and a specified easing function. + +#### [`d2d_svg.cpp`](/src/modules/shortcut_guide/d2d_svg.cpp) +Provides functionality for loading, resizing, recoloring, rendering, and manipulating SVG images using Direct2D. + +#### [`d2d_text.cpp`](/src/modules/shortcut_guide/d2d_text.cpp) +Handles creation, resizing, alignment, and rendering of text using Direct2D and DirectWrite. + +#### [`d2d_window.cpp`](/src/modules/shortcut_guide/d2d_window.cpp) +Manages a window using Direct2D and Direct3D for rendering. Handles window creation, resizing, rendering, and destruction. + +#### [`native_event_waiter.cpp`](/src/modules/shortcut_guide/native_event_waiter.cpp) +Waits for a named event and executes a specified action when the event is triggered. Uses a separate thread to handle event waiting and action execution. + +#### [`tasklist_positions.cpp`](/src/modules/shortcut_guide/tasklist_positions.cpp) +Handles retrieving and updating the positions and information of taskbar buttons in Windows. + +#### [`main.cpp`](/src/modules/shortcut_guide/main.cpp) +The entry point for the PowerToys Shortcut Guide application. Handles initialization, ensures single instance execution, manages parent process termination, creates and displays the overlay window, and runs the main event loop. + +## Features and Limitations + +- The overlay displays Windows shortcuts (Windows key combinations) +- The module supports localization, but only for the Windows controls on the left side of the overlay +- It's currently rated as a P3 (lower priority) module + +## Future Development + +A community-contributed version 2 is in development that will support: +- Application-specific shortcuts based on the active application +- Additional shortcuts beyond Windows key combinations +- PowerToys shortcuts diff --git a/doc/devdocs/modules/textextractor.md b/doc/devdocs/modules/textextractor.md new file mode 100644 index 0000000000..3529b692cd --- /dev/null +++ b/doc/devdocs/modules/textextractor.md @@ -0,0 +1,34 @@ +# Text Extractor + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/text-extractor) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3A%22Product-Text%20Extractor%22)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3A%22Product-Text%20Extractor%22)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3A%22Product-Text+Extractor%22) + +## Overview +Text Extractor is a PowerToys utility that enables users to extract and copy text from anywhere on the screen, including inside images and videos. The module uses Optical Character Recognition (OCR) technology to recognize text in visual content. This module is based on Joe Finney's Text Grab. + +## How it works +Text Extractor captures the screen content and uses OCR to identify and extract text from the selected area. Users can select a region of the screen, and Text Extractor will convert any visible text in that region into copyable text. + +## Architecture + +### Components +- **EventMonitor**: Handles the `ShowPowerOCRSharedEvent` which triggers the OCR functionality +- **OCROverlay**: The main UI component that provides: + - Language selection for OCR processing + - Canvas for selecting the screen area to extract text from +- **Screen Capture**: Uses `CopyFromScreen` to capture the screen content as the overlay background image + +### Activation Methods +- **Global Shortcut**: Activates Text Extractor through a keyboard shortcut +- **LaunchOCROverlayOnEveryScreen**: Functionality to display the OCR overlay across multiple monitors + +## Technical Implementation +Text Extractor is implemented using Windows Presentation Foundation (WPF) technology, which provides the UI framework for the selection canvas and other interface elements. + +## User Experience +When activated, Text Extractor displays an overlay on the screen that allows users to select an area containing text. Once selected, the OCR engine processes the image and extracts any text found, which can then be copied to the clipboard. diff --git a/doc/devdocs/modules/workspaces.md b/doc/devdocs/modules/workspaces.md new file mode 100644 index 0000000000..11255c521d --- /dev/null +++ b/doc/devdocs/modules/workspaces.md @@ -0,0 +1,34 @@ +# Workspaces + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/workspaces) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-Workspaces)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AIssue-Bug%20label%3AProduct-Workspaces)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-Workspaces) + +## Overview + +Workspaces is a PowerToys module that allows users to save and restore window layouts for different projects or workflows. + +## Links + +- [Source code folder](https://github.com/microsoft/PowerToys/tree/main/src/modules/Workspaces) +- [Issue tracker](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+label%3AWorkspaces) + +## Implementation Details + +TODO: Add implementation details + +## Debugging + +TODO: Add debugging information + +## Settings + +TODO: Add settings documentation + +## Future Improvements + +TODO: Add potential future improvements diff --git a/doc/devdocs/modules/zoomit.md b/doc/devdocs/modules/zoomit.md new file mode 100644 index 0000000000..1b09443d0a --- /dev/null +++ b/doc/devdocs/modules/zoomit.md @@ -0,0 +1,194 @@ +# ZoomIt Module + +[Public overview - Microsoft Learn](https://learn.microsoft.com/en-us/windows/powertoys/zoomit) + +## Quick Links + +[All Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-ZoomIt)<br> +[Bugs](https://github.com/microsoft/PowerToys/issues?q=is%3Aopen%20label%3AProduct-ZoomIt%20label%3AIssue-Bug%20)<br> +[Pull Requests](https://github.com/microsoft/PowerToys/pulls?q=is%3Apr+is%3Aopen+label%3AProduct-ZoomIt) + +## Overview + +ZoomIt is a screen zoom and annotation tool originally from Microsoft's Sysinternals suite. It allows users to: + +- Zoom in on specific areas of the screen +- Draw and annotate on the screen while zoomed in +- Use a timer for presentations or breaks +- Pan and move while zoomed in +- Record screen activity with audio + +ZoomIt runs in the background and is activated via customizable hotkeys. + +## Special Integration Considerations + +Unlike other PowerToys modules, ZoomIt has some unique integration aspects: + +1. **Registry-based Settings**: ZoomIt uses registry settings instead of JSON files (which is the standard for other PowerToys modules). This was required to maintain compatibility with the standalone Sysinternals version. + +2. **Dual Source of Truth**: The PowerToys repository serves as the source of truth for both the PowerToys version and the standalone Sysinternals version, with build flags to differentiate between them. + +3. **Settings Integration**: A special WinRT/C++ interop library was developed to bridge between PowerToys' JSON-based settings system and ZoomIt's registry-based settings. + +## Technical Architecture + +The ZoomIt module consists of the following components: + +1. **ZoomIt Executable** (`PowerToys.ZoomIt.exe`): The main ZoomIt application that provides the zooming and annotation functionality. + +2. **Module Interface** (`PowerToys.ZoomItModuleInterface.dll`): Implements the PowerToys module interface to integrate with the PowerToys runner. + +3. **Settings Interop** (`ZoomItSettingsInterop`): A WinRT/C++ interop library that enables communication between PowerToys settings and ZoomIt's registry settings. + +![key functions](../images/zoomit/functions.png) + +### Directory Structure + +``` +src/ +├── modules/ +│ └── ZoomIt/ +│ ├── ZoomIt/ # Main ZoomIt application code +│ ├── ZoomItModuleInterface/ # PowerToys module interface implementation +│ └── ZoomItSettingsInterop/ # WinRT/C++ interop for settings +├── settings-ui/ +│ └── Settings.UI/ +│ ├── SettingsXAML/ +│ │ └── Views/ +│ │ └── ZoomItPage.xaml # ZoomIt settings page UI +│ └── ViewModels/ +│ └── ZoomItViewModel.cs # ZoomIt settings view model +└── common/ + └── sysinternals/ # Common code from Sysinternals +``` + + +## Settings Management + +ZoomIt's settings are stored in the Windows registry instead of JSON files to maintain compatibility with the standalone version. The settings include: + +- Hotkey combinations for different modes (zoom, draw, etc.) +- Drawing options (colors, line thickness, etc.) +- Font settings for text annotations +- Microphone selection for recording +- Custom file paths for demo mode and break backgrounds + +The `ZoomItSettingsInterop` library handles: +1. Loading settings from registry and converting to JSON for PowerToys settings UI +2. Saving changes from the settings UI back to the registry +3. Notifying the ZoomIt application when settings change + +![interop](../images/zoomit/interop.png) + +## Integration Steps + +The integration of ZoomIt into PowerToys involved these key steps: + +1. **Code Migration**: + - Moving code from the Sysinternals ZoomIt repository to `src/modules/ZoomIt/ZoomIt` + - Adding required common libraries to `src/common/sysinternals` + - Sanitizing code for open source (removing private APIs, undocumented details, etc.) + - Ensuring no private APIs (validated through APIScan) + - Removing references to undocumented implementation details, constants, and names + - Standardizing dependencies with other PowerToys utilities + +2. **Module Interface Implementation**: + - Creating the PowerToys module interface + - Adding process management (start/terminate) + - Implementing event-based communication for settings updates + - Adding named events for communication between PowerToys and ZoomIt + +3. **Settings Integration**: + - Extracting ZoomIt settings code to a shareable component + - Creating a WinRT/C++ interop library for registry-JSON conversion + - Implementing all settings UI controls in PowerToys settings + - Building `ZoomItSettingsInterop` as a bridge between registry and JSON settings + +4. **PowerToys Integration**: + - Adding ZoomIt to the PowerToys runner + - Adding GPO rules for ZoomIt + - Implementing telemetry and logging + - Creating OOBE (out-of-box experience) page with animated tutorial + - Adding ZoomIt to process termination list for proper cleanup + - Adding telemetry events documentation + +5. **UI/UX Adjustments**: + - Redirecting ZoomIt's settings UI to PowerToys settings + - Handling hotkey conflicts with warning notifications + - Modifying tray icon behavior + - Removing original ZoomIt options menu entries + - Adding Sysinternals attribution on the settings page + +6. **Build System Updates**: + - Adding ZoomIt to the PowerToys solution + - Implementing build flags for standalone vs. PowerToys versions + - Adding signing for new binaries + - Fixing analyzer errors and code quality issues + +## Debug Instructions +1. Build the entire PowerToys solution at least once. +2. Set `runner` as the startup project and start debugging. +3. Once the PowerToys Settings app is running and ensure ZoomIt is activated. +4. Set `ZoomIt` as the startup project in Visual Studio. +5. Press `Ctrl + Alt + P` and attach ZoomIt to the process. +6. You should now be able to set breakpoints and step through the code. + +## Special Implementation Details + +### Font Selection + +ZoomIt requires storing font information as a binary LOGFONT structure in the registry. This required special handling: + +- Creating P/Invoke declarations for Windows font APIs +- Base64 encoding the binary data for transfer through JSON +- Using native Windows dialogs for font selection + +### Hotkey Management + +ZoomIt registers hotkeys through the Windows RegisterHotKey API. Special handling was needed to: + +- Detect and notify about hotkey conflicts +- Update hotkeys when settings change +- Support modifier keys + +### Process Communication + +Communication between PowerToys and ZoomIt uses: +- Command-line arguments to pass PowerToys process ID +- Named events for signaling settings changes and exit requests +- Windows messages for internal ZoomIt state management + +## Implementation Challenges + +Several challenges were encountered during ZoomIt integration: + +1. **First-Run Behavior**: + - Font loading crashed when no ZoomIt data existed in registry + - Hotkeys weren't registered on first run with no existing data + - Implemented safeguards to handle missing registry data + +2. **Settings Synchronization**: + - Modifier keys for shortcuts weren't correctly updated when settings changed + - Implemented proper event notification for settings changes + - Added hotkey conflict detection and warnings + +3. **File Interaction**: + - ZoomIt file pickers changed the working directory of the Settings project + - Fixed to maintain proper directory context + +4. **Drawing Issues**: + - Color settings lacking opacity caused drawing functionality to fail + - Removed internal state settings that weren't truly editable + +5. **Dual-Build Support**: + - Added build flags to support both PowerToys and standalone Sysinternals versions + - Implemented different executable properties based on build target + +## Source Code Management + +The PowerToys repository serves as the source of truth for both PowerToys and Sysinternals standalone versions of ZoomIt. Key repositories involved: + +- Utility repo: `https://dev.azure.com/sysinternals/Tools/_git/ZoomIt` +- Common library repo: `https://dev.azure.com/sysinternals/Tools/_git/Common` + +The integration process can be tracked through [PR #35880](https://github.com/microsoft/PowerToys/pull/35880) which contains the complete history of changes required to properly integrate ZoomIt. diff --git a/doc/devdocs/processes/gpo.md b/doc/devdocs/processes/gpo.md new file mode 100644 index 0000000000..e35d0e41b0 --- /dev/null +++ b/doc/devdocs/processes/gpo.md @@ -0,0 +1,125 @@ +# PowerToys GPO (Group Policy Objects) Implementation + +Group Policy Objects (GPOs) allow system administrators to control PowerToys settings across an organization. This document describes how GPOs are implemented in PowerToys. + +## GPO Overview + +GPO policies allow system administrators to control PowerToys settings. PowerToys ships GPO files as part of the release zip, not installed directly. + +## GPO File Structure + +### ADMX File +- Contains policy definitions +- Defines which versions support each policy +- Sets up folder structure +- Defines each policy with: + - Name + - Class (user scope or machine scope) + - Description + - Registry location where policy is stored + - Enabled/disabled values + +### ADML File +- Contains localized strings for the ADMX file +- Contains revision number that must be updated when changes are made +- Stores strings for: + - Folder names + - Version definitions + - Policy descriptions and titles +- Currently only ships English US version (no localization story yet) + +## Installation Process + +- Files need to be placed in: `C:\Windows\PolicyDefinitions\` +- ADMX file goes in the root folder +- ADML file goes in the language subfolder (e.g., en-US) +- After installation, policies appear in the Group Policy Editor (gpedit.msc) + +## Registry Implementation + +- Policies are stored as registry values +- Location: `HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\PowerToys` or `HKEY_CURRENT_USER\SOFTWARE\Policies\Microsoft\PowerToys` +- Machine scope takes precedence over user scope +- Policy states: + - Enabled: Registry value set to 1 + - Disabled: Registry value set to 0 + - Not Configured: Registry value does not exist + +## Code Integration + +### Common Files +- Policy keys defined in `common\utils\GPO.h` +- Contains functions to read registry values and get configured values +- WinRT C++ adapter created for C# applications to access GPO settings + +### WPF Applications +- WPF applications cannot directly load WinRT C++ projects +- Additional library created to allow WPF applications to access GPO values + +### Module Interface +- Each module must implement policy checking in its interface +- Runner checks this to determine if module should be started or not + +## UI Implementation + +- When a policy disables a utility: + - UI is locked (cannot be enabled) + - Settings page shows a lock icon + - Dashboard hides the module button + - If user tries to start the executable directly, it exits and logs a message + +## Types of GPO Policies + +### Basic Module Enable/Disable Policy +- Most common type +- Controls whether a module can be enabled or disabled +- Shared description text for these policies + +### Configuration Policies +- Example: Run at startup setting +- Controls specific settings rather than enabling/disabling modules +- Custom description text explaining what happens when enabled/disabled/not configured + +### Machine-Scope Only Policies +- Example: Mouse Without Borders service mode +- Only makes sense at machine level (not user level) +- Restricts functionality that requires elevated permissions + +## Steps to Add a New Policy + +1. Update ADMX file: + - Increase revision number + - Add supported version definition + - Define the policy with registry location + +2. Update ADML file: + - Increase revision number + - Add strings for version, title, description + +3. Update code: + - Add to GPO.h + - Add to GPO wrapper for C# access + - Update module interface + - Modify settings UI to show lock when policy applied + - Add checks in executable to prevent direct launching + - Update dashboard helper to respect policy + +4. Add to bug report tool to capture policy state + +## Update-Related GPO Settings + +- `disable automatic update download` - Prevents automatic downloading +- `disable new update toast` - Controls if toast notifications are shown +- `suspend new update toast` - Suspends toast notifications for 2 minor releases + +## Testing GPO Settings + +To test GPO settings locally: + +1. Run `regedit` as administrator +2. Navigate to `HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\PowerToys` +3. Create a new DWORD value with the name of the policy +4. Set the value to 0 (disabled) or 1 (enabled) +5. Restart PowerToys to see the effect + +For user-scope policies, use `HKEY_CURRENT_USER\SOFTWARE\Policies\Microsoft\PowerToys` instead. diff --git a/doc/devdocs/processes/release-process.md b/doc/devdocs/processes/release-process.md new file mode 100644 index 0000000000..c192de4cfb --- /dev/null +++ b/doc/devdocs/processes/release-process.md @@ -0,0 +1,183 @@ +# PowerToys Release Process + +This document outlines the process for preparing and publishing PowerToys releases. + +## Release Preparation + +### Branch Management +1. Sync commits from main branch to stable branch + - Usually sync current main to stable + - For hotfixes: might need to cherry-pick specific commits + +2. Start release build from the stable branch + - Use pipelines to build + - Set version number (e.g., 0.89.0) + - Build for both x64 and ARM64 + - Build time: ~1-2 hours (signing can take extra time) + - Build can be flaky, might need multiple attempts + +3. Artifacts from the build: + - ARM64 release files + - PowerToys setup for ARM64 (machine setup) + - User setup + - X64 release files + - PowerToys setup for x64 (machine setup) + - User setup + - GPO files (same for both architectures) + - Hash files for verification + - Symbols that are shipped with every release + +### Versioning +- Uses semantic versioning: `MAJOR.MINOR.PATCH` +- MINOR version increases with regular releases (e.g., 0.89.0) +- PATCH version increases for hotfixes (e.g., 0.87.0 → 0.87.1) +- Each release version must be greater than the previous one for proper updating + +## Testing Process + +### Release Candidate Testing +1. Fully test the builds using a checklist + - Manual tests for each release + - Each test item should be verified by at least 2 people + - Test on both x64 and ARM64 machines + - Every module is tested by at least two people + - New team members typically take 2 days for complete testing + - Experienced team members complete testing in less than a day (~2 hours for 1/3 of tests) + +2. For subsequent Release Candidates: + - Full retesting of modules with changes + - Verifying specific fixes + - Sanity checking all utilities (ensuring no startup crashes) + +3. If regressions found: + - Fix issues + - Return to step 1 (sync fixes to stable and build again) + +### Testing Workflow +1. Team divides the test checklist among members +2. Each member performs assigned tests +3. Members report any issues found +4. Team assesses if issues are release blockers +5. Team confirms testing completion before proceeding + +### Reporting Bugs During Testing +1. Discuss in team chat +2. Determine if it's a regression (check if bug exists in previous version) +3. Check if an issue is already open +4. Open a new issue if needed +5. Decide on criticality for the release (if regression) + +### Sign-off Process +- Teams sign off on modules independently +- Regressions found in first release candidates lead to PRs +- Second release candidate verified fixes +- Final verification ensures modules don't crash with new features + +## Documentation and Changelog + +### README Updates +1. Create PR with README updates for the release: + - Add new utilities to the list if applicable + - Update milestones + - Update expected download links + - Upload new hashes + - Update version and month + - Write highlights of important changes + - Thank open source contributors + - Don't thank internal team members or Microsoft employees assigned to the project + - Exception: thank external helpers like Niels (UI contributions) + +### Changelog Creation +- Changelog PR should be created several days before release +- Community members need time to comment and request changes +- Project managers need time to review and clean up +- When team testing is set, either tests are done or changelog is created right away + +### Changelog Structure +- **General section**: + - Issues/fixes not related to specific modules + - User-visible changes + - Important package updates (like .NET packages) + - Fixes that affect end users + +- **Development section**: + - CI-related changes + - Changes not visible to end users + - Performance improvements internal to the system + - Refactoring changes + - Logger updates and other developer-focused improvements + +### Formatting Notes +- Special attention needed for "highlights" section +- Different format is required for highlights in README versus release notes +- Must follow the exact same pattern/format for proper processing +- PowerToys pulls "What's New" information from the GitHub API + - Gets changelog from the latest 5 releases + - Format must be consistent for the PowerToys code to properly process it + - Code behind will delete everything between certain markers (installer hashes and highlights) + +### Documentation Changes +- Public docs appear on the web +- Changes happen in the Microsoft Docs repo: microsoft/windows-dev-docs +- For help with docs, contact Alvin Ashcraft from Microsoft +- Content automatically appears on learn.microsoft.com when PR is merged + +## GitHub Release Process + +### Creating the Release +1. Ask the project management team to start a GitHub release draft + - Draft should target stable branch + - Use proper version format (e.g., V 0.89.0) + - Set title using same format (e.g., "Release V 0.89.0") + +2. After testing is complete: + - Pick up the hashes from artifacts + - Apply changelog + - Fill in release notes + - Upload binaries + - GPO files + - Setup files + - ZIP files with symbols + - Only press "Save Draft", don't publish yet + +3. Final verification: + - Download every file from the draft + - Check that ZIPs can be unzipped + - Verify hashes match expectations + - Tell the project management team the release is good to go + - They will handle the actual publishing + +### Post-Release Actions +- GitHub Actions automatically trigger: + - Store submission + - WinGet submission +- Monitor these actions to ensure they complete successfully +- If something fails, action may need to be taken + +## Release Decision Making + +### Timing Considerations +- Release owner should coordinate with project managers +- Project managers have high-level view of what should be included in the release +- Use the "in for .XX" tag to identify PRs that should be included +- If a key feature isn't ready, discuss with PMs whether to delay the release + +### Release Coordination +- Release coordination requires good communication with domain feature owners +- Coordination needed with project managers and key feature developers +- Release candidate can only be done once key features have been merged +- Need to ensure all critical fixes are included before the release candidate + +## Special Cases + +### Hotfix Process +- For critical issues found after release +- Create a hotfix branch from the stable branch +- Cherry-pick only essential fixes +- Increment the PATCH version (e.g., 0.87.0 → 0.87.1) +- Follow the standard release process but with limited testing scope + +### Community Testing +- Community members generally don't have access to draft builds +- Exception: Some Microsoft MVPs sometimes test ARM64 builds +- If providing builds to community members, use a different version number (e.g., 0.1.x) to avoid installer conflicts diff --git a/doc/devdocs/processes/update-process.md b/doc/devdocs/processes/update-process.md new file mode 100644 index 0000000000..2a38eb6f70 --- /dev/null +++ b/doc/devdocs/processes/update-process.md @@ -0,0 +1,111 @@ +# PowerToys Update Process + +This document describes how the PowerToys update mechanism works. + +## Key Files + +- `updating.h` and `updating.cpp` in common - Contains code for handling updates and helper functions +- `update_state.h` and `update_state.cpp` - Handles loading and saving of update state + +## Update Process + +### Version Detection +- Uses GitHub API to get the latest version information +- API returns JSON with release information including version and assets +- Checks asset names to find the correct installer based on: + - Architecture (ARM64 or X64) + - Installation scope (user or machine) + +### Installation Scope +- Differentiates between user installer and machine installer +- Different patterns are defined to distinguish between the two scopes +- Both have different upgrade codes + +### Update State +- State is stored in a local file +- Contains information like: + - Current update state + - Release page URL + - Last time check was performed + - Whether a new version is available + - Whether installer is already downloaded + +### Update Checking +- Manual check: When user clicks "Check for Updates" in settings +- Automatic check: Periodic update worker runs periodically to check for updates +- Update state is saved to: `%LOCALAPPDATA%\Microsoft\PowerToys\update_state.json` + +### Update Process Flow +1. Check current version against latest version from GitHub +2. If newer version exists: + - Check metered connection settings + - Check if automatic updates are enabled + - Check GPO settings +3. Process new version: + - Check if installer is already downloaded + - Clean up old installer files + - Download new installer if needed +4. Notify user via toast notification + +### PowerToys Updater +- `PowerToysUpdate.exe` - Executable shipped with installer +- Handles downloading and running the installer +- Called when user clicks the update toast notification +- Downloads the installer if not already downloaded + +### Version Numbering +- Semantic versioning: `MAJOR.MINOR.PATCH` +- MINOR version increases with regular releases (e.g., 0.89.0) +- PATCH version increases for hotfixes (e.g., 0.87.0 → 0.87.1) + +### Installer Details +- Uses WiX bootstrapper +- Defines upgrade codes for per-user and per-machine installations +- These codes must remain consistent for proper updating + +## GPO Update Settings + +PowerToys respects Group Policy settings for controlling updates: + +- `disable automatic update download` - Prevents automatic downloading +- `disable new update toast` - Controls if toast notifications are shown +- `suspend new update toast` - Suspends toast notifications for 2 minor releases + +## User Settings + +Users can control update behavior through the PowerToys settings: + +- Automatic update downloads can be enabled/disabled +- Download and install updates automatically on metered connections + +## Update Notification + +When a new update is available: +1. Toast notification appears in the Windows Action Center +2. Clicking the notification starts the update process +3. The updater downloads the installer (if not already downloaded) +4. The installer runs with appropriate command-line arguments + +## Debugging Tips + +### Testing Update Detection +- To force an update check, modify the timestamp in the update state file to an earlier date +- Exit PowerToys, modify the file, then restart PowerToys + +### Common Issues +- Permission issues can prevent downloading updates +- Network connectivity problems may interrupt downloads +- Group Policy settings may block updates +- Installer may fail if the application is running + +### Update Logs +- Check PowerToys logs for update-related messages +- `%LOCALAPPDATA%\Microsoft\PowerToys\Logs\PowerToys-*.log` +- Look for entries related to update checking and downloading + +## Rollout Considerations + +- Updates are made available to all users simultaneously +- No staged rollout mechanism is currently implemented +- Critical issues discovered after release require a hotfix +- See [Release Process](release-process.md) for details on creating hotfixes diff --git a/doc/devdocs/readme.md b/doc/devdocs/readme.md index 50912c5b3f..e7c96c7776 100644 --- a/doc/devdocs/readme.md +++ b/doc/devdocs/readme.md @@ -1,20 +1,114 @@ -# Dev Documentation +# PowerToys Developer Documentation -## Fork, Clone, Branch and Create your PR +Welcome to the PowerToys developer documentation. This documentation provides information for developers who want to contribute to PowerToys or understand how it works. -Once you've discussed your proposed feature/fix/etc. with a team member, and an approach or a spec has been written and approved, it's time to start development: +## Getting Started + +### Prerequisites + +1. Windows 10 April 2018 Update (version 1803) or newer +1. [Visual Studio 2026](https://visualstudio.microsoft.com/downloads/) (recommended) or Visual Studio 2022 17.4+ with the following workloads/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) +1. .NET 8 SDK +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) + +> **Tip:** You can install Visual Studio with all required workloads automatically using the [WinGet configuration files](https://github.com/microsoft/PowerToys/tree/main/.config) in the repository: +> ```powershell +> winget configure .config\configuration.winget +> ``` +> Pick the file that matches your VS edition (e.g., `configuration.vsProfessional.winget` or `configuration.vsEnterprise.winget`). + +### Fork, Clone, and Set Up 1. Fork the repo on GitHub if you haven't already 1. Clone your fork locally -1. Create a feature branch -1. Work on your changes -1. Create a [Draft Pull Request (PR)](https://github.blog/2019-02-14-introducing-draft-pull-requests/) -1. When ready, mark your PR as "ready for review". +1. Run the automated setup script (**recommended**): + +```powershell +.\tools\build\setup-dev-environment.ps1 +``` + +This script will: +- Enable Windows long path support (requires administrator privileges) +- Enable Windows Developer Mode (requires administrator privileges) +- Guide you through installing required Visual Studio components from `.vsconfig` +- Initialize git submodules + +Run with `-Help` to see all available options. + +<details> +<summary><strong>Manual setup (if you prefer not to use the script)</strong></summary> + +#### Install Visual Studio dependencies + +1. Open the `PowerToys.slnx` file. +1. If you see a dialog that says `install extra components` in the solution explorer pane, click `install` + +Alternatively, import the `.vsconfig` file from the repository root using Visual Studio Installer to install all required workloads. + +#### Initialize submodules + +This is a one-time step required before you can compile most parts of PowerToys. + +1. Open a terminal +1. Navigate to the folder you cloned PowerToys to. +1. Run `git submodule update --init --recursive` + +</details> + +### Building + +#### Using Visual Studio + +- Open `PowerToys.slnx` in Visual Studio. +- In the `Solutions Configuration` drop-down menu select `Release` or `Debug`. +- From the `Build` menu choose `Build Solution`, or press <kbd>Control</kbd>+<kbd>Shift</kbd>+<kbd>b</kbd> on your keyboard. +- The build process may take several minutes depending on your computer's performance. Once it completes, the PowerToys binaries will be in your repo under `x64\Release\`. + - You can run `x64\Release\PowerToys.exe` directly without installing PowerToys, but some modules (i.e. PowerRename, ImageResizer, File Explorer extension etc.) will not be available unless you also build the installer and install PowerToys. + +#### Using Command Line + +You can also build from the command line using the provided scripts in `tools\build\`: + +```powershell +# Build the full solution (auto-detects platform) +.\tools\build\build.ps1 + +# Build with specific configuration +.\tools\build\build.ps1 -Platform x64 -Configuration Release + +# Build only essential projects (runner + settings) for faster iteration +.\tools\build\build-essentials.ps1 + +# Build everything including the installer (Release only) +.\tools\build\build-installer.ps1 +``` + +### Debugging + +See [Debugging](development/debugging.md) for detailed debugging techniques, including Visual Studio setup, attaching to child processes, and troubleshooting build errors. + +### Creating a New PowerToy + +See [Creating a New PowerToy](development/new-powertoy.md) for an end-to-end guide covering module architecture, settings integration, installer packaging, and testing. + +## Development Guidelines + +- [Coding Guidelines](development/guidelines.md) - Development guidelines and best practices +- [Coding Style](development/style.md) - Code formatting and style conventions +- [Logging and Telemetry](development/logging.md) - How to use logging and telemetry +- [Localization](development/localization.md) - How to support multiple languages +- [UI Testing](development/ui-tests.md) - How to write UI tests for PowerToys +- [Developing with VS Code](development/dev-with-vscode.md) - Build, debug, and contribute using VS Code ## Rules - **Follow the pattern of what you already see in the code.** -- [Coding style](style.md). +- [Coding style](development/style.md). - Try to package new functionality/components into libraries that have nicely defined interfaces. - Package new functionality into classes or refactor existing functionality into a class as you extend the code. - When adding new classes/methods/changing existing code, add new unit tests or update the existing tests. @@ -25,45 +119,46 @@ 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. - Use the `Squash and merge` option to merge a PR. If you don't want to squash it because there are logically different commits, use `Rebase and merge`. -- We don't close issues automatically when referenced in a PR, so after the PR is merged: - - mark the issue(s) that the PR solved with the `Resolution-Fix-Committed` label, remove the `In progress` label and if the issue is assigned to a project, move the item to the `Done` status. - - don't close the issue if it's a bug in the current released version; since users tend to not search for closed issues, we will close the resolved issues when a new version is released. - - if it's not a code fix that effects the end user, the issue can be closed (for example a fix in the build or a code refactoring and so on). +- Close issues automatically when referenced in a PR. You can use [closing keywords](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) in the body of the PR to have GitHub automatically link your PR to the issue. -## Compiling PowerToys +## Core Architecture -### Prerequisites for Compiling PowerToys +- [Architecture Overview](core/architecture.md) - Overview of the PowerToys architecture and module interface +- [Runner and System tray](core/runner.md) - Details about the PowerToys Runner process +- [Settings](core/settings/readme.md) - Documentation on the settings system +- [Installer](core/installer.md) - Information about the PowerToys installer +- [Modules](modules/readme.md) - Documentation for individual PowerToys modules -1. Windows 10 April 2018 Update (version 1803) or newer -1. Visual Studio Community/Professional/Enterprise 2022 17.4 or newer -1. A local clone of the PowerToys repository +## Common Components -### Install Visual Studio dependencies +- [Context Menu Handlers](common/context-menus.md) - How PowerToys implements and registers Explorer context menu handlers +- [Monaco Editor](common/monaco-editor.md) - How PowerToys uses the Monaco code editor component across modules -1. Open the `PowerToys.sln` file. -1. If you see a dialog that says `install extra components` in the solution explorer pane, click `install` +## Tools -### Get Submodules to compile +- [Tools Overview](tools/readme.md) - Overview of tools in PowerToys +- [Build Tools](tools/build-tools.md) - Tools that help building PowerToys +- [Bug Report Tool](tools/bug-report-tool.md) - Tool for collecting logs and system information +- [Debugging Tools](tools/debugging-tools.md) - Specialized tools for debugging +- [Fuzzing Testing](tools/fuzzingtesting.md) - How to implement and run fuzz testing for PowerToys modules -We have submodules that need to be initialized before you can compile most parts of PowerToys. This should be a one-time step. +## Processes -1. Open a terminal -1. Navigate to the folder you cloned PowerToys to. -1. Run `git submodule update --init --recursive` +- [Release Process](processes/release-process.md) - How PowerToys releases are prepared and published +- [Update Process](processes/update-process.md) - How PowerToys updates work +- [GPO Implementation](processes/gpo.md) - Group Policy Objects implementation details -### Compiling Source Code +## Other Resources -- Open `PowerToys.sln` in Visual Studio. -- In the `Solutions Configuration` drop-down menu select `Release` or `Debug`. -- From the `Build` menu choose `Build Solution`, or press <kbd>Control</kbd>+<kbd>Shift</kbd>+<kbd>b</kbd> on your keyboard. -- The build process may take several minutes depending on your computer's performance. Once it completes, the PowerToys binaries will be in your repo under `x64\Release\`. - - You can run `x64\Release\PowerToys.exe` directly without installing PowerToys, but some modules (i.e. PowerRename, ImageResizer, File Explorer extension etc.) will not be available unless you also build the installer and install PowerToys. +- [aka.ms links](akaLinks.md) - List of short links +- [Issue/PR commands](commands.md) - Special commands for managing issues and pull requests -## Compile the installer +## Building the Installer Our installer is two parts, an EXE and an MSI. The EXE (Bootstrapper) contains the MSI and handles more complex installation logic. - The EXE installs all prerequisites and installs PowerToys via the MSI. It has additional features such as the installation flags (see below). @@ -71,94 +166,9 @@ Our installer is two parts, an EXE and an MSI. The EXE (Bootstrapper) contains The installer can only be compiled in `Release` mode; steps 1 and 2 must be performed before the MSI can be compiled. -1. Compile `PowerToys.sln`. Instructions are listed above. +1. Compile `PowerToys.slnx`. Instructions are listed above. 1. Compile `BugReportTool.sln` tool. Path from root: `tools\BugReportTool\BugReportTool.sln` (details listed below) 1. Compile `StylesReportTool.sln` tool. Path from root: `tools\StylesReportTool\StylesReportTool.sln` (details listed below) -1. Compile `PowerToysSetup.sln` Path from root: `installer\PowerToysSetup.sln` (details listed below) +1. Compile `PowerToysSetup.slnx` Path from root: `installer\PowerToysSetup.slnx` (details listed below) -### Prerequisites for building the MSI installer - -1. Install the [WiX Toolset Visual Studio 2022 Extension](https://marketplace.visualstudio.com/items?itemName=WixToolset.WixToolsetVisualStudio2022Extension). -1. Install the [WiX Toolset build tools](https://github.com/wixtoolset/wix3/releases/tag/wix3141rtm). (installer [direct link](https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314.exe)) -1. Download [WiX binaries](https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip) and extract `wix.targets` to `C:\Program Files (x86)\WiX Toolset v3.14`. - -### Building prerequisite projects - -#### From the command line - -1. From the start menu, open a `Developer Command Prompt for VS 2022` -1. Ensure `nuget.exe` is in your `%path%` -1. In the repo root, run these commands: - -``` -nuget restore .\tools\BugReportTool\BugReportTool.sln -msbuild -p:Platform=x64 -p:Configuration=Release .\tools\BugReportTool\BugReportTool.sln - -nuget restore .\tools\StylesReportTool\StylesReportTool.sln -msbuild -p:Platform=x64 -p:Configuration=Release .\tools\StylesReportTool\StylesReportTool.sln -``` - -#### From Visual Studio - -If you prefer, you can alternatively build prerequisite projects for the installer using the Visual Studio UI. - -1. Open `tools\BugReportTool\BugReportTool.sln` -1. In Visual Studio, in the `Solutions Configuration` drop-down menu select `Release` -1. From the `Build` menu, choose `Build Solution`. -1. Open `tools\StylesReportTool\StylesReportTool.sln` -1. In Visual Studio, in the `Solutions Configuration` drop-down menu select `Release` -1. From the `Build` menu, choose `Build Solution`. - -### Locally compiling the installer - -1. Open `installer\PowerToysSetup.sln` -1. In Visual Studio, in the `Solutions Configuration` drop-down menu select `Release` -1. From the `Build` menu choose `Build Solution`. - -The resulting `PowerToysSetup.msi` installer will be available in the `installer\PowerToysSetup\x64\Release\` folder. - -#### Supported arguments for the .EXE Bootstrapper installer - -Head over to the wiki to see the [full list of supported installer arguments][installerArgWiki]. - -## Debugging - -To debug the PowerToys application in Visual Studio, set the `runner` project as your start-up project, then start the debugger. - -Some PowerToys modules must be run with the highest permission level if the current user is a member of the Administrators group. The highest permission level is required to be able to perform some actions when an elevated application (e.g. Task Manager) is in the foreground or is the target of an action. Without elevated privileges some PowerToys modules will still work but with some limitations: - -- The `FancyZones` module will not be able to move an elevated window to a zone. -- The `Shortcut Guide` module will not appear if the foreground window belongs to an elevated application. - -Therefore, it is recommended to run Visual Studio with elevated privileges when debugging these scenarios. If you want to avoid running Visual Studio with elevated privileges and don't mind the limitations described above, you can do the following: open the `runner` project properties and navigate to the `Linker -> Manifest File` settings, edit the `UAC Execution Level` property and change it from `highestAvailable (level='highestAvailable')` to `asInvoker (/level='asInvoker'). - -## How to create new PowerToys - -See the instructions on [how to install the PowerToys Module project template](/tools/project_template). <br /> -Specifications for the [PowerToys settings API](settingsv2/readme.md). - -## Implementation details - -### [`Runner`](runner.md) - -The PowerToys Runner contains the project for the PowerToys.exe executable. -It's responsible for: - -- Loading the individual PowerToys modules. -- Passing registered events to the PowerToys. -- Showing a system tray icon to manage the PowerToys. -- Bridging between the PowerToys modules and the Settings editor. - -![Image of the tray icon](/doc/images/runner/tray.png) - -### [`Interface`](modules/interface.md) - -The definition of the interface used by the [`runner`](/src/runner) to manage the PowerToys. All PowerToys must implement this interface. - -### [`Common`](common.md) - -The common lib, as the name suggests, contains code shared by multiple PowerToys components and modules, e.g. [json parsing](/src/common/utils/json.h) and [IPC primitives](/src/common/interop/two_way_pipe_message_ipc.h). - -### [`Settings`](settingsv2/) - -Settings v2 is our current settings implementation. Please head over to the dev docs that describe the current settings system. +See [Installer](core/installer.md) for more details on building and debugging the installer. diff --git a/doc/devdocs/runner.md b/doc/devdocs/runner.md deleted file mode 100644 index a0c20be09b..0000000000 --- a/doc/devdocs/runner.md +++ /dev/null @@ -1,48 +0,0 @@ -#### [`main.cpp`](/src/runner/main.cpp) -Contains the executable starting point, initialization code and the list of known PowerToys. All singletons are also initialized here at the start. Loads all the powertoys by scanning the `./modules` folder and `enable()`s those marked as enabled in `%LOCALAPPDATA%\Microsoft\PowerToys\settings.json` config. Then it runs [a message loop](https://learn.microsoft.com/windows/win32/winmsg/using-messages-and-message-queues) for the tray UI. Note that this message loop also [handles lowlevel_keyboard_hook events](https://github.com/microsoft/PowerToys/blob/1760af50c8803588cb575167baae0439af38a9c1/src/runner/lowlevel_keyboard_event.cpp#L24). - -#### [`powertoy_module.h`](/src/runner/powertoy_module.h) and [`powertoy_module.cpp`](/src/runner/powertoy_module.cpp) -Contains code for initializing and managing the PowerToy modules. `PowertoyModule` is a RAII-style holder for the `PowertoyModuleIface` pointer, which we got by [invoking module DLL's `powertoy_create` function](https://github.com/microsoft/PowerToys/blob/1760af50c8803588cb575167baae0439af38a9c1/src/runner/powertoy_module.cpp#L13-L24). - -#### [`tray_icon.cpp`](/src/runner/tray_icon.cpp) -Contains code for managing the PowerToys tray icon and its menu commands. Note that `dispatch_run_on_main_ui_thread` is used to -transfer received json message from the [Settings window](/doc/devdocs/settings.md) to the main thread, since we're communicating with it from [a dedicated thread](https://github.com/microsoft/PowerToys/blob/7357e40d3f54de51176efe54fda6d57028837b8c/src/runner/settings_window.cpp#L267-L271). - -#### [`settings_window.cpp`](/src/runner/settings_window.cpp) -Contains code for starting the PowerToys settings window and communicating with it. Settings window is a separate process, so we're using [Windows pipes](https://learn.microsoft.com/windows/win32/ipc/pipes) as a transport for json messages. - -#### [`general_settings.cpp`](/src/runner/general_settings.cpp) -Contains code for loading, saving and applying the general settings. - -#### [`auto_start_helper.cpp`](/src/runner/auto_start_helper.cpp) -Contains helper code for registering and unregistering PowerToys to run when the user logs in. - -#### [`unhandled_exception_handler.cpp`](/src/runner/unhandled_exception_handler.cpp) -Contains helper code to get stack traces in builds. Can be used by adding a call to `init_global_error_handlers` in [`WinMain`](./main.cpp). - -#### [`trace.cpp`](/src/runner/trace.cpp) -Contains code for telemetry. - -#### [`svgs`](/src/runner/svgs/) -Contains the SVG assets used by the PowerToys modules. - -#### [`bug_report.cpp`](/src/runner/bug_report.cpp) -Contains logic to start bug report tool. - -#### [`centralized_hotkeys.cpp`](/src/runner/centralized_hotkeys.cpp) -Contains hot key logic registration and un-registration. - -#### [`centralized_kb_hook.cpp`](/src/runner/centralized_kb_hook.cpp) -Contains logic to handle PowerToys' keyboard shortcut functionality. - -#### [`restart_elevated.cpp`](/src/runner/restart_elevated.cpp) -Contains logic for restarting the current process with different elevation levels. - -#### [`RestartManagement.cpp`](/src/runner/RestartManagement.cpp) -Contains code for restarting a process. - -#### [`settings_telemetry.cpp`](/src/runner/settings_telemetry.cpp) -Contains logic that periodically triggers module-specific setting's telemetry delivery and manages timing and error handling for the process. - -#### [`UpdateUtils.cpp`](/src/runner/UpdateUtils.cpp) -Contains code to handle the automatic update checking, notification, and installation process for PowerToys. \ No newline at end of file diff --git a/doc/devdocs/settingsv2/readme.md b/doc/devdocs/settingsv2/readme.md deleted file mode 100644 index c9cb7945be..0000000000 --- a/doc/devdocs/settingsv2/readme.md +++ /dev/null @@ -1,12 +0,0 @@ -# Table of Contents -1. [Settings overview](/doc/devdocs/settingsv2/project-overview.md) -2. [UI Architecture](/doc/devdocs/settingsv2/ui-architecture.md) -3. [ViewModels](/doc/devdocs/settingsv2/viewmodels.md) -4. Data flow - - [Inter-Process Communication with runner](/doc/devdocs/settingsv2/runner-ipc.md) - - [Communication with modules](/doc/devdocs/settingsv2/communication-with-modules.md) -5. [Settings Utilities](/doc/devdocs/settingsv2/settings-utilities.md) -6. [Custom Hotkey control and keyboard hook handling](hotkeycontrol.md) -7. [Compatibility with legacy settings and runner](/doc/devdocs/settingsv2/compatibility-legacy-settings.md) -8. [XAML Island tweaks](/doc/devdocs/settingsv2/xaml-island-tweaks.md) -9. [Telemetry](/doc/devdocs/settingsv2/telemetry.md) diff --git a/doc/devdocs/tools/bug-report-tool.md b/doc/devdocs/tools/bug-report-tool.md index f73bdf3fc3..f02b0da982 100644 --- a/doc/devdocs/tools/bug-report-tool.md +++ b/doc/devdocs/tools/bug-report-tool.md @@ -1,23 +1,56 @@ -# [Bug report tool](/tools/BugReportTool/) +# Bug Report Tool -This tool is used to collect logs and system information for bug reports. The bug report is then saved as a zip file on the desktop. +The Bug Report Tool is a utility that collects logs and system information to help diagnose issues with PowerToys. It creates a comprehensive report that can be shared with developers to help troubleshoot problems. -## Launching +## Location and Access -It can launch from the PowerToys tray icon by clicking "Report Bug", by clicking the bug report icon in the PowerToys flyout or by running the executable directly. +- Source code: `/tools/BugReportTool/` +- Users can trigger the tool via: + - Right-click on PowerToys tray icon → Report Bug + - Left-click on tray icon → Open Settings → Bug Report Tool -## Included files +## What It Does -The bug report includes the following files: +The Bug Report Tool creates a zip file on the desktop named "PowerToys_Report_[date]_[time].zip" containing logs and system information. It: + +1. Copies logs from PowerToys application directories +2. Collects system information relevant to PowerToys functionality +3. Redacts sensitive information +4. Packages everything into a single zip file for easy sharing + +## Information Collected + +### Logs +- Copies logs from: + - `%LOCALAPPDATA%\Microsoft\PowerToys\Logs` - Regular logs + - `%USERPROFILE%\AppData\LocalLow\Microsoft\PowerToys` - Low-privilege logs + +### System Information +- Windows version and build information +- Language and locale settings +- Monitor information (crucial for FancyZones and multi-monitor scenarios) +- .NET installation details +- PowerToys registry entries +- Group Policy Object (GPO) settings +- Application compatibility mode settings +- Event Viewer logs related to PowerToys executables +- PowerToys installer logs +- Windows 11 context menu package information + +### PowerToys Configuration +- Settings files +- Module configurations +- Installation details +- File structure and integrity (with hashes) + +## Key Files in the Report -* Settings files of the modules. -* Logs of the modules and the runner. -* Update log files. * `compatibility-tab-info.txt` - Information about [compatibility settings](https://support.microsoft.com/windows/make-older-apps-or-programs-compatible-with-windows-783d6dd7-b439-bdb0-0490-54eea0f45938) set for certain PowerToys executables both in the user and system scope. * `context-menu-packages.txt` - Information about the packages that are registered for the new Windows 11 context menu. * `dotnet-installation-info.txt` - Information about the installed .NET versions. * `EventViewer-*.xml` - These files contain event logs from the Windows Event Viewer for the executable specified in the file name. -* `gpo-configuration-info.txt` - Information about the configured [GPO](/doc/gpo/README.md). +* `EventViewer-Microsoft-Windows-AppXDeploymentServer/Operational.xml` - Contains event logs from the AppXDeployment-Server which are useful for diagnosing MSIX installation issues. +* `gpo-configuration-info.txt` - Information about the configured [GPO](doc/devdocs/processes/gpo.md). * `installationFolderStructure.txt` - Information about the folder structure of the installation. All lines with files have the following structure: `FileName Version MD5Hash`. * `last_version_run.json` - Information about the last version of PowerToys that was run. * `log_settings.json` - Information about the log level settings. @@ -29,3 +62,58 @@ The bug report includes the following files: * `UpdateState.json` - Information about the last update check and the current status of the update download. * `windows-settings.txt` - Information about the Windows language settings. * `windows-version.txt` - Information about the Windows version. + +## Privacy Considerations + +The tool redacts certain types of private information: +- Mouse Without Borders security keys +- FancyZones app zone history +- User-specific paths +- Machine names + +## Implementation Details + +The tool is implemented as a C# console application that: +1. Creates a temporary directory +2. Copies logs and configuration files to this directory +3. Runs commands to collect system information +4. Redacts sensitive information +5. Compresses everything into a zip file +6. Cleans up the temporary directory + +### Core Components + +- `BugReportTool.exe` - Main executable +- Helper classes for collecting specific types of information +- Redaction logic to remove sensitive data + +## Extending the Bug Report Tool + +When adding new PowerToys features, the Bug Report Tool may need to be updated to collect relevant information. Areas to consider: + +1. New log locations to include +2. Additional registry keys to examine +3. New GPO values to report +4. Process names to include in Event Viewer data collection +5. New configuration files to include + +## Build Process + +The Bug Report Tool is built separately from the main PowerToys solution: + +1. Path from root: `tools\BugReportTool\BugReportTool.sln` +2. Must be built before building the installer +3. Built version is included in the PowerToys installer + +### Building from the Command Line + +``` +nuget restore .\tools\BugReportTool\BugReportTool.sln +msbuild -p:Platform=x64 -p:Configuration=Release .\tools\BugReportTool\BugReportTool.sln +``` + +### Building from Visual Studio + +1. Open `tools\BugReportTool\BugReportTool.sln` +2. Set the Solution Configuration to `Release` +3. Build the solution \ No newline at end of file diff --git a/doc/devdocs/tools/debugging-tools.md b/doc/devdocs/tools/debugging-tools.md new file mode 100644 index 0000000000..03f5fd88a8 --- /dev/null +++ b/doc/devdocs/tools/debugging-tools.md @@ -0,0 +1,111 @@ +# PowerToys Debugging Tools + +PowerToys includes several specialized tools to help with debugging and troubleshooting. These tools are designed to make it easier to diagnose issues with PowerToys features. + +## FancyZones Debugging Tools + +### FancyZones Hit Test Tool + +- Location: `/tools/FancyZonesHitTest/` +- Purpose: Tests FancyZones layout selection logic +- Functionality: + - Simulates mouse cursor positions + - Highlights which zone would be selected + - Helps debug zone detection issues + +### FancyZones Draw Layout Test + +- Location: `/tools/FancyZonesDrawLayoutTest/` +- Purpose: Tests FancyZones layout drawing logic +- Functionality: + - Visualizes how layouts are drawn + - Helps debug rendering issues + - Tests different monitor configurations + +### FancyZones Zonable Tester + +- Location: `/tools/FancyZonesZonableTester/` +- Purpose: Tests if a window is "zonable" (can be moved to zones) +- Functionality: + - Checks if windows match criteria for zone placement + - Helps debug why certain windows can't be zoned + +## Monitor Information Tools + +### Monitor Info Report + +- Location: `/tools/MonitorPickerTool/` +- Purpose: Diagnostic tool for identifying WinAPI bugs related to physical monitor detection +- Functionality: + - Lists all connected monitors + - Shows detailed monitor information + - Helps debug multi-monitor scenarios + +## Window Information Tools + +### Styles Report Tool + +- Location: `/tools/StylesReportTool/` +- Purpose: Collect information about an open window +- Functionality: + - Reports window styles + - Shows window class information + - Helps debug window-related issues in modules like FancyZones + +### Build Process + +The Styles Report Tool is built separately from the main PowerToys solution: + +``` +nuget restore .\tools\StylesReportTool\StylesReportTool.sln +msbuild -p:Platform=x64 -p:Configuration=Release .\tools\StylesReportTool\StylesReportTool.sln +``` + +## Shell-Related Debugging Tools + +### PowerRenameContextMenu Test + +- Location: `/tools/PowerRenameContextMenuTest/` +- Purpose: Tests PowerRename context menu integration +- Functionality: + - Simulates right-click context menu + - Helps debug shell extension issues + +## Verification Tools + +### Verification Scripts + +- Location: `/tools/verification-scripts/` +- Purpose: Scripts to verify PowerToys installation and functionality +- Functionality: + - Verify binary integrity + - Check registry entries + - Test module loading + +## Other Debugging Tools + +### Clean Up Tool + +- Location: `/tools/CleanUp/` +- Purpose: Clean up PowerToys installation artifacts +- Functionality: + - Removes registry entries + - Deletes settings files + - Helps with clean reinstallation + +### Using Debugging Tools + +1. Most tools can be run directly from the command line +2. Some tools require administrator privileges +3. Tools are typically used during development or for advanced troubleshooting +4. Bug Report Tool can collect and package the output from several of these tools + +## Adding New Debugging Tools + +When creating new debugging tools: + +1. Place the tool in the `/tools/` directory +2. Follow existing naming conventions +3. Document the tool in this file +4. Include a README.md in the tool's directory +5. Consider adding the tool's output to the Bug Report Tool if appropriate diff --git a/doc/devdocs/tools/fancyzones-draw-layout-test.md b/doc/devdocs/tools/fancyzones-draw-layout-test.md deleted file mode 100644 index 9f15a3a2ad..0000000000 --- a/doc/devdocs/tools/fancyzones-draw-layout-test.md +++ /dev/null @@ -1,7 +0,0 @@ -# [FancyZones_DrawLayoutTest](/tools/FancyZones_DrawLayoutTest/) - -This test tool is created in order to debug issues related to the drawing of zone layout on screen. - -Currently, only column layout is supported with modifiable number of zones. Pressing **w** key toggles zone appearance on primary screen (multi monitor support not yet in place). Pressing **q** key exits application. - -Application is DPI unaware which means that application does not scale for DPI changes and it always assumes to have a scale factor of 100% (96 DPI). Scaling will be automatically performed by the system. diff --git a/doc/devdocs/tools/fancyzones-hit-test.md b/doc/devdocs/tools/fancyzones-hit-test.md deleted file mode 100644 index 96856891f9..0000000000 --- a/doc/devdocs/tools/fancyzones-hit-test.md +++ /dev/null @@ -1,5 +0,0 @@ -# [FancyZone hit test tool](/tools/FancyZone_HitTest/) - -![Image of the FancyZones hit test tool](/doc/images/tools/fancyzones-hit-test.png) - -This tool tests the FancyZones layout selection logic. It displays a window with 5 zones. By hovering the mouse over the zones, the zone under the mouse cursor is highlighted. The sidebar shows different metrics that are used to determine which zone is under the mouse cursor. diff --git a/doc/devdocs/tools/fancyzones-zonable-tester.md b/doc/devdocs/tools/fancyzones-zonable-tester.md deleted file mode 100644 index 0b552f4636..0000000000 --- a/doc/devdocs/tools/fancyzones-zonable-tester.md +++ /dev/null @@ -1,13 +0,0 @@ -# [FancyZones_zonable_tester](/tools/FancyZones_zonable_tester/) - -![Image of the FancyZones zonable tester](/doc/images/tools/fancyzones-zonable-tester.png) - -This command line application tests if the window where the mouse cursor is located is zonable. It also adds additional information about the window to the console output: - -* The HWND (window handle) of the window -* The process ID of the window -* The HWND of the window in the foreground -* The style of the window -* The exStyle of the window -* The window class -* The path of the process that created the window diff --git a/doc/devdocs/tools/fuzzingtesting.md b/doc/devdocs/tools/fuzzingtesting.md new file mode 100644 index 0000000000..668be2e689 --- /dev/null +++ b/doc/devdocs/tools/fuzzingtesting.md @@ -0,0 +1,243 @@ +# Fuzzing Testing in PowerToys + +## Overview + +Fuzzing is an automated testing technique that helps identify vulnerabilities and bugs by feeding random, invalid, or unexpected data into the application. This is especially important for PowerToys modules that handle file input/output or user input, such as Hosts File Editor, Registry Preview, and others. + +PowerToys integrates Microsoft's OneFuzz service to systematically discover edge cases and unexpected behaviors that could lead to crashes or security vulnerabilities. Fuzzing testing is a requirement from the security team to ensure robust and secure modules. + +## Why Fuzzing Matters + +- **Security Enhancement**: Identifies potential security vulnerabilities before they reach production +- **Stability Improvement**: Discovers edge cases that might cause crashes +- **Automated Bug Discovery**: Finds bugs that traditional testing might miss +- **Reduced Manual Testing**: Automates the process of testing with unusual inputs + +## Types of Fuzzing in PowerToys + +PowerToys supports two types of fuzzing depending on the module's implementation language: + +1. **.NET Fuzzing** - For C# modules (using [OneFuzz](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/howto/fuzzing-dotnet-code)) +2. **C++ Fuzzing** - For native C++ modules using [libFuzzer](https://llvm.org/docs/LibFuzzer.html) + +## Setting Up .NET Fuzzing Tests + +### Step 1: Add a Fuzzing Test Project + +Create a new test project within your module folder. Ensure the project name follows the format `*.FuzzTests`. + +### Step 2: Configure the Project + +1. Set up a `.NET 8 (Windows)` project + - Note: OneFuzz currently supports only .NET 8 projects. The Fuzz team is working on .NET 9 support. + +2. Add the required files to your fuzzing test project: + - Create fuzzing test code + - Add `OneFuzzConfig.json` configuration file + +### Step 3: Configure OneFuzzConfig.json + +The `OneFuzzConfig.json` file provides critical information for deploying fuzzing jobs. For detailed guidance, see the [OneFuzzConfig V3 Documentation](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/onefuzzconfig/onefuzzconfigv3). + +```json +{ + "fuzzers": [ + { + "name": "YourModuleFuzzer", + "fuzzerLibrary": "libfuzzer-dotnet", + "targetAssembly": "YourModule.FuzzTests.dll", + "targetClass": "YourModule.FuzzTests.FuzzTestClass", + "targetMethod": "FuzzTest", + "FuzzingTargetBinaries": [ + "YourModule.FuzzTests.dll" + ] + } + ], + "adoTemplate": [ + { + "AssignedTo": "leilzh@microsoft.com", + "jobNotificationEmail": "PowerToys@microsoft.com" + } + ], + "oneFuzzJobs": [ + { + "projectName": "PowerToys", + "targetName": "YourModule", + "jobDependencies": { + "binaries": [ + "PowerToys\\x64\\Debug\\tests\\YourModule.FuzzTests\\net8.0-windows10.0.19041.0\\**" + ] + } + } + ], + "configVersion": "3.0.0" +} +``` + +Key fields to update: +1. Update the `targetAssembly`, `targetClass`, `targetMethod`, and `FuzzingTargetBinaries` fields +2. Set the `AssignedTo` and `jobNotificationEmail` to your Microsoft email +3. Update the `projectName` and `targetName` fields +4. Define job dependencies pointing to your compiled fuzzing tests + +### Step 4: Configure the OneFuzz Pipeline + +Modify the patterns in the job steps within [job-fuzz.yml](https://github.com/microsoft/PowerToys/blob/main/.pipelines/v2/templates/job-fuzz.yml) to match your fuzzing project name: + +```yaml +- download: current + displayName: Download artifacts + artifact: $(ArtifactName) + patterns: |- + **/tests/*.FuzzTests/** +``` + +## Setting Up C++ Fuzzing Tests + +### Step 1: Create a New C++ Project + +- Use the **Empty Project** template +- Name it `<ModuleName>.FuzzingTest` + +### Step 2: Update Build Configuration + +- In **Configuration Manager**, uncheck Build for both Release|ARM64, Debug|ARM64 and Debug|x64 configurations +- ARM64 is not supported for fuzzing tests + +### Step 3: Enable ASan and libFuzzer in .vcxproj + +Edit the project file to enable fuzzing: + +```xml +<PropertyGroup> + <EnableASAN>true</EnableASAN> + <EnableFuzzer>true</EnableFuzzer> +</PropertyGroup> +``` + +### Step 4: Add Fuzzing Compiler Flags + +Add these to `AdditionalOptions` under the `Fuzzing` configuration: + +```xml +/fsanitize=address +/fsanitize-coverage=inline-8bit-counters +/fsanitize-coverage=edge +/fsanitize-coverage=trace-cmp +/fsanitize-coverage=trace-div +%(AdditionalOptions) +``` + +### Step 5: Link the Sanitizer Coverage Runtime + +In `Linker → Input → Additional Dependencies`, add: + +```text +$(VCToolsInstallDir)lib\$(Platform)\libsancov.lib +``` + +### Step 6: Copy Required Runtime DLL + +Add a `PostBuildEvent` to copy the ASAN DLL: + +```xml +<Command> + xcopy /y "$(VCToolsInstallDir)bin\Hostx64\x64\clang_rt.asan_dynamic-x86_64.dll" "$(OutDir)" +</Command> +``` + +### Step 7: Add Preprocessor Definitions + +To avoid annotation issues, add these to the `Preprocessor Definitions`: + +```text +_DISABLE_VECTOR_ANNOTATION;_DISABLE_STRING_ANNOTATION +``` + +### Step 8: Implement the Entry Point + +Every C++ fuzzing project must expose this function: + +```cpp +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) +{ + std::string input(reinterpret_cast<const char*>(data), size); + + try + { + // Call your module with the input here + } + catch (...) {} + + return 0; +} +``` + +## Running Fuzzing Tests + +### Running Locally (.NET) + +To run .NET fuzzing tests locally, follow the [Running a .NET Fuzz Target Locally](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/howto/fuzzing-dotnet-code#extra-running-a-net-fuzz-target-locally) guide: + +```powershell +# Instrument the assembly +.\dotnet-fuzzing-windows\sharpfuzz\SharpFuzz.CommandLine.exe path\to\YourModule.FuzzTests.dll + +# Set environment variables +$env:LIBFUZZER_DOTNET_TARGET_ASSEMBLY="path\to\YourModule.FuzzTests.dll" +$env:LIBFUZZER_DOTNET_TARGET_CLASS="YourModule.FuzzTests.FuzzTestClass" +$env:LIBFUZZER_DOTNET_TARGET_METHOD="FuzzTest" + +# Run the fuzzer +.\dotnet-fuzzing-windows\libfuzzer-dotnet\libfuzzer-dotnet.exe --target_path=dotnet-fuzzing-windows\LibFuzzerDotnetLoader\LibFuzzerDotnetLoader.exe +``` + +### Running in the Cloud + +To submit a job to the OneFuzz cloud service, follow the [OneFuzz Cloud Testing Walkthrough](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/faq/notwindows/walkthrough): + +1. Run the pipeline: + - Navigate to the [fuzzing pipeline](https://microsoft.visualstudio.com/Dart/_build?definitionId=152899&view=runs) + - Click "Run pipeline" + - Choose your branch and start the run + +2. Alternative: Use [OIP (OneFuzz Ingestion Preparation) tool](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/oip/onefuzzingestionpreparationtool): + ``` + oip submit --config .\OneFuzzConfig.json --drop-path <your_submission_directory> --platform windows --do-not-file-bugs --duration 1 + ``` + - Use `--do-not-file-bugs` to prevent automatic bug creation during initial testing + - `--duration` specifies the number of hours (default is 48 if not specified) + +3. OneFuzz will send you an email when the job has started with a link to view results + +## Reviewing Results + +1. You'll receive an email notification when your fuzzing job starts +2. Click the link in the email to view the job status on the [OneFuzz Web UI](https://onefuzz-ui.microsoft.com/) +3. The OneFuzz platform will show statistics like inputs processed, coverage, and any crashes found +4. If the final status is "success," your fuzzing test is working correctly + +## Current Status + +PowerToys has implemented fuzzing for several modules: +- Hosts File Editor +- Registry Preview +- Fancy Zones + +Modules that still need fuzzing implementation: +- Environmental Variables +- Keyboard Manager + +## Requesting Access to OneFuzz + +To log into the production instance of OneFuzz with the [CLI](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/howto/downloading-cli), you must request access. Visit the [OneFuzz Access Request Page](https://myaccess.microsoft.com/@microsoft.onmicrosoft.com#/access-packages/6df691eb-e3d1-444b-b4b2-9e944dc794be). + +## Resources + +- [OneFuzz Documentation](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/howto/fuzzing-dotnet-code) +- [OneFuzzConfig V3 Documentation](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/onefuzzconfig/onefuzzconfigv3) +- [OneFuzz Ingestion Preparation Tool](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/oip/onefuzzingestionpreparationtool) +- [OneFuzz CLI Setup Guide](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/howto/downloading-cli) +- [OneFuzz Web UI](https://onefuzz-ui.microsoft.com/) +- [libFuzzer Documentation](https://llvm.org/docs/LibFuzzer.html) +- [OneFuzz Cloud Testing Walkthrough](https://eng.ms/docs/cloud-ai-platform/azure-edge-platform-aep/aep-security/epsf-edge-and-platform-security-fundamentals/the-onefuzz-service/onefuzz/faq/notwindows/walkthrough) diff --git a/doc/devdocs/tools/readme.md b/doc/devdocs/tools/readme.md index a2640bda7a..8050a3a6b7 100644 --- a/doc/devdocs/tools/readme.md +++ b/doc/devdocs/tools/readme.md @@ -11,9 +11,6 @@ Following tools are currently available: * [BugReportTool](bug-report-tool.md) - A tool to collect logs and system information for bug reports. * [Build tools](build-tools.md) - A set of scripts that help building PowerToys. * [Clean up tool](clean-up-tool.md) - A tool to clean up the PowerToys installation. -* [FancyZones hit test](fancyzones-hit-test.md) - A tool to test FancyZones layout selection logic. -* [FancyZones draw layout test](fancyzones-draw-layout-test.md) - A tool to test FancyZones layout drawing logic. -* [FancyZones zonable tester](fancyzones-zonable-tester.md) - A tool to test if a window is zonable. * [Monitor info report](monitor-info-report.md) - A small diagnostic tool which helps identifying WinAPI bugs related to the physical monitor detection. * [project template](/tools/project_template/README.md) - A Visual Studio project template for a new PowerToys project. * [StylesReportTool](styles-report-tool.md) - A tool to collect information about an open window. diff --git a/doc/devdocs/tools/verification-scripts.md b/doc/devdocs/tools/verification-scripts.md index cff58f478f..d31ecd8e66 100644 --- a/doc/devdocs/tools/verification-scripts.md +++ b/doc/devdocs/tools/verification-scripts.md @@ -18,6 +18,7 @@ This script checks the preview handler registration for the following file types * .svgz * .pdf * .gcode +* .bgcode * .stl * .txt * .ini diff --git a/doc/dsc/Settings.md b/doc/dsc/Settings.md new file mode 100644 index 0000000000..24fab1e2dd --- /dev/null +++ b/doc/dsc/Settings.md @@ -0,0 +1,83 @@ +# Settings resource +Manage the settings for PowerToys modules + +## Commands + +### ✨ Modules +List all the modules supported by the settings resource. +```shell +PS C:\> PowerToys.DSC.exe modules --resource 'settings' +AdvancedPaste +AlwaysOnTop +App +Awake +ColorPicker +CropAndLock +EnvironmentVariables +FancyZones +FileLocksmith +FindMyMouse +Hosts +ImageResizer +KeyboardManager +MeasureTool +MouseHighlighter +MouseJump +MousePointerCrosshairs +Peek +PowerAccent +PowerOCR +PowerRename +RegistryPreview +ShortcutGuide +Workspaces +ZoomIt +``` + +### 📄 Get +Get the settings for a specific module. +```shell +PS C:\> PowerToys.DSC.exe get --resource 'settings' --module EnvironmentVariables +{"settings":{"properties":{"LaunchAdministrator":{"value":true}},"name":"EnvironmentVariables","version":"1.0"}} +``` + +### 🖨️ Export +Export the settings for a specific module. + +ℹ️ Settings resource Get and Export operation output states are identical. +```shell +PS C:\> PowerToys.DSC.exe get --resource 'settings' --module EnvironmentVariables +{"settings":{"properties":{"LaunchAdministrator":{"value":true}},"name":"EnvironmentVariables","version":"1.0"}} +``` + +### 📝 Set +Set the settings for a specific module. This command will update the settings to the specified values. +```shell +PS C:\> PowerToys.DSC.exe set --resource 'settings' --module Awake --input '{"settings":{"properties":{"keepDisplayOn":false,"mode":0,"intervalHours":0,"intervalMinutes":1,"expirationDateTime":"2025-08-13T10:10:00.000001-07:00","customTrayTimes":{}},"name":"Awake","version":"0.0.1"}}' +{"settings":{"properties":{"keepDisplayOn":false,"mode":0,"intervalHours":0,"intervalMinutes":1,"expirationDateTime":"2025-08-13T10:10:00.000001-07:00","customTrayTimes":{}},"name":"Awake","version":"0.0.1"}} +["settings"] +``` + +### 🧪 Test +Test the settings for a specific module. This command will check if the current settings match the desired state. +```shell +PS C:\> PowerToys.DSC.exe test --resource 'settings' --module Awake --input '{"settings":{"properties":{"keepDisplayOn":false,"mode":0,"intervalHours":0,"intervalMinutes":1,"expirationDateTime":"2025-08-13T10:10:00.000002-07:00","customTrayTimes":{}},"name":"Awake","version":"0.0.1"}}' +{"settings":{"properties":{"keepDisplayOn":false,"mode":0,"intervalHours":0,"intervalMinutes":1,"expirationDateTime":"2025-08-13T10:10:00.000001-07:00","customTrayTimes":{}},"name":"Awake","version":"0.0.1"},"_inDesiredState":false} +["settings"] +``` + +### 🛠️ Schema +Generates the JSON schema for the settings resource of a specific module. +```shell +PS C:\> PowerToys.DSC.exe schema --resource 'settings' --module Awake +{"$schema":"http://json-schema.org/draft-04/schema#","title":"SettingsResourceObjectOfAwakeSettings","type":"object","additionalProperties":false,"required":["settings"],"properties":{"_inDesiredState":{"type":["boolean","null"],"description":"Indicates whether an instance is in the desired state"},"settings":{"description":"The settings content for the module."}}} +PS E:\src\powertoys> PowerToys.DSC.exe schema --resource 'settings' --module Awake | Format-Json +``` + +### 📦 Manifest +Generates a manifest dsc resource JSON file for the specified module. +- If the module is not specified, it will generate a manifest for all modules. +- If the output directory is not specified, it will print the manifest to the console. +```shell +PS C:\> PowerToys.DSC.exe manifest --resource settings --module 'Awake' --outputDir "C:\manifests" +``` \ No newline at end of file diff --git a/doc/images/icons/Command Palette.png b/doc/images/icons/Command Palette.png new file mode 100644 index 0000000000..7360fdd113 Binary files /dev/null and b/doc/images/icons/Command Palette.png differ diff --git a/doc/images/icons/CursorWrap.png b/doc/images/icons/CursorWrap.png new file mode 100644 index 0000000000..20db84fc9a Binary files /dev/null and b/doc/images/icons/CursorWrap.png differ diff --git a/doc/images/icons/Find My Mouse.png b/doc/images/icons/Find My Mouse.png index 71dd994569..82fbe59800 100644 Binary files a/doc/images/icons/Find My Mouse.png and b/doc/images/icons/Find My Mouse.png differ diff --git a/doc/images/icons/Light Switch.png b/doc/images/icons/Light Switch.png new file mode 100644 index 0000000000..8a0778ff05 Binary files /dev/null and b/doc/images/icons/Light Switch.png differ diff --git a/doc/images/icons/Mouse Crosshairs.png b/doc/images/icons/Mouse Crosshairs.png index 6b1dcb9c16..a2c64a72a4 100644 Binary files a/doc/images/icons/Mouse Crosshairs.png and b/doc/images/icons/Mouse Crosshairs.png differ diff --git a/doc/images/icons/Mouse Highlighter.png b/doc/images/icons/Mouse Highlighter.png index b06843d941..0feb5cc15a 100644 Binary files a/doc/images/icons/Mouse Highlighter.png and b/doc/images/icons/Mouse Highlighter.png differ diff --git a/doc/images/icons/MouseJump.png b/doc/images/icons/MouseJump.png new file mode 100644 index 0000000000..2fbe450ac2 Binary files /dev/null and b/doc/images/icons/MouseJump.png differ diff --git a/doc/images/icons/MouseWithoutBorders.png b/doc/images/icons/MouseWithoutBorders.png index a29adf7d11..ee66893cbd 100644 Binary files a/doc/images/icons/MouseWithoutBorders.png and b/doc/images/icons/MouseWithoutBorders.png differ diff --git a/doc/images/icons/ZoomIt.png b/doc/images/icons/ZoomIt.png new file mode 100644 index 0000000000..777a30bd1f Binary files /dev/null and b/doc/images/icons/ZoomIt.png differ diff --git a/doc/images/overview/LightSwitch_large.png b/doc/images/overview/LightSwitch_large.png new file mode 100644 index 0000000000..3a98b7f3e2 Binary files /dev/null and b/doc/images/overview/LightSwitch_large.png differ diff --git a/doc/images/overview/LightSwitch_small.png b/doc/images/overview/LightSwitch_small.png new file mode 100644 index 0000000000..c6e94735a9 Binary files /dev/null and b/doc/images/overview/LightSwitch_small.png differ diff --git a/doc/images/overview/Original/Light Switch.png b/doc/images/overview/Original/Light Switch.png new file mode 100644 index 0000000000..04e551a85d Binary files /dev/null and b/doc/images/overview/Original/Light Switch.png differ diff --git a/doc/images/overview/PT_hero_image.png b/doc/images/overview/PT_hero_image.png deleted file mode 100644 index 026a456297..0000000000 Binary files a/doc/images/overview/PT_hero_image.png and /dev/null differ diff --git a/doc/images/overview/PT_large.png b/doc/images/overview/PT_large.png deleted file mode 100644 index 340cde5283..0000000000 Binary files a/doc/images/overview/PT_large.png and /dev/null differ diff --git a/doc/images/overview/PT_small.png b/doc/images/overview/PT_small.png deleted file mode 100644 index 4c66f43b62..0000000000 Binary files a/doc/images/overview/PT_small.png and /dev/null differ diff --git a/doc/images/readme/StoreBadge-dark.png b/doc/images/readme/StoreBadge-dark.png new file mode 100644 index 0000000000..8095159a82 Binary files /dev/null and b/doc/images/readme/StoreBadge-dark.png differ diff --git a/doc/images/readme/StoreBadge-light.png b/doc/images/readme/StoreBadge-light.png new file mode 100644 index 0000000000..fc4c9aa8eb Binary files /dev/null and b/doc/images/readme/StoreBadge-light.png differ diff --git a/doc/images/readme/pt-hero.dark.png b/doc/images/readme/pt-hero.dark.png new file mode 100644 index 0000000000..e0ac68155a Binary files /dev/null and b/doc/images/readme/pt-hero.dark.png differ diff --git a/doc/images/readme/pt-hero.light.png b/doc/images/readme/pt-hero.light.png new file mode 100644 index 0000000000..8cdda7b92f Binary files /dev/null and b/doc/images/readme/pt-hero.light.png differ diff --git a/doc/images/runner/tray.png b/doc/images/runner/tray.png deleted file mode 100644 index f4632d8107..0000000000 Binary files a/doc/images/runner/tray.png and /dev/null differ diff --git a/doc/planning/awake.md b/doc/planning/awake.md index d6ccd6808f..c2c9c7b2fa 100644 --- a/doc/planning/awake.md +++ b/doc/planning/awake.md @@ -1,5 +1,5 @@ --- -last-update: 7-16-2024 +last-update: 1-18-2026 --- # PowerToys Awake Changelog @@ -12,6 +12,7 @@ The build ID moniker is made up of two components - a reference to a [Halo](http | Build ID | Build Date | |:-------------------------------------------------------------------|:------------------| +| [`DIDACT_01182026`](#DIDACT_01182026-january-18-2026) | January 18, 2026 | | [`TILLSON_11272024`](#TILLSON_11272024-november-27-2024) | November 27, 2024 | | [`PROMETHEAN_09082024`](#PROMETHEAN_09082024-september-8-2024) | September 8, 2024 | | [`VISEGRADRELAY_08152024`](#VISEGRADRELAY_08152024-august-15-2024) | August 15, 2024 | @@ -20,6 +21,22 @@ The build ID moniker is made up of two components - a reference to a [Halo](http | [`LIBRARIAN_03202022`](#librarian_03202022-march-20-2022) | March 20, 2022 | | `ARBITER_01312022` | January 31, 2022 | +### `DIDACT_01182026` (January 18, 2026) + +>[!NOTE] +>See pull request: [Awake - `DIDACT_01182026`](https://github.com/microsoft/PowerToys/pull/44795) + +- [#32544](https://github.com/microsoft/PowerToys/issues/32544) Fixed an issue where Awake settings became non-functional after the PC wakes from sleep. Added `WM_POWERBROADCAST` handling to detect system resume events (`PBT_APMRESUMEAUTOMATIC`, `PBT_APMRESUMESUSPEND`) and re-apply `SetThreadExecutionState` to restore the awake state. +- [#36150](https://github.com/microsoft/PowerToys/issues/36150) Fixed an issue where Awake would not prevent sleep when AC power is connected. Added `PBT_APMPOWERSTATUSCHANGE` handling to re-apply `SetThreadExecutionState` when the power source changes (AC/battery transitions). +- Fixed an issue where toggling "Keep screen on" during an active timed session would disrupt the countdown timer. The display setting now updates directly without restarting the timer, preserving the exact remaining time. +- [#41918](https://github.com/microsoft/PowerToys/issues/41918) Fixed `WM_COMMAND` message processing flaw in `TrayHelper.WndProc` that incorrectly compared enum values against enum count. Added proper bounds checking for custom tray time entries. +- Investigated [#44134](https://github.com/microsoft/PowerToys/issues/44134) - documented that `ES_DISPLAY_REQUIRED` (used when "Keep display on" is enabled) blocks Task Scheduler idle detection, preventing scheduled maintenance tasks like SSD TRIM. Workaround: disable "Keep display on" or manually run `Optimize-Volume -DriveLetter C -ReTrim`. Additional investigation needed for potential "idle window" feature. +- [#41738](https://github.com/microsoft/PowerToys/issues/41738) Fixed `--display-on` CLI flag default from `true` to `false` to align with documentation and PowerToys settings behavior. This is a breaking change for scripts relying on the undocumented default. +- [#41674](https://github.com/microsoft/PowerToys/issues/41674) Fixed silent failure when `SetThreadExecutionState` fails. The monitor thread now handles the return value, logs an error, and reverts to passive mode with updated tray icon. +- [#38770](https://github.com/microsoft/PowerToys/issues/38770) Fixed tray icon failing to appear after Windows updates. Increased retry attempts and delays for icon Add operations (10 attempts, up to ~15.5 seconds total) while keeping existing fast retry behavior for Update/Delete operations. +- [#40501](https://github.com/microsoft/PowerToys/issues/40501) Fixed tray icon not disappearing when Awake is disabled. The `SetShellIcon` function was incorrectly requiring an icon for Delete operations, causing the `NIM_DELETE` message to never be sent. +- [#40659](https://github.com/microsoft/PowerToys/issues/40659) Fixed potential stack overflow crash in EXPIRABLE mode. Added early return after SaveSettings when correcting past expiration times, matching the pattern used by other mode handlers to prevent reentrant execution. + ### `TILLSON_11272024` (November 27, 2024) >[!NOTE] diff --git a/doc/specs/search-result.png b/doc/specs/search-result.png new file mode 100644 index 0000000000..4acde037e5 Binary files /dev/null and b/doc/specs/search-result.png differ diff --git a/doc/specs/settings-search.md b/doc/specs/settings-search.md new file mode 100644 index 0000000000..e99bb448b6 --- /dev/null +++ b/doc/specs/settings-search.md @@ -0,0 +1,233 @@ +# PowerToys Settings – Search Index (Hard-sealed) + +## 1. What to index + +This section describes the current structure of the settings pages in PowerToys. All user-facing settings are contained in the content of <controls:SettingsPageControl>. The logical and visual structure of settings follows a nested layout as shown below: + +```css +SettingsPageControl + └─ SettingsGroup + └─ [SettingsExpander] + └─ SettingsCard +``` +* Each SettingsGroup defines a functional section within a settings page. + +* An optional SettingsExpander may be used to further organize related settings inside a group. + +* Each actual setting is represented by a SettingsCard, which contains one user-tweakable control or a group of closely related controls. + +>Note: Not all SettingsCard are necessarily wrapped in a SettingsExpander; they can exist directly under a SettingsGroup. + +> For indexing purposes, we are specifically targeting all SettingsCard elements. These are the smallest units of user interaction and correspond to individual configurable settings. + +> There could be setting item in expander, so we also need to index expander items as well. + +### Module +Module is a primary type that needs to be indexed, for modules, we need to index the 'ModuleTitle' and the 'ModuleDescription'. +So these two should be passed in by x:Uid and binding with a key. + + +### SettingsCard/SettingsExpander + +Each SettingsCard/SettingsExpander should have an x:Uid for localization and indexing. The associated display strings are defined in the .resw files: + +{x:Uid}.Header – The visible label/title of the setting. +{x:Uid}.Description – (optional) The tooltip or explanatory text. + +The index should be built around these SettingsCard elements and their x:Uid-bound resources, as they represent the actual settings users will search for. + +--- + +## 2. How to Navigate + +### Entry +```csharp +enum EntryType +{ + SettingsPage, + SettingsCard, + SettingsExpander, +} + +public class SearchableElementMetadata +{ + public string PageName { get; set; } // Used to navigate to a specific page + public EntryType Type { get; set; } // Used to know how should we navigate(As a page, a settingscard or an expander?) + public string ParentElementName { get; set; } + public string ElementName { get; set; } + public string ElementUid { get; set; } + public string Icon { get; set; } +} +``` + +### Navigation +The steps for navigate to an item: +* Navigate among pages +* [optional] Expand the expander if setting entry is inside an expander +* [optional] Navigate within page + +> Use page name for navigation: +```csharp +Type GetPageTypeFromPageName(string PageName) +{ + var assembly = typeof(GeneralPage).Assembly; + return assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{PageName}"); +} + +NavigationService.Navigate(PageType, ElementName,ParentElementName); +``` + +> Use ElementName and ParentElementName for in page navigation: +```csharp +Page.OnNavigateTo(ElementName, ParentElementName){ + var element = this.FindName(name) as FrameworkElement; + var parentElement = this.FindName(ParentElementName) as FrameworkElement; + + if(parentElement) { + expander = (Expander)parentElement; + if(expander){ + expander.Expand(); + } + + // https://learn.microsoft.com/en-us/uwp/api/windows.ui.xaml.uielement.startbringintoview?view=winrt-26100 + element.StartBringIntoView(); + } +} +``` + +## 3. Runtime Search +When user start typing for an entry, e.g. shortcut or 快捷键(cn version of shortcut), +we need to go through all the entries to see if an entry matches the search text. + +A naive approach will be try to match all the localized text one by one and see if they match. +Total entry is within thousand(To fill in an exact number), performance is acceptable now. +```csharp +// Match +query = UserInput(); +matched = {}; + +indexes = BuildIndex(); + +foreach(var entry in indexes) { + if(entry.Match(query)) { + matched.Add(entry); + } +} +``` + +And we don't intend to introduce complexity on the match algorithm discussion, so let's use powertoys FuzzMatch impl for now. +```csharp +MatchResult Match(this Entry entry, string query) { + return FuzzMatch(entry.DisplayedText, query); +} + +struct MatchResult{ + int Score; + bool Result; +} +``` + +## 4. Search Result Page +![search result page](./search-result.png) +After we got matched items, map these items to a search result page according to spec. +```c# +ObservableCollection<SettingEntry> ModuleResult; +ObservableCollection<SettingsGroup> GroupedSettingsResults; + +public class SettingsGroup : INotifyPropertyChanged +{ + private string _groupName; + private ObservableCollection<SettingEntry> _settings; + public string GroupName + { + get => _groupName; + set + { + _groupName = value; + OnPropertyChanged(); + } + } + public ObservableCollection<SettingEntry> Settings + { + get => _settings; + set + { + _settings = value; + OnPropertyChanged(); + } + } + public event PropertyChangedEventHandler PropertyChanged; + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} +``` + +## 5. How to do Index +### Runtime index or build time index? +Now We need to build all the entries in our settings. + +Most of the entry properties are static, and in runtime, the `SettingsCard` is compiled into native winUI3 controls <small>(I suppose, please correct here if it's wrong)</small>, it's hard to locate all the `SettingsCard`, and performance is terrible if we do search for all the pages' elements. + +### Build time indexing +We can rely on xaml file parsing to get all the SettingsCard Entries. +And we don't want xaml file to be brought into production bundle. +Use a project for parsing and bring that index file into production bundle is a solution. +```csproj + <Target Name="GenerateSearchIndex" BeforeTargets="BeforeBuild"> + <PropertyGroup> + <BuilderExe>$(MSBuildProjectDirectory)\..\Settings.UI.XamlIndexBuilder\bin\$(Configuration)\net8.0\XamlIndexBuilder.exe</BuilderExe> + <XamlDir>$(MSBuildProjectDirectory)\Views</XamlDir> + <GeneratedJson>$(MSBuildProjectDirectory)\Services\searchable_elements.json</GeneratedJson> + </PropertyGroup> + <Exec Command=""$(BuilderExe)" "$(XamlDir)" "$(GeneratedJson)"" /> + </Target> +``` +```csharp +for(xamlFile in xamlFiles){ + var doc = Load(xamlFile); + var elements = doc.Descendants(); + + foreach(var element in elements){ + if(element.Name == "SettingsCard") { + var entry = new Entry{ + ElementName = element.Attribute["Name"], + PageName = FileName, + Type = "SettingsCard", + ElementUid = element.Attribute["Uid"], + DisplayedText = "", + } + + var parent = element.GetParent(); + if(parent.Name == "SettingsExpander"){ + entry.ParentElementName = parent.Attribute["Name"]; + } + } + } +} +``` +Runtime index loading: +``` +var entries = LoadEntriesFromFile(); +foreach(var entry in entries){ + entry.DisplayedText = ResourceLoader.GetString(entry.Uid); +} +``` +So now we have all the entries and entry properties. + +## Overall flow: + +![search workflow](./workflow.png) + + +## 6. Corner cases - that have not addressed yet + +1. CmdPal page is not in scope of this effort, that needs additional effort&design to launch and search within cmdpal settings page. + +2. Go back button + +3. Dynamic constructed settings page + - Shortcut guide, with visibility converter + - advanced paste dynamically configured setting items + - powertoys run's extensions \ No newline at end of file diff --git a/doc/specs/workflow.png b/doc/specs/workflow.png new file mode 100644 index 0000000000..6920644fc5 Binary files /dev/null and b/doc/specs/workflow.png differ diff --git a/doc/thirdPartyRunPlugins.md b/doc/thirdPartyRunPlugins.md index 90966ead20..80e1c16cc1 100644 --- a/doc/thirdPartyRunPlugins.md +++ b/doc/thirdPartyRunPlugins.md @@ -43,6 +43,17 @@ Contact the developers of a plugin directly for assistance with a specific plugi | [TailwindCSS](https://github.com/skttl/ptrun-tailwindcss) | [skttl](https://github.com/skttl) | Search the documentation of TailwindCSS | | [HttpStatusCodes](https://github.com/grzhan/HttpStatusCodePowerToys) | [grzhan](https://github.com/grzhan) | Search for http status codes | | [SVGL](https://github.com/Sameerjs6/powertoys-svgl) | [SameerJS6](https://github.com/SameerJS6) | Search, Browse and copy SVG logos from SVGL. | +| [QuickNotes](https://github.com/ruslanlap/CommunityPowerToysRunPlugin-QuickNotes) | [ruslanlap](https://github.com/ruslanlap) | Create, manage, and search notes directly from PowerToys Run. | +| [Weather](https://github.com/ruslanlap/PowerToysRun-Weather) | [ruslanlap](https://github.com/ruslanlap) | Get real-time weather information directly from PowerToys Run. | +| [Pomodoro](https://github.com/ruslanlap/PowerToysRun-Pomodoro) | [ruslanlap](https://github.com/ruslanlap) | Manage Pomodoro productivity sessions directly from PowerToys Run. | +| [Definition](https://github.com/ruslanlap/PowerToysRun-Definition) | [ruslanlap](https://github.com/ruslanlap) | Lookup word definitions, phonetics, and synonyms directly in PowerToys Run. | +| [Hotkeys](https://github.com/ruslanlap/PowerToysRun-Hotkeys) | [ruslanlap](https://github.com/ruslanlap) | Create, manage, and trigger custom keyboard shortcuts directly from PowerToys Run. | +| [RandomGen](https://github.com/ruslanlap/PowerToysRun-RandomGen) | [ruslanlap](https://github.com/ruslanlap) | 🎲 Generate random data instantly with a single keystroke. Perfect for developers, testers, designers, and anyone who needs quick access to random data. Features include secure passwords, PINs, names, business data, dates, numbers, GUIDs, color codes, and more. Especially useful for designers who need random color codes and placeholder content. | +| [Open With Cursor](https://github.com/VictorNoxx/PowerToys-Run-Cursor/) | [VictorNoxx](https://github.com/VictorNoxx) | Open Visual Studio, VS Code recents with Cursor AI | +| [Open With Antigravity](https://github.com/artickc/PowerToys-Run-Antygravity) | [artickc](https://github.com/artickc) | Open Visual Studio, VS Code recents with Antigravity AI | +| [Project Launcher Plugin](https://github.com/artickc/ProjectLauncherPowerToysPlugin) | [artickc](https://github.com/artickc) | Access your projects using Project Launcher and PowerToys Run | +| [CheatSheets](https://github.com/ruslanlap/PowerToysRun-CheatSheets) | [ruslanlap](https://github.com/ruslanlap) | 📚 Find cheat sheets and command examples instantly from tldr pages, cheat.sh, and devhints.io. Features include favorites system, categories, offline mode, and smart caching. | +| [QuickAI](https://github.com/ruslanlap/PowerToysRun-QuickAi) | [ruslanlap](https://github.com/ruslanlap) | AI-powered assistance with instant, smart responses from multiple providers (Groq, Together, Fireworks, OpenRouter, Cohere) | ## Extending software plugins @@ -65,3 +76,6 @@ Below are community created plugins that target a website or software. They are | [Bilibili](https://github.com/Whuihuan/PowerToysRun-Bilibili) | [Whuihuan](https://github.com/Whuihuan) | Use AVID or BVID to parse and jump to Bilibili | | [YubicoOauthOTP](https://github.com/dlnilsson/Community.PowerToys.Run.Plugin.YubicoOauthOTP) | [dlnilsson](https://github.com/dlnilsson) | Display generated codes from OATH accounts stored on the YubiKey in powerToys Run | | [Firefox Bookmark](https://github.com/8LWXpg/PowerToysRun-FirefoxBookmark) | [8LWXpg](https://github.com/8LWXpg) | Open bookmarks in Firefox based browser | +| [Linear](https://github.com/vednig/powertoys-linear) | [vednig](https://github.com/vednig) | Create Linear Issues directly from Powertoys Run | +| [PerplexitySearchShortcut](https://github.com/0x6f677548/PowerToys-Run-PerplexitySearchShortcut) | [0x6f677548](https://github.com/0x6f677548) | Search Perplexity | +| [SpeedTest](https://github.com/ruslanlap/PowerToysRun-SpeedTest) | [ruslanlap](https://github.com/ruslanlap) | One-command internet speed tests with real-time results, modern UI, and shareable links. | diff --git a/installer/PowerToysSetup.sln b/installer/PowerToysSetup.sln deleted file mode 100644 index 540ef43d23..0000000000 --- a/installer/PowerToysSetup.sln +++ /dev/null @@ -1,88 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32414.318 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "PowerToysInstaller", "PowerToysSetup\PowerToysInstaller.wixproj", "{022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "PowerToysSetupCustomActions", "PowerToysSetupCustomActions\PowerToysSetupCustomActions.vcxproj", "{32F3882B-F2D6-4586-B5ED-11E39E522BD3}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "spdlog", "..\src\logging\logging.vcxproj", "{7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "logger", "..\src\common\logger\logger.vcxproj", "{D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}" -EndProject -Project("{930C7802-8A8C-48F9-8165-68863BCCD9DD}") = "PowerToysBootstrapper", "PowerToysSetup\PowerToysBootstrapper.wixproj", "{31D72625-43C1-41B1-B784-BCE4A8DC5543}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Version", "..\src\common\version\version.vcxproj", "{CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}" -EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EtwTrace", "..\src\common\Telemetry\EtwTrace\EtwTrace.vcxproj", "{8F021B46-362B-485C-BFBA-CCF83E820CBD}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|ARM64 = Debug|ARM64 - Debug|x64 = Debug|x64 - Release|ARM64 = Release|ARM64 - Release|x64 = Release|x64 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Debug|ARM64.Build.0 = Debug|ARM64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Debug|x64.ActiveCfg = Debug|x64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Debug|x64.Build.0 = Debug|x64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Release|ARM64.ActiveCfg = Release|ARM64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Release|ARM64.Build.0 = Release|ARM64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Release|x64.ActiveCfg = Release|x64 - {022A9D30-7C4F-416D-A9DF-5FF2661CC0AD}.Release|x64.Build.0 = Release|x64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Debug|x64.ActiveCfg = Debug|x64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Debug|x64.Build.0 = Debug|x64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Release|ARM64.ActiveCfg = Release|ARM64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Release|ARM64.Build.0 = Release|ARM64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Release|x64.ActiveCfg = Release|x64 - {32F3882B-F2D6-4586-B5ED-11E39E522BD3}.Release|x64.Build.0 = Release|x64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|x64.ActiveCfg = Debug|x64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Debug|x64.Build.0 = Debug|x64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Release|ARM64.ActiveCfg = Release|ARM64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Release|ARM64.Build.0 = Release|ARM64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Release|x64.ActiveCfg = Release|x64 - {7E1E3F13-2BD6-3F75-A6A7-873A2B55C60F}.Release|x64.Build.0 = Release|x64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Debug|x64.ActiveCfg = Debug|x64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Debug|x64.Build.0 = Debug|x64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|ARM64.ActiveCfg = Release|ARM64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|ARM64.Build.0 = Release|ARM64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|x64.ActiveCfg = Release|x64 - {D9B8FC84-322A-4F9F-BBB9-20915C47DDFD}.Release|x64.Build.0 = Release|x64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Debug|ARM64.Build.0 = Debug|ARM64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Debug|x64.ActiveCfg = Debug|x64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Debug|x64.Build.0 = Debug|x64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Release|ARM64.ActiveCfg = Release|ARM64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Release|ARM64.Build.0 = Release|ARM64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Release|x64.ActiveCfg = Release|x64 - {31D72625-43C1-41B1-B784-BCE4A8DC5543}.Release|x64.Build.0 = Release|x64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|ARM64.Build.0 = Debug|ARM64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|x64.ActiveCfg = Debug|x64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Debug|x64.Build.0 = Debug|x64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Release|ARM64.ActiveCfg = Release|ARM64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Release|ARM64.Build.0 = Release|ARM64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Release|x64.ActiveCfg = Release|x64 - {CC6E41AC-8174-4E8A-8D22-85DD7F4851DF}.Release|x64.Build.0 = Release|x64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Debug|ARM64.ActiveCfg = Debug|ARM64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Debug|ARM64.Build.0 = Debug|ARM64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Debug|x64.ActiveCfg = Debug|x64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Debug|x64.Build.0 = Debug|x64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Release|ARM64.ActiveCfg = Release|ARM64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Release|ARM64.Build.0 = Release|ARM64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Release|x64.ActiveCfg = Release|x64 - {8F021B46-362B-485C-BFBA-CCF83E820CBD}.Release|x64.Build.0 = Release|x64 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {B7A3DA30-D443-40FF-AC51-988AD41E3962} - EndGlobalSection -EndGlobal diff --git a/installer/PowerToysSetup.slnx b/installer/PowerToysSetup.slnx new file mode 100644 index 0000000000..658310f9f6 --- /dev/null +++ b/installer/PowerToysSetup.slnx @@ -0,0 +1,22 @@ +<Solution> + <Configurations> + <Platform Name="ARM64" /> + <Platform Name="x64" /> + </Configurations> + <Project Path="../src/common/logger/logger.vcxproj" Id="d9b8fc84-322a-4f9f-bbb9-20915c47ddfd"> + <Build Solution="Debug|ARM64" Project="false" /> + </Project> + <Project Path="../src/common/Telemetry/EtwTrace/EtwTrace.vcxproj" Id="8f021b46-362b-485c-bfba-ccf83e820cbd" /> + <Project Path="../src/common/version/version.vcxproj" Id="cc6e41ac-8174-4e8a-8d22-85dd7f4851df" /> + <Project Path="../src/logging/logging.vcxproj" Id="7e1e3f13-2bd6-3f75-a6a7-873a2b55c60f"> + <Build Solution="Debug|ARM64" Project="false" /> + </Project> + <Project Path="PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj" Id="b3a354b0-1e54-4b55-a962-fb5af9330c19"> + <Build Solution="Debug|ARM64" Project="false" /> + </Project> + <Project Path="PowerToysSetupVNext/PowerToysBootstrapperVNext.wixproj" Type="b7dd6f7e-def8-4e67-b5b7-07ef123db6f0" /> + <Project Path="PowerToysSetupVNext/PowerToysInstallerVNext.wixproj" Type="b7dd6f7e-def8-4e67-b5b7-07ef123db6f0" /> + <Project Path="PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj" Id="f8b9f842-f5c3-4a2d-8c85-7f8b9e2b4f1d"> + <Build Solution="Debug|ARM64" Project="false" /> + </Project> +</Solution> diff --git a/installer/PowerToysSetup/BaseApplications.wxs b/installer/PowerToysSetup/BaseApplications.wxs deleted file mode 100644 index 134a3ee89a..0000000000 --- a/installer/PowerToysSetup/BaseApplications.wxs +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > - - <?include $(sys.CURRENTDIR)\Common.wxi?> - - <?define BaseApplicationsFiles=?> - <?define BaseApplicationsFilesPath=$(var.BinDir)\?> - - <Fragment> - <DirectoryRef Id="INSTALLFOLDER"> - <!-- Generated by generateFileComponents.ps1 --> - <!--BaseApplicationsFiles_Component_Def--> - </DirectoryRef> - - <ComponentGroup Id="BaseApplicationsComponentGroup"> - </ComponentGroup> - - </Fragment> -</Wix> diff --git a/installer/PowerToysSetup/FileLocksmith.wxs b/installer/PowerToysSetup/FileLocksmith.wxs deleted file mode 100644 index 085e60eaa7..0000000000 --- a/installer/PowerToysSetup/FileLocksmith.wxs +++ /dev/null @@ -1,45 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > - - <?include $(sys.CURRENTDIR)\Common.wxi?> - - <?define FileLocksmithAssetsFiles=?> - <?define FileLocksmithAssetsFilesPath=$(var.BinDir)WinUI3Apps\Assets\FileLocksmith\?> - - <Fragment> - <DirectoryRef Id="WinUI3AppsAssetsFolder"> - <Directory Id="FileLocksmithAssetsInstallFolder" Name="FileLocksmith" /> - </DirectoryRef> - <DirectoryRef Id="FileLocksmithAssetsInstallFolder" FileSource="$(var.FileLocksmithAssetsFilesPath)"> - <!-- Generated by generateFileComponents.ps1 --> - <!--FileLocksmithAssetsFiles_Component_Def--> - <!-- !Warning! Make sure to change Component Guid if you update something here --> - <Component Id="Module_FileLocksmith" Guid="108D3EC1-E6E0-4E81-88EF-25966133CB41" Win64="yes"> - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\CLSID\{84D68575-E186-46AD-B0CB-BAEB45EE29C0}"> - <RegistryValue Type="string" Value="File Locksmith Shell Extension" /> - <RegistryValue Type="string" Name="ContextMenuOptIn" Value="" /> - <RegistryValue Type="string" Key="InprocServer32" Value="[WinUI3AppsInstallFolder]PowerToys.FileLocksmithExt.dll" /> - <RegistryValue Type="string" Key="InprocServer32" Name="ThreadingModel" Value="Apartment" /> - </RegistryKey> - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\AllFileSystemObjects\ShellEx\ContextMenuHandlers\FileLocksmithExt"> - <RegistryValue Type="string" Value="{84D68575-E186-46AD-B0CB-BAEB45EE29C0}"/> - </RegistryKey> - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\Drive\ShellEx\ContextMenuHandlers\FileLocksmithExt"> - <RegistryValue Type="string" Value="{84D68575-E186-46AD-B0CB-BAEB45EE29C0}"/> - </RegistryKey> - </Component> - </DirectoryRef> - - <ComponentGroup Id="FileLocksmithComponentGroup"> - <Component Id="RemoveFileLocksmithFolder" Guid="1DAC9A3F-D89C-4730-BF57-1778E011709B" Directory="FileLocksmithAssetsInstallFolder" > - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveFileLocksmithFolder" Value="" KeyPath="yes"/> - </RegistryKey> - <RemoveFolder Id="RemoveFolderFileLocksmithAssetsFolder" Directory="FileLocksmithAssetsInstallFolder" On="uninstall"/> - </Component> - <ComponentRef Id="Module_FileLocksmith" /> - </ComponentGroup> - - </Fragment> -</Wix> diff --git a/installer/PowerToysSetup/ImageResizer.wxs b/installer/PowerToysSetup/ImageResizer.wxs deleted file mode 100644 index 9f4602939a..0000000000 --- a/installer/PowerToysSetup/ImageResizer.wxs +++ /dev/null @@ -1,96 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > - - <?include $(sys.CURRENTDIR)\Common.wxi?> - - <?define ImageResizerAssetsFiles=?> - <?define ImageResizerAssetsFilesPath=$(var.BinDir)Assets\ImageResizer\?> - - <Fragment> - <DirectoryRef Id="BaseApplicationsAssetsFolder"> - <Directory Id="ImageResizerAssetsFolder" Name="ImageResizer" /> - </DirectoryRef> - - <DirectoryRef Id="ImageResizerAssetsFolder" FileSource="$(var.ImageResizerAssetsFilesPath)"> - <!-- Generated by generateFileComponents.ps1 --> - <!--ImageResizerAssetsFiles_Component_Def--> - - <Component Id="Module_ImageResizer_Registry" Win64="yes"> - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\CLSID\{51B4D7E5-7568-4234-B4BB-47FB3C016A69}\InprocServer32"> - <RegistryValue Value="[INSTALLFOLDER]PowerToys.ImageResizerExt.dll" Type="string" /> - <RegistryValue Name="ThreadingModel" Value="Apartment" Type="string" /> - </RegistryKey> - - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\Directory\ShellEx\DragDropHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <!-- Registry Keys for the context menu handler for each of the following image formats: bmp, dib, gif, jfif, jpe, jpeg, jpg, jxr, png, rle, tif, tiff, wdp --> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.bmp\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.dib\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.gif\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.jfif\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.jpe\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.jpeg\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.jpg\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.jxr\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.png\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.rle\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.tif\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.tiff\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - <RegistryValue Root="$(var.RegistryScope)" - Key="SOFTWARE\Classes\SystemFileAssociations\.wdp\ShellEx\ContextMenuHandlers\ImageResizer" - Value="{51B4D7E5-7568-4234-B4BB-47FB3C016A69}" - Type="string" /> - </Component> - - </DirectoryRef> - - <ComponentGroup Id="ImageResizerComponentGroup"> - <Component Id="RemoveImageResizerFolder" Guid="8E5DE86A-8618-4590-9584-51BCD3A14280" Directory="ImageResizerAssetsFolder" > - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveImageResizerFolder" Value="" KeyPath="yes"/> - </RegistryKey> - <RemoveFolder Id="RemoveFolderImageResizerAssetsFolder" Directory="ImageResizerAssetsFolder" On="uninstall"/> - </Component> - <ComponentRef Id="Module_ImageResizer_Registry" /> - </ComponentGroup> - </Fragment> -</Wix> diff --git a/installer/PowerToysSetup/PowerRename.wxs b/installer/PowerToysSetup/PowerRename.wxs deleted file mode 100644 index 1e722d9334..0000000000 --- a/installer/PowerToysSetup/PowerRename.wxs +++ /dev/null @@ -1,46 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > - - <?include $(sys.CURRENTDIR)\Common.wxi?> - - <?define PowerRenameAssetsFiles=?> - <?define PowerRenameAssetsFilesPath=$(var.BinDir)WinUI3Apps\Assets\PowerRename\?> - - <Fragment> - <DirectoryRef Id="WinUI3AppsAssetsFolder"> - <Directory Id="PowerRenameAssetsFolder" Name="PowerRename" /> - </DirectoryRef> - <DirectoryRef Id="PowerRenameAssetsFolder" FileSource="$(var.PowerRenameAssetsFilesPath)"> - <!-- Generated by generateFileComponents.ps1 --> - <!--PowerRenameAssetsFiles_Component_Def--> - <!-- !Warning! Make sure to change Component Guid if you update something here --> - <Component Id="Module_PowerRename" Guid="40D43079-240E-402D-8CE8-571BFFA71175" Win64="yes"> - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\CLSID\{0440049F-D1DC-4E46-B27B-98393D79486B}"> - <RegistryValue Type="string" Value="PowerRename Shell Extension" /> - <RegistryValue Type="string" Name="ContextMenuOptIn" Value="" /> - <RegistryValue Type="string" Key="InprocServer32" Value="[WinUI3AppsInstallFolder]PowerToys.PowerRenameExt.dll" /> - <RegistryValue Type="string" Key="InprocServer32" Name="ThreadingModel" Value="Apartment" /> - </RegistryKey> - <RegistryKey Root="$(var.RegistryScope)" Key="SOFTWARE\Classes\AllFileSystemObjects\ShellEx\ContextMenuHandlers\PowerRenameExt"> - <RegistryValue Type="string" Value="{0440049F-D1DC-4E46-B27B-98393D79486B}"/> - </RegistryKey> - <RegistryKey Root="$(var.RegistryScope)" Key="SOFTWARE\Classes\Directory\background\ShellEx\ContextMenuHandlers\PowerRenameExt"> - <RegistryValue Type="string" Value="{0440049F-D1DC-4E46-B27B-98393D79486B}"/> - </RegistryKey> - - </Component> - </DirectoryRef> - - <ComponentGroup Id="PowerRenameComponentGroup"> - <Component Id="RemovePowerRenameFolder" Guid="2028549B-02E3-4D80-BC3F-59AEA37AC73D" Directory="PowerRenameAssetsFolder" > - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemovePowerRenameFolder" Value="" KeyPath="yes"/> - </RegistryKey> - <RemoveFolder Id="RemoveFolderPowerRenameAssetsFolder" Directory="PowerRenameAssetsFolder" On="uninstall"/> - </Component> - <ComponentRef Id="Module_PowerRename" /> - </ComponentGroup> - - </Fragment> -</Wix> diff --git a/installer/PowerToysSetup/PowerToys.wxs b/installer/PowerToysSetup/PowerToys.wxs deleted file mode 100644 index 2e50d278fb..0000000000 --- a/installer/PowerToysSetup/PowerToys.wxs +++ /dev/null @@ -1,92 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> - -<?define UpgradeCode="6341382d-c0a9-4238-9188-be9607e3fab2"?> - -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" - xmlns:bal="http://schemas.microsoft.com/wix/BalExtension"> - - <?include $(sys.CURRENTDIR)\Common.wxi?> - - <Bundle Name="PowerToys (Preview) $(var.PowerToysPlatform)" - Version="$(var.Version)" - Manufacturer="Microsoft Corporation" - IconSourceFile="$(var.BinDir)svgs\icon.ico" - UpgradeCode="$(var.UpgradeCode)"> - <BootstrapperApplicationRef Id="WixStandardBootstrapperApplication.RtfLicense"> - <bal:WixStandardBootstrapperApplication - LicenseFile="$(var.RepoDir)\installer\License.rtf" - LogoFile="$(var.RepoDir)\installer\PowerToysSetup\Images\logo44.png" - SuppressOptionsUI="no" - SuppressRepair="yes" /> - </BootstrapperApplicationRef> - - <util:RegistrySearch Variable="HasWebView2PerMachine" Root="HKLM" Key="SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Result="exists" /> - <util:RegistrySearch Variable="HasWebView2PerUser" Root="HKCU" Key="Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Result="exists" /> - - <?if $(var.PerUser) = "true" ?> - <Variable Name="InstallFolder" Type="string" Value="[LocalAppDataFolder]PowerToys" bal:Overridable="yes"/> - <?else?> - <Variable Name="InstallFolder" Type="string" Value="$(var.PlatformProgramFiles)PowerToys" bal:Overridable="yes"/> - <?endif?> - - <Variable Name="MsiLogFolder" Type="string" Value="[LocalAppDataFolder]\Microsoft\PowerToys\" /> - <Log Disable="no" Prefix='powertoys-bootstrapper-msi-$(var.Version)' Extension=".log" /> - - <!-- Only install/upgrade if the version is greater or equal than the currently installed version of PowerToys, to handle the case in which PowerToys was installed from old MSI (before WiX bootstrapper was used) --> - <!-- If the previous installation is a bundle installation, just let WiX run its logic. --> - <Variable Name="MinimumVersion" Type="version" Value="0.0.0.0"/> - <Variable Name="TargetPowerToysVersion" Type="version" Value="$(var.Version)"/> - <Variable Name="DetectedPowerToysVersion" Type="version" Value="0.0.0.0"/> - <Variable Name="DetectedPowerToysUserVersion" Type="version" Value="0.0.0.0"/> - - <util:ProductSearch Id="SearchInstalledPowerToysVersion" Variable="DetectedPowerToysVersion" UpgradeCode="42B84BF7-5FBF-473B-9C8B-049DC16F7708" Result="version" /> - <util:ProductSearch Id="SearchInstalledPowerToysUserVersion" Variable="DetectedPowerToysUserVersion" UpgradeCode="D8B559DB-4C98-487A-A33F-50A8EEE42726" Result="version" /> - - <?if $(var.PerUser) = "true" ?> - <bal:Condition Message="PowerToys is already installed on this system for all users. We recommend first uninstalling that version before installing this one." >MinimumVersion >= DetectedPowerToysVersion</bal:Condition> - <bal:Condition Message="The same or later version of PowerToys is already installed." >TargetPowerToysVersion >= DetectedPowerToysUserVersion OR WixBundleInstalled</bal:Condition> - <?else?> - <bal:Condition Message="PowerToys is already installed on this system for current user. We recommend first uninstalling that version before installing this one." >MinimumVersion >= DetectedPowerToysUserVersion</bal:Condition> - <bal:Condition Message="A later version of PowerToys is already installed." >TargetPowerToysVersion >= DetectedPowerToysVersion OR WixBundleInstalled</bal:Condition> - <?endif?> - - <Variable Name="DetectedWindowsBuildNumber" Type="version" Value="0"/> - <util:RegistrySearch Id="SearchWindowsBuildNumber" Root="HKLM" Key="SOFTWARE\Microsoft\Windows NT\CurrentVersion" Value="CurrentBuildNumber" Result="value" Format="raw" Variable="DetectedWindowsBuildNumber" /> - <bal:Condition Message="This application is only supported on Windows 10 version v2004 (build 19041) or higher.">DetectedWindowsBuildNumber >= 19041 OR WixBundleInstalled</bal:Condition> - - <Chain> - <ExePackage - DisplayName="Closing PowerToys application" - Name="terminate_powertoys.cmd" - Cache="no" - Compressed="yes" - Id="TerminatePowerToys" - SourceFile="terminate_powertoys.cmd" - Permanent="yes" - PerMachine="$(var.PerMachineYesNo)" - Vital="no"> - </ExePackage> - <ExePackage - DisplayName="Microsoft Edge WebView2" - Name="MicrosoftEdgeWebview2Setup.exe" - Compressed="yes" - Id="WebView2" - DetectCondition="HasWebView2PerMachine OR HasWebView2PerUser" - SourceFile="WebView2\MicrosoftEdgeWebview2Setup.exe" - InstallCommand="/silent /install" - RepairCommand="/repair /passive" - Permanent="yes" - PerMachine="$(var.PerMachineYesNo)" - UninstallCommand="/silent /uninstall"> - </ExePackage> - <MsiPackage - DisplayName="PowerToys MSI" - SourceFile="$(var.PowerToysPlatform)\Release\$(var.MSIPath)\$(var.MSIName)" - Compressed="yes" - DisplayInternalUI="no"> - <MsiProperty Name="BOOTSTRAPPERINSTALLFOLDER" Value="[InstallFolder]" /> - </MsiPackage> - </Chain> - </Bundle> -</Wix> diff --git a/installer/PowerToysSetup/PowerToysBootstrapper.wixproj b/installer/PowerToysSetup/PowerToysBootstrapper.wixproj deleted file mode 100644 index b2f5945dc2..0000000000 --- a/installer/PowerToysSetup/PowerToysBootstrapper.wixproj +++ /dev/null @@ -1,93 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" - DefaultTargets="Build" - InitialTargets="EnsureNuGetPackageBuildImports" - xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\src\Version.props" Condition="Exists('..\..\src\Version.props')" /> - <Import Project="..\wix.props" Condition="Exists('..\wix.props')" /> - - <PropertyGroup> - <DefineConstants>Version=$(Version)</DefineConstants> - <Name>PowerToysBootstrapper</Name> - <ProjectGuid>{31d72625-43c1-41b1-b784-bce4a8dc5543}</ProjectGuid> - </PropertyGroup> - <PropertyGroup Label="UserMacros" Condition=" '$(PerUser)' == 'true' "> - <DefineConstants>$(DefineConstants);PerUser=true</DefineConstants> - </PropertyGroup> - <PropertyGroup Label="UserMacros" Condition=" '$(PerUser)' != 'true' "> - <DefineConstants>$(DefineConstants);PerUser=false</DefineConstants> - </PropertyGroup> - <PropertyGroup Label="UserMacros" Condition=" '$(CIBuild)' == 'true' "> - <DefineConstants>$(DefineConstants);CIBuild=true</DefineConstants> - </PropertyGroup> - <PropertyGroup Label="UserMacros" Condition=" '$(CIBuild)' != 'true' "> - <DefineConstants>$(DefineConstants);CIBuild=false</DefineConstants> - </PropertyGroup> - <PropertyGroup> - <Configuration>Release</Configuration> - <Platform Condition="'$(Platform)'=='x64'">x64</Platform> - <Platform Condition="'$(Platform)'!='x64'">arm64</Platform> - <ProductVersion>3.10</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <OutputName>PowerToysSetup-$(Version)-$(Platform)</OutputName> - <OutputType>Bundle</OutputType> - <SuppressAclReset>True</SuppressAclReset> - <OutputName Condition=" '$(PerUser)' != 'true' ">PowerToysSetup-$(Version)-$(Platform)</OutputName> - <OutputName Condition=" '$(PerUser)' == 'true' ">PowerToysUserSetup-$(Version)-$(Platform)</OutputName> - <OutputPath Condition=" '$(PerUser)' != 'true' ">$(Platform)\$(Configuration)\MachineSetup</OutputPath> - <OutputPath Condition=" '$(PerUser)' == 'true' ">$(Platform)\$(Configuration)\UserSetup</OutputPath> - <IntermediateOutputPath>obj\$(Platform)\$(Configuration)\</IntermediateOutputPath> - <NuGetPackageImportStamp /> - </PropertyGroup> - <ItemGroup> - <Compile Include="PowerToys.wxs" /> - </ItemGroup> - <ItemGroup> - <WixExtension Include="WixUtilExtension"> - <HintPath>$(WixExtDir)\WixUtilExtension.dll</HintPath> - <Name>WixUtilExtension</Name> - </WixExtension> - <WixExtension Include="WixUIExtension"> - <HintPath>$(WixExtDir)\WixUIExtension.dll</HintPath> - <Name>WixUIExtension</Name> - </WixExtension> - <WixExtension Include="WixNetFxExtension"> - <HintPath>$(WixExtDir)\WixNetFxExtension.dll</HintPath> - <Name>WixNetFxExtension</Name> - </WixExtension> - <WixExtension Include="WixNetFxExtension"> - <HintPath>$(WixExtDir)\WixBalExtension.dll</HintPath> - <Name>WixBalExtension</Name> - </WixExtension> - </ItemGroup> - <ItemGroup> - <Folder Include="CustomDialogs" /> - </ItemGroup> - <ItemGroup> - <Content Include="packages.config" /> - </ItemGroup> - <Import Project="$(WixTargetsPath)" - Condition=" '$(WixTargetsPath)' != '' " /> - <Import Project="$(MSBuildExtensionsPath32)\Microsoft\WiX\v3.x\Wix.targets" - Condition=" '$(WixTargetsPath)' == '' AND Exists('$(MSBuildExtensionsPath32)\Microsoft\WiX\v3.x\Wix.targets') " /> - <Target Name="EnsureWixToolsetInstalled" - Condition=" '$(WixTargetsImported)' != 'true' "> - <Error Text="The WiX Toolset v3 build tools must be installed to build this project. To download the WiX Toolset, see http://wixtoolset.org/releases/" /> - </Target> - <Target Name="EnsureNuGetPackageBuildImports" - BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>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}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('..\wix.props')" - Text="$([System.String]::Format('$(ErrorText)', '..\wix.props'))" /> - </Target> - - <!-- Prevents NU1503 --> - <Target Name="_IsProjectRestoreSupported" Returns="@(_ValidProjectsForRestore)"> - <ItemGroup> - <_ValidProjectsForRestore Include="$(MSBuildProjectFullPath)" /> - </ItemGroup> - </Target> - <Target Name="Restore" /> -</Project> \ No newline at end of file diff --git a/installer/PowerToysSetup/Product.wxs b/installer/PowerToysSetup/Product.wxs deleted file mode 100644 index 37256bdd68..0000000000 --- a/installer/PowerToysSetup/Product.wxs +++ /dev/null @@ -1,502 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > - - <?include $(sys.CURRENTDIR)\Common.wxi?> - - <!-- WiX Components with multiple files cause issues due to the way Windows installs them. - Windows decides whether to install a component by checking the existence of KeyPath file and its version. - Thus, if some files were updated but KeyPath file was not, the component wouldn't be updated. - Some resource files, e.g. images, do not have version, so even if Component has only a single image and a static GUID, it won't be updated. - - Considering all of the above, it's much simpler to just have one file per Component with an implicit Guid. - - More info: - - https://stackoverflow.com/a/1604348/657390 - - https://stackoverflow.com/a/1422121/657390 - - https://robmensching.com/blog/posts/2003/10/18/component-rules-101/ - - https://robmensching.com/blog/posts/2003/10/4/windows-installer-components-introduction/ - --> - - <Product Id="*" - Name="PowerToys (Preview)" - Language="1033" - Version="$(var.Version)" - Manufacturer="Microsoft Corporation" - UpgradeCode="$(var.UpgradeCodeGUID)"> - - - <Package InstallerVersion="500" Compressed="yes" InstallScope="$(var.InstallScope)" InstallPrivileges="$(var.InstallPrivileges)" Platform="$(var.PlatformLK)" /> - - <MajorUpgrade DowngradeErrorMessage="A later version of [ProductName] is already installed." /> - - <Upgrade Id="$(var.UpgradeCodeGUID)"> - <UpgradeVersion - Minimum="0.0.0" Maximum="$(var.Version)" - Property="PREVIOUSVERSIONSINSTALLED" - IncludeMinimum="yes" IncludeMaximum="no" /> - </Upgrade> - - <MediaTemplate EmbedCab="yes" /> - - <Property Id="REINSTALLMODE" Value="amus" /> - <Property Id="WINDOWSBUILDNUMBER" Secure="yes"> - <RegistrySearch Id="BuildNumberSearch" Root="HKLM" Key="SOFTWARE\Microsoft\Windows NT\CurrentVersion" Name="CurrentBuildNumber" Type="raw" /> - </Property> - <Condition Message="This application is only supported on Windows 10 version v2004 (build 19041) or higher."> - <![CDATA[(WINDOWSBUILDNUMBER >= 19041)]]> - </Condition> - - <Icon Id="powertoys.exe" SourceFile="$(var.BinDir)svgs\icon.ico"/> - - <Property Id="ARPPRODUCTICON" Value="powertoys.exe" /> - - <Feature Id="CoreFeature" Title="PowerToys" AllowAdvertise="no" Absent="disallow" TypicalDefault="install" - Description="Contains all PowerToys features."> - <ComponentGroupRef Id="CoreComponents" /> - <ComponentGroupRef Id="BaseApplicationsComponentGroup" /> - <ComponentGroupRef Id="WinUI3ApplicationsComponentGroup" /> - <ComponentGroupRef Id="AwakeComponentGroup" /> - <ComponentGroupRef Id="ColorPickerComponentGroup" /> - <ComponentGroupRef Id="FileExplorerPreviewComponentGroup" /> - <ComponentGroupRef Id="FileLocksmithComponentGroup" /> - <ComponentGroupRef Id="HostsComponentGroup" /> - <ComponentGroupRef Id="ImageResizerComponentGroup" /> - <ComponentGroupRef Id="KeyboardManagerComponentGroup" /> - <ComponentGroupRef Id="PeekComponentGroup" /> - <ComponentGroupRef Id="PowerRenameComponentGroup" /> - <ComponentGroupRef Id="RegistryPreviewComponentGroup" /> - <ComponentGroupRef Id="RunComponentGroup" /> - <ComponentGroupRef Id="SettingsComponentGroup" /> - <ComponentGroupRef Id="ShortcutGuideComponentGroup" /> - <ComponentGroupRef Id="MouseWithoutBordersComponentGroup" /> - <ComponentGroupRef Id="EnvironmentVariablesComponentGroup" /> - <ComponentGroupRef Id="AdvancedPasteComponentGroup" /> - <ComponentGroupRef Id="NewPlusComponentGroup" /> - <ComponentGroupRef Id="NewPlusTemplatesComponentGroup" /> - <ComponentGroupRef Id="ResourcesComponentGroup" /> - <ComponentGroupRef Id="WindowsAppSDKComponentGroup" /> - <ComponentGroupRef Id="ToolComponentGroup" /> - <ComponentGroupRef Id="MonacoSRCHeatGenerated" /> - <ComponentGroupRef Id="WorkspacesComponentGroup" /> - - <?if $(var.CIBuild) = "true" ?> - <ComponentGroupRef Id="CmdPalComponentGroup" /> - <?endif?> - </Feature> - - <SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLFOLDER]" After="CostFinalize" /> - - <Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" /> - - <UI> - <UIRef Id="WixUI_InstallDir"/> - <Publish Dialog="WelcomeDlg" - Control="Next" - Event="NewDialog" - Value="InstallDirDlg" - Order="99">1</Publish> - <Publish Dialog="InstallDirDlg" - Control="Back" - Event="NewDialog" - Value="WelcomeDlg" - Order="99">1</Publish> - - <Publish Dialog="ExitDialog" - Control="Finish" - Event="EndDialog" - Value="Return">NOT Installed</Publish> - <Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Property="_REMOVE_ALL" Value="Yes">1</Publish> - <Publish Dialog="UserExit" Control="Finish" Event="DoAction" Value="TelemetryLogInstallCancel">NOT Installed</Publish> - <Publish Dialog="FatalError" Control="Finish" Event="DoAction" Value="TelemetryLogInstallFail">NOT Installed</Publish> - <Publish Dialog="UserExit" Control="Finish" Event="DoAction" Value="TelemetryLogUninstallCancel">Installed AND _REMOVE_ALL="Yes"</Publish> - <Publish Dialog="FatalError" Control="Finish" Event="DoAction" Value="TelemetryLogUninstallFail">Installed AND _REMOVE_ALL="Yes"</Publish> - <Publish Dialog="UserExit" Control="Finish" Event="DoAction" Value="TelemetryLogRepairCancel">Installed AND NOT (_REMOVE_ALL="Yes")</Publish> - <Publish Dialog="FatalError" Control="Finish" Event="DoAction" Value="TelemetryLogRepairFail">Installed AND NOT (_REMOVE_ALL="Yes")</Publish> - </UI> - - <WixVariable Id="WixUIBannerBmp" Value="$(var.ProjectDir)\Images\banner.png" /> - <WixVariable Id="WixUIDialogBmp" Value="$(var.ProjectDir)\Images\dialog.png" /> - <WixVariable Id="WixUILicenseRtf" Value="$(var.RepoDir)\installer\License.rtf" /> - <Property Id="INSTALLSTARTMENUSHORTCUT" Value="1"/> - <Property Id="WixShellExecTarget" Value="[#PowerToys_ActionRunner.exe]" /> - - <SetProperty Action="SetDEFAULTBOOTSTRAPPERINSTALLFOLDER" Id="DEFAULTBOOTSTRAPPERINSTALLFOLDER" Value="[$(var.DefaultInstallDir)]PowerToys" Before="SetBOOTSTRAPPERINSTALLFOLDER" Sequence="execute"></SetProperty> - - <!-- In case we didn't receive a value from the bootstrapper. --> - <SetProperty Action="SetBOOTSTRAPPERINSTALLFOLDER" Id="BOOTSTRAPPERINSTALLFOLDER" Value="[DEFAULTBOOTSTRAPPERINSTALLFOLDER]" Before="DetectPrevInstallPath" Sequence="execute"> - <![CDATA[BOOTSTRAPPERINSTALLFOLDER = ""]]> - </SetProperty> - <!-- Have to compare value sent by bootstrapper to default to avoid using it, as a check to verify it's not default. This hack can be removed if it's possible to set the bootstrapper option to the previous install folder--> - <SetProperty Action="SetINSTALLFOLDERTOPREVIOUSINSTALLFOLDER" Id="INSTALLFOLDER" Value="[PREVIOUSINSTALLFOLDER]" After="DetectPrevInstallPath" Sequence="execute"> - <![CDATA[BOOTSTRAPPERINSTALLFOLDER = DEFAULTBOOTSTRAPPERINSTALLFOLDER AND PREVIOUSINSTALLFOLDER <> ""]]> - </SetProperty> - <SetProperty Action="SetINSTALLFOLDERTOBOOTSTRAPPERINSTALLFOLDER" Id="INSTALLFOLDER" Value="[BOOTSTRAPPERINSTALLFOLDER]" After="DetectPrevInstallPath" Sequence="execute"> - <![CDATA[BOOTSTRAPPERINSTALLFOLDER <> DEFAULTBOOTSTRAPPERINSTALLFOLDER OR PREVIOUSINSTALLFOLDER = ""]]> - </SetProperty> - - <SetProperty Id="InstallScope" Value="$(var.InstallScope)" Before="DetectPrevInstallPath" Sequence="execute"></SetProperty> - <InstallExecuteSequence> - <Custom Action="DetectPrevInstallPath" After="AppSearch" /> - <Custom Action="SetLaunchPowerToysParam" Before="LaunchPowerToys" /> - <Custom Action="SetInstallCmdPalPackageParam" Before="InstallCmdPalPackage" /> - <Custom Action="SetUninstallCommandNotFoundParam" Before="UninstallCommandNotFound" /> - <Custom Action="SetUpgradeCommandNotFoundParam" Before="UpgradeCommandNotFound" /> - <Custom Action="SetApplyModulesRegistryChangeSetsParam" Before="ApplyModulesRegistryChangeSets" /> - - <?if $(var.PerUser) = "true" ?> - <Custom Action="SetInstallDSCModuleParam" Before="InstallDSCModule" /> - <?endif?> - - <Custom Action="SetUnApplyModulesRegistryChangeSetsParam" Before="UnApplyModulesRegistryChangeSets" /> - <Custom Action="CheckGPO" After="InstallInitialize"> - NOT Installed - </Custom> - <Custom Action="ApplyModulesRegistryChangeSets" After="InstallFiles"> - NOT Installed - </Custom> - <Custom Action="InstallCmdPalPackage" After="InstallFiles"> - NOT Installed - </Custom> - <Custom Action="WixCloseApplications" Before="RemoveFiles" /> - <Custom Action="RemovePowerToysSchTasks" After="RemoveFiles" /> - <!-- TODO: Use to activate embedded MSIX --> - <!--<Custom Action="InstallEmbeddedMSIXTask" After="InstallFinalize"> - NOT Installed - </Custom>--> - <?if $(var.PerUser) = "true" ?> - <Custom Action="InstallDSCModule" After="InstallFiles"/> - <?endif?> - <Custom Action="TelemetryLogInstallSuccess" After="InstallFinalize"> - NOT Installed - </Custom> - <Custom Action="TelemetryLogUninstallSuccess" After="InstallFinalize"> - Installed and (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - </Custom> - <Custom Action="UnApplyModulesRegistryChangeSets" Before="RemoveFiles"> - Installed AND (REMOVE="ALL") - </Custom> - <Custom Action="UnRegisterContextMenuPackages" Before="RemoveFiles"> - Installed AND (REMOVE="ALL") - </Custom> - <Custom Action="UnRegisterCmdPalPackage" Before="RemoveFiles"> - Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - </Custom> - <Custom Action="UnsetAdvancedPasteAPIKey" Before="RemoveFiles"> - Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - </Custom> - <Custom Action="UninstallCommandNotFound" Before="RemoveFiles"> - Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - </Custom> - <Custom Action="UpgradeCommandNotFound" After="InstallFiles"> - WIX_UPGRADE_DETECTED - </Custom> - <Custom Action="UninstallServicesTask" After="InstallFinalize"> - Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") - </Custom> - <!-- TODO: Use to activate embedded MSIX --> - <!--<Custom Action="UninstallEmbeddedMSIXTask" After="InstallFinalize"> - Installed AND (REMOVE="ALL") - </Custom>--> - <?if $(var.PerUser) = "true" ?> - <Custom Action="UninstallDSCModule" After="InstallFinalize"> - Installed AND (REMOVE="ALL") - </Custom> - <?endif?> - <Custom Action="TerminateProcesses" Before="InstallValidate" /> - <Custom Action="LaunchPowerToys" Before="InstallFinalize">NOT Installed</Custom> - - <!-- Clean Video Conference Mute registry keys that might be around from previous installations. We've deprecated this utility since then. --> - <Custom Action="CleanVideoConferenceRegistry" Before="InstallFinalize">NOT Installed</Custom> - - </InstallExecuteSequence> - - <CustomAction Id="SetLaunchPowerToysParam" - Property="LaunchPowerToys" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetInstallCmdPalPackageParam" - Property="InstallCmdPalPackage" - Value="[INSTALLFOLDER]" /> - - <CustomAction - Id="LaunchPowerToys" - Return="ignore" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="LaunchPowerToysCA" - /> - - <CustomAction - Id="TerminateProcesses" - Return="ignore" - Execute="immediate" - BinaryKey="PTCustomActions" - DllEntry="TerminateProcessesCA" /> - - <CustomAction Id="SetApplyModulesRegistryChangeSetsParam" - Property="ApplyModulesRegistryChangeSets" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetUnApplyModulesRegistryChangeSetsParam" - Property="UnApplyModulesRegistryChangeSets" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetInstallDSCModuleParam" - Property="InstallDSCModule" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetUninstallCommandNotFoundParam" - Property="UninstallCommandNotFound" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetUpgradeCommandNotFoundParam" - Property="UpgradeCommandNotFound" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetCreateWinAppSDKHardlinksParam" - Property="CreateWinAppSDKHardlinks" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetDeleteWinAppSDKHardlinksParam" - Property="DeleteWinAppSDKHardlinks" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetCreatePTInteropHardlinksParam" - Property="CreatePTInteropHardlinks" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetDeletePTInteropHardlinksParam" - Property="DeletePTInteropHardlinks" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetCreateDotnetRuntimeHardlinksParam" - Property="CreateDotnetRuntimeHardlinks" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="SetDeleteDotnetRuntimeHardlinksParam" - Property="DeleteDotnetRuntimeHardlinks" - Value="[INSTALLFOLDER]" /> - - <CustomAction Id="RemovePowerToysSchTasks" - Return="ignore" - Impersonate="no" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="RemoveScheduledTasksCA" - /> - - <CustomAction Id="InstallEmbeddedMSIXTask" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="InstallEmbeddedMSIXCA" - /> - - <CustomAction Id="UninstallEmbeddedMSIXTask" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="UninstallEmbeddedMSIXCA" - /> - - <CustomAction Id="InstallDSCModule" - Return="ignore" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="InstallDSCModuleCA" - /> - - <CustomAction Id="UninstallDSCModule" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="UninstallDSCModuleCA" - /> - - <CustomAction Id="UninstallServicesTask" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="UninstallServicesCA" - /> - - <CustomAction Id="UninstallCommandNotFound" - Return="ignore" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="UninstallCommandNotFoundModuleCA" - /> - - <CustomAction Id="UpgradeCommandNotFound" - Return="ignore" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="UpgradeCommandNotFoundModuleCA" - /> - - <CustomAction Id="UnsetAdvancedPasteAPIKey" - Return="ignore" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="UnsetAdvancedPasteAPIKeyCA" - /> - - <CustomAction Id="TelemetryLogInstallSuccess" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="TelemetryLogInstallSuccessCA" - /> - - <CustomAction Id="TelemetryLogInstallCancel" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="TelemetryLogInstallCancelCA" - /> - - <CustomAction Id="TelemetryLogInstallFail" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="TelemetryLogInstallFailCA" - /> - - <CustomAction Id="TelemetryLogUninstallSuccess" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="TelemetryLogUninstallSuccessCA" - /> - - <CustomAction Id="TelemetryLogUninstallCancel" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="TelemetryLogUninstallCancelCA" - /> - - <CustomAction Id="TelemetryLogUninstallFail" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="TelemetryLogUninstallFailCA" - /> - - <CustomAction Id="TelemetryLogRepairCancel" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="TelemetryLogRepairCancelCA" - /> - - <CustomAction Id="TelemetryLogRepairFail" - Return="ignore" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="TelemetryLogRepairFailCA" - /> - - <CustomAction Id="DetectPrevInstallPath" - Return="check" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="DetectPrevInstallPathCA" - /> - - <CustomAction Id="CleanVideoConferenceRegistry" - Return="ignore" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="CleanVideoConferenceRegistryCA" - /> - - <CustomAction Id="ApplyModulesRegistryChangeSets" - Return="check" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="ApplyModulesRegistryChangeSetsCA" - /> - - <CustomAction Id="UnApplyModulesRegistryChangeSets" - Return="check" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="UnApplyModulesRegistryChangeSetsCA" - /> - - <CustomAction Id="UnRegisterContextMenuPackages" - Return="ignore" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="UnRegisterContextMenuPackagesCA" - /> - - <CustomAction Id="UnRegisterCmdPalPackage" - Return="ignore" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="UnRegisterCmdPalPackageCA" - /> - - <CustomAction Id="CheckGPO" - Return="check" - Impersonate="yes" - BinaryKey="PTCustomActions" - DllEntry="CheckGPOCA" - /> - - <CustomAction Id="InstallCmdPalPackage" - Return="ignore" - Impersonate="yes" - Execute="deferred" - BinaryKey="PTCustomActions" - DllEntry="InstallCmdPalPackageCA" - /> - - <!-- Close 'PowerToys.exe' before uninstall--> - <Property Id="MSIRESTARTMANAGERCONTROL" Value="DisableShutdown" /> - <Property Id="MSIFASTINSTALL" Value="DisableShutdown" /> - <util:CloseApplication CloseMessage="yes" Target="PowerToys.exe" ElevatedCloseMessage="yes" RebootPrompt="no" TerminateProcess="0" /> - </Product> - - <Fragment> - <Binary Id="PTCustomActions" SourceFile="$(var.PowerToysSetupCustomActions.TargetPath)" /> - </Fragment> - - <!-- Installation directory structure --> - <Fragment> - <Directory Id="TARGETDIR" Name="SourceDir"> - <Directory Id="$(var.DefaultInstallDir)"> - <Directory Id="INSTALLFOLDER" Name="PowerToys"> - <Directory Id="BaseApplicationsAssetsFolder" Name="Assets"> - </Directory> - <Directory Id="DSCModulesReferenceFolder" Name="DSCModules" /> - <Directory Id="WinUI3AppsInstallFolder" Name="WinUI3Apps"> - <Directory Id="WinUI3AppsMicrosoftUIXamlInstallFolder" Name="Microsoft.UI.Xaml"> - <Directory Id="WinUI3AppsMicrosoftUIXamlAssetsInstallFolder" Name="Assets" /> - </Directory> - <Directory Id="WinUI3AppsAssetsFolder" Name="Assets"> - </Directory> - </Directory> - <Directory Id="ToolsFolder" Name="Tools"/> - </Directory> - </Directory> - <Directory Id="ProgramMenuFolder"> - <Directory Id="ApplicationProgramsFolder" Name="PowerToys (Preview)"/> - </Directory> - <Directory Id="DesktopFolder" Name="Desktop" /> - </Directory> - </Fragment> -</Wix> diff --git a/installer/PowerToysSetupCustomActions/CustomAction.cpp b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp similarity index 66% rename from installer/PowerToysSetupCustomActions/CustomAction.cpp rename to installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp index d1898eea32..43919ecaf1 100644 --- a/installer/PowerToysSetupCustomActions/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.cpp @@ -3,6 +3,8 @@ #include "RcResource.h" #include <ProjectTelemetry.h> #include <spdlog/sinks/base_sink.h> +#include <filesystem> +#include <string_view> #include "../../src/common/logger/logger.h" #include "../../src/common/utils/gpo.h" @@ -232,7 +234,9 @@ UINT __stdcall LaunchPowerToysCA(MSIHANDLE hInstall) auto action = [&commandLine](HANDLE userToken) { - STARTUPINFO startupInfo{.cb = sizeof(STARTUPINFO), .wShowWindow = SW_SHOWNORMAL}; + STARTUPINFO startupInfo = { 0 }; + startupInfo.cb = sizeof(STARTUPINFO); + startupInfo.wShowWindow = SW_SHOWNORMAL; PROCESS_INFORMATION processInformation; PVOID lpEnvironment = NULL; @@ -271,7 +275,9 @@ UINT __stdcall LaunchPowerToysCA(MSIHANDLE hInstall) } else { - STARTUPINFO startupInfo{.cb = sizeof(STARTUPINFO), .wShowWindow = SW_SHOWNORMAL}; + STARTUPINFO startupInfo = { 0 }; + startupInfo.cb = sizeof(STARTUPINFO); + startupInfo.wShowWindow = SW_SHOWNORMAL; PROCESS_INFORMATION processInformation; @@ -424,7 +430,7 @@ UINT __stdcall InstallDSCModuleCA(MSIHANDLE hInstall) const auto modulesPath = baseModulesPath / L"Microsoft.PowerToys.Configure" / (get_product_version(false) + L".0"); std::error_code errorCode; - fs::create_directories(modulesPath, errorCode); + std::filesystem::create_directories(modulesPath, errorCode); if (errorCode) { hr = E_FAIL; @@ -433,7 +439,7 @@ UINT __stdcall InstallDSCModuleCA(MSIHANDLE hInstall) for (const auto *filename : {DSC_CONFIGURE_PSD1_NAME, DSC_CONFIGURE_PSM1_NAME}) { - fs::copy_file(fs::path(installationFolder) / "DSCModules" / filename, modulesPath / filename, fs::copy_options::overwrite_existing, errorCode); + std::filesystem::copy_file(std::filesystem::path(installationFolder) / "DSCModules" / filename, modulesPath / filename, std::filesystem::copy_options::overwrite_existing, errorCode); if (errorCode) { @@ -481,7 +487,7 @@ UINT __stdcall UninstallDSCModuleCA(MSIHANDLE hInstall) for (const auto *filename : {DSC_CONFIGURE_PSD1_NAME, DSC_CONFIGURE_PSM1_NAME}) { - fs::remove(versionedModulePath / filename, errorCode); + std::filesystem::remove(versionedModulePath / filename, errorCode); if (errorCode) { @@ -492,7 +498,7 @@ UINT __stdcall UninstallDSCModuleCA(MSIHANDLE hInstall) for (const auto *modulePath : {&versionedModulePath, &powerToysModulePath}) { - fs::remove(*modulePath, errorCode); + std::filesystem::remove(*modulePath, errorCode); if (errorCode) { @@ -589,6 +595,216 @@ LExit: return WcaFinalize(er); } +UINT __stdcall InstallPackageIdentityMSIXCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + LPWSTR customActionData = nullptr; + std::wstring installFolderPath; + std::wstring installScope; + std::wstring msixPath; + std::wstring data; + size_t delimiterPos; + bool isMachineLevel = false; + + hr = WcaInitialize(hInstall, "InstallPackageIdentityMSIXCA"); + ExitOnFailure(hr, "Failed to initialize"); + + hr = WcaGetProperty(L"CustomActionData", &customActionData); + ExitOnFailure(hr, "Failed to get CustomActionData property"); + + // Parse CustomActionData: "[INSTALLFOLDER];[InstallScope]" + data = customActionData; + delimiterPos = data.find(L';'); + installFolderPath = data.substr(0, delimiterPos); + installScope = data.substr(delimiterPos + 1); + + // Check if this is a machine-level installation + if (installScope == L"perMachine") + { + isMachineLevel = true; + } + + Logger::info(L"Installing PackageIdentity MSIX - perUser: {}", !isMachineLevel); + + // Construct path to PackageIdentity MSIX + msixPath = installFolderPath; + msixPath += L"PowerToysSparse.msix"; + + if (std::filesystem::exists(msixPath)) + { + using namespace winrt::Windows::Management::Deployment; + using namespace winrt::Windows::Foundation; + + try + { + + std::wstring externalLocation = installFolderPath; // External content location (PowerToys install folder) + Uri externalUri{ externalLocation }; // External location URI for sparse package content + Uri packageUri{ msixPath }; // The MSIX file URI + + PackageManager packageManager; + + if (isMachineLevel) + { + // Machine-level installation + + StagePackageOptions stageOptions; + stageOptions.ExternalLocationUri(externalUri); + + auto stageResult = packageManager.StagePackageByUriAsync(packageUri, stageOptions).get(); + + uint32_t stageErrorCode = static_cast<uint32_t>(stageResult.ExtendedErrorCode()); + if (stageErrorCode == 0) + { + std::wstring packageFamilyName = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe"; + + try + { + auto provisionResult = packageManager.ProvisionPackageForAllUsersAsync(packageFamilyName).get(); + uint32_t provisionErrorCode = static_cast<uint32_t>(provisionResult.ExtendedErrorCode()); + + if (provisionErrorCode != 0) + { + Logger::error(L"Machine-level provisioning failed: 0x{:08X}", provisionErrorCode); + } + } + catch (const winrt::hresult_error& ex) + { + Logger::error(L"Provisioning exception: HRESULT 0x{:08X}", static_cast<uint32_t>(ex.code())); + } + } + else + { + Logger::error(L"Package staging failed: 0x{:08X}", stageErrorCode); + } + } + else + { + AddPackageOptions addOptions; + addOptions.ExternalLocationUri(externalUri); + + auto addResult = packageManager.AddPackageByUriAsync(packageUri, addOptions).get(); + + if (!addResult.IsRegistered()) + { + uint32_t errorCode = static_cast<uint32_t>(addResult.ExtendedErrorCode()); + Logger::error(L"Per-user installation failed: 0x{:08X}", errorCode); + } + } + } + catch (const std::exception& ex) + { + Logger::error(L"PackageIdentity MSIX installation failed - Exception: {}", + winrt::to_hstring(ex.what()).c_str()); + } + } + else + { + Logger::error(L"PackageIdentity MSIX not found: " + msixPath); + } + +LExit: + ReleaseStr(customActionData); + er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; + return WcaFinalize(er); +} + +UINT __stdcall UninstallPackageIdentityMSIXCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + using namespace winrt::Windows::Management::Deployment; + using namespace winrt::Windows::Foundation; + + LPWSTR installScope = nullptr; + bool isMachineLevel = false; + + PackageManager pm; + + hr = WcaInitialize(hInstall, "UninstallPackageIdentityMSIXCA"); + ExitOnFailure(hr, "Failed to initialize"); + + // Check if this was a machine-level installation + hr = WcaGetProperty(L"InstallScope", &installScope); + if (SUCCEEDED(hr) && installScope && wcscmp(installScope, L"perMachine") == 0) + { + isMachineLevel = true; + } + + Logger::info(L"Uninstalling PackageIdentity MSIX - perUser: {}", !isMachineLevel); + + try + { + std::wstring packageFamilyName = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe"; + + if (isMachineLevel) + { + // Machine-level uninstallation: deprovision + remove for all users + + // First deprovision the package + try + { + auto deprovisionResult = pm.DeprovisionPackageForAllUsersAsync(packageFamilyName).get(); + if (deprovisionResult.IsRegistered()) + { + Logger::warn(L"Machine-level deprovisioning completed with warnings"); + } + } + catch (const winrt::hresult_error& ex) + { + Logger::warn(L"Machine-level deprovisioning failed: HRESULT 0x{:08X}", static_cast<uint32_t>(ex.code())); + } + + // Then remove packages for all users + auto packages = pm.FindPackagesForUserWithPackageTypes({}, packageFamilyName, PackageTypes::Main); + for (const auto& package : packages) + { + try + { + auto machineResult = pm.RemovePackageAsync(package.Id().FullName(), RemovalOptions::RemoveForAllUsers).get(); + if (machineResult.IsRegistered()) + { + uint32_t errorCode = static_cast<uint32_t>(machineResult.ExtendedErrorCode()); + Logger::error(L"Machine-level removal failed: 0x{:08X} - {}", errorCode, machineResult.ErrorText()); + } + } + catch (const winrt::hresult_error& ex) + { + Logger::error(L"Machine-level removal exception: HRESULT 0x{:08X}", static_cast<uint32_t>(ex.code())); + } + } + } + else + { + // Per-user uninstallation: standard removal + + auto packages = pm.FindPackagesForUserWithPackageTypes({}, packageFamilyName, PackageTypes::Main); + for (const auto& package : packages) + { + auto userResult = pm.RemovePackageAsync(package.Id().FullName()).get(); + if (userResult.IsRegistered()) + { + uint32_t errorCode = static_cast<uint32_t>(userResult.ExtendedErrorCode()); + Logger::error(L"Per-user removal failed: 0x{:08X} - {}", errorCode, userResult.ErrorText()); + } + } + } + } + catch (const std::exception& ex) + { + std::string errorMsg = "Failed to uninstall PackageIdentity MSIX: " + std::string(ex.what()); + Logger::error(errorMsg); + // Don't fail the entire uninstallation if PackageIdentity fails + Logger::warn(L"Continuing uninstallation despite PackageIdentity MSIX error"); + } + +LExit: + ReleaseStr(installScope); + er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; + return WcaFinalize(er); +} + UINT __stdcall RemoveWindowsServiceByName(std::wstring serviceName) { SC_HANDLE hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_CONNECT); @@ -641,14 +857,69 @@ UINT __stdcall UnsetAdvancedPasteAPIKeyCA(MSIHANDLE hInstall) try { - winrt::Windows::Security::Credentials::PasswordVault vault; - winrt::Windows::Security::Credentials::PasswordCredential cred; - hr = WcaInitialize(hInstall, "UnsetAdvancedPasteAPIKey"); ExitOnFailure(hr, "Failed to initialize"); - cred = vault.Retrieve(L"https://platform.openai.com/api-keys", L"PowerToys_AdvancedPaste_OpenAIKey"); - vault.Remove(cred); + winrt::Windows::Security::Credentials::PasswordVault vault; + + auto hasPrefix = [](std::wstring_view value, wchar_t const* prefix) { + std::wstring_view prefixView{ prefix }; + return value.compare(0, prefixView.size(), prefixView) == 0; + }; + + const wchar_t* resourcePrefixes[] = { + L"https://platform.openai.com/api-keys", + L"https://azure.microsoft.com/products/ai-services/openai-service", + L"https://azure.microsoft.com/products/ai-services/ai-inference", + L"https://console.mistral.ai/account/api-keys", + L"https://ai.google.dev/", + }; + + const wchar_t* usernamePrefixes[] = { + L"PowerToys_AdvancedPaste_", + }; + + auto credentials = vault.RetrieveAll(); + for (auto const& credential : credentials) + { + bool shouldRemove = false; + + std::wstring resource{ credential.Resource() }; + for (auto const prefix : resourcePrefixes) + { + if (hasPrefix(resource, prefix)) + { + shouldRemove = true; + break; + } + } + + if (!shouldRemove) + { + std::wstring username{ credential.UserName() }; + for (auto const prefix : usernamePrefixes) + { + if (hasPrefix(username, prefix)) + { + shouldRemove = true; + break; + } + } + } + + if (!shouldRemove) + { + continue; + } + + try + { + vault.Remove(credential); + } + catch (...) + { + } + } } catch (...) { @@ -1153,6 +1424,114 @@ UINT __stdcall UnRegisterContextMenuPackagesCA(MSIHANDLE hInstall) return WcaFinalize(er); } + +UINT __stdcall CleanImageResizerRuntimeRegistryCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + hr = WcaInitialize(hInstall, "CleanImageResizerRuntimeRegistryCA"); + + try + { + const wchar_t* CLSID_STR = L"{51B4D7E5-7568-4234-B4BB-47FB3C016A69}"; + const wchar_t* exts[] = { L".bmp", L".dib", L".gif", L".jfif", L".jpe", L".jpeg", L".jpg", L".jxr", L".png", L".rle", L".tif", L".tiff", L".wdp" }; + + auto deleteKeyRecursive = [](HKEY root, const std::wstring &path) { + RegDeleteTreeW(root, path.c_str()); + }; + + // InprocServer32 chain root CLSID + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\CLSID\\" + std::wstring(CLSID_STR)); + // DragDrop handler + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\Directory\\ShellEx\\DragDropHandlers\\ImageResizer"); + // Extensions + for (auto ext : exts) + { + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\SystemFileAssociations\\" + std::wstring(ext) + L"\\ShellEx\\ContextMenuHandlers\\ImageResizer"); + } + // Sentinel + RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\PowerToys\\ImageResizer"); + } + catch (...) + { + er = ERROR_INSTALL_FAILURE; + } + + er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; + return WcaFinalize(er); +} + +UINT __stdcall CleanFileLocksmithRuntimeRegistryCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + hr = WcaInitialize(hInstall, "CleanFileLocksmithRuntimeRegistryCA"); + try + { + const wchar_t* CLSID_STR = L"{84D68575-E186-46AD-B0CB-BAEB45EE29C0}"; + auto deleteKeyRecursive = [](HKEY root, const std::wstring& path) { + RegDeleteTreeW(root, path.c_str()); + }; + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\CLSID\\" + std::wstring(CLSID_STR)); + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt"); + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\Drive\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt"); + RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\PowerToys\\FileLocksmith"); + } + catch (...) + { + er = ERROR_INSTALL_FAILURE; + } + er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; + return WcaFinalize(er); +} + +UINT __stdcall CleanPowerRenameRuntimeRegistryCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + hr = WcaInitialize(hInstall, "CleanPowerRenameRuntimeRegistryCA"); + try + { + const wchar_t* CLSID_STR = L"{0440049F-D1DC-4E46-B27B-98393D79486B}"; + auto deleteKeyRecursive = [](HKEY root, const std::wstring& path) { + RegDeleteTreeW(root, path.c_str()); + }; + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\CLSID\\" + std::wstring(CLSID_STR)); + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\PowerRenameExt"); + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\PowerRenameExt"); + RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\PowerToys\\PowerRename"); + } + catch (...) + { + er = ERROR_INSTALL_FAILURE; + } + er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; + return WcaFinalize(er); +} + +UINT __stdcall CleanNewPlusRuntimeRegistryCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + hr = WcaInitialize(hInstall, "CleanNewPlusRuntimeRegistryCA"); + try + { + const wchar_t* CLSID_STR = L"{FF90D477-E32A-4BE8-8CC5-A502A97F5401}"; + auto deleteKeyRecursive = [](HKEY root, const std::wstring& path) { + RegDeleteTreeW(root, path.c_str()); + }; + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\CLSID\\" + std::wstring(CLSID_STR)); + deleteKeyRecursive(HKEY_CURRENT_USER, L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\NewPlusShellExtensionWin10"); + RegDeleteTreeW(HKEY_CURRENT_USER, L"Software\\Microsoft\\PowerToys\\NewPlus"); + } + catch (...) + { + er = ERROR_INSTALL_FAILURE; + } + er = er == ERROR_SUCCESS ? (SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE) : er; + return WcaFinalize(er); +} + UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) { HRESULT hr = S_OK; @@ -1170,7 +1549,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) } processes.resize(bytes / sizeof(processes[0])); - std::array<std::wstring_view, 39> processesToTerminate = { + std::array<std::wstring_view, 45> processesToTerminate = { L"PowerToys.PowerLauncher.exe", L"PowerToys.Settings.exe", L"PowerToys.AdvancedPaste.exe", @@ -1185,13 +1564,17 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) L"PowerToys.Hosts.exe", 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", L"PowerToys.MonacoPreviewHandler.exe", L"PowerToys.MarkdownPreviewHandler.exe", L"PowerToys.StlThumbnailProvider.exe", L"PowerToys.SvgThumbnailProvider.exe", L"PowerToys.GcodePreviewHandler.exe", + L"PowerToys.BgcodePreviewHandler.exe", L"PowerToys.QoiPreviewHandler.exe", L"PowerToys.PdfPreviewHandler.exe", L"PowerToys.QoiThumbnailProvider.exe", @@ -1202,12 +1585,14 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) L"PowerToys.MouseWithoutBordersService.exe", L"PowerToys.CropAndLock.exe", L"PowerToys.EnvironmentVariables.exe", + L"PowerToys.QuickAccess.exe", L"PowerToys.WorkspacesSnapshotTool.exe", L"PowerToys.WorkspacesLauncher.exe", L"PowerToys.WorkspacesLauncherUI.exe", L"PowerToys.WorkspacesEditor.exe", L"PowerToys.WorkspacesWindowArranger.exe", L"Microsoft.CmdPal.UI.exe", + L"Microsoft.CmdPal.Ext.PowerToys.exe", L"PowerToys.ZoomIt.exe", L"PowerToys.exe", }; @@ -1265,6 +1650,120 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) return WcaFinalize(er); } +UINT __stdcall SetBundleInstallLocationCA(MSIHANDLE hInstall) +{ + HRESULT hr = S_OK; + UINT er = ERROR_SUCCESS; + + // Declare all variables at the beginning to avoid goto issues + std::wstring customActionData; + std::wstring installationFolder; + std::wstring bundleUpgradeCode; + std::wstring installScope; + bool isPerUser = false; + size_t pos1 = std::wstring::npos; + size_t pos2 = std::wstring::npos; + std::vector<HKEY> keysToTry; + + hr = WcaInitialize(hInstall, "SetBundleInstallLocationCA"); + ExitOnFailure(hr, "Failed to initialize"); + + // Parse CustomActionData: "installFolder;upgradeCode;installScope" + hr = getInstallFolder(hInstall, customActionData); + ExitOnFailure(hr, "Failed to get CustomActionData."); + + pos1 = customActionData.find(L';'); + if (pos1 == std::wstring::npos) + { + hr = E_INVALIDARG; + ExitOnFailure(hr, "Invalid CustomActionData format - missing first semicolon"); + } + + pos2 = customActionData.find(L';', pos1 + 1); + if (pos2 == std::wstring::npos) + { + hr = E_INVALIDARG; + ExitOnFailure(hr, "Invalid CustomActionData format - missing second semicolon"); + } + + installationFolder = customActionData.substr(0, pos1); + bundleUpgradeCode = customActionData.substr(pos1 + 1, pos2 - pos1 - 1); + installScope = customActionData.substr(pos2 + 1); + + isPerUser = (installScope == L"perUser"); + + // Use the appropriate registry based on install scope + HKEY targetKey = isPerUser ? HKEY_CURRENT_USER : HKEY_LOCAL_MACHINE; + const wchar_t* keyName = isPerUser ? L"HKCU" : L"HKLM"; + + WcaLog(LOGMSG_STANDARD, "SetBundleInstallLocationCA: Searching for Bundle in %ls registry", keyName); + + HKEY uninstallKey; + LONG openResult = RegOpenKeyExW(targetKey, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", 0, KEY_READ | KEY_ENUMERATE_SUB_KEYS, &uninstallKey); + if (openResult != ERROR_SUCCESS) + { + WcaLog(LOGMSG_STANDARD, "SetBundleInstallLocationCA: Failed to open uninstall key, error: %ld", openResult); + goto LExit; + } + + DWORD index = 0; + wchar_t subKeyName[256]; + DWORD subKeyNameSize = sizeof(subKeyName) / sizeof(wchar_t); + + while (RegEnumKeyExW(uninstallKey, index, subKeyName, &subKeyNameSize, nullptr, nullptr, nullptr, nullptr) == ERROR_SUCCESS) + { + HKEY productKey; + if (RegOpenKeyExW(uninstallKey, subKeyName, 0, KEY_READ | KEY_WRITE, &productKey) == ERROR_SUCCESS) + { + wchar_t upgradeCode[256]; + DWORD upgradeCodeSize = sizeof(upgradeCode); + DWORD valueType; + + if (RegQueryValueExW(productKey, L"BundleUpgradeCode", nullptr, &valueType, + reinterpret_cast<LPBYTE>(upgradeCode), &upgradeCodeSize) == ERROR_SUCCESS) + { + // Remove brackets from registry upgradeCode for comparison (bundleUpgradeCode doesn't have brackets) + std::wstring regUpgradeCode = upgradeCode; + if (!regUpgradeCode.empty() && regUpgradeCode.front() == L'{' && regUpgradeCode.back() == L'}') + { + regUpgradeCode = regUpgradeCode.substr(1, regUpgradeCode.length() - 2); + } + + if (_wcsicmp(regUpgradeCode.c_str(), bundleUpgradeCode.c_str()) == 0) + { + // Found matching Bundle, set InstallLocation + LONG setResult = RegSetValueExW(productKey, L"InstallLocation", 0, REG_SZ, + reinterpret_cast<const BYTE*>(installationFolder.c_str()), + static_cast<DWORD>((installationFolder.length() + 1) * sizeof(wchar_t))); + + if (setResult == ERROR_SUCCESS) + { + WcaLog(LOGMSG_STANDARD, "SetBundleInstallLocationCA: InstallLocation set successfully"); + } + else + { + WcaLog(LOGMSG_STANDARD, "SetBundleInstallLocationCA: Failed to set InstallLocation, error: %ld", setResult); + } + + RegCloseKey(productKey); + RegCloseKey(uninstallKey); + goto LExit; + } + } + RegCloseKey(productKey); + } + + index++; + subKeyNameSize = sizeof(subKeyName) / sizeof(wchar_t); + } + + RegCloseKey(uninstallKey); + +LExit: + er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE; + return WcaFinalize(er); +} + void initSystemLogger() { static std::once_flag initLoggerFlag; diff --git a/installer/PowerToysSetupCustomActions/CustomAction.def b/installer/PowerToysSetupCustomActionsVNext/CustomAction.def similarity index 72% rename from installer/PowerToysSetupCustomActions/CustomAction.def rename to installer/PowerToysSetupCustomActionsVNext/CustomAction.def index 6e42da27c5..4bad107f16 100644 --- a/installer/PowerToysSetupCustomActions/CustomAction.def +++ b/installer/PowerToysSetupCustomActionsVNext/CustomAction.def @@ -1,4 +1,4 @@ -LIBRARY "PowerToysSetupCustomActions" +LIBRARY "PowerToysSetupCustomActionsVNext" EXPORTS LaunchPowerToysCA @@ -28,3 +28,10 @@ EXPORTS UninstallCommandNotFoundModuleCA UpgradeCommandNotFoundModuleCA UnsetAdvancedPasteAPIKeyCA + CleanImageResizerRuntimeRegistryCA + CleanFileLocksmithRuntimeRegistryCA + CleanPowerRenameRuntimeRegistryCA + CleanNewPlusRuntimeRegistryCA + SetBundleInstallLocationCA + InstallPackageIdentityMSIXCA + UninstallPackageIdentityMSIXCA diff --git a/installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj similarity index 50% rename from installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj rename to installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj index 09ed1ee31a..6c24369efc 100644 --- a/installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj +++ b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj @@ -1,25 +1,26 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="15.0" DefaultTargets="Build" - xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="..\packages\WixToolset.WcaUtil.5.0.2\build\WixToolset.WcaUtil.props" Condition="Exists('..\packages\WixToolset.WcaUtil.5.0.2\build\WixToolset.WcaUtil.props')" /> + <Import Project="..\packages\WixToolset.DUtil.5.0.2\build\WixToolset.DUtil.props" Condition="Exists('..\packages\WixToolset.DUtil.5.0.2\build\WixToolset.DUtil.props')" /> + <Import Project="..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Import Project="..\wix.props" Condition="Exists('..\wix.props')" /> <PropertyGroup Label="Globals"> - <ProjectGuid>{32f3882b-f2d6-4586-b5ed-11e39e522bd3}</ProjectGuid> + <ProjectGuid>{B3A354B0-1E54-4B55-A962-FB5AF9330C19}</ProjectGuid> <Keyword>Win32Proj</Keyword> - <RootNamespace>PowerToysSetupCustomActions</RootNamespace> - <ProjectName>PowerToysSetupCustomActions</ProjectName> + <RootNamespace>PowerToysSetupCustomActionsVNext</RootNamespace> + <ProjectName>PowerToysSetupCustomActionsVNext</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" Condition="Exists('$(VCTargetsPath)\Microsoft.Cpp.Default.props')" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <CharacterSet>Unicode</CharacterSet> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <CharacterSet>Unicode</CharacterSet> <WholeProgramOptimization>true</WholeProgramOptimization> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <Import Project="..\..\deps\spdlog.props" /> @@ -33,13 +34,8 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir Condition=" '$(PerUser)' != 'true' ">$(Platform)\$(Configuration)\MachineSetup\</OutDir> - <OutDir Condition=" '$(PerUser)' == 'true' ">$(Platform)\$(Configuration)\UserSetup\</OutDir> - <IntDir Condition=" '$(PerUser)' != 'true' ">$(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\MachineSetup\obj\</IntDir> - <IntDir Condition=" '$(PerUser)' == 'true' ">$(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\UserSetup\obj\</IntDir> - <!-- The CMD script below checks this value, and it is **CASE SENSITIVE** --> - <NormalizedPerUserValue>false</NormalizedPerUserValue> - <NormalizedPerUserValue Condition=" '$(PerUser)' == 'true' ">true</NormalizedPerUserValue> + <OutDir>$(Platform)\$(Configuration)\SetupShared\</OutDir> + <IntDir>$(SolutionDir)$(ProjectName)\$(Platform)\$(Configuration)\SetupShared\obj\</IntDir> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'"> <LinkIncremental>true</LinkIncremental> @@ -52,36 +48,37 @@ <PreBuildEvent> <Command> call cmd /C "copy ""$(ProjectDir)DepsFilesLists.h"" ""$(ProjectDir)DepsFilesLists.h.bk""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\AdvancedPaste.wxs"" ""$(ProjectDir)..\PowerToysSetup\AdvancedPaste.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Awake.wxs"" ""$(ProjectDir)..\PowerToysSetup\Awake.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\BaseApplications.wxs"" ""$(ProjectDir)..\PowerToysSetup\BaseApplications.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\CmdPal.wxs"" ""$(ProjectDir)..\PowerToysSetup\CmdPal.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\ColorPicker.wxs"" ""$(ProjectDir)..\PowerToysSetup\ColorPicker.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Core.wxs"" ""$(ProjectDir)..\PowerToysSetup\Core.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\EnvironmentVariables.wxs"" ""$(ProjectDir)..\PowerToysSetup\EnvironmentVariables.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\FileExplorerPreview.wxs"" ""$(ProjectDir)..\PowerToysSetup\FileExplorerPreview.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\FileLocksmith.wxs"" ""$(ProjectDir)..\PowerToysSetup\FileLocksmith.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Hosts.wxs"" ""$(ProjectDir)..\PowerToysSetup\Hosts.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\ImageResizer.wxs"" ""$(ProjectDir)..\PowerToysSetup\ImageResizer.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\KeyboardManager.wxs"" ""$(ProjectDir)..\PowerToysSetup\KeyboardManager.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\MouseWithoutBorders.wxs"" ""$(ProjectDir)..\PowerToysSetup\MouseWithoutBorders.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\NewPlus.wxs"" ""$(ProjectDir)..\PowerToysSetup\NewPlus.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Peek.wxs"" ""$(ProjectDir)..\PowerToysSetup\Peek.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\PowerRename.wxs"" ""$(ProjectDir)..\PowerToysSetup\PowerRename.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Product.wxs"" ""$(ProjectDir)..\PowerToysSetup\Product.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\RegistryPreview.wxs"" ""$(ProjectDir)..\PowerToysSetup\RegistryPreview.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Resources.wxs"" ""$(ProjectDir)..\PowerToysSetup\Resources.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Run.wxs"" ""$(ProjectDir)..\PowerToysSetup\Run.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Settings.wxs"" ""$(ProjectDir)..\PowerToysSetup\Settings.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\ShortcutGuide.wxs"" ""$(ProjectDir)..\PowerToysSetup\ShortcutGuide.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Tools.wxs"" ""$(ProjectDir)..\PowerToysSetup\Tools.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\WinAppSDK.wxs"" ""$(ProjectDir)..\PowerToysSetup\WinAppSDK.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\WinUI3Applications.wxs"" ""$(ProjectDir)..\PowerToysSetup\WinUI3Applications.wxs.bk"""" - call cmd /C "copy ""$(ProjectDir)..\PowerToysSetup\Workspaces.wxs"" ""$(ProjectDir)..\PowerToysSetup\Workspaces.wxs.bk"""" - if not "$(NormalizedPerUserValue)" == "true" call powershell.exe -NonInteractive -executionpolicy Unrestricted -File ..\PowerToysSetup\generateAllFileComponents.ps1 -platform $(Platform) - if "$(NormalizedPerUserValue)" == "true" call powershell.exe -NonInteractive -executionpolicy Unrestricted -File ..\PowerToysSetup\generateAllFileComponents.ps1 -platform $(Platform) -installscopeperuser $(NormalizedPerUserValue) + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\AdvancedPaste.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\AdvancedPaste.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Awake.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Awake.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\BaseApplications.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\BaseApplications.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\CmdPal.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\CmdPal.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\ColorPicker.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\ColorPicker.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Core.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Core.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\DscResources.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\DscResources.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\EnvironmentVariables.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\EnvironmentVariables.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\FileExplorerPreview.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\FileExplorerPreview.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\FileLocksmith.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\FileLocksmith.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Hosts.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Hosts.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\ImageResizer.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\ImageResizer.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\KeyboardManager.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\KeyboardManager.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\LightSwitch.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\LightSwitch.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\MouseWithoutBorders.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\MouseWithoutBorders.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\NewPlus.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\NewPlus.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Peek.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Peek.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\PowerRename.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\PowerRename.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Product.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Product.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\RegistryPreview.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\RegistryPreview.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Resources.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Resources.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Run.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Run.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Settings.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Settings.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\ShortcutGuide.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\ShortcutGuide.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Tools.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Tools.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\WinAppSDK.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\WinAppSDK.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\WinUI3Applications.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\WinUI3Applications.wxs.bk"""" + call cmd /C "copy ""$(ProjectDir)..\PowerToysSetupVNext\Workspaces.wxs"" ""$(ProjectDir)..\PowerToysSetupVNext\Workspaces.wxs.bk"""" + call powershell.exe -NonInteractive -executionpolicy Unrestricted -File ..\PowerToysSetupVNext\generateAllFileComponents.ps1 -platform $(Platform) </Command> - <Message>Backing up original files and populating .NET and WPF Runtime dependencies </Message> + <Message>Backing up original files and populating .NET and WPF Runtime dependencies for WiX3 based installer</Message> </PreBuildEvent> </ItemDefinitionGroup> <PropertyGroup Condition="'$(RunBuildEvents)'=='false'"> @@ -90,8 +87,8 @@ </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>inc;..\..\src\;..\..\src\common\Telemetry;telemetry;$(WixSdkPath)VS2017\inc;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> - <AdditionalOptions>/await /Zc:twoPhase- /Wv:18 %(AdditionalOptions)</AdditionalOptions> + <AdditionalIncludeDirectories>inc;..\..\src\;..\..\src\common\Telemetry;telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalOptions>/Zc:twoPhase- /Wv:18 %(AdditionalOptions)</AdditionalOptions> <WarningLevel>Level4</WarningLevel> <DebugInformationFormat>ProgramDatabase</DebugInformationFormat> </ClCompile> @@ -105,7 +102,7 @@ <PreprocessorDefinitions>WIN64;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> - <AdditionalLibraryDirectories>$(WixSdkPath)VS2017\lib\$(Platform);%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> + <AdditionalLibraryDirectories>$(NUGET_PACKAGES)\wixtoolset.wcautil\5.0.2\build\native\v14\$(Platform);$(NUGET_PACKAGES)\wixtoolset.dutil\5.0.2\build\native\v14\$(Platform);%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> </Link> </ItemDefinitionGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> @@ -168,13 +165,15 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\packages\WixToolset.DUtil.5.0.2\build\WixToolset.DUtil.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\WixToolset.DUtil.5.0.2\build\WixToolset.DUtil.props'))" /> + <Error Condition="!Exists('..\packages\WixToolset.WcaUtil.5.0.2\build\WixToolset.WcaUtil.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\WixToolset.WcaUtil.5.0.2\build\WixToolset.WcaUtil.props'))" /> </Target> -</Project> \ No newline at end of file +</Project> diff --git a/installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj.filters b/installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj.filters similarity index 100% rename from installer/PowerToysSetupCustomActions/PowerToysSetupCustomActions.vcxproj.filters rename to installer/PowerToysSetupCustomActionsVNext/PowerToysSetupCustomActionsVNext.vcxproj.filters diff --git a/installer/PowerToysSetupCustomActions/RcResource.h b/installer/PowerToysSetupCustomActionsVNext/RcResource.h similarity index 100% rename from installer/PowerToysSetupCustomActions/RcResource.h rename to installer/PowerToysSetupCustomActionsVNext/RcResource.h diff --git a/installer/PowerToysSetupCustomActions/Resource.rc b/installer/PowerToysSetupCustomActionsVNext/Resource.rc similarity index 100% rename from installer/PowerToysSetupCustomActions/Resource.rc rename to installer/PowerToysSetupCustomActionsVNext/Resource.rc diff --git a/installer/PowerToysSetupCustomActionsVNext/packages.config b/installer/PowerToysSetupCustomActionsVNext/packages.config new file mode 100644 index 0000000000..471922dc14 --- /dev/null +++ b/installer/PowerToysSetupCustomActionsVNext/packages.config @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> + <package id="WixToolset.DUtil" version="5.0.2" targetFramework="native" /> + <package id="WixToolset.WcaUtil" version="5.0.2" targetFramework="native" /> +</packages> \ No newline at end of file diff --git a/installer/PowerToysSetupCustomActions/pch.cpp b/installer/PowerToysSetupCustomActionsVNext/pch.cpp similarity index 100% rename from installer/PowerToysSetupCustomActions/pch.cpp rename to installer/PowerToysSetupCustomActionsVNext/pch.cpp diff --git a/installer/PowerToysSetupCustomActions/pch.h b/installer/PowerToysSetupCustomActionsVNext/pch.h similarity index 100% rename from installer/PowerToysSetupCustomActions/pch.h rename to installer/PowerToysSetupCustomActionsVNext/pch.h diff --git a/installer/PowerToysSetupCustomActions/resource.h b/installer/PowerToysSetupCustomActionsVNext/resource.h similarity index 79% rename from installer/PowerToysSetupCustomActions/resource.h rename to installer/PowerToysSetupCustomActionsVNext/resource.h index d31a222438..0a9e468096 100644 --- a/installer/PowerToysSetupCustomActions/resource.h +++ b/installer/PowerToysSetupCustomActionsVNext/resource.h @@ -3,8 +3,8 @@ // Used by Resource.rc #define FILE_DESCRIPTION "PowerToys Setup Custom Actions" -#define INTERNAL_NAME "PowerToysSetupCustomActions" -#define ORIGINAL_FILENAME "PowerToysSetupCustomActions.dll" +#define INTERNAL_NAME "PowerToysSetupCustomActionsVNext" +#define ORIGINAL_FILENAME "PowerToysSetupCustomActionsVNext.dll" // Next default values for new objects // diff --git a/installer/PowerToysSetup/AdvancedPaste.wxs b/installer/PowerToysSetupVNext/AdvancedPaste.wxs similarity index 80% rename from installer/PowerToysSetup/AdvancedPaste.wxs rename to installer/PowerToysSetupVNext/AdvancedPaste.wxs index a865ddbf6c..f7a01b29ab 100644 --- a/installer/PowerToysSetup/AdvancedPaste.wxs +++ b/installer/PowerToysSetupVNext/AdvancedPaste.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -17,11 +15,11 @@ </DirectoryRef> <ComponentGroup Id="AdvancedPasteComponentGroup"> - <Component Id="RemoveAdvancedPasteFolder" Guid="55AFE81D-F6BD-439A-A229-66AF5C360AB0" Directory="AdvancedPasteAssetsFolder" > + <Component Id="RemoveAdvancedPasteFolder" Guid="55AFE81D-F6BD-439A-A229-66AF5C360AB0" Directory="AdvancedPasteAssetsFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveAdvancedPasteFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveAdvancedPasteFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderAdvancedPasteAssetsFolder" Directory="AdvancedPasteAssetsFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderAdvancedPasteAssetsFolder" Directory="AdvancedPasteAssetsFolder" On="uninstall" /> </Component> </ComponentGroup> diff --git a/installer/PowerToysSetup/Awake.wxs b/installer/PowerToysSetupVNext/Awake.wxs similarity index 77% rename from installer/PowerToysSetup/Awake.wxs rename to installer/PowerToysSetupVNext/Awake.wxs index a8f5536ff4..6dbd86ed43 100644 --- a/installer/PowerToysSetup/Awake.wxs +++ b/installer/PowerToysSetupVNext/Awake.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -20,11 +18,11 @@ </DirectoryRef> <ComponentGroup Id="AwakeComponentGroup"> - <Component Id="RemoveAwakeFolder" Guid="95D7774C-69A3-48A3-B417-1BD9664BE974" Directory="INSTALLFOLDER" > + <Component Id="RemoveAwakeFolder" Guid="95D7774C-69A3-48A3-B417-1BD9664BE974" Directory="INSTALLFOLDER"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveAwakeFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveAwakeFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderAwakeImagesFolder" Directory="AwakeImagesFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderAwakeImagesFolder" Directory="AwakeImagesFolder" On="uninstall" /> </Component> </ComponentGroup> diff --git a/installer/PowerToysSetupVNext/BaseApplications.wxs b/installer/PowerToysSetupVNext/BaseApplications.wxs new file mode 100644 index 0000000000..57a9c71637 --- /dev/null +++ b/installer/PowerToysSetupVNext/BaseApplications.wxs @@ -0,0 +1,25 @@ +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> + + <?include $(sys.CURRENTDIR)\Common.wxi?> + + <?define BaseApplicationsFiles=?> + <?define BaseApplicationsFilesPath=$(var.BinDir)\?> + + <Fragment> + <DirectoryRef Id="INSTALLFOLDER"> + <Component Id="Microsoft_CommandPalette_Extensions_winmd" Guid="304AD25A-A986-4058-940E-61DB79EBD78C" Bitness="always64"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="Microsoft_CommandPalette_Extensions_winmd" Value="" KeyPath="yes" /> + </RegistryKey> + <File Id="Microsoft.CommandPalette.Extensions.winmd" Source="$(var.BinDir)Microsoft.CommandPalette.Extensions.winmd" /> + </Component> + <!-- Generated by generateFileComponents.ps1 --> + <!--BaseApplicationsFiles_Component_Def--> + </DirectoryRef> + + <ComponentGroup Id="BaseApplicationsComponentGroup"> + <ComponentRef Id="Microsoft_CommandPalette_Extensions_winmd" /> + </ComponentGroup> + + </Fragment> +</Wix> diff --git a/installer/PowerToysSetup/CmdPal.wxs b/installer/PowerToysSetupVNext/CmdPal.wxs similarity index 55% rename from installer/PowerToysSetup/CmdPal.wxs rename to installer/PowerToysSetupVNext/CmdPal.wxs index 89a813979b..b6b80127c2 100644 --- a/installer/PowerToysSetup/CmdPal.wxs +++ b/installer/PowerToysSetupVNext/CmdPal.wxs @@ -1,76 +1,65 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"> - +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> - <?define CmdPalBuildDir="$(var.BinDir)\WinUI3Apps\CmdPal\"?> - <Fragment> <DirectoryRef Id="WinUI3AppsInstallFolder"> <Directory Id="CmdPalInstallFolder" Name="CmdPal"> <Directory Id="CmdPalDepsInstallFolder" Name="Dependencies"> <?if $(sys.BUILDARCH) = x64 ?> <Directory Id="CmdPalDepsX64InstallFolder" Name="x64" /> - <?else ?> + <?else?> <Directory Id="CmdPalDepsArm64InstallFolder" Name="arm64" /> - <?endif ?> + <?endif?> </Directory> </Directory> </DirectoryRef> - - <DirectoryRef Id="CmdPalInstallFolder" FileSource="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_Test"> - <Component Id="Module_CmdPal" Win64="yes" Guid="3A4942B2-1A86-4182-B3B4-65157365A980"> + <DirectoryRef Id="CmdPalInstallFolder" FileSource="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion)_Test"> + <Component Id="Module_CmdPal" Guid="3A4942B2-1A86-4182-B3B4-65157365A980" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Module_CmdPal" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Module_CmdPal" Value="" KeyPath="yes" /> </RegistryKey> + <RemoveFile Id="RemoveOldCmdPalMsix" Name="Microsoft.CmdPal.UI_*.msix" On="install" /> <?if $(sys.BUILDARCH) = x64 ?> - <File Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_Test\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_x64.msix" /> - <?else ?> - <File Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_Test\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_arm64.msix" /> - <?endif ?> - + <File Id="Microsoft.CmdPal.UI___var.CmdPalVersion_._x64.msix" Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion)_Test\Microsoft.CmdPal.UI_$(var.CmdPalVersion)_x64.msix" /> + <?else?> + <File Id="Microsoft.CmdPal.UI___var.CmdPalVersion_._arm64.msix" Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion)_Test\Microsoft.CmdPal.UI_$(var.CmdPalVersion)_arm64.msix" /> + <?endif?> </Component> </DirectoryRef> - <?if $(sys.BUILDARCH) = x64 ?> - <DirectoryRef Id="CmdPalDepsX64InstallFolder" FileSource="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_Test\Dependencies\x64"> - <Component Id="Module_CmdPal_Deps" Win64="yes" Guid="C2790FC4-0665-4462-947A-D942A2AABFF0"> + <DirectoryRef Id="CmdPalDepsX64InstallFolder" FileSource="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion)_Test\Dependencies\x64"> + <Component Id="Module_CmdPal_Deps" Guid="C2790FC4-0665-4462-947A-D942A2AABFF0" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Module_CmdPal_Deps" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Module_CmdPal_Deps" Value="" KeyPath="yes" /> </RegistryKey> - <File Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_Test\Dependencies\x64\Microsoft.VCLibs.x64.14.00.Desktop.appx" /> - <File Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_Test\Dependencies\x64\Microsoft.WindowsAppRuntime.1.6.msix" /> + <File Id="Microsoft.VCLibs.x64.14.00.Desktop.appx" Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion)_Test\Dependencies\x64\Microsoft.VCLibs.x64.14.00.Desktop.appx" /> </Component> </DirectoryRef> - <?else ?> - <DirectoryRef Id="CmdPalDepsArm64InstallFolder" FileSource="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_Test\Dependencies\arm64"> - <Component Id="Module_CmdPal_Deps" Win64="yes" Guid="C2790FC4-0665-4462-947A-D942A2AABFF0"> + <?else?> + <DirectoryRef Id="CmdPalDepsArm64InstallFolder" FileSource="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion)_Test\Dependencies\arm64"> + <Component Id="Module_CmdPal_Deps" Guid="C2790FC4-0665-4462-947A-D942A2AABFF0" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Module_CmdPal_Deps" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Module_CmdPal_Deps" Value="" KeyPath="yes" /> </RegistryKey> - <File Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_Test\Dependencies\arm64\Microsoft.VCLibs.ARM64.14.00.Desktop.appx" /> - <File Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion).0_Test\Dependencies\arm64\Microsoft.WindowsAppRuntime.1.6.msix" /> + <File Id="Microsoft.VCLibs.ARM64.14.00.Desktop.appx" Source="$(var.CmdPalBuildDir)AppPackages\Microsoft.CmdPal.UI_$(var.CmdPalVersion)_Test\Dependencies\arm64\Microsoft.VCLibs.ARM64.14.00.Desktop.appx" /> </Component> </DirectoryRef> - <?endif ?> - + <?endif?> <ComponentGroup Id="CmdPalComponentGroup"> <Component Id="RemoveCmdPalFolder" Guid="2DF90C08-CC75-4245-A14E-B82904636C53" Directory="INSTALLFOLDER"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveCmdPalFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveCmdPalFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveCmdPalInstallDirFolder" Directory="CmdPalInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveCmdPalDepsInstallDirFolder" Directory="CmdPalDepsInstallFolder" On="uninstall"/> + <RemoveFolder Id="RemoveCmdPalInstallDirFolder" Directory="CmdPalInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveCmdPalDepsInstallDirFolder" Directory="CmdPalDepsInstallFolder" On="uninstall" /> <?if $(sys.BUILDARCH) = x64 ?> - <RemoveFolder Id="RemoveCmdPalDepsX64InstallDirFolder" Directory="CmdPalDepsX64InstallFolder" On="uninstall"/> - <?else ?> - <RemoveFolder Id="RemoveCmdPalDepsArm64InstallDirFolder" Directory="CmdPalDepsArm64InstallFolder" On="uninstall"/> - <?endif ?> + <RemoveFolder Id="RemoveCmdPalDepsX64InstallDirFolder" Directory="CmdPalDepsX64InstallFolder" On="uninstall" /> + <?else?> + <RemoveFolder Id="RemoveCmdPalDepsArm64InstallDirFolder" Directory="CmdPalDepsArm64InstallFolder" On="uninstall" /> + <?endif?> </Component> <ComponentRef Id="Module_CmdPal" /> <ComponentRef Id="Module_CmdPal_Deps" /> </ComponentGroup> - </Fragment> </Wix> diff --git a/installer/PowerToysSetup/ColorPicker.wxs b/installer/PowerToysSetupVNext/ColorPicker.wxs similarity index 77% rename from installer/PowerToysSetup/ColorPicker.wxs rename to installer/PowerToysSetupVNext/ColorPicker.wxs index 0c744a7b26..97deb6c499 100644 --- a/installer/PowerToysSetup/ColorPicker.wxs +++ b/installer/PowerToysSetupVNext/ColorPicker.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -19,11 +17,11 @@ </DirectoryRef> <ComponentGroup Id="ColorPickerComponentGroup"> - <Component Id="RemoveColorPickerFolder" Guid="18C0C18C-F38A-4C88-B22C-9222F3A5B2EB" Directory="INSTALLFOLDER" > + <Component Id="RemoveColorPickerFolder" Guid="18C0C18C-F38A-4C88-B22C-9222F3A5B2EB" Directory="INSTALLFOLDER"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveColorPickerFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveColorPickerFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderColorPickerAssetsFolder" Directory="ColorPickerAssetsFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderColorPickerAssetsFolder" Directory="ColorPickerAssetsFolder" On="uninstall" /> </Component> </ComponentGroup> diff --git a/installer/PowerToysSetup/Common.wxi b/installer/PowerToysSetupVNext/Common.wxi similarity index 100% rename from installer/PowerToysSetup/Common.wxi rename to installer/PowerToysSetupVNext/Common.wxi diff --git a/installer/PowerToysSetup/Core.wxs b/installer/PowerToysSetupVNext/Core.wxs similarity index 52% rename from installer/PowerToysSetup/Core.wxs rename to installer/PowerToysSetupVNext/Core.wxs index eb39fdc9db..aaf9bb5550 100644 --- a/installer/PowerToysSetup/Core.wxs +++ b/installer/PowerToysSetupVNext/Core.wxs @@ -1,29 +1,46 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> <Fragment> <DirectoryRef Id="INSTALLFOLDER" FileSource="$(var.BinDir)"> - <Component Id="powertoys_per_machine_comp" Win64="yes"> + <Component Id="powertoys_per_machine_comp" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys"> <RegistryValue Type="string" Name="InstallScope" Value="$(var.InstallScope)" /> </RegistryKey> </Component> - <Component Id="powertoys_toast_clsid" Win64="yes"> - <RemoveFolder Id='Remove_powertoys_toast_clsid' On='uninstall' /> + <?if $(var.PerUser) = "true" ?> + <Component Id="powertoys_env_path_user" Bitness="always64"> + <!-- Anchor registry for component key path --> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="powertoys_env_path_user" Value="" KeyPath="yes" /> + </RegistryKey> + <!-- Append DSCModules folder to current user's PATH for DSC v3 usage --> + <Environment Id="AddPowerToysToUserPath" Name="PATH" Action="set" Part="last" System="no" Value="[DSCModulesReferenceFolder]" /> + </Component> + <?else?> + <Component Id="powertoys_env_path_machine" Bitness="always64"> + <!-- Anchor registry for component key path --> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="powertoys_env_path_machine" Value="" KeyPath="yes" /> + </RegistryKey> + <!-- Append DSCModules folder to machine PATH for DSC v3 usage --> + <Environment Id="AddPowerToysToMachinePath" Name="PATH" Action="set" Part="last" System="yes" Value="[DSCModulesReferenceFolder]" /> + </Component> + <?endif?> + <Component Id="powertoys_toast_clsid" Bitness="always64"> + <RemoveFolder Id="Remove_powertoys_toast_clsid" On="uninstall" /> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\CLSID\{DD5CACDA-7C2E-4997-A62A-04A597B58F76}"> <RegistryValue Type="string" Value="PowerToys Toast Notifications Background Activator" /> <RegistryValue Type="string" Key="LocalServer32" Value="[INSTALLFOLDER]PowerToys.exe -ToastActivated" /> <RegistryValue Type="string" Key="LocalServer32" Name="ThreadingModel" Value="Apartment" /> </RegistryKey> </Component> - <Component Id="powertoys_exe" Win64="yes" Guid="30261594-41A6-4509-AD09-FBC4E692F441"> - <File Id="PowerToys.exe" Checksum="yes" /> + <Component Id="powertoys_exe" Guid="30261594-41A6-4509-AD09-FBC4E692F441" Bitness="always64"> + <File Id="PowerToys.exe" Name="PowerToys.exe" Checksum="yes" /> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys"> - <RegistryValue Type="string" Name="URL Protocol" Value="" KeyPath="yes"/> - <RegistryValue Type="string" Value="URL:PowerToys custom internal URI protocol"/> + <RegistryValue Type="string" Name="URL Protocol" Value="" KeyPath="yes" /> + <RegistryValue Type="string" Value="URL:PowerToys custom internal URI protocol" /> <RegistryKey Key="DefaultIcon"> <RegistryValue Type="string" Value="PowerToys.exe" /> </RegistryKey> @@ -32,39 +49,39 @@ </RegistryKey> </RegistryKey> </Component> - <Component Id="License_rtf" Win64="yes" Guid="632C60DF-0DDC-4F14-8F2B-A28136CD9E63"> + <Component Id="License_rtf" Guid="632C60DF-0DDC-4F14-8F2B-A28136CD9E63" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="License_rtf" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="License_rtf" Value="" KeyPath="yes" /> </RegistryKey> <File Source="$(var.RepoDir)\installer\License.rtf" Id="License.rtf" /> </Component> - <Component Id="Notice_md" Win64="yes" Guid="1671B5F5-1260-42CF-83A8-9B3430DFF8C5"> + <Component Id="Notice_md" Guid="1671B5F5-1260-42CF-83A8-9B3430DFF8C5" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Notice_md" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Notice_md" Value="" KeyPath="yes" /> </RegistryKey> <File Source="$(var.RepoDir)\Notice.md" Id="Notice.md" /> </Component> - </DirectoryRef> - - <DirectoryRef Id="DSCModulesReferenceFolder"> - <Component Id="PowerToysDSCReference" Win64="yes" Guid="40869ACB-0BEB-4911-AE41-5E73BC1586A9"> - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="DSCModulesReference" Value="" KeyPath="yes"/> - </RegistryKey> - <File Source="$(var.RepoDir)\src\dsc\Microsoft.PowerToys.Configure\Generated\Microsoft.PowerToys.Configure\$(var.Version).0\Microsoft.PowerToys.Configure.psd1" Id="PTConfReference.psd1" /> - <File Source="$(var.RepoDir)\src\dsc\Microsoft.PowerToys.Configure\Generated\Microsoft.PowerToys.Configure\$(var.Version).0\Microsoft.PowerToys.Configure.psm1" Id="PTConfReference.psm1" /> - </Component> + <Directory Id="SvgsFolder" Name="svgs"> + <Component Id="svgs_icons" Guid="A9B7C5D3-E1F2-4A6B-8C9D-0E1F2A3B4C5D" Bitness="always64"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="svgs_icons" Value="" KeyPath="yes" /> + </RegistryKey> + <File Id="icon.ico" Source="$(var.BinDir)svgs\icon.ico" /> + <File Id="PowerToysWhite.ico" Source="$(var.BinDir)svgs\PowerToysWhite.ico" /> + <File Id="PowerToysDark.ico" Source="$(var.BinDir)svgs\PowerToysDark.ico" /> + </Component> + </Directory> </DirectoryRef> <?if $(var.PerUser) = "true" ?> <!-- DSC module files for PerUser handled in InstallDSCModule custom action. --> <?else?> - <DirectoryRef Id="ProgramFiles64Folder"> + <StandardDirectory Id="ProgramFiles64Folder"> <Directory Id="WindowsPowerShellFolder" Name="WindowsPowerShell"> <Directory Id="PowerShellModulesFolder" Name="Modules"> <Directory Id="PowerToysDscFolder" Name="Microsoft.PowerToys.Configure"> <Directory Id="PowerToysDscVerFolder" Name="$(var.Version).0"> - <Component Id="PowerToysDSC" Win64="yes" Guid="C52AECA0-DA73-49B8-BB49-31EF6640FF1F"> + <Component Id="PowerToysDSC" Guid="C52AECA0-DA73-49B8-BB49-31EF6640FF1F" Bitness="always64"> <!-- Don't fail installation because of DSC. Files are marked as not vital. --> <File Vital="no" Source="$(var.RepoDir)\src\dsc\Microsoft.PowerToys.Configure\Generated\Microsoft.PowerToys.Configure\$(var.Version).0\Microsoft.PowerToys.Configure.psd1" Id="PTConf.psd1" /> <File Vital="no" Source="$(var.RepoDir)\src\dsc\Microsoft.PowerToys.Configure\Generated\Microsoft.PowerToys.Configure\$(var.Version).0\Microsoft.PowerToys.Configure.psm1" Id="PTConf.psm1" /> @@ -73,66 +90,54 @@ </Directory> </Directory> </Directory> - </DirectoryRef> + </StandardDirectory> <?endif?> <DirectoryRef Id="ApplicationProgramsFolder"> - <Component Id="PowerToysStartMenuShortcut" > - <Shortcut Id="ApplicationStartMenuShortcut" - Name="PowerToys (Preview)" - Description="PowerToys - Windows system utilities to maximize productivity" - Icon="powertoys.exe" - IconIndex="0" - Target="[!PowerToys.exe]" - WorkingDirectory="INSTALLFOLDER"> - <ShortcutProperty Key="System.AppUserModel.ID" Value="Microsoft.PowerToysWin32"/> + <Component Id="PowerToysStartMenuShortcut"> + <Shortcut Id="ApplicationStartMenuShortcut" Name="PowerToys (Preview)" Description="PowerToys - Windows system utilities to maximize productivity" Icon="powertoys.exe" IconIndex="0" Target="[!PowerToys.exe]" WorkingDirectory="INSTALLFOLDER"> + <ShortcutProperty Key="System.AppUserModel.ID" Value="Microsoft.PowerToysWin32" /> </Shortcut> - <RemoveFolder Id="CleanUpStartMenuShortCut" Directory="ApplicationProgramsFolder" On="uninstall"/> + <RemoveFolder Id="CleanUpStartMenuShortCut" Directory="ApplicationProgramsFolder" On="uninstall" /> <!-- ApplicationStartMenuShortcut is implicitly installed in HKCU, so WIX won't allow changing this reg value to HKLM. --> - <RegistryValue Root="HKCU" Key="Software\Microsoft\PowerToys" Name="installed" Type="integer" Value="1" KeyPath="yes"/> + <RegistryValue Root="HKCU" Key="Software\Microsoft\PowerToys" Name="installed" Type="integer" Value="1" KeyPath="yes" /> </Component> </DirectoryRef> - <DirectoryRef Id="DesktopFolder"> - <Component Id="DesktopShortcut" > - <Condition>INSTALLDESKTOPSHORTCUT</Condition> + <StandardDirectory Id="DesktopFolder"> + <Component Id="DesktopShortcut" Condition="INSTALLDESKTOPSHORTCUT"> + <!-- DesktopShortcutId is implicitly installed in HKCU, so WIX won't allow changing this reg value to HKLM. --> - <RegistryValue Root="HKCU" - Key="Software\[Manufacturer]\[ProductName]" - Name="desktopshorcutinstalled" - Type="integer" - Value="1" - KeyPath="yes"/> - <Shortcut Id="DesktopShortcutId" - Name="PowerToys (Preview)" - Description="PowerToys - Windows system utilities to maximize productivity" - Target="[!PowerToys.exe]" - WorkingDirectory="INSTALLFOLDER" - Icon="powertoys.exe" - Directory="DesktopFolder"/> + <RegistryValue Root="HKCU" Key="Software\[Manufacturer]\[ProductName]" Name="desktopshorcutinstalled" Type="integer" Value="1" KeyPath="yes" /> + <Shortcut Id="DesktopShortcutId" Name="PowerToys (Preview)" Description="PowerToys - Windows system utilities to maximize productivity" Target="[!PowerToys.exe]" WorkingDirectory="INSTALLFOLDER" Icon="powertoys.exe" Directory="DesktopFolder" /> </Component> - </DirectoryRef> + </StandardDirectory> </Fragment> <Fragment> <ComponentGroup Id="CoreComponents"> - <Component Id="RemoveCoreFolder" Guid="9330BD69-2D12-4D98-B0C7-66C99564D619" Directory="INSTALLFOLDER" > + <Component Id="RemoveCoreFolder" Guid="9330BD69-2D12-4D98-B0C7-66C99564D619" Directory="INSTALLFOLDER"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveCoreFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveCoreFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveBaseApplicationsAssetsFolder" Directory="BaseApplicationsAssetsFolder" On="uninstall"/> - <RemoveFolder Id="RemoveDSCModulesReferenceFolder" Directory="DSCModulesReferenceFolder" On="uninstall"/> - <RemoveFolder Id="RemoveWinUI3AppsInstallFolder" Directory="WinUI3AppsInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveWinUI3AppsAssetsFolder" Directory="WinUI3AppsAssetsFolder" On="uninstall"/> - <RemoveFolder Id="RemoveINSTALLFOLDER" Directory="INSTALLFOLDER" On="uninstall"/> + <RemoveFolder Id="RemoveBaseApplicationsAssetsFolder" Directory="BaseApplicationsAssetsFolder" On="uninstall" /> + <RemoveFolder Id="RemoveWinUI3AppsInstallFolder" Directory="WinUI3AppsInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveWinUI3AppsAssetsFolder" Directory="WinUI3AppsAssetsFolder" On="uninstall" /> + <RemoveFolder Id="RemoveSvgsFolder" Directory="SvgsFolder" On="uninstall" /> + <RemoveFolder Id="RemoveINSTALLFOLDER" Directory="INSTALLFOLDER" On="uninstall" /> </Component> <ComponentRef Id="powertoys_exe" /> - <ComponentRef Id="PowerToysStartMenuShortcut"/> + <ComponentRef Id="PowerToysStartMenuShortcut" /> <ComponentRef Id="powertoys_per_machine_comp" /> <ComponentRef Id="powertoys_toast_clsid" /> <ComponentRef Id="License_rtf" /> <ComponentRef Id="Notice_md" /> + <ComponentRef Id="svgs_icons" /> <ComponentRef Id="DesktopShortcut" /> - <ComponentRef Id="PowerToysDSCReference" /> + <?if $(var.PerUser) = "true" ?> + <ComponentRef Id="powertoys_env_path_user" /> + <?else?> + <ComponentRef Id="powertoys_env_path_machine" /> + <?endif?> <?if $(var.PerUser) = "false" ?> <ComponentRef Id="PowerToysDSC" /> <?endif?> diff --git a/installer/PowerToysSetup/CustomDialogs/PTInstallDirDlg.wxs b/installer/PowerToysSetupVNext/CustomDialogs/PTInstallDirDlg.wxs similarity index 87% rename from installer/PowerToysSetup/CustomDialogs/PTInstallDirDlg.wxs rename to installer/PowerToysSetupVNext/CustomDialogs/PTInstallDirDlg.wxs index d5697ec631..0f1af3a9e4 100644 --- a/installer/PowerToysSetup/CustomDialogs/PTInstallDirDlg.wxs +++ b/installer/PowerToysSetupVNext/CustomDialogs/PTInstallDirDlg.wxs @@ -1,15 +1,14 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. --> +<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. --> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <Fragment> <UI> <Dialog Id="PTInstallDirDlg" Width="370" Height="270" Title="!(loc.InstallDirDlg_Title)"> <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)" /> <Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="!(loc.WixUIBack)" /> <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="!(loc.WixUICancel)"> - <Publish Event="SpawnDialog" Value="CancelDlg">1</Publish> + <Publish Event="SpawnDialog" Value="CancelDlg" /> </Control> <Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.InstallDirDlgDescription)" /> diff --git a/installer/PowerToysSetup/CustomDialogs/PTLicenseDlg.wxs b/installer/PowerToysSetupVNext/CustomDialogs/PTLicenseDlg.wxs similarity index 74% rename from installer/PowerToysSetup/CustomDialogs/PTLicenseDlg.wxs rename to installer/PowerToysSetupVNext/CustomDialogs/PTLicenseDlg.wxs index ee7b752591..969de90bf7 100644 --- a/installer/PowerToysSetup/CustomDialogs/PTLicenseDlg.wxs +++ b/installer/PowerToysSetupVNext/CustomDialogs/PTLicenseDlg.wxs @@ -1,8 +1,7 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. --> +<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. --> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <Fragment> <UI> <Dialog Id="PTLicenseDlg" Width="370" Height="270" Title="!(loc.LicenseAgreementDlg_Title)"> @@ -11,14 +10,14 @@ <Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" /> <Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes" Text="{\WixUI_Font_Title}[ProductName] License" /> <Control Id="Print" Type="PushButton" X="112" Y="243" Width="56" Height="17" Text="!(loc.WixUIPrint)"> - <Publish Event="DoAction" Value="WixUIPrintEula">1</Publish> + <Publish Event="DoAction" Value="WixUIPrintEula_$(sys.BUILDARCHSHORT)" /> </Control> <Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="!(loc.WixUIBack)" /> <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)"> - <Publish Event="SpawnWaitDialog" Value="WaitForCostingDlg">!(wix.WixUICostingPopupOptOut) OR CostingComplete = 1</Publish> + <Publish Event="SpawnWaitDialog" Value="WaitForCostingDlg" Condition="!(wix.WixUICostingPopupOptOut) OR CostingComplete = 1" /> </Control> <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="!(loc.WixUICancel)"> - <Publish Event="SpawnDialog" Value="CancelDlg">1</Publish> + <Publish Event="SpawnDialog" Value="CancelDlg" /> </Control> <Control Id="LicenseText" Type="ScrollableText" X="20" Y="60" Width="330" Height="140" Sunken="yes" TabSkip="no"> <Text SourceFile="!(wix.WixUILicenseRtf)" /> diff --git a/installer/PowerToysSetup/CustomDialogs/WixUI_PTInstallDir.wxs b/installer/PowerToysSetupVNext/CustomDialogs/WixUI_PTInstallDir.wxs similarity index 61% rename from installer/PowerToysSetup/CustomDialogs/WixUI_PTInstallDir.wxs rename to installer/PowerToysSetupVNext/CustomDialogs/WixUI_PTInstallDir.wxs index a06d1ed278..d7666077c0 100644 --- a/installer/PowerToysSetup/CustomDialogs/WixUI_PTInstallDir.wxs +++ b/installer/PowerToysSetupVNext/CustomDialogs/WixUI_PTInstallDir.wxs @@ -1,5 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. --> +<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. --> @@ -23,7 +22,7 @@ Patch dialog sequence: --> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <Fragment> <UI Id="WixUI_PTInstallDir"> <TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" /> @@ -44,33 +43,33 @@ Patch dialog sequence: <DialogRef Id="ResumeDlg" /> <DialogRef Id="UserExit" /> - <Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath" Order="3">1</Publish> - <Publish Dialog="BrowseDlg" Control="OK" Event="SpawnDialog" Value="InvalidDirDlg" Order="4"><![CDATA[NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"]]></Publish> + <Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath_$(sys.BUILDARCHSHORT)" Order="3" /> + <Publish Dialog="BrowseDlg" Control="OK" Event="SpawnDialog" Value="InvalidDirDlg" Order="4" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"" /> - <Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999">1</Publish> + <Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999" /> - <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="PTLicenseDlg">NOT Installed</Publish> - <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg">Installed AND PATCH</Publish> + <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="PTLicenseDlg" Condition="NOT Installed" /> + <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Condition="Installed AND PATCH" /> - <Publish Dialog="PTLicenseDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish> - <Publish Dialog="PTLicenseDlg" Control="Next" Event="NewDialog" Value="PTInstallDirDlg">1</Publish> + <Publish Dialog="PTLicenseDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" /> + <Publish Dialog="PTLicenseDlg" Control="Next" Event="NewDialog" Value="PTInstallDirDlg" /> - <Publish Dialog="PTInstallDirDlg" Control="Back" Event="NewDialog" Value="PTLicenseDlg">1</Publish> - <Publish Dialog="PTInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish> - <Publish Dialog="PTInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath" Order="2">NOT WIXUI_DONTVALIDATEPATH</Publish> - <Publish Dialog="PTInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3"><![CDATA[NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"]]></Publish> - <Publish Dialog="PTInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4">WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"</Publish> - <Publish Dialog="PTInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish> - <Publish Dialog="PTInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2">1</Publish> - <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="PTInstallDirDlg" Order="1">NOT Installed</Publish> - <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="2">Installed AND NOT PATCH</Publish> - <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2">Installed AND PATCH</Publish> + <Publish Dialog="PTInstallDirDlg" Control="Back" Event="NewDialog" Value="PTLicenseDlg" /> + <Publish Dialog="PTInstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1" /> + <Publish Dialog="PTInstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath_$(sys.BUILDARCHSHORT)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" /> + <Publish Dialog="PTInstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"" /> + <Publish Dialog="PTInstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"" /> + <Publish Dialog="PTInstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" /> + <Publish Dialog="PTInstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2" /> + <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="PTInstallDirDlg" Order="1" Condition="NOT Installed" /> + <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="2" Condition="Installed AND NOT PATCH" /> + <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" Condition="Installed AND PATCH" /> - <Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg">1</Publish> + <Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg" /> - <Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish> - <Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish> - <Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg">1</Publish> + <Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg" /> + <Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg" /> + <Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg" /> </UI> <UIRef Id="WixUI_Common" /> diff --git a/installer/PowerToysSetupVNext/Directory.Build.props b/installer/PowerToysSetupVNext/Directory.Build.props new file mode 100644 index 0000000000..9bb1d8b75c --- /dev/null +++ b/installer/PowerToysSetupVNext/Directory.Build.props @@ -0,0 +1,11 @@ +<Project> + <Import Project="..\..\Directory.Build.props" /> + <PropertyGroup> + <!-- Set BaseIntermediateOutputPath for each project to avoid conflicts --> + <BaseIntermediateOutputPath Condition="'$(MSBuildProjectName)' == 'PowerToysInstallerVNext'">obj\Installer\</BaseIntermediateOutputPath> + <BaseIntermediateOutputPath Condition="'$(MSBuildProjectName)' == 'PowerToysBootstrapperVNext'">obj\Bootstrapper\</BaseIntermediateOutputPath> + + <!-- Set MSBuildProjectExtensionsPath to use the BaseIntermediateOutputPath --> + <MSBuildProjectExtensionsPath Condition="'$(BaseIntermediateOutputPath)' != ''">$(BaseIntermediateOutputPath)</MSBuildProjectExtensionsPath> + </PropertyGroup> +</Project> diff --git a/installer/PowerToysSetupVNext/DscResources.wxs b/installer/PowerToysSetupVNext/DscResources.wxs new file mode 100644 index 0000000000..2c08253229 --- /dev/null +++ b/installer/PowerToysSetupVNext/DscResources.wxs @@ -0,0 +1,33 @@ +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> + + <?include $(sys.CURRENTDIR)\Common.wxi?> + + <?define DscJsonFiles=?> + <?define DscJsonFilesPath=$(var.BinDir)\DSCModules?> + + <Fragment> + <DirectoryRef Id="DSCModulesReferenceFolder" FileSource="$(var.DscJsonFilesPath)"> + <!-- DSC v2 PowerShell module files --> + <Component Id="PowerToysDSCReference" Guid="40869ACB-0BEB-4911-AE41-5E73BC1586A9" Bitness="always64"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="DSCModulesReference" Value="" KeyPath="yes" /> + </RegistryKey> + <File Source="$(var.RepoDir)\src\dsc\Microsoft.PowerToys.Configure\Generated\Microsoft.PowerToys.Configure\$(var.Version).0\Microsoft.PowerToys.Configure.psd1" Id="PTConfReference.psd1" /> + <File Source="$(var.RepoDir)\src\dsc\Microsoft.PowerToys.Configure\Generated\Microsoft.PowerToys.Configure\$(var.Version).0\Microsoft.PowerToys.Configure.psm1" Id="PTConfReference.psm1" /> + </Component> + + <!-- DSC v3 JSON manifest files - Generated by generateAllFileComponents.ps1 --> + <!--DscJsonFiles_Component_Def--> + </DirectoryRef> + + <ComponentGroup Id="DscResourcesComponentGroup"> + <ComponentRef Id="PowerToysDSCReference" /> + <Component Id="RemoveDSCModulesFolder" Guid="A3C77D92-4E97-4C1A-9F2E-8B3C5D6E7F80" Directory="DSCModulesReferenceFolder"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="RemoveDSCModulesFolder" Value="" KeyPath="yes" /> + </RegistryKey> + <RemoveFolder Id="RemoveDSCModulesReferenceFolder" Directory="DSCModulesReferenceFolder" On="uninstall" /> + </Component> + </ComponentGroup> + </Fragment> +</Wix> diff --git a/installer/PowerToysSetup/EnvironmentVariables.wxs b/installer/PowerToysSetupVNext/EnvironmentVariables.wxs similarity index 83% rename from installer/PowerToysSetup/EnvironmentVariables.wxs rename to installer/PowerToysSetupVNext/EnvironmentVariables.wxs index 44567055af..41762e73d0 100644 --- a/installer/PowerToysSetup/EnvironmentVariables.wxs +++ b/installer/PowerToysSetupVNext/EnvironmentVariables.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -17,11 +15,11 @@ </DirectoryRef> <ComponentGroup Id="EnvironmentVariablesComponentGroup"> - <Component Id="RemoveEnvironmentVariablesFolder" Guid="B62A779D-38BA-46B2-859D-9D242D9B0CC1" Directory="EnvironmentVariablesAssetsFolder" > + <Component Id="RemoveEnvironmentVariablesFolder" Guid="B62A779D-38BA-46B2-859D-9D242D9B0CC1" Directory="EnvironmentVariablesAssetsFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveEnvironmentVariablesFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveEnvironmentVariablesFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderEnvironmentVariablesAssetsFolder" Directory="EnvironmentVariablesAssetsFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderEnvironmentVariablesAssetsFolder" Directory="EnvironmentVariablesAssetsFolder" On="uninstall" /> </Component> </ComponentGroup> diff --git a/installer/PowerToysSetup/FileExplorerPreview.wxs b/installer/PowerToysSetupVNext/FileExplorerPreview.wxs similarity index 87% rename from installer/PowerToysSetup/FileExplorerPreview.wxs rename to installer/PowerToysSetupVNext/FileExplorerPreview.wxs index 0a92d94c3e..d2e3f3fd71 100644 --- a/installer/PowerToysSetup/FileExplorerPreview.wxs +++ b/installer/PowerToysSetupVNext/FileExplorerPreview.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -28,13 +26,13 @@ </DirectoryRef> <ComponentGroup Id="FileExplorerPreviewComponentGroup"> - <Component Id="RemoveFileExplorerPreviewFolder" Guid="4AB83E58-17F1-41AF-B67F-F6C36EFED28D" Directory="MonacoAssetsFolder" > + <Component Id="RemoveFileExplorerPreviewFolder" Guid="4AB83E58-17F1-41AF-B67F-F6C36EFED28D" Directory="MonacoAssetsFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveFileExplorerPreviewFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveFileExplorerPreviewFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveMonacoAssetsFolder" Directory="MonacoAssetsFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderMonacoPreviewHandlerCustomLanguagesFolder" Directory="MonacoPreviewHandlerMonacoCustomLanguagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderMonacoPreviewHandlerMonacoSRCFolder" Directory="MonacoPreviewHandlerMonacoSRCFolder" On="uninstall"/> + <RemoveFolder Id="RemoveMonacoAssetsFolder" Directory="MonacoAssetsFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderMonacoPreviewHandlerCustomLanguagesFolder" Directory="MonacoPreviewHandlerMonacoCustomLanguagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderMonacoPreviewHandlerMonacoSRCFolder" Directory="MonacoPreviewHandlerMonacoSRCFolder" On="uninstall" /> </Component> </ComponentGroup> </Fragment> diff --git a/installer/PowerToysSetupVNext/FileLocksmith.wxs b/installer/PowerToysSetupVNext/FileLocksmith.wxs new file mode 100644 index 0000000000..9ed8d5e29a --- /dev/null +++ b/installer/PowerToysSetupVNext/FileLocksmith.wxs @@ -0,0 +1,27 @@ +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> + + <?include $(sys.CURRENTDIR)\Common.wxi?> + + <?define FileLocksmithAssetsFiles=?> + <?define FileLocksmithAssetsFilesPath=$(var.BinDir)WinUI3Apps\Assets\FileLocksmith\?> + + <Fragment> + <DirectoryRef Id="WinUI3AppsAssetsFolder"> + <Directory Id="FileLocksmithAssetsInstallFolder" Name="FileLocksmith" /> + </DirectoryRef> + <DirectoryRef Id="FileLocksmithAssetsInstallFolder" FileSource="$(var.FileLocksmithAssetsFilesPath)"> + <!-- Generated by generateFileComponents.ps1 --> + <!--FileLocksmithAssetsFiles_Component_Def--> + </DirectoryRef> + + <ComponentGroup Id="FileLocksmithComponentGroup"> + <Component Id="RemoveFileLocksmithFolder" Guid="1DAC9A3F-D89C-4730-BF57-1778E011709B" Directory="FileLocksmithAssetsInstallFolder"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="RemoveFileLocksmithFolder" Value="" KeyPath="yes" /> + </RegistryKey> + <RemoveFolder Id="RemoveFolderFileLocksmithAssetsFolder" Directory="FileLocksmithAssetsInstallFolder" On="uninstall" /> + </Component> + </ComponentGroup> + + </Fragment> +</Wix> diff --git a/installer/PowerToysSetup/Hosts.wxs b/installer/PowerToysSetupVNext/Hosts.wxs similarity index 76% rename from installer/PowerToysSetup/Hosts.wxs rename to installer/PowerToysSetupVNext/Hosts.wxs index cb86aa8e11..3fd55ddfd1 100644 --- a/installer/PowerToysSetup/Hosts.wxs +++ b/installer/PowerToysSetupVNext/Hosts.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -17,11 +15,11 @@ </DirectoryRef> <ComponentGroup Id="HostsComponentGroup"> - <Component Id="RemoveHostsFolder" Guid="7FF19EBB-041D-4498-9826-C9AECEBE86E1" Directory="HostsAssetsFolder" > + <Component Id="RemoveHostsFolder" Guid="7FF19EBB-041D-4498-9826-C9AECEBE86E1" Directory="HostsAssetsFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveHostsFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveHostsFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderHostsAssetsFolder" Directory="HostsAssetsFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderHostsAssetsFolder" Directory="HostsAssetsFolder" On="uninstall" /> </Component> </ComponentGroup> diff --git a/installer/PowerToysSetupVNext/ImageResizer.wxs b/installer/PowerToysSetupVNext/ImageResizer.wxs new file mode 100644 index 0000000000..86566cc597 --- /dev/null +++ b/installer/PowerToysSetupVNext/ImageResizer.wxs @@ -0,0 +1,27 @@ +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> + + <?include $(sys.CURRENTDIR)\Common.wxi?> + + <?define ImageResizerAssetsFiles=?> + <?define ImageResizerAssetsFilesPath=$(var.BinDir)WinUI3Apps\Assets\ImageResizer\?> + + <Fragment> + <DirectoryRef Id="WinUI3AppsAssetsFolder"> + <Directory Id="ImageResizerAssetsFolder" Name="ImageResizer" /> + </DirectoryRef> + + <DirectoryRef Id="ImageResizerAssetsFolder" FileSource="$(var.ImageResizerAssetsFilesPath)"> + <!-- Generated by generateFileComponents.ps1 --> + <!--ImageResizerAssetsFiles_Component_Def--> + </DirectoryRef> + + <ComponentGroup Id="ImageResizerComponentGroup"> + <Component Id="RemoveImageResizerFolder" Guid="8E5DE86A-8618-4590-9584-51BCD3A14280" Directory="ImageResizerAssetsFolder"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="RemoveImageResizerFolder" Value="" KeyPath="yes" /> + </RegistryKey> + <RemoveFolder Id="RemoveFolderImageResizerAssetsFolder" Directory="ImageResizerAssetsFolder" On="uninstall" /> + </Component> + </ComponentGroup> + </Fragment> +</Wix> diff --git a/installer/PowerToysSetup/Images/banner.png b/installer/PowerToysSetupVNext/Images/banner.png similarity index 100% rename from installer/PowerToysSetup/Images/banner.png rename to installer/PowerToysSetupVNext/Images/banner.png diff --git a/installer/PowerToysSetup/Images/dialog.png b/installer/PowerToysSetupVNext/Images/dialog.png similarity index 100% rename from installer/PowerToysSetup/Images/dialog.png rename to installer/PowerToysSetupVNext/Images/dialog.png diff --git a/installer/PowerToysSetup/Images/logo.png b/installer/PowerToysSetupVNext/Images/logo.png similarity index 100% rename from installer/PowerToysSetup/Images/logo.png rename to installer/PowerToysSetupVNext/Images/logo.png diff --git a/installer/PowerToysSetup/Images/logo150.png b/installer/PowerToysSetupVNext/Images/logo150.png similarity index 100% rename from installer/PowerToysSetup/Images/logo150.png rename to installer/PowerToysSetupVNext/Images/logo150.png diff --git a/installer/PowerToysSetup/Images/logo44.png b/installer/PowerToysSetupVNext/Images/logo44.png similarity index 100% rename from installer/PowerToysSetup/Images/logo44.png rename to installer/PowerToysSetupVNext/Images/logo44.png diff --git a/installer/PowerToysSetup/KeyboardManager.wxs b/installer/PowerToysSetupVNext/KeyboardManager.wxs similarity index 50% rename from installer/PowerToysSetup/KeyboardManager.wxs rename to installer/PowerToysSetupVNext/KeyboardManager.wxs index dc216ccde3..9aa9fc9472 100644 --- a/installer/PowerToysSetup/KeyboardManager.wxs +++ b/installer/PowerToysSetupVNext/KeyboardManager.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -12,42 +10,42 @@ <!-- KBM Editor --> <DirectoryRef Id="KeyboardManagerEditorInstallFolder" FileSource="$(var.BinDir)KeyboardManagerEditor"> - <Component Id="Module_KeyboardManager_Editor" Win64="yes" Guid="E9C74E78-970F-4DF5-9CC0-FFD3CCF285B4"> + <Component Id="Module_KeyboardManager_Editor" Guid="E9C74E78-970F-4DF5-9CC0-FFD3CCF285B4" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Module_KeyboardManager_Editor" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Module_KeyboardManager_Editor" Value="" KeyPath="yes" /> </RegistryKey> - <File Source="$(var.BinDir)KeyboardManagerEditor\PowerToys.KeyboardManagerEditor.exe" /> - <File Source="$(var.BinDir)KeyboardManagerEditor\Microsoft.Toolkit.Win32.UI.XamlHost.dll" /> - <File Source="$(var.BinDir)KeyboardManagerEditor\Microsoft.UI.Xaml.dll" /> - <File Source="$(var.BinDir)KeyboardManagerEditor\msvcp140_app.dll" /> - <File Source="$(var.BinDir)KeyboardManagerEditor\resources.pri" /> - <File Source="$(var.BinDir)KeyboardManagerEditor\vcruntime140_app.dll" /> + <File Id="PowerToys.KeyboardManagerEditor.exe" Source="$(var.BinDir)KeyboardManagerEditor\PowerToys.KeyboardManagerEditor.exe" /> + <File Id="Microsoft.Toolkit.Win32.UI.XamlHost.dll" Source="$(var.BinDir)KeyboardManagerEditor\Microsoft.Toolkit.Win32.UI.XamlHost.dll" /> + <File Id="Microsoft.UI.Xaml.dll" Source="$(var.BinDir)KeyboardManagerEditor\Microsoft.UI.Xaml.dll" /> + <File Id="msvcp140_app.dll" Source="$(var.BinDir)KeyboardManagerEditor\msvcp140_app.dll" /> + <File Id="resources.pri" Source="$(var.BinDir)KeyboardManagerEditor\resources.pri" /> + <File Id="vcruntime140_app.dll" Source="$(var.BinDir)KeyboardManagerEditor\vcruntime140_app.dll" /> <?if $(sys.BUILDARCH) = x64 ?> - <File Source="$(var.BinDir)KeyboardManagerEditor\vcruntime140_1_app.dll" /> - <?endif ?> - <File Source="$(var.BinDir)KeyboardManagerEditor\vcruntime140.dll" /> - <File Source="$(var.BinDir)KeyboardManagerEditor\vcruntime140_1.dll" /> - <File Source="$(var.BinDir)KeyboardManagerEditor\msvcp140.dll" /> + <File Id="vcruntime140_1_app.dll" Source="$(var.BinDir)KeyboardManagerEditor\vcruntime140_1_app.dll" /> + <?endif?> + <File Id="vcruntime140.dll" Source="$(var.BinDir)KeyboardManagerEditor\vcruntime140.dll" /> + <File Id="vcruntime140_1.dll" Source="$(var.BinDir)KeyboardManagerEditor\vcruntime140_1.dll" /> + <File Id="msvcp140.dll" Source="$(var.BinDir)KeyboardManagerEditor\msvcp140.dll" /> </Component> </DirectoryRef> <!-- KBM Engine --> <DirectoryRef Id="KeyboardManagerEngineInstallFolder" FileSource="$(var.BinDir)KeyboardManagerEngine"> - <Component Id="Module_KeyboardManager_Engine" Win64="yes" Guid="3F58FCE3-B44D-4676-94E9-C59F1FE42FFD"> + <Component Id="Module_KeyboardManager_Engine" Guid="3F58FCE3-B44D-4676-94E9-C59F1FE42FFD" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Module_KeyboardManager_Engine" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Module_KeyboardManager_Engine" Value="" KeyPath="yes" /> </RegistryKey> - <File Source="$(var.BinDir)KeyboardManagerEngine\PowerToys.KeyboardManagerEngine.exe" /> + <File Id="PowerToys.KeyboardManagerEngine.exe" Source="$(var.BinDir)KeyboardManagerEngine\PowerToys.KeyboardManagerEngine.exe" /> </Component> </DirectoryRef> <ComponentGroup Id="KeyboardManagerComponentGroup"> - <Component Id="RemoveKeyboardManagerFolder" Guid="C411CB11-4617-40A4-B6DA-1823B49FB9FF" Directory="INSTALLFOLDER" > + <Component Id="RemoveKeyboardManagerFolder" Guid="C411CB11-4617-40A4-B6DA-1823B49FB9FF" Directory="INSTALLFOLDER"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveKeyboardManagerFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveKeyboardManagerFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderKeyboardManagerEditorFolder" Directory="KeyboardManagerEditorInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderKeyboardManagerEngineFolder" Directory="KeyboardManagerEngineInstallFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderKeyboardManagerEditorFolder" Directory="KeyboardManagerEditorInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderKeyboardManagerEngineFolder" Directory="KeyboardManagerEngineInstallFolder" On="uninstall" /> </Component> <ComponentRef Id="Module_KeyboardManager_Editor" /> <ComponentRef Id="Module_KeyboardManager_Engine" /> diff --git a/installer/PowerToysSetupVNext/LightSwitch.wxs b/installer/PowerToysSetupVNext/LightSwitch.wxs new file mode 100644 index 0000000000..01f4bc329b --- /dev/null +++ b/installer/PowerToysSetupVNext/LightSwitch.wxs @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> + + <?include $(sys.CURRENTDIR)\Common.wxi?> + + <?define LightSwitchFiles=?> + <?define LightSwitchFilesPath=$(var.BinDir)\LightSwitchService\?> + + <Fragment> + <!-- Light Switch background service --> + + <!-- Create a directory for the service binaries --> + <DirectoryRef Id="INSTALLFOLDER"> + <Directory Id="LightSwitchServiceFolder" Name="LightSwitchService" /> + </DirectoryRef> + + <!-- File components generated by generateAllFileComponents.ps1 --> + <DirectoryRef Id="LightSwitchServiceFolder" FileSource="$(var.LightSwitchFilesPath)"> + <!-- Generated by generateFileComponents.ps1 --> + <!--LightSwitchFiles_Component_Def--> + </DirectoryRef> + + <!-- Group to include the service + cleanup on uninstall --> + <ComponentGroup Id="LightSwitchComponentGroup"> + + <!-- Ensures folder removal on uninstall --> + <Component Id="RemoveLightSwitchServiceFolder" Guid="C1E2F2ED-34A2-4EB0-8E17-DC0535F50F9D" Directory="INSTALLFOLDER"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="RemoveLightSwitchServiceFolder" Value="" KeyPath="yes" /> + </RegistryKey> + <RemoveFolder Id="RemoveFolderLightSwitchServiceFolder" Directory="LightSwitchServiceFolder" On="uninstall" /> + </Component> + </ComponentGroup> + </Fragment> +</Wix> diff --git a/installer/PowerToysSetup/MouseWithoutBorders.wxs b/installer/PowerToysSetupVNext/MouseWithoutBorders.wxs similarity index 75% rename from installer/PowerToysSetup/MouseWithoutBorders.wxs rename to installer/PowerToysSetupVNext/MouseWithoutBorders.wxs index 8a5efa1f8d..9a0453f296 100644 --- a/installer/PowerToysSetup/MouseWithoutBorders.wxs +++ b/installer/PowerToysSetupVNext/MouseWithoutBorders.wxs @@ -1,7 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:Fire="http://schemas.microsoft.com/wix/FirewallExtension" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"> +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:Fire="http://wixtoolset.org/schemas/v4/wxs/firewall"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -9,7 +6,7 @@ <ComponentGroup Id="MouseWithoutBordersComponentGroup"> <Component Id="MouseWithoutBordersFirewallComponent" Directory="INSTALLFOLDER" Guid="FEA59459-EC0E-4636-8E76-4C168235982B"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="MouseWithoutBordersFirewall_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="MouseWithoutBordersFirewall_Component" Value="" KeyPath="yes" /> </RegistryKey> <!-- The program name used here will be generated by generateFileComponents.ps1 --> <Fire:FirewallException Id="MouseWithoutBordersFirewallException1" Name="PowerToys.MouseWithoutBorders" Scope="localSubnet" IgnoreFailure="yes" Program="[#BaseApplicationsFiles_File_PowerToys.MouseWithoutBorders.exe]" /> diff --git a/installer/PowerToysSetup/NewPlus.wxs b/installer/PowerToysSetupVNext/NewPlus.wxs similarity index 65% rename from installer/PowerToysSetup/NewPlus.wxs rename to installer/PowerToysSetupVNext/NewPlus.wxs index 4dd1c67701..7061c01126 100644 --- a/installer/PowerToysSetup/NewPlus.wxs +++ b/installer/PowerToysSetupVNext/NewPlus.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -18,29 +16,15 @@ <DirectoryRef Id="NewPlusAssetsInstallFolder" FileSource="$(var.NewPlusAssetsFilesPath)"> <!-- Generated by generateFileComponents.ps1 --> <!--NewPlusAssetsFiles_Component_Def--> - - <!-- NewPlus Shell Extension for Win10 registration --> - <Component Id="NewPlus_ShellExtension_win10" Guid="D5456D4A-6EEC-4B85-944D-6A6A4A74FFA6" Win64="yes"> - <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\CLSID\{FF90D477-E32A-4BE8-8CC5-A502A97F5401}"> - <RegistryValue Type="string" Value="NewPlus Shell Extension Win10" /> - <RegistryValue Type="string" Name="ContextMenuOptIn" Value="" /> - <RegistryValue Type="string" Key="InprocServer32" Value="[WinUI3AppsInstallFolder]PowerToys.NewPlus.ShellExtension.win10.dll" /> - <RegistryValue Type="string" Key="InprocServer32" Name="ThreadingModel" Value="Apartment" /> - </RegistryKey> - <RegistryKey Root="$(var.RegistryScope)" Key="SOFTWARE\Classes\Directory\background\ShellEx\ContextMenuHandlers\NewPlusShellExtensionWin10"> - <RegistryValue Type="string" Value="{FF90D477-E32A-4BE8-8CC5-A502A97F5401}"/> - </RegistryKey> - </Component> </DirectoryRef> <ComponentGroup Id="NewPlusComponentGroup"> - <Component Id="RemoveNewPlusFolder" Guid="4189C789-56EB-409D-912E-3F4F3F4F1FFA" Directory="NewPlusAssetsInstallFolder" > + <Component Id="RemoveNewPlusFolder" Guid="4189C789-56EB-409D-912E-3F4F3F4F1FFA" Directory="NewPlusAssetsInstallFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveNewPlusFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveNewPlusFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderNewPlusAssetsFolder" Directory="NewPlusAssetsInstallFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderNewPlusAssetsFolder" Directory="NewPlusAssetsInstallFolder" On="uninstall" /> </Component> - <ComponentRef Id="NewPlus_ShellExtension_win10" /> </ComponentGroup> @@ -48,22 +32,22 @@ <DirectoryRef Id="WinUI3AppsAssetsFolder"> <Directory Id="NewPlusInstallFolder" Name="NewPlus"> <Directory Id="NewPlusTemplatesInstallFolder" Name="Templates"> - <Directory Id="NewPlusTemplatesSubInstallFolder" Name="Example folder"/> + <Directory Id="NewPlusTemplatesSubInstallFolder" Name="Example folder" /> </Directory> </Directory> </DirectoryRef> <DirectoryRef Id="NewPlusTemplatesInstallFolder" FileSource="$(var.NewPlusTemplateFilesPath)"> - <Component Id="NewPlusTemplateFiles_Component" Win64="yes" Guid="39264075-4B7F-40E3-A76F-21E68576D43E"> + <Component Id="NewPlusTemplateFiles_Component" Guid="39264075-4B7F-40E3-A76F-21E68576D43E" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="NewPlusTemplateFiles_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="NewPlusTemplateFiles_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="NewPlusTemplateFiles_File_1.md" Source="$(var.NewPlusTemplateFilesPath)Any files or folders placed in the template folder are available via New+.txt" /> </Component> </DirectoryRef> <DirectoryRef Id="NewPlusTemplatesSubInstallFolder" FileSource="$(var.NewPlusTemplateSubFilesPath)"> - <Component Id="NewPlusTemplateSubFiles_Component" Win64="yes" Guid="7618E61C-CCB8-492F-B284-E1AE2954AF0B"> + <Component Id="NewPlusTemplateSubFiles_Component" Guid="7618E61C-CCB8-492F-B284-E1AE2954AF0B" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="NewPlusTemplateSubFiles_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="NewPlusTemplateSubFiles_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="NewPlusTemplateSubFiles_File_1.md" Source="$(var.NewPlusTemplateSubFilesPath)Example txt file.txt" /> <File Id="NewPlusTemplateSubFiles_File_2.md" Source="$(var.NewPlusTemplateSubFilesPath)Another example txt file.txt" /> @@ -71,13 +55,13 @@ </DirectoryRef> <ComponentGroup Id="NewPlusTemplatesComponentGroup"> - <Component Id="RemoveNewPlusTemplateFolder" Guid="3E9B15CA-A50C-42DA-977F-5E9914562FE7" Directory="NewPlusInstallFolder" > + <Component Id="RemoveNewPlusTemplateFolder" Guid="3E9B15CA-A50C-42DA-977F-5E9914562FE7" Directory="NewPlusInstallFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveNewPlusTemplateFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveNewPlusTemplateFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderNewPlusInstallFolder" Directory="NewPlusInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderNewPlusTemplatesInstallFolder" Directory="NewPlusTemplatesInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderNewPlusTemplatesSubInstallFolder" Directory="NewPlusTemplatesSubInstallFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderNewPlusInstallFolder" Directory="NewPlusInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderNewPlusTemplatesInstallFolder" Directory="NewPlusTemplatesInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderNewPlusTemplatesSubInstallFolder" Directory="NewPlusTemplatesSubInstallFolder" On="uninstall" /> </Component> <ComponentRef Id="NewPlusTemplateFiles_Component" /> <ComponentRef Id="NewPlusTemplateSubFiles_Component" /> diff --git a/installer/PowerToysSetup/Peek.wxs b/installer/PowerToysSetupVNext/Peek.wxs similarity index 76% rename from installer/PowerToysSetup/Peek.wxs rename to installer/PowerToysSetupVNext/Peek.wxs index f87794e945..f7f99326ef 100644 --- a/installer/PowerToysSetup/Peek.wxs +++ b/installer/PowerToysSetupVNext/Peek.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -17,11 +15,11 @@ </DirectoryRef> <ComponentGroup Id="PeekComponentGroup"> - <Component Id="RemovePeekFolder" Guid="EF9422D7-FF0A-4887-968A-E61B53ACD23A" Directory="PeekAssetsFolder" > + <Component Id="RemovePeekFolder" Guid="EF9422D7-FF0A-4887-968A-E61B53ACD23A" Directory="PeekAssetsFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemovePeekFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemovePeekFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderPeekAssetsFolder" Directory="PeekAssetsFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderPeekAssetsFolder" Directory="PeekAssetsFolder" On="uninstall" /> </Component> </ComponentGroup> 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 @@ +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" + xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util" > + + <?include $(sys.CURRENTDIR)\Common.wxi?> + + <?define PowerDisplayAssetsFiles=?> + <?define PowerDisplayAssetsFilesPath=$(var.BinDir)WinUI3Apps\Assets\PowerDisplay?> + + <Fragment> + <!-- Power Display --> + <DirectoryRef Id="WinUI3AppsAssetsFolder"> + <Directory Id="PowerDisplayAssetsInstallFolder" Name="PowerDisplay" /> + </DirectoryRef> + <DirectoryRef Id="PowerDisplayAssetsInstallFolder" FileSource="$(var.PowerDisplayAssetsFilesPath)"> + <!-- Generated by generateFileComponents.ps1 --> + <!--PowerDisplayAssetsFiles_Component_Def--> + </DirectoryRef> + + <ComponentGroup Id="PowerDisplayComponentGroup"> + <Component Id="RemovePowerDisplayFolder" Guid="B8F2E3A5-72C1-4A2D-9B3F-8E5D7C6A4F9B" Directory="PowerDisplayAssetsInstallFolder" > + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="RemovePowerDisplayFolder" Value="" KeyPath="yes"/> + </RegistryKey> + <RemoveFolder Id="RemoveFolderPowerDisplayAssetsFolder" Directory="PowerDisplayAssetsInstallFolder" On="uninstall"/> + </Component> + </ComponentGroup> + + </Fragment> +</Wix> \ No newline at end of file diff --git a/installer/PowerToysSetupVNext/PowerRename.wxs b/installer/PowerToysSetupVNext/PowerRename.wxs new file mode 100644 index 0000000000..331dd4521f --- /dev/null +++ b/installer/PowerToysSetupVNext/PowerRename.wxs @@ -0,0 +1,27 @@ +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> + + <?include $(sys.CURRENTDIR)\Common.wxi?> + + <?define PowerRenameAssetsFiles=?> + <?define PowerRenameAssetsFilesPath=$(var.BinDir)WinUI3Apps\Assets\PowerRename\?> + + <Fragment> + <DirectoryRef Id="WinUI3AppsAssetsFolder"> + <Directory Id="PowerRenameAssetsFolder" Name="PowerRename" /> + </DirectoryRef> + <DirectoryRef Id="PowerRenameAssetsFolder" FileSource="$(var.PowerRenameAssetsFilesPath)"> + <!-- Generated by generateFileComponents.ps1 --> + <!--PowerRenameAssetsFiles_Component_Def--> + </DirectoryRef> + + <ComponentGroup Id="PowerRenameComponentGroup"> + <Component Id="RemovePowerRenameFolder" Guid="2028549B-02E3-4D80-BC3F-59AEA37AC73D" Directory="PowerRenameAssetsFolder"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="RemovePowerRenameFolder" Value="" KeyPath="yes" /> + </RegistryKey> + <RemoveFolder Id="RemoveFolderPowerRenameAssetsFolder" Directory="PowerRenameAssetsFolder" On="uninstall" /> + </Component> + </ComponentGroup> + + </Fragment> +</Wix> diff --git a/installer/PowerToysSetupVNext/PowerToys.wxs b/installer/PowerToysSetupVNext/PowerToys.wxs new file mode 100644 index 0000000000..64f6f35c5e --- /dev/null +++ b/installer/PowerToysSetupVNext/PowerToys.wxs @@ -0,0 +1,68 @@ +<?define UpgradeCode="6341382d-c0a9-4238-9188-be9607e3fab2"?> + +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util" xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal"> + + <?include $(sys.CURRENTDIR)\Common.wxi?> + + <Bundle Name="PowerToys (Preview) $(var.PowerToysPlatform)" Version="$(var.Version)" Manufacturer="Microsoft Corporation" IconSourceFile="$(var.BinDir)svgs\icon.ico" UpgradeCode="$(var.UpgradeCode)"> + <BootstrapperApplication> + <bal:WixStandardBootstrapperApplication + LicenseFile="$(var.RepoDir)\installer\License.rtf" + LogoFile="$(var.RepoDir)\installer\PowerToysSetupVNext\Images\logo44.png" + SuppressOptionsUI="no" + SuppressRepair="yes" + Theme="rtfLicense" + ThemeFile="$(var.RepoDir)\installer\PowerToysSetupVNext\RtfTheme.xml"/> + <Payload Name="icon.ico" SourceFile="$(var.BinDir)svgs\icon.ico" Compressed="yes" /> + <Payload Name="SilentFilesInUseBAFunction.dll" SourceFile="$(var.RepoDir)installer\$(var.PowerToysPlatform)\Release\SilentFilesInUseBAFunction.dll" Compressed="yes" bal:BAFunctions="yes" /> + </BootstrapperApplication> + + <util:RegistrySearch Variable="HasWebView2PerMachine" Root="HKLM" Key="SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Result="exists" /> + <util:RegistrySearch Variable="HasWebView2PerUser" Root="HKCU" Key="Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" Result="exists" /> + + <?if $(var.PerUser) = "true" ?> + <Variable Name="InstallFolder" Type="formatted" Value="[LocalAppDataFolder]PowerToys" bal:Overridable="yes" /> + <?else?> + <Variable Name="InstallFolder" Type="formatted" Value="$(var.PlatformProgramFiles)PowerToys" bal:Overridable="yes" /> + <?endif?> + + <Log Disable="no" Prefix="powertoys-bootstrapper-msi-$(var.Version)" Extension=".log" /> + + <!-- Store Bundle UpgradeCode for CustomAction --> + <Variable Name="BundleUpgradeCode" Type="string" Value="$(var.UpgradeCode)" /> + + <!-- Only install/upgrade if the version is greater or equal than the currently installed version of PowerToys, to handle the case in which PowerToys was installed from old MSI (before WiX bootstrapper was used) --> + <!-- If the previous installation is a bundle installation, just let WiX run its logic. --> + <Variable Name="MinimumVersion" Type="version" Value="0.0.0.0" /> + <Variable Name="TargetPowerToysVersion" Type="version" Value="$(var.Version)" /> + <Variable Name="DetectedPowerToysVersion" Type="version" Value="0.0.0.0" /> + <Variable Name="DetectedPowerToysUserVersion" Type="version" Value="0.0.0.0" /> + + <util:ProductSearch Id="SearchInstalledPowerToysVersion" Variable="DetectedPowerToysVersion" UpgradeCode="42B84BF7-5FBF-473B-9C8B-049DC16F7708" Result="version" /> + <util:ProductSearch Id="SearchInstalledPowerToysUserVersion" Variable="DetectedPowerToysUserVersion" UpgradeCode="D8B559DB-4C98-487A-A33F-50A8EEE42726" Result="version" /> + + <?if $(var.PerUser) = "true" ?> + <bal:Condition Message="PowerToys is already installed on this system for all users. We recommend first uninstalling that version before installing this one." Condition="MinimumVersion >= DetectedPowerToysVersion" /> + <bal:Condition Message="The same or later version of PowerToys is already installed." Condition="TargetPowerToysVersion >= DetectedPowerToysUserVersion OR WixBundleInstalled" /> + <?else?> + <bal:Condition Message="PowerToys is already installed on this system for current user. We recommend first uninstalling that version before installing this one." Condition="MinimumVersion >= DetectedPowerToysUserVersion" /> + <bal:Condition Message="A later version of PowerToys is already installed." Condition="TargetPowerToysVersion >= DetectedPowerToysVersion OR WixBundleInstalled" /> + <?endif?> + + <Variable Name="DetectedWindowsBuildNumber" Type="version" Value="0" /> + <util:RegistrySearch Id="SearchWindowsBuildNumber" Root="HKLM" Key="SOFTWARE\Microsoft\Windows NT\CurrentVersion" Value="CurrentBuildNumber" Result="value" Variable="DetectedWindowsBuildNumber" /> + <bal:Condition Message="This application is only supported on Windows 10 version v2004 (build 19041) or higher." Condition="DetectedWindowsBuildNumber >= 19041 OR WixBundleInstalled" /> + + <Chain> + <ExePackage DisplayName="Closing PowerToys application" Name="terminate_powertoys.cmd" Cache="remove" Compressed="yes" Id="TerminatePowerToys" SourceFile="terminate_powertoys.cmd" Permanent="yes" PerMachine="$(var.PerMachineYesNo)" Vital="no"> + </ExePackage> + <ExePackage DisplayName="Microsoft Edge WebView2" Name="MicrosoftEdgeWebview2Setup.exe" Compressed="yes" Id="WebView2" DetectCondition="HasWebView2PerMachine OR HasWebView2PerUser" SourceFile="WebView2\MicrosoftEdgeWebview2Setup.exe" Permanent="yes" PerMachine="$(var.PerMachineYesNo)" InstallArguments="/silent /install" RepairArguments="/repair /passive" UninstallArguments="/silent /uninstall"> + </ExePackage> + <MsiPackage DisplayName="PowerToys MSI" SourceFile="$(var.PowerToysPlatform)\Release\$(var.MSIPath)\$(var.MSIName)" Compressed="yes" bal:DisplayInternalUICondition="false"> + <MsiProperty Name="BOOTSTRAPPERINSTALLFOLDER" Value="[InstallFolder]" /> + <MsiProperty Name="MSIRESTARTMANAGERCONTROL" Value="Disable" /> + <MsiProperty Name="BUNDLEINFO" Value="[BundleUpgradeCode]" /> + </MsiPackage> + </Chain> + </Bundle> +</Wix> diff --git a/installer/PowerToysSetupVNext/PowerToysBootstrapperVNext.wixproj b/installer/PowerToysSetupVNext/PowerToysBootstrapperVNext.wixproj new file mode 100644 index 0000000000..6b17b17030 --- /dev/null +++ b/installer/PowerToysSetupVNext/PowerToysBootstrapperVNext.wixproj @@ -0,0 +1,61 @@ +<Project Sdk="WixToolset.Sdk/5.0.2"> + <PropertyGroup> + <EnableDefaultCompileItems>false</EnableDefaultCompileItems> + </PropertyGroup> + <PropertyGroup> + <DefineConstants>Version=$(Version)</DefineConstants> + <Name>PowerToysVNextBootstrapper</Name> + </PropertyGroup> + <PropertyGroup Label="UserMacros" Condition=" '$(PerUser)' == 'true' "> + <DefineConstants>$(DefineConstants);PerUser=true</DefineConstants> + </PropertyGroup> + <PropertyGroup Label="UserMacros" Condition=" '$(PerUser)' != 'true' "> + <DefineConstants>$(DefineConstants);PerUser=false</DefineConstants> + </PropertyGroup> + <PropertyGroup Label="UserMacros" Condition=" '$(CIBuild)' == 'true' "> + <DefineConstants>$(DefineConstants);CIBuild=true</DefineConstants> + </PropertyGroup> + <PropertyGroup Label="UserMacros" Condition=" '$(CIBuild)' != 'true' "> + <DefineConstants>$(DefineConstants);CIBuild=false</DefineConstants> + </PropertyGroup> + <PropertyGroup> + <Configuration>Release</Configuration> + <Platform Condition="'$(Platform)'=='x64'">x64</Platform> + <Platform Condition="'$(Platform)'!='x64'">arm64</Platform> + <OutputName>PowerToysSetup-$(Version)-$(Platform)</OutputName> + <OutputType>Bundle</OutputType> + <SuppressAclReset>True</SuppressAclReset> + <OutputName Condition=" '$(PerUser)' != 'true' ">PowerToysSetup-$(Version)-$(Platform)</OutputName> + <OutputName Condition=" '$(PerUser)' == 'true' ">PowerToysUserSetup-$(Version)-$(Platform)</OutputName> + <OutputPath Condition=" '$(PerUser)' != 'true' ">$(Platform)\$(Configuration)\MachineSetup</OutputPath> + <OutputPath Condition=" '$(PerUser)' == 'true' ">$(Platform)\$(Configuration)\UserSetup</OutputPath> + <IntermediateOutputPath Condition=" '$(PerUser)' != 'true' ">$(BaseIntermediateOutputPath)$(Platform)\$(Configuration)\MachineSetup</IntermediateOutputPath> + <IntermediateOutputPath Condition=" '$(PerUser)' == 'true' ">$(BaseIntermediateOutputPath)$(Platform)\$(Configuration)\UserSetup</IntermediateOutputPath> + </PropertyGroup> + <ItemGroup> + <Compile Include="PowerToys.wxs" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="WixToolset.Util.wixext" /> + <PackageReference Include="WixToolset.UI.wixext" /> + <PackageReference Include="WixToolset.NetFx.wixext" /> + <PackageReference Include="WixToolset.Bal.wixext" /> + </ItemGroup> + <ItemGroup> + <Folder Include="CustomDialogs" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="SilentFilesInUseBA\SilentFilesInUseBAFunction.vcxproj"> + <Project>{F8B9F842-F5C3-4A2D-8C85-7F8B9E2B4F1D}</Project> + <Name>SilentFilesInUseBAFunction</Name> + <ReferenceOutputAssembly>false</ReferenceOutputAssembly> + </ProjectReference> + </ItemGroup> + <!-- Prevents NU1503 --> + <Target Name="_IsProjectRestoreSupported" Returns="@(_ValidProjectsForRestore)"> + <ItemGroup> + <_ValidProjectsForRestore Include="$(MSBuildProjectFullPath)" /> + </ItemGroup> + </Target> + <Target Name="Restore" /> +</Project> \ No newline at end of file diff --git a/installer/PowerToysSetup/PowerToysInstaller.wixproj b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj similarity index 66% rename from installer/PowerToysSetup/PowerToysInstaller.wixproj rename to installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj index f76d15b73a..4000503edf 100644 --- a/installer/PowerToysSetup/PowerToysInstaller.wixproj +++ b/installer/PowerToysSetupVNext/PowerToysInstallerVNext.wixproj @@ -1,201 +1,178 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" InitialTargets="EnsureNuGetPackageBuildImports" - xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\src\Version.props" Condition="Exists('..\..\src\Version.props')" /> - <Import Project="..\..\src\CmdPalVersion.props" Condition="Exists('..\..\src\CmdPalVersion.props')" /> - <Import Project="..\wix.props" Condition="Exists('..\wix.props')" /> - <PropertyGroup Condition="'$(Platform)' == 'x64'"> - <DefineConstants>Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\x64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion)</DefineConstants> - <!-- THIS IS AN INNER LOOP OPTIMIZATION - The build pipeline builds the Settings and Launcher projects for Publication - using a specific profile. If you're doing local installer builds, this will - simulate the build pipeline doing that for you. --> - <PreBuildEvent>IF NOT DEFINED IsPipeline ( -call "$([MSBuild]::GetVsInstallRoot())\Common7\Tools\VsDevCmd.bat" -arch=amd64 -host_arch=amd64 -winsdk=10.0.19041.0 -vcvars_ver=$(VCToolsVersion) -SET PTRoot=$(SolutionDir)\.. -call "..\..\..\publish.cmd" x64 -) -call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" - </PreBuildEvent> - </PropertyGroup> - <PropertyGroup Condition="'$(Platform)' != 'x64'"> - <DefineConstants>Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\ARM64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion);</DefineConstants> - <PreBuildEvent>IF NOT DEFINED IsPipeline ( -call "$([MSBuild]::GetVsInstallRoot())\Common7\Tools\VsDevCmd.bat" -arch=arm64 -host_arch=amd64 -winsdk=10.0.19041.0 -vcvars_ver=$(VCToolsVersion) -SET PTRoot=$(SolutionDir)\.. -call "..\..\..\publish.cmd" arm64 -) -call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" - </PreBuildEvent> - </PropertyGroup> - <PropertyGroup> - <RunPostBuildEvent>Always</RunPostBuildEvent> - <PostBuildEvent> - call move /Y ..\..\..\AdvancedPaste.wxs.bk ..\..\..\AdvancedPaste.wxs - call move /Y ..\..\..\Awake.wxs.bk ..\..\..\Awake.wxs - call move /Y ..\..\..\BaseApplications.wxs.bk ..\..\..\BaseApplications.wxs - call move /Y ..\..\..\CmdPal.wxs.bk ..\..\..\CmdPal.wxs - call move /Y ..\..\..\ColorPicker.wxs.bk ..\..\..\ColorPicker.wxs - call move /Y ..\..\..\Core.wxs.bk ..\..\..\Core.wxs - call move /Y ..\..\..\EnvironmentVariables.wxs.bk ..\..\..\EnvironmentVariables.wxs - call move /Y ..\..\..\FileExplorerPreview.wxs.bk ..\..\..\FileExplorerPreview.wxs - call move /Y ..\..\..\FileLocksmith.wxs.bk ..\..\..\FileLocksmith.wxs - call move /Y ..\..\..\Hosts.wxs.bk ..\..\..\Hosts.wxs - call move /Y ..\..\..\ImageResizer.wxs.bk ..\..\..\ImageResizer.wxs - call move /Y ..\..\..\KeyboardManager.wxs.bk ..\..\..\KeyboardManager.wxs - call move /Y ..\..\..\MouseWithoutBorders.wxs.bk ..\..\..\MouseWithoutBorders.wxs - 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 ..\..\..\Product.wxs.bk ..\..\..\Product.wxs - call move /Y ..\..\..\RegistryPreview.wxs.bk ..\..\..\RegistryPreview.wxs - call move /Y ..\..\..\Resources.wxs.bk ..\..\..\Resources.wxs - call move /Y ..\..\..\Run.wxs.bk ..\..\..\Run.wxs - call move /Y ..\..\..\Settings.wxs.bk ..\..\..\Settings.wxs - call move /Y ..\..\..\ShortcutGuide.wxs.bk ..\..\..\ShortcutGuide.wxs - call move /Y ..\..\..\Tools.wxs.bk ..\..\..\Tools.wxs - call move /Y ..\..\..\WinAppSDK.wxs.bk ..\..\..\WinAppSDK.wxs - call move /Y ..\..\..\WinUI3Applications.wxs.bk ..\..\..\WinUI3Applications.wxs - call move /Y ..\..\..\Workspaces.wxs.bk ..\..\..\Workspaces.wxs - </PostBuildEvent> - </PropertyGroup> - <PropertyGroup> - <Name>PowerToysInstaller</Name> - </PropertyGroup> - <PropertyGroup Label="UserMacros" Condition=" '$(PerUser)' == 'true' "> - <DefineConstants>$(DefineConstants);PerUser=true</DefineConstants> - </PropertyGroup> - <PropertyGroup Label="UserMacros" Condition=" '$(PerUser)' != 'true' "> - <DefineConstants>$(DefineConstants);PerUser=false</DefineConstants> - </PropertyGroup> - <PropertyGroup Label="UserMacros" Condition=" '$(CIBuild)' == 'true' "> - <DefineConstants>$(DefineConstants);CIBuild=true</DefineConstants> - </PropertyGroup> - <PropertyGroup Label="UserMacros" Condition=" '$(CIBuild)' != 'true' "> - <DefineConstants>$(DefineConstants);CIBuild=false</DefineConstants> - </PropertyGroup> - <PropertyGroup> - <!-- We do not support debug installer builds --> - <Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration> - <Platform>$(Platform)</Platform> - <ProductVersion>3.10</ProductVersion> - <ProjectGuid>022a9d30-7c4f-416d-a9df-5ff2661cc0ad</ProjectGuid> - <SchemaVersion>2.0</SchemaVersion> - <OutputName Condition=" '$(PerUser)' != 'true' ">PowerToysSetup-$(Version)-$(Platform)</OutputName> - <OutputName Condition=" '$(PerUser)' == 'true' ">PowerToysUserSetup-$(Version)-$(Platform)</OutputName> - <OutputType>Package</OutputType> - <SuppressAclReset>True</SuppressAclReset> - <NuGetPackageImportStamp> - </NuGetPackageImportStamp> - <!-- 1076 and ICE91 - warning: using this configuration for perMachine install could cause problems. --> - <!-- 1026 - warning: file ID is too long --> - <SuppressIces>ICE91</SuppressIces> - <SuppressSpecificWarnings>1026;1076</SuppressSpecificWarnings> - </PropertyGroup> - <PropertyGroup> - <OutputPath Condition=" '$(PerUser)' != 'true' ">$(Platform)\$(Configuration)\MachineSetup</OutputPath> - <OutputPath Condition=" '$(PerUser)' == 'true' ">$(Platform)\$(Configuration)\UserSetup</OutputPath> - <IntermediateOutputPath Condition=" '$(PerUser)' != 'true' ">obj\$(Platform)\$(Configuration)\MachineSetup</IntermediateOutputPath> - <IntermediateOutputPath Condition=" '$(PerUser)' == 'true' ">obj\$(Platform)\$(Configuration)\UserSetup</IntermediateOutputPath> - <SuppressIces>ICE40</SuppressIces> - </PropertyGroup> - <PropertyGroup> - <!-- suppress warning 1108 regarding -sh being deprecated --> - <!-- -sh suppresses file information which was causing wix build to hang in CI --> - <LinkerAdditionalOptions>-v -sh -sw1108</LinkerAdditionalOptions> - </PropertyGroup> - <ItemGroup> - <Compile Include="CustomDialogs\PTInstallDirDlg.wxs" /> - <Compile Include="CustomDialogs\PTLicenseDlg.wxs" /> - <Compile Include="CustomDialogs\WixUI_PTInstallDir.wxs" /> - <Compile Include="NewPlus.wxs" /> - <Compile Include="Product.wxs" /> - <Compile Include="AdvancedPaste.wxs" /> - <Compile Include="Awake.wxs" /> - <Compile Include="BaseApplications.wxs" /> - <Compile Include="CmdPal.wxs" /> - <Compile Include="ColorPicker.wxs" /> - <Compile Include="EnvironmentVariables.wxs" /> - <Compile Include="FileExplorerPreview.wxs" /> - <Compile Include="FileLocksmith.wxs" /> - <Compile Include="Hosts.wxs" /> - <Compile Include="ImageResizer.wxs" /> - <Compile Include="KeyboardManager.wxs" /> - <Compile Include="Peek.wxs" /> - <Compile Include="PowerRename.wxs" /> - <Compile Include="RegistryPreview.wxs" /> - <Compile Include="Run.wxs" /> - <Compile Include="Settings.wxs" /> - <Compile Include="ShortcutGuide.wxs" /> - <Compile Include="Tools.wxs" /> - <Compile Include="MouseWithoutBorders.wxs" /> - <Compile Include="WinUI3Applications.wxs" /> - <Compile Include="MonacoSRC.wxs" /> - <Compile Include="Core.wxs" /> - <Compile Include="Resources.wxs" /> - <Compile Include="WinAppSDK.wxs" /> - <Compile Include="Workspaces.wxs" /> - </ItemGroup> - <ItemGroup> - <WixExtension Include="WixFirewallExtension"> - <HintPath>$(WixExtDir)\WixFirewallExtension.dll</HintPath> - <Name>WixFirewallExtension</Name> - </WixExtension> - <WixExtension Include="WixUtilExtension"> - <HintPath>$(WixExtDir)\WixUtilExtension.dll</HintPath> - <Name>WixUtilExtension</Name> - </WixExtension> - <WixExtension Include="WixUIExtension"> - <HintPath>$(WixExtDir)\WixUIExtension.dll</HintPath> - <Name>WixUIExtension</Name> - </WixExtension> - <WixExtension Include="WixNetFxExtension"> - <HintPath>$(WixExtDir)\WixNetFxExtension.dll</HintPath> - <Name>WixNetFxExtension</Name> - </WixExtension> - </ItemGroup> - <ItemGroup> - <Folder Include="CustomDialogs" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\PowerToysSetupCustomActions\PowerToysSetupCustomActions.vcxproj"> - <Name>PowerToysSetupCustomActions</Name> - <Project>{32f3882b-f2d6-4586-b5ed-11e39e522bd3}</Project> - <Private>True</Private> - <DoNotHarvest>True</DoNotHarvest> - <RefProjectOutputGroups>Binaries;Content;Satellites</RefProjectOutputGroups> - <RefTargetDir>INSTALLFOLDER</RefTargetDir> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <Content Include="packages.config" /> - </ItemGroup> - <Import Project="$(WixTargetsPath)" Condition=" '$(WixTargetsPath)' != '' " /> - <Import Project="$(MSBuildExtensionsPath32)\Microsoft\WiX\v3.x\Wix.targets" Condition=" '$(WixTargetsPath)' == '' AND Exists('$(MSBuildExtensionsPath32)\Microsoft\WiX\v3.x\Wix.targets') " /> - <Target Name="EnsureWixToolsetInstalled" Condition=" '$(WixTargetsImported)' != 'true' "> - <Error Text="The WiX Toolset v3 build tools must be installed to build this project. To download the WiX Toolset, see http://wixtoolset.org/releases/" /> - </Target> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>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}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('..\wix.props')" Text="$([System.String]::Format('$(ErrorText)', '..\wix.props'))" /> - </Target> - <!-- - To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Wix.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> --> - <Target Name="BeforeBuild"> - <HeatDirectory Directory="..\..\src\Monaco\monacoSRC" PreprocessorVariable="var.MonacoSRCHarvestPath" OutputFile="MonacoSRC.wxs" ComponentGroupName="MonacoSRCHeatGenerated" DirectoryRefId="MonacoPreviewHandlerMonacoSRCFolder" AutogenerateGuids="false" GenerateGuidsNow="true" ToolPath="$(WixToolPath)" RunAsSeparateProcess="true" SuppressFragments="false" SuppressRegistry="false" SuppressRootDirectory="true" /> - </Target> - <!-- Prevents NU1503 --> - <Target Name="_IsProjectRestoreSupported" Returns="@(_ValidProjectsForRestore)"> - <ItemGroup> - <_ValidProjectsForRestore Include="$(MSBuildProjectFullPath)" /> - </ItemGroup> - </Target> - <Target Name="Restore" /> -</Project> +<Project Sdk="WixToolset.Sdk/5.0.2"> + <Import Project="..\..\src\CmdPalVersion.props" Condition="Exists('..\..\src\CmdPalVersion.props')" /> + <PropertyGroup> + <EnableDefaultCompileItems>false</EnableDefaultCompileItems> + </PropertyGroup> + <PropertyGroup Condition="'$(Platform)' == 'x64'"> + <DefineConstants>Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\x64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion)</DefineConstants> <!-- THIS IS AN INNER LOOP OPTIMIZATION + The build pipeline builds the Settings and Launcher projects for Publication + using a specific profile. If you're doing local installer builds, this will + simulate the build pipeline doing that for you. --> + <PreBuildEvent>IF NOT DEFINED IsPipeline ( +call "$([MSBuild]::GetVsInstallRoot())\Common7\Tools\VsDevCmd.bat" -arch=amd64 -host_arch=amd64 -winsdk=10.0.19041.0 -vcvars_ver=$(VCToolsVersion) +SET PTRoot=$(SolutionDir)\.. +call "..\..\..\publish.cmd" x64 +) +call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" -Platform "$(Platform)" -nugetHeatPath "$(NUGET_PACKAGES)\wixtoolset.heat\5.0.2" + </PreBuildEvent> + </PropertyGroup> + <PropertyGroup Condition="'$(Platform)' != 'x64'"> + <DefineConstants>Version=$(Version);MonacoSRCHarvestPath=$(ProjectDir)..\..\ARM64\$(Configuration)\Assets\Monaco\monacoSRC;CmdPalVersion=$(CmdPalVersion)</DefineConstants> + <PreBuildEvent>IF NOT DEFINED IsPipeline ( +call "$([MSBuild]::GetVsInstallRoot())\Common7\Tools\VsDevCmd.bat" -arch=arm64 -host_arch=amd64 -winsdk=10.0.19041.0 -vcvars_ver=$(VCToolsVersion) +SET PTRoot=$(SolutionDir)\.. +call "..\..\..\publish.cmd" arm64 +) +call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuildThisFileDirectory)\generateMonacoWxs.ps1 -monacoWxsFile "$(MSBuildThisFileDirectory)\MonacoSRC.wxs" -Platform "$(Platform)" -nugetHeatPath "$(NUGET_PACKAGES)\wixtoolset.heat\5.0.2" + </PreBuildEvent> + </PropertyGroup> + <PropertyGroup> + <RunPostBuildEvent>Always</RunPostBuildEvent> + <PostBuildEvent> + call move /Y ..\..\..\AdvancedPaste.wxs.bk ..\..\..\AdvancedPaste.wxs + call move /Y ..\..\..\Awake.wxs.bk ..\..\..\Awake.wxs + call move /Y ..\..\..\BaseApplications.wxs.bk ..\..\..\BaseApplications.wxs + call move /Y ..\..\..\CmdPal.wxs.bk ..\..\..\CmdPal.wxs + call move /Y ..\..\..\ColorPicker.wxs.bk ..\..\..\ColorPicker.wxs + call move /Y ..\..\..\Core.wxs.bk ..\..\..\Core.wxs + call move /Y ..\..\..\DscResources.wxs.bk ..\..\..\DscResources.wxs + call move /Y ..\..\..\EnvironmentVariables.wxs.bk ..\..\..\EnvironmentVariables.wxs + call move /Y ..\..\..\FileExplorerPreview.wxs.bk ..\..\..\FileExplorerPreview.wxs + call move /Y ..\..\..\FileLocksmith.wxs.bk ..\..\..\FileLocksmith.wxs + call move /Y ..\..\..\Hosts.wxs.bk ..\..\..\Hosts.wxs + call move /Y ..\..\..\LightSwitch.wxs.bk ..\..\..\LightSwitch.wxs + call move /Y ..\..\..\ImageResizer.wxs.bk ..\..\..\ImageResizer.wxs + call move /Y ..\..\..\KeyboardManager.wxs.bk ..\..\..\KeyboardManager.wxs + call move /Y ..\..\..\MouseWithoutBorders.wxs.bk ..\..\..\MouseWithoutBorders.wxs + 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 + call move /Y ..\..\..\Run.wxs.bk ..\..\..\Run.wxs + call move /Y ..\..\..\Settings.wxs.bk ..\..\..\Settings.wxs + call move /Y ..\..\..\ShortcutGuide.wxs.bk ..\..\..\ShortcutGuide.wxs + call move /Y ..\..\..\Tools.wxs.bk ..\..\..\Tools.wxs + call move /Y ..\..\..\WinAppSDK.wxs.bk ..\..\..\WinAppSDK.wxs + call move /Y ..\..\..\WinUI3Applications.wxs.bk ..\..\..\WinUI3Applications.wxs + call move /Y ..\..\..\Workspaces.wxs.bk ..\..\..\Workspaces.wxs + </PostBuildEvent> + </PropertyGroup> + <PropertyGroup Condition="'$(RunBuildEvents)'=='false'"> + <PostBuildEvent></PostBuildEvent> + <RunPostBuildEvent></RunPostBuildEvent> + <PreBuildEventUseInBuild>false</PreBuildEventUseInBuild> + <PostBuildEventUseInBuild>false</PostBuildEventUseInBuild> + </PropertyGroup> + <PropertyGroup Label="UserMacros" Condition=" '$(PerUser)' == 'true' "> + <DefineConstants>$(DefineConstants);PerUser=true</DefineConstants> + </PropertyGroup> + <PropertyGroup Label="UserMacros" Condition=" '$(PerUser)' != 'true' "> + <DefineConstants>$(DefineConstants);PerUser=false</DefineConstants> + </PropertyGroup> + <PropertyGroup Label="UserMacros" Condition=" '$(CIBuild)' == 'true' "> + <DefineConstants>$(DefineConstants);CIBuild=true</DefineConstants> + </PropertyGroup> + <PropertyGroup Label="UserMacros" Condition=" '$(CIBuild)' != 'true' "> + <DefineConstants>$(DefineConstants);CIBuild=false</DefineConstants> + </PropertyGroup> + <PropertyGroup> + <Name>PowerToysVNextInstaller</Name> + <!-- We do not support debug installer builds --> + <Configuration Condition=" '$(Configuration)' == '' ">Release</Configuration> + <Platform>$(Platform)</Platform> + <ProductVersion>3.10</ProductVersion> + <ProjectGuid>{b6e94700-df38-41f6-a3fd-18b69674ab1e}</ProjectGuid> + <SchemaVersion>2.0</SchemaVersion> + <OutputName Condition=" '$(PerUser)' != 'true' ">PowerToysSetup-$(Version)-$(Platform)</OutputName> + <OutputName Condition=" '$(PerUser)' == 'true' ">PowerToysUserSetup-$(Version)-$(Platform)</OutputName> + <OutputType>Package</OutputType> + <SuppressAclReset>True</SuppressAclReset> + <NuGetPackageImportStamp> + </NuGetPackageImportStamp> + <!-- 1076 and ICE91 - warning: using this configuration for perMachine install could cause problems. --> + <!-- 1026 - warning: file ID is too long --> + <SuppressIces>ICE91</SuppressIces> + <SuppressSpecificWarnings>1026;1076</SuppressSpecificWarnings> + </PropertyGroup> + <PropertyGroup> + <OutputPath Condition=" '$(PerUser)' != 'true' ">$(Platform)\$(Configuration)\MachineSetup</OutputPath> + <OutputPath Condition=" '$(PerUser)' == 'true' ">$(Platform)\$(Configuration)\UserSetup</OutputPath> + <IntermediateOutputPath Condition=" '$(PerUser)' != 'true' ">$(BaseIntermediateOutputPath)$(Platform)\$(Configuration)\MachineSetup</IntermediateOutputPath> + <IntermediateOutputPath Condition=" '$(PerUser)' == 'true' ">$(BaseIntermediateOutputPath)$(Platform)\$(Configuration)\UserSetup</IntermediateOutputPath> + <SuppressIces>ICE40</SuppressIces> + </PropertyGroup> + <ItemGroup> + <Compile Include="CustomDialogs\PTInstallDirDlg.wxs" /> + <Compile Include="CustomDialogs\PTLicenseDlg.wxs" /> + <Compile Include="CustomDialogs\WixUI_PTInstallDir.wxs" /> + <Compile Include="NewPlus.wxs" /> + <Compile Include="Product.wxs" /> + <Compile Include="AdvancedPaste.wxs" /> + <Compile Include="Awake.wxs" /> + <Compile Include="BaseApplications.wxs" /> + <Compile Include="CmdPal.wxs" /> + <Compile Include="ColorPicker.wxs" /> + <Compile Include="EnvironmentVariables.wxs" /> + <Compile Include="FileExplorerPreview.wxs" /> + <Compile Include="FileLocksmith.wxs" /> + <Compile Include="Hosts.wxs" /> + <Compile Include="ImageResizer.wxs" /> + <Compile Include="LightSwitch.wxs" /> + <Compile Include="KeyboardManager.wxs" /> + <Compile Include="Peek.wxs" /> + <Compile Include="PowerRename.wxs" /> + <Compile Include="PowerDisplay.wxs" /> + <Compile Include="DscResources.wxs" /> + <Compile Include="RegistryPreview.wxs" /> + <Compile Include="Run.wxs" /> + <Compile Include="Settings.wxs" /> + <Compile Include="ShortcutGuide.wxs" /> + <Compile Include="Tools.wxs" /> + <Compile Include="MouseWithoutBorders.wxs" /> + <Compile Include="WinUI3Applications.wxs" /> + <Compile Include="MonacoSRC.wxs" /> + <Compile Include="Core.wxs" /> + <Compile Include="Resources.wxs" /> + <Compile Include="WinAppSDK.wxs" /> + <Compile Include="Workspaces.wxs" /> + </ItemGroup> + <ItemGroup> + <Folder Include="CustomDialogs" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="WixToolset.Heat" /> + <PackageReference Include="WixToolset.Firewall.wixext" /> + <PackageReference Include="WixToolset.Util.wixext" /> + <PackageReference Include="WixToolset.UI.wixext" /> + <PackageReference Include="WixToolset.NetFx.wixext" /> + <PackageReference Include="WixToolset.Bal.wixext" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\PowerToysSetupCustomActionsVNext\PowerToysSetupCustomActionsVNext.vcxproj"> + <Name>PowerToysSetupCustomActionsVNext</Name> + <Project>{B3A354B0-1E54-4B55-A962-FB5AF9330C19}</Project> + <Private>True</Private> + <DoNotHarvest>True</DoNotHarvest> + <RefProjectOutputGroups>Binaries;Content;Satellites</RefProjectOutputGroups> + <RefTargetDir>INSTALLFOLDER</RefTargetDir> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <Content Include="packages.config" /> + </ItemGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + </Target> + <!-- Prevents NU1503 --> + <Target Name="_IsProjectRestoreSupported" Returns="@(_ValidProjectsForRestore)"> + <ItemGroup> + <_ValidProjectsForRestore Include="$(MSBuildProjectFullPath)" /> + </ItemGroup> + </Target> + <Target Name="Restore" /> +</Project> diff --git a/installer/PowerToysSetupVNext/Product.wxs b/installer/PowerToysSetupVNext/Product.wxs new file mode 100644 index 0000000000..1a5f8010f7 --- /dev/null +++ b/installer/PowerToysSetupVNext/Product.wxs @@ -0,0 +1,298 @@ +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util" xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"> + + <?include $(sys.CURRENTDIR)\Common.wxi?> + + <!-- WiX Components with multiple files cause issues due to the way Windows installs them. + Windows decides whether to install a component by checking the existence of KeyPath file and its version. + Thus, if some files were updated but KeyPath file was not, the component wouldn't be updated. + Some resource files, e.g. images, do not have version, so even if Component has only a single image and a static GUID, it won't be updated. + + Considering all of the above, it's much simpler to just have one file per Component with an implicit Guid. + + More info: + - https://stackoverflow.com/a/1604348/657390 + - https://stackoverflow.com/a/1422121/657390 + - https://robmensching.com/blog/posts/2003/10/18/component-rules-101/ + - https://robmensching.com/blog/posts/2003/10/4/windows-installer-components-introduction/ + --> + + <Package Name="PowerToys (Preview)" Language="1033" Version="$(var.Version)" Manufacturer="Microsoft Corporation" UpgradeCode="$(var.UpgradeCodeGUID)" Scope="$(var.InstallScope)"> + + + + + <MajorUpgrade DowngradeErrorMessage="A later version of [ProductName] is already installed." /> + + <Upgrade Id="$(var.UpgradeCodeGUID)"> + <UpgradeVersion Minimum="0.0.0" Maximum="$(var.Version)" Property="PREVIOUSVERSIONSINSTALLED" IncludeMinimum="yes" IncludeMaximum="no" /> + </Upgrade> + + <MediaTemplate EmbedCab="yes" /> + + <Property Id="REINSTALLMODE" Value="amus" /> + <Property Id="WINDOWSBUILDNUMBER" Secure="yes"> + <RegistrySearch Id="BuildNumberSearch" Root="HKLM" Key="SOFTWARE\Microsoft\Windows NT\CurrentVersion" Name="CurrentBuildNumber" Type="raw" /> + </Property> + <Launch Condition="(WINDOWSBUILDNUMBER >= 19041)" Message="This application is only supported on Windows 10 version v2004 (build 19041) or higher." /> + + <Icon Id="powertoys.exe" SourceFile="$(var.BinDir)svgs\icon.ico" /> + + <Property Id="ARPPRODUCTICON" Value="powertoys.exe" /> + + <Feature Id="CoreFeature" Title="PowerToys" AllowAdvertise="no" TypicalDefault="install" Description="Contains all PowerToys features." AllowAbsent="no"> + <ComponentGroupRef Id="CoreComponents" /> + <ComponentGroupRef Id="BaseApplicationsComponentGroup" /> + <ComponentGroupRef Id="WinUI3ApplicationsComponentGroup" /> + <ComponentGroupRef Id="AwakeComponentGroup" /> + <ComponentGroupRef Id="ColorPickerComponentGroup" /> + <ComponentGroupRef Id="FileExplorerPreviewComponentGroup" /> + <ComponentGroupRef Id="FileLocksmithComponentGroup" /> + <ComponentGroupRef Id="HostsComponentGroup" /> + <ComponentGroupRef Id="ImageResizerComponentGroup" /> + <ComponentGroupRef Id="KeyboardManagerComponentGroup" /> + <ComponentGroupRef Id="LightSwitchComponentGroup" /> + <ComponentGroupRef Id="PeekComponentGroup" /> + <ComponentGroupRef Id="PowerRenameComponentGroup" /> + <ComponentGroupRef Id="PowerDisplayComponentGroup" /> + <ComponentGroupRef Id="RegistryPreviewComponentGroup" /> + <ComponentGroupRef Id="RunComponentGroup" /> + <ComponentGroupRef Id="SettingsComponentGroup" /> + <ComponentGroupRef Id="ShortcutGuideComponentGroup" /> + <ComponentGroupRef Id="MouseWithoutBordersComponentGroup" /> + <ComponentGroupRef Id="EnvironmentVariablesComponentGroup" /> + <ComponentGroupRef Id="AdvancedPasteComponentGroup" /> + <ComponentGroupRef Id="NewPlusComponentGroup" /> + <ComponentGroupRef Id="NewPlusTemplatesComponentGroup" /> + <ComponentGroupRef Id="ResourcesComponentGroup" /> + <ComponentGroupRef Id="DscResourcesComponentGroup" /> + <ComponentGroupRef Id="WindowsAppSDKComponentGroup" /> + <ComponentGroupRef Id="ToolComponentGroup" /> + <ComponentGroupRef Id="MonacoSRCHeatGenerated" /> + <ComponentGroupRef Id="WorkspacesComponentGroup" /> + <ComponentGroupRef Id="CmdPalComponentGroup" /> + </Feature> + + <SetProperty Id="ARPINSTALLLOCATION" Value="[INSTALLFOLDER]" After="CostFinalize" Sequence="execute" /> + + <Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" /> + + <UI> + <ui:WixUI Id="WixUI_InstallDir" /> + <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="99" /> + <Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="99" /> + + <Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Condition="NOT Installed" /> + <Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Property="_REMOVE_ALL" Value="Yes" /> + <Publish Dialog="UserExit" Control="Finish" Event="DoAction" Value="TelemetryLogInstallCancel" Condition="NOT Installed" /> + <Publish Dialog="FatalError" Control="Finish" Event="DoAction" Value="TelemetryLogInstallFail" Condition="NOT Installed" /> + <Publish Dialog="UserExit" Control="Finish" Event="DoAction" Value="TelemetryLogUninstallCancel" Condition="Installed AND _REMOVE_ALL="Yes"" /> + <Publish Dialog="FatalError" Control="Finish" Event="DoAction" Value="TelemetryLogUninstallFail" Condition="Installed AND _REMOVE_ALL="Yes"" /> + <Publish Dialog="UserExit" Control="Finish" Event="DoAction" Value="TelemetryLogRepairCancel" Condition="Installed AND NOT (_REMOVE_ALL="Yes")" /> + <Publish Dialog="FatalError" Control="Finish" Event="DoAction" Value="TelemetryLogRepairFail" Condition="Installed AND NOT (_REMOVE_ALL="Yes")" /> + </UI> + + <WixVariable Id="WixUIBannerBmp" Value="$(var.ProjectDir)\Images\banner.png" /> + <WixVariable Id="WixUIDialogBmp" Value="$(var.ProjectDir)\Images\dialog.png" /> + <WixVariable Id="WixUILicenseRtf" Value="$(var.RepoDir)\installer\License.rtf" /> + <Property Id="INSTALLSTARTMENUSHORTCUT" Value="1" /> + <Property Id="WixShellExecTarget" Value="[#PowerToys_ActionRunner.exe]" /> + + <SetProperty Action="SetDEFAULTBOOTSTRAPPERINSTALLFOLDER" Id="DEFAULTBOOTSTRAPPERINSTALLFOLDER" Value="[$(var.DefaultInstallDir)]PowerToys" Before="SetBOOTSTRAPPERINSTALLFOLDER" Sequence="execute"></SetProperty> + + <!-- In case we didn't receive a value from the bootstrapper. --> + <SetProperty Action="SetBOOTSTRAPPERINSTALLFOLDER" Id="BOOTSTRAPPERINSTALLFOLDER" Value="[DEFAULTBOOTSTRAPPERINSTALLFOLDER]" Before="DetectPrevInstallPath" Sequence="execute" Condition="BOOTSTRAPPERINSTALLFOLDER = """ /> + <!-- Have to compare value sent by bootstrapper to default to avoid using it, as a check to verify it's not default. This hack can be removed if it's possible to set the bootstrapper option to the previous install folder--> + <SetProperty Action="SetINSTALLFOLDERTOPREVIOUSINSTALLFOLDER" Id="INSTALLFOLDER" Value="[PREVIOUSINSTALLFOLDER]" After="DetectPrevInstallPath" Sequence="execute" Condition="BOOTSTRAPPERINSTALLFOLDER = DEFAULTBOOTSTRAPPERINSTALLFOLDER AND PREVIOUSINSTALLFOLDER <> """ /> + <SetProperty Action="SetINSTALLFOLDERTOBOOTSTRAPPERINSTALLFOLDER" Id="INSTALLFOLDER" Value="[BOOTSTRAPPERINSTALLFOLDER]" After="DetectPrevInstallPath" Sequence="execute" Condition="BOOTSTRAPPERINSTALLFOLDER <> DEFAULTBOOTSTRAPPERINSTALLFOLDER OR PREVIOUSINSTALLFOLDER = """ /> + + <SetProperty Id="InstallScope" Value="$(var.InstallScope)" Before="DetectPrevInstallPath" Sequence="execute"></SetProperty> + <InstallExecuteSequence> + <Custom Action="DetectPrevInstallPath" After="AppSearch" /> + <Custom Action="SetLaunchPowerToysParam" Before="LaunchPowerToys" /> + <Custom Action="SetInstallCmdPalPackageParam" Before="InstallCmdPalPackage" /> + <Custom Action="SetUninstallCommandNotFoundParam" Before="UninstallCommandNotFound" /> + <Custom Action="SetUpgradeCommandNotFoundParam" Before="UpgradeCommandNotFound" /> + <Custom Action="SetApplyModulesRegistryChangeSetsParam" Before="ApplyModulesRegistryChangeSets" /> + <Custom Action="SetInstallPackageIdentityMSIXParam" Before="InstallPackageIdentityMSIX" /> + + <?if $(var.PerUser) = "true" ?> + <Custom Action="SetInstallDSCModuleParam" Before="InstallDSCModule" /> + <?endif?> + + <Custom Action="SetUnApplyModulesRegistryChangeSetsParam" Before="UnApplyModulesRegistryChangeSets" /> + <Custom Action="CheckGPO" After="InstallInitialize" Condition="NOT Installed" /> + <Custom Action="SetBundleInstallLocationData" Before="SetBundleInstallLocation" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" /> + <Custom Action="SetBundleInstallLocation" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" /> + <Custom Action="ApplyModulesRegistryChangeSets" After="InstallFiles" Condition="NOT Installed" /> + <Custom Action="InstallCmdPalPackage" After="InstallFiles" Condition="NOT Installed" /> + <Custom Action="InstallPackageIdentityMSIX" After="InstallFiles" Condition="NOT Installed AND WINDOWSBUILDNUMBER >= 22000" /> + <Custom Action="override Wix4CloseApplications_$(sys.BUILDARCHSHORT)" Before="RemoveFiles" /> + <Custom Action="RemovePowerToysSchTasks" After="RemoveFiles" /> + <!-- TODO: Use to activate embedded MSIX --> + <!--<Custom Action="InstallEmbeddedMSIXTask" After="InstallFinalize"> + NOT Installed + </Custom>--> + <?if $(var.PerUser) = "true" ?> + <Custom Action="InstallDSCModule" After="InstallFiles" /> + <?endif?> + <Custom Action="TelemetryLogInstallSuccess" After="InstallFinalize" Condition="NOT Installed" /> + <Custom Action="TelemetryLogUninstallSuccess" After="InstallFinalize" Condition="Installed and (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" /> + <Custom Action="UnApplyModulesRegistryChangeSets" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" /> + <Custom Action="UnRegisterContextMenuPackages" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" /> + <Custom Action="CleanImageResizerRuntimeRegistry" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" /> + <Custom Action="CleanFileLocksmithRuntimeRegistry" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" /> + <Custom Action="CleanPowerRenameRuntimeRegistry" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" /> + <Custom Action="CleanNewPlusRuntimeRegistry" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" /> + <Custom Action="UnsetAdvancedPasteAPIKey" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" /> + <Custom Action="UnRegisterCmdPalPackage" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" /> + <Custom Action="UninstallCommandNotFound" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" /> + <Custom Action="UpgradeCommandNotFound" After="InstallFiles" Condition="WIX_UPGRADE_DETECTED" /> + <Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" /> + <Custom Action="UninstallServicesTask" After="InstallFinalize" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" /> + <!-- TODO: Use to activate embedded MSIX --> + <!--<Custom Action="UninstallEmbeddedMSIXTask" After="InstallFinalize"> + Installed AND (REMOVE="ALL") + </Custom>--> + <?if $(var.PerUser) = "true" ?> + <Custom Action="UninstallDSCModule" After="InstallFinalize" Condition="Installed AND (REMOVE="ALL")" /> + <?endif?> + <Custom Action="TerminateProcesses" Before="InstallValidate" /> + <Custom Action="LaunchPowerToys" Before="InstallFinalize" Condition="NOT Installed" /> + + <!-- Clean Video Conference Mute registry keys that might be around from previous installations. We've deprecated this utility since then. --> + <Custom Action="CleanVideoConferenceRegistry" Before="InstallFinalize" Condition="NOT Installed" /> + + </InstallExecuteSequence> + + <CustomAction Id="SetLaunchPowerToysParam" Property="LaunchPowerToys" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetInstallCmdPalPackageParam" Property="InstallCmdPalPackage" Value="[INSTALLFOLDER]" /> + + <!-- Set InstallLocation for Bundle entry as well --> + <CustomAction Id="SetBundleInstallLocationData" Property="SetBundleInstallLocation" Value="[INSTALLFOLDER];[BUNDLEINFO];[InstallScope]" /> + + <CustomAction Id="LaunchPowerToys" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="LaunchPowerToysCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="TerminateProcesses" Return="ignore" Execute="immediate" DllEntry="TerminateProcessesCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="SetApplyModulesRegistryChangeSetsParam" Property="ApplyModulesRegistryChangeSets" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetUnApplyModulesRegistryChangeSetsParam" Property="UnApplyModulesRegistryChangeSets" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetInstallDSCModuleParam" Property="InstallDSCModule" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetUninstallCommandNotFoundParam" Property="UninstallCommandNotFound" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetUpgradeCommandNotFoundParam" Property="UpgradeCommandNotFound" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetCreateWinAppSDKHardlinksParam" Property="CreateWinAppSDKHardlinks" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetDeleteWinAppSDKHardlinksParam" Property="DeleteWinAppSDKHardlinks" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetCreatePTInteropHardlinksParam" Property="CreatePTInteropHardlinks" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetDeletePTInteropHardlinksParam" Property="DeletePTInteropHardlinks" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetCreateDotnetRuntimeHardlinksParam" Property="CreateDotnetRuntimeHardlinks" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="SetDeleteDotnetRuntimeHardlinksParam" Property="DeleteDotnetRuntimeHardlinks" Value="[INSTALLFOLDER]" /> + + <CustomAction Id="RemovePowerToysSchTasks" Return="ignore" Impersonate="no" Execute="deferred" DllEntry="RemoveScheduledTasksCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="InstallEmbeddedMSIXTask" Return="ignore" Impersonate="yes" DllEntry="InstallEmbeddedMSIXCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UninstallEmbeddedMSIXTask" Return="ignore" Impersonate="yes" DllEntry="UninstallEmbeddedMSIXCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="SetInstallPackageIdentityMSIXParam" Property="InstallPackageIdentityMSIX" Value="[INSTALLFOLDER];[InstallScope]" /> + + <CustomAction Id="InstallPackageIdentityMSIX" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="InstallPackageIdentityMSIXCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UninstallPackageIdentityMSIX" Return="ignore" Impersonate="yes" DllEntry="UninstallPackageIdentityMSIXCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="InstallDSCModule" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="InstallDSCModuleCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UninstallDSCModule" Return="ignore" Impersonate="yes" DllEntry="UninstallDSCModuleCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UninstallServicesTask" Return="ignore" Impersonate="yes" DllEntry="UninstallServicesCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UninstallCommandNotFound" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="UninstallCommandNotFoundModuleCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UpgradeCommandNotFound" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="UpgradeCommandNotFoundModuleCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UnsetAdvancedPasteAPIKey" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="UnsetAdvancedPasteAPIKeyCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="TelemetryLogInstallSuccess" Return="ignore" Impersonate="yes" DllEntry="TelemetryLogInstallSuccessCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="TelemetryLogInstallCancel" Return="ignore" Impersonate="yes" DllEntry="TelemetryLogInstallCancelCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="TelemetryLogInstallFail" Return="ignore" Impersonate="yes" DllEntry="TelemetryLogInstallFailCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="TelemetryLogUninstallSuccess" Return="ignore" Impersonate="yes" DllEntry="TelemetryLogUninstallSuccessCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="TelemetryLogUninstallCancel" Return="ignore" Impersonate="yes" DllEntry="TelemetryLogUninstallCancelCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="TelemetryLogUninstallFail" Return="ignore" Impersonate="yes" DllEntry="TelemetryLogUninstallFailCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="TelemetryLogRepairCancel" Return="ignore" Impersonate="yes" DllEntry="TelemetryLogRepairCancelCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="TelemetryLogRepairFail" Return="ignore" Impersonate="yes" DllEntry="TelemetryLogRepairFailCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="DetectPrevInstallPath" Return="check" Impersonate="yes" DllEntry="DetectPrevInstallPathCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="CleanVideoConferenceRegistry" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="CleanVideoConferenceRegistryCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="ApplyModulesRegistryChangeSets" Return="check" Impersonate="yes" Execute="deferred" DllEntry="ApplyModulesRegistryChangeSetsCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UnApplyModulesRegistryChangeSets" Return="check" Impersonate="yes" Execute="deferred" DllEntry="UnApplyModulesRegistryChangeSetsCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UnRegisterContextMenuPackages" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="UnRegisterContextMenuPackagesCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="CleanImageResizerRuntimeRegistry" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="CleanImageResizerRuntimeRegistryCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="CleanFileLocksmithRuntimeRegistry" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="CleanFileLocksmithRuntimeRegistryCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="CleanPowerRenameRuntimeRegistry" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="CleanPowerRenameRuntimeRegistryCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="CleanNewPlusRuntimeRegistry" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="CleanNewPlusRuntimeRegistryCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="UnRegisterCmdPalPackage" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="UnRegisterCmdPalPackageCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="CheckGPO" Return="check" Impersonate="yes" DllEntry="CheckGPOCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="InstallCmdPalPackage" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="InstallCmdPalPackageCA" BinaryRef="PTCustomActions" /> + + <CustomAction Id="SetBundleInstallLocation" Return="ignore" Impersonate="no" Execute="deferred" DllEntry="SetBundleInstallLocationCA" BinaryRef="PTCustomActions" /> + + <!-- Close 'PowerToys.exe' before uninstall--> + <Property Id="MSIRESTARTMANAGERCONTROL" Value="DisableShutdown" /> + <Property Id="MSIFASTINSTALL" Value="DisableShutdown" /> + <util:CloseApplication CloseMessage="yes" Target="PowerToys.exe" ElevatedCloseMessage="yes" RebootPrompt="no" TerminateProcess="0" /> + </Package> + + <Fragment> + <Binary Id="PTCustomActions" SourceFile="$(var.PowerToysSetupCustomActionsVNext.TargetPath)" /> + </Fragment> + + <!-- Installation directory structure --> + <Fragment> + <StandardDirectory Id="$(var.DefaultInstallDir)"> + <Directory Id="INSTALLFOLDER" Name="PowerToys"> + <Directory Id="BaseApplicationsAssetsFolder" Name="Assets"> + </Directory> + <Directory Id="DSCModulesReferenceFolder" Name="DSCModules" /> + <Directory Id="WinUI3AppsInstallFolder" Name="WinUI3Apps"> + <Directory Id="WinUI3AppsMicrosoftUIXamlInstallFolder" Name="Microsoft.UI.Xaml"> + <Directory Id="WinUI3AppsMicrosoftUIXamlAssetsInstallFolder" Name="Assets" /> + </Directory> + <Directory Id="WinUI3AppsAssetsFolder" Name="Assets"> + </Directory> + </Directory> + <Directory Id="ToolsFolder" Name="Tools" /> + </Directory> + </StandardDirectory> + <StandardDirectory Id="ProgramMenuFolder"> + <Directory Id="ApplicationProgramsFolder" Name="PowerToys (Preview)" /> + </StandardDirectory> + <StandardDirectory Id="DesktopFolder" /> + </Fragment> +</Wix> diff --git a/installer/PowerToysSetup/RegistryPreview.wxs b/installer/PowerToysSetupVNext/RegistryPreview.wxs similarity index 84% rename from installer/PowerToysSetup/RegistryPreview.wxs rename to installer/PowerToysSetupVNext/RegistryPreview.wxs index f7bd3948d4..e55cce9b1a 100644 --- a/installer/PowerToysSetup/RegistryPreview.wxs +++ b/installer/PowerToysSetupVNext/RegistryPreview.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -18,11 +16,11 @@ </DirectoryRef> <ComponentGroup Id="RegistryPreviewComponentGroup"> - <Component Id="RemoveRegistryPreviewFolder" Guid="D3DBC395-FAC5-44B1-BE44-3FE2B6E0F391" Directory="RegistryPreviewAssetsInstallFolder" > + <Component Id="RemoveRegistryPreviewFolder" Guid="D3DBC395-FAC5-44B1-BE44-3FE2B6E0F391" Directory="RegistryPreviewAssetsInstallFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveRegistryPreviewFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveRegistryPreviewFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderRegistryPreviewAssetsFolder" Directory="RegistryPreviewAssetsInstallFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderRegistryPreviewAssetsFolder" Directory="RegistryPreviewAssetsInstallFolder" On="uninstall" /> </Component> </ComponentGroup> diff --git a/installer/PowerToysSetup/Resources.wxs b/installer/PowerToysSetupVNext/Resources.wxs similarity index 70% rename from installer/PowerToysSetup/Resources.wxs rename to installer/PowerToysSetupVNext/Resources.wxs index 0f4e10f4a6..a392004320 100644 --- a/installer/PowerToysSetup/Resources.wxs +++ b/installer/PowerToysSetupVNext/Resources.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -11,7 +9,7 @@ <Fragment> <!-- Resource directories should be added only if the installer is built on the build farm --> <?ifdef env.IsPipeline?> - <?foreach ParentDirectory in INSTALLFOLDER;HistoryPluginFolder;CalculatorPluginFolder;FolderPluginFolder;ProgramPluginFolder;ShellPluginFolder;IndexerPluginFolder;UnitConverterPluginFolder;ValueGeneratorPluginFolder;UriPluginFolder;WindowWalkerPluginFolder;OneNotePluginFolder;RegistryPluginFolder;VSCodeWorkspacesPluginFolder;ServicePluginFolder;SystemPluginFolder;TimeDatePluginFolder;WindowsSettingsPluginFolder;WindowsTerminalPluginFolder;WebSearchPluginFolder;PowerToysPluginFolder?> + <?foreach ParentDirectory in INSTALLFOLDER;WinUI3AppsInstallFolder;HistoryPluginFolder;CalculatorPluginFolder;FolderPluginFolder;ProgramPluginFolder;ShellPluginFolder;IndexerPluginFolder;UnitConverterPluginFolder;ValueGeneratorPluginFolder;UriPluginFolder;WindowWalkerPluginFolder;OneNotePluginFolder;RegistryPluginFolder;VSCodeWorkspacesPluginFolder;ServicePluginFolder;SystemPluginFolder;TimeDatePluginFolder;WindowsSettingsPluginFolder;WindowsTerminalPluginFolder;WebSearchPluginFolder;PowerToysPluginFolder?> <DirectoryRef Id="$(var.ParentDirectory)"> <!-- Resource file directories --> <?foreach Language in $(var.LocLanguageList)?> @@ -161,312 +159,229 @@ <?define IdSafeLanguage = $(var.Language)?> <?define CompGUIDPrefix = 94D9A417-56FC-435D-8167-A45F5D7A75?> <?endif?> - <Component - Id="Launcher_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)00"> + <Component Id="Launcher_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)00"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.PowerLauncher.resources.dll" /> </Component> - <Component - Id="FancyZonesEditor_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)01"> + <Component Id="FancyZonesEditor_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)01"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="FancyZonesEditor_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="FancyZonesEditor_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="FancyZonesEditor_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.FancyZonesEditor.resources.dll" /> </Component> - <Component - Id="ImageResizer_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)02"> + <Component Id="ImageResizer_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)WinUI3AppsInstallFolder" Guid="$(var.CompGUIDPrefix)02"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="ImageResizer_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="ImageResizer_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> - <File Id="ImageResizer_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.ImageResizer.resources.dll" /> + <File Id="ImageResizer_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\WinUI3Apps\$(var.Language)\PowerToys.ImageResizer.resources.dll" /> </Component> - <Component - Id="ColorPicker_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)03"> + <Component Id="ColorPicker_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)03"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="ColorPicker_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="ColorPicker_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="ColorPicker_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.ColorPickerUI.resources.dll" /> </Component> - <Component - Id="MarkdownPreviewHandler_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)04"> + <Component Id="MarkdownPreviewHandler_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)04"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="MarkdownPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="MarkdownPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="MarkdownPreviewHandler_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)$(var.Language)\PowerToys.MarkdownPreviewHandler.resources.dll" /> </Component> - <Component - Id="SVGPreviewHandler_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)05"> + <Component Id="SVGPreviewHandler_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)05"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="SVGPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="SVGPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="SVGPreviewHandler_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.SvgPreviewHandler.resources.dll" /> </Component> - <Component - Id="PDFPreviewHandler_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)06"> + <Component Id="PDFPreviewHandler_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)06"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="PDFPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="PDFPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="PDFPreviewHandler_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.PdfPreviewHandler.resources.dll" /> </Component> - <Component - Id="GcodePreviewHandler_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)07"> + <Component Id="GcodePreviewHandler_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)07"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="GcodePreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="GcodePreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="GcodePreviewHandler_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.GcodePreviewHandler.resources.dll" /> </Component> <!-- PowerToys Run aka Launcher plugin resources --> - <Component - Id="Launcher_Calculator_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)CalculatorPluginFolder" - Guid="$(var.CompGUIDPrefix)08"> + <Component Id="Launcher_Calculator_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)CalculatorPluginFolder" Guid="$(var.CompGUIDPrefix)08"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_Calculator_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_Calculator_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_Calculator_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\Calculator\$(var.Language)\Microsoft.PowerToys.Run.Plugin.Calculator.resources.dll" /> </Component> - <Component - Id="Launcher_Folder_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)FolderPluginFolder" - Guid="$(var.CompGUIDPrefix)09"> + <Component Id="Launcher_Folder_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)FolderPluginFolder" Guid="$(var.CompGUIDPrefix)09"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_Folder_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_Folder_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_Folder_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\Folder\$(var.Language)\Microsoft.Plugin.Folder.resources.dll" /> </Component> - <Component - Id="Launcher_Program_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)0A" - Directory="Resource$(var.IdSafeLanguage)ProgramPluginFolder"> + <Component Id="Launcher_Program_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)0A" Directory="Resource$(var.IdSafeLanguage)ProgramPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_Program_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_Program_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_Program_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\Program\$(var.Language)\Microsoft.Plugin.Program.resources.dll" /> </Component> - <Component - Id="Launcher_Shell_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)0B" - Directory="Resource$(var.IdSafeLanguage)ShellPluginFolder"> + <Component Id="Launcher_Shell_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)0B" Directory="Resource$(var.IdSafeLanguage)ShellPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_Shell_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_Shell_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_Shell_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\Shell\$(var.Language)\Microsoft.Plugin.Shell.resources.dll" /> </Component> - <Component - Id="Launcher_Indexer_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)0C" - Directory="Resource$(var.IdSafeLanguage)IndexerPluginFolder"> + <Component Id="Launcher_Indexer_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)0C" Directory="Resource$(var.IdSafeLanguage)IndexerPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_Indexer_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_Indexer_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_Indexer_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\Indexer\$(var.Language)\Microsoft.Plugin.Indexer.resources.dll" /> </Component> - <Component - Id="Launcher_Uri_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)0D" - Directory="Resource$(var.IdSafeLanguage)UriPluginFolder"> + <Component Id="Launcher_Uri_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)0D" Directory="Resource$(var.IdSafeLanguage)UriPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_Uri_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_Uri_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_Uri_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\Uri\$(var.Language)\Microsoft.Plugin.Uri.resources.dll" /> </Component> - <Component - Id="Launcher_VSCodeWorkspaces_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)0E" - Directory="Resource$(var.IdSafeLanguage)VSCodeWorkspacesPluginFolder"> + <Component Id="Launcher_VSCodeWorkspaces_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)0E" Directory="Resource$(var.IdSafeLanguage)VSCodeWorkspacesPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_VSCodeWorkspaces_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_VSCodeWorkspaces_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_VSCodeWorkspaces_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\VSCodeWorkspaces\$(var.Language)\Community.PowerToys.Run.Plugin.VSCodeWorkspaces.resources.dll" /> </Component> - <Component Id="Launcher_WindowWalker_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)0F" - Directory="Resource$(var.IdSafeLanguage)WindowWalkerPluginFolder"> + <Component Id="Launcher_WindowWalker_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)0F" Directory="Resource$(var.IdSafeLanguage)WindowWalkerPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_WindowWalker_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_WindowWalker_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_WindowWalker_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\WindowWalker\$(var.Language)\Microsoft.Plugin.WindowWalker.resources.dll" /> </Component> - <Component - Id="Launcher_Registry_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)10" - Directory="Resource$(var.IdSafeLanguage)RegistryPluginFolder"> + <Component Id="Launcher_Registry_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)10" Directory="Resource$(var.IdSafeLanguage)RegistryPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_Registry_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_Registry_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_Registry_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\Registry\$(var.Language)\Microsoft.PowerToys.Run.Plugin.Registry.resources.dll" /> </Component> - <Component - Id="Launcher_Service_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)11" - Directory="Resource$(var.IdSafeLanguage)ServicePluginFolder"> + <Component Id="Launcher_Service_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)11" Directory="Resource$(var.IdSafeLanguage)ServicePluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_Service_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_Service_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_Service_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\Service\$(var.Language)\Microsoft.PowerToys.Run.Plugin.Service.resources.dll" /> </Component> - <Component - Id="Launcher_System_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)12" - Directory="Resource$(var.IdSafeLanguage)SystemPluginFolder"> + <Component Id="Launcher_System_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)12" Directory="Resource$(var.IdSafeLanguage)SystemPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_System_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_System_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_System_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\System\$(var.Language)\Microsoft.PowerToys.Run.Plugin.System.resources.dll" /> </Component> - <Component - Id="Launcher_WindowsSettings_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)13" - Directory="Resource$(var.IdSafeLanguage)WindowsSettingsPluginFolder"> + <Component Id="Launcher_WindowsSettings_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)13" Directory="Resource$(var.IdSafeLanguage)WindowsSettingsPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_WindowsSettings_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_WindowsSettings_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_WindowsSettings_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\WindowsSettings\$(var.Language)\Microsoft.PowerToys.Run.Plugin.WindowsSettings.resources.dll" /> </Component> - <Component - Id="Launcher_WindowsTerminal_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)15" - Directory="Resource$(var.IdSafeLanguage)WindowsTerminalPluginFolder"> + <Component Id="Launcher_WindowsTerminal_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)15" Directory="Resource$(var.IdSafeLanguage)WindowsTerminalPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_WindowsTerminal_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_WindowsTerminal_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_WindowsTerminal_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\WindowsTerminal\$(var.Language)\Microsoft.PowerToys.Run.Plugin.WindowsTerminal.resources.dll" /> </Component> - <Component - Id="Launcher_WebSearch_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)16" - Directory="Resource$(var.IdSafeLanguage)WebSearchPluginFolder"> + <Component Id="Launcher_WebSearch_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)16" Directory="Resource$(var.IdSafeLanguage)WebSearchPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_WebSearch_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_WebSearch_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_WebSearch_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\WebSearch\$(var.Language)\Community.PowerToys.Run.Plugin.WebSearch.resources.dll" /> </Component> - <Component - Id="Launcher_UnitConverter_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)17" - Directory="Resource$(var.IdSafeLanguage)UnitConverterPluginFolder"> + <Component Id="Launcher_UnitConverter_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)17" Directory="Resource$(var.IdSafeLanguage)UnitConverterPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_UnitConverter_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_UnitConverter_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_UnitConverter_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\UnitConverter\$(var.Language)\Community.PowerToys.Run.Plugin.UnitConverter.resources.dll" /> </Component> - <Component - Id="Launcher_TimeDate_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)18" - Directory="Resource$(var.IdSafeLanguage)TimeDatePluginFolder"> + <Component Id="Launcher_TimeDate_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)18" Directory="Resource$(var.IdSafeLanguage)TimeDatePluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_TimeDate_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_TimeDate_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_TimeDate_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\TimeDate\$(var.Language)\Microsoft.PowerToys.Run.Plugin.TimeDate.resources.dll" /> </Component> - <Component - Id="Launcher_OneNote_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)19" - Directory="Resource$(var.IdSafeLanguage)OneNotePluginFolder"> + <Component Id="Launcher_OneNote_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)19" Directory="Resource$(var.IdSafeLanguage)OneNotePluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Resource$(var.IdSafeLanguage)OneNotePluginFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Resource$(var.IdSafeLanguage)OneNotePluginFolder" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_OneNote_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\OneNote\$(var.Language)\Microsoft.PowerToys.Run.Plugin.OneNote.resources.dll" /> </Component> - <Component - Id="MonacoPreviewHandler_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)1A"> + <Component Id="MonacoPreviewHandler_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)1A"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="MonacoPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="MonacoPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="MonacoPreviewHandler_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.MonacoPreviewHandler.resources.dll" /> </Component> - <Component - Id="Launcher_History_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)HistoryPluginFolder" - Guid="$(var.CompGUIDPrefix)1B"> + <Component Id="Launcher_History_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)HistoryPluginFolder" Guid="$(var.CompGUIDPrefix)1B"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_History_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_History_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_History_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\History\$(var.Language)\Microsoft.PowerToys.Run.Plugin.History.resources.dll" /> </Component> - <Component - Id="Launcher_PowerToys_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)PowerToysPluginFolder" - Guid="$(var.CompGUIDPrefix)1C"> + <Component Id="Launcher_PowerToys_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)PowerToysPluginFolder" Guid="$(var.CompGUIDPrefix)1C"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_PowerToys_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_PowerToys_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_PowerToys_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\PowerToys\$(var.Language)\Microsoft.PowerToys.Run.Plugin.PowerToys.resources.dll" /> </Component> - <Component - Id="Launcher_ValueGenerator_$(var.IdSafeLanguage)_Component" - Guid="$(var.CompGUIDPrefix)1D" - Directory="Resource$(var.IdSafeLanguage)ValueGeneratorPluginFolder"> + <Component Id="Launcher_ValueGenerator_$(var.IdSafeLanguage)_Component" Guid="$(var.CompGUIDPrefix)1D" Directory="Resource$(var.IdSafeLanguage)ValueGeneratorPluginFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Launcher_ValueGenerator_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Launcher_ValueGenerator_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Launcher_ValueGenerator_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)RunPlugins\ValueGenerator\$(var.Language)\Community.PowerToys.Run.Plugin.ValueGenerator.resources.dll" /> </Component> - <Component - Id="QoiPreviewHandler_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)1E"> + <Component Id="QoiPreviewHandler_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)1E"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="QoiPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="QoiPreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="QoiPreviewHandler_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.QoiPreviewHandler.resources.dll" /> </Component> - <Component - Id="Awake_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)1F"> + <Component Id="Awake_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)1F"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="Awake_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="Awake_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="Awake_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.Awake.resources.dll" /> </Component> - <Component - Id="PowerOCR_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)20"> + <Component Id="PowerOCR_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)20"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="PowerOCR_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="PowerOCR_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="PowerOCR_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.PowerOCR.resources.dll" /> </Component> - <Component - Id="WorkspacesEditor_$(var.IdSafeLanguage)_Component" - Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" - Guid="$(var.CompGUIDPrefix)21"> + <Component Id="WorkspacesEditor_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)21"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="WorkspacesEditor_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="WorkspacesEditor_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="WorkspacesEditor_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.WorkspacesEditor.resources.dll" /> </Component> + <Component + Id="BgcodePreviewHandler_$(var.IdSafeLanguage)_Component" + Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" + Guid="$(var.CompGUIDPrefix)22"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="BgcodePreviewHandler_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + </RegistryKey> + <File Id="BgcodePreviewHandler_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\PowerToys.BgcodePreviewHandler.resources.dll" /> + </Component> + <Component Id="CmdPalExtPowerToys_$(var.IdSafeLanguage)_Component" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" Guid="$(var.CompGUIDPrefix)23"> + <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> + <RegistryValue Type="string" Name="CmdPalExtPowerToys_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> + </RegistryKey> + <File Id="CmdPalExtPowerToys_$(var.IdSafeLanguage)_File" Source="$(var.BinDir)\$(var.Language)\Microsoft.CmdPal.Ext.PowerToys.resources.dll" /> + </Component> <?undef IdSafeLanguage?> <?undef CompGUIDPrefix?> <?endforeach?> <?endif?> <?ifdef env.IsPipeline?> - <Component Id="RemoveResourcesFolder" Guid="9BC0A5A1-CBC5-47C8-8544-3F8A8C0D45F5" Directory="INSTALLFOLDER" > + <Component Id="RemoveResourcesFolder" Guid="9BC0A5A1-CBC5-47C8-8544-3F8A8C0D45F5" Directory="INSTALLFOLDER"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveResourcesFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveResourcesFolder" Value="" KeyPath="yes" /> </RegistryKey> <?foreach Language in $(var.LocLanguageList)?> <!--NB: Ids can't contain hyphens--> @@ -523,27 +438,28 @@ <?else?> <?define IdSafeLanguage = $(var.Language)?> <?endif?> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)INSTALLFOLDER" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)CalculatorPluginFolder" Directory="Resource$(var.IdSafeLanguage)CalculatorPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)FolderPluginFolder" Directory="Resource$(var.IdSafeLanguage)FolderPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)ProgramPluginFolder" Directory="Resource$(var.IdSafeLanguage)ProgramPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)ShellPluginFolder" Directory="Resource$(var.IdSafeLanguage)ShellPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)IndexerPluginFolder" Directory="Resource$(var.IdSafeLanguage)IndexerPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)UriPluginFolder" Directory="Resource$(var.IdSafeLanguage)UriPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)VSCodeWorkspacesPluginFolder" Directory="Resource$(var.IdSafeLanguage)VSCodeWorkspacesPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)WindowWalkerPluginFolder" Directory="Resource$(var.IdSafeLanguage)WindowWalkerPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)RegistryPluginFolder" Directory="Resource$(var.IdSafeLanguage)RegistryPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)ServicePluginFolder" Directory="Resource$(var.IdSafeLanguage)ServicePluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)SystemPluginFolder" Directory="Resource$(var.IdSafeLanguage)SystemPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)WindowsSettingsPluginFolder" Directory="Resource$(var.IdSafeLanguage)WindowsSettingsPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)WindowsTerminalPluginFolder" Directory="Resource$(var.IdSafeLanguage)WindowsTerminalPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)WebSearchPluginFolder" Directory="Resource$(var.IdSafeLanguage)WebSearchPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)UnitConverterPluginFolder" Directory="Resource$(var.IdSafeLanguage)UnitConverterPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)TimeDatePluginFolder" Directory="Resource$(var.IdSafeLanguage)TimeDatePluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)OneNotePluginFolder" Directory="Resource$(var.IdSafeLanguage)OneNotePluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)HistoryPluginFolder" Directory="Resource$(var.IdSafeLanguage)HistoryPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)PowerToysPluginFolder" Directory="Resource$(var.IdSafeLanguage)PowerToysPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)ValueGeneratorPluginFolder" Directory="Resource$(var.IdSafeLanguage)ValueGeneratorPluginFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)INSTALLFOLDER" Directory="Resource$(var.IdSafeLanguage)INSTALLFOLDER" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)CalculatorPluginFolder" Directory="Resource$(var.IdSafeLanguage)CalculatorPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)FolderPluginFolder" Directory="Resource$(var.IdSafeLanguage)FolderPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)ProgramPluginFolder" Directory="Resource$(var.IdSafeLanguage)ProgramPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)ShellPluginFolder" Directory="Resource$(var.IdSafeLanguage)ShellPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)IndexerPluginFolder" Directory="Resource$(var.IdSafeLanguage)IndexerPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)UriPluginFolder" Directory="Resource$(var.IdSafeLanguage)UriPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)VSCodeWorkspacesPluginFolder" Directory="Resource$(var.IdSafeLanguage)VSCodeWorkspacesPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)WindowWalkerPluginFolder" Directory="Resource$(var.IdSafeLanguage)WindowWalkerPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)RegistryPluginFolder" Directory="Resource$(var.IdSafeLanguage)RegistryPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)ServicePluginFolder" Directory="Resource$(var.IdSafeLanguage)ServicePluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)SystemPluginFolder" Directory="Resource$(var.IdSafeLanguage)SystemPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)WindowsSettingsPluginFolder" Directory="Resource$(var.IdSafeLanguage)WindowsSettingsPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)WindowsTerminalPluginFolder" Directory="Resource$(var.IdSafeLanguage)WindowsTerminalPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)WebSearchPluginFolder" Directory="Resource$(var.IdSafeLanguage)WebSearchPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)UnitConverterPluginFolder" Directory="Resource$(var.IdSafeLanguage)UnitConverterPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)TimeDatePluginFolder" Directory="Resource$(var.IdSafeLanguage)TimeDatePluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)OneNotePluginFolder" Directory="Resource$(var.IdSafeLanguage)OneNotePluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)HistoryPluginFolder" Directory="Resource$(var.IdSafeLanguage)HistoryPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)PowerToysPluginFolder" Directory="Resource$(var.IdSafeLanguage)PowerToysPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)ValueGeneratorPluginFolder" Directory="Resource$(var.IdSafeLanguage)ValueGeneratorPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderResourcesResource$(var.IdSafeLanguage)WinUI3AppsInstallFolder" Directory="Resource$(var.IdSafeLanguage)WinUI3AppsInstallFolder" On="uninstall"/> <?undef IdSafeLanguage?> <?endforeach?> </Component> diff --git a/installer/PowerToysSetupVNext/RtfTheme.xml b/installer/PowerToysSetupVNext/RtfTheme.xml new file mode 100644 index 0000000000..da875cc4c3 --- /dev/null +++ b/installer/PowerToysSetupVNext/RtfTheme.xml @@ -0,0 +1,117 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. --> + + +<Theme xmlns="http://wixtoolset.org/schemas/v4/thmutil"> + <Font Id="0" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font> + <Font Id="1" Height="-24" Weight="500" Foreground="windowtext">Segoe UI</Font> + <Font Id="2" Height="-22" Weight="500" Foreground="graytext">Segoe UI</Font> + <Font Id="3" Height="-12" Weight="500" Foreground="windowtext" Background="window">Segoe UI</Font> + + <Window Width="485" Height="300" HexStyle="100a0000" FontId="0" Caption="#(loc.Caption)" IconFile="icon.ico"> + <ImageControl X="11" Y="11" Width="64" Height="64" ImageFile="logo.png" Visible="yes"/> + <Label X="80" Y="11" Width="-11" Height="64" FontId="1" Visible="yes" DisablePrefix="yes">#(loc.Title)</Label> + + <Page Name="Help"> + <Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.HelpHeader)</Label> + <Label X="11" Y="112" Width="-11" Height="-35" FontId="3" DisablePrefix="yes">#(loc.HelpText)</Label> + <Button Name="HelpCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0"> + <Text>#(loc.HelpCloseButton)</Text> + <CloseWindowAction /> + </Button> + </Page> + <Page Name="Loading"> + <Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes" Visible="no" Name="CheckingForUpdatesLabel" /> + </Page> + <Page Name="Install"> + <Richedit Name="EulaRichedit" X="11" Y="80" Width="-11" Height="-70" TabStop="yes" FontId="0" HexStyle="800000" /> + <Checkbox Name="EulaAcceptCheckbox" X="-11" Y="-41" Width="260" Height="17" TabStop="yes" FontId="3" HideWhenDisabled="yes">#(loc.InstallAcceptCheckbox)</Checkbox> + <Button Name="InstallUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button> + <Button Name="OptionsButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" VisibleCondition="NOT WixStdBASuppressOptionsUI"> + <Text>#(loc.InstallOptionsButton)</Text> + <ChangePageAction Page="Options" /> + </Button> + <Button Name="InstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">#(loc.InstallInstallButton)</Button> + <Button Name="InstallCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0"> + <Text>#(loc.InstallCancelButton)</Text> + <CloseWindowAction /> + </Button> + </Page> + <Page Name="Options"> + <Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.OptionsHeader)</Label> + <Label X="11" Y="121" Width="-11" Height="17" FontId="3" DisablePrefix="yes">#(loc.OptionsLocationLabel)</Label> + <Editbox Name="InstallFolder" X="11" Y="143" Width="-91" Height="21" TabStop="yes" FontId="3" FileSystemAutoComplete="yes" /> + <Button Name="BrowseButton" X="-11" Y="142" Width="75" Height="23" TabStop="yes" FontId="3"> + <Text>#(loc.OptionsBrowseButton)</Text> + <BrowseDirectoryAction VariableName="InstallFolder" /> + </Button> + <Button Name="OptionsOkButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0"> + <Text>#(loc.OptionsOkButton)</Text> + <ChangePageAction Page="Install" /> + </Button> + <Button Name="OptionsCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0"> + <Text>#(loc.OptionsCancelButton)</Text> + <ChangePageAction Page="Install" Cancel="yes" /> + </Button> + </Page> + <Page Name="Progress"> + <Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ProgressHeader)</Label> + <Label X="11" Y="121" Width="70" Height="17" FontId="3" DisablePrefix="yes">#(loc.ProgressLabel)</Label> + <Label Name="OverallProgressPackageText" X="85" Y="121" Width="-11" Height="17" FontId="3" DisablePrefix="yes">#(loc.OverallProgressPackageText)</Label> + <Progressbar Name="OverallCalculatedProgressbar" X="11" Y="143" Width="-11" Height="15" /> + <Button Name="ProgressCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">#(loc.ProgressCancelButton)</Button> + </Page> + <Page Name="Modify"> + <Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes">#(loc.ModifyHeader)</Label> + <Button Name="ModifyUpdateButton" X="11" Y="-11" Width="200" Height="23" TabStop="yes" FontId="0" EnableCondition="WixStdBAUpdateAvailable" HideWhenDisabled="yes">#(loc.UpdateButton)</Button> + <Button Name="RepairButton" X="-171" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.ModifyRepairButton)</Button> + <Button Name="UninstallButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0">#(loc.ModifyUninstallButton)</Button> + <Button Name="ModifyCancelButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0"> + <Text>#(loc.ModifyCancelButton)</Text> + <CloseWindowAction /> + </Button> + </Page> + <Page Name="Success"> + <Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes"> + <Text>#(loc.SuccessHeader)</Text> + <Text Condition="WixBundleAction = 2">#(loc.SuccessLayoutHeader)</Text> + <Text Condition="WixBundleAction = 3">#(loc.SuccessUnsafeUninstallHeader)</Text> + <Text Condition="WixBundleAction = 4">#(loc.SuccessUninstallHeader)</Text> + <Text Condition="WixBundleAction = 5">#(loc.SuccessCacheHeader)</Text> + <Text Condition="WixBundleAction = 6">#(loc.SuccessInstallHeader)</Text> + <Text Condition="WixBundleAction = 7">#(loc.SuccessModifyHeader)</Text> + <Text Condition="WixBundleAction = 8">#(loc.SuccessRepairHeader)</Text> + </Label> + <Button Name="LaunchButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessLaunchButton)</Button> + <Label X="-11" Y="-51" Width="400" Height="34" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired"> + <Text>#(loc.SuccessRestartText)</Text> + <Text Condition="WixBundleAction = 3">#(loc.SuccessUninstallRestartText)</Text> + </Label> + <Button Name="SuccessRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.SuccessRestartButton)</Button> + <Button Name="SuccessCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0"> + <Text>#(loc.SuccessCloseButton)</Text> + <CloseWindowAction /> + </Button> + </Page> + <Page Name="Failure"> + <Label X="11" Y="80" Width="-11" Height="30" FontId="2" DisablePrefix="yes"> + <Text>#(loc.FailureHeader)</Text> + <Text Condition="WixBundleAction = 2">#(loc.FailureLayoutHeader)</Text> + <Text Condition="WixBundleAction = 3">#(loc.FailureUnsafeUninstallHeader)</Text> + <Text Condition="WixBundleAction = 4">#(loc.FailureUninstallHeader)</Text> + <Text Condition="WixBundleAction = 5">#(loc.FailureCacheHeader)</Text> + <Text Condition="WixBundleAction = 6">#(loc.FailureInstallHeader)</Text> + <Text Condition="WixBundleAction = 7">#(loc.FailureModifyHeader)</Text> + <Text Condition="WixBundleAction = 8">#(loc.FailureRepairHeader)</Text> + </Label> + <Hypertext Name="FailureLogFileLink" X="11" Y="121" Width="-11" Height="42" FontId="3" TabStop="yes" HideWhenDisabled="yes">#(loc.FailureHyperlinkLogText)</Hypertext> + <Hypertext Name="FailureMessageText" X="22" Y="163" Width="-11" Height="51" FontId="3" TabStop="yes" HideWhenDisabled="yes" /> + <Label X="-11" Y="-51" Width="400" Height="34" FontId="3" DisablePrefix="yes" VisibleCondition="WixStdBARestartRequired">#(loc.FailureRestartText)</Label> + <Button Name="FailureRestartButton" X="-91" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0" HideWhenDisabled="yes">#(loc.FailureRestartButton)</Button> + <Button Name="FailureCloseButton" X="-11" Y="-11" Width="75" Height="23" TabStop="yes" FontId="0"> + <Text>#(loc.FailureCloseButton)</Text> + <CloseWindowAction /> + </Button> + </Page> + </Window> +</Theme> diff --git a/installer/PowerToysSetup/Run.wxs b/installer/PowerToysSetupVNext/Run.wxs similarity index 90% rename from installer/PowerToysSetup/Run.wxs rename to installer/PowerToysSetupVNext/Run.wxs index 94a589585f..cf7542b70d 100644 --- a/installer/PowerToysSetup/Run.wxs +++ b/installer/PowerToysSetupVNext/Run.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -396,52 +394,52 @@ </DirectoryRef> <ComponentGroup Id="RunComponentGroup"> - <Component Id="RemoveLauncherFolder" Guid="3FFDC0B6-82BC-4C57-AEB1-C710DB108C23" Directory="INSTALLFOLDER" > + <Component Id="RemoveLauncherFolder" Guid="3FFDC0B6-82BC-4C57-AEB1-C710DB108C23" Directory="INSTALLFOLDER"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveLauncherFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveLauncherFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderLauncherImagesFolder" Directory="LauncherImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderLauncherPluginsFolder" Directory="LauncherPluginsFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderCalculatorPluginFolder" Directory="CalculatorPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderCalculatorImagesFolder" Directory="CalculatorImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderFolderPluginFolder" Directory="FolderPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderFolderPluginImagesFolder" Directory="FolderPluginImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderProgramPluginFolder" Directory="ProgramPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderProgramImagesFolder" Directory="ProgramImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderShellPluginFolder" Directory="ShellPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderShellImagesFolder" Directory="ShellImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderIndexerPluginFolder" Directory="IndexerPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderIndexerImagesFolder" Directory="IndexerImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderUnitConverterPluginFolder" Directory="UnitConverterPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderUnitConverterImagesFolder" Directory="UnitConverterImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderWebSearchPluginFolder" Directory="WebSearchPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderWebSearchImagesFolder" Directory="WebSearchImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderHistoryPluginFolder" Directory="HistoryPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderHistoryImagesFolder" Directory="HistoryImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderUriPluginFolder" Directory="UriPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderUriImagesFolder" Directory="UriImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderVSCodeWorkspacesPluginFolder" Directory="VSCodeWorkspacesPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderVSCodeWorkspaceImagesFolder" Directory="VSCodeWorkspaceImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderWindowWalkerPluginFolder" Directory="WindowWalkerPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderWindowWalkerImagesFolder" Directory="WindowWalkerImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderOneNotePluginFolder" Directory="OneNotePluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderOneNoteImagesFolder" Directory="OneNoteImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderRegistryPluginFolder" Directory="RegistryPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderRegistryImagesFolder" Directory="RegistryImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderServicePluginFolder" Directory="ServicePluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderServiceImagesFolder" Directory="ServiceImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderSystemPluginFolder" Directory="SystemPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderSystemImagesFolder" Directory="SystemImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderTimeDatePluginFolder" Directory="TimeDatePluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderTimeDateImagesFolder" Directory="TimeDateImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderWindowsSettingsPluginFolder" Directory="WindowsSettingsPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderWindowsSettingsImagesFolder" Directory="WindowsSettingsImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderWindowsTerminalPluginFolder" Directory="WindowsTerminalPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderWindowsTerminalImagesFolder" Directory="WindowsTerminalImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderPowerToysPluginFolder" Directory="PowerToysPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderPowerToysImagesFolder" Directory="PowerToysImagesFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderValueGeneratorPluginFolder" Directory="ValueGeneratorPluginFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderValueGeneratorImagesFolder" Directory="ValueGeneratorImagesFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderLauncherImagesFolder" Directory="LauncherImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderLauncherPluginsFolder" Directory="LauncherPluginsFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderCalculatorPluginFolder" Directory="CalculatorPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderCalculatorImagesFolder" Directory="CalculatorImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderFolderPluginFolder" Directory="FolderPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderFolderPluginImagesFolder" Directory="FolderPluginImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderProgramPluginFolder" Directory="ProgramPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderProgramImagesFolder" Directory="ProgramImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderShellPluginFolder" Directory="ShellPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderShellImagesFolder" Directory="ShellImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderIndexerPluginFolder" Directory="IndexerPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderIndexerImagesFolder" Directory="IndexerImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderUnitConverterPluginFolder" Directory="UnitConverterPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderUnitConverterImagesFolder" Directory="UnitConverterImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderWebSearchPluginFolder" Directory="WebSearchPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderWebSearchImagesFolder" Directory="WebSearchImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderHistoryPluginFolder" Directory="HistoryPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderHistoryImagesFolder" Directory="HistoryImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderUriPluginFolder" Directory="UriPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderUriImagesFolder" Directory="UriImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderVSCodeWorkspacesPluginFolder" Directory="VSCodeWorkspacesPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderVSCodeWorkspaceImagesFolder" Directory="VSCodeWorkspaceImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderWindowWalkerPluginFolder" Directory="WindowWalkerPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderWindowWalkerImagesFolder" Directory="WindowWalkerImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderOneNotePluginFolder" Directory="OneNotePluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderOneNoteImagesFolder" Directory="OneNoteImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderRegistryPluginFolder" Directory="RegistryPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderRegistryImagesFolder" Directory="RegistryImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderServicePluginFolder" Directory="ServicePluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderServiceImagesFolder" Directory="ServiceImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderSystemPluginFolder" Directory="SystemPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderSystemImagesFolder" Directory="SystemImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderTimeDatePluginFolder" Directory="TimeDatePluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderTimeDateImagesFolder" Directory="TimeDateImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderWindowsSettingsPluginFolder" Directory="WindowsSettingsPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderWindowsSettingsImagesFolder" Directory="WindowsSettingsImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderWindowsTerminalPluginFolder" Directory="WindowsTerminalPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderWindowsTerminalImagesFolder" Directory="WindowsTerminalImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderPowerToysPluginFolder" Directory="PowerToysPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderPowerToysImagesFolder" Directory="PowerToysImagesFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderValueGeneratorPluginFolder" Directory="ValueGeneratorPluginFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderValueGeneratorImagesFolder" Directory="ValueGeneratorImagesFolder" On="uninstall" /> </Component> </ComponentGroup> </Fragment> diff --git a/installer/PowerToysSetup/Settings.wxs b/installer/PowerToysSetupVNext/Settings.wxs similarity index 78% rename from installer/PowerToysSetup/Settings.wxs rename to installer/PowerToysSetupVNext/Settings.wxs index 07a2b056dc..cf7cf7f727 100644 --- a/installer/PowerToysSetup/Settings.wxs +++ b/installer/PowerToysSetupVNext/Settings.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -16,12 +14,17 @@ <?define SettingsV2OOBEAssetsFluentIconsFiles=?> <?define SettingsV2OOBEAssetsFluentIconsFilesPath=$(var.BinDir)WinUI3Apps\Assets\Settings\Icons\?> + <?define SettingsV2IconsModelsFiles=?> + <?define SettingsV2IconsModelsFilesPath=$(var.BinDir)WinUI3Apps\Assets\Settings\Icons\Models\?> + <Fragment> <DirectoryRef Id="WinUI3AppsAssetsFolder"> <Directory Id="SettingsV2AssetsInstallFolder" Name="Settings"> - <Directory Id="SettingsAppAssetsScriptsFolder" Name="Scripts"/> - <Directory Id="SettingsV2OOBEAssetsFluentIconsInstallFolder" Name="Icons" /> - <Directory Id="SettingsV2AssetsModulesInstallFolder" Name="Modules" > + <Directory Id="SettingsAppAssetsScriptsFolder" Name="Scripts" /> + <Directory Id="SettingsV2OOBEAssetsFluentIconsInstallFolder" Name="Icons"> + <Directory Id="SettingsV2IconsModelsInstallFolder" Name="Models" /> + </Directory> + <Directory Id="SettingsV2AssetsModulesInstallFolder" Name="Modules"> <Directory Id="SettingsV2OOBEAssetsModulesInstallFolder" Name="OOBE" /> </Directory> </Directory> @@ -47,10 +50,15 @@ <!--SettingsV2OOBEAssetsFluentIconsFiles_Component_Def--> </DirectoryRef> + <DirectoryRef Id="SettingsV2IconsModelsInstallFolder" FileSource="$(var.SettingsV2IconsModelsFilesPath)"> + <!-- Generated by generateFileComponents.ps1 --> + <!--SettingsV2IconsModelsFiles_Component_Def--> + </DirectoryRef> + <DirectoryRef Id="SettingsAppAssetsScriptsFolder" FileSource="$(var.SettingsV2AssetsFilesPath)\Scripts\"> - <Component Id="CommandNotFound_Scripts" Win64="yes" Guid="898EFA1E-EDD3-4F4B-8C7F-4A14B0D05B02"> + <Component Id="CommandNotFound_Scripts" Guid="898EFA1E-EDD3-4F4B-8C7F-4A14B0D05B02" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="CommandNotFound_Scripts" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="CommandNotFound_Scripts" Value="" KeyPath="yes" /> </RegistryKey> <File Id="CommandNotFound_Scripts_EnableModule.ps1" Source="$(var.SettingsV2AssetsFilesPath)\Scripts\EnableModule.ps1" /> <File Id="CommandNotFound_Scripts_UpgradeModule.ps1" Source="$(var.SettingsV2AssetsFilesPath)\Scripts\UpgradeModule.ps1" /> @@ -63,17 +71,18 @@ <ComponentGroup Id="SettingsComponentGroup"> - <Component Id="RemoveSettingsFolder" Guid="2D3AEF68-4E5A-4FF9-A5C0-9E53391AC754" Directory="SettingsV2AssetsInstallFolder" > + <Component Id="RemoveSettingsFolder" Guid="2D3AEF68-4E5A-4FF9-A5C0-9E53391AC754" Directory="SettingsV2AssetsInstallFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveSettingsFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveSettingsFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderSettingsV2AssetsInstallFolder" Directory="SettingsV2AssetsInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderSettingsV2OOBEAssetsFluentIconsInstallFolder" Directory="SettingsV2OOBEAssetsFluentIconsInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderSettingsV2AssetsModulesInstallFolder" Directory="SettingsV2AssetsModulesInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderSettingsV2OOBEAssetsModulesInstallFolder" Directory="SettingsV2OOBEAssetsModulesInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderSettingsAppAssetsScriptsFolder" Directory="SettingsAppAssetsScriptsFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderSettingsV2AssetsInstallFolder" Directory="SettingsV2AssetsInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderSettingsV2OOBEAssetsFluentIconsInstallFolder" Directory="SettingsV2OOBEAssetsFluentIconsInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderSettingsV2IconsModelsInstallFolder" Directory="SettingsV2IconsModelsInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderSettingsV2AssetsModulesInstallFolder" Directory="SettingsV2AssetsModulesInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderSettingsV2OOBEAssetsModulesInstallFolder" Directory="SettingsV2OOBEAssetsModulesInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderSettingsAppAssetsScriptsFolder" Directory="SettingsAppAssetsScriptsFolder" On="uninstall" /> </Component> - <ComponentRef Id="CommandNotFound_Scripts"/> + <ComponentRef Id="CommandNotFound_Scripts" /> </ComponentGroup> </Fragment> diff --git a/installer/PowerToysSetup/ShortcutGuide.wxs b/installer/PowerToysSetupVNext/ShortcutGuide.wxs similarity index 76% rename from installer/PowerToysSetup/ShortcutGuide.wxs rename to installer/PowerToysSetupVNext/ShortcutGuide.wxs index 729a805861..37b1c7800b 100644 --- a/installer/PowerToysSetup/ShortcutGuide.wxs +++ b/installer/PowerToysSetupVNext/ShortcutGuide.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -18,12 +16,12 @@ </DirectoryRef> <!-- Shortcut guide --> - <ComponentGroup Id="ShortcutGuideComponentGroup" > - <Component Id="RemoveShortcutGuideFolder" Guid="AD1ABC55-B593-4A60-A86A-BA8C0ED493A5" Directory="ShortcutGuideSvgsInstallFolder" > + <ComponentGroup Id="ShortcutGuideComponentGroup"> + <Component Id="RemoveShortcutGuideFolder" Guid="AD1ABC55-B593-4A60-A86A-BA8C0ED493A5" Directory="ShortcutGuideSvgsInstallFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveShortcutGuideFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveShortcutGuideFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderShortcutGuideSvgsInstallFolder" Directory="ShortcutGuideSvgsInstallFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderShortcutGuideSvgsInstallFolder" Directory="ShortcutGuideSvgsInstallFolder" On="uninstall" /> </Component> </ComponentGroup> diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj new file mode 100644 index 0000000000..15623db20b --- /dev/null +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunction.vcxproj @@ -0,0 +1,121 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. --> + +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup Label="ProjectConfigurations"> + <ProjectConfiguration Include="Debug|ARM64"> + <Configuration>Debug</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|ARM64"> + <Configuration>Release</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Debug|x64"> + <Configuration>Debug</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|x64"> + <Configuration>Release</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + </ItemGroup> + + <PropertyGroup Label="Globals"> + <ProjectGuid>{F8B9F842-F5C3-4A2D-8C85-7F8B9E2B4F1D}</ProjectGuid> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <CharacterSet>Unicode</CharacterSet> + <TargetName>SilentFilesInUseBAFunction</TargetName> + <ProjectName>PowerToysSetupCustomActionsVNext</ProjectName> + <ProjectModuleDefinitionFile>bafunctions.def</ProjectModuleDefinitionFile> + <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion> + </PropertyGroup> + + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + + <!-- Configuration-specific property groups --> + <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + + <PropertyGroup> + <ProjectAdditionalLinkLibraries>comctl32.lib;gdiplus.lib;msimg32.lib;shlwapi.lib;wininet.lib;version.lib</ProjectAdditionalLinkLibraries> + </PropertyGroup> + <ItemGroup> + <ProjectCapability Include="PackageReferences" /> + </ItemGroup> + <PropertyGroup> + <NuGetTargetMoniker Condition="'$(NuGetTargetMoniker)' == ''"> + native,Version=v0.0 + </NuGetTargetMoniker> + <RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers> + </PropertyGroup> + + <ItemGroup> + <ClCompile Include="SilentFilesInUseBAFunctions.cpp" /> + <ClCompile Include="bafunctions.cpp"> + <PrecompiledHeader>Create</PrecompiledHeader> + </ClCompile> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h" /> + <ClInclude Include="resource.h" /> + </ItemGroup> + <ItemGroup> + <None Include="bafunctions.def" /> + <None Include="Readme.txt" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="WixToolset.BootstrapperApplicationApi" /> + <PackageReference Include="WixToolset.WixStandardBootstrapperApplicationFunctionApi" /> + </ItemGroup> + + <ItemDefinitionGroup> + <Link> + <AdditionalDependencies>version.lib;%(AdditionalDependencies)</AdditionalDependencies> + <ModuleDefinitionFile>bafunctions.def</ModuleDefinitionFile> + </Link> + </ItemDefinitionGroup> + + <!-- C++ source compile-specific things for Debug/Release configurations --> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> + <ClCompile> + <PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <Optimization>Disabled</Optimization> + <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary> + </ClCompile> + <Link> + <GenerateDebugInformation>true</GenerateDebugInformation> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> + <ClCompile> + <PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <Optimization>MaxSpeed</Optimization> + <RuntimeLibrary>MultiThreaded</RuntimeLibrary> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + </ClCompile> + <Link> + <GenerateDebugInformation>true</GenerateDebugInformation> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + </Link> + </ItemDefinitionGroup> + + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> +</Project> diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp new file mode 100644 index 0000000000..e633135371 --- /dev/null +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/SilentFilesInUseBAFunctions.cpp @@ -0,0 +1,160 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +#include "pch.h" +#include "BalBaseBAFunctions.h" +#include "BalBaseBAFunctionsProc.h" + +class CSilentFilesInUseBAFunctions : public CBalBaseBAFunctions +{ +public: // IBootstrapperApplication + virtual STDMETHODIMP OnDetectBegin( + __in BOOL fCached, + __in BOOTSTRAPPER_REGISTRATION_TYPE registrationType, + __in DWORD cPackages, + __inout BOOL* pfCancel + ) + { + HRESULT hr = S_OK; + + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION SYSTEM ACTIVE *** Running detect begin BA function. fCached=%d, registrationType=%d, cPackages=%u, fCancel=%d", fCached, registrationType, cPackages, *pfCancel); + + return hr; + } + +public: // IBAFunctions + virtual STDMETHODIMP OnPlanBegin( + __in DWORD cPackages, + __inout BOOL* pfCancel + ) + { + HRESULT hr = S_OK; + + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION SYSTEM ACTIVE *** Running plan begin BA function. cPackages=%u, fCancel=%d", cPackages, *pfCancel); + + //------------------------------------------------------------------------------------------------- + // YOUR CODE GOES HERE + // BalExitOnFailure(hr, "Change this message to represent real error handling."); + //------------------------------------------------------------------------------------------------- + + return hr; + } + + virtual STDMETHODIMP OnExecuteBegin( + __in DWORD cExecutingPackages, + __inout BOOL* pfCancel + ) + { + HRESULT hr = S_OK; + + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION SYSTEM ACTIVE *** Running execute begin BA function. cExecutingPackages=%u, fCancel=%d", cExecutingPackages, *pfCancel); + + return hr; + } + + virtual STDMETHODIMP OnExecuteFilesInUse( + __in_z LPCWSTR wzPackageId, + __in DWORD cFiles, + __in_ecount_z(cFiles) LPCWSTR* rgwzFiles, + __in int nRecommendation, + __in BOOTSTRAPPER_FILES_IN_USE_TYPE /* source */, + __inout int* pResult + ) + { + HRESULT hr = S_OK; + + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION CALLED *** Running OnExecuteFilesInUse BA function. packageId=%ls, cFiles=%u, recommendation=%d", wzPackageId, cFiles, nRecommendation); + + // Log each file that's in use + for (DWORD i = 0; i < cFiles; i++) + { + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** FILE IN USE [%u]: %ls", i, rgwzFiles[i]); + } + + /* + * Summary: Why we return IDIGNORE here + * + * - Goal: Keep behavior consistent with our previous WiX 3 installer to avoid "files in use / close apps" prompts and preserve silent installs (e.g., winget). + * - WiX 5 change: We can no longer suppress that dialog the same way. Combined with winget adding /silent, this BAFunction returns IDIGNORE to continue without prompts. + * - Main trigger: Win10-style context menu uses registry + DLL; Explorer/dllhost.exe (COM Surrogate) often holds locks. Killing them is disruptive; this is a pragmatic trade-off. + * - Trade-off: Some file replacements may defer until reboot (PendingFileRename), but installation remains non-interruptive. + * - Full fix: Rewrite a custom Bootstrapper Application if we need complete control over prompts and behavior. + * - Note: Even with this handler, a full-UI install (e.g., double-clicking the installer) can still show a FilesInUse dialog; this primarily targets silent installs. + */ + *pResult = IDIGNORE; + + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** BA FUNCTION RETURNING IDIGNORE - SILENTLY CONTINUING ***"); + + return hr; + } + + virtual STDMETHODIMP OnExecuteComplete( + __in HRESULT hrStatus, + __inout BOOL* pfCancel + ) + { + HRESULT hr = S_OK; + + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CUSTOM BA FUNCTION SYSTEM ACTIVE *** Running execute complete BA function. hrStatus=0x%x, fCancel=%d", hrStatus, *pfCancel); + + return hr; + } + +public: + // + // Constructor - initialize member variables. + // + CSilentFilesInUseBAFunctions( + __in HMODULE hModule + ) : CBalBaseBAFunctions(hModule) + { + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** BA FUNCTION CONSTRUCTOR *** CSilentFilesInUseBAFunctions created"); + } + + // + // Destructor - release member variables. + // + ~CSilentFilesInUseBAFunctions() + { + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** BA FUNCTION DESTRUCTOR *** CSilentFilesInUseBAFunctions destroyed"); + } +}; + + +HRESULT WINAPI CreateBAFunctions( + __in HMODULE hModule, + __in const BA_FUNCTIONS_CREATE_ARGS* pArgs, + __inout BA_FUNCTIONS_CREATE_RESULTS* pResults + ) +{ + HRESULT hr = S_OK; + CSilentFilesInUseBAFunctions* pBAFunctions = NULL; + + // First thing - log that we're being called + BalInitialize(pArgs->pEngine); + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CREATEBAFUNCTIONS CALLED *** BA Function DLL is being loaded!"); + + pBAFunctions = new CSilentFilesInUseBAFunctions(hModule); + ExitOnNull(pBAFunctions, hr, E_OUTOFMEMORY, "Failed to create new CSilentFilesInUseBAFunctions object."); + + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CREATEBAFUNCTIONS *** Created CSilentFilesInUseBAFunctions object"); + + hr = pBAFunctions->OnCreate(pArgs->pEngine, pArgs->pCommand); + ExitOnFailure(hr, "Failed to call OnCreate CPrereqBaf."); + + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CREATEBAFUNCTIONS *** OnCreate completed successfully"); + + pResults->pfnBAFunctionsProc = BalBaseBAFunctionsProc; + pResults->pvBAFunctionsProcContext = pBAFunctions; + pBAFunctions = NULL; + + BalLog(BOOTSTRAPPER_LOG_LEVEL_STANDARD, "*** CREATEBAFUNCTIONS SUCCESS *** BA Function system initialized"); + +LExit: + if (FAILED(hr)) + { + BalLog(BOOTSTRAPPER_LOG_LEVEL_ERROR, "*** CREATEBAFUNCTIONS FAILED *** hr=0x%x", hr); + } + ReleaseObject(pBAFunctions); + + return hr; +} diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/bafunctions.cpp b/installer/PowerToysSetupVNext/SilentFilesInUseBA/bafunctions.cpp new file mode 100644 index 0000000000..b4efdc2a5e --- /dev/null +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/bafunctions.cpp @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +#include "pch.h" + +static HINSTANCE vhInstance = NULL; + +extern "C" BOOL WINAPI DllMain( + IN HINSTANCE hInstance, + IN DWORD dwReason, + IN LPVOID /* pvReserved */ + ) +{ + switch (dwReason) + { + case DLL_PROCESS_ATTACH: + ::DisableThreadLibraryCalls(hInstance); + vhInstance = hInstance; + break; + + case DLL_PROCESS_DETACH: + vhInstance = NULL; + break; + } + + return TRUE; +} + +extern "C" HRESULT WINAPI BAFunctionsCreate( + __in const BA_FUNCTIONS_CREATE_ARGS* pArgs, + __inout BA_FUNCTIONS_CREATE_RESULTS* pResults + ) +{ + HRESULT hr = S_OK; + + // This is required to enable logging functions. + BalInitialize(pArgs->pEngine); + + hr = CreateBAFunctions(vhInstance, pArgs, pResults); + BalExitOnFailure(hr, "Failed to create BAFunctions interface."); + +LExit: + return hr; +} + +extern "C" void WINAPI BAFunctionsDestroy( + __in const BA_FUNCTIONS_DESTROY_ARGS* /*pArgs*/, + __inout BA_FUNCTIONS_DESTROY_RESULTS* /*pResults*/ + ) +{ + BalUninitialize(); +} diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/bafunctions.def b/installer/PowerToysSetupVNext/SilentFilesInUseBA/bafunctions.def new file mode 100644 index 0000000000..6e016dad07 --- /dev/null +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/bafunctions.def @@ -0,0 +1,6 @@ +; Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + + +EXPORTS + BAFunctionsCreate + BAFunctionsDestroy diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/pch.h b/installer/PowerToysSetupVNext/SilentFilesInUseBA/pch.h new file mode 100644 index 0000000000..6f679095ae --- /dev/null +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/pch.h @@ -0,0 +1,37 @@ +#pragma once +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + + +#include <windows.h> + +#pragma warning(push) +#pragma warning(disable:4458) // declaration of 'xxx' hides class member +#include <gdiplus.h> +#pragma warning(pop) + +#include <msiquery.h> +#include <objbase.h> +#include <shlobj.h> +#include <shlwapi.h> +#include <stdlib.h> +#include <strsafe.h> +#include <CommCtrl.h> + +// Standard WiX header files, include as required +#include "dutil.h" +#include "dictutil.h" +#include "fileutil.h" +#include "pathutil.h" +#include "strutil.h" +#include "regutil.h" + +#include "BootstrapperApplicationBase.h" + +#include "BAFunctions.h" +#include "IBAFunctions.h" + +HRESULT WINAPI CreateBAFunctions( + __in HMODULE hModule, + __in const BA_FUNCTIONS_CREATE_ARGS* pArgs, + __inout BA_FUNCTIONS_CREATE_RESULTS* pResults + ); diff --git a/installer/PowerToysSetupVNext/SilentFilesInUseBA/resource.h b/installer/PowerToysSetupVNext/SilentFilesInUseBA/resource.h new file mode 100644 index 0000000000..149a8ff48a --- /dev/null +++ b/installer/PowerToysSetupVNext/SilentFilesInUseBA/resource.h @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. Licensed under the Microsoft Reciprocal License. See LICENSE.TXT file in the project root for full license information. + +#define IDC_STATIC -1 + + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1003 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/installer/PowerToysSetup/Tools.wxs b/installer/PowerToysSetupVNext/Tools.wxs similarity index 71% rename from installer/PowerToysSetup/Tools.wxs rename to installer/PowerToysSetupVNext/Tools.wxs index 24c3fc2007..9e43e90916 100644 --- a/installer/PowerToysSetup/Tools.wxs +++ b/installer/PowerToysSetupVNext/Tools.wxs @@ -1,31 +1,29 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> <Fragment> <DirectoryRef Id="ToolsFolder"> - <Component Id="BugReportTool_exe" Win64="yes" Guid="370D0C28-F423-4A12-9A64-6BAB57C7E5C3"> + <Component Id="BugReportTool_exe" Guid="370D0C28-F423-4A12-9A64-6BAB57C7E5C3" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="BugReportTool_exe" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="BugReportTool_exe" Value="" KeyPath="yes" /> </RegistryKey> <File Source="$(var.BinDir)Tools\PowerToys.BugReportTool.exe" Id="BugReportTool.exe" Checksum="yes" /> </Component> - <Component Id="StylesReportTool_exe" Win64="yes" Guid="9D348A78-38A0-4FDC-8D16-BDB0178E5F1E"> + <Component Id="StylesReportTool_exe" Guid="9D348A78-38A0-4FDC-8D16-BDB0178E5F1E" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="StylesReportTool_exe" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="StylesReportTool_exe" Value="" KeyPath="yes" /> </RegistryKey> <File Source="$(var.BinDir)StylesReportTool\PowerToys.StylesReportTool.exe" Id="StylesReportTool.exe" Checksum="yes" /> </Component> </DirectoryRef> <ComponentGroup Id="ToolComponentGroup"> - <Component Id="RemoveToolsFolder" Guid="0402A3E8-1B4F-4762-9CCF-2267BCF8B6EE" Directory="ToolsFolder" > + <Component Id="RemoveToolsFolder" Guid="0402A3E8-1B4F-4762-9CCF-2267BCF8B6EE" Directory="ToolsFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveToolsFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveToolsFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderToolsFolder" Directory="ToolsFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderToolsFolder" Directory="ToolsFolder" On="uninstall" /> </Component> <ComponentRef Id="BugReportTool_exe" /> <ComponentRef Id="StylesReportTool_exe" /> diff --git a/installer/PowerToysSetup/WebView2/MicrosoftEdgeWebview2Setup.exe b/installer/PowerToysSetupVNext/WebView2/MicrosoftEdgeWebview2Setup.exe similarity index 100% rename from installer/PowerToysSetup/WebView2/MicrosoftEdgeWebview2Setup.exe rename to installer/PowerToysSetupVNext/WebView2/MicrosoftEdgeWebview2Setup.exe diff --git a/installer/PowerToysSetup/WinAppSDK.wxs b/installer/PowerToysSetupVNext/WinAppSDK.wxs similarity index 95% rename from installer/PowerToysSetup/WinAppSDK.wxs rename to installer/PowerToysSetupVNext/WinAppSDK.wxs index 631fb033ef..00b8395735 100644 --- a/installer/PowerToysSetup/WinAppSDK.wxs +++ b/installer/PowerToysSetupVNext/WinAppSDK.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -9,15 +7,15 @@ <Fragment> <DirectoryRef Id="WinUI3AppsMicrosoftUIXamlAssetsInstallFolder" FileSource="$(var.BinDir)WinUI3Apps\Microsoft.UI.Xaml\Assets"> - <Component Id="WinUI3AppsMicrosoftUIXamlAssets_NoiseAsset_256x256_PNG.png" Win64="yes" Guid="39889494-838A-4B9A-BD0A-105A1F0161BF"> + <Component Id="WinUI3AppsMicrosoftUIXamlAssets_NoiseAsset_256x256_PNG.png" Guid="39889494-838A-4B9A-BD0A-105A1F0161BF" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="WinUI3AppsMicrosoftUIXamlAssets_NoiseAsset_256x256_PNG" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="WinUI3AppsMicrosoftUIXamlAssets_NoiseAsset_256x256_PNG" Value="" KeyPath="yes" /> </RegistryKey> <File Id="WinUI3AppsMicrosoftUIXamlAssetsFile_NoiseAsset_256x256_PNG.png" Source="$(var.BinDir)WinUI3Apps\Microsoft.UI.Xaml\Assets\NoiseAsset_256x256_PNG.png" /> </Component> - <Component Id="WinUI3AppsMicrosoftUIXamlAssets_map.html" Win64="yes" Guid="A970464C-A5BC-43DB-ACB3-7D83CF3047B3"> + <Component Id="WinUI3AppsMicrosoftUIXamlAssets_map.html" Guid="A970464C-A5BC-43DB-ACB3-7D83CF3047B3" Bitness="always64"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="WinUI3AppsMicrosoftUIXamlAssets_map" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="WinUI3AppsMicrosoftUIXamlAssets_map" Value="" KeyPath="yes" /> </RegistryKey> <File Id="WinUI3AppsMicrosoftUIXamlAssetsFile_map.html" Source="$(var.BinDir)WinUI3Apps\Microsoft.UI.Xaml\Assets\map.html" /> </Component> @@ -319,12 +317,9 @@ <?define IdSafeLanguage = $(var.Language)?> <?define CompGUIDPrefix = 51B656B3-2D45-49D8-9871-F0A1C8BEEE?> <?endif?> - <Component - Id="WinUI3Apps_WinAppSDKLoc_$(var.IdSafeLanguage)_Component" - Directory="WinAppSDKLoc$(var.IdSafeLanguage)WinUI3AppsInstallFolder" - Guid="$(var.CompGUIDPrefix)01"> + <Component Id="WinUI3Apps_WinAppSDKLoc_$(var.IdSafeLanguage)_Component" Directory="WinAppSDKLoc$(var.IdSafeLanguage)WinUI3AppsInstallFolder" Guid="$(var.CompGUIDPrefix)01"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="WinUI3Apps_WinAppSDKLoc_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="WinUI3Apps_WinAppSDKLoc_$(var.IdSafeLanguage)_Component" Value="" KeyPath="yes" /> </RegistryKey> <File Id="WinUI3Apps_WinAppSDKLoc_$(var.IdSafeLanguage)_XamlMui_File" Source="$(var.BinDir)WinUI3Apps\$(var.Language)\Microsoft.ui.xaml.dll.mui" /> <File Id="WinUI3Apps_WinAppSDKLoc_$(var.IdSafeLanguage)_XamlPhoneMui_File" Source="$(var.BinDir)WinUI3Apps\$(var.Language)\Microsoft.UI.Xaml.Phone.dll.mui" /> @@ -332,9 +327,9 @@ <?undef IdSafeLanguage?> <?undef CompGUIDPrefix?> <?endforeach?> - <Component Id="RemoveWinAppSDKFolder" Guid="1BBAA49F-3B2E-455C-A615-EEB079CB9A8B" Directory="WinUI3AppsInstallFolder" > + <Component Id="RemoveWinAppSDKFolder" Guid="1BBAA49F-3B2E-455C-A615-EEB079CB9A8B" Directory="WinUI3AppsInstallFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveWinAppSDKFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveWinAppSDKFolder" Value="" KeyPath="yes" /> </RegistryKey> <?foreach Language in $(var.WinAppSDKLocLanguageList)?> <?if $(var.Language) = af-ZA?> @@ -452,11 +447,11 @@ <?else?> <?define IdSafeLanguage = $(var.Language)?> <?endif?> - <RemoveFolder Id="RemoveFolderWinAppSDKLoc$(var.IdSafeLanguage)WinUI3AppsInstallFolder" Directory="WinAppSDKLoc$(var.IdSafeLanguage)WinUI3AppsInstallFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderWinAppSDKLoc$(var.IdSafeLanguage)WinUI3AppsInstallFolder" Directory="WinAppSDKLoc$(var.IdSafeLanguage)WinUI3AppsInstallFolder" On="uninstall" /> <?undef IdSafeLanguage?> <?endforeach?> - <RemoveFolder Id="RemoveFolderWinUI3AppsMicrosoftUIXamlInstallFolder" Directory="WinUI3AppsMicrosoftUIXamlInstallFolder" On="uninstall"/> - <RemoveFolder Id="RemoveFolderWinUI3AppsMicrosoftUIXamlAssetsInstallFolder" Directory="WinUI3AppsMicrosoftUIXamlAssetsInstallFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderWinUI3AppsMicrosoftUIXamlInstallFolder" Directory="WinUI3AppsMicrosoftUIXamlInstallFolder" On="uninstall" /> + <RemoveFolder Id="RemoveFolderWinUI3AppsMicrosoftUIXamlAssetsInstallFolder" Directory="WinUI3AppsMicrosoftUIXamlAssetsInstallFolder" On="uninstall" /> </Component> <ComponentRef Id="WinUI3AppsMicrosoftUIXamlAssets_NoiseAsset_256x256_PNG.png" /> <ComponentRef Id="WinUI3AppsMicrosoftUIXamlAssets_map.html" /> diff --git a/installer/PowerToysSetup/WinUI3Applications.wxs b/installer/PowerToysSetupVNext/WinUI3Applications.wxs similarity index 73% rename from installer/PowerToysSetup/WinUI3Applications.wxs rename to installer/PowerToysSetupVNext/WinUI3Applications.wxs index 742f3dcf80..4c177b960a 100644 --- a/installer/PowerToysSetup/WinUI3Applications.wxs +++ b/installer/PowerToysSetupVNext/WinUI3Applications.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> diff --git a/installer/PowerToysSetup/Workspaces.wxs b/installer/PowerToysSetupVNext/Workspaces.wxs similarity index 77% rename from installer/PowerToysSetup/Workspaces.wxs rename to installer/PowerToysSetupVNext/Workspaces.wxs index 4237aab945..86825ac393 100644 --- a/installer/PowerToysSetup/Workspaces.wxs +++ b/installer/PowerToysSetupVNext/Workspaces.wxs @@ -1,6 +1,4 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" - xmlns:util="http://schemas.microsoft.com/wix/UtilExtension" > +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <?include $(sys.CURRENTDIR)\Common.wxi?> @@ -18,12 +16,12 @@ </DirectoryRef> <!-- Workspaces --> - <ComponentGroup Id="WorkspacesComponentGroup" > - <Component Id="RemoveWorkspacesAssetsFolder" Guid="34FC1245-1197-4025-9CF1-A298D509C2CC" Directory="WorkspacesAssetsInstallFolder" > + <ComponentGroup Id="WorkspacesComponentGroup"> + <Component Id="RemoveWorkspacesAssetsFolder" Guid="34FC1245-1197-4025-9CF1-A298D509C2CC" Directory="WorkspacesAssetsInstallFolder"> <RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> - <RegistryValue Type="string" Name="RemoveWorkspacesAssetsFolder" Value="" KeyPath="yes"/> + <RegistryValue Type="string" Name="RemoveWorkspacesAssetsFolder" Value="" KeyPath="yes" /> </RegistryKey> - <RemoveFolder Id="RemoveFolderWorkspacesAssetsFolder" Directory="WorkspacesAssetsInstallFolder" On="uninstall"/> + <RemoveFolder Id="RemoveFolderWorkspacesAssetsFolder" Directory="WorkspacesAssetsInstallFolder" On="uninstall" /> </Component> </ComponentGroup> diff --git a/installer/PowerToysSetup/generateAllFileComponents.ps1 b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 similarity index 83% rename from installer/PowerToysSetup/generateAllFileComponents.ps1 rename to installer/PowerToysSetupVNext/generateAllFileComponents.ps1 index 3592b14362..2ada2b17d1 100644 --- a/installer/PowerToysSetup/generateAllFileComponents.ps1 +++ b/installer/PowerToysSetupVNext/generateAllFileComponents.ps1 @@ -1,9 +1,7 @@ [CmdletBinding()] Param( [Parameter(Mandatory = $True, Position = 1)] - [string]$platform, - [Parameter(Mandatory = $False, Position = 2)] - [string]$installscopeperuser = "false" + [string]$platform ) Function Generate-FileList() { @@ -77,9 +75,7 @@ Function Generate-FileComponents() { [Parameter(Mandatory = $True, Position = 1)] [string]$fileListName, [Parameter(Mandatory = $True, Position = 2)] - [string]$wxsFilePath, - [Parameter(Mandatory = $True, Position = 3)] - [string]$regroot + [string]$wxsFilePath ) $wxsFile = Get-Content $wxsFilePath; @@ -99,8 +95,8 @@ Function Generate-FileComponents() { $componentDefs = "`r`n" $componentDefs += @" - <Component Id="$($componentId)" Win64="yes" Guid="$((New-Guid).ToString().ToUpper())"> - <RegistryKey Root="$($regroot)" Key="Software\Classes\powertoys\components"> + <Component Id="$($componentId)" Guid="$((New-Guid).ToString().ToUpper())"> + <RegistryKey Root="`$(var.RegistryScope)" Key="Software\Classes\powertoys\components"> <RegistryValue Type="string" Name="$($componentId)" Value="" KeyPath="yes"/> </RegistryKey>`r`n "@ @@ -134,190 +130,198 @@ if ($platform -ceq "arm64") { $platform = "ARM64" } -if ($installscopeperuser -eq "true") { - $registryroot = "HKCU" -} else { - $registryroot = "HKLM" -} - #BaseApplications Generate-FileList -fileDepsJson "" -fileListName BaseApplicationsFiles -wxsFilePath $PSScriptRoot\BaseApplications.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release" -Generate-FileComponents -fileListName "BaseApplicationsFiles" -wxsFilePath $PSScriptRoot\BaseApplications.wxs -regroot $registryroot +Generate-FileComponents -fileListName "BaseApplicationsFiles" -wxsFilePath $PSScriptRoot\BaseApplications.wxs #WinUI3Applications Generate-FileList -fileDepsJson "" -fileListName WinUI3ApplicationsFiles -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps" -Generate-FileComponents -fileListName "WinUI3ApplicationsFiles" -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WinUI3ApplicationsFiles" -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs #AdvancedPaste Generate-FileList -fileDepsJson "" -fileListName AdvancedPasteAssetsFiles -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\AdvancedPaste" -Generate-FileComponents -fileListName "AdvancedPasteAssetsFiles" -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs -regroot $registryroot +Generate-FileComponents -fileListName "AdvancedPasteAssetsFiles" -wxsFilePath $PSScriptRoot\AdvancedPaste.wxs #AwakeFiles Generate-FileList -fileDepsJson "" -fileListName AwakeImagesFiles -wxsFilePath $PSScriptRoot\Awake.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Awake" -Generate-FileComponents -fileListName "AwakeImagesFiles" -wxsFilePath $PSScriptRoot\Awake.wxs -regroot $registryroot +Generate-FileComponents -fileListName "AwakeImagesFiles" -wxsFilePath $PSScriptRoot\Awake.wxs #ColorPicker Generate-FileList -fileDepsJson "" -fileListName ColorPickerAssetsFiles -wxsFilePath $PSScriptRoot\ColorPicker.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\ColorPicker" -Generate-FileComponents -fileListName "ColorPickerAssetsFiles" -wxsFilePath $PSScriptRoot\ColorPicker.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ColorPickerAssetsFiles" -wxsFilePath $PSScriptRoot\ColorPicker.wxs #Environment Variables Generate-FileList -fileDepsJson "" -fileListName EnvironmentVariablesAssetsFiles -wxsFilePath $PSScriptRoot\EnvironmentVariables.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\EnvironmentVariables" -Generate-FileComponents -fileListName "EnvironmentVariablesAssetsFiles" -wxsFilePath $PSScriptRoot\EnvironmentVariables.wxs -regroot $registryroot +Generate-FileComponents -fileListName "EnvironmentVariablesAssetsFiles" -wxsFilePath $PSScriptRoot\EnvironmentVariables.wxs #FileExplorerAdd-ons Generate-FileList -fileDepsJson "" -fileListName MonacoPreviewHandlerMonacoAssetsFiles -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Monaco" Generate-FileList -fileDepsJson "" -fileListName MonacoPreviewHandlerCustomLanguagesFiles -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Monaco\customLanguages" -Generate-FileComponents -fileListName "MonacoPreviewHandlerMonacoAssetsFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -regroot $registryroot -Generate-FileComponents -fileListName "MonacoPreviewHandlerCustomLanguagesFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs -regroot $registryroot +Generate-FileComponents -fileListName "MonacoPreviewHandlerMonacoAssetsFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs +Generate-FileComponents -fileListName "MonacoPreviewHandlerCustomLanguagesFiles" -wxsFilePath $PSScriptRoot\FileExplorerPreview.wxs #FileLocksmith Generate-FileList -fileDepsJson "" -fileListName FileLocksmithAssetsFiles -wxsFilePath $PSScriptRoot\FileLocksmith.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\FileLocksmith" -Generate-FileComponents -fileListName "FileLocksmithAssetsFiles" -wxsFilePath $PSScriptRoot\FileLocksmith.wxs -regroot $registryroot +Generate-FileComponents -fileListName "FileLocksmithAssetsFiles" -wxsFilePath $PSScriptRoot\FileLocksmith.wxs #Hosts Generate-FileList -fileDepsJson "" -fileListName HostsAssetsFiles -wxsFilePath $PSScriptRoot\Hosts.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Hosts" -Generate-FileComponents -fileListName "HostsAssetsFiles" -wxsFilePath $PSScriptRoot\Hosts.wxs -regroot $registryroot +Generate-FileComponents -fileListName "HostsAssetsFiles" -wxsFilePath $PSScriptRoot\Hosts.wxs #ImageResizer -Generate-FileList -fileDepsJson "" -fileListName ImageResizerAssetsFiles -wxsFilePath $PSScriptRoot\ImageResizer.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\ImageResizer" -Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PSScriptRoot\ImageResizer.wxs -regroot $registryroot +Generate-FileList -fileDepsJson "" -fileListName ImageResizerAssetsFiles -wxsFilePath $PSScriptRoot\ImageResizer.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\ImageResizer" +Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PSScriptRoot\ImageResizer.wxs + +# Light Switch Service +Generate-FileList -fileDepsJson "" -fileListName LightSwitchFiles -wxsFilePath $PSScriptRoot\LightSwitch.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\LightSwitchService" +Generate-FileComponents -fileListName "LightSwitchFiles" -wxsFilePath $PSScriptRoot\LightSwitch.wxs + +#PowerDisplay +Generate-FileList -fileDepsJson "" -fileListName PowerDisplayAssetsFiles -wxsFilePath $PSScriptRoot\PowerDisplay.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerDisplay" +Generate-FileComponents -fileListName "PowerDisplayAssetsFiles" -wxsFilePath $PSScriptRoot\PowerDisplay.wxs #New+ Generate-FileList -fileDepsJson "" -fileListName NewPlusAssetsFiles -wxsFilePath $PSScriptRoot\NewPlus.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\NewPlus" -Generate-FileComponents -fileListName "NewPlusAssetsFiles" -wxsFilePath $PSScriptRoot\NewPlus.wxs -regroot $registryroot +Generate-FileComponents -fileListName "NewPlusAssetsFiles" -wxsFilePath $PSScriptRoot\NewPlus.wxs #Peek Generate-FileList -fileDepsJson "" -fileListName PeekAssetsFiles -wxsFilePath $PSScriptRoot\Peek.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Peek\" -Generate-FileComponents -fileListName "PeekAssetsFiles" -wxsFilePath $PSScriptRoot\Peek.wxs -regroot $registryroot +Generate-FileComponents -fileListName "PeekAssetsFiles" -wxsFilePath $PSScriptRoot\Peek.wxs #PowerRename Generate-FileList -fileDepsJson "" -fileListName PowerRenameAssetsFiles -wxsFilePath $PSScriptRoot\PowerRename.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerRename\" -Generate-FileComponents -fileListName "PowerRenameAssetsFiles" -wxsFilePath $PSScriptRoot\PowerRename.wxs -regroot $registryroot +Generate-FileComponents -fileListName "PowerRenameAssetsFiles" -wxsFilePath $PSScriptRoot\PowerRename.wxs #RegistryPreview Generate-FileList -fileDepsJson "" -fileListName RegistryPreviewAssetsFiles -wxsFilePath $PSScriptRoot\RegistryPreview.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\RegistryPreview\" -Generate-FileComponents -fileListName "RegistryPreviewAssetsFiles" -wxsFilePath $PSScriptRoot\RegistryPreview.wxs -regroot $registryroot +Generate-FileComponents -fileListName "RegistryPreviewAssetsFiles" -wxsFilePath $PSScriptRoot\RegistryPreview.wxs #Run Generate-FileList -fileDepsJson "" -fileListName launcherImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\PowerLauncher" -Generate-FileComponents -fileListName "launcherImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "launcherImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ## Plugins ###Calculator Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Calculator\Microsoft.PowerToys.Run.Plugin.Calculator.deps.json" -fileListName calcComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName calcImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Calculator\Images" -Generate-FileComponents -fileListName "calcComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "calcImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "calcComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "calcImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Folder Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Folder\Microsoft.Plugin.Folder.deps.json" -fileListName FolderComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName FolderImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Folder\Images" -Generate-FileComponents -fileListName "FolderComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "FolderImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "FolderComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "FolderImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Program Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Program\Microsoft.Plugin.Program.deps.json" -fileListName ProgramComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName ProgramImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Program\Images" -Generate-FileComponents -fileListName "ProgramComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ProgramImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ProgramComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "ProgramImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Shell Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Shell\Microsoft.Plugin.Shell.deps.json" -fileListName ShellComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName ShellImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Shell\Images" -Generate-FileComponents -fileListName "ShellComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ShellImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ShellComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "ShellImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Indexer Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Indexer\Microsoft.Plugin.Indexer.deps.json" -fileListName IndexerComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName IndexerImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Indexer\Images" -Generate-FileComponents -fileListName "IndexerComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "IndexerImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "IndexerComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "IndexerImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###UnitConverter Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\UnitConverter\Community.PowerToys.Run.Plugin.UnitConverter.deps.json" -fileListName UnitConvCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName UnitConvImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\UnitConverter\Images" -Generate-FileComponents -fileListName "UnitConvCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "UnitConvImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "UnitConvCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "UnitConvImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###WebSearch Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WebSearch\Community.PowerToys.Run.Plugin.WebSearch.deps.json" -fileListName WebSrchCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName WebSrchImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WebSearch\Images" -Generate-FileComponents -fileListName "WebSrchCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WebSrchImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WebSrchCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "WebSrchImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###History Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\History\Microsoft.PowerToys.Run.Plugin.History.deps.json" -fileListName HistoryPluginComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName HistoryPluginImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\History\Images" -Generate-FileComponents -fileListName "HistoryPluginComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "HistoryPluginImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "HistoryPluginComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "HistoryPluginImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Uri Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Uri\Microsoft.Plugin.Uri.deps.json" -fileListName UriComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName UriImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Uri\Images" -Generate-FileComponents -fileListName "UriComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "UriImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "UriComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "UriImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###VSCodeWorkspaces Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\VSCodeWorkspaces\Community.PowerToys.Run.Plugin.VSCodeWorkspaces.deps.json" -fileListName VSCWrkCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName VSCWrkImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\VSCodeWorkspaces\Images" -Generate-FileComponents -fileListName "VSCWrkCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "VSCWrkImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "VSCWrkCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "VSCWrkImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###WindowWalker Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowWalker\Microsoft.Plugin.WindowWalker.deps.json" -fileListName WindowWlkrCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName WindowWlkrImagesCompFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowWalker\Images" -Generate-FileComponents -fileListName "WindowWlkrCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WindowWlkrImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WindowWlkrCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "WindowWlkrImagesCompFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###OneNote Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\OneNote\Microsoft.PowerToys.Run.Plugin.OneNote.deps.json" -fileListName OneNoteComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName OneNoteImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\OneNote\Images" -Generate-FileComponents -fileListName "OneNoteComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "OneNoteImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "OneNoteComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "OneNoteImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Registry Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Registry\Microsoft.PowerToys.Run.Plugin.Registry.deps.json" -fileListName RegistryComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName RegistryImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Registry\Images" -Generate-FileComponents -fileListName "RegistryComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "RegistryImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "RegistryComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "RegistryImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###Service Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Service\Microsoft.PowerToys.Run.Plugin.Service.deps.json" -fileListName ServiceComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName ServiceImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\Service\Images" -Generate-FileComponents -fileListName "ServiceComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ServiceImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ServiceComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "ServiceImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###System Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\System\Microsoft.PowerToys.Run.Plugin.System.deps.json" -fileListName SystemComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName SystemImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\System\Images" -Generate-FileComponents -fileListName "SystemComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SystemImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "SystemComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "SystemImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###TimeDate Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\TimeDate\Microsoft.PowerToys.Run.Plugin.TimeDate.deps.json" -fileListName TimeDateComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName TimeDateImagesComponentFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\TimeDate\Images" -Generate-FileComponents -fileListName "TimeDateComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "TimeDateImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "TimeDateComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "TimeDateImagesComponentFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###WindowsSettings Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsSettings\Microsoft.PowerToys.Run.Plugin.WindowsSettings.deps.json" -fileListName WinSetCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName WinSetImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsSettings\Images" -Generate-FileComponents -fileListName "WinSetCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WinSetImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WinSetCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "WinSetImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###WindowsTerminal Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsTerminal\Microsoft.PowerToys.Run.Plugin.WindowsTerminal.deps.json" -fileListName WinTermCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName WinTermImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\WindowsTerminal\Images" -Generate-FileComponents -fileListName "WinTermCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "WinTermImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WinTermCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "WinTermImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###PowerToys Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\PowerToys\Microsoft.PowerToys.Run.Plugin.PowerToys.deps.json" -fileListName PowerToysCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName PowerToysImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\PowerToys\Images" -Generate-FileComponents -fileListName "PowerToysCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "PowerToysImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "PowerToysCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "PowerToysImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs ###ValueGenerator Generate-FileList -fileDepsJson "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\ValueGenerator\Community.PowerToys.Run.Plugin.ValueGenerator.deps.json" -fileListName ValueGeneratorCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -isLauncherPlugin 1 Generate-FileList -fileDepsJson "" -fileListName ValueGeneratorImagesCmpFiles -wxsFilePath $PSScriptRoot\Run.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\RunPlugins\ValueGenerator\Images" -Generate-FileComponents -fileListName "ValueGeneratorCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot -Generate-FileComponents -fileListName "ValueGeneratorImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ValueGeneratorCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs +Generate-FileComponents -fileListName "ValueGeneratorImagesCmpFiles" -wxsFilePath $PSScriptRoot\Run.wxs ## Plugins #ShortcutGuide Generate-FileList -fileDepsJson "" -fileListName ShortcutGuideSvgFiles -wxsFilePath $PSScriptRoot\ShortcutGuide.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\ShortcutGuide\" -Generate-FileComponents -fileListName "ShortcutGuideSvgFiles" -wxsFilePath $PSScriptRoot\ShortcutGuide.wxs -regroot $registryroot +Generate-FileComponents -fileListName "ShortcutGuideSvgFiles" -wxsFilePath $PSScriptRoot\ShortcutGuide.wxs #Settings Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2AssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsModulesFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Modules\OOBE\" Generate-FileList -fileDepsJson "" -fileListName SettingsV2OOBEAssetsFluentIconsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Icons\" -Generate-FileComponents -fileListName "SettingsV2AssetsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SettingsV2AssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SettingsV2OOBEAssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot -Generate-FileComponents -fileListName "SettingsV2OOBEAssetsFluentIconsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs -regroot $registryroot +Generate-FileList -fileDepsJson "" -fileListName SettingsV2IconsModelsFiles -wxsFilePath $PSScriptRoot\Settings.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\Settings\Icons\Models\" +Generate-FileComponents -fileListName "SettingsV2AssetsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2AssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2OOBEAssetsModulesFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2OOBEAssetsFluentIconsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs +Generate-FileComponents -fileListName "SettingsV2IconsModelsFiles" -wxsFilePath $PSScriptRoot\Settings.wxs #Workspaces Generate-FileList -fileDepsJson "" -fileListName WorkspacesImagesComponentFiles -wxsFilePath $PSScriptRoot\Workspaces.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\Workspaces\" -Generate-FileComponents -fileListName "WorkspacesImagesComponentFiles" -wxsFilePath $PSScriptRoot\Workspaces.wxs -regroot $registryroot +Generate-FileComponents -fileListName "WorkspacesImagesComponentFiles" -wxsFilePath $PSScriptRoot\Workspaces.wxs + +#DSC Resources - JSON manifest files in DSCModules subfolder +Generate-FileList -fileDepsJson "" -fileListName DscJsonFiles -wxsFilePath $PSScriptRoot\DscResources.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\DSCModules\" +Generate-FileComponents -fileListName "DscJsonFiles" -wxsFilePath $PSScriptRoot\DscResources.wxs diff --git a/installer/PowerToysSetup/generateMonacoWxs.ps1 b/installer/PowerToysSetupVNext/generateMonacoWxs.ps1 similarity index 61% rename from installer/PowerToysSetup/generateMonacoWxs.ps1 rename to installer/PowerToysSetupVNext/generateMonacoWxs.ps1 index 94536618da..da2db6ae80 100644 --- a/installer/PowerToysSetup/generateMonacoWxs.ps1 +++ b/installer/PowerToysSetupVNext/generateMonacoWxs.ps1 @@ -1,9 +1,36 @@ [CmdletBinding()] Param( [Parameter(Mandatory = $True, Position = 1)] - [string]$monacoWxsFile + [string]$monacoWxsFile, + [Parameter(Mandatory = $True, Position = 2)] + [string]$platform, + [Parameter(Mandatory = $True, Position = 3)] + [string]$nugetHeatPath ) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +if ($platform -eq "x64") { + $HeatPath = Join-Path $nugetHeatPath "tools\net472\x64" +} else { + $HeatPath = Join-Path $nugetHeatPath "tools\net472\x86" +} + +# Validate heat.exe exists at the resolved path; fail fast if not found. +$heatExe = Join-Path $HeatPath "heat.exe" +if (-not (Test-Path $heatExe)) { + Write-Error "heat.exe not found at '$heatExe'. Ensure the WixToolset.Heat package (5.0.2) is restored under '$nugetHeatPath'." + exit 1 +} + +$SourceDir = Join-Path $scriptDir "..\..\src\Monaco\monacoSRC" # Now relative to script location +$OutputFile = Join-Path $scriptDir "MonacoSRC.wxs" +$ComponentGroup = "MonacoSRCHeatGenerated" +$DirectoryRef = "MonacoPreviewHandlerMonacoSRCFolder" +$Variable = "var.MonacoSRCHarvestPath" + +& $heatExe dir "$SourceDir" -out "$OutputFile" -cg "$ComponentGroup" -dr "$DirectoryRef" -var "$Variable" -gg -srd -nologo + $fileWxs = Get-Content $monacoWxsFile; $fileWxs = $fileWxs -replace " KeyPath=`"yes`" ", " " diff --git a/installer/PowerToysSetup/packages.config b/installer/PowerToysSetupVNext/packages.config similarity index 100% rename from installer/PowerToysSetup/packages.config rename to installer/PowerToysSetupVNext/packages.config diff --git a/installer/PowerToysSetup/publish.cmd b/installer/PowerToysSetupVNext/publish.cmd similarity index 79% rename from installer/PowerToysSetup/publish.cmd rename to installer/PowerToysSetupVNext/publish.cmd index 18fa40b4aa..f61668cffe 100644 --- a/installer/PowerToysSetup/publish.cmd +++ b/installer/PowerToysSetupVNext/publish.cmd @@ -8,10 +8,10 @@ SET VCToolsVersion=!VCToolsVersion! SET ClearDevCommandPromptEnvVars=false rem In case of Release we should not use Debug CRT in VCRT forwarders -msbuild !PTRoot!\src\modules\previewpane\MonacoPreviewHandler\MonacoPreviewHandler.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml +msbuild !PTRoot!\src\modules\previewpane\MonacoPreviewHandler\MonacoPreviewHandler.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml -p:TargetFramework=net9.0-windows10.0.26100.0 -msbuild !PTRoot!\src\modules\previewpane\MarkdownPreviewHandler\MarkdownPreviewHandler.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml +msbuild !PTRoot!\src\modules\previewpane\MarkdownPreviewHandler\MarkdownPreviewHandler.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml -p:TargetFramework=net9.0-windows10.0.26100.0 -msbuild !PTRoot!\src\modules\previewpane\SvgPreviewHandler\SvgPreviewHandler.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml +msbuild !PTRoot!\src\modules\previewpane\SvgPreviewHandler\SvgPreviewHandler.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml -p:TargetFramework=net9.0-windows10.0.26100.0 -msbuild !PTRoot!\src\modules\previewpane\SvgThumbnailProvider\SvgThumbnailProvider.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml +msbuild !PTRoot!\src\modules\previewpane\SvgThumbnailProvider\SvgThumbnailProvider.csproj -t:Publish -p:Configuration="Release" -p:Platform="!PlatformArg!" -p:AppxBundle=Never -p:PowerToysRoot=!PTRoot! -p:VCRTForwarders-IncludeDebugCRT=false -p:PublishProfile=InstallationPublishProfile.pubxml -p:TargetFramework=net9.0-windows10.0.26100.0 \ No newline at end of file diff --git a/installer/PowerToysSetup/terminate_powertoys.cmd b/installer/PowerToysSetupVNext/terminate_powertoys.cmd similarity index 100% rename from installer/PowerToysSetup/terminate_powertoys.cmd rename to installer/PowerToysSetupVNext/terminate_powertoys.cmd diff --git a/installer/wix.props b/installer/wix.props deleted file mode 100644 index d33624a8c7..0000000000 --- a/installer/wix.props +++ /dev/null @@ -1,14 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <WixInstallPath>C:\Program Files (x86)\WiX Toolset v3.14\bin\</WixInstallPath> - <WixExtDir>$(WixInstallPath)\</WixExtDir> - - <WixTargetsPath>$(WixInstallPath)\..\wix.targets</WixTargetsPath> - <LuxTargetsPath>$(WixInstallPath)\..\lux.targets</LuxTargetsPath> - - <WixTasksPath>$(WixInstallPath)\WixTasks.dll</WixTasksPath> - <WixSdkPath>$(WixInstallPath)\..\sdk\</WixSdkPath> - <WixCATargetsPath>$(WixSdkPath)\..\wix.ca.targets</WixCATargetsPath> - </PropertyGroup> -</Project> \ No newline at end of file diff --git a/nuget.config b/nuget.config index ff82043afa..c9ab0b2a86 100644 --- a/nuget.config +++ b/nuget.config @@ -2,11 +2,11 @@ <configuration> <packageSources> <clear /> - <add key="nuget.org" value="https://api.nuget.org/v3/index.json" /> + <add key="PowerToysPublicDependencies" value="https://pkgs.dev.azure.com/shine-oss/PowerToys/_packaging/PowerToysPublicDependencies/nuget/v3/index.json" /> </packageSources> <packageSourceMapping> - <packageSource key="nuget.org"> + <packageSource key="PowerToysPublicDependencies"> <package pattern="*" /> </packageSource> </packageSourceMapping> -</configuration> +</configuration> \ No newline at end of file diff --git a/packages.config b/packages.config index 65d836456e..0ae124b7c1 100644 --- a/packages.config +++ b/packages.config @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.MSBuildCache.AzurePipelines" version="0.1.283-preview" /> - <package id="Microsoft.MSBuildCache.Local" version="0.1.283-preview" /> - <package id="Microsoft.MSBuildCache.SharedCompilation" version="0.1.283-preview" /> + <package id="Microsoft.MSBuildCache.AzurePipelines" version="0.1.318-preview" /> + <package id="Microsoft.MSBuildCache.Local" version="0.1.318-preview" /> + <package id="Microsoft.MSBuildCache.SharedCompilation" version="0.1.318-preview" /> </packages> \ No newline at end of file diff --git a/src/ActionRunner/actionRunner.vcxproj b/src/ActionRunner/actionRunner.vcxproj index cc41616bba..553939cc80 100644 --- a/src/ActionRunner/actionRunner.vcxproj +++ b/src/ActionRunner/actionRunner.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h actionRunner.base.rc actionRunner.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h actionRunner.base.rc actionRunner.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> @@ -10,11 +11,10 @@ <RootNamespace>actionRunner</RootNamespace> <ProjectName>PowerToys.ActionRunner</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> - <Import Project="..\..\deps\expected.props" /> + <Import Project="$(RepoRoot)deps\expected.props" /> <PropertyGroup> <ConfigurationType>Application</ConfigurationType> </PropertyGroup> @@ -59,17 +59,17 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/ActionRunner/packages.config b/src/ActionRunner/packages.config index ff4b059648..d3882436a5 100644 --- a/src/ActionRunner/packages.config +++ b/src/ActionRunner/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/CmdPalVersion.props b/src/CmdPalVersion.props index e9e0e98130..c3c5d7b608 100644 --- a/src/CmdPalVersion.props +++ b/src/CmdPalVersion.props @@ -1,7 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> - <CmdPalVersion>0.0.1</CmdPalVersion> + <CmdPalVersion Condition="'$(CmdPalVersion)'=='' and '$(XES_APPXMANIFESTVERSION)'!=''">$(XES_APPXMANIFESTVERSION)</CmdPalVersion> + + <!-- MIKE: The file you're looking for is src/modules/cmdpal/custom.props --> + <CmdPalVersion Condition="'$(CmdPalVersion)'==''">0.0.1.0</CmdPalVersion> + <DevEnvironment>Local</DevEnvironment> <!-- Forcing for every DLL on by default --> diff --git a/src/Common.Dotnet.AotCompatibility.props b/src/Common.Dotnet.AotCompatibility.props index 71c490fd6c..bebb88428c 100644 --- a/src/Common.Dotnet.AotCompatibility.props +++ b/src/Common.Dotnet.AotCompatibility.props @@ -7,6 +7,7 @@ <CsWinRTAotWarningLevel>2</CsWinRTAotWarningLevel> <!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection --> - <WarningsNotAsErrors>IL2081</WarningsNotAsErrors> + <!-- Suppress CA1416 for Windows-specific APIs that are used in PowerToys which only runs on Windows 10.0.19041.0+ --> + <WarningsNotAsErrors>IL2081;CsWinRT1028;CA1416;$(WarningsNotAsErrors)</WarningsNotAsErrors> </PropertyGroup> </Project> diff --git a/src/Common.Dotnet.CsWinRT.props b/src/Common.Dotnet.CsWinRT.props index e4731ce2fd..63b40dc66a 100644 --- a/src/Common.Dotnet.CsWinRT.props +++ b/src/Common.Dotnet.CsWinRT.props @@ -1,9 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> <!-- Some items may be set in Directory.Build.props in root --> <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project=".\Common.Dotnet.PrepareGeneratedFolder.targets" /> + <PropertyGroup> - <WindowsSdkPackageVersion>10.0.22621.57</WindowsSdkPackageVersion> - <TargetFramework>net9.0-windows10.0.22621.0</TargetFramework> + <WindowsSdkPackageVersion>10.0.26100.68-preview</WindowsSdkPackageVersion> + <TargetFramework>net9.0-windows10.0.26100.0</TargetFramework> <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion> <SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion> <RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers> @@ -14,13 +16,13 @@ <WarningLevel>4</WarningLevel> <NoWarn></NoWarn> <TreatWarningsAsErrors>True</TreatWarningsAsErrors> - <WarningsNotAsErrors>CA1720;CA1859;CA2263;CA2022;MVVMTK0045;MVVMTK0049</WarningsNotAsErrors> + <WarningsNotAsErrors>CA1824;CA1416;CA1720;CA1859;CA2263;CA2022;MVVMTK0045;MVVMTK0049</WarningsNotAsErrors> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)' == 'Debug'"> <DebugSymbols>true</DebugSymbols> <DefineConstants>DEBUG;TRACE</DefineConstants> - <DebugType>full</DebugType> + <DebugType>portable</DebugType> <Optimize>false</Optimize> </PropertyGroup> @@ -41,4 +43,4 @@ <Analyzer Remove="@(Analyzer)" Condition="%(Analyzer.NuGetPackageId) == 'Microsoft.Windows.CsWinRT'" />    </ItemGroup> </Target> -</Project> \ No newline at end of file +</Project> diff --git a/src/Common.Dotnet.FuzzTest.props b/src/Common.Dotnet.FuzzTest.props new file mode 100644 index 0000000000..b3dbb32758 --- /dev/null +++ b/src/Common.Dotnet.FuzzTest.props @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Some items may be set in Directory.Build.props in root --> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <!-- OneFuzz does not currently support testing with .NET 9. + As a temporary workaround, create a .NET 8 project and use file links + to include the code that needs testing. --> + <PropertyGroup> + <TargetFramework>net8.0-windows10.0.26100.0</TargetFramework> + </PropertyGroup> +</Project> diff --git a/src/Common.Dotnet.PrepareGeneratedFolder.targets b/src/Common.Dotnet.PrepareGeneratedFolder.targets new file mode 100644 index 0000000000..d017590064 --- /dev/null +++ b/src/Common.Dotnet.PrepareGeneratedFolder.targets @@ -0,0 +1,16 @@ +<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + + <Target Name="EnsureGeneratedBaseFolder" BeforeTargets="XamlPreCompile"> + <PropertyGroup> + <!-- Only create the base 'generated' folder --> + <CompilerGeneratedFilesOutputPath>$(ProjectDir)obj\g</CompilerGeneratedFilesOutputPath> + </PropertyGroup> + + <!-- Create 'generated' folder if missing --> + <MakeDir Directories="$(CompilerGeneratedFilesOutputPath)" /> + + <!-- Optional logging for debugging --> + <Message Text="Ensured: $(GeneratedBasePath)" Importance="Low" /> + </Target> + +</Project> \ No newline at end of file diff --git a/src/Monaco/monacoSpecialLanguages.js b/src/Monaco/monacoSpecialLanguages.js index 5a76713a66..56be3cc9b9 100644 --- a/src/Monaco/monacoSpecialLanguages.js +++ b/src/Monaco/monacoSpecialLanguages.js @@ -7,7 +7,7 @@ import { srtDefinition } from './customLanguages/srt.js'; export async function registerAdditionalLanguages(monaco){ await languageDefinitions(); registerAdditionalLanguage("cppExt", [".ino", ".pde"], "cpp", monaco); - registerAdditionalLanguage("xmlExt", [".wsdl", ".csproj", ".vcxproj", ".vbproj", ".fsproj", ".resx", ".resw"], "xml", monaco); + registerAdditionalLanguage("xmlExt", [".wsdl", ".projitems", ".csproj", ".fsproj", ".shproj", ".vcxproj", ".vbproj", ".resx", ".resw"], "xml", monaco); registerAdditionalLanguage("txtExt", [".sln", ".log", ".vsconfig", ".env", ".ahk", ".ion"], "txt", monaco); registerAdditionalLanguage("razorExt", [".razor"], "razor", monaco); registerAdditionalLanguage("vbExt", [".vbs"], "vb", monaco); diff --git a/src/Monaco/monaco_languages.json b/src/Monaco/monaco_languages.json index 8f0408dc9b..7e516deb9e 100644 --- a/src/Monaco/monaco_languages.json +++ b/src/Monaco/monaco_languages.json @@ -1 +1 @@ -{"list":[{"id":"plaintext","extensions":[".txt"],"aliases":["Plain Text","text"],"mimetypes":["text/plain"]},{"id":"abap","extensions":[".abap"],"aliases":["abap","ABAP"]},{"id":"apex","extensions":[".cls"],"aliases":["Apex","apex"],"mimetypes":["text/x-apex-source","text/x-apex"]},{"id":"azcli","extensions":[".azcli"],"aliases":["Azure CLI","azcli"]},{"id":"bat","extensions":[".bat",".cmd"],"aliases":["Batch","bat"]},{"id":"bicep","extensions":[".bicep"],"aliases":["Bicep"]},{"id":"cameligo","extensions":[".mligo"],"aliases":["Cameligo"]},{"id":"clojure","extensions":[".clj",".cljs",".cljc",".edn"],"aliases":["clojure","Clojure"]},{"id":"coffeescript","extensions":[".coffee"],"aliases":["CoffeeScript","coffeescript","coffee"],"mimetypes":["text/x-coffeescript","text/coffeescript"]},{"id":"c","extensions":[".c",".h"],"aliases":["C","c"]},{"id":"cpp","extensions":[".cpp",".cc",".cxx",".hpp",".hh",".hxx"],"aliases":["C++","Cpp","cpp"]},{"id":"csharp","extensions":[".cs",".csx",".cake"],"aliases":["C#","csharp"]},{"id":"csp","extensions":[],"aliases":["CSP","csp"]},{"id":"css","extensions":[".css"],"aliases":["CSS","css"],"mimetypes":["text/css"]},{"id":"cypher","extensions":[".cypher",".cyp"],"aliases":["Cypher","OpenCypher"]},{"id":"dart","extensions":[".dart"],"aliases":["Dart","dart"],"mimetypes":["text/x-dart-source","text/x-dart"]},{"id":"dockerfile","extensions":[".dockerfile"],"filenames":["Dockerfile"],"aliases":["Dockerfile"]},{"id":"ecl","extensions":[".ecl"],"aliases":["ECL","Ecl","ecl"]},{"id":"elixir","extensions":[".ex",".exs"],"aliases":["Elixir","elixir","ex"]},{"id":"flow9","extensions":[".flow"],"aliases":["Flow9","Flow","flow9","flow"]},{"id":"fsharp","extensions":[".fs",".fsi",".ml",".mli",".fsx",".fsscript"],"aliases":["F#","FSharp","fsharp"]},{"id":"freemarker2","extensions":[".ftl",".ftlh",".ftlx"],"aliases":["FreeMarker2","Apache FreeMarker2"]},{"id":"freemarker2.tag-angle.interpolation-dollar","aliases":["FreeMarker2 (Angle/Dollar)","Apache FreeMarker2 (Angle/Dollar)"]},{"id":"freemarker2.tag-bracket.interpolation-dollar","aliases":["FreeMarker2 (Bracket/Dollar)","Apache FreeMarker2 (Bracket/Dollar)"]},{"id":"freemarker2.tag-angle.interpolation-bracket","aliases":["FreeMarker2 (Angle/Bracket)","Apache FreeMarker2 (Angle/Bracket)"]},{"id":"freemarker2.tag-bracket.interpolation-bracket","aliases":["FreeMarker2 (Bracket/Bracket)","Apache FreeMarker2 (Bracket/Bracket)"]},{"id":"freemarker2.tag-auto.interpolation-dollar","aliases":["FreeMarker2 (Auto/Dollar)","Apache FreeMarker2 (Auto/Dollar)"]},{"id":"freemarker2.tag-auto.interpolation-bracket","aliases":["FreeMarker2 (Auto/Bracket)","Apache FreeMarker2 (Auto/Bracket)"]},{"id":"go","extensions":[".go"],"aliases":["Go"]},{"id":"graphql","extensions":[".graphql",".gql"],"aliases":["GraphQL","graphql","gql"],"mimetypes":["application/graphql"]},{"id":"handlebars","extensions":[".handlebars",".hbs"],"aliases":["Handlebars","handlebars","hbs"],"mimetypes":["text/x-handlebars-template"]},{"id":"hcl","extensions":[".tf",".tfvars",".hcl"],"aliases":["Terraform","tf","HCL","hcl"]},{"id":"html","extensions":[".html",".htm",".shtml",".xhtml",".mdoc",".jsp",".asp",".aspx",".jshtm"],"aliases":["HTML","htm","html","xhtml"],"mimetypes":["text/html","text/x-jshtm","text/template","text/ng-template"]},{"id":"ini","extensions":[".ini",".properties",".gitconfig"],"filenames":["config",".gitattributes",".gitconfig",".editorconfig"],"aliases":["Ini","ini"]},{"id":"java","extensions":[".java",".jav"],"aliases":["Java","java"],"mimetypes":["text/x-java-source","text/x-java"]},{"id":"javascript","extensions":[".js",".es6",".jsx",".mjs",".cjs"],"firstLine":"^#!.*\\bnode","filenames":["jakefile"],"aliases":["JavaScript","javascript","js"],"mimetypes":["text/javascript"]},{"id":"julia","extensions":[".jl"],"aliases":["julia","Julia"]},{"id":"kotlin","extensions":[".kt",".kts"],"aliases":["Kotlin","kotlin"],"mimetypes":["text/x-kotlin-source","text/x-kotlin"]},{"id":"less","extensions":[".less"],"aliases":["Less","less"],"mimetypes":["text/x-less","text/less"]},{"id":"lexon","extensions":[".lex"],"aliases":["Lexon"]},{"id":"lua","extensions":[".lua"],"aliases":["Lua","lua"]},{"id":"liquid","extensions":[".liquid",".html.liquid"],"aliases":["Liquid","liquid"],"mimetypes":["application/liquid"]},{"id":"m3","extensions":[".m3",".i3",".mg",".ig"],"aliases":["Modula-3","Modula3","modula3","m3"]},{"id":"markdown","extensions":[".md",".markdown",".mdown",".mkdn",".mkd",".mdwn",".mdtxt",".mdtext"],"aliases":["Markdown","markdown"]},{"id":"mdx","extensions":[".mdx"],"aliases":["MDX","mdx"]},{"id":"mips","extensions":[".s"],"aliases":["MIPS","MIPS-V"],"mimetypes":["text/x-mips","text/mips","text/plaintext"]},{"id":"msdax","extensions":[".dax",".msdax"],"aliases":["DAX","MSDAX"]},{"id":"mysql","extensions":[],"aliases":["MySQL","mysql"]},{"id":"objective-c","extensions":[".m"],"aliases":["Objective-C"]},{"id":"pascal","extensions":[".pas",".p",".pp"],"aliases":["Pascal","pas"],"mimetypes":["text/x-pascal-source","text/x-pascal"]},{"id":"pascaligo","extensions":[".ligo"],"aliases":["Pascaligo","ligo"]},{"id":"perl","extensions":[".pl",".pm"],"aliases":["Perl","pl"]},{"id":"pgsql","extensions":[],"aliases":["PostgreSQL","postgres","pg","postgre"]},{"id":"php","extensions":[".php",".php4",".php5",".phtml",".ctp"],"aliases":["PHP","php"],"mimetypes":["application/x-php"]},{"id":"pla","extensions":[".pla"]},{"id":"postiats","extensions":[".dats",".sats",".hats"],"aliases":["ATS","ATS/Postiats"]},{"id":"powerquery","extensions":[".pq",".pqm"],"aliases":["PQ","M","Power Query","Power Query M"]},{"id":"powershell","extensions":[".ps1",".psm1",".psd1"],"aliases":["PowerShell","powershell","ps","ps1"]},{"id":"proto","extensions":[".proto"],"aliases":["protobuf","Protocol Buffers"]},{"id":"pug","extensions":[".jade",".pug"],"aliases":["Pug","Jade","jade"]},{"id":"python","extensions":[".py",".rpy",".pyw",".cpy",".gyp",".gypi"],"aliases":["Python","py"],"firstLine":"^#!/.*\\bpython[0-9.-]*\\b"},{"id":"qsharp","extensions":[".qs"],"aliases":["Q#","qsharp"]},{"id":"r","extensions":[".r",".rhistory",".rmd",".rprofile",".rt"],"aliases":["R","r"]},{"id":"razor","extensions":[".cshtml"],"aliases":["Razor","razor"],"mimetypes":["text/x-cshtml"]},{"id":"redis","extensions":[".redis"],"aliases":["redis"]},{"id":"redshift","extensions":[],"aliases":["Redshift","redshift"]},{"id":"restructuredtext","extensions":[".rst"],"aliases":["reStructuredText","restructuredtext"]},{"id":"ruby","extensions":[".rb",".rbx",".rjs",".gemspec",".pp"],"filenames":["rakefile","Gemfile"],"aliases":["Ruby","rb"]},{"id":"rust","extensions":[".rs",".rlib"],"aliases":["Rust","rust"]},{"id":"sb","extensions":[".sb"],"aliases":["Small Basic","sb"]},{"id":"scala","extensions":[".scala",".sc",".sbt"],"aliases":["Scala","scala","SBT","Sbt","sbt","Dotty","dotty"],"mimetypes":["text/x-scala-source","text/x-scala","text/x-sbt","text/x-dotty"]},{"id":"scheme","extensions":[".scm",".ss",".sch",".rkt"],"aliases":["scheme","Scheme"]},{"id":"scss","extensions":[".scss"],"aliases":["Sass","sass","scss"],"mimetypes":["text/x-scss","text/scss"]},{"id":"shell","extensions":[".sh",".bash"],"aliases":["Shell","sh"]},{"id":"sol","extensions":[".sol"],"aliases":["sol","solidity","Solidity"]},{"id":"aes","extensions":[".aes"],"aliases":["aes","sophia","Sophia"]},{"id":"sparql","extensions":[".rq"],"aliases":["sparql","SPARQL"]},{"id":"sql","extensions":[".sql"],"aliases":["SQL"]},{"id":"st","extensions":[".st",".iecst",".iecplc",".lc3lib",".TcPOU",".TcDUT",".TcGVL",".TcIO"],"aliases":["StructuredText","scl","stl"]},{"id":"swift","aliases":["Swift","swift"],"extensions":[".swift"],"mimetypes":["text/swift"]},{"id":"systemverilog","extensions":[".sv",".svh"],"aliases":["SV","sv","SystemVerilog","systemverilog"]},{"id":"verilog","extensions":[".v",".vh"],"aliases":["V","v","Verilog","verilog"]},{"id":"tcl","extensions":[".tcl"],"aliases":["tcl","Tcl","tcltk","TclTk","tcl/tk","Tcl/Tk"]},{"id":"twig","extensions":[".twig"],"aliases":["Twig","twig"],"mimetypes":["text/x-twig"]},{"id":"typescript","extensions":[".ts",".tsx",".cts",".mts"],"aliases":["TypeScript","ts","typescript"],"mimetypes":["text/typescript"]},{"id":"vb","extensions":[".vb"],"aliases":["Visual Basic","vb"]},{"id":"wgsl","extensions":[".wgsl"],"aliases":["WebGPU Shading Language","WGSL","wgsl"]},{"id":"xml","extensions":[".xml",".xsd",".dtd",".ascx",".csproj",".config",".props",".targets",".wxi",".wxl",".wxs",".xaml",".svg",".svgz",".opf",".xslt",".xsl"],"firstLine":"(\\<\\?xml.*)|(\\<svg)|(\\<\\!doctype\\s+svg)","aliases":["XML","xml"],"mimetypes":["text/xml","application/xml","application/xaml+xml","application/xml-dtd"]},{"id":"yaml","extensions":[".yaml",".yml"],"aliases":["YAML","yaml","YML","yml"],"mimetypes":["application/x-yaml","text/x-yaml"]},{"id":"json","extensions":[".json",".bowerrc",".jshintrc",".jscsrc",".eslintrc",".babelrc",".har"],"aliases":["JSON","json"],"mimetypes":["application/json"]},{"id":"cppExt","extensions":[".ino",".pde"]},{"id":"xmlExt","extensions":[".wsdl",".csproj",".vcxproj",".vbproj",".fsproj",".resx",".resw"]},{"id":"txtExt","extensions":[".sln",".log",".vsconfig",".env",".ahk",".ion"]},{"id":"razorExt","extensions":[".razor"]},{"id":"vbExt","extensions":[".vbs"]},{"id":"iniExt","extensions":[".inf",".gitconfig",".gitattributes",".editorconfig"]},{"id":"shellExt","extensions":[".ksh",".zsh",".bsh"]},{"id":"reg","extensions":[".reg"]},{"id":"gitignore","extensions":[".gitignore"]},{"id":"srt","extensions":[".srt"]}]} \ No newline at end of file +{"list":[{"id":"plaintext","extensions":[".txt"],"aliases":["Plain Text","text"],"mimetypes":["text/plain"]},{"id":"abap","extensions":[".abap"],"aliases":["abap","ABAP"]},{"id":"apex","extensions":[".cls"],"aliases":["Apex","apex"],"mimetypes":["text/x-apex-source","text/x-apex"]},{"id":"azcli","extensions":[".azcli"],"aliases":["Azure CLI","azcli"]},{"id":"bat","extensions":[".bat",".cmd"],"aliases":["Batch","bat"]},{"id":"bicep","extensions":[".bicep"],"aliases":["Bicep"]},{"id":"cameligo","extensions":[".mligo"],"aliases":["Cameligo"]},{"id":"clojure","extensions":[".clj",".cljs",".cljc",".edn"],"aliases":["clojure","Clojure"]},{"id":"coffeescript","extensions":[".coffee"],"aliases":["CoffeeScript","coffeescript","coffee"],"mimetypes":["text/x-coffeescript","text/coffeescript"]},{"id":"c","extensions":[".c",".h"],"aliases":["C","c"]},{"id":"cpp","extensions":[".cpp",".cc",".cxx",".hpp",".hh",".hxx"],"aliases":["C++","Cpp","cpp"]},{"id":"csharp","extensions":[".cs",".csx",".cake"],"aliases":["C#","csharp"]},{"id":"csp","extensions":[],"aliases":["CSP","csp"]},{"id":"css","extensions":[".css"],"aliases":["CSS","css"],"mimetypes":["text/css"]},{"id":"cypher","extensions":[".cypher",".cyp"],"aliases":["Cypher","OpenCypher"]},{"id":"dart","extensions":[".dart"],"aliases":["Dart","dart"],"mimetypes":["text/x-dart-source","text/x-dart"]},{"id":"dockerfile","extensions":[".dockerfile"],"filenames":["Dockerfile"],"aliases":["Dockerfile"]},{"id":"ecl","extensions":[".ecl"],"aliases":["ECL","Ecl","ecl"]},{"id":"elixir","extensions":[".ex",".exs"],"aliases":["Elixir","elixir","ex"]},{"id":"flow9","extensions":[".flow"],"aliases":["Flow9","Flow","flow9","flow"]},{"id":"fsharp","extensions":[".fs",".fsi",".ml",".mli",".fsx",".fsscript"],"aliases":["F#","FSharp","fsharp"]},{"id":"freemarker2","extensions":[".ftl",".ftlh",".ftlx"],"aliases":["FreeMarker2","Apache FreeMarker2"]},{"id":"freemarker2.tag-angle.interpolation-dollar","aliases":["FreeMarker2 (Angle/Dollar)","Apache FreeMarker2 (Angle/Dollar)"]},{"id":"freemarker2.tag-bracket.interpolation-dollar","aliases":["FreeMarker2 (Bracket/Dollar)","Apache FreeMarker2 (Bracket/Dollar)"]},{"id":"freemarker2.tag-angle.interpolation-bracket","aliases":["FreeMarker2 (Angle/Bracket)","Apache FreeMarker2 (Angle/Bracket)"]},{"id":"freemarker2.tag-bracket.interpolation-bracket","aliases":["FreeMarker2 (Bracket/Bracket)","Apache FreeMarker2 (Bracket/Bracket)"]},{"id":"freemarker2.tag-auto.interpolation-dollar","aliases":["FreeMarker2 (Auto/Dollar)","Apache FreeMarker2 (Auto/Dollar)"]},{"id":"freemarker2.tag-auto.interpolation-bracket","aliases":["FreeMarker2 (Auto/Bracket)","Apache FreeMarker2 (Auto/Bracket)"]},{"id":"go","extensions":[".go"],"aliases":["Go"]},{"id":"graphql","extensions":[".graphql",".gql"],"aliases":["GraphQL","graphql","gql"],"mimetypes":["application/graphql"]},{"id":"handlebars","extensions":[".handlebars",".hbs"],"aliases":["Handlebars","handlebars","hbs"],"mimetypes":["text/x-handlebars-template"]},{"id":"hcl","extensions":[".tf",".tfvars",".hcl"],"aliases":["Terraform","tf","HCL","hcl"]},{"id":"html","extensions":[".html",".htm",".shtml",".xhtml",".mdoc",".jsp",".asp",".aspx",".jshtm"],"aliases":["HTML","htm","html","xhtml"],"mimetypes":["text/html","text/x-jshtm","text/template","text/ng-template"]},{"id":"ini","extensions":[".ini",".properties",".gitconfig"],"filenames":["config",".gitattributes",".gitconfig",".editorconfig"],"aliases":["Ini","ini"]},{"id":"java","extensions":[".java",".jav"],"aliases":["Java","java"],"mimetypes":["text/x-java-source","text/x-java"]},{"id":"javascript","extensions":[".js",".es6",".jsx",".mjs",".cjs"],"firstLine":"^#!.*\\bnode","filenames":["jakefile"],"aliases":["JavaScript","javascript","js"],"mimetypes":["text/javascript"]},{"id":"julia","extensions":[".jl"],"aliases":["julia","Julia"]},{"id":"kotlin","extensions":[".kt",".kts"],"aliases":["Kotlin","kotlin"],"mimetypes":["text/x-kotlin-source","text/x-kotlin"]},{"id":"less","extensions":[".less"],"aliases":["Less","less"],"mimetypes":["text/x-less","text/less"]},{"id":"lexon","extensions":[".lex"],"aliases":["Lexon"]},{"id":"lua","extensions":[".lua"],"aliases":["Lua","lua"]},{"id":"liquid","extensions":[".liquid",".html.liquid"],"aliases":["Liquid","liquid"],"mimetypes":["application/liquid"]},{"id":"m3","extensions":[".m3",".i3",".mg",".ig"],"aliases":["Modula-3","Modula3","modula3","m3"]},{"id":"markdown","extensions":[".md",".markdown",".mdown",".mkdn",".mkd",".mdwn",".mdtxt",".mdtext"],"aliases":["Markdown","markdown"]},{"id":"mdx","extensions":[".mdx"],"aliases":["MDX","mdx"]},{"id":"mips","extensions":[".s"],"aliases":["MIPS","MIPS-V"],"mimetypes":["text/x-mips","text/mips","text/plaintext"]},{"id":"msdax","extensions":[".dax",".msdax"],"aliases":["DAX","MSDAX"]},{"id":"mysql","extensions":[],"aliases":["MySQL","mysql"]},{"id":"objective-c","extensions":[".m"],"aliases":["Objective-C"]},{"id":"pascal","extensions":[".pas",".p",".pp"],"aliases":["Pascal","pas"],"mimetypes":["text/x-pascal-source","text/x-pascal"]},{"id":"pascaligo","extensions":[".ligo"],"aliases":["Pascaligo","ligo"]},{"id":"perl","extensions":[".pl",".pm"],"aliases":["Perl","pl"]},{"id":"pgsql","extensions":[],"aliases":["PostgreSQL","postgres","pg","postgre"]},{"id":"php","extensions":[".php",".php4",".php5",".phtml",".ctp"],"aliases":["PHP","php"],"mimetypes":["application/x-php"]},{"id":"pla","extensions":[".pla"]},{"id":"postiats","extensions":[".dats",".sats",".hats"],"aliases":["ATS","ATS/Postiats"]},{"id":"powerquery","extensions":[".pq",".pqm"],"aliases":["PQ","M","Power Query","Power Query M"]},{"id":"powershell","extensions":[".ps1",".psm1",".psd1"],"aliases":["PowerShell","powershell","ps","ps1"]},{"id":"proto","extensions":[".proto"],"aliases":["protobuf","Protocol Buffers"]},{"id":"pug","extensions":[".jade",".pug"],"aliases":["Pug","Jade","jade"]},{"id":"python","extensions":[".py",".rpy",".pyw",".cpy",".gyp",".gypi"],"aliases":["Python","py"],"firstLine":"^#!/.*\\bpython[0-9.-]*\\b"},{"id":"qsharp","extensions":[".qs"],"aliases":["Q#","qsharp"]},{"id":"r","extensions":[".r",".rhistory",".rmd",".rprofile",".rt"],"aliases":["R","r"]},{"id":"razor","extensions":[".cshtml"],"aliases":["Razor","razor"],"mimetypes":["text/x-cshtml"]},{"id":"redis","extensions":[".redis"],"aliases":["redis"]},{"id":"redshift","extensions":[],"aliases":["Redshift","redshift"]},{"id":"restructuredtext","extensions":[".rst"],"aliases":["reStructuredText","restructuredtext"]},{"id":"ruby","extensions":[".rb",".rbx",".rjs",".gemspec",".pp"],"filenames":["rakefile","Gemfile"],"aliases":["Ruby","rb"]},{"id":"rust","extensions":[".rs",".rlib"],"aliases":["Rust","rust"]},{"id":"sb","extensions":[".sb"],"aliases":["Small Basic","sb"]},{"id":"scala","extensions":[".scala",".sc",".sbt"],"aliases":["Scala","scala","SBT","Sbt","sbt","Dotty","dotty"],"mimetypes":["text/x-scala-source","text/x-scala","text/x-sbt","text/x-dotty"]},{"id":"scheme","extensions":[".scm",".ss",".sch",".rkt"],"aliases":["scheme","Scheme"]},{"id":"scss","extensions":[".scss"],"aliases":["Sass","sass","scss"],"mimetypes":["text/x-scss","text/scss"]},{"id":"shell","extensions":[".sh",".bash"],"aliases":["Shell","sh"]},{"id":"sol","extensions":[".sol"],"aliases":["sol","solidity","Solidity"]},{"id":"aes","extensions":[".aes"],"aliases":["aes","sophia","Sophia"]},{"id":"sparql","extensions":[".rq"],"aliases":["sparql","SPARQL"]},{"id":"sql","extensions":[".sql"],"aliases":["SQL"]},{"id":"st","extensions":[".st",".iecst",".iecplc",".lc3lib",".TcPOU",".TcDUT",".TcGVL",".TcIO"],"aliases":["StructuredText","scl","stl"]},{"id":"swift","aliases":["Swift","swift"],"extensions":[".swift"],"mimetypes":["text/swift"]},{"id":"systemverilog","extensions":[".sv",".svh"],"aliases":["SV","sv","SystemVerilog","systemverilog"]},{"id":"verilog","extensions":[".v",".vh"],"aliases":["V","v","Verilog","verilog"]},{"id":"tcl","extensions":[".tcl"],"aliases":["tcl","Tcl","tcltk","TclTk","tcl/tk","Tcl/Tk"]},{"id":"twig","extensions":[".twig"],"aliases":["Twig","twig"],"mimetypes":["text/x-twig"]},{"id":"typescript","extensions":[".ts",".tsx",".cts",".mts"],"aliases":["TypeScript","ts","typescript"],"mimetypes":["text/typescript"]},{"id":"vb","extensions":[".vb"],"aliases":["Visual Basic","vb"]},{"id":"wgsl","extensions":[".wgsl"],"aliases":["WebGPU Shading Language","WGSL","wgsl"]},{"id":"xml","extensions":[".xml",".xsd",".dtd",".ascx",".csproj",".shproj",".projitems",".config",".props",".targets",".wxi",".wxl",".wxs",".xaml",".svg",".svgz",".opf",".xslt",".xsl"],"firstLine":"(\\<\\?xml.*)|(\\<svg)|(\\<\\!doctype\\s+svg)","aliases":["XML","xml"],"mimetypes":["text/xml","application/xml","application/xaml+xml","application/xml-dtd"]},{"id":"yaml","extensions":[".yaml",".yml"],"aliases":["YAML","yaml","YML","yml"],"mimetypes":["application/x-yaml","text/x-yaml"]},{"id":"json","extensions":[".json",".bowerrc",".jshintrc",".jscsrc",".eslintrc",".babelrc",".har"],"aliases":["JSON","json"],"mimetypes":["application/json"]},{"id":"cppExt","extensions":[".ino",".pde"]},{"id":"xmlExt","extensions":[".wsdl",".csproj",".vcxproj",".vbproj",".fsproj",".resx",".resw"]},{"id":"txtExt","extensions":[".sln",".log",".vsconfig",".env",".ahk",".ion"]},{"id":"razorExt","extensions":[".razor"]},{"id":"vbExt","extensions":[".vbs"]},{"id":"iniExt","extensions":[".inf",".gitconfig",".gitattributes",".editorconfig"]},{"id":"shellExt","extensions":[".ksh",".zsh",".bsh"]},{"id":"reg","extensions":[".reg"]},{"id":"gitignore","extensions":[".gitignore"]},{"id":"srt","extensions":[".srt"]}]} diff --git a/src/PackageIdentity/AppxManifest.xml b/src/PackageIdentity/AppxManifest.xml new file mode 100644 index 0000000000..502cc33ff0 --- /dev/null +++ b/src/PackageIdentity/AppxManifest.xml @@ -0,0 +1,109 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Sparse package manifest (moved to PackageIdentity folder for cleaner organization). + Based on Windows AI Foundry WPF sparse sample with PowerOCR customizations. --> +<Package + xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" + xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" + xmlns:uap2="http://schemas.microsoft.com/appx/manifest/uap/windows10/2" + xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3" + xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" + xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" + xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10" + xmlns:systemai="http://schemas.microsoft.com/appx/manifest/systemai/windows10" + xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" + IgnorableNamespaces="uap uap2 uap3 rescap desktop uap10 systemai com"> + <Identity + Name="Microsoft.PowerToys.SparseApp" + Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" + Version="0.0.1.0" /> + + <Properties> + <DisplayName>PowerToys.SparseApp</DisplayName> + <PublisherDisplayName>PowerToys</PublisherDisplayName> + <Logo>Images\StoreLogo.png</Logo> + <uap10:AllowExternalContent>true</uap10:AllowExternalContent> + </Properties> + + <Resources> + <Resource Language="en-us" /> + </Resources> + <Dependencies> + <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19000.0" MaxVersionTested="10.0.26226.0" /> + </Dependencies> + <Capabilities> + <Capability Name="internetClient" /> + <rescap:Capability Name="runFullTrust" /> + <systemai:Capability Name="systemAIModels"/> + <rescap:Capability Name="unvirtualizedResources"/> + </Capabilities> + + <Applications> + <Application Id="PowerToys.OCR" Executable="PowerToys.PowerOCR.exe" EntryPoint="Windows.FullTrustApplication"> + <uap:VisualElements + DisplayName="PowerToys.OCR" + Description="PowerToys OCR Module" + BackgroundColor="transparent" + Square150x150Logo="Images\Square150x150Logo.png" + Square44x44Logo="Images\Square44x44Logo.png" + AppListEntry="none"> + </uap:VisualElements> + </Application> + <Application Id="PowerToys.SettingsUI" Executable="WinUI3Apps\PowerToys.Settings.exe" EntryPoint="Windows.FullTrustApplication"> + <uap:VisualElements + DisplayName="PowerToys.SettingsUI" + Description="PowerToys Settings UI" + BackgroundColor="transparent" + Square150x150Logo="Images\Square150x150Logo.png" + Square44x44Logo="Images\Square44x44Logo.png" + AppListEntry="none"> + </uap:VisualElements> + </Application> + <Application Id="PowerToys.ImageResizerUI" Executable="WinUI3Apps\PowerToys.ImageResizer.exe" EntryPoint="Windows.FullTrustApplication"> + <uap:VisualElements + DisplayName="PowerToys.ImageResizer" + Description="PowerToys Image Resizer UI" + BackgroundColor="transparent" + Square150x150Logo="Images\Square150x150Logo.png" + Square44x44Logo="Images\Square44x44Logo.png" + AppListEntry="none"> + </uap:VisualElements> + </Application> + <Application Id="PowerToys.CmdPalExtension" Executable="Microsoft.CmdPal.Ext.PowerToys.exe" EntryPoint="Windows.FullTrustApplication"> + <uap:VisualElements + DisplayName="PowerToys.CommandPaletteExtension" + Description="PowerToys Command Palette Extension" + BackgroundColor="transparent" + Square150x150Logo="Images\Square150x150Logo.png" + Square44x44Logo="Images\Square44x44Logo.png" + AppListEntry="none"> + </uap:VisualElements> + <Extensions> + <com:Extension Category="windows.comServer"> + <com:ComServer> + <com:ExeServer Executable="Microsoft.CmdPal.Ext.PowerToys.exe" Arguments="-RegisterProcessAsComServer" DisplayName="PowerToys Command Palette Extension"> + <com:Class Id="7EC02C7D-8F98-4A2E-9F23-B58C2C2F2B17" DisplayName="PowerToys Command Palette Extension" /> + </com:ExeServer> + </com:ComServer> + </com:Extension> + <uap3:Extension Category="windows.appExtension"> + <uap3:AppExtension Name="com.microsoft.commandpalette" + Id="PowerToys" + PublicFolder="Public" + DisplayName="PowerToys" + Description="Surface PowerToys commands inside Command Palette"> + <uap3:Properties> + <CmdPalProvider> + <Activation> + <CreateInstance ClassId="7EC02C7D-8F98-4A2E-9F23-B58C2C2F2B17" /> + </Activation> + <SupportedInterfaces> + <Commands/> + </SupportedInterfaces> + </CmdPalProvider> + </uap3:Properties> + </uap3:AppExtension> + </uap3:Extension> + </Extensions> + </Application> + </Applications> +</Package> diff --git a/src/PackageIdentity/BuildSparsePackage.cmd b/src/PackageIdentity/BuildSparsePackage.cmd new file mode 100644 index 0000000000..71a4a6a77c --- /dev/null +++ b/src/PackageIdentity/BuildSparsePackage.cmd @@ -0,0 +1,6 @@ +@echo off +REM Wrapper to invoke PowerToys sparse package build script. +REM Pass through all arguments (e.g. Platform=arm64 Configuration=Debug -Clean) + +powershell -ExecutionPolicy Bypass -NoLogo -NoProfile -File "%~dp0\BuildSparsePackage.ps1" %* +exit /b %ERRORLEVEL% diff --git a/src/PackageIdentity/BuildSparsePackage.ps1 b/src/PackageIdentity/BuildSparsePackage.ps1 new file mode 100644 index 0000000000..1e341c24f5 --- /dev/null +++ b/src/PackageIdentity/BuildSparsePackage.ps1 @@ -0,0 +1,422 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +Param( + [Parameter(Mandatory=$false)] + [ValidateSet("arm64", "x64")] + [string]$Platform = "x64", + + [Parameter(Mandatory=$false)] + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release", + + [switch]$Clean, + [switch]$ForceCert, + [switch]$NoSign, + [switch]$CIBuild +) + +# PowerToys sparse packaging helper. +# Generates a sparse MSIX (no payload) that grants package identity to selected Win32 components. +# Multiple applications (PowerOCR, Settings UI, etc.) can share this single sparse identity. + +$ErrorActionPreference = 'Stop' + +$isCIBuild = $false +if ($CIBuild.IsPresent) { + $isCIBuild = $true +} elseif ($env:CIBuild) { + $isCIBuild = $env:CIBuild -ieq 'true' +} + +$currentPublisherHint = $script:Config.CertSubject + +# Configuration constants - centralized management +$script:Config = @{ + IdentityName = "Microsoft.PowerToys.SparseApp" + SparseMsixName = "PowerToysSparse.msix" + CertPrefix = "PowerToysSparse" + CertSubject = 'CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US' + CertValidMonths = 12 +} + +#region Helper Functions + +function Find-WindowsSDKTool { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$ToolName, + + [Parameter(Mandatory=$false)] + [string]$Architecture = "x64" + ) + + # Simple fallback: check common Windows SDK locations + $commonPaths = @( + "${env:ProgramFiles}\Windows Kits\10\bin\*\$Architecture\$ToolName", + "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\$Architecture\$ToolName", + "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x86\$ToolName" # SignTool fallback + ) + + foreach ($pattern in $commonPaths) { + $found = Get-ChildItem $pattern -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + Select-Object -First 1 + if ($found) { + Write-BuildLog "Found $ToolName at: $($found.FullName)" -Level Info + return $found.FullName + } + } + + throw "$ToolName not found. Please ensure Windows SDK is installed." +} + +function Test-CertificateValidity { + param([string]$ThumbprintFile) + + if (-not (Test-Path $ThumbprintFile)) { return $false } + + try { + $thumb = (Get-Content $ThumbprintFile -Raw).Trim() + if (-not $thumb) { return $false } + $cert = Get-Item "cert:\CurrentUser\My\$thumb" -ErrorAction Stop + return $cert.HasPrivateKey -and $cert.NotAfter -gt (Get-Date) + } catch { + return $false + } +} + +function Write-BuildLog { + param([string]$Message, [string]$Level = "Info") + + $colors = @{ Error = "Red"; Warning = "Yellow"; Success = "Green"; Info = "Cyan" } + $color = if ($colors.ContainsKey($Level)) { $colors[$Level] } else { "White" } + + Write-Host "[$(Get-Date -f 'HH:mm:ss')] $Message" -ForegroundColor $color +} + +function Stop-FileProcesses { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$FilePath + ) + + # This function is kept for compatibility but simplified since + # the staging directory approach resolves the file lock issues + Write-Verbose "File process check for: $FilePath" +} + +#endregion + +# Environment diagnostics for troubleshooting +Write-BuildLog "Starting PackageIdentity build process..." -Level Info +Write-BuildLog "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info +try { + $execPolicy = Get-ExecutionPolicy + Write-BuildLog "Execution Policy: $execPolicy" -Level Info +} catch { + Write-BuildLog "Execution Policy: Unable to determine (MSBuild environment)" -Level Info +} +Write-BuildLog "Current User: $env:USERNAME" -Level Info +Write-BuildLog "Build Platform: $Platform, Configuration: $Configuration" -Level Info + +# Check for Visual Studio environment +if ($env:VSINSTALLDIR) { + Write-BuildLog "Running in Visual Studio environment: $env:VSINSTALLDIR" -Level Info +} + +# Ensure certificate provider is available +try { + # Force load certificate provider for MSBuild environment + if (-not (Get-PSProvider -PSProvider Certificate -ErrorAction SilentlyContinue)) { + Write-BuildLog "Loading certificate provider..." -Level Warning + Import-Module Microsoft.PowerShell.Security -Force + } + if (-not (Test-Path 'Cert:\CurrentUser')) { + Write-BuildLog "Certificate drive not available, attempting to initialize..." -Level Warning + Import-Module PKI -ErrorAction SilentlyContinue + # Try to access the certificate store to force initialization + Get-ChildItem "Cert:\CurrentUser\My" -ErrorAction SilentlyContinue | Out-Null + } +} catch { + Write-BuildLog ("Note: Certificate provider setup may need manual configuration: {0}" -f $_) -Level Warning +} + +# Project root folder (now set to current script folder for local builds) +$ProjectRoot = $PSScriptRoot +$UserFolder = Join-Path $ProjectRoot '.user' +if (-not (Test-Path $UserFolder)) { New-Item -ItemType Directory -Path $UserFolder | Out-Null } + +# Certificate file paths using configuration +$prefix = $script:Config.CertPrefix +$CertThumbFile, $CertCerFile = @('.thumbprint', '.cer') | + ForEach-Object { Join-Path $UserFolder "$prefix.certificate.sample$_" } + +# Clean option: remove bin/obj and uninstall existing sparse package if present +if ($Clean) { + Write-BuildLog "Cleaning build artifacts..." -Level Info + 'bin','obj' | ForEach-Object { + $target = Join-Path $ProjectRoot $_ + if (Test-Path $target) { Remove-Item $target -Recurse -Force } + } + Write-BuildLog "Attempting to remove existing sparse package (best effort)" -Level Info + try { Get-AppxPackage -Name $script:Config.IdentityName | Remove-AppxPackage } catch {} +} + +# Force certificate regeneration if requested +if ($ForceCert -and (Test-Path $UserFolder)) { + Write-BuildLog "ForceCert specified: removing existing certificate artifacts..." -Level Warning + Remove-Item $UserFolder -Recurse -Force + New-Item -ItemType Directory -Path $UserFolder | Out-Null +} + +# Ensure dev cert (development only; not for production use) - skip if NoSign specified +$needNewCert = -not $NoSign -and (-not (Test-Path $CertThumbFile) -or $ForceCert -or -not (Test-CertificateValidity -ThumbprintFile $CertThumbFile)) + +if ($needNewCert) { + Write-BuildLog "Generating development certificate (prefix=$($script:Config.CertPrefix))..." -Level Info + + # Clear stale files in the certificate cache + if (Test-Path $UserFolder) { + Get-ChildItem -Path $UserFolder | ForEach-Object { + if ($_.PSIsContainer) { + Remove-Item $_.FullName -Recurse -Force + } else { + Remove-Item $_.FullName -Force + } + } + } + if (-not (Test-Path $UserFolder)) { + New-Item -ItemType Directory -Path $UserFolder | Out-Null + } + + $now = Get-Date + $expiration = $now.AddMonths($script:Config.CertValidMonths) + # Subject MUST match <Identity Publisher="..."> inside AppxManifest.xml + $friendlyName = "PowerToys Dev Sparse Cert Create=$now" + $keyFriendly = "PowerToys Dev Sparse Key Create=$now" + + $certStore = 'cert:\CurrentUser\My' + $ekuOid = '2.5.29.37' + $ekuValue = '1.3.6.1.5.5.7.3.3,1.3.6.1.4.1.311.10.3.13' + $eku = "$ekuOid={text}$ekuValue" + + $cert = New-SelfSignedCertificate -CertStoreLocation $certStore ` + -NotAfter $expiration ` + -Subject $script:Config.CertSubject ` + -FriendlyName $friendlyName ` + -KeyFriendlyName $keyFriendly ` + -KeyDescription $keyFriendly ` + -TextExtension $eku + + # Export certificate files + Set-Content -Path $CertThumbFile -Value $cert.Thumbprint -Force + Export-Certificate -Cert $cert -FilePath $CertCerFile -Force | Out-Null +} + +# Determine output directory - using PowerToys standard structure +# Navigate to PowerToys root (two levels up from src/PackageIdentity) +$PowerToysRoot = Split-Path (Split-Path $ProjectRoot -Parent) -Parent +$outDir = Join-Path $PowerToysRoot "$Platform\$Configuration" + +if (-not (Test-Path $outDir)) { + Write-BuildLog "Creating output directory: $outDir" -Level Info + New-Item -ItemType Directory -Path $outDir -Force | Out-Null +} + +# PackageIdentity folder (this script location) containing the sparse manifest and assets +$sparseDir = $PSScriptRoot +$manifestPath = Join-Path $sparseDir 'AppxManifest.xml' +if (-not (Test-Path $manifestPath)) { throw "Missing AppxManifest.xml in PackageIdentity folder: $manifestPath" } + +$versionPropsPath = Join-Path $PowerToysRoot 'src\Version.props' +$targetManifestVersion = $null +$versionCandidate = $null +if (Test-Path $versionPropsPath) { + try { + [xml]$propsXml = Get-Content -Path $versionPropsPath -Raw + $versionCandidate = $propsXml.Project.PropertyGroup.Version + } catch { + Write-BuildLog ("Unable to read version from {0}: {1}" -f $versionPropsPath, $_) -Level Warning + } +} else { + Write-BuildLog "Version.props not found at $versionPropsPath; manifest version will remain unchanged." -Level Warning +} + +if ($versionCandidate) { + $targetManifestVersion = $versionCandidate.Trim() + if (($targetManifestVersion -split '\.').Count -lt 4) { + $targetManifestVersion = "$targetManifestVersion.0" + } + Write-BuildLog "Using sparse package version from Version.props: $targetManifestVersion" -Level Info +} else { + Write-BuildLog "No version value provided; manifest version will remain unchanged." -Level Info +} + +# Find MakeAppx.exe from Windows SDK +try { + $hostSdkArchitecture = if ([System.Environment]::Is64BitProcess) { 'x64' } else { 'x86' } + $makeAppxPath = Find-WindowsSDKTool -ToolName "makeappx.exe" -Architecture $hostSdkArchitecture +} catch { + Write-Error "MakeAppx.exe not found. Please ensure Windows SDK is installed." + exit 1 +} + +# Pack sparse MSIX from PackageIdentity folder +$msixPath = Join-Path $outDir $script:Config.SparseMsixName + +# Clean up existing MSIX file +if (Test-Path $msixPath) { + Write-BuildLog "Removing existing MSIX file..." -Level Info + try { + Remove-Item $msixPath -Force -ErrorAction Stop + Write-BuildLog "Successfully removed existing MSIX file" -Level Success + } catch { + Write-BuildLog ("Warning: Could not remove existing MSIX file: {0}" -f $_) -Level Warning + } +} + +# Create a clean staging directory to avoid file lock issues +$stagingDir = Join-Path $outDir "staging" +if (Test-Path $stagingDir) { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue +} +New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null + +try { + Write-BuildLog "Creating clean staging directory for packaging..." -Level Info + + # Copy only essential files to staging directory to avoid file locks + $essentialFiles = @( + "AppxManifest.xml" + "Images\*" + ) + + foreach ($filePattern in $essentialFiles) { + $sourcePath = Join-Path $sparseDir $filePattern + $relativePath = $filePattern + + if ($filePattern.Contains('\')) { + $targetDir = Join-Path $stagingDir (Split-Path $relativePath -Parent) + if (-not (Test-Path $targetDir)) { + New-Item -ItemType Directory -Path $targetDir -Force | Out-Null + } + } + + if ($filePattern.EndsWith('\*')) { + # Copy directory contents + $sourceDir = $sourcePath.TrimEnd('\*') + $targetDir = Join-Path $stagingDir (Split-Path $relativePath.TrimEnd('\*') -Parent) + if (Test-Path $sourceDir) { + Copy-Item -Path "$sourceDir\*" -Destination $targetDir -Force -ErrorAction SilentlyContinue + } + } else { + # Copy single file + $targetPath = Join-Path $stagingDir $relativePath + if (Test-Path $sourcePath) { + Copy-Item -Path $sourcePath -Destination $targetPath -Force -ErrorAction SilentlyContinue + } + } + } + + # Ensure publisher matches the dev certificate for local builds + $manifestStagingPath = Join-Path $stagingDir 'AppxManifest.xml' + $shouldUseDevPublisher = -not $isCIBuild + if (Test-Path $manifestStagingPath) { + try { + [xml]$manifestXml = Get-Content -Path $manifestStagingPath -Raw + $identityNode = $manifestXml.Package.Identity + $manifestChanged = $false + if ($identityNode) { + $currentPublisherHint = $identityNode.Publisher + } + + if ($identityNode) { + if ($targetManifestVersion -and $identityNode.Version -ne $targetManifestVersion) { + Write-BuildLog "Updating manifest version to $targetManifestVersion" -Level Info + $identityNode.SetAttribute('Version', $targetManifestVersion) + $manifestChanged = $true + } + + if ($shouldUseDevPublisher -and $identityNode.Publisher -ne $script:Config.CertSubject) { + Write-BuildLog "Updating manifest publisher for local build" -Level Warning + $identityNode.SetAttribute('Publisher', $script:Config.CertSubject) + $manifestChanged = $true + } + $currentPublisherHint = $identityNode.Publisher + } + + if ($manifestChanged) { + $manifestXml.Save($manifestStagingPath) + } + } catch { + Write-BuildLog ("Unable to adjust manifest metadata: {0}" -f $_) -Level Warning + } + } + + Write-BuildLog "Staging directory prepared with essential files only" -Level Success + + # Pack MSIX using staging directory + Write-BuildLog "Packing sparse MSIX ($($script:Config.SparseMsixName)) from staging -> $msixPath" -Level Info + + & $makeAppxPath pack /d $stagingDir /p $msixPath /nv /o + + if ($LASTEXITCODE -eq 0 -and (Test-Path $msixPath)) { + Write-BuildLog "MSIX packaging completed successfully" -Level Success + } else { + Write-BuildLog "MakeAppx failed with exit code $LASTEXITCODE" -Level Error + exit 1 + } +} finally { + # Clean up staging directory + if (Test-Path $stagingDir) { + try { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + Write-BuildLog "Cleaned up staging directory" -Level Info + } catch { + Write-BuildLog ("Warning: Could not clean up staging directory: {0}" -f $_) -Level Warning + } + } +} + +# Sign package (skip if NoSign specified for CI scenarios) +if ($NoSign) { + Write-BuildLog "Skipping signing (NoSign specified for CI build)" -Level Warning +} else { + # Use certificate thumbprint for signing (safer, no password) + $certThumbprint = (Get-Content -Path $CertThumbFile -Raw).Trim() + try { + $signToolPath = Find-WindowsSDKTool -ToolName "signtool.exe" + } catch { + Write-Error "SignTool.exe not found. Please ensure Windows SDK is installed." + exit 1 + } + Write-BuildLog "Signing sparse MSIX using cert thumbprint $certThumbprint..." -Level Info + & $signToolPath sign /fd SHA256 /sha1 $certThumbprint $msixPath + if ($LASTEXITCODE -ne 0) { + Write-Warning "SignTool failed (exit $LASTEXITCODE). Ensure the certificate is in CurrentUser\\My and try -ForceCert if needed." + exit $LASTEXITCODE + } +} + +$publisherHintFile = Join-Path $UserFolder "$($script:Config.CertPrefix).publisher.txt" +try { + Set-Content -Path $publisherHintFile -Value $currentPublisherHint -Force -NoNewline +} catch { + Write-BuildLog ("Unable to write publisher hint: {0}" -f $_) -Level Warning +} + +Write-BuildLog "`nPackage created: $msixPath" -Level Success + +if ($NoSign) { + Write-BuildLog "UNSIGNED package created for CI build. Sign before deployment." -Level Warning +} else { + Write-BuildLog "Install the dev certificate (once): $CertCerFile" -Level Info + Write-BuildLog "Identity Name: $($script:Config.IdentityName)" -Level Info +} + +Write-BuildLog "Register sparse package:" -Level Info +Write-BuildLog " Add-AppxPackage -Path `"$msixPath`" -ExternalLocation `"$outDir`"" -Level Warning +Write-BuildLog "(If already installed and you changed manifest only): Add-AppxPackage -Register `"$manifestPath`" -ExternalLocation `"$outDir`" -ForceApplicationShutdown" -Level Warning diff --git a/src/PackageIdentity/Check-ProcessIdentity.ps1 b/src/PackageIdentity/Check-ProcessIdentity.ps1 new file mode 100644 index 0000000000..767afe542f --- /dev/null +++ b/src/PackageIdentity/Check-ProcessIdentity.ps1 @@ -0,0 +1,43 @@ +<# +.SYNOPSIS + Determine whether a given process (by PID) runs with an MSIX/UWP package identity. +.DESCRIPTION + Calls the Windows API GetPackageFullName to check if the target process executes under an MSIX/Sparse App/UWP package identity. + Returns the package full name when identity is present, or "No package identity" otherwise. +.PARAMETER ProcessId + The process ID to inspect. +.EXAMPLE + .\Check-ProcessIdentity.ps1 -pid 12345 +#> +param( + [Parameter(Mandatory=$true)] + [int]$ProcessId +) + +Add-Type -TypeDefinition @' +using System; +using System.Text; +using System.Runtime.InteropServices; +public class P { + [DllImport("kernel32.dll", SetLastError=true)] + public static extern IntPtr OpenProcess(uint a, bool b, int p); + [DllImport("kernel32.dll", SetLastError=true)] + public static extern bool CloseHandle(IntPtr h); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern int GetPackageFullName(IntPtr h, ref int l, StringBuilder b); + public static string G(int pid) { + IntPtr h = OpenProcess(0x1000, false, pid); + if (h == IntPtr.Zero) return "Failed to open process"; + int len = 0; + GetPackageFullName(h, ref len, null); + if (len == 0) { CloseHandle(h); return "No package identity"; } + var sb = new StringBuilder(len); + int r = GetPackageFullName(h, ref len, sb); + CloseHandle(h); + return r == 0 ? sb.ToString() : "Error:" + r; + } +} +'@ + +$result = [P]::G($ProcessId) +Write-Output $result diff --git a/src/PackageIdentity/Images/Square150x150Logo.png b/src/PackageIdentity/Images/Square150x150Logo.png new file mode 100644 index 0000000000..01a45755d7 Binary files /dev/null and b/src/PackageIdentity/Images/Square150x150Logo.png differ diff --git a/src/PackageIdentity/Images/Square44x44Logo.png b/src/PackageIdentity/Images/Square44x44Logo.png new file mode 100644 index 0000000000..01a45755d7 Binary files /dev/null and b/src/PackageIdentity/Images/Square44x44Logo.png differ diff --git a/src/PackageIdentity/Images/StoreLogo.png b/src/PackageIdentity/Images/StoreLogo.png new file mode 100644 index 0000000000..01a45755d7 Binary files /dev/null and b/src/PackageIdentity/Images/StoreLogo.png differ diff --git a/src/PackageIdentity/PackageIdentity.vcxproj b/src/PackageIdentity/PackageIdentity.vcxproj new file mode 100644 index 0000000000..8e8c9ce65a --- /dev/null +++ b/src/PackageIdentity/PackageIdentity.vcxproj @@ -0,0 +1,120 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + + <!-- CI Build Configuration --> + <PropertyGroup Condition="'$(CIBuild)'=='true'"> + <ForceCIPackaging>true</ForceCIPackaging> + <NoSignCI>true</NoSignCI> + </PropertyGroup> + + <!-- Target to generate sparse MSIX package --> + <Target Name="GenerateSparsePackage" BeforeTargets="PrepareForBuild"> + <!-- Use NoSign only for CI builds to avoid certificate issues on hosted agents --> + <PropertyGroup> + <NoSignParam Condition="'$(NoSignCI)' == 'true'">-NoSign</NoSignParam> + <NoSignParam Condition="'$(NoSignCI)' != 'true'"></NoSignParam> + <CIBuildParam Condition="'$(CIBuild)' == 'true'">-CIBuild</CIBuildParam> + <CIBuildParam Condition="'$(CIBuild)' != 'true'"></CIBuildParam> + </PropertyGroup> + + <Exec Command="pwsh -NonInteractive -ExecutionPolicy Bypass -File "$(MSBuildThisFileDirectory)BuildSparsePackage.ps1" -Platform $(Platform) -Configuration $(Configuration) $(NoSignParam) $(CIBuildParam)" + ContinueOnError="false" + WorkingDirectory="$(MSBuildThisFileDirectory)" /> + </Target> + + <ItemGroup Label="ProjectConfigurations"> + <ProjectConfiguration Include="Debug|x64"> + <Configuration>Debug</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|x64"> + <Configuration>Release</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Debug|ARM64"> + <Configuration>Debug</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|ARM64"> + <Configuration>Release</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + </ItemGroup> + + <PropertyGroup Label="Globals"> + <VCProjectVersion>15.0</VCProjectVersion> + <Keyword>Win32Proj</Keyword> + <ProjectGuid>{E2A5A82E-1E5B-4C8D-9A4F-2B1A8F9E5C3D}</ProjectGuid> + <RootNamespace>PackageIdentity</RootNamespace> + <ProjectName>PackageIdentity</ProjectName> + <UseFastUpToDateCheck>false</UseFastUpToDateCheck> + </PropertyGroup> + + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration"> + <ConfigurationType>Utility</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration"> + <ConfigurationType>Utility</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <WholeProgramOptimization>true</WholeProgramOptimization> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration"> + <ConfigurationType>Utility</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration"> + <ConfigurationType>Utility</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <WholeProgramOptimization>true</WholeProgramOptimization> + </PropertyGroup> + + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + + <ImportGroup Label="Shared"> + </ImportGroup> + + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + <Import Project="..\Solution.props" /> + </ImportGroup> + + <PropertyGroup Label="UserMacros" /> + + <ItemGroup> + <None Include="AppxManifest.xml" /> + <None Include="BuildSparsePackage.ps1" /> + <None Include="BuildSparsePackage.cmd" /> + <None Include="Check-ProcessIdentity.ps1" /> + </ItemGroup> + + <ItemGroup> + <Image Include="Images\Square150x150Logo.png"> + <Filter>Images</Filter> + </Image> + <Image Include="Images\Square44x44Logo.png"> + <Filter>Images</Filter> + </Image> + <Image Include="Images\StoreLogo.png"> + <Filter>Images</Filter> + </Image> + </ItemGroup> + + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + + <ImportGroup Label="ExtensionTargets"> + </ImportGroup> + +</Project> \ No newline at end of file diff --git a/src/PackageIdentity/PackageIdentity.vcxproj.filters b/src/PackageIdentity/PackageIdentity.vcxproj.filters new file mode 100644 index 0000000000..608c80f2b9 --- /dev/null +++ b/src/PackageIdentity/PackageIdentity.vcxproj.filters @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Images"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>png;jpg;jpeg;gif;bmp;ico</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <None Include="AppxManifest.xml" /> + <None Include="BuildSparsePackage.ps1" /> + <None Include="BuildSparsePackage.cmd" /> + </ItemGroup> + <ItemGroup> + <Image Include="Images\Square150x150Logo.png"> + <Filter>Images</Filter> + </Image> + <Image Include="Images\Square44x44Logo.png"> + <Filter>Images</Filter> + </Image> + <Image Include="Images\StoreLogo.png"> + <Filter>Images</Filter> + </Image> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/PackageIdentity/readme.md b/src/PackageIdentity/readme.md new file mode 100644 index 0000000000..2af2bbb26d --- /dev/null +++ b/src/PackageIdentity/readme.md @@ -0,0 +1,90 @@ +# PowerToys sparse package identity + +This document describes how to build, sign, register, and consume the shared sparse MSIX package that grants package identity to select Win32 components of PowerToys. + +## Package overview + +The sparse package lives under `src/PackageIdentity`. It produces a payload-free MSIX whose `Identity` matches `Microsoft.PowerToys.SparseApp`. The manifest contains one entry per Win32 surface that should run with identity (for example Settings, PowerOCR, Image Resizer). + +> The MSIX contains only metadata. When the package is registered you must point `-ExternalLocation` to the output folder that hosts the Win32 binaries (for example `x64\Release`). + +## Building the sparse package locally + +Two options are available: + +- Build the utility project from Visual Studio: `PackageIdentity.vcxproj` defines a `GenerateSparsePackage` target that runs before `PrepareForBuild` and invokes the helper script automatically. +- Invoke the helper script directly from PowerShell: + +```powershell +$repoRoot = "C:/git/PowerToys" +pwsh "$repoRoot/src/PackageIdentity/BuildSparsePackage.ps1" -Platform x64 -Configuration Release +``` + +Supported switches: + +- `-Clean` removes previous `bin`/`obj` outputs and uninstalls existing installation. +- `-ForceCert` regenerates the local dev certificate (.pfx/.cer/.pwd/.thumbprint) under `src/PackageIdentity/.user`. +- `-NoSign` skips signing. The MSIX still builds but must be signed before deployment. +- `-CIBuild` (or setting `$env:CIBuild = 'true'`) keeps the manifest publisher intact and skips the local cert substitution. + +The script determines the proper `makeappx.exe` for the host build machine (x64 on typical developer boxes) and creates `PowerToysSparse.msix` in `{repo}\<Platform>\<Configuration>`. + +> After packaging finishes, the helper also emits `src/PackageIdentity/.user/PowerToysSparse.publisher.txt`. This file mirrors the publisher string Windows will see once the sparse package is registered, which downstream projects can read to stay in sync when generating their own manifests. + +## Local signing basics + +When `-NoSign` is not used the script generates (or reuses) a development certificate and signs the package via `signtool.exe`: + +1. Artifacts are stored in `src/PackageIdentity/.user/PowerToysSparse.certificate.sample.*` (`.cer` and `.thumbprint`). +2. Install the `.cer` into `CurrentUser` → `TrustedPeople` (and `TrustedRoot`, if necessary) so Windows trusts the signature: + + ```powershell + $repoRoot = "C:/git/PowerToys" + Import-Certificate -FilePath "$repoRoot/src/PackageIdentity/.user/PowerToysSparse.certificate.sample.cer" -CertStoreLocation Cert:\CurrentUser\TrustedPeople + ``` + +3. The private key stays in the current user's personal certificate store. + +## Registering or unregistering the package + +After `PowerToysSparse.msix` is generated: + +```powershell +# First time registration +$repoRoot = "C:/git/PowerToys" +$outputRoot = Join-Path $repoRoot "x64/Release" +Add-AppxPackage -Path (Join-Path $outputRoot "PowerToysSparse.msix") -ExternalLocation $outputRoot + +# Re-register after manifest tweaks only +Add-AppxPackage -Register (Join-Path $repoRoot "src/PackageIdentity/AppxManifest.xml") -ExternalLocation $outputRoot -ForceApplicationShutdown + +# Remove the sparse identity +Get-AppxPackage -Name Microsoft.PowerToys.SparseApp | Remove-AppxPackage +``` + +`-ExternalLocation` should match the output folder that contains the Win32 executables declared in the manifest. Re-run registration whenever the manifest or executable layout changes. + +## CI-specific guidance + +- Pass `-CIBuild` to `BuildSparsePackage.ps1` (or build with `msbuild PackageIdentity.vcxproj /p:CIBuild=true`). This prevents the script from rewriting the manifest publisher to the local dev certificate subject. +- The project automatically adds `-NoSign` only when `$(CIBuild)` is `true`. Local Debug and Release builds are signed with the development certificate. +- Make sure the agent trusts whichever certificate signs the package. If the package remains unsigned (`-NoSign`) it cannot be installed on test machines until it is signed. + +## Consuming the identity from other components + +1. Add a new `<Application>` entry inside `src/PackageIdentity/AppxManifest.xml`. Use a unique `Id` (for example `PowerToys.MyModuleUI`) and set `Executable` to the Win32 binary relative to the `-ExternalLocation` root. +2. Ensure the binary is copied into the platform/configuration output folder (`x64\Release`, `ARM64\Debug`, etc.) so the sparse package can locate it. +3. Embed a sparse identity manifest in the Win32 binary so it binds to the MSIX identity at runtime. The manifest must declare an `<msix>` element with `packageName="Microsoft.PowerToys.SparseApp"`, `applicationId` matching the `<Application Id>`, and a `publisher` that matches the sparse package. Keep the manifest’s publisher in sync with `src/PackageIdentity/.user/PowerToysSparse.publisher.txt` (emitted by `BuildSparsePackage.ps1`). See `src/modules/imageresizer/ui/ImageResizerUI.csproj` for an example that points `ApplicationManifest` to `ImageResizerUI.dev.manifest` for local builds and switches to `ImageResizerUI.prod.manifest` when `$(CIBuild)` is `true`. +4. Register or re-register the sparse package so Windows learns about the new application Id. +5. To launch the Win32 surface with identity, use the `shell:AppsFolder` activation form (for example: `shell:AppsFolder\Microsoft.PowerToys.SparseApp_<PackageFamilyName>!PowerToys.MyModuleUI`) or activate it via `IApplicationActivationManager::ActivateApplication` using the same AppUserModelID. + + - For locally built packages, resolve the `<PackageFamilyName>` with `Get-AppxPackage -Name Microsoft.PowerToys.SparseApp | Select-Object -ExpandProperty PackageFamilyName`. + - Store-distributed builds use `Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe`. Local developer builds created by this script typically use a different family name derived from the dev certificate. + +6. Context menu handlers or other launchers should fall back to the unpackaged executable path for environments where the sparse package is not present. + +## Troubleshooting tips + +- `Program 'makeappx.exe' failed to run`: make sure you are running an x64 PowerShell host. The script now chooses the appropriate makeappx automatically; update your repo if the log still points to an ARM64 binary. +- `HRESULT 0x800B0109 (trust failure)`: install the development certificate into both `TrustedPeople` and `TrustedRoot` stores for the current user. +- Stale registration: remove the package with `Remove-AppxPackage` and re-run the script with `-Clean` to rebuild from scratch. diff --git a/src/Update/PowerToys.Update.cpp b/src/Update/PowerToys.Update.cpp index 1e16598c43..5be4a80d33 100644 --- a/src/Update/PowerToys.Update.cpp +++ b/src/Update/PowerToys.Update.cpp @@ -57,7 +57,7 @@ std::optional<fs::path> ObtainInstaller(bool& isUpToDate) auto state = UpdateState::read(); - const auto new_version_info = get_github_version_info_async().get(); + const auto new_version_info = std::move(get_github_version_info_async()).get(); if (std::holds_alternative<version_up_to_date>(*new_version_info)) { isUpToDate = true; @@ -76,7 +76,7 @@ std::optional<fs::path> ObtainInstaller(bool& isUpToDate) // Cleanup old updates before downloading the latest updating::cleanup_updates(); - auto downloaded_installer = download_new_version(std::get<new_version_download_info>(*new_version_info)).get(); + auto downloaded_installer = std::move(download_new_version_async(std::get<new_version_download_info>(*new_version_info))).get(); if (!downloaded_installer) { Logger::error("Couldn't download new installer"); diff --git a/src/Update/PowerToys.Update.vcxproj b/src/Update/PowerToys.Update.vcxproj index 172a7027a6..45ec0b1d65 100644 --- a/src/Update/PowerToys.Update.vcxproj +++ b/src/Update/PowerToys.Update.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h PowerToys.Update.base.rc PowerToys.Update.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h PowerToys.Update.base.rc PowerToys.Update.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> @@ -10,11 +11,10 @@ <RootNamespace>Update</RootNamespace> <ProjectName>PowerToys.Update</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> - <Import Project="..\..\deps\expected.props" /> + <Import Project="$(RepoRoot)deps\expected.props" /> <PropertyGroup> <ConfigurationType>Application</ConfigurationType> </PropertyGroup> @@ -65,17 +65,17 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/Update/packages.config b/src/Update/packages.config index ff4b059648..d3882436a5 100644 --- a/src/Update/packages.config +++ b/src/Update/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/codeAnalysis/GlobalSuppressions.cs b/src/codeAnalysis/GlobalSuppressions.cs index c05e5f8820..ae544b0c76 100644 --- a/src/codeAnalysis/GlobalSuppressions.cs +++ b/src/codeAnalysis/GlobalSuppressions.cs @@ -1,4 +1,4 @@ -// 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. @@ -63,8 +63,60 @@ using System.Diagnostics.CodeAnalysis; [assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "<Dotnet port with style preservation>", Scope = "namespaceanddescendants", Target = "MouseWithoutBorders")] [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "<Dotnet port with style preservation>", Scope = "namespaceanddescendants", Target = "MouseWithoutBorders")] -// AOT +// AOT MVVMTK0045 +[assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "MVVMTK0045:Using [ObservableProperty] on fields is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "namespaceanddescendants", Target = "AdvancedPaste.ViewModels")] [assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "MVVMTK0045:Using [ObservableProperty] on fields is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "namespaceanddescendants", Target = "HostsUILib")] +[assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "MVVMTK0045:Using [ObservableProperty] on fields is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "namespaceanddescendants", Target = "EnvironmentVariablesUILib")] +[assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "MVVMTK0045:Using [ObservableProperty] on fields is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "namespaceanddescendants", Target = "Peek.FilePreviewer")] [assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "MVVMTK0045:Using [ObservableProperty] on fields is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "namespaceanddescendants", Target = "Peek.UI")] [assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "MVVMTK0045:Using [ObservableProperty] on fields is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "namespaceanddescendants", Target = "Peek.UI.Views")] +[assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "MVVMTK0045:Using [ObservableProperty] on fields is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib")] + +// AOT MVVMTK0049 +[assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "MVVMTK0049:Using [INotifyPropertyChanged] is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "namespaceanddescendants", Target = "Peek.FilePreviewer")] [assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.INotifyPropertyChangedGenerator", "MVVMTK0049:Using [INotifyPropertyChanged] is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "type", Target = "~T:Peek.UI.Views.TitleBar")] +[assembly: SuppressMessage("CommunityToolkit.Mvvm.SourceGenerators.ObservablePropertyGenerator", "MVVMTK0049:Using [INotifyPropertyChanged] is not AOT compatible for WinRT", Justification = "Updated MVVM toolkit package introduced this.", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib")] + +// HexBox control in RegistryPreviewUILib (We decided to copy the original code and not fix all theses problems for easier updating.) +[assembly: SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("Naming", "CA1720:Identifiers should not contain type names", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("Performance", "CA1805:Do not initialize unnecessarily", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1623:Property summary documentation should match accessors", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1642:Constructor summary documentation should begin with standard text", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1648:<inheritdoc> has been used on an element that doesn't inherit from a base class or implement an interface.", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1500:Braces for multi-line statements should not share line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1502:Element should not be on a single line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1503:Braces should not be omitted", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1505:Opening braces should not be followed by blank line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1507:Code should not contain multiple blank lines in a row", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1508:Closing braces should not be preceded by blank line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1509:Opening braces should not be preceded by blank line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1512:Single-line comments should not be followed by blank line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1513:Closing brace should be followed by blank line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1514:Element documentation header should be preceded by blank line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1515:Single-line comment should be preceded by blank line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1516:Elements should be separated by blank line", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1119:Statement should not use unnecessary parenthesis", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1407:Arithmetic expressions should declare precedence", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1413:Use trailing comma in multi-line initializers", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1312:Variable names should begin with lower-case letter", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1313:Parameter names should begin with lower-case letter", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:Using directives should be placed correctly", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1108:Block statements should not contain embedded comments", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1116:Split parameters should start on line after declaration", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1117:Parameters should be on same line or separate lines", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1129:Do not use default value type constructor", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1000:Keywords should be spaced correctly", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1003:Symbols should be spaced correctly", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1005:Single line comments should begin with single space", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1024:Colons Should Be Spaced Correctly", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1025:Code should not contain multiple whitespace in a row", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("StyleCop.CSharp.SpacingRules", "SA1028:Code should not contain trailing whitespace", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] +[assembly: SuppressMessage("Usage", "CsWinRT1028:Class is not marked partial", Justification = "<Code port with style preservation>", Scope = "namespaceanddescendants", Target = "RegistryPreviewUILib.HexBox")] diff --git a/src/common/AllExperiments/AllExperiments.csproj b/src/common/AllExperiments/AllExperiments.csproj deleted file mode 100644 index 2da48432c1..0000000000 --- a/src/common/AllExperiments/AllExperiments.csproj +++ /dev/null @@ -1,27 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> - - <PropertyGroup> - <ImplicitUsings>enable</ImplicitUsings> - <Nullable>enable</Nullable> - <TargetName>PowerToys.AllExperiments</TargetName> - <MockDirectory>.\Microsoft.VariantAssignment\</MockDirectory> - </PropertyGroup> - - <ItemGroup> - <ProjectReference Include="..\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> - <ProjectReference Include="..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> - </ItemGroup> - - <!-- Experimentation is live, forcing inclusion --> - <ItemGroup Condition="'$(IsExperimentationLive)'!=''"> - <!-- Newtonsoft.Json is included and a version specified in Directory.Packages.props to avoid a vulnerability from older versions. --> - <PackageReference Include="Newtonsoft.Json" /> - <PackageReference Include="Microsoft.VariantAssignment.Client" /> - <PackageReference Include="Microsoft.VariantAssignment.Contract" /> - <Compile Remove=".\$(MockDirectory)\Client\*.cs" /> - <Compile Remove=".\$(MockDirectory)\Contract\*.cs" /> - </ItemGroup> - -</Project> diff --git a/src/common/AllExperiments/Experiments.cs b/src/common/AllExperiments/Experiments.cs deleted file mode 100644 index d527c52c81..0000000000 --- a/src/common/AllExperiments/Experiments.cs +++ /dev/null @@ -1,214 +0,0 @@ -// 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.Text.Json; - -using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; -using Microsoft.PowerToys.Telemetry; -using Microsoft.VariantAssignment.Client; -using Microsoft.VariantAssignment.Contract; -using Windows.System.Profile; - -namespace AllExperiments -{ - // The dependencies required to build this project are only available in the official build pipeline and are internal to Microsoft. - // However, this project is not required to build a test version of the application. - public class Experiments - { - public enum ExperimentState - { - Enabled, - Disabled, - NotLoaded, - } - -#pragma warning disable SA1401 // Need to use LandingPageExperiment as a static property in OobeShellPage.xaml.cs -#pragma warning disable CA2211 // Non-constant fields should not be visible - public static ExperimentState LandingPageExperiment = ExperimentState.NotLoaded; -#pragma warning restore CA2211 -#pragma warning restore SA1401 - - public async Task<bool> EnableLandingPageExperimentAsync() - { - if (Experiments.LandingPageExperiment != ExperimentState.NotLoaded) - { - return Experiments.LandingPageExperiment == ExperimentState.Enabled; - } - - Experiments varServ = new Experiments(); - await varServ.VariantAssignmentProvider_Initialize(); - var landingPageExperiment = varServ.IsExperiment; - - Experiments.LandingPageExperiment = landingPageExperiment ? ExperimentState.Enabled : ExperimentState.Disabled; - - return landingPageExperiment; - } - - private async Task VariantAssignmentProvider_Initialize() - { - IsExperiment = false; - string jsonFilePath = CreateFilePath(); - - var vaSettings = new VariantAssignmentClientSettings - { - Endpoint = new Uri("https://default.exp-tas.com/exptas77/a7a397e7-6fbe-4f21-a4e9-3f542e4b000e-exppowertoys/api/v1/tas"), - EnableCaching = true, - ResponseCacheTime = TimeSpan.FromMinutes(5), - }; - - try - { - var vaClient = vaSettings.GetTreatmentAssignmentServiceClient(); - var vaRequest = GetVariantAssignmentRequest(); - using var variantAssignments = await vaClient.GetVariantAssignmentsAsync(vaRequest).ConfigureAwait(false); - - if (variantAssignments.AssignedVariants.Count != 0) - { - var dataVersion = variantAssignments.DataVersion; - var featureVariables = variantAssignments.GetFeatureVariables(); - var assignmentContext = variantAssignments.GetAssignmentContext(); - var featureFlagValue = featureVariables[0].GetStringValue(); - - var experimentGroup = string.Empty; - string json = File.ReadAllText(jsonFilePath); - var jsonDictionary = JsonSerializer.Deserialize<Dictionary<string, object>>(json); - - if (jsonDictionary != null) - { - if (!jsonDictionary.TryGetValue("dataversion", out object? value)) - { - value = dataVersion; - jsonDictionary.Add("dataversion", value); - } - - if (!jsonDictionary.ContainsKey("variantassignment")) - { - jsonDictionary.Add("variantassignment", featureFlagValue); - } - else - { - var jsonDataVersion = value.ToString(); - if (jsonDataVersion != null && int.Parse(jsonDataVersion, CultureInfo.InvariantCulture) < dataVersion) - { - jsonDictionary["dataversion"] = dataVersion; - jsonDictionary["variantassignment"] = featureFlagValue; - } - } - - experimentGroup = jsonDictionary["variantassignment"].ToString(); - - string output = JsonSerializer.Serialize(jsonDictionary); - File.WriteAllText(jsonFilePath, output); - } - - if (experimentGroup == "alternate" && AssignmentUnit != string.Empty) - { - IsExperiment = true; - } - - PowerToysTelemetry.Log.WriteEvent(new OobeVariantAssignmentEvent() { AssignmentContext = assignmentContext, ClientID = AssignmentUnit }); - } - } - catch (HttpRequestException ex) - { - string json = File.ReadAllText(jsonFilePath); - var jsonDictionary = JsonSerializer.Deserialize<Dictionary<string, object>>(json); - - if (jsonDictionary != null) - { - if (jsonDictionary.TryGetValue("variantassignment", out object? value)) - { - if (value.ToString() == "alternate" && AssignmentUnit != string.Empty) - { - IsExperiment = true; - } - } - else - { - jsonDictionary["variantassignment"] = "current"; - } - } - - string output = JsonSerializer.Serialize(jsonDictionary); - File.WriteAllText(jsonFilePath, output); - - Logger.LogError("Error getting to TAS endpoint", ex); - } - catch (Exception ex) - { - Logger.LogError("Error getting variant assignments for experiment", ex); - } - } - - public bool IsExperiment { get; set; } - - private string? AssignmentUnit { get; set; } - - private VariantAssignmentRequest GetVariantAssignmentRequest() - { - var jsonFilePath = CreateFilePath(); - try - { - if (!File.Exists(jsonFilePath)) - { - AssignmentUnit = Guid.NewGuid().ToString(); - var data = new Dictionary<string, string>() - { - ["clientid"] = AssignmentUnit, - }; - string jsonData = JsonSerializer.Serialize(data); - File.WriteAllText(jsonFilePath, jsonData); - } - else - { - string json = File.ReadAllText(jsonFilePath); - var jsonDictionary = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(json); - if (jsonDictionary != null) - { - AssignmentUnit = jsonDictionary["clientid"]?.ToString(); - } - } - } - catch (Exception ex) - { - Logger.LogError("Error creating/getting AssignmentUnit", ex); - } - - var attrNames = new List<string> { "FlightRing", "c:InstallLanguage" }; - var attrData = AnalyticsInfo.GetSystemPropertiesAsync(attrNames).AsTask().GetAwaiter().GetResult(); - - var flightRing = string.Empty; - var installLanguage = string.Empty; - - if (attrData.ContainsKey("FlightRing")) - { - flightRing = attrData["FlightRing"]; - } - - if (attrData.ContainsKey("InstallLanguage")) - { - installLanguage = attrData["InstallLanguage"]; - } - - return new VariantAssignmentRequest - { - Parameters = - { - { "installLanguage", installLanguage }, - { "flightRing", flightRing }, - { "clientid", AssignmentUnit }, - }, - }; - } - - private string CreateFilePath() - { - var exeDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var settingsPath = @"Microsoft\PowerToys\experimentation.json"; - var filePath = Path.Combine(exeDir, settingsPath); - return filePath; - } - } -} diff --git a/src/common/AllExperiments/Logger.cs b/src/common/AllExperiments/Logger.cs deleted file mode 100644 index 7604618bdf..0000000000 --- a/src/common/AllExperiments/Logger.cs +++ /dev/null @@ -1,81 +0,0 @@ -// 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; -using System.Globalization; -using System.IO.Abstractions; - -namespace AllExperiments -{ - public static class Logger - { - private static readonly IFileSystem FileSystem = new FileSystem(); - private static readonly IPath Path = FileSystem.Path; - private static readonly IDirectory Directory = FileSystem.Directory; - - private static readonly string ApplicationLogPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Microsoft\\PowerToys\\Settings Logs\\Experimentation"); - - static Logger() - { - if (!Directory.Exists(ApplicationLogPath)) - { - Directory.CreateDirectory(ApplicationLogPath); - } - - // Using InvariantCulture since this is used for a log file name - var logFilePath = Path.Combine(ApplicationLogPath, "Log_" + DateTime.Now.ToString(@"yyyy-MM-dd", CultureInfo.InvariantCulture) + ".txt"); - - Trace.Listeners.Add(new TextWriterTraceListener(logFilePath)); - - Trace.AutoFlush = true; - } - - public static void LogInfo(string message) - { - Log(message, "INFO"); - } - - public static void LogError(string message) - { - Log(message, "ERROR"); -#if DEBUG - Debugger.Break(); -#endif - } - - public static void LogError(string message, Exception e) - { - Log( - message + Environment.NewLine + - e?.Message + Environment.NewLine + - "Inner exception: " + Environment.NewLine + - e?.InnerException?.Message + Environment.NewLine + - "Stack trace: " + Environment.NewLine + - e?.StackTrace, - "ERROR"); -#if DEBUG - Debugger.Break(); -#endif - } - - private static void Log(string message, string type) - { - Trace.WriteLine(type + ": " + DateTime.Now.TimeOfDay); - Trace.Indent(); - Trace.WriteLine(GetCallerInfo()); - Trace.WriteLine(message); - Trace.Unindent(); - } - - private static string GetCallerInfo() - { - StackTrace stackTrace = new StackTrace(); - - var methodName = stackTrace.GetFrame(3)?.GetMethod(); - var className = methodName?.DeclaringType?.Name; - return "[Method]: " + methodName?.Name + " [Class]: " + className; - } - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Client/VariantAssignmentClientExtensionMethods.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Client/VariantAssignmentClientExtensionMethods.cs deleted file mode 100644 index ee08acd718..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Client/VariantAssignmentClientExtensionMethods.cs +++ /dev/null @@ -1,21 +0,0 @@ -// 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.VariantAssignment.Contract; - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Client -{ -#pragma warning disable SA1200 // Using directives should be placed correctly - using TreatmentAssignmentServiceClient = VariantAssignmentServiceClient<TreatmentAssignmentServiceResponse>; -#pragma warning restore SA1200 // Using directives should be placed correctly - - public static class VariantAssignmentClientExtensionMethods - { - public static IVariantAssignmentProvider GetTreatmentAssignmentServiceClient(this VariantAssignmentClientSettings settings) - { - return new TreatmentAssignmentServiceClient(); - } - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Client/VariantAssignmentServiceClient.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Client/VariantAssignmentServiceClient.cs deleted file mode 100644 index 373651f83a..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Client/VariantAssignmentServiceClient.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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.VariantAssignment.Contract; - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Client -{ - internal sealed partial class VariantAssignmentServiceClient<TServerResponse> : IVariantAssignmentProvider, IDisposable - where TServerResponse : VariantAssignmentServiceResponse - { - public void Dispose() - { - throw new NotImplementedException(); - } - - public Task<IVariantAssignmentResponse> GetVariantAssignmentsAsync(IVariantAssignmentRequest request, CancellationToken ct = default) - { - return Task.FromResult(EmptyVariantAssignmentResponse.Instance); - } - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/EmptyVariantAssignmentResponse.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/EmptyVariantAssignmentResponse.cs deleted file mode 100644 index 0e0cd54094..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/EmptyVariantAssignmentResponse.cs +++ /dev/null @@ -1,54 +0,0 @@ -// 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. - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - public class EmptyVariantAssignmentResponse : IVariantAssignmentResponse - { - /// <summary> - /// Singleton instance of <see cref="EmptyVariantAssignmentResponse"/>. - /// </summary> - public static readonly IVariantAssignmentResponse Instance = new EmptyVariantAssignmentResponse(); - - public EmptyVariantAssignmentResponse() - { - } - - public long DataVersion => 0; - - public string Thumbprint => string.Empty; - - /// <inheritdoc/> - public IReadOnlyCollection<IAssignedVariant> AssignedVariants => Array.Empty<IAssignedVariant>(); - - /// <inheritdoc/> -#pragma warning disable CS8603 // Possible null reference return. - public IFeatureVariable GetFeatureVariable(IReadOnlyList<string> path) => null; -#pragma warning restore CS8603 // Possible null reference return. - - /// <inheritdoc/> - public IReadOnlyList<IFeatureVariable> GetFeatureVariables(IReadOnlyList<string> prefix) => Array.Empty<IFeatureVariable>(); - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - } - - string IVariantAssignmentResponse.GetAssignmentContext() - { - throw new NotImplementedException(); - } - - IReadOnlyList<IFeatureVariable> IVariantAssignmentResponse.GetFeatureVariables() - { - throw new NotImplementedException(); - } - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IAssignedVariant.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IAssignedVariant.cs deleted file mode 100644 index 6c9a31e8ce..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IAssignedVariant.cs +++ /dev/null @@ -1,11 +0,0 @@ -// 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. - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - public interface IAssignedVariant - { - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IFeatureVariable.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IFeatureVariable.cs deleted file mode 100644 index fc9193ed0d..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IFeatureVariable.cs +++ /dev/null @@ -1,16 +0,0 @@ -// 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. - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - public interface IFeatureVariable - { - /// <summary> - /// Gets the variable's value as a string. - /// </summary> - /// <returns>String value of the variable.</returns> - string GetStringValue(); - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IVariantAssignmentProvider.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IVariantAssignmentProvider.cs deleted file mode 100644 index dad9d39038..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IVariantAssignmentProvider.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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. - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - public interface IVariantAssignmentProvider : IDisposable - { - /// <summary> - /// Computes variant assignments based on <paramref name="request"/> data. - /// </summary> - /// <param name="request">Variant assignment parameters.</param> - /// <param name="ct">Propagates notification that operations should be canceled.</param> - /// <returns>An awaitable task that returns a <see cref="IVariantAssignmentResponse"/>.</returns> - Task<IVariantAssignmentResponse> GetVariantAssignmentsAsync(IVariantAssignmentRequest request, CancellationToken ct = default); - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IVariantAssignmentRequest.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IVariantAssignmentRequest.cs deleted file mode 100644 index 9639a3a58d..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IVariantAssignmentRequest.cs +++ /dev/null @@ -1,15 +0,0 @@ -// 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. - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - public interface IVariantAssignmentRequest - { - /// <summary> - /// Gets inputs used for evaluating filters, assignment units, etc. - /// </summary> - IReadOnlyCollection<(string Key, string Value)> Parameters { get; } - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IVariantAssignmentResponse.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IVariantAssignmentResponse.cs deleted file mode 100644 index 29ee2209de..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/IVariantAssignmentResponse.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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. - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - /// <summary> - /// Snapshot of variant assignments. - /// </summary> - public interface IVariantAssignmentResponse : IDisposable - { - ///// <summary> - ///// Gets the serial number of variant assignment configuration snapshot used when assigning variants. - ///// </summary> - long DataVersion { get; } - - ///// <summary> - ///// Get a hash of the response suitable for caching. - ///// </summary> - // string Thumbprint { get; } - - /// <summary> - /// Gets the variants assigned based on request parameters and a variant configuration snapshot. - /// </summary> - IReadOnlyCollection<IAssignedVariant> AssignedVariants { get; } - - /// <summary> - /// Gets feature variables assigned by variants in this response. - /// </summary> - /// <param name="prefix">(Optional) Filter feature variables where <see cref="IFeatureVariable.KeySegments"/> contains the <paramref name="prefix"/>.</param> - /// <returns>Range of matching feature variables.</returns> - IReadOnlyList<IFeatureVariable> GetFeatureVariables(IReadOnlyList<string> prefix); - - // this actually part of the interface but gets the job done - IReadOnlyList<IFeatureVariable> GetFeatureVariables(); - - // this actually part of the interface but gets the job done - string GetAssignmentContext(); - - /// <summary> - /// Gets a single feature variable assigned by variants in this response. - /// </summary> - /// <param name="path">Exact feature variable path.</param> - /// <returns>Matching feature variable or null.</returns> - IFeatureVariable GetFeatureVariable(IReadOnlyList<string> path); - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/TreatmentAssignmentServiceResponse.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/TreatmentAssignmentServiceResponse.cs deleted file mode 100644 index 6db91f6ffd..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/TreatmentAssignmentServiceResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -// 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. - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - internal sealed class TreatmentAssignmentServiceResponse : VariantAssignmentServiceResponse - { - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/VariantAssignmentClientSettings.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/VariantAssignmentClientSettings.cs deleted file mode 100644 index f57986368c..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/VariantAssignmentClientSettings.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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.DataAnnotations; - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - /// <summary> - /// Configuration for variant assignment service client. - /// </summary> - public class VariantAssignmentClientSettings - { - /// <summary> - /// Gets or sets the variant assignment service endpoint URL. - /// </summary> - [Required] - public Uri? Endpoint { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether gets or sets a value whether client side request caching should be enabled. - /// </summary> - public bool EnableCaching { get; set; } - - /// <summary> - /// Gets or sets the maximum time a cached variant assignment response may be used without re-validating. - /// </summary> - public TimeSpan ResponseCacheTime { get; set; } - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/VariantAssignmentRequest.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/VariantAssignmentRequest.cs deleted file mode 100644 index 976ce53531..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/VariantAssignmentRequest.cs +++ /dev/null @@ -1,21 +0,0 @@ -// 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.Specialized; - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - public class VariantAssignmentRequest : IVariantAssignmentRequest - { - private NameValueCollection _parameters = new NameValueCollection(); - - /// <summary> - /// Gets or sets mutable <see cref="IVariantAssignmentRequest.Parameters"/>. - /// </summary> - public NameValueCollection Parameters { get => _parameters; set => _parameters = value; } - - IReadOnlyCollection<(string Key, string Value)> IVariantAssignmentRequest.Parameters => (IReadOnlyCollection<(string Key, string Value)>)_parameters; - } -} diff --git a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/VariantAssignmentServiceResponse.cs b/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/VariantAssignmentServiceResponse.cs deleted file mode 100644 index e87425f4d3..0000000000 --- a/src/common/AllExperiments/Microsoft.VariantAssignment/Contract/VariantAssignmentServiceResponse.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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. - -// The goal of this class is to just mock out the Microsoft.VariantAssignment close source objects -namespace Microsoft.VariantAssignment.Contract -{ - /// <summary> - /// Mutable implementation of <see cref="IVariantAssignmentResponse"/> for (de)serialization. - /// </summary> - internal class VariantAssignmentServiceResponse : IVariantAssignmentResponse, IDisposable - { - /// <inheritdoc /> - public virtual long DataVersion { get; set; } - - public virtual IReadOnlyCollection<IAssignedVariant> AssignedVariants { get; set; } = Array.Empty<IAssignedVariant>(); - - public IFeatureVariable GetFeatureVariable(IReadOnlyList<string> path) - { - throw new NotImplementedException(); - } - - public IReadOnlyList<IFeatureVariable> GetFeatureVariables(IReadOnlyList<string> prefix) - { - throw new NotImplementedException(); - } - - protected virtual void Dispose(bool disposing) - { - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public IReadOnlyList<IFeatureVariable> GetFeatureVariables() - { - throw new NotImplementedException(); - } - - public string GetAssignmentContext() - { - return string.Empty; - } - } -} diff --git a/src/common/COMUtils/COMUtils.vcxproj b/src/common/COMUtils/COMUtils.vcxproj index f582df593b..43a9e9f8d0 100644 --- a/src/common/COMUtils/COMUtils.vcxproj +++ b/src/common/COMUtils/COMUtils.vcxproj @@ -10,7 +10,7 @@ <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -21,7 +21,7 @@ <ItemDefinitionGroup> <ClCompile> <PrecompiledHeader>NotUsing</PrecompiledHeader> - <AdditionalIncludeDirectories>..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> </ItemDefinitionGroup> @@ -36,12 +36,12 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/CalculatorEngineCommon/Calculator.cpp b/src/common/CalculatorEngineCommon/Calculator.cpp new file mode 100644 index 0000000000..0d96945560 --- /dev/null +++ b/src/common/CalculatorEngineCommon/Calculator.cpp @@ -0,0 +1,24 @@ +#include "pch.h" +#include "Calculator.h" +#include "Calculator.g.cpp" +#include "ExprtkEvaluator.h" + +namespace winrt::CalculatorEngineCommon::implementation +{ + Calculator::Calculator(winrt::Windows::Foundation::Collections::IPropertySet const& constants) + { + for (auto const& pair : constants) + { + auto key = pair.Key(); + auto value = winrt::unbox_value<double>(pair.Value()); + m_constants.emplace(winrt::to_string(key), value); + } + } + + hstring Calculator::EvaluateExpression(hstring const& expression) + { + auto result = ExprtkCalculator::internal::EvaluateExpression(winrt::to_string(expression), m_constants); + + return hstring(result); + } +} diff --git a/src/common/CalculatorEngineCommon/Calculator.h b/src/common/CalculatorEngineCommon/Calculator.h new file mode 100644 index 0000000000..abf2a4ec0d --- /dev/null +++ b/src/common/CalculatorEngineCommon/Calculator.h @@ -0,0 +1,25 @@ +#pragma once + +#include "Calculator.g.h" + +namespace winrt::CalculatorEngineCommon::implementation +{ + struct Calculator : CalculatorT<Calculator> + { + Calculator() = default; + + Calculator(winrt::Windows::Foundation::Collections::IPropertySet const& constants); + + winrt::hstring EvaluateExpression(winrt::hstring const& expression); + + private: + std::unordered_map<std::string, double> m_constants; + }; +} + +namespace winrt::CalculatorEngineCommon::factory_implementation +{ + struct Calculator : CalculatorT<Calculator, implementation::Calculator> + { + }; +} diff --git a/src/common/CalculatorEngineCommon/Calculator.idl b/src/common/CalculatorEngineCommon/Calculator.idl new file mode 100644 index 0000000000..241a517a7d --- /dev/null +++ b/src/common/CalculatorEngineCommon/Calculator.idl @@ -0,0 +1,10 @@ +namespace CalculatorEngineCommon +{ + [default_interface] + runtimeclass Calculator + { + Calculator(); + Calculator(Windows.Foundation.Collections.IPropertySet constants); + String EvaluateExpression(String expression); + } +} diff --git a/src/common/CalculatorEngineCommon/CalculatorEngineCommon.def b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.def new file mode 100644 index 0000000000..24e7c1235c --- /dev/null +++ b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.def @@ -0,0 +1,3 @@ +EXPORTS +DllCanUnloadNow = WINRT_CanUnloadNow PRIVATE +DllGetActivationFactory = WINRT_GetActivationFactory PRIVATE diff --git a/src/common/CalculatorEngineCommon/CalculatorEngineCommon.rc b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.rc new file mode 100644 index 0000000000..5a515fef17 --- /dev/null +++ b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.rc @@ -0,0 +1,36 @@ +#include <windows.h> +#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/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj new file mode 100644 index 0000000000..b8f7ac11f6 --- /dev/null +++ b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj @@ -0,0 +1,190 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> + <CppWinRTGenerateWindowsMetadata>true</CppWinRTGenerateWindowsMetadata> + <ProjectGuid>{2cf78cf7-8feb-4be1-9591-55fa25b48fc6}</ProjectGuid> + <ProjectName>CalculatorEngineCommon</ProjectName> + <RootNamespace>CalculatorEngineCommon</RootNamespace> + <AppxPackage>false</AppxPackage> + </PropertyGroup> + <!-- BEGIN common.build.pre.props --> + <PropertyGroup Label="Configuration"> + <EnableHybridCRT>true</EnableHybridCRT> + <UseCrtSDKReferenceStaticWarning Condition="'$(EnableHybridCRT)'=='true'">false</UseCrtSDKReferenceStaticWarning> + </PropertyGroup> + <!-- END common.build.pre.props --> + <!-- BEGIN cppwinrt.build.pre.props --> + <PropertyGroup Label="Globals"> + <CppWinRTEnabled>true</CppWinRTEnabled> + <CppWinRTOptimized>true</CppWinRTOptimized> + <DefaultLanguage>en-US</DefaultLanguage> + <MinimumVisualStudioVersion>17.0</MinimumVisualStudioVersion> + <ApplicationTypeRevision>10.0</ApplicationTypeRevision> + </PropertyGroup> + <PropertyGroup> + <MinimalCoreWin>true</MinimalCoreWin> + <AppContainerApplication>true</AppContainerApplication> + <WindowsStoreApp>true</WindowsStoreApp> + <ApplicationType>Windows Store</ApplicationType> + <UseCrtSDKReference Condition="'$(EnableHybridCRT)'=='true'">false</UseCrtSDKReference> + <!-- The SDK reference breaks the Hybrid CRT --> + </PropertyGroup> + <PropertyGroup> + <!-- We have to use the Desktop platform for Hybrid CRT to work. --> + <_VC_Target_Library_Platform>Desktop</_VC_Target_Library_Platform> + <_NoWinAPIFamilyApp>true</_NoWinAPIFamilyApp> + </PropertyGroup> + <!-- END cppwinrt.build.pre.props --> + <PropertyGroup Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + + <CharacterSet>Unicode</CharacterSet> + <GenerateManifest>false</GenerateManifest> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> + <UseDebugLibraries>true</UseDebugLibraries> + <LinkIncremental>true</LinkIncremental> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> + <UseDebugLibraries>false</UseDebugLibraries> + <WholeProgramOptimization>true</WholeProgramOptimization> + <LinkIncremental>false</LinkIncremental> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="PropertySheet.props" /> + </ImportGroup> + <PropertyGroup> + <TargetName>CalculatorEngineCommon</TargetName> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> + </PropertyGroup> + <ItemDefinitionGroup> + <ClCompile> + <PrecompiledHeaderOutputFile>$(IntDir)pch.pch</PrecompiledHeaderOutputFile> + <WarningLevel>Level4</WarningLevel> + <AdditionalOptions>%(AdditionalOptions) /bigobj</AdditionalOptions> + <PreprocessorDefinitions>_WINRT_DLL;WINRT_LEAN_AND_MEAN;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <AdditionalIncludeDirectories>../../..;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalUsingDirectories>$(WindowsSDK_WindowsMetadata);$(AdditionalUsingDirectories)</AdditionalUsingDirectories> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <GenerateWindowsMetadata>false</GenerateWindowsMetadata> + <ModuleDefinitionFile>CalculatorEngineCommon.def</ModuleDefinitionFile> + <AdditionalDependencies>Shell32.lib;user32.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> + <ClCompile> + <PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> + </ClCompile> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> + <ClCompile> + <PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> + </ClCompile> + <Link> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ProjectReference Include="..\version\version.vcxproj"> + <Project>{cc6e41ac-8174-4e8a-8d22-85dd7f4851df}</Project> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <ClInclude Include="ExprtkEvaluator.h" /> + <ClInclude Include="pch.h" /> + <ClInclude Include="exprtk.hpp" /> + <ClInclude Include="Calculator.h"> + <DependentUpon>Calculator.idl</DependentUpon> + </ClInclude> + <ClInclude Include="resource.h" /> + </ItemGroup> + <ItemGroup> + <ClCompile Include="ExprtkEvaluator.cpp"> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + </ClCompile> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader>Create</PrecompiledHeader> + </ClCompile> + <ClCompile Include="Calculator.cpp"> + <DependentUpon>Calculator.idl</DependentUpon> + </ClCompile> + <ClCompile Include="$(GeneratedFilesDir)module.g.cpp" /> + </ItemGroup> + <ItemGroup> + <Midl Include="Calculator.idl" /> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + <None Include="CalculatorEngineCommon.def" /> + </ItemGroup> + <ItemGroup> + <None Include="PropertySheet.props" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="CalculatorEngineCommon.rc" /> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> + <!-- BEGIN common.build.post.props --> + <!-- + The Hybrid CRT model statically links the runtime and STL and dynamically + links the UCRT instead of the VC++ CRT. The UCRT ships with Windows. + WinAppSDK asserts that this is "supported according to the CRT maintainer." + + This must come before Microsoft.Cpp.targets because it manipulates ClCompile.RuntimeLibrary. + --> + <ItemDefinitionGroup Condition="'$(EnableHybridCRT)'=='true' and '$(Configuration)'=='Debug'"> + <ClCompile> + <!-- We use MultiThreadedDebug, rather than MultiThreadedDebugDLL, to avoid DLL dependencies on VCRUNTIME140d.dll and MSVCP140d.dll. --> + <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary> + <LanguageStandard Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">stdcpp17</LanguageStandard> + </ClCompile> + <Link> + <!-- Link statically against the runtime and STL, but link dynamically against the CRT by ignoring the static CRT + lib and instead linking against the Universal CRT DLL import library. This "hybrid" linking mechanism is + supported according to the CRT maintainer. Dynamic linking against the CRT makes the binaries a bit smaller + than they would otherwise be if the CRT, runtime, and STL were all statically linked in. --> + <IgnoreSpecificDefaultLibraries>%(IgnoreSpecificDefaultLibraries);libucrtd.lib</IgnoreSpecificDefaultLibraries> + <AdditionalOptions>%(AdditionalOptions) /defaultlib:ucrtd.lib</AdditionalOptions> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(EnableHybridCRT)'=='true' and ('$(Configuration)'=='Release' or '$(Configuration)'=='AuditMode')"> + <ClCompile> + <!-- We use MultiThreaded, rather than MultiThreadedDLL, to avoid DLL dependencies on VCRUNTIME140.dll and MSVCP140.dll. --> + <RuntimeLibrary>MultiThreaded</RuntimeLibrary> + </ClCompile> + <Link> + <!-- Link statically against the runtime and STL, but link dynamically against the CRT by ignoring the static CRT + lib and instead linking against the Universal CRT DLL import library. This "hybrid" linking mechanism is + supported according to the CRT maintainer. Dynamic linking against the CRT makes the binaries a bit smaller + than they would otherwise be if the CRT, runtime, and STL were all statically linked in. --> + <IgnoreSpecificDefaultLibraries>%(IgnoreSpecificDefaultLibraries);libucrt.lib</IgnoreSpecificDefaultLibraries> + <AdditionalOptions>%(AdditionalOptions) /defaultlib:ucrt.lib</AdditionalOptions> + </Link> + </ItemDefinitionGroup> + <!-- END common.build.post.props --> +</Project> \ No newline at end of file diff --git a/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj.filters b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj.filters new file mode 100644 index 0000000000..6b84b33112 --- /dev/null +++ b/src/common/CalculatorEngineCommon/CalculatorEngineCommon.vcxproj.filters @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Resources"> + <UniqueIdentifier>accd3aa8-1ba0-4223-9bbe-0c431709210b</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tga;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + <Filter Include="Generated Files"> + <UniqueIdentifier>{926ab91d-31b4-48c3-b9a4-e681349f27f0}</UniqueIdentifier> + </Filter> + </ItemGroup> + <ItemGroup> + <ClCompile Include="pch.cpp" /> + <ClCompile Include="$(GeneratedFilesDir)module.g.cpp" /> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h" /> + </ItemGroup> + <ItemGroup> + <Midl Include="Calculator.idl" /> + </ItemGroup> + <ItemGroup> + <None Include="CalculatorEngineCommon.def" /> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <None Include="PropertySheet.props" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/common/CalculatorEngineCommon/ExprtkEvaluator.cpp b/src/common/CalculatorEngineCommon/ExprtkEvaluator.cpp new file mode 100644 index 0000000000..eb04c18783 --- /dev/null +++ b/src/common/CalculatorEngineCommon/ExprtkEvaluator.cpp @@ -0,0 +1,71 @@ +#include "ExprtkEvaluator.h" +#include "exprtk.hpp" +#include <iomanip> +#include <iostream> +#include <sstream> +#include <cmath> +#include <limits> + +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<double>::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) + { + std::wostringstream oss; + oss << std::fixed << std::setprecision(15) << value; + return oss.str(); + } + + std::wstring EvaluateExpression( + const std::string& expressionText, + const std::unordered_map<std::string, double>& constants) + { + exprtk::symbol_table<double> symbol_table; + + for (auto const& [name, value] : constants) + { + symbol_table.add_constant(name, value); + } + + symbol_table.add_function("factorial", factorial); + symbol_table.add_function("sign", sign); + + exprtk::expression<double> expression; + expression.register_symbol_table(symbol_table); + + exprtk::parser<double> parser; + + // Enable all base functions and arithmetic operators + parser.settings().enable_all_base_functions(); // Enable all base functions like sin, cos, log, etc. + parser.settings().enable_all_arithmetic_ops(); // Enable all arithmetic operators like +, -, *, /, etc. + + // Disable all control structures and assignment operators to ensure only expressions are evaluated + parser.settings().disable_all_control_structures(); // Disable control structures like if, for, while, etc. + parser.settings().disable_all_assignment_ops(); // Disable assignment operators like =, +=, -=, etc. + + // Disabled for now, but can be enabled later for enhanced functionality + parser.settings().disable_all_logic_ops(); // Disable logical operators like &&, ||, !, etc. + parser.settings().disable_all_inequality_ops(); // Disable inequality operators like <, >, <=, >=, !=, etc. + + if (!parser.compile(expressionText, expression)) + return L"NaN"; + + return ToWStringFullPrecision(expression.value()); + } +} \ No newline at end of file diff --git a/src/common/CalculatorEngineCommon/ExprtkEvaluator.h b/src/common/CalculatorEngineCommon/ExprtkEvaluator.h new file mode 100644 index 0000000000..bd09774b96 --- /dev/null +++ b/src/common/CalculatorEngineCommon/ExprtkEvaluator.h @@ -0,0 +1,10 @@ +#pragma once +#include <string> +#include <unordered_map> + +namespace ExprtkCalculator::internal +{ + std::wstring EvaluateExpression( + const std::string& expression, + const std::unordered_map<std::string, double>& constants); +} \ No newline at end of file diff --git a/src/common/CalculatorEngineCommon/PropertySheet.props b/src/common/CalculatorEngineCommon/PropertySheet.props new file mode 100644 index 0000000000..e34141b019 --- /dev/null +++ b/src/common/CalculatorEngineCommon/PropertySheet.props @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ImportGroup Label="PropertySheets" /> + <PropertyGroup Label="UserMacros" /> + <!-- + To customize common C++/WinRT project properties: + * right-click the project node + * expand the Common Properties item + * select the C++/WinRT property page + + For more advanced scenarios, and complete documentation, please see: + https://github.com/Microsoft/cppwinrt/tree/master/nuget + --> + <PropertyGroup /> + <ItemDefinitionGroup /> +</Project> \ No newline at end of file diff --git a/src/common/CalculatorEngineCommon/exprtk.hpp b/src/common/CalculatorEngineCommon/exprtk.hpp new file mode 100644 index 0000000000..6ad76542f4 --- /dev/null +++ b/src/common/CalculatorEngineCommon/exprtk.hpp @@ -0,0 +1,46251 @@ +/* + ****************************************************************** + * C++ Mathematical Expression Toolkit Library * + * * + * Author: Arash Partow (1999-2024) * + * URL: https://www.partow.net/programming/exprtk/index.html * + * * + * Copyright notice: * + * Free use of the C++ Mathematical Expression Toolkit Library is * + * permitted under the guidelines and in accordance with the most * + * current version of the MIT License. * + * https://www.opensource.org/licenses/MIT * + * SPDX-License-Identifier: MIT * + * * + * Example expressions: * + * (00) (y + x / y) * (x - y / x) * + * (01) (x^2 / sin(2 * pi / y)) - x / 2 * + * (02) sqrt(1 - (x^2)) * + * (03) 1 - sin(2 * x) + cos(pi / y) * + * (04) a * exp(2 * t) + c * + * (05) if(((x + 2) == 3) and ((y + 5) <= 9), 1 + w, 2 / z) * + * (06) (avg(x,y) <= x + y ? x - y : x * y) + 2 * pi / x * + * (07) z := x + sin(2 * pi / y) * + * (08) u := 2 * (pi * z) / (w := x + cos(y / pi)) * + * (09) clamp(-1, sin(2 * pi * x) + cos(y / 2 * pi), +1) * + * (10) inrange(-2, m, +2) == if(({-2 <= m} and [m <= +2]), 1, 0) * + * (11) (2sin(x)cos(2y)7 + 1) == (2 * sin(x) * cos(2*y) * 7 + 1) * + * (12) (x ilike 's*ri?g') and [y < (3 z^7 + w)] * + * * + ****************************************************************** +*/ + +#pragma system_header + +#ifndef INCLUDE_EXPRTK_HPP +#define INCLUDE_EXPRTK_HPP + + +#include <algorithm> +#include <cassert> +#include <cctype> +#include <cmath> +#include <cstdio> +#include <cstdlib> +#include <cstring> +#include <deque> +#include <functional> +#include <iterator> +#include <limits> +#include <list> +#include <map> +#include <set> +#include <stack> +#include <stdexcept> +#include <string> +#include <utility> +#include <vector> + + +namespace exprtk +{ + #ifdef exprtk_enable_debugging + #define exprtk_debug(params) printf params + #else + #define exprtk_debug(params) (void)0 + #endif + + #define exprtk_error_location \ + "exprtk.hpp:" + details::to_str(__LINE__) \ + + #if __cplusplus >= 201103L + #define exprtk_override override + #define exprtk_final final + #define exprtk_delete = delete + #else + #define exprtk_override + #define exprtk_final + #define exprtk_delete + #endif + + #if __cplusplus >= 201603L + #define exprtk_fallthrough [[fallthrough]]; + #elif (__cplusplus >= 201103L) && (defined(__GNUC__) && !defined(__clang__)) + #define exprtk_fallthrough [[gnu::fallthrough]]; + #else + #ifndef _MSC_VER + #define exprtk_fallthrough __attribute__ ((fallthrough)); + #else + #define exprtk_fallthrough + #endif + #endif + + namespace details + { + typedef char char_t; + typedef char_t* char_ptr; + typedef char_t const* char_cptr; + typedef unsigned char uchar_t; + typedef uchar_t* uchar_ptr; + typedef uchar_t const* uchar_cptr; + typedef unsigned long long int _uint64_t; + typedef long long int _int64_t; + + inline bool is_whitespace(const char_t c) + { + return (' ' == c) || ('\n' == c) || + ('\r' == c) || ('\t' == c) || + ('\b' == c) || ('\v' == c) || + ('\f' == c) ; + } + + inline bool is_operator_char(const char_t c) + { + return ('+' == c) || ('-' == c) || + ('*' == c) || ('/' == c) || + ('^' == c) || ('<' == c) || + ('>' == c) || ('=' == c) || + (',' == c) || ('!' == c) || + ('(' == c) || (')' == c) || + ('[' == c) || (']' == c) || + ('{' == c) || ('}' == c) || + ('%' == c) || (':' == c) || + ('?' == c) || ('&' == c) || + ('|' == c) || (';' == c) ; + } + + inline bool is_letter(const char_t c) + { + return (('a' <= c) && (c <= 'z')) || + (('A' <= c) && (c <= 'Z')) ; + } + + inline bool is_digit(const char_t c) + { + return ('0' <= c) && (c <= '9'); + } + + inline bool is_letter_or_digit(const char_t c) + { + return is_letter(c) || is_digit(c); + } + + inline bool is_left_bracket(const char_t c) + { + return ('(' == c) || ('[' == c) || ('{' == c); + } + + inline bool is_right_bracket(const char_t c) + { + return (')' == c) || (']' == c) || ('}' == c); + } + + inline bool is_bracket(const char_t c) + { + return is_left_bracket(c) || is_right_bracket(c); + } + + inline bool is_sign(const char_t c) + { + return ('+' == c) || ('-' == c); + } + + inline bool is_invalid(const char_t c) + { + return !is_whitespace (c) && + !is_operator_char(c) && + !is_letter (c) && + !is_digit (c) && + ('.' != c) && + ('_' != c) && + ('$' != c) && + ('~' != c) && + ('\'' != c); + } + + inline bool is_valid_string_char(const char_t c) + { + return std::isprint(static_cast<uchar_t>(c)) || + is_whitespace(c); + } + + #ifndef exprtk_disable_caseinsensitivity + inline void case_normalise(std::string& s) + { + for (std::size_t i = 0; i < s.size(); ++i) + { + s[i] = static_cast<std::string::value_type>(std::tolower(s[i])); + } + } + + inline bool imatch(const char_t c1, const char_t c2) + { + return std::tolower(c1) == std::tolower(c2); + } + + inline bool imatch(const std::string& s1, const std::string& s2) + { + if (s1.size() == s2.size()) + { + for (std::size_t i = 0; i < s1.size(); ++i) + { + if (std::tolower(s1[i]) != std::tolower(s2[i])) + { + return false; + } + } + + return true; + } + + return false; + } + + struct ilesscompare + { + inline bool operator() (const std::string& s1, const std::string& s2) const + { + const std::size_t length = std::min(s1.size(),s2.size()); + + for (std::size_t i = 0; i < length; ++i) + { + const char_t c1 = static_cast<char_t>(std::tolower(s1[i])); + const char_t c2 = static_cast<char_t>(std::tolower(s2[i])); + + if (c1 < c2) + return true; + else if (c2 < c1) + return false; + } + + return s1.size() < s2.size(); + } + }; + + #else + inline void case_normalise(std::string&) + {} + + inline bool imatch(const char_t c1, const char_t c2) + { + return c1 == c2; + } + + inline bool imatch(const std::string& s1, const std::string& s2) + { + return s1 == s2; + } + + struct ilesscompare + { + inline bool operator() (const std::string& s1, const std::string& s2) const + { + return s1 < s2; + } + }; + #endif + + inline bool is_valid_sf_symbol(const std::string& symbol) + { + // Special function: $f12 or $F34 + return (4 == symbol.size()) && + ('$' == symbol[0]) && + imatch('f',symbol[1]) && + is_digit(symbol[2]) && + is_digit(symbol[3]); + } + + inline const char_t& front(const std::string& s) + { + return s[0]; + } + + inline const char_t& back(const std::string& s) + { + return s[s.size() - 1]; + } + + template <typename SignedType> + inline std::string to_str_impl(SignedType i) + { + if (0 == i) + return std::string("0"); + + std::string result; + + const int sign = (i < 0) ? -1 : 1; + + for ( ; i; i /= 10) + { + result += '0' + static_cast<char_t>(sign * (i % 10)); + } + + if (sign < 0) + { + result += '-'; + } + + std::reverse(result.begin(), result.end()); + + return result; + } + + inline std::string to_str(int i) + { + return to_str_impl(i); + } + + inline std::string to_str(std::size_t i) + { + return to_str_impl(static_cast<long long int>(i)); + } + + inline bool is_hex_digit(const uchar_t digit) + { + return (('0' <= digit) && (digit <= '9')) || + (('A' <= digit) && (digit <= 'F')) || + (('a' <= digit) && (digit <= 'f')) ; + } + + inline uchar_t hex_to_bin(uchar_t h) + { + if (('0' <= h) && (h <= '9')) + return (h - '0'); + else + return static_cast<uchar_t>(std::toupper(h) - 'A' + 10); + } + + template <typename Iterator> + inline bool parse_hex(Iterator& itr, Iterator end, + char_t& result) + { + if ( + (end == (itr )) || + (end == (itr + 1)) || + (end == (itr + 2)) || + (end == (itr + 3)) || + ('0' != *(itr )) || + ('X' != std::toupper(*(itr + 1))) || + (!is_hex_digit(*(itr + 2))) || + (!is_hex_digit(*(itr + 3))) + ) + { + return false; + } + + result = hex_to_bin(static_cast<uchar_t>(*(itr + 2))) << 4 | + hex_to_bin(static_cast<uchar_t>(*(itr + 3))) ; + + return true; + } + + inline bool cleanup_escapes(std::string& s) + { + typedef std::string::iterator str_itr_t; + + str_itr_t itr1 = s.begin(); + str_itr_t itr2 = s.begin(); + str_itr_t end = s.end (); + + std::size_t removal_count = 0; + + while (end != itr1) + { + if ('\\' == (*itr1)) + { + if (end == ++itr1) + { + return false; + } + else if (parse_hex(itr1, end, *itr2)) + { + itr1 += 4; + itr2 += 1; + removal_count += 4; + } + else if ('a' == (*itr1)) { (*itr2++) = '\a'; ++itr1; ++removal_count; } + else if ('b' == (*itr1)) { (*itr2++) = '\b'; ++itr1; ++removal_count; } + else if ('f' == (*itr1)) { (*itr2++) = '\f'; ++itr1; ++removal_count; } + else if ('n' == (*itr1)) { (*itr2++) = '\n'; ++itr1; ++removal_count; } + else if ('r' == (*itr1)) { (*itr2++) = '\r'; ++itr1; ++removal_count; } + else if ('t' == (*itr1)) { (*itr2++) = '\t'; ++itr1; ++removal_count; } + else if ('v' == (*itr1)) { (*itr2++) = '\v'; ++itr1; ++removal_count; } + else if ('0' == (*itr1)) { (*itr2++) = '\0'; ++itr1; ++removal_count; } + else + { + (*itr2++) = (*itr1++); + ++removal_count; + } + + continue; + } + else + (*itr2++) = (*itr1++); + } + + if ((removal_count > s.size()) || (0 == removal_count)) + return false; + + s.resize(s.size() - removal_count); + + return true; + } + + class build_string + { + public: + + explicit build_string(const std::size_t& initial_size = 64) + { + data_.reserve(initial_size); + } + + inline build_string& operator << (const std::string& s) + { + data_ += s; + return (*this); + } + + inline build_string& operator << (char_cptr s) + { + data_ += std::string(s); + return (*this); + } + + inline operator std::string () const + { + return data_; + } + + inline std::string as_string() const + { + return data_; + } + + private: + + std::string data_; + }; + + static const std::string reserved_words[] = + { + "assert", "break", "case", "continue", "const", "default", + "false", "for", "if", "else", "ilike", "in", "like", "and", + "nand", "nor", "not", "null", "or", "repeat", "return", + "shl", "shr", "swap", "switch", "true", "until", "var", + "while", "xnor", "xor", "&", "|" + }; + + static const std::size_t reserved_words_size = sizeof(reserved_words) / sizeof(std::string); + + static const std::string reserved_symbols[] = + { + "abs", "acos", "acosh", "and", "asin", "asinh", "assert", + "atan", "atanh", "atan2", "avg", "break", "case", "ceil", + "clamp", "continue", "const", "cos", "cosh", "cot", "csc", + "default", "deg2grad", "deg2rad", "equal", "erf", "erfc", + "exp", "expm1", "false", "floor", "for", "frac", "grad2deg", + "hypot", "iclamp", "if", "else", "ilike", "in", "inrange", + "like", "log", "log10", "log2", "logn", "log1p", "mand", + "max", "min", "mod", "mor", "mul", "ncdf", "nand", "nor", + "not", "not_equal", "null", "or", "pow", "rad2deg", + "repeat", "return", "root", "round", "roundn", "sec", "sgn", + "shl", "shr", "sin", "sinc", "sinh", "sqrt", "sum", "swap", + "switch", "tan", "tanh", "true", "trunc", "until", "var", + "while", "xnor", "xor", "&", "|" + }; + + static const std::size_t reserved_symbols_size = sizeof(reserved_symbols) / sizeof(std::string); + + static const std::string base_function_list[] = + { + "abs", "acos", "acosh", "asin", "asinh", "atan", "atanh", + "atan2", "avg", "ceil", "clamp", "cos", "cosh", "cot", + "csc", "equal", "erf", "erfc", "exp", "expm1", "floor", + "frac", "hypot", "iclamp", "like", "log", "log10", "log2", + "logn", "log1p", "mand", "max", "min", "mod", "mor", "mul", + "ncdf", "pow", "root", "round", "roundn", "sec", "sgn", + "sin", "sinc", "sinh", "sqrt", "sum", "swap", "tan", "tanh", + "trunc", "not_equal", "inrange", "deg2grad", "deg2rad", + "rad2deg", "grad2deg" + }; + + static const std::size_t base_function_list_size = sizeof(base_function_list) / sizeof(std::string); + + static const std::string logic_ops_list[] = + { + "and", "nand", "nor", "not", "or", "xnor", "xor", "&", "|" + }; + + static const std::size_t logic_ops_list_size = sizeof(logic_ops_list) / sizeof(std::string); + + static const std::string cntrl_struct_list[] = + { + "if", "switch", "for", "while", "repeat", "return" + }; + + static const std::size_t cntrl_struct_list_size = sizeof(cntrl_struct_list) / sizeof(std::string); + + static const std::string arithmetic_ops_list[] = + { + "+", "-", "*", "/", "%", "^" + }; + + static const std::size_t arithmetic_ops_list_size = sizeof(arithmetic_ops_list) / sizeof(std::string); + + static const std::string assignment_ops_list[] = + { + ":=", "+=", "-=", + "*=", "/=", "%=" + }; + + static const std::size_t assignment_ops_list_size = sizeof(assignment_ops_list) / sizeof(std::string); + + static const std::string inequality_ops_list[] = + { + "<", "<=", "==", + "=", "!=", "<>", + ">=", ">" + }; + + static const std::size_t inequality_ops_list_size = sizeof(inequality_ops_list) / sizeof(std::string); + + inline bool is_reserved_word(const std::string& symbol) + { + for (std::size_t i = 0; i < reserved_words_size; ++i) + { + if (imatch(symbol, reserved_words[i])) + { + return true; + } + } + + return false; + } + + inline bool is_reserved_symbol(const std::string& symbol) + { + for (std::size_t i = 0; i < reserved_symbols_size; ++i) + { + if (imatch(symbol, reserved_symbols[i])) + { + return true; + } + } + + return false; + } + + inline bool is_base_function(const std::string& function_name) + { + for (std::size_t i = 0; i < base_function_list_size; ++i) + { + if (imatch(function_name, base_function_list[i])) + { + return true; + } + } + + return false; + } + + inline bool is_control_struct(const std::string& cntrl_strct) + { + for (std::size_t i = 0; i < cntrl_struct_list_size; ++i) + { + if (imatch(cntrl_strct, cntrl_struct_list[i])) + { + return true; + } + } + + return false; + } + + inline bool is_logic_opr(const std::string& lgc_opr) + { + for (std::size_t i = 0; i < logic_ops_list_size; ++i) + { + if (imatch(lgc_opr, logic_ops_list[i])) + { + return true; + } + } + + return false; + } + + struct cs_match + { + static inline bool cmp(const char_t c0, const char_t c1) + { + return (c0 == c1); + } + }; + + struct cis_match + { + static inline bool cmp(const char_t c0, const char_t c1) + { + return (std::tolower(c0) == std::tolower(c1)); + } + }; + + template <typename Iterator, typename Compare> + inline bool match_impl(const Iterator pattern_begin, + const Iterator pattern_end , + const Iterator data_begin , + const Iterator data_end , + const typename std::iterator_traits<Iterator>::value_type& zero_or_more, + const typename std::iterator_traits<Iterator>::value_type& exactly_one ) + { + typedef typename std::iterator_traits<Iterator>::value_type type; + + const Iterator null_itr(0); + + Iterator p_itr = pattern_begin; + Iterator d_itr = data_begin; + Iterator np_itr = null_itr; + Iterator nd_itr = null_itr; + + for ( ; ; ) + { + if (p_itr != pattern_end) + { + const type c = *(p_itr); + + if ((data_end != d_itr) && (Compare::cmp(c,*(d_itr)) || (exactly_one == c))) + { + ++d_itr; + ++p_itr; + continue; + } + else if (zero_or_more == c) + { + while ((pattern_end != p_itr) && (zero_or_more == *(p_itr))) + { + ++p_itr; + } + + const type d = *(p_itr); + + while ((data_end != d_itr) && !(Compare::cmp(d,*(d_itr)) || (exactly_one == d))) + { + ++d_itr; + } + + // set backtrack iterators + np_itr = p_itr - 1; + nd_itr = d_itr + 1; + + continue; + } + } + else if (data_end == d_itr) + break; + + if ((data_end == d_itr) || (null_itr == nd_itr)) + return false; + + p_itr = np_itr; + d_itr = nd_itr; + } + + return true; + } + + inline bool wc_match(const std::string& wild_card, + const std::string& str) + { + return match_impl<char_cptr,cs_match> + ( + wild_card.data(), + wild_card.data() + wild_card.size(), + str.data(), + str.data() + str.size(), + '*', '?' + ); + } + + inline bool wc_imatch(const std::string& wild_card, + const std::string& str) + { + return match_impl<char_cptr,cis_match> + ( + wild_card.data(), + wild_card.data() + wild_card.size(), + str.data(), + str.data() + str.size(), + '*', '?' + ); + } + + inline bool sequence_match(const std::string& pattern, + const std::string& str, + std::size_t& diff_index, + char_t& diff_value) + { + if (str.empty()) + { + return ("Z" == pattern); + } + else if ('*' == pattern[0]) + return false; + + typedef std::string::const_iterator itr_t; + + itr_t p_itr = pattern.begin(); + itr_t s_itr = str .begin(); + + const itr_t p_end = pattern.end(); + const itr_t s_end = str .end(); + + while ((s_end != s_itr) && (p_end != p_itr)) + { + if ('*' == (*p_itr)) + { + const char_t target = static_cast<char_t>(std::toupper(*(p_itr - 1))); + + if ('*' == target) + { + diff_index = static_cast<std::size_t>(std::distance(str.begin(),s_itr)); + diff_value = static_cast<char_t>(std::toupper(*p_itr)); + + return false; + } + else + ++p_itr; + + while (s_itr != s_end) + { + if (target != std::toupper(*s_itr)) + break; + else + ++s_itr; + } + + continue; + } + else if ( + ('?' != *p_itr) && + std::toupper(*p_itr) != std::toupper(*s_itr) + ) + { + diff_index = static_cast<std::size_t>(std::distance(str.begin(),s_itr)); + diff_value = static_cast<char_t>(std::toupper(*p_itr)); + + return false; + } + + ++p_itr; + ++s_itr; + } + + return ( + (s_end == s_itr) && + ( + (p_end == p_itr) || + ('*' == *p_itr) + ) + ); + } + + template<typename T> + struct set_zero_value_impl + { + static inline void process(T* base_ptr, const std::size_t size) + { + const T zero = T(0); + for (std::size_t i = 0; i < size; ++i) + { + base_ptr[i] = zero; + } + } + }; + + #define pod_set_zero_value(T) \ + template <> \ + struct set_zero_value_impl<T> \ + { \ + static inline void process(T* base_ptr, const std::size_t size) \ + { std::memset(base_ptr, 0x00, size * sizeof(T)); } \ + }; \ + + pod_set_zero_value(float ) + pod_set_zero_value(double ) + pod_set_zero_value(long double) + + #ifdef pod_set_zero_value + #undef pod_set_zero_value + #endif + + template<typename T> + inline void set_zero_value(T* data, const std::size_t size) + { + set_zero_value_impl<T>::process(data,size); + } + + template<typename T> + inline void set_zero_value(std::vector<T>& v) + { + set_zero_value(v.data(),v.size()); + } + + static const double pow10[] = + { + 1.0, + 1.0E+001, 1.0E+002, 1.0E+003, 1.0E+004, + 1.0E+005, 1.0E+006, 1.0E+007, 1.0E+008, + 1.0E+009, 1.0E+010, 1.0E+011, 1.0E+012, + 1.0E+013, 1.0E+014, 1.0E+015, 1.0E+016 + }; + + static const std::size_t pow10_size = sizeof(pow10) / sizeof(double); + + namespace numeric + { + namespace constant + { + static const double e = 2.71828182845904523536028747135266249775724709369996; + static const double pi = 3.14159265358979323846264338327950288419716939937510; + static const double pi_2 = 1.57079632679489661923132169163975144209858469968755; + static const double pi_4 = 0.78539816339744830961566084581987572104929234984378; + static const double pi_180 = 0.01745329251994329576923690768488612713442871888542; + static const double _1_pi = 0.31830988618379067153776752674502872406891929148091; + static const double _2_pi = 0.63661977236758134307553505349005744813783858296183; + static const double _180_pi = 57.29577951308232087679815481410517033240547246656443; + static const double log2 = 0.69314718055994530941723212145817656807550013436026; + static const double sqrt2 = 1.41421356237309504880168872420969807856967187537695; + } + + namespace details + { + struct unknown_type_tag { unknown_type_tag() {} }; + struct real_type_tag { real_type_tag () {} }; + struct int_type_tag { int_type_tag () {} }; + + template <typename T> + struct number_type + { + typedef unknown_type_tag type; + number_type() {} + }; + + #define exprtk_register_real_type_tag(T) \ + template <> struct number_type<T> \ + { typedef real_type_tag type; number_type() {} }; \ + + #define exprtk_register_int_type_tag(T) \ + template <> struct number_type<T> \ + { typedef int_type_tag type; number_type() {} }; \ + + exprtk_register_real_type_tag(float ) + exprtk_register_real_type_tag(double ) + exprtk_register_real_type_tag(long double) + + exprtk_register_int_type_tag(short ) + exprtk_register_int_type_tag(int ) + exprtk_register_int_type_tag(_int64_t ) + exprtk_register_int_type_tag(unsigned short) + exprtk_register_int_type_tag(unsigned int ) + exprtk_register_int_type_tag(_uint64_t ) + + #undef exprtk_register_real_type_tag + #undef exprtk_register_int_type_tag + + template <typename T> + struct epsilon_type {}; + + #define exprtk_define_epsilon_type(Type, Epsilon) \ + template <> struct epsilon_type<Type> \ + { \ + static inline Type value() \ + { \ + const Type epsilon = static_cast<Type>(Epsilon); \ + return epsilon; \ + } \ + }; \ + + exprtk_define_epsilon_type(float , 0.00000100000f) + exprtk_define_epsilon_type(double , 0.000000000100) + exprtk_define_epsilon_type(long double, 0.000000000001) + + #undef exprtk_define_epsilon_type + + template <typename T> + inline bool is_nan_impl(const T v, real_type_tag) + { + return std::not_equal_to<T>()(v,v); + } + + template <typename T> + inline int to_int32_impl(const T v, real_type_tag) + { + return static_cast<int>(v); + } + + template <typename T> + inline _int64_t to_int64_impl(const T v, real_type_tag) + { + return static_cast<_int64_t>(v); + } + + template <typename T> + inline _uint64_t to_uint64_impl(const T v, real_type_tag) + { + return static_cast<_uint64_t>(v); + } + + template <typename T> + inline bool is_true_impl(const T v) + { + return std::not_equal_to<T>()(T(0),v); + } + + template <typename T> + inline bool is_false_impl(const T v) + { + return std::equal_to<T>()(T(0),v); + } + + template <typename T> + inline T abs_impl(const T v, real_type_tag) + { + return ((v < T(0)) ? -v : v); + } + + template <typename T> + inline T min_impl(const T v0, const T v1, real_type_tag) + { + return std::min<T>(v0,v1); + } + + template <typename T> + inline T max_impl(const T v0, const T v1, real_type_tag) + { + return std::max<T>(v0,v1); + } + + template <typename T> + inline T equal_impl(const T v0, const T v1, real_type_tag) + { + const T epsilon = epsilon_type<T>::value(); + return (abs_impl(v0 - v1,real_type_tag()) <= (std::max(T(1),std::max(abs_impl(v0,real_type_tag()),abs_impl(v1,real_type_tag()))) * epsilon)) ? T(1) : T(0); + } + + inline float equal_impl(const float v0, const float v1, real_type_tag) + { + const float epsilon = epsilon_type<float>::value(); + return (abs_impl(v0 - v1,real_type_tag()) <= (std::max(1.0f,std::max(abs_impl(v0,real_type_tag()),abs_impl(v1,real_type_tag()))) * epsilon)) ? 1.0f : 0.0f; + } + + template <typename T> + inline T equal_impl(const T v0, const T v1, int_type_tag) + { + return (v0 == v1) ? 1 : 0; + } + + template <typename T> + inline T nequal_impl(const T v0, const T v1, real_type_tag) + { + typedef real_type_tag rtg; + const T epsilon = epsilon_type<T>::value(); + return (abs_impl(v0 - v1,rtg()) > (std::max(T(1),std::max(abs_impl(v0,rtg()),abs_impl(v1,rtg()))) * epsilon)) ? T(1) : T(0); + } + + inline float nequal_impl(const float v0, const float v1, real_type_tag) + { + typedef real_type_tag rtg; + const float epsilon = epsilon_type<float>::value(); + return (abs_impl(v0 - v1,rtg()) > (std::max(1.0f,std::max(abs_impl(v0,rtg()),abs_impl(v1,rtg()))) * epsilon)) ? 1.0f : 0.0f; + } + + template <typename T> + inline T nequal_impl(const T v0, const T v1, int_type_tag) + { + return (v0 != v1) ? 1 : 0; + } + + template <typename T> + inline T modulus_impl(const T v0, const T v1, real_type_tag) + { + return std::fmod(v0,v1); + } + + template <typename T> + inline T modulus_impl(const T v0, const T v1, int_type_tag) + { + return v0 % v1; + } + + template <typename T> + inline T pow_impl(const T v0, const T v1, real_type_tag) + { + return std::pow(v0,v1); + } + + template <typename T> + inline T pow_impl(const T v0, const T v1, int_type_tag) + { + return std::pow(static_cast<double>(v0),static_cast<double>(v1)); + } + + template <typename T> + inline T logn_impl(const T v0, const T v1, real_type_tag) + { + return std::log(v0) / std::log(v1); + } + + template <typename T> + inline T logn_impl(const T v0, const T v1, int_type_tag) + { + return static_cast<T>(logn_impl<double>(static_cast<double>(v0),static_cast<double>(v1),real_type_tag())); + } + + template <typename T> + inline T root_impl(const T v0, const T v1, real_type_tag) + { + if (v0 < T(0)) + { + return (v1 == trunc_impl(v1, real_type_tag())) && + (modulus_impl(v1, T(2), real_type_tag()) != T(0)) ? + -std::pow(abs_impl(v0, real_type_tag()), T(1) / v1) : + std::numeric_limits<double>::quiet_NaN(); + } + + return std::pow(v0, T(1) / v1); + } + + template <typename T> + inline T root_impl(const T v0, const T v1, int_type_tag) + { + return root_impl<double>(static_cast<double>(v0),static_cast<double>(v1),real_type_tag()); + } + + template <typename T> + inline T round_impl(const T v, real_type_tag) + { + return ((v < T(0)) ? std::ceil(v - T(0.5)) : std::floor(v + T(0.5))); + } + + template <typename T> + inline T roundn_impl(const T v0, const T v1, real_type_tag) + { + const int index = std::max<int>(0, std::min<int>(pow10_size - 1, static_cast<int>(std::floor(v1)))); + const T p10 = T(pow10[index]); + + if (v0 < T(0)) + return T(std::ceil ((v0 * p10) - T(0.5)) / p10); + else + return T(std::floor((v0 * p10) + T(0.5)) / p10); + } + + template <typename T> + inline T roundn_impl(const T v0, const T, int_type_tag) + { + return v0; + } + + template <typename T> + inline T hypot_impl(const T v0, const T v1, real_type_tag) + { + return std::sqrt((v0 * v0) + (v1 * v1)); + } + + template <typename T> + inline T hypot_impl(const T v0, const T v1, int_type_tag) + { + return static_cast<T>(std::sqrt(static_cast<double>((v0 * v0) + (v1 * v1)))); + } + + template <typename T> + inline T atan2_impl(const T v0, const T v1, real_type_tag) + { + return std::atan2(v0,v1); + } + + template <typename T> + inline T atan2_impl(const T, const T, int_type_tag) + { + return 0; + } + + template <typename T> + inline T shr_impl(const T v0, const T v1, real_type_tag) + { + return v0 * (T(1) / std::pow(T(2),static_cast<T>(static_cast<int>(v1)))); + } + + template <typename T> + inline T shr_impl(const T v0, const T v1, int_type_tag) + { + return v0 >> v1; + } + + template <typename T> + inline T shl_impl(const T v0, const T v1, real_type_tag) + { + return v0 * std::pow(T(2),static_cast<T>(static_cast<int>(v1))); + } + + template <typename T> + inline T shl_impl(const T v0, const T v1, int_type_tag) + { + return v0 << v1; + } + + template <typename T> + inline T sgn_impl(const T v, real_type_tag) + { + if (v > T(0)) return T(+1); + else if (v < T(0)) return T(-1); + else return T( 0); + } + + template <typename T> + inline T sgn_impl(const T v, int_type_tag) + { + if (v > T(0)) return T(+1); + else if (v < T(0)) return T(-1); + else return T( 0); + } + + template <typename T> + inline T and_impl(const T v0, const T v1, real_type_tag) + { + return (is_true_impl(v0) && is_true_impl(v1)) ? T(1) : T(0); + } + + template <typename T> + inline T and_impl(const T v0, const T v1, int_type_tag) + { + return v0 && v1; + } + + template <typename T> + inline T nand_impl(const T v0, const T v1, real_type_tag) + { + return (is_false_impl(v0) || is_false_impl(v1)) ? T(1) : T(0); + } + + template <typename T> + inline T nand_impl(const T v0, const T v1, int_type_tag) + { + return !(v0 && v1); + } + + template <typename T> + inline T or_impl(const T v0, const T v1, real_type_tag) + { + return (is_true_impl(v0) || is_true_impl(v1)) ? T(1) : T(0); + } + + template <typename T> + inline T or_impl(const T v0, const T v1, int_type_tag) + { + return (v0 || v1); + } + + template <typename T> + inline T nor_impl(const T v0, const T v1, real_type_tag) + { + return (is_false_impl(v0) && is_false_impl(v1)) ? T(1) : T(0); + } + + template <typename T> + inline T nor_impl(const T v0, const T v1, int_type_tag) + { + return !(v0 || v1); + } + + template <typename T> + inline T xor_impl(const T v0, const T v1, real_type_tag) + { + return (is_false_impl(v0) != is_false_impl(v1)) ? T(1) : T(0); + } + + template <typename T> + inline T xor_impl(const T v0, const T v1, int_type_tag) + { + return v0 ^ v1; + } + + template <typename T> + inline T xnor_impl(const T v0, const T v1, real_type_tag) + { + const bool v0_true = is_true_impl(v0); + const bool v1_true = is_true_impl(v1); + + if ((v0_true && v1_true) || (!v0_true && !v1_true)) + return T(1); + else + return T(0); + } + + template <typename T> + inline T xnor_impl(const T v0, const T v1, int_type_tag) + { + const bool v0_true = is_true_impl(v0); + const bool v1_true = is_true_impl(v1); + + if ((v0_true && v1_true) || (!v0_true && !v1_true)) + return T(1); + else + return T(0); + } + + #if (defined(_MSC_VER) && (_MSC_VER >= 1900)) || !defined(_MSC_VER) + #define exprtk_define_erf(TT, impl) \ + inline TT erf_impl(const TT v) { return impl(v); } \ + + exprtk_define_erf(float , ::erff) + exprtk_define_erf(double , ::erf ) + exprtk_define_erf(long double, ::erfl) + #undef exprtk_define_erf + #endif + + template <typename T> + inline T erf_impl(const T v, real_type_tag) + { + #if defined(_MSC_VER) && (_MSC_VER < 1900) + // Credits: Abramowitz & Stegun Equations 7.1.25-28 + static const T c[] = + { + T( 1.26551223), T(1.00002368), + T( 0.37409196), T(0.09678418), + T(-0.18628806), T(0.27886807), + T(-1.13520398), T(1.48851587), + T(-0.82215223), T(0.17087277) + }; + + const T t = T(1) / (T(1) + T(0.5) * abs_impl(v,real_type_tag())); + + const T result = T(1) - t * std::exp((-v * v) - + c[0] + t * (c[1] + t * + (c[2] + t * (c[3] + t * + (c[4] + t * (c[5] + t * + (c[6] + t * (c[7] + t * + (c[8] + t * (c[9])))))))))); + + return (v >= T(0)) ? result : -result; + #else + return erf_impl(v); + #endif + } + + template <typename T> + inline T erf_impl(const T v, int_type_tag) + { + return erf_impl(static_cast<double>(v),real_type_tag()); + } + + #if (defined(_MSC_VER) && (_MSC_VER >= 1900)) || !defined(_MSC_VER) + #define exprtk_define_erfc(TT, impl) \ + inline TT erfc_impl(const TT v) { return impl(v); } \ + + exprtk_define_erfc(float ,::erfcf) + exprtk_define_erfc(double ,::erfc ) + exprtk_define_erfc(long double,::erfcl) + #undef exprtk_define_erfc + #endif + + template <typename T> + inline T erfc_impl(const T v, real_type_tag) + { + #if defined(_MSC_VER) && (_MSC_VER < 1900) + return T(1) - erf_impl(v,real_type_tag()); + #else + return erfc_impl(v); + #endif + } + + template <typename T> + inline T erfc_impl(const T v, int_type_tag) + { + return erfc_impl(static_cast<double>(v),real_type_tag()); + } + + template <typename T> + inline T ncdf_impl(const T v, real_type_tag) + { + return T(0.5) * erfc_impl(-(v / T(numeric::constant::sqrt2)),real_type_tag()); + } + + template <typename T> + inline T ncdf_impl(const T v, int_type_tag) + { + return ncdf_impl(static_cast<double>(v),real_type_tag()); + } + + template <typename T> + inline T sinc_impl(const T v, real_type_tag) + { + if (std::abs(v) >= std::numeric_limits<T>::epsilon()) + return(std::sin(v) / v); + else + return T(1); + } + + template <typename T> + inline T sinc_impl(const T v, int_type_tag) + { + return sinc_impl(static_cast<double>(v),real_type_tag()); + } + + #if __cplusplus >= 201103L + template <typename T> + inline T acosh_impl(const T v, real_type_tag) + { + return std::acosh(v); + } + + template <typename T> + inline T asinh_impl(const T v, real_type_tag) + { + return std::asinh(v); + } + + template <typename T> + inline T atanh_impl(const T v, real_type_tag) + { + return std::atanh(v); + } + + template <typename T> + inline T trunc_impl(const T v, real_type_tag) + { + return std::trunc(v); + } + + template <typename T> + inline T expm1_impl(const T v, real_type_tag) + { + return std::expm1(v); + } + + template <typename T> + inline T expm1_impl(const T v, int_type_tag) + { + return std::expm1(v); + } + + template <typename T> + inline T log1p_impl(const T v, real_type_tag) + { + return std::log1p(v); + } + + template <typename T> + inline T log1p_impl(const T v, int_type_tag) + { + return std::log1p(v); + } + #else + template <typename T> + inline T acosh_impl(const T v, real_type_tag) + { + return std::log(v + std::sqrt((v * v) - T(1))); + } + + template <typename T> + inline T asinh_impl(const T v, real_type_tag) + { + return std::log(v + std::sqrt((v * v) + T(1))); + } + + template <typename T> + inline T atanh_impl(const T v, real_type_tag) + { + return (std::log(T(1) + v) - std::log(T(1) - v)) / T(2); + } + + template <typename T> + inline T trunc_impl(const T v, real_type_tag) + { + return T(static_cast<long long>(v)); + } + + template <typename T> + inline T expm1_impl(const T v, real_type_tag) + { + if (abs_impl(v,real_type_tag()) < T(0.00001)) + return v + (T(0.5) * v * v); + else + return std::exp(v) - T(1); + } + + template <typename T> + inline T expm1_impl(const T v, int_type_tag) + { + return T(std::exp<double>(v)) - T(1); + } + + template <typename T> + inline T log1p_impl(const T v, real_type_tag) + { + if (v > T(-1)) + { + if (abs_impl(v,real_type_tag()) > T(0.0001)) + { + return std::log(T(1) + v); + } + else + return (T(-0.5) * v + T(1)) * v; + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + template <typename T> + inline T log1p_impl(const T v, int_type_tag) + { + if (v > T(-1)) + { + return std::log(T(1) + v); + } + + return std::numeric_limits<T>::quiet_NaN(); + } + #endif + + template <typename T> inline T acos_impl(const T v, real_type_tag) { return std::acos (v); } + template <typename T> inline T asin_impl(const T v, real_type_tag) { return std::asin (v); } + template <typename T> inline T atan_impl(const T v, real_type_tag) { return std::atan (v); } + template <typename T> inline T ceil_impl(const T v, real_type_tag) { return std::ceil (v); } + template <typename T> inline T cos_impl(const T v, real_type_tag) { return std::cos (v); } + template <typename T> inline T cosh_impl(const T v, real_type_tag) { return std::cosh (v); } + template <typename T> inline T exp_impl(const T v, real_type_tag) { return std::exp (v); } + template <typename T> inline T floor_impl(const T v, real_type_tag) { return std::floor(v); } + template <typename T> inline T log_impl(const T v, real_type_tag) { return std::log (v); } + template <typename T> inline T log10_impl(const T v, real_type_tag) { return std::log10(v); } + template <typename T> inline T log2_impl(const T v, real_type_tag) { return std::log(v)/T(numeric::constant::log2); } + template <typename T> inline T neg_impl(const T v, real_type_tag) { return -v; } + template <typename T> inline T pos_impl(const T v, real_type_tag) { return +v; } + template <typename T> inline T sin_impl(const T v, real_type_tag) { return std::sin (v); } + template <typename T> inline T sinh_impl(const T v, real_type_tag) { return std::sinh (v); } + template <typename T> inline T sqrt_impl(const T v, real_type_tag) { return std::sqrt (v); } + template <typename T> inline T tan_impl(const T v, real_type_tag) { return std::tan (v); } + template <typename T> inline T tanh_impl(const T v, real_type_tag) { return std::tanh (v); } + template <typename T> inline T cot_impl(const T v, real_type_tag) { return T(1) / std::tan(v); } + template <typename T> inline T sec_impl(const T v, real_type_tag) { return T(1) / std::cos(v); } + template <typename T> inline T csc_impl(const T v, real_type_tag) { return T(1) / std::sin(v); } + template <typename T> inline T r2d_impl(const T v, real_type_tag) { return (v * T(numeric::constant::_180_pi)); } + template <typename T> inline T d2r_impl(const T v, real_type_tag) { return (v * T(numeric::constant::pi_180)); } + template <typename T> inline T d2g_impl(const T v, real_type_tag) { return (v * T(10.0/9.0)); } + template <typename T> inline T g2d_impl(const T v, real_type_tag) { return (v * T(9.0/10.0)); } + template <typename T> inline T notl_impl(const T v, real_type_tag) { return (std::not_equal_to<T>()(T(0),v) ? T(0) : T(1)); } + template <typename T> inline T frac_impl(const T v, real_type_tag) { return (v - trunc_impl(v,real_type_tag())); } + + template <typename T> inline T const_pi_impl(real_type_tag) { return T(numeric::constant::pi); } + template <typename T> inline T const_e_impl(real_type_tag) { return T(numeric::constant::e); } + template <typename T> inline T const_qnan_impl(real_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + + template <typename T> inline T abs_impl(const T v, int_type_tag) { return ((v >= T(0)) ? v : -v); } + template <typename T> inline T exp_impl(const T v, int_type_tag) { return std::exp (v); } + template <typename T> inline T log_impl(const T v, int_type_tag) { return std::log (v); } + template <typename T> inline T log10_impl(const T v, int_type_tag) { return std::log10(v); } + template <typename T> inline T log2_impl(const T v, int_type_tag) { return std::log(v)/T(numeric::constant::log2); } + template <typename T> inline T neg_impl(const T v, int_type_tag) { return -v; } + template <typename T> inline T pos_impl(const T v, int_type_tag) { return +v; } + template <typename T> inline T ceil_impl(const T v, int_type_tag) { return v; } + template <typename T> inline T floor_impl(const T v, int_type_tag) { return v; } + template <typename T> inline T round_impl(const T v, int_type_tag) { return v; } + template <typename T> inline T notl_impl(const T v, int_type_tag) { return !v; } + template <typename T> inline T sqrt_impl(const T v, int_type_tag) { return std::sqrt (v); } + template <typename T> inline T frac_impl(const T , int_type_tag) { return T(0); } + template <typename T> inline T trunc_impl(const T v, int_type_tag) { return v; } + template <typename T> inline T acos_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T acosh_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T asin_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T asinh_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T atan_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T atanh_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T cos_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T cosh_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T sin_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T sinh_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T tan_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T tanh_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T cot_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T sec_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + template <typename T> inline T csc_impl(const T , int_type_tag) { return std::numeric_limits<T>::quiet_NaN(); } + + template <typename T> + inline bool is_integer_impl(const T& v, real_type_tag) + { + return std::equal_to<T>()(T(0),std::fmod(v,T(1))); + } + + template <typename T> + inline bool is_integer_impl(const T&, int_type_tag) + { + return true; + } + } + + template <typename Type> + struct numeric_info { enum { length = 0, size = 32, bound_length = 0, min_exp = 0, max_exp = 0 }; }; + + template <> struct numeric_info<int > { enum { length = 10, size = 16, bound_length = 9 }; }; + template <> struct numeric_info<float > { enum { min_exp = -38, max_exp = +38 }; }; + template <> struct numeric_info<double > { enum { min_exp = -308, max_exp = +308 }; }; + template <> struct numeric_info<long double> { enum { min_exp = -308, max_exp = +308 }; }; + + template <typename T> + inline int to_int32(const T v) + { + const typename details::number_type<T>::type num_type; + return to_int32_impl(v, num_type); + } + + template <typename T> + inline _int64_t to_int64(const T v) + { + const typename details::number_type<T>::type num_type; + return to_int64_impl(v, num_type); + } + + template <typename T> + inline _uint64_t to_uint64(const T v) + { + const typename details::number_type<T>::type num_type; + return to_uint64_impl(v, num_type); + } + + template <typename T> + inline bool is_nan(const T v) + { + const typename details::number_type<T>::type num_type; + return is_nan_impl(v, num_type); + } + + template <typename T> + inline T min(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return min_impl(v0, v1, num_type); + } + + template <typename T> + inline T max(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return max_impl(v0, v1, num_type); + } + + template <typename T> + inline T equal(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return equal_impl(v0, v1, num_type); + } + + template <typename T> + inline T nequal(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return nequal_impl(v0, v1, num_type); + } + + template <typename T> + inline T modulus(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return modulus_impl(v0, v1, num_type); + } + + template <typename T> + inline T pow(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return pow_impl(v0, v1, num_type); + } + + template <typename T> + inline T logn(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return logn_impl(v0, v1, num_type); + } + + template <typename T> + inline T root(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return root_impl(v0, v1, num_type); + } + + template <typename T> + inline T roundn(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return roundn_impl(v0, v1, num_type); + } + + template <typename T> + inline T hypot(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return hypot_impl(v0, v1, num_type); + } + + template <typename T> + inline T atan2(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return atan2_impl(v0, v1, num_type); + } + + template <typename T> + inline T shr(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return shr_impl(v0, v1, num_type); + } + + template <typename T> + inline T shl(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return shl_impl(v0, v1, num_type); + } + + template <typename T> + inline T and_opr(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return and_impl(v0, v1, num_type); + } + + template <typename T> + inline T nand_opr(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return nand_impl(v0, v1, num_type); + } + + template <typename T> + inline T or_opr(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return or_impl(v0, v1, num_type); + } + + template <typename T> + inline T nor_opr(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return nor_impl(v0, v1, num_type); + } + + template <typename T> + inline T xor_opr(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return xor_impl(v0, v1, num_type); + } + + template <typename T> + inline T xnor_opr(const T v0, const T v1) + { + const typename details::number_type<T>::type num_type; + return xnor_impl(v0, v1, num_type); + } + + template <typename T> + inline bool is_integer(const T v) + { + const typename details::number_type<T>::type num_type; + return is_integer_impl(v, num_type); + } + + template <typename T, unsigned int N> + struct fast_exp + { + static inline T result(T v) + { + unsigned int k = N; + T l = T(1); + + while (k) + { + if (1 == (k % 2)) + { + l *= v; + --k; + } + + v *= v; + k /= 2; + } + + return l; + } + }; + + template <typename T> struct fast_exp<T,10> { static inline T result(const T v) { T v_5 = fast_exp<T,5>::result(v); return v_5 * v_5; } }; + template <typename T> struct fast_exp<T, 9> { static inline T result(const T v) { return fast_exp<T,8>::result(v) * v; } }; + template <typename T> struct fast_exp<T, 8> { static inline T result(const T v) { T v_4 = fast_exp<T,4>::result(v); return v_4 * v_4; } }; + template <typename T> struct fast_exp<T, 7> { static inline T result(const T v) { return fast_exp<T,6>::result(v) * v; } }; + template <typename T> struct fast_exp<T, 6> { static inline T result(const T v) { T v_3 = fast_exp<T,3>::result(v); return v_3 * v_3; } }; + template <typename T> struct fast_exp<T, 5> { static inline T result(const T v) { return fast_exp<T,4>::result(v) * v; } }; + template <typename T> struct fast_exp<T, 4> { static inline T result(const T v) { T v_2 = v * v; return v_2 * v_2; } }; + template <typename T> struct fast_exp<T, 3> { static inline T result(const T v) { return v * v * v; } }; + template <typename T> struct fast_exp<T, 2> { static inline T result(const T v) { return v * v; } }; + template <typename T> struct fast_exp<T, 1> { static inline T result(const T v) { return v; } }; + template <typename T> struct fast_exp<T, 0> { static inline T result(const T ) { return T(1); } }; + + #define exprtk_define_unary_function(FunctionName) \ + template <typename T> \ + inline T FunctionName (const T v) \ + { \ + const typename details::number_type<T>::type num_type; \ + return FunctionName##_impl(v,num_type); \ + } \ + + exprtk_define_unary_function(abs ) + exprtk_define_unary_function(acos ) + exprtk_define_unary_function(acosh) + exprtk_define_unary_function(asin ) + exprtk_define_unary_function(asinh) + exprtk_define_unary_function(atan ) + exprtk_define_unary_function(atanh) + exprtk_define_unary_function(ceil ) + exprtk_define_unary_function(cos ) + exprtk_define_unary_function(cosh ) + exprtk_define_unary_function(exp ) + exprtk_define_unary_function(expm1) + exprtk_define_unary_function(floor) + exprtk_define_unary_function(log ) + exprtk_define_unary_function(log10) + exprtk_define_unary_function(log2 ) + exprtk_define_unary_function(log1p) + exprtk_define_unary_function(neg ) + exprtk_define_unary_function(pos ) + exprtk_define_unary_function(round) + exprtk_define_unary_function(sin ) + exprtk_define_unary_function(sinc ) + exprtk_define_unary_function(sinh ) + exprtk_define_unary_function(sqrt ) + exprtk_define_unary_function(tan ) + exprtk_define_unary_function(tanh ) + exprtk_define_unary_function(cot ) + exprtk_define_unary_function(sec ) + exprtk_define_unary_function(csc ) + exprtk_define_unary_function(r2d ) + exprtk_define_unary_function(d2r ) + exprtk_define_unary_function(d2g ) + exprtk_define_unary_function(g2d ) + exprtk_define_unary_function(notl ) + exprtk_define_unary_function(sgn ) + exprtk_define_unary_function(erf ) + exprtk_define_unary_function(erfc ) + exprtk_define_unary_function(ncdf ) + exprtk_define_unary_function(frac ) + exprtk_define_unary_function(trunc) + #undef exprtk_define_unary_function + } + + template <typename T> + inline T compute_pow10(T d, const int exponent) + { + static const double fract10[] = + { + 0.0, + 1.0E+001, 1.0E+002, 1.0E+003, 1.0E+004, 1.0E+005, 1.0E+006, 1.0E+007, 1.0E+008, 1.0E+009, 1.0E+010, + 1.0E+011, 1.0E+012, 1.0E+013, 1.0E+014, 1.0E+015, 1.0E+016, 1.0E+017, 1.0E+018, 1.0E+019, 1.0E+020, + 1.0E+021, 1.0E+022, 1.0E+023, 1.0E+024, 1.0E+025, 1.0E+026, 1.0E+027, 1.0E+028, 1.0E+029, 1.0E+030, + 1.0E+031, 1.0E+032, 1.0E+033, 1.0E+034, 1.0E+035, 1.0E+036, 1.0E+037, 1.0E+038, 1.0E+039, 1.0E+040, + 1.0E+041, 1.0E+042, 1.0E+043, 1.0E+044, 1.0E+045, 1.0E+046, 1.0E+047, 1.0E+048, 1.0E+049, 1.0E+050, + 1.0E+051, 1.0E+052, 1.0E+053, 1.0E+054, 1.0E+055, 1.0E+056, 1.0E+057, 1.0E+058, 1.0E+059, 1.0E+060, + 1.0E+061, 1.0E+062, 1.0E+063, 1.0E+064, 1.0E+065, 1.0E+066, 1.0E+067, 1.0E+068, 1.0E+069, 1.0E+070, + 1.0E+071, 1.0E+072, 1.0E+073, 1.0E+074, 1.0E+075, 1.0E+076, 1.0E+077, 1.0E+078, 1.0E+079, 1.0E+080, + 1.0E+081, 1.0E+082, 1.0E+083, 1.0E+084, 1.0E+085, 1.0E+086, 1.0E+087, 1.0E+088, 1.0E+089, 1.0E+090, + 1.0E+091, 1.0E+092, 1.0E+093, 1.0E+094, 1.0E+095, 1.0E+096, 1.0E+097, 1.0E+098, 1.0E+099, 1.0E+100, + 1.0E+101, 1.0E+102, 1.0E+103, 1.0E+104, 1.0E+105, 1.0E+106, 1.0E+107, 1.0E+108, 1.0E+109, 1.0E+110, + 1.0E+111, 1.0E+112, 1.0E+113, 1.0E+114, 1.0E+115, 1.0E+116, 1.0E+117, 1.0E+118, 1.0E+119, 1.0E+120, + 1.0E+121, 1.0E+122, 1.0E+123, 1.0E+124, 1.0E+125, 1.0E+126, 1.0E+127, 1.0E+128, 1.0E+129, 1.0E+130, + 1.0E+131, 1.0E+132, 1.0E+133, 1.0E+134, 1.0E+135, 1.0E+136, 1.0E+137, 1.0E+138, 1.0E+139, 1.0E+140, + 1.0E+141, 1.0E+142, 1.0E+143, 1.0E+144, 1.0E+145, 1.0E+146, 1.0E+147, 1.0E+148, 1.0E+149, 1.0E+150, + 1.0E+151, 1.0E+152, 1.0E+153, 1.0E+154, 1.0E+155, 1.0E+156, 1.0E+157, 1.0E+158, 1.0E+159, 1.0E+160, + 1.0E+161, 1.0E+162, 1.0E+163, 1.0E+164, 1.0E+165, 1.0E+166, 1.0E+167, 1.0E+168, 1.0E+169, 1.0E+170, + 1.0E+171, 1.0E+172, 1.0E+173, 1.0E+174, 1.0E+175, 1.0E+176, 1.0E+177, 1.0E+178, 1.0E+179, 1.0E+180, + 1.0E+181, 1.0E+182, 1.0E+183, 1.0E+184, 1.0E+185, 1.0E+186, 1.0E+187, 1.0E+188, 1.0E+189, 1.0E+190, + 1.0E+191, 1.0E+192, 1.0E+193, 1.0E+194, 1.0E+195, 1.0E+196, 1.0E+197, 1.0E+198, 1.0E+199, 1.0E+200, + 1.0E+201, 1.0E+202, 1.0E+203, 1.0E+204, 1.0E+205, 1.0E+206, 1.0E+207, 1.0E+208, 1.0E+209, 1.0E+210, + 1.0E+211, 1.0E+212, 1.0E+213, 1.0E+214, 1.0E+215, 1.0E+216, 1.0E+217, 1.0E+218, 1.0E+219, 1.0E+220, + 1.0E+221, 1.0E+222, 1.0E+223, 1.0E+224, 1.0E+225, 1.0E+226, 1.0E+227, 1.0E+228, 1.0E+229, 1.0E+230, + 1.0E+231, 1.0E+232, 1.0E+233, 1.0E+234, 1.0E+235, 1.0E+236, 1.0E+237, 1.0E+238, 1.0E+239, 1.0E+240, + 1.0E+241, 1.0E+242, 1.0E+243, 1.0E+244, 1.0E+245, 1.0E+246, 1.0E+247, 1.0E+248, 1.0E+249, 1.0E+250, + 1.0E+251, 1.0E+252, 1.0E+253, 1.0E+254, 1.0E+255, 1.0E+256, 1.0E+257, 1.0E+258, 1.0E+259, 1.0E+260, + 1.0E+261, 1.0E+262, 1.0E+263, 1.0E+264, 1.0E+265, 1.0E+266, 1.0E+267, 1.0E+268, 1.0E+269, 1.0E+270, + 1.0E+271, 1.0E+272, 1.0E+273, 1.0E+274, 1.0E+275, 1.0E+276, 1.0E+277, 1.0E+278, 1.0E+279, 1.0E+280, + 1.0E+281, 1.0E+282, 1.0E+283, 1.0E+284, 1.0E+285, 1.0E+286, 1.0E+287, 1.0E+288, 1.0E+289, 1.0E+290, + 1.0E+291, 1.0E+292, 1.0E+293, 1.0E+294, 1.0E+295, 1.0E+296, 1.0E+297, 1.0E+298, 1.0E+299, 1.0E+300, + 1.0E+301, 1.0E+302, 1.0E+303, 1.0E+304, 1.0E+305, 1.0E+306, 1.0E+307, 1.0E+308 + }; + + static const int fract10_size = static_cast<int>(sizeof(fract10) / sizeof(double)); + + const int e = std::abs(exponent); + + if (exponent >= std::numeric_limits<T>::min_exponent10) + { + if (e < fract10_size) + { + if (exponent > 0) + return T(d * fract10[e]); + else + return T(d / fract10[e]); + } + else + return T(d * std::pow(10.0, 10.0 * exponent)); + } + else + { + d /= T(fract10[ -std::numeric_limits<T>::min_exponent10]); + return T(d / fract10[-exponent + std::numeric_limits<T>::min_exponent10]); + } + } + + template <typename Iterator, typename T> + inline bool string_to_type_converter_impl_ref(Iterator& itr, const Iterator end, T& result) + { + if (itr == end) + return false; + + const bool negative = ('-' == (*itr)); + + if (negative || ('+' == (*itr))) + { + if (end == ++itr) + return false; + } + + static const uchar_t zero = static_cast<uchar_t>('0'); + + while ((end != itr) && (zero == (*itr))) ++itr; + + bool return_result = true; + unsigned int digit = 0; + const std::size_t length = static_cast<std::size_t>(std::distance(itr,end)); + + if (length <= 4) + { + switch (length) + { + #ifdef exprtk_use_lut + + #define exprtk_process_digit \ + if ((digit = details::digit_table[(int)*itr++]) < 10) \ + result = result * 10 + (digit); \ + else \ + { \ + return_result = false; \ + break; \ + } \ + exprtk_fallthrough \ + + #else + + #define exprtk_process_digit \ + if ((digit = (*itr++ - zero)) < 10) \ + result = result * T(10) + digit; \ + else \ + { \ + return_result = false; \ + break; \ + } \ + exprtk_fallthrough \ + + #endif + + case 4 : exprtk_process_digit + case 3 : exprtk_process_digit + case 2 : exprtk_process_digit + case 1 : if ((digit = (*itr - zero))>= 10) + { + digit = 0; + return_result = false; + } + + #undef exprtk_process_digit + } + } + else + return_result = false; + + if (length && return_result) + { + result = result * 10 + static_cast<T>(digit); + ++itr; + } + + result = negative ? -result : result; + return return_result; + } + + template <typename Iterator, typename T> + static inline bool parse_nan(Iterator& itr, const Iterator end, T& t) + { + typedef typename std::iterator_traits<Iterator>::value_type type; + + static const std::size_t nan_length = 3; + + if (std::distance(itr,end) != static_cast<int>(nan_length)) + return false; + + if (static_cast<type>('n') == (*itr)) + { + if ( + (static_cast<type>('a') != *(itr + 1)) || + (static_cast<type>('n') != *(itr + 2)) + ) + { + return false; + } + } + else if ( + (static_cast<type>('A') != *(itr + 1)) || + (static_cast<type>('N') != *(itr + 2)) + ) + { + return false; + } + + t = std::numeric_limits<T>::quiet_NaN(); + + return true; + } + + template <typename Iterator, typename T> + static inline bool parse_inf(Iterator& itr, const Iterator end, T& t, const bool negative) + { + static const char_t inf_uc[] = "INFINITY"; + static const char_t inf_lc[] = "infinity"; + static const std::size_t inf_length = 8; + + const std::size_t length = static_cast<std::size_t>(std::distance(itr,end)); + + if ((3 != length) && (inf_length != length)) + return false; + + char_cptr inf_itr = ('i' == (*itr)) ? inf_lc : inf_uc; + + while (end != itr) + { + if (*inf_itr == static_cast<char_t>(*itr)) + { + ++itr; + ++inf_itr; + continue; + } + else + return false; + } + + if (negative) + t = -std::numeric_limits<T>::infinity(); + else + t = std::numeric_limits<T>::infinity(); + + return true; + } + + template <typename T> + inline bool valid_exponent(const int exponent, numeric::details::real_type_tag) + { + using namespace details::numeric; + return (numeric_info<T>::min_exp <= exponent) && (exponent <= numeric_info<T>::max_exp); + } + + template <typename Iterator, typename T> + inline bool string_to_real(Iterator& itr_external, const Iterator end, T& t, numeric::details::real_type_tag) + { + if (end == itr_external) return false; + + Iterator itr = itr_external; + + T d = T(0); + + const bool negative = ('-' == (*itr)); + + if (negative || '+' == (*itr)) + { + if (end == ++itr) + return false; + } + + bool instate = false; + + static const char_t zero = static_cast<uchar_t>('0'); + + #define parse_digit_1(d) \ + if ((digit = (*itr - zero)) < 10) \ + { d = d * T(10) + digit; } \ + else \ + { break; } \ + if (end == ++itr) break; \ + + #define parse_digit_2(d) \ + if ((digit = (*itr - zero)) < 10) \ + { d = d * T(10) + digit; } \ + else \ + { break; } \ + ++itr; \ + + if ('.' != (*itr)) + { + const Iterator curr = itr; + + while ((end != itr) && (zero == (*itr))) ++itr; + + while (end != itr) + { + unsigned int digit; + parse_digit_1(d) + parse_digit_1(d) + parse_digit_2(d) + } + + if (curr != itr) instate = true; + } + + int exponent = 0; + + if (end != itr) + { + if ('.' == (*itr)) + { + const Iterator curr = ++itr; + T tmp_d = T(0); + + while (end != itr) + { + unsigned int digit; + parse_digit_1(tmp_d) + parse_digit_1(tmp_d) + parse_digit_2(tmp_d) + } + + if (curr != itr) + { + instate = true; + + const int frac_exponent = static_cast<int>(-std::distance(curr, itr)); + + if (!valid_exponent<T>(frac_exponent, numeric::details::real_type_tag())) + return false; + + d += compute_pow10(tmp_d, frac_exponent); + } + + #undef parse_digit_1 + #undef parse_digit_2 + } + + if (end != itr) + { + typename std::iterator_traits<Iterator>::value_type c = (*itr); + + if (('e' == c) || ('E' == c)) + { + int exp = 0; + + if (!details::string_to_type_converter_impl_ref(++itr, end, exp)) + { + if (end == itr) + return false; + else + c = (*itr); + } + + exponent += exp; + } + + if (end != itr) + { + if (('f' == c) || ('F' == c) || ('l' == c) || ('L' == c)) + ++itr; + else if ('#' == c) + { + if (end == ++itr) + return false; + else if (('I' <= (*itr)) && ((*itr) <= 'n')) + { + if (('i' == (*itr)) || ('I' == (*itr))) + { + return parse_inf(itr, end, t, negative); + } + else if (('n' == (*itr)) || ('N' == (*itr))) + { + return parse_nan(itr, end, t); + } + else + return false; + } + else + return false; + } + else if (('I' <= (*itr)) && ((*itr) <= 'n')) + { + if (('i' == (*itr)) || ('I' == (*itr))) + { + return parse_inf(itr, end, t, negative); + } + else if (('n' == (*itr)) || ('N' == (*itr))) + { + return parse_nan(itr, end, t); + } + else + return false; + } + else + return false; + } + } + } + + if ((end != itr) || (!instate)) + return false; + else if (!valid_exponent<T>(exponent, numeric::details::real_type_tag())) + return false; + else if (exponent) + d = compute_pow10(d,exponent); + + t = static_cast<T>((negative) ? -d : d); + return true; + } + + template <typename T> + inline bool string_to_real(const std::string& s, T& t) + { + const typename numeric::details::number_type<T>::type num_type; + + char_cptr begin = s.data(); + char_cptr end = s.data() + s.size(); + + return string_to_real(begin, end, t, num_type); + } + + template <typename T> + struct functor_t + { + /* + Note: The following definitions for Type, may require tweaking + based on the compiler and target architecture. The benchmark + should provide enough information to make the right choice. + */ + //typedef T Type; + //typedef const T Type; + typedef const T& Type; + typedef T& RefType; + typedef T (*qfunc_t)(Type t0, Type t1, Type t2, Type t3); + typedef T (*tfunc_t)(Type t0, Type t1, Type t2); + typedef T (*bfunc_t)(Type t0, Type t1); + typedef T (*ufunc_t)(Type t0); + }; + + } // namespace details + + struct loop_runtime_check + { + enum loop_types + { + e_invalid = 0, + e_for_loop = 1, + e_while_loop = 2, + e_repeat_until_loop = 4, + e_all_loops = 7 + }; + + enum violation_type + { + e_unknown = 0, + e_iteration_count = 1, + e_timeout = 2 + }; + + loop_types loop_set; + + loop_runtime_check() + : loop_set(e_invalid) + , max_loop_iterations(0) + {} + + details::_uint64_t max_loop_iterations; + + struct violation_context + { + loop_types loop; + violation_type violation; + details::_uint64_t iteration_count; + }; + + virtual bool check() + { + return true; + } + + virtual void handle_runtime_violation(const violation_context&) + { + throw std::runtime_error("ExprTk Loop runtime violation."); + } + + virtual ~loop_runtime_check() + {} + }; + + typedef loop_runtime_check* loop_runtime_check_ptr; + + struct vector_access_runtime_check + { + struct violation_context + { + void* base_ptr; + void* end_ptr; + void* access_ptr; + std::size_t type_size; + }; + + virtual ~vector_access_runtime_check() + {} + + virtual bool handle_runtime_violation(violation_context& /*context*/) + { + throw std::runtime_error("ExprTk runtime vector access violation."); + #if !defined(_MSC_VER) && !defined(__NVCOMPILER) + return false; + #endif + } + }; + + typedef vector_access_runtime_check* vector_access_runtime_check_ptr; + + struct assert_check + { + struct assert_context + { + std::string condition; + std::string message; + std::string id; + std::size_t offet; + }; + + virtual ~assert_check() + {} + + virtual void handle_assert(const assert_context& /*context*/) + {} + }; + + typedef assert_check* assert_check_ptr; + + struct compilation_check + { + struct compilation_context + { + std::string error_message; + }; + + virtual bool continue_compilation(compilation_context& /*context*/) = 0; + + virtual ~compilation_check() + {} + }; + + typedef compilation_check* compilation_check_ptr; + + namespace lexer + { + struct token + { + enum token_type + { + e_none = 0, e_error = 1, e_err_symbol = 2, + e_err_number = 3, e_err_string = 4, e_err_sfunc = 5, + e_eof = 6, e_number = 7, e_symbol = 8, + e_string = 9, e_assign = 10, e_addass = 11, + e_subass = 12, e_mulass = 13, e_divass = 14, + e_modass = 15, e_shr = 16, e_shl = 17, + e_lte = 18, e_ne = 19, e_gte = 20, + e_swap = 21, e_lt = '<', e_gt = '>', + e_eq = '=', e_rbracket = ')', e_lbracket = '(', + e_rsqrbracket = ']', e_lsqrbracket = '[', e_rcrlbracket = '}', + e_lcrlbracket = '{', e_comma = ',', e_add = '+', + e_sub = '-', e_div = '/', e_mul = '*', + e_mod = '%', e_pow = '^', e_colon = ':', + e_ternary = '?' + }; + + token() + : type(e_none) + , value("") + , position(std::numeric_limits<std::size_t>::max()) + {} + + void clear() + { + type = e_none; + value = ""; + position = std::numeric_limits<std::size_t>::max(); + } + + template <typename Iterator> + inline token& set_operator(const token_type tt, + const Iterator begin, const Iterator end, + const Iterator base_begin = Iterator(0)) + { + type = tt; + value.assign(begin,end); + if (base_begin) + position = static_cast<std::size_t>(std::distance(base_begin,begin)); + return (*this); + } + + template <typename Iterator> + inline token& set_symbol(const Iterator begin, const Iterator end, const Iterator base_begin = Iterator(0)) + { + type = e_symbol; + value.assign(begin,end); + if (base_begin) + position = static_cast<std::size_t>(std::distance(base_begin,begin)); + return (*this); + } + + template <typename Iterator> + inline token& set_numeric(const Iterator begin, const Iterator end, const Iterator base_begin = Iterator(0)) + { + type = e_number; + value.assign(begin,end); + if (base_begin) + position = static_cast<std::size_t>(std::distance(base_begin,begin)); + return (*this); + } + + template <typename Iterator> + inline token& set_string(const Iterator begin, const Iterator end, const Iterator base_begin = Iterator(0)) + { + type = e_string; + value.assign(begin,end); + if (base_begin) + position = static_cast<std::size_t>(std::distance(base_begin,begin)); + return (*this); + } + + inline token& set_string(const std::string& s, const std::size_t p) + { + type = e_string; + value = s; + position = p; + return (*this); + } + + template <typename Iterator> + inline token& set_error(const token_type et, + const Iterator begin, const Iterator end, + const Iterator base_begin = Iterator(0)) + { + if ( + (e_error == et) || + (e_err_symbol == et) || + (e_err_number == et) || + (e_err_string == et) || + (e_err_sfunc == et) + ) + { + type = et; + } + else + type = e_error; + + value.assign(begin,end); + + if (base_begin) + position = static_cast<std::size_t>(std::distance(base_begin,begin)); + + return (*this); + } + + static inline std::string to_str(token_type t) + { + switch (t) + { + case e_none : return "NONE"; + case e_error : return "ERROR"; + case e_err_symbol : return "ERROR_SYMBOL"; + case e_err_number : return "ERROR_NUMBER"; + case e_err_string : return "ERROR_STRING"; + case e_eof : return "EOF"; + case e_number : return "NUMBER"; + case e_symbol : return "SYMBOL"; + case e_string : return "STRING"; + case e_assign : return ":="; + case e_addass : return "+="; + case e_subass : return "-="; + case e_mulass : return "*="; + case e_divass : return "/="; + case e_modass : return "%="; + case e_shr : return ">>"; + case e_shl : return "<<"; + case e_lte : return "<="; + case e_ne : return "!="; + case e_gte : return ">="; + case e_lt : return "<"; + case e_gt : return ">"; + case e_eq : return "="; + case e_rbracket : return ")"; + case e_lbracket : return "("; + case e_rsqrbracket : return "]"; + case e_lsqrbracket : return "["; + case e_rcrlbracket : return "}"; + case e_lcrlbracket : return "{"; + case e_comma : return ","; + case e_add : return "+"; + case e_sub : return "-"; + case e_div : return "/"; + case e_mul : return "*"; + case e_mod : return "%"; + case e_pow : return "^"; + case e_colon : return ":"; + case e_ternary : return "?"; + case e_swap : return "<=>"; + default : return "UNKNOWN"; + } + } + + static inline std::string seperator_to_str(const token_type t) + { + switch (t) + { + case e_comma : return ","; + case e_colon : return ":"; + case e_eof : return ";"; + default : return "UNKNOWN"; + } + + #if !defined(_MSC_VER) && !defined(__NVCOMPILER) + return "UNKNOWN"; + #endif + } + + inline bool is_error() const + { + return (e_error == type) || + (e_err_symbol == type) || + (e_err_number == type) || + (e_err_string == type) || + (e_err_sfunc == type) ; + } + + token_type type; + std::string value; + std::size_t position; + }; + + class generator + { + public: + + typedef token token_t; + typedef std::vector<token_t> token_list_t; + typedef token_list_t::iterator token_list_itr_t; + typedef details::char_t char_t; + + generator() + : base_itr_(0) + , s_itr_ (0) + , s_end_ (0) + { + clear(); + } + + inline void clear() + { + base_itr_ = 0; + s_itr_ = 0; + s_end_ = 0; + token_list_.clear(); + token_itr_ = token_list_.end(); + store_token_itr_ = token_list_.end(); + } + + inline bool process(const std::string& str) + { + base_itr_ = str.data(); + s_itr_ = str.data(); + s_end_ = str.data() + str.size(); + + eof_token_.set_operator(token_t::e_eof, s_end_, s_end_, base_itr_); + token_list_.clear(); + + while (!is_end(s_itr_)) + { + scan_token(); + + if (!token_list_.empty() && token_list_.back().is_error()) + return false; + } + + return true; + } + + inline bool empty() const + { + return token_list_.empty(); + } + + inline std::size_t size() const + { + return token_list_.size(); + } + + inline void begin() + { + token_itr_ = token_list_.begin(); + store_token_itr_ = token_list_.begin(); + } + + inline void store() + { + store_token_itr_ = token_itr_; + } + + inline void restore() + { + token_itr_ = store_token_itr_; + } + + inline token_t& next_token() + { + if (token_list_.end() != token_itr_) + { + return *token_itr_++; + } + else + return eof_token_; + } + + inline token_t& peek_next_token() + { + if (token_list_.end() != token_itr_) + { + return *token_itr_; + } + else + return eof_token_; + } + + inline token_t& operator[](const std::size_t& index) + { + if (index < token_list_.size()) + { + return token_list_[index]; + } + else + return eof_token_; + } + + inline token_t operator[](const std::size_t& index) const + { + if (index < token_list_.size()) + { + return token_list_[index]; + } + else + return eof_token_; + } + + inline bool finished() const + { + return (token_list_.end() == token_itr_); + } + + inline void insert_front(token_t::token_type tk_type) + { + if ( + !token_list_.empty() && + (token_list_.end() != token_itr_) + ) + { + token_t t = *token_itr_; + + t.type = tk_type; + token_itr_ = token_list_.insert(token_itr_,t); + } + } + + inline std::string substr(const std::size_t& begin, const std::size_t& end) const + { + const details::char_cptr begin_itr = ((base_itr_ + begin) < s_end_) ? (base_itr_ + begin) : s_end_; + const details::char_cptr end_itr = ((base_itr_ + end ) < s_end_) ? (base_itr_ + end ) : s_end_; + + return std::string(begin_itr,end_itr); + } + + inline std::string remaining() const + { + if (finished()) + return ""; + else if (token_list_.begin() != token_itr_) + return std::string(base_itr_ + (token_itr_ - 1)->position, s_end_); + else + return std::string(base_itr_ + token_itr_->position, s_end_); + } + + private: + + inline bool is_end(details::char_cptr itr) const + { + return (s_end_ == itr); + } + + #ifndef exprtk_disable_comments + inline bool is_comment_start(details::char_cptr itr) const + { + const char_t c0 = *(itr + 0); + const char_t c1 = *(itr + 1); + + if ('#' == c0) + return true; + else if (!is_end(itr + 1)) + { + if (('/' == c0) && ('/' == c1)) return true; + if (('/' == c0) && ('*' == c1)) return true; + } + return false; + } + #else + inline bool is_comment_start(details::char_cptr) const + { + return false; + } + #endif + + inline void skip_whitespace() + { + while (!is_end(s_itr_) && details::is_whitespace(*s_itr_)) + { + ++s_itr_; + } + } + + inline void skip_comments() + { + #ifndef exprtk_disable_comments + // The following comment styles are supported: + // 1. // .... \n + // 2. # .... \n + // 3. /* .... */ + struct test + { + static inline bool comment_start(const char_t c0, const char_t c1, int& mode, int& incr) + { + mode = 0; + if ('#' == c0) { mode = 1; incr = 1; } + else if ('/' == c0) + { + if ('/' == c1) { mode = 1; incr = 2; } + else if ('*' == c1) { mode = 2; incr = 2; } + } + return (0 != mode); + } + + static inline bool comment_end(const char_t c0, const char_t c1, int& mode) + { + if ( + ((1 == mode) && ('\n' == c0)) || + ((2 == mode) && ( '*' == c0) && ('/' == c1)) + ) + { + mode = 0; + return true; + } + else + return false; + } + }; + + int mode = 0; + int increment = 0; + + if (is_end(s_itr_)) + return; + else if (!test::comment_start(*s_itr_, *(s_itr_ + 1), mode, increment)) + return; + + details::char_cptr cmt_start = s_itr_; + + s_itr_ += increment; + + while (!is_end(s_itr_)) + { + if ((1 == mode) && test::comment_end(*s_itr_, 0, mode)) + { + ++s_itr_; + return; + } + + if ((2 == mode)) + { + if (!is_end((s_itr_ + 1)) && test::comment_end(*s_itr_, *(s_itr_ + 1), mode)) + { + s_itr_ += 2; + return; + } + } + + ++s_itr_; + } + + if (2 == mode) + { + token_t t; + t.set_error(token::e_error, cmt_start, cmt_start + mode, base_itr_); + token_list_.push_back(t); + } + #endif + } + + inline bool next_is_digit(const details::char_cptr itr) const + { + return ((itr + 1) != s_end_) && + details::is_digit(*(itr + 1)); + } + + inline void scan_token() + { + const char_t c = *s_itr_; + + if (details::is_whitespace(c)) + { + skip_whitespace(); + return; + } + else if (is_comment_start(s_itr_)) + { + skip_comments(); + return; + } + else if (details::is_operator_char(c)) + { + scan_operator(); + return; + } + else if (details::is_letter(c)) + { + scan_symbol(); + return; + } + else if (('.' == c) && !next_is_digit(s_itr_)) + { + scan_operator(); + return; + } + else if (details::is_digit(c) || ('.' == c)) + { + scan_number(); + return; + } + else if ('$' == c) + { + scan_special_function(); + return; + } + #ifndef exprtk_disable_string_capabilities + else if ('\'' == c) + { + scan_string(); + return; + } + #endif + else if ('~' == c) + { + token_t t; + t.set_symbol(s_itr_, s_itr_ + 1, base_itr_); + token_list_.push_back(t); + ++s_itr_; + return; + } + else + { + token_t t; + t.set_error(token::e_error, s_itr_, s_itr_ + 2, base_itr_); + token_list_.push_back(t); + ++s_itr_; + } + } + + inline void scan_operator() + { + token_t t; + + const char_t c0 = s_itr_[0]; + + if (!is_end(s_itr_ + 1)) + { + const char_t c1 = s_itr_[1]; + + if (!is_end(s_itr_ + 2)) + { + const char_t c2 = s_itr_[2]; + + if ((c0 == '<') && (c1 == '=') && (c2 == '>')) + { + t.set_operator(token_t::e_swap, s_itr_, s_itr_ + 3, base_itr_); + token_list_.push_back(t); + s_itr_ += 3; + return; + } + } + + token_t::token_type ttype = token_t::e_none; + + if ((c0 == '<') && (c1 == '=')) ttype = token_t::e_lte; + else if ((c0 == '>') && (c1 == '=')) ttype = token_t::e_gte; + else if ((c0 == '<') && (c1 == '>')) ttype = token_t::e_ne; + else if ((c0 == '!') && (c1 == '=')) ttype = token_t::e_ne; + else if ((c0 == '=') && (c1 == '=')) ttype = token_t::e_eq; + else if ((c0 == ':') && (c1 == '=')) ttype = token_t::e_assign; + else if ((c0 == '<') && (c1 == '<')) ttype = token_t::e_shl; + else if ((c0 == '>') && (c1 == '>')) ttype = token_t::e_shr; + else if ((c0 == '+') && (c1 == '=')) ttype = token_t::e_addass; + else if ((c0 == '-') && (c1 == '=')) ttype = token_t::e_subass; + else if ((c0 == '*') && (c1 == '=')) ttype = token_t::e_mulass; + else if ((c0 == '/') && (c1 == '=')) ttype = token_t::e_divass; + else if ((c0 == '%') && (c1 == '=')) ttype = token_t::e_modass; + + if (token_t::e_none != ttype) + { + t.set_operator(ttype, s_itr_, s_itr_ + 2, base_itr_); + token_list_.push_back(t); + s_itr_ += 2; + return; + } + } + + if ('<' == c0) + t.set_operator(token_t::e_lt , s_itr_, s_itr_ + 1, base_itr_); + else if ('>' == c0) + t.set_operator(token_t::e_gt , s_itr_, s_itr_ + 1, base_itr_); + else if (';' == c0) + t.set_operator(token_t::e_eof, s_itr_, s_itr_ + 1, base_itr_); + else if ('&' == c0) + t.set_symbol(s_itr_, s_itr_ + 1, base_itr_); + else if ('|' == c0) + t.set_symbol(s_itr_, s_itr_ + 1, base_itr_); + else + t.set_operator(token_t::token_type(c0), s_itr_, s_itr_ + 1, base_itr_); + + token_list_.push_back(t); + ++s_itr_; + } + + inline void scan_symbol() + { + details::char_cptr initial_itr = s_itr_; + + while (!is_end(s_itr_)) + { + if (!details::is_letter_or_digit(*s_itr_) && ('_' != (*s_itr_))) + { + if ('.' != (*s_itr_)) + break; + /* + Permit symbols that contain a 'dot' + Allowed : abc.xyz, a123.xyz, abc.123, abc_.xyz a123_.xyz abc._123 + Disallowed: .abc, abc.<white-space>, abc.<eof>, abc.<operator +,-,*,/...> + */ + if ( + (s_itr_ != initial_itr) && + !is_end(s_itr_ + 1) && + !details::is_letter_or_digit(*(s_itr_ + 1)) && + ('_' != (*(s_itr_ + 1))) + ) + break; + } + + ++s_itr_; + } + + token_t t; + t.set_symbol(initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + } + + inline void scan_number() + { + /* + Attempt to match a valid numeric value in one of the following formats: + (01) 123456 + (02) 123456. + (03) 123.456 + (04) 123.456e3 + (05) 123.456E3 + (06) 123.456e+3 + (07) 123.456E+3 + (08) 123.456e-3 + (09) 123.456E-3 + (00) .1234 + (11) .1234e3 + (12) .1234E+3 + (13) .1234e+3 + (14) .1234E-3 + (15) .1234e-3 + */ + + details::char_cptr initial_itr = s_itr_; + bool dot_found = false; + bool e_found = false; + bool post_e_sign_found = false; + bool post_e_digit_found = false; + token_t t; + + while (!is_end(s_itr_)) + { + if ('.' == (*s_itr_)) + { + if (dot_found) + { + t.set_error(token::e_err_number, initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + + dot_found = true; + ++s_itr_; + + continue; + } + else if ('e' == std::tolower(*s_itr_)) + { + const char_t& c = *(s_itr_ + 1); + + if (is_end(s_itr_ + 1)) + { + t.set_error(token::e_err_number, initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + else if ( + ('+' != c) && + ('-' != c) && + !details::is_digit(c) + ) + { + t.set_error(token::e_err_number, initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + + e_found = true; + ++s_itr_; + + continue; + } + else if (e_found && details::is_sign(*s_itr_) && !post_e_digit_found) + { + if (post_e_sign_found) + { + t.set_error(token::e_err_number, initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + + post_e_sign_found = true; + ++s_itr_; + + continue; + } + else if (e_found && details::is_digit(*s_itr_)) + { + post_e_digit_found = true; + ++s_itr_; + + continue; + } + else if (('.' != (*s_itr_)) && !details::is_digit(*s_itr_)) + break; + else + ++s_itr_; + } + + t.set_numeric(initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + + inline void scan_special_function() + { + details::char_cptr initial_itr = s_itr_; + token_t t; + + // $fdd(x,x,x) = at least 11 chars + if (std::distance(s_itr_,s_end_) < 11) + { + t.set_error( + token::e_err_sfunc, + initial_itr, std::min(initial_itr + 11, s_end_), + base_itr_); + token_list_.push_back(t); + + return; + } + + if ( + !(('$' == *s_itr_) && + (details::imatch ('f',*(s_itr_ + 1))) && + (details::is_digit(*(s_itr_ + 2))) && + (details::is_digit(*(s_itr_ + 3)))) + ) + { + t.set_error( + token::e_err_sfunc, + initial_itr, std::min(initial_itr + 4, s_end_), + base_itr_); + token_list_.push_back(t); + + return; + } + + s_itr_ += 4; // $fdd = 4chars + + t.set_symbol(initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + + #ifndef exprtk_disable_string_capabilities + inline void scan_string() + { + details::char_cptr initial_itr = s_itr_ + 1; + token_t t; + + if (std::distance(s_itr_,s_end_) < 2) + { + t.set_error(token::e_err_string, s_itr_, s_end_, base_itr_); + token_list_.push_back(t); + + return; + } + + ++s_itr_; + + bool escaped_found = false; + bool escaped = false; + + while (!is_end(s_itr_)) + { + if (!details::is_valid_string_char(*s_itr_)) + { + t.set_error(token::e_err_string, initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + else if (!escaped && ('\\' == *s_itr_)) + { + escaped_found = true; + escaped = true; + ++s_itr_; + + continue; + } + else if (!escaped) + { + if ('\'' == *s_itr_) + break; + } + else if (escaped) + { + if ( + !is_end(s_itr_) && ('0' == *(s_itr_)) && + ((s_itr_ + 4) <= s_end_) + ) + { + const bool x_separator = ('X' == std::toupper(*(s_itr_ + 1))); + + const bool both_digits = details::is_hex_digit(*(s_itr_ + 2)) && + details::is_hex_digit(*(s_itr_ + 3)) ; + + if (!(x_separator && both_digits)) + { + t.set_error(token::e_err_string, initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + else + s_itr_ += 3; + } + + escaped = false; + } + + ++s_itr_; + } + + if (is_end(s_itr_)) + { + t.set_error(token::e_err_string, initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + + if (!escaped_found) + t.set_string(initial_itr, s_itr_, base_itr_); + else + { + std::string parsed_string(initial_itr,s_itr_); + + if (!details::cleanup_escapes(parsed_string)) + { + t.set_error(token::e_err_string, initial_itr, s_itr_, base_itr_); + token_list_.push_back(t); + + return; + } + + t.set_string( + parsed_string, + static_cast<std::size_t>(std::distance(base_itr_,initial_itr))); + } + + token_list_.push_back(t); + ++s_itr_; + + return; + } + #endif + + private: + + token_list_t token_list_; + token_list_itr_t token_itr_; + token_list_itr_t store_token_itr_; + token_t eof_token_; + details::char_cptr base_itr_; + details::char_cptr s_itr_; + details::char_cptr s_end_; + + friend class token_scanner; + friend class token_modifier; + friend class token_inserter; + friend class token_joiner; + }; // class generator + + class helper_interface + { + public: + + virtual void init() { } + virtual void reset() { } + virtual bool result() { return true; } + virtual std::size_t process(generator&) { return 0; } + virtual ~helper_interface() { } + }; + + class token_scanner : public helper_interface + { + public: + + virtual ~token_scanner() exprtk_override + {} + + explicit token_scanner(const std::size_t& stride) + : stride_(stride) + { + if (stride > 4) + { + throw std::invalid_argument("token_scanner() - Invalid stride value"); + } + } + + inline std::size_t process(generator& g) exprtk_override + { + if (g.token_list_.size() >= stride_) + { + for (std::size_t i = 0; i < (g.token_list_.size() - stride_ + 1); ++i) + { + token t; + + switch (stride_) + { + case 1 : + { + const token& t0 = g.token_list_[i]; + + if (!operator()(t0)) + { + return 0; + } + } + break; + + case 2 : + { + const token& t0 = g.token_list_[i ]; + const token& t1 = g.token_list_[i + 1]; + + if (!operator()(t0, t1)) + { + return 0; + } + } + break; + + case 3 : + { + const token& t0 = g.token_list_[i ]; + const token& t1 = g.token_list_[i + 1]; + const token& t2 = g.token_list_[i + 2]; + + if (!operator()(t0, t1, t2)) + { + return 0; + } + } + break; + + case 4 : + { + const token& t0 = g.token_list_[i ]; + const token& t1 = g.token_list_[i + 1]; + const token& t2 = g.token_list_[i + 2]; + const token& t3 = g.token_list_[i + 3]; + + if (!operator()(t0, t1, t2, t3)) + { + return 0; + } + } + break; + } + } + } + + return 0; + } + + virtual bool operator() (const token&) + { + return false; + } + + virtual bool operator() (const token&, const token&) + { + return false; + } + + virtual bool operator() (const token&, const token&, const token&) + { + return false; + } + + virtual bool operator() (const token&, const token&, const token&, const token&) + { + return false; + } + + private: + + const std::size_t stride_; + }; // class token_scanner + + class token_modifier : public helper_interface + { + public: + + inline std::size_t process(generator& g) exprtk_override + { + std::size_t changes = 0; + + for (std::size_t i = 0; i < g.token_list_.size(); ++i) + { + if (modify(g.token_list_[i])) changes++; + } + + return changes; + } + + virtual bool modify(token& t) = 0; + }; + + class token_inserter : public helper_interface + { + public: + + explicit token_inserter(const std::size_t& stride) + : stride_(stride) + { + if (stride > 5) + { + throw std::invalid_argument("token_inserter() - Invalid stride value"); + } + } + + inline std::size_t process(generator& g) exprtk_override + { + if (g.token_list_.empty()) + return 0; + else if (g.token_list_.size() < stride_) + return 0; + + std::size_t changes = 0; + + typedef std::pair<std::size_t, token> insert_t; + std::vector<insert_t> insert_list; + insert_list.reserve(10000); + + for (std::size_t i = 0; i < (g.token_list_.size() - stride_ + 1); ++i) + { + int insert_index = -1; + token t; + + switch (stride_) + { + case 1 : insert_index = insert(g.token_list_[i],t); + break; + + case 2 : insert_index = insert(g.token_list_[i], g.token_list_[i + 1], t); + break; + + case 3 : insert_index = insert(g.token_list_[i], g.token_list_[i + 1], g.token_list_[i + 2], t); + break; + + case 4 : insert_index = insert(g.token_list_[i], g.token_list_[i + 1], g.token_list_[i + 2], g.token_list_[i + 3], t); + break; + + case 5 : insert_index = insert(g.token_list_[i], g.token_list_[i + 1], g.token_list_[i + 2], g.token_list_[i + 3], g.token_list_[i + 4], t); + break; + } + + if ((insert_index >= 0) && (insert_index <= (static_cast<int>(stride_) + 1))) + { + insert_list.push_back(insert_t(i, t)); + changes++; + } + } + + if (!insert_list.empty()) + { + generator::token_list_t token_list; + + std::size_t insert_index = 0; + + for (std::size_t i = 0; i < g.token_list_.size(); ++i) + { + token_list.push_back(g.token_list_[i]); + + if ( + (insert_index < insert_list.size()) && + (insert_list[insert_index].first == i) + ) + { + token_list.push_back(insert_list[insert_index].second); + insert_index++; + } + } + + std::swap(g.token_list_,token_list); + } + + return changes; + } + + #define token_inserter_empty_body \ + { \ + return -1; \ + } \ + + inline virtual int insert(const token&, token&) + token_inserter_empty_body + + inline virtual int insert(const token&, const token&, token&) + token_inserter_empty_body + + inline virtual int insert(const token&, const token&, const token&, token&) + token_inserter_empty_body + + inline virtual int insert(const token&, const token&, const token&, const token&, token&) + token_inserter_empty_body + + inline virtual int insert(const token&, const token&, const token&, const token&, const token&, token&) + token_inserter_empty_body + + #undef token_inserter_empty_body + + private: + + const std::size_t stride_; + }; + + class token_joiner : public helper_interface + { + public: + + explicit token_joiner(const std::size_t& stride) + : stride_(stride) + {} + + inline std::size_t process(generator& g) exprtk_override + { + if (g.token_list_.empty()) + return 0; + + switch (stride_) + { + case 2 : return process_stride_2(g); + case 3 : return process_stride_3(g); + default : return 0; + } + } + + virtual bool join(const token&, const token&, token&) { return false; } + virtual bool join(const token&, const token&, const token&, token&) { return false; } + + private: + + inline std::size_t process_stride_2(generator& g) + { + if (g.token_list_.size() < 2) + return 0; + + std::size_t changes = 0; + + generator::token_list_t token_list; + token_list.reserve(10000); + + for (int i = 0; i < static_cast<int>(g.token_list_.size() - 1); ++i) + { + token t; + + for ( ; ; ) + { + if (!join(g[i], g[i + 1], t)) + { + token_list.push_back(g[i]); + break; + } + + token_list.push_back(t); + + ++changes; + + i += 2; + + if (static_cast<std::size_t>(i) >= (g.token_list_.size() - 1)) + break; + } + } + + token_list.push_back(g.token_list_.back()); + + assert(token_list.size() <= g.token_list_.size()); + + std::swap(token_list, g.token_list_); + + return changes; + } + + inline std::size_t process_stride_3(generator& g) + { + if (g.token_list_.size() < 3) + return 0; + + std::size_t changes = 0; + + generator::token_list_t token_list; + token_list.reserve(10000); + + for (int i = 0; i < static_cast<int>(g.token_list_.size() - 2); ++i) + { + token t; + + for ( ; ; ) + { + if (!join(g[i], g[i + 1], g[i + 2], t)) + { + token_list.push_back(g[i]); + break; + } + + token_list.push_back(t); + + ++changes; + + i += 3; + + if (static_cast<std::size_t>(i) >= (g.token_list_.size() - 2)) + break; + } + } + + token_list.push_back(*(g.token_list_.begin() + g.token_list_.size() - 2)); + token_list.push_back(*(g.token_list_.begin() + g.token_list_.size() - 1)); + + assert(token_list.size() <= g.token_list_.size()); + + std::swap(token_list, g.token_list_); + + return changes; + } + + const std::size_t stride_; + }; + + namespace helper + { + + inline void dump(const lexer::generator& generator) + { + for (std::size_t i = 0; i < generator.size(); ++i) + { + const lexer::token& t = generator[i]; + printf("Token[%02d] @ %03d %6s --> '%s'\n", + static_cast<int>(i), + static_cast<int>(t.position), + t.to_str(t.type).c_str(), + t.value.c_str()); + } + } + + class commutative_inserter : public lexer::token_inserter + { + public: + + using lexer::token_inserter::insert; + + commutative_inserter() + : lexer::token_inserter(2) + {} + + inline void ignore_symbol(const std::string& symbol) + { + ignore_set_.insert(symbol); + } + + inline int insert(const lexer::token& t0, const lexer::token& t1, lexer::token& new_token) exprtk_override + { + bool match = false; + new_token.type = lexer::token::e_mul; + new_token.value = "*"; + new_token.position = t1.position; + + if (t0.type == lexer::token::e_symbol) + { + if (ignore_set_.end() != ignore_set_.find(t0.value)) + { + return -1; + } + else if (!t0.value.empty() && ('$' == t0.value[0])) + { + return -1; + } + } + + if (t1.type == lexer::token::e_symbol) + { + if (ignore_set_.end() != ignore_set_.find(t1.value)) + { + return -1; + } + } + if ((t0.type == lexer::token::e_number ) && (t1.type == lexer::token::e_symbol )) match = true; + else if ((t0.type == lexer::token::e_number ) && (t1.type == lexer::token::e_lbracket )) match = true; + else if ((t0.type == lexer::token::e_number ) && (t1.type == lexer::token::e_lcrlbracket)) match = true; + else if ((t0.type == lexer::token::e_number ) && (t1.type == lexer::token::e_lsqrbracket)) match = true; + else if ((t0.type == lexer::token::e_symbol ) && (t1.type == lexer::token::e_number )) match = true; + else if ((t0.type == lexer::token::e_rbracket ) && (t1.type == lexer::token::e_number )) match = true; + else if ((t0.type == lexer::token::e_rcrlbracket) && (t1.type == lexer::token::e_number )) match = true; + else if ((t0.type == lexer::token::e_rsqrbracket) && (t1.type == lexer::token::e_number )) match = true; + else if ((t0.type == lexer::token::e_rbracket ) && (t1.type == lexer::token::e_symbol )) match = true; + else if ((t0.type == lexer::token::e_rcrlbracket) && (t1.type == lexer::token::e_symbol )) match = true; + else if ((t0.type == lexer::token::e_rsqrbracket) && (t1.type == lexer::token::e_symbol )) match = true; + else if ((t0.type == lexer::token::e_symbol ) && (t1.type == lexer::token::e_symbol )) match = true; + + return (match) ? 1 : -1; + } + + private: + + std::set<std::string,details::ilesscompare> ignore_set_; + }; + + class operator_joiner exprtk_final : public token_joiner + { + public: + + explicit operator_joiner(const std::size_t& stride) + : token_joiner(stride) + {} + + inline bool join(const lexer::token& t0, const lexer::token& t1, lexer::token& t) exprtk_override + { + // ': =' --> ':=' + if ((t0.type == lexer::token::e_colon) && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_assign; + t.value = ":="; + t.position = t0.position; + + return true; + } + // '+ =' --> '+=' + else if ((t0.type == lexer::token::e_add) && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_addass; + t.value = "+="; + t.position = t0.position; + + return true; + } + // '- =' --> '-=' + else if ((t0.type == lexer::token::e_sub) && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_subass; + t.value = "-="; + t.position = t0.position; + + return true; + } + // '* =' --> '*=' + else if ((t0.type == lexer::token::e_mul) && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_mulass; + t.value = "*="; + t.position = t0.position; + + return true; + } + // '/ =' --> '/=' + else if ((t0.type == lexer::token::e_div) && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_divass; + t.value = "/="; + t.position = t0.position; + + return true; + } + // '% =' --> '%=' + else if ((t0.type == lexer::token::e_mod) && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_modass; + t.value = "%="; + t.position = t0.position; + + return true; + } + // '> =' --> '>=' + else if ((t0.type == lexer::token::e_gt) && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_gte; + t.value = ">="; + t.position = t0.position; + + return true; + } + // '< =' --> '<=' + else if ((t0.type == lexer::token::e_lt) && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_lte; + t.value = "<="; + t.position = t0.position; + + return true; + } + // '= =' --> '==' + else if ((t0.type == lexer::token::e_eq) && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_eq; + t.value = "=="; + t.position = t0.position; + + return true; + } + // '! =' --> '!=' + else if ((static_cast<details::char_t>(t0.type) == '!') && (t1.type == lexer::token::e_eq)) + { + t.type = lexer::token::e_ne; + t.value = "!="; + t.position = t0.position; + + return true; + } + // '< >' --> '<>' + else if ((t0.type == lexer::token::e_lt) && (t1.type == lexer::token::e_gt)) + { + t.type = lexer::token::e_ne; + t.value = "<>"; + t.position = t0.position; + + return true; + } + // '<= >' --> '<=>' + else if ((t0.type == lexer::token::e_lte) && (t1.type == lexer::token::e_gt)) + { + t.type = lexer::token::e_swap; + t.value = "<=>"; + t.position = t0.position; + + return true; + } + // '+ -' --> '-' + else if ((t0.type == lexer::token::e_add) && (t1.type == lexer::token::e_sub)) + { + t.type = lexer::token::e_sub; + t.value = "-"; + t.position = t0.position; + + return true; + } + // '- +' --> '-' + else if ((t0.type == lexer::token::e_sub) && (t1.type == lexer::token::e_add)) + { + t.type = lexer::token::e_sub; + t.value = "-"; + t.position = t0.position; + + return true; + } + // '- -' --> '+' + else if ((t0.type == lexer::token::e_sub) && (t1.type == lexer::token::e_sub)) + { + /* + Note: May need to reconsider this when wanting to implement + pre/postfix decrement operator + */ + t.type = lexer::token::e_add; + t.value = "+"; + t.position = t0.position; + + return true; + } + else + return false; + } + + inline bool join(const lexer::token& t0, + const lexer::token& t1, + const lexer::token& t2, + lexer::token& t) exprtk_override + { + // '[ * ]' --> '[*]' + if ( + (t0.type == lexer::token::e_lsqrbracket) && + (t1.type == lexer::token::e_mul ) && + (t2.type == lexer::token::e_rsqrbracket) + ) + { + t.type = lexer::token::e_symbol; + t.value = "[*]"; + t.position = t0.position; + + return true; + } + else + return false; + } + }; + + class bracket_checker exprtk_final : public lexer::token_scanner + { + public: + + using lexer::token_scanner::operator(); + + bracket_checker() + : token_scanner(1) + , state_(true) + {} + + bool result() exprtk_override + { + if (!stack_.empty()) + { + lexer::token t; + t.value = stack_.top().first; + t.position = stack_.top().second; + error_token_ = t; + state_ = false; + + return false; + } + else + return state_; + } + + lexer::token error_token() const + { + return error_token_; + } + + void reset() exprtk_override + { + // Why? because msvc doesn't support swap properly. + stack_ = std::stack<std::pair<char,std::size_t> >(); + state_ = true; + error_token_.clear(); + } + + bool operator() (const lexer::token& t) exprtk_override + { + if ( + !t.value.empty() && + (lexer::token::e_string != t.type) && + (lexer::token::e_symbol != t.type) && + exprtk::details::is_bracket(t.value[0]) + ) + { + details::char_t c = t.value[0]; + + if (t.type == lexer::token::e_lbracket ) stack_.push(std::make_pair(')',t.position)); + else if (t.type == lexer::token::e_lcrlbracket) stack_.push(std::make_pair('}',t.position)); + else if (t.type == lexer::token::e_lsqrbracket) stack_.push(std::make_pair(']',t.position)); + else if (exprtk::details::is_right_bracket(c)) + { + if (stack_.empty()) + { + state_ = false; + error_token_ = t; + + return false; + } + else if (c != stack_.top().first) + { + state_ = false; + error_token_ = t; + + return false; + } + else + stack_.pop(); + } + } + + return true; + } + + private: + + bool state_; + std::stack<std::pair<char,std::size_t> > stack_; + lexer::token error_token_; + }; + + template <typename T> + class numeric_checker exprtk_final : public lexer::token_scanner + { + public: + + using lexer::token_scanner::operator(); + + numeric_checker() + : token_scanner (1) + , current_index_(0) + {} + + bool result() exprtk_override + { + return error_list_.empty(); + } + + void reset() exprtk_override + { + error_list_.clear(); + current_index_ = 0; + } + + bool operator() (const lexer::token& t) exprtk_override + { + if (token::e_number == t.type) + { + T v; + + if (!exprtk::details::string_to_real(t.value,v)) + { + error_list_.push_back(current_index_); + } + } + + ++current_index_; + + return true; + } + + std::size_t error_count() const + { + return error_list_.size(); + } + + std::size_t error_index(const std::size_t& i) const + { + if (i < error_list_.size()) + return error_list_[i]; + else + return std::numeric_limits<std::size_t>::max(); + } + + void clear_errors() + { + error_list_.clear(); + } + + private: + + std::size_t current_index_; + std::vector<std::size_t> error_list_; + }; + + class symbol_replacer exprtk_final : public lexer::token_modifier + { + private: + + typedef std::map<std::string,std::pair<std::string,token::token_type>,details::ilesscompare> replace_map_t; + + public: + + bool remove(const std::string& target_symbol) + { + const replace_map_t::iterator itr = replace_map_.find(target_symbol); + + if (replace_map_.end() == itr) + return false; + + replace_map_.erase(itr); + + return true; + } + + bool add_replace(const std::string& target_symbol, + const std::string& replace_symbol, + const lexer::token::token_type token_type = lexer::token::e_symbol) + { + const replace_map_t::iterator itr = replace_map_.find(target_symbol); + + if (replace_map_.end() != itr) + { + return false; + } + + replace_map_[target_symbol] = std::make_pair(replace_symbol,token_type); + + return true; + } + + void clear() + { + replace_map_.clear(); + } + + private: + + bool modify(lexer::token& t) exprtk_override + { + if (lexer::token::e_symbol == t.type) + { + if (replace_map_.empty()) + return false; + + const replace_map_t::iterator itr = replace_map_.find(t.value); + + if (replace_map_.end() != itr) + { + t.value = itr->second.first; + t.type = itr->second.second; + + return true; + } + } + + return false; + } + + replace_map_t replace_map_; + }; + + class sequence_validator exprtk_final : public lexer::token_scanner + { + private: + + typedef std::pair<lexer::token::token_type,lexer::token::token_type> token_pair_t; + typedef std::set<token_pair_t> set_t; + + public: + + using lexer::token_scanner::operator(); + + sequence_validator() + : lexer::token_scanner(2) + { + add_invalid(lexer::token::e_number, lexer::token::e_number); + add_invalid(lexer::token::e_string, lexer::token::e_string); + add_invalid(lexer::token::e_number, lexer::token::e_string); + add_invalid(lexer::token::e_string, lexer::token::e_number); + + add_invalid_set1(lexer::token::e_assign ); + add_invalid_set1(lexer::token::e_shr ); + add_invalid_set1(lexer::token::e_shl ); + add_invalid_set1(lexer::token::e_lte ); + add_invalid_set1(lexer::token::e_ne ); + add_invalid_set1(lexer::token::e_gte ); + add_invalid_set1(lexer::token::e_lt ); + add_invalid_set1(lexer::token::e_gt ); + add_invalid_set1(lexer::token::e_eq ); + add_invalid_set1(lexer::token::e_comma ); + add_invalid_set1(lexer::token::e_add ); + add_invalid_set1(lexer::token::e_sub ); + add_invalid_set1(lexer::token::e_div ); + add_invalid_set1(lexer::token::e_mul ); + add_invalid_set1(lexer::token::e_mod ); + add_invalid_set1(lexer::token::e_pow ); + add_invalid_set1(lexer::token::e_colon ); + add_invalid_set1(lexer::token::e_ternary); + } + + bool result() exprtk_override + { + return error_list_.empty(); + } + + bool operator() (const lexer::token& t0, const lexer::token& t1) exprtk_override + { + const set_t::value_type p = std::make_pair(t0.type,t1.type); + + if (invalid_bracket_check(t0.type,t1.type)) + { + error_list_.push_back(std::make_pair(t0,t1)); + } + else if (invalid_comb_.find(p) != invalid_comb_.end()) + { + error_list_.push_back(std::make_pair(t0,t1)); + } + + return true; + } + + std::size_t error_count() const + { + return error_list_.size(); + } + + std::pair<lexer::token,lexer::token> error(const std::size_t index) + { + if (index < error_list_.size()) + { + return error_list_[index]; + } + else + { + static const lexer::token error_token; + return std::make_pair(error_token,error_token); + } + } + + void clear_errors() + { + error_list_.clear(); + } + + private: + + void add_invalid(const lexer::token::token_type base, const lexer::token::token_type t) + { + invalid_comb_.insert(std::make_pair(base,t)); + } + + void add_invalid_set1(const lexer::token::token_type t) + { + add_invalid(t, lexer::token::e_assign); + add_invalid(t, lexer::token::e_shr ); + add_invalid(t, lexer::token::e_shl ); + add_invalid(t, lexer::token::e_lte ); + add_invalid(t, lexer::token::e_ne ); + add_invalid(t, lexer::token::e_gte ); + add_invalid(t, lexer::token::e_lt ); + add_invalid(t, lexer::token::e_gt ); + add_invalid(t, lexer::token::e_eq ); + add_invalid(t, lexer::token::e_comma ); + add_invalid(t, lexer::token::e_div ); + add_invalid(t, lexer::token::e_mul ); + add_invalid(t, lexer::token::e_mod ); + add_invalid(t, lexer::token::e_pow ); + add_invalid(t, lexer::token::e_colon ); + } + + bool invalid_bracket_check(const lexer::token::token_type base, const lexer::token::token_type t) + { + if (details::is_right_bracket(static_cast<details::char_t>(base))) + { + switch (t) + { + case lexer::token::e_assign : return (']' != base); + case lexer::token::e_string : return (')' != base); + default : return false; + } + } + else if (details::is_left_bracket(static_cast<details::char_t>(base))) + { + if (details::is_right_bracket(static_cast<details::char_t>(t))) + return false; + else if (details::is_left_bracket(static_cast<details::char_t>(t))) + return false; + else + { + switch (t) + { + case lexer::token::e_number : return false; + case lexer::token::e_symbol : return false; + case lexer::token::e_string : return false; + case lexer::token::e_add : return false; + case lexer::token::e_sub : return false; + case lexer::token::e_colon : return false; + case lexer::token::e_ternary : return false; + default : return true ; + } + } + } + else if (details::is_right_bracket(static_cast<details::char_t>(t))) + { + switch (base) + { + case lexer::token::e_number : return false; + case lexer::token::e_symbol : return false; + case lexer::token::e_string : return false; + case lexer::token::e_eof : return false; + case lexer::token::e_colon : return false; + case lexer::token::e_ternary : return false; + default : return true ; + } + } + else if (details::is_left_bracket(static_cast<details::char_t>(t))) + { + switch (base) + { + case lexer::token::e_rbracket : return true; + case lexer::token::e_rsqrbracket : return true; + case lexer::token::e_rcrlbracket : return true; + default : return false; + } + } + + return false; + } + + set_t invalid_comb_; + std::vector<std::pair<lexer::token,lexer::token> > error_list_; + }; + + class sequence_validator_3tokens exprtk_final : public lexer::token_scanner + { + private: + + typedef lexer::token::token_type token_t; + typedef std::pair<token_t,std::pair<token_t,token_t> > token_triplet_t; + typedef std::set<token_triplet_t> set_t; + + public: + + using lexer::token_scanner::operator(); + + sequence_validator_3tokens() + : lexer::token_scanner(3) + { + add_invalid(lexer::token::e_number , lexer::token::e_number , lexer::token::e_number); + add_invalid(lexer::token::e_string , lexer::token::e_string , lexer::token::e_string); + add_invalid(lexer::token::e_comma , lexer::token::e_comma , lexer::token::e_comma ); + + add_invalid(lexer::token::e_add , lexer::token::e_add , lexer::token::e_add ); + add_invalid(lexer::token::e_sub , lexer::token::e_sub , lexer::token::e_sub ); + add_invalid(lexer::token::e_div , lexer::token::e_div , lexer::token::e_div ); + add_invalid(lexer::token::e_mul , lexer::token::e_mul , lexer::token::e_mul ); + add_invalid(lexer::token::e_mod , lexer::token::e_mod , lexer::token::e_mod ); + add_invalid(lexer::token::e_pow , lexer::token::e_pow , lexer::token::e_pow ); + + add_invalid(lexer::token::e_add , lexer::token::e_sub , lexer::token::e_add ); + add_invalid(lexer::token::e_sub , lexer::token::e_add , lexer::token::e_sub ); + add_invalid(lexer::token::e_div , lexer::token::e_mul , lexer::token::e_div ); + add_invalid(lexer::token::e_mul , lexer::token::e_div , lexer::token::e_mul ); + add_invalid(lexer::token::e_mod , lexer::token::e_pow , lexer::token::e_mod ); + add_invalid(lexer::token::e_pow , lexer::token::e_mod , lexer::token::e_pow ); + } + + bool result() exprtk_override + { + return error_list_.empty(); + } + + bool operator() (const lexer::token& t0, const lexer::token& t1, const lexer::token& t2) exprtk_override + { + const set_t::value_type p = std::make_pair(t0.type,std::make_pair(t1.type,t2.type)); + + if (invalid_comb_.find(p) != invalid_comb_.end()) + { + error_list_.push_back(std::make_pair(t0,t1)); + } + + return true; + } + + std::size_t error_count() const + { + return error_list_.size(); + } + + std::pair<lexer::token,lexer::token> error(const std::size_t index) + { + if (index < error_list_.size()) + { + return error_list_[index]; + } + else + { + static const lexer::token error_token; + return std::make_pair(error_token,error_token); + } + } + + void clear_errors() + { + error_list_.clear(); + } + + private: + + void add_invalid(const token_t t0, const token_t t1, const token_t t2) + { + invalid_comb_.insert(std::make_pair(t0,std::make_pair(t1,t2))); + } + + set_t invalid_comb_; + std::vector<std::pair<lexer::token,lexer::token> > error_list_; + }; + + struct helper_assembly + { + inline bool register_scanner(lexer::token_scanner* scanner) + { + if (token_scanner_list.end() != std::find(token_scanner_list.begin(), + token_scanner_list.end (), + scanner)) + { + return false; + } + + token_scanner_list.push_back(scanner); + + return true; + } + + inline bool register_modifier(lexer::token_modifier* modifier) + { + if (token_modifier_list.end() != std::find(token_modifier_list.begin(), + token_modifier_list.end (), + modifier)) + { + return false; + } + + token_modifier_list.push_back(modifier); + + return true; + } + + inline bool register_joiner(lexer::token_joiner* joiner) + { + if (token_joiner_list.end() != std::find(token_joiner_list.begin(), + token_joiner_list.end (), + joiner)) + { + return false; + } + + token_joiner_list.push_back(joiner); + + return true; + } + + inline bool register_inserter(lexer::token_inserter* inserter) + { + if (token_inserter_list.end() != std::find(token_inserter_list.begin(), + token_inserter_list.end (), + inserter)) + { + return false; + } + + token_inserter_list.push_back(inserter); + + return true; + } + + inline bool run_modifiers(lexer::generator& g) + { + error_token_modifier = reinterpret_cast<lexer::token_modifier*>(0); + + for (std::size_t i = 0; i < token_modifier_list.size(); ++i) + { + lexer::token_modifier& modifier = (*token_modifier_list[i]); + + modifier.reset(); + modifier.process(g); + + if (!modifier.result()) + { + error_token_modifier = token_modifier_list[i]; + + return false; + } + } + + return true; + } + + inline bool run_joiners(lexer::generator& g) + { + error_token_joiner = reinterpret_cast<lexer::token_joiner*>(0); + + for (std::size_t i = 0; i < token_joiner_list.size(); ++i) + { + lexer::token_joiner& joiner = (*token_joiner_list[i]); + + joiner.reset(); + joiner.process(g); + + if (!joiner.result()) + { + error_token_joiner = token_joiner_list[i]; + + return false; + } + } + + return true; + } + + inline bool run_inserters(lexer::generator& g) + { + error_token_inserter = reinterpret_cast<lexer::token_inserter*>(0); + + for (std::size_t i = 0; i < token_inserter_list.size(); ++i) + { + lexer::token_inserter& inserter = (*token_inserter_list[i]); + + inserter.reset(); + inserter.process(g); + + if (!inserter.result()) + { + error_token_inserter = token_inserter_list[i]; + + return false; + } + } + + return true; + } + + inline bool run_scanners(lexer::generator& g) + { + error_token_scanner = reinterpret_cast<lexer::token_scanner*>(0); + + for (std::size_t i = 0; i < token_scanner_list.size(); ++i) + { + lexer::token_scanner& scanner = (*token_scanner_list[i]); + + scanner.reset(); + scanner.process(g); + + if (!scanner.result()) + { + error_token_scanner = token_scanner_list[i]; + + return false; + } + } + + return true; + } + + std::vector<lexer::token_scanner*> token_scanner_list; + std::vector<lexer::token_modifier*> token_modifier_list; + std::vector<lexer::token_joiner*> token_joiner_list; + std::vector<lexer::token_inserter*> token_inserter_list; + + lexer::token_scanner* error_token_scanner; + lexer::token_modifier* error_token_modifier; + lexer::token_joiner* error_token_joiner; + lexer::token_inserter* error_token_inserter; + }; + } + + class parser_helper + { + public: + + typedef token token_t; + typedef generator generator_t; + + inline bool init(const std::string& str) + { + if (!lexer_.process(str)) + { + return false; + } + + lexer_.begin(); + + next_token(); + + return true; + } + + inline generator_t& lexer() + { + return lexer_; + } + + inline const generator_t& lexer() const + { + return lexer_; + } + + inline void store_token() + { + lexer_.store(); + store_current_token_ = current_token_; + } + + inline void restore_token() + { + lexer_.restore(); + current_token_ = store_current_token_; + } + + inline void next_token() + { + current_token_ = lexer_.next_token(); + } + + inline const token_t& current_token() const + { + return current_token_; + } + + inline const token_t& peek_next_token() + { + return lexer_.peek_next_token(); + } + + enum token_advance_mode + { + e_hold = 0, + e_advance = 1 + }; + + inline void advance_token(const token_advance_mode mode) + { + if (e_advance == mode) + { + next_token(); + } + } + + inline bool token_is(const token_t::token_type& ttype, const token_advance_mode mode = e_advance) + { + if (current_token().type != ttype) + { + return false; + } + + advance_token(mode); + + return true; + } + + inline bool token_is(const token_t::token_type& ttype, + const std::string& value, + const token_advance_mode mode = e_advance) + { + if ( + (current_token().type != ttype) || + !exprtk::details::imatch(value,current_token().value) + ) + { + return false; + } + + advance_token(mode); + + return true; + } + + inline bool token_is(const std::string& value, + const token_advance_mode mode = e_advance) + { + if (!exprtk::details::imatch(value,current_token().value)) + { + return false; + } + + advance_token(mode); + + return true; + } + + inline bool token_is_arithmetic_opr(const token_advance_mode mode = e_advance) + { + switch (current_token().type) + { + case token_t::e_add : + case token_t::e_sub : + case token_t::e_div : + case token_t::e_mul : + case token_t::e_mod : + case token_t::e_pow : break; + default : return false; + } + + advance_token(mode); + + return true; + } + + inline bool token_is_ineq_opr(const token_advance_mode mode = e_advance) + { + switch (current_token().type) + { + case token_t::e_eq : + case token_t::e_lte : + case token_t::e_ne : + case token_t::e_gte : + case token_t::e_lt : + case token_t::e_gt : break; + default : return false; + } + + advance_token(mode); + + return true; + } + + inline bool token_is_left_bracket(const token_advance_mode mode = e_advance) + { + switch (current_token().type) + { + case token_t::e_lbracket : + case token_t::e_lcrlbracket : + case token_t::e_lsqrbracket : break; + default : return false; + } + + advance_token(mode); + + return true; + } + + inline bool token_is_right_bracket(const token_advance_mode mode = e_advance) + { + switch (current_token().type) + { + case token_t::e_rbracket : + case token_t::e_rcrlbracket : + case token_t::e_rsqrbracket : break; + default : return false; + } + + advance_token(mode); + + return true; + } + + inline bool token_is_bracket(const token_advance_mode mode = e_advance) + { + switch (current_token().type) + { + case token_t::e_rbracket : + case token_t::e_rcrlbracket : + case token_t::e_rsqrbracket : + case token_t::e_lbracket : + case token_t::e_lcrlbracket : + case token_t::e_lsqrbracket : break; + default : return false; + } + + advance_token(mode); + + return true; + } + + inline bool token_is_loop(const token_advance_mode mode = e_advance) + { + return token_is("for" , mode) || + token_is("while" , mode) || + token_is("repeat", mode) ; + } + + inline bool peek_token_is(const token_t::token_type& ttype) + { + return (lexer_.peek_next_token().type == ttype); + } + + inline bool peek_token_is(const std::string& s) + { + return (exprtk::details::imatch(lexer_.peek_next_token().value,s)); + } + + private: + + generator_t lexer_; + token_t current_token_; + token_t store_current_token_; + }; + } + + template <typename T> + class vector_view + { + public: + + typedef T* data_ptr_t; + + vector_view(data_ptr_t data, const std::size_t& size) + : base_size_(size) + , size_(size) + , data_(data) + , data_ref_(0) + { + assert(size_ > 0); + } + + vector_view(const vector_view<T>& vv) + : base_size_(vv.base_size_) + , size_(vv.size_) + , data_(vv.data_) + , data_ref_(0) + { + assert(size_ > 0); + } + + inline void rebase(data_ptr_t data) + { + data_ = data; + + if (!data_ref_.empty()) + { + for (std::size_t i = 0; i < data_ref_.size(); ++i) + { + (*data_ref_[i]) = data; + } + } + } + + inline data_ptr_t data() const + { + return data_; + } + + inline std::size_t base_size() const + { + return base_size_; + } + + inline std::size_t size() const + { + return size_; + } + + inline const T& operator[](const std::size_t index) const + { + assert(index < size_); + return data_[index]; + } + + inline T& operator[](const std::size_t index) + { + assert(index < size_); + return data_[index]; + } + + void set_ref(data_ptr_t* data_ref) + { + data_ref_.push_back(data_ref); + exprtk_debug(("vector_view::set_ref() - data_ref: %p data_ref_.size(): %d\n", + reinterpret_cast<void*>(data_ref), + static_cast<int>(data_ref_.size()))); + } + + void remove_ref(data_ptr_t* data_ref) + { + data_ref_.erase( + std::remove(data_ref_.begin(), data_ref_.end(), data_ref), + data_ref_.end()); + exprtk_debug(("vector_view::remove_ref() - data_ref: %p data_ref_.size(): %d\n", + reinterpret_cast<void*>(data_ref), + static_cast<int>(data_ref_.size()))); + } + + bool set_size(const std::size_t new_size) + { + if ((new_size > 0) && (new_size <= base_size_)) + { + size_ = new_size; + exprtk_debug(("vector_view::set_size() - data_: %p size: %lu\n", + reinterpret_cast<void*>(data_), + size_)); + return true; + } + + exprtk_debug(("vector_view::set_size() - error invalid new_size: %lu base_size: %lu\n", + new_size, + base_size_)); + return false; + } + + private: + + const std::size_t base_size_; + std::size_t size_; + data_ptr_t data_; + std::vector<data_ptr_t*> data_ref_; + }; + + template <typename T> + inline vector_view<T> make_vector_view(T* data, + const std::size_t size, const std::size_t offset = 0) + { + return vector_view<T>(data + offset, size); + } + + template <typename T> + inline vector_view<T> make_vector_view(std::vector<T>& v, + const std::size_t size, const std::size_t offset = 0) + { + return vector_view<T>(v.data() + offset, size); + } + + template <typename T> class results_context; + + template <typename T> + struct type_store + { + enum store_type + { + e_unknown, + e_scalar , + e_vector , + e_string + }; + + type_store() + : data(0) + , size(0) + , type(e_unknown) + {} + + union + { + void* data; + T* vec_data; + }; + + std::size_t size; + store_type type; + + class parameter_list + { + public: + + explicit parameter_list(std::vector<type_store>& pl) + : parameter_list_(pl) + {} + + inline bool empty() const + { + return parameter_list_.empty(); + } + + inline std::size_t size() const + { + return parameter_list_.size(); + } + + inline type_store& operator[](const std::size_t& index) + { + return parameter_list_[index]; + } + + inline const type_store& operator[](const std::size_t& index) const + { + return parameter_list_[index]; + } + + inline type_store& front() + { + return parameter_list_[0]; + } + + inline const type_store& front() const + { + return parameter_list_[0]; + } + + inline type_store& back() + { + return parameter_list_.back(); + } + + inline const type_store& back() const + { + return parameter_list_.back(); + } + + private: + + std::vector<type_store>& parameter_list_; + + friend class results_context<T>; + }; + + template <typename ViewType> + struct type_view + { + typedef type_store<T> type_store_t; + typedef ViewType value_t; + + explicit type_view(type_store_t& ts) + : ts_(ts) + , data_(reinterpret_cast<value_t*>(ts_.data)) + {} + + explicit type_view(const type_store_t& ts) + : ts_(const_cast<type_store_t&>(ts)) + , data_(reinterpret_cast<value_t*>(ts_.data)) + {} + + inline std::size_t size() const + { + return ts_.size; + } + + inline value_t& operator[](const std::size_t& i) + { + return data_[i]; + } + + inline const value_t& operator[](const std::size_t& i) const + { + return data_[i]; + } + + inline const value_t* begin() const { return data_; } + inline value_t* begin() { return data_; } + + inline const value_t* end() const + { + return static_cast<value_t*>(data_ + ts_.size); + } + + inline value_t* end() + { + return static_cast<value_t*>(data_ + ts_.size); + } + + type_store_t& ts_; + value_t* data_; + }; + + typedef type_view<T> vector_view; + typedef type_view<char> string_view; + + struct scalar_view + { + typedef type_store<T> type_store_t; + typedef T value_t; + + explicit scalar_view(type_store_t& ts) + : v_(*reinterpret_cast<value_t*>(ts.data)) + {} + + explicit scalar_view(const type_store_t& ts) + : v_(*reinterpret_cast<value_t*>(const_cast<type_store_t&>(ts).data)) + {} + + inline value_t& operator() () + { + return v_; + } + + inline const value_t& operator() () const + { + return v_; + } + + inline operator value_t() const + { + return v_; + } + + inline operator value_t() + { + return v_; + } + + template <typename IntType> + inline bool to_int(IntType& i) const + { + if (!exprtk::details::numeric::is_integer(v_)) + return false; + + i = static_cast<IntType>(v_); + + return true; + } + + template <typename UIntType> + inline bool to_uint(UIntType& u) const + { + if (v_ < T(0)) + return false; + else if (!exprtk::details::numeric::is_integer(v_)) + return false; + + u = static_cast<UIntType>(v_); + + return true; + } + + T& v_; + }; + }; + + template <typename StringView> + inline std::string to_str(const StringView& view) + { + return std::string(view.begin(),view.size()); + } + + #ifndef exprtk_disable_return_statement + namespace details + { + template <typename T> class return_node; + template <typename T> class return_envelope_node; + } + #endif + + template <typename T> + class results_context + { + public: + + typedef type_store<T> type_store_t; + typedef typename type_store_t::scalar_view scalar_t; + typedef typename type_store_t::vector_view vector_t; + typedef typename type_store_t::string_view string_t; + + results_context() + : results_available_(false) + {} + + inline std::size_t count() const + { + if (results_available_) + return parameter_list_.size(); + else + return 0; + } + + inline type_store_t& operator[](const std::size_t& index) + { + return parameter_list_[index]; + } + + inline const type_store_t& operator[](const std::size_t& index) const + { + return parameter_list_[index]; + } + + inline bool get_scalar(const std::size_t& index, T& out) const + { + if ( + (index < parameter_list_.size()) && + (parameter_list_[index].type == type_store_t::e_scalar) + ) + { + const scalar_t scalar(parameter_list_[index]); + out = scalar(); + return true; + } + + return false; + } + + template <typename OutputIterator> + inline bool get_vector(const std::size_t& index, OutputIterator out_itr) const + { + if ( + (index < parameter_list_.size()) && + (parameter_list_[index].type == type_store_t::e_vector) + ) + { + const vector_t vector(parameter_list_[index]); + for (std::size_t i = 0; i < vector.size(); ++i) + { + *(out_itr++) = vector[i]; + } + + return true; + } + + return false; + } + + inline bool get_vector(const std::size_t& index, std::vector<T>& out) const + { + return get_vector(index,std::back_inserter(out)); + } + + inline bool get_string(const std::size_t& index, std::string& out) const + { + if ( + (index < parameter_list_.size()) && + (parameter_list_[index].type == type_store_t::e_string) + ) + { + const string_t str(parameter_list_[index]); + out.assign(str.begin(),str.size()); + return true; + } + + return false; + } + + private: + + inline void clear() + { + results_available_ = false; + } + + typedef std::vector<type_store_t> ts_list_t; + typedef typename type_store_t::parameter_list parameter_list_t; + + inline void assign(const parameter_list_t& pl) + { + parameter_list_ = pl.parameter_list_; + results_available_ = true; + } + + bool results_available_; + ts_list_t parameter_list_; + + #ifndef exprtk_disable_return_statement + friend class details::return_node<T>; + friend class details::return_envelope_node<T>; + #endif + }; + + namespace details + { + enum operator_type + { + e_default , e_null , e_add , e_sub , + e_mul , e_div , e_mod , e_pow , + e_atan2 , e_min , e_max , e_avg , + e_sum , e_prod , e_lt , e_lte , + e_eq , e_equal , e_ne , e_nequal , + e_gte , e_gt , e_and , e_nand , + e_or , e_nor , e_xor , e_xnor , + e_mand , e_mor , e_scand , e_scor , + e_shr , e_shl , e_abs , e_acos , + e_acosh , e_asin , e_asinh , e_atan , + e_atanh , e_ceil , e_cos , e_cosh , + e_exp , e_expm1 , e_floor , e_log , + e_log10 , e_log2 , e_log1p , e_logn , + e_neg , e_pos , e_round , e_roundn , + e_root , e_sqrt , e_sin , e_sinc , + e_sinh , e_sec , e_csc , e_tan , + e_tanh , e_cot , e_clamp , e_iclamp , + e_inrange , e_sgn , e_r2d , e_d2r , + e_d2g , e_g2d , e_hypot , e_notl , + e_erf , e_erfc , e_ncdf , e_frac , + e_trunc , e_assign , e_addass , e_subass , + e_mulass , e_divass , e_modass , e_in , + e_like , e_ilike , e_multi , e_smulti , + e_swap , + + // Do not add new functions/operators after this point. + e_sf00 = 1000, e_sf01 = 1001, e_sf02 = 1002, e_sf03 = 1003, + e_sf04 = 1004, e_sf05 = 1005, e_sf06 = 1006, e_sf07 = 1007, + e_sf08 = 1008, e_sf09 = 1009, e_sf10 = 1010, e_sf11 = 1011, + e_sf12 = 1012, e_sf13 = 1013, e_sf14 = 1014, e_sf15 = 1015, + e_sf16 = 1016, e_sf17 = 1017, e_sf18 = 1018, e_sf19 = 1019, + e_sf20 = 1020, e_sf21 = 1021, e_sf22 = 1022, e_sf23 = 1023, + e_sf24 = 1024, e_sf25 = 1025, e_sf26 = 1026, e_sf27 = 1027, + e_sf28 = 1028, e_sf29 = 1029, e_sf30 = 1030, e_sf31 = 1031, + e_sf32 = 1032, e_sf33 = 1033, e_sf34 = 1034, e_sf35 = 1035, + e_sf36 = 1036, e_sf37 = 1037, e_sf38 = 1038, e_sf39 = 1039, + e_sf40 = 1040, e_sf41 = 1041, e_sf42 = 1042, e_sf43 = 1043, + e_sf44 = 1044, e_sf45 = 1045, e_sf46 = 1046, e_sf47 = 1047, + e_sf48 = 1048, e_sf49 = 1049, e_sf50 = 1050, e_sf51 = 1051, + e_sf52 = 1052, e_sf53 = 1053, e_sf54 = 1054, e_sf55 = 1055, + e_sf56 = 1056, e_sf57 = 1057, e_sf58 = 1058, e_sf59 = 1059, + e_sf60 = 1060, e_sf61 = 1061, e_sf62 = 1062, e_sf63 = 1063, + e_sf64 = 1064, e_sf65 = 1065, e_sf66 = 1066, e_sf67 = 1067, + e_sf68 = 1068, e_sf69 = 1069, e_sf70 = 1070, e_sf71 = 1071, + e_sf72 = 1072, e_sf73 = 1073, e_sf74 = 1074, e_sf75 = 1075, + e_sf76 = 1076, e_sf77 = 1077, e_sf78 = 1078, e_sf79 = 1079, + e_sf80 = 1080, e_sf81 = 1081, e_sf82 = 1082, e_sf83 = 1083, + e_sf84 = 1084, e_sf85 = 1085, e_sf86 = 1086, e_sf87 = 1087, + e_sf88 = 1088, e_sf89 = 1089, e_sf90 = 1090, e_sf91 = 1091, + e_sf92 = 1092, e_sf93 = 1093, e_sf94 = 1094, e_sf95 = 1095, + e_sf96 = 1096, e_sf97 = 1097, e_sf98 = 1098, e_sf99 = 1099, + e_sffinal = 1100, + e_sf4ext00 = 2000, e_sf4ext01 = 2001, e_sf4ext02 = 2002, e_sf4ext03 = 2003, + e_sf4ext04 = 2004, e_sf4ext05 = 2005, e_sf4ext06 = 2006, e_sf4ext07 = 2007, + e_sf4ext08 = 2008, e_sf4ext09 = 2009, e_sf4ext10 = 2010, e_sf4ext11 = 2011, + e_sf4ext12 = 2012, e_sf4ext13 = 2013, e_sf4ext14 = 2014, e_sf4ext15 = 2015, + e_sf4ext16 = 2016, e_sf4ext17 = 2017, e_sf4ext18 = 2018, e_sf4ext19 = 2019, + e_sf4ext20 = 2020, e_sf4ext21 = 2021, e_sf4ext22 = 2022, e_sf4ext23 = 2023, + e_sf4ext24 = 2024, e_sf4ext25 = 2025, e_sf4ext26 = 2026, e_sf4ext27 = 2027, + e_sf4ext28 = 2028, e_sf4ext29 = 2029, e_sf4ext30 = 2030, e_sf4ext31 = 2031, + e_sf4ext32 = 2032, e_sf4ext33 = 2033, e_sf4ext34 = 2034, e_sf4ext35 = 2035, + e_sf4ext36 = 2036, e_sf4ext37 = 2037, e_sf4ext38 = 2038, e_sf4ext39 = 2039, + e_sf4ext40 = 2040, e_sf4ext41 = 2041, e_sf4ext42 = 2042, e_sf4ext43 = 2043, + e_sf4ext44 = 2044, e_sf4ext45 = 2045, e_sf4ext46 = 2046, e_sf4ext47 = 2047, + e_sf4ext48 = 2048, e_sf4ext49 = 2049, e_sf4ext50 = 2050, e_sf4ext51 = 2051, + e_sf4ext52 = 2052, e_sf4ext53 = 2053, e_sf4ext54 = 2054, e_sf4ext55 = 2055, + e_sf4ext56 = 2056, e_sf4ext57 = 2057, e_sf4ext58 = 2058, e_sf4ext59 = 2059, + e_sf4ext60 = 2060, e_sf4ext61 = 2061 + }; + + inline std::string to_str(const operator_type opr) + { + switch (opr) + { + case e_add : return "+" ; + case e_sub : return "-" ; + case e_mul : return "*" ; + case e_div : return "/" ; + case e_mod : return "%" ; + case e_pow : return "^" ; + case e_assign : return ":=" ; + case e_addass : return "+=" ; + case e_subass : return "-=" ; + case e_mulass : return "*=" ; + case e_divass : return "/=" ; + case e_modass : return "%=" ; + case e_lt : return "<" ; + case e_lte : return "<=" ; + case e_eq : return "==" ; + case e_equal : return "=" ; + case e_ne : return "!=" ; + case e_nequal : return "<>" ; + case e_gte : return ">=" ; + case e_gt : return ">" ; + case e_and : return "and" ; + case e_or : return "or" ; + case e_xor : return "xor" ; + case e_nand : return "nand"; + case e_nor : return "nor" ; + case e_xnor : return "xnor"; + default : return "N/A" ; + } + } + + struct base_operation_t + { + base_operation_t(const operator_type t, const unsigned int& np) + : type(t) + , num_params(np) + {} + + operator_type type; + unsigned int num_params; + }; + + namespace loop_unroll + { + const unsigned int global_loop_batch_size = + #ifndef exprtk_disable_superscalar_unroll + 16; + #else + 4; + #endif + + struct details + { + explicit details(const std::size_t& vsize, + const unsigned int loop_batch_size = global_loop_batch_size) + : batch_size(loop_batch_size ) + , remainder (vsize % batch_size) + , upper_bound(static_cast<int>(vsize - (remainder ? loop_batch_size : 0))) + {} + + unsigned int batch_size; + int remainder; + int upper_bound; + }; + } + + #ifdef exprtk_enable_debugging + inline void dump_ptr(const std::string& s, const void* ptr, const std::size_t size = 0) + { + if (size) + exprtk_debug(("%s - addr: %p size: %d\n", + s.c_str(), + ptr, + static_cast<unsigned int>(size))); + else + exprtk_debug(("%s - addr: %p\n", s.c_str(), ptr)); + } + + template <typename T> + inline void dump_vector(const std::string& vec_name, const T* data, const std::size_t size) + { + printf("----- %s (%p) -----\n", + vec_name.c_str(), + static_cast<const void*>(data)); + printf("[ "); + for (std::size_t i = 0; i < size; ++i) + { + printf("%8.3f\t", data[i]); + } + printf(" ]\n"); + printf("---------------------\n"); + } + #else + inline void dump_ptr(const std::string&, const void*) {} + inline void dump_ptr(const std::string&, const void*, const std::size_t) {} + template <typename T> + inline void dump_vector(const std::string&, const T*, const std::size_t) {} + #endif + + template <typename T> + class vec_data_store + { + public: + + typedef vec_data_store<T> type; + typedef T* data_t; + + private: + + struct control_block + { + control_block() + : ref_count(1) + , size (0) + , data (0) + , destruct (true) + {} + + explicit control_block(const std::size_t& dsize) + : ref_count(1 ) + , size (dsize) + , data (0 ) + , destruct (true ) + { create_data(); } + + control_block(const std::size_t& dsize, data_t dptr, bool dstrct = false) + : ref_count(1 ) + , size (dsize ) + , data (dptr ) + , destruct (dstrct) + {} + + ~control_block() + { + if (data && destruct && (0 == ref_count)) + { + dump_ptr("~vec_data_store::control_block() data",data); + delete[] data; + data = reinterpret_cast<data_t>(0); + } + } + + static inline control_block* create(const std::size_t& dsize, data_t data_ptr = data_t(0), bool dstrct = false) + { + if (dsize) + { + if (0 == data_ptr) + return (new control_block(dsize)); + else + return (new control_block(dsize, data_ptr, dstrct)); + } + else + return (new control_block); + } + + static inline void destroy(control_block*& cntrl_blck) + { + if (cntrl_blck) + { + if ( + (0 != cntrl_blck->ref_count) && + (0 == --cntrl_blck->ref_count) + ) + { + delete cntrl_blck; + } + + cntrl_blck = 0; + } + } + + std::size_t ref_count; + std::size_t size; + data_t data; + bool destruct; + + private: + + control_block(const control_block&) exprtk_delete; + control_block& operator=(const control_block&) exprtk_delete; + + inline void create_data() + { + destruct = true; + data = new T[size]; + std::fill_n(data, size, T(0)); + dump_ptr("control_block::create_data() - data", data, size); + } + }; + + public: + + vec_data_store() + : control_block_(control_block::create(0)) + {} + + explicit vec_data_store(const std::size_t& size) + : control_block_(control_block::create(size,reinterpret_cast<data_t>(0),true)) + {} + + vec_data_store(const std::size_t& size, data_t data, bool dstrct = false) + : control_block_(control_block::create(size, data, dstrct)) + {} + + vec_data_store(const type& vds) + { + control_block_ = vds.control_block_; + control_block_->ref_count++; + } + + ~vec_data_store() + { + control_block::destroy(control_block_); + } + + type& operator=(const type& vds) + { + if (this != &vds) + { + const std::size_t final_size = min_size(control_block_, vds.control_block_); + + vds.control_block_->size = final_size; + control_block_->size = final_size; + + if (control_block_->destruct || (0 == control_block_->data)) + { + control_block::destroy(control_block_); + + control_block_ = vds.control_block_; + control_block_->ref_count++; + } + } + + return (*this); + } + + inline data_t data() + { + return control_block_->data; + } + + inline data_t data() const + { + return control_block_->data; + } + + inline std::size_t size() const + { + return control_block_->size; + } + + inline data_t& ref() + { + return control_block_->data; + } + + inline void dump() const + { + #ifdef exprtk_enable_debugging + exprtk_debug(("size: %d\taddress:%p\tdestruct:%c\n", + size(), + data(), + (control_block_->destruct ? 'T' : 'F'))); + + for (std::size_t i = 0; i < size(); ++i) + { + if (5 == i) + exprtk_debug(("\n")); + + exprtk_debug(("%15.10f ", data()[i])); + } + exprtk_debug(("\n")); + #endif + } + + static inline void match_sizes(type& vds0, type& vds1) + { + const std::size_t size = min_size(vds0.control_block_,vds1.control_block_); + vds0.control_block_->size = size; + vds1.control_block_->size = size; + } + + private: + + static inline std::size_t min_size(const control_block* cb0, const control_block* cb1) + { + const std::size_t size0 = cb0->size; + const std::size_t size1 = cb1->size; + + if (size0 && size1) + return std::min(size0,size1); + else + return (size0) ? size0 : size1; + } + + control_block* control_block_; + }; + + namespace numeric + { + namespace details + { + template <typename T> + inline T process_impl(const operator_type operation, const T arg) + { + switch (operation) + { + case e_abs : return numeric::abs (arg); + case e_acos : return numeric::acos (arg); + case e_acosh : return numeric::acosh(arg); + case e_asin : return numeric::asin (arg); + case e_asinh : return numeric::asinh(arg); + case e_atan : return numeric::atan (arg); + case e_atanh : return numeric::atanh(arg); + case e_ceil : return numeric::ceil (arg); + case e_cos : return numeric::cos (arg); + case e_cosh : return numeric::cosh (arg); + case e_exp : return numeric::exp (arg); + case e_expm1 : return numeric::expm1(arg); + case e_floor : return numeric::floor(arg); + case e_log : return numeric::log (arg); + case e_log10 : return numeric::log10(arg); + case e_log2 : return numeric::log2 (arg); + case e_log1p : return numeric::log1p(arg); + case e_neg : return numeric::neg (arg); + case e_pos : return numeric::pos (arg); + case e_round : return numeric::round(arg); + case e_sin : return numeric::sin (arg); + case e_sinc : return numeric::sinc (arg); + case e_sinh : return numeric::sinh (arg); + case e_sqrt : return numeric::sqrt (arg); + case e_tan : return numeric::tan (arg); + case e_tanh : return numeric::tanh (arg); + case e_cot : return numeric::cot (arg); + case e_sec : return numeric::sec (arg); + case e_csc : return numeric::csc (arg); + case e_r2d : return numeric::r2d (arg); + case e_d2r : return numeric::d2r (arg); + case e_d2g : return numeric::d2g (arg); + case e_g2d : return numeric::g2d (arg); + case e_notl : return numeric::notl (arg); + case e_sgn : return numeric::sgn (arg); + case e_erf : return numeric::erf (arg); + case e_erfc : return numeric::erfc (arg); + case e_ncdf : return numeric::ncdf (arg); + case e_frac : return numeric::frac (arg); + case e_trunc : return numeric::trunc(arg); + + default : exprtk_debug(("numeric::details::process_impl<T> - Invalid unary operation.\n")); + return std::numeric_limits<T>::quiet_NaN(); + } + } + + template <typename T> + inline T process_impl(const operator_type operation, const T arg0, const T arg1) + { + switch (operation) + { + case e_add : return (arg0 + arg1); + case e_sub : return (arg0 - arg1); + case e_mul : return (arg0 * arg1); + case e_div : return (arg0 / arg1); + case e_mod : return modulus<T>(arg0,arg1); + case e_pow : return pow<T>(arg0,arg1); + case e_atan2 : return atan2<T>(arg0,arg1); + case e_min : return std::min<T>(arg0,arg1); + case e_max : return std::max<T>(arg0,arg1); + case e_logn : return logn<T>(arg0,arg1); + case e_lt : return (arg0 < arg1) ? T(1) : T(0); + case e_lte : return (arg0 <= arg1) ? T(1) : T(0); + case e_eq : return std::equal_to<T>()(arg0,arg1) ? T(1) : T(0); + case e_ne : return std::not_equal_to<T>()(arg0,arg1) ? T(1) : T(0); + case e_gte : return (arg0 >= arg1) ? T(1) : T(0); + case e_gt : return (arg0 > arg1) ? T(1) : T(0); + case e_and : return and_opr <T>(arg0,arg1); + case e_nand : return nand_opr<T>(arg0,arg1); + case e_or : return or_opr <T>(arg0,arg1); + case e_nor : return nor_opr <T>(arg0,arg1); + case e_xor : return xor_opr <T>(arg0,arg1); + case e_xnor : return xnor_opr<T>(arg0,arg1); + case e_root : return root <T>(arg0,arg1); + case e_roundn : return roundn <T>(arg0,arg1); + case e_equal : return equal <T>(arg0,arg1); + case e_nequal : return nequal <T>(arg0,arg1); + case e_hypot : return hypot <T>(arg0,arg1); + case e_shr : return shr <T>(arg0,arg1); + case e_shl : return shl <T>(arg0,arg1); + + default : exprtk_debug(("numeric::details::process_impl<T> - Invalid binary operation.\n")); + return std::numeric_limits<T>::quiet_NaN(); + } + } + + template <typename T> + inline T process_impl(const operator_type operation, const T arg0, const T arg1, int_type_tag) + { + switch (operation) + { + case e_add : return (arg0 + arg1); + case e_sub : return (arg0 - arg1); + case e_mul : return (arg0 * arg1); + case e_div : return (arg0 / arg1); + case e_mod : return arg0 % arg1; + case e_pow : return pow<T>(arg0,arg1); + case e_min : return std::min<T>(arg0,arg1); + case e_max : return std::max<T>(arg0,arg1); + case e_logn : return logn<T>(arg0,arg1); + case e_lt : return (arg0 < arg1) ? T(1) : T(0); + case e_lte : return (arg0 <= arg1) ? T(1) : T(0); + case e_eq : return (arg0 == arg1) ? T(1) : T(0); + case e_ne : return (arg0 != arg1) ? T(1) : T(0); + case e_gte : return (arg0 >= arg1) ? T(1) : T(0); + case e_gt : return (arg0 > arg1) ? T(1) : T(0); + case e_and : return ((arg0 != T(0)) && (arg1 != T(0))) ? T(1) : T(0); + case e_nand : return ((arg0 != T(0)) && (arg1 != T(0))) ? T(0) : T(1); + case e_or : return ((arg0 != T(0)) || (arg1 != T(0))) ? T(1) : T(0); + case e_nor : return ((arg0 != T(0)) || (arg1 != T(0))) ? T(0) : T(1); + case e_xor : return arg0 ^ arg1; + case e_xnor : return !(arg0 ^ arg1); + case e_root : return root<T>(arg0,arg1); + case e_equal : return arg0 == arg1; + case e_nequal : return arg0 != arg1; + case e_hypot : return hypot<T>(arg0,arg1); + case e_shr : return arg0 >> arg1; + case e_shl : return arg0 << arg1; + + default : exprtk_debug(("numeric::details::process_impl<IntType> - Invalid binary operation.\n")); + return std::numeric_limits<T>::quiet_NaN(); + } + } + } + + template <typename T> + inline T process(const operator_type operation, const T arg) + { + return exprtk::details::numeric::details::process_impl(operation,arg); + } + + template <typename T> + inline T process(const operator_type operation, const T arg0, const T arg1) + { + return exprtk::details::numeric::details::process_impl(operation, arg0, arg1); + } + } + + template <typename Node> + struct node_collector_interface + { + typedef Node* node_ptr_t; + typedef Node** node_pp_t; + typedef std::vector<node_pp_t> noderef_list_t; + + virtual ~node_collector_interface() + {} + + virtual void collect_nodes(noderef_list_t&) + {} + }; + + template <typename Node> + struct node_depth_base; + + template <typename T> + class expression_node : public node_collector_interface<expression_node<T> > + , public node_depth_base<expression_node<T> > + { + public: + + enum node_type + { + e_none , e_null , e_constant , e_unary , + e_binary , e_binary_ext , e_trinary , e_quaternary , + e_vararg , e_conditional , e_while , e_repeat , + e_for , e_switch , e_mswitch , e_return , + e_retenv , e_variable , e_stringvar , e_stringconst , + e_stringvarrng , e_cstringvarrng , e_strgenrange , e_strconcat , + e_stringvarsize , e_strswap , e_stringsize , e_stringvararg , + e_function , e_vafunction , e_genfunction , e_strfunction , + e_strcondition , e_strccondition , e_add , e_sub , + e_mul , e_div , e_mod , e_pow , + e_lt , e_lte , e_gt , e_gte , + e_eq , e_ne , e_and , e_nand , + e_or , e_nor , e_xor , e_xnor , + e_in , e_like , e_ilike , e_inranges , + e_ipow , e_ipowinv , e_abs , e_acos , + e_acosh , e_asin , e_asinh , e_atan , + e_atanh , e_ceil , e_cos , e_cosh , + e_exp , e_expm1 , e_floor , e_log , + e_log10 , e_log2 , e_log1p , e_neg , + e_pos , e_round , e_sin , e_sinc , + e_sinh , e_sqrt , e_tan , e_tanh , + e_cot , e_sec , e_csc , e_r2d , + e_d2r , e_d2g , e_g2d , e_notl , + e_sgn , e_erf , e_erfc , e_ncdf , + e_frac , e_trunc , e_uvouv , e_vov , + e_cov , e_voc , e_vob , e_bov , + e_cob , e_boc , e_vovov , e_vovoc , + e_vocov , e_covov , e_covoc , e_vovovov , + e_vovovoc , e_vovocov , e_vocovov , e_covovov , + e_covocov , e_vocovoc , e_covovoc , e_vococov , + e_sf3ext , e_sf4ext , e_nulleq , e_strass , + e_vector , e_vecsize , e_vecelem , e_veccelem , + e_vecelemrtc , e_veccelemrtc , e_rbvecelem , e_rbvecelemrtc , + e_rbveccelem , e_rbveccelemrtc , e_vecinit , e_vecvalass , + e_vecvecass , e_vecopvalass , e_vecopvecass , e_vecfunc , + e_vecvecswap , e_vecvecineq , e_vecvalineq , e_valvecineq , + e_vecvecarith , e_vecvalarith , e_valvecarith , e_vecunaryop , + e_vecondition , e_break , e_continue , e_swap , + e_assert + }; + + typedef T value_type; + typedef expression_node<T>* expression_ptr; + typedef node_collector_interface<expression_node<T> > nci_t; + typedef typename nci_t::noderef_list_t noderef_list_t; + typedef node_depth_base<expression_node<T> > ndb_t; + + virtual ~expression_node() + {} + + inline virtual T value() const + { + return std::numeric_limits<T>::quiet_NaN(); + } + + inline virtual expression_node<T>* branch(const std::size_t& index = 0) const + { + return reinterpret_cast<expression_ptr>(index * 0); + } + + inline virtual node_type type() const + { + return e_none; + } + + inline virtual bool valid() const + { + return true; + } + }; // class expression_node + + template <typename T> + inline bool is_generally_string_node(const expression_node<T>* node); + + inline bool is_true(const double v) + { + return std::not_equal_to<double>()(0.0,v); + } + + inline bool is_true(const long double v) + { + return std::not_equal_to<long double>()(0.0L,v); + } + + inline bool is_true(const float v) + { + return std::not_equal_to<float>()(0.0f,v); + } + + template <typename T> + inline bool is_true(const expression_node<T>* node) + { + return std::not_equal_to<T>()(T(0),node->value()); + } + + template <typename T> + inline bool is_true(const std::pair<expression_node<T>*,bool>& node) + { + return std::not_equal_to<T>()(T(0),node.first->value()); + } + + template <typename T> + inline bool is_false(const expression_node<T>* node) + { + return std::equal_to<T>()(T(0),node->value()); + } + + template <typename T> + inline bool is_false(const std::pair<expression_node<T>*,bool>& node) + { + return std::equal_to<T>()(T(0),node.first->value()); + } + + template <typename T> + inline bool is_literal_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_constant == node->type()); + } + + template <typename T> + inline bool is_unary_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_unary == node->type()); + } + + template <typename T> + inline bool is_neg_unary_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_neg == node->type()); + } + + template <typename T> + inline bool is_binary_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_binary == node->type()); + } + + template <typename T> + inline bool is_variable_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_variable == node->type()); + } + + template <typename T> + inline bool is_ivariable_node(const expression_node<T>* node) + { + return node && + ( + details::expression_node<T>::e_variable == node->type() || + details::expression_node<T>::e_vecelem == node->type() || + details::expression_node<T>::e_veccelem == node->type() || + details::expression_node<T>::e_vecelemrtc == node->type() || + details::expression_node<T>::e_veccelemrtc == node->type() || + details::expression_node<T>::e_rbvecelem == node->type() || + details::expression_node<T>::e_rbveccelem == node->type() || + details::expression_node<T>::e_rbvecelemrtc == node->type() || + details::expression_node<T>::e_rbveccelemrtc == node->type() + ); + } + + template <typename T> + inline bool is_vector_elem_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_vecelem == node->type()); + } + + template <typename T> + inline bool is_vector_celem_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_veccelem == node->type()); + } + + template <typename T> + inline bool is_vector_elem_rtc_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_vecelemrtc == node->type()); + } + + template <typename T> + inline bool is_vector_celem_rtc_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_veccelemrtc == node->type()); + } + + template <typename T> + inline bool is_rebasevector_elem_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_rbvecelem == node->type()); + } + + template <typename T> + inline bool is_rebasevector_elem_rtc_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_rbvecelemrtc == node->type()); + } + + template <typename T> + inline bool is_rebasevector_celem_rtc_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_rbveccelemrtc == node->type()); + } + + template <typename T> + inline bool is_rebasevector_celem_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_rbveccelem == node->type()); + } + + template <typename T> + inline bool is_vector_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_vector == node->type()); + } + + template <typename T> + inline bool is_ivector_node(const expression_node<T>* node) + { + if (node) + { + switch (node->type()) + { + case details::expression_node<T>::e_vector : + case details::expression_node<T>::e_vecvalass : + case details::expression_node<T>::e_vecvecass : + case details::expression_node<T>::e_vecopvalass : + case details::expression_node<T>::e_vecopvecass : + case details::expression_node<T>::e_vecvecswap : + case details::expression_node<T>::e_vecvecarith : + case details::expression_node<T>::e_vecvalarith : + case details::expression_node<T>::e_valvecarith : + case details::expression_node<T>::e_vecunaryop : + case details::expression_node<T>::e_vecondition : return true; + default : return false; + } + } + else + return false; + } + + template <typename T> + inline bool is_constant_node(const expression_node<T>* node) + { + return node && + ( + details::expression_node<T>::e_constant == node->type() || + details::expression_node<T>::e_stringconst == node->type() + ); + } + + template <typename T> + inline bool is_null_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_null == node->type()); + } + + template <typename T> + inline bool is_break_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_break == node->type()); + } + + template <typename T> + inline bool is_continue_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_continue == node->type()); + } + + template <typename T> + inline bool is_swap_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_swap == node->type()); + } + + template <typename T> + inline bool is_function(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_function == node->type()); + } + + template <typename T> + inline bool is_vararg_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_vararg == node->type()); + } + + template <typename T> + inline bool is_return_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_return == node->type()); + } + + template <typename T> class unary_node; + + template <typename T> + inline bool is_negate_node(const expression_node<T>* node) + { + if (node && is_unary_node(node)) + { + return (details::e_neg == static_cast<const unary_node<T>*>(node)->operation()); + } + else + return false; + } + + template <typename T> + inline bool is_assert_node(const expression_node<T>* node) + { + return node && (details::expression_node<T>::e_assert == node->type()); + } + + template <typename T> + inline bool branch_deletable(const expression_node<T>* node) + { + return (0 != node) && + !is_variable_node(node) && + !is_string_node (node) ; + } + + template <std::size_t N, typename T> + inline bool all_nodes_valid(expression_node<T>* const (&b)[N]) + { + for (std::size_t i = 0; i < N; ++i) + { + if (0 == b[i]) return false; + } + + return true; + } + + template <typename T, + typename Allocator, + template <typename, typename> class Sequence> + inline bool all_nodes_valid(const Sequence<expression_node<T>*,Allocator>& b) + { + for (std::size_t i = 0; i < b.size(); ++i) + { + if (0 == b[i]) return false; + } + + return true; + } + + template <std::size_t N, typename T> + inline bool all_nodes_variables(expression_node<T>* const (&b)[N]) + { + for (std::size_t i = 0; i < N; ++i) + { + if (0 == b[i]) + return false; + else if (!is_variable_node(b[i])) + return false; + } + + return true; + } + + template <typename T, + typename Allocator, + template <typename, typename> class Sequence> + inline bool all_nodes_variables(const Sequence<expression_node<T>*,Allocator>& b) + { + for (std::size_t i = 0; i < b.size(); ++i) + { + if (0 == b[i]) + return false; + else if (!is_variable_node(b[i])) + return false; + } + + return true; + } + + template <typename Node> + class node_collection_destructor + { + public: + + typedef node_collector_interface<Node> nci_t; + + typedef typename nci_t::node_ptr_t node_ptr_t; + typedef typename nci_t::node_pp_t node_pp_t; + typedef typename nci_t::noderef_list_t noderef_list_t; + + static void delete_nodes(node_ptr_t& root) + { + std::vector<node_pp_t> node_delete_list; + node_delete_list.reserve(1000); + + collect_nodes(root, node_delete_list); + + for (std::size_t i = 0; i < node_delete_list.size(); ++i) + { + node_ptr_t& node = *node_delete_list[i]; + exprtk_debug(("ncd::delete_nodes() - deleting: %p\n", reinterpret_cast<void*>(node))); + delete node; + node = reinterpret_cast<node_ptr_t>(0); + } + } + + private: + + static void collect_nodes(node_ptr_t& root, noderef_list_t& node_delete_list) + { + std::deque<node_ptr_t> node_list; + node_list.push_back(root); + node_delete_list.push_back(&root); + + noderef_list_t child_node_delete_list; + child_node_delete_list.reserve(1000); + + while (!node_list.empty()) + { + node_list.front()->collect_nodes(child_node_delete_list); + + if (!child_node_delete_list.empty()) + { + for (std::size_t i = 0; i < child_node_delete_list.size(); ++i) + { + node_pp_t& node = child_node_delete_list[i]; + + if (0 == (*node)) + { + exprtk_debug(("ncd::collect_nodes() - null node encountered.\n")); + } + + node_list.push_back(*node); + } + + node_delete_list.insert( + node_delete_list.end(), + child_node_delete_list.begin(), child_node_delete_list.end()); + + child_node_delete_list.clear(); + } + + node_list.pop_front(); + } + + std::reverse(node_delete_list.begin(), node_delete_list.end()); + } + }; + + template <typename NodeAllocator, typename T, std::size_t N> + inline void free_all_nodes(NodeAllocator& node_allocator, expression_node<T>* (&b)[N]) + { + for (std::size_t i = 0; i < N; ++i) + { + free_node(node_allocator,b[i]); + } + } + + template <typename NodeAllocator, + typename T, + typename Allocator, + template <typename, typename> class Sequence> + inline void free_all_nodes(NodeAllocator& node_allocator, Sequence<expression_node<T>*,Allocator>& b) + { + for (std::size_t i = 0; i < b.size(); ++i) + { + free_node(node_allocator,b[i]); + } + + b.clear(); + } + + template <typename NodeAllocator, typename T> + inline void free_node(NodeAllocator&, expression_node<T>*& node) + { + if ((0 == node) || is_variable_node(node) || is_string_node(node)) + { + return; + } + + node_collection_destructor<expression_node<T> > + ::delete_nodes(node); + } + + template <typename T> + inline void destroy_node(expression_node<T>*& node) + { + if (0 != node) + { + node_collection_destructor<expression_node<T> > + ::delete_nodes(node); + } + } + + template <typename Node> + struct node_depth_base + { + typedef Node* node_ptr_t; + typedef std::pair<node_ptr_t,bool> nb_pair_t; + + node_depth_base() + : depth_set(false) + , depth(0) + {} + + virtual ~node_depth_base() + {} + + virtual std::size_t node_depth() const { return 1; } + + std::size_t compute_node_depth(const Node* const& node) const + { + if (!depth_set) + { + depth = 1 + (node ? node->node_depth() : 0); + depth_set = true; + } + + return depth; + } + + std::size_t compute_node_depth(const nb_pair_t& branch) const + { + if (!depth_set) + { + depth = 1 + (branch.first ? branch.first->node_depth() : 0); + depth_set = true; + } + + return depth; + } + + template <std::size_t N> + std::size_t compute_node_depth(const nb_pair_t (&branch)[N]) const + { + if (!depth_set) + { + depth = 0; + + for (std::size_t i = 0; i < N; ++i) + { + if (branch[i].first) + { + depth = std::max(depth,branch[i].first->node_depth()); + } + } + + depth += 1; + depth_set = true; + } + + return depth; + } + + template <typename BranchType> + std::size_t max_node_depth(const BranchType& n0, const BranchType& n1) const + { + return std::max(compute_node_depth(n0), compute_node_depth(n1)); + } + + template <typename BranchType> + std::size_t max_node_depth(const BranchType& n0, const BranchType& n1, const BranchType& n2) const + { + return std::max(compute_node_depth(n0), + std::max(compute_node_depth(n1), compute_node_depth(n2))); + } + + template <typename BranchType> + std::size_t max_node_depth(const BranchType& n0, const BranchType& n1, + const BranchType& n2, const BranchType& n3) const + { + return std::max( + std::max(compute_node_depth(n0), compute_node_depth(n1)), + std::max(compute_node_depth(n2), compute_node_depth(n3))); + } + + template <typename BranchType> + std::size_t compute_node_depth(const BranchType& n0, const BranchType& n1) const + { + if (!depth_set) + { + depth = 1 + max_node_depth(n0, n1); + depth_set = true; + } + + return depth; + } + + template <typename BranchType> + std::size_t compute_node_depth(const BranchType& n0, const BranchType& n1, + const BranchType& n2) const + { + if (!depth_set) + { + depth = 1 + max_node_depth(n0, n1, n2); + depth_set = true; + } + + return depth; + } + + template <typename BranchType> + std::size_t compute_node_depth(const BranchType& n0, const BranchType& n1, + const BranchType& n2, const BranchType& n3) const + { + if (!depth_set) + { + depth = 1 + max_node_depth(n0, n1, n2, n3); + depth_set = true; + } + + return depth; + } + + template <typename Allocator, + template <typename, typename> class Sequence> + std::size_t compute_node_depth(const Sequence<node_ptr_t, Allocator>& branch_list) const + { + if (!depth_set) + { + for (std::size_t i = 0; i < branch_list.size(); ++i) + { + if (branch_list[i]) + { + depth = std::max(depth, compute_node_depth(branch_list[i])); + } + } + + depth_set = true; + } + + return depth; + } + + template <typename Allocator, + template <typename, typename> class Sequence> + std::size_t compute_node_depth(const Sequence<nb_pair_t,Allocator>& branch_list) const + { + if (!depth_set) + { + for (std::size_t i = 0; i < branch_list.size(); ++i) + { + if (branch_list[i].first) + { + depth = std::max(depth, compute_node_depth(branch_list[i].first)); + } + } + + depth_set = true; + } + + return depth; + } + + mutable bool depth_set; + mutable std::size_t depth; + + template <typename NodeSequence> + void collect(node_ptr_t const& node, + const bool deletable, + NodeSequence& delete_node_list) const + { + if ((0 != node) && deletable) + { + delete_node_list.push_back(const_cast<node_ptr_t*>(&node)); + } + } + + template <typename NodeSequence> + void collect(const nb_pair_t& branch, + NodeSequence& delete_node_list) const + { + collect(branch.first, branch.second, delete_node_list); + } + + template <typename NodeSequence> + void collect(Node*& node, + NodeSequence& delete_node_list) const + { + collect(node, branch_deletable(node), delete_node_list); + } + + template <std::size_t N, typename NodeSequence> + void collect(const nb_pair_t(&branch)[N], + NodeSequence& delete_node_list) const + { + for (std::size_t i = 0; i < N; ++i) + { + collect(branch[i].first, branch[i].second, delete_node_list); + } + } + + template <typename Allocator, + template <typename, typename> class Sequence, + typename NodeSequence> + void collect(const Sequence<nb_pair_t, Allocator>& branch, + NodeSequence& delete_node_list) const + { + for (std::size_t i = 0; i < branch.size(); ++i) + { + collect(branch[i].first, branch[i].second, delete_node_list); + } + } + + template <typename Allocator, + template <typename, typename> class Sequence, + typename NodeSequence> + void collect(const Sequence<node_ptr_t, Allocator>& branch_list, + NodeSequence& delete_node_list) const + { + for (std::size_t i = 0; i < branch_list.size(); ++i) + { + collect(branch_list[i], branch_deletable(branch_list[i]), delete_node_list); + } + } + + template <typename Boolean, + typename AllocatorT, + typename AllocatorB, + template <typename, typename> class Sequence, + typename NodeSequence> + void collect(const Sequence<node_ptr_t, AllocatorT>& branch_list, + const Sequence<Boolean, AllocatorB>& branch_deletable_list, + NodeSequence& delete_node_list) const + { + for (std::size_t i = 0; i < branch_list.size(); ++i) + { + collect(branch_list[i], branch_deletable_list[i], delete_node_list); + } + } + }; + + template <typename Type> + class vector_holder + { + private: + + typedef Type value_type; + typedef value_type* value_ptr; + typedef const value_ptr const_value_ptr; + typedef vector_holder<Type> vector_holder_t; + + class vector_holder_base + { + public: + + virtual ~vector_holder_base() + {} + + inline value_ptr operator[](const std::size_t& index) const + { + return value_at(index); + } + + inline std::size_t size() const + { + return vector_size(); + } + + inline std::size_t base_size() const + { + return vector_base_size(); + } + + inline value_ptr data() const + { + return value_at(0); + } + + virtual inline bool rebaseable() const + { + return false; + } + + virtual void set_ref(value_ptr*) + {} + + virtual void remove_ref(value_ptr*) + {} + + virtual vector_view<Type>* rebaseable_instance() + { + return reinterpret_cast<vector_view<Type>*>(0); + } + + protected: + + virtual value_ptr value_at(const std::size_t&) const = 0; + virtual std::size_t vector_size() const = 0; + virtual std::size_t vector_base_size() const = 0; + }; + + class array_vector_impl exprtk_final : public vector_holder_base + { + public: + + array_vector_impl(const Type* vec, const std::size_t& vec_size) + : vec_(vec) + , size_(vec_size) + {} + + protected: + + value_ptr value_at(const std::size_t& index) const exprtk_override + { + assert(index < size_); + return const_cast<const_value_ptr>(vec_ + index); + } + + std::size_t vector_size() const exprtk_override + { + return size_; + } + + std::size_t vector_base_size() const exprtk_override + { + return vector_size(); + } + + private: + + array_vector_impl(const array_vector_impl&) exprtk_delete; + array_vector_impl& operator=(const array_vector_impl&) exprtk_delete; + + const Type* vec_; + const std::size_t size_; + }; + + template <typename Allocator, + template <typename, typename> class Sequence> + class sequence_vector_impl exprtk_final : public vector_holder_base + { + public: + + typedef Sequence<Type,Allocator> sequence_t; + + explicit sequence_vector_impl(sequence_t& seq) + : sequence_(seq) + {} + + protected: + + value_ptr value_at(const std::size_t& index) const exprtk_override + { + assert(index < sequence_.size()); + return (&sequence_[index]); + } + + std::size_t vector_size() const exprtk_override + { + return sequence_.size(); + } + + std::size_t vector_base_size() const exprtk_override + { + return vector_size(); + } + + private: + + sequence_vector_impl(const sequence_vector_impl&) exprtk_delete; + sequence_vector_impl& operator=(const sequence_vector_impl&) exprtk_delete; + + sequence_t& sequence_; + }; + + class vector_view_impl exprtk_final : public vector_holder_base + { + public: + + typedef exprtk::vector_view<Type> vector_view_t; + + explicit vector_view_impl(vector_view_t& vec_view) + : vec_view_(vec_view) + { + assert(vec_view_.size() > 0); + } + + void set_ref(value_ptr* ref) exprtk_override + { + vec_view_.set_ref(ref); + } + + void remove_ref(value_ptr* ref) exprtk_override + { + vec_view_.remove_ref(ref); + } + + bool rebaseable() const exprtk_override + { + return true; + } + + vector_view<Type>* rebaseable_instance() exprtk_override + { + return &vec_view_; + } + + protected: + + value_ptr value_at(const std::size_t& index) const exprtk_override + { + assert(index < vec_view_.size()); + return (&vec_view_[index]); + } + + std::size_t vector_size() const exprtk_override + { + return vec_view_.size(); + } + + std::size_t vector_base_size() const exprtk_override + { + return vec_view_.base_size(); + } + + private: + + vector_view_impl(const vector_view_impl&) exprtk_delete; + vector_view_impl& operator=(const vector_view_impl&) exprtk_delete; + + vector_view_t& vec_view_; + }; + + class resizable_vector_impl exprtk_final : public vector_holder_base + { + public: + + resizable_vector_impl(vector_holder& vec_view_holder, + const Type* vec, + const std::size_t& vec_size) + : vec_(vec) + , size_(vec_size) + , vec_view_holder_(*vec_view_holder.rebaseable_instance()) + { + assert(vec_view_holder.rebaseable_instance()); + assert(size_ <= vector_base_size()); + } + + virtual ~resizable_vector_impl() exprtk_override + {} + + protected: + + value_ptr value_at(const std::size_t& index) const exprtk_override + { + assert(index < vector_size()); + return const_cast<const_value_ptr>(vec_ + index); + } + + std::size_t vector_size() const exprtk_override + { + return vec_view_holder_.size(); + } + + std::size_t vector_base_size() const exprtk_override + { + return vec_view_holder_.base_size(); + } + + bool rebaseable() const exprtk_override + { + return true; + } + + virtual vector_view<Type>* rebaseable_instance() exprtk_override + { + return &vec_view_holder_; + } + + private: + + resizable_vector_impl(const resizable_vector_impl&) exprtk_delete; + resizable_vector_impl& operator=(const resizable_vector_impl&) exprtk_delete; + + const Type* vec_; + const std::size_t size_; + vector_view<Type>& vec_view_holder_; + }; + + public: + + typedef typename details::vec_data_store<Type> vds_t; + + vector_holder(Type* vec, const std::size_t& vec_size) + : vector_holder_base_(new(buffer)array_vector_impl(vec,vec_size)) + {} + + explicit vector_holder(const vds_t& vds) + : vector_holder_base_(new(buffer)array_vector_impl(vds.data(),vds.size())) + {} + + template <typename Allocator> + explicit vector_holder(std::vector<Type,Allocator>& vec) + : vector_holder_base_(new(buffer)sequence_vector_impl<Allocator,std::vector>(vec)) + {} + + explicit vector_holder(exprtk::vector_view<Type>& vec) + : vector_holder_base_(new(buffer)vector_view_impl(vec)) + {} + + explicit vector_holder(vector_holder_t& vec_holder, const vds_t& vds) + : vector_holder_base_(new(buffer)resizable_vector_impl(vec_holder, vds.data(), vds.size())) + {} + + inline value_ptr operator[](const std::size_t& index) const + { + return (*vector_holder_base_)[index]; + } + + inline std::size_t size() const + { + return vector_holder_base_->size(); + } + + inline std::size_t base_size() const + { + return vector_holder_base_->base_size(); + } + + inline value_ptr data() const + { + return vector_holder_base_->data(); + } + + void set_ref(value_ptr* ref) + { + if (rebaseable()) + { + vector_holder_base_->set_ref(ref); + } + } + + void remove_ref(value_ptr* ref) + { + if (rebaseable()) + { + vector_holder_base_->remove_ref(ref); + } + } + + bool rebaseable() const + { + return vector_holder_base_->rebaseable(); + } + + vector_view<Type>* rebaseable_instance() + { + return vector_holder_base_->rebaseable_instance(); + } + + private: + + vector_holder(const vector_holder<Type>&) exprtk_delete; + vector_holder<Type>& operator=(const vector_holder<Type>&) exprtk_delete; + + mutable vector_holder_base* vector_holder_base_; + uchar_t buffer[64]; + }; + + template <typename T> + class null_node exprtk_final : public expression_node<T> + { + public: + + inline T value() const exprtk_override + { + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_null; + } + }; + + template <typename T, std::size_t N> + inline void construct_branch_pair(std::pair<expression_node<T>*,bool> (&branch)[N], + expression_node<T>* b, + const std::size_t& index) + { + if (b && (index < N)) + { + branch[index] = std::make_pair(b,branch_deletable(b)); + } + } + + template <typename T> + inline void construct_branch_pair(std::pair<expression_node<T>*,bool>& branch, expression_node<T>* b) + { + if (b) + { + branch = std::make_pair(b,branch_deletable(b)); + } + } + + template <std::size_t N, typename T> + inline void init_branches(std::pair<expression_node<T>*,bool> (&branch)[N], + expression_node<T>* b0, + expression_node<T>* b1 = reinterpret_cast<expression_node<T>*>(0), + expression_node<T>* b2 = reinterpret_cast<expression_node<T>*>(0), + expression_node<T>* b3 = reinterpret_cast<expression_node<T>*>(0), + expression_node<T>* b4 = reinterpret_cast<expression_node<T>*>(0), + expression_node<T>* b5 = reinterpret_cast<expression_node<T>*>(0), + expression_node<T>* b6 = reinterpret_cast<expression_node<T>*>(0), + expression_node<T>* b7 = reinterpret_cast<expression_node<T>*>(0), + expression_node<T>* b8 = reinterpret_cast<expression_node<T>*>(0), + expression_node<T>* b9 = reinterpret_cast<expression_node<T>*>(0)) + { + construct_branch_pair(branch, b0, 0); + construct_branch_pair(branch, b1, 1); + construct_branch_pair(branch, b2, 2); + construct_branch_pair(branch, b3, 3); + construct_branch_pair(branch, b4, 4); + construct_branch_pair(branch, b5, 5); + construct_branch_pair(branch, b6, 6); + construct_branch_pair(branch, b7, 7); + construct_branch_pair(branch, b8, 8); + construct_branch_pair(branch, b9, 9); + } + + template <typename T> + class null_eq_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + explicit null_eq_node(expression_ptr branch, const bool equality = true) + : equality_(equality) + { + construct_branch_pair(branch_, branch); + assert(valid()); + } + + inline T value() const exprtk_override + { + const T v = branch_.first->value(); + const bool result = details::numeric::is_nan(v); + + if (result) + return equality_ ? T(1) : T(0); + else + return equality_ ? T(0) : T(1); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_nulleq; + } + + inline expression_node<T>* branch(const std::size_t&) const exprtk_override + { + return branch_.first; + } + + inline bool valid() const exprtk_override + { + return branch_.first; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + bool equality_; + branch_t branch_; + }; + + template <typename T> + class literal_node exprtk_final : public expression_node<T> + { + public: + + explicit literal_node(const T& v) + : value_(v) + {} + + inline T value() const exprtk_override + { + return value_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_constant; + } + + inline expression_node<T>* branch(const std::size_t&) const exprtk_override + { + return reinterpret_cast<expression_node<T>*>(0); + } + + private: + + literal_node(const literal_node<T>&) exprtk_delete; + literal_node<T>& operator=(const literal_node<T>&) exprtk_delete; + + const T value_; + }; + + template <typename T> + struct range_pack; + + template <typename T> + struct range_data_type; + + template <typename T> + class range_interface + { + public: + + typedef range_pack<T> range_t; + + virtual ~range_interface() + {} + + virtual range_t& range_ref() = 0; + + virtual const range_t& range_ref() const = 0; + }; + + #ifndef exprtk_disable_string_capabilities + template <typename T> + class string_base_node + { + public: + + typedef range_data_type<T> range_data_type_t; + + virtual ~string_base_node() + {} + + virtual std::string str () const = 0; + + virtual char_cptr base() const = 0; + + virtual std::size_t size() const = 0; + }; + + template <typename T> + class string_literal_node exprtk_final + : public expression_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef range_pack<T> range_t; + + explicit string_literal_node(const std::string& v) + : value_(v) + { + rp_.n0_c = std::make_pair<bool,std::size_t>(true, 0); + rp_.n1_c = std::make_pair<bool,std::size_t>(true, v.size()); + rp_.cache.first = rp_.n0_c.second; + rp_.cache.second = rp_.n1_c.second; + } + + inline T value() const exprtk_override + { + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_stringconst; + } + + inline expression_node<T>* branch(const std::size_t&) const exprtk_override + { + return reinterpret_cast<expression_node<T>*>(0); + } + + std::string str() const exprtk_override + { + return value_; + } + + char_cptr base() const exprtk_override + { + return value_.data(); + } + + std::size_t size() const exprtk_override + { + return value_.size(); + } + + range_t& range_ref() exprtk_override + { + return rp_; + } + + const range_t& range_ref() const exprtk_override + { + return rp_; + } + + private: + + string_literal_node(const string_literal_node<T>&) exprtk_delete; + string_literal_node<T>& operator=(const string_literal_node<T>&) exprtk_delete; + + const std::string value_; + range_t rp_; + }; + #endif + + template <typename T> + class unary_node : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + unary_node(const operator_type& opr, expression_ptr branch) + : operation_(opr) + { + construct_branch_pair(branch_,branch); + assert(valid()); + } + + inline T value() const exprtk_override + { + return numeric::process<T> + (operation_,branch_.first->value()); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_unary; + } + + inline operator_type operation() + { + return operation_; + } + + inline expression_node<T>* branch(const std::size_t&) const exprtk_override + { + return branch_.first; + } + + inline bool valid() const exprtk_override + { + return branch_.first && branch_.first->valid(); + } + + inline void release() + { + branch_.second = false; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_final + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + operator_type operation_; + branch_t branch_; + }; + + template <typename T> + class binary_node : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + binary_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : operation_(opr) + { + init_branches<2>(branch_, branch0, branch1); + assert(valid()); + } + + inline T value() const exprtk_override + { + return numeric::process<T> + ( + operation_, + branch_[0].first->value(), + branch_[1].first->value() + ); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_binary; + } + + inline operator_type operation() + { + return operation_; + } + + inline expression_node<T>* branch(const std::size_t& index = 0) const exprtk_override + { + assert(index < 2); + return branch_[index].first; + } + + inline bool valid() const exprtk_override + { + return + branch_[0].first && branch_[0].first->valid() && + branch_[1].first && branch_[1].first->valid() ; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_final + { + return expression_node<T>::ndb_t::template compute_node_depth<2>(branch_); + } + + private: + + operator_type operation_; + branch_t branch_[2]; + }; + + template <typename T, typename Operation> + class binary_ext_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + binary_ext_node(expression_ptr branch0, expression_ptr branch1) + { + init_branches<2>(branch_, branch0, branch1); + assert(valid()); + } + + inline T value() const exprtk_override + { + const T arg0 = branch_[0].first->value(); + const T arg1 = branch_[1].first->value(); + return Operation::process(arg0,arg1); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_binary_ext; + } + + inline operator_type operation() + { + return Operation::operation(); + } + + inline expression_node<T>* branch(const std::size_t& index = 0) const exprtk_override + { + assert(index < 2); + return branch_[index].first; + } + + inline bool valid() const exprtk_override + { + return + branch_[0].first && branch_[0].first->valid() && + branch_[1].first && branch_[1].first->valid() ; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::template compute_node_depth<2>(branch_); + } + + protected: + + branch_t branch_[2]; + }; + + template <typename T> + class trinary_node : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + trinary_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1, + expression_ptr branch2) + : operation_(opr) + { + init_branches<3>(branch_, branch0, branch1, branch2); + assert(valid()); + } + + inline T value() const exprtk_override + { + const T arg0 = branch_[0].first->value(); + const T arg1 = branch_[1].first->value(); + const T arg2 = branch_[2].first->value(); + + switch (operation_) + { + case e_inrange : return (arg1 < arg0) ? T(0) : ((arg1 > arg2) ? T(0) : T(1)); + + case e_clamp : return (arg1 < arg0) ? arg0 : (arg1 > arg2 ? arg2 : arg1); + + case e_iclamp : if ((arg1 <= arg0) || (arg1 >= arg2)) + return arg1; + else + return ((T(2) * arg1 <= (arg2 + arg0)) ? arg0 : arg2); + + default : exprtk_debug(("trinary_node::value() - Error: Invalid operation\n")); + return std::numeric_limits<T>::quiet_NaN(); + } + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_trinary; + } + + inline bool valid() const exprtk_override + { + return + branch_[0].first && branch_[0].first->valid() && + branch_[1].first && branch_[1].first->valid() && + branch_[2].first && branch_[2].first->valid() ; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override exprtk_final + { + return expression_node<T>::ndb_t::template compute_node_depth<3>(branch_); + } + + protected: + + operator_type operation_; + branch_t branch_[3]; + }; + + template <typename T> + class quaternary_node : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + quaternary_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1, + expression_ptr branch2, + expression_ptr branch3) + : operation_(opr) + { + init_branches<4>(branch_, branch0, branch1, branch2, branch3); + } + + inline T value() const exprtk_override + { + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_quaternary; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override exprtk_final + { + return expression_node<T>::ndb_t::template compute_node_depth<4>(branch_); + } + + inline bool valid() const exprtk_override + { + return + branch_[0].first && branch_[0].first->valid() && + branch_[1].first && branch_[1].first->valid() && + branch_[2].first && branch_[2].first->valid() && + branch_[3].first && branch_[3].first->valid() ; + } + + protected: + + operator_type operation_; + branch_t branch_[4]; + }; + + template <typename T> + class conditional_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + conditional_node(expression_ptr condition, + expression_ptr consequent, + expression_ptr alternative) + { + construct_branch_pair(condition_ , condition ); + construct_branch_pair(consequent_ , consequent ); + construct_branch_pair(alternative_, alternative); + assert(valid()); + } + + inline T value() const exprtk_override + { + if (is_true(condition_)) + return consequent_.first->value(); + else + return alternative_.first->value(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_conditional; + } + + inline bool valid() const exprtk_override + { + return + condition_ .first && condition_ .first->valid() && + consequent_ .first && consequent_ .first->valid() && + alternative_.first && alternative_.first->valid() ; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(condition_ , node_delete_list); + expression_node<T>::ndb_t::collect(consequent_ , node_delete_list); + expression_node<T>::ndb_t::collect(alternative_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth + (condition_, consequent_, alternative_); + } + + private: + + branch_t condition_; + branch_t consequent_; + branch_t alternative_; + }; + + template <typename T> + class cons_conditional_node exprtk_final : public expression_node<T> + { + public: + + // Consequent only conditional statement node + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + cons_conditional_node(expression_ptr condition, + expression_ptr consequent) + { + construct_branch_pair(condition_ , condition ); + construct_branch_pair(consequent_, consequent); + assert(valid()); + } + + inline T value() const exprtk_override + { + if (is_true(condition_)) + return consequent_.first->value(); + else + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_conditional; + } + + inline bool valid() const exprtk_override + { + return + condition_ .first && condition_ .first->valid() && + consequent_.first && consequent_.first->valid() ; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(condition_ , node_delete_list); + expression_node<T>::ndb_t::collect(consequent_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t:: + compute_node_depth(condition_, consequent_); + } + + private: + + branch_t condition_; + branch_t consequent_; + }; + + #ifndef exprtk_disable_break_continue + template <typename T> + class break_exception + { + public: + + explicit break_exception(const T& v) + : value(v) + {} + + T value; + }; + + class continue_exception {}; + + template <typename T> + class break_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + explicit break_node(expression_ptr ret = expression_ptr(0)) + { + construct_branch_pair(return_, ret); + } + + inline T value() const exprtk_override + { + const T result = return_.first ? + return_.first->value() : + std::numeric_limits<T>::quiet_NaN(); + + throw break_exception<T>(result); + + #if !defined(_MSC_VER) && !defined(__NVCOMPILER) + return std::numeric_limits<T>::quiet_NaN(); + #endif + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_break; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(return_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(return_); + } + + private: + + branch_t return_; + }; + + template <typename T> + class continue_node exprtk_final : public expression_node<T> + { + public: + + inline T value() const exprtk_override + { + throw continue_exception(); + #if !defined(_MSC_VER) && !defined(__NVCOMPILER) + return std::numeric_limits<T>::quiet_NaN(); + #endif + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_break; + } + }; + #endif + + struct loop_runtime_checker + { + loop_runtime_checker(loop_runtime_check_ptr loop_runtime_check, + loop_runtime_check::loop_types lp_typ = loop_runtime_check::e_invalid) + : iteration_count_(0) + , loop_runtime_check_(loop_runtime_check) + , max_loop_iterations_(loop_runtime_check_->max_loop_iterations) + , loop_type_(lp_typ) + { + assert(loop_runtime_check_); + } + + inline void reset(const _uint64_t initial_value = 0) const + { + iteration_count_ = initial_value; + } + + inline bool check() const + { + assert(loop_runtime_check_); + + if ( + (++iteration_count_ <= max_loop_iterations_) && + loop_runtime_check_->check() + ) + { + return true; + } + + loop_runtime_check::violation_context ctxt; + ctxt.loop = loop_type_; + ctxt.violation = loop_runtime_check::e_iteration_count; + + loop_runtime_check_->handle_runtime_violation(ctxt); + + return false; + } + + bool valid() const + { + return 0 != loop_runtime_check_; + } + + mutable _uint64_t iteration_count_; + mutable loop_runtime_check_ptr loop_runtime_check_; + const details::_uint64_t& max_loop_iterations_; + loop_runtime_check::loop_types loop_type_; + }; + + template <typename T> + class while_loop_node : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + while_loop_node(expression_ptr condition, + expression_ptr loop_body) + { + construct_branch_pair(condition_, condition); + construct_branch_pair(loop_body_, loop_body); + assert(valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + while (is_true(condition_)) + { + result = loop_body_.first->value(); + } + + return result; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_while; + } + + inline bool valid() const exprtk_override + { + return + condition_.first && condition_.first->valid() && + loop_body_.first && loop_body_.first->valid() ; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(condition_ , node_delete_list); + expression_node<T>::ndb_t::collect(loop_body_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(condition_, loop_body_); + } + + protected: + + branch_t condition_; + branch_t loop_body_; + }; + + template <typename T> + class while_loop_rtc_node exprtk_final + : public while_loop_node<T> + , public loop_runtime_checker + { + public: + + typedef while_loop_node<T> parent_t; + typedef expression_node<T>* expression_ptr; + + while_loop_rtc_node(expression_ptr condition, + expression_ptr loop_body, + loop_runtime_check_ptr loop_rt_chk) + : parent_t(condition, loop_body) + , loop_runtime_checker(loop_rt_chk, loop_runtime_check::e_while_loop) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + + T result = T(0); + + loop_runtime_checker::reset(); + + while (is_true(parent_t::condition_) && loop_runtime_checker::check()) + { + result = parent_t::loop_body_.first->value(); + } + + return result; + } + + using parent_t::valid; + + bool valid() const exprtk_override exprtk_final + { + return parent_t::valid() && + loop_runtime_checker::valid(); + } + }; + + template <typename T> + class repeat_until_loop_node : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + repeat_until_loop_node(expression_ptr condition, + expression_ptr loop_body) + { + construct_branch_pair(condition_, condition); + construct_branch_pair(loop_body_, loop_body); + assert(valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + do + { + result = loop_body_.first->value(); + } + while (is_false(condition_.first)); + + return result; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_repeat; + } + + inline bool valid() const exprtk_override + { + return + condition_.first && condition_.first->valid() && + loop_body_.first && loop_body_.first->valid() ; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(condition_ , node_delete_list); + expression_node<T>::ndb_t::collect(loop_body_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(condition_, loop_body_); + } + + protected: + + branch_t condition_; + branch_t loop_body_; + }; + + template <typename T> + class repeat_until_loop_rtc_node exprtk_final + : public repeat_until_loop_node<T> + , public loop_runtime_checker + { + public: + + typedef repeat_until_loop_node<T> parent_t; + typedef expression_node<T>* expression_ptr; + + repeat_until_loop_rtc_node(expression_ptr condition, + expression_ptr loop_body, + loop_runtime_check_ptr loop_rt_chk) + : parent_t(condition, loop_body) + , loop_runtime_checker(loop_rt_chk, loop_runtime_check::e_repeat_until_loop) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + loop_runtime_checker::reset(1); + + do + { + result = parent_t::loop_body_.first->value(); + } + while (is_false(parent_t::condition_.first) && loop_runtime_checker::check()); + + return result; + } + + using parent_t::valid; + + inline bool valid() const exprtk_override exprtk_final + { + return parent_t::valid() && + loop_runtime_checker::valid(); + } + }; + + template <typename T> + class for_loop_node : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + for_loop_node(expression_ptr initialiser, + expression_ptr condition, + expression_ptr incrementor, + expression_ptr loop_body) + { + construct_branch_pair(initialiser_, initialiser); + construct_branch_pair(condition_ , condition ); + construct_branch_pair(incrementor_, incrementor); + construct_branch_pair(loop_body_ , loop_body ); + assert(valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + if (initialiser_.first) + initialiser_.first->value(); + + if (incrementor_.first) + { + while (is_true(condition_)) + { + result = loop_body_.first->value(); + incrementor_.first->value(); + } + } + else + { + while (is_true(condition_)) + { + result = loop_body_.first->value(); + } + } + + return result; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_for; + } + + inline bool valid() const exprtk_override + { + return condition_.first && loop_body_.first; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(initialiser_ , node_delete_list); + expression_node<T>::ndb_t::collect(condition_ , node_delete_list); + expression_node<T>::ndb_t::collect(incrementor_ , node_delete_list); + expression_node<T>::ndb_t::collect(loop_body_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth + (initialiser_, condition_, incrementor_, loop_body_); + } + + protected: + + branch_t initialiser_; + branch_t condition_ ; + branch_t incrementor_; + branch_t loop_body_ ; + }; + + template <typename T> + class for_loop_rtc_node exprtk_final + : public for_loop_node<T> + , public loop_runtime_checker + { + public: + + typedef for_loop_node<T> parent_t; + typedef expression_node<T>* expression_ptr; + + for_loop_rtc_node(expression_ptr initialiser, + expression_ptr condition, + expression_ptr incrementor, + expression_ptr loop_body, + loop_runtime_check_ptr loop_rt_chk) + : parent_t(initialiser, condition, incrementor, loop_body) + , loop_runtime_checker(loop_rt_chk, loop_runtime_check::e_for_loop) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + loop_runtime_checker::reset(); + + if (parent_t::initialiser_.first) + parent_t::initialiser_.first->value(); + + if (parent_t::incrementor_.first) + { + while (is_true(parent_t::condition_) && loop_runtime_checker::check()) + { + result = parent_t::loop_body_.first->value(); + parent_t::incrementor_.first->value(); + } + } + else + { + while (is_true(parent_t::condition_) && loop_runtime_checker::check()) + { + result = parent_t::loop_body_.first->value(); + } + } + + return result; + } + + using parent_t::valid; + + inline bool valid() const exprtk_override exprtk_final + { + return parent_t::valid() && + loop_runtime_checker::valid(); + } + }; + + #ifndef exprtk_disable_break_continue + template <typename T> + class while_loop_bc_node : public while_loop_node<T> + { + public: + + typedef while_loop_node<T> parent_t; + typedef expression_node<T>* expression_ptr; + + while_loop_bc_node(expression_ptr condition, + expression_ptr loop_body) + : parent_t(condition, loop_body) + { + assert(parent_t::valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + while (is_true(parent_t::condition_)) + { + try + { + result = parent_t::loop_body_.first->value(); + } + catch(const break_exception<T>& e) + { + return e.value; + } + catch(const continue_exception&) + {} + } + + return result; + } + }; + + template <typename T> + class while_loop_bc_rtc_node exprtk_final + : public while_loop_bc_node<T> + , public loop_runtime_checker + { + public: + + typedef while_loop_bc_node<T> parent_t; + typedef expression_node<T>* expression_ptr; + + while_loop_bc_rtc_node(expression_ptr condition, + expression_ptr loop_body, + loop_runtime_check_ptr loop_rt_chk) + : parent_t(condition, loop_body) + , loop_runtime_checker(loop_rt_chk, loop_runtime_check::e_while_loop) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + loop_runtime_checker::reset(); + + while (is_true(parent_t::condition_) && loop_runtime_checker::check()) + { + try + { + result = parent_t::loop_body_.first->value(); + } + catch(const break_exception<T>& e) + { + return e.value; + } + catch(const continue_exception&) + {} + } + + return result; + } + + using parent_t::valid; + + inline bool valid() const exprtk_override exprtk_final + { + return parent_t::valid() && + loop_runtime_checker::valid(); + } + }; + + template <typename T> + class repeat_until_loop_bc_node : public repeat_until_loop_node<T> + { + public: + + typedef repeat_until_loop_node<T> parent_t; + typedef expression_node<T>* expression_ptr; + + repeat_until_loop_bc_node(expression_ptr condition, + expression_ptr loop_body) + : parent_t(condition, loop_body) + { + assert(parent_t::valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + do + { + try + { + result = parent_t::loop_body_.first->value(); + } + catch(const break_exception<T>& e) + { + return e.value; + } + catch(const continue_exception&) + {} + } + while (is_false(parent_t::condition_.first)); + + return result; + } + }; + + template <typename T> + class repeat_until_loop_bc_rtc_node exprtk_final + : public repeat_until_loop_bc_node<T> + , public loop_runtime_checker + { + public: + + typedef repeat_until_loop_bc_node<T> parent_t; + typedef expression_node<T>* expression_ptr; + + repeat_until_loop_bc_rtc_node(expression_ptr condition, + expression_ptr loop_body, + loop_runtime_check_ptr loop_rt_chk) + : parent_t(condition, loop_body) + , loop_runtime_checker(loop_rt_chk, loop_runtime_check::e_repeat_until_loop) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + loop_runtime_checker::reset(); + + do + { + try + { + result = parent_t::loop_body_.first->value(); + } + catch(const break_exception<T>& e) + { + return e.value; + } + catch(const continue_exception&) + {} + } + while (is_false(parent_t::condition_.first) && loop_runtime_checker::check()); + + return result; + } + + using parent_t::valid; + + inline bool valid() const exprtk_override exprtk_final + { + return parent_t::valid() && + loop_runtime_checker::valid(); + } + }; + + template <typename T> + class for_loop_bc_node : public for_loop_node<T> + { + public: + + typedef for_loop_node<T> parent_t; + typedef expression_node<T>* expression_ptr; + + for_loop_bc_node(expression_ptr initialiser, + expression_ptr condition, + expression_ptr incrementor, + expression_ptr loop_body) + : parent_t(initialiser, condition, incrementor, loop_body) + { + assert(parent_t::valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + if (parent_t::initialiser_.first) + parent_t::initialiser_.first->value(); + + if (parent_t::incrementor_.first) + { + while (is_true(parent_t::condition_)) + { + try + { + result = parent_t::loop_body_.first->value(); + } + catch(const break_exception<T>& e) + { + return e.value; + } + catch(const continue_exception&) + {} + + parent_t::incrementor_.first->value(); + } + } + else + { + while (is_true(parent_t::condition_)) + { + try + { + result = parent_t::loop_body_.first->value(); + } + catch(const break_exception<T>& e) + { + return e.value; + } + catch(const continue_exception&) + {} + } + } + + return result; + } + }; + + template <typename T> + class for_loop_bc_rtc_node exprtk_final + : public for_loop_bc_node<T> + , public loop_runtime_checker + { + public: + + typedef for_loop_bc_node<T> parent_t; + typedef expression_node<T>* expression_ptr; + + for_loop_bc_rtc_node(expression_ptr initialiser, + expression_ptr condition, + expression_ptr incrementor, + expression_ptr loop_body, + loop_runtime_check_ptr loop_rt_chk) + : parent_t(initialiser, condition, incrementor, loop_body) + , loop_runtime_checker(loop_rt_chk, loop_runtime_check::e_for_loop) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + T result = T(0); + + loop_runtime_checker::reset(); + + if (parent_t::initialiser_.first) + parent_t::initialiser_.first->value(); + + if (parent_t::incrementor_.first) + { + while (is_true(parent_t::condition_) && loop_runtime_checker::check()) + { + try + { + result = parent_t::loop_body_.first->value(); + } + catch(const break_exception<T>& e) + { + return e.value; + } + catch(const continue_exception&) + {} + + parent_t::incrementor_.first->value(); + } + } + else + { + while (is_true(parent_t::condition_) && loop_runtime_checker::check()) + { + try + { + result = parent_t::loop_body_.first->value(); + } + catch(const break_exception<T>& e) + { + return e.value; + } + catch(const continue_exception&) + {} + } + } + + return result; + } + + using parent_t::valid; + + inline bool valid() const exprtk_override exprtk_final + { + return parent_t::valid() && + loop_runtime_checker::valid(); + } + }; + #endif + + template <typename T> + class switch_node : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + template <typename Allocator, + template <typename, typename> class Sequence> + explicit switch_node(const Sequence<expression_ptr,Allocator>& arg_list) + { + if (1 != (arg_list.size() & 1)) + return; + + arg_list_.resize(arg_list.size()); + + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + if (arg_list[i] && arg_list[i]->valid()) + { + construct_branch_pair(arg_list_[i], arg_list[i]); + } + else + { + arg_list_.clear(); + return; + } + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + const std::size_t upper_bound = (arg_list_.size() - 1); + + for (std::size_t i = 0; i < upper_bound; i += 2) + { + expression_ptr condition = arg_list_[i ].first; + expression_ptr consequent = arg_list_[i + 1].first; + + if (is_true(condition)) + { + return consequent->value(); + } + } + + return arg_list_[upper_bound].first->value(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override exprtk_final + { + return expression_node<T>::e_switch; + } + + inline bool valid() const exprtk_override + { + return !arg_list_.empty(); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(arg_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override exprtk_final + { + return expression_node<T>::ndb_t::compute_node_depth(arg_list_); + } + + protected: + + std::vector<branch_t> arg_list_; + }; + + template <typename T, typename Switch_N> + class switch_n_node exprtk_final : public switch_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + template <typename Allocator, + template <typename, typename> class Sequence> + explicit switch_n_node(const Sequence<expression_ptr,Allocator>& arg_list) + : switch_node<T>(arg_list) + {} + + inline T value() const exprtk_override + { + return Switch_N::process(switch_node<T>::arg_list_); + } + }; + + template <typename T> + class multi_switch_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + template <typename Allocator, + template <typename, typename> class Sequence> + explicit multi_switch_node(const Sequence<expression_ptr,Allocator>& arg_list) + { + if (0 != (arg_list.size() & 1)) + return; + + arg_list_.resize(arg_list.size()); + + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + if (arg_list[i] && arg_list[i]->valid()) + { + construct_branch_pair(arg_list_[i], arg_list[i]); + } + else + { + arg_list_.clear(); + return; + } + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + const std::size_t upper_bound = (arg_list_.size() - 1); + + T result = T(0); + + for (std::size_t i = 0; i < upper_bound; i += 2) + { + expression_ptr condition = arg_list_[i ].first; + expression_ptr consequent = arg_list_[i + 1].first; + + if (is_true(condition)) + { + result = consequent->value(); + } + } + + return result; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_mswitch; + } + + inline bool valid() const exprtk_override + { + return !arg_list_.empty() && (0 == (arg_list_.size() % 2)); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(arg_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override exprtk_final + { + return expression_node<T>::ndb_t::compute_node_depth(arg_list_); + } + + private: + + std::vector<branch_t> arg_list_; + }; + + template <typename T> + class ivariable + { + public: + + virtual ~ivariable() + {} + + virtual T& ref() = 0; + virtual const T& ref() const = 0; + }; + + template <typename T> + class variable_node exprtk_final + : public expression_node<T> + , public ivariable <T> + { + public: + + static T null_value; + + explicit variable_node() + : value_(&null_value) + {} + + explicit variable_node(T& v) + : value_(&v) + {} + + inline bool operator <(const variable_node<T>& v) const + { + return this < (&v); + } + + inline T value() const exprtk_override + { + return (*value_); + } + + inline T& ref() exprtk_override + { + return (*value_); + } + + inline const T& ref() const exprtk_override + { + return (*value_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_variable; + } + + private: + + T* value_; + }; + + template <typename T> + T variable_node<T>::null_value = T(std::numeric_limits<T>::quiet_NaN()); + + template <typename T> + struct range_pack + { + typedef expression_node<T>* expression_node_ptr; + typedef std::pair<std::size_t,std::size_t> cached_range_t; + + range_pack() + : n0_e (std::make_pair(false,expression_node_ptr(0))) + , n1_e (std::make_pair(false,expression_node_ptr(0))) + , n0_c (std::make_pair(false,0)) + , n1_c (std::make_pair(false,0)) + , cache(std::make_pair(0,0)) + {} + + void clear() + { + n0_e = std::make_pair(false,expression_node_ptr(0)); + n1_e = std::make_pair(false,expression_node_ptr(0)); + n0_c = std::make_pair(false,0); + n1_c = std::make_pair(false,0); + cache = std::make_pair(0,0); + } + + void free() + { + if (n0_e.first && n0_e.second) + { + n0_e.first = false; + + if ( + !is_variable_node(n0_e.second) && + !is_string_node (n0_e.second) + ) + { + destroy_node(n0_e.second); + } + } + + if (n1_e.first && n1_e.second) + { + n1_e.first = false; + + if ( + !is_variable_node(n1_e.second) && + !is_string_node (n1_e.second) + ) + { + destroy_node(n1_e.second); + } + } + } + + bool const_range() const + { + return ( n0_c.first && n1_c.first) && + (!n0_e.first && !n1_e.first); + } + + bool var_range() const + { + return ( n0_e.first && n1_e.first) && + (!n0_c.first && !n1_c.first); + } + + bool operator() (std::size_t& r0, std::size_t& r1, + const std::size_t& size = std::numeric_limits<std::size_t>::max()) const + { + if (n0_c.first) + r0 = n0_c.second; + else if (n0_e.first) + { + r0 = static_cast<std::size_t>(details::numeric::to_int64(n0_e.second->value())); + } + else + return false; + + if (n1_c.first) + r1 = n1_c.second; + else if (n1_e.first) + { + r1 = static_cast<std::size_t>(details::numeric::to_int64(n1_e.second->value())); + } + else + return false; + + if ( + (std::numeric_limits<std::size_t>::max() != size) && + (std::numeric_limits<std::size_t>::max() == r1 ) + ) + { + r1 = size; + } + + cache.first = r0; + cache.second = r1; + + #ifndef exprtk_enable_range_runtime_checks + return (r0 <= r1); + #else + return range_runtime_check(r0, r1, size); + #endif + } + + inline std::size_t const_size() const + { + return (n1_c.second - n0_c.second); + } + + inline std::size_t cache_size() const + { + return (cache.second - cache.first); + } + + std::pair<bool,expression_node_ptr> n0_e; + std::pair<bool,expression_node_ptr> n1_e; + std::pair<bool,std::size_t > n0_c; + std::pair<bool,std::size_t > n1_c; + mutable cached_range_t cache; + + #ifdef exprtk_enable_range_runtime_checks + bool range_runtime_check(const std::size_t r0, + const std::size_t r1, + const std::size_t size) const + { + if (r0 > size) + { + throw std::runtime_error("range error: (r0 < 0) || (r0 > size)"); + #if !defined(_MSC_VER) && !defined(__NVCOMPILER) + return false; + #endif + } + + if (r1 > size) + { + throw std::runtime_error("range error: (r1 < 0) || (r1 > size)"); + #if !defined(_MSC_VER) && !defined(__NVCOMPILER) + return false; + #endif + } + + return (r0 <= r1); + } + #endif + }; + + template <typename T> + class string_base_node; + + template <typename T> + struct range_data_type + { + typedef range_pack<T> range_t; + typedef string_base_node<T>* strbase_ptr_t; + + range_data_type() + : range(0) + , data (0) + , size (0) + , type_size(0) + , str_node (0) + {} + + range_t* range; + void* data; + std::size_t size; + std::size_t type_size; + strbase_ptr_t str_node; + }; + + template <typename T> class vector_node; + + template <typename T> + class vector_interface + { + public: + + typedef vector_node<T>* vector_node_ptr; + typedef vec_data_store<T> vds_t; + + virtual ~vector_interface() + {} + + virtual std::size_t size () const = 0; + + virtual std::size_t base_size() const = 0; + + virtual vector_node_ptr vec () const = 0; + + virtual vector_node_ptr vec () = 0; + + virtual vds_t& vds () = 0; + + virtual const vds_t& vds () const = 0; + + virtual bool side_effect () const { return false; } + }; + + template <typename T> + class vector_node exprtk_final + : public expression_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_node<T>* vector_node_ptr; + typedef vec_data_store<T> vds_t; + + explicit vector_node(vector_holder_t* vh) + : vector_holder_(vh) + , vds_((*vector_holder_).size(),(*vector_holder_)[0]) + { + vector_holder_->set_ref(&vds_.ref()); + } + + vector_node(const vds_t& vds, vector_holder_t* vh) + : vector_holder_(vh) + , vds_(vds) + {} + + ~vector_node() exprtk_override + { + assert(valid()); + vector_holder_->remove_ref(&vds_.ref()); + } + + inline T value() const exprtk_override + { + return vds().data()[0]; + } + + vector_node_ptr vec() const exprtk_override + { + return const_cast<vector_node_ptr>(this); + } + + vector_node_ptr vec() exprtk_override + { + return this; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vector; + } + + inline bool valid() const exprtk_override + { + return vector_holder_; + } + + std::size_t size() const exprtk_override + { + return vec_holder().size(); + } + + std::size_t base_size() const exprtk_override + { + return vec_holder().base_size(); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + inline vector_holder_t& vec_holder() + { + return (*vector_holder_); + } + + inline vector_holder_t& vec_holder() const + { + return (*vector_holder_); + } + + private: + + vector_holder_t* vector_holder_; + vds_t vds_; + }; + + template <typename T> + class vector_size_node exprtk_final + : public expression_node <T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + + explicit vector_size_node(vector_holder_t* vh) + : vector_holder_(vh) + {} + + ~vector_size_node() exprtk_override + { + assert(valid()); + } + + inline T value() const exprtk_override + { + assert(vector_holder_); + return static_cast<T>(vector_holder_->size()); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecsize; + } + + inline bool valid() const exprtk_override + { + return vector_holder_ && vector_holder_->size(); + } + + inline vector_holder_t* vec_holder() + { + return vector_holder_; + } + + private: + + vector_holder_t* vector_holder_; + }; + + template <typename T> + class vector_elem_node exprtk_final + : public expression_node<T> + , public ivariable <T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + vector_elem_node(expression_ptr vec_node, + expression_ptr index, + vector_holder_ptr vec_holder) + : vector_holder_(vec_holder) + , vector_base_((*vec_holder)[0]) + { + construct_branch_pair(vector_node_, vec_node); + construct_branch_pair(index_ , index ); + assert(valid()); + } + + inline T value() const exprtk_override + { + return *access_vector(); + } + + inline T& ref() exprtk_override + { + return *access_vector(); + } + + inline const T& ref() const exprtk_override + { + return *access_vector(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecelem; + } + + inline bool valid() const exprtk_override + { + return + vector_holder_ && + index_.first && + vector_node_.first && + index_.first->valid() && + vector_node_.first->valid(); + } + + inline vector_holder_t& vec_holder() + { + return (*vector_holder_); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(vector_node_, node_delete_list); + expression_node<T>::ndb_t::collect(index_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth + (vector_node_, index_); + } + + private: + + inline T* access_vector() const + { + vector_node_.first->value(); + return (vector_base_ + details::numeric::to_uint64(index_.first->value())); + } + + vector_holder_ptr vector_holder_; + T* vector_base_; + branch_t vector_node_; + branch_t index_; + }; + + template <typename T> + class vector_celem_node exprtk_final + : public expression_node<T> + , public ivariable <T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + vector_celem_node(expression_ptr vec_node, + const std::size_t index, + vector_holder_ptr vec_holder) + : index_(index) + , vector_holder_(vec_holder) + , vector_base_((*vec_holder)[0]) + { + construct_branch_pair(vector_node_, vec_node); + assert(valid()); + } + + inline T value() const exprtk_override + { + return *access_vector(); + } + + inline T& ref() exprtk_override + { + return *access_vector(); + } + + inline const T& ref() const exprtk_override + { + return *access_vector(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_veccelem; + } + + inline bool valid() const exprtk_override + { + return + vector_holder_ && + vector_node_.first && + vector_node_.first->valid(); + } + + inline vector_holder_t& vec_holder() + { + return (*vector_holder_); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(vector_node_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(vector_node_); + } + + private: + + inline T* access_vector() const + { + vector_node_.first->value(); + return (vector_base_ + index_); + } + + const std::size_t index_; + vector_holder_ptr vector_holder_; + T* vector_base_; + branch_t vector_node_; + }; + + template <typename T> + class vector_elem_rtc_node exprtk_final + : public expression_node<T> + , public ivariable <T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + vector_elem_rtc_node(expression_ptr vec_node, + expression_ptr index, + vector_holder_ptr vec_holder, + vector_access_runtime_check_ptr vec_rt_chk) + : vector_holder_(vec_holder) + , vector_base_((*vec_holder)[0]) + , vec_rt_chk_(vec_rt_chk) + , max_vector_index_(vector_holder_->size() - 1) + { + construct_branch_pair(vector_node_, vec_node); + construct_branch_pair(index_ , index ); + assert(valid()); + } + + inline T value() const exprtk_override + { + return *access_vector(); + } + + inline T& ref() exprtk_override + { + return *access_vector(); + } + + inline const T& ref() const exprtk_override + { + return *access_vector(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecelemrtc; + } + + inline bool valid() const exprtk_override + { + return + vector_holder_ && + index_.first && + vector_node_.first && + index_.first->valid() && + vector_node_.first->valid(); + } + + inline vector_holder_t& vec_holder() + { + return (*vector_holder_); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(vector_node_, node_delete_list); + expression_node<T>::ndb_t::collect(index_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth + (vector_node_, index_); + } + + private: + + inline T* access_vector() const + { + const _uint64_t index = details::numeric::to_uint64(index_.first->value()); + vector_node_.first->value(); + + if (index <= max_vector_index_) + { + return (vector_holder_->data() + index); + } + + assert(vec_rt_chk_); + + vector_access_runtime_check::violation_context context; + context.base_ptr = reinterpret_cast<void*>(vector_base_); + context.end_ptr = reinterpret_cast<void*>(vector_base_ + vector_holder_->size()); + context.access_ptr = reinterpret_cast<void*>(vector_base_ + index); + context.type_size = sizeof(T); + + return vec_rt_chk_->handle_runtime_violation(context) ? + reinterpret_cast<T*>(context.access_ptr) : + vector_base_ ; + } + + vector_holder_ptr vector_holder_; + T* vector_base_; + branch_t vector_node_; + branch_t index_; + vector_access_runtime_check_ptr vec_rt_chk_; + const std::size_t max_vector_index_; + }; + + template <typename T> + class vector_celem_rtc_node exprtk_final + : public expression_node<T> + , public ivariable <T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + vector_celem_rtc_node(expression_ptr vec_node, + const std::size_t index, + vector_holder_ptr vec_holder, + vector_access_runtime_check_ptr vec_rt_chk) + : index_(index) + , max_vector_index_(vec_holder->size() - 1) + , vector_holder_(vec_holder) + , vector_base_((*vec_holder)[0]) + , vec_rt_chk_(vec_rt_chk) + { + construct_branch_pair(vector_node_, vec_node); + assert(valid()); + } + + inline T value() const exprtk_override + { + return *access_vector(); + } + + inline T& ref() exprtk_override + { + return *access_vector(); + } + + inline const T& ref() const exprtk_override + { + return *access_vector(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_veccelemrtc; + } + + inline bool valid() const exprtk_override + { + return + vector_holder_ && + vector_node_.first && + vector_node_.first->valid(); + } + + inline vector_holder_t& vec_holder() + { + return (*vector_holder_); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(vector_node_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(vector_node_); + } + + private: + + inline T* access_vector() const + { + vector_node_.first->value(); + + if (index_ <= max_vector_index_) + { + return (vector_holder_->data() + index_); + } + + assert(vec_rt_chk_); + + vector_access_runtime_check::violation_context context; + context.base_ptr = reinterpret_cast<void*>(vector_base_); + context.end_ptr = reinterpret_cast<void*>(vector_base_ + vector_holder_->size()); + context.access_ptr = reinterpret_cast<void*>(vector_base_ + index_); + context.type_size = sizeof(T); + + return vec_rt_chk_->handle_runtime_violation(context) ? + reinterpret_cast<T*>(context.access_ptr) : + vector_base_ ; + } + + const std::size_t index_; + const std::size_t max_vector_index_; + vector_holder_ptr vector_holder_; + T* vector_base_; + branch_t vector_node_; + vector_access_runtime_check_ptr vec_rt_chk_; + }; + + template <typename T> + class rebasevector_elem_node exprtk_final + : public expression_node<T> + , public ivariable <T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef vec_data_store<T> vds_t; + typedef std::pair<expression_ptr,bool> branch_t; + + rebasevector_elem_node(expression_ptr vec_node, + expression_ptr index, + vector_holder_ptr vec_holder) + : vector_holder_(vec_holder) + { + construct_branch_pair(vector_node_, vec_node); + construct_branch_pair(index_ , index ); + assert(valid()); + } + + inline T value() const exprtk_override + { + return *access_vector(); + } + + inline T& ref() exprtk_override + { + return *access_vector(); + } + + inline const T& ref() const exprtk_override + { + return *access_vector(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_rbvecelem; + } + + inline bool valid() const exprtk_override + { + return + vector_holder_ && + index_.first && + vector_node_.first && + index_.first->valid() && + vector_node_.first->valid(); + } + + inline vector_holder_t& vec_holder() + { + return (*vector_holder_); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(vector_node_, node_delete_list); + expression_node<T>::ndb_t::collect(index_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth + (vector_node_, index_); + } + + private: + + inline T* access_vector() const + { + vector_node_.first->value(); + return (vector_holder_->data() + details::numeric::to_uint64(index_.first->value())); + } + + vector_holder_ptr vector_holder_; + branch_t vector_node_; + branch_t index_; + }; + + template <typename T> + class rebasevector_celem_node exprtk_final + : public expression_node<T> + , public ivariable <T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + rebasevector_celem_node(expression_ptr vec_node, + const std::size_t index, + vector_holder_ptr vec_holder) + : index_(index) + , vector_holder_(vec_holder) + { + construct_branch_pair(vector_node_, vec_node); + assert(valid()); + } + + inline T value() const exprtk_override + { + vector_node_.first->value(); + return ref(); + } + + inline T& ref() exprtk_override + { + return *(vector_holder_->data() + index_); + } + + inline const T& ref() const exprtk_override + { + return *(vector_holder_->data() + index_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_rbveccelem; + } + + inline bool valid() const exprtk_override + { + return + vector_holder_ && + vector_node_.first && + vector_node_.first->valid(); + } + + inline vector_holder_t& vec_holder() + { + return (*vector_holder_); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(vector_node_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(vector_node_); + } + + private: + + const std::size_t index_; + vector_holder_ptr vector_holder_; + branch_t vector_node_; + }; + + template <typename T> + class rebasevector_elem_rtc_node exprtk_final + : public expression_node<T> + , public ivariable <T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + rebasevector_elem_rtc_node(expression_ptr vec_node, + expression_ptr index, + vector_holder_ptr vec_holder, + vector_access_runtime_check_ptr vec_rt_chk) + : vector_holder_(vec_holder) + , vec_rt_chk_(vec_rt_chk) + { + construct_branch_pair(vector_node_, vec_node); + construct_branch_pair(index_ , index ); + assert(valid()); + } + + inline T value() const exprtk_override + { + return *access_vector(); + } + + inline T& ref() exprtk_override + { + return *access_vector(); + } + + inline const T& ref() const exprtk_override + { + return *access_vector(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_rbvecelemrtc; + } + + inline bool valid() const exprtk_override + { + return + vector_holder_ && + index_.first && + vector_node_.first && + index_.first->valid() && + vector_node_.first->valid(); + } + + inline vector_holder_t& vec_holder() + { + return (*vector_holder_); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(vector_node_, node_delete_list); + expression_node<T>::ndb_t::collect(index_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth + (vector_node_, index_); + } + + private: + + inline T* access_vector() const + { + vector_node_.first->value(); + const _uint64_t index = details::numeric::to_uint64(index_.first->value()); + + if (index <= (vector_holder_->size() - 1)) + { + return (vector_holder_->data() + index); + } + + assert(vec_rt_chk_); + + vector_access_runtime_check::violation_context context; + context.base_ptr = reinterpret_cast<void*>(vector_holder_->data()); + context.end_ptr = reinterpret_cast<void*>(vector_holder_->data() + vector_holder_->size()); + context.access_ptr = reinterpret_cast<void*>(vector_holder_->data() + index); + context.type_size = sizeof(T); + + return vec_rt_chk_->handle_runtime_violation(context) ? + reinterpret_cast<T*>(context.access_ptr) : + vector_holder_->data() ; + } + + vector_holder_ptr vector_holder_; + branch_t vector_node_; + branch_t index_; + vector_access_runtime_check_ptr vec_rt_chk_; + }; + + template <typename T> + class rebasevector_celem_rtc_node exprtk_final + : public expression_node<T> + , public ivariable <T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + rebasevector_celem_rtc_node(expression_ptr vec_node, + const std::size_t index, + vector_holder_ptr vec_holder, + vector_access_runtime_check_ptr vec_rt_chk) + : index_(index) + , vector_holder_(vec_holder) + , vector_base_((*vec_holder)[0]) + , vec_rt_chk_(vec_rt_chk) + { + construct_branch_pair(vector_node_, vec_node); + assert(valid()); + } + + inline T value() const exprtk_override + { + return *access_vector(); + } + + inline T& ref() exprtk_override + { + return *access_vector(); + } + + inline const T& ref() const exprtk_override + { + return *access_vector(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_rbveccelemrtc; + } + + inline bool valid() const exprtk_override + { + return + vector_holder_ && + vector_node_.first && + vector_node_.first->valid(); + } + + inline vector_holder_t& vec_holder() + { + return (*vector_holder_); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(vector_node_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(vector_node_); + } + + private: + + inline T* access_vector() const + { + vector_node_.first->value(); + + if (index_ <= vector_holder_->size() - 1) + { + return (vector_holder_->data() + index_); + } + + assert(vec_rt_chk_); + + vector_access_runtime_check::violation_context context; + context.base_ptr = reinterpret_cast<void*>(vector_base_); + context.end_ptr = reinterpret_cast<void*>(vector_base_ + vector_holder_->size()); + context.access_ptr = reinterpret_cast<void*>(vector_base_ + index_); + context.type_size = sizeof(T); + + return vec_rt_chk_->handle_runtime_violation(context) ? + reinterpret_cast<T*>(context.access_ptr) : + vector_base_ ; + } + + const std::size_t index_; + vector_holder_ptr vector_holder_; + T* vector_base_; + branch_t vector_node_; + vector_access_runtime_check_ptr vec_rt_chk_; + }; + + template <typename T> + class vector_initialisation_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + vector_initialisation_node(T* vector_base, + const std::size_t& size, + const std::vector<expression_ptr>& initialiser_list, + const bool single_value_initialse) + : vector_base_(vector_base) + , initialiser_list_(initialiser_list) + , size_(size) + , single_value_initialse_(single_value_initialse) + , zero_value_initialse_(false) + , const_nonzero_literal_value_initialse_(false) + , single_initialiser_value_(T(0)) + { + if (single_value_initialse_) + { + if (initialiser_list_.empty()) + zero_value_initialse_ = true; + else if ( + (initialiser_list_.size() == 1) && + details::is_constant_node(initialiser_list_[0]) && + (T(0) == initialiser_list_[0]->value()) + ) + { + zero_value_initialse_ = true; + } + else + { + assert(initialiser_list_.size() == 1); + + if (details::is_constant_node(initialiser_list_[0])) + { + const_nonzero_literal_value_initialse_ = true; + single_initialiser_value_ = initialiser_list_[0]->value(); + assert(T(0) != single_initialiser_value_); + } + } + } + } + + inline T value() const exprtk_override + { + if (single_value_initialse_) + { + if (zero_value_initialse_) + { + details::set_zero_value(vector_base_, size_); + } + else if (const_nonzero_literal_value_initialse_) + { + for (std::size_t i = 0; i < size_; ++i) + { + *(vector_base_ + i) = single_initialiser_value_; + } + } + else + { + for (std::size_t i = 0; i < size_; ++i) + { + *(vector_base_ + i) = initialiser_list_[0]->value(); + } + } + } + else + { + const std::size_t initialiser_list_size = initialiser_list_.size(); + + for (std::size_t i = 0; i < initialiser_list_size; ++i) + { + *(vector_base_ + i) = initialiser_list_[i]->value(); + } + + if (initialiser_list_size < size_) + { + details::set_zero_value( + vector_base_ + initialiser_list_size, + (size_ - initialiser_list_size)); + } + } + + return *(vector_base_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecinit; + } + + inline bool valid() const exprtk_override + { + return vector_base_; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(initialiser_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(initialiser_list_); + } + + private: + + vector_initialisation_node(const vector_initialisation_node<T>&) exprtk_delete; + vector_initialisation_node<T>& operator=(const vector_initialisation_node<T>&) exprtk_delete; + + mutable T* vector_base_; + std::vector<expression_ptr> initialiser_list_; + const std::size_t size_; + const bool single_value_initialse_; + bool zero_value_initialse_; + bool const_nonzero_literal_value_initialse_; + T single_initialiser_value_; + }; + + template <typename T> + class vector_init_zero_value_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + vector_init_zero_value_node(T* vector_base, + const std::size_t& size, + const std::vector<expression_ptr>& initialiser_list) + : vector_base_(vector_base) + , size_(size) + , initialiser_list_(initialiser_list) + {} + + inline T value() const exprtk_override + { + details::set_zero_value(vector_base_, size_); + return *(vector_base_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecinit; + } + + inline bool valid() const exprtk_override + { + return vector_base_; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(initialiser_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(initialiser_list_); + } + + private: + + vector_init_zero_value_node(const vector_init_zero_value_node<T>&) exprtk_delete; + vector_init_zero_value_node<T>& operator=(const vector_init_zero_value_node<T>&) exprtk_delete; + + mutable T* vector_base_; + const std::size_t size_; + std::vector<expression_ptr> initialiser_list_; + }; + + template <typename T> + class vector_init_single_constvalue_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + vector_init_single_constvalue_node(T* vector_base, + const std::size_t& size, + const std::vector<expression_ptr>& initialiser_list) + : vector_base_(vector_base) + , size_(size) + , initialiser_list_(initialiser_list) + { + single_initialiser_value_ = initialiser_list_[0]->value(); + assert(valid()); + } + + inline T value() const exprtk_override + { + for (std::size_t i = 0; i < size_; ++i) + { + *(vector_base_ + i) = single_initialiser_value_; + } + + return *(vector_base_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecinit; + } + + inline bool valid() const exprtk_override + { + return vector_base_ && + (initialiser_list_.size() == 1) && + (details::is_constant_node(initialiser_list_[0])) && + (single_initialiser_value_ != T(0)); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(initialiser_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(initialiser_list_); + } + + private: + + vector_init_single_constvalue_node(const vector_init_single_constvalue_node<T>&) exprtk_delete; + vector_init_single_constvalue_node<T>& operator=(const vector_init_single_constvalue_node<T>&) exprtk_delete; + + mutable T* vector_base_; + const std::size_t size_; + std::vector<expression_ptr> initialiser_list_; + T single_initialiser_value_; + }; + + template <typename T> + class vector_init_single_value_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + vector_init_single_value_node(T* vector_base, + const std::size_t& size, + const std::vector<expression_ptr>& initialiser_list) + : vector_base_(vector_base) + , size_(size) + , initialiser_list_(initialiser_list) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + expression_node<T>& node = *initialiser_list_[0]; + + for (std::size_t i = 0; i < size_; ++i) + { + *(vector_base_ + i) = node.value(); + } + + return *(vector_base_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecinit; + } + + inline bool valid() const exprtk_override + { + return vector_base_ && + (initialiser_list_.size() == 1) && + !details::is_constant_node(initialiser_list_[0]); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(initialiser_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(initialiser_list_); + } + + private: + + vector_init_single_value_node(const vector_init_single_value_node<T>&) exprtk_delete; + vector_init_single_value_node<T>& operator=(const vector_init_single_value_node<T>&) exprtk_delete; + + mutable T* vector_base_; + const std::size_t size_; + std::vector<expression_ptr> initialiser_list_; + }; + + template <typename T> + class vector_init_iota_constconst_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + vector_init_iota_constconst_node(T* vector_base, + const std::size_t& size, + const std::vector<expression_ptr>& initialiser_list) + : vector_base_(vector_base) + , size_(size) + , initialiser_list_(initialiser_list) + { + base_value_ = initialiser_list_[0]->value(); + increment_value_ = initialiser_list_[1]->value(); + + assert(valid()); + } + + inline T value() const exprtk_override + { + T value = base_value_; + + for (std::size_t i = 0; i < size_; ++i, value += increment_value_) + { + *(vector_base_ + i) = value; + } + + return *(vector_base_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecinit; + } + + inline bool valid() const exprtk_override + { + return vector_base_ && + (initialiser_list_.size() == 2) && + (details::is_constant_node(initialiser_list_[0])) && + (details::is_constant_node(initialiser_list_[1])) ; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(initialiser_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(initialiser_list_); + } + + private: + + vector_init_iota_constconst_node(const vector_init_iota_constconst_node<T>&) exprtk_delete; + vector_init_iota_constconst_node<T>& operator=(const vector_init_iota_constconst_node<T>&) exprtk_delete; + + mutable T* vector_base_; + const std::size_t size_; + std::vector<expression_ptr> initialiser_list_; + T base_value_; + T increment_value_; + }; + + template <typename T> + class vector_init_iota_constnconst_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + vector_init_iota_constnconst_node(T* vector_base, + const std::size_t& size, + const std::vector<expression_ptr>& initialiser_list) + : vector_base_(vector_base) + , size_(size) + , initialiser_list_(initialiser_list) + { + assert(valid()); + base_value_ = initialiser_list_[0]->value(); + } + + inline T value() const exprtk_override + { + T value = base_value_; + expression_node<T>& increment = *initialiser_list_[1]; + + for (std::size_t i = 0; i < size_; ++i, value += increment.value()) + { + *(vector_base_ + i) = value; + } + + return *(vector_base_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecinit; + } + + inline bool valid() const exprtk_override + { + return vector_base_ && + (initialiser_list_.size() == 2) && + ( details::is_constant_node(initialiser_list_[0])) && + (!details::is_constant_node(initialiser_list_[1])); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(initialiser_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(initialiser_list_); + } + + private: + + vector_init_iota_constnconst_node(const vector_init_iota_constnconst_node<T>&) exprtk_delete; + vector_init_iota_constnconst_node<T>& operator=(const vector_init_iota_constnconst_node<T>&) exprtk_delete; + + mutable T* vector_base_; + const std::size_t size_; + std::vector<expression_ptr> initialiser_list_; + T base_value_; + }; + + template <typename T> + class vector_init_iota_nconstconst_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + vector_init_iota_nconstconst_node(T* vector_base, + const std::size_t& size, + const std::vector<expression_ptr>& initialiser_list) + : vector_base_(vector_base) + , size_(size) + , initialiser_list_(initialiser_list) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + T value = initialiser_list_[0]->value(); + const T increment = initialiser_list_[1]->value(); + + for (std::size_t i = 0; i < size_; ++i, value += increment) + { + *(vector_base_ + i) = value; + } + + return *(vector_base_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecinit; + } + + inline bool valid() const exprtk_override + { + return vector_base_ && + (initialiser_list_.size() == 2) && + (!details::is_constant_node(initialiser_list_[0])) && + (details::is_constant_node(initialiser_list_[1])); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(initialiser_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(initialiser_list_); + } + + private: + + vector_init_iota_nconstconst_node(const vector_init_iota_nconstconst_node<T>&) exprtk_delete; + vector_init_iota_nconstconst_node<T>& operator=(const vector_init_iota_nconstconst_node<T>&) exprtk_delete; + + mutable T* vector_base_; + const std::size_t size_; + std::vector<expression_ptr> initialiser_list_; + }; + + template <typename T> + class vector_init_iota_nconstnconst_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + vector_init_iota_nconstnconst_node(T* vector_base, + const std::size_t& size, + const std::vector<expression_ptr>& initialiser_list) + : vector_base_(vector_base) + , size_(size) + , initialiser_list_(initialiser_list) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + T value = initialiser_list_[0]->value(); + expression_node<T>& increment = *initialiser_list_[1]; + + for (std::size_t i = 0; i < size_; ++i, value += increment.value()) + { + *(vector_base_ + i) = value; + } + + return *(vector_base_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecinit; + } + + inline bool valid() const exprtk_override + { + return vector_base_ && + (initialiser_list_.size() == 2) && + (!details::is_constant_node(initialiser_list_[0])) && + (!details::is_constant_node(initialiser_list_[1])); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(initialiser_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(initialiser_list_); + } + + private: + + vector_init_iota_nconstnconst_node(const vector_init_iota_nconstnconst_node<T>&) exprtk_delete; + vector_init_iota_nconstnconst_node<T>& operator=(const vector_init_iota_nconstnconst_node<T>&) exprtk_delete; + + mutable T* vector_base_; + const std::size_t size_; + std::vector<expression_ptr> initialiser_list_; + }; + + template <typename T> + class swap_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef variable_node<T>* variable_node_ptr; + + swap_node(variable_node_ptr var0, variable_node_ptr var1) + : var0_(var0) + , var1_(var1) + {} + + inline T value() const exprtk_override + { + std::swap(var0_->ref(),var1_->ref()); + return var1_->ref(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_swap; + } + + private: + + variable_node_ptr var0_; + variable_node_ptr var1_; + }; + + template <typename T> + class swap_generic_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef ivariable<T>* ivariable_ptr; + + swap_generic_node(expression_ptr var0, expression_ptr var1) + : binary_node<T>(details::e_swap, var0, var1) + , var0_(dynamic_cast<ivariable_ptr>(var0)) + , var1_(dynamic_cast<ivariable_ptr>(var1)) + {} + + inline T value() const exprtk_override + { + std::swap(var0_->ref(),var1_->ref()); + return var1_->ref(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_swap; + } + + private: + + ivariable_ptr var0_; + ivariable_ptr var1_; + }; + + template <typename T> + class swap_vecvec_node exprtk_final + : public binary_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_node <T>* vector_node_ptr; + typedef vec_data_store <T> vds_t; + + using binary_node<T>::branch; + + swap_vecvec_node(expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(details::e_swap, branch0, branch1) + , vec0_node_ptr_(0) + , vec1_node_ptr_(0) + , initialised_ (false) + { + if (is_ivector_node(branch(0))) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 != (vi = dynamic_cast<vector_interface<T>*>(branch(0)))) + { + vec0_node_ptr_ = vi->vec(); + vds() = vi->vds(); + } + } + + if (is_ivector_node(branch(1))) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 != (vi = dynamic_cast<vector_interface<T>*>(branch(1)))) + { + vec1_node_ptr_ = vi->vec(); + } + } + + if (vec0_node_ptr_ && vec1_node_ptr_) + { + initialised_ = size() <= base_size(); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + binary_node<T>::branch(0)->value(); + binary_node<T>::branch(1)->value(); + + T* vec0 = vec0_node_ptr_->vds().data(); + T* vec1 = vec1_node_ptr_->vds().data(); + + assert(size() <= base_size()); + const std::size_t n = size(); + + for (std::size_t i = 0; i < n; ++i) + { + std::swap(vec0[i],vec1[i]); + } + + return vec1_node_ptr_->value(); + } + + vector_node_ptr vec() const exprtk_override + { + return vec0_node_ptr_; + } + + vector_node_ptr vec() exprtk_override + { + return vec0_node_ptr_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecvecswap; + } + + inline bool valid() const exprtk_override + { + return initialised_ && binary_node<T>::valid(); + } + + std::size_t size() const exprtk_override + { + return std::min( + vec0_node_ptr_->vec_holder().size(), + vec1_node_ptr_->vec_holder().size()); + } + + std::size_t base_size() const exprtk_override + { + return std::min( + vec0_node_ptr_->vec_holder().base_size(), + vec1_node_ptr_->vec_holder().base_size()); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + private: + + vector_node<T>* vec0_node_ptr_; + vector_node<T>* vec1_node_ptr_; + bool initialised_; + vds_t vds_; + }; + + #ifndef exprtk_disable_string_capabilities + template <typename T> + class stringvar_node exprtk_final + : public expression_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + + static std::string null_value; + + explicit stringvar_node() + : value_(&null_value) + {} + + explicit stringvar_node(std::string& v) + : value_(&v) + { + rp_.n0_c = std::make_pair<bool,std::size_t>(true,0); + rp_.n1_c = std::make_pair<bool,std::size_t>(true,v.size()); + rp_.cache.first = rp_.n0_c.second; + rp_.cache.second = rp_.n1_c.second; + } + + inline bool operator <(const stringvar_node<T>& v) const + { + return this < (&v); + } + + inline T value() const exprtk_override + { + rp_.n1_c.second = (*value_).size(); + rp_.cache.second = rp_.n1_c.second; + + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const exprtk_override + { + return ref(); + } + + char_cptr base() const exprtk_override + { + return &(*value_)[0]; + } + + std::size_t size() const exprtk_override + { + return ref().size(); + } + + std::string& ref() + { + return (*value_); + } + + const std::string& ref() const + { + return (*value_); + } + + range_t& range_ref() exprtk_override + { + return rp_; + } + + const range_t& range_ref() const exprtk_override + { + return rp_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_stringvar; + } + + void rebase(std::string& s) + { + value_ = &s; + rp_.n0_c = std::make_pair<bool,std::size_t>(true,0); + rp_.n1_c = std::make_pair<bool,std::size_t>(true,value_->size() - 1); + rp_.cache.first = rp_.n0_c.second; + rp_.cache.second = rp_.n1_c.second; + } + + private: + + std::string* value_; + mutable range_t rp_; + }; + + template <typename T> + std::string stringvar_node<T>::null_value = std::string(""); + + template <typename T> + class string_range_node exprtk_final + : public expression_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + + static std::string null_value; + + explicit string_range_node(std::string& v, const range_t& rp) + : value_(&v) + , rp_(rp) + {} + + virtual ~string_range_node() + { + rp_.free(); + } + + inline bool operator <(const string_range_node<T>& v) const + { + return this < (&v); + } + + inline T value() const exprtk_override + { + return std::numeric_limits<T>::quiet_NaN(); + } + + inline std::string str() const exprtk_override + { + return (*value_); + } + + char_cptr base() const exprtk_override + { + return &(*value_)[0]; + } + + std::size_t size() const exprtk_override + { + return ref().size(); + } + + inline range_t range() const + { + return rp_; + } + + inline virtual std::string& ref() + { + return (*value_); + } + + inline virtual const std::string& ref() const + { + return (*value_); + } + + inline range_t& range_ref() exprtk_override + { + return rp_; + } + + inline const range_t& range_ref() const exprtk_override + { + return rp_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_stringvarrng; + } + + private: + + std::string* value_; + range_t rp_; + }; + + template <typename T> + std::string string_range_node<T>::null_value = std::string(""); + + template <typename T> + class const_string_range_node exprtk_final + : public expression_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + + explicit const_string_range_node(const std::string& v, const range_t& rp) + : value_(v) + , rp_(rp) + {} + + ~const_string_range_node() exprtk_override + { + rp_.free(); + } + + inline T value() const exprtk_override + { + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const exprtk_override + { + return value_; + } + + char_cptr base() const exprtk_override + { + return value_.data(); + } + + std::size_t size() const exprtk_override + { + return value_.size(); + } + + range_t range() const + { + return rp_; + } + + range_t& range_ref() exprtk_override + { + return rp_; + } + + const range_t& range_ref() const exprtk_override + { + return rp_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_cstringvarrng; + } + + private: + + const_string_range_node(const const_string_range_node<T>&) exprtk_delete; + const_string_range_node<T>& operator=(const const_string_range_node<T>&) exprtk_delete; + + const std::string value_; + range_t rp_; + }; + + template <typename T> + class generic_string_range_node exprtk_final + : public expression_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef expression_node <T>* expression_ptr; + typedef stringvar_node <T>* strvar_node_ptr; + typedef string_base_node<T>* str_base_ptr; + typedef typename range_interface<T>::range_t range_t; + typedef range_t* range_ptr; + typedef range_interface<T> irange_t; + typedef irange_t* irange_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + generic_string_range_node(expression_ptr str_branch, const range_t& brange) + : initialised_(false) + , str_base_ptr_ (0) + , str_range_ptr_(0) + , base_range_(brange) + { + range_.n0_c = std::make_pair<bool,std::size_t>(true,0); + range_.n1_c = std::make_pair<bool,std::size_t>(true,0); + range_.cache.first = range_.n0_c.second; + range_.cache.second = range_.n1_c.second; + + construct_branch_pair(branch_, str_branch); + + if (is_generally_string_node(branch_.first)) + { + str_base_ptr_ = dynamic_cast<str_base_ptr>(branch_.first); + + if (0 == str_base_ptr_) + return; + + str_range_ptr_ = dynamic_cast<irange_ptr>(branch_.first); + + if (0 == str_range_ptr_) + return; + } + + initialised_ = (str_base_ptr_ && str_range_ptr_); + assert(valid()); + } + + ~generic_string_range_node() exprtk_override + { + base_range_.free(); + } + + inline T value() const exprtk_override + { + branch_.first->value(); + + std::size_t str_r0 = 0; + std::size_t str_r1 = 0; + + std::size_t r0 = 0; + std::size_t r1 = 0; + + const range_t& range = str_range_ptr_->range_ref(); + + const std::size_t base_str_size = str_base_ptr_->size(); + + if ( + range (str_r0, str_r1, base_str_size ) && + base_range_(r0 , r1 , base_str_size - str_r0) + ) + { + const std::size_t size = r1 - r0; + + range_.n1_c.second = size; + range_.cache.second = range_.n1_c.second; + + value_.assign(str_base_ptr_->base() + str_r0 + r0, size); + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const exprtk_override + { + return value_; + } + + char_cptr base() const exprtk_override + { + return &value_[0]; + } + + std::size_t size() const exprtk_override + { + return value_.size(); + } + + range_t& range_ref() exprtk_override + { + return range_; + } + + const range_t& range_ref() const exprtk_override + { + return range_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strgenrange; + } + + inline bool valid() const exprtk_override + { + return initialised_ && branch_.first; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + bool initialised_; + branch_t branch_; + str_base_ptr str_base_ptr_; + irange_ptr str_range_ptr_; + mutable range_t base_range_; + mutable range_t range_; + mutable std::string value_; + }; + + template <typename T> + class string_concat_node exprtk_final + : public binary_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + typedef range_interface<T> irange_t; + typedef irange_t* irange_ptr; + typedef range_t* range_ptr; + typedef expression_node <T>* expression_ptr; + typedef string_base_node<T>* str_base_ptr; + + using binary_node<T>::branch; + + string_concat_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , initialised_(false) + , str0_base_ptr_ (0) + , str1_base_ptr_ (0) + , str0_range_ptr_(0) + , str1_range_ptr_(0) + { + range_.n0_c = std::make_pair<bool,std::size_t>(true,0); + range_.n1_c = std::make_pair<bool,std::size_t>(true,0); + + range_.cache.first = range_.n0_c.second; + range_.cache.second = range_.n1_c.second; + + if (is_generally_string_node(branch(0))) + { + str0_base_ptr_ = dynamic_cast<str_base_ptr>(branch(0)); + + if (0 == str0_base_ptr_) + return; + + str0_range_ptr_ = dynamic_cast<irange_ptr>(branch(0)); + + if (0 == str0_range_ptr_) + return; + } + + if (is_generally_string_node(branch(1))) + { + str1_base_ptr_ = dynamic_cast<str_base_ptr>(branch(1)); + + if (0 == str1_base_ptr_) + return; + + str1_range_ptr_ = dynamic_cast<irange_ptr>(branch(1)); + + if (0 == str1_range_ptr_) + return; + } + + initialised_ = str0_base_ptr_ && + str1_base_ptr_ && + str0_range_ptr_ && + str1_range_ptr_ ; + + assert(valid()); + } + + inline T value() const exprtk_override + { + branch(0)->value(); + branch(1)->value(); + + std::size_t str0_r0 = 0; + std::size_t str0_r1 = 0; + + std::size_t str1_r0 = 0; + std::size_t str1_r1 = 0; + + const range_t& range0 = str0_range_ptr_->range_ref(); + const range_t& range1 = str1_range_ptr_->range_ref(); + + if ( + range0(str0_r0, str0_r1, str0_base_ptr_->size()) && + range1(str1_r0, str1_r1, str1_base_ptr_->size()) + ) + { + const std::size_t size0 = (str0_r1 - str0_r0); + const std::size_t size1 = (str1_r1 - str1_r0); + + value_.assign(str0_base_ptr_->base() + str0_r0, size0); + value_.append(str1_base_ptr_->base() + str1_r0, size1); + + range_.n1_c.second = value_.size(); + range_.cache.second = range_.n1_c.second; + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const exprtk_override + { + return value_; + } + + char_cptr base() const exprtk_override + { + return &value_[0]; + } + + std::size_t size() const exprtk_override + { + return value_.size(); + } + + range_t& range_ref() exprtk_override + { + return range_; + } + + const range_t& range_ref() const exprtk_override + { + return range_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strconcat; + } + + inline bool valid() const exprtk_override + { + return initialised_ && binary_node<T>::valid(); + } + + private: + + bool initialised_; + str_base_ptr str0_base_ptr_; + str_base_ptr str1_base_ptr_; + irange_ptr str0_range_ptr_; + irange_ptr str1_range_ptr_; + mutable range_t range_; + mutable std::string value_; + }; + + template <typename T> + class swap_string_node exprtk_final + : public binary_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + typedef range_t* range_ptr; + typedef range_interface<T> irange_t; + typedef irange_t* irange_ptr; + typedef expression_node <T>* expression_ptr; + typedef stringvar_node <T>* strvar_node_ptr; + typedef string_base_node<T>* str_base_ptr; + + using binary_node<T>::branch; + + swap_string_node(expression_ptr branch0, expression_ptr branch1) + : binary_node<T>(details::e_swap, branch0, branch1) + , initialised_(false) + , str0_node_ptr_(0) + , str1_node_ptr_(0) + { + if (is_string_node(branch(0))) + { + str0_node_ptr_ = static_cast<strvar_node_ptr>(branch(0)); + } + + if (is_string_node(branch(1))) + { + str1_node_ptr_ = static_cast<strvar_node_ptr>(branch(1)); + } + + initialised_ = (str0_node_ptr_ && str1_node_ptr_); + assert(valid()); + } + + inline T value() const exprtk_override + { + branch(0)->value(); + branch(1)->value(); + + std::swap(str0_node_ptr_->ref(), str1_node_ptr_->ref()); + + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const exprtk_override + { + return str0_node_ptr_->str(); + } + + char_cptr base() const exprtk_override + { + return str0_node_ptr_->base(); + } + + std::size_t size() const exprtk_override + { + return str0_node_ptr_->size(); + } + + range_t& range_ref() exprtk_override + { + return str0_node_ptr_->range_ref(); + } + + const range_t& range_ref() const exprtk_override + { + return str0_node_ptr_->range_ref(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strswap; + } + + inline bool valid() const exprtk_override + { + return initialised_ && binary_node<T>::valid(); + } + + private: + + bool initialised_; + strvar_node_ptr str0_node_ptr_; + strvar_node_ptr str1_node_ptr_; + }; + + template <typename T> + class swap_genstrings_node exprtk_final : public binary_node<T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + typedef range_t* range_ptr; + typedef range_interface<T> irange_t; + typedef irange_t* irange_ptr; + typedef expression_node <T>* expression_ptr; + typedef string_base_node<T>* str_base_ptr; + + using binary_node<T>::branch; + + swap_genstrings_node(expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(details::e_default, branch0, branch1) + , str0_base_ptr_ (0) + , str1_base_ptr_ (0) + , str0_range_ptr_(0) + , str1_range_ptr_(0) + , initialised_(false) + { + if (is_generally_string_node(branch(0))) + { + str0_base_ptr_ = dynamic_cast<str_base_ptr>(branch(0)); + + if (0 == str0_base_ptr_) + return; + + irange_ptr range = dynamic_cast<irange_ptr>(branch(0)); + + if (0 == range) + return; + + str0_range_ptr_ = &(range->range_ref()); + } + + if (is_generally_string_node(branch(1))) + { + str1_base_ptr_ = dynamic_cast<str_base_ptr>(branch(1)); + + if (0 == str1_base_ptr_) + return; + + irange_ptr range = dynamic_cast<irange_ptr>(branch(1)); + + if (0 == range) + return; + + str1_range_ptr_ = &(range->range_ref()); + } + + initialised_ = str0_base_ptr_ && + str1_base_ptr_ && + str0_range_ptr_ && + str1_range_ptr_ ; + + assert(valid()); + } + + inline T value() const exprtk_override + { + branch(0)->value(); + branch(1)->value(); + + std::size_t str0_r0 = 0; + std::size_t str0_r1 = 0; + + std::size_t str1_r0 = 0; + std::size_t str1_r1 = 0; + + const range_t& range0 = (*str0_range_ptr_); + const range_t& range1 = (*str1_range_ptr_); + + if ( + range0(str0_r0, str0_r1, str0_base_ptr_->size()) && + range1(str1_r0, str1_r1, str1_base_ptr_->size()) + ) + { + const std::size_t size0 = range0.cache_size(); + const std::size_t size1 = range1.cache_size(); + const std::size_t max_size = std::min(size0,size1); + + char_ptr s0 = const_cast<char_ptr>(str0_base_ptr_->base() + str0_r0); + char_ptr s1 = const_cast<char_ptr>(str1_base_ptr_->base() + str1_r0); + + loop_unroll::details lud(max_size); + char_cptr upper_bound = s0 + lud.upper_bound; + + while (s0 < upper_bound) + { + #define exprtk_loop(N) \ + std::swap(s0[N], s1[N]); \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + s0 += lud.batch_size; + s1 += lud.batch_size; + } + + int i = 0; + + switch (lud.remainder) + { + #define case_stmt(N) \ + case N : { std::swap(s0[i], s1[i]); ++i; } \ + exprtk_fallthrough \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15) case_stmt(14) + case_stmt(13) case_stmt(12) + case_stmt(11) case_stmt(10) + case_stmt( 9) case_stmt( 8) + case_stmt( 7) case_stmt( 6) + case_stmt( 5) case_stmt( 4) + #endif + case_stmt( 3) case_stmt( 2) + case_stmt( 1) + default: break; + } + + #undef exprtk_loop + #undef case_stmt + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strswap; + } + + inline bool valid() const exprtk_override + { + return initialised_ && binary_node<T>::valid(); + } + + private: + + swap_genstrings_node(const swap_genstrings_node<T>&) exprtk_delete; + swap_genstrings_node<T>& operator=(const swap_genstrings_node<T>&) exprtk_delete; + + str_base_ptr str0_base_ptr_; + str_base_ptr str1_base_ptr_; + range_ptr str0_range_ptr_; + range_ptr str1_range_ptr_; + bool initialised_; + }; + + template <typename T> + class stringvar_size_node exprtk_final : public expression_node<T> + { + public: + + static const std::string null_value; + + explicit stringvar_size_node() + : value_(&null_value) + {} + + explicit stringvar_size_node(std::string& v) + : value_(&v) + {} + + inline T value() const exprtk_override + { + return T((*value_).size()); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_stringvarsize; + } + + private: + + const std::string* value_; + }; + + template <typename T> + const std::string stringvar_size_node<T>::null_value = std::string(""); + + template <typename T> + class string_size_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node <T>* expression_ptr; + typedef string_base_node<T>* str_base_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + explicit string_size_node(expression_ptr branch) + : str_base_ptr_(0) + { + construct_branch_pair(branch_, branch); + + if (is_generally_string_node(branch_.first)) + { + str_base_ptr_ = dynamic_cast<str_base_ptr>(branch_.first); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + branch_.first->value(); + return T(str_base_ptr_->size()); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_stringsize; + } + + inline bool valid() const exprtk_override + { + return str_base_ptr_; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + branch_t branch_; + str_base_ptr str_base_ptr_; + }; + + struct asn_assignment + { + static inline void execute(std::string& s, char_cptr data, const std::size_t size) + { s.assign(data,size); } + }; + + struct asn_addassignment + { + static inline void execute(std::string& s, char_cptr data, const std::size_t size) + { s.append(data,size); } + }; + + template <typename T, typename AssignmentProcess = asn_assignment> + class assignment_string_node exprtk_final + : public binary_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + typedef range_t* range_ptr; + typedef range_interface <T> irange_t; + typedef irange_t* irange_ptr; + typedef expression_node <T>* expression_ptr; + typedef stringvar_node <T>* strvar_node_ptr; + typedef string_base_node<T>* str_base_ptr; + + using binary_node<T>::branch; + + assignment_string_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , initialised_(false) + , str0_base_ptr_ (0) + , str1_base_ptr_ (0) + , str0_node_ptr_ (0) + , str1_range_ptr_(0) + { + if (is_string_node(branch(0))) + { + str0_node_ptr_ = static_cast<strvar_node_ptr>(branch(0)); + str0_base_ptr_ = dynamic_cast<str_base_ptr>(branch(0)); + } + + if (is_generally_string_node(branch(1))) + { + str1_base_ptr_ = dynamic_cast<str_base_ptr>(branch(1)); + + if (0 == str1_base_ptr_) + return; + + irange_ptr range = dynamic_cast<irange_ptr>(branch(1)); + + if (0 == range) + return; + + str1_range_ptr_ = &(range->range_ref()); + } + + initialised_ = str0_base_ptr_ && + str1_base_ptr_ && + str0_node_ptr_ && + str1_range_ptr_ ; + + assert(valid()); + } + + inline T value() const exprtk_override + { + branch(1)->value(); + + std::size_t r0 = 0; + std::size_t r1 = 0; + + const range_t& range = (*str1_range_ptr_); + + if (range(r0, r1, str1_base_ptr_->size())) + { + AssignmentProcess::execute( + str0_node_ptr_->ref(), + str1_base_ptr_->base() + r0, (r1 - r0)); + + branch(0)->value(); + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const exprtk_override + { + return str0_node_ptr_->str(); + } + + char_cptr base() const exprtk_override + { + return str0_node_ptr_->base(); + } + + std::size_t size() const exprtk_override + { + return str0_node_ptr_->size(); + } + + range_t& range_ref() exprtk_override + { + return str0_node_ptr_->range_ref(); + } + + const range_t& range_ref() const exprtk_override + { + return str0_node_ptr_->range_ref(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strass; + } + + inline bool valid() const exprtk_override + { + return initialised_ && binary_node<T>::valid(); + } + + private: + + bool initialised_; + str_base_ptr str0_base_ptr_; + str_base_ptr str1_base_ptr_; + strvar_node_ptr str0_node_ptr_; + range_ptr str1_range_ptr_; + }; + + template <typename T, typename AssignmentProcess = asn_assignment> + class assignment_string_range_node exprtk_final + : public binary_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + typedef range_t* range_ptr; + typedef range_interface <T> irange_t; + typedef irange_t* irange_ptr; + typedef expression_node <T>* expression_ptr; + typedef stringvar_node <T>* strvar_node_ptr; + typedef string_range_node<T>* str_rng_node_ptr; + typedef string_base_node <T>* str_base_ptr; + + using binary_node<T>::branch; + + assignment_string_range_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , initialised_(false) + , str0_base_ptr_ (0) + , str1_base_ptr_ (0) + , str0_rng_node_ptr_(0) + , str0_range_ptr_ (0) + , str1_range_ptr_ (0) + { + if (is_string_range_node(branch(0))) + { + str0_rng_node_ptr_ = static_cast<str_rng_node_ptr>(branch(0)); + str0_base_ptr_ = dynamic_cast<str_base_ptr>(branch(0)); + irange_ptr range = dynamic_cast<irange_ptr>(branch(0)); + + if (0 == range) + return; + + str0_range_ptr_ = &(range->range_ref()); + } + + if (is_generally_string_node(branch(1))) + { + str1_base_ptr_ = dynamic_cast<str_base_ptr>(branch(1)); + + if (0 == str1_base_ptr_) + return; + + irange_ptr range = dynamic_cast<irange_ptr>(branch(1)); + + if (0 == range) + return; + + str1_range_ptr_ = &(range->range_ref()); + } + + initialised_ = str0_base_ptr_ && + str1_base_ptr_ && + str0_rng_node_ptr_ && + str0_range_ptr_ && + str1_range_ptr_ ; + + assert(valid()); + } + + inline T value() const exprtk_override + { + branch(0)->value(); + branch(1)->value(); + + std::size_t s0_r0 = 0; + std::size_t s0_r1 = 0; + + std::size_t s1_r0 = 0; + std::size_t s1_r1 = 0; + + const range_t& range0 = (*str0_range_ptr_); + const range_t& range1 = (*str1_range_ptr_); + + if ( + range0(s0_r0, s0_r1, str0_base_ptr_->size()) && + range1(s1_r0, s1_r1, str1_base_ptr_->size()) + ) + { + const std::size_t size = std::min((s0_r1 - s0_r0), (s1_r1 - s1_r0)); + + std::copy( + str1_base_ptr_->base() + s1_r0, + str1_base_ptr_->base() + s1_r0 + size, + const_cast<char_ptr>(base() + s0_r0)); + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const exprtk_override + { + return str0_base_ptr_->str(); + } + + char_cptr base() const exprtk_override + { + return str0_base_ptr_->base(); + } + + std::size_t size() const exprtk_override + { + return str0_base_ptr_->size(); + } + + range_t& range_ref() exprtk_override + { + return str0_rng_node_ptr_->range_ref(); + } + + const range_t& range_ref() const exprtk_override + { + return str0_rng_node_ptr_->range_ref(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strass; + } + + inline bool valid() const exprtk_override + { + return initialised_ && binary_node<T>::valid(); + } + + private: + + bool initialised_; + str_base_ptr str0_base_ptr_; + str_base_ptr str1_base_ptr_; + str_rng_node_ptr str0_rng_node_ptr_; + range_ptr str0_range_ptr_; + range_ptr str1_range_ptr_; + }; + + template <typename T> + class conditional_string_node exprtk_final + : public trinary_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + typedef range_t* range_ptr; + typedef range_interface <T> irange_t; + typedef irange_t* irange_ptr; + typedef expression_node <T>* expression_ptr; + typedef string_base_node<T>* str_base_ptr; + + conditional_string_node(expression_ptr condition, + expression_ptr consequent, + expression_ptr alternative) + : trinary_node<T>(details::e_default, consequent, alternative, condition) + , initialised_(false) + , str0_base_ptr_ (0) + , str1_base_ptr_ (0) + , str0_range_ptr_(0) + , str1_range_ptr_(0) + , condition_ (condition ) + , consequent_ (consequent ) + , alternative_(alternative) + { + range_.n0_c = std::make_pair<bool,std::size_t>(true,0); + range_.n1_c = std::make_pair<bool,std::size_t>(true,0); + + range_.cache.first = range_.n0_c.second; + range_.cache.second = range_.n1_c.second; + + if (is_generally_string_node(trinary_node<T>::branch_[0].first)) + { + str0_base_ptr_ = dynamic_cast<str_base_ptr>(trinary_node<T>::branch_[0].first); + + if (0 == str0_base_ptr_) + return; + + str0_range_ptr_ = dynamic_cast<irange_ptr>(trinary_node<T>::branch_[0].first); + + if (0 == str0_range_ptr_) + return; + } + + if (is_generally_string_node(trinary_node<T>::branch_[1].first)) + { + str1_base_ptr_ = dynamic_cast<str_base_ptr>(trinary_node<T>::branch_[1].first); + + if (0 == str1_base_ptr_) + return; + + str1_range_ptr_ = dynamic_cast<irange_ptr>(trinary_node<T>::branch_[1].first); + + if (0 == str1_range_ptr_) + return; + } + + initialised_ = str0_base_ptr_ && + str1_base_ptr_ && + str0_range_ptr_ && + str1_range_ptr_ ; + + assert(valid()); + } + + inline T value() const exprtk_override + { + std::size_t r0 = 0; + std::size_t r1 = 0; + + if (is_true(condition_)) + { + consequent_->value(); + + const range_t& range = str0_range_ptr_->range_ref(); + + if (range(r0, r1, str0_base_ptr_->size())) + { + const std::size_t size = (r1 - r0); + + value_.assign(str0_base_ptr_->base() + r0, size); + + range_.n1_c.second = value_.size(); + range_.cache.second = range_.n1_c.second; + + return T(1); + } + } + else + { + alternative_->value(); + + const range_t& range = str1_range_ptr_->range_ref(); + + if (range(r0, r1, str1_base_ptr_->size())) + { + const std::size_t size = (r1 - r0); + + value_.assign(str1_base_ptr_->base() + r0, size); + + range_.n1_c.second = value_.size(); + range_.cache.second = range_.n1_c.second; + + return T(0); + } + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const exprtk_override + { + return value_; + } + + char_cptr base() const exprtk_override + { + return &value_[0]; + } + + std::size_t size() const exprtk_override + { + return value_.size(); + } + + range_t& range_ref() exprtk_override + { + return range_; + } + + const range_t& range_ref() const exprtk_override + { + return range_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strcondition; + } + + inline bool valid() const exprtk_override + { + return + initialised_ && + condition_ && condition_ ->valid() && + consequent_ && consequent_ ->valid() && + alternative_&& alternative_->valid() ; + } + + private: + + bool initialised_; + str_base_ptr str0_base_ptr_; + str_base_ptr str1_base_ptr_; + irange_ptr str0_range_ptr_; + irange_ptr str1_range_ptr_; + mutable range_t range_; + mutable std::string value_; + + expression_ptr condition_; + expression_ptr consequent_; + expression_ptr alternative_; + }; + + template <typename T> + class cons_conditional_str_node exprtk_final + : public binary_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + typedef range_t* range_ptr; + typedef range_interface <T> irange_t; + typedef irange_t* irange_ptr; + typedef expression_node <T>* expression_ptr; + typedef string_base_node<T>* str_base_ptr; + + using binary_node<T>::branch; + + cons_conditional_str_node(expression_ptr condition, + expression_ptr consequent) + : binary_node<T>(details::e_default, consequent, condition) + , initialised_(false) + , str0_base_ptr_ (0) + , str0_range_ptr_(0) + , condition_ (condition ) + , consequent_(consequent) + { + range_.n0_c = std::make_pair<bool,std::size_t>(true,0); + range_.n1_c = std::make_pair<bool,std::size_t>(true,0); + + range_.cache.first = range_.n0_c.second; + range_.cache.second = range_.n1_c.second; + + if (is_generally_string_node(branch(0))) + { + str0_base_ptr_ = dynamic_cast<str_base_ptr>(branch(0)); + + if (0 == str0_base_ptr_) + return; + + str0_range_ptr_ = dynamic_cast<irange_ptr>(branch(0)); + + if (0 == str0_range_ptr_) + return; + } + + initialised_ = str0_base_ptr_ && str0_range_ptr_ ; + assert(valid()); + } + + inline T value() const exprtk_override + { + if (is_true(condition_)) + { + consequent_->value(); + + const range_t& range = str0_range_ptr_->range_ref(); + + std::size_t r0 = 0; + std::size_t r1 = 0; + + if (range(r0, r1, str0_base_ptr_->size())) + { + const std::size_t size = (r1 - r0); + + value_.assign(str0_base_ptr_->base() + r0, size); + + range_.n1_c.second = value_.size(); + range_.cache.second = range_.n1_c.second; + + return T(1); + } + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const + { + return value_; + } + + char_cptr base() const + { + return &value_[0]; + } + + std::size_t size() const + { + return value_.size(); + } + + range_t& range_ref() + { + return range_; + } + + const range_t& range_ref() const + { + return range_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strccondition; + } + + inline bool valid() const exprtk_override + { + return + initialised_ && + condition_ && condition_ ->valid() && + consequent_ && consequent_ ->valid() ; + } + + private: + + bool initialised_; + str_base_ptr str0_base_ptr_; + irange_ptr str0_range_ptr_; + mutable range_t range_; + mutable std::string value_; + + expression_ptr condition_; + expression_ptr consequent_; + }; + + template <typename T, typename VarArgFunction> + class str_vararg_node exprtk_final + : public expression_node <T> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef typename range_interface<T>::range_t range_t; + typedef range_t* range_ptr; + typedef range_interface <T> irange_t; + typedef irange_t* irange_ptr; + typedef expression_node <T>* expression_ptr; + typedef string_base_node<T>* str_base_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + template <typename Allocator, + template <typename, typename> class Sequence> + explicit str_vararg_node(const Sequence<expression_ptr,Allocator>& arg_list) + : initialised_(false) + , str_base_ptr_ (0) + , str_range_ptr_(0) + { + construct_branch_pair(final_node_, const_cast<expression_ptr>(arg_list.back())); + + if (0 == final_node_.first) + return; + else if (!is_generally_string_node(final_node_.first)) + return; + + str_base_ptr_ = dynamic_cast<str_base_ptr>(final_node_.first); + + if (0 == str_base_ptr_) + return; + + str_range_ptr_ = dynamic_cast<irange_ptr>(final_node_.first); + + if (0 == str_range_ptr_) + return; + + if (arg_list.size() > 1) + { + const std::size_t arg_list_size = arg_list.size() - 1; + + arg_list_.resize(arg_list_size); + + for (std::size_t i = 0; i < arg_list_size; ++i) + { + if (arg_list[i] && arg_list[i]->valid()) + { + construct_branch_pair(arg_list_[i], arg_list[i]); + } + else + { + arg_list_.clear(); + return; + } + } + + initialised_ = true; + } + + initialised_ &= str_base_ptr_ && str_range_ptr_; + assert(valid()); + } + + inline T value() const exprtk_override + { + if (!arg_list_.empty()) + { + VarArgFunction::process(arg_list_); + } + + final_node_.first->value(); + + return std::numeric_limits<T>::quiet_NaN(); + } + + std::string str() const exprtk_override + { + return str_base_ptr_->str(); + } + + char_cptr base() const exprtk_override + { + return str_base_ptr_->base(); + } + + std::size_t size() const exprtk_override + { + return str_base_ptr_->size(); + } + + range_t& range_ref() exprtk_override + { + return str_range_ptr_->range_ref(); + } + + const range_t& range_ref() const exprtk_override + { + return str_range_ptr_->range_ref(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_stringvararg; + } + + inline bool valid() const exprtk_override + { + return + initialised_ && + final_node_.first && final_node_.first->valid(); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(final_node_ , node_delete_list); + expression_node<T>::ndb_t::collect(arg_list_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return std::max( + expression_node<T>::ndb_t::compute_node_depth(final_node_), + expression_node<T>::ndb_t::compute_node_depth(arg_list_ )); + } + + private: + + bool initialised_; + branch_t final_node_; + str_base_ptr str_base_ptr_; + irange_ptr str_range_ptr_; + std::vector<branch_t> arg_list_; + }; + #endif + + template <typename T> + class assert_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + typedef string_base_node<T>* str_base_ptr; + typedef assert_check::assert_context assert_context_t; + + assert_node(expression_ptr assert_condition_node, + expression_ptr assert_message_node, + assert_check_ptr assert_check, + assert_context_t context) + : assert_message_str_base_(0) + , assert_check_(assert_check) + , context_(context) + { + construct_branch_pair(assert_condition_node_, assert_condition_node); + construct_branch_pair(assert_message_node_ , assert_message_node ); + + #ifndef exprtk_disable_string_capabilities + if ( + assert_message_node_.first && + details::is_generally_string_node(assert_message_node_.first) + ) + { + assert_message_str_base_ = dynamic_cast<str_base_ptr>(assert_message_node_.first); + } + #endif + + assert(valid()); + } + + inline T value() const exprtk_override + { + if (details::is_true(assert_condition_node_.first->value())) + { + return T(1); + } + + #ifndef exprtk_disable_string_capabilities + if (assert_message_node_.first) + { + assert_message_node_.first->value(); + assert(assert_message_str_base_); + context_.message = assert_message_str_base_->str(); + } + #endif + + assert_check_->handle_assert(context_); + return T(0); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_assert; + } + + inline bool valid() const exprtk_override + { + return ( + assert_check_ && + assert_condition_node_.first && + assert_condition_node_.first->valid() + ) && + ( + (0 == assert_message_node_.first) || + ( + assert_message_node_.first && + assert_message_str_base_ && + assert_message_node_.first->valid() && + details::is_generally_string_node(assert_message_node_.first) + ) + ); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(assert_condition_node_, node_delete_list); + expression_node<T>::ndb_t::collect(assert_message_node_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth + (assert_condition_node_, assert_message_node_); + } + + private: + + branch_t assert_condition_node_; + branch_t assert_message_node_; + str_base_ptr assert_message_str_base_; + assert_check_ptr assert_check_; + mutable assert_context_t context_; + }; + + template <typename T, std::size_t N> + inline T axn(const T a, const T x) + { + // a*x^n + return a * exprtk::details::numeric::fast_exp<T,N>::result(x); + } + + template <typename T, std::size_t N> + inline T axnb(const T a, const T x, const T b) + { + // a*x^n+b + return a * exprtk::details::numeric::fast_exp<T,N>::result(x) + b; + } + + template <typename T> + struct sf_base + { + typedef typename details::functor_t<T>::Type Type; + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::qfunc_t quaternary_functor_t; + typedef typename functor_t::tfunc_t trinary_functor_t; + typedef typename functor_t::bfunc_t binary_functor_t; + typedef typename functor_t::ufunc_t unary_functor_t; + }; + + #define define_sfop3(NN, OP0, OP1) \ + template <typename T> \ + struct sf##NN##_op : public sf_base<T> \ + { \ + typedef typename sf_base<T>::Type const Type; \ + static inline T process(Type x, Type y, Type z) \ + { \ + return (OP0); \ + } \ + static inline std::string id() \ + { \ + return (OP1); \ + } \ + }; \ + + define_sfop3(00,(x + y) / z ,"(t+t)/t") + define_sfop3(01,(x + y) * z ,"(t+t)*t") + define_sfop3(02,(x + y) - z ,"(t+t)-t") + define_sfop3(03,(x + y) + z ,"(t+t)+t") + define_sfop3(04,(x - y) + z ,"(t-t)+t") + define_sfop3(05,(x - y) / z ,"(t-t)/t") + define_sfop3(06,(x - y) * z ,"(t-t)*t") + define_sfop3(07,(x * y) + z ,"(t*t)+t") + define_sfop3(08,(x * y) - z ,"(t*t)-t") + define_sfop3(09,(x * y) / z ,"(t*t)/t") + define_sfop3(10,(x * y) * z ,"(t*t)*t") + define_sfop3(11,(x / y) + z ,"(t/t)+t") + define_sfop3(12,(x / y) - z ,"(t/t)-t") + define_sfop3(13,(x / y) / z ,"(t/t)/t") + define_sfop3(14,(x / y) * z ,"(t/t)*t") + define_sfop3(15,x / (y + z) ,"t/(t+t)") + define_sfop3(16,x / (y - z) ,"t/(t-t)") + define_sfop3(17,x / (y * z) ,"t/(t*t)") + define_sfop3(18,x / (y / z) ,"t/(t/t)") + define_sfop3(19,x * (y + z) ,"t*(t+t)") + define_sfop3(20,x * (y - z) ,"t*(t-t)") + define_sfop3(21,x * (y * z) ,"t*(t*t)") + define_sfop3(22,x * (y / z) ,"t*(t/t)") + define_sfop3(23,x - (y + z) ,"t-(t+t)") + define_sfop3(24,x - (y - z) ,"t-(t-t)") + define_sfop3(25,x - (y / z) ,"t-(t/t)") + define_sfop3(26,x - (y * z) ,"t-(t*t)") + define_sfop3(27,x + (y * z) ,"t+(t*t)") + define_sfop3(28,x + (y / z) ,"t+(t/t)") + define_sfop3(29,x + (y + z) ,"t+(t+t)") + define_sfop3(30,x + (y - z) ,"t+(t-t)") + define_sfop3(31,(axnb<T,2>(x,y,z))," ") + define_sfop3(32,(axnb<T,3>(x,y,z))," ") + define_sfop3(33,(axnb<T,4>(x,y,z))," ") + define_sfop3(34,(axnb<T,5>(x,y,z))," ") + define_sfop3(35,(axnb<T,6>(x,y,z))," ") + define_sfop3(36,(axnb<T,7>(x,y,z))," ") + define_sfop3(37,(axnb<T,8>(x,y,z))," ") + define_sfop3(38,(axnb<T,9>(x,y,z))," ") + define_sfop3(39,x * numeric::log(y) + z,"") + define_sfop3(40,x * numeric::log(y) - z,"") + define_sfop3(41,x * numeric::log10(y) + z,"") + define_sfop3(42,x * numeric::log10(y) - z,"") + define_sfop3(43,x * numeric::sin(y) + z ,"") + define_sfop3(44,x * numeric::sin(y) - z ,"") + define_sfop3(45,x * numeric::cos(y) + z ,"") + define_sfop3(46,x * numeric::cos(y) - z ,"") + define_sfop3(47,details::is_true(x) ? y : z,"") + + #define define_sfop4(NN, OP0, OP1) \ + template <typename T> \ + struct sf##NN##_op : public sf_base<T> \ + { \ + typedef typename sf_base<T>::Type const Type; \ + static inline T process(Type x, Type y, Type z, Type w) \ + { \ + return (OP0); \ + } \ + static inline std::string id() \ + { \ + return (OP1); \ + } \ + }; \ + + define_sfop4(48,(x + ((y + z) / w)),"t+((t+t)/t)") + define_sfop4(49,(x + ((y + z) * w)),"t+((t+t)*t)") + define_sfop4(50,(x + ((y - z) / w)),"t+((t-t)/t)") + define_sfop4(51,(x + ((y - z) * w)),"t+((t-t)*t)") + define_sfop4(52,(x + ((y * z) / w)),"t+((t*t)/t)") + define_sfop4(53,(x + ((y * z) * w)),"t+((t*t)*t)") + define_sfop4(54,(x + ((y / z) + w)),"t+((t/t)+t)") + define_sfop4(55,(x + ((y / z) / w)),"t+((t/t)/t)") + define_sfop4(56,(x + ((y / z) * w)),"t+((t/t)*t)") + define_sfop4(57,(x - ((y + z) / w)),"t-((t+t)/t)") + define_sfop4(58,(x - ((y + z) * w)),"t-((t+t)*t)") + define_sfop4(59,(x - ((y - z) / w)),"t-((t-t)/t)") + define_sfop4(60,(x - ((y - z) * w)),"t-((t-t)*t)") + define_sfop4(61,(x - ((y * z) / w)),"t-((t*t)/t)") + define_sfop4(62,(x - ((y * z) * w)),"t-((t*t)*t)") + define_sfop4(63,(x - ((y / z) / w)),"t-((t/t)/t)") + define_sfop4(64,(x - ((y / z) * w)),"t-((t/t)*t)") + define_sfop4(65,(((x + y) * z) - w),"((t+t)*t)-t") + define_sfop4(66,(((x - y) * z) - w),"((t-t)*t)-t") + define_sfop4(67,(((x * y) * z) - w),"((t*t)*t)-t") + define_sfop4(68,(((x / y) * z) - w),"((t/t)*t)-t") + define_sfop4(69,(((x + y) / z) - w),"((t+t)/t)-t") + define_sfop4(70,(((x - y) / z) - w),"((t-t)/t)-t") + define_sfop4(71,(((x * y) / z) - w),"((t*t)/t)-t") + define_sfop4(72,(((x / y) / z) - w),"((t/t)/t)-t") + define_sfop4(73,((x * y) + (z * w)),"(t*t)+(t*t)") + define_sfop4(74,((x * y) - (z * w)),"(t*t)-(t*t)") + define_sfop4(75,((x * y) + (z / w)),"(t*t)+(t/t)") + define_sfop4(76,((x * y) - (z / w)),"(t*t)-(t/t)") + define_sfop4(77,((x / y) + (z / w)),"(t/t)+(t/t)") + define_sfop4(78,((x / y) - (z / w)),"(t/t)-(t/t)") + define_sfop4(79,((x / y) - (z * w)),"(t/t)-(t*t)") + define_sfop4(80,(x / (y + (z * w))),"t/(t+(t*t))") + define_sfop4(81,(x / (y - (z * w))),"t/(t-(t*t))") + define_sfop4(82,(x * (y + (z * w))),"t*(t+(t*t))") + define_sfop4(83,(x * (y - (z * w))),"t*(t-(t*t))") + + define_sfop4(84,(axn<T,2>(x,y) + axn<T,2>(z,w)),"") + define_sfop4(85,(axn<T,3>(x,y) + axn<T,3>(z,w)),"") + define_sfop4(86,(axn<T,4>(x,y) + axn<T,4>(z,w)),"") + define_sfop4(87,(axn<T,5>(x,y) + axn<T,5>(z,w)),"") + define_sfop4(88,(axn<T,6>(x,y) + axn<T,6>(z,w)),"") + define_sfop4(89,(axn<T,7>(x,y) + axn<T,7>(z,w)),"") + define_sfop4(90,(axn<T,8>(x,y) + axn<T,8>(z,w)),"") + define_sfop4(91,(axn<T,9>(x,y) + axn<T,9>(z,w)),"") + define_sfop4(92,((details::is_true(x) && details::is_true(y)) ? z : w),"") + define_sfop4(93,((details::is_true(x) || details::is_true(y)) ? z : w),"") + define_sfop4(94,((x < y) ? z : w),"") + define_sfop4(95,((x <= y) ? z : w),"") + define_sfop4(96,((x > y) ? z : w),"") + define_sfop4(97,((x >= y) ? z : w),"") + define_sfop4(98,(details::is_true(numeric::equal(x,y)) ? z : w),"") + define_sfop4(99,(x * numeric::sin(y) + z * numeric::cos(w)),"") + + define_sfop4(ext00,((x + y) - (z * w)),"(t+t)-(t*t)") + define_sfop4(ext01,((x + y) - (z / w)),"(t+t)-(t/t)") + define_sfop4(ext02,((x + y) + (z * w)),"(t+t)+(t*t)") + define_sfop4(ext03,((x + y) + (z / w)),"(t+t)+(t/t)") + define_sfop4(ext04,((x - y) + (z * w)),"(t-t)+(t*t)") + define_sfop4(ext05,((x - y) + (z / w)),"(t-t)+(t/t)") + define_sfop4(ext06,((x - y) - (z * w)),"(t-t)-(t*t)") + define_sfop4(ext07,((x - y) - (z / w)),"(t-t)-(t/t)") + define_sfop4(ext08,((x + y) - (z - w)),"(t+t)-(t-t)") + define_sfop4(ext09,((x + y) + (z - w)),"(t+t)+(t-t)") + define_sfop4(ext10,((x + y) + (z + w)),"(t+t)+(t+t)") + define_sfop4(ext11,((x + y) * (z - w)),"(t+t)*(t-t)") + define_sfop4(ext12,((x + y) / (z - w)),"(t+t)/(t-t)") + define_sfop4(ext13,((x - y) - (z + w)),"(t-t)-(t+t)") + define_sfop4(ext14,((x - y) + (z + w)),"(t-t)+(t+t)") + define_sfop4(ext15,((x - y) * (z + w)),"(t-t)*(t+t)") + define_sfop4(ext16,((x - y) / (z + w)),"(t-t)/(t+t)") + define_sfop4(ext17,((x * y) - (z + w)),"(t*t)-(t+t)") + define_sfop4(ext18,((x / y) - (z + w)),"(t/t)-(t+t)") + define_sfop4(ext19,((x * y) + (z + w)),"(t*t)+(t+t)") + define_sfop4(ext20,((x / y) + (z + w)),"(t/t)+(t+t)") + define_sfop4(ext21,((x * y) + (z - w)),"(t*t)+(t-t)") + define_sfop4(ext22,((x / y) + (z - w)),"(t/t)+(t-t)") + define_sfop4(ext23,((x * y) - (z - w)),"(t*t)-(t-t)") + define_sfop4(ext24,((x / y) - (z - w)),"(t/t)-(t-t)") + define_sfop4(ext25,((x + y) * (z * w)),"(t+t)*(t*t)") + define_sfop4(ext26,((x + y) * (z / w)),"(t+t)*(t/t)") + define_sfop4(ext27,((x + y) / (z * w)),"(t+t)/(t*t)") + define_sfop4(ext28,((x + y) / (z / w)),"(t+t)/(t/t)") + define_sfop4(ext29,((x - y) / (z * w)),"(t-t)/(t*t)") + define_sfop4(ext30,((x - y) / (z / w)),"(t-t)/(t/t)") + define_sfop4(ext31,((x - y) * (z * w)),"(t-t)*(t*t)") + define_sfop4(ext32,((x - y) * (z / w)),"(t-t)*(t/t)") + define_sfop4(ext33,((x * y) * (z + w)),"(t*t)*(t+t)") + define_sfop4(ext34,((x / y) * (z + w)),"(t/t)*(t+t)") + define_sfop4(ext35,((x * y) / (z + w)),"(t*t)/(t+t)") + define_sfop4(ext36,((x / y) / (z + w)),"(t/t)/(t+t)") + define_sfop4(ext37,((x * y) / (z - w)),"(t*t)/(t-t)") + define_sfop4(ext38,((x / y) / (z - w)),"(t/t)/(t-t)") + define_sfop4(ext39,((x * y) * (z - w)),"(t*t)*(t-t)") + define_sfop4(ext40,((x * y) / (z * w)),"(t*t)/(t*t)") + define_sfop4(ext41,((x / y) * (z / w)),"(t/t)*(t/t)") + define_sfop4(ext42,((x / y) * (z - w)),"(t/t)*(t-t)") + define_sfop4(ext43,((x * y) * (z * w)),"(t*t)*(t*t)") + define_sfop4(ext44,(x + (y * (z / w))),"t+(t*(t/t))") + define_sfop4(ext45,(x - (y * (z / w))),"t-(t*(t/t))") + define_sfop4(ext46,(x + (y / (z * w))),"t+(t/(t*t))") + define_sfop4(ext47,(x - (y / (z * w))),"t-(t/(t*t))") + define_sfop4(ext48,(((x - y) - z) * w),"((t-t)-t)*t") + define_sfop4(ext49,(((x - y) - z) / w),"((t-t)-t)/t") + define_sfop4(ext50,(((x - y) + z) * w),"((t-t)+t)*t") + define_sfop4(ext51,(((x - y) + z) / w),"((t-t)+t)/t") + define_sfop4(ext52,((x + (y - z)) * w),"(t+(t-t))*t") + define_sfop4(ext53,((x + (y - z)) / w),"(t+(t-t))/t") + define_sfop4(ext54,((x + y) / (z + w)),"(t+t)/(t+t)") + define_sfop4(ext55,((x - y) / (z - w)),"(t-t)/(t-t)") + define_sfop4(ext56,((x + y) * (z + w)),"(t+t)*(t+t)") + define_sfop4(ext57,((x - y) * (z - w)),"(t-t)*(t-t)") + define_sfop4(ext58,((x - y) + (z - w)),"(t-t)+(t-t)") + define_sfop4(ext59,((x - y) - (z - w)),"(t-t)-(t-t)") + define_sfop4(ext60,((x / y) + (z * w)),"(t/t)+(t*t)") + define_sfop4(ext61,(((x * y) * z) / w),"((t*t)*t)/t") + + #undef define_sfop3 + #undef define_sfop4 + + template <typename T, typename SpecialFunction> + class sf3_node exprtk_final : public trinary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + sf3_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1, + expression_ptr branch2) + : trinary_node<T>(opr, branch0, branch1, branch2) + {} + + inline T value() const exprtk_override + { + const T x = trinary_node<T>::branch_[0].first->value(); + const T y = trinary_node<T>::branch_[1].first->value(); + const T z = trinary_node<T>::branch_[2].first->value(); + + return SpecialFunction::process(x, y, z); + } + }; + + template <typename T, typename SpecialFunction> + class sf4_node exprtk_final : public quaternary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + sf4_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1, + expression_ptr branch2, + expression_ptr branch3) + : quaternary_node<T>(opr, branch0, branch1, branch2, branch3) + {} + + inline T value() const exprtk_override + { + const T x = quaternary_node<T>::branch_[0].first->value(); + const T y = quaternary_node<T>::branch_[1].first->value(); + const T z = quaternary_node<T>::branch_[2].first->value(); + const T w = quaternary_node<T>::branch_[3].first->value(); + + return SpecialFunction::process(x, y, z, w); + } + }; + + template <typename T, typename SpecialFunction> + class sf3_var_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + sf3_var_node(const T& v0, const T& v1, const T& v2) + : v0_(v0) + , v1_(v1) + , v2_(v2) + {} + + inline T value() const exprtk_override + { + return SpecialFunction::process(v0_, v1_, v2_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_trinary; + } + + private: + + sf3_var_node(const sf3_var_node<T,SpecialFunction>&) exprtk_delete; + sf3_var_node<T,SpecialFunction>& operator=(const sf3_var_node<T,SpecialFunction>&) exprtk_delete; + + const T& v0_; + const T& v1_; + const T& v2_; + }; + + template <typename T, typename SpecialFunction> + class sf4_var_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + sf4_var_node(const T& v0, const T& v1, const T& v2, const T& v3) + : v0_(v0) + , v1_(v1) + , v2_(v2) + , v3_(v3) + {} + + inline T value() const exprtk_override + { + return SpecialFunction::process(v0_, v1_, v2_, v3_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_trinary; + } + + private: + + sf4_var_node(const sf4_var_node<T,SpecialFunction>&) exprtk_delete; + sf4_var_node<T,SpecialFunction>& operator=(const sf4_var_node<T,SpecialFunction>&) exprtk_delete; + + const T& v0_; + const T& v1_; + const T& v2_; + const T& v3_; + }; + + template <typename T, typename VarArgFunction> + class vararg_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + template <typename Allocator, + template <typename, typename> class Sequence> + explicit vararg_node(const Sequence<expression_ptr,Allocator>& arg_list) + : initialised_(false) + { + arg_list_.resize(arg_list.size()); + + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + if (arg_list[i] && arg_list[i]->valid()) + { + construct_branch_pair(arg_list_[i],arg_list[i]); + } + else + { + arg_list_.clear(); + return; + } + } + + initialised_ = (arg_list_.size() == arg_list.size()); + assert(valid()); + } + + inline T value() const exprtk_override + { + return VarArgFunction::process(arg_list_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vararg; + } + + inline bool valid() const exprtk_override + { + return initialised_; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(arg_list_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(arg_list_); + } + + std::size_t size() const + { + return arg_list_.size(); + } + + expression_ptr operator[](const std::size_t& index) const + { + return arg_list_[index].first; + } + + private: + + std::vector<branch_t> arg_list_; + bool initialised_; + }; + + template <typename T, typename VarArgFunction> + class vararg_varnode exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + template <typename Allocator, + template <typename, typename> class Sequence> + explicit vararg_varnode(const Sequence<expression_ptr,Allocator>& arg_list) + : initialised_(false) + { + arg_list_.resize(arg_list.size()); + + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + if (arg_list[i] && arg_list[i]->valid() && is_variable_node(arg_list[i])) + { + variable_node<T>* var_node_ptr = static_cast<variable_node<T>*>(arg_list[i]); + arg_list_[i] = (&var_node_ptr->ref()); + } + else + { + arg_list_.clear(); + return; + } + } + + initialised_ = (arg_list.size() == arg_list_.size()); + assert(valid()); + } + + inline T value() const exprtk_override + { + return VarArgFunction::process(arg_list_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vararg; + } + + inline bool valid() const exprtk_override + { + return initialised_; + } + + private: + + std::vector<const T*> arg_list_; + bool initialised_; + }; + + template <typename T, typename VecFunction> + class vectorize_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + explicit vectorize_node(const expression_ptr v) + : ivec_ptr_(0) + { + construct_branch_pair(v_, v); + + if (is_ivector_node(v_.first)) + { + ivec_ptr_ = dynamic_cast<vector_interface<T>*>(v_.first); + } + } + + inline T value() const exprtk_override + { + v_.first->value(); + return VecFunction::process(ivec_ptr_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecfunc; + } + + inline bool valid() const exprtk_override + { + return ivec_ptr_ && v_.first && v_.first->valid(); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(v_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(v_); + } + + private: + + vector_interface<T>* ivec_ptr_; + branch_t v_; + }; + + template <typename T> + class assignment_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , var_node_ptr_(0) + { + if (is_variable_node(branch(0))) + { + var_node_ptr_ = static_cast<variable_node<T>*>(branch(0)); + } + } + + inline T value() const exprtk_override + { + T& result = var_node_ptr_->ref(); + result = branch(1)->value(); + + return result; + } + + inline bool valid() const exprtk_override + { + return var_node_ptr_ && binary_node<T>::valid(); + } + + private: + + variable_node<T>* var_node_ptr_; + }; + + template <typename T> + class assignment_vec_elem_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_vec_elem_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec_node_ptr_(0) + { + if (is_vector_elem_node(branch(0))) + { + vec_node_ptr_ = static_cast<vector_elem_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& result = vec_node_ptr_->ref(); + result = branch(1)->value(); + + return result; + } + + inline bool valid() const exprtk_override + { + return vec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + vector_elem_node<T>* vec_node_ptr_; + }; + + template <typename T> + class assignment_vec_elem_rtc_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_vec_elem_rtc_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec_node_ptr_(0) + { + if (is_vector_elem_rtc_node(branch(0))) + { + vec_node_ptr_ = static_cast<vector_elem_rtc_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& result = vec_node_ptr_->ref(); + result = branch(1)->value(); + + return result; + } + + inline bool valid() const exprtk_override + { + return vec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + vector_elem_rtc_node<T>* vec_node_ptr_; + }; + + template <typename T> + class assignment_rebasevec_elem_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using expression_node<T>::branch; + + assignment_rebasevec_elem_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , rbvec_node_ptr_(0) + { + if (is_rebasevector_elem_node(branch(0))) + { + rbvec_node_ptr_ = static_cast<rebasevector_elem_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& result = rbvec_node_ptr_->ref(); + result = branch(1)->value(); + + return result; + } + + inline bool valid() const exprtk_override + { + return rbvec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + rebasevector_elem_node<T>* rbvec_node_ptr_; + }; + + template <typename T> + class assignment_rebasevec_elem_rtc_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using expression_node<T>::branch; + + assignment_rebasevec_elem_rtc_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , rbvec_node_ptr_(0) + { + if (is_rebasevector_elem_rtc_node(branch(0))) + { + rbvec_node_ptr_ = static_cast<rebasevector_elem_rtc_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& result = rbvec_node_ptr_->ref(); + result = branch(1)->value(); + + return result; + } + + inline bool valid() const exprtk_override + { + return rbvec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + rebasevector_elem_rtc_node<T>* rbvec_node_ptr_; + }; + + template <typename T> + class assignment_rebasevec_celem_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_rebasevec_celem_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , rbvec_node_ptr_(0) + { + if (is_rebasevector_celem_node(branch(0))) + { + rbvec_node_ptr_ = static_cast<rebasevector_celem_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& result = rbvec_node_ptr_->ref(); + result = branch(1)->value(); + + return result; + } + + inline bool valid() const exprtk_override + { + return rbvec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + rebasevector_celem_node<T>* rbvec_node_ptr_; + }; + + template <typename T> + class assignment_vec_node exprtk_final + : public binary_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_node<T>* vector_node_ptr; + typedef vec_data_store<T> vds_t; + + using binary_node<T>::branch; + + assignment_vec_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec_node_ptr_(0) + { + if (is_vector_node(branch(0))) + { + vec_node_ptr_ = static_cast<vector_node<T>*>(branch(0)); + vds() = vec_node_ptr_->vds(); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + const T v = branch(1)->value(); + + T* vec = vds().data(); + + loop_unroll::details lud(size()); + const T* upper_bound = vec + lud.upper_bound; + + while (vec < upper_bound) + { + #define exprtk_loop(N) \ + vec[N] = v; \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec += lud.batch_size; + } + + switch (lud.remainder) + { + #define case_stmt(N) \ + case N : *vec++ = v; \ + exprtk_fallthrough \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15) case_stmt(14) + case_stmt(13) case_stmt(12) + case_stmt(11) case_stmt(10) + case_stmt( 9) case_stmt( 8) + case_stmt( 7) case_stmt( 6) + case_stmt( 5) case_stmt( 4) + #endif + case_stmt( 3) case_stmt( 2) + case 1 : *vec++ = v; + } + + #undef exprtk_loop + #undef case_stmt + + return vec_node_ptr_->value(); + } + + vector_node_ptr vec() const exprtk_override + { + return vec_node_ptr_; + } + + vector_node_ptr vec() exprtk_override + { + return vec_node_ptr_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecvalass; + } + + inline bool valid() const exprtk_override + { + return + vec_node_ptr_ && + (vds().size() <= vec_node_ptr_->vec_holder().base_size()) && + binary_node<T>::valid(); + } + + std::size_t size() const exprtk_override + { + return vec_node_ptr_->vec_holder().size(); + } + + std::size_t base_size() const exprtk_override + { + return vec_node_ptr_->vec_holder().base_size(); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + private: + + vector_node<T>* vec_node_ptr_; + vds_t vds_; + }; + + template <typename T> + class assignment_vecvec_node exprtk_final + : public binary_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_node<T>* vector_node_ptr; + typedef vec_data_store<T> vds_t; + + using binary_node<T>::branch; + + assignment_vecvec_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec0_node_ptr_(0) + , vec1_node_ptr_(0) + , initialised_(false) + , src_is_ivec_(false) + { + if (is_vector_node(branch(0))) + { + vec0_node_ptr_ = static_cast<vector_node<T>*>(branch(0)); + vds() = vec0_node_ptr_->vds(); + } + + if (is_vector_node(branch(1))) + { + vec1_node_ptr_ = static_cast<vector_node<T>*>(branch(1)); + vds_t::match_sizes(vds(),vec1_node_ptr_->vds()); + } + else if (is_ivector_node(branch(1))) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 != (vi = dynamic_cast<vector_interface<T>*>(branch(1)))) + { + vec1_node_ptr_ = vi->vec(); + + if (!vi->side_effect()) + { + vi->vds() = vds(); + src_is_ivec_ = true; + } + else + vds_t::match_sizes(vds(),vi->vds()); + } + } + + initialised_ = + vec0_node_ptr_ && + vec1_node_ptr_ && + (size() <= base_size()) && + (vds_.size() <= base_size()) && + binary_node<T>::valid(); + + assert(valid()); + } + + inline T value() const exprtk_override + { + branch(1)->value(); + + if (src_is_ivec_) + return vec0_node_ptr_->value(); + + T* vec0 = vec0_node_ptr_->vds().data(); + T* vec1 = vec1_node_ptr_->vds().data(); + + loop_unroll::details lud(size()); + const T* upper_bound = vec0 + lud.upper_bound; + + while (vec0 < upper_bound) + { + #define exprtk_loop(N) \ + vec0[N] = vec1[N]; \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec0 += lud.batch_size; + vec1 += lud.batch_size; + } + + switch (lud.remainder) + { + #define case_stmt(N,fall_through) \ + case N : *vec0++ = *vec1++; \ + fall_through \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15, exprtk_fallthrough) case_stmt(14, exprtk_fallthrough) + case_stmt(13, exprtk_fallthrough) case_stmt(12, exprtk_fallthrough) + case_stmt(11, exprtk_fallthrough) case_stmt(10, exprtk_fallthrough) + case_stmt( 9, exprtk_fallthrough) case_stmt( 8, exprtk_fallthrough) + case_stmt( 7, exprtk_fallthrough) case_stmt( 6, exprtk_fallthrough) + case_stmt( 5, exprtk_fallthrough) case_stmt( 4, exprtk_fallthrough) + #endif + case_stmt( 3, exprtk_fallthrough) case_stmt( 2, exprtk_fallthrough) + case_stmt( 1, (void)0;) + } + + #undef exprtk_loop + #undef case_stmt + + return vec0_node_ptr_->value(); + } + + vector_node_ptr vec() exprtk_override + { + return vec0_node_ptr_; + } + + vector_node_ptr vec() const exprtk_override + { + return vec0_node_ptr_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecvecass; + } + + inline bool valid() const exprtk_override + { + return initialised_; + } + + std::size_t size() const exprtk_override + { + return std::min( + vec0_node_ptr_->vec_holder().size(), + vec1_node_ptr_->vec_holder().size()); + } + + std::size_t base_size() const exprtk_override + { + return std::min( + vec0_node_ptr_->vec_holder().base_size(), + vec1_node_ptr_->vec_holder().base_size()); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + private: + + vector_node<T>* vec0_node_ptr_; + vector_node<T>* vec1_node_ptr_; + bool initialised_; + bool src_is_ivec_; + vds_t vds_; + }; + + template <typename T, typename Operation> + class assignment_op_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_op_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , var_node_ptr_(0) + { + if (is_variable_node(branch(0))) + { + var_node_ptr_ = static_cast<variable_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& v = var_node_ptr_->ref(); + v = Operation::process(v,branch(1)->value()); + + return v; + } + + inline bool valid() const exprtk_override + { + return var_node_ptr_ && binary_node<T>::valid(); + } + + private: + + variable_node<T>* var_node_ptr_; + }; + + template <typename T, typename Operation> + class assignment_vec_elem_op_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_vec_elem_op_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec_node_ptr_(0) + { + if (is_vector_elem_node(branch(0))) + { + vec_node_ptr_ = static_cast<vector_elem_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& v = vec_node_ptr_->ref(); + v = Operation::process(v,branch(1)->value()); + + return v; + } + + inline bool valid() const exprtk_override + { + return vec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + vector_elem_node<T>* vec_node_ptr_; + }; + + template <typename T, typename Operation> + class assignment_vec_elem_op_rtc_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_vec_elem_op_rtc_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec_node_ptr_(0) + { + if (is_vector_elem_rtc_node(branch(0))) + { + vec_node_ptr_ = static_cast<vector_elem_rtc_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& v = vec_node_ptr_->ref(); + v = Operation::process(v,branch(1)->value()); + + return v; + } + + inline bool valid() const exprtk_override + { + return vec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + vector_elem_rtc_node<T>* vec_node_ptr_; + }; + + template <typename T, typename Operation> + class assignment_vec_celem_op_rtc_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_vec_celem_op_rtc_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec_node_ptr_(0) + { + if (is_vector_celem_rtc_node(branch(0))) + { + vec_node_ptr_ = static_cast<vector_celem_rtc_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& v = vec_node_ptr_->ref(); + v = Operation::process(v,branch(1)->value()); + + return v; + } + + inline bool valid() const exprtk_override + { + return vec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + vector_celem_rtc_node<T>* vec_node_ptr_; + }; + + template <typename T, typename Operation> + class assignment_rebasevec_elem_op_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_rebasevec_elem_op_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , rbvec_node_ptr_(0) + { + if (is_rebasevector_elem_node(branch(0))) + { + rbvec_node_ptr_ = static_cast<rebasevector_elem_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& v = rbvec_node_ptr_->ref(); + v = Operation::process(v,branch(1)->value()); + + return v; + } + + inline bool valid() const exprtk_override + { + return rbvec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + rebasevector_elem_node<T>* rbvec_node_ptr_; + }; + + template <typename T, typename Operation> + class assignment_rebasevec_celem_op_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_rebasevec_celem_op_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , rbvec_node_ptr_(0) + { + if (is_rebasevector_celem_node(branch(0))) + { + rbvec_node_ptr_ = static_cast<rebasevector_celem_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& v = rbvec_node_ptr_->ref(); + v = Operation::process(v,branch(1)->value()); + + return v; + } + + inline bool valid() const exprtk_override + { + return rbvec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + rebasevector_celem_node<T>* rbvec_node_ptr_; + }; + + template <typename T, typename Operation> + class assignment_rebasevec_elem_op_rtc_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_rebasevec_elem_op_rtc_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , rbvec_node_ptr_(0) + { + if (is_rebasevector_elem_rtc_node(branch(0))) + { + rbvec_node_ptr_ = static_cast<rebasevector_elem_rtc_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& v = rbvec_node_ptr_->ref(); + v = Operation::process(v,branch(1)->value()); + + return v; + } + + inline bool valid() const exprtk_override + { + return rbvec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + rebasevector_elem_rtc_node<T>* rbvec_node_ptr_; + }; + + template <typename T, typename Operation> + class assignment_rebasevec_celem_op_rtc_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + assignment_rebasevec_celem_op_rtc_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , rbvec_node_ptr_(0) + { + if (is_rebasevector_celem_rtc_node(branch(0))) + { + rbvec_node_ptr_ = static_cast<rebasevector_celem_rtc_node<T>*>(branch(0)); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + T& v = rbvec_node_ptr_->ref(); + v = Operation::process(v,branch(1)->value()); + + return v; + } + + inline bool valid() const exprtk_override + { + return rbvec_node_ptr_ && binary_node<T>::valid(); + } + + private: + + rebasevector_celem_rtc_node<T>* rbvec_node_ptr_; + }; + + template <typename T, typename Operation> + class assignment_vec_op_node exprtk_final + : public binary_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_node<T>* vector_node_ptr; + typedef vec_data_store<T> vds_t; + + using binary_node<T>::branch; + + assignment_vec_op_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec_node_ptr_(0) + { + if (is_vector_node(branch(0))) + { + vec_node_ptr_ = static_cast<vector_node<T>*>(branch(0)); + vds() = vec_node_ptr_->vds(); + } + + assert(valid()); + } + + inline T value() const exprtk_override + { + const T v = branch(1)->value(); + + T* vec = vds().data(); + + loop_unroll::details lud(size()); + const T* upper_bound = vec + lud.upper_bound; + + while (vec < upper_bound) + { + #define exprtk_loop(N) \ + Operation::assign(vec[N],v); \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec += lud.batch_size; + } + + switch (lud.remainder) + { + #define case_stmt(N,fall_through) \ + case N : Operation::assign(*vec++,v); \ + fall_through \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15, exprtk_fallthrough) case_stmt(14, exprtk_fallthrough) + case_stmt(13, exprtk_fallthrough) case_stmt(12, exprtk_fallthrough) + case_stmt(11, exprtk_fallthrough) case_stmt(10, exprtk_fallthrough) + case_stmt( 9, exprtk_fallthrough) case_stmt( 8, exprtk_fallthrough) + case_stmt( 7, exprtk_fallthrough) case_stmt( 6, exprtk_fallthrough) + case_stmt( 5, exprtk_fallthrough) case_stmt( 4, exprtk_fallthrough) + #endif + case_stmt( 3, exprtk_fallthrough) case_stmt( 2, exprtk_fallthrough) + case_stmt( 1, (void)0;) + } + + #undef exprtk_loop + #undef case_stmt + + return vec_node_ptr_->value(); + } + + vector_node_ptr vec() const exprtk_override + { + return vec_node_ptr_; + } + + vector_node_ptr vec() exprtk_override + { + return vec_node_ptr_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecopvalass; + } + + inline bool valid() const exprtk_override + { + return + vec_node_ptr_ && + (size() <= base_size()) && + binary_node<T>::valid() ; + } + + std::size_t size() const exprtk_override + { + return vec_node_ptr_->vec_holder().size(); + } + + std::size_t base_size() const exprtk_override + { + return vec_node_ptr_->vec_holder().base_size(); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + bool side_effect() const exprtk_override + { + return true; + } + + private: + + vector_node<T>* vec_node_ptr_; + vds_t vds_; + }; + + template <typename T, typename Operation> + class assignment_vecvec_op_node exprtk_final + : public binary_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_node<T>* vector_node_ptr; + typedef vec_data_store<T> vds_t; + + using binary_node<T>::branch; + + assignment_vecvec_op_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec0_node_ptr_(0) + , vec1_node_ptr_(0) + , initialised_(false) + { + if (is_vector_node(branch(0))) + { + vec0_node_ptr_ = static_cast<vector_node<T>*>(branch(0)); + vds() = vec0_node_ptr_->vds(); + } + + if (is_vector_node(branch(1))) + { + vec1_node_ptr_ = static_cast<vector_node<T>*>(branch(1)); + vec1_node_ptr_->vds() = vds(); + } + else if (is_ivector_node(branch(1))) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 != (vi = dynamic_cast<vector_interface<T>*>(branch(1)))) + { + vec1_node_ptr_ = vi->vec(); + vec1_node_ptr_->vds() = vi->vds(); + } + else + vds_t::match_sizes(vds(),vec1_node_ptr_->vds()); + } + + initialised_ = + vec0_node_ptr_ && + vec1_node_ptr_ && + (size() <= base_size()) && + binary_node<T>::valid(); + + assert(valid()); + } + + inline T value() const exprtk_override + { + branch(0)->value(); + branch(1)->value(); + + T* vec0 = vec0_node_ptr_->vds().data(); + const T* vec1 = vec1_node_ptr_->vds().data(); + + loop_unroll::details lud(size()); + const T* upper_bound = vec0 + lud.upper_bound; + + while (vec0 < upper_bound) + { + #define exprtk_loop(N) \ + vec0[N] = Operation::process(vec0[N], vec1[N]); \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec0 += lud.batch_size; + vec1 += lud.batch_size; + } + + int i = 0; + + switch (lud.remainder) + { + #define case_stmt(N,fall_through) \ + case N : { vec0[i] = Operation::process(vec0[i], vec1[i]); ++i; } \ + fall_through \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15, exprtk_fallthrough) case_stmt(14, exprtk_fallthrough) + case_stmt(13, exprtk_fallthrough) case_stmt(12, exprtk_fallthrough) + case_stmt(11, exprtk_fallthrough) case_stmt(10, exprtk_fallthrough) + case_stmt( 9, exprtk_fallthrough) case_stmt( 8, exprtk_fallthrough) + case_stmt( 7, exprtk_fallthrough) case_stmt( 6, exprtk_fallthrough) + case_stmt( 5, exprtk_fallthrough) case_stmt( 4, exprtk_fallthrough) + #endif + case_stmt( 3, exprtk_fallthrough) case_stmt( 2, exprtk_fallthrough) + case_stmt( 1, (void)0;) + } + + #undef exprtk_loop + #undef case_stmt + + return vec0_node_ptr_->value(); + } + + vector_node_ptr vec() const exprtk_override + { + return vec0_node_ptr_; + } + + vector_node_ptr vec() exprtk_override + { + return vec0_node_ptr_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecopvecass; + } + + inline bool valid() const exprtk_override + { + return initialised_; + } + + std::size_t size() const exprtk_override + { + return std::min( + vec0_node_ptr_->vec_holder().size(), + vec1_node_ptr_->vec_holder().size()); + } + + std::size_t base_size() const exprtk_override + { + return std::min( + vec0_node_ptr_->vec_holder().base_size(), + vec1_node_ptr_->vec_holder().base_size()); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + bool side_effect() const exprtk_override + { + return true; + } + + private: + + vector_node<T>* vec0_node_ptr_; + vector_node<T>* vec1_node_ptr_; + bool initialised_; + vds_t vds_; + }; + + template <typename T> + struct memory_context_t + { + typedef vector_node<T>* vector_node_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + + memory_context_t() + : temp_(0) + , temp_vec_node_(0) + {} + + void clear() + { + delete temp_vec_node_; + delete temp_; + } + + vector_holder_ptr temp_; + vector_node_ptr temp_vec_node_; + }; + + template <typename T> + inline memory_context_t<T> make_memory_context(vector_holder<T>& vec_holder, + vec_data_store<T>& vds) + { + memory_context_t<T> result_ctxt; + + result_ctxt.temp_ = (vec_holder.rebaseable()) ? + new vector_holder<T>(vec_holder,vds) : + new vector_holder<T>(vds) ; + + result_ctxt.temp_vec_node_ = new vector_node<T>(vds,result_ctxt.temp_); + + return result_ctxt; + } + + template <typename T> + inline memory_context_t<T> make_memory_context(vector_holder<T>& vec_holder0, + vector_holder<T>& vec_holder1, + vec_data_store<T>& vds) + { + memory_context_t<T> result_ctxt; + + if (!vec_holder0.rebaseable() && !vec_holder1.rebaseable()) + result_ctxt.temp_ = new vector_holder<T>(vds); + else if (vec_holder0.rebaseable() && !vec_holder1.rebaseable()) + result_ctxt.temp_ = new vector_holder<T>(vec_holder0,vds); + else if (!vec_holder0.rebaseable() && vec_holder1.rebaseable()) + result_ctxt.temp_ = new vector_holder<T>(vec_holder1,vds); + else + { + result_ctxt.temp_ = (vec_holder0.base_size() >= vec_holder1.base_size()) ? + new vector_holder<T>(vec_holder0, vds) : + new vector_holder<T>(vec_holder1, vds) ; + } + + result_ctxt.temp_vec_node_ = new vector_node<T>(vds,result_ctxt.temp_); + + return result_ctxt; + } + + template <typename T, typename Operation> + class vec_binop_vecvec_node exprtk_final + : public binary_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_node<T>* vector_node_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef vec_data_store<T> vds_t; + typedef memory_context_t<T> memory_context; + + using binary_node<T>::branch; + + vec_binop_vecvec_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec0_node_ptr_(0) + , vec1_node_ptr_(0) + , initialised_(false) + { + bool v0_is_ivec = false; + bool v1_is_ivec = false; + + if (is_vector_node(branch(0))) + { + vec0_node_ptr_ = static_cast<vector_node_ptr>(branch(0)); + } + else if (is_ivector_node(branch(0))) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 != (vi = dynamic_cast<vector_interface<T>*>(branch(0)))) + { + vec0_node_ptr_ = vi->vec(); + v0_is_ivec = true; + } + } + + if (is_vector_node(branch(1))) + { + vec1_node_ptr_ = static_cast<vector_node_ptr>(branch(1)); + } + else if (is_ivector_node(branch(1))) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 != (vi = dynamic_cast<vector_interface<T>*>(branch(1)))) + { + vec1_node_ptr_ = vi->vec(); + v1_is_ivec = true; + } + } + + if (vec0_node_ptr_ && vec1_node_ptr_) + { + vector_holder<T>& vec0 = vec0_node_ptr_->vec_holder(); + vector_holder<T>& vec1 = vec1_node_ptr_->vec_holder(); + + if (v0_is_ivec && (vec0.base_size() <= vec1.base_size())) + { + vds_ = vds_t(vec0_node_ptr_->vds()); + } + else if (v1_is_ivec && (vec1.base_size() <= vec0.base_size())) + { + vds_ = vds_t(vec1_node_ptr_->vds()); + } + else + { + vds_ = vds_t(std::min(vec0.base_size(),vec1.base_size())); + } + + memory_context_ = make_memory_context(vec0, vec1, vds()); + + initialised_ = + (size() <= base_size()) && + binary_node<T>::valid(); + } + + assert(valid()); + } + + ~vec_binop_vecvec_node() + { + memory_context_.clear(); + } + + inline T value() const exprtk_override + { + branch(0)->value(); + branch(1)->value(); + + const T* vec0 = vec0_node_ptr_->vds().data(); + const T* vec1 = vec1_node_ptr_->vds().data(); + T* vec2 = vds().data(); + + loop_unroll::details lud(size()); + const T* upper_bound = vec2 + lud.upper_bound; + + while (vec2 < upper_bound) + { + #define exprtk_loop(N) \ + vec2[N] = Operation::process(vec0[N], vec1[N]); \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec0 += lud.batch_size; + vec1 += lud.batch_size; + vec2 += lud.batch_size; + } + + int i = 0; + + switch (lud.remainder) + { + #define case_stmt(N) \ + case N : { vec2[i] = Operation::process(vec0[i], vec1[i]); ++i; } \ + exprtk_fallthrough \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15) case_stmt(14) + case_stmt(13) case_stmt(12) + case_stmt(11) case_stmt(10) + case_stmt( 9) case_stmt( 8) + case_stmt( 7) case_stmt( 6) + case_stmt( 5) case_stmt( 4) + #endif + case_stmt( 3) case_stmt( 2) + case_stmt( 1) + default: break; + } + + #undef exprtk_loop + #undef case_stmt + + return (vds().data())[0]; + } + + vector_node_ptr vec() const exprtk_override + { + return memory_context_.temp_vec_node_; + } + + vector_node_ptr vec() exprtk_override + { + return memory_context_.temp_vec_node_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecvecarith; + } + + inline bool valid() const exprtk_override + { + return initialised_; + } + + std::size_t size() const exprtk_override + { + return std::min( + vec0_node_ptr_->vec_holder().size(), + vec1_node_ptr_->vec_holder().size()); + } + + std::size_t base_size() const exprtk_override + { + return std::min( + vec0_node_ptr_->vec_holder().base_size(), + vec1_node_ptr_->vec_holder().base_size()); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + private: + + vector_node_ptr vec0_node_ptr_; + vector_node_ptr vec1_node_ptr_; + bool initialised_; + vds_t vds_; + memory_context memory_context_; + }; + + template <typename T, typename Operation> + class vec_binop_vecval_node exprtk_final + : public binary_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_node<T>* vector_node_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef vec_data_store<T> vds_t; + typedef memory_context_t<T> memory_context; + + using binary_node<T>::branch; + + vec_binop_vecval_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec0_node_ptr_(0) + { + bool v0_is_ivec = false; + + if (is_vector_node(branch(0))) + { + vec0_node_ptr_ = static_cast<vector_node_ptr>(branch(0)); + } + else if (is_ivector_node(branch(0))) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 != (vi = dynamic_cast<vector_interface<T>*>(branch(0)))) + { + vec0_node_ptr_ = vi->vec(); + v0_is_ivec = true; + } + } + + if (vec0_node_ptr_) + { + if (v0_is_ivec) + vds() = vec0_node_ptr_->vds(); + else + vds() = vds_t(vec0_node_ptr_->base_size()); + + memory_context_ = make_memory_context(vec0_node_ptr_->vec_holder(), vds()); + } + + assert(valid()); + } + + ~vec_binop_vecval_node() + { + memory_context_.clear(); + } + + inline T value() const exprtk_override + { + branch(0)->value(); + const T v = branch(1)->value(); + + const T* vec0 = vec0_node_ptr_->vds().data(); + T* vec1 = vds().data(); + + loop_unroll::details lud(size()); + const T* upper_bound = vec0 + lud.upper_bound; + + while (vec0 < upper_bound) + { + #define exprtk_loop(N) \ + vec1[N] = Operation::process(vec0[N], v); \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec0 += lud.batch_size; + vec1 += lud.batch_size; + } + + int i = 0; + + switch (lud.remainder) + { + #define case_stmt(N,fall_through) \ + case N : { vec1[i] = Operation::process(vec0[i], v); ++i; } \ + fall_through \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15, exprtk_fallthrough) case_stmt(14, exprtk_fallthrough) + case_stmt(13, exprtk_fallthrough) case_stmt(12, exprtk_fallthrough) + case_stmt(11, exprtk_fallthrough) case_stmt(10, exprtk_fallthrough) + case_stmt( 9, exprtk_fallthrough) case_stmt( 8, exprtk_fallthrough) + case_stmt( 7, exprtk_fallthrough) case_stmt( 6, exprtk_fallthrough) + case_stmt( 5, exprtk_fallthrough) case_stmt( 4, exprtk_fallthrough) + #endif + case_stmt( 3, exprtk_fallthrough) case_stmt( 2, exprtk_fallthrough) + case_stmt( 1, (void)0;) + } + + #undef exprtk_loop + #undef case_stmt + + return (vds().data())[0]; + } + + vector_node_ptr vec() const exprtk_override + { + return memory_context_.temp_vec_node_; + } + + vector_node_ptr vec() exprtk_override + { + return memory_context_.temp_vec_node_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecvalarith; + } + + inline bool valid() const exprtk_override + { + return + vec0_node_ptr_ && + (size() <= base_size()) && + binary_node<T>::valid(); + } + + std::size_t size() const exprtk_override + { + return vec0_node_ptr_->size(); + } + + std::size_t base_size() const exprtk_override + { + return vec0_node_ptr_->vec_holder().base_size(); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + private: + + vector_node_ptr vec0_node_ptr_; + vds_t vds_; + memory_context memory_context_; + }; + + template <typename T, typename Operation> + class vec_binop_valvec_node exprtk_final + : public binary_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_node<T>* vector_node_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef vec_data_store<T> vds_t; + typedef memory_context_t<T> memory_context; + + using binary_node<T>::branch; + + vec_binop_valvec_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , vec1_node_ptr_(0) + { + bool v1_is_ivec = false; + + if (is_vector_node(branch(1))) + { + vec1_node_ptr_ = static_cast<vector_node_ptr>(branch(1)); + } + else if (is_ivector_node(branch(1))) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 != (vi = dynamic_cast<vector_interface<T>*>(branch(1)))) + { + vec1_node_ptr_ = vi->vec(); + v1_is_ivec = true; + } + } + + if (vec1_node_ptr_) + { + if (v1_is_ivec) + vds() = vec1_node_ptr_->vds(); + else + vds() = vds_t(vec1_node_ptr_->base_size()); + + memory_context_ = make_memory_context(vec1_node_ptr_->vec_holder(), vds()); + } + + assert(valid()); + } + + ~vec_binop_valvec_node() + { + memory_context_.clear(); + } + + inline T value() const exprtk_override + { + const T v = branch(0)->value(); + branch(1)->value(); + + T* vec0 = vds().data(); + const T* vec1 = vec1_node_ptr_->vds().data(); + + loop_unroll::details lud(size()); + const T* upper_bound = vec0 + lud.upper_bound; + + while (vec0 < upper_bound) + { + #define exprtk_loop(N) \ + vec0[N] = Operation::process(v, vec1[N]); \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec0 += lud.batch_size; + vec1 += lud.batch_size; + } + + int i = 0; + + switch (lud.remainder) + { + #define case_stmt(N,fall_through) \ + case N : { vec0[i] = Operation::process(v, vec1[i]); ++i; } \ + fall_through \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15, exprtk_fallthrough) case_stmt(14, exprtk_fallthrough) + case_stmt(13, exprtk_fallthrough) case_stmt(12, exprtk_fallthrough) + case_stmt(11, exprtk_fallthrough) case_stmt(10, exprtk_fallthrough) + case_stmt( 9, exprtk_fallthrough) case_stmt( 8, exprtk_fallthrough) + case_stmt( 7, exprtk_fallthrough) case_stmt( 6, exprtk_fallthrough) + case_stmt( 5, exprtk_fallthrough) case_stmt( 4, exprtk_fallthrough) + #endif + case_stmt( 3, exprtk_fallthrough) case_stmt( 2, exprtk_fallthrough) + case_stmt( 1, (void)0;) + } + + #undef exprtk_loop + #undef case_stmt + + return (vds().data())[0]; + } + + vector_node_ptr vec() const exprtk_override + { + return memory_context_.temp_vec_node_; + } + + vector_node_ptr vec() exprtk_override + { + return memory_context_.temp_vec_node_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecvalarith; + } + + inline bool valid() const exprtk_override + { + return + vec1_node_ptr_ && + (size() <= base_size()) && + (vds_.size() <= base_size()) && + binary_node<T>::valid(); + } + + std::size_t size() const exprtk_override + { + return vec1_node_ptr_->vec_holder().size(); + } + + std::size_t base_size() const exprtk_override + { + return vec1_node_ptr_->vec_holder().base_size(); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + private: + + vector_node_ptr vec1_node_ptr_; + vds_t vds_; + memory_context memory_context_; + }; + + template <typename T, typename Operation> + class unary_vector_node exprtk_final + : public unary_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef vector_node<T>* vector_node_ptr; + typedef vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef vec_data_store<T> vds_t; + typedef memory_context_t<T> memory_context; + + using expression_node<T>::branch; + + unary_vector_node(const operator_type& opr, expression_ptr branch0) + : unary_node<T>(opr, branch0) + , vec0_node_ptr_(0) + { + bool vec0_is_ivec = false; + + if (is_vector_node(branch(0))) + { + vec0_node_ptr_ = static_cast<vector_node_ptr>(branch(0)); + } + else if (is_ivector_node(branch(0))) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 != (vi = dynamic_cast<vector_interface<T>*>(branch(0)))) + { + vec0_node_ptr_ = vi->vec(); + vec0_is_ivec = true; + } + } + + if (vec0_node_ptr_) + { + if (vec0_is_ivec) + vds_ = vec0_node_ptr_->vds(); + else + vds_ = vds_t(vec0_node_ptr_->base_size()); + + memory_context_ = make_memory_context(vec0_node_ptr_->vec_holder(), vds()); + } + + assert(valid()); + } + + ~unary_vector_node() + { + memory_context_.clear(); + } + + inline T value() const exprtk_override + { + branch()->value(); + + const T* vec0 = vec0_node_ptr_->vds().data(); + T* vec1 = vds().data(); + + loop_unroll::details lud(size()); + const T* upper_bound = vec0 + lud.upper_bound; + + while (vec0 < upper_bound) + { + #define exprtk_loop(N) \ + vec1[N] = Operation::process(vec0[N]); \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec0 += lud.batch_size; + vec1 += lud.batch_size; + } + + int i = 0; + + switch (lud.remainder) + { + #define case_stmt(N) \ + case N : { vec1[i] = Operation::process(vec0[i]); ++i; } \ + exprtk_fallthrough \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15) case_stmt(14) + case_stmt(13) case_stmt(12) + case_stmt(11) case_stmt(10) + case_stmt( 9) case_stmt( 8) + case_stmt( 7) case_stmt( 6) + case_stmt( 5) case_stmt( 4) + #endif + case_stmt( 3) case_stmt( 2) + case_stmt( 1) + default: break; + } + + #undef exprtk_loop + #undef case_stmt + + return (vds().data())[0]; + } + + vector_node_ptr vec() const exprtk_override + { + return memory_context_.temp_vec_node_; + } + + vector_node_ptr vec() exprtk_override + { + return memory_context_.temp_vec_node_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecunaryop; + } + + inline bool valid() const exprtk_override + { + return vec0_node_ptr_ && unary_node<T>::valid(); + } + + std::size_t size() const exprtk_override + { + return vec0_node_ptr_->vec_holder().size(); + } + + std::size_t base_size() const exprtk_override + { + return vec0_node_ptr_->vec_holder().base_size(); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + private: + + vector_node_ptr vec0_node_ptr_; + vds_t vds_; + memory_context memory_context_; + }; + + template <typename T> + class conditional_vector_node exprtk_final + : public expression_node <T> + , public vector_interface<T> + { + public: + + typedef expression_node <T>* expression_ptr; + typedef vector_interface<T>* vec_interface_ptr; + typedef vector_node <T>* vector_node_ptr; + typedef vector_holder <T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + typedef vec_data_store <T> vds_t; + typedef memory_context_t<T> memory_context; + typedef std::pair<expression_ptr,bool> branch_t; + + conditional_vector_node(expression_ptr condition, + expression_ptr consequent, + expression_ptr alternative) + : consequent_node_ptr_ (0) + , alternative_node_ptr_(0) + , temp_vec_node_ (0) + , temp_ (0) + , result_vec_size_ (0) + , initialised_ (false) + { + construct_branch_pair(condition_ , condition ); + construct_branch_pair(consequent_ , consequent ); + construct_branch_pair(alternative_, alternative); + + if (details::is_ivector_node(consequent_.first)) + { + vec_interface_ptr ivec_ptr = dynamic_cast<vec_interface_ptr>(consequent_.first); + + if (0 != ivec_ptr) + { + consequent_node_ptr_ = ivec_ptr->vec(); + } + } + + if (details::is_ivector_node(alternative_.first)) + { + vec_interface_ptr ivec_ptr = dynamic_cast<vec_interface_ptr>(alternative_.first); + + if (0 != ivec_ptr) + { + alternative_node_ptr_ = ivec_ptr->vec(); + } + } + + if (consequent_node_ptr_ && alternative_node_ptr_) + { + const std::size_t vec_size = + std::max(consequent_node_ptr_ ->vec_holder().base_size(), + alternative_node_ptr_->vec_holder().base_size()); + + vds_ = vds_t(vec_size); + memory_context_ = make_memory_context( + consequent_node_ptr_ ->vec_holder(), + alternative_node_ptr_->vec_holder(), + vds()); + + initialised_ = (vec_size > 0); + } + + assert(initialised_); + } + + ~conditional_vector_node() + { + memory_context_.clear(); + } + + inline T value() const exprtk_override + { + T result = T(0); + T* source_vector = 0; + T* result_vector = vds().data(); + + if (is_true(condition_)) + { + result = consequent_.first->value(); + source_vector = consequent_node_ptr_->vds().data(); + result_vec_size_ = consequent_node_ptr_->size(); + } + else + { + result = alternative_.first->value(); + source_vector = alternative_node_ptr_->vds().data(); + result_vec_size_ = alternative_node_ptr_->size(); + } + + for (std::size_t i = 0; i < result_vec_size_; ++i) + { + result_vector[i] = source_vector[i]; + } + + return result; + } + + vector_node_ptr vec() const exprtk_override + { + return memory_context_.temp_vec_node_; + } + + vector_node_ptr vec() exprtk_override + { + return memory_context_.temp_vec_node_; + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vecondition; + } + + inline bool valid() const exprtk_override + { + return + initialised_ && + condition_ .first && condition_ .first->valid() && + consequent_ .first && consequent_ .first->valid() && + alternative_.first && alternative_.first->valid() && + size() <= base_size(); + } + + std::size_t size() const exprtk_override + { + return result_vec_size_; + } + + std::size_t base_size() const exprtk_override + { + return std::min( + consequent_node_ptr_ ->vec_holder().base_size(), + alternative_node_ptr_->vec_holder().base_size()); + } + + vds_t& vds() exprtk_override + { + return vds_; + } + + const vds_t& vds() const exprtk_override + { + return vds_; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(condition_ , node_delete_list); + expression_node<T>::ndb_t::collect(consequent_ , node_delete_list); + expression_node<T>::ndb_t::collect(alternative_ , node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth + (condition_, consequent_, alternative_); + } + + private: + + branch_t condition_; + branch_t consequent_; + branch_t alternative_; + vector_node_ptr consequent_node_ptr_; + vector_node_ptr alternative_node_ptr_; + vector_node_ptr temp_vec_node_; + vector_holder_ptr temp_; + vds_t vds_; + mutable std::size_t result_vec_size_; + bool initialised_; + memory_context memory_context_; + }; + + template <typename T> + class scand_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + scand_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + { + assert(binary_node<T>::valid()); + } + + inline T value() const exprtk_override + { + return ( + std::not_equal_to<T>() + (T(0),branch(0)->value()) && + std::not_equal_to<T>() + (T(0),branch(1)->value()) + ) ? T(1) : T(0); + } + }; + + template <typename T> + class scor_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + using binary_node<T>::branch; + + scor_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + { + assert(binary_node<T>::valid()); + } + + inline T value() const exprtk_override + { + return ( + std::not_equal_to<T>() + (T(0),branch(0)->value()) || + std::not_equal_to<T>() + (T(0),branch(1)->value()) + ) ? T(1) : T(0); + } + }; + + template <typename T, typename IFunction, std::size_t N> + class function_N_node exprtk_final : public expression_node<T> + { + public: + + // Function of N parameters. + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + typedef IFunction ifunction; + + explicit function_N_node(ifunction* func) + : function_((N == func->param_count) ? func : reinterpret_cast<ifunction*>(0)) + , parameter_count_(func->param_count) + , initialised_(false) + {} + + template <std::size_t NumBranches> + bool init_branches(expression_ptr (&b)[NumBranches]) + { + // Needed for incompetent and broken msvc compiler versions + #ifdef _MSC_VER + #pragma warning(push) + #pragma warning(disable: 4127) + #endif + + if (N != NumBranches) + { + return false; + } + + for (std::size_t i = 0; i < NumBranches; ++i) + { + if (b[i] && b[i]->valid()) + branch_[i] = std::make_pair(b[i],branch_deletable(b[i])); + else + return false; + } + + initialised_ = function_; + assert(valid()); + return initialised_; + + #ifdef _MSC_VER + #pragma warning(pop) + #endif + } + + inline bool operator <(const function_N_node<T,IFunction,N>& fn) const + { + return this < (&fn); + } + + inline T value() const exprtk_override + { + // Needed for incompetent and broken msvc compiler versions + #ifdef _MSC_VER + #pragma warning(push) + #pragma warning(disable: 4127) + #endif + + T v[N]; + evaluate_branches<T,N>::execute(v,branch_); + return invoke<T,N>::execute(*function_,v); + + #ifdef _MSC_VER + #pragma warning(pop) + #endif + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_function; + } + + inline bool valid() const exprtk_override + { + return initialised_; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::template compute_node_depth<N>(branch_); + } + + template <typename T_, std::size_t BranchCount> + struct evaluate_branches + { + static inline void execute(T_ (&v)[BranchCount], const branch_t (&b)[BranchCount]) + { + for (std::size_t i = 0; i < BranchCount; ++i) + { + v[i] = b[i].first->value(); + } + } + }; + + template <typename T_> + struct evaluate_branches <T_,6> + { + static inline void execute(T_ (&v)[6], const branch_t (&b)[6]) + { + v[0] = b[0].first->value(); + v[1] = b[1].first->value(); + v[2] = b[2].first->value(); + v[3] = b[3].first->value(); + v[4] = b[4].first->value(); + v[5] = b[5].first->value(); + } + }; + + template <typename T_> + struct evaluate_branches <T_,5> + { + static inline void execute(T_ (&v)[5], const branch_t (&b)[5]) + { + v[0] = b[0].first->value(); + v[1] = b[1].first->value(); + v[2] = b[2].first->value(); + v[3] = b[3].first->value(); + v[4] = b[4].first->value(); + } + }; + + template <typename T_> + struct evaluate_branches <T_,4> + { + static inline void execute(T_ (&v)[4], const branch_t (&b)[4]) + { + v[0] = b[0].first->value(); + v[1] = b[1].first->value(); + v[2] = b[2].first->value(); + v[3] = b[3].first->value(); + } + }; + + template <typename T_> + struct evaluate_branches <T_,3> + { + static inline void execute(T_ (&v)[3], const branch_t (&b)[3]) + { + v[0] = b[0].first->value(); + v[1] = b[1].first->value(); + v[2] = b[2].first->value(); + } + }; + + template <typename T_> + struct evaluate_branches <T_,2> + { + static inline void execute(T_ (&v)[2], const branch_t (&b)[2]) + { + v[0] = b[0].first->value(); + v[1] = b[1].first->value(); + } + }; + + template <typename T_> + struct evaluate_branches <T_,1> + { + static inline void execute(T_ (&v)[1], const branch_t (&b)[1]) + { + v[0] = b[0].first->value(); + } + }; + + template <typename T_, std::size_t ParamCount> + struct invoke { static inline T execute(ifunction&, branch_t (&)[ParamCount]) { return std::numeric_limits<T_>::quiet_NaN(); } }; + + template <typename T_> + struct invoke<T_,20> + { + static inline T_ execute(ifunction& f, T_ (&v)[20]) + { return f(v[0],v[1],v[2],v[3],v[4],v[5],v[6],v[7],v[8],v[9],v[10],v[11],v[12],v[13],v[14],v[15],v[16],v[17],v[18],v[19]); } + }; + + template <typename T_> + struct invoke<T_,19> + { + static inline T_ execute(ifunction& f, T_ (&v)[19]) + { return f(v[0],v[1],v[2],v[3],v[4],v[5],v[6],v[7],v[8],v[9],v[10],v[11],v[12],v[13],v[14],v[15],v[16],v[17],v[18]); } + }; + + template <typename T_> + struct invoke<T_,18> + { + static inline T_ execute(ifunction& f, T_ (&v)[18]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12], v[13], v[14], v[15], v[16], v[17]); } + }; + + template <typename T_> + struct invoke<T_,17> + { + static inline T_ execute(ifunction& f, T_ (&v)[17]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12], v[13], v[14], v[15], v[16]); } + }; + + template <typename T_> + struct invoke<T_,16> + { + static inline T_ execute(ifunction& f, T_ (&v)[16]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12], v[13], v[14], v[15]); } + }; + + template <typename T_> + struct invoke<T_,15> + { + static inline T_ execute(ifunction& f, T_ (&v)[15]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12], v[13], v[14]); } + }; + + template <typename T_> + struct invoke<T_,14> + { + static inline T_ execute(ifunction& f, T_ (&v)[14]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12], v[13]); } + }; + + template <typename T_> + struct invoke<T_,13> + { + static inline T_ execute(ifunction& f, T_ (&v)[13]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11], v[12]); } + }; + + template <typename T_> + struct invoke<T_,12> + { + static inline T_ execute(ifunction& f, T_ (&v)[12]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11]); } + }; + + template <typename T_> + struct invoke<T_,11> + { + static inline T_ execute(ifunction& f, T_ (&v)[11]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10]); } + }; + + template <typename T_> + struct invoke<T_,10> + { + static inline T_ execute(ifunction& f, T_ (&v)[10]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9]); } + }; + + template <typename T_> + struct invoke<T_,9> + { + static inline T_ execute(ifunction& f, T_ (&v)[9]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8]); } + }; + + template <typename T_> + struct invoke<T_,8> + { + static inline T_ execute(ifunction& f, T_ (&v)[8]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7]); } + }; + + template <typename T_> + struct invoke<T_,7> + { + static inline T_ execute(ifunction& f, T_ (&v)[7]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5], v[6]); } + }; + + template <typename T_> + struct invoke<T_,6> + { + static inline T_ execute(ifunction& f, T_ (&v)[6]) + { return f(v[0], v[1], v[2], v[3], v[4], v[5]); } + }; + + template <typename T_> + struct invoke<T_,5> + { + static inline T_ execute(ifunction& f, T_ (&v)[5]) + { return f(v[0], v[1], v[2], v[3], v[4]); } + }; + + template <typename T_> + struct invoke<T_,4> + { + static inline T_ execute(ifunction& f, T_ (&v)[4]) + { return f(v[0], v[1], v[2], v[3]); } + }; + + template <typename T_> + struct invoke<T_,3> + { + static inline T_ execute(ifunction& f, T_ (&v)[3]) + { return f(v[0], v[1], v[2]); } + }; + + template <typename T_> + struct invoke<T_,2> + { + static inline T_ execute(ifunction& f, T_ (&v)[2]) + { return f(v[0], v[1]); } + }; + + template <typename T_> + struct invoke<T_,1> + { + static inline T_ execute(ifunction& f, T_ (&v)[1]) + { return f(v[0]); } + }; + + private: + + ifunction* function_; + std::size_t parameter_count_; + branch_t branch_[N]; + bool initialised_; + }; + + template <typename T, typename IFunction> + class function_N_node<T,IFunction,0> exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef IFunction ifunction; + + explicit function_N_node(ifunction* func) + : function_((0 == func->param_count) ? func : reinterpret_cast<ifunction*>(0)) + { + assert(valid()); + } + + inline bool operator <(const function_N_node<T,IFunction,0>& fn) const + { + return this < (&fn); + } + + inline T value() const exprtk_override + { + return (*function_)(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_function; + } + + inline bool valid() const exprtk_override + { + return function_; + } + + private: + + ifunction* function_; + }; + + template <typename T, typename VarArgFunction> + class vararg_function_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + + vararg_function_node(VarArgFunction* func, + const std::vector<expression_ptr>& arg_list) + : function_(func) + , arg_list_(arg_list) + { + value_list_.resize(arg_list.size(),std::numeric_limits<T>::quiet_NaN()); + assert(valid()); + } + + inline bool operator <(const vararg_function_node<T,VarArgFunction>& fn) const + { + return this < (&fn); + } + + inline T value() const exprtk_override + { + populate_value_list(); + return (*function_)(value_list_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_vafunction; + } + + inline bool valid() const exprtk_override + { + return function_; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + for (std::size_t i = 0; i < arg_list_.size(); ++i) + { + if (arg_list_[i] && !details::is_variable_node(arg_list_[i])) + { + node_delete_list.push_back(&arg_list_[i]); + } + } + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(arg_list_); + } + + private: + + inline void populate_value_list() const + { + for (std::size_t i = 0; i < arg_list_.size(); ++i) + { + value_list_[i] = arg_list_[i]->value(); + } + } + + VarArgFunction* function_; + std::vector<expression_ptr> arg_list_; + mutable std::vector<T> value_list_; + }; + + template <typename T, typename GenericFunction> + class generic_function_node : public expression_node<T> + { + public: + + typedef type_store<T> type_store_t; + typedef expression_node<T>* expression_ptr; + typedef variable_node<T> variable_node_t; + typedef vector_node<T> vector_node_t; + typedef variable_node_t* variable_node_ptr_t; + typedef vector_node_t* vector_node_ptr_t; + typedef range_interface<T> range_interface_t; + typedef range_data_type<T> range_data_type_t; + typedef typename range_interface<T>::range_t range_t; + + typedef std::pair<expression_ptr,bool> branch_t; + typedef vector_holder<T>* vh_t; + typedef vector_view<T>* vecview_t; + + typedef std::vector<T> tmp_vs_t; + typedef std::vector<type_store_t> typestore_list_t; + typedef std::vector<range_data_type_t> range_list_t; + + explicit generic_function_node(const std::vector<expression_ptr>& arg_list, + GenericFunction* func = reinterpret_cast<GenericFunction*>(0)) + : function_(func) + , arg_list_(arg_list) + {} + + virtual ~generic_function_node() + { + for (std::size_t i = 0; i < vv_list_.size(); ++i) + { + vecview_t& vv = vv_list_[i]; + if (vv && typestore_list_[i].vec_data) + { + vv->remove_ref(&typestore_list_[i].vec_data); + typestore_list_[i].vec_data = 0; + } + } + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override exprtk_final + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + virtual bool init_branches() + { + expr_as_vec1_store_.resize(arg_list_.size(), T(0) ); + typestore_list_ .resize(arg_list_.size(), type_store_t() ); + range_list_ .resize(arg_list_.size(), range_data_type_t()); + branch_ .resize(arg_list_.size(), branch_t(reinterpret_cast<expression_ptr>(0),false)); + vv_list_ .resize(arg_list_.size(), vecview_t(0)); + + for (std::size_t i = 0; i < arg_list_.size(); ++i) + { + type_store_t& ts = typestore_list_[i]; + + if (0 == arg_list_[i]) + return false; + else if (is_ivector_node(arg_list_[i])) + { + vector_interface<T>* vi = reinterpret_cast<vector_interface<T>*>(0); + + if (0 == (vi = dynamic_cast<vector_interface<T>*>(arg_list_[i]))) + return false; + + ts.size = vi->size(); + ts.data = vi->vds().data(); + ts.type = type_store_t::e_vector; + + if ( + vi->vec()->vec_holder().rebaseable() && + vi->vec()->vec_holder().rebaseable_instance() + ) + { + vv_list_[i] = vi->vec()->vec_holder().rebaseable_instance(); + vv_list_[i]->set_ref(&ts.vec_data); + } + } + #ifndef exprtk_disable_string_capabilities + else if (is_generally_string_node(arg_list_[i])) + { + string_base_node<T>* sbn = reinterpret_cast<string_base_node<T>*>(0); + + if (0 == (sbn = dynamic_cast<string_base_node<T>*>(arg_list_[i]))) + return false; + + ts.size = sbn->size(); + ts.data = reinterpret_cast<void*>(const_cast<char_ptr>(sbn->base())); + ts.type = type_store_t::e_string; + + range_list_[i].data = ts.data; + range_list_[i].size = ts.size; + range_list_[i].type_size = sizeof(char); + range_list_[i].str_node = sbn; + + range_interface_t* ri = reinterpret_cast<range_interface_t*>(0); + + if (0 == (ri = dynamic_cast<range_interface_t*>(arg_list_[i]))) + return false; + + const range_t& rp = ri->range_ref(); + + if ( + rp.const_range() && + is_const_string_range_node(arg_list_[i]) + ) + { + ts.size = rp.const_size(); + ts.data = static_cast<char_ptr>(ts.data) + rp.n0_c.second; + range_list_[i].range = reinterpret_cast<range_t*>(0); + } + else + { + range_list_[i].range = &(ri->range_ref()); + range_param_list_.push_back(i); + } + } + #endif + else if (is_variable_node(arg_list_[i])) + { + variable_node_ptr_t var = variable_node_ptr_t(0); + + if (0 == (var = dynamic_cast<variable_node_ptr_t>(arg_list_[i]))) + return false; + + ts.size = 1; + ts.data = &var->ref(); + ts.type = type_store_t::e_scalar; + } + else + { + ts.size = 1; + ts.data = reinterpret_cast<void*>(&expr_as_vec1_store_[i]); + ts.type = type_store_t::e_scalar; + } + + branch_[i] = std::make_pair(arg_list_[i],branch_deletable(arg_list_[i])); + } + + return true; + } + + inline bool operator <(const generic_function_node<T,GenericFunction>& fn) const + { + return this < (&fn); + } + + inline T value() const exprtk_override + { + if (populate_value_list()) + { + typedef typename GenericFunction::parameter_list_t parameter_list_t; + + return (*function_)(parameter_list_t(typestore_list_)); + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_genfunction; + } + + inline bool valid() const exprtk_override + { + return function_; + } + + protected: + + inline virtual bool populate_value_list() const + { + for (std::size_t i = 0; i < branch_.size(); ++i) + { + expr_as_vec1_store_[i] = branch_[i].first->value(); + } + + if (!range_param_list_.empty()) + { + assert(range_param_list_.size() <= branch_.size()); + + for (std::size_t i = 0; i < range_param_list_.size(); ++i) + { + const std::size_t index = range_param_list_[i]; + range_data_type_t& rdt = range_list_[index]; + + const range_t& rp = (*rdt.range); + std::size_t r0 = 0; + std::size_t r1 = 0; + + const std::size_t data_size = + #ifndef exprtk_disable_string_capabilities + rdt.str_node ? rdt.str_node->size() : rdt.size; + #else + rdt.size; + #endif + + if (!rp(r0, r1, data_size)) + { + return false; + } + + type_store_t& ts = typestore_list_[index]; + + ts.size = rp.cache_size(); + #ifndef exprtk_disable_string_capabilities + if (ts.type == type_store_t::e_string) + ts.data = const_cast<char_ptr>(rdt.str_node->base()) + rp.cache.first; + else + #endif + ts.data = static_cast<char_ptr>(rdt.data) + (rp.cache.first * rdt.type_size); + } + } + + return true; + } + + GenericFunction* function_; + mutable typestore_list_t typestore_list_; + + private: + + std::vector<expression_ptr> arg_list_; + std::vector<branch_t> branch_; + std::vector<vecview_t> vv_list_; + mutable tmp_vs_t expr_as_vec1_store_; + mutable range_list_t range_list_; + std::vector<std::size_t> range_param_list_; + }; + + #ifndef exprtk_disable_string_capabilities + template <typename T, typename StringFunction> + class string_function_node : public generic_function_node<T,StringFunction> + , public string_base_node<T> + , public range_interface <T> + { + public: + + typedef generic_function_node<T,StringFunction> gen_function_t; + typedef typename range_interface<T>::range_t range_t; + + string_function_node(StringFunction* func, + const std::vector<typename gen_function_t::expression_ptr>& arg_list) + : gen_function_t(arg_list,func) + { + range_.n0_c = std::make_pair<bool,std::size_t>(true,0); + range_.n1_c = std::make_pair<bool,std::size_t>(true,0); + range_.cache.first = range_.n0_c.second; + range_.cache.second = range_.n1_c.second; + assert(valid()); + } + + inline bool operator <(const string_function_node<T,StringFunction>& fn) const + { + return this < (&fn); + } + + inline T value() const exprtk_override + { + if (gen_function_t::populate_value_list()) + { + typedef typename StringFunction::parameter_list_t parameter_list_t; + + const T result = + (*gen_function_t::function_) + ( + ret_string_, + parameter_list_t(gen_function_t::typestore_list_) + ); + + range_.n1_c.second = ret_string_.size(); + range_.cache.second = range_.n1_c.second; + + return result; + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strfunction; + } + + inline bool valid() const exprtk_override + { + return gen_function_t::function_; + } + + std::string str() const exprtk_override + { + return ret_string_; + } + + char_cptr base() const exprtk_override + { + return &ret_string_[0]; + } + + std::size_t size() const exprtk_override + { + return ret_string_.size(); + } + + range_t& range_ref() exprtk_override + { + return range_; + } + + const range_t& range_ref() const exprtk_override + { + return range_; + } + + protected: + + mutable range_t range_; + mutable std::string ret_string_; + }; + #endif + + template <typename T, typename GenericFunction> + class multimode_genfunction_node : public generic_function_node<T,GenericFunction> + { + public: + + typedef generic_function_node<T,GenericFunction> gen_function_t; + typedef typename gen_function_t::range_t range_t; + + multimode_genfunction_node(GenericFunction* func, + const std::size_t& param_seq_index, + const std::vector<typename gen_function_t::expression_ptr>& arg_list) + : gen_function_t(arg_list,func) + , param_seq_index_(param_seq_index) + {} + + inline T value() const exprtk_override + { + assert(gen_function_t::valid()); + + if (gen_function_t::populate_value_list()) + { + typedef typename GenericFunction::parameter_list_t parameter_list_t; + + return + (*gen_function_t::function_) + ( + param_seq_index_, + parameter_list_t(gen_function_t::typestore_list_) + ); + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override exprtk_final + { + return expression_node<T>::e_genfunction; + } + + private: + + std::size_t param_seq_index_; + }; + + #ifndef exprtk_disable_string_capabilities + template <typename T, typename StringFunction> + class multimode_strfunction_node exprtk_final : public string_function_node<T,StringFunction> + { + public: + + typedef string_function_node<T,StringFunction> str_function_t; + typedef typename str_function_t::range_t range_t; + + multimode_strfunction_node(StringFunction* func, + const std::size_t& param_seq_index, + const std::vector<typename str_function_t::expression_ptr>& arg_list) + : str_function_t(func,arg_list) + , param_seq_index_(param_seq_index) + {} + + inline T value() const exprtk_override + { + if (str_function_t::populate_value_list()) + { + typedef typename StringFunction::parameter_list_t parameter_list_t; + + const T result = + (*str_function_t::function_) + ( + param_seq_index_, + str_function_t::ret_string_, + parameter_list_t(str_function_t::typestore_list_) + ); + + str_function_t::range_.n1_c.second = str_function_t::ret_string_.size(); + str_function_t::range_.cache.second = str_function_t::range_.n1_c.second; + + return result; + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_strfunction; + } + + private: + + const std::size_t param_seq_index_; + }; + #endif + + class return_exception {}; + + template <typename T> + class null_igenfunc + { + public: + + virtual ~null_igenfunc() + {} + + typedef type_store<T> generic_type; + typedef typename generic_type::parameter_list parameter_list_t; + + inline virtual T operator() (parameter_list_t) + { + return std::numeric_limits<T>::quiet_NaN(); + } + }; + + #ifndef exprtk_disable_return_statement + template <typename T> + class return_node exprtk_final : public generic_function_node<T,null_igenfunc<T> > + { + public: + + typedef results_context<T> results_context_t; + typedef null_igenfunc<T> igeneric_function_t; + typedef igeneric_function_t* igeneric_function_ptr; + typedef generic_function_node<T,igeneric_function_t> gen_function_t; + + return_node(const std::vector<typename gen_function_t::expression_ptr>& arg_list, + results_context_t& rc) + : gen_function_t (arg_list) + , results_context_(&rc) + { + assert(valid()); + } + + inline T value() const exprtk_override + { + if (gen_function_t::populate_value_list()) + { + typedef typename type_store<T>::parameter_list parameter_list_t; + + results_context_-> + assign(parameter_list_t(gen_function_t::typestore_list_)); + + throw return_exception(); + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_return; + } + + inline bool valid() const exprtk_override + { + return results_context_; + } + + private: + + results_context_t* results_context_; + }; + + template <typename T> + class return_envelope_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef results_context<T> results_context_t; + typedef std::pair<expression_ptr,bool> branch_t; + + return_envelope_node(expression_ptr body, results_context_t& rc) + : results_context_(&rc ) + , return_invoked_ (false) + { + construct_branch_pair(body_, body); + assert(valid()); + } + + inline T value() const exprtk_override + { + try + { + return_invoked_ = false; + results_context_->clear(); + + return body_.first->value(); + } + catch(const return_exception&) + { + return_invoked_ = true; + + return std::numeric_limits<T>::quiet_NaN(); + } + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_retenv; + } + + inline bool valid() const exprtk_override + { + return results_context_ && body_.first; + } + + inline bool* retinvk_ptr() + { + return &return_invoked_; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(body_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(body_); + } + + private: + + results_context_t* results_context_; + mutable bool return_invoked_; + branch_t body_; + }; + #endif + + #define exprtk_define_unary_op(OpName) \ + template <typename T> \ + struct OpName##_op \ + { \ + typedef typename functor_t<T>::Type Type; \ + typedef typename expression_node<T>::node_type node_t; \ + \ + static inline T process(Type v) \ + { \ + return numeric:: OpName (v); \ + } \ + \ + static inline node_t type() \ + { \ + return expression_node<T>::e_##OpName; \ + } \ + \ + static inline details::operator_type operation() \ + { \ + return details::e_##OpName; \ + } \ + }; \ + + exprtk_define_unary_op(abs ) + exprtk_define_unary_op(acos ) + exprtk_define_unary_op(acosh) + exprtk_define_unary_op(asin ) + exprtk_define_unary_op(asinh) + exprtk_define_unary_op(atan ) + exprtk_define_unary_op(atanh) + exprtk_define_unary_op(ceil ) + exprtk_define_unary_op(cos ) + exprtk_define_unary_op(cosh ) + exprtk_define_unary_op(cot ) + exprtk_define_unary_op(csc ) + exprtk_define_unary_op(d2g ) + exprtk_define_unary_op(d2r ) + exprtk_define_unary_op(erf ) + exprtk_define_unary_op(erfc ) + exprtk_define_unary_op(exp ) + exprtk_define_unary_op(expm1) + exprtk_define_unary_op(floor) + exprtk_define_unary_op(frac ) + exprtk_define_unary_op(g2d ) + exprtk_define_unary_op(log ) + exprtk_define_unary_op(log10) + exprtk_define_unary_op(log2 ) + exprtk_define_unary_op(log1p) + exprtk_define_unary_op(ncdf ) + exprtk_define_unary_op(neg ) + exprtk_define_unary_op(notl ) + exprtk_define_unary_op(pos ) + exprtk_define_unary_op(r2d ) + exprtk_define_unary_op(round) + exprtk_define_unary_op(sec ) + exprtk_define_unary_op(sgn ) + exprtk_define_unary_op(sin ) + exprtk_define_unary_op(sinc ) + exprtk_define_unary_op(sinh ) + exprtk_define_unary_op(sqrt ) + exprtk_define_unary_op(tan ) + exprtk_define_unary_op(tanh ) + exprtk_define_unary_op(trunc) + #undef exprtk_define_unary_op + + template <typename T> + struct opr_base + { + typedef typename details::functor_t<T>::Type Type; + typedef typename details::functor_t<T>::RefType RefType; + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::qfunc_t quaternary_functor_t; + typedef typename functor_t::tfunc_t trinary_functor_t; + typedef typename functor_t::bfunc_t binary_functor_t; + typedef typename functor_t::ufunc_t unary_functor_t; + }; + + template <typename T> + struct add_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + typedef typename opr_base<T>::RefType RefType; + + static inline T process(Type t1, Type t2) { return t1 + t2; } + static inline T process(Type t1, Type t2, Type t3) { return t1 + t2 + t3; } + static inline void assign(RefType t1, Type t2) { t1 += t2; } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_add; } + static inline details::operator_type operation() { return details::e_add; } + }; + + template <typename T> + struct mul_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + typedef typename opr_base<T>::RefType RefType; + + static inline T process(Type t1, Type t2) { return t1 * t2; } + static inline T process(Type t1, Type t2, Type t3) { return t1 * t2 * t3; } + static inline void assign(RefType t1, Type t2) { t1 *= t2; } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_mul; } + static inline details::operator_type operation() { return details::e_mul; } + }; + + template <typename T> + struct sub_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + typedef typename opr_base<T>::RefType RefType; + + static inline T process(Type t1, Type t2) { return t1 - t2; } + static inline T process(Type t1, Type t2, Type t3) { return t1 - t2 - t3; } + static inline void assign(RefType t1, Type t2) { t1 -= t2; } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_sub; } + static inline details::operator_type operation() { return details::e_sub; } + }; + + template <typename T> + struct div_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + typedef typename opr_base<T>::RefType RefType; + + static inline T process(Type t1, Type t2) { return t1 / t2; } + static inline T process(Type t1, Type t2, Type t3) { return t1 / t2 / t3; } + static inline void assign(RefType t1, Type t2) { t1 /= t2; } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_div; } + static inline details::operator_type operation() { return details::e_div; } + }; + + template <typename T> + struct mod_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + typedef typename opr_base<T>::RefType RefType; + + static inline T process(Type t1, Type t2) { return numeric::modulus<T>(t1,t2); } + static inline void assign(RefType t1, Type t2) { t1 = numeric::modulus<T>(t1,t2); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_mod; } + static inline details::operator_type operation() { return details::e_mod; } + }; + + template <typename T> + struct pow_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + typedef typename opr_base<T>::RefType RefType; + + static inline T process(Type t1, Type t2) { return numeric::pow<T>(t1,t2); } + static inline void assign(RefType t1, Type t2) { t1 = numeric::pow<T>(t1,t2); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_pow; } + static inline details::operator_type operation() { return details::e_pow; } + }; + + template <typename T> + struct lt_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return ((t1 < t2) ? T(1) : T(0)); } + static inline T process(const std::string& t1, const std::string& t2) { return ((t1 < t2) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_lt; } + static inline details::operator_type operation() { return details::e_lt; } + }; + + template <typename T> + struct lte_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return ((t1 <= t2) ? T(1) : T(0)); } + static inline T process(const std::string& t1, const std::string& t2) { return ((t1 <= t2) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_lte; } + static inline details::operator_type operation() { return details::e_lte; } + }; + + template <typename T> + struct gt_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return ((t1 > t2) ? T(1) : T(0)); } + static inline T process(const std::string& t1, const std::string& t2) { return ((t1 > t2) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_gt; } + static inline details::operator_type operation() { return details::e_gt; } + }; + + template <typename T> + struct gte_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return ((t1 >= t2) ? T(1) : T(0)); } + static inline T process(const std::string& t1, const std::string& t2) { return ((t1 >= t2) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_gte; } + static inline details::operator_type operation() { return details::e_gte; } + }; + + template <typename T> + struct eq_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + static inline T process(Type t1, Type t2) { return (std::equal_to<T>()(t1,t2) ? T(1) : T(0)); } + static inline T process(const std::string& t1, const std::string& t2) { return ((t1 == t2) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_eq; } + static inline details::operator_type operation() { return details::e_eq; } + }; + + template <typename T> + struct equal_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return numeric::equal(t1,t2); } + static inline T process(const std::string& t1, const std::string& t2) { return ((t1 == t2) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_eq; } + static inline details::operator_type operation() { return details::e_equal; } + }; + + template <typename T> + struct ne_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return (std::not_equal_to<T>()(t1,t2) ? T(1) : T(0)); } + static inline T process(const std::string& t1, const std::string& t2) { return ((t1 != t2) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_ne; } + static inline details::operator_type operation() { return details::e_ne; } + }; + + template <typename T> + struct and_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return (details::is_true(t1) && details::is_true(t2)) ? T(1) : T(0); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_and; } + static inline details::operator_type operation() { return details::e_and; } + }; + + template <typename T> + struct nand_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return (details::is_true(t1) && details::is_true(t2)) ? T(0) : T(1); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_nand; } + static inline details::operator_type operation() { return details::e_nand; } + }; + + template <typename T> + struct or_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return (details::is_true(t1) || details::is_true(t2)) ? T(1) : T(0); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_or; } + static inline details::operator_type operation() { return details::e_or; } + }; + + template <typename T> + struct nor_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return (details::is_true(t1) || details::is_true(t2)) ? T(0) : T(1); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_nor; } + static inline details::operator_type operation() { return details::e_nor; } + }; + + template <typename T> + struct xor_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return numeric::xor_opr<T>(t1,t2); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_nor; } + static inline details::operator_type operation() { return details::e_xor; } + }; + + template <typename T> + struct xnor_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(Type t1, Type t2) { return numeric::xnor_opr<T>(t1,t2); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_nor; } + static inline details::operator_type operation() { return details::e_xnor; } + }; + + template <typename T> + struct in_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(const T&, const T&) { return std::numeric_limits<T>::quiet_NaN(); } + static inline T process(const std::string& t1, const std::string& t2) { return ((std::string::npos != t2.find(t1)) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_in; } + static inline details::operator_type operation() { return details::e_in; } + }; + + template <typename T> + struct like_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(const T&, const T&) { return std::numeric_limits<T>::quiet_NaN(); } + static inline T process(const std::string& t1, const std::string& t2) { return (details::wc_match(t2,t1) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_like; } + static inline details::operator_type operation() { return details::e_like; } + }; + + template <typename T> + struct ilike_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(const T&, const T&) { return std::numeric_limits<T>::quiet_NaN(); } + static inline T process(const std::string& t1, const std::string& t2) { return (details::wc_imatch(t2,t1) ? T(1) : T(0)); } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_ilike; } + static inline details::operator_type operation() { return details::e_ilike; } + }; + + template <typename T> + struct inrange_op : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + static inline T process(const T& t0, const T& t1, const T& t2) { return ((t0 <= t1) && (t1 <= t2)) ? T(1) : T(0); } + static inline T process(const std::string& t0, const std::string& t1, const std::string& t2) + { + return ((t0 <= t1) && (t1 <= t2)) ? T(1) : T(0); + } + static inline typename expression_node<T>::node_type type() { return expression_node<T>::e_inranges; } + static inline details::operator_type operation() { return details::e_inrange; } + }; + + template <typename T> + inline T value(details::expression_node<T>* n) + { + return n->value(); + } + + template <typename T> + inline T value(std::pair<details::expression_node<T>*,bool> n) + { + return n.first->value(); + } + + template <typename T> + inline T value(const T* t) + { + return (*t); + } + + template <typename T> + inline T value(const T& t) + { + return t; + } + + template <typename T> + struct vararg_add_op exprtk_final : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + template <typename Type, + typename Allocator, + template <typename, typename> class Sequence> + static inline T process(const Sequence<Type,Allocator>& arg_list) + { + switch (arg_list.size()) + { + case 0 : return T(0); + case 1 : return process_1(arg_list); + case 2 : return process_2(arg_list); + case 3 : return process_3(arg_list); + case 4 : return process_4(arg_list); + case 5 : return process_5(arg_list); + default : + { + T result = T(0); + + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + result += value(arg_list[i]); + } + + return result; + } + } + } + + template <typename Sequence> + static inline T process_1(const Sequence& arg_list) + { + return value(arg_list[0]); + } + + template <typename Sequence> + static inline T process_2(const Sequence& arg_list) + { + return value(arg_list[0]) + value(arg_list[1]); + } + + template <typename Sequence> + static inline T process_3(const Sequence& arg_list) + { + return value(arg_list[0]) + value(arg_list[1]) + + value(arg_list[2]) ; + } + + template <typename Sequence> + static inline T process_4(const Sequence& arg_list) + { + return value(arg_list[0]) + value(arg_list[1]) + + value(arg_list[2]) + value(arg_list[3]) ; + } + + template <typename Sequence> + static inline T process_5(const Sequence& arg_list) + { + return value(arg_list[0]) + value(arg_list[1]) + + value(arg_list[2]) + value(arg_list[3]) + + value(arg_list[4]) ; + } + }; + + template <typename T> + struct vararg_mul_op exprtk_final : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + template <typename Type, + typename Allocator, + template <typename, typename> class Sequence> + static inline T process(const Sequence<Type,Allocator>& arg_list) + { + switch (arg_list.size()) + { + case 0 : return T(0); + case 1 : return process_1(arg_list); + case 2 : return process_2(arg_list); + case 3 : return process_3(arg_list); + case 4 : return process_4(arg_list); + case 5 : return process_5(arg_list); + default : + { + T result = T(value(arg_list[0])); + + for (std::size_t i = 1; i < arg_list.size(); ++i) + { + result *= value(arg_list[i]); + } + + return result; + } + } + } + + template <typename Sequence> + static inline T process_1(const Sequence& arg_list) + { + return value(arg_list[0]); + } + + template <typename Sequence> + static inline T process_2(const Sequence& arg_list) + { + return value(arg_list[0]) * value(arg_list[1]); + } + + template <typename Sequence> + static inline T process_3(const Sequence& arg_list) + { + return value(arg_list[0]) * value(arg_list[1]) * + value(arg_list[2]) ; + } + + template <typename Sequence> + static inline T process_4(const Sequence& arg_list) + { + return value(arg_list[0]) * value(arg_list[1]) * + value(arg_list[2]) * value(arg_list[3]) ; + } + + template <typename Sequence> + static inline T process_5(const Sequence& arg_list) + { + return value(arg_list[0]) * value(arg_list[1]) * + value(arg_list[2]) * value(arg_list[3]) * + value(arg_list[4]) ; + } + }; + + template <typename T> + struct vararg_avg_op exprtk_final : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + template <typename Type, + typename Allocator, + template <typename, typename> class Sequence> + static inline T process(const Sequence<Type,Allocator>& arg_list) + { + switch (arg_list.size()) + { + case 0 : return T(0); + case 1 : return process_1(arg_list); + case 2 : return process_2(arg_list); + case 3 : return process_3(arg_list); + case 4 : return process_4(arg_list); + case 5 : return process_5(arg_list); + default : return vararg_add_op<T>::process(arg_list) / T(arg_list.size()); + } + } + + template <typename Sequence> + static inline T process_1(const Sequence& arg_list) + { + return value(arg_list[0]); + } + + template <typename Sequence> + static inline T process_2(const Sequence& arg_list) + { + return (value(arg_list[0]) + value(arg_list[1])) / T(2); + } + + template <typename Sequence> + static inline T process_3(const Sequence& arg_list) + { + return (value(arg_list[0]) + value(arg_list[1]) + value(arg_list[2])) / T(3); + } + + template <typename Sequence> + static inline T process_4(const Sequence& arg_list) + { + return (value(arg_list[0]) + value(arg_list[1]) + + value(arg_list[2]) + value(arg_list[3])) / T(4); + } + + template <typename Sequence> + static inline T process_5(const Sequence& arg_list) + { + return (value(arg_list[0]) + value(arg_list[1]) + + value(arg_list[2]) + value(arg_list[3]) + + value(arg_list[4])) / T(5); + } + }; + + template <typename T> + struct vararg_min_op exprtk_final : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + template <typename Type, + typename Allocator, + template <typename, typename> class Sequence> + static inline T process(const Sequence<Type,Allocator>& arg_list) + { + switch (arg_list.size()) + { + case 0 : return T(0); + case 1 : return process_1(arg_list); + case 2 : return process_2(arg_list); + case 3 : return process_3(arg_list); + case 4 : return process_4(arg_list); + case 5 : return process_5(arg_list); + default : + { + T result = T(value(arg_list[0])); + + for (std::size_t i = 1; i < arg_list.size(); ++i) + { + const T v = value(arg_list[i]); + + if (v < result) + result = v; + } + + return result; + } + } + } + + template <typename Sequence> + static inline T process_1(const Sequence& arg_list) + { + return value(arg_list[0]); + } + + template <typename Sequence> + static inline T process_2(const Sequence& arg_list) + { + return std::min<T>(value(arg_list[0]),value(arg_list[1])); + } + + template <typename Sequence> + static inline T process_3(const Sequence& arg_list) + { + return std::min<T>(std::min<T>(value(arg_list[0]),value(arg_list[1])),value(arg_list[2])); + } + + template <typename Sequence> + static inline T process_4(const Sequence& arg_list) + { + return std::min<T>( + std::min<T>(value(arg_list[0]), value(arg_list[1])), + std::min<T>(value(arg_list[2]), value(arg_list[3]))); + } + + template <typename Sequence> + static inline T process_5(const Sequence& arg_list) + { + return std::min<T>( + std::min<T>(std::min<T>(value(arg_list[0]), value(arg_list[1])), + std::min<T>(value(arg_list[2]), value(arg_list[3]))), + value(arg_list[4])); + } + }; + + template <typename T> + struct vararg_max_op exprtk_final : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + template <typename Type, + typename Allocator, + template <typename, typename> class Sequence> + static inline T process(const Sequence<Type,Allocator>& arg_list) + { + switch (arg_list.size()) + { + case 0 : return T(0); + case 1 : return process_1(arg_list); + case 2 : return process_2(arg_list); + case 3 : return process_3(arg_list); + case 4 : return process_4(arg_list); + case 5 : return process_5(arg_list); + default : + { + T result = T(value(arg_list[0])); + + for (std::size_t i = 1; i < arg_list.size(); ++i) + { + const T v = value(arg_list[i]); + + if (v > result) + result = v; + } + + return result; + } + } + } + + template <typename Sequence> + static inline T process_1(const Sequence& arg_list) + { + return value(arg_list[0]); + } + + template <typename Sequence> + static inline T process_2(const Sequence& arg_list) + { + return std::max<T>(value(arg_list[0]),value(arg_list[1])); + } + + template <typename Sequence> + static inline T process_3(const Sequence& arg_list) + { + return std::max<T>(std::max<T>(value(arg_list[0]),value(arg_list[1])),value(arg_list[2])); + } + + template <typename Sequence> + static inline T process_4(const Sequence& arg_list) + { + return std::max<T>( + std::max<T>(value(arg_list[0]), value(arg_list[1])), + std::max<T>(value(arg_list[2]), value(arg_list[3]))); + } + + template <typename Sequence> + static inline T process_5(const Sequence& arg_list) + { + return std::max<T>( + std::max<T>(std::max<T>(value(arg_list[0]), value(arg_list[1])), + std::max<T>(value(arg_list[2]), value(arg_list[3]))), + value(arg_list[4])); + } + }; + + template <typename T> + struct vararg_mand_op exprtk_final : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + template <typename Type, + typename Allocator, + template <typename, typename> class Sequence> + static inline T process(const Sequence<Type,Allocator>& arg_list) + { + switch (arg_list.size()) + { + case 1 : return process_1(arg_list); + case 2 : return process_2(arg_list); + case 3 : return process_3(arg_list); + case 4 : return process_4(arg_list); + case 5 : return process_5(arg_list); + default : + { + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + if (std::equal_to<T>()(T(0), value(arg_list[i]))) + return T(0); + } + + return T(1); + } + } + } + + template <typename Sequence> + static inline T process_1(const Sequence& arg_list) + { + return std::not_equal_to<T>() + (T(0), value(arg_list[0])) ? T(1) : T(0); + } + + template <typename Sequence> + static inline T process_2(const Sequence& arg_list) + { + return ( + std::not_equal_to<T>()(T(0), value(arg_list[0])) && + std::not_equal_to<T>()(T(0), value(arg_list[1])) + ) ? T(1) : T(0); + } + + template <typename Sequence> + static inline T process_3(const Sequence& arg_list) + { + return ( + std::not_equal_to<T>()(T(0), value(arg_list[0])) && + std::not_equal_to<T>()(T(0), value(arg_list[1])) && + std::not_equal_to<T>()(T(0), value(arg_list[2])) + ) ? T(1) : T(0); + } + + template <typename Sequence> + static inline T process_4(const Sequence& arg_list) + { + return ( + std::not_equal_to<T>()(T(0), value(arg_list[0])) && + std::not_equal_to<T>()(T(0), value(arg_list[1])) && + std::not_equal_to<T>()(T(0), value(arg_list[2])) && + std::not_equal_to<T>()(T(0), value(arg_list[3])) + ) ? T(1) : T(0); + } + + template <typename Sequence> + static inline T process_5(const Sequence& arg_list) + { + return ( + std::not_equal_to<T>()(T(0), value(arg_list[0])) && + std::not_equal_to<T>()(T(0), value(arg_list[1])) && + std::not_equal_to<T>()(T(0), value(arg_list[2])) && + std::not_equal_to<T>()(T(0), value(arg_list[3])) && + std::not_equal_to<T>()(T(0), value(arg_list[4])) + ) ? T(1) : T(0); + } + }; + + template <typename T> + struct vararg_mor_op exprtk_final : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + template <typename Type, + typename Allocator, + template <typename, typename> class Sequence> + static inline T process(const Sequence<Type,Allocator>& arg_list) + { + switch (arg_list.size()) + { + case 1 : return process_1(arg_list); + case 2 : return process_2(arg_list); + case 3 : return process_3(arg_list); + case 4 : return process_4(arg_list); + case 5 : return process_5(arg_list); + default : + { + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + if (std::not_equal_to<T>()(T(0), value(arg_list[i]))) + return T(1); + } + + return T(0); + } + } + } + + template <typename Sequence> + static inline T process_1(const Sequence& arg_list) + { + return std::not_equal_to<T>() + (T(0), value(arg_list[0])) ? T(1) : T(0); + } + + template <typename Sequence> + static inline T process_2(const Sequence& arg_list) + { + return ( + std::not_equal_to<T>()(T(0), value(arg_list[0])) || + std::not_equal_to<T>()(T(0), value(arg_list[1])) + ) ? T(1) : T(0); + } + + template <typename Sequence> + static inline T process_3(const Sequence& arg_list) + { + return ( + std::not_equal_to<T>()(T(0), value(arg_list[0])) || + std::not_equal_to<T>()(T(0), value(arg_list[1])) || + std::not_equal_to<T>()(T(0), value(arg_list[2])) + ) ? T(1) : T(0); + } + + template <typename Sequence> + static inline T process_4(const Sequence& arg_list) + { + return ( + std::not_equal_to<T>()(T(0), value(arg_list[0])) || + std::not_equal_to<T>()(T(0), value(arg_list[1])) || + std::not_equal_to<T>()(T(0), value(arg_list[2])) || + std::not_equal_to<T>()(T(0), value(arg_list[3])) + ) ? T(1) : T(0); + } + + template <typename Sequence> + static inline T process_5(const Sequence& arg_list) + { + return ( + std::not_equal_to<T>()(T(0), value(arg_list[0])) || + std::not_equal_to<T>()(T(0), value(arg_list[1])) || + std::not_equal_to<T>()(T(0), value(arg_list[2])) || + std::not_equal_to<T>()(T(0), value(arg_list[3])) || + std::not_equal_to<T>()(T(0), value(arg_list[4])) + ) ? T(1) : T(0); + } + }; + + template <typename T> + struct vararg_multi_op exprtk_final : public opr_base<T> + { + typedef typename opr_base<T>::Type Type; + + template <typename Type, + typename Allocator, + template <typename, typename> class Sequence> + static inline T process(const Sequence<Type,Allocator>& arg_list) + { + switch (arg_list.size()) + { + case 0 : return std::numeric_limits<T>::quiet_NaN(); + case 1 : return process_1(arg_list); + case 2 : return process_2(arg_list); + case 3 : return process_3(arg_list); + case 4 : return process_4(arg_list); + case 5 : return process_5(arg_list); + case 6 : return process_6(arg_list); + case 7 : return process_7(arg_list); + case 8 : return process_8(arg_list); + default : + { + for (std::size_t i = 0; i < (arg_list.size() - 1); ++i) + { + value(arg_list[i]); + } + return value(arg_list.back()); + } + } + } + + template <typename Sequence> + static inline T process_1(const Sequence& arg_list) + { + return value(arg_list[0]); + } + + template <typename Sequence> + static inline T process_2(const Sequence& arg_list) + { + value(arg_list[0]); + return value(arg_list[1]); + } + + template <typename Sequence> + static inline T process_3(const Sequence& arg_list) + { + value(arg_list[0]); + value(arg_list[1]); + return value(arg_list[2]); + } + + template <typename Sequence> + static inline T process_4(const Sequence& arg_list) + { + value(arg_list[0]); + value(arg_list[1]); + value(arg_list[2]); + return value(arg_list[3]); + } + + template <typename Sequence> + static inline T process_5(const Sequence& arg_list) + { + value(arg_list[0]); + value(arg_list[1]); + value(arg_list[2]); + value(arg_list[3]); + return value(arg_list[4]); + } + + template <typename Sequence> + static inline T process_6(const Sequence& arg_list) + { + value(arg_list[0]); + value(arg_list[1]); + value(arg_list[2]); + value(arg_list[3]); + value(arg_list[4]); + return value(arg_list[5]); + } + + template <typename Sequence> + static inline T process_7(const Sequence& arg_list) + { + value(arg_list[0]); + value(arg_list[1]); + value(arg_list[2]); + value(arg_list[3]); + value(arg_list[4]); + value(arg_list[5]); + return value(arg_list[6]); + } + + template <typename Sequence> + static inline T process_8(const Sequence& arg_list) + { + value(arg_list[0]); + value(arg_list[1]); + value(arg_list[2]); + value(arg_list[3]); + value(arg_list[4]); + value(arg_list[5]); + value(arg_list[6]); + return value(arg_list[7]); + } + }; + + template <typename T> + struct vec_add_op + { + typedef vector_interface<T>* ivector_ptr; + + static inline T process(const ivector_ptr v) + { + const T* vec = v->vec()->vds().data(); + const std::size_t vec_size = v->size(); + + loop_unroll::details lud(vec_size); + + if (vec_size <= static_cast<std::size_t>(lud.batch_size)) + { + T result = T(0); + int i = 0; + + switch (vec_size) + { + #define case_stmt(N,fall_through) \ + case N : result += vec[i++]; \ + fall_through \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(16, exprtk_fallthrough) case_stmt(15, exprtk_fallthrough) + case_stmt(14, exprtk_fallthrough) case_stmt(13, exprtk_fallthrough) + case_stmt(12, exprtk_fallthrough) case_stmt(11, exprtk_fallthrough) + case_stmt(10, exprtk_fallthrough) case_stmt( 9, exprtk_fallthrough) + case_stmt( 8, exprtk_fallthrough) case_stmt( 7, exprtk_fallthrough) + case_stmt( 6, exprtk_fallthrough) case_stmt( 5, exprtk_fallthrough) + + #endif + case_stmt( 4, exprtk_fallthrough) case_stmt( 3, exprtk_fallthrough) + case_stmt( 2, exprtk_fallthrough) case_stmt( 1, (void)0;) + } + + #undef case_stmt + + return result; + } + + T r[] = { + T(0), T(0), T(0), T(0), T(0), T(0), T(0), T(0), + T(0), T(0), T(0), T(0), T(0), T(0), T(0), T(0) + }; + + const T* upper_bound = vec + lud.upper_bound; + + while (vec < upper_bound) + { + #define exprtk_loop(N) \ + r[N] += vec[N]; \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec += lud.batch_size; + } + + int i = 0; + + switch (lud.remainder) + { + #define case_stmt(N,fall_through) \ + case N : r[0] += vec[i++]; \ + fall_through \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15, exprtk_fallthrough) case_stmt(14, exprtk_fallthrough) + case_stmt(13, exprtk_fallthrough) case_stmt(12, exprtk_fallthrough) + case_stmt(11, exprtk_fallthrough) case_stmt(10, exprtk_fallthrough) + case_stmt( 9, exprtk_fallthrough) case_stmt( 8, exprtk_fallthrough) + case_stmt( 7, exprtk_fallthrough) case_stmt( 6, exprtk_fallthrough) + case_stmt( 5, exprtk_fallthrough) case_stmt( 4, exprtk_fallthrough) + #endif + case_stmt( 3, exprtk_fallthrough) case_stmt( 2, exprtk_fallthrough) + case_stmt( 1, (void)0;) + } + + #undef exprtk_loop + #undef case_stmt + + return (r[ 0] + r[ 1] + r[ 2] + r[ 3]) + #ifndef exprtk_disable_superscalar_unroll + + (r[ 4] + r[ 5] + r[ 6] + r[ 7]) + + (r[ 8] + r[ 9] + r[10] + r[11]) + + (r[12] + r[13] + r[14] + r[15]) + #endif + ; + } + }; + + template <typename T> + struct vec_mul_op + { + typedef vector_interface<T>* ivector_ptr; + + static inline T process(const ivector_ptr v) + { + const T* vec = v->vec()->vds().data(); + const std::size_t vec_size = v->vec()->size(); + + loop_unroll::details lud(vec_size); + + if (vec_size <= static_cast<std::size_t>(lud.batch_size)) + { + T result = T(1); + int i = 0; + + switch (vec_size) + { + #define case_stmt(N,fall_through) \ + case N : result *= vec[i++]; \ + fall_through \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(16, exprtk_fallthrough) case_stmt(15, exprtk_fallthrough) + case_stmt(14, exprtk_fallthrough) case_stmt(13, exprtk_fallthrough) + case_stmt(12, exprtk_fallthrough) case_stmt(11, exprtk_fallthrough) + case_stmt(10, exprtk_fallthrough) case_stmt( 9, exprtk_fallthrough) + case_stmt( 8, exprtk_fallthrough) case_stmt( 7, exprtk_fallthrough) + case_stmt( 6, exprtk_fallthrough) case_stmt( 5, exprtk_fallthrough) + #endif + case_stmt( 4, exprtk_fallthrough) case_stmt( 3, exprtk_fallthrough) + case_stmt( 2, exprtk_fallthrough) case_stmt( 1, (void)0;) + } + + #undef case_stmt + + return result; + } + + T r[] = { + T(1), T(1), T(1), T(1), T(1), T(1), T(1), T(1), + T(1), T(1), T(1), T(1), T(1), T(1), T(1), T(1) + }; + + const T* upper_bound = vec + lud.upper_bound; + + while (vec < upper_bound) + { + #define exprtk_loop(N) \ + r[N] *= vec[N]; \ + + exprtk_loop( 0) exprtk_loop( 1) + exprtk_loop( 2) exprtk_loop( 3) + #ifndef exprtk_disable_superscalar_unroll + exprtk_loop( 4) exprtk_loop( 5) + exprtk_loop( 6) exprtk_loop( 7) + exprtk_loop( 8) exprtk_loop( 9) + exprtk_loop(10) exprtk_loop(11) + exprtk_loop(12) exprtk_loop(13) + exprtk_loop(14) exprtk_loop(15) + #endif + + vec += lud.batch_size; + } + + int i = 0; + + switch (lud.remainder) + { + #define case_stmt(N,fall_through) \ + case N : r[0] *= vec[i++]; \ + fall_through \ + + #ifndef exprtk_disable_superscalar_unroll + case_stmt(15, exprtk_fallthrough) case_stmt(14, exprtk_fallthrough) + case_stmt(13, exprtk_fallthrough) case_stmt(12, exprtk_fallthrough) + case_stmt(11, exprtk_fallthrough) case_stmt(10, exprtk_fallthrough) + case_stmt( 9, exprtk_fallthrough) case_stmt( 8, exprtk_fallthrough) + case_stmt( 7, exprtk_fallthrough) case_stmt( 6, exprtk_fallthrough) + case_stmt( 5, exprtk_fallthrough) case_stmt( 4, exprtk_fallthrough) + #endif + case_stmt( 3, exprtk_fallthrough) case_stmt( 2, exprtk_fallthrough) + case_stmt( 1, (void)0;) + } + + #undef exprtk_loop + #undef case_stmt + + return (r[ 0] * r[ 1] * r[ 2] * r[ 3]) + #ifndef exprtk_disable_superscalar_unroll + * (r[ 4] * r[ 5] * r[ 6] * r[ 7]) + * (r[ 8] * r[ 9] * r[10] * r[11]) + * (r[12] * r[13] * r[14] * r[15]) + #endif + ; + } + }; + + template <typename T> + struct vec_avg_op + { + typedef vector_interface<T>* ivector_ptr; + + static inline T process(const ivector_ptr v) + { + const T vec_size = T(v->vec()->size()); + return vec_add_op<T>::process(v) / vec_size; + } + }; + + template <typename T> + struct vec_min_op + { + typedef vector_interface<T>* ivector_ptr; + + static inline T process(const ivector_ptr v) + { + const T* vec = v->vec()->vds().data(); + const std::size_t vec_size = v->vec()->size(); + + T result = vec[0]; + + for (std::size_t i = 1; i < vec_size; ++i) + { + const T v_i = vec[i]; + + if (v_i < result) + result = v_i; + } + + return result; + } + }; + + template <typename T> + struct vec_max_op + { + typedef vector_interface<T>* ivector_ptr; + + static inline T process(const ivector_ptr v) + { + const T* vec = v->vec()->vds().data(); + const std::size_t vec_size = v->vec()->size(); + + T result = vec[0]; + + for (std::size_t i = 1; i < vec_size; ++i) + { + const T v_i = vec[i]; + + if (v_i > result) + result = v_i; + } + + return result; + } + }; + + template <typename T> + class vov_base_node : public expression_node<T> + { + public: + + virtual ~vov_base_node() + {} + + inline virtual operator_type operation() const + { + return details::e_default; + } + + virtual const T& v0() const = 0; + + virtual const T& v1() const = 0; + }; + + template <typename T> + class cov_base_node : public expression_node<T> + { + public: + + virtual ~cov_base_node() + {} + + inline virtual operator_type operation() const + { + return details::e_default; + } + + virtual const T c() const = 0; + + virtual const T& v() const = 0; + }; + + template <typename T> + class voc_base_node : public expression_node<T> + { + public: + + virtual ~voc_base_node() + {} + + inline virtual operator_type operation() const + { + return details::e_default; + } + + virtual const T c() const = 0; + + virtual const T& v() const = 0; + }; + + template <typename T> + class vob_base_node : public expression_node<T> + { + public: + + virtual ~vob_base_node() + {} + + virtual const T& v() const = 0; + }; + + template <typename T> + class bov_base_node : public expression_node<T> + { + public: + + virtual ~bov_base_node() + {} + + virtual const T& v() const = 0; + }; + + template <typename T> + class cob_base_node : public expression_node<T> + { + public: + + virtual ~cob_base_node() + {} + + inline virtual operator_type operation() const + { + return details::e_default; + } + + virtual const T c() const = 0; + + virtual void set_c(const T) = 0; + + virtual expression_node<T>* move_branch(const std::size_t& index) = 0; + }; + + template <typename T> + class boc_base_node : public expression_node<T> + { + public: + + virtual ~boc_base_node() + {} + + inline virtual operator_type operation() const + { + return details::e_default; + } + + virtual const T c() const = 0; + + virtual void set_c(const T) = 0; + + virtual expression_node<T>* move_branch(const std::size_t& index) = 0; + }; + + template <typename T> + class uv_base_node : public expression_node<T> + { + public: + + virtual ~uv_base_node() + {} + + inline virtual operator_type operation() const + { + return details::e_default; + } + + virtual const T& v() const = 0; + }; + + template <typename T> + class sos_base_node : public expression_node<T> + { + public: + + virtual ~sos_base_node() + {} + + inline virtual operator_type operation() const + { + return details::e_default; + } + }; + + template <typename T> + class sosos_base_node : public expression_node<T> + { + public: + + virtual ~sosos_base_node() + {} + + inline virtual operator_type operation() const + { + return details::e_default; + } + }; + + template <typename T> + class T0oT1oT2_base_node : public expression_node<T> + { + public: + + virtual ~T0oT1oT2_base_node() + {} + + virtual std::string type_id() const = 0; + }; + + template <typename T> + class T0oT1oT2oT3_base_node : public expression_node<T> + { + public: + + virtual ~T0oT1oT2oT3_base_node() + {} + + virtual std::string type_id() const = 0; + }; + + template <typename T, typename Operation> + class unary_variable_node exprtk_final : public uv_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef Operation operation_t; + + explicit unary_variable_node(const T& var) + : v_(var) + {} + + inline T value() const exprtk_override + { + return Operation::process(v_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline const T& v() const exprtk_override + { + return v_; + } + + private: + + unary_variable_node(const unary_variable_node<T,Operation>&) exprtk_delete; + unary_variable_node<T,Operation>& operator=(const unary_variable_node<T,Operation>&) exprtk_delete; + + const T& v_; + }; + + template <typename T> + class uvouv_node exprtk_final : public expression_node<T> + { + public: + + // UOpr1(v0) Op UOpr2(v1) + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::bfunc_t bfunc_t; + typedef typename functor_t::ufunc_t ufunc_t; + typedef expression_node<T>* expression_ptr; + + explicit uvouv_node(const T& var0,const T& var1, + ufunc_t uf0, ufunc_t uf1, bfunc_t bf) + : v0_(var0) + , v1_(var1) + , u0_(uf0 ) + , u1_(uf1 ) + , f_ (bf ) + {} + + inline T value() const exprtk_override + { + return f_(u0_(v0_),u1_(v1_)); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_uvouv; + } + + inline const T& v0() + { + return v0_; + } + + inline const T& v1() + { + return v1_; + } + + inline ufunc_t u0() + { + return u0_; + } + + inline ufunc_t u1() + { + return u1_; + } + + inline ufunc_t f() + { + return f_; + } + + private: + + uvouv_node(const uvouv_node<T>&) exprtk_delete; + uvouv_node<T>& operator=(const uvouv_node<T>&) exprtk_delete; + + const T& v0_; + const T& v1_; + const ufunc_t u0_; + const ufunc_t u1_; + const bfunc_t f_; + }; + + template <typename T, typename Operation> + class unary_branch_node exprtk_final : public expression_node<T> + { + public: + + typedef Operation operation_t; + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + + explicit unary_branch_node(expression_ptr branch) + { + construct_branch_pair(branch_, branch); + } + + inline T value() const exprtk_override + { + return Operation::process(branch_.first->value()); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline bool valid() const exprtk_override + { + return branch_.first && branch_.first->valid(); + } + + inline operator_type operation() + { + return Operation::operation(); + } + + inline expression_node<T>* branch(const std::size_t&) const exprtk_override + { + return branch_.first; + } + + inline void release() + { + branch_.second = false; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + unary_branch_node(const unary_branch_node<T,Operation>&) exprtk_delete; + unary_branch_node<T,Operation>& operator=(const unary_branch_node<T,Operation>&) exprtk_delete; + + branch_t branch_; + }; + + template <typename T> struct is_const { enum {result = 0}; }; + template <typename T> struct is_const <const T> { enum {result = 1}; }; + template <typename T> struct is_const_ref { enum {result = 0}; }; + template <typename T> struct is_const_ref <const T&> { enum {result = 1}; }; + template <typename T> struct is_ref { enum {result = 0}; }; + template <typename T> struct is_ref<T&> { enum {result = 1}; }; + template <typename T> struct is_ref<const T&> { enum {result = 0}; }; + + template <std::size_t State> + struct param_to_str { static std::string result() { static const std::string r("v"); return r; } }; + + template <> + struct param_to_str<0> { static std::string result() { static const std::string r("c"); return r; } }; + + #define exprtk_crtype(Type) \ + param_to_str<is_const_ref< Type >::result>::result() \ + + template <typename T> + struct T0oT1oT2process + { + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::bfunc_t bfunc_t; + + struct mode0 + { + static inline T process(const T& t0, const T& t1, const T& t2, const bfunc_t bf0, const bfunc_t bf1) + { + // (T0 o0 T1) o1 T2 + return bf1(bf0(t0,t1),t2); + } + + template <typename T0, typename T1, typename T2> + static inline std::string id() + { + static const std::string result = "(" + exprtk_crtype(T0) + "o" + + exprtk_crtype(T1) + ")o(" + + exprtk_crtype(T2) + ")" ; + return result; + } + }; + + struct mode1 + { + static inline T process(const T& t0, const T& t1, const T& t2, const bfunc_t bf0, const bfunc_t bf1) + { + // T0 o0 (T1 o1 T2) + return bf0(t0,bf1(t1,t2)); + } + + template <typename T0, typename T1, typename T2> + static inline std::string id() + { + static const std::string result = "(" + exprtk_crtype(T0) + ")o(" + + exprtk_crtype(T1) + "o" + + exprtk_crtype(T2) + ")" ; + return result; + } + }; + }; + + template <typename T> + struct T0oT1oT20T3process + { + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::bfunc_t bfunc_t; + + struct mode0 + { + static inline T process(const T& t0, const T& t1, + const T& t2, const T& t3, + const bfunc_t bf0, const bfunc_t bf1, const bfunc_t bf2) + { + // (T0 o0 T1) o1 (T2 o2 T3) + return bf1(bf0(t0,t1),bf2(t2,t3)); + } + + template <typename T0, typename T1, typename T2, typename T3> + static inline std::string id() + { + static const std::string result = "(" + exprtk_crtype(T0) + "o" + + exprtk_crtype(T1) + ")o" + + "(" + exprtk_crtype(T2) + "o" + + exprtk_crtype(T3) + ")" ; + return result; + } + }; + + struct mode1 + { + static inline T process(const T& t0, const T& t1, + const T& t2, const T& t3, + const bfunc_t bf0, const bfunc_t bf1, const bfunc_t bf2) + { + // (T0 o0 (T1 o1 (T2 o2 T3)) + return bf0(t0,bf1(t1,bf2(t2,t3))); + } + template <typename T0, typename T1, typename T2, typename T3> + static inline std::string id() + { + static const std::string result = "(" + exprtk_crtype(T0) + ")o((" + + exprtk_crtype(T1) + ")o(" + + exprtk_crtype(T2) + "o" + + exprtk_crtype(T3) + "))" ; + return result; + } + }; + + struct mode2 + { + static inline T process(const T& t0, const T& t1, + const T& t2, const T& t3, + const bfunc_t bf0, const bfunc_t bf1, const bfunc_t bf2) + { + // (T0 o0 ((T1 o1 T2) o2 T3) + return bf0(t0,bf2(bf1(t1,t2),t3)); + } + + template <typename T0, typename T1, typename T2, typename T3> + static inline std::string id() + { + static const std::string result = "(" + exprtk_crtype(T0) + ")o((" + + exprtk_crtype(T1) + "o" + + exprtk_crtype(T2) + ")o(" + + exprtk_crtype(T3) + "))" ; + return result; + } + }; + + struct mode3 + { + static inline T process(const T& t0, const T& t1, + const T& t2, const T& t3, + const bfunc_t bf0, const bfunc_t bf1, const bfunc_t bf2) + { + // (((T0 o0 T1) o1 T2) o2 T3) + return bf2(bf1(bf0(t0,t1),t2),t3); + } + + template <typename T0, typename T1, typename T2, typename T3> + static inline std::string id() + { + static const std::string result = "((" + exprtk_crtype(T0) + "o" + + exprtk_crtype(T1) + ")o(" + + exprtk_crtype(T2) + "))o(" + + exprtk_crtype(T3) + ")"; + return result; + } + }; + + struct mode4 + { + static inline T process(const T& t0, const T& t1, + const T& t2, const T& t3, + const bfunc_t bf0, const bfunc_t bf1, const bfunc_t bf2) + { + // ((T0 o0 (T1 o1 T2)) o2 T3 + return bf2(bf0(t0,bf1(t1,t2)),t3); + } + + template <typename T0, typename T1, typename T2, typename T3> + static inline std::string id() + { + static const std::string result = "((" + exprtk_crtype(T0) + ")o(" + + exprtk_crtype(T1) + "o" + + exprtk_crtype(T2) + "))o(" + + exprtk_crtype(T3) + ")" ; + return result; + } + }; + }; + + #undef exprtk_crtype + + template <typename T, typename T0, typename T1> + struct nodetype_T0oT1 { static const typename expression_node<T>::node_type result; }; + template <typename T, typename T0, typename T1> + const typename expression_node<T>::node_type nodetype_T0oT1<T,T0,T1>::result = expression_node<T>::e_none; + + #define synthesis_node_type_define(T0_, T1_, v_) \ + template <typename T, typename T0, typename T1> \ + struct nodetype_T0oT1<T,T0_,T1_> { static const typename expression_node<T>::node_type result; }; \ + template <typename T, typename T0, typename T1> \ + const typename expression_node<T>::node_type nodetype_T0oT1<T,T0_,T1_>::result = expression_node<T>:: v_; \ + + synthesis_node_type_define(const T0&, const T1&, e_vov) + synthesis_node_type_define(const T0&, const T1 , e_voc) + synthesis_node_type_define(const T0 , const T1&, e_cov) + synthesis_node_type_define( T0&, T1&, e_none) + synthesis_node_type_define(const T0 , const T1 , e_none) + synthesis_node_type_define( T0&, const T1 , e_none) + synthesis_node_type_define(const T0 , T1&, e_none) + synthesis_node_type_define(const T0&, T1&, e_none) + synthesis_node_type_define( T0&, const T1&, e_none) + #undef synthesis_node_type_define + + template <typename T, typename T0, typename T1, typename T2> + struct nodetype_T0oT1oT2 { static const typename expression_node<T>::node_type result; }; + template <typename T, typename T0, typename T1, typename T2> + const typename expression_node<T>::node_type nodetype_T0oT1oT2<T,T0,T1,T2>::result = expression_node<T>::e_none; + + #define synthesis_node_type_define(T0_, T1_, T2_, v_) \ + template <typename T, typename T0, typename T1, typename T2> \ + struct nodetype_T0oT1oT2<T,T0_,T1_,T2_> { static const typename expression_node<T>::node_type result; }; \ + template <typename T, typename T0, typename T1, typename T2> \ + const typename expression_node<T>::node_type nodetype_T0oT1oT2<T,T0_,T1_,T2_>::result = expression_node<T>:: v_; \ + + synthesis_node_type_define(const T0&, const T1&, const T2&, e_vovov) + synthesis_node_type_define(const T0&, const T1&, const T2 , e_vovoc) + synthesis_node_type_define(const T0&, const T1 , const T2&, e_vocov) + synthesis_node_type_define(const T0 , const T1&, const T2&, e_covov) + synthesis_node_type_define(const T0 , const T1&, const T2 , e_covoc) + synthesis_node_type_define(const T0 , const T1 , const T2 , e_none ) + synthesis_node_type_define(const T0 , const T1 , const T2&, e_none ) + synthesis_node_type_define(const T0&, const T1 , const T2 , e_none ) + synthesis_node_type_define( T0&, T1&, T2&, e_none ) + #undef synthesis_node_type_define + + template <typename T, typename T0, typename T1, typename T2, typename T3> + struct nodetype_T0oT1oT2oT3 { static const typename expression_node<T>::node_type result; }; + template <typename T, typename T0, typename T1, typename T2, typename T3> + const typename expression_node<T>::node_type nodetype_T0oT1oT2oT3<T,T0,T1,T2,T3>::result = expression_node<T>::e_none; + + #define synthesis_node_type_define(T0_, T1_, T2_, T3_, v_) \ + template <typename T, typename T0, typename T1, typename T2, typename T3> \ + struct nodetype_T0oT1oT2oT3<T,T0_,T1_,T2_,T3_> { static const typename expression_node<T>::node_type result; }; \ + template <typename T, typename T0, typename T1, typename T2, typename T3> \ + const typename expression_node<T>::node_type nodetype_T0oT1oT2oT3<T,T0_,T1_,T2_,T3_>::result = expression_node<T>:: v_; \ + + synthesis_node_type_define(const T0&, const T1&, const T2&, const T3&, e_vovovov) + synthesis_node_type_define(const T0&, const T1&, const T2&, const T3 , e_vovovoc) + synthesis_node_type_define(const T0&, const T1&, const T2 , const T3&, e_vovocov) + synthesis_node_type_define(const T0&, const T1 , const T2&, const T3&, e_vocovov) + synthesis_node_type_define(const T0 , const T1&, const T2&, const T3&, e_covovov) + synthesis_node_type_define(const T0 , const T1&, const T2 , const T3&, e_covocov) + synthesis_node_type_define(const T0&, const T1 , const T2&, const T3 , e_vocovoc) + synthesis_node_type_define(const T0 , const T1&, const T2&, const T3 , e_covovoc) + synthesis_node_type_define(const T0&, const T1 , const T2 , const T3&, e_vococov) + synthesis_node_type_define(const T0 , const T1 , const T2 , const T3 , e_none ) + synthesis_node_type_define(const T0 , const T1 , const T2 , const T3&, e_none ) + synthesis_node_type_define(const T0 , const T1 , const T2&, const T3 , e_none ) + synthesis_node_type_define(const T0 , const T1&, const T2 , const T3 , e_none ) + synthesis_node_type_define(const T0&, const T1 , const T2 , const T3 , e_none ) + synthesis_node_type_define(const T0 , const T1 , const T2&, const T3&, e_none ) + synthesis_node_type_define(const T0&, const T1&, const T2 , const T3 , e_none ) + #undef synthesis_node_type_define + + template <typename T, typename T0, typename T1> + class T0oT1 exprtk_final : public expression_node<T> + { + public: + + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::bfunc_t bfunc_t; + typedef T value_type; + typedef T0oT1<T,T0,T1> node_type; + + T0oT1(T0 p0, T1 p1, const bfunc_t p2) + : t0_(p0) + , t1_(p1) + , f_ (p2) + {} + + inline typename expression_node<T>::node_type type() const exprtk_override + { + static const typename expression_node<T>::node_type result = nodetype_T0oT1<T,T0,T1>::result; + return result; + } + + inline operator_type operation() const exprtk_override + { + return e_default; + } + + inline T value() const exprtk_override + { + return f_(t0_,t1_); + } + + inline T0 t0() const + { + return t0_; + } + + inline T1 t1() const + { + return t1_; + } + + inline bfunc_t f() const + { + return f_; + } + + template <typename Allocator> + static inline expression_node<T>* allocate(Allocator& allocator, + T0 p0, T1 p1, + bfunc_t p2) + { + return allocator + .template allocate_type<node_type, T0, T1, bfunc_t&> + (p0, p1, p2); + } + + private: + + T0oT1(const T0oT1<T,T0,T1>&) exprtk_delete; + T0oT1<T,T0,T1>& operator=(const T0oT1<T,T0,T1>&) { return (*this); } + + T0 t0_; + T1 t1_; + const bfunc_t f_; + }; + + template <typename T, typename T0, typename T1, typename T2, typename ProcessMode> + class T0oT1oT2 exprtk_final : public T0oT1oT2_base_node<T> + { + public: + + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::bfunc_t bfunc_t; + typedef T value_type; + typedef T0oT1oT2<T,T0,T1,T2,ProcessMode> node_type; + typedef ProcessMode process_mode_t; + + T0oT1oT2(T0 p0, T1 p1, T2 p2, const bfunc_t p3, const bfunc_t p4) + : t0_(p0) + , t1_(p1) + , t2_(p2) + , f0_(p3) + , f1_(p4) + {} + + inline typename expression_node<T>::node_type type() const exprtk_override + { + static const typename expression_node<T>::node_type result = nodetype_T0oT1oT2<T,T0,T1,T2>::result; + return result; + } + + inline operator_type operation() + { + return e_default; + } + + inline T value() const exprtk_override + { + return ProcessMode::process(t0_, t1_, t2_, f0_, f1_); + } + + inline T0 t0() const + { + return t0_; + } + + inline T1 t1() const + { + return t1_; + } + + inline T2 t2() const + { + return t2_; + } + + bfunc_t f0() const + { + return f0_; + } + + bfunc_t f1() const + { + return f1_; + } + + std::string type_id() const exprtk_override + { + return id(); + } + + static inline std::string id() + { + return process_mode_t::template id<T0,T1,T2>(); + } + + template <typename Allocator> + static inline expression_node<T>* allocate(Allocator& allocator, T0 p0, T1 p1, T2 p2, bfunc_t p3, bfunc_t p4) + { + return allocator + .template allocate_type<node_type, T0, T1, T2, bfunc_t, bfunc_t> + (p0, p1, p2, p3, p4); + } + + private: + + T0oT1oT2(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + + T0 t0_; + T1 t1_; + T2 t2_; + const bfunc_t f0_; + const bfunc_t f1_; + }; + + template <typename T, typename T0_, typename T1_, typename T2_, typename T3_, typename ProcessMode> + class T0oT1oT2oT3 exprtk_final : public T0oT1oT2oT3_base_node<T> + { + public: + + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::bfunc_t bfunc_t; + typedef T value_type; + typedef T0_ T0; + typedef T1_ T1; + typedef T2_ T2; + typedef T3_ T3; + typedef T0oT1oT2oT3<T,T0,T1,T2,T3,ProcessMode> node_type; + typedef ProcessMode process_mode_t; + + T0oT1oT2oT3(T0 p0, T1 p1, T2 p2, T3 p3, bfunc_t p4, bfunc_t p5, bfunc_t p6) + : t0_(p0) + , t1_(p1) + , t2_(p2) + , t3_(p3) + , f0_(p4) + , f1_(p5) + , f2_(p6) + {} + + inline T value() const exprtk_override + { + return ProcessMode::process(t0_, t1_, t2_, t3_, f0_, f1_, f2_); + } + + inline T0 t0() const + { + return t0_; + } + + inline T1 t1() const + { + return t1_; + } + + inline T2 t2() const + { + return t2_; + } + + inline T3 t3() const + { + return t3_; + } + + inline bfunc_t f0() const + { + return f0_; + } + + inline bfunc_t f1() const + { + return f1_; + } + + inline bfunc_t f2() const + { + return f2_; + } + + inline std::string type_id() const exprtk_override + { + return id(); + } + + static inline std::string id() + { + return process_mode_t::template id<T0, T1, T2, T3>(); + } + + template <typename Allocator> + static inline expression_node<T>* allocate(Allocator& allocator, + T0 p0, T1 p1, T2 p2, T3 p3, + bfunc_t p4, bfunc_t p5, bfunc_t p6) + { + return allocator + .template allocate_type<node_type, T0, T1, T2, T3, bfunc_t, bfunc_t> + (p0, p1, p2, p3, p4, p5, p6); + } + + private: + + T0oT1oT2oT3(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + + T0 t0_; + T1 t1_; + T2 t2_; + T3 t3_; + const bfunc_t f0_; + const bfunc_t f1_; + const bfunc_t f2_; + }; + + template <typename T, typename T0, typename T1, typename T2> + class T0oT1oT2_sf3 exprtk_final : public T0oT1oT2_base_node<T> + { + public: + + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::tfunc_t tfunc_t; + typedef T value_type; + typedef T0oT1oT2_sf3<T,T0,T1,T2> node_type; + + T0oT1oT2_sf3(T0 p0, T1 p1, T2 p2, const tfunc_t p3) + : t0_(p0) + , t1_(p1) + , t2_(p2) + , f_ (p3) + {} + + inline typename expression_node<T>::node_type type() const exprtk_override + { + static const typename expression_node<T>::node_type result = nodetype_T0oT1oT2<T,T0,T1,T2>::result; + return result; + } + + inline operator_type operation() const exprtk_override + { + return e_default; + } + + inline T value() const exprtk_override + { + return f_(t0_, t1_, t2_); + } + + inline T0 t0() const + { + return t0_; + } + + inline T1 t1() const + { + return t1_; + } + + inline T2 t2() const + { + return t2_; + } + + tfunc_t f() const + { + return f_; + } + + std::string type_id() const + { + return id(); + } + + static inline std::string id() + { + return "sf3"; + } + + template <typename Allocator> + static inline expression_node<T>* allocate(Allocator& allocator, T0 p0, T1 p1, T2 p2, tfunc_t p3) + { + return allocator + .template allocate_type<node_type, T0, T1, T2, tfunc_t> + (p0, p1, p2, p3); + } + + private: + + T0oT1oT2_sf3(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + + T0 t0_; + T1 t1_; + T2 t2_; + const tfunc_t f_; + }; + + template <typename T, typename T0, typename T1, typename T2> + class sf3ext_type_node : public T0oT1oT2_base_node<T> + { + public: + + virtual ~sf3ext_type_node() + {} + + virtual T0 t0() const = 0; + + virtual T1 t1() const = 0; + + virtual T2 t2() const = 0; + }; + + template <typename T, typename T0, typename T1, typename T2, typename SF3Operation> + class T0oT1oT2_sf3ext exprtk_final : public sf3ext_type_node<T,T0,T1,T2> + { + public: + + typedef T value_type; + typedef T0oT1oT2_sf3ext<T, T0, T1, T2, SF3Operation> node_type; + + T0oT1oT2_sf3ext(T0 p0, T1 p1, T2 p2) + : t0_(p0) + , t1_(p1) + , t2_(p2) + {} + + inline typename expression_node<T>::node_type type() const exprtk_override + { + static const typename expression_node<T>::node_type result = nodetype_T0oT1oT2<T,T0,T1,T2>::result; + return result; + } + + inline operator_type operation() + { + return e_default; + } + + inline T value() const exprtk_override + { + return SF3Operation::process(t0_, t1_, t2_); + } + + T0 t0() const exprtk_override + { + return t0_; + } + + T1 t1() const exprtk_override + { + return t1_; + } + + T2 t2() const exprtk_override + { + return t2_; + } + + std::string type_id() const exprtk_override + { + return id(); + } + + static inline std::string id() + { + return SF3Operation::id(); + } + + template <typename Allocator> + static inline expression_node<T>* allocate(Allocator& allocator, T0 p0, T1 p1, T2 p2) + { + return allocator + .template allocate_type<node_type, T0, T1, T2> + (p0, p1, p2); + } + + private: + + T0oT1oT2_sf3ext(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + + T0 t0_; + T1 t1_; + T2 t2_; + }; + + template <typename T> + inline bool is_sf3ext_node(const expression_node<T>* n) + { + switch (n->type()) + { + case expression_node<T>::e_vovov : return true; + case expression_node<T>::e_vovoc : return true; + case expression_node<T>::e_vocov : return true; + case expression_node<T>::e_covov : return true; + case expression_node<T>::e_covoc : return true; + default : return false; + } + } + + template <typename T, typename T0, typename T1, typename T2, typename T3> + class T0oT1oT2oT3_sf4 exprtk_final : public T0oT1oT2_base_node<T> + { + public: + + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::qfunc_t qfunc_t; + typedef T value_type; + typedef T0oT1oT2oT3_sf4<T, T0, T1, T2, T3> node_type; + + T0oT1oT2oT3_sf4(T0 p0, T1 p1, T2 p2, T3 p3, const qfunc_t p4) + : t0_(p0) + , t1_(p1) + , t2_(p2) + , t3_(p3) + , f_ (p4) + {} + + inline typename expression_node<T>::node_type type() const exprtk_override + { + static const typename expression_node<T>::node_type result = nodetype_T0oT1oT2oT3<T,T0,T1,T2,T3>::result; + return result; + } + + inline operator_type operation() const exprtk_override + { + return e_default; + } + + inline T value() const exprtk_override + { + return f_(t0_, t1_, t2_, t3_); + } + + inline T0 t0() const + { + return t0_; + } + + inline T1 t1() const + { + return t1_; + } + + inline T2 t2() const + { + return t2_; + } + + inline T3 t3() const + { + return t3_; + } + + qfunc_t f() const + { + return f_; + } + + std::string type_id() const + { + return id(); + } + + static inline std::string id() + { + return "sf4"; + } + + template <typename Allocator> + static inline expression_node<T>* allocate(Allocator& allocator, T0 p0, T1 p1, T2 p2, T3 p3, qfunc_t p4) + { + return allocator + .template allocate_type<node_type, T0, T1, T2, T3, qfunc_t> + (p0, p1, p2, p3, p4); + } + + private: + + T0oT1oT2oT3_sf4(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + + T0 t0_; + T1 t1_; + T2 t2_; + T3 t3_; + const qfunc_t f_; + }; + + template <typename T, typename T0, typename T1, typename T2, typename T3, typename SF4Operation> + class T0oT1oT2oT3_sf4ext exprtk_final : public T0oT1oT2oT3_base_node<T> + { + public: + + typedef T value_type; + typedef T0oT1oT2oT3_sf4ext<T, T0, T1, T2, T3, SF4Operation> node_type; + + T0oT1oT2oT3_sf4ext(T0 p0, T1 p1, T2 p2, T3 p3) + : t0_(p0) + , t1_(p1) + , t2_(p2) + , t3_(p3) + {} + + inline typename expression_node<T>::node_type type() const exprtk_override + { + static const typename expression_node<T>::node_type result = nodetype_T0oT1oT2oT3<T,T0,T1,T2,T3>::result; + return result; + } + + inline T value() const exprtk_override + { + return SF4Operation::process(t0_, t1_, t2_, t3_); + } + + inline T0 t0() const + { + return t0_; + } + + inline T1 t1() const + { + return t1_; + } + + inline T2 t2() const + { + return t2_; + } + + inline T3 t3() const + { + return t3_; + } + + std::string type_id() const exprtk_override + { + return id(); + } + + static inline std::string id() + { + return SF4Operation::id(); + } + + template <typename Allocator> + static inline expression_node<T>* allocate(Allocator& allocator, T0 p0, T1 p1, T2 p2, T3 p3) + { + return allocator + .template allocate_type<node_type, T0, T1, T2, T3> + (p0, p1, p2, p3); + } + + private: + + T0oT1oT2oT3_sf4ext(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + + T0 t0_; + T1 t1_; + T2 t2_; + T3 t3_; + }; + + template <typename T> + inline bool is_sf4ext_node(const expression_node<T>* n) + { + switch (n->type()) + { + case expression_node<T>::e_vovovov : return true; + case expression_node<T>::e_vovovoc : return true; + case expression_node<T>::e_vovocov : return true; + case expression_node<T>::e_vocovov : return true; + case expression_node<T>::e_covovov : return true; + case expression_node<T>::e_covocov : return true; + case expression_node<T>::e_vocovoc : return true; + case expression_node<T>::e_covovoc : return true; + case expression_node<T>::e_vococov : return true; + default : return false; + } + } + + template <typename T, typename T0, typename T1> + struct T0oT1_define + { + typedef details::T0oT1<T, T0, T1> type0; + }; + + template <typename T, typename T0, typename T1, typename T2> + struct T0oT1oT2_define + { + typedef details::T0oT1oT2<T, T0, T1, T2, typename T0oT1oT2process<T>::mode0> type0; + typedef details::T0oT1oT2<T, T0, T1, T2, typename T0oT1oT2process<T>::mode1> type1; + typedef details::T0oT1oT2_sf3<T, T0, T1, T2> sf3_type; + typedef details::sf3ext_type_node<T, T0, T1, T2> sf3_type_node; + }; + + template <typename T, typename T0, typename T1, typename T2, typename T3> + struct T0oT1oT2oT3_define + { + typedef details::T0oT1oT2oT3<T, T0, T1, T2, T3, typename T0oT1oT20T3process<T>::mode0> type0; + typedef details::T0oT1oT2oT3<T, T0, T1, T2, T3, typename T0oT1oT20T3process<T>::mode1> type1; + typedef details::T0oT1oT2oT3<T, T0, T1, T2, T3, typename T0oT1oT20T3process<T>::mode2> type2; + typedef details::T0oT1oT2oT3<T, T0, T1, T2, T3, typename T0oT1oT20T3process<T>::mode3> type3; + typedef details::T0oT1oT2oT3<T, T0, T1, T2, T3, typename T0oT1oT20T3process<T>::mode4> type4; + typedef details::T0oT1oT2oT3_sf4<T, T0, T1, T2, T3> sf4_type; + }; + + template <typename T, typename Operation> + class vov_node exprtk_final : public vov_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef Operation operation_t; + + // variable op variable node + explicit vov_node(const T& var0, const T& var1) + : v0_(var0) + , v1_(var1) + {} + + inline T value() const exprtk_override + { + return Operation::process(v0_,v1_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline const T& v0() const exprtk_override + { + return v0_; + } + + inline const T& v1() const exprtk_override + { + return v1_; + } + + protected: + + const T& v0_; + const T& v1_; + + private: + + vov_node(const vov_node<T,Operation>&) exprtk_delete; + vov_node<T,Operation>& operator=(const vov_node<T,Operation>&) exprtk_delete; + }; + + template <typename T, typename Operation> + class cov_node exprtk_final : public cov_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef Operation operation_t; + + // constant op variable node + explicit cov_node(const T& const_var, const T& var) + : c_(const_var) + , v_(var) + {} + + inline T value() const exprtk_override + { + return Operation::process(c_,v_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline const T c() const exprtk_override + { + return c_; + } + + inline const T& v() const exprtk_override + { + return v_; + } + + protected: + + const T c_; + const T& v_; + + private: + + cov_node(const cov_node<T,Operation>&) exprtk_delete; + cov_node<T,Operation>& operator=(const cov_node<T,Operation>&) exprtk_delete; + }; + + template <typename T, typename Operation> + class voc_node exprtk_final : public voc_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef Operation operation_t; + + // variable op constant node + explicit voc_node(const T& var, const T& const_var) + : v_(var) + , c_(const_var) + {} + + inline T value() const exprtk_override + { + return Operation::process(v_,c_); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline const T c() const exprtk_override + { + return c_; + } + + inline const T& v() const exprtk_override + { + return v_; + } + + protected: + + const T& v_; + const T c_; + + private: + + voc_node(const voc_node<T,Operation>&) exprtk_delete; + voc_node<T,Operation>& operator=(const voc_node<T,Operation>&) exprtk_delete; + }; + + template <typename T, typename Operation> + class vob_node exprtk_final : public vob_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + typedef Operation operation_t; + + // variable op binary node + explicit vob_node(const T& var, const expression_ptr branch) + : v_(var) + { + construct_branch_pair(branch_, branch); + assert(valid()); + } + + inline T value() const exprtk_override + { + return Operation::process(v_,branch_.first->value()); + } + + inline const T& v() const exprtk_override + { + return v_; + } + + inline bool valid() const exprtk_override + { + return branch_.first && branch_.first->valid(); + } + + inline expression_node<T>* branch(const std::size_t&) const exprtk_override + { + return branch_.first; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + vob_node(const vob_node<T,Operation>&) exprtk_delete; + vob_node<T,Operation>& operator=(const vob_node<T,Operation>&) exprtk_delete; + + const T& v_; + branch_t branch_; + }; + + template <typename T, typename Operation> + class bov_node exprtk_final : public bov_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + typedef Operation operation_t; + + // binary node op variable node + explicit bov_node(const expression_ptr branch, const T& var) + : v_(var) + { + construct_branch_pair(branch_, branch); + assert(valid()); + } + + inline T value() const exprtk_override + { + return Operation::process(branch_.first->value(),v_); + } + + inline const T& v() const exprtk_override + { + return v_; + } + + inline bool valid() const exprtk_override + { + return branch_.first && branch_.first->valid(); + } + + inline expression_node<T>* branch(const std::size_t&) const exprtk_override + { + return branch_.first; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + bov_node(const bov_node<T,Operation>&) exprtk_delete; + bov_node<T,Operation>& operator=(const bov_node<T,Operation>&) exprtk_delete; + + const T& v_; + branch_t branch_; + }; + + template <typename T, typename Operation> + class cob_node exprtk_final : public cob_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + typedef Operation operation_t; + + // constant op variable node + explicit cob_node(const T const_var, const expression_ptr branch) + : c_(const_var) + { + construct_branch_pair(branch_, branch); + assert(valid()); + } + + inline T value() const exprtk_override + { + return Operation::process(c_,branch_.first->value()); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline const T c() const exprtk_override + { + return c_; + } + + inline void set_c(const T new_c) exprtk_override + { + (*const_cast<T*>(&c_)) = new_c; + } + + inline bool valid() const exprtk_override + { + return branch_.first && branch_.first->valid(); + } + + inline expression_node<T>* branch(const std::size_t&) const exprtk_override + { + return branch_.first; + } + + inline expression_node<T>* move_branch(const std::size_t&) exprtk_override + { + branch_.second = false; + return branch_.first; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + cob_node(const cob_node<T,Operation>&) exprtk_delete; + cob_node<T,Operation>& operator=(const cob_node<T,Operation>&) exprtk_delete; + + const T c_; + branch_t branch_; + }; + + template <typename T, typename Operation> + class boc_node exprtk_final : public boc_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr,bool> branch_t; + typedef Operation operation_t; + + // binary node op constant node + explicit boc_node(const expression_ptr branch, const T const_var) + : c_(const_var) + { + construct_branch_pair(branch_, branch); + assert(valid()); + } + + inline T value() const exprtk_override + { + return Operation::process(branch_.first->value(),c_); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline const T c() const exprtk_override + { + return c_; + } + + inline void set_c(const T new_c) exprtk_override + { + (*const_cast<T*>(&c_)) = new_c; + } + + inline bool valid() const exprtk_override + { + return branch_.first && branch_.first->valid(); + } + + inline expression_node<T>* branch(const std::size_t&) const exprtk_override + { + return branch_.first; + } + + inline expression_node<T>* move_branch(const std::size_t&) exprtk_override + { + branch_.second = false; + return branch_.first; + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + boc_node(const boc_node<T,Operation>&) exprtk_delete; + boc_node<T,Operation>& operator=(const boc_node<T,Operation>&) exprtk_delete; + + const T c_; + branch_t branch_; + }; + + #ifndef exprtk_disable_string_capabilities + template <typename T, typename SType0, typename SType1, typename Operation> + class sos_node exprtk_final : public sos_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef Operation operation_t; + + // string op string node + explicit sos_node(SType0 p0, SType1 p1) + : s0_(p0) + , s1_(p1) + {} + + inline T value() const exprtk_override + { + return Operation::process(s0_,s1_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline std::string& s0() + { + return s0_; + } + + inline std::string& s1() + { + return s1_; + } + + protected: + + SType0 s0_; + SType1 s1_; + + private: + + sos_node(const sos_node<T,SType0,SType1,Operation>&) exprtk_delete; + sos_node<T,SType0,SType1,Operation>& operator=(const sos_node<T,SType0,SType1,Operation>&) exprtk_delete; + }; + + template <typename T, typename SType0, typename SType1, typename RangePack, typename Operation> + class str_xrox_node exprtk_final : public sos_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef Operation operation_t; + typedef str_xrox_node<T,SType0,SType1,RangePack,Operation> node_type; + + // string-range op string node + explicit str_xrox_node(SType0 p0, SType1 p1, RangePack rp0) + : s0_ (p0 ) + , s1_ (p1 ) + , rp0_(rp0) + {} + + ~str_xrox_node() exprtk_override + { + rp0_.free(); + } + + inline T value() const exprtk_override + { + std::size_t r0 = 0; + std::size_t r1 = 0; + + if (rp0_(r0, r1, s0_.size())) + return Operation::process(s0_.substr(r0, (r1 - r0) + 1), s1_); + else + return T(0); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline std::string& s0() + { + return s0_; + } + + inline std::string& s1() + { + return s1_; + } + + protected: + + SType0 s0_; + SType1 s1_; + RangePack rp0_; + + private: + + str_xrox_node(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + }; + + template <typename T, typename SType0, typename SType1, typename RangePack, typename Operation> + class str_xoxr_node exprtk_final : public sos_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef Operation operation_t; + typedef str_xoxr_node<T,SType0,SType1,RangePack,Operation> node_type; + + // string op string range node + explicit str_xoxr_node(SType0 p0, SType1 p1, RangePack rp1) + : s0_ (p0 ) + , s1_ (p1 ) + , rp1_(rp1) + {} + + ~str_xoxr_node() + { + rp1_.free(); + } + + inline T value() const exprtk_override + { + std::size_t r0 = 0; + std::size_t r1 = 0; + + if (rp1_(r0, r1, s1_.size())) + { + return Operation::process + ( + s0_, + s1_.substr(r0, (r1 - r0) + 1) + ); + } + else + return T(0); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline std::string& s0() + { + return s0_; + } + + inline std::string& s1() + { + return s1_; + } + + protected: + + SType0 s0_; + SType1 s1_; + RangePack rp1_; + + private: + + str_xoxr_node(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + }; + + template <typename T, typename SType0, typename SType1, typename RangePack, typename Operation> + class str_xroxr_node exprtk_final : public sos_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef Operation operation_t; + typedef str_xroxr_node<T,SType0,SType1,RangePack,Operation> node_type; + + // string-range op string-range node + explicit str_xroxr_node(SType0 p0, SType1 p1, RangePack rp0, RangePack rp1) + : s0_ (p0 ) + , s1_ (p1 ) + , rp0_(rp0) + , rp1_(rp1) + {} + + ~str_xroxr_node() exprtk_override + { + rp0_.free(); + rp1_.free(); + } + + inline T value() const exprtk_override + { + std::size_t r0_0 = 0; + std::size_t r0_1 = 0; + std::size_t r1_0 = 0; + std::size_t r1_1 = 0; + + if ( + rp0_(r0_0, r1_0, s0_.size()) && + rp1_(r0_1, r1_1, s1_.size()) + ) + { + return Operation::process + ( + s0_.substr(r0_0, (r1_0 - r0_0) + 1), + s1_.substr(r0_1, (r1_1 - r0_1) + 1) + ); + } + else + return T(0); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline std::string& s0() + { + return s0_; + } + + inline std::string& s1() + { + return s1_; + } + + protected: + + SType0 s0_; + SType1 s1_; + RangePack rp0_; + RangePack rp1_; + + private: + + str_xroxr_node(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + }; + + template <typename T, typename Operation> + class str_sogens_node exprtk_final : public binary_node<T> + { + public: + + typedef expression_node <T>* expression_ptr; + typedef string_base_node<T>* str_base_ptr; + typedef range_pack <T> range_t; + typedef range_t* range_ptr; + typedef range_interface <T> irange_t; + typedef irange_t* irange_ptr; + + using binary_node<T>::branch; + + str_sogens_node(const operator_type& opr, + expression_ptr branch0, + expression_ptr branch1) + : binary_node<T>(opr, branch0, branch1) + , str0_base_ptr_ (0) + , str1_base_ptr_ (0) + , str0_range_ptr_(0) + , str1_range_ptr_(0) + , initialised_ (false) + { + if (is_generally_string_node(branch(0))) + { + str0_base_ptr_ = dynamic_cast<str_base_ptr>(branch(0)); + + if (0 == str0_base_ptr_) + return; + + irange_ptr range = dynamic_cast<irange_ptr>(branch(0)); + + if (0 == range) + return; + + str0_range_ptr_ = &(range->range_ref()); + } + + if (is_generally_string_node(branch(1))) + { + str1_base_ptr_ = dynamic_cast<str_base_ptr>(branch(1)); + + if (0 == str1_base_ptr_) + return; + + irange_ptr range = dynamic_cast<irange_ptr>(branch(1)); + + if (0 == range) + return; + + str1_range_ptr_ = &(range->range_ref()); + } + + initialised_ = + str0_base_ptr_ && + str1_base_ptr_ && + str0_range_ptr_ && + str1_range_ptr_; + + assert(valid()); + } + + inline T value() const exprtk_override + { + branch(0)->value(); + branch(1)->value(); + + std::size_t str0_r0 = 0; + std::size_t str0_r1 = 0; + + std::size_t str1_r0 = 0; + std::size_t str1_r1 = 0; + + const range_t& range0 = (*str0_range_ptr_); + const range_t& range1 = (*str1_range_ptr_); + + if ( + range0(str0_r0, str0_r1, str0_base_ptr_->size()) && + range1(str1_r0, str1_r1, str1_base_ptr_->size()) + ) + { + return Operation::process + ( + str0_base_ptr_->str().substr(str0_r0,(str0_r1 - str0_r0)), + str1_base_ptr_->str().substr(str1_r0,(str1_r1 - str1_r0)) + ); + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline bool valid() const exprtk_override + { + return initialised_; + } + + private: + + str_sogens_node(const str_sogens_node<T,Operation>&) exprtk_delete; + str_sogens_node<T,Operation>& operator=(const str_sogens_node<T,Operation>&) exprtk_delete; + + str_base_ptr str0_base_ptr_; + str_base_ptr str1_base_ptr_; + range_ptr str0_range_ptr_; + range_ptr str1_range_ptr_; + bool initialised_; + }; + + template <typename T, typename SType0, typename SType1, typename SType2, typename Operation> + class sosos_node exprtk_final : public sosos_base_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef Operation operation_t; + typedef sosos_node<T, SType0, SType1, SType2, Operation> node_type; + + // string op string op string node + explicit sosos_node(SType0 p0, SType1 p1, SType2 p2) + : s0_(p0) + , s1_(p1) + , s2_(p2) + {} + + inline T value() const exprtk_override + { + return Operation::process(s0_, s1_, s2_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return Operation::type(); + } + + inline operator_type operation() const exprtk_override + { + return Operation::operation(); + } + + inline std::string& s0() + { + return s0_; + } + + inline std::string& s1() + { + return s1_; + } + + inline std::string& s2() + { + return s2_; + } + + protected: + + SType0 s0_; + SType1 s1_; + SType2 s2_; + + private: + + sosos_node(const node_type&) exprtk_delete; + node_type& operator=(const node_type&) exprtk_delete; + }; + #endif + + template <typename T, typename PowOp> + class ipow_node exprtk_final: public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef PowOp operation_t; + + explicit ipow_node(const T& v) + : v_(v) + {} + + inline T value() const exprtk_override + { + return PowOp::result(v_); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_ipow; + } + + private: + + ipow_node(const ipow_node<T,PowOp>&) exprtk_delete; + ipow_node<T,PowOp>& operator=(const ipow_node<T,PowOp>&) exprtk_delete; + + const T& v_; + }; + + template <typename T, typename PowOp> + class bipow_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr, bool> branch_t; + typedef PowOp operation_t; + + explicit bipow_node(expression_ptr branch) + { + construct_branch_pair(branch_, branch); + assert(valid()); + } + + inline T value() const exprtk_override + { + return PowOp::result(branch_.first->value()); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_ipow; + } + + inline bool valid() const exprtk_override + { + return branch_.first && branch_.first->valid(); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + bipow_node(const bipow_node<T,PowOp>&) exprtk_delete; + bipow_node<T,PowOp>& operator=(const bipow_node<T,PowOp>&) exprtk_delete; + + branch_t branch_; + }; + + template <typename T, typename PowOp> + class ipowinv_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef PowOp operation_t; + + explicit ipowinv_node(const T& v) + : v_(v) + {} + + inline T value() const exprtk_override + { + return (T(1) / PowOp::result(v_)); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_ipowinv; + } + + private: + + ipowinv_node(const ipowinv_node<T,PowOp>&) exprtk_delete; + ipowinv_node<T,PowOp>& operator=(const ipowinv_node<T,PowOp>&) exprtk_delete; + + const T& v_; + }; + + template <typename T, typename PowOp> + class bipowinv_node exprtk_final : public expression_node<T> + { + public: + + typedef expression_node<T>* expression_ptr; + typedef std::pair<expression_ptr, bool> branch_t; + typedef PowOp operation_t; + + explicit bipowinv_node(expression_ptr branch) + { + construct_branch_pair(branch_, branch); + assert(valid()); + } + + inline T value() const exprtk_override + { + return (T(1) / PowOp::result(branch_.first->value())); + } + + inline typename expression_node<T>::node_type type() const exprtk_override + { + return expression_node<T>::e_ipowinv; + } + + inline bool valid() const exprtk_override + { + return branch_.first && branch_.first->valid(); + } + + void collect_nodes(typename expression_node<T>::noderef_list_t& node_delete_list) exprtk_override + { + expression_node<T>::ndb_t::collect(branch_, node_delete_list); + } + + std::size_t node_depth() const exprtk_override + { + return expression_node<T>::ndb_t::compute_node_depth(branch_); + } + + private: + + bipowinv_node(const bipowinv_node<T,PowOp>&) exprtk_delete; + bipowinv_node<T,PowOp>& operator=(const bipowinv_node<T,PowOp>&) exprtk_delete; + + branch_t branch_; + }; + + template <typename T> + inline bool is_vov_node(const expression_node<T>* node) + { + return (0 != dynamic_cast<const vov_base_node<T>*>(node)); + } + + template <typename T> + inline bool is_cov_node(const expression_node<T>* node) + { + return (0 != dynamic_cast<const cov_base_node<T>*>(node)); + } + + template <typename T> + inline bool is_voc_node(const expression_node<T>* node) + { + return (0 != dynamic_cast<const voc_base_node<T>*>(node)); + } + + template <typename T> + inline bool is_cob_node(const expression_node<T>* node) + { + return (0 != dynamic_cast<const cob_base_node<T>*>(node)); + } + + template <typename T> + inline bool is_boc_node(const expression_node<T>* node) + { + return (0 != dynamic_cast<const boc_base_node<T>*>(node)); + } + + template <typename T> + inline bool is_t0ot1ot2_node(const expression_node<T>* node) + { + return (0 != dynamic_cast<const T0oT1oT2_base_node<T>*>(node)); + } + + template <typename T> + inline bool is_t0ot1ot2ot3_node(const expression_node<T>* node) + { + return (0 != dynamic_cast<const T0oT1oT2oT3_base_node<T>*>(node)); + } + + template <typename T> + inline bool is_uv_node(const expression_node<T>* node) + { + return (0 != dynamic_cast<const uv_base_node<T>*>(node)); + } + + template <typename T> + inline bool is_string_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_stringvar == node->type()); + } + + template <typename T> + inline bool is_string_range_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_stringvarrng == node->type()); + } + + template <typename T> + inline bool is_const_string_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_stringconst == node->type()); + } + + template <typename T> + inline bool is_const_string_range_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_cstringvarrng == node->type()); + } + + template <typename T> + inline bool is_string_assignment_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_strass == node->type()); + } + + template <typename T> + inline bool is_string_concat_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_strconcat == node->type()); + } + + template <typename T> + inline bool is_string_function_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_strfunction == node->type()); + } + + template <typename T> + inline bool is_string_condition_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_strcondition == node->type()); + } + + template <typename T> + inline bool is_string_ccondition_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_strccondition == node->type()); + } + + template <typename T> + inline bool is_string_vararg_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_stringvararg == node->type()); + } + + template <typename T> + inline bool is_genricstring_range_node(const expression_node<T>* node) + { + return node && (expression_node<T>::e_strgenrange == node->type()); + } + + template <typename T> + inline bool is_generally_string_node(const expression_node<T>* node) + { + if (node) + { + switch (node->type()) + { + case expression_node<T>::e_stringvar : + case expression_node<T>::e_stringconst : + case expression_node<T>::e_stringvarrng : + case expression_node<T>::e_cstringvarrng : + case expression_node<T>::e_strgenrange : + case expression_node<T>::e_strass : + case expression_node<T>::e_strconcat : + case expression_node<T>::e_strfunction : + case expression_node<T>::e_strcondition : + case expression_node<T>::e_strccondition : + case expression_node<T>::e_stringvararg : return true; + default : return false; + } + } + + return false; + } + + template <typename T> + inline bool is_loop_node(const expression_node<T>* node) + { + if (node) + { + switch (node->type()) + { + case expression_node<T>::e_for : + case expression_node<T>::e_repeat : + case expression_node<T>::e_while : return true; + default : return false; + } + } + + return false; + } + + template <typename T> + inline bool is_block_node(const expression_node<T>* node) + { + if (node) + { + if (is_loop_node(node)) + { + return true; + } + + switch (node->type()) + { + case expression_node<T>::e_conditional : + case expression_node<T>::e_mswitch : + case expression_node<T>::e_switch : + case expression_node<T>::e_vararg : return true; + default : return false; + } + } + + return false; + } + + class node_allocator + { + public: + + template <typename ResultNode, typename OpType, typename ExprNode> + inline expression_node<typename ResultNode::value_type>* allocate(OpType& operation, ExprNode (&branch)[1]) + { + expression_node<typename ResultNode::value_type>* result = + allocate<ResultNode>(operation, branch[0]); + result->node_depth(); + return result; + } + + template <typename ResultNode, typename OpType, typename ExprNode> + inline expression_node<typename ResultNode::value_type>* allocate(OpType& operation, ExprNode (&branch)[2]) + { + expression_node<typename ResultNode::value_type>* result = + allocate<ResultNode>(operation, branch[0], branch[1]); + result->node_depth(); + return result; + } + + template <typename ResultNode, typename OpType, typename ExprNode> + inline expression_node<typename ResultNode::value_type>* allocate(OpType& operation, ExprNode (&branch)[3]) + { + expression_node<typename ResultNode::value_type>* result = + allocate<ResultNode>(operation, branch[0], branch[1], branch[2]); + result->node_depth(); + return result; + } + + template <typename ResultNode, typename OpType, typename ExprNode> + inline expression_node<typename ResultNode::value_type>* allocate(OpType& operation, ExprNode (&branch)[4]) + { + expression_node<typename ResultNode::value_type>* result = + allocate<ResultNode>(operation, branch[0], branch[1], branch[2], branch[3]); + result->node_depth(); + return result; + } + + template <typename ResultNode, typename OpType, typename ExprNode> + inline expression_node<typename ResultNode::value_type>* allocate(OpType& operation, ExprNode (&branch)[5]) + { + expression_node<typename ResultNode::value_type>* result = + allocate<ResultNode>(operation, branch[0],branch[1], branch[2], branch[3], branch[4]); + result->node_depth(); + return result; + } + + template <typename ResultNode, typename OpType, typename ExprNode> + inline expression_node<typename ResultNode::value_type>* allocate(OpType& operation, ExprNode (&branch)[6]) + { + expression_node<typename ResultNode::value_type>* result = + allocate<ResultNode>(operation, branch[0], branch[1], branch[2], branch[3], branch[4], branch[5]); + result->node_depth(); + return result; + } + + template <typename node_type> + inline expression_node<typename node_type::value_type>* allocate() const + { + return (new node_type()); + } + + template <typename node_type, + typename Type, + typename Allocator, + template <typename, typename> class Sequence> + inline expression_node<typename node_type::value_type>* allocate(const Sequence<Type,Allocator>& seq) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(seq)); + result->node_depth(); + return result; + } + + template <typename node_type, typename T1> + inline expression_node<typename node_type::value_type>* allocate(T1& t1) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1)); + result->node_depth(); + return result; + } + + template <typename node_type, typename T1> + inline expression_node<typename node_type::value_type>* allocate_c(const T1& t1) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2> + inline expression_node<typename node_type::value_type>* allocate(const T1& t1, const T2& t2) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2> + inline expression_node<typename node_type::value_type>* allocate_cr(const T1& t1, T2& t2) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2> + inline expression_node<typename node_type::value_type>* allocate_rc(T1& t1, const T2& t2) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2> + inline expression_node<typename node_type::value_type>* allocate_rr(T1& t1, T2& t2) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2> + inline expression_node<typename node_type::value_type>* allocate_tt(T1 t1, T2 t2) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, typename T3> + inline expression_node<typename node_type::value_type>* allocate_ttt(T1 t1, T2 t2, T3 t3) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, typename T3, typename T4> + inline expression_node<typename node_type::value_type>* allocate_tttt(T1 t1, T2 t2, T3 t3, T4 t4) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, typename T3> + inline expression_node<typename node_type::value_type>* allocate_rrr(T1& t1, T2& t2, T3& t3) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, typename T3, typename T4> + inline expression_node<typename node_type::value_type>* allocate_rrrr(T1& t1, T2& t2, T3& t3, T4& t4) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, typename T3, typename T4, typename T5> + inline expression_node<typename node_type::value_type>* allocate_rrrrr(T1& t1, T2& t2, T3& t3, T4& t4, T5& t5) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, typename T3> + inline expression_node<typename node_type::value_type>* allocate(const T1& t1, const T2& t2, + const T3& t3) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4> + inline expression_node<typename node_type::value_type>* allocate(const T1& t1, const T2& t2, + const T3& t3, const T4& t4) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4, typename T5> + inline expression_node<typename node_type::value_type>* allocate(const T1& t1, const T2& t2, + const T3& t3, const T4& t4, + const T5& t5) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4, typename T5, typename T6> + inline expression_node<typename node_type::value_type>* allocate(const T1& t1, const T2& t2, + const T3& t3, const T4& t4, + const T5& t5, const T6& t6) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5, t6)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4, + typename T5, typename T6, typename T7> + inline expression_node<typename node_type::value_type>* allocate(const T1& t1, const T2& t2, + const T3& t3, const T4& t4, + const T5& t5, const T6& t6, + const T7& t7) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5, t6, t7)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4, + typename T5, typename T6, + typename T7, typename T8> + inline expression_node<typename node_type::value_type>* allocate(const T1& t1, const T2& t2, + const T3& t3, const T4& t4, + const T5& t5, const T6& t6, + const T7& t7, const T8& t8) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5, t6, t7, t8)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4, + typename T5, typename T6, + typename T7, typename T8, typename T9> + inline expression_node<typename node_type::value_type>* allocate(const T1& t1, const T2& t2, + const T3& t3, const T4& t4, + const T5& t5, const T6& t6, + const T7& t7, const T8& t8, + const T9& t9) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5, t6, t7, t8, t9)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4, + typename T5, typename T6, + typename T7, typename T8, + typename T9, typename T10> + inline expression_node<typename node_type::value_type>* allocate(const T1& t1, const T2& t2, + const T3& t3, const T4& t4, + const T5& t5, const T6& t6, + const T7& t7, const T8& t8, + const T9& t9, const T10& t10) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, typename T3> + inline expression_node<typename node_type::value_type>* allocate_type(T1 t1, T2 t2, T3 t3) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4> + inline expression_node<typename node_type::value_type>* allocate_type(T1 t1, T2 t2, + T3 t3, T4 t4) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4, + typename T5> + inline expression_node<typename node_type::value_type>* allocate_type(T1 t1, T2 t2, + T3 t3, T4 t4, + T5 t5) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4, + typename T5, typename T6> + inline expression_node<typename node_type::value_type>* allocate_type(T1 t1, T2 t2, + T3 t3, T4 t4, + T5 t5, T6 t6) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5, t6)); + result->node_depth(); + return result; + } + + template <typename node_type, + typename T1, typename T2, + typename T3, typename T4, + typename T5, typename T6, typename T7> + inline expression_node<typename node_type::value_type>* allocate_type(T1 t1, T2 t2, + T3 t3, T4 t4, + T5 t5, T6 t6, + T7 t7) const + { + expression_node<typename node_type::value_type>* + result = (new node_type(t1, t2, t3, t4, t5, t6, t7)); + result->node_depth(); + return result; + } + + template <typename T> + void inline free(expression_node<T>*& e) const + { + exprtk_debug(("node_allocator::free() - deleting expression_node " + "type: %03d addr: %p\n", + static_cast<int>(e->type()), + reinterpret_cast<void*>(e))); + delete e; + e = 0; + } + }; + + inline void load_operations_map(std::multimap<std::string,details::base_operation_t,details::ilesscompare>& m) + { + #define register_op(Symbol, Type, Args) \ + m.insert(std::make_pair(std::string(Symbol),details::base_operation_t(Type,Args))); \ + + register_op("abs" , e_abs , 1) + register_op("acos" , e_acos , 1) + register_op("acosh" , e_acosh , 1) + register_op("asin" , e_asin , 1) + register_op("asinh" , e_asinh , 1) + register_op("atan" , e_atan , 1) + register_op("atanh" , e_atanh , 1) + register_op("ceil" , e_ceil , 1) + register_op("cos" , e_cos , 1) + register_op("cosh" , e_cosh , 1) + register_op("exp" , e_exp , 1) + register_op("expm1" , e_expm1 , 1) + register_op("floor" , e_floor , 1) + register_op("log" , e_log , 1) + register_op("log10" , e_log10 , 1) + register_op("log2" , e_log2 , 1) + register_op("log1p" , e_log1p , 1) + register_op("round" , e_round , 1) + register_op("sin" , e_sin , 1) + register_op("sinc" , e_sinc , 1) + register_op("sinh" , e_sinh , 1) + register_op("sec" , e_sec , 1) + register_op("csc" , e_csc , 1) + register_op("sqrt" , e_sqrt , 1) + register_op("tan" , e_tan , 1) + register_op("tanh" , e_tanh , 1) + register_op("cot" , e_cot , 1) + register_op("rad2deg" , e_r2d , 1) + register_op("deg2rad" , e_d2r , 1) + register_op("deg2grad" , e_d2g , 1) + register_op("grad2deg" , e_g2d , 1) + register_op("sgn" , e_sgn , 1) + register_op("not" , e_notl , 1) + register_op("erf" , e_erf , 1) + register_op("erfc" , e_erfc , 1) + register_op("ncdf" , e_ncdf , 1) + register_op("frac" , e_frac , 1) + register_op("trunc" , e_trunc , 1) + register_op("atan2" , e_atan2 , 2) + register_op("mod" , e_mod , 2) + register_op("logn" , e_logn , 2) + register_op("pow" , e_pow , 2) + register_op("root" , e_root , 2) + register_op("roundn" , e_roundn , 2) + register_op("equal" , e_equal , 2) + register_op("not_equal" , e_nequal , 2) + register_op("hypot" , e_hypot , 2) + register_op("shr" , e_shr , 2) + register_op("shl" , e_shl , 2) + register_op("clamp" , e_clamp , 3) + register_op("iclamp" , e_iclamp , 3) + register_op("inrange" , e_inrange , 3) + #undef register_op + } + + } // namespace details + + class function_traits + { + public: + + function_traits() + : allow_zero_parameters_(false) + , has_side_effects_(true) + , min_num_args_(0) + , max_num_args_(std::numeric_limits<std::size_t>::max()) + {} + + inline bool& allow_zero_parameters() + { + return allow_zero_parameters_; + } + + inline bool& has_side_effects() + { + return has_side_effects_; + } + + std::size_t& min_num_args() + { + return min_num_args_; + } + + std::size_t& max_num_args() + { + return max_num_args_; + } + + private: + + bool allow_zero_parameters_; + bool has_side_effects_; + std::size_t min_num_args_; + std::size_t max_num_args_; + }; + + template <typename FunctionType> + void enable_zero_parameters(FunctionType& func) + { + func.allow_zero_parameters() = true; + + if (0 != func.min_num_args()) + { + func.min_num_args() = 0; + } + } + + template <typename FunctionType> + void disable_zero_parameters(FunctionType& func) + { + func.allow_zero_parameters() = false; + } + + template <typename FunctionType> + void enable_has_side_effects(FunctionType& func) + { + func.has_side_effects() = true; + } + + template <typename FunctionType> + void disable_has_side_effects(FunctionType& func) + { + func.has_side_effects() = false; + } + + template <typename FunctionType> + void set_min_num_args(FunctionType& func, const std::size_t& num_args) + { + func.min_num_args() = num_args; + + if ((0 != func.min_num_args()) && func.allow_zero_parameters()) + func.allow_zero_parameters() = false; + } + + template <typename FunctionType> + void set_max_num_args(FunctionType& func, const std::size_t& num_args) + { + func.max_num_args() = num_args; + } + + template <typename T> + class ifunction : public function_traits + { + public: + + explicit ifunction(const std::size_t& pc) + : param_count(pc) + {} + + virtual ~ifunction() + {} + + #define empty_method_body(N) \ + { \ + exprtk_debug(("ifunction::operator() - Operator(" #N ") has not been overridden\n")); \ + return std::numeric_limits<T>::quiet_NaN(); \ + } \ + + inline virtual T operator() () + empty_method_body(0) + + inline virtual T operator() (const T&) + empty_method_body(1) + + inline virtual T operator() (const T&,const T&) + empty_method_body(2) + + inline virtual T operator() (const T&, const T&, const T&) + empty_method_body(3) + + inline virtual T operator() (const T&, const T&, const T&, const T&) + empty_method_body(4) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&) + empty_method_body(5) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(6) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(7) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(8) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(9) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(10) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&) + empty_method_body(11) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&, const T&) + empty_method_body(12) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&, const T&, const T&) + empty_method_body(13) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&, const T&, const T&, const T&) + empty_method_body(14) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&, const T&, const T&, const T&, const T&) + empty_method_body(15) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(16) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&, const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(17) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(18) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(19) + + inline virtual T operator() (const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, + const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&, const T&) + empty_method_body(20) + + #undef empty_method_body + + std::size_t param_count; + }; + + template <typename T> + class ivararg_function : public function_traits + { + public: + + virtual ~ivararg_function() + {} + + inline virtual T operator() (const std::vector<T>&) + { + exprtk_debug(("ivararg_function::operator() - Operator has not been overridden\n")); + return std::numeric_limits<T>::quiet_NaN(); + } + }; + + template <typename T> + class igeneric_function : public function_traits + { + public: + + enum return_type + { + e_rtrn_scalar = 0, + e_rtrn_string = 1, + e_rtrn_overload = 2 + }; + + typedef T type; + typedef type_store<T> generic_type; + typedef typename generic_type::parameter_list parameter_list_t; + + explicit igeneric_function(const std::string& param_seq = "", const return_type rtr_type = e_rtrn_scalar) + : parameter_sequence(param_seq) + , rtrn_type(rtr_type) + {} + + virtual ~igeneric_function() + {} + + #define igeneric_function_empty_body(N) \ + { \ + exprtk_debug(("igeneric_function::operator() - Operator(" #N ") has not been overridden\n")); \ + return std::numeric_limits<T>::quiet_NaN(); \ + } \ + + // f(i_0,i_1,....,i_N) --> Scalar + inline virtual T operator() (parameter_list_t) + igeneric_function_empty_body(1) + + // f(i_0,i_1,....,i_N) --> String + inline virtual T operator() (std::string&, parameter_list_t) + igeneric_function_empty_body(2) + + // f(psi,i_0,i_1,....,i_N) --> Scalar + inline virtual T operator() (const std::size_t&, parameter_list_t) + igeneric_function_empty_body(3) + + // f(psi,i_0,i_1,....,i_N) --> String + inline virtual T operator() (const std::size_t&, std::string&, parameter_list_t) + igeneric_function_empty_body(4) + + #undef igeneric_function_empty_body + + std::string parameter_sequence; + return_type rtrn_type; + }; + + #ifndef exprtk_disable_string_capabilities + template <typename T> + class stringvar_base + { + public: + + typedef typename details::stringvar_node<T> stringvar_node_t; + + stringvar_base(const std::string& name, stringvar_node_t* svn) + : name_(name) + , string_varnode_(svn) + {} + + bool valid() const + { + return !name_.empty() && (0 != string_varnode_); + } + + std::string name() const + { + assert(string_varnode_); + return name_; + } + + void rebase(std::string& s) + { + assert(string_varnode_); + string_varnode_->rebase(s); + } + + private: + + std::string name_; + stringvar_node_t* string_varnode_; + }; + #endif + + template <typename T> class parser; + template <typename T> class expression_helper; + + template <typename T> + class symbol_table + { + public: + + enum symtab_mutability_type + { + e_unknown = 0, + e_mutable = 1, + e_immutable = 2 + }; + + typedef T (*ff00_functor)(); + typedef T (*ff01_functor)(T); + typedef T (*ff02_functor)(T, T); + typedef T (*ff03_functor)(T, T, T); + typedef T (*ff04_functor)(T, T, T, T); + typedef T (*ff05_functor)(T, T, T, T, T); + typedef T (*ff06_functor)(T, T, T, T, T, T); + typedef T (*ff07_functor)(T, T, T, T, T, T, T); + typedef T (*ff08_functor)(T, T, T, T, T, T, T, T); + typedef T (*ff09_functor)(T, T, T, T, T, T, T, T, T); + typedef T (*ff10_functor)(T, T, T, T, T, T, T, T, T, T); + typedef T (*ff11_functor)(T, T, T, T, T, T, T, T, T, T, T); + typedef T (*ff12_functor)(T, T, T, T, T, T, T, T, T, T, T, T); + typedef T (*ff13_functor)(T, T, T, T, T, T, T, T, T, T, T, T, T); + typedef T (*ff14_functor)(T, T, T, T, T, T, T, T, T, T, T, T, T, T); + typedef T (*ff15_functor)(T, T, T, T, T, T, T, T, T, T, T, T, T, T, T); + + protected: + + struct freefunc00 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc00(ff00_functor ff) : exprtk::ifunction<T>(0), f(ff) {} + inline T operator() () exprtk_override + { return f(); } + ff00_functor f; + }; + + struct freefunc01 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc01(ff01_functor ff) : exprtk::ifunction<T>(1), f(ff) {} + inline T operator() (const T& v0) exprtk_override + { return f(v0); } + ff01_functor f; + }; + + struct freefunc02 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc02(ff02_functor ff) : exprtk::ifunction<T>(2), f(ff) {} + inline T operator() (const T& v0, const T& v1) exprtk_override + { return f(v0, v1); } + ff02_functor f; + }; + + struct freefunc03 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc03(ff03_functor ff) : exprtk::ifunction<T>(3), f(ff) {} + inline T operator() (const T& v0, const T& v1, const T& v2) exprtk_override + { return f(v0, v1, v2); } + ff03_functor f; + }; + + struct freefunc04 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc04(ff04_functor ff) : exprtk::ifunction<T>(4), f(ff) {} + inline T operator() (const T& v0, const T& v1, const T& v2, const T& v3) exprtk_override + { return f(v0, v1, v2, v3); } + ff04_functor f; + }; + + struct freefunc05 : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc05(ff05_functor ff) : exprtk::ifunction<T>(5), f(ff) {} + inline T operator() (const T& v0, const T& v1, const T& v2, const T& v3, const T& v4) exprtk_override + { return f(v0, v1, v2, v3, v4); } + ff05_functor f; + }; + + struct freefunc06 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc06(ff06_functor ff) : exprtk::ifunction<T>(6), f(ff) {} + inline T operator() (const T& v0, const T& v1, const T& v2, const T& v3, const T& v4, const T& v5) exprtk_override + { return f(v0, v1, v2, v3, v4, v5); } + ff06_functor f; + }; + + struct freefunc07 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc07(ff07_functor ff) : exprtk::ifunction<T>(7), f(ff) {} + inline T operator() (const T& v0, const T& v1, const T& v2, const T& v3, const T& v4, + const T& v5, const T& v6) exprtk_override + { return f(v0, v1, v2, v3, v4, v5, v6); } + ff07_functor f; + }; + + struct freefunc08 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc08(ff08_functor ff) : exprtk::ifunction<T>(8), f(ff) {} + inline T operator() (const T& v0, const T& v1, const T& v2, const T& v3, const T& v4, + const T& v5, const T& v6, const T& v7) exprtk_override + { return f(v0, v1, v2, v3, v4, v5, v6, v7); } + ff08_functor f; + }; + + struct freefunc09 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc09(ff09_functor ff) : exprtk::ifunction<T>(9), f(ff) {} + inline T operator() (const T& v0, const T& v1, const T& v2, const T& v3, const T& v4, + const T& v5, const T& v6, const T& v7, const T& v8) exprtk_override + { return f(v0, v1, v2, v3, v4, v5, v6, v7, v8); } + ff09_functor f; + }; + + struct freefunc10 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc10(ff10_functor ff) : exprtk::ifunction<T>(10), f(ff) {} + inline T operator() (const T& v0, const T& v1, const T& v2, const T& v3, const T& v4, + const T& v5, const T& v6, const T& v7, const T& v8, const T& v9) exprtk_override + { return f(v0, v1, v2, v3, v4, v5, v6, v7, v8, v9); } + ff10_functor f; + }; + + struct freefunc11 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc11(ff11_functor ff) : exprtk::ifunction<T>(11), f(ff) {} + inline T operator() (const T& v0, const T& v1, const T& v2, const T& v3, const T& v4, + const T& v5, const T& v6, const T& v7, const T& v8, const T& v9, const T& v10) exprtk_override + { return f(v0, v1, v2, v3, v4, v5, v6, v7, v8, v9, v10); } + ff11_functor f; + }; + + struct freefunc12 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc12(ff12_functor ff) : exprtk::ifunction<T>(12), f(ff) {} + inline T operator() (const T& v00, const T& v01, const T& v02, const T& v03, const T& v04, + const T& v05, const T& v06, const T& v07, const T& v08, const T& v09, + const T& v10, const T& v11) exprtk_override + { return f(v00, v01, v02, v03, v04, v05, v06, v07, v08, v09, v10, v11); } + ff12_functor f; + }; + + struct freefunc13 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc13(ff13_functor ff) : exprtk::ifunction<T>(13), f(ff) {} + inline T operator() (const T& v00, const T& v01, const T& v02, const T& v03, const T& v04, + const T& v05, const T& v06, const T& v07, const T& v08, const T& v09, + const T& v10, const T& v11, const T& v12) exprtk_override + { return f(v00, v01, v02, v03, v04, v05, v06, v07, v08, v09, v10, v11, v12); } + ff13_functor f; + }; + + struct freefunc14 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc14(ff14_functor ff) : exprtk::ifunction<T>(14), f(ff) {} + inline T operator() (const T& v00, const T& v01, const T& v02, const T& v03, const T& v04, + const T& v05, const T& v06, const T& v07, const T& v08, const T& v09, + const T& v10, const T& v11, const T& v12, const T& v13) exprtk_override + { return f(v00, v01, v02, v03, v04, v05, v06, v07, v08, v09, v10, v11, v12, v13); } + ff14_functor f; + }; + + struct freefunc15 exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + explicit freefunc15(ff15_functor ff) : exprtk::ifunction<T>(15), f(ff) {} + inline T operator() (const T& v00, const T& v01, const T& v02, const T& v03, const T& v04, + const T& v05, const T& v06, const T& v07, const T& v08, const T& v09, + const T& v10, const T& v11, const T& v12, const T& v13, const T& v14) exprtk_override + { return f(v00, v01, v02, v03, v04, v05, v06, v07, v08, v09, v10, v11, v12, v13, v14); } + ff15_functor f; + }; + + template <typename Type, typename RawType> + struct type_store + { + typedef details::expression_node<T>* expression_ptr; + typedef typename details::variable_node<T> variable_node_t; + typedef ifunction<T> ifunction_t; + typedef ivararg_function<T> ivararg_function_t; + typedef igeneric_function<T> igeneric_function_t; + typedef details::vector_holder<T> vector_t; + #ifndef exprtk_disable_string_capabilities + typedef typename details::stringvar_node<T> stringvar_node_t; + #endif + + typedef Type type_t; + typedef type_t* type_ptr; + typedef std::pair<bool,type_ptr> type_pair_t; + typedef std::map<std::string,type_pair_t,details::ilesscompare> type_map_t; + typedef typename type_map_t::iterator tm_itr_t; + typedef typename type_map_t::const_iterator tm_const_itr_t; + + enum { lut_size = 256 }; + + type_map_t map; + std::size_t size; + + type_store() + : size(0) + {} + + struct deleter + { + #define exprtk_define_process(Type) \ + static inline void process(std::pair<bool,Type*>& n) \ + { \ + delete n.second; \ + } \ + + exprtk_define_process(variable_node_t ) + exprtk_define_process(vector_t ) + #ifndef exprtk_disable_string_capabilities + exprtk_define_process(stringvar_node_t) + #endif + + #undef exprtk_define_process + + template <typename DeleteType> + static inline void process(std::pair<bool,DeleteType*>&) + {} + }; + + inline bool symbol_exists(const std::string& symbol_name) const + { + if (symbol_name.empty()) + return false; + else if (map.end() != map.find(symbol_name)) + return true; + else + return false; + } + + template <typename PtrType> + inline std::string entity_name(const PtrType& ptr) const + { + if (map.empty()) + return std::string(); + + tm_const_itr_t itr = map.begin(); + + while (map.end() != itr) + { + if (itr->second.second == ptr) + { + return itr->first; + } + else + ++itr; + } + + return std::string(); + } + + inline bool is_constant(const std::string& symbol_name) const + { + if (symbol_name.empty()) + return false; + else + { + const tm_const_itr_t itr = map.find(symbol_name); + + if (map.end() == itr) + return false; + else + return (*itr).second.first; + } + } + + template <typename Tie, typename RType> + inline bool add_impl(const std::string& symbol_name, RType t, const bool is_const) + { + if (symbol_name.size() > 1) + { + for (std::size_t i = 0; i < details::reserved_symbols_size; ++i) + { + if (details::imatch(symbol_name, details::reserved_symbols[i])) + { + return false; + } + } + } + + const tm_itr_t itr = map.find(symbol_name); + + if (map.end() == itr) + { + map[symbol_name] = Tie::make(t,is_const); + ++size; + } + + return true; + } + + struct tie_array + { + static inline std::pair<bool,vector_t*> make(std::pair<T*,std::size_t> v, const bool is_const = false) + { + return std::make_pair(is_const, new vector_t(v.first, v.second)); + } + }; + + struct tie_stdvec + { + template <typename Allocator> + static inline std::pair<bool,vector_t*> make(std::vector<T,Allocator>& v, const bool is_const = false) + { + return std::make_pair(is_const, new vector_t(v)); + } + }; + + struct tie_vecview + { + static inline std::pair<bool,vector_t*> make(exprtk::vector_view<T>& v, const bool is_const = false) + { + return std::make_pair(is_const, new vector_t(v)); + } + }; + + struct tie_stddeq + { + template <typename Allocator> + static inline std::pair<bool,vector_t*> make(std::deque<T,Allocator>& v, const bool is_const = false) + { + return std::make_pair(is_const, new vector_t(v)); + } + }; + + template <std::size_t v_size> + inline bool add(const std::string& symbol_name, T (&v)[v_size], const bool is_const = false) + { + return add_impl<tie_array,std::pair<T*,std::size_t> > + (symbol_name, std::make_pair(v,v_size), is_const); + } + + inline bool add(const std::string& symbol_name, T* v, const std::size_t v_size, const bool is_const = false) + { + return add_impl<tie_array,std::pair<T*,std::size_t> > + (symbol_name, std::make_pair(v,v_size), is_const); + } + + template <typename Allocator> + inline bool add(const std::string& symbol_name, std::vector<T,Allocator>& v, const bool is_const = false) + { + return add_impl<tie_stdvec,std::vector<T,Allocator>&> + (symbol_name, v, is_const); + } + + inline bool add(const std::string& symbol_name, exprtk::vector_view<T>& v, const bool is_const = false) + { + return add_impl<tie_vecview,exprtk::vector_view<T>&> + (symbol_name, v, is_const); + } + + template <typename Allocator> + inline bool add(const std::string& symbol_name, std::deque<T,Allocator>& v, const bool is_const = false) + { + return add_impl<tie_stddeq,std::deque<T,Allocator>&> + (symbol_name, v, is_const); + } + + inline bool add(const std::string& symbol_name, RawType& t_, const bool is_const = false) + { + struct tie + { + static inline std::pair<bool,variable_node_t*> make(T& t, const bool is_constant = false) + { + return std::make_pair(is_constant, new variable_node_t(t)); + } + + #ifndef exprtk_disable_string_capabilities + static inline std::pair<bool,stringvar_node_t*> make(std::string& t, const bool is_constant = false) + { + return std::make_pair(is_constant, new stringvar_node_t(t)); + } + #endif + + static inline std::pair<bool,function_t*> make(function_t& t, const bool is_constant = false) + { + return std::make_pair(is_constant,&t); + } + + static inline std::pair<bool,vararg_function_t*> make(vararg_function_t& t, const bool is_constant = false) + { + return std::make_pair(is_constant,&t); + } + + static inline std::pair<bool,generic_function_t*> make(generic_function_t& t, const bool is_constant = false) + { + return std::make_pair(is_constant,&t); + } + }; + + const tm_itr_t itr = map.find(symbol_name); + + if (map.end() == itr) + { + map[symbol_name] = tie::make(t_,is_const); + ++size; + } + + return true; + } + + inline type_ptr get(const std::string& symbol_name) const + { + const tm_const_itr_t itr = map.find(symbol_name); + + if (map.end() == itr) + return reinterpret_cast<type_ptr>(0); + else + return itr->second.second; + } + + template <typename TType, typename TRawType, typename PtrType> + struct ptr_match + { + static inline bool test(const PtrType, const void*) + { + return false; + } + }; + + template <typename TType, typename TRawType> + struct ptr_match<TType,TRawType,variable_node_t*> + { + static inline bool test(const variable_node_t* p, const void* ptr) + { + exprtk_debug(("ptr_match::test() - %p <--> %p\n", reinterpret_cast<const void*>(&(p->ref())), ptr)); + return (&(p->ref()) == ptr); + } + }; + + inline type_ptr get_from_varptr(const void* ptr) const + { + tm_const_itr_t itr = map.begin(); + + while (map.end() != itr) + { + type_ptr ret_ptr = itr->second.second; + + if (ptr_match<Type,RawType,type_ptr>::test(ret_ptr,ptr)) + { + return ret_ptr; + } + + ++itr; + } + + return type_ptr(0); + } + + inline bool remove(const std::string& symbol_name, const bool delete_node = true) + { + const tm_itr_t itr = map.find(symbol_name); + + if (map.end() != itr) + { + if (delete_node) + { + deleter::process((*itr).second); + } + + map.erase(itr); + --size; + + return true; + } + else + return false; + } + + inline RawType& type_ref(const std::string& symbol_name) + { + struct init_type + { + static inline double set(double) { return (0.0); } + static inline double set(long double) { return (0.0); } + static inline float set(float) { return (0.0f); } + static inline std::string set(std::string) { return std::string(""); } + }; + + static RawType null_type = init_type::set(RawType()); + + const tm_const_itr_t itr = map.find(symbol_name); + + if (map.end() == itr) + return null_type; + else + return itr->second.second->ref(); + } + + inline void clear(const bool delete_node = true) + { + if (!map.empty()) + { + if (delete_node) + { + tm_itr_t itr = map.begin(); + tm_itr_t end = map.end (); + + while (end != itr) + { + deleter::process((*itr).second); + ++itr; + } + } + + map.clear(); + } + + size = 0; + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t get_list(Sequence<std::pair<std::string,RawType>,Allocator>& list) const + { + std::size_t count = 0; + + if (!map.empty()) + { + tm_const_itr_t itr = map.begin(); + tm_const_itr_t end = map.end (); + + while (end != itr) + { + list.push_back(std::make_pair((*itr).first,itr->second.second->ref())); + ++itr; + ++count; + } + } + + return count; + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t get_list(Sequence<std::string,Allocator>& vlist) const + { + std::size_t count = 0; + + if (!map.empty()) + { + tm_const_itr_t itr = map.begin(); + tm_const_itr_t end = map.end (); + + while (end != itr) + { + vlist.push_back((*itr).first); + ++itr; + ++count; + } + } + + return count; + } + }; + + typedef details::expression_node<T>* expression_ptr; + typedef typename details::variable_node<T> variable_t; + typedef typename details::vector_holder<T> vector_holder_t; + typedef variable_t* variable_ptr; + #ifndef exprtk_disable_string_capabilities + typedef typename details::stringvar_node<T> stringvar_t; + typedef stringvar_t* stringvar_ptr; + #endif + typedef ifunction <T> function_t; + typedef ivararg_function <T> vararg_function_t; + typedef igeneric_function<T> generic_function_t; + typedef function_t* function_ptr; + typedef vararg_function_t* vararg_function_ptr; + typedef generic_function_t* generic_function_ptr; + + static const std::size_t lut_size = 256; + + // Symbol Table Holder + struct control_block + { + struct st_data + { + type_store<variable_t , T > variable_store; + type_store<function_t , function_t > function_store; + type_store<vararg_function_t , vararg_function_t > vararg_function_store; + type_store<generic_function_t, generic_function_t> generic_function_store; + type_store<generic_function_t, generic_function_t> string_function_store; + type_store<generic_function_t, generic_function_t> overload_function_store; + type_store<vector_holder_t , vector_holder_t > vector_store; + #ifndef exprtk_disable_string_capabilities + type_store<stringvar_t , std::string > stringvar_store; + #endif + + st_data() + { + for (std::size_t i = 0; i < details::reserved_words_size; ++i) + { + reserved_symbol_table_.insert(details::reserved_words[i]); + } + + for (std::size_t i = 0; i < details::reserved_symbols_size; ++i) + { + reserved_symbol_table_.insert(details::reserved_symbols[i]); + } + } + + ~st_data() + { + for (std::size_t i = 0; i < free_function_list_.size(); ++i) + { + delete free_function_list_[i]; + } + } + + inline bool is_reserved_symbol(const std::string& symbol) const + { + return (reserved_symbol_table_.end() != reserved_symbol_table_.find(symbol)); + } + + static inline st_data* create() + { + return (new st_data); + } + + static inline void destroy(st_data*& sd) + { + delete sd; + sd = reinterpret_cast<st_data*>(0); + } + + std::list<T> local_symbol_list_; + std::list<std::string> local_stringvar_list_; + std::set<std::string> reserved_symbol_table_; + std::vector<ifunction<T>*> free_function_list_; + }; + + control_block() + : ref_count(1) + , data_(st_data::create()) + , mutability_(e_mutable) + {} + + explicit control_block(st_data* data) + : ref_count(1) + , data_(data) + , mutability_(e_mutable) + {} + + ~control_block() + { + if (data_ && (0 == ref_count)) + { + st_data::destroy(data_); + } + } + + static inline control_block* create() + { + return (new control_block); + } + + template <typename SymTab> + static inline void destroy(control_block*& cntrl_blck, SymTab* sym_tab) + { + if (cntrl_blck) + { + if ( + (0 != cntrl_blck->ref_count) && + (0 == --cntrl_blck->ref_count) + ) + { + if (sym_tab) + sym_tab->clear(); + + delete cntrl_blck; + } + + cntrl_blck = 0; + } + } + + void set_mutability(const symtab_mutability_type mutability) + { + mutability_ = mutability; + } + + std::size_t ref_count; + st_data* data_; + symtab_mutability_type mutability_; + }; + + public: + + explicit symbol_table(const symtab_mutability_type mutability = e_mutable) + : control_block_(control_block::create()) + { + control_block_->set_mutability(mutability); + clear(); + } + + ~symbol_table() + { + exprtk::details::dump_ptr("~symbol_table", this); + control_block::destroy(control_block_, this); + } + + symbol_table(const symbol_table<T>& st) + { + control_block_ = st.control_block_; + control_block_->ref_count++; + } + + inline symbol_table<T>& operator=(const symbol_table<T>& st) + { + if (this != &st) + { + control_block::destroy(control_block_,reinterpret_cast<symbol_table<T>*>(0)); + + control_block_ = st.control_block_; + control_block_->ref_count++; + } + + return (*this); + } + + inline bool operator==(const symbol_table<T>& st) const + { + return (this == &st) || (control_block_ == st.control_block_); + } + + inline symtab_mutability_type mutability() const + { + return valid() ? control_block_->mutability_ : e_unknown; + } + + inline void clear_variables(const bool delete_node = true) + { + local_data().variable_store.clear(delete_node); + } + + inline void clear_functions() + { + local_data().function_store.clear(); + } + + inline void clear_strings() + { + #ifndef exprtk_disable_string_capabilities + local_data().stringvar_store.clear(); + #endif + } + + inline void clear_vectors() + { + local_data().vector_store.clear(); + } + + inline void clear_local_constants() + { + local_data().local_symbol_list_.clear(); + } + + inline void clear() + { + if (!valid()) return; + clear_variables (); + clear_functions (); + clear_strings (); + clear_vectors (); + clear_local_constants(); + } + + inline std::size_t variable_count() const + { + if (valid()) + return local_data().variable_store.size; + else + return 0; + } + + #ifndef exprtk_disable_string_capabilities + inline std::size_t stringvar_count() const + { + if (valid()) + return local_data().stringvar_store.size; + else + return 0; + } + #endif + + inline std::size_t function_count() const + { + if (valid()) + return local_data().function_store.size; + else + return 0; + } + + inline std::size_t vector_count() const + { + if (valid()) + return local_data().vector_store.size; + else + return 0; + } + + inline variable_ptr get_variable(const std::string& variable_name) const + { + if (!valid()) + return reinterpret_cast<variable_ptr>(0); + else if (!valid_symbol(variable_name)) + return reinterpret_cast<variable_ptr>(0); + else + return local_data().variable_store.get(variable_name); + } + + inline variable_ptr get_variable(const T& var_ref) const + { + if (!valid()) + return reinterpret_cast<variable_ptr>(0); + else + return local_data().variable_store.get_from_varptr( + reinterpret_cast<const void*>(&var_ref)); + } + + #ifndef exprtk_disable_string_capabilities + inline stringvar_ptr get_stringvar(const std::string& string_name) const + { + if (!valid()) + return reinterpret_cast<stringvar_ptr>(0); + else if (!valid_symbol(string_name)) + return reinterpret_cast<stringvar_ptr>(0); + else + return local_data().stringvar_store.get(string_name); + } + + inline stringvar_base<T> get_stringvar_base(const std::string& string_name) const + { + static stringvar_base<T> null_stringvar_base("",reinterpret_cast<stringvar_ptr>(0)); + if (!valid()) + return null_stringvar_base; + else if (!valid_symbol(string_name)) + return null_stringvar_base; + + stringvar_ptr stringvar = local_data().stringvar_store.get(string_name); + + if (0 == stringvar) + { + return null_stringvar_base; + } + + return stringvar_base<T>(string_name,stringvar); + } + #endif + + inline function_ptr get_function(const std::string& function_name) const + { + if (!valid()) + return reinterpret_cast<function_ptr>(0); + else if (!valid_symbol(function_name)) + return reinterpret_cast<function_ptr>(0); + else + return local_data().function_store.get(function_name); + } + + inline vararg_function_ptr get_vararg_function(const std::string& vararg_function_name) const + { + if (!valid()) + return reinterpret_cast<vararg_function_ptr>(0); + else if (!valid_symbol(vararg_function_name)) + return reinterpret_cast<vararg_function_ptr>(0); + else + return local_data().vararg_function_store.get(vararg_function_name); + } + + inline generic_function_ptr get_generic_function(const std::string& function_name) const + { + if (!valid()) + return reinterpret_cast<generic_function_ptr>(0); + else if (!valid_symbol(function_name)) + return reinterpret_cast<generic_function_ptr>(0); + else + return local_data().generic_function_store.get(function_name); + } + + inline generic_function_ptr get_string_function(const std::string& function_name) const + { + if (!valid()) + return reinterpret_cast<generic_function_ptr>(0); + else if (!valid_symbol(function_name)) + return reinterpret_cast<generic_function_ptr>(0); + else + return local_data().string_function_store.get(function_name); + } + + inline generic_function_ptr get_overload_function(const std::string& function_name) const + { + if (!valid()) + return reinterpret_cast<generic_function_ptr>(0); + else if (!valid_symbol(function_name)) + return reinterpret_cast<generic_function_ptr>(0); + else + return local_data().overload_function_store.get(function_name); + } + + typedef vector_holder_t* vector_holder_ptr; + + inline vector_holder_ptr get_vector(const std::string& vector_name) const + { + if (!valid()) + return reinterpret_cast<vector_holder_ptr>(0); + else if (!valid_symbol(vector_name)) + return reinterpret_cast<vector_holder_ptr>(0); + else + return local_data().vector_store.get(vector_name); + } + + inline T& variable_ref(const std::string& symbol_name) + { + static T null_var = T(0); + if (!valid()) + return null_var; + else if (!valid_symbol(symbol_name)) + return null_var; + else + return local_data().variable_store.type_ref(symbol_name); + } + + #ifndef exprtk_disable_string_capabilities + inline std::string& stringvar_ref(const std::string& symbol_name) + { + static std::string null_stringvar; + if (!valid()) + return null_stringvar; + else if (!valid_symbol(symbol_name)) + return null_stringvar; + else + return local_data().stringvar_store.type_ref(symbol_name); + } + #endif + + inline bool is_constant_node(const std::string& symbol_name) const + { + if (!valid()) + return false; + else if (!valid_symbol(symbol_name)) + return false; + else + return local_data().variable_store.is_constant(symbol_name); + } + + #ifndef exprtk_disable_string_capabilities + inline bool is_constant_string(const std::string& symbol_name) const + { + if (!valid()) + return false; + else if (!valid_symbol(symbol_name)) + return false; + else if (!local_data().stringvar_store.symbol_exists(symbol_name)) + return false; + else + return local_data().stringvar_store.is_constant(symbol_name); + } + #endif + + inline bool create_variable(const std::string& variable_name, const T& value = T(0)) + { + if (!valid()) + return false; + else if (!valid_symbol(variable_name)) + return false; + else if (symbol_exists(variable_name)) + return false; + + local_data().local_symbol_list_.push_back(value); + T& t = local_data().local_symbol_list_.back(); + + return add_variable(variable_name,t); + } + + #ifndef exprtk_disable_string_capabilities + inline bool create_stringvar(const std::string& stringvar_name, const std::string& value = std::string("")) + { + if (!valid()) + return false; + else if (!valid_symbol(stringvar_name)) + return false; + else if (symbol_exists(stringvar_name)) + return false; + + local_data().local_stringvar_list_.push_back(value); + std::string& s = local_data().local_stringvar_list_.back(); + + return add_stringvar(stringvar_name,s); + } + #endif + + inline bool add_variable(const std::string& variable_name, T& t, const bool is_constant = false) + { + if (!valid()) + return false; + else if (!valid_symbol(variable_name)) + return false; + else if (symbol_exists(variable_name)) + return false; + else + return local_data().variable_store.add(variable_name, t, is_constant); + } + + inline bool add_constant(const std::string& constant_name, const T& value) + { + if (!valid()) + return false; + else if (!valid_symbol(constant_name)) + return false; + else if (symbol_exists(constant_name)) + return false; + + local_data().local_symbol_list_.push_back(value); + T& t = local_data().local_symbol_list_.back(); + + return add_variable(constant_name, t, true); + } + + #ifndef exprtk_disable_string_capabilities + inline bool add_stringvar(const std::string& stringvar_name, std::string& s, const bool is_constant = false) + { + if (!valid()) + return false; + else if (!valid_symbol(stringvar_name)) + return false; + else if (symbol_exists(stringvar_name)) + return false; + else + return local_data().stringvar_store.add(stringvar_name, s, is_constant); + } + #endif + + inline bool add_function(const std::string& function_name, function_t& function) + { + if (!valid()) + return false; + else if (!valid_symbol(function_name)) + return false; + else if (symbol_exists(function_name)) + return false; + else + return local_data().function_store.add(function_name,function); + } + + inline bool add_function(const std::string& vararg_function_name, vararg_function_t& vararg_function) + { + if (!valid()) + return false; + else if (!valid_symbol(vararg_function_name)) + return false; + else if (symbol_exists(vararg_function_name)) + return false; + else + return local_data().vararg_function_store.add(vararg_function_name,vararg_function); + } + + inline bool add_function(const std::string& function_name, generic_function_t& function) + { + if (!valid()) + return false; + else if (!valid_symbol(function_name)) + return false; + else if (symbol_exists(function_name)) + return false; + else + { + switch (function.rtrn_type) + { + case generic_function_t::e_rtrn_scalar : + return (std::string::npos == function.parameter_sequence.find_first_not_of("STVZ*?|")) ? + local_data().generic_function_store.add(function_name,function) : false; + + case generic_function_t::e_rtrn_string : + return (std::string::npos == function.parameter_sequence.find_first_not_of("STVZ*?|")) ? + local_data().string_function_store.add(function_name,function) : false; + + case generic_function_t::e_rtrn_overload : + return (std::string::npos == function.parameter_sequence.find_first_not_of("STVZ*?|:")) ? + local_data().overload_function_store.add(function_name,function) : false; + } + } + + return false; + } + + #define exprtk_define_freefunction(NN) \ + inline bool add_function(const std::string& function_name, ff##NN##_functor function) \ + { \ + if (!valid()) \ + { return false; } \ + if (!valid_symbol(function_name)) \ + { return false; } \ + if (symbol_exists(function_name)) \ + { return false; } \ + \ + exprtk::ifunction<T>* ifunc = new freefunc##NN(function); \ + \ + local_data().free_function_list_.push_back(ifunc); \ + \ + return add_function(function_name,(*local_data().free_function_list_.back())); \ + } \ + + exprtk_define_freefunction(00) exprtk_define_freefunction(01) + exprtk_define_freefunction(02) exprtk_define_freefunction(03) + exprtk_define_freefunction(04) exprtk_define_freefunction(05) + exprtk_define_freefunction(06) exprtk_define_freefunction(07) + exprtk_define_freefunction(08) exprtk_define_freefunction(09) + exprtk_define_freefunction(10) exprtk_define_freefunction(11) + exprtk_define_freefunction(12) exprtk_define_freefunction(13) + exprtk_define_freefunction(14) exprtk_define_freefunction(15) + + #undef exprtk_define_freefunction + + inline bool add_reserved_function(const std::string& function_name, function_t& function) + { + if (!valid()) + return false; + else if (!valid_symbol(function_name,false)) + return false; + else if (symbol_exists(function_name,false)) + return false; + else + return local_data().function_store.add(function_name,function); + } + + inline bool add_reserved_function(const std::string& vararg_function_name, vararg_function_t& vararg_function) + { + if (!valid()) + return false; + else if (!valid_symbol(vararg_function_name,false)) + return false; + else if (symbol_exists(vararg_function_name,false)) + return false; + else + return local_data().vararg_function_store.add(vararg_function_name,vararg_function); + } + + inline bool add_reserved_function(const std::string& function_name, generic_function_t& function) + { + if (!valid()) + return false; + else if (!valid_symbol(function_name,false)) + return false; + else if (symbol_exists(function_name,false)) + return false; + else + { + switch (function.rtrn_type) + { + case generic_function_t::e_rtrn_scalar : + return (std::string::npos == function.parameter_sequence.find_first_not_of("STVZ*?|")) ? + local_data().generic_function_store.add(function_name,function) : false; + + case generic_function_t::e_rtrn_string : + return (std::string::npos == function.parameter_sequence.find_first_not_of("STVZ*?|")) ? + local_data().string_function_store.add(function_name,function) : false; + + case generic_function_t::e_rtrn_overload : + return (std::string::npos == function.parameter_sequence.find_first_not_of("STVZ*?|:")) ? + local_data().overload_function_store.add(function_name,function) : false; + } + } + + return false; + } + + #define exprtk_define_reserved_function(NN) \ + inline bool add_reserved_function(const std::string& function_name, ff##NN##_functor function) \ + { \ + if (!valid()) \ + { return false; } \ + if (!valid_symbol(function_name,false)) \ + { return false; } \ + if (symbol_exists(function_name,false)) \ + { return false; } \ + \ + exprtk::ifunction<T>* ifunc = new freefunc##NN(function); \ + \ + local_data().free_function_list_.push_back(ifunc); \ + \ + return add_reserved_function(function_name,(*local_data().free_function_list_.back())); \ + } \ + + exprtk_define_reserved_function(00) exprtk_define_reserved_function(01) + exprtk_define_reserved_function(02) exprtk_define_reserved_function(03) + exprtk_define_reserved_function(04) exprtk_define_reserved_function(05) + exprtk_define_reserved_function(06) exprtk_define_reserved_function(07) + exprtk_define_reserved_function(08) exprtk_define_reserved_function(09) + exprtk_define_reserved_function(10) exprtk_define_reserved_function(11) + exprtk_define_reserved_function(12) exprtk_define_reserved_function(13) + exprtk_define_reserved_function(14) exprtk_define_reserved_function(15) + + #undef exprtk_define_reserved_function + + template <std::size_t N> + inline bool add_vector(const std::string& vector_name, T (&v)[N]) + { + if (!valid()) + return false; + else if (!valid_symbol(vector_name)) + return false; + else if (symbol_exists(vector_name)) + return false; + else + return local_data().vector_store.add(vector_name,v); + } + + inline bool add_vector(const std::string& vector_name, T* v, const std::size_t& v_size) + { + if (!valid()) + return false; + else if (!valid_symbol(vector_name)) + return false; + else if (symbol_exists(vector_name)) + return false; + else if (0 == v_size) + return false; + else + return local_data().vector_store.add(vector_name, v, v_size); + } + + template <typename Allocator> + inline bool add_vector(const std::string& vector_name, std::vector<T,Allocator>& v) + { + if (!valid()) + return false; + else if (!valid_symbol(vector_name)) + return false; + else if (symbol_exists(vector_name)) + return false; + else if (0 == v.size()) + return false; + else + return local_data().vector_store.add(vector_name,v); + } + + inline bool add_vector(const std::string& vector_name, exprtk::vector_view<T>& v) + { + if (!valid()) + return false; + else if (!valid_symbol(vector_name)) + return false; + else if (symbol_exists(vector_name)) + return false; + else if (0 == v.size()) + return false; + else + return local_data().vector_store.add(vector_name,v); + } + + inline bool remove_variable(const std::string& variable_name, const bool delete_node = true) + { + if (!valid()) + return false; + else + return local_data().variable_store.remove(variable_name, delete_node); + } + + #ifndef exprtk_disable_string_capabilities + inline bool remove_stringvar(const std::string& string_name) + { + if (!valid()) + return false; + else + return local_data().stringvar_store.remove(string_name); + } + #endif + + inline bool remove_function(const std::string& function_name) + { + if (!valid()) + return false; + else + return local_data().function_store.remove(function_name); + } + + inline bool remove_vararg_function(const std::string& vararg_function_name) + { + if (!valid()) + return false; + else + return local_data().vararg_function_store.remove(vararg_function_name); + } + + inline bool remove_vector(const std::string& vector_name) + { + if (!valid()) + return false; + else + return local_data().vector_store.remove(vector_name); + } + + inline bool add_constants() + { + return add_pi () && + add_epsilon () && + add_infinity() ; + } + + inline bool add_pi() + { + const typename details::numeric::details::number_type<T>::type num_type; + static const T local_pi = details::numeric::details::const_pi_impl<T>(num_type); + return add_constant("pi",local_pi); + } + + inline bool add_epsilon() + { + static const T local_epsilon = details::numeric::details::epsilon_type<T>::value(); + return add_constant("epsilon",local_epsilon); + } + + inline bool add_infinity() + { + static const T local_infinity = std::numeric_limits<T>::infinity(); + return add_constant("inf",local_infinity); + } + + template <typename Package> + inline bool add_package(Package& package) + { + return package.register_package(*this); + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t get_variable_list(Sequence<std::pair<std::string,T>,Allocator>& vlist) const + { + if (!valid()) + return 0; + else + return local_data().variable_store.get_list(vlist); + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t get_variable_list(Sequence<std::string,Allocator>& vlist) const + { + if (!valid()) + return 0; + else + return local_data().variable_store.get_list(vlist); + } + + #ifndef exprtk_disable_string_capabilities + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t get_stringvar_list(Sequence<std::pair<std::string,std::string>,Allocator>& svlist) const + { + if (!valid()) + return 0; + else + return local_data().stringvar_store.get_list(svlist); + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t get_stringvar_list(Sequence<std::string,Allocator>& svlist) const + { + if (!valid()) + return 0; + else + return local_data().stringvar_store.get_list(svlist); + } + #endif + + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t get_vector_list(Sequence<std::string,Allocator>& vec_list) const + { + if (!valid()) + return 0; + else + return local_data().vector_store.get_list(vec_list); + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t get_function_list(Sequence<std::string,Allocator>& function_list) const + { + if (!valid()) + return 0; + + std::vector<std::string> function_names; + std::size_t count = 0; + + count += local_data().function_store .get_list(function_names); + count += local_data().vararg_function_store .get_list(function_names); + count += local_data().generic_function_store .get_list(function_names); + count += local_data().string_function_store .get_list(function_names); + count += local_data().overload_function_store.get_list(function_names); + + std::set<std::string> function_set; + + for (std::size_t i = 0; i < function_names.size(); ++i) + { + function_set.insert(function_names[i]); + } + + std::copy(function_set.begin(), function_set.end(), + std::back_inserter(function_list)); + + return count; + } + + inline std::vector<std::string> get_function_list() const + { + std::vector<std::string> result; + get_function_list(result); + return result; + } + + inline bool symbol_exists(const std::string& symbol_name, const bool check_reserved_symb = true) const + { + /* + Function will return true if symbol_name exists as either a + reserved symbol, variable, stringvar, vector or function name + in any of the type stores. + */ + if (!valid()) + return false; + else if (local_data().variable_store.symbol_exists(symbol_name)) + return true; + #ifndef exprtk_disable_string_capabilities + else if (local_data().stringvar_store.symbol_exists(symbol_name)) + return true; + #endif + else if (local_data().vector_store.symbol_exists(symbol_name)) + return true; + else if (local_data().function_store.symbol_exists(symbol_name)) + return true; + else if (check_reserved_symb && local_data().is_reserved_symbol(symbol_name)) + return true; + else + return false; + } + + inline bool is_variable(const std::string& variable_name) const + { + if (!valid()) + return false; + else + return local_data().variable_store.symbol_exists(variable_name); + } + + #ifndef exprtk_disable_string_capabilities + inline bool is_stringvar(const std::string& stringvar_name) const + { + if (!valid()) + return false; + else + return local_data().stringvar_store.symbol_exists(stringvar_name); + } + + inline bool is_conststr_stringvar(const std::string& symbol_name) const + { + if (!valid()) + return false; + else if (!valid_symbol(symbol_name)) + return false; + else if (!local_data().stringvar_store.symbol_exists(symbol_name)) + return false; + + return ( + local_data().stringvar_store.symbol_exists(symbol_name) || + local_data().stringvar_store.is_constant (symbol_name) + ); + } + #endif + + inline bool is_function(const std::string& function_name) const + { + if (!valid()) + return false; + else + return local_data().function_store.symbol_exists(function_name); + } + + inline bool is_vararg_function(const std::string& vararg_function_name) const + { + if (!valid()) + return false; + else + return local_data().vararg_function_store.symbol_exists(vararg_function_name); + } + + inline bool is_vector(const std::string& vector_name) const + { + if (!valid()) + return false; + else + return local_data().vector_store.symbol_exists(vector_name); + } + + inline std::string get_variable_name(const expression_ptr& ptr) const + { + return local_data().variable_store.entity_name(ptr); + } + + inline std::string get_vector_name(const vector_holder_ptr& ptr) const + { + return local_data().vector_store.entity_name(ptr); + } + + #ifndef exprtk_disable_string_capabilities + inline std::string get_stringvar_name(const expression_ptr& ptr) const + { + return local_data().stringvar_store.entity_name(ptr); + } + + inline std::string get_conststr_stringvar_name(const expression_ptr& ptr) const + { + return local_data().stringvar_store.entity_name(ptr); + } + #endif + + inline bool valid() const + { + // Symbol table sanity check. + return control_block_ && control_block_->data_; + } + + inline void load_from(const symbol_table<T>& st) + { + { + std::vector<std::string> name_list; + + st.local_data().function_store.get_list(name_list); + + if (!name_list.empty()) + { + for (std::size_t i = 0; i < name_list.size(); ++i) + { + exprtk::ifunction<T>& ifunc = *st.get_function(name_list[i]); + add_function(name_list[i],ifunc); + } + } + } + + { + std::vector<std::string> name_list; + + st.local_data().vararg_function_store.get_list(name_list); + + if (!name_list.empty()) + { + for (std::size_t i = 0; i < name_list.size(); ++i) + { + exprtk::ivararg_function<T>& ivafunc = *st.get_vararg_function(name_list[i]); + add_function(name_list[i],ivafunc); + } + } + } + + { + std::vector<std::string> name_list; + + st.local_data().generic_function_store.get_list(name_list); + + if (!name_list.empty()) + { + for (std::size_t i = 0; i < name_list.size(); ++i) + { + exprtk::igeneric_function<T>& ifunc = *st.get_generic_function(name_list[i]); + add_function(name_list[i],ifunc); + } + } + } + + { + std::vector<std::string> name_list; + + st.local_data().string_function_store.get_list(name_list); + + if (!name_list.empty()) + { + for (std::size_t i = 0; i < name_list.size(); ++i) + { + exprtk::igeneric_function<T>& ifunc = *st.get_string_function(name_list[i]); + add_function(name_list[i],ifunc); + } + } + } + + { + std::vector<std::string> name_list; + + st.local_data().overload_function_store.get_list(name_list); + + if (!name_list.empty()) + { + for (std::size_t i = 0; i < name_list.size(); ++i) + { + exprtk::igeneric_function<T>& ifunc = *st.get_overload_function(name_list[i]); + add_function(name_list[i],ifunc); + } + } + } + } + + inline void load_variables_from(const symbol_table<T>& st) + { + std::vector<std::string> name_list; + + st.local_data().variable_store.get_list(name_list); + + if (!name_list.empty()) + { + for (std::size_t i = 0; i < name_list.size(); ++i) + { + T& variable = st.get_variable(name_list[i])->ref(); + add_variable(name_list[i], variable); + } + } + } + + inline void load_vectors_from(const symbol_table<T>& st) + { + std::vector<std::string> name_list; + + st.local_data().vector_store.get_list(name_list); + + if (!name_list.empty()) + { + for (std::size_t i = 0; i < name_list.size(); ++i) + { + vector_holder_t& vecholder = *st.get_vector(name_list[i]); + add_vector(name_list[i], vecholder.data(), vecholder.size()); + } + } + } + + private: + + inline bool valid_symbol(const std::string& symbol, const bool check_reserved_symb = true) const + { + if (symbol.empty()) + return false; + else if (!details::is_letter(symbol[0])) + return false; + else if (symbol.size() > 1) + { + for (std::size_t i = 1; i < symbol.size(); ++i) + { + if ( + !details::is_letter_or_digit(symbol[i]) && + ('_' != symbol[i]) + ) + { + if ((i < (symbol.size() - 1)) && ('.' == symbol[i])) + continue; + else + return false; + } + } + } + + return (check_reserved_symb) ? (!local_data().is_reserved_symbol(symbol)) : true; + } + + inline bool valid_function(const std::string& symbol) const + { + if (symbol.empty()) + return false; + else if (!details::is_letter(symbol[0])) + return false; + else if (symbol.size() > 1) + { + for (std::size_t i = 1; i < symbol.size(); ++i) + { + if ( + !details::is_letter_or_digit(symbol[i]) && + ('_' != symbol[i]) + ) + { + if ((i < (symbol.size() - 1)) && ('.' == symbol[i])) + continue; + else + return false; + } + } + } + + return true; + } + + typedef typename control_block::st_data local_data_t; + + inline local_data_t& local_data() + { + return *(control_block_->data_); + } + + inline const local_data_t& local_data() const + { + return *(control_block_->data_); + } + + control_block* control_block_; + + friend class parser<T>; + }; // class symbol_table + + template <typename T> + class function_compositor; + + template <typename T> + class expression + { + private: + + typedef details::expression_node<T>* expression_ptr; + typedef details::vector_holder<T>* vector_holder_ptr; + typedef std::vector<symbol_table<T> > symtab_list_t; + + struct control_block + { + enum data_type + { + e_unknown , + e_expr , + e_vecholder, + e_data , + e_vecdata , + e_string + }; + + static std::string to_str(data_type dt) + { + switch (dt) + { + case e_unknown : return "e_unknown "; + case e_expr : return "e_expr" ; + case e_vecholder : return "e_vecholder"; + case e_data : return "e_data" ; + case e_vecdata : return "e_vecdata" ; + case e_string : return "e_string" ; + } + + return ""; + } + + struct data_pack + { + data_pack() + : pointer(0) + , type(e_unknown) + , size(0) + {} + + data_pack(void* ptr, const data_type dt, const std::size_t sz = 0) + : pointer(ptr) + , type(dt) + , size(sz) + {} + + void* pointer; + data_type type; + std::size_t size; + }; + + typedef std::vector<data_pack> local_data_list_t; + typedef results_context<T> results_context_t; + typedef control_block* cntrl_blck_ptr_t; + + control_block() + : ref_count(0) + , expr (0) + , results (0) + , retinv_null(false) + , return_invoked(&retinv_null) + {} + + explicit control_block(expression_ptr e) + : ref_count(1) + , expr (e) + , results (0) + , retinv_null(false) + , return_invoked(&retinv_null) + {} + + ~control_block() + { + if (expr && details::branch_deletable(expr)) + { + destroy_node(expr); + } + + if (!local_data_list.empty()) + { + for (std::size_t i = 0; i < local_data_list.size(); ++i) + { + switch (local_data_list[i].type) + { + case e_expr : delete reinterpret_cast<expression_ptr>(local_data_list[i].pointer); + break; + + case e_vecholder : delete reinterpret_cast<vector_holder_ptr>(local_data_list[i].pointer); + break; + + case e_data : delete reinterpret_cast<T*>(local_data_list[i].pointer); + break; + + case e_vecdata : delete [] reinterpret_cast<T*>(local_data_list[i].pointer); + break; + + case e_string : delete reinterpret_cast<std::string*>(local_data_list[i].pointer); + break; + + default : break; + } + } + } + + if (results) + { + delete results; + } + } + + static inline cntrl_blck_ptr_t create(expression_ptr e) + { + return new control_block(e); + } + + static inline void destroy(cntrl_blck_ptr_t& cntrl_blck) + { + if (cntrl_blck) + { + if ( + (0 != cntrl_blck->ref_count) && + (0 == --cntrl_blck->ref_count) + ) + { + delete cntrl_blck; + } + + cntrl_blck = 0; + } + } + + std::size_t ref_count; + expression_ptr expr; + local_data_list_t local_data_list; + results_context_t* results; + bool retinv_null; + bool* return_invoked; + + friend class function_compositor<T>; + }; + + public: + + expression() + : control_block_(0) + { + set_expression(new details::null_node<T>()); + } + + expression(const expression<T>& e) + : control_block_ (e.control_block_ ) + , symbol_table_list_(e.symbol_table_list_) + { + control_block_->ref_count++; + } + + explicit expression(const symbol_table<T>& symbol_table) + : control_block_(0) + { + set_expression(new details::null_node<T>()); + symbol_table_list_.push_back(symbol_table); + } + + inline expression<T>& operator=(const expression<T>& e) + { + if (this != &e) + { + if (control_block_) + { + if ( + (0 != control_block_->ref_count) && + (0 == --control_block_->ref_count) + ) + { + delete control_block_; + } + + control_block_ = 0; + } + + control_block_ = e.control_block_; + control_block_->ref_count++; + symbol_table_list_ = e.symbol_table_list_; + } + + return *this; + } + + inline bool operator==(const expression<T>& e) const + { + return (this == &e); + } + + inline bool operator!() const + { + return ( + (0 == control_block_ ) || + (0 == control_block_->expr) + ); + } + + inline expression<T>& release() + { + exprtk::details::dump_ptr("expression::release", this); + control_block::destroy(control_block_); + + return (*this); + } + + ~expression() + { + control_block::destroy(control_block_); + } + + inline T value() const + { + assert(control_block_ ); + assert(control_block_->expr); + + return control_block_->expr->value(); + } + + inline T operator() () const + { + return value(); + } + + inline operator T() const + { + return value(); + } + + inline operator bool() const + { + return details::is_true(value()); + } + + inline bool register_symbol_table(symbol_table<T>& st) + { + for (std::size_t i = 0; i < symbol_table_list_.size(); ++i) + { + if (st == symbol_table_list_[i]) + { + return false; + } + } + + symbol_table_list_.push_back(st); + return true; + } + + inline const symbol_table<T>& get_symbol_table(const std::size_t& index = 0) const + { + return symbol_table_list_[index]; + } + + inline symbol_table<T>& get_symbol_table(const std::size_t& index = 0) + { + return symbol_table_list_[index]; + } + + std::size_t num_symbol_tables() const + { + return symbol_table_list_.size(); + } + + typedef results_context<T> results_context_t; + + inline const results_context_t& results() const + { + if (control_block_->results) + return (*control_block_->results); + else + { + static const results_context_t null_results; + return null_results; + } + } + + inline bool return_invoked() const + { + return (*control_block_->return_invoked); + } + + private: + + inline symtab_list_t get_symbol_table_list() const + { + return symbol_table_list_; + } + + inline void set_expression(const expression_ptr expr) + { + if (expr) + { + if (control_block_) + { + if (0 == --control_block_->ref_count) + { + delete control_block_; + } + } + + control_block_ = control_block::create(expr); + } + } + + inline void register_local_var(expression_ptr expr) + { + if (expr) + { + if (control_block_) + { + control_block_-> + local_data_list.push_back( + typename expression<T>::control_block:: + data_pack(reinterpret_cast<void*>(expr), + control_block::e_expr)); + } + } + } + + inline void register_local_var(vector_holder_ptr vec_holder) + { + if (vec_holder) + { + if (control_block_) + { + control_block_-> + local_data_list.push_back( + typename expression<T>::control_block:: + data_pack(reinterpret_cast<void*>(vec_holder), + control_block::e_vecholder)); + } + } + } + + inline void register_local_data(void* data, const std::size_t& size = 0, const std::size_t data_mode = 0) + { + if (data) + { + if (control_block_) + { + typename control_block::data_type dt = control_block::e_data; + + switch (data_mode) + { + case 0 : dt = control_block::e_data; break; + case 1 : dt = control_block::e_vecdata; break; + case 2 : dt = control_block::e_string; break; + } + + control_block_-> + local_data_list.push_back( + typename expression<T>::control_block:: + data_pack(reinterpret_cast<void*>(data), dt, size)); + } + } + } + + inline const typename control_block::local_data_list_t& local_data_list() + { + if (control_block_) + { + return control_block_->local_data_list; + } + else + { + static typename control_block::local_data_list_t null_local_data_list; + return null_local_data_list; + } + } + + inline void register_return_results(results_context_t* rc) + { + if (control_block_ && rc) + { + control_block_->results = rc; + } + } + + inline void set_retinvk(bool* retinvk_ptr) + { + if (control_block_) + { + control_block_->return_invoked = retinvk_ptr; + } + } + + control_block* control_block_; + symtab_list_t symbol_table_list_; + + friend class parser<T>; + friend class expression_helper<T>; + friend class function_compositor<T>; + template <typename TT> + friend bool is_valid(const expression<TT>& expr); + }; // class expression + + template <typename T> + class expression_helper + { + public: + + enum node_types + { + e_literal, + e_variable, + e_string, + e_unary, + e_binary, + e_function, + e_vararg, + e_null, + e_assert, + e_sf3ext, + e_sf4ext + }; + + static inline bool is_literal(const expression<T>& expr) + { + return expr.control_block_ && details::is_literal_node(expr.control_block_->expr); + } + + static inline bool is_variable(const expression<T>& expr) + { + return expr.control_block_ && details::is_variable_node(expr.control_block_->expr); + } + + static inline bool is_string(const expression<T>& expr) + { + return expr.control_block_ && details::is_generally_string_node(expr.control_block_->expr); + } + + static inline bool is_unary(const expression<T>& expr) + { + return expr.control_block_ && details::is_unary_node(expr.control_block_->expr); + } + + static inline bool is_binary(const expression<T>& expr) + { + return expr.control_block_ && details::is_binary_node(expr.control_block_->expr); + } + + static inline bool is_function(const expression<T>& expr) + { + return expr.control_block_ && details::is_function(expr.control_block_->expr); + } + + static inline bool is_vararg(const expression<T>& expr) + { + return expr.control_block_ && details::is_vararg_node(expr.control_block_->expr); + } + + static inline bool is_null(const expression<T>& expr) + { + return expr.control_block_ && details::is_null_node(expr.control_block_->expr); + } + + static inline bool is_assert(const expression<T>& expr) + { + return expr.control_block_ && details::is_assert_node(expr.control_block_->expr); + } + + static inline bool is_sf3ext(const expression<T>& expr) + { + return expr.control_block_ && details::is_sf3ext_node(expr.control_block_->expr); + } + + static inline bool is_sf4ext(const expression<T>& expr) + { + return expr.control_block_ && details::is_sf4ext_node(expr.control_block_->expr); + } + + static inline bool is_type(const expression<T>& expr, const node_types node_type) + { + if (0 == expr.control_block_) + { + return false; + } + + switch (node_type) + { + case e_literal : return is_literal_node(expr); + case e_variable : return is_variable (expr); + case e_string : return is_string (expr); + case e_unary : return is_unary (expr); + case e_binary : return is_binary (expr); + case e_function : return is_function (expr); + case e_null : return is_null (expr); + case e_assert : return is_assert (expr); + case e_sf3ext : return is_sf3ext (expr); + case e_sf4ext : return is_sf4ext (expr); + }; + + return false; + } + + static inline bool match_type_sequence(const expression<T>& expr, const std::vector<node_types>& type_seq) + { + if ((0 == expr.control_block_) || !is_vararg(expr)) + { + return false; + } + + typedef details::vararg_node<T, exprtk::details::vararg_multi_op<T> > mo_vararg_t; + + mo_vararg_t* vnode = dynamic_cast<mo_vararg_t*>(expr.control_block_->expr); + + if ( + (0 == vnode) || + type_seq.empty() || + (vnode->size() < type_seq.size()) + ) + { + return false; + } + + for (std::size_t i = 0; i < type_seq.size(); ++i) + { + assert((*vnode)[i]); + + switch (type_seq[i]) + { + case e_literal : { if (details::is_literal_node ((*vnode)[i])) continue; } break; + case e_variable : { if (details::is_variable_node ((*vnode)[i])) continue; } break; + case e_string : { if (details::is_generally_string_node((*vnode)[i])) continue; } break; + case e_unary : { if (details::is_unary_node ((*vnode)[i])) continue; } break; + case e_binary : { if (details::is_binary_node ((*vnode)[i])) continue; } break; + case e_function : { if (details::is_function ((*vnode)[i])) continue; } break; + case e_null : { if (details::is_null_node ((*vnode)[i])) continue; } break; + case e_assert : { if (details::is_assert_node ((*vnode)[i])) continue; } break; + case e_sf3ext : { if (details::is_sf3ext_node ((*vnode)[i])) continue; } break; + case e_sf4ext : { if (details::is_sf4ext_node ((*vnode)[i])) continue; } break; + case e_vararg : break; + } + + return false; + } + + return true; + } + }; + + template <typename T> + inline bool is_valid(const expression<T>& expr) + { + return expr.control_block_ && !expression_helper<T>::is_null(expr); + } + + namespace parser_error + { + enum error_mode + { + e_unknown = 0, + e_syntax = 1, + e_token = 2, + e_numeric = 4, + e_symtab = 5, + e_lexer = 6, + e_synthesis = 7, + e_helper = 8, + e_parser = 9 + }; + + struct type + { + type() + : mode(parser_error::e_unknown) + , line_no (0) + , column_no(0) + {} + + lexer::token token; + error_mode mode; + std::string diagnostic; + std::string src_location; + std::string error_line; + std::size_t line_no; + std::size_t column_no; + }; + + inline type make_error(const error_mode mode, + const std::string& diagnostic = "", + const std::string& src_location = "") + { + type t; + t.mode = mode; + t.token.type = lexer::token::e_error; + t.diagnostic = diagnostic; + t.src_location = src_location; + exprtk_debug(("%s\n", diagnostic .c_str())); + return t; + } + + inline type make_error(const error_mode mode, + const lexer::token& tk, + const std::string& diagnostic = "", + const std::string& src_location = "") + { + type t; + t.mode = mode; + t.token = tk; + t.diagnostic = diagnostic; + t.src_location = src_location; + exprtk_debug(("%s\n", diagnostic .c_str())); + return t; + } + + inline std::string to_str(error_mode mode) + { + switch (mode) + { + case e_unknown : return std::string("Unknown Error"); + case e_syntax : return std::string("Syntax Error" ); + case e_token : return std::string("Token Error" ); + case e_numeric : return std::string("Numeric Error"); + case e_symtab : return std::string("Symbol Error" ); + case e_lexer : return std::string("Lexer Error" ); + case e_helper : return std::string("Helper Error" ); + case e_parser : return std::string("Parser Error" ); + default : return std::string("Unknown Error"); + } + } + + inline bool update_error(type& error, const std::string& expression) + { + if ( + expression.empty() || + (error.token.position > expression.size()) || + (std::numeric_limits<std::size_t>::max() == error.token.position) + ) + { + return false; + } + + std::size_t error_line_start = 0; + + for (std::size_t i = error.token.position; i > 0; --i) + { + const details::char_t c = expression[i]; + + if (('\n' == c) || ('\r' == c)) + { + error_line_start = i + 1; + break; + } + } + + std::size_t next_nl_position = std::min(expression.size(), + expression.find_first_of('\n',error.token.position + 1)); + + error.column_no = error.token.position - error_line_start; + error.error_line = expression.substr(error_line_start, + next_nl_position - error_line_start); + + error.line_no = 0; + + for (std::size_t i = 0; i < next_nl_position; ++i) + { + if ('\n' == expression[i]) + ++error.line_no; + } + + return true; + } + + inline void dump_error(const type& error) + { + printf("Position: %02d Type: [%s] Msg: %s\n", + static_cast<int>(error.token.position), + exprtk::parser_error::to_str(error.mode).c_str(), + error.diagnostic.c_str()); + } + } + + namespace details + { + template <typename Parser> + inline void disable_type_checking(Parser& p) + { + p.state_.type_check_enabled = false; + } + } + + template <typename T> + class parser : public lexer::parser_helper + { + private: + + enum precedence_level + { + e_level00, e_level01, e_level02, e_level03, e_level04, + e_level05, e_level06, e_level07, e_level08, e_level09, + e_level10, e_level11, e_level12, e_level13, e_level14 + }; + + typedef const T& cref_t; + typedef const T const_t; + typedef ifunction<T> F; + typedef ivararg_function<T> VAF; + typedef igeneric_function<T> GF; + typedef ifunction<T> ifunction_t; + typedef ivararg_function<T> ivararg_function_t; + typedef igeneric_function<T> igeneric_function_t; + typedef details::expression_node<T> expression_node_t; + typedef details::literal_node<T> literal_node_t; + typedef details::unary_node<T> unary_node_t; + typedef details::binary_node<T> binary_node_t; + typedef details::trinary_node<T> trinary_node_t; + typedef details::quaternary_node<T> quaternary_node_t; + typedef details::conditional_node<T> conditional_node_t; + typedef details::cons_conditional_node<T> cons_conditional_node_t; + typedef details::while_loop_node<T> while_loop_node_t; + typedef details::repeat_until_loop_node<T> repeat_until_loop_node_t; + typedef details::for_loop_node<T> for_loop_node_t; + typedef details::while_loop_rtc_node<T> while_loop_rtc_node_t; + typedef details::repeat_until_loop_rtc_node<T> repeat_until_loop_rtc_node_t; + typedef details::for_loop_rtc_node<T> for_loop_rtc_node_t; + #ifndef exprtk_disable_break_continue + typedef details::while_loop_bc_node<T> while_loop_bc_node_t; + typedef details::repeat_until_loop_bc_node<T> repeat_until_loop_bc_node_t; + typedef details::for_loop_bc_node<T> for_loop_bc_node_t; + typedef details::while_loop_bc_rtc_node<T> while_loop_bc_rtc_node_t; + typedef details::repeat_until_loop_bc_rtc_node<T> repeat_until_loop_bc_rtc_node_t; + typedef details::for_loop_bc_rtc_node<T> for_loop_bc_rtc_node_t; + #endif + typedef details::switch_node<T> switch_node_t; + typedef details::variable_node<T> variable_node_t; + typedef details::vector_elem_node<T> vector_elem_node_t; + typedef details::vector_celem_node<T> vector_celem_node_t; + typedef details::vector_elem_rtc_node<T> vector_elem_rtc_node_t; + typedef details::vector_celem_rtc_node<T> vector_celem_rtc_node_t; + typedef details::rebasevector_elem_node<T> rebasevector_elem_node_t; + typedef details::rebasevector_celem_node<T> rebasevector_celem_node_t; + typedef details::rebasevector_elem_rtc_node<T> rebasevector_elem_rtc_node_t; + typedef details::rebasevector_celem_rtc_node<T> rebasevector_celem_rtc_node_t; + typedef details::vector_node<T> vector_node_t; + typedef details::vector_size_node<T> vector_size_node_t; + typedef details::range_pack<T> range_t; + #ifndef exprtk_disable_string_capabilities + typedef details::stringvar_node<T> stringvar_node_t; + typedef details::string_literal_node<T> string_literal_node_t; + typedef details::string_range_node<T> string_range_node_t; + typedef details::const_string_range_node<T> const_string_range_node_t; + typedef details::generic_string_range_node<T> generic_string_range_node_t; + typedef details::string_concat_node<T> string_concat_node_t; + typedef details::assignment_string_node<T> assignment_string_node_t; + typedef details::assignment_string_range_node<T> assignment_string_range_node_t; + typedef details::conditional_string_node<T> conditional_string_node_t; + typedef details::cons_conditional_str_node<T> cons_conditional_str_node_t; + #endif + typedef details::assignment_node<T> assignment_node_t; + typedef details::assignment_vec_elem_node<T> assignment_vec_elem_node_t; + typedef details::assignment_vec_elem_rtc_node<T> assignment_vec_elem_rtc_node_t; + typedef details::assignment_rebasevec_elem_node<T> assignment_rebasevec_elem_node_t; + typedef details::assignment_rebasevec_elem_rtc_node<T> assignment_rebasevec_elem_rtc_node_t; + typedef details::assignment_rebasevec_celem_node<T> assignment_rebasevec_celem_node_t; + typedef details::assignment_vec_node<T> assignment_vec_node_t; + typedef details::assignment_vecvec_node<T> assignment_vecvec_node_t; + typedef details::conditional_vector_node<T> conditional_vector_node_t; + typedef details::scand_node<T> scand_node_t; + typedef details::scor_node<T> scor_node_t; + typedef lexer::token token_t; + typedef expression_node_t* expression_node_ptr; + typedef expression<T> expression_t; + typedef symbol_table<T> symbol_table_t; + typedef typename expression<T>::symtab_list_t symbol_table_list_t; + typedef details::vector_holder<T> vector_holder_t; + typedef vector_holder_t* vector_holder_ptr; + + typedef typename details::functor_t<T> functor_t; + typedef typename functor_t::qfunc_t quaternary_functor_t; + typedef typename functor_t::tfunc_t trinary_functor_t; + typedef typename functor_t::bfunc_t binary_functor_t; + typedef typename functor_t::ufunc_t unary_functor_t; + + typedef details::operator_type operator_t; + + typedef std::map<operator_t, unary_functor_t > unary_op_map_t; + typedef std::map<operator_t, binary_functor_t > binary_op_map_t; + typedef std::map<operator_t, trinary_functor_t> trinary_op_map_t; + + typedef std::map<std::string,std::pair<trinary_functor_t ,operator_t> > sf3_map_t; + typedef std::map<std::string,std::pair<quaternary_functor_t,operator_t> > sf4_map_t; + + typedef std::map<binary_functor_t,operator_t> inv_binary_op_map_t; + typedef std::multimap<std::string,details::base_operation_t,details::ilesscompare> base_ops_map_t; + typedef std::set<std::string,details::ilesscompare> disabled_func_set_t; + + typedef details::T0oT1_define<T, cref_t , cref_t > vov_t; + typedef details::T0oT1_define<T, const_t, cref_t > cov_t; + typedef details::T0oT1_define<T, cref_t , const_t> voc_t; + + typedef details::T0oT1oT2_define<T, cref_t , cref_t , cref_t > vovov_t; + typedef details::T0oT1oT2_define<T, cref_t , cref_t , const_t> vovoc_t; + typedef details::T0oT1oT2_define<T, cref_t , const_t, cref_t > vocov_t; + typedef details::T0oT1oT2_define<T, const_t, cref_t , cref_t > covov_t; + typedef details::T0oT1oT2_define<T, const_t, cref_t , const_t> covoc_t; + typedef details::T0oT1oT2_define<T, const_t, const_t, cref_t > cocov_t; + typedef details::T0oT1oT2_define<T, cref_t , const_t, const_t> vococ_t; + + typedef details::T0oT1oT2oT3_define<T, cref_t , cref_t , cref_t , cref_t > vovovov_t; + typedef details::T0oT1oT2oT3_define<T, cref_t , cref_t , cref_t , const_t> vovovoc_t; + typedef details::T0oT1oT2oT3_define<T, cref_t , cref_t , const_t, cref_t > vovocov_t; + typedef details::T0oT1oT2oT3_define<T, cref_t , const_t, cref_t , cref_t > vocovov_t; + typedef details::T0oT1oT2oT3_define<T, const_t, cref_t , cref_t , cref_t > covovov_t; + + typedef details::T0oT1oT2oT3_define<T, const_t, cref_t , const_t, cref_t > covocov_t; + typedef details::T0oT1oT2oT3_define<T, cref_t , const_t, cref_t , const_t> vocovoc_t; + typedef details::T0oT1oT2oT3_define<T, const_t, cref_t , cref_t , const_t> covovoc_t; + typedef details::T0oT1oT2oT3_define<T, cref_t , const_t, const_t, cref_t > vococov_t; + + typedef results_context<T> results_context_t; + + typedef parser_helper prsrhlpr_t; + + struct scope_element + { + enum element_type + { + e_none , + e_literal , + e_variable, + e_vector , + e_vecelem , + e_string + }; + + typedef details::vector_holder<T> vector_holder_t; + typedef literal_node_t* literal_node_ptr; + typedef variable_node_t* variable_node_ptr; + typedef vector_holder_t* vector_holder_ptr; + typedef expression_node_t* expression_node_ptr; + #ifndef exprtk_disable_string_capabilities + typedef stringvar_node_t* stringvar_node_ptr; + #endif + + scope_element() + : name("???") + , size (std::numeric_limits<std::size_t>::max()) + , index(std::numeric_limits<std::size_t>::max()) + , depth(std::numeric_limits<std::size_t>::max()) + , ref_count(0) + , ip_index (0) + , type (e_none) + , active (false) + , data (0) + , var_node (0) + , vec_node (0) + #ifndef exprtk_disable_string_capabilities + , str_node(0) + #endif + {} + + bool operator < (const scope_element& se) const + { + if (ip_index < se.ip_index) + return true; + else if (ip_index > se.ip_index) + return false; + else if (depth < se.depth) + return true; + else if (depth > se.depth) + return false; + else if (index < se.index) + return true; + else if (index > se.index) + return false; + else + return (name < se.name); + } + + void clear() + { + name = "???"; + size = std::numeric_limits<std::size_t>::max(); + index = std::numeric_limits<std::size_t>::max(); + depth = std::numeric_limits<std::size_t>::max(); + type = e_none; + active = false; + ref_count = 0; + ip_index = 0; + data = 0; + var_node = 0; + vec_node = 0; + #ifndef exprtk_disable_string_capabilities + str_node = 0; + #endif + } + + std::string name; + std::size_t size; + std::size_t index; + std::size_t depth; + std::size_t ref_count; + std::size_t ip_index; + element_type type; + bool active; + void* data; + expression_node_ptr var_node; + vector_holder_ptr vec_node; + #ifndef exprtk_disable_string_capabilities + stringvar_node_ptr str_node; + #endif + }; + + class scope_element_manager + { + public: + + typedef expression_node_t* expression_node_ptr; + typedef variable_node_t* variable_node_ptr; + typedef parser<T> parser_t; + + explicit scope_element_manager(parser<T>& p) + : parser_(p) + , input_param_cnt_(0) + , total_local_symb_size_bytes_(0) + {} + + inline std::size_t size() const + { + return element_.size(); + } + + inline bool empty() const + { + return element_.empty(); + } + + inline scope_element& get_element(const std::size_t& index) + { + if (index < element_.size()) + return element_[index]; + else + return null_element_; + } + + inline scope_element& get_element(const std::string& var_name, + const std::size_t index = std::numeric_limits<std::size_t>::max()) + { + const std::size_t current_depth = parser_.state_.scope_depth; + + for (std::size_t i = 0; i < element_.size(); ++i) + { + scope_element& se = element_[i]; + + if (se.depth > current_depth) + continue; + else if ( + details::imatch(se.name, var_name) && + (se.index == index) + ) + return se; + } + + return null_element_; + } + + inline scope_element& get_active_element(const std::string& var_name, + const std::size_t index = std::numeric_limits<std::size_t>::max()) + { + const std::size_t current_depth = parser_.state_.scope_depth; + + for (std::size_t i = 0; i < element_.size(); ++i) + { + scope_element& se = element_[i]; + + if (se.depth > current_depth) + continue; + else if ( + details::imatch(se.name, var_name) && + (se.index == index) && + (se.active) + ) + return se; + } + + return null_element_; + } + + inline bool add_element(const scope_element& se) + { + for (std::size_t i = 0; i < element_.size(); ++i) + { + scope_element& cse = element_[i]; + + if ( + details::imatch(cse.name, se.name) && + (cse.depth <= se.depth) && + (cse.index == se.index) && + (cse.size == se.size ) && + (cse.type == se.type ) && + (cse.active) + ) + return false; + } + + switch (se.type) + { + case scope_element::e_variable : total_local_symb_size_bytes_ += sizeof(T); + break; + + case scope_element::e_literal : total_local_symb_size_bytes_ += sizeof(T); + break; + + case scope_element::e_vector : total_local_symb_size_bytes_ += sizeof(T) * se.size; + break; + + default : break; + } + + element_.push_back(se); + std::sort(element_.begin(),element_.end()); + + return true; + } + + inline void deactivate(const std::size_t& scope_depth) + { + exprtk_debug(("deactivate() - Scope depth: %d\n", + static_cast<int>(parser_.state_.scope_depth))); + + for (std::size_t i = 0; i < element_.size(); ++i) + { + scope_element& se = element_[i]; + + if (se.active && (se.depth >= scope_depth)) + { + exprtk_debug(("deactivate() - element[%02d] '%s'\n", + static_cast<int>(i), + se.name.c_str())); + + se.active = false; + } + } + } + + inline void free_element(scope_element& se) + { + exprtk_debug(("free_element() - se[%s]\n", se.name.c_str())); + + switch (se.type) + { + case scope_element::e_literal : delete reinterpret_cast<T*>(se.data); + delete se.var_node; + break; + + case scope_element::e_variable : delete reinterpret_cast<T*>(se.data); + delete se.var_node; + break; + + case scope_element::e_vector : delete[] reinterpret_cast<T*>(se.data); + delete se.vec_node; + break; + + case scope_element::e_vecelem : delete se.var_node; + break; + + #ifndef exprtk_disable_string_capabilities + case scope_element::e_string : delete reinterpret_cast<std::string*>(se.data); + delete se.str_node; + break; + #endif + + default : return; + } + + se.clear(); + } + + inline void cleanup() + { + for (std::size_t i = 0; i < element_.size(); ++i) + { + free_element(element_[i]); + } + + element_.clear(); + + input_param_cnt_ = 0; + total_local_symb_size_bytes_ = 0; + } + + inline std::size_t total_local_symb_size_bytes() const + { + return total_local_symb_size_bytes_; + } + + inline std::size_t next_ip_index() + { + return ++input_param_cnt_; + } + + inline expression_node_ptr get_variable(const T& v) + { + for (std::size_t i = 0; i < element_.size(); ++i) + { + scope_element& se = element_[i]; + + if ( + se.active && + se.var_node && + details::is_variable_node(se.var_node) + ) + { + variable_node_ptr vn = reinterpret_cast<variable_node_ptr>(se.var_node); + + if (&(vn->ref()) == (&v)) + { + return se.var_node; + } + } + } + + return expression_node_ptr(0); + } + + inline std::string get_vector_name(const T* data) + { + for (std::size_t i = 0; i < element_.size(); ++i) + { + scope_element& se = element_[i]; + + if ( + se.active && + se.vec_node && + (se.vec_node->data() == data) + ) + { + return se.name; + } + } + + return "neo-vector"; + } + + private: + + scope_element_manager(const scope_element_manager&) exprtk_delete; + scope_element_manager& operator=(const scope_element_manager&) exprtk_delete; + + parser_t& parser_; + std::vector<scope_element> element_; + scope_element null_element_; + std::size_t input_param_cnt_; + std::size_t total_local_symb_size_bytes_; + }; + + class scope_handler + { + public: + + typedef parser<T> parser_t; + + explicit scope_handler(parser<T>& p) + : parser_(p) + { + parser_.state_.scope_depth++; + #ifdef exprtk_enable_debugging + const std::string depth(2 * parser_.state_.scope_depth,'-'); + exprtk_debug(("%s> Scope Depth: %02d\n", + depth.c_str(), + static_cast<int>(parser_.state_.scope_depth))); + #endif + } + + ~scope_handler() + { + parser_.sem_.deactivate(parser_.state_.scope_depth); + parser_.state_.scope_depth--; + #ifdef exprtk_enable_debugging + const std::string depth(2 * parser_.state_.scope_depth,'-'); + exprtk_debug(("<%s Scope Depth: %02d\n", + depth.c_str(), + static_cast<int>(parser_.state_.scope_depth))); + #endif + } + + private: + + scope_handler(const scope_handler&) exprtk_delete; + scope_handler& operator=(const scope_handler&) exprtk_delete; + + parser_t& parser_; + }; + + template <typename T_> + struct halfopen_range_policy + { + static inline bool is_within(const T_& v, const T_& begin, const T_& end) + { + assert(begin <= end); + return (begin <= v) && (v < end); + } + + static inline bool is_less(const T_& v, const T_& begin) + { + return (v < begin); + } + + static inline bool is_greater(const T_& v, const T_& end) + { + return (end <= v); + } + + static inline bool end_inclusive() + { + return false; + } + }; + + template <typename T_> + struct closed_range_policy + { + static inline bool is_within(const T_& v, const T_& begin, const T_& end) + { + assert(begin <= end); + return (begin <= v) && (v <= end); + } + + static inline bool is_less(const T_& v, const T_& begin) + { + return (v < begin); + } + + static inline bool is_greater(const T_& v, const T_& end) + { + return (end < v); + } + + static inline bool end_inclusive() + { + return true; + } + }; + + template <typename IntervalPointType, + typename RangePolicy = halfopen_range_policy<IntervalPointType> > + class interval_container_t + { + public: + + typedef IntervalPointType interval_point_t; + typedef std::pair<interval_point_t, interval_point_t> interval_t; + typedef std::map<interval_point_t, interval_t> interval_map_t; + typedef typename interval_map_t::const_iterator interval_map_citr_t; + + std::size_t size() const + { + return interval_map_.size(); + } + + void reset() + { + interval_map_.clear(); + } + + bool in_interval(const interval_point_t point, interval_t& interval) const + { + interval_map_citr_t itr = RangePolicy::end_inclusive() ? + interval_map_.lower_bound(point): + interval_map_.upper_bound(point); + + for (; itr != interval_map_.end(); ++itr) + { + const interval_point_t& begin = itr->second.first; + const interval_point_t& end = itr->second.second; + + if (RangePolicy::is_within(point, begin, end)) + { + interval = interval_t(begin,end); + return true; + } + else if (RangePolicy::is_greater(point, end)) + { + break; + } + } + + return false; + } + + bool in_interval(const interval_point_t point) const + { + interval_t interval; + return in_interval(point,interval); + } + + bool add_interval(const interval_point_t begin, const interval_point_t end) + { + if ((end <= begin) || in_interval(begin) || in_interval(end)) + { + return false; + } + + interval_map_[end] = std::make_pair(begin, end); + + return true; + } + + bool add_interval(const interval_t interval) + { + return add_interval(interval.first, interval.second); + } + + private: + + interval_map_t interval_map_; + }; + + class stack_limit_handler + { + public: + + typedef parser<T> parser_t; + + explicit stack_limit_handler(parser<T>& p) + : parser_(p) + , limit_exceeded_(false) + { + if (++parser_.state_.stack_depth > parser_.settings_.max_stack_depth_) + { + limit_exceeded_ = true; + parser_.set_error(make_error( + parser_error::e_parser, + "ERR000 - Current stack depth " + details::to_str(parser_.state_.stack_depth) + + " exceeds maximum allowed stack depth of " + details::to_str(parser_.settings_.max_stack_depth_), + exprtk_error_location)); + } + } + + ~stack_limit_handler() + { + assert(parser_.state_.stack_depth > 0); + parser_.state_.stack_depth--; + } + + bool operator!() + { + return limit_exceeded_; + } + + private: + + stack_limit_handler(const stack_limit_handler&) exprtk_delete; + stack_limit_handler& operator=(const stack_limit_handler&) exprtk_delete; + + parser_t& parser_; + bool limit_exceeded_; + }; + + struct symtab_store + { + symbol_table_list_t symtab_list_; + + typedef typename symbol_table_t::local_data_t local_data_t; + typedef typename symbol_table_t::variable_ptr variable_ptr; + typedef typename symbol_table_t::function_ptr function_ptr; + #ifndef exprtk_disable_string_capabilities + typedef typename symbol_table_t::stringvar_ptr stringvar_ptr; + #endif + typedef typename symbol_table_t::vector_holder_ptr vector_holder_ptr; + typedef typename symbol_table_t::vararg_function_ptr vararg_function_ptr; + typedef typename symbol_table_t::generic_function_ptr generic_function_ptr; + + struct variable_context + { + variable_context() + : symbol_table(0) + , variable(0) + {} + + const symbol_table_t* symbol_table; + variable_ptr variable; + }; + + struct vector_context + { + vector_context() + : symbol_table(0) + , vector_holder(0) + {} + + const symbol_table_t* symbol_table; + vector_holder_ptr vector_holder; + }; + + #ifndef exprtk_disable_string_capabilities + struct string_context + { + string_context() + : symbol_table(0) + , str_var(0) + {} + + const symbol_table_t* symbol_table; + stringvar_ptr str_var; + }; + #endif + + inline bool empty() const + { + return symtab_list_.empty(); + } + + inline void clear() + { + symtab_list_.clear(); + } + + inline bool valid() const + { + if (!empty()) + { + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (symtab_list_[i].valid()) + return true; + } + } + + return false; + } + + inline bool valid_symbol(const std::string& symbol) const + { + if (!symtab_list_.empty()) + return symtab_list_[0].valid_symbol(symbol); + else + return false; + } + + inline bool valid_function_name(const std::string& symbol) const + { + if (!symtab_list_.empty()) + return symtab_list_[0].valid_function(symbol); + else + return false; + } + + inline variable_context get_variable_context(const std::string& variable_name) const + { + variable_context result; + + if (valid_symbol(variable_name)) + { + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + { + continue; + } + + result.variable = local_data(i) + .variable_store.get(variable_name); + if (result.variable) + { + result.symbol_table = &symtab_list_[i]; + break; + } + } + } + + return result; + } + + inline variable_ptr get_variable(const std::string& variable_name) const + { + if (!valid_symbol(variable_name)) + return reinterpret_cast<variable_ptr>(0); + + variable_ptr result = reinterpret_cast<variable_ptr>(0); + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else + result = local_data(i) + .variable_store.get(variable_name); + + if (result) break; + } + + return result; + } + + inline variable_ptr get_variable(const T& var_ref) const + { + variable_ptr result = reinterpret_cast<variable_ptr>(0); + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else + result = local_data(i).variable_store + .get_from_varptr(reinterpret_cast<const void*>(&var_ref)); + + if (result) break; + } + + return result; + } + + #ifndef exprtk_disable_string_capabilities + inline string_context get_string_context(const std::string& string_name) const + { + string_context result; + + if (!valid_symbol(string_name)) + return result; + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + { + continue; + } + + result.str_var = local_data(i).stringvar_store.get(string_name); + + if (result.str_var) + { + result.symbol_table = &symtab_list_[i]; + break; + } + } + + return result; + } + + inline stringvar_ptr get_stringvar(const std::string& string_name) const + { + if (!valid_symbol(string_name)) + return reinterpret_cast<stringvar_ptr>(0); + + stringvar_ptr result = reinterpret_cast<stringvar_ptr>(0); + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else + result = local_data(i) + .stringvar_store.get(string_name); + + if (result) break; + } + + return result; + } + #endif + + inline function_ptr get_function(const std::string& function_name) const + { + if (!valid_function_name(function_name)) + return reinterpret_cast<function_ptr>(0); + + function_ptr result = reinterpret_cast<function_ptr>(0); + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else + result = local_data(i) + .function_store.get(function_name); + + if (result) break; + } + + return result; + } + + inline vararg_function_ptr get_vararg_function(const std::string& vararg_function_name) const + { + if (!valid_function_name(vararg_function_name)) + return reinterpret_cast<vararg_function_ptr>(0); + + vararg_function_ptr result = reinterpret_cast<vararg_function_ptr>(0); + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else + result = local_data(i) + .vararg_function_store.get(vararg_function_name); + + if (result) break; + } + + return result; + } + + inline generic_function_ptr get_generic_function(const std::string& function_name) const + { + if (!valid_function_name(function_name)) + return reinterpret_cast<generic_function_ptr>(0); + + generic_function_ptr result = reinterpret_cast<generic_function_ptr>(0); + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else + result = local_data(i) + .generic_function_store.get(function_name); + + if (result) break; + } + + return result; + } + + inline generic_function_ptr get_string_function(const std::string& function_name) const + { + if (!valid_function_name(function_name)) + return reinterpret_cast<generic_function_ptr>(0); + + generic_function_ptr result = reinterpret_cast<generic_function_ptr>(0); + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else + result = + local_data(i).string_function_store.get(function_name); + + if (result) break; + } + + return result; + } + + inline generic_function_ptr get_overload_function(const std::string& function_name) const + { + if (!valid_function_name(function_name)) + return reinterpret_cast<generic_function_ptr>(0); + + generic_function_ptr result = reinterpret_cast<generic_function_ptr>(0); + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else + result = + local_data(i).overload_function_store.get(function_name); + + if (result) break; + } + + return result; + } + + inline vector_context get_vector_context(const std::string& vector_name) const + { + vector_context result; + if (!valid_symbol(vector_name)) + return result; + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + { + continue; + } + + result.vector_holder = local_data(i).vector_store.get(vector_name); + + if (result.vector_holder) + { + result.symbol_table = &symtab_list_[i]; + break; + } + } + + return result; + } + + inline vector_holder_ptr get_vector(const std::string& vector_name) const + { + if (!valid_symbol(vector_name)) + return reinterpret_cast<vector_holder_ptr>(0); + + vector_holder_ptr result = reinterpret_cast<vector_holder_ptr>(0); + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + { + continue; + } + + result = local_data(i).vector_store.get(vector_name); + + if (result) + { + break; + } + } + + return result; + } + + inline bool is_constant_node(const std::string& symbol_name) const + { + if (!valid_symbol(symbol_name)) + return false; + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + { + continue; + } + + if (local_data(i).variable_store.is_constant(symbol_name)) + { + return true; + } + } + + return false; + } + + #ifndef exprtk_disable_string_capabilities + inline bool is_constant_string(const std::string& symbol_name) const + { + if (!valid_symbol(symbol_name)) + return false; + + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else if (!local_data(i).stringvar_store.symbol_exists(symbol_name)) + continue; + else if (local_data(i).stringvar_store.is_constant(symbol_name)) + return true; + } + + return false; + } + #endif + + inline bool symbol_exists(const std::string& symbol) const + { + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + { + continue; + } + + if (symtab_list_[i].symbol_exists(symbol)) + { + return true; + } + } + + return false; + } + + inline bool is_variable(const std::string& variable_name) const + { + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else if ( + symtab_list_[i].local_data().variable_store + .symbol_exists(variable_name) + ) + return true; + } + + return false; + } + + #ifndef exprtk_disable_string_capabilities + inline bool is_stringvar(const std::string& stringvar_name) const + { + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else if ( + symtab_list_[i].local_data().stringvar_store + .symbol_exists(stringvar_name) + ) + return true; + } + + return false; + } + + inline bool is_conststr_stringvar(const std::string& symbol_name) const + { + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else if ( + symtab_list_[i].local_data().stringvar_store + .symbol_exists(symbol_name) + ) + { + return ( + local_data(i).stringvar_store.symbol_exists(symbol_name) || + local_data(i).stringvar_store.is_constant (symbol_name) + ); + + } + } + + return false; + } + #endif + + inline bool is_function(const std::string& function_name) const + { + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else if ( + local_data(i).vararg_function_store + .symbol_exists(function_name) + ) + return true; + } + + return false; + } + + inline bool is_vararg_function(const std::string& vararg_function_name) const + { + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else if ( + local_data(i).vararg_function_store + .symbol_exists(vararg_function_name) + ) + return true; + } + + return false; + } + + inline bool is_vector(const std::string& vector_name) const + { + for (std::size_t i = 0; i < symtab_list_.size(); ++i) + { + if (!symtab_list_[i].valid()) + continue; + else if ( + local_data(i).vector_store + .symbol_exists(vector_name) + ) + return true; + } + + return false; + } + + inline std::string get_variable_name(const expression_node_ptr& ptr) const + { + return local_data().variable_store.entity_name(ptr); + } + + inline std::string get_vector_name(const vector_holder_ptr& ptr) const + { + return local_data().vector_store.entity_name(ptr); + } + + #ifndef exprtk_disable_string_capabilities + inline std::string get_stringvar_name(const expression_node_ptr& ptr) const + { + return local_data().stringvar_store.entity_name(ptr); + } + + inline std::string get_conststr_stringvar_name(const expression_node_ptr& ptr) const + { + return local_data().stringvar_store.entity_name(ptr); + } + #endif + + inline local_data_t& local_data(const std::size_t& index = 0) + { + return symtab_list_[index].local_data(); + } + + inline const local_data_t& local_data(const std::size_t& index = 0) const + { + return symtab_list_[index].local_data(); + } + + inline symbol_table_t& get_symbol_table(const std::size_t& index = 0) + { + return symtab_list_[index]; + } + }; + + struct parser_state + { + parser_state() + : type_check_enabled(true) + { + reset(); + } + + void reset() + { + parsing_return_stmt = false; + parsing_break_stmt = false; + parsing_assert_stmt = false; + return_stmt_present = false; + side_effect_present = false; + scope_depth = 0; + stack_depth = 0; + parsing_loop_stmt_count = 0; + } + + #ifndef exprtk_enable_debugging + void activate_side_effect(const std::string&) + #else + void activate_side_effect(const std::string& source) + #endif + { + if (!side_effect_present) + { + side_effect_present = true; + + exprtk_debug(("activate_side_effect() - caller: %s\n", source.c_str())); + } + } + + bool parsing_return_stmt; + bool parsing_break_stmt; + bool parsing_assert_stmt; + bool return_stmt_present; + bool side_effect_present; + bool type_check_enabled; + std::size_t scope_depth; + std::size_t stack_depth; + std::size_t parsing_loop_stmt_count; + }; + + public: + + struct unknown_symbol_resolver + { + + enum usr_symbol_type + { + e_usr_unknown_type = 0, + e_usr_variable_type = 1, + e_usr_constant_type = 2 + }; + + enum usr_mode + { + e_usrmode_default = 0, + e_usrmode_extended = 1 + }; + + usr_mode mode; + + explicit unknown_symbol_resolver(const usr_mode m = e_usrmode_default) + : mode(m) + {} + + virtual ~unknown_symbol_resolver() + {} + + virtual bool process(const std::string& /*unknown_symbol*/, + usr_symbol_type& st, + T& default_value, + std::string& error_message) + { + if (e_usrmode_default != mode) + return false; + + st = e_usr_variable_type; + default_value = T(0); + error_message.clear(); + + return true; + } + + virtual bool process(const std::string& /* unknown_symbol */, + symbol_table_t& /* symbol_table */, + std::string& /* error_message */) + { + return false; + } + }; + + enum collect_type + { + e_ct_none = 0, + e_ct_variables = 1, + e_ct_functions = 2, + e_ct_assignments = 4 + }; + + enum symbol_type + { + e_st_unknown = 0, + e_st_variable = 1, + e_st_vector = 2, + e_st_vecelem = 3, + e_st_string = 4, + e_st_function = 5, + e_st_local_variable = 6, + e_st_local_vector = 7, + e_st_local_string = 8 + }; + + class dependent_entity_collector + { + public: + + typedef std::pair<std::string,symbol_type> symbol_t; + typedef std::vector<symbol_t> symbol_list_t; + + explicit dependent_entity_collector(const std::size_t options = e_ct_none) + : options_(options) + , collect_variables_ ((options_ & e_ct_variables ) == e_ct_variables ) + , collect_functions_ ((options_ & e_ct_functions ) == e_ct_functions ) + , collect_assignments_((options_ & e_ct_assignments) == e_ct_assignments) + , return_present_ (false) + , final_stmt_return_(false) + {} + + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t symbols(Sequence<symbol_t,Allocator>& symbols_list) + { + if (!collect_variables_ && !collect_functions_) + return 0; + else if (symbol_name_list_.empty()) + return 0; + + for (std::size_t i = 0; i < symbol_name_list_.size(); ++i) + { + details::case_normalise(symbol_name_list_[i].first); + } + + std::sort(symbol_name_list_.begin(), symbol_name_list_.end()); + + std::unique_copy + ( + symbol_name_list_.begin(), + symbol_name_list_.end (), + std::back_inserter(symbols_list) + ); + + return symbols_list.size(); + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline std::size_t assignment_symbols(Sequence<symbol_t,Allocator>& assignment_list) + { + if (!collect_assignments_) + return 0; + else if (assignment_name_list_.empty()) + return 0; + + for (std::size_t i = 0; i < assignment_name_list_.size(); ++i) + { + details::case_normalise(assignment_name_list_[i].first); + } + + std::sort(assignment_name_list_.begin(),assignment_name_list_.end()); + + std::unique_copy + ( + assignment_name_list_.begin(), + assignment_name_list_.end (), + std::back_inserter(assignment_list) + ); + + return assignment_list.size(); + } + + void clear() + { + symbol_name_list_ .clear(); + assignment_name_list_.clear(); + retparam_list_ .clear(); + return_present_ = false; + final_stmt_return_ = false; + } + + bool& collect_variables() + { + return collect_variables_; + } + + bool& collect_functions() + { + return collect_functions_; + } + + bool& collect_assignments() + { + return collect_assignments_; + } + + bool return_present() const + { + return return_present_; + } + + bool final_stmt_return() const + { + return final_stmt_return_; + } + + typedef std::vector<std::string> retparam_list_t; + + retparam_list_t return_param_type_list() const + { + return retparam_list_; + } + + private: + + inline void add_symbol(const std::string& symbol, const symbol_type st) + { + switch (st) + { + case e_st_variable : + case e_st_vector : + case e_st_string : + case e_st_local_variable : + case e_st_local_vector : + case e_st_local_string : if (collect_variables_) + symbol_name_list_ + .push_back(std::make_pair(symbol, st)); + break; + + case e_st_function : if (collect_functions_) + symbol_name_list_ + .push_back(std::make_pair(symbol, st)); + break; + + default : return; + } + } + + inline void add_assignment(const std::string& symbol, const symbol_type st) + { + switch (st) + { + case e_st_variable : + case e_st_vector : + case e_st_string : if (collect_assignments_) + assignment_name_list_ + .push_back(std::make_pair(symbol, st)); + break; + + default : return; + } + } + + std::size_t options_; + bool collect_variables_; + bool collect_functions_; + bool collect_assignments_; + bool return_present_; + bool final_stmt_return_; + symbol_list_t symbol_name_list_; + symbol_list_t assignment_name_list_; + retparam_list_t retparam_list_; + + friend class parser<T>; + }; + + class settings_store + { + private: + + typedef std::set<std::string,details::ilesscompare> disabled_entity_set_t; + typedef disabled_entity_set_t::iterator des_itr_t; + + public: + + enum settings_compilation_options + { + e_unknown = 0, + e_replacer = 1, + e_joiner = 2, + e_numeric_check = 4, + e_bracket_check = 8, + e_sequence_check = 16, + e_commutative_check = 32, + e_strength_reduction = 64, + e_disable_vardef = 128, + e_collect_vars = 256, + e_collect_funcs = 512, + e_collect_assings = 1024, + e_disable_usr_on_rsrvd = 2048, + e_disable_zero_return = 4096 + }; + + enum settings_base_funcs + { + e_bf_unknown = 0, + e_bf_abs , e_bf_acos , e_bf_acosh , e_bf_asin , + e_bf_asinh , e_bf_atan , e_bf_atan2 , e_bf_atanh , + e_bf_avg , e_bf_ceil , e_bf_clamp , e_bf_cos , + e_bf_cosh , e_bf_cot , e_bf_csc , e_bf_equal , + e_bf_erf , e_bf_erfc , e_bf_exp , e_bf_expm1 , + e_bf_floor , e_bf_frac , e_bf_hypot , e_bf_iclamp , + e_bf_like , e_bf_log , e_bf_log10 , e_bf_log1p , + e_bf_log2 , e_bf_logn , e_bf_mand , e_bf_max , + e_bf_min , e_bf_mod , e_bf_mor , e_bf_mul , + e_bf_ncdf , e_bf_pow , e_bf_root , e_bf_round , + e_bf_roundn , e_bf_sec , e_bf_sgn , e_bf_sin , + e_bf_sinc , e_bf_sinh , e_bf_sqrt , e_bf_sum , + e_bf_swap , e_bf_tan , e_bf_tanh , e_bf_trunc , + e_bf_not_equal , e_bf_inrange , e_bf_deg2grad , e_bf_deg2rad , + e_bf_rad2deg , e_bf_grad2deg + }; + + enum settings_control_structs + { + e_ctrl_unknown = 0, + e_ctrl_ifelse, + e_ctrl_switch, + e_ctrl_for_loop, + e_ctrl_while_loop, + e_ctrl_repeat_loop, + e_ctrl_return + }; + + enum settings_logic_opr + { + e_logic_unknown = 0, + e_logic_and, e_logic_nand , e_logic_nor , + e_logic_not, e_logic_or , e_logic_xnor, + e_logic_xor, e_logic_scand, e_logic_scor + }; + + enum settings_arithmetic_opr + { + e_arith_unknown = 0, + e_arith_add, e_arith_sub, e_arith_mul, + e_arith_div, e_arith_mod, e_arith_pow + }; + + enum settings_assignment_opr + { + e_assign_unknown = 0, + e_assign_assign, e_assign_addass, e_assign_subass, + e_assign_mulass, e_assign_divass, e_assign_modass + }; + + enum settings_inequality_opr + { + e_ineq_unknown = 0, + e_ineq_lt , e_ineq_lte, e_ineq_eq , + e_ineq_equal, e_ineq_ne , e_ineq_nequal, + e_ineq_gte , e_ineq_gt + }; + + static const std::size_t default_compile_all_opts = + e_replacer + + e_joiner + + e_numeric_check + + e_bracket_check + + e_sequence_check + + e_commutative_check + + e_strength_reduction; + + settings_store(const std::size_t compile_options = default_compile_all_opts) + : max_stack_depth_(400) + , max_node_depth_(10000) + , max_total_local_symbol_size_bytes_(2000000000) + , max_local_vector_size_(max_total_local_symbol_size_bytes_ / sizeof(T)) + { + load_compile_options(compile_options); + } + + settings_store& enable_all_base_functions() + { + disabled_func_set_.clear(); + return (*this); + } + + settings_store& enable_all_control_structures() + { + disabled_ctrl_set_.clear(); + return (*this); + } + + settings_store& enable_all_logic_ops() + { + disabled_logic_set_.clear(); + return (*this); + } + + settings_store& enable_all_arithmetic_ops() + { + disabled_arithmetic_set_.clear(); + return (*this); + } + + settings_store& enable_all_assignment_ops() + { + disabled_assignment_set_.clear(); + return (*this); + } + + settings_store& enable_all_inequality_ops() + { + disabled_inequality_set_.clear(); + return (*this); + } + + settings_store& enable_local_vardef() + { + disable_vardef_ = false; + return (*this); + } + + settings_store& enable_commutative_check() + { + enable_commutative_check_ = true; + return (*this); + } + + settings_store& enable_strength_reduction() + { + enable_strength_reduction_ = true; + return (*this); + } + + settings_store& disable_all_base_functions() + { + std::copy(details::base_function_list, + details::base_function_list + details::base_function_list_size, + std::insert_iterator<disabled_entity_set_t> + (disabled_func_set_, disabled_func_set_.begin())); + return (*this); + } + + settings_store& disable_all_control_structures() + { + std::copy(details::cntrl_struct_list, + details::cntrl_struct_list + details::cntrl_struct_list_size, + std::insert_iterator<disabled_entity_set_t> + (disabled_ctrl_set_, disabled_ctrl_set_.begin())); + return (*this); + } + + settings_store& disable_all_logic_ops() + { + std::copy(details::logic_ops_list, + details::logic_ops_list + details::logic_ops_list_size, + std::insert_iterator<disabled_entity_set_t> + (disabled_logic_set_, disabled_logic_set_.begin())); + return (*this); + } + + settings_store& disable_all_arithmetic_ops() + { + std::copy(details::arithmetic_ops_list, + details::arithmetic_ops_list + details::arithmetic_ops_list_size, + std::insert_iterator<disabled_entity_set_t> + (disabled_arithmetic_set_, disabled_arithmetic_set_.begin())); + return (*this); + } + + settings_store& disable_all_assignment_ops() + { + std::copy(details::assignment_ops_list, + details::assignment_ops_list + details::assignment_ops_list_size, + std::insert_iterator<disabled_entity_set_t> + (disabled_assignment_set_, disabled_assignment_set_.begin())); + return (*this); + } + + settings_store& disable_all_inequality_ops() + { + std::copy(details::inequality_ops_list, + details::inequality_ops_list + details::inequality_ops_list_size, + std::insert_iterator<disabled_entity_set_t> + (disabled_inequality_set_, disabled_inequality_set_.begin())); + return (*this); + } + + settings_store& disable_local_vardef() + { + disable_vardef_ = true; + return (*this); + } + + settings_store& disable_commutative_check() + { + enable_commutative_check_ = false; + return (*this); + } + + settings_store& disable_strength_reduction() + { + enable_strength_reduction_ = false; + return (*this); + } + + bool replacer_enabled () const { return enable_replacer_; } + bool commutative_check_enabled () const { return enable_commutative_check_; } + bool joiner_enabled () const { return enable_joiner_; } + bool numeric_check_enabled () const { return enable_numeric_check_; } + bool bracket_check_enabled () const { return enable_bracket_check_; } + bool sequence_check_enabled () const { return enable_sequence_check_; } + bool strength_reduction_enabled () const { return enable_strength_reduction_; } + bool collect_variables_enabled () const { return enable_collect_vars_; } + bool collect_functions_enabled () const { return enable_collect_funcs_; } + bool collect_assignments_enabled() const { return enable_collect_assings_; } + bool vardef_disabled () const { return disable_vardef_; } + bool rsrvd_sym_usr_disabled () const { return disable_rsrvd_sym_usr_; } + bool zero_return_disabled () const { return disable_zero_return_; } + + bool function_enabled(const std::string& function_name) const + { + if (disabled_func_set_.empty()) + return true; + else + return (disabled_func_set_.end() == disabled_func_set_.find(function_name)); + } + + bool control_struct_enabled(const std::string& control_struct) const + { + if (disabled_ctrl_set_.empty()) + return true; + else + return (disabled_ctrl_set_.end() == disabled_ctrl_set_.find(control_struct)); + } + + bool logic_enabled(const std::string& logic_operation) const + { + if (disabled_logic_set_.empty()) + return true; + else + return (disabled_logic_set_.end() == disabled_logic_set_.find(logic_operation)); + } + + bool arithmetic_enabled(const details::operator_type& arithmetic_operation) const + { + if (disabled_logic_set_.empty()) + return true; + else + return disabled_arithmetic_set_.end() == disabled_arithmetic_set_ + .find(arith_opr_to_string(arithmetic_operation)); + } + + bool assignment_enabled(const details::operator_type& assignment) const + { + if (disabled_assignment_set_.empty()) + return true; + else + return disabled_assignment_set_.end() == disabled_assignment_set_ + .find(assign_opr_to_string(assignment)); + } + + bool inequality_enabled(const details::operator_type& inequality) const + { + if (disabled_inequality_set_.empty()) + return true; + else + return disabled_inequality_set_.end() == disabled_inequality_set_ + .find(inequality_opr_to_string(inequality)); + } + + bool function_disabled(const std::string& function_name) const + { + if (disabled_func_set_.empty()) + return false; + else + return (disabled_func_set_.end() != disabled_func_set_.find(function_name)); + } + + bool control_struct_disabled(const std::string& control_struct) const + { + if (disabled_ctrl_set_.empty()) + return false; + else + return (disabled_ctrl_set_.end() != disabled_ctrl_set_.find(control_struct)); + } + + bool logic_disabled(const std::string& logic_operation) const + { + if (disabled_logic_set_.empty()) + return false; + else + return (disabled_logic_set_.end() != disabled_logic_set_.find(logic_operation)); + } + + bool assignment_disabled(const details::operator_type assignment_operation) const + { + if (disabled_assignment_set_.empty()) + return false; + else + return disabled_assignment_set_.end() != disabled_assignment_set_ + .find(assign_opr_to_string(assignment_operation)); + } + + bool logic_disabled(const details::operator_type logic_operation) const + { + if (disabled_logic_set_.empty()) + return false; + else + return disabled_logic_set_.end() != disabled_logic_set_ + .find(logic_opr_to_string(logic_operation)); + } + + bool arithmetic_disabled(const details::operator_type arithmetic_operation) const + { + if (disabled_arithmetic_set_.empty()) + return false; + else + return disabled_arithmetic_set_.end() != disabled_arithmetic_set_ + .find(arith_opr_to_string(arithmetic_operation)); + } + + bool inequality_disabled(const details::operator_type& inequality) const + { + if (disabled_inequality_set_.empty()) + return false; + else + return disabled_inequality_set_.end() != disabled_inequality_set_ + .find(inequality_opr_to_string(inequality)); + } + + settings_store& disable_base_function(const settings_base_funcs bf) + { + if ( + (e_bf_unknown != bf) && + (static_cast<std::size_t>(bf) < (details::base_function_list_size + 1)) + ) + { + disabled_func_set_.insert(details::base_function_list[bf - 1]); + } + + return (*this); + } + + settings_store& disable_control_structure(const settings_control_structs ctrl_struct) + { + if ( + (e_ctrl_unknown != ctrl_struct) && + (static_cast<std::size_t>(ctrl_struct) < (details::cntrl_struct_list_size + 1)) + ) + { + disabled_ctrl_set_.insert(details::cntrl_struct_list[ctrl_struct - 1]); + } + + return (*this); + } + + settings_store& disable_logic_operation(const settings_logic_opr logic) + { + if ( + (e_logic_unknown != logic) && + (static_cast<std::size_t>(logic) < (details::logic_ops_list_size + 1)) + ) + { + disabled_logic_set_.insert(details::logic_ops_list[logic - 1]); + } + + return (*this); + } + + settings_store& disable_arithmetic_operation(const settings_arithmetic_opr arithmetic) + { + if ( + (e_arith_unknown != arithmetic) && + (static_cast<std::size_t>(arithmetic) < (details::arithmetic_ops_list_size + 1)) + ) + { + disabled_arithmetic_set_.insert(details::arithmetic_ops_list[arithmetic - 1]); + } + + return (*this); + } + + settings_store& disable_assignment_operation(const settings_assignment_opr assignment) + { + if ( + (e_assign_unknown != assignment) && + (static_cast<std::size_t>(assignment) < (details::assignment_ops_list_size + 1)) + ) + { + disabled_assignment_set_.insert(details::assignment_ops_list[assignment - 1]); + } + + return (*this); + } + + settings_store& disable_inequality_operation(const settings_inequality_opr inequality) + { + if ( + (e_ineq_unknown != inequality) && + (static_cast<std::size_t>(inequality) < (details::inequality_ops_list_size + 1)) + ) + { + disabled_inequality_set_.insert(details::inequality_ops_list[inequality - 1]); + } + + return (*this); + } + + settings_store& enable_base_function(const settings_base_funcs bf) + { + if ( + (e_bf_unknown != bf) && + (static_cast<std::size_t>(bf) < (details::base_function_list_size + 1)) + ) + { + const des_itr_t itr = disabled_func_set_.find(details::base_function_list[bf - 1]); + + if (disabled_func_set_.end() != itr) + { + disabled_func_set_.erase(itr); + } + } + + return (*this); + } + + settings_store& enable_control_structure(const settings_control_structs ctrl_struct) + { + if ( + (e_ctrl_unknown != ctrl_struct) && + (static_cast<std::size_t>(ctrl_struct) < (details::cntrl_struct_list_size + 1)) + ) + { + const des_itr_t itr = disabled_ctrl_set_.find(details::cntrl_struct_list[ctrl_struct - 1]); + + if (disabled_ctrl_set_.end() != itr) + { + disabled_ctrl_set_.erase(itr); + } + } + + return (*this); + } + + settings_store& enable_logic_operation(const settings_logic_opr logic) + { + if ( + (e_logic_unknown != logic) && + (static_cast<std::size_t>(logic) < (details::logic_ops_list_size + 1)) + ) + { + const des_itr_t itr = disabled_logic_set_.find(details::logic_ops_list[logic - 1]); + + if (disabled_logic_set_.end() != itr) + { + disabled_logic_set_.erase(itr); + } + } + + return (*this); + } + + settings_store& enable_arithmetic_operation(const settings_arithmetic_opr arithmetic) + { + if ( + (e_arith_unknown != arithmetic) && + (static_cast<std::size_t>(arithmetic) < (details::arithmetic_ops_list_size + 1)) + ) + { + const des_itr_t itr = disabled_arithmetic_set_.find(details::arithmetic_ops_list[arithmetic - 1]); + + if (disabled_arithmetic_set_.end() != itr) + { + disabled_arithmetic_set_.erase(itr); + } + } + + return (*this); + } + + settings_store& enable_assignment_operation(const settings_assignment_opr assignment) + { + if ( + (e_assign_unknown != assignment) && + (static_cast<std::size_t>(assignment) < (details::assignment_ops_list_size + 1)) + ) + { + const des_itr_t itr = disabled_assignment_set_.find(details::assignment_ops_list[assignment - 1]); + + if (disabled_assignment_set_.end() != itr) + { + disabled_assignment_set_.erase(itr); + } + } + + return (*this); + } + + settings_store& enable_inequality_operation(const settings_inequality_opr inequality) + { + if ( + (e_ineq_unknown != inequality) && + (static_cast<std::size_t>(inequality) < (details::inequality_ops_list_size + 1)) + ) + { + const des_itr_t itr = disabled_inequality_set_.find(details::inequality_ops_list[inequality - 1]); + + if (disabled_inequality_set_.end() != itr) + { + disabled_inequality_set_.erase(itr); + } + } + + return (*this); + } + + void set_max_stack_depth(const std::size_t max_stack_depth) + { + max_stack_depth_ = max_stack_depth; + } + + void set_max_node_depth(const std::size_t max_node_depth) + { + max_node_depth_ = max_node_depth; + } + + void set_max_local_vector_size(const std::size_t max_local_vector_size) + { + max_local_vector_size_ = max_local_vector_size; + } + + void set_max_total_local_symbol_size_bytes(const std::size_t max_total_lcl_symb_size) + { + max_total_local_symbol_size_bytes_ = max_total_lcl_symb_size; + } + + std::size_t max_stack_depth() const + { + return max_stack_depth_; + } + + std::size_t max_node_depth() const + { + return max_node_depth_; + } + + std::size_t max_local_vector_size() const + { + return max_local_vector_size_; + } + + std::size_t max_total_local_symbol_size_bytes() const + { + return max_total_local_symbol_size_bytes_; + } + + private: + + void load_compile_options(const std::size_t compile_options) + { + enable_replacer_ = (compile_options & e_replacer ) == e_replacer; + enable_joiner_ = (compile_options & e_joiner ) == e_joiner; + enable_numeric_check_ = (compile_options & e_numeric_check ) == e_numeric_check; + enable_bracket_check_ = (compile_options & e_bracket_check ) == e_bracket_check; + enable_sequence_check_ = (compile_options & e_sequence_check ) == e_sequence_check; + enable_commutative_check_ = (compile_options & e_commutative_check ) == e_commutative_check; + enable_strength_reduction_ = (compile_options & e_strength_reduction ) == e_strength_reduction; + enable_collect_vars_ = (compile_options & e_collect_vars ) == e_collect_vars; + enable_collect_funcs_ = (compile_options & e_collect_funcs ) == e_collect_funcs; + enable_collect_assings_ = (compile_options & e_collect_assings ) == e_collect_assings; + disable_vardef_ = (compile_options & e_disable_vardef ) == e_disable_vardef; + disable_rsrvd_sym_usr_ = (compile_options & e_disable_usr_on_rsrvd) == e_disable_usr_on_rsrvd; + disable_zero_return_ = (compile_options & e_disable_zero_return ) == e_disable_zero_return; + } + + std::string assign_opr_to_string(details::operator_type opr) const + { + switch (opr) + { + case details::e_assign : return ":="; + case details::e_addass : return "+="; + case details::e_subass : return "-="; + case details::e_mulass : return "*="; + case details::e_divass : return "/="; + case details::e_modass : return "%="; + default : return "" ; + } + } + + std::string arith_opr_to_string(details::operator_type opr) const + { + switch (opr) + { + case details::e_add : return "+"; + case details::e_sub : return "-"; + case details::e_mul : return "*"; + case details::e_div : return "/"; + case details::e_mod : return "%"; + case details::e_pow : return "^"; + default : return "" ; + } + } + + std::string inequality_opr_to_string(details::operator_type opr) const + { + switch (opr) + { + case details::e_lt : return "<" ; + case details::e_lte : return "<="; + case details::e_eq : return "=="; + case details::e_equal : return "=" ; + case details::e_ne : return "!="; + case details::e_nequal: return "<>"; + case details::e_gte : return ">="; + case details::e_gt : return ">" ; + default : return "" ; + } + } + + std::string logic_opr_to_string(details::operator_type opr) const + { + switch (opr) + { + case details::e_and : return "and" ; + case details::e_or : return "or" ; + case details::e_xor : return "xor" ; + case details::e_nand : return "nand"; + case details::e_nor : return "nor" ; + case details::e_xnor : return "xnor"; + case details::e_notl : return "not" ; + default : return "" ; + } + } + + bool enable_replacer_; + bool enable_joiner_; + bool enable_numeric_check_; + bool enable_bracket_check_; + bool enable_sequence_check_; + bool enable_commutative_check_; + bool enable_strength_reduction_; + bool enable_collect_vars_; + bool enable_collect_funcs_; + bool enable_collect_assings_; + bool disable_vardef_; + bool disable_rsrvd_sym_usr_; + bool disable_zero_return_; + + disabled_entity_set_t disabled_func_set_ ; + disabled_entity_set_t disabled_ctrl_set_ ; + disabled_entity_set_t disabled_logic_set_; + disabled_entity_set_t disabled_arithmetic_set_; + disabled_entity_set_t disabled_assignment_set_; + disabled_entity_set_t disabled_inequality_set_; + + std::size_t max_stack_depth_; + std::size_t max_node_depth_; + std::size_t max_total_local_symbol_size_bytes_; + std::size_t max_local_vector_size_; + + friend class parser<T>; + }; + + typedef settings_store settings_t; + + explicit parser(const settings_t& settings = settings_t()) + : settings_(settings) + , resolve_unknown_symbol_(false) + , results_context_(0) + , unknown_symbol_resolver_(reinterpret_cast<unknown_symbol_resolver*>(0)) + #ifdef _MSC_VER + #pragma warning(push) + #pragma warning (disable:4355) + #endif + , sem_(*this) + #ifdef _MSC_VER + #pragma warning(pop) + #endif + , operator_joiner_2_(2) + , operator_joiner_3_(3) + , loop_runtime_check_(0) + , vector_access_runtime_check_(0) + , compilation_check_ptr_(0) + , assert_check_(0) + { + init_precompilation(); + + load_operations_map (base_ops_map_ ); + load_unary_operations_map (unary_op_map_ ); + load_binary_operations_map (binary_op_map_ ); + load_inv_binary_operations_map(inv_binary_op_map_); + load_sf3_map (sf3_map_ ); + load_sf4_map (sf4_map_ ); + + expression_generator_.init_synthesize_map(); + expression_generator_.set_parser(*this); + expression_generator_.set_uom (unary_op_map_ ); + expression_generator_.set_bom (binary_op_map_ ); + expression_generator_.set_ibom(inv_binary_op_map_); + expression_generator_.set_sf3m(sf3_map_ ); + expression_generator_.set_sf4m(sf4_map_ ); + expression_generator_.set_strength_reduction_state(settings_.strength_reduction_enabled()); + } + + ~parser() + {} + + inline void init_precompilation() + { + dec_.collect_variables() = + settings_.collect_variables_enabled(); + + dec_.collect_functions() = + settings_.collect_functions_enabled(); + + dec_.collect_assignments() = + settings_.collect_assignments_enabled(); + + if (settings_.replacer_enabled()) + { + symbol_replacer_.clear(); + symbol_replacer_.add_replace("true" , "1", lexer::token::e_number); + symbol_replacer_.add_replace("false", "0", lexer::token::e_number); + helper_assembly_.token_modifier_list.clear(); + helper_assembly_.register_modifier(&symbol_replacer_); + } + + if (settings_.commutative_check_enabled()) + { + for (std::size_t i = 0; i < details::reserved_words_size; ++i) + { + commutative_inserter_.ignore_symbol(details::reserved_words[i]); + } + + helper_assembly_.token_inserter_list.clear(); + helper_assembly_.register_inserter(&commutative_inserter_); + } + + if (settings_.joiner_enabled()) + { + helper_assembly_.token_joiner_list.clear(); + helper_assembly_.register_joiner(&operator_joiner_2_); + helper_assembly_.register_joiner(&operator_joiner_3_); + } + + if ( + settings_.numeric_check_enabled () || + settings_.bracket_check_enabled () || + settings_.sequence_check_enabled() + ) + { + helper_assembly_.token_scanner_list.clear(); + + if (settings_.numeric_check_enabled()) + { + helper_assembly_.register_scanner(&numeric_checker_); + } + + if (settings_.bracket_check_enabled()) + { + helper_assembly_.register_scanner(&bracket_checker_); + } + + if (settings_.sequence_check_enabled()) + { + helper_assembly_.register_scanner(&sequence_validator_ ); + helper_assembly_.register_scanner(&sequence_validator_3tkns_); + } + } + } + + inline bool compile(const std::string& expression_string, expression<T>& expr) + { + state_ .reset(); + error_list_ .clear(); + brkcnt_list_ .clear(); + synthesis_error_ .clear(); + immutable_memory_map_.reset(); + immutable_symtok_map_.clear(); + current_state_stack_ .clear(); + assert_ids_ .clear(); + sem_ .cleanup(); + + return_cleanup(); + + if (!valid_settings()) + { + return false; + } + + expression_generator_.set_allocator(node_allocator_); + + if (expression_string.empty()) + { + set_error(make_error( + parser_error::e_syntax, + "ERR001 - Empty expression!", + exprtk_error_location)); + + return false; + } + + if (!init(expression_string)) + { + process_lexer_errors(); + return false; + } + + if (lexer().empty()) + { + set_error(make_error( + parser_error::e_syntax, + "ERR002 - Empty expression!", + exprtk_error_location)); + + return false; + } + + if (halt_compilation_check()) + { + exprtk_debug(("halt_compilation_check() - compile checkpoint 0\n")); + sem_.cleanup(); + return false; + } + + if (!run_assemblies()) + { + sem_.cleanup(); + return false; + } + + if (halt_compilation_check()) + { + exprtk_debug(("halt_compilation_check() - compile checkpoint 1\n")); + sem_.cleanup(); + return false; + } + + symtab_store_.symtab_list_ = expr.get_symbol_table_list(); + dec_.clear(); + + lexer().begin(); + + next_token(); + + expression_node_ptr e = parse_corpus(); + + if ((0 != e) && (token_t::e_eof == current_token().type)) + { + bool* retinvk_ptr = 0; + + if (state_.return_stmt_present) + { + dec_.return_present_ = true; + + e = expression_generator_ + .return_envelope(e, results_context_, retinvk_ptr); + } + + expr.set_expression(e); + expr.set_retinvk(retinvk_ptr); + + register_local_vars(expr); + register_return_results(expr); + + return !(!expr); + } + else + { + if (error_list_.empty()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR003 - Invalid expression encountered", + exprtk_error_location)); + } + + if ((0 != e) && branch_deletable(e)) + { + destroy_node(e); + } + + dec_.clear (); + sem_.cleanup (); + return_cleanup(); + + return false; + } + } + + inline expression_t compile(const std::string& expression_string, symbol_table_t& symtab) + { + expression_t expression; + expression.register_symbol_table(symtab); + compile(expression_string,expression); + return expression; + } + + void process_lexer_errors() + { + for (std::size_t i = 0; i < lexer().size(); ++i) + { + if (lexer()[i].is_error()) + { + std::string diagnostic = "ERR004 - "; + + switch (lexer()[i].type) + { + case lexer::token::e_error : diagnostic += "General token error"; + break; + + case lexer::token::e_err_symbol : diagnostic += "Symbol error"; + break; + + case lexer::token::e_err_number : diagnostic += "Invalid numeric token"; + break; + + case lexer::token::e_err_string : diagnostic += "Invalid string token"; + break; + + case lexer::token::e_err_sfunc : diagnostic += "Invalid special function token"; + break; + + default : diagnostic += "Unknown compiler error"; + } + + set_error(make_error( + parser_error::e_lexer, + lexer()[i], + diagnostic + ": " + lexer()[i].value, + exprtk_error_location)); + } + } + } + + inline bool run_assemblies() + { + if (settings_.commutative_check_enabled()) + { + helper_assembly_.run_inserters(lexer()); + } + + if (settings_.joiner_enabled()) + { + helper_assembly_.run_joiners(lexer()); + } + + if (settings_.replacer_enabled()) + { + helper_assembly_.run_modifiers(lexer()); + } + + if ( + settings_.numeric_check_enabled () || + settings_.bracket_check_enabled () || + settings_.sequence_check_enabled() + ) + { + if (!helper_assembly_.run_scanners(lexer())) + { + if (helper_assembly_.error_token_scanner) + { + lexer::helper::bracket_checker* bracket_checker_ptr = 0; + lexer::helper::numeric_checker<T>* numeric_checker_ptr = 0; + lexer::helper::sequence_validator* sequence_validator_ptr = 0; + lexer::helper::sequence_validator_3tokens* sequence_validator3_ptr = 0; + + if (0 != (bracket_checker_ptr = dynamic_cast<lexer::helper::bracket_checker*>(helper_assembly_.error_token_scanner))) + { + set_error(make_error( + parser_error::e_token, + bracket_checker_ptr->error_token(), + "ERR005 - Mismatched brackets: '" + bracket_checker_ptr->error_token().value + "'", + exprtk_error_location)); + } + else if (0 != (numeric_checker_ptr = dynamic_cast<lexer::helper::numeric_checker<T>*>(helper_assembly_.error_token_scanner))) + { + for (std::size_t i = 0; i < numeric_checker_ptr->error_count(); ++i) + { + lexer::token error_token = lexer()[numeric_checker_ptr->error_index(i)]; + + set_error(make_error( + parser_error::e_token, + error_token, + "ERR006 - Invalid numeric token: '" + error_token.value + "'", + exprtk_error_location)); + } + + if (numeric_checker_ptr->error_count()) + { + numeric_checker_ptr->clear_errors(); + } + } + else if (0 != (sequence_validator_ptr = dynamic_cast<lexer::helper::sequence_validator*>(helper_assembly_.error_token_scanner))) + { + for (std::size_t i = 0; i < sequence_validator_ptr->error_count(); ++i) + { + std::pair<lexer::token,lexer::token> error_token = sequence_validator_ptr->error(i); + + set_error(make_error( + parser_error::e_token, + error_token.first, + "ERR007 - Invalid token sequence: '" + + error_token.first.value + "' and '" + + error_token.second.value + "'", + exprtk_error_location)); + } + + if (sequence_validator_ptr->error_count()) + { + sequence_validator_ptr->clear_errors(); + } + } + else if (0 != (sequence_validator3_ptr = dynamic_cast<lexer::helper::sequence_validator_3tokens*>(helper_assembly_.error_token_scanner))) + { + for (std::size_t i = 0; i < sequence_validator3_ptr->error_count(); ++i) + { + std::pair<lexer::token,lexer::token> error_token = sequence_validator3_ptr->error(i); + + set_error(make_error( + parser_error::e_token, + error_token.first, + "ERR008 - Invalid token sequence: '" + + error_token.first.value + "' and '" + + error_token.second.value + "'", + exprtk_error_location)); + } + + if (sequence_validator3_ptr->error_count()) + { + sequence_validator3_ptr->clear_errors(); + } + } + } + + return false; + } + } + + return true; + } + + inline settings_store& settings() + { + return settings_; + } + + inline parser_error::type get_error(const std::size_t& index) const + { + if (index < error_list_.size()) + { + return error_list_[index]; + } + + throw std::invalid_argument("parser::get_error() - Invalid error index specified"); + } + + inline std::string error() const + { + if (!error_list_.empty()) + { + return error_list_[0].diagnostic; + } + else + return std::string("No Error"); + } + + inline std::size_t error_count() const + { + return error_list_.size(); + } + + inline dependent_entity_collector& dec() + { + return dec_; + } + + inline std::size_t total_local_symbol_size_bytes() const + { + return sem_.total_local_symb_size_bytes(); + } + + inline bool replace_symbol(const std::string& old_symbol, const std::string& new_symbol) + { + if (!settings_.replacer_enabled()) + return false; + else if (details::is_reserved_word(old_symbol)) + return false; + else + return symbol_replacer_.add_replace(old_symbol,new_symbol,lexer::token::e_symbol); + } + + inline bool remove_replace_symbol(const std::string& symbol) + { + if (!settings_.replacer_enabled()) + return false; + else if (details::is_reserved_word(symbol)) + return false; + else + return symbol_replacer_.remove(symbol); + } + + inline void enable_unknown_symbol_resolver(unknown_symbol_resolver* usr = reinterpret_cast<unknown_symbol_resolver*>(0)) + { + resolve_unknown_symbol_ = true; + + if (usr) + unknown_symbol_resolver_ = usr; + else + unknown_symbol_resolver_ = &default_usr_; + } + + inline void enable_unknown_symbol_resolver(unknown_symbol_resolver& usr) + { + enable_unknown_symbol_resolver(&usr); + } + + inline void disable_unknown_symbol_resolver() + { + resolve_unknown_symbol_ = false; + unknown_symbol_resolver_ = &default_usr_; + } + + inline void register_loop_runtime_check(loop_runtime_check& lrtchk) + { + loop_runtime_check_ = &lrtchk; + } + + inline void register_vector_access_runtime_check(vector_access_runtime_check& vartchk) + { + vector_access_runtime_check_ = &vartchk; + } + + inline void register_compilation_timeout_check(compilation_check& compchk) + { + compilation_check_ptr_ = &compchk; + } + + inline void register_assert_check(assert_check& assrt_chck) + { + assert_check_ = &assrt_chck; + } + + inline void clear_loop_runtime_check() + { + loop_runtime_check_ = loop_runtime_check_ptr(0); + } + + inline void clear_vector_access_runtime_check() + { + vector_access_runtime_check_ = vector_access_runtime_check_ptr(0); + } + + inline void clear_compilation_timeout_check() + { + compilation_check_ptr_ = compilation_check_ptr(0); + } + + inline void clear_assert_check() + { + assert_check_ = assert_check_ptr(0); + } + + private: + + inline bool valid_base_operation(const std::string& symbol) const + { + const std::size_t length = symbol.size(); + + if ( + (length < 3) || // Shortest base op symbol length + (length > 9) // Longest base op symbol length + ) + return false; + else + return settings_.function_enabled(symbol) && + (base_ops_map_.end() != base_ops_map_.find(symbol)); + } + + inline bool valid_vararg_operation(const std::string& symbol) const + { + static const std::string s_sum = "sum" ; + static const std::string s_mul = "mul" ; + static const std::string s_avg = "avg" ; + static const std::string s_min = "min" ; + static const std::string s_max = "max" ; + static const std::string s_mand = "mand"; + static const std::string s_mor = "mor" ; + static const std::string s_multi = "~" ; + static const std::string s_mswitch = "[*]" ; + + return + ( + details::imatch(symbol,s_sum ) || + details::imatch(symbol,s_mul ) || + details::imatch(symbol,s_avg ) || + details::imatch(symbol,s_min ) || + details::imatch(symbol,s_max ) || + details::imatch(symbol,s_mand ) || + details::imatch(symbol,s_mor ) || + details::imatch(symbol,s_multi ) || + details::imatch(symbol,s_mswitch) + ) && + settings_.function_enabled(symbol); + } + + bool is_invalid_logic_operation(const details::operator_type operation) const + { + return settings_.logic_disabled(operation); + } + + bool is_invalid_arithmetic_operation(const details::operator_type operation) const + { + return settings_.arithmetic_disabled(operation); + } + + bool is_invalid_assignment_operation(const details::operator_type operation) const + { + return settings_.assignment_disabled(operation); + } + + bool is_invalid_inequality_operation(const details::operator_type operation) const + { + return settings_.inequality_disabled(operation); + } + + #ifdef exprtk_enable_debugging + inline void next_token() + { + const std::string ct_str = current_token().value; + const std::size_t ct_pos = current_token().position; + parser_helper::next_token(); + const std::string depth(2 * state_.scope_depth,' '); + exprtk_debug(("%s" + "prev[%s | %04d] --> curr[%s | %04d] stack_level: %3d\n", + depth.c_str(), + ct_str.c_str(), + static_cast<unsigned int>(ct_pos), + current_token().value.c_str(), + static_cast<unsigned int>(current_token().position), + static_cast<unsigned int>(state_.stack_depth))); + } + #endif + + inline expression_node_ptr parse_corpus() + { + std::vector<expression_node_ptr> arg_list; + std::vector<bool> side_effect_list; + + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + lexer::token begin_token; + lexer::token end_token; + + for ( ; ; ) + { + state_.side_effect_present = false; + + begin_token = current_token(); + + expression_node_ptr arg = parse_expression(); + + if (0 == arg) + { + if (error_list_.empty()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR009 - Invalid expression encountered", + exprtk_error_location)); + } + + return error_node(); + } + else + { + arg_list.push_back(arg); + + side_effect_list.push_back(state_.side_effect_present); + + end_token = current_token(); + + const std::string sub_expr = construct_subexpr(begin_token, end_token); + + exprtk_debug(("parse_corpus(%02d) Subexpr: %s\n", + static_cast<int>(arg_list.size() - 1), + sub_expr.c_str())); + + exprtk_debug(("parse_corpus(%02d) - Side effect present: %s\n", + static_cast<int>(arg_list.size() - 1), + state_.side_effect_present ? "true" : "false")); + + exprtk_debug(("-------------------------------------------------\n")); + } + + if (token_is(token_t::e_eof,prsrhlpr_t::e_hold)) + { + if (lexer().finished()) + break; + else + next_token(); + } + else if ( + !settings_.commutative_check_enabled() && + ( + current_token().type == token_t::e_symbol || + current_token().type == token_t::e_number || + current_token().type == token_t::e_string || + token_is_bracket(prsrhlpr_t::e_hold) + ) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR010 - Invalid syntax '" + current_token().value + "' possible missing operator or context", + exprtk_error_location)); + + return error_node(); + } + } + + if ( + !arg_list.empty() && + is_return_node(arg_list.back()) + ) + { + dec_.final_stmt_return_ = true; + } + + const expression_node_ptr result = simplify(arg_list,side_effect_list); + + svd.delete_ptr = (0 == result); + + return result; + } + + std::string construct_subexpr(lexer::token& begin_token, + lexer::token& end_token, + const bool cleanup_whitespace = true) + { + std::string result = lexer().substr(begin_token.position,end_token.position); + if (cleanup_whitespace) + { + for (std::size_t i = 0; i < result.size(); ++i) + { + if (details::is_whitespace(result[i])) result[i] = ' '; + } + } + + return result; + } + + static const precedence_level default_precedence = e_level00; + + struct state_t + { + inline void set(const precedence_level& l, + const precedence_level& r, + const details::operator_type& o, + const token_t tkn = token_t()) + { + left = l; + right = r; + operation = o; + token = tkn; + } + + inline void reset() + { + left = e_level00; + right = e_level00; + operation = details::e_default; + } + + precedence_level left; + precedence_level right; + details::operator_type operation; + token_t token; + }; + + inline void push_current_state(const state_t current_state) + { + current_state_stack_.push_back(current_state); + } + + inline void pop_current_state() + { + if (!current_state_stack_.empty()) + { + current_state_stack_.pop_back(); + } + } + + inline state_t current_state() const + { + return (!current_state_stack_.empty()) ? + current_state_stack_.back() : + state_t(); + } + + inline bool halt_compilation_check() + { + compilation_check::compilation_context context; + + if (compilation_check_ptr_ && !compilation_check_ptr_->continue_compilation(context)) + { + const std::string error_message = + !context.error_message.empty() ? " Details: " + context.error_message : ""; + + set_error(make_error( + parser_error::e_parser, + token_t(), + "ERR011 - Internal compilation check failed." + error_message, + exprtk_error_location)); + + return true; + } + + return false; + } + + inline expression_node_ptr parse_expression(precedence_level precedence = e_level00) + { + if (halt_compilation_check()) + { + exprtk_debug(("halt_compilation_check() - parse_expression checkpoint 2\n")); + return error_node(); + } + + stack_limit_handler slh(*this); + + if (!slh) + { + return error_node(); + } + + expression_node_ptr expression = parse_branch(precedence); + + if (0 == expression) + { + return error_node(); + } + + if (token_is(token_t::e_eof,prsrhlpr_t::e_hold)) + { + return expression; + } + + bool break_loop = false; + + state_t current_state; + + for ( ; ; ) + { + current_state.reset(); + + switch (current_token().type) + { + case token_t::e_assign : current_state.set(e_level00, e_level00, details::e_assign, current_token()); break; + case token_t::e_addass : current_state.set(e_level00, e_level00, details::e_addass, current_token()); break; + case token_t::e_subass : current_state.set(e_level00, e_level00, details::e_subass, current_token()); break; + case token_t::e_mulass : current_state.set(e_level00, e_level00, details::e_mulass, current_token()); break; + case token_t::e_divass : current_state.set(e_level00, e_level00, details::e_divass, current_token()); break; + case token_t::e_modass : current_state.set(e_level00, e_level00, details::e_modass, current_token()); break; + case token_t::e_swap : current_state.set(e_level00, e_level00, details::e_swap , current_token()); break; + case token_t::e_lt : current_state.set(e_level05, e_level06, details::e_lt , current_token()); break; + case token_t::e_lte : current_state.set(e_level05, e_level06, details::e_lte , current_token()); break; + case token_t::e_eq : current_state.set(e_level05, e_level06, details::e_eq , current_token()); break; + case token_t::e_ne : current_state.set(e_level05, e_level06, details::e_ne , current_token()); break; + case token_t::e_gte : current_state.set(e_level05, e_level06, details::e_gte , current_token()); break; + case token_t::e_gt : current_state.set(e_level05, e_level06, details::e_gt , current_token()); break; + case token_t::e_add : current_state.set(e_level07, e_level08, details::e_add , current_token()); break; + case token_t::e_sub : current_state.set(e_level07, e_level08, details::e_sub , current_token()); break; + case token_t::e_div : current_state.set(e_level10, e_level11, details::e_div , current_token()); break; + case token_t::e_mul : current_state.set(e_level10, e_level11, details::e_mul , current_token()); break; + case token_t::e_mod : current_state.set(e_level10, e_level11, details::e_mod , current_token()); break; + case token_t::e_pow : current_state.set(e_level12, e_level12, details::e_pow , current_token()); break; + default : + if (token_t::e_symbol == current_token().type) + { + static const std::string s_and = "and" ; + static const std::string s_nand = "nand" ; + static const std::string s_or = "or" ; + static const std::string s_nor = "nor" ; + static const std::string s_xor = "xor" ; + static const std::string s_xnor = "xnor" ; + static const std::string s_in = "in" ; + static const std::string s_like = "like" ; + static const std::string s_ilike = "ilike"; + static const std::string s_and1 = "&" ; + static const std::string s_or1 = "|" ; + static const std::string s_not = "not" ; + + if (details::imatch(current_token().value,s_and)) + { + current_state.set(e_level03, e_level04, details::e_and, current_token()); + break; + } + else if (details::imatch(current_token().value,s_and1)) + { + #ifndef exprtk_disable_sc_andor + current_state.set(e_level03, e_level04, details::e_scand, current_token()); + #else + current_state.set(e_level03, e_level04, details::e_and, current_token()); + #endif + break; + } + else if (details::imatch(current_token().value,s_nand)) + { + current_state.set(e_level03, e_level04, details::e_nand, current_token()); + break; + } + else if (details::imatch(current_token().value,s_or)) + { + current_state.set(e_level01, e_level02, details::e_or, current_token()); + break; + } + else if (details::imatch(current_token().value,s_or1)) + { + #ifndef exprtk_disable_sc_andor + current_state.set(e_level01, e_level02, details::e_scor, current_token()); + #else + current_state.set(e_level01, e_level02, details::e_or, current_token()); + #endif + break; + } + else if (details::imatch(current_token().value,s_nor)) + { + current_state.set(e_level01, e_level02, details::e_nor, current_token()); + break; + } + else if (details::imatch(current_token().value,s_xor)) + { + current_state.set(e_level01, e_level02, details::e_xor, current_token()); + break; + } + else if (details::imatch(current_token().value,s_xnor)) + { + current_state.set(e_level01, e_level02, details::e_xnor, current_token()); + break; + } + else if (details::imatch(current_token().value,s_in)) + { + current_state.set(e_level04, e_level04, details::e_in, current_token()); + break; + } + else if (details::imatch(current_token().value,s_like)) + { + current_state.set(e_level04, e_level04, details::e_like, current_token()); + break; + } + else if (details::imatch(current_token().value,s_ilike)) + { + current_state.set(e_level04, e_level04, details::e_ilike, current_token()); + break; + } + else if (details::imatch(current_token().value,s_not)) + { + break; + } + } + + break_loop = true; + } + + if (break_loop) + { + parse_pending_string_rangesize(expression); + break; + } + else if (current_state.left < precedence) + break; + + const lexer::token prev_token = current_token(); + + next_token(); + + expression_node_ptr right_branch = error_node(); + expression_node_ptr new_expression = error_node(); + + if (is_invalid_logic_operation(current_state.operation)) + { + free_node(node_allocator_, expression); + + set_error(make_error( + parser_error::e_syntax, + prev_token, + "ERR012 - Invalid or disabled logic operation '" + details::to_str(current_state.operation) + "'", + exprtk_error_location)); + + return error_node(); + } + else if (is_invalid_arithmetic_operation(current_state.operation)) + { + free_node(node_allocator_, expression); + + set_error(make_error( + parser_error::e_syntax, + prev_token, + "ERR013 - Invalid or disabled arithmetic operation '" + details::to_str(current_state.operation) + "'", + exprtk_error_location)); + + return error_node(); + } + else if (is_invalid_inequality_operation(current_state.operation)) + { + free_node(node_allocator_, expression); + + set_error(make_error( + parser_error::e_syntax, + prev_token, + "ERR014 - Invalid inequality operation '" + details::to_str(current_state.operation) + "'", + exprtk_error_location)); + + return error_node(); + } + else if (is_invalid_assignment_operation(current_state.operation)) + { + free_node(node_allocator_, expression); + + set_error(make_error( + parser_error::e_syntax, + prev_token, + "ERR015 - Invalid or disabled assignment operation '" + details::to_str(current_state.operation) + "'", + exprtk_error_location)); + + return error_node(); + } + + if (0 != (right_branch = parse_expression(current_state.right))) + { + if ( + details::is_return_node(expression ) || + details::is_return_node(right_branch) + ) + { + free_node(node_allocator_, expression ); + free_node(node_allocator_, right_branch); + + set_error(make_error( + parser_error::e_syntax, + prev_token, + "ERR016 - Return statements cannot be part of sub-expressions", + exprtk_error_location)); + + return error_node(); + } + + push_current_state(current_state); + + new_expression = expression_generator_ + ( + current_state.operation, + expression, + right_branch + ); + + pop_current_state(); + } + + if (0 == new_expression) + { + if (error_list_.empty()) + { + set_error(make_error( + parser_error::e_syntax, + prev_token, + !synthesis_error_.empty() ? + synthesis_error_ : + "ERR017 - General parsing error at token: '" + prev_token.value + "'", + exprtk_error_location)); + } + + free_node(node_allocator_, expression ); + free_node(node_allocator_, right_branch); + + return error_node(); + } + else + { + if ( + token_is(token_t::e_ternary,prsrhlpr_t::e_hold) && + (e_level00 == precedence) + ) + { + expression = parse_ternary_conditional_statement(new_expression); + } + else + expression = new_expression; + + parse_pending_string_rangesize(expression); + } + } + + if ((0 != expression) && (expression->node_depth() > settings_.max_node_depth_)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR018 - Expression depth of " + details::to_str(static_cast<int>(expression->node_depth())) + + " exceeds maximum allowed expression depth of " + details::to_str(static_cast<int>(settings_.max_node_depth_)), + exprtk_error_location)); + + free_node(node_allocator_, expression); + + return error_node(); + } + else if ( + !settings_.commutative_check_enabled() && + !details::is_logic_opr(current_token().value) && + (current_state.operation == details::e_default) && + ( + current_token().type == token_t::e_symbol || + current_token().type == token_t::e_number || + current_token().type == token_t::e_string + ) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR019 - Invalid syntax '" + current_token().value + "' possible missing operator or context", + exprtk_error_location)); + + free_node(node_allocator_, expression); + + return error_node(); + } + + return expression; + } + + bool simplify_unary_negation_branch(expression_node_ptr& node) + { + { + typedef details::unary_branch_node<T,details::neg_op<T> > ubn_t; + ubn_t* n = dynamic_cast<ubn_t*>(node); + + if (n) + { + expression_node_ptr un_r = n->branch(0); + n->release(); + free_node(node_allocator_, node); + node = un_r; + + return true; + } + } + + { + typedef details::unary_variable_node<T,details::neg_op<T> > uvn_t; + + uvn_t* n = dynamic_cast<uvn_t*>(node); + + if (n) + { + const T& v = n->v(); + expression_node_ptr return_node = error_node(); + + if ( + (0 != (return_node = symtab_store_.get_variable(v))) || + (0 != (return_node = sem_ .get_variable(v))) + ) + { + free_node(node_allocator_, node); + node = return_node; + + return true; + } + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR020 - Failed to find variable node in symbol table", + exprtk_error_location)); + + free_node(node_allocator_, node); + + return false; + } + } + } + + return false; + } + + static inline expression_node_ptr error_node() + { + return reinterpret_cast<expression_node_ptr>(0); + } + + struct scoped_expression_delete + { + scoped_expression_delete(parser<T>& pr, expression_node_ptr& expression) + : delete_ptr(true) + , parser_(pr) + , expression_(expression) + {} + + ~scoped_expression_delete() + { + if (delete_ptr) + { + free_node(parser_.node_allocator_, expression_); + } + } + + bool delete_ptr; + parser<T>& parser_; + expression_node_ptr& expression_; + + private: + + scoped_expression_delete(const scoped_expression_delete&) exprtk_delete; + scoped_expression_delete& operator=(const scoped_expression_delete&) exprtk_delete; + }; + + template <typename Type, std::size_t N> + struct scoped_delete + { + typedef Type* ptr_t; + + scoped_delete(parser<T>& pr, ptr_t& p) + : delete_ptr(true) + , parser_(pr) + , p_(&p) + {} + + scoped_delete(parser<T>& pr, ptr_t (&p)[N]) + : delete_ptr(true) + , parser_(pr) + , p_(&p[0]) + {} + + ~scoped_delete() + { + if (delete_ptr) + { + for (std::size_t i = 0; i < N; ++i) + { + free_node(parser_.node_allocator_, p_[i]); + } + } + } + + bool delete_ptr; + parser<T>& parser_; + ptr_t* p_; + + private: + + scoped_delete(const scoped_delete<Type,N>&) exprtk_delete; + scoped_delete<Type,N>& operator=(const scoped_delete<Type,N>&) exprtk_delete; + }; + + template <typename Type> + struct scoped_deq_delete + { + typedef Type* ptr_t; + + scoped_deq_delete(parser<T>& pr, std::deque<ptr_t>& deq) + : delete_ptr(true) + , parser_(pr) + , deq_(deq) + {} + + ~scoped_deq_delete() + { + if (delete_ptr && !deq_.empty()) + { + for (std::size_t i = 0; i < deq_.size(); ++i) + { + exprtk_debug(("~scoped_deq_delete() - deleting node: %p\n", reinterpret_cast<void*>(deq_[i]))); + free_node(parser_.node_allocator_,deq_[i]); + } + + deq_.clear(); + } + } + + bool delete_ptr; + parser<T>& parser_; + std::deque<ptr_t>& deq_; + + private: + + scoped_deq_delete(const scoped_deq_delete<Type>&) exprtk_delete; + scoped_deq_delete<Type>& operator=(const scoped_deq_delete<Type>&) exprtk_delete; + }; + + template <typename Type> + struct scoped_vec_delete + { + typedef Type* ptr_t; + + scoped_vec_delete(parser<T>& pr, std::vector<ptr_t>& vec) + : delete_ptr(true) + , parser_(pr) + , vec_(vec) + {} + + ~scoped_vec_delete() + { + if (delete_ptr && !vec_.empty()) + { + for (std::size_t i = 0; i < vec_.size(); ++i) + { + exprtk_debug(("~scoped_vec_delete() - deleting node: %p\n", reinterpret_cast<void*>(vec_[i]))); + free_node(parser_.node_allocator_,vec_[i]); + } + + vec_.clear(); + } + } + + ptr_t operator[](const std::size_t index) + { + return vec_[index]; + } + + bool delete_ptr; + parser<T>& parser_; + std::vector<ptr_t>& vec_; + + private: + + scoped_vec_delete(const scoped_vec_delete<Type>&) exprtk_delete; + scoped_vec_delete<Type>& operator=(const scoped_vec_delete<Type>&) exprtk_delete; + }; + + struct scoped_bool_negator + { + explicit scoped_bool_negator(bool& bb) + : b(bb) + { b = !b; } + + ~scoped_bool_negator() + { b = !b; } + + bool& b; + }; + + struct scoped_bool_or_restorer + { + explicit scoped_bool_or_restorer(bool& bb) + : b(bb) + , original_value_(bb) + {} + + ~scoped_bool_or_restorer() + { + b = b || original_value_; + } + + bool& b; + bool original_value_; + }; + + struct scoped_inc_dec + { + explicit scoped_inc_dec(std::size_t& v) + : v_(v) + { ++v_; } + + ~scoped_inc_dec() + { + assert(v_ > 0); + --v_; + } + + std::size_t& v_; + }; + + inline expression_node_ptr parse_function_invocation(ifunction<T>* function, const std::string& function_name) + { + expression_node_ptr func_node = reinterpret_cast<expression_node_ptr>(0); + + switch (function->param_count) + { + case 0 : func_node = parse_function_call_0 (function,function_name); break; + case 1 : func_node = parse_function_call< 1>(function,function_name); break; + case 2 : func_node = parse_function_call< 2>(function,function_name); break; + case 3 : func_node = parse_function_call< 3>(function,function_name); break; + case 4 : func_node = parse_function_call< 4>(function,function_name); break; + case 5 : func_node = parse_function_call< 5>(function,function_name); break; + case 6 : func_node = parse_function_call< 6>(function,function_name); break; + case 7 : func_node = parse_function_call< 7>(function,function_name); break; + case 8 : func_node = parse_function_call< 8>(function,function_name); break; + case 9 : func_node = parse_function_call< 9>(function,function_name); break; + case 10 : func_node = parse_function_call<10>(function,function_name); break; + case 11 : func_node = parse_function_call<11>(function,function_name); break; + case 12 : func_node = parse_function_call<12>(function,function_name); break; + case 13 : func_node = parse_function_call<13>(function,function_name); break; + case 14 : func_node = parse_function_call<14>(function,function_name); break; + case 15 : func_node = parse_function_call<15>(function,function_name); break; + case 16 : func_node = parse_function_call<16>(function,function_name); break; + case 17 : func_node = parse_function_call<17>(function,function_name); break; + case 18 : func_node = parse_function_call<18>(function,function_name); break; + case 19 : func_node = parse_function_call<19>(function,function_name); break; + case 20 : func_node = parse_function_call<20>(function,function_name); break; + default : { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR021 - Invalid number of parameters for function: '" + function_name + "'", + exprtk_error_location)); + + return error_node(); + } + } + + if (func_node) + return func_node; + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR022 - Failed to generate call to function: '" + function_name + "'", + exprtk_error_location)); + + return error_node(); + } + } + + template <std::size_t NumberofParameters> + inline expression_node_ptr parse_function_call(ifunction<T>* function, const std::string& function_name) + { + #ifdef _MSC_VER + #pragma warning(push) + #pragma warning(disable: 4127) + #endif + if (0 == NumberofParameters) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR023 - Expecting ifunction '" + function_name + "' to have non-zero parameter count", + exprtk_error_location)); + + return error_node(); + } + #ifdef _MSC_VER + #pragma warning(pop) + #endif + + expression_node_ptr branch[NumberofParameters]; + expression_node_ptr result = error_node(); + + std::fill_n(branch, NumberofParameters, reinterpret_cast<expression_node_ptr>(0)); + + scoped_delete<expression_node_t,NumberofParameters> sd((*this),branch); + + next_token(); + + if (!token_is(token_t::e_lbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR024 - Expecting argument list for function: '" + function_name + "'", + exprtk_error_location)); + + return error_node(); + } + + for (int i = 0; i < static_cast<int>(NumberofParameters); ++i) + { + branch[i] = parse_expression(); + + if (0 == branch[i]) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR025 - Failed to parse argument " + details::to_str(i) + " for function: '" + function_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (i < static_cast<int>(NumberofParameters - 1)) + { + if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR026 - Invalid number of arguments for function: '" + function_name + "'", + exprtk_error_location)); + + return error_node(); + } + } + } + + if (!token_is(token_t::e_rbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR027 - Invalid number of arguments for function: '" + function_name + "'", + exprtk_error_location)); + + return error_node(); + } + else + result = expression_generator_.function(function,branch); + + sd.delete_ptr = (0 == result); + + return result; + } + + inline expression_node_ptr parse_function_call_0(ifunction<T>* function, const std::string& function_name) + { + expression_node_ptr result = expression_generator_.function(function); + + state_.side_effect_present = function->has_side_effects(); + + next_token(); + + if ( + token_is(token_t::e_lbracket) && + !token_is(token_t::e_rbracket) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR028 - Expecting '()' to proceed call to function: '" + function_name + "'", + exprtk_error_location)); + + free_node(node_allocator_, result); + + return error_node(); + } + else + return result; + } + + template <std::size_t MaxNumberofParameters> + inline std::size_t parse_base_function_call(expression_node_ptr (¶m_list)[MaxNumberofParameters], const std::string& function_name = "") + { + std::fill_n(param_list, MaxNumberofParameters, reinterpret_cast<expression_node_ptr>(0)); + + scoped_delete<expression_node_t,MaxNumberofParameters> sd((*this),param_list); + + next_token(); + + if (!token_is(token_t::e_lbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR029 - Expected a '(' at start of function call to '" + function_name + + "', instead got: '" + current_token().value + "'", + exprtk_error_location)); + + return 0; + } + + if (token_is(token_t::e_rbracket, e_hold)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR030 - Expected at least one input parameter for function call '" + function_name + "'", + exprtk_error_location)); + + return 0; + } + + std::size_t param_index = 0; + + for (; param_index < MaxNumberofParameters; ++param_index) + { + param_list[param_index] = parse_expression(); + + if (0 == param_list[param_index]) + return 0; + else if (token_is(token_t::e_rbracket)) + { + sd.delete_ptr = false; + break; + } + else if (token_is(token_t::e_comma)) + continue; + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR031 - Expected a ',' between function input parameters, instead got: '" + current_token().value + "'", + exprtk_error_location)); + + return 0; + } + } + + if (sd.delete_ptr) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR032 - Invalid number of input parameters passed to function '" + function_name + "'", + exprtk_error_location)); + + return 0; + } + + return (param_index + 1); + } + + inline expression_node_ptr parse_base_operation() + { + typedef std::pair<base_ops_map_t::iterator,base_ops_map_t::iterator> map_range_t; + + const std::string operation_name = current_token().value; + const token_t diagnostic_token = current_token(); + + map_range_t itr_range = base_ops_map_.equal_range(operation_name); + + if (0 == std::distance(itr_range.first,itr_range.second)) + { + set_error(make_error( + parser_error::e_syntax, + diagnostic_token, + "ERR033 - No entry found for base operation: " + operation_name, + exprtk_error_location)); + + return error_node(); + } + + static const std::size_t MaxNumberofParameters = 4; + expression_node_ptr param_list[MaxNumberofParameters] = {0}; + + const std::size_t parameter_count = parse_base_function_call(param_list, operation_name); + + if ((parameter_count > 0) && (parameter_count <= MaxNumberofParameters)) + { + for (base_ops_map_t::iterator itr = itr_range.first; itr != itr_range.second; ++itr) + { + const details::base_operation_t& operation = itr->second; + + if (operation.num_params == parameter_count) + { + switch (parameter_count) + { + #define base_opr_case(N) \ + case N : { \ + expression_node_ptr pl##N[N] = {0}; \ + std::copy(param_list, param_list + N, pl##N); \ + lodge_symbol(operation_name, e_st_function); \ + return expression_generator_(operation.type, pl##N); \ + } \ + + base_opr_case(1) + base_opr_case(2) + base_opr_case(3) + base_opr_case(4) + #undef base_opr_case + } + } + } + } + + for (std::size_t i = 0; i < MaxNumberofParameters; ++i) + { + free_node(node_allocator_, param_list[i]); + } + + set_error(make_error( + parser_error::e_syntax, + diagnostic_token, + "ERR034 - Invalid number of input parameters for call to function: '" + operation_name + "'", + exprtk_error_location)); + + return error_node(); + } + + inline expression_node_ptr parse_conditional_statement_01(expression_node_ptr condition) + { + // Parse: [if][(][condition][,][consequent][,][alternative][)] + + expression_node_ptr consequent = error_node(); + expression_node_ptr alternative = error_node(); + + bool result = true; + + if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR035 - Expected ',' between if-statement condition and consequent", + exprtk_error_location)); + + result = false; + } + else if (0 == (consequent = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR036 - Failed to parse consequent for if-statement", + exprtk_error_location)); + + result = false; + } + else if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR037 - Expected ',' between if-statement consequent and alternative", + exprtk_error_location)); + + result = false; + } + else if (0 == (alternative = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR038 - Failed to parse alternative for if-statement", + exprtk_error_location)); + + result = false; + } + else if (!token_is(token_t::e_rbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR039 - Expected ')' at the end of if-statement", + exprtk_error_location)); + + result = false; + } + + #ifndef exprtk_disable_string_capabilities + if (result) + { + const bool consq_is_str = is_generally_string_node(consequent ); + const bool alter_is_str = is_generally_string_node(alternative); + + if (consq_is_str || alter_is_str) + { + if (consq_is_str && alter_is_str) + { + expression_node_ptr result_node = + expression_generator_ + .conditional_string(condition, consequent, alternative); + + if (result_node && result_node->valid()) + { + return result_node; + } + + set_error(make_error( + parser_error::e_synthesis, + current_token(), + "ERR040 - Failed to synthesize node: conditional_string", + exprtk_error_location)); + + free_node(node_allocator_, result_node); + return error_node(); + } + + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR041 - Return types of if-statement differ: string/non-string", + exprtk_error_location)); + + result = false; + } + } + #endif + + if (result) + { + const bool consq_is_vec = is_ivector_node(consequent ); + const bool alter_is_vec = is_ivector_node(alternative); + + if (consq_is_vec || alter_is_vec) + { + if (consq_is_vec && alter_is_vec) + { + return expression_generator_ + .conditional_vector(condition, consequent, alternative); + } + + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR042 - Return types of if-statement differ: vector/non-vector", + exprtk_error_location)); + + result = false; + } + } + + if (!result) + { + free_node(node_allocator_, condition ); + free_node(node_allocator_, consequent ); + free_node(node_allocator_, alternative); + + return error_node(); + } + else + return expression_generator_ + .conditional(condition, consequent, alternative); + } + + inline expression_node_ptr parse_conditional_statement_02(expression_node_ptr condition) + { + expression_node_ptr consequent = error_node(); + expression_node_ptr alternative = error_node(); + + bool result = true; + + if (token_is(token_t::e_lcrlbracket,prsrhlpr_t::e_hold)) + { + if (0 == (consequent = parse_multi_sequence("if-statement-01"))) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR043 - Failed to parse body of consequent for if-statement", + exprtk_error_location)); + + result = false; + } + else if + ( + !settings_.commutative_check_enabled() && + !token_is("else",prsrhlpr_t::e_hold) && + !token_is_loop(prsrhlpr_t::e_hold) && + !token_is_arithmetic_opr(prsrhlpr_t::e_hold) && + !token_is_right_bracket (prsrhlpr_t::e_hold) && + !token_is_ineq_opr (prsrhlpr_t::e_hold) && + !token_is(token_t::e_ternary,prsrhlpr_t::e_hold) && + !token_is(token_t::e_eof ,prsrhlpr_t::e_hold) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR044 - Expected ';' at the end of the consequent for if-statement (1)", + exprtk_error_location)); + + result = false; + } + } + else + { + if ( + settings_.commutative_check_enabled() && + token_is(token_t::e_mul,prsrhlpr_t::e_hold) + ) + { + next_token(); + } + + if (0 != (consequent = parse_expression())) + { + if (!token_is(token_t::e_eof, prsrhlpr_t::e_hold)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR045 - Expected ';' at the end of the consequent for if-statement (2)", + exprtk_error_location)); + + result = false; + } + } + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR046 - Failed to parse body of consequent for if-statement", + exprtk_error_location)); + + result = false; + } + } + + if (result) + { + if ( + details::imatch(current_token().value,"else") || + (token_is(token_t::e_eof, prsrhlpr_t::e_hold) && peek_token_is("else")) + ) + { + next_token(); + + if (details::imatch(current_token().value,"else")) + { + next_token(); + } + + if (token_is(token_t::e_lcrlbracket,prsrhlpr_t::e_hold)) + { + if (0 == (alternative = parse_multi_sequence("else-statement-01"))) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR047 - Failed to parse body of the 'else' for if-statement", + exprtk_error_location)); + + result = false; + } + } + else if (details::imatch(current_token().value,"if")) + { + if (0 == (alternative = parse_conditional_statement())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR048 - Failed to parse body of if-else statement", + exprtk_error_location)); + + result = false; + } + } + else if (0 != (alternative = parse_expression())) + { + if ( + !token_is(token_t::e_ternary , prsrhlpr_t::e_hold) && + !token_is(token_t::e_rcrlbracket, prsrhlpr_t::e_hold) && + !token_is(token_t::e_eof) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR049 - Expected ';' at the end of the 'else-if' for the if-statement", + exprtk_error_location)); + + result = false; + } + } + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR050 - Failed to parse body of the 'else' for if-statement", + exprtk_error_location)); + + result = false; + } + } + } + + #ifndef exprtk_disable_string_capabilities + if (result) + { + const bool consq_is_str = is_generally_string_node(consequent ); + const bool alter_is_str = is_generally_string_node(alternative); + + if (consq_is_str || alter_is_str) + { + if (consq_is_str && alter_is_str) + { + return expression_generator_ + .conditional_string(condition, consequent, alternative); + } + + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR051 - Return types of if-statement differ: string/non-string", + exprtk_error_location)); + + result = false; + } + } + #endif + + if (result) + { + const bool consq_is_vec = is_ivector_node(consequent ); + const bool alter_is_vec = is_ivector_node(alternative); + + if (consq_is_vec || alter_is_vec) + { + if (consq_is_vec && alter_is_vec) + { + return expression_generator_ + .conditional_vector(condition, consequent, alternative); + } + + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR052 - Return types of if-statement differ: vector/non-vector", + exprtk_error_location)); + + result = false; + } + } + + if (!result) + { + free_node(node_allocator_, condition ); + free_node(node_allocator_, consequent ); + free_node(node_allocator_, alternative); + + return error_node(); + } + else + return expression_generator_ + .conditional(condition, consequent, alternative); + } + + inline expression_node_ptr parse_conditional_statement() + { + expression_node_ptr condition = error_node(); + + next_token(); + + if (!token_is(token_t::e_lbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR053 - Expected '(' at start of if-statement, instead got: '" + current_token().value + "'", + exprtk_error_location)); + + return error_node(); + } + else if (0 == (condition = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR054 - Failed to parse condition for if-statement", + exprtk_error_location)); + + return error_node(); + } + else if (token_is(token_t::e_comma,prsrhlpr_t::e_hold)) + { + // if (x,y,z) + return parse_conditional_statement_01(condition); + } + else if (token_is(token_t::e_rbracket)) + { + /* + 00. if (x) y; + 01. if (x) y; else z; + 02. if (x) y; else {z0; ... zn;} + 03. if (x) y; else if (z) w; + 04. if (x) y; else if (z) w; else u; + 05. if (x) y; else if (z) w; else {u0; ... un;} + 06. if (x) y; else if (z) {w0; ... wn;} + 07. if (x) {y0; ... yn;} + 08. if (x) {y0; ... yn;} else z; + 09. if (x) {y0; ... yn;} else {z0; ... zn;}; + 10. if (x) {y0; ... yn;} else if (z) w; + 11. if (x) {y0; ... yn;} else if (z) w; else u; + 12. if (x) {y0; ... nex;} else if (z) w; else {u0 ... un;} + 13. if (x) {y0; ... yn;} else if (z) {w0; ... wn;} + */ + return parse_conditional_statement_02(condition); + } + + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR055 - Invalid if-statement", + exprtk_error_location)); + + free_node(node_allocator_, condition); + + return error_node(); + } + + inline expression_node_ptr parse_ternary_conditional_statement(expression_node_ptr condition) + { + // Parse: [condition][?][consequent][:][alternative] + expression_node_ptr consequent = error_node(); + expression_node_ptr alternative = error_node(); + + bool result = true; + + if (0 == condition) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR056 - Encountered invalid condition branch for ternary if-statement", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_ternary)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR057 - Expected '?' after condition of ternary if-statement", + exprtk_error_location)); + + result = false; + } + else if (0 == (consequent = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR058 - Failed to parse consequent for ternary if-statement", + exprtk_error_location)); + + result = false; + } + else if (!token_is(token_t::e_colon)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR059 - Expected ':' between ternary if-statement consequent and alternative", + exprtk_error_location)); + + result = false; + } + else if (0 == (alternative = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR060 - Failed to parse alternative for ternary if-statement", + exprtk_error_location)); + + result = false; + } + + #ifndef exprtk_disable_string_capabilities + if (result) + { + const bool consq_is_str = is_generally_string_node(consequent ); + const bool alter_is_str = is_generally_string_node(alternative); + + if (consq_is_str || alter_is_str) + { + if (consq_is_str && alter_is_str) + { + return expression_generator_ + .conditional_string(condition, consequent, alternative); + } + + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR061 - Return types of ternary differ: string/non-string", + exprtk_error_location)); + + result = false; + } + } + #endif + + if (result) + { + const bool consq_is_vec = is_ivector_node(consequent ); + const bool alter_is_vec = is_ivector_node(alternative); + + if (consq_is_vec || alter_is_vec) + { + if (consq_is_vec && alter_is_vec) + { + return expression_generator_ + .conditional_vector(condition, consequent, alternative); + } + + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR062 - Return types of ternary differ: vector/non-vector", + exprtk_error_location)); + + result = false; + } + } + + if (!result) + { + free_node(node_allocator_, condition ); + free_node(node_allocator_, consequent ); + free_node(node_allocator_, alternative); + + return error_node(); + } + else + return expression_generator_ + .conditional(condition, consequent, alternative); + } + + inline expression_node_ptr parse_not_statement() + { + if (settings_.logic_disabled("not")) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR063 - Invalid or disabled logic operation 'not'", + exprtk_error_location)); + + return error_node(); + } + + return parse_base_operation(); + } + + void handle_brkcnt_scope_exit() + { + assert(!brkcnt_list_.empty()); + brkcnt_list_.pop_front(); + } + + inline expression_node_ptr parse_while_loop() + { + // Parse: [while][(][test expr][)][{][expression][}] + expression_node_ptr condition = error_node(); + expression_node_ptr branch = error_node(); + expression_node_ptr result_node = error_node(); + + bool result = true; + + next_token(); + + if (!token_is(token_t::e_lbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR064 - Expected '(' at start of while-loop condition statement", + exprtk_error_location)); + + return error_node(); + } + else if (0 == (condition = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR065 - Failed to parse condition for while-loop", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_rbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR066 - Expected ')' at end of while-loop condition statement", + exprtk_error_location)); + + result = false; + } + + brkcnt_list_.push_front(false); + + if (result) + { + scoped_inc_dec sid(state_.parsing_loop_stmt_count); + + if (0 == (branch = parse_multi_sequence("while-loop", true))) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR067 - Failed to parse body of while-loop")); + result = false; + } + else if (0 == (result_node = expression_generator_.while_loop(condition, + branch, + brkcnt_list_.front()))) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR068 - Failed to synthesize while-loop", + exprtk_error_location)); + + result = false; + } + } + + handle_brkcnt_scope_exit(); + + if (!result) + { + free_node(node_allocator_, branch ); + free_node(node_allocator_, condition ); + free_node(node_allocator_, result_node); + + return error_node(); + } + + if (result_node && result_node->valid()) + { + return result_node; + } + + set_error(make_error( + parser_error::e_synthesis, + current_token(), + "ERR069 - Failed to synthesize 'valid' while-loop", + exprtk_error_location)); + + free_node(node_allocator_, result_node); + + return error_node(); + } + + inline expression_node_ptr parse_repeat_until_loop() + { + // Parse: [repeat][{][expression][}][until][(][test expr][)] + expression_node_ptr condition = error_node(); + expression_node_ptr branch = error_node(); + next_token(); + + std::vector<expression_node_ptr> arg_list; + std::vector<bool> side_effect_list; + + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + brkcnt_list_.push_front(false); + + if (details::imatch(current_token().value,"until")) + { + next_token(); + branch = node_allocator_.allocate<details::null_node<T> >(); + } + else + { + const token_t::token_type separator = token_t::e_eof; + + scope_handler sh(*this); + + scoped_bool_or_restorer sbr(state_.side_effect_present); + + scoped_inc_dec sid(state_.parsing_loop_stmt_count); + + for ( ; ; ) + { + state_.side_effect_present = false; + + expression_node_ptr arg = parse_expression(); + + if (0 == arg) + return error_node(); + else + { + arg_list.push_back(arg); + side_effect_list.push_back(state_.side_effect_present); + } + + if (details::imatch(current_token().value,"until")) + { + next_token(); + break; + } + + const bool is_next_until = peek_token_is(token_t::e_symbol) && + peek_token_is("until"); + + if (!token_is(separator) && is_next_until) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR070 - Expected '" + token_t::to_str(separator) + "' in body of repeat until loop", + exprtk_error_location)); + + return error_node(); + } + + if (details::imatch(current_token().value,"until")) + { + next_token(); + break; + } + } + + branch = simplify(arg_list,side_effect_list); + + svd.delete_ptr = (0 == branch); + + if (svd.delete_ptr) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR071 - Failed to parse body of repeat until loop", + exprtk_error_location)); + + return error_node(); + } + } + + if (!token_is(token_t::e_lbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR072 - Expected '(' before condition statement of repeat until loop", + exprtk_error_location)); + + free_node(node_allocator_, branch); + return error_node(); + } + else if (0 == (condition = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR073 - Failed to parse condition for repeat until loop", + exprtk_error_location)); + + free_node(node_allocator_, branch); + return error_node(); + } + else if (!token_is(token_t::e_rbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR074 - Expected ')' after condition of repeat until loop", + exprtk_error_location)); + + free_node(node_allocator_, branch ); + free_node(node_allocator_, condition); + + return error_node(); + } + + expression_node_ptr result_node = + expression_generator_ + .repeat_until_loop( + condition, + branch, + brkcnt_list_.front()); + + if (0 == result_node) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR075 - Failed to synthesize repeat until loop", + exprtk_error_location)); + + free_node(node_allocator_, condition); + + return error_node(); + } + + handle_brkcnt_scope_exit(); + + if (result_node && result_node->valid()) + { + return result_node; + } + + set_error(make_error( + parser_error::e_synthesis, + current_token(), + "ERR076 - Failed to synthesize 'valid' repeat until loop", + exprtk_error_location)); + + free_node(node_allocator_, result_node); + + return error_node(); + } + + inline expression_node_ptr parse_for_loop() + { + expression_node_ptr initialiser = error_node(); + expression_node_ptr condition = error_node(); + expression_node_ptr incrementor = error_node(); + expression_node_ptr loop_body = error_node(); + + scope_element* se = 0; + bool result = true; + + next_token(); + + scope_handler sh(*this); + + if (!token_is(token_t::e_lbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR077 - Expected '(' at start of for-loop", + exprtk_error_location)); + + return error_node(); + } + + if (!token_is(token_t::e_eof)) + { + if ( + !token_is(token_t::e_symbol,prsrhlpr_t::e_hold) && + details::imatch(current_token().value,"var") + ) + { + next_token(); + + if (!token_is(token_t::e_symbol,prsrhlpr_t::e_hold)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR078 - Expected a variable at the start of initialiser section of for-loop", + exprtk_error_location)); + + return error_node(); + } + else if (!peek_token_is(token_t::e_assign)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR079 - Expected variable assignment of initialiser section of for-loop", + exprtk_error_location)); + + return error_node(); + } + + const std::string loop_counter_symbol = current_token().value; + + se = &sem_.get_element(loop_counter_symbol); + + if ((se->name == loop_counter_symbol) && se->active) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR080 - For-loop variable '" + loop_counter_symbol+ "' is being shadowed by a previous declaration", + exprtk_error_location)); + + return error_node(); + } + else if (!symtab_store_.is_variable(loop_counter_symbol)) + { + if ( + !se->active && + (se->name == loop_counter_symbol) && + (se->type == scope_element::e_variable) + ) + { + se->active = true; + se->ref_count++; + } + else + { + scope_element nse; + nse.name = loop_counter_symbol; + nse.active = true; + nse.ref_count = 1; + nse.type = scope_element::e_variable; + nse.depth = state_.scope_depth; + nse.data = new T(T(0)); + nse.var_node = node_allocator_.allocate<variable_node_t>(*reinterpret_cast<T*>(nse.data)); + + if (!sem_.add_element(nse)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR081 - Failed to add new local variable '" + loop_counter_symbol + "' to SEM", + exprtk_error_location)); + + sem_.free_element(nse); + + result = false; + } + else + { + exprtk_debug(("parse_for_loop() - INFO - Added new local variable: %s\n", nse.name.c_str())); + + state_.activate_side_effect("parse_for_loop()"); + } + } + } + } + + if (0 == (initialiser = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR082 - Failed to parse initialiser of for-loop", + exprtk_error_location)); + + result = false; + } + else if (!token_is(token_t::e_eof)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR083 - Expected ';' after initialiser of for-loop", + exprtk_error_location)); + + result = false; + } + } + + if (!token_is(token_t::e_eof)) + { + if (0 == (condition = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR084 - Failed to parse condition of for-loop", + exprtk_error_location)); + + result = false; + } + else if (!token_is(token_t::e_eof)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR085 - Expected ';' after condition section of for-loop", + exprtk_error_location)); + + result = false; + } + } + + if (!token_is(token_t::e_rbracket)) + { + if (0 == (incrementor = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR086 - Failed to parse incrementor of for-loop", + exprtk_error_location)); + + result = false; + } + else if (!token_is(token_t::e_rbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR087 - Expected ')' after incrementor section of for-loop", + exprtk_error_location)); + + result = false; + } + } + + if (result) + { + brkcnt_list_.push_front(false); + + scoped_inc_dec sid(state_.parsing_loop_stmt_count); + + if (0 == (loop_body = parse_multi_sequence("for-loop", true))) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR088 - Failed to parse body of for-loop", + exprtk_error_location)); + + result = false; + } + } + + if (!result) + { + if (se) + { + se->ref_count--; + } + + free_node(node_allocator_, initialiser); + free_node(node_allocator_, condition ); + free_node(node_allocator_, incrementor); + free_node(node_allocator_, loop_body ); + return error_node(); + } + + expression_node_ptr result_node = + expression_generator_.for_loop(initialiser, + condition, + incrementor, + loop_body, + brkcnt_list_.front()); + handle_brkcnt_scope_exit(); + + if (result_node && result_node->valid()) + { + return result_node; + } + + set_error(make_error( + parser_error::e_synthesis, + current_token(), + "ERR089 - Failed to synthesize 'valid' for-loop", + exprtk_error_location)); + + free_node(node_allocator_, result_node); + + return error_node(); + } + + inline expression_node_ptr parse_switch_statement() + { + std::vector<expression_node_ptr> arg_list; + + if (!details::imatch(current_token().value,"switch")) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR090 - Expected keyword 'switch'", + exprtk_error_location)); + + return error_node(); + } + + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + next_token(); + + if (!token_is(token_t::e_lcrlbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR091 - Expected '{' for call to switch statement", + exprtk_error_location)); + + return error_node(); + } + + expression_node_ptr default_statement = error_node(); + + scoped_expression_delete defstmt_delete((*this), default_statement); + + for ( ; ; ) + { + if (details::imatch("case",current_token().value)) + { + next_token(); + + expression_node_ptr condition = parse_expression(); + + if (0 == condition) + return error_node(); + else if (!token_is(token_t::e_colon)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR092 - Expected ':' for case of switch statement", + exprtk_error_location)); + + free_node(node_allocator_, condition); + + return error_node(); + } + + expression_node_ptr consequent = + (token_is(token_t::e_lcrlbracket,prsrhlpr_t::e_hold)) ? + parse_multi_sequence("switch-consequent") : + parse_expression(); + + if (0 == consequent) + { + free_node(node_allocator_, condition); + + return error_node(); + } + else if (!token_is(token_t::e_eof)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR093 - Expected ';' at end of case for switch statement", + exprtk_error_location)); + + free_node(node_allocator_, condition ); + free_node(node_allocator_, consequent); + + return error_node(); + } + + // Can we optimise away the case statement? + if (is_constant_node(condition) && is_false(condition)) + { + free_node(node_allocator_, condition ); + free_node(node_allocator_, consequent); + } + else + { + arg_list.push_back(condition ); + arg_list.push_back(consequent); + } + + } + else if (details::imatch("default",current_token().value)) + { + if (0 != default_statement) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR094 - Multiple default cases for switch statement", + exprtk_error_location)); + + return error_node(); + } + + next_token(); + + if (!token_is(token_t::e_colon)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR095 - Expected ':' for default of switch statement", + exprtk_error_location)); + + return error_node(); + } + + default_statement = + (token_is(token_t::e_lcrlbracket,prsrhlpr_t::e_hold)) ? + parse_multi_sequence("switch-default"): + parse_expression(); + + if (0 == default_statement) + return error_node(); + else if (!token_is(token_t::e_eof)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR096 - Expected ';' at end of default for switch statement", + exprtk_error_location)); + + return error_node(); + } + } + else if (token_is(token_t::e_rcrlbracket)) + break; + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR097 - Expected '}' at end of switch statement", + exprtk_error_location)); + + return error_node(); + } + } + + const bool default_statement_present = (0 != default_statement); + + if (default_statement_present) + { + arg_list.push_back(default_statement); + } + else + { + arg_list.push_back(node_allocator_.allocate_c<literal_node_t>(std::numeric_limits<T>::quiet_NaN())); + } + + expression_node_ptr result = expression_generator_.switch_statement(arg_list, (0 != default_statement)); + + svd.delete_ptr = (0 == result); + defstmt_delete.delete_ptr = (0 == result); + + return result; + } + + inline expression_node_ptr parse_multi_switch_statement() + { + std::vector<expression_node_ptr> arg_list; + + if (!details::imatch(current_token().value,"[*]")) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR098 - Expected token '[*]'", + exprtk_error_location)); + + return error_node(); + } + + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + next_token(); + + if (!token_is(token_t::e_lcrlbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR099 - Expected '{' for call to [*] statement", + exprtk_error_location)); + + return error_node(); + } + + for ( ; ; ) + { + if (!details::imatch("case",current_token().value)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR100 - Expected a 'case' statement for multi-switch", + exprtk_error_location)); + + return error_node(); + } + + next_token(); + + expression_node_ptr condition = parse_expression(); + + if (0 == condition) + return error_node(); + + if (!token_is(token_t::e_colon)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR101 - Expected ':' for case of [*] statement", + exprtk_error_location)); + + return error_node(); + } + + expression_node_ptr consequent = + (token_is(token_t::e_lcrlbracket,prsrhlpr_t::e_hold)) ? + parse_multi_sequence("multi-switch-consequent") : + parse_expression(); + + if (0 == consequent) + return error_node(); + + if (!token_is(token_t::e_eof)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR102 - Expected ';' at end of case for [*] statement", + exprtk_error_location)); + + return error_node(); + } + + // Can we optimise away the case statement? + if (is_constant_node(condition) && is_false(condition)) + { + free_node(node_allocator_, condition ); + free_node(node_allocator_, consequent); + } + else + { + arg_list.push_back(condition ); + arg_list.push_back(consequent); + } + + if (token_is(token_t::e_rcrlbracket,prsrhlpr_t::e_hold)) + { + break; + } + } + + if (!token_is(token_t::e_rcrlbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR103 - Expected '}' at end of [*] statement", + exprtk_error_location)); + + return error_node(); + } + + const expression_node_ptr result = expression_generator_.multi_switch_statement(arg_list); + + svd.delete_ptr = (0 == result); + + return result; + } + + inline expression_node_ptr parse_vararg_function() + { + std::vector<expression_node_ptr> arg_list; + + details::operator_type opt_type = details::e_default; + const std::string symbol = current_token().value; + + if (details::imatch(symbol,"~")) + { + next_token(); + return check_block_statement_closure(parse_multi_sequence()); + } + else if (details::imatch(symbol,"[*]")) + { + return check_block_statement_closure(parse_multi_switch_statement()); + } + else if (details::imatch(symbol, "avg" )) opt_type = details::e_avg ; + else if (details::imatch(symbol, "mand")) opt_type = details::e_mand; + else if (details::imatch(symbol, "max" )) opt_type = details::e_max ; + else if (details::imatch(symbol, "min" )) opt_type = details::e_min ; + else if (details::imatch(symbol, "mor" )) opt_type = details::e_mor ; + else if (details::imatch(symbol, "mul" )) opt_type = details::e_prod; + else if (details::imatch(symbol, "sum" )) opt_type = details::e_sum ; + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR104 - Unsupported built-in vararg function: " + symbol, + exprtk_error_location)); + + return error_node(); + } + + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + lodge_symbol(symbol, e_st_function); + + next_token(); + + if (!token_is(token_t::e_lbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR105 - Expected '(' for call to vararg function: " + symbol, + exprtk_error_location)); + + return error_node(); + } + + if (token_is(token_t::e_rbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR106 - vararg function: " + symbol + + " requires at least one input parameter", + exprtk_error_location)); + + return error_node(); + } + + for ( ; ; ) + { + expression_node_ptr arg = parse_expression(); + + if (0 == arg) + return error_node(); + else + arg_list.push_back(arg); + + if (token_is(token_t::e_rbracket)) + break; + else if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR107 - Expected ',' for call to vararg function: " + symbol, + exprtk_error_location)); + + return error_node(); + } + } + + const expression_node_ptr result = expression_generator_.vararg_function(opt_type,arg_list); + + svd.delete_ptr = (0 == result); + return result; + } + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr parse_string_range_statement(expression_node_ptr& expression) + { + if (!token_is(token_t::e_lsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR108 - Expected '[' as start of string range definition", + exprtk_error_location)); + + free_node(node_allocator_, expression); + + return error_node(); + } + else if (token_is(token_t::e_rsqrbracket)) + { + return node_allocator_.allocate<details::string_size_node<T> >(expression); + } + + range_t rp; + + if (!parse_range(rp,true)) + { + free_node(node_allocator_, expression); + + return error_node(); + } + + expression_node_ptr result = expression_generator_(expression,rp); + + if (0 == result) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR109 - Failed to generate string range node", + exprtk_error_location)); + + free_node(node_allocator_, expression); + rp.free(); + } + + rp.clear(); + + if (result && result->valid()) + { + return result; + } + + set_error(make_error( + parser_error::e_synthesis, + current_token(), + "ERR110 - Failed to synthesize node: string_range_node", + exprtk_error_location)); + + free_node(node_allocator_, result); + rp.free(); + return error_node(); + } + #else + inline expression_node_ptr parse_string_range_statement(expression_node_ptr&) + { + return error_node(); + } + #endif + + inline bool parse_pending_string_rangesize(expression_node_ptr& expression) + { + // Allow no more than 100 range calls, eg: s[][][]...[][] + const std::size_t max_rangesize_parses = 100; + + std::size_t i = 0; + + while + ( + (0 != expression) && + (i++ < max_rangesize_parses) && + error_list_.empty() && + is_generally_string_node(expression) && + token_is(token_t::e_lsqrbracket,prsrhlpr_t::e_hold) + ) + { + expression = parse_string_range_statement(expression); + } + + return (i > 1); + } + + inline void parse_pending_vector_index_operator(expression_node_ptr& expression) + { + if + ( + (0 != expression) && + error_list_.empty() && + is_ivector_node(expression) + ) + { + if ( + settings_.commutative_check_enabled() && + token_is(token_t::e_mul,prsrhlpr_t::e_hold) && + peek_token_is(token_t::e_lsqrbracket) + ) + { + token_is(token_t::e_mul); + token_is(token_t::e_lsqrbracket); + } + else if (token_is(token_t::e_lsqrbracket,prsrhlpr_t::e_hold)) + { + token_is(token_t::e_lsqrbracket); + } + else if ( + token_is(token_t::e_rbracket,prsrhlpr_t::e_hold) && + peek_token_is(token_t::e_lsqrbracket) + ) + { + token_is(token_t::e_rbracket ); + token_is(token_t::e_lsqrbracket); + } + else + return; + + details::vector_interface<T>* vi = dynamic_cast<details::vector_interface<T>*>(expression); + + if (vi) + { + details::vector_holder<T>& vec = vi->vec()->vec_holder(); + const std::string vector_name = sem_.get_vector_name(vec.data()); + expression_node_ptr index = parse_vector_index(vector_name); + + if (index) + { + expression = synthesize_vector_element(vector_name, &vec, expression, index); + return; + } + } + + free_node(node_allocator_, expression); + expression = error_node(); + } + } + + template <typename Allocator1, + typename Allocator2, + template <typename, typename> class Sequence> + inline expression_node_ptr simplify(Sequence<expression_node_ptr,Allocator1>& expression_list, + Sequence<bool,Allocator2>& side_effect_list, + const bool specialise_on_final_type = false) + { + if (expression_list.empty()) + return error_node(); + else if (1 == expression_list.size()) + return expression_list[0]; + + Sequence<expression_node_ptr,Allocator1> tmp_expression_list; + + exprtk_debug(("simplify() - expression_list.size: %d side_effect_list.size(): %d\n", + static_cast<int>(expression_list .size()), + static_cast<int>(side_effect_list.size()))); + + bool return_node_present = false; + + for (std::size_t i = 0; i < (expression_list.size() - 1); ++i) + { + if (is_variable_node(expression_list[i])) + continue; + else if ( + is_return_node (expression_list[i]) || + is_break_node (expression_list[i]) || + is_continue_node(expression_list[i]) + ) + { + tmp_expression_list.push_back(expression_list[i]); + + // Remove all subexpressions after first short-circuit + // node has been encountered. + + for (std::size_t j = i + 1; j < expression_list.size(); ++j) + { + free_node(node_allocator_, expression_list[j]); + } + + return_node_present = true; + + break; + } + else if ( + is_constant_node(expression_list[i]) || + is_null_node (expression_list[i]) || + !side_effect_list[i] + ) + { + free_node(node_allocator_, expression_list[i]); + continue; + } + else + tmp_expression_list.push_back(expression_list[i]); + } + + if (!return_node_present) + { + tmp_expression_list.push_back(expression_list.back()); + } + + expression_list.swap(tmp_expression_list); + + if (tmp_expression_list.size() > expression_list.size()) + { + exprtk_debug(("simplify() - Reduced subexpressions from %d to %d\n", + static_cast<int>(tmp_expression_list.size()), + static_cast<int>(expression_list .size()))); + } + + if ( + return_node_present || + side_effect_list.back() || + (expression_list.size() > 1) + ) + state_.activate_side_effect("simplify()"); + + if (1 == expression_list.size()) + return expression_list[0]; + else if (specialise_on_final_type && is_generally_string_node(expression_list.back())) + return expression_generator_.vararg_function(details::e_smulti,expression_list); + else + return expression_generator_.vararg_function(details::e_multi,expression_list); + } + + inline expression_node_ptr parse_multi_sequence(const std::string& source = "", + const bool enforce_crlbrackets = false) + { + token_t::token_type open_bracket = token_t::e_lcrlbracket; + token_t::token_type close_bracket = token_t::e_rcrlbracket; + token_t::token_type separator = token_t::e_eof; + + if (!token_is(open_bracket)) + { + if (!enforce_crlbrackets && token_is(token_t::e_lbracket)) + { + open_bracket = token_t::e_lbracket; + close_bracket = token_t::e_rbracket; + separator = token_t::e_comma; + } + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR111 - Expected '" + token_t::to_str(open_bracket) + "' for call to multi-sequence" + + ((!source.empty()) ? std::string(" section of " + source): ""), + exprtk_error_location)); + + return error_node(); + } + } + else if (token_is(close_bracket)) + { + return node_allocator_.allocate<details::null_node<T> >(); + } + + std::vector<expression_node_ptr> arg_list; + std::vector<bool> side_effect_list; + + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + scope_handler sh(*this); + + scoped_bool_or_restorer sbr(state_.side_effect_present); + + for ( ; ; ) + { + state_.side_effect_present = false; + + expression_node_ptr arg = parse_expression(); + + if (0 == arg) + return error_node(); + else + { + arg_list.push_back(arg); + side_effect_list.push_back(state_.side_effect_present); + } + + if (token_is(close_bracket)) + break; + + const bool is_next_close = peek_token_is(close_bracket); + + if (!token_is(separator) && is_next_close) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR112 - Expected '" + lexer::token::seperator_to_str(separator) + "' for call to multi-sequence section of " + source, + exprtk_error_location)); + + return error_node(); + } + + if (token_is(close_bracket)) + break; + } + + expression_node_ptr result = simplify(arg_list, side_effect_list, source.empty()); + + svd.delete_ptr = (0 == result); + return result; + } + + inline bool parse_range(range_t& rp, const bool skip_lsqr = false) + { + // Examples of valid ranges: + // 1. [1:5] -> [1,5) + // 2. [ :5] -> [0,5) + // 3. [1: ] -> [1,end) + // 4. [x:y] -> [x,y) where x <= y + // 5. [x+1:y/2] -> [x+1,y/2) where x+1 <= y/2 + // 6. [ :y] -> [0,y) where 0 <= y + // 7. [x: ] -> [x,end) where x <= end + + rp.clear(); + + if (!skip_lsqr && !token_is(token_t::e_lsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR113 - Expected '[' for start of range", + exprtk_error_location)); + + return false; + } + + if (token_is(token_t::e_colon)) + { + rp.n0_c.first = true; + rp.n0_c.second = 0; + rp.cache.first = 0; + } + else + { + expression_node_ptr r0 = parse_expression(); + + if (0 == r0) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR114 - Failed parse begin section of range", + exprtk_error_location)); + + return false; + } + else if (is_constant_node(r0)) + { + const T r0_value = r0->value(); + + if (r0_value >= T(0)) + { + rp.n0_c.first = true; + rp.n0_c.second = static_cast<std::size_t>(details::numeric::to_int64(r0_value)); + rp.cache.first = rp.n0_c.second; + } + + free_node(node_allocator_, r0); + + if (r0_value < T(0)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR115 - Range lower bound less than zero! Constraint: r0 >= 0", + exprtk_error_location)); + + return false; + } + } + else + { + rp.n0_e.first = true; + rp.n0_e.second = r0; + } + + if (!token_is(token_t::e_colon)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR116 - Expected ':' for break in range", + exprtk_error_location)); + + rp.free(); + + return false; + } + } + + if (token_is(token_t::e_rsqrbracket)) + { + rp.n1_c.first = true; + rp.n1_c.second = std::numeric_limits<std::size_t>::max(); + } + else + { + expression_node_ptr r1 = parse_expression(); + + if (0 == r1) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR117 - Failed parse end section of range", + exprtk_error_location)); + + rp.free(); + + return false; + } + else if (is_constant_node(r1)) + { + const T r1_value = r1->value(); + + if (r1_value >= T(0)) + { + rp.n1_c.first = true; + rp.n1_c.second = static_cast<std::size_t>(details::numeric::to_int64(r1_value)); + rp.cache.second = rp.n1_c.second; + } + + free_node(node_allocator_, r1); + + if (r1_value < T(0)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR118 - Range upper bound less than zero! Constraint: r1 >= 0", + exprtk_error_location)); + + rp.free(); + + return false; + } + } + else + { + rp.n1_e.first = true; + rp.n1_e.second = r1; + } + + if (!token_is(token_t::e_rsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR119 - Expected ']' for start of range", + exprtk_error_location)); + + rp.free(); + + return false; + } + } + + if (rp.const_range()) + { + std::size_t r0 = 0; + std::size_t r1 = 0; + + bool rp_result = false; + + try + { + rp_result = rp(r0, r1); + } + catch (std::runtime_error&) + {} + + if (!rp_result || (r0 > r1)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR120 - Invalid range, Constraint: r0 <= r1", + exprtk_error_location)); + + return false; + } + } + + return true; + } + + inline void lodge_symbol(const std::string& symbol, + const symbol_type st) + { + dec_.add_symbol(symbol,st); + } + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr parse_string() + { + const std::string symbol = current_token().value; + + typedef details::stringvar_node<T>* strvar_node_t; + + expression_node_ptr result = error_node(); + strvar_node_t const_str_node = static_cast<strvar_node_t>(0); + + scope_element& se = sem_.get_active_element(symbol); + + if (scope_element::e_string == se.type) + { + se.active = true; + result = se.str_node; + lodge_symbol(symbol, e_st_local_string); + } + else + { + typedef typename symtab_store::string_context str_ctxt_t; + str_ctxt_t str_ctx = symtab_store_.get_string_context(symbol); + + if ((0 == str_ctx.str_var) || !symtab_store_.is_conststr_stringvar(symbol)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR121 - Unknown string symbol", + exprtk_error_location)); + + return error_node(); + } + + assert(str_ctx.str_var != 0); + assert(str_ctx.symbol_table != 0); + + result = str_ctx.str_var; + + if (symtab_store_.is_constant_string(symbol)) + { + const_str_node = static_cast<strvar_node_t>(result); + result = expression_generator_(const_str_node->str()); + } + else if (symbol_table_t::e_immutable == str_ctx.symbol_table->mutability()) + { + lodge_immutable_symbol( + current_token(), + make_memory_range(str_ctx.str_var->base(), str_ctx.str_var->size())); + } + + lodge_symbol(symbol, e_st_string); + } + + if (peek_token_is(token_t::e_lsqrbracket)) + { + next_token(); + + if (peek_token_is(token_t::e_rsqrbracket)) + { + next_token(); + next_token(); + + if (const_str_node) + { + free_node(node_allocator_, result); + + return expression_generator_(T(const_str_node->size())); + } + else + return node_allocator_.allocate<details::stringvar_size_node<T> > + (static_cast<details::stringvar_node<T>*>(result)->ref()); + } + + range_t rp; + + if (!parse_range(rp)) + { + free_node(node_allocator_, result); + + return error_node(); + } + else if (const_str_node) + { + free_node(node_allocator_, result); + result = expression_generator_(const_str_node->ref(),rp); + } + else + result = expression_generator_(static_cast<details::stringvar_node<T>*> + (result)->ref(), rp); + + if (result) + rp.clear(); + } + else + next_token(); + + return result; + } + #else + inline expression_node_ptr parse_string() + { + return error_node(); + } + #endif + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr parse_const_string() + { + const std::string const_str = current_token().value; + expression_node_ptr result = expression_generator_(const_str); + + if (peek_token_is(token_t::e_lsqrbracket)) + { + next_token(); + + if (peek_token_is(token_t::e_rsqrbracket)) + { + next_token(); + next_token(); + + free_node(node_allocator_, result); + + return expression_generator_(T(const_str.size())); + } + + range_t rp; + + if (!parse_range(rp)) + { + free_node(node_allocator_, result); + rp.free(); + + return error_node(); + } + + free_node(node_allocator_, result); + + if (rp.n1_c.first && (rp.n1_c.second == std::numeric_limits<std::size_t>::max())) + { + rp.n1_c.second = const_str.size() - 1; + rp.cache.second = rp.n1_c.second; + } + + if ( + (rp.n0_c.first && (rp.n0_c.second >= const_str.size())) || + (rp.n1_c.first && (rp.n1_c.second >= const_str.size())) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR122 - Overflow in range for string: '" + const_str + "'[" + + (rp.n0_c.first ? details::to_str(static_cast<int>(rp.n0_c.second)) : "?") + ":" + + (rp.n1_c.first ? details::to_str(static_cast<int>(rp.n1_c.second)) : "?") + "]", + exprtk_error_location)); + + rp.free(); + + return error_node(); + } + + result = expression_generator_(const_str,rp); + + if (result) + rp.clear(); + } + else + next_token(); + + return result; + } + #else + inline expression_node_ptr parse_const_string() + { + return error_node(); + } + #endif + + inline expression_node_ptr parse_vector_index(const std::string& vector_name = "") + { + expression_node_ptr index_expr = error_node(); + + if (0 == (index_expr = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR123 - Failed to parse index for vector: '" + vector_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_rsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR124 - Expected ']' for index of vector: '" + vector_name + "'", + exprtk_error_location)); + + free_node(node_allocator_, index_expr); + + return error_node(); + } + + return index_expr; + } + + inline expression_node_ptr parse_vector() + { + const std::string vector_name = current_token().value; + + vector_holder_ptr vec = vector_holder_ptr(0); + + const scope_element& se = sem_.get_active_element(vector_name); + + if ( + !details::imatch(se.name, vector_name) || + (se.depth > state_.scope_depth) || + (scope_element::e_vector != se.type) + ) + { + typedef typename symtab_store::vector_context vec_ctxt_t; + vec_ctxt_t vec_ctx = symtab_store_.get_vector_context(vector_name); + + if (0 == vec_ctx.vector_holder) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR125 - Symbol '" + vector_name + " not a vector", + exprtk_error_location)); + + return error_node(); + } + + assert(0 != vec_ctx.vector_holder); + assert(0 != vec_ctx.symbol_table ); + + vec = vec_ctx.vector_holder; + + if (symbol_table_t::e_immutable == vec_ctx.symbol_table->mutability()) + { + lodge_immutable_symbol( + current_token(), + make_memory_range(vec->data(), vec->size())); + } + } + else + { + vec = se.vec_node; + } + + assert(0 != vec); + + next_token(); + + if (!token_is(token_t::e_lsqrbracket)) + { + return node_allocator_.allocate<vector_node_t>(vec); + } + else if (token_is(token_t::e_rsqrbracket)) + { + return (vec->rebaseable()) ? + node_allocator_.allocate<vector_size_node_t>(vec) : + expression_generator_(T(vec->size())); + } + + expression_node_ptr index_expr = parse_vector_index(vector_name); + + if (index_expr) + { + expression_node_ptr vec_node = node_allocator_.allocate<vector_node_t>(vec); + + return synthesize_vector_element(vector_name, vec, vec_node, index_expr); + } + + return error_node(); + } + + inline expression_node_ptr synthesize_vector_element(const std::string& vector_name, + vector_holder_ptr vec, + expression_node_ptr vec_node, + expression_node_ptr index_expr) + { + // Perform compile-time range check + if (details::is_constant_node(index_expr)) + { + const std::size_t index = static_cast<std::size_t>(details::numeric::to_int32(index_expr->value())); + const std::size_t vec_size = vec->size(); + + if (index >= vec_size) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR126 - Index of " + details::to_str(index) + " out of range for " + "vector '" + vector_name + "' of size " + details::to_str(vec_size), + exprtk_error_location)); + + free_node(node_allocator_, vec_node ); + free_node(node_allocator_, index_expr); + + return error_node(); + } + } + + return expression_generator_.vector_element(vector_name, vec, vec_node, index_expr); + } + + inline expression_node_ptr parse_vararg_function_call(ivararg_function<T>* vararg_function, const std::string& vararg_function_name) + { + std::vector<expression_node_ptr> arg_list; + + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + next_token(); + + if (token_is(token_t::e_lbracket)) + { + if (token_is(token_t::e_rbracket)) + { + if (!vararg_function->allow_zero_parameters()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR127 - Zero parameter call to vararg function: " + + vararg_function_name + " not allowed", + exprtk_error_location)); + + return error_node(); + } + } + else + { + for ( ; ; ) + { + expression_node_ptr arg = parse_expression(); + + if (0 == arg) + return error_node(); + else + arg_list.push_back(arg); + + if (token_is(token_t::e_rbracket)) + break; + else if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR128 - Expected ',' for call to vararg function: " + + vararg_function_name, + exprtk_error_location)); + + return error_node(); + } + } + } + } + else if (!vararg_function->allow_zero_parameters()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR129 - Zero parameter call to vararg function: " + + vararg_function_name + " not allowed", + exprtk_error_location)); + + return error_node(); + } + + if (arg_list.size() < vararg_function->min_num_args()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR130 - Invalid number of parameters to call to vararg function: " + + vararg_function_name + ", require at least " + + details::to_str(static_cast<int>(vararg_function->min_num_args())) + " parameters", + exprtk_error_location)); + + return error_node(); + } + else if (arg_list.size() > vararg_function->max_num_args()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR131 - Invalid number of parameters to call to vararg function: " + + vararg_function_name + ", require no more than " + + details::to_str(static_cast<int>(vararg_function->max_num_args())) + " parameters", + exprtk_error_location)); + + return error_node(); + } + + expression_node_ptr result = expression_generator_.vararg_function_call(vararg_function,arg_list); + + svd.delete_ptr = (0 == result); + + return result; + } + + class type_checker + { + public: + + enum return_type_t + { + e_overload = ' ', + e_numeric = 'T', + e_string = 'S' + }; + + struct function_prototype_t + { + return_type_t return_type; + std::string param_seq; + }; + + typedef parser<T> parser_t; + typedef std::vector<function_prototype_t> function_definition_list_t; + + type_checker(parser_t& p, + const std::string& func_name, + const std::string& func_prototypes, + const return_type_t default_return_type) + : invalid_state_(true) + , parser_(p) + , function_name_(func_name) + , default_return_type_(default_return_type) + { + parse_function_prototypes(func_prototypes); + } + + bool verify(const std::string& param_seq, std::size_t& pseq_index) + { + if (function_definition_list_.empty()) + return true; + + std::vector<std::pair<std::size_t,char> > error_list; + + for (std::size_t i = 0; i < function_definition_list_.size(); ++i) + { + details::char_t diff_value = 0; + std::size_t diff_index = 0; + + const bool result = details::sequence_match(function_definition_list_[i].param_seq, + param_seq, + diff_index, diff_value); + + if (result) + { + pseq_index = i; + return true; + } + else + error_list.push_back(std::make_pair(diff_index, diff_value)); + } + + if (1 == error_list.size()) + { + parser_.set_error(make_error( + parser_error::e_syntax, + parser_.current_token(), + "ERR132 - Failed parameter type check for function '" + function_name_ + "', " + "Expected '" + function_definition_list_[0].param_seq + + "' call set: '" + param_seq + "'", + exprtk_error_location)); + } + else + { + // find first with largest diff_index; + std::size_t max_diff_index = 0; + + for (std::size_t i = 1; i < error_list.size(); ++i) + { + if (error_list[i].first > error_list[max_diff_index].first) + { + max_diff_index = i; + } + } + + parser_.set_error(make_error( + parser_error::e_syntax, + parser_.current_token(), + "ERR133 - Failed parameter type check for function '" + function_name_ + "', " + "Best match: '" + function_definition_list_[max_diff_index].param_seq + + "' call set: '" + param_seq + "'", + exprtk_error_location)); + } + + return false; + } + + std::size_t paramseq_count() const + { + return function_definition_list_.size(); + } + + std::string paramseq(const std::size_t& index) const + { + return function_definition_list_[index].param_seq; + } + + return_type_t return_type(const std::size_t& index) const + { + return function_definition_list_[index].return_type; + } + + bool invalid() const + { + return !invalid_state_; + } + + bool allow_zero_parameters() const + { + + for (std::size_t i = 0; i < function_definition_list_.size(); ++i) + { + if (std::string::npos != function_definition_list_[i].param_seq.find("Z")) + { + return true; + } + } + + return false; + } + + private: + + std::vector<std::string> split_param_seq(const std::string& param_seq, const details::char_t delimiter = '|') const + { + std::string::const_iterator current_begin = param_seq.begin(); + std::string::const_iterator iter = param_seq.begin(); + + std::vector<std::string> result; + + while (iter != param_seq.end()) + { + if (*iter == delimiter) + { + result.push_back(std::string(current_begin, iter)); + current_begin = ++iter; + } + else + ++iter; + } + + if (current_begin != iter) + { + result.push_back(std::string(current_begin, iter)); + } + + return result; + } + + inline bool is_valid_token(std::string param_seq, + function_prototype_t& funcproto) const + { + // Determine return type + funcproto.return_type = default_return_type_; + + if (param_seq.size() > 2) + { + if (':' == param_seq[1]) + { + // Note: Only overloaded igeneric functions can have return + // type definitions. + if (type_checker::e_overload != default_return_type_) + return false; + + switch (param_seq[0]) + { + case 'T' : funcproto.return_type = type_checker::e_numeric; + break; + + case 'S' : funcproto.return_type = type_checker::e_string; + break; + + default : return false; + } + + param_seq.erase(0,2); + } + } + + if ( + (std::string::npos != param_seq.find("?*")) || + (std::string::npos != param_seq.find("**")) + ) + { + return false; + } + else if ( + (std::string::npos == param_seq.find_first_not_of("STV*?|")) || + ("Z" == param_seq) + ) + { + funcproto.param_seq = param_seq; + return true; + } + + return false; + } + + void parse_function_prototypes(const std::string& func_prototypes) + { + if (func_prototypes.empty()) + return; + + std::vector<std::string> param_seq_list = split_param_seq(func_prototypes); + + typedef std::map<std::string,std::size_t> param_seq_map_t; + param_seq_map_t param_seq_map; + + for (std::size_t i = 0; i < param_seq_list.size(); ++i) + { + function_prototype_t func_proto; + + if (!is_valid_token(param_seq_list[i], func_proto)) + { + invalid_state_ = false; + + parser_.set_error(make_error( + parser_error::e_syntax, + parser_.current_token(), + "ERR134 - Invalid parameter sequence of '" + param_seq_list[i] + + "' for function: " + function_name_, + exprtk_error_location)); + return; + } + + param_seq_map_t::const_iterator seq_itr = param_seq_map.find(param_seq_list[i]); + + if (param_seq_map.end() != seq_itr) + { + invalid_state_ = false; + + parser_.set_error(make_error( + parser_error::e_syntax, + parser_.current_token(), + "ERR135 - Function '" + function_name_ + "' has a parameter sequence conflict between " + + "pseq_idx[" + details::to_str(seq_itr->second) + "] and" + + "pseq_idx[" + details::to_str(i) + "] " + + "param seq: " + param_seq_list[i], + exprtk_error_location)); + return; + } + + function_definition_list_.push_back(func_proto); + } + } + + type_checker(const type_checker&) exprtk_delete; + type_checker& operator=(const type_checker&) exprtk_delete; + + bool invalid_state_; + parser_t& parser_; + std::string function_name_; + const return_type_t default_return_type_; + function_definition_list_t function_definition_list_; + }; + + inline expression_node_ptr parse_generic_function_call(igeneric_function<T>* function, const std::string& function_name) + { + std::vector<expression_node_ptr> arg_list; + + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + next_token(); + + std::string param_type_list; + + type_checker tc( + (*this), + function_name, + function->parameter_sequence, + type_checker::e_string); + + if (tc.invalid()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR136 - Type checker instantiation failure for generic function: " + function_name, + exprtk_error_location)); + + return error_node(); + } + + if (token_is(token_t::e_lbracket)) + { + if (token_is(token_t::e_rbracket)) + { + if ( + !function->allow_zero_parameters() && + !tc .allow_zero_parameters() + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR137 - Zero parameter call to generic function: " + + function_name + " not allowed", + exprtk_error_location)); + + return error_node(); + } + } + else + { + for ( ; ; ) + { + expression_node_ptr arg = parse_expression(); + + if (0 == arg) + return error_node(); + + if (is_ivector_node(arg)) + param_type_list += 'V'; + else if (is_generally_string_node(arg)) + param_type_list += 'S'; + else // Everything else is assumed to be a scalar returning expression + param_type_list += 'T'; + + arg_list.push_back(arg); + + if (token_is(token_t::e_rbracket)) + break; + else if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR138 - Expected ',' for call to generic function: " + function_name, + exprtk_error_location)); + + return error_node(); + } + } + } + } + else if ( + !function->parameter_sequence.empty() && + function->allow_zero_parameters () && + !tc .allow_zero_parameters () + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR139 - Zero parameter call to generic function: " + + function_name + " not allowed", + exprtk_error_location)); + + return error_node(); + } + + std::size_t param_seq_index = 0; + + if ( + state_.type_check_enabled && + !tc.verify(param_type_list, param_seq_index) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR140 - Invalid input parameter sequence for call to generic function: " + function_name, + exprtk_error_location)); + + return error_node(); + } + + expression_node_ptr result = + (tc.paramseq_count() <= 1) ? + expression_generator_ + .generic_function_call(function, arg_list) : + expression_generator_ + .generic_function_call(function, arg_list, param_seq_index); + + svd.delete_ptr = (0 == result); + + return result; + } + + inline bool parse_igeneric_function_params(std::string& param_type_list, + std::vector<expression_node_ptr>& arg_list, + const std::string& function_name, + igeneric_function<T>* function, + const type_checker& tc) + { + if (token_is(token_t::e_lbracket)) + { + if (token_is(token_t::e_rbracket)) + { + if ( + !function->allow_zero_parameters() && + !tc .allow_zero_parameters() + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR141 - Zero parameter call to generic function: " + + function_name + " not allowed", + exprtk_error_location)); + + return false; + } + } + else + { + for ( ; ; ) + { + expression_node_ptr arg = parse_expression(); + + if (0 == arg) + return false; + + if (is_ivector_node(arg)) + param_type_list += 'V'; + else if (is_generally_string_node(arg)) + param_type_list += 'S'; + else // Everything else is a scalar returning expression + param_type_list += 'T'; + + arg_list.push_back(arg); + + if (token_is(token_t::e_rbracket)) + break; + else if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR142 - Expected ',' for call to string function: " + function_name, + exprtk_error_location)); + + return false; + } + } + } + + return true; + } + else + return false; + } + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr parse_string_function_call(igeneric_function<T>* function, const std::string& function_name) + { + // Move pass the function name + next_token(); + + std::string param_type_list; + + type_checker tc((*this), function_name, function->parameter_sequence, type_checker::e_string); + + if ( + (!function->parameter_sequence.empty()) && + (0 == tc.paramseq_count()) + ) + { + return error_node(); + } + + std::vector<expression_node_ptr> arg_list; + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + if (!parse_igeneric_function_params(param_type_list, arg_list, function_name, function, tc)) + { + return error_node(); + } + + std::size_t param_seq_index = 0; + + if (!tc.verify(param_type_list, param_seq_index)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR143 - Invalid input parameter sequence for call to string function: " + function_name, + exprtk_error_location)); + + return error_node(); + } + + expression_node_ptr result = + (tc.paramseq_count() <= 1) ? + expression_generator_ + .string_function_call(function, arg_list) : + expression_generator_ + .string_function_call(function, arg_list, param_seq_index); + + svd.delete_ptr = (0 == result); + + return result; + } + + inline expression_node_ptr parse_overload_function_call(igeneric_function<T>* function, const std::string& function_name) + { + // Move pass the function name + next_token(); + + std::string param_type_list; + + type_checker tc((*this), function_name, function->parameter_sequence, type_checker::e_overload); + + if ( + (!function->parameter_sequence.empty()) && + (0 == tc.paramseq_count()) + ) + { + return error_node(); + } + + std::vector<expression_node_ptr> arg_list; + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + if (!parse_igeneric_function_params(param_type_list, arg_list, function_name, function, tc)) + { + return error_node(); + } + + std::size_t param_seq_index = 0; + + if (!tc.verify(param_type_list, param_seq_index)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR144 - Invalid input parameter sequence for call to overloaded function: " + function_name, + exprtk_error_location)); + + return error_node(); + } + + expression_node_ptr result = error_node(); + + if (type_checker::e_numeric == tc.return_type(param_seq_index)) + { + if (tc.paramseq_count() <= 1) + result = expression_generator_ + .generic_function_call(function, arg_list); + else + result = expression_generator_ + .generic_function_call(function, arg_list, param_seq_index); + } + else if (type_checker::e_string == tc.return_type(param_seq_index)) + { + if (tc.paramseq_count() <= 1) + result = expression_generator_ + .string_function_call(function, arg_list); + else + result = expression_generator_ + .string_function_call(function, arg_list, param_seq_index); + } + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR145 - Invalid return type for call to overloaded function: " + function_name, + exprtk_error_location)); + } + + svd.delete_ptr = (0 == result); + return result; + } + #endif + + template <typename Type, std::size_t NumberOfParameters> + struct parse_special_function_impl + { + static inline expression_node_ptr process(parser<Type>& p, const details::operator_type opt_type, const std::string& sf_name) + { + expression_node_ptr branch[NumberOfParameters]; + expression_node_ptr result = error_node(); + + std::fill_n(branch, NumberOfParameters, reinterpret_cast<expression_node_ptr>(0)); + + scoped_delete<expression_node_t,NumberOfParameters> sd(p,branch); + + p.next_token(); + + if (!p.token_is(token_t::e_lbracket)) + { + p.set_error(make_error( + parser_error::e_syntax, + p.current_token(), + "ERR146 - Expected '(' for special function '" + sf_name + "'", + exprtk_error_location)); + + return error_node(); + } + + for (std::size_t i = 0; i < NumberOfParameters; ++i) + { + branch[i] = p.parse_expression(); + + if (0 == branch[i]) + { + return p.error_node(); + } + else if (i < (NumberOfParameters - 1)) + { + if (!p.token_is(token_t::e_comma)) + { + p.set_error(make_error( + parser_error::e_syntax, + p.current_token(), + "ERR147 - Expected ',' before next parameter of special function '" + sf_name + "'", + exprtk_error_location)); + + return p.error_node(); + } + } + } + + if (!p.token_is(token_t::e_rbracket)) + { + p.set_error(make_error( + parser_error::e_syntax, + p.current_token(), + "ERR148 - Invalid number of parameters for special function '" + sf_name + "'", + exprtk_error_location)); + + return p.error_node(); + } + else + result = p.expression_generator_.special_function(opt_type,branch); + + sd.delete_ptr = (0 == result); + + return result; + } + }; + + inline expression_node_ptr parse_special_function() + { + const std::string sf_name = current_token().value; + + // Expect: $fDD(expr0,expr1,expr2) or $fDD(expr0,expr1,expr2,expr3) + if ( + !details::is_digit(sf_name[2]) || + !details::is_digit(sf_name[3]) + ) + { + set_error(make_error( + parser_error::e_token, + current_token(), + "ERR149 - Invalid special function[1]: " + sf_name, + exprtk_error_location)); + + return error_node(); + } + + const int id = (sf_name[2] - '0') * 10 + + (sf_name[3] - '0'); + + if (id >= details::e_sffinal) + { + set_error(make_error( + parser_error::e_token, + current_token(), + "ERR150 - Invalid special function[2]: " + sf_name, + exprtk_error_location)); + + return error_node(); + } + + const int sf_3_to_4 = details::e_sf48; + const details::operator_type opt_type = details::operator_type(id + 1000); + const std::size_t NumberOfParameters = (id < (sf_3_to_4 - 1000)) ? 3U : 4U; + + switch (NumberOfParameters) + { + case 3 : return parse_special_function_impl<T,3>::process((*this), opt_type, sf_name); + case 4 : return parse_special_function_impl<T,4>::process((*this), opt_type, sf_name); + default : return error_node(); + } + } + + inline expression_node_ptr parse_null_statement() + { + next_token(); + return node_allocator_.allocate<details::null_node<T> >(); + } + + #ifndef exprtk_disable_break_continue + inline expression_node_ptr parse_break_statement() + { + if (state_.parsing_break_stmt) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR151 - Invoking 'break' within a break call is not allowed", + exprtk_error_location)); + + return error_node(); + } + else if (0 == state_.parsing_loop_stmt_count) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR152 - Invalid use of 'break', allowed only in the scope of a loop", + exprtk_error_location)); + + return error_node(); + } + + scoped_bool_negator sbn(state_.parsing_break_stmt); + + if (!brkcnt_list_.empty()) + { + next_token(); + + brkcnt_list_.front() = true; + + expression_node_ptr return_expr = error_node(); + + if (token_is(token_t::e_lsqrbracket)) + { + if (0 == (return_expr = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR153 - Failed to parse return expression for 'break' statement", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_rsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR154 - Expected ']' at the completion of break's return expression", + exprtk_error_location)); + + free_node(node_allocator_, return_expr); + + return error_node(); + } + } + + state_.activate_side_effect("parse_break_statement()"); + + return node_allocator_.allocate<details::break_node<T> >(return_expr); + } + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR155 - Invalid use of 'break', allowed only in the scope of a loop", + exprtk_error_location)); + } + + return error_node(); + } + + inline expression_node_ptr parse_continue_statement() + { + if (0 == state_.parsing_loop_stmt_count) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR156 - Invalid use of 'continue', allowed only in the scope of a loop", + exprtk_error_location)); + + return error_node(); + } + else + { + next_token(); + + brkcnt_list_.front() = true; + state_.activate_side_effect("parse_continue_statement()"); + + return node_allocator_.allocate<details::continue_node<T> >(); + } + } + #endif + + inline expression_node_ptr parse_define_vector_statement(const std::string& vec_name) + { + expression_node_ptr size_expression_node = error_node(); + + if (!token_is(token_t::e_lsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR157 - Expected '[' as part of vector size definition", + exprtk_error_location)); + + return error_node(); + } + else if (0 == (size_expression_node = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR158 - Failed to determine size of vector '" + vec_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (!is_constant_node(size_expression_node)) + { + const bool is_rebaseble_vector = + (size_expression_node->type() == details::expression_node<T>::e_vecsize) && + static_cast<details::vector_size_node<T>*>(size_expression_node)->vec_holder()->rebaseable(); + + free_node(node_allocator_, size_expression_node); + + const std::string error_msg = (is_rebaseble_vector) ? + std::string("Rebasable/Resizable vector cannot be used to define the size of vector") : + std::string("Expected a constant literal number as size of vector"); + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR159 - " + error_msg + " '" + vec_name + "'", + exprtk_error_location)); + + return error_node(); + } + + const T vector_size = size_expression_node->value(); + + free_node(node_allocator_, size_expression_node); + + const std::size_t max_vector_size = settings_.max_local_vector_size(); + + if ( + (vector_size <= T(0)) || + std::not_equal_to<T>() + (T(0),vector_size - details::numeric::trunc(vector_size)) || + (static_cast<std::size_t>(vector_size) > max_vector_size) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR160 - Invalid vector size. Must be an integer in the " + "range [0," + details::to_str(static_cast<std::size_t>(max_vector_size)) + "], size: " + + details::to_str(details::numeric::to_int32(vector_size)), + exprtk_error_location)); + + return error_node(); + } + + typename symbol_table_t::vector_holder_ptr vec_holder = typename symbol_table_t::vector_holder_ptr(0); + + const std::size_t vec_size = static_cast<std::size_t>(details::numeric::to_int32(vector_size)); + const std::size_t predicted_total_lclsymb_size = sizeof(T) * vec_size + sem_.total_local_symb_size_bytes(); + + if (predicted_total_lclsymb_size > settings().max_total_local_symbol_size_bytes()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR161 - Adding vector '" + vec_name + "' of size " + details::to_str(vec_size) + " bytes " + "will exceed max total local symbol size of: " + details::to_str(settings().max_total_local_symbol_size_bytes()) + " bytes, " + "current total size: " + details::to_str(sem_.total_local_symb_size_bytes()) + " bytes", + exprtk_error_location)); + + return error_node(); + } + + scope_element& se = sem_.get_element(vec_name); + + if (se.name == vec_name) + { + if (se.active) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR162 - Illegal redefinition of local vector: '" + vec_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if ( + (se.size == vec_size) && + (scope_element::e_vector == se.type) + ) + { + vec_holder = se.vec_node; + se.active = true; + se.depth = state_.scope_depth; + se.ref_count++; + } + } + + if (0 == vec_holder) + { + scope_element nse; + nse.name = vec_name; + nse.active = true; + nse.ref_count = 1; + nse.type = scope_element::e_vector; + nse.depth = state_.scope_depth; + nse.size = vec_size; + nse.data = new T[vec_size]; + nse.vec_node = new typename scope_element::vector_holder_t(reinterpret_cast<T*>(nse.data),nse.size); + + details::set_zero_value(reinterpret_cast<T*>(nse.data),vec_size); + + if (!sem_.add_element(nse)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR163 - Failed to add new local vector '" + vec_name + "' to SEM", + exprtk_error_location)); + + sem_.free_element(nse); + + return error_node(); + } + + assert(sem_.total_local_symb_size_bytes() <= settings().max_total_local_symbol_size_bytes()); + + vec_holder = nse.vec_node; + + exprtk_debug(("parse_define_vector_statement() - INFO - Added new local vector: %s[%d]\n", + nse.name.c_str(), + static_cast<int>(nse.size))); + } + + state_.activate_side_effect("parse_define_vector_statement()"); + + lodge_symbol(vec_name, e_st_local_vector); + + std::vector<expression_node_ptr> vec_initilizer_list; + + scoped_vec_delete<expression_node_t> svd((*this), vec_initilizer_list); + + bool single_value_initialiser = false; + bool range_value_initialiser = false; + bool vec_to_vec_initialiser = false; + bool null_initialisation = false; + + if (!token_is(token_t::e_rsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR164 - Expected ']' as part of vector size definition", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_eof, prsrhlpr_t::e_hold)) + { + if (!token_is(token_t::e_assign)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR165 - Expected ':=' as part of vector definition", + exprtk_error_location)); + + return error_node(); + } + else if (token_is(token_t::e_lsqrbracket)) + { + expression_node_ptr initialiser_component = parse_expression(); + + if (0 == initialiser_component) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR166 - Failed to parse first component of vector initialiser for vector: " + vec_name, + exprtk_error_location)); + + return error_node(); + } + + vec_initilizer_list.push_back(initialiser_component); + + if (token_is(token_t::e_colon)) + { + initialiser_component = parse_expression(); + + if (0 == initialiser_component) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR167 - Failed to parse second component of vector initialiser for vector: " + vec_name, + exprtk_error_location)); + + return error_node(); + } + + vec_initilizer_list.push_back(initialiser_component); + } + + if (!token_is(token_t::e_rsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR168 - Expected ']' to close single value vector initialiser", + exprtk_error_location)); + + return error_node(); + } + + switch (vec_initilizer_list.size()) + { + case 1 : single_value_initialiser = true; break; + case 2 : range_value_initialiser = true; break; + } + } + else if (!token_is(token_t::e_lcrlbracket)) + { + expression_node_ptr initialiser = error_node(); + + // Is this a vector to vector assignment and initialisation? + if (token_t::e_symbol == current_token().type) + { + // Is it a locally defined vector? + const scope_element& lcl_se = sem_.get_active_element(current_token().value); + + if (scope_element::e_vector == lcl_se.type) + { + if (0 != (initialiser = parse_expression())) + vec_initilizer_list.push_back(initialiser); + else + return error_node(); + } + // Are we dealing with a user defined vector? + else if (symtab_store_.is_vector(current_token().value)) + { + lodge_symbol(current_token().value, e_st_vector); + + if (0 != (initialiser = parse_expression())) + vec_initilizer_list.push_back(initialiser); + else + return error_node(); + } + // Are we dealing with a null initialisation vector definition? + else if (token_is(token_t::e_symbol,"null")) + null_initialisation = true; + } + + if (!null_initialisation) + { + if (0 == initialiser) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR169 - Expected '{' as part of vector initialiser list", + exprtk_error_location)); + + return error_node(); + } + else + vec_to_vec_initialiser = true; + } + } + else if (!token_is(token_t::e_rcrlbracket)) + { + for ( ; ; ) + { + expression_node_ptr initialiser = parse_expression(); + + if (0 == initialiser) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR170 - Expected '{' as part of vector initialiser list", + exprtk_error_location)); + + return error_node(); + } + else + vec_initilizer_list.push_back(initialiser); + + if (token_is(token_t::e_rcrlbracket)) + break; + + const bool is_next_close = peek_token_is(token_t::e_rcrlbracket); + + if (!token_is(token_t::e_comma) && is_next_close) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR171 - Expected ',' between vector initialisers", + exprtk_error_location)); + + return error_node(); + } + + if (token_is(token_t::e_rcrlbracket)) + break; + } + } + + if ( + !token_is(token_t::e_rbracket , prsrhlpr_t::e_hold) && + !token_is(token_t::e_rcrlbracket, prsrhlpr_t::e_hold) && + !token_is(token_t::e_rsqrbracket, prsrhlpr_t::e_hold) + ) + { + if (!token_is(token_t::e_eof,prsrhlpr_t::e_hold)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR172 - Expected ';' at end of vector definition", + exprtk_error_location)); + + return error_node(); + } + } + + if ( + !single_value_initialiser && + !range_value_initialiser && + (T(vec_initilizer_list.size()) > vector_size) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR173 - Initialiser list larger than the number of elements in the vector: '" + vec_name + "'", + exprtk_error_location)); + + return error_node(); + } + } + + expression_node_ptr result = error_node(); + + if ( + (vec_initilizer_list.size() == 1) && + single_value_initialiser + ) + { + if (details::is_constant_node(vec_initilizer_list[0])) + { + // vector_init_zero_value_node var v[10] := [0] + if (T(0) == vec_initilizer_list[0]->value()) + { + result = node_allocator_ + .allocate<details::vector_init_zero_value_node<T> >( + (*vec_holder)[0], + vec_size, + vec_initilizer_list); + } + else + { + // vector_init_single_constvalue_node var v[10] := [123] + result = node_allocator_ + .allocate<details::vector_init_single_constvalue_node<T> >( + (*vec_holder)[0], + vec_size, + vec_initilizer_list); + } + } + else + { + // vector_init_single_value_node var v[10] := [123 + (x / y)] + result = node_allocator_ + .allocate<details::vector_init_single_value_node<T> >( + (*vec_holder)[0], + vec_size, + vec_initilizer_list); + } + } + else if ( + (vec_initilizer_list.size() == 2) && + range_value_initialiser + ) + { + bool base_const = details::is_constant_node(vec_initilizer_list[0]); + bool inc_const = details::is_constant_node(vec_initilizer_list[1]); + + if (base_const && inc_const) + { + // vector_init_single_value_node var v[10] := [1 : 3.5] + result = node_allocator_ + .allocate<details::vector_init_iota_constconst_node<T> >( + (*vec_holder)[0], + vec_size, + vec_initilizer_list); + } + else if (base_const && !inc_const) + { + // vector_init_single_value_node var v[10] := [1 : x + y] + result = node_allocator_ + .allocate<details::vector_init_iota_constnconst_node<T> >( + (*vec_holder)[0], + vec_size, + vec_initilizer_list); + } + else if (!base_const && inc_const) + { + // vector_init_single_value_node var v[10] := [x + y : 3] + result = node_allocator_ + .allocate<details::vector_init_iota_nconstconst_node<T> >( + (*vec_holder)[0], + vec_size, + vec_initilizer_list); + } + else if (!base_const && !inc_const) + { + // vector_init_single_value_node var v[10] := [x + y : z / w] + result = node_allocator_ + .allocate<details::vector_init_iota_nconstnconst_node<T> >( + (*vec_holder)[0], + vec_size, + vec_initilizer_list); + } + } + else if (null_initialisation) + result = expression_generator_(T(0.0)); + else if (vec_to_vec_initialiser) + { + expression_node_ptr vec_node = node_allocator_.allocate<vector_node_t>(vec_holder); + + result = expression_generator_( + details::e_assign, + vec_node, + vec_initilizer_list[0]); + } + else + { + result = node_allocator_ + .allocate<details::vector_initialisation_node<T> >( + (*vec_holder)[0], + vec_size, + vec_initilizer_list, + single_value_initialiser); + } + + svd.delete_ptr = false; + + if (result && result->valid()) + { + return result; + } + + details::free_node(node_allocator_, result); + + set_error(make_error( + parser_error::e_synthesis, + current_token(), + "ERR174 - Failed to generate initialisation node for vector: " + vec_name, + exprtk_error_location)); + + return error_node(); + } + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr parse_define_string_statement(const std::string& str_name, expression_node_ptr initialisation_expression) + { + stringvar_node_t* str_node = reinterpret_cast<stringvar_node_t*>(0); + + scope_element& se = sem_.get_element(str_name); + + if (se.name == str_name) + { + if (se.active) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR175 - Illegal redefinition of local variable: '" + str_name + "'", + exprtk_error_location)); + + free_node(node_allocator_, initialisation_expression); + + return error_node(); + } + else if (scope_element::e_string == se.type) + { + str_node = se.str_node; + se.active = true; + se.depth = state_.scope_depth; + se.ref_count++; + } + } + + if (0 == str_node) + { + scope_element nse; + nse.name = str_name; + nse.active = true; + nse.ref_count = 1; + nse.type = scope_element::e_string; + nse.depth = state_.scope_depth; + nse.data = new std::string; + nse.str_node = new stringvar_node_t(*reinterpret_cast<std::string*>(nse.data)); + + if (!sem_.add_element(nse)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR176 - Failed to add new local string variable '" + str_name + "' to SEM", + exprtk_error_location)); + + free_node(node_allocator_, initialisation_expression); + + sem_.free_element(nse); + + return error_node(); + } + + assert(sem_.total_local_symb_size_bytes() <= settings().max_total_local_symbol_size_bytes()); + + str_node = nse.str_node; + + exprtk_debug(("parse_define_string_statement() - INFO - Added new local string variable: %s\n", nse.name.c_str())); + } + + lodge_symbol(str_name, e_st_local_string); + + state_.activate_side_effect("parse_define_string_statement()"); + + expression_node_ptr branch[2] = {0}; + + branch[0] = str_node; + branch[1] = initialisation_expression; + + return expression_generator_(details::e_assign,branch); + } + #else + inline expression_node_ptr parse_define_string_statement(const std::string&, expression_node_ptr) + { + return error_node(); + } + #endif + + inline bool local_variable_is_shadowed(const std::string& symbol) + { + const scope_element& se = sem_.get_element(symbol); + return (se.name == symbol) && se.active; + } + + inline expression_node_ptr parse_define_var_statement() + { + if (settings_.vardef_disabled()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR177 - Illegal variable definition", + exprtk_error_location)); + + return error_node(); + } + else if (!details::imatch(current_token().value,"var")) + { + return error_node(); + } + else + next_token(); + + const std::string var_name = current_token().value; + + expression_node_ptr initialisation_expression = error_node(); + + if (!token_is(token_t::e_symbol)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR178 - Expected a symbol for variable definition", + exprtk_error_location)); + + return error_node(); + } + else if (details::is_reserved_symbol(var_name)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR179 - Illegal redefinition of reserved keyword: '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (symtab_store_.symbol_exists(var_name)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR180 - Illegal redefinition of variable '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (local_variable_is_shadowed(var_name)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR181 - Illegal redefinition of local variable: '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (token_is(token_t::e_lsqrbracket,prsrhlpr_t::e_hold)) + { + return parse_define_vector_statement(var_name); + } + else if (token_is(token_t::e_lcrlbracket,prsrhlpr_t::e_hold)) + { + return parse_uninitialised_var_statement(var_name); + } + else if (token_is(token_t::e_assign)) + { + if (0 == (initialisation_expression = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR182 - Failed to parse initialisation expression for variable '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + } + + if ( + !token_is(token_t::e_rbracket , prsrhlpr_t::e_hold) && + !token_is(token_t::e_rcrlbracket, prsrhlpr_t::e_hold) && + !token_is(token_t::e_rsqrbracket, prsrhlpr_t::e_hold) + ) + { + if (!token_is(token_t::e_eof,prsrhlpr_t::e_hold)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR183 - Expected ';' after variable '" + var_name + "' definition", + exprtk_error_location)); + + free_node(node_allocator_, initialisation_expression); + + return error_node(); + } + } + + if ( + (0 != initialisation_expression) && + details::is_generally_string_node(initialisation_expression) + ) + { + return parse_define_string_statement(var_name,initialisation_expression); + } + + expression_node_ptr var_node = reinterpret_cast<expression_node_ptr>(0); + + scope_element& se = sem_.get_element(var_name); + + if (se.name == var_name) + { + if (se.active) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR184 - Illegal redefinition of local variable: '" + var_name + "'", + exprtk_error_location)); + + free_node(node_allocator_, initialisation_expression); + + return error_node(); + } + else if (scope_element::e_variable == se.type) + { + var_node = se.var_node; + se.active = true; + se.depth = state_.scope_depth; + se.ref_count++; + } + } + + if (0 == var_node) + { + const std::size_t predicted_total_lclsymb_size = sizeof(T) + sem_.total_local_symb_size_bytes(); + + if (predicted_total_lclsymb_size > settings().max_total_local_symbol_size_bytes()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR185 - Adding variable '" + var_name + "' " + "will exceed max total local symbol size of: " + details::to_str(settings().max_total_local_symbol_size_bytes()) + " bytes, " + "current total size: " + details::to_str(sem_.total_local_symb_size_bytes()) + " bytes", + exprtk_error_location)); + + free_node(node_allocator_, initialisation_expression); + + return error_node(); + } + + scope_element nse; + nse.name = var_name; + nse.active = true; + nse.ref_count = 1; + nse.type = scope_element::e_variable; + nse.depth = state_.scope_depth; + nse.data = new T(T(0)); + nse.var_node = node_allocator_.allocate<variable_node_t>(*reinterpret_cast<T*>(nse.data)); + + if (!sem_.add_element(nse)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR186 - Failed to add new local variable '" + var_name + "' to SEM", + exprtk_error_location)); + + free_node(node_allocator_, initialisation_expression); + + sem_.free_element(nse); + + return error_node(); + } + + assert(sem_.total_local_symb_size_bytes() <= settings().max_total_local_symbol_size_bytes()); + + var_node = nse.var_node; + + exprtk_debug(("parse_define_var_statement() - INFO - Added new local variable: %s\n", nse.name.c_str())); + } + + state_.activate_side_effect("parse_define_var_statement()"); + + lodge_symbol(var_name, e_st_local_variable); + + expression_node_ptr branch[2] = {0}; + + branch[0] = var_node; + branch[1] = initialisation_expression ? initialisation_expression : expression_generator_(T(0)); + + return expression_generator_(details::e_assign,branch); + } + + inline expression_node_ptr parse_define_constvar_statement() + { + if (settings_.vardef_disabled()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR187 - Illegal const variable definition", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is("const")) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR188 - Expected 'const' keyword for const-variable definition", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is("var")) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR189 - Expected 'var' keyword for const-variable definition", + exprtk_error_location)); + + return error_node(); + } + + const std::string var_name = current_token().value; + + expression_node_ptr initialisation_expression = error_node(); + + if (!token_is(token_t::e_symbol)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR190 - Expected a symbol for const-variable definition", + exprtk_error_location)); + + return error_node(); + } + else if (details::is_reserved_symbol(var_name)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR191 - Illegal redefinition of reserved keyword: '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (symtab_store_.symbol_exists(var_name)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR192 - Illegal redefinition of variable '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (local_variable_is_shadowed(var_name)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR193 - Illegal redefinition of local variable: '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_assign)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR194 - Expected assignment operator after const-variable: '" + var_name + "' definition", + exprtk_error_location)); + + return error_node(); + } + else if (0 == (initialisation_expression = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR195 - Failed to parse initialisation expression for const-variable: '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + + if (!details::is_literal_node(initialisation_expression)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR196 - initialisation expression for const-variable: '" + var_name + "' must be a constant/literal", + exprtk_error_location)); + + free_node(node_allocator_, initialisation_expression); + + return error_node(); + } + + assert(initialisation_expression); + + const T init_value = initialisation_expression->value(); + + free_node(node_allocator_, initialisation_expression); + + expression_node_ptr var_node = reinterpret_cast<expression_node_ptr>(0); + + scope_element& se = sem_.get_element(var_name); + + if (se.name == var_name) + { + if (se.active) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR197 - Illegal redefinition of local variable: '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (scope_element::e_literal == se.type) + { + var_node = se.var_node; + se.active = true; + se.depth = state_.scope_depth; + se.ref_count++; + } + } + + if (0 == var_node) + { + const std::size_t predicted_total_lclsymb_size = sizeof(T) + sem_.total_local_symb_size_bytes(); + + if (predicted_total_lclsymb_size > settings().max_total_local_symbol_size_bytes()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR198 - Adding variable '" + var_name + "' " + "will exceed max total local symbol size of: " + details::to_str(settings().max_total_local_symbol_size_bytes()) + " bytes, " + "current total size: " + details::to_str(sem_.total_local_symb_size_bytes()) + " bytes", + exprtk_error_location)); + + return error_node(); + } + + scope_element nse; + nse.name = var_name; + nse.active = true; + nse.ref_count = 1; + nse.type = scope_element::e_literal; + nse.depth = state_.scope_depth; + nse.data = 0; + nse.var_node = node_allocator_.allocate<literal_node_t>(init_value); + + if (!sem_.add_element(nse)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR199 - Failed to add new local const-variable '" + var_name + "' to SEM", + exprtk_error_location)); + + sem_.free_element(nse); + + return error_node(); + } + + assert(sem_.total_local_symb_size_bytes() <= settings().max_total_local_symbol_size_bytes()); + + var_node = nse.var_node; + + exprtk_debug(("parse_define_constvar_statement() - INFO - Added new local const-variable: %s\n", nse.name.c_str())); + } + + state_.activate_side_effect("parse_define_constvar_statement()"); + + lodge_symbol(var_name, e_st_local_variable); + + return expression_generator_(var_node->value()); + } + + inline expression_node_ptr parse_uninitialised_var_statement(const std::string& var_name) + { + if ( + !token_is(token_t::e_lcrlbracket) || + !token_is(token_t::e_rcrlbracket) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR200 - Expected a '{}' for uninitialised var definition", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_eof,prsrhlpr_t::e_hold)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR201 - Expected ';' after uninitialised variable definition", + exprtk_error_location)); + + return error_node(); + } + + expression_node_ptr var_node = reinterpret_cast<expression_node_ptr>(0); + + scope_element& se = sem_.get_element(var_name); + + if (se.name == var_name) + { + if (se.active) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR202 - Illegal redefinition of local variable: '" + var_name + "'", + exprtk_error_location)); + + return error_node(); + } + else if (scope_element::e_variable == se.type) + { + var_node = se.var_node; + se.active = true; + se.ref_count++; + } + } + + if (0 == var_node) + { + const std::size_t predicted_total_lclsymb_size = sizeof(T) + sem_.total_local_symb_size_bytes(); + + if (predicted_total_lclsymb_size > settings().max_total_local_symbol_size_bytes()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR203 - Adding variable '" + var_name + "' " + "will exceed max total local symbol size of: " + details::to_str(settings().max_total_local_symbol_size_bytes()) + " bytes, " + "current total size: " + details::to_str(sem_.total_local_symb_size_bytes()) + " bytes", + exprtk_error_location)); + + return error_node(); + } + + scope_element nse; + nse.name = var_name; + nse.active = true; + nse.ref_count = 1; + nse.type = scope_element::e_variable; + nse.depth = state_.scope_depth; + nse.ip_index = sem_.next_ip_index(); + nse.data = new T(T(0)); + nse.var_node = node_allocator_.allocate<variable_node_t>(*reinterpret_cast<T*>(nse.data)); + + if (!sem_.add_element(nse)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR204 - Failed to add new local variable '" + var_name + "' to SEM", + exprtk_error_location)); + + sem_.free_element(nse); + + return error_node(); + } + + assert(sem_.total_local_symb_size_bytes() <= settings().max_total_local_symbol_size_bytes()); + + exprtk_debug(("parse_uninitialised_var_statement() - INFO - Added new local variable: %s\n", + nse.name.c_str())); + } + + lodge_symbol(var_name, e_st_local_variable); + + state_.activate_side_effect("parse_uninitialised_var_statement()"); + + return expression_generator_(T(0)); + } + + inline expression_node_ptr parse_swap_statement() + { + if (!details::imatch(current_token().value,"swap")) + { + return error_node(); + } + else + next_token(); + + if (!token_is(token_t::e_lbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR205 - Expected '(' at start of swap statement", + exprtk_error_location)); + + return error_node(); + } + + expression_node_ptr variable0 = error_node(); + expression_node_ptr variable1 = error_node(); + + bool variable0_generated = false; + bool variable1_generated = false; + + const std::string var0_name = current_token().value; + + if (!token_is(token_t::e_symbol,prsrhlpr_t::e_hold)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR206 - Expected a symbol for variable or vector element definition", + exprtk_error_location)); + + return error_node(); + } + else if (peek_token_is(token_t::e_lsqrbracket)) + { + if (0 == (variable0 = parse_vector())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR207 - First parameter to swap is an invalid vector element: '" + var0_name + "'", + exprtk_error_location)); + + return error_node(); + } + + variable0_generated = true; + } + else + { + if (symtab_store_.is_variable(var0_name)) + { + variable0 = symtab_store_.get_variable(var0_name); + } + + const scope_element& se = sem_.get_element(var0_name); + + if ( + (se.active) && + (se.name == var0_name) && + (scope_element::e_variable == se.type) + ) + { + variable0 = se.var_node; + } + + lodge_symbol(var0_name, e_st_variable); + + if (0 == variable0) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR208 - First parameter to swap is an invalid variable: '" + var0_name + "'", + exprtk_error_location)); + + return error_node(); + } + else + next_token(); + } + + if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR209 - Expected ',' between parameters to swap", + exprtk_error_location)); + + if (variable0_generated) + { + free_node(node_allocator_, variable0); + } + + return error_node(); + } + + const std::string var1_name = current_token().value; + + if (!token_is(token_t::e_symbol,prsrhlpr_t::e_hold)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR210 - Expected a symbol for variable or vector element definition", + exprtk_error_location)); + + if (variable0_generated) + { + free_node(node_allocator_, variable0); + } + + return error_node(); + } + else if (peek_token_is(token_t::e_lsqrbracket)) + { + if (0 == (variable1 = parse_vector())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR211 - Second parameter to swap is an invalid vector element: '" + var1_name + "'", + exprtk_error_location)); + + if (variable0_generated) + { + free_node(node_allocator_, variable0); + } + + return error_node(); + } + + variable1_generated = true; + } + else + { + if (symtab_store_.is_variable(var1_name)) + { + variable1 = symtab_store_.get_variable(var1_name); + } + + const scope_element& se = sem_.get_element(var1_name); + + if ( + (se.active) && + (se.name == var1_name) && + (scope_element::e_variable == se.type) + ) + { + variable1 = se.var_node; + } + + lodge_symbol(var1_name, e_st_variable); + + if (0 == variable1) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR212 - Second parameter to swap is an invalid variable: '" + var1_name + "'", + exprtk_error_location)); + + if (variable0_generated) + { + free_node(node_allocator_, variable0); + } + + return error_node(); + } + else + next_token(); + } + + if (!token_is(token_t::e_rbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR213 - Expected ')' at end of swap statement", + exprtk_error_location)); + + if (variable0_generated) + { + free_node(node_allocator_, variable0); + } + + if (variable1_generated) + { + free_node(node_allocator_, variable1); + } + + return error_node(); + } + + typedef details::variable_node<T>* variable_node_ptr; + + variable_node_ptr v0 = variable_node_ptr(0); + variable_node_ptr v1 = variable_node_ptr(0); + + expression_node_ptr result = error_node(); + + if ( + (0 != (v0 = dynamic_cast<variable_node_ptr>(variable0))) && + (0 != (v1 = dynamic_cast<variable_node_ptr>(variable1))) + ) + { + result = node_allocator_.allocate<details::swap_node<T> >(v0, v1); + + if (variable0_generated) + { + free_node(node_allocator_, variable0); + } + + if (variable1_generated) + { + free_node(node_allocator_, variable1); + } + } + else + result = node_allocator_.allocate<details::swap_generic_node<T> > + (variable0, variable1); + + state_.activate_side_effect("parse_swap_statement()"); + + return result; + } + + #ifndef exprtk_disable_return_statement + inline expression_node_ptr parse_return_statement() + { + if (state_.parsing_return_stmt) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR214 - Return call within a return call is not allowed", + exprtk_error_location)); + + return error_node(); + } + + scoped_bool_negator sbn(state_.parsing_return_stmt); + + std::vector<expression_node_ptr> arg_list; + + scoped_vec_delete<expression_node_t> svd((*this), arg_list); + + if (!details::imatch(current_token().value,"return")) + { + return error_node(); + } + else + next_token(); + + if (!token_is(token_t::e_lsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR215 - Expected '[' at start of return statement", + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_rsqrbracket)) + { + for ( ; ; ) + { + expression_node_ptr arg = parse_expression(); + + if (0 == arg) + return error_node(); + + arg_list.push_back(arg); + + if (token_is(token_t::e_rsqrbracket)) + break; + else if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR216 - Expected ',' between values during call to return", + exprtk_error_location)); + + return error_node(); + } + } + } + else if (settings_.zero_return_disabled()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR217 - Zero parameter return statement not allowed", + exprtk_error_location)); + + return error_node(); + } + + const lexer::token prev_token = current_token(); + + if (token_is(token_t::e_rsqrbracket)) + { + if (!arg_list.empty()) + { + set_error(make_error( + parser_error::e_syntax, + prev_token, + "ERR218 - Invalid ']' found during return call", + exprtk_error_location)); + + return error_node(); + } + } + + std::string ret_param_type_list; + + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + if (0 == arg_list[i]) + return error_node(); + else if (is_ivector_node(arg_list[i])) + ret_param_type_list += 'V'; + else if (is_generally_string_node(arg_list[i])) + ret_param_type_list += 'S'; + else + ret_param_type_list += 'T'; + } + + dec_.retparam_list_.push_back(ret_param_type_list); + + expression_node_ptr result = expression_generator_.return_call(arg_list); + + svd.delete_ptr = (0 == result); + + state_.return_stmt_present = true; + + state_.activate_side_effect("parse_return_statement()"); + + return result; + } + #else + inline expression_node_ptr parse_return_statement() + { + return error_node(); + } + #endif + + inline expression_node_ptr parse_assert_statement() + { + assert(details::imatch(current_token().value, "assert")); + + if (state_.parsing_assert_stmt) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR219 - Assert statement within an assert statement is not allowed", + exprtk_error_location)); + + return error_node(); + } + + scoped_bool_negator sbn(state_.parsing_assert_stmt); + + next_token(); + + std::vector<expression_node_ptr> assert_arg_list(3, error_node()); + scoped_vec_delete<expression_node_t> svd((*this), assert_arg_list); + + expression_node_ptr& assert_condition = assert_arg_list[0]; + expression_node_ptr& assert_message = assert_arg_list[1]; + expression_node_ptr& assert_id = assert_arg_list[2]; + + if (!token_is(token_t::e_lbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR220 - Expected '(' at start of assert statement", + exprtk_error_location)); + + return error_node(); + } + + const token_t start_token = current_token(); + + // Parse the assert condition + if (0 == (assert_condition = parse_expression())) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR221 - Failed to parse condition for assert statement", + exprtk_error_location)); + + return error_node(); + } + + const token_t end_token = current_token(); + + if (!token_is(token_t::e_rbracket)) + { + if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR222 - Expected ',' between condition and message for assert statement", + exprtk_error_location)); + + return error_node(); + } + // Parse the assert message + else if ( + (0 == (assert_message = parse_expression())) || + !details::is_generally_string_node(assert_message) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR223 - " + + (assert_message ? + std::string("Expected string for assert message") : + std::string("Failed to parse message for assert statement")), + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_rbracket)) + { + if (!token_is(token_t::e_comma)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR224 - Expected ',' between message and ID for assert statement", + exprtk_error_location)); + + return error_node(); + } + // Parse assert ID + else if ( + (0 == (assert_id = parse_expression())) || + !details::is_const_string_node(assert_id) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR225 - " + + (assert_id ? + std::string("Expected literal string for assert ID") : + std::string("Failed to parse string for assert ID")), + exprtk_error_location)); + + return error_node(); + } + else if (!token_is(token_t::e_rbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR226 - Expected ')' at start of assert statement", + exprtk_error_location)); + + return error_node(); + } + } + } + + exprtk::assert_check::assert_context context; + context.condition = lexer().substr(start_token.position, end_token.position); + context.offet = start_token.position; + + if (0 == assert_check_) + { + exprtk_debug(("parse_assert_statement() - assert functionality is disabled. assert condition: %s\n", + context.condition.c_str())); + + return new details::null_node<T>(); + } + + #ifndef exprtk_disable_string_capabilities + if (assert_message && details::is_const_string_node(assert_message)) + { + context.message = dynamic_cast<details::string_base_node<T>*>(assert_message)->str(); + } + + if (assert_id && details::is_const_string_node(assert_id)) + { + context.id = dynamic_cast<details::string_base_node<T>*>(assert_id)->str(); + + if (assert_ids_.end() != assert_ids_.find(context.id)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR227 - Duplicate assert ID: " + context.id, + exprtk_error_location)); + + return error_node(); + } + + assert_ids_.insert(context.id); + free_node(node_allocator_, assert_id); + } + #endif + + expression_node_ptr result_node = + expression_generator_.assert_call( + assert_condition, + assert_message, + context); + + exprtk_debug(("parse_assert_statement() - assert condition: [%s]\n", context.condition.c_str() )); + exprtk_debug(("parse_assert_statement() - assert message: [%s]\n", context.message .c_str() )); + exprtk_debug(("parse_assert_statement() - assert id: [%s]\n", context.id .c_str() )); + exprtk_debug(("parse_assert_statement() - assert offset: [%d]\n", static_cast<int>(context.offet))); + + if (0 == result_node) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR228 - Failed to synthesize assert", + exprtk_error_location)); + + return error_node(); + } + + svd.delete_ptr = false; + return result_node; + } + + inline bool post_variable_process(const std::string& symbol) + { + if ( + peek_token_is(token_t::e_lbracket ) || + peek_token_is(token_t::e_lcrlbracket) || + peek_token_is(token_t::e_lsqrbracket) + ) + { + if (!settings_.commutative_check_enabled()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR229 - Invalid sequence of variable '" + symbol + "' and bracket", + exprtk_error_location)); + + return false; + } + + lexer().insert_front(token_t::e_mul); + } + + return true; + } + + inline bool post_bracket_process(const typename token_t::token_type& token, expression_node_ptr& branch) + { + bool implied_mul = false; + + if (details::is_generally_string_node(branch)) + return true; + + if (details::is_ivector_node(branch)) + return true; + + const lexer::parser_helper::token_advance_mode hold = prsrhlpr_t::e_hold; + + switch (token) + { + case token_t::e_lcrlbracket : implied_mul = token_is(token_t::e_lbracket , hold) || + token_is(token_t::e_lcrlbracket, hold) || + token_is(token_t::e_lsqrbracket, hold) ; + break; + + case token_t::e_lbracket : implied_mul = token_is(token_t::e_lbracket , hold) || + token_is(token_t::e_lcrlbracket, hold) || + token_is(token_t::e_lsqrbracket, hold) ; + break; + + case token_t::e_lsqrbracket : implied_mul = token_is(token_t::e_lbracket , hold) || + token_is(token_t::e_lcrlbracket, hold) || + token_is(token_t::e_lsqrbracket, hold) ; + break; + + default : return true; + } + + if (implied_mul) + { + if (!settings_.commutative_check_enabled()) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR230 - Invalid sequence of brackets", + exprtk_error_location)); + + return false; + } + else if (token_t::e_eof != current_token().type) + { + lexer().insert_front(current_token().type); + lexer().insert_front(token_t::e_mul); + next_token(); + } + } + + return true; + } + + typedef typename interval_container_t<const void*>::interval_t interval_t; + typedef interval_container_t<const void*> immutable_memory_map_t; + typedef std::map<interval_t,token_t> immutable_symtok_map_t; + + inline interval_t make_memory_range(const T& t) + { + const T* begin = reinterpret_cast<const T*>(&t); + const T* end = begin + 1; + return interval_t(begin, end); + } + + inline interval_t make_memory_range(const T* begin, const std::size_t size) + { + return interval_t(begin, begin + size); + } + + inline interval_t make_memory_range(details::char_cptr begin, const std::size_t size) + { + return interval_t(begin, begin + size); + } + + void lodge_immutable_symbol(const lexer::token& token, const interval_t interval) + { + immutable_memory_map_.add_interval(interval); + immutable_symtok_map_[interval] = token; + } + + inline expression_node_ptr parse_symtab_symbol() + { + const std::string symbol = current_token().value; + + // Are we dealing with a variable or a special constant? + typedef typename symtab_store::variable_context var_ctxt_t; + var_ctxt_t var_ctx = symtab_store_.get_variable_context(symbol); + + if (var_ctx.variable) + { + assert(var_ctx.symbol_table); + + expression_node_ptr result_variable = var_ctx.variable; + + if (symtab_store_.is_constant_node(symbol)) + { + result_variable = expression_generator_(var_ctx.variable->value()); + } + else if (symbol_table_t::e_immutable == var_ctx.symbol_table->mutability()) + { + lodge_immutable_symbol(current_token(), make_memory_range(var_ctx.variable->ref())); + result_variable = var_ctx.variable; + } + + if (!post_variable_process(symbol)) + return error_node(); + + lodge_symbol(symbol, e_st_variable); + + next_token(); + + return result_variable; + } + + // Are we dealing with a locally defined variable, vector or string? + if (!sem_.empty()) + { + scope_element& se = sem_.get_active_element(symbol); + + if (se.active && details::imatch(se.name, symbol)) + { + if ( + (scope_element::e_variable == se.type) || + (scope_element::e_literal == se.type) + ) + { + se.active = true; + lodge_symbol(symbol, e_st_local_variable); + + if (!post_variable_process(symbol)) + return error_node(); + + next_token(); + + return (scope_element::e_variable == se.type) ? + se.var_node : + expression_generator_(se.var_node->value()); + } + else if (scope_element::e_vector == se.type) + { + return parse_vector(); + } + #ifndef exprtk_disable_string_capabilities + else if (scope_element::e_string == se.type) + { + return parse_string(); + } + #endif + } + } + + #ifndef exprtk_disable_string_capabilities + // Are we dealing with a string variable? + if (symtab_store_.is_stringvar(symbol)) + { + return parse_string(); + } + #endif + + { + // Are we dealing with a function? + ifunction<T>* function = symtab_store_.get_function(symbol); + + if (function) + { + lodge_symbol(symbol, e_st_function); + + expression_node_ptr func_node = + parse_function_invocation(function,symbol); + + if (func_node) + return func_node; + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR231 - Failed to generate node for function: '" + symbol + "'", + exprtk_error_location)); + + return error_node(); + } + } + } + + { + // Are we dealing with a vararg function? + ivararg_function<T>* vararg_function = symtab_store_.get_vararg_function(symbol); + + if (vararg_function) + { + lodge_symbol(symbol, e_st_function); + + expression_node_ptr vararg_func_node = + parse_vararg_function_call(vararg_function, symbol); + + if (vararg_func_node) + return vararg_func_node; + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR232 - Failed to generate node for vararg function: '" + symbol + "'", + exprtk_error_location)); + + return error_node(); + } + } + } + + { + // Are we dealing with a vararg generic function? + igeneric_function<T>* generic_function = symtab_store_.get_generic_function(symbol); + + if (generic_function) + { + lodge_symbol(symbol, e_st_function); + + expression_node_ptr genericfunc_node = + parse_generic_function_call(generic_function, symbol); + + if (genericfunc_node) + return genericfunc_node; + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR233 - Failed to generate node for generic function: '" + symbol + "'", + exprtk_error_location)); + + return error_node(); + } + } + } + + #ifndef exprtk_disable_string_capabilities + { + // Are we dealing with a vararg string returning function? + igeneric_function<T>* string_function = symtab_store_.get_string_function(symbol); + + if (string_function) + { + lodge_symbol(symbol, e_st_function); + + expression_node_ptr stringfunc_node = + parse_string_function_call(string_function, symbol); + + if (stringfunc_node) + return stringfunc_node; + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR234 - Failed to generate node for string function: '" + symbol + "'", + exprtk_error_location)); + + return error_node(); + } + } + } + + { + // Are we dealing with a vararg overloaded scalar/string returning function? + igeneric_function<T>* overload_function = symtab_store_.get_overload_function(symbol); + + if (overload_function) + { + lodge_symbol(symbol, e_st_function); + + expression_node_ptr overloadfunc_node = + parse_overload_function_call(overload_function, symbol); + + if (overloadfunc_node) + return overloadfunc_node; + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR235 - Failed to generate node for overload function: '" + symbol + "'", + exprtk_error_location)); + + return error_node(); + } + } + } + #endif + + // Are we dealing with a vector? + if (symtab_store_.is_vector(symbol)) + { + lodge_symbol(symbol, e_st_vector); + return parse_vector(); + } + + if (details::is_reserved_symbol(symbol)) + { + if ( + settings_.function_enabled(symbol) || + !details::is_base_function(symbol) + ) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR236 - Invalid use of reserved symbol '" + symbol + "'", + exprtk_error_location)); + + return error_node(); + } + } + + // Should we handle unknown symbols? + if (resolve_unknown_symbol_ && unknown_symbol_resolver_) + { + if (!(settings_.rsrvd_sym_usr_disabled() && details::is_reserved_symbol(symbol))) + { + symbol_table_t& symtab = symtab_store_.get_symbol_table(); + + std::string error_message; + + if (unknown_symbol_resolver::e_usrmode_default == unknown_symbol_resolver_->mode) + { + T default_value = T(0); + + typename unknown_symbol_resolver::usr_symbol_type usr_symbol_type = unknown_symbol_resolver::e_usr_unknown_type; + + if (unknown_symbol_resolver_->process(symbol, usr_symbol_type, default_value, error_message)) + { + bool create_result = false; + + switch (usr_symbol_type) + { + case unknown_symbol_resolver::e_usr_variable_type : + create_result = symtab.create_variable(symbol, default_value); + break; + + case unknown_symbol_resolver::e_usr_constant_type : + create_result = symtab.add_constant(symbol, default_value); + break; + + default : create_result = false; + } + + if (create_result) + { + expression_node_ptr var = symtab_store_.get_variable(symbol); + + if (var) + { + if (symtab_store_.is_constant_node(symbol)) + { + var = expression_generator_(var->value()); + } + + lodge_symbol(symbol, e_st_variable); + + if (!post_variable_process(symbol)) + return error_node(); + + next_token(); + + return var; + } + } + } + + set_error(make_error( + parser_error::e_symtab, + current_token(), + "ERR237 - Failed to create variable: '" + symbol + "'" + + (error_message.empty() ? "" : " - " + error_message), + exprtk_error_location)); + + } + else if (unknown_symbol_resolver::e_usrmode_extended == unknown_symbol_resolver_->mode) + { + if (unknown_symbol_resolver_->process(symbol, symtab, error_message)) + { + expression_node_ptr result = parse_symtab_symbol(); + + if (result) + { + return result; + } + } + + set_error(make_error( + parser_error::e_symtab, + current_token(), + "ERR238 - Failed to resolve symbol: '" + symbol + "'" + + (error_message.empty() ? "" : " - " + error_message), + exprtk_error_location)); + } + + return error_node(); + } + } + + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR239 - Undefined symbol: '" + symbol + "'", + exprtk_error_location)); + + return error_node(); + } + + inline expression_node_ptr check_block_statement_closure(expression_node_ptr expression) + { + if ( + expression && + ( + (current_token().type == token_t::e_symbol) || + (current_token().type == token_t::e_number) + ) + ) + { + free_node(node_allocator_, expression); + + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR240 - Invalid syntax '" + current_token().value + "' possible missing operator or context", + exprtk_error_location)); + + return error_node(); + } + + return expression; + } + + inline expression_node_ptr parse_symbol() + { + static const std::string symbol_if = "if" ; + static const std::string symbol_while = "while" ; + static const std::string symbol_repeat = "repeat" ; + static const std::string symbol_for = "for" ; + static const std::string symbol_switch = "switch" ; + static const std::string symbol_null = "null" ; + static const std::string symbol_break = "break" ; + static const std::string symbol_continue = "continue"; + static const std::string symbol_var = "var" ; + static const std::string symbol_const = "const" ; + static const std::string symbol_swap = "swap" ; + static const std::string symbol_return = "return" ; + static const std::string symbol_not = "not" ; + static const std::string symbol_assert = "assert" ; + + const std::string symbol = current_token().value; + + if (valid_vararg_operation(symbol)) + { + return parse_vararg_function(); + } + else if (details::imatch(symbol, symbol_not)) + { + return parse_not_statement(); + } + else if (valid_base_operation(symbol)) + { + return parse_base_operation(); + } + else if ( + details::imatch(symbol, symbol_if) && + settings_.control_struct_enabled(symbol) + ) + { + return parse_conditional_statement(); + } + else if ( + details::imatch(symbol, symbol_while) && + settings_.control_struct_enabled(symbol) + ) + { + return check_block_statement_closure(parse_while_loop()); + } + else if ( + details::imatch(symbol, symbol_repeat) && + settings_.control_struct_enabled(symbol) + ) + { + return check_block_statement_closure(parse_repeat_until_loop()); + } + else if ( + details::imatch(symbol, symbol_for) && + settings_.control_struct_enabled(symbol) + ) + { + return check_block_statement_closure(parse_for_loop()); + } + else if ( + details::imatch(symbol, symbol_switch) && + settings_.control_struct_enabled(symbol) + ) + { + return check_block_statement_closure(parse_switch_statement()); + } + else if (details::is_valid_sf_symbol(symbol)) + { + return parse_special_function(); + } + else if (details::imatch(symbol, symbol_null)) + { + return parse_null_statement(); + } + #ifndef exprtk_disable_break_continue + else if (details::imatch(symbol, symbol_break)) + { + return parse_break_statement(); + } + else if (details::imatch(symbol, symbol_continue)) + { + return parse_continue_statement(); + } + #endif + else if (details::imatch(symbol, symbol_var)) + { + return parse_define_var_statement(); + } + else if (details::imatch(symbol, symbol_const)) + { + return parse_define_constvar_statement(); + } + else if (details::imatch(symbol, symbol_swap)) + { + return parse_swap_statement(); + } + #ifndef exprtk_disable_return_statement + else if ( + details::imatch(symbol, symbol_return) && + settings_.control_struct_enabled(symbol) + ) + { + return check_block_statement_closure(parse_return_statement()); + } + #endif + else if (details::imatch(symbol, symbol_assert)) + { + return parse_assert_statement(); + } + else if (symtab_store_.valid() || !sem_.empty()) + { + return parse_symtab_symbol(); + } + else + { + set_error(make_error( + parser_error::e_symtab, + current_token(), + "ERR241 - Unknown variable or function encountered. Symbol table(s) " + "is either invalid or does not contain symbol: '" + symbol + "'", + exprtk_error_location)); + + return error_node(); + } + } + + inline expression_node_ptr parse_branch(precedence_level precedence = e_level00) + { + stack_limit_handler slh(*this); + + if (!slh) + { + return error_node(); + } + + expression_node_ptr branch = error_node(); + + if (token_t::e_number == current_token().type) + { + T numeric_value = T(0); + + if (details::string_to_real(current_token().value, numeric_value)) + { + expression_node_ptr literal_exp = expression_generator_(numeric_value); + + if (0 == literal_exp) + { + set_error(make_error( + parser_error::e_numeric, + current_token(), + "ERR242 - Failed generate node for scalar: '" + current_token().value + "'", + exprtk_error_location)); + + return error_node(); + } + + next_token(); + branch = literal_exp; + } + else + { + set_error(make_error( + parser_error::e_numeric, + current_token(), + "ERR243 - Failed to convert '" + current_token().value + "' to a number", + exprtk_error_location)); + + return error_node(); + } + } + else if (token_t::e_symbol == current_token().type) + { + branch = parse_symbol(); + } + #ifndef exprtk_disable_string_capabilities + else if (token_t::e_string == current_token().type) + { + branch = parse_const_string(); + } + #endif + else if (token_t::e_lbracket == current_token().type) + { + next_token(); + + if (0 == (branch = parse_expression())) + { + return error_node(); + } + + token_is(token_t::e_eof); + + if (!token_is(token_t::e_rbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR244 - Expected ')' instead of: '" + current_token().value + "'", + exprtk_error_location)); + + details::free_node(node_allocator_, branch); + + return error_node(); + } + else if (!post_bracket_process(token_t::e_lbracket,branch)) + { + details::free_node(node_allocator_, branch); + + return error_node(); + } + + parse_pending_vector_index_operator(branch); + } + else if (token_t::e_lsqrbracket == current_token().type) + { + next_token(); + + if (0 == (branch = parse_expression())) + return error_node(); + else if (!token_is(token_t::e_rsqrbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR245 - Expected ']' instead of: '" + current_token().value + "'", + exprtk_error_location)); + + details::free_node(node_allocator_, branch); + + return error_node(); + } + else if (!post_bracket_process(token_t::e_lsqrbracket,branch)) + { + details::free_node(node_allocator_, branch); + + return error_node(); + } + } + else if (token_t::e_lcrlbracket == current_token().type) + { + next_token(); + + if (0 == (branch = parse_expression())) + return error_node(); + else if (!token_is(token_t::e_rcrlbracket)) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR246 - Expected '}' instead of: '" + current_token().value + "'", + exprtk_error_location)); + + details::free_node(node_allocator_, branch); + + return error_node(); + } + else if (!post_bracket_process(token_t::e_lcrlbracket,branch)) + { + details::free_node(node_allocator_, branch); + + return error_node(); + } + } + else if (token_t::e_sub == current_token().type) + { + next_token(); + branch = parse_expression(e_level11); + + if ( + branch && + !( + details::is_neg_unary_node (branch) && + simplify_unary_negation_branch(branch) + ) + ) + { + expression_node_ptr result = expression_generator_(details::e_neg,branch); + + if (0 == result) + { + details::free_node(node_allocator_, branch); + + return error_node(); + } + else + branch = result; + } + } + else if (token_t::e_add == current_token().type) + { + next_token(); + branch = parse_expression(e_level13); + } + else if (token_t::e_eof == current_token().type) + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR247 - Premature end of expression[1]", + exprtk_error_location)); + + return error_node(); + } + else + { + set_error(make_error( + parser_error::e_syntax, + current_token(), + "ERR248 - Premature end of expression[2]", + exprtk_error_location)); + + return error_node(); + } + + if ( + branch && + (e_level00 == precedence) && + token_is(token_t::e_ternary,prsrhlpr_t::e_hold) + ) + { + branch = parse_ternary_conditional_statement(branch); + } + + parse_pending_string_rangesize(branch); + + return branch; + } + + template <typename Type> + class expression_generator + { + public: + + typedef details::expression_node<Type>* expression_node_ptr; + typedef expression_node_ptr (*synthesize_functor_t)(expression_generator<T>&, const details::operator_type& operation, expression_node_ptr (&branch)[2]); + typedef std::map<std::string,synthesize_functor_t> synthesize_map_t; + typedef typename exprtk::parser<Type> parser_t; + typedef const Type& vtype; + typedef const Type ctype; + + inline void init_synthesize_map() + { + #ifndef exprtk_disable_enhanced_features + synthesize_map_["(v)o(v)"] = synthesize_vov_expression::process; + synthesize_map_["(c)o(v)"] = synthesize_cov_expression::process; + synthesize_map_["(v)o(c)"] = synthesize_voc_expression::process; + + #define register_synthezier(S) \ + synthesize_map_[S ::node_type::id()] = S ::process; \ + + register_synthezier(synthesize_vovov_expression0) + register_synthezier(synthesize_vovov_expression1) + register_synthezier(synthesize_vovoc_expression0) + register_synthezier(synthesize_vovoc_expression1) + register_synthezier(synthesize_vocov_expression0) + register_synthezier(synthesize_vocov_expression1) + register_synthezier(synthesize_covov_expression0) + register_synthezier(synthesize_covov_expression1) + register_synthezier(synthesize_covoc_expression0) + register_synthezier(synthesize_covoc_expression1) + register_synthezier(synthesize_cocov_expression1) + register_synthezier(synthesize_vococ_expression0) + + register_synthezier(synthesize_vovovov_expression0) + register_synthezier(synthesize_vovovoc_expression0) + register_synthezier(synthesize_vovocov_expression0) + register_synthezier(synthesize_vocovov_expression0) + register_synthezier(synthesize_covovov_expression0) + register_synthezier(synthesize_covocov_expression0) + register_synthezier(synthesize_vocovoc_expression0) + register_synthezier(synthesize_covovoc_expression0) + register_synthezier(synthesize_vococov_expression0) + + register_synthezier(synthesize_vovovov_expression1) + register_synthezier(synthesize_vovovoc_expression1) + register_synthezier(synthesize_vovocov_expression1) + register_synthezier(synthesize_vocovov_expression1) + register_synthezier(synthesize_covovov_expression1) + register_synthezier(synthesize_covocov_expression1) + register_synthezier(synthesize_vocovoc_expression1) + register_synthezier(synthesize_covovoc_expression1) + register_synthezier(synthesize_vococov_expression1) + + register_synthezier(synthesize_vovovov_expression2) + register_synthezier(synthesize_vovovoc_expression2) + register_synthezier(synthesize_vovocov_expression2) + register_synthezier(synthesize_vocovov_expression2) + register_synthezier(synthesize_covovov_expression2) + register_synthezier(synthesize_covocov_expression2) + register_synthezier(synthesize_vocovoc_expression2) + register_synthezier(synthesize_covovoc_expression2) + + register_synthezier(synthesize_vovovov_expression3) + register_synthezier(synthesize_vovovoc_expression3) + register_synthezier(synthesize_vovocov_expression3) + register_synthezier(synthesize_vocovov_expression3) + register_synthezier(synthesize_covovov_expression3) + register_synthezier(synthesize_covocov_expression3) + register_synthezier(synthesize_vocovoc_expression3) + register_synthezier(synthesize_covovoc_expression3) + register_synthezier(synthesize_vococov_expression3) + + register_synthezier(synthesize_vovovov_expression4) + register_synthezier(synthesize_vovovoc_expression4) + register_synthezier(synthesize_vovocov_expression4) + register_synthezier(synthesize_vocovov_expression4) + register_synthezier(synthesize_covovov_expression4) + register_synthezier(synthesize_covocov_expression4) + register_synthezier(synthesize_vocovoc_expression4) + register_synthezier(synthesize_covovoc_expression4) + + #undef register_synthezier + #endif + } + + inline void set_parser(parser_t& p) + { + parser_ = &p; + } + + inline void set_uom(unary_op_map_t& unary_op_map) + { + unary_op_map_ = &unary_op_map; + } + + inline void set_bom(binary_op_map_t& binary_op_map) + { + binary_op_map_ = &binary_op_map; + } + + inline void set_ibom(inv_binary_op_map_t& inv_binary_op_map) + { + inv_binary_op_map_ = &inv_binary_op_map; + } + + inline void set_sf3m(sf3_map_t& sf3_map) + { + sf3_map_ = &sf3_map; + } + + inline void set_sf4m(sf4_map_t& sf4_map) + { + sf4_map_ = &sf4_map; + } + + inline void set_allocator(details::node_allocator& na) + { + node_allocator_ = &na; + } + + inline void set_strength_reduction_state(const bool enabled) + { + strength_reduction_enabled_ = enabled; + } + + inline bool strength_reduction_enabled() const + { + return strength_reduction_enabled_; + } + + inline bool valid_operator(const details::operator_type& operation, binary_functor_t& bop) + { + typename binary_op_map_t::iterator bop_itr = binary_op_map_->find(operation); + + if (binary_op_map_->end() == bop_itr) + return false; + + bop = bop_itr->second; + + return true; + } + + inline bool valid_operator(const details::operator_type& operation, unary_functor_t& uop) + { + typename unary_op_map_t::iterator uop_itr = unary_op_map_->find(operation); + + if ((*unary_op_map_).end() == uop_itr) + return false; + + uop = uop_itr->second; + + return true; + } + + inline details::operator_type get_operator(const binary_functor_t& bop) const + { + return (*inv_binary_op_map_).find(bop)->second; + } + + inline expression_node_ptr operator() (const Type& v) const + { + return node_allocator_->allocate<literal_node_t>(v); + } + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr operator() (const std::string& s) const + { + return node_allocator_->allocate<string_literal_node_t>(s); + } + + inline expression_node_ptr operator() (std::string& s, range_t& rp) const + { + return node_allocator_->allocate_rr<string_range_node_t>(s,rp); + } + + inline expression_node_ptr operator() (const std::string& s, range_t& rp) const + { + return node_allocator_->allocate_tt<const_string_range_node_t>(s,rp); + } + + inline expression_node_ptr operator() (expression_node_ptr branch, range_t& rp) const + { + if (is_generally_string_node(branch)) + return node_allocator_->allocate_tt<generic_string_range_node_t>(branch,rp); + else + return error_node(); + } + #endif + + inline bool unary_optimisable(const details::operator_type& operation) const + { + return (details::e_abs == operation) || (details::e_acos == operation) || + (details::e_acosh == operation) || (details::e_asin == operation) || + (details::e_asinh == operation) || (details::e_atan == operation) || + (details::e_atanh == operation) || (details::e_ceil == operation) || + (details::e_cos == operation) || (details::e_cosh == operation) || + (details::e_exp == operation) || (details::e_expm1 == operation) || + (details::e_floor == operation) || (details::e_log == operation) || + (details::e_log10 == operation) || (details::e_log2 == operation) || + (details::e_log1p == operation) || (details::e_neg == operation) || + (details::e_pos == operation) || (details::e_round == operation) || + (details::e_sin == operation) || (details::e_sinc == operation) || + (details::e_sinh == operation) || (details::e_sqrt == operation) || + (details::e_tan == operation) || (details::e_tanh == operation) || + (details::e_cot == operation) || (details::e_sec == operation) || + (details::e_csc == operation) || (details::e_r2d == operation) || + (details::e_d2r == operation) || (details::e_d2g == operation) || + (details::e_g2d == operation) || (details::e_notl == operation) || + (details::e_sgn == operation) || (details::e_erf == operation) || + (details::e_erfc == operation) || (details::e_ncdf == operation) || + (details::e_frac == operation) || (details::e_trunc == operation) ; + } + + inline bool sf3_optimisable(const std::string& sf3id, trinary_functor_t& tfunc) const + { + typename sf3_map_t::const_iterator itr = sf3_map_->find(sf3id); + + if (sf3_map_->end() == itr) + return false; + else + tfunc = itr->second.first; + + return true; + } + + inline bool sf4_optimisable(const std::string& sf4id, quaternary_functor_t& qfunc) const + { + typename sf4_map_t::const_iterator itr = sf4_map_->find(sf4id); + + if (sf4_map_->end() == itr) + return false; + else + qfunc = itr->second.first; + + return true; + } + + inline bool sf3_optimisable(const std::string& sf3id, details::operator_type& operation) const + { + typename sf3_map_t::const_iterator itr = sf3_map_->find(sf3id); + + if (sf3_map_->end() == itr) + return false; + else + operation = itr->second.second; + + return true; + } + + inline bool sf4_optimisable(const std::string& sf4id, details::operator_type& operation) const + { + typename sf4_map_t::const_iterator itr = sf4_map_->find(sf4id); + + if (sf4_map_->end() == itr) + return false; + else + operation = itr->second.second; + + return true; + } + + inline expression_node_ptr operator() (const details::operator_type& operation, expression_node_ptr (&branch)[1]) + { + if (0 == branch[0]) + { + return error_node(); + } + else if (details::is_null_node(branch[0])) + { + return branch[0]; + } + else if (details::is_break_node(branch[0])) + { + return error_node(); + } + else if (details::is_continue_node(branch[0])) + { + return error_node(); + } + else if (details::is_constant_node(branch[0])) + { + return synthesize_expression<unary_node_t,1>(operation,branch); + } + else if (unary_optimisable(operation) && details::is_variable_node(branch[0])) + { + return synthesize_uv_expression(operation,branch); + } + else if (unary_optimisable(operation) && details::is_ivector_node(branch[0])) + { + return synthesize_uvec_expression(operation,branch); + } + else + return synthesize_unary_expression(operation,branch); + } + + inline bool is_assignment_operation(const details::operator_type& operation) const + { + return ( + (details::e_addass == operation) || + (details::e_subass == operation) || + (details::e_mulass == operation) || + (details::e_divass == operation) || + (details::e_modass == operation) + ) && + parser_->settings_.assignment_enabled(operation); + } + + #ifndef exprtk_disable_string_capabilities + inline bool valid_string_operation(const details::operator_type& operation) const + { + return (details::e_add == operation) || + (details::e_lt == operation) || + (details::e_lte == operation) || + (details::e_gt == operation) || + (details::e_gte == operation) || + (details::e_eq == operation) || + (details::e_ne == operation) || + (details::e_in == operation) || + (details::e_like == operation) || + (details::e_ilike == operation) || + (details::e_assign == operation) || + (details::e_addass == operation) || + (details::e_swap == operation) ; + } + #else + inline bool valid_string_operation(const details::operator_type&) const + { + return false; + } + #endif + + inline std::string to_str(const details::operator_type& operation) const + { + switch (operation) + { + case details::e_add : return "+" ; + case details::e_sub : return "-" ; + case details::e_mul : return "*" ; + case details::e_div : return "/" ; + case details::e_mod : return "%" ; + case details::e_pow : return "^" ; + case details::e_lt : return "<" ; + case details::e_lte : return "<=" ; + case details::e_gt : return ">" ; + case details::e_gte : return ">=" ; + case details::e_eq : return "==" ; + case details::e_ne : return "!=" ; + case details::e_and : return "and" ; + case details::e_nand : return "nand" ; + case details::e_or : return "or" ; + case details::e_nor : return "nor" ; + case details::e_xor : return "xor" ; + case details::e_xnor : return "xnor" ; + default : return "UNKNOWN"; + } + } + + inline bool operation_optimisable(const details::operator_type& operation) const + { + return (details::e_add == operation) || + (details::e_sub == operation) || + (details::e_mul == operation) || + (details::e_div == operation) || + (details::e_mod == operation) || + (details::e_pow == operation) || + (details::e_lt == operation) || + (details::e_lte == operation) || + (details::e_gt == operation) || + (details::e_gte == operation) || + (details::e_eq == operation) || + (details::e_ne == operation) || + (details::e_and == operation) || + (details::e_nand == operation) || + (details::e_or == operation) || + (details::e_nor == operation) || + (details::e_xor == operation) || + (details::e_xnor == operation) ; + } + + inline std::string branch_to_id(expression_node_ptr branch) const + { + static const std::string null_str ("(null)" ); + static const std::string const_str ("(c)" ); + static const std::string var_str ("(v)" ); + static const std::string vov_str ("(vov)" ); + static const std::string cov_str ("(cov)" ); + static const std::string voc_str ("(voc)" ); + static const std::string str_str ("(s)" ); + static const std::string strrng_str ("(rngs)" ); + static const std::string cs_str ("(cs)" ); + static const std::string cstrrng_str("(crngs)"); + + if (details::is_null_node(branch)) + return null_str; + else if (details::is_constant_node(branch)) + return const_str; + else if (details::is_variable_node(branch)) + return var_str; + else if (details::is_vov_node(branch)) + return vov_str; + else if (details::is_cov_node(branch)) + return cov_str; + else if (details::is_voc_node(branch)) + return voc_str; + else if (details::is_string_node(branch)) + return str_str; + else if (details::is_const_string_node(branch)) + return cs_str; + else if (details::is_string_range_node(branch)) + return strrng_str; + else if (details::is_const_string_range_node(branch)) + return cstrrng_str; + else if (details::is_t0ot1ot2_node(branch)) + return "(" + dynamic_cast<details::T0oT1oT2_base_node<T>*>(branch)->type_id() + ")"; + else if (details::is_t0ot1ot2ot3_node(branch)) + return "(" + dynamic_cast<details::T0oT1oT2oT3_base_node<T>*>(branch)->type_id() + ")"; + else + return "ERROR"; + } + + inline std::string branch_to_id(expression_node_ptr (&branch)[2]) const + { + return branch_to_id(branch[0]) + std::string("o") + branch_to_id(branch[1]); + } + + inline bool cov_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!operation_optimisable(operation)) + return false; + else + return details::is_constant_node(branch[0]) && + details::is_variable_node(branch[1]) ; + } + + inline bool voc_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!operation_optimisable(operation)) + return false; + else + return details::is_variable_node(branch[0]) && + details::is_constant_node(branch[1]) ; + } + + inline bool vov_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!operation_optimisable(operation)) + return false; + else + return details::is_variable_node(branch[0]) && + details::is_variable_node(branch[1]) ; + } + + inline bool cob_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!operation_optimisable(operation)) + return false; + else + return details::is_constant_node(branch[0]) && + !details::is_constant_node(branch[1]) ; + } + + inline bool boc_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!operation_optimisable(operation)) + return false; + else + return !details::is_constant_node(branch[0]) && + details::is_constant_node(branch[1]) ; + } + + inline bool cocob_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if ( + (details::e_add == operation) || + (details::e_sub == operation) || + (details::e_mul == operation) || + (details::e_div == operation) + ) + { + return (details::is_constant_node(branch[0]) && details::is_cob_node(branch[1])) || + (details::is_constant_node(branch[1]) && details::is_cob_node(branch[0])) ; + } + else + return false; + } + + inline bool coboc_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if ( + (details::e_add == operation) || + (details::e_sub == operation) || + (details::e_mul == operation) || + (details::e_div == operation) + ) + { + return (details::is_constant_node(branch[0]) && details::is_boc_node(branch[1])) || + (details::is_constant_node(branch[1]) && details::is_boc_node(branch[0])) ; + } + else + return false; + } + + inline bool uvouv_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!operation_optimisable(operation)) + return false; + else + return details::is_uv_node(branch[0]) && + details::is_uv_node(branch[1]) ; + } + + inline bool vob_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!operation_optimisable(operation)) + return false; + else + return details::is_variable_node(branch[0]) && + !details::is_variable_node(branch[1]) ; + } + + inline bool bov_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!operation_optimisable(operation)) + return false; + else + return !details::is_variable_node(branch[0]) && + details::is_variable_node(branch[1]) ; + } + + inline bool binext_optimisable(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!operation_optimisable(operation)) + return false; + else + return !details::is_constant_node(branch[0]) || + !details::is_constant_node(branch[1]) ; + } + + inline bool is_invalid_assignment_op(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (is_assignment_operation(operation)) + { + const bool b1_is_genstring = details::is_generally_string_node(branch[1]); + + if (details::is_string_node(branch[0])) + return !b1_is_genstring; + else if (details::is_literal_node(branch[0])) + return true; + else + return ( + !details::is_variable_node (branch[0]) && + !details::is_vector_elem_node (branch[0]) && + !details::is_vector_celem_node (branch[0]) && + !details::is_vector_elem_rtc_node (branch[0]) && + !details::is_vector_celem_rtc_node (branch[0]) && + !details::is_rebasevector_elem_node (branch[0]) && + !details::is_rebasevector_celem_node (branch[0]) && + !details::is_rebasevector_elem_rtc_node (branch[0]) && + !details::is_rebasevector_celem_rtc_node(branch[0]) && + !details::is_vector_node (branch[0]) + ) + || b1_is_genstring; + } + else + return false; + } + + inline bool is_constpow_operation(const details::operator_type& operation, expression_node_ptr(&branch)[2]) const + { + if ( + !details::is_constant_node(branch[1]) || + details::is_constant_node(branch[0]) || + details::is_variable_node(branch[0]) || + details::is_vector_node (branch[0]) || + details::is_generally_string_node(branch[0]) + ) + return false; + + const Type c = static_cast<details::literal_node<Type>*>(branch[1])->value(); + + return cardinal_pow_optimisable(operation, c); + } + + inline bool is_invalid_break_continue_op(expression_node_ptr (&branch)[2]) const + { + return ( + details::is_break_node (branch[0]) || + details::is_break_node (branch[1]) || + details::is_continue_node(branch[0]) || + details::is_continue_node(branch[1]) + ); + } + + inline bool is_invalid_string_op(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + const bool b0_string = is_generally_string_node(branch[0]); + const bool b1_string = is_generally_string_node(branch[1]); + + bool result = false; + + if (b0_string != b1_string) + result = true; + else if (!valid_string_operation(operation) && b0_string && b1_string) + result = true; + + if (result) + { + parser_->set_synthesis_error("Invalid string operation"); + } + + return result; + } + + inline bool is_invalid_string_op(const details::operator_type& operation, expression_node_ptr (&branch)[3]) const + { + const bool b0_string = is_generally_string_node(branch[0]); + const bool b1_string = is_generally_string_node(branch[1]); + const bool b2_string = is_generally_string_node(branch[2]); + + bool result = false; + + if ((b0_string != b1_string) || (b1_string != b2_string)) + result = true; + else if ((details::e_inrange != operation) && b0_string && b1_string && b2_string) + result = true; + + if (result) + { + parser_->set_synthesis_error("Invalid string operation"); + } + + return result; + } + + inline bool is_string_operation(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + const bool b0_string = is_generally_string_node(branch[0]); + const bool b1_string = is_generally_string_node(branch[1]); + + return (b0_string && b1_string && valid_string_operation(operation)); + } + + inline bool is_string_operation(const details::operator_type& operation, expression_node_ptr (&branch)[3]) const + { + const bool b0_string = is_generally_string_node(branch[0]); + const bool b1_string = is_generally_string_node(branch[1]); + const bool b2_string = is_generally_string_node(branch[2]); + + return (b0_string && b1_string && b2_string && (details::e_inrange == operation)); + } + + #ifndef exprtk_disable_sc_andor + inline bool is_shortcircuit_expression(const details::operator_type& operation) const + { + return ( + (details::e_scand == operation) || + (details::e_scor == operation) + ); + } + #else + inline bool is_shortcircuit_expression(const details::operator_type&) const + { + return false; + } + #endif + + inline bool is_null_present(expression_node_ptr (&branch)[2]) const + { + return ( + details::is_null_node(branch[0]) || + details::is_null_node(branch[1]) + ); + } + + inline bool is_vector_eqineq_logic_operation(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!is_ivector_node(branch[0]) && !is_ivector_node(branch[1])) + return false; + else + return ( + (details::e_lt == operation) || + (details::e_lte == operation) || + (details::e_gt == operation) || + (details::e_gte == operation) || + (details::e_eq == operation) || + (details::e_ne == operation) || + (details::e_equal == operation) || + (details::e_and == operation) || + (details::e_nand == operation) || + (details::e_or == operation) || + (details::e_nor == operation) || + (details::e_xor == operation) || + (details::e_xnor == operation) + ); + } + + inline bool is_vector_arithmetic_operation(const details::operator_type& operation, expression_node_ptr (&branch)[2]) const + { + if (!is_ivector_node(branch[0]) && !is_ivector_node(branch[1])) + return false; + else + return ( + (details::e_add == operation) || + (details::e_sub == operation) || + (details::e_mul == operation) || + (details::e_div == operation) || + (details::e_pow == operation) + ); + } + + inline expression_node_ptr operator() (const details::operator_type& operation, expression_node_ptr (&branch)[2]) + { + if ((0 == branch[0]) || (0 == branch[1])) + { + parser_->set_error(parser_error::make_error( + parser_error::e_syntax, + parser_->current_state().token, + "ERR249 - Invalid branches received for operator '" + details::to_str(operation) + "'", + exprtk_error_location)); + + return error_node(); + } + else if (is_invalid_string_op(operation,branch)) + { + parser_->set_error(parser_error::make_error( + parser_error::e_syntax, + parser_->current_state().token, + "ERR250 - Invalid branch pair for string operator '" + details::to_str(operation) + "'", + exprtk_error_location)); + + return error_node(); + } + else if (is_invalid_assignment_op(operation,branch)) + { + parser_->set_error(parser_error::make_error( + parser_error::e_syntax, + parser_->current_state().token, + "ERR251 - Invalid branch pair for assignment operator '" + details::to_str(operation) + "'", + exprtk_error_location)); + + return error_node(); + } + else if (is_invalid_break_continue_op(branch)) + { + parser_->set_error(parser_error::make_error( + parser_error::e_syntax, + parser_->current_state().token, + "ERR252 - Invalid branch pair for break/continue operator '" + details::to_str(operation) + "'", + exprtk_error_location)); + + return error_node(); + } + else if (details::e_assign == operation) + { + return synthesize_assignment_expression(operation, branch); + } + else if (details::e_swap == operation) + { + return synthesize_swap_expression(branch); + } + else if (is_assignment_operation(operation)) + { + return synthesize_assignment_operation_expression(operation, branch); + } + else if (is_vector_eqineq_logic_operation(operation, branch)) + { + return synthesize_veceqineqlogic_operation_expression(operation, branch); + } + else if (is_vector_arithmetic_operation(operation, branch)) + { + return synthesize_vecarithmetic_operation_expression(operation, branch); + } + else if (is_shortcircuit_expression(operation)) + { + return synthesize_shortcircuit_expression(operation, branch); + } + else if (is_string_operation(operation, branch)) + { + return synthesize_string_expression(operation, branch); + } + else if (is_null_present(branch)) + { + return synthesize_null_expression(operation, branch); + } + #ifndef exprtk_disable_cardinal_pow_optimisation + else if (is_constpow_operation(operation, branch)) + { + return cardinal_pow_optimisation(branch); + } + #endif + + expression_node_ptr result = error_node(); + + #ifndef exprtk_disable_enhanced_features + if (synthesize_expression(operation, branch, result)) + { + return result; + } + else + #endif + + { + /* + Possible reductions: + 1. c o cob -> cob + 2. cob o c -> cob + 3. c o boc -> boc + 4. boc o c -> boc + */ + result = error_node(); + + if (cocob_optimisable(operation, branch)) + { + result = synthesize_cocob_expression::process((*this), operation, branch); + } + else if (coboc_optimisable(operation, branch) && (0 == result)) + { + result = synthesize_coboc_expression::process((*this), operation, branch); + } + + if (result) + return result; + } + + if (uvouv_optimisable(operation, branch)) + { + return synthesize_uvouv_expression(operation, branch); + } + else if (vob_optimisable(operation, branch)) + { + return synthesize_vob_expression::process((*this), operation, branch); + } + else if (bov_optimisable(operation, branch)) + { + return synthesize_bov_expression::process((*this), operation, branch); + } + else if (cob_optimisable(operation, branch)) + { + return synthesize_cob_expression::process((*this), operation, branch); + } + else if (boc_optimisable(operation, branch)) + { + return synthesize_boc_expression::process((*this), operation, branch); + } + #ifndef exprtk_disable_enhanced_features + else if (cov_optimisable(operation, branch)) + { + return synthesize_cov_expression::process((*this), operation, branch); + } + #endif + else if (binext_optimisable(operation, branch)) + { + return synthesize_binary_ext_expression::process((*this), operation, branch); + } + else + return synthesize_expression<binary_node_t,2>(operation, branch); + } + + inline expression_node_ptr operator() (const details::operator_type& operation, expression_node_ptr (&branch)[3]) + { + if ( + (0 == branch[0]) || + (0 == branch[1]) || + (0 == branch[2]) + ) + { + details::free_all_nodes(*node_allocator_,branch); + + parser_->set_error(parser_error::make_error( + parser_error::e_syntax, + parser_->current_state().token, + "ERR253 - Invalid branches operator '" + details::to_str(operation) + "'", + exprtk_error_location)); + + return error_node(); + } + else if (is_invalid_string_op(operation, branch)) + { + parser_->set_error(parser_error::make_error( + parser_error::e_syntax, + parser_->current_state().token, + "ERR254 - Invalid branches for string operator '" + details::to_str(operation) + "'", + exprtk_error_location)); + + return error_node(); + } + else if (is_string_operation(operation, branch)) + { + return synthesize_string_expression(operation, branch); + } + else + return synthesize_expression<trinary_node_t,3>(operation, branch); + } + + inline expression_node_ptr operator() (const details::operator_type& operation, expression_node_ptr (&branch)[4]) + { + return synthesize_expression<quaternary_node_t,4>(operation,branch); + } + + inline expression_node_ptr operator() (const details::operator_type& operation, expression_node_ptr b0) + { + expression_node_ptr branch[1] = { b0 }; + return (*this)(operation,branch); + } + + inline expression_node_ptr operator() (const details::operator_type& operation, expression_node_ptr& b0, expression_node_ptr& b1) + { + expression_node_ptr result = error_node(); + + if ((0 != b0) && (0 != b1)) + { + expression_node_ptr branch[2] = { b0, b1 }; + result = expression_generator<Type>::operator()(operation, branch); + b0 = branch[0]; + b1 = branch[1]; + } + + return result; + } + + inline expression_node_ptr conditional(expression_node_ptr condition, + expression_node_ptr consequent, + expression_node_ptr alternative) const + { + if ((0 == condition) || (0 == consequent)) + { + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, consequent ); + details::free_node(*node_allocator_, alternative); + + const std::string invalid_branches = + ((0 == condition ) ? std::string("condition ") : "") + + ((0 == consequent) ? std::string("consequent") : "") ; + + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + parser_->current_state().token, + "ERR255 - Invalid " + invalid_branches + " for conditional statement", + exprtk_error_location)); + + return error_node(); + } + // Can the condition be immediately evaluated? if so optimise. + else if (details::is_constant_node(condition)) + { + // True branch + if (details::is_true(condition)) + { + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, alternative); + + return consequent; + } + // False branch + else + { + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, consequent); + + if (alternative) + return alternative; + else + return node_allocator_->allocate<details::null_node<T> >(); + } + } + + expression_node_ptr result = error_node(); + std::string node_name = "Unknown!"; + + if ((0 != consequent) && (0 != alternative)) + { + result = node_allocator_->allocate<conditional_node_t>(condition, consequent, alternative); + node_name = "conditional_node_t"; + } + else + { + result = node_allocator_->allocate<cons_conditional_node_t>(condition, consequent); + node_name = "cons_conditional_node_t"; + } + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + token_t(), + "ERR256 - Failed to synthesize node: " + node_name, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr conditional_string(expression_node_ptr condition, + expression_node_ptr consequent, + expression_node_ptr alternative) const + { + if ((0 == condition) || (0 == consequent)) + { + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, consequent ); + details::free_node(*node_allocator_, alternative); + + const std::string invalid_branches = + ((0 == condition ) ? std::string("condition ") : "") + + ((0 == consequent) ? std::string("consequent") : "") ; + + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + parser_->current_state().token, + "ERR257 - Invalid " + invalid_branches + " for string conditional statement", + exprtk_error_location)); + + return error_node(); + } + // Can the condition be immediately evaluated? if so optimise. + else if (details::is_constant_node(condition)) + { + // True branch + if (details::is_true(condition)) + { + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, alternative); + + return consequent; + } + // False branch + else + { + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, consequent); + + if (alternative) + return alternative; + else + return node_allocator_-> + allocate_c<details::string_literal_node<Type> >(""); + } + } + else if ((0 != consequent) && (0 != alternative)) + { + expression_node_ptr result = + node_allocator_->allocate<conditional_string_node_t>(condition, consequent, alternative); + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + token_t(), + "ERR258 - Failed to synthesize node: conditional_string_node_t", + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + } + + return error_node(); + } + #else + inline expression_node_ptr conditional_string(expression_node_ptr, + expression_node_ptr, + expression_node_ptr) const + { + return error_node(); + } + #endif + + inline expression_node_ptr conditional_vector(expression_node_ptr condition, + expression_node_ptr consequent, + expression_node_ptr alternative) const + { + if ((0 == condition) || (0 == consequent)) + { + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, consequent ); + details::free_node(*node_allocator_, alternative); + + const std::string invalid_branches = + ((0 == condition ) ? std::string("condition ") : "") + + ((0 == consequent) ? std::string("consequent") : "") ; + + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + parser_->current_state().token, + "ERR259 - Invalid " + invalid_branches + " for vector conditional statement", + exprtk_error_location)); + + return error_node(); + } + // Can the condition be immediately evaluated? if so optimise. + else if (details::is_constant_node(condition)) + { + // True branch + if (details::is_true(condition)) + { + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, alternative); + + return consequent; + } + // False branch + else + { + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, consequent); + + if (alternative) + return alternative; + else + return node_allocator_->allocate<details::null_node<T> >(); + + } + } + else if ((0 != consequent) && (0 != alternative)) + { + return node_allocator_-> + allocate<conditional_vector_node_t>(condition, consequent, alternative); + } + else + return error_node(); + } + + inline loop_runtime_check_ptr get_loop_runtime_check(const loop_runtime_check::loop_types loop_type) const + { + if ( + parser_->loop_runtime_check_ && + (loop_type == (parser_->loop_runtime_check_->loop_set & loop_type)) + ) + { + return parser_->loop_runtime_check_; + } + + return loop_runtime_check_ptr(0); + } + + inline vector_access_runtime_check_ptr get_vector_access_runtime_check() const + { + return parser_->vector_access_runtime_check_; + } + + inline expression_node_ptr while_loop(expression_node_ptr& condition, + expression_node_ptr& branch, + const bool break_continue_present = false) const + { + if ( + !break_continue_present && + !parser_->state_.return_stmt_present && + details::is_constant_node(condition) + ) + { + expression_node_ptr result = error_node(); + if (details::is_true(condition)) + { + // Infinite loops are not allowed. + + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + parser_->current_state().token, + "ERR260 - Infinite loop condition without 'break' or 'return' not allowed in while-loops", + exprtk_error_location)); + + result = error_node(); + } + else + result = node_allocator_->allocate<details::null_node<Type> >(); + + details::free_node(*node_allocator_, condition); + details::free_node(*node_allocator_, branch ); + + return result; + } + else if (details::is_null_node(condition)) + { + details::free_node(*node_allocator_,condition); + + return branch; + } + + loop_runtime_check_ptr rtc = get_loop_runtime_check(loop_runtime_check::e_while_loop); + + if (!break_continue_present) + { + if (rtc) + return node_allocator_->allocate<while_loop_rtc_node_t> + (condition, branch, rtc); + else + return node_allocator_->allocate<while_loop_node_t> + (condition, branch); + } + #ifndef exprtk_disable_break_continue + else + { + if (rtc) + return node_allocator_->allocate<while_loop_bc_rtc_node_t> + (condition, branch, rtc); + else + return node_allocator_->allocate<while_loop_bc_node_t> + (condition, branch); + } + #else + return error_node(); + #endif + } + + inline expression_node_ptr repeat_until_loop(expression_node_ptr& condition, + expression_node_ptr& branch, + const bool break_continue_present = false) const + { + if (!break_continue_present && details::is_constant_node(condition)) + { + if ( + details::is_true(condition) && + details::is_constant_node(branch) + ) + { + free_node(*node_allocator_,condition); + + return branch; + } + + details::free_node(*node_allocator_, condition); + details::free_node(*node_allocator_, branch ); + + return error_node(); + } + else if (details::is_null_node(condition)) + { + details::free_node(*node_allocator_,condition); + + return branch; + } + + loop_runtime_check_ptr rtc = get_loop_runtime_check(loop_runtime_check::e_repeat_until_loop); + + if (!break_continue_present) + { + if (rtc) + return node_allocator_->allocate<repeat_until_loop_rtc_node_t> + (condition, branch, rtc); + else + return node_allocator_->allocate<repeat_until_loop_node_t> + (condition, branch); + } + #ifndef exprtk_disable_break_continue + else + { + if (rtc) + return node_allocator_->allocate<repeat_until_loop_bc_rtc_node_t> + (condition, branch, rtc); + else + return node_allocator_->allocate<repeat_until_loop_bc_node_t> + (condition, branch); + } + #else + return error_node(); + #endif + } + + inline expression_node_ptr for_loop(expression_node_ptr& initialiser, + expression_node_ptr& condition, + expression_node_ptr& incrementor, + expression_node_ptr& loop_body, + bool break_continue_present = false) const + { + if ( + !break_continue_present && + !parser_->state_.return_stmt_present && + details::is_constant_node(condition) + ) + { + expression_node_ptr result = error_node(); + + if (details::is_true(condition)) + { + // Infinite loops are not allowed. + + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + parser_->current_state().token, + "ERR261 - Infinite loop condition without 'break' or 'return' not allowed in for-loop", + exprtk_error_location)); + + result = error_node(); + } + else + result = node_allocator_->allocate<details::null_node<Type> >(); + + details::free_node(*node_allocator_, initialiser); + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, incrementor); + details::free_node(*node_allocator_, loop_body ); + + return result; + } + else if (details::is_null_node(condition) || (0 == condition)) + { + details::free_node(*node_allocator_, initialiser); + details::free_node(*node_allocator_, condition ); + details::free_node(*node_allocator_, incrementor); + + return loop_body; + } + + loop_runtime_check_ptr rtc = get_loop_runtime_check(loop_runtime_check::e_for_loop); + + if (!break_continue_present) + { + if (rtc) + return node_allocator_->allocate<for_loop_rtc_node_t> + ( + initialiser, + condition, + incrementor, + loop_body, + rtc + ); + else + return node_allocator_->allocate<for_loop_node_t> + ( + initialiser, + condition, + incrementor, + loop_body + ); + } + #ifndef exprtk_disable_break_continue + else + { + if (rtc) + return node_allocator_->allocate<for_loop_bc_rtc_node_t> + ( + initialiser, + condition, + incrementor, + loop_body, + rtc + ); + else + return node_allocator_->allocate<for_loop_bc_node_t> + ( + initialiser, + condition, + incrementor, + loop_body + ); + } + #else + return error_node(); + #endif + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline expression_node_ptr const_optimise_switch(Sequence<expression_node_ptr,Allocator>& arg_list) + { + expression_node_ptr result = error_node(); + + for (std::size_t i = 0; i < (arg_list.size() / 2); ++i) + { + expression_node_ptr condition = arg_list[(2 * i) ]; + expression_node_ptr consequent = arg_list[(2 * i) + 1]; + + if ((0 == result) && details::is_true(condition)) + { + result = consequent; + break; + } + } + + if (0 == result) + { + result = arg_list.back(); + } + + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + expression_node_ptr current_expr = arg_list[i]; + + if (current_expr && (current_expr != result)) + { + free_node(*node_allocator_,current_expr); + } + } + + return result; + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline expression_node_ptr const_optimise_mswitch(Sequence<expression_node_ptr,Allocator>& arg_list) + { + expression_node_ptr result = error_node(); + + for (std::size_t i = 0; i < (arg_list.size() / 2); ++i) + { + expression_node_ptr condition = arg_list[(2 * i) ]; + expression_node_ptr consequent = arg_list[(2 * i) + 1]; + + if (details::is_true(condition)) + { + result = consequent; + } + } + + if (0 == result) + { + const T zero = T(0); + result = node_allocator_->allocate<literal_node_t>(zero); + } + + for (std::size_t i = 0; i < arg_list.size(); ++i) + { + expression_node_ptr& current_expr = arg_list[i]; + + if (current_expr && (current_expr != result)) + { + details::free_node(*node_allocator_,current_expr); + } + } + + return result; + } + + struct switch_nodes + { + typedef std::vector<std::pair<expression_node_ptr,bool> > arg_list_t; + + #define case_stmt(N) \ + if (is_true(arg[(2 * N)].first)) { return arg[(2 * N) + 1].first->value(); } \ + + struct switch_impl_1 + { + static inline T process(const arg_list_t& arg) + { + case_stmt(0) + + assert(arg.size() == ((2 * 1) + 1)); + + return arg.back().first->value(); + } + }; + + struct switch_impl_2 + { + static inline T process(const arg_list_t& arg) + { + case_stmt(0) case_stmt(1) + + assert(arg.size() == ((2 * 2) + 1)); + + return arg.back().first->value(); + } + }; + + struct switch_impl_3 + { + static inline T process(const arg_list_t& arg) + { + case_stmt(0) case_stmt(1) + case_stmt(2) + + assert(arg.size() == ((2 * 3) + 1)); + + return arg.back().first->value(); + } + }; + + struct switch_impl_4 + { + static inline T process(const arg_list_t& arg) + { + case_stmt(0) case_stmt(1) + case_stmt(2) case_stmt(3) + + assert(arg.size() == ((2 * 4) + 1)); + + return arg.back().first->value(); + } + }; + + struct switch_impl_5 + { + static inline T process(const arg_list_t& arg) + { + case_stmt(0) case_stmt(1) + case_stmt(2) case_stmt(3) + case_stmt(4) + + assert(arg.size() == ((2 * 5) + 1)); + + return arg.back().first->value(); + } + }; + + struct switch_impl_6 + { + static inline T process(const arg_list_t& arg) + { + case_stmt(0) case_stmt(1) + case_stmt(2) case_stmt(3) + case_stmt(4) case_stmt(5) + + assert(arg.size() == ((2 * 6) + 1)); + + return arg.back().first->value(); + } + }; + + struct switch_impl_7 + { + static inline T process(const arg_list_t& arg) + { + case_stmt(0) case_stmt(1) + case_stmt(2) case_stmt(3) + case_stmt(4) case_stmt(5) + case_stmt(6) + + assert(arg.size() == ((2 * 7) + 1)); + + return arg.back().first->value(); + } + }; + + #undef case_stmt + }; + + template <typename Allocator, + template <typename, typename> class Sequence> + inline expression_node_ptr switch_statement(Sequence<expression_node_ptr,Allocator>& arg_list, const bool default_statement_present) + { + if (arg_list.empty()) + return error_node(); + else if ( + !all_nodes_valid(arg_list) || + (!default_statement_present && (arg_list.size() < 2)) + ) + { + details::free_all_nodes(*node_allocator_,arg_list); + + return error_node(); + } + else if (is_constant_foldable(arg_list)) + return const_optimise_switch(arg_list); + + switch ((arg_list.size() - 1) / 2) + { + #define case_stmt(N) \ + case N : \ + return node_allocator_-> \ + allocate<details::switch_n_node \ + <Type,typename switch_nodes::switch_impl_##N > >(arg_list); \ + + case_stmt(1) + case_stmt(2) + case_stmt(3) + case_stmt(4) + case_stmt(5) + case_stmt(6) + case_stmt(7) + #undef case_stmt + + default : return node_allocator_->allocate<details::switch_node<Type> >(arg_list); + } + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline expression_node_ptr multi_switch_statement(Sequence<expression_node_ptr,Allocator>& arg_list) + { + if (!all_nodes_valid(arg_list)) + { + details::free_all_nodes(*node_allocator_,arg_list); + + return error_node(); + } + else if (is_constant_foldable(arg_list)) + return const_optimise_mswitch(arg_list); + else + return node_allocator_->allocate<details::multi_switch_node<Type> >(arg_list); + } + + inline expression_node_ptr assert_call(expression_node_ptr& assert_condition, + expression_node_ptr& assert_message, + const assert_check::assert_context& context) + { + typedef details::assert_node<Type> alloc_type; + + expression_node_ptr result = node_allocator_->allocate_rrrr<alloc_type> + (assert_condition, assert_message, parser_->assert_check_, context); + + if (result && result->valid()) + { + parser_->state_.activate_side_effect("assert_call()"); + return result; + } + + details::free_node(*node_allocator_, result ); + details::free_node(*node_allocator_, assert_condition); + details::free_node(*node_allocator_, assert_message ); + + return error_node(); + } + + #define unary_opr_switch_statements \ + case_stmt(details::e_abs , details::abs_op ) \ + case_stmt(details::e_acos , details::acos_op ) \ + case_stmt(details::e_acosh , details::acosh_op) \ + case_stmt(details::e_asin , details::asin_op ) \ + case_stmt(details::e_asinh , details::asinh_op) \ + case_stmt(details::e_atan , details::atan_op ) \ + case_stmt(details::e_atanh , details::atanh_op) \ + case_stmt(details::e_ceil , details::ceil_op ) \ + case_stmt(details::e_cos , details::cos_op ) \ + case_stmt(details::e_cosh , details::cosh_op ) \ + case_stmt(details::e_exp , details::exp_op ) \ + case_stmt(details::e_expm1 , details::expm1_op) \ + case_stmt(details::e_floor , details::floor_op) \ + case_stmt(details::e_log , details::log_op ) \ + case_stmt(details::e_log10 , details::log10_op) \ + case_stmt(details::e_log2 , details::log2_op ) \ + case_stmt(details::e_log1p , details::log1p_op) \ + case_stmt(details::e_neg , details::neg_op ) \ + case_stmt(details::e_pos , details::pos_op ) \ + case_stmt(details::e_round , details::round_op) \ + case_stmt(details::e_sin , details::sin_op ) \ + case_stmt(details::e_sinc , details::sinc_op ) \ + case_stmt(details::e_sinh , details::sinh_op ) \ + case_stmt(details::e_sqrt , details::sqrt_op ) \ + case_stmt(details::e_tan , details::tan_op ) \ + case_stmt(details::e_tanh , details::tanh_op ) \ + case_stmt(details::e_cot , details::cot_op ) \ + case_stmt(details::e_sec , details::sec_op ) \ + case_stmt(details::e_csc , details::csc_op ) \ + case_stmt(details::e_r2d , details::r2d_op ) \ + case_stmt(details::e_d2r , details::d2r_op ) \ + case_stmt(details::e_d2g , details::d2g_op ) \ + case_stmt(details::e_g2d , details::g2d_op ) \ + case_stmt(details::e_notl , details::notl_op ) \ + case_stmt(details::e_sgn , details::sgn_op ) \ + case_stmt(details::e_erf , details::erf_op ) \ + case_stmt(details::e_erfc , details::erfc_op ) \ + case_stmt(details::e_ncdf , details::ncdf_op ) \ + case_stmt(details::e_frac , details::frac_op ) \ + case_stmt(details::e_trunc , details::trunc_op) \ + + inline expression_node_ptr synthesize_uv_expression(const details::operator_type& operation, + expression_node_ptr (&branch)[1]) + { + T& v = static_cast<details::variable_node<T>*>(branch[0])->ref(); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate<typename details::unary_variable_node<Type,op1<Type> > >(v); \ + + unary_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + + inline expression_node_ptr synthesize_uvec_expression(const details::operator_type& operation, + expression_node_ptr (&branch)[1]) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate<typename details::unary_vector_node<Type,op1<Type> > > \ + (operation, branch[0]); \ + + unary_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + + inline expression_node_ptr synthesize_unary_expression(const details::operator_type& operation, + expression_node_ptr (&branch)[1]) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate<typename details::unary_branch_node<Type,op1<Type> > >(branch[0]); \ + + unary_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + + inline expression_node_ptr const_optimise_sf3(const details::operator_type& operation, + expression_node_ptr (&branch)[3]) + { + expression_node_ptr temp_node = error_node(); + + switch (operation) + { + #define case_stmt(op) \ + case details::e_sf##op : temp_node = node_allocator_-> \ + allocate<details::sf3_node<Type,details::sf##op##_op<Type> > > \ + (operation, branch); \ + break; \ + + case_stmt(00) case_stmt(01) case_stmt(02) case_stmt(03) + case_stmt(04) case_stmt(05) case_stmt(06) case_stmt(07) + case_stmt(08) case_stmt(09) case_stmt(10) case_stmt(11) + case_stmt(12) case_stmt(13) case_stmt(14) case_stmt(15) + case_stmt(16) case_stmt(17) case_stmt(18) case_stmt(19) + case_stmt(20) case_stmt(21) case_stmt(22) case_stmt(23) + case_stmt(24) case_stmt(25) case_stmt(26) case_stmt(27) + case_stmt(28) case_stmt(29) case_stmt(30) case_stmt(31) + case_stmt(32) case_stmt(33) case_stmt(34) case_stmt(35) + case_stmt(36) case_stmt(37) case_stmt(38) case_stmt(39) + case_stmt(40) case_stmt(41) case_stmt(42) case_stmt(43) + case_stmt(44) case_stmt(45) case_stmt(46) case_stmt(47) + #undef case_stmt + default : return error_node(); + } + + assert(temp_node); + + const T v = temp_node->value(); + + details::free_node(*node_allocator_,temp_node); + + return node_allocator_->allocate<literal_node_t>(v); + } + + inline expression_node_ptr varnode_optimise_sf3(const details::operator_type& operation, expression_node_ptr (&branch)[3]) + { + typedef details::variable_node<Type>* variable_ptr; + + const Type& v0 = static_cast<variable_ptr>(branch[0])->ref(); + const Type& v1 = static_cast<variable_ptr>(branch[1])->ref(); + const Type& v2 = static_cast<variable_ptr>(branch[2])->ref(); + + switch (operation) + { + #define case_stmt(op) \ + case details::e_sf##op : return node_allocator_-> \ + allocate_rrr<details::sf3_var_node<Type,details::sf##op##_op<Type> > > \ + (v0, v1, v2); \ + + case_stmt(00) case_stmt(01) case_stmt(02) case_stmt(03) + case_stmt(04) case_stmt(05) case_stmt(06) case_stmt(07) + case_stmt(08) case_stmt(09) case_stmt(10) case_stmt(11) + case_stmt(12) case_stmt(13) case_stmt(14) case_stmt(15) + case_stmt(16) case_stmt(17) case_stmt(18) case_stmt(19) + case_stmt(20) case_stmt(21) case_stmt(22) case_stmt(23) + case_stmt(24) case_stmt(25) case_stmt(26) case_stmt(27) + case_stmt(28) case_stmt(29) case_stmt(30) case_stmt(31) + case_stmt(32) case_stmt(33) case_stmt(34) case_stmt(35) + case_stmt(36) case_stmt(37) case_stmt(38) case_stmt(39) + case_stmt(40) case_stmt(41) case_stmt(42) case_stmt(43) + case_stmt(44) case_stmt(45) case_stmt(46) case_stmt(47) + #undef case_stmt + default : return error_node(); + } + } + + inline expression_node_ptr special_function(const details::operator_type& operation, expression_node_ptr (&branch)[3]) + { + if (!all_nodes_valid(branch)) + return error_node(); + else if (is_constant_foldable(branch)) + return const_optimise_sf3(operation,branch); + else if (all_nodes_variables(branch)) + return varnode_optimise_sf3(operation,branch); + else + { + switch (operation) + { + #define case_stmt(op) \ + case details::e_sf##op : return node_allocator_-> \ + allocate<details::sf3_node<Type,details::sf##op##_op<Type> > > \ + (operation, branch); \ + + case_stmt(00) case_stmt(01) case_stmt(02) case_stmt(03) + case_stmt(04) case_stmt(05) case_stmt(06) case_stmt(07) + case_stmt(08) case_stmt(09) case_stmt(10) case_stmt(11) + case_stmt(12) case_stmt(13) case_stmt(14) case_stmt(15) + case_stmt(16) case_stmt(17) case_stmt(18) case_stmt(19) + case_stmt(20) case_stmt(21) case_stmt(22) case_stmt(23) + case_stmt(24) case_stmt(25) case_stmt(26) case_stmt(27) + case_stmt(28) case_stmt(29) case_stmt(30) case_stmt(31) + case_stmt(32) case_stmt(33) case_stmt(34) case_stmt(35) + case_stmt(36) case_stmt(37) case_stmt(38) case_stmt(39) + case_stmt(40) case_stmt(41) case_stmt(42) case_stmt(43) + case_stmt(44) case_stmt(45) case_stmt(46) case_stmt(47) + #undef case_stmt + default : return error_node(); + } + } + } + + inline expression_node_ptr const_optimise_sf4(const details::operator_type& operation, expression_node_ptr (&branch)[4]) + { + expression_node_ptr temp_node = error_node(); + + switch (operation) + { + #define case_stmt(op) \ + case details::e_sf##op : temp_node = node_allocator_-> \ + allocate<details::sf4_node<Type,details::sf##op##_op<Type> > > \ + (operation, branch); \ + break; \ + + case_stmt(48) case_stmt(49) case_stmt(50) case_stmt(51) + case_stmt(52) case_stmt(53) case_stmt(54) case_stmt(55) + case_stmt(56) case_stmt(57) case_stmt(58) case_stmt(59) + case_stmt(60) case_stmt(61) case_stmt(62) case_stmt(63) + case_stmt(64) case_stmt(65) case_stmt(66) case_stmt(67) + case_stmt(68) case_stmt(69) case_stmt(70) case_stmt(71) + case_stmt(72) case_stmt(73) case_stmt(74) case_stmt(75) + case_stmt(76) case_stmt(77) case_stmt(78) case_stmt(79) + case_stmt(80) case_stmt(81) case_stmt(82) case_stmt(83) + case_stmt(84) case_stmt(85) case_stmt(86) case_stmt(87) + case_stmt(88) case_stmt(89) case_stmt(90) case_stmt(91) + case_stmt(92) case_stmt(93) case_stmt(94) case_stmt(95) + case_stmt(96) case_stmt(97) case_stmt(98) case_stmt(99) + #undef case_stmt + default : return error_node(); + } + + assert(temp_node); + + const T v = temp_node->value(); + + details::free_node(*node_allocator_,temp_node); + + return node_allocator_->allocate<literal_node_t>(v); + } + + inline expression_node_ptr varnode_optimise_sf4(const details::operator_type& operation, expression_node_ptr (&branch)[4]) + { + typedef details::variable_node<Type>* variable_ptr; + + const Type& v0 = static_cast<variable_ptr>(branch[0])->ref(); + const Type& v1 = static_cast<variable_ptr>(branch[1])->ref(); + const Type& v2 = static_cast<variable_ptr>(branch[2])->ref(); + const Type& v3 = static_cast<variable_ptr>(branch[3])->ref(); + + switch (operation) + { + #define case_stmt(op) \ + case details::e_sf##op : return node_allocator_-> \ + allocate_rrrr<details::sf4_var_node<Type,details::sf##op##_op<Type> > > \ + (v0, v1, v2, v3); \ + + case_stmt(48) case_stmt(49) case_stmt(50) case_stmt(51) + case_stmt(52) case_stmt(53) case_stmt(54) case_stmt(55) + case_stmt(56) case_stmt(57) case_stmt(58) case_stmt(59) + case_stmt(60) case_stmt(61) case_stmt(62) case_stmt(63) + case_stmt(64) case_stmt(65) case_stmt(66) case_stmt(67) + case_stmt(68) case_stmt(69) case_stmt(70) case_stmt(71) + case_stmt(72) case_stmt(73) case_stmt(74) case_stmt(75) + case_stmt(76) case_stmt(77) case_stmt(78) case_stmt(79) + case_stmt(80) case_stmt(81) case_stmt(82) case_stmt(83) + case_stmt(84) case_stmt(85) case_stmt(86) case_stmt(87) + case_stmt(88) case_stmt(89) case_stmt(90) case_stmt(91) + case_stmt(92) case_stmt(93) case_stmt(94) case_stmt(95) + case_stmt(96) case_stmt(97) case_stmt(98) case_stmt(99) + #undef case_stmt + default : return error_node(); + } + } + + inline expression_node_ptr special_function(const details::operator_type& operation, expression_node_ptr (&branch)[4]) + { + if (!all_nodes_valid(branch)) + return error_node(); + else if (is_constant_foldable(branch)) + return const_optimise_sf4(operation,branch); + else if (all_nodes_variables(branch)) + return varnode_optimise_sf4(operation,branch); + switch (operation) + { + #define case_stmt(op) \ + case details::e_sf##op : return node_allocator_-> \ + allocate<details::sf4_node<Type,details::sf##op##_op<Type> > > \ + (operation, branch); \ + + case_stmt(48) case_stmt(49) case_stmt(50) case_stmt(51) + case_stmt(52) case_stmt(53) case_stmt(54) case_stmt(55) + case_stmt(56) case_stmt(57) case_stmt(58) case_stmt(59) + case_stmt(60) case_stmt(61) case_stmt(62) case_stmt(63) + case_stmt(64) case_stmt(65) case_stmt(66) case_stmt(67) + case_stmt(68) case_stmt(69) case_stmt(70) case_stmt(71) + case_stmt(72) case_stmt(73) case_stmt(74) case_stmt(75) + case_stmt(76) case_stmt(77) case_stmt(78) case_stmt(79) + case_stmt(80) case_stmt(81) case_stmt(82) case_stmt(83) + case_stmt(84) case_stmt(85) case_stmt(86) case_stmt(87) + case_stmt(88) case_stmt(89) case_stmt(90) case_stmt(91) + case_stmt(92) case_stmt(93) case_stmt(94) case_stmt(95) + case_stmt(96) case_stmt(97) case_stmt(98) case_stmt(99) + #undef case_stmt + default : return error_node(); + } + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline expression_node_ptr const_optimise_varargfunc(const details::operator_type& operation, Sequence<expression_node_ptr,Allocator>& arg_list) + { + expression_node_ptr temp_node = error_node(); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : temp_node = node_allocator_-> \ + allocate<details::vararg_node<Type,op1<Type> > > \ + (arg_list); \ + break; \ + + case_stmt(details::e_sum , details::vararg_add_op ) + case_stmt(details::e_prod , details::vararg_mul_op ) + case_stmt(details::e_avg , details::vararg_avg_op ) + case_stmt(details::e_min , details::vararg_min_op ) + case_stmt(details::e_max , details::vararg_max_op ) + case_stmt(details::e_mand , details::vararg_mand_op ) + case_stmt(details::e_mor , details::vararg_mor_op ) + case_stmt(details::e_multi , details::vararg_multi_op) + #undef case_stmt + default : return error_node(); + } + + const T v = temp_node->value(); + + details::free_node(*node_allocator_,temp_node); + + return node_allocator_->allocate<literal_node_t>(v); + } + + inline bool special_one_parameter_vararg(const details::operator_type& operation) const + { + return ( + (details::e_sum == operation) || + (details::e_prod == operation) || + (details::e_avg == operation) || + (details::e_min == operation) || + (details::e_max == operation) + ); + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline expression_node_ptr varnode_optimise_varargfunc(const details::operator_type& operation, + Sequence<expression_node_ptr,Allocator>& arg_list) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate<details::vararg_varnode<Type,op1<Type> > >(arg_list); \ + + case_stmt(details::e_sum , details::vararg_add_op ) + case_stmt(details::e_prod , details::vararg_mul_op ) + case_stmt(details::e_avg , details::vararg_avg_op ) + case_stmt(details::e_min , details::vararg_min_op ) + case_stmt(details::e_max , details::vararg_max_op ) + case_stmt(details::e_mand , details::vararg_mand_op ) + case_stmt(details::e_mor , details::vararg_mor_op ) + case_stmt(details::e_multi , details::vararg_multi_op) + #undef case_stmt + default : return error_node(); + } + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline expression_node_ptr vectorize_func(const details::operator_type& operation, + Sequence<expression_node_ptr,Allocator>& arg_list) + { + if (1 == arg_list.size()) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate<details::vectorize_node<Type,op1<Type> > >(arg_list[0]); \ + + case_stmt(details::e_sum , details::vec_add_op) + case_stmt(details::e_prod , details::vec_mul_op) + case_stmt(details::e_avg , details::vec_avg_op) + case_stmt(details::e_min , details::vec_min_op) + case_stmt(details::e_max , details::vec_max_op) + #undef case_stmt + default : return error_node(); + } + } + else + return error_node(); + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline expression_node_ptr vararg_function(const details::operator_type& operation, + Sequence<expression_node_ptr,Allocator>& arg_list) + { + if (!all_nodes_valid(arg_list)) + { + details::free_all_nodes(*node_allocator_,arg_list); + + return error_node(); + } + else if (is_constant_foldable(arg_list)) + return const_optimise_varargfunc(operation,arg_list); + else if ((1 == arg_list.size()) && details::is_ivector_node(arg_list[0])) + return vectorize_func(operation,arg_list); + else if ((1 == arg_list.size()) && special_one_parameter_vararg(operation)) + return arg_list[0]; + else if (all_nodes_variables(arg_list)) + return varnode_optimise_varargfunc(operation,arg_list); + + #ifndef exprtk_disable_string_capabilities + if (details::e_smulti == operation) + { + expression_node_ptr result = node_allocator_-> + allocate<details::str_vararg_node<Type,details::vararg_multi_op<Type> > >(arg_list); + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR262 - Failed to synthesize node: str_vararg_node<vararg_multi_op>", + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + } + else + #endif + { + expression_node_ptr result = error_node(); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + allocate<details::vararg_node<Type,op1<Type> > >(arg_list); \ + break; \ + + case_stmt(details::e_sum , details::vararg_add_op ) + case_stmt(details::e_prod , details::vararg_mul_op ) + case_stmt(details::e_avg , details::vararg_avg_op ) + case_stmt(details::e_min , details::vararg_min_op ) + case_stmt(details::e_max , details::vararg_max_op ) + case_stmt(details::e_mand , details::vararg_mand_op ) + case_stmt(details::e_mor , details::vararg_mor_op ) + case_stmt(details::e_multi , details::vararg_multi_op) + #undef case_stmt + default : return error_node(); + } + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR263 - Failed to synthesize node: vararg_node", + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + } + + return error_node(); + } + + template <std::size_t N> + inline expression_node_ptr function(ifunction_t* f, expression_node_ptr (&b)[N]) + { + typedef typename details::function_N_node<T,ifunction_t,N> function_N_node_t; + expression_node_ptr result = synthesize_expression<function_N_node_t,N>(f,b); + + if (0 == result) + return error_node(); + else + { + // Can the function call be completely optimised? + if (details::is_constant_node(result)) + return result; + else if (!all_nodes_valid(b)) + { + details::free_node(*node_allocator_,result); + std::fill_n(b, N, reinterpret_cast<expression_node_ptr>(0)); + + return error_node(); + } + else if (N != f->param_count) + { + details::free_node(*node_allocator_,result); + std::fill_n(b, N, reinterpret_cast<expression_node_ptr>(0)); + + return error_node(); + } + + function_N_node_t* func_node_ptr = reinterpret_cast<function_N_node_t*>(result); + + if (!func_node_ptr->init_branches(b)) + { + details::free_node(*node_allocator_,result); + std::fill_n(b, N, reinterpret_cast<expression_node_ptr>(0)); + + return error_node(); + } + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR264 - Failed to synthesize node: function_N_node_t", + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + } + + inline expression_node_ptr function(ifunction_t* f) + { + typedef typename details::function_N_node<Type,ifunction_t,0> function_N_node_t; + return node_allocator_->allocate<function_N_node_t>(f); + } + + inline expression_node_ptr vararg_function_call(ivararg_function_t* vaf, + std::vector<expression_node_ptr>& arg_list) + { + if (!all_nodes_valid(arg_list)) + { + details::free_all_nodes(*node_allocator_,arg_list); + + return error_node(); + } + + typedef details::vararg_function_node<Type,ivararg_function_t> alloc_type; + + expression_node_ptr result = node_allocator_->allocate<alloc_type>(vaf,arg_list); + + if ( + !arg_list.empty() && + !vaf->has_side_effects() && + is_constant_foldable(arg_list) + ) + { + const Type v = result->value(); + details::free_node(*node_allocator_,result); + result = node_allocator_->allocate<literal_node_t>(v); + } + + parser_->state_.activate_side_effect("vararg_function_call()"); + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR265 - Failed to synthesize node: vararg_function_node<ivararg_function_t>", + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + + inline expression_node_ptr generic_function_call(igeneric_function_t* gf, + std::vector<expression_node_ptr>& arg_list, + const std::size_t& param_seq_index = std::numeric_limits<std::size_t>::max()) + { + if (!all_nodes_valid(arg_list)) + { + details::free_all_nodes(*node_allocator_,arg_list); + return error_node(); + } + + typedef details::generic_function_node <Type,igeneric_function_t> alloc_type1; + typedef details::multimode_genfunction_node<Type,igeneric_function_t> alloc_type2; + + const std::size_t no_psi = std::numeric_limits<std::size_t>::max(); + + expression_node_ptr result = error_node(); + std::string node_name = "Unknown"; + + if (no_psi == param_seq_index) + { + result = node_allocator_->allocate<alloc_type1>(arg_list,gf); + node_name = "generic_function_node<igeneric_function_t>"; + } + else + { + result = node_allocator_->allocate<alloc_type2>(gf, param_seq_index, arg_list); + node_name = "multimode_genfunction_node<igeneric_function_t>"; + } + + alloc_type1* genfunc_node_ptr = static_cast<alloc_type1*>(result); + + assert(genfunc_node_ptr); + + if ( + !arg_list.empty() && + !gf->has_side_effects() && + parser_->state_.type_check_enabled && + is_constant_foldable(arg_list) + ) + { + genfunc_node_ptr->init_branches(); + + const Type v = result->value(); + + details::free_node(*node_allocator_,result); + + return node_allocator_->allocate<literal_node_t>(v); + } + else if (genfunc_node_ptr->init_branches()) + { + if (result && result->valid()) + { + parser_->state_.activate_side_effect("generic_function_call()"); + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR266 - Failed to synthesize node: " + node_name, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + else + { + details::free_node(*node_allocator_, result); + details::free_all_nodes(*node_allocator_, arg_list); + + return error_node(); + } + } + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr string_function_call(igeneric_function_t* gf, + std::vector<expression_node_ptr>& arg_list, + const std::size_t& param_seq_index = std::numeric_limits<std::size_t>::max()) + { + if (!all_nodes_valid(arg_list)) + { + details::free_all_nodes(*node_allocator_,arg_list); + return error_node(); + } + + typedef details::string_function_node <Type,igeneric_function_t> alloc_type1; + typedef details::multimode_strfunction_node<Type,igeneric_function_t> alloc_type2; + + const std::size_t no_psi = std::numeric_limits<std::size_t>::max(); + + expression_node_ptr result = error_node(); + std::string node_name = "Unknown"; + + if (no_psi == param_seq_index) + { + result = node_allocator_->allocate<alloc_type1>(gf,arg_list); + node_name = "string_function_node<igeneric_function_t>"; + } + else + { + result = node_allocator_->allocate<alloc_type2>(gf, param_seq_index, arg_list); + node_name = "multimode_strfunction_node<igeneric_function_t>"; + } + + alloc_type1* strfunc_node_ptr = static_cast<alloc_type1*>(result); + + assert(strfunc_node_ptr); + + if ( + !arg_list.empty() && + !gf->has_side_effects() && + is_constant_foldable(arg_list) + ) + { + strfunc_node_ptr->init_branches(); + + const Type v = result->value(); + + details::free_node(*node_allocator_,result); + + return node_allocator_->allocate<literal_node_t>(v); + } + else if (strfunc_node_ptr->init_branches()) + { + if (result && result->valid()) + { + parser_->state_.activate_side_effect("string_function_call()"); + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR267 - Failed to synthesize node: " + node_name, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + else + { + details::free_node (*node_allocator_,result ); + details::free_all_nodes(*node_allocator_,arg_list); + + return error_node(); + } + } + #endif + + #ifndef exprtk_disable_return_statement + inline expression_node_ptr return_call(std::vector<expression_node_ptr>& arg_list) + { + if (!all_nodes_valid(arg_list)) + { + details::free_all_nodes(*node_allocator_,arg_list); + return error_node(); + } + + typedef details::return_node<Type> alloc_type; + + expression_node_ptr result = node_allocator_-> + allocate_rr<alloc_type>(arg_list,parser_->results_ctx()); + + alloc_type* return_node_ptr = static_cast<alloc_type*>(result); + + assert(return_node_ptr); + + if (return_node_ptr->init_branches()) + { + if (result && result->valid()) + { + parser_->state_.activate_side_effect("return_call()"); + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR268 - Failed to synthesize node: return_node", + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + else + { + details::free_node (*node_allocator_, result ); + details::free_all_nodes(*node_allocator_, arg_list); + + return error_node(); + } + } + + inline expression_node_ptr return_envelope(expression_node_ptr body, + results_context_t* rc, + bool*& return_invoked) + { + typedef details::return_envelope_node<Type> alloc_type; + + expression_node_ptr result = node_allocator_-> + allocate_cr<alloc_type>(body,(*rc)); + + return_invoked = static_cast<alloc_type*>(result)->retinvk_ptr(); + + return result; + } + #else + inline expression_node_ptr return_call(std::vector<expression_node_ptr>&) + { + return error_node(); + } + + inline expression_node_ptr return_envelope(expression_node_ptr, + results_context_t*, + bool*&) + { + return error_node(); + } + #endif + + inline expression_node_ptr vector_element(const std::string& symbol, + vector_holder_ptr vector_base, + expression_node_ptr vec_node, + expression_node_ptr index) + { + expression_node_ptr result = error_node(); + std::string node_name = "Unknown"; + + if (details::is_constant_node(index)) + { + const std::size_t vec_index = static_cast<std::size_t>(details::numeric::to_int64(index->value())); + + details::free_node(*node_allocator_,index); + + if (vec_index >= vector_base->size()) + { + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + token_t(), + "ERR269 - Index of " + details::to_str(vec_index) + " out of range for " + "vector '" + symbol + "' of size " + details::to_str(vector_base->size()), + exprtk_error_location)); + + details::free_node(*node_allocator_,vec_node); + + return error_node(); + } + + if (vector_base->rebaseable()) + { + vector_access_runtime_check_ptr rtc = get_vector_access_runtime_check(); + + result = (rtc) ? + node_allocator_->allocate<rebasevector_celem_rtc_node_t>(vec_node, vec_index, vector_base, rtc) : + node_allocator_->allocate<rebasevector_celem_node_t >(vec_node, vec_index, vector_base ) ; + + node_name = (rtc) ? + "rebasevector_elem_rtc_node_t" : + "rebasevector_elem_node_t" ; + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR270 - Failed to synthesize node: " + node_name + " for vector: " + symbol, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + else if (details::is_ivector_node(vec_node) && !details::is_vector_node(vec_node)) + { + vector_access_runtime_check_ptr rtc = get_vector_access_runtime_check(); + + result = (rtc) ? + node_allocator_->allocate<vector_celem_rtc_node_t>(vec_node, vec_index, vector_base, rtc) : + node_allocator_->allocate<vector_celem_node_t >(vec_node, vec_index, vector_base ) ; + + node_name = (rtc) ? + "vector_elem_rtc_node_t" : + "vector_elem_node_t" ; + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR271 - Failed to synthesize node: " + node_name + " for vector: " + symbol, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + + const scope_element& se = parser_->sem_.get_element(symbol,vec_index); + + if (se.index == vec_index) + { + result = se.var_node; + details::free_node(*node_allocator_,vec_node); + } + else + { + scope_element nse; + nse.name = symbol; + nse.active = true; + nse.ref_count = 1; + nse.type = scope_element::e_vecelem; + nse.index = vec_index; + nse.depth = parser_->state_.scope_depth; + nse.data = 0; + nse.var_node = node_allocator_->allocate<variable_node_t>((*(*vector_base)[vec_index])); + + if (!parser_->sem_.add_element(nse)) + { + parser_->set_synthesis_error("Failed to add new local vector element to SEM [1]"); + + parser_->sem_.free_element(nse); + + result = error_node(); + } + + assert(parser_->sem_.total_local_symb_size_bytes() <= parser_->settings().max_total_local_symbol_size_bytes()); + + details::free_node(*node_allocator_,vec_node); + + exprtk_debug(("vector_element() - INFO - Added new local vector element: %s\n", nse.name.c_str())); + + parser_->state_.activate_side_effect("vector_element()"); + + result = nse.var_node; + node_name = "variable_node_t"; + } + } + else + { + vector_access_runtime_check_ptr rtc = get_vector_access_runtime_check(); + + if (vector_base->rebaseable()) + { + result = (rtc) ? + node_allocator_->allocate<rebasevector_elem_rtc_node_t>(vec_node, index, vector_base, rtc) : + node_allocator_->allocate<rebasevector_elem_node_t >(vec_node, index, vector_base ) ; + + node_name = (rtc) ? + "rebasevector_elem_rtc_node_t" : + "rebasevector_elem_node_t" ; + } + else + { + result = rtc ? + node_allocator_->allocate<vector_elem_rtc_node_t>(vec_node, index, vector_base, rtc) : + node_allocator_->allocate<vector_elem_node_t >(vec_node, index, vector_base ) ; + + node_name = (rtc) ? + "vector_elem_rtc_node_t" : + "vector_elem_node_t" ; + } + } + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR272 - Failed to synthesize node: " + node_name, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + + private: + + template <std::size_t N, typename NodePtr> + inline bool is_constant_foldable(NodePtr (&b)[N]) const + { + for (std::size_t i = 0; i < N; ++i) + { + if (0 == b[i]) + return false; + else if (!details::is_constant_node(b[i])) + return false; + } + + return true; + } + + template <typename NodePtr, + typename Allocator, + template <typename, typename> class Sequence> + inline bool is_constant_foldable(const Sequence<NodePtr,Allocator>& b) const + { + for (std::size_t i = 0; i < b.size(); ++i) + { + if (0 == b[i]) + return false; + else if (!details::is_constant_node(b[i])) + return false; + } + + return true; + } + + void lodge_assignment(symbol_type cst, expression_node_ptr node) + { + parser_->state_.activate_side_effect("lodge_assignment()"); + + if (!parser_->dec_.collect_assignments()) + return; + + std::string symbol_name; + + switch (cst) + { + case e_st_variable : symbol_name = parser_->symtab_store_ + .get_variable_name(node); + break; + + #ifndef exprtk_disable_string_capabilities + case e_st_string : symbol_name = parser_->symtab_store_ + .get_stringvar_name(node); + break; + #endif + + case e_st_vector : { + typedef details::vector_holder<T> vector_holder_t; + + vector_holder_t& vh = static_cast<vector_node_t*>(node)->vec_holder(); + + symbol_name = parser_->symtab_store_.get_vector_name(&vh); + } + break; + + case e_st_vecelem : { + typedef details::vector_holder<T> vector_holder_t; + + vector_holder_t& vh = static_cast<vector_elem_node_t*>(node)->vec_holder(); + + symbol_name = parser_->symtab_store_.get_vector_name(&vh); + + cst = e_st_vector; + } + break; + + default : return; + } + + if (!symbol_name.empty()) + { + parser_->dec_.add_assignment(symbol_name,cst); + } + } + + const void* base_ptr(expression_node_ptr node) + { + if (node) + { + switch (node->type()) + { + case details::expression_node<T>::e_variable: + return reinterpret_cast<const void*>(&static_cast<variable_node_t*>(node)->ref()); + + case details::expression_node<T>::e_vecelem: + return reinterpret_cast<const void*>(&static_cast<vector_elem_node_t*>(node)->ref()); + + case details::expression_node<T>::e_veccelem: + return reinterpret_cast<const void*>(&static_cast<vector_celem_node_t*>(node)->ref()); + + case details::expression_node<T>::e_vecelemrtc: + return reinterpret_cast<const void*>(&static_cast<vector_elem_rtc_node_t*>(node)->ref()); + + case details::expression_node<T>::e_veccelemrtc: + return reinterpret_cast<const void*>(&static_cast<vector_celem_rtc_node_t*>(node)->ref()); + + case details::expression_node<T>::e_rbvecelem: + return reinterpret_cast<const void*>(&static_cast<rebasevector_elem_node_t*>(node)->ref()); + + case details::expression_node<T>::e_rbvecelemrtc: + return reinterpret_cast<const void*>(&static_cast<rebasevector_elem_rtc_node_t*>(node)->ref()); + + case details::expression_node<T>::e_rbveccelem: + return reinterpret_cast<const void*>(&static_cast<rebasevector_celem_node_t*>(node)->ref()); + + case details::expression_node<T>::e_rbveccelemrtc: + return reinterpret_cast<const void*>(&static_cast<rebasevector_celem_rtc_node_t*>(node)->ref()); + + case details::expression_node<T>::e_vector: + return reinterpret_cast<const void*>(static_cast<vector_node_t*>(node)->vec_holder().data()); + + #ifndef exprtk_disable_string_capabilities + case details::expression_node<T>::e_stringvar: + return reinterpret_cast<const void*>((static_cast<stringvar_node_t*>(node)->base())); + + case details::expression_node<T>::e_stringvarrng: + return reinterpret_cast<const void*>((static_cast<string_range_node_t*>(node)->base())); + #endif + default : return reinterpret_cast<const void*>(0); + } + } + + return reinterpret_cast<const void*>(0); + } + + bool assign_immutable_symbol(expression_node_ptr node) + { + interval_t interval; + const void* baseptr_addr = base_ptr(node); + + exprtk_debug(("assign_immutable_symbol - base ptr addr: %p\n", baseptr_addr)); + + if (parser_->immutable_memory_map_.in_interval(baseptr_addr,interval)) + { + typename immutable_symtok_map_t::iterator itr = parser_->immutable_symtok_map_.find(interval); + + if (parser_->immutable_symtok_map_.end() != itr) + { + token_t& token = itr->second; + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + token, + "ERR273 - Symbol '" + token.value + "' cannot be assigned-to as it is immutable.", + exprtk_error_location)); + } + else + parser_->set_synthesis_error("Unable to assign symbol is immutable."); + + return true; + } + + return false; + } + + inline expression_node_ptr synthesize_assignment_expression(const details::operator_type& operation, expression_node_ptr (&branch)[2]) + { + if (assign_immutable_symbol(branch[0])) + { + return error_node(); + } + else if (details::is_variable_node(branch[0])) + { + lodge_assignment(e_st_variable,branch[0]); + return synthesize_expression<assignment_node_t,2>(operation,branch); + } + else if (details::is_vector_elem_node(branch[0]) || details::is_vector_celem_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + return synthesize_expression<assignment_vec_elem_node_t, 2>(operation, branch); + } + else if (details::is_vector_elem_rtc_node(branch[0]) || details::is_vector_celem_rtc_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + return synthesize_expression<assignment_vec_elem_rtc_node_t, 2>(operation, branch); + } + else if (details::is_rebasevector_elem_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + return synthesize_expression<assignment_rebasevec_elem_node_t, 2>(operation, branch); + } + else if (details::is_rebasevector_elem_rtc_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + return synthesize_expression<assignment_rebasevec_elem_rtc_node_t, 2>(operation, branch); + } + else if (details::is_rebasevector_celem_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + return synthesize_expression<assignment_rebasevec_celem_node_t, 2>(operation, branch); + } + #ifndef exprtk_disable_string_capabilities + else if (details::is_string_node(branch[0])) + { + lodge_assignment(e_st_string,branch[0]); + return synthesize_expression<assignment_string_node_t,2>(operation, branch); + } + else if (details::is_string_range_node(branch[0])) + { + lodge_assignment(e_st_string,branch[0]); + return synthesize_expression<assignment_string_range_node_t,2>(operation, branch); + } + #endif + else if (details::is_vector_node(branch[0])) + { + lodge_assignment(e_st_vector,branch[0]); + + if (details::is_ivector_node(branch[1])) + return synthesize_expression<assignment_vecvec_node_t,2>(operation, branch); + else + return synthesize_expression<assignment_vec_node_t,2>(operation, branch); + } + else if (details::is_literal_node(branch[0])) + { + parser_->set_error(parser_error::make_error( + parser_error::e_syntax, + parser_->current_state().token, + "ERR274 - Cannot assign value to const variable", + exprtk_error_location)); + + return error_node(); + } + else + { + parser_->set_error(parser_error::make_error( + parser_error::e_syntax, + parser_->current_state().token, + "ERR275 - Invalid branches for assignment operator '" + details::to_str(operation) + "'", + exprtk_error_location)); + + return error_node(); + } + } + + inline expression_node_ptr synthesize_assignment_operation_expression(const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + if (assign_immutable_symbol(branch[0])) + { + return error_node(); + } + + expression_node_ptr result = error_node(); + std::string node_name = "Unknown"; + + if (details::is_variable_node(branch[0])) + { + lodge_assignment(e_st_variable,branch[0]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_op_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_op_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + else if (details::is_vector_elem_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_vec_elem_op_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_vec_elem_op_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + else if (details::is_vector_elem_rtc_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_vec_elem_op_rtc_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_vec_elem_op_rtc_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + else if (details::is_vector_celem_rtc_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_vec_celem_op_rtc_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_vec_celem_op_rtc_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + else if (details::is_rebasevector_elem_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_rebasevec_elem_op_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_rebasevec_elem_op_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + else if (details::is_rebasevector_celem_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_rebasevec_celem_op_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_rebasevec_celem_op_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + else if (details::is_rebasevector_elem_rtc_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_rebasevec_elem_op_rtc_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_rebasevec_elem_op_rtc_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + else if (details::is_rebasevector_celem_rtc_node(branch[0])) + { + lodge_assignment(e_st_vecelem,branch[0]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_rebasevec_celem_op_rtc_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_rebasevec_celem_op_rtc_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + else if (details::is_vector_node(branch[0])) + { + lodge_assignment(e_st_vector,branch[0]); + + if (details::is_ivector_node(branch[1])) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_vecvec_op_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_rebasevec_celem_op_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + else + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::assignment_vec_op_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "assignment_vec_op_node"; \ + break; \ + + case_stmt(details::e_addass , details::add_op) + case_stmt(details::e_subass , details::sub_op) + case_stmt(details::e_mulass , details::mul_op) + case_stmt(details::e_divass , details::div_op) + case_stmt(details::e_modass , details::mod_op) + #undef case_stmt + default : return error_node(); + } + } + } + #ifndef exprtk_disable_string_capabilities + else if ( + (details::e_addass == operation) && + details::is_string_node(branch[0]) + ) + { + typedef details::assignment_string_node<T,details::asn_addassignment> addass_t; + + lodge_assignment(e_st_string,branch[0]); + + result = synthesize_expression<addass_t,2>(operation,branch); + node_name = "assignment_string_node<T,details::asn_addassignment>"; + } + #endif + else + { + parser_->set_error(parser_error::make_error( + parser_error::e_syntax, + parser_->current_state().token, + "ERR276 - Invalid branches for assignment operator '" + details::to_str(operation) + "'", + exprtk_error_location)); + + return error_node(); + } + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR277 - Failed to synthesize node: " + node_name, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + + inline expression_node_ptr synthesize_veceqineqlogic_operation_expression(const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const bool is_b0_ivec = details::is_ivector_node(branch[0]); + const bool is_b1_ivec = details::is_ivector_node(branch[1]); + + #define batch_eqineq_logic_case \ + case_stmt(details::e_lt , details::lt_op ) \ + case_stmt(details::e_lte , details::lte_op ) \ + case_stmt(details::e_gt , details::gt_op ) \ + case_stmt(details::e_gte , details::gte_op ) \ + case_stmt(details::e_eq , details::eq_op ) \ + case_stmt(details::e_ne , details::ne_op ) \ + case_stmt(details::e_equal , details::equal_op) \ + case_stmt(details::e_and , details::and_op ) \ + case_stmt(details::e_nand , details::nand_op ) \ + case_stmt(details::e_or , details::or_op ) \ + case_stmt(details::e_nor , details::nor_op ) \ + case_stmt(details::e_xor , details::xor_op ) \ + case_stmt(details::e_xnor , details::xnor_op ) \ + + expression_node_ptr result = error_node(); + std::string node_name = "Unknown"; + + if (is_b0_ivec && is_b1_ivec) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::vec_binop_vecvec_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "vec_binop_vecvec_node"; \ + break; \ + + batch_eqineq_logic_case + #undef case_stmt + default : return error_node(); + } + } + else if (is_b0_ivec && !is_b1_ivec) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::vec_binop_vecval_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "vec_binop_vecval_node"; \ + break; \ + + batch_eqineq_logic_case + #undef case_stmt + default : return error_node(); + } + } + else if (!is_b0_ivec && is_b1_ivec) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::vec_binop_valvec_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "vec_binop_valvec_node"; \ + break; \ + + batch_eqineq_logic_case + #undef case_stmt + default : return error_node(); + } + } + else + return error_node(); + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR278 - Failed to synthesize node: " + node_name, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + + #undef batch_eqineq_logic_case + } + + inline expression_node_ptr synthesize_vecarithmetic_operation_expression(const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const bool is_b0_ivec = details::is_ivector_node(branch[0]); + const bool is_b1_ivec = details::is_ivector_node(branch[1]); + + #define vector_ops \ + case_stmt(details::e_add , details::add_op) \ + case_stmt(details::e_sub , details::sub_op) \ + case_stmt(details::e_mul , details::mul_op) \ + case_stmt(details::e_div , details::div_op) \ + case_stmt(details::e_mod , details::mod_op) \ + + expression_node_ptr result = error_node(); + std::string node_name = "Unknown"; + + if (is_b0_ivec && is_b1_ivec) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::vec_binop_vecvec_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "vec_binop_vecvec_node"; \ + break; \ + + vector_ops + case_stmt(details::e_pow,details:: pow_op) + #undef case_stmt + default : return error_node(); + } + } + else if (is_b0_ivec && !is_b1_ivec) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::vec_binop_vecval_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "vec_binop_vecval_node(b0ivec,!b1ivec)"; \ + break; \ + + vector_ops + case_stmt(details::e_pow,details:: pow_op) + #undef case_stmt + default : return error_node(); + } + } + else if (!is_b0_ivec && is_b1_ivec) + { + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : result = node_allocator_-> \ + template allocate_rrr<typename details::vec_binop_valvec_node<Type,op1<Type> > > \ + (operation, branch[0], branch[1]); \ + node_name = "vec_binop_vecval_node(!b0ivec,b1ivec)"; \ + break; \ + + vector_ops + #undef case_stmt + default : return error_node(); + } + } + else + return error_node(); + + if (result && result->valid()) + { + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR279 - Failed to synthesize node: " + node_name, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + + #undef vector_ops + } + + inline expression_node_ptr synthesize_swap_expression(expression_node_ptr (&branch)[2]) + { + const bool v0_is_ivar = details::is_ivariable_node(branch[0]); + const bool v1_is_ivar = details::is_ivariable_node(branch[1]); + + const bool v0_is_ivec = details::is_ivector_node (branch[0]); + const bool v1_is_ivec = details::is_ivector_node (branch[1]); + + #ifndef exprtk_disable_string_capabilities + const bool v0_is_str = details::is_generally_string_node(branch[0]); + const bool v1_is_str = details::is_generally_string_node(branch[1]); + #endif + + expression_node_ptr result = error_node(); + std::string node_name = "Unknown"; + + if (v0_is_ivar && v1_is_ivar) + { + typedef details::variable_node<T>* variable_node_ptr; + + variable_node_ptr v0 = variable_node_ptr(0); + variable_node_ptr v1 = variable_node_ptr(0); + + if ( + (0 != (v0 = dynamic_cast<variable_node_ptr>(branch[0]))) && + (0 != (v1 = dynamic_cast<variable_node_ptr>(branch[1]))) + ) + { + result = node_allocator_->allocate<details::swap_node<T> >(v0,v1); + node_name = "swap_node"; + } + else + { + result = node_allocator_->allocate<details::swap_generic_node<T> >(branch[0],branch[1]); + node_name = "swap_generic_node"; + } + } + else if (v0_is_ivec && v1_is_ivec) + { + result = node_allocator_->allocate<details::swap_vecvec_node<T> >(branch[0],branch[1]); + node_name = "swap_vecvec_node"; + } + #ifndef exprtk_disable_string_capabilities + else if (v0_is_str && v1_is_str) + { + if (is_string_node(branch[0]) && is_string_node(branch[1])) + { + result = node_allocator_->allocate<details::swap_string_node<T> > + (branch[0], branch[1]); + node_name = "swap_string_node"; + } + else + { + result = node_allocator_->allocate<details::swap_genstrings_node<T> > + (branch[0], branch[1]); + node_name = "swap_genstrings_node"; + } + } + #endif + else + { + parser_->set_synthesis_error("Only variables, strings, vectors or vector elements can be swapped"); + return error_node(); + } + + if (result && result->valid()) + { + parser_->state_.activate_side_effect("synthesize_swap_expression()"); + return result; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_synthesis, + token_t(), + "ERR280 - Failed to synthesize node: " + node_name, + exprtk_error_location)); + + details::free_node(*node_allocator_, result); + return error_node(); + } + + #ifndef exprtk_disable_sc_andor + inline expression_node_ptr synthesize_shortcircuit_expression(const details::operator_type& operation, expression_node_ptr (&branch)[2]) + { + expression_node_ptr result = error_node(); + + if (details::is_constant_node(branch[0])) + { + if ( + (details::e_scand == operation) && + std::equal_to<T>()(T(0),branch[0]->value()) + ) + result = node_allocator_->allocate_c<literal_node_t>(T(0)); + else if ( + (details::e_scor == operation) && + std::not_equal_to<T>()(T(0),branch[0]->value()) + ) + result = node_allocator_->allocate_c<literal_node_t>(T(1)); + } + + if (details::is_constant_node(branch[1]) && (0 == result)) + { + if ( + (details::e_scand == operation) && + std::equal_to<T>()(T(0),branch[1]->value()) + ) + result = node_allocator_->allocate_c<literal_node_t>(T(0)); + else if ( + (details::e_scor == operation) && + std::not_equal_to<T>()(T(0),branch[1]->value()) + ) + result = node_allocator_->allocate_c<literal_node_t>(T(1)); + } + + if (result) + { + details::free_node(*node_allocator_, branch[0]); + details::free_node(*node_allocator_, branch[1]); + + return result; + } + else if (details::e_scand == operation) + { + return synthesize_expression<scand_node_t,2>(operation, branch); + } + else if (details::e_scor == operation) + { + return synthesize_expression<scor_node_t,2>(operation, branch); + } + else + return error_node(); + } + #else + inline expression_node_ptr synthesize_shortcircuit_expression(const details::operator_type&, expression_node_ptr (&)[2]) + { + return error_node(); + } + #endif + + #define basic_opr_switch_statements \ + case_stmt(details::e_add , details::add_op) \ + case_stmt(details::e_sub , details::sub_op) \ + case_stmt(details::e_mul , details::mul_op) \ + case_stmt(details::e_div , details::div_op) \ + case_stmt(details::e_mod , details::mod_op) \ + case_stmt(details::e_pow , details::pow_op) \ + + #define extended_opr_switch_statements \ + case_stmt(details::e_lt , details::lt_op ) \ + case_stmt(details::e_lte , details::lte_op ) \ + case_stmt(details::e_gt , details::gt_op ) \ + case_stmt(details::e_gte , details::gte_op ) \ + case_stmt(details::e_eq , details::eq_op ) \ + case_stmt(details::e_ne , details::ne_op ) \ + case_stmt(details::e_and , details::and_op ) \ + case_stmt(details::e_nand , details::nand_op) \ + case_stmt(details::e_or , details::or_op ) \ + case_stmt(details::e_nor , details::nor_op ) \ + case_stmt(details::e_xor , details::xor_op ) \ + case_stmt(details::e_xnor , details::xnor_op) \ + + #ifndef exprtk_disable_cardinal_pow_optimisation + template <typename TType, template <typename, typename> class IPowNode> + inline expression_node_ptr cardinal_pow_optimisation_impl(const TType& v, const unsigned int& p) + { + switch (p) + { + #define case_stmt(cp) \ + case cp : return node_allocator_-> \ + allocate<IPowNode<T,details::numeric::fast_exp<T,cp> > >(v); \ + + case_stmt( 1) case_stmt( 2) case_stmt( 3) case_stmt( 4) + case_stmt( 5) case_stmt( 6) case_stmt( 7) case_stmt( 8) + case_stmt( 9) case_stmt(10) case_stmt(11) case_stmt(12) + case_stmt(13) case_stmt(14) case_stmt(15) case_stmt(16) + case_stmt(17) case_stmt(18) case_stmt(19) case_stmt(20) + case_stmt(21) case_stmt(22) case_stmt(23) case_stmt(24) + case_stmt(25) case_stmt(26) case_stmt(27) case_stmt(28) + case_stmt(29) case_stmt(30) case_stmt(31) case_stmt(32) + case_stmt(33) case_stmt(34) case_stmt(35) case_stmt(36) + case_stmt(37) case_stmt(38) case_stmt(39) case_stmt(40) + case_stmt(41) case_stmt(42) case_stmt(43) case_stmt(44) + case_stmt(45) case_stmt(46) case_stmt(47) case_stmt(48) + case_stmt(49) case_stmt(50) case_stmt(51) case_stmt(52) + case_stmt(53) case_stmt(54) case_stmt(55) case_stmt(56) + case_stmt(57) case_stmt(58) case_stmt(59) case_stmt(60) + #undef case_stmt + default : return error_node(); + } + } + + inline expression_node_ptr cardinal_pow_optimisation(const T& v, const T& c) + { + const bool not_recipricol = (c >= T(0)); + const unsigned int p = static_cast<unsigned int>(details::numeric::to_int32(details::numeric::abs(c))); + + if (0 == p) + return node_allocator_->allocate_c<literal_node_t>(T(1)); + else if (std::equal_to<T>()(T(2),c)) + { + return node_allocator_-> + template allocate_rr<typename details::vov_node<Type,details::mul_op<Type> > >(v,v); + } + else + { + if (not_recipricol) + return cardinal_pow_optimisation_impl<T,details::ipow_node>(v,p); + else + return cardinal_pow_optimisation_impl<T,details::ipowinv_node>(v,p); + } + } + + inline bool cardinal_pow_optimisable(const details::operator_type& operation, const T& c) const + { + return (details::e_pow == operation) && (details::numeric::abs(c) <= T(60)) && details::numeric::is_integer(c); + } + + inline expression_node_ptr cardinal_pow_optimisation(expression_node_ptr (&branch)[2]) + { + const Type c = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const bool not_recipricol = (c >= T(0)); + const unsigned int p = static_cast<unsigned int>(details::numeric::to_int32(details::numeric::abs(c))); + + node_allocator_->free(branch[1]); + + if (0 == p) + { + details::free_all_nodes(*node_allocator_, branch); + + return node_allocator_->allocate_c<literal_node_t>(T(1)); + } + else if (not_recipricol) + return cardinal_pow_optimisation_impl<expression_node_ptr,details::bipow_node>(branch[0],p); + else + return cardinal_pow_optimisation_impl<expression_node_ptr,details::bipowinv_node>(branch[0],p); + } + #else + inline expression_node_ptr cardinal_pow_optimisation(T&, const T&) + { + return error_node(); + } + + inline bool cardinal_pow_optimisable(const details::operator_type&, const T&) + { + return false; + } + + inline expression_node_ptr cardinal_pow_optimisation(expression_node_ptr(&)[2]) + { + return error_node(); + } + #endif + + struct synthesize_binary_ext_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const bool left_neg = is_neg_unary_node(branch[0]); + const bool right_neg = is_neg_unary_node(branch[1]); + + if (left_neg && right_neg) + { + if ( + (details::e_add == operation) || + (details::e_sub == operation) || + (details::e_mul == operation) || + (details::e_div == operation) + ) + { + if ( + !expr_gen.parser_->simplify_unary_negation_branch(branch[0]) || + !expr_gen.parser_->simplify_unary_negation_branch(branch[1]) + ) + { + details::free_all_nodes(*expr_gen.node_allocator_,branch); + + return error_node(); + } + } + + switch (operation) + { + // -f(x + 1) + -g(y + 1) --> -(f(x + 1) + g(y + 1)) + case details::e_add : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::add_op<Type> > > + (branch[0],branch[1])); + + // -f(x + 1) - -g(y + 1) --> g(y + 1) - f(x + 1) + case details::e_sub : return expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::sub_op<Type> > > + (branch[1],branch[0]); + + default : break; + } + } + else if (left_neg && !right_neg) + { + if ( + (details::e_add == operation) || + (details::e_sub == operation) || + (details::e_mul == operation) || + (details::e_div == operation) + ) + { + if (!expr_gen.parser_->simplify_unary_negation_branch(branch[0])) + { + details::free_all_nodes(*expr_gen.node_allocator_,branch); + + return error_node(); + } + + switch (operation) + { + // -f(x + 1) + g(y + 1) --> g(y + 1) - f(x + 1) + case details::e_add : return expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::sub_op<Type> > > + (branch[1], branch[0]); + + // -f(x + 1) - g(y + 1) --> -(f(x + 1) + g(y + 1)) + case details::e_sub : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::add_op<Type> > > + (branch[0], branch[1])); + + // -f(x + 1) * g(y + 1) --> -(f(x + 1) * g(y + 1)) + case details::e_mul : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::mul_op<Type> > > + (branch[0], branch[1])); + + // -f(x + 1) / g(y + 1) --> -(f(x + 1) / g(y + 1)) + case details::e_div : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::div_op<Type> > > + (branch[0], branch[1])); + + default : return error_node(); + } + } + } + else if (!left_neg && right_neg) + { + if ( + (details::e_add == operation) || + (details::e_sub == operation) || + (details::e_mul == operation) || + (details::e_div == operation) + ) + { + if (!expr_gen.parser_->simplify_unary_negation_branch(branch[1])) + { + details::free_all_nodes(*expr_gen.node_allocator_,branch); + + return error_node(); + } + + switch (operation) + { + // f(x + 1) + -g(y + 1) --> f(x + 1) - g(y + 1) + case details::e_add : return expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::sub_op<Type> > > + (branch[0], branch[1]); + + // f(x + 1) - - g(y + 1) --> f(x + 1) + g(y + 1) + case details::e_sub : return expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::add_op<Type> > > + (branch[0], branch[1]); + + // f(x + 1) * -g(y + 1) --> -(f(x + 1) * g(y + 1)) + case details::e_mul : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::mul_op<Type> > > + (branch[0], branch[1])); + + // f(x + 1) / -g(y + 1) --> -(f(x + 1) / g(y + 1)) + case details::e_div : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate<typename details::binary_ext_node<Type,details::div_op<Type> > > + (branch[0], branch[1])); + + default : return error_node(); + } + } + } + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return expr_gen.node_allocator_-> \ + template allocate<typename details::binary_ext_node<Type,op1<Type> > > \ + (branch[0], branch[1]); \ + + basic_opr_switch_statements + extended_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + }; + + struct synthesize_vob_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const Type& v = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + + #ifndef exprtk_disable_enhanced_features + if (details::is_sf3ext_node(branch[1])) + { + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile_right<vtype> + (expr_gen, v, operation, branch[1], result); + + if (synthesis_result) + { + details::free_node(*expr_gen.node_allocator_,branch[1]); + return result; + } + } + #endif + + if ( + (details::e_mul == operation) || + (details::e_div == operation) + ) + { + if (details::is_uv_node(branch[1])) + { + typedef details::uv_base_node<Type>* uvbn_ptr_t; + + details::operator_type o = static_cast<uvbn_ptr_t>(branch[1])->operation(); + + if (details::e_neg == o) + { + const Type& v1 = static_cast<uvbn_ptr_t>(branch[1])->v(); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + + switch (operation) + { + case details::e_mul : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate_rr<typename details:: + vov_node<Type,details::mul_op<Type> > >(v,v1)); + + case details::e_div : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate_rr<typename details:: + vov_node<Type,details::div_op<Type> > >(v,v1)); + + default : break; + } + } + } + } + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return expr_gen.node_allocator_-> \ + template allocate_rc<typename details::vob_node<Type,op1<Type> > > \ + (v, branch[1]); \ + + basic_opr_switch_statements + extended_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + }; + + struct synthesize_bov_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const Type& v = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + + #ifndef exprtk_disable_enhanced_features + if (details::is_sf3ext_node(branch[0])) + { + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile_left<vtype> + (expr_gen, v, operation, branch[0], result); + + if (synthesis_result) + { + details::free_node(*expr_gen.node_allocator_, branch[0]); + + return result; + } + } + #endif + + if ( + (details::e_add == operation) || + (details::e_sub == operation) || + (details::e_mul == operation) || + (details::e_div == operation) + ) + { + if (details::is_uv_node(branch[0])) + { + typedef details::uv_base_node<Type>* uvbn_ptr_t; + + details::operator_type o = static_cast<uvbn_ptr_t>(branch[0])->operation(); + + if (details::e_neg == o) + { + const Type& v0 = static_cast<uvbn_ptr_t>(branch[0])->v(); + + details::free_node(*expr_gen.node_allocator_,branch[0]); + + switch (operation) + { + case details::e_add : return expr_gen.node_allocator_-> + template allocate_rr<typename details:: + vov_node<Type,details::sub_op<Type> > >(v,v0); + + case details::e_sub : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate_rr<typename details:: + vov_node<Type,details::add_op<Type> > >(v0,v)); + + case details::e_mul : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate_rr<typename details:: + vov_node<Type,details::mul_op<Type> > >(v0,v)); + + case details::e_div : return expr_gen(details::e_neg, + expr_gen.node_allocator_-> + template allocate_rr<typename details:: + vov_node<Type,details::div_op<Type> > >(v0,v)); + default : break; + } + } + } + } + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return expr_gen.node_allocator_-> \ + template allocate_cr<typename details::bov_node<Type,op1<Type> > > \ + (branch[0], v); \ + + basic_opr_switch_statements + extended_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + }; + + struct synthesize_cob_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const Type c = static_cast<details::literal_node<Type>*>(branch[0])->value(); + + details::free_node(*expr_gen.node_allocator_,branch[0]); + + if (std::equal_to<T>()(T(0),c) && (details::e_mul == operation)) + { + details::free_node(*expr_gen.node_allocator_,branch[1]); + + return expr_gen(T(0)); + } + else if (std::equal_to<T>()(T(0),c) && (details::e_div == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[1]); + + return expr_gen(T(0)); + } + else if (std::equal_to<T>()(T(0),c) && (details::e_add == operation)) + return branch[1]; + else if (std::equal_to<T>()(T(1),c) && (details::e_mul == operation)) + return branch[1]; + + if (details::is_cob_node(branch[1])) + { + // Simplify expressions of the form: + // 1. (1 * (2 * (3 * (4 * (5 * (6 * (7 * (8 * (9 + x))))))))) --> 40320 * (9 + x) + // 2. (1 + (2 + (3 + (4 + (5 + (6 + (7 + (8 + (9 + x))))))))) --> 45 + x + if ( + (details::e_mul == operation) || + (details::e_add == operation) + ) + { + details::cob_base_node<Type>* cobnode = static_cast<details::cob_base_node<Type>*>(branch[1]); + + if (operation == cobnode->operation()) + { + switch (operation) + { + case details::e_add : cobnode->set_c(c + cobnode->c()); break; + case details::e_mul : cobnode->set_c(c * cobnode->c()); break; + default : return error_node(); + } + + return cobnode; + } + } + + if (operation == details::e_mul) + { + details::cob_base_node<Type>* cobnode = static_cast<details::cob_base_node<Type>*>(branch[1]); + details::operator_type cob_opr = cobnode->operation(); + + if ( + (details::e_div == cob_opr) || + (details::e_mul == cob_opr) + ) + { + switch (cob_opr) + { + case details::e_div : cobnode->set_c(c * cobnode->c()); break; + case details::e_mul : cobnode->set_c(cobnode->c() / c); break; + default : return error_node(); + } + + return cobnode; + } + } + else if (operation == details::e_div) + { + details::cob_base_node<Type>* cobnode = static_cast<details::cob_base_node<Type>*>(branch[1]); + details::operator_type cob_opr = cobnode->operation(); + + if ( + (details::e_div == cob_opr) || + (details::e_mul == cob_opr) + ) + { + details::expression_node<Type>* new_cobnode = error_node(); + + switch (cob_opr) + { + case details::e_div : new_cobnode = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::mul_op<Type> > > + (c / cobnode->c(), cobnode->move_branch(0)); + break; + + case details::e_mul : new_cobnode = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::div_op<Type> > > + (c / cobnode->c(), cobnode->move_branch(0)); + break; + + default : return error_node(); + } + + details::free_node(*expr_gen.node_allocator_,branch[1]); + + return new_cobnode; + } + } + } + #ifndef exprtk_disable_enhanced_features + else if (details::is_sf3ext_node(branch[1])) + { + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile_right<ctype> + (expr_gen, c, operation, branch[1], result); + + if (synthesis_result) + { + details::free_node(*expr_gen.node_allocator_,branch[1]); + + return result; + } + } + #endif + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return expr_gen.node_allocator_-> \ + template allocate_tt<typename details::cob_node<Type,op1<Type> > > \ + (c, branch[1]); \ + + basic_opr_switch_statements + extended_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + }; + + struct synthesize_boc_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const Type c = static_cast<details::literal_node<Type>*>(branch[1])->value(); + + details::free_node(*(expr_gen.node_allocator_), branch[1]); + + if (std::equal_to<T>()(T(0),c) && (details::e_mul == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[0]); + + return expr_gen(T(0)); + } + else if (std::equal_to<T>()(T(0),c) && (details::e_div == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[0]); + + return expr_gen(std::numeric_limits<T>::quiet_NaN()); + } + else if (std::equal_to<T>()(T(0),c) && (details::e_add == operation)) + return branch[0]; + else if (std::equal_to<T>()(T(1),c) && (details::e_mul == operation)) + return branch[0]; + + if (details::is_boc_node(branch[0])) + { + // Simplify expressions of the form: + // 1. (((((((((x + 9) * 8) * 7) * 6) * 5) * 4) * 3) * 2) * 1) --> (x + 9) * 40320 + // 2. (((((((((x + 9) + 8) + 7) + 6) + 5) + 4) + 3) + 2) + 1) --> x + 45 + if ( + (details::e_mul == operation) || + (details::e_add == operation) + ) + { + details::boc_base_node<Type>* bocnode = static_cast<details::boc_base_node<Type>*>(branch[0]); + + if (operation == bocnode->operation()) + { + switch (operation) + { + case details::e_add : bocnode->set_c(c + bocnode->c()); break; + case details::e_mul : bocnode->set_c(c * bocnode->c()); break; + default : return error_node(); + } + + return bocnode; + } + } + else if (operation == details::e_div) + { + details::boc_base_node<Type>* bocnode = static_cast<details::boc_base_node<Type>*>(branch[0]); + details::operator_type boc_opr = bocnode->operation(); + + if ( + (details::e_div == boc_opr) || + (details::e_mul == boc_opr) + ) + { + switch (boc_opr) + { + case details::e_div : bocnode->set_c(c * bocnode->c()); break; + case details::e_mul : bocnode->set_c(bocnode->c() / c); break; + default : return error_node(); + } + + return bocnode; + } + } + else if (operation == details::e_pow) + { + // (v ^ c0) ^ c1 --> v ^(c0 * c1) + details::boc_base_node<Type>* bocnode = static_cast<details::boc_base_node<Type>*>(branch[0]); + details::operator_type boc_opr = bocnode->operation(); + + if (details::e_pow == boc_opr) + { + bocnode->set_c(bocnode->c() * c); + + return bocnode; + } + } + } + + #ifndef exprtk_disable_enhanced_features + if (details::is_sf3ext_node(branch[0])) + { + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile_left<ctype> + (expr_gen, c, operation, branch[0], result); + + if (synthesis_result) + { + free_node(*expr_gen.node_allocator_, branch[0]); + + return result; + } + } + #endif + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return expr_gen.node_allocator_-> \ + template allocate_cr<typename details::boc_node<Type,op1<Type> > > \ + (branch[0], c); \ + + basic_opr_switch_statements + extended_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + }; + + struct synthesize_cocob_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + expression_node_ptr result = error_node(); + + // (cob) o c --> cob + if (details::is_cob_node(branch[0])) + { + details::cob_base_node<Type>* cobnode = static_cast<details::cob_base_node<Type>*>(branch[0]); + + const Type c = static_cast<details::literal_node<Type>*>(branch[1])->value(); + + if (std::equal_to<T>()(T(0),c) && (details::e_mul == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[0]); + details::free_node(*expr_gen.node_allocator_, branch[1]); + + return expr_gen(T(0)); + } + else if (std::equal_to<T>()(T(0),c) && (details::e_div == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[0]); + details::free_node(*expr_gen.node_allocator_, branch[1]); + + return expr_gen(T(std::numeric_limits<T>::quiet_NaN())); + } + else if (std::equal_to<T>()(T(0),c) && (details::e_add == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[1]); + + return branch[0]; + } + else if (std::equal_to<T>()(T(1),c) && (details::e_mul == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[1]); + + return branch[0]; + } + else if (std::equal_to<T>()(T(1),c) && (details::e_div == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[1]); + + return branch[0]; + } + + const bool op_addsub = (details::e_add == cobnode->operation()) || + (details::e_sub == cobnode->operation()) ; + + if (op_addsub) + { + switch (operation) + { + case details::e_add : cobnode->set_c(cobnode->c() + c); break; + case details::e_sub : cobnode->set_c(cobnode->c() - c); break; + default : return error_node(); + } + + result = cobnode; + } + else if (details::e_mul == cobnode->operation()) + { + switch (operation) + { + case details::e_mul : cobnode->set_c(cobnode->c() * c); break; + case details::e_div : cobnode->set_c(cobnode->c() / c); break; + default : return error_node(); + } + + result = cobnode; + } + else if (details::e_div == cobnode->operation()) + { + if (details::e_mul == operation) + { + cobnode->set_c(cobnode->c() * c); + result = cobnode; + } + else if (details::e_div == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::div_op<Type> > > + (cobnode->c() / c, cobnode->move_branch(0)); + + details::free_node(*expr_gen.node_allocator_, branch[0]); + } + } + + if (result) + { + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + } + + // c o (cob) --> cob + else if (details::is_cob_node(branch[1])) + { + details::cob_base_node<Type>* cobnode = static_cast<details::cob_base_node<Type>*>(branch[1]); + + const Type c = static_cast<details::literal_node<Type>*>(branch[0])->value(); + + if (std::equal_to<T>()(T(0),c) && (details::e_mul == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[0]); + details::free_node(*expr_gen.node_allocator_, branch[1]); + + return expr_gen(T(0)); + } + else if (std::equal_to<T>()(T(0),c) && (details::e_div == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[0]); + details::free_node(*expr_gen.node_allocator_, branch[1]); + + return expr_gen(T(0)); + } + else if (std::equal_to<T>()(T(0),c) && (details::e_add == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[0]); + + return branch[1]; + } + else if (std::equal_to<T>()(T(1),c) && (details::e_mul == operation)) + { + details::free_node(*expr_gen.node_allocator_, branch[0]); + + return branch[1]; + } + + if (details::e_add == cobnode->operation()) + { + if (details::e_add == operation) + { + cobnode->set_c(c + cobnode->c()); + result = cobnode; + } + else if (details::e_sub == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::sub_op<Type> > > + (c - cobnode->c(), cobnode->move_branch(0)); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + } + else if (details::e_sub == cobnode->operation()) + { + if (details::e_add == operation) + { + cobnode->set_c(c + cobnode->c()); + result = cobnode; + } + else if (details::e_sub == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::add_op<Type> > > + (c - cobnode->c(), cobnode->move_branch(0)); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + } + else if (details::e_mul == cobnode->operation()) + { + if (details::e_mul == operation) + { + cobnode->set_c(c * cobnode->c()); + result = cobnode; + } + else if (details::e_div == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::div_op<Type> > > + (c / cobnode->c(), cobnode->move_branch(0)); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + } + else if (details::e_div == cobnode->operation()) + { + if (details::e_mul == operation) + { + cobnode->set_c(c * cobnode->c()); + result = cobnode; + } + else if (details::e_div == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::mul_op<Type> > > + (c / cobnode->c(), cobnode->move_branch(0)); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + } + + if (result) + { + details::free_node(*expr_gen.node_allocator_,branch[0]); + } + } + + return result; + } + }; + + struct synthesize_coboc_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + expression_node_ptr result = error_node(); + + // (boc) o c --> boc + if (details::is_boc_node(branch[0])) + { + details::boc_base_node<Type>* bocnode = static_cast<details::boc_base_node<Type>*>(branch[0]); + + const Type c = static_cast<details::literal_node<Type>*>(branch[1])->value(); + + if (details::e_add == bocnode->operation()) + { + switch (operation) + { + case details::e_add : bocnode->set_c(bocnode->c() + c); break; + case details::e_sub : bocnode->set_c(bocnode->c() - c); break; + default : return error_node(); + } + + result = bocnode; + } + else if (details::e_mul == bocnode->operation()) + { + switch (operation) + { + case details::e_mul : bocnode->set_c(bocnode->c() * c); break; + case details::e_div : bocnode->set_c(bocnode->c() / c); break; + default : return error_node(); + } + + result = bocnode; + } + else if (details::e_sub == bocnode->operation()) + { + if (details::e_add == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::boc_node<Type,details::add_op<Type> > > + (bocnode->move_branch(0), c - bocnode->c()); + + details::free_node(*expr_gen.node_allocator_,branch[0]); + } + else if (details::e_sub == operation) + { + bocnode->set_c(bocnode->c() + c); + result = bocnode; + } + } + else if (details::e_div == bocnode->operation()) + { + switch (operation) + { + case details::e_div : bocnode->set_c(bocnode->c() * c); break; + case details::e_mul : bocnode->set_c(bocnode->c() / c); break; + default : return error_node(); + } + + result = bocnode; + } + + if (result) + { + details::free_node(*expr_gen.node_allocator_, branch[1]); + } + } + + // c o (boc) --> boc + else if (details::is_boc_node(branch[1])) + { + details::boc_base_node<Type>* bocnode = static_cast<details::boc_base_node<Type>*>(branch[1]); + + const Type c = static_cast<details::literal_node<Type>*>(branch[0])->value(); + + if (details::e_add == bocnode->operation()) + { + if (details::e_add == operation) + { + bocnode->set_c(c + bocnode->c()); + result = bocnode; + } + else if (details::e_sub == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::sub_op<Type> > > + (c - bocnode->c(), bocnode->move_branch(0)); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + } + else if (details::e_sub == bocnode->operation()) + { + if (details::e_add == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::boc_node<Type,details::add_op<Type> > > + (bocnode->move_branch(0), c - bocnode->c()); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + else if (details::e_sub == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::sub_op<Type> > > + (c + bocnode->c(), bocnode->move_branch(0)); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + } + else if (details::e_mul == bocnode->operation()) + { + if (details::e_mul == operation) + { + bocnode->set_c(c * bocnode->c()); + result = bocnode; + } + else if (details::e_div == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::div_op<Type> > > + (c / bocnode->c(), bocnode->move_branch(0)); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + } + else if (details::e_div == bocnode->operation()) + { + if (details::e_mul == operation) + { + bocnode->set_c(bocnode->c() / c); + result = bocnode; + } + else if (details::e_div == operation) + { + result = expr_gen.node_allocator_-> + template allocate_tt<typename details::cob_node<Type,details::div_op<Type> > > + (c * bocnode->c(), bocnode->move_branch(0)); + + details::free_node(*expr_gen.node_allocator_,branch[1]); + } + } + + if (result) + { + details::free_node(*expr_gen.node_allocator_,branch[0]); + } + } + + return result; + } + }; + + #ifndef exprtk_disable_enhanced_features + inline bool synthesize_expression(const details::operator_type& operation, + expression_node_ptr (&branch)[2], + expression_node_ptr& result) + { + result = error_node(); + + if (!operation_optimisable(operation)) + return false; + + const std::string node_id = branch_to_id(branch); + + const typename synthesize_map_t::iterator itr = synthesize_map_.find(node_id); + + if (synthesize_map_.end() != itr) + { + result = itr->second((*this), operation, branch); + + return true; + } + else + return false; + } + + struct synthesize_vov_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const Type& v1 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type& v2 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return expr_gen.node_allocator_-> \ + template allocate_rr<typename details::vov_node<Type,op1<Type> > > \ + (v1, v2); \ + + basic_opr_switch_statements + extended_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + }; + + struct synthesize_cov_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const Type c = static_cast<details::literal_node<Type>*> (branch[0])->value(); + const Type& v = static_cast<details::variable_node<Type>*>(branch[1])->ref (); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + if (std::equal_to<T>()(T(0),c) && (details::e_mul == operation)) + return expr_gen(T(0)); + else if (std::equal_to<T>()(T(0),c) && (details::e_div == operation)) + return expr_gen(T(0)); + else if (std::equal_to<T>()(T(0),c) && (details::e_add == operation)) + return static_cast<details::variable_node<Type>*>(branch[1]); + else if (std::equal_to<T>()(T(1),c) && (details::e_mul == operation)) + return static_cast<details::variable_node<Type>*>(branch[1]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return expr_gen.node_allocator_-> \ + template allocate_cr<typename details::cov_node<Type,op1<Type> > > \ + (c, v); \ + + basic_opr_switch_statements + extended_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + }; + + struct synthesize_voc_expression + { + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + const Type& v = static_cast<details::variable_node<Type>*>(branch[0])->ref (); + const Type c = static_cast<details::literal_node<Type>*> (branch[1])->value(); + + details::free_node(*(expr_gen.node_allocator_), branch[1]); + + if (expr_gen.cardinal_pow_optimisable(operation,c)) + { + if (std::equal_to<T>()(T(1),c)) + return branch[0]; + else + return expr_gen.cardinal_pow_optimisation(v,c); + } + else if (std::equal_to<T>()(T(0),c) && (details::e_mul == operation)) + return expr_gen(T(0)); + else if (std::equal_to<T>()(T(0),c) && (details::e_div == operation)) + return expr_gen(std::numeric_limits<T>::quiet_NaN()); + else if (std::equal_to<T>()(T(0),c) && (details::e_add == operation)) + return static_cast<details::variable_node<Type>*>(branch[0]); + else if (std::equal_to<T>()(T(1),c) && (details::e_mul == operation)) + return static_cast<details::variable_node<Type>*>(branch[0]); + else if (std::equal_to<T>()(T(1),c) && (details::e_div == operation)) + return static_cast<details::variable_node<Type>*>(branch[0]); + + switch (operation) + { + #define case_stmt(op0, op1) \ + case op0 : return expr_gen.node_allocator_-> \ + template allocate_rc<typename details::voc_node<Type,op1<Type> > > \ + (v, c); \ + + basic_opr_switch_statements + extended_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + }; + + struct synthesize_sf3ext_expression + { + template <typename T0, typename T1, typename T2> + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& sf3opr, + T0 t0, T1 t1, T2 t2) + { + switch (sf3opr) + { + #define case_stmt(op) \ + case details::e_sf##op : return details::T0oT1oT2_sf3ext<T,T0,T1,T2,details::sf##op##_op<Type> >:: \ + allocate(*(expr_gen.node_allocator_), t0, t1, t2); \ + + case_stmt(00) case_stmt(01) case_stmt(02) case_stmt(03) + case_stmt(04) case_stmt(05) case_stmt(06) case_stmt(07) + case_stmt(08) case_stmt(09) case_stmt(10) case_stmt(11) + case_stmt(12) case_stmt(13) case_stmt(14) case_stmt(15) + case_stmt(16) case_stmt(17) case_stmt(18) case_stmt(19) + case_stmt(20) case_stmt(21) case_stmt(22) case_stmt(23) + case_stmt(24) case_stmt(25) case_stmt(26) case_stmt(27) + case_stmt(28) case_stmt(29) case_stmt(30) + #undef case_stmt + default : return error_node(); + } + } + + template <typename T0, typename T1, typename T2> + static inline bool compile(expression_generator<Type>& expr_gen, const std::string& id, + T0 t0, T1 t1, T2 t2, + expression_node_ptr& result) + { + details::operator_type sf3opr; + + if (!expr_gen.sf3_optimisable(id,sf3opr)) + return false; + else + result = synthesize_sf3ext_expression::template process<T0, T1, T2> + (expr_gen, sf3opr, t0, t1, t2); + + return true; + } + }; + + struct synthesize_sf4ext_expression + { + template <typename T0, typename T1, typename T2, typename T3> + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& sf4opr, + T0 t0, T1 t1, T2 t2, T3 t3) + { + switch (sf4opr) + { + #define case_stmt0(op) \ + case details::e_sf##op : return details::T0oT1oT2oT3_sf4ext<Type,T0,T1,T2,T3,details::sf##op##_op<Type> >:: \ + allocate(*(expr_gen.node_allocator_), t0, t1, t2, t3); \ + + #define case_stmt1(op) \ + case details::e_sf4ext##op : return details::T0oT1oT2oT3_sf4ext<Type,T0,T1,T2,T3,details::sfext##op##_op<Type> >:: \ + allocate(*(expr_gen.node_allocator_), t0, t1, t2, t3); \ + + case_stmt0(48) case_stmt0(49) case_stmt0(50) case_stmt0(51) + case_stmt0(52) case_stmt0(53) case_stmt0(54) case_stmt0(55) + case_stmt0(56) case_stmt0(57) case_stmt0(58) case_stmt0(59) + case_stmt0(60) case_stmt0(61) case_stmt0(62) case_stmt0(63) + case_stmt0(64) case_stmt0(65) case_stmt0(66) case_stmt0(67) + case_stmt0(68) case_stmt0(69) case_stmt0(70) case_stmt0(71) + case_stmt0(72) case_stmt0(73) case_stmt0(74) case_stmt0(75) + case_stmt0(76) case_stmt0(77) case_stmt0(78) case_stmt0(79) + case_stmt0(80) case_stmt0(81) case_stmt0(82) case_stmt0(83) + + case_stmt1(00) case_stmt1(01) case_stmt1(02) case_stmt1(03) + case_stmt1(04) case_stmt1(05) case_stmt1(06) case_stmt1(07) + case_stmt1(08) case_stmt1(09) case_stmt1(10) case_stmt1(11) + case_stmt1(12) case_stmt1(13) case_stmt1(14) case_stmt1(15) + case_stmt1(16) case_stmt1(17) case_stmt1(18) case_stmt1(19) + case_stmt1(20) case_stmt1(21) case_stmt1(22) case_stmt1(23) + case_stmt1(24) case_stmt1(25) case_stmt1(26) case_stmt1(27) + case_stmt1(28) case_stmt1(29) case_stmt1(30) case_stmt1(31) + case_stmt1(32) case_stmt1(33) case_stmt1(34) case_stmt1(35) + case_stmt1(36) case_stmt1(37) case_stmt1(38) case_stmt1(39) + case_stmt1(40) case_stmt1(41) case_stmt1(42) case_stmt1(43) + case_stmt1(44) case_stmt1(45) case_stmt1(46) case_stmt1(47) + case_stmt1(48) case_stmt1(49) case_stmt1(50) case_stmt1(51) + case_stmt1(52) case_stmt1(53) case_stmt1(54) case_stmt1(55) + case_stmt1(56) case_stmt1(57) case_stmt1(58) case_stmt1(59) + case_stmt1(60) case_stmt1(61) + + #undef case_stmt0 + #undef case_stmt1 + default : return error_node(); + } + } + + template <typename T0, typename T1, typename T2, typename T3> + static inline bool compile(expression_generator<Type>& expr_gen, const std::string& id, + T0 t0, T1 t1, T2 t2, T3 t3, + expression_node_ptr& result) + { + details::operator_type sf4opr; + + if (!expr_gen.sf4_optimisable(id,sf4opr)) + return false; + else + result = synthesize_sf4ext_expression::template process<T0, T1, T2, T3> + (expr_gen, sf4opr, t0, t1, t2, t3); + + return true; + } + + // T o (sf3ext) + template <typename ExternalType> + static inline bool compile_right(expression_generator<Type>& expr_gen, + ExternalType t, + const details::operator_type& operation, + expression_node_ptr& sf3node, + expression_node_ptr& result) + { + if (!details::is_sf3ext_node(sf3node)) + return false; + + typedef details::T0oT1oT2_base_node<Type>* sf3ext_base_ptr; + + sf3ext_base_ptr n = static_cast<sf3ext_base_ptr>(sf3node); + const std::string id = "t" + expr_gen.to_str(operation) + "(" + n->type_id() + ")"; + + switch (n->type()) + { + case details::expression_node<Type>::e_covoc : return compile_right_impl + <typename covoc_t::sf3_type_node,ExternalType, ctype, vtype, ctype> + (expr_gen, id, t, sf3node, result); + + case details::expression_node<Type>::e_covov : return compile_right_impl + <typename covov_t::sf3_type_node,ExternalType, ctype, vtype, vtype> + (expr_gen, id, t, sf3node, result); + + case details::expression_node<Type>::e_vocov : return compile_right_impl + <typename vocov_t::sf3_type_node,ExternalType, vtype, ctype, vtype> + (expr_gen, id, t, sf3node, result); + + case details::expression_node<Type>::e_vovoc : return compile_right_impl + <typename vovoc_t::sf3_type_node,ExternalType, vtype, vtype, ctype> + (expr_gen, id, t, sf3node, result); + + case details::expression_node<Type>::e_vovov : return compile_right_impl + <typename vovov_t::sf3_type_node,ExternalType, vtype, vtype, vtype> + (expr_gen, id, t, sf3node, result); + + default : return false; + } + } + + // (sf3ext) o T + template <typename ExternalType> + static inline bool compile_left(expression_generator<Type>& expr_gen, + ExternalType t, + const details::operator_type& operation, + expression_node_ptr& sf3node, + expression_node_ptr& result) + { + if (!details::is_sf3ext_node(sf3node)) + return false; + + typedef details::T0oT1oT2_base_node<Type>* sf3ext_base_ptr; + + sf3ext_base_ptr n = static_cast<sf3ext_base_ptr>(sf3node); + + const std::string id = "(" + n->type_id() + ")" + expr_gen.to_str(operation) + "t"; + + switch (n->type()) + { + case details::expression_node<Type>::e_covoc : return compile_left_impl + <typename covoc_t::sf3_type_node,ExternalType, ctype, vtype, ctype> + (expr_gen, id, t, sf3node, result); + + case details::expression_node<Type>::e_covov : return compile_left_impl + <typename covov_t::sf3_type_node,ExternalType, ctype, vtype, vtype> + (expr_gen, id, t, sf3node, result); + + case details::expression_node<Type>::e_vocov : return compile_left_impl + <typename vocov_t::sf3_type_node,ExternalType, vtype, ctype, vtype> + (expr_gen, id, t, sf3node, result); + + case details::expression_node<Type>::e_vovoc : return compile_left_impl + <typename vovoc_t::sf3_type_node,ExternalType, vtype, vtype, ctype> + (expr_gen, id, t, sf3node, result); + + case details::expression_node<Type>::e_vovov : return compile_left_impl + <typename vovov_t::sf3_type_node,ExternalType, vtype, vtype, vtype> + (expr_gen, id, t, sf3node, result); + + default : return false; + } + } + + template <typename SF3TypeNode, typename ExternalType, typename T0, typename T1, typename T2> + static inline bool compile_right_impl(expression_generator<Type>& expr_gen, + const std::string& id, + ExternalType t, + expression_node_ptr& node, + expression_node_ptr& result) + { + SF3TypeNode* n = dynamic_cast<SF3TypeNode*>(node); + + if (n) + { + T0 t0 = n->t0(); + T1 t1 = n->t1(); + T2 t2 = n->t2(); + + return synthesize_sf4ext_expression::template compile<ExternalType, T0, T1, T2> + (expr_gen, id, t, t0, t1, t2, result); + } + else + return false; + } + + template <typename SF3TypeNode, typename ExternalType, typename T0, typename T1, typename T2> + static inline bool compile_left_impl(expression_generator<Type>& expr_gen, + const std::string& id, + ExternalType t, + expression_node_ptr& node, + expression_node_ptr& result) + { + SF3TypeNode* n = dynamic_cast<SF3TypeNode*>(node); + + if (n) + { + T0 t0 = n->t0(); + T1 t1 = n->t1(); + T2 t2 = n->t2(); + + return synthesize_sf4ext_expression::template compile<T0, T1, T2, ExternalType> + (expr_gen, id, t0, t1, t2, t, result); + } + else + return false; + } + }; + + struct synthesize_vovov_expression0 + { + typedef typename vovov_t::type0 node_type; + typedef typename vovov_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 v1) o1 (v2) + const details::vov_base_node<Type>* vov = static_cast<details::vov_base_node<Type>*>(branch[0]); + const Type& v0 = vov->v0(); + const Type& v1 = vov->v1(); + const Type& v2 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = vov->operation(); + const details::operator_type o1 = operation; + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v0 / v1) / v2 --> (vovov) v0 / (v1 * v2) + if ((details::e_div == o0) && (details::e_div == o1)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<vtype, vtype, vtype>(expr_gen, "t/(t*t)", v0, v1, v2, result); + + exprtk_debug(("(v0 / v1) / v2 --> (vovov) v0 / (v1 * v2)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<vtype, vtype, vtype> + (expr_gen, id(expr_gen, o0, o1), v0, v1, v2, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t"; + } + }; + + struct synthesize_vovov_expression1 + { + typedef typename vovov_t::type1 node_type; + typedef typename vovov_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0) o0 (v1 o1 v2) + const details::vov_base_node<Type>* vov = static_cast<details::vov_base_node<Type>*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type& v1 = vov->v0(); + const Type& v2 = vov->v1(); + const details::operator_type o0 = operation; + const details::operator_type o1 = vov->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // v0 / (v1 / v2) --> (vovov) (v0 * v2) / v1 + if ((details::e_div == o0) && (details::e_div == o1)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<vtype, vtype, vtype>(expr_gen, "(t*t)/t", v0, v2, v1, result); + + exprtk_debug(("v0 / (v1 / v2) --> (vovov) (v0 * v2) / v1\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<vtype, vtype, vtype> + (expr_gen, id(expr_gen, o0, o1), v0, v1, v2, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)"; + } + }; + + struct synthesize_vovoc_expression0 + { + typedef typename vovoc_t::type0 node_type; + typedef typename vovoc_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 v1) o1 (c) + const details::vov_base_node<Type>* vov = static_cast<details::vov_base_node<Type>*>(branch[0]); + const Type& v0 = vov->v0(); + const Type& v1 = vov->v1(); + const Type c = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const details::operator_type o0 = vov->operation(); + const details::operator_type o1 = operation; + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v0 / v1) / c --> (vovoc) v0 / (v1 * c) + if ((details::e_div == o0) && (details::e_div == o1)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<vtype, vtype, ctype>(expr_gen, "t/(t*t)", v0, v1, c, result); + + exprtk_debug(("(v0 / v1) / c --> (vovoc) v0 / (v1 * c)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<vtype, vtype, ctype> + (expr_gen, id(expr_gen, o0, o1), v0, v1, c, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, c, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t"; + } + }; + + struct synthesize_vovoc_expression1 + { + typedef typename vovoc_t::type1 node_type; + typedef typename vovoc_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0) o0 (v1 o1 c) + const details::voc_base_node<Type>* voc = static_cast<const details::voc_base_node<Type>*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type& v1 = voc->v(); + const Type c = voc->c(); + const details::operator_type o0 = operation; + const details::operator_type o1 = voc->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // v0 / (v1 / c) --> (vocov) (v0 * c) / v1 + if ((details::e_div == o0) && (details::e_div == o1)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<vtype, ctype, vtype>(expr_gen, "(t*t)/t", v0, c, v1, result); + + exprtk_debug(("v0 / (v1 / c) --> (vocov) (v0 * c) / v1\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<vtype, vtype, ctype> + (expr_gen, id(expr_gen, o0, o1), v0, v1, c, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, c, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)"; + } + }; + + struct synthesize_vocov_expression0 + { + typedef typename vocov_t::type0 node_type; + typedef typename vocov_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 c) o1 (v1) + const details::voc_base_node<Type>* voc = static_cast<details::voc_base_node<Type>*>(branch[0]); + const Type& v0 = voc->v(); + const Type c = voc->c(); + const Type& v1 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = voc->operation(); + const details::operator_type o1 = operation; + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v0 / c) / v1 --> (vovoc) v0 / (v1 * c) + if ((details::e_div == o0) && (details::e_div == o1)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<vtype, vtype, ctype>(expr_gen, "t/(t*t)", v0, v1, c, result); + + exprtk_debug(("(v0 / c) / v1 --> (vovoc) v0 / (v1 * c)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<vtype, ctype, vtype> + (expr_gen, id(expr_gen, o0, o1), v0, c, v1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, c, v1, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t"; + } + }; + + struct synthesize_vocov_expression1 + { + typedef typename vocov_t::type1 node_type; + typedef typename vocov_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0) o0 (c o1 v1) + const details::cov_base_node<Type>* cov = static_cast<details::cov_base_node<Type>*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type c = cov->c(); + const Type& v1 = cov->v(); + const details::operator_type o0 = operation; + const details::operator_type o1 = cov->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // v0 / (c / v1) --> (vovoc) (v0 * v1) / c + if ((details::e_div == o0) && (details::e_div == o1)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<vtype, vtype, ctype>(expr_gen, "(t*t)/t", v0, v1, c, result); + + exprtk_debug(("v0 / (c / v1) --> (vovoc) (v0 * v1) / c\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<vtype, ctype, vtype> + (expr_gen, id(expr_gen, o0, o1), v0, c, v1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, c, v1, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)"; + } + }; + + struct synthesize_covov_expression0 + { + typedef typename covov_t::type0 node_type; + typedef typename covov_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (c o0 v0) o1 (v1) + const details::cov_base_node<Type>* cov = static_cast<details::cov_base_node<Type>*>(branch[0]); + const Type c = cov->c(); + const Type& v0 = cov->v(); + const Type& v1 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = cov->operation(); + const details::operator_type o1 = operation; + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (c / v0) / v1 --> (covov) c / (v0 * v1) + if ((details::e_div == o0) && (details::e_div == o1)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t/(t*t)", c, v0, v1, result); + + exprtk_debug(("(c / v0) / v1 --> (covov) c / (v0 * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<ctype, vtype, vtype> + (expr_gen, id(expr_gen, o0, o1), c, v0, v1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), c, v0, v1, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t"; + } + }; + + struct synthesize_covov_expression1 + { + typedef typename covov_t::type1 node_type; + typedef typename covov_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (c) o0 (v0 o1 v1) + const details::vov_base_node<Type>* vov = static_cast<details::vov_base_node<Type>*>(branch[1]); + const Type c = static_cast<details::literal_node<Type>*>(branch[0])->value(); + const Type& v0 = vov->v0(); + const Type& v1 = vov->v1(); + const details::operator_type o0 = operation; + const details::operator_type o1 = vov->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // c / (v0 / v1) --> (covov) (c * v1) / v0 + if ((details::e_div == o0) && (details::e_div == o1)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", c, v1, v0, result); + + exprtk_debug(("c / (v0 / v1) --> (covov) (c * v1) / v0\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<ctype, vtype, vtype> + (expr_gen, id(expr_gen, o0, o1), c, v0, v1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), c, v0, v1, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)"; + } + }; + + struct synthesize_covoc_expression0 + { + typedef typename covoc_t::type0 node_type; + typedef typename covoc_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (c0 o0 v) o1 (c1) + const details::cov_base_node<Type>* cov = static_cast<details::cov_base_node<Type>*>(branch[0]); + const Type c0 = cov->c(); + const Type& v = cov->v(); + const Type c1 = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const details::operator_type o0 = cov->operation(); + const details::operator_type o1 = operation; + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (c0 + v) + c1 --> (cov) (c0 + c1) + v + if ((details::e_add == o0) && (details::e_add == o1)) + { + exprtk_debug(("(c0 + v) + c1 --> (cov) (c0 + c1) + v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::add_op<Type> > >(c0 + c1, v); + } + // (c0 + v) - c1 --> (cov) (c0 - c1) + v + else if ((details::e_add == o0) && (details::e_sub == o1)) + { + exprtk_debug(("(c0 + v) - c1 --> (cov) (c0 - c1) + v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::add_op<Type> > >(c0 - c1, v); + } + // (c0 - v) + c1 --> (cov) (c0 + c1) - v + else if ((details::e_sub == o0) && (details::e_add == o1)) + { + exprtk_debug(("(c0 - v) + c1 --> (cov) (c0 + c1) - v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::sub_op<Type> > >(c0 + c1, v); + } + // (c0 - v) - c1 --> (cov) (c0 - c1) - v + else if ((details::e_sub == o0) && (details::e_sub == o1)) + { + exprtk_debug(("(c0 - v) - c1 --> (cov) (c0 - c1) - v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::sub_op<Type> > >(c0 - c1, v); + } + // (c0 * v) * c1 --> (cov) (c0 * c1) * v + else if ((details::e_mul == o0) && (details::e_mul == o1)) + { + exprtk_debug(("(c0 * v) * c1 --> (cov) (c0 * c1) * v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::mul_op<Type> > >(c0 * c1, v); + } + // (c0 * v) / c1 --> (cov) (c0 / c1) * v + else if ((details::e_mul == o0) && (details::e_div == o1)) + { + exprtk_debug(("(c0 * v) / c1 --> (cov) (c0 / c1) * v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::mul_op<Type> > >(c0 / c1, v); + } + // (c0 / v) * c1 --> (cov) (c0 * c1) / v + else if ((details::e_div == o0) && (details::e_mul == o1)) + { + exprtk_debug(("(c0 / v) * c1 --> (cov) (c0 * c1) / v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::div_op<Type> > >(c0 * c1, v); + } + // (c0 / v) / c1 --> (cov) (c0 / c1) / v + else if ((details::e_div == o0) && (details::e_div == o1)) + { + exprtk_debug(("(c0 / v) / c1 --> (cov) (c0 / c1) / v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::div_op<Type> > >(c0 / c1, v); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<ctype, vtype, ctype> + (expr_gen, id(expr_gen, o0, o1), c0, v, c1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), c0, v, c1, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t"; + } + }; + + struct synthesize_covoc_expression1 + { + typedef typename covoc_t::type1 node_type; + typedef typename covoc_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (c0) o0 (v o1 c1) + const details::voc_base_node<Type>* voc = static_cast<details::voc_base_node<Type>*>(branch[1]); + const Type c0 = static_cast<details::literal_node<Type>*>(branch[0])->value(); + const Type& v = voc->v(); + const Type c1 = voc->c(); + const details::operator_type o0 = operation; + const details::operator_type o1 = voc->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (c0) + (v + c1) --> (cov) (c0 + c1) + v + if ((details::e_add == o0) && (details::e_add == o1)) + { + exprtk_debug(("(c0) + (v + c1) --> (cov) (c0 + c1) + v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::add_op<Type> > >(c0 + c1, v); + } + // (c0) + (v - c1) --> (cov) (c0 - c1) + v + else if ((details::e_add == o0) && (details::e_sub == o1)) + { + exprtk_debug(("(c0) + (v - c1) --> (cov) (c0 - c1) + v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::add_op<Type> > >(c0 - c1, v); + } + // (c0) - (v + c1) --> (cov) (c0 - c1) - v + else if ((details::e_sub == o0) && (details::e_add == o1)) + { + exprtk_debug(("(c0) - (v + c1) --> (cov) (c0 - c1) - v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::sub_op<Type> > >(c0 - c1, v); + } + // (c0) - (v - c1) --> (cov) (c0 + c1) - v + else if ((details::e_sub == o0) && (details::e_sub == o1)) + { + exprtk_debug(("(c0) - (v - c1) --> (cov) (c0 + c1) - v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::sub_op<Type> > >(c0 + c1, v); + } + // (c0) * (v * c1) --> (voc) v * (c0 * c1) + else if ((details::e_mul == o0) && (details::e_mul == o1)) + { + exprtk_debug(("(c0) * (v * c1) --> (voc) v * (c0 * c1)\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::mul_op<Type> > >(c0 * c1, v); + } + // (c0) * (v / c1) --> (cov) (c0 / c1) * v + else if ((details::e_mul == o0) && (details::e_div == o1)) + { + exprtk_debug(("(c0) * (v / c1) --> (cov) (c0 / c1) * v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::mul_op<Type> > >(c0 / c1, v); + } + // (c0) / (v * c1) --> (cov) (c0 / c1) / v + else if ((details::e_div == o0) && (details::e_mul == o1)) + { + exprtk_debug(("(c0) / (v * c1) --> (cov) (c0 / c1) / v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::div_op<Type> > >(c0 / c1, v); + } + // (c0) / (v / c1) --> (cov) (c0 * c1) / v + else if ((details::e_div == o0) && (details::e_div == o1)) + { + exprtk_debug(("(c0) / (v / c1) --> (cov) (c0 * c1) / v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::div_op<Type> > >(c0 * c1, v); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<ctype, vtype, ctype> + (expr_gen, id(expr_gen, o0, o1), c0, v, c1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), c0, v, c1, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)"; + } + }; + + struct synthesize_cocov_expression0 + { + typedef typename cocov_t::type0 node_type; + static inline expression_node_ptr process(expression_generator<Type>&, + const details::operator_type&, + expression_node_ptr (&)[2]) + { + // (c0 o0 c1) o1 (v) - Not possible. + return error_node(); + } + }; + + struct synthesize_cocov_expression1 + { + typedef typename cocov_t::type1 node_type; + typedef typename cocov_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (c0) o0 (c1 o1 v) + const details::cov_base_node<Type>* cov = static_cast<details::cov_base_node<Type>*>(branch[1]); + const Type c0 = static_cast<details::literal_node<Type>*>(branch[0])->value(); + const Type c1 = cov->c(); + const Type& v = cov->v(); + const details::operator_type o0 = operation; + const details::operator_type o1 = cov->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (c0) + (c1 + v) --> (cov) (c0 + c1) + v + if ((details::e_add == o0) && (details::e_add == o1)) + { + exprtk_debug(("(c0) + (c1 + v) --> (cov) (c0 + c1) + v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::add_op<Type> > >(c0 + c1, v); + } + // (c0) + (c1 - v) --> (cov) (c0 + c1) - v + else if ((details::e_add == o0) && (details::e_sub == o1)) + { + exprtk_debug(("(c0) + (c1 - v) --> (cov) (c0 + c1) - v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::sub_op<Type> > >(c0 + c1, v); + } + // (c0) - (c1 + v) --> (cov) (c0 - c1) - v + else if ((details::e_sub == o0) && (details::e_add == o1)) + { + exprtk_debug(("(c0) - (c1 + v) --> (cov) (c0 - c1) - v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::sub_op<Type> > >(c0 - c1, v); + } + // (c0) - (c1 - v) --> (cov) (c0 - c1) + v + else if ((details::e_sub == o0) && (details::e_sub == o1)) + { + exprtk_debug(("(c0) - (c1 - v) --> (cov) (c0 - c1) + v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::add_op<Type> > >(c0 - c1, v); + } + // (c0) * (c1 * v) --> (cov) (c0 * c1) * v + else if ((details::e_mul == o0) && (details::e_mul == o1)) + { + exprtk_debug(("(c0) * (c1 * v) --> (cov) (c0 * c1) * v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::mul_op<Type> > >(c0 * c1, v); + } + // (c0) * (c1 / v) --> (cov) (c0 * c1) / v + else if ((details::e_mul == o0) && (details::e_div == o1)) + { + exprtk_debug(("(c0) * (c1 / v) --> (cov) (c0 * c1) / v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::div_op<Type> > >(c0 * c1, v); + } + // (c0) / (c1 * v) --> (cov) (c0 / c1) / v + else if ((details::e_div == o0) && (details::e_mul == o1)) + { + exprtk_debug(("(c0) / (c1 * v) --> (cov) (c0 / c1) / v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::div_op<Type> > >(c0 / c1, v); + } + // (c0) / (c1 / v) --> (cov) (c0 / c1) * v + else if ((details::e_div == o0) && (details::e_div == o1)) + { + exprtk_debug(("(c0) / (c1 / v) --> (cov) (c0 / c1) * v\n")); + + return expr_gen.node_allocator_-> + template allocate_cr<typename details::cov_node<Type,details::mul_op<Type> > >(c0 / c1, v); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<ctype, ctype, vtype> + (expr_gen, id(expr_gen, o0, o1), c0, c1, v, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), c0, c1, v, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)"; + } + }; + + struct synthesize_vococ_expression0 + { + typedef typename vococ_t::type0 node_type; + typedef typename vococ_t::sf3_type sf3_type; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v o0 c0) o1 (c1) + const details::voc_base_node<Type>* voc = static_cast<details::voc_base_node<Type>*>(branch[0]); + const Type& v = voc->v(); + const Type& c0 = voc->c(); + const Type& c1 = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const details::operator_type o0 = voc->operation(); + const details::operator_type o1 = operation; + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v + c0) + c1 --> (voc) v + (c0 + c1) + if ((details::e_add == o0) && (details::e_add == o1)) + { + exprtk_debug(("(v + c0) + c1 --> (voc) v + (c0 + c1)\n")); + + return expr_gen.node_allocator_-> + template allocate_rc<typename details::voc_node<Type,details::add_op<Type> > >(v, c0 + c1); + } + // (v + c0) - c1 --> (voc) v + (c0 - c1) + else if ((details::e_add == o0) && (details::e_sub == o1)) + { + exprtk_debug(("(v + c0) - c1 --> (voc) v + (c0 - c1)\n")); + + return expr_gen.node_allocator_-> + template allocate_rc<typename details::voc_node<Type,details::add_op<Type> > >(v, c0 - c1); + } + // (v - c0) + c1 --> (voc) v - (c0 + c1) + else if ((details::e_sub == o0) && (details::e_add == o1)) + { + exprtk_debug(("(v - c0) + c1 --> (voc) v - (c0 + c1)\n")); + + return expr_gen.node_allocator_-> + template allocate_rc<typename details::voc_node<Type,details::add_op<Type> > >(v, c1 - c0); + } + // (v - c0) - c1 --> (voc) v - (c0 + c1) + else if ((details::e_sub == o0) && (details::e_sub == o1)) + { + exprtk_debug(("(v - c0) - c1 --> (voc) v - (c0 + c1)\n")); + + return expr_gen.node_allocator_-> + template allocate_rc<typename details::voc_node<Type,details::sub_op<Type> > >(v, c0 + c1); + } + // (v * c0) * c1 --> (voc) v * (c0 * c1) + else if ((details::e_mul == o0) && (details::e_mul == o1)) + { + exprtk_debug(("(v * c0) * c1 --> (voc) v * (c0 * c1)\n")); + + return expr_gen.node_allocator_-> + template allocate_rc<typename details::voc_node<Type,details::mul_op<Type> > >(v, c0 * c1); + } + // (v * c0) / c1 --> (voc) v * (c0 / c1) + else if ((details::e_mul == o0) && (details::e_div == o1)) + { + exprtk_debug(("(v * c0) / c1 --> (voc) v * (c0 / c1)\n")); + + return expr_gen.node_allocator_-> + template allocate_rc<typename details::voc_node<Type,details::mul_op<Type> > >(v, c0 / c1); + } + // (v / c0) * c1 --> (voc) v * (c1 / c0) + else if ((details::e_div == o0) && (details::e_mul == o1)) + { + exprtk_debug(("(v / c0) * c1 --> (voc) v * (c1 / c0)\n")); + + return expr_gen.node_allocator_-> + template allocate_rc<typename details::voc_node<Type,details::mul_op<Type> > >(v, c1 / c0); + } + // (v / c0) / c1 --> (voc) v / (c0 * c1) + else if ((details::e_div == o0) && (details::e_div == o1)) + { + exprtk_debug(("(v / c0) / c1 --> (voc) v / (c0 * c1)\n")); + + return expr_gen.node_allocator_-> + template allocate_rc<typename details::voc_node<Type,details::div_op<Type> > >(v, c0 * c1); + } + // (v ^ c0) ^ c1 --> (voc) v ^ (c0 * c1) + else if ((details::e_pow == o0) && (details::e_pow == o1)) + { + exprtk_debug(("(v ^ c0) ^ c1 --> (voc) v ^ (c0 * c1)\n")); + + return expr_gen.node_allocator_-> + template allocate_rc<typename details::voc_node<Type,details::pow_op<Type> > >(v, c0 * c1); + } + } + + const bool synthesis_result = + synthesize_sf3ext_expression::template compile<vtype, ctype, ctype> + (expr_gen, id(expr_gen, o0, o1), v, c0, c1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v, c0, c1, f0, f1); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t"; + } + }; + + struct synthesize_vococ_expression1 + { + typedef typename vococ_t::type0 node_type; + + static inline expression_node_ptr process(expression_generator<Type>&, + const details::operator_type&, + expression_node_ptr (&)[2]) + { + // (v) o0 (c0 o1 c1) - Not possible. + exprtk_debug(("(v) o0 (c0 o1 c1) - Not possible.\n")); + return error_node(); + } + }; + + struct synthesize_vovovov_expression0 + { + typedef typename vovovov_t::type0 node_type; + typedef typename vovovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 v1) o1 (v2 o2 v3) + const details::vov_base_node<Type>* vov0 = static_cast<details::vov_base_node<Type>*>(branch[0]); + const details::vov_base_node<Type>* vov1 = static_cast<details::vov_base_node<Type>*>(branch[1]); + const Type& v0 = vov0->v0(); + const Type& v1 = vov0->v1(); + const Type& v2 = vov1->v0(); + const Type& v3 = vov1->v1(); + const details::operator_type o0 = vov0->operation(); + const details::operator_type o1 = operation; + const details::operator_type o2 = vov1->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v0 / v1) * (v2 / v3) --> (vovovov) (v0 * v2) / (v1 * v3) + if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, vtype, vtype, vtype>(expr_gen, "(t*t)/(t*t)", v0, v2, v1, v3, result); + + exprtk_debug(("(v0 / v1) * (v2 / v3) --> (vovovov) (v0 * v2) / (v1 * v3)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / v1) / (v2 / v3) --> (vovovov) (v0 * v3) / (v1 * v2) + else if ((details::e_div == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, vtype, vtype, vtype>(expr_gen, "(t*t)/(t*t)", v0, v3, v1, v2, result); + + exprtk_debug(("(v0 / v1) / (v2 / v3) --> (vovovov) (v0 * v3) / (v1 * v2)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 + v1) / (v2 / v3) --> (vovovov) (v0 + v1) * (v3 / v2) + else if ((details::e_add == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, vtype, vtype, vtype>(expr_gen, "(t+t)*(t/t)", v0, v1, v3, v2, result); + + exprtk_debug(("(v0 + v1) / (v2 / v3) --> (vovovov) (v0 + v1) * (v3 / v2)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 - v1) / (v2 / v3) --> (vovovov) (v0 + v1) * (v3 / v2) + else if ((details::e_sub == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, vtype, vtype, vtype>(expr_gen, "(t-t)*(t/t)", v0, v1, v3, v2, result); + + exprtk_debug(("(v0 - v1) / (v2 / v3) --> (vovovov) (v0 - v1) * (v3 / v2)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 * v1) / (v2 / v3) --> (vovovov) ((v0 * v1) * v3) / v2 + else if ((details::e_mul == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, vtype, vtype, vtype>(expr_gen, "((t*t)*t)/t", v0, v1, v3, v2, result); + + exprtk_debug(("(v0 * v1) / (v2 / v3) --> (vovovov) ((v0 * v1) * v3) / v2\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, v2, v3, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, v3, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vovovoc_expression0 + { + typedef typename vovovoc_t::type0 node_type; + typedef typename vovovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 v1) o1 (v2 o2 c) + const details::vov_base_node<Type>* vov = static_cast<details::vov_base_node<Type>*>(branch[0]); + const details::voc_base_node<Type>* voc = static_cast<details::voc_base_node<Type>*>(branch[1]); + const Type& v0 = vov->v0(); + const Type& v1 = vov->v1(); + const Type& v2 = voc->v (); + const Type c = voc->c (); + const details::operator_type o0 = vov->operation(); + const details::operator_type o1 = operation; + const details::operator_type o2 = voc->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v0 / v1) * (v2 / c) --> (vovovoc) (v0 * v2) / (v1 * c) + if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, vtype, vtype, ctype>(expr_gen, "(t*t)/(t*t)", v0, v2, v1, c, result); + + exprtk_debug(("(v0 / v1) * (v2 / c) --> (vovovoc) (v0 * v2) / (v1 * c)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / v1) / (v2 / c) --> (vocovov) (v0 * c) / (v1 * v2) + if ((details::e_div == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, ctype, vtype, vtype>(expr_gen, "(t*t)/(t*t)", v0, c, v1, v2, result); + + exprtk_debug(("(v0 / v1) / (v2 / c) --> (vocovov) (v0 * c) / (v1 * v2)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, v2, c, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, c, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vovocov_expression0 + { + typedef typename vovocov_t::type0 node_type; + typedef typename vovocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 v1) o1 (c o2 v2) + const details::vov_base_node<Type>* vov = static_cast<details::vov_base_node<Type>*>(branch[0]); + const details::cov_base_node<Type>* cov = static_cast<details::cov_base_node<Type>*>(branch[1]); + const Type& v0 = vov->v0(); + const Type& v1 = vov->v1(); + const Type& v2 = cov->v (); + const Type c = cov->c (); + const details::operator_type o0 = vov->operation(); + const details::operator_type o1 = operation; + const details::operator_type o2 = cov->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v0 / v1) * (c / v2) --> (vocovov) (v0 * c) / (v1 * v2) + if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, ctype, vtype, vtype>(expr_gen, "(t*t)/(t*t)", v0, c, v1, v2, result); + + exprtk_debug(("(v0 / v1) * (c / v2) --> (vocovov) (v0 * c) / (v1 * v2)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / v1) / (c / v2) --> (vovovoc) (v0 * v2) / (v1 * c) + if ((details::e_div == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, vtype, vtype, ctype>(expr_gen, "(t*t)/(t*t)", v0, v2, v1, c, result); + + exprtk_debug(("(v0 / v1) / (c / v2) --> (vovovoc) (v0 * v2) / (v1 * c)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, c, v2, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, c, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vocovov_expression0 + { + typedef typename vocovov_t::type0 node_type; + typedef typename vocovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 c) o1 (v1 o2 v2) + const details::voc_base_node<Type>* voc = static_cast<details::voc_base_node<Type>*>(branch[0]); + const details::vov_base_node<Type>* vov = static_cast<details::vov_base_node<Type>*>(branch[1]); + const Type c = voc->c (); + const Type& v0 = voc->v (); + const Type& v1 = vov->v0(); + const Type& v2 = vov->v1(); + const details::operator_type o0 = voc->operation(); + const details::operator_type o1 = operation; + const details::operator_type o2 = vov->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v0 / c) * (v1 / v2) --> (vovocov) (v0 * v1) / (c * v2) + if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, vtype, ctype, vtype>(expr_gen, "(t*t)/(t*t)", v0, v1, c, v2, result); + + exprtk_debug(("(v0 / c) * (v1 / v2) --> (vovocov) (v0 * v1) / (c * v2)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c) / (v1 / v2) --> (vovocov) (v0 * v2) / (c * v1) + if ((details::e_div == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, vtype, ctype, vtype>(expr_gen, "(t*t)/(t*t)", v0, v2, c, v1, result); + + exprtk_debug(("(v0 / c) / (v1 / v2) --> (vovocov) (v0 * v2) / (c * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c, v1, v2, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, c, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_covovov_expression0 + { + typedef typename covovov_t::type0 node_type; + typedef typename covovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (c o0 v0) o1 (v1 o2 v2) + const details::cov_base_node<Type>* cov = static_cast<details::cov_base_node<Type>*>(branch[0]); + const details::vov_base_node<Type>* vov = static_cast<details::vov_base_node<Type>*>(branch[1]); + const Type c = cov->c (); + const Type& v0 = cov->v (); + const Type& v1 = vov->v0(); + const Type& v2 = vov->v1(); + const details::operator_type o0 = cov->operation(); + const details::operator_type o1 = operation; + const details::operator_type o2 = vov->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (c / v0) * (v1 / v2) --> (covovov) (c * v1) / (v0 * v2) + if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<ctype, vtype, vtype, vtype>(expr_gen, "(t*t)/(t*t)", c, v1, v0, v2, result); + + exprtk_debug(("(c / v0) * (v1 / v2) --> (covovov) (c * v1) / (v0 * v2)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c / v0) / (v1 / v2) --> (covovov) (c * v2) / (v0 * v1) + if ((details::e_div == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<ctype, vtype, vtype, vtype>(expr_gen, "(t*t)/(t*t)", c, v2, v0, v1, result); + + exprtk_debug(("(c / v0) / (v1 / v2) --> (covovov) (c * v2) / (v0 * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c, v0, v1, v2, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), c, v0, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_covocov_expression0 + { + typedef typename covocov_t::type0 node_type; + typedef typename covocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (c0 o0 v0) o1 (c1 o2 v1) + const details::cov_base_node<Type>* cov0 = static_cast<details::cov_base_node<Type>*>(branch[0]); + const details::cov_base_node<Type>* cov1 = static_cast<details::cov_base_node<Type>*>(branch[1]); + const Type c0 = cov0->c(); + const Type& v0 = cov0->v(); + const Type c1 = cov1->c(); + const Type& v1 = cov1->v(); + const details::operator_type o0 = cov0->operation(); + const details::operator_type o1 = operation; + const details::operator_type o2 = cov1->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (c0 + v0) + (c1 + v1) --> (covov) (c0 + c1) + v0 + v1 + if ((details::e_add == o0) && (details::e_add == o1) && (details::e_add == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t+t)+t", (c0 + c1), v0, v1, result); + + exprtk_debug(("(c0 + v0) + (c1 + v1) --> (covov) (c0 + c1) + v0 + v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 + v0) - (c1 + v1) --> (covov) (c0 - c1) + v0 - v1 + else if ((details::e_add == o0) && (details::e_sub == o1) && (details::e_add == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t+t)-t", (c0 - c1), v0, v1, result); + + exprtk_debug(("(c0 + v0) - (c1 + v1) --> (covov) (c0 - c1) + v0 - v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 - v0) - (c1 - v1) --> (covov) (c0 - c1) - v0 + v1 + else if ((details::e_sub == o0) && (details::e_sub == o1) && (details::e_sub == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t-t)+t", (c0 - c1), v0, v1, result); + + exprtk_debug(("(c0 - v0) - (c1 - v1) --> (covov) (c0 - c1) - v0 + v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 * v0) * (c1 * v1) --> (covov) (c0 * c1) * v0 * v1 + else if ((details::e_mul == o0) && (details::e_mul == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)*t", (c0 * c1), v0, v1, result); + + exprtk_debug(("(c0 * v0) * (c1 * v1) --> (covov) (c0 * c1) * v0 * v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 * v0) / (c1 * v1) --> (covov) (c0 / c1) * (v0 / v1) + else if ((details::e_mul == o0) && (details::e_div == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", (c0 / c1), v0, v1, result); + + exprtk_debug(("(c0 * v0) / (c1 * v1) --> (covov) (c0 / c1) * (v0 / v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 / v0) * (c1 / v1) --> (covov) (c0 * c1) / (v0 * v1) + else if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t/(t*t)", (c0 * c1), v0, v1, result); + + exprtk_debug(("(c0 / v0) * (c1 / v1) --> (covov) (c0 * c1) / (v0 * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 / v0) / (c1 / v1) --> (covov) ((c0 / c1) * v1) / v0 + else if ((details::e_div == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", (c0 / c1), v1, v0, result); + + exprtk_debug(("(c0 / v0) / (c1 / v1) --> (covov) ((c0 / c1) * v1) / v0\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 * v0) / (c1 / v1) --> (covov) (c0 / c1) * (v0 * v1) + else if ((details::e_mul == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t*(t*t)", (c0 / c1), v0, v1, result); + + exprtk_debug(("(c0 * v0) / (c1 / v1) --> (covov) (c0 / c1) * (v0 * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 / v0) / (c1 * v1) --> (covov) (c0 / c1) / (v0 * v1) + else if ((details::e_div == o0) && (details::e_div == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t/(t*t)", (c0 / c1), v0, v1, result); + + exprtk_debug(("(c0 / v0) / (c1 * v1) --> (covov) (c0 / c1) / (v0 * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c * v0) +/- (c * v1) --> (covov) c * (v0 +/- v1) + else if ( + (std::equal_to<T>()(c0,c1)) && + (details::e_mul == o0) && + (details::e_mul == o2) && + ( + (details::e_add == o1) || + (details::e_sub == o1) + ) + ) + { + std::string specfunc; + + switch (o1) + { + case details::e_add : specfunc = "t*(t+t)"; break; + case details::e_sub : specfunc = "t*(t-t)"; break; + default : return error_node(); + } + + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, specfunc, c0, v0, v1, result); + + exprtk_debug(("(c * v0) +/- (c * v1) --> (covov) c * (v0 +/- v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, c1, v1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, c1, v1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vocovoc_expression0 + { + typedef typename vocovoc_t::type0 node_type; + typedef typename vocovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 c0) o1 (v1 o2 c1) + const details::voc_base_node<Type>* voc0 = static_cast<details::voc_base_node<Type>*>(branch[0]); + const details::voc_base_node<Type>* voc1 = static_cast<details::voc_base_node<Type>*>(branch[1]); + const Type c0 = voc0->c(); + const Type& v0 = voc0->v(); + const Type c1 = voc1->c(); + const Type& v1 = voc1->v(); + const details::operator_type o0 = voc0->operation(); + const details::operator_type o1 = operation; + const details::operator_type o2 = voc1->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v0 + c0) + (v1 + c1) --> (covov) (c0 + c1) + v0 + v1 + if ((details::e_add == o0) && (details::e_add == o1) && (details::e_add == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t+t)+t", (c0 + c1), v0, v1, result); + + exprtk_debug(("(v0 + c0) + (v1 + c1) --> (covov) (c0 + c1) + v0 + v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 + c0) - (v1 + c1) --> (covov) (c0 - c1) + v0 - v1 + else if ((details::e_add == o0) && (details::e_sub == o1) && (details::e_add == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t+t)-t", (c0 - c1), v0, v1, result); + + exprtk_debug(("(v0 + c0) - (v1 + c1) --> (covov) (c0 - c1) + v0 - v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 - c0) - (v1 - c1) --> (covov) (c1 - c0) + v0 - v1 + else if ((details::e_sub == o0) && (details::e_sub == o1) && (details::e_sub == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t+t)-t", (c1 - c0), v0, v1, result); + + exprtk_debug(("(v0 - c0) - (v1 - c1) --> (covov) (c1 - c0) + v0 - v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 * c0) * (v1 * c1) --> (covov) (c0 * c1) * v0 * v1 + else if ((details::e_mul == o0) && (details::e_mul == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)*t", (c0 * c1), v0, v1, result); + + exprtk_debug(("(v0 * c0) * (v1 * c1) --> (covov) (c0 * c1) * v0 * v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 * c0) / (v1 * c1) --> (covov) (c0 / c1) * (v0 / v1) + else if ((details::e_mul == o0) && (details::e_div == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", (c0 / c1), v0, v1, result); + + exprtk_debug(("(v0 * c0) / (v1 * c1) --> (covov) (c0 / c1) * (v0 / v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c0) * (v1 / c1) --> (covov) (1 / (c0 * c1)) * v0 * v1 + else if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)*t", Type(1) / (c0 * c1), v0, v1, result); + + exprtk_debug(("(v0 / c0) * (v1 / c1) --> (covov) (1 / (c0 * c1)) * v0 * v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c0) / (v1 / c1) --> (covov) ((c1 / c0) * v0) / v1 + else if ((details::e_div == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", (c1 / c0), v0, v1, result); + + exprtk_debug(("(v0 / c0) / (v1 / c1) --> (covov) ((c1 / c0) * v0) / v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 * c0) / (v1 / c1) --> (covov) (c0 * c1) * (v0 / v1) + else if ((details::e_mul == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t*(t/t)", (c0 * c1), v0, v1, result); + + exprtk_debug(("(v0 * c0) / (v1 / c1) --> (covov) (c0 * c1) * (v0 / v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c0) / (v1 * c1) --> (covov) (1 / (c0 * c1)) * v0 / v1 + else if ((details::e_div == o0) && (details::e_div == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t*(t/t)", Type(1) / (c0 * c1), v0, v1, result); + + exprtk_debug(("(v0 / c0) / (v1 * c1) --> (covov) (1 / (c0 * c1)) * v0 / v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c0) * (v1 + c1) --> (vocovoc) (v0 * (1 / c0)) * (v1 + c1) + else if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_add == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, ctype, vtype, ctype>(expr_gen, "(t*t)*(t+t)", v0, T(1) / c0, v1, c1, result); + + exprtk_debug(("(v0 / c0) * (v1 + c1) --> (vocovoc) (v0 * (1 / c0)) * (v1 + c1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c0) * (v1 - c1) --> (vocovoc) (v0 * (1 / c0)) * (v1 - c1) + else if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_sub == o2)) + { + const bool synthesis_result = + synthesize_sf4ext_expression:: + template compile<vtype, ctype, vtype, ctype>(expr_gen, "(t*t)*(t-t)", v0, T(1) / c0, v1, c1, result); + + exprtk_debug(("(v0 / c0) * (v1 - c1) --> (vocovoc) (v0 * (1 / c0)) * (v1 - c1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 * c) +/- (v1 * c) --> (covov) c * (v0 +/- v1) + else if ( + (std::equal_to<T>()(c0,c1)) && + (details::e_mul == o0) && + (details::e_mul == o2) && + ( + (details::e_add == o1) || + (details::e_sub == o1) + ) + ) + { + std::string specfunc; + + switch (o1) + { + case details::e_add : specfunc = "t*(t+t)"; break; + case details::e_sub : specfunc = "t*(t-t)"; break; + default : return error_node(); + } + + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, specfunc, c0, v0, v1, result); + + exprtk_debug(("(v0 * c) +/- (v1 * c) --> (covov) c * (v0 +/- v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c) +/- (v1 / c) --> (vovoc) (v0 +/- v1) / c + else if ( + (std::equal_to<T>()(c0,c1)) && + (details::e_div == o0) && + (details::e_div == o2) && + ( + (details::e_add == o1) || + (details::e_sub == o1) + ) + ) + { + std::string specfunc; + + switch (o1) + { + case details::e_add : specfunc = "(t+t)/t"; break; + case details::e_sub : specfunc = "(t-t)/t"; break; + default : return error_node(); + } + + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<vtype, vtype, ctype>(expr_gen, specfunc, v0, v1, c0, result); + + exprtk_debug(("(v0 / c) +/- (v1 / c) --> (vovoc) (v0 +/- v1) / c\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c0, v1, c1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, c0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_covovoc_expression0 + { + typedef typename covovoc_t::type0 node_type; + typedef typename covovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (c0 o0 v0) o1 (v1 o2 c1) + const details::cov_base_node<Type>* cov = static_cast<details::cov_base_node<Type>*>(branch[0]); + const details::voc_base_node<Type>* voc = static_cast<details::voc_base_node<Type>*>(branch[1]); + const Type c0 = cov->c(); + const Type& v0 = cov->v(); + const Type c1 = voc->c(); + const Type& v1 = voc->v(); + const details::operator_type o0 = cov->operation(); + const details::operator_type o1 = operation; + const details::operator_type o2 = voc->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (c0 + v0) + (v1 + c1) --> (covov) (c0 + c1) + v0 + v1 + if ((details::e_add == o0) && (details::e_add == o1) && (details::e_add == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t+t)+t", (c0 + c1), v0, v1, result); + + exprtk_debug(("(c0 + v0) + (v1 + c1) --> (covov) (c0 + c1) + v0 + v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 + v0) - (v1 + c1) --> (covov) (c0 - c1) + v0 - v1 + else if ((details::e_add == o0) && (details::e_sub == o1) && (details::e_add == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t+t)-t", (c0 - c1), v0, v1, result); + + exprtk_debug(("(c0 + v0) - (v1 + c1) --> (covov) (c0 - c1) + v0 - v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 - v0) - (v1 - c1) --> (covov) (c0 + c1) - v0 - v1 + else if ((details::e_sub == o0) && (details::e_sub == o1) && (details::e_sub == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t-(t+t)", (c0 + c1), v0, v1, result); + + exprtk_debug(("(c0 - v0) - (v1 - c1) --> (covov) (c0 + c1) - v0 - v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 * v0) * (v1 * c1) --> (covov) (c0 * c1) * v0 * v1 + else if ((details::e_mul == o0) && (details::e_mul == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)*t", (c0 * c1), v0, v1, result); + + exprtk_debug(("(c0 * v0) * (v1 * c1) --> (covov) (c0 * c1) * v0 * v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 * v0) / (v1 * c1) --> (covov) (c0 / c1) * (v0 / v1) + else if ((details::e_mul == o0) && (details::e_div == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", (c0 / c1), v0, v1, result); + + exprtk_debug(("(c0 * v0) / (v1 * c1) --> (covov) (c0 / c1) * (v0 / v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 / v0) * (v1 / c1) --> (covov) (c0 / c1) * (v1 / v0) + else if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t*(t/t)", (c0 / c1), v1, v0, result); + + exprtk_debug(("(c0 / v0) * (v1 / c1) --> (covov) (c0 / c1) * (v1 / v0)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 / v0) / (v1 / c1) --> (covov) (c0 * c1) / (v0 * v1) + else if ((details::e_div == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t/(t*t)", (c0 * c1), v0, v1, result); + + exprtk_debug(("(c0 / v0) / (v1 / c1) --> (covov) (c0 * c1) / (v0 * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 * v0) / (v1 / c1) --> (covov) (c0 * c1) * (v0 / v1) + else if ((details::e_mul == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", (c0 * c1), v0, v1, result); + + exprtk_debug(("(c0 * v0) / (v1 / c1) --> (covov) (c0 * c1) * (v0 / v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c0 / v0) / (v1 * c1) --> (covov) (c0 / c1) / (v0 * v1) + else if ((details::e_div == o0) && (details::e_div == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "t/(t*t)", (c0 / c1), v0, v1, result); + + exprtk_debug(("(c0 / v0) / (v1 * c1) --> (covov) (c0 / c1) / (v0 * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (c * v0) +/- (v1 * c) --> (covov) c * (v0 +/- v1) + else if ( + (std::equal_to<T>()(c0,c1)) && + (details::e_mul == o0) && + (details::e_mul == o2) && + ( + (details::e_add == o1) || + (details::e_sub == o1) + ) + ) + { + std::string specfunc; + + switch (o1) + { + case details::e_add : specfunc = "t*(t+t)"; break; + case details::e_sub : specfunc = "t*(t-t)"; break; + default : return error_node(); + } + + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, specfunc, c0, v0, v1, result); + + exprtk_debug(("(c * v0) +/- (v1 * c) --> (covov) c * (v0 +/- v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, v1, c1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vococov_expression0 + { + typedef typename vococov_t::type0 node_type; + typedef typename vococov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 c0) o1 (c1 o2 v1) + const details::voc_base_node<Type>* voc = static_cast<details::voc_base_node<Type>*>(branch[0]); + const details::cov_base_node<Type>* cov = static_cast<details::cov_base_node<Type>*>(branch[1]); + const Type c0 = voc->c(); + const Type& v0 = voc->v(); + const Type c1 = cov->c(); + const Type& v1 = cov->v(); + const details::operator_type o0 = voc->operation(); + const details::operator_type o1 = operation; + const details::operator_type o2 = cov->operation(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + if (expr_gen.parser_->settings_.strength_reduction_enabled()) + { + // (v0 + c0) + (c1 + v1) --> (covov) (c0 + c1) + v0 + v1 + if ((details::e_add == o0) && (details::e_add == o1) && (details::e_add == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t+t)+t", (c0 + c1), v0, v1, result); + + exprtk_debug(("(v0 + c0) + (c1 + v1) --> (covov) (c0 + c1) + v0 + v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 + c0) - (c1 + v1) --> (covov) (c0 - c1) + v0 - v1 + else if ((details::e_add == o0) && (details::e_sub == o1) && (details::e_add == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t+t)-t", (c0 - c1), v0, v1, result); + + exprtk_debug(("(v0 + c0) - (c1 + v1) --> (covov) (c0 - c1) + v0 - v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 - c0) - (c1 - v1) --> (vovoc) v0 + v1 - (c1 + c0) + else if ((details::e_sub == o0) && (details::e_sub == o1) && (details::e_sub == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<vtype, vtype, ctype>(expr_gen, "(t+t)-t", v0, v1, (c1 + c0), result); + + exprtk_debug(("(v0 - c0) - (c1 - v1) --> (vovoc) v0 + v1 - (c1 + c0)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 * c0) * (c1 * v1) --> (covov) (c0 * c1) * v0 * v1 + else if ((details::e_mul == o0) && (details::e_mul == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)*t", (c0 * c1), v0, v1, result); + + exprtk_debug(("(v0 * c0) * (c1 * v1) --> (covov) (c0 * c1) * v0 * v1\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 * c0) / (c1 * v1) --> (covov) (c0 / c1) * (v0 * v1) + else if ((details::e_mul == o0) && (details::e_div == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", (c0 / c1), v0, v1, result); + + exprtk_debug(("(v0 * c0) / (c1 * v1) --> (covov) (c0 / c1) * (v0 * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c0) * (c1 / v1) --> (covov) (c1 / c0) * (v0 / v1) + else if ((details::e_div == o0) && (details::e_mul == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", (c1 / c0), v0, v1, result); + + exprtk_debug(("(v0 / c0) * (c1 / v1) --> (covov) (c1 / c0) * (v0 / v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 * c0) / (c1 / v1) --> (covov) (c0 / c1) * (v0 * v1) + else if ((details::e_mul == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)*t", (c0 / c1), v0, v1, result); + + exprtk_debug(("(v0 * c0) / (c1 / v1) --> (covov) (c0 / c1) * (v0 * v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c0) / (c1 * v1) --> (covov) (1 / (c0 * c1)) * (v0 / v1) + else if ((details::e_div == o0) && (details::e_div == o1) && (details::e_mul == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, "(t*t)/t", Type(1) / (c0 * c1), v0, v1, result); + + exprtk_debug(("(v0 / c0) / (c1 * v1) --> (covov) (1 / (c0 * c1)) * (v0 / v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 / c0) / (c1 / v1) --> (vovoc) (v0 * v1) * (1 / (c0 * c1)) + else if ((details::e_div == o0) && (details::e_div == o1) && (details::e_div == o2)) + { + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<vtype, vtype, ctype>(expr_gen, "(t*t)*t", v0, v1, Type(1) / (c0 * c1), result); + + exprtk_debug(("(v0 / c0) / (c1 / v1) --> (vovoc) (v0 * v1) * (1 / (c0 * c1))\n")); + + return (synthesis_result) ? result : error_node(); + } + // (v0 * c) +/- (c * v1) --> (covov) c * (v0 +/- v1) + else if ( + (std::equal_to<T>()(c0,c1)) && + (details::e_mul == o0) && + (details::e_mul == o2) && + ( + (details::e_add == o1) || (details::e_sub == o1) + ) + ) + { + std::string specfunc; + + switch (o1) + { + case details::e_add : specfunc = "t*(t+t)"; break; + case details::e_sub : specfunc = "t*(t-t)"; break; + default : return error_node(); + } + + const bool synthesis_result = + synthesize_sf3ext_expression:: + template compile<ctype, vtype, vtype>(expr_gen, specfunc, c0, v0, v1, result); + + exprtk_debug(("(v0 * c) +/- (c * v1) --> (covov) c * (v0 +/- v1)\n")); + + return (synthesis_result) ? result : error_node(); + } + } + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c0, c1, v1, result); + + if (synthesis_result) + return result; + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + else if (!expr_gen.valid_operator(o1,f1)) + return error_node(); + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + else + return node_type::allocate(*(expr_gen.node_allocator_), v0, c0, c1, v1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vovovov_expression1 + { + typedef typename vovovov_t::type1 node_type; + typedef typename vovovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 (v1 o1 (v2 o2 v3)) + typedef typename synthesize_vovov_expression1::node_type lcl_vovov_t; + + const lcl_vovov_t* vovov = static_cast<const lcl_vovov_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type& v1 = vovov->t0(); + const Type& v2 = vovov->t1(); + const Type& v3 = vovov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vovov->f0()); + const details::operator_type o2 = expr_gen.get_operator(vovov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vovov->f0(); + binary_functor_t f2 = vovov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen,id(expr_gen, o0, o1, o2), v0, v1, v2, v3, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 (v1 o1 (v2 o2 v3))\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, v3, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t))"; + } + }; + + struct synthesize_vovovoc_expression1 + { + typedef typename vovovoc_t::type1 node_type; + typedef typename vovovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 (v1 o1 (v2 o2 c)) + typedef typename synthesize_vovoc_expression1::node_type lcl_vovoc_t; + + const lcl_vovoc_t* vovoc = static_cast<const lcl_vovoc_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type& v1 = vovoc->t0(); + const Type& v2 = vovoc->t1(); + const Type c = vovoc->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vovoc->f0()); + const details::operator_type o2 = expr_gen.get_operator(vovoc->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vovoc->f0(); + binary_functor_t f2 = vovoc->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, v2, c, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 (v1 o1 (v2 o2 c))\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, c, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t))"; + } + }; + + struct synthesize_vovocov_expression1 + { + typedef typename vovocov_t::type1 node_type; + typedef typename vovocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 (v1 o1 (c o2 v2)) + typedef typename synthesize_vocov_expression1::node_type lcl_vocov_t; + + const lcl_vocov_t* vocov = static_cast<const lcl_vocov_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type& v1 = vocov->t0(); + const Type c = vocov->t1(); + const Type& v2 = vocov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vocov->f0()); + const details::operator_type o2 = expr_gen.get_operator(vocov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vocov->f0(); + binary_functor_t f2 = vocov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, c, v2, result); + + if (synthesis_result) + return result; + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 (v1 o1 (c o2 v2))\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, c, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t))"; + } + }; + + struct synthesize_vocovov_expression1 + { + typedef typename vocovov_t::type1 node_type; + typedef typename vocovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 (c o1 (v1 o2 v2)) + typedef typename synthesize_covov_expression1::node_type lcl_covov_t; + + const lcl_covov_t* covov = static_cast<const lcl_covov_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type c = covov->t0(); + const Type& v1 = covov->t1(); + const Type& v2 = covov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(covov->f0()); + const details::operator_type o2 = expr_gen.get_operator(covov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = covov->f0(); + binary_functor_t f2 = covov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c, v1, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 (c o1 (v1 o2 v2))\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t))"; + } + }; + + struct synthesize_covovov_expression1 + { + typedef typename covovov_t::type1 node_type; + typedef typename covovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // c o0 (v0 o1 (v1 o2 v2)) + typedef typename synthesize_vovov_expression1::node_type lcl_vovov_t; + + const lcl_vovov_t* vovov = static_cast<const lcl_vovov_t*>(branch[1]); + const Type c = static_cast<details::literal_node<Type>*>(branch[0])->value(); + const Type& v0 = vovov->t0(); + const Type& v1 = vovov->t1(); + const Type& v2 = vovov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vovov->f0()); + const details::operator_type o2 = expr_gen.get_operator(vovov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vovov->f0(); + binary_functor_t f2 = vovov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c, v0, v1, v2, result); + + if (synthesis_result) + return result; + if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("c o0 (v0 o1 (v1 o2 v2))\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c, v0, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t))"; + } + }; + + struct synthesize_covocov_expression1 + { + typedef typename covocov_t::type1 node_type; + typedef typename covocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // c0 o0 (v0 o1 (c1 o2 v1)) + typedef typename synthesize_vocov_expression1::node_type lcl_vocov_t; + + const lcl_vocov_t* vocov = static_cast<const lcl_vocov_t*>(branch[1]); + const Type c0 = static_cast<details::literal_node<Type>*>(branch[0])->value(); + const Type& v0 = vocov->t0(); + const Type c1 = vocov->t1(); + const Type& v1 = vocov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vocov->f0()); + const details::operator_type o2 = expr_gen.get_operator(vocov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vocov->f0(); + binary_functor_t f2 = vocov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, c1, v1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("c0 o0 (v0 o1 (c1 o2 v1))\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, c1, v1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t))"; + } + }; + + struct synthesize_vocovoc_expression1 + { + typedef typename vocovoc_t::type1 node_type; + typedef typename vocovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 (c0 o1 (v1 o2 c2)) + typedef typename synthesize_covoc_expression1::node_type lcl_covoc_t; + + const lcl_covoc_t* covoc = static_cast<const lcl_covoc_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type c0 = covoc->t0(); + const Type& v1 = covoc->t1(); + const Type c1 = covoc->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(covoc->f0()); + const details::operator_type o2 = expr_gen.get_operator(covoc->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = covoc->f0(); + binary_functor_t f2 = covoc->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c0, v1, c1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 (c0 o1 (v1 o2 c2))\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t))"; + } + }; + + struct synthesize_covovoc_expression1 + { + typedef typename covovoc_t::type1 node_type; + typedef typename covovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // c0 o0 (v0 o1 (v1 o2 c1)) + typedef typename synthesize_vovoc_expression1::node_type lcl_vovoc_t; + + const lcl_vovoc_t* vovoc = static_cast<const lcl_vovoc_t*>(branch[1]); + const Type c0 = static_cast<details::literal_node<Type>*>(branch[0])->value(); + const Type& v0 = vovoc->t0(); + const Type& v1 = vovoc->t1(); + const Type c1 = vovoc->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vovoc->f0()); + const details::operator_type o2 = expr_gen.get_operator(vovoc->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vovoc->f0(); + binary_functor_t f2 = vovoc->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, v1, c1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("c0 o0 (v0 o1 (v1 o2 c1))\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t))"; + } + }; + + struct synthesize_vococov_expression1 + { + typedef typename vococov_t::type1 node_type; + typedef typename vococov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 (c0 o1 (c1 o2 v1)) + typedef typename synthesize_cocov_expression1::node_type lcl_cocov_t; + + const lcl_cocov_t* cocov = static_cast<const lcl_cocov_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type c0 = cocov->t0(); + const Type c1 = cocov->t1(); + const Type& v1 = cocov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(cocov->f0()); + const details::operator_type o2 = expr_gen.get_operator(cocov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = cocov->f0(); + binary_functor_t f2 = cocov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c0, c1, v1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 (c0 o1 (c1 o2 v1))\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c0, c1, v1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "(t" << expr_gen.to_str(o2) + << "t))"; + } + }; + + struct synthesize_vovovov_expression2 + { + typedef typename vovovov_t::type2 node_type; + typedef typename vovovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 ((v1 o1 v2) o2 v3) + typedef typename synthesize_vovov_expression0::node_type lcl_vovov_t; + + const lcl_vovov_t* vovov = static_cast<const lcl_vovov_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type& v1 = vovov->t0(); + const Type& v2 = vovov->t1(); + const Type& v3 = vovov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vovov->f0()); + const details::operator_type o2 = expr_gen.get_operator(vovov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vovov->f0(); + binary_functor_t f2 = vovov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, v2, v3, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 ((v1 o1 v2) o2 v3)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, v3, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "((t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vovovoc_expression2 + { + typedef typename vovovoc_t::type2 node_type; + typedef typename vovovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 ((v1 o1 v2) o2 c) + typedef typename synthesize_vovoc_expression0::node_type lcl_vovoc_t; + + const lcl_vovoc_t* vovoc = static_cast<const lcl_vovoc_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type& v1 = vovoc->t0(); + const Type& v2 = vovoc->t1(); + const Type c = vovoc->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vovoc->f0()); + const details::operator_type o2 = expr_gen.get_operator(vovoc->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vovoc->f0(); + binary_functor_t f2 = vovoc->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, v2, c, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 ((v1 o1 v2) o2 c)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, c, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "((t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vovocov_expression2 + { + typedef typename vovocov_t::type2 node_type; + typedef typename vovocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 ((v1 o1 c) o2 v2) + typedef typename synthesize_vocov_expression0::node_type lcl_vocov_t; + + const lcl_vocov_t* vocov = static_cast<const lcl_vocov_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type& v1 = vocov->t0(); + const Type c = vocov->t1(); + const Type& v2 = vocov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vocov->f0()); + const details::operator_type o2 = expr_gen.get_operator(vocov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vocov->f0(); + binary_functor_t f2 = vocov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, c, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 ((v1 o1 c) o2 v2)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, c, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "((t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vocovov_expression2 + { + typedef typename vocovov_t::type2 node_type; + typedef typename vocovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 ((c o1 v1) o2 v2) + typedef typename synthesize_covov_expression0::node_type lcl_covov_t; + + const lcl_covov_t* covov = static_cast<const lcl_covov_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type c = covov->t0(); + const Type& v1 = covov->t1(); + const Type& v2 = covov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(covov->f0()); + const details::operator_type o2 = expr_gen.get_operator(covov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = covov->f0(); + binary_functor_t f2 = covov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c, v1, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 ((c o1 v1) o2 v2)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "((t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_covovov_expression2 + { + typedef typename covovov_t::type2 node_type; + typedef typename covovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // c o0 ((v1 o1 v2) o2 v3) + typedef typename synthesize_vovov_expression0::node_type lcl_vovov_t; + + const lcl_vovov_t* vovov = static_cast<const lcl_vovov_t*>(branch[1]); + const Type c = static_cast<details::literal_node<Type>*>(branch[0])->value(); + const Type& v0 = vovov->t0(); + const Type& v1 = vovov->t1(); + const Type& v2 = vovov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vovov->f0()); + const details::operator_type o2 = expr_gen.get_operator(vovov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vovov->f0(); + binary_functor_t f2 = vovov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c, v0, v1, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("c o0 ((v1 o1 v2) o2 v3)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c, v0, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "((t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_covocov_expression2 + { + typedef typename covocov_t::type2 node_type; + typedef typename covocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // c0 o0 ((v0 o1 c1) o2 v1) + typedef typename synthesize_vocov_expression0::node_type lcl_vocov_t; + + const lcl_vocov_t* vocov = static_cast<const lcl_vocov_t*>(branch[1]); + const Type c0 = static_cast<details::literal_node<Type>*>(branch[0])->value(); + const Type& v0 = vocov->t0(); + const Type c1 = vocov->t1(); + const Type& v1 = vocov->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vocov->f0()); + const details::operator_type o2 = expr_gen.get_operator(vocov->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vocov->f0(); + binary_functor_t f2 = vocov->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, c1, v1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("c0 o0 ((v0 o1 c1) o2 v1)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, c1, v1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "((t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vocovoc_expression2 + { + typedef typename vocovoc_t::type2 node_type; + typedef typename vocovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // v0 o0 ((c0 o1 v1) o2 c1) + typedef typename synthesize_covoc_expression0::node_type lcl_covoc_t; + + const lcl_covoc_t* covoc = static_cast<const lcl_covoc_t*>(branch[1]); + const Type& v0 = static_cast<details::variable_node<Type>*>(branch[0])->ref(); + const Type c0 = covoc->t0(); + const Type& v1 = covoc->t1(); + const Type c1 = covoc->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(covoc->f0()); + const details::operator_type o2 = expr_gen.get_operator(covoc->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = covoc->f0(); + binary_functor_t f2 = covoc->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c0, v1, c1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("v0 o0 ((c0 o1 v1) o2 c1)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "((t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_covovoc_expression2 + { + typedef typename covovoc_t::type2 node_type; + typedef typename covovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // c0 o0 ((v0 o1 v1) o2 c1) + typedef typename synthesize_vovoc_expression0::node_type lcl_vovoc_t; + + const lcl_vovoc_t* vovoc = static_cast<const lcl_vovoc_t*>(branch[1]); + const Type c0 = static_cast<details::literal_node<Type>*>(branch[0])->value(); + const Type& v0 = vovoc->t0(); + const Type& v1 = vovoc->t1(); + const Type c1 = vovoc->t2(); + const details::operator_type o0 = operation; + const details::operator_type o1 = expr_gen.get_operator(vovoc->f0()); + const details::operator_type o2 = expr_gen.get_operator(vovoc->f1()); + + binary_functor_t f0 = reinterpret_cast<binary_functor_t>(0); + binary_functor_t f1 = vovoc->f0(); + binary_functor_t f2 = vovoc->f1(); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, v1, c1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o0,f0)) + return error_node(); + + exprtk_debug(("c0 o0 ((v0 o1 v1) o2 c1)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "t" << expr_gen.to_str(o0) + << "((t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t)"; + } + }; + + struct synthesize_vococov_expression2 + { + typedef typename vococov_t::type2 node_type; + static inline expression_node_ptr process(expression_generator<Type>&, + const details::operator_type&, + expression_node_ptr (&)[2]) + { + // v0 o0 ((c0 o1 c1) o2 v1) - Not possible + exprtk_debug(("v0 o0 ((c0 o1 c1) o2 v1) - Not possible\n")); + return error_node(); + } + + static inline std::string id(expression_generator<Type>&, + const details::operator_type, + const details::operator_type, + const details::operator_type) + { + return "INVALID"; + } + }; + + struct synthesize_vovovov_expression3 + { + typedef typename vovovov_t::type3 node_type; + typedef typename vovovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 v1) o1 v2) o2 v3 + typedef typename synthesize_vovov_expression0::node_type lcl_vovov_t; + + const lcl_vovov_t* vovov = static_cast<const lcl_vovov_t*>(branch[0]); + const Type& v0 = vovov->t0(); + const Type& v1 = vovov->t1(); + const Type& v2 = vovov->t2(); + const Type& v3 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(vovov->f0()); + const details::operator_type o1 = expr_gen.get_operator(vovov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vovov->f0(); + binary_functor_t f1 = vovov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, v2, v3, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 v1) o1 v2) o2 v3\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, v3, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "((t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vovovoc_expression3 + { + typedef typename vovovoc_t::type3 node_type; + typedef typename vovovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 v1) o1 v2) o2 c + typedef typename synthesize_vovov_expression0::node_type lcl_vovov_t; + + const lcl_vovov_t* vovov = static_cast<const lcl_vovov_t*>(branch[0]); + const Type& v0 = vovov->t0(); + const Type& v1 = vovov->t1(); + const Type& v2 = vovov->t2(); + const Type c = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const details::operator_type o0 = expr_gen.get_operator(vovov->f0()); + const details::operator_type o1 = expr_gen.get_operator(vovov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vovov->f0(); + binary_functor_t f1 = vovov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, v2, c, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 v1) o1 v2) o2 c\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, c, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "((t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vovocov_expression3 + { + typedef typename vovocov_t::type3 node_type; + typedef typename vovocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 v1) o1 c) o2 v2 + typedef typename synthesize_vovoc_expression0::node_type lcl_vovoc_t; + + const lcl_vovoc_t* vovoc = static_cast<const lcl_vovoc_t*>(branch[0]); + const Type& v0 = vovoc->t0(); + const Type& v1 = vovoc->t1(); + const Type c = vovoc->t2(); + const Type& v2 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(vovoc->f0()); + const details::operator_type o1 = expr_gen.get_operator(vovoc->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vovoc->f0(); + binary_functor_t f1 = vovoc->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, c, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 v1) o1 c) o2 v2\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, c, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "((t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vocovov_expression3 + { + typedef typename vocovov_t::type3 node_type; + typedef typename vocovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 c) o1 v1) o2 v2 + typedef typename synthesize_vocov_expression0::node_type lcl_vocov_t; + + const lcl_vocov_t* vocov = static_cast<const lcl_vocov_t*>(branch[0]); + const Type& v0 = vocov->t0(); + const Type c = vocov->t1(); + const Type& v1 = vocov->t2(); + const Type& v2 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(vocov->f0()); + const details::operator_type o1 = expr_gen.get_operator(vocov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vocov->f0(); + binary_functor_t f1 = vocov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c, v1, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 c) o1 v1) o2 v2\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "((t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_covovov_expression3 + { + typedef typename covovov_t::type3 node_type; + typedef typename covovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((c o0 v0) o1 v1) o2 v2 + typedef typename synthesize_covov_expression0::node_type lcl_covov_t; + + const lcl_covov_t* covov = static_cast<const lcl_covov_t*>(branch[0]); + const Type c = covov->t0(); + const Type& v0 = covov->t1(); + const Type& v1 = covov->t2(); + const Type& v2 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(covov->f0()); + const details::operator_type o1 = expr_gen.get_operator(covov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = covov->f0(); + binary_functor_t f1 = covov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c, v0, v1, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((c o0 v0) o1 v1) o2 v2\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c, v0, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "((t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_covocov_expression3 + { + typedef typename covocov_t::type3 node_type; + typedef typename covocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((c0 o0 v0) o1 c1) o2 v1 + typedef typename synthesize_covoc_expression0::node_type lcl_covoc_t; + + const lcl_covoc_t* covoc = static_cast<const lcl_covoc_t*>(branch[0]); + const Type c0 = covoc->t0(); + const Type& v0 = covoc->t1(); + const Type c1 = covoc->t2(); + const Type& v1 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(covoc->f0()); + const details::operator_type o1 = expr_gen.get_operator(covoc->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = covoc->f0(); + binary_functor_t f1 = covoc->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, c1, v1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((c0 o0 v0) o1 c1) o2 v1\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, c1, v1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "((t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vocovoc_expression3 + { + typedef typename vocovoc_t::type3 node_type; + typedef typename vocovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 c0) o1 v1) o2 c1 + typedef typename synthesize_vocov_expression0::node_type lcl_vocov_t; + + const lcl_vocov_t* vocov = static_cast<const lcl_vocov_t*>(branch[0]); + const Type& v0 = vocov->t0(); + const Type c0 = vocov->t1(); + const Type& v1 = vocov->t2(); + const Type c1 = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const details::operator_type o0 = expr_gen.get_operator(vocov->f0()); + const details::operator_type o1 = expr_gen.get_operator(vocov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vocov->f0(); + binary_functor_t f1 = vocov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c0, v1, c1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 c0) o1 v1) o2 c1\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "((t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_covovoc_expression3 + { + typedef typename covovoc_t::type3 node_type; + typedef typename covovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((c0 o0 v0) o1 v1) o2 c1 + typedef typename synthesize_covov_expression0::node_type lcl_covov_t; + + const lcl_covov_t* covov = static_cast<const lcl_covov_t*>(branch[0]); + const Type c0 = covov->t0(); + const Type& v0 = covov->t1(); + const Type& v1 = covov->t2(); + const Type c1 = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const details::operator_type o0 = expr_gen.get_operator(covov->f0()); + const details::operator_type o1 = expr_gen.get_operator(covov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = covov->f0(); + binary_functor_t f1 = covov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, v1, c1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((c0 o0 v0) o1 v1) o2 c1\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "((t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vococov_expression3 + { + typedef typename vococov_t::type3 node_type; + typedef typename vococov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 c0) o1 c1) o2 v1 + typedef typename synthesize_vococ_expression0::node_type lcl_vococ_t; + + const lcl_vococ_t* vococ = static_cast<const lcl_vococ_t*>(branch[0]); + const Type& v0 = vococ->t0(); + const Type c0 = vococ->t1(); + const Type c1 = vococ->t2(); + const Type& v1 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(vococ->f0()); + const details::operator_type o1 = expr_gen.get_operator(vococ->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vococ->f0(); + binary_functor_t f1 = vococ->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c0, c1, v1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 c0) o1 c1) o2 v1\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c0, c1, v1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "((t" << expr_gen.to_str(o0) + << "t)" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vovovov_expression4 + { + typedef typename vovovov_t::type4 node_type; + typedef typename vovovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // (v0 o0 (v1 o1 v2)) o2 v3 + typedef typename synthesize_vovov_expression1::node_type lcl_vovov_t; + + const lcl_vovov_t* vovov = static_cast<const lcl_vovov_t*>(branch[0]); + const Type& v0 = vovov->t0(); + const Type& v1 = vovov->t1(); + const Type& v2 = vovov->t2(); + const Type& v3 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(vovov->f0()); + const details::operator_type o1 = expr_gen.get_operator(vovov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vovov->f0(); + binary_functor_t f1 = vovov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, v2, v3, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("(v0 o0 (v1 o1 v2)) o2 v3\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, v3, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vovovoc_expression4 + { + typedef typename vovovoc_t::type4 node_type; + typedef typename vovovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 (v1 o1 v2)) o2 c) + typedef typename synthesize_vovov_expression1::node_type lcl_vovov_t; + + const lcl_vovov_t* vovov = static_cast<const lcl_vovov_t*>(branch[0]); + const Type& v0 = vovov->t0(); + const Type& v1 = vovov->t1(); + const Type& v2 = vovov->t2(); + const Type c = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const details::operator_type o0 = expr_gen.get_operator(vovov->f0()); + const details::operator_type o1 = expr_gen.get_operator(vovov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vovov->f0(); + binary_functor_t f1 = vovov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, v2, c, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 (v1 o1 v2)) o2 c)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, v2, c, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vovocov_expression4 + { + typedef typename vovocov_t::type4 node_type; + typedef typename vovocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 (v1 o1 c)) o2 v1) + typedef typename synthesize_vovoc_expression1::node_type lcl_vovoc_t; + + const lcl_vovoc_t* vovoc = static_cast<const lcl_vovoc_t*>(branch[0]); + const Type& v0 = vovoc->t0(); + const Type& v1 = vovoc->t1(); + const Type c = vovoc->t2(); + const Type& v2 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(vovoc->f0()); + const details::operator_type o1 = expr_gen.get_operator(vovoc->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vovoc->f0(); + binary_functor_t f1 = vovoc->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, v1, c, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 (v1 o1 c)) o2 v1)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, v1, c, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vocovov_expression4 + { + typedef typename vocovov_t::type4 node_type; + typedef typename vocovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 (c o1 v1)) o2 v2) + typedef typename synthesize_vocov_expression1::node_type lcl_vocov_t; + + const lcl_vocov_t* vocov = static_cast<const lcl_vocov_t*>(branch[0]); + const Type& v0 = vocov->t0(); + const Type c = vocov->t1(); + const Type& v1 = vocov->t2(); + const Type& v2 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(vocov->f0()); + const details::operator_type o1 = expr_gen.get_operator(vocov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vocov->f0(); + binary_functor_t f1 = vocov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c, v1, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 (c o1 v1)) o2 v2)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_covovov_expression4 + { + typedef typename covovov_t::type4 node_type; + typedef typename covovov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((c o0 (v0 o1 v1)) o2 v2) + typedef typename synthesize_covov_expression1::node_type lcl_covov_t; + + const lcl_covov_t* covov = static_cast<const lcl_covov_t*>(branch[0]); + const Type c = covov->t0(); + const Type& v0 = covov->t1(); + const Type& v1 = covov->t2(); + const Type& v2 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(covov->f0()); + const details::operator_type o1 = expr_gen.get_operator(covov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = covov->f0(); + binary_functor_t f1 = covov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c, v0, v1, v2, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((c o0 (v0 o1 v1)) o2 v2)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c, v0, v1, v2, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_covocov_expression4 + { + typedef typename covocov_t::type4 node_type; + typedef typename covocov_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((c0 o0 (v0 o1 c1)) o2 v1) + typedef typename synthesize_covoc_expression1::node_type lcl_covoc_t; + + const lcl_covoc_t* covoc = static_cast<const lcl_covoc_t*>(branch[0]); + const Type c0 = covoc->t0(); + const Type& v0 = covoc->t1(); + const Type c1 = covoc->t2(); + const Type& v1 = static_cast<details::variable_node<Type>*>(branch[1])->ref(); + const details::operator_type o0 = expr_gen.get_operator(covoc->f0()); + const details::operator_type o1 = expr_gen.get_operator(covoc->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = covoc->f0(); + binary_functor_t f1 = covoc->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, c1, v1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((c0 o0 (v0 o1 c1)) o2 v1)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, c1, v1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vocovoc_expression4 + { + typedef typename vocovoc_t::type4 node_type; + typedef typename vocovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((v0 o0 (c0 o1 v1)) o2 c1) + typedef typename synthesize_vocov_expression1::node_type lcl_vocov_t; + + const lcl_vocov_t* vocov = static_cast<const lcl_vocov_t*>(branch[0]); + const Type& v0 = vocov->t0(); + const Type c0 = vocov->t1(); + const Type& v1 = vocov->t2(); + const Type c1 = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const details::operator_type o0 = expr_gen.get_operator(vocov->f0()); + const details::operator_type o1 = expr_gen.get_operator(vocov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = vocov->f0(); + binary_functor_t f1 = vocov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), v0, c0, v1, c1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((v0 o0 (c0 o1 v1)) o2 c1)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), v0, c0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_covovoc_expression4 + { + typedef typename covovoc_t::type4 node_type; + typedef typename covovoc_t::sf4_type sf4_type; + typedef typename node_type::T0 T0; + typedef typename node_type::T1 T1; + typedef typename node_type::T2 T2; + typedef typename node_type::T3 T3; + + static inline expression_node_ptr process(expression_generator<Type>& expr_gen, + const details::operator_type& operation, + expression_node_ptr (&branch)[2]) + { + // ((c0 o0 (v0 o1 v1)) o2 c1) + typedef typename synthesize_covov_expression1::node_type lcl_covov_t; + + const lcl_covov_t* covov = static_cast<const lcl_covov_t*>(branch[0]); + const Type c0 = covov->t0(); + const Type& v0 = covov->t1(); + const Type& v1 = covov->t2(); + const Type c1 = static_cast<details::literal_node<Type>*>(branch[1])->value(); + const details::operator_type o0 = expr_gen.get_operator(covov->f0()); + const details::operator_type o1 = expr_gen.get_operator(covov->f1()); + const details::operator_type o2 = operation; + + binary_functor_t f0 = covov->f0(); + binary_functor_t f1 = covov->f1(); + binary_functor_t f2 = reinterpret_cast<binary_functor_t>(0); + + details::free_node(*(expr_gen.node_allocator_),branch[0]); + details::free_node(*(expr_gen.node_allocator_),branch[1]); + + expression_node_ptr result = error_node(); + + const bool synthesis_result = + synthesize_sf4ext_expression::template compile<T0, T1, T2, T3> + (expr_gen, id(expr_gen, o0, o1, o2), c0, v0, v1, c1, result); + + if (synthesis_result) + return result; + else if (!expr_gen.valid_operator(o2,f2)) + return error_node(); + + exprtk_debug(("((c0 o0 (v0 o1 v1)) o2 c1)\n")); + + return node_type::allocate(*(expr_gen.node_allocator_), c0, v0, v1, c1, f0, f1, f2); + } + + static inline std::string id(expression_generator<Type>& expr_gen, + const details::operator_type o0, + const details::operator_type o1, + const details::operator_type o2) + { + return details::build_string() + << "(t" << expr_gen.to_str(o0) + << "(t" << expr_gen.to_str(o1) + << "t)" << expr_gen.to_str(o2) + << "t"; + } + }; + + struct synthesize_vococov_expression4 + { + typedef typename vococov_t::type4 node_type; + static inline expression_node_ptr process(expression_generator<Type>&, + const details::operator_type&, + expression_node_ptr (&)[2]) + { + // ((v0 o0 (c0 o1 c1)) o2 v1) - Not possible + exprtk_debug(("((v0 o0 (c0 o1 c1)) o2 v1) - Not possible\n")); + return error_node(); + } + + static inline std::string id(expression_generator<Type>&, + const details::operator_type, + const details::operator_type, + const details::operator_type) + { + return "INVALID"; + } + }; + #endif + + inline expression_node_ptr synthesize_uvouv_expression(const details::operator_type& operation, expression_node_ptr (&branch)[2]) + { + // Definition: uv o uv + details::operator_type o0 = static_cast<details::uv_base_node<Type>*>(branch[0])->operation(); + details::operator_type o1 = static_cast<details::uv_base_node<Type>*>(branch[1])->operation(); + const Type& v0 = static_cast<details::uv_base_node<Type>*>(branch[0])->v(); + const Type& v1 = static_cast<details::uv_base_node<Type>*>(branch[1])->v(); + unary_functor_t u0 = reinterpret_cast<unary_functor_t> (0); + unary_functor_t u1 = reinterpret_cast<unary_functor_t> (0); + binary_functor_t f = reinterpret_cast<binary_functor_t>(0); + + if (!valid_operator(o0,u0)) + return error_node(); + else if (!valid_operator(o1,u1)) + return error_node(); + else if (!valid_operator(operation,f)) + return error_node(); + + expression_node_ptr result = error_node(); + + if ( + (details::e_neg == o0) && + (details::e_neg == o1) + ) + { + switch (operation) + { + // (-v0 + -v1) --> -(v0 + v1) + case details::e_add : result = (*this)(details::e_neg, + node_allocator_-> + allocate_rr<typename details:: + vov_node<Type,details::add_op<Type> > >(v0, v1)); + exprtk_debug(("(-v0 + -v1) --> -(v0 + v1)\n")); + break; + + // (-v0 - -v1) --> (v1 - v0) + case details::e_sub : result = node_allocator_-> + allocate_rr<typename details:: + vov_node<Type,details::sub_op<Type> > >(v1, v0); + exprtk_debug(("(-v0 - -v1) --> (v1 - v0)\n")); + break; + + // (-v0 * -v1) --> (v0 * v1) + case details::e_mul : result = node_allocator_-> + allocate_rr<typename details:: + vov_node<Type,details::mul_op<Type> > >(v0, v1); + exprtk_debug(("(-v0 * -v1) --> (v0 * v1)\n")); + break; + + // (-v0 / -v1) --> (v0 / v1) + case details::e_div : result = node_allocator_-> + allocate_rr<typename details:: + vov_node<Type,details::div_op<Type> > >(v0, v1); + exprtk_debug(("(-v0 / -v1) --> (v0 / v1)\n")); + break; + + default : break; + } + } + + if (0 == result) + { + result = node_allocator_-> + allocate_rrrrr<typename details::uvouv_node<Type> >(v0, v1, u0, u1, f); + } + + details::free_all_nodes(*node_allocator_,branch); + return result; + } + + #undef basic_opr_switch_statements + #undef extended_opr_switch_statements + #undef unary_opr_switch_statements + + #ifndef exprtk_disable_string_capabilities + + #define string_opr_switch_statements \ + case_stmt(details::e_lt , details::lt_op ) \ + case_stmt(details::e_lte , details::lte_op ) \ + case_stmt(details::e_gt , details::gt_op ) \ + case_stmt(details::e_gte , details::gte_op ) \ + case_stmt(details::e_eq , details::eq_op ) \ + case_stmt(details::e_ne , details::ne_op ) \ + case_stmt(details::e_in , details::in_op ) \ + case_stmt(details::e_like , details::like_op ) \ + case_stmt(details::e_ilike , details::ilike_op) \ + + template <typename T0, typename T1> + inline expression_node_ptr synthesize_str_xrox_expression_impl(const details::operator_type& opr, + T0 s0, T1 s1, + range_t rp0) + { + switch (opr) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate_ttt<typename details::str_xrox_node<Type,T0,T1,range_t,op1<Type> >,T0,T1> \ + (s0, s1, rp0); \ + + string_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + + template <typename T0, typename T1> + inline expression_node_ptr synthesize_str_xoxr_expression_impl(const details::operator_type& opr, + T0 s0, T1 s1, + range_t rp1) + { + switch (opr) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate_ttt<typename details::str_xoxr_node<Type,T0,T1,range_t,op1<Type> >,T0,T1> \ + (s0, s1, rp1); \ + + string_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + + template <typename T0, typename T1> + inline expression_node_ptr synthesize_str_xroxr_expression_impl(const details::operator_type& opr, + T0 s0, T1 s1, + range_t rp0, range_t rp1) + { + switch (opr) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate_tttt<typename details::str_xroxr_node<Type,T0,T1,range_t,op1<Type> >,T0,T1> \ + (s0, s1, rp0, rp1); \ + + string_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + + template <typename T0, typename T1> + inline expression_node_ptr synthesize_sos_expression_impl(const details::operator_type& opr, T0 s0, T1 s1) + { + switch (opr) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate_tt<typename details::sos_node<Type,T0,T1,op1<Type> >,T0,T1>(s0, s1); \ + + string_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + + inline expression_node_ptr synthesize_sos_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string& s0 = static_cast<details::stringvar_node<Type>*>(branch[0])->ref(); + std::string& s1 = static_cast<details::stringvar_node<Type>*>(branch[1])->ref(); + + return synthesize_sos_expression_impl<std::string&,std::string&>(opr, s0, s1); + } + + inline expression_node_ptr synthesize_sros_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string& s0 = static_cast<details::string_range_node<Type>*>(branch[0])->ref (); + std::string& s1 = static_cast<details::stringvar_node<Type>*> (branch[1])->ref (); + range_t rp0 = static_cast<details::string_range_node<Type>*>(branch[0])->range(); + + static_cast<details::string_range_node<Type>*>(branch[0])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[0]); + + return synthesize_str_xrox_expression_impl<std::string&,std::string&>(opr, s0, s1, rp0); + } + + inline expression_node_ptr synthesize_sosr_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string& s0 = static_cast<details::stringvar_node<Type>*> (branch[0])->ref (); + std::string& s1 = static_cast<details::string_range_node<Type>*>(branch[1])->ref (); + range_t rp1 = static_cast<details::string_range_node<Type>*>(branch[1])->range(); + + static_cast<details::string_range_node<Type>*>(branch[1])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[1]); + + return synthesize_str_xoxr_expression_impl<std::string&,std::string&>(opr, s0, s1, rp1); + } + + inline expression_node_ptr synthesize_socsr_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string& s0 = static_cast<details::stringvar_node<Type>*> (branch[0])->ref (); + std::string s1 = static_cast<details::const_string_range_node<Type>*>(branch[1])->str (); + range_t rp1 = static_cast<details::const_string_range_node<Type>*>(branch[1])->range(); + + static_cast<details::const_string_range_node<Type>*>(branch[1])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[1]); + + return synthesize_str_xoxr_expression_impl<std::string&, const std::string>(opr, s0, s1, rp1); + } + + inline expression_node_ptr synthesize_srosr_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string& s0 = static_cast<details::string_range_node<Type>*>(branch[0])->ref (); + std::string& s1 = static_cast<details::string_range_node<Type>*>(branch[1])->ref (); + range_t rp0 = static_cast<details::string_range_node<Type>*>(branch[0])->range(); + range_t rp1 = static_cast<details::string_range_node<Type>*>(branch[1])->range(); + + static_cast<details::string_range_node<Type>*>(branch[0])->range_ref().clear(); + static_cast<details::string_range_node<Type>*>(branch[1])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[0]); + details::free_node(*node_allocator_,branch[1]); + + return synthesize_str_xroxr_expression_impl<std::string&,std::string&>(opr, s0, s1, rp0, rp1); + } + + inline expression_node_ptr synthesize_socs_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string& s0 = static_cast< details::stringvar_node<Type>*>(branch[0])->ref(); + std::string s1 = static_cast<details::string_literal_node<Type>*>(branch[1])->str(); + + details::free_node(*node_allocator_,branch[1]); + + return synthesize_sos_expression_impl<std::string&, const std::string>(opr, s0, s1); + } + + inline expression_node_ptr synthesize_csos_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string s0 = static_cast<details::string_literal_node<Type>*>(branch[0])->str(); + std::string& s1 = static_cast<details::stringvar_node<Type>* >(branch[1])->ref(); + + details::free_node(*node_allocator_,branch[0]); + + return synthesize_sos_expression_impl<const std::string,std::string&>(opr, s0, s1); + } + + inline expression_node_ptr synthesize_csosr_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string s0 = static_cast<details::string_literal_node<Type>*>(branch[0])->str (); + std::string& s1 = static_cast<details::string_range_node<Type>* >(branch[1])->ref (); + range_t rp1 = static_cast<details::string_range_node<Type>* >(branch[1])->range(); + + static_cast<details::string_range_node<Type>*>(branch[1])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[0]); + details::free_node(*node_allocator_,branch[1]); + + return synthesize_str_xoxr_expression_impl<const std::string,std::string&>(opr, s0, s1, rp1); + } + + inline expression_node_ptr synthesize_srocs_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string& s0 = static_cast<details::string_range_node<Type>* >(branch[0])->ref (); + std::string s1 = static_cast<details::string_literal_node<Type>*>(branch[1])->str (); + range_t rp0 = static_cast<details::string_range_node<Type>* >(branch[0])->range(); + + static_cast<details::string_range_node<Type>*>(branch[0])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[0]); + details::free_node(*node_allocator_,branch[1]); + + return synthesize_str_xrox_expression_impl<std::string&, const std::string>(opr, s0, s1, rp0); + } + + inline expression_node_ptr synthesize_srocsr_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string& s0 = static_cast<details::string_range_node<Type>* >(branch[0])->ref (); + std::string s1 = static_cast<details::const_string_range_node<Type>*>(branch[1])->str (); + range_t rp0 = static_cast<details::string_range_node<Type>* >(branch[0])->range(); + range_t rp1 = static_cast<details::const_string_range_node<Type>*>(branch[1])->range(); + + static_cast<details::string_range_node<Type>*> (branch[0])->range_ref().clear(); + static_cast<details::const_string_range_node<Type>*>(branch[1])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[0]); + details::free_node(*node_allocator_,branch[1]); + + return synthesize_str_xroxr_expression_impl<std::string&, const std::string>(opr, s0, s1, rp0, rp1); + } + + inline expression_node_ptr synthesize_csocs_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + const std::string s0 = static_cast<details::string_literal_node<Type>*>(branch[0])->str(); + const std::string s1 = static_cast<details::string_literal_node<Type>*>(branch[1])->str(); + + expression_node_ptr result = error_node(); + + if (details::e_add == opr) + result = node_allocator_->allocate_c<details::string_literal_node<Type> >(s0 + s1); + else if (details::e_in == opr) + result = node_allocator_->allocate_c<details::literal_node<Type> >(details::in_op <Type>::process(s0,s1)); + else if (details::e_like == opr) + result = node_allocator_->allocate_c<details::literal_node<Type> >(details::like_op <Type>::process(s0,s1)); + else if (details::e_ilike == opr) + result = node_allocator_->allocate_c<details::literal_node<Type> >(details::ilike_op<Type>::process(s0,s1)); + else + { + expression_node_ptr temp = synthesize_sos_expression_impl<const std::string, const std::string>(opr, s0, s1); + + const Type v = temp->value(); + + details::free_node(*node_allocator_,temp); + + result = node_allocator_->allocate<literal_node_t>(v); + } + + details::free_all_nodes(*node_allocator_,branch); + + return result; + } + + inline expression_node_ptr synthesize_csocsr_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + const std::string s0 = static_cast<details::string_literal_node<Type>* >(branch[0])->str (); + std::string s1 = static_cast<details::const_string_range_node<Type>*>(branch[1])->str (); + range_t rp1 = static_cast<details::const_string_range_node<Type>*>(branch[1])->range(); + + static_cast<details::const_string_range_node<Type>*>(branch[1])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[0]); + details::free_node(*node_allocator_,branch[1]); + + return synthesize_str_xoxr_expression_impl<const std::string, const std::string>(opr, s0, s1, rp1); + } + + inline expression_node_ptr synthesize_csros_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + std::string s0 = static_cast<details::const_string_range_node<Type>*>(branch[0])->str (); + std::string& s1 = static_cast<details::stringvar_node<Type>* >(branch[1])->ref (); + range_t rp0 = static_cast<details::const_string_range_node<Type>*>(branch[0])->range(); + + static_cast<details::const_string_range_node<Type>*>(branch[0])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[0]); + + return synthesize_str_xrox_expression_impl<const std::string,std::string&>(opr, s0, s1, rp0); + } + + inline expression_node_ptr synthesize_csrosr_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + const std::string s0 = static_cast<details::const_string_range_node<Type>*>(branch[0])->str (); + std::string& s1 = static_cast<details::string_range_node<Type>* >(branch[1])->ref (); + const range_t rp0 = static_cast<details::const_string_range_node<Type>*>(branch[0])->range(); + const range_t rp1 = static_cast<details::string_range_node<Type>* >(branch[1])->range(); + + static_cast<details::const_string_range_node<Type>*>(branch[0])->range_ref().clear(); + static_cast<details::string_range_node<Type>*> (branch[1])->range_ref().clear(); + + details::free_node(*node_allocator_,branch[0]); + details::free_node(*node_allocator_,branch[1]); + + return synthesize_str_xroxr_expression_impl<const std::string,std::string&>(opr, s0, s1, rp0, rp1); + } + + inline expression_node_ptr synthesize_csrocs_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + const std::string s0 = static_cast<details::const_string_range_node<Type>*>(branch[0])->str (); + const std::string s1 = static_cast<details::string_literal_node<Type>* >(branch[1])->str (); + const range_t rp0 = static_cast<details::const_string_range_node<Type>*>(branch[0])->range(); + + static_cast<details::const_string_range_node<Type>*>(branch[0])->range_ref().clear(); + + details::free_all_nodes(*node_allocator_,branch); + + return synthesize_str_xrox_expression_impl<const std::string,std::string>(opr, s0, s1, rp0); + } + + inline expression_node_ptr synthesize_csrocsr_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + const std::string s0 = static_cast<details::const_string_range_node<Type>*>(branch[0])->str (); + const std::string s1 = static_cast<details::const_string_range_node<Type>*>(branch[1])->str (); + const range_t rp0 = static_cast<details::const_string_range_node<Type>*>(branch[0])->range(); + const range_t rp1 = static_cast<details::const_string_range_node<Type>*>(branch[1])->range(); + + static_cast<details::const_string_range_node<Type>*>(branch[0])->range_ref().clear(); + static_cast<details::const_string_range_node<Type>*>(branch[1])->range_ref().clear(); + + details::free_all_nodes(*node_allocator_,branch); + + return synthesize_str_xroxr_expression_impl<const std::string, const std::string>(opr, s0, s1, rp0, rp1); + } + + inline expression_node_ptr synthesize_strogen_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + switch (opr) + { + #define case_stmt(op0, op1) \ + case op0 : return node_allocator_-> \ + allocate_ttt<typename details::str_sogens_node<Type,op1<Type> > > \ + (opr, branch[0], branch[1]); \ + + string_opr_switch_statements + #undef case_stmt + default : return error_node(); + } + } + + #undef string_opr_switch_statements + #endif + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr synthesize_string_expression(const details::operator_type& opr, expression_node_ptr (&branch)[2]) + { + if ((0 == branch[0]) || (0 == branch[1])) + { + details::free_all_nodes(*node_allocator_,branch); + + return error_node(); + } + + const bool b0_is_s = details::is_string_node (branch[0]); + const bool b0_is_cs = details::is_const_string_node (branch[0]); + const bool b0_is_sr = details::is_string_range_node (branch[0]); + const bool b0_is_csr = details::is_const_string_range_node(branch[0]); + + const bool b1_is_s = details::is_string_node (branch[1]); + const bool b1_is_cs = details::is_const_string_node (branch[1]); + const bool b1_is_sr = details::is_string_range_node (branch[1]); + const bool b1_is_csr = details::is_const_string_range_node(branch[1]); + + const bool b0_is_gen = details::is_string_assignment_node (branch[0]) || + details::is_genricstring_range_node(branch[0]) || + details::is_string_concat_node (branch[0]) || + details::is_string_function_node (branch[0]) || + details::is_string_condition_node (branch[0]) || + details::is_string_ccondition_node (branch[0]) || + details::is_string_vararg_node (branch[0]) ; + + const bool b1_is_gen = details::is_string_assignment_node (branch[1]) || + details::is_genricstring_range_node(branch[1]) || + details::is_string_concat_node (branch[1]) || + details::is_string_function_node (branch[1]) || + details::is_string_condition_node (branch[1]) || + details::is_string_ccondition_node (branch[1]) || + details::is_string_vararg_node (branch[1]) ; + + if (details::e_add == opr) + { + if (!b0_is_cs || !b1_is_cs) + { + return synthesize_expression<string_concat_node_t,2>(opr,branch); + } + } + + if (b0_is_gen || b1_is_gen) + { + return synthesize_strogen_expression(opr,branch); + } + else if (b0_is_s) + { + if (b1_is_s ) return synthesize_sos_expression (opr,branch); + else if (b1_is_cs ) return synthesize_socs_expression (opr,branch); + else if (b1_is_sr ) return synthesize_sosr_expression (opr,branch); + else if (b1_is_csr) return synthesize_socsr_expression (opr,branch); + } + else if (b0_is_cs) + { + if (b1_is_s ) return synthesize_csos_expression (opr,branch); + else if (b1_is_cs ) return synthesize_csocs_expression (opr,branch); + else if (b1_is_sr ) return synthesize_csosr_expression (opr,branch); + else if (b1_is_csr) return synthesize_csocsr_expression(opr,branch); + } + else if (b0_is_sr) + { + if (b1_is_s ) return synthesize_sros_expression (opr,branch); + else if (b1_is_sr ) return synthesize_srosr_expression (opr,branch); + else if (b1_is_cs ) return synthesize_srocs_expression (opr,branch); + else if (b1_is_csr) return synthesize_srocsr_expression(opr,branch); + } + else if (b0_is_csr) + { + if (b1_is_s ) return synthesize_csros_expression (opr,branch); + else if (b1_is_sr ) return synthesize_csrosr_expression (opr,branch); + else if (b1_is_cs ) return synthesize_csrocs_expression (opr,branch); + else if (b1_is_csr) return synthesize_csrocsr_expression(opr,branch); + } + + return error_node(); + } + #else + inline expression_node_ptr synthesize_string_expression(const details::operator_type&, expression_node_ptr (&branch)[2]) + { + details::free_all_nodes(*node_allocator_,branch); + return error_node(); + } + #endif + + #ifndef exprtk_disable_string_capabilities + inline expression_node_ptr synthesize_string_expression(const details::operator_type& opr, expression_node_ptr (&branch)[3]) + { + if (details::e_inrange != opr) + return error_node(); + else if ((0 == branch[0]) || (0 == branch[1]) || (0 == branch[2])) + { + details::free_all_nodes(*node_allocator_,branch); + + return error_node(); + } + else if ( + details::is_const_string_node(branch[0]) && + details::is_const_string_node(branch[1]) && + details::is_const_string_node(branch[2]) + ) + { + const std::string s0 = static_cast<details::string_literal_node<Type>*>(branch[0])->str(); + const std::string s1 = static_cast<details::string_literal_node<Type>*>(branch[1])->str(); + const std::string s2 = static_cast<details::string_literal_node<Type>*>(branch[2])->str(); + + const Type v = (((s0 <= s1) && (s1 <= s2)) ? Type(1) : Type(0)); + + details::free_all_nodes(*node_allocator_,branch); + + return node_allocator_->allocate_c<details::literal_node<Type> >(v); + } + else if ( + details::is_string_node(branch[0]) && + details::is_string_node(branch[1]) && + details::is_string_node(branch[2]) + ) + { + std::string& s0 = static_cast<details::stringvar_node<Type>*>(branch[0])->ref(); + std::string& s1 = static_cast<details::stringvar_node<Type>*>(branch[1])->ref(); + std::string& s2 = static_cast<details::stringvar_node<Type>*>(branch[2])->ref(); + + typedef typename details::sosos_node<Type, std::string&, std::string&, std::string&, details::inrange_op<Type> > inrange_t; + + return node_allocator_->allocate_type<inrange_t, std::string&, std::string&, std::string&>(s0, s1, s2); + } + else if ( + details::is_const_string_node(branch[0]) && + details::is_string_node(branch[1]) && + details::is_const_string_node(branch[2]) + ) + { + std::string s0 = static_cast<details::string_literal_node<Type>*>(branch[0])->str(); + std::string& s1 = static_cast<details::stringvar_node<Type>* >(branch[1])->ref(); + std::string s2 = static_cast<details::string_literal_node<Type>*>(branch[2])->str(); + + typedef typename details::sosos_node<Type, std::string, std::string&, std::string, details::inrange_op<Type> > inrange_t; + + details::free_node(*node_allocator_,branch[0]); + details::free_node(*node_allocator_,branch[2]); + + return node_allocator_->allocate_type<inrange_t, std::string, std::string&, std::string>(s0, s1, s2); + } + else if ( + details::is_string_node(branch[0]) && + details::is_const_string_node(branch[1]) && + details::is_string_node(branch[2]) + ) + { + std::string& s0 = static_cast<details::stringvar_node<Type>* >(branch[0])->ref(); + std::string s1 = static_cast<details::string_literal_node<Type>*>(branch[1])->str(); + std::string& s2 = static_cast<details::stringvar_node<Type>* >(branch[2])->ref(); + + typedef typename details::sosos_node<Type, std::string&, std::string, std::string&, details::inrange_op<Type> > inrange_t; + + details::free_node(*node_allocator_,branch[1]); + + return node_allocator_->allocate_type<inrange_t, std::string&, std::string, std::string&>(s0, s1, s2); + } + else if ( + details::is_string_node(branch[0]) && + details::is_string_node(branch[1]) && + details::is_const_string_node(branch[2]) + ) + { + std::string& s0 = static_cast<details::stringvar_node<Type>* >(branch[0])->ref(); + std::string& s1 = static_cast<details::stringvar_node<Type>* >(branch[1])->ref(); + std::string s2 = static_cast<details::string_literal_node<Type>*>(branch[2])->str(); + + typedef typename details::sosos_node<Type, std::string&, std::string&, std::string, details::inrange_op<Type> > inrange_t; + + details::free_node(*node_allocator_,branch[2]); + + return node_allocator_->allocate_type<inrange_t, std::string&, std::string&, std::string>(s0, s1, s2); + } + else if ( + details::is_const_string_node(branch[0]) && + details:: is_string_node(branch[1]) && + details:: is_string_node(branch[2]) + ) + { + std::string s0 = static_cast<details::string_literal_node<Type>*>(branch[0])->str(); + std::string& s1 = static_cast<details::stringvar_node<Type>* >(branch[1])->ref(); + std::string& s2 = static_cast<details::stringvar_node<Type>* >(branch[2])->ref(); + + typedef typename details::sosos_node<Type, std::string, std::string&, std::string&, details::inrange_op<Type> > inrange_t; + + details::free_node(*node_allocator_,branch[0]); + + return node_allocator_->allocate_type<inrange_t, std::string, std::string&, std::string&>(s0, s1, s2); + } + else + return error_node(); + } + #else + inline expression_node_ptr synthesize_string_expression(const details::operator_type&, expression_node_ptr (&branch)[3]) + { + details::free_all_nodes(*node_allocator_,branch); + return error_node(); + } + #endif + + inline expression_node_ptr synthesize_null_expression(const details::operator_type& operation, expression_node_ptr (&branch)[2]) + { + /* + Note: The following are the type promotion rules + that relate to operations that include 'null': + 0. null ==/!= null --> true false + 1. null operation null --> null + 2. x ==/!= null --> true/false + 3. null ==/!= x --> true/false + 4. x operation null --> x + 5. null operation x --> x + */ + + typedef typename details::null_eq_node<T> nulleq_node_t; + + const bool b0_null = details::is_null_node(branch[0]); + const bool b1_null = details::is_null_node(branch[1]); + + if (b0_null && b1_null) + { + expression_node_ptr result = error_node(); + + if (details::e_eq == operation) + result = node_allocator_->allocate_c<literal_node_t>(T(1)); + else if (details::e_ne == operation) + result = node_allocator_->allocate_c<literal_node_t>(T(0)); + + if (result) + { + details::free_node(*node_allocator_,branch[0]); + details::free_node(*node_allocator_,branch[1]); + + return result; + } + + details::free_node(*node_allocator_,branch[1]); + + return branch[0]; + } + else if (details::e_eq == operation) + { + expression_node_ptr result = node_allocator_-> + allocate_rc<nulleq_node_t>(branch[b0_null ? 0 : 1],true); + + details::free_node(*node_allocator_,branch[b0_null ? 1 : 0]); + + return result; + } + else if (details::e_ne == operation) + { + expression_node_ptr result = node_allocator_-> + allocate_rc<nulleq_node_t>(branch[b0_null ? 0 : 1],false); + + details::free_node(*node_allocator_,branch[b0_null ? 1 : 0]); + + return result; + } + else if (b0_null) + { + details::free_node(*node_allocator_,branch[0]); + branch[0] = branch[1]; + branch[1] = error_node(); + } + else if (b1_null) + { + details::free_node(*node_allocator_,branch[1]); + branch[1] = error_node(); + } + + if ( + (details::e_add == operation) || (details::e_sub == operation) || + (details::e_mul == operation) || (details::e_div == operation) || + (details::e_mod == operation) || (details::e_pow == operation) + ) + { + return branch[0]; + } + + details::free_node(*node_allocator_, branch[0]); + + if ( + (details::e_lt == operation) || (details::e_lte == operation) || + (details::e_gt == operation) || (details::e_gte == operation) || + (details::e_and == operation) || (details::e_nand == operation) || + (details::e_or == operation) || (details::e_nor == operation) || + (details::e_xor == operation) || (details::e_xnor == operation) || + (details::e_in == operation) || (details::e_like == operation) || + (details::e_ilike == operation) + ) + { + return node_allocator_->allocate_c<literal_node_t>(T(0)); + } + + return node_allocator_->allocate<details::null_node<Type> >(); + } + + template <typename NodeType, std::size_t N> + inline expression_node_ptr synthesize_expression(const details::operator_type& operation, expression_node_ptr (&branch)[N]) + { + if ( + (details::e_in == operation) || + (details::e_like == operation) || + (details::e_ilike == operation) + ) + { + free_all_nodes(*node_allocator_,branch); + + return error_node(); + } + else if (!details::all_nodes_valid<N>(branch)) + { + free_all_nodes(*node_allocator_,branch); + + return error_node(); + } + else if ((details::e_default != operation)) + { + // Attempt simple constant folding optimisation. + expression_node_ptr expression_point = node_allocator_->allocate<NodeType>(operation,branch); + + if (is_constant_foldable<N>(branch)) + { + const Type v = expression_point->value(); + details::free_node(*node_allocator_,expression_point); + + return node_allocator_->allocate<literal_node_t>(v); + } + + if (expression_point && expression_point->valid()) + { + return expression_point; + } + + parser_->set_error(parser_error::make_error( + parser_error::e_parser, + token_t(), + "ERR281 - Failed to synthesize node: NodeType", + exprtk_error_location)); + + details::free_node(*node_allocator_, expression_point); + } + + return error_node(); + } + + template <typename NodeType, std::size_t N> + inline expression_node_ptr synthesize_expression(F* f, expression_node_ptr (&branch)[N]) + { + if (!details::all_nodes_valid<N>(branch)) + { + free_all_nodes(*node_allocator_,branch); + + return error_node(); + } + + typedef typename details::function_N_node<T,ifunction_t,N> function_N_node_t; + + // Attempt simple constant folding optimisation. + + expression_node_ptr expression_point = node_allocator_->allocate<NodeType>(f); + function_N_node_t* func_node_ptr = dynamic_cast<function_N_node_t*>(expression_point); + + if (0 == func_node_ptr) + { + free_all_nodes(*node_allocator_,branch); + + return error_node(); + } + else + func_node_ptr->init_branches(branch); + + if (is_constant_foldable<N>(branch) && !f->has_side_effects()) + { + Type v = expression_point->value(); + details::free_node(*node_allocator_,expression_point); + + return node_allocator_->allocate<literal_node_t>(v); + } + + parser_->state_.activate_side_effect("synthesize_expression(function<NT,N>)"); + + return expression_point; + } + + bool strength_reduction_enabled_; + details::node_allocator* node_allocator_; + synthesize_map_t synthesize_map_; + unary_op_map_t* unary_op_map_; + binary_op_map_t* binary_op_map_; + inv_binary_op_map_t* inv_binary_op_map_; + sf3_map_t* sf3_map_; + sf4_map_t* sf4_map_; + parser_t* parser_; + }; // class expression_generator + + inline void set_error(const parser_error::type& error_type) + { + error_list_.push_back(error_type); + } + + inline void remove_last_error() + { + if (!error_list_.empty()) + { + error_list_.pop_back(); + } + } + + inline void set_synthesis_error(const std::string& synthesis_error_message) + { + if (synthesis_error_.empty()) + { + synthesis_error_ = synthesis_error_message; + } + } + + inline void register_local_vars(expression<T>& e) + { + for (std::size_t i = 0; i < sem_.size(); ++i) + { + scope_element& se = sem_.get_element(i); + + exprtk_debug(("register_local_vars() - se[%s]\n", se.name.c_str())); + + if ( + (scope_element::e_variable == se.type) || + (scope_element::e_literal == se.type) || + (scope_element::e_vecelem == se.type) + ) + { + if (se.var_node) + { + e.register_local_var(se.var_node); + } + + if (se.data) + { + e.register_local_data(se.data, 1, 0); + } + } + else if (scope_element::e_vector == se.type) + { + if (se.vec_node) + { + e.register_local_var(se.vec_node); + } + + if (se.data) + { + e.register_local_data(se.data, se.size, 1); + } + } + #ifndef exprtk_disable_string_capabilities + else if (scope_element::e_string == se.type) + { + if (se.str_node) + { + e.register_local_var(se.str_node); + } + + if (se.data) + { + e.register_local_data(se.data, se.size, 2); + } + } + #endif + + se.var_node = 0; + se.vec_node = 0; + #ifndef exprtk_disable_string_capabilities + se.str_node = 0; + #endif + se.data = 0; + se.ref_count = 0; + se.active = false; + } + } + + inline void register_return_results(expression<T>& e) + { + e.register_return_results(results_context_); + results_context_ = 0; + } + + inline void load_unary_operations_map(unary_op_map_t& m) + { + #define register_unary_op(Op, UnaryFunctor) \ + m.insert(std::make_pair(Op,UnaryFunctor<T>::process)); \ + + register_unary_op(details::e_abs , details::abs_op ) + register_unary_op(details::e_acos , details::acos_op ) + register_unary_op(details::e_acosh , details::acosh_op) + register_unary_op(details::e_asin , details::asin_op ) + register_unary_op(details::e_asinh , details::asinh_op) + register_unary_op(details::e_atanh , details::atanh_op) + register_unary_op(details::e_ceil , details::ceil_op ) + register_unary_op(details::e_cos , details::cos_op ) + register_unary_op(details::e_cosh , details::cosh_op ) + register_unary_op(details::e_exp , details::exp_op ) + register_unary_op(details::e_expm1 , details::expm1_op) + register_unary_op(details::e_floor , details::floor_op) + register_unary_op(details::e_log , details::log_op ) + register_unary_op(details::e_log10 , details::log10_op) + register_unary_op(details::e_log2 , details::log2_op ) + register_unary_op(details::e_log1p , details::log1p_op) + register_unary_op(details::e_neg , details::neg_op ) + register_unary_op(details::e_pos , details::pos_op ) + register_unary_op(details::e_round , details::round_op) + register_unary_op(details::e_sin , details::sin_op ) + register_unary_op(details::e_sinc , details::sinc_op ) + register_unary_op(details::e_sinh , details::sinh_op ) + register_unary_op(details::e_sqrt , details::sqrt_op ) + register_unary_op(details::e_tan , details::tan_op ) + register_unary_op(details::e_tanh , details::tanh_op ) + register_unary_op(details::e_cot , details::cot_op ) + register_unary_op(details::e_sec , details::sec_op ) + register_unary_op(details::e_csc , details::csc_op ) + register_unary_op(details::e_r2d , details::r2d_op ) + register_unary_op(details::e_d2r , details::d2r_op ) + register_unary_op(details::e_d2g , details::d2g_op ) + register_unary_op(details::e_g2d , details::g2d_op ) + register_unary_op(details::e_notl , details::notl_op ) + register_unary_op(details::e_sgn , details::sgn_op ) + register_unary_op(details::e_erf , details::erf_op ) + register_unary_op(details::e_erfc , details::erfc_op ) + register_unary_op(details::e_ncdf , details::ncdf_op ) + register_unary_op(details::e_frac , details::frac_op ) + register_unary_op(details::e_trunc , details::trunc_op) + #undef register_unary_op + } + + inline void load_binary_operations_map(binary_op_map_t& m) + { + typedef typename binary_op_map_t::value_type value_type; + + #define register_binary_op(Op, BinaryFunctor) \ + m.insert(value_type(Op,BinaryFunctor<T>::process)); \ + + register_binary_op(details::e_add , details::add_op ) + register_binary_op(details::e_sub , details::sub_op ) + register_binary_op(details::e_mul , details::mul_op ) + register_binary_op(details::e_div , details::div_op ) + register_binary_op(details::e_mod , details::mod_op ) + register_binary_op(details::e_pow , details::pow_op ) + register_binary_op(details::e_lt , details::lt_op ) + register_binary_op(details::e_lte , details::lte_op ) + register_binary_op(details::e_gt , details::gt_op ) + register_binary_op(details::e_gte , details::gte_op ) + register_binary_op(details::e_eq , details::eq_op ) + register_binary_op(details::e_ne , details::ne_op ) + register_binary_op(details::e_and , details::and_op ) + register_binary_op(details::e_nand , details::nand_op) + register_binary_op(details::e_or , details::or_op ) + register_binary_op(details::e_nor , details::nor_op ) + register_binary_op(details::e_xor , details::xor_op ) + register_binary_op(details::e_xnor , details::xnor_op) + #undef register_binary_op + } + + inline void load_inv_binary_operations_map(inv_binary_op_map_t& m) + { + typedef typename inv_binary_op_map_t::value_type value_type; + + #define register_binary_op(Op, BinaryFunctor) \ + m.insert(value_type(BinaryFunctor<T>::process,Op)); \ + + register_binary_op(details::e_add , details::add_op ) + register_binary_op(details::e_sub , details::sub_op ) + register_binary_op(details::e_mul , details::mul_op ) + register_binary_op(details::e_div , details::div_op ) + register_binary_op(details::e_mod , details::mod_op ) + register_binary_op(details::e_pow , details::pow_op ) + register_binary_op(details::e_lt , details::lt_op ) + register_binary_op(details::e_lte , details::lte_op ) + register_binary_op(details::e_gt , details::gt_op ) + register_binary_op(details::e_gte , details::gte_op ) + register_binary_op(details::e_eq , details::eq_op ) + register_binary_op(details::e_ne , details::ne_op ) + register_binary_op(details::e_and , details::and_op ) + register_binary_op(details::e_nand , details::nand_op) + register_binary_op(details::e_or , details::or_op ) + register_binary_op(details::e_nor , details::nor_op ) + register_binary_op(details::e_xor , details::xor_op ) + register_binary_op(details::e_xnor , details::xnor_op) + #undef register_binary_op + } + + inline void load_sf3_map(sf3_map_t& sf3_map) + { + typedef std::pair<trinary_functor_t,details::operator_type> pair_t; + + #define register_sf3(Op) \ + sf3_map[details::sf##Op##_op<T>::id()] = pair_t(details::sf##Op##_op<T>::process,details::e_sf##Op); \ + + register_sf3(00) register_sf3(01) register_sf3(02) register_sf3(03) + register_sf3(04) register_sf3(05) register_sf3(06) register_sf3(07) + register_sf3(08) register_sf3(09) register_sf3(10) register_sf3(11) + register_sf3(12) register_sf3(13) register_sf3(14) register_sf3(15) + register_sf3(16) register_sf3(17) register_sf3(18) register_sf3(19) + register_sf3(20) register_sf3(21) register_sf3(22) register_sf3(23) + register_sf3(24) register_sf3(25) register_sf3(26) register_sf3(27) + register_sf3(28) register_sf3(29) register_sf3(30) + #undef register_sf3 + + #define register_sf3_extid(Id, Op) \ + sf3_map[Id] = pair_t(details::sf##Op##_op<T>::process,details::e_sf##Op); \ + + register_sf3_extid("(t-t)-t",23) // (t-t)-t --> t-(t+t) + #undef register_sf3_extid + } + + inline void load_sf4_map(sf4_map_t& sf4_map) + { + typedef std::pair<quaternary_functor_t,details::operator_type> pair_t; + + #define register_sf4(Op) \ + sf4_map[details::sf##Op##_op<T>::id()] = pair_t(details::sf##Op##_op<T>::process,details::e_sf##Op); \ + + register_sf4(48) register_sf4(49) register_sf4(50) register_sf4(51) + register_sf4(52) register_sf4(53) register_sf4(54) register_sf4(55) + register_sf4(56) register_sf4(57) register_sf4(58) register_sf4(59) + register_sf4(60) register_sf4(61) register_sf4(62) register_sf4(63) + register_sf4(64) register_sf4(65) register_sf4(66) register_sf4(67) + register_sf4(68) register_sf4(69) register_sf4(70) register_sf4(71) + register_sf4(72) register_sf4(73) register_sf4(74) register_sf4(75) + register_sf4(76) register_sf4(77) register_sf4(78) register_sf4(79) + register_sf4(80) register_sf4(81) register_sf4(82) register_sf4(83) + #undef register_sf4 + + #define register_sf4ext(Op) \ + sf4_map[details::sfext##Op##_op<T>::id()] = pair_t(details::sfext##Op##_op<T>::process,details::e_sf4ext##Op); \ + + register_sf4ext(00) register_sf4ext(01) register_sf4ext(02) register_sf4ext(03) + register_sf4ext(04) register_sf4ext(05) register_sf4ext(06) register_sf4ext(07) + register_sf4ext(08) register_sf4ext(09) register_sf4ext(10) register_sf4ext(11) + register_sf4ext(12) register_sf4ext(13) register_sf4ext(14) register_sf4ext(15) + register_sf4ext(16) register_sf4ext(17) register_sf4ext(18) register_sf4ext(19) + register_sf4ext(20) register_sf4ext(21) register_sf4ext(22) register_sf4ext(23) + register_sf4ext(24) register_sf4ext(25) register_sf4ext(26) register_sf4ext(27) + register_sf4ext(28) register_sf4ext(29) register_sf4ext(30) register_sf4ext(31) + register_sf4ext(32) register_sf4ext(33) register_sf4ext(34) register_sf4ext(35) + register_sf4ext(36) register_sf4ext(36) register_sf4ext(38) register_sf4ext(39) + register_sf4ext(40) register_sf4ext(41) register_sf4ext(42) register_sf4ext(43) + register_sf4ext(44) register_sf4ext(45) register_sf4ext(46) register_sf4ext(47) + register_sf4ext(48) register_sf4ext(49) register_sf4ext(50) register_sf4ext(51) + register_sf4ext(52) register_sf4ext(53) register_sf4ext(54) register_sf4ext(55) + register_sf4ext(56) register_sf4ext(57) register_sf4ext(58) register_sf4ext(59) + register_sf4ext(60) register_sf4ext(61) + #undef register_sf4ext + } + + inline results_context_t& results_ctx() + { + if (0 == results_context_) + { + results_context_ = new results_context_t(); + } + + return (*results_context_); + } + + inline void return_cleanup() + { + #ifndef exprtk_disable_return_statement + if (results_context_) + { + delete results_context_; + results_context_ = 0; + } + + state_.return_stmt_present = false; + #endif + } + + inline bool valid_settings() + { + const std::size_t max_local_vector_size_bytes = sizeof(T) * settings_.max_local_vector_size(); + + if (max_local_vector_size_bytes > settings_.max_total_local_symbol_size_bytes()) + { + set_error(make_error( + parser_error::e_parser, + "ERR282 - Max local vector size of " + details::to_str(max_local_vector_size_bytes) + " bytes " + "is larger than max total local symbol size of " + details::to_str(settings_.max_total_local_symbol_size_bytes()) + " bytes", + exprtk_error_location)); + + return false; + } + + return true; + } + + private: + + parser(const parser<T>&) exprtk_delete; + parser<T>& operator=(const parser<T>&) exprtk_delete; + + settings_store settings_; + expression_generator<T> expression_generator_; + details::node_allocator node_allocator_; + symtab_store symtab_store_; + dependent_entity_collector dec_; + std::deque<parser_error::type> error_list_; + std::deque<bool> brkcnt_list_; + parser_state state_; + bool resolve_unknown_symbol_; + results_context_t* results_context_; + unknown_symbol_resolver* unknown_symbol_resolver_; + unknown_symbol_resolver default_usr_; + base_ops_map_t base_ops_map_; + unary_op_map_t unary_op_map_; + binary_op_map_t binary_op_map_; + inv_binary_op_map_t inv_binary_op_map_; + sf3_map_t sf3_map_; + sf4_map_t sf4_map_; + std::string synthesis_error_; + scope_element_manager sem_; + std::vector<state_t> current_state_stack_; + + immutable_memory_map_t immutable_memory_map_; + immutable_symtok_map_t immutable_symtok_map_; + + lexer::helper::helper_assembly helper_assembly_; + + lexer::helper::commutative_inserter commutative_inserter_; + lexer::helper::operator_joiner operator_joiner_2_; + lexer::helper::operator_joiner operator_joiner_3_; + lexer::helper::symbol_replacer symbol_replacer_; + lexer::helper::bracket_checker bracket_checker_; + lexer::helper::numeric_checker<T> numeric_checker_; + lexer::helper::sequence_validator sequence_validator_; + lexer::helper::sequence_validator_3tokens sequence_validator_3tkns_; + + loop_runtime_check_ptr loop_runtime_check_; + vector_access_runtime_check_ptr vector_access_runtime_check_; + compilation_check_ptr compilation_check_ptr_; + assert_check_ptr assert_check_; + std::set<std::string> assert_ids_; + + template <typename ParserType> + friend void details::disable_type_checking(ParserType& p); + }; // class parser + + namespace details + { + template <typename T> + struct collector_helper + { + typedef exprtk::symbol_table<T> symbol_table_t; + typedef exprtk::expression<T> expression_t; + typedef exprtk::parser<T> parser_t; + typedef typename parser_t::dependent_entity_collector::symbol_t symbol_t; + typedef typename parser_t::unknown_symbol_resolver usr_t; + + struct resolve_as_vector : public usr_t + { + typedef exprtk::parser<T> parser_t; + + using usr_t::process; + + resolve_as_vector() + : usr_t(usr_t::e_usrmode_extended) + {} + + virtual bool process(const std::string& unknown_symbol, + symbol_table_t& symbol_table, + std::string&) exprtk_override + { + static T v[1]; + symbol_table.add_vector(unknown_symbol,v); + return true; + } + }; + + static inline bool collection_pass(const std::string& expression_string, + std::set<std::string>& symbol_set, + const bool collect_variables, + const bool collect_functions, + const bool vector_pass, + symbol_table_t& ext_symbol_table) + { + symbol_table_t symbol_table; + expression_t expression; + parser_t parser; + + resolve_as_vector vect_resolver; + + expression.register_symbol_table(symbol_table ); + expression.register_symbol_table(ext_symbol_table); + + if (vector_pass) + parser.enable_unknown_symbol_resolver(&vect_resolver); + else + parser.enable_unknown_symbol_resolver(); + + if (collect_variables) + parser.dec().collect_variables() = true; + + if (collect_functions) + parser.dec().collect_functions() = true; + + bool pass_result = false; + + details::disable_type_checking(parser); + + if (parser.compile(expression_string, expression)) + { + pass_result = true; + + std::deque<symbol_t> symb_list; + parser.dec().symbols(symb_list); + + for (std::size_t i = 0; i < symb_list.size(); ++i) + { + symbol_set.insert(symb_list[i].first); + } + } + + return pass_result; + } + }; + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline bool collect_variables(const std::string& expression, + Sequence<std::string, Allocator>& symbol_list) + { + typedef double T; + typedef details::collector_helper<T> collect_t; + + collect_t::symbol_table_t null_symbol_table; + + std::set<std::string> symbol_set; + + const bool variable_pass = collect_t::collection_pass + (expression, symbol_set, true, false, false, null_symbol_table); + const bool vector_pass = collect_t::collection_pass + (expression, symbol_set, true, false, true, null_symbol_table); + + if (!variable_pass && !vector_pass) + return false; + + std::set<std::string>::iterator itr = symbol_set.begin(); + + while (symbol_set.end() != itr) + { + symbol_list.push_back(*itr); + ++itr; + } + + return true; + } + + template <typename T, + typename Allocator, + template <typename, typename> class Sequence> + inline bool collect_variables(const std::string& expression, + exprtk::symbol_table<T>& extrnl_symbol_table, + Sequence<std::string, Allocator>& symbol_list) + { + typedef details::collector_helper<T> collect_t; + + std::set<std::string> symbol_set; + + const bool variable_pass = collect_t::collection_pass + (expression, symbol_set, true, false, false, extrnl_symbol_table); + const bool vector_pass = collect_t::collection_pass + (expression, symbol_set, true, false, true, extrnl_symbol_table); + + if (!variable_pass && !vector_pass) + return false; + + std::set<std::string>::iterator itr = symbol_set.begin(); + + while (symbol_set.end() != itr) + { + symbol_list.push_back(*itr); + ++itr; + } + + return true; + } + + template <typename Allocator, + template <typename, typename> class Sequence> + inline bool collect_functions(const std::string& expression, + Sequence<std::string, Allocator>& symbol_list) + { + typedef double T; + typedef details::collector_helper<T> collect_t; + + collect_t::symbol_table_t null_symbol_table; + + std::set<std::string> symbol_set; + + const bool variable_pass = collect_t::collection_pass + (expression, symbol_set, false, true, false, null_symbol_table); + const bool vector_pass = collect_t::collection_pass + (expression, symbol_set, false, true, true, null_symbol_table); + + if (!variable_pass && !vector_pass) + return false; + + std::set<std::string>::iterator itr = symbol_set.begin(); + + while (symbol_set.end() != itr) + { + symbol_list.push_back(*itr); + ++itr; + } + + return true; + } + + template <typename T, + typename Allocator, + template <typename, typename> class Sequence> + inline bool collect_functions(const std::string& expression, + exprtk::symbol_table<T>& extrnl_symbol_table, + Sequence<std::string, Allocator>& symbol_list) + { + typedef details::collector_helper<T> collect_t; + + std::set<std::string> symbol_set; + + const bool variable_pass = collect_t::collection_pass + (expression, symbol_set, false, true, false, extrnl_symbol_table); + const bool vector_pass = collect_t::collection_pass + (expression, symbol_set, false, true, true, extrnl_symbol_table); + + if (!variable_pass && !vector_pass) + return false; + + std::set<std::string>::iterator itr = symbol_set.begin(); + + while (symbol_set.end() != itr) + { + symbol_list.push_back(*itr); + ++itr; + } + + return true; + } + + template <typename T> + inline T integrate(const expression<T>& e, + T& x, + const T& r0, const T& r1, + const std::size_t number_of_intervals = 1000000) + { + if (r0 > r1) + return T(0); + + const T h = (r1 - r0) / (T(2) * number_of_intervals); + T total_area = T(0); + + for (std::size_t i = 0; i < number_of_intervals; ++i) + { + x = r0 + T(2) * i * h; + const T y0 = e.value(); x += h; + const T y1 = e.value(); x += h; + const T y2 = e.value(); x += h; + total_area += h * (y0 + T(4) * y1 + y2) / T(3); + } + + return total_area; + } + + template <typename T> + inline T integrate(const expression<T>& e, + const std::string& variable_name, + const T& r0, const T& r1, + const std::size_t number_of_intervals = 1000000) + { + const symbol_table<T>& sym_table = e.get_symbol_table(); + + if (!sym_table.valid()) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + details::variable_node<T>* var = sym_table.get_variable(variable_name); + + if (var) + { + T& x = var->ref(); + const T x_original = x; + const T result = integrate(e, x, r0, r1, number_of_intervals); + x = x_original; + + return result; + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + template <typename T> + inline T derivative(const expression<T>& e, + T& x, + const T& h = T(0.00000001)) + { + const T x_init = x; + const T _2h = T(2) * h; + + x = x_init + _2h; + const T y0 = e.value(); + x = x_init + h; + const T y1 = e.value(); + x = x_init - h; + const T y2 = e.value(); + x = x_init - _2h; + const T y3 = e.value(); + x = x_init; + + return (-y0 + T(8) * (y1 - y2) + y3) / (T(12) * h); + } + + template <typename T> + inline T second_derivative(const expression<T>& e, + T& x, + const T& h = T(0.00001)) + { + const T x_init = x; + const T _2h = T(2) * h; + + const T y = e.value(); + x = x_init + _2h; + const T y0 = e.value(); + x = x_init + h; + const T y1 = e.value(); + x = x_init - h; + const T y2 = e.value(); + x = x_init - _2h; + const T y3 = e.value(); + x = x_init; + + return (-y0 + T(16) * (y1 + y2) - T(30) * y - y3) / (T(12) * h * h); + } + + template <typename T> + inline T third_derivative(const expression<T>& e, + T& x, + const T& h = T(0.0001)) + { + const T x_init = x; + const T _2h = T(2) * h; + + x = x_init + _2h; + const T y0 = e.value(); + x = x_init + h; + const T y1 = e.value(); + x = x_init - h; + const T y2 = e.value(); + x = x_init - _2h; + const T y3 = e.value(); + x = x_init; + + return (y0 + T(2) * (y2 - y1) - y3) / (T(2) * h * h * h); + } + + template <typename T> + inline T derivative(const expression<T>& e, + const std::string& variable_name, + const T& h = T(0.00000001)) + { + const symbol_table<T>& sym_table = e.get_symbol_table(); + + if (!sym_table.valid()) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + details::variable_node<T>* var = sym_table.get_variable(variable_name); + + if (var) + { + T& x = var->ref(); + const T x_original = x; + const T result = derivative(e, x, h); + x = x_original; + + return result; + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + template <typename T> + inline T second_derivative(const expression<T>& e, + const std::string& variable_name, + const T& h = T(0.00001)) + { + const symbol_table<T>& sym_table = e.get_symbol_table(); + + if (!sym_table.valid()) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + details::variable_node<T>* var = sym_table.get_variable(variable_name); + + if (var) + { + T& x = var->ref(); + const T x_original = x; + const T result = second_derivative(e, x, h); + x = x_original; + + return result; + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + template <typename T> + inline T third_derivative(const expression<T>& e, + const std::string& variable_name, + const T& h = T(0.0001)) + { + const symbol_table<T>& sym_table = e.get_symbol_table(); + + if (!sym_table.valid()) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + details::variable_node<T>* var = sym_table.get_variable(variable_name); + + if (var) + { + T& x = var->ref(); + const T x_original = x; + const T result = third_derivative(e, x, h); + x = x_original; + + return result; + } + + return std::numeric_limits<T>::quiet_NaN(); + } + + /* + Note: The following 'compute' routines are simple helpers, + for quickly setting up the required pieces of code in order + to evaluate an expression. By virtue of how they operate + there will be an overhead with regards to their setup and + teardown and hence should not be used in time critical + sections of code. + Furthermore they only assume a small sub set of variables, + no string variables or user defined functions. + */ + template <typename T> + inline bool compute(const std::string& expression_string, T& result) + { + // No variables + symbol_table<T> symbol_table; + symbol_table.add_constants(); + + expression<T> expression; + expression.register_symbol_table(symbol_table); + + parser<T> parser; + + if (parser.compile(expression_string,expression)) + { + result = expression.value(); + + return true; + } + else + return false; + } + + template <typename T> + inline bool compute(const std::string& expression_string, + const T& x, + T& result) + { + // Only 'x' + static const std::string x_var("x"); + + symbol_table<T> symbol_table; + symbol_table.add_constants(); + symbol_table.add_constant(x_var,x); + + expression<T> expression; + expression.register_symbol_table(symbol_table); + + parser<T> parser; + + if (parser.compile(expression_string,expression)) + { + result = expression.value(); + + return true; + } + else + return false; + } + + template <typename T> + inline bool compute(const std::string& expression_string, + const T&x, const T& y, + T& result) + { + // Only 'x' and 'y' + static const std::string x_var("x"); + static const std::string y_var("y"); + + symbol_table<T> symbol_table; + symbol_table.add_constants(); + symbol_table.add_constant(x_var,x); + symbol_table.add_constant(y_var,y); + + expression<T> expression; + expression.register_symbol_table(symbol_table); + + parser<T> parser; + + if (parser.compile(expression_string,expression)) + { + result = expression.value(); + + return true; + } + else + return false; + } + + template <typename T> + inline bool compute(const std::string& expression_string, + const T& x, const T& y, const T& z, + T& result) + { + // Only 'x', 'y' or 'z' + static const std::string x_var("x"); + static const std::string y_var("y"); + static const std::string z_var("z"); + + symbol_table<T> symbol_table; + symbol_table.add_constants(); + symbol_table.add_constant(x_var,x); + symbol_table.add_constant(y_var,y); + symbol_table.add_constant(z_var,z); + + expression<T> expression; + expression.register_symbol_table(symbol_table); + + parser<T> parser; + + if (parser.compile(expression_string,expression)) + { + result = expression.value(); + + return true; + } + else + return false; + } + + template <typename T, std::size_t N> + class polynomial : public ifunction<T> + { + private: + + template <typename Type, std::size_t NumberOfCoefficients> + struct poly_impl { }; + + template <typename Type> + struct poly_impl <Type,12> + { + static inline T evaluate(const Type x, + const Type c12, const Type c11, const Type c10, const Type c9, const Type c8, + const Type c7, const Type c6, const Type c5, const Type c4, const Type c3, + const Type c2, const Type c1, const Type c0) + { + // p(x) = c_12x^12 + c_11x^11 + c_10x^10 + c_9x^9 + c_8x^8 + c_7x^7 + c_6x^6 + c_5x^5 + c_4x^4 + c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return ((((((((((((c12 * x + c11) * x + c10) * x + c9) * x + c8) * x + c7) * x + c6) * x + c5) * x + c4) * x + c3) * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,11> + { + static inline T evaluate(const Type x, + const Type c11, const Type c10, const Type c9, const Type c8, const Type c7, + const Type c6, const Type c5, const Type c4, const Type c3, const Type c2, + const Type c1, const Type c0) + { + // p(x) = c_11x^11 + c_10x^10 + c_9x^9 + c_8x^8 + c_7x^7 + c_6x^6 + c_5x^5 + c_4x^4 + c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return (((((((((((c11 * x + c10) * x + c9) * x + c8) * x + c7) * x + c6) * x + c5) * x + c4) * x + c3) * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,10> + { + static inline T evaluate(const Type x, + const Type c10, const Type c9, const Type c8, const Type c7, const Type c6, + const Type c5, const Type c4, const Type c3, const Type c2, const Type c1, + const Type c0) + { + // p(x) = c_10x^10 + c_9x^9 + c_8x^8 + c_7x^7 + c_6x^6 + c_5x^5 + c_4x^4 + c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return ((((((((((c10 * x + c9) * x + c8) * x + c7) * x + c6) * x + c5) * x + c4) * x + c3) * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,9> + { + static inline T evaluate(const Type x, + const Type c9, const Type c8, const Type c7, const Type c6, const Type c5, + const Type c4, const Type c3, const Type c2, const Type c1, const Type c0) + { + // p(x) = c_9x^9 + c_8x^8 + c_7x^7 + c_6x^6 + c_5x^5 + c_4x^4 + c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return (((((((((c9 * x + c8) * x + c7) * x + c6) * x + c5) * x + c4) * x + c3) * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,8> + { + static inline T evaluate(const Type x, + const Type c8, const Type c7, const Type c6, const Type c5, const Type c4, + const Type c3, const Type c2, const Type c1, const Type c0) + { + // p(x) = c_8x^8 + c_7x^7 + c_6x^6 + c_5x^5 + c_4x^4 + c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return ((((((((c8 * x + c7) * x + c6) * x + c5) * x + c4) * x + c3) * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,7> + { + static inline T evaluate(const Type x, + const Type c7, const Type c6, const Type c5, const Type c4, const Type c3, + const Type c2, const Type c1, const Type c0) + { + // p(x) = c_7x^7 + c_6x^6 + c_5x^5 + c_4x^4 + c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return (((((((c7 * x + c6) * x + c5) * x + c4) * x + c3) * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,6> + { + static inline T evaluate(const Type x, + const Type c6, const Type c5, const Type c4, const Type c3, const Type c2, + const Type c1, const Type c0) + { + // p(x) = c_6x^6 + c_5x^5 + c_4x^4 + c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return ((((((c6 * x + c5) * x + c4) * x + c3) * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,5> + { + static inline T evaluate(const Type x, + const Type c5, const Type c4, const Type c3, const Type c2, + const Type c1, const Type c0) + { + // p(x) = c_5x^5 + c_4x^4 + c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return (((((c5 * x + c4) * x + c3) * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,4> + { + static inline T evaluate(const Type x, const Type c4, const Type c3, const Type c2, const Type c1, const Type c0) + { + // p(x) = c_4x^4 + c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return ((((c4 * x + c3) * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,3> + { + static inline T evaluate(const Type x, const Type c3, const Type c2, const Type c1, const Type c0) + { + // p(x) = c_3x^3 + c_2x^2 + c_1x^1 + c_0x^0 + return (((c3 * x + c2) * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,2> + { + static inline T evaluate(const Type x, const Type c2, const Type c1, const Type c0) + { + // p(x) = c_2x^2 + c_1x^1 + c_0x^0 + return ((c2 * x + c1) * x + c0); + } + }; + + template <typename Type> + struct poly_impl <Type,1> + { + static inline T evaluate(const Type x, const Type c1, const Type c0) + { + // p(x) = c_1x^1 + c_0x^0 + return (c1 * x + c0); + } + }; + + public: + + using ifunction<T>::operator(); + + polynomial() + : ifunction<T>((N+2 <= 20) ? (N + 2) : std::numeric_limits<std::size_t>::max()) + { + disable_has_side_effects(*this); + } + + virtual ~polynomial() exprtk_override + {} + + #define poly_rtrn(NN) \ + return (NN != N) ? std::numeric_limits<T>::quiet_NaN() : + + inline virtual T operator() (const T& x, const T& c1, const T& c0) exprtk_override + { + poly_rtrn(1) (poly_impl<T,1>::evaluate(x, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c2, const T& c1, const T& c0) exprtk_override + { + poly_rtrn(2) (poly_impl<T,2>::evaluate(x, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c3, const T& c2, const T& c1, const T& c0) exprtk_override + { + poly_rtrn(3) (poly_impl<T,3>::evaluate(x, c3, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c4, const T& c3, const T& c2, const T& c1, + const T& c0) exprtk_override + { + poly_rtrn(4) (poly_impl<T,4>::evaluate(x, c4, c3, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c5, const T& c4, const T& c3, const T& c2, + const T& c1, const T& c0) exprtk_override + { + poly_rtrn(5) (poly_impl<T,5>::evaluate(x, c5, c4, c3, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c6, const T& c5, const T& c4, const T& c3, + const T& c2, const T& c1, const T& c0) exprtk_override + { + poly_rtrn(6) (poly_impl<T,6>::evaluate(x, c6, c5, c4, c3, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c7, const T& c6, const T& c5, const T& c4, + const T& c3, const T& c2, const T& c1, const T& c0) exprtk_override + { + poly_rtrn(7) (poly_impl<T,7>::evaluate(x, c7, c6, c5, c4, c3, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c8, const T& c7, const T& c6, const T& c5, + const T& c4, const T& c3, const T& c2, const T& c1, const T& c0) exprtk_override + { + poly_rtrn(8) (poly_impl<T,8>::evaluate(x, c8, c7, c6, c5, c4, c3, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c9, const T& c8, const T& c7, const T& c6, + const T& c5, const T& c4, const T& c3, const T& c2, const T& c1, + const T& c0) exprtk_override + { + poly_rtrn(9) (poly_impl<T,9>::evaluate(x, c9, c8, c7, c6, c5, c4, c3, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c10, const T& c9, const T& c8, const T& c7, + const T& c6, const T& c5, const T& c4, const T& c3, const T& c2, + const T& c1, const T& c0) exprtk_override + { + poly_rtrn(10) (poly_impl<T,10>::evaluate(x, c10, c9, c8, c7, c6, c5, c4, c3, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c11, const T& c10, const T& c9, const T& c8, + const T& c7, const T& c6, const T& c5, const T& c4, const T& c3, + const T& c2, const T& c1, const T& c0) exprtk_override + { + poly_rtrn(11) (poly_impl<T,11>::evaluate(x, c11, c10, c9, c8, c7, c6, c5, c4, c3, c2, c1, c0)); + } + + inline virtual T operator() (const T& x, const T& c12, const T& c11, const T& c10, const T& c9, + const T& c8, const T& c7, const T& c6, const T& c5, const T& c4, + const T& c3, const T& c2, const T& c1, const T& c0) exprtk_override + { + poly_rtrn(12) (poly_impl<T,12>::evaluate(x, c12, c11, c10, c9, c8, c7, c6, c5, c4, c3, c2, c1, c0)); + } + + #undef poly_rtrn + + inline virtual T operator() () exprtk_override + { + return std::numeric_limits<T>::quiet_NaN(); + } + + inline virtual T operator() (const T&) exprtk_override + { + return std::numeric_limits<T>::quiet_NaN(); + } + + inline virtual T operator() (const T&, const T&) exprtk_override + { + return std::numeric_limits<T>::quiet_NaN(); + } + }; + + template <typename T> + class function_compositor + { + public: + + typedef exprtk::expression<T> expression_t; + typedef exprtk::symbol_table<T> symbol_table_t; + typedef exprtk::parser<T> parser_t; + typedef typename parser_t::settings_store settings_t; + + struct function + { + function() + {} + + explicit function(const std::string& n) + : name_(n) + {} + + function(const std::string& name, + const std::string& expression) + : name_(name) + , expression_(expression) + {} + + function(const std::string& name, + const std::string& expression, + const std::string& v0) + : name_(name) + , expression_(expression) + { + v_.push_back(v0); + } + + function(const std::string& name, + const std::string& expression, + const std::string& v0, const std::string& v1) + : name_(name) + , expression_(expression) + { + v_.push_back(v0); v_.push_back(v1); + } + + function(const std::string& name, + const std::string& expression, + const std::string& v0, const std::string& v1, + const std::string& v2) + : name_(name) + , expression_(expression) + { + v_.push_back(v0); v_.push_back(v1); + v_.push_back(v2); + } + + function(const std::string& name, + const std::string& expression, + const std::string& v0, const std::string& v1, + const std::string& v2, const std::string& v3) + : name_(name) + , expression_(expression) + { + v_.push_back(v0); v_.push_back(v1); + v_.push_back(v2); v_.push_back(v3); + } + + function(const std::string& name, + const std::string& expression, + const std::string& v0, const std::string& v1, + const std::string& v2, const std::string& v3, + const std::string& v4) + : name_(name) + , expression_(expression) + { + v_.push_back(v0); v_.push_back(v1); + v_.push_back(v2); v_.push_back(v3); + v_.push_back(v4); + } + + inline function& name(const std::string& n) + { + name_ = n; + return (*this); + } + + inline function& expression(const std::string& e) + { + expression_ = e; + return (*this); + } + + inline function& var(const std::string& v) + { + v_.push_back(v); + return (*this); + } + + inline function& vars(const std::string& v0, + const std::string& v1) + { + v_.push_back(v0); + v_.push_back(v1); + return (*this); + } + + inline function& vars(const std::string& v0, + const std::string& v1, + const std::string& v2) + { + v_.push_back(v0); + v_.push_back(v1); + v_.push_back(v2); + return (*this); + } + + inline function& vars(const std::string& v0, + const std::string& v1, + const std::string& v2, + const std::string& v3) + { + v_.push_back(v0); + v_.push_back(v1); + v_.push_back(v2); + v_.push_back(v3); + return (*this); + } + + inline function& vars(const std::string& v0, + const std::string& v1, + const std::string& v2, + const std::string& v3, + const std::string& v4) + { + v_.push_back(v0); + v_.push_back(v1); + v_.push_back(v2); + v_.push_back(v3); + v_.push_back(v4); + return (*this); + } + + std::string name_; + std::string expression_; + std::deque<std::string> v_; + }; + + private: + + struct base_func : public exprtk::ifunction<T> + { + typedef const T& type; + typedef exprtk::ifunction<T> function_t; + typedef std::vector<T*> varref_t; + typedef std::vector<T> var_t; + typedef std::vector<std::string> str_t; + typedef std::pair<T*,std::size_t> lvarref_t; + typedef std::vector<lvarref_t> lvr_vec_t; + typedef std::vector<std::string*> lstr_vec_t; + + using exprtk::ifunction<T>::operator(); + + explicit base_func(const std::size_t& pc = 0) + : exprtk::ifunction<T>(pc) + , local_var_stack_size(0) + , stack_depth(0) + { + v.resize(pc); + } + + virtual ~base_func() + {} + + #define exprtk_assign(Index) \ + (*v[Index]) = v##Index; \ + + inline void update(const T& v0) + { + exprtk_assign(0) + } + + inline void update(const T& v0, const T& v1) + { + exprtk_assign(0) exprtk_assign(1) + } + + inline void update(const T& v0, const T& v1, const T& v2) + { + exprtk_assign(0) exprtk_assign(1) + exprtk_assign(2) + } + + inline void update(const T& v0, const T& v1, const T& v2, const T& v3) + { + exprtk_assign(0) exprtk_assign(1) + exprtk_assign(2) exprtk_assign(3) + } + + inline void update(const T& v0, const T& v1, const T& v2, const T& v3, const T& v4) + { + exprtk_assign(0) exprtk_assign(1) + exprtk_assign(2) exprtk_assign(3) + exprtk_assign(4) + } + + inline void update(const T& v0, const T& v1, const T& v2, const T& v3, const T& v4, const T& v5) + { + exprtk_assign(0) exprtk_assign(1) + exprtk_assign(2) exprtk_assign(3) + exprtk_assign(4) exprtk_assign(5) + } + + #ifdef exprtk_assign + #undef exprtk_assign + #endif + + inline function_t& setup(expression_t& expr) + { + expression = expr; + + typedef typename expression_t::control_block ctrlblk_t; + typedef typename ctrlblk_t::local_data_list_t ldl_t; + typedef typename ctrlblk_t::data_type data_t; + typedef typename ldl_t::value_type ldl_value_type; + + const ldl_t ldl = expr.local_data_list(); + + std::vector<std::pair<std::size_t,data_t> > index_list; + + for (std::size_t i = 0; i < ldl.size(); ++i) + { + exprtk_debug(("base_func::setup() - element[%02d] type: %s size: %d\n", + static_cast<int>(i), + expression_t::control_block::to_str(ldl[i].type).c_str(), + static_cast<int>(ldl[i].size))); + + switch (ldl[i].type) + { + case ctrlblk_t::e_unknown : continue; + case ctrlblk_t::e_expr : continue; + case ctrlblk_t::e_vecholder : continue; + default : break; + } + + if (ldl[i].size) + { + index_list.push_back(std::make_pair(i,ldl[i].type)); + } + } + + std::size_t input_param_count = 0; + + for (std::size_t i = 0; i < index_list.size(); ++i) + { + const std::size_t index = index_list[i].first; + const ldl_value_type& local_var = ldl[index]; + + assert(local_var.pointer); + + if (i < (index_list.size() - v.size())) + { + if (local_var.type == ctrlblk_t::e_string) + { + local_str_vars.push_back( + reinterpret_cast<std::string*>(local_var.pointer)); + } + else if ( + (local_var.type == ctrlblk_t::e_data ) || + (local_var.type == ctrlblk_t::e_vecdata) + ) + { + local_vars.push_back(std::make_pair( + reinterpret_cast<T*>(local_var.pointer), + local_var.size)); + + local_var_stack_size += local_var.size; + } + } + else + { + v[input_param_count++] = reinterpret_cast<T*>(local_var.pointer); + } + } + + clear_stack(); + + return (*this); + } + + inline void pre() + { + if (stack_depth++) + { + if (!v.empty()) + { + var_t var_stack(v.size(),T(0)); + copy(v,var_stack); + input_params_stack.push_back(var_stack); + } + + if (!local_vars.empty()) + { + var_t local_vec_frame(local_var_stack_size,T(0)); + copy(local_vars,local_vec_frame); + local_var_stack.push_back(local_vec_frame); + } + + if (!local_str_vars.empty()) + { + str_t local_str_frame(local_str_vars.size()); + copy(local_str_vars,local_str_frame); + local_str_stack.push_back(local_str_frame); + } + } + } + + inline void post() + { + if (--stack_depth) + { + if (!v.empty()) + { + copy(input_params_stack.back(), v); + input_params_stack.pop_back(); + } + + if (!local_vars.empty()) + { + copy(local_var_stack.back(), local_vars); + local_var_stack.pop_back(); + } + + if (!local_str_vars.empty()) + { + copy(local_str_stack.back(), local_str_vars); + local_str_stack.pop_back(); + } + } + } + + void copy(const varref_t& src_v, var_t& dest_v) + { + for (std::size_t i = 0; i < src_v.size(); ++i) + { + dest_v[i] = (*src_v[i]); + } + } + + void copy(const lstr_vec_t& src_v, str_t& dest_v) + { + for (std::size_t i = 0; i < src_v.size(); ++i) + { + dest_v[i] = (*src_v[i]); + } + } + + void copy(const var_t& src_v, varref_t& dest_v) + { + for (std::size_t i = 0; i < src_v.size(); ++i) + { + (*dest_v[i]) = src_v[i]; + } + } + + void copy(const lvr_vec_t& src_v, var_t& dest_v) + { + typename var_t::iterator itr = dest_v.begin(); + typedef typename std::iterator_traits<typename var_t::iterator>::difference_type diff_t; + + for (std::size_t i = 0; i < src_v.size(); ++i) + { + lvarref_t vr = src_v[i]; + + if (1 == vr.second) + *itr++ = (*vr.first); + else + { + std::copy(vr.first, vr.first + vr.second, itr); + itr += static_cast<diff_t>(vr.second); + } + } + } + + void copy(const var_t& src_v, lvr_vec_t& dest_v) + { + typename var_t::const_iterator itr = src_v.begin(); + typedef typename std::iterator_traits<typename var_t::iterator>::difference_type diff_t; + + for (std::size_t i = 0; i < dest_v.size(); ++i) + { + lvarref_t& vr = dest_v[i]; + + assert(vr.first != 0); + assert(vr.second > 0); + + if (1 == vr.second) + (*vr.first) = *itr++; + else + { + std::copy(itr, itr + static_cast<diff_t>(vr.second), vr.first); + itr += static_cast<diff_t>(vr.second); + } + } + } + + void copy(const str_t& src_str, lstr_vec_t& dest_str) + { + assert(src_str.size() == dest_str.size()); + + for (std::size_t i = 0; i < dest_str.size(); ++i) + { + *dest_str[i] = src_str[i]; + } + } + + inline void clear_stack() + { + for (std::size_t i = 0; i < v.size(); ++i) + { + (*v[i]) = 0; + } + } + + inline virtual T value(expression_t& e) + { + return e.value(); + } + + expression_t expression; + varref_t v; + lvr_vec_t local_vars; + lstr_vec_t local_str_vars; + std::size_t local_var_stack_size; + std::size_t stack_depth; + std::deque<var_t> input_params_stack; + std::deque<var_t> local_var_stack; + std::deque<str_t> local_str_stack; + }; + + typedef std::map<std::string,base_func*> funcparam_t; + + typedef const T& type; + + template <typename BaseFuncType> + struct scoped_bft + { + explicit scoped_bft(BaseFuncType& bft) + : bft_(bft) + { + bft_.pre (); + } + + ~scoped_bft() + { + bft_.post(); + } + + BaseFuncType& bft_; + + private: + + scoped_bft(const scoped_bft&) exprtk_delete; + scoped_bft& operator=(const scoped_bft&) exprtk_delete; + }; + + struct func_0param : public base_func + { + using exprtk::ifunction<T>::operator(); + + func_0param() : base_func(0) {} + + inline T operator() () exprtk_override + { + scoped_bft<func_0param> sb(*this); + return this->value(base_func::expression); + } + }; + + struct func_1param : public base_func + { + using exprtk::ifunction<T>::operator(); + + func_1param() : base_func(1) {} + + inline T operator() (type v0) exprtk_override + { + scoped_bft<func_1param> sb(*this); + base_func::update(v0); + return this->value(base_func::expression); + } + }; + + struct func_2param : public base_func + { + using exprtk::ifunction<T>::operator(); + + func_2param() : base_func(2) {} + + inline T operator() (type v0, type v1) exprtk_override + { + scoped_bft<func_2param> sb(*this); + base_func::update(v0, v1); + return this->value(base_func::expression); + } + }; + + struct func_3param : public base_func + { + using exprtk::ifunction<T>::operator(); + + func_3param() : base_func(3) {} + + inline T operator() (type v0, type v1, type v2) exprtk_override + { + scoped_bft<func_3param> sb(*this); + base_func::update(v0, v1, v2); + return this->value(base_func::expression); + } + }; + + struct func_4param : public base_func + { + using exprtk::ifunction<T>::operator(); + + func_4param() : base_func(4) {} + + inline T operator() (type v0, type v1, type v2, type v3) exprtk_override + { + scoped_bft<func_4param> sb(*this); + base_func::update(v0, v1, v2, v3); + return this->value(base_func::expression); + } + }; + + struct func_5param : public base_func + { + using exprtk::ifunction<T>::operator(); + + func_5param() : base_func(5) {} + + inline T operator() (type v0, type v1, type v2, type v3, type v4) exprtk_override + { + scoped_bft<func_5param> sb(*this); + base_func::update(v0, v1, v2, v3, v4); + return this->value(base_func::expression); + } + }; + + struct func_6param : public base_func + { + using exprtk::ifunction<T>::operator(); + + func_6param() : base_func(6) {} + + inline T operator() (type v0, type v1, type v2, type v3, type v4, type v5) exprtk_override + { + scoped_bft<func_6param> sb(*this); + base_func::update(v0, v1, v2, v3, v4, v5); + return this->value(base_func::expression); + } + }; + + static T return_value(expression_t& e) + { + typedef exprtk::results_context<T> results_context_t; + typedef typename results_context_t::type_store_t type_t; + typedef typename type_t::scalar_view scalar_t; + + const T result = e.value(); + + if (e.return_invoked()) + { + // Due to the post compilation checks, it can be safely + // assumed that there will be at least one parameter + // and that the first parameter will always be scalar. + return scalar_t(e.results()[0])(); + } + + return result; + } + + #define def_fp_retval(N) \ + struct func_##N##param_retval exprtk_final : public func_##N##param \ + { \ + inline T value(expression_t& e) exprtk_override \ + { \ + return return_value(e); \ + } \ + }; \ + + def_fp_retval(0) + def_fp_retval(1) + def_fp_retval(2) + def_fp_retval(3) + def_fp_retval(4) + def_fp_retval(5) + def_fp_retval(6) + + #undef def_fp_retval + + template <typename Allocator, + template <typename, typename> class Sequence> + inline bool add(const std::string& name, + const std::string& expression, + const Sequence<std::string,Allocator>& var_list, + const bool override = false) + { + const typename std::map<std::string,expression_t>::iterator itr = expr_map_.find(name); + + if (expr_map_.end() != itr) + { + if (!override) + { + exprtk_debug(("Compositor error(add): function '%s' already defined\n", + name.c_str())); + + return false; + } + + remove(name, var_list.size()); + } + + if (compile_expression(name, expression, var_list)) + { + const std::size_t n = var_list.size(); + + fp_map_[n][name]->setup(expr_map_[name]); + + return true; + } + else + { + exprtk_debug(("Compositor error(add): Failed to compile function '%s'\n", + name.c_str())); + + return false; + } + } + + public: + + function_compositor() + : parser_(settings_t::default_compile_all_opts + + settings_t::e_disable_zero_return) + , fp_map_(7) + , load_variables_(false) + , load_vectors_(false) + {} + + explicit function_compositor(const symbol_table_t& st) + : symbol_table_(st) + , parser_(settings_t::default_compile_all_opts + + settings_t::e_disable_zero_return) + , fp_map_(7) + , load_variables_(false) + , load_vectors_(false) + {} + + ~function_compositor() + { + clear(); + } + + inline symbol_table_t& symbol_table() + { + return symbol_table_; + } + + inline const symbol_table_t& symbol_table() const + { + return symbol_table_; + } + + inline void add_auxiliary_symtab(symbol_table_t& symtab) + { + auxiliary_symtab_list_.push_back(&symtab); + } + + void load_variables(const bool load = true) + { + load_variables_ = load; + } + + void load_vectors(const bool load = true) + { + load_vectors_ = load; + } + + inline void register_loop_runtime_check(loop_runtime_check& lrtchk) + { + parser_.register_loop_runtime_check(lrtchk); + } + + inline void register_vector_access_runtime_check(vector_access_runtime_check& vartchk) + { + parser_.register_vector_access_runtime_check(vartchk); + } + + inline void register_compilation_timeout_check(compilation_check& compchk) + { + parser_.register_compilation_timeout_check(compchk); + } + + inline void clear_loop_runtime_check() + { + parser_.clear_loop_runtime_check(); + } + + inline void clear_vector_access_runtime_check() + { + parser_.clear_vector_access_runtime_check(); + } + + inline void clear_compilation_timeout_check() + { + parser_.clear_compilation_timeout_check(); + } + + void clear() + { + symbol_table_.clear(); + expr_map_ .clear(); + + for (std::size_t i = 0; i < fp_map_.size(); ++i) + { + typename funcparam_t::iterator itr = fp_map_[i].begin(); + typename funcparam_t::iterator end = fp_map_[i].end (); + + while (itr != end) + { + delete itr->second; + ++itr; + } + + fp_map_[i].clear(); + } + + clear_loop_runtime_check (); + clear_vector_access_runtime_check(); + clear_compilation_timeout_check (); + } + + inline bool add(const function& f, const bool override = false) + { + return add(f.name_, f.expression_, f.v_,override); + } + + inline std::string error() const + { + if (!error_list_.empty()) + { + return error_list_[0].diagnostic; + } + else + return std::string("No Error"); + } + + inline std::size_t error_count() const + { + return error_list_.size(); + } + + inline parser_error::type get_error(const std::size_t& index) const + { + if (index < error_list_.size()) + { + return error_list_[index]; + } + + throw std::invalid_argument("compositor::get_error() - Invalid error index specified"); + } + + private: + + template <typename Allocator, + template <typename, typename> class Sequence> + bool compile_expression(const std::string& name, + const std::string& expression, + const Sequence<std::string,Allocator>& input_var_list, + bool return_present = false) + { + expression_t compiled_expression; + symbol_table_t local_symbol_table; + + local_symbol_table.load_from(symbol_table_); + local_symbol_table.add_constants(); + + if (load_variables_) + { + local_symbol_table.load_variables_from(symbol_table_); + } + + if (load_vectors_) + { + local_symbol_table.load_vectors_from(symbol_table_); + } + + error_list_.clear(); + + if (!valid(name,input_var_list.size())) + { + parser_error::type error = + parser_error::make_error( + parser_error::e_parser, + lexer::token(), + "ERR283 - Function '" + name + "' is an invalid overload", + exprtk_error_location); + + error_list_.push_back(error); + return false; + } + + if (!forward(name, + input_var_list.size(), + local_symbol_table, + return_present)) + return false; + + compiled_expression.register_symbol_table(local_symbol_table); + + for (std::size_t i = 0; i < auxiliary_symtab_list_.size(); ++i) + { + compiled_expression.register_symbol_table((*auxiliary_symtab_list_[i])); + } + + std::string mod_expression; + + for (std::size_t i = 0; i < input_var_list.size(); ++i) + { + mod_expression += " var " + input_var_list[i] + "{};\n"; + } + + if ( + ('{' == details::front(expression)) && + ('}' == details::back (expression)) + ) + mod_expression += "~" + expression + ";"; + else + mod_expression += "~{" + expression + "};"; + + if (!parser_.compile(mod_expression,compiled_expression)) + { + exprtk_debug(("Compositor Error: %s\n", parser_.error().c_str())); + exprtk_debug(("Compositor modified expression: \n%s\n", mod_expression.c_str())); + + remove(name,input_var_list.size()); + + for (std::size_t err_index = 0; err_index < parser_.error_count(); ++err_index) + { + error_list_.push_back(parser_.get_error(err_index)); + } + + return false; + } + + if (!return_present && parser_.dec().return_present()) + { + remove(name,input_var_list.size()); + return compile_expression(name, expression, input_var_list, true); + } + + // Make sure every return point has a scalar as its first parameter + if (parser_.dec().return_present()) + { + typedef std::vector<std::string> str_list_t; + + str_list_t ret_param_list = parser_.dec().return_param_type_list(); + + for (std::size_t i = 0; i < ret_param_list.size(); ++i) + { + const std::string& params = ret_param_list[i]; + + if (params.empty() || ('T' != params[0])) + { + exprtk_debug(("Compositor Error: Return statement in function '%s' is invalid\n", + name.c_str())); + + remove(name,input_var_list.size()); + + return false; + } + } + } + + expr_map_[name] = compiled_expression; + + exprtk::ifunction<T>& ifunc = (*(fp_map_[input_var_list.size()])[name]); + + if (symbol_table_.add_function(name,ifunc)) + return true; + else + { + exprtk_debug(("Compositor Error: Failed to add function '%s' to symbol table\n", + name.c_str())); + return false; + } + } + + inline bool symbol_used(const std::string& symbol) const + { + return ( + symbol_table_.is_variable (symbol) || + symbol_table_.is_stringvar (symbol) || + symbol_table_.is_function (symbol) || + symbol_table_.is_vector (symbol) || + symbol_table_.is_vararg_function(symbol) + ); + } + + inline bool valid(const std::string& name, + const std::size_t& arg_count) const + { + if (arg_count > 6) + return false; + else if (symbol_used(name)) + return false; + else if (fp_map_[arg_count].end() != fp_map_[arg_count].find(name)) + return false; + else + return true; + } + + inline bool forward(const std::string& name, + const std::size_t& arg_count, + symbol_table_t& sym_table, + const bool ret_present = false) + { + switch (arg_count) + { + #define case_stmt(N) \ + case N : (fp_map_[arg_count])[name] = \ + (!ret_present) ? static_cast<base_func*> \ + (new func_##N##param) : \ + static_cast<base_func*> \ + (new func_##N##param_retval) ; \ + break; \ + + case_stmt(0) case_stmt(1) case_stmt(2) + case_stmt(3) case_stmt(4) case_stmt(5) + case_stmt(6) + #undef case_stmt + } + + exprtk::ifunction<T>& ifunc = (*(fp_map_[arg_count])[name]); + + return sym_table.add_function(name,ifunc); + } + + inline void remove(const std::string& name, const std::size_t& arg_count) + { + if (arg_count > 6) + return; + + const typename std::map<std::string,expression_t>::iterator em_itr = expr_map_.find(name); + + if (expr_map_.end() != em_itr) + { + expr_map_.erase(em_itr); + } + + const typename funcparam_t::iterator fp_itr = fp_map_[arg_count].find(name); + + if (fp_map_[arg_count].end() != fp_itr) + { + delete fp_itr->second; + fp_map_[arg_count].erase(fp_itr); + } + + symbol_table_.remove_function(name); + } + + private: + + symbol_table_t symbol_table_; + parser_t parser_; + std::map<std::string,expression_t> expr_map_; + std::vector<funcparam_t> fp_map_; + std::vector<symbol_table_t*> auxiliary_symtab_list_; + std::deque<parser_error::type> error_list_; + bool load_variables_; + bool load_vectors_; + }; // class function_compositor + +} // namespace exprtk + +#if defined(_MSC_VER) || defined(_WIN32) || defined(__WIN32__) || defined(WIN32) +# ifndef NOMINMAX +# define NOMINMAX +# endif +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# include <windows.h> +# include <ctime> +#else +# include <ctime> +# include <sys/time.h> +# include <sys/types.h> +#endif + +namespace exprtk +{ + class timer + { + public: + + #if defined(_MSC_VER) || defined(_WIN32) || defined(__WIN32__) || defined(WIN32) + timer() + : in_use_(false) + , start_time_{ {0, 0} } + , stop_time_ { {0, 0} } + { + QueryPerformanceFrequency(&clock_frequency_); + } + + inline void start() + { + in_use_ = true; + QueryPerformanceCounter(&start_time_); + } + + inline void stop() + { + QueryPerformanceCounter(&stop_time_); + in_use_ = false; + } + + inline double time() const + { + return (1.0 * (stop_time_.QuadPart - start_time_.QuadPart)) / (1.0 * clock_frequency_.QuadPart); + } + + #else + + timer() + : in_use_(false) + { + start_time_.tv_sec = 0; + start_time_.tv_usec = 0; + + stop_time_.tv_sec = 0; + stop_time_.tv_usec = 0; + } + + inline void start() + { + in_use_ = true; + gettimeofday(&start_time_,0); + } + + inline void stop() + { + gettimeofday(&stop_time_, 0); + in_use_ = false; + } + + inline unsigned long long int usec_time() const + { + if (!in_use_) + { + if (stop_time_.tv_sec >= start_time_.tv_sec) + { + return 1000000LLU * static_cast<details::_uint64_t>(stop_time_.tv_sec - start_time_.tv_sec ) + + static_cast<details::_uint64_t>(stop_time_.tv_usec - start_time_.tv_usec) ; + } + else + return std::numeric_limits<details::_uint64_t>::max(); + } + else + return std::numeric_limits<details::_uint64_t>::max(); + } + + inline double time() const + { + return usec_time() * 0.000001; + } + + #endif + + inline bool in_use() const + { + return in_use_; + } + + private: + + bool in_use_; + + #if defined(_MSC_VER) || defined(_WIN32) || defined(__WIN32__) || defined(WIN32) + LARGE_INTEGER start_time_; + LARGE_INTEGER stop_time_; + LARGE_INTEGER clock_frequency_; + #else + struct timeval start_time_; + struct timeval stop_time_; + #endif + }; + + template <typename T> + struct type_defs + { + typedef symbol_table<T> symbol_table_t; + typedef expression<T> expression_t; + typedef parser<T> parser_t; + typedef parser_error::type error_t; + typedef function_compositor<T> compositor_t; + typedef typename compositor_t::function function_t; + }; + +} // namespace exprtk + +#ifndef exprtk_disable_rtl_io +namespace exprtk +{ + namespace rtl { namespace io { namespace details + { + template <typename T> + inline void print_type(const std::string& fmt, + const T v, + exprtk::details::numeric::details::real_type_tag) + { + #if defined(__clang__) + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wformat-nonliteral" + #elif defined(__GNUC__) || defined(__GNUG__) + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wformat-nonliteral" + #elif defined(_MSC_VER) + #endif + + printf(fmt.c_str(), v); + + #if defined(__clang__) + #pragma clang diagnostic pop + #elif defined(__GNUC__) || defined(__GNUG__) + #pragma GCC diagnostic pop + #elif defined(_MSC_VER) + #endif + } + + template <typename T> + struct print_impl + { + typedef typename igeneric_function<T>::generic_type generic_type; + typedef typename igeneric_function<T>::parameter_list_t parameter_list_t; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + typedef typename generic_type::string_view string_t; + typedef typename exprtk::details::numeric::details::number_type<T>::type num_type; + + static void process(const std::string& scalar_format, parameter_list_t parameters) + { + for (std::size_t i = 0; i < parameters.size(); ++i) + { + generic_type& gt = parameters[i]; + + switch (gt.type) + { + case generic_type::e_scalar : print(scalar_format,scalar_t(gt)); + break; + + case generic_type::e_vector : print(scalar_format,vector_t(gt)); + break; + + case generic_type::e_string : print(string_t(gt)); + break; + + default : continue; + } + } + } + + static inline void print(const std::string& scalar_format, const scalar_t& s) + { + print_type(scalar_format,s(),num_type()); + } + + static inline void print(const std::string& scalar_format, const vector_t& v) + { + for (std::size_t i = 0; i < v.size(); ++i) + { + print_type(scalar_format,v[i],num_type()); + + if ((i + 1) < v.size()) + printf(" "); + } + } + + static inline void print(const string_t& s) + { + printf("%s",to_str(s).c_str()); + } + }; + + } // namespace exprtk::rtl::io::details + + template <typename T> + struct print exprtk_final : public exprtk::igeneric_function<T> + { + typedef typename igeneric_function<T>::parameter_list_t parameter_list_t; + + using exprtk::igeneric_function<T>::operator(); + + explicit print(const std::string& scalar_format = "%10.5f") + : scalar_format_(scalar_format) + { + exprtk::enable_zero_parameters(*this); + } + + inline T operator() (parameter_list_t parameters) exprtk_override + { + details::print_impl<T>::process(scalar_format_,parameters); + return T(0); + } + + std::string scalar_format_; + }; + + template <typename T> + struct println exprtk_final : public exprtk::igeneric_function<T> + { + typedef typename igeneric_function<T>::parameter_list_t parameter_list_t; + + using exprtk::igeneric_function<T>::operator(); + + explicit println(const std::string& scalar_format = "%10.5f") + : scalar_format_(scalar_format) + { + exprtk::enable_zero_parameters(*this); + } + + inline T operator() (parameter_list_t parameters) exprtk_override + { + details::print_impl<T>::process(scalar_format_,parameters); + printf("\n"); + return T(0); + } + + std::string scalar_format_; + }; + + template <typename T> + struct package + { + print <T> p; + println<T> pl; + + bool register_package(exprtk::symbol_table<T>& symtab) + { + #define exprtk_register_function(FunctionName, FunctionType) \ + if (!symtab.add_function(FunctionName,FunctionType)) \ + { \ + exprtk_debug(( \ + "exprtk::rtl::io::register_package - Failed to add function: %s\n", \ + FunctionName)); \ + return false; \ + } \ + + exprtk_register_function("print" , p ) + exprtk_register_function("println", pl) + #undef exprtk_register_function + + return true; + } + }; + + } // namespace exprtk::rtl::io + } // namespace exprtk::rtl +} // namespace exprtk +#endif + +#ifndef exprtk_disable_rtl_io_file +#include <fstream> +namespace exprtk +{ + namespace rtl { namespace io { namespace file { namespace details + { + using ::exprtk::details::char_ptr; + using ::exprtk::details::char_cptr; + + enum file_mode + { + e_error = 0, + e_read = 1, + e_write = 2, + e_rdwrt = 4 + }; + + struct file_descriptor + { + file_descriptor(const std::string& fname, const std::string& access) + : stream_ptr(0) + , mode(get_file_mode(access)) + , file_name(fname) + {} + + void* stream_ptr; + file_mode mode; + std::string file_name; + + bool open() + { + if (e_read == mode) + { + std::ifstream* stream = new std::ifstream(file_name.c_str(),std::ios::binary); + + if (!(*stream)) + { + file_name.clear(); + delete stream; + + return false; + } + + stream_ptr = stream; + + return true; + } + else if (e_write == mode) + { + std::ofstream* stream = new std::ofstream(file_name.c_str(),std::ios::binary); + + if (!(*stream)) + { + file_name.clear(); + delete stream; + + return false; + } + + stream_ptr = stream; + + return true; + } + else if (e_rdwrt == mode) + { + std::fstream* stream = new std::fstream(file_name.c_str(),std::ios::binary); + + if (!(*stream)) + { + file_name.clear(); + delete stream; + + return false; + } + + stream_ptr = stream; + + return true; + } + + return false; + } + + template <typename Stream, typename Ptr> + void close(Ptr& p) + { + Stream* stream = reinterpret_cast<Stream*>(p); + stream->close(); + delete stream; + p = reinterpret_cast<Ptr>(0); + } + + bool close() + { + switch (mode) + { + case e_read : close<std::ifstream>(stream_ptr); + break; + + case e_write : close<std::ofstream>(stream_ptr); + break; + + case e_rdwrt : close<std::fstream> (stream_ptr); + break; + + default : return false; + } + + return true; + } + + template <typename View> + bool write(const View& view, const std::size_t amount, const std::size_t offset = 0) + { + switch (mode) + { + case e_write : reinterpret_cast<std::ofstream*>(stream_ptr)-> + write(reinterpret_cast<char_cptr>(view.begin() + offset), amount * sizeof(typename View::value_t)); + break; + + case e_rdwrt : reinterpret_cast<std::fstream*>(stream_ptr)-> + write(reinterpret_cast<char_cptr>(view.begin() + offset) , amount * sizeof(typename View::value_t)); + break; + + default : return false; + } + + return true; + } + + template <typename View> + bool read(View& view, const std::size_t amount, const std::size_t offset = 0) + { + switch (mode) + { + case e_read : reinterpret_cast<std::ifstream*>(stream_ptr)-> + read(reinterpret_cast<char_ptr>(view.begin() + offset), amount * sizeof(typename View::value_t)); + break; + + case e_rdwrt : reinterpret_cast<std::fstream*>(stream_ptr)-> + read(reinterpret_cast<char_ptr>(view.begin() + offset) , amount * sizeof(typename View::value_t)); + break; + + default : return false; + } + + return true; + } + + bool getline(std::string& s) + { + switch (mode) + { + case e_read : return (!!std::getline(*reinterpret_cast<std::ifstream*>(stream_ptr),s)); + case e_rdwrt : return (!!std::getline(*reinterpret_cast<std::fstream* >(stream_ptr),s)); + default : return false; + } + } + + bool eof() const + { + switch (mode) + { + case e_read : return reinterpret_cast<std::ifstream*>(stream_ptr)->eof(); + case e_write : return reinterpret_cast<std::ofstream*>(stream_ptr)->eof(); + case e_rdwrt : return reinterpret_cast<std::fstream* >(stream_ptr)->eof(); + default : return true; + } + } + + file_mode get_file_mode(const std::string& access) const + { + if (access.empty() || access.size() > 2) + return e_error; + + std::size_t w_cnt = 0; + std::size_t r_cnt = 0; + + for (std::size_t i = 0; i < access.size(); ++i) + { + switch (std::tolower(access[i])) + { + case 'r' : r_cnt++; break; + case 'w' : w_cnt++; break; + default : return e_error; + } + } + + if ((0 == r_cnt) && (0 == w_cnt)) + return e_error; + else if ((r_cnt > 1) || (w_cnt > 1)) + return e_error; + else if ((1 == r_cnt) && (1 == w_cnt)) + return e_rdwrt; + else if (1 == r_cnt) + return e_read; + else + return e_write; + } + }; + + template <typename T> + file_descriptor* make_handle(T v) + { + const std::size_t fd_size = sizeof(details::file_descriptor*); + details::file_descriptor* fd = reinterpret_cast<file_descriptor*>(0); + + std::memcpy(reinterpret_cast<char_ptr >(&fd), + reinterpret_cast<char_cptr>(&v ), + fd_size); + return fd; + } + + template <typename T> + void perform_check() + { + #ifdef _MSC_VER + #pragma warning(push) + #pragma warning(disable: 4127) + #endif + if (sizeof(T) < sizeof(void*)) + { + throw std::runtime_error("exprtk::rtl::io::file - Error - pointer size larger than holder."); + } + #ifdef _MSC_VER + #pragma warning(pop) + #endif + assert(sizeof(T) <= sizeof(void*)); + } + + } // namespace exprtk::rtl::io::file::details + + template <typename T> + class open exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::string_view string_t; + + using igfun_t::operator(); + + open() + : exprtk::igeneric_function<T>("S|SS") + { details::perform_check<T>(); } + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const std::string file_name = to_str(string_t(parameters[0])); + + if (file_name.empty()) + { + return T(0); + } + + if ((1 == ps_index) && (0 == string_t(parameters[1]).size())) + { + return T(0); + } + + const std::string access = + (0 == ps_index) ? "r" : to_str(string_t(parameters[1])); + + details::file_descriptor* fd = new details::file_descriptor(file_name,access); + + if (fd->open()) + { + T t = T(0); + + const std::size_t fd_size = sizeof(details::file_descriptor*); + + std::memcpy(reinterpret_cast<char*>(&t ), + reinterpret_cast<char*>(&fd), + fd_size); + return t; + } + else + { + delete fd; + return T(0); + } + } + }; + + template <typename T> + struct close exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + close() + : exprtk::ifunction<T>(1) + { details::perform_check<T>(); } + + inline T operator() (const T& v) exprtk_override + { + details::file_descriptor* fd = details::make_handle(v); + + if (!fd->close()) + return T(0); + + delete fd; + + return T(1); + } + }; + + template <typename T> + class write exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::string_view string_t; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + write() + : igfun_t("TS|TST|TV|TVT") + { details::perform_check<T>(); } + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + details::file_descriptor* fd = details::make_handle(scalar_t(parameters[0])()); + + switch (ps_index) + { + case 0 : { + const string_t buffer(parameters[1]); + const std::size_t amount = buffer.size(); + return T(fd->write(buffer, amount) ? 1 : 0); + } + + case 1 : { + const string_t buffer(parameters[1]); + const std::size_t amount = + std::min(buffer.size(), + static_cast<std::size_t>(scalar_t(parameters[2])())); + return T(fd->write(buffer, amount) ? 1 : 0); + } + + case 2 : { + const vector_t vec(parameters[1]); + const std::size_t amount = vec.size(); + return T(fd->write(vec, amount) ? 1 : 0); + } + + case 3 : { + const vector_t vec(parameters[1]); + const std::size_t amount = + std::min(vec.size(), + static_cast<std::size_t>(scalar_t(parameters[2])())); + return T(fd->write(vec, amount) ? 1 : 0); + } + } + + return T(0); + } + }; + + template <typename T> + class read exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::string_view string_t; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + read() + : igfun_t("TS|TST|TV|TVT") + { details::perform_check<T>(); } + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + details::file_descriptor* fd = details::make_handle(scalar_t(parameters[0])()); + + switch (ps_index) + { + case 0 : { + string_t buffer(parameters[1]); + const std::size_t amount = buffer.size(); + return T(fd->read(buffer,amount) ? 1 : 0); + } + + case 1 : { + string_t buffer(parameters[1]); + const std::size_t amount = + std::min(buffer.size(), + static_cast<std::size_t>(scalar_t(parameters[2])())); + return T(fd->read(buffer,amount) ? 1 : 0); + } + + case 2 : { + vector_t vec(parameters[1]); + const std::size_t amount = vec.size(); + return T(fd->read(vec,amount) ? 1 : 0); + } + + case 3 : { + vector_t vec(parameters[1]); + const std::size_t amount = + std::min(vec.size(), + static_cast<std::size_t>(scalar_t(parameters[2])())); + return T(fd->read(vec,amount) ? 1 : 0); + } + } + + return T(0); + } + }; + + template <typename T> + class getline exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::string_view string_t; + typedef typename generic_type::scalar_view scalar_t; + + using igfun_t::operator(); + + getline() + : igfun_t("T",igfun_t::e_rtrn_string) + { details::perform_check<T>(); } + + inline T operator() (std::string& result, parameter_list_t parameters) exprtk_override + { + details::file_descriptor* fd = details::make_handle(scalar_t(parameters[0])()); + return T(fd->getline(result) ? 1 : 0); + } + }; + + template <typename T> + struct eof exprtk_final : public exprtk::ifunction<T> + { + using exprtk::ifunction<T>::operator(); + + eof() + : exprtk::ifunction<T>(1) + { details::perform_check<T>(); } + + inline T operator() (const T& v) exprtk_override + { + details::file_descriptor* fd = details::make_handle(v); + return (fd->eof() ? T(1) : T(0)); + } + }; + + template <typename T> + struct package + { + open <T> o; + close <T> c; + write <T> w; + read <T> r; + getline<T> g; + eof <T> e; + + bool register_package(exprtk::symbol_table<T>& symtab) + { + #define exprtk_register_function(FunctionName, FunctionType) \ + if (!symtab.add_function(FunctionName,FunctionType)) \ + { \ + exprtk_debug(( \ + "exprtk::rtl::io::file::register_package - Failed to add function: %s\n", \ + FunctionName)); \ + return false; \ + } \ + + exprtk_register_function("open" , o) + exprtk_register_function("close" , c) + exprtk_register_function("write" , w) + exprtk_register_function("read" , r) + exprtk_register_function("getline" , g) + exprtk_register_function("eof" , e) + #undef exprtk_register_function + + return true; + } + }; + + } // namespace exprtk::rtl::io::file + } // namespace exprtk::rtl::io + } // namespace exprtk::rtl +} // namespace exprtk +#endif + +#ifndef exprtk_disable_rtl_vecops +namespace exprtk +{ + namespace rtl { namespace vecops { + + namespace helper + { + template <typename Vector> + inline bool invalid_range(const Vector& v, const std::size_t r0, const std::size_t r1) + { + if (r0 > (v.size() - 1)) + return true; + else if (r1 > (v.size() - 1)) + return true; + else if (r1 < r0) + return true; + else + return false; + } + + template <typename T> + struct load_vector_range + { + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + static inline bool process(parameter_list_t& parameters, + std::size_t& r0, std::size_t& r1, + const std::size_t& r0_prmidx, + const std::size_t& r1_prmidx, + const std::size_t vec_idx = 0) + { + if (r0_prmidx >= parameters.size()) + return false; + + if (r1_prmidx >= parameters.size()) + return false; + + if (!scalar_t(parameters[r0_prmidx]).to_uint(r0)) + return false; + + if (!scalar_t(parameters[r1_prmidx]).to_uint(r1)) + return false; + + return !invalid_range(vector_t(parameters[vec_idx]), r0, r1); + } + }; + } + + namespace details + { + template <typename T> + inline void kahan_sum(T& sum, T& error, const T v) + { + const T x = v - error; + const T y = sum + x; + error = (y - sum) - x; + sum = y; + } + + } // namespace exprtk::rtl::details + + template <typename T> + class all_true exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + all_true() + : exprtk::igeneric_function<T>("V|VTT|T*") + /* + Overloads: + 0. V - vector + 1. VTT - vector, r0, r1 + 2. T* - T....T + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + if (2 == ps_index) + { + for (std::size_t i = 0; i < parameters.size(); ++i) + { + if (scalar_t(parameters[i])() == T(0)) + { + return T(0); + } + } + } + else + { + const vector_t vec(parameters[0]); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + for (std::size_t i = r0; i <= r1; ++i) + { + if (vec[i] == T(0)) + { + return T(0); + } + } + } + + return T(1); + } + }; + + template <typename T> + class all_false exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + all_false() + : exprtk::igeneric_function<T>("V|VTT|T*") + /* + Overloads: + 0. V - vector + 1. VTT - vector, r0, r1 + 2. T* - T....T + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + if (2 == ps_index) + { + for (std::size_t i = 0; i < parameters.size(); ++i) + { + if (scalar_t(parameters[i])() != T(0)) + { + return T(0); + } + } + } + else + { + const vector_t vec(parameters[0]); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + for (std::size_t i = r0; i <= r1; ++i) + { + if (vec[i] != T(0)) + { + return T(0); + } + } + } + + return T(1); + } + }; + + template <typename T> + class any_true exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + any_true() + : exprtk::igeneric_function<T>("V|VTT|T*") + /* + Overloads: + 0. V - vector + 1. VTT - vector, r0, r1 + 2. T* - T....T + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + if (2 == ps_index) + { + for (std::size_t i = 0; i < parameters.size(); ++i) + { + if (scalar_t(parameters[i])() != T(0)) + { + return T(1); + } + } + } + else + { + const vector_t vec(parameters[0]); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + for (std::size_t i = r0; i <= r1; ++i) + { + if (vec[i] != T(0)) + { + return T(1); + } + } + } + + return T(0); + } + }; + + template <typename T> + class any_false exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + any_false() + : exprtk::igeneric_function<T>("V|VTT|T*") + /* + Overloads: + 0. V - vector + 1. VTT - vector, r0, r1 + 2. T* - T....T + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + if (2 == ps_index) + { + for (std::size_t i = 0; i < parameters.size(); ++i) + { + if (scalar_t(parameters[i])() == T(0)) + { + return T(1); + } + } + } + else + { + const vector_t vec(parameters[0]); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + for (std::size_t i = r0; i <= r1; ++i) + { + if (vec[i] == T(0)) + { + return T(1); + } + } + } + + return T(0); + } + }; + + template <typename T> + class count exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + count() + : exprtk::igeneric_function<T>("V|VTT|T*") + /* + Overloads: + 0. V - vector + 1. VTT - vector, r0, r1 + 2. T* - T....T + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + std::size_t cnt = 0; + + if (2 == ps_index) + { + for (std::size_t i = 0; i < parameters.size(); ++i) + { + if (scalar_t(parameters[i])() != T(0)) ++cnt; + } + } + else + { + const vector_t vec(parameters[0]); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + for (std::size_t i = r0; i <= r1; ++i) + { + if (vec[i] != T(0)) ++cnt; + } + } + + return T(cnt); + } + }; + + template <typename T> + class copy exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + copy() + : exprtk::igeneric_function<T>("VV|VTTVTT") + /* + Overloads: + 0. VV - x(vector), y(vector) + 1. VTTVTT - x(vector), xr0, xr1, y(vector), yr0, yr1, + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[0]); + vector_t y(parameters[(0 == ps_index) ? 1 : 3]); + + std::size_t xr0 = 0; + std::size_t xr1 = x.size() - 1; + + std::size_t yr0 = 0; + std::size_t yr1 = y.size() - 1; + + if (1 == ps_index) + { + if ( + !helper::load_vector_range<T>::process(parameters, xr0, xr1, 1, 2, 0) || + !helper::load_vector_range<T>::process(parameters, yr0, yr1, 4, 5, 3) + ) + return T(0); + } + + const std::size_t n = std::min(xr1 - xr0 + 1, yr1 - yr0 + 1); + + std::copy( + x.begin() + xr0, + x.begin() + xr0 + n, + y.begin() + yr0); + + return T(n); + } + }; + + template <typename T> + class rol exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + rol() + : exprtk::igeneric_function<T>("VT|VTTT") + /* + Overloads: + 0. VT - vector, N + 1. VTTT - vector, N, r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + std::size_t n = 0; + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if (!scalar_t(parameters[1]).to_uint(n)) + return T(0); + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 2, 3, 0) + ) + return T(0); + + const std::size_t dist = r1 - r0 + 1; + const std::size_t shift = n % dist; + + std::rotate( + vec.begin() + r0, + vec.begin() + r0 + shift, + vec.begin() + r1 + 1); + + return T(1); + } + }; + + template <typename T> + class ror exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + ror() + : exprtk::igeneric_function<T>("VT|VTTT") + /* + Overloads: + 0. VT - vector, N + 1. VTTT - vector, N, r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + std::size_t n = 0; + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if (!scalar_t(parameters[1]).to_uint(n)) + return T(0); + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 2, 3, 0) + ) + return T(0); + + const std::size_t dist = r1 - r0 + 1; + const std::size_t shift = (dist - (n % dist)) % dist; + + std::rotate( + vec.begin() + r0, + vec.begin() + r0 + shift, + vec.begin() + r1 + 1); + + return T(1); + } + }; + + template <typename T> + class reverse exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + reverse() + : exprtk::igeneric_function<T>("V|VTT") + /* + Overloads: + 0. V - vector + 1. VTT - vector, r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + return T(0); + + std::reverse(vec.begin() + r0, vec.begin() + r1 + 1); + + return T(1); + } + }; + + template <typename T> + class shift_left exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + shift_left() + : exprtk::igeneric_function<T>("VT|VTTT") + /* + Overloads: + 0. VT - vector, N + 1. VTTT - vector, N, r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + std::size_t n = 0; + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if (!scalar_t(parameters[1]).to_uint(n)) + return T(0); + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 2, 3, 0) + ) + return T(0); + + const std::size_t dist = r1 - r0 + 1; + + if (n > dist) + return T(0); + + std::rotate( + vec.begin() + r0, + vec.begin() + r0 + n, + vec.begin() + r1 + 1); + + for (std::size_t i = r1 - n + 1ULL; i <= r1; ++i) + { + vec[i] = T(0); + } + + return T(1); + } + }; + + template <typename T> + class shift_right exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + shift_right() + : exprtk::igeneric_function<T>("VT|VTTT") + /* + Overloads: + 0. VT - vector, N + 1. VTTT - vector, N, r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + std::size_t n = 0; + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if (!scalar_t(parameters[1]).to_uint(n)) + return T(0); + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 2, 3, 0) + ) + return T(0); + + const std::size_t dist = r1 - r0 + 1; + + if (n > dist) + return T(0); + + const std::size_t shift = (dist - (n % dist)) % dist; + + std::rotate( + vec.begin() + r0, + vec.begin() + r0 + shift, + vec.begin() + r1 + 1); + + for (std::size_t i = r0; i < r0 + n; ++i) + { + vec[i] = T(0); + } + + return T(1); + } + }; + + template <typename T> + class sort exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::string_view string_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + sort() + : exprtk::igeneric_function<T>("V|VTT|VS|VSTT") + /* + Overloads: + 0. V - vector + 1. VTT - vector, r0, r1 + 2. VS - vector, string + 3. VSTT - vector, string, r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0)) + return T(0); + if ((3 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 2, 3, 0)) + return T(0); + + bool ascending = true; + + if ((2 == ps_index) || (3 == ps_index)) + { + if (exprtk::details::imatch(to_str(string_t(parameters[1])),"ascending")) + ascending = true; + else if (exprtk::details::imatch(to_str(string_t(parameters[1])),"descending")) + ascending = false; + else + return T(0); + } + + if (ascending) + std::sort( + vec.begin() + r0, + vec.begin() + r1 + 1, + std::less<T>()); + else + std::sort( + vec.begin() + r0, + vec.begin() + r1 + 1, + std::greater<T>()); + + return T(1); + } + }; + + template <typename T> + class nthelement exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + nthelement() + : exprtk::igeneric_function<T>("VT|VTTT") + /* + Overloads: + 0. VT - vector, nth-element + 1. VTTT - vector, nth-element, r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + std::size_t n = 0; + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if (!scalar_t(parameters[1]).to_uint(n)) + return T(0); + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 2, 3, 0)) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + std::nth_element( + vec.begin() + r0, + vec.begin() + r0 + n , + vec.begin() + r1 + 1); + + return T(1); + } + }; + + template <typename T> + class assign exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + assign() + : exprtk::igeneric_function<T>("VT|VTTT|VTTTT") + /* + Overloads: + 0. VT - vector, V + 1. VTTT - vector, V, r0, r1 + 2. VTTTT - vector, V, r0, r1, SS + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + const T assign_value = scalar_t(parameters[1]); + + const std::size_t step_size = (2 != ps_index) ? 1 : + static_cast<std::size_t>(scalar_t(parameters.back())()); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + ((ps_index == 1) || (ps_index == 2)) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 2, 3, 0) + ) + { + return T(0); + } + + for (std::size_t i = r0; i <= r1; i += step_size) + { + vec[i] = assign_value; + } + + return T(1); + } + }; + + template <typename T> + class iota exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + iota() + : exprtk::igeneric_function<T>("VTT|VT|VTTTT|VTTT") + /* + Overloads: + 0. VTT - vector, SV, SS + 1. VT - vector, SV, SS (+1) + 2. VTTT - vector, r0, r1, SV, SS + 3. VTT - vector, r0, r1, SV, SS (+1) + + Where: + 1. SV - Start value + 2. SS - Step size + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + const T start_value = (ps_index <= 1) ? + scalar_t(parameters[1]) : + scalar_t(parameters[3]) ; + + const T step_size = ((0 == ps_index) || (2 == ps_index)) ? + scalar_t(parameters.back())() : + T(1) ; + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + ((ps_index == 2) || (ps_index == 3)) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + { + return T(0); + } + + for (std::size_t i = r0; i <= r1; ++i) + { + vec[i] = start_value + ((i - r0) * step_size); + } + + return T(1); + } + }; + + template <typename T> + class sumk exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + sumk() + : exprtk::igeneric_function<T>("V|VTT|VTTT") + /* + Overloads: + 0. V - vector + 1. VTT - vector, r0, r1 + 2. VTTT - vector, r0, r1, stride + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t vec(parameters[0]); + + const std::size_t stride = (2 != ps_index) ? 1 : + static_cast<std::size_t>(scalar_t(parameters[3])()); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + ((1 == ps_index) || (2 == ps_index)) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + { + return std::numeric_limits<T>::quiet_NaN(); + } + + T result = T(0); + T error = T(0); + + for (std::size_t i = r0; i <= r1; i += stride) + { + details::kahan_sum(result, error, vec[i]); + } + + return result; + } + }; + + template <typename T> + class axpy exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + axpy() + : exprtk::igeneric_function<T>("TVV|TVVTT") + /* + y <- ax + y + Overloads: + 0. TVV - a, x(vector), y(vector) + 1. TVVTT - a, x(vector), y(vector), r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[1]); + vector_t y(parameters[2]); + + std::size_t r0 = 0; + std::size_t r1 = std::min(x.size(),y.size()) - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 3, 4, 1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(y, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + + const T a = scalar_t(parameters[0])(); + + for (std::size_t i = r0; i <= r1; ++i) + { + y[i] = (a * x[i]) + y[i]; + } + + return T(1); + } + }; + + template <typename T> + class axpby exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + axpby() + : exprtk::igeneric_function<T>("TVTV|TVTVTT") + /* + y <- ax + by + Overloads: + 0. TVTV - a, x(vector), b, y(vector) + 1. TVTVTT - a, x(vector), b, y(vector), r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[1]); + vector_t y(parameters[3]); + + std::size_t r0 = 0; + std::size_t r1 = std::min(x.size(),y.size()) - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 4, 5, 1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(y, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + + const T a = scalar_t(parameters[0])(); + const T b = scalar_t(parameters[2])(); + + for (std::size_t i = r0; i <= r1; ++i) + { + y[i] = (a * x[i]) + (b * y[i]); + } + + return T(1); + } + }; + + template <typename T> + class axpyz exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + axpyz() + : exprtk::igeneric_function<T>("TVVV|TVVVTT") + /* + z <- ax + y + Overloads: + 0. TVVV - a, x(vector), y(vector), z(vector) + 1. TVVVTT - a, x(vector), y(vector), z(vector), r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[1]); + const vector_t y(parameters[2]); + vector_t z(parameters[3]); + + std::size_t r0 = 0; + std::size_t r1 = std::min(x.size(),y.size()) - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 4, 5, 1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(y, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(z, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + + const T a = scalar_t(parameters[0])(); + + for (std::size_t i = r0; i <= r1; ++i) + { + z[i] = (a * x[i]) + y[i]; + } + + return T(1); + } + }; + + template <typename T> + class axpbyz exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + axpbyz() + : exprtk::igeneric_function<T>("TVTVV|TVTVVTT") + /* + z <- ax + by + Overloads: + 0. TVTVV - a, x(vector), b, y(vector), z(vector) + 1. TVTVVTT - a, x(vector), b, y(vector), z(vector), r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[1]); + const vector_t y(parameters[3]); + vector_t z(parameters[4]); + + std::size_t r0 = 0; + std::size_t r1 = std::min(x.size(),y.size()) - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 5, 6, 1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(y, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(z, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + + const T a = scalar_t(parameters[0])(); + const T b = scalar_t(parameters[2])(); + + for (std::size_t i = r0; i <= r1; ++i) + { + z[i] = (a * x[i]) + (b * y[i]); + } + + return T(1); + } + }; + + template <typename T> + class axpbsy exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + axpbsy() + : exprtk::igeneric_function<T>("TVTTV|TVTTVTT") + /* + y <- ax + by + Overloads: + 0. TVTVV - a, x(vector), b, shift, y(vector), z(vector) + 1. TVTVVTT - a, x(vector), b, shift, y(vector), z(vector), r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[1]); + vector_t y(parameters[4]); + + std::size_t r0 = 0; + std::size_t r1 = std::min(x.size(),y.size()) - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 5, 6, 1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(y, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + + const T a = scalar_t(parameters[0])(); + const T b = scalar_t(parameters[2])(); + + const std::size_t s = static_cast<std::size_t>(scalar_t(parameters[3])()); + + for (std::size_t i = r0; i <= r1; ++i) + { + y[i] = (a * x[i]) + (b * y[i + s]); + } + + return T(1); + } + }; + + template <typename T> + class axpbsyz exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + axpbsyz() + : exprtk::igeneric_function<T>("TVTTVV|TVTTVVTT") + /* + z <- ax + by + Overloads: + 0. TVTVV - a, x(vector), b, shift, y(vector), z(vector) + 1. TVTVVTT - a, x(vector), b, shift, y(vector), z(vector), r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[1]); + const vector_t y(parameters[4]); + vector_t z(parameters[5]); + + std::size_t r0 = 0; + std::size_t r1 = std::min(x.size(),y.size()) - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 6, 7, 1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(y, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(z, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + + const T a = scalar_t(parameters[0])(); + const T b = scalar_t(parameters[2])(); + + const std::size_t s = static_cast<std::size_t>(scalar_t(parameters[3])()); + + for (std::size_t i = r0; i <= r1; ++i) + { + z[i] = (a * x[i]) + (b * y[i + s]); + } + + return T(1); + } + }; + + template <typename T> + class axpbz exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + axpbz() + : exprtk::igeneric_function<T>("TVTV|TVTVTT") + /* + z <- ax + b + Overloads: + 0. TVTV - a, x(vector), b, z(vector) + 1. TVTVTT - a, x(vector), b, z(vector), r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[1]); + vector_t z(parameters[3]); + + std::size_t r0 = 0; + std::size_t r1 = x.size() - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 4, 5, 1)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(z, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + + const T a = scalar_t(parameters[0])(); + const T b = scalar_t(parameters[2])(); + + for (std::size_t i = r0; i <= r1; ++i) + { + z[i] = (a * x[i]) + b; + } + + return T(1); + } + }; + + template <typename T> + class diff exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + diff() + : exprtk::igeneric_function<T>("VV|VVT") + /* + x_(i - stride) - x_i + Overloads: + 0. VV - x(vector), y(vector) + 1. VVT - x(vector), y(vector), stride + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[0]); + vector_t y(parameters[1]); + + const std::size_t r0 = 0; + const std::size_t r1 = std::min(x.size(),y.size()) - 1; + + const std::size_t stride = (1 != ps_index) ? 1 : + std::min(r1,static_cast<std::size_t>(scalar_t(parameters[2])())); + + for (std::size_t i = 0; i < stride; ++i) + { + y[i] = std::numeric_limits<T>::quiet_NaN(); + } + + for (std::size_t i = (r0 + stride); i <= r1; ++i) + { + y[i] = x[i] - x[i - stride]; + } + + return T(1); + } + }; + + template <typename T> + class dot exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + dot() + : exprtk::igeneric_function<T>("VV|VVTT") + /* + Overloads: + 0. VV - x(vector), y(vector) + 1. VVTT - x(vector), y(vector), r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[0]); + const vector_t y(parameters[1]); + + std::size_t r0 = 0; + std::size_t r1 = std::min(x.size(),y.size()) - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 2, 3, 0)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(y, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + + T result = T(0); + + for (std::size_t i = r0; i <= r1; ++i) + { + result += (x[i] * y[i]); + } + + return result; + } + }; + + template <typename T> + class dotk exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + dotk() + : exprtk::igeneric_function<T>("VV|VVTT") + /* + Overloads: + 0. VV - x(vector), y(vector) + 1. VVTT - x(vector), y(vector), r0, r1 + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + const vector_t x(parameters[0]); + const vector_t y(parameters[1]); + + std::size_t r0 = 0; + std::size_t r1 = std::min(x.size(),y.size()) - 1; + + if ((1 == ps_index) && !helper::load_vector_range<T>::process(parameters, r0, r1, 2, 3, 0)) + return std::numeric_limits<T>::quiet_NaN(); + else if (helper::invalid_range(y, r0, r1)) + return std::numeric_limits<T>::quiet_NaN(); + + T result = T(0); + T error = T(0); + + for (std::size_t i = r0; i <= r1; ++i) + { + details::kahan_sum(result, error, (x[i] * y[i])); + } + + return result; + } + }; + + template <typename T> + class threshold_below exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + threshold_below() + : exprtk::igeneric_function<T>("VTT|VTTTT") + /* + Overloads: + 0. VTT - vector, TV, SV + 1. VTTTT - vector, r0, r1, TV, SV + + Where: + TV - Threshold value + SV - Snap-to value + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + const T threshold_value = (0 == ps_index) ? + scalar_t(parameters[1]) : + scalar_t(parameters[3]) ; + + const T snap_value = scalar_t(parameters.back()); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + { + return T(0); + } + + for (std::size_t i = r0; i <= r1; ++i) + { + if (vec[i] < threshold_value) + { + vec[i] = snap_value; + } + } + + return T(1); + } + }; + + template <typename T> + class threshold_above exprtk_final : public exprtk::igeneric_function<T> + { + public: + + typedef typename exprtk::igeneric_function<T> igfun_t; + typedef typename igfun_t::parameter_list_t parameter_list_t; + typedef typename igfun_t::generic_type generic_type; + typedef typename generic_type::scalar_view scalar_t; + typedef typename generic_type::vector_view vector_t; + + using igfun_t::operator(); + + threshold_above() + : exprtk::igeneric_function<T>("VTT|VTTTT") + /* + Overloads: + 0. VTT - vector, TV, SV + 1. VTTTT - vector, r0, r1, TV, SV + + Where: + TV - Threshold value + SV - Snap-to value + */ + {} + + inline T operator() (const std::size_t& ps_index, parameter_list_t parameters) exprtk_override + { + vector_t vec(parameters[0]); + + const T threshold_value = (0 == ps_index) ? + scalar_t(parameters[1]) : + scalar_t(parameters[3]) ; + + const T snap_value = scalar_t(parameters.back()); + + std::size_t r0 = 0; + std::size_t r1 = vec.size() - 1; + + if ( + (1 == ps_index) && + !helper::load_vector_range<T>::process(parameters, r0, r1, 1, 2, 0) + ) + { + return T(0); + } + + for (std::size_t i = r0; i <= r1; ++i) + { + if (vec[i] > threshold_value) + { + vec[i] = snap_value; + } + } + + return T(1); + } + }; + + template <typename T> + struct package + { + all_true <T> at; + all_false <T> af; + any_true <T> nt; + any_false <T> nf; + count <T> c; + copy <T> cp; + rol <T> rl; + ror <T> rr; + reverse <T> rev; + shift_left <T> sl; + shift_right <T> sr; + sort <T> st; + nthelement <T> ne; + assign <T> an; + iota <T> ia; + sumk <T> sk; + axpy <T> b1_axpy; + axpby <T> b1_axpby; + axpyz <T> b1_axpyz; + axpbyz <T> b1_axpbyz; + axpbsy <T> b1_axpbsy; + axpbsyz <T> b1_axpbsyz; + axpbz <T> b1_axpbz; + diff <T> df; + dot <T> dt; + dotk <T> dtk; + threshold_above<T> ta; + threshold_below<T> tb; + + bool register_package(exprtk::symbol_table<T>& symtab) + { + #define exprtk_register_function(FunctionName, FunctionType) \ + if (!symtab.add_function(FunctionName,FunctionType)) \ + { \ + exprtk_debug(( \ + "exprtk::rtl::vecops::register_package - Failed to add function: %s\n", \ + FunctionName)); \ + return false; \ + } \ + + exprtk_register_function("all_true" , at ) + exprtk_register_function("all_false" , af ) + exprtk_register_function("any_true" , nt ) + exprtk_register_function("any_false" , nf ) + exprtk_register_function("count" , c ) + exprtk_register_function("copy" , cp ) + exprtk_register_function("rotate_left" , rl ) + exprtk_register_function("rol" , rl ) + exprtk_register_function("rotate_right" , rr ) + exprtk_register_function("ror" , rr ) + exprtk_register_function("reverse" , rev ) + exprtk_register_function("shftl" , sl ) + exprtk_register_function("shftr" , sr ) + exprtk_register_function("sort" , st ) + exprtk_register_function("nth_element" , ne ) + exprtk_register_function("assign" , an ) + exprtk_register_function("iota" , ia ) + exprtk_register_function("sumk" , sk ) + exprtk_register_function("axpy" , b1_axpy ) + exprtk_register_function("axpby" , b1_axpby ) + exprtk_register_function("axpyz" , b1_axpyz ) + exprtk_register_function("axpbyz" , b1_axpbyz ) + exprtk_register_function("axpbsy" , b1_axpbsy ) + exprtk_register_function("axpbsyz" , b1_axpbsyz) + exprtk_register_function("axpbz" , b1_axpbz ) + exprtk_register_function("diff" , df ) + exprtk_register_function("dot" , dt ) + exprtk_register_function("dotk" , dtk ) + exprtk_register_function("threshold_above" , ta ) + exprtk_register_function("threshold_below" , tb ) + #undef exprtk_register_function + + return true; + } + }; + + } // namespace exprtk::rtl::vecops + } // namespace exprtk::rtl +} // namespace exprtk +#endif + +namespace exprtk +{ + namespace information + { + using ::exprtk::details::char_cptr; + + static char_cptr library = "Mathematical Expression Toolkit"; + static char_cptr version = "2.718281828459045235360287471352662497757" + "24709369995957496696762772407663035354759" + "45713821785251664274274663919320030599218" + "17413596629043572900334295260595630738132"; + static char_cptr date = "20240101"; + static char_cptr min_cpp = "199711L"; + + static inline std::string data() + { + static const std::string info_str = std::string(library) + + std::string(" v") + std::string(version) + + std::string(" (") + date + std::string(")") + + std::string(" (") + min_cpp + std::string(")"); + return info_str; + } + + } // namespace information + + #ifdef exprtk_debug + #undef exprtk_debug + #endif + + #ifdef exprtk_error_location + #undef exprtk_error_location + #endif + + #ifdef exprtk_fallthrough + #undef exprtk_fallthrough + #endif + + #ifdef exprtk_override + #undef exprtk_override + #endif + + #ifdef exprtk_final + #undef exprtk_final + #endif + + #ifdef exprtk_delete + #undef exprtk_delete + #endif + +} // namespace exprtk + +#endif diff --git a/src/common/CalculatorEngineCommon/packages.config b/src/common/CalculatorEngineCommon/packages.config new file mode 100644 index 0000000000..2766ceaf47 --- /dev/null +++ b/src/common/CalculatorEngineCommon/packages.config @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> +</packages> \ No newline at end of file diff --git a/src/common/CalculatorEngineCommon/pch.cpp b/src/common/CalculatorEngineCommon/pch.cpp new file mode 100644 index 0000000000..bcb5590be1 --- /dev/null +++ b/src/common/CalculatorEngineCommon/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/common/CalculatorEngineCommon/pch.h b/src/common/CalculatorEngineCommon/pch.h new file mode 100644 index 0000000000..21199686d5 --- /dev/null +++ b/src/common/CalculatorEngineCommon/pch.h @@ -0,0 +1,4 @@ +#pragma once +#include <unknwn.h> +#include <winrt/Windows.Foundation.h> +#include <winrt/Windows.Foundation.Collections.h> diff --git a/src/common/CalculatorEngineCommon/readme.md b/src/common/CalculatorEngineCommon/readme.md new file mode 100644 index 0000000000..1455e8f1aa --- /dev/null +++ b/src/common/CalculatorEngineCommon/readme.md @@ -0,0 +1,29 @@ +# C++/WinRT CalculatorEngine Project Overview + +This project wraps the exprtk expression parsing library with a C++/WinRT component, +making advanced mathematical evaluation capabilities available to Windows applications. +It is designed specifically to provide calculation support for the CmdPal calculator extension. + +## Using exprtk + +This project uses [exprtk](https://github.com/ArashPartow/exprtk) as the +expression parsing and evaluation engine. + +How to use exprtk in this project: +- The exprtk header file (`exprtk.hpp`) is included in the project source. +- You can use exprtk to parse and evaluate mathematical expressions in your + C++ code. For example: + + ```cpp + #include "exprtk.hpp" + exprtk::expression<double> expression; + exprtk::parser<double> parser; + std::string formula = "3 + 4 * 2"; + parser.compile(formula, expression); + double result = expression.value(); + ``` + +How to update exprtk: +1. Download the latest `exprtk.hpp` from the [official repository](https://github.com/ArashPartow/exprtk). +2. Replace the existing `exprtk.hpp` file in the project with the new version. +3. Rebuild the project to ensure compatibility and take advantage of any updates. \ No newline at end of file diff --git a/src/common/CalculatorEngineCommon/resource.h b/src/common/CalculatorEngineCommon/resource.h new file mode 100644 index 0000000000..1dfef46e9e --- /dev/null +++ b/src/common/CalculatorEngineCommon/resource.h @@ -0,0 +1,13 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by CalculatorEngineCommon.rc + +////////////////////////////// +// Non-localizable + +#define FILE_DESCRIPTION "CalculatorEngineCommon" +#define INTERNAL_NAME "CalculatorEngineCommon" +#define ORIGINAL_FILENAME "CalculatorEngineCommon.dll" + +// Non-localizable +////////////////////////////// diff --git a/src/common/Common.Search/Common.Search.csproj b/src/common/Common.Search/Common.Search.csproj new file mode 100644 index 0000000000..07a4e0d83a --- /dev/null +++ b/src/common/Common.Search/Common.Search.csproj @@ -0,0 +1,8 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> +</Project> diff --git a/src/common/Common.Search/FuzzSearch/MatchOption.cs b/src/common/Common.Search/FuzzSearch/MatchOption.cs new file mode 100644 index 0000000000..9f9f573f42 --- /dev/null +++ b/src/common/Common.Search/FuzzSearch/MatchOption.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 Common.Search.FuzzSearch; + +public class MatchOption +{ + public bool IgnoreCase { get; set; } = true; +} diff --git a/src/common/Common.Search/FuzzSearch/MatchResult.cs b/src/common/Common.Search/FuzzSearch/MatchResult.cs new file mode 100644 index 0000000000..f448f449a7 --- /dev/null +++ b/src/common/Common.Search/FuzzSearch/MatchResult.cs @@ -0,0 +1,67 @@ +// 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 Common.Search.FuzzSearch; + +public class MatchResult +{ + /// <summary> + /// The raw calculated search score without any search precision filtering applied. + /// </summary> + private int _rawScore; + + public MatchResult(bool success, SearchPrecisionScore searchPrecision) + { + Success = success; + SearchPrecision = searchPrecision; + } + + public MatchResult(bool success, SearchPrecisionScore searchPrecision, List<int> matchData, int rawScore) + { + Success = success; + SearchPrecision = searchPrecision; + MatchData = matchData; + RawScore = rawScore; + } + + public bool Success { get; set; } + + /// <summary> + /// Gets the final score of the match result with search precision filters applied. + /// </summary> + public int Score { get; private set; } + + public int RawScore + { + get => _rawScore; + + set + { + _rawScore = value; + Score = ScoreAfterSearchPrecisionFilter(_rawScore); + } + } + + /// <summary> + /// Gets matched data to highlight. + /// </summary> + public List<int> MatchData { get; private set; } = new(); + + public SearchPrecisionScore SearchPrecision { get; set; } + + public bool IsSearchPrecisionScoreMet() + { + return IsSearchPrecisionScoreMet(_rawScore); + } + + private bool IsSearchPrecisionScoreMet(int rawScore) + { + return rawScore >= (int)SearchPrecision; + } + + private int ScoreAfterSearchPrecisionFilter(int rawScore) + { + return IsSearchPrecisionScoreMet(rawScore) ? rawScore : 0; + } +} diff --git a/src/common/Common.Search/FuzzSearch/SearchPrecisionScore.cs b/src/common/Common.Search/FuzzSearch/SearchPrecisionScore.cs new file mode 100644 index 0000000000..5f1917f14c --- /dev/null +++ b/src/common/Common.Search/FuzzSearch/SearchPrecisionScore.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 Common.Search.FuzzSearch; + +public enum SearchPrecisionScore +{ + Regular = 50, + Low = 20, + None = 0, +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StringMatcher.cs b/src/common/Common.Search/FuzzSearch/StringMatcher.cs similarity index 78% rename from src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StringMatcher.cs rename to src/common/Common.Search/FuzzSearch/StringMatcher.cs index e65be7156a..be702bb8d9 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StringMatcher.cs +++ b/src/common/Common.Search/FuzzSearch/StringMatcher.cs @@ -4,51 +4,15 @@ using System.Globalization; -namespace Microsoft.CommandPalette.Extensions.Toolkit; +namespace Common.Search.FuzzSearch; -public partial class StringMatcher +public class StringMatcher { - private readonly MatchOption _defaultMatchOption = new(); - - public SearchPrecisionScore UserSettingSearchPrecision { get; set; } - - // private readonly IAlphabet _alphabet; - public StringMatcher(/*IAlphabet alphabet = null*/) + public StringMatcher() { - // _alphabet = alphabet; } - private static StringMatcher? _instance; - - public static StringMatcher Instance - { - get - { - _instance ??= new StringMatcher(); - - return _instance; - } - set => _instance = value; - } - - private static readonly char[] Separator = new[] { ' ' }; - - public static MatchResult FuzzySearch(string query, string stringToCompare) - { - return Instance.FuzzyMatch(query, stringToCompare); - } - - public MatchResult FuzzyMatch(string query, string stringToCompare) - { - try - { - return FuzzyMatch(query, stringToCompare, _defaultMatchOption); - } - catch (IndexOutOfRangeException) - { - return new MatchResult(false, UserSettingSearchPrecision); - } - } + private static readonly char[] Separator = [' ']; /// <summary> /// Current method: @@ -61,16 +25,20 @@ public partial class StringMatcher /// 6. Move onto the next substring's characters until all substrings are checked. /// 7. Consider success and move onto scoring if every char or substring without whitespaces matched /// </summary> - public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt) + public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt = null) { + opt = opt ?? new MatchOption(); + if (string.IsNullOrEmpty(stringToCompare)) { - return new MatchResult(false, UserSettingSearchPrecision); + return new MatchResult(false, SearchPrecisionScore.Regular); } - var bestResult = new MatchResult(false, UserSettingSearchPrecision); + SearchPrecisionScore score = SearchPrecisionScore.Regular; - for (var startIndex = 0; startIndex < stringToCompare.Length; startIndex++) + var bestResult = new MatchResult(false, score); + + for (int startIndex = 0; startIndex < stringToCompare.Length; startIndex++) { MatchResult result = FuzzyMatch(query, stringToCompare, opt, startIndex); if (result.Success && (!bestResult.Success || result.Score > bestResult.Score)) @@ -82,38 +50,32 @@ public partial class StringMatcher return bestResult; } - private MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt, int startIndex) + private static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt, int startIndex) { if (string.IsNullOrEmpty(stringToCompare) || string.IsNullOrEmpty(query)) { - return new MatchResult(false, UserSettingSearchPrecision); + return new MatchResult(false, SearchPrecisionScore.Regular); } ArgumentNullException.ThrowIfNull(opt); query = query.Trim(); - // if (_alphabet != null) - // { - // query = _alphabet.Translate(query); - // stringToCompare = _alphabet.Translate(stringToCompare); - // } - // Using InvariantCulture since this is internal var fullStringToCompareWithoutCase = opt.IgnoreCase ? stringToCompare.ToUpper(CultureInfo.InvariantCulture) : stringToCompare; var queryWithoutCase = opt.IgnoreCase ? query.ToUpper(CultureInfo.InvariantCulture) : query; var querySubstrings = queryWithoutCase.Split(Separator, StringSplitOptions.RemoveEmptyEntries); - var currentQuerySubstringIndex = 0; + int currentQuerySubstringIndex = 0; var currentQuerySubstring = querySubstrings[currentQuerySubstringIndex]; var currentQuerySubstringCharacterIndex = 0; var firstMatchIndex = -1; var firstMatchIndexInWord = -1; var lastMatchIndex = 0; - var allQuerySubstringsMatched = false; - var matchFoundInPreviousLoop = false; - var allSubstringsContainedInCompareString = true; + bool allQuerySubstringsMatched = false; + bool matchFoundInPreviousLoop = false; + bool allSubstringsContainedInCompareString = true; var indexList = new List<int>(); List<int> spaceIndices = new List<int>(); @@ -207,10 +169,10 @@ public partial class StringMatcher var nearestSpaceIndex = CalculateClosestSpaceIndex(spaceIndices, firstMatchIndex); var score = CalculateSearchScore(query, stringToCompare, firstMatchIndex - nearestSpaceIndex - 1, lastMatchIndex - firstMatchIndex, allSubstringsContainedInCompareString); - return new MatchResult(true, UserSettingSearchPrecision, indexList, score); + return new MatchResult(true, SearchPrecisionScore.Regular, indexList, score); } - return new MatchResult(false, UserSettingSearchPrecision); + return new MatchResult(false, SearchPrecisionScore.Regular); } // To get the index of the closest space which precedes the first matching index @@ -222,14 +184,14 @@ public partial class StringMatcher } else { - return spaceIndices.OrderBy(item => (firstMatchIndex - item)).Where(item => firstMatchIndex > item).FirstOrDefault(-1); + return spaceIndices.OrderBy(item => firstMatchIndex - item).Where(item => firstMatchIndex > item).FirstOrDefault(-1); } } private static bool AllPreviousCharsMatched(int startIndexToVerify, int currentQuerySubstringCharacterIndex, string fullStringToCompareWithoutCase, string currentQuerySubstring) { var allMatch = true; - for (var indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) + for (int indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) { if (fullStringToCompareWithoutCase[startIndexToVerify + indexToCheck] != currentQuerySubstring[indexToCheck]) @@ -249,7 +211,7 @@ public partial class StringMatcher updatedList.AddRange(indexList); - for (var indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) + for (int indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) { updatedList.Add(startIndexToVerify + indexToCheck); } @@ -269,10 +231,9 @@ public partial class StringMatcher // while the score is lower if they are more spread out // The length of the match is assigned a larger weight factor. - // I.e. the length is more important than where in the string a match is found. const int matchLenWeightFactor = 2; - var score = 100 * (query.Length + 1) * matchLenWeightFactor / ((1 + firstIndex) + (matchLenWeightFactor * (matchLen + 1))); + var score = 100 * (query.Length + 1) * matchLenWeightFactor / (1 + firstIndex + (matchLenWeightFactor * (matchLen + 1))); // A match with less characters assigning more weights if (stringToCompare.Length - query.Length < 5) @@ -286,8 +247,8 @@ public partial class StringMatcher if (allSubstringsContainedInCompareString) { - var count = query.Count(c => !char.IsWhiteSpace(c)); - var threshold = 4; + int count = query.Count(c => !char.IsWhiteSpace(c)); + int threshold = 4; if (count <= threshold) { score += count * 10; diff --git a/src/common/Common.Search/GlobalSuppressions.cs b/src/common/Common.Search/GlobalSuppressions.cs new file mode 100644 index 0000000000..c1ccfc6882 --- /dev/null +++ b/src/common/Common.Search/GlobalSuppressions.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. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.MatchResult._rawScore")] +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._defaultMatchOption")] +[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore)")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore,System.Collections.Generic.List{System.Int32},System.Int32)")] +[assembly: SuppressMessage("Compiler", "CS8618:Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.", Justification = "Coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher.Separator")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1201:Elements should appear in the correct order", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.StringMatcher.#ctor")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.StringMatcher.FuzzyMatch(System.String,System.String)~Common.Search.MatchResult")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.StringMatcher.FuzzyMatch(System.String,System.String,Common.Search.MatchOption)~Common.Search.MatchResult")] +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1407:Arithmetic expressions should declare precedence", Justification = "migrate from stable code", Scope = "member", Target = "~M:Common.Search.StringMatcher.CalculateSearchScore(System.String,System.String,System.Int32,System.Int32,System.Boolean)~System.Int32")] +[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "migrate from stable code", Scope = "member", Target = "~M:Common.Search.StringMatcher.FuzzyMatch(System.String,System.String,Common.Search.MatchOption,System.Int32)~Common.Search.MatchResult")] +[assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1204:Static elements should appear before instance elements", Justification = "migrate from stable code", Scope = "member", Target = "~M:Common.Search.StringMatcher.CalculateClosestSpaceIndex(System.Collections.Generic.List{System.Int32},System.Int32)~System.Int32")] diff --git a/src/common/Common.Search/stylecop.json b/src/common/Common.Search/stylecop.json new file mode 100644 index 0000000000..a338998bbc --- /dev/null +++ b/src/common/Common.Search/stylecop.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "Microsoft Corporation", + "copyrightText": "Copyright (c) {companyName}\r\nThe {companyName} licenses this file to you under the MIT license.\r\nSee the LICENSE file in the project root for more information.", + "xmlHeader": false, + "headerDecoration": "", + "fileNamingConvention": "metadata", + "documentInterfaces": false, + "documentExposedElements": false, + "documentInternalElements": false + }, + "layoutRules": { + "newlineAtEndOfFile": "require" + }, + "orderingRules": { + "usingDirectivesPlacement": "outsideNamespace", + "systemUsingDirectivesFirst": true + } + } +} \ No newline at end of file diff --git a/src/common/Common.UI/Common.UI.csproj b/src/common/Common.UI/Common.UI.csproj index 19fe2f1ee4..671e03f7ec 100644 --- a/src/common/Common.UI/Common.UI.csproj +++ b/src/common/Common.UI/Common.UI.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\Common.Dotnet.AotCompatibility.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <UseWPF>true</UseWPF> diff --git a/src/common/Common.UI/SettingsDeepLink.cs b/src/common/Common.UI/SettingsDeepLink.cs index d247e726a1..fedf5480e3 100644 --- a/src/common/Common.UI/SettingsDeepLink.cs +++ b/src/common/Common.UI/SettingsDeepLink.cs @@ -2,8 +2,10 @@ // 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; using System.IO; +using ManagedCommon; namespace Common.UI { @@ -11,42 +13,63 @@ namespace Common.UI { public enum SettingsWindow { - Overview = 0, + Dashboard = 0, + Overview, + AlwaysOnTop, Awake, ColorPicker, + CmdNotFound, + LightSwitch, FancyZones, + FileLocksmith, Run, ImageResizer, KBM, MouseUtils, + MouseWithoutBorders, + Peek, + PowerAccent, + PowerLauncher, + PowerPreview, PowerRename, FileExplorer, ShortcutGuide, Hosts, MeasureTool, PowerOCR, + Workspaces, RegistryPreview, CropAndLock, EnvironmentVariables, - Dashboard, AdvancedPaste, - Workspaces, + NewPlus, CmdPal, ZoomIt, + PowerDisplay, } private static string SettingsWindowNameToString(SettingsWindow value) { switch (value) { + case SettingsWindow.Dashboard: + return "Dashboard"; case SettingsWindow.Overview: return "Overview"; + case SettingsWindow.AlwaysOnTop: + return "AlwaysOnTop"; case SettingsWindow.Awake: return "Awake"; case SettingsWindow.ColorPicker: return "ColorPicker"; + case SettingsWindow.CmdNotFound: + return "CmdNotFound"; + case SettingsWindow.LightSwitch: + return "LightSwitch"; case SettingsWindow.FancyZones: return "FancyZones"; + case SettingsWindow.FileLocksmith: + return "FileLocksmith"; case SettingsWindow.Run: return "Run"; case SettingsWindow.ImageResizer: @@ -55,6 +78,16 @@ namespace Common.UI return "KBM"; case SettingsWindow.MouseUtils: return "MouseUtils"; + case SettingsWindow.MouseWithoutBorders: + return "MouseWithoutBorders"; + case SettingsWindow.Peek: + return "Peek"; + case SettingsWindow.PowerAccent: + return "PowerAccent"; + case SettingsWindow.PowerLauncher: + return "PowerLauncher"; + case SettingsWindow.PowerPreview: + return "PowerPreview"; case SettingsWindow.PowerRename: return "PowerRename"; case SettingsWindow.FileExplorer: @@ -67,22 +100,24 @@ namespace Common.UI return "MeasureTool"; case SettingsWindow.PowerOCR: return "PowerOcr"; + case SettingsWindow.Workspaces: + return "Workspaces"; case SettingsWindow.RegistryPreview: return "RegistryPreview"; case SettingsWindow.CropAndLock: return "CropAndLock"; case SettingsWindow.EnvironmentVariables: return "EnvironmentVariables"; - case SettingsWindow.Dashboard: - return "Dashboard"; case SettingsWindow.AdvancedPaste: return "AdvancedPaste"; - case SettingsWindow.Workspaces: - return "Workspaces"; + case SettingsWindow.NewPlus: + return "NewPlus"; case SettingsWindow.CmdPal: return "CmdPal"; case SettingsWindow.ZoomIt: return "ZoomIt"; + case SettingsWindow.PowerDisplay: + return "PowerDisplay"; default: { return string.Empty; @@ -90,28 +125,33 @@ namespace Common.UI } } - public static void OpenSettings(SettingsWindow window, bool mainExecutableIsOnTheParentFolder) + // What about debug build? Should also consider debug build, maybe tray window message? + public static void OpenSettings(SettingsWindow window) { try { - var directoryPath = System.AppContext.BaseDirectory; - if (mainExecutableIsOnTheParentFolder) + var exePath = Path.Combine( + PowerToysPathResolver.GetPowerToysInstallPath(), + "PowerToys.exe"); + + if (exePath == null || !File.Exists(exePath)) { - // Need to go into parent folder for PowerToys.exe. Likely a WinUI3 App SDK application. - directoryPath = Path.Combine(directoryPath, ".."); - directoryPath = Path.Combine(directoryPath, "PowerToys.exe"); - } - else - { - // PowerToys.exe is in the same path as the application. - directoryPath = Path.Combine(directoryPath, "PowerToys.exe"); + Logger.LogError($"Failed to find powertoys exe path, {exePath}"); + return; } - Process.Start(new ProcessStartInfo(directoryPath) { Arguments = "--open-settings=" + SettingsWindowNameToString(window) }); + var args = "--open-settings=" + SettingsWindowNameToString(window); + + Process.Start(new ProcessStartInfo + { + FileName = exePath, + Arguments = args, + UseShellExecute = false, + }); } - catch + catch (Exception ex) { - // TODO(stefan): Log exception once unified logging is implemented + Logger.LogError(ex.Message); } } } diff --git a/src/common/Display/Display.vcxproj b/src/common/Display/Display.vcxproj index 87b74ba534..cbe24d73cf 100644 --- a/src/common/Display/Display.vcxproj +++ b/src/common/Display/Display.vcxproj @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{CABA8DFB-823B-4BF2-93AC-3F31984150D9}</ProjectGuid> @@ -10,7 +11,7 @@ <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -24,7 +25,7 @@ <ItemDefinitionGroup> <ClCompile> <PrecompiledHeader>NotUsing</PrecompiledHeader> - <AdditionalIncludeDirectories>..\..\..\;..\..\common;.\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;..\..\common;.\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> </ItemDefinitionGroup> @@ -39,5 +40,18 @@ <ClCompile Include="monitors.cpp" /> <ClCompile Include="dpi_aware.cpp" /> </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> </Project> \ 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..97349a856f --- /dev/null +++ b/src/common/Display/packages.config @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> +</packages> diff --git a/src/common/FilePreviewCommon/BgcodeBlockType.cs b/src/common/FilePreviewCommon/BgcodeBlockType.cs new file mode 100644 index 0000000000..296231e91c --- /dev/null +++ b/src/common/FilePreviewCommon/BgcodeBlockType.cs @@ -0,0 +1,16 @@ +// 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.PowerToys.FilePreviewCommon +{ + public enum BgcodeBlockType + { + FileMetadataBlock = 0, + GCodeBlock = 1, + SlicerMetadataBlock = 2, + PrinterMetadataBlock = 3, + PrintMetadataBlock = 4, + ThumbnailBlock = 5, + } +} diff --git a/src/common/FilePreviewCommon/BgcodeChecksumType.cs b/src/common/FilePreviewCommon/BgcodeChecksumType.cs new file mode 100644 index 0000000000..8d223568b7 --- /dev/null +++ b/src/common/FilePreviewCommon/BgcodeChecksumType.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.PowerToys.FilePreviewCommon +{ + public enum BgcodeChecksumType + { + None = 0, + CRC32 = 1, + } +} diff --git a/src/common/FilePreviewCommon/BgcodeCompressionType.cs b/src/common/FilePreviewCommon/BgcodeCompressionType.cs new file mode 100644 index 0000000000..95f15705e6 --- /dev/null +++ b/src/common/FilePreviewCommon/BgcodeCompressionType.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.PowerToys.FilePreviewCommon +{ + public enum BgcodeCompressionType + { + NoCompression = 0, + DeflateAlgorithm = 1, + HeatshrinkAlgorithm11 = 2, + HeatshrinkAlgorithm12 = 3, + } +} diff --git a/src/common/FilePreviewCommon/BgcodeHelper.cs b/src/common/FilePreviewCommon/BgcodeHelper.cs new file mode 100644 index 0000000000..0b1dca2d64 --- /dev/null +++ b/src/common/FilePreviewCommon/BgcodeHelper.cs @@ -0,0 +1,129 @@ +// 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.IO; +using System.IO.Compression; +using System.Linq; + +namespace Microsoft.PowerToys.FilePreviewCommon +{ + /// <summary> + /// Bgcode file helper class. + /// </summary> + public static class BgcodeHelper + { + private const uint MagicNumber = 'G' | 'C' << 8 | 'D' << 16 | 'E' << 24; + + /// <summary> + /// Gets any thumbnails found in a bgcode file. + /// </summary> + /// <param name="reader">The <see cref="BinaryReader"/> instance to the bgcode file.</param> + /// <returns>The thumbnails found in a bgcode file.</returns> + public static IEnumerable<BgcodeThumbnail> GetThumbnails(BinaryReader reader) + { + var magicNumber = reader.ReadUInt32(); + + if (magicNumber != MagicNumber) + { + throw new InvalidDataException("Invalid magic number."); + } + + var version = reader.ReadUInt32(); + + if (version != 1) + { + // Version 1 is the only one that exists + throw new InvalidDataException("Unsupported version."); + } + + var checksum = (BgcodeChecksumType)reader.ReadUInt16(); + + while (reader.BaseStream.Position < reader.BaseStream.Length) + { + var blockType = (BgcodeBlockType)reader.ReadUInt16(); + var compression = (BgcodeCompressionType)reader.ReadUInt16(); + var uncompressedSize = reader.ReadUInt32(); + + var size = compression == BgcodeCompressionType.NoCompression ? uncompressedSize : reader.ReadUInt32(); + + switch (blockType) + { + case BgcodeBlockType.FileMetadataBlock: + case BgcodeBlockType.PrinterMetadataBlock: + case BgcodeBlockType.PrintMetadataBlock: + case BgcodeBlockType.SlicerMetadataBlock: + case BgcodeBlockType.GCodeBlock: + reader.BaseStream.Seek(2 + size, SeekOrigin.Current); // Skip + + break; + + case BgcodeBlockType.ThumbnailBlock: + var format = (BgcodeThumbnailFormat)reader.ReadUInt16(); + + reader.BaseStream.Seek(4, SeekOrigin.Current); // Skip width and height + + var data = ReadAndDecompressData(reader, compression, (int)size); + + if (data != null) + { + yield return new BgcodeThumbnail(format, data); + } + + break; + } + + if (checksum == BgcodeChecksumType.CRC32) + { + reader.BaseStream.Seek(4, SeekOrigin.Current); // Skip checksum + } + } + } + + /// <summary> + /// Gets the best thumbnail available in a bgcode file. + /// </summary> + /// <param name="reader">The <see cref="BinaryReader"/> instance to the gcode file.</param> + /// <returns>The best thumbnail available in the gcode file.</returns> + public static BgcodeThumbnail? GetBestThumbnail(BinaryReader reader) + { + return GetThumbnails(reader) + .OrderByDescending(x => x.Format switch + { + BgcodeThumbnailFormat.PNG => 2, + BgcodeThumbnailFormat.QOI => 1, + BgcodeThumbnailFormat.JPG => 0, + _ => 0, + }) + .ThenByDescending(x => x.Data.Length) + .FirstOrDefault(); + } + + private static byte[]? ReadAndDecompressData(BinaryReader reader, BgcodeCompressionType compression, int size) + { + // Though the spec doesn't actually mention it, the reference encoder code never applies compression to thumbnails data + // which makes complete sense as this data is PNG, JPEG or QOI encoded so already compressed as much as possible! + switch (compression) + { + case BgcodeCompressionType.NoCompression: + return reader.ReadBytes(size); + + case BgcodeCompressionType.DeflateAlgorithm: + var buffer = new byte[size]; + + using (var deflateStream = new DeflateStream(reader.BaseStream, CompressionMode.Decompress, true)) + { + deflateStream.ReadExactly(buffer, 0, size); + } + + return buffer; + + default: + reader.BaseStream.Seek(size, SeekOrigin.Current); // Skip unknown or unsupported compression types + + return null; + } + } + } +} diff --git a/src/common/FilePreviewCommon/BgcodeThumbnail.cs b/src/common/FilePreviewCommon/BgcodeThumbnail.cs new file mode 100644 index 0000000000..d3449556c5 --- /dev/null +++ b/src/common/FilePreviewCommon/BgcodeThumbnail.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Drawing; +using System.IO; + +namespace Microsoft.PowerToys.FilePreviewCommon +{ + /// <summary> + /// Represents a bgcode thumbnail. + /// </summary> + public class BgcodeThumbnail + { + /// <summary> + /// Gets the bgcode thumbnail image format. + /// </summary> + public BgcodeThumbnailFormat Format { get; } + + /// <summary> + /// Gets the bgcode thumbnail image data. + /// </summary> + public byte[] Data { get; } + + /// <summary> + /// Initializes a new instance of the <see cref="BgcodeThumbnail"/> class. + /// </summary> + /// <param name="format">The bgcode thumbnail image format.</param> + /// <param name="data">The bgcode thumbnail image data.</param> + public BgcodeThumbnail(BgcodeThumbnailFormat format, byte[] data) + { + Format = format; + Data = data; + } + + /// <summary> + /// Gets a <see cref="Bitmap"/> representing this thumbnail. + /// </summary> + /// <returns>A <see cref="Bitmap"/> representing this thumbnail.</returns> + public Bitmap? GetBitmap() + { + switch (Format) + { + case BgcodeThumbnailFormat.JPG: + case BgcodeThumbnailFormat.PNG: + return BitmapFromByteArray(); + + case BgcodeThumbnailFormat.QOI: + return BitmapFromQoiByteArray(); + + default: + return null; + } + } + + private Bitmap BitmapFromByteArray() + { + return new Bitmap(new MemoryStream(Data)); + } + + private Bitmap BitmapFromQoiByteArray() + { + return QoiImage.FromStream(new MemoryStream(Data)); + } + } +} diff --git a/src/common/FilePreviewCommon/BgcodeThumbnailFormat.cs b/src/common/FilePreviewCommon/BgcodeThumbnailFormat.cs new file mode 100644 index 0000000000..61219dae48 --- /dev/null +++ b/src/common/FilePreviewCommon/BgcodeThumbnailFormat.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. + +namespace Microsoft.PowerToys.FilePreviewCommon +{ + public enum BgcodeThumbnailFormat + { + /// <summary> + /// PNG image format. + /// </summary> + PNG = 0, + + /// <summary> + /// JPG image format. + /// </summary> + JPG = 1, + + /// <summary> + /// QOI image format. + /// </summary> + QOI = 2, + } +} diff --git a/src/common/FilePreviewCommon/FilePreviewCommon.csproj b/src/common/FilePreviewCommon/FilePreviewCommon.csproj index 560fbf3287..b968ba5ab4 100644 --- a/src/common/FilePreviewCommon/FilePreviewCommon.csproj +++ b/src/common/FilePreviewCommon/FilePreviewCommon.csproj @@ -1,8 +1,8 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\Monaco.props" /> - <Import Project="..\..\Common.Dotnet.AotCompatibility.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Monaco.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <Description>PowerToys FilePreviewCommon</Description> diff --git a/src/common/FilePreviewCommon/QoiImage.cs b/src/common/FilePreviewCommon/QoiImage.cs index ac315eb832..fd72cb0e29 100644 --- a/src/common/FilePreviewCommon/QoiImage.cs +++ b/src/common/FilePreviewCommon/QoiImage.cs @@ -92,6 +92,9 @@ namespace Microsoft.PowerToys.FilePreviewCommon var run = 0; var chunksLen = fileSize - QOI_PADDING_LENGTH; + var x = 0; + var rowAdd = bitmapData.Stride - (channels * bitmapData.Width); + for (var dataIndex = 0; dataIndex < dataLength; dataIndex += channels) { if (run > 0) @@ -153,6 +156,14 @@ namespace Microsoft.PowerToys.FilePreviewCommon bitmapPixel[3] = pixel.A; } } + + x++; + if (x == bitmapData.Width) + { + // We align dataIndex with the stride + dataIndex += rowAdd; + x = 0; + } } bitmap.UnlockBits(bitmapData); diff --git a/src/common/GPOWrapper/GPOWrapper.cpp b/src/common/GPOWrapper/GPOWrapper.cpp index 62b5b49a9d..1132df9599 100644 --- a/src/common/GPOWrapper/GPOWrapper.cpp +++ b/src/common/GPOWrapper/GPOWrapper.cpp @@ -28,6 +28,14 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredCropAndLockEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredLightSwitchEnabledValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredLightSwitchEnabledValue()); + } + GpoRuleConfigured GPOWrapper::GetConfiguredPowerDisplayEnabledValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredPowerDisplayEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredFancyZonesEnabledValue() { return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredFancyZonesEnabledValue()); @@ -56,6 +64,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredGcodePreviewEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredBgcodePreviewEnabledValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredBgcodePreviewEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredSvgThumbnailsEnabledValue() { return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredSvgThumbnailsEnabledValue()); @@ -68,6 +80,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredGcodeThumbnailsEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredBgcodeThumbnailsEnabledValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredBgcodeThumbnailsEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredStlThumbnailsEnabledValue() { return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredStlThumbnailsEnabledValue()); @@ -100,6 +116,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredMousePointerCrosshairsEnabledValue()); } + GpoRuleConfigured GPOWrapper::GetConfiguredCursorWrapEnabledValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredCursorWrapEnabledValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredPowerRenameEnabledValue() { return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredPowerRenameEnabledValue()); @@ -180,6 +200,34 @@ namespace winrt::PowerToys::GPOWrapper::implementation { return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue()); } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteOpenAIValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteOpenAIValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteAzureOpenAIValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteAzureOpenAIValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteAzureAIInferenceValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteAzureAIInferenceValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteMistralValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteMistralValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteGoogleValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteGoogleValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteOllamaValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteOllamaValue()); + } + GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPasteFoundryLocalValue() + { + return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteFoundryLocalValue()); + } GpoRuleConfigured GPOWrapper::GetConfiguredNewPlusEnabledValue() { return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredNewPlusEnabledValue()); diff --git a/src/common/GPOWrapper/GPOWrapper.h b/src/common/GPOWrapper/GPOWrapper.h index 0d7783883b..aceb3bf756 100644 --- a/src/common/GPOWrapper/GPOWrapper.h +++ b/src/common/GPOWrapper/GPOWrapper.h @@ -13,6 +13,8 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredCmdPalEnabledValue(); static GpoRuleConfigured GetConfiguredColorPickerEnabledValue(); static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue(); + static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue(); + static GpoRuleConfigured GetConfiguredPowerDisplayEnabledValue(); static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue(); static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue(); static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue(); @@ -21,9 +23,11 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredMouseWithoutBordersEnabledValue(); static GpoRuleConfigured GetConfiguredPdfPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredGcodePreviewEnabledValue(); + static GpoRuleConfigured GetConfiguredBgcodePreviewEnabledValue(); static GpoRuleConfigured GetConfiguredSvgThumbnailsEnabledValue(); static GpoRuleConfigured GetConfiguredPdfThumbnailsEnabledValue(); static GpoRuleConfigured GetConfiguredGcodeThumbnailsEnabledValue(); + static GpoRuleConfigured GetConfiguredBgcodeThumbnailsEnabledValue(); static GpoRuleConfigured GetConfiguredStlThumbnailsEnabledValue(); static GpoRuleConfigured GetConfiguredHostsFileEditorEnabledValue(); static GpoRuleConfigured GetConfiguredImageResizerEnabledValue(); @@ -32,6 +36,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); static GpoRuleConfigured GetConfiguredQuickAccentEnabledValue(); @@ -51,6 +56,13 @@ namespace winrt::PowerToys::GPOWrapper::implementation static GpoRuleConfigured GetConfiguredQoiPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredQoiThumbnailsEnabledValue(); static GpoRuleConfigured GetAllowedAdvancedPasteOnlineAIModelsValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureAIInferenceValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteMistralValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue(); static GpoRuleConfigured GetConfiguredNewPlusEnabledValue(); static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue(); static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.idl b/src/common/GPOWrapper/GPOWrapper.idl index 1e3c3a19f5..58c35cd977 100644 --- a/src/common/GPOWrapper/GPOWrapper.idl +++ b/src/common/GPOWrapper/GPOWrapper.idl @@ -17,6 +17,8 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredCmdPalEnabledValue(); static GpoRuleConfigured GetConfiguredColorPickerEnabledValue(); static GpoRuleConfigured GetConfiguredCropAndLockEnabledValue(); + static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue(); + static GpoRuleConfigured GetConfiguredPowerDisplayEnabledValue(); static GpoRuleConfigured GetConfiguredFancyZonesEnabledValue(); static GpoRuleConfigured GetConfiguredFileLocksmithEnabledValue(); static GpoRuleConfigured GetConfiguredSvgPreviewEnabledValue(); @@ -24,9 +26,11 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredMonacoPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredPdfPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredGcodePreviewEnabledValue(); + static GpoRuleConfigured GetConfiguredBgcodePreviewEnabledValue(); static GpoRuleConfigured GetConfiguredSvgThumbnailsEnabledValue(); static GpoRuleConfigured GetConfiguredPdfThumbnailsEnabledValue(); static GpoRuleConfigured GetConfiguredGcodeThumbnailsEnabledValue(); + static GpoRuleConfigured GetConfiguredBgcodeThumbnailsEnabledValue(); static GpoRuleConfigured GetConfiguredStlThumbnailsEnabledValue(); static GpoRuleConfigured GetConfiguredHostsFileEditorEnabledValue(); static GpoRuleConfigured GetConfiguredImageResizerEnabledValue(); @@ -35,6 +39,7 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredMouseHighlighterEnabledValue(); static GpoRuleConfigured GetConfiguredMouseJumpEnabledValue(); static GpoRuleConfigured GetConfiguredMousePointerCrosshairsEnabledValue(); + static GpoRuleConfigured GetConfiguredCursorWrapEnabledValue(); static GpoRuleConfigured GetConfiguredMouseWithoutBordersEnabledValue(); static GpoRuleConfigured GetConfiguredPowerRenameEnabledValue(); static GpoRuleConfigured GetConfiguredPowerLauncherEnabledValue(); @@ -55,6 +60,13 @@ namespace PowerToys static GpoRuleConfigured GetConfiguredQoiPreviewEnabledValue(); static GpoRuleConfigured GetConfiguredQoiThumbnailsEnabledValue(); static GpoRuleConfigured GetAllowedAdvancedPasteOnlineAIModelsValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureOpenAIValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteAzureAIInferenceValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteMistralValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue(); + static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue(); static GpoRuleConfigured GetConfiguredNewPlusEnabledValue(); static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue(); static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue(); diff --git a/src/common/GPOWrapper/GPOWrapper.vcxproj b/src/common/GPOWrapper/GPOWrapper.vcxproj index c77620493d..5a2902e8cf 100644 --- a/src/common/GPOWrapper/GPOWrapper.vcxproj +++ b/src/common/GPOWrapper/GPOWrapper.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -16,10 +17,9 @@ <ApplicationType>Windows Store</ApplicationType> <ApplicationTypeRevision>10.0</ApplicationTypeRevision> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <GenerateManifest>false</GenerateManifest> <DesktopCompatible>true</DesktopCompatible> @@ -116,13 +116,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/GPOWrapper/packages.config b/src/common/GPOWrapper/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/common/GPOWrapper/packages.config +++ b/src/common/GPOWrapper/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/GPOWrapperProjection/GPOWrapper.cs b/src/common/GPOWrapperProjection/GPOWrapper.cs index 6cb91a69ac..9793310277 100644 --- a/src/common/GPOWrapperProjection/GPOWrapper.cs +++ b/src/common/GPOWrapperProjection/GPOWrapper.cs @@ -66,5 +66,10 @@ namespace PowerToys.GPOWrapperProjection { return (GpoRuleConfigured)PowerToys.GPOWrapper.GPOWrapper.GetConfiguredWorkspacesEnabledValue(); } + + public static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue() + { + return (GpoRuleConfigured)PowerToys.GPOWrapper.GPOWrapper.GetConfiguredLightSwitchEnabledValue(); + } } } diff --git a/src/common/GPOWrapperProjection/GPOWrapperProjection.csproj b/src/common/GPOWrapperProjection/GPOWrapperProjection.csproj index 4afd5d9393..a3f43f48fb 100644 --- a/src/common/GPOWrapperProjection/GPOWrapperProjection.csproj +++ b/src/common/GPOWrapperProjection/GPOWrapperProjection.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.cs new file mode 100644 index 0000000000..489a779179 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryCachedModel.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 LanguageModelProvider.FoundryLocal; + +internal sealed record FoundryCachedModel(string Name, string? Id); diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.cs new file mode 100644 index 0000000000..413bb47316 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryCatalogModel.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. + +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record FoundryCatalogModel +{ + [JsonPropertyName("name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("displayName")] + public string DisplayName { get; init; } = string.Empty; + + [JsonPropertyName("providerType")] + public string ProviderType { get; init; } = string.Empty; + + [JsonPropertyName("uri")] + public string Uri { get; init; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; init; } = string.Empty; + + [JsonPropertyName("modelType")] + public string ModelType { get; init; } = string.Empty; + + [JsonPropertyName("promptTemplate")] + public PromptTemplate PromptTemplate { get; init; } = default!; + + [JsonPropertyName("publisher")] + public string Publisher { get; init; } = string.Empty; + + [JsonPropertyName("task")] + public string Task { get; init; } = string.Empty; + + [JsonPropertyName("runtime")] + public Runtime Runtime { get; init; } = default!; + + [JsonPropertyName("fileSizeMb")] + public long FileSizeMb { get; init; } + + [JsonPropertyName("modelSettings")] + public ModelSettings ModelSettings { get; init; } = default!; + + [JsonPropertyName("alias")] + public string Alias { get; init; } = string.Empty; + + [JsonPropertyName("supportsToolCalling")] + public bool SupportsToolCalling { get; init; } + + [JsonPropertyName("license")] + public string License { get; init; } = string.Empty; + + [JsonPropertyName("licenseDescription")] + public string LicenseDescription { get; init; } = string.Empty; + + [JsonPropertyName("parentModelUri")] + public string ParentModelUri { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs new file mode 100644 index 0000000000..a279f7389a --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryClient.cs @@ -0,0 +1,279 @@ +// 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 ManagedCommon; +using Microsoft.AI.Foundry.Local; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed class FoundryClient +{ + public static async Task<FoundryClient?> CreateAsync() + { + // First attempt with current environment + var client = await TryCreateClientAsync().ConfigureAwait(false); + if (client != null) + { + return client; + } + + // If failed, refresh PATH from registry and retry once + // This handles cases where PowerToys was launched by MSI installer. + Logger.LogInfo("[FoundryClient] First attempt failed, refreshing PATH and retrying"); + RefreshEnvironmentPath(); + + return await TryCreateClientAsync().ConfigureAwait(false); + } + + private static async Task<FoundryClient?> TryCreateClientAsync() + { + try + { + Logger.LogInfo("[FoundryClient] Creating Foundry Local client"); + + var manager = new FoundryLocalManager(); + + // Check if service is already running + if (manager.IsServiceRunning) + { + Logger.LogInfo("[FoundryClient] Foundry service is already running"); + return new FoundryClient(manager); + } + + // Start the service using SDK's method + Logger.LogInfo("[FoundryClient] Starting Foundry service using manager.StartServiceAsync()"); + await manager.StartServiceAsync().ConfigureAwait(false); + + Logger.LogInfo("[FoundryClient] Foundry service started successfully"); + return new FoundryClient(manager); + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error creating client: {ex.Message}"); + if (ex.InnerException != null) + { + Logger.LogError($"[FoundryClient] Inner exception: {ex.InnerException.Message}"); + } + + return null; + } + } + + private readonly FoundryLocalManager _foundryManager; + private readonly List<FoundryCatalogModel> _catalogModels = []; + + private FoundryClient(FoundryLocalManager foundryManager) + { + _foundryManager = foundryManager; + } + + public Task<string?> GetServiceUrl() + { + try + { + return Task.FromResult(_foundryManager.Endpoint?.ToString()); + } + catch + { + return Task.FromResult<string?>(null); + } + } + + public Uri? GetServiceUri() + { + try + { + return _foundryManager.ServiceUri; + } + catch + { + return null; + } + } + + public async Task<List<FoundryCatalogModel>> ListCatalogModels() + { + if (_catalogModels.Count > 0) + { + return _catalogModels; + } + + try + { + Logger.LogInfo("[FoundryClient] Listing catalog models"); + var models = await _foundryManager.ListCatalogModelsAsync().ConfigureAwait(false); + + if (models != null) + { + foreach (var model in models) + { + _catalogModels.Add(new FoundryCatalogModel + { + Name = model.ModelId ?? string.Empty, + DisplayName = model.DisplayName ?? string.Empty, + ProviderType = model.ProviderType ?? string.Empty, + Uri = model.Uri ?? string.Empty, + Version = model.Version ?? string.Empty, + ModelType = model.ModelType ?? string.Empty, + Publisher = model.Publisher ?? string.Empty, + Task = model.Task ?? string.Empty, + FileSizeMb = model.FileSizeMb, + Alias = model.Alias ?? string.Empty, + License = model.License ?? string.Empty, + LicenseDescription = model.LicenseDescription ?? string.Empty, + ParentModelUri = model.ParentModelUri ?? string.Empty, + SupportsToolCalling = model.SupportsToolCalling, + }); + } + + Logger.LogInfo($"[FoundryClient] Found {_catalogModels.Count} catalog models"); + } + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error listing catalog models: {ex.Message}"); + + // Surfacing errors here prevents listing other providers; swallow and return cached list instead. + } + + return _catalogModels; + } + + public async Task<List<FoundryCachedModel>> ListCachedModels() + { + try + { + Logger.LogInfo("[FoundryClient] Listing cached models"); + var cachedModels = await _foundryManager.ListCachedModelsAsync().ConfigureAwait(false); + var catalogModels = await ListCatalogModels().ConfigureAwait(false); + + List<FoundryCachedModel> models = []; + + foreach (var model in cachedModels) + { + var catalogModel = catalogModels.FirstOrDefault(m => m.Name == model.ModelId); + var alias = catalogModel?.Alias ?? model.Alias; + models.Add(new FoundryCachedModel(model.ModelId ?? string.Empty, alias)); + } + + Logger.LogInfo($"[FoundryClient] Found {models.Count} cached models"); + return models; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Error listing cached models: {ex.Message}"); + return []; + } + } + + public async Task<bool> IsModelLoaded(string modelId) + { + try + { + var loadedModels = await _foundryManager.ListLoadedModelsAsync().ConfigureAwait(false); + var isLoaded = loadedModels.Any(m => m.ModelId == modelId); + Logger.LogInfo($"[FoundryClient] IsModelLoaded({modelId}): {isLoaded}"); + Logger.LogInfo($"[FoundryClient] Loaded models: {string.Join(", ", loadedModels.Select(m => m.ModelId))}"); + return isLoaded; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] IsModelLoaded exception: {ex.Message}"); + return false; + } + } + + public async Task<bool> EnsureModelLoaded(string modelId) + { + Logger.LogInfo($"[FoundryClient] EnsureModelLoaded called with: {modelId}"); + + // Check if already loaded + if (await IsModelLoaded(modelId).ConfigureAwait(false)) + { + Logger.LogInfo($"[FoundryClient] Model already loaded: {modelId}"); + return true; + } + + // Load the model + Logger.LogInfo($"[FoundryClient] Loading model: {modelId}"); + await _foundryManager.LoadModelAsync(modelId).ConfigureAwait(false); + + // Verify it's loaded + var loaded = await IsModelLoaded(modelId).ConfigureAwait(false); + Logger.LogInfo($"[FoundryClient] Model load result: {loaded}"); + return loaded; + } + + public async Task EnsureRunning() + { + if (!_foundryManager.IsServiceRunning) + { + await _foundryManager.StartServiceAsync(); + } + } + + /// <summary> + /// Refreshes the PATH environment variable from the system registry. + /// This is necessary when tools are installed while PowerToys is running, + /// as the installer updates the system PATH but running processes don't see the change. + /// </summary> + private static void RefreshEnvironmentPath() + { + try + { + Logger.LogInfo("[FoundryClient] Refreshing PATH environment variable from system"); + + var currentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process) ?? string.Empty; + var machinePath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Machine) ?? string.Empty; + var userPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.User) ?? string.Empty; + + var pathsToAdd = new List<string>(); + + if (!string.IsNullOrWhiteSpace(currentPath)) + { + pathsToAdd.AddRange(currentPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)); + } + + if (!string.IsNullOrWhiteSpace(userPath)) + { + var userPaths = userPath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in userPaths) + { + if (!pathsToAdd.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + pathsToAdd.Add(path); + } + } + } + + if (!string.IsNullOrWhiteSpace(machinePath)) + { + var machinePaths = machinePath.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + foreach (var path in machinePaths) + { + if (!pathsToAdd.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + pathsToAdd.Add(path); + } + } + } + + var newPath = string.Join(Path.PathSeparator.ToString(), pathsToAdd); + + if (currentPath != newPath) + { + Logger.LogInfo("[FoundryClient] Updating process PATH with latest system values"); + Environment.SetEnvironmentVariable("PATH", newPath, EnvironmentVariableTarget.Process); + } + else + { + Logger.LogInfo("[FoundryClient] PATH is already up to date"); + } + } + catch (Exception ex) + { + Logger.LogError($"[FoundryClient] Failed to refresh PATH: {ex.Message}"); + } + } +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs b/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs new file mode 100644 index 0000000000..5dcb4076ed --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/FoundryJsonContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace LanguageModelProvider.FoundryLocal; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = false)] +[JsonSerializable(typeof(FoundryCatalogModel))] +[JsonSerializable(typeof(List<FoundryCatalogModel>))] +internal sealed partial class FoundryJsonContext : JsonSerializerContext +{ +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs b/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs new file mode 100644 index 0000000000..fda91217eb --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/ModelSettings.cs @@ -0,0 +1,16 @@ +// 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; + +namespace LanguageModelProvider.FoundryLocal; + +internal sealed record ModelSettings +{ + // The sample shows an empty array; keep it open-ended. + [JsonPropertyName("parameters")] + public List<JsonElement> Parameters { get; init; } = []; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs b/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs new file mode 100644 index 0000000000..a2cbb9fe45 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/PromptTemplate.cs @@ -0,0 +1,16 @@ +// 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 LanguageModelProvider.FoundryLocal; + +internal sealed record PromptTemplate +{ + [JsonPropertyName("assistant")] + public string Assistant { get; init; } = string.Empty; + + [JsonPropertyName("prompt")] + public string Prompt { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs b/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs new file mode 100644 index 0000000000..e2019c8f87 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocal/Runtime.cs @@ -0,0 +1,16 @@ +// 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 LanguageModelProvider.FoundryLocal; + +internal sealed record Runtime +{ + [JsonPropertyName("deviceType")] + public string DeviceType { get; init; } = string.Empty; + + [JsonPropertyName("executionProvider")] + public string ExecutionProvider { get; init; } = string.Empty; +} diff --git a/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs new file mode 100644 index 0000000000..ae39080706 --- /dev/null +++ b/src/common/LanguageModelProvider/FoundryLocalModelProvider.cs @@ -0,0 +1,240 @@ +// 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.ClientModel; +using LanguageModelProvider.FoundryLocal; +using ManagedCommon; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace LanguageModelProvider; + +public sealed class FoundryLocalModelProvider : ILanguageModelProvider +{ + private FoundryClient? _foundryClient; + private IEnumerable<FoundryCatalogModel>? _catalogModels; + private string? _serviceUrl; + + public static FoundryLocalModelProvider Instance { get; } = new(); + + public string Name => "FoundryLocal"; + + public string ProviderDescription => "The model will run locally via Foundry Local"; + + public IChatClient? GetIChatClient(string modelId) + { + Logger.LogInfo($"[FoundryLocal] GetIChatClient called with url: {modelId}"); + InitializeAsync().GetAwaiter().GetResult(); + + if (string.IsNullOrWhiteSpace(modelId)) + { + Logger.LogError("[FoundryLocal] Model ID is empty after extraction"); + return null; + } + + // Check if model is in catalog + if (!EnsureModelInCatalog(modelId)) + { + var errorMessage = $"{modelId} is not supported in Foundry Local. Please configure supported models in Settings."; + Logger.LogError($"[FoundryLocal] {errorMessage}"); + throw new InvalidOperationException(errorMessage); + } + + // Ensure the model is loaded before returning chat client + var isLoaded = EnsureModelLoadedWithRefresh(modelId); + if (!isLoaded) + { + Logger.LogError($"[FoundryLocal] Failed to load model: {modelId}"); + throw new InvalidOperationException($"Failed to load the model '{modelId}'."); + } + + var client = _foundryClient; + if (client == null) + { + const string message = "Foundry Local client could not be created. Please make sure Foundry Local is installed and running."; + Logger.LogError($"[FoundryLocal] {message}"); + throw new InvalidOperationException(message); + } + + // Use ServiceUri instead of Endpoint since Endpoint already includes /v1 + var baseUri = client.GetServiceUri(); + if (baseUri == null && TryRefreshClient("Service URI was not available")) + { + baseUri = _foundryClient?.GetServiceUri(); + } + + if (baseUri == null) + { + const string message = "Foundry Local service URL is not available. Please make sure Foundry Local is installed and running."; + Logger.LogError($"[FoundryLocal] {message}"); + throw new InvalidOperationException(message); + } + + var endpointUri = new Uri($"{baseUri.ToString().TrimEnd('/')}/v1"); + Logger.LogInfo($"[FoundryLocal] Creating OpenAI client with endpoint: {endpointUri}"); + + return new OpenAIClient( + new ApiKeyCredential("none"), + new OpenAIClientOptions { Endpoint = endpointUri, NetworkTimeout = TimeSpan.FromMinutes(5) }) + .GetChatClient(modelId) + .AsIChatClient(); + } + + public string GetIChatClientString(string url) + { + try + { + InitializeAsync().GetAwaiter().GetResult(); + } + catch + { + return string.Empty; + } + + var modelId = url.Split('/').LastOrDefault(); + + if (string.IsNullOrWhiteSpace(_serviceUrl) || string.IsNullOrWhiteSpace(modelId)) + { + return string.Empty; + } + + return $"new OpenAIClient(new ApiKeyCredential(\"none\"), new OpenAIClientOptions{{ Endpoint = new Uri(\"{_serviceUrl}/v1\") }}).GetChatClient(\"{modelId}\").AsIChatClient()"; + } + + public async Task<IEnumerable<ModelDetails>> GetModelsAsync(CancellationToken cancelationToken = default) + { + await InitializeAsync(cancelationToken); + + if (_foundryClient == null) + { + return Array.Empty<ModelDetails>(); + } + + var cachedModels = await _foundryClient.ListCachedModels(); + List<ModelDetails> downloadedModels = []; + + foreach (var model in cachedModels) + { + Logger.LogInfo($"[FoundryLocal] Adding unmatched cached model: {model.Name}"); + downloadedModels.Add(new ModelDetails + { + Id = $"fl-{model.Name}", + Name = model.Name, + Url = $"fl://{model.Name}", + Description = $"{model.Name} running locally with Foundry Local", + HardwareAccelerators = [HardwareAccelerator.FOUNDRYLOCAL], + ProviderModelDetails = model, + }); + } + + return downloadedModels; + } + + private async Task InitializeAsync(CancellationToken cancelationToken = default) + { + if (_foundryClient != null && _catalogModels != null && _catalogModels.Any()) + { + await _foundryClient.EnsureRunning().ConfigureAwait(false); + _serviceUrl = await _foundryClient.GetServiceUrl().ConfigureAwait(false); + return; + } + + Logger.LogInfo("[FoundryLocal] Initializing provider"); + _foundryClient ??= await FoundryClient.CreateAsync(); + + if (_foundryClient == null) + { + const string message = "Foundry Local client could not be created. Please make sure Foundry Local is installed and running."; + Logger.LogError($"[FoundryLocal] {message}"); + throw new InvalidOperationException(message); + } + + _serviceUrl ??= await _foundryClient.GetServiceUrl(); + Logger.LogInfo($"[FoundryLocal] Service URL: {_serviceUrl}"); + + var catalogModels = await _foundryClient.ListCatalogModels(); + Logger.LogInfo($"[FoundryLocal] Found {catalogModels.Count} catalog models"); + _catalogModels = catalogModels; + } + + public async Task<bool> IsAvailable() + { + Logger.LogInfo("[FoundryLocal] Checking availability"); + await InitializeAsync(); + var available = _foundryClient != null; + Logger.LogInfo($"[FoundryLocal] Available: {available}"); + return available; + } + + private bool EnsureModelInCatalog(string modelId) + { + var isInCatalog = _catalogModels?.Any(m => m.Name == modelId) ?? false; + if (isInCatalog) + { + return true; + } + + Logger.LogWarning($"[FoundryLocal] Model not found in catalog. Refreshing client for model: {modelId}"); + if (!TryRefreshClient("Model not in catalog")) + { + return false; + } + + return _catalogModels?.Any(m => m.Name == modelId) ?? false; + } + + private bool EnsureModelLoadedWithRefresh(string modelId) + { + var isLoaded = false; + + try + { + isLoaded = _foundryClient!.EnsureModelLoaded(modelId).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Logger.LogWarning($"[FoundryLocal] EnsureModelLoaded failed: {ex.Message}"); + } + + if (isLoaded) + { + return true; + } + + if (!TryRefreshClient("EnsureModelLoaded failed")) + { + return false; + } + + try + { + return _foundryClient!.EnsureModelLoaded(modelId).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Logger.LogError($"[FoundryLocal] EnsureModelLoaded failed after refresh: {ex.Message}", ex); + return false; + } + } + + private bool TryRefreshClient(string reason) + { + Logger.LogInfo($"[FoundryLocal] Refreshing Foundry Local client: {reason}"); + + try + { + _foundryClient = null; + _catalogModels = null; + _serviceUrl = null; + + InitializeAsync().GetAwaiter().GetResult(); + return _foundryClient != null; + } + catch (Exception ex) + { + Logger.LogError($"[FoundryLocal] Failed to refresh Foundry Local client: {ex.Message}", ex); + return false; + } + } +} diff --git a/src/common/LanguageModelProvider/HardwareAccelerator.cs b/src/common/LanguageModelProvider/HardwareAccelerator.cs new file mode 100644 index 0000000000..d2c94b8155 --- /dev/null +++ b/src/common/LanguageModelProvider/HardwareAccelerator.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace LanguageModelProvider; + +public enum HardwareAccelerator +{ + CPU, + DML, + QNN, + WCRAPI, + OLLAMA, + OPENAI, + FOUNDRYLOCAL, + LEMONADE, + NPU, + GPU, + VitisAI, + OpenVINO, + NvTensorRT, +} diff --git a/src/common/LanguageModelProvider/ILanguageModelProvider.cs b/src/common/LanguageModelProvider/ILanguageModelProvider.cs new file mode 100644 index 0000000000..9d203adaf6 --- /dev/null +++ b/src/common/LanguageModelProvider/ILanguageModelProvider.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 Microsoft.Extensions.AI; + +namespace LanguageModelProvider; + +public interface ILanguageModelProvider +{ + string Name { get; } + + string ProviderDescription { get; } + + Task<IEnumerable<ModelDetails>> GetModelsAsync(CancellationToken cancelationToken = default); + + IChatClient? GetIChatClient(string modelId); + + string GetIChatClientString(string url); +} diff --git a/src/common/LanguageModelProvider/LanguageModelProvider.csproj b/src/common/LanguageModelProvider/LanguageModelProvider.csproj new file mode 100644 index 0000000000..8d9d6754aa --- /dev/null +++ b/src/common/LanguageModelProvider/LanguageModelProvider.csproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.AI" /> + <PackageReference Include="Microsoft.Extensions.AI.OpenAI" /> + <PackageReference Include="Microsoft.AI.Foundry.Local" /> + <PackageReference Include="OpenAI" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\ManagedCommon\ManagedCommon.csproj" /> + </ItemGroup> +</Project> diff --git a/src/common/LanguageModelProvider/ModelDetails.cs b/src/common/LanguageModelProvider/ModelDetails.cs new file mode 100644 index 0000000000..e383aa7d27 --- /dev/null +++ b/src/common/LanguageModelProvider/ModelDetails.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace LanguageModelProvider; + +public class ModelDetails +{ + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public long Size { get; set; } + + public bool IsUserAdded { get; set; } + + public string Icon { get; set; } = string.Empty; + + public List<HardwareAccelerator> HardwareAccelerators { get; set; } = []; + + public string License { get; set; } = string.Empty; + + public object? ProviderModelDetails { get; set; } +} diff --git a/src/common/ManagedCommon/ColorFormatHelper.cs b/src/common/ManagedCommon/ColorFormatHelper.cs index 08e62b921d..471104f215 100644 --- a/src/common/ManagedCommon/ColorFormatHelper.cs +++ b/src/common/ManagedCommon/ColorFormatHelper.cs @@ -141,6 +141,40 @@ namespace ManagedCommon return lab; } + /// <summary> + /// Convert a given <see cref="Color"/> to a Oklab color + /// </summary> + /// <param name="color">The <see cref="Color"/> to convert</param> + /// <returns>The perceptual lightness [0..1] and two chromaticities [-0.5..0.5]</returns> + public static (double Lightness, double ChromaticityA, double ChromaticityB) ConvertToOklabColor(Color color) + { + var linear = ConvertSRGBToLinearRGB(color.R / 255d, color.G / 255d, color.B / 255d); + var oklab = GetOklabColorFromLinearRGB(linear.R, linear.G, linear.B); + return oklab; + } + + /// <summary> + /// Convert a given <see cref="Color"/> to a Oklch color + /// </summary> + /// <param name="color">The <see cref="Color"/> to convert</param> + /// <returns>The perceptual lightness [0..1], the chroma [0..0.5], and the hue angle [0°..360°]</returns> + public static (double Lightness, double Chroma, double Hue) ConvertToOklchColor(Color color) + { + var oklab = ConvertToOklabColor(color); + var oklch = GetOklchColorFromOklab(oklab.Lightness, oklab.ChromaticityA, oklab.ChromaticityB); + + return oklch; + } + + public static (double R, double G, double B) ConvertSRGBToLinearRGB(double r, double g, double b) + { + // inverse companding, gamma correction must be undone + double rLinear = (r > 0.04045) ? Math.Pow((r + 0.055) / 1.055, 2.4) : (r / 12.92); + double gLinear = (g > 0.04045) ? Math.Pow((g + 0.055) / 1.055, 2.4) : (g / 12.92); + double bLinear = (b > 0.04045) ? Math.Pow((b + 0.055) / 1.055, 2.4) : (b / 12.92); + return (rLinear, gLinear, bLinear); + } + /// <summary> /// Convert a given <see cref="Color"/> to a CIE XYZ color (XYZ) /// The constants of the formula matches this Wikipedia page, but at a higher precision: @@ -156,10 +190,7 @@ namespace ManagedCommon double g = color.G / 255d; double b = color.B / 255d; - // inverse companding, gamma correction must be undone - double rLinear = (r > 0.04045) ? Math.Pow((r + 0.055) / 1.055, 2.4) : (r / 12.92); - double gLinear = (g > 0.04045) ? Math.Pow((g + 0.055) / 1.055, 2.4) : (g / 12.92); - double bLinear = (b > 0.04045) ? Math.Pow((b + 0.055) / 1.055, 2.4) : (b / 12.92); + (double rLinear, double gLinear, double bLinear) = ConvertSRGBToLinearRGB(r, g, b); return ( (rLinear * 0.41239079926595948) + (gLinear * 0.35758433938387796) + (bLinear * 0.18048078840183429), @@ -210,6 +241,63 @@ namespace ManagedCommon return (l, a, b); } + /// <summary> + /// Convert a linear RGB color <see cref="double"/> to an Oklab color. + /// The constants of this formula come from https://github.com/Evercoder/culori/blob/2bedb8f0507116e75f844a705d0b45cf279b15d0/src/oklab/convertLrgbToOklab.js + /// and the implementation is based on https://bottosson.github.io/posts/oklab/ + /// </summary> + /// <param name="r">Linear R value</param> + /// <param name="g">Linear G value</param> + /// <param name="b">Linear B value</param> + /// <returns>The perceptual lightness [0..1] and two chromaticities [-0.5..0.5]</returns> + private static (double Lightness, double ChromaticityA, double ChromaticityB) + GetOklabColorFromLinearRGB(double r, double g, double b) + { + double l = (0.41222147079999993 * r) + (0.5363325363 * g) + (0.0514459929 * b); + double m = (0.2119034981999999 * r) + (0.6806995450999999 * g) + (0.1073969566 * b); + double s = (0.08830246189999998 * r) + (0.2817188376 * g) + (0.6299787005000002 * b); + + double l_ = Math.Cbrt(l); + double m_ = Math.Cbrt(m); + double s_ = Math.Cbrt(s); + + return ( + (0.2104542553 * l_) + (0.793617785 * m_) - (0.0040720468 * s_), + (1.9779984951 * l_) - (2.428592205 * m_) + (0.4505937099 * s_), + (0.0259040371 * l_) + (0.7827717662 * m_) - (0.808675766 * s_) + ); + } + + /// <summary> + /// Convert an Oklab color <see cref="double"/> from Cartesian form to its polar form Oklch + /// https://bottosson.github.io/posts/oklab/#the-oklab-color-space + /// </summary> + /// <param name="lightness">The <see cref="lightness"/></param> + /// <param name="chromaticity_a">The <see cref="chromaticity_a"/></param> + /// <param name="chromaticity_b">The <see cref="chromaticity_b"/></param> + /// <returns>The perceptual lightness [0..1], the chroma [0..0.5], and the hue angle [0°..360°]</returns> + private static (double Lightness, double Chroma, double Hue) + GetOklchColorFromOklab(double lightness, double chromaticity_a, double chromaticity_b) + { + return GetLCHColorFromLAB(lightness, chromaticity_a, chromaticity_b); + } + + /// <summary> + /// Convert a color in Cartesian form (Lab) to its polar form (LCh) + /// </summary> + /// <param name="lightness">The <see cref="lightness"/></param> + /// <param name="chromaticity_a">The <see cref="chromaticity_a"/></param> + /// <param name="chromaticity_b">The <see cref="chromaticity_b"/></param> + /// <returns>The lightness, chroma, and hue angle</returns> + private static (double Lightness, double Chroma, double Hue) + GetLCHColorFromLAB(double lightness, double chromaticity_a, double chromaticity_b) + { + // Lab to LCh transformation + double chroma = Math.Sqrt(Math.Pow(chromaticity_a, 2) + Math.Pow(chromaticity_b, 2)); + double hue = Math.Round(chroma, 3) == 0 ? 0.0 : ((Math.Atan2(chromaticity_b, chromaticity_a) * 180d / Math.PI) + 360d) % 360d; + return (lightness, chroma, hue); + } + /// <summary> /// Convert a given <see cref="Color"/> to a natural color (hue, whiteness, blackness) /// </summary> @@ -276,12 +364,17 @@ namespace ManagedCommon { "Br", 'p' }, // brightness percent { "In", 'p' }, // intensity percent { "Ll", 'p' }, // lightness (HSL) percent - { "Lc", 'p' }, // lightness(CIELAB)percent { "Va", 'p' }, // value percent { "Wh", 'p' }, // whiteness percent { "Bn", 'p' }, // blackness percent - { "Ca", 'p' }, // chromaticityA percent - { "Cb", 'p' }, // chromaticityB percent + { "Lc", 'p' }, // lightness (CIE) percent + { "Ca", 'p' }, // chromaticityA (CIELAB) percent + { "Cb", 'p' }, // chromaticityB (CIELAB) percent + { "Lo", 'p' }, // lightness (Oklab/Oklch) percent + { "Oa", 'p' }, // chromaticityA (Oklab) percent + { "Ob", 'p' }, // chromaticityB (Oklab) percent + { "Oc", 'p' }, // chroma (Oklch) percent + { "Oh", 'p' }, // hue angle (Oklch) percent { "Xv", 'i' }, // X value int { "Yv", 'i' }, // Y value int { "Zv", 'i' }, // Z value int @@ -424,6 +517,10 @@ namespace ManagedCommon var (lightnessC, _, _) = ConvertToCIELABColor(color); lightnessC = Math.Round(lightnessC, 2); return lightnessC.ToString(CultureInfo.InvariantCulture); + case "Lo": + var (lightnessO, _, _) = ConvertToOklabColor(color); + lightnessO = Math.Round(lightnessO, 2); + return lightnessO.ToString(CultureInfo.InvariantCulture); case "Wh": var (_, whiteness, _) = ConvertToHWBColor(color); whiteness = Math.Round(whiteness * 100); @@ -440,6 +537,22 @@ namespace ManagedCommon var (_, _, chromaticityB) = ConvertToCIELABColor(color); chromaticityB = Math.Round(chromaticityB, 2); return chromaticityB.ToString(CultureInfo.InvariantCulture); + case "Oa": + var (_, chromaticityAOklab, _) = ConvertToOklabColor(color); + chromaticityAOklab = Math.Round(chromaticityAOklab, 2); + return chromaticityAOklab.ToString(CultureInfo.InvariantCulture); + case "Ob": + var (_, _, chromaticityBOklab) = ConvertToOklabColor(color); + chromaticityBOklab = Math.Round(chromaticityBOklab, 2); + return chromaticityBOklab.ToString(CultureInfo.InvariantCulture); + case "Oc": + var (_, chromaOklch, _) = ConvertToOklchColor(color); + chromaOklch = Math.Round(chromaOklch, 2); + return chromaOklch.ToString(CultureInfo.InvariantCulture); + case "Oh": + var (_, _, hueOklch) = ConvertToOklchColor(color); + hueOklch = Math.Round(hueOklch, 2); + return hueOklch.ToString(CultureInfo.InvariantCulture); case "Xv": var (x, _, _) = ConvertToCIEXYZColor(color); x = Math.Round(x * 100, 4); @@ -495,8 +608,10 @@ namespace ManagedCommon case "HSI": return "hsi(%Hu, %Si%, %In%)"; case "HWB": return "hwb(%Hu, %Wh%, %Bn%)"; case "NCol": return "%Hn, %Wh%, %Bn%"; - case "CIELAB": return "CIELab(%Lc, %Ca, %Cb)"; case "CIEXYZ": return "XYZ(%Xv, %Yv, %Zv)"; + case "CIELAB": return "CIELab(%Lc, %Ca, %Cb)"; + case "Oklab": return "oklab(%Lo, %Oa, %Ob)"; + case "Oklch": return "oklch(%Lo, %Oc, %Oh)"; case "VEC4": return "(%Reff, %Grff, %Blff, 1f)"; case "Decimal": return "%Dv"; case "HEX Int": return "0xFF%ReX%GrX%BlX"; diff --git a/src/common/ManagedCommon/IdRecoveryHelper.cs b/src/common/ManagedCommon/IdRecoveryHelper.cs new file mode 100644 index 0000000000..cd0fcb57a5 --- /dev/null +++ b/src/common/ManagedCommon/IdRecoveryHelper.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ManagedCommon +{ + public static class IdRecoveryHelper + { + /// <summary> + /// Fixes invalid IDs in the given list by assigning unique values. + /// It ensures that all IDs are non-empty and unique, correcting any duplicates or empty IDs. + /// </summary> + /// <param name="items">The list of items that may contain invalid IDs.</param> + public static void RecoverInvalidIds<T>(IEnumerable<T> items) + where T : class, IHasId + { + var idSet = new HashSet<int>(); + int newId = 0; + var sortedItems = items.OrderBy(i => i.Id).ToList(); // Sort items by ID for consistent processing + + // Iterate through the list and fix invalid IDs + foreach (var item in sortedItems) + { + // If the ID is invalid or already exists in the set (duplicate), assign a new unique ID + if (!idSet.Add(item.Id)) + { + // Find the next available unique ID + while (idSet.Contains(newId)) + { + newId++; + } + + item.Id = newId; + idSet.Add(newId); // Add the newly assigned ID to the set + } + } + } + } + + public interface IHasId + { + int Id { get; set; } + } +} diff --git a/src/common/ManagedCommon/Logger.cs b/src/common/ManagedCommon/Logger.cs index b67d63c3b5..7f72cdd78b 100644 --- a/src/common/ManagedCommon/Logger.cs +++ b/src/common/ManagedCommon/Logger.cs @@ -6,9 +6,10 @@ using System; using System.Diagnostics; using System.Globalization; using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; - +using System.Threading.Tasks; using PowerToys.Interop; namespace ManagedCommon @@ -18,19 +19,27 @@ namespace ManagedCommon private static readonly string Error = "Error"; private static readonly string Warning = "Warning"; private static readonly string Info = "Info"; +#if DEBUG private static readonly string Debug = "Debug"; +#endif private static readonly string TraceFlag = "Trace"; - private static readonly Assembly Assembly = Assembly.GetExecutingAssembly(); + private static readonly string Version = Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? "Unknown"; - /* - * Please pay more attention! - * If you want to publish it with Native AOT enabled (or publish as a single file). - * You need to find another way to remove Assembly.Location usage. - */ -#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - private static readonly string Version = FileVersionInfo.GetVersionInfo(Assembly.Location).ProductVersion; -#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file + /// <summary> + /// Gets the path to the log directory for the current version of the app. + /// </summary> + public static string CurrentVersionLogDirectoryPath { get; private set; } + + /// <summary> + /// Gets the path to the current log file. + /// </summary> + public static string CurrentLogFile { get; private set; } + + /// <summary> + /// Gets the path to the log directory for the app. + /// </summary> + public static string AppLogDirectoryPath { get; private set; } /// <summary> /// Initializes the logger and sets the path for logging. @@ -40,25 +49,84 @@ namespace ManagedCommon /// <param name="isLocalLow">If the process using Logger is a low-privilege process.</param> public static void InitializeLogger(string applicationLogPath, bool isLocalLow = false) { - if (isLocalLow) + string versionedPath = LogDirectoryPath(applicationLogPath, isLocalLow); + string basePath = Path.GetDirectoryName(versionedPath); + + if (!Directory.Exists(versionedPath)) { - applicationLogPath = Environment.GetEnvironmentVariable("userprofile") + "\\appdata\\LocalLow\\Microsoft\\PowerToys" + applicationLogPath + "\\" + Version; - } - else - { - applicationLogPath = Constants.AppDataPath() + applicationLogPath + "\\" + Version; + Directory.CreateDirectory(versionedPath); } - if (!Directory.Exists(applicationLogPath)) - { - Directory.CreateDirectory(applicationLogPath); - } + AppLogDirectoryPath = basePath; + CurrentVersionLogDirectoryPath = versionedPath; - var logFilePath = Path.Combine(applicationLogPath, "Log_" + DateTime.Now.ToString(@"yyyy-MM-dd", CultureInfo.InvariantCulture) + ".txt"); + var logFile = "Log_" + DateTime.Now.ToString(@"yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"; + var logFilePath = Path.Combine(versionedPath, logFile); + CurrentLogFile = logFilePath; Trace.Listeners.Add(new TextWriterTraceListener(logFilePath)); Trace.AutoFlush = true; + + // Clean up old version log folders + Task.Run(() => DeleteOldVersionLogFolders(basePath, versionedPath)); + } + + public static string LogDirectoryPath(string applicationLogPath, bool isLocalLow = false) + { + string basePath; + if (isLocalLow) + { + basePath = Environment.GetEnvironmentVariable("userprofile") + "\\appdata\\LocalLow\\Microsoft\\PowerToys" + applicationLogPath; + } + else + { + basePath = Constants.AppDataPath() + applicationLogPath; + } + + string versionedPath = Path.Combine(basePath, Version); + return versionedPath; + } + + /// <summary> + /// Deletes old version log folders, keeping only the current version's folder. + /// </summary> + /// <param name="basePath">The base path to the log files folder.</param> + /// <param name="currentVersionPath">The path to the current version's log folder.</param> + private static void DeleteOldVersionLogFolders(string basePath, string currentVersionPath) + { + try + { + if (!Directory.Exists(basePath)) + { + return; + } + + var dirs = Directory.GetDirectories(basePath) + .Select(d => new DirectoryInfo(d)) + .OrderBy(d => d.CreationTime) + .Where(d => !string.Equals(d.FullName, currentVersionPath, StringComparison.OrdinalIgnoreCase)) + .Take(3) + .ToList(); + + foreach (var directory in dirs) + { + try + { + Directory.Delete(directory.FullName, true); + LogInfo($"Deleted old log directory: {directory.FullName}"); + Task.Delay(500).Wait(); + } + catch (Exception ex) + { + LogError($"Failed to delete old log directory: {directory.FullName}", ex); + } + } + } + catch (Exception ex) + { + LogError("Error cleaning up old log folders", ex); + } } public static void LogError(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) @@ -76,13 +144,13 @@ namespace ManagedCommon { var exMessage = message + Environment.NewLine + - ex.GetType() + ": " + ex.Message + Environment.NewLine; + ex.GetType() + " (" + ex.HResult + "): " + ex.Message + Environment.NewLine; if (ex.InnerException != null) { exMessage += "Inner exception: " + Environment.NewLine + - ex.InnerException.GetType() + ": " + ex.InnerException.Message + Environment.NewLine; + ex.InnerException.GetType() + " (" + ex.InnerException.HResult + "): " + ex.InnerException.Message + Environment.NewLine; } exMessage += @@ -105,7 +173,9 @@ namespace ManagedCommon public static void LogDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) { +#if DEBUG Log(message, Debug, memberName, sourceFilePath, sourceLineNumber); +#endif } public static void LogTrace([System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) diff --git a/src/common/ManagedCommon/ManagedCommon.csproj b/src/common/ManagedCommon/ManagedCommon.csproj index bd74253073..83ff7e058d 100644 --- a/src/common/ManagedCommon/ManagedCommon.csproj +++ b/src/common/ManagedCommon/ManagedCommon.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\Common.Dotnet.AotCompatibility.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <Description>PowerToys ManagedCommon</Description> diff --git a/src/common/ManagedCommon/ModuleType.cs b/src/common/ManagedCommon/ModuleType.cs index 65b00d4b5a..548276f725 100644 --- a/src/common/ManagedCommon/ModuleType.cs +++ b/src/common/ManagedCommon/ModuleType.cs @@ -12,6 +12,7 @@ namespace ManagedCommon ColorPicker, CmdPal, CropAndLock, + CursorWrap, EnvironmentVariables, FancyZones, FileLocksmith, @@ -19,6 +20,7 @@ namespace ManagedCommon Hosts, ImageResizer, KeyboardManager, + LightSwitch, MouseHighlighter, MouseJump, MousePointerCrosshairs, @@ -28,11 +30,13 @@ namespace ManagedCommon PowerRename, PowerLauncher, PowerAccent, + PowerDisplay, RegistryPreview, MeasureTool, ShortcutGuide, PowerOCR, Workspaces, ZoomIt, + GeneralSettings, } } diff --git a/src/common/ManagedCommon/OSVersionHelper.cs b/src/common/ManagedCommon/OSVersionHelper.cs index 6a865b7ae1..c9c17755ad 100644 --- a/src/common/ManagedCommon/OSVersionHelper.cs +++ b/src/common/ManagedCommon/OSVersionHelper.cs @@ -10,7 +10,7 @@ namespace ManagedCommon { public static bool IsWindows10() { - return Environment.OSVersion.Version.Major >= 10 && Environment.OSVersion.Version.Minor < 22000; + return Environment.OSVersion.Version.Major >= 10 && Environment.OSVersion.Version.Build < 22000; } public static bool IsWindows11() diff --git a/src/common/ManagedCommon/PowerToysPathResolver.cs b/src/common/ManagedCommon/PowerToysPathResolver.cs new file mode 100644 index 0000000000..fc6afee818 --- /dev/null +++ b/src/common/ManagedCommon/PowerToysPathResolver.cs @@ -0,0 +1,168 @@ +// 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; +using System.IO; +using System.Runtime.Versioning; +using Microsoft.Win32; + +namespace ManagedCommon +{ + [SupportedOSPlatform("windows")] + public class PowerToysPathResolver + { + private const string PowerToysRegistryKey = @"Software\Classes\powertoys"; + private const string PowerToysExe = "PowerToys.exe"; + + /// <summary> + /// Gets the PowerToys installation path by checking registry entries + /// </summary> + /// <returns>The path to PowerToys installation directory, or null if not found</returns> + public static string GetPowerToysInstallPath() + { +#if DEBUG + // In debug builds, resolve directly from the running process (no installer/registry involved). + return GetPathFromCurrentProcess(); +#else + // Try to get path from Per-User installation first + string path = GetPathFromRegistry(RegistryHive.CurrentUser); + if (!string.IsNullOrEmpty(path)) + { + return path; + } + + // Fall back to Per-Machine installation + path = GetPathFromRegistry(RegistryHive.LocalMachine); + if (!string.IsNullOrEmpty(path)) + { + return path; + } + + return null; +#endif + } + + private static string GetPathFromRegistry(RegistryHive hive) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, RegistryView.Registry64); + + // First try to get path from the powertoys protocol registration + string path = GetPathFromProtocolRegistration(baseKey); + if (!string.IsNullOrEmpty(path)) + { + return path; + } + } + catch (Exception) + { + // Ignore registry access errors + } + + return null; + } + + private static string GetPathFromProtocolRegistration(RegistryKey baseKey) + { + try + { + using var key = baseKey.OpenSubKey($@"{PowerToysRegistryKey}\shell\open\command"); + + if (key != null) + { + string command = key.GetValue(string.Empty)?.ToString(); + if (!string.IsNullOrEmpty(command)) + { + // Parse command like: "C:\Program Files\PowerToys\PowerToys.exe" "%1" + return ExtractPathFromCommand(command); + } + } + } + catch (Exception) + { + // Ignore registry access errors + } + + return null; + } + + private static string GetPathFromCurrentProcess() + { + try + { + // If we're running inside PowerToys.exe (dev/debug builds), use the executable location. + var processPath = Process.GetCurrentProcess().MainModule?.FileName; + if (!string.IsNullOrEmpty(processPath)) + { + var processDir = Path.GetDirectoryName(processPath); + if (!string.IsNullOrEmpty(processDir) && File.Exists(Path.Combine(processDir, PowerToysExe))) + { + return processDir; + } + } + + // As a fallback, walk up from AppContext.BaseDirectory to find PowerToys.exe. + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, PowerToysExe); + if (File.Exists(candidate)) + { + return directory.FullName; + } + + directory = directory.Parent; + } + } + catch + { + // Ignore reflection/process permission errors; caller will see null and handle accordingly. + } + + return null; + } + + private static string ExtractPathFromCommand(string command) + { + if (string.IsNullOrEmpty(command)) + { + return null; + } + + try + { + // Handle quoted paths: "C:\Program Files\PowerToys\PowerToys.exe" "%1" + if (command.StartsWith('\"')) + { + int endQuote = command.IndexOf('\"', 1); + if (endQuote > 1) + { + string exePath = command.Substring(1, endQuote - 1); + if (File.Exists(exePath)) + { + return Path.GetDirectoryName(exePath); + } + } + } + else + { + // Handle unquoted paths (less common) + string[] parts = command.Split(' '); + if (parts.Length > 0 && File.Exists(parts[0])) + { + return Path.GetDirectoryName(parts[0]); + } + } + } + catch (Exception) + { + // Ignore path parsing errors + } + + return null; + } + } +} diff --git a/src/common/ManagedCommon/WindowHelpers.cs b/src/common/ManagedCommon/WindowHelpers.cs index c5ee13f69c..442ca34e27 100644 --- a/src/common/ManagedCommon/WindowHelpers.cs +++ b/src/common/ManagedCommon/WindowHelpers.cs @@ -41,6 +41,7 @@ namespace ManagedCommon /// black. Calls <c>DwmExtendFrameIntoClientArea()</c> with a <c>cyTopHeight</c> of 2 to force /// the window's top border to be visible.<br/><br/> /// Is a no-op on versions other than Windows 10. + /// WinUI issue: https://github.com/microsoft/microsoft-ui-xaml/issues/6901 /// </summary> public static void ForceTopBorder1PixelInsetOnWindows10(IntPtr handle) { diff --git a/src/common/ManagedCsWin32/CLSID.cs b/src/common/ManagedCsWin32/CLSID.cs new file mode 100644 index 0000000000..00315fe737 --- /dev/null +++ b/src/common/ManagedCsWin32/CLSID.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ManagedCsWin32; + +public static partial class CLSID +{ + public static readonly Guid SearchManager = new Guid("7D096C5F-AC08-4F1F-BEB7-5C22C517CE39"); + public static readonly Guid CollatorDataSource = new Guid("9E175B8B-F52A-11D8-B9A5-505054503030"); + public static readonly Guid ApplicationActivationManager = new Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C"); + public static readonly Guid VirtualDesktopManager = new("aa509086-5ca9-4c25-8f95-589d3c07b48a"); + public static readonly Guid DesktopWallpaper = new("C2CF3110-460E-4FC1-B9D0-8A1C0C9CC4BD"); +} diff --git a/src/common/ManagedCsWin32/ComHelper.cs b/src/common/ManagedCsWin32/ComHelper.cs new file mode 100644 index 0000000000..9d7fd71d23 --- /dev/null +++ b/src/common/ManagedCsWin32/ComHelper.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; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace ManagedCsWin32; + +public static class ComHelper +{ + private static StrategyBasedComWrappers cw = new StrategyBasedComWrappers(); + + public static T CreateComInstance<T>(ref Guid rclsid, CLSCTX dwClsContext) + { + var riid = typeof(T).GUID; + + var hr = Ole32.CoCreateInstance(ref rclsid, IntPtr.Zero, dwClsContext, ref riid, out IntPtr comPtr); + if (hr != 0) + { + throw new ArgumentException($"Failed to create {typeof(T).Name} instance. HR: {hr}"); + } + + if (comPtr == IntPtr.Zero) + { + throw new ArgumentException($"Failed to create {typeof(T).Name} instance. CoCreateInstance return null ptr."); + } + + try + { + var comObject = cw.GetOrCreateObjectForComInstance(comPtr, CreateObjectFlags.None); + if (comObject == null) + { + throw new ArgumentException($"Failed to create {typeof(T).Name} instance. Cast error."); + } + + return (T)comObject; + } + finally + { + Marshal.Release(comPtr); + } + } +} diff --git a/src/common/ManagedCsWin32/IID.cs b/src/common/ManagedCsWin32/IID.cs new file mode 100644 index 0000000000..5da7306755 --- /dev/null +++ b/src/common/ManagedCsWin32/IID.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace ManagedCsWin32; + +public static partial class IID +{ + public static readonly Guid ISearchManager = new Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF69"); + public static readonly Guid IPropertyStore = new Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"); + public static readonly Guid IApplicationActivationManager = new Guid("2e941141-7f97-4756-ba1d-9decde894a3d"); + public static readonly Guid IVirtualDesktopManager = new("a5cd92ff-29be-454c-8d04-d82879fb3f1b"); +} diff --git a/src/common/ManagedCsWin32/Kernel32.cs b/src/common/ManagedCsWin32/Kernel32.cs new file mode 100644 index 0000000000..38280ebcf5 --- /dev/null +++ b/src/common/ManagedCsWin32/Kernel32.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace ManagedCsWin32; + +public static partial class Kernel32 +{ + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool DeviceIoControl( + IntPtr hDevice, + uint dwIoControlCode, + IntPtr inBuffer, + int nInBufferSize, + IntPtr outBuffer, + int nOutBufferSize, + out int pBytesReturned, + IntPtr lpOverlapped); + + [LibraryImport("kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16, EntryPoint = "CreateFileW")] + public static partial int CreateFile( + string lpFileName, + FileAccessType dwDesiredAccess, + FileShareType dwShareMode, + IntPtr lpSecurityAttributes, + CreationDisposition dwCreationDisposition, + FileAttributes dwFlagsAndAttributes, + IntPtr hTemplateFile); +} + +[Flags] +public enum FileAccessType : uint +{ + DELETE = 0x00010000, + READ_CONTROL = 0x00020000, + WRITE_DAC = 0x00040000, + WRITE_OWNER = 0x00080000, + SYNCHRONIZE = 0x00100000, + + STANDARD_RIGHTS_REQUIRED = 0x000F0000, + + STANDARD_RIGHTS_READ = READ_CONTROL, + STANDARD_RIGHTS_WRITE = READ_CONTROL, + STANDARD_RIGHTS_EXECUTE = READ_CONTROL, + + STANDARD_RIGHTS_ALL = 0x001F0000, + + SPECIFIC_RIGHTS_ALL = 0x0000FFFF, + + ACCESS_SYSTEM_SECURITY = 0x01000000, + + MAXIMUM_ALLOWED = 0x02000000, + + GENERIC_READ = 0x80000000, + GENERIC_WRITE = 0x40000000, + GENERIC_EXECUTE = 0x20000000, + GENERIC_ALL = 0x10000000, + + FILE_READ_DATA = 0x0001, + FILE_WRITE_DATA = 0x0002, + FILE_APPEND_DATA = 0x0004, + FILE_READ_EA = 0x0008, + FILE_WRITE_EA = 0x0010, + FILE_EXECUTE = 0x0020, + FILE_READ_ATTRIBUTES = 0x0080, + FILE_WRITE_ATTRIBUTES = 0x0100, + + FILE_ALL_ACCESS = + STANDARD_RIGHTS_REQUIRED | + SYNCHRONIZE + | 0x1FF, + + FILE_GENERIC_READ = + STANDARD_RIGHTS_READ | + FILE_READ_DATA | + FILE_READ_ATTRIBUTES | + FILE_READ_EA | + SYNCHRONIZE, + + FILE_GENERIC_WRITE = + STANDARD_RIGHTS_WRITE | + FILE_WRITE_DATA | + FILE_WRITE_ATTRIBUTES | + FILE_WRITE_EA | + FILE_APPEND_DATA | + SYNCHRONIZE, + + FILE_GENERIC_EXECUTE = + STANDARD_RIGHTS_EXECUTE | + FILE_READ_ATTRIBUTES | + FILE_EXECUTE | + SYNCHRONIZE, +} + +[Flags] +public enum FileShareType : uint +{ + None = 0x00000000, + Read = 0x00000001, + Write = 0x00000002, + Delete = 0x00000004, +} + +public enum CreationDisposition : uint +{ + New = 1, + CreateAlways = 2, + OpenExisting = 3, + OpenAlways = 4, + TruncateExisting = 5, +} + +[Flags] +public enum FileAttributes : uint +{ + Readonly = 0x00000001, + Hidden = 0x00000002, + System = 0x00000004, + Directory = 0x00000010, + Archive = 0x00000020, + Device = 0x00000040, + Normal = 0x00000080, + Temporary = 0x00000100, + SparseFile = 0x00000200, + ReparsePoint = 0x00000400, + Compressed = 0x00000800, + Offline = 0x00001000, + NotContentIndexed = 0x00002000, + Encrypted = 0x00004000, + Write_Through = 0x80000000, + Overlapped = 0x40000000, + NoBuffering = 0x20000000, + RandomAccess = 0x10000000, + SequentialScan = 0x08000000, + DeleteOnClose = 0x04000000, + BackupSemantics = 0x02000000, + PosixSemantics = 0x01000000, + OpenReparsePoint = 0x00200000, + OpenNoRecall = 0x00100000, + FirstPipeInstance = 0x00080000, +} diff --git a/src/common/ManagedCsWin32/ManagedCsWin32.csproj b/src/common/ManagedCsWin32/ManagedCsWin32.csproj new file mode 100644 index 0000000000..65b06d5f9c --- /dev/null +++ b/src/common/ManagedCsWin32/ManagedCsWin32.csproj @@ -0,0 +1,9 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <Description>PowerToys ManagedCsWin32</Description> + <AssemblyName>PowerToys.ManagedCsWin32</AssemblyName> + </PropertyGroup> +</Project> diff --git a/src/common/ManagedCsWin32/Ole32.cs b/src/common/ManagedCsWin32/Ole32.cs new file mode 100644 index 0000000000..cf56c80373 --- /dev/null +++ b/src/common/ManagedCsWin32/Ole32.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace ManagedCsWin32; + +public static partial class Ole32 +{ + [LibraryImport("ole32.dll")] + public static partial int CoCreateInstance( + ref Guid rclsid, + IntPtr pUnkOuter, + CLSCTX dwClsContext, + ref Guid riid, + out IntPtr rReturnedComObject); + + [LibraryImport("ole32.dll")] + internal static partial int CoInitializeEx(nint pvReserved, uint dwCoInit); + + [LibraryImport("ole32.dll")] + internal static partial void CoUninitialize(); +} + +[Flags] +public enum CLSCTX : uint +{ + InProcServer = 0x1, + InProcHandler = 0x2, + LocalServer = 0x4, + InProcServer16 = 0x8, + RemoteServer = 0x10, + InProcHandler16 = 0x20, + Reserved1 = 0x40, + Reserved2 = 0x80, + Reserved3 = 0x100, + Reserved4 = 0x200, + NoCodeDownload = 0x400, + Reserved5 = 0x800, + NoCustomMarshal = 0x1000, + EnableCodeDownload = 0x2000, + NoFailureLog = 0x4000, + DisableAAA = 0x8000, + EnableAAA = 0x10000, + FromDefaultContext = 0x20000, + ActivateX86Server = 0x40000, +#pragma warning disable CA1069 // Keep the original defines for compatibility + Activate32BitServer = 0x40000, // Same as ActivateX86Server +#pragma warning restore CA1069 // Keep the original defines for compatibility + Activate64BitServer = 0x80000, + EnableCloaking = 0x100000, + AppContainer = 0x400000, + ActivateAAAAsIU = 0x800000, + Reserved6 = 0x1000000, + ActivateARM32Server = 0x2000000, + AllowLowerTrustRegistration = 0x4000000, + PSDll = 0x80000000, + + INPROC = InProcServer | InProcHandler, + SERVER = InProcServer | LocalServer | RemoteServer, + ALL = InProcHandler | SERVER, +} diff --git a/src/common/ManagedCsWin32/Shell32.cs b/src/common/ManagedCsWin32/Shell32.cs new file mode 100644 index 0000000000..66dc7e38e0 --- /dev/null +++ b/src/common/ManagedCsWin32/Shell32.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; +using System.Runtime.InteropServices; + +namespace ManagedCsWin32; + +public static partial class Shell32 +{ + [LibraryImport("SHELL32.dll", EntryPoint = "ShellExecuteExW", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool ShellExecuteEx(ref SHELLEXECUTEINFOW lpExecInfo); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct SHELLEXECUTEINFOW + { + public uint CbSize; + public uint FMask; + public IntPtr Hwnd; + + public IntPtr LpVerb; + public IntPtr LpFile; + public IntPtr LpParameters; + public IntPtr LpDirectory; + public int Show; + public IntPtr HInstApp; + public IntPtr LpIDList; + public IntPtr LpClass; + public IntPtr HkeyClass; + public uint DwHotKey; + public IntPtr HIconOrMonitor; + public IntPtr Process; + } +} diff --git a/src/common/ManagedTelemetry/Telemetry/Events/DebugEvent.cs b/src/common/ManagedTelemetry/Telemetry/Events/DebugEvent.cs index aac9471f3d..01d3f0e192 100644 --- a/src/common/ManagedTelemetry/Telemetry/Events/DebugEvent.cs +++ b/src/common/ManagedTelemetry/Telemetry/Events/DebugEvent.cs @@ -2,11 +2,13 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; namespace Microsoft.PowerToys.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class DebugEvent : EventBase, IEvent { public string Message { get; set; } diff --git a/src/common/ManagedTelemetry/Telemetry/ManagedTelemetry.csproj b/src/common/ManagedTelemetry/Telemetry/ManagedTelemetry.csproj index 3929c60618..c73bd6ca6f 100644 --- a/src/common/ManagedTelemetry/Telemetry/ManagedTelemetry.csproj +++ b/src/common/ManagedTelemetry/Telemetry/ManagedTelemetry.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <Description>PowerToys Telemetry</Description> diff --git a/src/common/ManagedTelemetry/Telemetry/PowerToysTelemetry.cs b/src/common/ManagedTelemetry/Telemetry/PowerToysTelemetry.cs index c1b77e67e4..1e849a2489 100644 --- a/src/common/ManagedTelemetry/Telemetry/PowerToysTelemetry.cs +++ b/src/common/ManagedTelemetry/Telemetry/PowerToysTelemetry.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Telemetry @@ -34,7 +34,8 @@ namespace Microsoft.PowerToys.Telemetry /// <summary> /// Publishes ETW event when an action is triggered on /// </summary> - public void WriteEvent<T>(T telemetryEvent) + [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = "We will ensure the public properties won't be trimmed by ourself.")] + public void WriteEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T telemetryEvent) where T : EventBase, IEvent { if (DataDiagnosticsSettings.GetEnabledValue()) diff --git a/src/common/PowerToys.ModuleContracts/IModuleService.cs b/src/common/PowerToys.ModuleContracts/IModuleService.cs new file mode 100644 index 0000000000..845d40e656 --- /dev/null +++ b/src/common/PowerToys.ModuleContracts/IModuleService.cs @@ -0,0 +1,47 @@ +// 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 Common.UI; + +namespace PowerToys.ModuleContracts; + +/// <summary> +/// Base contract for PowerToys modules exposed to the Command Palette. +/// </summary> +public interface IModuleService +{ + /// <summary> + /// Gets module identifier (e.g., Workspaces, Awake). + /// </summary> + string Key { get; } + + Task<OperationResult> LaunchAsync(CancellationToken cancellationToken = default); + + Task<OperationResult> OpenSettingsAsync(CancellationToken cancellationToken = default); +} + +/// <summary> +/// Helper base to reduce duplication for simple modules. +/// </summary> +public abstract class ModuleServiceBase : IModuleService +{ + public abstract string Key { get; } + + protected abstract SettingsDeepLink.SettingsWindow SettingsWindow { get; } + + public abstract Task<OperationResult> LaunchAsync(CancellationToken cancellationToken = default); + + public virtual Task<OperationResult> OpenSettingsAsync(CancellationToken cancellationToken = default) + { + try + { + SettingsDeepLink.OpenSettings(SettingsWindow); + return Task.FromResult(OperationResult.Ok()); + } + catch (Exception ex) + { + return Task.FromResult(OperationResult.Fail($"Failed to open settings for {Key}: {ex.Message}")); + } + } +} diff --git a/src/common/PowerToys.ModuleContracts/OperationResult.cs b/src/common/PowerToys.ModuleContracts/OperationResult.cs new file mode 100644 index 0000000000..a20aa26a3f --- /dev/null +++ b/src/common/PowerToys.ModuleContracts/OperationResult.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerToys.ModuleContracts; + +/// <summary> +/// Lightweight result type for module operations. +/// </summary> +public readonly record struct OperationResult(bool Success, string? Error = null) +{ + public static OperationResult Ok() => new(true, null); + + public static OperationResult Fail(string error) => new(false, error); +} + +/// <summary> +/// Result type with a payload. +/// </summary> +public readonly record struct OperationResult<T>(bool Success, T? Value, string? Error = null); + +/// <summary> +/// Factory helpers for creating operation results. +/// </summary> +public static class OperationResults +{ + public static OperationResult<T> Ok<T>(T value) => new(true, value, null); + + public static OperationResult<T> Fail<T>(string error) => new(false, default, error); +} diff --git a/src/common/PowerToys.ModuleContracts/PowerToys.ModuleContracts.csproj b/src/common/PowerToys.ModuleContracts/PowerToys.ModuleContracts.csproj new file mode 100644 index 0000000000..eec8c621b2 --- /dev/null +++ b/src/common/PowerToys.ModuleContracts/PowerToys.ModuleContracts.csproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\Common.UI\Common.UI.csproj" /> + </ItemGroup> +</Project> diff --git a/src/common/SettingsAPI/SettingsAPI.vcxproj b/src/common/SettingsAPI/SettingsAPI.vcxproj index d09e33a334..34300796de 100644 --- a/src/common/SettingsAPI/SettingsAPI.vcxproj +++ b/src/common/SettingsAPI/SettingsAPI.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> @@ -9,10 +10,9 @@ <RootNamespace>SettingsAPI</RootNamespace> <ProjectName>SettingsAPI</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -22,7 +22,7 @@ <PropertyGroup Label="UserMacros" /> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> </ItemDefinitionGroup> @@ -52,17 +52,17 @@ </ProjectReference> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/SettingsAPI/packages.config b/src/common/SettingsAPI/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/common/SettingsAPI/packages.config +++ b/src/common/SettingsAPI/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/SettingsAPI/settings_objects.h b/src/common/SettingsAPI/settings_objects.h index 84b064d5af..8927ba5657 100644 --- a/src/common/SettingsAPI/settings_objects.h +++ b/src/common/SettingsAPI/settings_objects.h @@ -119,6 +119,16 @@ namespace PowerToysSettings class HotkeyObject { public: + HotkeyObject() : + m_json(json::JsonObject()) + { + m_json.SetNamedValue(L"win", json::value(false)); + m_json.SetNamedValue(L"ctrl", json::value(false)); + m_json.SetNamedValue(L"alt", json::value(false)); + m_json.SetNamedValue(L"shift", json::value(false)); + m_json.SetNamedValue(L"code", json::value(0)); + m_json.SetNamedValue(L"key", json::value(L"")); + } static HotkeyObject from_json(json::JsonObject json) { return HotkeyObject(std::move(json)); diff --git a/src/common/Telemetry/EtwTrace/EtwTrace.vcxproj b/src/common/Telemetry/EtwTrace/EtwTrace.vcxproj index 17b3be7a26..4c2a4e48e6 100644 --- a/src/common/Telemetry/EtwTrace/EtwTrace.vcxproj +++ b/src/common/Telemetry/EtwTrace/EtwTrace.vcxproj @@ -1,17 +1,17 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>17.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{8f021b46-362b-485c-bfba-ccf83e820cbd}</ProjectGuid> <RootNamespace>EtwTrace</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -37,15 +37,15 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/Telemetry/EtwTrace/packages.config b/src/common/Telemetry/EtwTrace/packages.config index ff4b059648..d3882436a5 100644 --- a/src/common/Telemetry/EtwTrace/packages.config +++ b/src/common/Telemetry/EtwTrace/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/Themes/Themes.vcxproj b/src/common/Themes/Themes.vcxproj index f9772c874f..bace40814b 100644 --- a/src/common/Themes/Themes.vcxproj +++ b/src/common/Themes/Themes.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{98537082-0FDB-40DE-ABD8-0DC5A4269BAB}</ProjectGuid> @@ -8,10 +9,9 @@ <RootNamespace>Themes</RootNamespace> <ProjectName>Themes</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -24,7 +24,7 @@ <PropertyGroup Label="UserMacros" /> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> <PrecompiledHeader>NotUsing</PrecompiledHeader> </ClCompile> @@ -46,13 +46,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/Themes/packages.config b/src/common/Themes/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/common/Themes/packages.config +++ b/src/common/Themes/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/Themes/theme_listener.cpp b/src/common/Themes/theme_listener.cpp index 3a31b0db3c..972953a53c 100644 --- a/src/common/Themes/theme_listener.cpp +++ b/src/common/Themes/theme_listener.cpp @@ -16,13 +16,50 @@ DWORD WINAPI _checkTheme(LPVOID lpParam) void ThemeListener::AddChangedHandler(THEME_HANDLE handle) { + std::lock_guard<std::mutex> lock(handlesMutex); handles.push_back(handle); } void ThemeListener::DelChangedHandler(THEME_HANDLE handle) { + std::lock_guard<std::mutex> lock(handlesMutex); auto it = std::find(handles.begin(), handles.end(), handle); - handles.erase(it); + if (it != handles.end()) + { + handles.erase(it); + } +} + +void ThemeListener::AddAppThemeChangedHandler(THEME_HANDLE handle) +{ + std::lock_guard<std::mutex> lock(handlesMutex); + appThemeHandles.push_back(handle); +} + +void ThemeListener::DelAppThemeChangedHandler(THEME_HANDLE handle) +{ + std::lock_guard<std::mutex> lock(handlesMutex); + auto it = std::find(appThemeHandles.begin(), appThemeHandles.end(), handle); + if (it != appThemeHandles.end()) + { + appThemeHandles.erase(it); + } +} + +void ThemeListener::AddSystemThemeChangedHandler(THEME_HANDLE handle) +{ + std::lock_guard<std::mutex> lock(handlesMutex); + systemThemeHandles.push_back(handle); +} + +void ThemeListener::DelSystemThemeChangedHandler(THEME_HANDLE handle) +{ + std::lock_guard<std::mutex> lock(handlesMutex); + auto it = std::find(systemThemeHandles.begin(), systemThemeHandles.end(), handle); + if (it != systemThemeHandles.end()) + { + systemThemeHandles.erase(it); + } } void ThemeListener::CheckTheme() @@ -48,13 +85,51 @@ void ThemeListener::CheckTheme() WaitForSingleObject(hEvent, INFINITE); - auto _theme = ThemeHelpers::GetAppTheme(); - if (AppTheme != _theme) + auto _appTheme = ThemeHelpers::GetAppTheme(); + auto _systemTheme = ThemeHelpers::GetSystemTheme(); + + bool appThemeChanged = (AppTheme != _appTheme); + bool systemThemeChanged = (SystemTheme != _systemTheme); + + if (appThemeChanged || systemThemeChanged) { - AppTheme = _theme; - for (int i = 0; i < handles.size(); i++) + AppTheme = _appTheme; + SystemTheme = _systemTheme; + + // Copy handlers under lock, then invoke outside lock to avoid deadlock + std::vector<THEME_HANDLE> handlesCopy; + std::vector<THEME_HANDLE> appThemeHandlesCopy; + std::vector<THEME_HANDLE> systemThemeHandlesCopy; + { - handles[i](); + std::lock_guard<std::mutex> lock(handlesMutex); + handlesCopy = handles; + if (appThemeChanged) + { + appThemeHandlesCopy = appThemeHandles; + } + if (systemThemeChanged) + { + systemThemeHandlesCopy = systemThemeHandles; + } + } + + // Call generic handlers (backward compatible) + for (const auto& handler : handlesCopy) + { + handler(); + } + + // Call app theme specific handlers + for (const auto& handler : appThemeHandlesCopy) + { + handler(); + } + + // Call system theme specific handlers + for (const auto& handler : systemThemeHandlesCopy) + { + handler(); } } } diff --git a/src/common/Themes/theme_listener.h b/src/common/Themes/theme_listener.h index a6ab4464fc..6937f60dd2 100644 --- a/src/common/Themes/theme_listener.h +++ b/src/common/Themes/theme_listener.h @@ -3,6 +3,7 @@ #include <windows.h> #include <iostream> #include <vector> +#include <mutex> typedef void (*THEME_HANDLE)(); DWORD WINAPI _checkTheme(LPVOID lpParam); @@ -14,6 +15,7 @@ public: ThemeListener() { AppTheme = ThemeHelpers::GetAppTheme(); + SystemTheme = ThemeHelpers::GetSystemTheme(); dwThreadHandle = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)_checkTheme, this, 0, &dwThreadId); } ~ThemeListener() @@ -23,12 +25,20 @@ public: } Theme AppTheme; + Theme SystemTheme; void ThemeListener::AddChangedHandler(THEME_HANDLE handle); void ThemeListener::DelChangedHandler(THEME_HANDLE handle); + void ThemeListener::AddAppThemeChangedHandler(THEME_HANDLE handle); + void ThemeListener::DelAppThemeChangedHandler(THEME_HANDLE handle); + void ThemeListener::AddSystemThemeChangedHandler(THEME_HANDLE handle); + void ThemeListener::DelSystemThemeChangedHandler(THEME_HANDLE handle); void CheckTheme(); private: HANDLE dwThreadHandle; DWORD dwThreadId; std::vector<THEME_HANDLE> handles; + std::vector<THEME_HANDLE> appThemeHandles; + std::vector<THEME_HANDLE> systemThemeHandles; + mutable std::mutex handlesMutex; }; \ No newline at end of file diff --git a/src/common/UITestAutomation/Element/CheckBox.cs b/src/common/UITestAutomation/Element/CheckBox.cs new file mode 100644 index 0000000000..c0161b4f59 --- /dev/null +++ b/src/common/UITestAutomation/Element/CheckBox.cs @@ -0,0 +1,53 @@ +// 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.PowerToys.UITest +{ + public class CheckBox : Element + { + private static readonly string ExpectedControlType = "ControlType.CheckBox"; + + /// <summary> + /// Initializes a new instance of the <see cref="CheckBox"/> class. + /// </summary> + public CheckBox() + { + this.TargetControlType = CheckBox.ExpectedControlType; + } + + /// <summary> + /// Select the item of the ComboBox. + /// </summary> + /// <param name="value">The text to select from the list view.</param> + public void Select(string value) + { + this.Find<NavigationViewItem>(value).Click(); + } + + /// <summary> + /// Gets a value indicating whether the CheckBox is checked. + /// </summary> + public bool IsChecked => this.Selected; + + public CheckBox SetCheck(bool value = true, int msPreAction = 500, int msPostAction = 500) + { + if (this.IsChecked != value) + { + if (msPreAction > 0) + { + Task.Delay(msPreAction).Wait(); + } + + // Toggle the switch + this.Click(); + if (msPostAction > 0) + { + Task.Delay(msPostAction).Wait(); + } + } + + return this; + } + } +} diff --git a/src/common/UITestAutomation/Element/ComboBox.cs b/src/common/UITestAutomation/Element/ComboBox.cs new file mode 100644 index 0000000000..1cac4d3ba5 --- /dev/null +++ b/src/common/UITestAutomation/Element/ComboBox.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.UITest +{ + public class ComboBox : Element + { + private static readonly string ExpectedControlType = "ControlType.ComboBox"; + + /// <summary> + /// Initializes a new instance of the <see cref="ComboBox"/> class. + /// </summary> + public ComboBox() + { + this.TargetControlType = ComboBox.ExpectedControlType; + } + + /// <summary> + /// Select the item of the ComboBox. + /// </summary> + /// <param name="value">The text to select from the list view.</param> + public void Select(string value) + { + this.Find<NavigationViewItem>(value).Click(); + } + + /// <summary> + /// Select a text item from the ComboBox. + /// </summary> + /// <param name="value">The text to select from the ComboBox.</param> + public void SelectTxt(string value) + { + this.Click(); // First click to expand the ComboBox + Thread.Sleep(100); // Wait for the dropdown to appear + this.Find<Element>(value).Click(); // Find and click the text item using basic Element type + } + } +} diff --git a/src/common/UITestAutomation/Element/Custom.cs b/src/common/UITestAutomation/Element/Custom.cs new file mode 100644 index 0000000000..a2f0467715 --- /dev/null +++ b/src/common/UITestAutomation/Element/Custom.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.UITest +{ + public class Custom : Element + { + private static readonly string ExpectedControlType = "ControlType.Custom"; + + /// <summary> + /// Initializes a new instance of the <see cref="Custom"/> class. + /// </summary> + public Custom() + { + this.TargetControlType = Custom.ExpectedControlType; + } + + /// <summary> + /// Sends a combination of keys. + /// </summary> + /// <param name="keys">The keys to send.</param> + public void SendKeys(params Key[] keys) + { + PerformAction((actions, windowElement) => + { + KeyboardHelper.SendKeys(keys); + }); + } + + /// <summary> + /// Drag element move offset. + /// </summary> + /// <param name="offsetX">The offsetX to move.</param> + /// <param name="offsetY">The offsetY to move.</param> + public void Drag(int offsetX, int offsetY) + { + PerformAction((actions, windowElement) => + { + actions.MoveToElement(windowElement).MoveByOffset(10, 10).ClickAndHold(windowElement).MoveByOffset(offsetX, offsetY).Release(); + actions.Build().Perform(); + }); + } + + /// <summary> + /// Drag element move to other element. + /// </summary> + /// <param name="element">Move to this element.</param> + public void Drag(Element element) + { + PerformAction((actions, windowElement) => + { + actions.MoveToElement(windowElement).ClickAndHold(); + Assert.IsNotNull(element.WindowsElement, "element is null"); + int dx = (element.WindowsElement.Rect.X - windowElement.Rect.X) / 10; + int dy = (element.WindowsElement.Rect.Y - windowElement.Rect.Y) / 10; + for (int i = 0; i < 10; i++) + { + actions.MoveByOffset(dx, dy); + } + + actions.Release(); + actions.Build().Perform(); + }); + } + } +} diff --git a/src/common/UITestAutomation/Element/Element.cs b/src/common/UITestAutomation/Element/Element.cs index 7ca0cf53a5..515506d4c8 100644 --- a/src/common/UITestAutomation/Element/Element.cs +++ b/src/common/UITestAutomation/Element/Element.cs @@ -7,7 +7,7 @@ using System.Drawing; using System.Runtime.CompilerServices; using System.Xml.Linq; using ABI.Windows.Foundation; -using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities; +using Microsoft.PowerToys.UITest; using Microsoft.VisualStudio.TestTools.UnitTesting; using OpenQA.Selenium; using OpenQA.Selenium.Appium; @@ -25,8 +25,20 @@ namespace Microsoft.PowerToys.UITest { private WindowsElement? windowsElement; + protected internal WindowsElement? WindowsElement + { + get => windowsElement; + set => windowsElement = value; + } + private WindowsDriver<WindowsElement>? driver; + protected internal WindowsDriver<WindowsElement>? Driver + { + get => driver; + set => driver = value; + } + protected string? TargetControlType { get; set; } internal bool IsMatchingTarget() @@ -68,6 +80,14 @@ namespace Microsoft.PowerToys.UITest get { return this.windowsElement?.Selected ?? false; } } + /// <summary> + /// Gets a value indicating whether the UI element is visible to the user. + /// </summary> + public bool Displayed + { + get { return this.windowsElement?.Displayed ?? false; } + } + /// <summary> /// Gets the Rect of the UI element. /// </summary> @@ -112,9 +132,12 @@ namespace Microsoft.PowerToys.UITest /// Click the UI element. /// </summary> /// <param name="rightClick">If true, performs a right-click; otherwise, performs a left-click. Default value is false</param> - public virtual void Click(bool rightClick = false) + /// <param name="msPreAction">Delay in milliseconds before performing the click action. Default is 500 ms.</param> + /// <param name="msPostAction">Delay in milliseconds after performing the click action. Default is 500 ms.</param> + public virtual void Click(bool rightClick = false, int msPreAction = 500, int msPostAction = 500) { - PerformAction((actions, windowElement) => + PerformAction( + (actions, windowElement) => { actions.MoveToElement(windowElement); @@ -131,7 +154,9 @@ namespace Microsoft.PowerToys.UITest } actions.Build().Perform(); - }); + }, + msPreAction, + msPostAction); } /// <summary> @@ -152,51 +177,20 @@ namespace Microsoft.PowerToys.UITest } /// <summary> - /// Drag element move offset. + /// Release action /// </summary> - /// <param name="offsetX">The offsetX to move.</param> - /// <param name="offsetY">The offsetY to move.</param> - public void Drag(int offsetX, int offsetY) + public void ReleaseAction() { - PerformAction((actions, windowElement) => - { - actions.MoveToElement(windowElement).MoveByOffset(10, 10).ClickAndHold(windowElement).MoveByOffset(offsetX, offsetY).Release(); - actions.Build().Perform(); - }); + var releaseAction = new Actions(driver); + releaseAction.Release().Perform(); } /// <summary> - /// Drag element move to other element. + /// Release key /// </summary> - /// <param name="element">Move to this element.</param> - public void Drag(Element element) + public void ReleaseKey(Key key) { - PerformAction((actions, windowElement) => - { - actions.MoveToElement(windowElement).ClickAndHold(); - Assert.IsNotNull(element.windowsElement, "element is null"); - int dx = (element.windowsElement.Rect.X - windowElement.Rect.X) / 10; - int dy = (element.windowsElement.Rect.Y - windowElement.Rect.Y) / 10; - for (int i = 0; i < 10; i++) - { - actions.MoveByOffset(dx, dy); - } - - actions.Release(); - actions.Build().Perform(); - }); - } - - /// <summary> - /// Send Key of the element. - /// </summary> - /// <param name="key">The Key to Send.</param> - public void SendKeys(string key) - { - PerformAction((actions, windowElement) => - { - windowElement.SendKeys(key); - }); + KeyboardHelper.ReleaseKey(key); } /// <summary> @@ -218,7 +212,7 @@ namespace Microsoft.PowerToys.UITest /// <param name="by">The selector to use for finding the element.</param> /// <param name="timeoutMS">The timeout in milliseconds.</param> /// <returns>The found element.</returns> - public T Find<T>(By by, int timeoutMS = 3000) + public T Find<T>(By by, int timeoutMS = 5000) where T : Element, new() { Assert.IsNotNull(this.windowsElement, $"WindowsElement is null in method Find<{typeof(T).Name}> with parameters: by = {by}, timeoutMS = {timeoutMS}"); @@ -226,7 +220,7 @@ namespace Microsoft.PowerToys.UITest // leverage findAll to filter out mismatched elements var collection = this.FindAll<T>(by, timeoutMS); - Assert.IsTrue(collection.Count > 0, $"Element not found using selector: {by}"); + Assert.IsTrue(collection.Count > 0, $"UI-Element({typeof(T).Name}) not found using selector: {by}"); return collection[0]; } @@ -239,7 +233,7 @@ namespace Microsoft.PowerToys.UITest /// <param name="name">The name for finding the element.</param> /// <param name="timeoutMS">The timeout in milliseconds.</param> /// <returns>The found element.</returns> - public T Find<T>(string name, int timeoutMS = 3000) + public T Find<T>(string name, int timeoutMS = 5000) where T : Element, new() { return this.Find<T>(By.Name(name), timeoutMS); @@ -252,7 +246,7 @@ namespace Microsoft.PowerToys.UITest /// <param name="by">The selector to use for finding the element.</param> /// <param name="timeoutMS">The timeout in milliseconds.</param> /// <returns>The found element.</returns> - public Element Find(By by, int timeoutMS = 3000) + public Element Find(By by, int timeoutMS = 5000) { return this.Find<Element>(by, timeoutMS); } @@ -264,7 +258,7 @@ namespace Microsoft.PowerToys.UITest /// <param name="name">The name for finding the element.</param> /// <param name="timeoutMS">The timeout in milliseconds.</param> /// <returns>The found element.</returns> - public Element Find(string name, int timeoutMS = 3000) + public Element Find(string name, int timeoutMS = 5000) { return this.Find<Element>(By.Name(name), timeoutMS); } @@ -276,7 +270,7 @@ namespace Microsoft.PowerToys.UITest /// <param name="by">The selector to use for finding the elements.</param> /// <param name="timeoutMS">The timeout in milliseconds.</param> /// <returns>A read-only collection of the found elements.</returns> - public ReadOnlyCollection<T> FindAll<T>(By by, int timeoutMS = 3000) + public ReadOnlyCollection<T> FindAll<T>(By by, int timeoutMS = 5000) where T : Element, new() { Assert.IsNotNull(this.windowsElement, $"WindowsElement is null in method FindAll<{typeof(T).Name}> with parameters: by = {by}, timeoutMS = {timeoutMS}"); @@ -308,7 +302,7 @@ namespace Microsoft.PowerToys.UITest /// <param name="name">The name for finding the element.</param> /// <param name="timeoutMS">The timeout in milliseconds.</param> /// <returns>A read-only collection of the found elements.</returns> - public ReadOnlyCollection<T> FindAll<T>(string name, int timeoutMS = 3000) + public ReadOnlyCollection<T> FindAll<T>(string name, int timeoutMS = 5000) where T : Element, new() { return this.FindAll<T>(By.Name(name), timeoutMS); @@ -321,7 +315,7 @@ namespace Microsoft.PowerToys.UITest /// <param name="by">The selector to use for finding the elements.</param> /// <param name="timeoutMS">The timeout in milliseconds.</param> /// <returns>A read-only collection of the found elements.</returns> - public ReadOnlyCollection<Element> FindAll(By by, int timeoutMS = 3000) + public ReadOnlyCollection<Element> FindAll(By by, int timeoutMS = 5000) { return this.FindAll<Element>(by, timeoutMS); } @@ -333,11 +327,23 @@ namespace Microsoft.PowerToys.UITest /// <param name="name">The name for finding the element.</param> /// <param name="timeoutMS">The timeout in milliseconds.</param> /// <returns>A read-only collection of the found elements.</returns> - public ReadOnlyCollection<Element> FindAll(string name, int timeoutMS = 3000) + public ReadOnlyCollection<Element> FindAll(string name, int timeoutMS = 5000) { return this.FindAll<Element>(By.Name(name), timeoutMS); } + /// <summary> + /// Send Key of the element. + /// </summary> + /// <param name="key">The Key to Send.</param> + public void SendKeys(string key) + { + PerformAction((actions, windowElement) => + { + windowElement.SendKeys(key); + }); + } + /// <summary> /// Simulates a manual operation on the element. /// </summary> @@ -360,5 +366,29 @@ namespace Microsoft.PowerToys.UITest Task.Delay(msPostAction).Wait(); } } + + /// <summary> + /// Save UI Element to a PNG file. + /// </summary> + /// <param name="path">the full path</param> + public void SaveToPngFile(string path) + { + Assert.IsNotNull(this.windowsElement, $"WindowsElement is null in method SaveToPngFile with parameter: path = {path}"); + this.windowsElement.GetScreenshot().SaveAsFile(path); + } + + public void EnsureVisible(Element scrollViewer, int maxScrolls = 10) + { + int count = 0; + if (scrollViewer.WindowsElement != null) + { + while (!this.windowsElement!.Displayed && count < maxScrolls) + { + scrollViewer.WindowsElement.SendKeys(OpenQA.Selenium.Keys.PageDown); + Task.Delay(250).Wait(); + count++; + } + } + } } } diff --git a/src/common/UITestAutomation/Element/Group.cs b/src/common/UITestAutomation/Element/Group.cs new file mode 100644 index 0000000000..55619a281d --- /dev/null +++ b/src/common/UITestAutomation/Element/Group.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + public class Group : Element + { + private static readonly string ExpectedControlType = "ControlType.Group"; + + /// <summary> + /// Initializes a new instance of the <see cref="Group"/> class. + /// </summary> + public Group() + { + this.TargetControlType = Group.ExpectedControlType; + } + } +} diff --git a/src/common/UITestAutomation/Element/NavigationViewItem.cs b/src/common/UITestAutomation/Element/NavigationViewItem.cs index 0a71d9a321..6ae5d4d74d 100644 --- a/src/common/UITestAutomation/Element/NavigationViewItem.cs +++ b/src/common/UITestAutomation/Element/NavigationViewItem.cs @@ -24,9 +24,12 @@ namespace Microsoft.PowerToys.UITest /// Click the ListItem element. /// </summary> /// <param name="rightClick">If true, performs a right-click; otherwise, performs a left-click. Default value is false</param> - public override void Click(bool rightClick = false) + /// <param name="msPreAction">Pre action delay in milliseconds. Default value is 500</param> + /// <param name="msPostAction">Post action delay in milliseconds. Default value is 500</param> + public override void Click(bool rightClick = false, int msPreAction = 500, int msPostAction = 500) { - PerformAction((actions, windowElement) => + PerformAction( + (actions, windowElement) => { actions.MoveToElement(windowElement, 10, 10); @@ -40,7 +43,36 @@ namespace Microsoft.PowerToys.UITest } actions.Build().Perform(); - }); + }, + msPreAction, + msPostAction); + } + + /// <summary> + /// Click the center of the ListItem element. + /// </summary> + /// <param name="rightClick">If true, performs a right-click; otherwise, performs a left-click. Default value is false</param> + /// <param name="msPreAction">Pre action delay in milliseconds. Default value is 500</param> + /// <param name="msPostAction">Post action delay in milliseconds. Default value is 500</param> + public void ClickCenter(bool rightClick = false, int msPreAction = 500, int msPostAction = 500) + { + PerformAction( + (actions, windowElement) => + { + actions.MoveToElement(windowElement); + if (rightClick) + { + actions.ContextClick(); + } + else + { + actions.Click(); + } + + actions.Build().Perform(); + }, + msPreAction, + msPostAction); } /// <summary> diff --git a/src/common/UITestAutomation/Element/Pane.cs b/src/common/UITestAutomation/Element/Pane.cs new file mode 100644 index 0000000000..86d1df6c0d --- /dev/null +++ b/src/common/UITestAutomation/Element/Pane.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium.Appium.Windows; +using OpenQA.Selenium.Interactions; + +namespace Microsoft.PowerToys.UITest +{ + public class Pane : Element + { + private static readonly string ExpectedControlType = "ControlType.Pane"; + + /// <summary> + /// Initializes a new instance of the <see cref="Pane"/> class. + /// </summary> + public Pane() + { + this.TargetControlType = Pane.ExpectedControlType; + } + + /// <summary> + /// Drag element move offset. + /// </summary> + /// <param name="offsetX">The offsetX to move.</param> + /// <param name="offsetY">The offsetY to move.</param> + public void Drag(int offsetX, int offsetY) + { + PerformAction((actions, windowElement) => + { + actions.MoveToElement(windowElement).MoveByOffset(10, 10).ClickAndHold(windowElement).MoveByOffset(offsetX, offsetY).Release(); + actions.Build().Perform(); + }); + } + + /// <summary> + /// Simulates holding when dragging to target position. + /// </summary> + /// <param name="offsetX">The offsetX to move.</param> + /// <param name="offsetY">The offsetY to move.</param> + public void DragAndHold(int offsetX, int offsetY) + { + PerformAction((actions, windowElement) => + { + actions.MoveToElement(windowElement).MoveByOffset(10, 10).ClickAndHold(windowElement).MoveByOffset(offsetX, offsetY); + actions.Build().Perform(); + }); + } + + public void ReleaseDrag() + { + var releaseAction = new Actions(this.Driver); + releaseAction.Release().Perform(); + } + + /// <summary> + /// Simulates holding a key, clicking and dragging a UI element to the specified screen coordinates. + /// </summary> + /// <param name="key">The keyboard key to press and hold during the drag operation.</param> + /// <param name="targetX">The target X-coordinate to drag the element to.</param> + /// <param name="targetY">The target Y-coordinate to drag the element to.</param> + public void KeyDownAndDrag(Key key, int targetX, int targetY) + { + HoldShiftToDrag(key, targetX, targetY); + ReleaseAction(); + ReleaseKey(key); + } + + /// <summary> + /// Simulates holding a key, clicking and dragging a UI element to the specified screen coordinates. + /// </summary> + /// <param name="key">The keyboard key to press and hold during the drag operation.</param> + /// <param name="targetX">The target X-coordinate to drag the element to.</param> + /// <param name="targetY">The target Y-coordinate to drag the element to.</param> + public void HoldShiftToDrag(Key key, int targetX, int targetY) + { + PerformAction((actions, windowElement) => + { + KeyboardHelper.PressKey(key); + + actions.MoveToElement(WindowsElement) + .ClickAndHold() + .Perform(); + + int dx = targetX - windowElement.Rect.X; + int dy = targetY - windowElement.Rect.Y; + + int stepCount = 10; + int stepX = dx / stepCount; + int stepY = dy / stepCount; + + for (int i = 0; i < stepCount; i++) + { + var stepAction = new Actions(Driver); + stepAction.MoveByOffset(stepX, stepY).Perform(); + } + }); + } + } +} diff --git a/src/common/UITestAutomation/Element/RadioButton.cs b/src/common/UITestAutomation/Element/RadioButton.cs new file mode 100644 index 0000000000..c88ccee79c --- /dev/null +++ b/src/common/UITestAutomation/Element/RadioButton.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.UITest +{ + /// <summary> + /// Represents a radio button UI element in the application. + /// </summary> + public class RadioButton : Element + { + private static readonly string ExpectedControlType = "ControlType.RadioButton"; + + /// <summary> + /// Initializes a new instance of the <see cref="RadioButton"/> class. + /// </summary> + public RadioButton() + { + this.TargetControlType = RadioButton.ExpectedControlType; + } + + /// <summary> + /// Gets a value indicating whether the RadioButton is selected. + /// </summary> + public bool IsSelected => this.Selected; + + /// <summary> + /// Select the RadioButton. + /// </summary> + public void Select() + { + if (!this.IsSelected) + { + this.Click(); + } + } + } +} diff --git a/src/common/UITestAutomation/Element/Slider.cs b/src/common/UITestAutomation/Element/Slider.cs new file mode 100644 index 0000000000..027723fb77 --- /dev/null +++ b/src/common/UITestAutomation/Element/Slider.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium.Appium.Windows; + +namespace Microsoft.PowerToys.UITest +{ + public class Slider : Element + { + private static readonly string ExpectedControlType = "ControlType.Slider"; + + /// <summary> + /// Initializes a new instance of the <see cref="Slider"/> class. + /// </summary> + public Slider() + { + this.TargetControlType = Slider.ExpectedControlType; + } + + /// <summary> + /// Gets the value of a Slider (WindowsElement) + /// </summary> + /// <returns>The integer value of the slider</returns> + public int GetValue() + { + return this.Text == string.Empty ? 0 : int.Parse(this.Text); + } + + /// <summary> + /// Sends a combination of keys. + /// </summary> + /// <param name="keys">The keys to send.</param> + public void SendKeys(params Key[] keys) + { + PerformAction((actions, windowElement) => + { + KeyboardHelper.SendKeys(keys); + }); + } + + /// <summary> + /// Sets the value of a Slider (WindowsElement) to the specified integer value. + /// Throws an exception if the value is out of the slider's valid range. + /// </summary> + /// <param name="targetValue">The target integer value to set</param> + public void SetValue(int targetValue) + { + // Read range and current value + int min = int.Parse(this.GetAttribute("RangeValue.Minimum")); + int max = int.Parse(this.GetAttribute("RangeValue.Maximum")); + int current = int.Parse(this.Text); + + // Use Assert to check if the target value is within the valid range + Assert.IsTrue( + targetValue >= min && targetValue <= max, + $"Target value {targetValue} is out of range (min: {min}, max: {max})."); + + // Compute difference + int diff = targetValue - current; + if (diff == 0) + { + return; + } + + string key = diff > 0 ? OpenQA.Selenium.Keys.Right : OpenQA.Selenium.Keys.Left; + int steps = Math.Abs(diff); + + for (int i = 0; i < steps; i++) + { + this.SendKeys(key); + + // Thread.Sleep(2); + } + + // Final check + int finalValue = int.Parse(this.Text); + Assert.AreEqual( + targetValue, finalValue, $"Slider value mismatch: expected {targetValue}, but got {finalValue}."); + } + + /// <summary> + /// Sets the value of a Slider (WindowsElement) to the specified integer value. + /// Throws an exception if the value is out of the slider's valid range. + /// </summary> + /// <param name="targetValue">The target integer value to set</param> + public void QuickSetValue(int targetValue) + { + // Read range and current value + int min = int.Parse(this.GetAttribute("RangeValue.Minimum")); + int max = int.Parse(this.GetAttribute("RangeValue.Maximum")); + int current = int.Parse(this.Text); + + // Use Assert to check if the target value is within the valid range + Assert.IsTrue( + targetValue >= min && targetValue <= max, + $"Target value {targetValue} is out of range (min: {min}, max: {max})."); + + // Compute difference + int diff = targetValue - current; + if (diff == 0) + { + return; + } + + string key = diff > 0 ? OpenQA.Selenium.Keys.Right : OpenQA.Selenium.Keys.Left; + int steps = Math.Abs(diff); + + int maxKeysPerSend = 50; + int fullChunks = steps / maxKeysPerSend; + int remainder = steps % maxKeysPerSend; + for (int i = 0; i < fullChunks; i++) + { + SendKeys(new string(key[0], maxKeysPerSend)); + Thread.Sleep(2); + } + + if (remainder > 0) + { + SendKeys(new string(key[0], remainder)); + Thread.Sleep(2); + } + + // Final check + int finalValue = int.Parse(this.Text); + Assert.AreEqual( + targetValue, finalValue, $"Slider value mismatch: expected {targetValue}, but got {finalValue}."); + } + } +} diff --git a/src/common/UITestAutomation/Element/Tab.cs b/src/common/UITestAutomation/Element/Tab.cs new file mode 100644 index 0000000000..ccf7e80a43 --- /dev/null +++ b/src/common/UITestAutomation/Element/Tab.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium.Appium.Windows; +using OpenQA.Selenium.Interactions; + +namespace Microsoft.PowerToys.UITest +{ + public class Tab : Element + { + private static readonly string ExpectedControlType = "ControlType.Tab"; + + /// <summary> + /// Initializes a new instance of the <see cref="Tab"/> class. + /// </summary> + public Tab() + { + this.TargetControlType = Tab.ExpectedControlType; + } + + /// <summary> + /// Simulates holding a key, clicking and dragging a UI element to the specified screen coordinates. + /// </summary> + /// <param name="key">The keyboard key to press and hold during the drag operation.</param> + /// <param name="targetX">The target X-coordinate to drag the element to.</param> + /// <param name="targetY">The target Y-coordinate to drag the element to.</param> + public void KeyDownAndDrag(Key key, int targetX, int targetY) + { + HoldShiftToDrag(key, targetX, targetY); + ReleaseAction(); + ReleaseKey(key); + } + + /// <summary> + /// Simulates holding a key, clicking and dragging a UI element to the specified screen coordinates. + /// </summary> + /// <param name="key">The keyboard key to press and hold during the drag operation.</param> + /// <param name="targetX">The target X-coordinate to drag the element to.</param> + /// <param name="targetY">The target Y-coordinate to drag the element to.</param> + public void HoldShiftToDrag(Key key, int targetX, int targetY) + { + PerformAction((actions, windowElement) => + { + KeyboardHelper.PressKey(key); + + actions.MoveToElement(WindowsElement) + .ClickAndHold() + .Perform(); + + int dx = targetX - windowElement.Rect.X; + int dy = targetY - windowElement.Rect.Y; + + int stepCount = 10; + int stepX = dx / stepCount; + int stepY = dy / stepCount; + + for (int i = 0; i < stepCount; i++) + { + var stepAction = new Actions(Driver); + stepAction.MoveByOffset(stepX, stepY).Perform(); + } + }); + } + } +} diff --git a/src/common/UITestAutomation/Element/TextBox.cs b/src/common/UITestAutomation/Element/TextBox.cs index 71f833625d..c2fc49e791 100644 --- a/src/common/UITestAutomation/Element/TextBox.cs +++ b/src/common/UITestAutomation/Element/TextBox.cs @@ -2,7 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using OpenQA.Selenium; +using System.Threading.Tasks; namespace Microsoft.PowerToys.UITest { @@ -27,23 +27,51 @@ namespace Microsoft.PowerToys.UITest /// </summary> /// <param name="value">The text to set.</param> /// <param name="clearText">A value indicating whether to clear the text before setting it. Default value is true</param> + /// <param name="charDelayMS">Delay in milliseconds between each character. Default is 0 (no delay).</param> /// <returns>The current TextBox instance.</returns> - public TextBox SetText(string value, bool clearText = true) + public TextBox SetText(string value, bool clearText = true, int charDelayMS = 0) { if (clearText) { PerformAction((actions, windowElement) => { // select all text and delete it - windowElement.SendKeys(Keys.Control + "a"); - windowElement.SendKeys(Keys.Delete); + windowElement.SendKeys(OpenQA.Selenium.Keys.Control + "a"); + windowElement.SendKeys(OpenQA.Selenium.Keys.Delete); }); + Task.Delay(500).Wait(); } - PerformAction((actions, windowElement) => + // TODO: CmdPal bug – when inputting text, characters are swallowed too quickly. + // This should be fixed within CmdPal itself. + // Temporary workaround: introduce a delay between character inputs to avoid the issue + if (charDelayMS > 0 || EnvironmentConfig.IsInPipeline) { - windowElement.SendKeys(value); - }); + // Send text character by character with delay (if specified or in pipeline) + PerformAction((actions, windowElement) => + { + foreach (char c in value) + { + windowElement.SendKeys(c.ToString()); + if (charDelayMS > 0) + { + Task.Delay(charDelayMS).Wait(); + } + else if (EnvironmentConfig.IsInPipeline) + { + Task.Delay(50).Wait(); + } + } + }); + } + else + { + // No character delay - send all text at once (original behavior) + PerformAction((actions, windowElement) => + { + windowElement.SendKeys(value); + }); + } return this; } diff --git a/src/common/UITestAutomation/Element/Thumb.cs b/src/common/UITestAutomation/Element/Thumb.cs new file mode 100644 index 0000000000..fada5cb02d --- /dev/null +++ b/src/common/UITestAutomation/Element/Thumb.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 Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium.Appium.Windows; + +namespace Microsoft.PowerToys.UITest +{ + public class Thumb : Element + { + private static readonly string ExpectedControlType = "ControlType.Thumb"; + + /// <summary> + /// Initializes a new instance of the <see cref="Thumb"/> class. + /// </summary> + public Thumb() + { + this.TargetControlType = Thumb.ExpectedControlType; + } + + /// <summary> + /// Drag element move offset. + /// </summary> + /// <param name="offsetX">The offsetX to move.</param> + /// <param name="offsetY">The offsetY to move.</param> + public void Drag(int offsetX, int offsetY) + { + PerformAction((actions, windowElement) => + { + actions.MoveToElement(windowElement).MoveByOffset(10, 10).ClickAndHold(windowElement).MoveByOffset(offsetX, offsetY).Release(); + actions.Build().Perform(); + }); + } + } +} diff --git a/src/common/UITestAutomation/EnvironmentConfig.cs b/src/common/UITestAutomation/EnvironmentConfig.cs new file mode 100644 index 0000000000..ac0f1fa456 --- /dev/null +++ b/src/common/UITestAutomation/EnvironmentConfig.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; + +namespace Microsoft.PowerToys.UITest +{ + /// <summary> + /// Centralized configuration for all environment variables used in UI tests. + /// </summary> + public static class EnvironmentConfig + { + private static readonly Lazy<bool> _isInPipeline = new(() => + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("platform"))); + + private static readonly Lazy<bool> _useInstallerForTest = new(() => + { + string? envValue = Environment.GetEnvironmentVariable("useInstallerForTest") ?? + Environment.GetEnvironmentVariable("USEINSTALLERFORTEST"); + return !string.IsNullOrEmpty(envValue) && bool.TryParse(envValue, out bool result) && result; + }); + + private static readonly Lazy<string?> _platform = new(() => + Environment.GetEnvironmentVariable("platform")); + + /// <summary> + /// Gets a value indicating whether the tests are running in a CI/CD pipeline. + /// Determined by the presence of the "platform" environment variable. + /// </summary> + public static bool IsInPipeline => _isInPipeline.Value; + + /// <summary> + /// Gets a value indicating whether to use installer paths for testing. + /// Checks both "useInstallerForTest" and "USEINSTALLERFORTEST" environment variables. + /// </summary> + public static bool UseInstallerForTest => _useInstallerForTest.Value; + + /// <summary> + /// Gets the platform name from the environment variable. + /// Typically used in CI/CD pipelines to identify the build platform. + /// </summary> + public static string? Platform => _platform.Value; + } +} diff --git a/src/common/UITestAutomation/FindHelper.cs b/src/common/UITestAutomation/FindHelper.cs index 465f1206a1..eb05f4c682 100644 --- a/src/common/UITestAutomation/FindHelper.cs +++ b/src/common/UITestAutomation/FindHelper.cs @@ -20,7 +20,7 @@ namespace Microsoft.PowerToys.UITest public static ReadOnlyCollection<T>? FindAll<T, TW>(Func<IReadOnlyCollection<TW>> findElementsFunc, WindowsDriver<WindowsElement>? driver, int timeoutMS) where T : Element, new() { - var items = findElementsFunc(); + var items = FindElementsWithRetry(findElementsFunc, timeoutMS); var res = items.Select(item => { var element = item as WindowsElement; @@ -30,17 +30,30 @@ namespace Microsoft.PowerToys.UITest return new ReadOnlyCollection<T>(res); } - public static ReadOnlyCollection<T>? FindAll<T, TW>(Func<ReadOnlyCollection<TW>> findElementsFunc, WindowsDriver<WindowsElement>? driver, int timeoutMS) - where T : Element, new() + private static ReadOnlyCollection<TW> FindElementsWithRetry<TW>(Func<IReadOnlyCollection<TW>> findElementsFunc, int timeoutMS) { - var items = findElementsFunc(); - var res = items.Select(item => - { - var element = item as WindowsElement; - return NewElement<T>(element, driver, timeoutMS); - }).Where(item => item.IsMatchingTarget()).ToList(); + var timeout = TimeSpan.FromMilliseconds(timeoutMS); + var retryIntervalMS = TimeSpan.FromMilliseconds(500); + DateTime startTime = DateTime.Now; - return new ReadOnlyCollection<T>(res); + while (DateTime.Now - startTime < timeout) + { + try + { + var items = findElementsFunc(); + if (items.Count > 0) + { + return new ReadOnlyCollection<TW>((IList<TW>)items); + } + + Task.Delay(retryIntervalMS).Wait(); + } + catch (Exception) + { + } + } + + return new ReadOnlyCollection<TW>(new List<TW>()); } public static T NewElement<T>(WindowsElement? element, WindowsDriver<WindowsElement>? driver, int timeoutMS) @@ -50,11 +63,6 @@ namespace Microsoft.PowerToys.UITest Assert.IsNotNull(element, $"New Element {typeof(T).Name} error: element is null."); T newElement = new T(); - if (timeoutMS > 0) - { - // Only set timeout if it is positive value - driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromMilliseconds(timeoutMS); - } newElement.SetSession(driver); newElement.SetWindowsElement(element); diff --git a/src/common/UITestAutomation/KeyboardHelper.cs b/src/common/UITestAutomation/KeyboardHelper.cs new file mode 100644 index 0000000000..d781d20df4 --- /dev/null +++ b/src/common/UITestAutomation/KeyboardHelper.cs @@ -0,0 +1,475 @@ +// 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.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + /// <summary> + /// Represents keyboard keys. + /// </summary> + public enum Key + { + Ctrl, + LCtrl, + RCtrl, + Alt, + Shift, + Tab, + Esc, + Enter, + Win, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + Num0, + Num1, + Num2, + Num3, + Num4, + Num5, + Num6, + Num7, + Num8, + Num9, + F1, + F2, + F3, + F4, + F5, + F6, + F7, + F8, + F9, + F10, + F11, + F12, + Space, + Backspace, + Delete, + Insert, + Home, + End, + PageUp, + PageDown, + Up, + Down, + Left, + Right, + Other, + } + + /// <summary> +    /// Provides methods for simulating keyboard input. +    /// </summary> + internal static class KeyboardHelper + { + [DllImport("user32.dll")] +#pragma warning disable SA1300 // Element should begin with upper-case letter + private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); +#pragma warning restore SA1300 // Element should begin with upper-case letter + +#pragma warning disable SA1310 // Field names should not contain underscore + private const byte VK_LWIN = 0x5B; + private const uint KEYEVENTF_KEYDOWN = 0x0000; + private const uint KEYEVENTF_KEYUP = 0x0002; +#pragma warning restore SA1310 // Field names should not contain underscore + + /// <summary> + /// Sends a combination of keys. + /// </summary> + /// <param name="keys">The keys to send.</param> + public static void SendKeys(params Key[] keys) + { + string keysToSend = string.Join(string.Empty, keys.Select(TranslateKey)); + SendWinKeyCombination(keysToSend); + } + + public static void PressKey(Key key) + { + PressVirtualKey(TranslateKeyHex(key)); + } + + public static void ReleaseKey(Key key) + { + ReleaseVirtualKey(TranslateKeyHex(key)); + } + + public static void SendKey(Key key) + { + PressVirtualKey(TranslateKeyHex(key)); + ReleaseVirtualKey(TranslateKeyHex(key)); + } + + /// <summary> +        /// Translates a key to its corresponding SendKeys representation. +        /// </summary> +        /// <param name="key">The key to translate.</param> +        /// <returns>The SendKeys representation of the key.</returns> + private static string TranslateKey(Key key) + { + switch (key) + { + case Key.Ctrl: + return "^"; + case Key.LCtrl: + return "^"; + case Key.RCtrl: + return "^"; + case Key.Alt: + return "%"; + case Key.Shift: + return "+"; + case Key.Tab: + return "{TAB}"; + case Key.Esc: + return "{ESC}"; + case Key.Enter: + return "{ENTER}"; + case Key.Win: + return "{WIN}"; + case Key.Space: + return " "; + case Key.Backspace: + return "{BACKSPACE}"; + case Key.Delete: + return "{DELETE}"; + case Key.Insert: + return "{INSERT}"; + case Key.Home: + return "{HOME}"; + case Key.End: + return "{END}"; + case Key.PageUp: + return "{PGUP}"; + case Key.PageDown: + return "{PGDN}"; + case Key.Up: + return "{UP}"; + case Key.Down: + return "{DOWN}"; + case Key.Left: + return "{LEFT}"; + case Key.Right: + return "{RIGHT}"; + case Key.F1: + return "{F1}"; + case Key.F2: + return "{F2}"; + case Key.F3: + return "{F3}"; + case Key.F4: + return "{F4}"; + case Key.F5: + return "{F5}"; + case Key.F6: + return "{F6}"; + case Key.F7: + return "{F7}"; + case Key.F8: + return "{F8}"; + case Key.F9: + return "{F9}"; + case Key.F10: + return "{F10}"; + case Key.F11: + return "{F11}"; + case Key.F12: + return "{F12}"; + case Key.A: + return "a"; + case Key.B: + return "b"; + case Key.C: + return "c"; + case Key.D: + return "d"; + case Key.E: + return "e"; + case Key.F: + return "f"; + case Key.G: + return "g"; + case Key.H: + return "h"; + case Key.I: + return "i"; + case Key.J: + return "j"; + case Key.K: + return "k"; + case Key.L: + return "l"; + case Key.M: + return "m"; + case Key.N: + return "n"; + case Key.O: + return "o"; + case Key.P: + return "p"; + case Key.Q: + return "q"; + case Key.R: + return "r"; + case Key.S: + return "s"; + case Key.T: + return "t"; + case Key.U: + return "u"; + case Key.V: + return "v"; + case Key.W: + return "w"; + case Key.X: + return "x"; + case Key.Y: + return "y"; + case Key.Z: + return "z"; + case Key.Num0: + return "0"; + case Key.Num1: + return "1"; + case Key.Num2: + return "2"; + case Key.Num3: + return "3"; + case Key.Num4: + return "4"; + case Key.Num5: + return "5"; + case Key.Num6: + return "6"; + case Key.Num7: + return "7"; + case Key.Num8: + return "8"; + case Key.Num9: + return "9"; + default: + return string.Empty; + } + } + + /// <summary> + /// map the virtual key codes to the corresponding keys. + /// </summary> + private static byte TranslateKeyHex(Key key) + { + switch (key) + { + case Key.Win: + return 0x5B; // Windows Key - 0x5B in hex + case Key.Ctrl: + return 0x11; // Ctrl Key - 0x11 in hex + case Key.Alt: + return 0x12; // Alt Key - 0x12 in hex + case Key.Shift: + return 0x10; // Shift Key - 0x10 in hex + case Key.LCtrl: + return 0xA2; // Left Ctrl Key - 0xA2 in hex + case Key.RCtrl: + return 0xA3; // Right Ctrl Key - 0xA3 in hex + case Key.A: + return 0x41; // A Key - 0x41 in hex + case Key.B: + return 0x42; // B Key - 0x42 in hex + case Key.C: + return 0x43; // C Key - 0x43 in hex + case Key.D: + return 0x44; // D Key - 0x44 in hex + case Key.E: + return 0x45; // E Key - 0x45 in hex + case Key.F: + return 0x46; // F Key - 0x46 in hex + case Key.G: + return 0x47; // G Key - 0x47 in hex + case Key.H: + return 0x48; // H Key - 0x48 in hex + case Key.I: + return 0x49; // I Key - 0x49 in hex + case Key.J: + return 0x4A; // J Key - 0x4A in hex + case Key.K: + return 0x4B; // K Key - 0x4B in hex + case Key.L: + return 0x4C; // L Key - 0x4C in hex + case Key.M: + return 0x4D; // M Key - 0x4D in hex + case Key.N: + return 0x4E; // N Key - 0x4E in hex + case Key.O: + return 0x4F; // O Key - 0x4F in hex + case Key.P: + return 0x50; // P Key - 0x50 in hex + case Key.Q: + return 0x51; // Q Key - 0x51 in hex + case Key.R: + return 0x52; // R Key - 0x52 in hex + case Key.S: + return 0x53; // S Key - 0x53 in hex + case Key.T: + return 0x54; // T Key - 0x54 in hex + case Key.U: + return 0x55; // U Key - 0x55 in hex + case Key.V: + return 0x56; // V Key - 0x56 in hex + case Key.W: + return 0x57; // W Key - 0x57 in hex + case Key.X: + return 0x58; // X Key - 0x58 in hex + case Key.Y: + return 0x59; // Y Key - 0x59 in hex + case Key.Z: + return 0x5A; // Z Key - 0x5A in hex + case Key.Num0: + return 0x30; // 0 Key - 0x30 in hex + case Key.Num1: + return 0x31; // 1 Key - 0x31 in hex + case Key.Num2: + return 0x32; // 2 Key - 0x32 in hex + case Key.Num3: + return 0x33; // 3 Key - 0x33 in hex + case Key.Num4: + return 0x34; // 4 Key - 0x34 in hex + case Key.Num5: + return 0x35; // 5 Key - 0x35 in hex + case Key.Num6: + return 0x36; // 6 Key - 0x36 in hex + case Key.Num7: + return 0x37; // 7 Key - 0x37 in hex + case Key.Num8: + return 0x38; // 8 Key - 0x38 in hex + case Key.Num9: + return 0x39; // 9 Key - 0x39 in hex + case Key.F1: + return 0x70; // F1 Key - 0x70 in hex + case Key.F2: + return 0x71; // F2 Key - 0x71 in hex + case Key.F3: + return 0x72; // F3 Key - 0x72 in hex + case Key.F4: + return 0x73; // F4 Key - 0x73 in hex + case Key.F5: + return 0x74; // F5 Key - 0x74 in hex + case Key.F6: + return 0x75; // F6 Key - 0x75 in hex + case Key.F7: + return 0x76; // F7 Key - 0x76 in hex + case Key.F8: + return 0x77; // F8 Key - 0x77 in hex + case Key.F9: + return 0x78; // F9 Key - 0x78 in hex + case Key.F10: + return 0x79; // F10 Key - 0x79 in hex + case Key.F11: + return 0x7A; // F11 Key - 0x7A in hex + case Key.F12: + return 0x7B; // F12 Key - 0x7B in hex + case Key.Up: + return 0x26; // Up Arrow Key - 0x26 in hex + case Key.Down: + return 0x28; // Down Arrow Key - 0x28 in hex + case Key.Left: + return 0x25; // Left Arrow Key - 0x25 in hex + case Key.Right: + return 0x27; // Right Arrow Key - 0x27 in hex + case Key.Home: + return 0x24; // Home Key - 0x24 in hex + case Key.End: + return 0x23; // End Key - 0x23 in hex + case Key.PageUp: + return 0x21; // Page Up Key - 0x21 in hex + case Key.PageDown: + return 0x22; // Page Down Key - 0x22 in hex + case Key.Space: + return 0x20; // Space Key - 0x20 in hex + case Key.Enter: + return 0x0D; // Enter Key - 0x0D in hex + case Key.Backspace: + return 0x08; // Backspace Key - 0x08 in hex + case Key.Tab: + return 0x09; // Tab Key - 0x09 in hex + case Key.Esc: + return 0x1B; // Escape Key - 0x1B in hex + case Key.Insert: + return 0x2D; // Insert Key - 0x2D in hex + case Key.Delete: + return 0x2E; // Delete Key - 0x2E in hex + default: + throw new ArgumentException($"Key {key} is not supported, Please add your key at TranslateKeyHex for translation to hex."); + } + } + + /// <summary> + /// Sends a combination of keys, including the Windows key, to the system. + /// </summary> + /// <param name="keys">The keys to send.</param> + private static void SendWinKeyCombination(string keys) + { + bool winKeyDown = false; + + if (keys.Contains("{WIN}")) + { + keybd_event(VK_LWIN, 0, KEYEVENTF_KEYDOWN, UIntPtr.Zero); + winKeyDown = true; + keys = keys.Replace("{WIN}", string.Empty); // Remove {WIN} from the string +        } + + System.Windows.Forms.SendKeys.SendWait(keys); + +        // Release Windows key + if (winKeyDown) + { + keybd_event(VK_LWIN, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + } + } + + /// <summary> + /// Just press the key.(no release) + /// </summary> + private static void PressVirtualKey(byte key) + { + keybd_event(key, 0, KEYEVENTF_KEYDOWN, UIntPtr.Zero); + } + + /// <summary> + /// Release only the button (if pressed first) + /// </summary> + private static void ReleaseVirtualKey(byte key) + { + keybd_event(key, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + } + } +} diff --git a/src/common/UITestAutomation/ModuleConfigData.cs b/src/common/UITestAutomation/ModuleConfigData.cs index d8dd1cac5a..4dcd168da3 100644 --- a/src/common/UITestAutomation/ModuleConfigData.cs +++ b/src/common/UITestAutomation/ModuleConfigData.cs @@ -29,11 +29,58 @@ namespace Microsoft.PowerToys.UITest PowerToysSettings, FancyZone, Hosts, + Runner, + Workspaces, + PowerRename, + CommandPalette, + ScreenRuler, + LightSwitch, + } + + /// <summary> + /// Represents the window size for the UI test. + /// </summary> + public enum WindowSize + { + /// <summary> + /// Unspecified window size, won't make any size change + /// </summary> + UnSpecified, + + /// <summary> + /// Small window size, 640 * 480 + /// </summary> + Small, + + /// <summary> + /// Small window size, 480 * 640 + /// </summary> + Small_Vertical, + + /// <summary> + /// Medium window size, 1024 * 768 + /// </summary> + Medium, + + /// <summary> + /// Medium window size, 768 * 1024 + /// </summary> + Medium_Vertical, + + /// <summary> + /// Large window size, 1920 * 1080 + /// </summary> + Large, + + /// <summary> + /// Large window size, 1080 * 1920 + /// </summary> + Large_Vertical, } internal class ModuleConfigData { - private Dictionary<PowerToysModule, string> ModulePath { get; } + private Dictionary<PowerToysModule, ModuleInfo> ModuleInfo { get; } // Singleton instance of ModuleConfigData. private static readonly Lazy<ModuleConfigData> SingletonInstance = new Lazy<ModuleConfigData>(() => new ModuleConfigData()); @@ -42,31 +89,75 @@ namespace Microsoft.PowerToys.UITest public const string WindowsApplicationDriverUrl = "http://127.0.0.1:4723"; - public Dictionary<PowerToysModule, string> ModuleWindowName { get; } + private bool UseInstallerForTest { get; } private ModuleConfigData() { - // The exe window name for each module. - ModuleWindowName = new Dictionary<PowerToysModule, string> - { - [PowerToysModule.PowerToysSettings] = "PowerToys Settings", - [PowerToysModule.FancyZone] = "FancyZones Layout", - [PowerToysModule.Hosts] = "Hosts File Editor", - }; + // Check if we should use installer paths from environment variable + UseInstallerForTest = EnvironmentConfig.UseInstallerForTest; - // Exe start path for the module if it exists. - ModulePath = new Dictionary<PowerToysModule, string> + // Module information including executable name, window name, and optional subdirectory + ModuleInfo = new Dictionary<PowerToysModule, ModuleInfo> { - [PowerToysModule.PowerToysSettings] = @"\..\..\..\WinUI3Apps\PowerToys.Settings.exe", - [PowerToysModule.FancyZone] = @"\..\..\..\PowerToys.FancyZonesEditor.exe", - [PowerToysModule.Hosts] = @"\..\..\..\WinUI3Apps\PowerToys.Hosts.exe", + [PowerToysModule.PowerToysSettings] = new ModuleInfo("PowerToys.Settings.exe", "PowerToys Settings", "WinUI3Apps"), + [PowerToysModule.FancyZone] = new ModuleInfo("PowerToys.FancyZonesEditor.exe", "FancyZones Layout"), + [PowerToysModule.Hosts] = new ModuleInfo("PowerToys.Hosts.exe", "Hosts File Editor", "WinUI3Apps"), + [PowerToysModule.Runner] = new ModuleInfo("PowerToys.exe", "PowerToys"), + [PowerToysModule.Workspaces] = new ModuleInfo("PowerToys.WorkspacesEditor.exe", "Workspaces Editor"), + [PowerToysModule.PowerRename] = new ModuleInfo("PowerToys.PowerRename.exe", "PowerRename", "WinUI3Apps"), + [PowerToysModule.CommandPalette] = new ModuleInfo("Microsoft.CmdPal.UI.exe", "PowerToys Command Palette", "WinUI3Apps\\CmdPal"), + [PowerToysModule.ScreenRuler] = new ModuleInfo("PowerToys.MeasureToolUI.exe", "PowerToys.ScreenRuler", "WinUI3Apps"), + [PowerToysModule.LightSwitch] = new ModuleInfo("PowerToys.LightSwitch.exe", "PowerToys.LightSwitch", "LightSwitchService"), }; } - public string GetModulePath(PowerToysModule scope) => ModulePath[scope]; + private string GetPowerToysInstallPath() + { + // Try common installation paths + string[] possiblePaths = + { + @"C:\Program Files\PowerToys", + @"C:\Program Files (x86)\PowerToys", + Environment.ExpandEnvironmentVariables(@"%LocalAppData%\PowerToys"), + Environment.ExpandEnvironmentVariables(@"%ProgramFiles%\PowerToys"), + }; + + foreach (string path in possiblePaths) + { + if (Directory.Exists(path) && File.Exists(Path.Combine(path, "PowerToys.exe"))) + { + return path; + } + } + + // Fallback to Program Files if not found + return @"C:\Program Files\PowerToys"; + } + + public string GetModulePath(PowerToysModule scope) + { + var moduleInfo = ModuleInfo[scope]; + + if (UseInstallerForTest) + { + string powerToysInstallPath = GetPowerToysInstallPath(); + string installedPath = moduleInfo.GetInstalledPath(powerToysInstallPath); + + if (File.Exists(installedPath)) + { + return installedPath; + } + else + { + Console.WriteLine($"Warning: Installed module not found at {installedPath}, using development path"); + } + } + + return moduleInfo.GetDevelopmentPath(); + } public string GetWindowsApplicationDriverUrl() => WindowsApplicationDriverUrl; - public string GetModuleWindowName(PowerToysModule scope) => ModuleWindowName[scope]; + public string GetModuleWindowName(PowerToysModule scope) => ModuleInfo[scope].WindowName; } } diff --git a/src/common/UITestAutomation/ModuleInfo.cs b/src/common/UITestAutomation/ModuleInfo.cs new file mode 100644 index 0000000000..35add0e0d2 --- /dev/null +++ b/src/common/UITestAutomation/ModuleInfo.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + internal class ModuleInfo + { + public string ExecutableName { get; } + + public string? SubDirectory { get; } + + public string WindowName { get; } + + public ModuleInfo(string executableName, string windowName, string? subDirectory = null) + { + ExecutableName = executableName; + WindowName = windowName; + SubDirectory = subDirectory; + } + + /// <summary> + /// Gets the relative development path for this module + /// </summary> + public string GetDevelopmentPath() + { + if (string.IsNullOrEmpty(SubDirectory)) + { + return $@"\..\..\..\{ExecutableName}"; + } + + return $@"\..\..\..\{SubDirectory}\{ExecutableName}"; + } + + /// <summary> + /// Gets the installed path for this module based on the PowerToys install directory + /// </summary> + public string GetInstalledPath(string powerToysInstallPath) + { + if (string.IsNullOrEmpty(SubDirectory)) + { + return Path.Combine(powerToysInstallPath, ExecutableName); + } + + return Path.Combine(powerToysInstallPath, SubDirectory, ExecutableName); + } + } +} diff --git a/src/common/UITestAutomation/MonitorInfoData.cs b/src/common/UITestAutomation/MonitorInfoData.cs new file mode 100644 index 0000000000..319b1ba1d8 --- /dev/null +++ b/src/common/UITestAutomation/MonitorInfoData.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace Microsoft.PowerToys.UITest +{ + public class MonitorInfoData + { + public MonitorInfoData() + { + } + + public struct MonitorInfoDataWrapper + { + public string DeviceName { get; set; } + + public string DeviceString { get; set; } + + public string DeviceID { get; set; } + + public string DeviceKey { get; set; } + + public int PelsWidth { get; set; } + + public int PelsHeight { get; set; } + + public int DisplayFrequency { get; set; } + } + + public struct ParamsWrapper + { + public List<MonitorInfoDataWrapper> Monitors { get; set; } + } + } +} diff --git a/src/common/UITestAutomation/MouseHelper.cs b/src/common/UITestAutomation/MouseHelper.cs new file mode 100644 index 0000000000..c08e1c61de --- /dev/null +++ b/src/common/UITestAutomation/MouseHelper.cs @@ -0,0 +1,217 @@ +// 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.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + public enum MouseActionType + { + LeftClick, + RightClick, + MiddleClick, + LeftDoubleClick, + RightDoubleClick, + LeftDown, + LeftUp, + RightDown, + RightUp, + MiddleDown, + MiddleUp, + ScrollUp, + ScrollDown, + } + + internal static class MouseHelper + { + [StructLayout(LayoutKind.Sequential)] + public struct POINT + { + public int X; + public int Y; + } + + [Flags] + internal enum MouseEvent + { + LeftDown = 0x0002, + LeftUp = 0x0004, + RightDown = 0x0008, + RightUp = 0x0010, + MiddleDown = 0x0020, + MiddleUp = 0x0040, + Wheel = 0x0800, + } + + [DllImport("user32.dll")] + public static extern bool GetCursorPos(out POINT lpPoint); + + [DllImport("user32.dll")] + public static extern bool SetCursorPos(int x, int y); + + [DllImport("user32.dll")] +#pragma warning disable SA1300 // Element should begin with upper-case letter + private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo); +#pragma warning restore SA1300 // Element should begin with upper-case letter + +        /// <summary> +        /// Gets the current position of the mouse cursor as a tuple. +        /// </summary> +        /// <returns>A tuple containing the X and Y coordinates of the cursor.</returns> + public static Tuple<int, int> GetMousePosition() + { + GetCursorPos(out POINT point); + return Tuple.Create(point.X, point.Y); + } + + /// <summary> +    /// Moves the mouse cursor to the specified screen coordinates. +    /// </summary> +    /// <param name="x">The new x-coordinate of the cursor.</param> +    /// <param name="y">The new y-coordinate of the cursor.</param + public static void MoveMouseTo(int x, int y) + { + SetCursorPos(x, y); + } + + /// <summary> + /// The delay in milliseconds between mouse down and up events to simulate a click. + /// </summary> + private const int ClickDelay = 100; + + /// <summary> + /// The amount of scroll units to simulate a single mouse wheel tick. + /// </summary> + private const int ScrollAmount = 120; + + /// <summary> + /// Simulates a left mouse click (press and release). + /// </summary> + public static void LeftClick() + { + LeftDown(); + Thread.Sleep(ClickDelay); + LeftUp(); + } + + /// <summary> + /// Simulates a right mouse click (press and release). + /// </summary> + public static void RightClick() + { + RightDown(); + Thread.Sleep(ClickDelay); + RightUp(); + } + + /// <summary> + /// Simulates a middle mouse click (press and release). + /// </summary> + public static void MiddleClick() + { + MiddleDown(); + Thread.Sleep(ClickDelay); + MiddleUp(); + } + + /// <summary> + /// Simulates a left mouse double-click. + /// </summary> + public static void LeftDoubleClick() + { + LeftClick(); + Thread.Sleep(ClickDelay); + LeftClick(); + } + + /// <summary> + /// Simulates a right mouse double-click. + /// </summary> + public static void RightDoubleClick() + { + RightClick(); + Thread.Sleep(ClickDelay); + RightClick(); + } + + /// <summary> + /// Simulates pressing the left mouse button down. + /// </summary> + public static void LeftDown() + { + mouse_event((uint)MouseEvent.LeftDown, 0, 0, 0, UIntPtr.Zero); + } + + /// <summary> + /// Simulates pressing the right mouse button down. + /// </summary> + public static void RightDown() + { + mouse_event((uint)MouseEvent.RightDown, 0, 0, 0, UIntPtr.Zero); + } + + /// <summary> + /// Simulates pressing the middle mouse button down. + /// </summary> + public static void MiddleDown() + { + mouse_event((uint)MouseEvent.MiddleDown, 0, 0, 0, UIntPtr.Zero); + } + + /// <summary> + /// Simulates releasing the left mouse button. + /// </summary> + public static void LeftUp() + { + mouse_event((uint)MouseEvent.LeftUp, 0, 0, 0, UIntPtr.Zero); + } + + /// <summary> + /// Simulates releasing the right mouse button. + /// </summary> + public static void RightUp() + { + mouse_event((uint)MouseEvent.RightUp, 0, 0, 0, UIntPtr.Zero); + } + + /// <summary> + /// Simulates releasing the middle mouse button. + /// </summary> + public static void MiddleUp() + { + mouse_event((uint)MouseEvent.MiddleUp, 0, 0, 0, UIntPtr.Zero); + } + + /// <summary> + /// Simulates a mouse scroll wheel action by a specified amount. + /// Positive values scroll up, negative values scroll down. + /// </summary> + /// <param name="amount">The scroll amount. Typically 120 or -120 per tick.</param> + public static void ScrollWheel(int amount) + { + mouse_event((uint)MouseEvent.Wheel, 0, 0, (uint)amount, UIntPtr.Zero); + } + + /// <summary> + /// Simulates scrolling the mouse wheel up by one tick. + /// </summary> + public static void ScrollUp() + { + ScrollWheel(ScrollAmount); + } + + /// <summary> + /// Simulates scrolling the mouse wheel down by one tick. + /// </summary> + public static void ScrollDown() + { + ScrollWheel(-ScrollAmount); + } + } +} diff --git a/src/common/UITestAutomation/ScreenCapture.cs b/src/common/UITestAutomation/ScreenCapture.cs new file mode 100644 index 0000000000..8e59c20c77 --- /dev/null +++ b/src/common/UITestAutomation/ScreenCapture.cs @@ -0,0 +1,133 @@ +// 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.Drawing.Imaging; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.UITest +{ + /// <summary> + /// Provides methods for capturing the screen with the mouse cursor. + /// </summary> + internal static class ScreenCapture + { + [DllImport("user32.dll")] + private static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("gdi32.dll")] + private static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + [DllImport("user32.dll")] + private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC); + + [DllImport("user32.dll")] + private static extern bool GetCursorInfo(out CURSORINFO pci); + + [DllImport("user32.dll")] + private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags); + + /// <summary> + /// Represents a point with X and Y coordinates. + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct POINT + { + public int X; + public int Y; + } + + /// <summary> + /// Contains information about the cursor. + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct CURSORINFO + { + /// <summary> + /// Gets or sets the size of the structure. + /// </summary> + public int CbSize; + + /// <summary> + /// Gets or sets the cursor state. + /// </summary> + public int Flags; + + /// <summary> + /// Gets or sets the handle to the cursor. + /// </summary> + public IntPtr HCursor; + + /// <summary> + /// Gets or sets the screen position of the cursor. + /// </summary> + public POINT PTScreenPos; + } + + private const int CURSORSHOWING = 0x00000001; + private const int DESKTOPHORZRES = 118; + private const int DESKTOPVERTRES = 117; + private const int DINORMAL = 0x0003; + + /// <summary> + /// Captures the screen with the mouse cursor and saves it to the specified file path. + /// </summary> + /// <param name="filePath">The file path to save the captured image.</param> + private static void CaptureScreenWithMouse(string filePath) + { + IntPtr hdc = GetDC(IntPtr.Zero); + int screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES); + int screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES); + ReleaseDC(IntPtr.Zero, hdc); + + Rectangle bounds = new Rectangle(0, 0, screenWidth, screenHeight); + using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height)) + { + using (System.Drawing.Graphics g = System.Drawing.Graphics.FromImage(bitmap)) + { + g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size); + + CURSORINFO cursorInfo; + cursorInfo.CbSize = Marshal.SizeOf<CURSORINFO>(); + if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING) + { + using (System.Drawing.Graphics gIcon = System.Drawing.Graphics.FromImage(bitmap)) + { + IntPtr hdcDest = gIcon.GetHdc(); + DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL); + gIcon.ReleaseHdc(hdcDest); + } + } + } + + bitmap.Save(filePath, ImageFormat.Png); + } + } + + /// <summary> + /// Captures a screenshot and saves it to the specified directory. + /// </summary> + /// <param name="directory">The directory to save the screenshot.</param> + private static void CaptureScreenshot(string directory) + { + string filePath = Path.Combine(directory, $"screenshot_{DateTime.Now:yyyyMMdd_HHmmssfff}.png"); + CaptureScreenWithMouse(filePath); + } + + /// <summary> + /// Timer callback method to capture a screenshot. + /// </summary> + /// <param name="state">The state object passed to the callback method.</param> + public static void TimerCallback(object? state) + { + string directory = (string)state!; + CaptureScreenshot(directory); + } + } +} diff --git a/src/common/UITestAutomation/ScreenRecording.cs b/src/common/UITestAutomation/ScreenRecording.cs new file mode 100644 index 0000000000..57e844936d --- /dev/null +++ b/src/common/UITestAutomation/ScreenRecording.cs @@ -0,0 +1,399 @@ +// 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.Diagnostics; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + /// <summary> + /// Provides methods for recording the screen during UI tests. + /// Requires FFmpeg to be installed and available in PATH. + /// </summary> + internal class ScreenRecording : IDisposable + { + private readonly string outputDirectory; + private readonly string framesDirectory; + private readonly string outputFilePath; + private readonly List<string> capturedFrames; + private readonly SemaphoreSlim recordingLock = new(1, 1); + private readonly Stopwatch recordingStopwatch = new(); + private readonly string? ffmpegPath; + private CancellationTokenSource? recordingCancellation; + private Task? recordingTask; + private bool isRecording; + private int frameCount; + + [DllImport("user32.dll")] + private static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("gdi32.dll")] + private static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + [DllImport("user32.dll")] + private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC); + + [DllImport("user32.dll")] + private static extern bool GetCursorInfo(out ScreenCapture.CURSORINFO pci); + + [DllImport("user32.dll")] + private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags); + + private const int CURSORSHOWING = 0x00000001; + private const int DESKTOPHORZRES = 118; + private const int DESKTOPVERTRES = 117; + private const int DINORMAL = 0x0003; + private const int TargetFps = 15; // 15 FPS for good balance of quality and size + + /// <summary> + /// Initializes a new instance of the <see cref="ScreenRecording"/> class. + /// </summary> + /// <param name="outputDirectory">Directory where the recording will be saved.</param> + public ScreenRecording(string outputDirectory) + { + this.outputDirectory = outputDirectory; + string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + framesDirectory = Path.Combine(outputDirectory, $"frames_{timestamp}"); + outputFilePath = Path.Combine(outputDirectory, $"recording_{timestamp}.mp4"); + capturedFrames = new List<string>(); + frameCount = 0; + + // Check if FFmpeg is available + ffmpegPath = FindFfmpeg(); + if (ffmpegPath == null) + { + Console.WriteLine("FFmpeg not found. Screen recording will be disabled."); + Console.WriteLine("To enable video recording, install FFmpeg: https://ffmpeg.org/download.html"); + } + } + + /// <summary> + /// Gets a value indicating whether screen recording is available (FFmpeg found). + /// </summary> + public bool IsAvailable => ffmpegPath != null; + + /// <summary> + /// Starts recording the screen. + /// </summary> + /// <returns>A task representing the asynchronous operation.</returns> + public async Task StartRecordingAsync() + { + await recordingLock.WaitAsync(); + try + { + if (isRecording || !IsAvailable) + { + return; + } + + // Create frames directory + Directory.CreateDirectory(framesDirectory); + + recordingCancellation = new CancellationTokenSource(); + isRecording = true; + recordingStopwatch.Start(); + + // Start the recording task + recordingTask = Task.Run(() => RecordFrames(recordingCancellation.Token)); + + Console.WriteLine($"Started screen recording at {TargetFps} FPS"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to start recording: {ex.Message}"); + isRecording = false; + } + finally + { + recordingLock.Release(); + } + } + + /// <summary> + /// Stops recording and encodes video. + /// </summary> + /// <returns>A task representing the asynchronous operation.</returns> + public async Task StopRecordingAsync() + { + await recordingLock.WaitAsync(); + try + { + if (!isRecording || recordingCancellation == null) + { + return; + } + + // Signal cancellation + recordingCancellation.Cancel(); + + // Wait for recording task to complete + if (recordingTask != null) + { + await recordingTask; + } + + recordingStopwatch.Stop(); + isRecording = false; + + double duration = recordingStopwatch.Elapsed.TotalSeconds; + Console.WriteLine($"Recording stopped. Captured {capturedFrames.Count} frames in {duration:F2} seconds"); + + // Encode to video + await EncodeToVideoAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"Error stopping recording: {ex.Message}"); + } + finally + { + Cleanup(); + recordingLock.Release(); + } + } + + /// <summary> + /// Records frames from the screen. + /// </summary> + private void RecordFrames(CancellationToken cancellationToken) + { + try + { + int frameInterval = 1000 / TargetFps; + var frameTimer = Stopwatch.StartNew(); + + while (!cancellationToken.IsCancellationRequested) + { + var frameStart = frameTimer.ElapsedMilliseconds; + + try + { + CaptureFrame(); + } + catch (Exception ex) + { + Console.WriteLine($"Error capturing frame: {ex.Message}"); + } + + // Sleep for remaining time to maintain target FPS + var frameTime = frameTimer.ElapsedMilliseconds - frameStart; + var sleepTime = Math.Max(0, frameInterval - (int)frameTime); + + if (sleepTime > 0) + { + Thread.Sleep(sleepTime); + } + } + } + catch (OperationCanceledException) + { + // Expected when stopping + } + catch (Exception ex) + { + Console.WriteLine($"Error during recording: {ex.Message}"); + } + } + + /// <summary> + /// Captures a single frame. + /// </summary> + private void CaptureFrame() + { + IntPtr hdc = GetDC(IntPtr.Zero); + int screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES); + int screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES); + ReleaseDC(IntPtr.Zero, hdc); + + Rectangle bounds = new Rectangle(0, 0, screenWidth, screenHeight); + using (Bitmap bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format24bppRgb)) + { + using (Graphics g = Graphics.FromImage(bitmap)) + { + g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size); + + ScreenCapture.CURSORINFO cursorInfo; + cursorInfo.CbSize = Marshal.SizeOf<ScreenCapture.CURSORINFO>(); + if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING) + { + IntPtr hdcDest = g.GetHdc(); + DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL); + g.ReleaseHdc(hdcDest); + } + } + + string framePath = Path.Combine(framesDirectory, $"frame_{frameCount:D6}.jpg"); + bitmap.Save(framePath, ImageFormat.Jpeg); + capturedFrames.Add(framePath); + frameCount++; + } + } + + /// <summary> + /// Encodes captured frames to video using ffmpeg. + /// </summary> + private async Task EncodeToVideoAsync() + { + if (capturedFrames.Count == 0) + { + Console.WriteLine("No frames captured"); + return; + } + + try + { + // Build ffmpeg command with proper non-interactive flags + string inputPattern = Path.Combine(framesDirectory, "frame_%06d.jpg"); + + // -y: overwrite without asking + // -nostdin: disable interaction + // -loglevel error: only show errors + // -stats: show encoding progress + string args = $"-y -nostdin -loglevel error -stats -framerate {TargetFps} -i \"{inputPattern}\" -c:v libx264 -pix_fmt yuv420p -crf 23 \"{outputFilePath}\""; + + Console.WriteLine($"Encoding {capturedFrames.Count} frames to video..."); + + var startInfo = new ProcessStartInfo + { + FileName = ffmpegPath!, + Arguments = args, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, // Important: redirect stdin to prevent hanging + CreateNoWindow = true, + }; + + using var process = Process.Start(startInfo); + if (process != null) + { + // Close stdin immediately to ensure FFmpeg doesn't wait for input + process.StandardInput.Close(); + + // Read output streams asynchronously to prevent deadlock + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + + // Wait for process to exit + await process.WaitForExitAsync(); + + // Get the output + string stdout = await outputTask; + string stderr = await errorTask; + + if (process.ExitCode == 0 && File.Exists(outputFilePath)) + { + var fileInfo = new FileInfo(outputFilePath); + Console.WriteLine($"Video created: {outputFilePath} ({fileInfo.Length / 1024 / 1024:F1} MB)"); + } + else + { + Console.WriteLine($"FFmpeg encoding failed with exit code {process.ExitCode}"); + if (!string.IsNullOrWhiteSpace(stderr)) + { + Console.WriteLine($"FFmpeg error: {stderr}"); + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error encoding video: {ex.Message}"); + } + } + + /// <summary> + /// Finds ffmpeg executable. + /// </summary> + private static string? FindFfmpeg() + { + // Check if ffmpeg is in PATH + var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty<string>(); + + foreach (var dir in pathDirs) + { + var ffmpegPath = Path.Combine(dir, "ffmpeg.exe"); + if (File.Exists(ffmpegPath)) + { + return ffmpegPath; + } + } + + // Check common installation locations + var commonPaths = new[] + { + @"C:\.tools\ffmpeg\bin\ffmpeg.exe", + @"C:\ffmpeg\bin\ffmpeg.exe", + @"C:\Program Files\ffmpeg\bin\ffmpeg.exe", + @"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe", + @$"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\WinGet\Links\ffmpeg.exe", + }; + + foreach (var path in commonPaths) + { + if (File.Exists(path)) + { + return path; + } + } + + return null; + } + + /// <summary> + /// Gets the path to the recorded video file. + /// </summary> + public string OutputFilePath => outputFilePath; + + /// <summary> + /// Gets the directory containing recordings. + /// </summary> + public string OutputDirectory => outputDirectory; + + /// <summary> + /// Cleans up resources. + /// </summary> + private void Cleanup() + { + recordingCancellation?.Dispose(); + recordingCancellation = null; + recordingTask = null; + + // Clean up frames directory if it exists + try + { + if (Directory.Exists(framesDirectory)) + { + Directory.Delete(framesDirectory, true); + } + } + catch (Exception ex) + { + Console.WriteLine($"Failed to cleanup frames directory: {ex.Message}"); + } + } + + /// <summary> + /// Disposes resources. + /// </summary> + public void Dispose() + { + if (isRecording) + { + StopRecordingAsync().GetAwaiter().GetResult(); + } + + Cleanup(); + recordingLock.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/common/UITestAutomation/Session.cs b/src/common/UITestAutomation/Session.cs index 9e5101fc75..3dd19ebe54 100644 --- a/src/common/UITestAutomation/Session.cs +++ b/src/common/UITestAutomation/Session.cs @@ -1,14 +1,17 @@ -// 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.ObjectModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Xml.Linq; +using System.Text; using Microsoft.VisualStudio.TestTools.UnitTesting; using OpenQA.Selenium.Appium; using OpenQA.Selenium.Appium.Windows; using OpenQA.Selenium.Interactions; +using static Microsoft.PowerToys.UITest.WindowHelper; namespace Microsoft.PowerToys.UITest { @@ -17,35 +20,70 @@ namespace Microsoft.PowerToys.UITest /// </summary> public class Session { - private WindowsDriver<WindowsElement> Root { get; set; } + public WindowsDriver<WindowsElement> Root { get; set; } private WindowsDriver<WindowsElement> WindowsDriver { get; set; } - [DllImport("user32.dll")] - private static extern bool SetForegroundWindow(nint hWnd); + private List<IntPtr> windowHandlers = new List<IntPtr>(); - public Session(WindowsDriver<WindowsElement> root, WindowsDriver<WindowsElement> windowsDriver) + private Window? MainWindow { get; set; } + + /// <summary> + /// Gets Main Window Handler + /// </summary> + public IntPtr MainWindowHandler { get; private set; } + + /// <summary> + /// Gets Init Scope + /// </summary> + public PowerToysModule InitScope { get; private set; } + + /// <summary> + /// Gets the RunAsAdmin flag. + /// If true, the session is running as admin. + /// If false, the session is not running as admin. + /// If null, no information is available. + /// </summary> + public bool? IsElevated { get; private set; } + + public Session(WindowsDriver<WindowsElement> pRoot, WindowsDriver<WindowsElement> pDriver, PowerToysModule scope, WindowSize size) { - this.Root = root; - this.WindowsDriver = windowsDriver; + this.MainWindowHandler = IntPtr.Zero; + this.Root = pRoot; + this.WindowsDriver = pDriver; + this.InitScope = scope; + + if (size != WindowSize.UnSpecified) + { + // Attach to the scope & reset MainWindowHandler + this.Attach(scope, size); + } } /// <summary> - /// Finds an element by selector. + /// Cleans up the Session Exe. + /// </summary> + public void Cleanup() + { + windowHandlers.Clear(); + } + + /// <summary> + /// Finds an Element or its derived class by selector. /// </summary> /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> /// <param name="by">The selector to find the element.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>The found element.</returns> - public T Find<T>(By by, int timeoutMS = 3000) + public T Find<T>(By by, int timeoutMS = 5000, bool global = false) where T : Element, new() { Assert.IsNotNull(this.WindowsDriver, $"WindowsElement is null in method Find<{typeof(T).Name}> with parameters: by = {by}, timeoutMS = {timeoutMS}"); // leverage findAll to filter out mismatched elements - var collection = this.FindAll<T>(by, timeoutMS); + var collection = this.FindAll<T>(by, timeoutMS, global); - Assert.IsTrue(collection.Count > 0, $"Element not found using selector: {by}"); + Assert.IsTrue(collection.Count > 0, $"UI-Element({typeof(T).Name}) not found using selector: {by}"); return collection[0]; } @@ -55,62 +93,159 @@ namespace Microsoft.PowerToys.UITest /// </summary> /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> /// <param name="name">The name of the element.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>The found element.</returns> - public T Find<T>(string name, int timeoutMS = 3000) + public T Find<T>(string name, int timeoutMS = 5000, bool global = false) where T : Element, new() { - return this.Find<T>(By.Name(name), timeoutMS); + return this.Find<T>(By.Name(name), timeoutMS, global); } /// <summary> /// Shortcut for this.Find<Element>(by, timeoutMS) /// </summary> /// <param name="by">The selector to find the element.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>The found element.</returns> - public Element Find(By by, int timeoutMS = 3000) + public Element Find(By by, int timeoutMS = 5000, bool global = false) { - return this.Find<Element>(by, timeoutMS); + return this.Find<Element>(by, timeoutMS, global); } /// <summary> /// Shortcut for this.Find<Element>(By.Name(name), timeoutMS) /// </summary> /// <param name="name">The name of the element.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>The found element.</returns> - public Element Find(string name, int timeoutMS = 3000) + public Element Find(string name, int timeoutMS = 5000, bool global = false) { - return this.Find<Element>(By.Name(name), timeoutMS); + return this.Find<Element>(By.Name(name), timeoutMS, global); } /// <summary> - /// Finds all elements by selector. + /// Has only one Element or its derived class by selector. + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="by">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if only has one element; otherwise, false.</returns> + public bool HasOne<T>(By by, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return this.FindAll<T>(by, timeoutMS, global).Count == 1; + } + + /// <summary> + /// Shortcut for this.HasOne<Element>(by, timeoutMS) + /// </summary> + /// <param name="by">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if only has one element; otherwise, false.</returns> + public bool HasOne(By by, int timeoutMS = 5000, bool global = false) + { + return this.HasOne<Element>(by, timeoutMS, global); + } + + /// <summary> + /// Shortcut for this.HasOne<T>(By.Name(name), timeoutMS) + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="name">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if only has one element; otherwise, false.</returns> + public bool HasOne<T>(string name, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return this.HasOne<T>(By.Name(name), timeoutMS, global); + } + + /// <summary> + /// Shortcut for this.HasOne<Element>(name, timeoutMS) + /// </summary> + /// <param name="name">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if only has one element; otherwise, false.</returns> + public bool HasOne(string name, int timeoutMS = 5000, bool global = false) + { + return this.HasOne<Element>(By.Name(name), timeoutMS, global); + } + + /// <summary> + /// Has one or more Element or its derived class by selector. + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="by">The selector to find the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if has one or more element; otherwise, false.</returns> + public bool Has<T>(By by, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return this.FindAll<T>(by, timeoutMS, global).Count >= 1; + } + + /// <summary> + /// Shortcut for this.Has<Element>(by, timeoutMS) + /// </summary> + /// <param name="by">The selector to find the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if has one or more element; otherwise, false.</returns> + public bool Has(By by, int timeoutMS = 5000, bool global = false) + { + return this.Has<Element>(by, timeoutMS, global); + } + + /// <summary> + /// Shortcut for this.Has<T>(By.Name(name), timeoutMS) + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="name">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if has one or more element; otherwise, false.</returns> + public bool Has<T>(string name, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return this.Has<T>(By.Name(name), timeoutMS, global); + } + + /// <summary> + /// Shortcut for this.Has<Element>(name, timeoutMS) + /// </summary> + /// <param name="name">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if has one or more element; otherwise, false.</returns> + public bool Has(string name, int timeoutMS = 5000, bool global = false) + { + return this.Has<Element>(name, timeoutMS, global); + } + + /// <summary> + /// Finds all Element or its derived class by selector. /// </summary> /// <typeparam name="T">The class of the elements, should be Element or its derived class.</typeparam> /// <param name="by">The selector to find the elements.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>A read-only collection of the found elements.</returns> - public ReadOnlyCollection<T> FindAll<T>(By by, int timeoutMS = 3000) + public ReadOnlyCollection<T> FindAll<T>(By by, int timeoutMS = 5000, bool global = false) where T : Element, new() { - Assert.IsNotNull(this.WindowsDriver, $"WindowsElement is null in method FindAll<{typeof(T).Name}> with parameters: by = {by}, timeoutMS = {timeoutMS}"); + var driver = global ? this.Root : this.WindowsDriver; + Assert.IsNotNull(driver, $"WindowsElement is null in method FindAll<{typeof(T).Name}> with parameters: by = {by}, timeoutMS = {timeoutMS}"); var foundElements = FindHelper.FindAll<T, WindowsElement>( () => { if (by.GetIsAccessibilityId()) { - var elements = this.WindowsDriver.FindElementsByAccessibilityId(by.GetAccessibilityId()); + var elements = driver.FindElementsByAccessibilityId(by.GetAccessibilityId()); return elements; } else { - var elements = this.WindowsDriver.FindElements(by.ToSeleniumBy()); + var elements = driver.FindElements(by.ToSeleniumBy()); return elements; } }, - this.WindowsDriver, + driver, timeoutMS); return foundElements ?? new ReadOnlyCollection<T>([]); @@ -122,12 +257,12 @@ namespace Microsoft.PowerToys.UITest /// </summary> /// <typeparam name="T">The class of the elements, should be Element or its derived class.</typeparam> /// <param name="name">The name to find the elements.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>A read-only collection of the found elements.</returns> - public ReadOnlyCollection<T> FindAll<T>(string name, int timeoutMS = 3000) + public ReadOnlyCollection<T> FindAll<T>(string name, int timeoutMS = 5000, bool global = false) where T : Element, new() { - return this.FindAll<T>(By.Name(name), timeoutMS); + return this.FindAll<T>(By.Name(name), timeoutMS, global); } /// <summary> @@ -135,11 +270,11 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.FindAll<Element>(by, timeoutMS) /// </summary> /// <param name="by">The selector to find the elements.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>A read-only collection of the found elements.</returns> - public ReadOnlyCollection<Element> FindAll(By by, int timeoutMS = 3000) + public ReadOnlyCollection<Element> FindAll(By by, int timeoutMS = 5000, bool global = false) { - return this.FindAll<Element>(by, timeoutMS); + return this.FindAll<Element>(by, timeoutMS, global); } /// <summary> @@ -147,55 +282,186 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.FindAll<Element>(By.Name(name), timeoutMS) /// </summary> /// <param name="name">The name to find the elements.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>A read-only collection of the found elements.</returns> - public ReadOnlyCollection<Element> FindAll(string name, int timeoutMS = 3000) + public ReadOnlyCollection<Element> FindAll(string name, int timeoutMS = 5000, bool global = false) { - return this.FindAll<Element>(By.Name(name), timeoutMS); + return this.FindAll<Element>(By.Name(name), timeoutMS, global); } /// <summary> - /// Keyboard Action key. + /// Close the main window. /// </summary> - /// <param name="key1">The Keys1 to click.</param> - /// <param name="key2">The Keys2 to click.</param> - /// <param name="key3">The Keys3 to click.</param> - /// <param name="key4">The Keys4 to click.</param> - public void KeyboardAction(string key1, string key2 = "", string key3 = "", string key4 = "") + public void CloseMainWindow() { - PerformAction((actions, windowElement) => + if (MainWindow != null) { - if (string.IsNullOrEmpty(key2)) - { - actions.SendKeys(key1); - } - else if (string.IsNullOrEmpty(key3)) - { - actions.SendKeys(key1).SendKeys(key2); - } - else if (string.IsNullOrEmpty(key4)) - { - actions.SendKeys(key1).SendKeys(key2).SendKeys(key3); - } - else - { - actions.SendKeys(key1).SendKeys(key2).SendKeys(key3).SendKeys(key4); - } + MainWindow.Close(); + MainWindow = null; + } + } - actions.Release(); - actions.Build().Perform(); + /// <summary> + /// Sends a combination of keys. + /// </summary> + /// <param name="keys">The keys to send.</param> + public void SendKeys(params Key[] keys) + { + PerformAction(() => + { + KeyboardHelper.SendKeys(keys); }); } + /// <summary> + /// release the key (after the hold key and drag is completed.) + /// </summary> + /// <param name="key">The key release.</param> + public void PressKey(Key key) + { + PerformAction(() => + { + KeyboardHelper.PressKey(key); + }); + } + + /// <summary> + /// press and hold the specified key. + /// </summary> + /// <param name="key">The key to press and hold .</param> + public void ReleaseKey(Key key) + { + PerformAction(() => + { + KeyboardHelper.ReleaseKey(key); + }); + } + + /// <summary> + /// press and hold the specified key. + /// </summary> + /// <param name="key">The key to press and release .</param> + public void SendKey(Key key, int msPreAction = 500, int msPostAction = 500) + { + PerformAction( + () => + { + KeyboardHelper.SendKey(key); + }, + msPreAction, + msPostAction); + } + + /// <summary> + /// Sends a sequence of keys. + /// </summary> + /// <param name="keys">An array of keys to send.</param> + public void SendKeySequence(params Key[] keys) + { + PerformAction(() => + { + foreach (var key in keys) + { + KeyboardHelper.SendKeys(key); + } + }); + } + +        /// <summary> +        /// Gets the current position of the mouse cursor as a tuple. +        /// </summary> +        /// <returns>A tuple containing the X and Y coordinates of the cursor.</returns> + public Tuple<int, int> GetMousePosition() + { + return MouseHelper.GetMousePosition(); + } + + /// <summary> +    /// Moves the mouse cursor to the specified screen coordinates. +    /// </summary> +    /// <param name="x">The new x-coordinate of the cursor.</param> +    /// <param name="y">The new y-coordinate of the cursor.</param + public void MoveMouseTo(int x, int y, int msPreAction = 500, int msPostAction = 500) + { + PerformAction( + () => + { + MouseHelper.MoveMouseTo(x, y); + }, + msPreAction, + msPostAction); + } + + /// <summary> + /// Performs a mouse action based on the specified action type. + /// </summary> + /// <param name="action">The mouse action to perform.</param> + /// <param name="msPreAction">Pre-action delay in milliseconds.</param> + /// <param name="msPostAction">Post-action delay in milliseconds.</param> + public void PerformMouseAction(MouseActionType action, int msPreAction = 500, int msPostAction = 500) + { + PerformAction( + () => + { + switch (action) + { + case MouseActionType.LeftClick: + MouseHelper.LeftClick(); + break; + case MouseActionType.RightClick: + MouseHelper.RightClick(); + break; + case MouseActionType.MiddleClick: + MouseHelper.MiddleClick(); + break; + case MouseActionType.LeftDoubleClick: + MouseHelper.LeftDoubleClick(); + break; + case MouseActionType.RightDoubleClick: + MouseHelper.RightDoubleClick(); + break; + case MouseActionType.LeftDown: + MouseHelper.LeftDown(); + break; + case MouseActionType.LeftUp: + MouseHelper.LeftUp(); + break; + case MouseActionType.RightDown: + MouseHelper.RightDown(); + break; + case MouseActionType.RightUp: + MouseHelper.RightUp(); + break; + case MouseActionType.MiddleDown: + MouseHelper.MiddleDown(); + break; + case MouseActionType.MiddleUp: + MouseHelper.MiddleUp(); + break; + case MouseActionType.ScrollUp: + MouseHelper.ScrollUp(); + break; + case MouseActionType.ScrollDown: + MouseHelper.ScrollDown(); + break; + default: + throw new ArgumentException("Unsupported mouse action.", nameof(action)); + } + }, + msPreAction, + msPostAction); + } + /// <summary> /// Attaches to an existing PowerToys module. /// </summary> /// <param name="module">The PowerToys module to attach to.</param> + /// <param name="size">The window size to set. Default is no change to window size</param> /// <returns>The attached session.</returns> - public Session Attach(PowerToysModule module) + public Session Attach(PowerToysModule module, WindowSize size = WindowSize.UnSpecified) { string windowName = ModuleConfigData.Instance.GetModuleWindowName(module); - return this.Attach(windowName); + return this.Attach(windowName, size); } /// <summary> @@ -203,35 +469,148 @@ namespace Microsoft.PowerToys.UITest /// The session should be attached when a new app is started. /// </summary> /// <param name="windowName">The window name to attach to.</param> + /// <param name="size">The window size to set. Default is no change to window size</param> /// <returns>The attached session.</returns> - public Session Attach(string windowName) + public Session Attach(string windowName, WindowSize size = WindowSize.UnSpecified) { + this.IsElevated = null; + this.MainWindowHandler = IntPtr.Zero; + if (this.Root != null) { - var window = this.Root.FindElementByName(windowName); - Assert.IsNotNull(window, $"Failed to attach. Window '{windowName}' not found"); + // search window handler by window title (admin and non-admin titles) + var timeout = TimeSpan.FromMinutes(2); + var retryInterval = TimeSpan.FromSeconds(5); + DateTime startTime = DateTime.Now; + + List<(IntPtr HWnd, string Title)>? matchingWindows = null; + + while (DateTime.Now - startTime < timeout) + { + matchingWindows = WindowHelper.ApiHelper.FindDesktopWindowHandler( + new[] { windowName, WindowHelper.AdministratorPrefix + windowName }); + + if (matchingWindows.Count > 0 && matchingWindows[0].HWnd != IntPtr.Zero) + { + break; + } + + Task.Delay(retryInterval).Wait(); + } + + if (matchingWindows == null || matchingWindows.Count == 0 || matchingWindows[0].HWnd == IntPtr.Zero) + { + Assert.Fail($"Failed to attach. Window '{windowName}' not found after {timeout.TotalSeconds} seconds."); + } + + // pick one from matching windows + this.MainWindowHandler = matchingWindows[0].HWnd; + this.IsElevated = matchingWindows[0].Title.StartsWith(WindowHelper.AdministratorPrefix); + + ApiHelper.SetForegroundWindow(this.MainWindowHandler); + + var hexWindowHandle = this.MainWindowHandler.ToInt64().ToString("x"); - var windowHandle = new nint(int.Parse(window.GetAttribute("NativeWindowHandle"))); - SetForegroundWindow(windowHandle); - var hexWindowHandle = windowHandle.ToString("x"); var appCapabilities = new AppiumOptions(); - appCapabilities.AddAdditionalCapability("appTopLevelWindow", hexWindowHandle); appCapabilities.AddAdditionalCapability("deviceName", "WindowsPC"); this.WindowsDriver = new WindowsDriver<WindowsElement>(new Uri(ModuleConfigData.Instance.GetWindowsApplicationDriverUrl()), appCapabilities); - Assert.IsNotNull(this.WindowsDriver, "Attach WindowsDriver is null"); - // Set implicit timeout to make element search retry every 500 ms - this.WindowsDriver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(3); + this.windowHandlers.Add(this.MainWindowHandler); + + if (size != WindowSize.UnSpecified) + { + WindowHelper.SetWindowSize(this.MainWindowHandler, size); + } + + // Set MainWindow + MainWindow = Find<Window>(matchingWindows[0].Title); } else { Assert.IsNotNull(this.Root, $"Failed to attach to the window '{windowName}'. Root driver is null"); } + Task.Delay(3000).Wait(); return this; } + /// <summary> + /// Sets the main window size. + /// </summary> + /// <param name="size">WindowSize enum</param> + public void SetMainWindowSize(WindowSize size) + { + if (this.MainWindowHandler == IntPtr.Zero) + { + // Attach to the scope & reset MainWindowHandler + this.Attach(this.InitScope); + } + + WindowHelper.SetWindowSize(this.MainWindowHandler, size); + } + + /// <summary> + /// Gets the main window center coordinates. + /// </summary> + /// <returns>(x, y)</returns> + public (int CenterX, int CenterY) GetMainWindowCenter() + { + return WindowHelper.GetWindowCenter(this.MainWindowHandler); + } + + /// <summary> + /// Gets the main window center coordinates. + /// </summary> + /// <returns>(int Left, int Top, int Right, int Bottom)</returns> + public (int Left, int Top, int Right, int Bottom) GetMainWindowRect() + { + return WindowHelper.GetWindowRect(this.MainWindowHandler); + } + + /// <summary> + /// Launches the specified executable with optional arguments and simulates a delay before and after execution. + /// </summary> + /// <param name="executablePath">The full path to the executable to launch.</param> + /// <param name="arguments">Optional command-line arguments to pass to the executable.</param> + /// <param name="msPreAction">The number of milliseconds to wait before launching the executable. Default is 0 ms.</param> + /// <param name="msPostAction">The number of milliseconds to wait after launching the executable. Default is 2000 ms.</param> + public void StartExe(string executablePath, string arguments = "", int msPreAction = 0, int msPostAction = 2000) + { + PerformAction( + () => + { + StartExeInternal(executablePath, arguments); + }, + msPreAction, + msPostAction); + } + + private void StartExeInternal(string executablePath, string arguments = "") + { + var processInfo = new ProcessStartInfo + { + FileName = executablePath, + Arguments = arguments, + UseShellExecute = true, + }; + Process.Start(processInfo); + } + + /// <summary> + /// Terminates all running processes that match the specified process name. + /// Waits for each process to exit after sending the kill signal. + /// </summary> + /// <param name="processName">The name of the process to terminate (without extension, e.g., "notepad").</param> + public void KillAllProcessesByName(string processName) + { + foreach (var process in Process.GetProcessesByName(processName)) + { + process.Kill(); + process.WaitForExit(); + } + } + /// <summary> /// Simulates a manual operation on the element. /// </summary> @@ -254,5 +633,26 @@ namespace Microsoft.PowerToys.UITest Task.Delay(msPostAction).Wait(); } } + + /// <summary> + /// Simulates a manual operation on the element. + /// </summary> + /// <param name="action">The action to perform on the element.</param> + /// <param name="msPreAction">The number of milliseconds to wait before the action. Default value is 500 ms</param> + /// <param name="msPostAction">The number of milliseconds to wait after the action. Default value is 500 ms</param> + protected void PerformAction(Action action, int msPreAction = 500, int msPostAction = 500) + { + if (msPreAction > 0) + { + Task.Delay(msPreAction).Wait(); + } + + action(); + + if (msPostAction > 0) + { + Task.Delay(msPostAction).Wait(); + } + } } } diff --git a/src/common/UITestAutomation/SessionHelper.cs b/src/common/UITestAutomation/SessionHelper.cs index 324f41e5e3..fef220a647 100644 --- a/src/common/UITestAutomation/SessionHelper.cs +++ b/src/common/UITestAutomation/SessionHelper.cs @@ -5,49 +5,68 @@ using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; using System.Reflection; +using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using OpenQA.Selenium.Appium; using OpenQA.Selenium.Appium.Windows; +using static Microsoft.PowerToys.UITest.WindowHelper; namespace Microsoft.PowerToys.UITest { /// <summary> /// Nested class for test initialization. /// </summary> - internal class SessionHelper + public class SessionHelper { // Default session path is PowerToys settings dashboard private readonly string sessionPath = ModuleConfigData.Instance.GetModulePath(PowerToysModule.PowerToysSettings); + private readonly string runnerPath = ModuleConfigData.Instance.GetModulePath(PowerToysModule.Runner); + private string? locationPath; - private WindowsDriver<WindowsElement> Root { get; set; } + private static WindowsDriver<WindowsElement>? root; private WindowsDriver<WindowsElement>? Driver { get; set; } - private Process? appDriver; + private static Process? appDriver; + private Process? runner; + + private PowerToysModule scope; + private string[]? commandLineArgs; + + /// <summary> + /// Gets a value indicating whether to use installer paths for testing. + /// </summary> + private bool UseInstallerForTest { get; } [UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "<Pending>")] - public SessionHelper(PowerToysModule scope) + public SessionHelper(PowerToysModule scope, string[]? commandLineArgs = null) { + this.scope = scope; + this.commandLineArgs = commandLineArgs; this.sessionPath = ModuleConfigData.Instance.GetModulePath(scope); - this.locationPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + UseInstallerForTest = EnvironmentConfig.UseInstallerForTest; + this.locationPath = UseInstallerForTest ? string.Empty : Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - var winAppDriverProcessInfo = new ProcessStartInfo + CheckWinAppDriverAndRoot(); + } + + /// <summary> + /// Initializes WinAppDriver And Root. + /// </summary> + public void CheckWinAppDriverAndRoot() + { + if (SessionHelper.root == null || SessionHelper.appDriver?.SessionId == null || SessionHelper.appDriver == null || SessionHelper.appDriver.HasExited) { - FileName = "C:\\Program Files (x86)\\Windows Application Driver\\WinAppDriver.exe", - Verb = "runas", - }; - - this.appDriver = Process.Start(winAppDriverProcessInfo); - - var desktopCapabilities = new AppiumOptions(); - desktopCapabilities.AddAdditionalCapability("app", "Root"); - this.Root = new WindowsDriver<WindowsElement>(new Uri(ModuleConfigData.Instance.GetWindowsApplicationDriverUrl()), desktopCapabilities); - - // Set default timeout to 5 seconds - this.Root.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(5); + this.StartWindowsAppDriverApp(); + var desktopCapabilities = new AppiumOptions(); + desktopCapabilities.AddAdditionalCapability("app", "Root"); + SessionHelper.root = new WindowsDriver<WindowsElement>(new Uri(ModuleConfigData.Instance.GetWindowsApplicationDriverUrl()), desktopCapabilities); + } } /// <summary> @@ -56,7 +75,9 @@ namespace Microsoft.PowerToys.UITest /// <param name="scope">The PowerToys module to start.</param> public SessionHelper Init() { - this.StartExe(locationPath + this.sessionPath); + this.ExitExe(this.locationPath + this.sessionPath); + + this.StartExe(this.locationPath + this.sessionPath, this.commandLineArgs); Assert.IsNotNull(this.Driver, $"Failed to initialize the test environment. Driver is null."); @@ -69,44 +90,15 @@ namespace Microsoft.PowerToys.UITest public void Cleanup() { ExitScopeExe(); - try - { - appDriver?.Kill(); - appDriver?.WaitForExit(); // Optional: Wait for the process to exit - } - catch (Exception ex) - { - // Handle exceptions if needed - Debug.WriteLine($"Exception during Cleanup: {ex.Message}"); - } } /// <summary> - /// Starts a new exe and takes control of it. + /// Exit a exe by Name. /// </summary> - /// <param name="appPath">The path to the application executable.</param> - public void StartExe(string appPath) + /// <param name="processName">The path to the application executable.</param> + public void ExitExeByName(string processName) { - var opts = new AppiumOptions(); - opts.AddAdditionalCapability("app", appPath); - Console.WriteLine($"appPath: {appPath}"); - this.Driver = new WindowsDriver<WindowsElement>(new Uri(ModuleConfigData.Instance.GetWindowsApplicationDriverUrl()), opts); - - // Set default timeout to 5 seconds - this.Driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(5); - } - - /// <summary> - /// Exit a exe. - /// </summary> - /// <param name="path">The path to the application executable.</param> - public void ExitExe(string path) - { - // Exit Exe - string exeName = Path.GetFileNameWithoutExtension(path); - - // PowerToys.FancyZonesEditor - Process[] processes = Process.GetProcessesByName(exeName); + Process[] processes = Process.GetProcessesByName(processName); foreach (Process process in processes) { try @@ -121,29 +113,289 @@ namespace Microsoft.PowerToys.UITest } } + /// <summary> + /// Exit a exe. + /// </summary> + /// <param name="appPath">The path to the application executable.</param> + public void ExitExe(string appPath) + { + // Exit Exe + string exeName = Path.GetFileNameWithoutExtension(appPath); + + ExitExeByName(exeName); + } + + /// <summary> + /// Starts a new exe and takes control of it. + /// </summary> + /// <param name="appPath">The path to the application executable.</param> + /// <param name="args">Optional command line arguments to pass to the application.</param> + public void StartExe(string appPath, string[]? args = null, string? enableModules = null) + { + var opts = new AppiumOptions(); + if (!string.IsNullOrEmpty(enableModules)) + { + opts.AddAdditionalCapability("enableModules", enableModules); + } + + if (scope == PowerToysModule.PowerToysSettings) + { + TryLaunchPowerToysSettings(opts); + } + else if (scope == PowerToysModule.CommandPalette && UseInstallerForTest) + { + TryLaunchCommandPalette(opts); + } + else + { + opts.AddAdditionalCapability("app", appPath); + + if (args != null && args.Length > 0) + { + // Build command line arguments string + string argsString = string.Join(" ", args.Select(arg => + { + // Quote arguments that contain spaces + if (arg.Contains(' ')) + { + return $"\"{arg}\""; + } + + return arg; + })); + + opts.AddAdditionalCapability("appArguments", argsString); + } + } + + Driver = NewWindowsDriver(opts); + } + + private void TryLaunchPowerToysSettings(AppiumOptions opts) + { + if (opts.ToCapabilities().HasCapability("enableModules")) + { + var modulesString = (string)opts.ToCapabilities().GetCapability("enableModules"); + var modulesArray = modulesString.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + SettingsConfigHelper.ConfigureGlobalModuleSettings(modulesArray); + } + else + { + SettingsConfigHelper.ConfigureGlobalModuleSettings(); + } + + const int maxTries = 3; + const int delayMs = 5000; + const int maxRetries = 3; + + for (int tryCount = 1; tryCount <= maxTries; tryCount++) + { + try + { + var runnerProcessInfo = new ProcessStartInfo + { + FileName = locationPath + runnerPath, + Verb = "runas", + Arguments = "--open-settings", + }; + + ExitExe(runnerProcessInfo.FileName); + + // Verify process was killed + string exeName = Path.GetFileNameWithoutExtension(runnerProcessInfo.FileName); + var remainingProcesses = Process.GetProcessesByName(exeName); + + runner = Process.Start(runnerProcessInfo); + + if (WaitForWindowAndSetCapability(opts, "PowerToys Settings", delayMs, maxRetries)) + { + // Exit CmdPal UI before launching new process if use installer for test + ExitExeByName("Microsoft.CmdPal.UI"); + return; + } + + // Window not found, kill all PowerToys processes and retry + if (tryCount < maxTries) + { + KillPowerToysProcesses(); + } + } + catch (Exception ex) + { + if (tryCount == maxTries) + { + throw new InvalidOperationException($"Failed to launch PowerToys Settings after {maxTries} attempts: {ex.Message}", ex); + } + + // Kill processes and retry + KillPowerToysProcesses(); + } + } + + throw new InvalidOperationException($"Failed to launch PowerToys Settings: Window not found after {maxTries} attempts."); + } + + private void TryLaunchCommandPalette(AppiumOptions opts) + { + try + { + // Exit any existing CmdPal UI process + ExitExeByName("Microsoft.CmdPal.UI"); + + var processStartInfo = new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = "/c start shell:appsFolder\\Microsoft.CommandPalette_8wekyb3d8bbwe!App", + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + }; + + var process = Process.Start(processStartInfo); + process?.WaitForExit(); + + if (!WaitForWindowAndSetCapability(opts, "Command Palette", 5000, 10)) + { + throw new TimeoutException("Failed to find Command Palette window after multiple attempts."); + } + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to launch Command Palette: {ex.Message}", ex); + } + } + + private bool WaitForWindowAndSetCapability(AppiumOptions opts, string windowName, int delayMs, int maxRetries) + { + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + var window = ApiHelper.FindDesktopWindowHandler( + [windowName, AdministratorPrefix + windowName]); + + if (window.Count > 0) + { + var hexHwnd = window[0].HWnd.ToString("x"); + opts.AddAdditionalCapability("appTopLevelWindow", hexHwnd); + return true; + } + + if (attempt < maxRetries) + { + Thread.Sleep(delayMs); + } + } + + return false; + } + + /// <summary> + /// Starts a new exe and takes control of it. + /// </summary> + /// <param name="info">The AppiumOptions for the application.</param> + private WindowsDriver<WindowsElement> NewWindowsDriver(AppiumOptions info) + { + // Create driver with retry + var timeout = TimeSpan.FromMinutes(2); + var retryInterval = TimeSpan.FromSeconds(5); + DateTime startTime = DateTime.Now; + + while (true) + { + try + { + var res = new WindowsDriver<WindowsElement>(new Uri(ModuleConfigData.Instance.GetWindowsApplicationDriverUrl()), info); + return res; + } + catch (Exception) + { + if (DateTime.Now - startTime > timeout) + { + throw; + } + + Task.Delay(retryInterval).Wait(); + CheckWinAppDriverAndRoot(); + } + } + } + /// <summary> /// Exit now exe. /// </summary> public void ExitScopeExe() { ExitExe(sessionPath); + try + { + if (this.scope == PowerToysModule.PowerToysSettings) + { + runner?.Kill(); + runner?.WaitForExit(); // Optional: Wait for the process to exit + } + } + catch (Exception ex) + { + // Handle exceptions if needed + Console.WriteLine($"Exception during Cleanup: {ex.Message}"); + } } /// <summary> /// Restarts now exe and takes control of it. /// </summary> - public void RestartScopeExe() + public void RestartScopeExe(string? enableModules = null) { - ExitExe(sessionPath); - StartExe(locationPath + sessionPath); + ExitScopeExe(); + StartExe(locationPath + sessionPath, commandLineArgs, enableModules); } - public WindowsDriver<WindowsElement> GetRoot() => this.Root; + public WindowsDriver<WindowsElement> GetRoot() + { + return SessionHelper.root!; + } public WindowsDriver<WindowsElement> GetDriver() { Assert.IsNotNull(this.Driver, $"Failed to get driver. Driver is null."); return this.Driver; } + + private void StartWindowsAppDriverApp() + { + var winAppDriverProcessInfo = new ProcessStartInfo + { + FileName = "C:\\Program Files (x86)\\Windows Application Driver\\WinAppDriver.exe", + Verb = "runas", + }; + + this.ExitExe(winAppDriverProcessInfo.FileName); + SessionHelper.appDriver = Process.Start(winAppDriverProcessInfo); + } + + private void KillPowerToysProcesses() + { + var powerToysProcessNames = new[] { "PowerToys", "Microsoft.CmdPal.UI" }; + + foreach (var processName in powerToysProcessNames) + { + try + { + var processes = Process.GetProcessesByName(processName); + + foreach (var process in processes) + { + process.Kill(); + process.WaitForExit(); + } + + // Verify processes are actually gone + var remainingProcesses = Process.GetProcessesByName(processName); + } + catch (Exception ex) + { + Console.WriteLine($"[KillPowerToysProcesses] Failed to kill process {processName}: {ex.Message}"); + } + } + } } } diff --git a/src/common/UITestAutomation/SettingsConfigHelper.cs b/src/common/UITestAutomation/SettingsConfigHelper.cs new file mode 100644 index 0000000000..81e5e3c180 --- /dev/null +++ b/src/common/UITestAutomation/SettingsConfigHelper.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; + +namespace Microsoft.PowerToys.UITest +{ + /// <summary> + /// Helper class for configuring PowerToys settings for UI tests. + /// </summary> + public class SettingsConfigHelper + { + private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; + private static readonly SettingsUtils SettingsUtils = SettingsUtils.Default; + + /// <summary> + /// Configures global PowerToys settings to enable only specified modules and disable all others. + /// </summary> + /// <param name="modulesToEnable">Array of module names to enable (e.g., "Peek", "FancyZones"). All other modules will be disabled. If null or empty, all modules will be disabled.</param> + /// <exception cref="InvalidOperationException">Thrown when settings file operations fail.</exception> + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")] + public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable) + { + modulesToEnable ??= Array.Empty<string>(); + + try + { + GeneralSettings settings; + try + { + settings = SettingsUtils.GetSettingsOrDefault<GeneralSettings>(); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to load settings, creating defaults: {ex.Message}"); + settings = new GeneralSettings(); + } + + string settingsJson = settings.ToJsonString(); + using (JsonDocument doc = JsonDocument.Parse(settingsJson)) + { + var options = new JsonSerializerOptions { WriteIndented = true }; + var root = doc.RootElement.Clone(); + + if (root.TryGetProperty("enabled", out var enabledElement)) + { + var enabledModules = new Dictionary<string, bool>(); + + foreach (var property in enabledElement.EnumerateObject()) + { + string moduleName = property.Name; + + bool shouldEnable = Array.Exists(modulesToEnable, m => string.Equals(m, moduleName, StringComparison.Ordinal)); + enabledModules[moduleName] = shouldEnable; + } + + var settingsDict = JsonSerializer.Deserialize<Dictionary<string, object>>(settingsJson); + if (settingsDict != null) + { + settingsDict["enabled"] = enabledModules; + settingsJson = JsonSerializer.Serialize(settingsDict, IndentedJsonOptions); + } + } + } + + SettingsUtils.SaveSettings(settingsJson); + + string enabledList = modulesToEnable.Length > 0 ? string.Join(", ", modulesToEnable) : "none"; + Debug.WriteLine($"Successfully updated global settings"); + Debug.WriteLine($"Enabled modules: {enabledList}"); + } + catch (Exception ex) + { + Debug.WriteLine($"ERROR in ConfigureGlobalModuleSettings: {ex.Message}"); + throw new InvalidOperationException($"Failed to configure global module settings: {ex.Message}", ex); + } + } + + /// <summary> + /// Updates a module's settings file. If the file doesn't exist, creates it with default content. + /// If the file exists, reads it and applies the provided update function to modify the settings. + /// </summary> + /// <param name="moduleName">The name of the module (e.g., "Peek", "FancyZones").</param> + /// <param name="defaultSettingsContent">The default JSON content to use if the settings file doesn't exist.</param> + /// <param name="updateSettingsAction"> + /// A callback function that modifies the settings dictionary. The function receives the deserialized settings + /// and should modify it in-place. The function should accept a Dictionary<string, object> and not return a value. + /// Example: (settings) => { ((Dictionary<string, object>)settings["properties"])["SomeSetting"] = newValue; } + /// </param> + /// <exception cref="ArgumentNullException">Thrown when moduleName or updateSettingsAction is null.</exception> + /// <exception cref="InvalidOperationException">Thrown when settings file operations fail.</exception> + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This is test code and will not be trimmed")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This is test code and will not be AOT compiled")] + public static void UpdateModuleSettings( + string moduleName, + string defaultSettingsContent, + Action<Dictionary<string, object>> updateSettingsAction) + { + ArgumentNullException.ThrowIfNull(moduleName); + ArgumentNullException.ThrowIfNull(updateSettingsAction); + + try + { + // Build the path to the module settings file + string powerToysSettingsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys"); + + string moduleDirectory = Path.Combine(powerToysSettingsDirectory, moduleName); + string settingsPath = Path.Combine(moduleDirectory, "settings.json"); + + // Ensure directory exists + Directory.CreateDirectory(moduleDirectory); + + // Read existing settings or use default + string existingJson = string.Empty; + if (File.Exists(settingsPath)) + { + existingJson = File.ReadAllText(settingsPath); + } + + Dictionary<string, object>? settings; + + // If file doesn't exist or is empty, create from defaults + if (string.IsNullOrWhiteSpace(existingJson)) + { + if (string.IsNullOrWhiteSpace(defaultSettingsContent)) + { + throw new ArgumentException("Default settings content must be provided when file doesn't exist.", nameof(defaultSettingsContent)); + } + + settings = JsonSerializer.Deserialize<Dictionary<string, object>>(defaultSettingsContent) + ?? throw new InvalidOperationException($"Failed to deserialize default settings for {moduleName}"); + + Debug.WriteLine($"Created default settings for {moduleName} at {settingsPath}"); + } + else + { + // Parse existing settings + settings = JsonSerializer.Deserialize<Dictionary<string, object>>(existingJson) + ?? throw new InvalidOperationException($"Failed to deserialize existing settings for {moduleName}"); + + Debug.WriteLine($"Loaded existing settings for {moduleName} from {settingsPath}"); + } + + // Apply the update action to modify settings + updateSettingsAction(settings); + + // Serialize and save the updated settings using SettingsUtils + string updatedJson = JsonSerializer.Serialize(settings, IndentedJsonOptions); + SettingsUtils.SaveSettings(updatedJson, moduleName); + + Debug.WriteLine($"Successfully updated settings for {moduleName}"); + } + catch (Exception ex) + { + Debug.WriteLine($"ERROR in UpdateModuleSettings for {moduleName}: {ex.Message}"); + throw new InvalidOperationException($"Failed to update settings for {moduleName}: {ex.Message}", ex); + } + } + } +} diff --git a/src/common/UITestAutomation/UITestAutomation.csproj b/src/common/UITestAutomation/UITestAutomation.csproj index f351bd72a5..59310e79c0 100644 --- a/src/common/UITestAutomation/UITestAutomation.csproj +++ b/src/common/UITestAutomation/UITestAutomation.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <OutputType>Library</OutputType> @@ -8,6 +8,9 @@ <Nullable>enable</Nullable> <PublishAot>true</PublishAot> <InvariantGlobalization>true</InvariantGlobalization> + <TargetFramework>net9.0-windows10.0.26100.0</TargetFramework> + <UseWindowsForms>true</UseWindowsForms> + <PublishTrimmed>false</PublishTrimmed> </PropertyGroup> <ItemGroup> @@ -15,6 +18,12 @@ <!-- Test libraries/utilities should not use the metapackage. --> <PackageReference Include="MSTest.TestFramework" /> <PackageReference Include="System.IO.Abstractions" /> + <PackageReference Include="System.Text.RegularExpressions" /> + <PackageReference Include="CoenM.ImageSharp.ImageHash" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> </ItemGroup> </Project> diff --git a/src/common/UITestAutomation/UITestBase.cs b/src/common/UITestAutomation/UITestBase.cs index 40de0dc991..877f384104 100644 --- a/src/common/UITestAutomation/UITestBase.cs +++ b/src/common/UITestAutomation/UITestBase.cs @@ -4,12 +4,11 @@ using System.Collections.ObjectModel; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using System.Xml.Linq; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; using Microsoft.VisualStudio.TestTools.UnitTesting; -using OpenQA.Selenium.Appium; -using OpenQA.Selenium.Appium.Windows; +using OpenQA.Selenium; namespace Microsoft.PowerToys.UITest { @@ -17,19 +16,46 @@ namespace Microsoft.PowerToys.UITest /// Base class that should be inherited by all Test Classes. /// </summary> [TestClass] - public class UITestBase + public class UITestBase : IDisposable { - public Session Session { get; set; } + public required TestContext TestContext { get; set; } - private readonly SessionHelper sessionHelper; + public required Session Session { get; set; } + + /// <summary> + /// Gets a value indicating whether the tests are running in a CI/CD pipeline. + /// </summary> + public bool IsInPipeline { get; } + + public string? ScreenshotDirectory { get; set; } + + public string? RecordingDirectory { get; set; } + + public static MonitorInfoData.ParamsWrapper MonitorInfoData { get; set; } = new MonitorInfoData.ParamsWrapper() { Monitors = new List<MonitorInfoData.MonitorInfoDataWrapper>() }; private readonly PowerToysModule scope; + private readonly WindowSize size; + private readonly string[]? commandLineArgs; + private SessionHelper? sessionHelper; + private System.Threading.Timer? screenshotTimer; + private ScreenRecording? screenRecording; - public UITestBase(PowerToysModule scope = PowerToysModule.PowerToysSettings) + public UITestBase(PowerToysModule scope = PowerToysModule.PowerToysSettings, WindowSize size = WindowSize.UnSpecified, string[]? commandLineArgs = null) { + this.IsInPipeline = EnvironmentConfig.IsInPipeline; + Console.WriteLine($"Running tests on platform: {EnvironmentConfig.Platform}"); + if (IsInPipeline) + { + NativeMethods.ChangeDisplayResolution(1920, 1080); + NativeMethods.GetMonitorInfo(); + + // Escape Popups before starting + System.Windows.Forms.SendKeys.SendWait("{ESC}"); + } + this.scope = scope; - this.sessionHelper = new SessionHelper(scope).Init(); - this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver()); + this.size = size; + this.commandLineArgs = commandLineArgs; } /// <summary> @@ -38,24 +64,97 @@ namespace Microsoft.PowerToys.UITest [TestInitialize] public void TestInit() { - if (this.scope == PowerToysModule.PowerToysSettings) + KeyboardHelper.SendKeys(Key.Win, Key.M); + CloseOtherApplications(); + if (IsInPipeline) { - // close Debug warning dialog if any - // Such debug warning dialog seems only appear in PowerToys Settings - if (this.FindAll("DEBUG").Count > 0) + string baseDirectory = this.TestContext.TestResultsDirectory ?? string.Empty; + ScreenshotDirectory = Path.Combine(baseDirectory, "UITestScreenshots_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(ScreenshotDirectory); + + RecordingDirectory = Path.Combine(baseDirectory, "UITestRecordings_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(RecordingDirectory); + + // Take screenshot every 1 second + screenshotTimer = new System.Threading.Timer(ScreenCapture.TimerCallback, ScreenshotDirectory, TimeSpan.Zero, TimeSpan.FromMilliseconds(1000)); + + // Start screen recording (requires FFmpeg) + try { - this.Find("DEBUG").Find<Button>("Close").Click(); + screenRecording = new ScreenRecording(RecordingDirectory); + if (screenRecording.IsAvailable) + { + _ = screenRecording.StartRecordingAsync(); + } + else + { + screenRecording = null; + } } + catch (Exception ex) + { + Console.WriteLine($"Failed to start screen recording: {ex.Message}"); + screenRecording = null; + } + + // Escape Popups before starting + System.Windows.Forms.SendKeys.SendWait("{ESC}"); } + + this.sessionHelper = new SessionHelper(scope, commandLineArgs).Init(); + this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver(), scope, size); } /// <summary> - /// UnInitializes the test. + /// Cleanups the test. /// </summary> [TestCleanup] - public void TestClean() + public void TestCleanup() { - this.sessionHelper.Cleanup(); + if (IsInPipeline) + { + screenshotTimer?.Change(Timeout.Infinite, Timeout.Infinite); + + // Stop screen recording + if (screenRecording != null) + { + try + { + screenRecording.StopRecordingAsync().GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to stop screen recording: {ex.Message}"); + } + } + + if (TestContext.CurrentTestOutcome is UnitTestOutcome.Failed + or UnitTestOutcome.Error + or UnitTestOutcome.Unknown) + { + Task.Delay(1000).Wait(); + AddScreenShotsToTestResultsDirectory(); + AddRecordingsToTestResultsDirectory(); + AddLogFilesToTestResultsDirectory(); + } + else + { + // Clean up recording if test passed + CleanupRecordingDirectory(); + } + + Dispose(); + } + + this.Session.Cleanup(); + this.sessionHelper!.Cleanup(); + } + + public void Dispose() + { + screenshotTimer?.Dispose(); + screenRecording?.Dispose(); + GC.SuppressFinalize(this); } /// <summary> @@ -64,47 +163,311 @@ namespace Microsoft.PowerToys.UITest /// </summary> /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> /// <param name="by">The selector to find the element.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>The found element.</returns> - protected T Find<T>(By by, int timeoutMS = 3000) + protected T Find<T>(By by, int timeoutMS = 5000, bool global = false) where T : Element, new() { - return this.Session.Find<T>(by, timeoutMS); + return this.Session.Find<T>(by, timeoutMS, global); } /// <summary> - /// Shortcut for this.Session.Find<Element>(By.Name(name), timeoutMS) + /// Shortcut for this.Session.Find<Element>(name, timeoutMS) /// </summary> /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> /// <param name="name">The name of the element.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>The found element.</returns> - protected T Find<T>(string name, int timeoutMS = 3000) + protected T Find<T>(string name, int timeoutMS = 5000, bool global = false) where T : Element, new() { - return this.Session.Find<T>(By.Name(name), timeoutMS); + return this.Session.Find<T>(By.Name(name), timeoutMS, global); } /// <summary> /// Shortcut for this.Session.Find<Element>(by, timeoutMS) /// </summary> /// <param name="by">The selector to find the element.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>The found element.</returns> - protected Element Find(By by, int timeoutMS = 3000) + protected Element Find(By by, int timeoutMS = 5000, bool global = false) { - return this.Session.Find(by, timeoutMS); + return this.Session.Find(by, timeoutMS, global); } /// <summary> - /// Shortcut for this.Session.Find<Element>(By.Name(name), timeoutMS) + /// Shortcut for this.Session.Find<Element>(name, timeoutMS) /// </summary> /// <param name="name">The name of the element.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>The found element.</returns> - protected Element Find(string name, int timeoutMS = 3000) + protected Element Find(string name, int timeoutMS = 5000, bool global = false) { - return this.Session.Find(name, timeoutMS); + return this.Session.Find(name, timeoutMS, global); + } + + /// <summary> + /// Has only one Element or its derived class by selector. + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="by">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if only has one element; otherwise, false.</returns> + public bool HasOne<T>(By by, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return this.FindAll<T>(by, timeoutMS, global).Count == 1; + } + + /// <summary> + /// Shortcut for this.Session.HasOne<Element>(by, timeoutMS) + /// </summary> + /// <param name="by">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if only has one element; otherwise, false.</returns> + public bool HasOne(By by, int timeoutMS = 5000, bool global = false) + { + return this.Session.HasOne<Element>(by, timeoutMS, global); + } + + /// <summary> + /// Shortcut for this.Session.HasOne<T>(name, timeoutMS) + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="name">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if only has one element; otherwise, false.</returns> + public bool HasOne<T>(string name, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return this.Session.HasOne<T>(By.Name(name), timeoutMS, global); + } + + /// <summary> + /// Shortcut for this.Session.HasOne<Element>(name, timeoutMS) + /// </summary> + /// <param name="name">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if only has one element; otherwise, false.</returns> + public bool HasOne(string name, int timeoutMS = 5000, bool global = false) + { + return this.Session.HasOne<Element>(name, timeoutMS, global); + } + + /// <summary> + /// Shortcut for this.Session.Has<T>(by, timeoutMS) + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="by">The selector to find the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if has one or more element; otherwise, false.</returns> + public bool Has<T>(By by, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return this.Session.FindAll<T>(by, timeoutMS, global).Count >= 1; + } + + /// <summary> + /// Shortcut for this.Session.Has<Element>(by, timeoutMS) + /// </summary> + /// <param name="by">The selector to find the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if has one or more element; otherwise, false.</returns> + public bool Has(By by, int timeoutMS = 5000, bool global = false) + { + return this.Session.Has<Element>(by, timeoutMS, global); + } + + /// <summary> + /// Shortcut for this.Session.Has<T>(By.Name(name), timeoutMS) + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="name">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if has one or more element; otherwise, false.</returns> + public bool Has<T>(string name, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return this.Session.Has<T>(By.Name(name), timeoutMS, global); + } + + /// <summary> + /// Shortcut for this.Session.Has<Element>(name, timeoutMS) + /// </summary> + /// <param name="name">The name of the element.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>True if has one or more element; otherwise, false.</returns> + public bool Has(string name, int timeoutMS = 5000, bool global = false) + { + return this.Session.Has<Element>(name, timeoutMS, global); + } + + /// <summary> + /// Finds an element using partial name matching (contains). + /// Useful for finding windows with variable titles like "filename.txt - Notepad" or "filename - Notepad". + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="partialName">Part of the name to search for.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found element.</returns> + protected T FindByPartialName<T>(string partialName, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return Session.Find<T>(By.XPath($"//*[contains(@Name, '{partialName}')]"), timeoutMS, global); + } + + /// <summary> + /// Finds an element using partial name matching (contains). + /// </summary> + /// <param name="partialName">Part of the name to search for.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found element.</returns> + protected Element FindByPartialName(string partialName, int timeoutMS = 5000, bool global = false) + { + return FindByPartialName<Element>(partialName, timeoutMS, global); + } + + /// <summary> + /// Base method for finding elements by selector and filtering by name pattern. + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="selector">The selector to find initial candidates.</param> + /// <param name="namePattern">Pattern to match against the Name attribute. Supports regex patterns.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <param name="errorMessage">Custom error message when no element is found.</param> + /// <returns>The found element.</returns> + private T FindByNamePattern<T>(By selector, string namePattern, int timeoutMS = 5000, bool global = false, string? errorMessage = null) + where T : Element, new() + { + var elements = Session.FindAll<T>(selector, timeoutMS, global); + var regex = new Regex(namePattern, RegexOptions.IgnoreCase); + + foreach (var element in elements) + { + var name = element.GetAttribute("Name"); + if (!string.IsNullOrEmpty(name) && regex.IsMatch(name)) + { + return element; + } + } + + throw new NoSuchElementException(errorMessage ?? $"No element found matching pattern: {namePattern}"); + } + + /// <summary> + /// Finds an element using regular expression pattern matching. + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="pattern">Regular expression pattern to match against the Name attribute.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found element.</returns> + protected T FindByPattern<T>(string pattern, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return FindByNamePattern<T>(By.XPath("//*[@Name]"), pattern, timeoutMS, global, $"No element found matching pattern: {pattern}"); + } + + /// <summary> + /// Finds an element using regular expression pattern matching. + /// </summary> + /// <param name="pattern">Regular expression pattern to match against the Name attribute.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found element.</returns> + protected Element FindByPattern(string pattern, int timeoutMS = 5000, bool global = false) + { + return FindByPattern<Element>(pattern, timeoutMS, global); + } + + /// <summary> + /// Finds an element by ClassName only. + /// Returns the first element found with the specified ClassName. + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="className">The ClassName to search for (e.g., "Notepad", "CabinetWClass").</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found element.</returns> + protected T FindByClassName<T>(string className, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return Session.Find<T>(By.ClassName(className), timeoutMS, global); + } + + /// <summary> + /// Finds an element by ClassName only. + /// Returns the first element found with the specified ClassName. + /// </summary> + /// <param name="className">The ClassName to search for (e.g., "Notepad", "CabinetWClass").</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found element.</returns> + protected Element FindByClassName(string className, int timeoutMS = 5000, bool global = false) + { + return FindByClassName<Element>(className, timeoutMS, global); + } + + /// <summary> + /// Finds an element by ClassName and matches its Name attribute using regex pattern matching. + /// </summary> + /// <typeparam name="T">The class of the element, should be Element or its derived class.</typeparam> + /// <param name="className">The ClassName to search for (e.g., "Notepad", "CabinetWClass").</param> + /// <param name="namePattern">Pattern to match against the Name attribute. Supports regex patterns.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found element.</returns> + protected T FindByClassNameAndNamePattern<T>(string className, string namePattern, int timeoutMS = 5000, bool global = false) + where T : Element, new() + { + return FindByNamePattern<T>(By.ClassName(className), namePattern, timeoutMS, global, $"No element with ClassName '{className}' found matching name pattern: {namePattern}"); + } + + /// <summary> + /// Finds an element by ClassName and matches its Name attribute using regex pattern matching. + /// </summary> + /// <param name="className">The ClassName to search for (e.g., "Notepad", "CabinetWClass").</param> + /// <param name="namePattern">Pattern to match against the Name attribute. Supports regex patterns.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found element.</returns> + protected Element FindByClassNameAndNamePattern(string className, string namePattern, int timeoutMS = 5000, bool global = false) + { + return FindByClassNameAndNamePattern<Element>(className, namePattern, timeoutMS, global); + } + + /// <summary> + /// Finds a Notepad window regardless of whether the file extension is shown in the title. + /// Handles both "filename.txt - Notepad" and "filename - Notepad" formats. + /// Uses ClassName to efficiently find Notepad windows first, then matches the filename. + /// </summary> + /// <param name="baseFileName">The base filename without extension (e.g., "test" for "test.txt").</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found Notepad window element.</returns> + protected Element FindNotepadWindow(string baseFileName, int timeoutMS = 5000, bool global = false) + { + string pattern = $@"^{Regex.Escape(baseFileName)}(\.\w+)?(\s*-\s*|\s+)Notepad$"; + return FindByClassNameAndNamePattern("Notepad", pattern, timeoutMS, global); + } + + /// <summary> + /// Finds an Explorer window regardless of the folder or file name display format. + /// Handles various Explorer window title formats like "FolderName", "FileName", "FolderName - File Explorer", etc. + /// Uses ClassName to efficiently find Explorer windows first, then matches the folder or file name. + /// </summary> + /// <param name="folderName">The folder or file name to search for (e.g., "Documents", "Desktop", "test.txt").</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found Explorer window element.</returns> + protected Element FindExplorerWindow(string folderName, int timeoutMS = 5000, bool global = false) + { + string pattern = $@"^{Regex.Escape(folderName)}(\s*-\s*(File\s+Explorer|Windows\s+Explorer))?$"; + return FindByClassNameAndNamePattern("CabinetWClass", pattern, timeoutMS, global); + } + + /// <summary> + /// Finds an Explorer window by partial folder path. + /// Useful when the full path might be displayed in the title. + /// </summary> + /// <param name="partialPath">Part of the folder path to search for.</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> + /// <returns>The found Explorer window element.</returns> + protected Element FindExplorerByPartialPath(string partialPath, int timeoutMS = 5000, bool global = false) + { + return FindByPartialName(partialPath, timeoutMS, global); } /// <summary> @@ -113,12 +476,12 @@ namespace Microsoft.PowerToys.UITest /// </summary> /// <typeparam name="T">The class of the elements, should be Element or its derived class.</typeparam> /// <param name="by">The selector to find the elements.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>A read-only collection of the found elements.</returns> - protected ReadOnlyCollection<T> FindAll<T>(By by, int timeoutMS = 3000) + protected ReadOnlyCollection<T> FindAll<T>(By by, int timeoutMS = 5000, bool global = false) where T : Element, new() { - return this.Session.FindAll<T>(by, timeoutMS); + return this.Session.FindAll<T>(by, timeoutMS, global); } /// <summary> @@ -127,12 +490,12 @@ namespace Microsoft.PowerToys.UITest /// </summary> /// <typeparam name="T">The class of the elements, should be Element or its derived class.</typeparam> /// <param name="name">The name of the elements.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>A read-only collection of the found elements.</returns> - protected ReadOnlyCollection<T> FindAll<T>(string name, int timeoutMS = 3000) + protected ReadOnlyCollection<T> FindAll<T>(string name, int timeoutMS = 5000, bool global = false) where T : Element, new() { - return this.Session.FindAll<T>(By.Name(name), timeoutMS); + return this.Session.FindAll<T>(By.Name(name), timeoutMS, global); } /// <summary> @@ -140,11 +503,11 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.Session.FindAll<Element>(by, timeoutMS) /// </summary> /// <param name="by">The selector to find the elements.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>A read-only collection of the found elements.</returns> - protected ReadOnlyCollection<Element> FindAll(By by, int timeoutMS = 3000) + protected ReadOnlyCollection<Element> FindAll(By by, int timeoutMS = 5000, bool global = false) { - return this.Session.FindAll<Element>(by, timeoutMS); + return this.Session.FindAll<Element>(by, timeoutMS, global); } /// <summary> @@ -152,21 +515,274 @@ namespace Microsoft.PowerToys.UITest /// Shortcut for this.Session.FindAll<Element>(By.Name(name), timeoutMS) /// </summary> /// <param name="name">The name of the elements.</param> - /// <param name="timeoutMS">The timeout in milliseconds (default is 3000).</param> + /// <param name="timeoutMS">The timeout in milliseconds (default is 5000).</param> /// <returns>A read-only collection of the found elements.</returns> - protected ReadOnlyCollection<Element> FindAll(string name, int timeoutMS = 3000) + protected ReadOnlyCollection<Element> FindAll(string name, int timeoutMS = 5000, bool global = false) { - return this.Session.FindAll<Element>(By.Name(name), timeoutMS); + return this.Session.FindAll<Element>(By.Name(name), timeoutMS, global); + } + + /// <summary> + /// Scrolls the page + /// </summary> + /// <param name="scrollCount">The number of scroll attempts.</param> + /// <param name="direction">The direction to scroll.</param> + /// <param name="msPreAction">Pre-action delay in milliseconds.</param> + /// <param name="msPostAction">Post-action delay in milliseconds.</param> + public void Scroll(int scrollCount = 5, string direction = "Up", int msPreAction = 500, int msPostAction = 500) + { + MouseActionType mouseAction = direction == "Up" ? MouseActionType.ScrollUp : MouseActionType.ScrollDown; + for (int i = 0; i < scrollCount; i++) + { + Session.PerformMouseAction(mouseAction, msPreAction, msPostAction); // Ensure settings are visible + } + } + + /// <summary> + /// Captures the last screenshot when the test fails. + /// </summary> + protected void CaptureLastScreenshot() + { + // Implement your screenshot capture logic here + // For example, save a screenshot to a file and return the file path + string screenshotPath = Path.Combine(this.TestContext.TestResultsDirectory ?? string.Empty, "last_screenshot.png"); + + this.Session.Root.GetScreenshot().SaveAsFile(screenshotPath, ScreenshotImageFormat.Png); + + // Save screenshot to screenshotPath & upload to test attachment + this.TestContext.AddResultFile(screenshotPath); + } + + /// <summary> + /// Retrieves the color of the pixel at the specified screen coordinates. + /// </summary> + /// <param name="x">The X coordinate on the screen.</param> + /// <param name="y">The Y coordinate on the screen.</param> + /// <returns>The color of the pixel at the specified coordinates.</returns> + public Color GetPixelColor(int x, int y) + { + return WindowHelper.GetPixelColor(x, y); + } + + /// <summary> + /// Retrieves the color of the pixel at the specified screen coordinates as a string. + /// </summary> + /// <param name="x">The X coordinate on the screen.</param> + /// <param name="y">The Y coordinate on the screen.</param> + /// <returns>The color of the pixel at the specified coordinates.</returns> + public string GetPixelColorString(int x, int y) + { + return WindowHelper.GetPixelColorString(x, y); + } + + /// <summary> + /// Gets the size of the display. + /// </summary> + /// <returns> + /// A tuple containing the width and height of the display. + /// </returns + public Tuple<int, int> GetDisplaySize() + { + return WindowHelper.GetDisplaySize(); + } + + /// <summary> + /// Sends a combination of keys. + /// </summary> + /// <param name="keys">The keys to send.</param> + public void SendKeys(params Key[] keys) + { + this.Session.SendKeys(keys); + } + + /// <summary> + /// Sends a sequence of keys. + /// </summary> + /// <param name="keys">An array of keys to send.</param> + public void SendKeySequence(params Key[] keys) + { + this.Session.SendKeySequence(keys); + } + + /// <summary> +        /// Gets the current position of the mouse cursor as a tuple. +        /// </summary> +        /// <returns>A tuple containing the X and Y coordinates of the cursor.</returns> + public Tuple<int, int> GetMousePosition() + { + return this.Session.GetMousePosition(); + } + + /// <summary> + /// Gets the screen center coordinates. + /// </summary> + /// <returns>(x, y)</returns> + public (int CenterX, int CenterY) GetScreenCenter() + { + return WindowHelper.GetScreenCenter(); + } + + public bool IsWindowOpen(string windowName) + { + return WindowHelper.IsWindowOpen(windowName); + } + + /// <summary> +    /// Moves the mouse cursor to the specified screen coordinates. +    /// </summary> +    /// <param name="x">The new x-coordinate of the cursor.</param> +    /// <param name="y">The new y-coordinate of the cursor.</param + public void MoveMouseTo(int x, int y) + { +        this.Session.MoveMouseTo(x, y); + } + + protected void AddScreenShotsToTestResultsDirectory() + { + if (ScreenshotDirectory != null) + { + foreach (string file in Directory.GetFiles(ScreenshotDirectory)) + { + this.TestContext.AddResultFile(file); + } + } + } + + /// <summary> + /// Adds screen recordings to test results directory when test fails. + /// </summary> + protected void AddRecordingsToTestResultsDirectory() + { + if (RecordingDirectory != null && Directory.Exists(RecordingDirectory)) + { + // Add video files (MP4) + var videoFiles = Directory.GetFiles(RecordingDirectory, "*.mp4"); + foreach (string file in videoFiles) + { + this.TestContext.AddResultFile(file); + var fileInfo = new FileInfo(file); + Console.WriteLine($"Added video recording: {Path.GetFileName(file)} ({fileInfo.Length / 1024 / 1024:F1} MB)"); + } + + if (videoFiles.Length == 0) + { + Console.WriteLine("No video recording available (FFmpeg not found). Screenshots are still captured."); + } + } + } + + /// <summary> + /// Cleans up recording directory when test passes. + /// </summary> + private void CleanupRecordingDirectory() + { + if (RecordingDirectory != null && Directory.Exists(RecordingDirectory)) + { + try + { + Directory.Delete(RecordingDirectory, true); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to cleanup recording directory: {ex.Message}"); + } + } + } + + /// <summary> + /// Copies PowerToys log files to test results directory when test fails. + /// Renames files to include the directory structure after \PowerToys. + /// </summary> + protected void AddLogFilesToTestResultsDirectory() + { + try + { + var localAppDataLow = Path.Combine( + Environment.GetEnvironmentVariable("USERPROFILE") ?? string.Empty, + "AppData", + "LocalLow", + "Microsoft", + "PowerToys"); + + if (Directory.Exists(localAppDataLow)) + { + CopyLogFilesFromDirectory(localAppDataLow, string.Empty); + } + + var localAppData = Path.Combine( + Environment.GetEnvironmentVariable("LOCALAPPDATA") ?? string.Empty, + "Microsoft", + "PowerToys"); + + if (Directory.Exists(localAppData)) + { + CopyLogFilesFromDirectory(localAppData, string.Empty); + } + } + catch (Exception ex) + { + // Don't fail the test if log file copying fails + Console.WriteLine($"Failed to copy log files: {ex.Message}"); + } + } + + /// <summary> + /// Recursively copies log files from a directory and renames them with directory structure. + /// </summary> + /// <param name="sourceDir">Source directory to copy from</param> + /// <param name="relativePath">Relative path from PowerToys folder</param> + private void CopyLogFilesFromDirectory(string sourceDir, string relativePath) + { + if (!Directory.Exists(sourceDir)) + { + return; + } + + // Process log files in current directory + var logFiles = Directory.GetFiles(sourceDir, "*.log"); + foreach (var logFile in logFiles) + { + try + { + var fileName = Path.GetFileName(logFile); + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName); + var extension = Path.GetExtension(fileName); + + // Create new filename with directory structure + var directoryPart = string.IsNullOrEmpty(relativePath) ? string.Empty : relativePath.Replace("\\", "-") + "-"; + var newFileName = $"{directoryPart}{fileNameWithoutExt}{extension}"; + + // Copy file to test results directory with new name + var testResultsDir = TestContext.TestResultsDirectory ?? Path.GetTempPath(); + var destinationPath = Path.Combine(testResultsDir, newFileName); + + File.Copy(logFile, destinationPath, true); + TestContext.AddResultFile(destinationPath); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to copy log file {logFile}: {ex.Message}"); + } + } + + // Recursively process subdirectories + var subdirectories = Directory.GetDirectories(sourceDir); + foreach (var subdir in subdirectories) + { + var dirName = Path.GetFileName(subdir); + var newRelativePath = string.IsNullOrEmpty(relativePath) ? dirName : Path.Combine(relativePath, dirName); + CopyLogFilesFromDirectory(subdir, newRelativePath); + } } /// <summary> /// Restart scope exe. /// </summary> - public void RestartScopeExe() + public Session RestartScopeExe(string? enableModules = null) { - this.sessionHelper.RestartScopeExe(); - this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver()); - return; + this.sessionHelper!.RestartScopeExe(enableModules); + this.Session = new Session(this.sessionHelper.GetRoot(), this.sessionHelper.GetDriver(), this.scope, this.size); + return Session; } /// <summary> @@ -174,8 +790,211 @@ namespace Microsoft.PowerToys.UITest /// </summary> public void ExitScopeExe() { - this.sessionHelper.ExitScopeExe(); + this.sessionHelper!.ExitScopeExe(); return; } + + private void CloseOtherApplications() + { + // Close other applications + var processNamesToClose = new List<string> + { + "PowerToys", + "PowerToys.Settings", + "PowerToys.FancyZonesEditor", + }; + foreach (var processName in processNamesToClose) + { + foreach (var process in Process.GetProcessesByName(processName)) + { + process.Kill(); + process.WaitForExit(); + } + } + } + + public class NativeMethods + { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] + public struct DISPLAY_DEVICE + { + public int cb; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string DeviceName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string DeviceString; + public int StateFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string DeviceID; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] + public string DeviceKey; + } + + [DllImport("user32.dll")] + private static extern int EnumDisplaySettings(IntPtr deviceName, int modeNum, ref DEVMODE devMode); + + [DllImport("user32.dll")] + private static extern int EnumDisplaySettings(string deviceName, int modeNum, ref DEVMODE devMode); + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + private static extern bool EnumDisplayDevices(IntPtr lpDevice, int iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, int dwFlags); + + [DllImport("user32.dll")] + private static extern int ChangeDisplaySettings(ref DEVMODE devMode, int flags); + + [DllImport("user32.dll", CharSet = CharSet.Ansi)] + private static extern int ChangeDisplaySettingsEx(IntPtr lpszDeviceName, ref DEVMODE lpDevMode, IntPtr hwnd, uint dwflags, IntPtr lParam); + + private const int DM_PELSWIDTH = 0x80000; + private const int DM_PELSHEIGHT = 0x100000; + + public const int ENUM_CURRENT_SETTINGS = -1; + public const int CDS_TEST = 0x00000002; + public const int CDS_UPDATEREGISTRY = 0x01; + public const int DISP_CHANGE_SUCCESSFUL = 0; + public const int DISP_CHANGE_RESTART = 1; + public const int DISP_CHANGE_FAILED = -1; + + [StructLayout(LayoutKind.Sequential)] + public struct DEVMODE + { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string DmDeviceName; + public short DmSpecVersion; + public short DmDriverVersion; + public short DmSize; + public short DmDriverExtra; + public int DmFields; + public int DmPositionX; + public int DmPositionY; + public int DmDisplayOrientation; + public int DmDisplayFixedOutput; + public short DmColor; + public short DmDuplex; + public short DmYResolution; + public short DmTTOption; + public short DmCollate; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string DmFormName; + public short DmLogPixels; + public int DmBitsPerPel; + public int DmPelsWidth; + public int DmPelsHeight; + public int DmDisplayFlags; + public int DmDisplayFrequency; + public int DmICMMethod; + public int DmICMIntent; + public int DmMediaType; + public int DmDitherType; + public int DmReserved1; + public int DmReserved2; + public int DmPanningWidth; + public int DmPanningHeight; + } + + public static void GetMonitorInfo() + { + int deviceIndex = 0; + DISPLAY_DEVICE d = default(DISPLAY_DEVICE); + d.cb = Marshal.SizeOf(d); + + Console.WriteLine("monitor list :"); + while (EnumDisplayDevices(IntPtr.Zero, deviceIndex, ref d, 0)) + { + Console.WriteLine($"monitor {deviceIndex + 1}:"); + Console.WriteLine($" name: {d.DeviceName}"); + Console.WriteLine($"  string: {d.DeviceString}"); + Console.WriteLine($"  ID: {d.DeviceID}"); + Console.WriteLine($"  key: {d.DeviceKey}"); + Console.WriteLine(); + + DEVMODE dm = default(DEVMODE); + dm.DmSize = (short)Marshal.SizeOf<DEVMODE>(); + int modeNum = 0; + while (EnumDisplaySettings(d.DeviceName, modeNum, ref dm) > 0) + { + MonitorInfoData.Monitors.Add(new MonitorInfoData.MonitorInfoDataWrapper() + { + DeviceName = d.DeviceName, + DeviceString = d.DeviceString, + DeviceID = d.DeviceID, + DeviceKey = d.DeviceKey, + PelsWidth = dm.DmPelsWidth, + PelsHeight = dm.DmPelsHeight, + DisplayFrequency = dm.DmDisplayFrequency, + }); + Console.WriteLine($"  mode {modeNum}: {dm.DmPelsWidth}x{dm.DmPelsHeight} @ {dm.DmDisplayFrequency}Hz"); + modeNum++; + } + + deviceIndex++; + d.cb = Marshal.SizeOf(d); // Reset the size for the next device + } + } + + public static void ChangeDisplayResolution(int PelsWidth, int PelsHeight) + { + Screen screen = Screen.PrimaryScreen!; + if (screen.Bounds.Width == PelsWidth && screen.Bounds.Height == PelsHeight) + { + return; + } + + DEVMODE devMode = default(DEVMODE); + devMode.DmDeviceName = new string(new char[32]); + devMode.DmFormName = new string(new char[32]); + devMode.DmSize = (short)Marshal.SizeOf<DEVMODE>(); + + int modeNum = 0; + while (EnumDisplaySettings(IntPtr.Zero, modeNum, ref devMode) > 0) + { + Console.WriteLine($"Mode {modeNum}: {devMode.DmPelsWidth}x{devMode.DmPelsHeight} @ {devMode.DmDisplayFrequency}Hz"); + modeNum++; + } + + devMode.DmPelsWidth = PelsWidth; + devMode.DmPelsHeight = PelsHeight; + + int result = NativeMethods.ChangeDisplaySettings(ref devMode, NativeMethods.CDS_TEST); + + if (result == DISP_CHANGE_SUCCESSFUL) + { + result = ChangeDisplaySettings(ref devMode, CDS_UPDATEREGISTRY); + if (result == DISP_CHANGE_SUCCESSFUL) + { + Console.WriteLine($"Changing display resolution to {devMode.DmPelsWidth}x{devMode.DmPelsHeight}"); + } + else + { + Console.WriteLine($"Failed to change display resolution. Error code: {result}"); + } + } + else if (result == DISP_CHANGE_RESTART) + { + Console.WriteLine($"Changing display resolution to {devMode.DmPelsWidth}x{devMode.DmPelsHeight} requires a restart"); + } + else + { + Console.WriteLine($"Failed to change display resolution. Error code: {result}"); + } + } + + // Windows API for moving windows + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + + private const uint SWPNOSIZE = 0x0001; + private const uint SWPNOZORDER = 0x0004; + + public static void MoveWindow(Element window, int x, int y) + { + var windowHandle = IntPtr.Parse(window.GetAttribute("NativeWindowHandle") ?? "0", System.Globalization.CultureInfo.InvariantCulture); + if (windowHandle != IntPtr.Zero) + { + SetWindowPos(windowHandle, IntPtr.Zero, x, y, 0, 0, SWPNOSIZE | SWPNOZORDER); + Task.Delay(500).Wait(); + } + } + } } } diff --git a/src/common/UITestAutomation/VisualAssert.cs b/src/common/UITestAutomation/VisualAssert.cs new file mode 100644 index 0000000000..f08ba780d9 --- /dev/null +++ b/src/common/UITestAutomation/VisualAssert.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.IO; +using CoenM.ImageHash; +using CoenM.ImageHash.HashAlgorithms; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Microsoft.PowerToys.UITest +{ + public static class VisualAssert + { + /// <summary> + /// Asserts current visual state of the element is equal with base line image. + /// To use this VisualAssert, you need to set Window Theme to Light-Mode to avoid Theme color difference in baseline image. + /// Such limitation could be removed either Auto-generate baseline image for both Light & Dark mode + /// </summary> + /// <param name="testContext">TestContext object</param> + /// <param name="element">Element object</param> + /// <param name="scenarioSubname">additional scenario name if two or more scenarios in one test</param> + [RequiresUnreferencedCode("This method uses reflection which may not be compatible with trimming.")] + public static void AreEqual(TestContext? testContext, Element element, string scenarioSubname = "") + { + // Perform visual validation only in the pipeline + if (!EnvironmentConfig.IsInPipeline) + { + Console.WriteLine("Skip visual validation in the local run."); + return; + } + + if (element == null) + { + Assert.Fail("Element object is null or invalid"); + } + + var stackTrace = new StackTrace(); + var callerFrame = stackTrace.GetFrame(1); + var callerMethod = callerFrame?.GetMethod(); + + var callerName = callerMethod?.Name; + var callerClassName = callerMethod?.DeclaringType?.Name; + + if (string.IsNullOrEmpty(callerName) || string.IsNullOrEmpty(callerClassName)) + { + Assert.Fail("Unable to determine the caller method and class name."); + } + + if (string.IsNullOrWhiteSpace(scenarioSubname)) + { + scenarioSubname = string.Join("_", callerClassName, callerName, EnvironmentConfig.Platform); + } + else + { + scenarioSubname = string.Join("_", callerClassName, callerName, scenarioSubname.Trim(), EnvironmentConfig.Platform); + } + + var baselineImageResourceName = callerMethod!.DeclaringType!.Assembly.GetManifestResourceNames().Where(name => name.Contains(scenarioSubname)).FirstOrDefault(); + + var tempTestImagePath = GetTempFilePath(scenarioSubname, "test", ".png"); + + element.SaveToPngFile(tempTestImagePath); + + if (string.IsNullOrEmpty(baselineImageResourceName) + || !Path.GetFileNameWithoutExtension(baselineImageResourceName).EndsWith(scenarioSubname)) + { + testContext?.AddResultFile(tempTestImagePath); + Assert.Fail($"Baseline image for scenario {scenarioSubname} can't be found, test image saved in file://{tempTestImagePath.Replace('\\', '/')}"); + } + + var tempBaselineImagePath = GetTempFilePath(scenarioSubname, "baseline", Path.GetExtension(baselineImageResourceName)); + + bool isSame = false; + + using (var stream = callerMethod!.DeclaringType!.Assembly.GetManifestResourceStream(baselineImageResourceName)) + { + if (stream == null) + { + Assert.Fail($"Resource stream '{baselineImageResourceName}' is null."); + } + + using (var baselineImage = new Bitmap(stream)) + { + using (var testImage = new Bitmap(tempTestImagePath)) + { + isSame = VisualAssert.AreEqual(baselineImage, testImage); + + if (!isSame) + { + // Copy baseline image to temp folder as well + baselineImage.Save(tempBaselineImagePath); + } + } + } + } + + if (!isSame) + { + if (testContext != null) + { + testContext.AddResultFile(tempBaselineImagePath); + testContext.AddResultFile(tempTestImagePath); + } + + Assert.Fail($"Fail to validate visual result for scenario {scenarioSubname}, baseline image can be found file://{tempBaselineImagePath.Replace('\\', '/')}, and test image can be found file://{tempTestImagePath.Replace('\\', '/')}"); + } + } + + /// <summary> + /// Get temp file path + /// </summary> + /// <param name="scenario">scenario name</param> + /// <param name="imageType">baseline or test image</param> + /// <param name="extension">image file extension</param> + /// <returns>full temp file path</returns> + private static string GetTempFilePath(string scenario, string imageType, string extension) + { + var tempFileFullName = $"{scenario}_{imageType}{extension}"; + + // Remove invalid filename character if any + Path.GetInvalidFileNameChars().ToList().ForEach(c => tempFileFullName = tempFileFullName.Replace(c, '-')); + + return Path.Combine(Path.GetTempPath(), tempFileFullName); + } + + /// <summary> + /// Test if two images are equal using ImageHash comparison + /// </summary> + /// <param name="baselineImage">baseline image</param> + /// <param name="testImage">test image</param> + /// <returns>true if are equal,otherwise false</returns> + private static bool AreEqual(Bitmap baselineImage, Bitmap testImage) + { + try + { + // Define a threshold for similarity percentage + const int SimilarityThreshold = 95; + + // Use CoenM.ImageHash for perceptual hash comparison + var hashAlgorithm = new AverageHash(); + + // Convert System.Drawing.Bitmap to SixLabors.ImageSharp.Image + using var baselineImageSharp = ConvertBitmapToImageSharp(baselineImage); + using var testImageSharp = ConvertBitmapToImageSharp(testImage); + + // Calculate hashes for both images + var baselineHash = hashAlgorithm.Hash(baselineImageSharp); + var testHash = hashAlgorithm.Hash(testImageSharp); + + // Compare hashes using CompareHash method + // Returns similarity percentage (0-100, where 100 is identical) + var similarity = CompareHash.Similarity(baselineHash, testHash); + + // Consider images equal if similarity is very high + // Allow for minor rendering differences (threshold can be adjusted) + return similarity >= SimilarityThreshold; // 95% similarity threshold + } + catch + { + // Fallback to pixel-by-pixel comparison if hash comparison fails + if (baselineImage.Width != testImage.Width || baselineImage.Height != testImage.Height) + { + return false; + } + + // WinAppDriver sometimes adds a border to the screenshot (around 2 pix width), and it is not always consistent. + // So we exclude the border when comparing the images, and usually it is the edge of the windows, won't affect the comparison. + int excludeBorderWidth = 5, excludeBorderHeight = 5; + + for (int x = excludeBorderWidth; x < baselineImage.Width - excludeBorderWidth; x++) + { + for (int y = excludeBorderHeight; y < baselineImage.Height - excludeBorderHeight; y++) + { + if (!VisualHelper.PixIsSame(baselineImage.GetPixel(x, y), testImage.GetPixel(x, y))) + { + return false; + } + } + } + + return true; + } + } + + /// <summary> + /// Convert System.Drawing.Bitmap to SixLabors.ImageSharp.Image + /// </summary> + /// <param name="bitmap">The bitmap to convert</param> + /// <returns>ImageSharp Image</returns> + private static Image<Rgba32> ConvertBitmapToImageSharp(Bitmap bitmap) + { + using var memoryStream = new MemoryStream(); + bitmap.Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); + memoryStream.Position = 0; + return SixLabors.ImageSharp.Image.Load<Rgba32>(memoryStream); + } + } +} diff --git a/src/common/UITestAutomation/VisualHelper.cs b/src/common/UITestAutomation/VisualHelper.cs new file mode 100644 index 0000000000..c9da896c64 --- /dev/null +++ b/src/common/UITestAutomation/VisualHelper.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Imaging; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.PowerToys.UITest +{ + internal static class VisualHelper + { + /// <summary> + /// Compare two pixels with a fuzz factor + /// </summary> + /// <param name="c1">base color</param> + /// <param name="c2">test color</param> + /// <param name="fuzz">fuzz factor, default is 10</param> + /// <returns>true if same; otherwise, is false</returns> + public static bool PixIsSame(Color c1, Color c2, int fuzz = 10) + { + return Math.Abs(c1.A - c2.A) <= fuzz && Math.Abs(c1.R - c2.R) <= fuzz && Math.Abs(c1.G - c2.G) <= fuzz && Math.Abs(c1.B - c2.B) <= fuzz; + } + } +} diff --git a/src/common/UITestAutomation/WindowHelper.cs b/src/common/UITestAutomation/WindowHelper.cs new file mode 100644 index 0000000000..2030415ae9 --- /dev/null +++ b/src/common/UITestAutomation/WindowHelper.cs @@ -0,0 +1,331 @@ +// 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.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.UITest +{ + public static class WindowHelper + { + internal const string AdministratorPrefix = "Administrator: "; + + /// <summary> + /// Sets the main window size. + /// </summary> + /// <param name="size">WindowSize enum</param> + public static void SetWindowSize(IntPtr windowHandler, WindowSize size) + { + if (size == WindowSize.UnSpecified) + { + return; + } + + int width = 0, height = 0; + + switch (size) + { + case WindowSize.Small: + width = 640; + height = 480; + break; + case WindowSize.Small_Vertical: + width = 480; + height = 640; + break; + case WindowSize.Medium: + width = 1024; + height = 768; + break; + case WindowSize.Medium_Vertical: + width = 768; + height = 1024; + break; + case WindowSize.Large: + width = 1920; + height = 1080; + break; + case WindowSize.Large_Vertical: + width = 1080; + height = 1920; + break; + } + + if (width > 0 && height > 0) + { + WindowHelper.SetMainWindowSize(windowHandler, width, height); + } + } + + /// <summary> + /// Gets the main window center coordinates. + /// </summary> + /// <returns>(x, y)</returns> + public static (int CenterX, int CenterY) GetWindowCenter(IntPtr windowHandler) + { + if (windowHandler == IntPtr.Zero) + { + return (0, 0); + } + else + { + var rect = ApiHelper.GetWindowCenter(windowHandler); + return (rect.CenterX, rect.CenterY); + } + } + + /// <summary> + /// Gets the main window center coordinates. + /// </summary> + /// <returns>(x, y)</returns> + public static (int Left, int Top, int Right, int Bottom) GetWindowRect(IntPtr windowHandler) + { + if (windowHandler == IntPtr.Zero) + { + return (0, 0, 0, 0); + } + else + { + var rect = ApiHelper.GetWindowRect(windowHandler); + return (rect.Left, rect.Top, rect.Right, rect.Bottom); + } + } + + /// <summary> + /// Gets the screen center coordinates. + /// </summary> + /// <returns>(x, y)</returns> + public static (int CenterX, int CenterY) GetScreenCenter() + { + return ApiHelper.GetScreenCenter(); + } + + /// <summary> + /// Sets the main window size based on Width and Height. + /// </summary> + /// <param name="width">the width in pixel</param> + /// <param name="height">the height in pixel</param> + public static void SetMainWindowSize(IntPtr windowHandler, int width, int height) + { + if (windowHandler == IntPtr.Zero + || width <= 0 + || height <= 0) + { + return; + } + + ApiHelper.SetWindowPos(windowHandler, IntPtr.Zero, 0, 0, width, height, ApiHelper.SetWindowPosNoZorder | ApiHelper.SetWindowPosShowWindow); + + // Wait for 1000ms after resize + Task.Delay(1000).Wait(); + } + + /// <summary> + /// Retrieves the color of the pixel at the specified screen coordinates. + /// </summary> + /// <param name="x">The X coordinate on the screen.</param> + /// <param name="y">The Y coordinate on the screen.</param> + /// <returns>The color of the pixel at the specified coordinates.</returns> + public static Color GetPixelColor(int x, int y) + { + IntPtr hdc = ApiHelper.GetDC(IntPtr.Zero); + uint pixel = ApiHelper.GetPixel(hdc, x, y); + _ = ApiHelper.ReleaseDC(IntPtr.Zero, hdc); + + int r = (int)(pixel & 0x000000FF); + int g = (int)((pixel & 0x0000FF00) >> 8); + int b = (int)((pixel & 0x00FF0000) >> 16); + + return Color.FromArgb(r, g, b); + } + + /// <summary> + /// Retrieves the color of the pixel at the specified screen coordinates as a string. + /// </summary> + /// <param name="x">The X coordinate on the screen.</param> + /// <param name="y">The Y coordinate on the screen.</param> + /// <returns>The color of the pixel at the specified coordinates.</returns> + public static string GetPixelColorString(int x, int y) + { + Color color = WindowHelper.GetPixelColor(x, y); + return $"#{color.R:X2}{color.G:X2}{color.B:X2}"; + } + + /// <summary> + /// Gets the size of the display. + /// </summary> + /// <returns> + /// A tuple containing the width and height of the display. + /// </returns + public static Tuple<int, int> GetDisplaySize() + { + IntPtr hdc = ApiHelper.GetDC(IntPtr.Zero); + int screenWidth = ApiHelper.GetDeviceCaps(hdc, ApiHelper.DESKTOPHORZRES); + int screenHeight = ApiHelper.GetDeviceCaps(hdc, ApiHelper.DESKTOPVERTRES); + _ = ApiHelper.ReleaseDC(IntPtr.Zero, hdc); + + return Tuple.Create(screenWidth, screenHeight); + } + + public static bool IsWindowOpen(string windowName) + { + var matchingWindows = ApiHelper.FindDesktopWindowHandler([windowName, AdministratorPrefix + windowName]); + return matchingWindows.Count > 0; + } + + internal static class ApiHelper + { + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + public const uint SetWindowPosNoMove = 0x0002; + public const uint SetWindowPosNoZorder = 0x0004; + public const uint SetWindowPosShowWindow = 0x0040; + + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + + // Delegate for the EnumWindows callback function + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + // P/Invoke declaration for EnumWindows + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + // P/Invoke declaration for GetWindowTextLength + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern int GetWindowTextLength(IntPtr hWnd); + + // P/Invoke declaration for GetWindowText + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("gdi32.dll")] + public static extern uint GetPixel(IntPtr hdc, int x, int y); + + [DllImport("gdi32.dll")] + public static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + public const int DESKTOPHORZRES = 118; + public const int DESKTOPVERTRES = 117; + + [DllImport("user32.dll")] + public static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); + + // Define the Win32 RECT structure + [StructLayout(LayoutKind.Sequential)] + public struct RECT + { + public int Left; // X coordinate of the left edge of the window + public int Top; // Y coordinate of the top edge of the window + public int Right; // X coordinate of the right edge of the window + public int Bottom; // Y coordinate of the bottom edge of the window + } + + // Import GetWindowRect API to retrieve window's screen coordinates + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + + public static List<(IntPtr HWnd, string Title)> FindDesktopWindowHandler(string[] matchingWindowsTitles) + { + var windows = new List<(IntPtr HWnd, string Title)>(); + + _ = EnumWindows( + (hWnd, lParam) => + { + int length = GetWindowTextLength(hWnd); + if (length > 0) + { + var builder = new StringBuilder(length + 1); + _ = GetWindowText(hWnd, builder, builder.Capacity); + + var title = builder.ToString(); + if (matchingWindowsTitles.Contains(title)) + { + windows.Add((hWnd, title)); + } + } + + return true; // Continue enumeration + }, + IntPtr.Zero); + + return windows; + } + + /// <summary> + /// Get the center point coordinates of a specified window (in screen coordinates) + /// </summary> + /// <param name="hWnd">The window handle</param> + /// <returns>The center point (x, y)</returns> + public static (int CenterX, int CenterY) GetWindowCenter(IntPtr hWnd) + { + if (hWnd == IntPtr.Zero) + { + throw new ArgumentException("Invalid window handle"); + } + + if (GetWindowRect(hWnd, out RECT rect)) + { + int width = rect.Right - rect.Left; + int height = rect.Bottom - rect.Top; + + int centerX = rect.Left + (width / 2); + int centerY = rect.Top + (height / 2); + + return (centerX, centerY); + } + else + { + throw new InvalidOperationException("Failed to retrieve window coordinates"); + } + } + + public static (int Left, int Top, int Right, int Bottom) GetWindowRect(IntPtr hWnd) + { + if (hWnd == IntPtr.Zero) + { + throw new ArgumentException("Invalid window handle"); + } + + if (GetWindowRect(hWnd, out RECT rect)) + { + return (rect.Left, rect.Top, rect.Right, rect.Bottom); + } + else + { + throw new InvalidOperationException("Failed to retrieve window coordinates"); + } + } + + [DllImport("user32.dll")] + public static extern int GetSystemMetrics(int nIndex); + + public enum SystemMetric + { + ScreenWidth = 0, // Width of the primary screen in pixels (SM_CXSCREEN) + ScreenHeight = 1, // Height of the primary screen in pixels (SM_CYSCREEN) + VirtualScreenWidth = 78, // Width of the virtual screen that includes all monitors (SM_CXVIRTUALSCREEN) + VirtualScreenHeight = 79, // Height of the virtual screen that includes all monitors (SM_CYVIRTUALSCREEN) + MonitorCount = 80, // Number of display monitors (SM_CMONITORS, available on Windows XP+) + } + + public static (int CenterX, int CenterY) GetScreenCenter() + { + int width = GetSystemMetrics((int)SystemMetric.ScreenWidth); + int height = GetSystemMetrics((int)SystemMetric.ScreenHeight); + + return (width / 2, height / 2); + } + } + } +} diff --git a/src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj b/src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj index cc25f7ae43..b2fea03866 100644 --- a/src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj +++ b/src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj @@ -1,18 +1,19 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{1A066C63-64B3-45F8-92FE-664E1CCE8077}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>UnitTestsCommonLib</RootNamespace> <ProjectSubType>NativeUnitTestProject</ProjectSubType> + <ProjectName>Common.Lib.UnitTests</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseOfMfc>false</UseOfMfc> - <PlatformToolset>v143</PlatformToolset> + <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\tests\UnitTestsCommonLib\</OutDir> </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> @@ -57,13 +58,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/UnitTests-CommonLib/packages.config b/src/common/UnitTests-CommonLib/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/common/UnitTests-CommonLib/packages.config +++ b/src/common/UnitTests-CommonLib/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file 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 <appMutex.h> + +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<wil::unique_mutex_nothrow> 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 <color.h> + +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<uint8_t>(0), r); + Assert::AreEqual(static_cast<uint8_t>(0), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(255), r); + Assert::AreEqual(static_cast<uint8_t>(255), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(255), r); + Assert::AreEqual(static_cast<uint8_t>(0), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(0), r); + Assert::AreEqual(static_cast<uint8_t>(255), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(0), r); + Assert::AreEqual(static_cast<uint8_t>(0), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(0xAB), r); + Assert::AreEqual(static_cast<uint8_t>(0x12), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(255), a); + Assert::AreEqual(static_cast<uint8_t>(0), r); + Assert::AreEqual(static_cast<uint8_t>(0), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(255), a); + Assert::AreEqual(static_cast<uint8_t>(255), r); + Assert::AreEqual(static_cast<uint8_t>(255), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(0), a); + Assert::AreEqual(static_cast<uint8_t>(255), r); + Assert::AreEqual(static_cast<uint8_t>(255), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(0x80), a); + Assert::AreEqual(static_cast<uint8_t>(255), r); + Assert::AreEqual(static_cast<uint8_t>(0), g); + Assert::AreEqual(static_cast<uint8_t>(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<uint8_t>(0x12), a); + Assert::AreEqual(static_cast<uint8_t>(0x34), r); + Assert::AreEqual(static_cast<uint8_t>(0x56), g); + Assert::AreEqual(static_cast<uint8_t>(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 <com_object_factory.h> + +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<IUnknown*>(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<TestComObject> factory; + Assert::IsTrue(true); + } + + TEST_METHOD(ComObjectFactory_QueryInterface_IUnknown_Succeeds) + { + com_object_factory<TestComObject> factory; + IUnknown* pUnknown = nullptr; + + HRESULT hr = factory.QueryInterface(IID_IUnknown, reinterpret_cast<void**>(&pUnknown)); + + Assert::AreEqual(S_OK, hr); + Assert::IsNotNull(pUnknown); + + if (pUnknown) + { + pUnknown->Release(); + } + } + + TEST_METHOD(ComObjectFactory_QueryInterface_IClassFactory_Succeeds) + { + com_object_factory<TestComObject> factory; + IClassFactory* pFactory = nullptr; + + HRESULT hr = factory.QueryInterface(IID_IClassFactory, reinterpret_cast<void**>(&pFactory)); + + Assert::AreEqual(S_OK, hr); + Assert::IsNotNull(pFactory); + + if (pFactory) + { + pFactory->Release(); + } + } + + TEST_METHOD(ComObjectFactory_QueryInterface_InvalidInterface_Fails) + { + com_object_factory<TestComObject> 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<TestComObject> 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<TestComObject> 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<TestComObject> factory; + IUnknown* pObj = nullptr; + + HRESULT hr = factory.CreateInstance(nullptr, IID_IUnknown, reinterpret_cast<void**>(&pObj)); + + Assert::AreEqual(S_OK, hr); + Assert::IsNotNull(pObj); + + if (pObj) + { + pObj->Release(); + } + } + + TEST_METHOD(ComObjectFactory_CreateInstance_WithAggregation_Fails) + { + com_object_factory<TestComObject> factory; + TestComObject outer; + IUnknown* pObj = nullptr; + + // Aggregation should fail for our simple test object + HRESULT hr = factory.CreateInstance(&outer, IID_IUnknown, reinterpret_cast<void**>(&pObj)); + + Assert::AreEqual(CLASS_E_NOAGGREGATION, hr); + Assert::IsNull(pObj); + } + + TEST_METHOD(ComObjectFactory_CreateInstance_NullOutput_Fails) + { + com_object_factory<TestComObject> factory; + + HRESULT hr = factory.CreateInstance(nullptr, IID_IUnknown, nullptr); + + Assert::AreEqual(E_POINTER, hr); + } + + TEST_METHOD(ComObjectFactory_LockServer_Lock_Succeeds) + { + com_object_factory<TestComObject> factory; + + HRESULT hr = factory.LockServer(TRUE); + Assert::AreEqual(S_OK, hr); + + // Unlock + factory.LockServer(FALSE); + } + + TEST_METHOD(ComObjectFactory_LockServer_Unlock_Succeeds) + { + com_object_factory<TestComObject> factory; + + factory.LockServer(TRUE); + HRESULT hr = factory.LockServer(FALSE); + + Assert::AreEqual(S_OK, hr); + } + + TEST_METHOD(ComObjectFactory_LockServer_MultipleLocks_Work) + { + com_object_factory<TestComObject> 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<TestComObject> factory; + std::vector<std::thread> threads; + std::atomic<int> 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<void**>(&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 <elevation.h> + +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<DWORD>(0), info.processID); + } + + // Thread safety tests + TEST_METHOD(IsProcessElevated_ThreadSafe) + { + std::vector<std::thread> threads; + std::atomic<int> 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<std::chrono::milliseconds>(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 <excluded_apps.h> + +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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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<std::wstring> 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 <exec.h> +#include <cctype> + +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<char>(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::milliseconds>( + 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::milliseconds>( + 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 <game_mode.h> + +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<std::thread> threads; + std::atomic<int> 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 <gpo.h> + +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<int>(gpo_rule_configured_not_configured), + static_cast<int>(gpo_rule_configured_enabled)); + Assert::AreNotEqual(static_cast<int>(gpo_rule_configured_enabled), + static_cast<int>(gpo_rule_configured_disabled)); + Assert::AreNotEqual(static_cast<int>(gpo_rule_configured_not_configured), + static_cast<int>(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 <HDropIterator.h> +#include <shlobj.h> + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(HDropIteratorTests) + { + public: + // Helper to create a test HDROP structure + static HGLOBAL CreateTestHDrop(const std::vector<std::wstring>& 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<DROPFILES*>(GlobalLock(hGlobal)); + if (!pDropFiles) + { + GlobalFree(hGlobal); + return nullptr; + } + + pDropFiles->pFiles = sizeof(DROPFILES); + pDropFiles->fWide = TRUE; + + wchar_t* pData = reinterpret_cast<wchar_t*>(reinterpret_cast<BYTE*>(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<std::wstring> 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<DROPFILES*>(GlobalLock(hGlobal)); + Assert::IsNotNull(pDropFiles); + Assert::IsTrue(pDropFiles->fWide); + + GlobalUnlock(hGlobal); + GlobalFree(hGlobal); + Assert::IsTrue(true); + } + + TEST_METHOD(HDropIterator_SingleFile_Works) + { + std::vector<std::wstring> testFiles = { L"C:\\test\\single.txt" }; + + HGLOBAL hGlobal = CreateTestHDrop(testFiles); + if (!hGlobal) + { + Assert::IsTrue(true); + return; + } + + // Verify structure + DROPFILES* pDropFiles = static_cast<DROPFILES*>(GlobalLock(hGlobal)); + Assert::IsNotNull(pDropFiles); + + // Read back the file name + wchar_t* pData = reinterpret_cast<wchar_t*>(reinterpret_cast<BYTE*>(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<std::wstring> 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<DROPFILES*>(GlobalLock(hGlobal)); + Assert::IsNotNull(pDropFiles); + + // Count files by iterating through null-terminated strings + wchar_t* pData = reinterpret_cast<wchar_t*>(reinterpret_cast<BYTE*>(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<std::wstring> testFiles = { + L"C:\\test\\file.txt" + }; + + HGLOBAL hGlobal = CreateTestHDrop(testFiles); + if (!hGlobal) + { + Assert::IsTrue(true); + return; + } + + DROPFILES* pDropFiles = static_cast<DROPFILES*>(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<std::wstring> testFiles = { longPath }; + + HGLOBAL hGlobal = CreateTestHDrop(testFiles); + if (!hGlobal) + { + Assert::IsTrue(true); + return; + } + + DROPFILES* pDropFiles = static_cast<DROPFILES*>(GlobalLock(hGlobal)); + Assert::IsNotNull(pDropFiles); + + wchar_t* pData = reinterpret_cast<wchar_t*>(reinterpret_cast<BYTE*>(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 <HttpClient.h> + +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<int> 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<std::unique_ptr<http::HttpClient>> clients; + + for (int i = 0; i < 10; ++i) + { + clients.push_back(std::make_unique<http::HttpClient>()); + } + + // All clients should coexist without crashing + Assert::AreEqual(static_cast<size_t>(10), clients.size()); + } + + TEST_METHOD(HttpClient_ConcurrentConstruction_DoesNotCrash) + { + std::vector<std::thread> threads; + std::atomic<int> 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 <json.h> + +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 <logger_helper.h> + +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<std::thread> threads; + std::atomic<int> 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<std::thread> threads; + std::atomic<int> 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 <modulesRegistry.h> + +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 <MsWindowsSettings.h> + +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<std::thread> threads; + std::atomic<int> 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 <MsiUtils.h> + +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<std::thread> threads; + std::atomic<int> 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<std::thread> threads; + std::atomic<int> 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 <os-detect.h> + +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<std::chrono::milliseconds>(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 <package.h> + +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<UINT16>(0), version.Major); + Assert::AreEqual(static_cast<UINT16>(0), version.Minor); + Assert::AreEqual(static_cast<UINT16>(0), version.Build); + Assert::AreEqual(static_cast<UINT16>(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<UINT16>(1), version.Major); + Assert::AreEqual(static_cast<UINT16>(2), version.Minor); + Assert::AreEqual(static_cast<UINT16>(3), version.Build); + Assert::AreEqual(static_cast<UINT16>(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<std::thread> threads; + std::atomic<int> 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 <processApi.h> + +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 <process_path.h> + +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<DWORD>(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<DWORD>(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<HMODULE>(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<HWND>(0x12345678)); + Assert::IsTrue(path.empty()); + } + + TEST_METHOD(GetProcessPath_NullHwnd_ReturnsEmpty) + { + auto path = get_process_path(static_cast<HWND>(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 <ProcessWaiter.h> + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace ProcessWaiter; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(ProcessWaiterTests) + { + public: + TEST_METHOD(OnProcessTerminate_InvalidPid_DoesNotCrash) + { + std::atomic<bool> 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<bool> 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<bool> 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<bool> 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<void(DWORD)>()); + + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + Assert::IsTrue(true); + } + + TEST_METHOD(OnProcessTerminate_MultipleCallsForSamePid_DoesNotCrash) + { + std::atomic<int> 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<bool> 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<bool> 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 <registry.h> + +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<std::wstring>(change.value)); + } + + TEST_METHOD(Registry_ValueChange_DwordValue) + { + registry::ValueChange change{ HKEY_CURRENT_USER, L"Software\\PowerToys\\Test", L"TestDword", static_cast<DWORD>(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<DWORD>(42), std::get<DWORD>(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<DWORD>(123) }); + + Assert::AreEqual(static_cast<size_t>(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 <resources.h> + +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<std::thread> threads; + std::atomic<int> 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<std::chrono::milliseconds>(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 <serialized.h> + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace UnitTestsCommonUtils +{ + TEST_CLASS(SerializedTests) + { + public: + // Basic Read tests + TEST_METHOD(Read_DefaultState_ReturnsDefaultValue) + { + Serialized<int> 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<std::string> 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<int> 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<std::string> 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<int> 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<int> 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<std::string> 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<std::vector<int>> s; + s.Access([](std::vector<int>& 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<int>& v) { + size = v.size(); + for (int i : v) sum += i; + }); + + Assert::AreEqual(static_cast<size_t>(3), size); + Assert::AreEqual(6, sum); + } + + TEST_METHOD(Serialized_MapType_Works) + { + Serialized<std::map<std::string, int>> s; + s.Access([](std::map<std::string, int>& v) { + v["one"] = 1; + v["two"] = 2; + }); + + int value = 0; + s.Read([&value](const std::map<std::string, int>& 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<int> s; + s.Access([](int& v) { v = 42; }); + + std::atomic<int> readCount{ 0 }; + std::vector<std::thread> 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<int> s; + std::atomic<bool> done{ false }; + std::atomic<int> accessCount{ 0 }; + std::atomic<int> readersReady{ 0 }; + std::atomic<bool> 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<std::thread> readers; + std::atomic<int> 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<TestStruct> 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<TestStruct> 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 <string_utils.h> + +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 <string> +#include <filesystem> +#include <fstream> +#include <random> + +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<wchar_t>(file)), + std::istreambuf_iterator<wchar_t>()); + } + + 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<const BYTE*>(value.c_str()), + static_cast<DWORD>((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<const BYTE*>(&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<typename Predicate> + 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 <common/logger/logger.h> +#include <common/SettingsAPI/settings_helpers.h> +#include <spdlog/sinks/null_sink.h> + +std::shared_ptr<spdlog::logger> 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 <OnThreadExecutor.h> +#include <EventWaiter.h> +#include <EventLocker.h> + +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<bool> 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<int> counter{ 0 }; + + std::vector<std::future<void>> 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<int> order; + std::mutex orderMutex; + + std::vector<std::future<void>> 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<size_t>(5), order.size()); + for (int i = 0; i < 5; ++i) + { + Assert::AreEqual(i, order[i]); + } + } + + TEST_METHOD(Submit_TaskReturnsResult) + { + OnThreadExecutor executor; + std::atomic<int> 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<int> 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<bool> completed{ false }; + std::future<void> 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<int> 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<bool> called{ false }; + std::atomic<DWORD> 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<DWORD>(ERROR_SUCCESS), errorCode.load()); + } + + TEST_METHOD(Destructor_StopsListening) + { + std::atomic<bool> 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 <timeutil.h> + +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<time_t>(0), result.value()); + } + + TEST_METHOD(FromString_ValidNumber_ReturnsTime) + { + auto result = timeutil::from_string(L"1234567890"); + Assert::IsTrue(result.has_value()); + Assert::AreEqual(static_cast<time_t>(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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(-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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(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<int64_t>(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 <UnhandledExceptionHandler.h> + +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<std::thread> threads; + std::atomic<int> 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<DWORD> 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 <windows.h> +#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..04bf0c1ca0 --- /dev/null +++ b/src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj @@ -0,0 +1,96 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <VCProjectVersion>16.0</VCProjectVersion> + <ProjectGuid>{8B5CFB38-CCBA-40A8-AD7A-89C57B070884}</ProjectGuid> + <Keyword>Win32Proj</Keyword> + <RootNamespace>UnitTestsCommonUtils</RootNamespace> + <ProjectSubType>NativeUnitTestProject</ProjectSubType> + <ProjectName>Common.Utils.UnitTests</ProjectName> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <PropertyGroup Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseOfMfc>false</UseOfMfc> + <PlatformToolset>v143</PlatformToolset> + <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\tests\UnitTestsCommonUtils\</OutDir> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <ItemDefinitionGroup> + <ClCompile> + <AdditionalIncludeDirectories>..\;..\utils;..\Telemetry;..\..\;..\..\..\deps\;..\..\..\deps\spdlog\include;..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\include;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <LanguageStandard>stdcpp23</LanguageStandard> + <PreprocessorDefinitions>SPDLOG_WCHAR_TO_UTF8_SUPPORT;SPDLOG_HEADER_ONLY;%(PreprocessorDefinitions)</PreprocessorDefinitions> + </ClCompile> + <Link> + <AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> + <AdditionalDependencies>RuntimeObject.lib;Msi.lib;Shlwapi.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> + </ClCompile> + <ClCompile Include="StringUtils.Tests.cpp" /> + <ClCompile Include="ColorUtils.Tests.cpp" /> + <ClCompile Include="TimeUtils.Tests.cpp" /> + <ClCompile Include="WinApiError.Tests.cpp" /> + <ClCompile Include="Serialized.Tests.cpp" /> + <ClCompile Include="Json.Tests.cpp" /> + <ClCompile Include="OsDetect.Tests.cpp" /> + <ClCompile Include="Threading.Tests.cpp" /> + <ClCompile Include="ProcessPath.Tests.cpp" /> + <ClCompile Include="Window.Tests.cpp" /> + <ClCompile Include="GameMode.Tests.cpp" /> + <ClCompile Include="Gpo.Tests.cpp" /> + <ClCompile Include="MsiUtils.Tests.cpp" /> + <ClCompile Include="HttpClient.Tests.cpp" /> + <ClCompile Include="ComObjectFactory.Tests.cpp" /> + <ClCompile Include="AppMutex.Tests.cpp" /> + <ClCompile Include="Elevation.Tests.cpp" /> + <ClCompile Include="Exec.Tests.cpp" /> + <ClCompile Include="ExcludedApps.Tests.cpp" /> + <ClCompile Include="HDropIterator.Tests.cpp" /> + <ClCompile Include="LoggerHelper.Tests.cpp" /> + <ClCompile Include="ModulesRegistry.Tests.cpp" /> + <ClCompile Include="MsWindowsSettings.Tests.cpp" /> + <ClCompile Include="Package.Tests.cpp" /> + <ClCompile Include="ProcessApi.Tests.cpp" /> + <ClCompile Include="ProcessWaiter.Tests.cpp" /> + <ClCompile Include="Registry.Tests.cpp" /> + <ClCompile Include="Resources.Tests.cpp" /> + <ClCompile Include="TestStubs.cpp" /> + <ClCompile Include="UnhandledException.Tests.cpp" /> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h" /> + <ClInclude Include="resource.h" /> + <ClInclude Include="TestHelpers.h" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="UnitTests-CommonUtils.rc" /> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> +</Project> 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;hm;inl;inc;ipp;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + <Filter Include="Source Files\Pure Functions"> + <UniqueIdentifier>{A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}</UniqueIdentifier> + </Filter> + <Filter Include="Source Files\Threading"> + <UniqueIdentifier>{B2C3D4E5-F6A7-4B6C-9D0E-1F2A3B4C5D6E}</UniqueIdentifier> + </Filter> + <Filter Include="Source Files\Process"> + <UniqueIdentifier>{C3D4E5F6-A7B8-4C7D-0E1F-2A3B4C5D6E7F}</UniqueIdentifier> + </Filter> + <Filter Include="Source Files\Registry"> + <UniqueIdentifier>{D4E5F6A7-B8C9-4D8E-1F2A-3B4C5D6E7F8A}</UniqueIdentifier> + </Filter> + <Filter Include="Source Files\Integration"> + <UniqueIdentifier>{E5F6A7B8-C9D0-4E9F-2A3B-4C5D6E7F8A9B}</UniqueIdentifier> + </Filter> + </ItemGroup> + <ItemGroup> + <ClCompile Include="pch.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="StringUtils.Tests.cpp"> + <Filter>Source Files\Pure Functions</Filter> + </ClCompile> + <ClCompile Include="ColorUtils.Tests.cpp"> + <Filter>Source Files\Pure Functions</Filter> + </ClCompile> + <ClCompile Include="TimeUtils.Tests.cpp"> + <Filter>Source Files\Pure Functions</Filter> + </ClCompile> + <ClCompile Include="WinApiError.Tests.cpp"> + <Filter>Source Files\Pure Functions</Filter> + </ClCompile> + <ClCompile Include="Serialized.Tests.cpp"> + <Filter>Source Files\Pure Functions</Filter> + </ClCompile> + <ClCompile Include="Json.Tests.cpp"> + <Filter>Source Files\Pure Functions</Filter> + </ClCompile> + <ClCompile Include="ExcludedApps.Tests.cpp"> + <Filter>Source Files\Pure Functions</Filter> + </ClCompile> + <ClCompile Include="OsDetect.Tests.cpp"> + <Filter>Source Files\Pure Functions</Filter> + </ClCompile> + <ClCompile Include="Threading.Tests.cpp"> + <Filter>Source Files\Threading</Filter> + </ClCompile> + <ClCompile Include="AppMutex.Tests.cpp"> + <Filter>Source Files\Threading</Filter> + </ClCompile> + <ClCompile Include="ProcessWaiter.Tests.cpp"> + <Filter>Source Files\Threading</Filter> + </ClCompile> + <ClCompile Include="ProcessPath.Tests.cpp"> + <Filter>Source Files\Process</Filter> + </ClCompile> + <ClCompile Include="ProcessApi.Tests.cpp"> + <Filter>Source Files\Process</Filter> + </ClCompile> + <ClCompile Include="Window.Tests.cpp"> + <Filter>Source Files\Process</Filter> + </ClCompile> + <ClCompile Include="Exec.Tests.cpp"> + <Filter>Source Files\Process</Filter> + </ClCompile> + <ClCompile Include="GameMode.Tests.cpp"> + <Filter>Source Files\Process</Filter> + </ClCompile> + <ClCompile Include="MsWindowsSettings.Tests.cpp"> + <Filter>Source Files\Process</Filter> + </ClCompile> + <ClCompile Include="Registry.Tests.cpp"> + <Filter>Source Files\Registry</Filter> + </ClCompile> + <ClCompile Include="Gpo.Tests.cpp"> + <Filter>Source Files\Registry</Filter> + </ClCompile> + <ClCompile Include="ModulesRegistry.Tests.cpp"> + <Filter>Source Files\Registry</Filter> + </ClCompile> + <ClCompile Include="Elevation.Tests.cpp"> + <Filter>Source Files\Integration</Filter> + </ClCompile> + <ClCompile Include="Package.Tests.cpp"> + <Filter>Source Files\Integration</Filter> + </ClCompile> + <ClCompile Include="MsiUtils.Tests.cpp"> + <Filter>Source Files\Integration</Filter> + </ClCompile> + <ClCompile Include="HttpClient.Tests.cpp"> + <Filter>Source Files\Integration</Filter> + </ClCompile> + <ClCompile Include="Resources.Tests.cpp"> + <Filter>Source Files\Integration</Filter> + </ClCompile> + <ClCompile Include="LoggerHelper.Tests.cpp"> + <Filter>Source Files\Integration</Filter> + </ClCompile> + <ClCompile Include="ComObjectFactory.Tests.cpp"> + <Filter>Source Files\Integration</Filter> + </ClCompile> + <ClCompile Include="HDropIterator.Tests.cpp"> + <Filter>Source Files\Integration</Filter> + </ClCompile> + <ClCompile Include="UnhandledException.Tests.cpp"> + <Filter>Source Files\Integration</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="resource.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="TestHelpers.h"> + <Filter>Header Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="UnitTests-CommonUtils.rc"> + <Filter>Resource Files</Filter> + </ResourceCompile> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> +</Project> 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 <winapi_error.h> + +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 <window.h> + +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<HWND>(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<TestData*>(reinterpret_cast<LPARAM>(&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<int*>(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::milliseconds>( + 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::milliseconds>( + 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::milliseconds>( + 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..97349a856f --- /dev/null +++ b/src/common/UnitTests-CommonUtils/packages.config @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> +</packages> 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 <Windows.h> +#include <winrt/base.h> +#include <winrt/Windows.Foundation.h> +#include <winrt/Windows.Foundation.Collections.h> +#include <winrt/Windows.Foundation.Metadata.h> +#include <winrt/Windows.Data.Json.h> + +#include <string> +#include <vector> +#include <optional> +#include <functional> +#include <thread> +#include <atomic> +#include <mutex> +#include <shared_mutex> +#include <future> +#include <queue> +#include <filesystem> +#include <fstream> +#include <chrono> +#include <ctime> + +// 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 62e6425f6f..67b4da51f2 100644 --- a/src/common/interop/Constants.cpp +++ b/src/common/interop/Constants.cpp @@ -75,10 +75,62 @@ namespace winrt::PowerToys::Interop::implementation { return CommonSharedConstants::ADVANCED_PASTE_CUSTOM_ACTION_MESSAGE; } + hstring Constants::AdvancedPasteShowUIEvent() + { + return CommonSharedConstants::ADVANCED_PASTE_SHOW_UI_EVENT; + } hstring Constants::AdvancedPasteTerminateAppMessage() { return CommonSharedConstants::ADVANCED_PASTE_TERMINATE_APP_MESSAGE; } + hstring Constants::AlwaysOnTopPinEvent() + { + return CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT; + } + hstring Constants::FindMyMouseTriggerEvent() + { + return CommonSharedConstants::FIND_MY_MOUSE_TRIGGER_EVENT; + } + hstring Constants::MouseHighlighterTriggerEvent() + { + return CommonSharedConstants::MOUSE_HIGHLIGHTER_TRIGGER_EVENT; + } + hstring Constants::MouseCrosshairsTriggerEvent() + { + return CommonSharedConstants::MOUSE_CROSSHAIRS_TRIGGER_EVENT; + } + hstring Constants::CursorWrapTriggerEvent() + { + return CommonSharedConstants::CURSOR_WRAP_TRIGGER_EVENT; + } + hstring Constants::LightSwitchToggleEvent() + { + return CommonSharedConstants::LIGHTSWITCH_TOGGLE_EVENT; + } + hstring Constants::ZoomItZoomEvent() + { + return CommonSharedConstants::ZOOMIT_ZOOM_EVENT; + } + hstring Constants::ZoomItDrawEvent() + { + return CommonSharedConstants::ZOOMIT_DRAW_EVENT; + } + hstring Constants::ZoomItBreakEvent() + { + return CommonSharedConstants::ZOOMIT_BREAK_EVENT; + } + hstring Constants::ZoomItLiveZoomEvent() + { + return CommonSharedConstants::ZOOMIT_LIVEZOOM_EVENT; + } + hstring Constants::ZoomItSnipEvent() + { + return CommonSharedConstants::ZOOMIT_SNIP_EVENT; + } + hstring Constants::ZoomItRecordEvent() + { + return CommonSharedConstants::ZOOMIT_RECORD_EVENT; + } hstring Constants::ShowPowerOCRSharedEvent() { return CommonSharedConstants::SHOW_POWEROCR_SHARED_EVENT; @@ -127,6 +179,10 @@ namespace winrt::PowerToys::Interop::implementation { return CommonSharedConstants::GCODE_PREVIEW_RESIZE_EVENT; } + hstring Constants::BgcodePreviewResizeEvent() + { + return CommonSharedConstants::BGCODE_PREVIEW_RESIZE_EVENT; + } hstring Constants::QoiPreviewResizeEvent() { return CommonSharedConstants::QOI_PREVIEW_RESIZE_EVENT; @@ -167,6 +223,10 @@ namespace winrt::PowerToys::Interop::implementation { return CommonSharedConstants::CROP_AND_LOCK_REPARENT_EVENT; } + hstring Constants::CropAndLockScreenshotEvent() + { + return CommonSharedConstants::CROP_AND_LOCK_SCREENSHOT_EVENT; + } hstring Constants::ShowEnvironmentVariablesSharedEvent() { return CommonSharedConstants::SHOW_ENVIRONMENT_VARIABLES_EVENT; @@ -191,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 1b3a0f556c..faa2a97379 100644 --- a/src/common/interop/Constants.h +++ b/src/common/interop/Constants.h @@ -23,6 +23,20 @@ namespace winrt::PowerToys::Interop::implementation static hstring AdvancedPasteAdditionalActionMessage(); static hstring AdvancedPasteCustomActionMessage(); static hstring AdvancedPasteTerminateAppMessage(); + static hstring AdvancedPasteShowUIEvent(); + static hstring AlwaysOnTopPinEvent(); + static hstring MeasureToolTriggerEvent(); + static hstring FindMyMouseTriggerEvent(); + static hstring MouseHighlighterTriggerEvent(); + static hstring MouseCrosshairsTriggerEvent(); + static hstring CursorWrapTriggerEvent(); + static hstring LightSwitchToggleEvent(); + static hstring ZoomItZoomEvent(); + static hstring ZoomItDrawEvent(); + static hstring ZoomItBreakEvent(); + static hstring ZoomItLiveZoomEvent(); + static hstring ZoomItSnipEvent(); + static hstring ZoomItRecordEvent(); static hstring ShowPowerOCRSharedEvent(); static hstring TerminatePowerOCRSharedEvent(); static hstring MouseJumpShowPreviewEvent(); @@ -33,8 +47,8 @@ namespace winrt::PowerToys::Interop::implementation static hstring PowerAccentExitEvent(); static hstring ShortcutGuideTriggerEvent(); static hstring RegistryPreviewTriggerEvent(); - static hstring MeasureToolTriggerEvent(); static hstring GcodePreviewResizeEvent(); + static hstring BgcodePreviewResizeEvent(); static hstring QoiPreviewResizeEvent(); static hstring DevFilesPreviewResizeEvent(); static hstring MarkdownPreviewResizeEvent(); @@ -45,12 +59,22 @@ namespace winrt::PowerToys::Interop::implementation static hstring TerminateHostsSharedEvent(); static hstring CropAndLockThumbnailEvent(); static hstring CropAndLockReparentEvent(); + static hstring CropAndLockScreenshotEvent(); static hstring ShowEnvironmentVariablesSharedEvent(); static hstring ShowEnvironmentVariablesAdminSharedEvent(); static hstring WorkspacesLaunchEditorEvent(); 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 1de4b849ab..042d790699 100644 --- a/src/common/interop/Constants.idl +++ b/src/common/interop/Constants.idl @@ -20,6 +20,19 @@ namespace PowerToys static String AdvancedPasteAdditionalActionMessage(); static String AdvancedPasteCustomActionMessage(); static String AdvancedPasteTerminateAppMessage(); + static String AdvancedPasteShowUIEvent(); + static String AlwaysOnTopPinEvent(); + static String FindMyMouseTriggerEvent(); + static String MouseHighlighterTriggerEvent(); + static String MouseCrosshairsTriggerEvent(); + static String CursorWrapTriggerEvent(); + static String LightSwitchToggleEvent(); + static String ZoomItZoomEvent(); + static String ZoomItDrawEvent(); + static String ZoomItBreakEvent(); + static String ZoomItLiveZoomEvent(); + static String ZoomItSnipEvent(); + static String ZoomItRecordEvent(); static String ShowPowerOCRSharedEvent(); static String TerminatePowerOCRSharedEvent(); static String MouseJumpShowPreviewEvent(); @@ -32,6 +45,7 @@ namespace PowerToys static String RegistryPreviewTriggerEvent(); static String MeasureToolTriggerEvent(); static String GcodePreviewResizeEvent(); + static String BgcodePreviewResizeEvent(); static String QoiPreviewResizeEvent(); static String DevFilesPreviewResizeEvent(); static String MarkdownPreviewResizeEvent(); @@ -42,12 +56,22 @@ namespace PowerToys static String TerminateHostsSharedEvent(); static String CropAndLockThumbnailEvent(); static String CropAndLockReparentEvent(); + static String CropAndLockScreenshotEvent(); static String ShowEnvironmentVariablesSharedEvent(); static String ShowEnvironmentVariablesAdminSharedEvent(); static String WorkspacesLaunchEditorEvent(); 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(); } } -} \ No newline at end of file +} diff --git a/src/common/interop/HotkeyManager.cpp b/src/common/interop/HotkeyManager.cpp index adcbb9ada0..a9a6a19e6a 100644 --- a/src/common/interop/HotkeyManager.cpp +++ b/src/common/interop/HotkeyManager.cpp @@ -14,7 +14,7 @@ namespace winrt::PowerToys::Interop::implementation } // When all Shortcut keys are pressed, fire the HotkeyCallback event. - void HotkeyManager::KeyboardEventProc(KeyboardEvent ev) + void HotkeyManager::KeyboardEventProc(KeyboardEvent /*ev*/) { // pressedKeys always stores the latest keyboard state auto pressedKeysHandle = GetHotkeyHandle(pressedKeys); diff --git a/src/common/interop/KeyboardHook.cpp b/src/common/interop/KeyboardHook.cpp index 0658594689..5b8415c68d 100644 --- a/src/common/interop/KeyboardHook.cpp +++ b/src/common/interop/KeyboardHook.cpp @@ -83,7 +83,7 @@ namespace winrt::PowerToys::Interop::implementation ev.key = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam)->vkCode; ev.dwExtraInfo = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam)->dwExtraInfo; - // Ignore the keyboard hook if the FilterkeyboardEvent returns false. + // Ignore the keyboard hook if the FilterKeyboardEvent returns false. if ((s_instance->filterKeyboardEvent != nullptr && !s_instance->filterKeyboardEvent(ev))) { continue; diff --git a/src/common/interop/LayoutMapManaged.cpp b/src/common/interop/LayoutMapManaged.cpp index 8ef49fd345..445abb7137 100644 --- a/src/common/interop/LayoutMapManaged.cpp +++ b/src/common/interop/LayoutMapManaged.cpp @@ -12,7 +12,7 @@ namespace winrt::PowerToys::Interop::implementation { return _map->GetKeyFromName(std::wstring(name)); } - void LayoutMapManaged::Updatelayout() + void LayoutMapManaged::UpdateLayout() { _map->UpdateLayout(); } diff --git a/src/common/interop/LayoutMapManaged.h b/src/common/interop/LayoutMapManaged.h index d192a6e92e..74507bf196 100644 --- a/src/common/interop/LayoutMapManaged.h +++ b/src/common/interop/LayoutMapManaged.h @@ -10,7 +10,7 @@ namespace winrt::PowerToys::Interop::implementation hstring GetKeyName(uint32_t key); uint32_t GetKeyValue(hstring const& name); - void Updatelayout(); + void UpdateLayout(); private: std::unique_ptr<LayoutMap> _map = std::make_unique<LayoutMap>(); diff --git a/src/common/interop/LayoutMapManaged.idl b/src/common/interop/LayoutMapManaged.idl index 6281270094..2737ec15a2 100644 --- a/src/common/interop/LayoutMapManaged.idl +++ b/src/common/interop/LayoutMapManaged.idl @@ -6,7 +6,7 @@ namespace PowerToys LayoutMapManaged(); String GetKeyName(UInt32 key); UInt32 GetKeyValue(String name); - void Updatelayout(); + void UpdateLayout(); } } } \ No newline at end of file diff --git a/src/common/interop/PowerToys.Interop.vcxproj b/src/common/interop/PowerToys.Interop.vcxproj index aadd8b2ebb..0c0727afa2 100644 --- a/src/common/interop/PowerToys.Interop.vcxproj +++ b/src/common/interop/PowerToys.Interop.vcxproj @@ -1,6 +1,7 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup> <AssemblyTitle>PowerToys.Interop</AssemblyTitle> </PropertyGroup> @@ -38,10 +39,9 @@ <ApplicationType>Windows Store</ApplicationType> <ApplicationTypeRevision>10.0</ApplicationTypeRevision> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <GenerateManifest>false</GenerateManifest> </PropertyGroup> @@ -56,21 +56,21 @@ <PropertyGroup Label="UserMacros" /> <PropertyGroup> <TargetName>PowerToys.Interop</TargetName> - <OutDir>..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <CopyCppRuntimeToOutputDir>true</CopyCppRuntimeToOutputDir> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> <ClCompile> - <RuntimeLibrary>MultiThreadedDebugDLL</RuntimeLibrary> + <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary> <UseDebugLibraries>true</UseDebugLibraries> <LinkIncremental>true</LinkIncremental> </ClCompile> </ItemDefinitionGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> <ClCompile> - <RuntimeLibrary>MultiThreadedDLL</RuntimeLibrary> + <RuntimeLibrary>MultiThreaded</RuntimeLibrary> <UseDebugLibraries>false</UseDebugLibraries> <WholeProgramOptimization>true</WholeProgramOptimization> <LinkIncremental>false</LinkIncremental> @@ -80,7 +80,7 @@ <ClCompile> <PrecompiledHeaderOutputFile>$(IntDir)pch.pch</PrecompiledHeaderOutputFile> <PreprocessorDefinitions>_WINRT_DLL;WINRT_LEAN_AND_MEAN;PowerToysInterop;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>$(SolutionDir)src\common\interop;../../;../;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\interop;../../;../;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <AdditionalUsingDirectories>$(WindowsSDK_WindowsMetadata);$(AdditionalUsingDirectories)</AdditionalUsingDirectories> <OmitDefaultLibName>false</OmitDefaultLibName> <AdditionalOptions>%(AdditionalOptions) /bigobj /Zc:twoPhase- </AdditionalOptions> @@ -172,15 +172,15 @@ <ImportGroup Label="ExtensionTargets" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/interop/interop-tests/Microsoft.Interop.Tests.csproj b/src/common/interop/interop-tests/Common.Interop.UnitTests.csproj similarity index 91% rename from src/common/interop/interop-tests/Microsoft.Interop.Tests.csproj rename to src/common/interop/interop-tests/Common.Interop.UnitTests.csproj index ed7a9f29de..1ef8e2b6b1 100644 --- a/src/common/interop/interop-tests/Microsoft.Interop.Tests.csproj +++ b/src/common/interop/interop-tests/Common.Interop.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/common/interop/keyboard_layout.cpp b/src/common/interop/keyboard_layout.cpp index 12253c55eb..0f89ce61df 100644 --- a/src/common/interop/keyboard_layout.cpp +++ b/src/common/interop/keyboard_layout.cpp @@ -117,7 +117,7 @@ void LayoutMap::LayoutMapImpl::UpdateLayout() } } - // Override special key names like Shift, Ctrl etc because they don't have unicode mappings and key names like Enter, Space as they appear as "\r", " " + // Override special key names like Shift, Ctrl, etc. because they don't have unicode mappings and key names like Enter, Space as they appear as "\r", " " // To do: localization keyboardLayoutMap[VK_CANCEL] = L"Break"; keyboardLayoutMap[VK_BACK] = L"Backspace"; diff --git a/src/common/interop/packages.config b/src/common/interop/packages.config index 6199e2345c..d3882436a5 100644 --- a/src/common/interop/packages.config +++ b/src/common/interop/packages.config @@ -1,4 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/interop/shared_constants.h b/src/common/interop/shared_constants.h index d240297f39..079f53c85c 100644 --- a/src/common/interop/shared_constants.h +++ b/src/common/interop/shared_constants.h @@ -40,6 +40,8 @@ namespace CommonSharedConstants const wchar_t ADVANCED_PASTE_CUSTOM_ACTION_MESSAGE[] = L"CustomAction"; const wchar_t ADVANCED_PASTE_TERMINATE_APP_MESSAGE[] = L"TerminateApp"; + + const wchar_t ADVANCED_PASTE_SHOW_UI_EVENT[] = L"Local\\PowerToys_AdvancedPaste_ShowUI"; // Path to the event used to show Color Picker const wchar_t SHOW_COLOR_PICKER_SHARED_EVENT[] = L"Local\\ShowColorPickerEvent-8c46be2a-3e05-4186-b56b-4ae986ef2525"; @@ -70,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"; @@ -83,15 +89,27 @@ namespace CommonSharedConstants const wchar_t TERMINATE_MOUSE_JUMP_SHARED_EVENT[] = L"Local\\TerminateMouseJumpEvent-252fa337-317f-4c37-a61f-99464c3f9728"; + // Paths to the events used by other Mouse Utilities + const wchar_t FIND_MY_MOUSE_TRIGGER_EVENT[] = L"Local\\FindMyMouseTriggerEvent-5a9dc5f4-1c74-4f2f-a66f-1b9b6a2f9b23"; + const wchar_t MOUSE_HIGHLIGHTER_TRIGGER_EVENT[] = L"Local\\MouseHighlighterTriggerEvent-1e3c9c3d-3fdf-4f9a-9a52-31c9b3c3a8f4"; + const wchar_t MOUSE_CROSSHAIRS_TRIGGER_EVENT[] = L"Local\\MouseCrosshairsTriggerEvent-0d4c7f92-0a5c-4f5c-b64b-8a2a2f7e0b21"; + const wchar_t CURSOR_WRAP_TRIGGER_EVENT[] = L"Local\\CursorWrapTriggerEvent-1f8452b5-4e6e-45b3-8b09-13f14a5900c9"; + // Path to the event used by RegistryPreview const wchar_t REGISTRY_PREVIEW_TRIGGER_EVENT[] = L"Local\\RegistryPreviewEvent-4C559468-F75A-4E7F-BC4F-9C9688316687"; // Path to the event used by MeasureTool const wchar_t MEASURE_TOOL_TRIGGER_EVENT[] = L"Local\\MeasureToolEvent-3d46745f-09b3-4671-a577-236be7abd199"; + // Path to the event used by LightSwitch + const wchar_t LIGHTSWITCH_TOGGLE_EVENT[] = L"Local\\PowerToys-LightSwitch-ToggleEvent-d8dc2f29-8c94-4ca1-8c5f-3e2b1e3c4f5a"; + // Path to the event used by GcodePreviewHandler const wchar_t GCODE_PREVIEW_RESIZE_EVENT[] = L"Local\\PowerToysGcodePreviewResizeEvent-6ff1f9bd-ccbd-4b24-a79f-40a34fb0317d"; + // Path to the event used by BgcodePreviewHandler + const wchar_t BGCODE_PREVIEW_RESIZE_EVENT[] = L"Local\\PowerToysBgcodePreviewResizeEvent-1a76a553-919a-49e0-8179-776582d8e476"; + // Path to the event used by QoiPreviewHandler const wchar_t QOI_PREVIEW_RESIZE_EVENT[] = L"Local\\PowerToysQoiPreviewResizeEvent-579518d1-8c8b-494f-8143-04f43d761ead"; @@ -118,6 +136,7 @@ namespace CommonSharedConstants // Path to the events used by CropAndLock const wchar_t CROP_AND_LOCK_REPARENT_EVENT[] = L"Local\\PowerToysCropAndLockReparentEvent-6060860a-76a1-44e8-8d0e-6355785e9c36"; const wchar_t CROP_AND_LOCK_THUMBNAIL_EVENT[] = L"Local\\PowerToysCropAndLockThumbnailEvent-1637be50-da72-46b2-9220-b32b206b2434"; + const wchar_t CROP_AND_LOCK_SCREENSHOT_EVENT[] = L"Local\\PowerToysCropAndLockScreenshotEvent-ff077ab2-8360-4bd1-864a-637389d35593"; const wchar_t CROP_AND_LOCK_EXIT_EVENT[] = L"Local\\PowerToysCropAndLockExitEvent-d995d409-7b70-482b-bad6-e7c8666f375a"; // Path to the events used by EnvironmentVariables @@ -127,6 +146,29 @@ namespace CommonSharedConstants // Path to the events used by ZoomIt const wchar_t ZOOMIT_REFRESH_SETTINGS_EVENT[] = L"Local\\PowerToysZoomIt-RefreshSettingsEvent-f053a563-d519-4b0d-8152-a54489c13324"; const wchar_t ZOOMIT_EXIT_EVENT[] = L"Local\\PowerToysZoomIt-ExitEvent-36641ce6-df02-4eac-abea-a3fbf9138220"; + const wchar_t ZOOMIT_ZOOM_EVENT[] = L"Local\\PowerToysZoomIt-ZoomEvent-1e4190d7-94bc-4ad5-adc0-9a8fd07cb393"; + const wchar_t ZOOMIT_DRAW_EVENT[] = L"Local\\PowerToysZoomIt-DrawEvent-56338997-404d-4549-bd9a-d132b6766975"; + const wchar_t ZOOMIT_BREAK_EVENT[] = L"Local\\PowerToysZoomIt-BreakEvent-17f2e63c-4c56-41dd-90a0-2d12f9f50c6b"; + const wchar_t ZOOMIT_LIVEZOOM_EVENT[] = L"Local\\PowerToysZoomIt-LiveZoomEvent-390bf0c7-616f-47dc-bafe-a2d228add20d"; + 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"; diff --git a/src/common/logger/logger.vcxproj b/src/common/logger/logger.vcxproj index 3e5529a747..9df3fba318 100644 --- a/src/common/logger/logger.vcxproj +++ b/src/common/logger/logger.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|Win32"> <Configuration>Debug</Configuration> @@ -33,14 +34,12 @@ <ProjectGuid>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</ProjectGuid> <RootNamespace>logger</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> - <OutDir>..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> - <Import Project="..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionSettings"> </ImportGroup> <ImportGroup Label="Shared"> @@ -83,13 +82,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index dab7ad64a2..6f0592ea53 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -8,75 +8,82 @@ struct LogSettings inline const static std::wstring logLevelOption = L"logLevel"; inline const static std::string runnerLoggerName = "runner"; inline const static std::wstring logPath = L"Logs\\"; - inline const static std::wstring runnerLogPath = L"RunnerLogs\\runner-log.txt"; + inline const static std::wstring runnerLogPath = L"RunnerLogs\\runner-log.log"; inline const static std::string actionRunnerLoggerName = "action-runner"; - inline const static std::wstring actionRunnerLogPath = L"RunnerLogs\\action-runner-log.txt"; + inline const static std::wstring actionRunnerLogPath = L"RunnerLogs\\action-runner-log.log"; inline const static std::string updateLoggerName = "update"; - inline const static std::wstring updateLogPath = L"UpdateLogs\\update-log.txt"; + inline const static std::wstring updateLogPath = L"UpdateLogs\\update-log.log"; inline const static std::string fileExplorerLoggerName = "FileExplorer"; - inline const static std::wstring fileExplorerLogPath = L"Logs\\file-explorer-log.txt"; + inline const static std::wstring fileExplorerLogPath = L"Logs\\file-explorer-log.log"; inline const static std::string gcodePrevLoggerName = "GcodePrevHandler"; - inline const static std::wstring gcodePrevLogPath = L"logs\\FileExplorer_localLow\\GcodePreviewHandler\\gcode-prev-handler-log.txt"; + inline const static std::wstring gcodePrevLogPath = L"logs\\FileExplorer_localLow\\GcodePreviewHandler\\gcode-prev-handler-log.log"; inline const static std::string gcodeThumbLoggerName = "GcodeThumbnailProvider"; - inline const static std::wstring gcodeThumbLogPath = L"logs\\FileExplorer_localLow\\GcodeThumbnailProvider\\gcode-thumbnail-provider-log.txt"; + inline const static std::wstring gcodeThumbLogPath = L"logs\\FileExplorer_localLow\\GcodeThumbnailProvider\\gcode-thumbnail-provider-log.log"; + inline const static std::string bgcodePrevLoggerName = "bgcodePrevHandler"; + inline const static std::wstring bgcodePrevLogPath = L"logs\\FileExplorer_localLow\\BgcodePreviewHandler\\bgcode-prev-handler-log.log"; + inline const static std::string bgcodeThumbLoggerName = "BgcodeThumbnailProvider"; + inline const static std::wstring bgcodeThumbLogPath = L"logs\\FileExplorer_localLow\\BgcodeThumbnailProvider\\bgcode-thumbnail-provider-log.log"; inline const static std::string mdPrevLoggerName = "MDPrevHandler"; - inline const static std::wstring mdPrevLogPath = L"logs\\FileExplorer_localLow\\MDPrevHandler\\md-prev-handler-log.txt"; + inline const static std::wstring mdPrevLogPath = L"logs\\FileExplorer_localLow\\MDPrevHandler\\md-prev-handler-log.log"; inline const static std::string monacoPrevLoggerName = "MonacoPrevHandler"; - inline const static std::wstring monacoPrevLogPath = L"logs\\FileExplorer_localLow\\MonacoPrevHandler\\monaco-prev-handler-log.txt"; + inline const static std::wstring monacoPrevLogPath = L"logs\\FileExplorer_localLow\\MonacoPrevHandler\\monaco-prev-handler-log.log"; inline const static std::string pdfPrevLoggerName = "PdfPrevHandler"; - inline const static std::wstring pdfPrevLogPath = L"logs\\FileExplorer_localLow\\PdfPrevHandler\\pdf-prev-handler-log.txt"; + inline const static std::wstring pdfPrevLogPath = L"logs\\FileExplorer_localLow\\PdfPrevHandler\\pdf-prev-handler-log.log"; inline const static std::string pdfThumbLoggerName = "PdfThumbnailProvider"; - inline const static std::wstring pdfThumbLogPath = L"logs\\FileExplorer_localLow\\PdfThumbnailProvider\\pdf-thumbnail-provider-log.txt"; + inline const static std::wstring pdfThumbLogPath = L"logs\\FileExplorer_localLow\\PdfThumbnailProvider\\pdf-thumbnail-provider-log.log"; inline const static std::string qoiPrevLoggerName = "QoiPrevHandler"; - inline const static std::wstring qoiPrevLogPath = L"logs\\FileExplorer_localLow\\QoiPreviewHandler\\qoi-prev-handler-log.txt"; + inline const static std::wstring qoiPrevLogPath = L"logs\\FileExplorer_localLow\\QoiPreviewHandler\\qoi-prev-handler-log.log"; inline const static std::string qoiThumbLoggerName = "QoiThumbnailProvider"; - inline const static std::wstring qoiThumbLogPath = L"logs\\FileExplorer_localLow\\QoiThumbnailProvider\\qoi-thumbnail-provider-log.txt"; + inline const static std::wstring qoiThumbLogPath = L"logs\\FileExplorer_localLow\\QoiThumbnailProvider\\qoi-thumbnail-provider-log.log"; inline const static std::string stlThumbLoggerName = "StlThumbnailProvider"; - inline const static std::wstring stlThumbLogPath = L"logs\\FileExplorer_localLow\\StlThumbnailProvider\\stl-thumbnail-provider-log.txt"; + inline const static std::wstring stlThumbLogPath = L"logs\\FileExplorer_localLow\\StlThumbnailProvider\\stl-thumbnail-provider-log.log"; inline const static std::string svgPrevLoggerName = "SvgPrevHandler"; - inline const static std::wstring svgPrevLogPath = L"logs\\FileExplorer_localLow\\SvgPrevHandler\\svg-prev-handler-log.txt"; + inline const static std::wstring svgPrevLogPath = L"logs\\FileExplorer_localLow\\SvgPrevHandler\\svg-prev-handler-log.log"; inline const static std::string svgThumbLoggerName = "SvgThumbnailProvider"; - inline const static std::wstring svgThumbLogPath = L"logs\\FileExplorer_localLow\\SvgThumbnailProvider\\svg-thumbnail-provider-log.txt"; + inline const static std::wstring svgThumbLogPath = L"logs\\FileExplorer_localLow\\SvgThumbnailProvider\\svg-thumbnail-provider-log.log"; inline const static std::string launcherLoggerName = "launcher"; - inline const static std::wstring launcherLogPath = L"LogsModuleInterface\\launcher-log.txt"; + inline const static std::wstring launcherLogPath = L"LogsModuleInterface\\launcher-log.log"; inline const static std::string mouseWithoutBordersLoggerName = "mouseWithoutBorders"; - inline const static std::wstring mouseWithoutBordersLogPath = L"LogsModuleInterface\\mouseWithoutBorders-log.txt"; - inline const static std::wstring awakeLogPath = L"Logs\\awake-log.txt"; - inline const static std::wstring powerAccentLogPath = L"quick-accent-log.txt"; + inline const static std::wstring mouseWithoutBordersLogPath = L"LogsModuleInterface\\mouseWithoutBorders-log.log"; + inline const static std::wstring awakeLogPath = L"Logs\\awake-log.log"; + inline const static std::wstring powerAccentLogPath = L"quick-accent-log.log"; inline const static std::string fancyZonesLoggerName = "fancyzones"; - inline const static std::wstring fancyZonesLogPath = L"fancyzones-log.txt"; + inline const static std::wstring fancyZonesLogPath = L"fancyzones-log.log"; inline const static std::wstring fancyZonesOldLogPath = L"FancyZonesLogs\\"; // needed to clean up old logs inline const static std::string shortcutGuideLoggerName = "shortcut-guide"; - inline const static std::wstring shortcutGuideLogPath = L"ShortcutGuideLogs\\shortcut-guide-log.txt"; - inline const static std::wstring powerOcrLogPath = L"Logs\\text-extractor-log.txt"; + inline const static std::wstring shortcutGuideLogPath = L"ShortcutGuideLogs\\shortcut-guide-log.log"; + inline const static std::wstring powerOcrLogPath = L"Logs\\text-extractor-log.log"; inline const static std::string keyboardManagerLoggerName = "keyboard-manager"; - inline const static std::wstring keyboardManagerLogPath = L"Logs\\keyboard-manager-log.txt"; + inline const static std::wstring keyboardManagerLogPath = L"Logs\\keyboard-manager-log.log"; inline const static std::string findMyMouseLoggerName = "find-my-mouse"; inline const static std::string mouseHighlighterLoggerName = "mouse-highlighter"; inline const static std::string mouseJumpLoggerName = "mouse-jump"; inline const static std::string mousePointerCrosshairsLoggerName = "mouse-pointer-crosshairs"; + inline const static std::string cursorWrapLoggerName = "cursor-wrap"; inline const static std::string imageResizerLoggerName = "imageresizer"; inline const static std::string powerRenameLoggerName = "powerrename"; inline const static std::string alwaysOnTopLoggerName = "always-on-top"; inline const static std::string powerOcrLoggerName = "TextExtractor"; inline const static std::string fileLocksmithLoggerName = "FileLocksmith"; - inline const static std::wstring alwaysOnTopLogPath = L"always-on-top-log.txt"; + inline const static std::wstring alwaysOnTopLogPath = L"always-on-top-log.log"; inline const static std::string hostsLoggerName = "hosts"; - inline const static std::wstring hostsLogPath = L"Logs\\hosts-log.txt"; + inline const static std::wstring hostsLogPath = L"Logs\\hosts-log.log"; inline const static std::string registryPreviewLoggerName = "registrypreview"; inline const static std::string cropAndLockLoggerName = "crop-and-lock"; - inline const static std::wstring registryPreviewLogPath = L"Logs\\registryPreview-log.txt"; + inline const static std::wstring registryPreviewLogPath = L"Logs\\registryPreview-log.log"; inline const static std::string environmentVariablesLoggerName = "environment-variables"; - inline const static std::wstring cmdNotFoundLogPath = L"Logs\\cmd-not-found-log.txt"; + inline const static std::wstring cmdNotFoundLogPath = L"Logs\\cmd-not-found-log.log"; inline const static std::string cmdNotFoundLoggerName = "cmd-not-found"; inline const static std::string newLoggerName = "NewPlus"; inline const static std::string workspacesLauncherLoggerName = "workspaces-launcher"; - inline const static std::wstring workspacesLauncherLogPath = L"workspaces-launcher-log.txt"; + inline const static std::wstring workspacesLauncherLogPath = L"workspaces-launcher-log.log"; inline const static std::string workspacesWindowArrangerLoggerName = "workspaces-window-arranger"; - inline const static std::wstring workspacesWindowArrangerLogPath = L"workspaces-window-arranger-log.txt"; + inline const static std::wstring workspacesWindowArrangerLogPath = L"workspaces-window-arranger-log.log"; inline const static std::string workspacesSnapshotToolLoggerName = "workspaces-snapshot-tool"; - inline const static std::wstring workspacesSnapshotToolLogPath = L"workspaces-snapshot-tool-log.txt"; + 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/logger/packages.config b/src/common/logger/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/common/logger/packages.config +++ b/src/common/logger/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj b/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj index 077333a664..65a087a4c1 100644 --- a/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj +++ b/src/common/notifications/BackgroundActivator/BackgroundActivator.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -15,10 +16,9 @@ <ApplicationType>Windows Store</ApplicationType> <ApplicationTypeRevision>10.0</ApplicationTypeRevision> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <GenerateManifest>false</GenerateManifest> </PropertyGroup> @@ -44,7 +44,8 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <TargetName>notifications</TargetName> + <!-- Use unique name to avoid conflict with parent notifications.vcxproj --> + <TargetName>BackgroundActivator</TargetName> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> <ClCompile> @@ -60,7 +61,8 @@ <ClCompile> <PrecompiledHeaderOutputFile>$(IntDir)pch.pch</PrecompiledHeaderOutputFile> <WarningLevel>Level4</WarningLevel> - <AdditionalOptions>%(AdditionalOptions) /bigobj</AdditionalOptions> + <!-- /FS required for parallel builds - prevents C1041 PDB conflicts --> + <AdditionalOptions>%(AdditionalOptions) /bigobj /FS</AdditionalOptions> <PreprocessorDefinitions>_WINRT_DLL;WIN32_LEAN_AND_MEAN;WINRT_LEAN_AND_MEAN;%(PreprocessorDefinitions)</PreprocessorDefinitions> <AdditionalUsingDirectories>$(WindowsSDK_WindowsMetadata);$(AdditionalUsingDirectories)</AdditionalUsingDirectories> </ClCompile> @@ -96,13 +98,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/notifications/BackgroundActivator/packages.config b/src/common/notifications/BackgroundActivator/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/common/notifications/BackgroundActivator/packages.config +++ b/src/common/notifications/BackgroundActivator/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj b/src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj index 88a9a6b5f2..a2383b6ff0 100644 --- a/src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj +++ b/src/common/notifications/BackgroundActivatorDLL/BackgroundActivatorDLL.vcxproj @@ -1,16 +1,16 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{031AC72E-FA28-4AB7-B690-6F7B9C28AA73}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>BackgroundActivatorDLL</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -64,10 +64,10 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\version\version.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\version\version.vcxproj"> <Project>{cc6e41ac-8174-4e8a-8d22-85dd7f4851df}</Project> </ProjectReference> <ProjectReference Include="..\BackgroundActivator\BackgroundActivator.vcxproj"> @@ -81,7 +81,7 @@ <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/notifications/BackgroundActivatorDLL/packages.config b/src/common/notifications/BackgroundActivatorDLL/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/common/notifications/BackgroundActivatorDLL/packages.config +++ b/src/common/notifications/BackgroundActivatorDLL/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/notifications/notifications.cpp b/src/common/notifications/notifications.cpp index 0190e088b2..8acdaad754 100644 --- a/src/common/notifications/notifications.cpp +++ b/src/common/notifications/notifications.cpp @@ -190,7 +190,7 @@ void notifications::show_toast_with_activations(std::wstring message, // We must set toast's title and contents immediately, because some of the toasts we send could be snoozed. // Windows instantiates the snoozed toast from scratch before showing it again, so all bindings that were set // using NotificationData would be empty. - // Add the launch attribute if launch_uri is provided, otherwise omit it + // Add the launch attribute if launch_uri is provided; otherwise, omit it toast_xml += LR"(<?xml version="1.0"?>)"; if (!launch_uri.empty()) { diff --git a/src/common/notifications/notifications.vcxproj b/src/common/notifications/notifications.vcxproj index b55d67e7b3..bb124ab4ba 100644 --- a/src/common/notifications/notifications.vcxproj +++ b/src/common/notifications/notifications.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{1D5BE09D-78C0-4FD7-AF00-AE7C1AF7C525}</ProjectGuid> @@ -8,10 +9,9 @@ <RootNamespace>notifications</RootNamespace> <ProjectName>Notifications</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -21,7 +21,8 @@ <PropertyGroup Label="UserMacros" /> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalOptions>/FS %(AdditionalOptions)</AdditionalOptions> <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> </ItemDefinitionGroup> @@ -44,15 +45,15 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/common/notifications/packages.config b/src/common/notifications/packages.config index ff4b059648..d3882436a5 100644 --- a/src/common/notifications/packages.config +++ b/src/common/notifications/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/updating/installer.cpp b/src/common/updating/installer.cpp index 2be4a97c5f..0e491392d5 100644 --- a/src/common/updating/installer.cpp +++ b/src/common/updating/installer.cpp @@ -18,7 +18,7 @@ namespace // Strings in this namespace should not be localized namespace updating { - std::future<bool> uninstall_previous_msix_version_async() + winrt::Windows::Foundation::IAsyncOperation<bool> uninstall_previous_msix_version_async() { winrt::Windows::Management::Deployment::PackageManager package_manager; diff --git a/src/common/updating/installer.h b/src/common/updating/installer.h index 5f2ca4bbb9..0b3694e17d 100644 --- a/src/common/updating/installer.h +++ b/src/common/updating/installer.h @@ -2,11 +2,11 @@ #include <string> #include <optional> -#include <future> +#include <winrt/Windows.Foundation.h> #include <common/version/helper.h> namespace updating { - std::future<bool> uninstall_previous_msix_version_async(); -} \ No newline at end of file + winrt::Windows::Foundation::IAsyncOperation<bool> uninstall_previous_msix_version_async(); +} diff --git a/src/common/updating/packages.config b/src/common/updating/packages.config index ff4b059648..d3882436a5 100644 --- a/src/common/updating/packages.config +++ b/src/common/updating/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/common/updating/pch.h b/src/common/updating/pch.h index 41408474c9..e57cdb04f9 100644 --- a/src/common/updating/pch.h +++ b/src/common/updating/pch.h @@ -33,6 +33,7 @@ #include <winrt/Windows.System.h> #include <wil/resource.h> +#include <wil/coroutine.h> #endif //PCH_H diff --git a/src/common/updating/updating.cpp b/src/common/updating/updating.cpp index 9d80662d54..d4d6fc3a8c 100644 --- a/src/common/updating/updating.cpp +++ b/src/common/updating/updating.cpp @@ -82,11 +82,7 @@ namespace updating // prevent the warning that may show up depend on the value of the constants (#defines) #pragma warning(push) #pragma warning(disable : 4702) -#if USE_STD_EXPECTED - std::future<std::expected<github_version_info, std::wstring>> get_github_version_info_async(const bool prerelease) -#else - std::future<nonstd::expected<github_version_info, std::wstring>> get_github_version_info_async(const bool prerelease) -#endif + wil::task<github_version_result> get_github_version_info_async(const bool prerelease) { // If the current version starts with 0.0.*, it means we're on a local build from a farm and shouldn't check for updates. if constexpr (VERSION_MAJOR == 0 && VERSION_MINOR == 0) @@ -170,7 +166,7 @@ namespace updating return !ec ? std::optional{ installer_download_path } : std::nullopt; } - std::future<std::optional<std::filesystem::path>> download_new_version(const new_version_download_info& new_version) + wil::task<std::optional<std::filesystem::path>> download_new_version_async(new_version_download_info new_version) { auto installer_download_path = create_download_path(); if (!installer_download_path) diff --git a/src/common/updating/updating.h b/src/common/updating/updating.h index 148a5e5b25..4afedfa928 100644 --- a/src/common/updating/updating.h +++ b/src/common/updating/updating.h @@ -2,7 +2,6 @@ #include <optional> #include <string> -#include <future> #include <filesystem> #include <variant> #include <winrt/Windows.Foundation.h> @@ -16,6 +15,7 @@ #endif #include <common/version/helper.h> +#include <wil/coroutine.h> namespace updating { @@ -32,13 +32,15 @@ namespace updating }; using github_version_info = std::variant<new_version_download_info, version_up_to_date>; - std::future<std::optional<std::filesystem::path>> download_new_version(const new_version_download_info& new_version); - std::filesystem::path get_pending_updates_path(); #if USE_STD_EXPECTED - std::future<std::expected<github_version_info, std::wstring>> get_github_version_info_async(const bool prerelease = false); + using github_version_result = std::expected<github_version_info, std::wstring>; #else - std::future<nonstd::expected<github_version_info, std::wstring>> get_github_version_info_async(const bool prerelease = false); + using github_version_result = nonstd::expected<github_version_info, std::wstring>; #endif + + wil::task<github_version_result> get_github_version_info_async(bool prerelease = false); + wil::task<std::optional<std::filesystem::path>> download_new_version_async(new_version_download_info new_version); + std::filesystem::path get_pending_updates_path(); void cleanup_updates(); // non-localized diff --git a/src/common/updating/updating.vcxproj b/src/common/updating/updating.vcxproj index bfcf1f22b4..6582b15d22 100644 --- a/src/common/updating/updating.vcxproj +++ b/src/common/updating/updating.vcxproj @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{17DA04DF-E393-4397-9CF0-84DABE11032E}</ProjectGuid> @@ -12,7 +12,7 @@ <Import Project="..\..\..\deps\expected.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <Import Project="..\..\..\deps\spdlog.props" /> @@ -57,15 +57,15 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file 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/EventWaiter.h b/src/common/utils/EventWaiter.h index b9f420c81d..c2db880530 100644 --- a/src/common/utils/EventWaiter.h +++ b/src/common/utils/EventWaiter.h @@ -3,78 +3,128 @@ #include <functional> #include <thread> #include <string> +#include <atomic> #include <windows.h> +/// <summary> +/// A reusable utility class that listens for a named Windows event and invokes a callback when triggered. +/// Provides RAII-based resource management for event handles and the listener thread. +/// The thread is properly joined on destruction to ensure clean shutdown. +/// </summary> class EventWaiter { public: - EventWaiter() {} - EventWaiter(const std::wstring& name, std::function<void(DWORD)> callback) + EventWaiter() = default; + + EventWaiter(const EventWaiter&) = delete; + EventWaiter& operator=(const EventWaiter&) = delete; + EventWaiter(EventWaiter&&) = delete; + EventWaiter& operator=(EventWaiter&&) = delete; + + ~EventWaiter() { - // Create localExitThreadEvent and localWaitingEvent for capturing. We cannot capture 'this' as we implement move constructor. - auto localExitThreadEvent = exitThreadEvent = CreateEvent(nullptr, false, false, nullptr); - HANDLE localWaitingEvent = waitingEvent = CreateEvent(nullptr, false, false, name.c_str()); - std::thread([=]() { - HANDLE events[2] = { localWaitingEvent, localExitThreadEvent }; - while (true) + stop(); + } + + /// <summary> + /// Starts listening for the specified named event. When the event is signaled, the callback is invoked. + /// </summary> + /// <param name="name">The name of the Windows event to listen for.</param> + /// <param name="callback">The callback function to invoke when the event is triggered. Receives ERROR_SUCCESS on success.</param> + /// <returns>true if listening started successfully, false otherwise.</returns> + bool start(const std::wstring& name, std::function<void(DWORD)> callback) + { + if (m_listening) + { + return false; + } + + m_exitThreadEvent = CreateEventW(nullptr, false, false, nullptr); + m_waitingEvent = CreateEventW(nullptr, false, false, name.c_str()); + + if (!m_exitThreadEvent || !m_waitingEvent) + { + cleanup(); + return false; + } + + m_listening = true; + m_eventThread = std::thread([this, cb = std::move(callback)]() { + HANDLE events[2] = { m_waitingEvent, m_exitThreadEvent }; + while (m_listening) { auto waitResult = WaitForMultipleObjects(2, events, false, INFINITE); + if (!m_listening) + { + break; + } + if (waitResult == WAIT_OBJECT_0 + 1) { + // Exit event signaled break; } if (waitResult == WAIT_FAILED) { - callback(GetLastError()); + cb(GetLastError()); continue; } if (waitResult == WAIT_OBJECT_0) { - callback(ERROR_SUCCESS); + cb(ERROR_SUCCESS); } } - }).detach(); + }); + + return true; } - EventWaiter(EventWaiter&) = delete; - EventWaiter& operator=(EventWaiter&) = delete; - - EventWaiter(EventWaiter&& a) noexcept + /// <summary> + /// Stops listening for the event and cleans up resources. + /// Waits for the listener thread to finish before returning. + /// Safe to call multiple times. + /// </summary> + void stop() { - this->exitThreadEvent = a.exitThreadEvent; - this->waitingEvent = a.waitingEvent; - - a.exitThreadEvent = nullptr; - a.waitingEvent = nullptr; - } - - EventWaiter& operator=(EventWaiter&& a) noexcept - { - this->exitThreadEvent = a.exitThreadEvent; - this->waitingEvent = a.waitingEvent; - - a.exitThreadEvent = nullptr; - a.waitingEvent = nullptr; - return *this; - } - - ~EventWaiter() - { - if (exitThreadEvent) + m_listening = false; + if (m_exitThreadEvent) { - SetEvent(exitThreadEvent); - CloseHandle(exitThreadEvent); + SetEvent(m_exitThreadEvent); } - - if (waitingEvent) + if (m_eventThread.joinable()) { - CloseHandle(waitingEvent); + m_eventThread.join(); } + cleanup(); + } + + /// <summary> + /// Returns whether the listener is currently active. + /// </summary> + bool is_listening() const + { + return m_listening; } private: - HANDLE exitThreadEvent = nullptr; - HANDLE waitingEvent = nullptr; + void cleanup() + { + if (m_exitThreadEvent) + { + CloseHandle(m_exitThreadEvent); + m_exitThreadEvent = nullptr; + } + if (m_waitingEvent) + { + CloseHandle(m_waitingEvent); + m_waitingEvent = nullptr; + } + } + + HANDLE m_exitThreadEvent = nullptr; + HANDLE m_waitingEvent = nullptr; + std::thread m_eventThread; + std::atomic_bool m_listening{ false }; }; \ No newline at end of file diff --git a/src/common/utils/HttpClient.h b/src/common/utils/HttpClient.h index 8726368fbb..ff9b47917f 100644 --- a/src/common/utils/HttpClient.h +++ b/src/common/utils/HttpClient.h @@ -1,6 +1,7 @@ #pragma once -#include <future> +#include <functional> +#include <string> #include <winrt/Windows.Foundation.h> #include <winrt/Windows.Storage.Streams.h> #include <winrt/Windows.Web.Http.h> @@ -21,15 +22,15 @@ namespace http headers.UserAgent().TryParseAdd(USER_AGENT); } - std::future<std::wstring> request(const winrt::Windows::Foundation::Uri& url) + winrt::Windows::Foundation::IAsyncOperation<winrt::hstring> request(winrt::Windows::Foundation::Uri url) { auto response = co_await m_client.GetAsync(url); (void)response.EnsureSuccessStatusCode(); auto body = co_await response.Content().ReadAsStringAsync(); - co_return std::wstring(body); + co_return body; } - std::future<void> download(const winrt::Windows::Foundation::Uri& url, const std::wstring& dstFilePath) + winrt::Windows::Foundation::IAsyncAction download(winrt::Windows::Foundation::Uri url, std::wstring dstFilePath) { auto response = co_await m_client.GetAsync(url); (void)response.EnsureSuccessStatusCode(); @@ -38,7 +39,7 @@ namespace http file_stream.Close(); } - std::future<void> download(const winrt::Windows::Foundation::Uri& url, const std::wstring& dstFilePath, const std::function<void(float)>& progressUpdateCallback) + winrt::Windows::Foundation::IAsyncAction download(winrt::Windows::Foundation::Uri url, std::wstring dstFilePath, std::function<void(float)> progressUpdateCallback) { auto response = co_await m_client.GetAsync(url, HttpCompletionOption::ResponseHeadersRead); response.EnsureSuccessStatusCode(); 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 <Windows.h> +#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<void(DWORD)> 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<long> _refCount; -}; \ No newline at end of file +}; diff --git a/src/common/utils/elevation.h b/src/common/utils/elevation.h index 7f2ecbf6df..e412ce5aa3 100644 --- a/src/common/utils/elevation.h +++ b/src/common/utils/elevation.h @@ -257,7 +257,9 @@ inline HANDLE run_elevated(const std::wstring& file, const std::wstring& params, exec_info.nShow = SW_HIDE; } - return ShellExecuteExW(&exec_info) ? exec_info.hProcess : nullptr; + BOOL result = ShellExecuteExW(&exec_info); + + return result ? exec_info.hProcess : nullptr; } // Run command as non-elevated user, returns true if succeeded, puts the process id into returnPid if returnPid != NULL diff --git a/src/common/utils/gpo.h b/src/common/utils/gpo.h index 5d79fa3b01..0b2611b076 100644 --- a/src/common/utils/gpo.h +++ b/src/common/utils/gpo.h @@ -3,6 +3,7 @@ #include <Windows.h> #include <optional> #include <vector> +#include <string> namespace powertoys_gpo { @@ -30,6 +31,8 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_CMD_NOT_FOUND = L"ConfigureEnabledUtilityCmdNotFound"; 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"; @@ -37,9 +40,11 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_MONACO_PREVIEW = L"ConfigureEnabledUtilityFileExplorerMonacoPreview"; const std::wstring POLICY_CONFIGURE_ENABLED_PDF_PREVIEW = L"ConfigureEnabledUtilityFileExplorerPDFPreview"; const std::wstring POLICY_CONFIGURE_ENABLED_GCODE_PREVIEW = L"ConfigureEnabledUtilityFileExplorerGcodePreview"; + const std::wstring POLICY_CONFIGURE_ENABLED_BGCODE_PREVIEW = L"ConfigureEnabledUtilityFileExplorerBgcodePreview"; const std::wstring POLICY_CONFIGURE_ENABLED_SVG_THUMBNAILS = L"ConfigureEnabledUtilityFileExplorerSVGThumbnails"; const std::wstring POLICY_CONFIGURE_ENABLED_PDF_THUMBNAILS = L"ConfigureEnabledUtilityFileExplorerPDFThumbnails"; const std::wstring POLICY_CONFIGURE_ENABLED_GCODE_THUMBNAILS = L"ConfigureEnabledUtilityFileExplorerGcodeThumbnails"; + const std::wstring POLICY_CONFIGURE_ENABLED_BGCODE_THUMBNAILS = L"ConfigureEnabledUtilityFileExplorerBgcodeThumbnails"; const std::wstring POLICY_CONFIGURE_ENABLED_STL_THUMBNAILS = L"ConfigureEnabledUtilityFileExplorerSTLThumbnails"; const std::wstring POLICY_CONFIGURE_ENABLED_HOSTS_FILE_EDITOR = L"ConfigureEnabledUtilityHostsFileEditor"; const std::wstring POLICY_CONFIGURE_ENABLED_IMAGE_RESIZER = L"ConfigureEnabledUtilityImageResizer"; @@ -48,6 +53,7 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_HIGHLIGHTER = L"ConfigureEnabledUtilityMouseHighlighter"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_JUMP = L"ConfigureEnabledUtilityMouseJump"; const std::wstring POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS = L"ConfigureEnabledUtilityMousePointerCrosshairs"; + const std::wstring POLICY_CONFIGURE_ENABLED_CURSOR_WRAP = L"ConfigureEnabledUtilityCursorWrap"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_RENAME = L"ConfigureEnabledUtilityPowerRename"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_LAUNCHER = L"ConfigureEnabledUtilityPowerLauncher"; const std::wstring POLICY_CONFIGURE_ENABLED_QUICK_ACCENT = L"ConfigureEnabledUtilityQuickAccent"; @@ -79,6 +85,13 @@ namespace powertoys_gpo const std::wstring POLICY_CONFIGURE_RUN_AT_STARTUP = L"ConfigureRunAtStartup"; const std::wstring POLICY_CONFIGURE_ENABLED_POWER_LAUNCHER_ALL_PLUGINS = L"PowerLauncherAllPluginsEnabledState"; const std::wstring POLICY_ALLOW_ADVANCED_PASTE_ONLINE_AI_MODELS = L"AllowPowerToysAdvancedPasteOnlineAIModels"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OPENAI = L"AllowAdvancedPasteOpenAI"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_AZURE_OPENAI = L"AllowAdvancedPasteAzureOpenAI"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_AZURE_AI_INFERENCE = L"AllowAdvancedPasteAzureAIInference"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_MISTRAL = L"AllowAdvancedPasteMistral"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_GOOGLE = L"AllowAdvancedPasteGoogle"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OLLAMA = L"AllowAdvancedPasteOllama"; + const std::wstring POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL = L"AllowAdvancedPasteFoundryLocal"; const std::wstring POLICY_MWB_CLIPBOARD_SHARING_ENABLED = L"MwbClipboardSharingEnabled"; const std::wstring POLICY_MWB_FILE_TRANSFER_ENABLED = L"MwbFileTransferEnabled"; const std::wstring POLICY_MWB_USE_ORIGINAL_USER_INTERFACE = L"MwbUseOriginalUserInterface"; @@ -207,7 +220,7 @@ namespace powertoys_gpo inline std::optional<std::wstring> getPolicyListValue(const std::wstring& registry_list_path, const std::wstring& registry_list_value_name) { - // This function returns the value of an entry of an policy list. The user scope is only checked, if the list is not enabled for the machine to not mix the lists. + // This function returns the value of an entry of a policy list. The user scope is only checked, if the list is not enabled for the machine to not mix the lists. HKEY key{}; @@ -293,6 +306,16 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_CROP_AND_LOCK); } + inline gpo_rule_configured_t getConfiguredLightSwitchEnabledValue() + { + 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); @@ -328,6 +351,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_GCODE_PREVIEW); } + inline gpo_rule_configured_t getConfiguredBgcodePreviewEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_BGCODE_PREVIEW); + } + inline gpo_rule_configured_t getConfiguredSvgThumbnailsEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_SVG_THUMBNAILS); @@ -343,6 +371,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_GCODE_THUMBNAILS); } + inline gpo_rule_configured_t getConfiguredBgcodeThumbnailsEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_BGCODE_THUMBNAILS); + } + inline gpo_rule_configured_t getConfiguredStlThumbnailsEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_STL_THUMBNAILS); @@ -383,6 +416,11 @@ namespace powertoys_gpo return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_MOUSE_POINTER_CROSSHAIRS); } + inline gpo_rule_configured_t getConfiguredCursorWrapEnabledValue() + { + return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_CURSOR_WRAP); + } + inline gpo_rule_configured_t getConfiguredPowerRenameEnabledValue() { return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_POWER_RENAME); @@ -557,6 +595,41 @@ namespace powertoys_gpo return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_ONLINE_AI_MODELS); } + inline gpo_rule_configured_t getAllowedAdvancedPasteOpenAIValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_OPENAI); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteAzureOpenAIValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_AZURE_OPENAI); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteAzureAIInferenceValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_AZURE_AI_INFERENCE); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteMistralValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_MISTRAL); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteGoogleValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_GOOGLE); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteOllamaValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_OLLAMA); + } + + inline gpo_rule_configured_t getAllowedAdvancedPasteFoundryLocalValue() + { + return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL); + } + inline gpo_rule_configured_t getConfiguredMwbClipboardSharingEnabledValue() { return getConfiguredValue(POLICY_MWB_CLIPBOARD_SHARING_ENABLED); diff --git a/src/common/utils/logger_helper.h b/src/common/utils/logger_helper.h index c2207525e3..1e7e937c5a 100644 --- a/src/common/utils/logger_helper.h +++ b/src/common/utils/logger_helper.h @@ -3,6 +3,7 @@ #include <filesystem> #include <common/version/version.h> #include <common/SettingsAPI/settings_helpers.h> +#include "../logger/logger.h" namespace LoggerHelpers { @@ -91,7 +92,7 @@ namespace LoggerHelpers currentFolder.append(get_product_version()); auto logsPath = currentFolder; - logsPath.append(L"log.txt"); + logsPath.append(L"log.log"); Logger::init(loggerName, logsPath.wstring(), PTSettingsHelper::get_log_settings_file_location()); delete_other_versions_log_folders(rootFolder.wstring(), currentFolder); diff --git a/src/common/utils/modulesRegistry.h b/src/common/utils/modulesRegistry.h index 8b957bd5b9..be6c04a969 100644 --- a/src/common/utils/modulesRegistry.h +++ b/src/common/utils/modulesRegistry.h @@ -17,6 +17,7 @@ namespace NonLocalizable const static std::vector<std::wstring> ExtMarkdown = { L".md", L".markdown", L".mdown", L".mkdn", L".mkd", L".mdwn", L".mdtxt", L".mdtext" }; const static std::vector<std::wstring> ExtPDF = { L".pdf" }; const static std::vector<std::wstring> ExtGCode = { L".gcode" }; + const static std::vector<std::wstring> ExtBGCode = { L".bgcode" }; const static std::vector<std::wstring> ExtSTL = { L".stl" }; const static std::vector<std::wstring> ExtQOI = { L".qoi" }; const static std::vector<std::wstring> ExtNoNoNo = { @@ -146,6 +147,19 @@ inline registry::ChangeSet getGcodePreviewHandlerChangeSet(const std::wstring in NonLocalizable::ExtGCode); } +inline registry::ChangeSet getBgcodePreviewHandlerChangeSet(const std::wstring installationDir, const bool perUser) +{ + using namespace registry::shellex; + return generatePreviewHandler(PreviewHandlerType::preview, + perUser, + L"{0e6d5bdd-d5f8-4692-a089-8bb88cdd37f4}", + get_std_product_version(), + (fs::path{ installationDir } / LR"d(PowerToys.BgcodePreviewHandlerCpp.dll)d").wstring(), + L"BgcodePreviewHandler", + L"Binary G-code Preview Handler", + NonLocalizable::ExtBGCode); +} + inline registry::ChangeSet getQoiPreviewHandlerChangeSet(const std::wstring installationDir, const bool perUser) { using namespace registry::shellex; @@ -200,6 +214,19 @@ inline registry::ChangeSet getGcodeThumbnailHandlerChangeSet(const std::wstring NonLocalizable::ExtGCode); } +inline registry::ChangeSet getBgcodeThumbnailHandlerChangeSet(const std::wstring installationDir, const bool perUser) +{ + using namespace registry::shellex; + return generatePreviewHandler(PreviewHandlerType::thumbnail, + perUser, + L"{5c93a1e4-99d0-4fb3-991c-6c296a27be21}", + get_std_product_version(), + (fs::path{ installationDir } / LR"d(PowerToys.BgcodeThumbnailProviderCpp.dll)d").wstring(), + L"BgcodeThumbnailProvider", + L"Binary G-code Thumbnail Provider", + NonLocalizable::ExtBGCode); +} + inline registry::ChangeSet getStlThumbnailHandlerChangeSet(const std::wstring installationDir, const bool perUser) { using namespace registry::shellex; @@ -275,9 +302,11 @@ inline std::vector<registry::ChangeSet> getAllOnByDefaultModulesChangeSets(const getMdPreviewHandlerChangeSet(installationDir, PER_USER), getMonacoPreviewHandlerChangeSet(installationDir, PER_USER), getGcodePreviewHandlerChangeSet(installationDir, PER_USER), + getBgcodePreviewHandlerChangeSet(installationDir, PER_USER), getQoiPreviewHandlerChangeSet(installationDir, PER_USER), getSvgThumbnailHandlerChangeSet(installationDir, PER_USER), getGcodeThumbnailHandlerChangeSet(installationDir, PER_USER), + getBgcodeThumbnailHandlerChangeSet(installationDir, PER_USER), getStlThumbnailHandlerChangeSet(installationDir, PER_USER), getQoiThumbnailHandlerChangeSet(installationDir, PER_USER), getRegistryPreviewChangeSet(installationDir, PER_USER) }; @@ -291,10 +320,12 @@ inline std::vector<registry::ChangeSet> getAllModulesChangeSets(const std::wstri getMonacoPreviewHandlerChangeSet(installationDir, PER_USER), getPdfPreviewHandlerChangeSet(installationDir, PER_USER), getGcodePreviewHandlerChangeSet(installationDir, PER_USER), + getBgcodePreviewHandlerChangeSet(installationDir, PER_USER), getQoiPreviewHandlerChangeSet(installationDir, PER_USER), getSvgThumbnailHandlerChangeSet(installationDir, PER_USER), getPdfThumbnailHandlerChangeSet(installationDir, PER_USER), getGcodeThumbnailHandlerChangeSet(installationDir, PER_USER), + getBgcodeThumbnailHandlerChangeSet(installationDir, PER_USER), getStlThumbnailHandlerChangeSet(installationDir, PER_USER), getQoiThumbnailHandlerChangeSet(installationDir, PER_USER), getRegistryPreviewChangeSet(installationDir, PER_USER), diff --git a/src/common/utils/package.h b/src/common/utils/package.h index 10855eb99c..6db77d593f 100644 --- a/src/common/utils/package.h +++ b/src/common/utils/package.h @@ -2,11 +2,15 @@ #include <Windows.h> +#include <algorithm> +#include <appxpackaging.h> #include <exception> #include <filesystem> #include <regex> #include <string> #include <optional> +#include <Shlwapi.h> +#include <wrl/client.h> #include <winrt/Windows.ApplicationModel.h> #include <winrt/Windows.Foundation.h> @@ -15,11 +19,19 @@ #include "../logger/logger.h" #include "../version/version.h" -namespace package { - - using namespace winrt::Windows::Foundation; - using namespace winrt::Windows::ApplicationModel; - using namespace winrt::Windows::Management::Deployment; +namespace package +{ + 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() { @@ -46,6 +58,118 @@ namespace package { dwlConditionMask); } + struct PACKAGE_VERSION + { + UINT16 Major; + UINT16 Minor; + UINT16 Build; + UINT16 Revision; + }; + + class ComInitializer + { + public: + explicit ComInitializer(DWORD coInitFlags = COINIT_MULTITHREADED) : + _initialized(false) + { + const HRESULT hr = CoInitializeEx(nullptr, coInitFlags); + _initialized = SUCCEEDED(hr); + } + + ~ComInitializer() + { + if (_initialized) + { + CoUninitialize(); + } + } + + bool Succeeded() const { return _initialized; } + + private: + bool _initialized; + }; + + inline bool GetPackageNameAndVersionFromAppx( + const std::wstring& appxPath, + std::wstring& outName, + PACKAGE_VERSION& outVersion) + { + try + { + ComInitializer comInit; + if (!comInit.Succeeded()) + { + Logger::error(L"COM initialization failed."); + return false; + } + + ComPtr<IAppxFactory> factory; + ComPtr<IStream> stream; + ComPtr<IAppxPackageReader> reader; + ComPtr<IAppxManifestReader> manifest; + ComPtr<IAppxManifestPackageId> packageId; + + HRESULT hr = CoCreateInstance(__uuidof(AppxFactory), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory)); + if (FAILED(hr)) + return false; + + hr = SHCreateStreamOnFileEx(appxPath.c_str(), STGM_READ | STGM_SHARE_DENY_WRITE, FILE_ATTRIBUTE_NORMAL, FALSE, nullptr, &stream); + if (FAILED(hr)) + return false; + + hr = factory->CreatePackageReader(stream.Get(), &reader); + if (FAILED(hr)) + return false; + + hr = reader->GetManifest(&manifest); + if (FAILED(hr)) + return false; + + hr = manifest->GetPackageId(&packageId); + if (FAILED(hr)) + return false; + + LPWSTR name = nullptr; + hr = packageId->GetName(&name); + if (FAILED(hr)) + return false; + + UINT64 version = 0; + hr = packageId->GetVersion(&version); + if (FAILED(hr)) + return false; + + outName = std::wstring(name); + CoTaskMemFree(name); + + outVersion.Major = static_cast<UINT16>((version >> 48) & 0xFFFF); + outVersion.Minor = static_cast<UINT16>((version >> 32) & 0xFFFF); + outVersion.Build = static_cast<UINT16>((version >> 16) & 0xFFFF); + outVersion.Revision = static_cast<UINT16>(version & 0xFFFF); + + Logger::info(L"Package name: {}, version: {}.{}.{}.{}, appxPath: {}", + outName, + outVersion.Major, + outVersion.Minor, + outVersion.Build, + outVersion.Revision, + appxPath); + + return true; + } + catch (const std::exception& ex) + { + Logger::error(L"Standard exception: {}", winrt::to_hstring(ex.what())); + return false; + } + catch (...) + { + Logger::error(L"Unknown or non-standard exception occurred."); + return false; + } + } + inline std::optional<Package> GetRegisteredPackage(std::wstring packageDisplayName, bool checkVersion) { PackageManager packageManager; @@ -185,6 +309,7 @@ namespace package { if (!std::filesystem::exists(directoryPath)) { Logger::error(L"The directory '" + directoryPath + L"' does not exist."); + return {}; } const std::regex pattern(R"(^.+\.(appx|msix|msixbundle)$)", std::regex_constants::icase); @@ -220,6 +345,30 @@ namespace package { } } } + + // Sort by package version in descending order (newest first) + std::sort(matchedFiles.begin(), matchedFiles.end(), [](const std::wstring& a, const std::wstring& b) { + std::wstring nameA, nameB; + PACKAGE_VERSION versionA{}, versionB{}; + + bool gotA = GetPackageNameAndVersionFromAppx(a, nameA, versionA); + bool gotB = GetPackageNameAndVersionFromAppx(b, nameB, versionB); + + // Files that failed to parse go to the end + if (!gotA) + return false; + if (!gotB) + return true; + + // Compare versions: Major, Minor, Build, Revision (descending) + if (versionA.Major != versionB.Major) + return versionA.Major > versionB.Major; + if (versionA.Minor != versionB.Minor) + return versionA.Minor > versionB.Minor; + if (versionA.Build != versionB.Build) + return versionA.Build > versionB.Build; + return versionA.Revision > versionB.Revision; + }); } catch (const std::exception& ex) { @@ -229,6 +378,59 @@ namespace package { return matchedFiles; } + inline bool IsPackageSatisfied(const std::wstring& appxPath) + { + std::wstring targetName; + PACKAGE_VERSION targetVersion{}; + + if (!GetPackageNameAndVersionFromAppx(appxPath, targetName, targetVersion)) + { + Logger::error(L"Failed to get package name and version from appx: " + appxPath); + return false; + } + + PackageManager pm; + + for (const auto& package : pm.FindPackagesForUser({})) + { + const auto& id = package.Id(); + if (std::wstring(id.Name()) == targetName) + { + const auto& version = id.Version(); + + if (version.Major > targetVersion.Major || + (version.Major == targetVersion.Major && version.Minor > targetVersion.Minor) || + (version.Major == targetVersion.Major && version.Minor == targetVersion.Minor && version.Build > targetVersion.Build) || + (version.Major == targetVersion.Major && version.Minor == targetVersion.Minor && version.Build == targetVersion.Build && version.Revision >= targetVersion.Revision)) + { + Logger::info( + L"Package {} is already satisfied with version {}.{}.{}.{}; target version {}.{}.{}.{}; appxPath: {}", + id.Name(), + version.Major, + version.Minor, + version.Build, + version.Revision, + targetVersion.Major, + targetVersion.Minor, + targetVersion.Build, + targetVersion.Revision, + appxPath); + return true; + } + } + } + + Logger::info( + L"Package {} is not satisfied. Target version: {}.{}.{}.{}; appxPath: {}", + targetName, + targetVersion.Major, + targetVersion.Minor, + targetVersion.Build, + targetVersion.Revision, + appxPath); + return false; + } + inline bool RegisterPackage(std::wstring pkgPath, std::vector<std::wstring> dependencies) { try @@ -238,16 +440,23 @@ namespace package { PackageManager packageManager; // Declare use of an external location - DeploymentOptions options = DeploymentOptions::ForceApplicationShutdown; + DeploymentOptions options = DeploymentOptions::ForceTargetApplicationShutdown; - Collections::IVector<Uri> uris = winrt::single_threaded_vector<Uri>(); + IVector<Uri> uris = winrt::single_threaded_vector<Uri>(); if (!dependencies.empty()) { for (const auto& dependency : dependencies) { try { - uris.Append(Uri(dependency)); + if (IsPackageSatisfied(dependency)) + { + Logger::info(L"Dependency already satisfied: {}", dependency); + } + else + { + uris.Append(Uri(dependency)); + } } catch (const winrt::hresult_error& ex) { @@ -282,7 +491,6 @@ namespace package { { Logger::debug(L"Register {} package started.", pkgPath); } - } catch (std::exception& e) { @@ -293,4 +501,4 @@ namespace package { return true; } -} \ No newline at end of file +} diff --git a/src/common/utils/registry.h b/src/common/utils/registry.h index 059589352d..c9770bbea3 100644 --- a/src/common/utils/registry.h +++ b/src/common/utils/registry.h @@ -16,9 +16,54 @@ namespace registry { + namespace detail + { + struct on_exit + { + std::function<void()> f; + + on_exit(std::function<void()> f) : + f{ std::move(f) } {} + ~on_exit() { f(); } + }; + + template<class... Ts> + struct overloaded : Ts... + { + using Ts::operator()...; + }; + + template<class... Ts> + overloaded(Ts...) -> overloaded<Ts...>; + + inline const wchar_t* getScopeName(HKEY scope) + { + if (scope == HKEY_LOCAL_MACHINE) + { + return L"HKLM"; + } + else if (scope == HKEY_CURRENT_USER) + { + return L"HKCU"; + } + else if (scope == HKEY_CLASSES_ROOT) + { + return L"HKCR"; + } + else + { + return L"HK??"; + } + } + } + namespace install_scope { const wchar_t INSTALL_SCOPE_REG_KEY[] = L"Software\\Classes\\powertoys\\"; + const wchar_t UNINSTALL_REG_KEY[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall"; + + // Bundle UpgradeCode from PowerToys.wxs (with braces as stored in registry) + const wchar_t BUNDLE_UPGRADE_CODE[] = L"{6341382D-C0A9-4238-9188-BE9607E3FAB2}"; enum class InstallScope { @@ -26,8 +71,67 @@ namespace registry PerUser, }; + // Helper function to find PowerToys bundle in Windows Uninstall registry by BundleUpgradeCode + inline bool find_powertoys_bundle_in_uninstall_registry(HKEY rootKey) + { + HKEY uninstallKey{}; + if (RegOpenKeyExW(rootKey, UNINSTALL_REG_KEY, 0, KEY_READ, &uninstallKey) != ERROR_SUCCESS) + { + return false; + } + detail::on_exit closeUninstallKey{ [uninstallKey] { RegCloseKey(uninstallKey); } }; + + DWORD index = 0; + wchar_t subKeyName[256]; + + // Enumerate all subkeys under Uninstall + while (RegEnumKeyW(uninstallKey, index++, subKeyName, 256) == ERROR_SUCCESS) + { + HKEY productKey{}; + if (RegOpenKeyExW(uninstallKey, subKeyName, 0, KEY_READ, &productKey) != ERROR_SUCCESS) + { + continue; + } + detail::on_exit closeProductKey{ [productKey] { RegCloseKey(productKey); } }; + + // Check BundleUpgradeCode value (specific to WiX Bundle installations) + wchar_t bundleUpgradeCode[256]{}; + DWORD bundleUpgradeCodeSize = sizeof(bundleUpgradeCode); + + if (RegQueryValueExW(productKey, L"BundleUpgradeCode", nullptr, nullptr, + reinterpret_cast<LPBYTE>(bundleUpgradeCode), &bundleUpgradeCodeSize) == ERROR_SUCCESS) + { + if (_wcsicmp(bundleUpgradeCode, BUNDLE_UPGRADE_CODE) == 0) + { + return true; + } + } + } + + return false; + } + inline const InstallScope get_current_install_scope() { + // 1. Check HKCU Uninstall registry first (user-level bundle) + // Note: MSI components are always in HKLM regardless of install scope, + // but the Bundle entry will be in HKCU for per-user installations + if (find_powertoys_bundle_in_uninstall_registry(HKEY_CURRENT_USER)) + { + Logger::info(L"Found user-level PowerToys bundle via BundleUpgradeCode in HKCU"); + return InstallScope::PerUser; + } + + // 2. Check HKLM Uninstall registry (machine-level bundle) + if (find_powertoys_bundle_in_uninstall_registry(HKEY_LOCAL_MACHINE)) + { + Logger::info(L"Found machine-level PowerToys bundle via BundleUpgradeCode in HKLM"); + return InstallScope::PerMachine; + } + + // 3. Fallback to legacy custom registry key detection + Logger::info(L"PowerToys bundle not found in Uninstall registry, falling back to legacy detection"); + // Open HKLM key HKEY perMachineKey{}; if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, @@ -45,6 +149,7 @@ namespace registry &perUserKey) != ERROR_SUCCESS) { // both keys are missing + Logger::warn(L"No PowerToys installation detected, defaulting to PerMachine"); return InstallScope::PerMachine; } else @@ -96,47 +201,6 @@ namespace registry template<class> inline constexpr bool always_false_v = false; - namespace detail - { - struct on_exit - { - std::function<void()> f; - - on_exit(std::function<void()> f) : - f{ std::move(f) } {} - ~on_exit() { f(); } - }; - - template<class... Ts> - struct overloaded : Ts... - { - using Ts::operator()...; - }; - - template<class... Ts> - overloaded(Ts...) -> overloaded<Ts...>; - - inline const wchar_t* getScopeName(HKEY scope) - { - if (scope == HKEY_LOCAL_MACHINE) - { - return L"HKLM"; - } - else if (scope == HKEY_CURRENT_USER) - { - return L"HKCU"; - } - else if (scope == HKEY_CLASSES_ROOT) - { - return L"HKCR"; - } - else - { - return L"HK??"; - } - } - } - struct ValueChange { using value_t = std::variant<DWORD, std::wstring>; diff --git a/src/common/utils/shell_ext_registration.h b/src/common/utils/shell_ext_registration.h new file mode 100644 index 0000000000..38fa344ab1 --- /dev/null +++ b/src/common/utils/shell_ext_registration.h @@ -0,0 +1,266 @@ +// Shared runtime shell extension registration utility for PowerToys modules. +// Provides a generic EnsureRegistered function so individual modules only need +// to supply a specification (CLSID, sentinel, handler key paths, etc.). + +#pragma once + +#include <string> +#include <vector> +#include <windows.h> +#include <shlwapi.h> + +#include "../logger/logger.h" + +namespace runtime_shell_ext +{ + struct Spec + { + // Mandatory + std::wstring clsid; // e.g. {GUID} + std::wstring sentinelKey; // e.g. Software\\Microsoft\\PowerToys\\ModuleName + std::wstring sentinelValue; // e.g. ContextMenuRegistered + std::vector<std::wstring> dllFileCandidates; // relative filenames (pick first existing) + std::vector<std::wstring> contextMenuHandlerKeyPaths; // full HKCU relative paths where default value = CLSID + + // Optional + std::wstring friendlyName; // if non-empty written as default under CLSID root + bool writeOptInEmptyValue = true; // write ContextMenuOptIn="" under CLSID root (legacy pattern) + bool writeThreadingModel = true; // write Apartment threading model + std::vector<std::wstring> extraAssociationPaths; // additional key paths (DragDropHandlers etc.) default=CLSID + std::vector<std::wstring> systemFileAssocExtensions; // e.g. .png -> Software\\Classes\\SystemFileAssociations\\.png\\ShellEx\\ContextMenuHandlers\\<HandlerName> + std::wstring systemFileAssocHandlerName; // e.g. ImageResizer + std::wstring representativeSystemExt; // used to decide if associations need repair (.png) + bool logRepairs = true; + }; + + namespace detail + { + // Minimal RAII wrapper for HKEY + struct unique_hkey + { + HKEY h{ nullptr }; + unique_hkey() = default; + explicit unique_hkey(HKEY handle) : h(handle) {} + ~unique_hkey() { if (h) RegCloseKey(h); } + unique_hkey(const unique_hkey&) = delete; + unique_hkey& operator=(const unique_hkey&) = delete; + unique_hkey(unique_hkey&& other) noexcept : h(other.h) { other.h = nullptr; } + unique_hkey& operator=(unique_hkey&& other) noexcept { if (this != &other) { if (h) RegCloseKey(h); h = other.h; other.h = nullptr; } return *this; } + HKEY get() const { return h; } + HKEY* put() { if (h) { RegCloseKey(h); h = nullptr; } return &h; } + }; + inline std::wstring base_dir_from_module(HMODULE h) + { + wchar_t buf[MAX_PATH]; + if (GetModuleFileNameW(h, buf, MAX_PATH)) + { + PathRemoveFileSpecW(buf); + return buf; + } + return L""; + } + + inline std::wstring pick_existing_dll(const std::wstring& base, const std::vector<std::wstring>& candidates) + { + for (const auto& rel : candidates) + { + std::wstring full = base + L"\\" + rel; + if (GetFileAttributesW(full.c_str()) != INVALID_FILE_ATTRIBUTES) + { + return full; + } + } + if (!candidates.empty()) + { + return base + L"\\" + candidates.front(); + } + return L""; + } + + inline bool sentinel_exists(const Spec& spec) + { + unique_hkey key; + if (RegOpenKeyExW(HKEY_CURRENT_USER, spec.sentinelKey.c_str(), 0, KEY_READ, key.put()) != ERROR_SUCCESS) + return false; + DWORD v = 0; DWORD sz = sizeof(v); + return RegQueryValueExW(key.get(), spec.sentinelValue.c_str(), nullptr, nullptr, reinterpret_cast<LPBYTE>(&v), &sz) == ERROR_SUCCESS && v == 1; + } + + inline void write_sentinel(const Spec& spec) + { + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, spec.sentinelKey.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + DWORD one = 1; + RegSetValueExW(key.get(), spec.sentinelValue.c_str(), 0, REG_DWORD, reinterpret_cast<const BYTE*>(&one), sizeof(one)); + } + } + + inline void write_inproc_server(const Spec& spec, const std::wstring& dllPath) + { + using namespace std::string_literals; + std::wstring clsidRoot = L"Software\\Classes\\CLSID\\"s + spec.clsid; + std::wstring inprocKey = clsidRoot + L"\\InprocServer32"; + { + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, clsidRoot.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + if (!spec.friendlyName.empty()) + { + RegSetValueExW(key.get(), nullptr, 0, REG_SZ, reinterpret_cast<const BYTE*>(spec.friendlyName.c_str()), static_cast<DWORD>((spec.friendlyName.size() + 1) * sizeof(wchar_t))); + } + if (spec.writeOptInEmptyValue) + { + const wchar_t* optIn = L"ContextMenuOptIn"; + const wchar_t empty = L'\0'; + RegSetValueExW(key.get(), optIn, 0, REG_SZ, reinterpret_cast<const BYTE*>(&empty), sizeof(empty)); + } + } + } + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, inprocKey.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + RegSetValueExW(key.get(), nullptr, 0, REG_SZ, reinterpret_cast<const BYTE*>(dllPath.c_str()), static_cast<DWORD>((dllPath.size() + 1) * sizeof(wchar_t))); + if (spec.writeThreadingModel) + { + const wchar_t* tm = L"Apartment"; + RegSetValueExW(key.get(), L"ThreadingModel", 0, REG_SZ, reinterpret_cast<const BYTE*>(tm), static_cast<DWORD>((wcslen(tm) + 1) * sizeof(wchar_t))); + } + } + } + + inline std::wstring read_inproc_server(const Spec& spec) + { + using namespace std::string_literals; + std::wstring inprocKey = L"Software\\Classes\\CLSID\\"s + spec.clsid + L"\\InprocServer32"; + unique_hkey key; + if (RegOpenKeyExW(HKEY_CURRENT_USER, inprocKey.c_str(), 0, KEY_READ, key.put()) != ERROR_SUCCESS) + return L""; + wchar_t buf[MAX_PATH]; DWORD sz = sizeof(buf); + if (RegQueryValueExW(key.get(), nullptr, nullptr, nullptr, reinterpret_cast<LPBYTE>(buf), &sz) == ERROR_SUCCESS) + return std::wstring(buf); + return L""; + } + + inline void write_default_value_key(const std::wstring& keyPath, const std::wstring& value) + { + unique_hkey key; + if (RegCreateKeyExW(HKEY_CURRENT_USER, keyPath.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, key.put(), nullptr) == ERROR_SUCCESS) + { + RegSetValueExW(key.get(), nullptr, 0, REG_SZ, reinterpret_cast<const BYTE*>(value.c_str()), static_cast<DWORD>((value.size() + 1) * sizeof(wchar_t))); + } + } + + inline bool representative_association_exists(const Spec& spec) + { + using namespace std::string_literals; + if (spec.representativeSystemExt.empty() || spec.systemFileAssocHandlerName.empty()) + return true; + std::wstring keyPath = L"Software\\Classes\\SystemFileAssociations\\"s + spec.representativeSystemExt + L"\\ShellEx\\ContextMenuHandlers\\" + spec.systemFileAssocHandlerName; + unique_hkey key; + return RegOpenKeyExW(HKEY_CURRENT_USER, keyPath.c_str(), 0, KEY_READ, key.put()) == ERROR_SUCCESS; + } + } + + inline bool EnsureRegistered(const Spec& spec, HMODULE moduleInstance) + { + using namespace std::string_literals; + auto base = detail::base_dir_from_module(moduleInstance); + auto dllPath = detail::pick_existing_dll(base, spec.dllFileCandidates); + if (dllPath.empty()) + { + Logger::error(L"Runtime registration: cannot locate dll path for CLSID {}", spec.clsid); + return false; + } + bool exists = detail::sentinel_exists(spec); + bool repaired = false; + if (exists) + { + auto current = detail::read_inproc_server(spec); + if (_wcsicmp(current.c_str(), dllPath.c_str()) != 0) + { + detail::write_inproc_server(spec, dllPath); + repaired = true; + } + if (!detail::representative_association_exists(spec)) + { + repaired = true; + } + } + if (!exists) + { + detail::write_inproc_server(spec, dllPath); + } + if (!exists || repaired) + { + for (const auto& path : spec.contextMenuHandlerKeyPaths) + { + detail::write_default_value_key(path, spec.clsid); + } + for (const auto& path : spec.extraAssociationPaths) + { + detail::write_default_value_key(path, spec.clsid); + } + if (!spec.systemFileAssocExtensions.empty() && !spec.systemFileAssocHandlerName.empty()) + { + for (const auto& ext : spec.systemFileAssocExtensions) + { + std::wstring path = L"Software\\Classes\\SystemFileAssociations\\"s + ext + L"\\ShellEx\\ContextMenuHandlers\\" + spec.systemFileAssocHandlerName; + detail::write_default_value_key(path, spec.clsid); + } + } + } + if (!exists) + { + detail::write_sentinel(spec); + Logger::info(L"Runtime registration completed for CLSID {}", spec.clsid); + } + else if (repaired && spec.logRepairs) + { + Logger::info(L"Runtime registration repaired for CLSID {}", spec.clsid); + } + return true; + } + + inline bool Unregister(const Spec& spec) + { + using namespace std::string_literals; + // Remove handler key paths + for (const auto& path : spec.contextMenuHandlerKeyPaths) + { + RegDeleteTreeW(HKEY_CURRENT_USER, path.c_str()); + } + // Remove extra association paths (e.g., drag & drop handlers) + for (const auto& path : spec.extraAssociationPaths) + { + RegDeleteTreeW(HKEY_CURRENT_USER, path.c_str()); + } + // Remove per-extension system file association handler keys + if (!spec.systemFileAssocExtensions.empty() && !spec.systemFileAssocHandlerName.empty()) + { + for (const auto& ext : spec.systemFileAssocExtensions) + { + std::wstring keyPath = L"Software\\Classes\\SystemFileAssociations\\"s + ext + L"\\ShellEx\\ContextMenuHandlers\\" + spec.systemFileAssocHandlerName; + RegDeleteTreeW(HKEY_CURRENT_USER, keyPath.c_str()); + } + } + // Remove CLSID branch + if (!spec.clsid.empty()) + { + std::wstring clsidRoot = L"Software\\Classes\\CLSID\\"s + spec.clsid; + RegDeleteTreeW(HKEY_CURRENT_USER, clsidRoot.c_str()); + } + // Remove sentinel value (not deleting entire key to avoid disturbing other values) + if (!spec.sentinelKey.empty() && !spec.sentinelValue.empty()) + { + HKEY hKey{}; + if (RegOpenKeyExW(HKEY_CURRENT_USER, spec.sentinelKey.c_str(), 0, KEY_SET_VALUE, &hKey) == ERROR_SUCCESS) + { + RegDeleteValueW(hKey, spec.sentinelValue.c_str()); + RegCloseKey(hKey); + } + } + Logger::info(L"Successfully unregistered CLSID {}", spec.clsid); + return true; + } +} 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 <cinttypes> #include <string> #include <optional> +#include <cwctype> #include <winrt/base.h> @@ -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<std::time_t>(i); } diff --git a/src/common/version/version.vcxproj b/src/common/version/version.vcxproj index b045d8f5a5..ed5ff0d799 100644 --- a/src/common/version/version.vcxproj +++ b/src/common/version/version.vcxproj @@ -47,7 +47,7 @@ <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -57,7 +57,7 @@ <PropertyGroup Label="UserMacros" /> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> <PrecompiledHeader>NotUsing</PrecompiledHeader> </ClCompile> diff --git a/src/dsc/Microsoft.PowerToys.Configure/examples/disableAllModules.winget b/src/dsc/Microsoft.PowerToys.Configure/examples/disableAllModules.winget index 92986e8107..6b64e2114d 100644 --- a/src/dsc/Microsoft.PowerToys.Configure/examples/disableAllModules.winget +++ b/src/dsc/Microsoft.PowerToys.Configure/examples/disableAllModules.winget @@ -54,6 +54,8 @@ properties: EnablePdfThumbnail: false EnableGcodePreview: false EnableGcodeThumbnail: false + EnableBgcodePreview: false + EnableBgcodeThumbnail: false EnableStlThumbnail: false EnableQoiPreview: false EnableQoiThumbnail: false diff --git a/src/dsc/Microsoft.PowerToys.Configure/examples/enableAllModules.winget b/src/dsc/Microsoft.PowerToys.Configure/examples/enableAllModules.winget index 5ff4dcfe71..e3b9c56c09 100644 --- a/src/dsc/Microsoft.PowerToys.Configure/examples/enableAllModules.winget +++ b/src/dsc/Microsoft.PowerToys.Configure/examples/enableAllModules.winget @@ -54,6 +54,8 @@ properties: EnablePdfThumbnail: true EnableGcodePreview: true EnableGcodeThumbnail: true + EnableBgcodePreview: true + EnableBgcodeThumbnail: true EnableStlThumbnail: true EnableQoiPreview: true EnableQoiThumbnail: true diff --git a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj index 83ef8f96b4..ab943c5090 100644 --- a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj +++ b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/PowerToys.Settings.DSC.Schema.Generator.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>Exe</OutputType> @@ -31,11 +31,6 @@ <!-- The following sections assume that the machine we're building on is always x64. That means we won't be able to run/inspect arm64 executables, therefore we must always execute x64 generator. --> <Target Name="PostBuildAction" AfterTargets="Build" Outputs="$(GeneratedDSCModule)" Condition="'$(Platform)'!='ARM64'"> - <Exec Command=""$(OutDir)$(AssemblyName).exe" "$(SolutionDir)x64\$(Configuration)\WinUI3Apps\PowerToys.Settings.UI.Lib.dll" $(GeneratedDSCModule) $(GeneratedDSCManifest)" /> + <Exec Command=""$(OutDir)$(AssemblyName).exe" "..\..\..\x64\$(Configuration)\WinUI3Apps\PowerToys.Settings.UI.Lib.dll" $(GeneratedDSCModule) $(GeneratedDSCManifest)" /> </Target> - - <Target Name="PreBuild" BeforeTargets="PreBuildEvent" Condition="'$(Platform)'=='ARM64'"> - <Exec Command=""$(MSBuildToolsPath)\msbuild.exe" PowerToys.sln -p:Configuration="$(Configuration)" -p:Platform="x64" -verbosity:m -t:DSC\PowerToys_Settings_DSC_Schema_Generator" WorkingDirectory="$(SolutionDir)" /> - </Target> - </Project> diff --git a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/app.manifest b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/app.manifest index 9742e0b540..c5043e485d 100644 --- a/src/dsc/PowerToys.Settings.DSC.Schema.Generator/app.manifest +++ b/src/dsc/PowerToys.Settings.DSC.Schema.Generator/app.manifest @@ -9,7 +9,7 @@ 2) System < Windows 10 Anniversary Update --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/BaseDscTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/BaseDscTest.cs new file mode 100644 index 0000000000..2eda4bdac5 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/BaseDscTest.cs @@ -0,0 +1,68 @@ +// 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.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using System.IO; +using System.Resources; +using PowerToys.DSC.UnitTests.Models; + +namespace PowerToys.DSC.UnitTests; + +public class BaseDscTest +{ + private readonly ResourceManager _resourceManager; + + public BaseDscTest() + { + _resourceManager = new ResourceManager("PowerToys.DSC.Properties.Resources", typeof(PowerToys.DSC.Program).Assembly); + } + + /// <summary> + /// Returns the string resource for the given name, formatted with the provided arguments. + /// </summary> + /// <param name="name">The name of the resource string.</param> + /// <param name="args">The arguments to format the resource string with.</param> + /// <returns></returns> + public string GetResourceString(string name, params string[] args) + { + return string.Format(CultureInfo.InvariantCulture, _resourceManager.GetString(name, CultureInfo.InvariantCulture), args); + } + + /// <summary> + /// Execute a dsc command with the provided arguments. + /// </summary> + /// <typeparam name="T"></typeparam> + /// <param name="args"></param> + /// <returns></returns> + protected DscExecuteResult ExecuteDscCommand<T>(params string[] args) + where T : Command, new() + { + var originalOut = Console.Out; + var originalErr = Console.Error; + + var outSw = new StringWriter(); + var errSw = new StringWriter(); + + try + { + Console.SetOut(outSw); + Console.SetError(errSw); + + var executeResult = new T().Invoke(args); + var output = outSw.ToString(); + var errorOutput = errSw.ToString(); + return new(executeResult == 0, output, errorOutput); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalErr); + outSw.Dispose(); + errSw.Dispose(); + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/CommandTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/CommandTest.cs new file mode 100644 index 0000000000..0941c03fdf --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/CommandTest.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerToys.DSC.Commands; +using PowerToys.DSC.DSCResources; + +namespace PowerToys.DSC.UnitTests; + +[TestClass] +public sealed class CommandTest : BaseDscTest +{ + [TestMethod] + public void GetResource_Found_Success() + { + // Act + var result = ExecuteDscCommand<GetCommand>("--resource", SettingsResource.ResourceName); + + // Assert + Assert.IsTrue(result.Success); + } + + [TestMethod] + public void GetResource_NotFound_Fail() + { + // Arrange + var availableResources = string.Join(", ", BaseCommand.AvailableResources); + + // Act + var result = ExecuteDscCommand<GetCommand>("--resource", "ResourceNotFound"); + + // Assert + Assert.IsFalse(result.Success); + Assert.Contains(GetResourceString("InvalidResourceNameError", availableResources), result.Error); + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/Models/DscExecuteResult.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/Models/DscExecuteResult.cs new file mode 100644 index 0000000000..7bf79f1041 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/Models/DscExecuteResult.cs @@ -0,0 +1,103 @@ +// 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.Diagnostics; +using System.Linq; +using System.Text.Json; +using PowerToys.DSC.Models; + +namespace PowerToys.DSC.UnitTests.Models; + +/// <summary> +/// Result of executing a DSC command. +/// </summary> +public class DscExecuteResult +{ + /// <summary> + /// Initializes a new instance of the <see cref="DscExecuteResult"/> class. + /// </summary> + /// <param name="success">Value indicating whether the command execution was successful.</param> + /// <param name="output">Output stream content.</param> + /// <param name="error">Error stream content.</param> + public DscExecuteResult(bool success, string output, string error) + { + Success = success; + Output = output; + Error = error; + } + + /// <summary> + /// Gets a value indicating whether the command execution was successful. + /// </summary> + public bool Success { get; } + + /// <summary> + /// Gets the output stream content of the operation. + /// </summary> + public string Output { get; } + + /// <summary> + /// Gets the error stream content of the operation. + /// </summary> + public string Error { get; } + + /// <summary> + /// Gets the messages from the error stream. + /// </summary> + /// <returns>List of messages with their levels.</returns> + public List<(DscMessageLevel Level, string Message)> Messages() + { + var lines = Error.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + return lines.SelectMany(line => + { + var map = JsonSerializer.Deserialize<Dictionary<string, string>>(line); + return map.Select(v => (GetMessageLevel(v.Key), v.Value)).ToList(); + }).ToList(); + } + + /// <summary> + /// Gets the output as state. + /// </summary> + /// <returns>State.</returns> + public T OutputState<T>() + { + var lines = Output.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + Debug.Assert(lines.Length == 1, "Output should contain exactly one line."); + return JsonSerializer.Deserialize<T>(lines[0]); + } + + /// <summary> + /// Gets the output as state and diff. + /// </summary> + /// <returns>State and diff.</returns> + public (T State, List<string> Diff) OutputStateAndDiff<T>() + { + var lines = Output.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); + Debug.Assert(lines.Length == 2, "Output should contain exactly two lines."); + var obj = JsonSerializer.Deserialize<T>(lines[0]); + var diff = JsonSerializer.Deserialize<List<string>>(lines[1]); + return (obj, diff); + } + + /// <summary> + /// Gets the message level from a string representation. + /// </summary> + /// <param name="level">The string representation of the message level.</param> + /// <returns>The level as <see cref="DscMessageLevel"/>.</returns> + /// <exception cref="ArgumentOutOfRangeException">Thrown when the level is unknown.</exception> + private DscMessageLevel GetMessageLevel(string level) + { + return level switch + { + "error" => DscMessageLevel.Error, + "warn" => DscMessageLevel.Warning, + "info" => DscMessageLevel.Info, + "debug" => DscMessageLevel.Debug, + "trace" => DscMessageLevel.Trace, + _ => throw new ArgumentOutOfRangeException(nameof(level), level, "Unknown message level"), + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/PowerToys.DSC.UnitTests.csproj b/src/dsc/v3/PowerToys.DSC.UnitTests/PowerToys.DSC.UnitTests.csproj new file mode 100644 index 0000000000..750a125bfa --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/PowerToys.DSC.UnitTests.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <OutputType>Exe</OutputType> + <IsPackable>false</IsPackable> + <OutputPath>$(RepoRoot)$(Configuration)\$(Platform)\tests\PowerToys.DSC.Tests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\PowerToys.DSC\PowerToys.DSC.csproj" /> + </ItemGroup> +</Project> diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs new file mode 100644 index 0000000000..45fd36d10e --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAdvancedPasteModuleTest.cs @@ -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. + +using System; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceAdvancedPasteModuleTest : SettingsResourceModuleTest<AdvancedPasteSettings> +{ + public SettingsResourceAdvancedPasteModuleTest() + : base(nameof(ModuleType.AdvancedPaste)) + { + } + + protected override Action<AdvancedPasteSettings> GetSettingsModifier() + { + return s => + { + s.Properties.ShowCustomPreview = !s.Properties.ShowCustomPreview; + s.Properties.CloseAfterLosingFocus = !s.Properties.CloseAfterLosingFocus; + + // s.Properties.IsAdvancedAIEnabled = !s.Properties.IsAdvancedAIEnabled; + s.Properties.AdvancedPasteUIShortcut = new HotkeySettings + { + Key = "mock", + Alt = true, + }; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAlwaysOnTopModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAlwaysOnTopModuleTest.cs new file mode 100644 index 0000000000..5aeb10b27e --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAlwaysOnTopModuleTest.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceAlwaysOnTopModuleTest : SettingsResourceModuleTest<AlwaysOnTopSettings> +{ + public SettingsResourceAlwaysOnTopModuleTest() + : base(nameof(ModuleType.AlwaysOnTop)) + { + } + + protected override Action<AlwaysOnTopSettings> GetSettingsModifier() + { + return s => + { + s.Properties.RoundCornersEnabled.Value = !s.Properties.RoundCornersEnabled.Value; + s.Properties.FrameEnabled.Value = !s.Properties.FrameEnabled.Value; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAppModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAppModuleTest.cs new file mode 100644 index 0000000000..b49563e100 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAppModuleTest.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerToys.DSC.DSCResources; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceAppModuleTest : SettingsResourceModuleTest<GeneralSettings> +{ + public SettingsResourceAppModuleTest() + : base(SettingsResource.AppModule) + { + } + + protected override Action<GeneralSettings> GetSettingsModifier() + { + return s => + { + s.Startup = !s.Startup; + s.ShowSysTrayIcon = !s.ShowSysTrayIcon; + s.Enabled.Awake = !s.Enabled.Awake; + s.Enabled.ColorPicker = !s.Enabled.ColorPicker; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAwakeModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAwakeModuleTest.cs new file mode 100644 index 0000000000..bd5e60c371 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceAwakeModuleTest.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceAwakeModuleTest : SettingsResourceModuleTest<AwakeSettings> +{ + public SettingsResourceAwakeModuleTest() + : base(nameof(ModuleType.Awake)) + { + } + + protected override Action<AwakeSettings> GetSettingsModifier() + { + return s => + { + s.Properties.ExpirationDateTime = DateTimeOffset.MinValue; + s.Properties.IntervalHours = DefaultSettings.Properties.IntervalHours + 1; + s.Properties.IntervalMinutes = DefaultSettings.Properties.IntervalMinutes + 1; + s.Properties.Mode = s.Properties.Mode == AwakeMode.PASSIVE ? AwakeMode.TIMED : AwakeMode.PASSIVE; + s.Properties.KeepDisplayOn = !s.Properties.KeepDisplayOn; + s.Properties.CustomTrayTimes = new Dictionary<string, uint> + { + { "08:00", 1 }, + { "12:00", 2 }, + { "16:00", 3 }, + }; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceColorPickerModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceColorPickerModuleTest.cs new file mode 100644 index 0000000000..175b74623c --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceColorPickerModuleTest.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceColorPickerModuleTest : SettingsResourceModuleTest<ColorPickerSettings> +{ + public SettingsResourceColorPickerModuleTest() + : base(nameof(ModuleType.ColorPicker)) + { + } + + protected override Action<ColorPickerSettings> GetSettingsModifier() + { + return s => + { + s.Properties.ShowColorName = !s.Properties.ShowColorName; + s.Properties.ColorHistoryLimit = s.Properties.ColorHistoryLimit == 0 ? 10 : 0; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCommandTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCommandTest.cs new file mode 100644 index 0000000000..5333f5a832 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCommandTest.cs @@ -0,0 +1,87 @@ +// 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 ManagedCommon; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerToys.DSC.Commands; +using PowerToys.DSC.DSCResources; +using PowerToys.DSC.Models; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceCommandTest : BaseDscTest +{ + [TestMethod] + public void Modules_ListAllSupportedModules() + { + // Arrange + var expectedModules = new List<string>() + { + SettingsResource.AppModule, + nameof(ModuleType.AdvancedPaste), + nameof(ModuleType.AlwaysOnTop), + nameof(ModuleType.Awake), + nameof(ModuleType.ColorPicker), + nameof(ModuleType.CropAndLock), + nameof(ModuleType.EnvironmentVariables), + nameof(ModuleType.FancyZones), + nameof(ModuleType.FileLocksmith), + nameof(ModuleType.FindMyMouse), + nameof(ModuleType.Hosts), + nameof(ModuleType.ImageResizer), + nameof(ModuleType.KeyboardManager), + nameof(ModuleType.MouseHighlighter), + nameof(ModuleType.MouseJump), + nameof(ModuleType.MousePointerCrosshairs), + nameof(ModuleType.Peek), + nameof(ModuleType.PowerRename), + nameof(ModuleType.PowerAccent), + nameof(ModuleType.RegistryPreview), + nameof(ModuleType.MeasureTool), + nameof(ModuleType.ShortcutGuide), + nameof(ModuleType.PowerOCR), + nameof(ModuleType.Workspaces), + nameof(ModuleType.ZoomIt), + }; + + // Act + var result = ExecuteDscCommand<ModulesCommand>("--resource", SettingsResource.ResourceName); + + // Assert + Assert.IsTrue(result.Success); + Assert.AreEqual(string.Join(Environment.NewLine, expectedModules.Order()), result.Output.Trim()); + } + + [TestMethod] + public void Set_EmptyInput_Fail() + { + // Act + var result = ExecuteDscCommand<SetCommand>("--resource", SettingsResource.ResourceName, "--module", "Awake"); + var messages = result.Messages(); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(1, messages.Count); + Assert.AreEqual(DscMessageLevel.Error, messages[0].Level); + Assert.AreEqual(GetResourceString("InputEmptyOrNullError"), messages[0].Message); + } + + [TestMethod] + public void Test_EmptyInput_Fail() + { + // Act + var result = ExecuteDscCommand<TestCommand>("--resource", SettingsResource.ResourceName, "--module", "Awake"); + var messages = result.Messages(); + + // Assert + Assert.IsFalse(result.Success); + Assert.AreEqual(1, messages.Count); + Assert.AreEqual(DscMessageLevel.Error, messages[0].Level); + Assert.AreEqual(GetResourceString("InputEmptyOrNullError"), messages[0].Message); + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCropAndLockModuleTest.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCropAndLockModuleTest.cs new file mode 100644 index 0000000000..516a5fac86 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceCropAndLockModuleTest.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +[TestClass] +public sealed class SettingsResourceCropAndLockModuleTest : SettingsResourceModuleTest<CropAndLockSettings> +{ + public SettingsResourceCropAndLockModuleTest() + : base(nameof(ModuleType.CropAndLock)) + { + } + + protected override Action<CropAndLockSettings> GetSettingsModifier() + { + return s => + { + s.Properties.ThumbnailHotkey = new KeyboardKeysProperty() + { + Value = new HotkeySettings + { + Key = "mock", + Alt = true, + }, + }; + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs new file mode 100644 index 0000000000..8d43f48a77 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC.UnitTests/SettingsResourceTests/SettingsResourceModuleTest`1.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerToys.DSC.Commands; +using PowerToys.DSC.DSCResources; +using PowerToys.DSC.Models.ResourceObjects; + +namespace PowerToys.DSC.UnitTests.SettingsResourceTests; + +public abstract class SettingsResourceModuleTest<TSettingsConfig> : BaseDscTest + where TSettingsConfig : ISettingsConfig, new() +{ + private readonly SettingsUtils _settingsUtils = SettingsUtils.Default; + private TSettingsConfig _originalSettings; + + protected TSettingsConfig DefaultSettings => new(); + + protected string Module { get; } + + protected List<string> DiffSettings { get; } = [SettingsResourceObject<AwakeSettings>.SettingsJsonPropertyName]; + + protected List<string> DiffEmpty { get; } = []; + + public SettingsResourceModuleTest(string module) + { + Module = module; + } + + [TestInitialize] + public void TestInitialize() + { + _originalSettings = GetSettings(); + ResetSettingsToDefaultValues(); + } + + [TestCleanup] + public void TestCleanup() + { + SaveSettings(_originalSettings); + } + + [TestMethod] + public void Get_Success() + { + // Arrange + var settingsBeforeExecute = GetSettings(); + + // Act + var result = ExecuteDscCommand<GetCommand>("--resource", SettingsResource.ResourceName, "--module", Module); + var state = result.OutputState<SettingsResourceObject<TSettingsConfig>>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + } + + [TestMethod] + public void Export_Success() + { + // Arrange + var settingsBeforeExecute = GetSettings(); + + // Act + var result = ExecuteDscCommand<ExportCommand>("--resource", SettingsResource.ResourceName, "--module", Module); + var state = result.OutputState<SettingsResourceObject<TSettingsConfig>>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + } + + [TestMethod] + public void SetWithDiff_Success() + { + // Arrange + var settingsModifier = GetSettingsModifier(); + var input = CreateInputResourceObject(settingsModifier); + + // Act + var result = ExecuteDscCommand<SetCommand>("--resource", SettingsResource.ResourceName, "--module", Module, "--input", input); + var (state, diff) = result.OutputStateAndDiff<SettingsResourceObject<TSettingsConfig>>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsHasChanged(settingsModifier); + AssertStateAndSettingsAreEqual(GetSettings(), state); + CollectionAssert.AreEqual(DiffSettings, diff); + } + + [TestMethod] + public void SetWithoutDiff_Success() + { + // Arrange + var settingsModifier = GetSettingsModifier(); + UpdateSettings(settingsModifier); + var settingsBeforeExecute = GetSettings(); + var input = CreateInputResourceObject(settingsModifier); + + // Act + var result = ExecuteDscCommand<SetCommand>("--resource", SettingsResource.ResourceName, "--module", Module, "--input", input); + var (state, diff) = result.OutputStateAndDiff<SettingsResourceObject<TSettingsConfig>>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + CollectionAssert.AreEqual(DiffEmpty, diff); + } + + [TestMethod] + public void TestWithDiff_Success() + { + // Arrange + var settingsModifier = GetSettingsModifier(); + var settingsBeforeExecute = GetSettings(); + var input = CreateInputResourceObject(settingsModifier); + + // Act + var result = ExecuteDscCommand<TestCommand>("--resource", SettingsResource.ResourceName, "--module", Module, "--input", input); + var (state, diff) = result.OutputStateAndDiff<SettingsResourceObject<TSettingsConfig>>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + CollectionAssert.AreEqual(DiffSettings, diff); + Assert.IsFalse(state.InDesiredState); + } + + [TestMethod] + public void TestWithoutDiff_Success() + { + // Arrange + var settingsModifier = GetSettingsModifier(); + UpdateSettings(settingsModifier); + var settingsBeforeExecute = GetSettings(); + var input = CreateInputResourceObject(settingsModifier); + + // Act + var result = ExecuteDscCommand<TestCommand>("--resource", SettingsResource.ResourceName, "--module", Module, "--input", input); + var (state, diff) = result.OutputStateAndDiff<SettingsResourceObject<TSettingsConfig>>(); + + // Assert + Assert.IsTrue(result.Success); + AssertSettingsAreEqual(settingsBeforeExecute, GetSettings()); + AssertStateAndSettingsAreEqual(settingsBeforeExecute, state); + CollectionAssert.AreEqual(DiffEmpty, diff); + Assert.IsTrue(state.InDesiredState); + } + + /// <summary> + /// Gets the settings modifier action for the specific settings configuration. + /// </summary> + /// <returns>An action that modifies the settings configuration.</returns> + protected abstract Action<TSettingsConfig> GetSettingsModifier(); + + /// <summary> + /// Resets the settings to default values. + /// </summary> + private void ResetSettingsToDefaultValues() + { + SaveSettings(DefaultSettings); + } + + /// <summary> + /// Get the settings for the specified module. + /// </summary> + /// <returns>An instance of the settings type with the current configuration.</returns> + private TSettingsConfig GetSettings() + { + return _settingsUtils.GetSettingsOrDefault<TSettingsConfig>(DefaultSettings.GetModuleName()); + } + + /// <summary> + /// Saves the settings for the specified module. + /// </summary> + /// <param name="settings">Settings to save.</param> + private void SaveSettings(TSettingsConfig settings) + { + _settingsUtils.SaveSettings(JsonSerializer.Serialize(settings), DefaultSettings.GetModuleName()); + } + + /// <summary> + /// Create the resource object for the operation. + /// </summary> + /// <param name="settings">Settings to include in the resource object.</param> + /// <returns>A JSON string representing the resource object.</returns> + private string CreateResourceObject(TSettingsConfig settings) + { + var resourceObject = new SettingsResourceObject<TSettingsConfig> + { + Settings = settings, + }; + return JsonSerializer.Serialize(resourceObject); + } + + private string CreateInputResourceObject(Action<TSettingsConfig> settingsModifier) + { + var settings = DefaultSettings; + settingsModifier(settings); + return CreateResourceObject(settings); + } + + /// <summary> + /// Create the response for the Get operation. + /// </summary> + /// <returns>A JSON string representing the response.</returns> + private string CreateGetResponse() + { + return CreateResourceObject(GetSettings()); + } + + /// <summary> + /// Asserts that the state and settings are equal. + /// </summary> + /// <param name="settings">Settings manifest to compare against.</param> + /// <param name="state">Output state to compare.</param> + private void AssertStateAndSettingsAreEqual(TSettingsConfig settings, SettingsResourceObject<TSettingsConfig> state) + { + AssertSettingsAreEqual(settings, state.Settings); + } + + /// <summary> + /// Asserts that two settings manifests are equal. + /// </summary> + /// <param name="expected">Expected settings.</param> + /// <param name="actual">Actual settings.</param> + private void AssertSettingsAreEqual(TSettingsConfig expected, TSettingsConfig actual) + { + var expectedJson = JsonSerializer.SerializeToNode(expected) as JsonObject; + var actualJson = JsonSerializer.SerializeToNode(actual) as JsonObject; + Assert.IsTrue(JsonNode.DeepEquals(expectedJson, actualJson)); + } + + /// <summary> + /// Asserts that the current settings have changed. + /// </summary> + /// <param name="action">Action to prepare the default settings.</param> + private void AssertSettingsHasChanged(Action<TSettingsConfig> action) + { + var currentSettings = GetSettings(); + var defaultSettings = DefaultSettings; + action(defaultSettings); + AssertSettingsAreEqual(defaultSettings, currentSettings); + } + + /// <summary> + /// Updates the settings. + /// </summary> + /// <param name="action">Action to modify the settings.</param> + private void UpdateSettings(Action<TSettingsConfig> action) + { + var settings = GetSettings(); + action(settings); + SaveSettings(settings); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/BaseCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/BaseCommand.cs new file mode 100644 index 0000000000..d8cfaaefc6 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/BaseCommand.cs @@ -0,0 +1,120 @@ +// 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.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.IO; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using PowerToys.DSC.DSCResources; +using PowerToys.DSC.Options; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// <summary> +/// Base class for all DSC commands. +/// </summary> +public abstract class BaseCommand : Command +{ + private static readonly CompositeFormat ModuleNotSupportedByResource = CompositeFormat.Parse(Resources.ModuleNotSupportedByResource); + + // Shared options for all commands + private readonly ModuleOption _moduleOption; + private readonly ResourceOption _resourceOption; + private readonly InputOption _inputOption; + + // The dictionary of available resources and their factories. + private static readonly Dictionary<string, Func<string?, BaseResource>> _resourceFactories = new() + { + { SettingsResource.ResourceName, module => new SettingsResource(module) }, + + // Add other resources here + }; + + /// <summary> + /// Gets the list of available DSC resources that can be used with the command. + /// </summary> + public static List<string> AvailableResources => [.._resourceFactories.Keys]; + + /// <summary> + /// Gets the DSC resource to be used by the command. + /// </summary> + protected BaseResource? Resource { get; private set; } + + /// <summary> + /// Gets the input JSON provided by the user. + /// </summary> + protected string? Input { get; private set; } + + /// <summary> + /// Gets the PowerToys module to be used by the command. + /// </summary> + protected string? Module { get; private set; } + + public BaseCommand(string name, string description) + : base(name, description) + { + // Register the common options for all commands + _moduleOption = new ModuleOption(); + AddOption(_moduleOption); + + _resourceOption = new ResourceOption(AvailableResources); + AddOption(_resourceOption); + + _inputOption = new InputOption(); + AddOption(_inputOption); + + // Register the command handler + this.SetHandler(CommandHandler); + } + + /// <summary> + /// Handles the command invocation. + /// </summary> + /// <param name="context">The invocation context containing the parsed command options.</param> + public void CommandHandler(InvocationContext context) + { + Input = context.ParseResult.GetValueForOption(_inputOption); + Module = context.ParseResult.GetValueForOption(_moduleOption); + Resource = ResolvedResource(context); + + // Validate the module against the resource's supported modules + var supportedModules = Resource.GetSupportedModules(); + if (!string.IsNullOrEmpty(Module) && !supportedModules.Contains(Module)) + { + var errorMessage = string.Format(CultureInfo.InvariantCulture, ModuleNotSupportedByResource, Module, Resource.Name); + context.Console.Error.WriteLine(errorMessage); + context.ExitCode = 1; + return; + } + + // Continue with the command handler logic + CommandHandlerInternal(context); + } + + /// <summary> + /// Handles the command logic internally. + /// </summary> + /// <param name="context">Invocation context containing the parsed command options.</param> + public abstract void CommandHandlerInternal(InvocationContext context); + + /// <summary> + /// Resolves the resource from the provided resource name in the context. + /// </summary> + /// <param name="context">Invocation context containing the parsed command options.</param> + /// <returns>The resolved <see cref="BaseResource"/> instance.</returns> + private BaseResource ResolvedResource(InvocationContext context) + { + // Resource option has already been validated before the command + // handler is invoked. + var resourceName = context.ParseResult.GetValueForOption(_resourceOption); + Debug.Assert(!string.IsNullOrEmpty(resourceName), "Resource name must not be null or empty."); + Debug.Assert(_resourceFactories.ContainsKey(resourceName), $"Resource '{resourceName}' is not registered."); + return _resourceFactories[resourceName](Module); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/ExportCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/ExportCommand.cs new file mode 100644 index 0000000000..e8001fd0bd --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/ExportCommand.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.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// <summary> +/// Command to export all state instances. +/// </summary> +public sealed class ExportCommand : BaseCommand +{ + public ExportCommand() + : base("export", Resources.ExportCommandDescription) + { + } + + /// <inheritdoc/> + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.ExportState(Input) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/GetCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/GetCommand.cs new file mode 100644 index 0000000000..a5fed7bc73 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/GetCommand.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.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// <summary> +/// Command to get the resource state. +/// </summary> +public sealed class GetCommand : BaseCommand +{ + public GetCommand() + : base("get", Resources.GetCommandDescription) + { + } + + /// <inheritdoc/> + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.GetState(Input) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/ManifestCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/ManifestCommand.cs new file mode 100644 index 0000000000..da3c637137 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/ManifestCommand.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine.Invocation; +using PowerToys.DSC.Options; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// <summary> +/// Command to get the manifest of the DSC resource. +/// </summary> +public sealed class ManifestCommand : BaseCommand +{ + /// <summary> + /// Option to specify the output directory for the manifest. + /// </summary> + private readonly OutputDirectoryOption _outputDirectoryOption; + + public ManifestCommand() + : base("manifest", Resources.ManifestCommandDescription) + { + _outputDirectoryOption = new OutputDirectoryOption(); + AddOption(_outputDirectoryOption); + } + + /// <inheritdoc/> + public override void CommandHandlerInternal(InvocationContext context) + { + var outputDir = context.ParseResult.GetValueForOption(_outputDirectoryOption); + context.ExitCode = Resource!.Manifest(outputDir) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/ModulesCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/ModulesCommand.cs new file mode 100644 index 0000000000..9eb60659df --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/ModulesCommand.cs @@ -0,0 +1,47 @@ +// 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.CommandLine; +using System.CommandLine.Invocation; +using System.Diagnostics; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// <summary> +/// Command to get all supported modules for a specific resource. +/// </summary> +/// <remarks> +/// This class is primarily used for debugging purposes and for build scripts. +/// </remarks> +public sealed class ModulesCommand : BaseCommand +{ + public ModulesCommand() + : base("modules", Resources.ModulesCommandDescription) + { + } + + /// <inheritdoc/> + public override void CommandHandlerInternal(InvocationContext context) + { + // Module is optional, if not provided, all supported modules for the + // resource will be printed. If provided, it must be one of the + // supported modules since it has been validated before this command is + // executed. + if (!string.IsNullOrEmpty(Module)) + { + Debug.Assert(Resource!.GetSupportedModules().Contains(Module), "Module must be present in the list of supported modules."); + context.Console.WriteLine(Module); + } + else + { + // Print the supported modules for the specified resource + foreach (var module in Resource!.GetSupportedModules()) + { + context.Console.WriteLine(module); + } + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/SchemaCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/SchemaCommand.cs new file mode 100644 index 0000000000..f7fbfc2448 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/SchemaCommand.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.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// <summary> +/// Command to output the schema of the resource. +/// </summary> +public sealed class SchemaCommand : BaseCommand +{ + public SchemaCommand() + : base("schema", Resources.SchemaCommandDescription) + { + } + + /// <inheritdoc/> + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.Schema() ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/SetCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/SetCommand.cs new file mode 100644 index 0000000000..f76c24a0a8 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/SetCommand.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.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// <summary> +/// Command to set the resource state. +/// </summary> +public sealed class SetCommand : BaseCommand +{ + public SetCommand() + : base("set", Resources.SetCommandDescription) + { + } + + /// <inheritdoc/> + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.SetState(Input) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Commands/TestCommand.cs b/src/dsc/v3/PowerToys.DSC/Commands/TestCommand.cs new file mode 100644 index 0000000000..fcdd83342e --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Commands/TestCommand.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.CommandLine.Invocation; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Commands; + +/// <summary> +/// Command to test the resource state. +/// </summary> +public sealed class TestCommand : BaseCommand +{ + public TestCommand() + : base("test", Resources.TestCommandDescription) + { + } + + /// <inheritdoc/> + public override void CommandHandlerInternal(InvocationContext context) + { + context.ExitCode = Resource!.TestState(Input) ? 0 : 1; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/DSCResources/BaseResource.cs b/src/dsc/v3/PowerToys.DSC/DSCResources/BaseResource.cs new file mode 100644 index 0000000000..51d265cff7 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/DSCResources/BaseResource.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using PowerToys.DSC.Models; + +namespace PowerToys.DSC.DSCResources; + +/// <summary> +/// Base class for all DSC resources. +/// </summary> +public abstract class BaseResource +{ + /// <summary> + /// Gets the name of the resource. + /// </summary> + public string Name { get; } + + /// <summary> + /// Gets the module being used by the resource, if provided. + /// </summary> + public string? Module { get; } + + public BaseResource(string name, string? module) + { + Name = name; + Module = module; + } + + /// <summary> + /// Calls the get method on the resource. + /// </summary> + /// <param name="input">The input string, if any.</param> + /// <returns>True if the operation was successful; otherwise false.</returns> + public abstract bool GetState(string? input); + + /// <summary> + /// Calls the set method on the resource. + /// </summary> + /// <param name="input">The input string, if any.</param> + /// <returns>True if the operation was successful; otherwise false.</returns> + public abstract bool SetState(string? input); + + /// <summary> + /// Calls the test method on the resource. + /// </summary> + /// <param name="input">The input string, if any.</param> + /// <returns>True if the operation was successful; otherwise false.</returns> + public abstract bool TestState(string? input); + + /// <summary> + /// Calls the export method on the resource. + /// </summary> + /// <param name="input"> The input string, if any.</param> + /// <returns>True if the operation was successful; otherwise false.</returns> + public abstract bool ExportState(string? input); + + /// <summary> + /// Calls the schema method on the resource. + /// </summary> + /// <returns>True if the operation was successful; otherwise false.</returns> + public abstract bool Schema(); + + /// <summary> + /// Generates a DSC resource JSON manifest for the resource. If the + /// outputDir is not provided, the manifest will be printed to the console. + /// </summary> + /// <param name="outputDir"> The directory where the manifest should be + /// saved. If null, the manifest will be printed to the console.</param> + /// <returns>True if the manifest was successfully generated and saved,otherwise false.</returns> + public abstract bool Manifest(string? outputDir); + + /// <summary> + /// Gets the list of supported modules for the resource. + /// </summary> + /// <returns>Gets a list of supported modules.</returns> + public abstract IList<string> GetSupportedModules(); + + /// <summary> + /// Writes a JSON output line to the console. + /// </summary> + /// <param name="output">The JSON output to write.</param> + protected void WriteJsonOutputLine(JsonNode output) + { + var json = output.ToJsonString(new() { WriteIndented = false }); + WriteJsonOutputLine(json); + } + + /// <summary> + /// Writes a JSON output line to the console. + /// </summary> + /// <param name="output">The JSON output to write.</param> + protected void WriteJsonOutputLine(string output) + { + Console.WriteLine(output); + } + + /// <summary> + /// Writes a message output line to the console with the specified message level. + /// </summary> + /// <param name="level">The level of the message.</param> + /// <param name="message">The message to write.</param> + protected void WriteMessageOutputLine(DscMessageLevel level, string message) + { + var messageObj = new Dictionary<string, string> + { + [GetMessageLevel(level)] = message, + }; + var messageJson = System.Text.Json.JsonSerializer.Serialize(messageObj); + Console.Error.WriteLine(messageJson); + } + + /// <summary> + /// Gets the message level as a string based on the provided dsc message level enum value. + /// </summary> + /// <param name="level">The dsc message level.</param> + /// <returns>A string representation of the message level.</returns> + /// <exception cref="ArgumentOutOfRangeException">Thrown when the provided message level is not recognized.</exception> + private static string GetMessageLevel(DscMessageLevel level) + { + return level switch + { + DscMessageLevel.Error => "error", + DscMessageLevel.Warning => "warn", + DscMessageLevel.Info => "info", + DscMessageLevel.Debug => "debug", + DscMessageLevel.Trace => "trace", + _ => throw new ArgumentOutOfRangeException(nameof(level), level, null), + }; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/DSCResources/SettingsResource.cs b/src/dsc/v3/PowerToys.DSC/DSCResources/SettingsResource.cs new file mode 100644 index 0000000000..5f69b20227 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/DSCResources/SettingsResource.cs @@ -0,0 +1,248 @@ +// 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.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using PowerToys.DSC.Models; +using PowerToys.DSC.Models.FunctionData; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.DSCResources; + +/// <summary> +/// Represents the DSC resource for managing PowerToys settings. +/// </summary> +public sealed class SettingsResource : BaseResource +{ + private static readonly CompositeFormat FailedToWriteManifests = CompositeFormat.Parse(Resources.FailedToWriteManifests); + + public const string AppModule = "App"; + public const string ResourceName = "settings"; + + private readonly Dictionary<string, Func<string?, ISettingsFunctionData>> _moduleFunctionData; + + public string ModuleOrDefault => string.IsNullOrEmpty(Module) ? AppModule : Module; + + public SettingsResource(string? module) + : base(ResourceName, module) + { + _moduleFunctionData = new() + { + { AppModule, CreateModuleFunctionData<GeneralSettings> }, + { nameof(ModuleType.AdvancedPaste), CreateModuleFunctionData<AdvancedPasteSettings> }, + { nameof(ModuleType.AlwaysOnTop), CreateModuleFunctionData<AlwaysOnTopSettings> }, + { nameof(ModuleType.Awake), CreateModuleFunctionData<AwakeSettings> }, + { nameof(ModuleType.ColorPicker), CreateModuleFunctionData<ColorPickerSettings> }, + { nameof(ModuleType.CropAndLock), CreateModuleFunctionData<CropAndLockSettings> }, + { nameof(ModuleType.EnvironmentVariables), CreateModuleFunctionData<EnvironmentVariablesSettings> }, + { nameof(ModuleType.FancyZones), CreateModuleFunctionData<FancyZonesSettings> }, + { nameof(ModuleType.FileLocksmith), CreateModuleFunctionData<FileLocksmithSettings> }, + { nameof(ModuleType.FindMyMouse), CreateModuleFunctionData<FindMyMouseSettings> }, + { nameof(ModuleType.Hosts), CreateModuleFunctionData<HostsSettings> }, + { nameof(ModuleType.ImageResizer), CreateModuleFunctionData<ImageResizerSettings> }, + { nameof(ModuleType.KeyboardManager), CreateModuleFunctionData<KeyboardManagerSettings> }, + { nameof(ModuleType.MouseHighlighter), CreateModuleFunctionData<MouseHighlighterSettings> }, + { nameof(ModuleType.MouseJump), CreateModuleFunctionData<MouseJumpSettings> }, + { nameof(ModuleType.MousePointerCrosshairs), CreateModuleFunctionData<MousePointerCrosshairsSettings> }, + { nameof(ModuleType.Peek), CreateModuleFunctionData<PeekSettings> }, + { nameof(ModuleType.PowerRename), CreateModuleFunctionData<PowerRenameSettings> }, + { nameof(ModuleType.PowerAccent), CreateModuleFunctionData<PowerAccentSettings> }, + { nameof(ModuleType.RegistryPreview), CreateModuleFunctionData<RegistryPreviewSettings> }, + { nameof(ModuleType.MeasureTool), CreateModuleFunctionData<MeasureToolSettings> }, + { nameof(ModuleType.ShortcutGuide), CreateModuleFunctionData<ShortcutGuideSettings> }, + { nameof(ModuleType.PowerOCR), CreateModuleFunctionData<PowerOcrSettings> }, + { nameof(ModuleType.Workspaces), CreateModuleFunctionData<WorkspacesSettings> }, + { nameof(ModuleType.ZoomIt), CreateModuleFunctionData<ZoomItSettings> }, + + // The following modules are not currently supported: + // - MouseWithoutBorders Contains sensitive configuration values, making export/import potentially insecure. + // - PowerLauncher Uses absolute file paths in its settings, which are not portable across systems. + // - NewPlus Uses absolute file paths in its settings, which are not portable across systems. + }; + } + + /// <inheritdoc/> + public override bool ExportState(string? input) + { + var data = CreateFunctionData(); + data.GetState(); + WriteJsonOutputLine(data.Output.ToJson()); + return true; + } + + /// <inheritdoc/> + public override bool GetState(string? input) + { + return ExportState(input); + } + + /// <inheritdoc/> + public override bool SetState(string? input) + { + if (string.IsNullOrEmpty(input)) + { + WriteMessageOutputLine(DscMessageLevel.Error, Resources.InputEmptyOrNullError); + return false; + } + + var data = CreateFunctionData(input); + data.GetState(); + + // Capture the diff before updating the output + var diff = data.GetDiffJson(); + + // Only call Set if the desired state is different from the current state + if (!data.TestState()) + { + var inputSettings = data.Input.SettingsInternal; + data.Output.SettingsInternal = inputSettings; + data.SetState(); + } + + WriteJsonOutputLine(data.Output.ToJson()); + WriteJsonOutputLine(diff); + return true; + } + + /// <inheritdoc/> + public override bool TestState(string? input) + { + if (string.IsNullOrEmpty(input)) + { + WriteMessageOutputLine(DscMessageLevel.Error, Resources.InputEmptyOrNullError); + return false; + } + + var data = CreateFunctionData(input); + data.GetState(); + data.Output.InDesiredState = data.TestState(); + + WriteJsonOutputLine(data.Output.ToJson()); + WriteJsonOutputLine(data.GetDiffJson()); + return true; + } + + /// <inheritdoc/> + public override bool Schema() + { + var data = CreateFunctionData(); + WriteJsonOutputLine(data.Schema()); + return true; + } + + /// <inheritdoc/> + /// <remarks> + /// If an output directory is specified, write the manifests to files, + /// otherwise output them to the console. + /// </remarks> + public override bool Manifest(string? outputDir) + { + var manifests = GenerateManifests(); + + if (!string.IsNullOrEmpty(outputDir)) + { + try + { + foreach (var (name, manifest) in manifests) + { + File.WriteAllText(Path.Combine(outputDir, $"microsoft.powertoys.{name}.settings.dsc.resource.json"), manifest); + } + } + catch (Exception ex) + { + var errorMessage = string.Format(CultureInfo.InvariantCulture, FailedToWriteManifests, outputDir, ex.Message); + WriteMessageOutputLine(DscMessageLevel.Error, errorMessage); + return false; + } + } + else + { + foreach (var (_, manifest) in manifests) + { + WriteJsonOutputLine(manifest); + } + } + + return true; + } + + /// <summary> + /// Generates manifests for the specified module or all supported modules + /// if no module is specified. + /// </summary> + /// <returns>A list of tuples containing the module name and its corresponding manifest JSON.</returns> + private List<(string Name, string Manifest)> GenerateManifests() + { + List<(string Name, string Manifest)> manifests = []; + if (!string.IsNullOrEmpty(Module)) + { + manifests.Add((Module, GenerateManifest(Module))); + } + else + { + foreach (var module in GetSupportedModules()) + { + manifests.Add((module, GenerateManifest(module))); + } + } + + return manifests; + } + + /// <summary> + /// Generate a DSC resource JSON manifest for the specified module. + /// </summary> + /// <param name="module">The name of the module for which to generate the manifest.</param> + /// <returns>A JSON string representing the DSC resource manifest.</returns> + private string GenerateManifest(string module) + { + // Note: The description is not localized because the generated + // manifest file will be part of the package + return new DscManifest($"{module}Settings", "0.1.0") + .AddDescription($"Allows management of {module} settings state via the DSC v3 command line interface protocol.") + .AddStdinMethod("export", ["export", "--module", module, "--resource", "settings"]) + .AddStdinMethod("get", ["get", "--module", module, "--resource", "settings"]) + .AddJsonInputMethod("set", "--input", ["set", "--module", module, "--resource", "settings"], implementsPretest: true, stateAndDiff: true) + .AddJsonInputMethod("test", "--input", ["test", "--module", module, "--resource", "settings"], stateAndDiff: true) + .AddCommandMethod("schema", ["schema", "--module", module, "--resource", "settings"]) + .ToJson(); + } + + /// <inheritdoc/> + public override IList<string> GetSupportedModules() + { + return [.. _moduleFunctionData.Keys.Order()]; + } + + /// <summary> + /// Creates the function data for the specified module or the default module if none is specified. + /// </summary> + /// <param name="input">The input string, if any.</param> + /// <returns>An instance of <see cref="ISettingsFunctionData"/> for the specified module.</returns> + public ISettingsFunctionData CreateFunctionData(string? input = null) + { + Debug.Assert(_moduleFunctionData.ContainsKey(ModuleOrDefault), "Module should be supported by the resource."); + return _moduleFunctionData[ModuleOrDefault](input); + } + + /// <summary> + /// Creates the function data for a specific settings configuration type. + /// </summary> + /// <typeparam name="TSettingsConfig">The type of settings configuration to create function data for.</typeparam> + /// <param name="input">The input string, if any.</param> + /// <returns>An instance of <see cref="ISettingsFunctionData"/> for the specified settings configuration type.</returns> + private ISettingsFunctionData CreateModuleFunctionData<TSettingsConfig>(string? input) + where TSettingsConfig : ISettingsConfig, new() + { + return new SettingsFunctionData<TSettingsConfig>(input); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs b/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs new file mode 100644 index 0000000000..5eb91acec3 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/DscManifest.cs @@ -0,0 +1,152 @@ +// 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.Nodes; + +namespace PowerToys.DSC.Models; + +/// <summary> +/// Class for building a DSC manifest for PowerToys resources. +/// </summary> +public sealed class DscManifest +{ + private const string Schema = "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.vscode.json"; + private const string Executable = @"..\PowerToys.DSC.exe"; + + private readonly string _type; + private readonly string _version; + private readonly JsonObject _manifest; + + public DscManifest(string type, string version) + { + _type = type; + _version = version; + _manifest = new JsonObject + { + ["$schema"] = Schema, + ["type"] = $"Microsoft.PowerToys/{_type}", + ["version"] = _version, + ["tags"] = new JsonArray("PowerToys"), + }; + } + + /// <summary> + /// Adds a description to the manifest. + /// </summary> + /// <param name="description">The description to add.</param> + /// <returns>Returns the current instance of <see cref="DscManifest"/>.</returns> + public DscManifest AddDescription(string description) + { + _manifest["description"] = description; + return this; + } + + /// <summary> + /// Adds a method to the manifest with the specified executable and arguments. + /// </summary> + /// <param name="method">The name of the method to add.</param> + /// <param name="inputArg">The input argument for the method</param> + /// <param name="args">The list of arguments for the method.</param> + /// <param name="implementsPretest">Whether the method implements a pretest.</param> + /// <param name="stateAndDiff">Whether the method returns state and diff.</param> + /// <returns>Returns the current instance of <see cref="DscManifest"/>.</returns> + public DscManifest AddJsonInputMethod(string method, string inputArg, List<string> args, bool? implementsPretest = null, bool? stateAndDiff = null) + { + var argsJson = CreateJsonArray(args); + argsJson.Add(new JsonObject + { + ["jsonInputArg"] = inputArg, + ["mandatory"] = true, + }); + var methodObject = AddMethod(argsJson, implementsPretest, stateAndDiff); + _manifest[method] = methodObject; + return this; + } + + /// <summary> + /// Adds a method to the manifest that reads from standard input (stdin). + /// </summary> + /// <param name="method">The name of the method to add.</param> + /// <param name="args">The list of arguments for the method.</param> + /// <param name="implementsPretest">Whether the method implements a pretest.</param> + /// <param name="stateAndDiff">Whether the method returns state and diff.</param> + /// <returns>Returns the current instance of <see cref="DscManifest"/>.</returns> + public DscManifest AddStdinMethod(string method, List<string> args, bool? implementsPretest = null, bool? stateAndDiff = null) + { + var argsJson = CreateJsonArray(args); + var methodObject = AddMethod(argsJson, implementsPretest, stateAndDiff); + methodObject["input"] = "stdin"; + _manifest[method] = methodObject; + return this; + } + + /// <summary> + /// Adds a command method to the manifest. + /// </summary> + /// <param name="method">The name of the method to add.</param> + /// <param name="args">The list of arguments for the method.</param> + /// <returns>Returns the current instance of <see cref="DscManifest"/>.</returns> + public DscManifest AddCommandMethod(string method, List<string> args) + { + _manifest[method] = new JsonObject + { + ["command"] = AddMethod(CreateJsonArray(args)), + }; + return this; + } + + /// <summary> + /// Gets the JSON representation of the manifest. + /// </summary> + /// <returns>Returns the JSON string of the manifest.</returns> + public string ToJson() + { + return _manifest.ToJsonString(new() { WriteIndented = true }); + } + + /// <summary> + /// Add a method to the manifest with the specified arguments. + /// </summary> + /// <param name="args">The list of arguments for the method.</param> + /// <param name="implementsPretest">Whether the method implements a pretest.</param> + /// <param name="stateAndDiff">Whether the method returns state and diff.</param> + /// <returns>Returns the method object.</returns> + private JsonObject AddMethod(JsonArray args, bool? implementsPretest = null, bool? stateAndDiff = null) + { + var methodObject = new JsonObject + { + ["executable"] = Executable, + ["args"] = args, + }; + + if (implementsPretest.HasValue) + { + methodObject["implementsPretest"] = implementsPretest.Value; + } + + if (stateAndDiff.HasValue) + { + methodObject["return"] = stateAndDiff.Value ? "stateAndDiff" : "state"; + } + + return methodObject; + } + + /// <summary> + /// Creates a JSON array from a list of strings. + /// </summary> + /// <param name="args">The list of strings to convert.</param> + /// <returns>Returns the JSON array.</returns> + private JsonArray CreateJsonArray(List<string> args) + { + var jsonArray = new JsonArray(); + foreach (var arg in args) + { + jsonArray.Add(arg); + } + + return jsonArray; + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/DscMessageLevel.cs b/src/dsc/v3/PowerToys.DSC/Models/DscMessageLevel.cs new file mode 100644 index 0000000000..9c5b12b3c0 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/DscMessageLevel.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. + +namespace PowerToys.DSC.Models; + +/// <summary> +/// Specifies the severity level of a message. +/// </summary> +public enum DscMessageLevel +{ + /// <summary> + /// Represents an error message. + /// </summary> + Error, + + /// <summary> + /// Represents a warning message. + /// </summary> + Warning, + + /// <summary> + /// Represents an informational message. + /// </summary> + Info, + + /// <summary> + /// Represents a debug message. + /// </summary> + Debug, + + /// <summary> + /// Represents a trace message. + /// </summary> + Trace, +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/FunctionData/BaseFunctionData.cs b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/BaseFunctionData.cs new file mode 100644 index 0000000000..4456beed82 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/BaseFunctionData.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 Newtonsoft.Json; +using NJsonSchema.Generation; +using PowerToys.DSC.Models.ResourceObjects; + +namespace PowerToys.DSC.Models.FunctionData; + +/// <summary> +/// Base class for function data objects. +/// </summary> +public class BaseFunctionData +{ + /// <summary> + /// Generates a JSON schema for the specified resource object type. + /// </summary> + /// <typeparam name="T">The type of the resource object.</typeparam> + /// <returns>A JSON schema string.</returns> + protected static string GenerateSchema<T>() + where T : BaseResourceObject + { + var settings = new SystemTextJsonSchemaGeneratorSettings() + { + FlattenInheritanceHierarchy = true, + SerializerOptions = + { + IgnoreReadOnlyFields = true, + }, + }; + var generator = new JsonSchemaGenerator(settings); + var schema = generator.Generate(typeof(T)); + return schema.ToJson(Formatting.None); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/FunctionData/ISettingsFunctionData.cs b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/ISettingsFunctionData.cs new file mode 100644 index 0000000000..7cf02d1c74 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/ISettingsFunctionData.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Nodes; +using PowerToys.DSC.Models.ResourceObjects; + +namespace PowerToys.DSC.Models.FunctionData; + +/// <summary> +/// Interface for function data related to settings. +/// </summary> +public interface ISettingsFunctionData +{ + /// <summary> + /// Gets the input settings resource object. + /// </summary> + public ISettingsResourceObject Input { get; } + + /// <summary> + /// Gets the output settings resource object. + /// </summary> + public ISettingsResourceObject Output { get; } + + /// <summary> + /// Gets the current settings. + /// </summary> + public void GetState(); + + /// <summary> + /// Sets the current settings. + /// </summary> + public void SetState(); + + /// <summary> + /// Tests if the current settings and the desired state are valid. + /// </summary> + /// <returns>True if the current settings match the desired state; otherwise false.</returns> + public bool TestState(); + + /// <summary> + /// Gets the difference between the current settings and the desired state in JSON format. + /// </summary> + /// <returns>A JSON array representing the differences.</returns> + public JsonArray GetDiffJson(); + + /// <summary> + /// Gets the schema for the settings resource object. + /// </summary> + /// <returns></returns> + public string Schema(); +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs new file mode 100644 index 0000000000..9d87b1e773 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/FunctionData/SettingsFunctionData`1.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using PowerToys.DSC.Models.ResourceObjects; + +namespace PowerToys.DSC.Models.FunctionData; + +/// <summary> +/// Represents function data for the settings DSC resource. +/// </summary> +/// <typeparam name="TSettingsConfig">The module settings configuration type.</typeparam> +public sealed class SettingsFunctionData<TSettingsConfig> : BaseFunctionData, ISettingsFunctionData + where TSettingsConfig : ISettingsConfig, new() +{ + private static readonly SettingsUtils _settingsUtils = SettingsUtils.Default; + private static readonly TSettingsConfig _settingsConfig = new(); + + private readonly SettingsResourceObject<TSettingsConfig> _input; + private readonly SettingsResourceObject<TSettingsConfig> _output; + + /// <inheritdoc/> + public ISettingsResourceObject Input => _input; + + /// <inheritdoc/> + public ISettingsResourceObject Output => _output; + + public SettingsFunctionData(string? input = null) + { + _output = new(); + _input = string.IsNullOrEmpty(input) ? new() : JsonSerializer.Deserialize<SettingsResourceObject<TSettingsConfig>>(input) ?? new(); + } + + /// <inheritdoc/> + public void GetState() + { + _output.Settings = GetSettings(); + } + + /// <inheritdoc/> + public void SetState() + { + Debug.Assert(_output.Settings != null, "Output settings should not be null"); + SaveSettings(_output.Settings); + } + + /// <inheritdoc/> + public bool TestState() + { + var input = JsonSerializer.SerializeToNode(_input.Settings); + var output = JsonSerializer.SerializeToNode(_output.Settings); + return JsonNode.DeepEquals(input, output); + } + + /// <inheritdoc/> + public JsonArray GetDiffJson() + { + var diff = new JsonArray(); + if (!TestState()) + { + diff.Add(SettingsResourceObject<TSettingsConfig>.SettingsJsonPropertyName); + } + + return diff; + } + + /// <inheritdoc/> + public string Schema() + { + return GenerateSchema<SettingsResourceObject<TSettingsConfig>>(); + } + + /// <summary> + /// Gets the settings configuration from the settings utils for a specific module. + /// </summary> + /// <returns>The settings configuration for the module.</returns> + private static TSettingsConfig GetSettings() + { + return _settingsUtils.GetSettingsOrDefault<TSettingsConfig>(_settingsConfig.GetModuleName()); + } + + /// <summary> + /// Saves the settings configuration to the settings utils for a specific module. + /// </summary> + /// <param name="settings">Settings of a specific module</param> + private static void SaveSettings(TSettingsConfig settings) + { + var inputJson = JsonSerializer.Serialize(settings); + _settingsUtils.SaveSettings(inputJson, _settingsConfig.GetModuleName()); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/BaseResourceObject.cs b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/BaseResourceObject.cs new file mode 100644 index 0000000000..d6e3e08dcc --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/BaseResourceObject.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.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace PowerToys.DSC.Models.ResourceObjects; + +/// <summary> +/// Base class for all resource objects. +/// </summary> +public class BaseResourceObject +{ + private readonly JsonSerializerOptions _options; + + public BaseResourceObject() + { + _options = new() + { + WriteIndented = false, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + } + + /// <summary> + /// Gets or sets whether an instance is in the desired state. + /// </summary> + [JsonPropertyName("_inDesiredState")] + [Description("Indicates whether an instance is in the desired state")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? InDesiredState { get; set; } + + /// <summary> + /// Generates a JSON representation of the resource object. + /// </summary> + /// <returns></returns> + public JsonNode ToJson() + { + return JsonSerializer.SerializeToNode(this, GetType(), _options) ?? new JsonObject(); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/ISettingsResourceObject.cs b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/ISettingsResourceObject.cs new file mode 100644 index 0000000000..85c9c7eadc --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/ISettingsResourceObject.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Nodes; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace PowerToys.DSC.Models.ResourceObjects; + +/// <summary> +/// Interface for settings resource objects. +/// </summary> +public interface ISettingsResourceObject +{ + /// <summary> + /// Gets or sets the settings configuration. + /// </summary> + public ISettingsConfig SettingsInternal { get; set; } + + /// <summary> + /// Gets or sets whether an instance is in the desired state. + /// </summary> + public bool? InDesiredState { get; set; } + + /// <summary> + /// Generates a JSON representation of the resource object. + /// </summary> + /// <returns>String representation of the resource object in JSON format.</returns> + public JsonNode ToJson(); +} diff --git a/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/SettingsResourceObject`1.cs b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/SettingsResourceObject`1.cs new file mode 100644 index 0000000000..d5017336ed --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Models/ResourceObjects/SettingsResourceObject`1.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using NJsonSchema.Annotations; + +namespace PowerToys.DSC.Models.ResourceObjects; + +/// <summary> +/// Represents a settings resource object for a module's settings configuration. +/// </summary> +/// <typeparam name="TSettingsConfig">The type of the settings configuration.</typeparam> +public sealed class SettingsResourceObject<TSettingsConfig> : BaseResourceObject, ISettingsResourceObject + where TSettingsConfig : ISettingsConfig, new() +{ + public const string SettingsJsonPropertyName = "settings"; + + /// <summary> + /// Gets or sets the settings content for the module. + /// </summary> + [JsonPropertyName(SettingsJsonPropertyName)] + [Required] + [Description("The settings content for the module.")] + [JsonSchemaType(typeof(object))] + public TSettingsConfig Settings { get; set; } = new(); + + /// <inheritdoc/> + [JsonIgnore] + public ISettingsConfig SettingsInternal { get => Settings; set => Settings = (TSettingsConfig)value; } +} diff --git a/src/dsc/v3/PowerToys.DSC/Options/InputOption.cs b/src/dsc/v3/PowerToys.DSC/Options/InputOption.cs new file mode 100644 index 0000000000..048c50a2df --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Options/InputOption.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using System.Text; +using System.Text.Json; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Options; + +/// <summary> +/// Represents an option for specifying JSON input for the dsc command. +/// </summary> +public sealed class InputOption : Option<string> +{ + private static readonly CompositeFormat InvalidJsonInputError = CompositeFormat.Parse(Resources.InvalidJsonInputError); + + public InputOption() + : base("--input", Resources.InputOptionDescription) + { + AddValidator(OptionValidator); + } + + /// <summary> + /// Validates the JSON input provided to the option. + /// </summary> + /// <param name="result">The option result to validate.</param> + private void OptionValidator(OptionResult result) + { + var value = result.GetValueOrDefault<string>() ?? string.Empty; + if (string.IsNullOrEmpty(value)) + { + result.ErrorMessage = Resources.InputEmptyOrNullError; + } + else + { + try + { + JsonDocument.Parse(value); + } + catch (Exception e) + { + result.ErrorMessage = string.Format(CultureInfo.InvariantCulture, InvalidJsonInputError, e.Message); + } + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Options/ModuleOption.cs b/src/dsc/v3/PowerToys.DSC/Options/ModuleOption.cs new file mode 100644 index 0000000000..a5273c2cb0 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Options/ModuleOption.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.CommandLine; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Options; + +/// <summary> +/// Represents an option for specifying the module name for the dsc command. +/// </summary> +public sealed class ModuleOption : Option<string?> +{ + public ModuleOption() + : base("--module", Resources.ModuleOptionDescription) + { + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Options/OutputDirectoryOption.cs b/src/dsc/v3/PowerToys.DSC/Options/OutputDirectoryOption.cs new file mode 100644 index 0000000000..7de1af64b7 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Options/OutputDirectoryOption.cs @@ -0,0 +1,43 @@ +// 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.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using System.IO; +using System.Text; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Options; + +/// <summary> +/// Represents an option for specifying the output directory for the dsc command. +/// </summary> +public sealed class OutputDirectoryOption : Option<string> +{ + private static readonly CompositeFormat InvalidOutputDirectoryError = CompositeFormat.Parse(Resources.InvalidOutputDirectoryError); + + public OutputDirectoryOption() + : base("--outputDir", Resources.OutputDirectoryOptionDescription) + { + AddValidator(OptionValidator); + } + + /// <summary> + /// Validates the output directory option. + /// </summary> + /// <param name="result">The option result to validate.</param> + private void OptionValidator(OptionResult result) + { + var value = result.GetValueOrDefault<string>() ?? string.Empty; + if (string.IsNullOrEmpty(value)) + { + result.ErrorMessage = Resources.OutputDirectoryEmptyOrNullError; + } + else if (!Directory.Exists(value)) + { + result.ErrorMessage = string.Format(CultureInfo.InvariantCulture, InvalidOutputDirectoryError, value); + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Options/ResourceOption.cs b/src/dsc/v3/PowerToys.DSC/Options/ResourceOption.cs new file mode 100644 index 0000000000..cfce5dbfc7 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Options/ResourceOption.cs @@ -0,0 +1,43 @@ +// 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.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using System.Text; +using PowerToys.DSC.Properties; + +namespace PowerToys.DSC.Options; + +/// <summary> +/// Represents an option for specifying the resource name for the dsc command. +/// </summary> +public sealed class ResourceOption : Option<string> +{ + private static readonly CompositeFormat InvalidResourceNameError = CompositeFormat.Parse(Resources.InvalidResourceNameError); + + private readonly IList<string> _resources = []; + + public ResourceOption(IList<string> resources) + : base("--resource", Resources.ResourceOptionDescription) + { + _resources = resources; + IsRequired = true; + AddValidator(OptionValidator); + } + + /// <summary> + /// Validates the resource option to ensure that the specified resource name is valid. + /// </summary> + /// <param name="result">The option result to validate.</param> + private void OptionValidator(OptionResult result) + { + var value = result.GetValueOrDefault<string>() ?? string.Empty; + if (!_resources.Contains(value)) + { + result.ErrorMessage = string.Format(CultureInfo.InvariantCulture, InvalidResourceNameError, string.Join(", ", _resources)); + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj b/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj new file mode 100644 index 0000000000..c756ccfc7f --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj @@ -0,0 +1,52 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + + <PropertyGroup> + <OutputType>Exe</OutputType> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <AssemblyName>PowerToys.DSC</AssemblyName> + <AssemblyDescription>PowerToys DSC</AssemblyDescription> + <RootNamespace>PowerToys.DSC</RootNamespace> + <Nullable>enable</Nullable> + <!-- Ensure WindowsDesktop runtime pack is included for consistent WindowsBase.dll version --> + <UseWPF>true</UseWPF> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="NJsonSchema" /> + <PackageReference Include="System.CommandLine" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> + </ItemGroup> + + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>Resources.resx</DependentUpon> + </Compile> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <Generator>ResXFileCodeGenerator</Generator> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + </EmbeddedResource> + </ItemGroup> + + <!-- Generate the DSC resource JSON files to DSCModules subfolder --> + <!-- Skip generation in CI/CD builds (CIBuild=true) to avoid unnecessary work during pipeline --> + <Target Name="GenerateDscResourceJsonFiles" AfterTargets="Build" Condition="'$(CIBuild)' != 'true'"> + <Message Text="Generating DSC resource JSON files to DSCModules subfolder..." Importance="high" /> + <MakeDir Directories="$(TargetDir)DSCModules" /> + + <Exec Command=""$(TargetDir)$(AssemblyName).exe" manifest --resource settings --outputDir "$(TargetDir)DSCModules"" Condition="'$(SelfContained)' == 'true'" /> + <Exec Command="dotnet "$(TargetPath)" manifest --resource settings --outputDir "$(TargetDir)DSCModules"" Condition="'$(SelfContained)' != 'true'" /> + </Target> +</Project> \ No newline at end of file diff --git a/src/dsc/v3/PowerToys.DSC/Program.cs b/src/dsc/v3/PowerToys.DSC/Program.cs new file mode 100644 index 0000000000..09a22b64d6 --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Program.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.CommandLine; +using System.CommandLine.Parsing; +using System.Threading.Tasks; +using PowerToys.DSC.Commands; + +namespace PowerToys.DSC; + +/// <summary> +/// Main entry point for the PowerToys Desired State Configuration CLI application. +/// </summary> +public class Program +{ + public static async Task<int> Main(string[] args) + { + var rootCommand = new RootCommand(Properties.Resources.PowerToysDSC); + rootCommand.AddCommand(new GetCommand()); + rootCommand.AddCommand(new SetCommand()); + rootCommand.AddCommand(new ExportCommand()); + rootCommand.AddCommand(new TestCommand()); + rootCommand.AddCommand(new SchemaCommand()); + rootCommand.AddCommand(new ManifestCommand()); + rootCommand.AddCommand(new ModulesCommand()); + return await rootCommand.InvokeAsync(args); + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Properties/Resources.Designer.cs b/src/dsc/v3/PowerToys.DSC/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..4089d98c6b --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Properties/Resources.Designer.cs @@ -0,0 +1,234 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// 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. +// </auto-generated> +//------------------------------------------------------------------------------ + +namespace PowerToys.DSC.Properties { + using System; + + + /// <summary> + /// A strongly-typed resource class, for looking up localized strings, etc. + /// </summary> + // 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", "17.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() { + } + + /// <summary> + /// Returns the cached ResourceManager instance used by this class. + /// </summary> + [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("PowerToys.DSC.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// <summary> + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// <summary> + /// Looks up a localized string similar to Get all state instances. + /// </summary> + internal static string ExportCommandDescription { + get { + return ResourceManager.GetString("ExportCommandDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Failed to write manifests to directory '{0}': {1}. + /// </summary> + internal static string FailedToWriteManifests { + get { + return ResourceManager.GetString("FailedToWriteManifests", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Get the resource state. + /// </summary> + internal static string GetCommandDescription { + get { + return ResourceManager.GetString("GetCommandDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Input cannot be empty or null. + /// </summary> + internal static string InputEmptyOrNullError { + get { + return ResourceManager.GetString("InputEmptyOrNullError", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The JSON input. + /// </summary> + internal static string InputOptionDescription { + get { + return ResourceManager.GetString("InputOptionDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Invalid JSON input: {0}. + /// </summary> + internal static string InvalidJsonInputError { + get { + return ResourceManager.GetString("InvalidJsonInputError", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Invalid output directory: {0}. + /// </summary> + internal static string InvalidOutputDirectoryError { + get { + return ResourceManager.GetString("InvalidOutputDirectoryError", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Invalid resource name. Valid resource names are: {0}. + /// </summary> + internal static string InvalidResourceNameError { + get { + return ResourceManager.GetString("InvalidResourceNameError", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Get the manifest of the dsc resource. + /// </summary> + internal static string ManifestCommandDescription { + get { + return ResourceManager.GetString("ManifestCommandDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Module '{0}' is not supported for the resource {1}. Use the 'module' command to list available modules.. + /// </summary> + internal static string ModuleNotSupportedByResource { + get { + return ResourceManager.GetString("ModuleNotSupportedByResource", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The module name. + /// </summary> + internal static string ModuleOptionDescription { + get { + return ResourceManager.GetString("ModuleOptionDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Get all supported modules for a specific resource. + /// </summary> + internal static string ModulesCommandDescription { + get { + return ResourceManager.GetString("ModulesCommandDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Output directory cannot be empty or null. + /// </summary> + internal static string OutputDirectoryEmptyOrNullError { + get { + return ResourceManager.GetString("OutputDirectoryEmptyOrNullError", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The output directory. + /// </summary> + internal static string OutputDirectoryOptionDescription { + get { + return ResourceManager.GetString("OutputDirectoryOptionDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PowerToys Desired State Configuration commands. + /// </summary> + internal static string PowerToysDSC { + get { + return ResourceManager.GetString("PowerToysDSC", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The resource name. + /// </summary> + internal static string ResourceOptionDescription { + get { + return ResourceManager.GetString("ResourceOptionDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Outputs schema of the resource. + /// </summary> + internal static string SchemaCommandDescription { + get { + return ResourceManager.GetString("SchemaCommandDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Set the resource state. + /// </summary> + internal static string SetCommandDescription { + get { + return ResourceManager.GetString("SetCommandDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Test the resource state. + /// </summary> + internal static string TestCommandDescription { + get { + return ResourceManager.GetString("TestCommandDescription", resourceCulture); + } + } + } +} diff --git a/src/dsc/v3/PowerToys.DSC/Properties/Resources.resx b/src/dsc/v3/PowerToys.DSC/Properties/Resources.resx new file mode 100644 index 0000000000..2648d6501b --- /dev/null +++ b/src/dsc/v3/PowerToys.DSC/Properties/Resources.resx @@ -0,0 +1,183 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="PowerToysDSC" xml:space="preserve"> + <value>PowerToys Desired State Configuration commands</value> + <comment>{Locked="PowerToys Desired State Configuration"}</comment> + </data> + <data name="ModuleNotSupportedByResource" xml:space="preserve"> + <value>Module '{0}' is not supported for the resource {1}. Use the 'module' command to list available modules.</value> + <comment>{Locked="'module'","{0}","{1}"}</comment> + </data> + <data name="ExportCommandDescription" xml:space="preserve"> + <value>Get all state instances</value> + </data> + <data name="GetCommandDescription" xml:space="preserve"> + <value>Get the resource state</value> + </data> + <data name="ManifestCommandDescription" xml:space="preserve"> + <value>Get the manifest of the dsc resource</value> + </data> + <data name="ModulesCommandDescription" xml:space="preserve"> + <value>Get all supported modules for a specific resource</value> + </data> + <data name="SchemaCommandDescription" xml:space="preserve"> + <value>Outputs schema of the resource</value> + </data> + <data name="SetCommandDescription" xml:space="preserve"> + <value>Set the resource state</value> + </data> + <data name="TestCommandDescription" xml:space="preserve"> + <value>Test the resource state</value> + </data> + <data name="InputEmptyOrNullError" xml:space="preserve"> + <value>Input cannot be empty or null</value> + </data> + <data name="FailedToWriteManifests" xml:space="preserve"> + <value>Failed to write manifests to directory '{0}': {1}</value> + <comment>{Locked="{0}","{1}"}</comment> + </data> + <data name="InputOptionDescription" xml:space="preserve"> + <value>The JSON input</value> + </data> + <data name="ModuleOptionDescription" xml:space="preserve"> + <value>The module name</value> + </data> + <data name="OutputDirectoryOptionDescription" xml:space="preserve"> + <value>The output directory</value> + </data> + <data name="ResourceOptionDescription" xml:space="preserve"> + <value>The resource name</value> + </data> + <data name="InvalidJsonInputError" xml:space="preserve"> + <value>Invalid JSON input: {0}</value> + <comment>{Locked="{0}"}</comment> + </data> + <data name="OutputDirectoryEmptyOrNullError" xml:space="preserve"> + <value>Output directory cannot be empty or null</value> + </data> + <data name="InvalidOutputDirectoryError" xml:space="preserve"> + <value>Invalid output directory: {0}</value> + <comment>{Locked="{0}"}</comment> + </data> + <data name="InvalidResourceNameError" xml:space="preserve"> + <value>Invalid resource name. Valid resource names are: {0}</value> + <comment>{Locked="{0}"}</comment> + </data> +</root> \ No newline at end of file diff --git a/src/gpo/assets/PowerToys.admx b/src/gpo/assets/PowerToys.admx index 9bbd0d7ff3..ddef3d95eb 100644 --- a/src/gpo/assets/PowerToys.admx +++ b/src/gpo/assets/PowerToys.admx @@ -1,11 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> <!-- Copyright (c) Microsoft Corporation. Licensed under the MIT License. --> -<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.17" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions"> +<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.19" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions"> <policyNamespaces> <target prefix="powertoys" namespace="Microsoft.Policies.PowerToys" /> </policyNamespaces> - <resources minRequiredRevision="1.17"/><!-- Last changed with PowerToys v0.90.0 --> + <resources minRequiredRevision="1.19"/><!-- Last changed with PowerToys v0.97.0 --> <supportedOn> <definitions> <definition name="SUPPORTED_POWERTOYS_0_64_0" displayName="$(string.SUPPORTED_POWERTOYS_0_64_0)"/> @@ -26,6 +26,8 @@ <definition name="SUPPORTED_POWERTOYS_0_88_0" displayName="$(string.SUPPORTED_POWERTOYS_0_88_0)"/> <definition name="SUPPORTED_POWERTOYS_0_89_0" displayName="$(string.SUPPORTED_POWERTOYS_0_89_0)"/> <definition name="SUPPORTED_POWERTOYS_0_90_0" displayName="$(string.SUPPORTED_POWERTOYS_0_90_0)"/> + <definition name="SUPPORTED_POWERTOYS_0_96_0" displayName="$(string.SUPPORTED_POWERTOYS_0_96_0)"/> + <definition name="SUPPORTED_POWERTOYS_0_97_0" displayName="$(string.SUPPORTED_POWERTOYS_0_97_0)"/> <definition name="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1" displayName="$(string.SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1)"/> </definitions> </supportedOn> @@ -137,6 +139,26 @@ <decimal value="0" /> </disabledValue> </policy> + <policy name="ConfigureEnabledUtilityLightSwitch" class="Both" displayName="$(string.ConfigureEnabledUtilityLightSwitch)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityLightSwitch"> + <parentCategory ref="PowerToys" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_95_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> + <policy name="ConfigureEnabledUtilityPowerDisplay" class="Both" displayName="$(string.ConfigureEnabledUtilityPowerDisplay)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityPowerDisplay"> + <parentCategory ref="PowerToys" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_95_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> <policy name="ConfigureEnabledUtilityEnvironmentVariables" class="Both" displayName="$(string.ConfigureEnabledUtilityEnvironmentVariables)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityEnvironmentVariables"> <parentCategory ref="PowerToys" /> <supportedOn ref="SUPPORTED_POWERTOYS_0_75_0" /> @@ -217,6 +239,16 @@ <decimal value="0" /> </disabledValue> </policy> + <policy name="ConfigureEnabledUtilityFileExplorerBgcodePreview" class="Both" displayName="$(string.ConfigureEnabledUtilityFileExplorerBgcodePreview)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityFileExplorerBgcodePreview"> + <parentCategory ref="PowerToys" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_93_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> <policy name="ConfigureEnabledUtilityFileExplorerSVGThumbnails" class="Both" displayName="$(string.ConfigureEnabledUtilityFileExplorerSVGThumbnails)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityFileExplorerSVGThumbnails"> <parentCategory ref="PowerToys" /> <supportedOn ref="SUPPORTED_POWERTOYS_0_64_0" /> @@ -247,6 +279,16 @@ <decimal value="0" /> </disabledValue> </policy> + <policy name="ConfigureEnabledUtilityFileExplorerBgcodeThumbnails" class="Both" displayName="$(string.ConfigureEnabledUtilityFileExplorerBgcodeThumbnails)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityFileExplorerBgcodeThumbnails"> + <parentCategory ref="PowerToys" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_93_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> <policy name="ConfigureEnabledUtilityFileExplorerQOIPreview" class="Both" displayName="$(string.ConfigureEnabledUtilityFileExplorerQOIPreview)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityFileExplorerQOIPreview"> <parentCategory ref="PowerToys" /> <supportedOn ref="SUPPORTED_POWERTOYS_0_76_0" /> @@ -307,6 +349,16 @@ <decimal value="0" /> </disabledValue> </policy> + <policy name="ConfigureEnabledUtilityCursorWrap" class="Both" displayName="$(string.ConfigureEnabledUtilityCursorWrap)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityCursorWrap"> + <parentCategory ref="PowerToys" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_97_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> <policy name="ConfigureEnabledUtilityFindMyMouse" class="Both" displayName="$(string.ConfigureEnabledUtilityFindMyMouse)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityFindMyMouse"> <parentCategory ref="PowerToys" /> <supportedOn ref="SUPPORTED_POWERTOYS_0_64_0" /> @@ -584,6 +636,86 @@ <decimal value="0" /> </disabledValue> </policy> + <policy name="AllowAdvancedPasteOpenAI" class="Both" displayName="$(string.AllowAdvancedPasteOpenAI)" explainText="$(string.AllowAdvancedPasteOpenAIDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPasteOpenAI"> + <parentCategory ref="AdvancedPaste" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_96_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> + <policy name="AllowAdvancedPasteAzureOpenAI" class="Both" displayName="$(string.AllowAdvancedPasteAzureOpenAI)" explainText="$(string.AllowAdvancedPasteAzureOpenAIDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPasteAzureOpenAI"> + <parentCategory ref="AdvancedPaste" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_96_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> + <policy name="AllowAdvancedPasteAzureAIInference" class="Both" displayName="$(string.AllowAdvancedPasteAzureAIInference)" explainText="$(string.AllowAdvancedPasteAzureAIInferenceDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPasteAzureAIInference"> + <parentCategory ref="AdvancedPaste" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_96_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> + <policy name="AllowAdvancedPasteMistral" class="Both" displayName="$(string.AllowAdvancedPasteMistral)" explainText="$(string.AllowAdvancedPasteMistralDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPasteMistral"> + <parentCategory ref="AdvancedPaste" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_96_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> + <policy name="AllowAdvancedPasteGoogle" class="Both" displayName="$(string.AllowAdvancedPasteGoogle)" explainText="$(string.AllowAdvancedPasteGoogleDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPasteGoogle"> + <parentCategory ref="AdvancedPaste" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_96_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> + <policy name="AllowAdvancedPasteAnthropic" class="Both" displayName="$(string.AllowAdvancedPasteAnthropic)" explainText="$(string.AllowAdvancedPasteAnthropicDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPasteAnthropic"> + <parentCategory ref="AdvancedPaste" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_96_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> + <policy name="AllowAdvancedPasteOllama" class="Both" displayName="$(string.AllowAdvancedPasteOllama)" explainText="$(string.AllowAdvancedPasteOllamaDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPasteOllama"> + <parentCategory ref="AdvancedPaste" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_96_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> + <policy name="AllowAdvancedPasteFoundryLocal" class="Both" displayName="$(string.AllowAdvancedPasteFoundryLocal)" explainText="$(string.AllowAdvancedPasteFoundryLocalDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPasteFoundryLocal"> + <parentCategory ref="AdvancedPaste" /> + <supportedOn ref="SUPPORTED_POWERTOYS_0_96_0" /> + <enabledValue> + <decimal value="1" /> + </enabledValue> + <disabledValue> + <decimal value="0" /> + </disabledValue> + </policy> <policy name="MwbClipboardSharingEnabled" class="Both" displayName="$(string.MwbClipboardSharingEnabled)" explainText="$(string.MwbClipboardSharingEnabledDescription)" key="Software\Policies\PowerToys" valueName="MwbClipboardSharingEnabled"> <parentCategory ref="MouseWithoutBorders" /> <supportedOn ref="SUPPORTED_POWERTOYS_0_83_0" /> diff --git a/src/gpo/assets/en-US/PowerToys.adml b/src/gpo/assets/en-US/PowerToys.adml index 95deba2bd6..ccd38d9934 100644 --- a/src/gpo/assets/en-US/PowerToys.adml +++ b/src/gpo/assets/en-US/PowerToys.adml @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <!-- Copyright (c) Microsoft Corporation. Licensed under the MIT License. --> -<policyDefinitionResources xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.17" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions"> +<policyDefinitionResources xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.19" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions"> <displayName>PowerToys</displayName> <description>PowerToys</description> <resources> @@ -33,6 +33,8 @@ <string id="SUPPORTED_POWERTOYS_0_88_0">PowerToys version 0.88.0 or later</string> <string id="SUPPORTED_POWERTOYS_0_89_0">PowerToys version 0.89.0 or later</string> <string id="SUPPORTED_POWERTOYS_0_90_0">PowerToys version 0.90.0 or later</string> + <string id="SUPPORTED_POWERTOYS_0_96_0">PowerToys version 0.96.0 or later</string> + <string id="SUPPORTED_POWERTOYS_0_97_0">PowerToys version 0.97.0 or later</string> <string id="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1">From PowerToys version 0.64.0 until PowerToys version 0.87.1</string> <string id="ConfigureAllUtilityGlobalEnabledStateDescription">This policy configures the enabled state for all PowerToys utilities. @@ -245,6 +247,8 @@ If you don't configure this policy, the user will be able to control the setting <string id="ConfigureEnabledUtilityCmdNotFound">Command Not Found: Configure enabled state</string> <string id="ConfigureEnabledUtilityCmdPal">CmdPal: Configure enabled state</string> <string id="ConfigureEnabledUtilityCropAndLock">Crop And Lock: Configure enabled state</string> + <string id="ConfigureEnabledUtilityLightSwitch">Light Switch: Configure enabled state</string> + <string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string> <string id="ConfigureEnabledUtilityEnvironmentVariables">Environment Variables: Configure enabled state</string> <string id="ConfigureEnabledUtilityFancyZones">FancyZones: Configure enabled state</string> <string id="ConfigureEnabledUtilityFileLocksmith">File Locksmith: Configure enabled state</string> @@ -253,15 +257,18 @@ If you don't configure this policy, the user will be able to control the setting <string id="ConfigureEnabledUtilityFileExplorerMonacoPreview">Source code file preview: Configure enabled state</string> <string id="ConfigureEnabledUtilityFileExplorerPDFPreview">PDF file preview: Configure enabled state</string> <string id="ConfigureEnabledUtilityFileExplorerGcodePreview">Gcode file preview: Configure enabled state</string> + <string id="ConfigureEnabledUtilityFileExplorerBgcodePreview">BGcode file preview: Configure enabled state</string> <string id="ConfigureEnabledUtilityFileExplorerSVGThumbnails">SVG file thumbnail: Configure enabled state</string> <string id="ConfigureEnabledUtilityFileExplorerPDFThumbnails">PDF file thumbnail: Configure enabled state</string> <string id="ConfigureEnabledUtilityFileExplorerGcodeThumbnails">Gcode file thumbnail: Configure enabled state</string> + <string id="ConfigureEnabledUtilityFileExplorerBgcodeThumbnails">BGcode file thumbnail: Configure enabled state</string> <string id="ConfigureEnabledUtilityFileExplorerSTLThumbnails">STL file thumbnail: Configure enabled state</string> <string id="ConfigureEnabledUtilityHostsFileEditor">Hosts file editor: Configure enabled state</string> <string id="ConfigureEnabledUtilityImageResizer">Image Resizer: Configure enabled state</string> <string id="ConfigureEnabledUtilityKeyboardManager">Keyboard Manager: Configure enabled state</string> <string id="ConfigureEnabledUtilityFindMyMouse">Find My Mouse: Configure enabled state</string> <string id="ConfigureEnabledUtilityMouseHighlighter">Mouse Highlighter: Configure enabled state</string> + <string id="ConfigureEnabledUtilityCursorWrap">CursorWrap: Configure enabled state</string> <string id="ConfigureEnabledUtilityMouseJump">Mouse Jump: Configure enabled state</string> <string id="ConfigureEnabledUtilityMousePointerCrosshairs">Mouse Pointer Crosshairs: Configure enabled state</string> <string id="ConfigureEnabledUtilityMouseWithoutBorders">Mouse Without Borders: Configure enabled state</string> @@ -288,6 +295,54 @@ If you don't configure this policy, the user will be able to control the setting <string id="ConfigureEnabledUtilityFileExplorerQOIPreview">QOI file preview: Configure enabled state</string> <string id="ConfigureEnabledUtilityFileExplorerQOIThumbnails">QOI file thumbnail: Configure enabled state</string> <string id="AllowPowerToysAdvancedPasteOnlineAIModels">Allow using online AI models</string> + <string id="AllowAdvancedPasteOpenAI">Advanced Paste: Allow OpenAI endpoint</string> + <string id="AllowAdvancedPasteOpenAIDescription">This policy controls whether users can use the OpenAI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use OpenAI as their AI provider. + +If you disable this policy, users will not be able to select or use OpenAI endpoint in Advanced Paste settings.</string> + <string id="AllowAdvancedPasteAzureOpenAI">Advanced Paste: Allow Azure OpenAI endpoint</string> + <string id="AllowAdvancedPasteAzureOpenAIDescription">This policy controls whether users can use the Azure OpenAI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Azure OpenAI as their AI provider. + +If you disable this policy, users will not be able to select or use Azure OpenAI endpoint in Advanced Paste settings.</string> + <string id="AllowAdvancedPasteAzureAIInference">Advanced Paste: Allow Azure AI Inference endpoint</string> + <string id="AllowAdvancedPasteAzureAIInferenceDescription">This policy controls whether users can use the Azure AI Inference endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Azure AI Inference as their AI provider. + +If you disable this policy, users will not be able to select or use Azure AI Inference endpoint in Advanced Paste settings.</string> + <string id="AllowAdvancedPasteMistral">Advanced Paste: Allow Mistral endpoint</string> + <string id="AllowAdvancedPasteMistralDescription">This policy controls whether users can use the Mistral AI endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Mistral as their AI provider. + +If you disable this policy, users will not be able to select or use Mistral endpoint in Advanced Paste settings.</string> + <string id="AllowAdvancedPasteGoogle">Advanced Paste: Allow Google endpoint</string> + <string id="AllowAdvancedPasteGoogleDescription">This policy controls whether users can use the Google (Gemini) endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Google as their AI provider. + +If you disable this policy, users will not be able to select or use Google endpoint in Advanced Paste settings.</string> + <string id="AllowAdvancedPasteAnthropic">Advanced Paste: Allow Anthropic endpoint</string> + <string id="AllowAdvancedPasteAnthropicDescription">This policy controls whether users can use the Anthropic (Claude) endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Anthropic as their AI provider. + +If you disable this policy, users will not be able to select or use Anthropic endpoint in Advanced Paste settings.</string> + <string id="AllowAdvancedPasteOllama">Advanced Paste: Allow Ollama endpoint</string> + <string id="AllowAdvancedPasteOllamaDescription">This policy controls whether users can use the Ollama local model endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Ollama as their AI provider. + +If you disable this policy, users will not be able to select or use Ollama endpoint in Advanced Paste settings.</string> + <string id="AllowAdvancedPasteFoundryLocal">Advanced Paste: Allow Foundry Local endpoint</string> + <string id="AllowAdvancedPasteFoundryLocalDescription">This policy controls whether users can use the Foundry Local model endpoint in Advanced Paste. + +If you enable or don't configure this policy, users can configure and use Foundry Local as their AI provider. + +If you disable this policy, users will not be able to select or use Foundry Local endpoint in Advanced Paste settings.</string> <string id="MwbClipboardSharingEnabled">Clipboard sharing enabled</string> <string id="MwbFileTransferEnabled">File transfer enabled</string> <string id="MwbUseOriginalUserInterface">Original user interface is available</string> diff --git a/src/logging/logging.vcxproj b/src/logging/logging.vcxproj index ee1c6a7078..5203df355e 100644 --- a/src/logging/logging.vcxproj +++ b/src/logging/logging.vcxproj @@ -1,5 +1,6 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="16.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|Win32"> <Configuration>Debug</Configuration> @@ -31,13 +32,12 @@ <Keyword>Win32Proj</Keyword> <ProjectName>spdlog</ProjectName> </PropertyGroup> - <Import Project="$(ProjectDir)..\..\deps\spdlog.props" /> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> <CharacterSet>MultiByte</CharacterSet> - <PlatformToolset>v143</PlatformToolset> - <OutDir>..\..\$(Platform)\$(Configuration)\</OutDir> + + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -65,98 +65,98 @@ </Lib> </ItemDefinitionGroup> <ItemGroup> - <ClCompile Include="$(ProjectDir)..\..\deps\spdlog\src\spdlog.cpp" /> - <ClCompile Include="$(ProjectDir)..\..\deps\spdlog\src\stdout_sinks.cpp" /> - <ClCompile Include="$(ProjectDir)..\..\deps\spdlog\src\color_sinks.cpp" /> - <ClCompile Include="$(ProjectDir)..\..\deps\spdlog\src\file_sinks.cpp" /> - <ClCompile Include="$(ProjectDir)..\..\deps\spdlog\src\async.cpp" /> - <ClCompile Include="$(ProjectDir)..\..\deps\spdlog\src\cfg.cpp" /> - <ClCompile Include="$(ProjectDir)..\..\deps\spdlog\src\fmt.cpp" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\async.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\async_logger-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\async_logger.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\common-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\common.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\formatter.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fwd.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\logger-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\logger.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\pattern_formatter-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\pattern_formatter.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\spdlog-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\spdlog.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\stopwatch.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\tweakme.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\version.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\backtracer-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\backtracer.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\circular_q.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\console_globals.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\file_helper-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\file_helper.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\fmt_helper.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\log_msg-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\log_msg.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\log_msg_buffer-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\log_msg_buffer.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\mpmc_blocking_q.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\null_mutex.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\os-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\os.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\periodic_worker-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\periodic_worker.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\registry-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\registry.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\synchronous_factory.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\tcp_client-windows.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\tcp_client.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\thread_pool-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\thread_pool.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\details\windows_include.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\android_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\ansicolor_sink-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\ansicolor_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\base_sink-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\base_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\basic_file_sink-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\basic_file_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\daily_file_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\dist_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\dup_filter_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\msvc_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\null_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\ostream_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\ringbuffer_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\rotating_file_sink-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\rotating_file_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\sink-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\stdout_color_sinks-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\stdout_color_sinks.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\stdout_sinks-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\stdout_sinks.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\syslog_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\systemd_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\tcp_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\win_eventlog_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\wincolor_sink-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\sinks\wincolor_sink.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bin_to_hex.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\chrono.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\fmt.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\ostr.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\chrono.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\color.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\compile.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\core.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\format-inl.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\format.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\locale.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\os.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\ostream.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\posix.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\printf.h" /> - <ClInclude Include="$(ProjectDir)..\..\deps\spdlog\include\spdlog\fmt\bundled\ranges.h" /> + <ClCompile Include="$(RepoRoot)deps\spdlog\src\spdlog.cpp" /> + <ClCompile Include="$(RepoRoot)deps\spdlog\src\stdout_sinks.cpp" /> + <ClCompile Include="$(RepoRoot)deps\spdlog\src\color_sinks.cpp" /> + <ClCompile Include="$(RepoRoot)deps\spdlog\src\file_sinks.cpp" /> + <ClCompile Include="$(RepoRoot)deps\spdlog\src\async.cpp" /> + <ClCompile Include="$(RepoRoot)deps\spdlog\src\cfg.cpp" /> + <ClCompile Include="$(RepoRoot)deps\spdlog\src\fmt.cpp" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\async.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\async_logger-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\async_logger.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\common-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\common.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\formatter.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fwd.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\logger-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\logger.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\pattern_formatter-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\pattern_formatter.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\spdlog-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\spdlog.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\stopwatch.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\tweakme.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\version.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\backtracer-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\backtracer.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\circular_q.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\console_globals.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\file_helper-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\file_helper.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\fmt_helper.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\log_msg-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\log_msg.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\log_msg_buffer-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\log_msg_buffer.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\mpmc_blocking_q.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\null_mutex.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\os-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\os.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\periodic_worker-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\periodic_worker.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\registry-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\registry.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\synchronous_factory.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\tcp_client-windows.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\tcp_client.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\thread_pool-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\thread_pool.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\details\windows_include.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\android_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\ansicolor_sink-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\ansicolor_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\base_sink-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\base_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\basic_file_sink-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\basic_file_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\daily_file_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\dist_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\dup_filter_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\msvc_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\null_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\ostream_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\ringbuffer_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\rotating_file_sink-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\rotating_file_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\sink-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\stdout_color_sinks-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\stdout_color_sinks.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\stdout_sinks-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\stdout_sinks.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\syslog_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\systemd_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\tcp_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\win_eventlog_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\wincolor_sink-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\sinks\wincolor_sink.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bin_to_hex.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\chrono.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\fmt.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\ostr.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\chrono.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\color.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\compile.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\core.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\format-inl.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\format.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\locale.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\os.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\ostream.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\posix.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\printf.h" /> + <ClInclude Include="$(RepoRoot)deps\spdlog\include\spdlog\fmt\bundled\ranges.h" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> diff --git a/src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/AdvancedPaste.FuzzTests.csproj b/src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/AdvancedPaste.FuzzTests.csproj index 027e3ea975..fb4334f6d8 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/AdvancedPaste.FuzzTests.csproj +++ b/src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/AdvancedPaste.FuzzTests.csproj @@ -1,6 +1,8 @@ <Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.FuzzTest.props" /> <PropertyGroup> - <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework> <LangVersion>latest</LangVersion> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> @@ -12,7 +14,7 @@ <TestingPlatformCommandLineArguments>$(TestingPlatformCommandLineArguments) --ignore-exit-code 8</TestingPlatformCommandLineArguments> </PropertyGroup> <PropertyGroup> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\AdvancedPaste.FuzzTests\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\AdvancedPaste.FuzzTests\</OutputPath> </PropertyGroup> <ItemGroup> <Compile Include="..\AdvancedPaste\Helpers\JsonHelper.cs" Link="JsonHelper.cs" /> diff --git a/src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/OneFuzzConfig.json b/src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/OneFuzzConfig.json index 41bdc8c58a..ca5b104cfe 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/OneFuzzConfig.json +++ b/src/modules/AdvancedPaste/AdvancedPaste.FuzzTests/OneFuzzConfig.json @@ -17,10 +17,10 @@ "org": "microsoft", "project": "OS", "AssignedTo": "leilzh@microsoft.com", - "AreaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\SHINE\\PowerToys", + "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT", "IterationPath": "OS\\Future" }, - "jobNotificationEmail": "leilzh@microsoft.com", + "jobNotificationEmail": "PowerToys@microsoft.com", "skip": false, "rebootAfterSetup": false, "oneFuzzJobs": [ diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/AdvancedPaste.UnitTests.csproj b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/AdvancedPaste.UnitTests.csproj index 67cc99303a..71cd9dfed3 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/AdvancedPaste.UnitTests.csproj +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/AdvancedPaste.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <IsPackable>false</IsPackable> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ConvertersTests/HexColorToColorConverterTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ConvertersTests/HexColorToColorConverterTests.cs new file mode 100644 index 0000000000..b8915f278e --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ConvertersTests/HexColorToColorConverterTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using AdvancedPaste.Converters; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.UI; + +namespace AdvancedPaste.UnitTests.ConvertersTests; + +[TestClass] +public sealed class HexColorToColorConverterTests +{ + [TestMethod] + public void TestConvert_ValidSixDigitHex_ReturnsColor() + { + Color? result = HexColorConverterHelper.ConvertHexColorToRgb("#FFBFAB"); + Assert.IsNotNull(result); + + var color = (Windows.UI.Color)result; + Assert.AreEqual(255, color.R); + Assert.AreEqual(191, color.G); + Assert.AreEqual(171, color.B); + Assert.AreEqual(255, color.A); + } + + [TestMethod] + public void TestConvert_ValidThreeDigitHex_ReturnsColor() + { + Color? result = HexColorConverterHelper.ConvertHexColorToRgb("#abc"); + Assert.IsNotNull(result); + + var color = (Windows.UI.Color)result; + + // #abc should expand to #aabbcc + Assert.AreEqual(170, color.R); // 0xaa + Assert.AreEqual(187, color.G); // 0xbb + Assert.AreEqual(204, color.B); // 0xcc + Assert.AreEqual(255, color.A); + } + + [TestMethod] + public void TestConvert_NullOrEmpty_ReturnsNull() + { + Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb(null)); + Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb(string.Empty)); + Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb(" ")); + } + + [TestMethod] + public void TestConvert_InvalidHex_ReturnsNull() + { + Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb("#GGGGGG")); + Assert.IsNull(HexColorConverterHelper.ConvertHexColorToRgb("#12345")); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/HelpersTests/ClipboardItemHelperTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/HelpersTests/ClipboardItemHelperTests.cs new file mode 100644 index 0000000000..2b2a2c7595 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/HelpersTests/ClipboardItemHelperTests.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 AdvancedPaste.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AdvancedPaste.UnitTests.HelpersTests; + +[TestClass] +public sealed class ClipboardItemHelperTests +{ + [TestMethod] + [DataRow("#FFBFAB", true)] + [DataRow("#000000", true)] + [DataRow("#FFFFFF", true)] + [DataRow("#fff", true)] + [DataRow("#abc", true)] + [DataRow("#123456", true)] + [DataRow("#AbCdEf", true)] + [DataRow("FFBFAB", false)] // Missing # + [DataRow("#GGGGGG", false)] // Invalid hex characters + [DataRow("#12345", false)] // Wrong length + [DataRow("#1234567", false)] // Too long + [DataRow("", false)] + [DataRow(null, false)] + [DataRow(" #FFF ", true)] // Whitespace should be trimmed + [DataRow("Not a color", false)] + [DataRow("#", false)] + [DataRow("##FFFFFF", false)] + public void TestIsRgbHexColor(string input, bool expected) + { + bool result = ClipboardItemHelper.IsRgbHexColor(input); + Assert.AreEqual(expected, result, $"IsRgbHexColor(\"{input}\") should return {expected}"); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs new file mode 100644 index 0000000000..4446e24dde --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/Mocks/IntegrationTestUserSettings.cs @@ -0,0 +1,68 @@ +// 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.Threading.Tasks; +using AdvancedPaste.Models; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.UnitTests.Mocks; + +/// <summary> +/// Minimal <see cref="IUserSettings"/> implementation used by integration tests that +/// need to construct the runtime Advanced Paste services. +/// </summary> +internal sealed class IntegrationTestUserSettings : IUserSettings +{ + private readonly PasteAIConfiguration _configuration; + private readonly IReadOnlyList<AdvancedPasteCustomAction> _customActions; + private readonly IReadOnlyList<PasteFormats> _additionalActions; + + public IntegrationTestUserSettings() + { + var provider = new PasteAIProviderDefinition + { + Id = "integration-openai", + EnableAdvancedAI = true, + ServiceTypeKind = AIServiceType.OpenAI, + ModelName = "gpt-4o", + ModerationEnabled = true, + }; + + _configuration = new PasteAIConfiguration + { + ActiveProviderId = provider.Id, + Providers = new ObservableCollection<PasteAIProviderDefinition> { provider }, + }; + + _customActions = Array.Empty<AdvancedPasteCustomAction>(); + _additionalActions = Array.Empty<PasteFormats>(); + } + + public bool IsAIEnabled => true; + + public bool ShowCustomPreview => false; + + public bool CloseAfterLosingFocus => false; + + public bool EnableClipboardPreview => true; + + public IReadOnlyList<AdvancedPasteCustomAction> CustomActions => _customActions; + + public IReadOnlyList<PasteFormats> AdditionalActions => _additionalActions; + + public PasteAIConfiguration PasteAIConfiguration => _configuration; + + public event EventHandler Changed; + + public Task SetActiveAIProviderAsync(string providerId) + { + _configuration.ActiveProviderId = providerId ?? string.Empty; + Changed?.Invoke(this, EventArgs.Empty); + return Task.CompletedTask; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs index 20aaf77709..1f7829a0bd 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/AIServiceBatchIntegrationTests.cs @@ -13,6 +13,8 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Services.OpenAI; using AdvancedPaste.UnitTests.Mocks; using ManagedCommon; @@ -28,7 +30,7 @@ namespace AdvancedPaste.UnitTests.ServicesTests; /// Tests that write batch AI outputs against a list of inputs. Connects to OpenAI and uses the full AdvancedPaste action catalog for Semantic Kernel. /// If queries produce errors, the error message is written to the output file. If queries produce text-file output, their contents are included as though they were text output. /// To run this test-suite, first: -/// 1. Setup an OpenAI API key using AdvancedPaste Settings. +/// 1. Set up an OpenAI API key using AdvancedPaste Settings. /// 2. Comment out the [Ignore] attribute above. /// 3. Ensure the %USERPROFILE% folder contains the required input files (paths are below). /// These tests are idempotent and resumable, allowing for partial runs and restarts. It's ok to use existing output files as input files - output-related fields will simply be ignored. @@ -79,7 +81,9 @@ public sealed class AIServiceBatchIntegrationTests Assert.IsTrue(results.Count <= inputs.Count); CollectionAssert.AreEqual(results.Select(result => result.ToInput()).ToList(), inputs.Take(results.Count).ToList()); + #pragma warning disable IL2026, IL3050 // The tests rely on runtime JSON serialization for ad-hoc data files. async Task WriteResultsAsync() => await File.WriteAllTextAsync(resultsFile, JsonSerializer.Serialize(results, SerializerOptions)); + #pragma warning restore IL2026, IL3050 Logger.LogInfo($"Starting {nameof(TestGenerateBatchResults)}; Count={inputs.Count}, InCache={results.Count}"); @@ -101,8 +105,12 @@ public sealed class AIServiceBatchIntegrationTests await WriteResultsAsync(); } - private static async Task<List<T>> GetDataListAsync<T>(string filePath) => - File.Exists(filePath) ? JsonSerializer.Deserialize<List<T>>(await File.ReadAllTextAsync(filePath)) : []; + private static async Task<List<T>> GetDataListAsync<T>(string filePath) + { + #pragma warning disable IL2026, IL3050 // Tests only run locally and can depend on runtime JSON serialization. + return File.Exists(filePath) ? JsonSerializer.Deserialize<List<T>>(await File.ReadAllTextAsync(filePath)) : []; + #pragma warning restore IL2026, IL3050 + } private static async Task<string> GetTextOutputAsync(BatchTestInput input, PasteFormats format) { @@ -130,23 +138,35 @@ public sealed class AIServiceBatchIntegrationTests private static async Task<DataPackage> GetOutputDataPackageAsync(BatchTestInput batchTestInput, PasteFormats format) { - VaultCredentialsProvider credentialsProvider = new(); - PromptModerationService promptModerationService = new(credentialsProvider); + var services = CreateServices(); NoOpProgress progress = new(); - CustomTextTransformService customTextTransformService = new(credentialsProvider, promptModerationService); switch (format) { case PasteFormats.CustomTextTransformation: - return DataPackageHelpers.CreateFromText(await customTextTransformService.TransformTextAsync(batchTestInput.Prompt, batchTestInput.Clipboard, CancellationToken.None, progress)); + var transformResult = await services.CustomActionTransformService.TransformAsync(batchTestInput.Prompt, batchTestInput.Clipboard, null, CancellationToken.None, progress); + return DataPackageHelpers.CreateFromText(transformResult.Content ?? string.Empty); case PasteFormats.KernelQuery: var clipboardData = DataPackageHelpers.CreateFromText(batchTestInput.Clipboard).GetView(); - KernelService kernelService = new(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, customTextTransformService); - return await kernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress); + return await services.KernelService.TransformClipboardAsync(batchTestInput.Prompt, clipboardData, isSavedQuery: false, CancellationToken.None, progress); default: throw new InvalidOperationException($"Unexpected format {format}"); } } + + private static IntegrationTestServices CreateServices() + { + IntegrationTestUserSettings userSettings = new(); + EnhancedVaultCredentialsProvider credentialsProvider = new(userSettings); + PromptModerationService promptModerationService = new(credentialsProvider); + PasteAIProviderFactory providerFactory = new(); + ICustomActionTransformService customActionTransformService = new CustomActionTransformService(promptModerationService, providerFactory, credentialsProvider, userSettings); + IKernelService kernelService = new AdvancedAIKernelService(credentialsProvider, new NoOpKernelQueryCacheService(), promptModerationService, userSettings, customActionTransformService); + + return new IntegrationTestServices(customActionTransformService, kernelService); + } + + private readonly record struct IntegrationTestServices(ICustomActionTransformService CustomActionTransformService, IKernelService KernelService); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs index 998534cf5e..7c16089cd5 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste.UnitTests/ServicesTests/KernelServiceIntegrationTests.cs @@ -11,6 +11,8 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Services.OpenAI; using AdvancedPaste.Telemetry; using AdvancedPaste.UnitTests.Mocks; @@ -27,16 +29,19 @@ namespace AdvancedPaste.UnitTests.ServicesTests; public sealed class KernelServiceIntegrationTests : IDisposable { private const string StandardImageFile = "image_with_text_example.png"; - private KernelService _kernelService; + private IKernelService _kernelService; private AdvancedPasteEventListener _eventListener; [TestInitialize] public void TestInitialize() { - VaultCredentialsProvider credentialsProvider = new(); + IntegrationTestUserSettings userSettings = new(); + EnhancedVaultCredentialsProvider credentialsProvider = new(userSettings); PromptModerationService promptModerationService = new(credentialsProvider); + PasteAIProviderFactory providerFactory = new(); + CustomActionTransformService customActionTransformService = new(promptModerationService, providerFactory, credentialsProvider, userSettings); - _kernelService = new KernelService(new NoOpKernelQueryCacheService(), credentialsProvider, promptModerationService, new CustomTextTransformService(credentialsProvider, promptModerationService)); + _kernelService = new AdvancedAIKernelService(credentialsProvider, new NoOpKernelQueryCacheService(), promptModerationService, userSettings, customActionTransformService); _eventListener = new(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj index fba18de07c..090f3c75a7 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPaste.csproj @@ -1,11 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> <UseWinUI>true</UseWinUI> <ApplicationIcon>Assets\AdvancedPaste\AdvancedPaste.ico</ApplicationIcon> <ApplicationManifest>app.manifest</ApplicationManifest> @@ -33,11 +33,14 @@ </PropertyGroup> <ItemGroup> + <None Remove="AdvancedPasteXAML\Controls\ClipboardHistoryItemPreviewControl.xaml" /> <None Remove="AdvancedPasteXAML\Controls\PromptBox.xaml" /> + <None Remove="AdvancedPasteXAML\Styles\Button.xaml" /> <None Remove="Assets\AdvancedPaste\AIIcon.png" /> <None Remove="Assets\AdvancedPaste\Gradient.png" /> <None Remove="AdvancedPasteXAML\Controls\AnimatedContentControl\AnimatedBorderBrush.xaml" /> <None Remove="AdvancedPasteXAML\Views\MainPage.xaml" /> + <None Remove="Assets\AdvancedPaste\SemanticKernel.svg" /> </ItemGroup> <ItemGroup> @@ -49,7 +52,6 @@ <ItemGroup> <PackageReference Include="OpenAI" /> - <PackageReference Include="Azure.AI.OpenAI" /> <PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.WinUI.Animations" /> <PackageReference Include="CommunityToolkit.WinUI.Converters" /> @@ -57,10 +59,15 @@ <PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" /> <!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. --> <PackageReference Include="MessagePack" /> + <PackageReference Include="Microsoft.SemanticKernel.Connectors.AzureAIInference" /> + <PackageReference Include="Microsoft.SemanticKernel.Connectors.Google" /> + <PackageReference Include="Microsoft.SemanticKernel.Connectors.MistralAI" /> + <PackageReference Include="Microsoft.SemanticKernel.Connectors.Ollama" /> <PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.SemanticKernel" /> <PackageReference Include="Microsoft.WindowsAppSDK" /> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" /> + <PackageReference Include="System.ClientModel" /> <PackageReference Include="Microsoft.Windows.Compatibility" /> <PackageReference Include="Microsoft.Windows.CsWin32" /> <PackageReference Include="Microsoft.Windows.SDK.BuildTools" /> @@ -102,6 +109,7 @@ <!-- HACK: Common.UI is referenced, even if it is not used, to force dll versions to be the same as in other projects that use it. It's still unclear why this is the case, but this is need for flattening the install directory. --> <ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" /> <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\..\common\LanguageModelProvider\LanguageModelProvider.csproj" /> <ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" /> <ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> </ItemGroup> @@ -114,9 +122,38 @@ <PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'"> <HasPackageAndPublishMenu>true</HasPackageAndPublishMenu> </PropertyGroup> + <ItemGroup> + <Page Update="AdvancedPasteXAML\Controls\ClipboardHistoryItemPreviewControl.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> <ItemGroup> <Page Update="AdvancedPasteXAML\Controls\PromptBox.xaml"> <Generator>MSBuild:Compile</Generator> </Page> </ItemGroup> + <ItemGroup> + <Page Update="AdvancedPasteXAML\Styles\Button.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + <ItemGroup> + <!-- Share AI Model Provider Icons from Settings.UI to avoid duplication --> + <!-- These icons are included from Settings.UI project --> + <Content Include="..\..\..\settings-ui\Settings.UI\Assets\Settings\Icons\Models\*.svg"> + <Link>Assets\Settings\Icons\Models\%(Filename)%(Extension)</Link> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <!-- AdvancedPaste specific assets --> + <Content Include="Assets\AdvancedPaste\*.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Include="Assets\AdvancedPaste\*.ico"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <!-- Keep SemanticKernel.svg as it's specific to AdvancedPaste --> + <Content Include="Assets\AdvancedPaste\SemanticKernel.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> </Project> diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml index 8f9c215823..df6ed811ac 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8" ?> <Application x:Class="AdvancedPaste.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" @@ -9,159 +9,10 @@ <ResourceDictionary.MergedDictionaries> <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> <ResourceDictionary Source="ms-appx:///AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml" /> + <ResourceDictionary Source="ms-appx:///AdvancedPasteXAML/Styles/Button.xaml" /> <!-- Other merged dictionaries here --> </ResourceDictionary.MergedDictionaries> <!-- Other app resources here --> - - <ResourceDictionary.ThemeDictionaries> - <ResourceDictionary x:Key="Default"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="Light"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="HighContrast"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SystemColorHighlightTextColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SystemControlBackgroundBaseLowBrush" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SystemColorGrayTextColor" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="SystemColorButtonTextColorBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="SystemControlDisabledBaseMediumLowBrush" /> - </ResourceDictionary> - </ResourceDictionary.ThemeDictionaries> - - <Style x:Key="SubtleButtonStyle" TargetType="Button"> - <Setter Property="Background" Value="{ThemeResource SubtleButtonBackground}" /> - <Setter Property="BackgroundSizing" Value="InnerBorderEdge" /> - <Setter Property="Foreground" Value="{ThemeResource SubtleButtonForeground}" /> - <Setter Property="BorderBrush" Value="{ThemeResource SubtleButtonBorderBrush}" /> - <Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" /> - <Setter Property="Padding" Value="{StaticResource ButtonPadding}" /> - <Setter Property="HorizontalAlignment" Value="Left" /> - <Setter Property="VerticalAlignment" Value="Center" /> - <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> - <Setter Property="FontWeight" Value="Normal" /> - <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> - <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> - <Setter Property="FocusVisualMargin" Value="-3" /> - <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> - <Setter Property="Template"> - <Setter.Value> - <ControlTemplate TargetType="Button"> - <ContentPresenter - x:Name="ContentPresenter" - Padding="{TemplateBinding Padding}" - HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" - VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" - AnimatedIcon.State="Normal" - AutomationProperties.AccessibilityView="Raw" - Background="{TemplateBinding Background}" - BackgroundSizing="{TemplateBinding BackgroundSizing}" - BorderBrush="{TemplateBinding BorderBrush}" - BorderThickness="{TemplateBinding BorderThickness}" - Content="{TemplateBinding Content}" - ContentTemplate="{TemplateBinding ContentTemplate}" - ContentTransitions="{TemplateBinding ContentTransitions}" - CornerRadius="{TemplateBinding CornerRadius}" - Foreground="{TemplateBinding Foreground}"> - <ContentPresenter.BackgroundTransition> - <BrushTransition Duration="0:0:0.083" /> - </ContentPresenter.BackgroundTransition> - <VisualStateManager.VisualStateGroups> - <VisualStateGroup x:Name="CommonStates"> - <VisualState x:Name="Normal" /> - <VisualState x:Name="PointerOver"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="PointerOver" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Pressed"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Pressed" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Disabled"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <!-- DisabledVisual Should be handled by the control, not the animated icon. --> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Normal" /> - </VisualState.Setters> - </VisualState> - </VisualStateGroup> - </VisualStateManager.VisualStateGroups> - </ContentPresenter> - </ControlTemplate> - </Setter.Value> - </Setter> - </Style> </ResourceDictionary> </Application.Resources> </Application> diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs index 3ac3baa9d0..3fa940952e 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs @@ -10,10 +10,10 @@ using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Services; +using AdvancedPaste.Services.CustomActions; using AdvancedPaste.Settings; using AdvancedPaste.ViewModels; using ManagedCommon; @@ -77,11 +77,12 @@ namespace AdvancedPaste { services.AddSingleton<IFileSystem, FileSystem>(); services.AddSingleton<IUserSettings, UserSettings>(); - services.AddSingleton<IAICredentialsProvider, Services.OpenAI.VaultCredentialsProvider>(); + services.AddSingleton<IAICredentialsProvider, EnhancedVaultCredentialsProvider>(); services.AddSingleton<IPromptModerationService, Services.OpenAI.PromptModerationService>(); - services.AddSingleton<ICustomTextTransformService, Services.OpenAI.CustomTextTransformService>(); services.AddSingleton<IKernelQueryCacheService, CustomActionKernelQueryCacheService>(); - services.AddSingleton<IKernelService, Services.OpenAI.KernelService>(); + services.AddSingleton<IPasteAIProviderFactory, PasteAIProviderFactory>(); + services.AddSingleton<ICustomActionTransformService, CustomActionTransformService>(); + services.AddSingleton<IKernelService, AdvancedAIKernelService>(); services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>(); services.AddSingleton<OptionsViewModel>(); }).Build(); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedBorderBrush.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedBorderBrush.xaml index b73ce13a30..65e8f566e7 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedBorderBrush.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedBorderBrush.xaml @@ -1,4 +1,4 @@ -<XamlCompositionBrushBase +<XamlCompositionBrushBase x:Class="AdvancedPaste.Controls.AnimatedBorderBrush" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml index fd42be2b3b..a250fdffdc 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/AnimatedContentControl/AnimatedContentControl.xaml @@ -1,4 +1,4 @@ -<ResourceDictionary +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:animations="using:CommunityToolkit.WinUI.Animations" @@ -11,7 +11,7 @@ <Setter Property="Background" Value="Transparent" /> <Setter Property="BorderBrush" Value="Transparent" /> <Setter Property="BorderThickness" Value="1" /> - <Setter Property="CornerRadius" Value="8" /> + <Setter Property="CornerRadius" Value="16" /> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="local:AnimatedContentControl"> diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml new file mode 100644 index 0000000000..996f0c5b4f --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml @@ -0,0 +1,81 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="AdvancedPaste.Controls.ClipboardHistoryItemPreviewControl" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:converters="using:AdvancedPaste.Converters" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:AdvancedPaste.Controls" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" + mc:Ignorable="d"> + <UserControl.Resources> + <converters:DateTimeToFriendlyStringConverter x:Key="DateTimeToFriendlyStringConverter" /> + <converters:HexColorToBrushConverter x:Key="HexColorToBrushConverter" /> + <tkconverters:BoolToVisibilityConverter x:Name="BoolToVisibilityConverter" /> + </UserControl.Resources> + <Grid ColumnSpacing="12"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="80" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Border Background="{ThemeResource SubtleFillColorSecondaryBrush}" CornerRadius="16,0,0,16"> + <Grid> + <!-- Image preview --> + <Image + Source="{x:Bind ClipboardItem.Image, Mode=OneWay}" + Stretch="UniformToFill" + Visibility="{x:Bind HasImage, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> + <!-- Color preview with text --> + <Grid Visibility="{x:Bind HasColor, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Ellipse + Grid.Column="0" + Width="8" + Height="8" + Margin="8,0,8,0" + Fill="{x:Bind ClipboardItem.Content, Mode=OneWay, Converter={StaticResource HexColorToBrushConverter}}" /> + <TextBlock + Grid.Column="1" + VerticalAlignment="Center" + FontSize="10" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind ClipboardItem.Content, Mode=OneWay}" + TextWrapping="NoWrap" /> + </Grid> + <!-- Text preview --> + <TextBlock + Margin="8,0,0,0" + VerticalAlignment="Center" + FontSize="10" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind ClipboardItem.Content, Mode=OneWay}" + TextWrapping="Wrap" + Visibility="{x:Bind HasText, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> + <!-- Icon glyph fallback --> + <FontIcon + HorizontalAlignment="Center" + VerticalAlignment="Center" + FontSize="48" + Glyph="{x:Bind IconGlyph, Mode=OneWay}" + Visibility="{x:Bind HasGlyph, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> + </Grid> + </Border> + <StackPanel + Grid.Column="1" + VerticalAlignment="Center" + Spacing="2"> + <TextBlock + Style="{StaticResource BodyTextBlockStyle}" + Text="{x:Bind Header, Mode=OneWay}" + TextWrapping="NoWrap" /> + <TextBlock + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{x:Bind Timestamp, Converter={StaticResource DateTimeToFriendlyStringConverter}, Mode=OneWay}" /> + </StackPanel> + </Grid> +</UserControl> diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs new file mode 100644 index 0000000000..765ba0e076 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/ClipboardHistoryItemPreviewControl.xaml.cs @@ -0,0 +1,129 @@ +// 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 AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace AdvancedPaste.Controls +{ + public sealed partial class ClipboardHistoryItemPreviewControl : UserControl + { + public static readonly DependencyProperty ClipboardItemProperty = DependencyProperty.Register( + nameof(ClipboardItem), + typeof(ClipboardItem), + typeof(ClipboardHistoryItemPreviewControl), + new PropertyMetadata(defaultValue: null, OnClipboardItemChanged)); + + public ClipboardItem ClipboardItem + { + get => (ClipboardItem)GetValue(ClipboardItemProperty); + set => SetValue(ClipboardItemProperty, value); + } + + // Computed properties for display + public string Header => ClipboardItem != null ? GetHeaderFromFormat(ClipboardItem.Format) : string.Empty; + + public string IconGlyph => ClipboardItem != null ? GetGlyphFromFormat(ClipboardItem.Format) : string.Empty; + + public string ContentText => ClipboardItem?.Content ?? string.Empty; + + public ImageSource ContentImage => ClipboardItem?.Image; + + public DateTimeOffset? Timestamp => ClipboardItem?.Timestamp ?? ClipboardItem?.Item?.Timestamp; + + public bool HasImage => ContentImage is not null; + + public bool HasText => !string.IsNullOrEmpty(ContentText) && !HasImage && !HasColor; + + public bool HasGlyph => !HasImage && !HasText && !HasColor && !string.IsNullOrEmpty(IconGlyph); + + public bool HasColor => ClipboardItemHelper.IsRgbHexColor(ContentText); + + public ClipboardHistoryItemPreviewControl() + { + InitializeComponent(); + } + + private static void OnClipboardItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is ClipboardHistoryItemPreviewControl control) + { + // Notify bindings that all computed properties may have changed + control.Bindings.Update(); + } + } + + private static string GetHeaderFromFormat(ClipboardFormat format) + { + // Check flags in priority order (most specific first) + if (format.HasFlag(ClipboardFormat.Image)) + { + return GetStringOrFallback("ClipboardPreviewCategoryImage", "Image"); + } + + if (format.HasFlag(ClipboardFormat.Video)) + { + return GetStringOrFallback("ClipboardPreviewCategoryVideo", "Video"); + } + + if (format.HasFlag(ClipboardFormat.Audio)) + { + return GetStringOrFallback("ClipboardPreviewCategoryAudio", "Audio"); + } + + if (format.HasFlag(ClipboardFormat.File)) + { + return GetStringOrFallback("ClipboardPreviewCategoryFile", "File"); + } + + if (format.HasFlag(ClipboardFormat.Text) || format.HasFlag(ClipboardFormat.Html)) + { + return GetStringOrFallback("ClipboardPreviewCategoryText", "Text"); + } + + return GetStringOrFallback("ClipboardPreviewCategoryUnknown", "Clipboard"); + } + + private static string GetGlyphFromFormat(ClipboardFormat format) + { + // Check flags in priority order (most specific first) + if (format.HasFlag(ClipboardFormat.Image)) + { + return "\uEB9F"; // Image icon + } + + if (format.HasFlag(ClipboardFormat.Video)) + { + return "\uE714"; // Video icon + } + + if (format.HasFlag(ClipboardFormat.Audio)) + { + return "\uE189"; // Audio icon + } + + if (format.HasFlag(ClipboardFormat.File)) + { + return "\uE8A5"; // File icon + } + + if (format.HasFlag(ClipboardFormat.Text) || format.HasFlag(ClipboardFormat.Html)) + { + return "\uE8D2"; // Text icon + } + + return "\uE77B"; // Generic clipboard icon + } + + private static string GetStringOrFallback(string resourceKey, string fallback) + { + var value = ResourceLoaderInstance.ResourceLoader.GetString(resourceKey); + return string.IsNullOrEmpty(value) ? fallback : value; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml index dd09c717b0..6303564d9b 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml @@ -7,34 +7,21 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:AdvancedPaste.Controls" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:settings="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" + x:Name="PromptBoxControl" mc:Ignorable="d"> <UserControl.Resources> <ResourceDictionary> - <ResourceDictionary.ThemeDictionaries> - <ResourceDictionary x:Key="Default"> - <Color x:Key="AccentGradientColor">#65C8F2</Color> - <LinearGradientBrush x:Key="AccentGradientBrush" StartPoint="0,0" EndPoint="1,1"> - <GradientStop Offset="0.0" Color="#98EFFE" /> - <GradientStop Offset="0.25" Color="#48B1E9" /> - <GradientStop Offset="1.0" Color="{StaticResource AccentGradientColor}" /> - </LinearGradientBrush> - </ResourceDictionary> - <ResourceDictionary x:Key="Light"> - <Color x:Key="AccentGradientColor">#005FB8</Color> - <LinearGradientBrush x:Key="AccentGradientBrush" StartPoint="0,0" EndPoint="1,1"> - <GradientStop Offset="0.0" Color="#4992C7" /> - <GradientStop Offset="0.25" Color="#1353A0" /> - <GradientStop Offset="1.0" Color="{StaticResource AccentGradientColor}" /> - </LinearGradientBrush> - </ResourceDictionary> - <ResourceDictionary x:Key="HighContrast"> - <Color x:Key="AccentGradientColor">#48B1E9</Color> - <SolidColorBrush x:Key="AccentGradientBrush" Color="{StaticResource AccentGradientColor}" /> - </ResourceDictionary> - </ResourceDictionary.ThemeDictionaries> + <LinearGradientBrush x:Name="IntelligentUnderlineGradient" StartPoint="0,0.5" EndPoint="1,0.5"> + <GradientStop Offset="0.0" Color="#FF0078D4" /> + <GradientStop Offset="0.42" Color="#FF464FEB" /> + <GradientStop Offset="0.87" Color="#FFD660FF" /> + <GradientStop Offset="1.0" Color="#FFFEA874" /> + </LinearGradientBrush> + <x:Double x:Key="ModelSelectorButtonWidth">44</x:Double> <Style x:Key="CustomTextBoxStyle" TargetType="TextBox"> <Setter Property="Foreground" Value="{ThemeResource TextControlForeground}" /> <Setter Property="Background" Value="{ThemeResource TextControlBackground}" /> @@ -155,6 +142,7 @@ Foreground="{ThemeResource TextControlHeaderForeground}" TextWrapping="Wrap" Visibility="Collapsed" /> + <Border x:Name="BorderElement" Grid.Row="1" @@ -168,48 +156,32 @@ BorderThickness="{TemplateBinding BorderThickness}" Control.IsTemplateFocusTarget="True" CornerRadius="{TemplateBinding CornerRadius}" /> - <Viewbox + <Rectangle + x:Name="FocusHighlighter" Grid.Row="1" - Width="16" - Height="16" - Margin="8,0,0,0"> - <StackPanel - Margin="0" - Padding="0" - HorizontalAlignment="Stretch" - VerticalAlignment="Stretch"> - <ProgressRing - Width="30" - Height="30" - HorizontalAlignment="Right" - VerticalAlignment="Center" - IsActive="{Binding DataContext.IsBusy, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" - IsIndeterminate="{Binding DataContext.HasIndeterminateTransformProgress, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" - Maximum="100" - Minimum="0" - Visibility="{Binding DataContext.IsBusy, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}, Converter={StaticResource BoolToVisibilityConverter}}" - Value="{Binding DataContext.TransformProgress, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" /> - - <StackPanel - Margin="0" - Padding="0" - HorizontalAlignment="Stretch" - VerticalAlignment="Stretch" - Visibility="{Binding DataContext.IsBusy, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}, Converter={StaticResource BoolToInvertedVisibilityConverter}}"> - <Image - x:Name="AIGlyphImage" - AutomationProperties.AccessibilityView="Raw" - Source="/Assets/AdvancedPaste/SemanticKernel.svg" - Visibility="{Binding DataContext.IsAdvancedAIEnabled, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}, Converter={StaticResource BoolToVisibilityConverter}}" /> - <PathIcon - x:Name="AIGlyph" - AutomationProperties.AccessibilityView="Raw" - Data="M128 766q0-42 24-77t65-48l178-57q32-11 61-30t52-42q50-50 71-114l58-179q13-40 48-65t78-26q42 0 77 24t50 65l58 177q21 66 72 117 49 50 117 72l176 58q43 14 69 48t26 80q0 41-25 76t-64 49l-178 58q-66 21-117 72-32 32-51 73t-33 84-26 83-30 73-45 51-71 20q-42 0-77-24t-49-65l-58-178q-8-25-19-47t-28-43q-34-43-77-68t-89-41-89-27-78-29-55-45-21-75zm1149 7q-76-29-145-53t-129-60-104-88-73-138l-57-176-67 176q-18 48-42 89t-60 78q-34 34-76 61t-89 43l-177 57q75 29 144 53t127 60 103 89 73 137l57 176 67-176q37-97 103-168t168-103l177-57zm-125 759q0-31 20-57t49-36l99-32q34-11 53-34t30-51 20-59 20-54 33-41 58-16q32 0 59 19t38 50q6 20 11 40t13 40 17 38 25 34q16 17 39 26t48 18 49 16 44 20 31 32 12 50q0 33-18 60t-51 38q-19 6-39 11t-41 13-39 17-34 25q-24 25-35 62t-24 73-35 61-68 25q-32 0-59-19t-38-50q-6-18-11-39t-13-41-17-40-24-33q-18-17-41-27t-47-17-49-15-43-20-30-33-12-54zm583 4q-43-13-74-30t-55-41-40-55-32-74q-12 41-29 72t-42 55-55 42-71 31q81 23 128 71t71 129q15-43 31-74t40-54 53-40 75-32z" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Visibility="{Binding DataContext.IsAdvancedAIEnabled, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource BoolToInvertedVisibilityConverter}}" /> - </StackPanel> - </StackPanel> - </Viewbox> + Grid.RowSpan="1" + Grid.ColumnSpan="4" + Height="2" + Margin="12,0,12,0" + HorizontalAlignment="Stretch" + VerticalAlignment="Bottom" + Fill="{StaticResource IntelligentUnderlineGradient}" + RadiusX="1" + RadiusY="1" + Visibility="Collapsed" /> + <Grid Grid.Row="1" Width="{StaticResource ModelSelectorButtonWidth}"> + <ProgressRing + Width="20" + Height="20" + HorizontalAlignment="Center" + VerticalAlignment="Center" + IsActive="{Binding DataContext.IsBusy, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" + IsIndeterminate="{Binding DataContext.HasIndeterminateTransformProgress, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Maximum="100" + Minimum="0" + Visibility="{Binding DataContext.IsBusy, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}, Converter={StaticResource BoolToVisibilityConverter}}" + Value="{Binding DataContext.TransformProgress, Mode=OneWay, RelativeSource={RelativeSource Mode=TemplatedParent}}" /> + </Grid> <ScrollViewer x:Name="ContentElement" Grid.Row="1" @@ -279,12 +251,6 @@ <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentElement" Storyboard.TargetProperty="Foreground"> <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextControlForegroundDisabled}" /> </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="AIGlyph" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextControlForegroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="AIGlyphImage" Storyboard.TargetProperty="Opacity"> - <DiscreteObjectKeyFrame KeyTime="0" Value="0.4" /> - </ObjectAnimationUsingKeyFrames> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="PlaceholderTextContentPresenter" Storyboard.TargetProperty="Foreground"> <DiscreteObjectKeyFrame KeyTime="0" Value="{Binding PlaceholderForeground, RelativeSource={RelativeSource TemplatedParent}, TargetNullValue={ThemeResource TextControlPlaceholderForegroundDisabled}}" /> </ObjectAnimationUsingKeyFrames> @@ -314,7 +280,10 @@ <ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderElement" Storyboard.TargetProperty="Background"> <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextControlBackgroundFocused}" /> </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderElement" Storyboard.TargetProperty="BorderBrush"> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="FocusHighlighter" Storyboard.TargetProperty="Visibility"> + <DiscreteObjectKeyFrame KeyTime="0" Value="Visible" /> + </ObjectAnimationUsingKeyFrames> + <!--<ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderElement" Storyboard.TargetProperty="BorderBrush"> <DiscreteObjectKeyFrame KeyTime="0"> <DiscreteObjectKeyFrame.Value> <LinearGradientBrush MappingMode="Absolute" StartPoint="0,0" EndPoint="0,2"> @@ -331,7 +300,7 @@ </ObjectAnimationUsingKeyFrames> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderElement" Storyboard.TargetProperty="BorderThickness"> <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextControlBorderThemeThicknessFocused}" /> - </ObjectAnimationUsingKeyFrames> + </ObjectAnimationUsingKeyFrames>--> <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentElement" Storyboard.TargetProperty="Foreground"> <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource TextControlForegroundFocused}" /> </ObjectAnimationUsingKeyFrames> @@ -364,6 +333,8 @@ FalseValue="Visible" TrueValue="Collapsed" /> <converters:CountToVisibilityConverter x:Key="CountToVisibilityConverter" /> + <converters:CountToInvertedVisibilityConverter x:Key="CountToInvertedVisibilityConverter" /> + <converters:ServiceTypeToIconConverter x:Key="ServiceTypeToIconConverter" /> </ResourceDictionary> </UserControl.Resources> <Grid x:Name="PromptBoxGrid" Loaded="Grid_Loaded"> @@ -374,18 +345,19 @@ <local:AnimatedContentControl x:Name="Loader" MinHeight="48" - CornerRadius="8"> + CornerRadius="16"> <Grid> <TextBox x:Name="InputTxtBox" HorizontalAlignment="Stretch" x:FieldModifier="public" + CornerRadius="16" DataContext="{x:Bind ViewModel}" IsEnabled="{x:Bind ViewModel.ClipboardHasData, Mode=OneWay}" KeyDown="InputTxtBox_KeyDown" PlaceholderText="{x:Bind ViewModel.InputTxtBoxPlaceholderText, Mode=OneWay}" Style="{StaticResource CustomTextBoxStyle}" - TabIndex="0" + TabIndex="1" Text="{x:Bind ViewModel.Query, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"> <ToolTipService.ToolTip> <TextBlock x:Uid="InputTxtBoxTooltip" /> @@ -545,6 +517,139 @@ </Flyout> </FlyoutBase.AttachedFlyout> </TextBox> + <DropDownButton + x:Name="AIProviderButton" + x:Uid="AIProviderButton" + MinWidth="{StaticResource ModelSelectorButtonWidth}" + Margin="1,1,0,2" + Padding="0,0,4,0" + VerticalAlignment="Stretch" + BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderThickness="0,0,1,0" + CornerRadius="16,0,0,16" + Style="{StaticResource SubtleDropDownButtonStyle}" + TabIndex="0" + Visibility="{x:Bind ViewModel.IsBusy, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}"> + <ToolTipService.ToolTip> + <TextBlock Text="{x:Bind ViewModel.ActiveAIProviderTooltip, Mode=OneWay}" TextWrapping="WrapWholeWords" /> + </ToolTipService.ToolTip> + <DropDownButton.Content> + <Image + x:Name="AIProviderIcon" + Width="16" + Height="16" + AutomationProperties.AccessibilityView="Raw" + Source="{x:Bind ViewModel.ActiveAIProvider?.ServiceType, Mode=OneWay, Converter={StaticResource ServiceTypeToIconConverter}}" /> + </DropDownButton.Content> + <DropDownButton.Flyout> + <Flyout + Opened="AIProviderFlyout_Opened" + Placement="Bottom" + ShouldConstrainToRootBounds="False"> + <Grid + Width="386" + Margin="-4" + RowSpacing="12"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <TextBlock + x:Uid="AIProvidersFlyoutHeader" + Grid.Row="0" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + <ListView + x:Name="AIProviderListView" + Grid.Row="1" + MaxHeight="320" + Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="{StaticResource OverlayCornerRadius}" + ItemsSource="{x:Bind ViewModel.AIProviders, Mode=OneWay}" + ScrollViewer.VerticalScrollBarVisibility="Auto" + ScrollViewer.VerticalScrollMode="Auto" + SelectedItem="{x:Bind ViewModel.ActiveAIProvider, Mode=OneWay}" + SelectionChanged="AIProviderListView_SelectionChanged" + SelectionMode="Single" + Visibility="{x:Bind ViewModel.AIProviders.Count, Mode=OneWay, Converter={StaticResource CountToVisibilityConverter}}"> + <ListView.ItemsPanel> + <ItemsPanelTemplate> + <StackPanel Orientation="Vertical" Spacing="2" /> + </ItemsPanelTemplate> + </ListView.ItemsPanel> + <ListView.ItemTemplate> + <DataTemplate x:DataType="settings:PasteAIProviderDefinition"> + <Grid Padding="0,8,0,8" ColumnSpacing="12"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <Image + Width="20" + Height="20" + AutomationProperties.AccessibilityView="Raw" + Source="{x:Bind ServiceType, Mode=OneWay, Converter={StaticResource ServiceTypeToIconConverter}}" /> + <StackPanel + Grid.Column="1" + VerticalAlignment="Center" + Spacing="2"> + <TextBlock Text="{x:Bind DisplayName, Mode=OneWay}" TextTrimming="CharacterEllipsis" /> + <TextBlock + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{x:Bind ServiceType, Mode=OneWay}" /> + </StackPanel> + <Border + Grid.Column="2" + Padding="2,0,2,0" + VerticalAlignment="Center" + BorderBrush="{ThemeResource ControlStrokeColorSecondary}" + BorderThickness="1" + CornerRadius="{StaticResource ControlCornerRadius}" + Visibility="{x:Bind IsLocalModel, Mode=OneWay}"> + <TextBlock + x:Uid="LocalModelBadge" + AutomationProperties.AccessibilityView="Raw" + FontSize="10" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </Border> + <!--<Border + Grid.Column="2" + Padding="2,0,2,0" + VerticalAlignment="Center" + BorderBrush="{ThemeResource TertiaryButtonBorderBrush}" + BorderThickness="1" + CornerRadius="{StaticResource ControlCornerRadius}" + Visibility="{x:Bind EnableAdvanceAI}"> + <TextBlock + AutomationProperties.AccessibilityView="Raw" + FontSize="10" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="ADVANCED" /> + </Border>--> + </Grid> + </DataTemplate> + </ListView.ItemTemplate> + </ListView> + <TextBlock + x:Uid="AIProvidersEmptyText" + Grid.Row="1" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + TextWrapping="WrapWholeWords" + Visibility="{x:Bind ViewModel.AIProviders.Count, Mode=OneWay, Converter={StaticResource CountToInvertedVisibilityConverter}}" /> + <HyperlinkButton + x:Uid="AIProvidersManageButtonContent" + Grid.Row="2" + HorizontalAlignment="Right" + Command="{x:Bind ViewModel.OpenSettingsCommand, Mode=OneWay}" /> + </Grid> + </Flyout> + </DropDownButton.Flyout> + </DropDownButton> <Grid Width="32" Height="32" @@ -562,10 +667,9 @@ Command="{x:Bind GenerateCustomAICommand}" Content="{ui:FontIcon Glyph=, FontSize=16}" - Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}" IsEnabled="{x:Bind ViewModel.IsCustomAIAvailable, Mode=OneWay}" Style="{StaticResource SubtleButtonStyle}" - TabIndex="1" + TabIndex="2" Visibility="{x:Bind ViewModel.Query.Length, Mode=OneWay, Converter={StaticResource CountToVisibilityConverter}}"> <ToolTipService.ToolTip> <TextBlock x:Uid="SendBtnToolTip" TextWrapping="WrapWholeWords" /> @@ -640,8 +744,8 @@ x:Name="LoadingText" x:Uid="LoadingText" Grid.Row="1" - FontWeight="SemiBold" - Foreground="{ThemeResource AccentGradientBrush}" + Margin="4,4,0,0" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" Style="{StaticResource CaptionTextBlockStyle}" Visibility="Collapsed"> <animations:Implicit.ShowAnimations> diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml.cs index 19c0fd8ce6..cc19a78f81 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml.cs @@ -10,9 +10,11 @@ using AdvancedPaste.Models; using AdvancedPaste.ViewModels; using CommunityToolkit.Mvvm.Input; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; namespace AdvancedPaste.Controls { @@ -20,6 +22,8 @@ namespace AdvancedPaste.Controls { public OptionsViewModel ViewModel { get; private set; } + private bool _syncingProviderSelection; + public static readonly DependencyProperty PlaceholderTextProperty = DependencyProperty.Register( nameof(PlaceholderText), typeof(string), @@ -44,6 +48,18 @@ namespace AdvancedPaste.Controls set => SetValue(FooterProperty, value); } + public static readonly DependencyProperty ModelSelectorProperty = DependencyProperty.Register( + nameof(ModelSelector), + typeof(object), + typeof(PromptBox), + new PropertyMetadata(defaultValue: null)); + + public object ModelSelector + { + get => GetValue(ModelSelectorProperty); + set => SetValue(ModelSelectorProperty, value); + } + public PromptBox() { InitializeComponent(); @@ -60,6 +76,11 @@ namespace AdvancedPaste.Controls var state = ViewModel.IsBusy ? "LoadingState" : ViewModel.PasteActionError.HasText ? "ErrorState" : "DefaultState"; VisualStateManager.GoToState(this, state, true); } + + if (e.PropertyName is nameof(ViewModel.ActiveAIProvider) or nameof(ViewModel.AIProviders)) + { + SyncProviderSelection(); + } } private void ViewModel_PreviewRequested(object sender, EventArgs e) @@ -73,6 +94,7 @@ namespace AdvancedPaste.Controls private void Grid_Loaded(object sender, RoutedEventArgs e) { InputTxtBox.Focus(FocusState.Programmatic); + SyncProviderSelection(); } [RelayCommand] @@ -111,5 +133,57 @@ namespace AdvancedPaste.Controls { Loader.IsLoading = loading; } + + private void SyncProviderSelection() + { + if (AIProviderListView is null) + { + return; + } + + try + { + _syncingProviderSelection = true; + AIProviderListView.SelectedItem = ViewModel.ActiveAIProvider; + } + finally + { + _syncingProviderSelection = false; + } + } + + private void AIProviderFlyout_Opened(object sender, object e) + { + SyncProviderSelection(); + } + + private async void AIProviderListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_syncingProviderSelection) + { + return; + } + + var flyout = FlyoutBase.GetAttachedFlyout(AIProviderButton); + + if (AIProviderListView.SelectedItem is not PasteAIProviderDefinition provider) + { + return; + } + + if (string.Equals(ViewModel.ActiveAIProvider?.Id, provider.Id, StringComparison.OrdinalIgnoreCase)) + { + flyout?.Hide(); + return; + } + + if (ViewModel.SetActiveProviderCommand.CanExecute(provider)) + { + await ViewModel.SetActiveProviderCommand.ExecuteAsync(provider); + SyncProviderSelection(); + } + + flyout?.Hide(); + } } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/CountToInvertedVisibilityConverter.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/CountToInvertedVisibilityConverter.cs new file mode 100644 index 0000000000..46efa91ee1 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/CountToInvertedVisibilityConverter.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 System.Collections; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace AdvancedPaste.Converters; + +public sealed partial class CountToInvertedVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + bool hasItems = ((value is int intValue) && intValue > 0) || (value is IEnumerable enumerable && enumerable.GetEnumerator().MoveNext()); + return targetType == typeof(Visibility) + ? (hasItems ? Visibility.Collapsed : Visibility.Visible) + : !hasItems; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/DateTimeToFriendlyStringConverter.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/DateTimeToFriendlyStringConverter.cs new file mode 100644 index 0000000000..0bd3226485 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/DateTimeToFriendlyStringConverter.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.UI.Xaml.Data; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace AdvancedPaste.Converters +{ + public sealed partial class DateTimeToFriendlyStringConverter : IValueConverter + { + private static readonly ResourceLoader _resources = new ResourceLoader(); + + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not DateTimeOffset dto) + { + return string.Empty; + } + + // Use local times to calculate relative values and formatting + var now = DateTimeOffset.Now; + var localValue = dto.ToLocalTime(); + var culture = !string.IsNullOrEmpty(language) + ? new CultureInfo(language) + : CultureInfo.CurrentCulture; + + var delta = now - localValue; + + // Future dates: fall back to date/time formatting + if (delta < TimeSpan.Zero) + { + return FormatDateAndTime(localValue, culture); + } + + // < 1 minute + if (delta.TotalSeconds < 60) + { + return _resources.GetString("Relative_JustNow"); // "Just now" + } + + // < 60 minutes + if (delta.TotalMinutes < 60) + { + var mins = (int)Math.Round(delta.TotalMinutes); + if (mins <= 1) + { + return _resources.GetString("Relative_MinuteAgo"); // "1 minute ago" + } + + var fmt = _resources.GetString("Relative_MinutesAgo_Format"); // "{0} minutes ago" + return string.Format(culture, fmt, mins); + } + + // Same calendar day → "Today, {time}" + var today = now.Date; + if (localValue.Date == today) + { + var time = localValue.ToString("t", culture); // localized short time + var fmt = _resources.GetString("Relative_Today_TimeFormat"); // "Today, {0}" + return string.Format(culture, fmt, time); + } + + // Yesterday → "Yesterday, {time}" + if (localValue.Date == today.AddDays(-1)) + { + var time = localValue.ToString("t", culture); + var fmt = _resources.GetString("Relative_Yesterday_TimeFormat"); // "Yesterday, {0}" + return string.Format(culture, fmt, time); + } + + // Within last 7 days → "{Weekday}, {time}" + if (delta.TotalDays < 7) + { + var weekday = localValue.ToString("dddd", culture); // localized weekday + var time = localValue.ToString("t", culture); + var fmt = _resources.GetString("Relative_Weekday_TimeFormat"); // "{0}, {1}" + return string.Format(culture, fmt, weekday, time); + } + + // Older → localized date + time + return FormatDateAndTime(localValue, culture); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); + + private static string FormatDateAndTime(DateTimeOffset localValue, CultureInfo culture) + { + // Use localized short date + short time + var date = localValue.ToString("d", culture); + var time = localValue.ToString("t", culture); + var fmt = _resources.GetString("Relative_Date_TimeFormat"); // "{0}, {1}" + return string.Format(culture, fmt, date, time); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/HexColorConverterHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/HexColorConverterHelper.cs new file mode 100644 index 0000000000..388b4d3340 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/HexColorConverterHelper.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace AdvancedPaste.Converters +{ + public static class HexColorConverterHelper + { + public static Windows.UI.Color? ConvertHexColorToRgb(string hexColor) + { + try + { + // Remove # if present + var cleanHex = hexColor.TrimStart('#'); + + // Expand 3-digit hex to 6-digit (#ABC -> #AABBCC) + if (cleanHex.Length == 3) + { + cleanHex = $"{cleanHex[0]}{cleanHex[0]}{cleanHex[1]}{cleanHex[1]}{cleanHex[2]}{cleanHex[2]}"; + } + + if (cleanHex.Length == 6) + { + var r = System.Convert.ToByte(cleanHex.Substring(0, 2), 16); + var g = System.Convert.ToByte(cleanHex.Substring(2, 2), 16); + var b = System.Convert.ToByte(cleanHex.Substring(4, 2), 16); + + return Windows.UI.Color.FromArgb(255, r, g, b); + } + } + catch + { + // Invalid color format - return null + } + + return null; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/HexColorToBrushConverter.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/HexColorToBrushConverter.cs new file mode 100644 index 0000000000..436217462a --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/HexColorToBrushConverter.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace AdvancedPaste.Converters +{ + public sealed partial class HexColorToBrushConverter : IValueConverter + { + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); + + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string hexColor || string.IsNullOrWhiteSpace(hexColor)) + { + return null; + } + + Windows.UI.Color? color = HexColorConverterHelper.ConvertHexColorToRgb(hexColor); + + return color != null ? new SolidColorBrush((Windows.UI.Color)color) : null; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/ServiceTypeToIconConverter.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/ServiceTypeToIconConverter.cs new file mode 100644 index 0000000000..fa50822c29 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/ServiceTypeToIconConverter.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; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace AdvancedPaste.Converters; + +public sealed partial class ServiceTypeToIconConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + string iconPath = value switch + { + string service when !string.IsNullOrWhiteSpace(service) => AIServiceTypeRegistry.GetIconPath(service), + AIServiceType serviceType => AIServiceTypeRegistry.GetIconPath(serviceType), + _ => null, + }; + + if (string.IsNullOrEmpty(iconPath)) + { + iconPath = AIServiceTypeRegistry.GetIconPath(AIServiceType.Unknown); + } + + try + { + return new SvgImageSource(new Uri(iconPath)); + } + catch (Exception ex) + { + Logger.LogDebug("Failed to create SvgImageSource for AI service icon", ex.Message); + return null; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + => throw new NotSupportedException(); +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml index 33a0dec49f..f5303805bc 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml @@ -7,9 +7,9 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pages="using:AdvancedPaste.Pages" xmlns:winuiex="using:WinUIEx" - Width="420" + Width="486" Height="188" - MinWidth="420" + MinWidth="486" MinHeight="188" Closed="WindowEx_Closed" IsAlwaysOnTop="True" diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml index a37e53f49e..bddfab733d 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml @@ -1,4 +1,4 @@ -<Page +<Page x:Class="AdvancedPaste.Pages.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" @@ -35,7 +35,7 @@ AutomationProperties.HelpText="{x:Bind Name, Mode=OneWay}" AutomationProperties.Name="{x:Bind AccessibleName, Mode=OneWay}"> <Grid.ColumnDefinitions> - <ColumnDefinition Width="26" /> + <ColumnDefinition Width="48" /> <ColumnDefinition Width="*" /> <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> @@ -52,6 +52,7 @@ Grid.Column="1" VerticalAlignment="Center" x:Phase="1" + Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind Name, Mode=OneWay}" /> <TextBlock Grid.Column="2" @@ -74,7 +75,7 @@ AutomationProperties.AccessibilityView="Raw" Opacity="0.5"> <Grid.ColumnDefinitions> - <ColumnDefinition Width="26" /> + <ColumnDefinition Width="48" /> <ColumnDefinition Width="*" /> <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> @@ -87,6 +88,7 @@ Grid.Column="1" VerticalAlignment="Center" x:Phase="1" + Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind Name, Mode=OneWay}" /> </Grid> </DataTemplate> @@ -142,221 +144,204 @@ </Page.KeyboardAccelerators> <Grid> <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - <controls:PromptBox - x:Name="CustomFormatTextBox" - x:Uid="CustomFormatTextBox" - Margin="8,4,8,0" - x:FieldModifier="public" - TabIndex="0"> - <controls:PromptBox.Footer> - <StackPanel Orientation="Horizontal"> - <TextBlock - Margin="0,0,2,0" - HorizontalAlignment="Left" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}"> - <Run x:Uid="AIMistakeNote" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> - </TextBlock> - <TextBlock - Margin="4,0,2,0" - HorizontalAlignment="Left" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}"> - <Hyperlink - x:Name="TermsHyperlink" - NavigateUri="https://openai.com/policies/terms-of-use" - TabIndex="3"> - <Run x:Uid="TermsLink" /> - </Hyperlink> - <ToolTipService.ToolTip> - <TextBlock Text="https://openai.com/policies/terms-of-use" /> - </ToolTipService.ToolTip> - </TextBlock> - <TextBlock - Margin="0,0,2,0" - HorizontalAlignment="Left" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}" - ToolTipService.ToolTip=""> - <Run x:Uid="AIFooterSeparator" Foreground="{ThemeResource TextFillColorSecondaryBrush}">|</Run> - </TextBlock> - <TextBlock - Margin="0,0,2,0" - HorizontalAlignment="Left" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}"> - <Hyperlink NavigateUri="https://openai.com/policies/privacy-policy" TabIndex="3"> - <Run x:Uid="PrivacyLink" /> - </Hyperlink> - <ToolTipService.ToolTip> - <TextBlock Text="https://openai.com/policies/privacy-policy" /> - </ToolTipService.ToolTip> - </TextBlock> - </StackPanel> - </controls:PromptBox.Footer> - </controls:PromptBox> <Grid - Grid.Row="2" - Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}" + Margin="8,0,8,16" + Padding="4" + Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="0,1,0,0" - RowSpacing="4"> - <Grid.RowDefinitions> - <RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - <ListView - x:Name="PasteOptionsListView" - Grid.Row="0" - VerticalAlignment="Bottom" - IsItemClickEnabled="True" - ItemClick="PasteFormat_ItemClick" - ItemContainerTransitions="{x:Null}" - ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}" - ItemsSource="{x:Bind ViewModel.StandardPasteFormats, Mode=OneWay}" - ScrollViewer.VerticalScrollBarVisibility="Visible" - ScrollViewer.VerticalScrollMode="Auto" - SelectionMode="None" - TabIndex="1" /> - - <Rectangle - Grid.Row="1" - Height="1" - HorizontalAlignment="Stretch" - Fill="{ThemeResource DividerStrokeColorDefaultBrush}" - Visibility="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" /> - - <ListView - x:Name="CustomActionsListView" - Grid.Row="2" - VerticalAlignment="Top" - IsItemClickEnabled="True" - ItemClick="PasteFormat_ItemClick" - ItemContainerTransitions="{x:Null}" - ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}" - ItemsSource="{x:Bind ViewModel.CustomActionPasteFormats, Mode=OneWay}" - ScrollViewer.VerticalScrollBarVisibility="Visible" - ScrollViewer.VerticalScrollMode="Auto" - SelectionMode="None" - TabIndex="2" /> - - <Rectangle - Grid.Row="3" - Height="1" - HorizontalAlignment="Stretch" - Fill="{ThemeResource DividerStrokeColorDefaultBrush}" /> + BorderThickness="1" + CornerRadius="20" + Visibility="{x:Bind ViewModel.ShowClipboardPreview, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <controls:ClipboardHistoryItemPreviewControl Height="48" ClipboardItem="{x:Bind ViewModel.CurrentClipboardItem, Mode=OneWay}" /> <Button - Grid.Row="4" - Height="32" - Margin="4,0,4,4" - Padding="{StaticResource ButtonPadding}" - HorizontalAlignment="Stretch" - HorizontalContentAlignment="Stretch" - VerticalContentAlignment="Stretch" - AutomationProperties.LabeledBy="{x:Bind ClipboardHistoryButton}" + x:Uid="ClipboardHistoryButton" + Grid.Column="1" + Margin="0,0,4,0" + VerticalAlignment="Center" IsEnabled="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=TwoWay}" - Style="{StaticResource SubtleButtonStyle}"> - <Grid - HorizontalAlignment="Stretch" - VerticalAlignment="Stretch" - ColumnSpacing="10"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - <FontIcon - Margin="0,0,0,0" - VerticalAlignment="Center" - AutomationProperties.AccessibilityView="Raw" - FontSize="16" - Glyph="" /> - <TextBlock - x:Name="ClipboardHistoryButton" - x:Uid="ClipboardHistoryButton" - Grid.Column="1" - VerticalAlignment="Center" /> - <FontIcon - Grid.Column="2" - AutomationProperties.AccessibilityView="Raw" - FontSize="12" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Glyph="" /> - </Grid> + Style="{StaticResource SubtleButtonStyle}" + Visibility="{x:Bind ViewModel.ShowClipboardHistoryButton, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> + <ToolTipService.ToolTip> + <TextBlock x:Uid="ClipboardHistoryButtonToolTip" /> + </ToolTipService.ToolTip> + <FontIcon + Margin="0,0,0,0" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + FontSize="16" + Glyph="" /> <Button.Flyout> <Flyout FlyoutPresenterStyle="{StaticResource PaddingLessFlyoutPresenterStyle}" Placement="Right" ShouldConstrainToRootBounds="False"> - - <ListView + <ItemsView Width="320" - IsItemClickEnabled="True" - ItemClick="ClipboardHistory_ItemClick" + Margin="8,8,8,0" + IsItemInvokedEnabled="True" + ItemInvoked="ClipboardHistory_ItemInvoked" ItemsSource="{x:Bind clipboardHistory, Mode=OneWay}" SelectionMode="None"> - <ListView.Transitions /> - <ListView.ItemTemplate> + <ItemsView.Layout> + <StackLayout Orientation="Vertical" Spacing="8" /> + </ItemsView.Layout> + <ItemsView.Transitions /> + <ItemsView.ItemTemplate> <DataTemplate x:DataType="local:ClipboardItem"> - <Grid - Height="40" - HorizontalAlignment="Stretch" + <ItemContainer AutomationProperties.Name="{x:Bind Description, Mode=OneWay}" - ColumnSpacing="8" + CornerRadius="16" ToolTipService.ToolTip="{x:Bind Content}"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="*" MaxWidth="240" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - <Image - Grid.Column="0" - HorizontalAlignment="Left" - x:Phase="2" - Source="{x:Bind Image}" - Visibility="Visible" /> - <TextBlock - Grid.Column="0" - HorizontalAlignment="Left" - VerticalAlignment="Center" - x:Phase="1" - Text="{x:Bind Content}" - TextTrimming="CharacterEllipsis" - Visibility="Visible" /> - <Button - x:Name="ClipboardHistoryItemMoreOptionsButton" - x:Uid="ClipboardHistoryItemMoreOptionsButton" - Grid.Column="1" - VerticalAlignment="Center" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Style="{StaticResource SubtleButtonStyle}"> - <Button.Content> - <FontIcon FontSize="16" Glyph="" /> - </Button.Content> - <Button.Flyout> - <MenuFlyout> - <MenuFlyoutItem - x:Uid="ClipboardHistoryItemDeleteButton" - Click="ClipboardHistoryItemDeleteButton_Click" - CommandParameter="{x:Bind (local:ClipboardItem)}" - Icon="Delete" /> - </MenuFlyout> - </Button.Flyout> - </Button> - </Grid> + <Grid + Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + ColumnSpacing="8" + CornerRadius="16"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" MaxWidth="240" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <controls:ClipboardHistoryItemPreviewControl + Height="64" + x:Phase="0" + ClipboardItem="{x:Bind}" /> + <Button + x:Name="ClipboardHistoryItemMoreOptionsButton" + x:Uid="ClipboardHistoryItemMoreOptionsButton" + Grid.Column="1" + VerticalAlignment="Center" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource SubtleButtonStyle}"> + <Button.Content> + <FontIcon FontSize="16" Glyph="" /> + </Button.Content> + <Button.Flyout> + <MenuFlyout> + <MenuFlyoutItem + x:Uid="ClipboardHistoryItemDeleteButton" + Click="ClipboardHistoryItemDeleteButton_Click" + CommandParameter="{x:Bind (local:ClipboardItem)}" + Icon="Delete" /> + </MenuFlyout> + </Button.Flyout> + </Button> + </Grid> + </ItemContainer> </DataTemplate> - </ListView.ItemTemplate> - </ListView> + </ItemsView.ItemTemplate> + </ItemsView> </Flyout> </Button.Flyout> </Button> - </Grid> + <controls:PromptBox + x:Name="CustomFormatTextBox" + x:Uid="CustomFormatTextBox" + Grid.Row="1" + Margin="20,0,20,0" + x:FieldModifier="public" + IsEnabled="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay}" + TabIndex="0"> + <controls:PromptBox.Footer> + <StackPanel Orientation="Horizontal"> + <TextBlock + x:Uid="AIMistakeNote" + Margin="0,0,2,0" + HorizontalAlignment="Left" + VerticalAlignment="Center" + FontSize="10" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + <Button + Padding="0" + VerticalAlignment="Center" + Style="{StaticResource SubtleButtonStyle}" + Visibility="{x:Bind ViewModel.HasLegalLinks, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <FontIcon FontSize="12" Glyph="" /> + <Button.Flyout> + <Flyout> + <StackPanel Spacing="8"> + <TextBlock TextWrapping="Wrap"> + <Run x:Uid="AIMistakeNote" /><LineBreak /><Run + x:Uid="CustomEndpointWarning" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </TextBlock> + <StackPanel Orientation="Horizontal" Spacing="8"> + <HyperlinkButton + x:Name="TermsHyperlink" + x:Uid="TermsLink" + Padding="0" + FontSize="12" + NavigateUri="{x:Bind ViewModel.TermsLinkUri, Mode=OneWay}" + Visibility="{x:Bind ViewModel.HasTermsLink, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> + <HyperlinkButton + x:Name="PrivacyHyperLink" + x:Uid="PrivacyLink" + Padding="0" + FontSize="12" + NavigateUri="{x:Bind ViewModel.PrivacyLinkUri, Mode=OneWay}" + Visibility="{x:Bind ViewModel.HasPrivacyLink, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> + </StackPanel> + </StackPanel> + </Flyout> + </Button.Flyout> + </Button> + </StackPanel> + </controls:PromptBox.Footer> + </controls:PromptBox> + <ScrollViewer Grid.Row="2"> + <Grid RowSpacing="4"> + <Grid.RowDefinitions> + <RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <ListView + x:Name="PasteOptionsListView" + Grid.Row="0" + VerticalAlignment="Bottom" + IsItemClickEnabled="True" + ItemClick="PasteFormat_ItemClick" + ItemContainerTransitions="{x:Null}" + ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}" + ItemsSource="{x:Bind ViewModel.StandardPasteFormats, Mode=OneWay}" + ScrollViewer.VerticalScrollBarVisibility="Disabled" + ScrollViewer.VerticalScrollMode="Disabled" + SelectionMode="None" + TabIndex="1" /> + <Rectangle + Grid.Row="1" + Height="1" + HorizontalAlignment="Stretch" + Fill="{ThemeResource DividerStrokeColorDefaultBrush}" + Visibility="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" /> + + <ListView + x:Name="CustomActionsListView" + Grid.Row="2" + VerticalAlignment="Top" + IsItemClickEnabled="True" + ItemClick="PasteFormat_ItemClick" + ItemContainerTransitions="{x:Null}" + ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}" + ItemsSource="{x:Bind ViewModel.CustomActionPasteFormats, Mode=OneWay}" + ScrollViewer.VerticalScrollBarVisibility="Disabled" + ScrollViewer.VerticalScrollMode="Disabled" + SelectionMode="None" + TabIndex="2" /> + </Grid> + </ScrollViewer> </Grid> </Page> diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs index 9c4ac5cc71..5bef6389f0 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs @@ -68,11 +68,22 @@ namespace AdvancedPaste.Pages if (item.Content.Contains(StandardDataFormats.Text)) { string text = await item.Content.GetTextAsync(); - items.Add(new ClipboardItem { Content = text, Item = item }); + items.Add(new ClipboardItem + { + Content = text, + Format = ClipboardFormat.Text, + Timestamp = item.Timestamp, + Item = item, + }); } else if (item.Content.Contains(StandardDataFormats.Bitmap)) { - items.Add(new ClipboardItem { Item = item }); + items.Add(new ClipboardItem + { + Format = ClipboardFormat.Image, + Timestamp = item.Timestamp, + Item = item, + }); } } } @@ -187,21 +198,14 @@ namespace AdvancedPaste.Pages } } - private async void ClipboardHistory_ItemClick(object sender, ItemClickEventArgs e) + private void ClipboardHistory_ItemInvoked(ItemsView sender, ItemsViewItemInvokedEventArgs args) { - var item = e.ClickedItem as ClipboardItem; - if (item is not null) + if (args.InvokedItem is ClipboardItem item && item.Item is not null) { PowerToysTelemetry.Log.WriteEvent(new Telemetry.AdvancedPasteClipboardItemClicked()); - if (!string.IsNullOrEmpty(item.Content)) - { - ClipboardHelper.SetTextContent(item.Content); - } - else if (item.Image is not null) - { - RandomAccessStreamReference image = await item.Item.Content.GetBitmapAsync(); - ClipboardHelper.SetImageContent(image); - } + + // Use SetHistoryItemAsContent to set the clipboard content without creating a new history entry + Clipboard.SetHistoryItemAsContent(item.Item); } } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Styles/Button.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Styles/Button.xaml new file mode 100644 index 0000000000..df0852d662 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Styles/Button.xaml @@ -0,0 +1,135 @@ +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:animatedvisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals"> + + <Style x:Key="SubtleDropDownButtonStyle" TargetType="DropDownButton"> + <Setter Property="Background" Value="{ThemeResource SubtleButtonBackground}" /> + <Setter Property="Foreground" Value="{ThemeResource SubtleButtonForeground}" /> + <Setter Property="BorderBrush" Value="{ThemeResource SubtleButtonBorderBrush}" /> + <Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" /> + <Setter Property="Padding" Value="{StaticResource ButtonPadding}" /> + <Setter Property="HorizontalAlignment" Value="Left" /> + <Setter Property="VerticalAlignment" Value="Center" /> + <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> + <Setter Property="FontWeight" Value="Normal" /> + <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> + <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> + <Setter Property="FocusVisualMargin" Value="-3" /> + <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> + <Setter Property="BackgroundSizing" Value="InnerBorderEdge" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Grid + x:Name="RootGrid" + Padding="{TemplateBinding Padding}" + Background="{TemplateBinding Background}" + BackgroundSizing="{TemplateBinding BackgroundSizing}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{TemplateBinding CornerRadius}"> + <Grid.BackgroundTransition> + <BrushTransition Duration="0:0:0.083" /> + </Grid.BackgroundTransition> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <ContentPresenter + x:Name="ContentPresenter" + HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + AutomationProperties.AccessibilityView="Raw" + Content="{TemplateBinding Content}" + ContentTemplate="{TemplateBinding ContentTemplate}" + ContentTransitions="{TemplateBinding ContentTransitions}" /> + <AnimatedIcon + xmlns:local="using:Microsoft.UI.Xaml.Controls" + x:Name="ChevronIcon" + Grid.Column="1" + Width="12" + Height="12" + Margin="-4,0,0,0" + local:AnimatedIcon.State="Normal" + AutomationProperties.AccessibilityView="Raw" + Foreground="{ThemeResource DropDownButtonForegroundSecondary}"> + <animatedvisuals:AnimatedChevronDownSmallVisualSource /> + <AnimatedIcon.FallbackIconSource> + <FontIconSource + FontFamily="{ThemeResource SymbolThemeFontFamily}" + FontSize="8" + Glyph="" + IsTextScaleFactorEnabled="False" /> + </AnimatedIcon.FallbackIconSource> + </AnimatedIcon> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Normal" /> + <VisualState x:Name="PointerOver"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPointerOver}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPointerOver}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPointerOver}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource DropDownButtonForegroundSecondaryPointerOver}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + <VisualState.Setters> + <Setter Target="ChevronIcon.(AnimatedIcon.State)" Value="PointerOver" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="Pressed"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPressed}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPressed}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPressed}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource DropDownButtonForegroundSecondaryPressed}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + <VisualState.Setters> + <Setter Target="ChevronIcon.(AnimatedIcon.State)" Value="Pressed" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="Disabled"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundDisabled}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushDisabled}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundDisabled}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundDisabled}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + <VisualState.Setters> + <!-- DisabledVisual Should be handled by the control, not the animated icon. --> + <Setter Target="ChevronIcon.(AnimatedIcon.State)" Value="Normal" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> +</ResourceDictionary> diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Themes/Generic.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Themes/Generic.xaml index bc1d711b26..d10ea73d98 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Themes/Generic.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Themes/Generic.xaml @@ -1,4 +1,4 @@ -<ResourceDictionary +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:AdvancedPaste"> diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png b/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png index 73621edfc0..78a9a18606 100644 Binary files a/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png and b/src/modules/AdvancedPaste/AdvancedPaste/Assets/AdvancedPaste/Gradient.png differ diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs index 1ab58bf269..b74192213b 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceFormatEvent.cs @@ -18,6 +18,7 @@ namespace AdvancedPaste.Helpers PromptTokens = semanticKernelFormatEvent.PromptTokens; CompletionTokens = semanticKernelFormatEvent.CompletionTokens; ModelName = semanticKernelFormatEvent.ModelName; + ProviderType = semanticKernelFormatEvent.ProviderType; ActionChain = semanticKernelFormatEvent.ActionChain; } @@ -38,6 +39,8 @@ namespace AdvancedPaste.Helpers public string ModelName { get; set; } + public string ProviderType { get; set; } + public string ActionChain { get; set; } public string ToJsonString() => JsonSerializer.Serialize(this, SourceGenerationContext.Default.AIServiceFormatEvent); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs new file mode 100644 index 0000000000..ba7d33f4fb --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/AIServiceUsageHelper.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using AdvancedPaste.Models; +using Microsoft.SemanticKernel; + +namespace AdvancedPaste.Helpers; + +/// <summary> +/// Helper class for extracting AI service usage information from chat messages. +/// </summary> +public static class AIServiceUsageHelper +{ + /// <summary> + /// Extracts AI service usage information from OpenAI chat message metadata. + /// </summary> + /// <param name="chatMessage">The chat message containing usage metadata.</param> + /// <returns>AI service usage information or AIServiceUsage.None if extraction fails.</returns> + public static AIServiceUsage GetOpenAIServiceUsage(ChatMessageContent chatMessage) + { + // Try to get usage information from metadata + if (chatMessage.Metadata?.TryGetValue("Usage", out var usageObj) == true) + { + // Handle different possible usage types through reflection to be version-agnostic + var usageType = usageObj.GetType(); + + try + { + // Try common property names for prompt tokens + var promptTokensProp = usageType.GetProperty("PromptTokens") ?? + usageType.GetProperty("InputTokens") ?? + usageType.GetProperty("InputTokenCount"); + + var completionTokensProp = usageType.GetProperty("CompletionTokens") ?? + usageType.GetProperty("OutputTokens") ?? + usageType.GetProperty("OutputTokenCount"); + + if (promptTokensProp != null && completionTokensProp != null) + { + var promptTokens = (int)(promptTokensProp.GetValue(usageObj) ?? 0); + var completionTokens = (int)(completionTokensProp.GetValue(usageObj) ?? 0); + return new AIServiceUsage(promptTokens, completionTokens); + } + } + catch + { + // If reflection fails, fall back to no usage + } + } + + return AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs new file mode 100644 index 0000000000..e4e18338c9 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardItemHelper.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.ApplicationModel.DataTransfer; + +namespace AdvancedPaste.Helpers +{ + internal static partial class ClipboardItemHelper + { + // Compiled regex for better performance when checking multiple clipboard items + private static readonly Regex HexColorRegex = HexColorCompiledRegex(); + + /// <summary> + /// Creates a ClipboardItem from current clipboard data. + /// </summary> + public static async Task<ClipboardItem> CreateFromCurrentClipboardAsync( + DataPackageView clipboardData, + ClipboardFormat availableFormats, + DateTimeOffset? timestamp = null, + BitmapImage existingImage = null) + { + if (clipboardData == null || availableFormats == ClipboardFormat.None) + { + return null; + } + + var clipboardItem = new ClipboardItem + { + Format = availableFormats, + Timestamp = timestamp, + }; + + // Text or HTML content + if (availableFormats.HasFlag(ClipboardFormat.Text) || availableFormats.HasFlag(ClipboardFormat.Html)) + { + clipboardItem.Content = await clipboardData.GetTextOrEmptyAsync(); + } + + // Image content + else if (availableFormats.HasFlag(ClipboardFormat.Image)) + { + // Reuse existing image if provided + if (existingImage != null) + { + clipboardItem.Image = existingImage; + } + else + { + clipboardItem.Image = await TryCreateBitmapImageAsync(clipboardData); + } + } + + return clipboardItem; + } + + /// <summary> + /// Checks if text is a valid RGB hex color (e.g., #FFBFAB or #fff). + /// </summary> + public static bool IsRgbHexColor(string text) + { + if (text == null) + { + return false; + } + + string trimmedText = text.Trim(); + if (trimmedText.Length > 7) + { + return false; + } + + if (string.IsNullOrWhiteSpace(trimmedText)) + { + return false; + } + + // Match #RGB or #RRGGBB format (case-insensitive) + return HexColorRegex.IsMatch(trimmedText); + } + + /// <summary> + /// Creates a BitmapImage from clipboard data. + /// </summary> + private static async Task<BitmapImage> TryCreateBitmapImageAsync(DataPackageView clipboardData) + { + try + { + var imageReference = await clipboardData.GetBitmapAsync(); + if (imageReference != null) + { + using (var imageStream = await imageReference.OpenReadAsync()) + { + var bitmapImage = new BitmapImage(); + await bitmapImage.SetSourceAsync(imageStream); + return bitmapImage; + } + } + } + catch + { + // Silently fail - caller can check for null + } + + return null; + } + + [GeneratedRegex(@"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$")] + private static partial Regex HexColorCompiledRegex(); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs index 529773f9a6..f5439aecf1 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/DataPackageHelpers.cs @@ -6,11 +6,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; +using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Models; using ManagedCommon; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.Data.Html; @@ -180,9 +182,67 @@ internal static class DataPackageHelpers } } + internal static async Task<string> GetClipboardTextOrThrowAsync(this DataPackageView dataPackageView, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(dataPackageView); + + try + { + if (dataPackageView.Contains(StandardDataFormats.Text)) + { + return await dataPackageView.GetTextAsync(); + } + + if (dataPackageView.Contains(StandardDataFormats.Html)) + { + var html = await dataPackageView.GetHtmlFormatAsync(); + return HtmlUtilities.ConvertToText(html); + } + + if (dataPackageView.Contains(StandardDataFormats.Bitmap)) + { + var bitmap = await dataPackageView.GetImageContentAsync(); + if (bitmap != null) + { + return await OcrHelpers.ExtractTextAsync(bitmap, cancellationToken); + } + } + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + throw CreateClipboardTextMissingException(ex); + } + + throw CreateClipboardTextMissingException(); + } + + private static PasteActionException CreateClipboardTextMissingException(Exception innerException = null) + { + var message = ResourceLoaderInstance.ResourceLoader.GetString("ClipboardEmptyWarning"); + return new PasteActionException(message, innerException ?? new InvalidOperationException("Clipboard does not contain text content.")); + } + internal static async Task<string> GetHtmlContentAsync(this DataPackageView dataPackageView) => dataPackageView.Contains(StandardDataFormats.Html) ? await dataPackageView.GetHtmlFormatAsync() : string.Empty; + internal static async Task<byte[]> GetImageAsPngBytesAsync(this DataPackageView dataPackageView) + { + var bitmap = await dataPackageView.GetImageContentAsync(); + if (bitmap == null) + { + return null; + } + + using var pngStream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, pngStream); + encoder.SetSoftwareBitmap(bitmap); + await encoder.FlushAsync(); + + using var memoryStream = new MemoryStream(); + await pngStream.AsStreamForRead().CopyToAsync(memoryStream); + return memoryStream.ToArray(); + } + internal static async Task<SoftwareBitmap> GetImageContentAsync(this DataPackageView dataPackageView) { using var stream = await dataPackageView.GetImageStreamAsync(); @@ -195,6 +255,22 @@ internal static class DataPackageHelpers return null; } + internal static async Task<BitmapImage> GetPreviewBitmapAsync(this DataPackageView dataPackageView) + { + var stream = await dataPackageView.GetImageStreamAsync(); + if (stream == null) + { + return null; + } + + using (stream) + { + var bitmapImage = new BitmapImage(); + bitmapImage.SetSource(stream); + return bitmapImage; + } + } + private static async Task<IRandomAccessStream> GetImageStreamAsync(this DataPackageView dataPackageView) { if (dataPackageView.Contains(StandardDataFormats.StorageItems)) diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs index 105fe2c0d8..d692263dc1 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using AdvancedPaste.Models; using Microsoft.PowerToys.Settings.UI.Library; @@ -12,16 +13,22 @@ namespace AdvancedPaste.Settings { public interface IUserSettings { - public bool IsAdvancedAIEnabled { get; } + public bool IsAIEnabled { get; } public bool ShowCustomPreview { get; } public bool CloseAfterLosingFocus { get; } + public bool EnableClipboardPreview { get; } + public IReadOnlyList<AdvancedPasteCustomAction> CustomActions { get; } public IReadOnlyList<PasteFormats> AdditionalActions { get; } + public PasteAIConfiguration PasteAIConfiguration { get; } + public event EventHandler Changed; + + Task SetActiveAIProviderAsync(string providerId); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs index 6e53e9b618..08293d4be0 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/NativeMethods.cs @@ -166,5 +166,8 @@ namespace AdvancedPaste.Helpers [DllImport("Shlwapi.dll", SetLastError = true, CharSet = CharSet.Unicode)] internal static extern HResult AssocQueryString(AssocF flags, AssocStr str, string pszAssoc, string pszExtra, [Out] StringBuilder pszOut, [In][Out] ref uint pcchOut); + + [DllImport("user32.dll", SetLastError = true)] + internal static extern uint GetClipboardSequenceNumber(); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs index b56868ece8..218349b32b 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs @@ -18,10 +18,19 @@ public static class OcrHelpers { public static async Task<string> ExtractTextAsync(SoftwareBitmap bitmap, CancellationToken cancellationToken) { - var ocrLanguage = GetOCRLanguage() ?? throw new InvalidOperationException("Unable to determine OCR language"); + var ocrLanguage = GetOCRLanguage(); cancellationToken.ThrowIfCancellationRequested(); - var ocrEngine = OcrEngine.TryCreateFromLanguage(ocrLanguage) ?? throw new InvalidOperationException("Unable to create OCR engine"); + OcrEngine ocrEngine; + if (ocrLanguage is not null) + { + ocrEngine = OcrEngine.TryCreateFromLanguage(ocrLanguage) ?? throw new InvalidOperationException("Unable to create OCR engine from specified language"); + } + else + { + ocrEngine = OcrEngine.TryCreateFromUserProfileLanguages() ?? throw new InvalidOperationException("Unable to create OCR engine from user profile language"); + } + cancellationToken.ThrowIfCancellationRequested(); var ocrResult = await ocrEngine.RecognizeAsync(bitmap); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 8a25b70f07..59f31f0e99 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -13,6 +13,7 @@ using AdvancedPaste.Models; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Utilities; +using Windows.Security.Credentials; namespace AdvancedPaste.Settings { @@ -33,23 +34,29 @@ namespace AdvancedPaste.Settings public event EventHandler Changed; - public bool IsAdvancedAIEnabled { get; private set; } + public bool IsAIEnabled { get; private set; } public bool ShowCustomPreview { get; private set; } public bool CloseAfterLosingFocus { get; private set; } + public bool EnableClipboardPreview { get; private set; } + public IReadOnlyList<PasteFormats> AdditionalActions => _additionalActions; public IReadOnlyList<AdvancedPasteCustomAction> CustomActions => _customActions; + public PasteAIConfiguration PasteAIConfiguration { get; private set; } + public UserSettings(IFileSystem fileSystem) { _settingsUtils = new SettingsUtils(fileSystem); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + EnableClipboardPreview = true; + PasteAIConfiguration = new PasteAIConfiguration(); _additionalActions = []; _customActions = []; _taskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); @@ -94,13 +101,17 @@ namespace AdvancedPaste.Settings var settings = _settingsUtils.GetSettingsOrDefault<AdvancedPasteSettings>(AdvancedPasteModuleName); if (settings != null) { + bool migratedLegacyEnablement = TryMigrateLegacyAIEnablement(settings); + void UpdateSettings() { var properties = settings.Properties; - IsAdvancedAIEnabled = properties.IsAdvancedAIEnabled; + IsAIEnabled = properties.IsAIEnabled; ShowCustomPreview = properties.ShowCustomPreview; CloseAfterLosingFocus = properties.CloseAfterLosingFocus; + EnableClipboardPreview = properties.EnableClipboardPreview; + PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration(); var sourceAdditionalActions = properties.AdditionalActions; (PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats = @@ -126,6 +137,11 @@ namespace AdvancedPaste.Settings Task.Factory .StartNew(UpdateSettings, CancellationToken.None, TaskCreationOptions.None, _taskScheduler) .Wait(); + + if (migratedLegacyEnablement) + { + settings.Save(_settingsUtils); + } } retry = false; @@ -144,6 +160,220 @@ namespace AdvancedPaste.Settings } } + private static bool TryMigrateLegacyAIEnablement(AdvancedPasteSettings settings) + { + if (settings?.Properties is null) + { + return false; + } + + var properties = settings.Properties; + bool legacyAdvancedAIConsumed = properties.TryConsumeLegacyAdvancedAIEnabled(out var advancedFlag); + bool legacyAdvancedAIEnabled = legacyAdvancedAIConsumed && advancedFlag; + PasswordCredential legacyCredential = TryGetLegacyOpenAICredential(); + + if (legacyCredential is null) + { + return legacyAdvancedAIConsumed; + } + + var configuration = properties.PasteAIConfiguration; + + if (configuration is null) + { + configuration = new PasteAIConfiguration(); + properties.PasteAIConfiguration = configuration; + } + + bool configurationUpdated = false; + + var ensureResult = AdvancedPasteMigrationHelper.EnsureOpenAIProvider(configuration); + PasteAIProviderDefinition openAIProvider = ensureResult.Provider; + configurationUpdated |= ensureResult.Updated; + + if (legacyAdvancedAIConsumed && openAIProvider is not null && openAIProvider.EnableAdvancedAI != legacyAdvancedAIEnabled) + { + openAIProvider.EnableAdvancedAI = legacyAdvancedAIEnabled; + configurationUpdated = true; + } + + if (openAIProvider is not null) + { + StoreMigratedOpenAICredential(openAIProvider.Id, openAIProvider.ServiceType, legacyCredential.Password); + RemoveLegacyOpenAICredential(); + } + + const bool shouldEnableAI = true; + bool enabledUpdated = false; + if (properties.IsAIEnabled != shouldEnableAI) + { + properties.IsAIEnabled = shouldEnableAI; + enabledUpdated = true; + } + + return configurationUpdated || enabledUpdated || legacyAdvancedAIConsumed; + } + + private static PasswordCredential TryGetLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + var credential = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + credential?.RetrievePassword(); + return credential; + } + catch (Exception) + { + return null; + } + } + + private static void RemoveLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + TryRemoveCredential(vault, "https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + } + catch (Exception) + { + } + } + + private static void StoreMigratedOpenAICredential(string providerId, string serviceType, string password) + { + if (string.IsNullOrWhiteSpace(password)) + { + return; + } + + try + { + var serviceKind = serviceType.ToAIServiceType(); + if (serviceKind != AIServiceType.OpenAI) + { + return; + } + + string resource = "https://platform.openai.com/api-keys"; + string username = $"PowerToys_AdvancedPaste_PasteAI_openai_{NormalizeProviderIdentifier(providerId)}"; + + PasswordVault vault = new(); + TryRemoveCredential(vault, resource, username); + + PasswordCredential credential = new(resource, username, password); + vault.Add(credential); + } + catch (Exception ex) + { + Logger.LogError("Failed to migrate legacy OpenAI credential", ex); + } + } + + private static void TryRemoveCredential(PasswordVault vault, string credentialResource, string credentialUserName) + { + try + { + PasswordCredential existingCred = vault.Retrieve(credentialResource, credentialUserName); + vault.Remove(existingCred); + } + catch (Exception) + { + // Credential doesn't exist, which is fine + } + } + + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } + + public async Task SetActiveAIProviderAsync(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return; + } + + await Task.Run(() => + { + lock (_loadingSettingsLock) + { + var settings = _settingsUtils.GetSettingsOrDefault<AdvancedPasteSettings>(AdvancedPasteModuleName); + var configuration = settings?.Properties?.PasteAIConfiguration; + var providers = configuration?.Providers; + + if (configuration == null || providers == null || providers.Count == 0) + { + return; + } + + var target = providers.FirstOrDefault(provider => string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase)); + if (target == null) + { + return; + } + + if (string.Equals(configuration.ActiveProvider?.Id, providerId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + configuration.ActiveProviderId = providerId; + + foreach (var provider in providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + + try + { + settings.Save(_settingsUtils); + } + catch (Exception ex) + { + Logger.LogError("Failed to set active AI provider", ex); + return; + } + + try + { + Task.Factory + .StartNew( + () => + { + PasteAIConfiguration.ActiveProviderId = providerId; + + if (PasteAIConfiguration.Providers is not null) + { + foreach (var provider in PasteAIConfiguration.Providers) + { + provider.IsActive = string.Equals(provider.Id, providerId, StringComparison.OrdinalIgnoreCase); + } + } + + Changed?.Invoke(this, EventArgs.Empty); + }, + CancellationToken.None, + TaskCreationOptions.None, + _taskScheduler) + .Wait(); + } + catch (Exception ex) + { + Logger.LogError("Failed to dispatch active AI provider change", ex); + } + } + }); + } + public void Dispose() { Dispose(true); diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs index 1013108bc9..16814e7001 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs @@ -2,6 +2,7 @@ // 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 AdvancedPaste.Helpers; using Microsoft.UI.Xaml.Media.Imaging; using Windows.ApplicationModel.DataTransfer; @@ -12,10 +13,15 @@ public class ClipboardItem { public string Content { get; set; } - public ClipboardHistoryItem Item { get; set; } - public BitmapImage Image { get; set; } + public ClipboardFormat Format { get; set; } + + public DateTimeOffset? Timestamp { get; set; } + + // Only used for clipboard history items that have a ClipboardHistoryItem + public ClipboardHistoryItem Item { get; set; } + public string Description => !string.IsNullOrEmpty(Content) ? Content : Image is not null ? ResourceLoaderInstance.ResourceLoader.GetString("ClipboardHistoryImage") : string.Empty; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionModeratedException.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionModeratedException.cs index 42268d2631..ecfa615125 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionModeratedException.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionModeratedException.cs @@ -17,7 +17,7 @@ public sealed class PasteActionModeratedException : PasteActionException } /// <summary> - /// Non-localized error description for logs, reports, telemetry etc. + /// Non-localized error description for logs, reports, telemetry, etc. /// </summary> public const string ErrorDescription = "Paste operation moderated"; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs index 99243ebb5e..1479912e66 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs @@ -46,7 +46,7 @@ public enum PasteFormats CanPreview = true, SupportedClipboardFormats = ClipboardFormat.Image, IPCKey = AdvancedPasteAdditionalActions.PropertyNames.ImageToText, - KernelFunctionDescription = "Takes an image in the clipboard and extracts all text from it using OCR.")] + KernelFunctionDescription = "Takes an image from the clipboard and extracts text using OCR. This function is intended only for explicit text extraction or OCR requests.")] ImageToText, [PasteFormatMetadata( @@ -118,8 +118,8 @@ public enum PasteFormats IconGlyph = "\uE945", RequiresAIService = true, CanPreview = true, - SupportedClipboardFormats = ClipboardFormat.Text, - KernelFunctionDescription = "Takes input instructions and transforms clipboard text (not TXT files) with these input instructions, putting the result back on the clipboard. This uses AI to accomplish the task.", + SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Image, + KernelFunctionDescription = "Takes user instructions and applies them to the current clipboard content (text or image). Use this function for image analysis, description, or transformation tasks beyond simple OCR.", RequiresPrompt = true)] CustomTextTransformation, } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs new file mode 100644 index 0000000000..c886bcef43 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/AdvancedAIKernelService.cs @@ -0,0 +1,220 @@ +// 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.Linq; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services; + +public sealed class AdvancedAIKernelService : KernelServiceBase +{ + private sealed record RuntimeConfiguration( + AIServiceType ServiceType, + string ModelName, + string Endpoint, + string DeploymentName, + string ModelPath, + string SystemPrompt, + bool ModerationEnabled) : IKernelRuntimeConfiguration; + + private readonly IAICredentialsProvider credentialsProvider; + + public AdvancedAIKernelService( + IAICredentialsProvider credentialsProvider, + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) + : base(queryCacheService, promptModerationService, userSettings, customActionTransformService) + { + ArgumentNullException.ThrowIfNull(credentialsProvider); + + this.credentialsProvider = credentialsProvider; + } + + protected override string AdvancedAIModelName => GetRuntimeConfiguration().ModelName; + + protected override PromptExecutionSettings PromptExecutionSettings => CreatePromptExecutionSettings(); + + protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) + { + ArgumentNullException.ThrowIfNull(kernelBuilder); + + var runtimeConfig = GetRuntimeConfiguration(); + var serviceType = runtimeConfig.ServiceType; + var modelName = runtimeConfig.ModelName; + var requiresApiKey = RequiresApiKey(serviceType); + var apiKey = string.Empty; + if (requiresApiKey) + { + this.credentialsProvider.Refresh(); + apiKey = (this.credentialsProvider.GetKey() ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"An API key is required for {serviceType} but none was found in the credential vault."); + } + } + + var endpoint = string.IsNullOrWhiteSpace(runtimeConfig.Endpoint) ? null : runtimeConfig.Endpoint.Trim(); + var deployment = string.IsNullOrWhiteSpace(runtimeConfig.DeploymentName) ? modelName : runtimeConfig.DeploymentName; + + switch (serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(modelName, apiKey, serviceId: modelName); + break; + case AIServiceType.AzureOpenAI: + kernelBuilder.AddAzureOpenAIChatCompletion(deployment, RequireEndpoint(endpoint, serviceType), apiKey, serviceId: modelName); + break; + default: + throw new NotSupportedException($"Service type '{runtimeConfig.ServiceType}' is not supported"); + } + } + + protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) + { + return AIServiceUsageHelper.GetOpenAIServiceUsage(chatMessage); + } + + protected override bool ShouldModerateAdvancedAI() + { + if (!TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return false; + } + + return runtimeConfig.ModerationEnabled && (runtimeConfig.ServiceType == AIServiceType.OpenAI || runtimeConfig.ServiceType == AIServiceType.AzureOpenAI); + } + + private static string GetModelName(PasteAIProviderDefinition config) + { + if (!string.IsNullOrWhiteSpace(config?.ModelName)) + { + return config.ModelName; + } + + return "gpt-4o"; + } + + protected override IKernelRuntimeConfiguration GetRuntimeConfiguration() + { + if (TryGetRuntimeConfiguration(out var runtimeConfig)) + { + return runtimeConfig; + } + + throw new InvalidOperationException("No Advanced AI provider is configured."); + } + + private bool TryGetRuntimeConfiguration(out IKernelRuntimeConfiguration runtimeConfig) + { + runtimeConfig = null; + + if (!TryResolveAdvancedProvider(out var provider)) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + if (!IsServiceTypeSupported(serviceType)) + { + return false; + } + + runtimeConfig = new RuntimeConfiguration( + serviceType, + GetModelName(provider), + provider.EndpointUrl, + provider.DeploymentName, + provider.ModelPath, + provider.SystemPrompt, + provider.ModerationEnabled); + return true; + } + + private bool TryResolveAdvancedProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = this.UserSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedProvider(PasteAIProviderDefinition provider) + { + if (provider is null || !provider.EnableAdvancedAI) + { + return false; + } + + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + return IsServiceTypeSupported(serviceType); + } + + private static bool IsServiceTypeSupported(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI or AIServiceType.AzureOpenAI; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return true; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} configuration but was not provided."); + } + + private PromptExecutionSettings CreatePromptExecutionSettings() + { + var serviceType = GetRuntimeConfiguration().ServiceType; + return new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(), + }; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs new file mode 100644 index 0000000000..562ea3976c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformResult.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformResult + { + public CustomActionTransformResult(string content, AIServiceUsage usage) + { + Content = content; + Usage = usage; + } + + public string Content { get; } + + public AIServiceUsage Usage { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs new file mode 100644 index 0000000000..05cdcbe81f --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/CustomActionTransformService.cs @@ -0,0 +1,207 @@ +// 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.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using AdvancedPaste.Settings; +using AdvancedPaste.Telemetry; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class CustomActionTransformService : ICustomActionTransformService + { + private const string DefaultSystemPrompt = """ + You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. + Do not output anything else besides the reformatted clipboard content. + """; + + private readonly IPromptModerationService promptModerationService; + private readonly IPasteAIProviderFactory providerFactory; + private readonly IAICredentialsProvider credentialsProvider; + private readonly IUserSettings userSettings; + + public CustomActionTransformService(IPromptModerationService promptModerationService, IPasteAIProviderFactory providerFactory, IAICredentialsProvider credentialsProvider, IUserSettings userSettings) + { + this.promptModerationService = promptModerationService; + this.providerFactory = providerFactory; + this.credentialsProvider = credentialsProvider; + this.userSettings = userSettings; + } + + public async Task<CustomActionTransformResult> TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress<double> progress) + { + var pasteConfig = userSettings?.PasteAIConfiguration; + var providerConfig = BuildProviderConfig(pasteConfig); + + return await TransformAsync(prompt, inputText, imageBytes, providerConfig, cancellationToken, progress); + } + + private async Task<CustomActionTransformResult> TransformAsync(string prompt, string inputText, byte[] imageBytes, PasteAIConfig providerConfig, CancellationToken cancellationToken, IProgress<double> progress) + { + ArgumentNullException.ThrowIfNull(providerConfig); + + if (string.IsNullOrWhiteSpace(prompt)) + { + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + if (string.IsNullOrWhiteSpace(inputText) && imageBytes is null) + { + Logger.LogWarning("Clipboard has no usable data"); + return new CustomActionTransformResult(string.Empty, AIServiceUsage.None); + } + + var systemPrompt = providerConfig.SystemPrompt ?? DefaultSystemPrompt; + + var fullPrompt = (systemPrompt ?? string.Empty) + "\n\n" + (inputText ?? string.Empty); + + if (ShouldModerate(providerConfig)) + { + await promptModerationService.ValidateAsync(fullPrompt, cancellationToken); + } + + try + { + var provider = providerFactory.CreateProvider(providerConfig); + + var request = new PasteAIRequest + { + Prompt = prompt, + InputText = inputText, + ImageBytes = imageBytes, + ImageMimeType = imageBytes != null ? "image/png" : null, + SystemPrompt = systemPrompt, + }; + + var operationStart = DateTime.UtcNow; + + var providerContent = await provider.ProcessPasteAsync( + request, + cancellationToken, + progress); + + var durationMs = (int)Math.Round((DateTime.UtcNow - operationStart).TotalMilliseconds); + + var usage = request.Usage; + var content = providerContent ?? string.Empty; + + // Log endpoint usage (custom action pipeline is not the advanced SK flow) + var endpointEvent = new AdvancedPasteEndpointUsageEvent(providerConfig.ProviderType, providerConfig.Model ?? string.Empty, isAdvanced: false, durationMs: durationMs); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + + Logger.LogDebug($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} complete; ModelName={providerConfig.Model ?? string.Empty}, PromptTokens={usage.PromptTokens}, CompletionTokens={usage.CompletionTokens}, DurationMs={durationMs}"); + + return new CustomActionTransformResult(content, usage); + } + catch (Exception ex) + { + Logger.LogError($"{nameof(CustomActionTransformService)}.{nameof(TransformAsync)} failed", ex); + var statusCode = ExtractStatusCode(ex); + var modelName = providerConfig.Model ?? string.Empty; + AdvancedPasteCustomActionErrorEvent errorEvent = new(providerConfig.ProviderType, modelName, statusCode, ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); + PowerToysTelemetry.Log.WriteEvent(errorEvent); + + if (ex is PasteActionException or OperationCanceledException) + { + throw; + } + + var failureMessage = providerConfig.ProviderType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => ErrorHelpers.TranslateErrorText(statusCode), + _ => ResourceLoaderInstance.ResourceLoader.GetString("PasteError"), + }; + + throw new PasteActionException(failureMessage, ex); + } + } + + private static int ExtractStatusCode(Exception exception) + { + if (exception is HttpOperationException httpOperationException) + { + return (int?)httpOperationException.StatusCode ?? -1; + } + + if (exception is HttpRequestException httpRequestException && httpRequestException.StatusCode is HttpStatusCode statusCode) + { + return (int)statusCode; + } + + return -1; + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private PasteAIConfig BuildProviderConfig(PasteAIConfiguration config) + { + config ??= new PasteAIConfiguration(); + var provider = config.ActiveProvider ?? config.Providers?.FirstOrDefault() ?? new PasteAIProviderDefinition(); + var serviceType = NormalizeServiceType(provider.ServiceTypeKind); + var systemPrompt = string.IsNullOrWhiteSpace(provider.SystemPrompt) ? DefaultSystemPrompt : provider.SystemPrompt; + var apiKey = AcquireApiKey(serviceType); + var modelName = provider.ModelName; + + var providerConfig = new PasteAIConfig + { + ProviderType = serviceType, + ApiKey = apiKey, + Model = modelName, + Endpoint = provider.EndpointUrl, + DeploymentName = provider.DeploymentName, + LocalModelPath = provider.ModelPath, + ModelPath = provider.ModelPath, + SystemPrompt = systemPrompt, + ModerationEnabled = provider.ModerationEnabled, + }; + + return providerConfig; + } + + private string AcquireApiKey(AIServiceType serviceType) + { + if (!RequiresApiKey(serviceType)) + { + return string.Empty; + } + + credentialsProvider.Refresh(); + return credentialsProvider.GetKey() ?? string.Empty; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Onnx => false, + AIServiceType.Ollama => false, + _ => true, + }; + } + + private static bool ShouldModerate(PasteAIConfig providerConfig) + { + if (providerConfig is null || !providerConfig.ModerationEnabled) + { + return false; + } + + return providerConfig.ProviderType == AIServiceType.OpenAI || providerConfig.ProviderType == AIServiceType.AzureOpenAI; + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs new file mode 100644 index 0000000000..8b57baae74 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/FoundryLocalPasteProvider.cs @@ -0,0 +1,196 @@ +// 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 AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using LanguageModelProvider; +using Microsoft.Extensions.AI; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions; + +public sealed class FoundryLocalPasteProvider : IPasteAIProvider +{ + private static readonly IReadOnlyCollection<AIServiceType> SupportedTypes = new[] + { + AIServiceType.FoundryLocal, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new FoundryLocalPasteProvider(config)); + + private static readonly FoundryLocalModelProvider _modelProvider = FoundryLocalModelProvider.Instance; + + private readonly PasteAIConfig _config; + + public FoundryLocalPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + } + + public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return await FoundryLocalModelProvider.Instance.IsAvailable().ConfigureAwait(false); + } + + public async Task<string> ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress<double> progress) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new PasteActionException( + "System prompt is required for Foundry Local", + new ArgumentException("System prompt must be provided", nameof(request))); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + if (string.IsNullOrWhiteSpace(prompt) || string.IsNullOrWhiteSpace(inputText)) + { + throw new PasteActionException( + "Prompt and input text are required", + new ArgumentException("Prompt and input text must be provided", nameof(request))); + } + + var modelReference = _config?.Model; + if (string.IsNullOrWhiteSpace(modelReference)) + { + throw new PasteActionException( + "No Foundry Local model selected", + new InvalidOperationException("Model identifier is required"), + aiServiceMessage: "Please select a model in the AI provider settings."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + IChatClient chatClient; + try + { + chatClient = _modelProvider.GetIChatClient(modelReference); + } + catch (InvalidOperationException ex) + { + // GetIChatClient throws InvalidOperationException for user-facing errors + var errorMessage = string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString("FoundryLocal_UnableToLoadModel"), modelReference); + throw new PasteActionException( + errorMessage, + ex, + aiServiceMessage: ex.Message); + } + + var userMessageContent = $""" + User instructions: + {prompt} + + Text: + {inputText} + + Output: + """; + + var chatMessages = new List<ChatMessage> + { + new(ChatRole.System, systemPrompt), + new(ChatRole.User, userMessageContent), + }; + + var chatOptions = CreateChatOptions(_config?.SystemPrompt, modelReference); + + progress?.Report(0.1); + + var response = await chatClient.GetResponseAsync(chatMessages, chatOptions, cancellationToken).ConfigureAwait(false); + + progress?.Report(0.8); + + var responseText = GetResponseText(response); + request.Usage = ToUsage(response.Usage); + + progress?.Report(1.0); + + return responseText ?? string.Empty; + } + catch (OperationCanceledException) + { + // Let cancellation exceptions pass through unchanged + throw; + } + catch (PasteActionException) + { + // Let our custom exceptions pass through unchanged + throw; + } + catch (Exception ex) + { + // Wrap any other exceptions with context + var modelInfo = !string.IsNullOrWhiteSpace(_config?.Model) ? $" (Model: {_config.Model})" : string.Empty; + throw new PasteActionException( + $"Failed to generate response using Foundry Local{modelInfo}", + ex, + aiServiceMessage: $"Error details: {ex.Message}"); + } + } + + private static ChatOptions CreateChatOptions(string systemPrompt, string modelReference) + { + var options = new ChatOptions + { + ModelId = modelReference, + MaxOutputTokens = 2048, + }; + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + options.Instructions = systemPrompt; + } + + return options; + } + + private static string GetResponseText(ChatResponse response) + { + if (!string.IsNullOrWhiteSpace(response.Text)) + { + return response.Text; + } + + if (response.Messages is { Count: > 0 }) + { + var lastMessage = response.Messages.LastOrDefault(m => !string.IsNullOrWhiteSpace(m.Text)); + if (!string.IsNullOrWhiteSpace(lastMessage?.Text)) + { + return lastMessage.Text; + } + } + + return string.Empty; + } + + private static AIServiceUsage ToUsage(UsageDetails usageDetails) + { + if (usageDetails is null) + { + return AIServiceUsage.None; + } + + int promptTokens = (int)(usageDetails.InputTokenCount ?? 0); + int completionTokens = (int)(usageDetails.OutputTokenCount ?? 0); + + if (promptTokens == 0 && completionTokens == 0) + { + return AIServiceUsage.None; + } + + return new AIServiceUsage(promptTokens, completionTokens); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs new file mode 100644 index 0000000000..564db3fdc5 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/ICustomActionTransformService.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +using AdvancedPaste.Settings; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface ICustomActionTransformService + { + Task<CustomActionTransformResult> TransformAsync(string prompt, string inputText, byte[] imageBytes, CancellationToken cancellationToken, IProgress<double> progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.cs new file mode 100644 index 0000000000..764d99f942 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProvider.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; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProvider + { + Task<bool> IsAvailableAsync(CancellationToken cancellationToken); + + Task<string> ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress<double> progress); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs new file mode 100644 index 0000000000..aacc61bec9 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/IPasteAIProviderFactory.cs @@ -0,0 +1,11 @@ +// 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 AdvancedPaste.Services.CustomActions +{ + public interface IPasteAIProviderFactory + { + IPasteAIProvider CreateProvider(PasteAIConfig config); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs new file mode 100644 index 0000000000..f4d45ccd74 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/LocalModelPasteProvider.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class LocalModelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection<AIServiceType> SupportedTypes = new[] + { + AIServiceType.Onnx, + AIServiceType.ML, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new LocalModelPasteProvider(config)); + + private readonly PasteAIConfig _config; + + public LocalModelPasteProvider(PasteAIConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + } + + public Task<bool> IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public Task<string> ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress<double> progress) + { + ArgumentNullException.ThrowIfNull(request); + + // TODO: Implement local model inference logic using _config.LocalModelPath/_config.ModelPath + var content = request.InputText ?? string.Empty; + request.Usage = AIServiceUsage.None; + return Task.FromResult(content); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.cs new file mode 100644 index 0000000000..1d8a60f041 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIConfig.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; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel.ChatCompletion; + +namespace AdvancedPaste.Services.CustomActions +{ + public class PasteAIConfig + { + public AIServiceType ProviderType { get; set; } + + public string Model { get; set; } + + public string ApiKey { get; set; } + + public string Endpoint { get; set; } + + public string DeploymentName { get; set; } + + public string LocalModelPath { get; set; } + + public string ModelPath { get; set; } + + public string SystemPrompt { get; set; } + + public bool ModerationEnabled { get; set; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.cs new file mode 100644 index 0000000000..7339b4e4e3 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderFactory.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. + +using System; +using System.Collections.Generic; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderFactory : IPasteAIProviderFactory + { + private static readonly IReadOnlyList<PasteAIProviderRegistration> ProviderRegistrations = new[] + { + SemanticKernelPasteProvider.Registration, + LocalModelPasteProvider.Registration, + FoundryLocalPasteProvider.Registration, + }; + + private static readonly IReadOnlyDictionary<AIServiceType, Func<PasteAIConfig, IPasteAIProvider>> ProviderFactories = CreateProviderFactories(); + + public IPasteAIProvider CreateProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + var serviceType = config.ProviderType; + if (serviceType == AIServiceType.Unknown) + { + serviceType = AIServiceType.OpenAI; + config.ProviderType = serviceType; + } + + if (!ProviderFactories.TryGetValue(serviceType, out var factory)) + { + throw new NotSupportedException($"Provider {config.ProviderType} not supported"); + } + + return factory(config); + } + + private static IReadOnlyDictionary<AIServiceType, Func<PasteAIConfig, IPasteAIProvider>> CreateProviderFactories() + { + var map = new Dictionary<AIServiceType, Func<PasteAIConfig, IPasteAIProvider>>(); + + foreach (var registration in ProviderRegistrations) + { + Register(map, registration.SupportedTypes, registration.Factory); + } + + return map; + } + + private static void Register(Dictionary<AIServiceType, Func<PasteAIConfig, IPasteAIProvider>> map, IReadOnlyCollection<AIServiceType> types, Func<PasteAIConfig, IPasteAIProvider> factory) + { + foreach (var type in types) + { + map[type] = factory; + } + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs new file mode 100644 index 0000000000..6bd78450e8 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIProviderRegistration.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIProviderRegistration + { + public PasteAIProviderRegistration(IReadOnlyCollection<Microsoft.PowerToys.Settings.UI.Library.AIServiceType> supportedTypes, Func<PasteAIConfig, IPasteAIProvider> factory) + { + SupportedTypes = supportedTypes ?? throw new ArgumentNullException(nameof(supportedTypes)); + Factory = factory ?? throw new ArgumentNullException(nameof(factory)); + } + + public IReadOnlyCollection<Microsoft.PowerToys.Settings.UI.Library.AIServiceType> SupportedTypes { get; } + + public Func<PasteAIConfig, IPasteAIProvider> Factory { get; } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs new file mode 100644 index 0000000000..96dabbfa05 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/PasteAIRequest.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using AdvancedPaste.Models; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class PasteAIRequest + { + public string Prompt { get; init; } + + public string InputText { get; init; } + + public byte[] ImageBytes { get; init; } + + public string ImageMimeType { get; init; } + + public string SystemPrompt { get; init; } + + public AIServiceUsage Usage { get; set; } = AIServiceUsage.None; + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs new file mode 100644 index 0000000000..636d2e3e78 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/CustomActions/SemanticKernelPasteProvider.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvancedPaste.Helpers; +using AdvancedPaste.Models; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.AzureAIInference; +using Microsoft.SemanticKernel.Connectors.Google; +using Microsoft.SemanticKernel.Connectors.MistralAI; +using Microsoft.SemanticKernel.Connectors.Ollama; +using Microsoft.SemanticKernel.Connectors.OpenAI; + +namespace AdvancedPaste.Services.CustomActions +{ + public sealed class SemanticKernelPasteProvider : IPasteAIProvider + { + private static readonly IReadOnlyCollection<AIServiceType> SupportedTypes = new[] + { + AIServiceType.OpenAI, + AIServiceType.AzureOpenAI, + AIServiceType.Mistral, + AIServiceType.Google, + AIServiceType.AzureAIInference, + AIServiceType.Ollama, + }; + + public static PasteAIProviderRegistration Registration { get; } = new(SupportedTypes, config => new SemanticKernelPasteProvider(config)); + + private readonly PasteAIConfig _config; + private readonly AIServiceType _serviceType; + + public SemanticKernelPasteProvider(PasteAIConfig config) + { + ArgumentNullException.ThrowIfNull(config); + _config = config; + _serviceType = config.ProviderType; + if (_serviceType == AIServiceType.Unknown) + { + _serviceType = AIServiceType.OpenAI; + _config.ProviderType = _serviceType; + } + } + + public IReadOnlyCollection<AIServiceType> SupportedServiceTypes => SupportedTypes; + + public Task<bool> IsAvailableAsync(CancellationToken cancellationToken) => Task.FromResult(true); + + public async Task<string> ProcessPasteAsync(PasteAIRequest request, CancellationToken cancellationToken, IProgress<double> progress) + { + ArgumentNullException.ThrowIfNull(request); + + var systemPrompt = request.SystemPrompt; + if (string.IsNullOrWhiteSpace(systemPrompt)) + { + throw new ArgumentException("System prompt must be provided", nameof(request)); + } + + var prompt = request.Prompt; + var inputText = request.InputText; + var imageBytes = request.ImageBytes; + + if (string.IsNullOrWhiteSpace(prompt) || (string.IsNullOrWhiteSpace(inputText) && imageBytes is null)) + { + throw new ArgumentException("Prompt and input content must be provided", nameof(request)); + } + + var executionSettings = CreateExecutionSettings(); + var kernel = CreateKernel(); + var modelId = _config.Model; + + IChatCompletionService chatService; + if (!string.IsNullOrWhiteSpace(modelId)) + { + try + { + chatService = kernel.GetRequiredService<IChatCompletionService>(modelId); + } + catch (Exception) + { + chatService = kernel.GetRequiredService<IChatCompletionService>(); + } + } + else + { + chatService = kernel.GetRequiredService<IChatCompletionService>(); + } + + var chatHistory = new ChatHistory(); + chatHistory.AddSystemMessage(systemPrompt); + + if (imageBytes != null) + { + var collection = new ChatMessageContentItemCollection(); + if (!string.IsNullOrWhiteSpace(inputText)) + { + collection.Add(new TextContent($"Clipboard Content:\n{inputText}")); + } + + collection.Add(new ImageContent(imageBytes, request.ImageMimeType ?? "image/png")); + collection.Add(new TextContent($"User instructions:\n{prompt}\n\nOutput:")); + chatHistory.AddUserMessage(collection); + } + else + { + var userMessageContent = $""" + User instructions: + {prompt} + + Clipboard Content: + {inputText} + + Output: + """; + chatHistory.AddUserMessage(userMessageContent); + } + + var response = await chatService.GetChatMessageContentAsync(chatHistory, executionSettings, kernel, cancellationToken); + chatHistory.Add(response); + + request.Usage = AIServiceUsageHelper.GetOpenAIServiceUsage(response); + return response.Content; + } + + private Kernel CreateKernel() + { + var kernelBuilder = Kernel.CreateBuilder(); + var endpoint = string.IsNullOrWhiteSpace(_config.Endpoint) ? null : _config.Endpoint.Trim(); + var apiKey = _config.ApiKey?.Trim() ?? string.Empty; + + if (RequiresApiKey(_serviceType) && string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException($"API key is required for {_serviceType} but was not provided."); + } + + switch (_serviceType) + { + case AIServiceType.OpenAI: + kernelBuilder.AddOpenAIChatCompletion(_config.Model, apiKey, serviceId: _config.Model); + break; + case AIServiceType.AzureOpenAI: + var deploymentName = string.IsNullOrWhiteSpace(_config.DeploymentName) ? _config.Model : _config.DeploymentName; + kernelBuilder.AddAzureOpenAIChatCompletion(deploymentName, RequireEndpoint(endpoint, _serviceType), apiKey, serviceId: _config.Model); + break; + case AIServiceType.Mistral: + kernelBuilder.AddMistralChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.Google: + kernelBuilder.AddGoogleAIGeminiChatCompletion(_config.Model, apiKey: apiKey); + break; + case AIServiceType.AzureAIInference: + kernelBuilder.AddAzureAIInferenceChatCompletion(_config.Model, apiKey: apiKey, endpoint: new Uri(endpoint)); + break; + case AIServiceType.Ollama: + kernelBuilder.AddOllamaChatCompletion(_config.Model, endpoint: new Uri(endpoint)); + break; + + default: + throw new NotSupportedException($"Provider '{_config.ProviderType}' is not supported by {nameof(SemanticKernelPasteProvider)}"); + } + + return kernelBuilder.Build(); + } + + private PromptExecutionSettings CreateExecutionSettings() + { + return _serviceType switch + { + AIServiceType.OpenAI or AIServiceType.AzureOpenAI => new OpenAIPromptExecutionSettings + { + FunctionChoiceBehavior = null, + }, + _ => new PromptExecutionSettings(), + }; + } + + private static bool RequiresApiKey(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.Ollama => false, + _ => true, + }; + } + + private static string RequireEndpoint(string endpoint, AIServiceType serviceType) + { + if (!string.IsNullOrWhiteSpace(endpoint)) + { + return endpoint; + } + + throw new InvalidOperationException($"Endpoint is required for {serviceType} but was not provided."); + } + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs new file mode 100644 index 0000000000..648881fba0 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/EnhancedVaultCredentialsProvider.cs @@ -0,0 +1,182 @@ +// 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.Linq; +using System.Threading; +using AdvancedPaste.Settings; +using Microsoft.PowerToys.Settings.UI.Library; +using Windows.Security.Credentials; + +namespace AdvancedPaste.Services; + +/// <summary> +/// Enhanced credentials provider that supports different AI service types +/// Keys are stored in Windows Credential Vault with service-specific identifiers +/// </summary> +public sealed class EnhancedVaultCredentialsProvider : IAICredentialsProvider +{ + private sealed class CredentialSlot + { + public AIServiceType ServiceType { get; set; } = AIServiceType.Unknown; + + public string ProviderId { get; set; } = string.Empty; + + public (string Resource, string Username)? Entry { get; set; } + + public string Key { get; set; } = string.Empty; + } + + private readonly IUserSettings _userSettings; + private readonly CredentialSlot _slot; + private readonly Lock _syncRoot = new(); + + public EnhancedVaultCredentialsProvider(IUserSettings userSettings) + { + _userSettings = userSettings ?? throw new ArgumentNullException(nameof(userSettings)); + + _slot = new CredentialSlot(); + + Refresh(); + } + + public string GetKey() + { + using (_syncRoot.EnterScope()) + { + UpdateSlot(forceRefresh: false); + return _slot.Key; + } + } + + public bool IsConfigured() + { + return !string.IsNullOrEmpty(GetKey()); + } + + public bool Refresh() + { + using (_syncRoot.EnterScope()) + { + return UpdateSlot(forceRefresh: true); + } + } + + private bool UpdateSlot(bool forceRefresh) + { + var (serviceType, providerId) = ResolveCredentialTarget(); + var desiredServiceType = NormalizeServiceType(serviceType); + providerId ??= string.Empty; + + var hasChanged = false; + + if (_slot.ServiceType != desiredServiceType || !string.Equals(_slot.ProviderId, providerId, StringComparison.Ordinal)) + { + _slot.ServiceType = desiredServiceType; + _slot.ProviderId = providerId; + _slot.Entry = BuildCredentialEntry(desiredServiceType, providerId); + forceRefresh = true; + hasChanged = true; + } + + if (!forceRefresh) + { + return hasChanged; + } + + var newKey = LoadKey(_slot.Entry); + if (!string.Equals(_slot.Key, newKey, StringComparison.Ordinal)) + { + _slot.Key = newKey; + hasChanged = true; + } + + return hasChanged; + } + + private (AIServiceType ServiceType, string ProviderId) ResolveCredentialTarget() + { + var provider = _userSettings.PasteAIConfiguration?.ActiveProvider; + if (provider is null) + { + return (AIServiceType.OpenAI, string.Empty); + } + + return (provider.ServiceTypeKind, provider.Id ?? string.Empty); + } + + private static AIServiceType NormalizeServiceType(AIServiceType serviceType) + { + return serviceType == AIServiceType.Unknown ? AIServiceType.OpenAI : serviceType; + } + + private static string LoadKey((string Resource, string Username)? entry) + { + if (entry is null) + { + return string.Empty; + } + + try + { + var credential = new PasswordVault().Retrieve(entry.Value.Resource, entry.Value.Username); + return credential?.Password ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + + private static (string Resource, string Username)? BuildCredentialEntry(AIServiceType serviceType, string providerId) + { + string resource; + string serviceKey; + + switch (serviceType) + { + case AIServiceType.OpenAI: + resource = "https://platform.openai.com/api-keys"; + serviceKey = "openai"; + break; + case AIServiceType.AzureOpenAI: + resource = "https://azure.microsoft.com/products/ai-services/openai-service"; + serviceKey = "azureopenai"; + break; + case AIServiceType.AzureAIInference: + resource = "https://azure.microsoft.com/products/ai-services/ai-inference"; + serviceKey = "azureaiinference"; + break; + case AIServiceType.Mistral: + resource = "https://console.mistral.ai/account/api-keys"; + serviceKey = "mistral"; + break; + case AIServiceType.Google: + resource = "https://ai.google.dev/"; + serviceKey = "google"; + break; + case AIServiceType.FoundryLocal: + case AIServiceType.ML: + case AIServiceType.Onnx: + case AIServiceType.Ollama: + return null; + default: + return null; + } + + string username = $"PowerToys_AdvancedPaste_PasteAI_{serviceKey}_{NormalizeProviderIdentifier(providerId)}"; + return (resource, username); + } + + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs index 54759b7dc8..7aa6f63b19 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IAICredentialsProvider.cs @@ -4,11 +4,26 @@ namespace AdvancedPaste.Services; +/// <summary> +/// Provides access to AI credentials stored for Advanced Paste scenarios. +/// </summary> public interface IAICredentialsProvider { - bool IsConfigured { get; } + /// <summary> + /// Gets a value indicating whether any credential is configured. + /// </summary> + /// <returns><see langword="true"/> when a non-empty credential exists for the active AI provider.</returns> + bool IsConfigured(); - string Key { get; } + /// <summary> + /// Retrieves the credential for the active AI provider. + /// </summary> + /// <returns>Credential string or <see cref="string.Empty"/> when missing.</returns> + string GetKey(); + /// <summary> + /// Refreshes the cached credential for the active AI provider. + /// </summary> + /// <returns><see langword="true"/> when the credential changed.</returns> bool Refresh(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs deleted file mode 100644 index 75f1df259e..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/ICustomTextTransformService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace AdvancedPaste.Services; - -public interface ICustomTextTransformService -{ - Task<string> TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress<double> progress); -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs new file mode 100644 index 0000000000..d634c13e30 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IKernelRuntimeConfiguration.cs @@ -0,0 +1,27 @@ +// 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.PowerToys.Settings.UI.Library; + +namespace AdvancedPaste.Services; + +/// <summary> +/// Represents runtime information required to configure an AI kernel service. +/// </summary> +public interface IKernelRuntimeConfiguration +{ + AIServiceType ServiceType { get; } + + string ModelName { get; } + + string Endpoint { get; } + + string DeploymentName { get; } + + string ModelPath { get; } + + string SystemPrompt { get; } + + bool ModerationEnabled { get; } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs index e921b21e54..0d753d1ec3 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/KernelServiceBase.cs @@ -5,15 +5,16 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; - using AdvancedPaste.Helpers; using AdvancedPaste.Models; using AdvancedPaste.Models.KernelQueryCache; +using AdvancedPaste.Services.CustomActions; +using AdvancedPaste.Settings; using AdvancedPaste.Telemetry; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Telemetry; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; @@ -21,15 +22,21 @@ using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheService, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : IKernelService +public abstract class KernelServiceBase( + IKernelQueryCacheService queryCacheService, + IPromptModerationService promptModerationService, + IUserSettings userSettings, + ICustomActionTransformService customActionTransformService) : IKernelService { private const string PromptParameterName = "prompt"; + private const string DefaultSystemPrompt = "You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. Call function when necessary to help user finish the transformation task. You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. The user will put in a request to format their clipboard data and you will fulfill it. Do not output anything else besides the reformatted clipboard content."; private readonly IKernelQueryCacheService _queryCacheService = queryCacheService; private readonly IPromptModerationService _promptModerationService = promptModerationService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly IUserSettings _userSettings = userSettings; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; - protected abstract string ModelName { get; } + protected abstract string AdvancedAIModelName { get; } protected abstract PromptExecutionSettings PromptExecutionSettings { get; } @@ -37,6 +44,8 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi protected abstract AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage); + protected abstract IKernelRuntimeConfiguration GetRuntimeConfiguration(); + public async Task<DataPackage> TransformClipboardAsync(string prompt, DataPackageView clipboardData, bool isSavedQuery, CancellationToken cancellationToken, IProgress<double> progress) { Logger.LogTrace(); @@ -58,12 +67,36 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi LogResult(cacheUsed, isSavedQuery, kernel.GetOrAddActionChain(), usage); + var outputPackage = kernel.GetDataPackage(); + var hasUsableData = await outputPackage.GetView().HasUsableDataAsync(); + if (kernel.GetLastError() is Exception ex) { - throw ex; + // If we have an error, but the AI provided a final text response, we can ignore the error (likely a tool failure that the AI handled). + // However, if we have usable data (e.g. from a successful tool call before the error?), we might want to keep it? + // In the case of ImageToText failure, outputPackage is empty (new DataPackage), hasUsableData is false. + // So we check if there is a valid response in the chat history. + var lastMessage = chatHistory.LastOrDefault(); + bool hasAssistantResponse = lastMessage != null && lastMessage.Role == AuthorRole.Assistant && !string.IsNullOrEmpty(lastMessage.Content); + + if (!hasAssistantResponse && !hasUsableData) + { + throw ex; + } + + // If we have a response or data, we log the error but proceed. + Logger.LogWarning($"Kernel operation encountered an error but proceeded with available response/data: {ex.Message}"); } - var outputPackage = kernel.GetDataPackage(); + if (!hasUsableData) + { + var lastMessage = chatHistory.LastOrDefault(); + if (lastMessage != null && lastMessage.Role == AuthorRole.Assistant && !string.IsNullOrEmpty(lastMessage.Content)) + { + outputPackage = DataPackageHelpers.CreateFromText(lastMessage.Content); + kernel.SetDataPackage(outputPackage); + } + } if (!(await outputPackage.GetView().HasUsableDataAsync())) { @@ -132,21 +165,35 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi private async Task<(ChatHistory ChatHistory, AIServiceUsage Usage)> ExecuteAICompletion(Kernel kernel, string prompt, CancellationToken cancellationToken) { + var runtimeConfig = GetRuntimeConfiguration(); + ChatHistory chatHistory = []; - chatHistory.AddSystemMessage(""" - You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. - You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. - The user will put in a request to format their clipboard data and you will fulfill it. - You will not directly see the output clipboard content, and do not need to provide it in the chat. You just need to do the transform operations as needed. - If you are unable to fulfill the request, end with an error message in the language of the user's request. - """); + var systemPrompt = string.IsNullOrWhiteSpace(runtimeConfig.SystemPrompt) ? DefaultSystemPrompt : runtimeConfig.SystemPrompt; + chatHistory.AddSystemMessage(systemPrompt); chatHistory.AddSystemMessage($"Available clipboard formats: {await kernel.GetDataFormatsAsync()}"); - chatHistory.AddUserMessage(prompt); - await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + var imageBytes = await kernel.GetDataPackageView().GetImageAsPngBytesAsync(); + if (imageBytes != null) + { + var collection = new ChatMessageContentItemCollection + { + new TextContent(prompt), + new ImageContent(imageBytes, "image/png"), + }; + chatHistory.AddUserMessage(collection); + } + else + { + chatHistory.AddUserMessage(prompt); + } - var chatResult = await kernel.GetRequiredService<IChatCompletionService>() + if (ShouldModerateAdvancedAI()) + { + await _promptModerationService.ValidateAsync(GetFullPrompt(chatHistory), cancellationToken); + } + + var chatResult = await kernel.GetRequiredService<IChatCompletionService>(AdvancedAIModelName) .GetChatMessageContentAsync(chatHistory, PromptExecutionSettings, kernel, cancellationToken); chatHistory.Add(chatResult); @@ -175,10 +222,26 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return ([], AIServiceUsage.None); } + protected IUserSettings UserSettings => _userSettings; + private void LogResult(bool cacheUsed, bool isSavedQuery, IEnumerable<ActionChainItem> actionChain, AIServiceUsage usage) { - AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new(cacheUsed, isSavedQuery, usage.PromptTokens, usage.CompletionTokens, ModelName, AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); + var runtimeConfig = GetRuntimeConfiguration(); + + AdvancedPasteSemanticKernelFormatEvent telemetryEvent = new( + cacheUsed, + isSavedQuery, + usage.PromptTokens, + usage.CompletionTokens, + AdvancedAIModelName, + runtimeConfig.ServiceType.ToString(), + AdvancedPasteSemanticKernelFormatEvent.FormatActionChain(actionChain)); PowerToysTelemetry.Log.WriteEvent(telemetryEvent); + + // Log endpoint usage + var endpointEvent = new AdvancedPasteEndpointUsageEvent(runtimeConfig.ServiceType, AdvancedAIModelName, isAdvanced: true); + PowerToysTelemetry.Log.WriteEvent(endpointEvent); + var logEvent = new AIServiceFormatEvent(telemetryEvent); Logger.LogDebug($"{nameof(TransformClipboardAsync)} complete; {logEvent.ToJsonString()}"); } @@ -191,20 +254,104 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi return kernelBuilder.Build(); } - private IEnumerable<KernelFunction> GetKernelFunctions() => - from format in Enum.GetValues<PasteFormats>() - let metadata = PasteFormat.MetadataDict[format] - let coreDescription = metadata.KernelFunctionDescription - where !string.IsNullOrEmpty(coreDescription) - let requiresPrompt = metadata.RequiresPrompt - orderby requiresPrompt descending - select KernelFunctionFactory.CreateFromMethod( - method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) - : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), - functionName: format.ToString(), - description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", - parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, - returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + private IEnumerable<KernelFunction> GetKernelFunctions() + { + // Get standard format functions + var standardFunctions = + from format in Enum.GetValues<PasteFormats>() + let metadata = PasteFormat.MetadataDict[format] + let coreDescription = metadata.KernelFunctionDescription + where !string.IsNullOrEmpty(coreDescription) + let requiresPrompt = metadata.RequiresPrompt + orderby requiresPrompt descending + select KernelFunctionFactory.CreateFromMethod( + method: requiresPrompt ? async (Kernel kernel, string prompt) => await ExecutePromptTransformAsync(kernel, format, prompt) + : async (Kernel kernel) => await ExecuteStandardTransformAsync(kernel, format), + functionName: format.ToString(), + description: requiresPrompt ? coreDescription : $"{coreDescription} Puts the result back on the clipboard.", + parameters: requiresPrompt ? [new(PromptParameterName) { Description = "Input instructions to AI", ParameterType = typeof(string) }] : null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + + HashSet<string> usedFunctionNames = new(Enum.GetNames<PasteFormats>(), StringComparer.OrdinalIgnoreCase); + + // Get custom action functions + var customActionFunctions = _userSettings.CustomActions + .Where(customAction => !string.IsNullOrWhiteSpace(customAction.Name) && !string.IsNullOrWhiteSpace(customAction.Prompt)) + .Select(customAction => + { + var sanitizedBaseName = SanitizeFunctionName(customAction.Name); + var functionName = GetUniqueFunctionName(sanitizedBaseName, usedFunctionNames, customAction.Id); + var description = string.IsNullOrWhiteSpace(customAction.Description) + ? $"Runs the \"{customAction.Name}\" custom action." + : customAction.Description; + return KernelFunctionFactory.CreateFromMethod( + method: async (Kernel kernel) => await ExecuteCustomActionAsync(kernel, customAction.Prompt), + functionName: functionName, + description: description, + parameters: null, + returnParameter: new() { Description = "Array of available clipboard formats after operation" }); + }); + + return standardFunctions.Concat(customActionFunctions); + } + + private static string GetUniqueFunctionName(string baseName, HashSet<string> usedFunctionNames, int customActionId) + { + ArgumentNullException.ThrowIfNull(usedFunctionNames); + + var candidate = string.IsNullOrEmpty(baseName) ? "_CustomAction" : baseName; + + if (usedFunctionNames.Add(candidate)) + { + return candidate; + } + + int suffix = 1; + while (true) + { + var nextCandidate = $"{candidate}_{customActionId}_{suffix}"; + if (usedFunctionNames.Add(nextCandidate)) + { + return nextCandidate; + } + + suffix++; + } + } + + private static string SanitizeFunctionName(string name) + { + // Remove invalid characters and ensure the function name is valid for kernel + var sanitized = new string(name.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray()); + + // Ensure it starts with a letter or underscore + if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]) && sanitized[0] != '_') + { + sanitized = "_" + sanitized; + } + + // Ensure it's not empty + return string.IsNullOrEmpty(sanitized) ? "_CustomAction" : sanitized; + } + + private Task<string> ExecuteCustomActionAsync(Kernel kernel, string fixedPrompt) => + ExecuteTransformAsync( + kernel, + new ActionChainItem(PasteFormats.CustomTextTransformation, Arguments: new() { { PromptParameterName, fixedPrompt } }), + async dataPackageView => + { + var imageBytes = await dataPackageView.GetImageAsPngBytesAsync(); + var input = await dataPackageView.GetTextOrHtmlTextAsync(); + + if (string.IsNullOrEmpty(input) && imageBytes == null) + { + // If we have no text and no image, try to get text via OCR or throw if nothing exists + input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); + } + + var result = await _customActionTransformService.TransformAsync(fixedPrompt, input, imageBytes, kernel.GetCancellationToken(), kernel.GetProgress()); + return DataPackageHelpers.CreateFromText(result?.Content ?? string.Empty); + }); private Task<string> ExecutePromptTransformAsync(Kernel kernel, PasteFormats format, string prompt) => ExecuteTransformAsync( @@ -212,15 +359,22 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi new ActionChainItem(format, Arguments: new() { { PromptParameterName, prompt } }), async dataPackageView => { - var input = await dataPackageView.GetTextAsync(); - string output = await GetPromptBasedOutput(format, prompt, input, kernel.GetCancellationToken(), kernel.GetProgress()); + var imageBytes = await dataPackageView.GetImageAsPngBytesAsync(); + var input = await dataPackageView.GetTextOrHtmlTextAsync(); + + if (string.IsNullOrEmpty(input) && imageBytes == null) + { + input = await dataPackageView.GetClipboardTextOrThrowAsync(kernel.GetCancellationToken()); + } + + string output = await GetPromptBasedOutput(format, prompt, input, imageBytes, kernel.GetCancellationToken(), kernel.GetProgress()); return DataPackageHelpers.CreateFromText(output); }); - private async Task<string> GetPromptBasedOutput(PasteFormats format, string prompt, string input, CancellationToken cancellationToken, IProgress<double> progress) => + private async Task<string> GetPromptBasedOutput(PasteFormats format, string prompt, string input, byte[] imageBytes, CancellationToken cancellationToken, IProgress<double> progress) => format switch { - PasteFormats.CustomTextTransformation => await _customTextTransformService.TransformTextAsync(prompt, input, cancellationToken, progress), + PasteFormats.CustomTextTransformation => (await _customActionTransformService.TransformAsync(prompt, input, imageBytes, cancellationToken, progress))?.Content ?? string.Empty, _ => throw new ArgumentException($"Unsupported format {format} for prompt transform", nameof(format)), }; @@ -281,4 +435,9 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi var usageString = usage.HasUsage ? $" [{usage}]" : string.Empty; return $"-> {role}: {redactedContent}{usageString}"; } + + protected virtual bool ShouldModerateAdvancedAI() + { + return false; + } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs deleted file mode 100644 index b6aa156b9d..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/CustomTextTransformService.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -using AdvancedPaste.Helpers; -using AdvancedPaste.Models; -using AdvancedPaste.Telemetry; -using Azure; -using Azure.AI.OpenAI; -using ManagedCommon; -using Microsoft.PowerToys.Telemetry; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class CustomTextTransformService(IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService) : ICustomTextTransformService -{ - private const string ModelName = "gpt-3.5-turbo-instruct"; - - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - private readonly IPromptModerationService _promptModerationService = promptModerationService; - - private async Task<Completions> GetAICompletionAsync(string systemInstructions, string userMessage, CancellationToken cancellationToken) - { - var fullPrompt = systemInstructions + "\n\n" + userMessage; - - await _promptModerationService.ValidateAsync(fullPrompt, cancellationToken); - - OpenAIClient azureAIClient = new(_aiCredentialsProvider.Key); - - var response = await azureAIClient.GetCompletionsAsync( - new() - { - DeploymentName = ModelName, - Prompts = - { - fullPrompt, - }, - Temperature = 0.01F, - MaxTokens = 2000, - }, - cancellationToken); - - if (response.Value.Choices[0].FinishReason == "length") - { - Logger.LogDebug("Cut off due to length constraints"); - } - - return response; - } - - public async Task<string> TransformTextAsync(string prompt, string inputText, CancellationToken cancellationToken, IProgress<double> progress) - { - if (string.IsNullOrWhiteSpace(prompt)) - { - return string.Empty; - } - - if (string.IsNullOrWhiteSpace(inputText)) - { - Logger.LogWarning("Clipboard has no usable text data"); - return string.Empty; - } - - string systemInstructions = -$@"You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. -Do not output anything else besides the reformatted clipboard content."; - - string userMessage = -$@"User instructions: -{prompt} - -Clipboard Content: -{inputText} - -Output: -"; - - try - { - var response = await GetAICompletionAsync(systemInstructions, userMessage, cancellationToken); - - var usage = response.Usage; - AdvancedPasteGenerateCustomFormatEvent telemetryEvent = new(usage.PromptTokens, usage.CompletionTokens, ModelName); - PowerToysTelemetry.Log.WriteEvent(telemetryEvent); - var logEvent = new AIServiceFormatEvent(telemetryEvent); - - Logger.LogDebug($"{nameof(TransformTextAsync)} complete; {logEvent.ToJsonString()}"); - - return response.Choices[0].Text; - } - catch (Exception ex) - { - Logger.LogError($"{nameof(TransformTextAsync)} failed", ex); - - AdvancedPasteGenerateCustomErrorEvent errorEvent = new(ex is PasteActionModeratedException ? PasteActionModeratedException.ErrorDescription : ex.Message); - PowerToysTelemetry.Log.WriteEvent(errorEvent); - - if (ex is PasteActionException or OperationCanceledException) - { - throw; - } - else - { - throw new PasteActionException(ErrorHelpers.TranslateErrorText((ex as RequestFailedException)?.Status ?? -1), ex); - } - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs deleted file mode 100644 index b19a6d51cb..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/KernelService.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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 AdvancedPaste.Models; -using Azure.AI.OpenAI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Connectors.OpenAI; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class KernelService(IKernelQueryCacheService queryCacheService, IAICredentialsProvider aiCredentialsProvider, IPromptModerationService promptModerationService, ICustomTextTransformService customTextTransformService) : - KernelServiceBase(queryCacheService, promptModerationService, customTextTransformService) -{ - private readonly IAICredentialsProvider _aiCredentialsProvider = aiCredentialsProvider; - - protected override string ModelName => "gpt-4o"; - - protected override PromptExecutionSettings PromptExecutionSettings => - new OpenAIPromptExecutionSettings() - { - ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions, - Temperature = 0.01, - }; - - protected override void AddChatCompletionService(IKernelBuilder kernelBuilder) => kernelBuilder.AddOpenAIChatCompletion(ModelName, _aiCredentialsProvider.Key); - - protected override AIServiceUsage GetAIServiceUsage(ChatMessageContent chatMessage) => - chatMessage.Metadata?.GetValueOrDefault("Usage") is CompletionsUsage completionsUsage - ? new(PromptTokens: completionsUsage.PromptTokens, CompletionTokens: completionsUsage.CompletionTokens) - : AIServiceUsage.None; -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs index 0ca15e4161..2668300526 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/PromptModerationService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services; using ManagedCommon; using OpenAI.Moderations; @@ -23,7 +24,16 @@ public sealed class PromptModerationService(IAICredentialsProvider aiCredentials { try { - ModerationClient moderationClient = new(ModelName, _aiCredentialsProvider.Key); + _aiCredentialsProvider.Refresh(); + var apiKey = _aiCredentialsProvider.GetKey()?.Trim() ?? string.Empty; + + if (string.IsNullOrEmpty(apiKey)) + { + Logger.LogWarning("Skipping OpenAI moderation because no credential is configured."); + return; + } + + ModerationClient moderationClient = new(ModelName, apiKey); var moderationClientResult = await moderationClient.ClassifyTextAsync(fullPrompt, cancellationToken); var moderationResult = moderationClientResult.Value; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs deleted file mode 100644 index 169c1c2422..0000000000 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/OpenAI/VaultCredentialsProvider.cs +++ /dev/null @@ -1,37 +0,0 @@ -// 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 Windows.Security.Credentials; - -namespace AdvancedPaste.Services.OpenAI; - -public sealed class VaultCredentialsProvider : IAICredentialsProvider -{ - public VaultCredentialsProvider() => Refresh(); - - public string Key { get; private set; } - - public bool IsConfigured => !string.IsNullOrEmpty(Key); - - public bool Refresh() - { - var oldKey = Key; - Key = LoadKey(); - return oldKey != Key; - } - - private static string LoadKey() - { - try - { - return new PasswordVault().Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey")?.Password ?? string.Empty; - } - catch (Exception) - { - return string.Empty; - } - } -} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs index 5d6740977b..ff64a5ad83 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -8,15 +8,16 @@ using System.Threading.Tasks; using AdvancedPaste.Helpers; using AdvancedPaste.Models; +using AdvancedPaste.Services.CustomActions; using Microsoft.PowerToys.Telemetry; using Windows.ApplicationModel.DataTransfer; namespace AdvancedPaste.Services; -public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTextTransformService customTextTransformService) : IPasteFormatExecutor +public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor { private readonly IKernelService _kernelService = kernelService; - private readonly ICustomTextTransformService _customTextTransformService = customTextTransformService; + private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService; public async Task<DataPackage> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress<double> progress) { @@ -36,7 +37,7 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomTex pasteFormat.Format switch { PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress), - PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText(await _customTextTransformService.TransformTextAsync(pasteFormat.Prompt, await clipboardData.GetTextAsync(), cancellationToken, progress)), + PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(pasteFormat.Prompt, await clipboardData.GetTextOrHtmlTextAsync(), await clipboardData.GetImageAsPngBytesAsync(), cancellationToken, progress))?.Content ?? string.Empty), _ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress), }); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw index 30b46190e3..f365778321 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw +++ b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw @@ -144,14 +144,66 @@ <data name="PasteActionModerated" xml:space="preserve"> <value>The paste operation was moderated due to sensitive content. Please try another query.</value> </data> - <data name="ClipboardHistoryButton.Text" xml:space="preserve"> + <data name="ClipboardHistoryButtonToolTip.Text" xml:space="preserve"> <value>Clipboard history</value> </data> <data name="ClipboardHistoryButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> <value>Clipboard history</value> </data> + <data name="AIProviderButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>AI provider selector</value> + </data> + <data name="AIProviderButtonTooltipEmpty" xml:space="preserve"> + <value>Select an AI provider</value> + </data> + <data name="AIProviderButtonTooltipFormat" xml:space="preserve"> + <value>Active provider: {0}</value> + </data> + <data name="AIProvidersFlyoutHeader.Text" xml:space="preserve"> + <value>Configured models</value> + </data> + <data name="AIProvidersEmptyText.Text" xml:space="preserve"> + <value>No models configured</value> + </data> + <data name="AIProvidersManageButtonContent.Content" xml:space="preserve"> + <value>Configure models in Settings</value> + </data> <data name="ClipboardHistoryImage" xml:space="preserve"> <value>Image data</value> + <comment>Label used to represent an image in the clipboard history</comment> + </data> + <data name="ClipboardPreviewCategoryText" xml:space="preserve"> + <value>Text</value> + </data> + <data name="ClipboardPreviewCategoryImage" xml:space="preserve"> + <value>Image</value> + </data> + <data name="ClipboardPreviewCategoryAudio" xml:space="preserve"> + <value>Audio</value> + </data> + <data name="ClipboardPreviewCategoryVideo" xml:space="preserve"> + <value>Video</value> + </data> + <data name="ClipboardPreviewCategoryFile" xml:space="preserve"> + <value>File</value> + </data> + <data name="ClipboardPreviewCategoryUnknown" xml:space="preserve"> + <value>Clipboard</value> + </data> + <data name="ClipboardPreviewCopiedJustNow" xml:space="preserve"> + <value>Copied just now</value> + </data> + <data name="ClipboardPreviewCopiedSeconds" xml:space="preserve"> + <value>Copied {0} sec ago</value> + </data> + <data name="ClipboardPreviewCopiedMinutes" xml:space="preserve"> + <value>Copied {0} min ago</value> + </data> + <data name="ClipboardPreviewCopiedHours" xml:space="preserve"> + <value>Copied {0} hr ago</value> + </data> + <data name="ClipboardPreviewCopiedDays" xml:space="preserve"> + <value>Copied {0} day ago</value> </data> <data name="ClipboardHistoryItemMoreOptionsButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> <value>More options</value> @@ -194,15 +246,19 @@ </data> <data name="TranscodeToMp3" xml:space="preserve"> <value>Transcode to .mp3</value> - </data> + <comment>Option to transcode audio files to MP3 format</comment> + </data> <data name="TranscodeToMp4" xml:space="preserve"> <value>Transcode to .mp4 (H.264/AAC)</value> + <comment>Option to transcode video files to MP4 format with H.264 video codec and AAC audio codec</comment> </data> <data name="TranscodeErrorGeneral" xml:space="preserve"> <value>An error occurred while transcoding media file</value> + <comment>Error message displayed when media conversion fails for an unknown or general reason</comment> </data> <data name="TranscodeErrorUnsupportedCodec" xml:space="preserve"> <value>The media file contains an unsupported codec</value> + <comment>Error message displayed when media conversion fails due to an unsupported codec in the source file</comment> </data> <data name="PasteButtonAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> <value>Paste</value> @@ -267,11 +323,11 @@ <data name="NextResultBtnAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> <value>Next result</value> </data> - <data name="PrivacyLink.Text" xml:space="preserve"> - <value>OpenAI Privacy</value> + <data name="PrivacyLink.Content" xml:space="preserve"> + <value>Privacy Policy</value> </data> - <data name="TermsLink.Text" xml:space="preserve"> - <value>OpenAI Terms</value> + <data name="TermsLink.Content" xml:space="preserve"> + <value>Terms</value> </data> <data name="OpenAIGpoDisabled" xml:space="preserve"> <value>To custom with AI is disabled by your organization</value> @@ -282,4 +338,38 @@ <data name="PasteAsFile_FilePrefix" xml:space="preserve"> <value>PowerToys_Paste_</value> </data> + <data name="Relative_JustNow" xml:space="preserve"> + <value>Just now</value> + </data> + <data name="Relative_MinuteAgo" xml:space="preserve"> + <value>1 minute ago</value> + </data> + <data name="Relative_MinutesAgo_Format" xml:space="preserve"> + <value>{0} minutes ago</value> + </data> + <data name="Relative_Today_TimeFormat" xml:space="preserve"> + <value>Today, {0}</value> + </data> + <data name="Relative_Yesterday_TimeFormat" xml:space="preserve"> + <value>Yesterday, {0}</value> + </data> + <data name="Relative_Weekday_TimeFormat" xml:space="preserve"> + <value>{0}, {1}</value> + <comment>(e.g., “Wednesday, 17:05”)</comment> + </data> + <data name="Relative_Date_TimeFormat" xml:space="preserve"> + <value>{0}, {1}</value> + <comment>(e.g., "10/20/2025, 17:05" in the user's locale)</comment> + </data> + <data name="CustomEndpointWarning" xml:space="preserve"> + <value>You are using a custom endpoint. Verify all answers.</value> + </data> + <data name="LocalModelBadge.Text" xml:space="preserve"> + <value>Local</value> + <comment>Badge label displayed next to local AI model providers (e.g., Ollama, Foundry Local) to indicate the model runs locally</comment> + </data> + <data name="FoundryLocal_UnableToLoadModel" xml:space="preserve"> + <value>Unable to load Foundry Local model: {0}</value> + <comment>{0} is the model identifier. Do not translate {0}.</comment> + </data> </root> \ No newline at end of file diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteClipboardItemClicked.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteClipboardItemClicked.cs index c26481f86d..512edb1393 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteClipboardItemClicked.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteClipboardItemClicked.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace AdvancedPaste.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AdvancedPasteClipboardItemClicked : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteClipboardItemDeletedEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteClipboardItemDeletedEvent.cs index ab7cf26781..e870036449 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteClipboardItemDeletedEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteClipboardItemDeletedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace AdvancedPaste.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AdvancedPasteClipboardItemDeletedEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomActionErrorEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomActionErrorEvent.cs new file mode 100644 index 0000000000..06f45a98ae --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomActionErrorEvent.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace AdvancedPaste.Telemetry; + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public sealed class AdvancedPasteCustomActionErrorEvent : EventBase, IEvent +{ + public AdvancedPasteCustomActionErrorEvent(AIServiceType providerType, string modelName, int statusCode, string error) + { + ProviderType = providerType.ToString(); + ModelName = modelName; + StatusCode = statusCode; + Error = error; + } + + public string ProviderType { get; set; } + + public string ModelName { get; set; } + + public int StatusCode { get; set; } + + public string Error { get; set; } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomFormatOutputThumbUpDownEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomFormatOutputThumbUpDownEvent.cs index 3a49a8fa2b..4caf6648f7 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomFormatOutputThumbUpDownEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteCustomFormatOutputThumbUpDownEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace AdvancedPaste.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AdvancedPasteCustomFormatOutputThumbUpDownEvent : EventBase, IEvent { public bool PositiveFeedback { get; set; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs new file mode 100644 index 0000000000..671f6a7b9c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteEndpointUsageEvent.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace AdvancedPaste.Telemetry; + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class AdvancedPasteEndpointUsageEvent : EventBase, IEvent +{ + /// <summary> + /// Gets or sets the AI provider type (e.g., OpenAI, AzureOpenAI, Google). + /// </summary> + public string ProviderType { get; set; } + + /// <summary> + /// Gets or sets the configured model name. + /// </summary> + public string ModelName { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the advanced AI pipeline was used. + /// </summary> + public bool IsAdvanced { get; set; } + + /// <summary> + /// Gets or sets the total duration in milliseconds, or -1 if unavailable. + /// </summary> + public int DurationMs { get; set; } + + public AdvancedPasteEndpointUsageEvent(AIServiceType providerType, string modelName, bool isAdvanced, int durationMs = -1) + { + ProviderType = providerType.ToString(); + ModelName = modelName; + IsAdvanced = isAdvanced; + DurationMs = durationMs; + } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteFormatClickedEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteFormatClickedEvent.cs index aa4761e705..61def2340c 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteFormatClickedEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteFormatClickedEvent.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using AdvancedPaste.Models; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -11,6 +11,7 @@ using Microsoft.PowerToys.Telemetry.Events; namespace AdvancedPaste.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AdvancedPasteFormatClickedEvent : EventBase, IEvent { public PasteFormats PasteFormat { get; set; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteGenerateCustomErrorEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteGenerateCustomErrorEvent.cs index 1fbce2032d..226462ce7f 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteGenerateCustomErrorEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteGenerateCustomErrorEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace AdvancedPaste.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AdvancedPasteGenerateCustomErrorEvent : EventBase, IEvent { public string Error { get; set; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteGenerateCustomFormatEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteGenerateCustomFormatEvent.cs index b615a0425d..baebd0ebed 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteGenerateCustomFormatEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteGenerateCustomFormatEvent.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; using Microsoft.PowerToys.Telemetry; @@ -10,6 +11,7 @@ using Microsoft.PowerToys.Telemetry.Events; namespace AdvancedPaste.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AdvancedPasteGenerateCustomFormatEvent : EventBase, IEvent { public int PromptTokens { get; set; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteInAppKeyboardShortcutEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteInAppKeyboardShortcutEvent.cs index f909853cfe..710d7276c0 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteInAppKeyboardShortcutEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteInAppKeyboardShortcutEvent.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using AdvancedPaste.Models; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -11,6 +11,7 @@ using Microsoft.PowerToys.Telemetry.Events; namespace AdvancedPaste.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AdvancedPasteInAppKeyboardShortcutEvent : EventBase, IEvent { public PasteFormats PasteFormat { get; set; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelErrorEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelErrorEvent.cs index 425cf5dd4e..534185c11d 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelErrorEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelErrorEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace AdvancedPaste.Telemetry; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AdvancedPasteSemanticKernelErrorEvent(string error) : EventBase, IEvent { public string Error { get; set; } = error; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs index 75467dae70..53b4008782 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Telemetry/AdvancedPasteSemanticKernelFormatEvent.cs @@ -3,9 +3,9 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; using System.Linq; - using AdvancedPaste.Models; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,7 +13,8 @@ using Microsoft.PowerToys.Telemetry.Events; namespace AdvancedPaste.Telemetry; [EventData] -public class AdvancedPasteSemanticKernelFormatEvent(bool cacheUsed, bool isSavedQuery, int promptTokens, int completionTokens, string modelName, string actionChain) : EventBase, IEvent +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class AdvancedPasteSemanticKernelFormatEvent(bool cacheUsed, bool isSavedQuery, int promptTokens, int completionTokens, string modelName, string providerType, string actionChain) : EventBase, IEvent { public static string FormatActionChain(IEnumerable<ActionChainItem> actionChain) => FormatActionChain(actionChain.Select(item => item.Format)); @@ -29,6 +30,8 @@ public class AdvancedPasteSemanticKernelFormatEvent(bool cacheUsed, bool isSaved public string ModelName { get; set; } = modelName; + public string ProviderType { get; set; } = providerType; + /// <summary> /// Gets or sets a comma-separated list of paste formats used - in the same order they were executed. /// Conceptually an array but formatted this way to work around https://github.com/dotnet/runtime/issues/10428 diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index 688c3047e2..b474b8215a 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Globalization; using System.IO.Abstractions; using System.Linq; using System.Runtime.InteropServices; @@ -22,6 +23,8 @@ using CommunityToolkit.Mvvm.Input; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.Win32; using Windows.ApplicationModel.DataTransfer; using Windows.System; @@ -37,12 +40,21 @@ namespace AdvancedPaste.ViewModels private readonly DispatcherTimer _clipboardTimer; private readonly IUserSettings _userSettings; private readonly IPasteFormatExecutor _pasteFormatExecutor; - private readonly IAICredentialsProvider _aiCredentialsProvider; + private readonly IAICredentialsProvider _credentialsProvider; private CancellationTokenSource _pasteActionCancellationTokenSource; + private string _currentClipboardHistoryId; + private uint _lastClipboardSequenceNumber; + private DateTimeOffset? _currentClipboardTimestamp; + private ClipboardFormat _lastClipboardFormats = ClipboardFormat.None; + private bool _clipboardHistoryUnavailableLogged; + public DataPackageView ClipboardData { get; set; } + [ObservableProperty] + private ClipboardItem _currentClipboardItem; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] [NotifyPropertyChangedFor(nameof(ClipboardHasData))] @@ -52,12 +64,21 @@ namespace AdvancedPaste.ViewModels private ClipboardFormat _availableClipboardFormats; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowClipboardHistoryButton))] private bool _clipboardHistoryEnabled; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CustomAIUnavailableErrorText))] [NotifyPropertyChangedFor(nameof(IsCustomAIServiceEnabled))] [NotifyPropertyChangedFor(nameof(IsCustomAIAvailable))] + [NotifyPropertyChangedFor(nameof(AllowedAIProviders))] + [NotifyPropertyChangedFor(nameof(ActiveAIProvider))] + [NotifyPropertyChangedFor(nameof(ActiveAIProviderTooltip))] + [NotifyPropertyChangedFor(nameof(TermsLinkUri))] + [NotifyPropertyChangedFor(nameof(PrivacyLinkUri))] + [NotifyPropertyChangedFor(nameof(HasTermsLink))] + [NotifyPropertyChangedFor(nameof(HasPrivacyLink))] + [NotifyPropertyChangedFor(nameof(HasLegalLinks))] private bool _isAllowedByGPO; [ObservableProperty] @@ -79,19 +100,146 @@ namespace AdvancedPaste.ViewModels public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = []; - public bool IsCustomAIServiceEnabled => IsAllowedByGPO && _aiCredentialsProvider.IsConfigured; + public bool IsCustomAIServiceEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + // Check if there are any allowed providers + if (!AllowedAIProviders.Any()) + { + return false; + } + + // We should handle the IsAIEnabled logic in settings, don't check again here. + // If setting says yes, and here should pass check, and if error happens, it happens. + return true; + } + } public bool IsCustomAIAvailable => IsCustomAIServiceEnabled && ClipboardHasDataForCustomAI; - public bool IsAdvancedAIEnabled => IsCustomAIServiceEnabled && _userSettings.IsAdvancedAIEnabled; + public bool IsAdvancedAIEnabled + { + get + { + if (!IsAllowedByGPO || !_userSettings.IsAIEnabled) + { + return false; + } + + if (!TryResolveAdvancedAIProvider(out _)) + { + return false; + } + + return _credentialsProvider.IsConfigured(); + } + } + + public ObservableCollection<PasteAIProviderDefinition> AIProviders => _userSettings?.PasteAIConfiguration?.Providers ?? new ObservableCollection<PasteAIProviderDefinition>(); + + public IEnumerable<PasteAIProviderDefinition> AllowedAIProviders + { + get + { + var providers = AIProviders; + if (providers is null || providers.Count == 0) + { + return Enumerable.Empty<PasteAIProviderDefinition>(); + } + + return providers.Where(IsProviderAllowedByGPO); + } + } + + public PasteAIProviderDefinition ActiveAIProvider + { + get + { + var provider = _userSettings?.PasteAIConfiguration?.ActiveProvider; + if (provider is null || !IsProviderAllowedByGPO(provider)) + { + return null; + } + + return provider; + } + } + + public string ActiveAIProviderTooltip + { + get + { + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + var provider = ActiveAIProvider; + + if (provider is null) + { + return resourceLoader.GetString("AIProviderButtonTooltipEmpty"); + } + + var format = resourceLoader.GetString("AIProviderButtonTooltipFormat"); + var displayName = provider.DisplayName; + + if (!string.IsNullOrEmpty(format)) + { + return string.Format(CultureInfo.CurrentCulture, format, displayName); + } + + return displayName; + } + } + + private AIServiceTypeMetadata GetActiveProviderMetadata() + { + var provider = ActiveAIProvider ?? AllowedAIProviders.FirstOrDefault(); + var serviceType = provider?.ServiceTypeKind ?? AIServiceType.OpenAI; + return AIServiceTypeRegistry.GetMetadata(serviceType); + } + + public Uri TermsLinkUri + { + get + { + var metadata = GetActiveProviderMetadata(); + return metadata.HasTermsLink ? metadata.TermsUri : null; + } + } + + public Uri PrivacyLinkUri + { + get + { + var metadata = GetActiveProviderMetadata(); + return metadata.HasPrivacyLink ? metadata.PrivacyUri : null; + } + } + + public bool HasTermsLink => GetActiveProviderMetadata().HasTermsLink; + + public bool HasPrivacyLink => GetActiveProviderMetadata().HasPrivacyLink; + + public bool HasLegalLinks => HasTermsLink || HasPrivacyLink; public bool ClipboardHasData => AvailableClipboardFormats != ClipboardFormat.None; public bool ClipboardHasDataForCustomAI => PasteFormat.SupportsClipboardFormats(CustomAIFormat, AvailableClipboardFormats); + public bool ShowClipboardPreview => _userSettings.EnableClipboardPreview; + + public bool ShowClipboardHistoryButton => ClipboardHistoryEnabled; + public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress); - private PasteFormats CustomAIFormat => _userSettings.IsAdvancedAIEnabled ? PasteFormats.KernelQuery : PasteFormats.CustomTextTransformation; + private PasteFormats CustomAIFormat => + _userSettings.IsAIEnabled && TryResolveAdvancedAIProvider(out _) + ? PasteFormats.KernelQuery + : PasteFormats.CustomTextTransformation; private bool Visible { @@ -110,9 +258,9 @@ namespace AdvancedPaste.ViewModels public event EventHandler PreviewRequested; - public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider aiCredentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) + public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor) { - _aiCredentialsProvider = aiCredentialsProvider; + _credentialsProvider = credentialsProvider; _userSettings = userSettings; _pasteFormatExecutor = pasteFormatExecutor; @@ -130,6 +278,7 @@ namespace AdvancedPaste.ViewModels _clipboardTimer.Start(); RefreshPasteFormats(); + UpdateAIProviderActiveFlags(); _userSettings.Changed += UserSettings_Changed; PropertyChanged += (_, e) => { @@ -158,15 +307,21 @@ namespace AdvancedPaste.ViewModels if (Visible) { await ReadClipboardAsync(); - UpdateAllowedByGPO(); } } private void UserSettings_Changed(object sender, EventArgs e) { + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); OnPropertyChanged(nameof(ClipboardHasDataForCustomAI)); OnPropertyChanged(nameof(IsCustomAIAvailable)); OnPropertyChanged(nameof(IsAdvancedAIEnabled)); + OnPropertyChanged(nameof(AIProviders)); + OnPropertyChanged(nameof(AllowedAIProviders)); + OnPropertyChanged(nameof(ShowClipboardPreview)); + + NotifyActiveProviderChanged(); EnqueueRefreshPasteFormats(); } @@ -192,6 +347,33 @@ namespace AdvancedPaste.ViewModels private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) => PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled); + private void UpdateAIProviderActiveFlags() + { + var providers = _userSettings?.PasteAIConfiguration?.Providers; + if (providers is not null) + { + var activeId = ActiveAIProvider?.Id; + + foreach (var provider in providers) + { + provider.IsActive = !string.IsNullOrEmpty(activeId) && string.Equals(provider.Id, activeId, StringComparison.OrdinalIgnoreCase); + } + } + + NotifyActiveProviderChanged(); + } + + private void NotifyActiveProviderChanged() + { + OnPropertyChanged(nameof(ActiveAIProvider)); + OnPropertyChanged(nameof(ActiveAIProviderTooltip)); + OnPropertyChanged(nameof(TermsLinkUri)); + OnPropertyChanged(nameof(PrivacyLinkUri)); + OnPropertyChanged(nameof(HasTermsLink)); + OnPropertyChanged(nameof(HasPrivacyLink)); + OnPropertyChanged(nameof(HasLegalLinks)); + } + private void RefreshPasteFormats() { var ctrlString = ResourceLoaderInstance.ResourceLoader.GetString("CtrlKey"); @@ -253,8 +435,104 @@ namespace AdvancedPaste.ViewModels return; } - ClipboardData = Clipboard.GetContent(); - AvailableClipboardFormats = await ClipboardData.GetAvailableFormatsAsync(); + try + { + ClipboardData = Clipboard.GetContent(); + AvailableClipboardFormats = ClipboardData != null ? await ClipboardData.GetAvailableFormatsAsync() : ClipboardFormat.None; + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + // Logger.LogDebug("Failed to read clipboard content", ex); + ClipboardData = null; + AvailableClipboardFormats = ClipboardFormat.None; + } + + await UpdateClipboardPreviewAsync(); + } + + private async Task UpdateClipboardPreviewAsync() + { + if (ClipboardData is null || !ClipboardHasData) + { + ResetClipboardPreview(); + _currentClipboardHistoryId = null; + _lastClipboardSequenceNumber = 0; + _currentClipboardTimestamp = null; + _lastClipboardFormats = ClipboardFormat.None; + return; + } + + var formatsChanged = AvailableClipboardFormats != _lastClipboardFormats; + _lastClipboardFormats = AvailableClipboardFormats; + + var clipboardChanged = await UpdateClipboardTimestampAsync(formatsChanged); + + // Create ClipboardItem directly from current clipboard data using helper + CurrentClipboardItem = await ClipboardItemHelper.CreateFromCurrentClipboardAsync( + ClipboardData, + AvailableClipboardFormats, + _currentClipboardTimestamp, + clipboardChanged ? null : CurrentClipboardItem?.Image); + } + + private async Task<bool> UpdateClipboardTimestampAsync(bool formatsChanged) + { + bool clipboardChanged = formatsChanged; + + var currentSequenceNumber = NativeMethods.GetClipboardSequenceNumber(); + if (_lastClipboardSequenceNumber != currentSequenceNumber) + { + clipboardChanged = true; + _lastClipboardSequenceNumber = currentSequenceNumber; + } + + if (Clipboard.IsHistoryEnabled()) + { + try + { + var historyItems = await Clipboard.GetHistoryItemsAsync(); + if (historyItems.Status == ClipboardHistoryItemsResultStatus.Success && historyItems.Items.Count > 0) + { + var latest = historyItems.Items[0]; + if (_currentClipboardHistoryId != latest.Id) + { + clipboardChanged = true; + _currentClipboardHistoryId = latest.Id; + } + + _currentClipboardTimestamp = latest.Timestamp; + _clipboardHistoryUnavailableLogged = false; + return clipboardChanged; + } + } + catch (Exception ex) + { + if (!_clipboardHistoryUnavailableLogged) + { + Logger.LogDebug("Failed to access clipboard history timestamp", ex.Message); + _clipboardHistoryUnavailableLogged = true; + } + } + } + + if (!_currentClipboardTimestamp.HasValue || clipboardChanged) + { + _currentClipboardTimestamp = DateTimeOffset.Now; + clipboardChanged = true; + } + + return clipboardChanged; + } + + private void ResetClipboardPreview() + { + // Clear to avoid leaks due to Garbage Collection not clearing the bitmap from memory + if (CurrentClipboardItem?.Image is not null) + { + CurrentClipboardItem.Image.ClearValue(BitmapImage.UriSourceProperty); + } + + CurrentClipboardItem = null; } public async Task OnShowAsync() @@ -270,7 +548,7 @@ namespace AdvancedPaste.ViewModels _dispatcherQueue.TryEnqueue(() => { - GetMainWindow()?.FinishLoading(_aiCredentialsProvider.IsConfigured); + GetMainWindow()?.FinishLoading(IsCustomAIServiceEnabled); OnPropertyChanged(nameof(InputTxtBoxPlaceholderText)); OnPropertyChanged(nameof(CustomAIUnavailableErrorText)); OnPropertyChanged(nameof(IsCustomAIServiceEnabled)); @@ -319,7 +597,7 @@ namespace AdvancedPaste.ViewModels return ResourceLoaderInstance.ResourceLoader.GetString("OpenAIGpoDisabled"); } - if (!_aiCredentialsProvider.IsConfigured) + if (!IsCustomAIServiceEnabled) { return ResourceLoaderInstance.ResourceLoader.GetString("OpenAINotConfigured"); } @@ -383,7 +661,7 @@ namespace AdvancedPaste.ViewModels [RelayCommand] public void OpenSettings() { - SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.AdvancedPaste, true); + SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.AdvancedPaste); GetMainWindow()?.Close(); } @@ -515,11 +793,113 @@ namespace AdvancedPaste.ViewModels IsAllowedByGPO = PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOnlineAIModelsValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled; } + private bool IsProviderAllowedByGPO(PasteAIProviderDefinition provider) + { + if (provider is null) + { + return false; + } + + var serviceType = provider.ServiceType.ToAIServiceType(); + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + + // Check global online AI GPO for online services + if (metadata.IsOnlineService && !IsAllowedByGPO) + { + return false; + } + + // Check individual endpoint GPO + return serviceType switch + { + AIServiceType.OpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureOpenAI => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureOpenAIValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.AzureAIInference => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteAzureAIInferenceValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Mistral => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteMistralValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Google => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteGoogleValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.Ollama => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteOllamaValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + AIServiceType.FoundryLocal => PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPasteFoundryLocalValue() != PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + _ => true, // Allow unknown types by default + }; + } + + private bool TryResolveAdvancedAIProvider(out PasteAIProviderDefinition provider) + { + provider = null; + + var configuration = _userSettings?.PasteAIConfiguration; + if (configuration is null) + { + return false; + } + + var activeProvider = configuration.ActiveProvider; + if (IsAdvancedAIProvider(activeProvider)) + { + provider = activeProvider; + return true; + } + + if (activeProvider is not null) + { + return false; + } + + var fallback = configuration.Providers?.FirstOrDefault(IsAdvancedAIProvider); + if (fallback is not null) + { + provider = fallback; + return true; + } + + return false; + } + + private static bool IsAdvancedAIProvider(PasteAIProviderDefinition provider) + { + return provider is not null && provider.EnableAdvancedAI && SupportsAdvancedAI(provider.ServiceTypeKind); + } + + private static bool SupportsAdvancedAI(AIServiceType serviceType) + { + return serviceType is AIServiceType.OpenAI + or AIServiceType.AzureOpenAI; + } + private bool UpdateOpenAIKey() { UpdateAllowedByGPO(); - return IsAllowedByGPO && _aiCredentialsProvider.Refresh(); + return _credentialsProvider.Refresh(); + } + + [RelayCommand] + private async Task SetActiveProviderAsync(PasteAIProviderDefinition provider) + { + if (provider is null || string.IsNullOrEmpty(provider.Id)) + { + return; + } + + if (string.Equals(ActiveAIProvider?.Id, provider.Id, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + await _userSettings.SetActiveAIProviderAsync(provider.Id); + } + catch (Exception ex) + { + Logger.LogError("Failed to activate AI provider", ex); + return; + } + + UpdateAIProviderActiveFlags(); + OnPropertyChanged(nameof(AIProviders)); + NotifyActiveProviderChanged(); + EnqueueRefreshPasteFormats(); } public async Task CancelPasteActionAsync() diff --git a/src/modules/AdvancedPaste/AdvancedPaste/app.manifest b/src/modules/AdvancedPaste/AdvancedPaste/app.manifest index 808075be8e..3f0eeb537f 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/app.manifest +++ b/src/modules/AdvancedPaste/AdvancedPaste/app.manifest @@ -16,7 +16,7 @@ 1) Per-Monitor for >= Windows 10 Anniversary Update 2) System < Windows 10 Anniversary Update --> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> </assembly> \ No newline at end of file diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj index 083aa868d3..9f7675799e 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/AdvancedPasteModuleInterface.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h AdvancedPaste.base.rc AdvancedPaste.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted ..\..\..\..\tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h AdvancedPaste.base.rc AdvancedPaste.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> @@ -12,10 +13,9 @@ <ProjectName>AdvancedPasteModuleInterface</ProjectName> <TargetName>PowerToys.AdvancedPasteModuleInterface</TargetName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -27,12 +27,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -56,10 +56,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -73,15 +73,15 @@ </None> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 896b362735..17205687a5 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -15,8 +15,12 @@ #include <common/utils/logger_helper.h> #include <common/utils/winapi_error.h> #include <common/utils/gpo.h> +#include <common/utils/EventWaiter.h> -#include <winrt/Windows.Security.Credentials.h> +#include <algorithm> +#include <cwctype> +#include <chrono> +#include <thread> #include <vector> BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) @@ -54,12 +58,15 @@ namespace const wchar_t JSON_KEY_ADVANCED_PASTE_UI_HOTKEY[] = L"advanced-paste-ui-hotkey"; const wchar_t JSON_KEY_PASTE_AS_MARKDOWN_HOTKEY[] = L"paste-as-markdown-hotkey"; const wchar_t JSON_KEY_PASTE_AS_JSON_HOTKEY[] = L"paste-as-json-hotkey"; - const wchar_t JSON_KEY_IS_ADVANCED_AI_ENABLED[] = L"IsAdvancedAIEnabled"; + const wchar_t JSON_KEY_IS_AI_ENABLED[] = L"IsAIEnabled"; + const wchar_t JSON_KEY_IS_OPEN_AI_ENABLED[] = L"IsOpenAIEnabled"; const wchar_t JSON_KEY_SHOW_CUSTOM_PREVIEW[] = L"ShowCustomPreview"; + const wchar_t JSON_KEY_AUTO_COPY_SELECTION_CUSTOM_ACTION[] = L"AutoCopySelectionForCustomActionHotkey"; + const wchar_t JSON_KEY_PASTE_AI_CONFIGURATION[] = L"paste-ai-configuration"; + const wchar_t JSON_KEY_PROVIDERS[] = L"providers"; + const wchar_t JSON_KEY_SERVICE_TYPE[] = L"service-type"; + const wchar_t JSON_KEY_ENABLE_ADVANCED_AI[] = L"enable-advanced-ai"; const wchar_t JSON_KEY_VALUE[] = L"value"; - - const wchar_t OPENAI_VAULT_RESOURCE[] = L"https://platform.openai.com/api-keys"; - const wchar_t OPENAI_VAULT_USERNAME[] = L"PowerToys_AdvancedPaste_OpenAIKey"; } class AdvancedPaste : public PowertoyModuleIface @@ -94,8 +101,13 @@ private: using CustomAction = ActionData<int>; std::vector<CustomAction> m_custom_actions; + bool m_is_ai_enabled = false; bool m_is_advanced_ai_enabled = false; bool m_preview_custom_format_output = true; + bool m_auto_copy_selection_custom_action = false; + + // Event listening for external triggers (e.g., from CmdPal extension) + EventWaiter m_triggerEventWaiter; Hotkey parse_single_hotkey(const wchar_t* keyName, const winrt::Windows::Data::Json::JsonObject& settingsObject) { @@ -112,7 +124,7 @@ private: return {}; } - static Hotkey parse_single_hotkey(const winrt::Windows::Data::Json::JsonObject& jsonHotkeyObject) + static Hotkey parse_single_hotkey(const winrt::Windows::Data::Json::JsonObject& jsonHotkeyObject, bool isShown = true) { try { @@ -122,6 +134,7 @@ private: hotkey.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT); hotkey.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL); hotkey.key = static_cast<unsigned char>(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE)); + hotkey.isShown = isShown; return hotkey; } catch (...) @@ -144,32 +157,11 @@ private: return jsonObject; } - static bool open_ai_key_exists() - { - try - { - winrt::Windows::Security::Credentials::PasswordVault().Retrieve(OPENAI_VAULT_RESOURCE, OPENAI_VAULT_USERNAME); - return true; - } - catch (const winrt::hresult_error& ex) - { - // Looks like the only way to access the PasswordVault is through an API that throws an exception in case the resource doesn't exist. - // If the debugger breaks here, just continue. - // If you want to disable breaking here in a more permanent way, just add a condition in Visual Studio's Exception Settings to not break on win::hresult_error, but that might make you not hit other exceptions you might want to catch. - if (ex.code() == HRESULT_FROM_WIN32(ERROR_NOT_FOUND)) - { - return false; // Credential doesn't exist. - } - Logger::error("Unexpected error while retrieving OpenAI key from vault: {}", winrt::to_string(ex.message())); - return false; - } - } - - bool is_open_ai_enabled() + bool is_ai_enabled() { return gpo_policy_enabled_configuration() != powertoys_gpo::gpo_rule_configured_disabled && powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue() != powertoys_gpo::gpo_rule_configured_disabled && - open_ai_key_exists(); + m_is_ai_enabled; } static std::wstring kebab_to_pascal_case(const std::wstring& kebab_str) @@ -200,6 +192,13 @@ private: return result; } + static std::wstring to_lower_case(const std::wstring& value) + { + std::wstring result = value; + std::transform(result.begin(), result.end(), result.begin(), [](wchar_t ch) { return std::towlower(ch); }); + return result; + } + bool migrate_data_and_remove_data_file(Hotkey& old_paste_as_plain_hotkey) { const wchar_t OLD_JSON_KEY_ACTIVATION_SHORTCUT[] = L"ActivationShortcut"; @@ -231,8 +230,10 @@ private: return false; } - void process_additional_action(const winrt::hstring& actionName, const winrt::Windows::Data::Json::IJsonValue& actionValue) + void process_additional_action(const winrt::hstring& actionName, const winrt::Windows::Data::Json::IJsonValue& actionValue, bool actionsGroupIsShown = true) { + bool actionIsShown = true; + if (actionValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) { return; @@ -240,9 +241,9 @@ private: const auto action = actionValue.GetObjectW(); - if (!action.GetNamedBoolean(JSON_KEY_IS_SHOWN, false)) + if (!action.GetNamedBoolean(JSON_KEY_IS_SHOWN, false) || !actionsGroupIsShown) { - return; + actionIsShown = false; } if (action.HasKey(JSON_KEY_SHORTCUT)) @@ -250,7 +251,7 @@ private: const AdditionalAction additionalAction { actionName.c_str(), - parse_single_hotkey(action.GetNamedObject(JSON_KEY_SHORTCUT)) + parse_single_hotkey(action.GetNamedObject(JSON_KEY_SHORTCUT), actionIsShown) }; m_additional_actions.push_back(additionalAction); @@ -259,11 +260,66 @@ private: { for (const auto& [subActionName, subAction] : action) { - process_additional_action(subActionName, subAction); + process_additional_action(subActionName, subAction, actionIsShown); } } } + bool has_advanced_ai_provider(const winrt::Windows::Data::Json::JsonObject& propertiesObject) + { + if (!propertiesObject.HasKey(JSON_KEY_PASTE_AI_CONFIGURATION)) + { + return false; + } + + const auto configValue = propertiesObject.GetNamedValue(JSON_KEY_PASTE_AI_CONFIGURATION); + if (configValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + return false; + } + + const auto configObject = configValue.GetObjectW(); + if (!configObject.HasKey(JSON_KEY_PROVIDERS)) + { + return false; + } + + const auto providersValue = configObject.GetNamedValue(JSON_KEY_PROVIDERS); + if (providersValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Array) + { + return false; + } + + const auto providers = providersValue.GetArray(); + for (const auto providerValue : providers) + { + if (providerValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object) + { + continue; + } + + const auto providerObject = providerValue.GetObjectW(); + if (!providerObject.GetNamedBoolean(JSON_KEY_ENABLE_ADVANCED_AI, false)) + { + continue; + } + + if (!providerObject.HasKey(JSON_KEY_SERVICE_TYPE)) + { + continue; + } + + const std::wstring serviceType = providerObject.GetNamedString(JSON_KEY_SERVICE_TYPE, L"").c_str(); + const auto normalizedServiceType = to_lower_case(serviceType); + if (normalizedServiceType == L"openai" || normalizedServiceType == L"azureopenai") + { + return true; + } + } + + return false; + } + void read_settings(PowerToysSettings::PowerToyValues& settings) { const auto settingsObject = settings.get_raw_json(); @@ -271,6 +327,37 @@ private: // Migrate Paste As Plain text shortcut Hotkey old_paste_as_plain_hotkey; bool old_data_migrated = migrate_data_and_remove_data_file(old_paste_as_plain_hotkey); + + if (settingsObject.GetView().Size()) + { + const auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + + m_is_advanced_ai_enabled = has_advanced_ai_provider(propertiesObject); + + if (propertiesObject.HasKey(JSON_KEY_IS_AI_ENABLED)) + { + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else if (propertiesObject.HasKey(JSON_KEY_IS_OPEN_AI_ENABLED)) + { + m_is_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_OPEN_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE, false); + } + else + { + m_is_ai_enabled = false; + } + + if (propertiesObject.HasKey(JSON_KEY_SHOW_CUSTOM_PREVIEW)) + { + m_preview_custom_format_output = propertiesObject.GetNamedObject(JSON_KEY_SHOW_CUSTOM_PREVIEW).GetNamedBoolean(JSON_KEY_VALUE); + } + + if (propertiesObject.HasKey(JSON_KEY_AUTO_COPY_SELECTION_CUSTOM_ACTION)) + { + m_auto_copy_selection_custom_action = propertiesObject.GetNamedObject(JSON_KEY_AUTO_COPY_SELECTION_CUSTOM_ACTION).GetNamedBoolean(JSON_KEY_VALUE, false); + } + } + if (old_data_migrated) { m_paste_as_plain_hotkey = old_paste_as_plain_hotkey; @@ -317,52 +404,46 @@ private: { const auto additionalActions = propertiesObject.GetNamedObject(JSON_KEY_ADDITIONAL_ACTIONS); - for (const auto& [actionName, additionalAction] : additionalActions) + // Define the expected order to ensure consistent hotkey ID assignment + const std::vector<winrt::hstring> expectedOrder = { + L"image-to-text", + L"paste-as-file", + L"transcode" + }; + + // Process actions in the predefined order + for (auto& actionKey : expectedOrder) { - process_additional_action(actionName, additionalAction); + if (additionalActions.HasKey(actionKey)) + { + const auto actionValue = additionalActions.GetNamedValue(actionKey); + process_additional_action(actionKey, actionValue); + } } } if (propertiesObject.HasKey(JSON_KEY_CUSTOM_ACTIONS)) { const auto customActions = propertiesObject.GetNamedObject(JSON_KEY_CUSTOM_ACTIONS).GetNamedArray(JSON_KEY_VALUE); - if (customActions.Size() > 0 && is_open_ai_enabled()) + if (customActions.Size() > 0 && is_ai_enabled()) { for (const auto& customAction : customActions) { const auto object = customAction.GetObjectW(); + bool actionIsShown = object.GetNamedBoolean(JSON_KEY_IS_SHOWN, false); - if (object.GetNamedBoolean(JSON_KEY_IS_SHOWN, false)) - { - const CustomAction customActionData - { - static_cast<int>(object.GetNamedNumber(JSON_KEY_ID)), - parse_single_hotkey(object.GetNamedObject(JSON_KEY_SHORTCUT)) - }; + const CustomAction customActionData{ + static_cast<int>(object.GetNamedNumber(JSON_KEY_ID)), + parse_single_hotkey(object.GetNamedObject(JSON_KEY_SHORTCUT), actionIsShown) + }; - m_custom_actions.push_back(customActionData); - } + m_custom_actions.push_back(customActionData); } } } } } } - - if (settingsObject.GetView().Size()) - { - const auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); - - if (propertiesObject.HasKey(JSON_KEY_IS_ADVANCED_AI_ENABLED)) - { - m_is_advanced_ai_enabled = propertiesObject.GetNamedObject(JSON_KEY_IS_ADVANCED_AI_ENABLED).GetNamedBoolean(JSON_KEY_VALUE); - } - - if (propertiesObject.HasKey(JSON_KEY_SHOW_CUSTOM_PREVIEW)) - { - m_preview_custom_format_output = propertiesObject.GetNamedObject(JSON_KEY_SHOW_CUSTOM_PREVIEW).GetNamedBoolean(JSON_KEY_VALUE); - } - } } // Load the settings file. @@ -408,6 +489,131 @@ private: } } + bool try_send_copy_message() + { + GUITHREADINFO gui_info = {}; + gui_info.cbSize = sizeof(GUITHREADINFO); + + if (!GetGUIThreadInfo(0, &gui_info)) + { + return false; + } + + HWND target = gui_info.hwndFocus ? gui_info.hwndFocus : gui_info.hwndActive; + if (!target) + { + return false; + } + + DWORD_PTR result = 0; + return SendMessageTimeout(target, + WM_COPY, + 0, + 0, + SMTO_ABORTIFHUNG | SMTO_BLOCK, + 50, + &result) != 0; + } + + bool send_copy_selection() + { + constexpr int copy_attempts = 2; + constexpr auto copy_retry_delay = std::chrono::milliseconds(100); + constexpr int clipboard_poll_attempts = 5; + constexpr auto clipboard_poll_delay = std::chrono::milliseconds(30); + + bool copy_succeeded = false; + for (int attempt = 0; attempt < copy_attempts; ++attempt) + { + const auto initial_sequence = GetClipboardSequenceNumber(); + copy_succeeded = try_send_copy_message(); + + if (!copy_succeeded) + { + std::vector<INPUT> inputs; + + // send Ctrl+C (key downs and key ups) + { + INPUT input_event = {}; + input_event.type = INPUT_KEYBOARD; + input_event.ki.wVk = VK_CONTROL; + input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG; + inputs.push_back(input_event); + } + + { + INPUT input_event = {}; + input_event.type = INPUT_KEYBOARD; + input_event.ki.wVk = 0x43; // C + // Avoid triggering detection by the centralized keyboard hook. + input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG; + inputs.push_back(input_event); + } + + { + INPUT input_event = {}; + input_event.type = INPUT_KEYBOARD; + input_event.ki.wVk = 0x43; // C + input_event.ki.dwFlags = KEYEVENTF_KEYUP; + // Avoid triggering detection by the centralized keyboard hook. + input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG; + inputs.push_back(input_event); + } + + { + INPUT input_event = {}; + input_event.type = INPUT_KEYBOARD; + input_event.ki.wVk = VK_CONTROL; + input_event.ki.dwFlags = KEYEVENTF_KEYUP; + input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG; + inputs.push_back(input_event); + } + + auto uSent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT)); + if (uSent != inputs.size()) + { + DWORD errorCode = GetLastError(); + auto errorMessage = get_last_error_message(errorCode); + Logger::error(L"SendInput failed for Ctrl+C. Expected to send {} inputs and sent only {}. {}", inputs.size(), uSent, errorMessage.has_value() ? errorMessage.value() : L""); + Trace::AdvancedPaste_Error(errorCode, errorMessage.has_value() ? errorMessage.value() : L"", L"input.SendInput"); + } + else + { + copy_succeeded = true; + } + } + + if (copy_succeeded) + { + bool sequence_changed = false; + for (int poll_attempt = 0; poll_attempt < clipboard_poll_attempts; ++poll_attempt) + { + if (GetClipboardSequenceNumber() != initial_sequence) + { + sequence_changed = true; + break; + } + + std::this_thread::sleep_for(clipboard_poll_delay); + } + + copy_succeeded = sequence_changed; + } + + if (copy_succeeded) + { + break; + } + + if (attempt + 1 < copy_attempts) + { + std::this_thread::sleep_for(copy_retry_delay); + } + } + + return copy_succeeded; + } + void try_to_paste_as_plain_text() { std::wstring clipboard_text; @@ -711,6 +917,17 @@ public: Trace::AdvancedPaste_Enable(true); m_enabled = true; m_process_manager.start(); + + // Start listening for external trigger event so we can invoke the same logic as the hotkey. + // Note: Use start() directly instead of constructor + move assignment to avoid dangling this pointer in the thread. + m_triggerEventWaiter.start(CommonSharedConstants::ADVANCED_PASTE_SHOW_UI_EVENT, [this](DWORD) { + // Same logic as hotkeyId == 1 (m_advanced_paste_ui_hotkey) + Logger::trace(L"AdvancedPaste ShowUI event triggered"); + m_process_manager.start(); + m_process_manager.bring_to_front(); + m_process_manager.send_message(CommonSharedConstants::ADVANCED_PASTE_SHOW_UI_MESSAGE); + Trace::AdvancedPaste_Invoked(L"AdvancedPasteUIEvent"); + }); }; void Disable(bool traceEvent) @@ -719,6 +936,9 @@ public: { m_process_manager.stop(); + // Stop event listening + m_triggerEventWaiter.stop(); + if (traceEvent) { Trace::AdvancedPaste_Enable(false); @@ -739,6 +959,28 @@ public: Logger::trace(L"AdvancedPaste hotkey pressed"); if (m_enabled) { + size_t additional_action_index = 0; + size_t custom_action_index = 0; + bool is_custom_action_hotkey = false; + + if (hotkeyId >= NUM_DEFAULT_HOTKEYS) + { + additional_action_index = hotkeyId - NUM_DEFAULT_HOTKEYS; + if (additional_action_index >= m_additional_actions.size()) + { + custom_action_index = additional_action_index - m_additional_actions.size(); + is_custom_action_hotkey = custom_action_index < m_custom_actions.size(); + } + } + + if (is_custom_action_hotkey && m_auto_copy_selection_custom_action) + { + if (!send_copy_selection()) + { + return false; + } + } + m_process_manager.start(); // hotkeyId in same order as set by get_hotkeys @@ -781,7 +1023,6 @@ public: } - const auto additional_action_index = hotkeyId - NUM_DEFAULT_HOTKEYS; if (additional_action_index < m_additional_actions.size()) { const auto& id = m_additional_actions.at(additional_action_index).id; @@ -794,7 +1035,6 @@ public: return true; } - const auto custom_action_index = additional_action_index - m_additional_actions.size(); if (custom_action_index < m_custom_actions.size()) { const auto id = m_custom_actions.at(custom_action_index).id; diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/packages.config b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/packages.config +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/AdvancedPaste-UITests.csproj b/src/modules/AdvancedPaste/UITest-AdvancedPaste/AdvancedPaste-UITests.csproj new file mode 100644 index 0000000000..6848b5f1a6 --- /dev/null +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/AdvancedPaste-UITests.csproj @@ -0,0 +1,38 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <ProjectGuid>{2B1505FA-132A-460B-B22B-7CC3FFAB0C5D}</ProjectGuid> + <RootNamespace>Microsoft.AdvancedPaste.UITests</RootNamespace> + <IsPackable>false</IsPackable> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + + <!-- This is a UI test, so don't run as part of MSBuild --> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\UITests-AdvancedPaste\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Appium.WebDriver" /> + <PackageReference Include="MSTest" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> + + <ItemGroup> + <Content Include="TestFiles\**\*.*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + +</Project> \ No newline at end of file diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/AdvancedPasteUITest.cs b/src/modules/AdvancedPaste/UITest-AdvancedPaste/AdvancedPasteUITest.cs new file mode 100644 index 0000000000..7b62533bdb --- /dev/null +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/AdvancedPasteUITest.cs @@ -0,0 +1,1028 @@ +// 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.Diagnostics; +using System.Drawing; +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows.Forms; +using Microsoft.AdvancedPaste.UITests.Helper; +using Microsoft.CodeCoverage.Core.Reports.Coverage; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium; +using Windows.ApplicationModel.DataTransfer; +using static System.Net.Mime.MediaTypeNames; +using static System.Resources.ResXFileRef; +using static System.Runtime.InteropServices.JavaScript.JSType; +using static System.Windows.Forms.VisualStyles.VisualStyleElement.ToolTip; + +namespace Microsoft.AdvancedPaste.UITests +{ + [TestClass] + public class AdvancedPasteUITest : UITestBase + { + private readonly string testFilesFolderPath; + private readonly string tempRTFFileName = "TempFile.rtf"; + private readonly string pasteAsPlainTextRawFileName = "PasteAsPlainTextFileRaw.rtf"; + private readonly string pasteAsPlainTextPlainFileName = "PasteAsPlainTextFilePlain.rtf"; + private readonly string pasteAsPlainTextPlainNoRepeatFileName = "PasteAsPlainTextFilePlainNoRepeat.rtf"; + private readonly string wordpadPath = @"C:\Program Files\wordpad\wordpad.exe"; + + private readonly string tempTxtFileName = "TempFile.txt"; + private readonly string pasteAsMarkdownSrcFile = "PasteAsMarkdownFile.html"; + private readonly string pasteAsMarkdownResultFile = "PasteAsMarkdownResultFile.txt"; + + private readonly string pasteAsJsonFileName = "PasteAsJsonFile.xml"; + private readonly string pasteAsJsonResultFile = "PasteAsJsonResultFile.txt"; + + private bool _notepadSettingsChanged; + + // Static constructor - runs before any instance is created + static AdvancedPasteUITest() + { + // Using the predefined settings. + // paste as plain text: win + ctrl + alt + o + // paste as markdown text: win + ctrl + alt + m + // paste as json text: win + ctrl + alt + j + CopySettingsFileBeforeTests(); + } + + public AdvancedPasteUITest() + : base(PowerToysModule.PowerToysSettings, size: WindowSize.Large_Vertical) + { + Type currentTestType = typeof(AdvancedPasteUITest); + string? dirName = Path.GetDirectoryName(currentTestType.Assembly.Location); + Assert.IsNotNull(dirName, "Failed to get directory name of the current test assembly."); + + string testFilesFolder = Path.Combine(dirName, "TestFiles"); + Assert.IsTrue(Directory.Exists(testFilesFolder), $"Test files directory not found at: {testFilesFolder}"); + + testFilesFolderPath = testFilesFolder; + + // ignore the notepad settings in pipeline + _notepadSettingsChanged = true; + } + + [TestInitialize] + public void TestInitialize() + { + Session.CloseMainWindow(); + SendKeys(Key.Win, Key.M); + } + + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("PasteAsPlainText")] + [Ignore("Temporarily disabled due to wordpad.exe is missing in pipeline.")] + public void TestCasePasteAsPlainText() + { + // Copy some rich text(e.g word of the text is different color, another work is bold, underlined, etd.). + // Paste the text using standard Windows Ctrl + V shortcut and ensure that rich text is pasted(with all colors, formatting, etc.) + DeleteAndCopyFile(pasteAsPlainTextRawFileName, tempRTFFileName); + ContentCopyAndPasteDirectly(tempRTFFileName, isRTF: true); + + var resultWithFormatting = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempRTFFileName), + Path.Combine(testFilesFolderPath, pasteAsPlainTextRawFileName), + compareFormatting: true); + + Assert.IsTrue(resultWithFormatting.IsConsistent, "RTF files should be identical including formatting"); + + // Paste the text using Paste As Plain Text activation shortcut and ensure that plain text without any formatting is pasted. + // Paste again the text using standard Windows Ctrl + V shortcut and ensure the text is now pasted plain without formatting as well. + DeleteAndCopyFile(pasteAsPlainTextRawFileName, tempRTFFileName); + ContentCopyAndPasteWithShortcutThenPasteAgain(tempRTFFileName, isRTF: true); + resultWithFormatting = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempRTFFileName), + Path.Combine(testFilesFolderPath, pasteAsPlainTextPlainFileName), + compareFormatting: true); + Assert.IsTrue(resultWithFormatting.IsConsistent, "RTF files should be identical without formatting"); + + // Copy some rich text again. + // Open Advanced Paste window using hotkey, click Paste as Plain Text button and confirm that plain text without any formatting is pasted. + DeleteAndCopyFile(pasteAsPlainTextRawFileName, tempRTFFileName); + ContentCopyAndPasteCase3(tempRTFFileName, isRTF: true); + resultWithFormatting = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempRTFFileName), + Path.Combine(testFilesFolderPath, pasteAsPlainTextPlainNoRepeatFileName), + compareFormatting: true); + Assert.IsTrue(resultWithFormatting.IsConsistent, "RTF files should be identical without formatting"); + + // Copy some rich text again. + // Open Advanced Paste window using hotkey, press Ctrl + 1 and confirm that plain text without any formatting is pasted. + DeleteAndCopyFile(pasteAsPlainTextRawFileName, tempRTFFileName); + ContentCopyAndPasteCase4(tempRTFFileName, isRTF: true); + resultWithFormatting = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempRTFFileName), + Path.Combine(testFilesFolderPath, pasteAsPlainTextPlainNoRepeatFileName), + compareFormatting: true); + Assert.IsTrue(resultWithFormatting.IsConsistent, "RTF files should be identical without formatting"); + } + + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("PasteAsMarkdownCase1")] + public void TestCasePasteAsMarkdownCase1() + { + if (_notepadSettingsChanged == false) + { + ChangeNotePadSettings(); + } + + // Copy some text(e.g.some HTML text - convertible to Markdown) + // Paste the text using set hotkey and confirm that pasted text is converted to markdown + DeleteAndCopyFile(pasteAsMarkdownSrcFile, tempTxtFileName); + ContentCopyAndPasteAsMarkdownCase1(tempTxtFileName); + var result = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempTxtFileName), + Path.Combine(testFilesFolderPath, pasteAsMarkdownResultFile), + compareFormatting: true); + Assert.IsTrue(result.IsConsistent, "Paste as markdown using shortcut failed."); + } + + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("PasteAsMarkdownCase2")] + public void TestCasePasteAsMarkdownCase2() + { + if (_notepadSettingsChanged == false) + { + ChangeNotePadSettings(); + } + + // Copy some text(same as in the previous step or different.If nothing is coppied between steps, previously pasted Markdown text will be picked up from clipboard and converted again to nested Markdown). + // Open Advanced Paste window using hotkey, click Paste as markdown button and confirm that pasted text is converted to markdown + DeleteAndCopyFile(pasteAsMarkdownSrcFile, tempTxtFileName); + ContentCopyAndPasteAsMarkdownCase2(tempTxtFileName); + var result = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempTxtFileName), + Path.Combine(testFilesFolderPath, pasteAsMarkdownResultFile), + compareFormatting: true); + Assert.IsTrue(result.IsConsistent, "Paste as markdown using shortcut failed."); + } + + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("PasteAsMarkdownCase3")] + public void TestCasePasteAsMarkdownCase3() + { + if (_notepadSettingsChanged == false) + { + ChangeNotePadSettings(); + } + + // Copy some text(same as in the previous step or different.If nothing is coppied between steps, previously pasted Markdown text will be picked up from clipboard and converted again to nested Markdown). + // Open Advanced Paste window using hotkey, press Ctrl + 2 and confirm that pasted text is converted to markdown + DeleteAndCopyFile(pasteAsMarkdownSrcFile, tempTxtFileName); + ContentCopyAndPasteAsMarkdownCase3(tempTxtFileName); + var result = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempTxtFileName), + Path.Combine(testFilesFolderPath, pasteAsMarkdownResultFile), + compareFormatting: true); + Assert.IsTrue(result.IsConsistent, "Paste as markdown using shortcut failed."); + } + + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("PasteAsJSONCase1")] + public void TestCasePasteAsJSONCase1() + { + if (_notepadSettingsChanged == false) + { + ChangeNotePadSettings(); + } + + // Copy some XML or CSV text(or any other text, it will be converted to simple JSON object) + // Paste the text using set hotkey and confirm that pasted text is converted to JSON + DeleteAndCopyFile(pasteAsJsonFileName, tempTxtFileName); + ContentCopyAndPasteAsJsonCase1(tempTxtFileName); + var result = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempTxtFileName), + Path.Combine(testFilesFolderPath, pasteAsJsonResultFile), + compareFormatting: true); + Assert.IsTrue(result.IsConsistent, "Paste as Json using shortcut failed."); + } + + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("PasteAsJSONCase2")] + public void TestCasePasteAsJSONCase2() + { + if (_notepadSettingsChanged == false) + { + ChangeNotePadSettings(); + } + + // Copy some text(same as in the previous step or different.If nothing is coppied between steps, previously pasted JSON text will be picked up from clipboard and converted again to nested JSON). + // Open Advanced Paste window using hotkey, click Paste as markdown button and confirm that pasted text is converted to markdown + DeleteAndCopyFile(pasteAsJsonFileName, tempTxtFileName); + ContentCopyAndPasteAsJsonCase2(tempTxtFileName); + var result = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempTxtFileName), + Path.Combine(testFilesFolderPath, pasteAsJsonResultFile), + compareFormatting: true); + Assert.IsTrue(result.IsConsistent, "Paste as Json using shortcut failed."); + } + + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("PasteAsJSONCase3")] + public void TestCasePasteAsJSONCase3() + { + if (_notepadSettingsChanged == false) + { + ChangeNotePadSettings(); + } + + // Copy some text(same as in the previous step or different.If nothing is coppied between steps, previously pasted JSON text will be picked up from clipboard and converted again to nested JSON). + // Open Advanced Paste window using hotkey, press Ctrl + 3 and confirm that pasted text is converted to markdown + DeleteAndCopyFile(pasteAsJsonFileName, tempTxtFileName); + ContentCopyAndPasteAsJsonCase3(tempTxtFileName); + var result = FileReader.CompareRtfFiles( + Path.Combine(testFilesFolderPath, tempTxtFileName), + Path.Combine(testFilesFolderPath, pasteAsJsonResultFile), + compareFormatting: true); + Assert.IsTrue(result.IsConsistent, "Paste as Json using shortcut failed."); + } + + /* + * Clipboard History + - [x] Open Settings and Enable clipboard history (if not enabled already). Open Advanced Paste window with hotkey, click Clipboard history and try deleting some entry. Check OS clipboard history (Win+V), and confirm that the same entry no longer exist. + - [x] Open Advanced Paste window with hotkey, click Clipboard history, and click any entry (but first). Observe that entry is put on top of clipboard history. Check OS clipboard history (Win+V), and confirm that the same entry is on top of the clipboard. + - [x] Open Settings and Disable clipboard history. Open Advanced Paste window with hotkey and observe that Clipboard history button is disabled. + * Disable Advanced Paste, try different Advanced Paste hotkeys and confirm that it's disabled and nothing happens. + */ + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("TestCaseClipboardHistoryDeleteTest")] + public void TestCaseClipboardHistoryDeleteTest() + { + RestartScopeExe(); + Thread.Sleep(1500); + + // Find the PowerToys Settings window + var settingsWindow = Find<Window>("PowerToys Settings", global: true); + Assert.IsNotNull(settingsWindow, "Failed to open PowerToys Settings window"); + + if (FindAll<NavigationViewItem>("Advanced Paste").Count == 0) + { + // Expand Advanced list-group if needed + Find<NavigationViewItem>("System Tools").Click(); + } + + Find<NavigationViewItem>("Advanced Paste").Click(); + + Find<ToggleSwitch>("Clipboard history").Toggle(true); + + Session.CloseMainWindow(); + + // clear system clipboard + ClearSystemClipboardHistory(); + + // set test content to clipboard + const string textForTesting = "Test text"; + SetClipboardTextInSTAMode(textForTesting); + + // Open Advanced Paste window with hotkey, click Clipboard history and try deleting some entry. + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(1500); + + var apWind = this.Find<Window>("Advanced Paste", global: true); + apWind.Find<PowerToys.UITest.Button>("Clipboard history").Click(); + + var textGroup = apWind.Find<Group>(textForTesting); + Assert.IsNotNull(textGroup, "Cannot find the test string from advanced paste clipboard history."); + + textGroup.Find<PowerToys.UITest.Button>("More options").Click(); + apWind.Find<TextBlock>("Delete").Click(); + + // Check OS clipboard history (Win+V), and confirm that the same entry no longer exist. + this.SendKeys(Key.Win, Key.V); + + Thread.Sleep(1500); + + var clipboardWindow = this.Find<Window>("Windows Input Experience", global: true); + Assert.IsNotNull(clipboardWindow, "Cannot find system clipboard window."); + + var nothingText = clipboardWindow.Find<Group>("Nothing here, You'll see your clipboard history here once you've copied something."); + Assert.IsNotNull(nothingText, "System clipboard is not empty, which should be yes."); + } + + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("TestCaseClipboardHistorySelectTest")] + public void TestCaseClipboardHistorySelectTest() + { + RestartScopeExe(); + Thread.Sleep(1500); + + // Find the PowerToys Settings window + var settingsWindow = Find<Window>("PowerToys Settings", global: true); + Assert.IsNotNull(settingsWindow, "Failed to open PowerToys Settings window"); + + if (FindAll<NavigationViewItem>("Advanced Paste").Count == 0) + { + // Expand Advanced list-group if needed + Find<NavigationViewItem>("System Tools").Click(); + } + + Find<NavigationViewItem>("Advanced Paste").Click(); + + Find<ToggleSwitch>("Clipboard history").Toggle(true); + + Session.CloseMainWindow(); + + // clear system clipboard + ClearSystemClipboardHistory(); + + // set test content to clipboard + string[] textForTesting = { "Test text1", "Test text2", "Test text3", "Test text4", "Test text5", "Test text6", }; + foreach (var str in textForTesting) + { + SetClipboardTextInSTAMode(str); + Thread.Sleep(1000); + } + + // Open Advanced Paste window with hotkey + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(1500); + + var apWind = this.Find<Window>("Advanced Paste", global: true); + apWind.Find<PowerToys.UITest.Button>("Clipboard history").Click(); + + // click the 3rd item + var textGroup = apWind.Find<Group>(textForTesting[0]); + Assert.IsNotNull(textGroup, "Cannot find the test string from advanced paste clipboard history."); + textGroup.Click(); + + // Check OS clipboard history (Win+V) + this.SendKeys(Key.Win, Key.V); + + Thread.Sleep(1500); + + var clipboardWindow = this.Find<Window>("Windows Input Experience", global: true); + Assert.IsNotNull(clipboardWindow, "Cannot find system clipboard window."); + + var txtFound = clipboardWindow.Find<Element>(textForTesting[0]); + Assert.IsNotNull(txtFound, "Cannot find textblock"); + } + + // [x] Open Settings and Disable clipboard history.Open Advanced Paste window with hotkey and observe that Clipboard history button is disabled. + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("TestCaseClipboardHistoryDisableTest")] + public void TestCaseClipboardHistoryDisableTest() + { + RestartScopeExe(); + Thread.Sleep(1500); + + // Find the PowerToys Settings window + var settingsWindow = Find<Window>("PowerToys Settings", global: true); + Assert.IsNotNull(settingsWindow, "Failed to open PowerToys Settings window"); + + if (FindAll<NavigationViewItem>("Advanced Paste").Count == 0) + { + // Expand Advanced list-group if needed + Find<NavigationViewItem>("System Tools").Click(); + } + + Find<NavigationViewItem>("Advanced Paste").Click(); + + Find<ToggleSwitch>("Clipboard history").Toggle(false); + + Session.CloseMainWindow(); + + // set test content to clipboard + const string textForTesting = "Test text"; + SetClipboardTextInSTAMode(textForTesting); + + // Open Advanced Paste window with hotkey, click Clipboard history and try deleting some entry. + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(1500); + + var apWind = this.Find<Window>("Advanced Paste", global: true); + + // Click the button (which should still exist but be disabled) + apWind.Find<PowerToys.UITest.Button>("Clipboard history").Click(); + + // Verify that the clipboard content doesn't appear + // Use a short timeout to avoid a long wait when the element doesn't exist + Assert.IsFalse( + Has<Group>(textForTesting), + "Clipboard content should not appear when clipboard history is disabled"); + } + + // Disable Advanced Paste, try different Advanced Paste hotkeys and confirm that it's disabled and nothing happens. + [TestMethod] + [TestCategory("AdvancedPasteUITest")] + [TestCategory("TestCaseDisableAdvancedPaste")] + public void TestCaseDisableAdvancedPaste() + { + RestartScopeExe(); + Thread.Sleep(1500); + + // Find the PowerToys Settings window + var settingsWindow = Find<Window>("PowerToys Settings", global: true); + Assert.IsNotNull(settingsWindow, "Failed to open PowerToys Settings window"); + + if (FindAll<NavigationViewItem>("Advanced Paste").Count == 0) + { + // Expand System Tools if needed + Find<NavigationViewItem>("System Tools").Click(); + } + + Find<NavigationViewItem>("Advanced Paste").Click(); + + // Disable Advanced Paste module + var moduleToggle = Find<ToggleSwitch>("Enable Advanced Paste"); + moduleToggle.Toggle(false); + + Session.CloseMainWindow(); + + // Prepare some text to test with + const string textForTesting = "Test text for disabled module"; + SetClipboardTextInSTAMode(textForTesting); + + // Try main Advanced Paste hotkey + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(500); + + // Verify Advanced Paste window does not appear + Assert.IsFalse( + Has<Window>("Advanced Paste", global: true), + "Advanced Paste window should not appear when the module is disabled"); + + // Re-enable Advanced Paste for other tests + RestartScopeExe(); + Thread.Sleep(1500); + + settingsWindow = Find<Window>("PowerToys Settings", global: true); + + if (FindAll<NavigationViewItem>("Advanced Paste").Count == 0) + { + Find<NavigationViewItem>("System Tools").Click(); + } + + Find<NavigationViewItem>("Advanced Paste").Click(); + Find<ToggleSwitch>("Enable Advanced Paste").Toggle(true); + + Session.CloseMainWindow(); + } + + private void ClearSystemClipboardHistory() + { + this.SendKeys(Key.Win, Key.V); + + Thread.Sleep(1500); + + var clipboardWindow = this.Find<Window>("Windows Input Experience", global: true); + Assert.IsNotNull(clipboardWindow, "Cannot find system clipboard window."); + + clipboardWindow.Find<PowerToys.UITest.Button>("Clear all except pinned items").Click(); + } + + private void SetClipboardTextInSTAMode(string text) + { + var thread = new Thread(() => + { + System.Windows.Forms.Clipboard.SetText(text); + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + } + + private void ContentCopyAndPasteDirectly(string fileName, bool isRTF = false) + { + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.V); + Thread.Sleep(1000); + this.SendKeys(Key.Backspace); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + process.Kill(true); + } + + private void ContentCopyAndPasteWithShortcutThenPasteAgain(string fileName, bool isRTF = false) + { + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + this.SendKeys(Key.Win, Key.LCtrl, Key.Alt, Key.O); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.V); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + process.Kill(true); + } + + private void ContentCopyAndPasteCase3(string fileName, bool isRTF = false) + { + // Copy some rich text again. + // Open Advanced Paste window using hotkey, click Paste as Plain Text button and confirm that plain text without any formatting is pasted. + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + + // Open Advanced Paste window using hotkey + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(15000); + + // Click Paste as Plain Text button and confirm that plain text without any formatting is pasted. + var apWind = this.Find<Window>("Advanced Paste", global: true); + apWind.Find<TextBlock>("Paste as plain text").Click(); + + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + process.Kill(true); + } + + private void ContentCopyAndPasteCase4(string fileName, bool isRTF = false) + { + // Copy some rich text again. + // Open Advanced Paste window using hotkey, press Ctrl + 1 and confirm that plain text without any formatting is pasted. + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + + // Open Advanced Paste window using hotkey + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(1000); + + // press Ctrl + 1 and confirm that plain text without any formatting is pasted. + this.SendKeys(Key.LCtrl, Key.Num1); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + process.Kill(true); + } + + private void ContentCopyAndPasteAsMarkdownCase1(string fileName, bool isRTF = false) + { + // Copy some rich text again. + // Open Advanced Paste window using hotkey, press Ctrl + 1 and confirm that plain text without any formatting is pasted. + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + + this.SendKeys(Key.Win, Key.LCtrl, Key.Alt, Key.M); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + window.Close(); + } + + private void ContentCopyAndPasteAsMarkdownCase2(string fileName, bool isRTF = false) + { + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + + // Open Advanced Paste window using hotkey + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(15000); + + // click Paste as markdown button and confirm that pasted text is converted to markdown + var apWind = this.Find<Window>("Advanced Paste", global: true); + apWind.Find<TextBlock>("Paste as markdown").Click(); + + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + window.Close(); + } + + private void ContentCopyAndPasteAsMarkdownCase3(string fileName, bool isRTF = false) + { + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + + // Open Advanced Paste window using hotkey + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(15000); + + this.SendKeys(Key.LCtrl, Key.Num2); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + window.Close(); + } + + private void ContentCopyAndPasteAsJsonCase1(string fileName, bool isRTF = false) + { + // Copy some rich text again. + // Open Advanced Paste window using hotkey, press Ctrl + 1 and confirm that plain text without any formatting is pasted. + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + + this.SendKeys(Key.Win, Key.LCtrl, Key.Alt, Key.J); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + window.Close(); + } + + private void ContentCopyAndPasteAsJsonCase2(string fileName, bool isRTF = false) + { + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + + // Open Advanced Paste window using hotkey + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(15000); + + // click Paste as markdown button and confirm that pasted text is converted to markdown + var apWind = this.Find<Window>("Advanced Paste", global: true); + apWind.Find<TextBlock>("Paste as JSON").Click(); + + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + window.Close(); + } + + private void ContentCopyAndPasteAsJsonCase3(string fileName, bool isRTF = false) + { + string tempFile = Path.Combine(testFilesFolderPath, fileName); + + Process process = Process.Start(isRTF ? wordpadPath : "notepad.exe", tempFile); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {(isRTF ? "WordPad" : "Notepad")}."); + } + + Thread.Sleep(15000); + + var window = FindWindowWithFlexibleTitle(Path.GetFileName(tempFile), isRTF); + + window.Click(); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.A); + Thread.Sleep(1000); + this.SendKeys(Key.LCtrl, Key.C); + Thread.Sleep(1000); + this.SendKeys(Key.Delete); + Thread.Sleep(1000); + + // Open Advanced Paste window using hotkey + this.SendKeys(Key.Win, Key.Shift, Key.V); + Thread.Sleep(15000); + + this.SendKeys(Key.LCtrl, Key.Num3); + Thread.Sleep(1000); + + this.SendKeys(Key.LCtrl, Key.S); + Thread.Sleep(1000); + + window.Close(); + } + + private string DeleteAndCopyFile(string sourceFileName, string destinationFileName) + { + string sourcePath = Path.Combine(testFilesFolderPath, sourceFileName); + string destinationPath = Path.Combine(testFilesFolderPath, destinationFileName); + + // Check if source file exists + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException($"Source file not found: {sourcePath}"); + } + + // Delete destination file if it exists + if (File.Exists(destinationPath)) + { + try + { + File.Delete(destinationPath); + } + catch (IOException ex) + { + throw new IOException($"Failed to delete file {destinationPath}. The file may be in use: {ex.Message}", ex); + } + } + + // Copy the source file to the destination + try + { + File.Copy(sourcePath, destinationPath); + } + catch (IOException ex) + { + throw new IOException($"Failed to copy file from {sourcePath} to {destinationPath}: {ex.Message}", ex); + } + + return destinationPath; + } + + private void ChangeNotePadSettings() + { + Process process = Process.Start("notepad.exe"); + if (process == null) + { + throw new InvalidOperationException($"Failed to start Notepad.exe"); + } + + Thread.Sleep(15000); + + var window = FindWindowWithFlexibleTitle("Untitled", false); + + window.Find<PowerToys.UITest.Button>("Settings").Click(); + var combobox = window.Find<PowerToys.UITest.ComboBox>("Opening files"); + combobox.SelectTxt("Open in a new window"); + + window.Find<Group>("When Notepad starts").Click(); + + window.Find<PowerToys.UITest.RadioButton>("Open a new window").Select(); + + _notepadSettingsChanged = true; + window.Close(); + } + + /// <summary> + /// Finds a window with flexible title matching, trying multiple title variations + /// </summary> + /// <param name="baseTitle">The base title to search for</param> + /// <param name="isRTF">Whether the window is a WordPad window</param> + /// <returns>The found Window element or throws an exception if not found</returns> + private Window FindWindowWithFlexibleTitle(string baseTitle, bool isRTF) + { + Window? window = null; + string appType = isRTF ? "WordPad" : "Notepad"; + + // Try different title variations + string[] titleVariations = new string[] + { + baseTitle + (isRTF ? " - WordPad" : " - Notepad"), // With suffix + baseTitle, // Without suffix + Path.GetFileNameWithoutExtension(baseTitle) + (isRTF ? " - WordPad" : " - Notepad"), // Without extension, with suffix + Path.GetFileNameWithoutExtension(baseTitle), // Without extension, without suffix + }; + + Exception? lastException = null; + + foreach (string title in titleVariations) + { + try + { + window = this.Find<Window>(title, global: true); + if (window != null) + { + return window; + } + } + catch (Exception ex) + { + // Save the exception, but continue trying other variations + lastException = ex; + } + } + + // If we couldn't find the window with any variation, throw an exception with details + throw new InvalidOperationException( + $"Failed to find {appType} window with title containing '{baseTitle}'. "); + } + + private static void CopySettingsFileBeforeTests() + { + try + { + // Determine the assembly location and test files path + string? assemblyLocation = Path.GetDirectoryName(typeof(AdvancedPasteUITest).Assembly.Location); + if (assemblyLocation == null) + { + Debug.WriteLine("ERROR: Failed to get assembly location"); + return; + } + + string testFilesFolder = Path.Combine(assemblyLocation, "TestFiles"); + if (!Directory.Exists(testFilesFolder)) + { + Debug.WriteLine($"ERROR: Test files directory not found at: {testFilesFolder}"); + return; + } + + // Settings file source path + string settingsFileName = "settings.json"; + string sourceSettingsPath = Path.Combine(testFilesFolder, settingsFileName); + + // Make sure the source file exists + if (!File.Exists(sourceSettingsPath)) + { + Debug.WriteLine($"ERROR: Settings file not found at: {sourceSettingsPath}"); + return; + } + + // Determine the target directory in %LOCALAPPDATA% + string targetDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys", + "AdvancedPaste"); + + // Create the directory if it doesn't exist + if (!Directory.Exists(targetDirectory)) + { + Directory.CreateDirectory(targetDirectory); + } + + string targetSettingsPath = Path.Combine(targetDirectory, settingsFileName); + + // Copy the file and overwrite if it exists + File.Copy(sourceSettingsPath, targetSettingsPath, true); + + Debug.WriteLine($"Successfully copied settings file from {sourceSettingsPath} to {targetSettingsPath}"); + } + catch (Exception ex) + { + Debug.WriteLine($"ERROR copying settings file: {ex.Message}"); + } + } + } +} diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/Helper/FileReader.cs b/src/modules/AdvancedPaste/UITest-AdvancedPaste/Helper/FileReader.cs new file mode 100644 index 0000000000..d711fe010a --- /dev/null +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/Helper/FileReader.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 System; +using System.IO; +using System.Text; +using System.Windows.Forms; + +namespace Microsoft.AdvancedPaste.UITests.Helper; + +public class FileReader +{ + public static string ReadContent(string filePath) + { + try + { + return File.ReadAllText(filePath); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to read file: {ex.Message}", ex); + } + } + + public static string ReadRTFPlainText(string filePath) + { + try + { + using (var rtb = new System.Windows.Forms.RichTextBox()) + { + rtb.Rtf = File.ReadAllText(filePath); + return rtb.Text; + } + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to read plain text from file: {ex.Message}", ex); + } + } + + /// <summary> + /// Compares the contents of two RTF files to check if they are consistent. + /// </summary> + /// <param name="firstFilePath">Path to the first RTF file</param> + /// <param name="secondFilePath">Path to the second RTF file</param> + /// <param name="compareFormatting">If true, compares the raw RTF content (including formatting). + /// If false, compares only the plain text content.</param> + /// <returns> + /// A tuple containing: (bool isConsistent, string firstContent, string secondContent) + /// - isConsistent: true if the files are consistent according to the comparison method + /// - firstContent: the content of the first file + /// - secondContent: the content of the second file + /// </returns> + public static (bool IsConsistent, string FirstContent, string SecondContent) CompareRtfFiles( + string firstFilePath, + string secondFilePath, + bool compareFormatting = false) + { + try + { + string firstContent, secondContent; + + if (compareFormatting) + { + // Compare raw RTF content (including formatting) + firstContent = ReadContent(firstFilePath); + secondContent = ReadContent(secondFilePath); + } + else + { + // Compare only the plain text content + firstContent = ReadRTFPlainText(firstFilePath); + secondContent = ReadRTFPlainText(secondFilePath); + } + + bool isConsistent = string.Equals(firstContent, secondContent, StringComparison.Ordinal); + return (isConsistent, firstContent, secondContent); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to compare RTF files: {ex.Message}", ex); + } + } +} diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonFile.xml b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonFile.xml new file mode 100644 index 0000000000..90f0a1b454 --- /dev/null +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonFile.xml @@ -0,0 +1,6 @@ +<note> + <to>Tove</to> + <from>Jani</from> + <heading>Reminder</heading> + <body>Don't forget me this weekend!</body> +</note> \ No newline at end of file diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonResultFile.txt b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonResultFile.txt new file mode 100644 index 0000000000..2bea5fd966 --- /dev/null +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsJsonResultFile.txt @@ -0,0 +1,8 @@ +{ + "note": { + "to": "Tove", + "from": "Jani", + "heading": "Reminder", + "body": "Don't forget me this weekend!" + } +} \ No newline at end of file diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownFile.html b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownFile.html new file mode 100644 index 0000000000..097b3d4d2b --- /dev/null +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownFile.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html> + +<body> + + <h2 title="I'm a header">The title Attribute</h2> + + <p title="I'm a tooltip">Mouse over this paragraph, to display the title attribute as a tooltip.</p> + +</body> + +</html> \ No newline at end of file diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownResultFile.txt b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownResultFile.txt new file mode 100644 index 0000000000..a383bfdb1b --- /dev/null +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsMarkdownResultFile.txt @@ -0,0 +1,3 @@ +## The title Attribute + +Mouse over this paragraph, to display the title attribute as a tooltip. \ No newline at end of file diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlain.rtf b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlain.rtf new file mode 100644 index 0000000000..c0d8a0402b Binary files /dev/null and b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlain.rtf differ diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlainNoRepeat.rtf b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlainNoRepeat.rtf new file mode 100644 index 0000000000..67f5ed3dce Binary files /dev/null and b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFilePlainNoRepeat.rtf differ diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFileRaw.rtf b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFileRaw.rtf new file mode 100644 index 0000000000..be2ac272dd Binary files /dev/null and b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/PasteAsPlainTextFileRaw.rtf differ diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json new file mode 100644 index 0000000000..bc0803796e --- /dev/null +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/TestFiles/settings.json @@ -0,0 +1 @@ +{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"} \ No newline at end of file diff --git a/src/modules/AdvancedPaste/UITest-AdvancedPaste/UITestAdvancedPaste.md b/src/modules/AdvancedPaste/UITest-AdvancedPaste/UITestAdvancedPaste.md new file mode 100644 index 0000000000..202ee43494 --- /dev/null +++ b/src/modules/AdvancedPaste/UITest-AdvancedPaste/UITestAdvancedPaste.md @@ -0,0 +1,41 @@ +## [Advanced Paste](tests-checklist-template-advanced-paste-section.md) + NOTES: + When using Advanced Paste, make sure that window focused while starting/using Advanced paste is text editor or has text input field focused (e.g. Word). + * Paste As Plain Text + - [x] Copy some rich text (e.g word of the text is different color, another work is bold, underlined, etd.). + - [x] Paste the text using standard Windows Ctrl + V shortcut and ensure that rich text is pasted (with all colors, formatting, etc.) + - [x] Paste the text using Paste As Plain Text activation shortcut and ensure that plain text without any formatting is pasted. + - [x] Paste again the text using standard Windows Ctrl + V shortcut and ensure the text is now pasted plain without formatting as well. + - [x] Copy some rich text again. + - [x] Open Advanced Paste window using hotkey, click Paste as Plain Text button and confirm that plain text without any formatting is pasted. + - [x] Copy some rich text again. + - [x] Open Advanced Paste window using hotkey, press Ctrl + 1 and confirm that plain text without any formatting is pasted. + * Paste As Markdown + - [] Open Settings and set Paste as Markdown directly hotkey + - [x] Copy some text (e.g. some HTML text - convertible to Markdown) + - [x] Paste the text using set hotkey and confirm that pasted text is converted to markdown + - [x] Copy some text (same as in the previous step or different. If nothing is coppied between steps, previously pasted Markdown text will be picked up from clipboard and converted again to nested Markdown). + - [x] Open Advanced Paste window using hotkey, click Paste as markdown button and confirm that pasted text is converted to markdown + - [x] Copy some text (same as in the previous step or different. If nothing is coppied between steps, previously pasted Markdown text will be picked up from clipboard and converted again to nested Markdown). + - [x] Open Advanced Paste window using hotkey, press Ctrl + 2 and confirm that pasted text is converted to markdown + * Paste As JSON + - [] Open Settings and set Paste as JSON directly hotkey + - [x] Copy some XML or CSV text (or any other text, it will be converted to simple JSON object) + - [x] Paste the text using set hotkey and confirm that pasted text is converted to JSON + - [x] Copy some text (same as in the previous step or different. If nothing is coppied between steps, previously pasted JSON text will be picked up from clipboard and converted again to nested JSON). + - [x] Open Advanced Paste window using hotkey, click Paste as markdown button and confirm that pasted text is converted to markdown + - [x] Copy some text (same as in the previous step or different. If nothing is coppied between steps, previously pasted JSON text will be picked up from clipboard and converted again to nested JSON). + - [x] Open Advanced Paste window using hotkey, press Ctrl + 3 and confirm that pasted text is converted to markdown + * Paste as custom format using AI + - [] Open Settings, navigate to Enable Paste with AI and set OpenAI key. + - [] Copy some text to clipboard. Any text. + - [] Open Advanced Paste window using hotkey, and confirm that Custom intput text box is now enabled. Write "Insert smiley after every word" and press Enter. Observe that result preview shows coppied text with smileys between words. Press Enter to paste the result and observe that it is pasted. + - [] Open Advanced Paste window using hotkey. Input some query (any, feel free to play around) and press Enter. When result is shown, click regenerate button, to see if new result is generated. Select one of the results and paste. Observe that correct result is pasted. + - [] Create few custom actions. Set up hotkey for custom actions and confirm they work. Enable/disable custom actions and confirm that the change is reflected in Advanced Paste UI - custom action is not listed. Try different ctrl + <num> in-app shortcuts for custom actions. Try moving custom actions up/down and confirm that the change is reflected in Advanced Paste UI. + - [] Open Settings and disable Custom format preview. Open Advanced Paste window with hotkey, enter some query and press enter. Observe that result is now pasted right away, without showing the preview first. + - [] Open Settings and Disable Enable Paste with AI. Open Advanced Paste window with hotkey and observe that Custom Input text box is now disabled. + * Clipboard History + - [] Open Settings and Enable clipboard history (if not enabled already). Open Advanced Paste window with hotkey, click Clipboard history and try deleting some entry. Check OS clipboard history (Win+V), and confirm that the same entry no longer exist. + - [] Open Advanced Paste window with hotkey, click Clipboard history, and click any entry (but first). Observe that entry is put on top of clipboard history. Check OS clipboard history (Win+V), and confirm that the same entry is on top of the clipboard. + - [] Open Settings and Disable clipboard history. Open Advanced Paste window with hotkey and observe that Clipboard history button is disabled. + * Disable Advanced Paste, try different Advanced Paste hotkeys and confirm that it's disabled and nothing happens. \ No newline at end of file diff --git a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj index f3ce71829f..e931d3bb85 100644 --- a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj +++ b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -10,10 +11,9 @@ <ProjectGuid>{f5e1146e-b7b3-4e11-85fd-270a500bd78c}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>CropAndLock</RootNamespace> - <WindowsTargetPlatformVersion Condition=" '$(WindowsTargetPlatformVersion)' == '' ">10.0.22621.0</WindowsTargetPlatformVersion> + <WindowsTargetPlatformVersion Condition=" '$(WindowsTargetPlatformVersion)' == '' ">10.0.26100.0</WindowsTargetPlatformVersion> <WindowsTargetPlatformMinVersion>10.0.19041.0</WindowsTargetPlatformMinVersion> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|ARM64"> <Configuration>Debug</Configuration> @@ -34,10 +34,6 @@ </ItemGroup> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> - <PlatformToolset Condition="'$(VisualStudioVersion)' == '16.0'">v142</PlatformToolset> - <PlatformToolset Condition="'$(VisualStudioVersion)' == '17.0'">v143</PlatformToolset> - <PlatformToolset Condition="'$(VisualStudioVersion)' == '18.0'">v143</PlatformToolset> <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> </PropertyGroup> @@ -64,14 +60,14 @@ <PropertyGroup Label="UserMacros" /> <PropertyGroup> <TargetName>PowerToys.$(MSBuildProjectName)</TargetName> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>_CONSOLE;WINRT_LEAN_AND_MEAN;%(PreprocessorDefinitions)</PreprocessorDefinitions> <WarningLevel>Level4</WarningLevel> <AdditionalOptions>%(AdditionalOptions) /bigobj</AdditionalOptions> - <AdditionalIncludeDirectories>$(SolutionDir)src\common\Telemetry;$(SolutionDir)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -112,6 +108,7 @@ <ClCompile Include="ChildWindow.cpp" /> <ClCompile Include="main.cpp" /> <ClCompile Include="ReparentCropAndLockWindow.cpp" /> + <ClCompile Include="ScreenshotCropAndLockWindow.cpp" /> <ClCompile Include="ThumbnailCropAndLockWindow.cpp" /> <ClCompile Include="OverlayWindow.cpp" /> <ClCompile Include="pch.cpp"> @@ -126,6 +123,7 @@ <ClInclude Include="DisplaysUtil.h" /> <ClInclude Include="ModuleConstants.h" /> <ClInclude Include="ReparentCropAndLockWindow.h" /> + <ClInclude Include="ScreenshotCropAndLockWindow.h" /> <ClInclude Include="ThumbnailCropAndLockWindow.h" /> <ClInclude Include="SettingsWindow.h" /> <ClInclude Include="OverlayWindow.h" /> @@ -145,30 +143,33 @@ <Manifest Include="app.manifest" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> + <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> + </ProjectReference> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets" Condition="Exists('..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets" Condition="Exists('$(RepoRoot)packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj.filters b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj.filters index bea68db119..e906ed2a02 100644 --- a/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj.filters +++ b/src/modules/CropAndLock/CropAndLock/CropAndLock.vcxproj.filters @@ -12,6 +12,7 @@ <ClCompile Include="ReparentCropAndLockWindow.cpp" /> <ClCompile Include="ChildWindow.cpp" /> <ClCompile Include="trace.cpp" /> + <ClCompile Include="ScreenshotCropAndLockWindow.cpp" /> </ItemGroup> <ItemGroup> <ClInclude Include="pch.h" /> @@ -28,6 +29,7 @@ <ClInclude Include="trace.h" /> <ClInclude Include="ModuleConstants.h" /> <ClInclude Include="DispatcherQueue.desktop.interop.h" /> + <ClInclude Include="ScreenshotCropAndLockWindow.h" /> </ItemGroup> <ItemGroup> <ResourceCompile Include="CropAndLock.rc" /> diff --git a/src/modules/CropAndLock/CropAndLock/OverlayWindow.cpp b/src/modules/CropAndLock/CropAndLock/OverlayWindow.cpp index 12f4598d74..3c35a09785 100644 --- a/src/modules/CropAndLock/CropAndLock/OverlayWindow.cpp +++ b/src/modules/CropAndLock/CropAndLock/OverlayWindow.cpp @@ -60,7 +60,7 @@ OverlayWindow::OverlayWindow( m_crosshairCursor.reset(winrt::check_pointer(LoadCursorW(nullptr, IDC_CROSS))); m_cursorType = CursorType::Standard; - // Setup the visual tree + // Set up the visual tree m_compositor = compositor; m_target = CreateWindowTarget(m_compositor); m_rootVisual = m_compositor.CreateContainerVisual(); diff --git a/src/modules/CropAndLock/CropAndLock/ScreenshotCropAndLockWindow.cpp b/src/modules/CropAndLock/CropAndLock/ScreenshotCropAndLockWindow.cpp new file mode 100644 index 0000000000..11afbd0a26 --- /dev/null +++ b/src/modules/CropAndLock/CropAndLock/ScreenshotCropAndLockWindow.cpp @@ -0,0 +1,178 @@ +#include "pch.h" +#include "ScreenshotCropAndLockWindow.h" + +const std::wstring ScreenshotCropAndLockWindow::ClassName = L"CropAndLock.ScreenshotCropAndLockWindow"; +std::once_flag ScreenshotCropAndLockWindowClassRegistration; + +void ScreenshotCropAndLockWindow::RegisterWindowClass() +{ + auto instance = winrt::check_pointer(GetModuleHandleW(nullptr)); + WNDCLASSEXW wcex = {}; + wcex.cbSize = sizeof(wcex); + wcex.style = CS_HREDRAW | CS_VREDRAW; + wcex.lpfnWndProc = WndProc; + wcex.hInstance = instance; + wcex.hIcon = LoadIconW(instance, IDI_APPLICATION); + wcex.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wcex.hbrBackground = static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)); + wcex.lpszClassName = ClassName.c_str(); + wcex.hIconSm = LoadIconW(wcex.hInstance, IDI_APPLICATION); + winrt::check_bool(RegisterClassExW(&wcex)); +} + +ScreenshotCropAndLockWindow::ScreenshotCropAndLockWindow(std::wstring const& titleString, int width, int height) +{ + auto instance = winrt::check_pointer(GetModuleHandleW(nullptr)); + + std::call_once(ScreenshotCropAndLockWindowClassRegistration, []() { RegisterWindowClass(); }); + + auto exStyle = 0; + auto style = WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN; + + RECT rect = { 0, 0, width, height }; + winrt::check_bool(AdjustWindowRectEx(&rect, style, false, exStyle)); + auto adjustedWidth = rect.right - rect.left; + auto adjustedHeight = rect.bottom - rect.top; + + winrt::check_bool(CreateWindowExW(exStyle, ClassName.c_str(), titleString.c_str(), style, CW_USEDEFAULT, CW_USEDEFAULT, adjustedWidth, adjustedHeight, nullptr, nullptr, instance, this)); + WINRT_ASSERT(m_window); +} + +ScreenshotCropAndLockWindow::~ScreenshotCropAndLockWindow() +{ + DestroyWindow(m_window); +} + +LRESULT ScreenshotCropAndLockWindow::MessageHandler(UINT const message, WPARAM const wparam, LPARAM const lparam) +{ + switch (message) + { + case WM_DESTROY: + if (m_closedCallback != nullptr && !m_destroyed) + { + m_destroyed = true; + m_closedCallback(m_window); + } + break; + case WM_PAINT: + if (m_captured && m_bitmap) + { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(m_window, &ps); + HDC memDC = CreateCompatibleDC(hdc); + SelectObject(memDC, m_bitmap.get()); + + RECT clientRect = {}; + GetClientRect(m_window, &clientRect); + int clientWidth = clientRect.right - clientRect.left; + int clientHeight = clientRect.bottom - clientRect.top; + + int srcWidth = m_destRect.right - m_destRect.left; + int srcHeight = m_destRect.bottom - m_destRect.top; + + float srcAspect = static_cast<float>(srcWidth) / srcHeight; + float dstAspect = static_cast<float>(clientWidth) / clientHeight; + + int drawWidth = clientWidth; + int drawHeight = static_cast<int>(clientWidth / srcAspect); + if (dstAspect > srcAspect) + { + drawHeight = clientHeight; + drawWidth = static_cast<int>(clientHeight * srcAspect); + } + + int offsetX = (clientWidth - drawWidth) / 2; + int offsetY = (clientHeight - drawHeight) / 2; + + SetStretchBltMode(hdc, HALFTONE); + StretchBlt(hdc, offsetX, offsetY, drawWidth, drawHeight, memDC, 0, 0, srcWidth, srcHeight, SRCCOPY); + DeleteDC(memDC); + EndPaint(m_window, &ps); + } + break; + default: + return base_type::MessageHandler(message, wparam, lparam); + } + return 0; +} + +void ScreenshotCropAndLockWindow::CropAndLock(HWND windowToCrop, RECT cropRect) +{ + if (m_captured) + { + return; + } + + // Get full window bounds + RECT windowRect{}; + winrt::check_hresult(DwmGetWindowAttribute( + windowToCrop, + DWMWA_EXTENDED_FRAME_BOUNDS, + &windowRect, + sizeof(windowRect))); + + RECT clientRect = ClientAreaInScreenSpace(windowToCrop); + auto offsetX = clientRect.left - windowRect.left; + auto offsetY = clientRect.top - windowRect.top; + + m_sourceRect = { + cropRect.left + offsetX, + cropRect.top + offsetY, + cropRect.right + offsetX, + cropRect.bottom + offsetY + }; + + int fullWidth = windowRect.right - windowRect.left; + int fullHeight = windowRect.bottom - windowRect.top; + + HDC fullDC = CreateCompatibleDC(nullptr); + HDC screenDC = GetDC(nullptr); + HBITMAP fullBitmap = CreateCompatibleBitmap(screenDC, fullWidth, fullHeight); + HGDIOBJ oldFullBitmap = SelectObject(fullDC, fullBitmap); + + // Capture full window + winrt::check_bool(PrintWindow(windowToCrop, fullDC, PW_RENDERFULLCONTENT)); + + + // Crop + int cropWidth = m_sourceRect.right - m_sourceRect.left; + int cropHeight = m_sourceRect.bottom - m_sourceRect.top; + + HDC cropDC = CreateCompatibleDC(nullptr); + HBITMAP cropBitmap = CreateCompatibleBitmap(screenDC, cropWidth, cropHeight); + HGDIOBJ oldCropBitmap = SelectObject(cropDC, cropBitmap); + ReleaseDC(nullptr, screenDC); + + BitBlt( + cropDC, + 0, + 0, + cropWidth, + cropHeight, + fullDC, + m_sourceRect.left, + m_sourceRect.top, + SRCCOPY); + + SelectObject(fullDC, oldFullBitmap); + DeleteObject(fullBitmap); + DeleteDC(fullDC); + + SelectObject(cropDC, oldCropBitmap); + DeleteDC(cropDC); + m_bitmap.reset(cropBitmap); + + // Resize our window + RECT dest{ 0, 0, cropWidth, cropHeight }; + LONG_PTR exStyle = GetWindowLongPtrW(m_window, GWL_EXSTYLE); + LONG_PTR style = GetWindowLongPtrW(m_window, GWL_STYLE); + + winrt::check_bool(AdjustWindowRectEx(&dest, static_cast<DWORD>(style), FALSE, static_cast<DWORD>(exStyle))); + + winrt::check_bool(SetWindowPos( + m_window, HWND_TOPMOST, 0, 0, dest.right - dest.left, dest.bottom - dest.top, SWP_NOMOVE | SWP_SHOWWINDOW)); + + m_destRect = { 0, 0, cropWidth, cropHeight }; + m_captured = true; + InvalidateRect(m_window, nullptr, FALSE); +} \ No newline at end of file diff --git a/src/modules/CropAndLock/CropAndLock/ScreenshotCropAndLockWindow.h b/src/modules/CropAndLock/CropAndLock/ScreenshotCropAndLockWindow.h new file mode 100644 index 0000000000..149e4c740a --- /dev/null +++ b/src/modules/CropAndLock/CropAndLock/ScreenshotCropAndLockWindow.h @@ -0,0 +1,27 @@ +#pragma once +#include <robmikh.common/DesktopWindow.h> +#include "CropAndLockWindow.h" + +struct ScreenshotCropAndLockWindow : robmikh::common::desktop::DesktopWindow<ScreenshotCropAndLockWindow>, CropAndLockWindow +{ + static const std::wstring ClassName; + ScreenshotCropAndLockWindow(std::wstring const& titleString, int width, int height); + ~ScreenshotCropAndLockWindow() override; + LRESULT MessageHandler(UINT const message, WPARAM const wparam, LPARAM const lparam); + + HWND Handle() override { return m_window; } + void CropAndLock(HWND windowToCrop, RECT cropRect) override; + void OnClosed(std::function<void(HWND)> callback) override { m_closedCallback = callback; } + +private: + static void RegisterWindowClass(); + +private: + std::unique_ptr<void, decltype(&DeleteObject)> m_bitmap{ nullptr, &DeleteObject }; + RECT m_destRect = {}; + RECT m_sourceRect = {}; + + bool m_captured = false; + bool m_destroyed = false; + std::function<void(HWND)> m_closedCallback; +}; \ No newline at end of file diff --git a/src/modules/CropAndLock/CropAndLock/SettingsWindow.h b/src/modules/CropAndLock/CropAndLock/SettingsWindow.h index 88489601ee..f51e4636f0 100644 --- a/src/modules/CropAndLock/CropAndLock/SettingsWindow.h +++ b/src/modules/CropAndLock/CropAndLock/SettingsWindow.h @@ -4,4 +4,5 @@ enum class CropAndLockType { Reparent, Thumbnail, + Screenshot, }; diff --git a/src/modules/CropAndLock/CropAndLock/ThumbnailCropAndLockWindow.cpp b/src/modules/CropAndLock/CropAndLock/ThumbnailCropAndLockWindow.cpp index 454e8d5abe..12de3d5d2e 100644 --- a/src/modules/CropAndLock/CropAndLock/ThumbnailCropAndLockWindow.cpp +++ b/src/modules/CropAndLock/CropAndLock/ThumbnailCropAndLockWindow.cpp @@ -147,7 +147,7 @@ void ThumbnailCropAndLockWindow::CropAndLock(HWND windowToCrop, RECT cropRect) auto adjustedHeight = windowRect.bottom - windowRect.top; winrt::check_bool(SetWindowPos(m_window, HWND_TOPMOST, 0, 0, adjustedWidth, adjustedHeight, SWP_NOMOVE | SWP_SHOWWINDOW)); - // Setup the thumbnail + // Set up the thumbnail winrt::check_hresult(DwmRegisterThumbnail(m_window, m_currentTarget, m_thumbnail.addressof())); clientRect = {}; diff --git a/src/modules/CropAndLock/CropAndLock/main.cpp b/src/modules/CropAndLock/CropAndLock/main.cpp index 79d26fc8c1..8f3ac89569 100644 --- a/src/modules/CropAndLock/CropAndLock/main.cpp +++ b/src/modules/CropAndLock/CropAndLock/main.cpp @@ -2,6 +2,7 @@ #include "SettingsWindow.h" #include "OverlayWindow.h" #include "CropAndLockWindow.h" +#include "ScreenshotCropAndLockWindow.h" #include "ThumbnailCropAndLockWindow.h" #include "ReparentCropAndLockWindow.h" #include "ModuleConstants.h" @@ -17,6 +18,9 @@ #include <common/Telemetry/EtwTrace/EtwTrace.h> +#include <common/Themes/theme_helpers.h> +#include <common/Themes/theme_listener.h> + #pragma comment(linker, "/manifestdependency:\"type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"") namespace winrt @@ -35,6 +39,23 @@ namespace util const std::wstring instanceMutexName = L"Local\\PowerToys_CropAndLock_InstanceMutex"; bool m_running = true; +// Theming +ThemeListener theme_listener{}; +// Keep a list of our cropped windows +std::vector<std::shared_ptr<CropAndLockWindow>> croppedWindows; + +void handleTheme() +{ + auto theme = theme_listener.AppTheme; + auto isDark = theme == Theme::Dark; + Logger::info(L"Theme is now {}", isDark ? L"Dark" : L"Light"); + for (auto&& croppedWindow : croppedWindows) + { + ThemeHelpers::SetImmersiveDarkMode(croppedWindow->Handle(), isDark); + } +} + + int WINAPI wWinMain(_In_ HINSTANCE, _In_opt_ HINSTANCE, _In_ PWSTR lpCmdLine, _In_ int) { // Initialize COM @@ -42,6 +63,8 @@ int WINAPI wWinMain(_In_ HINSTANCE, _In_opt_ HINSTANCE, _In_ PWSTR lpCmdLine, _I Trace::CropAndLock::RegisterProvider(); + theme_listener.AddChangedHandler(handleTheme); + Shared::Trace::ETWTrace trace; trace.UpdateState(true); @@ -107,12 +130,11 @@ int WINAPI wWinMain(_In_ HINSTANCE, _In_opt_ HINSTANCE, _In_ PWSTR lpCmdLine, _I // Create our overlay window std::unique_ptr<OverlayWindow> overlayWindow; - // Keep a list of our cropped windows - std::vector<std::shared_ptr<CropAndLockWindow>> croppedWindows; // Handles and thread for the events sent from runner HANDLE m_reparent_event_handle; HANDLE m_thumbnail_event_handle; + HANDLE m_screenshot_event_handle; HANDLE m_exit_event_handle; std::thread m_event_triggers_thread; @@ -161,12 +183,18 @@ int WINAPI wWinMain(_In_ HINSTANCE, _In_opt_ HINSTANCE, _In_ PWSTR lpCmdLine, _I Logger::trace(L"Creating a thumbnail window"); Trace::CropAndLock::CreateThumbnailWindow(); break; + case CropAndLockType::Screenshot: + croppedWindow = std::make_shared<ScreenshotCropAndLockWindow>(title, 800, 600); + Logger::trace(L"Creating a screenshot window"); + Trace::CropAndLock::CreateScreenshotWindow(); + break; default: return; } croppedWindow->CropAndLock(targetWindow, cropRect); croppedWindow->OnClosed(removeWindowCallback); croppedWindows.push_back(croppedWindow); + handleTheme(); }; overlayWindow.reset(); @@ -194,8 +222,9 @@ int WINAPI wWinMain(_In_ HINSTANCE, _In_opt_ HINSTANCE, _In_ PWSTR lpCmdLine, _I // Start a thread to listen on the events. m_reparent_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::CROP_AND_LOCK_REPARENT_EVENT); m_thumbnail_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::CROP_AND_LOCK_THUMBNAIL_EVENT); + m_screenshot_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::CROP_AND_LOCK_SCREENSHOT_EVENT); m_exit_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::CROP_AND_LOCK_EXIT_EVENT); - if (!m_reparent_event_handle || !m_thumbnail_event_handle || !m_exit_event_handle) + if (!m_reparent_event_handle || !m_thumbnail_event_handle || !m_screenshot_event_handle || !m_exit_event_handle) { Logger::warn(L"Failed to create events. {}", get_last_error_or_default(GetLastError())); return 1; @@ -203,10 +232,10 @@ int WINAPI wWinMain(_In_ HINSTANCE, _In_opt_ HINSTANCE, _In_ PWSTR lpCmdLine, _I m_event_triggers_thread = std::thread([&]() { MSG msg; - HANDLE event_handles[3] = { m_reparent_event_handle, m_thumbnail_event_handle, m_exit_event_handle }; + HANDLE event_handles[4] = { m_reparent_event_handle, m_thumbnail_event_handle, m_screenshot_event_handle, m_exit_event_handle }; while (m_running) { - DWORD dwEvt = MsgWaitForMultipleObjects(3, event_handles, false, INFINITE, QS_ALLINPUT); + DWORD dwEvt = MsgWaitForMultipleObjects(4, event_handles, false, INFINITE, QS_ALLINPUT); if (!m_running) { break; @@ -238,13 +267,25 @@ int WINAPI wWinMain(_In_ HINSTANCE, _In_opt_ HINSTANCE, _In_ PWSTR lpCmdLine, _I break; } case WAIT_OBJECT_0 + 2: + { + // Screenshot Event + bool enqueueSucceeded = controller.DispatcherQueue().TryEnqueue([&]() { + ProcessCommand(CropAndLockType::Screenshot); + }); + if (!enqueueSucceeded) + { + Logger::error("Couldn't enqueue message to screenshot a window."); + } + break; + } + case WAIT_OBJECT_0 + 3: { // Exit Event Logger::trace(L"Received an exit event."); PostThreadMessage(mainThreadId, WM_QUIT, 0, 0); break; } - case WAIT_OBJECT_0 + 3: + case WAIT_OBJECT_0 + 4: if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); @@ -274,6 +315,7 @@ int WINAPI wWinMain(_In_ HINSTANCE, _In_opt_ HINSTANCE, _In_ PWSTR lpCmdLine, _I SetEvent(m_reparent_event_handle); CloseHandle(m_reparent_event_handle); CloseHandle(m_thumbnail_event_handle); + CloseHandle(m_screenshot_event_handle); CloseHandle(m_exit_event_handle); m_event_triggers_thread.join(); diff --git a/src/modules/CropAndLock/CropAndLock/packages.config b/src/modules/CropAndLock/CropAndLock/packages.config index 691158d1b2..51fbe043d6 100644 --- a/src/modules/CropAndLock/CropAndLock/packages.config +++ b/src/modules/CropAndLock/CropAndLock/packages.config @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> <package id="robmikh.common" version="0.0.23-beta" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/CropAndLock/CropAndLock/trace.cpp b/src/modules/CropAndLock/CropAndLock/trace.cpp index 42674ec624..3a08fb9683 100644 --- a/src/modules/CropAndLock/CropAndLock/trace.cpp +++ b/src/modules/CropAndLock/CropAndLock/trace.cpp @@ -41,6 +41,15 @@ void Trace::CropAndLock::ActivateThumbnail() noexcept TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); } +void Trace::CropAndLock::ActivateScreenshot() noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "CropAndLock_ActivateScreenshot", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} + void Trace::CropAndLock::CreateReparentWindow() noexcept { TraceLoggingWriteWrapper( @@ -59,8 +68,17 @@ void Trace::CropAndLock::CreateThumbnailWindow() noexcept TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); } +void Trace::CropAndLock::CreateScreenshotWindow() noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "CropAndLock_CreateScreenshotWindow", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} + // Event to send settings telemetry. -void Trace::CropAndLock::SettingsTelemetry(PowertoyModuleIface::Hotkey& reparentHotkey, PowertoyModuleIface::Hotkey& thumbnailHotkey) noexcept +void Trace::CropAndLock::SettingsTelemetry(PowertoyModuleIface::Hotkey& reparentHotkey, PowertoyModuleIface::Hotkey& thumbnailHotkey, PowertoyModuleIface::Hotkey& screenshotHotkey) noexcept { std::wstring hotKeyStrReparent = std::wstring(reparentHotkey.win ? L"Win + " : L"") + @@ -76,11 +94,19 @@ void Trace::CropAndLock::SettingsTelemetry(PowertoyModuleIface::Hotkey& reparent std::wstring(thumbnailHotkey.alt ? L"Alt + " : L"") + std::wstring(L"VK ") + std::to_wstring(thumbnailHotkey.key); + std::wstring hotKeyStrScreenshot = + std::wstring(screenshotHotkey.win ? L"Win + " : L"") + + std::wstring(screenshotHotkey.ctrl ? L"Ctrl + " : L"") + + std::wstring(screenshotHotkey.shift ? L"Shift + " : L"") + + std::wstring(screenshotHotkey.alt ? L"Alt + " : L"") + + std::wstring(L"VK ") + std::to_wstring(screenshotHotkey.key); + TraceLoggingWriteWrapper( g_hProvider, "CropAndLock_Settings", ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), TraceLoggingWideString(hotKeyStrReparent.c_str(), "ReparentHotKey"), - TraceLoggingWideString(hotKeyStrThumbnail.c_str(), "ThumbnailHotkey")); + TraceLoggingWideString(hotKeyStrThumbnail.c_str(), "ThumbnailHotkey"), + TraceLoggingWideString(hotKeyStrScreenshot.c_str(), "ScreenshotHotkey")); } diff --git a/src/modules/CropAndLock/CropAndLock/trace.h b/src/modules/CropAndLock/CropAndLock/trace.h index 5a9aaa95ca..bd9a3431a2 100644 --- a/src/modules/CropAndLock/CropAndLock/trace.h +++ b/src/modules/CropAndLock/CropAndLock/trace.h @@ -12,8 +12,10 @@ public: static void Enable(bool enabled) noexcept; static void ActivateReparent() noexcept; static void ActivateThumbnail() noexcept; + static void ActivateScreenshot() noexcept; static void CreateReparentWindow() noexcept; static void CreateThumbnailWindow() noexcept; - static void SettingsTelemetry(PowertoyModuleIface::Hotkey&, PowertoyModuleIface::Hotkey&) noexcept; + static void CreateScreenshotWindow() noexcept; + static void SettingsTelemetry(PowertoyModuleIface::Hotkey&, PowertoyModuleIface::Hotkey&, PowertoyModuleIface::Hotkey&) noexcept; }; }; diff --git a/src/modules/CropAndLock/CropAndLockModuleInterface/CropAndLockModuleInterface.vcxproj b/src/modules/CropAndLock/CropAndLockModuleInterface/CropAndLockModuleInterface.vcxproj index c7fccf462a..aaaf2d0bd5 100644 --- a/src/modules/CropAndLock/CropAndLockModuleInterface/CropAndLockModuleInterface.vcxproj +++ b/src/modules/CropAndLock/CropAndLockModuleInterface/CropAndLockModuleInterface.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> @@ -8,17 +9,16 @@ <RootNamespace>CropAndLockModuleInterface</RootNamespace> <ProjectName>CropAndLockModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,12 +31,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.CropAndLockModuleInterface</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -91,28 +91,28 @@ <ResourceCompile Include="CropAndLockModuleInterface.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.211019.2\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.211019.2\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets" Condition="Exists('..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.211019.2\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.211019.2\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets" Condition="Exists('$(RepoRoot)packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/CropAndLock/CropAndLockModuleInterface/dllmain.cpp b/src/modules/CropAndLock/CropAndLockModuleInterface/dllmain.cpp index 42c7c6da7e..9821b786f1 100644 --- a/src/modules/CropAndLock/CropAndLockModuleInterface/dllmain.cpp +++ b/src/modules/CropAndLock/CropAndLockModuleInterface/dllmain.cpp @@ -29,6 +29,7 @@ namespace const wchar_t JSON_KEY_CODE[] = L"code"; const wchar_t JSON_KEY_REPARENT_HOTKEY[] = L"reparent-hotkey"; const wchar_t JSON_KEY_THUMBNAIL_HOTKEY[] = L"thumbnail-hotkey"; + const wchar_t JSON_KEY_SCREENSHOT_HOTKEY[] = L"screenshot-hotkey"; const wchar_t JSON_KEY_VALUE[] = L"value"; } @@ -124,6 +125,10 @@ public: SetEvent(m_thumbnail_event_handle); Trace::CropAndLock::ActivateThumbnail(); } + if (hotkeyId == 2) { // Same order as set by get_hotkeys + SetEvent(m_screenshot_event_handle); + Trace::CropAndLock::ActivateScreenshot(); + } return true; } @@ -133,12 +138,13 @@ public: virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override { - if (hotkeys && buffer_size >= 2) + if (hotkeys && buffer_size >= 3) { hotkeys[0] = m_reparent_hotkey; hotkeys[1] = m_thumbnail_hotkey; + hotkeys[2] = m_screenshot_hotkey; } - return 2; + return 3; } // Enable the powertoy @@ -171,7 +177,7 @@ public: virtual void send_settings_telemetry() override { Logger::info("Send settings telemetry"); - Trace::CropAndLock::SettingsTelemetry(m_reparent_hotkey, m_thumbnail_hotkey); + Trace::CropAndLock::SettingsTelemetry(m_reparent_hotkey, m_thumbnail_hotkey, m_screenshot_hotkey); } CropAndLockModuleInterface() @@ -182,6 +188,7 @@ public: m_reparent_event_handle = CreateDefaultEvent(CommonSharedConstants::CROP_AND_LOCK_REPARENT_EVENT); m_thumbnail_event_handle = CreateDefaultEvent(CommonSharedConstants::CROP_AND_LOCK_THUMBNAIL_EVENT); + m_screenshot_event_handle = CreateDefaultEvent(CommonSharedConstants::CROP_AND_LOCK_SCREENSHOT_EVENT); m_exit_event_handle = CreateDefaultEvent(CommonSharedConstants::CROP_AND_LOCK_EXIT_EVENT); init_settings(); @@ -202,6 +209,7 @@ private: ResetEvent(m_reparent_event_handle); ResetEvent(m_thumbnail_event_handle); + ResetEvent(m_screenshot_event_handle); ResetEvent(m_exit_event_handle); SHELLEXECUTEINFOW sei{ sizeof(sei) }; @@ -234,6 +242,7 @@ private: ResetEvent(m_reparent_event_handle); ResetEvent(m_thumbnail_event_handle); + ResetEvent(m_screenshot_event_handle); // Log telemetry if (traceEvent) @@ -283,6 +292,21 @@ private: { Logger::error("Failed to initialize CropAndLock thumbnail shortcut from settings. Value will keep unchanged."); } + try + { + Hotkey _temp_screenshot; + auto jsonHotkeyObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SCREENSHOT_HOTKEY).GetNamedObject(JSON_KEY_VALUE); + _temp_screenshot.win = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_WIN); + _temp_screenshot.alt = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_ALT); + _temp_screenshot.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT); + _temp_screenshot.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL); + _temp_screenshot.key = static_cast<unsigned char>(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE)); + m_screenshot_hotkey = _temp_screenshot; + } + catch (...) + { + Logger::error("Failed to initialize CropAndLock screenshot shortcut from settings. Value will keep unchanged."); + } } else { @@ -321,9 +345,11 @@ private: // TODO: actual default hotkey setting in line with other PowerToys. Hotkey m_reparent_hotkey = { .win = true, .ctrl = true, .shift = true, .alt = false, .key = 'R' }; Hotkey m_thumbnail_hotkey = { .win = true, .ctrl = true, .shift = true, .alt = false, .key = 'T' }; + Hotkey m_screenshot_hotkey = { .win = true, .ctrl = true, .shift = true, .alt = false, .key = 'S' }; HANDLE m_reparent_event_handle; HANDLE m_thumbnail_event_handle; + HANDLE m_screenshot_event_handle; HANDLE m_exit_event_handle; }; diff --git a/src/modules/CropAndLock/CropAndLockModuleInterface/packages.config b/src/modules/CropAndLock/CropAndLockModuleInterface/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/CropAndLock/CropAndLockModuleInterface/packages.config +++ b/src/modules/CropAndLock/CropAndLockModuleInterface/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariables.csproj b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariables.csproj index 2dcbc1e237..01587e9186 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariables.csproj +++ b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariables.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> @@ -16,7 +16,7 @@ <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> <DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> <AssemblyName>PowerToys.EnvironmentVariables</AssemblyName> <ApplicationIcon>Assets\EnvironmentVariables\EnvironmentVariables.ico</ApplicationIcon> <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> diff --git a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml index 32c536101e..ae77a78caa 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml +++ b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml @@ -20,27 +20,14 @@ <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - <Grid - x:Name="titleBar" - Height="32" - ColumnSpacing="16"> - <Grid.ColumnDefinitions> - <ColumnDefinition x:Name="LeftPaddingColumn" Width="0" /> - <ColumnDefinition x:Name="IconColumn" Width="Auto" /> - <ColumnDefinition x:Name="TitleColumn" Width="Auto" /> - <ColumnDefinition x:Name="RightPaddingColumn" Width="0" /> - </Grid.ColumnDefinitions> - <Image - Grid.Column="1" - Width="16" - Height="16" - VerticalAlignment="Center" - Source="../Assets/EnvironmentVariables/EnvironmentVariables.ico" /> - <TextBlock - x:Name="AppTitleTextBlock" - Grid.Column="2" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}" /> - </Grid> + <TitleBar x:Name="titleBar" IsTabStop="False"> + <!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource --> + <TitleBar.LeftHeader> + <ImageIcon + Height="16" + Margin="16,0,0,0" + Source="/Assets/EnvironmentVariables/EnvironmentVariables.ico" /> + </TitleBar.LeftHeader> + </TitleBar> </Grid> </winuiex:WindowEx> diff --git a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml.cs b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml.cs index 57639312e1..1543aaf134 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml.cs +++ b/src/modules/EnvironmentVariables/EnvironmentVariables/EnvironmentVariablesXAML/MainWindow.xaml.cs @@ -4,22 +4,19 @@ using System; using System.Runtime.InteropServices; - using EnvironmentVariables.Win32; using EnvironmentVariablesUILib; using EnvironmentVariablesUILib.Helpers; using EnvironmentVariablesUILib.ViewModels; using ManagedCommon; using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using WinUIEx; namespace EnvironmentVariables { - /// <summary> - /// An empty window that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class MainWindow : WindowEx { private EnvironmentVariablesMainPage MainPage { get; } @@ -34,8 +31,9 @@ namespace EnvironmentVariables AppWindow.SetIcon("Assets/EnvironmentVariables/EnvironmentVariables.ico"); var loader = ResourceLoaderInstance.ResourceLoader; var title = App.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle"); + Title = title; - AppTitleTextBlock.Text = title; + titleBar.Title = title; var handle = this.GetWindowHandle(); RegisterWindow(handle); diff --git a/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesOpenedEvent.cs b/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesOpenedEvent.cs index 0cbcecdd0b..07f94a46a3 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesOpenedEvent.cs +++ b/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesOpenedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace EnvironmentVariables.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class EnvironmentVariablesOpenedEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesProfileEnabledEvent.cs b/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesProfileEnabledEvent.cs index a711e3096a..5c9eeebbc0 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesProfileEnabledEvent.cs +++ b/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesProfileEnabledEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace EnvironmentVariables.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class EnvironmentVariablesProfileEnabledEvent : EventBase, IEvent { public bool Enabled { get; set; } diff --git a/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesVariableChangedEvent.cs b/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesVariableChangedEvent.cs index 80231358a6..6ded4827e7 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesVariableChangedEvent.cs +++ b/src/modules/EnvironmentVariables/EnvironmentVariables/Telemetry/EnvironmentVariablesVariableChangedEvent.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using EnvironmentVariablesUILib.Models; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -11,6 +11,7 @@ using Microsoft.PowerToys.Telemetry.Events; namespace EnvironmentVariables.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class EnvironmentVariablesVariableChangedEvent : EventBase, IEvent { public VariablesSetType VariablesType { get; set; } diff --git a/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/EnvironmentVariablesModuleInterface.vcxproj b/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/EnvironmentVariablesModuleInterface.vcxproj index 068a0ad590..183084faf2 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/EnvironmentVariablesModuleInterface.vcxproj +++ b/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/EnvironmentVariablesModuleInterface.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h EnvironmentVariablesModuleInterface.base.rc EnvironmentVariablesModuleInterface.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h EnvironmentVariablesModuleInterface.base.rc EnvironmentVariablesModuleInterface.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>17.0</VCProjectVersion> @@ -11,17 +12,16 @@ <RootNamespace>EnvironmentVariablesModuleInterface</RootNamespace> <ProjectName>EnvironmentVariablesModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -35,7 +35,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.EnvironmentVariablesModuleInterface</TargetName> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> @@ -70,7 +70,7 @@ </ItemDefinitionGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -94,26 +94,26 @@ <None Include="Resource.resx" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\version\version.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\version\version.vcxproj"> <Project>{cc6e41ac-8174-4e8a-8d22-85dd7f4851df}</Project> </ProjectReference> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/dllmain.cpp b/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/dllmain.cpp index a4158d1c66..89db922ddd 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/dllmain.cpp +++ b/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/dllmain.cpp @@ -146,7 +146,7 @@ public: } } - m_showEventWaiter = EventWaiter(CommonSharedConstants::SHOW_ENVIRONMENT_VARIABLES_EVENT, [&](int err) { + m_showEventWaiter.start(CommonSharedConstants::SHOW_ENVIRONMENT_VARIABLES_EVENT, [&](DWORD err) { if (m_enabled && err == ERROR_SUCCESS) { Logger::trace(L"{} event was signaled", CommonSharedConstants::SHOW_ENVIRONMENT_VARIABLES_EVENT); @@ -164,7 +164,7 @@ public: } }); - m_showAdminEventWaiter = EventWaiter(CommonSharedConstants::SHOW_ENVIRONMENT_VARIABLES_ADMIN_EVENT, [&](int err) { + m_showAdminEventWaiter.start(CommonSharedConstants::SHOW_ENVIRONMENT_VARIABLES_ADMIN_EVENT, [&](DWORD err) { if (m_enabled && err == ERROR_SUCCESS) { Logger::trace(L"{} event was signaled", CommonSharedConstants::SHOW_ENVIRONMENT_VARIABLES_ADMIN_EVENT); diff --git a/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/packages.config b/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/packages.config +++ b/src/modules/EnvironmentVariables/EnvironmentVariablesModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesMainPage.xaml b/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesMainPage.xaml index de06561b50..f2628cf375 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesMainPage.xaml +++ b/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesMainPage.xaml @@ -21,157 +21,6 @@ <ResourceDictionary Source="ms-appx:///CommunityToolkit.WinUI.Controls.SettingsControls/SettingsExpander/SettingsExpander.xaml" /> </ResourceDictionary.MergedDictionaries> - <ResourceDictionary.ThemeDictionaries> - <ResourceDictionary x:Key="Default"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="Light"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="HighContrast"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SystemColorHighlightTextColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SystemControlBackgroundBaseLowBrush" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SystemColorGrayTextColor" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="SystemColorButtonTextColorBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="SystemControlDisabledBaseMediumLowBrush" /> - </ResourceDictionary> - - </ResourceDictionary.ThemeDictionaries> - - <Style x:Key="SubtleButtonStyle" TargetType="Button"> - <Setter Property="Background" Value="{ThemeResource SubtleButtonBackground}" /> - <Setter Property="BackgroundSizing" Value="InnerBorderEdge" /> - <Setter Property="Foreground" Value="{ThemeResource SubtleButtonForeground}" /> - <Setter Property="BorderBrush" Value="{ThemeResource SubtleButtonBorderBrush}" /> - <Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" /> - <Setter Property="Padding" Value="{StaticResource ButtonPadding}" /> - <Setter Property="HorizontalAlignment" Value="Left" /> - <Setter Property="VerticalAlignment" Value="Center" /> - <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> - <Setter Property="FontWeight" Value="Normal" /> - <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> - <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> - <Setter Property="FocusVisualMargin" Value="-3" /> - <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> - <Setter Property="Template"> - <Setter.Value> - <ControlTemplate TargetType="Button"> - <ContentPresenter - x:Name="ContentPresenter" - Padding="{TemplateBinding Padding}" - HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" - VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" - AnimatedIcon.State="Normal" - AutomationProperties.AccessibilityView="Raw" - Background="{TemplateBinding Background}" - BackgroundSizing="{TemplateBinding BackgroundSizing}" - BorderBrush="{TemplateBinding BorderBrush}" - BorderThickness="{TemplateBinding BorderThickness}" - Content="{TemplateBinding Content}" - ContentTemplate="{TemplateBinding ContentTemplate}" - ContentTransitions="{TemplateBinding ContentTransitions}" - CornerRadius="{TemplateBinding CornerRadius}" - Foreground="{TemplateBinding Foreground}"> - <ContentPresenter.BackgroundTransition> - <BrushTransition Duration="0:0:0.083" /> - </ContentPresenter.BackgroundTransition> - <VisualStateManager.VisualStateGroups> - <VisualStateGroup x:Name="CommonStates"> - <VisualState x:Name="Normal" /> - <VisualState x:Name="PointerOver"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="PointerOver" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Pressed"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Pressed" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Disabled"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <!-- DisabledVisual Should be handled by the control, not the animated icon. --> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Normal" /> - </VisualState.Setters> - </VisualState> - </VisualStateGroup> - </VisualStateManager.VisualStateGroups> - </ContentPresenter> - </ControlTemplate> - </Setter.Value> - </Setter> - </Style> - <x:Double x:Key="SecondaryTextFontSize">12</x:Double> <Style x:Key="SecondaryTextStyle" TargetType="TextBlock"> <Setter Property="FontSize" Value="{StaticResource SecondaryTextFontSize}" /> diff --git a/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesUILib.csproj b/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesUILib.csproj index 24820b7d83..858f43313f 100644 --- a/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesUILib.csproj +++ b/src/modules/EnvironmentVariables/EnvironmentVariablesUILib/EnvironmentVariablesUILib.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <OutputType>Library</OutputType> @@ -15,8 +15,6 @@ <ProjectPriFileName>PowerToys.EnvironmentVariablesUILib.pri</ProjectPriFileName> <GenerateLibraryLayout>true</GenerateLibraryLayout> <IsPackable>true</IsPackable> - <!-- The default generated file path exceeds the length limit 260 on the build agent. Using a shorter path as a workaround. --> - <CompilerGeneratedFilesOutputPath>$(ProjectDir)obj\g</CompilerGeneratedFilesOutputPath> </PropertyGroup> <PropertyGroup> @@ -56,7 +54,4 @@ <Manifest Include="$(ApplicationManifest)" /> </ItemGroup> - <Target Name="EnsureCompilerGeneratedFilesOutputPathExists" BeforeTargets="XamlPreCompile"> - <MakeDir Directories="$(CompilerGeneratedFilesOutputPath)" /> - </Target> </Project> diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.cpp b/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.cpp new file mode 100644 index 0000000000..17015e1ea3 --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.cpp @@ -0,0 +1,248 @@ +#include "pch.h" +#include "CLILogic.h" +#include <common/utils/json.h> +#include <iostream> +#include <sstream> +#include <chrono> +#include "resource.h" +#include <common/logger/logger.h> +#include <common/utils/logger_helper.h> +#include <type_traits> + +template<typename T> +DWORD_PTR ToDwordPtr(T val) +{ + if constexpr (std::is_pointer_v<T>) + { + return reinterpret_cast<DWORD_PTR>(val); + } + else + { + return static_cast<DWORD_PTR>(val); + } +} + +template<typename... Args> +std::wstring FormatString(IStringProvider& strings, UINT id, Args... args) +{ + std::wstring format = strings.GetString(id); + if (format.empty()) return L""; + + DWORD_PTR arguments[] = { ToDwordPtr(args)..., 0 }; + + LPWSTR buffer = nullptr; + FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_STRING | FORMAT_MESSAGE_ARGUMENT_ARRAY, + format.c_str(), + 0, + 0, + reinterpret_cast<LPWSTR>(&buffer), + 0, + reinterpret_cast<va_list*>(arguments)); + + if (buffer) + { + std::wstring result(buffer); + LocalFree(buffer); + return result; + } + return L""; +} + +std::wstring get_usage(IStringProvider& strings) +{ + return strings.GetString(IDS_USAGE); +} + +std::wstring get_json(const std::vector<ProcessResult>& results) +{ + json::JsonObject root; + json::JsonArray processes; + + for (const auto& result : results) + { + json::JsonObject process; + process.SetNamedValue(L"pid", json::JsonValue::CreateNumberValue(result.pid)); + process.SetNamedValue(L"name", json::JsonValue::CreateStringValue(result.name)); + process.SetNamedValue(L"user", json::JsonValue::CreateStringValue(result.user)); + + json::JsonArray files; + for (const auto& file : result.files) + { + files.Append(json::JsonValue::CreateStringValue(file)); + } + process.SetNamedValue(L"files", files); + + processes.Append(process); + } + + root.SetNamedValue(L"processes", processes); + return root.Stringify().c_str(); +} + +std::wstring get_text(const std::vector<ProcessResult>& results, IStringProvider& strings) +{ + std::wstringstream ss; + if (results.empty()) + { + ss << strings.GetString(IDS_NO_PROCESSES); + return ss.str(); + } + + ss << strings.GetString(IDS_HEADER); + for (const auto& result : results) + { + ss << result.pid << L"\t" + << result.user << L"\t" + << result.name << std::endl; + } + return ss.str(); +} + +std::wstring kill_processes(const std::vector<ProcessResult>& results, IProcessTerminator& terminator, IStringProvider& strings) +{ + std::wstringstream ss; + for (const auto& result : results) + { + if (terminator.terminate(result.pid)) + { + ss << FormatString(strings, IDS_TERMINATED, result.pid, result.name.c_str()); + } + else + { + ss << FormatString(strings, IDS_FAILED_TERMINATE, result.pid, result.name.c_str()); + } + } + return ss.str(); +} + +CommandResult run_command(int argc, wchar_t* argv[], IProcessFinder& finder, IProcessTerminator& terminator, IStringProvider& strings) +{ + Logger::info("Parsing arguments"); + if (argc < 2) + { + Logger::warn("No arguments provided"); + return { 1, get_usage(strings) }; + } + + bool json_output = false; + bool kill = false; + bool wait = false; + int timeout_ms = -1; + std::vector<std::wstring> paths; + + for (int i = 1; i < argc; ++i) + { + std::wstring arg = argv[i]; + if (arg == L"--json") + { + json_output = true; + } + else if (arg == L"--kill") + { + kill = true; + } + else if (arg == L"--wait") + { + wait = true; + } + else if (arg == L"--timeout") + { + if (i + 1 < argc) + { + try + { + timeout_ms = std::stoi(argv[++i]); + } + catch (...) + { + Logger::error("Invalid timeout value"); + return { 1, strings.GetString(IDS_ERROR_INVALID_TIMEOUT) }; + } + } + else + { + Logger::error("Timeout argument missing"); + return { 1, strings.GetString(IDS_ERROR_TIMEOUT_ARG) }; + } + } + else if (arg == L"--help") + { + return { 0, get_usage(strings) }; + } + else + { + paths.push_back(arg); + } + } + + if (paths.empty()) + { + Logger::error("No paths specified"); + return { 1, strings.GetString(IDS_ERROR_NO_PATHS) }; + } + + Logger::info("Processing {} paths", paths.size()); + + if (wait) + { + std::wstringstream ss; + if (json_output) + { + Logger::warn("Wait is incompatible with JSON output"); + ss << strings.GetString(IDS_WARN_JSON_WAIT); + json_output = false; + } + + ss << strings.GetString(IDS_WAITING); + auto start_time = std::chrono::steady_clock::now(); + while (true) + { + auto results = finder.find(paths); + if (results.empty()) + { + Logger::info("Files unlocked"); + ss << strings.GetString(IDS_UNLOCKED); + break; + } + + if (timeout_ms >= 0) + { + auto current_time = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(current_time - start_time).count(); + if (elapsed > timeout_ms) + { + Logger::warn("Timeout waiting for files to be unlocked"); + ss << strings.GetString(IDS_TIMEOUT); + return { 1, ss.str() }; + } + } + + Sleep(200); + } + return { 0, ss.str() }; + } + + auto results = finder.find(paths); + Logger::info("Found {} processes locking the files", results.size()); + std::wstringstream output_ss; + + if (kill) + { + Logger::info("Killing processes"); + output_ss << kill_processes(results, terminator, strings); + // Re-check after killing + results = finder.find(paths); + Logger::info("Remaining processes: {}", results.size()); + } + + if (json_output) + { + output_ss << get_json(results) << std::endl; + } + else + { + output_ss << get_text(results, strings); + } + + return { 0, output_ss.str() }; +} diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.h b/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.h new file mode 100644 index 0000000000..c8f519592f --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/CLILogic.h @@ -0,0 +1,31 @@ +#pragma once +#include <vector> +#include <string> +#include "FileLocksmithLib/FileLocksmith.h" +#include <Windows.h> + +struct CommandResult +{ + int exit_code; + std::wstring output; +}; + +struct IProcessFinder +{ + virtual std::vector<ProcessResult> find(const std::vector<std::wstring>& paths) = 0; + virtual ~IProcessFinder() = default; +}; + +struct IProcessTerminator +{ + virtual bool terminate(DWORD pid) = 0; + virtual ~IProcessTerminator() = default; +}; + +struct IStringProvider +{ + virtual std::wstring GetString(UINT id) = 0; + virtual ~IStringProvider() = default; +}; + +CommandResult run_command(int argc, wchar_t* argv[], IProcessFinder& finder, IProcessTerminator& terminator, IStringProvider& strings); diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.rc b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.rc new file mode 100644 index 0000000000..81fa8346fc --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.rc @@ -0,0 +1,62 @@ +#include "resource.h" +#include <windows.h> +#include "../../../common/version/version.h" + +#define APSTUDIO_READONLY_SYMBOLS +#include "winres.h" +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +VS_VERSION_INFO VERSIONINFO + FILEVERSION FILE_VERSION + PRODUCTVERSION PRODUCT_VERSION + FILEFLAGSMASK 0x3fL +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x40004L + FILETYPE 0x1L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", "File Locksmith CLI" + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", "FileLocksmithCLI.exe" + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", "FileLocksmithCLI.exe" + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + +STRINGTABLE +BEGIN + IDS_USAGE "Usage: FileLocksmithCLI.exe [options] <path1> [path2] ...\nOptions:\n --kill Kill processes locking the files\n --json Output results in JSON format\n --wait Wait for files to be unlocked\n --timeout Timeout in milliseconds for --wait\n --help Show this help message\n" + IDS_NO_PROCESSES "No processes found locking the file(s).\n" + IDS_HEADER "PID\tUser\tProcess\n" + IDS_TERMINATED "Terminated process %1!d! (%2)\n" + IDS_FAILED_TERMINATE "Failed to terminate process %1!d! (%2)\n" + IDS_FAILED_OPEN "Failed to open process %1!d! (%2)\n" + IDS_ERROR_NO_PATHS "Error: No paths specified.\n" + IDS_WARN_JSON_WAIT "Warning: --wait is incompatible with --json. Ignoring --json.\n" + IDS_WAITING "Waiting for files to be unlocked...\n" + IDS_UNLOCKED "Files unlocked.\n" + IDS_TIMEOUT "Timeout waiting for files to be unlocked.\n" + IDS_ERROR_INVALID_TIMEOUT "Error: Invalid timeout value.\n" + IDS_ERROR_TIMEOUT_ARG "Error: --timeout requires an argument.\n" +END diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj new file mode 100644 index 0000000000..898e97ae80 --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj @@ -0,0 +1,117 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <VCProjectVersion>17.0</VCProjectVersion> + <Keyword>Win32Proj</Keyword> + <ProjectGuid>{49D456D3-F485-45AF-8875-45B44F193DDC}</ProjectGuid> + <RootNamespace>FileLocksmithCLI</RootNamespace> + <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion> + <ProjectName>FileLocksmithCLI</ProjectName> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="..\..\..\..\deps\spdlog.props" /> + <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> + <ConfigurationType>Application</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> + <ConfigurationType>Application</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>..\..\..\..\$(Platform)\$(Configuration)</OutDir> + </PropertyGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>false</ConformanceMode> + <AdditionalIncludeDirectories>$(ProjectDir)..;$(ProjectDir)..\..\..;$(ProjectDir)..\..;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <PrecompiledHeader>Use</PrecompiledHeader> + <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> + <RunCodeAnalysis>false</RunCodeAnalysis> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <AdditionalDependencies>shlwapi.lib;ntdll.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>false</ConformanceMode> + <AdditionalIncludeDirectories>$(ProjectDir)..;$(ProjectDir)..\..\..;$(ProjectDir)..\..;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <PrecompiledHeader>Use</PrecompiledHeader> + <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> + <RunCodeAnalysis>false</RunCodeAnalysis> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <AdditionalDependencies>shlwapi.lib;ntdll.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClCompile Include="CLILogic.cpp" /> + <ClCompile Include="main.cpp" /> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> + </ClCompile> + </ItemGroup> + <ItemGroup> + <ClInclude Include="CLILogic.h" /> + <ClInclude Include="pch.h" /> + <ClInclude Include="resource.h" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="FileLocksmithCLI.rc" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\FileLocksmithLib\FileLocksmithLib.vcxproj"> + <Project>{9d52fd25-ef90-4f9a-a015-91efc5daf54f}</Project> + </ProjectReference> + <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> + </ProjectReference> + <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + <ProjectReference Include="..\..\..\common\version\version.vcxproj"> + <Project>{1248566c-272a-43c0-88d6-e6675d569a09}</Project> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> +</Project> diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj.filters b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj.filters new file mode 100644 index 0000000000..e8a641d95d --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/FileLocksmithCLI.vcxproj.filters @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <ClCompile Include="CLILogic.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="main.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="pch.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <ClInclude Include="CLILogic.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="pch.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="resource.h"> + <Filter>Header Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> +</Project> diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/main.cpp b/src/modules/FileLocksmith/FileLocksmithCLI/main.cpp new file mode 100644 index 0000000000..67a4304b4e --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/main.cpp @@ -0,0 +1,71 @@ +#include "pch.h" +#include "CLILogic.h" +#include "FileLocksmithLib/FileLocksmith.h" +#include <iostream> +#include "resource.h" +#include <common/logger/logger.h> +#include <common/utils/logger_helper.h> + +struct RealProcessFinder : IProcessFinder +{ + std::vector<ProcessResult> find(const std::vector<std::wstring>& paths) override + { + return find_processes_recursive(paths); + } +}; + +struct RealProcessTerminator : IProcessTerminator +{ + bool terminate(DWORD pid) override + { + HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, pid); + if (hProcess) + { + bool result = TerminateProcess(hProcess, 0); + CloseHandle(hProcess); + return result; + } + return false; + } +}; + +struct RealStringProvider : IStringProvider +{ + std::wstring GetString(UINT id) override + { + wchar_t buffer[4096]; + int len = LoadStringW(GetModuleHandle(NULL), id, buffer, ARRAYSIZE(buffer)); + if (len > 0) + { + return std::wstring(buffer, len); + } + return L""; + } +}; + +#ifndef UNIT_TEST +int wmain(int argc, wchar_t* argv[]) +{ + winrt::init_apartment(); + LoggerHelpers::init_logger(L"FileLocksmithCLI", L"", LogSettings::fileLocksmithLoggerName); + Logger::info("FileLocksmithCLI started"); + + RealProcessFinder finder; + RealProcessTerminator terminator; + RealStringProvider strings; + + auto result = run_command(argc, argv, finder, terminator, strings); + + if (result.exit_code != 0) + { + Logger::error("Command failed with exit code {}", result.exit_code); + } + else + { + Logger::info("Command succeeded"); + } + + std::wcout << result.output; + return result.exit_code; +} +#endif diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/packages.config b/src/modules/FileLocksmith/FileLocksmithCLI/packages.config new file mode 100644 index 0000000000..97349a856f --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/packages.config @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> +</packages> diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/pch.cpp b/src/modules/FileLocksmith/FileLocksmithCLI/pch.cpp new file mode 100644 index 0000000000..1d9f38c57d --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/pch.h b/src/modules/FileLocksmith/FileLocksmithCLI/pch.h new file mode 100644 index 0000000000..6099342b41 --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/pch.h @@ -0,0 +1,22 @@ +#pragma once + +#ifndef PCH_H +#define PCH_H + +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include <Windows.h> +#include <winternl.h> +#include <Psapi.h> +#include <shellapi.h> + +#include <iostream> +#include <vector> +#include <string> +#include <map> +#include <set> +#include <algorithm> + +#include <winrt/base.h> + +#endif // PCH_H diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/resource.h b/src/modules/FileLocksmith/FileLocksmithCLI/resource.h new file mode 100644 index 0000000000..be12cac3ac --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/resource.h @@ -0,0 +1,16 @@ +// resource.h +#pragma once + +#define IDS_USAGE 101 +#define IDS_NO_PROCESSES 102 +#define IDS_HEADER 103 +#define IDS_TERMINATED 104 +#define IDS_FAILED_TERMINATE 105 +#define IDS_FAILED_OPEN 106 +#define IDS_ERROR_NO_PATHS 107 +#define IDS_WARN_JSON_WAIT 108 +#define IDS_WAITING 109 +#define IDS_UNLOCKED 110 +#define IDS_TIMEOUT 111 +#define IDS_ERROR_INVALID_TIMEOUT 112 +#define IDS_ERROR_TIMEOUT_ARG 113 diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLITests.cpp b/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLITests.cpp new file mode 100644 index 0000000000..a67e42badf --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLITests.cpp @@ -0,0 +1,130 @@ +#include "pch.h" +#include "CppUnitTest.h" +#include "../CLILogic.h" +#include <map> + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace FileLocksmithCLIUnitTests +{ + struct MockProcessFinder : IProcessFinder + { + std::vector<ProcessResult> results; + std::vector<ProcessResult> find(const std::vector<std::wstring>& paths) override + { + (void)paths; + return results; + } + }; + + struct MockProcessTerminator : IProcessTerminator + { + bool shouldSucceed = true; + std::vector<DWORD> terminatedPids; + bool terminate(DWORD pid) override + { + terminatedPids.push_back(pid); + return shouldSucceed; + } + }; + + struct MockStringProvider : IStringProvider + { + std::map<UINT, std::wstring> strings; + std::wstring GetString(UINT id) override + { + if (strings.count(id)) return strings[id]; + return L"String_" + std::to_wstring(id); + } + }; + + TEST_CLASS(CLITests) + { + public: + + TEST_METHOD(TestNoArgs) + { + MockProcessFinder finder; + MockProcessTerminator terminator; + MockStringProvider strings; + + wchar_t* argv[] = { (wchar_t*)L"exe" }; + auto result = run_command(1, argv, finder, terminator, strings); + + Assert::AreEqual(1, result.exit_code); + } + + TEST_METHOD(TestHelp) + { + MockProcessFinder finder; + MockProcessTerminator terminator; + MockStringProvider strings; + + wchar_t* argv[] = { (wchar_t*)L"exe", (wchar_t*)L"--help" }; + auto result = run_command(2, argv, finder, terminator, strings); + + Assert::AreEqual(0, result.exit_code); + } + + TEST_METHOD(TestFindProcesses) + { + MockProcessFinder finder; + finder.results = { { L"process", 123, L"user", { L"file1" } } }; + MockProcessTerminator terminator; + MockStringProvider strings; + + wchar_t* argv[] = { (wchar_t*)L"exe", (wchar_t*)L"file1" }; + auto result = run_command(2, argv, finder, terminator, strings); + + Assert::AreEqual(0, result.exit_code); + Assert::IsTrue(result.output.find(L"123") != std::wstring::npos); + Assert::IsTrue(result.output.find(L"process") != std::wstring::npos); + } + + TEST_METHOD(TestJsonOutput) + { + MockProcessFinder finder; + finder.results = { { L"process", 123, L"user", { L"file1" } } }; + MockProcessTerminator terminator; + MockStringProvider strings; + + wchar_t* argv[] = { (wchar_t*)L"exe", (wchar_t*)L"file1", (wchar_t*)L"--json" }; + auto result = run_command(3, argv, finder, terminator, strings); + + Microsoft::VisualStudio::CppUnitTestFramework::Logger::WriteMessage(result.output.c_str()); + + Assert::AreEqual(0, result.exit_code); + Assert::IsTrue(result.output.find(L"\"pid\"") != std::wstring::npos); + Assert::IsTrue(result.output.find(L"123") != std::wstring::npos); + } + + TEST_METHOD(TestKill) + { + MockProcessFinder finder; + finder.results = { { L"process", 123, L"user", { L"file1" } } }; + MockProcessTerminator terminator; + MockStringProvider strings; + + wchar_t* argv[] = { (wchar_t*)L"exe", (wchar_t*)L"file1", (wchar_t*)L"--kill" }; + auto result = run_command(3, argv, finder, terminator, strings); + + Assert::AreEqual(0, result.exit_code); + Assert::AreEqual((size_t)1, terminator.terminatedPids.size()); + Assert::AreEqual((DWORD)123, terminator.terminatedPids[0]); + } + + TEST_METHOD(TestTimeout) + { + MockProcessFinder finder; + // Always return results so it waits + finder.results = { { L"process", 123, L"user", { L"file1" } } }; + MockProcessTerminator terminator; + MockStringProvider strings; + + wchar_t* argv[] = { (wchar_t*)L"exe", (wchar_t*)L"file1", (wchar_t*)L"--wait", (wchar_t*)L"--timeout", (wchar_t*)L"100" }; + auto result = run_command(5, argv, finder, terminator, strings); + + Assert::AreEqual(1, result.exit_code); + } + }; +} diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj b/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj new file mode 100644 index 0000000000..28bd4a7afb --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/tests/FileLocksmithCLIUnitTests.vcxproj @@ -0,0 +1,83 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <ProjectGuid>{A1B2C3D4-E5F6-7890-1234-567890ABCDEF}</ProjectGuid> + <Keyword>Win32Proj</Keyword> + <RootNamespace>FileLocksmithCLIUnitTests</RootNamespace> + <ProjectName>FileLocksmithCLI.UnitTests</ProjectName> + <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion> + </PropertyGroup> + <Import Project="$(RepoRoot)deps\spdlog.props" /> + <PropertyGroup Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\tests\FileLocksmithCLI\</OutDir> + </PropertyGroup> + <ItemDefinitionGroup> + <ClCompile> + <AdditionalIncludeDirectories>..\;..\..\;$(RepoRoot)src\;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <PreprocessorDefinitions>WIN32;UNIT_TEST;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <UseFullPaths>true</UseFullPaths> + <PrecompiledHeader>Use</PrecompiledHeader> + <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> + <DisableSpecificWarnings>26466;%(DisableSpecificWarnings)</DisableSpecificWarnings> + </ClCompile> + <Link> + <AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> + <AdditionalDependencies>shlwapi.lib;ntdll.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClInclude Include="pch.h" /> + </ItemGroup> + <ItemGroup> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> + </ClCompile> + <ClCompile Include="FileLocksmithCLITests.cpp" /> + <ClCompile Include="..\CLILogic.cpp"> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + </ClCompile> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\FileLocksmithLib\FileLocksmithLib.vcxproj"> + <Project>{9d52fd25-ef90-4f9a-a015-91efc5daf54f}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> + <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\version\version.vcxproj"> + <Project>{1248566c-272a-43c0-88d6-e6675d569a09}</Project> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> +</Project> diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/tests/packages.config b/src/modules/FileLocksmith/FileLocksmithCLI/tests/packages.config new file mode 100644 index 0000000000..97349a856f --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/tests/packages.config @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> +</packages> diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/tests/pch.cpp b/src/modules/FileLocksmith/FileLocksmithCLI/tests/pch.cpp new file mode 100644 index 0000000000..1d9f38c57d --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/tests/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/FileLocksmith/FileLocksmithCLI/tests/pch.h b/src/modules/FileLocksmith/FileLocksmithCLI/tests/pch.h new file mode 100644 index 0000000000..7041294e28 --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithCLI/tests/pch.h @@ -0,0 +1,9 @@ +#pragma once +#include <winrt/base.h> +#include <Windows.h> +#include <shellapi.h> +#include <string> +#include <vector> +#include <iostream> +#include <sstream> +#include "CppUnitTest.h" diff --git a/src/modules/FileLocksmith/FileLocksmithContextMenu/FileLocksmithContextMenu.vcxproj b/src/modules/FileLocksmith/FileLocksmithContextMenu/FileLocksmithContextMenu.vcxproj index 10478cd30c..eefb123196 100644 --- a/src/modules/FileLocksmith/FileLocksmithContextMenu/FileLocksmithContextMenu.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithContextMenu/FileLocksmithContextMenu.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h FileLocksmithContextMenu.base.rc FileLocksmithContextMenu.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h FileLocksmithContextMenu.base.rc FileLocksmithContextMenu.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>17.0</VCProjectVersion> @@ -10,23 +11,22 @@ <ProjectGuid>{799a50d8-de89-4ed1-8ff8-ad5a9ed8c0ca}</ProjectGuid> <RootNamespace>FileLocksmithContextMenu</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup> <TargetName>PowerToys.FileLocksmithContextMenu</TargetName> <!-- Needs a different int dir to avoid conflicts in msix creation. --> <IntDir>$(SolutionDir)$(Platform)\$(Configuration)\TemporaryBuild\obj\$(ProjectName)\</IntDir> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -125,13 +125,13 @@ MakeAppx.exe pack /d . /p $(OutDir)FileLocksmithContextMenuPackage.msix /nv</Com </None> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\version\version.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\version\version.vcxproj"> <Project>{cc6e41ac-8174-4e8a-8d22-85dd7f4851df}</Project> </ProjectReference> <ProjectReference Include="..\FileLocksmithLib\FileLocksmithLib.vcxproj"> @@ -141,18 +141,18 @@ MakeAppx.exe pack /d . /p $(OutDir)FileLocksmithContextMenuPackage.msix /nv</Com <ItemGroup> <ResourceCompile Include="FileLocksmithContextMenu.base.rc" /> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/FileLocksmith/FileLocksmithContextMenu/packages.config b/src/modules/FileLocksmith/FileLocksmithContextMenu/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/FileLocksmith/FileLocksmithContextMenu/packages.config +++ b/src/modules/FileLocksmith/FileLocksmithContextMenu/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj index 0c285a8bfa..02d4697938 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj @@ -1,28 +1,28 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h FileLocksmithExt.base.rc FileLocksmithExt.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h FileLocksmithExt.base.rc FileLocksmithExt.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{57175ec7-92a5-4c1e-8244-e3fbca2a81de}</ProjectGuid> <RootNamespace>FileLocksmithExt</RootNamespace> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.FileLocksmithExt</TargetName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -73,6 +73,7 @@ <ClInclude Include="ClassFactory.h" /> <ClInclude Include="dllmain.h" /> <ClInclude Include="ExplorerCommand.h" /> + <ClInclude Include="RuntimeRegistration.h" /> <ClInclude Include="pch.h" /> <None Include="packages.config" /> <None Include="resource.base.h" /> @@ -97,16 +98,16 @@ <None Include="dll.def" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> <ProjectReference Include="..\FileLocksmithLib\FileLocksmithLib.vcxproj"> @@ -114,15 +115,15 @@ </ProjectReference> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters index c3b4f47ebc..49bf0e21a9 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters +++ b/src/modules/FileLocksmith/FileLocksmithExt/FileLocksmithExt.vcxproj.filters @@ -27,6 +27,9 @@ <ClInclude Include="dllmain.h"> <Filter>Header Files</Filter> </ClInclude> + <ClInclude Include="RuntimeRegistration.h"> + <Filter>Header Files</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <ClCompile Include="dllmain.cpp"> diff --git a/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp b/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp index ec755d99a3..5a46049e2f 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp +++ b/src/modules/FileLocksmith/FileLocksmithExt/PowerToysModule.cpp @@ -12,12 +12,33 @@ #include "FileLocksmithLib/Constants.h" #include "FileLocksmithLib/Settings.h" #include "FileLocksmithLib/Trace.h" +#include "RuntimeRegistration.h" #include "dllmain.h" #include "Generated Files/resource.h" class FileLocksmithModule : public PowertoyModuleIface { +private: + // Update registration based on enabled state + void UpdateRegistration(bool enabled) + { + if (enabled) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + FileLocksmithRuntimeRegistration::EnsureRegistered(); + Logger::info(L"File Locksmith context menu registered"); +#endif + } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + FileLocksmithRuntimeRegistration::Unregister(); + Logger::info(L"File Locksmith context menu unregistered"); +#endif + } + } + public: FileLocksmithModule() { @@ -82,7 +103,6 @@ public: { std::wstring path = get_module_folderpath(globals::instance); std::wstring packageUri = path + L"\\FileLocksmithContextMenuPackage.msix"; - if (!package::IsPackageRegisteredWithPowerToysVersion(constants::nonlocalizable::ContextMenuPackageName)) { package::RegisterSparsePackage(path, packageUri); @@ -90,12 +110,14 @@ public: } m_enabled = true; + UpdateRegistration(m_enabled); } virtual void disable() override { Logger::info(L"File Locksmith disabled"); m_enabled = false; + UpdateRegistration(m_enabled); } virtual bool is_enabled() override @@ -128,6 +150,7 @@ private: { m_enabled = FileLocksmithSettingsInstance().GetEnabled(); m_extended_only = FileLocksmithSettingsInstance().GetShowInExtendedContextMenu(); + UpdateRegistration(m_enabled); Trace::EnableFileLocksmith(m_enabled); } diff --git a/src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h b/src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h new file mode 100644 index 0000000000..4dd0d34bea --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithExt/RuntimeRegistration.h @@ -0,0 +1,36 @@ +// Header-only runtime registration for FileLocksmith context menu extension. +#pragma once + +#include <common/utils/shell_ext_registration.h> + +namespace globals { extern HMODULE instance; } + +namespace FileLocksmithRuntimeRegistration +{ + namespace + { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{84D68575-E186-46AD-B0CB-BAEB45EE29C0}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\FileLocksmith"; + spec.sentinelValue = L"ContextMenuRegistered"; + spec.dllFileCandidates = { L"PowerToys.FileLocksmithExt.dll" }; + spec.contextMenuHandlerKeyPaths = { + L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt", + L"Software\\Classes\\Drive\\ShellEx\\ContextMenuHandlers\\FileLocksmithExt" }; + spec.friendlyName = L"File Locksmith Shell Extension"; + return spec; + } + } + + inline bool EnsureRegistered() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), globals::instance); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/FileLocksmith/FileLocksmithExt/packages.config b/src/modules/FileLocksmith/FileLocksmithExt/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/FileLocksmith/FileLocksmithExt/packages.config +++ b/src/modules/FileLocksmith/FileLocksmithExt/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/FileLocksmith/FileLocksmithLib/Constants.h b/src/modules/FileLocksmith/FileLocksmithLib/Constants.h index 98a141831b..3440001f9f 100644 --- a/src/modules/FileLocksmith/FileLocksmithLib/Constants.h +++ b/src/modules/FileLocksmith/FileLocksmithLib/Constants.h @@ -14,7 +14,7 @@ namespace constants::nonlocalizable // String key used by PowerToys constexpr WCHAR PowerToyKey[] = L"File Locksmith"; - // Nonlocalized name of this PowerToy, for logs, etc + // Nonlocalized name of this PowerToy, for logs, etc. constexpr WCHAR PowerToyName[] = L"File Locksmith"; // JSON key used to store whether the module is enabled diff --git a/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmith.h b/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmith.h new file mode 100644 index 0000000000..087df84a68 --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmith.h @@ -0,0 +1,9 @@ +#pragma once + +#include "ProcessResult.h" + +// Second version, checks handles towards files and all subfiles and folders of given dirs, if any. +std::vector<ProcessResult> find_processes_recursive(const std::vector<std::wstring>& paths); + +// Gives the full path of the executable, given the process id +std::wstring pid_to_full_path(DWORD pid); diff --git a/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj b/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj index ebbeb20895..4547182612 100644 --- a/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>17.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> @@ -8,17 +9,16 @@ <RootNamespace>FileLocksmithLib</RootNamespace> <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> @@ -34,9 +34,9 @@ <ClCompile> <WarningLevel>Level3</WarningLevel> <SDLCheck>true</SDLCheck> - <PreprocessorDefinitions>WIN32;_DEBUG;_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <PreprocessorDefinitions>WIN32;_DEBUG;_LIB;FILELOCKSMITH_LIB_STATIC;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>../../..;../..;</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\FileLocksmithLibInterop;../../..;../..;</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem> @@ -50,9 +50,9 @@ <FunctionLevelLinking>true</FunctionLevelLinking> <IntrinsicFunctions>true</IntrinsicFunctions> <SDLCheck>true</SDLCheck> - <PreprocessorDefinitions>WIN32;NDEBUG;_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <PreprocessorDefinitions>WIN32;NDEBUG;_LIB;FILELOCKSMITH_LIB_STATIC;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>../../..;../..;</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\FileLocksmithLibInterop;../../..;../..;</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem> @@ -68,13 +68,15 @@ <ClInclude Include="Settings.h" /> <ClInclude Include="Trace.h" /> <ClInclude Include="framework.h" /> - <ClInclude Include="pch.h" /> </ItemGroup> <ItemGroup> <ClCompile Include="IPC.cpp" /> <ClCompile Include="Settings.cpp" /> <ClCompile Include="Trace.cpp" /> <ClCompile Include="FileLocksmithLib.cpp" /> + <ClCompile Include="..\FileLocksmithLibInterop\FileLocksmith.cpp" /> + <ClCompile Include="..\FileLocksmithLibInterop\NtdllBase.cpp" /> + <ClCompile Include="..\FileLocksmithLibInterop\NtdllExtensions.cpp" /> <ClCompile Include="pch.cpp"> <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> </ClCompile> @@ -84,13 +86,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj.filters b/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj.filters index ed6b2674fc..c38c9b2d2e 100644 --- a/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj.filters +++ b/src/modules/FileLocksmith/FileLocksmithLib/FileLocksmithLib.vcxproj.filters @@ -38,6 +38,15 @@ <ClCompile Include="FileLocksmithLib.cpp"> <Filter>Source Files</Filter> </ClCompile> + <ClCompile Include="FileLocksmith.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="NtdllBase.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="NtdllExtensions.cpp"> + <Filter>Source Files</Filter> + </ClCompile> <ClCompile Include="pch.cpp"> <Filter>Source Files</Filter> </ClCompile> diff --git a/src/modules/FileLocksmith/FileLocksmithLib/ProcessResult.h b/src/modules/FileLocksmith/FileLocksmithLib/ProcessResult.h new file mode 100644 index 0000000000..614e38a209 --- /dev/null +++ b/src/modules/FileLocksmith/FileLocksmithLib/ProcessResult.h @@ -0,0 +1,12 @@ +#pragma once +#include <string> +#include <vector> +#include <Windows.h> + +struct ProcessResult +{ + std::wstring name; + DWORD pid; + std::wstring user; + std::vector<std::wstring> files; +}; diff --git a/src/modules/FileLocksmith/FileLocksmithLib/Settings.cpp b/src/modules/FileLocksmith/FileLocksmithLib/Settings.cpp index de997144ca..c30387df5d 100644 --- a/src/modules/FileLocksmith/FileLocksmithLib/Settings.cpp +++ b/src/modules/FileLocksmith/FileLocksmithLib/Settings.cpp @@ -2,6 +2,7 @@ #include "Settings.h" #include "Constants.h" +#include <filesystem> #include <common/utils/json.h> #include <common/SettingsAPI/settings_helpers.h> diff --git a/src/modules/FileLocksmith/FileLocksmithLib/packages.config b/src/modules/FileLocksmith/FileLocksmithLib/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/FileLocksmith/FileLocksmithLib/packages.config +++ b/src/modules/FileLocksmith/FileLocksmithLib/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj b/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj index c4489cdad8..4f1b5bcd85 100644 --- a/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj +++ b/src/modules/FileLocksmith/FileLocksmithLibInterop/FileLocksmithLibInterop.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|ARM64"> <Configuration>Debug</Configuration> @@ -26,13 +27,12 @@ <ProjectName>PowerToys.FileLocksmithLib.Interop</ProjectName> <RootNamespace>PowerToys.FileLocksmithLib.Interop</RootNamespace> <TargetFramework>net8.0-windows10.0.22621.0</TargetFramework> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.FileLocksmithLib.Interop</TargetName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <GenerateManifest>false</GenerateManifest> </PropertyGroup> @@ -116,7 +116,7 @@ <ClInclude Include="resource.h" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\interop\PowerToys.Interop.vcxproj"> <Project>{f055103b-f80b-4d0c-bf48-057c55620033}</Project> </ProjectReference> </ItemGroup> @@ -133,13 +133,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/FileLocksmith/FileLocksmithLibInterop/packages.config b/src/modules/FileLocksmith/FileLocksmithLibInterop/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/FileLocksmith/FileLocksmithLibInterop/packages.config +++ b/src/modules/FileLocksmith/FileLocksmithLibInterop/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/FileLocksmith/FileLocksmithLibInterop/pch.h b/src/modules/FileLocksmith/FileLocksmithLibInterop/pch.h index f2449b1578..4f47976891 100644 --- a/src/modules/FileLocksmith/FileLocksmithLibInterop/pch.h +++ b/src/modules/FileLocksmith/FileLocksmithLibInterop/pch.h @@ -18,4 +18,6 @@ #include <algorithm> #include <fstream> +#ifndef FILELOCKSMITH_LIB_STATIC #include <winrt/PowerToys.Interop.h> +#endif diff --git a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithUI.csproj b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithUI.csproj index f4b28d3922..600216fdbf 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithUI.csproj +++ b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithUI.csproj @@ -1,13 +1,13 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <AssemblyTitle>PowerToys.FileLocksmith</AssemblyTitle> <AssemblyDescription>PowerToys File Locksmith</AssemblyDescription> <OutputType>WinExe</OutputType> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> <RootNamespace>PowerToys.FileLocksmithUI</RootNamespace> <AssemblyName>PowerToys.FileLocksmithUI</AssemblyName> <ApplicationManifest>app.manifest</ApplicationManifest> diff --git a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/App.xaml b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/App.xaml index 99233539ff..d3e50b1e1e 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/App.xaml +++ b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/App.xaml @@ -9,157 +9,6 @@ <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> <!-- Other merged dictionaries here --> </ResourceDictionary.MergedDictionaries> - - <ResourceDictionary.ThemeDictionaries> - <ResourceDictionary x:Key="Default"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="Light"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="HighContrast"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SystemColorHighlightTextColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SystemControlBackgroundBaseLowBrush" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SystemColorGrayTextColor" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="SystemColorButtonTextColorBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="SystemControlDisabledBaseMediumLowBrush" /> - </ResourceDictionary> - - </ResourceDictionary.ThemeDictionaries> - - <Style x:Key="SubtleButtonStyle" TargetType="Button"> - <Setter Property="Background" Value="{ThemeResource SubtleButtonBackground}" /> - <Setter Property="BackgroundSizing" Value="InnerBorderEdge" /> - <Setter Property="Foreground" Value="{ThemeResource SubtleButtonForeground}" /> - <Setter Property="BorderBrush" Value="{ThemeResource SubtleButtonBorderBrush}" /> - <Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" /> - <Setter Property="Padding" Value="{StaticResource ButtonPadding}" /> - <Setter Property="HorizontalAlignment" Value="Left" /> - <Setter Property="VerticalAlignment" Value="Center" /> - <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> - <Setter Property="FontWeight" Value="Normal" /> - <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> - <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> - <Setter Property="FocusVisualMargin" Value="-3" /> - <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> - <Setter Property="Template"> - <Setter.Value> - <ControlTemplate TargetType="Button"> - <ContentPresenter - x:Name="ContentPresenter" - Padding="{TemplateBinding Padding}" - HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" - VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" - AnimatedIcon.State="Normal" - AutomationProperties.AccessibilityView="Raw" - Background="{TemplateBinding Background}" - BackgroundSizing="{TemplateBinding BackgroundSizing}" - BorderBrush="{TemplateBinding BorderBrush}" - BorderThickness="{TemplateBinding BorderThickness}" - Content="{TemplateBinding Content}" - ContentTemplate="{TemplateBinding ContentTemplate}" - ContentTransitions="{TemplateBinding ContentTransitions}" - CornerRadius="{TemplateBinding CornerRadius}" - Foreground="{TemplateBinding Foreground}"> - <ContentPresenter.BackgroundTransition> - <BrushTransition Duration="0:0:0.083" /> - </ContentPresenter.BackgroundTransition> - <VisualStateManager.VisualStateGroups> - <VisualStateGroup x:Name="CommonStates"> - <VisualState x:Name="Normal" /> - <VisualState x:Name="PointerOver"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="PointerOver" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Pressed"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Pressed" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Disabled"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <!-- DisabledVisual Should be handled by the control, not the animated icon. --> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Normal" /> - </VisualState.Setters> - </VisualState> - </VisualStateGroup> - </VisualStateManager.VisualStateGroups> - </ContentPresenter> - </ControlTemplate> - </Setter.Value> - </Setter> - </Style> </ResourceDictionary> </Application.Resources> </Application> diff --git a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml index ddca6691c4..01403ba36e 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml +++ b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml @@ -20,30 +20,15 @@ <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - <Grid - x:Name="AppTitleBar" - Height="32" - ColumnSpacing="16"> - <Grid.ColumnDefinitions> - <ColumnDefinition x:Name="LeftPaddingColumn" Width="0" /> - <ColumnDefinition x:Name="IconColumn" Width="Auto" /> - <ColumnDefinition x:Name="TitleColumn" Width="Auto" /> - <ColumnDefinition x:Name="RightDragColumn" Width="*" /> - <ColumnDefinition x:Name="RightPaddingColumn" Width="0" /> - </Grid.ColumnDefinitions> - <Image - Grid.Column="1" - Width="16" - Height="16" - VerticalAlignment="Center" - Source="../Assets/FileLocksmith/Icon.ico" /> - <TextBlock - x:Name="AppTitleTextBlock" - Grid.Column="2" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}" /> - </Grid> - + <TitleBar x:Name="titleBar" IsTabStop="False"> + <!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource --> + <TitleBar.LeftHeader> + <ImageIcon + Height="16" + Margin="16,0,0,0" + Source="/Assets/FileLocksmith/Icon.ico" /> + </TitleBar.LeftHeader> + </TitleBar> <views:MainPage x:Name="mainPage" Grid.Row="1" /> </Grid> -</winuiex:WindowEx> +</winuiex:WindowEx> \ No newline at end of file diff --git a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml.cs b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml.cs index bb4cfafe7b..cf36969774 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml.cs +++ b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/MainWindow.xaml.cs @@ -18,30 +18,16 @@ namespace FileLocksmithUI { InitializeComponent(); mainPage.ViewModel.IsElevated = isElevated; + SetTitleBar(titleBar); ExtendsContentIntoTitleBar = true; - SetTitleBar(AppTitleBar); - Activated += MainWindow_Activated; + AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall; AppWindow.SetIcon("Assets/FileLocksmith/Icon.ico"); WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(this.GetWindowHandle()); var loader = ResourceLoaderInstance.ResourceLoader; var title = isElevated ? loader.GetString("AppAdminTitle") : loader.GetString("AppTitle"); Title = title; - AppTitleTextBlock.Text = title; - } - - private void MainWindow_Activated(object sender, WindowActivatedEventArgs args) - { - if (args.WindowActivationState == WindowActivationState.Deactivated) - { - AppTitleTextBlock.Foreground = - (SolidColorBrush)App.Current.Resources["WindowCaptionForegroundDisabled"]; - } - else - { - AppTitleTextBlock.Foreground = - (SolidColorBrush)App.Current.Resources["WindowCaptionForeground"]; - } + titleBar.Title = title; } public void Dispose() diff --git a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/Views/MainPage.xaml b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/Views/MainPage.xaml index 21561991c5..1959eaf820 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/Views/MainPage.xaml +++ b/src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithXAML/Views/MainPage.xaml @@ -190,7 +190,7 @@ TextWrapping="Wrap" /> </ContentDialog> <ContentDialog x:Name="ProcessFilesListDialog" x:Uid="ProcessFilesListDialog"> - <ScrollViewer Padding="15" HorizontalScrollBarVisibility="Auto"> + <ScrollViewer Padding="16" HorizontalScrollBarVisibility="Auto"> <TextBlock x:Name="ProcessFilesListDialogTextBlock" x:Uid="ProcessFilesListDialogTextBlock" diff --git a/src/modules/FileLocksmith/FileLocksmithUI/Properties/PublishProfiles/InstallationPublishProfile.pubxml b/src/modules/FileLocksmith/FileLocksmithUI/Properties/PublishProfiles/InstallationPublishProfile.pubxml index cff222baf4..86771137b4 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/Properties/PublishProfiles/InstallationPublishProfile.pubxml +++ b/src/modules/FileLocksmith/FileLocksmithUI/Properties/PublishProfiles/InstallationPublishProfile.pubxml @@ -5,7 +5,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121. <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <PublishProtocol>FileSystem</PublishProtocol> - <TargetFramework>net9.0-windows10.0.22621.0</TargetFramework> + <TargetFramework>net9.0-windows10.0.26100.0</TargetFramework> <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion> <SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion> <PublishDir>$(PowerToysRoot)\$(Platform)\$(Configuration)\WinUI3Apps</PublishDir> diff --git a/src/modules/FileLocksmith/FileLocksmithUI/ViewModels/MainViewModel.cs b/src/modules/FileLocksmith/FileLocksmithUI/ViewModels/MainViewModel.cs index c4cd641874..7a0db51c00 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/ViewModels/MainViewModel.cs +++ b/src/modules/FileLocksmith/FileLocksmithUI/ViewModels/MainViewModel.cs @@ -141,7 +141,7 @@ namespace PowerToys.FileLocksmithUI.ViewModels catch (Exception ex) { Logger.LogError($"Couldn't add a waiter to wait for a process to exit. PID = {process.pid} and Name = {process.name}.", ex); - Processes.Remove(process); // If we couldn't get an handle to the process or it has exited in the meanwhile, don't show it. + Processes.Remove(process); // If we couldn't get a handle to the process or it has exited in the meanwhile, don't show it. } } @@ -162,8 +162,8 @@ namespace PowerToys.FileLocksmithUI.ViewModels } catch (Exception ex) { - Logger.LogError($"Couldn't get an handle to kill process {selectedProcess.name} with PID {selectedProcess.pid}. Likely has been killed already.", ex); - Processes.Remove(selectedProcess); // If we couldn't get an handle to the process, remove it from the list, since it's likely been killed already. + Logger.LogError($"Couldn't get a handle to kill process {selectedProcess.name} with PID {selectedProcess.pid}. Likely has been killed already.", ex); + Processes.Remove(selectedProcess); // If we couldn't get a handle to the process, remove it from the list, since it's likely been killed already. } } diff --git a/src/modules/FileLocksmith/FileLocksmithUI/app.manifest b/src/modules/FileLocksmith/FileLocksmithUI/app.manifest index 575aa4df9d..6a4422b71b 100644 --- a/src/modules/FileLocksmith/FileLocksmithUI/app.manifest +++ b/src/modules/FileLocksmith/FileLocksmithUI/app.manifest @@ -9,7 +9,7 @@ 2) System < Windows 10 Anniversary Update --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> diff --git a/src/modules/Hosts/Hosts.FuzzTests/FuzzTests.cs b/src/modules/Hosts/Hosts.FuzzTests/FuzzTests.cs index 36c7705549..c6fdb14e97 100644 --- a/src/modules/Hosts/Hosts.FuzzTests/FuzzTests.cs +++ b/src/modules/Hosts/Hosts.FuzzTests/FuzzTests.cs @@ -1,11 +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. -using System; -using System.IO; -using System.IO.Abstractions.TestingHelpers; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Hosts.Tests.Mocks; using HostsUILib.Helpers; @@ -19,6 +15,7 @@ namespace Hosts.FuzzTests { private static Mock<IUserSettings> _userSettings; private static Mock<IElevationHelper> _elevationHelper; + private static Mock<IBackupManager> _backupManager; // Case1: Fuzzing method for ValidIPv4 public static void FuzzValidIPv4(ReadOnlySpan<byte> input) @@ -73,9 +70,10 @@ namespace Hosts.FuzzTests _userSettings = new Mock<IUserSettings>(); _elevationHelper = new Mock<IElevationHelper>(); _elevationHelper.Setup(m => m.IsElevated).Returns(true); + _backupManager = new Mock<IBackupManager>(); var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); string input = System.Text.Encoding.UTF8.GetString(data); diff --git a/src/modules/Hosts/Hosts.FuzzTests/Hosts.FuzzTests.csproj b/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj similarity index 80% rename from src/modules/Hosts/Hosts.FuzzTests/Hosts.FuzzTests.csproj rename to src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj index e6dcf22d3d..e328ad985b 100644 --- a/src/modules/Hosts/Hosts.FuzzTests/Hosts.FuzzTests.csproj +++ b/src/modules/Hosts/Hosts.FuzzTests/HostsEditor.FuzzTests.csproj @@ -1,8 +1,12 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.FuzzTest.props" /> + <PropertyGroup> - <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework> <LangVersion>latest</LangVersion> <ImplicitUsings>enable</ImplicitUsings> + <DefineConstants>TESTONLY</DefineConstants> <!-- exit code 8 means no tests ran. --> <!-- Doc: https://learn.microsoft.com/dotnet/core/testing/unit-testing-platform-exit-codes --> @@ -11,7 +15,7 @@ </PropertyGroup> <PropertyGroup> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\Hosts.FuzzTests\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\Hosts.FuzzTests\</OutputPath> </PropertyGroup> <ItemGroup> @@ -31,8 +35,11 @@ <Compile Include="..\HostsUILib\Models\Entry.cs" Link="Entry.cs" /> <Compile Include="..\HostsUILib\Models\HostsData.cs" Link="HostsData.cs" /> <Compile Include="..\HostsUILib\Settings\HostsAdditionalLinesPosition.cs" Link="HostsAdditionalLinesPosition.cs" /> + <Compile Include="..\HostsUILib\Settings\HostsDeleteBackupMode.cs" Link="HostsDeleteBackupMode.cs" /> <Compile Include="..\HostsUILib\Settings\HostsEncoding.cs" Link="HostsEncoding.cs" /> <Compile Include="..\HostsUILib\Settings\IUserSettings.cs" Link="IUserSettings.cs" /> + <Compile Include="..\HostsUILib\Helpers\IBackupManager.cs" Link="IBackupManager.cs" /> + <Compile Include="..\HostsUILib\Helpers\BackupManager.cs" Link="BackupManager.cs" /> </ItemGroup> <ItemGroup> diff --git a/src/modules/Hosts/Hosts.FuzzTests/OneFuzzConfig.json b/src/modules/Hosts/Hosts.FuzzTests/OneFuzzConfig.json index f091a1ed00..6a5b9883f1 100644 --- a/src/modules/Hosts/Hosts.FuzzTests/OneFuzzConfig.json +++ b/src/modules/Hosts/Hosts.FuzzTests/OneFuzzConfig.json @@ -4,8 +4,8 @@ { "fuzzer": { "$type": "libfuzzerDotNet", - "dll": "Hosts.FuzzTests.dll", - "class": "Hosts.FuzzTests.FuzzTests", + "dll": "HostsEditor.FuzzTests.dll", + "class": "HostsEditor.FuzzTests.FuzzTests", "method": "FuzzValidIPv4", "FuzzingTargetBinaries": [ "PowerToys.Hosts.dll" @@ -16,11 +16,11 @@ // project, where bugs will be filed "org": "microsoft", "project": "OS", - "AssignedTo": "mengyuanchen@microsoft.com", - "AreaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\SHINE\\PowerToys", + "AssignedTo": "leilzh@microsoft.com", + "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT", "IterationPath": "OS\\Future" }, - "jobNotificationEmail": "mengyuanchen@microsoft.com", + "jobNotificationEmail": "PowerToys@microsoft.com", "skip": false, "rebootAfterSetup": false, "oneFuzzJobs": [ @@ -35,8 +35,8 @@ // the DLL and PDB files // you will need to add any other files required // (globs are supported) - "Hosts.FuzzTests.dll", - "Hosts.FuzzTests.pdb", + "HostsEditor.FuzzTests.dll", + "HostsEditor.FuzzTests.pdb", "Microsoft.Windows.SDK.NET.dll", "WinRT.Runtime.dll" ], @@ -45,8 +45,8 @@ { "fuzzer": { "$type": "libfuzzerDotNet", - "dll": "Hosts.FuzzTests.dll", - "class": "Hosts.FuzzTests.FuzzTests", + "dll": "HostsEditor.FuzzTests.dll", + "class": "HostsEditor.FuzzTests.FuzzTests", "method": "FuzzValidIPv6", "FuzzingTargetBinaries": [ "PowerToys.Hosts.dll" @@ -57,11 +57,11 @@ // project, where bugs will be filed "org": "microsoft", "project": "OS", - "AssignedTo": "mengyuanchen@microsoft.com", - "AreaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\SHINE\\PowerToys", + "AssignedTo": "leilzh@microsoft.com", + "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT", "IterationPath": "OS\\Future" }, - "jobNotificationEmail": "mengyuanchen@microsoft.com", + "jobNotificationEmail": "PowerToys@microsoft.com", "skip": false, "rebootAfterSetup": false, "oneFuzzJobs": [ @@ -76,8 +76,8 @@ // the DLL and PDB files // you will need to add any other files required // (globs are supported) - "Hosts.FuzzTests.dll", - "Hosts.FuzzTests.pdb", + "HostsEditor.FuzzTests.dll", + "HostsEditor.FuzzTests.pdb", "Microsoft.Windows.SDK.NET.dll", "WinRT.Runtime.dll" ], @@ -86,8 +86,8 @@ { "fuzzer": { "$type": "libfuzzerDotNet", - "dll": "Hosts.FuzzTests.dll", - "class": "Hosts.FuzzTests.FuzzTests", + "dll": "HostsEditor.FuzzTests.dll", + "class": "HostsEditor.FuzzTests.FuzzTests", "method": "FuzzValidHosts", "FuzzingTargetBinaries": [ "PowerToys.Hosts.dll" @@ -98,11 +98,11 @@ // project, where bugs will be filed "org": "microsoft", "project": "OS", - "AssignedTo": "mengyuanchen@microsoft.com", - "AreaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\SHINE\\PowerToys", + "AssignedTo": "leilzh@microsoft.com", + "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT", "IterationPath": "OS\\Future" }, - "jobNotificationEmail": "mengyuanchen@microsoft.com", + "jobNotificationEmail": "PowerToys@microsoft.com", "skip": false, "rebootAfterSetup": false, "oneFuzzJobs": [ @@ -117,8 +117,8 @@ // the DLL and PDB files // you will need to add any other files required // (globs are supported) - "Hosts.FuzzTests.dll", - "Hosts.FuzzTests.pdb", + "HostsEditor.FuzzTests.dll", + "HostsEditor.FuzzTests.pdb", "Microsoft.Windows.SDK.NET.dll", "WinRT.Runtime.dll" ], @@ -127,8 +127,8 @@ { "fuzzer": { "$type": "libfuzzerDotNet", - "dll": "Hosts.FuzzTests.dll", - "class": "Hosts.FuzzTests.FuzzTests", + "dll": "HostsEditor.FuzzTests.dll", + "class": "HostsEditor.FuzzTests.FuzzTests", "method": "FuzzWriteAsync", "FuzzingTargetBinaries": [ "PowerToys.Hosts.dll" @@ -139,11 +139,11 @@ // project, where bugs will be filed "org": "microsoft", "project": "OS", - "AssignedTo": "mengyuanchen@microsoft.com", - "AreaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\SHINE\\PowerToys", + "AssignedTo": "leilzh@microsoft.com", + "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT", "IterationPath": "OS\\Future" }, - "jobNotificationEmail": "mengyuanchen@microsoft.com", + "jobNotificationEmail": "PowerToys@microsoft.com", "skip": false, "rebootAfterSetup": false, "oneFuzzJobs": [ @@ -158,19 +158,19 @@ // the DLL and PDB files // you will need to add any other files required // (globs are supported) - "Hosts.FuzzTests.dll", - "Hosts.FuzzTests.pdb", - "Microsoft.Windows.SDK.NET.dll", - "WinRT.Runtime.dll", - "Moq.dll", - "testhost.dll", "Castle.Core.dll", - "System.IO.Abstractions.dll", "CommunityToolkit.Mvvm.dll", + "HostsEditor.FuzzTests.dll", + "HostsEditor.FuzzTests.pdb", + "Microsoft.Windows.SDK.NET.dll", + "Moq.dll", + "System.IO.Abstractions.dll", "System.IO.Abstractions.TestingHelpers.dll", "TestableIO.System.IO.Abstractions.dll", "TestableIO.System.IO.Abstractions.TestingHelpers.dll", - "TestableIO.System.IO.Abstractions.Wrappers.dll" + "TestableIO.System.IO.Abstractions.Wrappers.dll", + "Testably.Abstractions.FileSystem.Interface.dll", + "WinRT.Runtime.dll" ], "SdlWorkItemId": 49911822 } diff --git a/src/modules/Hosts/Hosts.Tests/BackupManagerTest.cs b/src/modules/Hosts/Hosts.Tests/BackupManagerTest.cs new file mode 100644 index 0000000000..6aeb834029 --- /dev/null +++ b/src/modules/Hosts/Hosts.Tests/BackupManagerTest.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO.Abstractions.TestingHelpers; +using HostsUILib.Helpers; +using HostsUILib.Settings; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Hosts.Tests +{ + [TestClass] + public class BackupManagerTest + { + private const string HostsPath = @"C:\Windows\System32\Drivers\etc\hosts"; + private const string BackupPath = @"C:\Backup\hosts"; + private const string BackupSearchPattern = $"*_PowerToysBackup_*"; + + [TestMethod] + public void Hosts_Backup_Not_Executed() + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, true); + var userSettings = new Mock<IUserSettings>(); + userSettings.Setup(m => m.BackupHosts).Returns(false); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Create(HostsPath); + + Assert.AreEqual(0, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + } + + [TestMethod] + public void Hosts_Backup_Executed_Once() + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, true); + var userSettings = new Mock<IUserSettings>(); + userSettings.Setup(m => m.BackupHosts).Returns(true); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Create(HostsPath); + backupManager.Create(HostsPath); + + Assert.AreEqual(1, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + var hostsContent = fileSystem.File.ReadAllText(HostsPath); + var backupContent = fileSystem.File.ReadAllText(fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern)[0]); + Assert.AreEqual(hostsContent, backupContent); + } + + [DataTestMethod] + [DataRow(-10, -10)] + [DataRow(-10, 0)] + [DataRow(-10, 10)] + [DataRow(0, -10)] + [DataRow(0, 0)] + [DataRow(0, 10)] + [DataRow(10, -10)] + [DataRow(10, 0)] + [DataRow(10, 10)] + public void Hosts_Backups_Delete_Never(int count, int days) + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, false); + var userSettings = new Mock<IUserSettings>(); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + userSettings.Setup(m => m.DeleteBackupsMode).Returns(HostsDeleteBackupMode.Never); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Delete(); + + Assert.AreEqual(30, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + Assert.AreEqual(31, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + [DataTestMethod] + [DataRow(-10, 30)] + [DataRow(0, 30)] + [DataRow(10, 10)] + public void Hosts_Backups_Delete_ByCount(int count, int expectedBackups) + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, false); + var userSettings = new Mock<IUserSettings>(); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + userSettings.Setup(m => m.DeleteBackupsMode).Returns(HostsDeleteBackupMode.Count); + userSettings.Setup(m => m.DeleteBackupsCount).Returns(count); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Delete(); + + Assert.AreEqual(expectedBackups, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + Assert.AreEqual(expectedBackups + 1, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + [DataTestMethod] + [DataRow(-10, -10, 30)] + [DataRow(-10, 0, 30)] + [DataRow(-10, 10, 5)] + [DataRow(0, -10, 30)] + [DataRow(0, 0, 30)] + [DataRow(0, 10, 5)] + [DataRow(10, -10, 30)] + [DataRow(10, 0, 30)] + [DataRow(5, 1, 5)] + [DataRow(1, 15, 10)] + [DataRow(2, 2, 2)] + public void Hosts_Backups_Delete_ByAge(int count, int days, int expectedBackups) + { + var fileSystem = new MockFileSystem(); + SetupFiles(fileSystem, false); + var userSettings = new Mock<IUserSettings>(); + userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + userSettings.Setup(m => m.DeleteBackupsMode).Returns(HostsDeleteBackupMode.Age); + userSettings.Setup(m => m.DeleteBackupsCount).Returns(count); + userSettings.Setup(m => m.DeleteBackupsDays).Returns(days); + var backupManager = new BackupManager(fileSystem, userSettings.Object); + backupManager.Delete(); + + Assert.AreEqual(expectedBackups, fileSystem.Directory.GetFiles(BackupPath, BackupSearchPattern).Length); + Assert.AreEqual(expectedBackups + 1, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + private void SetupFiles(MockFileSystem fileSystem, bool hostsOnly) + { + fileSystem.AddDirectory(BackupPath); + fileSystem.AddFile(HostsPath, new MockFileData("HOSTS FILE CONTENT")); + + if (hostsOnly) + { + return; + } + + var today = new DateTimeOffset(DateTime.Today); + + var notBackupData = new MockFileData("NOT A BACKUP") + { + CreationTime = today.AddDays(-100), + }; + + fileSystem.AddFile(fileSystem.Path.Combine(BackupPath, "hosts_not_a_backup"), notBackupData); + + // The first backup is from 5 days ago. There are 30 backups, one for each day. + var offset = 5; + for (var i = 0; i < 30; i++) + { + var backupData = new MockFileData("THIS IS A BACKUP") + { + CreationTime = today.AddDays(-i - offset), + }; + + fileSystem.AddFile(fileSystem.Path.Combine(BackupPath, $"hosts_PowerToysBackup_{i}"), backupData); + } + } + } +} diff --git a/src/modules/Hosts/Hosts.Tests/Hosts.Tests.csproj b/src/modules/Hosts/Hosts.Tests/HostsEditor.UnitTests.csproj similarity index 93% rename from src/modules/Hosts/Hosts.Tests/Hosts.Tests.csproj rename to src/modules/Hosts/Hosts.Tests/HostsEditor.UnitTests.csproj index 317b72c4b8..a4ad7094af 100644 --- a/src/modules/Hosts/Hosts.Tests/Hosts.Tests.csproj +++ b/src/modules/Hosts/Hosts.Tests/HostsEditor.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs index 8eaa37a348..4c6ee77f8c 100644 --- a/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs +++ b/src/modules/Hosts/Hosts.Tests/HostsServiceTest.cs @@ -20,8 +20,10 @@ namespace Hosts.Tests [TestClass] public class HostsServiceTest { + private const string BackupPath = @"C:\Backup\hosts"; private static Mock<IUserSettings> _userSettings; private static Mock<IElevationHelper> _elevationHelper; + private static Mock<IBackupManager> _backupManager; [ClassInitialize] public static void ClassInitialize(TestContext context) @@ -29,27 +31,7 @@ namespace Hosts.Tests _userSettings = new Mock<IUserSettings>(); _elevationHelper = new Mock<IElevationHelper>(); _elevationHelper.Setup(m => m.IsElevated).Returns(true); - } - - [TestMethod] - public void Hosts_Exists() - { - var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); - fileSystem.AddFile(service.HostsFilePath, new MockFileData(string.Empty)); - var result = service.Exists(); - - Assert.IsTrue(result); - } - - [TestMethod] - public void Hosts_Not_Exists() - { - var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); - var result = service.Exists(); - - Assert.IsFalse(result); + _backupManager = new Mock<IBackupManager>(); } [TestMethod] @@ -67,7 +49,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -92,7 +74,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -118,7 +100,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -137,7 +119,7 @@ namespace Hosts.Tests public async Task Empty_Hosts() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(string.Empty)); await service.WriteAsync(string.Empty, Enumerable.Empty<Entry>()); @@ -168,7 +150,7 @@ namespace Hosts.Tests var fileSystem = new CustomMockFileSystem(); var userSettings = new Mock<IUserSettings>(); userSettings.Setup(m => m.AdditionalLinesPosition).Returns(HostsAdditionalLinesPosition.Top); - var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -200,7 +182,7 @@ namespace Hosts.Tests var fileSystem = new CustomMockFileSystem(); var userSettings = new Mock<IUserSettings>(); userSettings.Setup(m => m.AdditionalLinesPosition).Returns(HostsAdditionalLinesPosition.Bottom); - var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -224,7 +206,7 @@ namespace Hosts.Tests "; var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); var data = await service.ReadAsync(); @@ -241,7 +223,7 @@ namespace Hosts.Tests var elevationHelper = new Mock<IElevationHelper>(); elevationHelper.Setup(m => m.IsElevated).Returns(false); - var service = new HostsService(fileSystem, _userSettings.Object, elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, elevationHelper.Object, _backupManager.Object); await Assert.ThrowsExceptionAsync<NotRunningElevatedException>(async () => await service.WriteAsync("# Empty hosts file", Enumerable.Empty<Entry>())); } @@ -249,7 +231,7 @@ namespace Hosts.Tests public async Task Save_ReadOnlyHostsException() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); var hostsFile = new MockFileData(string.Empty) { @@ -265,7 +247,7 @@ namespace Hosts.Tests public void Remove_ReadOnly_Attribute() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); var hostsFile = new MockFileData(string.Empty) { @@ -284,7 +266,7 @@ namespace Hosts.Tests public async Task Save_Hidden_Hosts() { var fileSystem = new CustomMockFileSystem(); - var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, _backupManager.Object); var hostsFile = new MockFileData(string.Empty) { @@ -298,5 +280,86 @@ namespace Hosts.Tests var hidden = fileSystem.FileInfo.New(service.HostsFilePath).Attributes.HasFlag(FileAttributes.Hidden); Assert.IsTrue(hidden); } + + [TestMethod] + public async Task NoLeadingSpaces_Disabled_RemovesIndent() + { + var content = + @"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var expected = + @"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +# 10.1.1.30 host30 host30.local # new entry +"; + + var fs = new CustomMockFileSystem(); + var settings = new Mock<IUserSettings>(); + settings.Setup(s => s.NoLeadingSpaces).Returns(true); + var svc = new HostsService(fs, settings.Object, _elevationHelper.Object, _backupManager.Object); + fs.AddFile(svc.HostsFilePath, new MockFileData(content)); + + var data = await svc.ReadAsync(); + var entries = data.Entries.ToList(); + entries.Add(new Entry(0, "10.1.1.30", "host30 host30.local", "new entry", false)); + await svc.WriteAsync(data.AdditionalLines, entries); + + var result = fs.GetFile(svc.HostsFilePath); + Assert.AreEqual(expected, result.TextContents); + } + + [TestMethod] + public async Task Hosts_Backup_Not_Executed() + { + var content = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var fileSystem = new CustomMockFileSystem(); + fileSystem.AddDirectory(BackupPath); + _userSettings.Setup(m => m.BackupHosts).Returns(false); + _userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, _userSettings.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, backupManager); + + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var data = await service.ReadAsync(); + var entries = data.Entries.ToList(); + entries.Add(new Entry(0, "10.1.1.30", "host30 host30.local", "new entry", false)); + await service.WriteAsync(data.AdditionalLines, data.Entries); + + Assert.AreEqual(0, fileSystem.Directory.GetFiles(BackupPath).Length); + } + + [TestMethod] + public async Task Hosts_Backup_Executed_Once() + { + var content = +@"10.1.1.1 host host.local # comment +10.1.1.2 host2 host2.local # another comment +"; + + var fileSystem = new CustomMockFileSystem(); + _userSettings.Setup(m => m.BackupHosts).Returns(true); + _userSettings.Setup(m => m.BackupPath).Returns(BackupPath); + var backupManager = new BackupManager(fileSystem, _userSettings.Object); + var service = new HostsService(fileSystem, _userSettings.Object, _elevationHelper.Object, backupManager); + + fileSystem.AddFile(service.HostsFilePath, new MockFileData(content)); + + var data = await service.ReadAsync(); + var entries = data.Entries.ToList(); + entries.Add(new Entry(0, "10.1.1.30", "host30 host30.local", "new entry", false)); + await service.WriteAsync(data.AdditionalLines, data.Entries); + await service.WriteAsync(data.AdditionalLines, data.Entries); + + Assert.AreEqual(1, fileSystem.Directory.GetFiles(BackupPath).Length); + var backupContent = fileSystem.File.ReadAllText(fileSystem.Directory.GetFiles(BackupPath)[0]); + Assert.AreEqual(content, backupContent); + } } } diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry_arm64.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry_arm64.png new file mode 100644 index 0000000000..a1dda9387e Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry_arm64.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry_x64Win10.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry_x64Win10.png new file mode 100644 index 0000000000..a8551d4c40 Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry_x64Win10.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry_x64Win11.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry_x64Win11.png new file mode 100644 index 0000000000..4f676b5672 Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestAddingEntry_x64Win11.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView_arm64.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView_arm64.png new file mode 100644 index 0000000000..3b7b7098b7 Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView_arm64.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView_x64Win10.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView_x64Win10.png new file mode 100644 index 0000000000..90bf6d0001 Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView_x64Win10.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView_x64Win11.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView_x64Win11.png new file mode 100644 index 0000000000..72221f605c Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_EmptyView_x64Win11.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView_arm64.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView_arm64.png new file mode 100644 index 0000000000..0f7282ba95 Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView_arm64.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView_x64Win10.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView_x64Win10.png new file mode 100644 index 0000000000..bb2f552c12 Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView_x64Win10.png differ diff --git a/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView_x64Win11.png b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView_x64Win11.png new file mode 100644 index 0000000000..57cba609cb Binary files /dev/null and b/src/modules/Hosts/Hosts.UITests/Baseline/HostModuleTests_TestEmptyView_NonEmptyView_x64Win11.png differ diff --git a/src/modules/Hosts/Hosts.UITests/HostModuleTests.cs b/src/modules/Hosts/Hosts.UITests/HostModuleTests.cs index c50bbe988e..70cfe12746 100644 --- a/src/modules/Hosts/Hosts.UITests/HostModuleTests.cs +++ b/src/modules/Hosts/Hosts.UITests/HostModuleTests.cs @@ -13,7 +13,7 @@ namespace Hosts.UITests public class HostModuleTests : UITestBase { public HostModuleTests() - : base(PowerToysModule.Hosts) + : base(PowerToysModule.Hosts, WindowSize.Small_Vertical) { } @@ -31,14 +31,17 @@ namespace Hosts.UITests /// </item> /// </list> /// </summary> - [TestMethod] + [TestMethod("Hosts.Basic.EmptyViewShouldWork")] + [TestCategory("Hosts File Editor #4")] public void TestEmptyView() { this.CloseWarningDialog(); this.RemoveAllEntries(); // 'Add an entry' button (only show-up when list is empty) should be visible - Assert.IsTrue(this.FindAll<HyperlinkButton>("Add an entry").Count == 1, "'Add an entry' button should be visible in the empty view"); + Assert.IsTrue(this.HasOne<HyperlinkButton>("Add an entry"), "'Add an entry' button should be visible in the empty view"); + + VisualAssert.AreEqual(this.TestContext, this.Find("Entries"), "EmptyView"); // Click 'Add an entry' from empty-view for adding Host override rule this.Find<HyperlinkButton>("Add an entry").Click(); @@ -46,8 +49,10 @@ namespace Hosts.UITests this.AddEntry("192.168.0.1", "localhost", false, false); // Should have one row now and not more empty view - Assert.IsTrue(this.FindAll<Button>("Delete").Count == 1, "Should have one row now"); - Assert.IsTrue(this.FindAll<HyperlinkButton>("Add an entry").Count == 0, "'Add an entry' button should be invisible if not empty view"); + Assert.IsTrue(this.Has<Button>("Delete"), "Should have one row now"); + Assert.IsFalse(this.Has<HyperlinkButton>("Add an entry"), "'Add an entry' button should be invisible if not empty view"); + + VisualAssert.AreEqual(this.TestContext, this.Find("Entries"), "NonEmptyView"); } /// <summary> @@ -58,17 +63,20 @@ namespace Hosts.UITests /// </item> /// </list> /// </summary> - [TestMethod] + [TestMethod("Hosts.Basic.AddEntryButtonShouldWork")] + [TestCategory("Hosts File Editor #4")] public void TestAddingEntry() { this.CloseWarningDialog(); this.RemoveAllEntries(); - Assert.IsTrue(this.FindAll<Button>("Delete").Count == 0, "Should have no row after removing all"); + Assert.IsFalse(this.Has<Button>("Delete"), "Should have no row after removing all"); this.AddEntry("192.168.0.1", "localhost", true); - Assert.IsTrue(this.FindAll<Button>("Delete").Count == 1, "Should have one row now"); + Assert.IsTrue(this.Has<Button>("Delete"), "Should have one row now"); + + VisualAssert.AreEqual(this.TestContext, this.Find("Entries")); } /// <summary> @@ -82,7 +90,8 @@ namespace Hosts.UITests /// </item> /// </list> /// </summary> - [TestMethod] + [TestMethod("Hosts.Basic.CanNotAddMoreThenNighHosts")] + [TestCategory("Hosts File Editor #5")] public void TestTooManyHosts() { this.CloseWarningDialog(); @@ -116,18 +125,48 @@ namespace Hosts.UITests /// </item> /// </list> /// </summary> - [TestMethod] + [TestMethod("Hosts.Basic.ErrorMessageShowupIfNotRunAsAdmin")] + [TestCategory("Hosts File Editor #8")] public void TestErrorMessageWithNonAdminPermission() { - this.CloseWarningDialog(); - this.RemoveAllEntries(); + if (this.Session.IsElevated == false) + { + this.CloseWarningDialog(); + this.RemoveAllEntries(); - // Add new URL override and a warning tip should be shown - this.AddEntry("192.168.0.1", "localhost", true); + // Add new URL override and a warning tip should be shown + this.AddEntry("192.168.0.1", "localhost", true); - Assert.IsTrue( - this.FindAll<TextBlock>("The hosts file cannot be saved because the program isn't running as administrator.").Count == 1, - "Should display host-file saving error if not run as administrator"); + Assert.IsTrue( + this.FindAll<TextBlock>("The hosts file cannot be saved because the program isn't running as administrator.").Count == 1, + "Should display host-file saving error if not run as administrator"); + } + } + + /// <summary> + /// Test No Error-message in the Hosts-File-Editor + /// <list type="bullet"> + /// <item> + /// <description>Validating error message should be shown if not run as admin.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("Hosts.Basic.NoErrorMessageShowupIfRunAsAdmin")] + [TestCategory("Hosts File Editor #8")] + public void TestNoErrorMessageWithNonAdminPermission() + { + if (this.Session.IsElevated == true) + { + this.CloseWarningDialog(); + this.RemoveAllEntries(); + + // Add new URL override and a warning tip should be shown + this.AddEntry("192.168.0.1", "localhost", true); + + Assert.IsFalse( + this.FindAll<TextBlock>("The hosts file cannot be saved because the program isn't running as administrator.").Count > 0, + "Should display host-file saving error if not run as administrator"); + } } /// <summary> @@ -144,7 +183,8 @@ namespace Hosts.UITests /// </item> /// </list> /// </summary> - [TestMethod] + [TestMethod("Hosts.Basic.FiltersControlShouldWork")] + [TestCategory("Hosts File Editor #6")] public void TestFilterControl() { this.CloseWarningDialog(); @@ -212,7 +252,7 @@ namespace Hosts.UITests // Close-filter-panel this.Find<Button>("Filters").Click(); - Assert.IsFalse(this.FindAll<Button>("Clear filters").Count == 1, "Filter panel should be closed afer click Filter Button"); + Assert.IsFalse(this.FindAll<Button>("Clear filters").Count == 1, "Filter panel should be closed after clicking Filter Button"); } private void AddEntry(string ip, string host, bool active = true, bool clickAddEntryButton = true) @@ -243,25 +283,25 @@ namespace Hosts.UITests private void CloseWarningDialog() { // Find 'Accept' button which come in 'Warning' dialog - if (this.FindAll("Warning").Count > 0 && - this.FindAll<Button>("Accept").Count > 0) + if (this.FindAll("Warning", 1000).Count > 0 && + this.FindAll<Button>("Accept", 1000).Count > 0) { // Hide Warning dialog if any - this.Find<Button>("Accept").Click(); + this.Find<Button>("Accept", 1000).Click(); } } private void RemoveAllEntries() { // Delete all existing host-override rules - foreach (var deleteBtn in this.FindAll<Button>("Delete")) + foreach (var deleteBtn in this.FindAll<Button>("Delete", 1000)) { deleteBtn.Click(); - this.Find<Button>("Yes").Click(); + this.Find<Button>("Yes", 1000).Click(); } // Should have no row left, and no more delete button - Assert.IsTrue(this.FindAll<Button>("Delete").Count == 0); + Assert.IsTrue(this.FindAll<Button>("Delete", 1000).Count == 0); } } } diff --git a/src/modules/Hosts/Hosts.UITests/HostsEditor.UITests.csproj b/src/modules/Hosts/Hosts.UITests/HostsEditor.UITests.csproj new file mode 100644 index 0000000000..e3fe2d8f65 --- /dev/null +++ b/src/modules/Hosts/Hosts.UITests/HostsEditor.UITests.csproj @@ -0,0 +1,39 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <ProjectGuid>{4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}</ProjectGuid> + <RootNamespace>PowerToys.Hosts.UITests</RootNamespace> + <AssemblyName>PowerToys.Hosts.UITests</AssemblyName> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + + <!-- This is a UI test, so don't run as part of MSBuild --> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\Hosts.UITests\</OutputPath> + </PropertyGroup> + <ItemGroup> + <EmbeddedResource Include="Baseline\HostModuleTests_TestAddingEntry_arm64.png" /> + <EmbeddedResource Include="Baseline\HostModuleTests_TestEmptyView_EmptyView_arm64.png" /> + <EmbeddedResource Include="Baseline\HostModuleTests_TestEmptyView_NonEmptyView_arm64.png" /> + <EmbeddedResource Include="Baseline\HostModuleTests_TestAddingEntry_x64Win10.png" /> + <EmbeddedResource Include="Baseline\HostModuleTests_TestEmptyView_EmptyView_x64Win10.png" /> + <EmbeddedResource Include="Baseline\HostModuleTests_TestEmptyView_NonEmptyView_x64Win10.png" /> + <EmbeddedResource Include="Baseline\HostModuleTests_TestAddingEntry_x64Win11.png" /> + <EmbeddedResource Include="Baseline\HostModuleTests_TestEmptyView_EmptyView_x64Win11.png" /> + <EmbeddedResource Include="Baseline\HostModuleTests_TestEmptyView_NonEmptyView_x64Win11.png" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/Hosts/Hosts.UITests/HostsSettingTests.cs b/src/modules/Hosts/Hosts.UITests/HostsSettingTests.cs index c8ab562602..d1eb482aa8 100644 --- a/src/modules/Hosts/Hosts.UITests/HostsSettingTests.cs +++ b/src/modules/Hosts/Hosts.UITests/HostsSettingTests.cs @@ -12,6 +12,11 @@ namespace Hosts.UITests [TestClass] public class HostsSettingTests : UITestBase { + public HostsSettingTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.Medium) + { + } + /// <summary> /// Test Warning Dialog at startup /// <list type="bullet"> @@ -29,7 +34,9 @@ namespace Hosts.UITests /// </item> /// </list> /// </summary> - [TestMethod] + [TestMethod("Hosts.Settings.ShowWarningDialogIfRunAsAdmin")] + [TestCategory("Hosts File Editor #1")] + [TestCategory("Hosts File Editor #9")] public void TestWarningDialog() { this.LaunchFromSetting(showWarning: true); @@ -51,10 +58,7 @@ namespace Hosts.UITests this.Find<Button>("Launch Hosts File Editor").Click(); - // wait for 500 ms to make sure Hosts File Editor is launched - Task.Delay(500).Wait(); - - this.Session.Attach(PowerToysModule.Hosts); + this.Session.Attach(PowerToysModule.Hosts, WindowSize.Small_Vertical); // Should show warning dialog Assert.IsTrue(this.FindAll("Warning").Count > 0, "Should show warning dialog"); @@ -68,7 +72,7 @@ namespace Hosts.UITests Assert.IsFalse(this.IsHostsFileEditorClosed(), "Hosts File Editor should NOT be closed after click Accept button in Warning Dialog"); // Close Hosts File Editor window - this.Session.Find<Window>("Hosts File Editor").Close(); + this.Session.CloseMainWindow(); // Restore back to PowerToysSettings Session this.Session.Attach(PowerToysModule.PowerToysSettings); @@ -82,7 +86,7 @@ namespace Hosts.UITests Assert.IsFalse(this.IsHostsFileEditorClosed(), "Hosts File Editor should NOT be closed"); // Close Hosts File Editor window - this.Session.Find<Window>("Hosts File Editor").Close(); + this.Session.CloseMainWindow(); // Restore back to PowerToysSettings Session this.Session.Attach(PowerToysModule.PowerToysSettings); @@ -90,14 +94,9 @@ namespace Hosts.UITests private bool IsHostsFileEditorClosed() { - try + if (this.Session.FindAll<Window>("Hosts File Editor").Count == 0 && this.Session.FindAll<Window>("Administrator: Hosts File Editor").Count == 0) { - this.Session.FindAll<Window>("Hosts File Editor"); - } - catch (Exception ex) - { - // Validate if editor window closed by checking exception.Message - return ex.Message.Contains("Currently selected window has been closed"); + return true; } return false; @@ -114,14 +113,14 @@ namespace Hosts.UITests this.Find<NavigationViewItem>("Hosts File Editor").Click(); - this.Find<ToggleSwitch>("Enable Hosts File Editor").Toggle(true); + this.Find<ToggleSwitch>("Hosts File Editor").Toggle(true); this.Find<ToggleSwitch>("Launch as administrator").Toggle(launchAsAdmin); this.Find<ToggleSwitch>("Show a warning at startup").Toggle(showWarning); // launch Hosts File Editor this.Find<Button>("Launch Hosts File Editor").Click(); - Task.Delay(500).Wait(); + Task.Delay(2000).Wait(); this.Session.Attach(PowerToysModule.Hosts); } diff --git a/src/modules/Hosts/Hosts/Hosts.csproj b/src/modules/Hosts/Hosts/Hosts.csproj index cf595dd44b..2d00648ca4 100644 --- a/src/modules/Hosts/Hosts/Hosts.csproj +++ b/src/modules/Hosts/Hosts/Hosts.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> @@ -13,7 +13,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> <AssemblyName>PowerToys.Hosts</AssemblyName> <DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants> <ApplicationIcon>Assets/Hosts/Hosts.ico</ApplicationIcon> diff --git a/src/modules/Hosts/Hosts/HostsXAML/App.xaml b/src/modules/Hosts/Hosts/HostsXAML/App.xaml index e053137314..bc33532ec5 100644 --- a/src/modules/Hosts/Hosts/HostsXAML/App.xaml +++ b/src/modules/Hosts/Hosts/HostsXAML/App.xaml @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8" ?> <Application x:Class="Hosts.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" diff --git a/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs b/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs index fbe5d3662d..0b2739ebe1 100644 --- a/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs +++ b/src/modules/Hosts/Hosts/HostsXAML/App.xaml.cs @@ -56,6 +56,7 @@ namespace Hosts { // Core Services services.AddSingleton<IFileSystem, FileSystem>(); + services.AddSingleton<IBackupManager, BackupManager>(); services.AddSingleton<IHostsService, HostsService>(); services.AddSingleton<IUserSettings, Hosts.Settings.UserSettings>(); services.AddSingleton<IElevationHelper, ElevationHelper>(); @@ -66,7 +67,7 @@ namespace Hosts services.AddSingleton<IElevationHelper, ElevationHelper>(); services.AddSingleton<OpenSettingsFunction>(() => { - SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.Hosts, true); + SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.Hosts); }); services.AddSingleton<MainViewModel, MainViewModel>(); @@ -74,7 +75,7 @@ namespace Hosts }). Build(); - var cleanupBackupThread = new Thread(() => + var deleteBackupThread = new Thread(() => { // Delete old backups only if running elevated if (!Host.GetService<IElevationHelper>().IsElevated) @@ -84,7 +85,7 @@ namespace Hosts try { - Host.GetService<IHostsService>().CleanupBackup(); + Host.GetService<IBackupManager>().Delete(); } catch (Exception ex) { @@ -92,8 +93,8 @@ namespace Hosts } }); - cleanupBackupThread.IsBackground = true; - cleanupBackupThread.Start(); + deleteBackupThread.IsBackground = true; + deleteBackupThread.Start(); UnhandledException += App_UnhandledException; diff --git a/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml b/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml index 73a883f68b..001cbeb3ed 100644 --- a/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml +++ b/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml @@ -20,27 +20,14 @@ <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - <Grid - x:Name="titleBar" - Height="32" - ColumnSpacing="16"> - <Grid.ColumnDefinitions> - <ColumnDefinition x:Name="LeftPaddingColumn" Width="0" /> - <ColumnDefinition x:Name="IconColumn" Width="Auto" /> - <ColumnDefinition x:Name="TitleColumn" Width="Auto" /> - <ColumnDefinition x:Name="RightPaddingColumn" Width="0" /> - </Grid.ColumnDefinitions> - <Image - Grid.Column="1" - Width="16" - Height="16" - VerticalAlignment="Center" - Source="../Assets/Hosts/Hosts.ico" /> - <TextBlock - x:Name="AppTitleTextBlock" - Grid.Column="2" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}" /> - </Grid> + <TitleBar x:Name="titleBar" IsTabStop="False"> + <!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource --> + <TitleBar.LeftHeader> + <ImageIcon + Height="16" + Margin="16,0,0,0" + Source="/Assets/Hosts/Hosts.ico" /> + </TitleBar.LeftHeader> + </TitleBar> </Grid> </winuiex:WindowEx> diff --git a/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml.cs b/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml.cs index 85df8e7313..d1937d508d 100644 --- a/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml.cs +++ b/src/modules/Hosts/Hosts/HostsXAML/MainWindow.xaml.cs @@ -9,19 +9,15 @@ using HostsUILib.Helpers; using HostsUILib.Views; using ManagedCommon; using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using Microsoft.Windows.ApplicationModel.Resources; using WinUIEx; -// To learn more about WinUI, the WinUI project structure, -// and more about our project templates, see: http://aka.ms/winui-project-info. namespace Hosts { - /// <summary> - /// An empty window that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class MainWindow : WindowEx { private HostsMainPage MainPage { get; } @@ -38,31 +34,18 @@ namespace Hosts var title = Host.GetService<IElevationHelper>().IsElevated ? loader.GetString("WindowAdminTitle") : loader.GetString("WindowTitle"); Title = title; - AppTitleTextBlock.Text = title; + titleBar.Title = title; var handle = this.GetWindowHandle(); WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(handle); WindowHelpers.BringToForeground(handle); - Activated += MainWindow_Activated; MainPage = Host.GetService<HostsMainPage>(); PowerToysTelemetry.Log.WriteEvent(new HostEditorStartFinishEvent() { TimeStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); } - private void MainWindow_Activated(object sender, WindowActivatedEventArgs args) - { - if (args.WindowActivationState == WindowActivationState.Deactivated) - { - AppTitleTextBlock.Foreground = (SolidColorBrush)App.Current.Resources["WindowCaptionForegroundDisabled"]; - } - else - { - AppTitleTextBlock.Foreground = (SolidColorBrush)App.Current.Resources["WindowCaptionForeground"]; - } - } - private void Grid_Loaded(object sender, RoutedEventArgs e) { MainGrid.Children.Add(MainPage); diff --git a/src/modules/Hosts/Hosts/Settings/UserSettings.cs b/src/modules/Hosts/Hosts/Settings/UserSettings.cs index 3530a3f74b..bd69a336eb 100644 --- a/src/modules/Hosts/Hosts/Settings/UserSettings.cs +++ b/src/modules/Hosts/Hosts/Settings/UserSettings.cs @@ -1,4 +1,4 @@ -// 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. @@ -26,6 +26,8 @@ namespace Hosts.Settings private bool _loopbackDuplicates; + public bool NoLeadingSpaces { get; private set; } + public bool LoopbackDuplicates { get => _loopbackDuplicates; @@ -43,17 +45,34 @@ namespace Hosts.Settings public HostsAdditionalLinesPosition AdditionalLinesPosition { get; private set; } // Moved from Settings.UI.Library - public HostsEncoding Encoding { get; set; } + public HostsEncoding Encoding { get; private set; } + + public bool BackupHosts { get; private set; } + + public string BackupPath { get; private set; } + + // Moved from Settings.UI.Library + public HostsDeleteBackupMode DeleteBackupsMode { get; private set; } + + public int DeleteBackupsDays { get; private set; } + + public int DeleteBackupsCount { get; private set; } public event EventHandler LoopbackDuplicatesChanged; public UserSettings() { - _settingsUtils = new SettingsUtils(); - ShowStartupWarning = true; - LoopbackDuplicates = false; - AdditionalLinesPosition = HostsAdditionalLinesPosition.Top; - Encoding = HostsEncoding.Utf8; + _settingsUtils = SettingsUtils.Default; + var defaultSettings = new HostsProperties(); + ShowStartupWarning = defaultSettings.ShowStartupWarning; + LoopbackDuplicates = defaultSettings.LoopbackDuplicates; + AdditionalLinesPosition = (HostsAdditionalLinesPosition)defaultSettings.AdditionalLinesPosition; + Encoding = (HostsEncoding)defaultSettings.Encoding; + BackupHosts = defaultSettings.BackupHosts; + BackupPath = defaultSettings.BackupPath; + DeleteBackupsMode = (HostsDeleteBackupMode)defaultSettings.DeleteBackupsMode; + DeleteBackupsDays = defaultSettings.DeleteBackupsDays; + DeleteBackupsCount = defaultSettings.DeleteBackupsCount; LoadSettingsFromJson(); @@ -88,6 +107,12 @@ namespace Hosts.Settings AdditionalLinesPosition = (HostsAdditionalLinesPosition)settings.Properties.AdditionalLinesPosition; Encoding = (HostsEncoding)settings.Properties.Encoding; LoopbackDuplicates = settings.Properties.LoopbackDuplicates; + NoLeadingSpaces = settings.Properties.NoLeadingSpaces; + BackupHosts = settings.Properties.BackupHosts; + BackupPath = settings.Properties.BackupPath; + DeleteBackupsMode = (HostsDeleteBackupMode)settings.Properties.DeleteBackupsMode; + DeleteBackupsDays = settings.Properties.DeleteBackupsDays; + DeleteBackupsCount = settings.Properties.DeleteBackupsCount; } retry = false; diff --git a/src/modules/Hosts/Hosts/Telemetry/HostEditorStartEvent.cs b/src/modules/Hosts/Hosts/Telemetry/HostEditorStartEvent.cs index 02c3c57e49..7a1c70a6a9 100644 --- a/src/modules/Hosts/Hosts/Telemetry/HostEditorStartEvent.cs +++ b/src/modules/Hosts/Hosts/Telemetry/HostEditorStartEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace HostsEditor.Telemetry; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class HostEditorStartEvent() : EventBase, IEvent { public long TimeStamp { get; set; } diff --git a/src/modules/Hosts/Hosts/Telemetry/HostEditorStartFinishEvent.cs b/src/modules/Hosts/Hosts/Telemetry/HostEditorStartFinishEvent.cs index ff055417cd..dce7f99347 100644 --- a/src/modules/Hosts/Hosts/Telemetry/HostEditorStartFinishEvent.cs +++ b/src/modules/Hosts/Hosts/Telemetry/HostEditorStartFinishEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace HostsEditor.Telemetry; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class HostEditorStartFinishEvent() : EventBase, IEvent { public long TimeStamp { get; set; } diff --git a/src/modules/Hosts/Hosts/Telemetry/HostsFileEditorOpenedEvent.cs b/src/modules/Hosts/Hosts/Telemetry/HostsFileEditorOpenedEvent.cs index f88cd653a7..4c5d871f59 100644 --- a/src/modules/Hosts/Hosts/Telemetry/HostsFileEditorOpenedEvent.cs +++ b/src/modules/Hosts/Hosts/Telemetry/HostsFileEditorOpenedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Hosts.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class HostsFileEditorOpenedEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/Hosts/Hosts/app.manifest b/src/modules/Hosts/Hosts/app.manifest index ca937b4b2a..d60aec67f6 100644 --- a/src/modules/Hosts/Hosts/app.manifest +++ b/src/modules/Hosts/Hosts/app.manifest @@ -10,7 +10,7 @@ 2) System < Windows 10 Anniversary Update --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> diff --git a/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj b/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj index ec9c3857c8..0b7f31b3e5 100644 --- a/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj +++ b/src/modules/Hosts/HostsModuleInterface/HostsModuleInterface.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h HostsModuleInterface.base.rc HostsModuleInterface.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted ..\..\..\..\tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h HostsModuleInterface.base.rc HostsModuleInterface.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> @@ -11,17 +12,16 @@ <RootNamespace>HostsModuleInterface</RootNamespace> <ProjectName>HostsModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -35,7 +35,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.HostsModuleInterface</TargetName> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'"> @@ -46,7 +46,7 @@ </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -64,10 +64,10 @@ <ClCompile Include="dllmain.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -79,15 +79,15 @@ <None Include="Resource.resx" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/Hosts/HostsModuleInterface/dllmain.cpp b/src/modules/Hosts/HostsModuleInterface/dllmain.cpp index 993226ac2b..fb8dd40011 100644 --- a/src/modules/Hosts/HostsModuleInterface/dllmain.cpp +++ b/src/modules/Hosts/HostsModuleInterface/dllmain.cpp @@ -155,7 +155,7 @@ public: } } - m_showEventWaiter = EventWaiter(CommonSharedConstants::SHOW_HOSTS_EVENT, [&](int err) + m_showEventWaiter.start(CommonSharedConstants::SHOW_HOSTS_EVENT, [&](DWORD err) { if (m_enabled && err == ERROR_SUCCESS) { @@ -174,7 +174,7 @@ public: } }); - m_showAdminEventWaiter = EventWaiter(CommonSharedConstants::SHOW_HOSTS_ADMIN_EVENT, [&](int err) + m_showAdminEventWaiter.start(CommonSharedConstants::SHOW_HOSTS_ADMIN_EVENT, [&](DWORD err) { if (m_enabled && err == ERROR_SUCCESS) { diff --git a/src/modules/Hosts/HostsModuleInterface/packages.config b/src/modules/Hosts/HostsModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/Hosts/HostsModuleInterface/packages.config +++ b/src/modules/Hosts/HostsModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/Hosts/HostsUILib/Helpers/BackupManager.cs b/src/modules/Hosts/HostsUILib/Helpers/BackupManager.cs new file mode 100644 index 0000000000..5417408409 --- /dev/null +++ b/src/modules/Hosts/HostsUILib/Helpers/BackupManager.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO.Abstractions; +using System.Linq; +using HostsUILib.Settings; + +namespace HostsUILib.Helpers +{ + public class BackupManager : IBackupManager + { + private const string BackupSuffix = "_PowerToysBackup_"; + private readonly IFileSystem _fileSystem; + private readonly IUserSettings _userSettings; + private bool _backupDone; + + public BackupManager(IFileSystem fileSystem, IUserSettings userSettings) + { + _fileSystem = fileSystem; + _userSettings = userSettings; + } + + public void Create(string hostsFilePath) + { + if (_backupDone || !_userSettings.BackupHosts || !_fileSystem.File.Exists(hostsFilePath)) + { + return; + } + + try + { + if (!_fileSystem.Directory.Exists(_userSettings.BackupPath)) + { + _fileSystem.Directory.CreateDirectory(_userSettings.BackupPath); + } + + var backupPath = _fileSystem.Path.Combine(_userSettings.BackupPath, $"hosts{BackupSuffix}{DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}"); + + _fileSystem.File.Copy(hostsFilePath, backupPath); + _backupDone = true; + } + catch (Exception ex) + { + LoggerInstance.Logger.LogError("Backup failed", ex); + } + } + + public void Delete() + { + switch (_userSettings.DeleteBackupsMode) + { + case HostsDeleteBackupMode.Count: + DeleteByCount(_userSettings.DeleteBackupsCount); + break; + case HostsDeleteBackupMode.Age: + DeleteByAge(_userSettings.DeleteBackupsDays, _userSettings.DeleteBackupsCount); + break; + } + } + + public void DeleteByCount(int count) + { + if (count < 1) + { + return; + } + + var backups = GetAll().OrderByDescending(f => f.CreationTime).Skip(count).ToArray(); + DeleteAll(backups); + } + + public void DeleteByAge(int days, int count) + { + if (days < 1) + { + return; + } + + var backupsEnumerable = GetAll(); + + if (count > 0) + { + backupsEnumerable = backupsEnumerable.OrderByDescending(f => f.CreationTime).Skip(count); + } + + var backups = backupsEnumerable.Where(f => f.CreationTime < DateTime.Now.AddDays(-days)).ToArray(); + DeleteAll(backups); + } + + private IEnumerable<IFileInfo> GetAll() + { + if (!_fileSystem.Directory.Exists(_userSettings.BackupPath)) + { + return []; + } + + return _fileSystem.Directory.GetFiles(_userSettings.BackupPath, $"*{BackupSuffix}*").Select(_fileSystem.FileInfo.New); + } + + private void DeleteAll(IFileInfo[] files) + { + foreach (var f in files) + { + _fileSystem.File.Delete(f.FullName); + } + } + } +} diff --git a/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs b/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs index b07eb8f93c..9b16e04f20 100644 --- a/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs +++ b/src/modules/Hosts/HostsUILib/Helpers/HostsService.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.IO.Abstractions; using System.Linq; @@ -23,16 +22,15 @@ namespace HostsUILib.Helpers { public partial class HostsService : IHostsService, IDisposable { - private const string _backupSuffix = $"_PowerToysBackup_"; - private const int _defaultBufferSize = 4096; // From System.IO.File source code + private const int DefaultBufferSize = 4096; // From System.IO.File source code private readonly SemaphoreSlim _asyncLock = new SemaphoreSlim(1, 1); private readonly IFileSystem _fileSystem; private readonly IUserSettings _userSettings; private readonly IElevationHelper _elevationHelper; private readonly IFileSystemWatcher _fileSystemWatcher; + private readonly IBackupManager _backupManager; private readonly string _hostsFilePath; - private bool _backupDone; private bool _disposed; public string HostsFilePath => _hostsFilePath; @@ -44,11 +42,13 @@ namespace HostsUILib.Helpers public HostsService( IFileSystem fileSystem, IUserSettings userSettings, - IElevationHelper elevationHelper) + IElevationHelper elevationHelper, + IBackupManager backupManager) { _fileSystem = fileSystem; _userSettings = userSettings; _elevationHelper = elevationHelper; + _backupManager = backupManager; _hostsFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"System32\drivers\etc\hosts"); @@ -60,18 +60,13 @@ namespace HostsUILib.Helpers _fileSystemWatcher.EnableRaisingEvents = true; } - public bool Exists() - { - return _fileSystem.File.Exists(HostsFilePath); - } - public async Task<HostsData> ReadAsync() { var entries = new List<Entry>(); var unparsedBuilder = new StringBuilder(); var splittedEntries = false; - if (!Exists()) + if (!_fileSystem.File.Exists(HostsFilePath)) { return new HostsData(entries, unparsedBuilder.ToString(), false); } @@ -157,7 +152,7 @@ namespace HostsUILib.Helpers { lineBuilder.Append('#').Append(' '); } - else if (anyDisabled) + else if (anyDisabled && !_userSettings.NoLeadingSpaces) { lineBuilder.Append(' ').Append(' '); } @@ -192,15 +187,10 @@ namespace HostsUILib.Helpers { await _asyncLock.WaitAsync(); _fileSystemWatcher.EnableRaisingEvents = false; - - if (!_backupDone && Exists()) - { - _fileSystem.File.Copy(HostsFilePath, HostsFilePath + _backupSuffix + DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)); - _backupDone = true; - } + _backupManager.Create(HostsFilePath); // FileMode.OpenOrCreate is necessary to prevent UnauthorizedAccessException when the hosts file is hidden - using var stream = _fileSystem.FileStream.New(HostsFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read, _defaultBufferSize, FileOptions.Asynchronous); + using var stream = _fileSystem.FileStream.New(HostsFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read, DefaultBufferSize, FileOptions.Asynchronous); using var writer = new StreamWriter(stream, Encoding); foreach (var line in lines) { @@ -231,15 +221,6 @@ namespace HostsUILib.Helpers } } - public void CleanupBackup() - { - Directory.GetFiles(Path.GetDirectoryName(HostsFilePath), $"*{_backupSuffix}*") - .Select(f => new FileInfo(f)) - .Where(f => f.CreationTime < DateTime.Now.AddDays(-15)) - .ToList() - .ForEach(f => f.Delete()); - } - public void OpenHostsFile() { var notepadFallback = false; diff --git a/src/modules/Hosts/HostsUILib/Helpers/IBackupManager.cs b/src/modules/Hosts/HostsUILib/Helpers/IBackupManager.cs new file mode 100644 index 0000000000..9da9802a26 --- /dev/null +++ b/src/modules/Hosts/HostsUILib/Helpers/IBackupManager.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. + +namespace HostsUILib.Helpers +{ + public interface IBackupManager + { + void Create(string hostsFilePath); + + void Delete(); + } +} diff --git a/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs b/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs index fe75946a12..c6f2678156 100644 --- a/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs +++ b/src/modules/Hosts/HostsUILib/Helpers/IHostsService.cs @@ -22,8 +22,6 @@ namespace HostsUILib.Helpers Task<bool> PingAsync(string address); - void CleanupBackup(); - void OpenHostsFile(); void RemoveReadOnlyAttribute(); diff --git a/src/modules/Hosts/HostsUILib/HostsMainPage.xaml b/src/modules/Hosts/HostsUILib/HostsMainPage.xaml index f00eef9117..77b71ef5f1 100644 --- a/src/modules/Hosts/HostsUILib/HostsMainPage.xaml +++ b/src/modules/Hosts/HostsUILib/HostsMainPage.xaml @@ -27,160 +27,6 @@ <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> <!-- Other merged dictionaries here --> </ResourceDictionary.MergedDictionaries> - - - <ResourceDictionary.ThemeDictionaries> - <ResourceDictionary x:Key="Default"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="Light"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="HighContrast"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SystemColorHighlightTextColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SystemControlBackgroundBaseLowBrush" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SystemColorGrayTextColor" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="SystemColorButtonTextColorBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="SystemControlDisabledBaseMediumLowBrush" /> - </ResourceDictionary> - - </ResourceDictionary.ThemeDictionaries> - - <Style x:Key="SubtleButtonStyle" TargetType="Button"> - <Setter Property="Background" Value="{ThemeResource SubtleButtonBackground}" /> - <Setter Property="BackgroundSizing" Value="InnerBorderEdge" /> - <Setter Property="Foreground" Value="{ThemeResource SubtleButtonForeground}" /> - <Setter Property="BorderBrush" Value="{ThemeResource SubtleButtonBorderBrush}" /> - <Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" /> - <Setter Property="Padding" Value="{StaticResource ButtonPadding}" /> - <Setter Property="HorizontalAlignment" Value="Left" /> - <Setter Property="VerticalAlignment" Value="Center" /> - <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> - <Setter Property="FontWeight" Value="Normal" /> - <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> - <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> - <Setter Property="FocusVisualMargin" Value="-3" /> - <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> - <Setter Property="Template"> - <Setter.Value> - <ControlTemplate TargetType="Button"> - <ContentPresenter - x:Name="ContentPresenter" - Padding="{TemplateBinding Padding}" - HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" - VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" - AnimatedIcon.State="Normal" - AutomationProperties.AccessibilityView="Raw" - Background="{TemplateBinding Background}" - BackgroundSizing="{TemplateBinding BackgroundSizing}" - BorderBrush="{TemplateBinding BorderBrush}" - BorderThickness="{TemplateBinding BorderThickness}" - Content="{TemplateBinding Content}" - ContentTemplate="{TemplateBinding ContentTemplate}" - ContentTransitions="{TemplateBinding ContentTransitions}" - CornerRadius="{TemplateBinding CornerRadius}" - Foreground="{TemplateBinding Foreground}"> - <ContentPresenter.BackgroundTransition> - <BrushTransition Duration="0:0:0.083" /> - </ContentPresenter.BackgroundTransition> - <VisualStateManager.VisualStateGroups> - <VisualStateGroup x:Name="CommonStates"> - <VisualState x:Name="Normal" /> - <VisualState x:Name="PointerOver"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="PointerOver" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Pressed"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Pressed" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Disabled"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <!-- DisabledVisual Should be handled by the control, not the animated icon. --> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Normal" /> - </VisualState.Setters> - </VisualState> - </VisualStateGroup> - </VisualStateManager.VisualStateGroups> - </ContentPresenter> - </ControlTemplate> - </Setter.Value> - </Setter> - </Style> - - <tkconverters:StringVisibilityConverter x:Key="StringVisibilityConverter" EmptyValue="Collapsed" @@ -611,6 +457,7 @@ <ContentDialog x:Name="EntryDialog" x:Uid="EntryDialog" + x:DataType="models:Entry" IsPrimaryButtonEnabled="{Binding Valid, Mode=OneWay}" Loaded="ContentDialog_Loaded_ApplyMargin" PrimaryButtonStyle="{StaticResource AccentButtonStyle}"> diff --git a/src/modules/Hosts/HostsUILib/HostsMainPage.xaml.cs b/src/modules/Hosts/HostsUILib/HostsMainPage.xaml.cs index 197fab4a3b..9e17077e8a 100644 --- a/src/modules/Hosts/HostsUILib/HostsMainPage.xaml.cs +++ b/src/modules/Hosts/HostsUILib/HostsMainPage.xaml.cs @@ -139,10 +139,23 @@ namespace HostsUILib.Views dialog.XamlRoot = XamlRoot; dialog.Style = Application.Current.Resources["DefaultContentDialogStyle"] as Style; dialog.Title = resourceLoader.GetString("WarningDialog_Title"); - dialog.Content = new TextBlock + dialog.Content = new StackPanel { - Text = resourceLoader.GetString("WarningDialog_Text"), - TextWrapping = TextWrapping.Wrap, + Children = + { + new TextBlock + { + Text = resourceLoader.GetString("WarningDialog_Text"), + TextWrapping = TextWrapping.Wrap, + }, + new HyperlinkButton + { + Content = resourceLoader.GetString("WarningDialog_LearnMore"), + NavigateUri = new Uri("https://aka.ms/PowerToysOverview_HostsFileEditor"), + Padding = new Thickness(0), + Margin = new Thickness(0, 5, 0, 5), + }, + }, }; dialog.PrimaryButtonText = resourceLoader.GetString("WarningDialog_AcceptBtn"); dialog.PrimaryButtonStyle = Application.Current.Resources["AccentButtonStyle"] as Style; diff --git a/src/modules/Hosts/HostsUILib/HostsUILib.csproj b/src/modules/Hosts/HostsUILib/HostsUILib.csproj index 21e7822000..267e5e137d 100644 --- a/src/modules/Hosts/HostsUILib/HostsUILib.csproj +++ b/src/modules/Hosts/HostsUILib/HostsUILib.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <OutputType>Library</OutputType> diff --git a/src/modules/Hosts/HostsUILib/Models/Entry.cs b/src/modules/Hosts/HostsUILib/Models/Entry.cs index 0c7c47efc1..02b02f6c63 100644 --- a/src/modules/Hosts/HostsUILib/Models/Entry.cs +++ b/src/modules/Hosts/HostsUILib/Models/Entry.cs @@ -11,6 +11,9 @@ using HostsUILib.Helpers; namespace HostsUILib.Models { +#if !TESTONLY + [Microsoft.UI.Xaml.Data.Bindable] +#endif public partial class Entry : ObservableObject { private static readonly char[] _spaceCharacters = new char[] { ' ', '\t' }; diff --git a/src/modules/Hosts/HostsUILib/Settings/HostsDeleteBackupMode.cs b/src/modules/Hosts/HostsUILib/Settings/HostsDeleteBackupMode.cs new file mode 100644 index 0000000000..d1e1d79ded --- /dev/null +++ b/src/modules/Hosts/HostsUILib/Settings/HostsDeleteBackupMode.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. + +namespace HostsUILib.Settings +{ + public enum HostsDeleteBackupMode + { + Never = 0, + Count = 1, + Age = 2, + } +} diff --git a/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs b/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs index 21a8e6fa36..4f175398ad 100644 --- a/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs +++ b/src/modules/Hosts/HostsUILib/Settings/IUserSettings.cs @@ -16,8 +16,20 @@ namespace HostsUILib.Settings public HostsEncoding Encoding { get; } + public bool BackupHosts { get; } + + public string BackupPath { get; } + + public HostsDeleteBackupMode DeleteBackupsMode { get; } + + public int DeleteBackupsDays { get; } + + public int DeleteBackupsCount { get; } + event EventHandler LoopbackDuplicatesChanged; public delegate void OpenSettingsFunction(); + + public bool NoLeadingSpaces { get; } } } diff --git a/src/modules/Hosts/HostsUILib/Strings/en-us/Resources.resw b/src/modules/Hosts/HostsUILib/Strings/en-us/Resources.resw index af1346226c..3b21a1f763 100644 --- a/src/modules/Hosts/HostsUILib/Strings/en-us/Resources.resw +++ b/src/modules/Hosts/HostsUILib/Strings/en-us/Resources.resw @@ -331,6 +331,9 @@ <data name="WarningDialog_Title" xml:space="preserve"> <value>Warning</value> </data> + <data name="WarningDialog_LearnMore" xml:space="preserve"> + <value>Learn more</value> + </data> <data name="WindowAdminTitle" xml:space="preserve"> <value>Administrator: Hosts File Editor</value> <comment>Title of the window when running as administrator. "Hosts File Editor" is the name of the utility. "Hosts" refers to the system hosts file, do not loc</comment> diff --git a/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj b/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj new file mode 100644 index 0000000000..c0c7cb2e5a --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <ItemGroup Label="ProjectConfigurations"> + <ProjectConfiguration Include="Debug|x64"> + <Configuration>Debug</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|x64"> + <Configuration>Release</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Debug|ARM64"> + <Configuration>Debug</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|ARM64"> + <Configuration>Release</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + </ItemGroup> + <PropertyGroup Label="Globals"> + <VCProjectVersion>17.0</VCProjectVersion> + <Keyword>Win32Proj</Keyword> + <ProjectGuid>{79267138-2895-4346-9021-21408d65379f}</ProjectGuid> + <RootNamespace>LightSwitchLib</RootNamespace> + <WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion> + <ProjectName>LightSwitchLib</ProjectName> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration"> + <ConfigurationType>StaticLibrary</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration"> + <ConfigurationType>StaticLibrary</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration"> + <ConfigurationType>StaticLibrary</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration"> + <ConfigurationType>StaticLibrary</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir> + </PropertyGroup> + <ItemDefinitionGroup> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <PrecompiledHeader>Use</PrecompiledHeader> + <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> + <AdditionalIncludeDirectories> + ./; + ..\..\..\common; + ..\..\..\common\logger; + ..\..\..\common\utils; + ..\..\..\..\deps\spdlog\include; + %(AdditionalIncludeDirectories) + </AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClInclude Include="pch.h" /> + <ClInclude Include="ThemeHelper.h" /> + </ItemGroup> + <ItemGroup> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">Create</PrecompiledHeader> + <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">Create</PrecompiledHeader> + <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader> + <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">Create</PrecompiledHeader> + </ClCompile> + <ClCompile Include="ThemeHelper.cpp" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> + </ProjectReference> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <Import Project="..\..\..\..\deps\spdlog.props" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> +</Project> diff --git a/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj.filters b/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj.filters new file mode 100644 index 0000000000..0792aad8f7 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchLib/LightSwitchLib.vcxproj.filters @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="ThemeHelper.h"> + <Filter>Header Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <ClCompile Include="pch.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="ThemeHelper.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> +</Project> diff --git a/src/modules/LightSwitch/LightSwitchLib/ThemeHelper.cpp b/src/modules/LightSwitch/LightSwitchLib/ThemeHelper.cpp new file mode 100644 index 0000000000..ccd8ff244f --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchLib/ThemeHelper.cpp @@ -0,0 +1,136 @@ +#include "pch.h" +#include "ThemeHelper.h" +#include <logger/logger.h> + +// Controls changing the themes. + +static void ResetColorPrevalence() +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + PERSONALIZATION_REGISTRY_PATH, + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = 0; // back to default value + RegSetValueEx(hKey, L"ColorPrevalence", 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast<LPARAM>(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_DWMCOLORIZATIONCOLORCHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +void SetAppsTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + PERSONALIZATION_REGISTRY_PATH, + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"AppsUseLightTheme", 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value)); + RegCloseKey(hKey); + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast<LPARAM>(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +void SetSystemTheme(bool mode) +{ + HKEY hKey; + if (RegOpenKeyEx(HKEY_CURRENT_USER, + PERSONALIZATION_REGISTRY_PATH, + 0, + KEY_SET_VALUE, + &hKey) == ERROR_SUCCESS) + { + DWORD value = mode; + RegSetValueEx(hKey, L"SystemUsesLightTheme", 0, REG_DWORD, reinterpret_cast<const BYTE*>(&value), sizeof(value)); + RegCloseKey(hKey); + + if (mode) // if are changing to light mode + { + ResetColorPrevalence(); + Logger::info(L"[LightSwitchLib] Reset ColorPrevalence to default when switching to light mode."); + } + + SendMessageTimeout(HWND_BROADCAST, WM_SETTINGCHANGE, 0, reinterpret_cast<LPARAM>(L"ImmersiveColorSet"), SMTO_ABORTIFHUNG, 5000, nullptr); + + SendMessageTimeout(HWND_BROADCAST, WM_THEMECHANGED, 0, 0, SMTO_ABORTIFHUNG, 5000, nullptr); + } +} + +// Can think of this as "is the current theme light?" +bool GetCurrentSystemTheme() +{ + HKEY hKey; + DWORD value = 1; // default = light + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + PERSONALIZATION_REGISTRY_PATH, + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"SystemUsesLightTheme", nullptr, nullptr, reinterpret_cast<LPBYTE>(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} + +bool GetCurrentAppsTheme() +{ + HKEY hKey; + DWORD value = 1; + DWORD size = sizeof(value); + + if (RegOpenKeyEx(HKEY_CURRENT_USER, + PERSONALIZATION_REGISTRY_PATH, + 0, + KEY_READ, + &hKey) == ERROR_SUCCESS) + { + RegQueryValueEx(hKey, L"AppsUseLightTheme", nullptr, nullptr, reinterpret_cast<LPBYTE>(&value), &size); + RegCloseKey(hKey); + } + + return value == 1; // true = light, false = dark +} + +bool IsNightLightEnabled() +{ + HKEY hKey; + const wchar_t* path = NIGHT_LIGHT_REGISTRY_PATH; + + if (RegOpenKeyExW(HKEY_CURRENT_USER, path, 0, KEY_READ, &hKey) != ERROR_SUCCESS) + return false; + + // RegGetValueW will set size to the size of the data and we expect that to be at least 25 bytes (we need to access bytes 23 and 24) + DWORD size = 0; + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, nullptr, &size) != ERROR_SUCCESS || size < 25) + { + RegCloseKey(hKey); + return false; + } + + std::vector<BYTE> data(size); + if (RegGetValueW(hKey, nullptr, L"Data", RRF_RT_REG_BINARY, nullptr, data.data(), &size) != ERROR_SUCCESS) + { + RegCloseKey(hKey); + return false; + } + + RegCloseKey(hKey); + return data[23] == 0x10 && data[24] == 0x00; +} diff --git a/src/modules/LightSwitch/LightSwitchLib/ThemeHelper.h b/src/modules/LightSwitch/LightSwitchLib/ThemeHelper.h new file mode 100644 index 0000000000..8720a3b19d --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchLib/ThemeHelper.h @@ -0,0 +1,10 @@ +#pragma once + +inline constexpr wchar_t PERSONALIZATION_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +inline constexpr wchar_t NIGHT_LIGHT_REGISTRY_PATH[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\CloudStore\\Store\\DefaultAccount\\Current\\default$windows.data.bluelightreduction.bluelightreductionstate\\windows.data.bluelightreduction.bluelightreductionstate"; + +void SetSystemTheme(bool isLight); +void SetAppsTheme(bool isLight); +bool GetCurrentSystemTheme(); +bool GetCurrentAppsTheme(); +bool IsNightLightEnabled(); diff --git a/src/modules/LightSwitch/LightSwitchLib/pch.cpp b/src/modules/LightSwitch/LightSwitchLib/pch.cpp new file mode 100644 index 0000000000..1d9f38c57d --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchLib/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/LightSwitch/LightSwitchLib/pch.h b/src/modules/LightSwitch/LightSwitchLib/pch.h new file mode 100644 index 0000000000..b8d235d9c5 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchLib/pch.h @@ -0,0 +1,5 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +#include <windows.h> +#include <vector> diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/ExportedFunctions.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/ExportedFunctions.cpp new file mode 100644 index 0000000000..d9046c361e --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/ExportedFunctions.cpp @@ -0,0 +1,22 @@ +#include "pch.h" +#include "ThemeHelper.h" + +extern "C" __declspec(dllexport) void __cdecl LightSwitch_SetSystemTheme(bool isLight) +{ + SetSystemTheme(isLight); +} + +extern "C" __declspec(dllexport) void __cdecl LightSwitch_SetAppsTheme(bool isLight) +{ + SetAppsTheme(isLight); +} + +extern "C" __declspec(dllexport) bool __cdecl LightSwitch_GetCurrentSystemTheme() +{ + return GetCurrentSystemTheme(); +} + +extern "C" __declspec(dllexport) bool __cdecl LightSwitch_GetCurrentAppsTheme() +{ + return GetCurrentAppsTheme(); +} diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.rc b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.rc new file mode 100644 index 0000000000..54ba40dd45 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.rc @@ -0,0 +1,36 @@ +#include <windows.h> +#include "resource.h" +#include "../../../common/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 \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj new file mode 100644 index 0000000000..fd2d8f120f --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj @@ -0,0 +1,224 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <ItemGroup Label="ProjectConfigurations"> + <ProjectConfiguration Include="Debug|x64"> + <Configuration>Debug</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|x64"> + <Configuration>Release</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Debug|ARM64"> + <Configuration>Debug</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|ARM64"> + <Configuration>Release</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + </ItemGroup> + <PropertyGroup Label="Globals"> + <VCProjectVersion>15.0</VCProjectVersion> + <ProjectGuid>{38177d56-6ad1-4adf-88c9-2843a7932166}</ProjectGuid> + <Keyword>Win32Proj</Keyword> + <RootNamespace>LightSwitchModuleInterface</RootNamespace> + <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion> + <ProjectName>LightSwitchModuleInterface</ProjectName> + <TargetName>PowerToys.LightSwitchModuleInterface</TargetName> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + <PlatformToolset>v142</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + <PlatformToolset>v142</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + <PlatformToolset>v142</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + <PlatformToolset>v142</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <LinkIncremental>true</LinkIncremental> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <LinkIncremental>false</LinkIncremental> + </PropertyGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <ClCompile> + <PrecompiledHeader>Use</PrecompiledHeader> + <WarningLevel>Level3</WarningLevel> + <Optimization>Disabled</Optimization> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_DEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> + <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary> + <LanguageStandard>stdcpplatest</LanguageStandard> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <ClCompile> + <PrecompiledHeader>Use</PrecompiledHeader> + <WarningLevel>Level3</WarningLevel> + <Optimization>MaxSpeed</Optimization> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>NDEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> + <RuntimeLibrary>MultiThreaded</RuntimeLibrary> + <LanguageStandard>stdcpplatest</LanguageStandard> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'"> + <ClCompile> + <PrecompiledHeader>Use</PrecompiledHeader> + <WarningLevel>Level3</WarningLevel> + <Optimization>Disabled</Optimization> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_DEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> + <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary> + <LanguageStandard>stdcpplatest</LanguageStandard> + <PrecompiledHeader>Use</PrecompiledHeader> + <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'"> + <ClCompile> + <PrecompiledHeader>Use</PrecompiledHeader> + <WarningLevel>Level3</WarningLevel> + <Optimization>MaxSpeed</Optimization> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>NDEBUG;EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> + <RuntimeLibrary>MultiThreaded</RuntimeLibrary> + <LanguageStandard>stdcpplatest</LanguageStandard> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup> + <ClCompile> + <AdditionalIncludeDirectories>..\LightSwitchLib;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + </ClCompile> + </ItemDefinitionGroup> + <ItemGroup> + <ClInclude Include="pch.h" /> + <ClInclude Include="resource.h" /> + <ClInclude Include="trace.h" /> + </ItemGroup> + <ItemGroup> + <ClCompile Include="dllmain.cpp" /> + <ClCompile Include="ExportedFunctions.cpp" /> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">Create</PrecompiledHeader> + <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">Create</PrecompiledHeader> + <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">Create</PrecompiledHeader> + <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">Create</PrecompiledHeader> + <PrecompiledHeaderFile Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">pch.h</PrecompiledHeaderFile> + <PrecompiledHeaderFile Condition="'$(Configuration)|$(Platform)'=='Release|x64'">pch.h</PrecompiledHeaderFile> + <PrecompiledHeaderFile Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">pch.h</PrecompiledHeaderFile> + <PrecompiledHeaderFile Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">pch.h</PrecompiledHeaderFile> + </ClCompile> + <ClCompile Include="trace.cpp" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="LightSwitchModuleInterface.rc" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> + <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\ManagedCommon\ManagedCommon.csproj"> + <Project>{4aed67b6-55fd-486f-b917-e543dee2cb3c}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + <ProjectReference Include="..\LightSwitchLib\LightSwitchLib.vcxproj"> + <Project>{79267138-2895-4346-9021-21408d65379f}</Project> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> +</Project> diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj.filters b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj.filters new file mode 100644 index 0000000000..45352efe4b --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj.filters @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <ClCompile Include="dllmain.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="pch.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="trace.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="ThemeHelper.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="resource.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="trace.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="ThemeHelper.h"> + <Filter>Header Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <Filter Include="Header Files"> + <UniqueIdentifier>{bbf22ac8-46f8-4206-b44b-9c3897e99ce5}</UniqueIdentifier> + </Filter> + <Filter Include="Source Files"> + <UniqueIdentifier>{530ed784-9a70-46a0-8fb6-20d5dee4f7d3}</UniqueIdentifier> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{da1cb871-86d3-414c-adf5-a7e9f2077d2f}</UniqueIdentifier> + </Filter> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="LightSwitchModuleInterface.rc"> + <Filter>Resource Files</Filter> + </ResourceCompile> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp new file mode 100644 index 0000000000..bab4e30797 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/dllmain.cpp @@ -0,0 +1,717 @@ +#include "pch.h" +#include <interface/powertoy_module_interface.h> +#include "trace.h" +#include <common/logger/logger.h> +#include <common/SettingsAPI/settings_objects.h> +#include <common/SettingsAPI/settings_helpers.h> +#include <locale> +#include <codecvt> +#include <common/utils/logger_helper.h> +#include "ThemeHelper.h" +#include <thread> +#include <atomic> + +extern "C" IMAGE_DOS_HEADER __ImageBase; + +namespace +{ + const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; + const wchar_t JSON_KEY_WIN[] = L"win"; + const wchar_t JSON_KEY_ALT[] = L"alt"; + const wchar_t JSON_KEY_CTRL[] = L"ctrl"; + const wchar_t JSON_KEY_SHIFT[] = L"shift"; + const wchar_t JSON_KEY_CODE[] = L"code"; + const wchar_t JSON_KEY_TOGGLE_THEME_HOTKEY[] = L"toggle-theme-hotkey"; + const wchar_t JSON_KEY_VALUE[] = L"value"; +} + +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; +} + +// The PowerToy name that will be shown in the settings. +const static wchar_t* MODULE_NAME = L"LightSwitch"; +// Add a description that will we shown in the module settings page. +const static wchar_t* MODULE_DESC = L"This is a module that allows you to control light/dark theming via set times, sun rise, or directly invoking the change."; + +enum class ScheduleMode +{ + Off, + FixedHours, + SunsetToSunrise, + FollowNightLight, + // add more later +}; + +inline std::wstring ToString(ScheduleMode mode) +{ + switch (mode) + { + case ScheduleMode::SunsetToSunrise: + return L"SunsetToSunrise"; + case ScheduleMode::FixedHours: + return L"FixedHours"; + case ScheduleMode::FollowNightLight: + return L"FollowNightLight"; + default: + return L"Off"; + } +} + +inline ScheduleMode FromString(const std::wstring& str) +{ + if (str == L"SunsetToSunrise") + return ScheduleMode::SunsetToSunrise; + if (str == L"FixedHours") + return ScheduleMode::FixedHours; + if (str == L"FollowNightLight") + return ScheduleMode::FollowNightLight; + return ScheduleMode::Off; +} + +// These are the properties shown in the Settings page. +struct ModuleSettings +{ + bool m_changeSystem = true; + bool m_changeApps = true; + ScheduleMode m_scheduleMode = ScheduleMode::Off; + int m_lightTime = 480; + int m_darkTime = 1200; + int m_sunrise_offset = 0; + int m_sunset_offset = 0; + std::wstring m_latitude = L"0.0"; + std::wstring m_longitude = L"0.0"; +} g_settings; + +class LightSwitchInterface : public PowertoyModuleIface +{ +private: + bool m_enabled = false; + + HANDLE m_process{ nullptr }; + HANDLE m_force_light_event_handle; + HANDLE m_force_dark_event_handle; + HANDLE m_manual_override_event_handle; + HANDLE m_toggle_event_handle{ nullptr }; + std::thread m_toggle_thread; + std::atomic<bool> m_toggle_thread_running{ false }; + + static const constexpr int NUM_DEFAULT_HOTKEYS = 4; + + Hotkey m_toggle_theme_hotkey = { .win = true, .ctrl = true, .shift = true, .alt = false, .key = 'D' }; + + void init_settings(); + void ToggleTheme(); + void StartToggleListener(); + void StopToggleListener(); + +public: + LightSwitchInterface() + { + LoggerHelpers::init_logger(L"LightSwitch", L"ModuleInterface", LogSettings::lightSwitchLoggerName); + + m_force_light_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_LIGHT"); + m_force_dark_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_DARK"); + m_manual_override_event_handle = CreateEventW(nullptr, TRUE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + m_toggle_event_handle = CreateDefaultEvent(L"Local\\PowerToys-LightSwitch-ToggleEvent-d8dc2f29-8c94-4ca1-8c5f-3e2b1e3c4f5a"); + + init_settings(); + }; + + virtual const wchar_t* get_key() override + { + return L"LightSwitch"; + } + + // Destroy the powertoy and free memory + virtual void destroy() override + { + // Ensure worker threads/process handles are cleaned up before destruction + disable(); + delete this; + } + + // Return the display name of the powertoy, this will be cached by the runner + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + // Return the configured status for the gpo policy for the module + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredLightSwitchEnabledValue(); + } + + // Return JSON with the configuration options. + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast<HINSTANCE>(&__ImageBase); + + // Create a Settings object with your module name + PowerToysSettings::Settings settings(hinstance, get_name()); + settings.set_description(MODULE_DESC); + settings.set_overview_link(L"https://aka.ms/powertoys"); + + // Boolean toggles + settings.add_bool_toggle( + L"changeSystem", + L"Change System Theme", + g_settings.m_changeSystem); + + settings.add_bool_toggle( + L"changeApps", + L"Change Apps Theme", + g_settings.m_changeApps); + + settings.add_choice_group( + L"scheduleMode", + L"Theme schedule mode", + ToString(g_settings.m_scheduleMode), + { { L"Off", L"Disable the schedule" }, + { L"FixedHours", L"Set hours manually" }, + { L"SunsetToSunrise", L"Use sunrise/sunset times" }, + { L"FollowNightLight", L"Follow Windows Night Light state" } + }); + + // Integer spinners + settings.add_int_spinner( + L"lightTime", + L"Time to switch to light theme (minutes after midnight).", + g_settings.m_lightTime, + 0, + 1439, + 1); + + settings.add_int_spinner( + L"darkTime", + L"Time to switch to dark theme (minutes after midnight).", + g_settings.m_darkTime, + 0, + 1439, + 1); + + settings.add_int_spinner( + L"sunrise_offset", + L"Time to offset turning on your light theme.", + g_settings.m_sunrise_offset, + 0, + 1439, + 1); + + settings.add_int_spinner( + L"sunset_offset", + L"Time to offset turning on your dark theme.", + g_settings.m_sunset_offset, + 0, + 1439, + 1); + + // Strings for latitude and longitude + settings.add_string( + L"latitude", + L"Your latitude in decimal degrees (e.g. 39.95).", + g_settings.m_latitude); + + settings.add_string( + L"longitude", + L"Your longitude in decimal degrees (e.g. -75.16).", + g_settings.m_longitude); + + // One-shot actions (buttons) + settings.add_custom_action( + L"forceLight", + L"Switch immediately to light theme", + L"Force Light", + L"{}"); + + settings.add_custom_action( + L"forceDark", + L"Switch immediately to dark theme", + L"Force Dark", + L"{}"); + + // Hotkeys + PowerToysSettings::HotkeyObject dm_hk = PowerToysSettings::HotkeyObject::from_settings( + m_toggle_theme_hotkey.win, + m_toggle_theme_hotkey.ctrl, + m_toggle_theme_hotkey.alt, + m_toggle_theme_hotkey.shift, + m_toggle_theme_hotkey.key); + + settings.add_hotkey( + L"toggle-theme-hotkey", + L"Shortcut to toggle theme immediately", + dm_hk); + + // Serialize to buffer for the PowerToys runner + return settings.serialize_to_buffer(buffer, buffer_size); + } + + // Signal from the Settings editor to call a custom action. + // This can be used to spawn more complex editors. + void call_custom_action(const wchar_t* action) override + { + try + { + auto action_object = PowerToysSettings::CustomActionObject::from_json_string(action); + + if (action_object.get_name() == L"forceLight") + { + Logger::info(L"[Light Switch] Custom action triggered: Force Light"); + SetSystemTheme(true); + SetAppsTheme(true); + } + else if (action_object.get_name() == L"forceDark") + { + Logger::info(L"[Light Switch] Custom action triggered: Force Dark"); + SetSystemTheme(false); + SetAppsTheme(false); + } + } + catch (...) + { + Logger::error(L"[Light Switch] Invalid custom action JSON"); + } + } + + // Called by the runner to pass the updated settings values as a serialized JSON. + virtual void set_config(const wchar_t* config) override + { + try + { + auto values = PowerToysSettings::PowerToyValues::from_json_string(config, get_key()); + + parse_hotkey(values); + + if (auto v = values.get_bool_value(L"changeSystem")) + { + g_settings.m_changeSystem = *v; + } + + if (auto v = values.get_bool_value(L"changeApps")) + { + g_settings.m_changeApps = *v; + } + + auto previousMode = g_settings.m_scheduleMode; + + if (auto v = values.get_string_value(L"scheduleMode")) + { + auto newMode = FromString(*v); + if (newMode != g_settings.m_scheduleMode) + { + Logger::info(L"[LightSwitchInterface] Schedule mode changed from {} to {}", + ToString(g_settings.m_scheduleMode), + ToString(newMode)); + g_settings.m_scheduleMode = newMode; + + start_service_if_needed(); + } + } + + if (auto v = values.get_int_value(L"lightTime")) + { + g_settings.m_lightTime = *v; + } + + if (auto v = values.get_int_value(L"darkTime")) + { + g_settings.m_darkTime = *v; + } + + if (auto v = values.get_int_value(L"sunrise_offset")) + { + g_settings.m_sunrise_offset = *v; + } + + if (auto v = values.get_int_value(L"sunset_offset")) + { + g_settings.m_sunset_offset = *v; + } + + if (auto v = values.get_string_value(L"latitude")) + { + g_settings.m_latitude = *v; + } + if (auto v = values.get_string_value(L"longitude")) + { + g_settings.m_longitude = *v; + } + + values.save_to_settings_file(); + } + catch (const std::exception&) + { + Logger::error("[Light Switch] set_config: Failed to parse or apply config."); + } + } + + virtual void start_service_if_needed() + { + if (!m_process || WaitForSingleObject(m_process, 0) != WAIT_TIMEOUT) + { + Logger::info(L"[LightSwitchInterface] Starting LightSwitchService due to active schedule mode."); + enable(); + } + else + { + Logger::debug(L"[LightSwitchInterface] Service already running, skipping start."); + } + } + + /*virtual void stop_worker_only() + { + if (m_process) + { + Logger::info(L"[LightSwitchInterface] Stopping LightSwitchService (worker only)."); + constexpr DWORD timeout_ms = 1500; + DWORD result = WaitForSingleObject(m_process, timeout_ms); + + if (result == WAIT_TIMEOUT) + { + Logger::warn("Light Switch: Process didn't exit in time. Forcing termination."); + TerminateProcess(m_process, 0); + } + + CloseHandle(m_process); + m_process = nullptr; + } + }*/ + + /*virtual void stop_service_if_running() + { + if (m_process) + { + Logger::info(L"[LightSwitchInterface] Stopping LightSwitchService due to schedule OFF."); + stop_worker_only(); + } + }*/ + + virtual void enable() + { + m_enabled = true; + Logger::info(L"Enabling Light Switch module..."); + Trace::Enable(true); + + unsigned long powertoys_pid = GetCurrentProcessId(); + std::wstring args = L"--pid " + std::to_wstring(powertoys_pid); + std::wstring exe_name = L"LightSwitchService\\PowerToys.LightSwitchService.exe"; + + std::wstring resolved_path(MAX_PATH, L'\0'); + DWORD result = SearchPathW( + nullptr, + exe_name.c_str(), + nullptr, + static_cast<DWORD>(resolved_path.size()), + resolved_path.data(), + nullptr); + + if (result == 0 || result >= resolved_path.size()) + { + Logger::error( + L"Failed to locate Light Switch executable named '{}' at location '{}'", + exe_name, + resolved_path.c_str()); + return; + } + + resolved_path.resize(result); + Logger::debug(L"Resolved executable path: {}", resolved_path); + + std::wstring command_line = L"\"" + resolved_path + L"\" " + args; + + STARTUPINFO si = { sizeof(si) }; + PROCESS_INFORMATION pi; + + if (!CreateProcessW( + resolved_path.c_str(), + command_line.data(), + nullptr, + nullptr, + TRUE, + 0, + nullptr, + nullptr, + &si, + &pi)) + { + Logger::error(L"Failed to launch Light Switch process. {}", get_last_error_or_default(GetLastError())); + return; + } + + Logger::info(L"Light Switch process launched successfully (PID: {}).", pi.dwProcessId); + m_process = pi.hProcess; + CloseHandle(pi.hThread); + + StartToggleListener(); + } + + // Disable the powertoy + virtual void disable() + { + Logger::info("Light Switch disabling"); + m_enabled = false; + + if (m_process) + { + constexpr DWORD timeout_ms = 1500; + DWORD result = WaitForSingleObject(m_process, timeout_ms); + + if (result == WAIT_TIMEOUT) + { + Logger::warn("Light Switch: Process didn't exit in time. Forcing termination."); + TerminateProcess(m_process, 0); + } + + CloseHandle(m_manual_override_event_handle); + m_manual_override_event_handle = nullptr; + + CloseHandle(m_process); + m_process = nullptr; + } + + Trace::Enable(false); + StopToggleListener(); + } + + // Returns if the powertoys is enabled + virtual bool is_enabled() override + { + return m_enabled; + } + + // Returns whether the PowerToys should be enabled by default + virtual bool is_enabled_by_default() const override + { + return false; + } + + void parse_hotkey(PowerToysSettings::PowerToyValues& settings) + { + auto settingsObject = settings.get_raw_json(); + if (settingsObject.GetView().Size()) + { + try + { + Hotkey _temp_toggle_theme; + auto jsonHotkeyObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_TOGGLE_THEME_HOTKEY).GetNamedObject(JSON_KEY_VALUE); + _temp_toggle_theme.win = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_WIN); + _temp_toggle_theme.alt = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_ALT); + _temp_toggle_theme.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT); + _temp_toggle_theme.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL); + _temp_toggle_theme.key = static_cast<unsigned char>(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE)); + m_toggle_theme_hotkey = _temp_toggle_theme; + } + catch (...) + { + Logger::error("Failed to initialize Light Switch force dark mode shortcut from settings. Value will keep unchanged."); + } + } + else + { + Logger::info("Light Switch settings are empty"); + } + } + + virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override + { + if (hotkeys && buffer_size >= 1) + { + hotkeys[0] = m_toggle_theme_hotkey; + } + return 1; + } + + virtual bool on_hotkey(size_t hotkeyId) override + { + if (m_enabled) + { + Logger::trace(L"Light Switch hotkey pressed"); + Trace::ShortcutInvoked(); + + if (!is_process_running()) + { + enable(); + } + else if (hotkeyId == 0) + { + Logger::info(L"[Light Switch] Hotkey triggered: Toggle Theme"); + ToggleTheme(); + } + + return true; + } + + return false; + } + + bool is_process_running() + { + return WaitForSingleObject(m_process, 0) == WAIT_TIMEOUT; + } + +}; + +void LightSwitchInterface::ToggleTheme() +{ + if (g_settings.m_changeSystem) + { + SetSystemTheme(!GetCurrentSystemTheme()); + } + if (g_settings.m_changeApps) + { + SetAppsTheme(!GetCurrentAppsTheme()); + } + + if (!m_manual_override_event_handle) + { + m_manual_override_event_handle = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + if (!m_manual_override_event_handle) + { + m_manual_override_event_handle = CreateEventW(nullptr, TRUE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + } + } + + if (m_manual_override_event_handle) + { + SetEvent(m_manual_override_event_handle); + Logger::debug(L"[Light Switch] Manual override event set"); + } +} + +void LightSwitchInterface::StartToggleListener() +{ + if (m_toggle_thread_running || !m_toggle_event_handle) + { + return; + } + + m_toggle_thread_running = true; + m_toggle_thread = std::thread([this]() { + while (m_toggle_thread_running) + { + const DWORD wait_result = WaitForSingleObject(m_toggle_event_handle, 500); + if (!m_toggle_thread_running) + { + break; + } + + if (wait_result == WAIT_OBJECT_0) + { + ToggleTheme(); + ResetEvent(m_toggle_event_handle); + } + } + }); +} + +void LightSwitchInterface::StopToggleListener() +{ + if (!m_toggle_thread_running) + { + return; + } + + m_toggle_thread_running = false; + if (m_toggle_event_handle) + { + SetEvent(m_toggle_event_handle); + } + if (m_toggle_thread.joinable()) + { + m_toggle_thread.join(); + } +} + +std::wstring utf8_to_wstring(const std::string& str) +{ + if (str.empty()) + return std::wstring(); + + int size_needed = MultiByteToWideChar( + CP_UTF8, + 0, + str.c_str(), + static_cast<int>(str.size()), + nullptr, + 0); + + std::wstring wstr(size_needed, 0); + + MultiByteToWideChar( + CP_UTF8, + 0, + str.c_str(), + static_cast<int>(str.size()), + &wstr[0], + size_needed); + + return wstr; +} + +// Load the settings file. +void LightSwitchInterface::init_settings() +{ + Logger::info(L"[Light Switch] init_settings: starting to load settings for module"); + + try + { + PowerToysSettings::PowerToyValues settings = + PowerToysSettings::PowerToyValues::load_from_settings_file(get_name()); + + parse_hotkey(settings); + + if (auto v = settings.get_bool_value(L"changeSystem")) + g_settings.m_changeSystem = *v; + if (auto v = settings.get_bool_value(L"changeApps")) + g_settings.m_changeApps = *v; + if (auto v = settings.get_string_value(L"scheduleMode")) + g_settings.m_scheduleMode = FromString(*v); + if (auto v = settings.get_int_value(L"lightTime")) + g_settings.m_lightTime = *v; + if (auto v = settings.get_int_value(L"darkTime")) + g_settings.m_darkTime = *v; + if (auto v = settings.get_int_value(L"sunrise_offset")) + g_settings.m_sunrise_offset = *v; + if (auto v = settings.get_int_value(L"sunset_offset")) + g_settings.m_sunset_offset = *v; + if (auto v = settings.get_string_value(L"latitude")) + g_settings.m_latitude = *v; + if (auto v = settings.get_string_value(L"longitude")) + g_settings.m_longitude = *v; + + Logger::info(L"[Light Switch] init_settings: loaded successfully"); + } + catch (const winrt::hresult_error& e) + { + Logger::error(L"[Light Switch] init_settings: hresult_error 0x{:08X} - {}", e.code(), e.message().c_str()); + } + catch (const std::exception& e) + { + std::wstring whatStr = utf8_to_wstring(e.what()); + Logger::error(L"[Light Switch] init_settings: std::exception - {}", whatStr); + } + catch (...) + { + Logger::error(L"[Light Switch] init_settings: unknown exception while loading settings"); + } +} + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new LightSwitchInterface(); +} diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/pch.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.cpp new file mode 100644 index 0000000000..a83d3bb2cc --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.cpp @@ -0,0 +1,2 @@ +#include "pch.h" +#pragma comment(lib, "windowsapp") \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/pch.h b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.h new file mode 100644 index 0000000000..39f8f4ac84 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/pch.h @@ -0,0 +1,14 @@ +#pragma once +#define WIN32_LEAN_AND_MEAN +#include <windows.h> + +#include <common/SettingsAPI/settings_helpers.h> +#include <common/utils/gpo.h> +#include <common/utils/winapi_error.h> +#include <shlwapi.h> +#include <shellapi.h> +#include <winrt/Windows.Foundation.h> +#include <winrt/Windows.System.h> +#include <winrt/Windows.Globalization.h> +#include <winrt/Windows.ApplicationModel.h> +#include <winrt/Windows.ApplicationModel.Core.h> diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/resource.h b/src/modules/LightSwitch/LightSwitchModuleInterface/resource.h new file mode 100644 index 0000000000..548cde844b --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/resource.h @@ -0,0 +1,13 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by CalculatorEngineCommon.rc + +////////////////////////////// +// Non-localizable + +#define FILE_DESCRIPTION "Light Switch Module" +#define INTERNAL_NAME "Light Switch" +#define ORIGINAL_FILENAME "PowerToys.LightSwitchModuleInterface.dll" + +// Non-localizable +////////////////////////////// diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/trace.cpp b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.cpp new file mode 100644 index 0000000000..40fc67e679 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.cpp @@ -0,0 +1,39 @@ +#include "pch.h" +#include "trace.h" +#include <TraceLoggingProvider.h> + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::Enable(bool enabled) noexcept +{ + TraceLoggingWrite( + g_hProvider, + "LightSwitch_EnableLightSwitch", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} + +void Trace::ShortcutInvoked() noexcept +{ + TraceLoggingWrite( + g_hProvider, + "LightSwitch_ShortcutInvoked", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} diff --git a/src/modules/LightSwitch/LightSwitchModuleInterface/trace.h b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.h new file mode 100644 index 0000000000..bfa32062b9 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchModuleInterface/trace.h @@ -0,0 +1,16 @@ +#pragma once + +#include <windows.h> +#include <TraceLoggingActivity.h> +#include <common/telemetry/ProjectTelemetry.h> + +TRACELOGGING_DECLARE_PROVIDER(g_hProvider); + +class Trace +{ +public: + static void RegisterProvider(); + static void UnregisterProvider(); + static void Enable(bool enabled) noexcept; + static void ShortcutInvoked() noexcept; +}; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitch.ico b/src/modules/LightSwitch/LightSwitchService/LightSwitch.ico new file mode 100644 index 0000000000..ee1be50010 Binary files /dev/null and b/src/modules/LightSwitch/LightSwitchService/LightSwitch.ico differ diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp new file mode 100644 index 0000000000..5b4fa8297b --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.cpp @@ -0,0 +1,379 @@ +#include <windows.h> +#include <tchar.h> +#include "ThemeScheduler.h" +#include "ThemeHelper.h" +#include <common/SettingsAPI/settings_objects.h> +#include <common/SettingsAPI/settings_helpers.h> +#include <stdio.h> +#include <string> +#include <LightSwitchSettings.h> +#include <common/utils/gpo.h> +#include <logger/logger_settings.h> +#include <logger/logger.h> +#include <utils/logger_helper.h> +#include "LightSwitchStateManager.h" +#include <LightSwitchUtils.h> +#include <NightLightRegistryObserver.h> +#include <trace.h> + +SERVICE_STATUS g_ServiceStatus = {}; +SERVICE_STATUS_HANDLE g_StatusHandle = nullptr; +HANDLE g_ServiceStopEvent = nullptr; +static LightSwitchStateManager* g_stateManagerPtr = nullptr; + +VOID WINAPI ServiceMain(DWORD argc, LPTSTR* argv); +VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl); +DWORD WINAPI ServiceWorkerThread(LPVOID lpParam); +void ApplyTheme(bool shouldBeLight); + +// Entry point for the executable +int _tmain(int argc, TCHAR* argv[]) +{ + DWORD parentPid = 0; + bool debug = false; + for (int i = 1; i < argc; ++i) + { + if (_tcscmp(argv[i], _T("--debug")) == 0) + debug = true; + else if (_tcscmp(argv[i], _T("--pid")) == 0 && i + 1 < argc) + parentPid = _tstoi(argv[++i]); + } + + // Try to connect to SCM + wchar_t serviceName[] = L"LightSwitchService"; + SERVICE_TABLE_ENTRYW table[] = { { serviceName, ServiceMain }, { nullptr, nullptr } }; + + LoggerHelpers::init_logger(L"LightSwitch", L"Service", LogSettings::lightSwitchLoggerName); + + if (!StartServiceCtrlDispatcherW(table)) + { + DWORD err = GetLastError(); + if (err == ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) // not launched by SCM + { + g_ServiceStopEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr); + HANDLE hThread = CreateThread( + nullptr, 0, ServiceWorkerThread, reinterpret_cast<void*>(static_cast<ULONG_PTR>(parentPid)), 0, nullptr); + + // Wait so the process stays alive + WaitForSingleObject(hThread, INFINITE); + CloseHandle(hThread); + CloseHandle(g_ServiceStopEvent); + return 0; + } + return static_cast<int>(err); + } + + return 0; +} + +// Called when the service is launched by Windows +VOID WINAPI ServiceMain(DWORD, LPTSTR*) +{ + g_StatusHandle = RegisterServiceCtrlHandler(_T("LightSwitchService"), ServiceCtrlHandler); + if (!g_StatusHandle) + return; + + g_ServiceStatus.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + g_ServiceStatus.dwControlsAccepted = SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN; + g_ServiceStatus.dwCurrentState = SERVICE_START_PENDING; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + + g_ServiceStopEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr); + if (!g_ServiceStopEvent) + { + g_ServiceStatus.dwCurrentState = SERVICE_STOPPED; + g_ServiceStatus.dwWin32ExitCode = GetLastError(); + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + return; + } + + SECURITY_ATTRIBUTES sa{ sizeof(sa) }; + sa.bInheritHandle = FALSE; + sa.lpSecurityDescriptor = nullptr; + + g_ServiceStatus.dwCurrentState = SERVICE_RUNNING; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + + HANDLE hThread = CreateThread(nullptr, 0, ServiceWorkerThread, nullptr, 0, nullptr); + WaitForSingleObject(hThread, INFINITE); + CloseHandle(hThread); + + CloseHandle(g_ServiceStopEvent); + g_ServiceStatus.dwCurrentState = SERVICE_STOPPED; + g_ServiceStatus.dwWin32ExitCode = 0; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); +} + +VOID WINAPI ServiceCtrlHandler(DWORD dwCtrl) +{ + switch (dwCtrl) + { + case SERVICE_CONTROL_STOP: + if (g_ServiceStatus.dwCurrentState != SERVICE_RUNNING) + break; + + g_ServiceStatus.dwCurrentState = SERVICE_STOP_PENDING; + SetServiceStatus(g_StatusHandle, &g_ServiceStatus); + + // Signal the service to stop + Logger::info(L"[LightSwitchService] Stop requested, signaling worker thread to exit."); + SetEvent(g_ServiceStopEvent); + break; + + default: + break; + } +} + +void ApplyTheme(bool shouldBeLight) +{ + const auto& s = LightSwitchSettings::settings(); + + if (s.changeSystem) + { + bool isSystemCurrentlyLight = GetCurrentSystemTheme(); + if (shouldBeLight != isSystemCurrentlyLight) + { + SetSystemTheme(shouldBeLight); + Logger::info(L"[LightSwitchService] Changed system theme to {}.", shouldBeLight ? L"light" : L"dark"); + } + } + + if (s.changeApps) + { + bool isAppsCurrentlyLight = GetCurrentAppsTheme(); + if (shouldBeLight != isAppsCurrentlyLight) + { + SetAppsTheme(shouldBeLight); + Logger::info(L"[LightSwitchService] Changed apps theme to {}.", shouldBeLight ? L"light" : L"dark"); + } + } +} + +static void DetectAndHandleExternalThemeChange(LightSwitchStateManager& stateManager) +{ + const auto& s = LightSwitchSettings::settings(); + if (s.scheduleMode == ScheduleMode::Off) + return; + + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + // Compute effective boundaries (with offsets if needed) + int effectiveLight = s.lightTime; + int effectiveDark = s.darkTime; + + if (s.scheduleMode == ScheduleMode::SunsetToSunrise) + { + effectiveLight = (s.lightTime + s.sunrise_offset) % 1440; + effectiveDark = (s.darkTime + s.sunset_offset) % 1440; + } + + // Use shared helper (handles wraparound logic) + bool shouldBeLight = false; + if (s.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !IsNightLightEnabled(); + } + else + { + shouldBeLight = ShouldBeLight(nowMinutes, effectiveLight, effectiveDark); + } + + // Compare current system/apps theme + bool currentSystemLight = GetCurrentSystemTheme(); + bool currentAppsLight = GetCurrentAppsTheme(); + + bool systemMismatch = s.changeSystem && (currentSystemLight != shouldBeLight); + bool appsMismatch = s.changeApps && (currentAppsLight != shouldBeLight); + + // Trigger manual override only if mismatch and not already active + if ((systemMismatch || appsMismatch) && !stateManager.GetState().isManualOverride) + { + Logger::info(L"[LightSwitchService] External theme change detected (Windows Settings). Entering manual override mode."); + stateManager.OnManualOverride(); + } +} + +DWORD WINAPI ServiceWorkerThread(LPVOID lpParam) +{ + DWORD parentPid = static_cast<DWORD>(reinterpret_cast<ULONG_PTR>(lpParam)); + HANDLE hParent = nullptr; + if (parentPid) + hParent = OpenProcess(SYNCHRONIZE, FALSE, parentPid); + + Logger::info(L"[LightSwitchService] Worker thread starting..."); + Logger::info(L"[LightSwitchService] Parent PID: {}", parentPid); + + // ──────────────────────────────────────────────────────────────── + // Initialization + // ──────────────────────────────────────────────────────────────── + static LightSwitchStateManager stateManager; + g_stateManagerPtr = &stateManager; + + LightSwitchSettings::instance().InitFileWatcher(); + + HANDLE hManualOverride = OpenEventW(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE"); + HANDLE hSettingsChanged = LightSwitchSettings::instance().GetSettingsChangedEvent(); + + static std::unique_ptr<NightLightRegistryObserver> g_nightLightWatcher; + + LightSwitchSettings::instance().LoadSettings(); + const auto& settings = LightSwitchSettings::instance().settings(); + + // after loading settings: + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique<NightLightRegistryObserver>( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + + SYSTEMTIME st; + GetLocalTime(&st); + int nowMinutes = st.wHour * 60 + st.wMinute; + + Logger::info(L"[LightSwitchService] Initialized at {:02d}:{:02d}.", st.wHour, st.wMinute); + stateManager.SyncInitialThemeState(); + + // ──────────────────────────────────────────────────────────────── + // Worker Loop + // ──────────────────────────────────────────────────────────────── + for (;;) + { + HANDLE waits[4]; + DWORD count = 0; + waits[count++] = g_ServiceStopEvent; + if (hParent) + waits[count++] = hParent; + if (hManualOverride) + waits[count++] = hManualOverride; + waits[count++] = hSettingsChanged; + + // Wait for one of these to trigger or for a new minute tick + SYSTEMTIME st; + GetLocalTime(&st); + int msToNextMinute = (60 - st.wSecond) * 1000 - st.wMilliseconds; + if (msToNextMinute < 50) + msToNextMinute = 50; + + DWORD wait = WaitForMultipleObjects(count, waits, FALSE, msToNextMinute); + + if (wait == WAIT_TIMEOUT) + { + // regular minute tick + GetLocalTime(&st); + nowMinutes = st.wHour * 60 + st.wMinute; + DetectAndHandleExternalThemeChange(stateManager); + stateManager.OnTick(); + continue; + } + + if (wait == WAIT_OBJECT_0) + { + Logger::info(L"[LightSwitchService] Stop event triggered — exiting."); + break; + } + + if (hParent && wait == WAIT_OBJECT_0 + 1) + { + Logger::info(L"[LightSwitchService] Parent process exited — stopping service."); + break; + } + + if (hManualOverride && wait == WAIT_OBJECT_0 + (hParent ? 2 : 1)) + { + Logger::info(L"[LightSwitchService] Manual override event detected."); + stateManager.OnManualOverride(); + ResetEvent(hManualOverride); + continue; + } + + if (wait == WAIT_OBJECT_0 + (hParent ? (hManualOverride ? 3 : 2) : 2)) + { + ResetEvent(hSettingsChanged); + LightSwitchSettings::instance().LoadSettings(); + stateManager.OnSettingsChanged(); + + const auto& settings = LightSwitchSettings::instance().settings(); + bool nightLightNeeded = (settings.scheduleMode == ScheduleMode::FollowNightLight); + + if (nightLightNeeded && !g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Starting Night Light registry watcher..."); + + g_nightLightWatcher = std::make_unique<NightLightRegistryObserver>( + HKEY_CURRENT_USER, + NIGHT_LIGHT_REGISTRY_PATH, + []() { + if (g_stateManagerPtr) + g_stateManagerPtr->OnNightLightChange(); + }); + + stateManager.OnNightLightChange(); + } + else if (!nightLightNeeded && g_nightLightWatcher) + { + Logger::info(L"[LightSwitchService] Stopping Night Light registry watcher..."); + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + + continue; + } + } + + // ──────────────────────────────────────────────────────────────── + // Cleanup + // ──────────────────────────────────────────────────────────────── + if (hManualOverride) + CloseHandle(hManualOverride); + if (hParent) + CloseHandle(hParent); + if (g_nightLightWatcher) + { + g_nightLightWatcher->Stop(); + g_nightLightWatcher.reset(); + } + + Logger::info(L"[LightSwitchService] Worker thread exiting cleanly."); + return 0; +} + +int APIENTRY wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) +{ + Trace::LightSwitch::RegisterProvider(); + + if (powertoys_gpo::getConfiguredLightSwitchEnabledValue() == powertoys_gpo::gpo_rule_configured_disabled) + { + wchar_t msg[160]; + swprintf_s( + msg, + L"Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator."); + Logger::info(msg); + Trace::LightSwitch::UnregisterProvider(); + return 0; + } + int argc = 0; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + int rc = _tmain(argc, argv); // reuse your existing logic + LocalFree(argv); + + Trace::LightSwitch::UnregisterProvider(); + return rc; +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc new file mode 100644 index 0000000000..c1914c5a5e Binary files /dev/null and b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.rc differ diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj new file mode 100644 index 0000000000..86bc711c49 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj @@ -0,0 +1,124 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <ItemGroup Label="ProjectConfigurations"> + <ProjectConfiguration Include="Debug|x64"> + <Configuration>Debug</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|x64"> + <Configuration>Release</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Debug|ARM64"> + <Configuration>Debug</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|ARM64"> + <Configuration>Release</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + </ItemGroup> + <PropertyGroup Label="Globals"> + <VCProjectVersion>17.0</VCProjectVersion> + <Keyword>Win32Proj</Keyword> + <ProjectGuid>{08e71c67-6a7e-4ca1-b04e-2fb336410bac}</ProjectGuid> + <RootNamespace>LightSwitchService</RootNamespace> + <WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion> + <ProjectName>LightSwitchService</ProjectName> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration"> + <ConfigurationType>Application</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration"> + <ConfigurationType>Application</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir> + <TargetName>PowerToys.LightSwitchService</TargetName> + </PropertyGroup> + <ItemDefinitionGroup> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <SDLCheck>true</SDLCheck> + <ConformanceMode>true</ConformanceMode> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + <PreprocessorDefinitions>%(PreprocessorDefinitions)</PreprocessorDefinitions> + <AdditionalIncludeDirectories> + ./../; + ..\LightSwitchLib; + ..\..\..\common; + $(RepoRoot)src\common\logger; + $(RepoRoot)src\common\utils; + $(RepoRoot)src\common\SettingsAPI; + $(RepoRoot)src\common\Telemetry; + $(RepoRoot)src\; + ..\..\..\..\deps\spdlog\include; + ./; + %(AdditionalIncludeDirectories) + </AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <AdditionalDependencies>Advapi32.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClCompile Include="LightSwitchService.cpp" /> + <ClCompile Include="LightSwitchSettings.cpp" /> + <ClCompile Include="LightSwitchStateManager.cpp" /> + <ClCompile Include="NightLightRegistryObserver.cpp" /> + <ClCompile Include="SettingsConstants.cpp" /> + <ClCompile Include="ThemeScheduler.cpp" /> + <ClCompile Include="trace.cpp" /> + <ClCompile Include="WinHookEventIDs.cpp" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="LightSwitchService.rc" /> + </ItemGroup> + <ItemGroup> + <ClInclude Include="LightSwitchSettings.h" /> + <ClInclude Include="LightSwitchStateManager.h" /> + <ClInclude Include="LightSwitchUtils.h" /> + <ClInclude Include="NightLightRegistryObserver.h" /> + <ClInclude Include="SettingsConstants.h" /> + <ClInclude Include="SettingsObserver.h" /> + <ClInclude Include="ThemeScheduler.h" /> + <ClInclude Include="trace.h" /> + <ClInclude Include="WinHookEventIDs.h" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="$(RepoRoot)src\common\ManagedCommon\ManagedCommon.csproj"> + <Project>{4aed67b6-55fd-486f-b917-e543dee2cb3c}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\notifications\notifications.vcxproj"> + <Project>{1d5be09d-78c0-4fd7-af00-ae7c1af7c525}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> + </ProjectReference> + <ProjectReference Include="..\LightSwitchLib\LightSwitchLib.vcxproj"> + <Project>{79267138-2895-4346-9021-21408d65379f}</Project> + </ProjectReference> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> +</Project> diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters new file mode 100644 index 0000000000..a704e87073 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj.filters @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <ClCompile Include="LightSwitchService.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="ThemeScheduler.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="ThemeHelper.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="LightSwitchSettings.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="SettingsConstants.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="WinHookEventIDs.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="LightSwitchStateManager.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="NightLightRegistryObserver.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="trace.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <ClInclude Include="ThemeScheduler.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="ThemeHelper.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="LightSwitchSettings.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="SettingsConstants.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="SettingsObserver.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="WinHookEventIDs.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="LightSwitchStateManager.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="LightSwitchUtils.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="NightLightRegistryObserver.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="trace.h"> + <Filter>Header Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="LightSwitchService.rc"> + <Filter>Resource Files</Filter> + </ResourceCompile> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp new file mode 100644 index 0000000000..15e9f7c915 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.cpp @@ -0,0 +1,301 @@ +#include "LightSwitchSettings.h" +#include <common/utils/json.h> +#include <common/SettingsAPI/settings_helpers.h> +#include "SettingsObserver.h" +#include <filesystem> +#include <fstream> +#include <logger.h> +#include <LightSwitchService/trace.h> + +using namespace std; + +LightSwitchSettings& LightSwitchSettings::instance() +{ + static LightSwitchSettings inst; + return inst; +} + +LightSwitchSettings::LightSwitchSettings() +{ + LoadSettings(); +} + +std::wstring LightSwitchSettings::GetSettingsFileName() +{ + return PTSettingsHelper::get_module_save_file_location(L"LightSwitch"); +} + +void LightSwitchSettings::InitFileWatcher() +{ + if (!m_settingsChangedEvent) + { + m_settingsChangedEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + } + + if (!m_settingsFileWatcher) + { + m_settingsFileWatcher = std::make_unique<FileWatcher>( + GetSettingsFileName(), + [this]() { + using namespace std::chrono; + + { + std::lock_guard<std::mutex> lock(m_debounceMutex); + m_lastChangeTime = steady_clock::now(); + if (m_debouncePending) + return; + m_debouncePending = true; + } + + m_debounceThread = std::jthread([this](std::stop_token stop) { + using namespace std::chrono; + while (!stop.stop_requested()) + { + std::this_thread::sleep_for(seconds(3)); + + auto elapsed = steady_clock::now() - m_lastChangeTime; + if (elapsed >= seconds(1)) + break; + } + + { + std::lock_guard<std::mutex> lock(m_debounceMutex); + m_debouncePending = false; + } + + Logger::info(L"[LightSwitchSettings] Settings file stabilized, reloading."); + + try + { + LoadSettings(); + SetEvent(m_settingsChangedEvent); + } + catch (const std::exception& e) + { + std::wstring wmsg; + wmsg.assign(e.what(), e.what() + strlen(e.what())); + Logger::error(L"[LightSwitchSettings] Exception during debounced reload: {}", wmsg); + } + }); + }); + } +} + +LightSwitchSettings::~LightSwitchSettings() +{ + Logger::info(L"[LightSwitchSettings] Cleaning up settings resources..."); + + // Stop and join the debounce thread (std::jthread auto-joins, but we can signal stop too) + if (m_debounceThread.joinable()) + { + m_debounceThread.request_stop(); + } + + // Release the file watcher so it closes file handles and background threads + if (m_settingsFileWatcher) + { + m_settingsFileWatcher.reset(); + Logger::info(L"[LightSwitchSettings] File watcher stopped."); + } + + // Close the Windows event handle + if (m_settingsChangedEvent) + { + CloseHandle(m_settingsChangedEvent); + m_settingsChangedEvent = nullptr; + Logger::info(L"[LightSwitchSettings] Settings changed event closed."); + } + + Logger::info(L"[LightSwitchSettings] Cleanup complete."); +} + + +void LightSwitchSettings::AddObserver(SettingsObserver& observer) +{ + m_observers.insert(&observer); +} + +void LightSwitchSettings::RemoveObserver(SettingsObserver& observer) +{ + m_observers.erase(&observer); +} + +void LightSwitchSettings::NotifyObservers(SettingId id) const +{ + for (auto observer : m_observers) + { + if (observer->WantsToBeNotified(id)) + { + observer->SettingsUpdate(id); + } + } +} + +HANDLE LightSwitchSettings::GetSettingsChangedEvent() const +{ + return m_settingsChangedEvent; +} + +void LightSwitchSettings::LoadSettings() +{ + std::lock_guard<std::mutex> guard(m_settingsMutex); + try + { + PowerToysSettings::PowerToyValues values = + PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); + + + if (const auto jsonVal = values.get_string_value(L"scheduleMode")) + { + auto val = *jsonVal; + auto newMode = FromString(val); + if (m_settings.scheduleMode != newMode) + { + m_settings.scheduleMode = newMode; + Trace::LightSwitch::ScheduleModeToggled(val); + NotifyObservers(SettingId::ScheduleMode); + } + } + + // Latitude + if (const auto jsonVal = values.get_string_value(L"latitude")) + { + auto val = *jsonVal; + if (m_settings.latitude != val) + { + m_settings.latitude = val; + NotifyObservers(SettingId::Latitude); + } + } + + // Longitude + if (const auto jsonVal = values.get_string_value(L"longitude")) + { + auto val = *jsonVal; + if (m_settings.longitude != val) + { + m_settings.longitude = val; + NotifyObservers(SettingId::Longitude); + } + } + + // LightTime + if (const auto jsonVal = values.get_int_value(L"lightTime")) + { + auto val = *jsonVal; + if (m_settings.lightTime != val) + { + m_settings.lightTime = val; + NotifyObservers(SettingId::LightTime); + } + } + + // DarkTime + if (const auto jsonVal = values.get_int_value(L"darkTime")) + { + auto val = *jsonVal; + if (m_settings.darkTime != val) + { + m_settings.darkTime = val; + NotifyObservers(SettingId::DarkTime); + } + } + + // Offset + if (const auto jsonVal = values.get_int_value(L"sunrise_offset")) + { + auto val = *jsonVal; + if (m_settings.sunrise_offset != val) + { + m_settings.sunrise_offset = val; + NotifyObservers(SettingId::Sunrise_Offset); + } + } + + if (const auto jsonVal = values.get_int_value(L"sunset_offset")) + { + auto val = *jsonVal; + if (m_settings.sunset_offset != val) + { + m_settings.sunset_offset = val; + NotifyObservers(SettingId::Sunset_Offset); + } + } + + bool themeTargetChanged = false; + + // ChangeSystem + if (const auto jsonVal = values.get_bool_value(L"changeSystem")) + { + auto val = *jsonVal; + if (m_settings.changeSystem != val) + { + m_settings.changeSystem = val; + themeTargetChanged = true; + NotifyObservers(SettingId::ChangeSystem); + } + } + + // ChangeApps + if (const auto jsonVal = values.get_bool_value(L"changeApps")) + { + auto val = *jsonVal; + if (m_settings.changeApps != val) + { + m_settings.changeApps = val; + themeTargetChanged = true; + NotifyObservers(SettingId::ChangeApps); + } + } + + // EnableDarkModeProfile + if (const auto jsonVal = values.get_bool_value(L"enableDarkModeProfile")) + { + auto val = *jsonVal; + if (m_settings.enableDarkModeProfile != val) + { + m_settings.enableDarkModeProfile = val; + } + } + + // EnableLightModeProfile + if (const auto jsonVal = values.get_bool_value(L"enableLightModeProfile")) + { + auto val = *jsonVal; + if (m_settings.enableLightModeProfile != val) + { + m_settings.enableLightModeProfile = val; + } + } + + // DarkModeProfile + if (const auto jsonVal = values.get_string_value(L"darkModeProfile")) + { + auto val = *jsonVal; + if (m_settings.darkModeProfile != val) + { + m_settings.darkModeProfile = val; + } + } + + // LightModeProfile + if (const auto jsonVal = values.get_string_value(L"lightModeProfile")) + { + auto val = *jsonVal; + if (m_settings.lightModeProfile != val) + { + m_settings.lightModeProfile = val; + } + } + + // For ChangeSystem/ChangeApps changes, log telemetry + if (themeTargetChanged) + { + Trace::LightSwitch::ThemeTargetChanged(m_settings.changeApps, m_settings.changeSystem); + } + } + catch (...) + { + // Keeps defaults if load fails + } +} diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h new file mode 100644 index 0000000000..4fd9777c5e --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchSettings.h @@ -0,0 +1,115 @@ +#pragma once + +#include <unordered_set> +#include <string> +#include <vector> +#include <memory> +#include <windows.h> +#include <mutex> +#include <atomic> +#include <thread> +#include <chrono> +#include <common/SettingsAPI/FileWatcher.h> +#include <common/SettingsAPI/settings_objects.h> +#include <SettingsConstants.h> + +class SettingsObserver; + +enum class ScheduleMode +{ + Off, + FixedHours, + SunsetToSunrise, + FollowNightLight, + // Add more in the future +}; + +inline std::wstring ToString(ScheduleMode mode) +{ + switch (mode) + { + case ScheduleMode::FixedHours: + return L"FixedHours"; + case ScheduleMode::SunsetToSunrise: + return L"SunsetToSunrise"; + case ScheduleMode::FollowNightLight: + return L"FollowNightLight"; + default: + return L"Off"; + } +} + +inline ScheduleMode FromString(const std::wstring& str) +{ + if (str == L"SunsetToSunrise") + return ScheduleMode::SunsetToSunrise; + if (str == L"FixedHours") + return ScheduleMode::FixedHours; + if (str == L"FollowNightLight") + return ScheduleMode::FollowNightLight; + else + return ScheduleMode::Off; +} + +struct LightSwitchConfig +{ + ScheduleMode scheduleMode = ScheduleMode::FixedHours; + + std::wstring latitude = L"0.0"; + std::wstring longitude = L"0.0"; + + // Stored as minutes since midnight + int lightTime = 8 * 60; // 08:00 default + int darkTime = 20 * 60; // 20:00 default + + int sunrise_offset = 0; + int sunset_offset = 0; + + bool changeSystem = false; + bool changeApps = false; + + bool enableDarkModeProfile = false; + bool enableLightModeProfile = false; + std::wstring darkModeProfile = L""; + std::wstring lightModeProfile = L""; +}; + +class LightSwitchSettings +{ +public: + static LightSwitchSettings& instance(); + + static inline const LightSwitchConfig& settings() + { + return instance().m_settings; + } + + void InitFileWatcher(); + static std::wstring GetSettingsFileName(); + + void AddObserver(SettingsObserver& observer); + void RemoveObserver(SettingsObserver& observer); + + void LoadSettings(); + + HANDLE GetSettingsChangedEvent() const; + +private: + LightSwitchSettings(); + ~LightSwitchSettings(); + + LightSwitchConfig m_settings; + std::unique_ptr<FileWatcher> m_settingsFileWatcher; + std::unordered_set<SettingsObserver*> m_observers; + + void NotifyObservers(SettingId id) const; + + HANDLE m_settingsChangedEvent = nullptr; + mutable std::mutex m_settingsMutex; + + // Debounce state + std::atomic_bool m_debouncePending{ false }; + std::mutex m_debounceMutex; + std::chrono::steady_clock::time_point m_lastChangeTime{}; + std::jthread m_debounceThread; +}; diff --git a/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp new file mode 100644 index 0000000000..28bcca6512 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.cpp @@ -0,0 +1,333 @@ +#include "pch.h" +#include "LightSwitchStateManager.h" +#include <logger.h> +#include <LightSwitchUtils.h> +#include "ThemeScheduler.h" +#include <ThemeHelper.h> +#include <common/interop/shared_constants.h> + +void ApplyTheme(bool shouldBeLight); + +// Constructor +LightSwitchStateManager::LightSwitchStateManager() +{ + Logger::info(L"[LightSwitchStateManager] Initialized"); +} + +// Called when settings.json changes +void LightSwitchStateManager::OnSettingsChanged() +{ + std::lock_guard<std::mutex> lock(_stateMutex); + + // If manual override was active, clear it so new settings take effect + if (_state.isManualOverride) + { + _state.isManualOverride = false; + } + + EvaluateAndApplyIfNeeded(); +} + +// Called once per minute +void LightSwitchStateManager::OnTick() +{ + std::lock_guard<std::mutex> lock(_stateMutex); + if (_state.lastAppliedMode != ScheduleMode::FollowNightLight) + { + EvaluateAndApplyIfNeeded(); + } +} + +// Called when manual override is triggered (via hotkey) +void LightSwitchStateManager::OnManualOverride() +{ + std::lock_guard<std::mutex> lock(_stateMutex); + Logger::info(L"[LightSwitchStateManager] Manual override triggered"); + _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(); +} + +// Runs with the registry observer detects a change in Night Light settings. +void LightSwitchStateManager::OnNightLightChange() +{ + std::lock_guard<std::mutex> lock(_stateMutex); + + bool newNightLightState = IsNightLightEnabled(); + + // In Follow Night Light mode, treat a Night Light toggle as a boundary + if (_state.lastAppliedMode == ScheduleMode::FollowNightLight && _state.isManualOverride) + { + Logger::info(L"[LightSwitchStateManager] Night Light changed while manual override active; " + L"treating as a boundary and clearing manual override."); + _state.isManualOverride = false; + } + + if (newNightLightState != _state.isNightLightActive) + { + Logger::info(L"[LightSwitchStateManager] Night Light toggled to {}", + newNightLightState ? L"ON" : L"OFF"); + + _state.isNightLightActive = newNightLightState; + } + else + { + Logger::debug(L"[LightSwitchStateManager] Night Light change event fired, but no actual change."); + } + + EvaluateAndApplyIfNeeded(); +} + +// Helpers +bool LightSwitchStateManager::CoordinatesAreValid(const std::wstring& lat, const std::wstring& lon) +{ + try + { + double latVal = std::stod(lat); + double lonVal = std::stod(lon); + return !(latVal == 0 && lonVal == 0) && (latVal >= -90.0 && latVal <= 90.0) && (lonVal >= -180.0 && lonVal <= 180.0); + } + catch (...) + { + return false; + } +} + +void LightSwitchStateManager::SyncInitialThemeState() +{ + std::lock_guard<std::mutex> 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<int, int> update_sun_times(auto& settings) +{ + double latitude = std::stod(settings.latitude); + double longitude = std::stod(settings.longitude); + + SYSTEMTIME st; + GetLocalTime(&st); + + SunTimes newTimes = CalculateSunriseSunset(latitude, longitude, st.wYear, st.wMonth, st.wDay); + + int newLightTime = newTimes.sunriseHour * 60 + newTimes.sunriseMinute; + int newDarkTime = newTimes.sunsetHour * 60 + newTimes.sunsetMinute; + + try + { + auto values = PowerToysSettings::PowerToyValues::load_from_settings_file(L"LightSwitch"); + values.add_property(L"lightTime", newLightTime); + values.add_property(L"darkTime", newDarkTime); + values.save_to_settings_file(); + + Logger::info(L"[LightSwitchService] Updated sun times and saved to config."); + } + catch (const std::exception& e) + { + std::string msg = e.what(); + std::wstring wmsg(msg.begin(), msg.end()); + Logger::error(L"[LightSwitchService] Exception during sun time update: {}", wmsg); + } + + return { newLightTime, newDarkTime }; +} + +// Internal: decide what should happen now +void LightSwitchStateManager::EvaluateAndApplyIfNeeded() +{ + LightSwitchSettings::instance().LoadSettings(); + const auto& _currentSettings = LightSwitchSettings::settings(); + auto now = GetNowMinutes(); + + // Early exit: OFF mode just pauses activity + if (_currentSettings.scheduleMode == ScheduleMode::Off) + { + _state.lastTickMinutes = now; + return; + } + + bool coordsValid = CoordinatesAreValid(_currentSettings.latitude, _currentSettings.longitude); + + // Handle Sun Mode recalculation + if (_currentSettings.scheduleMode == ScheduleMode::SunsetToSunrise && coordsValid) + { + SYSTEMTIME st; + GetLocalTime(&st); + bool newDay = (_state.lastEvaluatedDay != st.wDay); + bool modeChangedToSun = (_state.lastAppliedMode != ScheduleMode::SunsetToSunrise && + _currentSettings.scheduleMode == ScheduleMode::SunsetToSunrise); + + if (newDay || modeChangedToSun) + { + auto [newLightTime, newDarkTime] = update_sun_times(_currentSettings); + _state.lastEvaluatedDay = st.wDay; + _state.effectiveLightMinutes = newLightTime + _currentSettings.sunrise_offset; + _state.effectiveDarkMinutes = newDarkTime + _currentSettings.sunset_offset; + } + else + { + _state.effectiveLightMinutes = _currentSettings.lightTime + _currentSettings.sunrise_offset; + _state.effectiveDarkMinutes = _currentSettings.darkTime + _currentSettings.sunset_offset; + } + } + else if (_currentSettings.scheduleMode == ScheduleMode::FixedHours) + { + _state.effectiveLightMinutes = _currentSettings.lightTime; + _state.effectiveDarkMinutes = _currentSettings.darkTime; + } + + // Handle manual override logic + if (_state.isManualOverride) + { + bool crossedBoundary = false; + if (_state.lastTickMinutes != -1) + { + int prev = _state.lastTickMinutes; + + // Handle midnight wraparound safely + if (now < prev) + { + crossedBoundary = + (prev <= _state.effectiveLightMinutes || now >= _state.effectiveLightMinutes) || + (prev <= _state.effectiveDarkMinutes || now >= _state.effectiveDarkMinutes); + } + else + { + crossedBoundary = + (prev < _state.effectiveLightMinutes && now >= _state.effectiveLightMinutes) || + (prev < _state.effectiveDarkMinutes && now >= _state.effectiveDarkMinutes); + } + } + + if (crossedBoundary) + { + _state.isManualOverride = false; + } + else + { + _state.lastTickMinutes = now; + return; + } + } + + _state.lastAppliedMode = _currentSettings.scheduleMode; + + bool shouldBeLight = false; + if (_currentSettings.scheduleMode == ScheduleMode::FollowNightLight) + { + shouldBeLight = !_state.isNightLightActive; + } + else + { + shouldBeLight = ShouldBeLight(now, _state.effectiveLightMinutes, _state.effectiveDarkMinutes); + } + + bool appsNeedsToChange = _currentSettings.changeApps && (_state.isAppsLightActive != shouldBeLight); + bool systemNeedsToChange = _currentSettings.changeSystem && (_state.isSystemLightActive != shouldBeLight); + + /* Logger::debug( + L"[LightSwitchStateManager] now = {:02d}:{:02d}, light boundary = {:02d}:{:02d} ({}), dark boundary = {:02d}:{:02d} ({})", + now / 60, + now % 60, + _state.effectiveLightMinutes / 60, + _state.effectiveLightMinutes % 60, + _state.effectiveLightMinutes, + _state.effectiveDarkMinutes / 60, + _state.effectiveDarkMinutes % 60, + _state.effectiveDarkMinutes); */ + + /* Logger::debug("should be light = {}, apps needs change = {}, system needs change = {}", + shouldBeLight ? "true" : "false", + appsNeedsToChange ? "true" : "false", + systemNeedsToChange ? "true" : "false"); */ + + // Only apply theme if there's a change or no override active + if (!_state.isManualOverride && (appsNeedsToChange || systemNeedsToChange)) + { + Logger::info(L"[LightSwitchStateManager] Applying {} theme", shouldBeLight ? L"light" : L"dark"); + ApplyTheme(shouldBeLight); + + _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 new file mode 100644 index 0000000000..b6c001fc64 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchStateManager.h @@ -0,0 +1,54 @@ +#pragma once +#include "LightSwitchSettings.h" +#include <optional> + +// Represents runtime-only information (not saved in settings.json) +struct LightSwitchState +{ + ScheduleMode lastAppliedMode = ScheduleMode::Off; + bool isManualOverride = false; + bool isSystemLightActive = false; + bool isAppsLightActive = false; + bool isNightLightActive = false; + int lastEvaluatedDay = -1; + int lastTickMinutes = -1; + + // Derived, runtime-resolved times + int effectiveLightMinutes = 0; // the boundary we actually act on + int effectiveDarkMinutes = 0; // includes offsets if needed +}; + +// The controller that reacts to settings changes, time ticks, and manual overrides. +class LightSwitchStateManager +{ +public: + LightSwitchStateManager(); + + // Called when settings.json changes or stabilizes. + void OnSettingsChanged(); + + // Called every minute (from service worker tick). + void OnTick(); + + // Called when manual override is toggled (via shortcut or system change). + void OnManualOverride(); + + // Called when night light changes in windows settings + void OnNightLightChange(); + + // Initial sync at startup to align internal state with system theme + void SyncInitialThemeState(); + + // Accessor for current state (optional, for debugging or telemetry) + const LightSwitchState& GetState() const { return _state; } + +private: + LightSwitchState _state; + std::mutex _stateMutex; + + 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/LightSwitch/LightSwitchService/LightSwitchUtils.h b/src/modules/LightSwitch/LightSwitchService/LightSwitchUtils.h new file mode 100644 index 0000000000..0f4462bb65 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/LightSwitchUtils.h @@ -0,0 +1,24 @@ +#pragma once +#include <windows.h> + +constexpr bool ShouldBeLight(int nowMinutes, int lightTime, int darkTime) +{ + // Normalize values into [0, 1439] + int normalizedLightTime = (lightTime % 1440 + 1440) % 1440; + int normalizedDarkTime = (darkTime % 1440 + 1440) % 1440; + int normalizedNowMinutes = (nowMinutes % 1440 + 1440) % 1440; + + // Case 1: Normal range, e.g. light mode comes before dark mode in the same day + if (normalizedLightTime < normalizedDarkTime) + return normalizedNowMinutes >= normalizedLightTime && normalizedNowMinutes < normalizedDarkTime; + + // Case 2: Wrap-around range, e.g. light mode starts in the evening and dark mode starts in the morning + return normalizedNowMinutes >= normalizedLightTime || normalizedNowMinutes < normalizedDarkTime; +} + +inline int GetNowMinutes() +{ + SYSTEMTIME st; + GetLocalTime(&st); + return st.wHour * 60 + st.wMinute; +} diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp new file mode 100644 index 0000000000..8da19c6595 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.cpp @@ -0,0 +1 @@ +#include "NightLightRegistryObserver.h" diff --git a/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h new file mode 100644 index 0000000000..2806c28316 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/NightLightRegistryObserver.h @@ -0,0 +1,134 @@ +#pragma once +#include <wtypes.h> +#include <string> +#include <functional> +#include <thread> +#include <atomic> +#include <mutex> + +class NightLightRegistryObserver +{ +public: + NightLightRegistryObserver(HKEY root, const std::wstring& subkey, std::function<void()> callback) : + _root(root), _subkey(subkey), _callback(std::move(callback)), _stop(false) + { + _thread = std::thread([this]() { this->Run(); }); + } + + ~NightLightRegistryObserver() + { + Stop(); + } + + void Stop() + { + _stop = true; + + { + std::lock_guard<std::mutex> lock(_mutex); + if (_event) + SetEvent(_event); + } + + if (_thread.joinable()) + _thread.join(); + + std::lock_guard<std::mutex> lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + + +private: + void Run() + { + { + std::lock_guard<std::mutex> lock(_mutex); + if (RegOpenKeyExW(_root, _subkey.c_str(), 0, KEY_NOTIFY, &_hKey) != ERROR_SUCCESS) + return; + + _event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!_event) + { + RegCloseKey(_hKey); + _hKey = nullptr; + return; + } + } + + while (!_stop) + { + HKEY hKeyLocal = nullptr; + HANDLE eventLocal = nullptr; + + { + std::lock_guard<std::mutex> lock(_mutex); + if (_stop) + break; + + hKeyLocal = _hKey; + eventLocal = _event; + } + + if (!hKeyLocal || !eventLocal) + break; + + if (_stop) + break; + + if (RegNotifyChangeKeyValue(hKeyLocal, FALSE, REG_NOTIFY_CHANGE_LAST_SET, eventLocal, TRUE) != ERROR_SUCCESS) + break; + + DWORD wait = WaitForSingleObject(eventLocal, INFINITE); + if (_stop || wait == WAIT_FAILED) + break; + + ResetEvent(eventLocal); + + if (!_stop && _callback) + { + try + { + _callback(); + } + catch (...) + { + } + } + } + + { + std::lock_guard<std::mutex> lock(_mutex); + if (_hKey) + { + RegCloseKey(_hKey); + _hKey = nullptr; + } + + if (_event) + { + CloseHandle(_event); + _event = nullptr; + } + } + } + + + HKEY _root; + std::wstring _subkey; + std::function<void()> _callback; + HANDLE _event = nullptr; + HKEY _hKey = nullptr; + std::thread _thread; + std::atomic<bool> _stop; + std::mutex _mutex; +}; \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.cpp b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.cpp new file mode 100644 index 0000000000..534e55f5e3 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.cpp @@ -0,0 +1 @@ +#include "SettingsConstants.h" diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h new file mode 100644 index 0000000000..1ec1f36340 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsConstants.h @@ -0,0 +1,14 @@ +#pragma once + +enum class SettingId +{ + ScheduleMode = 0, + Latitude, + Longitude, + LightTime, + DarkTime, + Sunrise_Offset, + Sunset_Offset, + ChangeSystem, + ChangeApps +}; diff --git a/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h b/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h new file mode 100644 index 0000000000..b0ddde72ec --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/SettingsObserver.h @@ -0,0 +1,33 @@ +#pragma once + +#include <unordered_set> +#include "SettingsConstants.h" +#include "LightSwitchSettings.h" + +class LightSwitchSettings; + +class SettingsObserver +{ +public: + SettingsObserver(std::unordered_set<SettingId> observedSettings) : + m_observedSettings(std::move(observedSettings)) + { + LightSwitchSettings::instance().AddObserver(*this); + } + + virtual ~SettingsObserver() + { + LightSwitchSettings::instance().RemoveObserver(*this); + } + + // Override this in your class to respond to updates + virtual void SettingsUpdate(SettingId type) {} + + virtual bool WantsToBeNotified(SettingId type) const noexcept + { + return m_observedSettings.contains(type); + } + +protected: + std::unordered_set<SettingId> m_observedSettings; +}; diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.cpp b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.cpp new file mode 100644 index 0000000000..7b07dd0ef7 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.cpp @@ -0,0 +1,89 @@ +#include "ThemeScheduler.h" +#include <utility> + +SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day) +{ + double zenith = 90.833; + int N1 = static_cast<int>(floor(275.0 * month / 9.0)); + int N2 = static_cast<int>(floor((static_cast<double>(month) + 9) / 12.0)); + int N3 = static_cast<int>(floor((1.0 + floor((year - 4.0 * floor(year / 4.0) + 2.0) / 3.0)))); + int N = N1 - (N2 * N3) + day - 30; + + auto calcTime = [&](bool sunrise) -> double { + double lngHour = longitude / 15.0; + double t = sunrise ? N + ((6 - lngHour) / 24) : N + ((18 - lngHour) / 24); + + double M = (0.9856 * t) - 3.289; + double L = M + (1.916 * sin(deg2rad(M))) + (0.020 * sin(2 * deg2rad(M))) + 282.634; + if (L < 0) + L += 360; + if (L > 360) + L -= 360; + + double RA = rad2deg(atan(0.91764 * tan(deg2rad(L)))); + if (RA < 0) + RA += 360; + if (RA > 360) + RA -= 360; + + double Lquadrant = floor(L / 90) * 90; + double RAquadrant = floor(RA / 90) * 90; + RA = RA + (Lquadrant - RAquadrant); + RA /= 15; + + double sinDec = 0.39782 * sin(deg2rad(L)); + double cosDec = cos(asin(sinDec)); + + double cosH = (cos(deg2rad(zenith)) - (sinDec * sin(deg2rad(latitude)))) / (cosDec * cos(deg2rad(latitude))); + if (cosH > 1 || cosH < -1) + return -1; + + double H = sunrise ? 360 - rad2deg(acos(cosH)) : rad2deg(acos(cosH)); + H /= 15; + + double T = H + RA - (0.06571 * t) - 6.622; + double UT = T - lngHour; + while (UT < 0) + UT += 24; + while (UT >= 24) + UT -= 24; + + return UT; + }; + + double riseUT = calcTime(true); + double setUT = calcTime(false); + + auto toLocal = [](double UT) { + TIME_ZONE_INFORMATION tz; + DWORD state = GetTimeZoneInformation(&tz); + double totalBias = tz.Bias; + + if (state == TIME_ZONE_ID_DAYLIGHT) + totalBias += tz.DaylightBias; + else if (state == TIME_ZONE_ID_STANDARD) + totalBias += tz.StandardBias; + + double biasHours = -(totalBias / 60.0); + double localTime = UT + biasHours; + + while (localTime < 0) + localTime += 24; + while (localTime >= 24) + localTime -= 24; + + int hour = static_cast<int>(localTime); + int minute = static_cast<int>((localTime - hour) * 60); + return std::pair<int, int>{ hour, minute }; + }; + + auto [riseHour, riseMinute] = toLocal(riseUT); + auto [setHour, setMinute] = toLocal(setUT); + + SunTimes result; + result.sunriseHour = riseHour; + result.sunriseMinute = riseMinute; + result.sunsetHour = setHour; + result.sunsetMinute = setMinute; + return result; +} diff --git a/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.h b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.h new file mode 100644 index 0000000000..4e6869830a --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/ThemeScheduler.h @@ -0,0 +1,25 @@ +#pragma once +#include <cmath> +#include <ctime> +#include <windows.h> + +// Struct to hold calculated sunrise/sunset times +struct SunTimes +{ + int sunriseHour; + int sunriseMinute; + int sunsetHour; + int sunsetMinute; +}; + +constexpr double PI = 3.14159265358979323846; +constexpr double deg2rad(double deg) +{ + return deg * PI / 180.0; +} +constexpr double rad2deg(double rad) +{ + return rad * 180.0 / PI; +} + +SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day); diff --git a/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.cpp b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.cpp new file mode 100644 index 0000000000..5e271fc8d0 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.cpp @@ -0,0 +1,15 @@ + +#include "WinHookEventIDs.h" +#include <wtypes.h> +#include <mutex> + +UINT WM_PRIV_SETTINGS_CHANGED = 0; + +std::once_flag init_flag; + +void InitializeWinhookEventIds() +{ + std::call_once(init_flag, [&] { + WM_PRIV_SETTINGS_CHANGED = RegisterWindowMessage(L"{11978F7B-221A-4E65-B9A9-693F7D6E4B25}"); + }); +} diff --git a/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.h b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.h new file mode 100644 index 0000000000..177fd139cd --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/WinHookEventIDs.h @@ -0,0 +1,6 @@ +#pragma once +#include <Windows.h> + +extern UINT WM_PRIV_SETTINGS_CHANGED; // Scheduled when a watched settings file is updated + +void InitializeWinhookEventIds(); \ No newline at end of file diff --git a/src/runner/packages.config b/src/modules/LightSwitch/LightSwitchService/packages.config similarity index 75% rename from src/runner/packages.config rename to src/modules/LightSwitch/LightSwitchService/packages.config index ff4b059648..d3882436a5 100644 --- a/src/runner/packages.config +++ b/src/modules/LightSwitch/LightSwitchService/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/resource.h b/src/modules/LightSwitch/LightSwitchService/resource.h new file mode 100644 index 0000000000..e8ed3b4747 --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by LightSwitchService.rc +// +#define IDI_ICON1 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/src/modules/LightSwitch/LightSwitchService/trace.cpp b/src/modules/LightSwitch/LightSwitchService/trace.cpp new file mode 100644 index 0000000000..99afe7a95d --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/trace.cpp @@ -0,0 +1,43 @@ +#include "pch.h" +#include "trace.h" + +// Telemetry strings should not be localized. +#define LoggingProviderKey "Microsoft.PowerToys" + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + LoggingProviderKey, + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::LightSwitch::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::LightSwitch::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::LightSwitch::ScheduleModeToggled(const std::wstring& newMode) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "LightSwitch_ScheduleModeToggled", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingWideString(newMode.c_str(), "NewMode")); +} + +void Trace::LightSwitch::ThemeTargetChanged(bool changeApps, bool changeSystem) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "LightSwitch_ThemeTargetChanged", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(changeApps, "ChangeApps"), + TraceLoggingBoolean(changeSystem, "ChangeSystem")); +} \ No newline at end of file diff --git a/src/modules/LightSwitch/LightSwitchService/trace.h b/src/modules/LightSwitch/LightSwitchService/trace.h new file mode 100644 index 0000000000..a06177075b --- /dev/null +++ b/src/modules/LightSwitch/LightSwitchService/trace.h @@ -0,0 +1,17 @@ +#pragma once + +#include <common/Telemetry/TraceBase.h> +#include <string> + +class Trace +{ +public: + class LightSwitch : public telemetry::TraceBase + { + public: + static void RegisterProvider(); + static void UnregisterProvider(); + static void ScheduleModeToggled(const std::wstring& newMode) noexcept; + static void ThemeTargetChanged(bool changeApps, bool changeSystem) noexcept; + }; +}; diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/LockScreenLogo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/LockScreenLogo.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/LockScreenLogo.scale-200.png rename to src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/LockScreenLogo.scale-200.png diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/SplashScreen.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/SplashScreen.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/SplashScreen.scale-200.png rename to src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/SplashScreen.scale-200.png diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/Square150x150Logo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square150x150Logo.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/Square150x150Logo.scale-200.png rename to src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square150x150Logo.scale-200.png diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/Square44x44Logo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/Square44x44Logo.scale-200.png rename to src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.scale-200.png diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.targetsize-24_altform-unplated.png similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png rename to src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Square44x44Logo.targetsize-24_altform-unplated.png diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/StoreLogo.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/StoreLogo.png similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/StoreLogo.png rename to src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/StoreLogo.png diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/Wide310x150Logo.scale-200.png b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Wide310x150Logo.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Assets/Wide310x150Logo.scale-200.png rename to src/modules/LightSwitch/Tests/LightSwitch.UITests/Assets/Wide310x150Logo.scale-200.png diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj b/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj new file mode 100644 index 0000000000..c230c1c958 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/LightSwitch.UITests.csproj @@ -0,0 +1,33 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <PropertyGroup> + <RootNamespace>PowerToys.LightSwitch.UITests</RootNamespace> + <AssemblyName>LightSwitch.UITests</AssemblyName> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + + <!-- This is a UI test, so don't run as part of MSBuild --> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + <PropertyGroup> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\LightSwitch.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <ProjectReference Include="..\..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj"> + <ReferenceOutputAssembly>false</ReferenceOutputAssembly> + </ProjectReference> + <ProjectReference Include="..\..\LightSwitchModuleInterface\LightSwitchModuleInterface.vcxproj"> + <ReferenceOutputAssembly>false</ReferenceOutputAssembly> + </ProjectReference> + </ItemGroup> + + <Target Name="CopyNativeDll" AfterTargets="Build"> + <Copy SourceFiles="$(SolutionDir)$(Platform)\$(Configuration)\PowerToys.LightSwitchModuleInterface.dll" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="Exists('$(SolutionDir)$(Platform)\$(Configuration)\PowerToys.LightSwitchModuleInterface.dll')" /> + <Copy SourceFiles="$(SolutionDir)$(Platform)\$(Configuration)\LightSwitchLib\LightSwitchLib.lib" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="Exists('$(SolutionDir)$(Platform)\$(Configuration)\LightSwitchLib\LightSwitchLib.lib')" ContinueOnError="true" /> + </Target> +</Project> \ No newline at end of file diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/Package.appxmanifest b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Package.appxmanifest new file mode 100644 index 0000000000..a38ad92615 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/Package.appxmanifest @@ -0,0 +1,51 @@ +<?xml version="1.0" encoding="utf-8"?> + +<Package + xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" + xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" + xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" + xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" + IgnorableNamespaces="uap rescap"> + + <Identity + Name="ad22010c-e33e-4459-848e-a1ed976bfd3b" + Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" + Version="1.0.0.0" /> + + <mp:PhoneIdentity PhoneProductId="ad22010c-e33e-4459-848e-a1ed976bfd3b" PhonePublisherId="00000000-0000-0000-0000-000000000000"/> + + <Properties> + <DisplayName>LightSwitch.UITests</DisplayName> + <PublisherDisplayName>Microsoft</PublisherDisplayName> + <Logo>Assets\StoreLogo.png</Logo> + </Properties> + + <Dependencies> + <TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" /> + <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" /> + </Dependencies> + + <Resources> + <Resource Language="x-generate"/> + </Resources> + + <Applications> + <Application Id="App" + Executable="$targetnametoken$.exe" + EntryPoint="$targetentrypoint$"> + <uap:VisualElements + DisplayName="LightSwitch.UITests" + Description="LightSwitch.UITests" + BackgroundColor="transparent" + Square150x150Logo="Assets\Square150x150Logo.png" + Square44x44Logo="Assets\Square44x44Logo.png"> + <uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" /> + <uap:SplashScreen Image="Assets\SplashScreen.png" /> + </uap:VisualElements> + </Application> + </Applications> + + <Capabilities> + <rescap:Capability Name="runFullTrust" /> + </Capabilities> +</Package> diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestGeolocation.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestGeolocation.cs new file mode 100644 index 0000000000..aaa5124995 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestGeolocation.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestGeolocation : UITestBase + { + public TestGeolocation() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.Geolocation")] + [TestCategory("Location")] + public void TestGeolocationUpdate() + { + TestHelper.InitializeTest(this, "geolocation test"); + TestHelper.PerformGeolocationTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs new file mode 100644 index 0000000000..d7748fc2f5 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestHelper.cs @@ -0,0 +1,502 @@ +// 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.Runtime.InteropServices; +using System.Threading.Tasks; +using System.Windows.Forms; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.Win32; + +namespace LightSwitch.UITests +{ + internal sealed class TestHelper + { + private static readonly string[] ShortcutSeparators = { " + ", "+", " " }; + + [DllImport("PowerToys.LightSwitchModuleInterface.dll", CallingConvention = CallingConvention.Cdecl)] + private static extern void LightSwitch_SetSystemTheme(bool isLight); + + [DllImport("PowerToys.LightSwitchModuleInterface.dll", CallingConvention = CallingConvention.Cdecl)] + private static extern void LightSwitch_SetAppsTheme(bool isLight); + + [DllImport("PowerToys.LightSwitchModuleInterface.dll", CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool LightSwitch_GetCurrentSystemTheme(); + + [DllImport("PowerToys.LightSwitchModuleInterface.dll", CallingConvention = CallingConvention.Cdecl)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool LightSwitch_GetCurrentAppsTheme(); + + /// <summary> + /// Performs common test initialization: navigate to settings, enable toggle, verify shortcut + /// </summary> + /// <param name="testBase">The test base instance</param> + /// <param name="testName">Name of the test for assertions</param> + /// <returns>The activation keys for the test</returns> + public static Key[] InitializeTest(UITestBase testBase, string testName) + { + LaunchFromSetting(testBase); + + var toggleSwitch = SetLightSwitchToggle(testBase, enable: true); + Assert.IsTrue( + toggleSwitch.IsOn, + $"Light Switch toggle switch should be ON for {testName}"); + + var activationKeys = ReadActivationShortcut(testBase); + Assert.IsNotNull(activationKeys, "Should be able to read activation shortcut"); + Assert.IsTrue(activationKeys.Length > 0, "Activation shortcut should contain at least one key"); + + return activationKeys; + } + + /// <summary> + /// Navigate to the Light Switch settings page + /// </summary> + public static void LaunchFromSetting(UITestBase testBase) + { + var lightSwitch = testBase.Session.FindAll<NavigationViewItem>(By.AccessibilityId("LightSwitchNavItem")); + + if (lightSwitch.Count == 0) + { + testBase.Session.Find<NavigationViewItem>(By.AccessibilityId("SystemToolsNavItem"), 5000).Click(msPostAction: 500); + } + + testBase.Session.Find<NavigationViewItem>(By.AccessibilityId("LightSwitchNavItem"), 5000).Click(msPostAction: 500); + } + + /// <summary> + /// Set the Light Switch enable toggle switch to the specified state + /// </summary> + public static ToggleSwitch SetLightSwitchToggle(UITestBase testBase, bool enable) + { + var toggleSwitch = testBase.Session.Find<ToggleSwitch>(By.AccessibilityId("Toggle_LightSwitch"), 5000); + + if (toggleSwitch.IsOn != enable) + { + toggleSwitch.Click(msPreAction: 1000, msPostAction: 2000); + } + + if (toggleSwitch.IsOn != enable) + { + testBase.Session.SendKey(Key.Space, msPreAction: 0, msPostAction: 2000); + } + + return toggleSwitch; + } + + /// <summary> + /// Read the current activation shortcut from the ShortcutControl + /// </summary> + public static Key[] ReadActivationShortcut(UITestBase testBase) + { + var shortcutCard = testBase.Session.Find<Element>(By.AccessibilityId("Shortcut_LightSwitch"), 5000); + var shortcutButton = shortcutCard.Find<Element>(By.AccessibilityId("EditButton"), 5000); + return ParseShortcutText(shortcutButton.HelpText); + } + + /// <summary> + /// Parse shortcut text like "Win + Ctrl + Shift + M" into Key array + /// </summary> + private static Key[] ParseShortcutText(string shortcutText) + { + if (string.IsNullOrEmpty(shortcutText)) + { + return new Key[] { Key.Win, Key.Ctrl, Key.Shift, Key.D }; + } + + var keys = new List<Key>(); + var parts = shortcutText.Split(ShortcutSeparators, StringSplitOptions.RemoveEmptyEntries); + + foreach (var part in parts) + { + var cleanPart = part.Trim().ToLowerInvariant(); + var key = cleanPart switch + { + "win" or "windows" => Key.Win, + "ctrl" or "control" => Key.Ctrl, + "shift" => Key.Shift, + "alt" => Key.Alt, + _ when cleanPart.Length == 1 && char.IsLetter(cleanPart[0]) && + cleanPart[0] >= 'a' && cleanPart[0] <= 'z' => + (Key)Enum.Parse(typeof(Key), cleanPart.ToUpperInvariant()), + _ => (Key?)null, + }; + + if (key.HasValue) + { + keys.Add(key.Value); + } + } + + return keys.Count > 0 ? keys.ToArray() : new Key[] { Key.Win, Key.Ctrl, Key.Shift, Key.D }; + } + + /// <summary> + /// Performs common test cleanup: close LightSwitch task + /// </summary> + /// <param name="testBase">The test base instance</param> + public static void CleanupTest(UITestBase testBase) + { + CloseLightSwitch(testBase); + + // Ensure we're attached to settings after cleanup + try + { + testBase.Session.Attach(PowerToysModule.PowerToysSettings); + } + catch + { + // Ignore attachment errors - this is just cleanup + } + } + + /// <summary> + /// Switch to white/light theme for both system and apps + /// </summary> + /// <param name="testBase">The test base instance</param> + public static void CloseLightSwitch(UITestBase testBase) + { + // Kill LightSwitch process before setting themes + KillLightSwitchProcess(); + + // Set both themes to light (white) + SetSystemTheme(true); + SetAppsTheme(true); + } + + /// <summary> + /// Kill the LightSwitch service process if it's running + /// </summary> + private static void KillLightSwitchProcess() + { + try + { + var processes = System.Diagnostics.Process.GetProcessesByName("PowerToys.LightSwitchService"); + foreach (var process in processes) + { + try + { + process.Kill(); + process.WaitForExit(2000); + } + catch + { + // Ignore errors killing individual processes + } + finally + { + process.Dispose(); + } + } + } + catch + { + // Ignore errors enumerating processes + } + } + + /// <summary> + /// Perform a update time test operation + /// </summary> + public static void PerformUpdateTimeTest(UITestBase testBase) + { + // Make sure in manual mode + var modeCombobox = testBase.Session.Find<Element>(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + var neededTabs = 6; + + if (modeCombobox.Text != "Fixed hours") + { + modeCombobox.Click(); + var manualListItem = testBase.Session.Find<Element>(By.AccessibilityId("ManualCBItem_LightSwitch"), 5000); + Assert.IsNotNull(manualListItem, "Fixed Hours combobox item not found."); + manualListItem.Click(); + neededTabs = 1; + } + + Assert.AreEqual("Fixed hours", modeCombobox.Text, "Mode combobox should be set to Fixed hours."); + + var timeline = testBase.Session.Find<Element>(By.AccessibilityId("Timeline_LightSwitch"), 5000); + Assert.IsNotNull(timeline, "Timeline not found."); + + var helpText = timeline.GetAttribute("HelpText"); + string originalEndValue = GetHelpTextValue(helpText, "End"); + + for (int i = 0; i < neededTabs; i++) + { + testBase.Session.SendKeys(Key.Tab); + } + + testBase.Session.SendKeys(Key.Enter); + testBase.Session.SendKeys(Key.Up); + testBase.Session.SendKeys(Key.Enter); + + helpText = timeline.GetAttribute("HelpText"); + string updatedEndValue = GetHelpTextValue(helpText, "End"); + + Assert.AreNotEqual(originalEndValue, updatedEndValue, "Timeline end time should have been updated."); + + helpText = timeline.GetAttribute("HelpText"); + string originalStartValue = GetHelpTextValue(helpText, "Start"); + + testBase.Session.SendKeys(Key.Tab); + testBase.Session.SendKeys(Key.Enter); + testBase.Session.SendKeys(Key.Up); + testBase.Session.SendKeys(Key.Enter); + + helpText = timeline.GetAttribute("HelpText"); + string updatedStartValue = GetHelpTextValue(helpText, "Start"); + + Assert.AreNotEqual(originalStartValue, updatedStartValue, "Timeline start time should have been updated."); + } + + /// <summary> + /// Perform a update manual location test operation + /// </summary> + public static void PerformUserSelectedLocationTest(UITestBase testBase) + { + // Make sure in sun time mode + var modeCombobox = testBase.Session.Find<Element>(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + if (modeCombobox.Text != "Sunset to sunrise") + { + modeCombobox.Click(); + var sunriseListItem = testBase.Session.Find<Element>(By.AccessibilityId("SunCBItem_LightSwitch"), 5000); + Assert.IsNotNull(sunriseListItem, "Sunrise combobox item not found."); + sunriseListItem.Click(); + } + + Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + + // Click the select location button + var setLocationButton = testBase.Session.Find<Element>(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(setLocationButton, "Set location button not found."); + setLocationButton.Click(msPostAction: 1000); + + var latitudeBox = testBase.Session.Find<Element>(By.AccessibilityId("LatitudeBox_LightSwitch"), 5000); + Assert.IsNotNull(latitudeBox, "Latitude text box not found."); + latitudeBox.Click(); + + testBase.Session.SendKeys(Key.Up); + + var longitudeBox = testBase.Session.Find<Element>(By.AccessibilityId("LongitudeBox_LightSwitch"), 5000); + Assert.IsNotNull(longitudeBox, "Longitude text box not found."); + longitudeBox.Click(); + + testBase.Session.SendKeys(Key.Down); + + var sunrise = testBase.Session.Find<Element>(By.AccessibilityId("SunriseText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunrise.Text)); + + var sunset = testBase.Session.Find<Element>(By.AccessibilityId("SunsetText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunset.Text)); + } + + /// <summary> + /// Perform a update geolocation test operation + /// </summary> + public static void PerformGeolocationTest(UITestBase testBase) + { + // Make sure in sun time mode + var modeCombobox = testBase.Session.Find<Element>(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + if (modeCombobox.Text != "Sunset to sunrise") + { + modeCombobox.Click(); + var sunriseListItem = testBase.Session.Find<Element>(By.AccessibilityId("SunCBItem_LightSwitch"), 5000); + Assert.IsNotNull(sunriseListItem, "Sunrise combobox item not found."); + sunriseListItem.Click(); + } + + Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + + // Click the select location button + var setLocationButton = testBase.Session.Find<Element>(By.AccessibilityId("SetLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(setLocationButton, "Set location button not found."); + setLocationButton.Click(msPostAction: 1000); + + var syncLocationButton = testBase.Session.Find<Element>(By.AccessibilityId("SyncLocationButton_LightSwitch"), 5000); + Assert.IsNotNull(syncLocationButton, "Sync location button not found."); + syncLocationButton.Click(msPostAction: 8000); + + var sunrise = testBase.Session.Find<Element>(By.AccessibilityId("SunriseText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunrise.Text)); + + var sunset = testBase.Session.Find<Element>(By.AccessibilityId("SunsetText_LightSwitch"), 5000); + Assert.IsFalse(string.IsNullOrWhiteSpace(sunset.Text)); + } + + /// <summary> + /// Perform a update time test operation + /// </summary> + public static void PerformOffsetTest(UITestBase testBase) + { + // Make sure in sun time mode + var modeCombobox = testBase.Session.Find<Element>(By.AccessibilityId("ModeSelection_LightSwitch"), 5000); + Assert.IsNotNull(modeCombobox, "Mode combobox not found."); + + if (modeCombobox.Text != "Sunset to sunrise") + { + modeCombobox.Click(); + var sunriseListItem = testBase.Session.Find<Element>(By.AccessibilityId("SunCBItem_LightSwitch"), 5000); + Assert.IsNotNull(sunriseListItem, "Sunrise combobox item not found."); + sunriseListItem.Click(); + } + + Assert.AreEqual("Sunset to sunrise", modeCombobox.Text, "Mode combobox should be set to Sunset to sunrise."); + + // Testing sunrise offset + var sunriseOffset = testBase.Session.Find<Element>(By.AccessibilityId("SunriseOffset_LightSwitch"), 5000); + Assert.IsNotNull(sunriseOffset, "Sunrise offset number box not found."); + + var timeline = testBase.Session.Find<Element>(By.AccessibilityId("Timeline_LightSwitch"), 5000); + Assert.IsNotNull(timeline, "Timeline not found."); + + var helpText = timeline.GetAttribute("HelpText"); + string originalStartValue = GetHelpTextValue(helpText, "Start"); + + sunriseOffset.Click(); + testBase.Session.SendKeys(Key.Up); + + helpText = timeline.GetAttribute("HelpText"); + string updatedStartValue = GetHelpTextValue(helpText, "Start"); + + Assert.AreNotEqual(originalStartValue, updatedStartValue, "Timeline start time should have been updated."); + + // Testing sunset offset + var sunsetOffset = testBase.Session.Find<Element>(By.AccessibilityId("SunsetOffset_LightSwitch"), 5000); + Assert.IsNotNull(sunsetOffset, "Sunrise offset number box not found."); + + helpText = timeline.GetAttribute("HelpText"); + string originalEndValue = GetHelpTextValue(helpText, "End"); + + sunsetOffset.Click(); + testBase.Session.SendKeys(Key.Up); + + helpText = timeline.GetAttribute("HelpText"); + string updatedEndValue = GetHelpTextValue(helpText, "End"); + + Assert.AreNotEqual(originalEndValue, updatedEndValue, "Timeline end time should have been updated."); + } + + /// <summary> + /// Perform a test for shortcut changing themes + /// </summary> + public static void PerformShortcutTest(UITestBase testBase, Key[] activationKeys) + { + // Test when both are checked + var systemCheckbox = testBase.Session.Find<Element>(By.AccessibilityId("ChangeSystemCheckbox_LightSwitch"), 5000); + Assert.IsNotNull(systemCheckbox, "System checkbox not found."); + + var scrollViewer = testBase.Session.Find<Element>(By.AccessibilityId("PageScrollViewer")); + systemCheckbox.EnsureVisible(scrollViewer); + + int neededTabs = 10; + + if (!systemCheckbox.Selected) + { + for (int i = 0; i < neededTabs; i++) + { + testBase.Session.SendKeys(Key.Tab); + } + + systemCheckbox.Click(); + } + + Assert.IsTrue(systemCheckbox.Selected, "System checkbox should be checked."); + + var appsCheckbox = testBase.Session.Find<Element>(By.AccessibilityId("ChangeAppsCheckbox_LightSwitch"), 5000); + Assert.IsNotNull(appsCheckbox, "Apps checkbox not found."); + + if (!appsCheckbox.Selected) + { + appsCheckbox.Click(); + } + + Assert.IsTrue(appsCheckbox.Selected, "Apps checkbox should be checked."); + + var systemBeforeValue = GetSystemTheme(); + var appsBeforeValue = GetAppsTheme(); + + Task.Delay(1000).Wait(); + testBase.Session.SendKeys(activationKeys); + Task.Delay(5000).Wait(); + + var systemAfterValue = GetSystemTheme(); + var appsAfterValue = GetAppsTheme(); + + Assert.AreNotEqual(systemBeforeValue, systemAfterValue, "System theme should have changed."); + Assert.AreNotEqual(appsBeforeValue, appsAfterValue, "Apps theme should have changed."); + + // Test with nothing checked + if (systemCheckbox.Selected) + { + systemCheckbox.Click(); + } + + if (appsCheckbox.Selected) + { + appsCheckbox.Click(); + } + + Assert.IsFalse(systemCheckbox.Selected, "System checkbox should be unchecked."); + Assert.IsFalse(appsCheckbox.Selected, "Apps checkbox should be unchecked."); + + var noneSystemBeforeValue = GetSystemTheme(); + var noneAppsBeforeValue = GetAppsTheme(); + + Task.Delay(1000).Wait(); + testBase.Session.SendKeys(activationKeys); + Task.Delay(5000).Wait(); + + var noneSystemAfterValue = GetSystemTheme(); + var noneAppsAfterValue = GetAppsTheme(); + + Assert.AreEqual(noneSystemBeforeValue, noneSystemAfterValue, "System theme should not have changed."); + Assert.AreEqual(noneAppsBeforeValue, noneAppsAfterValue, "Apps theme should not have changed."); + } + + /* Helpers */ + private static int GetSystemTheme() + { + return LightSwitch_GetCurrentSystemTheme() ? 1 : 0; + } + + private static int GetAppsTheme() + { + return LightSwitch_GetCurrentAppsTheme() ? 1 : 0; + } + + private static void SetSystemTheme(bool isLight) + { + LightSwitch_SetSystemTheme(isLight); + } + + private static void SetAppsTheme(bool isLight) + { + LightSwitch_SetAppsTheme(isLight); + } + + private static string GetHelpTextValue(string helpText, string key) + { + foreach (var part in helpText.Split(';')) + { + var kv = part.Split('='); + if (kv.Length == 2 && kv[0] == key) + { + return kv[1]; + } + } + + return string.Empty; + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestOffset.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestOffset.cs new file mode 100644 index 0000000000..e8ed9debf6 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestOffset.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestOffset : UITestBase + { + public TestOffset() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.Offset")] + [TestCategory("Time")] + public void TestTimeOffset() + { + TestHelper.InitializeTest(this, "offset test"); + TestHelper.PerformOffsetTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestShortcut.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestShortcut.cs new file mode 100644 index 0000000000..26e17c4612 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestShortcut.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestShortcut : UITestBase + { + public TestShortcut() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.TestShortcut")] + [TestCategory("Shortcut")] + public void TestLightSwitchShortcut() + { + var activationKeys = TestHelper.InitializeTest(this, "light switch shortcut test"); + TestHelper.PerformShortcutTest(this, activationKeys); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUpdateManualTime.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUpdateManualTime.cs new file mode 100644 index 0000000000..f92909657f --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUpdateManualTime.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestUpdateManualTime : UITestBase + { + public TestUpdateManualTime() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.UpdateManualTime")] + [TestCategory("Time")] + public void TestUpdateTime() + { + TestHelper.InitializeTest(this, "update manual time test"); + TestHelper.PerformUpdateTimeTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUserSelectedLocation.cs b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUserSelectedLocation.cs new file mode 100644 index 0000000000..924a04d9d9 --- /dev/null +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/TestUserSelectedLocation.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace LightSwitch.UITests +{ + [TestClass] + public class TestUserSelectedLocation : UITestBase + { + public TestUserSelectedLocation() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("LightSwitch.UserSelectedLocation")] + [TestCategory("Location")] + public void TestUserSelectedLocationUpdate() + { + TestHelper.InitializeTest(this, "user selected location test"); + TestHelper.PerformUserSelectedLocationTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/app.manifest b/src/modules/LightSwitch/Tests/LightSwitch.UITests/app.manifest similarity index 93% rename from src/modules/cmdpal/Exts/SamplePagesExtension/app.manifest rename to src/modules/LightSwitch/Tests/LightSwitch.UITests/app.manifest index 6a7c7d68a7..0cec0ecb5e 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/app.manifest +++ b/src/modules/LightSwitch/Tests/LightSwitch.UITests/app.manifest @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> - <assemblyIdentity version="1.0.0.0" name="SamplePagesExtension.app"/> + <assemblyIdentity version="1.0.0.0" name="LightSwitch.UITests.app"/> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> <application> diff --git a/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj b/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj index 86d258854f..a61b2e58bb 100644 --- a/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj +++ b/src/modules/MeasureTool/MeasureToolCore/PowerToys.MeasureToolCore.vcxproj @@ -1,8 +1,16 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.props" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.props')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.props')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="NuGet"> + <!-- Tell NuGet this is PackageReference style --> + <RestoreProjectStyle>PackageReference</RestoreProjectStyle> + + <!-- Tell NuGet we're a native project --> + <NuGetTargetMoniker>native,Version=v0.0</NuGetTargetMoniker> + + <!-- Tell NuGet we target Windows (use your existing WindowsTargetPlatformVersion) --> + <NuGetTargetPlatformIdentifier>Windows</NuGetTargetPlatformIdentifier> + <NuGetTargetPlatformVersion>$(WindowsTargetPlatformVersion)</NuGetTargetPlatformVersion> + </PropertyGroup> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -22,9 +30,14 @@ <ApplicationType>Windows Store</ApplicationType> <ApplicationTypeRevision>10.0</ApplicationTypeRevision> <UseWinUI>true</UseWinUI> - <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> + <WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained> <EnablePreviewMsixTooling>true</EnablePreviewMsixTooling> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.CppWinRT" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.ImplementationLibrary" GeneratePathProperty="true" /> + </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> @@ -32,7 +45,6 @@ <DesktopCompatible>true</DesktopCompatible> </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> <ImportGroup Label="ExtensionSettings"> </ImportGroup> <ImportGroup Label="PropertySheets"> @@ -40,13 +52,13 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <WarningLevel>Level4</WarningLevel> <AdditionalOptions>%(AdditionalOptions) /bigobj</AdditionalOptions> - <AdditionalIncludeDirectories>$(SolutionDir)src\;..\..\..\common\Telemetry;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -113,22 +125,19 @@ </Midl> </ItemGroup> <ItemGroup> - <None Include="packages.config" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> @@ -136,24 +145,5 @@ <ResourceCompile Include="PowerToys.MeasureToolCore.rc" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.targets" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.targets')" /> - </ImportGroup> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>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}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.targets'))" /> - </Target> + <Import Project="$(RepoRoot)deps\spdlog.props" /> </Project> \ No newline at end of file diff --git a/src/modules/MeasureTool/MeasureToolCore/ToolState.h b/src/modules/MeasureTool/MeasureToolCore/ToolState.h index 57d5d04ca1..6d046a43fe 100644 --- a/src/modules/MeasureTool/MeasureToolCore/ToolState.h +++ b/src/modules/MeasureTool/MeasureToolCore/ToolState.h @@ -31,7 +31,11 @@ struct CommonState Measurement::Unit units = Measurement::Unit::Pixel; - POINT cursorPosSystemSpace = {}; // updated atomically + #pragma warning(push) + #pragma warning(disable : 4324) + alignas(8) POINT cursorPosSystemSpace = {}; // updated atomically + #pragma warning(pop) + std::atomic_bool closeOnOtherMonitors = false; float GetPhysicalPx2MmRatio(HWND window) const diff --git a/src/modules/MeasureTool/MeasureToolCore/app.manifest b/src/modules/MeasureTool/MeasureToolCore/app.manifest index 1292107c7f..43d75e11a1 100644 --- a/src/modules/MeasureTool/MeasureToolCore/app.manifest +++ b/src/modules/MeasureTool/MeasureToolCore/app.manifest @@ -9,7 +9,7 @@ 2) System < Windows 10 Anniversary Update --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> diff --git a/src/modules/MeasureTool/MeasureToolCore/packages.config b/src/modules/MeasureTool/MeasureToolCore/packages.config deleted file mode 100644 index 61ff4b9f07..0000000000 --- a/src/modules/MeasureTool/MeasureToolCore/packages.config +++ /dev/null @@ -1,8 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> - <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> - <package id="Microsoft.Windows.SDK.BuildTools" version="10.0.22621.2428" targetFramework="native" /> - <package id="Microsoft.WindowsAppSDK" version="1.6.250205002" targetFramework="native" /> -</packages> \ No newline at end of file diff --git a/src/modules/MeasureTool/MeasureToolModuleInterface/MeasureToolModuleInterface.vcxproj b/src/modules/MeasureTool/MeasureToolModuleInterface/MeasureToolModuleInterface.vcxproj index 557564781f..55728a6bbf 100644 --- a/src/modules/MeasureTool/MeasureToolModuleInterface/MeasureToolModuleInterface.vcxproj +++ b/src/modules/MeasureTool/MeasureToolModuleInterface/MeasureToolModuleInterface.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{92C39820-9F84-4529-BC7D-22AAE514D63B}</ProjectGuid> @@ -8,17 +9,16 @@ <RootNamespace>MeasureToolModuleInterface</RootNamespace> <ProjectName>MeasureToolModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -32,7 +32,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.MeasureToolModuleInterface</TargetName> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'"> @@ -43,7 +43,7 @@ </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -60,10 +60,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -74,15 +74,15 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/MeasureTool/MeasureToolModuleInterface/dllmain.cpp b/src/modules/MeasureTool/MeasureToolModuleInterface/dllmain.cpp index cfac5ce640..0c31e7f9ef 100644 --- a/src/modules/MeasureTool/MeasureToolModuleInterface/dllmain.cpp +++ b/src/modules/MeasureTool/MeasureToolModuleInterface/dllmain.cpp @@ -149,7 +149,7 @@ public: init_settings(); triggerEvent = CreateEvent(nullptr, false, false, CommonSharedConstants::MEASURE_TOOL_TRIGGER_EVENT); - triggerEventWaiter = EventWaiter(CommonSharedConstants::MEASURE_TOOL_TRIGGER_EVENT, [this](int) { + triggerEventWaiter.start(CommonSharedConstants::MEASURE_TOOL_TRIGGER_EVENT, [this](DWORD) { on_hotkey(0); }); } diff --git a/src/modules/MeasureTool/MeasureToolModuleInterface/packages.config b/src/modules/MeasureTool/MeasureToolModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/MeasureTool/MeasureToolModuleInterface/packages.config +++ b/src/modules/MeasureTool/MeasureToolModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/MeasureTool/MeasureToolUI/MeasureToolUI.csproj b/src/modules/MeasureTool/MeasureToolUI/MeasureToolUI.csproj index 434ff088b2..f2424eda21 100644 --- a/src/modules/MeasureTool/MeasureToolUI/MeasureToolUI.csproj +++ b/src/modules/MeasureTool/MeasureToolUI/MeasureToolUI.csproj @@ -1,13 +1,13 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <AssemblyTitle>PowerToys.MeasureTool</AssemblyTitle> <AssemblyDescription>PowerToys MeasureTool</AssemblyDescription> <OutputType>WinExe</OutputType> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> <RootNamespace>PowerToys.MeasureToolUI</RootNamespace> <AssemblyName>PowerToys.MeasureToolUI</AssemblyName> <ApplicationManifest>app.manifest</ApplicationManifest> @@ -73,6 +73,13 @@ <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" /> <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> <ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> - <ProjectReference Include="..\MeasureToolCore\PowerToys.MeasureToolCore.vcxproj" /> + <ProjectReference Include="..\MeasureToolCore\PowerToys.MeasureToolCore.vcxproj"> + <ReferenceOutputAssembly>false</ReferenceOutputAssembly> + <BuildProject>true</BuildProject> + </ProjectReference> + <CsWinRTInputs Include="$(OutputPath)\PowerToys.MeasureToolCore.winmd" /> + <None Include="$(OutputPath)\PowerToys.MeasureToolCore.dll"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> </ItemGroup> </Project> diff --git a/src/modules/MeasureTool/MeasureToolUI/MeasureToolXAML/MainWindow.xaml b/src/modules/MeasureTool/MeasureToolUI/MeasureToolXAML/MainWindow.xaml index 8e58d66499..5ae5e6e4c1 100644 --- a/src/modules/MeasureTool/MeasureToolUI/MeasureToolXAML/MainWindow.xaml +++ b/src/modules/MeasureTool/MeasureToolUI/MeasureToolXAML/MainWindow.xaml @@ -5,11 +5,11 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:winuiex="using:WinUIEx" + Title="PowerToys.ScreenRuler" IsAlwaysOnTop="True" IsMaximizable="False" IsMinimizable="False" IsResizable="False" - IsShownInSwitchers="False" IsTitleBarVisible="False" mc:Ignorable="d"> <winuiex:WindowEx.Backdrop> @@ -250,6 +250,7 @@ <ToggleButton Name="btnBounds" x:Uid="BtnBounds" + AutomationProperties.AutomationId="Button_Bounds" Click="BoundsTool_Click" Content="" KeyboardAcceleratorPlacementMode="Auto" @@ -267,6 +268,7 @@ <ToggleButton Name="btnSpacing" x:Uid="BtnSpacing" + AutomationProperties.AutomationId="Button_Spacing" Click="MeasureTool_Click" Style="{StaticResource ToggleButtonRadioButtonStyle}"> <ToolTipService.ToolTip> @@ -284,6 +286,7 @@ <ToggleButton Name="btnHorizontalSpacing" x:Uid="BtnHorizontalSpacing" + AutomationProperties.AutomationId="Button_SpacingHorizontal" Click="HorizontalMeasureTool_Click" Style="{StaticResource ToggleButtonRadioButtonStyle}"> <ToolTipService.ToolTip> @@ -304,6 +307,7 @@ <ToggleButton Name="btnVerticalSpacing" x:Uid="BtnVerticalSpacing" + AutomationProperties.AutomationId="Button_SpacingVertical" Click="VerticalMeasureTool_Click" Style="{StaticResource ToggleButtonRadioButtonStyle}"> <ToolTipService.ToolTip> @@ -324,6 +328,7 @@ <AppBarSeparator /> <Button x:Uid="BtnClosePanel" + AutomationProperties.AutomationId="Button_Close" Click="ClosePanelTool_Click" Content="" Foreground="{StaticResource CloseButtonBackgroundPointerOver}"> diff --git a/src/modules/MeasureTool/MeasureToolUI/MeasureToolXAML/MainWindow.xaml.cs b/src/modules/MeasureTool/MeasureToolUI/MeasureToolXAML/MainWindow.xaml.cs index 4f807655da..afc59eb8d3 100644 --- a/src/modules/MeasureTool/MeasureToolUI/MeasureToolXAML/MainWindow.xaml.cs +++ b/src/modules/MeasureTool/MeasureToolUI/MeasureToolXAML/MainWindow.xaml.cs @@ -52,12 +52,23 @@ namespace MeasureToolUI var presenter = _appWindow.Presenter as OverlappedPresenter; presenter.IsAlwaysOnTop = true; this.SetIsAlwaysOnTop(true); - this.SetIsShownInSwitchers(false); this.SetIsResizable(false); this.SetIsMinimizable(false); this.SetIsMaximizable(false); IsTitleBarVisible = false; + try + { + this.SetIsShownInSwitchers(false); + } + catch (NotImplementedException) + { + // WinUI will throw if explorer is not running, safely ignore + } + catch (Exception) + { + } + // Remove the caption style from the window style. Windows App SDK 1.6 added it, which made the title bar and borders appear for Measure Tool. This code removes it. var windowStyle = GetWindowLong(hwnd, GWL_STYLE); windowStyle = windowStyle & (~WS_CAPTION); diff --git a/src/modules/MeasureTool/MeasureToolUI/Settings.cs b/src/modules/MeasureTool/MeasureToolUI/Settings.cs index 4e8cd99b18..ac48339ad6 100644 --- a/src/modules/MeasureTool/MeasureToolUI/Settings.cs +++ b/src/modules/MeasureTool/MeasureToolUI/Settings.cs @@ -11,7 +11,7 @@ namespace MeasureToolUI { public sealed class Settings { - private static readonly SettingsUtils ModuleSettings = new(); + private static readonly SettingsUtils ModuleSettings = SettingsUtils.Default; public MeasureToolMeasureStyle DefaultMeasureStyle { diff --git a/src/modules/MeasureTool/MeasureToolUI/app.manifest b/src/modules/MeasureTool/MeasureToolUI/app.manifest index 908d7af1a8..17f567bf50 100644 --- a/src/modules/MeasureTool/MeasureToolUI/app.manifest +++ b/src/modules/MeasureTool/MeasureToolUI/app.manifest @@ -9,7 +9,7 @@ 2) System < Windows 10 Anniversary Update --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> diff --git a/src/modules/MeasureTool/Tests/ScreenRuler.UITests/Package.appxmanifest b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/Package.appxmanifest new file mode 100644 index 0000000000..4b8f6ce2fb --- /dev/null +++ b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/Package.appxmanifest @@ -0,0 +1,51 @@ +<?xml version="1.0" encoding="utf-8"?> + +<Package + xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" + xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" + xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" + xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" + IgnorableNamespaces="uap rescap"> + + <Identity + Name="d4d0f157-5c12-4390-9689-152b0c86a582" + Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" + Version="1.0.0.0" /> + + <mp:PhoneIdentity PhoneProductId="d4d0f157-5c12-4390-9689-152b0c86a582" PhonePublisherId="00000000-0000-0000-0000-000000000000"/> + + <Properties> + <DisplayName>ScreenRuler.UITests</DisplayName> + <PublisherDisplayName>Microsoft</PublisherDisplayName> + <Logo>Assets\StoreLogo.png</Logo> + </Properties> + + <Dependencies> + <TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" /> + <TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" /> + </Dependencies> + + <Resources> + <Resource Language="x-generate"/> + </Resources> + + <Applications> + <Application Id="App" + Executable="$targetnametoken$.exe" + EntryPoint="$targetentrypoint$"> + <uap:VisualElements + DisplayName="ScreenRuler.UITests" + Description="ScreenRuler.UITests" + BackgroundColor="transparent" + Square150x150Logo="Assets\Square150x150Logo.png" + Square44x44Logo="Assets\Square44x44Logo.png"> + <uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" /> + <uap:SplashScreen Image="Assets\SplashScreen.png" /> + </uap:VisualElements> + </Application> + </Applications> + + <Capabilities> + <rescap:Capability Name="runFullTrust" /> + </Capabilities> +</Package> diff --git a/src/modules/MeasureTool/Tests/ScreenRuler.UITests/ScreenRuler.UITests.csproj b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/ScreenRuler.UITests.csproj new file mode 100644 index 0000000000..4e59a4c5b4 --- /dev/null +++ b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/ScreenRuler.UITests.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <PropertyGroup> + <RootNamespace>PowerToys.ScreenRuler.UITests</RootNamespace> + <AssemblyName>ScreenRuler.UITests</AssemblyName> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + + <!-- This is a UI test, so don't run as part of MSBuild --> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + <PropertyGroup> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\ScreenRuler.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <ProjectReference Include="..\..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> +</Project> + \ No newline at end of file diff --git a/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestBounds.cs b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestBounds.cs new file mode 100644 index 0000000000..58ffefba1b --- /dev/null +++ b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestBounds.cs @@ -0,0 +1,27 @@ +// 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.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ScreenRuler.UITests +{ + [TestClass] + public class TestBounds : UITestBase + { + public TestBounds() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("ScreenRuler.BoundsTool")] + [TestCategory("Spacing")] + public void TestScreenRulerBoundsTool() + { + TestHelper.InitializeTest(this, "bounds test"); + TestHelper.PerformBoundsToolTest(this); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestHelper.cs b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestHelper.cs new file mode 100644 index 0000000000..c06a0a436f --- /dev/null +++ b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestHelper.cs @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ScreenRuler.UITests +{ + public static class TestHelper + { + private static readonly string[] ShortcutSeparators = { " + ", "+", " " }; + + // Button automation names from Resources.resw + public const string BoundsButtonId = "Button_Bounds"; + public const string SpacingButtonName = "Button_Spacing"; + public const string HorizontalSpacingButtonName = "Button_SpacingHorizontal"; + public const string VerticalSpacingButtonName = "Button_SpacingVertical"; + public const string CloseButtonId = "Button_Close"; + + /// <summary> + /// Performs common test initialization: navigate to settings, enable toggle, verify shortcut + /// </summary> + /// <param name="testBase">The test base instance</param> + /// <param name="testName">Name of the test for assertions</param> + /// <returns>The activation keys for the test</returns> + public static Key[] InitializeTest(UITestBase testBase, string testName) + { + LaunchFromSetting(testBase); + + var toggleSwitch = SetScreenRulerToggle(testBase, enable: true); + Assert.IsTrue( + toggleSwitch.IsOn, + $"Screen Ruler toggle switch should be ON for {testName}"); + + var activationKeys = ReadActivationShortcut(testBase); + Assert.IsNotNull(activationKeys, "Should be able to read activation shortcut"); + Assert.IsTrue(activationKeys.Length > 0, "Activation shortcut should contain at least one key"); + + return activationKeys; + } + + /// <summary> + /// Performs common test cleanup: close ScreenRuler UI + /// </summary> + /// <param name="testBase">The test base instance</param> + public static void CleanupTest(UITestBase testBase) + { + CloseScreenRulerUI(testBase); + + // Ensure we're attached to settings after cleanup + try + { + testBase.Session.Attach(PowerToysModule.PowerToysSettings); + } + catch + { + // Ignore attachment errors - this is just cleanup + } + } + + /// <summary> + /// Navigate to the Screen Ruler (Measure Tool) settings page + /// </summary> + public static void LaunchFromSetting(UITestBase testBase) + { + var screenRulers = testBase.Session.FindAll<NavigationViewItem>(By.AccessibilityId("ScreenRulerNavItem")); + + if (screenRulers.Count == 0) + { + testBase.Session.Find<NavigationViewItem>(By.AccessibilityId("SystemToolsNavItem"), 5000).Click(msPostAction: 500); + } + + testBase.Session.Find<NavigationViewItem>(By.AccessibilityId("ScreenRulerNavItem"), 5000).Click(msPostAction: 500); + } + + /// <summary> + /// Set the Screen Ruler toggle switch to the specified state + /// </summary> + public static ToggleSwitch SetScreenRulerToggle(UITestBase testBase, bool enable) + { + var toggleSwitch = testBase.Session.Find<ToggleSwitch>(By.AccessibilityId("Toggle_ScreenRuler"), 5000); + + if (toggleSwitch.IsOn != enable) + { + toggleSwitch.Click(msPreAction: 1000, msPostAction: 2000); + } + + if (toggleSwitch.IsOn != enable) + { + testBase.Session.SendKey(Key.Space, msPreAction: 0, msPostAction: 2000); + } + + return toggleSwitch; + } + + /// <summary> + /// Set the Screen Ruler toggle and verify its state + /// </summary> + /// <param name="testBase">The test base instance</param> + /// <param name="enable">True to enable, false to disable</param> + /// <param name="testName">Name of the test for assertion messages</param> + public static void SetAndVerifyScreenRulerToggle(UITestBase testBase, bool enable, string testName) + { + var toggleSwitch = SetScreenRulerToggle(testBase, enable); + Assert.AreEqual( + enable, + toggleSwitch.IsOn, + $"Screen Ruler toggle switch should be {(enable ? "ON" : "OFF")} for {testName}"); + } + + /// <summary> + /// Read the current activation shortcut from the ShortcutControl + /// </summary> + public static Key[] ReadActivationShortcut(UITestBase testBase) + { + var shortcutCard = testBase.Session.Find<Element>(By.AccessibilityId("Shortcut_ScreenRuler"), 5000); + var shortcutButton = shortcutCard.Find<Element>(By.AccessibilityId("EditButton"), 5000); + return ParseShortcutText(shortcutButton.HelpText); + } + + /// <summary> + /// Parse shortcut text like "Win + Ctrl + Shift + M" into Key array + /// </summary> + private static Key[] ParseShortcutText(string shortcutText) + { + if (string.IsNullOrEmpty(shortcutText)) + { + return new Key[] { Key.Win, Key.Ctrl, Key.Shift, Key.M }; + } + + var keys = new List<Key>(); + var parts = shortcutText.Split(ShortcutSeparators, StringSplitOptions.RemoveEmptyEntries); + + foreach (var part in parts) + { + var cleanPart = part.Trim().ToLowerInvariant(); + var key = cleanPart switch + { + "win" or "windows" => Key.Win, + "ctrl" or "control" => Key.Ctrl, + "shift" => Key.Shift, + "alt" => Key.Alt, + _ when cleanPart.Length == 1 && char.IsLetter(cleanPart[0]) && + cleanPart[0] >= 'a' && cleanPart[0] <= 'z' => + (Key)Enum.Parse(typeof(Key), cleanPart.ToUpperInvariant()), + _ => (Key?)null, + }; + + if (key.HasValue) + { + keys.Add(key.Value); + } + } + + return keys.Count > 0 ? keys.ToArray() : new Key[] { Key.Win, Key.Ctrl, Key.Shift, Key.M }; + } + + /// <summary> + /// Check if ScreenRulerUI window is open + /// </summary> + public static bool IsScreenRulerUIOpen(UITestBase testBase) => testBase.IsWindowOpen("PowerToys.ScreenRuler"); + + /// <summary> + /// Wait for ScreenRulerUI to reach the specified state within the timeout + /// </summary> + public static bool WaitForScreenRulerUIState(UITestBase testBase, bool shouldBeOpen, int timeoutMs = 5000, int pollingIntervalMs = 100) + { + var endTime = DateTime.Now.AddMilliseconds(timeoutMs); + + while (DateTime.Now < endTime) + { + if (IsScreenRulerUIOpen(testBase) == shouldBeOpen) + { + return true; + } + + Task.Delay(pollingIntervalMs).Wait(); + } + + return false; + } + + /// <summary> + /// Wait for ScreenRulerUI to appear within the specified timeout + /// </summary> + public static bool WaitForScreenRulerUI(UITestBase testBase, int timeoutMs = 5000) => + WaitForScreenRulerUIState(testBase, shouldBeOpen: true, timeoutMs); + + /// <summary> + /// Wait for ScreenRulerUI to disappear within the specified timeout + /// </summary> + public static bool WaitForScreenRulerUIToDisappear(UITestBase testBase, int timeoutMs = 5000) => + WaitForScreenRulerUIState(testBase, shouldBeOpen: false, timeoutMs); + + /// <summary> + /// Close ScreenRulerUI if it's open + /// </summary> + public static void CloseScreenRulerUI(UITestBase testBase) + { + if (IsScreenRulerUIOpen(testBase)) + { + try + { + // Attach to ScreenRuler window before trying to find and click close button + testBase.Session.Attach(PowerToysModule.ScreenRuler); + var closeButton = testBase.Session.Find<Element>(By.AccessibilityId(CloseButtonId), 15000, true); + closeButton?.Click(); + } + catch + { + // If we can't find the close button, ignore - the window might have closed already + } + finally + { + // Attach back to settings after closing + try + { + testBase.Session.Attach(PowerToysModule.PowerToysSettings); + } + catch + { + // Ignore attachment errors + } + } + } + } + + /// <summary> + /// Get a specific ScreenRulerUI button by its automation name + /// </summary> + public static Element? GetScreenRulerButton(UITestBase testBase, string buttonName, int timeoutMs = 1000) + { + return testBase.Session.Find<Element>(By.AccessibilityId(buttonName), timeoutMs, true); + + /* + try + { + // Attach to ScreenRuler window before trying to find buttons + testBase.Session.Attach(PowerToysModule.ScreenRuler); + return testBase.Session.Find<Element>(By.AccessibilityId(buttonName), timeoutMs, true); + } + catch + { + return null; + } + finally + { + // Attach back to settings if needed for further operations + // This ensures we don't break the test flow + try + { + testBase.Session.Attach(PowerToysModule.PowerToysSettings); + } + catch + { + // Ignore attachment errors - the calling code will handle as needed + } + } + */ + } + + /// <summary> + /// Clear the clipboard content using STA thread + /// </summary> + public static void ClearClipboard() + { + ExecuteInSTAThread(() => System.Windows.Forms.Clipboard.Clear()); + } + + /// <summary> + /// Get text content from clipboard using STA thread + /// </summary> + public static string GetClipboardText() + { + string result = string.Empty; + ExecuteInSTAThread(() => + { + if (System.Windows.Forms.Clipboard.ContainsText()) + { + result = System.Windows.Forms.Clipboard.GetText(); + } + }); + return result ?? string.Empty; + } + + /// <summary> + /// Execute an action in an STA thread with error handling + /// </summary> + private static void ExecuteInSTAThread(Action action) + { + try + { + var staThread = new Thread(() => + { + try + { + action(); + } + catch + { + // Ignore clipboard errors + } + }); + + staThread.SetApartmentState(ApartmentState.STA); + staThread.Start(); + staThread.Join(TimeSpan.FromSeconds(5)); + } + catch + { + // Ignore clipboard errors + } + } + + /// <summary> + /// Validate clipboard content contains valid spacing measurement for the specified type + /// </summary> + public static bool ValidateSpacingClipboardContent(string clipboardText, string spacingType) + { + if (string.IsNullOrEmpty(clipboardText)) + { + return false; + } + + return spacingType switch + { + "Spacing" => Regex.IsMatch(clipboardText, @"\d+\s*[�x×]\s*\d+"), + "Horizontal Spacing" or "Vertical Spacing" => Regex.IsMatch(clipboardText, @"^\d+$"), + _ => false, + }; + } + + /// <summary> + /// Perform a complete spacing tool test operation + /// </summary> + public static void PerformSpacingToolTest(UITestBase testBase, string buttonId, string testName) + { + ClearClipboard(); + + // Launch ScreenRuler UI + var activationKeys = ReadActivationShortcut(testBase); + testBase.SendKeys(activationKeys); + + Assert.IsTrue( + WaitForScreenRulerUI(testBase, 2000), + $"ScreenRulerUI should appear after pressing activation shortcut for {testName}: {string.Join(" + ", activationKeys)}"); + + // Attach to ScreenRuler window and click spacing button + // testBase.Session.Attach(PowerToysModule.ScreenRuler); + var spacingButton = testBase.Session.Find<Element>(By.AccessibilityId(buttonId), 15000, true); + Assert.IsNotNull(spacingButton, $"{testName} button should be found"); + + spacingButton!.Click(); + Task.Delay(500).Wait(); + + // Perform measurement action (stay attached to ScreenRuler for this) + PerformMeasurementAction(testBase); + + // Validate results + ValidateClipboardResults(testName); + + // Cleanup - this will handle session attachment properly + CloseScreenRulerUI(testBase); + Assert.IsTrue( + WaitForScreenRulerUIToDisappear(testBase, 2000), + $"{testName}: ScreenRulerUI should close after calling CloseScreenRulerUI"); + } + + /// <summary> + /// Perform a bounds tool test operation + /// </summary> + public static void PerformBoundsToolTest(UITestBase testBase) + { + ClearClipboard(); + + var activationKeys = ReadActivationShortcut(testBase); + testBase.SendKeys(activationKeys); + + Assert.IsTrue( + WaitForScreenRulerUI(testBase, 2000), + $"ScreenRulerUI should appear after pressing activation shortcut: {string.Join(" + ", activationKeys)}"); + + // Attach to ScreenRuler window and click bounds button + // testBase.Session.Attach(PowerToysModule.ScreenRuler); + var boundsButton = testBase.Session.Find<Element>(By.AccessibilityId(BoundsButtonId), 15000, true); + Assert.IsNotNull(boundsButton, "Bounds button should be found"); + + boundsButton.Click(); + Task.Delay(500).Wait(); + + // Perform drag operation to create 100x100 box (stay attached to ScreenRuler) + var currentPos = testBase.GetMousePosition(); + int startX = currentPos.Item1; + int startY = currentPos.Item2 + 200; + + testBase.MoveMouseTo(startX, startY); + Task.Delay(200).Wait(); + + // Drag operation + testBase.Session.PerformMouseAction(MouseActionType.LeftDown); + Task.Delay(100).Wait(); + + testBase.MoveMouseTo(startX + 99, startY + 99); + Task.Delay(200).Wait(); + + testBase.Session.PerformMouseAction(MouseActionType.LeftUp); + Task.Delay(500).Wait(); + + // Dismiss selection + testBase.Session.PerformMouseAction(MouseActionType.RightClick); + Task.Delay(500).Wait(); + + // Validate results + string clipboardText = GetClipboardText(); + Assert.IsFalse(string.IsNullOrEmpty(clipboardText), "Clipboard should contain measurement data"); + Assert.IsTrue( + clipboardText.Contains("100 × 100") || clipboardText.Contains("100 x 100"), + $"Clipboard should contain '100 x 100', but contained: '{clipboardText}'"); + + // Cleanup - this will handle session attachment properly + CloseScreenRulerUI(testBase); + Assert.IsTrue( + WaitForScreenRulerUIToDisappear(testBase, 2000), + "ScreenRulerUI should close after calling CloseScreenRulerUI"); + } + + /// <summary> + /// Perform a measurement action (move mouse and click) + /// </summary> + private static void PerformMeasurementAction(UITestBase testBase) + { + var currentPos = testBase.GetMousePosition(); + int startX = currentPos.Item1; + int startY = currentPos.Item2 + 200; + + testBase.MoveMouseTo(startX, startY); + Task.Delay(200).Wait(); + + testBase.Session.PerformMouseAction(MouseActionType.LeftClick); + Task.Delay(500).Wait(); + + testBase.Session.PerformMouseAction(MouseActionType.RightClick); + Task.Delay(500).Wait(); + } + + /// <summary> + /// Validate clipboard results for spacing tests + /// </summary> + private static void ValidateClipboardResults(string testName) + { + string clipboardText = GetClipboardText(); + Assert.IsFalse(string.IsNullOrEmpty(clipboardText), $"{testName}: Clipboard should contain measurement data"); + + bool containsValidPattern = ValidateSpacingClipboardContent(clipboardText, testName); + Assert.IsTrue( + containsValidPattern, + $"{testName}: Clipboard should contain valid spacing measurement, but contained: '{clipboardText}'"); + } + } +} diff --git a/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestShortcutActivation.cs b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestShortcutActivation.cs new file mode 100644 index 0000000000..8c0a24cae8 --- /dev/null +++ b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestShortcutActivation.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ScreenRuler.UITests +{ + [TestClass] + public class TestShortcutActivation : UITestBase + { + public TestShortcutActivation() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("ScreenRuler.ShortcutActivation")] + [TestCategory("Activation")] + public void TestScreenRulerShortcutActivation() + { + var activationKeys = TestHelper.InitializeTest(this, "activation test"); + + // Test 1: Press the activation shortcut and verify the toolbar appears + SendKeys(activationKeys); + bool screenRulerAppeared = TestHelper.WaitForScreenRulerUI(this, 1000); + Assert.IsTrue( + screenRulerAppeared, + $"ScreenRulerUI should appear after pressing activation shortcut: {string.Join(" + ", activationKeys)}"); + + // Test 2: Press the activation shortcut again and verify the toolbar disappears + SendKeys(activationKeys); + bool screenRulerDisappeared = TestHelper.WaitForScreenRulerUIToDisappear(this, 1000); + Assert.IsTrue( + screenRulerDisappeared, + $"ScreenRulerUI should disappear after pressing activation shortcut again: {string.Join(" + ", activationKeys)}"); + + // Test 3: Disable Screen Ruler and verify that the activation shortcut no longer activates the utility + // Ensure we're attached to settings UI before toggling + Session.Attach(PowerToysModule.PowerToysSettings); + TestHelper.SetAndVerifyScreenRulerToggle(this, enable: false, "disabled state test"); + + // Try to activate with shortcut while disabled + SendKeys(activationKeys); + Task.Delay(1000).Wait(); + Assert.IsFalse( + TestHelper.IsScreenRulerUIOpen(this), + "ScreenRulerUI should not appear when Screen Ruler is disabled"); + + // Test 4: Enable Screen Ruler and press the activation shortcut and verify the toolbar appears + // Ensure we're attached to settings UI before toggling + Session.Attach(PowerToysModule.PowerToysSettings); + TestHelper.SetAndVerifyScreenRulerToggle(this, enable: true, "re-enabled state test"); + + SendKeys(activationKeys); + screenRulerAppeared = TestHelper.WaitForScreenRulerUI(this, 1000); + Assert.IsTrue( + screenRulerAppeared, + $"ScreenRulerUI should appear after re-enabling and pressing activation shortcut: {string.Join(" + ", activationKeys)}"); + + // Test 5: Verify the utility can be closed via the cleanup method + TestHelper.CloseScreenRulerUI(this); + bool screenRulerClosed = TestHelper.WaitForScreenRulerUIToDisappear(this, 1000); + Assert.IsTrue( + screenRulerClosed, + "ScreenRulerUI should close after calling CloseScreenRulerUI"); + + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacing.cs b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacing.cs new file mode 100644 index 0000000000..4279394a1a --- /dev/null +++ b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacing.cs @@ -0,0 +1,27 @@ +// 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.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ScreenRuler.UITests +{ + [TestClass] + public class TestSpacing : UITestBase + { + public TestSpacing() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("ScreenRuler.SpacingTool")] + [TestCategory("Spacing")] + public void TestScreenRulerSpacingTool() + { + TestHelper.InitializeTest(this, "spacing test"); + TestHelper.PerformSpacingToolTest(this, TestHelper.SpacingButtonName, "Spacing"); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacingHorizontal.cs b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacingHorizontal.cs new file mode 100644 index 0000000000..78f6e15325 --- /dev/null +++ b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacingHorizontal.cs @@ -0,0 +1,27 @@ +// 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.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ScreenRuler.UITests +{ + [TestClass] + public class TestSpacingHorizontal : UITestBase + { + public TestSpacingHorizontal() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("ScreenRuler.HorizontalSpacingTool")] + [TestCategory("Spacing")] + public void TestScreenRulerHorizontalSpacingTool() + { + TestHelper.InitializeTest(this, "horizontal spacing test"); + TestHelper.PerformSpacingToolTest(this, TestHelper.HorizontalSpacingButtonName, "Horizontal Spacing"); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacingVertical.cs b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacingVertical.cs new file mode 100644 index 0000000000..52a42df1f6 --- /dev/null +++ b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/TestSpacingVertical.cs @@ -0,0 +1,27 @@ +// 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.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ScreenRuler.UITests +{ + [TestClass] + public class TestSpacingVertical : UITestBase + { + public TestSpacingVertical() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + [TestMethod("ScreenRuler.VerticalSpacingTool")] + [TestCategory("Spacing")] + public void TestScreenRulerVerticalSpacingTool() + { + TestHelper.InitializeTest(this, "vertical spacing test"); + TestHelper.PerformSpacingToolTest(this, TestHelper.VerticalSpacingButtonName, "Vertical Spacing"); + TestHelper.CleanupTest(this); + } + } +} diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/app.manifest b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/app.manifest similarity index 93% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/app.manifest rename to src/modules/MeasureTool/Tests/ScreenRuler.UITests/app.manifest index a620209321..b15e30cce2 100644 --- a/src/modules/cmdpal/Exts/ProcessMonitorExtension/app.manifest +++ b/src/modules/MeasureTool/Tests/ScreenRuler.UITests/app.manifest @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> - <assemblyIdentity version="1.0.0.0" name="KillProcessExtension.app"/> + <assemblyIdentity version="1.0.0.0" name="ScreenRuler.UITests.app"/> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> <application> diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.rc b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc new file mode 100644 index 0000000000..37752edae0 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.rc @@ -0,0 +1,46 @@ +#include <windows.h> +#include "resource.h" +#include "../../../../common/version/version.h" + +#define APSTUDIO_READONLY_SYMBOLS +#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" + BEGIN + VALUE "CompanyName", COMPANY_NAME + VALUE "FileDescription", "PowerToys CursorWrap" + VALUE "FileVersion", FILE_VERSION_STRING + VALUE "InternalName", "CursorWrap" + VALUE "LegalCopyright", COPYRIGHT_NOTE + VALUE "OriginalFilename", "PowerToys.CursorWrap.dll" + VALUE "ProductName", PRODUCT_NAME + VALUE "ProductVersion", PRODUCT_VERSION_STRING + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END + +STRINGTABLE +BEGIN + IDS_CURSORWRAP_NAME L"CursorWrap" + IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG L"Disable wrapping during drag" +END \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj new file mode 100644 index 0000000000..70ef9f352f --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj @@ -0,0 +1,133 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <VCProjectVersion>15.0</VCProjectVersion> + <ProjectGuid>{48a1db8c-5df8-4fb3-9e14-2b67f3f2d8b5}</ProjectGuid> + <Keyword>Win32Proj</Keyword> + <RootNamespace>CursorWrap</RootNamespace> + <ProjectName>CursorWrap</ProjectName> + </PropertyGroup> + <Import Project="$(RepoRoot)deps\spdlog.props" /> + <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> + <TargetName>PowerToys.CursorWrap</TargetName> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Debug'"> + <LinkIncremental>true</LinkIncremental> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Release'"> + <LinkIncremental>false</LinkIncremental> + </PropertyGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <Optimization>Disabled</Optimization> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary> + <LanguageStandard>stdcpplatest</LanguageStandard> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <Optimization>MaxSpeed</Optimization> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <RuntimeLibrary>MultiThreaded</RuntimeLibrary> + <LanguageStandard>stdcpplatest</LanguageStandard> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup> + <ClCompile> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + </ClCompile> + </ItemDefinitionGroup> + <ItemGroup> + <ClInclude Include="CursorWrapCore.h" /> + <ClInclude Include="CursorWrapTests.h" /> + <ClInclude Include="MonitorTopology.h" /> + <ClInclude Include="pch.h" /> + <ClInclude Include="trace.h" /> + <ClInclude Include="resource.h" /> + </ItemGroup> + <ItemGroup> + <ClCompile Include="CursorWrapCore.cpp" /> + <ClCompile Include="dllmain.cpp" /> + <ClCompile Include="MonitorTopology.cpp" /> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> + </ClCompile> + <ClCompile Include="trace.cpp" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="CursorWrap.rc" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> + <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <None Include="COMPLETE_REWRITE_SUMMARY.md" /> + <None Include="CRITICAL_BUG_ANALYSIS.md" /> + <None Include="CURSOR_WRAP_FIX_ANALYSIS.md" /> + <None Include="DEBUG_GUIDE.md" /> + <None Include="packages.config" /> + <None Include="VERTICAL_WRAP_BUG_FIX.md" /> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> +</Project> 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 <sstream> +#include <iomanip> +#include <ctime> + +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<GetDpiForMonitorFunc>(GetProcAddress(shcore, "GetDpiForMonitor")); + if (getDpi) + { + getDpi(hMon, 0, &dpiX, &dpiY); // MDT_EFFECTIVE_DPI = 0 + } + FreeLibrary(shcore); + } + } + + int scalingPercent = static_cast<int>((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<CursorWrapCore*>(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<int>(self->m_monitors.size()); + self->m_monitors.push_back(info); + + Logger::info(L"Enumerated monitor {}: hMonitor={}, rect=({},{},{},{}), primary={}", + info.monitorId, reinterpret_cast<uintptr_t>(hMonitor), + mi.rcMonitor.left, mi.rcMonitor.top, mi.rcMonitor.right, mi.rcMonitor.bottom, + info.isPrimary ? L"yes" : L"no"); + } + + return TRUE; + }, reinterpret_cast<LPARAM>(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>(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 <windows.h> +#include <vector> +#include <string> +#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<MonitorInfo>& 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<MonitorInfo> m_monitors; + MonitorTopology m_topology; +}; diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h new file mode 100644 index 0000000000..4274ad714f --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests.h @@ -0,0 +1,213 @@ +#pragma once + +#include <vector> +#include <string> + +// Test case structure for comprehensive monitor layout testing +struct MonitorTestCase +{ + std::string name; + std::string description; + int grid[3][3]; // 3x3 grid representing monitor layout (0 = no monitor, 1-9 = monitor ID) + + // Test scenarios to validate + struct TestScenario + { + int sourceMonitor; // Which monitor to start cursor on (1-based) + int edgeDirection; // 0=top, 1=right, 2=bottom, 3=left + int expectedTargetMonitor; // Expected destination monitor (1-based, -1 = wrap within same monitor) + std::string description; + }; + + std::vector<TestScenario> scenarios; +}; + +// Comprehensive test cases for all possible 3x3 monitor grid configurations +class CursorWrapTestSuite +{ +public: + static std::vector<MonitorTestCase> GetAllTestCases() + { + std::vector<MonitorTestCase> testCases; + + // Test Case 1: Single monitor (center) + testCases.push_back({ + "Single_Center", + "Single monitor in center position", + { + {0, 0, 0}, + {0, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, -1, "Top edge wraps to bottom of same monitor"}, + {1, 1, -1, "Right edge wraps to left of same monitor"}, + {1, 2, -1, "Bottom edge wraps to top of same monitor"}, + {1, 3, -1, "Left edge wraps to right of same monitor"} + } + }); + + // Test Case 2: Two monitors horizontal (left + right) + testCases.push_back({ + "Dual_Horizontal_Left_Right", + "Two monitors: left + right", + { + {0, 0, 0}, + {1, 0, 2}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom of monitor 1"}, + {1, 1, 2, "Monitor 1 right edge moves to monitor 2 left"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left edge wraps to right of monitor 1"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right edge wraps to left of monitor 2"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, 1, "Monitor 2 left edge moves to monitor 1 right"} + } + }); + + // Test Case 3: Two monitors vertical (Monitor 2 above Monitor 1) - CORRECTED FOR USER'S SETUP + testCases.push_back({ + "Dual_Vertical_2_Above_1", + "Two monitors: Monitor 2 (top) above Monitor 1 (bottom/main)", + { + {0, 2, 0}, // Row 0: Monitor 2 (physically top monitor) + {0, 0, 0}, // Row 1: Empty + {0, 1, 0} // Row 2: Monitor 1 (physically bottom/main monitor) + }, + { + // Monitor 1 (bottom/main monitor) tests + {1, 0, 2, "Monitor 1 (bottom) top edge should move to Monitor 2 (top) bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, -1, "Monitor 1 left wraps to right of monitor 1"}, + + // Monitor 2 (top monitor) tests + {2, 0, -1, "Monitor 2 (top) top wraps to bottom of monitor 2"}, + {2, 1, -1, "Monitor 2 right wraps to left of monitor 2"}, + {2, 2, 1, "Monitor 2 (top) bottom edge should move to Monitor 1 (bottom) top"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"} + } + }); + + // Test Case 4: Three monitors L-shape (center + left + top) + testCases.push_back({ + "Triple_L_Shape", + "Three monitors in L-shape: center + left + top", + { + {0, 3, 0}, + {2, 1, 0}, + {0, 0, 0} + }, + { + {1, 0, 3, "Monitor 1 top moves to monitor 3 bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left of monitor 1"}, + {1, 2, -1, "Monitor 1 bottom wraps to top of monitor 1"}, + {1, 3, 2, "Monitor 1 left moves to monitor 2 right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom of monitor 2"}, + {2, 1, 1, "Monitor 2 right moves to monitor 1 left"}, + {2, 2, -1, "Monitor 2 bottom wraps to top of monitor 2"}, + {2, 3, -1, "Monitor 2 left wraps to right of monitor 2"}, + {3, 0, -1, "Monitor 3 top wraps to bottom of monitor 3"}, + {3, 1, -1, "Monitor 3 right wraps to left of monitor 3"}, + {3, 2, 1, "Monitor 3 bottom moves to monitor 1 top"}, + {3, 3, -1, "Monitor 3 left wraps to right of monitor 3"} + } + }); + + // Test Case 5: Three monitors horizontal (left + center + right) + testCases.push_back({ + "Triple_Horizontal", + "Three monitors horizontal: left + center + right", + { + {0, 0, 0}, + {1, 2, 3}, + {0, 0, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, 2, "Monitor 1 right moves to monitor 2"}, + {1, 2, -1, "Monitor 1 bottom wraps to top"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, -1, "Monitor 2 top wraps to bottom"}, + {2, 1, 3, "Monitor 2 right moves to monitor 3"}, + {2, 2, -1, "Monitor 2 bottom wraps to top"}, + {2, 3, 1, "Monitor 2 left moves to monitor 1"}, + {3, 0, -1, "Monitor 3 top wraps to bottom"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, 2, "Monitor 3 left moves to monitor 2"} + } + }); + + // Test Case 6: Three monitors vertical (top + center + bottom) + testCases.push_back({ + "Triple_Vertical", + "Three monitors vertical: top + center + bottom", + { + {0, 1, 0}, + {0, 2, 0}, + {0, 3, 0} + }, + { + {1, 0, -1, "Monitor 1 top wraps to bottom"}, + {1, 1, -1, "Monitor 1 right wraps to left"}, + {1, 2, 2, "Monitor 1 bottom moves to monitor 2"}, + {1, 3, -1, "Monitor 1 left wraps to right"}, + {2, 0, 1, "Monitor 2 top moves to monitor 1"}, + {2, 1, -1, "Monitor 2 right wraps to left"}, + {2, 2, 3, "Monitor 2 bottom moves to monitor 3"}, + {2, 3, -1, "Monitor 2 left wraps to right"}, + {3, 0, 2, "Monitor 3 top moves to monitor 2"}, + {3, 1, -1, "Monitor 3 right wraps to left"}, + {3, 2, -1, "Monitor 3 bottom wraps to top"}, + {3, 3, -1, "Monitor 3 left wraps to right"} + } + }); + + return testCases; + } + + // Helper function to print test case in a readable format + static std::string FormatTestCase(const MonitorTestCase& testCase) + { + std::string result = "Test Case: " + testCase.name + "\n"; + result += "Description: " + testCase.description + "\n"; + result += "Layout:\n"; + + for (int row = 0; row < 3; row++) + { + result += " "; + for (int col = 0; col < 3; col++) + { + if (testCase.grid[row][col] == 0) + { + result += ". "; + } + else + { + result += std::to_string(testCase.grid[row][col]) + " "; + } + } + result += "\n"; + } + + result += "Test Scenarios:\n"; + for (const auto& scenario : testCase.scenarios) + { + result += " - " + scenario.description + "\n"; + } + + return result; + } + + // Helper function to validate a specific test case against actual behavior + static bool ValidateTestCase(const MonitorTestCase& testCase) + { + // This would be called with actual CursorWrap instance to validate behavior + // For now, just return true - this would need actual implementation + return true; + } +}; \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CURSOR_WRAP_TESTS.md b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CURSOR_WRAP_TESTS.md new file mode 100644 index 0000000000..3ca8229b9f --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/CURSOR_WRAP_TESTS.md @@ -0,0 +1,66 @@ +# Validating/Testing Cursor Wrap. + +If a user determines that CursorWrap isn't working on their PC there are some steps you can take to determine why CursorWrap functionality might not be working as expected. + +Note that for a single monitor cursor wrap should always work since all monitor edges are not touching/overlapping with other monitors - the cursor should always wrap to the opposite edge of the same monitor. + +Multi-monitor is supported through building a polygon shape for the outer edges of all monitors, inner monitor edges are ignored, movement of the cursor from one monitor to an adjacent monitor is handled by Windows - CursorWrap doesn't get involved in monitor-to-monitor movement, only outer-edges. + +We have seen a couple of computer setups that have multi-monitors where CursorWrap doesn't work as expected, this appears to be due to a monitor not being 'snapped' to the edge of an adjacent monitor - If you use Display Settings in Windows you can move monitors around, these appear to 'snap' to an edge of an existing monitor. + +What to do if Cursor Wrapping isn't working as expected ? + +1. in the CursorWrapTests folder there's a PowerShell script called `Capture-MonitorLayout.ps1` - this will generate a .json file in the form `"$($env:USERNAME)_monitor_layout.json` - the .json file contains an array of monitors, their position, size, dpi, and scaling. +2. Use `CursorWrapTests/monitor_layout_tests.py` to validate the monitor layout/wrapping behavior (uses the json file from point 1 above). +3. Use `analyze_test_results.py` to analyze the monitor layout test output and provide information about why wrapping might not be working + +To run `monitor_layout_tests.py` you will need Python installed on your PC. + +Run `python monitor_layout_tests.py --layout-file <path to json file>` you can also add an optional `--verbose` to view verbose output. + +monitor_layout_tests.py will produce an output file called `test_report.json` - the contents of the file will look like this (this is from a single monitor test). + +```json +{ + "summary": { + "total_configs": 1, + "passed": 1, + "failed": 0, + "total_issues": 0, + "pass_rate": "100.00%" + }, + "failures": [], + "recommendations": [ + "All tests passed - edge detection logic is working correctly!" + ] +} +``` + +If there are failures (the failures array is not empty) you can run the second python application called `analyze_test_results.py` + +Supported options include: +```text + -h, --help show this help message and exit + --report REPORT Path to test report JSON file + --detailed Show detailed failure listing + --copilot Generate GitHub Copilot-friendly fix prompt + ``` + +Running the analyze_test_results.py script against our single monitor test results produces the following: + +```text +python .\analyze_test_results.py --detailed +================================================================================ +CURSORWRAP TEST RESULTS ANALYSIS +================================================================================ + +Total Configurations Tested: 1 +Passed: 1 (100.00%) +Failed: 0 +Total Issues: 0 + +✓ ALL TESTS PASSED! Edge detection logic is working correctly. + +✓ No failures to analyze! +``` + diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/Capture-MonitorLayout.ps1 b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/Capture-MonitorLayout.ps1 new file mode 100644 index 0000000000..10c0f252f3 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/Capture-MonitorLayout.ps1 @@ -0,0 +1,327 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Captures the current monitor layout configuration for CursorWrap testing. + +.DESCRIPTION + Queries Windows for all connected monitors and saves their configuration + (position, size, DPI, primary status) to a JSON file that can be used + for testing the CursorWrap module. + + By default, potentially identifying information (computer name, user name, + device names) is anonymized to protect privacy when sharing layout files. + +.PARAMETER OutputPath + Path where the JSON file will be saved. Default: cursorwrap_monitor_layout.json + +.PARAMETER AddUserMachineNames + Include computer name and user name in the output. By default these are + blank to protect privacy when sharing layout files. + +.PARAMETER AddDeviceNames + Include device names (e.g., \\.\DISPLAY1) in the output. By default these + are anonymized to "DISPLAY1", "DISPLAY2", etc. to reduce fingerprinting. + +.PARAMETER Help + Show this help message and exit. + +.EXAMPLE + .\Capture-MonitorLayout.ps1 + Captures layout with privacy-safe defaults (no user/machine names). + +.EXAMPLE + .\Capture-MonitorLayout.ps1 -OutputPath "my_setup.json" + Saves to a custom filename. + +.EXAMPLE + .\Capture-MonitorLayout.ps1 -AddUserMachineNames + Includes computer name and user name in the output. + +.EXAMPLE + .\Capture-MonitorLayout.ps1 -AddUserMachineNames -AddDeviceNames + Includes all identifying information (useful for personal debugging). +#> + +param( + [Parameter(Mandatory=$false)] + [string]$OutputPath = "cursorwrap_monitor_layout.json", + + [Parameter(Mandatory=$false)] + [switch]$AddUserMachineNames, + + [Parameter(Mandatory=$false)] + [switch]$AddDeviceNames, + + [Parameter(Mandatory=$false)] + [Alias("h", "?")] + [switch]$Help +) + +# Show help if requested +if ($Help) { + Get-Help $MyInvocation.MyCommand.Path -Detailed + exit 0 +} + +# Add Windows Forms for screen enumeration +Add-Type -AssemblyName System.Windows.Forms + +function Get-MonitorDPI { + param([System.Windows.Forms.Screen]$Screen) + + # Try to get DPI using P/Invoke with multiple methods + Add-Type @" +using System; +using System.Runtime.InteropServices; +public class DisplayConfig { + [DllImport("user32.dll")] + public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); + + [DllImport("shcore.dll")] + public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY); + + [DllImport("user32.dll")] + public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi); + + [DllImport("user32.dll")] + public static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags); + + [DllImport("gdi32.dll")] + public static extern int GetDeviceCaps(IntPtr hdc, int nIndex); + + [DllImport("user32.dll")] + public static extern IntPtr GetDC(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC); + + [StructLayout(LayoutKind.Sequential)] + public struct POINT { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + public struct MONITORINFOEX { + public int cbSize; + public RECT rcMonitor; + public RECT rcWork; + public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string szDevice; + } + + [StructLayout(LayoutKind.Sequential)] + public struct RECT { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + public const uint MONITOR_DEFAULTTOPRIMARY = 1; + public const int MDT_EFFECTIVE_DPI = 0; + public const int MDT_ANGULAR_DPI = 1; + public const int MDT_RAW_DPI = 2; + public const int LOGPIXELSX = 88; + public const int LOGPIXELSY = 90; +} +"@ -ErrorAction SilentlyContinue + + try { + $point = New-Object DisplayConfig+POINT + $point.X = $Screen.Bounds.Left + ($Screen.Bounds.Width / 2) + $point.Y = $Screen.Bounds.Top + ($Screen.Bounds.Height / 2) + + $hMonitor = [DisplayConfig]::MonitorFromPoint($point, 1) + + # Method 1: Try GetDpiForMonitor (Windows 8.1+) + [uint]$dpiX = 0 + [uint]$dpiY = 0 + $result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 0, [ref]$dpiX, [ref]$dpiY) + + if ($result -eq 0 -and $dpiX -gt 0) { + Write-Verbose "DPI detected via GetDpiForMonitor: $dpiX" + return $dpiX + } + + # Method 2: Try RAW DPI + $result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 2, [ref]$dpiX, [ref]$dpiY) + if ($result -eq 0 -and $dpiX -gt 0) { + Write-Verbose "DPI detected via RAW DPI: $dpiX" + return $dpiX + } + + # Method 3: Try getting device context DPI (legacy method) + $hdc = [DisplayConfig]::GetDC([IntPtr]::Zero) + if ($hdc -ne [IntPtr]::Zero) { + $dpiValue = [DisplayConfig]::GetDeviceCaps($hdc, 88) # LOGPIXELSX + [DisplayConfig]::ReleaseDC([IntPtr]::Zero, $hdc) + if ($dpiValue -gt 0) { + Write-Verbose "DPI detected via GetDeviceCaps: $dpiValue" + return $dpiValue + } + } + } + catch { + Write-Verbose "DPI detection error: $($_.Exception.Message)" + } + + Write-Warning "Could not detect DPI for $($Screen.DeviceName), using default 96 DPI" + return 96 # Standard 96 DPI (100% scaling) +} + +function Capture-MonitorLayout { + Write-Host "Capturing monitor layout..." -ForegroundColor Cyan + Write-Host "=" * 80 + + $screens = [System.Windows.Forms.Screen]::AllScreens + $monitors = @() + $monitorIndex = 1 + + foreach ($screen in $screens) { + $isPrimary = $screen.Primary + $bounds = $screen.Bounds + $dpi = Get-MonitorDPI -Screen $screen + + # Anonymize device name by default to reduce fingerprinting + $deviceName = if ($AddDeviceNames) { + $screen.DeviceName + } else { + "DISPLAY$monitorIndex" + } + + $monitor = [ordered]@{ + left = $bounds.Left + top = $bounds.Top + right = $bounds.Right + bottom = $bounds.Bottom + width = $bounds.Width + height = $bounds.Height + dpi = $dpi + scaling_percent = [math]::Round(($dpi / 96.0) * 100, 0) + primary = $isPrimary + device_name = $deviceName + } + + $monitors += $monitor + $monitorIndex++ + + # Display info + $primaryTag = if ($isPrimary) { " [PRIMARY]" } else { "" } + $scaling = [math]::Round(($dpi / 96.0) * 100, 0) + + Write-Host "`nMonitor $($monitors.Count)$primaryTag" -ForegroundColor Green + Write-Host " Device: $($screen.DeviceName)" + Write-Host " Position: ($($bounds.Left), $($bounds.Top))" + Write-Host " Size: $($bounds.Width)x$($bounds.Height)" + Write-Host " DPI: $dpi ($scaling% scaling)" + Write-Host " Bounds: [$($bounds.Left), $($bounds.Top), $($bounds.Right), $($bounds.Bottom)]" + } + + # Create output object with privacy-safe defaults + $output = [ordered]@{ + captured_at = (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz") + computer_name = if ($AddUserMachineNames) { $env:COMPUTERNAME } else { "" } + user_name = if ($AddUserMachineNames) { $env:USERNAME } else { "" } + monitor_count = $monitors.Count + monitors = $monitors + } + + # Save to JSON + $json = $output | ConvertTo-Json -Depth 10 + Set-Content -Path $OutputPath -Value $json -Encoding UTF8 + + Write-Host "`n" + ("=" * 80) + Write-Host "Monitor layout saved to: $OutputPath" -ForegroundColor Green + Write-Host "Total monitors captured: $($monitors.Count)" -ForegroundColor Cyan + Write-Host "`nYou can now use this file with the test script:" -ForegroundColor Yellow + Write-Host " python monitor_layout_tests.py --layout-file $OutputPath" -ForegroundColor White + + return $output +} + +# Main execution +try { + $layout = Capture-MonitorLayout + + # Display summary + Write-Host "`n" + ("=" * 80) + Write-Host "SUMMARY" -ForegroundColor Cyan + Write-Host ("=" * 80) + if ($layout.computer_name) { + Write-Host "Configuration Name: $($layout.computer_name)" + } + Write-Host "Captured: $($layout.captured_at)" + Write-Host "Monitors: $($layout.monitor_count)" + + # Privacy notice + if (-not $AddUserMachineNames -or -not $AddDeviceNames) { + Write-Host "`nPrivacy: " -NoNewline -ForegroundColor Yellow + $privacyNotes = @() + if (-not $AddUserMachineNames) { $privacyNotes += "user/machine names excluded" } + if (-not $AddDeviceNames) { $privacyNotes += "device names anonymized" } + Write-Host ($privacyNotes -join ", ") -ForegroundColor Yellow + Write-Host " Use -AddUserMachineNames and/or -AddDeviceNames to include." -ForegroundColor DarkGray + } + + # Calculate desktop dimensions + $widths = @($layout.monitors | ForEach-Object { $_.width }) + $heights = @($layout.monitors | ForEach-Object { $_.height }) + + $totalWidth = ($widths | Measure-Object -Sum).Sum + $maxHeight = ($heights | Measure-Object -Maximum).Maximum + + Write-Host "Total Desktop Width: $totalWidth pixels" + Write-Host "Max Desktop Height: $maxHeight pixels" + + # Analyze potential coordinate issues + Write-Host "`n" + ("=" * 80) + Write-Host "COORDINATE ANALYSIS" -ForegroundColor Cyan + Write-Host ("=" * 80) + + # Check for gaps between monitors + if ($layout.monitor_count -gt 1) { + $hasGaps = $false + for ($i = 0; $i -lt $layout.monitor_count - 1; $i++) { + $m1 = $layout.monitors[$i] + for ($j = $i + 1; $j -lt $layout.monitor_count; $j++) { + $m2 = $layout.monitors[$j] + + # Check horizontal gap + $hGap = [Math]::Min([Math]::Abs($m1.right - $m2.left), [Math]::Abs($m2.right - $m1.left)) + # Check vertical overlap + $vOverlapStart = [Math]::Max($m1.top, $m2.top) + $vOverlapEnd = [Math]::Min($m1.bottom, $m2.bottom) + $vOverlap = $vOverlapEnd - $vOverlapStart + + if ($hGap -gt 50 -and $vOverlap -gt 0) { + Write-Host "⚠ Gap detected between Monitor $($i+1) and Monitor $($j+1): ${hGap}px horizontal gap" -ForegroundColor Yellow + Write-Host " Vertical overlap: ${vOverlap}px" -ForegroundColor Yellow + Write-Host " This may indicate a Windows coordinate bug if monitors appear snapped in Display Settings" -ForegroundColor Yellow + $hasGaps = $true + } + } + } + if (-not $hasGaps) { + Write-Host "✓ No unexpected gaps detected" -ForegroundColor Green + } + } + + # DPI/Scaling notes + Write-Host "`nDPI/Scaling Impact on Coordinates:" -ForegroundColor Cyan + Write-Host "• Coordinate values (left, top, right, bottom) are in LOGICAL PIXELS" + Write-Host "• These are DPI-independent virtual coordinates" + Write-Host "• Physical pixels = Logical pixels × (DPI / 96)" + Write-Host "• Example: 1920 logical pixels at 150% scaling = 1920 × 1.5 = 2880 physical pixels" + Write-Host "• Windows snaps monitors using logical pixel coordinates" + Write-Host "• If monitors appear snapped but coordinates show gaps, this is a Windows bug" + + exit 0 +} +catch { + Write-Host "`nError capturing monitor layout:" -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray + exit 1 +} diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/analyze_test_results.py b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/analyze_test_results.py new file mode 100644 index 0000000000..f045119dee --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/analyze_test_results.py @@ -0,0 +1,430 @@ +""" +Test Results Analyzer for CursorWrap Monitor Layout Tests + +Analyzes test_report.json and provides detailed explanations of failures, +patterns, and recommendations. +""" + +import json +import sys +from collections import defaultdict +from typing import Dict, List, Any + + +class TestResultAnalyzer: + """Analyzes test results and provides insights""" + + def __init__(self, report_path: str = "test_report.json"): + with open(report_path, 'r') as f: + self.report = json.load(f) + + self.failures = self.report.get('failures', []) + self.summary = self.report.get('summary', {}) + self.recommendations = self.report.get('recommendations', []) + + def print_overview(self): + """Print test overview""" + print("=" * 80) + print("CURSORWRAP TEST RESULTS ANALYSIS") + print("=" * 80) + print(f"\nTotal Configurations Tested: {self.summary.get('total_configs', 0)}") + print(f"Passed: {self.summary.get('passed', 0)} ({self.summary.get('pass_rate', 'N/A')})") + print(f"Failed: {self.summary.get('failed', 0)}") + print(f"Total Issues: {self.summary.get('total_issues', 0)}") + + if self.summary.get('passed', 0) == self.summary.get('total_configs', 0): + print("\n✓ ALL TESTS PASSED! Edge detection logic is working correctly.") + return + + print(f"\n⚠ {self.summary.get('total_issues', 0)} issues detected\n") + + def analyze_failure_patterns(self): + """Analyze and categorize failure patterns""" + print("=" * 80) + print("FAILURE PATTERN ANALYSIS") + print("=" * 80) + + # Group by test type + by_test_type = defaultdict(list) + for failure in self.failures: + by_test_type[failure['test_name']].append(failure) + + # Group by configuration + by_config = defaultdict(list) + for failure in self.failures: + by_config[failure['monitor_config']].append(failure) + + print(f"\n1. Failures by Test Type:") + for test_type, failures in sorted(by_test_type.items(), key=lambda x: len(x[1]), reverse=True): + print(f" • {test_type}: {len(failures)} failures") + + print(f"\n2. Configurations with Failures:") + for config, failures in sorted(by_config.items(), key=lambda x: len(x[1]), reverse=True): + print(f" • {config}") + print(f" {len(failures)} issues") + + return by_test_type, by_config + + def analyze_wrap_calculation_failures(self, failures: List[Dict[str, Any]]): + """Detailed analysis of wrap calculation failures""" + print("\n" + "=" * 80) + print("WRAP CALCULATION FAILURE ANALYSIS") + print("=" * 80) + + # Analyze cursor positions + positions = [] + configs = set() + + for failure in failures: + configs.add(failure['monitor_config']) + # Extract position from expected message + if 'test_point' in failure.get('details', {}): + pos = failure['details']['test_point'] + positions.append(pos) + + print(f"\nAffected Configurations: {len(configs)}") + for config in sorted(configs): + print(f" • {config}") + + if positions: + print(f"\nFailed Test Points: {len(positions)}") + # Analyze if failures are at edges + edge_positions = defaultdict(int) + for x, y in positions: + # Simplified edge detection + if x <= 10: + edge_positions['left edge'] += 1 + elif y <= 10: + edge_positions['top edge'] += 1 + else: + edge_positions['other'] += 1 + + if edge_positions: + print("\nPosition Distribution:") + for pos_type, count in edge_positions.items(): + print(f" • {pos_type}: {count}") + + def explain_common_issues(self): + """Explain common issues found in results""" + print("\n" + "=" * 80) + print("COMMON ISSUE EXPLANATIONS") + print("=" * 80) + + has_wrap_failures = any(f['test_name'] == 'wrap_calculation' for f in self.failures) + has_edge_failures = any(f['test_name'] == 'single_monitor_edges' for f in self.failures) + has_touching_failures = any(f['test_name'] == 'touching_monitors' for f in self.failures) + + if has_wrap_failures: + print("\n⚠ WRAP CALCULATION FAILURES") + print("-" * 80) + print("Issue: Cursor is on an outer edge but wrapping is not occurring.") + print("\nLikely Causes:") + print(" 1. Partial Overlap Problem:") + print(" • When monitors have different sizes (e.g., 4K + 1080p)") + print(" • Only part of an edge is actually adjacent to another monitor") + print(" • Current code marks the ENTIRE edge as non-outer if ANY part is adjacent") + print(" • This prevents wrapping even in regions where it should occur") + print("\n 2. Edge Detection Logic:") + print(" • Check IdentifyOuterEdges() in MonitorTopology.cpp") + print(" • Consider segmenting edges based on actual overlap regions") + print("\n 3. Test Point Selection:") + print(" • Failures may be at corners or quarter points") + print(" • Indicates edge behavior varies along its length") + + if has_edge_failures: + print("\n⚠ SINGLE MONITOR EDGE FAILURES") + print("-" * 80) + print("Issue: Single monitor should have exactly 4 outer edges.") + print("\nThis indicates a fundamental problem in edge detection baseline.") + + if has_touching_failures: + print("\n⚠ TOUCHING MONITORS FAILURES") + print("-" * 80) + print("Issue: Adjacent monitors not detected correctly.") + print("\nCheck EdgesAreAdjacent() logic and 50px tolerance settings.") + + def print_recommendations(self): + """Print recommendations from the report""" + if not self.recommendations: + return + + print("\n" + "=" * 80) + print("RECOMMENDATIONS") + print("=" * 80) + + for i, rec in enumerate(self.recommendations, 1): + print(f"\n{i}. {rec}") + + def detailed_failure_dump(self): + """Print all failure details""" + print("\n" + "=" * 80) + print("DETAILED FAILURE LISTING") + print("=" * 80) + + for i, failure in enumerate(self.failures, 1): + print(f"\n[{i}] {failure['test_name']}") + print(f"Configuration: {failure['monitor_config']}") + print(f"Expected: {failure['expected']}") + print(f"Actual: {failure['actual']}") + + if 'details' in failure: + details = failure['details'] + if 'edge' in details: + edge = details['edge'] + print(f"Edge: {edge.get('edge_type', 'N/A')} at position {edge.get('position', 'N/A')}, " + f"range [{edge.get('range_start', 'N/A')}, {edge.get('range_end', 'N/A')}]") + if 'test_point' in details: + print(f"Test Point: {details['test_point']}") + print("-" * 80) + + def generate_github_copilot_prompt(self): + """Generate a prompt suitable for GitHub Copilot to fix the issues""" + print("\n" + "=" * 80) + print("GITHUB COPILOT FIX PROMPT") + print("=" * 80) + print("\n```markdown") + print("# CursorWrap Edge Detection Bug Report") + print() + print("## Test Results Summary") + print(f"- Total Configurations Tested: {self.summary.get('total_configs', 0)}") + print(f"- Pass Rate: {self.summary.get('pass_rate', 'N/A')}") + print(f"- Failed Tests: {self.summary.get('failed', 0)}") + print(f"- Total Issues: {self.summary.get('total_issues', 0)}") + print() + + # Group failures + by_test_type = defaultdict(list) + for failure in self.failures: + by_test_type[failure['test_name']].append(failure) + + print("## Critical Issues Found") + print() + + # Analyze wrap calculation failures + if 'wrap_calculation' in by_test_type: + failures = by_test_type['wrap_calculation'] + configs = set(f['monitor_config'] for f in failures) + + print("### 1. Wrap Calculation Failures (PARTIAL OVERLAP BUG)") + print() + print(f"**Count**: {len(failures)} failures across {len(configs)} configuration(s)") + print() + print("**Affected Configurations**:") + for config in sorted(configs): + print(f"- {config}") + print() + + print("**Root Cause Analysis**:") + print() + print("The current implementation in `MonitorTopology::IdentifyOuterEdges()` marks an") + print("ENTIRE edge as non-outer if ANY portion of that edge is adjacent to another monitor.") + print() + print("**Problem Scenario**: 1080p monitor + 4K monitor at bottom-right") + print("```") + print("4K Monitor (3840x2160 at 0,0)") + print("┌────────────────────────────────────────┐") + print("│ │ <- Y: 0-1080 NO adjacent monitor") + print("│ │ RIGHT EDGE SHOULD BE OUTER") + print("│ │") + print("│ │┌──────────┐") + print("│ ││ 1080p │ <- Y: 1080-2160 HAS adjacent") + print("└────────────────────────────────────────┘│ at │ RIGHT EDGE NOT OUTER") + print(" │ (3840, │") + print(" │ 1080) │") + print(" └──────────┘") + print("```") + print() + print("**Current Behavior**: Right edge of 4K monitor is marked as NON-OUTER for entire") + print("range (Y: 0-2160) because it detects adjacency in the bottom portion (Y: 1080-2160).") + print() + print("**Expected Behavior**: Right edge should be:") + print("- OUTER from Y: 0 to Y: 1080 (no adjacent monitor)") + print("- NON-OUTER from Y: 1080 to Y: 2160 (adjacent to 1080p monitor)") + print() + + print("**Failed Test Examples**:") + print() + for i, failure in enumerate(failures[:3], 1): # Show first 3 + details = failure.get('details', {}) + test_point = details.get('test_point', 'N/A') + edge = details.get('edge', {}) + edge_type = edge.get('edge_type', 'N/A') + position = edge.get('position', 'N/A') + range_start = edge.get('range_start', 'N/A') + range_end = edge.get('range_end', 'N/A') + + print(f"{i}. **Configuration**: {failure['monitor_config']}") + print(f" - Test Point: {test_point}") + print(f" - Edge: {edge_type} at X={position}, Y range=[{range_start}, {range_end}]") + print(f" - Expected: Cursor wraps to opposite edge") + print(f" - Actual: No wrap occurred (edge incorrectly marked as non-outer)") + print() + + if len(failures) > 3: + print(f" ... and {len(failures) - 3} more similar failures") + print() + + # Other failure types + if 'single_monitor_edges' in by_test_type: + print("### 2. Single Monitor Edge Detection Failures") + print() + print(f"**Count**: {len(by_test_type['single_monitor_edges'])} failures") + print() + print("Single monitor configurations should have exactly 4 outer edges.") + print("This indicates a fundamental problem in baseline edge detection.") + print() + + if 'touching_monitors' in by_test_type: + print("### 3. Adjacent Monitor Detection Failures") + print() + print(f"**Count**: {len(by_test_type['touching_monitors'])} failures") + print() + print("Adjacent monitors not being detected correctly by EdgesAreAdjacent().") + print() + + print("## Required Code Changes") + print() + print("### File: `MonitorTopology.cpp`") + print() + print("**Change 1**: Modify `IdentifyOuterEdges()` to support partial edge adjacency") + print() + print("Instead of marking entire edges as outer/non-outer, the code needs to:") + print() + print("1. **Segment edges** based on actual overlap regions with adjacent monitors") + print("2. Create **sub-edges** for portions of an edge that have different outer status") + print("3. Update `IsOnOuterEdge()` to check if the **cursor's specific position** is on an outer portion") + print() + print("**Proposed Approach**:") + print() + print("```cpp") + print("// Instead of: edge.isOuter = true/false for entire edge") + print("// Use: Store list of outer ranges for each edge") + print() + print("struct MonitorEdge {") + print(" // ... existing fields ...") + print(" std::vector<std::pair<int, int>> outerRanges; // Ranges where edge is outer") + print("};") + print() + print("// In IdentifyOuterEdges():") + print("// For each edge, find ALL adjacent opposite edges") + print("// Calculate which portions of the edge have NO adjacent opposite") + print("// Store these as outer ranges") + print() + print("// In IsOnOuterEdge():") + print("// Check if cursor position falls within any outer range") + print("if (edge.type == EdgeType::Left || edge.type == EdgeType::Right) {") + print(" // Check if cursorPos.y is in any outer range") + print("} else {") + print(" // Check if cursorPos.x is in any outer range") + print("}") + print("```") + print() + print("**Change 2**: Update `EdgesAreAdjacent()` validation") + print() + print("The 50px tolerance logic is correct but needs to return overlap range info:") + print() + print("```cpp") + print("struct AdjacencyResult {") + print(" bool isAdjacent;") + print(" int overlapStart; // Where the adjacency begins") + print(" int overlapEnd; // Where the adjacency ends") + print("};") + print() + print("AdjacencyResult CheckEdgeAdjacency(const MonitorEdge& edge1, ") + print(" const MonitorEdge& edge2, ") + print(" int tolerance);") + print("```") + print() + print("## Test Validation") + print() + print("After implementing changes, run:") + print("```bash") + print("python monitor_layout_tests.py --max-monitors 10") + print("```") + print() + print("Expected results:") + print("- All 21+ configurations should pass") + print("- Specifically, the 4K+1080p configuration should pass all 5 test points per edge") + print("- Wrap calculation should work correctly at partial overlap boundaries") + print() + print("## Files to Modify") + print() + print("1. `MonitorTopology.h` - Update MonitorEdge structure") + print("2. `MonitorTopology.cpp` - Implement segmented edge detection") + print(" - `IdentifyOuterEdges()` - Main logic change") + print(" - `IsOnOuterEdge()` - Check position against ranges") + print(" - `EdgesAreAdjacent()` - Optionally return range info") + print() + print("```") + + def run_analysis(self, detailed: bool = False, copilot_mode: bool = False): + """Run complete analysis""" + if copilot_mode: + self.generate_github_copilot_prompt() + return + + self.print_overview() + + if not self.failures: + print("\n✓ No failures to analyze!") + return + + by_test_type, by_config = self.analyze_failure_patterns() + + # Specific analysis for wrap calculation failures + if 'wrap_calculation' in by_test_type: + self.analyze_wrap_calculation_failures(by_test_type['wrap_calculation']) + + self.explain_common_issues() + self.print_recommendations() + + if detailed: + self.detailed_failure_dump() + + +def main(): + """Main entry point""" + import argparse + + parser = argparse.ArgumentParser( + description="Analyze CursorWrap test results" + ) + parser.add_argument( + "--report", + default="test_report.json", + help="Path to test report JSON file" + ) + parser.add_argument( + "--detailed", + action="store_true", + help="Show detailed failure listing" + ) + parser.add_argument( + "--copilot", + action="store_true", + help="Generate GitHub Copilot-friendly fix prompt" + ) + + args = parser.parse_args() + + try: + analyzer = TestResultAnalyzer(args.report) + analyzer.run_analysis(detailed=args.detailed, copilot_mode=args.copilot) + + # Exit with error code if there were failures + sys.exit(0 if not analyzer.failures else 1) + + except FileNotFoundError: + print(f"Error: Could not find report file: {args.report}") + print("\nRun monitor_layout_tests.py first to generate the report.") + sys.exit(1) + except json.JSONDecodeError: + print(f"Error: Invalid JSON in report file: {args.report}") + sys.exit(1) + except Exception as e: + print(f"Error analyzing report: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/modules/MouseUtils/CursorWrap/CursorWrapTests/monitor_layout_tests.py b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/monitor_layout_tests.py new file mode 100644 index 0000000000..0bb110faec --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/CursorWrapTests/monitor_layout_tests.py @@ -0,0 +1,892 @@ +""" +Monitor Layout Edge Detection Test Suite for CursorWrap + +This script validates the edge detection and wrapping logic across thousands of +monitor configurations without requiring the full PowerToys build environment. + +Tests: +- 1-4 monitor configurations +- Common resolutions and DPI scales +- Various arrangements (horizontal, vertical, L-shape, grid) +- Edge detection (touching vs. gap) +- Wrap calculations + +Output: JSON report with failures for GitHub Copilot analysis +""" + +import json +from dataclasses import dataclass, asdict +from typing import List, Tuple, Dict, Optional +from enum import Enum +import sys + +# ============================================================================ +# Data Structures (mirrors C++ implementation) +# ============================================================================ + + +@dataclass +class MonitorInfo: + """Represents a physical monitor""" + left: int + top: int + right: int + bottom: int + dpi: int = 96 + primary: bool = False + + @property + def width(self) -> int: + return self.right - self.left + + @property + def height(self) -> int: + return self.bottom - self.top + + @property + def center_x(self) -> int: + return (self.left + self.right) // 2 + + @property + def center_y(self) -> int: + return (self.top + self.bottom) // 2 + + +class EdgeType(Enum): + LEFT = "Left" + RIGHT = "Right" + TOP = "Top" + BOTTOM = "Bottom" + + +@dataclass +class Edge: + """Represents a monitor edge""" + edge_type: EdgeType + position: int # x for vertical, y for horizontal + range_start: int + range_end: int + monitor_index: int + + def overlaps(self, other: 'Edge', tolerance: int = 1) -> bool: + """Check if two edges overlap in their perpendicular range""" + if self.edge_type != other.edge_type: + return False + if abs(self.position - other.position) > tolerance: + return False + return not ( + self.range_end <= other.range_start or other.range_end <= self.range_start) + + +@dataclass +class TestFailure: + """Records a test failure for analysis""" + test_name: str + monitor_config: str + expected: str + actual: str + details: Dict + +# ============================================================================ +# Edge Detection Logic (Python implementation of C++ logic) +# ============================================================================ + + +class MonitorTopology: + """Implements the edge detection logic to be validated""" + + ADJACENCY_TOLERANCE = 50 # Pixels - tolerance for detecting adjacent edges (matches C++ implementation) + EDGE_THRESHOLD = 1 # Pixels - cursor must be within this distance to trigger wrap + + def __init__(self, monitors: List[MonitorInfo]): + self.monitors = monitors + self.outer_edges: List[Edge] = [] + self._detect_outer_edges() + + def _detect_outer_edges(self): + """Detect which edges are outer (can wrap)""" + all_edges = self._collect_all_edges() + + for edge in all_edges: + if self._is_outer_edge(edge, all_edges): + self.outer_edges.append(edge) + + def _collect_all_edges(self) -> List[Edge]: + """Collect all edges from all monitors""" + edges = [] + + for idx, mon in enumerate(self.monitors): + edges.append( + Edge( + EdgeType.LEFT, + mon.left, + mon.top, + mon.bottom, + idx)) + edges.append( + Edge( + EdgeType.RIGHT, + mon.right, + mon.top, + mon.bottom, + idx)) + edges.append(Edge(EdgeType.TOP, mon.top, mon.left, mon.right, idx)) + edges.append( + Edge( + EdgeType.BOTTOM, + mon.bottom, + mon.left, + mon.right, + idx)) + + return edges + + def _is_outer_edge(self, edge: Edge, all_edges: List[Edge]) -> bool: + """ + Determine if an edge is "outer" (can wrap) + + Rules: + 1. If edge has an adjacent opposite edge (within 50px tolerance AND overlapping range), it's NOT outer + 2. Otherwise, edge IS outer + Note: This matches C++ EdgesAreAdjacent() logic + """ + opposite_type = self._get_opposite_edge_type(edge.edge_type) + + # Find opposite edges that overlap in perpendicular range + opposite_edges = [e for e in all_edges + if e.edge_type == opposite_type + and e.monitor_index != edge.monitor_index + and self._ranges_overlap(edge.range_start, edge.range_end, + e.range_start, e.range_end)] + + if not opposite_edges: + return True # No opposite edges = outer edge + + # Check if any opposite edge is adjacent (within tolerance) + for opp in opposite_edges: + distance = abs(edge.position - opp.position) + if distance <= self.ADJACENCY_TOLERANCE: + return False # Adjacent edge found = not outer + + return True # No adjacent edges = outer + + @staticmethod + def _get_opposite_edge_type(edge_type: EdgeType) -> EdgeType: + """Get the opposite edge type""" + opposites = { + EdgeType.LEFT: EdgeType.RIGHT, + EdgeType.RIGHT: EdgeType.LEFT, + EdgeType.TOP: EdgeType.BOTTOM, + EdgeType.BOTTOM: EdgeType.TOP + } + return opposites[edge_type] + + @staticmethod + def _ranges_overlap( + a_start: int, + a_end: int, + b_start: int, + b_end: int) -> bool: + """Check if two 1D ranges overlap""" + return not (a_end <= b_start or b_end <= a_start) + + def calculate_wrap_position(self, x: int, y: int) -> Tuple[int, int]: + """Calculate where cursor should wrap to""" + # Find which outer edge was crossed and calculate wrap + # At corners, multiple edges may match - try all and return first successful wrap + for edge in self.outer_edges: + if self._is_on_edge(x, y, edge): + new_x, new_y = self._wrap_from_edge(x, y, edge) + if (new_x, new_y) != (x, y): + # Wrap succeeded + return (new_x, new_y) + + return (x, y) # No wrap + + def _is_on_edge(self, x: int, y: int, edge: Edge) -> bool: + """Check if point is on the given edge""" + tolerance = 2 # Pixels + + if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT): + return (abs(x - edge.position) <= tolerance and + edge.range_start <= y <= edge.range_end) + else: + return (abs(y - edge.position) <= tolerance and + edge.range_start <= x <= edge.range_end) + + def _wrap_from_edge(self, x: int, y: int, edge: Edge) -> Tuple[int, int]: + """Calculate wrap destination from an outer edge""" + opposite_type = self._get_opposite_edge_type(edge.edge_type) + + # Find opposite outer edges that overlap + opposite_edges = [e for e in self.outer_edges + if e.edge_type == opposite_type + and self._point_in_range(x, y, e)] + + if not opposite_edges: + return (x, y) # No wrap destination + + # Find closest opposite edge + target_edge = min(opposite_edges, + key=lambda e: abs(e.position - edge.position)) + + # Calculate new position + if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT): + return (target_edge.position, y) + else: + return (x, target_edge.position) + + @staticmethod + def _point_in_range(x: int, y: int, edge: Edge) -> bool: + """Check if point's perpendicular coordinate is in edge's range""" + if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT): + return edge.range_start <= y <= edge.range_end + else: + return edge.range_start <= x <= edge.range_end + +# ============================================================================ +# Test Configuration Generators +# ============================================================================ + + +class TestConfigGenerator: + """Generates comprehensive test configurations""" + + # Common resolutions + RESOLUTIONS = [ + (1920, 1080), # 1080p + (2560, 1440), # 1440p + (3840, 2160), # 4K + (3440, 1440), # Ultrawide + (1920, 1200), # 16:10 + ] + + # DPI scales + DPI_SCALES = [96, 120, 144, 192] # 100%, 125%, 150%, 200% + + @classmethod + def load_from_file(cls, filepath: str) -> List[List[MonitorInfo]]: + """Load monitor configuration from captured JSON file""" + # Handle UTF-8 with BOM (PowerShell default) + with open(filepath, 'r', encoding='utf-8-sig') as f: + data = json.load(f) + + monitors = [] + for mon in data.get('monitors', []): + monitor = MonitorInfo( + left=mon['left'], + top=mon['top'], + right=mon['right'], + bottom=mon['bottom'], + dpi=mon.get('dpi', 96), + primary=mon.get('primary', False) + ) + monitors.append(monitor) + + return [monitors] if monitors else [] + + @classmethod + def generate_all_configs(cls, + max_monitors: int = 4) -> List[List[MonitorInfo]]: + """Generate all test configurations""" + configs = [] + + # Single monitor (baseline) + configs.extend(cls._single_monitor_configs()) + + # Two monitors (most common) + if max_monitors >= 2: + configs.extend(cls._two_monitor_configs()) + + # Three monitors + if max_monitors >= 3: + configs.extend(cls._three_monitor_configs()) + + # Four monitors + if max_monitors >= 4: + configs.extend(cls._four_monitor_configs()) + + # Five+ monitors + if max_monitors >= 5: + configs.extend(cls._five_plus_monitor_configs(max_monitors)) + + return configs + + @classmethod + def _single_monitor_configs(cls) -> List[List[MonitorInfo]]: + """Single monitor configurations""" + configs = [] + + for width, height in cls.RESOLUTIONS[:3]: # Limit for single monitor + for dpi in cls.DPI_SCALES[:2]: # Limit DPI variations + mon = MonitorInfo(0, 0, width, height, dpi, True) + configs.append([mon]) + + return configs + + @classmethod + def _two_monitor_configs(cls) -> List[List[MonitorInfo]]: + """Two monitor configurations""" + configs = [] + # Both 1080p for simplicity + res1, res2 = cls.RESOLUTIONS[0], cls.RESOLUTIONS[0] + + # Horizontal (touching) + configs.append([ + MonitorInfo(0, 0, res1[0], res1[1], primary=True), + MonitorInfo(res1[0], 0, res1[0] + res2[0], res2[1]) + ]) + + # Vertical (touching) + configs.append([ + MonitorInfo(0, 0, res1[0], res1[1], primary=True), + MonitorInfo(0, res1[1], res2[0], res1[1] + res2[1]) + ]) + + # Different resolutions + res_big = cls.RESOLUTIONS[2] # 4K + configs.append([ + MonitorInfo(0, 0, res1[0], res1[1], primary=True), + MonitorInfo(res1[0], 0, res1[0] + res_big[0], res_big[1]) + ]) + + # Offset alignment (common real-world scenario) + offset = 200 + configs.append([ + MonitorInfo(0, offset, res1[0], offset + res1[1], primary=True), + MonitorInfo(res1[0], 0, res1[0] + res2[0], res2[1]) + ]) + + return configs + + @classmethod + def _three_monitor_configs(cls) -> List[List[MonitorInfo]]: + """Three monitor configurations""" + configs = [] + res = cls.RESOLUTIONS[0] # 1080p + + # Linear horizontal + configs.append([ + MonitorInfo(0, 0, res[0], res[1], primary=True), + MonitorInfo(res[0], 0, res[0] * 2, res[1]), + MonitorInfo(res[0] * 2, 0, res[0] * 3, res[1]) + ]) + + # L-shape (common gaming setup) + configs.append([ + MonitorInfo(0, 0, res[0], res[1], primary=True), + MonitorInfo(res[0], 0, res[0] * 2, res[1]), + MonitorInfo(0, res[1], res[0], res[1] * 2) + ]) + + # Vertical stack + configs.append([ + MonitorInfo(0, 0, res[0], res[1], primary=True), + MonitorInfo(0, res[1], res[0], res[1] * 2), + MonitorInfo(0, res[1] * 2, res[0], res[1] * 3) + ]) + + return configs + + @classmethod + def _four_monitor_configs(cls) -> List[List[MonitorInfo]]: + """Four monitor configurations""" + configs = [] + res = cls.RESOLUTIONS[0] # 1080p + + # 2x2 grid (classic) + configs.append([ + MonitorInfo(0, 0, res[0], res[1], primary=True), + MonitorInfo(res[0], 0, res[0] * 2, res[1]), + MonitorInfo(0, res[1], res[0], res[1] * 2), + MonitorInfo(res[0], res[1], res[0] * 2, res[1] * 2) + ]) + + # Linear horizontal + configs.append([ + MonitorInfo(0, 0, res[0], res[1], primary=True), + MonitorInfo(res[0], 0, res[0] * 2, res[1]), + MonitorInfo(res[0] * 2, 0, res[0] * 3, res[1]), + MonitorInfo(res[0] * 3, 0, res[0] * 4, res[1]) + ]) + + return configs + + @classmethod + def _five_plus_monitor_configs(cls, max_count: int) -> List[List[MonitorInfo]]: + """Five to ten monitor configurations""" + configs = [] + res = cls.RESOLUTIONS[0] # 1080p + + # Linear horizontal (5-10 monitors) + for count in range(5, min(max_count + 1, 11)): + monitor_list = [] + for i in range(count): + is_primary = (i == 0) + monitor_list.append( + MonitorInfo(res[0] * i, 0, res[0] * (i + 1), res[1], primary=is_primary) + ) + configs.append(monitor_list) + + return configs + +# ============================================================================ +# Test Validators +# ============================================================================ + + +class EdgeDetectionValidator: + """Validates edge detection logic""" + + @staticmethod + def validate_single_monitor( + monitors: List[MonitorInfo]) -> Optional[TestFailure]: + """Single monitor should have 4 outer edges""" + topology = MonitorTopology(monitors) + expected_count = 4 + actual_count = len(topology.outer_edges) + + if actual_count != expected_count: + return TestFailure( + test_name="single_monitor_edges", + monitor_config=EdgeDetectionValidator._describe_config( + monitors), + expected=f"{expected_count} outer edges", + actual=f"{actual_count} outer edges", + details={"edges": [asdict(e) for e in topology.outer_edges]} + ) + return None + + @staticmethod + def validate_touching_monitors( + monitors: List[MonitorInfo]) -> Optional[TestFailure]: + """Touching monitors should have no gap between them""" + topology = MonitorTopology(monitors) + + # For 2 touching monitors horizontally, expect 6 outer edges (not 8) + if len(monitors) == 2: + # Check if they're aligned horizontally and touching + m1, m2 = monitors + if m1.right == m2.left and m1.top == m2.top and m1.bottom == m2.bottom: + expected = 6 # 2 internal edges removed + actual = len(topology.outer_edges) + if actual != expected: + return TestFailure( + test_name="touching_monitors", + monitor_config=EdgeDetectionValidator._describe_config( + monitors), + expected=f"{expected} outer edges (2 touching edges removed)", + actual=f"{actual} outer edges", + details={"edges": [asdict(e) + for e in topology.outer_edges]} + ) + return None + + @staticmethod + def validate_wrap_calculation( + monitors: List[MonitorInfo]) -> List[TestFailure]: + """Validate cursor wrap calculations""" + failures = [] + topology = MonitorTopology(monitors) + + # Test wrapping at each outer edge with multiple points + for edge in topology.outer_edges: + test_points = EdgeDetectionValidator._get_test_points_on_edge( + edge, monitors) + + for test_point in test_points: + x, y = test_point + + # Check if there's actually a valid wrap destination + # (some outer edges may not have opposite edges due to partial overlap) + opposite_type = topology._get_opposite_edge_type(edge.edge_type) + has_opposite = any( + e.edge_type == opposite_type and + topology._point_in_range(x, y, e) + for e in topology.outer_edges + ) + + if not has_opposite: + # No wrap destination available - this is OK for partial overlaps + continue + + new_x, new_y = topology.calculate_wrap_position(x, y) + + # Verify wrap happened (position changed) + if (new_x, new_y) == (x, y): + # Should have wrapped but didn't + failure = TestFailure( + test_name="wrap_calculation", + monitor_config=EdgeDetectionValidator._describe_config( + monitors), + expected=f"Cursor should wrap from ({x},{y})", + actual=f"No wrap occurred", + details={ + "edge": asdict(edge), + "test_point": (x, y) + } + ) + failures.append(failure) + + return failures + + @staticmethod + def _get_test_points_on_edge( + edge: Edge, monitors: List[MonitorInfo]) -> List[Tuple[int, int]]: + """Get multiple test points on the given edge (5 points: top/left corner, quarter, center, three-quarter, bottom/right corner)""" + monitor = monitors[edge.monitor_index] + points = [] + + if edge.edge_type == EdgeType.LEFT: + x = monitor.left + for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]: + y = int(monitor.top + (monitor.height - 1) * ratio) + points.append((x, y)) + elif edge.edge_type == EdgeType.RIGHT: + x = monitor.right - 1 + for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]: + y = int(monitor.top + (monitor.height - 1) * ratio) + points.append((x, y)) + elif edge.edge_type == EdgeType.TOP: + y = monitor.top + for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]: + x = int(monitor.left + (monitor.width - 1) * ratio) + points.append((x, y)) + elif edge.edge_type == EdgeType.BOTTOM: + y = monitor.bottom - 1 + for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]: + x = int(monitor.left + (monitor.width - 1) * ratio) + points.append((x, y)) + + return points + + @staticmethod + def _describe_config(monitors: List[MonitorInfo]) -> str: + """Generate human-readable config description""" + if len(monitors) == 1: + m = monitors[0] + return f"Single {m.width}x{m.height} @{m.dpi}DPI" + + desc = f"{len(monitors)} monitors: " + for i, m in enumerate(monitors): + desc += f"M{i}({m.width}x{m.height} at {m.left},{m.top}) " + return desc.strip() + +# ============================================================================ +# Test Runner +# ============================================================================ + + +class TestRunner: + """Orchestrates the test execution""" + + def __init__(self, max_monitors: int = 10, verbose: bool = False, layout_file: str = None): + self.max_monitors = max_monitors + self.verbose = verbose + self.layout_file = layout_file + self.failures: List[TestFailure] = [] + self.test_count = 0 + self.passed_count = 0 + + def _print_layout_diagram(self, monitors: List[MonitorInfo]): + """Print a text-based diagram of the monitor layout""" + print("\n" + "=" * 80) + print("MONITOR LAYOUT DIAGRAM") + print("=" * 80) + + # Find bounds of entire desktop + min_x = min(m.left for m in monitors) + min_y = min(m.top for m in monitors) + max_x = max(m.right for m in monitors) + max_y = max(m.bottom for m in monitors) + + # Calculate scale to fit in ~70 chars wide + desktop_width = max_x - min_x + desktop_height = max_y - min_y + + # Scale factor: target 70 chars width + scale = desktop_width / 70.0 + if scale < 1: + scale = 1 + + # Create grid (70 chars wide, proportional height) + grid_width = 70 + grid_height = max(10, int(desktop_height / scale)) + grid_height = min(grid_height, 30) # Cap at 30 lines + + # Initialize grid with spaces + grid = [[' ' for _ in range(grid_width)] for _ in range(grid_height)] + + # Draw each monitor + for idx, mon in enumerate(monitors): + # Convert monitor coords to grid coords + x1 = int((mon.left - min_x) / scale) + y1 = int((mon.top - min_y) / scale) + x2 = int((mon.right - min_x) / scale) + y2 = int((mon.bottom - min_y) / scale) + + # Clamp to grid + x1 = max(0, min(x1, grid_width - 1)) + x2 = max(0, min(x2, grid_width)) + y1 = max(0, min(y1, grid_height - 1)) + y2 = max(0, min(y2, grid_height)) + + # Draw monitor border and fill + char = str(idx) if idx < 10 else chr(65 + idx - 10) # 0-9, then A-Z + + for y in range(y1, y2): + for x in range(x1, x2): + if y < grid_height and x < grid_width: + # Draw borders + if y == y1 or y == y2 - 1: + grid[y][x] = '─' + elif x == x1 or x == x2 - 1: + grid[y][x] = '│' + else: + grid[y][x] = char + + # Draw corners + if y1 < grid_height and x1 < grid_width: + grid[y1][x1] = '┌' + if y1 < grid_height and x2 - 1 < grid_width: + grid[y1][x2 - 1] = '┐' + if y2 - 1 < grid_height and x1 < grid_width: + grid[y2 - 1][x1] = '└' + if y2 - 1 < grid_height and x2 - 1 < grid_width: + grid[y2 - 1][x2 - 1] = '┘' + + # Print grid + print() + for row in grid: + print(''.join(row)) + + # Print legend + print("\n" + "-" * 80) + print("MONITOR DETAILS:") + print("-" * 80) + for idx, mon in enumerate(monitors): + char = str(idx) if idx < 10 else chr(65 + idx - 10) + primary = " [PRIMARY]" if mon.primary else "" + scaling = int((mon.dpi / 96.0) * 100) + print(f" [{char}] Monitor {idx}{primary}") + print(f" Position: ({mon.left}, {mon.top})") + print(f" Size: {mon.width}x{mon.height}") + print(f" DPI: {mon.dpi} ({scaling}% scaling)") + print(f" Bounds: [{mon.left}, {mon.top}, {mon.right}, {mon.bottom}]") + + print("=" * 80 + "\n") + + def run_all_tests(self): + """Execute all test configurations""" + print("=" * 80) + print("CursorWrap Monitor Layout Edge Detection Test Suite") + print("=" * 80) + + # Load or generate configs + if self.layout_file: + print(f"\nLoading monitor layout from {self.layout_file}...") + configs = TestConfigGenerator.load_from_file(self.layout_file) + # Show visual diagram for captured layouts + if configs: + self._print_layout_diagram(configs[0]) + else: + print("\nGenerating test configurations...") + configs = TestConfigGenerator.generate_all_configs(self.max_monitors) + + total_tests = len(configs) + print(f"Testing {total_tests} configuration(s)") + print("=" * 80) + + # Run tests + for i, config in enumerate(configs, 1): + self._run_test_config(config, i, total_tests) + + # Report results + self._print_summary() + self._save_report() + + def _run_test_config( + self, + monitors: List[MonitorInfo], + iteration: int, + total: int): + """Run all validators on a single configuration""" + desc = EdgeDetectionValidator._describe_config(monitors) + + if not self.verbose: + # Minimal output: just progress + progress = (iteration / total) * 100 + print( + f"\r[{iteration}/{total}] {progress:5.1f}% - Testing: {desc[:60]:<60}", end="", flush=True) + else: + print(f"\n[{iteration}/{total}] Testing: {desc}") + + # Run validators + self.test_count += 1 + config_passed = True + + # Single monitor validation + if len(monitors) == 1: + failure = EdgeDetectionValidator.validate_single_monitor(monitors) + if failure: + self.failures.append(failure) + config_passed = False + + # Touching monitors validation (2+ monitors) + if len(monitors) >= 2: + failure = EdgeDetectionValidator.validate_touching_monitors(monitors) + if failure: + self.failures.append(failure) + config_passed = False + + # Wrap calculation validation + wrap_failures = EdgeDetectionValidator.validate_wrap_calculation(monitors) + if wrap_failures: + self.failures.extend(wrap_failures) + config_passed = False + + if config_passed: + self.passed_count += 1 + + if self.verbose and not config_passed: + print(f" ? FAILED ({len([f for f in self.failures if desc in f.monitor_config])} issues)") + elif self.verbose: + print(" ? PASSED") + + def _print_summary(self): + """Print test summary""" + print("\n\n" + "=" * 80) + print("TEST SUMMARY") + print("=" * 80) + print(f"Total Configurations: {self.test_count}") + print(f"Passed: {self.passed_count} ({self.passed_count/self.test_count*100:.1f}%)") + print(f"Failed: {self.test_count - self.passed_count} ({(self.test_count - self.passed_count)/self.test_count*100:.1f}%)") + print(f"Total Issues Found: {len(self.failures)}") + print("=" * 80) + + if self.failures: + print("\n?? FAILURES DETECTED - See test_report.json for details") + print("\nTop 5 Failure Types:") + failure_types = {} + for f in self.failures: + failure_types[f.test_name] = failure_types.get(f.test_name, 0) + 1 + + for test_name, count in sorted(failure_types.items(), key=lambda x: x[1], reverse=True)[:5]: + print(f" - {test_name}: {count} failures") + else: + print("\n? ALL TESTS PASSED!") + + def _save_report(self): + """Save detailed JSON report""" + + # Helper to convert enums to strings + def convert_for_json(obj): + if isinstance(obj, dict): + return {k: convert_for_json(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [convert_for_json(item) for item in obj] + elif isinstance(obj, Enum): + return obj.value + else: + return obj + + report = { + "summary": { + "total_configs": self.test_count, + "passed": self.passed_count, + "failed": self.test_count - self.passed_count, + "total_issues": len(self.failures), + "pass_rate": f"{self.passed_count/self.test_count*100:.2f}%" + }, + "failures": convert_for_json([asdict(f) for f in self.failures]), + "recommendations": self._generate_recommendations() + } + + output_file = "test_report.json" + with open(output_file, "w") as f: + json.dump(report, f, indent=2) + + print(f"\n?? Detailed report saved to: {output_file}") + + def _generate_recommendations(self) -> List[str]: + """Generate recommendations based on failures""" + recommendations = [] + + failure_types = {} + for f in self.failures: + failure_types[f.test_name] = failure_types.get(f.test_name, 0) + 1 + + if "single_monitor_edges" in failure_types: + recommendations.append( + "Single monitor edge detection failing - verify baseline case in MonitorTopology::_detect_outer_edges()" + ) + + if "touching_monitors" in failure_types: + recommendations.append( + f"Adjacent monitor detection failing ({failure_types['touching_monitors']} cases) - " + "review ADJACENCY_TOLERANCE (50px) and edge overlap logic in EdgesAreAdjacent()" + ) + + if "wrap_calculation" in failure_types: + recommendations.append( + f"Wrap calculation failing ({failure_types['wrap_calculation']} cases) - " + "review CursorWrapCore::HandleMouseMove() wrap destination logic" + ) + + if not recommendations: + recommendations.append("All tests passed - edge detection logic is working correctly!") + + return recommendations + +# ============================================================================ +# Main Entry Point +# ============================================================================ + +# ============================================================================ +# Main Entry Point +# ============================================================================ + +def main(): + """Main entry point""" + import argparse + + parser = argparse.ArgumentParser( + description="CursorWrap Monitor Layout Edge Detection Test Suite" + ) + parser.add_argument( + "--max-monitors", + type=int, + default=10, + help="Maximum number of monitors to test (1-10)" + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Enable verbose output" + ) + parser.add_argument( + "--layout-file", + type=str, + help="Use captured monitor layout JSON file instead of generated configs" + ) + + args = parser.parse_args() + + if not args.layout_file: + # Validate max_monitors only for generated configs + if args.max_monitors < 1 or args.max_monitors > 10: + print("Error: max-monitors must be between 1 and 10") + sys.exit(1) + + runner = TestRunner( + max_monitors=args.max_monitors, + verbose=args.verbose, + layout_file=args.layout_file + ) + runner.run_all_tests() + + # Exit with error code if tests failed + sys.exit(0 if not runner.failures else 1) + +if __name__ == "__main__": + main() 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 <algorithm> +#include <cmath> + +void MonitorTopology::Initialize(const std::vector<MonitorInfo>& 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<uintptr_t>(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<int>(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<uintptr_t>(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<uintptr_t>(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<EdgeType> 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<int64_t>(clamped) - static_cast<int64_t>(edge.start); + int64_t denominator = static_cast<int64_t>(edge.end) - static_cast<int64_t>(edge.start); + return static_cast<double>(numerator) / static_cast<double>(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<int64_t>(edge.end) - static_cast<int64_t>(edge.start); + int64_t offset = static_cast<int64_t>(relativePosition * static_cast<double>(range)); + // Clamp result to int range before returning + int64_t result = static_cast<int64_t>(edge.start) + offset; + return static_cast<int>(result); +} + +std::vector<MonitorTopology::GapInfo> MonitorTopology::DetectMonitorGaps() const +{ + std::vector<GapInfo> 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<int>(i); + gap.monitor2Index = static_cast<int>(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<int>(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<uintptr_t>(m_monitors[i].hMonitor), reinterpret_cast<uintptr_t>(monitor)); + return static_cast<int>(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<uintptr_t>(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 <windows.h> +#include <vector> +#include <map> + +// 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<MonitorInfo>& 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<MonitorEdge>& 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<GapInfo> DetectMonitorGaps() const; + +private: + std::vector<MonitorInfo> m_monitors; + std::vector<MonitorEdge> 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<std::pair<int, EdgeType>, 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 new file mode 100644 index 0000000000..b172f1c8b6 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/dllmain.cpp @@ -0,0 +1,700 @@ +// 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" +#include "trace.h" +#include "../../../common/utils/process_path.h" +#include "../../../common/utils/resources.h" +#include "../../../common/logger/logger.h" +#include "../../../common/utils/logger_helper.h" +#include "../../../common/interop/shared_constants.h" +#include <atomic> +#include <thread> +#include <vector> +#include <map> +#include <string> +#include <algorithm> +#include <windows.h> +#include <dbt.h> +#include <sstream> +#include "resource.h" +#include "CursorWrapCore.h" + +// Disable C26451 arithmetic overflow warning for this file since the operations are safe in this context +#pragma warning(disable: 26451) + +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; +} + +// Non-Localizable strings +namespace +{ + const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; + const wchar_t JSON_KEY_VALUE[] = L"value"; + 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. +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"<no description>"; + +// 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; + +// Global instance pointer for the mouse hook +static CursorWrap* g_cursorWrapInstance = nullptr; + +// Implement the PowerToy Module Interface and all the required methods. +class CursorWrap : public PowertoyModuleIface +{ +private: + // The PowerToy state. + 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<bool> m_hookActive{ false }; + + // Core wrapping engine (edge-based polygon model) + CursorWrapCore m_core; + + // Hotkey + Hotkey m_activationHotkey{}; + + // Event-driven trigger support (for CmdPal/automation) + HANDLE m_triggerEventHandle = nullptr; + HANDLE m_terminateEventHandle = nullptr; + 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(); + m_core.UpdateMonitorInfo(); + g_cursorWrapInstance = this; // Set global instance pointer + }; + + // Destroy the powertoy and free memory + virtual void destroy() override + { + // Ensure hooks/threads/handles are torn down before deletion + disable(); + g_cursorWrapInstance = nullptr; // Clear global instance pointer + delete this; + } + + // Return the localized display name of the powertoy + virtual const wchar_t* get_name() override + { + return MODULE_NAME; + } + + // Return the non localized key of the powertoy, this will be cached by the runner + virtual const wchar_t* get_key() override + { + return MODULE_NAME; + } + + // Return the configured status for the gpo policy for the module + virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override + { + return powertoys_gpo::getConfiguredCursorWrapEnabledValue(); + } + + // Return JSON with the configuration options. + virtual bool get_config(wchar_t* buffer, int* buffer_size) override + { + HINSTANCE hinstance = reinterpret_cast<HINSTANCE>(&__ImageBase); + + PowerToysSettings::Settings settings(hinstance, get_name()); + + settings.set_description(IDS_CURSORWRAP_NAME); + settings.set_icon_key(L"pt-cursor-wrap"); + + // Create HotkeyObject from the Hotkey struct for the settings + auto hotkey_object = PowerToysSettings::HotkeyObject::from_settings( + m_activationHotkey.win, + m_activationHotkey.ctrl, + m_activationHotkey.alt, + m_activationHotkey.shift, + m_activationHotkey.key); + + settings.add_hotkey(JSON_KEY_ACTIVATION_SHORTCUT, IDS_CURSORWRAP_NAME, hotkey_object); + settings.add_bool_toggle(JSON_KEY_AUTO_ACTIVATE, IDS_CURSORWRAP_NAME, m_autoActivate); + settings.add_bool_toggle(JSON_KEY_DISABLE_WRAP_DURING_DRAG, IDS_CURSORWRAP_NAME, m_disableWrapDuringDrag); + + return settings.serialize_to_buffer(buffer, buffer_size); + } + + // Signal from the Settings editor to call a custom action. + // This can be used to spawn more complex editors. + virtual void call_custom_action(const wchar_t* /*action*/) override {} + + // Called by the runner to pass the updated settings values as a serialized JSON. + virtual void set_config(const wchar_t* config) override + { + try + { + // Parse the input JSON string. + PowerToysSettings::PowerToyValues values = + PowerToysSettings::PowerToyValues::from_json_string(config, get_key()); + + parse_settings(values); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to parse CursorWrap settings json."); + } + } + + // Enable the powertoy + virtual void enable() + { + m_enabled = true; + Trace::EnableCursorWrap(true); + + // 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; + m_eventThread = std::thread([this]() { + HANDLE handles[2] = { m_triggerEventHandle, m_terminateEventHandle }; + + // WH_MOUSE_LL callbacks are delivered to the thread that installed the hook. + // Ensure this thread has a message queue and pumps messages while the hook is active. + MSG msg; + PeekMessage(&msg, nullptr, WM_USER, WM_USER, PM_NOREMOVE); + + // 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) + { + auto res = MsgWaitForMultipleObjects(2, handles, false, INFINITE, QS_ALLINPUT); + if (!m_listening) + { + break; + } + + if (res == WAIT_OBJECT_0) + { + ToggleMouseHook(); + } + else if (res == WAIT_OBJECT_0 + 1) + { + break; + } + else + { + while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + } + } + + // Cleanup display change notifications + UnregisterDisplayChanges(); + + StopMouseHook(); + Logger::info("CursorWrap event listener stopped"); + }); + } + } + + // Disable the powertoy + virtual void disable() + { + m_enabled = false; + Trace::EnableCursorWrap(false); + + m_listening = false; + if (m_terminateEventHandle) + { + SetEvent(m_terminateEventHandle); + } + if (m_eventThread.joinable()) + { + m_eventThread.join(); + } + if (m_triggerEventHandle) + { + CloseHandle(m_triggerEventHandle); + m_triggerEventHandle = nullptr; + } + if (m_terminateEventHandle) + { + CloseHandle(m_terminateEventHandle); + m_terminateEventHandle = nullptr; + } + } + + // Returns if the powertoys is enabled + virtual bool is_enabled() override + { + return m_enabled; + } + + // Returns whether the PowerToys should be enabled by default + virtual bool is_enabled_by_default() const override + { + return false; + } + + // Legacy hotkey support + virtual size_t get_hotkeys(Hotkey* buffer, size_t buffer_size) override + { + if (buffer && buffer_size >= 1) + { + buffer[0] = m_activationHotkey; + } + return 1; + } + + virtual bool on_hotkey(size_t hotkeyId) override + { + if (!m_enabled || hotkeyId != 0) + { + return false; + } + + // Toggle on the thread that owns the WH_MOUSE_LL hook (the event listener thread). + if (m_triggerEventHandle) + { + return SetEvent(m_triggerEventHandle); + } + + return false; + } + + // 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. + if (m_hookActive) + { + StopMouseHook(); + } + else + { + StartMouseHook(); + } + } + + // Load the settings file. + void init_settings() + { + try + { + // Load and parse the settings file for this PowerToy. + PowerToysSettings::PowerToyValues settings = + PowerToysSettings::PowerToyValues::load_from_settings_file(CursorWrap::get_key()); + parse_settings(settings); + } + catch (std::exception&) + { + Logger::error("Invalid json when trying to load the CursorWrap settings json from file."); + } + } + + void parse_settings(PowerToysSettings::PowerToyValues& settings) + { + auto settingsObject = settings.get_raw_json(); + if (settingsObject.GetView().Size()) + { + try + { + // Parse activation HotKey + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); + auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); + + m_activationHotkey.win = hotkey.win_pressed(); + m_activationHotkey.ctrl = hotkey.ctrl_pressed(); + m_activationHotkey.shift = hotkey.shift_pressed(); + m_activationHotkey.alt = hotkey.alt_pressed(); + m_activationHotkey.key = static_cast<unsigned char>(hotkey.get_code()); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap activation shortcut"); + } + + try + { + // Parse auto activate + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_AUTO_ACTIVATE); + m_autoActivate = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); + } + catch (...) + { + Logger::warn("Failed to initialize CursorWrap auto activate from settings. Will use default value"); + } + + try + { + // Parse disable wrap during drag + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + if (propertiesObject.HasKey(JSON_KEY_DISABLE_WRAP_DURING_DRAG)) + { + auto disableDragObject = propertiesObject.GetNamedObject(JSON_KEY_DISABLE_WRAP_DURING_DRAG); + m_disableWrapDuringDrag = disableDragObject.GetNamedBoolean(JSON_KEY_VALUE); + } + } + catch (...) + { + 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<int>(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 + { + Logger::info("CursorWrap settings are empty"); + } + + // Set default hotkey if not configured + if (m_activationHotkey.key == 0) + { + m_activationHotkey.win = true; + m_activationHotkey.alt = true; + m_activationHotkey.ctrl = false; + m_activationHotkey.shift = false; + m_activationHotkey.key = 'U'; // Win+Alt+U + } + } + + void StartMouseHook() + { + if (m_mouseHook || m_hookActive) + { + Logger::info("CursorWrap mouse hook already active"); + return; + } + + // Refresh monitor info before starting hook + m_core.UpdateMonitorInfo(); + + m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, GetModuleHandle(nullptr), 0); + if (m_mouseHook) + { + m_hookActive = true; + Logger::info("CursorWrap mouse hook started successfully"); +#ifdef _DEBUG + Logger::info(L"CursorWrap DEBUG: Hook installed"); +#endif + } + else + { + DWORD error = GetLastError(); + Logger::error(L"Failed to install CursorWrap mouse hook, error: {}", error); + } + } + + void StopMouseHook() + { + if (m_mouseHook) + { + UnhookWindowsHookEx(m_mouseHook); + m_mouseHook = nullptr; + m_hookActive = false; + Logger::info("CursorWrap mouse hook stopped"); +#ifdef _DEBUG + Logger::info("CursorWrap DEBUG: Mouse hook stopped"); +#endif + } + } + + 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) + { + auto* pMouseStruct = reinterpret_cast<MSLLHOOKSTRUCT*>(lParam); + POINT currentPos = { pMouseStruct->pt.x, pMouseStruct->pt.y }; + + if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive) + { + 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 + Logger::info(L"CursorWrap DEBUG: Wrapping cursor from ({}, {}) to ({}, {})", + currentPos.x, currentPos.y, newPos.x, newPos.y); +#endif + SetCursorPos(newPos.x, newPos.y); + return 1; // Suppress the original message + } + } + } + + return CallNextHookEx(nullptr, nCode, wParam, lParam); + } +}; + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new CursorWrap(); +} diff --git a/src/modules/MouseUtils/CursorWrap/packages.config b/src/modules/MouseUtils/CursorWrap/packages.config new file mode 100644 index 0000000000..7d3cbd2b91 --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/packages.config @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> +</packages> \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.cpp b/src/modules/MouseUtils/CursorWrap/pch.cpp new file mode 100644 index 0000000000..17305716aa --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/pch.h b/src/modules/MouseUtils/CursorWrap/pch.h new file mode 100644 index 0000000000..86f11c99ba --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/pch.h @@ -0,0 +1,13 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +#include <windows.h> + +#include <atomic> +#include <thread> +#include <vector> + +// Note: Common includes moved to individual source files due to include path issues +// #include <common/SettingsAPI/settings_helpers.h> +// #include <common/logger/logger.h> +// #include <common/utils/logger_helper.h> \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/resource.h b/src/modules/MouseUtils/CursorWrap/resource.h new file mode 100644 index 0000000000..9b49c0e3cc --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/resource.h @@ -0,0 +1,4 @@ +#pragma once + +#define IDS_CURSORWRAP_NAME 101 +#define IDS_CURSORWRAP_DISABLE_WRAP_DURING_DRAG 102 diff --git a/src/modules/MouseUtils/CursorWrap/trace.cpp b/src/modules/MouseUtils/CursorWrap/trace.cpp new file mode 100644 index 0000000000..ebfe32c23c --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.cpp @@ -0,0 +1,31 @@ +#include "pch.h" +#include "trace.h" + +#include "../../../../common/Telemetry/TraceBase.h" + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + "Microsoft.PowerToys", + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::EnableCursorWrap(const bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "CursorWrap_EnableCursorWrap", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} \ No newline at end of file diff --git a/src/modules/MouseUtils/CursorWrap/trace.h b/src/modules/MouseUtils/CursorWrap/trace.h new file mode 100644 index 0000000000..b2f6a9a8eb --- /dev/null +++ b/src/modules/MouseUtils/CursorWrap/trace.h @@ -0,0 +1,11 @@ +#pragma once + +#include <common/Telemetry/TraceBase.h> + +class Trace : public telemetry::TraceBase +{ +public: + static void RegisterProvider(); + static void UnregisterProvider(); + static void EnableCursorWrap(const bool enabled) noexcept; +}; \ No newline at end of file diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp index f4ecb031b4..1c408175a0 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.cpp @@ -8,21 +8,28 @@ #include "common/utils/process_path.h" #include "common/utils/excluded_apps.h" #include "common/utils/MsWindowsSettings.h" +#include <winrt/Windows.Graphics.h> + +#include <winrt/Microsoft.UI.Composition.Interop.h> +#include <winrt/Microsoft.UI.Dispatching.h> +#include <winrt/Microsoft.UI.Xaml.h> +#include <winrt/Microsoft.UI.Xaml.Controls.h> +#include <winrt/Microsoft.UI.Xaml.Media.h> +#include <winrt/Microsoft.UI.Xaml.Hosting.h> +#include <winrt/Microsoft.UI.Interop.h> +#include <winrt/Microsoft.UI.Content.h> + #include <vector> -#ifdef COMPOSITION namespace winrt { using namespace winrt::Windows::System; - using namespace winrt::Windows::UI::Composition; } -namespace ABI -{ - using namespace ABI::Windows::System; - using namespace ABI::Windows::UI::Composition::Desktop; -} -#endif +namespace muxc = winrt::Microsoft::UI::Composition; +namespace muxx = winrt::Microsoft::UI::Xaml; +namespace muxxc = winrt::Microsoft::UI::Xaml::Controls; +namespace muxxh = winrt::Microsoft::UI::Xaml::Hosting; #pragma region Super_Sonar_Base_Code @@ -70,11 +77,9 @@ protected: int m_sonarRadius = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_RADIUS; int m_sonarZoomFactor = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_INITIAL_ZOOM; DWORD m_fadeDuration = FIND_MY_MOUSE_DEFAULT_ANIMATION_DURATION_MS; - int m_finalAlphaNumerator = FIND_MY_MOUSE_DEFAULT_OVERLAY_OPACITY; std::vector<std::wstring> m_excludedApps; int m_shakeMinimumDistance = FIND_MY_MOUSE_DEFAULT_SHAKE_MINIMUM_DISTANCE; - static constexpr int FinalAlphaDenominator = 100; - winrt::DispatcherQueueController m_dispatcherQueueController{ nullptr }; + winrt::Microsoft::UI::Dispatching::DispatcherQueueController m_dispatcherQueueController{ nullptr }; // Don't consider movements started past these milliseconds to detect shaking. int m_shakeIntervalMs = FIND_MY_MOUSE_DEFAULT_SHAKE_INTERVAL_MS; @@ -82,7 +87,6 @@ protected: int m_shakeFactor = FIND_MY_MOUSE_DEFAULT_SHAKE_FACTOR; private: - // Save the mouse movement that occurred in any direction. struct PointerRecentMovement { @@ -149,7 +153,7 @@ private: void DetectShake(); bool KeyboardInputCanActivate(); - void StartSonar(); + void StartSonar(FindMyMouseActivationMethod activationMethod); void StopSonar(); }; @@ -159,7 +163,6 @@ bool SuperSonar<D>::Initialize(HINSTANCE hinst) SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); WNDCLASS wc{}; - if (!GetClassInfoW(hinst, className, &wc)) { wc.lpfnWndProc = s_WndProc; @@ -171,14 +174,28 @@ bool SuperSonar<D>::Initialize(HINSTANCE hinst) if (!RegisterClassW(&wc)) { + Logger::error("RegisterClassW failed. GetLastError={}", GetLastError()); return false; } } + // else: class already registered m_hwndOwner = CreateWindow(L"static", nullptr, WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, hinst, nullptr); + if (!m_hwndOwner) + { + Logger::error("Failed to create owner window. GetLastError={}", GetLastError()); + return false; + } DWORD exStyle = WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW | Shim()->GetExtendedStyle(); - return CreateWindowExW(exStyle, className, windowTitle, WS_POPUP, CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, m_hwndOwner, nullptr, hinst, this) != nullptr; + HWND created = CreateWindowExW(exStyle, className, windowTitle, WS_POPUP, CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, m_hwndOwner, nullptr, hinst, this); + if (!created) + { + Logger::error("CreateWindowExW failed. GetLastError={}", GetLastError()); + return false; + } + + return true; } template<typename D> @@ -226,7 +243,8 @@ LRESULT SuperSonar<D>::BaseWndProc(UINT message, WPARAM wParam, LPARAM lParam) n switch (message) { case WM_CREATE: - if(!OnSonarCreate()) return -1; + if (!OnSonarCreate()) + return -1; UpdateMouseSnooping(); return 0; @@ -255,7 +273,7 @@ LRESULT SuperSonar<D>::BaseWndProc(UINT message, WPARAM wParam, LPARAM lParam) n { if (m_sonarStart == NoSonar) { - StartSonar(); + StartSonar(FindMyMouseActivationMethod::Shortcut); } else { @@ -314,8 +332,7 @@ void SuperSonar<D>::OnSonarKeyboardInput(RAWINPUT const& input) return; } - if ((m_activationMethod != FindMyMouseActivationMethod::DoubleRightControlKey && m_activationMethod != FindMyMouseActivationMethod::DoubleLeftControlKey) - || input.data.keyboard.VKey != VK_CONTROL) + if ((m_activationMethod != FindMyMouseActivationMethod::DoubleRightControlKey && m_activationMethod != FindMyMouseActivationMethod::DoubleLeftControlKey) || input.data.keyboard.VKey != VK_CONTROL) { StopSonar(); return; @@ -326,8 +343,7 @@ void SuperSonar<D>::OnSonarKeyboardInput(RAWINPUT const& input) bool leftCtrlPressed = (input.data.keyboard.Flags & RI_KEY_E0) == 0; bool rightCtrlPressed = (input.data.keyboard.Flags & RI_KEY_E0) != 0; - if ((m_activationMethod == FindMyMouseActivationMethod::DoubleRightControlKey && !rightCtrlPressed) - || (m_activationMethod == FindMyMouseActivationMethod::DoubleLeftControlKey && !leftCtrlPressed)) + if ((m_activationMethod == FindMyMouseActivationMethod::DoubleRightControlKey && !rightCtrlPressed) || (m_activationMethod == FindMyMouseActivationMethod::DoubleLeftControlKey && !leftCtrlPressed)) { StopSonar(); return; @@ -366,7 +382,7 @@ void SuperSonar<D>::OnSonarKeyboardInput(RAWINPUT const& input) IsEqual(m_lastKeyPos, ptCursor)) { m_sonarState = SonarState::ControlDown2; - StartSonar(); + StartSonar(m_activationMethod); } else { @@ -376,7 +392,6 @@ void SuperSonar<D>::OnSonarKeyboardInput(RAWINPUT const& input) GetCursorPos(&m_lastKeyPos); UpdateMouseSnooping(); } - Logger::info("Detecting double left control click with {} ms interval.", doubleClickInterval); m_lastKeyTime = now; m_lastKeyPos = ptCursor; } @@ -402,14 +417,13 @@ template<typename D> void SuperSonar<D>::DetectShake() { ULONGLONG shakeStartTick = GetTickCount64() - m_shakeIntervalMs; - + // Prune the story of movements for those movements that started too long ago. std::erase_if(m_movementHistory, [shakeStartTick](const PointerRecentMovement& movement) { return movement.tick < shakeStartTick; }); - - + double distanceTravelled = 0; - LONGLONG currentX=0, minX=0, maxX=0; - LONGLONG currentY=0, minY=0, maxY=0; + LONGLONG currentX = 0, minX = 0, maxX = 0; + LONGLONG currentY = 0, minY = 0, maxY = 0; for (const PointerRecentMovement& movement : m_movementHistory) { @@ -421,23 +435,22 @@ void SuperSonar<D>::DetectShake() minY = min(currentY, minY); maxY = max(currentY, maxY); } - + if (distanceTravelled < m_shakeMinimumDistance) { return; } - // Size of the rectangle the pointer moved in. - double rectangleWidth = static_cast<double>(maxX) - minX; - double rectangleHeight = static_cast<double>(maxY) - minY; + // Size of the rectangle that the pointer moved in. + double rectangleWidth = static_cast<double>(maxX) - minX; + double rectangleHeight = static_cast<double>(maxY) - minY; double diagonal = sqrt(rectangleWidth * rectangleWidth + rectangleHeight * rectangleHeight); - if (diagonal > 0 && distanceTravelled / diagonal > (m_shakeFactor/100.f)) + if (diagonal > 0 && distanceTravelled / diagonal > (m_shakeFactor / 100.f)) { m_movementHistory.clear(); - StartSonar(); + StartSonar(m_activationMethod); } - } template<typename D> @@ -453,7 +466,7 @@ void SuperSonar<D>::OnSonarMouseInput(RAWINPUT const& input) { LONG relativeX = 0; LONG relativeY = 0; - if ((input.data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE) == MOUSE_MOVE_ABSOLUTE && (input.data.mouse.lLastX!=0 || input.data.mouse.lLastY!=0)) + if ((input.data.mouse.usFlags & MOUSE_MOVE_ABSOLUTE) == MOUSE_MOVE_ABSOLUTE && (input.data.mouse.lLastX != 0 || input.data.mouse.lLastY != 0)) { // Getting absolute mouse coordinates. Likely inside a VM / RDP session. if (m_seenAnAbsoluteMousePosition) @@ -482,7 +495,7 @@ void SuperSonar<D>::OnSonarMouseInput(RAWINPUT const& input) } else { - m_movementHistory.push_back({ .diff = { .x=relativeX, .y=relativeY }, .tick = GetTickCount64() }); + m_movementHistory.push_back({ .diff = { .x = relativeX, .y = relativeY }, .tick = GetTickCount64() }); // Mouse movement changed directions. Take the opportunity do detect shake. DetectShake(); } @@ -491,7 +504,6 @@ void SuperSonar<D>::OnSonarMouseInput(RAWINPUT const& input) { m_movementHistory.push_back({ .diff = { .x = relativeX, .y = relativeY }, .tick = GetTickCount64() }); } - } if (input.data.mouse.usButtonFlags) @@ -505,7 +517,7 @@ void SuperSonar<D>::OnSonarMouseInput(RAWINPUT const& input) } template<typename D> -void SuperSonar<D>::StartSonar() +void SuperSonar<D>::StartSonar(FindMyMouseActivationMethod activationMethod) { // Don't activate if game mode is on. if (m_doNotActivateOnGameMode && detect_game_mode()) @@ -518,10 +530,9 @@ void SuperSonar<D>::StartSonar() return; } - Logger::info("Focusing the sonar on the mouse cursor."); - Trace::MousePointerFocused(); + Trace::MousePointerFocused(static_cast<int>(activationMethod)); // Cover the entire virtual screen. - // HACK: Draw with 1 pixel off. Otherwise Windows glitches the task bar transparency when a transparent window fill the whole screen. + // HACK: Draw with 1 pixel off. Otherwise, Windows glitches the task bar transparency when a transparent window fill the whole screen. SetWindowPos(m_hwnd, HWND_TOPMOST, GetSystemMetrics(SM_XVIRTUALSCREEN) + 1, GetSystemMetrics(SM_YVIRTUALSCREEN) + 1, GetSystemMetrics(SM_CXVIRTUALSCREEN) - 2, GetSystemMetrics(SM_CYVIRTUALSCREEN) - 2, 0); m_sonarPos = ptNowhere; OnMouseTimer(); @@ -633,12 +644,26 @@ struct CompositionSpotlight : SuperSonar<CompositionSpotlight> DWORD GetExtendedStyle() { - return WS_EX_NOREDIRECTIONBITMAP; + // Remove WS_EX_NOREDIRECTIONBITMAP for Composition/XAML to allow DWM redirection. + return 0; } void AfterMoveSonar() { - m_spotlight.Offset({ static_cast<float>(m_sonarPos.x), static_cast<float>(m_sonarPos.y), 0.0f }); + const float scale = static_cast<float>(m_surface.XamlRoot().RasterizationScale()); + // Move gradient center + if (m_spotlightMaskGradient) + { + m_spotlightMaskGradient.EllipseCenter({ static_cast<float>(m_sonarPos.x) / scale, + static_cast<float>(m_sonarPos.y) / scale }); + } + // Move spotlight visual (color fill) below masked backdrop + if (m_spotlight) + { + m_spotlight.Offset({ static_cast<float>(m_sonarPos.x) / scale, + static_cast<float>(m_sonarPos.y) / scale, + 0.0f }); + } } LRESULT WndProc(UINT message, WPARAM wParam, LPARAM lParam) noexcept @@ -646,24 +671,29 @@ struct CompositionSpotlight : SuperSonar<CompositionSpotlight> switch (message) { case WM_CREATE: - return OnCompositionCreate() && BaseWndProc(message, wParam, lParam); + if (!OnCompositionCreate()) + return -1; + return BaseWndProc(message, wParam, lParam); case WM_OPACITY_ANIMATION_COMPLETED: OnOpacityAnimationCompleted(); break; + case WM_SIZE: + UpdateIslandSize(); + break; } return BaseWndProc(message, wParam, lParam); } void SetSonarVisibility(bool visible) { - m_batch = m_compositor.GetCommitBatch(winrt::CompositionBatchTypes::Animation); + m_batch = m_compositor.GetCommitBatch(muxc::CompositionBatchTypes::Animation); BOOL isEnabledAnimations = GetAnimationsEnabled(); m_animation.Duration(std::chrono::milliseconds{ isEnabledAnimations ? m_fadeDuration : 1 }); m_batch.Completed([hwnd = m_hwnd](auto&&, auto&&) { PostMessage(hwnd, WM_OPACITY_ANIMATION_COMPLETED, 0, 0); }); - m_root.Opacity(visible ? static_cast<float>(m_finalAlphaNumerator) / FinalAlphaDenominator : 0.0f); + m_root.Opacity(visible ? 1.0f : 0.0f); if (visible) { ShowWindow(m_hwnd, SW_SHOWNOACTIVATE); @@ -679,54 +709,141 @@ private: bool OnCompositionCreate() try { - // We need a dispatcher queue. - DispatcherQueueOptions options = { - sizeof(options), - DQTYPE_THREAD_CURRENT, - DQTAT_COM_ASTA, - }; - ABI::IDispatcherQueueController* controller; - winrt::check_hresult(CreateDispatcherQueueController(options, &controller)); - *winrt::put_abi(m_dispatcherQueueController) = controller; + // Creating composition resources + // Ensure a DispatcherQueue bound to this thread (required by WinAppSDK composition/XAML) + if (!m_dispatcherQueueController) + { + // Ensure COM is initialized + try + { + winrt::init_apartment(winrt::apartment_type::single_threaded); + // COM STA initialized + } + catch (const winrt::hresult_error& e) + { + Logger::error("Failed to initialize COM apartment: {}", winrt::to_string(e.message())); + return false; + } - // Create the compositor for our window. - m_compositor = winrt::Compositor(); - ABI::IDesktopWindowTarget* target; - winrt::check_hresult(m_compositor.as<ABI::ICompositorDesktopInterop>()->CreateDesktopWindowTarget(m_hwnd, false, &target)); - *winrt::put_abi(m_target) = target; + try + { + m_dispatcherQueueController = + winrt::Microsoft::UI::Dispatching::DispatcherQueueController::CreateOnCurrentThread(); + // DispatcherQueueController created + } + catch (const winrt::hresult_error& e) + { + Logger::error("Failed to create DispatcherQueueController: {}", winrt::to_string(e.message())); + return false; + } + } - // Our composition tree: + // 1) Create a XAML island and attach it to this HWND + try + { + m_island = winrt::Microsoft::UI::Xaml::Hosting::DesktopWindowXamlSource{}; + auto windowId = winrt::Microsoft::UI::GetWindowIdFromWindow(m_hwnd); + m_island.Initialize(windowId); + // Xaml source initialized + } + catch (const winrt::hresult_error& e) + { + Logger::error("Failed to create XAML island: {}", winrt::to_string(e.message())); + return false; + } + + UpdateIslandSize(); + // Island size set + + // 2) Create a XAML container to host the Composition child visual + m_surface = winrt::Microsoft::UI::Xaml::Controls::Grid{}; + + // A transparent background keeps hit-testing consistent vs. null brush + m_surface.Background(winrt::Microsoft::UI::Xaml::Media::SolidColorBrush{ + winrt::Microsoft::UI::Colors::Transparent() }); + m_surface.HorizontalAlignment(muxx::HorizontalAlignment::Stretch); + m_surface.VerticalAlignment(muxx::VerticalAlignment::Stretch); + + m_island.Content(m_surface); + + // 3) Get the compositor from the XAML visual tree (pure MUXC path) + try + { + auto elementVisual = + winrt::Microsoft::UI::Xaml::Hosting::ElementCompositionPreview::GetElementVisual(m_surface); + m_compositor = elementVisual.Compositor(); + // Compositor acquired + } + catch (const winrt::hresult_error& e) + { + Logger::error("Failed to get compositor: {}", winrt::to_string(e.message())); + return false; + } + + // 4) Build the composition tree // - // [root] ContainerVisual - // \ LayerVisual - // \[gray backdrop] - // [spotlight] + // [root] ContainerVisual (fills host) + // \ LayerVisual + // \ [backdrop dim * radial gradient mask (hole)] m_root = m_compositor.CreateContainerVisual(); - m_root.RelativeSizeAdjustment({ 1.0f, 1.0f }); // fill the parent + m_root.RelativeSizeAdjustment({ 1.0f, 1.0f }); m_root.Opacity(0.0f); - m_target.Root(m_root); + + // Insert our root as a hand-in Visual under the XAML element + winrt::Microsoft::UI::Xaml::Hosting::ElementCompositionPreview::SetElementChildVisual(m_surface, m_root); auto layer = m_compositor.CreateLayerVisual(); - layer.RelativeSizeAdjustment({ 1.0f, 1.0f }); // fill the parent + layer.RelativeSizeAdjustment({ 1.0f, 1.0f }); m_root.Children().InsertAtTop(layer); - m_backdrop = m_compositor.CreateSpriteVisual(); - m_backdrop.RelativeSizeAdjustment({ 1.0f, 1.0f }); // fill the parent - m_backdrop.Brush(m_compositor.CreateColorBrush(m_backgroundColor)); - layer.Children().InsertAtTop(m_backdrop); + const float scale = static_cast<float>(m_surface.XamlRoot().RasterizationScale()); + const float rDip = m_sonarRadiusFloat / scale; + const float zoom = static_cast<float>(m_sonarZoomFactor); - m_circleGeometry = m_compositor.CreateEllipseGeometry(); // radius set via expression animation + // Spotlight shape (below backdrop, visible through hole) + m_circleGeometry = m_compositor.CreateEllipseGeometry(); m_circleShape = m_compositor.CreateSpriteShape(m_circleGeometry); m_circleShape.FillBrush(m_compositor.CreateColorBrush(m_spotlightColor)); - m_circleShape.Offset({ m_sonarRadiusFloat * m_sonarZoomFactor, m_sonarRadiusFloat * m_sonarZoomFactor }); + m_circleShape.Offset({ rDip * zoom, rDip * zoom }); m_spotlight = m_compositor.CreateShapeVisual(); - m_spotlight.Size({ m_sonarRadiusFloat * 2 * m_sonarZoomFactor, m_sonarRadiusFloat * 2 * m_sonarZoomFactor }); + m_spotlight.Size({ rDip * 2 * zoom, rDip * 2 * zoom }); m_spotlight.AnchorPoint({ 0.5f, 0.5f }); m_spotlight.Shapes().Append(m_circleShape); - layer.Children().InsertAtTop(m_spotlight); - // Implicitly animate the alpha. + // Dim color (source) + m_dimColorBrush = m_compositor.CreateColorBrush(m_backgroundColor); + // Radial gradient mask (center transparent, outer opaque) + // Fixed feather width: 1px for radius < 300, 2px for radius >= 300 + const float featherPixels = (m_sonarRadius >= 300) ? 2.0f : 1.0f; + const float featherOffset = 1.0f - featherPixels / (rDip * zoom); + m_spotlightMaskGradient = m_compositor.CreateRadialGradientBrush(); + m_spotlightMaskGradient.MappingMode(muxc::CompositionMappingMode::Absolute); + m_maskStopCenter = m_compositor.CreateColorGradientStop(); + m_maskStopCenter.Offset(0.0f); + m_maskStopCenter.Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0)); + m_maskStopInner = m_compositor.CreateColorGradientStop(); + m_maskStopInner.Offset(featherOffset); + m_maskStopInner.Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0)); + m_maskStopOuter = m_compositor.CreateColorGradientStop(); + m_maskStopOuter.Offset(1.0f); + m_maskStopOuter.Color(winrt::Windows::UI::ColorHelper::FromArgb(255, 255, 255, 255)); + m_spotlightMaskGradient.ColorStops().Append(m_maskStopCenter); + m_spotlightMaskGradient.ColorStops().Append(m_maskStopInner); + m_spotlightMaskGradient.ColorStops().Append(m_maskStopOuter); + m_spotlightMaskGradient.EllipseCenter({ rDip * zoom, rDip * zoom }); + m_spotlightMaskGradient.EllipseRadius({ rDip * zoom, rDip * zoom }); + + m_maskBrush = m_compositor.CreateMaskBrush(); + m_maskBrush.Source(m_dimColorBrush); + m_maskBrush.Mask(m_spotlightMaskGradient); + + m_backdrop = m_compositor.CreateSpriteVisual(); + m_backdrop.RelativeSizeAdjustment({ 1.0f, 1.0f }); + m_backdrop.Brush(m_maskBrush); + layer.Children().InsertAtTop(m_backdrop); + + // 5) Implicit opacity animation on the root m_animation = m_compositor.CreateScalarKeyFrameAnimation(); m_animation.Target(L"Opacity"); m_animation.InsertExpressionKeyFrame(1.0f, L"this.FinalValue"); @@ -735,20 +852,15 @@ private: collection.Insert(L"Opacity", m_animation); m_root.ImplicitAnimations(collection); - // Radius of spotlight shrinks as opacity increases. - // At opacity zero, it is m_sonarRadius * SonarZoomFactor. - // At maximum opacity, it is m_sonarRadius. - auto radiusExpression = m_compositor.CreateExpressionAnimation(); - radiusExpression.SetReferenceParameter(L"Root", m_root); - wchar_t expressionText[256]; - winrt::check_hresult(StringCchPrintfW(expressionText, ARRAYSIZE(expressionText), L"Lerp(Vector2(%d, %d), Vector2(%d, %d), Root.Opacity * %d / %d)", m_sonarRadius * m_sonarZoomFactor, m_sonarRadius * m_sonarZoomFactor, m_sonarRadius, m_sonarRadius, FinalAlphaDenominator, m_finalAlphaNumerator)); - radiusExpression.Expression(expressionText); - m_circleGeometry.StartAnimation(L"Radius", radiusExpression); + // 6) Spotlight radius shrinks as opacity increases (expression animation) + SetupRadiusAnimations(rDip * zoom, rDip, featherPixels); + // Composition created successfully return true; } - catch (...) + catch (const winrt::hresult_error& e) { + Logger::error("Failed to create FindMyMouse visual: {}", winrt::to_string(e.message())); return false; } @@ -760,11 +872,62 @@ private: } } + // Helper to setup radius and feather expression animations + void SetupRadiusAnimations(float startRadiusDip, float endRadiusDip, float featherPixels) + { + // Radius expression: shrinks from startRadiusDip to endRadiusDip as opacity goes 0->1 + auto radiusExpression = m_compositor.CreateExpressionAnimation(); + radiusExpression.SetReferenceParameter(L"Root", m_root); + wchar_t expressionText[256]; + winrt::check_hresult(StringCchPrintfW( + expressionText, ARRAYSIZE(expressionText), + L"Lerp(Vector2(%.1f, %.1f), Vector2(%.1f, %.1f), Root.Opacity)", + startRadiusDip, startRadiusDip, endRadiusDip, endRadiusDip)); + radiusExpression.Expression(expressionText); + m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", radiusExpression); + + // Feather expression: maintains fixed pixel width as radius changes + auto featherExpression = m_compositor.CreateExpressionAnimation(); + featherExpression.SetReferenceParameter(L"Root", m_root); + wchar_t featherExpressionText[256]; + winrt::check_hresult(StringCchPrintfW( + featherExpressionText, ARRAYSIZE(featherExpressionText), + L"1.0f - %.1ff / Lerp(%.1ff, %.1ff, Root.Opacity)", + featherPixels, startRadiusDip, endRadiusDip)); + featherExpression.Expression(featherExpressionText); + m_maskStopInner.StartAnimation(L"Offset", featherExpression); + + // Circle geometry radius for visual consistency + if (m_circleGeometry) + { + auto radiusExpression2 = m_compositor.CreateExpressionAnimation(); + radiusExpression2.SetReferenceParameter(L"Root", m_root); + radiusExpression2.Expression(expressionText); + m_circleGeometry.StartAnimation(L"Radius", radiusExpression2); + } + } + + void UpdateIslandSize() + { + if (!m_island) + return; + + RECT rc{}; + if (!GetClientRect(m_hwnd, &rc)) + return; + + const int width = rc.right - rc.left; + const int height = rc.bottom - rc.top; + + auto bridge = m_island.SiteBridge(); + bridge.MoveAndResize(winrt::Windows::Graphics::RectInt32{ 0, 0, width, height }); + } + public: - void ApplySettings(const FindMyMouseSettings& settings, bool applyToRuntimeObjects) { + void ApplySettings(const FindMyMouseSettings& settings, bool applyToRuntimeObjects) + { if (!applyToRuntimeObjects) { - // Runtime objects not created yet. Just update fields. m_sonarRadius = settings.spotlightRadius; m_sonarRadiusFloat = static_cast<float>(m_sonarRadius); m_backgroundColor = settings.backgroundColor; @@ -773,7 +936,6 @@ public: m_includeWinKey = settings.includeWinKey; m_doNotActivateOnGameMode = settings.doNotActivateOnGameMode; m_fadeDuration = settings.animationDurationMs > 0 ? settings.animationDurationMs : 1; - m_finalAlphaNumerator = settings.overlayOpacity; m_sonarZoomFactor = settings.spotlightInitialZoom; m_excludedApps = settings.excludedApps; m_shakeMinimumDistance = settings.shakeMinimumDistance; @@ -782,11 +944,9 @@ public: } else { - // Runtime objects already created. Should update in the owner thread. if (m_dispatcherQueueController == nullptr) { Logger::warn("Tried accessing the dispatch queue controller before it was initialized."); - // No dispatcher Queue Controller? Means initialization still hasn't run, so settings will be applied then. return; } auto dispatcherQueue = m_dispatcherQueueController.DispatcherQueue(); @@ -794,7 +954,6 @@ public: bool enqueueSucceeded = dispatcherQueue.TryEnqueue([=]() { if (!m_destroyed) { - // Runtime objects not created yet. Just update fields. m_sonarRadius = localSettings.spotlightRadius; m_sonarRadiusFloat = static_cast<float>(m_sonarRadius); m_backgroundColor = localSettings.backgroundColor; @@ -803,7 +962,6 @@ public: m_includeWinKey = localSettings.includeWinKey; m_doNotActivateOnGameMode = localSettings.doNotActivateOnGameMode; m_fadeDuration = localSettings.animationDurationMs > 0 ? localSettings.animationDurationMs : 1; - m_finalAlphaNumerator = localSettings.overlayOpacity; m_sonarZoomFactor = localSettings.spotlightInitialZoom; m_excludedApps = localSettings.excludedApps; m_shakeMinimumDistance = localSettings.shakeMinimumDistance; @@ -812,20 +970,35 @@ public: UpdateMouseSnooping(); // For the shake mouse activation method // Apply new settings to runtime composition objects. - m_backdrop.Brush().as<winrt::CompositionColorBrush>().Color(m_backgroundColor); - m_circleShape.FillBrush().as<winrt::CompositionColorBrush>().Color(m_spotlightColor); - m_circleShape.Offset({ m_sonarRadiusFloat * m_sonarZoomFactor, m_sonarRadiusFloat * m_sonarZoomFactor }); - m_spotlight.Size({ m_sonarRadiusFloat * 2 * m_sonarZoomFactor, m_sonarRadiusFloat * 2 * m_sonarZoomFactor }); - m_animation.Duration(std::chrono::milliseconds{ m_fadeDuration }); - m_circleGeometry.StopAnimation(L"Radius"); - - // Update animation - auto radiusExpression = m_compositor.CreateExpressionAnimation(); - radiusExpression.SetReferenceParameter(L"Root", m_root); - wchar_t expressionText[256]; - winrt::check_hresult(StringCchPrintfW(expressionText, ARRAYSIZE(expressionText), L"Lerp(Vector2(%d, %d), Vector2(%d, %d), Root.Opacity * %d / %d)", m_sonarRadius * m_sonarZoomFactor, m_sonarRadius * m_sonarZoomFactor, m_sonarRadius, m_sonarRadius, FinalAlphaDenominator, m_finalAlphaNumerator)); - radiusExpression.Expression(expressionText); - m_circleGeometry.StartAnimation(L"Radius", radiusExpression); + if (m_dimColorBrush) + { + m_dimColorBrush.Color(m_backgroundColor); + } + if (m_circleShape) + { + if (auto brush = m_circleShape.FillBrush().try_as<muxc::CompositionColorBrush>()) + { + brush.Color(m_spotlightColor); + } + } + const float scale = static_cast<float>(m_surface.XamlRoot().RasterizationScale()); + const float rDip = m_sonarRadiusFloat / scale; + const float zoom = static_cast<float>(m_sonarZoomFactor); + const float featherPixels = (m_sonarRadius >= 300) ? 2.0f : 1.0f; + const float startRadiusDip = rDip * zoom; + m_spotlightMaskGradient.StopAnimation(L"EllipseRadius"); + m_maskStopInner.StopAnimation(L"Offset"); + if (m_circleGeometry) + { + m_circleGeometry.StopAnimation(L"Radius"); + } + m_spotlightMaskGradient.EllipseCenter({ startRadiusDip, startRadiusDip }); + if (m_spotlight) + { + m_spotlight.Size({ rDip * 2 * zoom, rDip * 2 * zoom }); + m_circleShape.Offset({ startRadiusDip, startRadiusDip }); + } + SetupRadiusAnimations(startRadiusDip, rDip, featherPixels); } }); if (!enqueueSucceeded) @@ -836,218 +1009,31 @@ public: } private: - winrt::Compositor m_compositor{ nullptr }; - winrt::Desktop::DesktopWindowTarget m_target{ nullptr }; - winrt::ContainerVisual m_root{ nullptr }; - winrt::CompositionEllipseGeometry m_circleGeometry{ nullptr }; - winrt::ShapeVisual m_spotlight{ nullptr }; - winrt::CompositionCommitBatch m_batch{ nullptr }; - winrt::SpriteVisual m_backdrop{ nullptr }; - winrt::CompositionSpriteShape m_circleShape{ nullptr }; + muxc::Compositor m_compositor{ nullptr }; + muxxh::DesktopWindowXamlSource m_island{ nullptr }; + muxxc::Grid m_surface{ nullptr }; + + muxc::ContainerVisual m_root{ nullptr }; + muxc::CompositionCommitBatch m_batch{ nullptr }; + muxc::SpriteVisual m_backdrop{ nullptr }; + // Spotlight shape visuals + muxc::CompositionEllipseGeometry m_circleGeometry{ nullptr }; + muxc::ShapeVisual m_spotlight{ nullptr }; + muxc::CompositionSpriteShape m_circleShape{ nullptr }; + // Radial gradient mask components + muxc::CompositionMaskBrush m_maskBrush{ nullptr }; + muxc::CompositionColorBrush m_dimColorBrush{ nullptr }; + muxc::CompositionRadialGradientBrush m_spotlightMaskGradient{ nullptr }; + muxc::CompositionColorGradientStop m_maskStopCenter{ nullptr }; + muxc::CompositionColorGradientStop m_maskStopInner{ nullptr }; + muxc::CompositionColorGradientStop m_maskStopOuter{ nullptr }; winrt::Windows::UI::Color m_backgroundColor = FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR; winrt::Windows::UI::Color m_spotlightColor = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR; - winrt::ScalarKeyFrameAnimation m_animation{ nullptr }; -}; - -template<typename D> -struct GdiSonar : SuperSonar<D> -{ - LRESULT WndProc(UINT message, WPARAM wParam, LPARAM lParam) noexcept - { - switch (message) - { - case WM_CREATE: - SetLayeredWindowAttributes(this->m_hwnd, 0, 0, LWA_ALPHA); - break; - - case WM_TIMER: - switch (wParam) - { - case TIMER_ID_FADE: - OnFadeTimer(); - break; - } - break; - - case WM_PAINT: - this->Shim()->OnPaint(); - break; - } - return this->BaseWndProc(message, wParam, lParam); - } - - void BeforeMoveSonar() { this->Shim()->InvalidateSonar(); } - void AfterMoveSonar() { this->Shim()->InvalidateSonar(); } - - void SetSonarVisibility(bool visible) - { - m_alphaTarget = visible ? MaxAlpha : 0; - m_fadeStart = GetTickCount() - FadeFramePeriod; - SetTimer(this->m_hwnd, TIMER_ID_FADE, FadeFramePeriod, nullptr); - OnFadeTimer(); - } - - void OnFadeTimer() - { - auto now = GetTickCount(); - auto step = (int)((now - m_fadeStart) * MaxAlpha / this->m_fadeDuration); - - this->Shim()->InvalidateSonar(); - if (m_alpha < m_alphaTarget) - { - m_alpha += step; - if (m_alpha > m_alphaTarget) - m_alpha = m_alphaTarget; - } - else if (m_alpha > m_alphaTarget) - { - m_alpha -= step; - if (m_alpha < m_alphaTarget) - m_alpha = m_alphaTarget; - } - SetLayeredWindowAttributes(this->m_hwnd, 0, (BYTE)m_alpha, LWA_ALPHA); - this->Shim()->InvalidateSonar(); - if (m_alpha == m_alphaTarget) - { - KillTimer(this->m_hwnd, TIMER_ID_FADE); - if (m_alpha == 0) - { - ShowWindow(this->m_hwnd, SW_HIDE); - } - } - else - { - ShowWindow(this->m_hwnd, SW_SHOWNOACTIVATE); - } - } - -protected: - int CurrentSonarRadius() - { - int range = MaxAlpha - m_alpha; - int radius = this->m_sonarRadius + this->m_sonarRadius * range * (this->m_sonarZoomFactor - 1) / MaxAlpha; - return radius; - } - -private: - static constexpr DWORD FadeFramePeriod = 10; - int MaxAlpha = SuperSonar<D>::m_finalAlphaNumerator * 255 / SuperSonar<D>::FinalAlphaDenominator; - static constexpr DWORD TIMER_ID_FADE = 101; - -private: - int m_alpha = 0; - int m_alphaTarget = 0; - DWORD m_fadeStart = 0; -}; - -struct GdiSpotlight : GdiSonar<GdiSpotlight> -{ - void InvalidateSonar() - { - RECT rc; - auto radius = CurrentSonarRadius(); - rc.left = this->m_sonarPos.x - radius; - rc.top = this->m_sonarPos.y - radius; - rc.right = this->m_sonarPos.x + radius; - rc.bottom = this->m_sonarPos.y + radius; - InvalidateRect(this->m_hwnd, &rc, FALSE); - } - - void OnPaint() - { - PAINTSTRUCT ps; - BeginPaint(this->m_hwnd, &ps); - - auto radius = CurrentSonarRadius(); - auto spotlight = CreateRoundRectRgn( - this->m_sonarPos.x - radius, this->m_sonarPos.y - radius, this->m_sonarPos.x + radius, this->m_sonarPos.y + radius, radius * 2, radius * 2); - - FillRgn(ps.hdc, spotlight, static_cast<HBRUSH>(GetStockObject(WHITE_BRUSH))); - Sleep(1000 / 60); - ExtSelectClipRgn(ps.hdc, spotlight, RGN_DIFF); - FillRect(ps.hdc, &ps.rcPaint, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH))); - DeleteObject(spotlight); - - EndPaint(this->m_hwnd, &ps); - } -}; - -struct GdiCrosshairs : GdiSonar<GdiCrosshairs> -{ - void InvalidateSonar() - { - RECT rc; - auto radius = CurrentSonarRadius(); - GetClientRect(m_hwnd, &rc); - rc.left = m_sonarPos.x - radius; - rc.right = m_sonarPos.x + radius; - InvalidateRect(m_hwnd, &rc, FALSE); - - GetClientRect(m_hwnd, &rc); - rc.top = m_sonarPos.y - radius; - rc.bottom = m_sonarPos.y + radius; - InvalidateRect(m_hwnd, &rc, FALSE); - } - - void OnPaint() - { - PAINTSTRUCT ps; - BeginPaint(this->m_hwnd, &ps); - - auto radius = CurrentSonarRadius(); - RECT rc; - - HBRUSH white = static_cast<HBRUSH>(GetStockObject(WHITE_BRUSH)); - - rc.left = m_sonarPos.x - radius; - rc.top = ps.rcPaint.top; - rc.right = m_sonarPos.x + radius; - rc.bottom = ps.rcPaint.bottom; - FillRect(ps.hdc, &rc, white); - - rc.left = ps.rcPaint.left; - rc.top = m_sonarPos.y - radius; - rc.right = ps.rcPaint.right; - rc.bottom = m_sonarPos.y + radius; - FillRect(ps.hdc, &rc, white); - - HBRUSH black = static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH)); - - // Top left - rc.left = ps.rcPaint.left; - rc.top = ps.rcPaint.top; - rc.right = m_sonarPos.x - radius; - rc.bottom = m_sonarPos.y - radius; - FillRect(ps.hdc, &rc, black); - - // Top right - rc.left = m_sonarPos.x + radius; - rc.top = ps.rcPaint.top; - rc.right = ps.rcPaint.right; - rc.bottom = m_sonarPos.y - radius; - FillRect(ps.hdc, &rc, black); - - // Bottom left - rc.left = ps.rcPaint.left; - rc.top = m_sonarPos.y + radius; - rc.right = m_sonarPos.x - radius; - rc.bottom = ps.rcPaint.bottom; - FillRect(ps.hdc, &rc, black); - - // Bottom right - rc.left = m_sonarPos.x + radius; - rc.top = m_sonarPos.y + radius; - rc.right = ps.rcPaint.right; - rc.bottom = ps.rcPaint.bottom; - FillRect(ps.hdc, &rc, black); - - EndPaint(this->m_hwnd, &ps); - } + muxc::ScalarKeyFrameAnimation m_animation{ nullptr }; }; #pragma endregion Super_Sonar_Base_Code - #pragma region Super_Sonar_API CompositionSpotlight* m_sonar = nullptr; @@ -1055,7 +1041,6 @@ void FindMyMouseApplySettings(const FindMyMouseSettings& settings) { if (m_sonar != nullptr) { - Logger::info("Applying settings."); m_sonar->ApplySettings(settings, true); } } @@ -1064,7 +1049,6 @@ void FindMyMouseDisable() { if (m_sonar != nullptr) { - Logger::info("Terminating a sonar instance."); m_sonar->Terminate(); } } @@ -1077,7 +1061,6 @@ bool FindMyMouseIsEnabled() // Based on SuperSonar's original wWinMain. int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings) { - Logger::info("Starting a sonar instance."); if (m_sonar != nullptr) { Logger::error("A sonar instance was still working when trying to start a new one."); @@ -1092,7 +1075,6 @@ int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings) return 0; } m_sonar = &sonar; - Logger::info("Initialized the sonar instance."); InitializeWinhookEventIds(); @@ -1105,7 +1087,6 @@ int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings) DispatchMessage(&msg); } - Logger::info("Sonar message loop ended."); m_sonar = nullptr; return (int)msg.wParam; @@ -1121,4 +1102,4 @@ HWND GetSonarHwnd() noexcept return nullptr; } -#pragma endregion Super_Sonar_API +#pragma endregion Super_Sonar_API \ No newline at end of file diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.h b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.h index fb52bf11e5..9efa4dd295 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.h +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.h @@ -11,9 +11,9 @@ enum struct FindMyMouseActivationMethod : int }; constexpr bool FIND_MY_MOUSE_DEFAULT_DO_NOT_ACTIVATE_ON_GAME_MODE = true; -const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(255, 0, 0, 0); -const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(255, 255, 255, 255); -constexpr int FIND_MY_MOUSE_DEFAULT_OVERLAY_OPACITY = 50; +// Default colors now include full alpha. Opacity is encoded directly in color alpha (legacy overlay_opacity migrated into A channel) +const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(128, 0, 0, 0); +const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(128, 255, 255, 255); constexpr int FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_RADIUS = 100; constexpr int FIND_MY_MOUSE_DEFAULT_ANIMATION_DURATION_MS = 500; constexpr int FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_INITIAL_ZOOM = 9; @@ -30,7 +30,6 @@ struct FindMyMouseSettings bool doNotActivateOnGameMode = FIND_MY_MOUSE_DEFAULT_DO_NOT_ACTIVATE_ON_GAME_MODE; winrt::Windows::UI::Color backgroundColor = FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR; winrt::Windows::UI::Color spotlightColor = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR; - int overlayOpacity = FIND_MY_MOUSE_DEFAULT_OVERLAY_OPACITY; int spotlightRadius = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_RADIUS; int animationDurationMs = FIND_MY_MOUSE_DEFAULT_ANIMATION_DURATION_MS; int spotlightInitialZoom = FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_INITIAL_ZOOM; @@ -44,4 +43,4 @@ int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings); void FindMyMouseDisable(); bool FindMyMouseIsEnabled(); void FindMyMouseApplySettings(const FindMyMouseSettings& settings); -HWND GetSonarHwnd() noexcept; \ No newline at end of file +HWND GetSonarHwnd() noexcept; diff --git a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj index 9d4dbd2b28..7c4d855986 100644 --- a/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj +++ b/src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj @@ -1,24 +1,45 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="NuGet"> + <!-- Tell NuGet this is PackageReference style --> + <RestoreProjectStyle>PackageReference</RestoreProjectStyle> + + <!-- Tell NuGet we're a native project --> + <NuGetTargetMoniker>native,Version=v0.0</NuGetTargetMoniker> + + <!-- Tell NuGet we target Windows (use your existing WindowsTargetPlatformVersion) --> + <NuGetTargetPlatformIdentifier>Windows</NuGetTargetPlatformIdentifier> + <NuGetTargetPlatformVersion>$(WindowsTargetPlatformVersion)</NuGetTargetPlatformVersion> + </PropertyGroup> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{e94fd11c-0591-456f-899f-efc0ca548336}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>FindMyMouse</RootNamespace> <ProjectName>FindMyMouse</ProjectName> + <CppWinRTOptimized>true</CppWinRTOptimized> + <CppWinRTEnableComponentProjection>false</CppWinRTEnableComponentProjection> + <CppWinRTGenerateWindowsMetadata>false</CppWinRTGenerateWindowsMetadata> + <WindowsAppSdkBootstrapInitialize>false</WindowsAppSdkBootstrapInitialize> + <WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained> + <WindowsAppSDKVerifyTransitiveDependencies>false</WindowsAppSDKVerifyTransitiveDependencies> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" GeneratePathProperty="true"/> + <PackageReference Include="Microsoft.WindowsAppSDK.Foundation" GeneratePathProperty="true"/> + <PackageReference Include="Microsoft.Windows.CppWinRT" GeneratePathProperty="true"/> + </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -30,9 +51,10 @@ <ImportGroup Label="PropertySheets"> <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> </ImportGroup> + <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.FindMyMouse</TargetName> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'"> @@ -79,7 +101,8 @@ </ItemDefinitionGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <!-- Add Generated Files folder so #include <winrt/...> finds projected headers --> + <AdditionalIncludeDirectories>$(GeneratedFilesDir);$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;$(MSBuildThisFileDirectory)..\..\..\..\src\;$(MSBuildThisFileDirectory)..\..\..\..\src\modules;$(MSBuildThisFileDirectory)..\..\..\..\src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -98,30 +121,37 @@ <ClCompile Include="trace.cpp" /> <ClCompile Include="WinHookEventIDs.cpp" /> </ItemGroup> + <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> <ItemGroup> <ResourceCompile Include="FindMyMouse.rc" /> </ItemGroup> - <ItemGroup> - <None Include="packages.config" /> - </ItemGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> - <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - </ImportGroup> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>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}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <!-- Deduplicate WindowsAppRuntimeAutoInitializer.cpp (added twice via transitive imports causing LNK4042). Remove all then add exactly once. --> + <Target Name="FixWinAppSDKAutoInitializer" BeforeTargets="ClCompile" AfterTargets="WindowsAppRuntimeAutoInitializer"> + <ItemGroup> + <!-- Remove ALL injected versions of the file --> + <ClCompile Remove="@(ClCompile)" Condition="'%(Filename)' == 'WindowsAppRuntimeAutoInitializer'" /> + + <!-- Add ONE copy back manually --> + <ClCompile Include="$(PkgMicrosoft_WindowsAppSDK_Foundation)\include\WindowsAppRuntimeAutoInitializer.cpp"> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + </ClCompile> + </ItemGroup> </Target> -</Project> \ No newline at end of file + <Target Name="RemoveManagedWebView2CoreFromNativeOutDir" AfterTargets="Build"> + <ItemGroup> + <_ToDelete Include="$(OutDir)Microsoft.Web.WebView2.Core.dll" /> + </ItemGroup> + <Delete Files="@(_ToDelete)" Condition="Exists('%(Identity)')" /> + </Target> + + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> +</Project> diff --git a/src/modules/MouseUtils/FindMyMouse/dllmain.cpp b/src/modules/MouseUtils/FindMyMouse/dllmain.cpp index 0518f468c2..52b79074c8 100644 --- a/src/modules/MouseUtils/FindMyMouse/dllmain.cpp +++ b/src/modules/MouseUtils/FindMyMouse/dllmain.cpp @@ -8,6 +8,8 @@ #include <common/utils/logger_helper.h> #include <common/utils/color.h> #include <common/utils/string_utils.h> +#include <common/utils/EventWaiter.h> +#include <common/interop/shared_constants.h> namespace { @@ -18,7 +20,7 @@ namespace const wchar_t JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE[] = L"do_not_activate_on_game_mode"; const wchar_t JSON_KEY_BACKGROUND_COLOR[] = L"background_color"; const wchar_t JSON_KEY_SPOTLIGHT_COLOR[] = L"spotlight_color"; - const wchar_t JSON_KEY_OVERLAY_OPACITY[] = L"overlay_opacity"; + const wchar_t JSON_KEY_OVERLAY_OPACITY[] = L"overlay_opacity"; // legacy only (migrated into color alpha) const wchar_t JSON_KEY_SPOTLIGHT_RADIUS[] = L"spotlight_radius"; const wchar_t JSON_KEY_ANIMATION_DURATION_MS[] = L"animation_duration_ms"; const wchar_t JSON_KEY_SPOTLIGHT_INITIAL_ZOOM[] = L"spotlight_initial_zoom"; @@ -69,6 +71,9 @@ private: // Find My Mouse specific settings FindMyMouseSettings m_findMyMouseSettings; + // Event-driven trigger support + EventWaiter m_triggerEventWaiter; + // Load initial settings from the persisted values. void init_settings(); @@ -86,6 +91,8 @@ public: // Destroy the powertoy and free memory virtual void destroy() override { + // Ensure threads/handles are cleaned up before destruction + disable(); delete this; } @@ -150,6 +157,11 @@ public: m_enabled = true; Trace::EnableFindMyMouse(true); std::thread([=]() { FindMyMouseMain(m_hModule, m_findMyMouseSettings); }).detach(); + + // Start listening for external trigger event so we can invoke the same logic as the hotkey. + m_triggerEventWaiter.start(CommonSharedConstants::FIND_MY_MOUSE_TRIGGER_EVENT, [this](DWORD) { + OnHotkeyEx(); + }); } // Disable the powertoy @@ -158,6 +170,8 @@ public: m_enabled = false; Trace::EnableFindMyMouse(false); FindMyMouseDisable(); + + m_triggerEventWaiter.stop(); } // Returns if the powertoys is enabled @@ -204,16 +218,50 @@ void FindMyMouse::init_settings() } } +inline static uint8_t LegacyOpacityToAlpha(int overlayOpacityPercent) +{ + if (overlayOpacityPercent < 0) + { + return 255; // fallback: fully opaque + } + + if (overlayOpacityPercent > 100) + { + overlayOpacityPercent = 100; + } + + // Round to nearest integer (0–255) + return static_cast<uint8_t>((overlayOpacityPercent * 255 + 50) / 100); +} + void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { auto settingsObject = settings.get_raw_json(); FindMyMouseSettings findMyMouseSettings; - if (settingsObject.GetView().Size()) + + if (!settingsObject.GetView().Size()) + { + Logger::info("Find My Mouse settings are empty"); + m_findMyMouseSettings = findMyMouseSettings; + return; + } + + // Early exit if no properties object exists + if (!settingsObject.HasKey(JSON_KEY_PROPERTIES)) + { + Logger::info("Find My Mouse settings have no properties"); + m_findMyMouseSettings = findMyMouseSettings; + return; + } + + auto properties = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); + + // Parse Activation Method + if (properties.HasKey(JSON_KEY_ACTIVATION_METHOD)) { try { - // Parse Activation Method - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_METHOD); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_ACTIVATION_METHOD); int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value < static_cast<int>(FindMyMouseActivationMethod::EnumElements) && value >= 0) { @@ -224,97 +272,148 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) } else { - findMyMouseSettings.activationMethod = static_cast<FindMyMouseActivationMethod>(value); - } + findMyMouseSettings.activationMethod = static_cast<FindMyMouseActivationMethod>(value); + } } else { throw std::runtime_error("Invalid Activation Method value"); } - } catch (...) { Logger::warn("Failed to initialize Activation Method from settings. Will use default value"); } + } + + // Parse Include Win Key + if (properties.HasKey(JSON_KEY_INCLUDE_WIN_KEY)) + { try { - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_INCLUDE_WIN_KEY); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_INCLUDE_WIN_KEY); findMyMouseSettings.includeWinKey = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); } catch (...) { Logger::warn("Failed to get 'include windows key with ctrl' setting"); } + } + + // Parse Do Not Activate On Game Mode + if (properties.HasKey(JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE)) + { try { - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_DO_NOT_ACTIVATE_ON_GAME_MODE); findMyMouseSettings.doNotActivateOnGameMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); } catch (...) { Logger::warn("Failed to get 'do not activate on game mode' setting"); } + } + + // Colors + legacy overlay opacity migration + // Desired behavior: + // - Old schema: colors stored as RGB (no alpha) + separate overlay_opacity (0-100). We should migrate by applying that opacity as alpha. + // - New schema: colors stored as ARGB (alpha embedded). Ignore overlay_opacity even if still present. + int legacyOverlayOpacity = -1; + bool backgroundColorHadExplicitAlpha = false; + bool spotlightColorHadExplicitAlpha = false; + + // Parse Legacy Overlay Opacity (may not exist in newer settings) + if (properties.HasKey(JSON_KEY_OVERLAY_OPACITY)) + { try { - // Parse background color - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_BACKGROUND_COLOR); - auto backgroundColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); - uint8_t r, g, b; - if (!checkValidRGB(backgroundColor, &r, &g, &b)) + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_OVERLAY_OPACITY); + int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); + if (value >= 0 && value <= 100) { - Logger::error("Background color RGB value is invalid. Will use default value"); + legacyOverlayOpacity = value; + } + } + catch (...) + { + // overlay_opacity may have invalid data + } + } + + // Parse Background Color + if (properties.HasKey(JSON_KEY_BACKGROUND_COLOR)) + { + try + { + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_BACKGROUND_COLOR); + auto backgroundColorStr = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); + uint8_t a = 255, r, g, b; + bool parsed = false; + if (checkValidARGB(backgroundColorStr, &a, &r, &g, &b)) + { + parsed = true; + backgroundColorHadExplicitAlpha = true; // New schema with alpha present + } + else if (checkValidRGB(backgroundColorStr, &r, &g, &b)) + { + a = LegacyOpacityToAlpha(legacyOverlayOpacity); + parsed = true; // Old schema (no alpha component) + } + if (parsed) + { + findMyMouseSettings.backgroundColor = winrt::Windows::UI::ColorHelper::FromArgb(a, r, g, b); } else { - findMyMouseSettings.backgroundColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); + Logger::error("Background color value is invalid. Will use default"); } } catch (...) { Logger::warn("Failed to initialize background color from settings. Will use default value"); } + } + + // Parse Spotlight Color + if (properties.HasKey(JSON_KEY_SPOTLIGHT_COLOR)) + { try { - // Parse spotlight color - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_COLOR); - auto spotlightColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); - uint8_t r, g, b; - if (!checkValidRGB(spotlightColor, &r, &g, &b)) + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SPOTLIGHT_COLOR); + auto spotlightColorStr = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); + uint8_t a = 255, r, g, b; + bool parsed = false; + if (checkValidARGB(spotlightColorStr, &a, &r, &g, &b)) { - Logger::error("Spotlight color RGB value is invalid. Will use default value"); + parsed = true; + spotlightColorHadExplicitAlpha = true; + } + else if (checkValidRGB(spotlightColorStr, &r, &g, &b)) + { + a = LegacyOpacityToAlpha(legacyOverlayOpacity); + parsed = true; + } + if (parsed) + { + findMyMouseSettings.spotlightColor = winrt::Windows::UI::ColorHelper::FromArgb(a, r, g, b); } else { - findMyMouseSettings.spotlightColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); + Logger::error("Spotlight color value is invalid. Will use default"); } } catch (...) { Logger::warn("Failed to initialize spotlight color from settings. Will use default value"); } + } + + // Parse Spotlight Radius + if (properties.HasKey(JSON_KEY_SPOTLIGHT_RADIUS)) + { try { - // Parse Overlay Opacity - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_OVERLAY_OPACITY); - int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - findMyMouseSettings.overlayOpacity = value; - } - else - { - throw std::runtime_error("Invalid Overlay Opacity value"); - } - } - catch (...) - { - Logger::warn("Failed to initialize Overlay Opacity from settings. Will use default value"); - } - try - { - // Parse Spotlight Radius - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_RADIUS); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SPOTLIGHT_RADIUS); int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -329,10 +428,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Spotlight Radius from settings. Will use default value"); } + } + + // Parse Animation Duration + if (properties.HasKey(JSON_KEY_ANIMATION_DURATION_MS)) + { try { - // Parse Animation Duration - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ANIMATION_DURATION_MS); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_ANIMATION_DURATION_MS); int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -347,10 +450,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Animation Duration from settings. Will use default value"); } + } + + // Parse Spotlight Initial Zoom + if (properties.HasKey(JSON_KEY_SPOTLIGHT_INITIAL_ZOOM)) + { try { - // Parse Spotlight Initial Zoom - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_INITIAL_ZOOM); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SPOTLIGHT_INITIAL_ZOOM); int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -365,10 +472,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Spotlight Initial Zoom from settings. Will use default value"); } + } + + // Parse Excluded Apps + if (properties.HasKey(JSON_KEY_EXCLUDED_APPS)) + { try { - // Parse Excluded Apps - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_EXCLUDED_APPS); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_EXCLUDED_APPS); std::wstring apps = jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE).c_str(); std::vector<std::wstring> excludedApps; auto excludedUppercase = apps; @@ -390,10 +501,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Excluded Apps from settings. Will use default value"); } + } + + // Parse Shaking Minimum Distance + if (properties.HasKey(JSON_KEY_SHAKING_MINIMUM_DISTANCE)) + { try { - // Parse Shaking Minimum Distance - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHAKING_MINIMUM_DISTANCE); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SHAKING_MINIMUM_DISTANCE); int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -408,10 +523,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Shaking Minimum Distance from settings. Will use default value"); } + } + + // Parse Shaking Interval Milliseconds + if (properties.HasKey(JSON_KEY_SHAKING_INTERVAL_MS)) + { try { - // Parse Shaking Interval Milliseconds - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHAKING_INTERVAL_MS); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SHAKING_INTERVAL_MS); int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -426,10 +545,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Shaking Interval Milliseconds from settings. Will use default value"); } + } + + // Parse Shaking Factor + if (properties.HasKey(JSON_KEY_SHAKING_FACTOR)) + { try { - // Parse Shaking Factor - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHAKING_FACTOR); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_SHAKING_FACTOR); int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); if (value >= 0) { @@ -444,11 +567,14 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Shaking Factor from settings. Will use default value"); } + } + // Parse HotKey + if (properties.HasKey(JSON_KEY_ACTIVATION_SHORTCUT)) + { try { - // Parse HotKey - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); + auto jsonPropertiesObject = properties.GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); m_hotkey = HotkeyEx(); if (hotkey.win_pressed()) @@ -477,23 +603,19 @@ void FindMyMouse::parse_settings(PowerToysSettings::PowerToyValues& settings) { Logger::warn("Failed to initialize Activation Shortcut from settings. Will use default value"); } + } - if (!m_hotkey.modifiersMask) - { - Logger::info("Using default Activation Shortcut"); - m_hotkey.modifiersMask = MOD_SHIFT | MOD_WIN; - m_hotkey.vkCode = 0x46; // F key - } - } - else + if (!m_hotkey.modifiersMask) { - Logger::info("Find My Mouse settings are empty"); + Logger::info("Using default Activation Shortcut"); + m_hotkey.modifiersMask = MOD_SHIFT | MOD_WIN; + m_hotkey.vkCode = 0x46; // F key } + m_findMyMouseSettings = findMyMouseSettings; } - extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() { return new FindMyMouse(); -} \ No newline at end of file +} diff --git a/src/modules/MouseUtils/FindMyMouse/pch.h b/src/modules/MouseUtils/FindMyMouse/pch.h index 26da2455f2..a0a8f1819c 100644 --- a/src/modules/MouseUtils/FindMyMouse/pch.h +++ b/src/modules/MouseUtils/FindMyMouse/pch.h @@ -5,15 +5,22 @@ #include <windows.h> #include <strsafe.h> #include <hIdUsage.h> +// Required for IUnknown and DECLARE_INTERFACE_* used by interop headers +#include <Unknwn.h> #ifdef COMPOSITION -#include <windows.ui.composition.interop.h> #include <DispatcherQueue.h> #include <winrt/Windows.System.h> #include <winrt/Windows.Foundation.h> -#include <winrt/Windows.UI.Composition.Desktop.h> +#include <winrt/Microsoft.UI.Composition.h> +#include <winrt/Microsoft.UI.h> +#include <winrt/Windows.UI.h> #endif #include <winrt/Windows.Foundation.Collections.h> #include <common/SettingsAPI/settings_helpers.h> #include <common/logger/logger.h> + +#ifdef GetCurrentTime +#undef GetCurrentTime +#endif \ No newline at end of file diff --git a/src/modules/MouseUtils/FindMyMouse/trace.cpp b/src/modules/MouseUtils/FindMyMouse/trace.cpp index bf79461e9a..6309596cf8 100644 --- a/src/modules/MouseUtils/FindMyMouse/trace.cpp +++ b/src/modules/MouseUtils/FindMyMouse/trace.cpp @@ -22,11 +22,12 @@ void Trace::EnableFindMyMouse(const bool enabled) noexcept } // Log that the user activated the module by focusing the mouse pointer -void Trace::MousePointerFocused() noexcept +void Trace::MousePointerFocused(const int activationMethod) noexcept { TraceLoggingWriteWrapper( g_hProvider, "FindMyMouse_MousePointerFocused", ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), - TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingInt32(activationMethod, "ActivationMethod")); } diff --git a/src/modules/MouseUtils/FindMyMouse/trace.h b/src/modules/MouseUtils/FindMyMouse/trace.h index 59d3183b5b..90933e9403 100644 --- a/src/modules/MouseUtils/FindMyMouse/trace.h +++ b/src/modules/MouseUtils/FindMyMouse/trace.h @@ -9,5 +9,6 @@ public: static void EnableFindMyMouse(const bool enabled) noexcept; // Log that the user activated the module by focusing the mouse pointer - static void MousePointerFocused() noexcept; + // activationMethod: 0 = DoubleLeftControlKey, 1 = DoubleRightControlKey, 2 = ShakeMouse, 3 = Shortcut + static void MousePointerFocused(const int activationMethod) noexcept; }; diff --git a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.cpp b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.cpp index 199ee3280e..05670742ec 100644 --- a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.cpp +++ b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.cpp @@ -4,6 +4,8 @@ #include "pch.h" #include "MouseHighlighter.h" #include "trace.h" +#include <cmath> +#include <algorithm> #ifdef COMPOSITION namespace winrt @@ -43,11 +45,14 @@ private: void AddDrawingPoint(MouseButton button); void UpdateDrawingPointPosition(MouseButton button); void StartDrawingPointFading(MouseButton button); - void ClearDrawingPoint(MouseButton button); + void ClearDrawingPoint(); void ClearDrawing(); void BringToFront(); HHOOK m_mouseHook = NULL; static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept; + // Helpers for spotlight overlay + float GetDpiScale() const; + void UpdateSpotlightMask(float cx, float cy, float radius, bool show); static constexpr auto m_className = L"MouseHighlighter"; static constexpr auto m_windowTitle = L"PowerToys Mouse Highlighter"; @@ -66,10 +71,19 @@ private: winrt::CompositionSpriteShape m_leftPointer{ nullptr }; winrt::CompositionSpriteShape m_rightPointer{ nullptr }; winrt::CompositionSpriteShape m_alwaysPointer{ nullptr }; + // Spotlight overlay (mask with soft feathered edge) + winrt::SpriteVisual m_overlay{ nullptr }; + winrt::CompositionMaskBrush m_spotlightMask{ nullptr }; + winrt::CompositionRadialGradientBrush m_spotlightMaskGradient{ nullptr }; + winrt::CompositionColorBrush m_spotlightSource{ nullptr }; + winrt::CompositionColorGradientStop m_maskStopCenter{ nullptr }; + winrt::CompositionColorGradientStop m_maskStopInner{ nullptr }; + winrt::CompositionColorGradientStop m_maskStopOuter{ nullptr }; bool m_leftPointerEnabled = true; bool m_rightPointerEnabled = true; bool m_alwaysPointerEnabled = true; + bool m_spotlightMode = false; bool m_leftButtonPressed = false; bool m_rightButtonPressed = false; @@ -95,8 +109,7 @@ bool Highlighter::CreateHighlighter() try { // We need a dispatcher queue. - DispatcherQueueOptions options = - { + DispatcherQueueOptions options = { sizeof(options), DQTYPE_THREAD_CURRENT, DQTAT_COM_ASTA, @@ -121,8 +134,38 @@ bool Highlighter::CreateHighlighter() m_shape.RelativeSizeAdjustment({ 1.0f, 1.0f }); m_root.Children().InsertAtTop(m_shape); + // Create spotlight overlay (soft feather, DPI-aware) + m_overlay = m_compositor.CreateSpriteVisual(); + m_overlay.RelativeSizeAdjustment({ 1.0f, 1.0f }); + m_spotlightSource = m_compositor.CreateColorBrush(m_alwaysColor); + m_spotlightMaskGradient = m_compositor.CreateRadialGradientBrush(); + m_spotlightMaskGradient.MappingMode(winrt::CompositionMappingMode::Absolute); + // Center region fully transparent + m_maskStopCenter = m_compositor.CreateColorGradientStop(); + m_maskStopCenter.Offset(0.0f); + m_maskStopCenter.Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0)); + // Inner edge of feather (still transparent) + m_maskStopInner = m_compositor.CreateColorGradientStop(); + m_maskStopInner.Offset(0.995f); // will be updated per-radius + m_maskStopInner.Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0)); + // Outer edge (opaque mask -> overlay visible) + m_maskStopOuter = m_compositor.CreateColorGradientStop(); + m_maskStopOuter.Offset(1.0f); + m_maskStopOuter.Color(winrt::Windows::UI::ColorHelper::FromArgb(255, 255, 255, 255)); + m_spotlightMaskGradient.ColorStops().Append(m_maskStopCenter); + m_spotlightMaskGradient.ColorStops().Append(m_maskStopInner); + m_spotlightMaskGradient.ColorStops().Append(m_maskStopOuter); + + m_spotlightMask = m_compositor.CreateMaskBrush(); + m_spotlightMask.Source(m_spotlightSource); + m_spotlightMask.Mask(m_spotlightMaskGradient); + m_overlay.Brush(m_spotlightMask); + m_overlay.IsVisible(false); + m_root.Children().InsertAtTop(m_overlay); + return true; - } catch (...) + } + catch (...) { return false; } @@ -130,6 +173,9 @@ bool Highlighter::CreateHighlighter() void Highlighter::AddDrawingPoint(MouseButton button) { + if (!m_compositor) + return; + POINT pt; // Applies DPIs. @@ -141,6 +187,7 @@ void Highlighter::AddDrawingPoint(MouseButton button) // Create circle and add it. auto circleGeometry = m_compositor.CreateEllipseGeometry(); circleGeometry.Radius({ m_radius, m_radius }); + auto circleShape = m_compositor.CreateSpriteShape(circleGeometry); circleShape.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) }); if (button == MouseButton::Left) @@ -156,16 +203,25 @@ void Highlighter::AddDrawingPoint(MouseButton button) else { // always - circleShape.FillBrush(m_compositor.CreateColorBrush(m_alwaysColor)); - m_alwaysPointer = circleShape; + if (m_spotlightMode) + { + UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true); + return; + } + else + { + circleShape.FillBrush(m_compositor.CreateColorBrush(m_alwaysColor)); + m_alwaysPointer = circleShape; + } } + m_shape.Shapes().Append(circleShape); // TODO: We're leaking shapes for long drawing sessions. // Perhaps add a task to the Dispatcher every X circles to clean up. // Get back on top in case other Window is now the topmost. - // HACK: Draw with 1 pixel off. Otherwise Windows glitches the task bar transparency when a transparent window fill the whole screen. + // HACK: Draw with 1 pixel off. Otherwise, Windows glitches the task bar transparency when a transparent window fill the whole screen. SetWindowPos(m_hwnd, HWND_TOPMOST, GetSystemMetrics(SM_XVIRTUALSCREEN) + 1, GetSystemMetrics(SM_YVIRTUALSCREEN) + 1, GetSystemMetrics(SM_CXVIRTUALSCREEN) - 2, GetSystemMetrics(SM_CYVIRTUALSCREEN) - 2, 0); } @@ -189,8 +245,15 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button) } else { - // always - m_alwaysPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) }); + // always / spotlight idle + if (m_spotlightMode) + { + UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true); + } + else if (m_alwaysPointer) + { + m_alwaysPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) }); + } } } void Highlighter::StartDrawingPointFading(MouseButton button) @@ -229,20 +292,22 @@ void Highlighter::StartDrawingPointFading(MouseButton button) circleShape.FillBrush().StartAnimation(L"Color", animation); } -void Highlighter::ClearDrawingPoint(MouseButton _button) +void Highlighter::ClearDrawingPoint() { - winrt::Windows::UI::Composition::CompositionSpriteShape circleShape{ nullptr }; - - if (nullptr == m_alwaysPointer) + if (m_spotlightMode) { - // Guard against alwaysPointer not being initialized. - return; + if (m_overlay) + { + m_overlay.IsVisible(false); + } + } + else + { + if (m_alwaysPointer) + { + m_alwaysPointer.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0)); + } } - - // always - circleShape = m_alwaysPointer; - - circleShape.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color(winrt::Windows::UI::ColorHelper::FromArgb(0, 0, 0, 0)); } void Highlighter::ClearDrawing() @@ -269,13 +334,14 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed) { // Clear AlwaysPointer only when it's enabled and RightPointer is not active - instance->ClearDrawingPoint(MouseButton::None); + instance->ClearDrawingPoint(); } if (instance->m_leftButtonPressed) { // There might be a stray point from the user releasing the mouse button on an elevated window, which wasn't caught by us. instance->StartDrawingPointFading(MouseButton::Left); } + instance->AddDrawingPoint(MouseButton::Left); instance->m_leftButtonPressed = true; // start a timer for the scenario, when the user clicks a pinned window which has no focus. @@ -293,7 +359,7 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed) { // Clear AlwaysPointer only when it's enabled and LeftPointer is not active - instance->ClearDrawingPoint(MouseButton::None); + instance->ClearDrawingPoint(); } if (instance->m_rightButtonPressed) { @@ -358,13 +424,21 @@ void Highlighter::StartDrawing() { Logger::info("Starting draw mode."); Trace::StartHighlightingSession(); + + if (m_spotlightMode && m_alwaysColor.A != 0) + { + Trace::StartSpotlightSession(); + } + m_visible = true; - // HACK: Draw with 1 pixel off. Otherwise Windows glitches the task bar transparency when a transparent window fill the whole screen. + // HACK: Draw with 1 pixel off. Otherwise, Windows glitches the task bar transparency when a transparent window fill the whole screen. SetWindowPos(m_hwnd, HWND_TOPMOST, GetSystemMetrics(SM_XVIRTUALSCREEN) + 1, GetSystemMetrics(SM_YVIRTUALSCREEN) + 1, GetSystemMetrics(SM_CXVIRTUALSCREEN) - 2, GetSystemMetrics(SM_CYVIRTUALSCREEN) - 2, 0); ClearDrawing(); ShowWindow(m_hwnd, SW_SHOWNOACTIVATE); - instance->AddDrawingPoint(MouseButton::None); + + instance->AddDrawingPoint(Highlighter::MouseButton::None); + m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, m_hinstance, 0); } @@ -377,6 +451,10 @@ void Highlighter::StopDrawing() m_leftPointer = nullptr; m_rightPointer = nullptr; m_alwaysPointer = nullptr; + if (m_overlay) + { + m_overlay.IsVisible(false); + } ShowWindow(m_hwnd, SW_HIDE); UnhookWindowsHookEx(m_mouseHook); ClearDrawing(); @@ -388,7 +466,8 @@ void Highlighter::SwitchActivationMode() PostMessage(m_hwnd, WM_SWITCH_ACTIVATION_MODE, 0, 0); } -void Highlighter::ApplySettings(MouseHighlighterSettings settings) { +void Highlighter::ApplySettings(MouseHighlighterSettings settings) +{ m_radius = static_cast<float>(settings.radius); m_fadeDelay_ms = settings.fadeDelayMs; m_fadeDuration_ms = settings.fadeDurationMs; @@ -398,10 +477,34 @@ void Highlighter::ApplySettings(MouseHighlighterSettings settings) { m_leftPointerEnabled = settings.leftButtonColor.A != 0; m_rightPointerEnabled = settings.rightButtonColor.A != 0; m_alwaysPointerEnabled = settings.alwaysColor.A != 0; + m_spotlightMode = settings.spotlightMode && settings.alwaysColor.A != 0; + + if (m_spotlightMode) + { + m_leftPointerEnabled = false; + m_rightPointerEnabled = false; + } + + // Keep spotlight overlay color updated + if (m_spotlightSource) + { + m_spotlightSource.Color(m_alwaysColor); + } + if (!m_spotlightMode && m_overlay) + { + m_overlay.IsVisible(false); + } + + if (instance->m_visible) + { + instance->StopDrawing(); + instance->StartDrawing(); + } } -void Highlighter::BringToFront() { - // HACK: Draw with 1 pixel off. Otherwise Windows glitches the task bar transparency when a transparent window fill the whole screen. +void Highlighter::BringToFront() +{ + // HACK: Draw with 1 pixel off. Otherwise, Windows glitches the task bar transparency when a transparent window fill the whole screen. SetWindowPos(m_hwnd, HWND_TOPMOST, GetSystemMetrics(SM_XVIRTUALSCREEN) + 1, GetSystemMetrics(SM_YVIRTUALSCREEN) + 1, GetSystemMetrics(SM_CXVIRTUALSCREEN) - 2, GetSystemMetrics(SM_CYVIRTUALSCREEN) - 2, 0); } @@ -488,8 +591,7 @@ bool Highlighter::MyRegisterClass(HINSTANCE hInstance) m_hwndOwner = CreateWindow(L"static", nullptr, WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, hInstance, nullptr); DWORD exStyle = WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_NOREDIRECTIONBITMAP | WS_EX_TOOLWINDOW; - return CreateWindowExW(exStyle, m_className, m_windowTitle, WS_POPUP, - CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, m_hwndOwner, nullptr, hInstance, nullptr) != nullptr; + return CreateWindowExW(exStyle, m_className, m_windowTitle, WS_POPUP, CW_USEDEFAULT, 0, CW_USEDEFAULT, 0, m_hwndOwner, nullptr, hInstance, nullptr) != nullptr; } void Highlighter::Terminate() @@ -504,6 +606,43 @@ void Highlighter::Terminate() } } +float Highlighter::GetDpiScale() const +{ + return static_cast<float>(GetDpiForWindow(m_hwnd)) / 96.0f; +} + +// Update spotlight radial mask center/radius with DPI-aware feather +void Highlighter::UpdateSpotlightMask(float cx, float cy, float radius, bool show) +{ + if (!m_spotlightMaskGradient) + { + return; + } + + m_spotlightMaskGradient.EllipseCenter({ cx, cy }); + m_spotlightMaskGradient.EllipseRadius({ radius, radius }); + + const float dpiScale = GetDpiScale(); + // Target a very fine edge: ~1 physical pixel, convert to DIPs: 1 / dpiScale + const float featherDip = 1.0f / (dpiScale > 0.0f ? dpiScale : 1.0f); + const float safeRadius = (std::max)(radius, 1.0f); + const float featherRel = (std::min)(0.25f, featherDip / safeRadius); + + if (m_maskStopInner) + { + m_maskStopInner.Offset((std::max)(0.0f, 1.0f - featherRel)); + } + + if (m_spotlightSource) + { + m_spotlightSource.Color(m_alwaysColor); + } + if (m_overlay) + { + m_overlay.IsVisible(show); + } +} + #pragma region MouseHighlighter_API void MouseHighlighterApplySettings(MouseHighlighterSettings settings) diff --git a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.h b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.h index 6116b37ce6..53e7ba2bc0 100644 --- a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.h +++ b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.h @@ -18,6 +18,7 @@ struct MouseHighlighterSettings int fadeDelayMs = MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS; int fadeDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS; bool autoActivate = MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE; + bool spotlightMode = false; }; int MouseHighlighterMain(HINSTANCE hinst, MouseHighlighterSettings settings); diff --git a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj index df0df021da..728f9ae131 100644 --- a/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj +++ b/src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{782a61be-9d85-4081-b35c-1ccc9dcc1e88}</ProjectGuid> @@ -8,17 +9,16 @@ <RootNamespace>MouseHighlighter</RootNamespace> <ProjectName>MouseHighlighter</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -32,7 +32,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.MouseHighlighter</TargetName> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'"> @@ -79,7 +79,7 @@ </ItemDefinitionGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -100,10 +100,10 @@ <ResourceCompile Include="MouseHighlighter.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -111,15 +111,15 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/MouseUtils/MouseHighlighter/dllmain.cpp b/src/modules/MouseUtils/MouseHighlighter/dllmain.cpp index 17d1513d9f..83a8837409 100644 --- a/src/modules/MouseUtils/MouseHighlighter/dllmain.cpp +++ b/src/modules/MouseUtils/MouseHighlighter/dllmain.cpp @@ -4,6 +4,8 @@ #include "trace.h" #include "MouseHighlighter.h" #include "common/utils/color.h" +#include <common/utils/EventWaiter.h> +#include <common/interop/shared_constants.h> namespace { @@ -18,6 +20,7 @@ namespace const wchar_t JSON_KEY_HIGHLIGHT_FADE_DELAY_MS[] = L"highlight_fade_delay_ms"; const wchar_t JSON_KEY_HIGHLIGHT_FADE_DURATION_MS[] = L"highlight_fade_duration_ms"; const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate"; + const wchar_t JSON_KEY_SPOTLIGHT_MODE[] = L"spotlight_mode"; } extern "C" IMAGE_DOS_HEADER __ImageBase; @@ -60,6 +63,9 @@ private: // Mouse Highlighter specific settings MouseHighlighterSettings m_highlightSettings; + // Event-driven trigger support + EventWaiter m_triggerEventWaiter; + public: // Constructor MouseHighlighter() @@ -71,6 +77,8 @@ public: // Destroy the powertoy and free memory virtual void destroy() override { + // Tear down threads/handles before deletion to avoid abort() on joinable threads during shutdown + disable(); delete this; } @@ -131,6 +139,11 @@ public: m_enabled = true; Trace::EnableMouseHighlighter(true); std::thread([=]() { MouseHighlighterMain(m_hModule, m_highlightSettings); }).detach(); + + // Start listening for external trigger event so we can invoke the same logic as the hotkey. + m_triggerEventWaiter.start(CommonSharedConstants::MOUSE_HIGHLIGHTER_TRIGGER_EVENT, [this](DWORD) { + OnHotkeyEx(); + }); } // Disable the powertoy @@ -139,6 +152,8 @@ public: m_enabled = false; Trace::EnableMouseHighlighter(false); MouseHighlighterDisable(); + + m_triggerEventWaiter.stop(); } // Returns if the powertoys is enabled @@ -367,6 +382,16 @@ public: { Logger::warn("Failed to initialize auto activate from settings. Will use default value"); } + try + { + // Parse spotlight mode + auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SPOTLIGHT_MODE); + highlightSettings.spotlightMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); + } + catch (...) + { + Logger::warn("Failed to initialize spotlight mode settings. Will use default value"); + } } else { diff --git a/src/modules/MouseUtils/MouseHighlighter/packages.config b/src/modules/MouseUtils/MouseHighlighter/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/MouseUtils/MouseHighlighter/packages.config +++ b/src/modules/MouseUtils/MouseHighlighter/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/MouseUtils/MouseHighlighter/trace.cpp b/src/modules/MouseUtils/MouseHighlighter/trace.cpp index 7f8d413b5a..1c40b699d7 100644 --- a/src/modules/MouseUtils/MouseHighlighter/trace.cpp +++ b/src/modules/MouseUtils/MouseHighlighter/trace.cpp @@ -30,3 +30,13 @@ void Trace::StartHighlightingSession() noexcept ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); } + +// Log that spotlight mode is enabled +void Trace::StartSpotlightSession() noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "MouseHighlighter_StartSpotlightSession", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} diff --git a/src/modules/MouseUtils/MouseHighlighter/trace.h b/src/modules/MouseUtils/MouseHighlighter/trace.h index 12708940e9..b0ee1e5105 100644 --- a/src/modules/MouseUtils/MouseHighlighter/trace.h +++ b/src/modules/MouseUtils/MouseHighlighter/trace.h @@ -10,4 +10,7 @@ public: // Log that the user activated the module by starting a highlighting session static void StartHighlightingSession() noexcept; + + // Log that spotlight mode is enabled + static void StartSpotlightSession() noexcept; }; 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.Common.UnitTests/Helpers/LayoutHelperTests.cs b/src/modules/MouseUtils/MouseJump.Common.UnitTests/Helpers/LayoutHelperTests.cs index 64b7d0cef2..95d364b100 100644 --- a/src/modules/MouseUtils/MouseJump.Common.UnitTests/Helpers/LayoutHelperTests.cs +++ b/src/modules/MouseUtils/MouseJump.Common.UnitTests/Helpers/LayoutHelperTests.cs @@ -60,7 +60,7 @@ public static class LayoutHelperTests yield return new object[] { new TestCase(layoutConfig, layoutInfo) }; // check we handle rounding errors in scaling the preview form - // that might make the form a pixel *smaller* than the current screen - + // that might make the form one pixel *smaller* than the current screen - // e.g. a desktop 7168 x 1440 scaled to a screen 1024 x 768 // with a 5px form padding border: // diff --git a/src/modules/MouseUtils/MouseJump.Common.UnitTests/MouseJump.Common.UnitTests.csproj b/src/modules/MouseUtils/MouseJump.Common.UnitTests/MouseJump.Common.UnitTests.csproj index 24ccf1bb0a..aa885b9071 100644 --- a/src/modules/MouseUtils/MouseJump.Common.UnitTests/MouseJump.Common.UnitTests.csproj +++ b/src/modules/MouseUtils/MouseJump.Common.UnitTests/MouseJump.Common.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> @@ -9,7 +9,7 @@ <AssemblyTitle>PowerToys.MouseJump.Common.UnitTests</AssemblyTitle> <AssemblyDescription>PowerToys MouseJump.Common.UnitTests</AssemblyDescription> <OutputType>Exe</OutputType> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\MouseJump.Common.UnitTests\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\MouseJump.Common.UnitTests\</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> diff --git a/src/modules/MouseUtils/MouseJump.Common/Helpers/MouseHelper.cs b/src/modules/MouseUtils/MouseJump.Common/Helpers/MouseHelper.cs index 443b252aa3..364618fb73 100644 --- a/src/modules/MouseUtils/MouseJump.Common/Helpers/MouseHelper.cs +++ b/src/modules/MouseUtils/MouseJump.Common/Helpers/MouseHelper.cs @@ -72,7 +72,7 @@ public static class MouseHelper // | (a) | | // +---------+----------------+ // - // setting the position a second time seems to fix this and moves the + // setting the position again seems to fix this and moves the // cursor to the expected location (b) var target = location.ToPoint(); for (var i = 0; i < 2; i++) diff --git a/src/modules/MouseUtils/MouseJump.Common/Imaging/IImageRegionCopyService.cs b/src/modules/MouseUtils/MouseJump.Common/Imaging/IImageRegionCopyService.cs index c352510e6b..54f0681328 100644 --- a/src/modules/MouseUtils/MouseJump.Common/Imaging/IImageRegionCopyService.cs +++ b/src/modules/MouseUtils/MouseJump.Common/Imaging/IImageRegionCopyService.cs @@ -10,7 +10,7 @@ public interface IImageRegionCopyService { /// <summary> /// Copies the source region from the provider's source image (e.g. the interactive desktop, - /// a static image, etc) to the target region on the specified Graphics object. + /// a static image, etc.) to the target region on the specified Graphics object. /// </summary> /// <remarks> /// Implementations of this interface are used to capture regions of the interactive desktop diff --git a/src/modules/MouseUtils/MouseJump.Common/MouseJump.Common.csproj b/src/modules/MouseUtils/MouseJump.Common/MouseJump.Common.csproj index 4b56443fa7..79d6b7b47e 100644 --- a/src/modules/MouseUtils/MouseJump.Common/MouseJump.Common.csproj +++ b/src/modules/MouseUtils/MouseJump.Common/MouseJump.Common.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> @@ -9,7 +9,7 @@ <AssemblyTitle>PowerToys.MouseJump.Common</AssemblyTitle> <AssemblyDescription>PowerToys MouseJump.Common</AssemblyDescription> <OutputType>Library</OutputType> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> diff --git a/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj b/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj index 29e8f444bf..c74aac6c94 100644 --- a/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj +++ b/src/modules/MouseUtils/MouseJump/MouseJump.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{8a08d663-4995-40e3-b42c-3f910625f284}</ProjectGuid> @@ -8,17 +9,16 @@ <RootNamespace>MouseJumpModuleInterface</RootNamespace> <ProjectName>MouseJump</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -32,7 +32,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.MouseJump</TargetName> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'"> @@ -79,7 +79,7 @@ </ItemDefinitionGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -95,10 +95,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -109,15 +109,15 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/MouseUtils/MouseJump/packages.config b/src/modules/MouseUtils/MouseJump/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/MouseUtils/MouseJump/packages.config +++ b/src/modules/MouseUtils/MouseJump/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/MouseUtils/MouseJumpUI/Helpers/SettingsHelper.cs b/src/modules/MouseUtils/MouseJumpUI/Helpers/SettingsHelper.cs index efe721e873..6e19043547 100644 --- a/src/modules/MouseUtils/MouseJumpUI/Helpers/SettingsHelper.cs +++ b/src/modules/MouseUtils/MouseJumpUI/Helpers/SettingsHelper.cs @@ -53,7 +53,7 @@ internal sealed class SettingsHelper lock (this.LockObject) { { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; // set this to 1 to disable retries var remainingRetries = 5; diff --git a/src/modules/MouseUtils/MouseJumpUI/MainForm.cs b/src/modules/MouseUtils/MouseJumpUI/MainForm.cs index d4b2ca75de..6017c427fb 100644 --- a/src/modules/MouseUtils/MouseJumpUI/MainForm.cs +++ b/src/modules/MouseUtils/MouseJumpUI/MainForm.cs @@ -217,7 +217,7 @@ internal sealed partial class MainForm : Form this.Thumbnail.Image = null; tmp.Dispose(); - // force preview image memory to be released, otherwise + // force preview image memory to be released; otherwise, // all the disposed images can pile up without being GC'ed GC.Collect(); } @@ -250,7 +250,7 @@ internal sealed partial class MainForm : Form if (!this.Visible) { // we seem to need to turn off topmost and then re-enable it again - // when we show the form, otherwise it doesn't always get shown topmost... + // when we show the form; otherwise, it doesn't always get shown topmost... this.TopMost = false; this.TopMost = true; this.Show(); diff --git a/src/modules/MouseUtils/MouseJumpUI/MouseJumpUI.csproj b/src/modules/MouseUtils/MouseJumpUI/MouseJumpUI.csproj index 4ffd69bee6..53f949f4c3 100644 --- a/src/modules/MouseUtils/MouseJumpUI/MouseJumpUI.csproj +++ b/src/modules/MouseUtils/MouseJumpUI/MouseJumpUI.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> @@ -9,7 +9,7 @@ <AssemblyTitle>PowerToys.MouseJumpUI</AssemblyTitle> <AssemblyDescription>PowerToys MouseJumpUI</AssemblyDescription> <OutputType>WinExe</OutputType> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> diff --git a/src/modules/MouseUtils/MouseJumpUI/Telemetry/MouseJumpShowEvent.cs b/src/modules/MouseUtils/MouseJumpUI/Telemetry/MouseJumpShowEvent.cs index 6dff94368c..0d721bbd1e 100644 --- a/src/modules/MouseUtils/MouseJumpUI/Telemetry/MouseJumpShowEvent.cs +++ b/src/modules/MouseUtils/MouseJumpUI/Telemetry/MouseJumpShowEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace MouseJumpUI.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MouseJumpShowEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/MouseUtils/MouseJumpUI/Telemetry/MouseJumpTeleportCursorEvent.cs b/src/modules/MouseUtils/MouseJumpUI/Telemetry/MouseJumpTeleportCursorEvent.cs index f09ff59985..e392ff6d1d 100644 --- a/src/modules/MouseUtils/MouseJumpUI/Telemetry/MouseJumpTeleportCursorEvent.cs +++ b/src/modules/MouseUtils/MouseJumpUI/Telemetry/MouseJumpTeleportCursorEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace MouseJumpUI.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MouseJumpTeleportCursorEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp index 18282499b8..9b155cb3f3 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp +++ b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.cpp @@ -27,6 +27,88 @@ struct InclusiveCrosshairs void SwitchActivationMode(); void ApplySettings(InclusiveCrosshairsSettings& settings, bool applyToRuntimeObjects); +public: + // Allow external callers to request a position update (thread-safe enqueue) + static void RequestUpdatePosition() + { + if (instance != nullptr) + { + auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue(); + dispatcherQueue.TryEnqueue([]() { + if (instance != nullptr) + { + instance->UpdateCrosshairsPosition(); + } + }); + } + } + + static void EnsureOn() + { + if (instance != nullptr) + { + auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue(); + dispatcherQueue.TryEnqueue([]() { + if (instance != nullptr && !instance->m_drawing) + { + instance->StartDrawing(); + } + }); + } + } + + static void EnsureOff() + { + if (instance != nullptr) + { + auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue(); + dispatcherQueue.TryEnqueue([]() { + if (instance != nullptr && instance->m_drawing) + { + instance->StopDrawing(); + } + }); + } + } + + static void SetExternalControl(bool enabled) + { + if (instance != nullptr) + { + auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue(); + dispatcherQueue.TryEnqueue([enabled]() { + if (instance != nullptr) + { + instance->m_externalControl = enabled; + if (enabled && instance->m_mouseHook) + { + UnhookWindowsHookEx(instance->m_mouseHook); + instance->m_mouseHook = NULL; + } + else if (!enabled && instance->m_drawing && !instance->m_mouseHook) + { + instance->m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, MouseHookProc, instance->m_hinstance, 0); + } + } + }); + } + } + + static void SetCrosshairsOrientation(CrosshairsOrientation orientation) + { + if (instance != nullptr) + { + auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue(); + dispatcherQueue.TryEnqueue([orientation]() { + if (instance != nullptr) + { + instance->m_crosshairs_orientation = orientation; + instance->UpdateCrosshairsPosition(); + } + }); + } + } + private: enum class MouseButton { @@ -69,6 +151,7 @@ private: bool m_drawing = false; bool m_destroyed = false; bool m_hiddenCursor = false; + bool m_externalControl = false; void SetAutoHideTimer() noexcept; // Configurable Settings @@ -79,6 +162,7 @@ private: int m_crosshairs_border_size = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_BORDER_SIZE; bool m_crosshairs_is_fixed_length_enabled = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED; int m_crosshairs_fixed_length = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH; + CrosshairsOrientation m_crosshairs_orientation = static_cast<CrosshairsOrientation>(INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION); float m_crosshairs_opacity = max(0.f, min(1.f, (float)INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_OPACITY / 100.0f)); bool m_crosshairs_auto_hide = INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE; }; @@ -181,7 +265,7 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() { POINT ptCursor; - // HACK: Draw with 1 pixel off. Otherwise Windows glitches the task bar transparency when a transparent window fill the whole screen. + // HACK: Draw with 1 pixel off. Otherwise, Windows glitches the task bar transparency when a transparent window fill the whole screen. SetWindowPos(m_hwnd, HWND_TOPMOST, GetSystemMetrics(SM_XVIRTUALSCREEN) + 1, GetSystemMetrics(SM_YVIRTUALSCREEN) + 1, GetSystemMetrics(SM_CXVIRTUALSCREEN) - 2, GetSystemMetrics(SM_CYVIRTUALSCREEN) - 2, 0); GetCursorPos(&ptCursor); @@ -218,6 +302,8 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() float halfPixelAdjustment = m_crosshairs_thickness % 2 == 1 ? 0.5f : 0.0f; float borderSizePadding = m_crosshairs_border_size * 2.f; + // Left and Right crosshairs (horizontal line) + if (m_crosshairs_orientation == CrosshairsOrientation::Both || m_crosshairs_orientation == CrosshairsOrientation::HorizontalOnly) { float leftCrosshairsFullScreenLength = ptCursor.x - ptMonitorUpperLeft.x - m_crosshairs_radius + halfPixelAdjustment * 2.f; float leftCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : leftCrosshairsFullScreenLength; @@ -226,9 +312,7 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() m_left_crosshairs_border.Size({ leftCrosshairsBorderLength, m_crosshairs_thickness + borderSizePadding }); m_left_crosshairs.Offset({ ptCursor.x - m_crosshairs_radius + halfPixelAdjustment * 2.f, ptCursor.y + halfPixelAdjustment, .0f }); m_left_crosshairs.Size({ leftCrosshairsLength, static_cast<float>(m_crosshairs_thickness) }); - } - { float rightCrosshairsFullScreenLength = static_cast<float>(ptMonitorBottomRight.x) - ptCursor.x - m_crosshairs_radius; float rightCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : rightCrosshairsFullScreenLength; float rightCrosshairsBorderLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length + borderSizePadding : rightCrosshairsFullScreenLength + m_crosshairs_border_size; @@ -237,7 +321,17 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() m_right_crosshairs.Offset({ static_cast<float>(ptCursor.x) + m_crosshairs_radius, ptCursor.y + halfPixelAdjustment, .0f }); m_right_crosshairs.Size({ rightCrosshairsLength, static_cast<float>(m_crosshairs_thickness) }); } + else + { + // Hide horizontal crosshairs by setting size to 0 + m_left_crosshairs_border.Size({ 0.0f, 0.0f }); + m_left_crosshairs.Size({ 0.0f, 0.0f }); + m_right_crosshairs_border.Size({ 0.0f, 0.0f }); + m_right_crosshairs.Size({ 0.0f, 0.0f }); + } + // Top and Bottom crosshairs (vertical line) + if (m_crosshairs_orientation == CrosshairsOrientation::Both || m_crosshairs_orientation == CrosshairsOrientation::VerticalOnly) { float topCrosshairsFullScreenLength = ptCursor.y - ptMonitorUpperLeft.y - m_crosshairs_radius + halfPixelAdjustment * 2.f; float topCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : topCrosshairsFullScreenLength; @@ -246,9 +340,7 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() m_top_crosshairs_border.Size({ m_crosshairs_thickness + borderSizePadding, topCrosshairsBorderLength }); m_top_crosshairs.Offset({ ptCursor.x + halfPixelAdjustment, ptCursor.y - m_crosshairs_radius + halfPixelAdjustment * 2.f, .0f }); m_top_crosshairs.Size({ static_cast<float>(m_crosshairs_thickness), topCrosshairsLength }); - } - { float bottomCrosshairsFullScreenLength = static_cast<float>(ptMonitorBottomRight.y) - ptCursor.y - m_crosshairs_radius; float bottomCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : bottomCrosshairsFullScreenLength; float bottomCrosshairsBorderLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length + borderSizePadding : bottomCrosshairsFullScreenLength + m_crosshairs_border_size; @@ -257,6 +349,14 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition() m_bottom_crosshairs.Offset({ ptCursor.x + halfPixelAdjustment, static_cast<float>(ptCursor.y) + m_crosshairs_radius, .0f }); m_bottom_crosshairs.Size({ static_cast<float>(m_crosshairs_thickness), bottomCrosshairsLength }); } + else + { + // Hide vertical crosshairs by setting size to 0 + m_top_crosshairs_border.Size({ 0.0f, 0.0f }); + m_top_crosshairs.Size({ 0.0f, 0.0f }); + m_bottom_crosshairs_border.Size({ 0.0f, 0.0f }); + m_bottom_crosshairs.Size({ 0.0f, 0.0f }); + } } LRESULT CALLBACK InclusiveCrosshairs::MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept @@ -264,9 +364,12 @@ LRESULT CALLBACK InclusiveCrosshairs::MouseHookProc(int nCode, WPARAM wParam, LP if (nCode >= 0) { MSLLHOOKSTRUCT* hookData = reinterpret_cast<MSLLHOOKSTRUCT*>(lParam); - if (wParam == WM_MOUSEMOVE) + if (instance && !instance->m_externalControl) { - instance->UpdateCrosshairsPosition(); + if (wParam == WM_MOUSEMOVE) + { + instance->UpdateCrosshairsPosition(); + } } } return CallNextHookEx(0, nCode, wParam, lParam); @@ -327,6 +430,7 @@ void InclusiveCrosshairs::ApplySettings(InclusiveCrosshairsSettings& settings, b m_crosshairs_auto_hide = settings.crosshairsAutoHide; m_crosshairs_is_fixed_length_enabled = settings.crosshairsIsFixedLengthEnabled; m_crosshairs_fixed_length = settings.crosshairsFixedLength; + m_crosshairs_orientation = settings.crosshairsOrientation; if (applyToRunTimeObjects) { @@ -527,6 +631,31 @@ bool InclusiveCrosshairsIsEnabled() return (InclusiveCrosshairs::instance != nullptr); } +void InclusiveCrosshairsRequestUpdatePosition() +{ + InclusiveCrosshairs::RequestUpdatePosition(); +} + +void InclusiveCrosshairsEnsureOn() +{ + InclusiveCrosshairs::EnsureOn(); +} + +void InclusiveCrosshairsEnsureOff() +{ + InclusiveCrosshairs::EnsureOff(); +} + +void InclusiveCrosshairsSetExternalControl(bool enabled) +{ + InclusiveCrosshairs::SetExternalControl(enabled); +} + +void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation) +{ + InclusiveCrosshairs::SetCrosshairsOrientation(orientation); +} + int InclusiveCrosshairsMain(HINSTANCE hInstance, InclusiveCrosshairsSettings& settings) { Logger::info("Starting a crosshairs instance."); diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h index 43456a4326..4475a397a8 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h +++ b/src/modules/MouseUtils/MousePointerCrosshairs/InclusiveCrosshairs.h @@ -10,8 +10,16 @@ constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_BORDER_SIZE = 1; constexpr bool INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE = false; constexpr bool INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED = false; constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH = 1; +constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION = 0; // 0=Both, 1=Vertical, 2=Horizontal constexpr bool INCLUSIVE_MOUSE_DEFAULT_AUTO_ACTIVATE = false; +enum struct CrosshairsOrientation : int +{ + Both = 0, + VerticalOnly = 1, + HorizontalOnly = 2, +}; + struct InclusiveCrosshairsSettings { winrt::Windows::UI::Color crosshairsColor = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_COLOR; @@ -23,6 +31,7 @@ struct InclusiveCrosshairsSettings bool crosshairsAutoHide = INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE; bool crosshairsIsFixedLengthEnabled = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED; int crosshairsFixedLength = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH; + CrosshairsOrientation crosshairsOrientation = static_cast<CrosshairsOrientation>(INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION); bool autoActivate = INCLUSIVE_MOUSE_DEFAULT_AUTO_ACTIVATE; }; @@ -31,3 +40,8 @@ void InclusiveCrosshairsDisable(); bool InclusiveCrosshairsIsEnabled(); void InclusiveCrosshairsSwitch(); void InclusiveCrosshairsApplySettings(InclusiveCrosshairsSettings& settings); +void InclusiveCrosshairsRequestUpdatePosition(); +void InclusiveCrosshairsEnsureOn(); +void InclusiveCrosshairsEnsureOff(); +void InclusiveCrosshairsSetExternalControl(bool enabled); +void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation); diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj index 7da54a51e9..09dfcb5861 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj +++ b/src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{eae14c0e-7a6b-45da-9080-a7d8c077ba6e}</ProjectGuid> @@ -8,18 +9,17 @@ <RootNamespace>MousePointerCrosshairs</RootNamespace> <ProjectName>MousePointerCrosshairs</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -33,7 +33,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.MousePointerCrosshairs</TargetName> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'"> @@ -80,7 +80,7 @@ </ItemDefinitionGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;..\..\..\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -101,10 +101,10 @@ <ResourceCompile Include="MousePointerCrosshairs.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -113,13 +113,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp b/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp index d2273c7efd..5697d83d30 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp +++ b/src/modules/MouseUtils/MousePointerCrosshairs/dllmain.cpp @@ -4,6 +4,20 @@ #include "trace.h" #include "InclusiveCrosshairs.h" #include "common/utils/color.h" +#include <common/utils/EventWaiter.h> +#include <common/interop/shared_constants.h> +#include <thread> +#include <chrono> +#include <memory> +#include <algorithm> + +extern void InclusiveCrosshairsRequestUpdatePosition(); +extern void InclusiveCrosshairsEnsureOn(); +extern void InclusiveCrosshairsEnsureOff(); +extern void InclusiveCrosshairsSetExternalControl(bool enabled); +extern void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation); +extern bool InclusiveCrosshairsIsEnabled(); +extern void InclusiveCrosshairsSwitch(); // Non-Localizable strings namespace @@ -11,6 +25,7 @@ namespace const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; const wchar_t JSON_KEY_VALUE[] = L"value"; const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"activation_shortcut"; + const wchar_t JSON_KEY_GLIDING_ACTIVATION_SHORTCUT[] = L"gliding_cursor_activation_shortcut"; const wchar_t JSON_KEY_CROSSHAIRS_COLOR[] = L"crosshairs_color"; const wchar_t JSON_KEY_CROSSHAIRS_OPACITY[] = L"crosshairs_opacity"; const wchar_t JSON_KEY_CROSSHAIRS_RADIUS[] = L"crosshairs_radius"; @@ -20,14 +35,17 @@ namespace const wchar_t JSON_KEY_CROSSHAIRS_AUTO_HIDE[] = L"crosshairs_auto_hide"; const wchar_t JSON_KEY_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED[] = L"crosshairs_is_fixed_length_enabled"; const wchar_t JSON_KEY_CROSSHAIRS_FIXED_LENGTH[] = L"crosshairs_fixed_length"; + const wchar_t JSON_KEY_CROSSHAIRS_ORIENTATION[] = L"crosshairs_orientation"; const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate"; + const wchar_t JSON_KEY_GLIDE_TRAVEL_SPEED[] = L"gliding_travel_speed"; + const wchar_t JSON_KEY_GLIDE_DELAY_SPEED[] = L"gliding_delay_speed"; } extern "C" IMAGE_DOS_HEADER __ImageBase; HMODULE m_hModule; -BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID /*lpReserved*/) { m_hModule = hModule; switch (ul_reason_for_call) @@ -50,6 +68,9 @@ const static wchar_t* MODULE_NAME = L"MousePointerCrosshairs"; // Add a description that will we shown in the module settings page. const static wchar_t* MODULE_DESC = L"<no description>"; +class MousePointerCrosshairs; // fwd +static std::atomic<MousePointerCrosshairs*> g_instance{ nullptr }; // for hook callback + // Implement the PowerToy Module Interface and all the required methods. class MousePointerCrosshairs : public PowertoyModuleIface { @@ -57,23 +78,73 @@ private: // The PowerToy state. bool m_enabled = false; - // Hotkey to invoke the module - HotkeyEx m_hotkey; + // Additional hotkeys (legacy API) to support multiple shortcuts + Hotkey m_activationHotkey{}; // Crosshairs toggle + Hotkey m_glidingHotkey{}; // Gliding cursor state machine + + // Low-level keyboard hook (Escape to cancel gliding) + HHOOK m_keyboardHook = nullptr; + + // Shared state for worker threads (decoupled from this lifetime) + struct State + { + std::atomic<bool> stopX{ false }; + std::atomic<bool> stopY{ false }; + + // positions and speeds + int currentXPos{ 0 }; + int currentYPos{ 0 }; + int currentXSpeed{ 0 }; // pixels per base window + int currentYSpeed{ 0 }; // pixels per base window + int xPosSnapshot{ 0 }; // xPos captured at end of horizontal scan + + // Fractional accumulators to spread movement across 10ms ticks + double xFraction{ 0.0 }; + double yFraction{ 0.0 }; + + // Speeds represent pixels per 200ms (min 5, max 60 enforced by UI/settings) + int fastHSpeed{ 30 }; // pixels per base window + int slowHSpeed{ 5 }; // pixels per base window + int fastVSpeed{ 30 }; // pixels per base window + int slowVSpeed{ 5 }; // pixels per base window + }; + + std::shared_ptr<State> m_state; + + // Worker threads + std::thread m_xThread; + std::thread m_yThread; + + // Gliding cursor state machine + std::atomic<int> m_glideState{ 0 }; // 0..4 like the AHK script + + // Timer configuration: 10ms tick, speeds are defined per 200ms base window + static constexpr int kTimerTickMs = 10; + static constexpr int kBaseSpeedTickMs = 200; // mapping period for configured pixel counts // Mouse Pointer Crosshairs specific settings InclusiveCrosshairsSettings m_inclusiveCrosshairsSettings; + // Event-driven trigger support + EventWaiter m_triggerEventWaiter; + public: // Constructor MousePointerCrosshairs() { LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::mousePointerCrosshairsLoggerName); + m_state = std::make_shared<State>(); init_settings(); + g_instance.store(this, std::memory_order_release); }; // Destroy the powertoy and free memory virtual void destroy() override { + // Ensure all background threads/handles are torn down before destruction to avoid std::terminate/abort on joinable threads + disable(); + g_instance.store(nullptr, std::memory_order_release); + m_state.reset(); delete this; } @@ -107,9 +178,7 @@ public: // Signal from the Settings editor to call a custom action. // This can be used to spawn more complex editors. - virtual void call_custom_action(const wchar_t* action) override - { - } + virtual void call_custom_action(const wchar_t* /*action*/) override {} // Called by the runner to pass the updated settings values as a serialized JSON. virtual void set_config(const wchar_t* config) override @@ -136,6 +205,11 @@ public: m_enabled = true; Trace::EnableMousePointerCrosshairs(true); std::thread([=]() { InclusiveCrosshairsMain(m_hModule, m_inclusiveCrosshairsSettings); }).detach(); + + // Start listening for external trigger event so we can invoke the same logic as the activation hotkey. + m_triggerEventWaiter.start(CommonSharedConstants::MOUSE_CROSSHAIRS_TRIGGER_EVENT, [this](DWORD) { + on_hotkey(0); // activation hotkey + }); } // Disable the powertoy @@ -143,7 +217,13 @@ public: { m_enabled = false; Trace::EnableMousePointerCrosshairs(false); + UninstallKeyboardHook(); + StopXTimer(); + StopYTimer(); + m_glideState = 0; InclusiveCrosshairsDisable(); + + m_triggerEventWaiter.stop(); } // Returns if the powertoys is enabled @@ -158,15 +238,347 @@ public: return false; } - virtual std::optional<HotkeyEx> GetHotkeyEx() override + // Legacy multi-hotkey support (like CropAndLock) + virtual size_t get_hotkeys(Hotkey* buffer, size_t buffer_size) override { - return m_hotkey; + if (buffer && buffer_size >= 2) + { + buffer[0] = m_activationHotkey; // Crosshairs toggle + buffer[1] = m_glidingHotkey; // Gliding cursor toggle + } + return 2; } - virtual void OnHotkeyEx() override + virtual bool on_hotkey(size_t hotkeyId) override { - InclusiveCrosshairsSwitch(); + if (!m_enabled) + { + return false; + } + + if (hotkeyId == 0) // Crosshairs activation + { + // If gliding cursor is active, cancel it and activate crosshairs + if (m_glideState.load() != 0) + { + CancelGliding(true /*activateCrosshairs*/); + return true; + } + // Otherwise, normal crosshairs toggle + InclusiveCrosshairsSwitch(); + return true; + } + if (hotkeyId == 1) // Gliding cursor activation + { + HandleGlidingHotkey(); + return true; + } + return false; } + +private: + static void LeftClick() + { + INPUT inputs[2]{}; + inputs[0].type = INPUT_MOUSE; + inputs[0].mi.dwFlags = MOUSEEVENTF_LEFTDOWN; + inputs[1].type = INPUT_MOUSE; + inputs[1].mi.dwFlags = MOUSEEVENTF_LEFTUP; + SendInput(2, inputs, sizeof(INPUT)); + } + + // Cancel gliding with option to activate crosshairs in user's preferred orientation + void CancelGliding(bool activateCrosshairs) + { + int state = m_glideState.load(); + if (state == 0) + { + return; // nothing to cancel + } + + // Stop all gliding operations + StopXTimer(); + StopYTimer(); + m_glideState = 0; + UninstallKeyboardHook(); + + // Reset crosshairs control and restore user settings + InclusiveCrosshairsSetExternalControl(false); + InclusiveCrosshairsSetOrientation(m_inclusiveCrosshairsSettings.crosshairsOrientation); + + if (activateCrosshairs) + { + // User is switching to crosshairs mode - enable with their settings + InclusiveCrosshairsEnsureOn(); + } + else + { + // User canceled (Escape) - turn off crosshairs completely + InclusiveCrosshairsEnsureOff(); + } + + // Reset gliding state + if (auto s = m_state) + { + s->xFraction = 0.0; + s->yFraction = 0.0; + } + + Logger::debug("Gliding cursor cancelled (activateCrosshairs={})", activateCrosshairs ? 1 : 0); + } + + // Stateless helpers operating on shared State + static void PositionCursorX(const std::shared_ptr<State>& s) + { + int screenW = GetSystemMetrics(SM_CXVIRTUALSCREEN); + int screenH = GetSystemMetrics(SM_CYVIRTUALSCREEN); + s->currentYPos = screenH / 2; + + // Distribute movement over 10ms ticks to match pixels-per-base-window speeds + const double perTick = (static_cast<double>(s->currentXSpeed) * kTimerTickMs) / static_cast<double>(kBaseSpeedTickMs); + s->xFraction += perTick; + int step = static_cast<int>(s->xFraction); + if (step > 0) + { + s->xFraction -= step; + s->currentXPos += step; + } + + s->xPosSnapshot = s->currentXPos; + if (s->currentXPos >= screenW) + { + s->currentXPos = 0; + s->currentXSpeed = s->fastHSpeed; + s->xPosSnapshot = 0; + s->xFraction = 0.0; // reset fractional remainder on wrap + } + SetCursorPos(s->currentXPos, s->currentYPos); + // Ensure overlay crosshairs follow immediately + InclusiveCrosshairsRequestUpdatePosition(); + } + + static void PositionCursorY(const std::shared_ptr<State>& s) + { + int screenH = GetSystemMetrics(SM_CYVIRTUALSCREEN); + // Keep X at snapshot + // Use s->xPosSnapshot captured during X pass + + // Distribute movement over 10ms ticks to match pixels-per-base-window speeds + const double perTick = (static_cast<double>(s->currentYSpeed) * kTimerTickMs) / static_cast<double>(kBaseSpeedTickMs); + s->yFraction += perTick; + int step = static_cast<int>(s->yFraction); + if (step > 0) + { + s->yFraction -= step; + s->currentYPos += step; + } + + if (s->currentYPos >= screenH) + { + s->currentYPos = 0; + s->currentYSpeed = s->fastVSpeed; + s->yFraction = 0.0; // reset fractional remainder on wrap + } + SetCursorPos(s->xPosSnapshot, s->currentYPos); + // Ensure overlay crosshairs follow immediately + InclusiveCrosshairsRequestUpdatePosition(); + } + + void StartXTimer() + { + auto s = m_state; + if (!s) + { + return; + } + s->stopX = false; + std::weak_ptr<State> wp = s; + m_xThread = std::thread([wp]() { + while (true) + { + auto sp = wp.lock(); + if (!sp || sp->stopX.load()) + { + break; + } + PositionCursorX(sp); + std::this_thread::sleep_for(std::chrono::milliseconds(kTimerTickMs)); + } + }); + } + + void StopXTimer() + { + auto s = m_state; + if (s) + { + s->stopX = true; + } + if (m_xThread.joinable()) + { + m_xThread.join(); + } + } + + void StartYTimer() + { + auto s = m_state; + if (!s) + { + return; + } + s->stopY = false; + std::weak_ptr<State> wp = s; + m_yThread = std::thread([wp]() { + while (true) + { + auto sp = wp.lock(); + if (!sp || sp->stopY.load()) + { + break; + } + PositionCursorY(sp); + std::this_thread::sleep_for(std::chrono::milliseconds(kTimerTickMs)); + } + }); + } + + void StopYTimer() + { + auto s = m_state; + if (s) + { + s->stopY = true; + } + if (m_yThread.joinable()) + { + m_yThread.join(); + } + } + + void HandleGlidingHotkey() + { + auto s = m_state; + if (!s) + { + return; + } + + int state = m_glideState.load(); + switch (state) + { + case 0: // Starting gliding + { + // Install keyboard hook for Escape cancellation + InstallKeyboardHook(); + + // Force crosshairs visible in BOTH orientation for gliding, regardless of user setting + // Set external control before enabling to prevent internal movement hook from attaching + InclusiveCrosshairsSetExternalControl(true); + InclusiveCrosshairsSetOrientation(CrosshairsOrientation::Both); + InclusiveCrosshairsEnsureOn(); // Always ensure they are visible + + // Initialize gliding state + s->currentXPos = 0; + s->currentXSpeed = s->fastHSpeed; + s->xFraction = 0.0; + s->yFraction = 0.0; + int y = GetSystemMetrics(SM_CYVIRTUALSCREEN) / 2; + SetCursorPos(0, y); + InclusiveCrosshairsRequestUpdatePosition(); + + m_glideState = 1; + StartXTimer(); + break; + } + case 1: // Slow horizontal + s->currentXSpeed = s->slowHSpeed; + m_glideState = 2; + break; + case 2: // Switch to vertical fast + { + StopXTimer(); + s->currentYSpeed = s->fastVSpeed; + s->currentYPos = 0; + s->yFraction = 0.0; + SetCursorPos(s->xPosSnapshot, s->currentYPos); + InclusiveCrosshairsRequestUpdatePosition(); + m_glideState = 3; + StartYTimer(); + break; + } + case 3: // Slow vertical + s->currentYSpeed = s->slowVSpeed; + m_glideState = 4; + break; + case 4: // Finalize (click and end) + default: + { + // Complete the gliding sequence + StopYTimer(); + m_glideState = 0; + LeftClick(); + + // Restore normal crosshairs operation and turn them off + InclusiveCrosshairsSetExternalControl(false); + InclusiveCrosshairsSetOrientation(m_inclusiveCrosshairsSettings.crosshairsOrientation); + InclusiveCrosshairsEnsureOff(); + + UninstallKeyboardHook(); + + // Reset state + if (auto sp = m_state) + { + sp->xFraction = 0.0; + sp->yFraction = 0.0; + } + break; + } + } + } + + // Low-level keyboard hook for Escape cancellation + static LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) + { + if (nCode == HC_ACTION) + { + const KBDLLHOOKSTRUCT* kb = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam); + if (kb && kb->vkCode == VK_ESCAPE && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN)) + { + if (auto inst = g_instance.load(std::memory_order_acquire)) + { + if (inst->m_enabled && inst->m_glideState.load() != 0) + { + inst->CancelGliding(false); // Escape cancels without activating crosshairs + } + } + } + } + return CallNextHookEx(nullptr, nCode, wParam, lParam); + } + + void InstallKeyboardHook() + { + if (m_keyboardHook) + { + return; // already installed + } + m_keyboardHook = SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKeyboardProc, m_hModule, 0); + if (!m_keyboardHook) + { + Logger::error("Failed to install low-level keyboard hook for MousePointerCrosshairs (Escape cancel). GetLastError={}.", GetLastError()); + } + } + + void UninstallKeyboardHook() + { + if (m_keyboardHook) + { + UnhookWindowsHookEx(m_keyboardHook); + m_keyboardHook = nullptr; + } + } + // Load the settings file. void init_settings() { @@ -185,221 +597,317 @@ public: void parse_settings(PowerToysSettings::PowerToyValues& settings) { - // TODO: refactor to use common/utils/json.h instead + // Refactored JSON parsing: uses inline try-catch blocks for each property for clarity and error handling auto settingsObject = settings.get_raw_json(); InclusiveCrosshairsSettings inclusiveCrosshairsSettings; + if (settingsObject.GetView().Size()) { try { - // Parse HotKey - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); - auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject); - m_hotkey = HotkeyEx(); - if (hotkey.win_pressed()) - { - m_hotkey.modifiersMask |= MOD_WIN; - } - - if (hotkey.ctrl_pressed()) - { - m_hotkey.modifiersMask |= MOD_CONTROL; - } - - if (hotkey.shift_pressed()) - { - m_hotkey.modifiersMask |= MOD_SHIFT; - } - - if (hotkey.alt_pressed()) - { - m_hotkey.modifiersMask |= MOD_ALT; - } - - m_hotkey.vkCode = hotkey.get_code(); - } - catch (...) - { - Logger::warn("Failed to initialize Mouse Pointer Crosshairs activation shortcut"); - } - try - { - // Parse Opacity - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_OPACITY); - int value = static_cast<uint8_t>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - inclusiveCrosshairsSettings.crosshairsOpacity = value; - } - else - { - throw std::runtime_error("Invalid Opacity value"); - } - } - catch (...) - { - Logger::warn("Failed to initialize Opacity from settings. Will use default value"); - } - try - { - // Parse crosshairs color - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_COLOR); - auto crosshairsColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); - uint8_t r, g, b; - if (!checkValidRGB(crosshairsColor, &r, &g, &b)) - { - Logger::error("Crosshairs color RGB value is invalid. Will use default value"); - } - else - { - inclusiveCrosshairsSettings.crosshairsColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); - } - } - catch (...) - { - Logger::warn("Failed to initialize crosshairs color from settings. Will use default value"); - } - try - { - // Parse Radius - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_RADIUS); - int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - inclusiveCrosshairsSettings.crosshairsRadius = value; - } - else - { - throw std::runtime_error("Invalid Radius value"); - } + auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES); - } - catch (...) - { - Logger::warn("Failed to initialize Radius from settings. Will use default value"); - } - try - { - // Parse Thickness - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_THICKNESS); - int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) + // Parse activation hotkey + try { - inclusiveCrosshairsSettings.crosshairsThickness = value; + auto jsonHotkeyObject = propertiesObject.GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT); + auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonHotkeyObject); + m_activationHotkey.win = hotkey.win_pressed(); + m_activationHotkey.ctrl = hotkey.ctrl_pressed(); + m_activationHotkey.shift = hotkey.shift_pressed(); + m_activationHotkey.alt = hotkey.alt_pressed(); + m_activationHotkey.key = static_cast<unsigned char>(hotkey.get_code()); } - else + catch (...) { - throw std::runtime_error("Invalid Thickness value"); + Logger::warn("Failed to initialize Mouse Pointer Crosshairs activation shortcut"); } - - } - catch (...) - { - Logger::warn("Failed to initialize Thickness from settings. Will use default value"); - } - try - { - // Parse crosshairs border color - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_BORDER_COLOR); - auto crosshairsBorderColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE); - uint8_t r, g, b; - if (!checkValidRGB(crosshairsBorderColor, &r, &g, &b)) + + // Parse gliding cursor hotkey + try { - Logger::error("Crosshairs border color RGB value is invalid. Will use default value"); + auto jsonHotkeyObject = propertiesObject.GetNamedObject(JSON_KEY_GLIDING_ACTIVATION_SHORTCUT); + auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonHotkeyObject); + m_glidingHotkey.win = hotkey.win_pressed(); + m_glidingHotkey.ctrl = hotkey.ctrl_pressed(); + m_glidingHotkey.shift = hotkey.shift_pressed(); + m_glidingHotkey.alt = hotkey.alt_pressed(); + m_glidingHotkey.key = static_cast<unsigned char>(hotkey.get_code()); } - else + catch (...) { - inclusiveCrosshairsSettings.crosshairsBorderColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); + Logger::warn("Failed to initialize Gliding Cursor activation shortcut. Using default Win+Alt+."); + m_glidingHotkey.win = true; + m_glidingHotkey.alt = true; + m_glidingHotkey.ctrl = false; + m_glidingHotkey.shift = false; + m_glidingHotkey.key = VK_OEM_PERIOD; + } + + // Parse individual properties with error handling and defaults + try + { + if (propertiesObject.HasKey(L"crosshairs_opacity")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_opacity"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsOpacity = static_cast<int>(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_radius")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_radius"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsRadius = static_cast<int>(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_thickness")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_thickness"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsThickness = static_cast<int>(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_border_size")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_border_size"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsBorderSize = static_cast<int>(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_fixed_length")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_fixed_length"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsFixedLength = static_cast<int>(propertyObj.GetNamedNumber(L"value")); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_auto_hide")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_auto_hide"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsAutoHide = propertyObj.GetNamedBoolean(L"value"); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_is_fixed_length_enabled")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_is_fixed_length_enabled"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.crosshairsIsFixedLengthEnabled = propertyObj.GetNamedBoolean(L"value"); + } + } + } + catch (...) { /* Use default value */ } + + try + { + if (propertiesObject.HasKey(L"auto_activate")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"auto_activate"); + if (propertyObj.HasKey(L"value")) + { + inclusiveCrosshairsSettings.autoActivate = propertyObj.GetNamedBoolean(L"value"); + } + } + } + catch (...) { /* Use default value */ } + + // Parse orientation with validation - this fixes the original issue! + try + { + if (propertiesObject.HasKey(L"crosshairs_orientation")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_orientation"); + if (propertyObj.HasKey(L"value")) + { + int orientationValue = static_cast<int>(propertyObj.GetNamedNumber(L"value")); + if (orientationValue >= 0 && orientationValue <= 2) + { + inclusiveCrosshairsSettings.crosshairsOrientation = static_cast<CrosshairsOrientation>(orientationValue); + } + } + } + } + catch (...) { /* Use default value (Both = 0) */ } + + // Parse colors with validation + try + { + if (propertiesObject.HasKey(L"crosshairs_color")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_color"); + if (propertyObj.HasKey(L"value")) + { + std::wstring crosshairsColorValue = std::wstring(propertyObj.GetNamedString(L"value").c_str()); + uint8_t r, g, b; + if (checkValidRGB(crosshairsColorValue, &r, &g, &b)) + { + inclusiveCrosshairsSettings.crosshairsColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); + } + } + } + } + catch (...) { /* Use default color */ } + + try + { + if (propertiesObject.HasKey(L"crosshairs_border_color")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_border_color"); + if (propertyObj.HasKey(L"value")) + { + std::wstring borderColorValue = std::wstring(propertyObj.GetNamedString(L"value").c_str()); + uint8_t r, g, b; + if (checkValidRGB(borderColorValue, &r, &g, &b)) + { + inclusiveCrosshairsSettings.crosshairsBorderColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b); + } + } + } + } + catch (...) { /* Use default border color */ } + + // Parse speed settings with validation + try + { + if (propertiesObject.HasKey(L"gliding_travel_speed")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"gliding_travel_speed"); + if (propertyObj.HasKey(L"value") && m_state) + { + int travelSpeedValue = static_cast<int>(propertyObj.GetNamedNumber(L"value")); + if (travelSpeedValue >= 5 && travelSpeedValue <= 60) + { + m_state->fastHSpeed = travelSpeedValue; + m_state->fastVSpeed = travelSpeedValue; + } + else + { + // Clamp to valid range + int clampedValue = travelSpeedValue; + if (clampedValue < 5) clampedValue = 5; + if (clampedValue > 60) clampedValue = 60; + m_state->fastHSpeed = clampedValue; + m_state->fastVSpeed = clampedValue; + Logger::warn("Travel speed value out of range, clamped to valid range"); + } + } + } + } + catch (...) + { + if (m_state) + { + m_state->fastHSpeed = 25; + m_state->fastVSpeed = 25; + } + } + + try + { + if (propertiesObject.HasKey(L"gliding_delay_speed")) + { + auto propertyObj = propertiesObject.GetNamedObject(L"gliding_delay_speed"); + if (propertyObj.HasKey(L"value") && m_state) + { + int delaySpeedValue = static_cast<int>(propertyObj.GetNamedNumber(L"value")); + if (delaySpeedValue >= 5 && delaySpeedValue <= 60) + { + m_state->slowHSpeed = delaySpeedValue; + m_state->slowVSpeed = delaySpeedValue; + } + else + { + // Clamp to valid range + int clampedValue = delaySpeedValue; + if (clampedValue < 5) clampedValue = 5; + if (clampedValue > 60) clampedValue = 60; + m_state->slowHSpeed = clampedValue; + m_state->slowVSpeed = clampedValue; + Logger::warn("Delay speed value out of range, clamped to valid range"); + } + } + } + } + catch (...) + { + if (m_state) + { + m_state->slowHSpeed = 5; + m_state->slowVSpeed = 5; + } } } catch (...) { - Logger::warn("Failed to initialize crosshairs border color from settings. Will use default value"); - } - try - { - // Parse border size - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_BORDER_SIZE); - int value = static_cast <int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - inclusiveCrosshairsSettings.crosshairsBorderSize = value; - } - else - { - throw std::runtime_error("Invalid Border Color value"); - } - } - catch (...) - { - Logger::warn("Failed to initialize border color from settings. Will use default value"); - } - try - { - // Parse auto hide - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_AUTO_HIDE); - inclusiveCrosshairsSettings.crosshairsAutoHide = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); - } - catch (...) - { - Logger::warn("Failed to initialize auto hide from settings. Will use default value"); - } - try - { - // Parse whether the fixed length is enabled - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED); - bool value = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); - inclusiveCrosshairsSettings.crosshairsIsFixedLengthEnabled = value; - } - catch (...) - { - Logger::warn("Failed to initialize fixed length enabled from settings. Will use default value"); - } - try - { - // Parse fixed length - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_FIXED_LENGTH); - int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE)); - if (value >= 0) - { - inclusiveCrosshairsSettings.crosshairsFixedLength = value; - } - else - { - throw std::runtime_error("Invalid Fixed Length value"); - } - } - catch (...) - { - Logger::warn("Failed to initialize fixed length from settings. Will use default value"); - } - try - { - // Parse auto activate - auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_AUTO_ACTIVATE); - inclusiveCrosshairsSettings.autoActivate = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE); - } - catch (...) - { - Logger::warn("Failed to initialize auto activate from settings. Will use default value"); + Logger::warn("Error parsing some MousePointerCrosshairs properties. Using defaults for failed properties."); } } else { Logger::info("Mouse Pointer Crosshairs settings are empty"); } - if (!m_hotkey.modifiersMask) + + // Set default hotkeys if not configured + if (m_activationHotkey.key == 0) { - Logger::info("Mouse Pointer Crosshairs is going to use default shortcut"); - m_hotkey.modifiersMask = MOD_WIN | MOD_ALT; - m_hotkey.vkCode = 0x50; // P key + m_activationHotkey.win = true; + m_activationHotkey.alt = true; + m_activationHotkey.ctrl = false; + m_activationHotkey.shift = false; + m_activationHotkey.key = 'P'; } + if (m_glidingHotkey.key == 0) + { + m_glidingHotkey.win = true; + m_glidingHotkey.alt = true; + m_glidingHotkey.ctrl = false; + m_glidingHotkey.shift = false; + m_glidingHotkey.key = VK_OEM_PERIOD; + } + m_inclusiveCrosshairsSettings = inclusiveCrosshairsSettings; } - }; extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() { return new MousePointerCrosshairs(); -} \ No newline at end of file +} diff --git a/src/modules/MouseUtils/MousePointerCrosshairs/packages.config b/src/modules/MouseUtils/MousePointerCrosshairs/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/MouseUtils/MousePointerCrosshairs/packages.config +++ b/src/modules/MouseUtils/MousePointerCrosshairs/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs new file mode 100644 index 0000000000..9c07a6beea --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/FindMyMouseTests.cs @@ -0,0 +1,633 @@ +// 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.Globalization; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Devices.Printers; + +namespace MouseUtils.UITests +{ + [TestClass] + public class FindMyMouseTests : UITestBase + { + /// <summary> + /// Test Warning Dialog at startup + /// <list type="bullet"> + /// <item> + /// <description>Validating Warning-Dialog will be shown if 'Show a warning at startup' toggle is On.</description> + /// </item> + /// <item> + /// <description>Validating Warning-Dialog will NOT be shown if 'Show a warning at startup' toggle is Off.</description> + /// </item> + /// <item> + /// <description>Validating click 'Quit' button in Warning-Dialog, the Hosts File Editor window would be closed.</description> + /// </item> + /// <item> + /// <description>Validating click 'Accept' button in Warning-Dialog, the Hosts File Editor window would NOT be closed.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("MouseUtils.FindMyMouse.EnableFindMyMouse")] + [TestCategory("Mouse Utils #1")] + [TestCategory("Mouse Utils #2")] + [TestCategory("Mouse Utils #3")] + [TestCategory("Mouse Utils #4")] + public void TestEnableFindMyMouse() + { + LaunchFromSetting(); + + var settings = new FindMyMouseSettings(); + settings.OverlayOpacity = "100"; + settings.Radius = "50"; + settings.InitialZoom = "1"; + settings.AnimationDuration = "0"; + settings.BackgroundColor = "000000"; + settings.SpotlightColor = "FFFFFF"; + + var foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + Assert.IsNotNull(foundCustom); + + if (CheckAnimationEnable(ref foundCustom)) + { + foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + } + + if (foundCustom != null) + { + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + + SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); + Assert.IsNotNull(foundCustom, "Find My Mouse group not found."); + SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); + + var excludedApps = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); + if (excludedApps != null) + { + excludedApps.Click(); + excludedApps.Click(); + } + else + { + Assert.Fail("Activation method group not found."); + } + } + else + { + Assert.Fail("Find My Mouse group not found."); + } + + // [Test Case]Enable FindMyMouse. Then, without moving your mouse: Press Left Ctrl twice and verify the overlay appears. + VerifySpotlightSettings(ref settings); + + // [Test Case]Enable FindMyMouse. Then, without moving your mouse: Press any other key and verify the overlay disappears. + Session.SendKeys(Key.A); + VerifySpotlightDisappears(ref settings); + + // [Test Case]Enable FindMyMouse. Then, without moving your mouse: Press Left Ctrl twice and verify the overlay appears. + VerifySpotlightSettings(ref settings); + + // [Test Case]Enable FindMyMouse. Then, without moving your mouse: Press a mouse button and verify the overlay disappears. + Task.Delay(1000).Wait(); + + Session.PerformMouseAction(MouseActionType.LeftClick, 500, 1000); + + VerifySpotlightDisappears(ref settings); + } + + [TestMethod("MouseUtils.FindMyMouse.FindMyMouseDifferentSettings")] + [TestCategory("Mouse Utils #10")] + [TestCategory("Mouse Utils #11")] + [TestCategory("Mouse Utils #12")] + public void TestFindMyMouseDifferentSettings() + { + LaunchFromSetting(); + + var settings = new FindMyMouseSettings(); + settings.OverlayOpacity = "100"; + settings.Radius = "80"; + settings.InitialZoom = "1"; + settings.AnimationDuration = "0"; + settings.BackgroundColor = "FF0000"; + settings.SpotlightColor = "0000FF"; + + var foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + Assert.IsNotNull(foundCustom); + + if (CheckAnimationEnable(ref foundCustom)) + { + foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + } + + if (foundCustom != null) + { + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + + SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); + Assert.IsNotNull(foundCustom, "Find My Mouse group not found."); + SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); + + var excludedApps = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); + if (excludedApps != null) + { + excludedApps.Click(); + excludedApps.Click(); + } + else + { + Assert.Fail("Excluded apps group not found."); + } + } + else + { + Assert.Fail("Find My Mouse group not found."); + } + + // [Test Case]Test the different settings and verify they apply, Background color + // [Test Case]Test the different settings and verify they apply, Spotlight color + // [Test Case]Test the different settings and verify they apply, Spotlight radius + VerifySpotlightSettings(ref settings); + + Session.SendKeys(Key.A); + VerifySpotlightDisappears(ref settings); + } + + [TestMethod("MouseUtils.FindMyMouse.DisableFindMyMouse")] + [TestCategory("Mouse Utils #5")] + [TestCategory("Mouse Utils #6")] + public void TestDisableFindMyMouse() + { + LaunchFromSetting(); + + var settings = new FindMyMouseSettings(); + settings.OverlayOpacity = "100"; + settings.Radius = "50"; + settings.InitialZoom = "1"; + settings.AnimationDuration = "0"; + settings.BackgroundColor = "000000"; + settings.SpotlightColor = "FFFFFF"; + var foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + + Assert.IsNotNull(foundCustom); + + if (CheckAnimationEnable(ref foundCustom)) + { + foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + } + + if (foundCustom != null) + { + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); + Assert.IsNotNull(foundCustom); + SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); + + var excludedApps = foundCustom.Find<Group>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); + if (excludedApps != null) + { + excludedApps.Click(); + excludedApps.Click(); + } + else + { + Assert.Fail("Activation method group not found."); + } + } + else + { + Assert.Fail("Find My Mouse group not found."); + } + + // [Test Case]Enable FindMyMouse. Then, without moving your mouse: Press Left Ctrl twice and verify the overlay appears. + // VerifySpotlightSettings(ref settings); + ActivateSpotlight(ref settings); + VerifySpotlightAppears(ref settings); + + // [Test Case] Disable FindMyMouse. Verify the overlay no longer appears when you press Left Ctrl twice + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + Task.Delay(1000).Wait(); + ActivateSpotlight(ref settings); + + VerifySpotlightDisappears(ref settings); + + // [Test Case] Press Left Ctrl twice and verify the overlay appears + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + Task.Delay(2000).Wait(); + ActivateSpotlight(ref settings); + VerifySpotlightAppears(ref settings); + + Session.PerformMouseAction(MouseActionType.LeftClick); + } + + [TestMethod("MouseUtils.FindMyMouse.DisableFindMyMouse3")] + [TestCategory("Mouse Utils #6")] + public void TestDisableFindMyMouse3() + { + LaunchFromSetting(); + + var settings = new FindMyMouseSettings(); + settings.OverlayOpacity = "100"; + settings.Radius = "50"; + settings.InitialZoom = "1"; + settings.AnimationDuration = "0"; + settings.BackgroundColor = "000000"; + settings.SpotlightColor = "FFFFFF"; + var foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + + Assert.IsNotNull(foundCustom); + + if (CheckAnimationEnable(ref foundCustom)) + { + foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + } + + if (foundCustom != null) + { + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); + Assert.IsNotNull(foundCustom); + SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); + + var excludedApps = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); + if (excludedApps != null) + { + excludedApps.Click(); + excludedApps.Click(); + } + else + { + Assert.Fail("Activation method group not found."); + } + } + else + { + Assert.Fail("Find My Mouse group not found."); + } + + // [Test Case]Enable FindMyMouse. Then, without moving your mouse: Press Left Ctrl twice and verify the overlay appears. + // VerifySpotlightSettings(ref settings); + ActivateSpotlight(ref settings); + VerifySpotlightAppears(ref settings); + + // [Test Case] Disable FindMyMouse. Verify the overlay no longer appears when you press Left Ctrl twice + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + Task.Delay(1000).Wait(); + ActivateSpotlight(ref settings); + + VerifySpotlightDisappears(ref settings); + + // [Test Case] Press Left Ctrl twice and verify the overlay appears + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + Task.Delay(2000).Wait(); + ActivateSpotlight(ref settings); + VerifySpotlightAppears(ref settings); + + Session.PerformMouseAction(MouseActionType.LeftClick); + } + + [TestMethod("MouseUtils.FindMyMouse.DisableFindMyMouse2")] + [TestCategory("Mouse Utils #5")] + public void TestDisableFindMyMouse2() + { + LaunchFromSetting(); + + var settings = new FindMyMouseSettings(); + settings.OverlayOpacity = "100"; + settings.Radius = "50"; + settings.InitialZoom = "1"; + settings.AnimationDuration = "0"; + settings.BackgroundColor = "000000"; + settings.SpotlightColor = "FFFFFF"; + var foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + if (foundCustom != null) + { + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + + // foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + SetFindMyMouseActivationMethod(ref foundCustom, "Press Left Control twice"); + Assert.IsNotNull(foundCustom, "Find My Mouse group not found."); + + // SetFindMyMouseAppearanceBehavior(ref foundCustom, ref settings); + var excludedApps = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseExcludedApps)); + if (excludedApps != null) + { + excludedApps.Click(); + excludedApps.Click(); + } + else + { + Assert.Fail("Activation method group not found."); + } + } + else + { + Assert.Fail("Find My Mouse group not found."); + } + + // [Test Case]Enable FindMyMouse. Then, without moving your mouse: Press Left Ctrl twice and verify the overlay appears. + // VerifySpotlightSettings(ref settings); + + // [Test Case] Disable FindMyMouse. Verify the overlay no longer appears when you press Left Ctrl twice + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + Task.Delay(2000).Wait(); + Session.SendKey(Key.LCtrl, 0, 0); + Task.Delay(100).Wait(); + Session.SendKey(Key.LCtrl, 0, 0); + + VerifySpotlightDisappears(ref settings); + } + + private void VerifySpotlightDisappears(ref FindMyMouseSettings settings) + { + Task.Delay(2000).Wait(); + + var location = Session.GetMousePosition(); + int radius = int.Parse(settings.Radius, CultureInfo.InvariantCulture); + var colorSpotlight = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreNotEqual("#" + settings.SpotlightColor, colorSpotlight); + + var colorBackground = this.GetPixelColorString(location.Item1 + radius + 50, location.Item2 + radius + 50); + Assert.AreNotEqual("#" + settings.BackgroundColor, colorBackground); + + var colorBackground2 = this.GetPixelColorString(location.Item1 + radius + 100, location.Item2 + radius + 100); + Assert.AreNotEqual("#" + settings.BackgroundColor, colorBackground2); + } + + private void VerifySpotlightAppears(ref FindMyMouseSettings settings) + { + Task.Delay(2000).Wait(); + + var location = Session.GetMousePosition(); + int radius = int.Parse(settings.Radius, CultureInfo.InvariantCulture); + var colorSpotlight = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreEqual("#" + settings.SpotlightColor, colorSpotlight); + + var colorSpotlight2 = this.GetPixelColorString(location.Item1 + radius - 1, location.Item2); + + // Session.MoveMouseTo(location.Item1 + radius - 10, location.Item2); + Assert.AreEqual("#" + settings.SpotlightColor, colorSpotlight2); + Task.Delay(100).Wait(); + + var colorBackground = this.GetPixelColorString(location.Item1 + radius + 50, location.Item2 + radius + 50); + Assert.AreEqual("#" + settings.BackgroundColor, colorBackground); + } + + private void ActivateSpotlight(ref FindMyMouseSettings settings) + { + var xy = Session.GetMousePosition(); + Session.MoveMouseTo(xy.Item1 - 200, xy.Item2 - 100); + Task.Delay(1000).Wait(); + + Session.PerformMouseAction(MouseActionType.LeftClick); + Task.Delay(1000).Wait(); + if (settings.SelectedActivationMethod == FindMyMouseSettings.ActivationMethod.PressLeftControlTwice) + { + Session.SendKey(Key.LCtrl, 0, 0); + Task.Delay(200).Wait(); + Session.SendKey(Key.LCtrl, 0, 0); + } + else if (settings.SelectedActivationMethod == FindMyMouseSettings.ActivationMethod.PressRightControlTwice) + { + Session.SendKey(Key.RCtrl, 0, 0); + Task.Delay(200).Wait(); + Session.SendKey(Key.RCtrl, 0, 0); + } + else if (settings.SelectedActivationMethod == FindMyMouseSettings.ActivationMethod.ShakeMouse) + { + // Simulate shake mouse; + } + else if (settings.SelectedActivationMethod == FindMyMouseSettings.ActivationMethod.CustomShortcut) + { + // Simulate custom shortcut + } + } + + private void VerifySpotlightSettings(ref FindMyMouseSettings settings, bool equal = true) + { + ActivateSpotlight(ref settings); + + VerifySpotlightAppears(ref settings); + } + + private void SetFindMyMouseActivationMethod(ref Custom? foundCustom, string method) + { + Assert.IsNotNull(foundCustom); + var groupActivation = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseActivationMethod)); + if (groupActivation != null) + { + groupActivation.Click(); + string findMyMouseComboBoxKey = "Activation method"; + var foundElements = foundCustom.FindAll<ComboBox>(findMyMouseComboBoxKey); + if (foundElements.Count != 0) + { + var myMouseComboBox = foundCustom.Find<ComboBox>(findMyMouseComboBoxKey); + Assert.IsNotNull(myMouseComboBox); + myMouseComboBox.Click(); + var selectedItem = myMouseComboBox.Find<NavigationViewItem>(method); + Assert.IsNotNull(selectedItem); + selectedItem.Click(); + } + else + { + Assert.IsTrue(false, "ComboBox is not found in the setting page."); + } + } + else + { + Assert.Fail("Activation method group not found."); + } + } + + private void SetFindMyMouseAppearanceBehavior(ref Custom foundCustom, ref FindMyMouseSettings settings) + { + Assert.IsNotNull(foundCustom); + var groupAppearanceBehavior = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseAppearanceBehavior)); + if (groupAppearanceBehavior != null) + { + var expandState = groupAppearanceBehavior.Selected; + if (!expandState) + { + groupAppearanceBehavior.Click(); + Task.Delay(500).Wait(); + } + + // Set the BackGround color + var backgroundColor = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseBackgroundColor)); + Assert.IsNotNull(backgroundColor); + + var button = backgroundColor.Find<Button>(By.XPath(".//Button")); + Assert.IsNotNull(button); + button.Click(); + + var popupWindow = this.Find<Window>("Popup"); + Assert.IsNotNull(popupWindow); + Task.Delay(1000).Wait(); + var colorModelComboBox = this.Find<ComboBox>("Color model"); + Assert.IsNotNull(colorModelComboBox); + colorModelComboBox.Click(); + var selectedItem = colorModelComboBox.Find<NavigationViewItem>("RGB"); + selectedItem.Click(); + Task.Delay(500).Wait(); + var rgbHexEdit = this.Find<TextBox>("RGB hex"); + Assert.IsNotNull(rgbHexEdit); + Task.Delay(500).Wait(); + int retry = 5; + while (retry > 0) + { + Task.Delay(500).Wait(); + rgbHexEdit.SetText(settings.BackgroundColor); + Task.Delay(500).Wait(); + string rgbHex = rgbHexEdit.Text; + bool isValid = rgbHex.StartsWith('#') && rgbHex.Length == 7 && rgbHex.Substring(1) == settings.BackgroundColor; + Task.Delay(500).Wait(); + if (isValid) + { + break; + } + + retry--; + } + + button.Click(); + + // Set the Spotlight color + var spotlightColor = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseSpotlightColor)); + Assert.IsNotNull(spotlightColor); + + var spotlightColorButton = spotlightColor.Find<Button>(By.XPath(".//Button")); + Assert.IsNotNull(spotlightColorButton); + spotlightColorButton.Click(); + + var spotlightColorPopupWindow = Session.Find<Window>("Popup"); + Assert.IsNotNull(spotlightColorPopupWindow); + var spotlightColorModelComboBox = this.Find<ComboBox>("Color model"); + Assert.IsNotNull(spotlightColorModelComboBox); + spotlightColorModelComboBox.Click(); + var selectedItem2 = spotlightColorModelComboBox.Find<NavigationViewItem>("RGB"); + Assert.IsNotNull(selectedItem2); + selectedItem2.Click(); + Task.Delay(500).Wait(); + var rgbHexEdit2 = this.Find<TextBox>("RGB hex"); + Assert.IsNotNull(rgbHexEdit2); + Task.Delay(500).Wait(); + retry = 5; + while (retry > 0) + { + Task.Delay(500).Wait(); + rgbHexEdit2.SetText(settings.SpotlightColor); + Task.Delay(500).Wait(); + string rgbHex = rgbHexEdit2.Text; + bool isValid = rgbHex.StartsWith('#') && rgbHex.Length == 7 && rgbHex.Substring(1) == settings.SpotlightColor; + Task.Delay(500).Wait(); + if (isValid) + { + break; + } + + retry--; + } + + Task.Delay(500).Wait(); + spotlightColorButton.Click(false, 500, 1500); + + // Set the Fade Initial zoom to 0 + var spotlightInitialZoomSlider = foundCustom.Find<Slider>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseSpotlightZoom)); + Assert.IsNotNull(spotlightInitialZoomSlider); + Task.Delay(1000).Wait(); + spotlightInitialZoomSlider.QuickSetValue(int.Parse(settings.InitialZoom, CultureInfo.InvariantCulture)); + Assert.AreEqual(settings.InitialZoom, spotlightInitialZoomSlider.Text); + Task.Delay(1000).Wait(); + + //// Change the edit value + var spotlightRadius = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseSpotlightRadius)); + var spotlightRadiusEdit = spotlightRadius.Find<TextBox>(By.AccessibilityId("InputBox")); + Assert.IsNotNull(spotlightRadiusEdit); + Task.Delay(1000).Wait(); + spotlightRadiusEdit.SetText(settings.Radius); + Assert.AreEqual(settings.Radius, spotlightRadiusEdit.Text); + Task.Delay(1000).Wait(); + + // Set the duration to 0 ms + var spotlightAnimationDuration = foundCustom.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseAnimationDuration)); + var spotlightAnimationDurationEdit = spotlightAnimationDuration.Find<TextBox>(By.AccessibilityId("InputBox")); + Assert.IsNotNull(spotlightAnimationDurationEdit); + Task.Delay(1000).Wait(); + spotlightAnimationDurationEdit.SetText(settings.AnimationDuration); + Assert.AreEqual(settings.AnimationDuration, spotlightAnimationDurationEdit.Text); + Task.Delay(1000).Wait(); + + // groupAppearanceBehavior.Click(); + } + else + { + Assert.Fail("Appearance & behavior group not found."); + } + } + + private bool CheckAnimationEnable(ref Custom? foundCustom) + { + Assert.IsNotNull(foundCustom, "Find My Mouse group not found."); + var foundElements = foundCustom.FindAll<TextBlock>("Animations are disabled in your system settings."); + + // Assert.IsNull(animationDisabledWarning); + if (foundElements.Count != 0) + { + var openSettingsLink = foundCustom.Find<Element>("Open animation settings"); + Assert.IsNotNull(openSettingsLink); + openSettingsLink.Click(false, 500, 3000); + + string settingsWindow = "Settings"; + this.Session.Attach(settingsWindow); + var animationEffects = this.Find<ToggleSwitch>("Animation effects"); + Assert.IsNotNull(animationEffects); + animationEffects.Toggle(true); + + Task.Delay(2000).Wait(); + Session.SendKeys(Key.Alt, Key.F4); + this.Session.Attach(PowerToysModule.PowerToysSettings); + this.LaunchFromSetting(reload: true); + } + else + { + return false; + } + + return true; + } + + private void LaunchFromSetting(bool reload = false, bool launchAsAdmin = false) + { + Session = RestartScopeExe("FindMyMouse,MouseHighlighter,MouseJump,MousePointerCrosshairs,CursorWrap"); + + // this.Session.Attach(PowerToysModule.PowerToysSettings); + this.Session.SetMainWindowSize(WindowSize.Large); + + // Goto Hosts File Editor setting page + if (this.FindAll(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseUtilitiesNavItem)).Count == 0) + { + // Expand Advanced list-group if needed + this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.InputOutputNavItem)).Click(); + } + + if (reload) + { + this.Find<NavigationViewItem>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.KeyboardManagerNavItem)).Click(); + } + + Task.Delay(1000).Wait(); + this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseUtilitiesNavItem)).Click(); + } + } +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests/MouseHighlighterTests.cs b/src/modules/MouseUtils/MouseUtils.UITests/MouseHighlighterTests.cs new file mode 100644 index 0000000000..cd93631b55 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/MouseHighlighterTests.cs @@ -0,0 +1,498 @@ +// 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.Globalization; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Devices.Printers; + +namespace MouseUtils.UITests +{ + [TestClass] + public class MouseHighlighterTests : UITestBase + { + [TestMethod("MouseUtils.MouseHighlighter.EnableMouseHighlighter")] + [TestCategory("Mouse Utils #17")] + [TestCategory("Mouse Utils #18")] + [TestCategory("Mouse Utils #19")] + [TestCategory("Mouse Utils #20")] + [TestCategory("Mouse Utils #21")] + public void TestEnableMouseHighlighter() + { + LaunchFromSetting(); + var foundCustom0 = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + if (foundCustom0 != null) + { + foundCustom0.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + } + else + { + Assert.Fail("Find My Mouse custom not found."); + } + + var settings = new MouseHighlighterSettings(); + settings.PrimaryButtonHighlightColor = "FFFF0000"; + settings.SecondaryButtonHighlightColor = "FF00FF00"; + settings.AlwaysHighlightColor = "004cFF71"; + settings.Radius = "50"; + settings.FadeDelay = "0"; + settings.FadeDuration = "90"; + + var foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighter)); + if (foundCustom != null) + { + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterToggle)).Toggle(true); + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterToggle)).Toggle(false); + + var xy = Session.GetMousePosition(); + Session.MoveMouseTo(xy.Item1, xy.Item2 - 100); + + Session.PerformMouseAction(MouseActionType.ScrollDown); + Session.PerformMouseAction(MouseActionType.ScrollDown); + Session.PerformMouseAction(MouseActionType.ScrollDown); + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterToggle)).Toggle(true); + + // Change the shortcut key for MouseHighlighter + // [TestCase]Change activation shortcut and test it + var activationShortcutButton = foundCustom.Find<Button>("Activation shortcut"); + Assert.IsNotNull(activationShortcutButton); + + activationShortcutButton.Click(); + Task.Delay(500).Wait(); + var activationShortcutWindow = Session.Find<Window>("Activation shortcut"); + Assert.IsNotNull(activationShortcutWindow); + + // Invalid shortcut key + Session.SendKeySequence(Key.H); + + // IOUtil.SimulateKeyPress(0x41); + var invalidShortcutText = activationShortcutWindow.Find<TextBlock>("Invalid shortcut"); + Assert.IsNotNull(invalidShortcutText); + + // IOUtil.SimulateShortcut(0x5B, 0x10, 0x45); + Session.SendKeys(Key.Win, Key.Shift, Key.H); + + // Assert.IsNull(activationShortcutWindow.Find<TextBlock>("Invalid shortcut")); + var saveButton = activationShortcutWindow.Find<Button>("Save"); + Assert.IsNotNull(saveButton); + saveButton.Click(false, 500, 1000); + + SetMouseHighlighterAppearanceBehavior(ref foundCustom, ref settings); + + var xy0 = Session.GetMousePosition(); + Session.MoveMouseTo(xy0.Item1 - 100, xy0.Item2); + Session.PerformMouseAction(MouseActionType.LeftClick); + + // Check the mouse highlighter is enabled + Session.SendKeys(Key.Win, Key.Shift, Key.H); + + // IOUtil.SimulateShortcut(0x5B, 0x10, 0x45); + Task.Delay(1000).Wait(); + + // MouseSimulator.LeftClick(); + // [Test Case] Press the activation shortcut and press left and right click somewhere, verifying the highlights are applied. + // [Test Case] Press the activation shortcut again and verify no highlights appear when the mouse buttons are clicked. + VerifyMouseHighlighterAppears(ref settings, "leftClick"); + VerifyMouseHighlighterAppears(ref settings, "rightClick"); + + // Disable mouse highlighter + Session.SendKeys(Key.Win, Key.Shift, Key.H); + Task.Delay(1000).Wait(); + + VerifyMouseHighlighterNotAppears(ref settings, "leftClick"); + VerifyMouseHighlighterNotAppears(ref settings, "rightClick"); + + // [Test Case] Disable Mouse Highlighter and verify that the module is not activated when you press the activation shortcut. + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterToggle)).Toggle(false); + xy = Session.GetMousePosition(); + Session.MoveMouseTo(xy.Item1 - 100, xy.Item2); + + Session.SendKeys(Key.Win, Key.Shift, Key.H); + Task.Delay(1000).Wait(); + + VerifyMouseHighlighterNotAppears(ref settings, "leftClick"); + VerifyMouseHighlighterNotAppears(ref settings, "rightClick"); + + // [Test Case] With left mouse button pressed, drag the mouse and verify the highlight is dragged with the pointer. + // [Test Case] With right mouse button pressed, drag the mouse and verify the highlight is dragged with the pointer. + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterToggle)).Toggle(true); + xy = Session.GetMousePosition(); + Session.MoveMouseTo(xy.Item1 - 100, xy.Item2); + + Session.SendKeys(Key.Win, Key.Shift, Key.H); + Task.Delay(1000).Wait(); + VerifyMouseHighlighterDrag(ref settings, "leftClick"); + VerifyMouseHighlighterDrag(ref settings, "rightClick"); + } + else + { + Assert.Fail("Mouse Highlighter Custom not found."); + } + + Task.Delay(500).Wait(); + } + + [TestMethod("MouseUtils.MouseHighlighter.MouseHighlighterDifferentSettings")] + [TestCategory("Mouse Utils #22")] + [TestCategory("Mouse Utils #23")] + [TestCategory("Mouse Utils #24")] + public void TestMouseHighlighterDifferentSettings() + { + LaunchFromSetting(); + var foundCustom0 = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + if (foundCustom0 != null) + { + foundCustom0.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + } + else + { + Assert.Fail("Find My Mouse custom not found."); + } + + var settings = new MouseHighlighterSettings(); + settings.PrimaryButtonHighlightColor = "FF000000"; + settings.SecondaryButtonHighlightColor = "FFFFFFFF"; + settings.AlwaysHighlightColor = "004cFF71"; + settings.Radius = "70"; + settings.FadeDelay = "0"; + settings.FadeDuration = "90"; + + var foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighter)); + if (foundCustom != null) + { + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterToggle)).Toggle(true); + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterToggle)).Toggle(false); + + var xy = Session.GetMousePosition(); + Session.MoveMouseTo(xy.Item1, xy.Item2 - 100); + + Session.PerformMouseAction(MouseActionType.ScrollDown); + Session.PerformMouseAction(MouseActionType.ScrollDown); + Session.PerformMouseAction(MouseActionType.ScrollDown); + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterToggle)).Toggle(true); + + // Change the shortcut key for MouseHighlighter + // [TestCase] Test the different settings and verify they apply - Change activation shortcut and test it + // [Test Case] Test the different settings and verify they apply - Left button highlight color + // [Test Case] Test the different settings and verify they apply - Right button highlight color + // [Test Case] Test the different settings and verify they apply - Radius + var activationShortcutButton = foundCustom.Find<Button>("Activation shortcut"); + Assert.IsNotNull(activationShortcutButton); + + activationShortcutButton.Click(); + Task.Delay(500).Wait(); + var activationShortcutWindow = Session.Find<Window>("Activation shortcut"); + Assert.IsNotNull(activationShortcutWindow); + + // Invalid shortcut key + Session.SendKeySequence(Key.H); + + // IOUtil.SimulateKeyPress(0x41); + var invalidShortcutText = activationShortcutWindow.Find<TextBlock>("Invalid shortcut"); + Assert.IsNotNull(invalidShortcutText); + + // IOUtil.SimulateShortcut(0x5B, 0x10, 0x45); + Session.SendKeys(Key.Win, Key.Shift, Key.O); + + // Assert.IsNull(activationShortcutWindow.Find<TextBlock>("Invalid shortcut")); + var saveButton = activationShortcutWindow.Find<Button>("Save"); + Assert.IsNotNull(saveButton); + saveButton.Click(false, 500, 1000); + + SetMouseHighlighterAppearanceBehavior(ref foundCustom, ref settings); + + var xy0 = Session.GetMousePosition(); + Session.MoveMouseTo(xy0.Item1 - 100, xy0.Item2); + Session.PerformMouseAction(MouseActionType.LeftClick); + + // Check the mouse highlighter is enabled + Session.SendKeys(Key.Win, Key.Shift, Key.O); + + Task.Delay(1000).Wait(); + + VerifyMouseHighlighterAppears(ref settings, "leftClick"); + VerifyMouseHighlighterAppears(ref settings, "rightClick"); + } + else + { + Assert.Fail("Mouse Highlighter Custom not found."); + } + + Task.Delay(500).Wait(); + } + + private void VerifyMouseHighlighterDrag(ref MouseHighlighterSettings settings, string action = "leftClick") + { + Task.Delay(500).Wait(); + string expectedColor = string.Empty; + if (action == "leftClick") + { + IOUtil.SimulateMouseDown(true); + expectedColor = settings.PrimaryButtonHighlightColor.Substring(2); + } + else if (action == "rightClick") + { + IOUtil.SimulateMouseDown(false); + expectedColor = settings.SecondaryButtonHighlightColor.Substring(2); + } + else + { + Assert.Fail("Invalid action specified."); + } + + expectedColor = "#" + expectedColor; + Task.Delay(100).Wait(); + var location = Session.GetMousePosition(); + int radius = int.Parse(settings.Radius, CultureInfo.InvariantCulture); + var colorLeftClick = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreEqual(expectedColor, colorLeftClick); + + var colorLeftClick2 = this.GetPixelColorString(location.Item1 + radius - 1, location.Item2); + + Assert.AreEqual(expectedColor, colorLeftClick2); + + var colorBackground = this.GetPixelColorString(location.Item1 + radius + 50, location.Item2 + radius + 50); + Assert.AreNotEqual(expectedColor, colorBackground); + + // Drag the mouse + // Session.MoveMouseTo(location.Item1 - 400, location.Item2); + for (int i = 0; i < 500; i++) + { + IOUtil.MoveMouseBy(-1, 0); + Task.Delay(10).Wait(); + } + + Task.Delay(2000).Wait(); + + location = Session.GetMousePosition(); + colorLeftClick = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreEqual(expectedColor, colorLeftClick); + + colorLeftClick2 = this.GetPixelColorString(location.Item1 + radius - 1, location.Item2); + + Assert.AreEqual(expectedColor, colorLeftClick2); + + colorBackground = this.GetPixelColorString(location.Item1 + radius + 50, location.Item2 + radius + 50); + Assert.AreNotEqual(expectedColor, colorBackground); + + if (action == "leftClick") + { + IOUtil.SimulateMouseUp(true); + } + else if (action == "rightClick") + { + IOUtil.SimulateMouseUp(false); + } + + int duration = int.Parse(settings.FadeDuration, CultureInfo.InvariantCulture); + Task.Delay(duration + 100).Wait(); + colorLeftClick = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreNotEqual("#" + settings.PrimaryButtonHighlightColor, colorLeftClick); + } + + private void VerifyMouseHighlighterNotAppears(ref MouseHighlighterSettings settings, string action = "leftClick") + { + Task.Delay(500).Wait(); + string expectedColor = string.Empty; + if (action == "leftClick") + { + // MouseSimulator.LeftDown(); + Session.PerformMouseAction(MouseActionType.LeftDown); + expectedColor = settings.PrimaryButtonHighlightColor.Substring(2); + } + else if (action == "rightClick") + { + // MouseSimulator.RightDown(); + Session.PerformMouseAction(MouseActionType.RightDown); + expectedColor = settings.SecondaryButtonHighlightColor.Substring(2); + } + else + { + Assert.Fail("Invalid action specified."); + } + + expectedColor = "#" + expectedColor; + var location = Session.GetMousePosition(); + int radius = int.Parse(settings.Radius, CultureInfo.InvariantCulture); + var colorLeftClick = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreNotEqual(expectedColor, colorLeftClick); + var colorLeftClick2 = this.GetPixelColorString(location.Item1 + radius - 1, location.Item2); + Assert.AreNotEqual(expectedColor, colorLeftClick2); + if (action == "leftClick") + { + Session.PerformMouseAction(MouseActionType.LeftUp); + } + else if (action == "rightClick") + { + Session.PerformMouseAction(MouseActionType.RightUp); + } + } + + private void VerifyMouseHighlighterAppears(ref MouseHighlighterSettings settings, string action = "leftClick") + { + Task.Delay(500).Wait(); + string expectedColor = string.Empty; + if (action == "leftClick") + { + // MouseSimulator.LeftDown(); + Session.PerformMouseAction(MouseActionType.LeftDown); + expectedColor = settings.PrimaryButtonHighlightColor.Substring(2); + } + else if (action == "rightClick") + { + // MouseSimulator.RightDown(); + Session.PerformMouseAction(MouseActionType.RightDown); + expectedColor = settings.SecondaryButtonHighlightColor.Substring(2); + } + else + { + Assert.Fail("Invalid action specified."); + } + + expectedColor = "#" + expectedColor; + + var location = Session.GetMousePosition(); + int radius = int.Parse(settings.Radius, CultureInfo.InvariantCulture); + var colorLeftClick = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreEqual(expectedColor, colorLeftClick); + + var colorLeftClick2 = this.GetPixelColorString(location.Item1 + radius - 1, location.Item2); + + Assert.AreEqual(expectedColor, colorLeftClick2); + Task.Delay(500).Wait(); + + var colorBackground = this.GetPixelColorString(location.Item1 + radius + 50, location.Item2 + radius + 50); + Assert.AreNotEqual(expectedColor, colorBackground); + if (action == "leftClick") + { + // MouseSimulator.LeftUp(); + Session.PerformMouseAction(MouseActionType.LeftUp); + } + else if (action == "rightClick") + { + // MouseSimulator.RightUp(); + Session.PerformMouseAction(MouseActionType.RightUp); + } + + int duration = int.Parse(settings.FadeDuration, CultureInfo.InvariantCulture); + Task.Delay(duration + 100).Wait(); + colorLeftClick = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreNotEqual("#" + settings.PrimaryButtonHighlightColor, colorLeftClick); + } + + private void SetColor(ref Custom foundCustom, string colorName = "Primary button highlight color", string colorValue = "000000", string opacity = "0") + { + Assert.IsNotNull(foundCustom); + var groupAppearanceBehavior = foundCustom.Find<Group>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterAppearanceBehavior)); + if (groupAppearanceBehavior != null) + { + if (foundCustom.FindAll<TextBox>("Fade duration (ms) Minimum0").Count == 0) + { + groupAppearanceBehavior.Click(); + } + + // Set primary button highlight color + var primaryButtonHighlightColor = foundCustom.Find<Group>(colorName); + Assert.IsNotNull(primaryButtonHighlightColor); + + var button = primaryButtonHighlightColor.Find<Button>(By.XPath(".//Button")); + Assert.IsNotNull(button); + button.Click(false, 500, 700); + + var popupWindow = Session.Find<Window>("Popup"); + Assert.IsNotNull(popupWindow); + var colorModelComboBox = this.Find<ComboBox>("Color model"); + Assert.IsNotNull(colorModelComboBox); + colorModelComboBox.Click(false, 500, 700); + var selectedItem = colorModelComboBox.Find<NavigationViewItem>("RGB"); + selectedItem.Click(); + var rgbHexEdit = this.Find<TextBox>("RGB hex"); + Assert.IsNotNull(rgbHexEdit); + Task.Delay(500).Wait(); + rgbHexEdit.SetText(colorValue); + int retry = 5; + while (retry > 0) + { + Task.Delay(500).Wait(); + rgbHexEdit.SetText(colorValue); + Task.Delay(500).Wait(); + string rgbHex = rgbHexEdit.Text; + bool isValid = rgbHex.StartsWith('#') && rgbHex.Length == 9 && rgbHex.Substring(1) == colorValue; + Task.Delay(500).Wait(); + if (isValid) + { + break; + } + + retry--; + } + + Task.Delay(500).Wait(); + button.Click(); + } + } + + private void SetMouseHighlighterAppearanceBehavior(ref Custom foundCustom, ref MouseHighlighterSettings settings) + { + Assert.IsNotNull(foundCustom); + var groupAppearanceBehavior = foundCustom.Find<Group>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseHighlighterAppearanceBehavior)); + if (groupAppearanceBehavior != null) + { + // groupAppearanceBehavior.Click(); + if (foundCustom.FindAll<TextBox>(settings.GetElementName(MouseHighlighterSettings.SettingsUIElements.FadeDurationEdit)).Count == 0) + { + groupAppearanceBehavior.Click(); + } + + // Set primary button highlight color + SetColor(ref foundCustom, settings.GetElementName(MouseHighlighterSettings.SettingsUIElements.PrimaryButtonHighlightColorGroup), settings.PrimaryButtonHighlightColor); + + // Set secondary button highlight color + SetColor(ref foundCustom, settings.GetElementName(MouseHighlighterSettings.SettingsUIElements.SecondaryButtonHighlightColorGroup), settings.SecondaryButtonHighlightColor); + + // Set the duration to duration ms + var fadeDurationEdit = foundCustom.Find<TextBox>(settings.GetElementName(MouseHighlighterSettings.SettingsUIElements.FadeDurationEdit)); + Assert.IsNotNull(fadeDurationEdit); + fadeDurationEdit.SetText(settings.FadeDuration); + Assert.AreEqual(settings.FadeDuration, fadeDurationEdit.Text); + + // Set Fade delay(ms) + var fadeDelayEdit = foundCustom.Find<TextBox>(settings.GetElementName(MouseHighlighterSettings.SettingsUIElements.FadeDelayEdit)); + Assert.IsNotNull(fadeDelayEdit); + fadeDelayEdit.SetText(settings.FadeDelay); + Assert.AreEqual(settings.FadeDelay, fadeDelayEdit.Text); + + // Set the fade radius (px) + var fadeRadiusEdit = foundCustom.Find<TextBox>(settings.GetElementName(MouseHighlighterSettings.SettingsUIElements.RadiusEdit)); + Assert.IsNotNull(fadeRadiusEdit); + fadeRadiusEdit.SetText(settings.Radius); + Assert.AreEqual(settings.Radius, fadeRadiusEdit.Text); + + // Set always highlight color + SetColor(ref foundCustom, settings.GetElementName(MouseHighlighterSettings.SettingsUIElements.AlwaysHighlightColorGroup), settings.AlwaysHighlightColor); + } + else + { + Assert.Fail("MouseHighlighter Appearance & behavior group not found."); + } + } + + private void LaunchFromSetting(bool showWarning = false, bool launchAsAdmin = false) + { + this.Session.SetMainWindowSize(WindowSize.Large); + + // Goto Mouse utilities setting page + if (this.FindAll(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseUtilitiesNavItem)).Count == 0) + { + // Expand Input / Output list-group if needed + this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.InputOutputNavItem)).Click(); + } + + this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseUtilitiesNavItem)).Click(); + } + } +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests/MouseJumpTests.cs b/src/modules/MouseUtils/MouseUtils.UITests/MouseJumpTests.cs new file mode 100644 index 0000000000..766135b33a --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/MouseJumpTests.cs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace MouseUtils.UITests +{ + [TestClass] + public class MouseJumpTests : UITestBase + { + [TestMethod("MouseUtils.MouseJump.EnableMouseJump")] + [TestCategory("Mouse Utils #39")] + [TestCategory("Mouse Utils #40")] + [TestCategory("Mouse Utils #41")] + [TestCategory("Mouse Utils #45")] + public void TestEnableMouseJump() + { + LaunchFromSetting(true); + } + + [TestMethod("MouseUtils.MouseJump.EnableMouseJump2")] + [TestCategory("Mouse Utils #39")] + [TestCategory("Mouse Utils #41")] + [TestCategory("Mouse Utils #45")] + public void TestEnableMouseJump2() + { + LaunchFromSetting(); + var foundCustom0 = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + if (foundCustom0 != null) + { + foundCustom0.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + foundCustom0.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + } + else + { + Assert.Fail("Find My Mouse custom not found."); + } + + for (int i = 0; i < 10; i++) + { + Session.PerformMouseAction(MouseActionType.ScrollDown); + } + + var foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseJump)); + if (foundCustom != null) + { + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseJumpToggle)).Toggle(true); + + var xy = Session.GetMousePosition(); + Session.MoveMouseTo(xy.Item1, xy.Item2 - 100); + + // Change the shortcut key for MouseHighlighter + // [TestCase]Change activation shortcut and test it + var activationShortcutButton = foundCustom.Find<Button>("Activation shortcut"); + Assert.IsNotNull(activationShortcutButton); + + activationShortcutButton.Click(false, 500, 1000); + var activationShortcutWindow = Session.Find<Window>("Activation shortcut"); + Assert.IsNotNull(activationShortcutWindow); + + // Invalid shortcut key + Session.SendKeySequence(Key.H); + + // IOUtil.SimulateKeyPress(0x41); + var invalidShortcutText = activationShortcutWindow.Find<TextBlock>("Invalid shortcut"); + Assert.IsNotNull(invalidShortcutText); + + // IOUtil.SimulateShortcut(0x5B, 0x10, 0x45); + Session.SendKeys(Key.Win, Key.Shift, Key.Z); + + // Assert.IsNull(activationShortcutWindow.Find<TextBlock>("Invalid shortcut")); + var saveButton = activationShortcutWindow.Find<Button>("Save"); + Assert.IsNotNull(saveButton); + saveButton.Click(false, 500, 1500); + + var screenCenter = this.GetScreenCenter(); + Session.MoveMouseTo(screenCenter.CenterX, screenCenter.CenterY, 500, 1000); + Session.MoveMouseTo(screenCenter.CenterX, screenCenter.CenterY - 300, 500, 1000); + + // [TestCase] Enable Mouse Jump. Then - Press the activation shortcut and verify the screens preview appears. + // [TestCase] Enable Mouse Jump. Then - Click around the screen preview and ensure that mouse cursor jumped to clicked location. + Session.SendKeys(Key.Win, Key.Shift, Key.Z); + VerifyWindowAppears(); + + Task.Delay(1000).Wait(); + + // [TestCase] Enable Mouse Jump. Then - Disable Mouse Jump and verify that the module is not activated when you press the activation shortcut. + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseJumpToggle)).Toggle(false); + Session.MoveMouseTo(screenCenter.CenterX, screenCenter.CenterY - 300, 500, 1000); + Session.SendKeys(Key.Win, Key.Shift, Key.Z); + Task.Delay(500).Wait(); + VerifyWindowNotAppears(); + } + else + { + Assert.Fail("Mouse Highlighter Custom not found."); + } + + Task.Delay(500).Wait(); + } + + [TestMethod("MouseUtils.MouseJump.EnableMouseJump3")] + [TestCategory("Mouse Utils #40")] + public void TestEnableMouseJump3() + { + LaunchFromSetting(); + var foundCustom0 = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouse)); + if (foundCustom0 != null) + { + foundCustom0.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(true); + foundCustom0.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.FindMyMouseToggle)).Toggle(false); + } + else + { + Assert.Fail("Find My Mouse custom not found."); + } + + for (int i = 0; i < 10; i++) + { + Session.PerformMouseAction(MouseActionType.ScrollDown); + } + + var foundCustom = this.Find<Custom>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseJump)); + if (foundCustom != null) + { + foundCustom.Find<ToggleSwitch>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseJumpToggle)).Toggle(true); + + var xy = Session.GetMousePosition(); + Session.MoveMouseTo(xy.Item1, xy.Item2 - 100); + + // Change the shortcut key for MouseHighlighter + // [TestCase]Change activation shortcut and test it + var activationShortcutButton = foundCustom.Find<Button>("Activation shortcut"); + Assert.IsNotNull(activationShortcutButton); + + activationShortcutButton.Click(false, 500, 1000); + var activationShortcutWindow = Session.Find<Window>("Activation shortcut"); + Assert.IsNotNull(activationShortcutWindow); + + // Invalid shortcut key + Session.SendKeySequence(Key.H); + + // IOUtil.SimulateKeyPress(0x41); + var invalidShortcutText = activationShortcutWindow.Find<TextBlock>("Invalid shortcut"); + Assert.IsNotNull(invalidShortcutText); + + // IOUtil.SimulateShortcut(0x5B, 0x10, 0x45); + Session.SendKeys(Key.Win, Key.Shift, Key.J); + + // Assert.IsNull(activationShortcutWindow.Find<TextBlock>("Invalid shortcut")); + var saveButton = activationShortcutWindow.Find<Button>("Save"); + Assert.IsNotNull(saveButton); + saveButton.Click(false, 500, 1500); + + var screenCenter = this.GetScreenCenter(); + Session.MoveMouseTo(screenCenter.CenterX, screenCenter.CenterY, 500, 1000); + Session.MoveMouseTo(screenCenter.CenterX, screenCenter.CenterY - 300, 500, 1000); + + // [TestCase] Enable Mouse Jump. Then - Change activation shortcut and verify that new shortcut triggers Mouse Jump. + Session.SendKeys(Key.Win, Key.Shift, Key.J); + VerifyWindowAppears(); + } + else + { + Assert.Fail("Mouse Highlighter Custom not found."); + } + + Task.Delay(500).Wait(); + } + + private void VerifyWindowAppears() + { + string windowName = "MouseJump"; + Session.Attach(windowName); + var center = this.Session.GetMainWindowCenter(); + Session.MoveMouseTo(center.CenterX, center.CenterY); + Session.PerformMouseAction(MouseActionType.LeftClick, 1000, 1000); + var screenCenter = this.GetScreenCenter(); + + // Get Mouse position + var xy = Session.GetMousePosition(); + + double distance = CalculateDistance(xy.Item1, xy.Item2, screenCenter.CenterX, screenCenter.CenterY); + Assert.IsTrue(distance <= 10, "Mouse Jump window should be opened and mouse should be moved to the center of the screen."); + } + + private void VerifyWindowNotAppears() + { + string windowName = "MouseJump"; + bool open = this.IsWindowOpen(windowName); + Assert.IsFalse(open, "Mouse Jump window should not be opened."); + } + + /// <summary> + /// Calculate the Euclidean distance between two 2D points + /// </summary> + /// <param name="x1">X coordinate of first point</param> + /// <param name="y1">Y coordinate of first point</param> + /// <param name="x2">X coordinate of second point</param> + /// <param name="y2">Y coordinate of second point</param> + /// <returns>Distance (double)</returns> + public double CalculateDistance(int x1, int y1, int x2, int y2) + { + int dx = x2 - x1; + int dy = y2 - y1; + return Math.Sqrt((dx * dx) + (dy * dy)); + } + + private void LaunchFromSetting(bool firstTime = false, bool launchAsAdmin = false) + { + Session.SetMainWindowSize(WindowSize.Large); + Task.Delay(1000).Wait(); + + // Goto Mouse utilities setting page + if (this.FindAll(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseUtilitiesNavItem)).Count == 0) + { + // Expand Input / Output list-group if needed + this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.InputOutputNavItem)).Click(); + Task.Delay(2000).Wait(); + } + + // Goto Mouse utilities setting page + if (this.FindAll(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseUtilitiesNavItem)).Count == 0) + { + RestartScopeExe(); + Session.SetMainWindowSize(WindowSize.Large); + Task.Delay(1000).Wait(); + + // Expand Input / Output list-group if needed + this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.InputOutputNavItem)).Click(); + Task.Delay(2000).Wait(); + } + + // Click on the Mouse utilities + // Task.Delay(2000).Wait(); + if (firstTime) + { + return; + } + else + { + this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseUtilitiesNavItem)).Click(); + } + } + } +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests/MousePointerCrosshairsTests.cs b/src/modules/MouseUtils/MouseUtils.UITests/MousePointerCrosshairsTests.cs new file mode 100644 index 0000000000..0680f7eee6 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/MousePointerCrosshairsTests.cs @@ -0,0 +1,412 @@ +// 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.Globalization; +using System.Reflection; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Devices.Printers; + +namespace MouseUtils.UITests +{ + [TestClass] + public class MousePointerCrosshairsTests : UITestBase + { + [TestMethod("MouseUtils.MousePointerCrosshairs.EnableMousePointerCrosshairs")] + [TestCategory("Mouse Utils #29")] + [TestCategory("Mouse Utils #30")] + [TestCategory("Mouse Utils #31")] + public void TestEnableMousePointerCrosshairs() + { + LaunchFromSetting(); + + var settings = new MousePointerCrosshairsSettings(); + settings.CrosshairsColor = "FF0000"; + settings.CrosshairsBorderColor = "FF0000"; + settings.Opacity = "100"; + settings.CenterRadius = "0"; + settings.Thickness = "20"; + settings.BorderSize = "0"; + settings.IsFixLength = false; + settings.FixedLength = "1"; + + var foundCustom = FindMouseUtilElement(MouseUtilsSettings.MouseUtils.FindMyMouse); + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.FindMyMouse, false); + + foundCustom = FindMouseUtilElement(MouseUtilsSettings.MouseUtils.MouseHighlighter); + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.MouseHighlighter, true); + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.MouseHighlighter, false); + + for (int i = 0; i < 10; i++) + { + Session.PerformMouseAction(MouseActionType.ScrollDown); + } + + foundCustom = FindMouseUtilElement(MouseUtilsSettings.MouseUtils.MousePointerCrosshairs); + + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.MousePointerCrosshairs, false); + Task.Delay(500).Wait(); + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.MousePointerCrosshairs, true); + + Assert.IsNotNull(foundCustom); + + // [Test Case] Change activation shortcut and test it. + var activationShortcutButton = foundCustom.Find<Button>("Activation shortcut"); + Assert.IsNotNull(activationShortcutButton); + + activationShortcutButton.Click(false, 500, 1000); + var activationShortcutWindow = Session.Find<Window>("Activation shortcut"); + Assert.IsNotNull(activationShortcutWindow); + + // Invalid shortcut key + Session.SendKeySequence(Key.H); + + var invalidShortcutText = activationShortcutWindow.Find<TextBlock>("Invalid shortcut"); + Assert.IsNotNull(invalidShortcutText); + + Session.SendKeys(Key.Win, Key.Alt, Key.A); + + var saveButton = activationShortcutWindow.Find<Button>("Save"); + Assert.IsNotNull(saveButton); + saveButton.Click(false, 500, 1000); + + SetMousePointerCrosshairsAppearanceBehavior(ref foundCustom, ref settings); + Task.Delay(500).Wait(); + + // [Test Case] Press the activation shortcut and verify the crosshairs appear, and that they follow the mouse around. + var xy0 = Session.GetMousePosition(); + Session.MoveMouseTo(xy0.Item1 - 100, xy0.Item2); + + IOUtil.MouseClick(); + Task.Delay(500).Wait(); + Session.SendKeys(Key.Win, Key.Alt, Key.A); + Task.Delay(1000).Wait(); + + xy0 = Session.GetMousePosition(); + + VerifyMousePointerCrosshairsAppears(ref settings); + Task.Delay(500).Wait(); + + for (int i = 0; i < 100; i++) + { + IOUtil.MoveMouseBy(-1, 0); + Task.Delay(10).Wait(); + } + + VerifyMousePointerCrosshairsAppears(ref settings); + + // [Test Case] Press the activation shortcut again and verify the crosshairs disappear. + Session.SendKeys(Key.Win, Key.Alt, Key.A); + Task.Delay(1000).Wait(); + + VerifyMousePointerCrosshairsNotAppears(ref settings); + Task.Delay(500).Wait(); + + // [Test Case] Disable Mouse Pointer Crosshairs and verify that the module is not activated when you press the activation shortcut. + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.MousePointerCrosshairs, false); + xy0 = Session.GetMousePosition(); + Session.MoveMouseTo(xy0.Item1 - 100, xy0.Item2); + Session.PerformMouseAction(MouseActionType.LeftClick); + Session.SendKeys(Key.Win, Key.Alt, Key.A); + Task.Delay(1000).Wait(); + + VerifyMousePointerCrosshairsNotAppears(ref settings); + } + + [TestMethod("MouseUtils.MousePointerCrosshairs.MousePointerCrosshairsDifferentSettings")] + [TestCategory("Mouse Utils #32")] + [TestCategory("Mouse Utils #33")] + public void TestMousePointerCrosshairsDifferentSettings() + { + LaunchFromSetting(); + + var settings = new MousePointerCrosshairsSettings(); + settings.CrosshairsColor = "00FF00"; + settings.CrosshairsBorderColor = "00FF00"; + settings.Opacity = "100"; + settings.CenterRadius = "0"; + settings.Thickness = "20"; + settings.BorderSize = "0"; + settings.IsFixLength = false; + settings.FixedLength = "1"; + + var foundCustom = FindMouseUtilElement(MouseUtilsSettings.MouseUtils.FindMyMouse); + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.FindMyMouse, false); + + foundCustom = FindMouseUtilElement(MouseUtilsSettings.MouseUtils.MouseHighlighter); + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.MouseHighlighter, true); + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.MouseHighlighter, false); + + for (int i = 0; i < 10; i++) + { + Session.PerformMouseAction(MouseActionType.ScrollDown); + } + + foundCustom = FindMouseUtilElement(MouseUtilsSettings.MouseUtils.MousePointerCrosshairs); + + // this.FindGroup("Enable Mouse Pointer Crosshairs"); + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.MousePointerCrosshairs, false); + Task.Delay(500).Wait(); + MouseUtilsSettings.SetMouseUtilEnabled(foundCustom, MouseUtilsSettings.MouseUtils.MousePointerCrosshairs, true); + + Assert.IsNotNull(foundCustom); + + // [Test Case] Change activation shortcut and test it. + var activationShortcutButton = foundCustom.Find<Button>("Activation shortcut"); + Assert.IsNotNull(activationShortcutButton); + + activationShortcutButton.Click(false, 500, 1000); + var activationShortcutWindow = Session.Find<Window>("Activation shortcut"); + Assert.IsNotNull(activationShortcutWindow); + + // Invalid shortcut key + Session.SendKeySequence(Key.H); + + var invalidShortcutText = activationShortcutWindow.Find<TextBlock>("Invalid shortcut"); + Assert.IsNotNull(invalidShortcutText); + + Session.SendKeys(Key.Win, Key.Alt, Key.P); + + var saveButton = activationShortcutWindow.Find<Button>("Save"); + Assert.IsNotNull(saveButton); + saveButton.Click(false, 500, 1000); + + SetMousePointerCrosshairsAppearanceBehavior(ref foundCustom, ref settings); + Task.Delay(500).Wait(); + + // [Test Case] Test the different settings and verify they apply - Change activation shortcut and test it. + // [Test Case] Test the different settings and verify they apply - Crosshairs color. + var xy0 = Session.GetMousePosition(); + Session.MoveMouseTo(xy0.Item1 - 100, xy0.Item2); + + IOUtil.MouseClick(); + Task.Delay(500).Wait(); + Session.SendKeys(Key.Win, Key.Alt, Key.P); + Task.Delay(1000).Wait(); + + xy0 = Session.GetMousePosition(); + + VerifyMousePointerCrosshairsAppears(ref settings); + Task.Delay(500).Wait(); + + for (int i = 0; i < 100; i++) + { + IOUtil.MoveMouseBy(-1, 0); + Task.Delay(10).Wait(); + } + + VerifyMousePointerCrosshairsAppears(ref settings); + + // Press the activation shortcut again and verify the crosshairs disappear. + Session.SendKeys(Key.Win, Key.Alt, Key.P); + Task.Delay(1000).Wait(); + + VerifyMousePointerCrosshairsNotAppears(ref settings); + } + + private void VerifyMousePointerCrosshairsNotAppears(ref MousePointerCrosshairsSettings settings) + { + Task.Delay(500).Wait(); + string expectedColor = string.Empty; + expectedColor = "#" + settings.CrosshairsColor; + var location = Session.GetMousePosition(); + + int radius = int.Parse(settings.CenterRadius, CultureInfo.InvariantCulture); + + var color = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreNotEqual(expectedColor, color); + } + + private void VerifyMousePointerCrosshairsAppears(ref MousePointerCrosshairsSettings settings) + { + Task.Delay(1000).Wait(); + string expectedColor = string.Empty; + expectedColor = "#" + settings.CrosshairsColor; + var location = Session.GetMousePosition(); + + int radius = int.Parse(settings.CenterRadius, CultureInfo.InvariantCulture); + + var color = this.GetPixelColorString(location.Item1, location.Item2); + Assert.AreEqual(expectedColor, color, "Center color check failed"); + + var colorX = this.GetPixelColorString(location.Item1 + 50, location.Item2); + Assert.AreEqual(expectedColor, colorX, "Center x + 50 color check failed"); + + colorX = this.GetPixelColorString(location.Item1 - 50, location.Item2); + Assert.AreEqual(expectedColor, colorX, "Center x - 50 color check failed"); + + var colorY = this.GetPixelColorString(location.Item1, location.Item2 + 50); + Assert.AreEqual(expectedColor, colorY, "Center y + 50 color check failed"); + + colorY = this.GetPixelColorString(location.Item1, location.Item2 - 50); + Assert.AreEqual(expectedColor, colorY, "Center y + 50 color check failed"); + } + + private void SetColor(ref Custom foundCustom, string colorName, string colorValue = "000000") + { + Assert.IsNotNull(foundCustom); + var groupAppearanceBehavior = foundCustom.Find<Group>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MousePointerCrosshairsAppearanceBehavior)); + if (groupAppearanceBehavior != null) + { + // Set primary button highlight color + var primaryButtonHighlightColor = foundCustom.Find<Group>(colorName); + Assert.IsNotNull(primaryButtonHighlightColor); + + var button = primaryButtonHighlightColor.Find<Button>(By.XPath(".//Button")); + Assert.IsNotNull(button); + button.Click(false); + var popupWindow = Session.Find<Window>("Popup"); + Assert.IsNotNull(popupWindow); + var colorModelComboBox = this.Find<ComboBox>("Color model"); + Assert.IsNotNull(colorModelComboBox); + colorModelComboBox.Click(); + var selectedItem = colorModelComboBox.Find<NavigationViewItem>("RGB"); + selectedItem.Click(); + var rgbHexEdit = this.Find<TextBox>("RGB hex"); + Assert.IsNotNull(rgbHexEdit); + rgbHexEdit.SetText(colorValue); + + button.Click(); + } + } + + private void SetMousePointerCrosshairsAppearanceBehavior(ref Custom foundCustom, ref MousePointerCrosshairsSettings settings) + { + Assert.IsNotNull(foundCustom); + var groupAppearanceBehavior = foundCustom.Find<Group>(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MousePointerCrosshairsAppearanceBehavior)); + if (groupAppearanceBehavior != null) + { + // groupAppearanceBehavior.Click(); + if (foundCustom.FindAll<TextBox>(settings.GetElementName(MousePointerCrosshairsSettings.SettingsUIElements.ThicknessEdit)).Count == 0) + { + groupAppearanceBehavior.Click(); + Session.PerformMouseAction(MouseActionType.ScrollDown); + Session.PerformMouseAction(MouseActionType.ScrollDown); + Session.PerformMouseAction(MouseActionType.ScrollDown); + } + + // Set the crosshairs color + SetColor(ref foundCustom, settings.GetElementName(MousePointerCrosshairsSettings.SettingsUIElements.CrosshairsColorGroup), settings.CrosshairsColor); + + // Set the crosshairs border color + SetColor(ref foundCustom, settings.GetElementName(MousePointerCrosshairsSettings.SettingsUIElements.CrosshairsBorderColorGroup), settings.CrosshairsBorderColor); + + // Set the duration to duration ms + var opacitySlider = foundCustom.Find<Slider>(settings.GetElementName(MousePointerCrosshairsSettings.SettingsUIElements.OpacitySlider)); + Assert.IsNotNull(opacitySlider); + Assert.IsNotNull(settings.Opacity); + int opacityValue = int.Parse(settings.Opacity, CultureInfo.InvariantCulture); + opacitySlider.QuickSetValue(opacityValue); + Assert.AreEqual(settings.Opacity, opacitySlider.Text); + Task.Delay(500).Wait(); + + // Set the center radius (px) + var centerRadiusEdit = foundCustom.Find<TextBox>(settings.GetElementName(MousePointerCrosshairsSettings.SettingsUIElements.CenterRadiusEdit)); + Assert.IsNotNull(centerRadiusEdit); + centerRadiusEdit.SetText(settings.CenterRadius); + Assert.AreEqual(settings.CenterRadius, centerRadiusEdit.Text); + + // Set the thickness (px) + var thicknessEdit = foundCustom.Find<TextBox>(settings.GetElementName(MousePointerCrosshairsSettings.SettingsUIElements.ThicknessEdit)); + Assert.IsNotNull(thicknessEdit); + thicknessEdit.SetText(settings.Thickness); + Assert.AreEqual(settings.Thickness, thicknessEdit.Text); + + // Set the border size (px) + var borderSizeEdit = foundCustom.Find<TextBox>(settings.GetElementName(MousePointerCrosshairsSettings.SettingsUIElements.BorderSizeEdit)); + Assert.IsNotNull(borderSizeEdit); + borderSizeEdit.SetText(settings.BorderSize); + Assert.AreEqual(settings.BorderSize, borderSizeEdit.Text); + + // Set the fixed length (px) + var isFixedLength = foundCustom.Find<ToggleSwitch>(settings.GetElementName(MousePointerCrosshairsSettings.SettingsUIElements.IsFixLengthToggle)); + Assert.IsNotNull(isFixedLength); + isFixedLength.Toggle(settings.IsFixLength); + Assert.AreEqual(settings.IsFixLength, isFixedLength.IsOn); + if (settings.IsFixLength) + { + var fixedLengthEdit = foundCustom.Find<TextBox>(settings.GetElementName(MousePointerCrosshairsSettings.SettingsUIElements.FixedLengthEdit)); + Assert.IsNotNull(fixedLengthEdit); + fixedLengthEdit.SetText(settings.FixedLength); + Assert.AreEqual(settings.FixedLength, fixedLengthEdit.Text); + } + } + else + { + Assert.Fail("MousePointerCrosshairs Appearance & behavior group not found."); + } + } + + private bool FindGroup(string groupName) + { + try + { + var foundElements = this.FindAll<Element>(groupName); + foreach (var element in foundElements) + { + string className = element.ClassName; + string name = element.Name; + string text = element.Text; + string helptext = element.HelpText; + string controlType = element.ControlType; + } + + if (foundElements.Count == 0) + { + return false; + } + } + catch (Exception ex) + { + // Validate if group is not found by checking exception.Message + return ex.Message.Contains("No element found"); + } + + return true; + } + + public Custom? FindMouseUtilElement(MouseUtilsSettings.MouseUtils element) + { + string accessibilityId = element switch + { + MouseUtilsSettings.MouseUtils.FindMyMouse => MouseUtilsSettings.AccessibilityIds.FindMyMouse, + MouseUtilsSettings.MouseUtils.MouseHighlighter => MouseUtilsSettings.AccessibilityIds.MouseHighlighter, + MouseUtilsSettings.MouseUtils.MousePointerCrosshairs => MouseUtilsSettings.AccessibilityIds.MousePointerCrosshairs, + MouseUtilsSettings.MouseUtils.MouseJump => MouseUtilsSettings.AccessibilityIds.MouseJump, + _ => throw new ArgumentException($"Unknown MouseUtils element: {element}"), + }; + + var foundCustom = this.Find<Custom>(By.AccessibilityId(accessibilityId)); + for (int i = 0; i < 20; i++) + { + if (foundCustom != null) + { + break; + } + + Session.PerformMouseAction(MouseActionType.ScrollDown); + foundCustom = this.Find<Custom>(By.AccessibilityId(accessibilityId)); + } + + return foundCustom; + } + + private void LaunchFromSetting(bool showWarning = false, bool launchAsAdmin = false) + { + Session.SetMainWindowSize(WindowSize.Large); + + // Goto Mouse utilities setting page + if (this.FindAll(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseUtilitiesNavItem)).Count == 0) + { + // Expand Input / Output list-group if needed + this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.InputOutputNavItem)).Click(); + } + + this.Find(By.AccessibilityId(MouseUtilsSettings.AccessibilityIds.MouseUtilitiesNavItem)).Click(); + } + } +} diff --git a/src/modules/Hosts/Hosts.UITests/Hosts.UITests.csproj b/src/modules/MouseUtils/MouseUtils.UITests/MouseUtils.UITests.csproj similarity index 70% rename from src/modules/Hosts/Hosts.UITests/Hosts.UITests.csproj rename to src/modules/MouseUtils/MouseUtils.UITests/MouseUtils.UITests.csproj index f9a7f4247b..33d722718f 100644 --- a/src/modules/Hosts/Hosts.UITests/Hosts.UITests.csproj +++ b/src/modules/MouseUtils/MouseUtils.UITests/MouseUtils.UITests.csproj @@ -1,11 +1,11 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> - <ProjectGuid>{4E0AE3A4-2EE0-44D7-A2D0-8769977254A0}</ProjectGuid> - <RootNamespace>PowerToys.Hosts.UITests</RootNamespace> - <AssemblyName>PowerToys.Hosts.UITests</AssemblyName> + <ProjectGuid>{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}</ProjectGuid> + <RootNamespace>PowerToys.MouseUtils.UITests</RootNamespace> + <AssemblyName>PowerToys.MouseUtils.UITests</AssemblyName> <IsPackable>false</IsPackable> <IsTestProject>true</IsTestProject> <Nullable>enable</Nullable> @@ -15,7 +15,7 @@ <RunVSTest>false</RunVSTest> </PropertyGroup> <PropertyGroup> - <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\Hosts.UITests\</OutputPath> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\MouseUtils.UITests\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/MouseUtils/MouseUtils.UITests/Release-Test-Checklist-Migration-Progress.md b/src/modules/MouseUtils/MouseUtils.UITests/Release-Test-Checklist-Migration-Progress.md new file mode 100644 index 0000000000..42e400f83d --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/Release-Test-Checklist-Migration-Progress.md @@ -0,0 +1,64 @@ +## [Mouse Utils](tests-checklist-template-mouse-utils-section.md) + +Find My Mouse: + * Enable FindMyMouse. Then, without moving your mouse: + - [x] Press Left Ctrl twice and verify the overlay appears. + - [x] Press any other key and verify the overlay disappears. + - [x] Press Left Ctrl twice and verify the overlay appears. + - [x] Press a mouse button and verify the overlay disappears. + * Disable FindMyMouse. Verify the overlay no longer appears when you press Left Ctrl twice. + * Enable FindMyMouse. Then, without moving your mouse: + - [x] Press Left Ctrl twice and verify the overlay appears. + * Enable the "Do not activate on game mode" option. Start playing a game that uses CG native full screen. + - [ ] Verify the overlay no longer appears when you press Left Ctrl twice. + * Disable the "Do not activate on game mode" option. Start playing the same game. + - [ ] Verify the overlay appears when you press Left Ctrl twice. (though it'll likely minimize the game) + * Test the different settings and verify they apply: + - [ ] Overlay opacity + - [x] Background color + - [x] Spotlight color + - [x] Spotlight radius + - [ ] Spotlight initial zoom (1x vs 9x will show the difference) + - [ ] Animation duration + - [ ] Change activation method to shake and activate by shaking your mouse pointer + - [ ] Excluded apps + +Mouse Highlighter: + * Enable Mouse Highlighter. Then: + - [x] Press the activation shortcut and press left and right click somewhere, verifying the highlights are applied. + - [x] With left mouse button pressed, drag the mouse and verify the highlight is dragged with the pointer. + - [x] With right mouse button pressed, drag the mouse and verify the highlight is dragged with the pointer. + - [x] Press the activation shortcut again and verify no highlights appear when the mouse buttons are clicked. + - [x] Disable Mouse Highlighter and verify that the module is not activated when you press the activation shortcut. + * Test the different settings and verify they apply: + - [x] Change activation shortcut and test it + - [x] Left button highlight color + - [x] Right button highlight color + - [ ] Opacity + - [ ] Radius + - [ ] Fade delay + - [ ] Fade duration + +Mouse Pointer Crosshairs: + * Enable Mouse Pointer Crosshairs. Then: + - [x] Press the activation shortcut and verify the crosshairs appear, and that they follow the mouse around. + - [x] Press the activation shortcut again and verify the crosshairs disappear. + - [x] Disable Mouse Pointer Crosshairs and verify that the module is not activated when you press the activation shortcut. + * Test the different settings and verify they apply: + - [x] Change activation shortcut and test it + - [x] Crosshairs color + - [ ] Crosshairs opacity + - [ ] Crosshairs center radius + - [ ] Crosshairs thickness + - [ ] Crosshairs border color + - [ ] Crosshairs border size + +Mouse Jump: + * Enable Mouse Jump. Then: + - [x] Press the activation shortcut and verify the screens preview appears. + - [x] Change activation shortcut and verify that new shortcut triggers Mouse Jump. + - [x] Click around the screen preview and ensure that mouse cursor jumped to clicked location. + - [ ] Reorder screens in Display settings and confirm that Mouse Jump reflects the change and still works correctly. + - [ ] Change scaling of screens and confirm that Mouse Jump still works correctly. + - [ ] Unplug additional monitors and confirm that Mouse Jump still works correctly. + - [x] Disable Mouse Jump and verify that the module is not activated when you press the activation shortcut. \ No newline at end of file diff --git a/src/modules/MouseUtils/MouseUtils.UITests/util/FindMyMouseSettings.cs b/src/modules/MouseUtils/MouseUtils.UITests/util/FindMyMouseSettings.cs new file mode 100644 index 0000000000..a1f1a29436 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/util/FindMyMouseSettings.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MouseUtils.UITests +{ + public class FindMyMouseSettings + { + // Appearance settings + public string OverlayOpacity { get; set; } + + public string Radius { get; set; } + + public string InitialZoom { get; set; } + + public string AnimationDuration { get; set; } + + // Color settings + public string BackgroundColor { get; set; } + + public string SpotlightColor { get; set; } + + // Activation method settings + public enum ActivationMethod + { + PressLeftControlTwice, + PressRightControlTwice, + ShakeMouse, + CustomShortcut, + } + + public ActivationMethod SelectedActivationMethod { get; set; } + + // Optional constructor to initialize properties + public FindMyMouseSettings( + string overlayOpacity = "", + string radius = "", + string initialZoom = "", + string animationDuration = "", + string backgroundColor = "", + string spotlightColor = "") + { + OverlayOpacity = overlayOpacity; + Radius = radius; + InitialZoom = initialZoom; + AnimationDuration = animationDuration; + BackgroundColor = backgroundColor; + SpotlightColor = spotlightColor; + SelectedActivationMethod = ActivationMethod.PressLeftControlTwice; // Default value + } + } +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests/util/IOUtil.cs b/src/modules/MouseUtils/MouseUtils.UITests/util/IOUtil.cs new file mode 100644 index 0000000000..1b55600279 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/util/IOUtil.cs @@ -0,0 +1,291 @@ +// 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.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +// The MouseUtils module relies on simulating system-level input events (such as mouse movement or key presses) to test visual or behavioral responses. +// The UI Test framework provides built-in methods for simulating mouse movement and clicks, which work for MouseUtils reliably on high-performance dev boxes. +// However, on low-performance environments such as CI/CD pipelines or virtual machines, these simulated input events are not always correctly recognized by the operating system. +// IOUtils class is added specifically for MouseUtils tests. +// For any test scenario that involves simulating continuous mouse movement (e.g., detecting crosshair changes while moving the cursor), +// input simulation methods in IOUtils class should be used. +namespace MouseUtils.UITests +{ + public class IOUtil + { + private readonly UIntPtr ignoreKeyEventFlag = 0x5555; + + [DllImport("user32.dll")] + + private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); + + [StructLayout(LayoutKind.Sequential)] + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + + internal struct INPUT + { + internal INPUTTYPE type; + internal InputUnion data; + + internal static int Size + { + get { return Marshal.SizeOf<INPUT>(); } + } + } + + [StructLayout(LayoutKind.Explicit)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + internal struct InputUnion + { + [FieldOffset(0)] + internal MOUSEINPUT mi; + + [FieldOffset(0)] + internal KEYBDINPUT ki; + + [FieldOffset(0)] + internal HARDWAREINPUT hi; + } + + [StructLayout(LayoutKind.Sequential)] + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + + internal struct MOUSEINPUT + { + internal int dx; + internal int dy; + internal int mouseData; + internal uint dwFlags; + internal uint time; + internal UIntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + + internal struct KEYBDINPUT + { + internal short wVk; + internal short wScan; + internal uint dwFlags; + internal int time; + internal UIntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + internal struct HARDWAREINPUT + { + internal int uMsg; + internal short wParamL; + internal short wParamH; + } + + internal enum INPUTTYPE : uint + { + INPUT_MOUSE = 0, + INPUT_KEYBOARD = 1, + INPUT_HARDWARE = 2, + } + + [Flags] + internal enum KeyEventF + { + KeyDown = 0x0000, + ExtendedKey = 0x0001, + KeyUp = 0x0002, + Unicode = 0x0004, + Scancode = 0x0008, + } + + [Flags] + internal enum MouseEventF : uint + { + MOVE = 0x0001, + LEFTDOWN = 0x0002, + LEFTUP = 0x0004, + RIGHTDOWN = 0x0008, + RIGHTUP = 0x0010, + ABSOLUTE = 0x8000, + MIDDLEDOWN = 0x0020, + MIDDLEUP = 0x0040, + } + + public static void SimulateMouseDown(bool leftButton = true) + { + SendMouseInput(leftButton ? MouseEventF.LEFTDOWN : MouseEventF.RIGHTDOWN); + } + + public static void SimulateMouseUp(bool leftButton = true) + { + SendMouseInput(leftButton ? MouseEventF.LEFTUP : MouseEventF.RIGHTUP); + } + + public static void MouseClick(bool leftButton = true) + { + SendMouseInput(leftButton ? MouseEventF.LEFTDOWN : MouseEventF.RIGHTDOWN); + SendMouseInput(leftButton ? MouseEventF.LEFTUP : MouseEventF.RIGHTUP); + } + + private static void SendMouseInput(MouseEventF mouseFlags) + { + var input = new INPUT + { + type = INPUTTYPE.INPUT_MOUSE, + data = new InputUnion + { + mi = new MOUSEINPUT + { + dx = 0, + dy = 0, + mouseData = 0, + dwFlags = (uint)mouseFlags, + time = 0, + dwExtraInfo = UIntPtr.Zero, + }, + }, + }; + + INPUT[] inputs = [input]; + _ = SendInput(1, inputs, INPUT.Size); + } + + [DllImport("user32.dll")] + private static extern IntPtr GetMessageExtraInfo(); + + public static void MoveMouseBy(int dx, int dy) + { + var input = new INPUT + { + type = INPUTTYPE.INPUT_MOUSE, + data = new InputUnion + { + mi = new MOUSEINPUT + { + dx = dx, + dy = dy, + mouseData = 0, + dwFlags = (uint)MouseEventF.MOVE, + time = 0, + dwExtraInfo = (nuint)GetMessageExtraInfo(), + }, + }, + }; + + INPUT[] inputs = [input]; + _ = SendInput(1, inputs, INPUT.Size); + } + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int nIndex); + + public static void MoveMouseTo(int x, int y) + { + int screenWidth = GetSystemMetrics(0); + int screenHeight = GetSystemMetrics(1); + + int normalizedX = (int)(x * 65535 / screenWidth); + int normalizedY = (int)(y * 65535 / screenHeight); + + var input = new INPUT + { + type = INPUTTYPE.INPUT_MOUSE, + data = new InputUnion + { + mi = new MOUSEINPUT + { + dx = normalizedX, + dy = normalizedY, + mouseData = 0, + dwFlags = (uint)(MouseEventF.MOVE | MouseEventF.ABSOLUTE), + time = 0, + dwExtraInfo = UIntPtr.Zero, + }, + }, + }; + + INPUT[] inputs = [input]; + _ = SendInput(1, inputs, INPUT.Size); + } + + private void SendSingleKeyboardInput(short keyCode, uint keyStatus) + { + var inputShift = new INPUT + { + type = INPUTTYPE.INPUT_KEYBOARD, + data = new InputUnion + { + ki = new KEYBDINPUT + { + wVk = keyCode, + dwFlags = keyStatus, + + // Any keyevent with the extraInfo set to this value will be ignored by the keyboard hook and sent to the system instead. + dwExtraInfo = ignoreKeyEventFlag, + }, + }, + }; + + INPUT[] inputs = [inputShift]; + _ = SendInput(1, inputs, INPUT.Size); + } + + public static void SimulateKeyDown(ushort keyCode) + { + SendKey(keyCode, false); + } + + public static void SimulateKeyUp(ushort keyCode) + { + SendKey(keyCode, true); + } + + public static void SimulateKeyPress(ushort keyCode) + { + SendKey(keyCode, false); + SendKey(keyCode, true); + } + + public static void SimulateShortcut(params ushort[] keyCodes) + { + foreach (var key in keyCodes) + { + SimulateKeyDown(key); + } + + for (int i = keyCodes.Length - 1; i >= 0; i--) + { + SimulateKeyUp(keyCodes[i]); + } + } + + public static void SendKey(ushort keyCode, bool keyUp) + { + var inputShift = new INPUT + { + type = INPUTTYPE.INPUT_KEYBOARD, + data = new InputUnion + { + ki = new KEYBDINPUT + { + wVk = (short)keyCode, + dwFlags = (uint)(keyUp ? KeyEventF.KeyUp : 0), + dwExtraInfo = (uint)IntPtr.Zero, + }, + }, + }; + INPUT[] inputs = [inputShift]; + + _ = SendInput(1, inputs, INPUT.Size); + } + } +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests/util/MouseHighlighterSettings.cs b/src/modules/MouseUtils/MouseUtils.UITests/util/MouseHighlighterSettings.cs new file mode 100644 index 0000000000..601f2c3838 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/util/MouseHighlighterSettings.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MouseUtils.UITests +{ + public class MouseHighlighterSettings + { + // Appearance settings + public string Radius { get; set; } + + public string FadeDelay { get; set; } + + public string FadeDuration { get; set; } + + // Color settings + public string PrimaryButtonHighlightColor { get; set; } + + public string SecondaryButtonHighlightColor { get; set; } + + public string AlwaysHighlightColor { get; set; } + + // Settings UI Elements + public enum SettingsUIElements + { + PrimaryButtonHighlightColorGroup, + SecondaryButtonHighlightColorGroup, + AlwaysHighlightColorGroup, + RadiusEdit, + FadeDelayEdit, + FadeDurationEdit, + } + + private Dictionary<SettingsUIElements, string> ElementNameMap { get; } + + // Optional constructor to initialize properties + public MouseHighlighterSettings( + string radius = "", + string fadeDelay = "", + string fadeDuration = "", + string primaryButtonHighlightColor = "", + string secondaryButtonHighlightColor = "", + string alwaysHighlightColor = "") + { + this.Radius = radius; + this.FadeDelay = fadeDelay; + this.FadeDuration = fadeDuration; + this.PrimaryButtonHighlightColor = primaryButtonHighlightColor; + this.SecondaryButtonHighlightColor = secondaryButtonHighlightColor; + this.AlwaysHighlightColor = alwaysHighlightColor; + + ElementNameMap = new Dictionary<SettingsUIElements, string> + { + [SettingsUIElements.PrimaryButtonHighlightColorGroup] = @"Primary button highlight color", + [SettingsUIElements.SecondaryButtonHighlightColorGroup] = @"Secondary button highlight color", + [SettingsUIElements.AlwaysHighlightColorGroup] = @"Always highlight color", + [SettingsUIElements.RadiusEdit] = @"Radius (px) Minimum5", + [SettingsUIElements.FadeDelayEdit] = @"Fade delay (ms) Minimum0", + [SettingsUIElements.FadeDurationEdit] = @"Fade duration (ms) Minimum0", + }; + } + + public string GetElementName(SettingsUIElements element) + { + return ElementNameMap[element]; + } + } +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests/util/MousePointerCrosshairsSettings.cs b/src/modules/MouseUtils/MouseUtils.UITests/util/MousePointerCrosshairsSettings.cs new file mode 100644 index 0000000000..135fd7dd28 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/util/MousePointerCrosshairsSettings.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Windows.UI.Core.AnimationMetrics; + +namespace MouseUtils.UITests +{ + public class MousePointerCrosshairsSettings + { + // Appearance settings + public string Opacity { get; set; } + + public string CenterRadius { get; set; } + + public string Thickness { get; set; } + + public string BorderSize { get; set; } + + public bool IsFixLength { get; set; } + + public string FixedLength { get; set; } + + // Color settings + public string CrosshairsColor { get; set; } + + public string CrosshairsBorderColor { get; set; } + + // Settings UI Elements + public enum SettingsUIElements + { + CrosshairsColorGroup, + CrosshairsBorderColorGroup, + OpacitySlider, + CenterRadiusEdit, + ThicknessEdit, + BorderSizeEdit, + FixedLengthEdit, + IsFixLengthToggle, + } + + private Dictionary<SettingsUIElements, string> ElementNameMap { get; } + + // Optional constructor to initialize properties + public MousePointerCrosshairsSettings( + string opacity = "", + string centerRadius = "", + string thickness = "", + string borderSize = "", + bool isFixLength = false, + string fixedLength = "", + string crosshairsColor = "", + string crosshairsBorderColor = "") + { + this.Opacity = opacity; + this.CenterRadius = centerRadius; + this.Thickness = thickness; + this.BorderSize = borderSize; + this.IsFixLength = isFixLength; + this.FixedLength = fixedLength; + this.CrosshairsColor = crosshairsColor; + this.CrosshairsBorderColor = crosshairsBorderColor; + ElementNameMap = new Dictionary<SettingsUIElements, string> + { + [SettingsUIElements.CrosshairsColorGroup] = @"Crosshairs color", + [SettingsUIElements.CrosshairsBorderColorGroup] = @"Crosshairs border color", + [SettingsUIElements.OpacitySlider] = @"Crosshairs opacity (%)", + [SettingsUIElements.CenterRadiusEdit] = @"Crosshairs center radius (px) Minimum0 Maximum500", + [SettingsUIElements.ThicknessEdit] = @"Crosshairs thickness (px) Minimum1 Maximum50", + [SettingsUIElements.BorderSizeEdit] = @"Crosshairs border size (px) Minimum0 Maximum50", + [SettingsUIElements.FixedLengthEdit] = @"Crosshairs fixed length (px) Minimum1", + [SettingsUIElements.IsFixLengthToggle] = @"Fix crosshairs length", + }; + } + + public string GetElementName(SettingsUIElements element) + { + return ElementNameMap[element]; + } + } +} diff --git a/src/modules/MouseUtils/MouseUtils.UITests/util/MouseUtilsSettings.cs b/src/modules/MouseUtils/MouseUtils.UITests/util/MouseUtilsSettings.cs new file mode 100644 index 0000000000..3530ab0932 --- /dev/null +++ b/src/modules/MouseUtils/MouseUtils.UITests/util/MouseUtilsSettings.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium.Appium.Windows; + +namespace MouseUtils.UITests +{ + public class MouseUtilsSettings + { + // Accessibility ID constants + public static class AccessibilityIds + { + // Mouse Utils module IDs + public const string FindMyMouse = "MouseUtils_FindMyMouseTestId"; + public const string MouseHighlighter = "MouseUtils_MouseHighlighterTestId"; + public const string MousePointerCrosshairs = "MouseUtils_MousePointerCrosshairsTestId"; + public const string MouseJump = "MouseUtils_MouseJumpTestId"; + + // ToggleSwitch IDs + public const string FindMyMouseToggle = "MouseUtils_FindMyMouseToggleId"; + public const string MouseHighlighterToggle = "MouseUtils_MouseHighlighterToggleId"; + public const string MousePointerCrosshairsToggle = "MouseUtils_MousePointerCrosshairsToggleId"; + public const string MouseJumpToggle = "MouseUtils_MouseJumpToggleId"; + + // Find My Mouse UI Element IDs + public const string FindMyMouseActivationMethod = "MouseUtils_FindMyMouseActivationMethodId"; + public const string FindMyMouseAppearanceBehavior = "MouseUtils_FindMyMouseAppearanceBehaviorId"; + public const string FindMyMouseExcludedApps = "MouseUtils_FindMyMouseExcludedAppsId"; + public const string FindMyMouseBackgroundColor = "MouseUtils_FindMyMouseBackgroundColorId"; + public const string FindMyMouseSpotlightColor = "MouseUtils_FindMyMouseSpotlightColorId"; + public const string FindMyMouseSpotlightZoom = "MouseUtils_FindMyMouseSpotlightZoomId"; + public const string FindMyMouseSpotlightRadius = "MouseUtils_FindMyMouseSpotlightRadiusId"; + public const string FindMyMouseAnimationDuration = "MouseUtils_FindMyMouseAnimationDurationId"; + + // Mouse Highlighter UI Element IDs + public const string MouseHighlighterActivationShortcut = "MouseUtils_MouseHighlighterActivationShortcutId"; + public const string MouseHighlighterAppearanceBehavior = "MouseUtils_MouseHighlighterAppearanceBehaviorId"; + + // Mouse Pointer Crosshairs UI Element IDs + public const string MousePointerCrosshairsAppearanceBehavior = "MouseUtils_MousePointerCrosshairsAppearanceBehaviorId"; + + // Mouse Jump UI Element IDs + public const string MouseJumpActivationShortcut = "MouseUtils_MouseJumpActivationShortcutId"; + + // Navigation IDs + public const string InputOutputNavItem = "InputOutputNavItem"; + public const string MouseUtilitiesNavItem = "MouseUtilitiesNavItem"; + public const string KeyboardManagerNavItem = "KeyboardManagerNavItem"; + } + + // Mouse Utils Modules + public enum MouseUtils + { + MouseHighlighter, + FindMyMouse, + MousePointerCrosshairs, + MouseJump, + } + + private static readonly Dictionary<MouseUtils, string> MouseUtilUINameMap = new() + { + [MouseUtils.MouseHighlighter] = @"Mouse Highlighter", + [MouseUtils.FindMyMouse] = @"Find My Mouse", + [MouseUtils.MousePointerCrosshairs] = @"Mouse Pointer Crosshairs", + [MouseUtils.MouseJump] = @"Mouse Jump", + }; + + private static readonly Dictionary<MouseUtils, string> MouseUtilUIToggleMap = new() + { + [MouseUtils.MouseHighlighter] = @"Mouse Highlighter", + [MouseUtils.FindMyMouse] = @"Find My Mouse", + [MouseUtils.MousePointerCrosshairs] = @"Mouse Pointer Crosshairs", + [MouseUtils.MouseJump] = @"Mouse Jump", + }; + + public static string GetMouseUtilUIName(MouseUtils element) + { + return MouseUtilUINameMap[element]; + } + + public static void SetMouseUtilEnabled(Custom? custom, MouseUtils element, bool isEnable = true) + { + if (custom != null) + { + string toggleName = MouseUtilUIToggleMap[element]; + var toggle = custom.Find<ToggleSwitch>(toggleName); + + toggle.Toggle(isEnable); + } + else + { + Assert.Fail(element + " custom not found."); + } + } + } +} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.Clipboard.cs b/src/modules/MouseWithoutBorders/App/Class/Common.Clipboard.cs deleted file mode 100644 index 51be48ec5a..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.Clipboard.cs +++ /dev/null @@ -1,1162 +0,0 @@ -// 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.Specialized; -using System.Diagnostics; -using System.Drawing; -using System.Globalization; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net.Sockets; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; - -using Microsoft.PowerToys.Telemetry; - -// <summary> -// Clipboard related routines. -// </summary> -// <history> -// 2008 created by Truong Do (ductdo). -// 2009-... modified by Truong Do (TruongDo). -// 2023- Included in PowerToys. -// </history> -using MouseWithoutBorders.Class; -using MouseWithoutBorders.Core; -using MouseWithoutBorders.Exceptions; - -using SystemClipboard = System.Windows.Forms.Clipboard; -using Thread = MouseWithoutBorders.Core.Thread; - -namespace MouseWithoutBorders -{ - internal partial class Common - { - internal static readonly char[] Comma = new char[] { ',' }; - internal static readonly char[] Star = new char[] { '*' }; - internal static readonly char[] NullSeparator = new char[] { '\0' }; - - internal const uint BIG_CLIPBOARD_DATA_TIMEOUT = 30000; - private const uint MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP = 1024 * 1024; // 1MB - private const uint MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT = 100 * 1024 * 1024; // 100MB - private const int TEXT_HEADER_SIZE = 12; - private const int DATA_SIZE = 48; - private const string TEXT_TYPE_SEP = "{4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F}"; - private static long lastClipboardEventTime; - private static string lastMachineWithClipboardData; - private static string lastDragDropFile; -#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter - internal static long clipboardCopiedTime; -#pragma warning restore SA1307 - - internal static ID LastIDWithClipboardData { get; set; } - - internal static string LastDragDropFile - { - get => Common.lastDragDropFile; - set => Common.lastDragDropFile = value; - } - - internal static string LastMachineWithClipboardData - { - get => Common.lastMachineWithClipboardData; - set => Common.lastMachineWithClipboardData = value; - } - - internal static long LastClipboardEventTime - { - get => Common.lastClipboardEventTime; - set => Common.lastClipboardEventTime = value; - } - - internal static IntPtr NextClipboardViewer { get; set; } - - internal static bool IsClipboardDataImage { get; private set; } - - internal static byte[] LastClipboardData { get; private set; } - - private static object lastClipboardObject = string.Empty; - - internal static bool HasSwitchedMachineSinceLastCopy { get; set; } - - internal static bool CheckClipboardEx(ByteArrayOrString data, bool isFilePath) - { - Logger.LogDebug($"{nameof(CheckClipboardEx)}: ShareClipboard = {Setting.Values.ShareClipboard}, TransferFile = {Setting.Values.TransferFile}, data = {data}."); - Logger.LogDebug($"{nameof(CheckClipboardEx)}: {nameof(Setting.Values.OneWayClipboardMode)} = {Setting.Values.OneWayClipboardMode}."); - - if (!Setting.Values.ShareClipboard) - { - return false; - } - - if (Common.RunWithNoAdminRight && Setting.Values.OneWayClipboardMode) - { - return false; - } - - if (GetTick() - LastClipboardEventTime < 1000) - { - Logger.LogDebug("GetTick() - lastClipboardEventTime < 1000"); - LastClipboardEventTime = GetTick(); - return false; - } - - LastClipboardEventTime = GetTick(); - - try - { - IsClipboardDataImage = false; - LastClipboardData = null; - LastDragDropFile = null; - GC.Collect(); - - string stringData = null; - byte[] byteData = null; - - if (data.IsByteArray) - { - byteData = data.GetByteArray(); - } - else - { - stringData = data.GetString(); - } - - if (stringData != null) - { - if (!HasSwitchedMachineSinceLastCopy) - { - if (lastClipboardObject is string lastStringData && lastStringData.Equals(stringData, StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("CheckClipboardEx: Same string data."); - return false; - } - } - - HasSwitchedMachineSinceLastCopy = false; - - if (isFilePath) - { - Logger.LogDebug("Clipboard contains FileDropList"); - - if (!Setting.Values.TransferFile) - { - Logger.LogDebug("TransferFile option is unchecked."); - return false; - } - - string filePath = stringData; - - _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => - { - if (File.Exists(filePath) || Directory.Exists(filePath)) - { - if (File.Exists(filePath) && new FileInfo(filePath).Length <= MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT) - { - Logger.LogDebug("Clipboard contains: " + filePath); - LastDragDropFile = filePath; - SendClipboardBeat(); - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_BIG_CLIPBOARD, -1, ICON_BIG_CLIPBOARD, -1 }); - } - else - { - if (Directory.Exists(filePath)) - { - Logger.LogDebug("Clipboard contains a directory: " + filePath); - LastDragDropFile = filePath; - SendClipboardBeat(); - } - else - { - LastDragDropFile = filePath + " - File too big (greater than 100MB), please drag and drop the file instead!"; - SendClipboardBeat(); - Logger.Log("Clipboard: File too big: " + filePath); - } - - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_ERROR, -1, ICON_ERROR, -1 }); - } - } - else - { - Logger.Log("CheckClipboardEx: File not found: " + filePath); - } - }); - } - else - { - byte[] texts = Common.GetBytesU(stringData); - - using MemoryStream ms = new(); - using (DeflateStream s = new(ms, CompressionMode.Compress, true)) - { - s.Write(texts, 0, texts.Length); - } - - Logger.LogDebug("Plain/Zip = " + texts.Length.ToString(CultureInfo.CurrentCulture) + "/" + - ms.Length.ToString(CultureInfo.CurrentCulture)); - - LastClipboardData = ms.GetBuffer(); - } - } - else if (byteData != null) - { - if (!HasSwitchedMachineSinceLastCopy) - { - if (lastClipboardObject is byte[] lastByteData && Enumerable.SequenceEqual(lastByteData, byteData)) - { - Logger.LogDebug("CheckClipboardEx: Same byte[] data."); - return false; - } - } - - HasSwitchedMachineSinceLastCopy = false; - - Logger.LogDebug("Clipboard contains image"); - IsClipboardDataImage = true; - LastClipboardData = byteData; - } - else - { - Logger.LogDebug("*** Clipboard contains something else!"); - return false; - } - - lastClipboardObject = data; - - if (LastClipboardData != null && LastClipboardData.Length > 0) - { - if (LastClipboardData.Length > MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP) - { - SendClipboardBeat(); - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_BIG_CLIPBOARD, -1, ICON_BIG_CLIPBOARD, -1 }); - } - else - { - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_SMALL_CLIPBOARD, -1, -1, -1 }); - SendClipboardDataUsingTCP(LastClipboardData, IsClipboardDataImage); - } - - return true; - } - } - catch (Exception e) - { - Logger.Log(e); - } - - return false; - } - - private static void SendClipboardDataUsingTCP(byte[] bytes, bool image) - { - if (Sk == null) - { - return; - } - - new Task(() => - { - // SuppressFlow fixes an issue on service mode, where the helper process can't get enough permissions to be started again. - // More details can be found on: https://github.com/microsoft/PowerToys/pull/36892 - using var asyncFlowControl = ExecutionContext.SuppressFlow(); - - System.Threading.Thread thread = Thread.CurrentThread; - thread.Name = $"{nameof(SendClipboardDataUsingTCP)}.{thread.ManagedThreadId}"; - Thread.UpdateThreads(thread); - int l = bytes.Length; - int index = 0; - int len; - DATA package = new(); - byte[] buf = new byte[PACKAGE_SIZE_EX]; - int dataStart = PACKAGE_SIZE_EX - DATA_SIZE; - - while (true) - { - if ((index + DATA_SIZE) > l) - { - len = l - index; - Array.Clear(buf, 0, PACKAGE_SIZE_EX); - } - else - { - len = DATA_SIZE; - } - - Array.Copy(bytes, index, buf, dataStart, len); - package.Bytes = buf; - - package.Type = image ? PackageType.ClipboardImage : PackageType.ClipboardText; - package.Des = ID.ALL; - SkSend(package, (uint)MachineID, false); - - index += DATA_SIZE; - if (index >= l) - { - break; - } - } - - package.Type = PackageType.ClipboardDataEnd; - package.Des = ID.ALL; - SkSend(package, (uint)MachineID, false); - }).Start(); - } - - internal static void ReceiveClipboardDataUsingTCP(DATA data, bool image, TcpSk tcp) - { - try - { - if (Sk == null || RunOnLogonDesktop || RunOnScrSaverDesktop) - { - return; - } - - MemoryStream m = new(); - int dataStart = PACKAGE_SIZE_EX - DATA_SIZE; - m.Write(data.Bytes, dataStart, DATA_SIZE); - int unexpectedCount = 0; - - bool done = false; - do - { - data = SocketStuff.TcpReceiveData(tcp, out int err); - - switch (data.Type) - { - case PackageType.ClipboardImage: - case PackageType.ClipboardText: - m.Write(data.Bytes, dataStart, DATA_SIZE); - break; - - case PackageType.ClipboardDataEnd: - done = true; - break; - - default: - Receiver.ProcessPackage(data, tcp); - if (++unexpectedCount > 100) - { - Logger.Log("ReceiveClipboardDataUsingTCP: unexpectedCount > 100!"); - done = true; - } - - break; - } - } - while (!done); - - LastClipboardEventTime = GetTick(); - - if (image) - { - Image im = Image.FromStream(m); - Clipboard.SetImage(im); - LastClipboardEventTime = GetTick(); - } - else - { - Common.SetClipboardData(m.GetBuffer()); - LastClipboardEventTime = GetTick(); - } - - m.Dispose(); - - SetToggleIcon(new int[TOGGLE_ICONS_SIZE] { ICON_SMALL_CLIPBOARD, -1, ICON_SMALL_CLIPBOARD, -1 }); - } - catch (Exception e) - { - Logger.Log("ReceiveClipboardDataUsingTCP: " + e.Message); - } - } - - private static readonly Lock ClipboardThreadOldLock = new(); - private static System.Threading.Thread clipboardThreadOld; - - internal static void GetRemoteClipboard(string postAction) - { - if (!RunOnLogonDesktop && !RunOnScrSaverDesktop) - { - if (Common.LastMachineWithClipboardData == null || - Common.LastMachineWithClipboardData.Length < 1) - { - return; - } - - new Task(() => - { - // SuppressFlow fixes an issue on service mode, where the helper process can't get enough permissions to be started again. - // More details can be found on: https://github.com/microsoft/PowerToys/pull/36892 - using var asyncFlowControl = ExecutionContext.SuppressFlow(); - - System.Threading.Thread thread = Thread.CurrentThread; - thread.Name = $"{nameof(ConnectAndGetData)}.{thread.ManagedThreadId}"; - Thread.UpdateThreads(thread); - ConnectAndGetData(postAction); - }).Start(); - } - } - - private static Stream m; - - private static void ConnectAndGetData(object postAction) - { - if (Sk == null) - { - Logger.Log("ConnectAndGetData: Sk == null!"); - return; - } - - string remoteMachine; - TcpClient clipboardTcpClient = null; - string postAct = (string)postAction; - - Logger.LogDebug("ConnectAndGetData.postAction: " + postAct); - - ClipboardPostAction clipboardPostAct = postAct.Contains("mspaint,") ? ClipboardPostAction.Mspaint - : postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase) ? ClipboardPostAction.Desktop - : ClipboardPostAction.Other; - - try - { - remoteMachine = postAct.Contains("mspaint,") ? postAct.Split(Comma)[1] : Common.LastMachineWithClipboardData; - - remoteMachine = remoteMachine.Trim(); - - if (!IsConnectedByAClientSocketTo(remoteMachine)) - { - Logger.Log($"No potential inbound connection from {MachineName} to {remoteMachine}, ask for a push back instead."); - ID machineId = MachineStuff.MachinePool.ResolveID(remoteMachine); - - if (machineId != ID.NONE) - { - SkSend( - new DATA() - { - Type = PackageType.ClipboardAsk, - Des = machineId, - MachineName = MachineName, - PostAction = clipboardPostAct, - }, - null, - false); - } - else - { - Logger.Log($"Unable to resolve {remoteMachine} to its long IP."); - } - - return; - } - - ShowToolTip("Connecting to " + remoteMachine, 2000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); - - clipboardTcpClient = ConnectToRemoteClipboardSocket(remoteMachine); - } - catch (ThreadAbortException) - { - Logger.Log("The current thread is being aborted (1)."); - if (clipboardTcpClient != null && clipboardTcpClient.Connected) - { - clipboardTcpClient.Client.Close(); - } - - return; - } - catch (Exception e) - { - Logger.Log(e); - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, Common.ICON_BIG_CLIPBOARD, -1, - }); - ShowToolTip(e.Message, 1000, ToolTipIcon.Warning, Setting.Values.ShowClipNetStatus); - return; - } - - bool clientPushData = false; - - if (!ShakeHand(ref remoteMachine, clipboardTcpClient.Client, out Stream enStream, out Stream deStream, ref clientPushData, ref clipboardPostAct)) - { - return; - } - - ReceiveAndProcessClipboardData(remoteMachine, clipboardTcpClient.Client, enStream, deStream, postAct); - } - - internal static void ReceiveAndProcessClipboardData(string remoteMachine, Socket s, Stream enStream, Stream deStream, string postAct) - { - lock (ClipboardThreadOldLock) - { - // Do not enable two connections at the same time. - if (clipboardThreadOld != null && clipboardThreadOld.ThreadState != System.Threading.ThreadState.AbortRequested - && clipboardThreadOld.ThreadState != System.Threading.ThreadState.Aborted && clipboardThreadOld.IsAlive - && clipboardThreadOld.ManagedThreadId != Thread.CurrentThread.ManagedThreadId) - { - if (clipboardThreadOld.Join(3000)) - { - if (m != null) - { - m.Flush(); - m.Close(); - m = null; - } - } - } - - clipboardThreadOld = Thread.CurrentThread; - } - - try - { - byte[] header = new byte[1024]; - byte[] buf = new byte[NETWORK_STREAM_BUF_SIZE]; - string fileName = null; - string tempFile = "data", savingFolder = string.Empty; - Common.ToggleIconsIndex = 0; - int rv; - long receivedCount = 0; - - if ((rv = deStream.ReadEx(header, 0, header.Length)) < header.Length) - { - Logger.Log("Reading header failed: " + rv.ToString(CultureInfo.CurrentCulture)); - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, -1, -1, - }); - return; - } - - fileName = Common.GetStringU(header).Replace("\0", string.Empty); - Logger.LogDebug("Header: " + fileName); - string[] headers = fileName.Split(Star); - - if (headers.Length < 2 || !long.TryParse(headers[0], out long dataSize)) - { - Logger.Log(string.Format( - CultureInfo.CurrentCulture, - "Reading header failed: {0}:{1}", - headers.Length, - fileName)); - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, -1, -1, - }); - return; - } - - fileName = headers[1]; - - Logger.LogDebug(string.Format( - CultureInfo.CurrentCulture, - "Receiving {0}:{1} from {2}...", - Path.GetFileName(fileName), - dataSize, - remoteMachine)); - ShowToolTip( - string.Format( - CultureInfo.CurrentCulture, - "Receiving {0} from {1}...", - Path.GetFileName(fileName), - remoteMachine), - 5000, - ToolTipIcon.Info, - Setting.Values.ShowClipNetStatus); - if (fileName.StartsWith("image", StringComparison.CurrentCultureIgnoreCase) || - fileName.StartsWith("text", StringComparison.CurrentCultureIgnoreCase)) - { - m = new MemoryStream(); - } - else - { - if (postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase)) - { - _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => - { - savingFolder = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + "\\MouseWithoutBorders\\"; - - if (!Directory.Exists(savingFolder)) - { - _ = Directory.CreateDirectory(savingFolder); - } - }); - - tempFile = savingFolder + Path.GetFileName(fileName); - m = new FileStream(tempFile, FileMode.Create); - } - else if (postAct.Contains("mspaint")) - { - tempFile = GetMyStorageDir() + @"ScreenCapture-" + - remoteMachine + ".png"; - m = new FileStream(tempFile, FileMode.Create); - } - else - { - tempFile = GetMyStorageDir(); - tempFile += Path.GetFileName(fileName); - m = new FileStream(tempFile, FileMode.Create); - } - - Logger.Log("==> " + tempFile); - } - - ShowToolTip( - string.Format( - CultureInfo.CurrentCulture, - "Receiving {0} from {1}...", - Path.GetFileName(fileName), - remoteMachine), - 5000, - ToolTipIcon.Info, - Setting.Values.ShowClipNetStatus); - - do - { - rv = deStream.ReadEx(buf, 0, buf.Length); - - if (rv > 0) - { - receivedCount += rv; - - if (receivedCount > dataSize) - { - rv -= (int)(receivedCount - dataSize); - } - - m.Write(buf, 0, rv); - } - - if (Common.ToggleIcons == null) - { - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_SMALL_CLIPBOARD, - -1, Common.ICON_SMALL_CLIPBOARD, -1, - }); - } - - string text = string.Format(CultureInfo.CurrentCulture, "{0}KB received: {1}", m.Length / 1024, Path.GetFileName(fileName)); - - DoSomethingInUIThread(() => - { - MainForm.SetTrayIconText(text); - }); - } - while (rv > 0); - - if (m != null && fileName != null) - { - m.Flush(); - Logger.LogDebug(m.Length.ToString(CultureInfo.CurrentCulture) + " bytes received."); - Common.LastClipboardEventTime = Common.GetTick(); - string toolTipText = null; - string sizeText = m.Length >= 1024 - ? (m.Length / 1024).ToString(CultureInfo.CurrentCulture) + "KB" - : m.Length.ToString(CultureInfo.CurrentCulture) + "Bytes"; - - PowerToysTelemetry.Log.WriteEvent(new MouseWithoutBorders.Telemetry.MouseWithoutBordersClipboardFileTransferEvent()); - - if (fileName.StartsWith("image", StringComparison.CurrentCultureIgnoreCase)) - { - Clipboard.SetImage(Image.FromStream(m)); - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} from {2} is in Clipboard.", - sizeText, - "image", - remoteMachine); - } - else if (fileName.StartsWith("text", StringComparison.CurrentCultureIgnoreCase)) - { - byte[] data = (m as MemoryStream).GetBuffer(); - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} from {2} is in Clipboard.", - sizeText, - "text", - remoteMachine); - Common.SetClipboardData(data); - } - else if (tempFile != null) - { - if (postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase)) - { - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} received from {2}!", - sizeText, - Path.GetFileName(fileName), - remoteMachine); - - _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => - { - ProcessStartInfo startInfo = new(); - startInfo.UseShellExecute = true; - startInfo.WorkingDirectory = savingFolder; - startInfo.FileName = savingFolder; - startInfo.Verb = "open"; - _ = Process.Start(startInfo); - }); - } - else if (postAct.Contains("mspaint")) - { - m.Close(); - m = null; - OpenImage(tempFile); - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} from {2} is in Mspaint.", - sizeText, - Path.GetFileName(tempFile), - remoteMachine); - } - else - { - StringCollection filePaths = new() - { - tempFile, - }; - Clipboard.SetFileDropList(filePaths); - toolTipText = string.Format( - CultureInfo.CurrentCulture, - "{0} {1} from {2} is in Clipboard.", - sizeText, - Path.GetFileName(fileName), - remoteMachine); - } - } - - if (!string.IsNullOrWhiteSpace(toolTipText)) - { - Common.ShowToolTip(toolTipText, 5000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); - } - - DoSomethingInUIThread(() => - { - MainForm.UpdateNotifyIcon(); - }); - - m?.Close(); - m = null; - } - } - catch (ThreadAbortException) - { - Logger.Log("The current thread is being aborted (3)."); - s.Close(); - - if (m != null) - { - m.Close(); - m = null; - } - - return; - } - catch (Exception e) - { - if (e is IOException) - { - string log = $"{nameof(ReceiveAndProcessClipboardData)}: Exception accessing the socket: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; - Logger.Log(log); - } - else - { - Logger.Log(e); - } - - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, Common.ICON_BIG_CLIPBOARD, -1, - }); - ShowToolTip(e.Message, 1000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); - - if (m != null) - { - m.Close(); - m = null; - } - - return; - } - - s.Close(); - } - - internal static bool ShakeHand(ref string remoteName, Socket s, out Stream enStream, out Stream deStream, ref bool clientPushData, ref ClipboardPostAction postAction) - { - const int CLIPBOARD_HANDSHAKE_TIMEOUT = 30; - s.ReceiveTimeout = CLIPBOARD_HANDSHAKE_TIMEOUT * 1000; - s.NoDelay = true; - s.SendBufferSize = s.ReceiveBufferSize = 1024000; - - bool handShaken = false; - enStream = deStream = null; - - try - { - DATA package = new() - { - Type = clientPushData ? PackageType.ClipboardPush : PackageType.Clipboard, - PostAction = postAction, - Src = MachineID, - MachineName = MachineName, - }; - - byte[] buf = new byte[PACKAGE_SIZE_EX]; - - NetworkStream ns = new(s); - enStream = Common.GetEncryptedStream(ns); - Common.SendOrReceiveARandomDataBlockPerInitialIV(enStream); - Logger.LogDebug($"{nameof(ShakeHand)}: Writing header package."); - enStream.Write(package.Bytes, 0, PACKAGE_SIZE_EX); - - Logger.LogDebug($"{nameof(ShakeHand)}: Sent: clientPush={clientPushData}, postAction={postAction}."); - - deStream = Common.GetDecryptedStream(ns); - Common.SendOrReceiveARandomDataBlockPerInitialIV(deStream, false); - - Logger.LogDebug($"{nameof(ShakeHand)}: Reading header package."); - - int bytesReceived = deStream.ReadEx(buf, 0, Common.PACKAGE_SIZE_EX); - package.Bytes = buf; - - string name = "Unknown"; - - if (bytesReceived == Common.PACKAGE_SIZE_EX) - { - if (package.Type is PackageType.Clipboard or PackageType.ClipboardPush) - { - name = remoteName = package.MachineName; - - Logger.LogDebug($"{nameof(ShakeHand)}: Connection from {name}:{package.Src}"); - - if (MachineStuff.MachinePool.ResolveID(name) == package.Src && Common.IsConnectedTo(package.Src)) - { - clientPushData = package.Type == PackageType.ClipboardPush; - postAction = package.PostAction; - handShaken = true; - Logger.LogDebug($"{nameof(ShakeHand)}: Received: clientPush={clientPushData}, postAction={postAction}."); - } - else - { - Logger.LogDebug($"{nameof(ShakeHand)}: No active connection to the machine: {name}."); - } - } - else - { - Logger.LogDebug($"{nameof(ShakeHand)}: Unexpected package type: {package.Type}."); - } - } - else - { - Logger.LogDebug($"{nameof(ShakeHand)}: BytesTransferred != PACKAGE_SIZE_EX: {bytesReceived}"); - } - - if (!handShaken) - { - string msg = $"Clipboard connection rejected: {name}:{remoteName}/{package.Src}\r\n\r\nMake sure you run the same version in all machines."; - Logger.Log(msg); - Common.ShowToolTip(msg, 3000, ToolTipIcon.Warning); - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_BIG_CLIPBOARD, -1, -1, -1 }); - } - } - catch (ThreadAbortException) - { - Logger.Log($"{nameof(ShakeHand)}: The current thread is being aborted."); - s.Close(); - } - catch (Exception e) - { - if (e is IOException) - { - string log = $"{nameof(ShakeHand)}: Exception accessing the socket: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; - Logger.Log(log); - } - else - { - Logger.Log(e); - } - - Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] - { - Common.ICON_BIG_CLIPBOARD, - -1, Common.ICON_BIG_CLIPBOARD, -1, - }); - MainForm.UpdateNotifyIcon(); - ShowToolTip(e.Message + "\r\n\r\nMake sure you run the same version in all machines.", 1000, ToolTipIcon.Warning, Setting.Values.ShowClipNetStatus); - - if (m != null) - { - m.Close(); - m = null; - } - } - - return handShaken; - } - - internal static TcpClient ConnectToRemoteClipboardSocket(string remoteMachine) - { - TcpClient clipboardTcpClient; - clipboardTcpClient = new TcpClient(AddressFamily.InterNetworkV6); - clipboardTcpClient.Client.DualMode = true; - - SocketStuff sk = Common.Sk; - - if (sk != null) - { - Common.DoSomethingInUIThread(() => Common.MainForm.ChangeIcon(Common.ICON_SMALL_CLIPBOARD)); - - System.Net.IPAddress ip = GetConnectedClientSocketIPAddressFor(remoteMachine); - Logger.LogDebug($"{nameof(ConnectToRemoteClipboardSocket)}Connecting to {remoteMachine}:{ip}:{sk.TcpPort}..."); - - if (ip != null) - { - clipboardTcpClient.Connect(ip, sk.TcpPort); - } - else - { - clipboardTcpClient.Connect(remoteMachine, sk.TcpPort); - } - - Logger.LogDebug($"Connected from {clipboardTcpClient.Client.LocalEndPoint}. Getting data..."); - return clipboardTcpClient; - } - else - { - throw new ExpectedSocketException($"{nameof(ConnectToRemoteClipboardSocket)}: No longer connected."); - } - } - - internal static void SetClipboardData(byte[] data) - { - if (data == null || data.Length <= 0) - { - Logger.Log("data is null or empty!"); - return; - } - - if (data.Length > 1024000) - { - ShowToolTip( - string.Format( - CultureInfo.CurrentCulture, - "Decompressing {0} clipboard data ...", - (data.Length / 1024).ToString(CultureInfo.CurrentCulture) + "KB"), - 5000, - ToolTipIcon.Info, - Setting.Values.ShowClipNetStatus); - } - - string st = string.Empty; - - using (MemoryStream ms = new(data)) - { - using DeflateStream s = new(ms, CompressionMode.Decompress, true); - const int BufferSize = 1024000; // Buffer size should be big enough, this is critical to performance! - - int rv = 0; - - do - { - byte[] buffer = new byte[BufferSize]; - rv = s.ReadEx(buffer, 0, BufferSize); - - if (rv > 0) - { - st += Common.GetStringU(buffer); - } - else - { - break; - } - } - while (true); - } - - int textTypeCount = 0; - string[] texts = st.Split(new string[] { TEXT_TYPE_SEP }, StringSplitOptions.RemoveEmptyEntries); - string tmp; - DataObject data1 = new(); - - foreach (string txt in texts) - { - if (string.IsNullOrEmpty(txt.Trim(NullSeparator))) - { - continue; - } - - tmp = txt[3..]; - - if (txt.StartsWith("RTF", StringComparison.CurrentCultureIgnoreCase)) - { - Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of RTF <-"); - data1.SetData(DataFormats.Rtf, tmp); - } - else if (txt.StartsWith("HTM", StringComparison.CurrentCultureIgnoreCase)) - { - Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of HTM <-"); - data1.SetData(DataFormats.Html, tmp); - } - else if (txt.StartsWith("TXT", StringComparison.CurrentCultureIgnoreCase)) - { - Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of TXT <-"); - data1.SetData(DataFormats.UnicodeText, tmp); - } - else - { - if (textTypeCount == 0) - { - Logger.LogDebug(((double)txt.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of UNI <-"); - data1.SetData(DataFormats.UnicodeText, txt); - } - - Logger.Log("Invalid clipboard format received!"); - } - - textTypeCount++; - } - - if (textTypeCount > 0) - { - Clipboard.SetDataObject(data1); - } - } - } - - internal static class Clipboard - { - public static void SetFileDropList(StringCollection filePaths) - { - Common.DoSomethingInUIThread(() => - { - try - { - _ = Common.Retry( - nameof(SystemClipboard.SetFileDropList), - () => - { - SystemClipboard.SetFileDropList(filePaths); - return true; - }, - (log) => Logger.TelemetryLogTrace( - log, - SeverityLevel.Information), - () => Common.LastClipboardEventTime = Common.GetTick()); - } - catch (ExternalException e) - { - Logger.Log(e); - } - catch (ThreadStateException e) - { - Logger.Log(e); - } - catch (ArgumentNullException e) - { - Logger.Log(e); - } - catch (ArgumentException e) - { - Logger.Log(e); - } - }); - } - - public static void SetImage(Image image) - { - Common.DoSomethingInUIThread(() => - { - try - { - _ = Common.Retry( - nameof(SystemClipboard.SetImage), - () => - { - SystemClipboard.SetImage(image); - return true; - }, - (log) => Logger.TelemetryLogTrace(log, SeverityLevel.Information), - () => Common.LastClipboardEventTime = Common.GetTick()); - } - catch (ExternalException e) - { - Logger.Log(e); - } - catch (ThreadStateException e) - { - Logger.Log(e); - } - catch (ArgumentNullException e) - { - Logger.Log(e); - } - }); - } - - public static void SetText(string text) - { - Common.DoSomethingInUIThread(() => - { - try - { - _ = Common.Retry( - nameof(SystemClipboard.SetText), - () => - { - SystemClipboard.SetText(text); - return true; - }, - (log) => Logger.TelemetryLogTrace(log, SeverityLevel.Information), - () => Common.LastClipboardEventTime = Common.GetTick()); - } - catch (ExternalException e) - { - Logger.Log(e); - } - catch (ThreadStateException e) - { - Logger.Log(e); - } - catch (ArgumentNullException e) - { - Logger.Log(e); - } - }); - } - - public static void SetDataObject(DataObject dataObject) - { - Common.DoSomethingInUIThread(() => - { - try - { - SystemClipboard.SetDataObject(dataObject, true, 10, 200); - } - catch (ExternalException e) - { - string dataFormats = string.Join(",", dataObject.GetFormats()); - Logger.Log($"{e.Message}: {dataFormats}"); - } - catch (ThreadStateException e) - { - Logger.Log(e); - } - catch (ArgumentNullException e) - { - Logger.Log(e); - } - }); - } - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.Encryption.cs b/src/modules/MouseWithoutBorders/App/Class/Common.Encryption.cs deleted file mode 100644 index 1293a4ef39..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.Encryption.cs +++ /dev/null @@ -1,248 +0,0 @@ -// 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. - -// <summary> -// Encrypt/decrypt implementation. -// </summary> -// <history> -// 2008 created by Truong Do (ductdo). -// 2009-... modified by Truong Do (TruongDo). -// 2023- Included in PowerToys. -// </history> -using System; -using System.Collections.Concurrent; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Threading.Tasks; - -using MouseWithoutBorders.Core; - -namespace MouseWithoutBorders -{ - internal partial class Common - { -#pragma warning disable SYSLIB0021 - private static AesCryptoServiceProvider symAl; -#pragma warning restore SYSLIB0021 -#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter - internal static string myKey; -#pragma warning restore SA1307 - private static uint magicNumber; - private static Random ran = new(); // Used for non encryption related functionality. - internal const int SymAlBlockSize = 16; - - /// <summary> - /// This is used for the first encryption block, the following blocks will be combined with the cipher text of the previous block. - /// Thus identical blocks in the socket stream would be encrypted to different cipher text blocks. - /// The first block is a handshake one containing random data. - /// Related Unit Test: TestEncryptDecrypt - /// </summary> - internal static readonly string InitialIV = ulong.MaxValue.ToString(CultureInfo.InvariantCulture); - - internal static Random Ran - { - get => Common.ran ??= new Random(); - set => Common.ran = value; - } - - internal static uint MagicNumber - { - get => Common.magicNumber; - set => Common.magicNumber = value; - } - - internal static string MyKey - { - get => Common.myKey; - - set - { - if (Common.myKey != value) - { - Common.myKey = value; - _ = Task.Factory.StartNew( - () => Common.GenLegalKey(), - System.Threading.CancellationToken.None, - TaskCreationOptions.None, - TaskScheduler.Default); // Cache the key to improve UX. - } - } - } - - internal static string KeyDisplayedText(string key) - { - string displayedValue = string.Empty; - int i = 0; - - do - { - int length = Math.Min(4, key.Length - i); - displayedValue += string.Concat(key.AsSpan(i, length), " "); - i += 4; - } - while (i < key.Length - 1); - - return displayedValue.Trim(); - } - - internal static bool GeneratedKey { get; set; } - - internal static bool KeyCorrupted { get; set; } - - internal static void InitEncryption() - { - try - { - if (symAl == null) - { -#pragma warning disable SYSLIB0021 // No proper replacement for now - symAl = new AesCryptoServiceProvider(); -#pragma warning restore SYSLIB0021 - symAl.KeySize = 256; - symAl.BlockSize = SymAlBlockSize * 8; - symAl.Padding = PaddingMode.Zeros; - symAl.Mode = CipherMode.CBC; - symAl.GenerateIV(); - } - } - catch (Exception e) - { - Logger.Log(e); - } - } - - private static readonly ConcurrentDictionary<string, byte[]> LegalKeyDictionary = new(StringComparer.OrdinalIgnoreCase); - - internal static byte[] GenLegalKey() - { - byte[] rv; - string myKey = Common.MyKey; - - if (!LegalKeyDictionary.TryGetValue(myKey, out byte[] value)) - { - Rfc2898DeriveBytes key = new( - myKey, - Common.GetBytesU(InitialIV), - 50000, - HashAlgorithmName.SHA512); - rv = key.GetBytes(32); - _ = LegalKeyDictionary.AddOrUpdate(myKey, rv, (k, v) => rv); - } - else - { - rv = value; - } - - return rv; - } - - private static byte[] GenLegalIV() - { - string st = InitialIV; - int ivLength = symAl.IV.Length; - if (st.Length > ivLength) - { - st = st[..ivLength]; - } - else if (st.Length < ivLength) - { - st = st.PadRight(ivLength, ' '); - } - - return GetBytes(st); - } - - internal static Stream GetEncryptedStream(Stream encryptedStream) - { - ICryptoTransform encryptor; - encryptor = symAl.CreateEncryptor(GenLegalKey(), GenLegalIV()); - return new CryptoStream(encryptedStream, encryptor, CryptoStreamMode.Write); - } - - internal static Stream GetDecryptedStream(Stream encryptedStream) - { - ICryptoTransform decryptor; - decryptor = symAl.CreateDecryptor(GenLegalKey(), GenLegalIV()); - return new CryptoStream(encryptedStream, decryptor, CryptoStreamMode.Read); - } - - internal static uint Get24BitHash(string st) - { - if (string.IsNullOrEmpty(st)) - { - return 0; - } - - byte[] bytes = new byte[PACKAGE_SIZE]; - for (int i = 0; i < PACKAGE_SIZE; i++) - { - if (i < st.Length) - { - bytes[i] = (byte)st[i]; - } - } - - var hash = SHA512.Create(); - byte[] hashValue = hash.ComputeHash(bytes); - - for (int i = 0; i < 50000; i++) - { - hashValue = hash.ComputeHash(hashValue); - } - - Logger.LogDebug(string.Format(CultureInfo.CurrentCulture, "magic: {0},{1},{2}", hashValue[0], hashValue[1], hashValue[^1])); - hash.Clear(); - return (uint)((hashValue[0] << 23) + (hashValue[1] << 16) + (hashValue[^1] << 8) + hashValue[2]); - } - - internal static string GetDebugInfo(string st) - { - return string.IsNullOrEmpty(st) ? st : ((byte)(Common.GetBytesU(st).Sum(value => value) % 256)).ToString(CultureInfo.InvariantCulture); - } - - internal static string CreateDefaultKey() - { - return CreateRandomKey(); - } - - private const int PW_LENGTH = 16; - - public static string CreateRandomKey() - { - // Not including characters like "'`O0& since they are confusing to users. - string[] chars = new[] { "abcdefghjkmnpqrstuvxyz", "ABCDEFGHJKMNPQRSTUVXYZ", "123456789", "~!@#$%^*()_-+=:;<,>.?/\\|[]" }; - char[][] charactersUsedForKey = chars.Select(charset => Enumerable.Range(0, charset.Length - 1).Select(i => charset[i]).ToArray()).ToArray(); - byte[] randomData = new byte[1]; - string key = string.Empty; - - do - { - foreach (string set in chars) - { - randomData = RandomNumberGenerator.GetBytes(1); - key += set[randomData[0] % set.Length]; - - if (key.Length >= PW_LENGTH) - { - break; - } - } - } - while (key.Length < PW_LENGTH); - - return key; - } - - internal static bool IsKeyValid(string key, out string error) - { - error = string.IsNullOrEmpty(key) || key.Length < 16 - ? "Key must have at least 16 characters in length (spaces are discarded). Key must be auto generated in one of the machines." - : null; - - return error == null; - } - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.InitAndCleanup.cs b/src/modules/MouseWithoutBorders/App/Class/Common.InitAndCleanup.cs deleted file mode 100644 index 44861926e9..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.InitAndCleanup.cs +++ /dev/null @@ -1,284 +0,0 @@ -// 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.Globalization; -using System.Linq; -using System.Net.NetworkInformation; -using System.Security.Cryptography; -using System.Threading; - -// <summary> -// Initialization and clean up. -// </summary> -// <history> -// 2008 created by Truong Do (ductdo). -// 2009-... modified by Truong Do (TruongDo). -// 2023- Included in PowerToys. -// </history> -using Microsoft.Win32; -using MouseWithoutBorders.Class; -using MouseWithoutBorders.Core; -using MouseWithoutBorders.Form; -using Windows.UI.Input.Preview.Injection; - -using Thread = MouseWithoutBorders.Core.Thread; - -namespace MouseWithoutBorders -{ - internal partial class Common - { - private static bool initDone; - internal static int REOPEN_WHEN_WSAECONNRESET = -10054; - internal static int REOPEN_WHEN_HOTKEY = -10055; - internal static int PleaseReopenSocket; - internal static bool ReopenSocketDueToReadError; - - internal static DateTime LastResumeSuspendTime { get; set; } = DateTime.UtcNow; - - internal static bool InitDone - { - get => Common.initDone; - set => Common.initDone = value; - } - - internal static void UpdateMachineTimeAndID() - { - Common.MachineName = Common.MachineName.Trim(); - _ = MachineStuff.MachinePool.TryUpdateMachineID(Common.MachineName, Common.MachineID, true); - } - - private static void InitializeMachinePoolFromSettings() - { - try - { - MachineInf[] info = MachinePoolHelpers.LoadMachineInfoFromMachinePoolStringSetting(Setting.Values.MachinePoolString); - for (int i = 0; i < info.Length; i++) - { - info[i].Name = info[i].Name.Trim(); - } - - MachineStuff.MachinePool.Initialize(info); - MachineStuff.MachinePool.ResetIPAddressesForDeadMachines(true); - } - catch (Exception ex) - { - Logger.Log(ex); - MachineStuff.MachinePool.Clear(); - } - } - - internal static void SetupMachineNameAndID() - { - try - { - GetMachineName(); - DesMachineID = MachineStuff.NewDesMachineID = MachineID; - - // MessageBox.Show(machineID.ToString(CultureInfo.CurrentCulture)); // For test - InitializeMachinePoolFromSettings(); - - Common.MachineName = Common.MachineName.Trim(); - _ = MachineStuff.MachinePool.LearnMachine(Common.MachineName); - _ = MachineStuff.MachinePool.TryUpdateMachineID(Common.MachineName, Common.MachineID, true); - - MachineStuff.UpdateMachinePoolStringSetting(); - } - catch (Exception e) - { - Logger.Log(e); - } - } - - internal static void Init() - { - _ = Helper.GetUserName(); - Common.GeneratedKey = true; - - try - { - Common.MyKey = Setting.Values.MyKey; - int tmp = Setting.Values.MyKeyDaysToExpire; - } - catch (FormatException e) - { - Common.KeyCorrupted = true; - Setting.Values.MyKey = Common.MyKey = Common.CreateRandomKey(); - Logger.Log(e.Message); - } - catch (CryptographicException e) - { - Common.KeyCorrupted = true; - Setting.Values.MyKey = Common.MyKey = Common.CreateRandomKey(); - Logger.Log(e.Message); - } - - try - { - InputSimulation.Injector = InputInjector.TryCreate(); - if (InputSimulation.Injector != null) - { - InputSimulation.MoveMouseRelative(0, 0); - NativeMethods.InjectMouseInputAvailable = true; - } - } - catch (EntryPointNotFoundException) - { - NativeMethods.InjectMouseInputAvailable = false; - Logger.Log($"{nameof(NativeMethods.InjectMouseInputAvailable)} = false"); - } - - bool dummy = Setting.Values.DrawMouseEx; - Is64bitOS = IntPtr.Size == 8; - tcpPort = Setting.Values.TcpPort; - GetScreenConfig(); - PackageSent = new PackageMonitor(0); - PackageReceived = new PackageMonitor(0); - SetupMachineNameAndID(); - InitEncryption(); - CreateHelperThreads(); - - SystemEvents.DisplaySettingsChanged += new EventHandler(SystemEvents_DisplaySettingsChanged); - NetworkChange.NetworkAvailabilityChanged += new NetworkAvailabilityChangedEventHandler(NetworkChange_NetworkAvailabilityChanged); - SystemEvents.PowerModeChanged += new PowerModeChangedEventHandler(SystemEvents_PowerModeChanged); - PleaseReopenSocket = 9; - /* TODO: Telemetry for the matrix? */ - } - - private static void SystemEvents_PowerModeChanged(object sender, PowerModeChangedEventArgs e) - { - Helper.WndProcCounter++; - - if (e.Mode is PowerModes.Resume or PowerModes.Suspend) - { - Logger.TelemetryLogTrace($"{nameof(SystemEvents_PowerModeChanged)}: {e.Mode}", SeverityLevel.Information); - LastResumeSuspendTime = DateTime.UtcNow; - MachineStuff.SwitchToMultipleMode(false, true); - } - } - - private static void CreateHelperThreads() - { - // NOTE(@yuyoyuppe): service crashes while trying to obtain this info, disabling. - /* - Thread watchDogThread = new(new ThreadStart(WatchDogThread), nameof(WatchDogThread)); - watchDogThread.Priority = ThreadPriority.Highest; - watchDogThread.Start(); - */ - - helper = new Thread(new ThreadStart(Helper.HelperThread), "Helper Thread"); - helper.SetApartmentState(ApartmentState.STA); - helper.Start(); - } - - private static void AskHelperThreadsToExit(int waitTime) - { - Helper.signalHelperToExit = true; - Helper.signalWatchDogToExit = true; - _ = EvSwitch.Set(); - - int c = 0; - if (helper != null && c < waitTime) - { - while (Helper.signalHelperToExit) - { - Thread.Sleep(1); - } - - helper = null; - } - } - - internal static void Cleanup() - { - try - { - SendByeBye(); - - // UnhookClipboard(); - AskHelperThreadsToExit(500); - MainForm.NotifyIcon.Visible = false; - MainForm.NotifyIcon.Dispose(); - CloseAllFormsAndHooks(); - - DoSomethingInUIThread(() => - { - Sk?.Close(true); - }); - } - catch (Exception e) - { - Logger.Log(e); - } - } - - private static long lastReleaseAllKeysCall; - - internal static void ReleaseAllKeys() - { - if (Math.Abs(GetTick() - lastReleaseAllKeysCall) < 2000) - { - return; - } - - lastReleaseAllKeysCall = GetTick(); - - KEYBDDATA kd; - kd.dwFlags = (int)LLKHF.UP; - - VK[] keys = new VK[] - { - VK.LSHIFT, VK.LCONTROL, VK.LMENU, VK.LWIN, VK.RSHIFT, - VK.RCONTROL, VK.RMENU, VK.RWIN, VK.SHIFT, VK.MENU, VK.CONTROL, - }; - - Logger.LogDebug("***** ReleaseAllKeys has been called! *****:"); - - foreach (VK vk in keys) - { - if ((NativeMethods.GetAsyncKeyState((IntPtr)vk) & 0x8000) != 0) - { - Logger.LogDebug(vk.ToString() + " is down, release it..."); - Hook?.ResetLastSwitchKeys(); // Sticky key can turn ALL PC mode on (CtrlCtrlCtrl) - kd.wVk = (int)vk; - InputSimulation.SendKey(kd); - Hook?.ResetLastSwitchKeys(); - } - } - } - - private static void NetworkChange_NetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e) - { - Logger.LogDebug("NetworkAvailabilityEventArgs.IsAvailable: " + e.IsAvailable.ToString(CultureInfo.InvariantCulture)); - Helper.WndProcCounter++; - ScheduleReopenSocketsDueToNetworkChanges(!e.IsAvailable); - } - - private static void ScheduleReopenSocketsDueToNetworkChanges(bool closeSockets = true) - { - if (closeSockets) - { - // Slept/hibernated machine may still have the sockets' status as Connected:( (unchanged) so it would not re-connect after a timeout when waking up. - // Closing the sockets when it is going to sleep/hibernate will trigger the reconnection faster when it wakes up. - DoSomethingInUIThread( - () => - { - SocketStuff s = Sk; - Sk = null; - s?.Close(false); - }, - true); - } - - if (!Common.IsMyDesktopActive()) - { - PleaseReopenSocket = 0; - } - else if (PleaseReopenSocket != 10) - { - PleaseReopenSocket = 10; - } - } - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.Package.cs b/src/modules/MouseWithoutBorders/App/Class/Common.Package.cs deleted file mode 100644 index baaf1c0544..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.Package.cs +++ /dev/null @@ -1,262 +0,0 @@ -// 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. - -// <summary> -// Package format/conversion. -// </summary> -// <history> -// 2008 created by Truong Do (ductdo). -// 2009-... modified by Truong Do (TruongDo). -// 2023- Included in PowerToys. -// </history> -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; - -// In X64, we are WOW -[module: SuppressMessage("Microsoft.Portability", "CA1900:ValueTypeFieldsShouldBePortable", Scope = "type", Target = "MouseWithoutBorders.DATA", Justification = "Dotnet port with style preservation")] - -namespace MouseWithoutBorders -{ - internal enum PackageType// : int - { - // Search for PACKAGE_TYPE_RELATED before changing these! - Invalid = 0xFF, - - Error = 0xFE, - - Hi = 2, - Hello = 3, - ByeBye = 4, - - Heartbeat = 20, - Awake = 21, - HideMouse = 50, - Heartbeat_ex = 51, - Heartbeat_ex_l2 = 52, - Heartbeat_ex_l3 = 53, - - Clipboard = 69, - ClipboardDragDrop = 70, - ClipboardDragDropEnd = 71, - ExplorerDragDrop = 72, - ClipboardCapture = 73, - CaptureScreenCommand = 74, - ClipboardDragDropOperation = 75, - ClipboardDataEnd = 76, - MachineSwitched = 77, - ClipboardAsk = 78, - ClipboardPush = 79, - - NextMachine = 121, - Keyboard = 122, - Mouse = 123, - ClipboardText = 124, - ClipboardImage = 125, - - Handshake = 126, - HandshakeAck = 127, - - Matrix = 128, - MatrixSwapFlag = 2, - MatrixTwoRowFlag = 4, - } - - internal struct PackageMonitor - { - internal ulong Keyboard; - internal ulong Mouse; - internal ulong Heartbeat; - internal ulong ByeBye; - internal ulong Hello; - internal ulong Matrix; - internal ulong ClipboardText; - internal ulong ClipboardImage; - internal ulong Clipboard; - internal ulong ClipboardDragDrop; - internal ulong ClipboardDragDropEnd; - internal ulong ClipboardAsk; - internal ulong ExplorerDragDrop; - internal ulong Nil; - - internal PackageMonitor(ulong value) - { - ClipboardDragDrop = ClipboardDragDropEnd = ExplorerDragDrop = - Keyboard = Mouse = Heartbeat = ByeBye = Hello = Clipboard = - Matrix = ClipboardImage = ClipboardText = Nil = ClipboardAsk = value; - } - } - - internal enum ID : uint - { - NONE = 0, - ALL = 255, - } - - internal enum ClipboardPostAction : uint - { - Other = 0, - Desktop = 1, - Mspaint = 2, - } - - [StructLayout(LayoutKind.Sequential)] - internal struct KEYBDDATA - { - [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Same name as in winAPI")] - internal int wVk; - [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Same name as in winAPI")] - internal int dwFlags; - } - - [StructLayout(LayoutKind.Sequential)] - internal struct MOUSEDATA - { - internal int X; - internal int Y; - internal int WheelDelta; - [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Same name as in winAPI")] - internal int dwFlags; - } - - // The beauty of "union" in C# - [StructLayout(LayoutKind.Explicit)] - internal class DATA - { - [FieldOffset(0)] - internal PackageType Type; // 4 (first byte = package type, 1 = checksum, 2+3 = magic no.) - - [FieldOffset(sizeof(PackageType))] - internal int Id; // 4 - - [FieldOffset(sizeof(PackageType) + sizeof(uint))] - internal ID Src; // 4 - - [FieldOffset(sizeof(PackageType) + (2 * sizeof(uint)))] - internal ID Des; // 4 - - [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)))] - internal long DateTime; - - [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)) + sizeof(long))] - internal KEYBDDATA Kd; - - [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)))] - internal MOUSEDATA Md; - - [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)))] - internal ID Machine1; - - [FieldOffset(sizeof(PackageType) + (4 * sizeof(uint)))] - internal ID Machine2; - - [FieldOffset(sizeof(PackageType) + (5 * sizeof(uint)))] - internal ID Machine3; - - [FieldOffset(sizeof(PackageType) + (6 * sizeof(uint)))] - internal ID Machine4; - - [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)))] - internal ClipboardPostAction PostAction; - - [FieldOffset(sizeof(PackageType) + (7 * sizeof(uint)))] - private long machineNameP1; - - [FieldOffset(sizeof(PackageType) + (7 * sizeof(uint)) + sizeof(long))] - private long machineNameP2; - - [FieldOffset(sizeof(PackageType) + (7 * sizeof(uint)) + (2 * sizeof(long)))] - private long machineNameP3; - - [FieldOffset(sizeof(PackageType) + (7 * sizeof(uint)) + (3 * sizeof(long)))] - private long machineNameP4; - - internal string MachineName - { - get - { - string name = Common.GetString(BitConverter.GetBytes(machineNameP1)) - + Common.GetString(BitConverter.GetBytes(machineNameP2)) - + Common.GetString(BitConverter.GetBytes(machineNameP3)) - + Common.GetString(BitConverter.GetBytes(machineNameP4)); - return name.Trim(); - } - - set - { - byte[] machineName = Common.GetBytes(value.PadRight(32, ' ')); - machineNameP1 = BitConverter.ToInt64(machineName, 0); - machineNameP2 = BitConverter.ToInt64(machineName, 8); - machineNameP3 = BitConverter.ToInt64(machineName, 16); - machineNameP4 = BitConverter.ToInt64(machineName, 24); - } - } - - public DATA() - { - } - - public DATA(byte[] initialData) - { - Bytes = initialData; - } - - internal byte[] Bytes - { - get - { - byte[] buf = new byte[IsBigPackage ? Common.PACKAGE_SIZE_EX : Common.PACKAGE_SIZE]; - Array.Copy(StructToBytes(this), buf, IsBigPackage ? Common.PACKAGE_SIZE_EX : Common.PACKAGE_SIZE); - - return buf; - } - - set - { - Debug.Assert(value.Length <= Common.PACKAGE_SIZE_EX, "Length > package size"); - byte[] buf = new byte[Common.PACKAGE_SIZE_EX]; - Array.Copy(value, buf, value.Length); - BytesToStruct(buf, this); - } - } - - internal bool IsBigPackage - { - get => Type == 0 - ? throw new InvalidOperationException("Package type not set.") - : Type switch - { - PackageType.Hello or PackageType.Awake or PackageType.Heartbeat or PackageType.Heartbeat_ex or PackageType.Handshake or PackageType.HandshakeAck or PackageType.ClipboardPush or PackageType.Clipboard or PackageType.ClipboardAsk or PackageType.ClipboardImage or PackageType.ClipboardText or PackageType.ClipboardDataEnd => true, - _ => (Type & PackageType.Matrix) == PackageType.Matrix, - }; - } - - private byte[] StructToBytes(object structObject) - { - byte[] bytes = new byte[Common.PACKAGE_SIZE_EX]; - GCHandle bHandle = GCHandle.Alloc(bytes, GCHandleType.Pinned); - Marshal.StructureToPtr(structObject, Marshal.UnsafeAddrOfPinnedArrayElement(bytes, 0), false); - bHandle.Free(); - return bytes; - } - - private void BytesToStruct(byte[] value, object structObject) - { - GCHandle bHandle = GCHandle.Alloc(value, GCHandleType.Pinned); - Marshal.PtrToStructure(Marshal.UnsafeAddrOfPinnedArrayElement(value, 0), structObject); - bHandle.Free(); - } - } - - internal partial class Common - { - internal const byte PACKAGE_SIZE = 32; - internal const byte PACKAGE_SIZE_EX = 64; - internal const byte WP_PACKAGE_SIZE = 6; - internal static PackageMonitor PackageSent; - internal static PackageMonitor PackageReceived; - internal static int PackageID; - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.ShutdownWithPowerToys.cs b/src/modules/MouseWithoutBorders/App/Class/Common.ShutdownWithPowerToys.cs deleted file mode 100644 index 7c0dd4eb9b..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.ShutdownWithPowerToys.cs +++ /dev/null @@ -1,33 +0,0 @@ -// 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.Telemetry; -using MouseWithoutBorders.Class; - -using Logger = MouseWithoutBorders.Core.Logger; - -namespace MouseWithoutBorders -{ - internal class ShutdownWithPowerToys - { - public static void WaitForPowerToysRunner(ETWTrace etwTrace) - { - try - { - RunnerHelper.WaitForPowerToysRunnerExitFallback(() => - { - etwTrace?.Dispose(); - Common.MainForm.Quit(true, false); - }); - } - catch (Exception e) - { - Logger.Log(e); - } - } - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.VK.cs b/src/modules/MouseWithoutBorders/App/Class/Common.VK.cs deleted file mode 100644 index 79aa50c6dc..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.VK.cs +++ /dev/null @@ -1,130 +0,0 @@ -// 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. - -// <summary> -// Virtual key constants. -// </summary> -// <history> -// 2008 created by Truong Do (ductdo). -// 2009-... modified by Truong Do (TruongDo). -// 2023- Included in PowerToys. -// </history> -using System; - -namespace MouseWithoutBorders -{ - internal enum VK : ushort - { - CAPITAL = 0x14, - NUMLOCK = 0x90, - SHIFT = 0x10, - CONTROL = 0x11, - MENU = 0x12, - ESCAPE = 0x1B, - BACK = 0x08, - TAB = 0x09, - RETURN = 0x0D, - PRIOR = 0x21, - NEXT = 0x22, - END = 0x23, - HOME = 0x24, - LEFT = 0x25, - UP = 0x26, - RIGHT = 0x27, - DOWN = 0x28, - SELECT = 0x29, - PRINT = 0x2A, - EXECUTE = 0x2B, - SNAPSHOT = 0x2C, - INSERT = 0x2D, - DELETE = 0x2E, - HELP = 0x2F, - NUMPAD0 = 0x60, - NUMPAD1 = 0x61, - NUMPAD2 = 0x62, - NUMPAD3 = 0x63, - NUMPAD4 = 0x64, - NUMPAD5 = 0x65, - NUMPAD6 = 0x66, - NUMPAD7 = 0x67, - NUMPAD8 = 0x68, - NUMPAD9 = 0x69, - MULTIPLY = 0x6A, - ADD = 0x6B, - SEPARATOR = 0x6C, - SUBTRACT = 0x6D, - DECIMAL = 0x6E, - DIVIDE = 0x6F, - F1 = 0x70, - F2 = 0x71, - F3 = 0x72, - F4 = 0x73, - F5 = 0x74, - F6 = 0x75, - F7 = 0x76, - F8 = 0x77, - F9 = 0x78, - F10 = 0x79, - F11 = 0x7A, - F12 = 0x7B, - OEM_1 = 0xBA, - OEM_PLUS = 0xBB, - OEM_COMMA = 0xBC, - OEM_MINUS = 0xBD, - OEM_PERIOD = 0xBE, - OEM_2 = 0xBF, - OEM_3 = 0xC0, - MEDIA_NEXT_TRACK = 0xB0, - MEDIA_PREV_TRACK = 0xB1, - MEDIA_STOP = 0xB2, - MEDIA_PLAY_PAUSE = 0xB3, - LWIN = 0x5B, - RWIN = 0x5C, - LSHIFT = 0xA0, - RSHIFT = 0xA1, - LCONTROL = 0xA2, - RCONTROL = 0xA3, - LMENU = 0xA4, - RMENU = 0xA5, - } - - internal partial class Common - { - internal const ushort KEYEVENTF_KEYDOWN = 0x0001; - internal const ushort KEYEVENTF_KEYUP = 0x0002; - - internal const int WH_MOUSE = 7; - internal const int WH_KEYBOARD = 2; - internal const int WH_MOUSE_LL = 14; - internal const int WH_KEYBOARD_LL = 13; - - internal const int WM_MOUSEMOVE = 0x200; - internal const int WM_LBUTTONDOWN = 0x201; - internal const int WM_RBUTTONDOWN = 0x204; - internal const int WM_MBUTTONDOWN = 0x207; - internal const int WM_XBUTTONDOWN = 0x20B; - internal const int WM_LBUTTONUP = 0x202; - internal const int WM_RBUTTONUP = 0x205; - internal const int WM_MBUTTONUP = 0x208; - internal const int WM_XBUTTONUP = 0x20C; - internal const int WM_LBUTTONDBLCLK = 0x203; - internal const int WM_RBUTTONDBLCLK = 0x206; - internal const int WM_MBUTTONDBLCLK = 0x209; - internal const int WM_MOUSEWHEEL = 0x020A; - - internal const int WM_KEYDOWN = 0x100; - internal const int WM_KEYUP = 0x101; - internal const int WM_SYSKEYDOWN = 0x104; - internal const int WM_SYSKEYUP = 0x105; - - [Flags] - internal enum LLKHF - { - EXTENDED = 0x01, - INJECTED = 0x10, - ALTDOWN = 0x20, - UP = 0x80, - } - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.WinAPI.cs b/src/modules/MouseWithoutBorders/App/Class/Common.WinAPI.cs deleted file mode 100644 index ed56101930..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.WinAPI.cs +++ /dev/null @@ -1,363 +0,0 @@ -// 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.Diagnostics; -using System.Drawing; -using System.Globalization; -using System.Linq; -using System.Runtime.InteropServices; -using System.Threading; -using System.Windows.Forms; - -// <summary> -// Screen/Desktop helper functions. -// </summary> -// <history> -// 2008 created by Truong Do (ductdo). -// 2009-... modified by Truong Do (TruongDo). -// 2023- Included in PowerToys. -// </history> -using MouseWithoutBorders.Class; -using MouseWithoutBorders.Core; - -using Thread = MouseWithoutBorders.Core.Thread; - -namespace MouseWithoutBorders -{ - // Desktops, and GetScreenConfig routines - internal partial class Common - { - private static MyRectangle newDesktopBounds; - private static MyRectangle newPrimaryScreenBounds; - private static string activeDesktop; - - internal static string ActiveDesktop => Common.activeDesktop; - - private static void SystemEvents_DisplaySettingsChanged(object sender, EventArgs e) - { - GetScreenConfig(); - } - - internal static readonly List<Point> SensitivePoints = new(); - - private static bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref NativeMethods.RECT lprcMonitor, IntPtr dwData) - { - // lprcMonitor is wrong!!! => using GetMonitorInfo(...) - // Log(String.Format( CultureInfo.CurrentCulture,"MONITOR: l{0}, t{1}, r{2}, b{3}", lprcMonitor.Left, lprcMonitor.Top, lprcMonitor.Right, lprcMonitor.Bottom)); - NativeMethods.MonitorInfoEx mi = default; - mi.cbSize = Marshal.SizeOf(mi); - _ = NativeMethods.GetMonitorInfo(hMonitor, ref mi); - - try - { - // For logging only - _ = NativeMethods.GetDpiForMonitor(hMonitor, 0, out uint dpiX, out uint dpiY); - Logger.Log(string.Format(CultureInfo.CurrentCulture, "MONITOR: ({0}, {1}, {2}, {3}). DPI: ({4}, {5})", mi.rcMonitor.Left, mi.rcMonitor.Top, mi.rcMonitor.Right, mi.rcMonitor.Bottom, dpiX, dpiY)); - } - catch (DllNotFoundException) - { - Logger.Log("GetDpiForMonitor is unsupported in Windows 7 and lower."); - } - catch (EntryPointNotFoundException) - { - Logger.Log("GetDpiForMonitor is unsupported in Windows 7 and lower."); - } - catch (Exception e) - { - Logger.Log(e); - } - - if (mi.rcMonitor.Left == 0 && mi.rcMonitor.Top == 0 && mi.rcMonitor.Right != 0 && mi.rcMonitor.Bottom != 0) - { - // Primary screen - _ = Interlocked.Exchange(ref screenWidth, mi.rcMonitor.Right - mi.rcMonitor.Left); - _ = Interlocked.Exchange(ref screenHeight, mi.rcMonitor.Bottom - mi.rcMonitor.Top); - - newPrimaryScreenBounds.Left = mi.rcMonitor.Left; - newPrimaryScreenBounds.Top = mi.rcMonitor.Top; - newPrimaryScreenBounds.Right = mi.rcMonitor.Right; - newPrimaryScreenBounds.Bottom = mi.rcMonitor.Bottom; - } - else - { - if (mi.rcMonitor.Left < newDesktopBounds.Left) - { - newDesktopBounds.Left = mi.rcMonitor.Left; - } - - if (mi.rcMonitor.Top < newDesktopBounds.Top) - { - newDesktopBounds.Top = mi.rcMonitor.Top; - } - - if (mi.rcMonitor.Right > newDesktopBounds.Right) - { - newDesktopBounds.Right = mi.rcMonitor.Right; - } - - if (mi.rcMonitor.Bottom > newDesktopBounds.Bottom) - { - newDesktopBounds.Bottom = mi.rcMonitor.Bottom; - } - } - - lock (SensitivePoints) - { - SensitivePoints.Add(new Point(mi.rcMonitor.Left, mi.rcMonitor.Top)); - SensitivePoints.Add(new Point(mi.rcMonitor.Right, mi.rcMonitor.Top)); - SensitivePoints.Add(new Point(mi.rcMonitor.Right, mi.rcMonitor.Bottom)); - SensitivePoints.Add(new Point(mi.rcMonitor.Left, mi.rcMonitor.Bottom)); - } - - return true; - } - - internal static void GetScreenConfig() - { - try - { - Logger.LogDebug("==================== GetScreenConfig started"); - newDesktopBounds = new MyRectangle(); - newPrimaryScreenBounds = new MyRectangle(); - newDesktopBounds.Left = newPrimaryScreenBounds.Left = Screen.PrimaryScreen.Bounds.Left; - newDesktopBounds.Top = newPrimaryScreenBounds.Top = Screen.PrimaryScreen.Bounds.Top; - newDesktopBounds.Right = newPrimaryScreenBounds.Right = Screen.PrimaryScreen.Bounds.Right; - newDesktopBounds.Bottom = newPrimaryScreenBounds.Bottom = Screen.PrimaryScreen.Bounds.Bottom; - - Logger.Log(string.Format( - CultureInfo.CurrentCulture, - "logon = {0} PrimaryScreenBounds = {1},{2},{3},{4} desktopBounds = {5},{6},{7},{8}", - Common.RunOnLogonDesktop, - Common.newPrimaryScreenBounds.Left, - Common.newPrimaryScreenBounds.Top, - Common.newPrimaryScreenBounds.Right, - Common.newPrimaryScreenBounds.Bottom, - Common.newDesktopBounds.Left, - Common.newDesktopBounds.Top, - Common.newDesktopBounds.Right, - Common.newDesktopBounds.Bottom)); - -#if USE_MANAGED_ROUTINES - // Managed routines do not work well when running on secure desktop:( - screenWidth = Screen.PrimaryScreen.Bounds.Width; - screenHeight = Screen.PrimaryScreen.Bounds.Height; - screenCount = Screen.AllScreens.Length; - for (int i = 0; i < Screen.AllScreens.Length; i++) - { - if (Screen.AllScreens[i].Bounds.Left < desktopBounds.Left) desktopBounds.Left = Screen.AllScreens[i].Bounds.Left; - if (Screen.AllScreens[i].Bounds.Top < desktopBounds.Top) desktopBounds.Top = Screen.AllScreens[i].Bounds.Top; - if (Screen.AllScreens[i].Bounds.Right > desktopBounds.Right) desktopBounds.Right = Screen.AllScreens[i].Bounds.Right; - if (Screen.AllScreens[i].Bounds.Bottom > desktopBounds.Bottom) desktopBounds.Bottom = Screen.AllScreens[i].Bounds.Bottom; - } -#else - lock (SensitivePoints) - { - SensitivePoints.Clear(); - } - - NativeMethods.EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, MonitorEnumProc, IntPtr.Zero); - - // 1000 calls to EnumDisplayMonitors cost a dozen of milliseconds -#endif - Interlocked.Exchange(ref MachineStuff.desktopBounds, newDesktopBounds); - Interlocked.Exchange(ref MachineStuff.primaryScreenBounds, newPrimaryScreenBounds); - - Logger.Log(string.Format( - CultureInfo.CurrentCulture, - "logon = {0} PrimaryScreenBounds = {1},{2},{3},{4} desktopBounds = {5},{6},{7},{8}", - Common.RunOnLogonDesktop, - MachineStuff.PrimaryScreenBounds.Left, - MachineStuff.PrimaryScreenBounds.Top, - MachineStuff.PrimaryScreenBounds.Right, - MachineStuff.PrimaryScreenBounds.Bottom, - MachineStuff.DesktopBounds.Left, - MachineStuff.DesktopBounds.Top, - MachineStuff.DesktopBounds.Right, - MachineStuff.DesktopBounds.Bottom)); - - Logger.Log("==================== GetScreenConfig ended"); - } - catch (Exception e) - { - Logger.Log(e); - } - } - -#if USING_SCREEN_SAVER_ROUTINES - [DllImport("user32.dll", CharSet = CharSet.Auto)] - private static extern int PostMessage(IntPtr hWnd, int wMsg, int wParam, int lParam); - - [DllImport("user32.dll", CharSet = CharSet.Auto)] - private static extern IntPtr OpenDesktop(string hDesktop, int Flags, bool Inherit, UInt32 DesiredAccess); - - [DllImport("user32.dll", CharSet = CharSet.Auto)] - private static extern bool CloseDesktop(IntPtr hDesktop); - - [DllImport("user32.dll", CharSet = CharSet.Auto)] - private static extern bool EnumDesktopWindows( IntPtr hDesktop, EnumDesktopWindowsProc callback, IntPtr lParam); - - [DllImport("user32.dll", CharSet = CharSet.Auto)] - private static extern bool IsWindowVisible(IntPtr hWnd); - - [DllImport("user32.dll", CharSet = CharSet.Auto)] - private static extern bool SystemParametersInfo(int uAction, int uParam, ref int pvParam, int flags); - - private delegate bool EnumDesktopWindowsProc(IntPtr hDesktop, IntPtr lParam); - private const int WM_CLOSE = 16; - private const int SPI_GETSCREENSAVERRUNNING = 114; - - internal static bool IsScreenSaverRunning() - { - int isRunning = 0; - SystemParametersInfo(SPI_GETSCREENSAVERRUNNING, 0,ref isRunning, 0); - return (isRunning != 0); - } - - internal static void CloseScreenSaver() - { - IntPtr hDesktop = OpenDesktop("Screen-saver", 0, false, DESKTOP_READOBJECTS | DESKTOP_WRITEOBJECTS); - if (hDesktop != IntPtr.Zero) - { - LogDebug("Closing screen saver..."); - EnumDesktopWindows(hDesktop, new EnumDesktopWindowsProc(CloseScreenSaverFunc), IntPtr.Zero); - CloseDesktop(hDesktop); - } - } - - private static bool CloseScreenSaverFunc(IntPtr hWnd, IntPtr lParam) - { - if (IsWindowVisible(hWnd)) - { - LogDebug("Posting WM_CLOSE to " + hWnd.ToString(CultureInfo.InvariantCulture)); - PostMessage(hWnd, WM_CLOSE, 0, 0); - } - return true; - } -#endif - - internal static string GetMyDesktop() - { - byte[] arThreadDesktop = new byte[256]; - IntPtr hD = NativeMethods.GetThreadDesktop(NativeMethods.GetCurrentThreadId()); - if (hD != IntPtr.Zero) - { - _ = NativeMethods.GetUserObjectInformation(hD, NativeMethods.UOI_NAME, arThreadDesktop, arThreadDesktop.Length, out _); - return GetString(arThreadDesktop).Replace("\0", string.Empty); - } - - return string.Empty; - } - - internal static string GetInputDesktop() - { - byte[] arInputDesktop = new byte[256]; - IntPtr hD = NativeMethods.OpenInputDesktop(0, false, NativeMethods.DESKTOP_READOBJECTS); - if (hD != IntPtr.Zero) - { - _ = NativeMethods.GetUserObjectInformation(hD, NativeMethods.UOI_NAME, arInputDesktop, arInputDesktop.Length, out _); - return GetString(arInputDesktop).Replace("\0", string.Empty); - } - - return string.Empty; - } - - internal static void StartMMService(string desktopToRunMouseWithoutBordersOn) - { - if (!Common.RunWithNoAdminRight) - { - Logger.LogDebug("*** Starting on active Desktop: " + desktopToRunMouseWithoutBordersOn); - Service.StartMouseWithoutBordersService(desktopToRunMouseWithoutBordersOn); - } - } - - internal static void CheckForDesktopSwitchEvent(bool cleanupIfExit) - { - try - { - if (!IsMyDesktopActive() || Common.CurrentProcess.SessionId != NativeMethods.WTSGetActiveConsoleSessionId()) - { - Helper.RunDDHelper(true); - int waitCount = 20; - - while (NativeMethods.WTSGetActiveConsoleSessionId() == 0xFFFFFFFF && waitCount > 0) - { - waitCount--; - Logger.LogDebug("The session is detached/attached."); - Thread.Sleep(500); - } - - string myDesktop = GetMyDesktop(); - activeDesktop = GetInputDesktop(); - - Logger.LogDebug("*** Active Desktop = " + activeDesktop); - Logger.LogDebug("*** My Desktop = " + myDesktop); - - if (myDesktop.Equals(activeDesktop, StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("*** Active Desktop == My Desktop (TS session)"); - } - - if (!activeDesktop.Equals("winlogon", StringComparison.OrdinalIgnoreCase) && - !activeDesktop.Equals("default", StringComparison.OrdinalIgnoreCase) && - !activeDesktop.Equals("disconnect", StringComparison.OrdinalIgnoreCase)) - { - try - { - StartMMService(activeDesktop); - } - catch (Exception e) - { - Logger.Log($"{nameof(CheckForDesktopSwitchEvent)}: {e}"); - } - } - else - { - if (!myDesktop.Equals(activeDesktop, StringComparison.OrdinalIgnoreCase)) - { - Logger.Log("*** Active Desktop <> My Desktop"); - } - - uint sid = NativeMethods.WTSGetActiveConsoleSessionId(); - - if (Process.GetProcessesByName(Common.BinaryName).Any(p => (uint)p.SessionId == sid)) - { - Logger.Log("Found MouseWithoutBorders on the active session!"); - } - else - { - Logger.Log("MouseWithoutBorders not found on the active session!"); - StartMMService(null); - } - } - - if (!myDesktop.Equals("winlogon", StringComparison.OrdinalIgnoreCase) && - !myDesktop.Equals("default", StringComparison.OrdinalIgnoreCase)) - { - Logger.LogDebug("*** Desktop inactive, exiting: " + myDesktop); - Setting.Values.LastX = JUST_GOT_BACK_FROM_SCREEN_SAVER; - if (cleanupIfExit) - { - Common.Cleanup(); - } - - Process.GetCurrentProcess().KillProcess(); - } - } - } - catch (Exception e) - { - Logger.Log(e); - } - } - - private static Point p; - - internal static bool IsMyDesktopActive() - { - return NativeMethods.GetCursorPos(ref p); - } - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/Common.cs b/src/modules/MouseWithoutBorders/App/Class/Common.cs deleted file mode 100644 index 8d4ff7f326..0000000000 --- a/src/modules/MouseWithoutBorders/App/Class/Common.cs +++ /dev/null @@ -1,1564 +0,0 @@ -// 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; -using System.Diagnostics.CodeAnalysis; -using System.Drawing; -using System.Drawing.Imaging; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Windows.Forms; - -using Microsoft.PowerToys.Settings.UI.Library; - -// <summary> -// Most of the helper methods. -// </summary> -// <history> -// 2008 created by Truong Do (ductdo). -// 2009-... modified by Truong Do (TruongDo). -// 2023- Included in PowerToys. -// </history> -using MouseWithoutBorders.Class; -using MouseWithoutBorders.Core; -using MouseWithoutBorders.Exceptions; - -using Thread = MouseWithoutBorders.Core.Thread; - -// Log is enough -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CheckClipboard()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CheckForDesktopSwitchEvent(System.Boolean)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#SetAsStartupItem(System.Boolean)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#HelperThread()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#GetMyStorageDir()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#MouseEvent(MouseWithoutBorders.MOUSEDATA)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#KeybdEvent(MouseWithoutBorders.KEYBDDATA)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ImpersonateLoggedOnUserAndDoSomething(System.Threading.ThreadStart)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#StartMouseWithoutBordersService()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#HookClipboard()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ReceiveClipboardData(MouseWithoutBorders.DATA,System.Boolean)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ReceiverCallback(System.Object)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ConnectAndGetData(System.Object)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CheckNewVersion()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#StartServiceAndSendLogoffSignal()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#GetScreenConfig()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CaptureScreen()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#InitEncryption()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ToggleIcon()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#GetNameAndIPAddresses()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#Cleanup()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling", Scope = "type", Target = "MouseWithoutBorders.Common", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity", Scope = "member", Target = "MouseWithoutBorders.Common.#ConnectAndGetData(System.Object)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity", Scope = "member", Target = "MouseWithoutBorders.Common.#ProcessPackage(MouseWithoutBorders.DATA)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#SetOEMBackground(System.Boolean)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#get_Machine_Pool()", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#SetOEMBackground(System.Boolean,System.String)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#GetNewImageAndSaveTo(System.String,System.String)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CreateLowIntegrityProcess(System.String,System.String,System.Int32)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#LogAll()", MessageId = "System.String.Format(System.IFormatProvider,System.String,System.Object[])", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#CheckForDesktopSwitchEvent(System.Boolean)", MessageId = "MouseWithoutBorders.NativeMethods.SendMessage(System.IntPtr,System.Int32,System.IntPtr,System.IntPtr)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#DragDropStep04()", MessageId = "MouseWithoutBorders.NativeMethods.SendMessage(System.IntPtr,System.Int32,System.IntPtr,System.IntPtr)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#CreateLowIntegrityProcess(System.String,System.String,System.Int32)", MessageId = "MouseWithoutBorders.NativeMethods.WaitForSingleObject(System.IntPtr,System.Int32)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#GetText(System.IntPtr)", MessageId = "MouseWithoutBorders.NativeMethods.GetWindowText(System.IntPtr,System.Text.StringBuilder,System.Int32)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#ImpersonateLoggedOnUserAndDoSomething(System.Threading.ThreadStart)", MessageId = "MouseWithoutBorders.NativeMethods.WTSQueryUserToken(System.UInt32,System.IntPtr@)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CreateLowIntegrityProcess(System.String,System.String,System.Int32,System.Boolean,System.Int64)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CreateProcessInInputDesktopSession(System.String,System.String,System.String,System.Boolean,System.Int16)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#SkSend(MouseWithoutBorders.DATA,System.Boolean,System.Int32)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ReceiveClipboardDataUsingTCP(MouseWithoutBorders.DATA,System.Boolean,System.Net.Sockets.Socket)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#UpdateMachineMatrix(MouseWithoutBorders.DATA)", Justification = "Dotnet port with style preservation")] -[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ReopenSockets(System.Boolean)", Justification = "Dotnet port with style preservation")] - -namespace MouseWithoutBorders -{ - internal partial class Common - { - internal Common() - { - } - - private static InputHook hook; - private static FrmMatrix matrixForm; - private static FrmInputCallback inputCallbackForm; - private static FrmAbout aboutForm; - private static Thread helper; -#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter - internal static int screenWidth; - internal static int screenHeight; -#pragma warning restore SA1307 - private static int lastX; - private static int lastY; - - private static bool mainFormVisible = true; - private static bool runOnLogonDesktop; - private static bool runOnScrSaverDesktop; - -#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter - internal static int[] toggleIcons; - internal static int toggleIconsIndex; -#pragma warning restore SA1307 - internal const int TOGGLE_ICONS_SIZE = 4; - internal const int ICON_ONE = 0; - internal const int ICON_ALL = 1; - internal const int ICON_SMALL_CLIPBOARD = 2; - internal const int ICON_BIG_CLIPBOARD = 3; - internal const int ICON_ERROR = 4; - internal const int JUST_GOT_BACK_FROM_SCREEN_SAVER = 9999; - - internal const int NETWORK_STREAM_BUF_SIZE = 1024 * 1024; - internal static readonly EventWaitHandle EvSwitch = new(false, EventResetMode.AutoReset); - private static Point lastPos; -#pragma warning disable SA1307 // Accessible fields should begin with upper-case names - internal static int switchCount; -#pragma warning restore SA1307 - private static long lastReconnectByHotKeyTime; - private static int tcpPort; - private static bool secondOpenSocketTry; - private static string binaryName; - - internal static Process CurrentProcess { get; set; } - - internal static bool HotkeyMatched(int vkCode, bool winDown, bool ctrlDown, bool altDown, bool shiftDown, HotkeySettings hotkey) - { - return !hotkey.IsEmpty() && (vkCode == hotkey.Code) && (!hotkey.Win || winDown) && (!hotkey.Alt || altDown) && (!hotkey.Shift || shiftDown) && (!hotkey.Ctrl || ctrlDown); - } - - public static string BinaryName - { - get => Common.binaryName; - set => Common.binaryName = value; - } - - public static bool SecondOpenSocketTry - { - get => Common.secondOpenSocketTry; - set => Common.secondOpenSocketTry = value; - } - - public static long LastReconnectByHotKeyTime - { - get => Common.lastReconnectByHotKeyTime; - set => Common.lastReconnectByHotKeyTime = value; - } - - public static int SwitchCount - { - get => Common.switchCount; - set => Common.switchCount = value; - } - - public static Point LastPos - { - get => Common.lastPos; - set => Common.lastPos = value; - } - - internal static FrmAbout AboutForm - { - get => Common.aboutForm; - set => Common.aboutForm = value; - } - - internal static FrmInputCallback InputCallbackForm - { - get => Common.inputCallbackForm; - set => Common.inputCallbackForm = value; - } - - public static int PaintCount { get; set; } - - internal static bool RunOnScrSaverDesktop - { - get => Common.runOnScrSaverDesktop; - set => Common.runOnScrSaverDesktop = value; - } - - internal static bool RunOnLogonDesktop - { - get => Common.runOnLogonDesktop; - set => Common.runOnLogonDesktop = value; - } - - internal static bool RunWithNoAdminRight { get; set; } - - internal static int LastX - { - get => Common.lastX; - set => Common.lastX = value; - } - - internal static int LastY - { - get => Common.lastY; - set => Common.lastY = value; - } - - internal static int[] ToggleIcons => Common.toggleIcons; - - internal static int ScreenHeight => Common.screenHeight; - - internal static int ScreenWidth => Common.screenWidth; - - internal static bool Is64bitOS - { - get; private set; - - // set { Common.is64bitOS = value; } - } - - internal static int ToggleIconsIndex - { - // get { return Common.toggleIconsIndex; } - set => Common.toggleIconsIndex = value; - } - - internal static InputHook Hook - { - get => Common.hook; - set => Common.hook = value; - } - - internal static SocketStuff Sk { get; set; } - - internal static FrmScreen MainForm { get; set; } - - internal static FrmMouseCursor MouseCursorForm { get; set; } - - internal static FrmMatrix MatrixForm - { - get => Common.matrixForm; - set => Common.matrixForm = value; - } - - internal static ID DesMachineID - { - get => MachineStuff.desMachineID; - - set - { - MachineStuff.desMachineID = value; - MachineStuff.DesMachineName = MachineStuff.NameFromID(MachineStuff.desMachineID); - } - } - - internal static ID MachineID => (ID)Setting.Values.MachineId; - - internal static string MachineName { get; set; } - - internal static bool MainFormVisible - { - get => Common.mainFormVisible; - set => Common.mainFormVisible = value; - } - - internal static Mutex SocketMutex { get; set; } // Synchronization between MouseWithoutBorders running in different desktops - - // TODO: For telemetry only, to be removed. - private static int socketMutexBalance; - - internal static void ReleaseSocketMutex() - { - if (SocketMutex != null) - { - Logger.LogDebug("SOCKET MUTEX BEGIN RELEASE."); - - try - { - _ = Interlocked.Decrement(ref socketMutexBalance); - SocketMutex.ReleaseMutex(); - } - catch (ApplicationException e) - { - // The current thread does not own the mutex, the thread acquired it will own it. - Logger.TelemetryLogTrace($"{nameof(ReleaseSocketMutex)}: {e.Message}. {Thread.CurrentThread.ManagedThreadId}/{UIThreadID}.", SeverityLevel.Warning); - } - - Logger.LogDebug("SOCKET MUTEX RELEASED."); - } - else - { - Logger.LogDebug("SOCKET MUTEX NULL."); - } - } - - internal static void AcquireSocketMutex() - { - if (SocketMutex != null) - { - Logger.LogDebug("SOCKET MUTEX BEGIN WAIT."); - int waitTimeout = 60000; // TcpListener.Stop may take very long to complete for some reason. - - int socketMutexBalance = int.MinValue; - - bool acquireMutex = ExecuteAndTrace( - "Waiting for sockets to close", - () => - { - socketMutexBalance = Interlocked.Increment(ref Common.socketMutexBalance); - _ = SocketMutex.WaitOne(waitTimeout); // The app now requires .Net 4.0. Note: .Net20RTM does not have the one-parameter version of the API. - }, - TimeSpan.FromSeconds(5)); - - // Took longer than expected. - if (!acquireMutex) - { - Process[] ps = Process.GetProcessesByName(Common.BinaryName); - Logger.TelemetryLogTrace($"Balance: {socketMutexBalance}, Active: {IsMyDesktopActive()}, Sid/Console: {Process.GetCurrentProcess().SessionId}/{NativeMethods.WTSGetActiveConsoleSessionId()}, Desktop/Input: {GetMyDesktop()}/{GetInputDesktop()}, count: {ps?.Length}.", SeverityLevel.Warning); - } - - Logger.LogDebug("SOCKET MUTEX ENDED."); - } - else - { - Logger.LogDebug("SOCKET MUTEX NULL."); - } - } - - internal static bool BlockingUI { get; private set; } - - internal static bool ExecuteAndTrace(string actionName, Action action, TimeSpan timeout, bool restart = false) - { - bool rv = true; - Logger.LogDebug(actionName); - bool done = false; - - BlockingUI = true; - - if (restart) - { - Common.MainForm.Text = Setting.Values.MyIdEx; - - /* closesocket() rarely gets stuck for some reason inside ntdll!ZwClose ...=>... afd!AfdCleanupCore. - * There is no good workaround for it so far, still working with [Winsock 2.0 Discussions] to address the issue. - * */ - new Thread( - () => - { - for (int i = 0; i < timeout.TotalSeconds; i++) - { - Thread.Sleep(1000); - - if (done) - { - return; - } - } - - Logger.TelemetryLogTrace($"[{actionName}] took more than {(long)timeout.TotalSeconds}, restarting the process.", SeverityLevel.Warning, true); - - string desktop = Common.GetMyDesktop(); - MachineStuff.oneInstanceCheck?.Close(); - _ = Process.Start(Application.ExecutablePath, desktop); - Logger.LogDebug($"Started on desktop {desktop}"); - - Process.GetCurrentProcess().KillProcess(true); - }, - $"{actionName} watchdog").Start(); - } - - Stopwatch timer = Stopwatch.StartNew(); - - try - { - action(); - } - finally - { - done = true; - BlockingUI = false; - - if (restart) - { - Common.MainForm.Text = Setting.Values.MyID; - } - - timer.Stop(); - - if (timer.Elapsed > timeout) - { - rv = false; - - if (!restart) - { - Logger.TelemetryLogTrace($"[{actionName}] took more than {(long)timeout.TotalSeconds}: {(long)timer.Elapsed.TotalSeconds}.", SeverityLevel.Warning); - } - } - } - - return rv; - } - - internal static byte[] GetBytes(string st) - { - return ASCIIEncoding.ASCII.GetBytes(st); - } - - internal static string GetString(byte[] bytes) - { - return ASCIIEncoding.ASCII.GetString(bytes); - } - - internal static byte[] GetBytesU(string st) - { - return ASCIIEncoding.Unicode.GetBytes(st); - } - - internal static string GetStringU(byte[] bytes) - { - return ASCIIEncoding.Unicode.GetString(bytes); - } - - internal static int UIThreadID { get; set; } - - internal static void DoSomethingInUIThread(Action action, bool blocking = false) - { - InvokeInFormThread(MainForm, UIThreadID, action, blocking); - } - - internal static int InputCallbackThreadID { get; set; } - - internal static void DoSomethingInTheInputCallbackThread(Action action, bool blocking = true) - { - InvokeInFormThread(InputCallbackForm, InputCallbackThreadID, action, blocking); - } - - private static void InvokeInFormThread(System.Windows.Forms.Form form, int threadId, Action action, bool blocking) - { - if (form != null) - { - int currentThreadId = Thread.CurrentThread.ManagedThreadId; - - if (currentThreadId == threadId) - { - action(); - } - else - { - bool done = false; - - try - { - Action callback = () => - { - try - { - action(); - } - catch (Exception e) - { - Logger.Log(e); - } - finally - { - done = true; - } - }; - _ = form.BeginInvoke(callback); - } - catch (Exception e) - { - done = true; - Logger.Log(e); - } - - while (blocking && !done) - { - Thread.Sleep(16); - - if (currentThreadId == UIThreadID || currentThreadId == InputCallbackThreadID) - { - Application.DoEvents(); - } - } - } - } - } - - private static readonly Lock InputSimulationLock = new(); - - internal static void DoSomethingInTheInputSimulationThread(ThreadStart target) - { - /* - * For some reason, SendInput may hit deadlock if it is called in the InputHookProc thread. - * For now leave it as is in the caller thread which is the socket receiver thread. - * */ - - // SendInput is thread-safe but few users seem to hit a deadlock occasionally, probably a Windows bug. - lock (InputSimulationLock) - { - target(); - } - } - - internal static void SendPackage(ID des, PackageType packageType) - { - DATA package = new(); - package.Type = packageType; - package.Des = des; - package.MachineName = MachineName; - - SkSend(package, null, false); - } - - internal static void SendHeartBeat(bool initial = false) - { - SendPackage(ID.ALL, initial && Common.GeneratedKey ? PackageType.Heartbeat_ex : PackageType.Heartbeat); - } - - private static long lastSendNextMachine; - - internal static void SendNextMachine(ID hostMachine, ID nextMachine, Point requestedXY) - { - Logger.LogDebug($"SendNextMachine: Host machine: {hostMachine}, Next machine: {nextMachine}, Requested XY: {requestedXY}"); - - if (GetTick() - lastSendNextMachine < 100) - { - Logger.LogDebug("Machine switching in progress."); // "Move Mouse relatively" mode, slow machine/network, quick/busy hand. - return; - } - - lastSendNextMachine = GetTick(); - - DATA package = new(); - package.Type = PackageType.NextMachine; - - package.Des = hostMachine; - - package.Md.X = requestedXY.X; - package.Md.Y = requestedXY.Y; - package.Md.WheelDelta = (int)nextMachine; - - SkSend(package, null, false); - - Logger.LogDebug("SendNextMachine done."); - } - - private static ulong lastInputEventCount; - private static ulong lastRealInputEventCount; - - internal static void SendAwakeBeat() - { - if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && Common.IsMyDesktopActive() && - Setting.Values.BlockScreenSaver && lastRealInputEventCount != Event.RealInputEventCount) - { - SendPackage(ID.ALL, PackageType.Awake); - } - else - { - SendHeartBeat(); - } - - lastInputEventCount = Event.InputEventCount; - lastRealInputEventCount = Event.RealInputEventCount; - } - - internal static void HumanBeingDetected() - { - if (lastInputEventCount == Event.InputEventCount) - { - if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && Common.IsMyDesktopActive()) - { - PokeMyself(); - } - } - - lastInputEventCount = Event.InputEventCount; - } - - private static void PokeMyself() - { - int x, y = 0; - - for (int i = 0; i < 10; i++) - { - x = Ran.Next(-9, 10); - InputSimulation.MoveMouseRelative(x, y); - Thread.Sleep(50); - InputSimulation.MoveMouseRelative(-x, -y); - Thread.Sleep(50); - - if (lastInputEventCount != Event.InputEventCount) - { - break; - } - } - } - - internal static void InitLastInputEventCount() - { - lastInputEventCount = Event.InputEventCount; - lastRealInputEventCount = Event.RealInputEventCount; - } - - internal static void SendHello() - { - SendPackage(ID.ALL, PackageType.Hello); - } - - /* - internal static void SendHi() - { - SendPackage(IP.ALL, PackageType.hi); - } - * */ - - private static void SendByeBye() - { - Logger.LogDebug($"{nameof(SendByeBye)}"); - SendPackage(ID.ALL, PackageType.ByeBye); - } - - internal static void SendClipboardBeat() - { - SendPackage(ID.ALL, PackageType.Clipboard); - } - - internal static void ProcessByeByeMessage(DATA package) - { - if (package.Src == MachineStuff.desMachineID) - { - MachineStuff.SwitchToMachine(MachineName.Trim()); - } - - _ = MachineStuff.RemoveDeadMachines(package.Src); - } - - internal static long GetTick() // ms - { - return DateTime.Now.Ticks / 10000; - } - - internal static void SetToggleIcon(int[] toggleIcons) - { - Logger.LogDebug($"{nameof(SetToggleIcon)}: {toggleIcons?.FirstOrDefault()}"); - Common.toggleIcons = toggleIcons; - toggleIconsIndex = 0; - } - - internal static string CaptureScreen() - { - try - { - string fileName = GetMyStorageDir() + @"ScreenCaptureByMouseWithoutBorders.png"; - int w = MachineStuff.desktopBounds.Right - MachineStuff.desktopBounds.Left; - int h = MachineStuff.desktopBounds.Bottom - MachineStuff.desktopBounds.Top; - Bitmap bm = new(w, h); - Graphics g = Graphics.FromImage(bm); - Size s = new(w, h); - g.CopyFromScreen(MachineStuff.desktopBounds.Left, MachineStuff.desktopBounds.Top, 0, 0, s); - bm.Save(fileName, ImageFormat.Png); - bm.Dispose(); - return fileName; - } - catch (Exception e) - { - Logger.Log(e); - return null; - } - } - - internal static void PrepareScreenCapture() - { - Common.DoSomethingInUIThread(() => - { - if (!DragDrop.MouseDown && Helper.SendMessageToHelper(0x401, IntPtr.Zero, IntPtr.Zero) > 0) - { - Common.MMSleep(0.2); - InputSimulation.SendKey(new KEYBDDATA() { wVk = (int)VK.SNAPSHOT }); - InputSimulation.SendKey(new KEYBDDATA() { dwFlags = (int)Common.LLKHF.UP, wVk = (int)VK.SNAPSHOT }); - - Logger.LogDebug("PrepareScreenCapture: SNAPSHOT simulated."); - - _ = NativeMethods.MoveWindow( - (IntPtr)NativeMethods.FindWindow(null, Helper.HELPER_FORM_TEXT), - MachineStuff.DesktopBounds.Left, - MachineStuff.DesktopBounds.Top, - MachineStuff.DesktopBounds.Right - MachineStuff.DesktopBounds.Left, - MachineStuff.DesktopBounds.Bottom - MachineStuff.DesktopBounds.Top, - false); - - _ = Helper.SendMessageToHelper(0x406, IntPtr.Zero, IntPtr.Zero, false); - } - else - { - Logger.Log("PrepareScreenCapture: Validation failed."); - } - }); - } - - internal static void OpenImage(string file) - { - // We want to run mspaint under the user account who ran explorer.exe (who logged in this current input desktop) - - // ImpersonateLoggedOnUserAndDoSomething(delegate() - // { - // Process.Start("explorer", "\"" + file + "\""); - // }); - _ = Launch.CreateProcessInInputDesktopSession( - "\"" + Environment.ExpandEnvironmentVariables(@"%SystemRoot%\System32\Mspaint.exe") + - "\"", - "\"" + file + "\"", - GetInputDesktop(), - 1); - - // CreateNormalIntegrityProcess(Environment.ExpandEnvironmentVariables(@"%SystemRoot%\System32\Mspaint.exe") + - // " \"" + file + "\""); - - // We don't want to run mspaint as local system account - /* - ProcessStartInfo s = new ProcessStartInfo( - Environment.ExpandEnvironmentVariables(@"%SystemRoot%\System32\Mspaint.exe"), - "\"" + file + "\""); - s.WindowStyle = ProcessWindowStyle.Maximized; - Process.Start(s); - * */ - } - - internal static void SendImage(string machine, string file) - { - LastDragDropFile = file; - - // Send ClipboardCapture - if (machine.Equals("All", StringComparison.OrdinalIgnoreCase)) - { - SendPackage(ID.ALL, PackageType.ClipboardCapture); - } - else - { - ID id = MachineStuff.MachinePool.ResolveID(machine); - if (id != ID.NONE) - { - SendPackage(id, PackageType.ClipboardCapture); - } - } - } - - internal static void SendImage(ID src, string file) - { - LastDragDropFile = file; - - // Send ClipboardCapture - SendPackage(src, PackageType.ClipboardCapture); - } - - internal static void ShowToolTip(string tip, int timeOutInMilliseconds = 5000, ToolTipIcon icon = ToolTipIcon.Info, bool showBalloonTip = true, bool forceEvenIfHidingOldUI = false) - { - if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) - { - DoSomethingInUIThread(() => - { - if (Setting.Values.FirstRun) - { - MachineStuff.Settings?.ShowTip(icon, tip, timeOutInMilliseconds); - } - - Common.MatrixForm?.ShowTip(icon, tip, timeOutInMilliseconds); - - if (showBalloonTip) - { - if (MainForm != null) - { - MainForm.ShowToolTip(tip, timeOutInMilliseconds, forceEvenIfHidingOldUI: forceEvenIfHidingOldUI); - } - else - { - Logger.Log(tip); - } - } - }); - } - } - - private static FrmMessage topMostMessageForm; - - internal static void ToggleShowTopMostMessage(string text, string bigText, int timeOut) - { - DoSomethingInUIThread(() => - { - if (topMostMessageForm == null) - { - topMostMessageForm = new FrmMessage(text, bigText, timeOut); - topMostMessageForm.Show(); - } - else - { - FrmMessage currentMessageForm = topMostMessageForm; - topMostMessageForm = null; - currentMessageForm.Close(); - } - }); - } - - internal static void HideTopMostMessage() - { - DoSomethingInUIThread(() => - { - topMostMessageForm?.Close(); - }); - } - - internal static void NullTopMostMessage() - { - DoSomethingInUIThread(() => - { - if (topMostMessageForm != null) - { - topMostMessageForm = null; - } - }); - } - - internal static bool IsTopMostMessageNotNull() - { - return topMostMessageForm != null; - } - - private static bool TestSend(TcpSk t) - { - ID remoteMachineID; - - if (t.Status == SocketStatus.Connected) - { - try - { - DATA package = new(); - package.Type = PackageType.Hi; - package.Des = remoteMachineID = (ID)t.MachineId; - package.MachineName = MachineName; - - _ = Sk.TcpSend(t, package); - t.EncryptedStream?.Flush(); - - return true; - } - catch (ExpectedSocketException) - { - t.BackingSocket = null; // To be removed at CloseAnUnusedSocket() - } - } - - t.Status = SocketStatus.SendError; - return false; - } - - internal static bool IsConnectedTo(ID remoteMachineID) - { - bool updateClientSockets = false; - - if (remoteMachineID == MachineID) - { - return true; - } - - SocketStuff sk = Common.Sk; - - if (sk != null) - { - lock (sk.TcpSocketsLock) - { - if (sk.TcpSockets != null) - { - foreach (TcpSk t in sk.TcpSockets) - { - if (t.Status == SocketStatus.Connected && (uint)remoteMachineID == t.MachineId) - { - if (TestSend(t)) - { - return true; - } - else - { - updateClientSockets = true; - } - } - } - } - } - } - - if (updateClientSockets) - { - MachineStuff.UpdateClientSockets(nameof(IsConnectedTo)); - } - - return false; - } - -#if DEBUG - private static long minSendTime = long.MaxValue; - private static long avgSendTime; - private static long maxSendTime; - private static long totalSendCount; - private static long totalSendTime; -#endif - - internal static void SkSend(DATA data, uint? exceptDes, bool includeHandShakingSockets) - { - bool connected = false; - - SocketStuff sk = Sk; - - if (sk != null) - { -#if DEBUG - long startStop = DateTime.Now.Ticks; - totalSendCount++; -#endif - - try - { - data.Id = Interlocked.Increment(ref PackageID); - - bool updateClientSockets = false; - - lock (sk.TcpSocketsLock) - { - foreach (TcpSk t in sk.TcpSockets) - { - if (t != null && t.BackingSocket != null && (t.Status == SocketStatus.Connected || (t.Status == SocketStatus.Handshaking && includeHandShakingSockets))) - { - if (t.MachineId == (uint)data.Des || (data.Des == ID.ALL && t.MachineId != exceptDes && MachineStuff.InMachineMatrix(t.MachineName))) - { - try - { - sk.TcpSend(t, data); - - if (data.Des != ID.ALL) - { - connected = true; - } - } - catch (ExpectedSocketException) - { - t.BackingSocket = null; // To be removed at CloseAnUnusedSocket() - updateClientSockets = true; - } - catch (Exception e) - { - Logger.Log(e); - t.BackingSocket = null; // To be removed at CloseAnUnusedSocket() - updateClientSockets = true; - } - } - } - } - } - - if (!connected && data.Des != ID.ALL) - { - Logger.LogDebug("********** No active connection found for the remote machine! **********" + data.Des.ToString()); - - if (data.Des == ID.NONE || MachineStuff.RemoveDeadMachines(data.Des)) - { - // SwitchToMachine(MachineName.Trim()); - MachineStuff.NewDesMachineID = DesMachineID = MachineID; - MachineStuff.SwitchLocation.X = Event.XY_BY_PIXEL + Event.myLastX; - MachineStuff.SwitchLocation.Y = Event.XY_BY_PIXEL + Event.myLastY; - MachineStuff.SwitchLocation.ResetCount(); - EvSwitch.Set(); - } - } - - if (updateClientSockets) - { - MachineStuff.UpdateClientSockets("SkSend"); - } - } - catch (Exception e) - { - Logger.Log(e); - } - -#if DEBUG - startStop = DateTime.Now.Ticks - startStop; - totalSendTime += startStop; - if (startStop < minSendTime) - { - minSendTime = startStop; - } - - if (startStop > maxSendTime) - { - maxSendTime = startStop; - } - - avgSendTime = totalSendTime / totalSendCount; -#endif - } - else - { - PackageSent.Nil++; - } - } - - internal static void CloseAnUnusedSocket() - { - SocketStuff sk = Common.Sk; - - if (sk != null) - { - lock (sk.TcpSocketsLock) - { - if (sk.TcpSockets != null) - { - TcpSk tobeRemoved = null; - - foreach (TcpSk t in sk.TcpSockets) - { - if ((t.Status != SocketStatus.Connected && t.BirthTime < GetTick() - SocketStuff.CONNECT_TIMEOUT) || t.BackingSocket == null) - { - Logger.LogDebug("CloseAnUnusedSocket: " + t.MachineName + ":" + t.MachineId + "|" + t.Status.ToString()); - tobeRemoved = t; - - if (t.BackingSocket != null) - { - try - { - t.BackingSocket.Close(); - } - catch (Exception e) - { - Logger.Log(e); - } - } - - break; // Each time we try to remove one socket only. - } - } - - if (tobeRemoved != null) - { - _ = sk.TcpSockets.Remove(tobeRemoved); - } - } - } - } - } - - internal static bool AtLeastOneSocketConnected() - { - SocketStuff sk = Common.Sk; - - if (sk != null) - { - lock (sk.TcpSocketsLock) - { - if (sk.TcpSockets != null) - { - foreach (TcpSk t in sk.TcpSockets) - { - if (t.Status == SocketStatus.Connected) - { - Logger.LogDebug("AtLeastOneSocketConnected returning true: " + t.MachineName); - return true; - } - } - } - } - } - - Logger.LogDebug("AtLeastOneSocketConnected returning false."); - return false; - } - - internal static Socket AtLeastOneServerSocketConnected() - { - SocketStuff sk = Common.Sk; - - if (sk != null) - { - lock (sk.TcpSocketsLock) - { - if (sk.TcpSockets != null) - { - foreach (TcpSk t in sk.TcpSockets) - { - if (!t.IsClient && t.Status == SocketStatus.Connected) - { - Logger.LogDebug("AtLeastOneServerSocketConnected returning true: " + t.MachineName); - return t.BackingSocket; - } - } - } - } - } - - Logger.LogDebug("AtLeastOneServerSocketConnected returning false."); - return null; - } - - internal static TcpSk GetConnectedClientSocket() - { - SocketStuff sk = Common.Sk; - - if (sk != null) - { - lock (sk.TcpSocketsLock) - { - return sk.TcpSockets?.FirstOrDefault(item => item.IsClient && item.Status == SocketStatus.Connected); - } - } - else - { - return null; - } - } - - internal static bool AtLeastOneSocketEstablished() - { - SocketStuff sk = Common.Sk; - - if (sk != null) - { - lock (sk.TcpSocketsLock) - { - if (sk.TcpSockets != null) - { - foreach (TcpSk t in sk.TcpSockets) - { - if (t.BackingSocket != null && t.BackingSocket.Connected) - { - if (TestSend(t)) - { - Logger.LogDebug($"{nameof(AtLeastOneSocketEstablished)} returning true: {t.MachineName}"); - return true; - } - } - } - } - } - } - - Logger.LogDebug($"{nameof(AtLeastOneSocketEstablished)} returning false."); - return false; - } - - internal static bool IsConnectedByAClientSocketTo(string machineName) - { - SocketStuff sk = Common.Sk; - - if (sk != null) - { - lock (sk.TcpSocketsLock) - { - foreach (TcpSk t in sk.TcpSockets) - { - if (t != null && t.IsClient && t.Status == SocketStatus.Connected - && t.BackingSocket != null && t.MachineName.Equals(machineName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - } - - return false; - } - - internal static IPAddress GetConnectedClientSocketIPAddressFor(string machineName) - { - SocketStuff sk = Common.Sk; - - if (sk != null) - { - lock (sk.TcpSocketsLock) - { - return sk.TcpSockets.FirstOrDefault(t => t != null && t.IsClient && t.Status == SocketStatus.Connected - && t.Address != null && t.MachineName.Equals(machineName, StringComparison.OrdinalIgnoreCase)) - ?.Address; - } - } - - return null; - } - - internal static bool IsConnectingByAClientSocketTo(string machineName, IPAddress ip) - { - SocketStuff sk = Common.Sk; - - if (sk != null) - { - lock (sk.TcpSocketsLock) - { - foreach (TcpSk t in sk.TcpSockets) - { - if (t != null && t.IsClient && t.Status == SocketStatus.Connecting - && t.BackingSocket != null && t.MachineName.Equals(machineName, StringComparison.OrdinalIgnoreCase) - && t.Address.ToString().Equals(ip.ToString(), StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - } - - return false; - } - - internal static void UpdateSetupMachineMatrix(string desMachine) - { - int machineCt = 0; - - foreach (string m in MachineStuff.MachineMatrix) - { - if (!string.IsNullOrEmpty(m.Trim())) - { - machineCt++; - } - } - - if (machineCt < 2 && MachineStuff.Settings != null && (MachineStuff.Settings.GetCurrentPage() is SetupPage1 || MachineStuff.Settings.GetCurrentPage() is SetupPage2b)) - { - MachineStuff.MachineMatrix = new string[MachineStuff.MAX_MACHINE] { Common.MachineName.Trim(), desMachine, string.Empty, string.Empty }; - Logger.LogDebug("UpdateSetupMachineMatrix: " + string.Join(",", MachineStuff.MachineMatrix)); - - Common.DoSomethingInUIThread( - () => - { - MachineStuff.Settings.SetControlPage(new SetupPage4()); - }, - true); - } - } - - internal static void ReopenSockets(bool byUser) - { - DoSomethingInUIThread( - () => - { - try - { - SocketStuff tmpSk = Sk; - - if (tmpSk != null) - { - Sk = null; // TODO: This looks redundant. - tmpSk.Close(byUser); - } - - Sk = new SocketStuff(tcpPort, byUser); - } - catch (Exception e) - { - Sk = null; - Logger.Log(e); - } - - if (Sk != null) - { - if (byUser) - { - SocketStuff.ClearBadIPs(); - } - - MachineStuff.UpdateClientSockets("ReopenSockets"); - } - }, - true); - - if (Sk == null) - { - return; - } - - Common.DoSomethingInTheInputCallbackThread(() => - { - if (Common.Hook != null) - { - Common.Hook.Stop(); - Common.Hook = null; - } - - if (byUser) - { - Common.InputCallbackForm.Close(); - Common.InputCallbackForm = null; - Program.StartInputCallbackThread(); - } - else - { - Common.InputCallbackForm.InstallKeyboardAndMouseHook(); - } - }); - } - - private static string GetMyStorageDir() - { - string st = string.Empty; - - try - { - if (RunOnLogonDesktop || RunOnScrSaverDesktop) - { - st = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); - if (!Directory.Exists(st)) - { - _ = Directory.CreateDirectory(st); - } - - st += @"\" + Common.BinaryName; - if (!Directory.Exists(st)) - { - _ = Directory.CreateDirectory(st); - } - - st += @"\ScreenCaptures\"; - if (!Directory.Exists(st)) - { - _ = Directory.CreateDirectory(st); - } - } - else - { - _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => - { - st = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\" + Common.BinaryName; - if (!Directory.Exists(st)) - { - _ = Directory.CreateDirectory(st); - } - - st += @"\ScreenCaptures\"; - if (!Directory.Exists(st)) - { - _ = Directory.CreateDirectory(st); - } - }); - } - - Logger.LogDebug("GetMyStorageDir: " + st); - - // Delete old files. - foreach (FileInfo fi in new DirectoryInfo(st).GetFiles()) - { - if (fi.CreationTime.AddDays(1) < DateTime.Now) - { - fi.Delete(); - } - } - - return st; - } - catch (Exception e) - { - Logger.Log(e); - - if (string.IsNullOrEmpty(st) || !st.Contains(Common.BinaryName)) - { - st = Path.GetTempPath(); - } - - return st; - } - } - - internal static void GetMachineName() - { - string machine_Name = string.Empty; - - try - { - machine_Name = Dns.GetHostName(); - Logger.LogDebug("GetHostName = " + machine_Name); - } - catch (Exception e) - { - Logger.Log(e); - - if (string.IsNullOrEmpty(machine_Name)) - { - machine_Name = "RANDOM" + Ran.Next().ToString(CultureInfo.CurrentCulture); - } - } - - if (machine_Name.Length > 32) - { - machine_Name = machine_Name[..32]; - } - - Common.MachineName = machine_Name.Trim(); - - Logger.LogDebug($"========== {nameof(GetMachineName)} ended!"); - } - - private static string GetNetworkName(NetworkInterface networkInterface) - { - return $"{networkInterface.Name} | {networkInterface.Description.Replace(":", "-")}"; - } - - internal static string GetRemoteStringIP(Socket s, bool throwException = false) - { - if (s == null) - { - return string.Empty; - } - - string ip; - - try - { - ip = (s?.RemoteEndPoint as IPEndPoint)?.Address?.ToString(); - - if (string.IsNullOrEmpty(ip)) - { - return string.Empty; - } - } - catch (ObjectDisposedException e) - { - Logger.Log($"{nameof(GetRemoteStringIP)}: The socket could have been disposed by other threads, error: {e.Message}"); - - if (throwException) - { - throw; - } - - return string.Empty; - } - catch (SocketException e) - { - Logger.Log($"{nameof(GetRemoteStringIP)}: {e.Message}"); - - if (throwException) - { - throw; - } - - return string.Empty; - } - - return ip; - } - - internal static void CloseAllFormsAndHooks() - { - if (Hook != null) - { - Hook.Stop(); - Hook = null; - if (InputCallbackForm != null) - { - DoSomethingInTheInputCallbackThread(() => - { - InputCallbackForm.Close(); - InputCallbackForm = null; - }); - } - } - - if (MainForm != null) - { - MainForm.Destroy(); - MainForm = null; - } - - if (MatrixForm != null) - { - MatrixForm.Close(); - MatrixForm = null; - } - - if (AboutForm != null) - { - AboutForm.Close(); - AboutForm = null; - } - } - - internal static void MoveMouseToCenter() - { - Logger.LogDebug("+++++ MoveMouseToCenter"); - InputSimulation.MoveMouse( - MachineStuff.PrimaryScreenBounds.Left + ((MachineStuff.PrimaryScreenBounds.Right - MachineStuff.PrimaryScreenBounds.Left) / 2), - MachineStuff.PrimaryScreenBounds.Top + ((MachineStuff.PrimaryScreenBounds.Bottom - MachineStuff.PrimaryScreenBounds.Top) / 2)); - } - - internal static void HideMouseCursor(bool byHideMouseMessage) - { - Common.LastPos = new Point( - MachineStuff.PrimaryScreenBounds.Left + ((MachineStuff.PrimaryScreenBounds.Right - MachineStuff.PrimaryScreenBounds.Left) / 2), - Setting.Values.HideMouse ? 4 : MachineStuff.PrimaryScreenBounds.Top + ((MachineStuff.PrimaryScreenBounds.Bottom - MachineStuff.PrimaryScreenBounds.Top) / 2)); - - if ((MachineStuff.desMachineID != MachineID && MachineStuff.desMachineID != ID.ALL) || byHideMouseMessage) - { - _ = NativeMethods.SetCursorPos(Common.LastPos.X, Common.LastPos.Y); - _ = NativeMethods.GetCursorPos(ref Common.lastPos); - Logger.LogDebug($"+++++ HideMouseCursor, byHideMouseMessage = {byHideMouseMessage}"); - } - - CustomCursor.ShowFakeMouseCursor(int.MinValue, int.MinValue); - } - - internal static string GetText(IntPtr hWnd) - { - int length = NativeMethods.GetWindowTextLength(hWnd); - StringBuilder sb = new(length + 1); - int rv = NativeMethods.GetWindowText(hWnd, sb, sb.Capacity); - Logger.LogDebug("GetWindowText returned " + rv.ToString(CultureInfo.CurrentCulture)); - return sb.ToString(); - } - - public static string GetWindowClassName(IntPtr hWnd) - { - StringBuilder buffer = new(128); - _ = NativeMethods.GetClassName(hWnd, buffer, buffer.Capacity); - return buffer.ToString(); - } - - internal static void MMSleep(double secs) - { - for (int i = 0; i < secs * 10; i++) - { - Application.DoEvents(); - Thread.Sleep(100); - } - } - - internal static void UpdateMultipleModeIconAndMenu() - { - MainForm?.UpdateMultipleModeIconAndMenu(); - } - - internal static void SendOrReceiveARandomDataBlockPerInitialIV(Stream st, bool send = true) - { - byte[] ranData = new byte[SymAlBlockSize]; - - try - { - if (send) - { - ranData = RandomNumberGenerator.GetBytes(SymAlBlockSize); - st.Write(ranData, 0, ranData.Length); - } - else - { - int toRead = ranData.Length; - int read = st.ReadEx(ranData, 0, toRead); - - if (read != toRead) - { - Logger.LogDebug("Stream has no more data after reading {0} bytes.", read); - } - } - } - catch (IOException e) - { - string log = $"{nameof(SendOrReceiveARandomDataBlockPerInitialIV)}: Exception {(send ? "writing" : "reading")} to the socket stream: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; - Logger.Log(log); - - if (e.InnerException is not (SocketException or ObjectDisposedException)) - { - throw; - } - } - } - } -} diff --git a/src/modules/MouseWithoutBorders/App/Class/IClipboardHelper.cs b/src/modules/MouseWithoutBorders/App/Class/IClipboardHelper.cs index 9a52f69529..15ac6fb8b8 100644 --- a/src/modules/MouseWithoutBorders/App/Class/IClipboardHelper.cs +++ b/src/modules/MouseWithoutBorders/App/Class/IClipboardHelper.cs @@ -18,16 +18,17 @@ using System.Threading.Tasks; using System.Windows.Forms; using Microsoft.VisualStudio.Threading; +using MouseWithoutBorders.Core; using Newtonsoft.Json; using StreamJsonRpc; #if !MM_HELPER using MouseWithoutBorders.Class; -using MouseWithoutBorders.Core; #endif using SystemClipboard = System.Windows.Forms.Clipboard; #if !MM_HELPER +using Clipboard = MouseWithoutBorders.Core.Clipboard; using Thread = MouseWithoutBorders.Core.Thread; #endif @@ -159,7 +160,7 @@ namespace MouseWithoutBorders public void SendClipboardData(ByteArrayOrString data, bool isFilePath) { - _ = Common.CheckClipboardEx(data, isFilePath); + _ = Clipboard.CheckClipboardEx(data, isFilePath); } } #endif @@ -245,11 +246,11 @@ WellKnownSidType.AuthenticatedUserSid, null); CancellationToken cancellationToken = _serverTaskCancellationSource.Token; IpcChannel<ClipboardHelper>.StartIpcServer(ChannelName + "/" + RemoteObjectName, cancellationToken); - Common.IpcChannelCreated = true; + IpcChannelHelper.IpcChannelCreated = true; } catch (Exception e) { - Common.IpcChannelCreated = false; + IpcChannelHelper.IpcChannelCreated = false; Common.ShowToolTip("Error setting up clipboard sharing, clipboard sharing will not work!", 5000, ToolTipIcon.Error); Logger.Log(e); } @@ -404,7 +405,7 @@ WellKnownSidType.AuthenticatedUserSid, null); try { - rv = Common.Retry(nameof(SystemClipboard.ContainsFileDropList), () => { return SystemClipboard.ContainsFileDropList(); }, (log) => Log(log)); + rv = IpcChannelHelper.Retry(nameof(SystemClipboard.ContainsFileDropList), () => { return SystemClipboard.ContainsFileDropList(); }, (log) => Log(log)); } catch (ExternalException e) { @@ -426,7 +427,7 @@ WellKnownSidType.AuthenticatedUserSid, null); try { - rv = Common.Retry(nameof(SystemClipboard.ContainsImage), () => { return SystemClipboard.ContainsImage(); }, (log) => Log(log)); + rv = IpcChannelHelper.Retry(nameof(SystemClipboard.ContainsImage), () => { return SystemClipboard.ContainsImage(); }, (log) => Log(log)); } catch (ExternalException e) { @@ -448,7 +449,7 @@ WellKnownSidType.AuthenticatedUserSid, null); try { - rv = Common.Retry(nameof(SystemClipboard.ContainsText), () => { return SystemClipboard.ContainsText(); }, (log) => Log(log)); + rv = IpcChannelHelper.Retry(nameof(SystemClipboard.ContainsText), () => { return SystemClipboard.ContainsText(); }, (log) => Log(log)); } catch (ExternalException e) { @@ -470,7 +471,7 @@ WellKnownSidType.AuthenticatedUserSid, null); try { - rv = Common.Retry(nameof(SystemClipboard.GetFileDropList), () => { return SystemClipboard.GetFileDropList(); }, (log) => Log(log)); + rv = IpcChannelHelper.Retry(nameof(SystemClipboard.GetFileDropList), () => { return SystemClipboard.GetFileDropList(); }, (log) => Log(log)); } catch (ExternalException e) { @@ -492,7 +493,7 @@ WellKnownSidType.AuthenticatedUserSid, null); try { - rv = Common.Retry(nameof(SystemClipboard.GetImage), () => { return SystemClipboard.GetImage(); }, (log) => Log(log)); + rv = IpcChannelHelper.Retry(nameof(SystemClipboard.GetImage), () => { return SystemClipboard.GetImage(); }, (log) => Log(log)); } catch (ExternalException e) { @@ -514,7 +515,7 @@ WellKnownSidType.AuthenticatedUserSid, null); try { - rv = Common.Retry(nameof(SystemClipboard.GetText), () => { return SystemClipboard.GetText(format); }, (log) => Log(log)); + rv = IpcChannelHelper.Retry(nameof(SystemClipboard.GetText), () => { return SystemClipboard.GetText(format); }, (log) => Log(log)); } catch (ExternalException e) { @@ -538,7 +539,7 @@ WellKnownSidType.AuthenticatedUserSid, null); { try { - _ = Common.Retry( + _ = IpcChannelHelper.Retry( nameof(SystemClipboard.SetImage), () => { @@ -567,7 +568,7 @@ WellKnownSidType.AuthenticatedUserSid, null); { try { - _ = Common.Retry( + _ = IpcChannelHelper.Retry( nameof(SystemClipboard.SetText), () => { @@ -599,44 +600,4 @@ WellKnownSidType.AuthenticatedUserSid, null); { internal const int QUIT_CMD = 0x409; } - - internal sealed partial class Common - { - internal static bool IpcChannelCreated { get; set; } - - internal static T Retry<T>(string name, Func<T> func, Action<string> log, Action preRetry = null) - { - int count = 0; - - do - { - try - { - T rv = func(); - - if (count > 0) - { - log($"Trace: {name} has been successful after {count} retry."); - } - - return rv; - } - catch (Exception) - { - count++; - - preRetry?.Invoke(); - - if (count > 10) - { - throw; - } - - Application.DoEvents(); - Thread.Sleep(200); - } - } - while (true); - } - } } diff --git a/src/modules/MouseWithoutBorders/App/Class/InputHook.cs b/src/modules/MouseWithoutBorders/App/Class/InputHook.cs index 33cbe77e89..53e815c1a0 100644 --- a/src/modules/MouseWithoutBorders/App/Class/InputHook.cs +++ b/src/modules/MouseWithoutBorders/App/Class/InputHook.cs @@ -109,7 +109,7 @@ namespace MouseWithoutBorders.Class // Install Mouse Hook mouseHookProcedure = new NativeMethods.HookProc(MouseHookProc); hMouseHook = NativeMethods.SetWindowsHookEx( - Common.WH_MOUSE_LL, + WM.WH_MOUSE_LL, mouseHookProcedure, Marshal.GetHINSTANCE( Assembly.GetExecutingAssembly().GetModules()[0]), @@ -126,7 +126,7 @@ namespace MouseWithoutBorders.Class // Install Keyboard Hook keyboardHookProcedure = new NativeMethods.HookProc(KeyboardHookProc); hKeyboardHook = NativeMethods.SetWindowsHookEx( - Common.WH_KEYBOARD_LL, + WM.WH_KEYBOARD_LL, keyboardHookProcedure, Marshal.GetHINSTANCE( Assembly.GetExecutingAssembly().GetModules()[0]), @@ -233,7 +233,7 @@ namespace MouseWithoutBorders.Class if (nCode >= 0 && MouseEvent != null) { - if (wParam == Common.WM_LBUTTONUP && SkipMouseUpCount > 0) + if (wParam == WM.WM_LBUTTONUP && SkipMouseUpCount > 0) { Logger.LogDebug($"{nameof(SkipMouseUpCount)}: {SkipMouseUpCount}."); SkipMouseUpCount--; @@ -241,7 +241,7 @@ namespace MouseWithoutBorders.Class return rv; } - if ((wParam == Common.WM_LBUTTONUP || wParam == Common.WM_LBUTTONDOWN) && SkipMouseUpDown) + if ((wParam == WM.WM_LBUTTONUP || wParam == WM.WM_LBUTTONDOWN) && SkipMouseUpDown) { rv = NativeMethods.CallNextHookEx(hMouseHook, nCode, wParam, lParam); return rv; @@ -370,7 +370,7 @@ namespace MouseWithoutBorders.Class private bool ProcessKeyEx(int vkCode, int flags, KEYBDDATA hookCallbackKeybdData) { - if ((flags & (int)Common.LLKHF.UP) == (int)Common.LLKHF.UP) + if ((flags & (int)WM.LLKHF.UP) == (int)WM.LLKHF.UP) { EasyMouseKeyDown = false; @@ -553,7 +553,7 @@ namespace MouseWithoutBorders.Class KeyboardEvent(hookCallbackKeybdData); } - hookCallbackKeybdData.dwFlags |= (int)Common.LLKHF.UP; + hookCallbackKeybdData.dwFlags |= (int)WM.LLKHF.UP; foreach (var code in codes) { @@ -579,7 +579,7 @@ namespace MouseWithoutBorders.Class { Common.ShowToolTip("Reconnecting...", 2000); Common.LastReconnectByHotKeyTime = Common.GetTick(); - Common.PleaseReopenSocket = Common.REOPEN_WHEN_HOTKEY; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_HOTKEY; return false; } @@ -632,7 +632,7 @@ namespace MouseWithoutBorders.Class { // Common.DoSomethingInUIThread(delegate() { - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); } // ); diff --git a/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs b/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs index a991c7f64f..156f69597d 100644 --- a/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs +++ b/src/modules/MouseWithoutBorders/App/Class/InputSimulation.cs @@ -112,12 +112,12 @@ namespace MouseWithoutBorders.Class uint scanCode = 0; // http://msdn.microsoft.com/en-us/library/ms644967(VS.85).aspx - if ((kd.dwFlags & (int)Common.LLKHF.UP) == (int)Common.LLKHF.UP) + if ((kd.dwFlags & (int)WM.LLKHF.UP) == (int)WM.LLKHF.UP) { dwFlags = NativeMethods.KEYEVENTF.KEYUP; } - if ((kd.dwFlags & (int)Common.LLKHF.EXTENDED) == (int)Common.LLKHF.EXTENDED) + if ((kd.dwFlags & (int)WM.LLKHF.EXTENDED) == (int)WM.LLKHF.EXTENDED) { dwFlags |= NativeMethods.KEYEVENTF.EXTENDEDKEY; } @@ -173,41 +173,44 @@ namespace MouseWithoutBorders.Class mouse_input.mi.dy = (int)dy; mouse_input.mi.mouseData = md.WheelDelta; - if (md.dwFlags != Common.WM_MOUSEMOVE) + if (md.dwFlags != WM.WM_MOUSEMOVE) { Logger.LogDebug($"InputSimulation.SendMouse: x = {md.X}, y = {md.Y}, WheelDelta = {md.WheelDelta}, dwFlags = {md.dwFlags}."); } switch (md.dwFlags) { - case Common.WM_MOUSEMOVE: + case WM.WM_MOUSEMOVE: mouse_input.mi.dwFlags |= (int)(NativeMethods.MOUSEEVENTF.MOVE | NativeMethods.MOUSEEVENTF.ABSOLUTE); break; - case Common.WM_LBUTTONDOWN: + case WM.WM_LBUTTONDOWN: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.LEFTDOWN; break; - case Common.WM_LBUTTONUP: + case WM.WM_LBUTTONUP: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.LEFTUP; break; - case Common.WM_RBUTTONDOWN: + case WM.WM_RBUTTONDOWN: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.RIGHTDOWN; break; - case Common.WM_RBUTTONUP: + case WM.WM_RBUTTONUP: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.RIGHTUP; break; - case Common.WM_MBUTTONDOWN: + case WM.WM_MBUTTONDOWN: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.MIDDLEDOWN; break; - case Common.WM_MBUTTONUP: + case WM.WM_MBUTTONUP: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.MIDDLEUP; break; - case Common.WM_MOUSEWHEEL: + case WM.WM_MOUSEWHEEL: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.WHEEL; break; - case Common.WM_XBUTTONUP: + case WM.WM_MOUSEHWHEEL: + mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.HWHEEL; + break; + case WM.WM_XBUTTONUP: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.XUP; break; - case Common.WM_XBUTTONDOWN: + case WM.WM_XBUTTONDOWN: mouse_input.mi.dwFlags |= (int)NativeMethods.MOUSEEVENTF.XDOWN; break; @@ -370,7 +373,7 @@ namespace MouseWithoutBorders.Class { eatKey = false; - if ((flags & (int)Common.LLKHF.UP) == (int)Common.LLKHF.UP) + if ((flags & (int)WM.LLKHF.UP) == (int)WM.LLKHF.UP) { switch ((VK)vkCode) { @@ -407,7 +410,7 @@ namespace MouseWithoutBorders.Class { ResetModifiersState(Setting.Values.HotKeyLockMachine); eatKey = true; - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); _ = NativeMethods.LockWorkStation(); } } @@ -439,7 +442,7 @@ namespace MouseWithoutBorders.Class { ctrlDown = altDown = false; eatKey = true; - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); } break; @@ -449,7 +452,7 @@ namespace MouseWithoutBorders.Class { winDown = false; eatKey = true; - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); uint rv = NativeMethods.LockWorkStation(); Logger.LogDebug("LockWorkStation returned " + rv.ToString(CultureInfo.CurrentCulture)); } diff --git a/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs b/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs index da6ce18920..831144f377 100644 --- a/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs +++ b/src/modules/MouseWithoutBorders/App/Class/NativeMethods.cs @@ -122,9 +122,16 @@ namespace MouseWithoutBorders.Class [DllImport("user32.dll", SetLastError = false)] internal static extern IntPtr GetDesktopWindow(); + [LibraryImport("user32.dll")] + internal static partial IntPtr GetShellWindow(); + [DllImport("user32.dll")] internal static extern IntPtr GetWindowDC(IntPtr hWnd); + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetWindowRect(IntPtr hWnd, out RECT rect); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] internal static extern int DrawText(IntPtr hDC, string lpString, int nCount, ref RECT lpRect, uint uFormat); @@ -291,6 +298,17 @@ namespace MouseWithoutBorders.Class [DllImport("user32.dll", SetLastError = true)] internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + [LibraryImport("kernel32.dll", + EntryPoint = "QueryFullProcessImageNameW", + SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool QueryFullProcessImageName( + IntPtr hProcess, QUERY_FULL_PROCESS_NAME_FLAGS dwFlags, [Out] char[] lpExeName, ref uint lpdwSize); + + [LibraryImport("shell32.dll", SetLastError = true)] + internal static partial int SHQueryUserNotificationState(out USER_NOTIFICATION_STATE state); + [StructLayout(LayoutKind.Sequential)] internal struct POINT { @@ -333,11 +351,11 @@ namespace MouseWithoutBorders.Class [DllImport("ntdll.dll")] internal static extern int NtQueryInformationProcess( - IntPtr hProcess, - int processInformationClass /* 0 */, - ref PROCESS_BASIC_INFORMATION processBasicInformation, - uint processInformationLength, - out uint returnLength); + IntPtr hProcess, + int processInformationClass /* 0 */, + ref PROCESS_BASIC_INFORMATION processBasicInformation, + uint processInformationLength, + out uint returnLength); #endif #if USE_GetSecurityDescriptorSacl @@ -538,6 +556,7 @@ namespace MouseWithoutBorders.Class XDOWN = 0x0080, XUP = 0x0100, WHEEL = 0x0800, + HWHEEL = 0x1000, VIRTUALDESK = 0x4000, ABSOLUTE = 0x8000, } @@ -632,14 +651,14 @@ namespace MouseWithoutBorders.Class { internal int LowPart; internal int HighPart; - }// end struct + } // end struct [StructLayout(LayoutKind.Sequential)] internal struct LUID_AND_ATTRIBUTES { internal LUID Luid; internal int Attributes; - }// end struct + } // end struct [StructLayout(LayoutKind.Sequential)] internal struct TOKEN_PRIVILEGES @@ -670,23 +689,23 @@ namespace MouseWithoutBorders.Class internal const int TOKEN_ADJUST_SESSIONID = 0x0100; internal const int TOKEN_ALL_ACCESS_P = STANDARD_RIGHTS_REQUIRED | - TOKEN_ASSIGN_PRIMARY | - TOKEN_DUPLICATE | - TOKEN_IMPERSONATE | - TOKEN_QUERY | - TOKEN_QUERY_SOURCE | - TOKEN_ADJUST_PRIVILEGES | - TOKEN_ADJUST_GROUPS | - TOKEN_ADJUST_DEFAULT; + TOKEN_ASSIGN_PRIMARY | + TOKEN_DUPLICATE | + TOKEN_IMPERSONATE | + TOKEN_QUERY | + TOKEN_QUERY_SOURCE | + TOKEN_ADJUST_PRIVILEGES | + TOKEN_ADJUST_GROUPS | + TOKEN_ADJUST_DEFAULT; internal const int TOKEN_ALL_ACCESS = TOKEN_ALL_ACCESS_P | TOKEN_ADJUST_SESSIONID; internal const int TOKEN_READ = STANDARD_RIGHTS_READ | TOKEN_QUERY; internal const int TOKEN_WRITE = STANDARD_RIGHTS_WRITE | - TOKEN_ADJUST_PRIVILEGES | - TOKEN_ADJUST_GROUPS | - TOKEN_ADJUST_DEFAULT; + TOKEN_ADJUST_PRIVILEGES | + TOKEN_ADJUST_GROUPS | + TOKEN_ADJUST_DEFAULT; internal const int TOKEN_EXECUTE = STANDARD_RIGHTS_EXECUTE; @@ -940,6 +959,30 @@ namespace MouseWithoutBorders.Class NameDnsDomain = 12, } + internal enum MONITOR_FROM_WINDOW_FLAGS : uint + { + DEFAULT_TO_NULL = 0x00000000, + DEFAULT_TO_PRIMARY = 0x00000001, + DEFAULT_TO_NEAREST = 0x00000002, + } + + internal enum QUERY_FULL_PROCESS_NAME_FLAGS : uint + { + DEFAULT = 0x00000000, + PROCESS_NAME_NATIVE = 0x00000001, + } + + internal enum USER_NOTIFICATION_STATE + { + NOT_PRESENT = 1, + BUSY = 2, + RUNNING_D3D_FULL_SCREEN = 3, + PRESENTATION_MODE = 4, + ACCEPTS_NOTIFICATIONS = 5, + QUIET_TIME = 6, + APP = 7, + } + [DllImport("secur32.dll", CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.I1)] internal static extern bool GetUserNameEx(int nameFormat, StringBuilder userName, ref uint userNameSize); @@ -970,7 +1013,7 @@ namespace MouseWithoutBorders.Class /// <summary> /// Use this method to figure out if your code is running on a Microsoft computer. /// </summary> - /// <returns>True if running on a Microsoft computer, otherwise false.</returns> + /// <returns>True if running on a Microsoft computer; otherwise, false.</returns> internal static bool IsRunningAtMicrosoft() { string domain = GetDNSDomain(); diff --git a/src/modules/MouseWithoutBorders/App/Class/Program.cs b/src/modules/MouseWithoutBorders/App/Class/Program.cs index 2fd8357e24..23513e1515 100644 --- a/src/modules/MouseWithoutBorders/App/Class/Program.cs +++ b/src/modules/MouseWithoutBorders/App/Class/Program.cs @@ -143,7 +143,7 @@ namespace MouseWithoutBorders.Class return; } - string myDesktop = Common.GetMyDesktop(); + string myDesktop = WinAPI.GetMyDesktop(); if (firstArg.Equals("winlogon", StringComparison.OrdinalIgnoreCase)) { @@ -235,7 +235,7 @@ namespace MouseWithoutBorders.Class _ = Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.SetCompatibleTextRenderingDefault(false); - Common.Init(); + InitAndCleanup.Init(); Core.Helper.WndProcCounter++; var formScreen = new FrmScreen(); @@ -305,8 +305,8 @@ namespace MouseWithoutBorders.Class MachineStuff.ClearComputerMatrix(); Setting.Values.MyKey = securityKey; - Common.MyKey = securityKey; - Common.MagicNumber = Common.Get24BitHash(Common.MyKey); + Encryption.MyKey = securityKey; + Encryption.MagicNumber = Encryption.Get24BitHash(Encryption.MyKey); MachineStuff.MachineMatrix = new string[MachineStuff.MAX_MACHINE] { pcName.Trim().ToUpper(CultureInfo.CurrentCulture), Common.MachineName.Trim(), string.Empty, string.Empty }; string[] machines = MachineStuff.MachineMatrix; @@ -314,7 +314,7 @@ namespace MouseWithoutBorders.Class MachineStuff.UpdateMachinePoolStringSetting(); SocketStuff.InvalidKeyFound = false; - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; Common.ReopenSockets(true); MachineStuff.SendMachineMatrix(); @@ -328,8 +328,8 @@ namespace MouseWithoutBorders.Class Setting.Values.EasyMouse = (int)EasyMouseOption.Enable; MachineStuff.ClearComputerMatrix(); - Setting.Values.MyKey = Common.MyKey = Common.CreateRandomKey(); - Common.GeneratedKey = true; + Setting.Values.MyKey = Encryption.MyKey = Encryption.CreateRandomKey(); + Encryption.GeneratedKey = true; Setting.Values.PauseInstantSaving = false; Setting.Values.SaveSettings(); @@ -340,7 +340,7 @@ namespace MouseWithoutBorders.Class public void Reconnect() { SocketStuff.InvalidKeyFound = false; - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; Common.ReopenSockets(true); for (int i = 0; i < 10; i++) @@ -397,7 +397,7 @@ namespace MouseWithoutBorders.Class using var asyncFlowControl = ExecutionContext.SuppressFlow(); Common.InputCallbackThreadID = Thread.CurrentThread.ManagedThreadId; - while (!Common.InitDone) + while (!InitAndCleanup.InitDone) { Thread.Sleep(100); } diff --git a/src/modules/MouseWithoutBorders/App/Class/Setting.cs b/src/modules/MouseWithoutBorders/App/Class/Setting.cs index 72365fe478..7d440a6125 100644 --- a/src/modules/MouseWithoutBorders/App/Class/Setting.cs +++ b/src/modules/MouseWithoutBorders/App/Class/Setting.cs @@ -109,16 +109,16 @@ namespace MouseWithoutBorders.Class var shouldReopenSockets = false; - if (Common.MyKey != _properties.SecurityKey.Value) + if (Encryption.MyKey != _properties.SecurityKey.Value) { - Common.MyKey = _properties.SecurityKey.Value; + Encryption.MyKey = _properties.SecurityKey.Value; shouldReopenSockets = true; } if (shouldReopenSockets) { SocketStuff.InvalidKeyFound = false; - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; Common.ReopenSockets(true); } @@ -192,7 +192,7 @@ namespace MouseWithoutBorders.Class internal Settings() { - _settingsUtils = new SettingsUtils(); + _settingsUtils = SettingsUtils.Default; _watcher = SettingsHelper.GetFileWatcher("MouseWithoutBorders", "settings.json", () => { @@ -414,6 +414,44 @@ namespace MouseWithoutBorders.Class } } + internal bool DisableEasyMouseWhenForegroundWindowIsFullscreen + { + get + { + lock (_loadingSettingsLock) + { + return _properties.DisableEasyMouseWhenForegroundWindowIsFullscreen; + } + } + + set + { + lock (_loadingSettingsLock) + { + _properties.DisableEasyMouseWhenForegroundWindowIsFullscreen = value; + } + } + } + + internal HashSet<string> EasyMouseFullscreenSwitchBlockExcludedApps + { + get + { + lock (_loadingSettingsLock) + { + return _properties.EasyMouseFullscreenSwitchBlockExcludedApps.Value; + } + } + + set + { + lock (_loadingSettingsLock) + { + _properties.EasyMouseFullscreenSwitchBlockExcludedApps.Value = value; + } + } + } + internal string Enc(string st, bool dec, DataProtectionScope protectionScope) { if (st == null || st.Length < 1) @@ -451,7 +489,7 @@ namespace MouseWithoutBorders.Class } else { - string randomKey = Common.CreateDefaultKey(); + string randomKey = Encryption.CreateDefaultKey(); _properties.SecurityKey.Value = randomKey; return randomKey; @@ -1017,8 +1055,13 @@ namespace MouseWithoutBorders.Class if (machineId == 0) { - _properties.MachineID.Value = Common.Ran.Next(); - machineId = _properties.MachineID.Value; + var newMachineId = Encryption.Ran.Next(); + _properties.MachineID.Value = newMachineId; + machineId = newMachineId; + if (!PauseInstantSaving) + { + SaveSettings(); + } } } @@ -1030,6 +1073,11 @@ namespace MouseWithoutBorders.Class lock (_loadingSettingsLock) { _properties.MachineID.Value = value; + machineId = value; + if (!PauseInstantSaving) + { + SaveSettings(); + } } } } diff --git a/src/modules/MouseWithoutBorders/App/Class/SocketStuff.cs b/src/modules/MouseWithoutBorders/App/Class/SocketStuff.cs index 8796f61dfb..575c9582df 100644 --- a/src/modules/MouseWithoutBorders/App/Class/SocketStuff.cs +++ b/src/modules/MouseWithoutBorders/App/Class/SocketStuff.cs @@ -29,6 +29,7 @@ using MouseWithoutBorders.Core; // </history> using MouseWithoutBorders.Exceptions; +using Clipboard = MouseWithoutBorders.Core.Clipboard; using Thread = MouseWithoutBorders.Core.Thread; [module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.SocketStuff.#SendData(System.Byte[])", Justification = "Dotnet port with style preservation")] @@ -100,7 +101,7 @@ namespace MouseWithoutBorders.Class { if (encryptedStream == null && BackingSocket.Connected) { - encryptedStream = Common.GetEncryptedStream(new NetworkStream(BackingSocket)); + encryptedStream = Encryption.GetEncryptedStream(new NetworkStream(BackingSocket)); Common.SendOrReceiveARandomDataBlockPerInitialIV(encryptedStream); } @@ -114,7 +115,7 @@ namespace MouseWithoutBorders.Class { if (decryptedStream == null && BackingSocket.Connected) { - decryptedStream = Common.GetDecryptedStream(new NetworkStream(BackingSocket)); + decryptedStream = Encryption.GetDecryptedStream(new NetworkStream(BackingSocket)); Common.SendOrReceiveARandomDataBlockPerInitialIV(decryptedStream, false); } @@ -180,7 +181,7 @@ namespace MouseWithoutBorders.Class Logger.LogDebug("SocketStuff started."); bASE_PORT = port; - Common.Ran = new Random(); + Encryption.Ran = new Random(); Logger.LogDebug("Validating session..."); @@ -220,11 +221,11 @@ namespace MouseWithoutBorders.Class if (Setting.Values.IsMyKeyRandom) { - Setting.Values.MyKey = Common.MyKey; + Setting.Values.MyKey = Encryption.MyKey; } - Common.MagicNumber = Common.Get24BitHash(Common.MyKey); - Common.PackageID = Setting.Values.PackageID; + Encryption.MagicNumber = Encryption.Get24BitHash(Encryption.MyKey); + Package.PackageID = Setting.Values.PackageID; TcpPort = bASE_PORT; @@ -241,7 +242,7 @@ namespace MouseWithoutBorders.Class Logger.TelemetryLogTrace($"{nameof(SocketStuff)}: {e.Message}", SeverityLevel.Warning); } - Common.GetScreenConfig(); + WinAPI.GetScreenConfig(); if (firstRun && Common.RunOnScrSaverDesktop) { @@ -281,7 +282,7 @@ namespace MouseWithoutBorders.Class * */ Common.GetMachineName(); // IPs might have been changed - Common.UpdateMachineTimeAndID(); + InitAndCleanup.UpdateMachineTimeAndID(); Logger.LogDebug("Creating sockets..."); @@ -304,11 +305,11 @@ namespace MouseWithoutBorders.Class sleepSecs = 10; // It is reasonable to give a try on restarting MwB processes in other sessions. - if (restartCount++ < 5 && Common.IsMyDesktopActive() && !Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) + if (restartCount++ < 5 && WinAPI.IsMyDesktopActive() && !Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) { Logger.TelemetryLogTrace("Restarting the service dues to WSAEADDRINUSE.", SeverityLevel.Warning); Program.StartService(); - Common.PleaseReopenSocket = Common.REOPEN_WHEN_WSAECONNRESET; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_WSAECONNRESET; } break; @@ -360,7 +361,7 @@ namespace MouseWithoutBorders.Class { Setting.Values.LastX = Common.LastX; Setting.Values.LastY = Common.LastY; - Setting.Values.PackageID = Common.PackageID; + Setting.Values.PackageID = Package.PackageID; // Common.Log("Saving IP: " + Setting.Values.DesMachineID.ToString(CultureInfo.CurrentCulture)); Setting.Values.DesMachineID = (uint)Common.DesMachineID; @@ -504,10 +505,10 @@ namespace MouseWithoutBorders.Class throw new ExpectedSocketException(log); } - bytes[3] = (byte)((Common.MagicNumber >> 24) & 0xFF); - bytes[2] = (byte)((Common.MagicNumber >> 16) & 0xFF); + bytes[3] = (byte)((Encryption.MagicNumber >> 24) & 0xFF); + bytes[2] = (byte)((Encryption.MagicNumber >> 16) & 0xFF); bytes[1] = 0; - for (int i = 2; i < Common.PACKAGE_SIZE; i++) + for (int i = 2; i < Package.PACKAGE_SIZE; i++) { bytes[1] = (byte)(bytes[1] + bytes[i]); } @@ -534,13 +535,13 @@ namespace MouseWithoutBorders.Class magic = (buf[3] << 24) + (buf[2] << 16); - if (magic != (Common.MagicNumber & 0xFFFF0000)) + if (magic != (Encryption.MagicNumber & 0xFFFF0000)) { Logger.Log("Magic number invalid!"); buf[0] = (byte)PackageType.Invalid; } - for (int i = 2; i < Common.PACKAGE_SIZE; i++) + for (int i = 2; i < Package.PACKAGE_SIZE; i++) { checksum = (byte)(checksum + buf[i]); } @@ -556,7 +557,7 @@ namespace MouseWithoutBorders.Class internal static DATA TcpReceiveData(TcpSk tcp, out int bytesReceived) { - byte[] buf = new byte[Common.PACKAGE_SIZE_EX]; + byte[] buf = new byte[Package.PACKAGE_SIZE_EX]; Stream decryptedStream = tcp.DecryptedStream; if (tcp.BackingSocket == null || !tcp.BackingSocket.Connected || decryptedStream == null) @@ -570,9 +571,9 @@ namespace MouseWithoutBorders.Class try { - bytesReceived = decryptedStream.ReadEx(buf, 0, Common.PACKAGE_SIZE); + bytesReceived = decryptedStream.ReadEx(buf, 0, Package.PACKAGE_SIZE); - if (bytesReceived != Common.PACKAGE_SIZE) + if (bytesReceived != Package.PACKAGE_SIZE) { buf[0] = bytesReceived == 0 ? (byte)PackageType.Error : (byte)PackageType.Invalid; } @@ -585,9 +586,9 @@ namespace MouseWithoutBorders.Class if (package.IsBigPackage) { - bytesReceived = decryptedStream.ReadEx(buf, Common.PACKAGE_SIZE, Common.PACKAGE_SIZE); + bytesReceived = decryptedStream.ReadEx(buf, Package.PACKAGE_SIZE, Package.PACKAGE_SIZE); - if (bytesReceived != Common.PACKAGE_SIZE) + if (bytesReceived != Package.PACKAGE_SIZE) { buf[0] = bytesReceived == 0 ? (byte)PackageType.Error : (byte)PackageType.Invalid; } @@ -613,28 +614,28 @@ namespace MouseWithoutBorders.Class switch (type) { case PackageType.Keyboard: - Common.PackageSent.Keyboard++; + Package.PackageSent.Keyboard++; break; case PackageType.Mouse: - Common.PackageSent.Mouse++; + Package.PackageSent.Mouse++; break; case PackageType.Heartbeat: case PackageType.Heartbeat_ex: - Common.PackageSent.Heartbeat++; + Package.PackageSent.Heartbeat++; break; case PackageType.Hello: - Common.PackageSent.Hello++; + Package.PackageSent.Hello++; break; case PackageType.ByeBye: - Common.PackageSent.ByeBye++; + Package.PackageSent.ByeBye++; break; case PackageType.Matrix: - Common.PackageSent.Matrix++; + Package.PackageSent.Matrix++; break; default: @@ -642,11 +643,11 @@ namespace MouseWithoutBorders.Class switch (subtype) { case (byte)PackageType.ClipboardText: - Common.PackageSent.ClipboardText++; + Package.PackageSent.ClipboardText++; break; case (byte)PackageType.ClipboardImage: - Common.PackageSent.ClipboardImage++; + Package.PackageSent.ClipboardImage++; break; default: @@ -1248,7 +1249,7 @@ namespace MouseWithoutBorders.Class // WSAECONNRESET if (e is ExpectedSocketException se && se.ShouldReconnect) { - Common.PleaseReopenSocket = Common.REOPEN_WHEN_WSAECONNRESET; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_WSAECONNRESET; Logger.Log($"MainTCPRoutine: {nameof(FlagReopenSocketIfNeeded)}"); } } @@ -1265,7 +1266,7 @@ namespace MouseWithoutBorders.Class string strIP = string.Empty; ID remoteID = ID.NONE; - byte[] buf = RandomNumberGenerator.GetBytes(Common.PACKAGE_SIZE_EX); + byte[] buf = RandomNumberGenerator.GetBytes(Package.PACKAGE_SIZE_EX); d = new DATA(buf); TcpSk currentTcp = tcp; @@ -1279,8 +1280,8 @@ namespace MouseWithoutBorders.Class try { - currentSocket.SendBufferSize = Common.PACKAGE_SIZE * 10000; - currentSocket.ReceiveBufferSize = Common.PACKAGE_SIZE * 10000; + currentSocket.SendBufferSize = Package.PACKAGE_SIZE * 10000; + currentSocket.ReceiveBufferSize = Package.PACKAGE_SIZE * 10000; currentSocket.NoDelay = true; // This is very interesting to know:( currentSocket.SendTimeout = 500; d.MachineName = Common.MachineName; @@ -1306,7 +1307,7 @@ namespace MouseWithoutBorders.Class } catch (ObjectDisposedException e) { - Common.PleaseReopenSocket = Common.REOPEN_WHEN_WSAECONNRESET; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_WSAECONNRESET; UpdateTcpSockets(currentTcp, SocketStatus.ForceClosed); currentSocket.Close(); Logger.Log($"{nameof(MainTCPRoutine)}: The socket could have been closed/disposed by other threads: {e.Message}"); @@ -1353,10 +1354,10 @@ namespace MouseWithoutBorders.Class * In this case, we should give ONE try to reconnect. */ - if (Common.ReopenSocketDueToReadError) + if (InitAndCleanup.ReopenSocketDueToReadError) { - Common.PleaseReopenSocket = Common.REOPEN_WHEN_WSAECONNRESET; - Common.ReopenSocketDueToReadError = false; + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_WSAECONNRESET; + InitAndCleanup.ReopenSocketDueToReadError = false; } break; @@ -1641,7 +1642,7 @@ namespace MouseWithoutBorders.Class bool clientPushData = true; ClipboardPostAction postAction = ClipboardPostAction.Other; - bool handShaken = Common.ShakeHand(ref remoteEndPoint, s, out Stream enStream, out Stream deStream, ref clientPushData, ref postAction); + bool handShaken = Clipboard.ShakeHand(ref remoteEndPoint, s, out Stream enStream, out Stream deStream, ref clientPushData, ref postAction); if (!handShaken) { @@ -1656,7 +1657,7 @@ namespace MouseWithoutBorders.Class if (clientPushData) { - Common.ReceiveAndProcessClipboardData(remoteEndPoint, s, enStream, deStream, $"{postAction}"); + Clipboard.ReceiveAndProcessClipboardData(remoteEndPoint, s, enStream, deStream, $"{postAction}"); } else { @@ -1680,23 +1681,23 @@ namespace MouseWithoutBorders.Class const int CLOSE_TIMEOUT = 10; byte[] header = new byte[1024]; string headerString = string.Empty; - if (Common.LastDragDropFile != null) + if (Clipboard.LastDragDropFile != null) { string fileName = null; if (!Launch.ImpersonateLoggedOnUserAndDoSomething(() => { - if (!File.Exists(Common.LastDragDropFile)) + if (!File.Exists(Clipboard.LastDragDropFile)) { - headerString = Directory.Exists(Common.LastDragDropFile) - ? $"{0}*{Common.LastDragDropFile} - Folder is not supported, zip it first!" - : Common.LastDragDropFile.Contains("- File too big") - ? $"{0}*{Common.LastDragDropFile}" - : $"{0}*{Common.LastDragDropFile} not found!"; + headerString = Directory.Exists(Clipboard.LastDragDropFile) + ? $"{0}*{Clipboard.LastDragDropFile} - Folder is not supported, zip it first!" + : Clipboard.LastDragDropFile.Contains("- File too big") + ? $"{0}*{Clipboard.LastDragDropFile}" + : $"{0}*{Clipboard.LastDragDropFile} not found!"; } else { - fileName = Common.LastDragDropFile; + fileName = Clipboard.LastDragDropFile; headerString = $"{new FileInfo(fileName).Length}*{fileName}"; } })) @@ -1739,11 +1740,11 @@ namespace MouseWithoutBorders.Class Logger.Log(log); } } - else if (!Common.IsClipboardDataImage && Common.LastClipboardData != null) + else if (!Clipboard.IsClipboardDataImage && Clipboard.LastClipboardData != null) { try { - byte[] data = Common.LastClipboardData; + byte[] data = Clipboard.LastClipboardData; headerString = $"{data.Length}*{"text"}"; Common.GetBytesU(headerString).CopyTo(header, 0); @@ -1773,9 +1774,9 @@ namespace MouseWithoutBorders.Class Logger.Log(log); } } - else if (Common.LastClipboardData != null && Common.LastClipboardData.Length > 0) + else if (Clipboard.LastClipboardData != null && Clipboard.LastClipboardData.Length > 0) { - byte[] data = Common.LastClipboardData; + byte[] data = Clipboard.LastClipboardData; headerString = $"{data.Length}*{"image"}"; Common.GetBytesU(headerString).CopyTo(header, 0); @@ -1828,7 +1829,7 @@ namespace MouseWithoutBorders.Class } while (rv > 0); - if ((rv = Common.PACKAGE_SIZE - (sentCount % Common.PACKAGE_SIZE)) > 0) + if ((rv = Package.PACKAGE_SIZE - (sentCount % Package.PACKAGE_SIZE)) > 0) { Array.Clear(buf, 0, buf.Length); ecStream.Write(buf, 0, rv); @@ -1899,7 +1900,7 @@ namespace MouseWithoutBorders.Class } while (rv > 0); - if ((rv = sentCount % Common.PACKAGE_SIZE) > 0) + if ((rv = sentCount % Package.PACKAGE_SIZE) > 0) { Array.Clear(buf, 0, buf.Length); ecStream.Write(buf, 0, rv); @@ -1983,9 +1984,9 @@ namespace MouseWithoutBorders.Class if (tcp.MachineId == Setting.Values.MachineId) { tcp = null; - Setting.Values.MachineId = Common.Ran.Next(); - Common.UpdateMachineTimeAndID(); - Common.PleaseReopenSocket = Common.REOPEN_WHEN_HOTKEY; + Setting.Values.MachineId = Encryption.Ran.Next(); + InitAndCleanup.UpdateMachineTimeAndID(); + InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_HOTKEY; Logger.TelemetryLogTrace("MachineID conflict.", SeverityLevel.Information); } diff --git a/src/modules/MouseWithoutBorders/App/Class/TcpServer.cs b/src/modules/MouseWithoutBorders/App/Class/TcpServer.cs index f915ae94e0..cc98381483 100644 --- a/src/modules/MouseWithoutBorders/App/Class/TcpServer.cs +++ b/src/modules/MouseWithoutBorders/App/Class/TcpServer.cs @@ -70,7 +70,7 @@ namespace MouseWithoutBorders.Class continue; } - if (!Common.IsMyDesktopActive()) + if (!WinAPI.IsMyDesktopActive()) { // We can just throw the SocketException but to avoid a redundant log entry: throw new ExpectedSocketException($"{nameof(StartServer)}: The desktop is no longer active."); diff --git a/src/modules/MouseWithoutBorders/App/Core/Clipboard.cs b/src/modules/MouseWithoutBorders/App/Core/Clipboard.cs new file mode 100644 index 0000000000..db16ac8b4d --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/Clipboard.cs @@ -0,0 +1,1155 @@ +// 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.Specialized; +using System.Diagnostics; +using System.Drawing; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; + +using Microsoft.PowerToys.Telemetry; +using MouseWithoutBorders.Class; +using MouseWithoutBorders.Exceptions; + +using SystemClipboard = System.Windows.Forms.Clipboard; + +// <summary> +// Clipboard related routines. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal static class Clipboard +{ + private static readonly char[] Comma = new char[] { ',' }; + private static readonly char[] Star = new char[] { '*' }; + private static readonly char[] NullSeparator = new char[] { '\0' }; + + internal const uint BIG_CLIPBOARD_DATA_TIMEOUT = 30000; + private const uint MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP = 1024 * 1024; // 1MB + private const uint MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT = 100 * 1024 * 1024; // 100MB + private const int TEXT_HEADER_SIZE = 12; + private const int DATA_SIZE = 48; + private const string TEXT_TYPE_SEP = "{4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F}"; + private static long lastClipboardEventTime; + private static string lastMachineWithClipboardData; + private static string lastDragDropFile; +#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter + internal static long clipboardCopiedTime; +#pragma warning restore SA1307 + + internal static ID LastIDWithClipboardData { get; set; } + + internal static string LastDragDropFile + { + get => Clipboard.lastDragDropFile; + set => Clipboard.lastDragDropFile = value; + } + + internal static string LastMachineWithClipboardData + { + get => Clipboard.lastMachineWithClipboardData; + set => Clipboard.lastMachineWithClipboardData = value; + } + + private static long LastClipboardEventTime + { + get => Clipboard.lastClipboardEventTime; + set => Clipboard.lastClipboardEventTime = value; + } + + private static IntPtr NextClipboardViewer { get; set; } + + internal static bool IsClipboardDataImage { get; private set; } + + internal static byte[] LastClipboardData { get; private set; } + + private static object lastClipboardObject = string.Empty; + + internal static bool HasSwitchedMachineSinceLastCopy { get; set; } + + internal static bool CheckClipboardEx(ByteArrayOrString data, bool isFilePath) + { + Logger.LogDebug($"{nameof(CheckClipboardEx)}: ShareClipboard = {Setting.Values.ShareClipboard}, TransferFile = {Setting.Values.TransferFile}, data = {data}."); + Logger.LogDebug($"{nameof(CheckClipboardEx)}: {nameof(Setting.Values.OneWayClipboardMode)} = {Setting.Values.OneWayClipboardMode}."); + + if (!Setting.Values.ShareClipboard) + { + return false; + } + + if (Common.RunWithNoAdminRight && Setting.Values.OneWayClipboardMode) + { + return false; + } + + if (Common.GetTick() - LastClipboardEventTime < 1000) + { + Logger.LogDebug("GetTick() - lastClipboardEventTime < 1000"); + LastClipboardEventTime = Common.GetTick(); + return false; + } + + LastClipboardEventTime = Common.GetTick(); + + try + { + IsClipboardDataImage = false; + LastClipboardData = null; + LastDragDropFile = null; + GC.Collect(); + + string stringData = null; + byte[] byteData = null; + + if (data.IsByteArray) + { + byteData = data.GetByteArray(); + } + else + { + stringData = data.GetString(); + } + + if (stringData != null) + { + if (!HasSwitchedMachineSinceLastCopy) + { + if (lastClipboardObject is string lastStringData && lastStringData.Equals(stringData, StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("CheckClipboardEx: Same string data."); + return false; + } + } + + HasSwitchedMachineSinceLastCopy = false; + + if (isFilePath) + { + Logger.LogDebug("Clipboard contains FileDropList"); + + if (!Setting.Values.TransferFile) + { + Logger.LogDebug("TransferFile option is unchecked."); + return false; + } + + string filePath = stringData; + + _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => + { + if (File.Exists(filePath) || Directory.Exists(filePath)) + { + if (File.Exists(filePath) && new FileInfo(filePath).Length <= MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT) + { + Logger.LogDebug("Clipboard contains: " + filePath); + LastDragDropFile = filePath; + Common.SendClipboardBeat(); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_BIG_CLIPBOARD, -1, Common.ICON_BIG_CLIPBOARD, -1 }); + } + else + { + if (Directory.Exists(filePath)) + { + Logger.LogDebug("Clipboard contains a directory: " + filePath); + LastDragDropFile = filePath; + Common.SendClipboardBeat(); + } + else + { + LastDragDropFile = filePath + " - File too big (greater than 100MB), please drag and drop the file instead!"; + Common.SendClipboardBeat(); + Logger.Log("Clipboard: File too big: " + filePath); + } + + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_ERROR, -1, Common.ICON_ERROR, -1 }); + } + } + else + { + Logger.Log("CheckClipboardEx: File not found: " + filePath); + } + }); + } + else + { + byte[] texts = Common.GetBytesU(stringData); + + using MemoryStream ms = new(); + using (DeflateStream s = new(ms, CompressionMode.Compress, true)) + { + s.Write(texts, 0, texts.Length); + } + + Logger.LogDebug("Plain/Zip = " + texts.Length.ToString(CultureInfo.CurrentCulture) + "/" + + ms.Length.ToString(CultureInfo.CurrentCulture)); + + LastClipboardData = ms.GetBuffer(); + } + } + else if (byteData != null) + { + if (!HasSwitchedMachineSinceLastCopy) + { + if (lastClipboardObject is byte[] lastByteData && Enumerable.SequenceEqual(lastByteData, byteData)) + { + Logger.LogDebug("CheckClipboardEx: Same byte[] data."); + return false; + } + } + + HasSwitchedMachineSinceLastCopy = false; + + Logger.LogDebug("Clipboard contains image"); + IsClipboardDataImage = true; + LastClipboardData = byteData; + } + else + { + Logger.LogDebug("*** Clipboard contains something else!"); + return false; + } + + lastClipboardObject = data; + + if (LastClipboardData != null && LastClipboardData.Length > 0) + { + if (LastClipboardData.Length > MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP) + { + Common.SendClipboardBeat(); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_BIG_CLIPBOARD, -1, Common.ICON_BIG_CLIPBOARD, -1 }); + } + else + { + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_SMALL_CLIPBOARD, -1, -1, -1 }); + SendClipboardDataUsingTCP(LastClipboardData, IsClipboardDataImage); + } + + return true; + } + } + catch (Exception e) + { + Logger.Log(e); + } + + return false; + } + + private static void SendClipboardDataUsingTCP(byte[] bytes, bool image) + { + if (Common.Sk == null) + { + return; + } + + new Task(() => + { + // SuppressFlow fixes an issue on service mode, where the helper process can't get enough permissions to be started again. + // More details can be found on: https://github.com/microsoft/PowerToys/pull/36892 + using var asyncFlowControl = ExecutionContext.SuppressFlow(); + + System.Threading.Thread thread = Thread.CurrentThread; + thread.Name = $"{nameof(SendClipboardDataUsingTCP)}.{thread.ManagedThreadId}"; + Thread.UpdateThreads(thread); + int l = bytes.Length; + int index = 0; + int len; + DATA package = new(); + byte[] buf = new byte[Package.PACKAGE_SIZE_EX]; + int dataStart = Package.PACKAGE_SIZE_EX - DATA_SIZE; + + while (true) + { + if ((index + DATA_SIZE) > l) + { + len = l - index; + Array.Clear(buf, 0, Package.PACKAGE_SIZE_EX); + } + else + { + len = DATA_SIZE; + } + + Array.Copy(bytes, index, buf, dataStart, len); + package.Bytes = buf; + + package.Type = image ? PackageType.ClipboardImage : PackageType.ClipboardText; + package.Des = ID.ALL; + Common.SkSend(package, (uint)Common.MachineID, false); + + index += DATA_SIZE; + if (index >= l) + { + break; + } + } + + package.Type = PackageType.ClipboardDataEnd; + package.Des = ID.ALL; + Common.SkSend(package, (uint)Common.MachineID, false); + }).Start(); + } + + internal static void ReceiveClipboardDataUsingTCP(DATA data, bool image, TcpSk tcp) + { + try + { + if (Common.Sk == null || Common.RunOnLogonDesktop || Common.RunOnScrSaverDesktop) + { + return; + } + + MemoryStream m = new(); + int dataStart = Package.PACKAGE_SIZE_EX - DATA_SIZE; + m.Write(data.Bytes, dataStart, DATA_SIZE); + int unexpectedCount = 0; + + bool done = false; + do + { + data = SocketStuff.TcpReceiveData(tcp, out int err); + + switch (data.Type) + { + case PackageType.ClipboardImage: + case PackageType.ClipboardText: + m.Write(data.Bytes, dataStart, DATA_SIZE); + break; + + case PackageType.ClipboardDataEnd: + done = true; + break; + + default: + Receiver.ProcessPackage(data, tcp); + if (++unexpectedCount > 100) + { + Logger.Log("ReceiveClipboardDataUsingTCP: unexpectedCount > 100!"); + done = true; + } + + break; + } + } + while (!done); + + LastClipboardEventTime = Common.GetTick(); + + if (image) + { + Image im = Image.FromStream(m); + Clipboard.SetImage(im); + LastClipboardEventTime = Common.GetTick(); + } + else + { + Clipboard.SetClipboardData(m.GetBuffer()); + LastClipboardEventTime = Common.GetTick(); + } + + m.Dispose(); + + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_SMALL_CLIPBOARD, -1, Common.ICON_SMALL_CLIPBOARD, -1 }); + } + catch (Exception e) + { + Logger.Log("ReceiveClipboardDataUsingTCP: " + e.Message); + } + } + + private static readonly Lock ClipboardThreadOldLock = new(); + private static System.Threading.Thread clipboardThreadOld; + + internal static void GetRemoteClipboard(string postAction) + { + if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) + { + if (Clipboard.LastMachineWithClipboardData == null || + Clipboard.LastMachineWithClipboardData.Length < 1) + { + return; + } + + new Task(() => + { + // SuppressFlow fixes an issue on service mode, where the helper process can't get enough permissions to be started again. + // More details can be found on: https://github.com/microsoft/PowerToys/pull/36892 + using var asyncFlowControl = ExecutionContext.SuppressFlow(); + + System.Threading.Thread thread = Thread.CurrentThread; + thread.Name = $"{nameof(ConnectAndGetData)}.{thread.ManagedThreadId}"; + Thread.UpdateThreads(thread); + ConnectAndGetData(postAction); + }).Start(); + } + } + + private static Stream m; + + private static void ConnectAndGetData(object postAction) + { + if (Common.Sk == null) + { + Logger.Log("ConnectAndGetData: Sk == null!"); + return; + } + + string remoteMachine; + TcpClient clipboardTcpClient = null; + string postAct = (string)postAction; + + Logger.LogDebug("ConnectAndGetData.postAction: " + postAct); + + ClipboardPostAction clipboardPostAct = postAct.Contains("mspaint,") ? ClipboardPostAction.Mspaint + : postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase) ? ClipboardPostAction.Desktop + : ClipboardPostAction.Other; + + try + { + remoteMachine = postAct.Contains("mspaint,") ? postAct.Split(Comma)[1] : Clipboard.LastMachineWithClipboardData; + + remoteMachine = remoteMachine.Trim(); + + if (!Common.IsConnectedByAClientSocketTo(remoteMachine)) + { + Logger.Log($"No potential inbound connection from {Common.MachineName} to {remoteMachine}, ask for a push back instead."); + ID machineId = MachineStuff.MachinePool.ResolveID(remoteMachine); + + if (machineId != ID.NONE) + { + Common.SkSend( + new DATA() + { + Type = PackageType.ClipboardAsk, + Des = machineId, + MachineName = Common.MachineName, + PostAction = clipboardPostAct, + }, + null, + false); + } + else + { + Logger.Log($"Unable to resolve {remoteMachine} to its long IP."); + } + + return; + } + + Common.ShowToolTip("Connecting to " + remoteMachine, 2000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); + + clipboardTcpClient = ConnectToRemoteClipboardSocket(remoteMachine); + } + catch (ThreadAbortException) + { + Logger.Log("The current thread is being aborted (1)."); + if (clipboardTcpClient != null && clipboardTcpClient.Connected) + { + clipboardTcpClient.Client.Close(); + } + + return; + } + catch (Exception e) + { + Logger.Log(e); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, Common.ICON_BIG_CLIPBOARD, -1, + }); + Common.ShowToolTip(e.Message, 1000, ToolTipIcon.Warning, Setting.Values.ShowClipNetStatus); + return; + } + + bool clientPushData = false; + + if (!ShakeHand(ref remoteMachine, clipboardTcpClient.Client, out Stream enStream, out Stream deStream, ref clientPushData, ref clipboardPostAct)) + { + return; + } + + ReceiveAndProcessClipboardData(remoteMachine, clipboardTcpClient.Client, enStream, deStream, postAct); + } + + internal static void ReceiveAndProcessClipboardData(string remoteMachine, Socket s, Stream enStream, Stream deStream, string postAct) + { + lock (ClipboardThreadOldLock) + { + // Do not enable two connections at the same time. + if (clipboardThreadOld != null && clipboardThreadOld.ThreadState != System.Threading.ThreadState.AbortRequested + && clipboardThreadOld.ThreadState != System.Threading.ThreadState.Aborted && clipboardThreadOld.IsAlive + && clipboardThreadOld.ManagedThreadId != Thread.CurrentThread.ManagedThreadId) + { + if (clipboardThreadOld.Join(3000)) + { + if (m != null) + { + m.Flush(); + m.Close(); + m = null; + } + } + } + + clipboardThreadOld = Thread.CurrentThread; + } + + try + { + byte[] header = new byte[1024]; + byte[] buf = new byte[Common.NETWORK_STREAM_BUF_SIZE]; + string fileName = null; + string tempFile = "data", savingFolder = string.Empty; + Common.ToggleIconsIndex = 0; + int rv; + long receivedCount = 0; + + if ((rv = deStream.ReadEx(header, 0, header.Length)) < header.Length) + { + Logger.Log("Reading header failed: " + rv.ToString(CultureInfo.CurrentCulture)); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, -1, -1, + }); + return; + } + + fileName = Common.GetStringU(header).Replace("\0", string.Empty); + Logger.LogDebug("Header: " + fileName); + string[] headers = fileName.Split(Star); + + if (headers.Length < 2 || !long.TryParse(headers[0], out long dataSize)) + { + Logger.Log(string.Format( + CultureInfo.CurrentCulture, + "Reading header failed: {0}:{1}", + headers.Length, + fileName)); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, -1, -1, + }); + return; + } + + fileName = headers[1]; + + Logger.LogDebug(string.Format( + CultureInfo.CurrentCulture, + "Receiving {0}:{1} from {2}...", + Path.GetFileName(fileName), + dataSize, + remoteMachine)); + Common.ShowToolTip( + string.Format( + CultureInfo.CurrentCulture, + "Receiving {0} from {1}...", + Path.GetFileName(fileName), + remoteMachine), + 5000, + ToolTipIcon.Info, + Setting.Values.ShowClipNetStatus); + if (fileName.StartsWith("image", StringComparison.CurrentCultureIgnoreCase) || + fileName.StartsWith("text", StringComparison.CurrentCultureIgnoreCase)) + { + m = new MemoryStream(); + } + else + { + if (postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase)) + { + _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => + { + savingFolder = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + "\\MouseWithoutBorders\\"; + + if (!Directory.Exists(savingFolder)) + { + _ = Directory.CreateDirectory(savingFolder); + } + }); + + tempFile = savingFolder + Path.GetFileName(fileName); + m = new FileStream(tempFile, FileMode.Create); + } + else if (postAct.Contains("mspaint")) + { + tempFile = Common.GetMyStorageDir() + @"ScreenCapture-" + + remoteMachine + ".png"; + m = new FileStream(tempFile, FileMode.Create); + } + else + { + tempFile = Common.GetMyStorageDir(); + tempFile += Path.GetFileName(fileName); + m = new FileStream(tempFile, FileMode.Create); + } + + Logger.Log("==> " + tempFile); + } + + Common.ShowToolTip( + string.Format( + CultureInfo.CurrentCulture, + "Receiving {0} from {1}...", + Path.GetFileName(fileName), + remoteMachine), + 5000, + ToolTipIcon.Info, + Setting.Values.ShowClipNetStatus); + + do + { + rv = deStream.ReadEx(buf, 0, buf.Length); + + if (rv > 0) + { + receivedCount += rv; + + if (receivedCount > dataSize) + { + rv -= (int)(receivedCount - dataSize); + } + + m.Write(buf, 0, rv); + } + + if (Common.ToggleIcons == null) + { + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_SMALL_CLIPBOARD, + -1, Common.ICON_SMALL_CLIPBOARD, -1, + }); + } + + string text = string.Format(CultureInfo.CurrentCulture, "{0}KB received: {1}", m.Length / 1024, Path.GetFileName(fileName)); + + Common.DoSomethingInUIThread(() => + { + Common.MainForm.SetTrayIconText(text); + }); + } + while (rv > 0); + + if (m != null && fileName != null) + { + m.Flush(); + Logger.LogDebug(m.Length.ToString(CultureInfo.CurrentCulture) + " bytes received."); + Clipboard.LastClipboardEventTime = Common.GetTick(); + string toolTipText = null; + string sizeText = m.Length >= 1024 + ? (m.Length / 1024).ToString(CultureInfo.CurrentCulture) + "KB" + : m.Length.ToString(CultureInfo.CurrentCulture) + "Bytes"; + + PowerToysTelemetry.Log.WriteEvent(new MouseWithoutBorders.Telemetry.MouseWithoutBordersClipboardFileTransferEvent()); + + if (fileName.StartsWith("image", StringComparison.CurrentCultureIgnoreCase)) + { + Clipboard.SetImage(Image.FromStream(m)); + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} from {2} is in Clipboard.", + sizeText, + "image", + remoteMachine); + } + else if (fileName.StartsWith("text", StringComparison.CurrentCultureIgnoreCase)) + { + byte[] data = (m as MemoryStream).GetBuffer(); + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} from {2} is in Clipboard.", + sizeText, + "text", + remoteMachine); + Clipboard.SetClipboardData(data); + } + else if (tempFile != null) + { + if (postAct.Equals("desktop", StringComparison.OrdinalIgnoreCase)) + { + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} received from {2}!", + sizeText, + Path.GetFileName(fileName), + remoteMachine); + + _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => + { + ProcessStartInfo startInfo = new(); + startInfo.UseShellExecute = true; + startInfo.WorkingDirectory = savingFolder; + startInfo.FileName = savingFolder; + startInfo.Verb = "open"; + _ = Process.Start(startInfo); + }); + } + else if (postAct.Contains("mspaint")) + { + m.Close(); + m = null; + Common.OpenImage(tempFile); + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} from {2} is in Mspaint.", + sizeText, + Path.GetFileName(tempFile), + remoteMachine); + } + else + { + StringCollection filePaths = new() + { + tempFile, + }; + Clipboard.SetFileDropList(filePaths); + toolTipText = string.Format( + CultureInfo.CurrentCulture, + "{0} {1} from {2} is in Clipboard.", + sizeText, + Path.GetFileName(fileName), + remoteMachine); + } + } + + if (!string.IsNullOrWhiteSpace(toolTipText)) + { + Common.ShowToolTip(toolTipText, 5000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); + } + + Common.DoSomethingInUIThread(() => + { + Common.MainForm.UpdateNotifyIcon(); + }); + + m?.Close(); + m = null; + } + } + catch (ThreadAbortException) + { + Logger.Log("The current thread is being aborted (3)."); + s.Close(); + + if (m != null) + { + m.Close(); + m = null; + } + + return; + } + catch (Exception e) + { + if (e is IOException) + { + string log = $"{nameof(ReceiveAndProcessClipboardData)}: Exception accessing the socket: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; + Logger.Log(log); + } + else + { + Logger.Log(e); + } + + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, Common.ICON_BIG_CLIPBOARD, -1, + }); + Common.ShowToolTip(e.Message, 1000, ToolTipIcon.Info, Setting.Values.ShowClipNetStatus); + + if (m != null) + { + m.Close(); + m = null; + } + + return; + } + + s.Close(); + } + + internal static bool ShakeHand(ref string remoteName, Socket s, out Stream enStream, out Stream deStream, ref bool clientPushData, ref ClipboardPostAction postAction) + { + const int CLIPBOARD_HANDSHAKE_TIMEOUT = 30; + s.ReceiveTimeout = CLIPBOARD_HANDSHAKE_TIMEOUT * 1000; + s.NoDelay = true; + s.SendBufferSize = s.ReceiveBufferSize = 1024000; + + bool handShaken = false; + enStream = deStream = null; + + try + { + DATA package = new() + { + Type = clientPushData ? PackageType.ClipboardPush : PackageType.Clipboard, + PostAction = postAction, + Src = Common.MachineID, + MachineName = Common.MachineName, + }; + + byte[] buf = new byte[Package.PACKAGE_SIZE_EX]; + + NetworkStream ns = new(s); + enStream = Encryption.GetEncryptedStream(ns); + Common.SendOrReceiveARandomDataBlockPerInitialIV(enStream); + Logger.LogDebug($"{nameof(ShakeHand)}: Writing header package."); + enStream.Write(package.Bytes, 0, Package.PACKAGE_SIZE_EX); + + Logger.LogDebug($"{nameof(ShakeHand)}: Sent: clientPush={clientPushData}, postAction={postAction}."); + + deStream = Encryption.GetDecryptedStream(ns); + Common.SendOrReceiveARandomDataBlockPerInitialIV(deStream, false); + + Logger.LogDebug($"{nameof(ShakeHand)}: Reading header package."); + + int bytesReceived = deStream.ReadEx(buf, 0, Package.PACKAGE_SIZE_EX); + package.Bytes = buf; + + string name = "Unknown"; + + if (bytesReceived == Package.PACKAGE_SIZE_EX) + { + if (package.Type is PackageType.Clipboard or PackageType.ClipboardPush) + { + name = remoteName = package.MachineName; + + Logger.LogDebug($"{nameof(ShakeHand)}: Connection from {name}:{package.Src}"); + + if (MachineStuff.MachinePool.ResolveID(name) == package.Src && Common.IsConnectedTo(package.Src)) + { + clientPushData = package.Type == PackageType.ClipboardPush; + postAction = package.PostAction; + handShaken = true; + Logger.LogDebug($"{nameof(ShakeHand)}: Received: clientPush={clientPushData}, postAction={postAction}."); + } + else + { + Logger.LogDebug($"{nameof(ShakeHand)}: No active connection to the machine: {name}."); + } + } + else + { + Logger.LogDebug($"{nameof(ShakeHand)}: Unexpected package type: {package.Type}."); + } + } + else + { + Logger.LogDebug($"{nameof(ShakeHand)}: BytesTransferred != PACKAGE_SIZE_EX: {bytesReceived}"); + } + + if (!handShaken) + { + string msg = $"Clipboard connection rejected: {name}:{remoteName}/{package.Src}\r\n\r\nMake sure you run the same version in all machines."; + Logger.Log(msg); + Common.ShowToolTip(msg, 3000, ToolTipIcon.Warning); + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] { Common.ICON_BIG_CLIPBOARD, -1, -1, -1 }); + } + } + catch (ThreadAbortException) + { + Logger.Log($"{nameof(ShakeHand)}: The current thread is being aborted."); + s.Close(); + } + catch (Exception e) + { + if (e is IOException) + { + string log = $"{nameof(ShakeHand)}: Exception accessing the socket: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; + Logger.Log(log); + } + else + { + Logger.Log(e); + } + + Common.SetToggleIcon(new int[Common.TOGGLE_ICONS_SIZE] + { + Common.ICON_BIG_CLIPBOARD, + -1, Common.ICON_BIG_CLIPBOARD, -1, + }); + Common.MainForm.UpdateNotifyIcon(); + Common.ShowToolTip(e.Message + "\r\n\r\nMake sure you run the same version in all machines.", 1000, ToolTipIcon.Warning, Setting.Values.ShowClipNetStatus); + + if (m != null) + { + m.Close(); + m = null; + } + } + + return handShaken; + } + + internal static TcpClient ConnectToRemoteClipboardSocket(string remoteMachine) + { + TcpClient clipboardTcpClient; + clipboardTcpClient = new TcpClient(AddressFamily.InterNetworkV6); + clipboardTcpClient.Client.DualMode = true; + + SocketStuff sk = Common.Sk; + + if (sk != null) + { + Common.DoSomethingInUIThread(() => Common.MainForm.ChangeIcon(Common.ICON_SMALL_CLIPBOARD)); + + System.Net.IPAddress ip = Common.GetConnectedClientSocketIPAddressFor(remoteMachine); + Logger.LogDebug($"{nameof(ConnectToRemoteClipboardSocket)}Connecting to {remoteMachine}:{ip}:{sk.TcpPort}..."); + + if (ip != null) + { + clipboardTcpClient.Connect(ip, sk.TcpPort); + } + else + { + clipboardTcpClient.Connect(remoteMachine, sk.TcpPort); + } + + Logger.LogDebug($"Connected from {clipboardTcpClient.Client.LocalEndPoint}. Getting data..."); + return clipboardTcpClient; + } + else + { + throw new ExpectedSocketException($"{nameof(ConnectToRemoteClipboardSocket)}: No longer connected."); + } + } + + private static void SetClipboardData(byte[] data) + { + if (data == null || data.Length <= 0) + { + Logger.Log("data is null or empty!"); + return; + } + + if (data.Length > 1024000) + { + Common.ShowToolTip( + string.Format( + CultureInfo.CurrentCulture, + "Decompressing {0} clipboard data ...", + (data.Length / 1024).ToString(CultureInfo.CurrentCulture) + "KB"), + 5000, + ToolTipIcon.Info, + Setting.Values.ShowClipNetStatus); + } + + string st = string.Empty; + + using (MemoryStream ms = new(data)) + { + using DeflateStream s = new(ms, CompressionMode.Decompress, true); + const int BufferSize = 1024000; // Buffer size should be big enough, this is critical to performance! + + int rv = 0; + + do + { + byte[] buffer = new byte[BufferSize]; + rv = s.ReadEx(buffer, 0, BufferSize); + + if (rv > 0) + { + st += Common.GetStringU(buffer); + } + else + { + break; + } + } + while (true); + } + + int textTypeCount = 0; + string[] texts = st.Split(new string[] { TEXT_TYPE_SEP }, StringSplitOptions.RemoveEmptyEntries); + string tmp; + DataObject data1 = new(); + + foreach (string txt in texts) + { + if (string.IsNullOrEmpty(txt.Trim(NullSeparator))) + { + continue; + } + + tmp = txt[3..]; + + if (txt.StartsWith("RTF", StringComparison.CurrentCultureIgnoreCase)) + { + Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of RTF <-"); + data1.SetData(DataFormats.Rtf, tmp); + } + else if (txt.StartsWith("HTM", StringComparison.CurrentCultureIgnoreCase)) + { + Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of HTM <-"); + data1.SetData(DataFormats.Html, tmp); + } + else if (txt.StartsWith("TXT", StringComparison.CurrentCultureIgnoreCase)) + { + Logger.LogDebug(((double)tmp.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of TXT <-"); + data1.SetData(DataFormats.UnicodeText, tmp); + } + else + { + if (textTypeCount == 0) + { + Logger.LogDebug(((double)txt.Length / 1024).ToString("0.00", CultureInfo.InvariantCulture) + "KB of UNI <-"); + data1.SetData(DataFormats.UnicodeText, txt); + } + + Logger.Log("Invalid clipboard format received!"); + } + + textTypeCount++; + } + + if (textTypeCount > 0) + { + Clipboard.SetDataObject(data1); + } + } + + private static void SetFileDropList(StringCollection filePaths) + { + Common.DoSomethingInUIThread(() => + { + try + { + _ = IpcChannelHelper.Retry( + nameof(SystemClipboard.SetFileDropList), + () => + { + SystemClipboard.SetFileDropList(filePaths); + return true; + }, + (log) => Logger.TelemetryLogTrace( + log, + SeverityLevel.Information), + () => Clipboard.LastClipboardEventTime = Common.GetTick()); + } + catch (ExternalException e) + { + Logger.Log(e); + } + catch (ThreadStateException e) + { + Logger.Log(e); + } + catch (ArgumentNullException e) + { + Logger.Log(e); + } + catch (ArgumentException e) + { + Logger.Log(e); + } + }); + } + + private static void SetImage(Image image) + { + Common.DoSomethingInUIThread(() => + { + try + { + _ = IpcChannelHelper.Retry( + nameof(SystemClipboard.SetImage), + () => + { + SystemClipboard.SetImage(image); + return true; + }, + (log) => Logger.TelemetryLogTrace(log, SeverityLevel.Information), + () => Clipboard.LastClipboardEventTime = Common.GetTick()); + } + catch (ExternalException e) + { + Logger.Log(e); + } + catch (ThreadStateException e) + { + Logger.Log(e); + } + catch (ArgumentNullException e) + { + Logger.Log(e); + } + }); + } + + internal static void SetText(string text) + { + Common.DoSomethingInUIThread(() => + { + try + { + _ = IpcChannelHelper.Retry( + nameof(SystemClipboard.SetText), + () => + { + SystemClipboard.SetText(text); + return true; + }, + (log) => Logger.TelemetryLogTrace(log, SeverityLevel.Information), + () => Clipboard.LastClipboardEventTime = Common.GetTick()); + } + catch (ExternalException e) + { + Logger.Log(e); + } + catch (ThreadStateException e) + { + Logger.Log(e); + } + catch (ArgumentNullException e) + { + Logger.Log(e); + } + }); + } + + private static void SetDataObject(DataObject dataObject) + { + Common.DoSomethingInUIThread(() => + { + try + { + SystemClipboard.SetDataObject(dataObject, true, 10, 200); + } + catch (ExternalException e) + { + string dataFormats = string.Join(",", dataObject.GetFormats()); + Logger.Log($"{e.Message}: {dataFormats}"); + } + catch (ThreadStateException e) + { + Logger.Log(e); + } + catch (ArgumentNullException e) + { + Logger.Log(e); + } + }); + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/ClipboardPostAction.cs b/src/modules/MouseWithoutBorders/App/Core/ClipboardPostAction.cs new file mode 100644 index 0000000000..c07a8bb91a --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/ClipboardPostAction.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +// <summary> +// Package format/conversion. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal enum ClipboardPostAction : uint +{ + Other = 0, + Desktop = 1, + Mspaint = 2, +} diff --git a/src/modules/MouseWithoutBorders/App/Core/Common.cs b/src/modules/MouseWithoutBorders/App/Core/Common.cs new file mode 100644 index 0000000000..3c2206f2ab --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/Common.cs @@ -0,0 +1,1654 @@ +// 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; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Drawing.Imaging; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Windows.Forms; + +using Microsoft.PowerToys.Settings.UI.Library; +using MouseWithoutBorders.Class; +using MouseWithoutBorders.Exceptions; + +using Clipboard = MouseWithoutBorders.Core.Clipboard; +using Thread = MouseWithoutBorders.Core.Thread; + +// Log is enough +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CheckClipboard()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CheckForDesktopSwitchEvent(System.Boolean)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#SetAsStartupItem(System.Boolean)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#HelperThread()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#GetMyStorageDir()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#MouseEvent(MouseWithoutBorders.MOUSEDATA)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#KeybdEvent(MouseWithoutBorders.KEYBDDATA)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ImpersonateLoggedOnUserAndDoSomething(System.Threading.ThreadStart)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#StartMouseWithoutBordersService()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#HookClipboard()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ReceiveClipboardData(MouseWithoutBorders.DATA,System.Boolean)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ReceiverCallback(System.Object)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ConnectAndGetData(System.Object)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CheckNewVersion()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#StartServiceAndSendLogoffSignal()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#GetScreenConfig()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CaptureScreen()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#InitEncryption()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ToggleIcon()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#GetNameAndIPAddresses()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#Cleanup()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling", Scope = "type", Target = "MouseWithoutBorders.Common", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity", Scope = "member", Target = "MouseWithoutBorders.Common.#ConnectAndGetData(System.Object)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity", Scope = "member", Target = "MouseWithoutBorders.Common.#ProcessPackage(MouseWithoutBorders.DATA)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#SetOEMBackground(System.Boolean)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#get_Machine_Pool()", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#SetOEMBackground(System.Boolean,System.String)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#GetNewImageAndSaveTo(System.String,System.String)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CreateLowIntegrityProcess(System.String,System.String,System.Int32)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#LogAll()", MessageId = "System.String.Format(System.IFormatProvider,System.String,System.Object[])", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#CheckForDesktopSwitchEvent(System.Boolean)", MessageId = "MouseWithoutBorders.NativeMethods.SendMessage(System.IntPtr,System.Int32,System.IntPtr,System.IntPtr)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#DragDropStep04()", MessageId = "MouseWithoutBorders.NativeMethods.SendMessage(System.IntPtr,System.Int32,System.IntPtr,System.IntPtr)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#CreateLowIntegrityProcess(System.String,System.String,System.Int32)", MessageId = "MouseWithoutBorders.NativeMethods.WaitForSingleObject(System.IntPtr,System.Int32)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#GetText(System.IntPtr)", MessageId = "MouseWithoutBorders.NativeMethods.GetWindowText(System.IntPtr,System.Text.StringBuilder,System.Int32)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Usage", "CA1806:DoNotIgnoreMethodResults", Scope = "member", Target = "MouseWithoutBorders.Common.#ImpersonateLoggedOnUserAndDoSomething(System.Threading.ThreadStart)", MessageId = "MouseWithoutBorders.NativeMethods.WTSQueryUserToken(System.UInt32,System.IntPtr@)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CreateLowIntegrityProcess(System.String,System.String,System.Int32,System.Boolean,System.Int64)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#CreateProcessInInputDesktopSession(System.String,System.String,System.String,System.Boolean,System.Int16)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#SkSend(MouseWithoutBorders.DATA,System.Boolean,System.Int32)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ReceiveClipboardDataUsingTCP(MouseWithoutBorders.DATA,System.Boolean,System.Net.Sockets.Socket)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#UpdateMachineMatrix(MouseWithoutBorders.DATA)", Justification = "Dotnet port with style preservation")] +[module: SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Scope = "member", Target = "MouseWithoutBorders.Common.#ReopenSockets(System.Boolean)", Justification = "Dotnet port with style preservation")] + +// <summary> +// Most of the helper methods. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal static class Common +{ + private static InputHook hook; + private static FrmMatrix matrixForm; + private static FrmInputCallback inputCallbackForm; + private static FrmAbout aboutForm; +#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter + internal static Thread helper; + internal static int screenWidth; + internal static int screenHeight; +#pragma warning restore SA1307 + private static int lastX; + private static int lastY; + + private static bool mainFormVisible = true; + private static bool runOnLogonDesktop; + private static bool runOnScrSaverDesktop; + +#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter + internal static int[] toggleIcons; + internal static int toggleIconsIndex; +#pragma warning restore SA1307 + internal const int TOGGLE_ICONS_SIZE = 4; + internal const int ICON_ONE = 0; + internal const int ICON_ALL = 1; + internal const int ICON_SMALL_CLIPBOARD = 2; + internal const int ICON_BIG_CLIPBOARD = 3; + internal const int ICON_ERROR = 4; + internal const int JUST_GOT_BACK_FROM_SCREEN_SAVER = 9999; + + internal const int NETWORK_STREAM_BUF_SIZE = 1024 * 1024; + internal static readonly EventWaitHandle EvSwitch = new(false, EventResetMode.AutoReset); + private static Point lastPos; +#pragma warning disable SA1307 // Accessible fields should begin with upper-case names + internal static int switchCount; +#pragma warning restore SA1307 + private static long lastReconnectByHotKeyTime; +#pragma warning disable SA1307 // Accessible fields should begin with upper-case names + internal static int tcpPort; +#pragma warning restore SA1307 + private static bool secondOpenSocketTry; + private static string binaryName; + + internal static Process CurrentProcess { get; set; } + + internal static bool HotkeyMatched(int vkCode, bool winDown, bool ctrlDown, bool altDown, bool shiftDown, HotkeySettings hotkey) + { + return !hotkey.IsEmpty() && (vkCode == hotkey.Code) && (!hotkey.Win || winDown) && (!hotkey.Alt || altDown) && (!hotkey.Shift || shiftDown) && (!hotkey.Ctrl || ctrlDown); + } + + internal static string BinaryName + { + get => Common.binaryName; + set => Common.binaryName = value; + } + + internal static bool SecondOpenSocketTry + { + get => Common.secondOpenSocketTry; + set => Common.secondOpenSocketTry = value; + } + + internal static long LastReconnectByHotKeyTime + { + get => Common.lastReconnectByHotKeyTime; + set => Common.lastReconnectByHotKeyTime = value; + } + + internal static int SwitchCount + { + get => Common.switchCount; + set => Common.switchCount = value; + } + + internal static Point LastPos + { + get => Common.lastPos; + set => Common.lastPos = value; + } + + internal static FrmAbout AboutForm + { + get => Common.aboutForm; + set => Common.aboutForm = value; + } + + internal static FrmInputCallback InputCallbackForm + { + get => Common.inputCallbackForm; + set => Common.inputCallbackForm = value; + } + + internal static int PaintCount { get; set; } + + internal static bool RunOnScrSaverDesktop + { + get => Common.runOnScrSaverDesktop; + set => Common.runOnScrSaverDesktop = value; + } + + internal static bool RunOnLogonDesktop + { + get => Common.runOnLogonDesktop; + set => Common.runOnLogonDesktop = value; + } + + internal static bool RunWithNoAdminRight { get; set; } + + internal static int LastX + { + get => Common.lastX; + set => Common.lastX = value; + } + + internal static int LastY + { + get => Common.lastY; + set => Common.lastY = value; + } + + internal static int[] ToggleIcons => Common.toggleIcons; + + internal static int ScreenHeight => Common.screenHeight; + + internal static int ScreenWidth => Common.screenWidth; + + internal static bool Is64bitOS + { + get; set; + + // set { Common.is64bitOS = value; } + } + + internal static int ToggleIconsIndex + { + // get { return Common.toggleIconsIndex; } + set => Common.toggleIconsIndex = value; + } + + internal static InputHook Hook + { + get => Common.hook; + set => Common.hook = value; + } + + internal static SocketStuff Sk { get; set; } + + internal static FrmScreen MainForm { get; set; } + + internal static FrmMouseCursor MouseCursorForm { get; set; } + + internal static FrmMatrix MatrixForm + { + get => Common.matrixForm; + set => Common.matrixForm = value; + } + + internal static ID DesMachineID + { + get => MachineStuff.desMachineID; + + set + { + MachineStuff.desMachineID = value; + MachineStuff.DesMachineName = MachineStuff.NameFromID(MachineStuff.desMachineID); + } + } + + internal static ID MachineID => (ID)Setting.Values.MachineId; + + internal static string MachineName { get; set; } + + internal static bool MainFormVisible + { + get => Common.mainFormVisible; + set => Common.mainFormVisible = value; + } + + internal static Mutex SocketMutex { get; set; } // Synchronization between MouseWithoutBorders running in different desktops + + // TODO: For telemetry only, to be removed. + private static int socketMutexBalance; + + internal static void ReleaseSocketMutex() + { + if (SocketMutex != null) + { + Logger.LogDebug("SOCKET MUTEX BEGIN RELEASE."); + + try + { + _ = Interlocked.Decrement(ref socketMutexBalance); + SocketMutex.ReleaseMutex(); + } + catch (ApplicationException e) + { + // The current thread does not own the mutex, the thread acquired it will own it. + Logger.TelemetryLogTrace($"{nameof(ReleaseSocketMutex)}: {e.Message}. {Thread.CurrentThread.ManagedThreadId}/{UIThreadID}.", SeverityLevel.Warning); + } + + Logger.LogDebug("SOCKET MUTEX RELEASED."); + } + else + { + Logger.LogDebug("SOCKET MUTEX NULL."); + } + } + + internal static void AcquireSocketMutex() + { + if (SocketMutex != null) + { + Logger.LogDebug("SOCKET MUTEX BEGIN WAIT."); + int waitTimeout = 60000; // TcpListener.Stop may take very long to complete for some reason. + + int socketMutexBalance = int.MinValue; + + bool acquireMutex = ExecuteAndTrace( + "Waiting for sockets to close", + () => + { + socketMutexBalance = Interlocked.Increment(ref Common.socketMutexBalance); + _ = SocketMutex.WaitOne(waitTimeout); // The app now requires .Net 4.0. Note: .Net20RTM does not have the one-parameter version of the API. + }, + TimeSpan.FromSeconds(5)); + + // Took longer than expected. + if (!acquireMutex) + { + Process[] ps = Process.GetProcessesByName(Common.BinaryName); + Logger.TelemetryLogTrace($"Balance: {socketMutexBalance}, Active: {WinAPI.IsMyDesktopActive()}, Sid/Console: {Process.GetCurrentProcess().SessionId}/{NativeMethods.WTSGetActiveConsoleSessionId()}, Desktop/Input: {WinAPI.GetMyDesktop()}/{WinAPI.GetInputDesktop()}, count: {ps?.Length}.", SeverityLevel.Warning); + } + + Logger.LogDebug("SOCKET MUTEX ENDED."); + } + else + { + Logger.LogDebug("SOCKET MUTEX NULL."); + } + } + + internal static bool BlockingUI { get; private set; } + + internal static bool ExecuteAndTrace(string actionName, Action action, TimeSpan timeout, bool restart = false) + { + bool rv = true; + Logger.LogDebug(actionName); + bool done = false; + + BlockingUI = true; + + if (restart) + { + Common.MainForm.Text = Setting.Values.MyIdEx; + + /* closesocket() rarely gets stuck for some reason inside ntdll!ZwClose ...=>... afd!AfdCleanupCore. + * There is no good workaround for it so far, still working with [Winsock 2.0 Discussions] to address the issue. + * */ + new Thread( + () => + { + for (int i = 0; i < timeout.TotalSeconds; i++) + { + Thread.Sleep(1000); + + if (done) + { + return; + } + } + + Logger.TelemetryLogTrace($"[{actionName}] took more than {(long)timeout.TotalSeconds}, restarting the process.", SeverityLevel.Warning, true); + + string desktop = WinAPI.GetMyDesktop(); + MachineStuff.oneInstanceCheck?.Close(); + _ = Process.Start(Application.ExecutablePath, desktop); + Logger.LogDebug($"Started on desktop {desktop}"); + + Process.GetCurrentProcess().KillProcess(true); + }, + $"{actionName} watchdog").Start(); + } + + Stopwatch timer = Stopwatch.StartNew(); + + try + { + action(); + } + finally + { + done = true; + BlockingUI = false; + + if (restart) + { + Common.MainForm.Text = Setting.Values.MyID; + } + + timer.Stop(); + + if (timer.Elapsed > timeout) + { + rv = false; + + if (!restart) + { + Logger.TelemetryLogTrace($"[{actionName}] took more than {(long)timeout.TotalSeconds}: {(long)timer.Elapsed.TotalSeconds}.", SeverityLevel.Warning); + } + } + } + + return rv; + } + + internal static byte[] GetBytes(string st) + { + return ASCIIEncoding.ASCII.GetBytes(st); + } + + internal static string GetString(byte[] bytes) + { + return ASCIIEncoding.ASCII.GetString(bytes); + } + + internal static byte[] GetBytesU(string st) + { + return ASCIIEncoding.Unicode.GetBytes(st); + } + + internal static string GetStringU(byte[] bytes) + { + return ASCIIEncoding.Unicode.GetString(bytes); + } + + internal static int UIThreadID { get; set; } + + internal static void DoSomethingInUIThread(Action action, bool blocking = false) + { + InvokeInFormThread(MainForm, UIThreadID, action, blocking); + } + + internal static int InputCallbackThreadID { get; set; } + + internal static void DoSomethingInTheInputCallbackThread(Action action, bool blocking = true) + { + InvokeInFormThread(InputCallbackForm, InputCallbackThreadID, action, blocking); + } + + private static void InvokeInFormThread(System.Windows.Forms.Form form, int threadId, Action action, bool blocking) + { + if (form != null) + { + int currentThreadId = Thread.CurrentThread.ManagedThreadId; + + if (currentThreadId == threadId) + { + action(); + } + else + { + bool done = false; + + try + { + Action callback = () => + { + try + { + action(); + } + catch (Exception e) + { + Logger.Log(e); + } + finally + { + done = true; + } + }; + _ = form.BeginInvoke(callback); + } + catch (Exception e) + { + done = true; + Logger.Log(e); + } + + while (blocking && !done) + { + Thread.Sleep(16); + + if (currentThreadId == UIThreadID || currentThreadId == InputCallbackThreadID) + { + Application.DoEvents(); + } + } + } + } + } + + private static readonly Lock InputSimulationLock = new(); + + internal static void DoSomethingInTheInputSimulationThread(ThreadStart target) + { + /* + * For some reason, SendInput may hit deadlock if it is called in the InputHookProc thread. + * For now leave it as is in the caller thread which is the socket receiver thread. + * */ + + // SendInput is thread-safe but few users seem to hit a deadlock occasionally, probably a Windows bug. + lock (InputSimulationLock) + { + target(); + } + } + + internal static void SendPackage(ID des, PackageType packageType) + { + DATA package = new(); + package.Type = packageType; + package.Des = des; + package.MachineName = MachineName; + + SkSend(package, null, false); + } + + internal static void SendHeartBeat(bool initial = false) + { + SendPackage(ID.ALL, initial && Encryption.GeneratedKey ? PackageType.Heartbeat_ex : PackageType.Heartbeat); + } + + private static long lastSendNextMachine; + + internal static void SendNextMachine(ID hostMachine, ID nextMachine, Point requestedXY) + { + Logger.LogDebug($"SendNextMachine: Host machine: {hostMachine}, Next machine: {nextMachine}, Requested XY: {requestedXY}"); + + if (GetTick() - lastSendNextMachine < 100) + { + Logger.LogDebug("Machine switching in progress."); // "Move Mouse relatively" mode, slow machine/network, quick/busy hand. + return; + } + + lastSendNextMachine = GetTick(); + + DATA package = new(); + package.Type = PackageType.NextMachine; + + package.Des = hostMachine; + + package.Md.X = requestedXY.X; + package.Md.Y = requestedXY.Y; + package.Md.WheelDelta = (int)nextMachine; + + SkSend(package, null, false); + + Logger.LogDebug("SendNextMachine done."); + } + + private static ulong lastInputEventCount; + private static ulong lastRealInputEventCount; + + internal static void SendAwakeBeat() + { + if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && WinAPI.IsMyDesktopActive() && + Setting.Values.BlockScreenSaver && lastRealInputEventCount != Event.RealInputEventCount) + { + SendPackage(ID.ALL, PackageType.Awake); + } + else + { + SendHeartBeat(); + } + + lastInputEventCount = Event.InputEventCount; + lastRealInputEventCount = Event.RealInputEventCount; + } + + internal static void HumanBeingDetected() + { + if (lastInputEventCount == Event.InputEventCount) + { + if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && WinAPI.IsMyDesktopActive()) + { + PokeMyself(); + } + } + + lastInputEventCount = Event.InputEventCount; + } + + private static void PokeMyself() + { + int x, y = 0; + + for (int i = 0; i < 10; i++) + { + x = Encryption.Ran.Next(-9, 10); + InputSimulation.MoveMouseRelative(x, y); + Thread.Sleep(50); + InputSimulation.MoveMouseRelative(-x, -y); + Thread.Sleep(50); + + if (lastInputEventCount != Event.InputEventCount) + { + break; + } + } + } + + internal static void InitLastInputEventCount() + { + lastInputEventCount = Event.InputEventCount; + lastRealInputEventCount = Event.RealInputEventCount; + } + + internal static void SendHello() + { + SendPackage(ID.ALL, PackageType.Hello); + } + + /* + internal static void SendHi() + { + SendPackage(IP.ALL, PackageType.hi); + } + * */ + + internal static void SendByeBye() + { + Logger.LogDebug($"{nameof(SendByeBye)}"); + SendPackage(ID.ALL, PackageType.ByeBye); + } + + internal static void SendClipboardBeat() + { + SendPackage(ID.ALL, PackageType.Clipboard); + } + + internal static void ProcessByeByeMessage(DATA package) + { + if (package.Src == MachineStuff.desMachineID) + { + MachineStuff.SwitchToMachine(MachineName.Trim()); + } + + _ = MachineStuff.RemoveDeadMachines(package.Src); + } + + internal static long GetTick() // ms + { + return DateTime.Now.Ticks / 10000; + } + + internal static void SetToggleIcon(int[] toggleIcons) + { + Logger.LogDebug($"{nameof(SetToggleIcon)}: {toggleIcons?.FirstOrDefault()}"); + Common.toggleIcons = toggleIcons; + toggleIconsIndex = 0; + } + + internal static string CaptureScreen() + { + try + { + string fileName = GetMyStorageDir() + @"ScreenCaptureByMouseWithoutBorders.png"; + int w = MachineStuff.desktopBounds.Right - MachineStuff.desktopBounds.Left; + int h = MachineStuff.desktopBounds.Bottom - MachineStuff.desktopBounds.Top; + Bitmap bm = new(w, h); + Graphics g = Graphics.FromImage(bm); + Size s = new(w, h); + g.CopyFromScreen(MachineStuff.desktopBounds.Left, MachineStuff.desktopBounds.Top, 0, 0, s); + bm.Save(fileName, ImageFormat.Png); + bm.Dispose(); + return fileName; + } + catch (Exception e) + { + Logger.Log(e); + return null; + } + } + + private static void PrepareScreenCapture() + { + Common.DoSomethingInUIThread(() => + { + if (!DragDrop.MouseDown && Helper.SendMessageToHelper(0x401, IntPtr.Zero, IntPtr.Zero) > 0) + { + Common.MMSleep(0.2); + InputSimulation.SendKey(new KEYBDDATA() { wVk = (int)VK.SNAPSHOT }); + InputSimulation.SendKey(new KEYBDDATA() { dwFlags = (int)WM.LLKHF.UP, wVk = (int)VK.SNAPSHOT }); + + Logger.LogDebug("PrepareScreenCapture: SNAPSHOT simulated."); + + _ = NativeMethods.MoveWindow( + (IntPtr)NativeMethods.FindWindow(null, Helper.HELPER_FORM_TEXT), + MachineStuff.DesktopBounds.Left, + MachineStuff.DesktopBounds.Top, + MachineStuff.DesktopBounds.Right - MachineStuff.DesktopBounds.Left, + MachineStuff.DesktopBounds.Bottom - MachineStuff.DesktopBounds.Top, + false); + + _ = Helper.SendMessageToHelper(0x406, IntPtr.Zero, IntPtr.Zero, false); + } + else + { + Logger.Log("PrepareScreenCapture: Validation failed."); + } + }); + } + + internal static void OpenImage(string file) + { + // We want to run mspaint under the user account who ran explorer.exe (who logged in this current input desktop) + + // ImpersonateLoggedOnUserAndDoSomething(delegate() + // { + // Process.Start("explorer", "\"" + file + "\""); + // }); + _ = Launch.CreateProcessInInputDesktopSession( + "\"" + Environment.ExpandEnvironmentVariables(@"%SystemRoot%\System32\Mspaint.exe") + + "\"", + "\"" + file + "\"", + WinAPI.GetInputDesktop(), + 1); + + // CreateNormalIntegrityProcess(Environment.ExpandEnvironmentVariables(@"%SystemRoot%\System32\Mspaint.exe") + + // " \"" + file + "\""); + + // We don't want to run mspaint as local system account + /* + ProcessStartInfo s = new ProcessStartInfo( + Environment.ExpandEnvironmentVariables(@"%SystemRoot%\System32\Mspaint.exe"), + "\"" + file + "\""); + s.WindowStyle = ProcessWindowStyle.Maximized; + Process.Start(s); + * */ + } + + internal static void SendImage(string machine, string file) + { + Clipboard.LastDragDropFile = file; + + // Send ClipboardCapture + if (machine.Equals("All", StringComparison.OrdinalIgnoreCase)) + { + SendPackage(ID.ALL, PackageType.ClipboardCapture); + } + else + { + ID id = MachineStuff.MachinePool.ResolveID(machine); + if (id != ID.NONE) + { + SendPackage(id, PackageType.ClipboardCapture); + } + } + } + + internal static void SendImage(ID src, string file) + { + Clipboard.LastDragDropFile = file; + + // Send ClipboardCapture + SendPackage(src, PackageType.ClipboardCapture); + } + + internal static void ShowToolTip(string tip, int timeOutInMilliseconds = 5000, ToolTipIcon icon = ToolTipIcon.Info, bool showBalloonTip = true, bool forceEvenIfHidingOldUI = false) + { + if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) + { + DoSomethingInUIThread(() => + { + if (Setting.Values.FirstRun) + { + MachineStuff.Settings?.ShowTip(icon, tip, timeOutInMilliseconds); + } + + Common.MatrixForm?.ShowTip(icon, tip, timeOutInMilliseconds); + + if (showBalloonTip) + { + if (MainForm != null) + { + MainForm.ShowToolTip(tip, timeOutInMilliseconds, forceEvenIfHidingOldUI: forceEvenIfHidingOldUI); + } + else + { + Logger.Log(tip); + } + } + }); + } + } + + private static FrmMessage topMostMessageForm; + + internal static void ToggleShowTopMostMessage(string text, string bigText, int timeOut) + { + DoSomethingInUIThread(() => + { + if (topMostMessageForm == null) + { + topMostMessageForm = new FrmMessage(text, bigText, timeOut); + topMostMessageForm.Show(); + } + else + { + FrmMessage currentMessageForm = topMostMessageForm; + topMostMessageForm = null; + currentMessageForm.Close(); + } + }); + } + + internal static void HideTopMostMessage() + { + DoSomethingInUIThread(() => + { + topMostMessageForm?.Close(); + }); + } + + internal static void NullTopMostMessage() + { + DoSomethingInUIThread(() => + { + if (topMostMessageForm != null) + { + topMostMessageForm = null; + } + }); + } + + internal static bool IsTopMostMessageNotNull() + { + return topMostMessageForm != null; + } + + private static bool TestSend(TcpSk t) + { + ID remoteMachineID; + + if (t.Status == SocketStatus.Connected) + { + try + { + DATA package = new(); + package.Type = PackageType.Hi; + package.Des = remoteMachineID = (ID)t.MachineId; + package.MachineName = MachineName; + + _ = Sk.TcpSend(t, package); + t.EncryptedStream?.Flush(); + + return true; + } + catch (ExpectedSocketException) + { + t.BackingSocket = null; // To be removed at CloseAnUnusedSocket() + } + } + + t.Status = SocketStatus.SendError; + return false; + } + + internal static bool IsConnectedTo(ID remoteMachineID) + { + bool updateClientSockets = false; + + if (remoteMachineID == MachineID) + { + return true; + } + + SocketStuff sk = Common.Sk; + + if (sk != null) + { + lock (sk.TcpSocketsLock) + { + if (sk.TcpSockets != null) + { + foreach (TcpSk t in sk.TcpSockets) + { + if (t.Status == SocketStatus.Connected && (uint)remoteMachineID == t.MachineId) + { + if (TestSend(t)) + { + return true; + } + else + { + updateClientSockets = true; + } + } + } + } + } + } + + if (updateClientSockets) + { + MachineStuff.UpdateClientSockets(nameof(IsConnectedTo)); + } + + return false; + } + +#if DEBUG + private static long minSendTime = long.MaxValue; + private static long avgSendTime; + private static long maxSendTime; + private static long totalSendCount; + private static long totalSendTime; +#endif + + internal static void SkSend(DATA data, uint? exceptDes, bool includeHandShakingSockets) + { + bool connected = false; + + SocketStuff sk = Sk; + + if (sk != null) + { +#if DEBUG + long startStop = DateTime.Now.Ticks; + totalSendCount++; +#endif + + try + { + data.Id = Interlocked.Increment(ref Package.PackageID); + + bool updateClientSockets = false; + + lock (sk.TcpSocketsLock) + { + foreach (TcpSk t in sk.TcpSockets) + { + if (t != null && t.BackingSocket != null && (t.Status == SocketStatus.Connected || (t.Status == SocketStatus.Handshaking && includeHandShakingSockets))) + { + if (t.MachineId == (uint)data.Des || (data.Des == ID.ALL && t.MachineId != exceptDes && MachineStuff.InMachineMatrix(t.MachineName))) + { + try + { + sk.TcpSend(t, data); + + if (data.Des != ID.ALL) + { + connected = true; + } + } + catch (ExpectedSocketException) + { + t.BackingSocket = null; // To be removed at CloseAnUnusedSocket() + updateClientSockets = true; + } + catch (Exception e) + { + Logger.Log(e); + t.BackingSocket = null; // To be removed at CloseAnUnusedSocket() + updateClientSockets = true; + } + } + } + } + } + + if (!connected && data.Des != ID.ALL) + { + Logger.LogDebug("********** No active connection found for the remote machine! **********" + data.Des.ToString()); + + if (data.Des == ID.NONE || MachineStuff.RemoveDeadMachines(data.Des)) + { + // SwitchToMachine(MachineName.Trim()); + MachineStuff.NewDesMachineID = DesMachineID = MachineID; + MachineStuff.SwitchLocation.X = Event.XY_BY_PIXEL + Event.myLastX; + MachineStuff.SwitchLocation.Y = Event.XY_BY_PIXEL + Event.myLastY; + MachineStuff.SwitchLocation.ResetCount(); + EvSwitch.Set(); + } + } + + if (updateClientSockets) + { + MachineStuff.UpdateClientSockets("SkSend"); + } + } + catch (Exception e) + { + Logger.Log(e); + } + +#if DEBUG + startStop = DateTime.Now.Ticks - startStop; + totalSendTime += startStop; + if (startStop < minSendTime) + { + minSendTime = startStop; + } + + if (startStop > maxSendTime) + { + maxSendTime = startStop; + } + + avgSendTime = totalSendTime / totalSendCount; +#endif + } + else + { + Package.PackageSent.Nil++; + } + } + + internal static void CloseAnUnusedSocket() + { + SocketStuff sk = Common.Sk; + + if (sk != null) + { + lock (sk.TcpSocketsLock) + { + if (sk.TcpSockets != null) + { + TcpSk tobeRemoved = null; + + foreach (TcpSk t in sk.TcpSockets) + { + if ((t.Status != SocketStatus.Connected && t.BirthTime < GetTick() - SocketStuff.CONNECT_TIMEOUT) || t.BackingSocket == null) + { + Logger.LogDebug("CloseAnUnusedSocket: " + t.MachineName + ":" + t.MachineId + "|" + t.Status.ToString()); + tobeRemoved = t; + + if (t.BackingSocket != null) + { + try + { + t.BackingSocket.Close(); + } + catch (Exception e) + { + Logger.Log(e); + } + } + + break; // Each time we try to remove one socket only. + } + } + + if (tobeRemoved != null) + { + _ = sk.TcpSockets.Remove(tobeRemoved); + } + } + } + } + } + + internal static bool AtLeastOneSocketConnected() + { + SocketStuff sk = Common.Sk; + + if (sk != null) + { + lock (sk.TcpSocketsLock) + { + if (sk.TcpSockets != null) + { + foreach (TcpSk t in sk.TcpSockets) + { + if (t.Status == SocketStatus.Connected) + { + Logger.LogDebug("AtLeastOneSocketConnected returning true: " + t.MachineName); + return true; + } + } + } + } + } + + Logger.LogDebug("AtLeastOneSocketConnected returning false."); + return false; + } + + private static Socket AtLeastOneServerSocketConnected() + { + SocketStuff sk = Common.Sk; + + if (sk != null) + { + lock (sk.TcpSocketsLock) + { + if (sk.TcpSockets != null) + { + foreach (TcpSk t in sk.TcpSockets) + { + if (!t.IsClient && t.Status == SocketStatus.Connected) + { + Logger.LogDebug("AtLeastOneServerSocketConnected returning true: " + t.MachineName); + return t.BackingSocket; + } + } + } + } + } + + Logger.LogDebug("AtLeastOneServerSocketConnected returning false."); + return null; + } + + internal static TcpSk GetConnectedClientSocket() + { + SocketStuff sk = Common.Sk; + + if (sk != null) + { + lock (sk.TcpSocketsLock) + { + return sk.TcpSockets?.FirstOrDefault(item => item.IsClient && item.Status == SocketStatus.Connected); + } + } + else + { + return null; + } + } + + internal static bool AtLeastOneSocketEstablished() + { + SocketStuff sk = Common.Sk; + + if (sk != null) + { + lock (sk.TcpSocketsLock) + { + if (sk.TcpSockets != null) + { + foreach (TcpSk t in sk.TcpSockets) + { + if (t.BackingSocket != null && t.BackingSocket.Connected) + { + if (TestSend(t)) + { + Logger.LogDebug($"{nameof(AtLeastOneSocketEstablished)} returning true: {t.MachineName}"); + return true; + } + } + } + } + } + } + + Logger.LogDebug($"{nameof(AtLeastOneSocketEstablished)} returning false."); + return false; + } + + internal static bool IsConnectedByAClientSocketTo(string machineName) + { + SocketStuff sk = Common.Sk; + + if (sk != null) + { + lock (sk.TcpSocketsLock) + { + foreach (TcpSk t in sk.TcpSockets) + { + if (t != null && t.IsClient && t.Status == SocketStatus.Connected + && t.BackingSocket != null && t.MachineName.Equals(machineName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + + return false; + } + + internal static IPAddress GetConnectedClientSocketIPAddressFor(string machineName) + { + SocketStuff sk = Common.Sk; + + if (sk != null) + { + lock (sk.TcpSocketsLock) + { + return sk.TcpSockets.FirstOrDefault(t => t != null && t.IsClient && t.Status == SocketStatus.Connected + && t.Address != null && t.MachineName.Equals(machineName, StringComparison.OrdinalIgnoreCase)) + ?.Address; + } + } + + return null; + } + + internal static bool IsConnectingByAClientSocketTo(string machineName, IPAddress ip) + { + SocketStuff sk = Common.Sk; + + if (sk != null) + { + lock (sk.TcpSocketsLock) + { + foreach (TcpSk t in sk.TcpSockets) + { + if (t != null && t.IsClient && t.Status == SocketStatus.Connecting + && t.BackingSocket != null && t.MachineName.Equals(machineName, StringComparison.OrdinalIgnoreCase) + && t.Address.ToString().Equals(ip.ToString(), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + + return false; + } + + internal static void UpdateSetupMachineMatrix(string desMachine) + { + int machineCt = 0; + + foreach (string m in MachineStuff.MachineMatrix) + { + if (!string.IsNullOrEmpty(m.Trim())) + { + machineCt++; + } + } + + if (machineCt < 2 && MachineStuff.Settings != null && (MachineStuff.Settings.GetCurrentPage() is SetupPage1 || MachineStuff.Settings.GetCurrentPage() is SetupPage2b)) + { + MachineStuff.MachineMatrix = new string[MachineStuff.MAX_MACHINE] { Common.MachineName.Trim(), desMachine, string.Empty, string.Empty }; + Logger.LogDebug("UpdateSetupMachineMatrix: " + string.Join(",", MachineStuff.MachineMatrix)); + + Common.DoSomethingInUIThread( + () => + { + MachineStuff.Settings.SetControlPage(new SetupPage4()); + }, + true); + } + } + + internal static void ReopenSockets(bool byUser) + { + DoSomethingInUIThread( + () => + { + try + { + SocketStuff tmpSk = Sk; + + if (tmpSk != null) + { + Sk = null; // TODO: This looks redundant. + tmpSk.Close(byUser); + } + + Sk = new SocketStuff(tcpPort, byUser); + } + catch (Exception e) + { + Sk = null; + Logger.Log(e); + } + + if (Sk != null) + { + if (byUser) + { + SocketStuff.ClearBadIPs(); + } + + MachineStuff.UpdateClientSockets("ReopenSockets"); + } + }, + true); + + if (Sk == null) + { + return; + } + + Common.DoSomethingInTheInputCallbackThread(() => + { + if (Common.Hook != null) + { + Common.Hook.Stop(); + Common.Hook = null; + } + + if (byUser) + { + Common.InputCallbackForm.Close(); + Common.InputCallbackForm = null; + Program.StartInputCallbackThread(); + } + else + { + Common.InputCallbackForm.InstallKeyboardAndMouseHook(); + } + }); + } + + internal static string GetMyStorageDir() + { + string st = string.Empty; + + try + { + if (RunOnLogonDesktop || RunOnScrSaverDesktop) + { + st = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (!Directory.Exists(st)) + { + _ = Directory.CreateDirectory(st); + } + + st += @"\" + Common.BinaryName; + if (!Directory.Exists(st)) + { + _ = Directory.CreateDirectory(st); + } + + st += @"\ScreenCaptures\"; + if (!Directory.Exists(st)) + { + _ = Directory.CreateDirectory(st); + } + } + else + { + _ = Launch.ImpersonateLoggedOnUserAndDoSomething(() => + { + st = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) + @"\" + Common.BinaryName; + if (!Directory.Exists(st)) + { + _ = Directory.CreateDirectory(st); + } + + st += @"\ScreenCaptures\"; + if (!Directory.Exists(st)) + { + _ = Directory.CreateDirectory(st); + } + }); + } + + Logger.LogDebug("GetMyStorageDir: " + st); + + // Delete old files. + foreach (FileInfo fi in new DirectoryInfo(st).GetFiles()) + { + if (fi.CreationTime.AddDays(1) < DateTime.Now) + { + fi.Delete(); + } + } + + return st; + } + catch (Exception e) + { + Logger.Log(e); + + if (string.IsNullOrEmpty(st) || !st.Contains(Common.BinaryName)) + { + st = Path.GetTempPath(); + } + + return st; + } + } + + internal static void GetMachineName() + { + string machine_Name = string.Empty; + + try + { + machine_Name = Dns.GetHostName(); + Logger.LogDebug("GetHostName = " + machine_Name); + } + catch (Exception e) + { + Logger.Log(e); + + if (string.IsNullOrEmpty(machine_Name)) + { + machine_Name = "RANDOM" + Encryption.Ran.Next().ToString(CultureInfo.CurrentCulture); + } + } + + if (machine_Name.Length > 32) + { + machine_Name = machine_Name[..32]; + } + + Common.MachineName = machine_Name.Trim(); + + Logger.LogDebug($"========== {nameof(GetMachineName)} ended!"); + } + + private static string GetNetworkName(NetworkInterface networkInterface) + { + return $"{networkInterface.Name} | {networkInterface.Description.Replace(":", "-")}"; + } + + internal static string GetRemoteStringIP(Socket s, bool throwException = false) + { + if (s == null) + { + return string.Empty; + } + + string ip; + + try + { + ip = (s?.RemoteEndPoint as IPEndPoint)?.Address?.ToString(); + + if (string.IsNullOrEmpty(ip)) + { + return string.Empty; + } + } + catch (ObjectDisposedException e) + { + Logger.Log($"{nameof(GetRemoteStringIP)}: The socket could have been disposed by other threads, error: {e.Message}"); + + if (throwException) + { + throw; + } + + return string.Empty; + } + catch (SocketException e) + { + Logger.Log($"{nameof(GetRemoteStringIP)}: {e.Message}"); + + if (throwException) + { + throw; + } + + return string.Empty; + } + + return ip; + } + + internal static void CloseAllFormsAndHooks() + { + if (Hook != null) + { + Hook.Stop(); + Hook = null; + if (InputCallbackForm != null) + { + DoSomethingInTheInputCallbackThread(() => + { + InputCallbackForm.Close(); + InputCallbackForm = null; + }); + } + } + + if (MainForm != null) + { + MainForm.Destroy(); + MainForm = null; + } + + if (MatrixForm != null) + { + MatrixForm.Close(); + MatrixForm = null; + } + + if (AboutForm != null) + { + AboutForm.Close(); + AboutForm = null; + } + } + + internal static void MoveMouseToCenter() + { + Logger.LogDebug("+++++ MoveMouseToCenter"); + InputSimulation.MoveMouse( + MachineStuff.PrimaryScreenBounds.Left + ((MachineStuff.PrimaryScreenBounds.Right - MachineStuff.PrimaryScreenBounds.Left) / 2), + MachineStuff.PrimaryScreenBounds.Top + ((MachineStuff.PrimaryScreenBounds.Bottom - MachineStuff.PrimaryScreenBounds.Top) / 2)); + } + + internal static void HideMouseCursor(bool byHideMouseMessage) + { + Common.LastPos = new Point( + MachineStuff.PrimaryScreenBounds.Left + ((MachineStuff.PrimaryScreenBounds.Right - MachineStuff.PrimaryScreenBounds.Left) / 2), + Setting.Values.HideMouse ? 4 : MachineStuff.PrimaryScreenBounds.Top + ((MachineStuff.PrimaryScreenBounds.Bottom - MachineStuff.PrimaryScreenBounds.Top) / 2)); + + if ((MachineStuff.desMachineID != MachineID && MachineStuff.desMachineID != ID.ALL) || byHideMouseMessage) + { + _ = NativeMethods.SetCursorPos(Common.LastPos.X, Common.LastPos.Y); + _ = NativeMethods.GetCursorPos(ref Common.lastPos); + Logger.LogDebug($"+++++ HideMouseCursor, byHideMouseMessage = {byHideMouseMessage}"); + } + + CustomCursor.ShowFakeMouseCursor(int.MinValue, int.MinValue); + } + + internal static string GetText(IntPtr hWnd) + { + int length = NativeMethods.GetWindowTextLength(hWnd); + StringBuilder sb = new(length + 1); + int rv = NativeMethods.GetWindowText(hWnd, sb, sb.Capacity); + Logger.LogDebug("GetWindowText returned " + rv.ToString(CultureInfo.CurrentCulture)); + return sb.ToString(); + } + + private static string GetWindowClassName(IntPtr hWnd) + { + StringBuilder buffer = new(128); + _ = NativeMethods.GetClassName(hWnd, buffer, buffer.Capacity); + return buffer.ToString(); + } + + internal static void MMSleep(double secs) + { + for (int i = 0; i < secs * 10; i++) + { + Application.DoEvents(); + Thread.Sleep(100); + } + } + + internal static void UpdateMultipleModeIconAndMenu() + { + MainForm?.UpdateMultipleModeIconAndMenu(); + } + + internal static void SendOrReceiveARandomDataBlockPerInitialIV(Stream st, bool send = true) + { + byte[] ranData = new byte[Encryption.SymAlBlockSize]; + + try + { + if (send) + { + ranData = RandomNumberGenerator.GetBytes(Encryption.SymAlBlockSize); + st.Write(ranData, 0, ranData.Length); + } + else + { + int toRead = ranData.Length; + int read = st.ReadEx(ranData, 0, toRead); + + if (read != toRead) + { + Logger.LogDebug("Stream has no more data after reading {0} bytes.", read); + } + } + } + catch (IOException e) + { + string log = $"{nameof(SendOrReceiveARandomDataBlockPerInitialIV)}: Exception {(send ? "writing" : "reading")} to the socket stream: {e.InnerException?.GetType()}/{e.Message}. (This is expected when the remote machine closes the connection during desktop switch or reconnection.)"; + Logger.Log(log); + + if (e.InnerException is not (SocketException or ObjectDisposedException)) + { + throw; + } + } + } + + private static bool DisableEasyMouseWhenForegroundWindowIsFullscreenSetting() + { + return Setting.Values.DisableEasyMouseWhenForegroundWindowIsFullscreen; + } + + private static bool IsAppIgnoredByEasyMouseFullscreenCheck(IntPtr foregroundWindowHandle) + { + if (NativeMethods.GetWindowThreadProcessId(foregroundWindowHandle, out var processId) == 0) + { + Logger.LogDebug($"GetWindowThreadProcessId failed with error : {Marshal.GetLastWin32Error()}"); + return false; + } + + var processHandle = NativeMethods.OpenProcess(0x1000, false, processId); + if (processHandle == IntPtr.Zero) + { + return false; + } + + uint maxPath = 260; + var nameBuffer = new char[maxPath]; + if (!NativeMethods.QueryFullProcessImageName( + processHandle, NativeMethods.QUERY_FULL_PROCESS_NAME_FLAGS.DEFAULT, nameBuffer, ref maxPath)) + { + Logger.LogDebug($"QueryFullProcessImageName failed with error : {Marshal.GetLastWin32Error()}"); + NativeMethods.CloseHandle(processHandle); + return false; + } + + NativeMethods.CloseHandle(processHandle); + + var name = new string(nameBuffer, 0, (int)maxPath); + + var excludedApps = Setting.Values.EasyMouseFullscreenSwitchBlockExcludedApps; + + return excludedApps.Contains(Path.GetFileNameWithoutExtension(name), StringComparer.OrdinalIgnoreCase) + || excludedApps.Contains(Path.GetFileName(name), StringComparer.OrdinalIgnoreCase); + } + + private static bool IsEasyMouseBlockedByFullscreenWindow() + { + var shellHandle = NativeMethods.GetShellWindow(); + var desktopHandle = NativeMethods.GetDesktopWindow(); + var foregroundHandle = NativeMethods.GetForegroundWindow(); + + // If the foreground window is either the desktop or the Windows shell, we are not in fullscreen mode. + if (foregroundHandle.Equals(shellHandle) || foregroundHandle.Equals(desktopHandle)) + { + return false; + } + + if (NativeMethods.SHQueryUserNotificationState(out var userNotificationState) != 0) + { + Logger.LogDebug($"SHQueryUserNotificationState failed with error : {Marshal.GetLastWin32Error()}"); + return false; + } + + switch (userNotificationState) + { + // An application running in full screen mode, check if the foreground window is + // listed as ignored in the settings. + case NativeMethods.USER_NOTIFICATION_STATE.BUSY: + case NativeMethods.USER_NOTIFICATION_STATE.RUNNING_D3D_FULL_SCREEN: + case NativeMethods.USER_NOTIFICATION_STATE.PRESENTATION_MODE: + return !IsAppIgnoredByEasyMouseFullscreenCheck(foregroundHandle); + + // No full screen app running. + case NativeMethods.USER_NOTIFICATION_STATE.NOT_PRESENT: + case NativeMethods.USER_NOTIFICATION_STATE.ACCEPTS_NOTIFICATIONS: + case NativeMethods.USER_NOTIFICATION_STATE.QUIET_TIME: + // Cannot determine + case NativeMethods.USER_NOTIFICATION_STATE.APP: + default: + return false; + } + } + + /// <summary> + /// Check if a machine switch triggered by EasyMouse would be allowed to proceed due to other settings. + /// </summary> + /// <returns>A boolean that tells us if the switch isn't blocked by any other settings</returns> + internal static bool IsEasyMouseSwitchAllowed() + { + // Never prevent a switch if we are not moving out of the host machine. + if (!DisableEasyMouseWhenForegroundWindowIsFullscreenSetting() || DesMachineID != MachineID) + { + return true; + } + + // Check if the switch is blocked by a full-screen window running in the foreground + return !IsEasyMouseBlockedByFullscreenWindow(); + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/DATA.cs b/src/modules/MouseWithoutBorders/App/Core/DATA.cs new file mode 100644 index 0000000000..4085483bd9 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/DATA.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +// In X64, we are WOW +[module: SuppressMessage("Microsoft.Portability", "CA1900:ValueTypeFieldsShouldBePortable", Scope = "type", Target = "MouseWithoutBorders.Core.DATA", Justification = "Dotnet port with style preservation")] + +// <summary> +// Package format/conversion. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +// The beauty of "union" in C# +[StructLayout(LayoutKind.Explicit)] +internal sealed class DATA +{ + [FieldOffset(0)] + internal PackageType Type; // 4 (first byte = package type, 1 = checksum, 2+3 = magic no.) + + [FieldOffset(sizeof(PackageType))] + internal int Id; // 4 + + [FieldOffset(sizeof(PackageType) + sizeof(uint))] + internal ID Src; // 4 + + [FieldOffset(sizeof(PackageType) + (2 * sizeof(uint)))] + internal ID Des; // 4 + + [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)))] + internal long DateTime; + + [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)) + sizeof(long))] + internal KEYBDDATA Kd; + + [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)))] + internal MOUSEDATA Md; + + [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)))] + internal ID Machine1; + + [FieldOffset(sizeof(PackageType) + (4 * sizeof(uint)))] + internal ID Machine2; + + [FieldOffset(sizeof(PackageType) + (5 * sizeof(uint)))] + internal ID Machine3; + + [FieldOffset(sizeof(PackageType) + (6 * sizeof(uint)))] + internal ID Machine4; + + [FieldOffset(sizeof(PackageType) + (3 * sizeof(uint)))] + internal ClipboardPostAction PostAction; + + [FieldOffset(sizeof(PackageType) + (7 * sizeof(uint)))] + private long machineNameP1; + + [FieldOffset(sizeof(PackageType) + (7 * sizeof(uint)) + sizeof(long))] + private long machineNameP2; + + [FieldOffset(sizeof(PackageType) + (7 * sizeof(uint)) + (2 * sizeof(long)))] + private long machineNameP3; + + [FieldOffset(sizeof(PackageType) + (7 * sizeof(uint)) + (3 * sizeof(long)))] + private long machineNameP4; + + internal string MachineName + { + get + { + string name = Common.GetString(BitConverter.GetBytes(machineNameP1)) + + Common.GetString(BitConverter.GetBytes(machineNameP2)) + + Common.GetString(BitConverter.GetBytes(machineNameP3)) + + Common.GetString(BitConverter.GetBytes(machineNameP4)); + return name.Trim(); + } + + set + { + byte[] machineName = Common.GetBytes(value.PadRight(32, ' ')); + machineNameP1 = BitConverter.ToInt64(machineName, 0); + machineNameP2 = BitConverter.ToInt64(machineName, 8); + machineNameP3 = BitConverter.ToInt64(machineName, 16); + machineNameP4 = BitConverter.ToInt64(machineName, 24); + } + } + + public DATA() + { + } + + public DATA(byte[] initialData) + { + Bytes = initialData; + } + + internal byte[] Bytes + { + get + { + byte[] buf = new byte[IsBigPackage ? Package.PACKAGE_SIZE_EX : Package.PACKAGE_SIZE]; + Array.Copy(StructToBytes(this), buf, IsBigPackage ? Package.PACKAGE_SIZE_EX : Package.PACKAGE_SIZE); + + return buf; + } + + set + { + Debug.Assert(value.Length <= Package.PACKAGE_SIZE_EX, "Length > package size"); + byte[] buf = new byte[Package.PACKAGE_SIZE_EX]; + Array.Copy(value, buf, value.Length); + BytesToStruct(buf, this); + } + } + + internal bool IsBigPackage + { + get => Type == 0 + ? throw new InvalidOperationException("Package type not set.") + : Type switch + { + PackageType.Hello or PackageType.Awake or PackageType.Heartbeat or PackageType.Heartbeat_ex or PackageType.Handshake or PackageType.HandshakeAck or PackageType.ClipboardPush or PackageType.Clipboard or PackageType.ClipboardAsk or PackageType.ClipboardImage or PackageType.ClipboardText or PackageType.ClipboardDataEnd => true, + _ => (Type & PackageType.Matrix) == PackageType.Matrix, + }; + } + + private byte[] StructToBytes(object structObject) + { + byte[] bytes = new byte[Package.PACKAGE_SIZE_EX]; + GCHandle bHandle = GCHandle.Alloc(bytes, GCHandleType.Pinned); + Marshal.StructureToPtr(structObject, Marshal.UnsafeAddrOfPinnedArrayElement(bytes, 0), false); + bHandle.Free(); + return bytes; + } + + private void BytesToStruct(byte[] value, object structObject) + { + GCHandle bHandle = GCHandle.Alloc(value, GCHandleType.Pinned); + Marshal.PtrToStructure(Marshal.UnsafeAddrOfPinnedArrayElement(value, 0), structObject); + bHandle.Free(); + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/DragDrop.cs b/src/modules/MouseWithoutBorders/App/Core/DragDrop.cs index 7fb32b50fb..d262e48f24 100644 --- a/src/modules/MouseWithoutBorders/App/Core/DragDrop.cs +++ b/src/modules/MouseWithoutBorders/App/Core/DragDrop.cs @@ -27,7 +27,7 @@ namespace MouseWithoutBorders.Core; * * SEQUENCE OF EVENTS: * DragDropStep01: MachineX: Remember mouse down state since it could be a start of a dragging - * DragDropStep02: MachineY: Send an message to the MachineX to ask it to check if it is + * DragDropStep02: MachineY: Send a message to the MachineX to ask it to check if it is * doing drag/drop * DragDropStep03: MachineX: Got explorerDragDrop, send WM_CHECK_EXPLORER_DRAG_DROP to its mainForm * DragDropStep04: MachineX: Show Mouse Without Borders Helper form at mouse cursor to get DragEnter event. @@ -67,23 +67,23 @@ internal static class DragDrop return; } - if (wParam == Common.WM_LBUTTONDOWN) + if (wParam == WM.WM_LBUTTONDOWN) { MouseDown = true; DragMachine = MachineStuff.desMachineID; MachineStuff.dropMachineID = ID.NONE; Logger.LogDebug("DragDropStep01: MouseDown"); } - else if (wParam == Common.WM_LBUTTONUP) + else if (wParam == WM.WM_LBUTTONUP) { MouseDown = false; Logger.LogDebug("DragDropStep01: MouseUp"); } - if (wParam == Common.WM_RBUTTONUP && IsDropping) + if (wParam == WM.WM_RBUTTONUP && IsDropping) { IsDropping = false; - Common.LastIDWithClipboardData = ID.NONE; + Clipboard.LastIDWithClipboardData = ID.NONE; } } @@ -193,7 +193,7 @@ internal static class DragDrop { if (!string.IsNullOrEmpty(dragFileName) && (File.Exists(dragFileName) || Directory.Exists(dragFileName))) { - Common.LastDragDropFile = dragFileName; + Clipboard.LastDragDropFile = dragFileName; /* * possibleDropMachineID is used as desID sent in DragDropStep06(); * */ @@ -252,7 +252,7 @@ internal static class DragDrop internal static void DragDropStep09(int wParam) { - if (wParam == Common.WM_MOUSEMOVE && IsDropping) + if (wParam == WM.WM_MOUSEMOVE && IsDropping) { // Show/Move form Common.DoSomethingInUIThread(() => @@ -260,7 +260,7 @@ internal static class DragDrop _ = NativeMethods.PostMessage(Common.MainForm.Handle, NativeMethods.WM_SHOW_DRAG_DROP, (IntPtr)0, (IntPtr)0); }); } - else if (wParam == Common.WM_LBUTTONUP && (IsDropping || IsDragging)) + else if (wParam == WM.WM_LBUTTONUP && (IsDropping || IsDragging)) { if (IsDropping) { @@ -270,7 +270,7 @@ internal static class DragDrop else { IsDragging = false; - Common.LastIDWithClipboardData = ID.NONE; + Clipboard.LastIDWithClipboardData = ID.NONE; } } } @@ -280,7 +280,7 @@ internal static class DragDrop Logger.LogDebug("DragDropStep10: Hide the form and get data..."); IsDropping = false; IsDragging = false; - Common.LastIDWithClipboardData = ID.NONE; + Clipboard.LastIDWithClipboardData = ID.NONE; Common.DoSomethingInUIThread(() => { @@ -288,7 +288,7 @@ internal static class DragDrop }); PowerToysTelemetry.Log.WriteEvent(new MouseWithoutBorders.Telemetry.MouseWithoutBordersDragAndDropEvent()); - Common.GetRemoteClipboard("desktop"); + Clipboard.GetRemoteClipboard("desktop"); } internal static void DragDropStep11() @@ -298,8 +298,8 @@ internal static class DragDrop IsDropping = false; IsDragging = false; DragMachine = (ID)1; - Common.LastIDWithClipboardData = ID.NONE; - Common.LastDragDropFile = null; + Clipboard.LastIDWithClipboardData = ID.NONE; + Clipboard.LastDragDropFile = null; MouseDown = false; } @@ -307,7 +307,7 @@ internal static class DragDrop { Logger.LogDebug("DragDropStep12: ClipboardDragDropEnd received"); IsDropping = false; - Common.LastIDWithClipboardData = ID.NONE; + Clipboard.LastIDWithClipboardData = ID.NONE; Common.DoSomethingInUIThread(() => { diff --git a/src/modules/MouseWithoutBorders/App/Core/Encryption.cs b/src/modules/MouseWithoutBorders/App/Core/Encryption.cs new file mode 100644 index 0000000000..9d00b6bb40 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/Encryption.cs @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; + +// <summary> +// Encrypt/decrypt implementation. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal static class Encryption +{ +#pragma warning disable SYSLIB0021 + private static AesCryptoServiceProvider symAl; +#pragma warning restore SYSLIB0021 +#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter + internal static string myKey; +#pragma warning restore SA1307 + private static uint magicNumber; + private static Random ran = new(); // Used for non encryption related functionality. + internal const int SymAlBlockSize = 16; + + /// <summary> + /// This is used for the first encryption block, the following blocks will be combined with the cipher text of the previous block. + /// Thus identical blocks in the socket stream would be encrypted to different cipher text blocks. + /// The first block is a handshake one containing random data. + /// Related Unit Test: TestEncryptDecrypt + /// </summary> + private static readonly string InitialIV = ulong.MaxValue.ToString(CultureInfo.InvariantCulture); + + internal static Random Ran + { + get => Encryption.ran ??= new Random(); + set => Encryption.ran = value; + } + + internal static uint MagicNumber + { + get => Encryption.magicNumber; + set => Encryption.magicNumber = value; + } + + internal static string MyKey + { + get => Encryption.myKey; + + set + { + if (Encryption.myKey != value) + { + Encryption.myKey = value; + _ = Task.Factory.StartNew( + () => Encryption.GenLegalKey(), + System.Threading.CancellationToken.None, + TaskCreationOptions.None, + TaskScheduler.Default); // Cache the key to improve UX. + } + } + } + + private static string KeyDisplayedText(string key) + { + string displayedValue = string.Empty; + int i = 0; + + do + { + int length = Math.Min(4, key.Length - i); + displayedValue += string.Concat(key.AsSpan(i, length), " "); + i += 4; + } + while (i < key.Length - 1); + + return displayedValue.Trim(); + } + + internal static bool GeneratedKey { get; set; } + + internal static bool KeyCorrupted { get; set; } + + internal static void InitEncryption() + { + try + { + if (symAl == null) + { +#pragma warning disable SYSLIB0021 // No proper replacement for now + symAl = new AesCryptoServiceProvider(); +#pragma warning restore SYSLIB0021 + symAl.KeySize = 256; + symAl.BlockSize = SymAlBlockSize * 8; + symAl.Padding = PaddingMode.Zeros; + symAl.Mode = CipherMode.CBC; + symAl.GenerateIV(); + } + } + catch (Exception e) + { + Logger.Log(e); + } + } + + private static readonly ConcurrentDictionary<string, byte[]> LegalKeyDictionary = new(StringComparer.OrdinalIgnoreCase); + + private static byte[] GenLegalKey() + { + byte[] rv; + string myKey = Encryption.MyKey; + + if (!LegalKeyDictionary.TryGetValue(myKey, out byte[] value)) + { + Rfc2898DeriveBytes key = new( + myKey, + Common.GetBytesU(InitialIV), + 50000, + HashAlgorithmName.SHA512); + rv = key.GetBytes(32); + _ = LegalKeyDictionary.AddOrUpdate(myKey, rv, (k, v) => rv); + } + else + { + rv = value; + } + + return rv; + } + + private static byte[] GenLegalIV() + { + string st = InitialIV; + int ivLength = symAl.IV.Length; + if (st.Length > ivLength) + { + st = st[..ivLength]; + } + else if (st.Length < ivLength) + { + st = st.PadRight(ivLength, ' '); + } + + return Common.GetBytes(st); + } + + internal static Stream GetEncryptedStream(Stream encryptedStream) + { + ICryptoTransform encryptor; + encryptor = symAl.CreateEncryptor(GenLegalKey(), GenLegalIV()); + return new CryptoStream(encryptedStream, encryptor, CryptoStreamMode.Write); + } + + internal static Stream GetDecryptedStream(Stream encryptedStream) + { + ICryptoTransform decryptor; + decryptor = symAl.CreateDecryptor(GenLegalKey(), GenLegalIV()); + return new CryptoStream(encryptedStream, decryptor, CryptoStreamMode.Read); + } + + internal static uint Get24BitHash(string st) + { + if (string.IsNullOrEmpty(st)) + { + return 0; + } + + byte[] bytes = new byte[Package.PACKAGE_SIZE]; + for (int i = 0; i < Package.PACKAGE_SIZE; i++) + { + if (i < st.Length) + { + bytes[i] = (byte)st[i]; + } + } + + var hash = SHA512.Create(); + byte[] hashValue = hash.ComputeHash(bytes); + + for (int i = 0; i < 50000; i++) + { + hashValue = hash.ComputeHash(hashValue); + } + + Logger.LogDebug(string.Format(CultureInfo.CurrentCulture, "magic: {0},{1},{2}", hashValue[0], hashValue[1], hashValue[^1])); + hash.Clear(); + return (uint)((hashValue[0] << 23) + (hashValue[1] << 16) + (hashValue[^1] << 8) + hashValue[2]); + } + + internal static string GetDebugInfo(string st) + { + return string.IsNullOrEmpty(st) ? st : ((byte)(Common.GetBytesU(st).Sum(value => value) % 256)).ToString(CultureInfo.InvariantCulture); + } + + internal static string CreateDefaultKey() + { + return CreateRandomKey(); + } + + private const int PW_LENGTH = 16; + + internal static string CreateRandomKey() + { + // Not including characters like "'`O0& since they are confusing to users. + string[] chars = new[] { "abcdefghjkmnpqrstuvxyz", "ABCDEFGHJKMNPQRSTUVXYZ", "123456789", "~!@#$%^*()_-+=:;<,>.?/\\|[]" }; + char[][] charactersUsedForKey = chars.Select(charset => Enumerable.Range(0, charset.Length - 1).Select(i => charset[i]).ToArray()).ToArray(); + byte[] randomData = new byte[1]; + string key = string.Empty; + + do + { + foreach (string set in chars) + { + randomData = RandomNumberGenerator.GetBytes(1); + key += set[randomData[0] % set.Length]; + + if (key.Length >= PW_LENGTH) + { + break; + } + } + } + while (key.Length < PW_LENGTH); + + return key; + } + + internal static bool IsKeyValid(string key, out string error) + { + error = string.IsNullOrEmpty(key) || key.Length < 16 + ? "Key must have at least 16 characters in length (spaces are discarded). Key must be auto generated in one of the machines." + : null; + + return error == null; + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/Event.cs b/src/modules/MouseWithoutBorders/App/Core/Event.cs index 7856a64d87..659a15526e 100644 --- a/src/modules/MouseWithoutBorders/App/Core/Event.cs +++ b/src/modules/MouseWithoutBorders/App/Core/Event.cs @@ -66,15 +66,19 @@ internal static class Event try { Common.PaintCount = 0; - bool switchByMouseEnabled = IsSwitchingByMouseEnabled(); - if (switchByMouseEnabled && Common.Sk != null && (Common.DesMachineID == Common.MachineID || !Setting.Values.MoveMouseRelatively) && e.dwFlags == Common.WM_MOUSEMOVE) + // Check if easy mouse setting is enabled. + bool isEasyMouseEnabled = IsSwitchingByMouseEnabled(); + + if (isEasyMouseEnabled && Common.Sk != null && (Common.DesMachineID == Common.MachineID || !Setting.Values.MoveMouseRelatively) && e.dwFlags == WM.WM_MOUSEMOVE) { Point p = MachineStuff.MoveToMyNeighbourIfNeeded(e.X, e.Y, MachineStuff.desMachineID); - if (!p.IsEmpty) + // Check if easy mouse switches are disabled when an application is running in fullscreen mode, + // if they are, check that there is no application running in fullscreen mode before switching. + if (!p.IsEmpty && Common.IsEasyMouseSwitchAllowed()) { - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; Logger.LogDebug(string.Format( CultureInfo.CurrentCulture, @@ -111,7 +115,7 @@ internal static class Event Common.SkSend(MousePackage, null, false); - if (MousePackage.Md.dwFlags is Common.WM_LBUTTONUP or Common.WM_RBUTTONUP) + if (MousePackage.Md.dwFlags is WM.WM_LBUTTONUP or WM.WM_RBUTTONUP) { Thread.Sleep(10); } @@ -165,7 +169,8 @@ internal static class Event string newDesMachineName = MachineStuff.NameFromID(newDesMachineID); if (!Common.IsConnectedTo(newDesMachineID)) - {// Connection lost, cancel switching + { + // Connection lost, cancel switching Logger.LogDebug("No active connection found for " + newDesMachineName); // ShowToolTip("No active connection found for [" + newDesMachineName + "]!", 500); @@ -213,10 +218,10 @@ internal static class Event if (MachineStuff.desMachineID == Common.MachineID) { - if (Common.GetTick() - Common.clipboardCopiedTime < Common.BIG_CLIPBOARD_DATA_TIMEOUT) + if (Common.GetTick() - Clipboard.clipboardCopiedTime < Clipboard.BIG_CLIPBOARD_DATA_TIMEOUT) { - Common.clipboardCopiedTime = 0; - Common.GetRemoteClipboard("PrepareToSwitchToMachine"); + Clipboard.clipboardCopiedTime = 0; + Clipboard.GetRemoteClipboard("PrepareToSwitchToMachine"); } } else @@ -260,7 +265,7 @@ internal static class Event KeybdPackage.Kd = e; KeybdPackage.DateTime = Common.GetTick(); Common.SkSend(KeybdPackage, null, false); - if (KeybdPackage.Kd.dwFlags is Common.WM_KEYUP or Common.WM_SYSKEYUP) + if (KeybdPackage.Kd.dwFlags is WM.WM_KEYUP or WM.WM_SYSKEYUP) { Thread.Sleep(10); } diff --git a/src/modules/MouseWithoutBorders/App/Core/Helper.cs b/src/modules/MouseWithoutBorders/App/Core/Helper.cs index 4122fbe31b..2d97d91123 100644 --- a/src/modules/MouseWithoutBorders/App/Core/Helper.cs +++ b/src/modules/MouseWithoutBorders/App/Core/Helper.cs @@ -119,7 +119,7 @@ internal static class Helper if (MachineStuff.NewDesMachineID == Common.MachineID) { - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); } } } @@ -290,14 +290,14 @@ internal static class Helper return; } - if (!Common.IsMyDesktopActive()) + if (!WinAPI.IsMyDesktopActive()) { return; } - if (!Common.IpcChannelCreated) + if (!IpcChannelHelper.IpcChannelCreated) { - Logger.TelemetryLogTrace($"{nameof(Common.IpcChannelCreated)} = {Common.IpcChannelCreated}. {Logger.GetStackTrace(new StackTrace())}", SeverityLevel.Warning); + Logger.TelemetryLogTrace($"{nameof(IpcChannelHelper.IpcChannelCreated)} = {IpcChannelHelper.IpcChannelCreated}. {Logger.GetStackTrace(new StackTrace())}", SeverityLevel.Warning); return; } @@ -314,10 +314,10 @@ internal static class Helper _ = Launch.CreateProcessInInputDesktopSession( $"\"{Path.GetDirectoryName(Application.ExecutablePath)}\\{HelperProcessName}.exe\"", string.Empty, - Common.GetInputDesktop(), + WinAPI.GetInputDesktop(), 0); - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; // Common.CreateLowIntegrityProcess("\"" + Path.GetDirectoryName(Application.ExecutablePath) + "\\MouseWithoutBordersHelper.exe\"", string.Empty, 0, false, 0); var processes = Process.GetProcessesByName(HelperProcessName); @@ -379,7 +379,7 @@ internal static class Helper log += "=============================================================================================================================\r\n"; log += $"{Application.ProductName} version {Application.ProductVersion}\r\n"; - log += $"{Setting.Values.Username}/{Common.GetDebugInfo(Common.MyKey)}\r\n"; + log += $"{Setting.Values.Username}/{Encryption.GetDebugInfo(Encryption.MyKey)}\r\n"; log += $"{Common.MachineName}/{Common.MachineID}/{Common.DesMachineID}\r\n"; log += $"Id: {Setting.Values.DeviceId}\r\n"; log += $"Matrix: {string.Join(",", MachineStuff.MachineMatrix)}\r\n"; diff --git a/src/modules/MouseWithoutBorders/App/Core/ID.cs b/src/modules/MouseWithoutBorders/App/Core/ID.cs new file mode 100644 index 0000000000..11dfcc22c8 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/ID.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. + +// <summary> +// Package format/conversion. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal enum ID : uint +{ + NONE = 0, + ALL = 255, +} diff --git a/src/modules/MouseWithoutBorders/App/Core/InitAndCleanup.cs b/src/modules/MouseWithoutBorders/App/Core/InitAndCleanup.cs new file mode 100644 index 0000000000..510671ee95 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/InitAndCleanup.cs @@ -0,0 +1,278 @@ +// 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.Globalization; +using System.Net.NetworkInformation; +using System.Security.Cryptography; +using System.Threading; + +using Microsoft.Win32; +using MouseWithoutBorders.Class; +using Windows.UI.Input.Preview.Injection; + +// <summary> +// Initialization and clean up. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal static class InitAndCleanup +{ + private static bool initDone; + internal static int REOPEN_WHEN_WSAECONNRESET = -10054; + internal static int REOPEN_WHEN_HOTKEY = -10055; + internal static int PleaseReopenSocket; + internal static bool ReopenSocketDueToReadError; + + private static DateTime LastResumeSuspendTime { get; set; } = DateTime.UtcNow; + + internal static bool InitDone + { + get => InitAndCleanup.initDone; + set => InitAndCleanup.initDone = value; + } + + internal static void UpdateMachineTimeAndID() + { + Common.MachineName = Common.MachineName.Trim(); + _ = MachineStuff.MachinePool.TryUpdateMachineID(Common.MachineName, Common.MachineID, true); + } + + private static void InitializeMachinePoolFromSettings() + { + try + { + MachineInf[] info = MachinePoolHelpers.LoadMachineInfoFromMachinePoolStringSetting(Setting.Values.MachinePoolString); + for (int i = 0; i < info.Length; i++) + { + info[i].Name = info[i].Name.Trim(); + } + + MachineStuff.MachinePool.Initialize(info); + MachineStuff.MachinePool.ResetIPAddressesForDeadMachines(true); + } + catch (Exception ex) + { + Logger.Log(ex); + MachineStuff.MachinePool.Clear(); + } + } + + private static void SetupMachineNameAndID() + { + try + { + Common.GetMachineName(); + Common.DesMachineID = MachineStuff.NewDesMachineID = Common.MachineID; + + // MessageBox.Show(machineID.ToString(CultureInfo.CurrentCulture)); // For test + InitializeMachinePoolFromSettings(); + + Common.MachineName = Common.MachineName.Trim(); + _ = MachineStuff.MachinePool.LearnMachine(Common.MachineName); + _ = MachineStuff.MachinePool.TryUpdateMachineID(Common.MachineName, Common.MachineID, true); + + MachineStuff.UpdateMachinePoolStringSetting(); + } + catch (Exception e) + { + Logger.Log(e); + } + } + + internal static void Init() + { + _ = Helper.GetUserName(); + Encryption.GeneratedKey = true; + + try + { + Encryption.MyKey = Setting.Values.MyKey; + int tmp = Setting.Values.MyKeyDaysToExpire; + } + catch (FormatException e) + { + Encryption.KeyCorrupted = true; + Setting.Values.MyKey = Encryption.MyKey = Encryption.CreateRandomKey(); + Logger.Log(e.Message); + } + catch (CryptographicException e) + { + Encryption.KeyCorrupted = true; + Setting.Values.MyKey = Encryption.MyKey = Encryption.CreateRandomKey(); + Logger.Log(e.Message); + } + + try + { + InputSimulation.Injector = InputInjector.TryCreate(); + if (InputSimulation.Injector != null) + { + InputSimulation.MoveMouseRelative(0, 0); + NativeMethods.InjectMouseInputAvailable = true; + } + } + catch (EntryPointNotFoundException) + { + NativeMethods.InjectMouseInputAvailable = false; + Logger.Log($"{nameof(NativeMethods.InjectMouseInputAvailable)} = false"); + } + + bool dummy = Setting.Values.DrawMouseEx; + Common.Is64bitOS = IntPtr.Size == 8; + Common.tcpPort = Setting.Values.TcpPort; + WinAPI.GetScreenConfig(); + Package.PackageSent = new PackageMonitor(0); + Package.PackageReceived = new PackageMonitor(0); + SetupMachineNameAndID(); + Encryption.InitEncryption(); + CreateHelperThreads(); + + SystemEvents.DisplaySettingsChanged += new EventHandler(WinAPI.SystemEvents_DisplaySettingsChanged); + NetworkChange.NetworkAvailabilityChanged += new NetworkAvailabilityChangedEventHandler(NetworkChange_NetworkAvailabilityChanged); + SystemEvents.PowerModeChanged += new PowerModeChangedEventHandler(SystemEvents_PowerModeChanged); + PleaseReopenSocket = 9; + /* TODO: Telemetry for the matrix? */ + } + + private static void SystemEvents_PowerModeChanged(object sender, PowerModeChangedEventArgs e) + { + Helper.WndProcCounter++; + + if (e.Mode is PowerModes.Resume or PowerModes.Suspend) + { + Logger.TelemetryLogTrace($"{nameof(SystemEvents_PowerModeChanged)}: {e.Mode}", SeverityLevel.Information); + LastResumeSuspendTime = DateTime.UtcNow; + MachineStuff.SwitchToMultipleMode(false, true); + } + } + + private static void CreateHelperThreads() + { + // NOTE(@yuyoyuppe): service crashes while trying to obtain this info, disabling. + /* + Thread watchDogThread = new(new ThreadStart(WatchDogThread), nameof(WatchDogThread)); + watchDogThread.Priority = ThreadPriority.Highest; + watchDogThread.Start(); + */ + + Common.helper = new Thread(new ThreadStart(Helper.HelperThread), "Helper Thread"); + Common.helper.SetApartmentState(ApartmentState.STA); + Common.helper.Start(); + } + + private static void AskHelperThreadsToExit(int waitTime) + { + Helper.signalHelperToExit = true; + Helper.signalWatchDogToExit = true; + _ = Common.EvSwitch.Set(); + + int c = 0; + if (Common.helper != null && c < waitTime) + { + while (Helper.signalHelperToExit) + { + Thread.Sleep(1); + } + + Common.helper = null; + } + } + + internal static void Cleanup() + { + try + { + Common.SendByeBye(); + + // UnhookClipboard(); + AskHelperThreadsToExit(500); + Common.MainForm.NotifyIcon.Visible = false; + Common.MainForm.NotifyIcon.Dispose(); + Common.CloseAllFormsAndHooks(); + + Common.DoSomethingInUIThread(() => + { + Common.Sk?.Close(true); + }); + } + catch (Exception e) + { + Logger.Log(e); + } + } + + private static long lastReleaseAllKeysCall; + + internal static void ReleaseAllKeys() + { + if (Math.Abs(Common.GetTick() - lastReleaseAllKeysCall) < 2000) + { + return; + } + + lastReleaseAllKeysCall = Common.GetTick(); + + KEYBDDATA kd; + kd.dwFlags = (int)WM.LLKHF.UP; + + VK[] keys = new VK[] + { + VK.LSHIFT, VK.LCONTROL, VK.LMENU, VK.LWIN, VK.RSHIFT, + VK.RCONTROL, VK.RMENU, VK.RWIN, VK.SHIFT, VK.MENU, VK.CONTROL, + }; + + Logger.LogDebug("***** ReleaseAllKeys has been called! *****:"); + + foreach (VK vk in keys) + { + if ((NativeMethods.GetAsyncKeyState((IntPtr)vk) & 0x8000) != 0) + { + Logger.LogDebug(vk.ToString() + " is down, release it..."); + Common.Hook?.ResetLastSwitchKeys(); // Sticky key can turn ALL PC mode on (CtrlCtrlCtrl) + kd.wVk = (int)vk; + InputSimulation.SendKey(kd); + Common.Hook?.ResetLastSwitchKeys(); + } + } + } + + private static void NetworkChange_NetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e) + { + Logger.LogDebug("NetworkAvailabilityEventArgs.IsAvailable: " + e.IsAvailable.ToString(CultureInfo.InvariantCulture)); + Helper.WndProcCounter++; + ScheduleReopenSocketsDueToNetworkChanges(!e.IsAvailable); + } + + private static void ScheduleReopenSocketsDueToNetworkChanges(bool closeSockets = true) + { + if (closeSockets) + { + // Slept/hibernated machine may still have the sockets' status as Connected:( (unchanged) so it would not re-connect after a timeout when waking up. + // Closing the sockets when it is going to sleep/hibernate will trigger the reconnection faster when it wakes up. + Common.DoSomethingInUIThread( + () => + { + SocketStuff s = Common.Sk; + Common.Sk = null; + s?.Close(false); + }, + true); + } + + if (!WinAPI.IsMyDesktopActive()) + { + PleaseReopenSocket = 0; + } + else if (PleaseReopenSocket != 10) + { + PleaseReopenSocket = 10; + } + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/IpcChannelHelper.cs b/src/modules/MouseWithoutBorders/App/Core/IpcChannelHelper.cs new file mode 100644 index 0000000000..7e6bfd0217 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/IpcChannelHelper.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Windows.Forms; + +#if !MM_HELPER +using Thread = MouseWithoutBorders.Core.Thread; +#endif + +namespace MouseWithoutBorders.Core; + +internal static class IpcChannelHelper +{ + internal static bool IpcChannelCreated { get; set; } + + internal static T Retry<T>(string name, Func<T> func, Action<string> log, Action preRetry = null) + { + int count = 0; + + do + { + try + { + T rv = func(); + + if (count > 0) + { + log($"Trace: {name} has been successful after {count} retry."); + } + + return rv; + } + catch (Exception) + { + count++; + + preRetry?.Invoke(); + + if (count > 10) + { + throw; + } + + Application.DoEvents(); + Thread.Sleep(200); + } + } + while (true); + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/KEYBDDATA.cs b/src/modules/MouseWithoutBorders/App/Core/KEYBDDATA.cs new file mode 100644 index 0000000000..244c069c98 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/KEYBDDATA.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.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +// <summary> +// Package format/conversion. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +[StructLayout(LayoutKind.Sequential)] +internal struct KEYBDDATA +{ + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Same name as in winAPI")] + internal int wVk; + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Same name as in winAPI")] + internal int dwFlags; +} diff --git a/src/modules/MouseWithoutBorders/App/Core/Logger.cs b/src/modules/MouseWithoutBorders/App/Core/Logger.cs index 6635de59f0..334d269400 100644 --- a/src/modules/MouseWithoutBorders/App/Core/Logger.cs +++ b/src/modules/MouseWithoutBorders/App/Core/Logger.cs @@ -121,52 +121,52 @@ internal static class Logger { string log; - if (!lastPackageSent.Equals(Common.PackageSent)) + if (!lastPackageSent.Equals(Package.PackageSent)) { log = string.Format( CultureInfo.CurrentCulture, "SENT:" + HeaderSENT, - Common.PackageSent.Heartbeat, - Common.PackageSent.Keyboard, - Common.PackageSent.Mouse, - Common.PackageSent.Hello, - Common.PackageSent.Matrix, - Common.PackageSent.ClipboardText, - Common.PackageSent.ClipboardImage, - Common.PackageSent.ByeBye, - Common.PackageSent.Clipboard, - Common.PackageSent.ClipboardDragDrop, - Common.PackageSent.ClipboardDragDropEnd, - Common.PackageSent.ExplorerDragDrop, + Package.PackageSent.Heartbeat, + Package.PackageSent.Keyboard, + Package.PackageSent.Mouse, + Package.PackageSent.Hello, + Package.PackageSent.Matrix, + Package.PackageSent.ClipboardText, + Package.PackageSent.ClipboardImage, + Package.PackageSent.ByeBye, + Package.PackageSent.Clipboard, + Package.PackageSent.ClipboardDragDrop, + Package.PackageSent.ClipboardDragDropEnd, + Package.PackageSent.ExplorerDragDrop, Event.inputEventCount, - Common.PackageSent.Nil); + Package.PackageSent.Nil); Log(log); - lastPackageSent = Common.PackageSent; // Copy data + lastPackageSent = Package.PackageSent; // Copy data } - if (!lastPackageReceived.Equals(Common.PackageReceived)) + if (!lastPackageReceived.Equals(Package.PackageReceived)) { log = string.Format( CultureInfo.CurrentCulture, "RECEIVED:" + HeaderRECEIVED, - Common.PackageReceived.Heartbeat, - Common.PackageReceived.Keyboard, - Common.PackageReceived.Mouse, - Common.PackageReceived.Hello, - Common.PackageReceived.Matrix, - Common.PackageReceived.ClipboardText, - Common.PackageReceived.ClipboardImage, - Common.PackageReceived.ByeBye, - Common.PackageReceived.Clipboard, - Common.PackageReceived.ClipboardDragDrop, - Common.PackageReceived.ClipboardDragDropEnd, - Common.PackageReceived.ExplorerDragDrop, + Package.PackageReceived.Heartbeat, + Package.PackageReceived.Keyboard, + Package.PackageReceived.Mouse, + Package.PackageReceived.Hello, + Package.PackageReceived.Matrix, + Package.PackageReceived.ClipboardText, + Package.PackageReceived.ClipboardImage, + Package.PackageReceived.ByeBye, + Package.PackageReceived.Clipboard, + Package.PackageReceived.ClipboardDragDrop, + Package.PackageReceived.ClipboardDragDropEnd, + Package.PackageReceived.ExplorerDragDrop, Event.invalidPackageCount, - Common.PackageReceived.Nil, + Package.PackageReceived.Nil, Receiver.processedPackageCount, Receiver.skippedPackageCount); Log(log); - lastPackageReceived = Common.PackageReceived; + lastPackageReceived = Package.PackageReceived; } } @@ -198,7 +198,6 @@ internal static class Logger } Logger.DumpProgramLogs(sb, level); - Logger.DumpOtherLogs(sb, level); Logger.DumpStaticTypes(sb, level); log = string.Format( @@ -209,9 +208,9 @@ internal static class Logger "Private Mem: " + (Process.GetCurrentProcess().PrivateMemorySize64 / 1024).ToString(CultureInfo.CurrentCulture) + "KB", sb.ToString()); - if (!string.IsNullOrEmpty(Common.myKey)) + if (!string.IsNullOrEmpty(Encryption.myKey)) { - log = log.Replace(Common.MyKey, Common.GetDebugInfo(Common.MyKey)); + log = log.Replace(Encryption.MyKey, Encryption.GetDebugInfo(Encryption.MyKey)); } log += Thread.DumpThreadsStack(); @@ -240,29 +239,32 @@ internal static class Logger _ = Logger.PrivateDump(sb, AllLogs, "[Program logs]\r\n===============\r\n", 0, level, false); } - internal static void DumpOtherLogs(StringBuilder sb, int level) - { - _ = Logger.PrivateDump(sb, new Common(), "[Other Logs]\r\n===============\r\n", 0, level, false); - } - internal static void DumpStaticTypes(StringBuilder sb, int level) { - sb.AppendLine($"[{nameof(DragDrop)}]\r\n==============="); - Logger.DumpType(sb, typeof(DragDrop), 0, level); - sb.AppendLine($"[{nameof(Event)}]\r\n==============="); - Logger.DumpType(sb, typeof(Event), 0, level); - sb.AppendLine($"[{nameof(Helper)}]\r\n==============="); - Logger.DumpType(sb, typeof(Helper), 0, level); - sb.AppendLine($"[{nameof(Launch)}]\r\n==============="); - Logger.DumpType(sb, typeof(Launch), 0, level); - sb.AppendLine($"[{nameof(Logger)}]\r\n==============="); - Logger.DumpType(sb, typeof(Logger), 0, level); - sb.AppendLine($"[{nameof(MachineStuff)}]\r\n==============="); - Logger.DumpType(sb, typeof(MachineStuff), 0, level); - sb.AppendLine($"[{nameof(Receiver)}]\r\n==============="); - Logger.DumpType(sb, typeof(Receiver), 0, level); - sb.AppendLine($"[{nameof(Service)}]\r\n==============="); - Logger.DumpType(sb, typeof(Service), 0, level); + var staticTypes = new List<Type> + { + typeof(Clipboard), + typeof(Common), + typeof(DragDrop), + typeof(Encryption), + typeof(Event), + typeof(IpcChannelHelper), + typeof(InitAndCleanup), + typeof(Helper), + typeof(Launch), + typeof(Logger), + typeof(MachineStuff), + typeof(Package), + typeof(Receiver), + typeof(Service), + typeof(WinAPI), + typeof(WM), + }; + foreach (var staticType in staticTypes) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[{staticType.Name}]\r\n==============="); + Logger.DumpType(sb, staticType, 0, level); + } } internal static bool PrivateDump(StringBuilder sb, object obj, string objName, int level, int maxLevel, bool stop) @@ -292,7 +294,7 @@ internal static class Logger // strArr[3] = t.FullName; strArr[4] = " = "; strArr[5] = objName.Equals("myKey", StringComparison.OrdinalIgnoreCase) - ? Common.GetDebugInfo(objString) + ? Encryption.GetDebugInfo(objString) : objName.Equals("lastClipboardObject", StringComparison.OrdinalIgnoreCase) ? string.Empty : objString diff --git a/src/modules/MouseWithoutBorders/App/Core/MOUSEDATA.cs b/src/modules/MouseWithoutBorders/App/Core/MOUSEDATA.cs new file mode 100644 index 0000000000..8f8e0f4267 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/MOUSEDATA.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +// <summary> +// Package format/conversion. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +[StructLayout(LayoutKind.Sequential)] +internal struct MOUSEDATA +{ + internal int X; + internal int Y; + internal int WheelDelta; + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Same name as in winAPI")] + internal int dwFlags; +} diff --git a/src/modules/MouseWithoutBorders/App/Core/MachineStuff.cs b/src/modules/MouseWithoutBorders/App/Core/MachineStuff.cs index 144102629f..add9a03b04 100644 --- a/src/modules/MouseWithoutBorders/App/Core/MachineStuff.cs +++ b/src/modules/MouseWithoutBorders/App/Core/MachineStuff.cs @@ -221,9 +221,9 @@ internal static class MachineStuff if (Setting.Values.BlockMouseAtCorners) { - lock (Common.SensitivePoints) + lock (WinAPI.SensitivePoints) { - foreach (Point p in Common.SensitivePoints) + foreach (Point p in WinAPI.SensitivePoints) { if (Math.Abs(p.X - x) < 100 && Math.Abs(p.Y - y) < 100) { @@ -793,8 +793,8 @@ internal static class MachineStuff internal static void ShowSetupForm(bool reopenSockets = false) { Logger.LogDebug("========== BEGIN THE SETUP EXPERIENCE ==========", true); - Setting.Values.MyKey = Common.MyKey = Common.CreateRandomKey(); - Common.GeneratedKey = true; + Setting.Values.MyKey = Encryption.MyKey = Encryption.CreateRandomKey(); + Encryption.GeneratedKey = true; if (Process.GetCurrentProcess().SessionId != NativeMethods.WTSGetActiveConsoleSessionId()) { @@ -992,7 +992,7 @@ internal static class MachineStuff Setting.Values.MatrixOneRow = !((package.Type & PackageType.MatrixTwoRowFlag) == PackageType.MatrixTwoRowFlag); MachineMatrix = MachineMatrix; // Save - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; UpdateClientSockets("UpdateMachineMatrix"); @@ -1044,7 +1044,7 @@ internal static class MachineStuff Common.MoveMouseToCenter(); } - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); Common.UpdateMultipleModeIconAndMenu(); } @@ -1067,7 +1067,7 @@ internal static class MachineStuff internal static void AssertOneInstancePerDesktopSession() { - string eventName = $"Global\\{Application.ProductName}-{FrmAbout.AssemblyVersion}-{Common.GetMyDesktop()}-{Common.CurrentProcess.SessionId}"; + string eventName = $"Global\\{Application.ProductName}-{FrmAbout.AssemblyVersion}-{WinAPI.GetMyDesktop()}-{Common.CurrentProcess.SessionId}"; oneInstanceCheck = new EventWaitHandle(false, EventResetMode.ManualReset, eventName, out bool created); if (!created) diff --git a/src/modules/MouseWithoutBorders/App/Core/Package.cs b/src/modules/MouseWithoutBorders/App/Core/Package.cs new file mode 100644 index 0000000000..54b5e3e467 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/Package.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// <summary> +// Package format/conversion. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal static class Package +{ + internal const byte PACKAGE_SIZE = 32; + internal const byte PACKAGE_SIZE_EX = 64; + private const byte WP_PACKAGE_SIZE = 6; + internal static PackageMonitor PackageSent; + internal static PackageMonitor PackageReceived; + internal static int PackageID; +} diff --git a/src/modules/MouseWithoutBorders/App/Core/PackageMonitor.cs b/src/modules/MouseWithoutBorders/App/Core/PackageMonitor.cs new file mode 100644 index 0000000000..e0ccf9ef75 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/PackageMonitor.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// <summary> +// Package format/conversion. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal struct PackageMonitor +{ + internal ulong Keyboard; + internal ulong Mouse; + internal ulong Heartbeat; + internal ulong ByeBye; + internal ulong Hello; + internal ulong Matrix; + internal ulong ClipboardText; + internal ulong ClipboardImage; + internal ulong Clipboard; + internal ulong ClipboardDragDrop; + internal ulong ClipboardDragDropEnd; + internal ulong ClipboardAsk; + internal ulong ExplorerDragDrop; + internal ulong Nil; + + internal PackageMonitor(ulong value) + { + ClipboardDragDrop = ClipboardDragDropEnd = ExplorerDragDrop = + Keyboard = Mouse = Heartbeat = ByeBye = Hello = Clipboard = + Matrix = ClipboardImage = ClipboardText = Nil = ClipboardAsk = value; + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/PackageType.cs b/src/modules/MouseWithoutBorders/App/Core/PackageType.cs new file mode 100644 index 0000000000..9b7b48fc12 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/PackageType.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// <summary> +// Package format/conversion. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal enum PackageType // : int +{ + // Search for PACKAGE_TYPE_RELATED before changing these! + Invalid = 0xFF, + + Error = 0xFE, + + Hi = 2, + Hello = 3, + ByeBye = 4, + + Heartbeat = 20, + Awake = 21, + HideMouse = 50, + Heartbeat_ex = 51, + Heartbeat_ex_l2 = 52, + Heartbeat_ex_l3 = 53, + + Clipboard = 69, + ClipboardDragDrop = 70, + ClipboardDragDropEnd = 71, + ExplorerDragDrop = 72, + ClipboardCapture = 73, + CaptureScreenCommand = 74, + ClipboardDragDropOperation = 75, + ClipboardDataEnd = 76, + MachineSwitched = 77, + ClipboardAsk = 78, + ClipboardPush = 79, + + NextMachine = 121, + Keyboard = 122, + Mouse = 123, + ClipboardText = 124, + ClipboardImage = 125, + + Handshake = 126, + HandshakeAck = 127, + + Matrix = 128, + MatrixSwapFlag = 2, + MatrixTwoRowFlag = 4, +} diff --git a/src/modules/MouseWithoutBorders/App/Core/Receiver.cs b/src/modules/MouseWithoutBorders/App/Core/Receiver.cs index 303bec4ff0..0a6aaad2ee 100644 --- a/src/modules/MouseWithoutBorders/App/Core/Receiver.cs +++ b/src/modules/MouseWithoutBorders/App/Core/Receiver.cs @@ -93,7 +93,7 @@ internal static class Receiver switch (package.Type) { case PackageType.Keyboard: - Common.PackageReceived.Keyboard++; + Package.PackageReceived.Keyboard++; if (package.Des == Common.MachineID || package.Des == ID.ALL) { JustGotAKey = Common.GetTick(); @@ -102,7 +102,7 @@ internal static class Receiver bool nonElevated = Common.RunWithNoAdminRight && false; if (nonElevated && Setting.Values.OneWayControlMode) { - if ((package.Kd.dwFlags & (int)Common.LLKHF.UP) == (int)Common.LLKHF.UP) + if ((package.Kd.dwFlags & (int)WM.LLKHF.UP) == (int)WM.LLKHF.UP) { Helper.ShowOneWayModeMessage(); } @@ -116,7 +116,7 @@ internal static class Receiver break; case PackageType.Mouse: - Common.PackageReceived.Mouse++; + Package.PackageReceived.Mouse++; if (package.Des == Common.MachineID || package.Des == ID.ALL) { @@ -127,16 +127,16 @@ internal static class Receiver // NOTE(@yuyoyuppe): disabled to drop elevation requirement bool nonElevated = Common.RunWithNoAdminRight && false; - if (nonElevated && Setting.Values.OneWayControlMode && package.Md.dwFlags != Common.WM_MOUSEMOVE) + if (nonElevated && Setting.Values.OneWayControlMode && package.Md.dwFlags != WM.WM_MOUSEMOVE) { if (!DragDrop.IsDropping) { - if (package.Md.dwFlags is Common.WM_LBUTTONDOWN or Common.WM_RBUTTONDOWN) + if (package.Md.dwFlags is WM.WM_LBUTTONDOWN or WM.WM_RBUTTONDOWN) { Helper.ShowOneWayModeMessage(); } } - else if (package.Md.dwFlags is Common.WM_LBUTTONUP or Common.WM_RBUTTONUP) + else if (package.Md.dwFlags is WM.WM_LBUTTONUP or WM.WM_RBUTTONUP) { DragDrop.IsDropping = false; } @@ -146,7 +146,7 @@ internal static class Receiver if (Math.Abs(package.Md.X) >= Event.MOVE_MOUSE_RELATIVE && Math.Abs(package.Md.Y) >= Event.MOVE_MOUSE_RELATIVE) { - if (package.Md.dwFlags == Common.WM_MOUSEMOVE) + if (package.Md.dwFlags == WM.WM_MOUSEMOVE) { InputSimulation.MoveMouseRelative( package.Md.X < 0 ? package.Md.X + Event.MOVE_MOUSE_RELATIVE : package.Md.X - Event.MOVE_MOUSE_RELATIVE, @@ -157,7 +157,7 @@ internal static class Receiver if (!p.IsEmpty) { - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; Logger.LogDebug(string.Format( CultureInfo.CurrentCulture, @@ -203,19 +203,19 @@ internal static class Receiver break; case PackageType.ExplorerDragDrop: - Common.PackageReceived.ExplorerDragDrop++; + Package.PackageReceived.ExplorerDragDrop++; DragDrop.DragDropStep03(package); break; case PackageType.Heartbeat: case PackageType.Heartbeat_ex: - Common.PackageReceived.Heartbeat++; + Package.PackageReceived.Heartbeat++; - Common.GeneratedKey = Common.GeneratedKey || package.Type == PackageType.Heartbeat_ex; + Encryption.GeneratedKey = Encryption.GeneratedKey || package.Type == PackageType.Heartbeat_ex; - if (Common.GeneratedKey) + if (Encryption.GeneratedKey) { - Setting.Values.MyKey = Common.MyKey; + Setting.Values.MyKey = Encryption.MyKey; Common.SendPackage(ID.ALL, PackageType.Heartbeat_ex_l2); } @@ -230,26 +230,26 @@ internal static class Receiver break; case PackageType.Heartbeat_ex_l2: - Common.GeneratedKey = true; - Setting.Values.MyKey = Common.MyKey; + Encryption.GeneratedKey = true; + Setting.Values.MyKey = Encryption.MyKey; Common.SendPackage(ID.ALL, PackageType.Heartbeat_ex_l3); break; case PackageType.Heartbeat_ex_l3: - Common.GeneratedKey = true; - Setting.Values.MyKey = Common.MyKey; + Encryption.GeneratedKey = true; + Setting.Values.MyKey = Encryption.MyKey; break; case PackageType.Awake: - Common.PackageReceived.Heartbeat++; + Package.PackageReceived.Heartbeat++; _ = MachineStuff.AddToMachinePool(package); Common.HumanBeingDetected(); break; case PackageType.Hello: - Common.PackageReceived.Hello++; + Package.PackageReceived.Hello++; Common.SendHeartBeat(); string newMachine = MachineStuff.AddToMachinePool(package); if (Setting.Values.MachineMatrixString == null) @@ -262,19 +262,19 @@ internal static class Receiver break; case PackageType.Hi: - Common.PackageReceived.Hello++; + Package.PackageReceived.Hello++; break; case PackageType.ByeBye: - Common.PackageReceived.ByeBye++; + Package.PackageReceived.ByeBye++; Common.ProcessByeByeMessage(package); break; case PackageType.Clipboard: - Common.PackageReceived.Clipboard++; + Package.PackageReceived.Clipboard++; if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) { - Common.clipboardCopiedTime = Common.GetTick(); + Clipboard.clipboardCopiedTime = Common.GetTick(); GetNameOfMachineWithClipboardData(package); SignalBigClipboardData(); } @@ -282,29 +282,29 @@ internal static class Receiver break; case PackageType.MachineSwitched: - if (Common.GetTick() - Common.clipboardCopiedTime < Common.BIG_CLIPBOARD_DATA_TIMEOUT && (package.Des == Common.MachineID)) + if (Common.GetTick() - Clipboard.clipboardCopiedTime < Clipboard.BIG_CLIPBOARD_DATA_TIMEOUT && (package.Des == Common.MachineID)) { - Common.clipboardCopiedTime = 0; - Common.GetRemoteClipboard("PackageType.MachineSwitched"); + Clipboard.clipboardCopiedTime = 0; + Clipboard.GetRemoteClipboard("PackageType.MachineSwitched"); } break; case PackageType.ClipboardCapture: - Common.PackageReceived.Clipboard++; + Package.PackageReceived.Clipboard++; if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) { if (package.Des == Common.MachineID || package.Des == ID.ALL) { GetNameOfMachineWithClipboardData(package); - Common.GetRemoteClipboard("mspaint," + Common.LastMachineWithClipboardData); + Clipboard.GetRemoteClipboard("mspaint," + Clipboard.LastMachineWithClipboardData); } } break; case PackageType.CaptureScreenCommand: - Common.PackageReceived.Clipboard++; + Package.PackageReceived.Clipboard++; if (package.Des == Common.MachineID || package.Des == ID.ALL) { Common.SendImage(package.Src, Common.CaptureScreen()); @@ -313,7 +313,7 @@ internal static class Receiver break; case PackageType.ClipboardAsk: - Common.PackageReceived.ClipboardAsk++; + Package.PackageReceived.ClipboardAsk++; if (package.Des == Common.MachineID) { @@ -326,10 +326,10 @@ internal static class Receiver Thread.UpdateThreads(thread); string remoteMachine = package.MachineName; - System.Net.Sockets.TcpClient client = Common.ConnectToRemoteClipboardSocket(remoteMachine); + System.Net.Sockets.TcpClient client = Clipboard.ConnectToRemoteClipboardSocket(remoteMachine); bool clientPushData = true; - if (Common.ShakeHand(ref remoteMachine, client.Client, out Stream enStream, out Stream deStream, ref clientPushData, ref package.PostAction)) + if (Clipboard.ShakeHand(ref remoteMachine, client.Client, out Stream enStream, out Stream deStream, ref clientPushData, ref package.PostAction)) { SocketStuff.SendClipboardData(client.Client, enStream); } @@ -344,35 +344,35 @@ internal static class Receiver break; case PackageType.ClipboardDragDrop: - Common.PackageReceived.ClipboardDragDrop++; + Package.PackageReceived.ClipboardDragDrop++; DragDrop.DragDropStep08(package); break; case PackageType.ClipboardDragDropOperation: - Common.PackageReceived.ClipboardDragDrop++; + Package.PackageReceived.ClipboardDragDrop++; DragDrop.DragDropStep08_2(package); break; case PackageType.ClipboardDragDropEnd: - Common.PackageReceived.ClipboardDragDropEnd++; + Package.PackageReceived.ClipboardDragDropEnd++; DragDrop.DragDropStep12(); break; case PackageType.ClipboardText: case PackageType.ClipboardImage: - Common.clipboardCopiedTime = 0; + Clipboard.clipboardCopiedTime = 0; if (package.Type == PackageType.ClipboardImage) { - Common.PackageReceived.ClipboardImage++; + Package.PackageReceived.ClipboardImage++; } else { - Common.PackageReceived.ClipboardText++; + Package.PackageReceived.ClipboardText++; } if (tcp != null) { - Common.ReceiveClipboardDataUsingTCP( + Clipboard.ReceiveClipboardDataUsingTCP( package, package.Type == PackageType.ClipboardImage, tcp); @@ -381,16 +381,16 @@ internal static class Receiver break; case PackageType.HideMouse: - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; Common.HideMouseCursor(true); Helper.MainFormDotEx(false); - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); break; default: if ((package.Type & PackageType.Matrix) == PackageType.Matrix) { - Common.PackageReceived.Matrix++; + Package.PackageReceived.Matrix++; MachineStuff.UpdateMachineMatrix(package); break; } @@ -405,11 +405,11 @@ internal static class Receiver internal static void GetNameOfMachineWithClipboardData(DATA package) { - Common.LastIDWithClipboardData = package.Src; - List<MachineInf> matchingMachines = MachineStuff.MachinePool.TryFindMachineByID(Common.LastIDWithClipboardData); + Clipboard.LastIDWithClipboardData = package.Src; + List<MachineInf> matchingMachines = MachineStuff.MachinePool.TryFindMachineByID(Clipboard.LastIDWithClipboardData); if (matchingMachines.Count >= 1) { - Common.LastMachineWithClipboardData = matchingMachines[0].Name.Trim(); + Clipboard.LastMachineWithClipboardData = matchingMachines[0].Name.Trim(); } /* diff --git a/src/modules/MouseWithoutBorders/App/Core/ShutdownWithPowerToys.cs b/src/modules/MouseWithoutBorders/App/Core/ShutdownWithPowerToys.cs new file mode 100644 index 0000000000..a221aa1733 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/ShutdownWithPowerToys.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; + +using ManagedCommon; +using Microsoft.PowerToys.Telemetry; + +namespace MouseWithoutBorders.Core; + +internal static class ShutdownWithPowerToys +{ + internal static void WaitForPowerToysRunner(ETWTrace etwTrace) + { + try + { + RunnerHelper.WaitForPowerToysRunnerExitFallback(() => + { + etwTrace?.Dispose(); + Common.MainForm.Quit(true, false); + }); + } + catch (Exception e) + { + Logger.Log(e); + } + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/VK.cs b/src/modules/MouseWithoutBorders/App/Core/VK.cs new file mode 100644 index 0000000000..239692abed --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/VK.cs @@ -0,0 +1,88 @@ +// 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. + +// <summary> +// Virtual key constants. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal enum VK : ushort +{ + CAPITAL = 0x14, + NUMLOCK = 0x90, + SHIFT = 0x10, + CONTROL = 0x11, + MENU = 0x12, + ESCAPE = 0x1B, + BACK = 0x08, + TAB = 0x09, + RETURN = 0x0D, + PRIOR = 0x21, + NEXT = 0x22, + END = 0x23, + HOME = 0x24, + LEFT = 0x25, + UP = 0x26, + RIGHT = 0x27, + DOWN = 0x28, + SELECT = 0x29, + PRINT = 0x2A, + EXECUTE = 0x2B, + SNAPSHOT = 0x2C, + INSERT = 0x2D, + DELETE = 0x2E, + HELP = 0x2F, + NUMPAD0 = 0x60, + NUMPAD1 = 0x61, + NUMPAD2 = 0x62, + NUMPAD3 = 0x63, + NUMPAD4 = 0x64, + NUMPAD5 = 0x65, + NUMPAD6 = 0x66, + NUMPAD7 = 0x67, + NUMPAD8 = 0x68, + NUMPAD9 = 0x69, + MULTIPLY = 0x6A, + ADD = 0x6B, + SEPARATOR = 0x6C, + SUBTRACT = 0x6D, + DECIMAL = 0x6E, + DIVIDE = 0x6F, + F1 = 0x70, + F2 = 0x71, + F3 = 0x72, + F4 = 0x73, + F5 = 0x74, + F6 = 0x75, + F7 = 0x76, + F8 = 0x77, + F9 = 0x78, + F10 = 0x79, + F11 = 0x7A, + F12 = 0x7B, + OEM_1 = 0xBA, + OEM_PLUS = 0xBB, + OEM_COMMA = 0xBC, + OEM_MINUS = 0xBD, + OEM_PERIOD = 0xBE, + OEM_2 = 0xBF, + OEM_3 = 0xC0, + MEDIA_NEXT_TRACK = 0xB0, + MEDIA_PREV_TRACK = 0xB1, + MEDIA_STOP = 0xB2, + MEDIA_PLAY_PAUSE = 0xB3, + LWIN = 0x5B, + RWIN = 0x5C, + LSHIFT = 0xA0, + RSHIFT = 0xA1, + LCONTROL = 0xA2, + RCONTROL = 0xA3, + LMENU = 0xA4, + RMENU = 0xA5, +} diff --git a/src/modules/MouseWithoutBorders/App/Core/WM.cs b/src/modules/MouseWithoutBorders/App/Core/WM.cs new file mode 100644 index 0000000000..e93897e93b --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/WM.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +// <summary> +// Virtual key constants. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +internal partial class WM +{ + internal const ushort KEYEVENTF_KEYDOWN = 0x0001; + internal const ushort KEYEVENTF_KEYUP = 0x0002; + + internal const int WH_MOUSE = 7; + internal const int WH_KEYBOARD = 2; + internal const int WH_MOUSE_LL = 14; + internal const int WH_KEYBOARD_LL = 13; + + internal const int WM_MOUSEMOVE = 0x200; + internal const int WM_LBUTTONDOWN = 0x201; + internal const int WM_RBUTTONDOWN = 0x204; + internal const int WM_MBUTTONDOWN = 0x207; + internal const int WM_XBUTTONDOWN = 0x20B; + internal const int WM_LBUTTONUP = 0x202; + internal const int WM_RBUTTONUP = 0x205; + internal const int WM_MBUTTONUP = 0x208; + internal const int WM_XBUTTONUP = 0x20C; + internal const int WM_LBUTTONDBLCLK = 0x203; + internal const int WM_RBUTTONDBLCLK = 0x206; + internal const int WM_MBUTTONDBLCLK = 0x209; + internal const int WM_MOUSEWHEEL = 0x020A; + internal const int WM_MOUSEHWHEEL = 0x020E; + + internal const int WM_KEYDOWN = 0x100; + internal const int WM_KEYUP = 0x101; + internal const int WM_SYSKEYDOWN = 0x104; + internal const int WM_SYSKEYUP = 0x105; + + [Flags] + internal enum LLKHF + { + EXTENDED = 0x01, + INJECTED = 0x10, + ALTDOWN = 0x20, + UP = 0x80, + } +} diff --git a/src/modules/MouseWithoutBorders/App/Core/WinAPI.cs b/src/modules/MouseWithoutBorders/App/Core/WinAPI.cs new file mode 100644 index 0000000000..4d14dcb973 --- /dev/null +++ b/src/modules/MouseWithoutBorders/App/Core/WinAPI.cs @@ -0,0 +1,359 @@ +// 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.Diagnostics; +using System.Drawing; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Windows.Forms; + +using MouseWithoutBorders.Class; + +// <summary> +// Screen/Desktop helper functions. +// </summary> +// <history> +// 2008 created by Truong Do (ductdo). +// 2009-... modified by Truong Do (TruongDo). +// 2023- Included in PowerToys. +// </history> +namespace MouseWithoutBorders.Core; + +// Desktops, and GetScreenConfig routines +internal static class WinAPI +{ + private static MyRectangle newDesktopBounds; + private static MyRectangle newPrimaryScreenBounds; + private static string activeDesktop; + + private static string ActiveDesktop => WinAPI.activeDesktop; + + internal static void SystemEvents_DisplaySettingsChanged(object sender, EventArgs e) + { + GetScreenConfig(); + } + + internal static readonly List<Point> SensitivePoints = new(); + + private static bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref NativeMethods.RECT lprcMonitor, IntPtr dwData) + { + // lprcMonitor is wrong!!! => using GetMonitorInfo(...) + // Log(String.Format( CultureInfo.CurrentCulture,"MONITOR: l{0}, t{1}, r{2}, b{3}", lprcMonitor.Left, lprcMonitor.Top, lprcMonitor.Right, lprcMonitor.Bottom)); + NativeMethods.MonitorInfoEx mi = default; + mi.cbSize = Marshal.SizeOf(mi); + _ = NativeMethods.GetMonitorInfo(hMonitor, ref mi); + + try + { + // For logging only + _ = NativeMethods.GetDpiForMonitor(hMonitor, 0, out uint dpiX, out uint dpiY); + Logger.Log(string.Format(CultureInfo.CurrentCulture, "MONITOR: ({0}, {1}, {2}, {3}). DPI: ({4}, {5})", mi.rcMonitor.Left, mi.rcMonitor.Top, mi.rcMonitor.Right, mi.rcMonitor.Bottom, dpiX, dpiY)); + } + catch (DllNotFoundException) + { + Logger.Log("GetDpiForMonitor is unsupported in Windows 7 and lower."); + } + catch (EntryPointNotFoundException) + { + Logger.Log("GetDpiForMonitor is unsupported in Windows 7 and lower."); + } + catch (Exception e) + { + Logger.Log(e); + } + + if (mi.rcMonitor.Left == 0 && mi.rcMonitor.Top == 0 && mi.rcMonitor.Right != 0 && mi.rcMonitor.Bottom != 0) + { + // Primary screen + _ = Interlocked.Exchange(ref Common.screenWidth, mi.rcMonitor.Right - mi.rcMonitor.Left); + _ = Interlocked.Exchange(ref Common.screenHeight, mi.rcMonitor.Bottom - mi.rcMonitor.Top); + + newPrimaryScreenBounds.Left = mi.rcMonitor.Left; + newPrimaryScreenBounds.Top = mi.rcMonitor.Top; + newPrimaryScreenBounds.Right = mi.rcMonitor.Right; + newPrimaryScreenBounds.Bottom = mi.rcMonitor.Bottom; + } + else + { + if (mi.rcMonitor.Left < newDesktopBounds.Left) + { + newDesktopBounds.Left = mi.rcMonitor.Left; + } + + if (mi.rcMonitor.Top < newDesktopBounds.Top) + { + newDesktopBounds.Top = mi.rcMonitor.Top; + } + + if (mi.rcMonitor.Right > newDesktopBounds.Right) + { + newDesktopBounds.Right = mi.rcMonitor.Right; + } + + if (mi.rcMonitor.Bottom > newDesktopBounds.Bottom) + { + newDesktopBounds.Bottom = mi.rcMonitor.Bottom; + } + } + + lock (SensitivePoints) + { + SensitivePoints.Add(new Point(mi.rcMonitor.Left, mi.rcMonitor.Top)); + SensitivePoints.Add(new Point(mi.rcMonitor.Right, mi.rcMonitor.Top)); + SensitivePoints.Add(new Point(mi.rcMonitor.Right, mi.rcMonitor.Bottom)); + SensitivePoints.Add(new Point(mi.rcMonitor.Left, mi.rcMonitor.Bottom)); + } + + return true; + } + + internal static void GetScreenConfig() + { + try + { + Logger.LogDebug("==================== GetScreenConfig started"); + newDesktopBounds = new MyRectangle(); + newPrimaryScreenBounds = new MyRectangle(); + newDesktopBounds.Left = newPrimaryScreenBounds.Left = Screen.PrimaryScreen.Bounds.Left; + newDesktopBounds.Top = newPrimaryScreenBounds.Top = Screen.PrimaryScreen.Bounds.Top; + newDesktopBounds.Right = newPrimaryScreenBounds.Right = Screen.PrimaryScreen.Bounds.Right; + newDesktopBounds.Bottom = newPrimaryScreenBounds.Bottom = Screen.PrimaryScreen.Bounds.Bottom; + + Logger.Log(string.Format( + CultureInfo.CurrentCulture, + "logon = {0} PrimaryScreenBounds = {1},{2},{3},{4} desktopBounds = {5},{6},{7},{8}", + Common.RunOnLogonDesktop, + WinAPI.newPrimaryScreenBounds.Left, + WinAPI.newPrimaryScreenBounds.Top, + WinAPI.newPrimaryScreenBounds.Right, + WinAPI.newPrimaryScreenBounds.Bottom, + WinAPI.newDesktopBounds.Left, + WinAPI.newDesktopBounds.Top, + WinAPI.newDesktopBounds.Right, + WinAPI.newDesktopBounds.Bottom)); + +#if USE_MANAGED_ROUTINES + // Managed routines do not work well when running on secure desktop:( + screenWidth = Screen.PrimaryScreen.Bounds.Width; + screenHeight = Screen.PrimaryScreen.Bounds.Height; + screenCount = Screen.AllScreens.Length; + for (int i = 0; i < Screen.AllScreens.Length; i++) + { + if (Screen.AllScreens[i].Bounds.Left < desktopBounds.Left) desktopBounds.Left = Screen.AllScreens[i].Bounds.Left; + if (Screen.AllScreens[i].Bounds.Top < desktopBounds.Top) desktopBounds.Top = Screen.AllScreens[i].Bounds.Top; + if (Screen.AllScreens[i].Bounds.Right > desktopBounds.Right) desktopBounds.Right = Screen.AllScreens[i].Bounds.Right; + if (Screen.AllScreens[i].Bounds.Bottom > desktopBounds.Bottom) desktopBounds.Bottom = Screen.AllScreens[i].Bounds.Bottom; + } +#else + lock (SensitivePoints) + { + SensitivePoints.Clear(); + } + + NativeMethods.EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, MonitorEnumProc, IntPtr.Zero); + + // 1000 calls to EnumDisplayMonitors cost a dozen of milliseconds +#endif + Interlocked.Exchange(ref MachineStuff.desktopBounds, newDesktopBounds); + Interlocked.Exchange(ref MachineStuff.primaryScreenBounds, newPrimaryScreenBounds); + + Logger.Log(string.Format( + CultureInfo.CurrentCulture, + "logon = {0} PrimaryScreenBounds = {1},{2},{3},{4} desktopBounds = {5},{6},{7},{8}", + Common.RunOnLogonDesktop, + MachineStuff.PrimaryScreenBounds.Left, + MachineStuff.PrimaryScreenBounds.Top, + MachineStuff.PrimaryScreenBounds.Right, + MachineStuff.PrimaryScreenBounds.Bottom, + MachineStuff.DesktopBounds.Left, + MachineStuff.DesktopBounds.Top, + MachineStuff.DesktopBounds.Right, + MachineStuff.DesktopBounds.Bottom)); + + Logger.Log("==================== GetScreenConfig ended"); + } + catch (Exception e) + { + Logger.Log(e); + } + } + +#if USING_SCREEN_SAVER_ROUTINES + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern int PostMessage(IntPtr hWnd, int wMsg, int wParam, int lParam); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern IntPtr OpenDesktop(string hDesktop, int Flags, bool Inherit, UInt32 DesiredAccess); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern bool CloseDesktop(IntPtr hDesktop); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern bool EnumDesktopWindows( IntPtr hDesktop, EnumDesktopWindowsProc callback, IntPtr lParam); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern bool SystemParametersInfo(int uAction, int uParam, ref int pvParam, int flags); + + private delegate bool EnumDesktopWindowsProc(IntPtr hDesktop, IntPtr lParam); + private const int WM_CLOSE = 16; + private const int SPI_GETSCREENSAVERRUNNING = 114; + + internal static bool IsScreenSaverRunning() + { + int isRunning = 0; + SystemParametersInfo(SPI_GETSCREENSAVERRUNNING, 0,ref isRunning, 0); + return (isRunning != 0); + } + + internal static void CloseScreenSaver() + { + IntPtr hDesktop = OpenDesktop("Screen-saver", 0, false, DESKTOP_READOBJECTS | DESKTOP_WRITEOBJECTS); + if (hDesktop != IntPtr.Zero) + { + LogDebug("Closing screen saver..."); + EnumDesktopWindows(hDesktop, new EnumDesktopWindowsProc(CloseScreenSaverFunc), IntPtr.Zero); + CloseDesktop(hDesktop); + } + } + + private static bool CloseScreenSaverFunc(IntPtr hWnd, IntPtr lParam) + { + if (IsWindowVisible(hWnd)) + { + LogDebug("Posting WM_CLOSE to " + hWnd.ToString(CultureInfo.InvariantCulture)); + PostMessage(hWnd, WM_CLOSE, 0, 0); + } + return true; + } +#endif + + internal static string GetMyDesktop() + { + byte[] arThreadDesktop = new byte[256]; + IntPtr hD = NativeMethods.GetThreadDesktop(NativeMethods.GetCurrentThreadId()); + if (hD != IntPtr.Zero) + { + _ = NativeMethods.GetUserObjectInformation(hD, NativeMethods.UOI_NAME, arThreadDesktop, arThreadDesktop.Length, out _); + return Common.GetString(arThreadDesktop).Replace("\0", string.Empty); + } + + return string.Empty; + } + + internal static string GetInputDesktop() + { + byte[] arInputDesktop = new byte[256]; + IntPtr hD = NativeMethods.OpenInputDesktop(0, false, NativeMethods.DESKTOP_READOBJECTS); + if (hD != IntPtr.Zero) + { + _ = NativeMethods.GetUserObjectInformation(hD, NativeMethods.UOI_NAME, arInputDesktop, arInputDesktop.Length, out _); + return Common.GetString(arInputDesktop).Replace("\0", string.Empty); + } + + return string.Empty; + } + + private static void StartMMService(string desktopToRunMouseWithoutBordersOn) + { + if (!Common.RunWithNoAdminRight) + { + Logger.LogDebug("*** Starting on active Desktop: " + desktopToRunMouseWithoutBordersOn); + Service.StartMouseWithoutBordersService(desktopToRunMouseWithoutBordersOn); + } + } + + internal static void CheckForDesktopSwitchEvent(bool cleanupIfExit) + { + try + { + if (!IsMyDesktopActive() || Common.CurrentProcess.SessionId != NativeMethods.WTSGetActiveConsoleSessionId()) + { + Helper.RunDDHelper(true); + int waitCount = 20; + + while (NativeMethods.WTSGetActiveConsoleSessionId() == 0xFFFFFFFF && waitCount > 0) + { + waitCount--; + Logger.LogDebug("The session is detached/attached."); + Thread.Sleep(500); + } + + string myDesktop = GetMyDesktop(); + activeDesktop = GetInputDesktop(); + + Logger.LogDebug("*** Active Desktop = " + activeDesktop); + Logger.LogDebug("*** My Desktop = " + myDesktop); + + if (myDesktop.Equals(activeDesktop, StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("*** Active Desktop == My Desktop (TS session)"); + } + + if (!activeDesktop.Equals("winlogon", StringComparison.OrdinalIgnoreCase) && + !activeDesktop.Equals("default", StringComparison.OrdinalIgnoreCase) && + !activeDesktop.Equals("disconnect", StringComparison.OrdinalIgnoreCase)) + { + try + { + StartMMService(activeDesktop); + } + catch (Exception e) + { + Logger.Log($"{nameof(CheckForDesktopSwitchEvent)}: {e}"); + } + } + else + { + if (!myDesktop.Equals(activeDesktop, StringComparison.OrdinalIgnoreCase)) + { + Logger.Log("*** Active Desktop <> My Desktop"); + } + + uint sid = NativeMethods.WTSGetActiveConsoleSessionId(); + + if (Process.GetProcessesByName(Common.BinaryName).Any(p => (uint)p.SessionId == sid)) + { + Logger.Log("Found MouseWithoutBorders on the active session!"); + } + else + { + Logger.Log("MouseWithoutBorders not found on the active session!"); + StartMMService(null); + } + } + + if (!myDesktop.Equals("winlogon", StringComparison.OrdinalIgnoreCase) && + !myDesktop.Equals("default", StringComparison.OrdinalIgnoreCase)) + { + Logger.LogDebug("*** Desktop inactive, exiting: " + myDesktop); + Setting.Values.LastX = Common.JUST_GOT_BACK_FROM_SCREEN_SAVER; + if (cleanupIfExit) + { + InitAndCleanup.Cleanup(); + } + + Process.GetCurrentProcess().KillProcess(); + } + } + } + catch (Exception e) + { + Logger.Log(e); + } + } + + private static Point p; + + internal static bool IsMyDesktopActive() + { + return NativeMethods.GetCursorPos(ref p); + } +} diff --git a/src/modules/MouseWithoutBorders/App/Form/Settings/SettingsFormPage.cs b/src/modules/MouseWithoutBorders/App/Form/Settings/SettingsFormPage.cs index 39574ac8fe..81c04982a9 100644 --- a/src/modules/MouseWithoutBorders/App/Form/Settings/SettingsFormPage.cs +++ b/src/modules/MouseWithoutBorders/App/Form/Settings/SettingsFormPage.cs @@ -42,7 +42,7 @@ namespace MouseWithoutBorders protected string GetSecureKey() { - return Common.MyKey; + return Encryption.MyKey; } private void BackButton_Click(object sender, EventArgs e) diff --git a/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage2a.cs b/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage2a.cs index c86df58143..5fcee5dc54 100644 --- a/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage2a.cs +++ b/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage2a.cs @@ -89,8 +89,8 @@ namespace MouseWithoutBorders { if (GetSecureKey() != SecurityCodeField.Text) { - Common.MyKey = Regex.Replace(SecurityCodeField.Text, @"\s+", string.Empty); - SecurityCode = Common.MyKey; + Encryption.MyKey = Regex.Replace(SecurityCodeField.Text, @"\s+", string.Empty); + SecurityCode = Encryption.MyKey; } MachineStuff.MachineMatrix = new string[MachineStuff.MAX_MACHINE] { ComputerNameField.Text.Trim().ToUpper(CultureInfo.CurrentCulture), Common.MachineName.Trim(), string.Empty, string.Empty }; diff --git a/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage2b.cs b/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage2b.cs index 5649ae8d7f..bb802aa159 100644 --- a/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage2b.cs +++ b/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage2b.cs @@ -2,6 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using MouseWithoutBorders.Core; + namespace MouseWithoutBorders { public partial class SetupPage2b : SettingsFormPage diff --git a/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage3a.cs b/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage3a.cs index 84d464d33d..97884d4821 100644 --- a/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage3a.cs +++ b/src/modules/MouseWithoutBorders/App/Form/Settings/SetupPage3a.cs @@ -84,7 +84,7 @@ namespace MouseWithoutBorders if ((connectedClientSocket = Common.GetConnectedClientSocket()) != null) { ShowStatus($"Connected from local IP Address: {connectedClientSocket.Address}."); - Common.UpdateMachineTimeAndID(); + InitAndCleanup.UpdateMachineTimeAndID(); Common.MMSleep(1); connected = true; diff --git a/src/modules/MouseWithoutBorders/App/Form/frmAbout.cs b/src/modules/MouseWithoutBorders/App/Form/frmAbout.cs index 3a7ae9901c..069c428589 100644 --- a/src/modules/MouseWithoutBorders/App/Form/frmAbout.cs +++ b/src/modules/MouseWithoutBorders/App/Form/frmAbout.cs @@ -15,6 +15,8 @@ using System.Globalization; using System.Reflection; using System.Windows.Forms; +using MouseWithoutBorders.Core; + namespace MouseWithoutBorders { internal partial class FrmAbout : System.Windows.Forms.Form, IDisposable diff --git a/src/modules/MouseWithoutBorders/App/Form/frmMatrix.cs b/src/modules/MouseWithoutBorders/App/Form/frmMatrix.cs index ca095d8140..703ad8ef91 100644 --- a/src/modules/MouseWithoutBorders/App/Form/frmMatrix.cs +++ b/src/modules/MouseWithoutBorders/App/Form/frmMatrix.cs @@ -22,6 +22,8 @@ using Microsoft.PowerToys.Telemetry; // </history> using MouseWithoutBorders.Class; using MouseWithoutBorders.Core; + +using Clipboard = MouseWithoutBorders.Core.Clipboard; using Timer = System.Windows.Forms.Timer; [module: SuppressMessage("Microsoft.Globalization", "CA1300:SpecifyMessageBoxOptions", Scope = "member", Target = "MouseWithoutBorders.frmMatrix.#buttonOK_Click(System.Object,System.EventArgs)", Justification = "Dotnet port with style preservation")] @@ -110,7 +112,7 @@ namespace MouseWithoutBorders { SocketStuff.InvalidKeyFound = false; showInvalidKeyMessage = false; - Common.ReopenSocketDueToReadError = true; + InitAndCleanup.ReopenSocketDueToReadError = true; Common.ReopenSockets(true); for (int i = 0; i < 10; i++) @@ -133,7 +135,7 @@ namespace MouseWithoutBorders internal void UpdateKeyTextBox() { _ = Helper.GetUserName(); - textBoxEnc.Text = Common.MyKey; + textBoxEnc.Text = Encryption.MyKey; } private void InitAll() @@ -503,19 +505,19 @@ namespace MouseWithoutBorders private bool UpdateKey(string newKey) { - if (!Common.IsKeyValid(newKey, out string rv)) + if (!Encryption.IsKeyValid(newKey, out string rv)) { ShowKeyErrorMsg(rv); return false; } - if (!newKey.Equals(Common.MyKey, StringComparison.OrdinalIgnoreCase)) + if (!newKey.Equals(Encryption.MyKey, StringComparison.OrdinalIgnoreCase)) { - Common.MyKey = newKey; - Common.GeneratedKey = false; + Encryption.MyKey = newKey; + Encryption.GeneratedKey = false; } - Common.MagicNumber = Common.Get24BitHash(Common.MyKey); + Encryption.MagicNumber = Encryption.Get24BitHash(Encryption.MyKey); return true; } @@ -780,7 +782,7 @@ namespace MouseWithoutBorders ShowUpdateMessage(); - Common.HasSwitchedMachineSinceLastCopy = true; + Clipboard.HasSwitchedMachineSinceLastCopy = true; } private void CheckBoxDisableCAD_CheckedChanged(object sender, EventArgs e) @@ -1114,10 +1116,10 @@ namespace MouseWithoutBorders if (MessageBox.Show(message, Application.ProductName, MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2) == DialogResult.Yes) { - Setting.Values.MyKey = Common.MyKey = Common.CreateRandomKey(); - textBoxEnc.Text = Common.MyKey; + Setting.Values.MyKey = Encryption.MyKey = Encryption.CreateRandomKey(); + textBoxEnc.Text = Encryption.MyKey; checkBoxShowKey.Checked = true; - Common.GeneratedKey = true; + Encryption.GeneratedKey = true; ButtonOK_Click(null, null); Common.ShowToolTip("New security key was generated, update other machines to the same key.", 10000, ToolTipIcon.Info, false); } diff --git a/src/modules/MouseWithoutBorders/App/Form/frmMessage.cs b/src/modules/MouseWithoutBorders/App/Form/frmMessage.cs index 2abb18f932..e258df9709 100644 --- a/src/modules/MouseWithoutBorders/App/Form/frmMessage.cs +++ b/src/modules/MouseWithoutBorders/App/Form/frmMessage.cs @@ -6,6 +6,8 @@ using System; using System.Globalization; using System.Windows.Forms; +using MouseWithoutBorders.Core; + namespace MouseWithoutBorders { public partial class FrmMessage : System.Windows.Forms.Form diff --git a/src/modules/MouseWithoutBorders/App/Form/frmMouseCursor.cs b/src/modules/MouseWithoutBorders/App/Form/frmMouseCursor.cs index b4c2f13efd..e5b8095d18 100644 --- a/src/modules/MouseWithoutBorders/App/Form/frmMouseCursor.cs +++ b/src/modules/MouseWithoutBorders/App/Form/frmMouseCursor.cs @@ -6,6 +6,7 @@ using System; using System.Windows.Forms; using MouseWithoutBorders.Class; +using MouseWithoutBorders.Core; namespace MouseWithoutBorders { diff --git a/src/modules/MouseWithoutBorders/App/Form/frmScreen.cs b/src/modules/MouseWithoutBorders/App/Form/frmScreen.cs index b01a653f60..ca123ec850 100644 --- a/src/modules/MouseWithoutBorders/App/Form/frmScreen.cs +++ b/src/modules/MouseWithoutBorders/App/Form/frmScreen.cs @@ -139,13 +139,13 @@ namespace MouseWithoutBorders { if (cleanup) { - Common.Cleanup(); + InitAndCleanup.Cleanup(); } Helper.WndProcCounter++; if (!Common.RunOnScrSaverDesktop) { - Common.ReleaseAllKeys(); + InitAndCleanup.ReleaseAllKeys(); } Helper.RunDDHelper(true); @@ -318,7 +318,7 @@ namespace MouseWithoutBorders try { - if (!Common.IsMyDesktopActive() || Common.CurrentProcess.SessionId != NativeMethods.WTSGetActiveConsoleSessionId()) + if (!WinAPI.IsMyDesktopActive() || Common.CurrentProcess.SessionId != NativeMethods.WTSGetActiveConsoleSessionId()) { myDesktopNotActive = true; @@ -348,7 +348,7 @@ namespace MouseWithoutBorders Common.Hook?.ResetLastSwitchKeys(); }); - Common.CheckForDesktopSwitchEvent(true); + WinAPI.CheckForDesktopSwitchEvent(true); } } else @@ -369,21 +369,21 @@ namespace MouseWithoutBorders if (myDesktopNotActive) { myDesktopNotActive = false; - Common.MyKey = Setting.Values.MyKey; + Encryption.MyKey = Setting.Values.MyKey; } MachineStuff.UpdateMachinePoolStringSetting(); - if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && (Setting.Values.FirstRun || Common.KeyCorrupted)) + if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && (Setting.Values.FirstRun || Encryption.KeyCorrupted)) { if (!shownSetupFormOneTime) { shownSetupFormOneTime = true; MachineStuff.ShowMachineMatrix(); - if (Common.KeyCorrupted && !Setting.Values.FirstRun) + if (Encryption.KeyCorrupted && !Setting.Values.FirstRun) { - Common.KeyCorrupted = false; + Encryption.KeyCorrupted = false; string msg = "The security key is corrupted for some reason, please re-setup."; MessageBox.Show(msg, Application.ProductName, MessageBoxButtons.OK, MessageBoxIcon.Warning); } @@ -412,7 +412,7 @@ namespace MouseWithoutBorders count = 0; - Common.InitDone = true; + InitAndCleanup.InitDone = true; #if SHOW_ON_WINLOGON if (Common.RunOnLogonDesktop) { @@ -423,39 +423,39 @@ namespace MouseWithoutBorders if ((count % 2) == 0) { - if (Common.PleaseReopenSocket == 10 || (Common.PleaseReopenSocket > 0 && count > 0 && count % 300 == 0)) + if (InitAndCleanup.PleaseReopenSocket == 10 || (InitAndCleanup.PleaseReopenSocket > 0 && count > 0 && count % 300 == 0)) { - if (!Common.AtLeastOneSocketEstablished() || Common.PleaseReopenSocket == 10) + if (!Common.AtLeastOneSocketEstablished() || InitAndCleanup.PleaseReopenSocket == 10) { Thread.Sleep(1000); - if (Common.PleaseReopenSocket > 0) + if (InitAndCleanup.PleaseReopenSocket > 0) { - Common.PleaseReopenSocket--; + InitAndCleanup.PleaseReopenSocket--; } // Double check. if (!Common.AtLeastOneSocketEstablished()) { Common.GetMachineName(); - Logger.LogDebug("Common.pleaseReopenSocket: " + Common.PleaseReopenSocket.ToString(CultureInfo.InvariantCulture)); + Logger.LogDebug("Common.pleaseReopenSocket: " + InitAndCleanup.PleaseReopenSocket.ToString(CultureInfo.InvariantCulture)); Common.ReopenSockets(false); MachineStuff.NewDesMachineID = Common.DesMachineID = Common.MachineID; } } else { - Common.PleaseReopenSocket = 0; + InitAndCleanup.PleaseReopenSocket = 0; } } - if (Common.PleaseReopenSocket == Common.REOPEN_WHEN_HOTKEY) + if (InitAndCleanup.PleaseReopenSocket == InitAndCleanup.REOPEN_WHEN_HOTKEY) { - Common.PleaseReopenSocket = 0; + InitAndCleanup.PleaseReopenSocket = 0; Common.ReopenSockets(true); } - else if (Common.PleaseReopenSocket == Common.REOPEN_WHEN_WSAECONNRESET) + else if (InitAndCleanup.PleaseReopenSocket == InitAndCleanup.REOPEN_WHEN_WSAECONNRESET) { - Common.PleaseReopenSocket = 0; + InitAndCleanup.PleaseReopenSocket = 0; Thread.Sleep(1000); MachineStuff.UpdateClientSockets("REOPEN_WHEN_WSAECONNRESET"); } @@ -490,9 +490,9 @@ namespace MouseWithoutBorders if (count == 600) { - if (!Common.GeneratedKey) + if (!Encryption.GeneratedKey) { - Common.MyKey = Setting.Values.MyKey; + Encryption.MyKey = Setting.Values.MyKey; if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop) { @@ -505,7 +505,7 @@ namespace MouseWithoutBorders Common.ShowToolTip("The security key must be auto generated in one of the machines.", 10000); } } - else if (!Common.KeyCorrupted && !Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && !Setting.Values.FirstRun && Common.AtLeastOneSocketConnected()) + else if (!Encryption.KeyCorrupted && !Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && !Setting.Values.FirstRun && Common.AtLeastOneSocketConnected()) { int myKeyDaysToExpire = Setting.Values.MyKeyDaysToExpire; @@ -531,7 +531,7 @@ namespace MouseWithoutBorders #if SHOW_ON_WINLOGON // if (Common.RunOnLogonDesktop) ShowMouseWithoutBordersUiOnWinLogonDesktop(false); #endif - Common.CheckForDesktopSwitchEvent(true); + WinAPI.CheckForDesktopSwitchEvent(true); MachineStuff.UpdateClientSockets("helperTimer_Tick"); // Sockets may be closed by the remote host when both machines switch desktop at the same time. } @@ -582,7 +582,7 @@ namespace MouseWithoutBorders int rv = 0; - if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && Common.IsMyDesktopActive() && (rv = Helper.SendMessageToHelper(0x400, IntPtr.Zero, IntPtr.Zero)) <= 0) + if (!Common.RunOnLogonDesktop && !Common.RunOnScrSaverDesktop && WinAPI.IsMyDesktopActive() && (rv = Helper.SendMessageToHelper(0x400, IntPtr.Zero, IntPtr.Zero)) <= 0) { Logger.TelemetryLogTrace($"{Helper.HELPER_FORM_TEXT} not found: {rv}", SeverityLevel.Warning); } diff --git a/src/modules/MouseWithoutBorders/App/Helper/MouseWithoutBordersHelper.csproj b/src/modules/MouseWithoutBorders/App/Helper/MouseWithoutBordersHelper.csproj index f97c0c44f3..4b3fc7fd50 100644 --- a/src/modules/MouseWithoutBorders/App/Helper/MouseWithoutBordersHelper.csproj +++ b/src/modules/MouseWithoutBorders/App/Helper/MouseWithoutBordersHelper.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> @@ -11,7 +11,7 @@ <AssemblyName>PowerToys.MouseWithoutBordersHelper</AssemblyName> <DisableWinExeOutputInference>true</DisableWinExeOutputInference> <ImportWindowsDesktopTargets>true</ImportWindowsDesktopTargets> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ApplicationIcon>..\Logo.ico</ApplicationIcon> <NoWin32Manifest>true</NoWin32Manifest> @@ -49,6 +49,9 @@ <Compile Include="..\Class\IClipboardHelper.cs"> <Link>IClipboardHelper.cs</Link> </Compile> + <Compile Include="..\Core\IpcChannelHelper.cs"> + <Link>IpcChannelHelper.cs</Link> + </Compile> </ItemGroup> <ItemGroup> diff --git a/src/modules/MouseWithoutBorders/App/MouseWithoutBorders.csproj b/src/modules/MouseWithoutBorders/App/MouseWithoutBorders.csproj index 4c2e35c6ad..acc66deea6 100644 --- a/src/modules/MouseWithoutBorders/App/MouseWithoutBorders.csproj +++ b/src/modules/MouseWithoutBorders/App/MouseWithoutBorders.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> @@ -11,7 +11,7 @@ <AssemblyName>PowerToys.MouseWithoutBorders</AssemblyName> <DisableWinExeOutputInference>true</DisableWinExeOutputInference> <ImportWindowsDesktopTargets>true</ImportWindowsDesktopTargets> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ApplicationIcon>Logo.ico</ApplicationIcon> <NoWin32Manifest>true</NoWin32Manifest> diff --git a/src/modules/MouseWithoutBorders/App/MouseWithoutBorders.exe.manifest b/src/modules/MouseWithoutBorders/App/MouseWithoutBorders.exe.manifest index 63feb1d04f..30503d0fa8 100644 --- a/src/modules/MouseWithoutBorders/App/MouseWithoutBorders.exe.manifest +++ b/src/modules/MouseWithoutBorders/App/MouseWithoutBorders.exe.manifest @@ -56,6 +56,7 @@ <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/MouseWithoutBorders/App/Service/MouseWithoutBordersService.csproj b/src/modules/MouseWithoutBorders/App/Service/MouseWithoutBordersService.csproj index 1431445733..53af161e16 100644 --- a/src/modules/MouseWithoutBorders/App/Service/MouseWithoutBordersService.csproj +++ b/src/modules/MouseWithoutBorders/App/Service/MouseWithoutBordersService.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>true</ImplicitUsings> @@ -12,7 +12,7 @@ <AssemblyName>PowerToys.MouseWithoutBordersService</AssemblyName> <DisableWinExeOutputInference>true</DisableWinExeOutputInference> <ImportWindowsDesktopTargets>true</ImportWindowsDesktopTargets> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ApplicationIcon>..\Logo.ico</ApplicationIcon> <NoWin32Manifest>true</NoWin32Manifest> diff --git a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersClipboardFileTransferEvent.cs b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersClipboardFileTransferEvent.cs index ed758ebe63..6f50b623a4 100644 --- a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersClipboardFileTransferEvent.cs +++ b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersClipboardFileTransferEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace MouseWithoutBorders.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MouseWithoutBordersClipboardFileTransferEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersDragAndDropEvent.cs b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersDragAndDropEvent.cs index 9159f76c43..04fb2f90ea 100644 --- a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersDragAndDropEvent.cs +++ b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersDragAndDropEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace MouseWithoutBorders.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MouseWithoutBordersDragAndDropEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersMultipleModeEvent.cs b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersMultipleModeEvent.cs index 644098aaca..96098f4f39 100644 --- a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersMultipleModeEvent.cs +++ b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersMultipleModeEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace MouseWithoutBorders.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MouseWithoutBordersMultipleModeEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIOpenedEvent.cs b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIOpenedEvent.cs index 24b024dfdf..48b73da828 100644 --- a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIOpenedEvent.cs +++ b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIOpenedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace MouseWithoutBorders.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MouseWithoutBordersOldUIOpenedEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIQuitEvent.cs b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIQuitEvent.cs index 0987460180..73a284a3ac 100644 --- a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIQuitEvent.cs +++ b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIQuitEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace MouseWithoutBorders.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MouseWithoutBordersOldUIQuitEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIReconfigureEvent.cs b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIReconfigureEvent.cs index fa40d32e92..04bfa92496 100644 --- a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIReconfigureEvent.cs +++ b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersOldUIReconfigureEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace MouseWithoutBorders.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MouseWithoutBordersOldUIReconfigureEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersStartedEvent.cs b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersStartedEvent.cs index 2bd96a5406..a97a89dbb8 100644 --- a/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersStartedEvent.cs +++ b/src/modules/MouseWithoutBorders/App/Telemetry/MouseWithoutBordersStartedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace MouseWithoutBorders.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MouseWithoutBordersStartedEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/MouseWithoutBorders/ModuleInterface/MouseWithoutBordersModuleInterface.vcxproj b/src/modules/MouseWithoutBorders/ModuleInterface/MouseWithoutBordersModuleInterface.vcxproj index 8f04969947..acb106b8f5 100644 --- a/src/modules/MouseWithoutBorders/ModuleInterface/MouseWithoutBordersModuleInterface.vcxproj +++ b/src/modules/MouseWithoutBorders/ModuleInterface/MouseWithoutBordersModuleInterface.vcxproj @@ -1,18 +1,18 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{2833C9C6-AB32-4048-A5C7-A70898337B57}</ProjectGuid> <Keyword>Win32Proj</Keyword> <ProjectName>MouseWithoutBordersModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -22,13 +22,13 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.MouseWithoutBordersModuleInterface</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>FANCYZONES_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -50,10 +50,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -64,17 +64,17 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/MouseWithoutBorders/ModuleInterface/dllmain.cpp b/src/modules/MouseWithoutBorders/ModuleInterface/dllmain.cpp index 67799aa6c5..29d7a781ae 100644 --- a/src/modules/MouseWithoutBorders/ModuleInterface/dllmain.cpp +++ b/src/modules/MouseWithoutBorders/ModuleInterface/dllmain.cpp @@ -13,6 +13,7 @@ #include <common/utils/winapi_error.h> #include <common/utils/processApi.h> #include <common/utils/elevation.h> +#include <common/utils/logger_helper.h> HINSTANCE g_hInst_MouseWithoutBorders = 0; @@ -409,9 +410,12 @@ public: { app_name = L"MouseWithoutBorders"; app_key = app_name; - std::filesystem::path logFilePath(PTSettingsHelper::get_module_save_folder_location(app_key)); - logFilePath.append(LogSettings::mouseWithoutBordersLogPath); - Logger::init(LogSettings::mouseWithoutBordersLoggerName, logFilePath.wstring(), PTSettingsHelper::get_log_settings_file_location()); + + LoggerHelpers::init_logger(app_key, L"ModuleInterface", LogSettings::mouseWithoutBordersLoggerName); + + std::filesystem::path oldLogPath(PTSettingsHelper::get_module_save_folder_location(app_key)); + oldLogPath.append("LogsModuleInterface"); + LoggerHelpers::delete_old_log_folder(oldLogPath); try { @@ -552,6 +556,61 @@ public: return m_enabled; } + virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override + { + constexpr size_t num_hotkeys = 4; // We have 4 hotkeys + + if (hotkeys && buffer_size >= num_hotkeys) + { + PowerToysSettings::PowerToyValues values = PowerToysSettings::PowerToyValues::load_from_settings_file(MODULE_NAME); + + // Cache the raw JSON object to avoid multiple parsing + json::JsonObject root_json = values.get_raw_json(); + json::JsonObject properties_json = root_json.GetNamedObject(L"properties", json::JsonObject{}); + + size_t hotkey_index = 0; + + // Helper lambda to extract hotkey from JSON properties + auto extract_hotkey = [&](const wchar_t* property_name) -> Hotkey { + if (properties_json.HasKey(property_name)) + { + try + { + json::JsonObject hotkey_json = properties_json.GetNamedObject(property_name); + + // Extract hotkey properties directly from JSON + bool win = hotkey_json.GetNamedBoolean(L"win", false); + bool ctrl = hotkey_json.GetNamedBoolean(L"ctrl", false); + bool alt = hotkey_json.GetNamedBoolean(L"alt", false); + bool shift = hotkey_json.GetNamedBoolean(L"shift", false); + unsigned char key = static_cast<unsigned char>( + hotkey_json.GetNamedNumber(L"code", 0)); + + return { win, ctrl, shift, alt, key }; + } + catch (...) + { + // If parsing individual hotkey fails, use defaults + return { false, false, false, false, 0 }; + } + } + else + { + // Property doesn't exist, use defaults + return { false, false, false, false, 0 }; + } + }; + + // Extract all hotkeys using the optimized helper + hotkeys[hotkey_index++] = extract_hotkey(L"ToggleEasyMouseShortcut"); // [0] Toggle Easy Mouse + hotkeys[hotkey_index++] = extract_hotkey(L"LockMachineShortcut"); // [1] Lock Machine + hotkeys[hotkey_index++] = extract_hotkey(L"Switch2AllPCShortcut"); // [2] Switch to All PCs + hotkeys[hotkey_index++] = extract_hotkey(L"ReconnectShortcut"); // [3] Reconnect + } + + return num_hotkeys; + } + void launch_add_firewall_process() { Logger::trace(L"Starting Process to add firewall rule"); @@ -567,7 +626,7 @@ public: executable_args.append(L"\" & echo \"Adding an inbound firewall rule for PowerToys.MouseWithoutBorders.exe\""); executable_args.append(L" & netsh advfirewall firewall add rule name=\"PowerToys.MouseWithoutBorders\" dir=in action=allow program=\""); executable_args.append(executable_path); - executable_args.append(L"\" enable=yes remoteip=LocalSubnet profile=any protocol=tcp & pause\""); + executable_args.append(L"\" enable=yes remoteip=any profile=any protocol=tcp & pause\""); SHELLEXECUTEINFOW sei{ sizeof(sei) }; sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI }; diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/Logger.PrivateDump.expected.txt b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/Logger.PrivateDump.expected.txt index 3c933bfff1..3a36e9b3d2 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/Logger.PrivateDump.expected.txt +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/Logger.PrivateDump.expected.txt @@ -1,9 +1,8 @@ [Program logs] =============== = System.String[] -[Other Logs] +[Clipboard] =============== - = MouseWithoutBorders.Common Comma = System.Char[] --System.Char[] = System.Char[]: N/A Star = System.Char[] @@ -26,6 +25,14 @@ ClipboardThreadOldLock = Lock --s_contentionCount = 0 --s_maxSpinCount = 22 --s_minSpinCountForAdaptiveSpin = -100 +BIG_CLIPBOARD_DATA_TIMEOUT = 30000 +MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP = 1048576 +MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT = 104857600 +TEXT_HEADER_SIZE = 12 +DATA_SIZE = 48 +TEXT_TYPE_SEP = {4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F} +[Common] +=============== screenWidth = 0 screenHeight = 0 lastX = 0 @@ -68,6 +75,23 @@ avgSendTime = 0 maxSendTime = 0 totalSendCount = 0 totalSendTime = 0 +TOGGLE_ICONS_SIZE = 4 +ICON_ONE = 0 +ICON_ALL = 1 +ICON_SMALL_CLIPBOARD = 2 +ICON_BIG_CLIPBOARD = 3 +ICON_ERROR = 4 +JUST_GOT_BACK_FROM_SCREEN_SAVER = 9999 +NETWORK_STREAM_BUF_SIZE = 1048576 +[DragDrop] +=============== +isDragging = False +dragDropStep05ExCalledByIpc = 0 +isDropping = False +dragMachine = NONE +<MouseDown>k__BackingField = False +[Encryption] +=============== magicNumber = 0 ran = System.Random --_impl = System.Random+XoshiroImpl @@ -99,119 +123,18 @@ LegalKeyDictionary = Concurrent.ConcurrentDictionary`2[System.String,System.Byte --_budget = ???????????? --_growLockArray = True --_comparerIsDefaultForClasses = False -initDone = False -REOPEN_WHEN_WSAECONNRESET = -10054 -REOPEN_WHEN_HOTKEY = -10055 -PleaseReopenSocket = 0 -ReopenSocketDueToReadError = False -<LastResumeSuspendTime>k__BackingField = ???????????? ---_dateData = ???????????? ---MinValue = 01/01/0001 00:00:00 ---MaxValue = 31/12/9999 23:59:59 ---UnixEpoch = 01/01/1970 00:00:00 -lastReleaseAllKeysCall = 0 -PackageSent = MouseWithoutBorders.PackageMonitor ---Keyboard = 0 ---Mouse = 0 ---Heartbeat = 0 ---ByeBye = 0 ---Hello = 0 ---Matrix = 0 ---ClipboardText = 0 ---ClipboardImage = 0 ---Clipboard = 0 ---ClipboardDragDrop = 0 ---ClipboardDragDropEnd = 0 ---ClipboardAsk = 0 ---ExplorerDragDrop = 0 ---Nil = 0 -PackageReceived = MouseWithoutBorders.PackageMonitor ---Keyboard = 0 ---Mouse = 0 ---Heartbeat = 0 ---ByeBye = 0 ---Hello = 0 ---Matrix = 0 ---ClipboardText = 0 ---ClipboardImage = 0 ---Clipboard = 0 ---ClipboardDragDrop = 0 ---ClipboardDragDropEnd = 0 ---ClipboardAsk = 0 ---ExplorerDragDrop = 0 ---Nil = 0 -PackageID = 0 -SensitivePoints = Generic.List`1[Point] ---_items = Point[] -----System.Drawing.Point[] = Point[]: N/A ---_size = 0 ---_version = 0 ---s_emptyArray = Point[] -----System.Drawing.Point[] = Point[]: N/A -p = {X=0,Y=0} ---x = 0 ---y = 0 ---Empty = {X=0,Y=0} -<IpcChannelCreated>k__BackingField = False -BIG_CLIPBOARD_DATA_TIMEOUT = 30000 -MAX_CLIPBOARD_DATA_SIZE_CAN_BE_SENT_INSTANTLY_TCP = 1048576 -MAX_CLIPBOARD_FILE_SIZE_CAN_BE_SENT = 104857600 -TEXT_HEADER_SIZE = 12 -DATA_SIZE = 48 -TEXT_TYPE_SEP = {4CFF57F7-BEDD-43d5-AE8F-27A61E886F2F} -TOGGLE_ICONS_SIZE = 4 -ICON_ONE = 0 -ICON_ALL = 1 -ICON_SMALL_CLIPBOARD = 2 -ICON_BIG_CLIPBOARD = 3 -ICON_ERROR = 4 -JUST_GOT_BACK_FROM_SCREEN_SAVER = 9999 -NETWORK_STREAM_BUF_SIZE = 1048576 SymAlBlockSize = 16 PW_LENGTH = 16 -PACKAGE_SIZE = 32 -PACKAGE_SIZE_EX = 64 -WP_PACKAGE_SIZE = 6 -KEYEVENTF_KEYDOWN = 1 -KEYEVENTF_KEYUP = 2 -WH_MOUSE = 7 -WH_KEYBOARD = 2 -WH_MOUSE_LL = 14 -WH_KEYBOARD_LL = 13 -WM_MOUSEMOVE = 512 -WM_LBUTTONDOWN = 513 -WM_RBUTTONDOWN = 516 -WM_MBUTTONDOWN = 519 -WM_XBUTTONDOWN = 523 -WM_LBUTTONUP = 514 -WM_RBUTTONUP = 517 -WM_MBUTTONUP = 520 -WM_XBUTTONUP = 524 -WM_LBUTTONDBLCLK = 515 -WM_RBUTTONDBLCLK = 518 -WM_MBUTTONDBLCLK = 521 -WM_MOUSEWHEEL = 522 -WM_KEYDOWN = 256 -WM_KEYUP = 257 -WM_SYSKEYDOWN = 260 -WM_SYSKEYUP = 261 -[DragDrop] -=============== -isDragging = False -dragDropStep05ExCalledByIpc = 0 -isDropping = False -dragMachine = NONE -<MouseDown>k__BackingField = False [Event] =============== -KeybdPackage = MouseWithoutBorders.DATA +KeybdPackage = MouseWithoutBorders.Core.DATA --Type = 0 --Id = 0 --Src = NONE --Des = NONE --DateTime = 0 ---Kd = MouseWithoutBorders.KEYBDDATA ---Md = MouseWithoutBorders.MOUSEDATA +--Kd = MouseWithoutBorders.Core.KEYBDDATA +--Md = MouseWithoutBorders.Core.MOUSEDATA --Machine1 = NONE --Machine2 = NONE --Machine3 = NONE @@ -221,14 +144,14 @@ KeybdPackage = MouseWithoutBorders.DATA --machineNameP2 = 0 --machineNameP3 = 0 --machineNameP4 = 0 -MousePackage = MouseWithoutBorders.DATA +MousePackage = MouseWithoutBorders.Core.DATA --Type = 0 --Id = 0 --Src = NONE --Des = NONE --DateTime = 0 ---Kd = MouseWithoutBorders.KEYBDDATA ---Md = MouseWithoutBorders.MOUSEDATA +--Kd = MouseWithoutBorders.Core.KEYBDDATA +--Md = MouseWithoutBorders.Core.MOUSEDATA --Machine1 = NONE --Machine2 = NONE --Machine3 = NONE @@ -249,6 +172,22 @@ actualLastPos = {X=0,Y=0} --Empty = {X=0,Y=0} myLastX = 0 myLastY = 0 +[IpcChannelHelper] +=============== +<IpcChannelCreated>k__BackingField = False +[InitAndCleanup] +=============== +initDone = False +REOPEN_WHEN_WSAECONNRESET = -10054 +REOPEN_WHEN_HOTKEY = -10055 +PleaseReopenSocket = 0 +ReopenSocketDueToReadError = False +<LastResumeSuspendTime>k__BackingField = ???????????? +--_dateData = ???????????? +--MinValue = 01/01/0001 00:00:00 +--MaxValue = 31/12/9999 23:59:59 +--UnixEpoch = 01/01/1970 00:00:00 +lastReleaseAllKeysCall = 0 [Helper] =============== signalHelperToExit = False @@ -292,7 +231,7 @@ LogCounter = Concurrent.ConcurrentDictionary`2[System.String,32] allLogsIndex = 0 lastHour = 0 exceptionCount = 0 -lastPackageSent = MouseWithoutBorders.PackageMonitor +lastPackageSent = MouseWithoutBorders.Core.PackageMonitor --Keyboard = 0 --Mouse = 0 --Heartbeat = 0 @@ -307,7 +246,7 @@ lastPackageSent = MouseWithoutBorders.PackageMonitor --ClipboardAsk = 0 --ExplorerDragDrop = 0 --Nil = 0 -lastPackageReceived = MouseWithoutBorders.PackageMonitor +lastPackageReceived = MouseWithoutBorders.Core.PackageMonitor --Keyboard = 0 --Mouse = 0 --Heartbeat = 0 @@ -362,6 +301,42 @@ MAX_SOCKET = 8 HEARTBEAT_TIMEOUT = 1500000 SKIP_PIXELS = 1 JUMP_PIXELS = 2 +[Package] +=============== +PackageSent = MouseWithoutBorders.Core.PackageMonitor +--Keyboard = 0 +--Mouse = 0 +--Heartbeat = 0 +--ByeBye = 0 +--Hello = 0 +--Matrix = 0 +--ClipboardText = 0 +--ClipboardImage = 0 +--Clipboard = 0 +--ClipboardDragDrop = 0 +--ClipboardDragDropEnd = 0 +--ClipboardAsk = 0 +--ExplorerDragDrop = 0 +--Nil = 0 +PackageReceived = MouseWithoutBorders.Core.PackageMonitor +--Keyboard = 0 +--Mouse = 0 +--Heartbeat = 0 +--ByeBye = 0 +--Hello = 0 +--Matrix = 0 +--ClipboardText = 0 +--ClipboardImage = 0 +--Clipboard = 0 +--ClipboardDragDrop = 0 +--ClipboardDragDropEnd = 0 +--ClipboardAsk = 0 +--ExplorerDragDrop = 0 +--Nil = 0 +PackageID = 0 +PACKAGE_SIZE = 32 +PACKAGE_SIZE_EX = 64 +WP_PACKAGE_SIZE = 6 [Receiver] =============== QUEUE_SIZE = 50 @@ -432,3 +407,42 @@ lastStartServiceTime = ???????????? --MinValue = 01/01/0001 00:00:00 --MaxValue = 31/12/9999 23:59:59 --UnixEpoch = 01/01/1970 00:00:00 +[WinAPI] +=============== +SensitivePoints = Generic.List`1[Point] +--_items = Point[] +----System.Drawing.Point[] = Point[]: N/A +--_size = 0 +--_version = 0 +--s_emptyArray = Point[] +----System.Drawing.Point[] = Point[]: N/A +p = {X=0,Y=0} +--x = 0 +--y = 0 +--Empty = {X=0,Y=0} +[WM] +=============== +KEYEVENTF_KEYDOWN = 1 +KEYEVENTF_KEYUP = 2 +WH_MOUSE = 7 +WH_KEYBOARD = 2 +WH_MOUSE_LL = 14 +WH_KEYBOARD_LL = 13 +WM_MOUSEMOVE = 512 +WM_LBUTTONDOWN = 513 +WM_RBUTTONDOWN = 516 +WM_MBUTTONDOWN = 519 +WM_XBUTTONDOWN = 523 +WM_LBUTTONUP = 514 +WM_RBUTTONUP = 517 +WM_MBUTTONUP = 520 +WM_XBUTTONUP = 524 +WM_LBUTTONDBLCLK = 515 +WM_RBUTTONDBLCLK = 518 +WM_MBUTTONDBLCLK = 521 +WM_MOUSEWHEEL = 522 +WM_MOUSEHWHEEL = 526 +WM_KEYDOWN = 256 +WM_KEYUP = 257 +WM_SYSKEYDOWN = 260 +WM_SYSKEYUP = 261 diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/LoggerTests.cs b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/LoggerTests.cs index 326dac70fa..f7cd3b461a 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/LoggerTests.cs +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/Core/LoggerTests.cs @@ -50,7 +50,7 @@ public static class LoggerTests var lines = log.Split("\r\n"); // some parts of the PrivateDump output are impossible to reproduce - - // e.g. random numbers, system timestamps, thread ids, etc, so we'll mask them + // e.g. random numbers, system timestamps, thread ids, etc., so we'll mask them var maskPrefixes = new string[] { "----_s0 = ", @@ -117,7 +117,6 @@ public static class LoggerTests // copied from DumpObjects in Logger.cs var sb = new StringBuilder(1000000); Logger.DumpProgramLogs(sb, settingsDumpObjectsLevel); - Logger.DumpOtherLogs(sb, settingsDumpObjectsLevel); Logger.DumpStaticTypes(sb, settingsDumpObjectsLevel); var actual = sb.ToString(); diff --git a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/MouseWithoutBorders.UnitTests.csproj b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/MouseWithoutBorders.UnitTests.csproj index fc151fcdfa..c604365e96 100644 --- a/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/MouseWithoutBorders.UnitTests.csproj +++ b/src/modules/MouseWithoutBorders/MouseWithoutBorders.UnitTests/MouseWithoutBorders.UnitTests.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj index 38d8f640f0..895430df0b 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/NewPlus.ShellExtension.win10.vcxproj @@ -1,20 +1,20 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h new.base.rc new.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h new.base.rc new.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>17.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{0db0f63a-d2f8-4da3-a650-2d0b8724218e}</ProjectGuid> <RootNamespace>NewPlusShellExtensionWin10</RootNamespace> - <WindowsTargetPlatformVersion>10.0.22621.0</WindowsTargetPlatformVersion> + <WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> @@ -34,7 +34,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.NewPlus.ShellExtension.win10</TargetName> <LinkIncremental /> <IgnoreImportLibrary /> @@ -46,7 +46,7 @@ <PreprocessorDefinitions>_DEBUG;NEWPLUSSHELLEXTENSIONWIN10_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> <PrecompiledHeader>Use</PrecompiledHeader> - <AdditionalIncludeDirectories>..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories);..\NewShellExtensionContextMenu</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories);..\NewShellExtensionContextMenu;$(MSBuildThisFileDirectory)</AdditionalIncludeDirectories> <CompileAsWinRT>false</CompileAsWinRT> <LanguageStandard>stdcpplatest</LanguageStandard> </ClCompile> @@ -67,7 +67,7 @@ <PreprocessorDefinitions>NDEBUG;NEWPLUSSHELLEXTENSIONWIN10_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> <PrecompiledHeader>Use</PrecompiledHeader> - <AdditionalIncludeDirectories>..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories);..\NewShellExtensionContextMenu</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories);..\NewShellExtensionContextMenu;$(MSBuildThisFileDirectory)</AdditionalIncludeDirectories> <CompileAsWinRT>false</CompileAsWinRT> <LanguageStandard>stdcpplatest</LanguageStandard> </ClCompile> @@ -92,6 +92,7 @@ <ClInclude Include="..\NewShellExtensionContextMenu\template_folder.h" /> <ClInclude Include="..\NewShellExtensionContextMenu\template_item.h" /> <ClInclude Include="..\NewShellExtensionContextMenu\trace.h" /> + <ClInclude Include="..\NewShellExtensionContextMenu\Helpers.h" /> <ClInclude Include="dll_main.h" /> <ClInclude Include="Generated Files\resource.h" /> <ClInclude Include="pch.h" /> @@ -99,7 +100,7 @@ <ClInclude Include="shell_context_menu_win10.h" /> </ItemGroup> <ItemGroup> - <ClCompile Include="..\..\powerrename\lib\Helpers.cpp" /> + <ClCompile Include="..\NewShellExtensionContextMenu\Helpers.cpp" /> <ClCompile Include="..\NewShellExtensionContextMenu\new_utilities.cpp" /> <ClCompile Include="..\NewShellExtensionContextMenu\powertoys_module.cpp" /> <ClCompile Include="..\NewShellExtensionContextMenu\settings.cpp" /> @@ -124,13 +125,13 @@ </None> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> @@ -139,17 +140,17 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/packages.config b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/packages.config +++ b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/pch.h b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/pch.h index 5018653070..711063d0cb 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu.win10/pch.h +++ b/src/modules/NewPlus/NewShellExtensionContextMenu.win10/pch.h @@ -3,6 +3,7 @@ #pragma once #define WIN32_LEAN_AND_MEAN +#define NOMINMAX #define NOMCX #define NOHELP #define NOCOMM diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.cpp b/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.cpp new file mode 100644 index 0000000000..945a516ca4 --- /dev/null +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.cpp @@ -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. + +#include "pch.h" +#include "Helpers.h" +#include <regex> + +// Minimal subset of PowerRename Helpers used by NewPlus +// This is a copy from PowerRename main branch to avoid cross-module dependencies + +HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime) +{ + std::locale::global(std::locale("")); + HRESULT hr = E_INVALIDARG; + if (source && wcslen(source) > 0) + { + std::wstring res(source); + wchar_t replaceTerm[MAX_PATH] = { 0 }; + wchar_t formattedDate[MAX_PATH] = { 0 }; + + wchar_t localeName[LOCALE_NAME_MAX_LENGTH]; + if (GetUserDefaultLocaleName(localeName, LOCALE_NAME_MAX_LENGTH) == 0) + { + StringCchCopy(localeName, LOCALE_NAME_MAX_LENGTH, L"en_US"); + } + + int hour12 = (fileTime.wHour % 12); + if (hour12 == 0) + { + hour12 = 12; + } + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%04d"), L"$01", fileTime.wYear); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YYYY"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", (fileTime.wYear % 100)); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YY"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", (fileTime.wYear % 10)); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$Y"), replaceTerm); + + GetDateFormatEx(localeName, NULL, &fileTime, L"MMMM", formattedDate, MAX_PATH, NULL); + formattedDate[0] = towupper(formattedDate[0]); + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MMMM"), replaceTerm); + + GetDateFormatEx(localeName, NULL, &fileTime, L"MMM", formattedDate, MAX_PATH, NULL); + formattedDate[0] = towupper(formattedDate[0]); + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MMM"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMonth); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$MM"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMonth); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$M"), replaceTerm); + + GetDateFormatEx(localeName, NULL, &fileTime, L"dddd", formattedDate, MAX_PATH, NULL); + formattedDate[0] = towupper(formattedDate[0]); + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DDDD"), replaceTerm); + + GetDateFormatEx(localeName, NULL, &fileTime, L"ddd", formattedDate, MAX_PATH, NULL); + formattedDate[0] = towupper(formattedDate[0]); + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DDD"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wDay); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DD"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wDay); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$D"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", hour12); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$HH"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", hour12); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$H"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", (fileTime.wHour < 12) ? L"AM" : L"PM"); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$TT"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", (fileTime.wHour < 12) ? L"am" : L"pm"); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$tt"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wHour); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$hh"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wHour); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$h"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMinute); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$mm"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMinute); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$m"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wSecond); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ss"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wSecond); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$s"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%03d"), L"$01", fileTime.wMilliseconds); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$fff"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wMilliseconds / 10); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$ff"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMilliseconds / 100); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$f"), replaceTerm); + + hr = StringCchCopy(result, cchMax, res.c_str()); + } + + return hr; +} diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.h b/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.h new file mode 100644 index 0000000000..540478856e --- /dev/null +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/Helpers.h @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once + +// Minimal subset of PowerRename Helpers used by NewPlus +// This is a copy from PowerRename's main branch to avoid cross-module dependencies +HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime); diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj index 9ac251c8ec..f899012bf2 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h new.base.rc new.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h new.base.rc new.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>17.0</VCProjectVersion> @@ -12,17 +13,16 @@ <WindowsTargetPlatformVersion>10.0.20348.0</WindowsTargetPlatformVersion> <ProjectName>NewPlus.ShellExtension</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -37,14 +37,14 @@ <PropertyGroup Label="UserMacros" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'"> <TargetExt>.dll</TargetExt> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.NewPlus.ShellExtension</TargetName> <IntDir>$(SolutionDir)$(Platform)\$(Configuration)\TemporaryBuild\obj\$(ProjectName)\</IntDir> <LinkIncremental /> <IgnoreImportLibrary /> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'"> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.NewPlus.ShellExtension</TargetName> <IntDir>$(SolutionDir)$(Platform)\$(Configuration)\TemporaryBuild\obj\$(ProjectName)\</IntDir> <LinkIncremental /> @@ -59,7 +59,7 @@ <PrecompiledHeader>Use</PrecompiledHeader> <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> <LanguageStandard>stdcpplatest</LanguageStandard> - <AdditionalIncludeDirectories>..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -90,7 +90,7 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv</Command> <PrecompiledHeader>Use</PrecompiledHeader> <PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile> <LanguageStandard>stdcpplatest</LanguageStandard> - <AdditionalIncludeDirectories>..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -114,6 +114,7 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv</Command> </ItemDefinitionGroup> <ItemGroup> <ClInclude Include="dll_main.h" /> + <ClInclude Include="Helpers.h" /> <ClInclude Include="helpers_filesystem.h" /> <ClInclude Include="helpers_variables.h" /> <ClInclude Include="shell_context_menu.h" /> @@ -123,6 +124,7 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv</Command> <ClInclude Include="settings.h" /> <ClInclude Include="trace.h" /> <ClInclude Include="new_utilities.h" /> + <ClInclude Include="RuntimeRegistration.h" /> <ClInclude Include="resource.base.h" /> <ClInclude Include="template_folder.h" /> <ClInclude Include="pch.h" /> @@ -130,7 +132,7 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv</Command> <ClInclude Include="template_item.h" /> </ItemGroup> <ItemGroup> - <ClCompile Include="..\..\powerrename\lib\Helpers.cpp" /> + <ClCompile Include="Helpers.cpp" /> <ClCompile Include="new_utilities.cpp" /> <ClCompile Include="shell_context_menu.cpp" /> <ClCompile Include="shell_context_sub_menu.cpp" /> @@ -165,19 +167,19 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv</Command> </CopyFileToFolders> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\version\version.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\version\version.vcxproj"> <Project>{cc6e41ac-8174-4e8a-8d22-85dd7f4851df}</Project> </ProjectReference> </ItemGroup> @@ -229,15 +231,15 @@ MakeAppx.exe pack /d . /p $(OutDir)NewPlusPackage.msix /nv</Command> <None Include="AppxManifest.xml" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters index 7d014eb00f..d8b2eaf9ea 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/NewShellExtensionContextMenu.vcxproj.filters @@ -84,6 +84,9 @@ <ClInclude Include="helpers_variables.h"> <Filter>Header Files</Filter> </ClInclude> + <ClInclude Include="RuntimeRegistration.h"> + <Filter>Header Files</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <None Include="packages.config" /> diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h b/src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h new file mode 100644 index 0000000000..91596d9e3d --- /dev/null +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/RuntimeRegistration.h @@ -0,0 +1,36 @@ +// Header-only runtime registration for New+ Win10 context menu. +#pragma once + +#include <windows.h> +#include <string> +#include <common/utils/shell_ext_registration.h> + +// Provided by dll_main.cpp +extern HMODULE module_instance_handle; + +namespace NewPlusRuntimeRegistration +{ + namespace { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{FF90D477-E32A-4BE8-8CC5-A502A97F5401}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\NewPlus"; + spec.sentinelValue = L"ContextMenuRegisteredWin10"; + spec.dllFileCandidates = { L"PowerToys.NewPlus.ShellExtension.win10.dll" }; + spec.contextMenuHandlerKeyPaths = { L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\NewPlusShellExtensionWin10" }; + spec.friendlyName = L"NewPlus Shell Extension Win10"; + return spec; + } + } + + inline bool EnsureRegisteredWin10() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), module_instance_handle); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/helpers_variables.h b/src/modules/NewPlus/NewShellExtensionContextMenu/helpers_variables.h index 63f23c3e86..1d511f2afe 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/helpers_variables.h +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/helpers_variables.h @@ -1,7 +1,7 @@ #pragma once #include <regex> -#include "..\..\powerrename\lib\Helpers.h" +#include "Helpers.h" #include "helpers_filesystem.h" #pragma comment(lib, "Pathcch.lib") diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h b/src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h index 50f92562d2..02874ab8f3 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/new_utilities.h @@ -302,9 +302,9 @@ namespace newplus::utilities POINT mouse_position; GetCursorPos(&mouse_position); mouse_position.x -= GetSystemMetrics(SM_CXMENUSIZE); - mouse_position.x = max(mouse_position.x, 20); + mouse_position.x = (std::max)(mouse_position.x, 20L); mouse_position.y -= GetSystemMetrics(SM_CXMENUSIZE)/2; - mouse_position.y = max(mouse_position.y, 20); + mouse_position.y = (std::max)(mouse_position.y, 20L); POINT position[] = { mouse_position }; folder_view->SelectAndPositionItems(1, shell_item_to_select_and_position, position, common_select_flags | SVSI_POSITIONITEM); } diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/packages.config b/src/modules/NewPlus/NewShellExtensionContextMenu/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/packages.config +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/pch.h b/src/modules/NewPlus/NewShellExtensionContextMenu/pch.h index b766a837d5..13093e1d08 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/pch.h +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/pch.h @@ -3,6 +3,7 @@ #pragma once #define WIN32_LEAN_AND_MEAN +#define NOMINMAX #define NOMCX #define NOHELP #define NOCOMM @@ -13,6 +14,7 @@ #include <shellapi.h> #include <Windows.h> #include <shlobj.h> +#include <algorithm> #include <vector> #include <system_error> #include <memory> diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp b/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp index 303f072e3b..b63b755d86 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/powertoys_module.cpp @@ -16,10 +16,31 @@ #include "trace.h" #include "new_utilities.h" #include "Generated Files/resource.h" +#include "RuntimeRegistration.h" // Note: Settings are managed via Settings and UI Settings class NewModule : public PowertoyModuleIface { +private: + // Update registration based on enabled state + void UpdateRegistration(bool enabled) + { + if (enabled) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + NewPlusRuntimeRegistration::EnsureRegisteredWin10(); + Logger::info(L"New+ context menu registered"); +#endif + } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + NewPlusRuntimeRegistration::Unregister(); + Logger::info(L"New+ context menu unregistered"); +#endif + } + } + public: NewModule() { @@ -93,10 +114,13 @@ public: // Log telemetry Trace::EventToggleOnOff(true); - - newplus::utilities::register_msix_package(); + if (package::IsWin11OrGreater()) + { + newplus::utilities::register_msix_package(); + } powertoy_new_enabled = true; + UpdateRegistration(powertoy_new_enabled); } virtual void disable() override @@ -142,11 +166,13 @@ private: Trace::EventToggleOnOff(false); } powertoy_new_enabled = false; + UpdateRegistration(powertoy_new_enabled); } void init_settings() { powertoy_new_enabled = NewSettingsInstance().GetEnabled(); + UpdateRegistration(powertoy_new_enabled); } }; diff --git a/src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp b/src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp index a7ddfe835f..a178e00195 100644 --- a/src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp +++ b/src/modules/NewPlus/NewShellExtensionContextMenu/template_item.cpp @@ -60,8 +60,8 @@ std::wstring template_item::get_target_filename(const bool include_starting_digi std::wstring template_item::remove_starting_digits_from_filename(std::wstring filename) const { - filename.erase(0, min(filename.find_first_not_of(L"0123456789"), filename.size())); - filename.erase(0, min(filename.find_first_not_of(L" ."), filename.size())); + filename.erase(0, std::min(filename.find_first_not_of(L"0123456789"), filename.size())); + filename.erase(0, std::min(filename.find_first_not_of(L" ."), filename.size())); return filename; } diff --git a/src/modules/PowerOCR/PowerOCR-UITests/PowerOCR.UITests.csproj b/src/modules/PowerOCR/PowerOCR-UITests/PowerOCR.UITests.csproj new file mode 100644 index 0000000000..572cf5ab69 --- /dev/null +++ b/src/modules/PowerOCR/PowerOCR-UITests/PowerOCR.UITests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <PropertyGroup> + <RootNamespace>PowerOCR.UITests</RootNamespace> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\PowerOCR.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/PowerOCR/PowerOCR-UITests/PowerOCRTests.cs b/src/modules/PowerOCR/PowerOCR-UITests/PowerOCRTests.cs new file mode 100644 index 0000000000..18702eaaf6 --- /dev/null +++ b/src/modules/PowerOCR/PowerOCR-UITests/PowerOCRTests.cs @@ -0,0 +1,294 @@ +// 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.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium.Interactions; +using static Microsoft.PowerToys.UITest.UITestBase; + +namespace PowerOCR.UITests; + +[TestClass] +public class PowerOCRTests : UITestBase +{ + public PowerOCRTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.Medium) + { + } + + [TestInitialize] + public void TestInitialize() + { + if (FindAll<NavigationViewItem>(By.AccessibilityId("TextExtractorNavItem")).Count == 0) + { + // Expand System Tools list-group if needed + Find<NavigationViewItem>(By.AccessibilityId("SystemToolsNavItem")).Click(); + } + + Find<NavigationViewItem>(By.AccessibilityId("TextExtractorNavItem")).Click(); + + Find<ToggleSwitch>(By.AccessibilityId("EnableTextExtractorToggleSwitch")).Toggle(true); + + // Reset activation shortcut to default (Win+Shift+T) + var shortcutControl = Find<Element>(By.AccessibilityId("TextExtractorActivationShortcut"), 5000); + if (shortcutControl != null) + { + shortcutControl.Click(); + Thread.Sleep(500); + + // Set default shortcut Win+Shift+T + SendKeys(Key.Win, Key.Shift, Key.T); + Thread.Sleep(1000); + + // Click Save to confirm + var saveButton = Find<Button>(By.Name("Save"), 3000); + if (saveButton != null) + { + saveButton.Click(); + Thread.Sleep(1000); + } + } + } + + [TestMethod("PowerOCR.DetectTextExtractor")] + [TestCategory("PowerOCR Detection")] + public void DetectTextExtractorTest() + { + // Step 1: Press the activation shortcut and verify the overlay appears + SendKeys(Key.Win, Key.Shift, Key.T); + var textExtractorWindow = Find<Element>(By.AccessibilityId("TextExtractorWindow"), 10000, true); + Assert.IsNotNull(textExtractorWindow, "TextExtractor window should be found after hotkey activation"); + + // Step 2: Press Escape and verify the overlay disappears + SendKeys(Key.Esc); + Thread.Sleep(3000); + + var windowsAfterEscape = FindAll<Element>(By.AccessibilityId("TextExtractorWindow"), 2000, true); + Assert.AreEqual(0, windowsAfterEscape.Count, "TextExtractor window should be dismissed after pressing Escape"); + + // Step 3: Press the activation shortcut again and verify the overlay appears + SendKeys(Key.Win, Key.Shift, Key.T); + + textExtractorWindow = Find<Element>(By.AccessibilityId("TextExtractorWindow"), 10000, true); + Assert.IsNotNull(textExtractorWindow, "TextExtractor window should appear again after hotkey activation"); + + // Step 4: Right-click and select Cancel. Verify the overlay disappears + textExtractorWindow.Click(rightClick: true); + Thread.Sleep(500); + + // Look for Cancel menu item using its AutomationId + var cancelMenuItem = Find<Element>(By.AccessibilityId("CancelMenuItem"), 3000, true); + Assert.IsNotNull(cancelMenuItem, "Cancel menu item should be available in context menu"); + + cancelMenuItem.Click(); + Thread.Sleep(3000); + + var windowsAfterCancel = FindAll<Element>(By.AccessibilityId("TextExtractorWindow"), 2000, true); + Assert.AreEqual(0, windowsAfterCancel.Count, "TextExtractor window should be dismissed after clicking Cancel"); + } + + [TestMethod("PowerOCR.DisableTextExtractorTest")] + [TestCategory("PowerOCR Settings")] + public void DisableTextExtractorTest() + { + Find<ToggleSwitch>(By.AccessibilityId("EnableTextExtractorToggleSwitch")).Toggle(false); + + SendKeys(Key.Win, Key.Shift, Key.T); + + // Verify that no TextExtractor window appears + var windowsWhenDisabled = FindAll<Element>(By.AccessibilityId("TextExtractorWindow"), 10000, true); + Assert.AreEqual(0, windowsWhenDisabled.Count, "TextExtractor window should not appear when the utility is disabled"); + } + + [TestMethod("PowerOCR.ActivationShortcutSettingsTest")] + [TestCategory("PowerOCR Settings")] + public void ActivationShortcutSettingsTest() + { + // Find the activation shortcut control + var shortcutControl = Find<Element>(By.AccessibilityId("TextExtractorActivationShortcut"), 5000); + Assert.IsNotNull(shortcutControl, "Activation shortcut control should be found"); + + // Click to focus the shortcut control + shortcutControl.Click(); + Thread.Sleep(500); + + // Test changing the shortcut to Ctrl+Shift+T + SendKeys(Key.Ctrl, Key.Shift, Key.T); + Thread.Sleep(1000); + + // Click the Save button to confirm the shortcut change + var saveButton = Find<Button>(By.Name("Save"), 3000); + Assert.IsNotNull(saveButton, "Save button should be found in the shortcut dialog"); + saveButton.Click(); + Thread.Sleep(1000); + + // Test the new shortcut + SendKeys(Key.Ctrl, Key.Shift, Key.T); + Thread.Sleep(3000); + + var textExtractorWindow = FindAll<Element>(By.AccessibilityId("TextExtractorWindow"), 3000, true); + Assert.IsTrue(textExtractorWindow.Count > 0, "TextExtractor should activate with new shortcut Ctrl+Shift+T"); + } + + [TestMethod("PowerOCR.OCRLanguageSettingsTest")] + [TestCategory("PowerOCR Settings")] + public void OCRLanguageSettingsTest() + { + // Find the language combo box + var languageComboBox = Find<ComboBox>(By.AccessibilityId("TextExtractorLanguageComboBox"), 5000); + Assert.IsNotNull(languageComboBox, "Language combo box should be found"); + + // Click to open the dropdown + languageComboBox.Click(); + + // Verify dropdown is opened by checking if dropdown items are available + var dropdownItems = FindAll<Element>(By.ClassName("ComboBoxItem"), 2000); + Assert.IsTrue(dropdownItems.Count > 0, "Dropdown should contain language options"); + + // Close dropdown by pressing Escape + SendKeys(Key.Esc); + } + + [TestMethod("PowerOCR.OCRLanguageSelectionTest")] + [TestCategory("PowerOCR Language")] + public void OCRLanguageSelectionTest() + { + // Activate Text Extractor overlay + SendKeys(Key.Win, Key.Shift, Key.T); + Thread.Sleep(3000); + + var textExtractorWindow = Find<Element>(By.AccessibilityId("TextExtractorWindow"), 10000, true); + Assert.IsNotNull(textExtractorWindow, "TextExtractor window should be found after hotkey activation"); + + // Right-click on the canvas to open context menu + textExtractorWindow.Click(rightClick: true); + + // Look for language options that should appear after Cancel menu item + var allMenuItems = FindAll<Element>(By.ClassName("MenuItem"), 2000, true); + if (allMenuItems.Count > 4) + { + // Find the Cancel menu item first + Element? cancelItem = null; + int cancelIndex = -1; + for (int i = 0; i < allMenuItems.Count; i++) + { + if (allMenuItems[i].GetAttribute("AutomationId") == "CancelMenuItem") + { + cancelItem = allMenuItems[i]; + cancelIndex = i; + break; + } + } + + Assert.IsNotNull(cancelItem, "Cancel menu item should be found"); + + // Look for language options after Cancel menu item + if (cancelIndex >= 0 && cancelIndex < allMenuItems.Count - 1) + { + // Select the first language option after Cancel + var languageOption = allMenuItems[cancelIndex + 1]; + languageOption.Click(); + Thread.Sleep(1000); + + Assert.IsTrue(true, "Language selection changed successfully through right-click menu"); + } + } + + // Close the TextExtractor overlay + SendKeys(Key.Esc); + Thread.Sleep(1000); + } + + [TestMethod("PowerOCR.TextSelectionAndClipboardTest")] + [TestCategory("PowerOCR Selection")] + public void TextSelectionAndClipboardTest() + { + // Clear clipboard first using STA thread + ClearClipboardSafely(); + Thread.Sleep(500); + + // Activate Text Extractor overlay + SendKeys(Key.Win, Key.Shift, Key.T); + Thread.Sleep(3000); + + var textExtractorWindow = Find<Element>(By.AccessibilityId("TextExtractorWindow"), 10000, true); + Assert.IsNotNull(textExtractorWindow, "TextExtractor window should be found after hotkey activation"); + + // Click on the TextExtractor window to position cursor + textExtractorWindow.Click(); + Thread.Sleep(500); + + // Get screen dimensions for full screen selection + var primaryScreen = System.Windows.Forms.Screen.PrimaryScreen; + Assert.IsNotNull(primaryScreen, "Primary screen should be available"); + + var screenWidth = primaryScreen.Bounds.Width; + var screenHeight = primaryScreen.Bounds.Height; + + // Define full screen selection area + var startX = 0; + var startY = 0; + var endX = screenWidth; + var endY = screenHeight; + + // Perform continuous mouse drag to select entire screen + PerformSeleniumDrag(startX, startY, endX, endY); + Thread.Sleep(3000); // Wait longer for full screen OCR processing + + // Verify text was copied to clipboard using STA thread + var clipboardText = GetClipboardTextSafely(); + + Assert.IsFalse(string.IsNullOrWhiteSpace(clipboardText), "Clipboard should contain extracted text after selection"); + + // Close the TextExtractor overlay + SendKeys(Key.Esc); + Thread.Sleep(1000); + } + + private static void ClearClipboardSafely() + { + var thread = new System.Threading.Thread(() => + { + System.Windows.Forms.Clipboard.Clear(); + }); + thread.SetApartmentState(System.Threading.ApartmentState.STA); + thread.Start(); + thread.Join(); + } + + private static string GetClipboardTextSafely() + { + string result = string.Empty; + var thread = new System.Threading.Thread(() => + { + try + { + result = System.Windows.Forms.Clipboard.GetText(); + } + catch (Exception) + { + result = string.Empty; + } + }); + thread.SetApartmentState(System.Threading.ApartmentState.STA); + thread.Start(); + thread.Join(); + return result; + } + + private void PerformSeleniumDrag(int startX, int startY, int endX, int endY) + { + // Use Selenium Actions for proper drag and drop operation + var actions = new Actions(Session.Root); + + // Move to start position, click and hold, drag to end position, then release + actions.MoveByOffset(startX, startY) + .ClickAndHold() + .MoveByOffset(endX - startX, endY - startY) + .Release() + .Build() + .Perform(); + } +} diff --git a/src/modules/PowerOCR/PowerOCR-UITests/tests-checklist-text-extractor.md b/src/modules/PowerOCR/PowerOCR-UITests/tests-checklist-text-extractor.md new file mode 100644 index 0000000000..5a1080e21b --- /dev/null +++ b/src/modules/PowerOCR/PowerOCR-UITests/tests-checklist-text-extractor.md @@ -0,0 +1,15 @@ +## Text Extractor + * Enable Text Extractor. Then: + - [x] Press the activation shortcut and verify the overlay appears. + - [x] Press Escape and verify the overlay disappears. + - [x] Press the activation shortcut and verify the overlay appears. + - [x] Right-click and select Cancel. Verify the overlay disappears. + - [x] Disable Text Extractor and verify that the activation shortcut no longer activates the utility. + * With Text Extractor enabled and activated: + - [x] Try to select text and verify it is copied to the clipboard. + - [x] Try to select a different OCR language by right-clicking and verify the change is applied. + * In a multi-monitor setup with different DPIs on each monitor: + - [ ] Verify text is correctly captured on all monitors. + * Test the different settings and verify they are applied: + - [x] Activation shortcut + - [x] OCR Language \ No newline at end of file diff --git a/src/modules/PowerOCR/PowerOCR/OCROverlay.xaml b/src/modules/PowerOCR/PowerOCR/OCROverlay.xaml index f28d642b6b..4c2a5868c5 100644 --- a/src/modules/PowerOCR/PowerOCR/OCROverlay.xaml +++ b/src/modules/PowerOCR/PowerOCR/OCROverlay.xaml @@ -6,6 +6,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:p="clr-namespace:PowerOCR.Properties" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + x:Name="TextExtractorWindow" Title="TextExtractor" ui:Design.Background="Transparent" AllowsTransparency="True" @@ -87,6 +88,7 @@ <Separator /> <MenuItem Name="CancelMenuItem" + AutomationProperties.AutomationId="CancelMenuItem" Click="CancelMenuItem_Click" Header="{x:Static p:Resources.Cancel}" /> </ContextMenu> @@ -117,6 +119,7 @@ <ComboBox x:Name="LanguagesComboBox" Margin="4,0" + AutomationProperties.AutomationId="OCROverlayLanguagesComboBox" AutomationProperties.Name="{x:Static p:Resources.SelectedLang}" SelectionChanged="LanguagesComboBox_SelectionChanged"> <ComboBox.ItemTemplate> diff --git a/src/modules/PowerOCR/PowerOCR/OCROverlay.xaml.cs b/src/modules/PowerOCR/PowerOCR/OCROverlay.xaml.cs index 2664b8f03b..88219a4110 100644 --- a/src/modules/PowerOCR/PowerOCR/OCROverlay.xaml.cs +++ b/src/modules/PowerOCR/PowerOCR/OCROverlay.xaml.cs @@ -426,7 +426,7 @@ public partial class OCROverlay : Window private void SettingsMenuItem_Click(object sender, RoutedEventArgs e) { WindowUtilities.CloseAllOCROverlays(); - SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.PowerOCR, false); + SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.PowerOCR); } private static bool CheckIfCheckingOrUnchecking(object? sender) diff --git a/src/modules/PowerOCR/PowerOCR/PowerOCR.csproj b/src/modules/PowerOCR/PowerOCR/PowerOCR.csproj index 1c95308d25..8586acaa2e 100644 --- a/src/modules/PowerOCR/PowerOCR/PowerOCR.csproj +++ b/src/modules/PowerOCR/PowerOCR/PowerOCR.csproj @@ -1,11 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <AssemblyTitle>PowerToys.PowerOCR</AssemblyTitle> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> diff --git a/src/modules/PowerOCR/PowerOCR/Settings/UserSettings.cs b/src/modules/PowerOCR/PowerOCR/Settings/UserSettings.cs index 0c57171f3a..053f3b5f30 100644 --- a/src/modules/PowerOCR/PowerOCR/Settings/UserSettings.cs +++ b/src/modules/PowerOCR/PowerOCR/Settings/UserSettings.cs @@ -29,7 +29,7 @@ namespace PowerOCR.Settings [ImportingConstructor] public UserSettings(Helpers.IThrottledActionInvoker throttledActionInvoker) { - _settingsUtils = new SettingsUtils(); + _settingsUtils = SettingsUtils.Default; ActivationShortcut = new SettingItem<string>(DefaultActivationShortcut); PreferredLanguage = new SettingItem<string>(string.Empty); diff --git a/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRCancelledEvent.cs b/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRCancelledEvent.cs index 34d8e2327c..68fe6eded5 100644 --- a/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRCancelledEvent.cs +++ b/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRCancelledEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace PowerOCR.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class PowerOCRCancelledEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRCaptureEvent.cs b/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRCaptureEvent.cs index 3f2b52838b..b84bc7b463 100644 --- a/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRCaptureEvent.cs +++ b/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRCaptureEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace PowerOCR.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class PowerOCRCaptureEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRInvokedEvent.cs b/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRInvokedEvent.cs index 84e1e10e3f..51168e3612 100644 --- a/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRInvokedEvent.cs +++ b/src/modules/PowerOCR/PowerOCR/Telemetry/PowerOCRInvokedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace PowerOCR.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class PowerOCRInvokedEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/PowerOCR/PowerOCRModuleInterface/PowerOCRModuleInterface.vcxproj b/src/modules/PowerOCR/PowerOCRModuleInterface/PowerOCRModuleInterface.vcxproj index 53b1dd8336..0230eb2d3a 100644 --- a/src/modules/PowerOCR/PowerOCRModuleInterface/PowerOCRModuleInterface.vcxproj +++ b/src/modules/PowerOCR/PowerOCRModuleInterface/PowerOCRModuleInterface.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h PowerOCR.base.rc PowerOCR.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h PowerOCR.base.rc PowerOCR.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> @@ -12,10 +13,9 @@ <ProjectName>PowerOCRModuleInterface</ProjectName> <TargetName>PowerToys.PowerOCRModuleInterface</TargetName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -27,12 +27,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -54,10 +54,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -71,15 +71,15 @@ </None> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/PowerOCR/PowerOCRModuleInterface/packages.config b/src/modules/PowerOCR/PowerOCRModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/PowerOCR/PowerOCRModuleInterface/packages.config +++ b/src/modules/PowerOCR/PowerOCRModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.exe.manifest b/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.exe.manifest index 68d17e1db8..4747d3bd23 100644 --- a/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.exe.manifest +++ b/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.exe.manifest @@ -2,6 +2,7 @@ <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.vcxproj b/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.vcxproj index 045be94f2b..374dc951de 100644 --- a/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.vcxproj +++ b/src/modules/ShortcutGuide/ShortcutGuide/ShortcutGuide.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ShortcutGuide.base.rc ShortcutGuide.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ShortcutGuide.base.rc ShortcutGuide.rc" /> </Target> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> @@ -14,10 +15,9 @@ <Keyword>Win32Proj</Keyword> <RootNamespace>ShortcutGuide</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <PlatformToolset Condition="'$(VisualStudioVersion)' == '15.0'">v141</PlatformToolset> <PlatformToolset Condition="'$(VisualStudioVersion)' == '16.0'">v142</PlatformToolset> <CharacterSet>Unicode</CharacterSet> @@ -47,11 +47,11 @@ <TargetName>PowerToys.$(MSBuildProjectName)</TargetName> </PropertyGroup> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\..\;..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src\;..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <AdditionalDependencies>ole32.lib;Shell32.lib;OleAut32.lib;Dbghelp.lib;Dwmapi.lib;Dcomp.lib;Shlwapi.lib;%(AdditionalDependencies)</AdditionalDependencies> @@ -149,19 +149,19 @@ </CopyFileToFolders> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> @@ -179,15 +179,15 @@ <Manifest Include="ShortcutGuide.exe.manifest" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/ShortcutGuide/ShortcutGuide/main.cpp b/src/modules/ShortcutGuide/ShortcutGuide/main.cpp index 713446403b..57a4491d41 100644 --- a/src/modules/ShortcutGuide/ShortcutGuide/main.cpp +++ b/src/modules/ShortcutGuide/ShortcutGuide/main.cpp @@ -121,7 +121,7 @@ int WINAPI wWinMain(_In_ HINSTANCE /*hInstance*/, _In_opt_ HINSTANCE /*hPrevInst else { auto mainThreadId = GetCurrentThreadId(); - exitEventWaiter = EventWaiter(CommonSharedConstants::SHORTCUT_GUIDE_EXIT_EVENT, [mainThreadId, &window](int err) { + exitEventWaiter.start(CommonSharedConstants::SHORTCUT_GUIDE_EXIT_EVENT, [mainThreadId, &window](DWORD err) { if (err != ERROR_SUCCESS) { Logger::error(L"Failed to wait for {} event. {}", CommonSharedConstants::SHORTCUT_GUIDE_EXIT_EVENT, get_last_error_or_default(err)); diff --git a/src/modules/ShortcutGuide/ShortcutGuide/overlay_window.cpp b/src/modules/ShortcutGuide/ShortcutGuide/overlay_window.cpp index bbb660a6ea..825bd10a1f 100644 --- a/src/modules/ShortcutGuide/ShortcutGuide/overlay_window.cpp +++ b/src/modules/ShortcutGuide/ShortcutGuide/overlay_window.cpp @@ -808,7 +808,7 @@ void D2DOverlayWindow::render(ID2D1DeviceContext5* d2d_device_context) d2d_device_context->FillRectangle(monitor_rect, brush.get()); } } - // Finalize the overlay - dimm the buttons if no thumbnail is present and show "No active window" + // Finalize the overlay - dim the buttons if no thumbnail is present and show "No active window" use_overlay->toggle_window_group(miniature_shown || window_state == MINIMIZED); if (!miniature_shown && window_state != MINIMIZED) { diff --git a/src/modules/ShortcutGuide/ShortcutGuide/packages.config b/src/modules/ShortcutGuide/ShortcutGuide/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/ShortcutGuide/ShortcutGuide/packages.config +++ b/src/modules/ShortcutGuide/ShortcutGuide/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj index be903fbe3e..e24c0db76d 100644 --- a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj +++ b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/ShortcutGuideModuleInterface.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ShortcutGuideModuleInterface.base.rc ShortcutGuideModuleInterface.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ShortcutGuideModuleInterface.base.rc ShortcutGuideModuleInterface.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> @@ -11,18 +12,17 @@ <RootNamespace>ShortcutGuideModuleInterface</RootNamespace> <ProjectName>ShortcutGuideModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> @@ -37,12 +37,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.ShortcutGuideModuleInterface</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\..\;..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src\;..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <AdditionalDependencies>Dwmapi.lib;Dcomp.lib;Shlwapi.lib;%(AdditionalDependencies)</AdditionalDependencies> @@ -60,10 +60,10 @@ </ClCompile> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -76,15 +76,15 @@ <None Include="ShortcutGuideModuleInterface.base.rc" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/dllmain.cpp b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/dllmain.cpp index 5e8fe9aa1b..a870fb9ad8 100644 --- a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/dllmain.cpp +++ b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/dllmain.cpp @@ -37,7 +37,7 @@ public: } triggerEvent = CreateEvent(nullptr, false, false, CommonSharedConstants::SHORTCUT_GUIDE_TRIGGER_EVENT); - triggerEventWaiter = EventWaiter(CommonSharedConstants::SHORTCUT_GUIDE_TRIGGER_EVENT, [this](int) { + triggerEventWaiter.start(CommonSharedConstants::SHORTCUT_GUIDE_TRIGGER_EVENT, [this](DWORD) { OnHotkeyEx(); }); diff --git a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/packages.config b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/packages.config +++ b/src/modules/ShortcutGuide/ShortcutGuideModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/Workspaces/Workspaces.ModuleServices/IWorkspaceService.cs b/src/modules/Workspaces/Workspaces.ModuleServices/IWorkspaceService.cs new file mode 100644 index 0000000000..00300fea9f --- /dev/null +++ b/src/modules/Workspaces/Workspaces.ModuleServices/IWorkspaceService.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using PowerToys.ModuleContracts; +using WorkspacesCsharpLibrary.Data; + +namespace Workspaces.ModuleServices; + +/// <summary> +/// Workspaces-specific operations. +/// </summary> +public interface IWorkspaceService : IModuleService +{ + Task<OperationResult> LaunchWorkspaceAsync(string workspaceId, CancellationToken cancellationToken = default); + + Task<OperationResult> LaunchEditorAsync(CancellationToken cancellationToken = default); + + Task<OperationResult> SnapshotAsync(string? targetPath = null, CancellationToken cancellationToken = default); + + Task<OperationResult<IReadOnlyList<ProjectWrapper>>> GetWorkspacesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/modules/Workspaces/Workspaces.ModuleServices/WorkspaceService.cs b/src/modules/Workspaces/Workspaces.ModuleServices/WorkspaceService.cs new file mode 100644 index 0000000000..eb916e24df --- /dev/null +++ b/src/modules/Workspaces/Workspaces.ModuleServices/WorkspaceService.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.IO; +using Common.UI; +using ManagedCommon; +using PowerToys.Interop; +using PowerToys.ModuleContracts; +using WorkspacesCsharpLibrary.Data; + +namespace Workspaces.ModuleServices; + +/// <summary> +/// Implementation of workspace actions for reuse across hosts. +/// </summary> +public sealed class WorkspaceService : ModuleServiceBase, IWorkspaceService +{ + public static WorkspaceService Instance { get; } = new(); + + public override string Key => SettingsDeepLink.SettingsWindow.Workspaces.ToString(); + + protected override SettingsDeepLink.SettingsWindow SettingsWindow => SettingsDeepLink.SettingsWindow.Workspaces; + + public override Task<OperationResult> LaunchAsync(CancellationToken cancellationToken = default) + { + // Treat launch as invoking the Workspaces editor. + return LaunchEditorAsync(cancellationToken); + } + + public Task<OperationResult> LaunchEditorAsync(CancellationToken cancellationToken = default) + { + try + { + using var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.WorkspacesLaunchEditorEvent()); + eventHandle.Set(); + return Task.FromResult(OperationResult.Ok()); + } + catch (Exception ex) + { + return Task.FromResult(OperationResult.Fail($"Failed to launch Workspaces editor: {ex.Message}")); + } + } + + public Task<OperationResult> LaunchWorkspaceAsync(string workspaceId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(workspaceId)) + { + return Task.FromResult(OperationResult.Fail("Workspace id is required.")); + } + + try + { + var powertoysBaseDir = PowerToysPathResolver.GetPowerToysInstallPath(); + if (string.IsNullOrEmpty(powertoysBaseDir)) + { + return Task.FromResult(OperationResult.Fail("PowerToys installation path not found.")); + } + + var launcherPath = Path.Combine(powertoysBaseDir, "PowerToys.WorkspacesLauncher.exe"); + var startInfo = new ProcessStartInfo(launcherPath) + { + Arguments = workspaceId, + UseShellExecute = true, + }; + + Process.Start(startInfo); + return Task.FromResult(OperationResult.Ok()); + } + catch (Exception ex) + { + return Task.FromResult(OperationResult.Fail($"Failed to launch workspace: {ex.Message}")); + } + } + + public Task<OperationResult> SnapshotAsync(string? targetPath = null, CancellationToken cancellationToken = default) + { + // Snapshot orchestration is not yet exposed via events; provide a clear failure for now. + return Task.FromResult(OperationResult.Fail("Snapshot is not implemented for Workspaces.")); + } + + public Task<OperationResult<IReadOnlyList<ProjectWrapper>>> GetWorkspacesAsync(CancellationToken cancellationToken = default) + { + try + { + var items = WorkspacesStorage.Load(); + + return Task.FromResult(OperationResults.Ok<IReadOnlyList<ProjectWrapper>>(items)); + } + catch (Exception ex) + { + return Task.FromResult(OperationResults.Fail<IReadOnlyList<ProjectWrapper>>($"Failed to read workspaces: {ex.Message}")); + } + } +} diff --git a/src/modules/Workspaces/Workspaces.ModuleServices/Workspaces.ModuleServices.csproj b/src/modules/Workspaces/Workspaces.ModuleServices/Workspaces.ModuleServices.csproj new file mode 100644 index 0000000000..74bc59de0c --- /dev/null +++ b/src/modules/Workspaces/Workspaces.ModuleServices/Workspaces.ModuleServices.csproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" /> + <ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" /> + <ProjectReference Include="..\..\..\common\PowerToys.ModuleContracts\PowerToys.ModuleContracts.csproj" /> + <ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/ApplicationWrapper.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/ApplicationWrapper.cs new file mode 100644 index 0000000000..1b5cbb258b --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/ApplicationWrapper.cs @@ -0,0 +1,70 @@ +// 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 WorkspacesCsharpLibrary.Data; + +public struct ApplicationWrapper +{ + public struct WindowPositionWrapper + { + [JsonPropertyName("X")] + public int X { get; set; } + + [JsonPropertyName("Y")] + public int Y { get; set; } + + [JsonPropertyName("width")] + public int Width { get; set; } + + [JsonPropertyName("height")] + public int Height { get; set; } + } + + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("application")] + public string Application { get; set; } + + [JsonPropertyName("application-path")] + public string ApplicationPath { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("package-full-name")] + public string PackageFullName { get; set; } + + [JsonPropertyName("app-user-model-id")] + public string AppUserModelId { get; set; } + + [JsonPropertyName("pwa-app-id")] + public string PwaAppId { get; set; } + + [JsonPropertyName("command-line-arguments")] + public string CommandLineArguments { get; set; } + + [JsonPropertyName("is-elevated")] + public bool IsElevated { get; set; } + + [JsonPropertyName("can-launch-elevated")] + public bool CanLaunchElevated { get; set; } + + [JsonPropertyName("minimized")] + public bool Minimized { get; set; } + + [JsonPropertyName("maximized")] + public bool Maximized { get; set; } + + [JsonPropertyName("position")] + public WindowPositionWrapper Position { get; set; } + + [JsonPropertyName("monitor")] + public int Monitor { get; set; } + + [JsonPropertyName("version")] + public string Version { get; set; } +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/InvokePoint.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/InvokePoint.cs new file mode 100644 index 0000000000..3f24d51f28 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/InvokePoint.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. + +namespace WorkspacesCsharpLibrary.Data; + +public enum InvokePoint +{ + EditorButton = 0, + Shortcut, + LaunchAndEdit, + CommandPaletteExtension, +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/MonitorConfigurationWrapper.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/MonitorConfigurationWrapper.cs new file mode 100644 index 0000000000..1c48dee1ab --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/MonitorConfigurationWrapper.cs @@ -0,0 +1,43 @@ +// 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 WorkspacesCsharpLibrary.Data; + +public struct MonitorConfigurationWrapper +{ + public struct MonitorRectWrapper + { + [JsonPropertyName("top")] + public int Top { get; set; } + + [JsonPropertyName("left")] + public int Left { get; set; } + + [JsonPropertyName("width")] + public int Width { get; set; } + + [JsonPropertyName("height")] + public int Height { get; set; } + } + + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("instance-id")] + public string InstanceId { get; set; } + + [JsonPropertyName("monitor-number")] + public int MonitorNumber { get; set; } + + [JsonPropertyName("dpi")] + public int Dpi { get; set; } + + [JsonPropertyName("monitor-rect-dpi-aware")] + public MonitorRectWrapper MonitorRectDpiAware { get; set; } + + [JsonPropertyName("monitor-rect-dpi-unaware")] + public MonitorRectWrapper MonitorRectDpiUnaware { get; set; } +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/ProjectData.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/ProjectData.cs new file mode 100644 index 0000000000..04006cb2c5 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/ProjectData.cs @@ -0,0 +1,11 @@ +// 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 WorkspacesCsharpLibrary.Data; + +namespace WorkspacesCsharpLibrary.Data; + +public class ProjectData : WorkspacesEditorData<ProjectWrapper> +{ +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/ProjectWrapper.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/ProjectWrapper.cs new file mode 100644 index 0000000000..3f0f4dbc58 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/ProjectWrapper.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace WorkspacesCsharpLibrary.Data; + +public struct ProjectWrapper +{ + public string Id { get; set; } + + public string Name { get; set; } + + public long CreationTime { get; set; } + + public long LastLaunchedTime { get; set; } + + public bool IsShortcutNeeded { get; set; } + + public bool MoveExistingWindows { get; set; } + + public List<MonitorConfigurationWrapper> MonitorConfiguration { get; set; } + + public List<ApplicationWrapper> Applications { get; set; } +} diff --git a/src/modules/Workspaces/WorkspacesEditor/Data/TempProjectData.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/TempProjectData.cs similarity index 82% rename from src/modules/Workspaces/WorkspacesEditor/Data/TempProjectData.cs rename to src/modules/Workspaces/WorkspacesCsharpLibrary/Data/TempProjectData.cs index a1600885b9..c5e4a3ce25 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Data/TempProjectData.cs +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/TempProjectData.cs @@ -2,9 +2,10 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using WorkspacesEditor.Utils; +using WorkspacesCsharpLibrary.Data; +using WorkspacesCsharpLibrary.Utils; -namespace WorkspacesEditor.Data +namespace WorkspacesCsharpLibrary.Data { public class TempProjectData : ProjectData { diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesData.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesData.cs new file mode 100644 index 0000000000..6395bffdba --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesData.cs @@ -0,0 +1,27 @@ +// 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 WorkspacesCsharpLibrary.Utils; +using static WorkspacesCsharpLibrary.Data.WorkspacesData; + +namespace WorkspacesCsharpLibrary.Data; + +public class WorkspacesData : WorkspacesEditorData<WorkspacesListWrapper> +{ + public string File => FolderUtils.DataFolder() + "\\workspaces.json"; + + public struct WorkspacesListWrapper + { + public List<ProjectWrapper> Workspaces { get; set; } + } + + public enum OrderBy + { + LastViewed = 0, + Created = 1, + Name = 2, + Unknown = 3, + } +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesEditorData`1.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesEditorData`1.cs new file mode 100644 index 0000000000..eed73af224 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesEditorData`1.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using WorkspacesCsharpLibrary.Utils; + +namespace WorkspacesCsharpLibrary.Data; + +/// <summary> +/// Shared JSON serializer helper for Workspaces payloads. +/// </summary> +public class WorkspacesEditorData<T> +{ + [RequiresUnreferencedCode("JSON serialization uses reflection-based serializer.")] + [RequiresDynamicCode("JSON serialization uses reflection-based serializer.")] + public T Read(string file) + { + IOUtils ioUtils = new(); + string data = ioUtils.ReadFile(file); + return JsonSerializer.Deserialize<T>(data, WorkspacesJsonOptions.EditorOptions)!; + } + + [RequiresUnreferencedCode("JSON serialization uses reflection-based serializer.")] + [RequiresDynamicCode("JSON serialization uses reflection-based serializer.")] + public string Serialize(T data) + { + return JsonSerializer.Serialize(data, WorkspacesJsonOptions.EditorOptions); + } + + [RequiresUnreferencedCode("JSON serialization uses reflection-based serializer.")] + [RequiresDynamicCode("JSON serialization uses reflection-based serializer.")] + public T Deserialize(string json) + { + return JsonSerializer.Deserialize<T>(json, WorkspacesJsonOptions.EditorOptions)!; + } +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesJsonOptions.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesJsonOptions.cs new file mode 100644 index 0000000000..d9d00152b3 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesJsonOptions.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using WorkspacesCsharpLibrary.Utils; + +namespace WorkspacesCsharpLibrary.Data; + +internal static class WorkspacesJsonOptions +{ + internal static readonly JsonSerializerOptions EditorOptions = new() + { + PropertyNamingPolicy = new DashCaseNamingPolicy(), + WriteIndented = true, + }; +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesStorage.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesStorage.cs new file mode 100644 index 0000000000..ea33884577 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesStorage.cs @@ -0,0 +1,96 @@ +// 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.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace WorkspacesCsharpLibrary.Data; + +/// <summary> +/// Lightweight reader for persisted workspaces. +/// </summary> +public static class WorkspacesStorage +{ + public static IReadOnlyList<ProjectWrapper> Load() + { + var filePath = GetDefaultFilePath(); + if (!File.Exists(filePath)) + { + return []; + } + + try + { + var json = File.ReadAllText(filePath); + var data = JsonSerializer.Deserialize(json, WorkspacesStorageJsonContext.Default.WorkspacesFile); + + if (data?.Workspaces == null) + { + return []; + } + + return data.Workspaces + .Where(ws => !string.IsNullOrWhiteSpace(ws.Id) && !string.IsNullOrWhiteSpace(ws.Name)) + .Select(ws => new ProjectWrapper + { + Id = ws.Id!, + Name = ws.Name!, + Applications = ws.Applications ?? new List<ApplicationWrapper>(), + CreationTime = ws.CreationTime, + LastLaunchedTime = ws.LastLaunchedTime, + IsShortcutNeeded = ws.IsShortcutNeeded, + MoveExistingWindows = ws.MoveExistingWindows, + MonitorConfiguration = ws.MonitorConfiguration ?? new List<MonitorConfigurationWrapper>(), + }) + .ToList() + .AsReadOnly(); + } + catch + { + return Array.Empty<ProjectWrapper>(); + } + } + + public static string GetDefaultFilePath() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(localAppData, "Microsoft", "PowerToys", "Workspaces", "workspaces.json"); + } + + internal sealed class WorkspacesFile + { + public List<WorkspaceProject> Workspaces { get; set; } = new(); + } + + internal sealed class WorkspaceProject + { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("applications")] + public List<ApplicationWrapper> Applications { get; set; } = new(); + + [JsonPropertyName("monitor-configuration")] + public List<MonitorConfigurationWrapper> MonitorConfiguration { get; set; } = new(); + + [JsonPropertyName("creation-time")] + public long CreationTime { get; set; } + + [JsonPropertyName("last-launched-time")] + public long LastLaunchedTime { get; set; } + + [JsonPropertyName("is-shortcut-needed")] + public bool IsShortcutNeeded { get; set; } + + [JsonPropertyName("move-existing-windows")] + public bool MoveExistingWindows { get; set; } + } +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesStorageJsonContext.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesStorageJsonContext.cs new file mode 100644 index 0000000000..45ba31a03d --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Data/WorkspacesStorageJsonContext.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 WorkspacesCsharpLibrary.Data; + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(WorkspacesStorage.WorkspacesFile))] +[JsonSerializable(typeof(WorkspacesStorage.WorkspaceProject))] +[JsonSerializable(typeof(ApplicationWrapper))] +[JsonSerializable(typeof(ApplicationWrapper.WindowPositionWrapper))] +[JsonSerializable(typeof(MonitorConfigurationWrapper))] +[JsonSerializable(typeof(MonitorConfigurationWrapper.MonitorRectWrapper))] +internal sealed partial class WorkspacesStorageJsonContext : JsonSerializerContext +{ +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Models/BaseApplication.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Models/BaseApplication.cs index e3c0bff508..897bd97de5 100644 --- a/src/modules/Workspaces/WorkspacesCsharpLibrary/Models/BaseApplication.cs +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Models/BaseApplication.cs @@ -16,7 +16,7 @@ using Windows.Management.Deployment; namespace WorkspacesCsharpLibrary.Models { - public class BaseApplication : INotifyPropertyChanged, IDisposable + public partial class BaseApplication : INotifyPropertyChanged, IDisposable { public event PropertyChangedEventHandler PropertyChanged; diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/DashCaseNamingPolicy.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/DashCaseNamingPolicy.cs new file mode 100644 index 0000000000..cb1a0f1377 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/DashCaseNamingPolicy.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; +using WorkspacesCsharpLibrary.Utils; + +namespace WorkspacesCsharpLibrary.Utils; + +public class DashCaseNamingPolicy : JsonNamingPolicy +{ + public static DashCaseNamingPolicy Instance { get; } = new DashCaseNamingPolicy(); + + public override string ConvertName(string name) + { + return name.UpperCamelCaseToDashCase(); + } +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/FolderUtils.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/FolderUtils.cs new file mode 100644 index 0000000000..cef2aae957 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/FolderUtils.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; + +namespace WorkspacesCsharpLibrary.Utils; + +public class FolderUtils +{ + public static string Desktop() + { + return Environment.GetFolderPath(Environment.SpecialFolder.Desktop); + } + + public static string Temp() + { + return Path.GetTempPath(); + } + + // Note: the same path should be used in SnapshotTool and Launcher + public static string DataFolder() + { + return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + "\\Microsoft\\PowerToys\\Workspaces"; + } +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/IOUtils.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/IOUtils.cs new file mode 100644 index 0000000000..8ceb703cc4 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/IOUtils.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.IO.Abstractions; +using System.Threading.Tasks; + +namespace WorkspacesCsharpLibrary.Utils; + +public class IOUtils +{ + private readonly IFileSystem _fileSystem = new FileSystem(); + + public void WriteFile(string fileName, string data) + { + _fileSystem.File.WriteAllText(fileName, data); + } + + public string ReadFile(string fileName) + { + if (_fileSystem.File.Exists(fileName)) + { + int attempts = 0; + while (attempts < 10) + { + try + { + using FileSystemStream inputStream = _fileSystem.File.Open(fileName, FileMode.Open); + using StreamReader reader = new(inputStream); + string data = reader.ReadToEnd(); + inputStream.Close(); + return data; + } + catch (Exception) + { + Task.Delay(10).Wait(); + } + + attempts++; + } + } + + return string.Empty; + } +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/StringUtils.cs b/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/StringUtils.cs new file mode 100644 index 0000000000..0b85911fa8 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/Utils/StringUtils.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.Linq; + +namespace WorkspacesCsharpLibrary.Utils; + +public static class StringUtils +{ + public static string UpperCamelCaseToDashCase(this string str) + { + // If it's a single letter variable, leave it as it is + return str.Length == 1 + ? str + : string.Concat(str.Select((x, i) => i > 0 && char.IsUpper(x) ? "-" + x : x.ToString())).ToLowerInvariant(); + } +} diff --git a/src/modules/Workspaces/WorkspacesCsharpLibrary/WorkspacesCsharpLibrary.csproj b/src/modules/Workspaces/WorkspacesCsharpLibrary/WorkspacesCsharpLibrary.csproj index eea9001b12..b88e6a9f9c 100644 --- a/src/modules/Workspaces/WorkspacesCsharpLibrary/WorkspacesCsharpLibrary.csproj +++ b/src/modules/Workspaces/WorkspacesCsharpLibrary/WorkspacesCsharpLibrary.csproj @@ -1,18 +1,24 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <AssemblyTitle>PowerToys.WorkspacesCsharpLibrary</AssemblyTitle> <AssemblyDescription>PowerToys Workspaces Csharp Library</AssemblyDescription> <Description>PowerToys Workspaces Csharp Library</Description> + <ImplicitUsings>enable</ImplicitUsings> <UseWPF>true</UseWPF> <UseWindowsForms>true</UseWindowsForms> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AssemblyName>PowerToys.WorkspacesCsharpLibrary</AssemblyName> </PropertyGroup> -</Project> \ No newline at end of file + <ItemGroup> + <PackageReference Include="System.IO.Abstractions" /> + </ItemGroup> + +</Project> diff --git a/src/modules/Workspaces/WorkspacesEditor/Data/InvokePoint.cs b/src/modules/Workspaces/WorkspacesEditor/Data/InvokePoint.cs deleted file mode 100644 index fe41a65bd7..0000000000 --- a/src/modules/Workspaces/WorkspacesEditor/Data/InvokePoint.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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 WorkspacesEditor.Data -{ - /* sync with workspaces-common */ - public enum InvokePoint - { - EditorButton = 0, - Shortcut, - LaunchAndEdit, - } -} diff --git a/src/modules/Workspaces/WorkspacesEditor/Data/ProjectData.cs b/src/modules/Workspaces/WorkspacesEditor/Data/ProjectData.cs deleted file mode 100644 index 050591591a..0000000000 --- a/src/modules/Workspaces/WorkspacesEditor/Data/ProjectData.cs +++ /dev/null @@ -1,99 +0,0 @@ -// 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 static WorkspacesEditor.Data.ProjectData; - -namespace WorkspacesEditor.Data -{ - public class ProjectData : WorkspacesEditorData<ProjectWrapper> - { - public struct ApplicationWrapper - { - public struct WindowPositionWrapper - { - public int X { get; set; } - - public int Y { get; set; } - - public int Width { get; set; } - - public int Height { get; set; } - } - - public string Id { get; set; } - - public string Application { get; set; } - - public string ApplicationPath { get; set; } - - public string Title { get; set; } - - public string PackageFullName { get; set; } - - public string AppUserModelId { get; set; } - - public string PwaAppId { get; set; } - - public string CommandLineArguments { get; set; } - - public bool IsElevated { get; set; } - - public bool CanLaunchElevated { get; set; } - - public bool Minimized { get; set; } - - public bool Maximized { get; set; } - - public WindowPositionWrapper Position { get; set; } - - public int Monitor { get; set; } - } - - public struct MonitorConfigurationWrapper - { - public struct MonitorRectWrapper - { - public int Top { get; set; } - - public int Left { get; set; } - - public int Width { get; set; } - - public int Height { get; set; } - } - - public string Id { get; set; } - - public string InstanceId { get; set; } - - public int MonitorNumber { get; set; } - - public int Dpi { get; set; } - - public MonitorRectWrapper MonitorRectDpiAware { get; set; } - - public MonitorRectWrapper MonitorRectDpiUnaware { get; set; } - } - - public struct ProjectWrapper - { - public string Id { get; set; } - - public string Name { get; set; } - - public long CreationTime { get; set; } - - public long LastLaunchedTime { get; set; } - - public bool IsShortcutNeeded { get; set; } - - public bool MoveExistingWindows { get; set; } - - public List<MonitorConfigurationWrapper> MonitorConfiguration { get; set; } - - public List<ApplicationWrapper> Applications { get; set; } - } - } -} diff --git a/src/modules/Workspaces/WorkspacesEditor/Data/WorkspacesData.cs b/src/modules/Workspaces/WorkspacesEditor/Data/WorkspacesData.cs deleted file mode 100644 index 6e0d015905..0000000000 --- a/src/modules/Workspaces/WorkspacesEditor/Data/WorkspacesData.cs +++ /dev/null @@ -1,30 +0,0 @@ -// 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 WorkspacesEditor.Utils; - -using static WorkspacesEditor.Data.ProjectData; -using static WorkspacesEditor.Data.WorkspacesData; - -namespace WorkspacesEditor.Data -{ - public class WorkspacesData : WorkspacesEditorData<WorkspacesListWrapper> - { - public string File => FolderUtils.DataFolder() + "\\workspaces.json"; - - public struct WorkspacesListWrapper - { - public List<ProjectWrapper> Workspaces { get; set; } - } - - public enum OrderBy - { - LastViewed = 0, - Created = 1, - Name = 2, - Unknown = 3, - } - } -} diff --git a/src/modules/Workspaces/WorkspacesEditor/Data/WorkspacesEditorData`1.cs b/src/modules/Workspaces/WorkspacesEditor/Data/WorkspacesEditorData`1.cs deleted file mode 100644 index c2ad0a70a4..0000000000 --- a/src/modules/Workspaces/WorkspacesEditor/Data/WorkspacesEditorData`1.cs +++ /dev/null @@ -1,33 +0,0 @@ -// 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 WorkspacesEditor.Utils; - -namespace WorkspacesEditor.Data -{ - public class WorkspacesEditorData<T> - { - protected JsonSerializerOptions JsonOptions - { - get => new() - { - PropertyNamingPolicy = new DashCaseNamingPolicy(), - WriteIndented = true, - }; - } - - public T Read(string file) - { - IOUtils ioUtils = new(); - string data = ioUtils.ReadFile(file); - return JsonSerializer.Deserialize<T>(data, JsonOptions); - } - - public string Serialize(T data) - { - return JsonSerializer.Serialize(data, JsonOptions); - } - } -} diff --git a/src/modules/Workspaces/WorkspacesEditor/MainPage.xaml b/src/modules/Workspaces/WorkspacesEditor/MainPage.xaml index aa2ec3f6d1..37933ffed2 100644 --- a/src/modules/Workspaces/WorkspacesEditor/MainPage.xaml +++ b/src/modules/Workspaces/WorkspacesEditor/MainPage.xaml @@ -1,4 +1,4 @@ -<Page +<Page x:Class="WorkspacesEditor.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" @@ -172,7 +172,7 @@ VerticalContentAlignment="Stretch" VerticalScrollBarVisibility="Auto" Visibility="{Binding IsWorkspacesViewEmpty, Mode=OneWay, Converter={StaticResource BooleanToInvertedVisibilityConverter}, UpdateSourceTrigger=PropertyChanged}"> - <ItemsControl ItemsSource="{Binding WorkspacesView, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"> + <ItemsControl x:Name="WorkspacesItemsControl" ItemsSource="{Binding WorkspacesView, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <StackPanel @@ -235,7 +235,10 @@ Grid.Column="1" Margin="12,12,12,12" Orientation="Vertical"> - <StackPanel HorizontalAlignment="Right" Orientation="Horizontal"> + <StackPanel + x:Name="WorkspaceActionGroup" + HorizontalAlignment="Right" + Orientation="Horizontal"> <Button x:Name="MoreButton" HorizontalAlignment="Right" @@ -248,7 +251,8 @@ </Button> <Popup AllowsTransparency="True" - IsOpen="{Binding IsPopupVisible, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" + Closed="PopupClosed" + IsOpen="{Binding IsPopupVisible, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Placement="Left" PlacementTarget="{Binding ElementName=MoreButton}" StaysOpen="False"> diff --git a/src/modules/Workspaces/WorkspacesEditor/MainPage.xaml.cs b/src/modules/Workspaces/WorkspacesEditor/MainPage.xaml.cs index ce35ca124b..9c90e24d52 100644 --- a/src/modules/Workspaces/WorkspacesEditor/MainPage.xaml.cs +++ b/src/modules/Workspaces/WorkspacesEditor/MainPage.xaml.cs @@ -4,7 +4,8 @@ using System.Windows; using System.Windows.Controls; - +using System.Windows.Controls.Primitives; +using ManagedCommon; using WorkspacesEditor.Models; using WorkspacesEditor.ViewModels; @@ -42,17 +43,28 @@ namespace WorkspacesEditor e.Handled = true; Button button = sender as Button; Project selectedProject = button.DataContext as Project; + selectedProject.IsPopupVisible = false; + _mainViewModel.DeleteProject(selectedProject); } private void MoreButton_Click(object sender, RoutedEventArgs e) { + _mainViewModel.CloseAllPopups(); e.Handled = true; Button button = sender as Button; Project project = button.DataContext as Project; project.IsPopupVisible = true; } + private void PopupClosed(object sender, object e) + { + if (sender is Popup p && p.DataContext is Project proj) + { + proj.IsPopupVisible = false; + } + } + private void LaunchButton_Click(object sender, RoutedEventArgs e) { e.Handled = true; diff --git a/src/modules/Workspaces/WorkspacesEditor/Models/Application.cs b/src/modules/Workspaces/WorkspacesEditor/Models/Application.cs index 7d8bed64de..9444cbb678 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Models/Application.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Models/Application.cs @@ -41,6 +41,7 @@ namespace WorkspacesEditor.Models Maximized = other.Maximized; Position = other.Position; MonitorNumber = other.MonitorNumber; + Version = other.Version; Parent = other.Parent; IsNotFound = other.IsNotFound; @@ -274,5 +275,7 @@ namespace WorkspacesEditor.Models CommandLineArguments = newCommandLineValue; OnPropertyChanged(new PropertyChangedEventArgs(nameof(AppMainParams))); } + + public string Version { get; set; } } } diff --git a/src/modules/Workspaces/WorkspacesEditor/Models/Project.cs b/src/modules/Workspaces/WorkspacesEditor/Models/Project.cs index 860f78c9d8..bb5fd1c93e 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Models/Project.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Models/Project.cs @@ -13,7 +13,7 @@ using System.Threading.Tasks; using System.Windows.Media.Imaging; using ManagedCommon; -using WorkspacesEditor.Data; +using WorkspacesCsharpLibrary.Data; using WorkspacesEditor.Utils; namespace WorkspacesEditor.Models @@ -226,7 +226,7 @@ namespace WorkspacesEditor.Models } } - public Project(ProjectData.ProjectWrapper project) + public Project(ProjectWrapper project) { Id = project.Id; Name = project.Name; @@ -237,7 +237,7 @@ namespace WorkspacesEditor.Models Monitors = []; Applications = []; - foreach (ProjectData.ApplicationWrapper app in project.Applications) + foreach (ApplicationWrapper app in project.Applications) { Models.Application newApp = new() { @@ -246,6 +246,7 @@ namespace WorkspacesEditor.Models AppPath = app.ApplicationPath, AppTitle = app.Title, PwaAppId = string.IsNullOrEmpty(app.PwaAppId) ? string.Empty : app.PwaAppId, + Version = string.IsNullOrEmpty(app.Version) ? string.Empty : app.Version, PackageFullName = app.PackageFullName, AppUserModelId = app.AppUserModelId, Parent = this, @@ -268,7 +269,7 @@ namespace WorkspacesEditor.Models Applications.Add(newApp); } - foreach (ProjectData.MonitorConfigurationWrapper monitor in project.MonitorConfiguration) + foreach (MonitorConfigurationWrapper monitor in project.MonitorConfiguration) { System.Windows.Rect dpiAware = new(monitor.MonitorRectDpiAware.Left, monitor.MonitorRectDpiAware.Top, monitor.MonitorRectDpiAware.Width, monitor.MonitorRectDpiAware.Height); System.Windows.Rect dpiUnaware = new(monitor.MonitorRectDpiUnaware.Left, monitor.MonitorRectDpiUnaware.Top, monitor.MonitorRectDpiUnaware.Width, monitor.MonitorRectDpiUnaware.Height); 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 { /// <summary> @@ -11,9 +14,40 @@ namespace WorkspacesEditor /// </summary> public partial class OverlayWindow : Window { + private int _targetX; + private int _targetY; + private int _targetWidth; + private int _targetHeight; + public OverlayWindow() { InitializeComponent(); + SourceInitialized += OnWindowSourceInitialized; + } + + /// <summary> + /// Sets the target bounds for the overlay window. + /// The window will be positioned using DPI-unaware context after initialization. + /// </summary> + 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/Telemetry/CreateEvent.cs b/src/modules/Workspaces/WorkspacesEditor/Telemetry/CreateEvent.cs index f3baf78408..fd109d8324 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Telemetry/CreateEvent.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Telemetry/CreateEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace WorkspacesEditor.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class CreateEvent : EventBase, IEvent { public CreateEvent() @@ -32,7 +33,7 @@ namespace WorkspacesEditor.Telemetry // Number of apps with "Launch as admin" set public int AdminCount { get; set; } - // True of user checked "Create Shortcut". False if not. + // True if user checked "Create Shortcut". False if not. public bool ShortcutCreated { get; set; } public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/Workspaces/WorkspacesEditor/Telemetry/DeleteEvent.cs b/src/modules/Workspaces/WorkspacesEditor/Telemetry/DeleteEvent.cs index bf74b89975..f89cf3f79f 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Telemetry/DeleteEvent.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Telemetry/DeleteEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace WorkspacesEditor.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class DeleteEvent : EventBase, IEvent { public DeleteEvent() diff --git a/src/modules/Workspaces/WorkspacesEditor/Telemetry/EditEvent.cs b/src/modules/Workspaces/WorkspacesEditor/Telemetry/EditEvent.cs index 813f9fda03..5afffac64e 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Telemetry/EditEvent.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Telemetry/EditEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace WorkspacesEditor.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class EditEvent : EventBase, IEvent { public EditEvent() diff --git a/src/modules/Workspaces/WorkspacesEditor/Telemetry/WorkspacesEditorStartEvent.cs b/src/modules/Workspaces/WorkspacesEditor/Telemetry/WorkspacesEditorStartEvent.cs index e1cd8864bd..f2176685a6 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Telemetry/WorkspacesEditorStartEvent.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Telemetry/WorkspacesEditorStartEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace WorkspacesEditor.Telemetry; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class WorkspacesEditorStartEvent() : EventBase, IEvent { public long TimeStamp { get; set; } diff --git a/src/modules/Workspaces/WorkspacesEditor/Telemetry/WorkspacesEditorStartFinishEvent.cs b/src/modules/Workspaces/WorkspacesEditor/Telemetry/WorkspacesEditorStartFinishEvent.cs index 006280bad8..133eb01211 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Telemetry/WorkspacesEditorStartFinishEvent.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Telemetry/WorkspacesEditorStartFinishEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace WorkspacesEditor.Telemetry; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class WorkspacesEditorStartFinishEvent() : EventBase, IEvent { public long TimeStamp { get; set; } diff --git a/src/modules/Workspaces/WorkspacesEditor/Themes/Dark.xaml b/src/modules/Workspaces/WorkspacesEditor/Themes/Dark.xaml index 52a5bff77f..f0260aa053 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Themes/Dark.xaml +++ b/src/modules/Workspaces/WorkspacesEditor/Themes/Dark.xaml @@ -1,4 +1,4 @@ -<ResourceDictionary +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:system="clr-namespace:System;assembly=System.Runtime"> diff --git a/src/modules/Workspaces/WorkspacesEditor/Utils/DashCaseNamingPolicy.cs b/src/modules/Workspaces/WorkspacesEditor/Utils/DashCaseNamingPolicy.cs deleted file mode 100644 index 3e8857a076..0000000000 --- a/src/modules/Workspaces/WorkspacesEditor/Utils/DashCaseNamingPolicy.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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; - -namespace WorkspacesEditor.Utils -{ - public class DashCaseNamingPolicy : JsonNamingPolicy - { - public static DashCaseNamingPolicy Instance { get; } = new DashCaseNamingPolicy(); - - public override string ConvertName(string name) - { - return name.UpperCamelCaseToDashCase(); - } - } -} diff --git a/src/modules/Workspaces/WorkspacesEditor/Utils/FolderUtils.cs b/src/modules/Workspaces/WorkspacesEditor/Utils/FolderUtils.cs deleted file mode 100644 index fc12593e09..0000000000 --- a/src/modules/Workspaces/WorkspacesEditor/Utils/FolderUtils.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.IO; - -namespace WorkspacesEditor.Utils -{ - public class FolderUtils - { - public static string Desktop() - { - return Environment.GetFolderPath(Environment.SpecialFolder.Desktop); - } - - public static string Temp() - { - return Path.GetTempPath(); - } - - // Note: the same path should be used in SnapshotTool and Launcher - public static string DataFolder() - { - return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + "\\Microsoft\\PowerToys\\Workspaces"; - } - } -} diff --git a/src/modules/Workspaces/WorkspacesEditor/Utils/IOUtils.cs b/src/modules/Workspaces/WorkspacesEditor/Utils/IOUtils.cs deleted file mode 100644 index fe69777593..0000000000 --- a/src/modules/Workspaces/WorkspacesEditor/Utils/IOUtils.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.IO; -using System.IO.Abstractions; -using System.Threading.Tasks; - -namespace WorkspacesEditor.Utils -{ - public class IOUtils - { - private readonly IFileSystem _fileSystem = new FileSystem(); - - public IOUtils() - { - } - - public void WriteFile(string fileName, string data) - { - _fileSystem.File.WriteAllText(fileName, data); - } - - public string ReadFile(string fileName) - { - if (_fileSystem.File.Exists(fileName)) - { - int attempts = 0; - while (attempts < 10) - { - try - { - using FileSystemStream inputStream = _fileSystem.File.Open(fileName, FileMode.Open); - using StreamReader reader = new(inputStream); - string data = reader.ReadToEnd(); - inputStream.Close(); - return data; - } - catch (Exception) - { - Task.Delay(10).Wait(); - } - - attempts++; - } - } - - return string.Empty; - } - } -} 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); + + /// <summary> + /// Positions a WPF window using DPI-unaware context to match the virtual coordinates. + /// This fixes overlay positioning on mixed-DPI multi-monitor setups. + /// </summary> + 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/Utils/Settings.cs b/src/modules/Workspaces/WorkspacesEditor/Utils/Settings.cs index 29dd65d56f..0955e4019e 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Utils/Settings.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Utils/Settings.cs @@ -9,7 +9,7 @@ namespace WorkspacesEditor.Utils public class Settings { private const string WorkspacesModuleName = "Workspaces"; - private static readonly SettingsUtils _settingsUtils = new(); + private static readonly SettingsUtils _settingsUtils = SettingsUtils.Default; public static WorkspacesSettings ReadSettings() { diff --git a/src/modules/Workspaces/WorkspacesEditor/Utils/WorkspacesEditorIO.cs b/src/modules/Workspaces/WorkspacesEditor/Utils/WorkspacesEditorIO.cs index c467259521..b0b3dc9a50 100644 --- a/src/modules/Workspaces/WorkspacesEditor/Utils/WorkspacesEditorIO.cs +++ b/src/modules/Workspaces/WorkspacesEditor/Utils/WorkspacesEditorIO.cs @@ -6,9 +6,9 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; - using ManagedCommon; -using WorkspacesEditor.Data; +using WorkspacesCsharpLibrary.Data; +using WorkspacesCsharpLibrary.Utils; using WorkspacesEditor.Models; using WorkspacesEditor.ViewModels; @@ -81,7 +81,7 @@ namespace WorkspacesEditor.Utils foreach (Project project in workspaces) { - ProjectData.ProjectWrapper wrapper = new() + ProjectWrapper wrapper = new() { Id = project.Id, Name = project.Name, @@ -95,7 +95,7 @@ namespace WorkspacesEditor.Utils foreach (Application app in project.Applications.Where(x => x.IsIncluded)) { - wrapper.Applications.Add(new ProjectData.ApplicationWrapper + wrapper.Applications.Add(new ApplicationWrapper { Id = app.Id, Application = app.AppName, @@ -107,9 +107,10 @@ namespace WorkspacesEditor.Utils CommandLineArguments = app.CommandLineArguments, IsElevated = app.IsElevated, CanLaunchElevated = app.CanLaunchElevated, + Version = app.Version, Maximized = app.Maximized, Minimized = app.Minimized, - Position = new ProjectData.ApplicationWrapper.WindowPositionWrapper + Position = new ApplicationWrapper.WindowPositionWrapper { X = app.Position.X, Y = app.Position.Y, @@ -122,20 +123,20 @@ namespace WorkspacesEditor.Utils foreach (MonitorSetup monitor in project.Monitors) { - wrapper.MonitorConfiguration.Add(new ProjectData.MonitorConfigurationWrapper + wrapper.MonitorConfiguration.Add(new MonitorConfigurationWrapper { Id = monitor.MonitorName, InstanceId = monitor.MonitorInstanceId, MonitorNumber = monitor.MonitorNumber, Dpi = monitor.Dpi, - MonitorRectDpiAware = new ProjectData.MonitorConfigurationWrapper.MonitorRectWrapper + MonitorRectDpiAware = new MonitorConfigurationWrapper.MonitorRectWrapper { Left = (int)monitor.MonitorDpiAwareBounds.Left, Top = (int)monitor.MonitorDpiAwareBounds.Top, Width = (int)monitor.MonitorDpiAwareBounds.Width, Height = (int)monitor.MonitorDpiAwareBounds.Height, }, - MonitorRectDpiUnaware = new ProjectData.MonitorConfigurationWrapper.MonitorRectWrapper + MonitorRectDpiUnaware = new MonitorConfigurationWrapper.MonitorRectWrapper { Left = (int)monitor.MonitorDpiUnawareBounds.Left, Top = (int)monitor.MonitorDpiUnawareBounds.Top, @@ -162,7 +163,7 @@ namespace WorkspacesEditor.Utils private bool AddWorkspaces(MainViewModel mainViewModel, WorkspacesData.WorkspacesListWrapper workspaces) { - foreach (ProjectData.ProjectWrapper project in workspaces.Workspaces) + foreach (ProjectWrapper project in workspaces.Workspaces) { mainViewModel.Workspaces.Add(new Project(project)); } diff --git a/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs b/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs index b0974e1241..5741fd65ab 100644 --- a/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs +++ b/src/modules/Workspaces/WorkspacesEditor/ViewModels/MainViewModel.cs @@ -18,12 +18,12 @@ using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Telemetry; using WorkspacesCsharpLibrary; -using WorkspacesEditor.Data; +using WorkspacesCsharpLibrary.Data; +using WorkspacesCsharpLibrary.Utils; using WorkspacesEditor.Models; using WorkspacesEditor.Telemetry; using WorkspacesEditor.Utils; - -using static WorkspacesEditor.Data.WorkspacesData; +using static WorkspacesCsharpLibrary.Data.WorkspacesData; namespace WorkspacesEditor.ViewModels { @@ -133,7 +133,7 @@ namespace WorkspacesEditor.ViewModels _orderByIndex = value; OnPropertyChanged(new PropertyChangedEventArgs(nameof(WorkspacesView))); settings.Properties.SortBy = (WorkspacesProperties.SortByProperty)value; - settings.Save(new SettingsUtils()); + settings.Save(SettingsUtils.Default); } } @@ -193,27 +193,29 @@ namespace WorkspacesEditor.ViewModels ApplyShortcut(editedProject); } + private string GetDesktopShortcutAddress(Project project) => Path.Combine(FolderUtils.Desktop(), project.Name + ".lnk"); + + private string GetShortcutStoreAddress(Project project) + { + var dataFolder = FolderUtils.DataFolder(); + Directory.CreateDirectory(dataFolder); + var shortcutStoreFolder = Path.Combine(dataFolder, "WorkspacesIcons"); + Directory.CreateDirectory(shortcutStoreFolder); + return Path.Combine(shortcutStoreFolder, project.Id + ".ico"); + } + private void ApplyShortcut(Project project) { - string basePath = AppDomain.CurrentDomain.BaseDirectory; - string shortcutAddress = Path.Combine(FolderUtils.Desktop(), project.Name + ".lnk"); - string shortcutIconFilename = Path.Combine(FolderUtils.Temp(), project.Id + ".ico"); - if (!project.IsShortcutNeeded) { - if (File.Exists(shortcutIconFilename)) - { - File.Delete(shortcutIconFilename); - } - - if (File.Exists(shortcutAddress)) - { - File.Delete(shortcutAddress); - } - + RemoveShortcut(project); return; } + var basePath = AppDomain.CurrentDomain.BaseDirectory; + var shortcutAddress = GetDesktopShortcutAddress(project); + var shortcutIconFilename = GetShortcutStoreAddress(project); + Bitmap icon = WorkspacesIcon.DrawIcon(WorkspacesIcon.IconTextFromProjectName(project.Name), App.ThemeManager.GetCurrentTheme()); WorkspacesIcon.SaveIcon(icon, shortcutIconFilename); @@ -360,8 +362,8 @@ namespace WorkspacesEditor.ViewModels private void RemoveShortcut(Project selectedProject) { - string shortcutAddress = Path.Combine(FolderUtils.Desktop(), selectedProject.Name + ".lnk"); - string shortcutIconFilename = Path.Combine(FolderUtils.Temp(), selectedProject.Id + ".ico"); + string shortcutAddress = GetDesktopShortcutAddress(selectedProject); + string shortcutIconFilename = GetShortcutStoreAddress(selectedProject); if (File.Exists(shortcutIconFilename)) { @@ -435,7 +437,11 @@ namespace WorkspacesEditor.ViewModels private void RunSnapshotTool(bool isExistingProjectLaunched) { Process process = new Process(); - process.StartInfo = new ProcessStartInfo(@".\PowerToys.WorkspacesSnapshotTool.exe"); + + var exeDir = Path.GetDirectoryName(Environment.ProcessPath); + var snapshotUtilsPath = Path.Combine(exeDir, "PowerToys.WorkspacesSnapshotTool.exe"); + + process.StartInfo = new ProcessStartInfo(snapshotUtilsPath); process.StartInfo.CreateNoWindow = true; process.StartInfo.Arguments = isExistingProjectLaunched ? $"{(int)InvokePoint.LaunchAndEdit}" : string.Empty; @@ -489,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/WorkspacesEditor/WorkspacesEditor.csproj b/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditor.csproj index 3f7d153e56..9fa94a892b 100644 --- a/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditor.csproj +++ b/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditor.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <AssemblyTitle>PowerToys.WorkspacesEditor</AssemblyTitle> @@ -13,7 +13,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> </PropertyGroup> <PropertyGroup> @@ -78,6 +78,15 @@ <ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> <ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" /> </ItemGroup> + <ItemGroup> + <Compile Remove="Data\WorkspacesData.cs" /> + <Compile Remove="Data\ProjectData.cs" /> + <Compile Remove="Data\WorkspacesEditorData`1.cs" /> + <Compile Remove="Utils\IOUtils.cs" /> + <Compile Remove="Utils\FolderUtils.cs" /> + <Compile Remove="Utils\DashCaseNamingPolicy.cs" /> + </ItemGroup> + <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> <DesignTime>True</DesignTime> @@ -96,4 +105,4 @@ <LastGenOutput>Settings.Designer.cs</LastGenOutput> </None> </ItemGroup> -</Project> \ No newline at end of file +</Project> diff --git a/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditorPage.xaml b/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditorPage.xaml index 6bc7bfe637..5165344abc 100644 --- a/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditorPage.xaml +++ b/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditorPage.xaml @@ -51,6 +51,8 @@ MouseLeave="AppBorder_MouseLeave"> <Expander Margin="0,0,20,0" + AutomationProperties.AutomationId="{Binding AppName}" + AutomationProperties.Name="{Binding AppName}" FlowDirection="RightToLeft" IsEnabled="{Binding IsIncluded, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" IsExpanded="{Binding IsExpanded, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"> @@ -399,7 +401,11 @@ <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - <ItemsControl ItemTemplateSelector="{StaticResource AppListDataTemplateSelector}" ItemsSource="{Binding ApplicationsListed, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" /> + <ItemsControl + x:Name="CapturedAppList" + AutomationProperties.Name="Captured Application List" + ItemTemplateSelector="{StaticResource AppListDataTemplateSelector}" + ItemsSource="{Binding ApplicationsListed, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" /> </Grid> </StackPanel> </ScrollViewer> diff --git a/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditorPage.xaml.cs b/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditorPage.xaml.cs index 1dde4d1114..4a573584ad 100644 --- a/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditorPage.xaml.cs +++ b/src/modules/Workspaces/WorkspacesEditor/WorkspacesEditorPage.xaml.cs @@ -9,7 +9,7 @@ using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using WorkspacesEditor.Data; +using WorkspacesCsharpLibrary.Data; using WorkspacesEditor.Models; using WorkspacesEditor.ViewModels; diff --git a/src/modules/Workspaces/WorkspacesEditor/app.manifest b/src/modules/Workspaces/WorkspacesEditor/app.manifest index 598c47dd41..98afec4cae 100644 --- a/src/modules/Workspaces/WorkspacesEditor/app.manifest +++ b/src/modules/Workspaces/WorkspacesEditor/app.manifest @@ -51,7 +51,7 @@ also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. --> <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">System</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/Workspaces/WorkspacesEditorUITest/Workspaces.Editor.UITests.csproj b/src/modules/Workspaces/WorkspacesEditorUITest/Workspaces.Editor.UITests.csproj new file mode 100644 index 0000000000..8b8643c962 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesEditorUITest/Workspaces.Editor.UITests.csproj @@ -0,0 +1,32 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <LangVersion>latest</LangVersion> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <OutputType>Exe</OutputType> + <RunVSTest>false</RunVSTest> + <IsTestProject>true</IsTestProject> + <IsPackable>false</IsPackable> + <AssemblyName>PowerToys.Workspaces.UITests</AssemblyName> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\Workspaces.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Appium.WebDriver" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + <ProjectReference Include="..\WorkspacesEditor\WorkspacesEditor.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesEditingPageTests.cs b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesEditingPageTests.cs new file mode 100644 index 0000000000..ee6ca1facc --- /dev/null +++ b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesEditingPageTests.cs @@ -0,0 +1,605 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace WorkspacesEditorUITest; + +[TestClass] +[Ignore("not stable")] +public class WorkspacesEditingPageTests : WorkspacesUiAutomationBase +{ + public WorkspacesEditingPageTests() + : base() + { + } + + [TestMethod("WorkspacesEditingPage.RemoveApp")] + [TestCategory("Workspaces Editing Page UI")] + public void TestRemoveAppFromWorkspace() + { + // Find app list + var appList = Find<Custom>("AppList"); + var initialAppCount = appList.FindAll<Custom>(By.ClassName("AppItem")).Count; + + if (initialAppCount == 0) + { + Assert.Inconclusive("No apps in workspace to remove"); + return; + } + + // Remove first app + var firstApp = appList.FindAll<Custom>(By.ClassName("AppItem"))[0]; + var appName = firstApp.GetAttribute("Name"); + + var removeButton = firstApp.Find<Button>("Remove"); + removeButton.Click(); + Thread.Sleep(500); + + // Verify app removed from list + var finalAppCount = appList.FindAll<Custom>(By.ClassName("AppItem")).Count; + Assert.AreEqual(initialAppCount - 1, finalAppCount, "App should be removed from list"); + + // Verify preview updated + var previewPane = Find<Pane>("Preview"); + var windowPreviews = previewPane.FindAll<Custom>(By.ClassName("WindowPreview")); + Assert.AreEqual(finalAppCount, windowPreviews.Count, "Preview should show correct number of windows"); + } + + [TestMethod("WorkspacesEditingPage.RemoveAndAddBackApp")] + [TestCategory("Workspaces Editing Page UI")] + public void TestRemoveAndAddBackApp() + { + // Find app list + var appList = Find<Custom>("AppList"); + var apps = appList.FindAll<Custom>(By.ClassName("AppItem")); + + if (apps.Count == 0) + { + Assert.Inconclusive("No apps in workspace to test"); + return; + } + + var firstApp = apps[0]; + var appName = firstApp.GetAttribute("Name"); + + // Remove app + var removeButton = firstApp.Find<Button>("Remove"); + removeButton.Click(); + Thread.Sleep(500); + + // Verify removed app shows in "removed apps" section + Assert.IsTrue(Has<Button>("Add back"), "Should have 'Add back' button for removed apps"); + + // Add back the app + var addBackButton = Find<Button>("Add back"); + addBackButton.Click(); + Thread.Sleep(500); + + // Verify app is back in the list + var restoredApp = appList.Find<Custom>(By.Name(appName), timeoutMS: 2000); + Assert.IsNotNull(restoredApp, "App should be restored to the list"); + + // Verify preview updated + var previewPane = Find<Pane>("Preview"); + var windowPreviews = previewPane.FindAll<Custom>(By.ClassName("WindowPreview")); + var currentAppCount = appList.FindAll<Custom>(By.ClassName("AppItem")).Count; + Assert.AreEqual(currentAppCount, windowPreviews.Count, "Preview should show all windows again"); + } + + [TestMethod("WorkspacesEditingPage.SetAppMinimized")] + [TestCategory("Workspaces Editing Page UI")] + public void TestSetAppMinimized() + { + // Find first app + var appList = Find<Custom>("AppList"); + var apps = appList.FindAll<Custom>(By.ClassName("AppItem")); + + if (apps.Count == 0) + { + Assert.Inconclusive("No apps in workspace to test"); + return; + } + + var firstApp = apps[0]; + + // Find and toggle minimized checkbox + var minimizedCheckbox = firstApp.Find<CheckBox>("Minimized"); + bool wasMinimized = minimizedCheckbox.IsChecked; + + minimizedCheckbox.Click(); + Thread.Sleep(500); + + // Verify state changed + Assert.AreNotEqual(wasMinimized, minimizedCheckbox.IsChecked, "Minimized state should toggle"); + + // Verify preview reflects the change + var previewPane = Find<Pane>("Preview"); + var windowPreviews = previewPane.FindAll<Custom>(By.ClassName("WindowPreview")); + + // The first window preview should indicate minimized state + if (minimizedCheckbox.IsChecked && windowPreviews.Count > 0) + { + var firstPreview = windowPreviews[0]; + var opacity = firstPreview.GetAttribute("Opacity"); + + // Minimized windows might have reduced opacity or other visual indicator + Assert.IsNotNull(opacity, "Minimized window should have visual indication in preview"); + } + } + + [TestMethod("WorkspacesEditingPage.SetAppMaximized")] + [TestCategory("Workspaces Editing Page UI")] + public void TestSetAppMaximized() + { + // Find first app + var appList = Find<Custom>("AppList"); + var apps = appList.FindAll<Custom>(By.ClassName("AppItem")); + + if (apps.Count == 0) + { + Assert.Inconclusive("No apps in workspace to test"); + return; + } + + var firstApp = apps[0]; + + // Find and toggle maximized checkbox + var maximizedCheckbox = firstApp.Find<CheckBox>("Maximized"); + bool wasMaximized = maximizedCheckbox.IsChecked; + + maximizedCheckbox.Click(); + Thread.Sleep(500); + + // Verify state changed + Assert.AreNotEqual(wasMaximized, maximizedCheckbox.IsChecked, "Maximized state should toggle"); + + // Verify preview reflects the change + var previewPane = Find<Pane>("Preview"); + if (maximizedCheckbox.IsChecked) + { + // Maximized window should fill the preview area + var windowPreviews = previewPane.FindAll<Custom>(By.ClassName("WindowPreview")); + if (windowPreviews.Count > 0) + { + var firstPreview = windowPreviews[0]; + + // Check if preview shows maximized state + var width = firstPreview.GetAttribute("Width"); + var height = firstPreview.GetAttribute("Height"); + Assert.IsNotNull(width, "Maximized window should have width in preview"); + Assert.IsNotNull(height, "Maximized window should have height in preview"); + } + } + } + + [TestMethod("WorkspacesEditingPage.LaunchAsAdmin")] + [TestCategory("Workspaces Editing Page UI")] + public void TestSetLaunchAsAdmin() + { + // Find app that supports admin launch + var appList = Find<Custom>("AppList"); + var apps = appList.FindAll<Custom>(By.ClassName("AppItem")); + + bool foundAdminCapableApp = false; + foreach (var app in apps) + { + try + { + var adminCheckbox = app.Find<CheckBox>("Launch as admin", timeoutMS: 1000); + if (adminCheckbox != null && adminCheckbox.IsChecked) + { + foundAdminCapableApp = true; + bool wasAdmin = adminCheckbox.IsChecked; + + adminCheckbox.Click(); + Thread.Sleep(500); + + // Verify state changed + Assert.AreNotEqual(wasAdmin, adminCheckbox.IsChecked, "Admin launch state should toggle"); + break; + } + } + catch + { + // This app doesn't support admin launch + continue; + } + } + + if (!foundAdminCapableApp) + { + Assert.Inconclusive("No apps in workspace support admin launch"); + } + } + + [TestMethod("WorkspacesEditingPage.AddCLIArgs")] + [TestCategory("Workspaces Editing Page UI")] + public void TestAddCommandLineArguments() + { + // Find first app + var appList = Find<Custom>("AppList"); + var apps = appList.FindAll<Custom>(By.ClassName("AppItem")); + + if (apps.Count == 0) + { + Assert.Inconclusive("No apps in workspace to test"); + return; + } + + var firstApp = apps[0]; + + // Find CLI args textbox + var cliArgsTextBox = firstApp.Find<TextBox>("Command line arguments", timeoutMS: 2000); + if (cliArgsTextBox == null) + { + Assert.Inconclusive("App does not support command line arguments"); + return; + } + + // Add test arguments + string testArgs = "--test-arg value"; + cliArgsTextBox.SetText(testArgs); + Thread.Sleep(500); + + // Verify arguments were entered + Assert.AreEqual(testArgs, cliArgsTextBox.Text, "Command line arguments should be set"); + } + + [TestMethod("WorkspacesEditingPage.ChangeAppPosition")] + [TestCategory("Workspaces Editing Page UI")] + public void TestManuallyChangeAppPosition() + { + // Find first app + var appList = Find<Custom>("AppList"); + var apps = appList.FindAll<Custom>(By.ClassName("AppItem")); + + if (apps.Count == 0) + { + Assert.Inconclusive("No apps in workspace to test"); + return; + } + + var firstApp = apps[0]; + + // Find position controls + var xPositionBox = firstApp.Find<TextBox>("X position", timeoutMS: 2000); + var yPositionBox = firstApp.Find<TextBox>("Y position", timeoutMS: 2000); + + if (xPositionBox == null || yPositionBox == null) + { + // Try alternate approach with spinners + var positionSpinners = firstApp.FindAll<Custom>(By.ClassName("SpinBox")); + if (positionSpinners.Count >= 2) + { + xPositionBox = positionSpinners[0].Find<TextBox>(By.ClassName("TextBox")); + yPositionBox = positionSpinners[1].Find<TextBox>(By.ClassName("TextBox")); + } + } + + if (xPositionBox != null && yPositionBox != null) + { + // Change position + xPositionBox.SetText("200"); + Thread.Sleep(500); + + yPositionBox.SetText("150"); + Thread.Sleep(500); + + // Verify preview updated + var previewPane = Find<Pane>("Preview"); + var windowPreviews = previewPane.FindAll<Custom>(By.ClassName("WindowPreview")); + Assert.IsTrue(windowPreviews.Count > 0, "Preview should show window at new position"); + } + else + { + Assert.Inconclusive("Could not find position controls"); + } + } + + [TestMethod("WorkspacesEditingPage.ChangeWorkspaceName")] + [TestCategory("Workspaces Editing Page UI")] + public void TestChangeWorkspaceName() + { + // Find workspace name textbox + var nameTextBox = Find<TextBox>("Workspace name"); + string originalName = nameTextBox.Text; + + // Change name + string newName = "Renamed_Workspace_" + DateTime.Now.Ticks; + nameTextBox.SetText(newName); + Thread.Sleep(500); + + // Save changes + var saveButton = Find<Button>("Save"); + saveButton.Click(); + Thread.Sleep(1000); + + // Verify we're back at main list + Assert.IsTrue(Has<Custom>("WorkspacesList"), "Should return to main list after saving"); + + // Verify workspace was renamed + var workspacesList = Find<Custom>("WorkspacesList"); + var renamedWorkspace = workspacesList.Find<Custom>(By.Name(newName), timeoutMS: 2000); + Assert.IsNotNull(renamedWorkspace, "Workspace should be renamed in the list"); + } + + [TestMethod("WorkspacesEditingPage.SaveAndCancelWork")] + [TestCategory("Workspaces Editing Page UI")] + public void TestSaveAndCancelButtons() + { + // Make a change + var nameTextBox = Find<TextBox>("Workspace name"); + string originalName = nameTextBox.Text; + string tempName = originalName + "_temp"; + + nameTextBox.SetText(tempName); + Thread.Sleep(500); + + // Test Cancel button + var cancelButton = Find<Button>("Cancel"); + cancelButton.Click(); + Thread.Sleep(1000); + + // Verify returned to main list without saving + Assert.IsTrue(Has<Custom>("WorkspacesList"), "Should return to main list"); + + // Go back to editing + var workspacesList = Find<Custom>("WorkspacesList"); + var workspace = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem"))[0]; + workspace.Click(); + Thread.Sleep(1000); + + // Verify name wasn't changed + nameTextBox = Find<TextBox>("Workspace name"); + Assert.AreEqual(originalName, nameTextBox.Text, "Name should not be changed after cancel"); + + // Now test Save button + nameTextBox.SetText(tempName); + Thread.Sleep(500); + + var saveButton = Find<Button>("Save"); + saveButton.Click(); + Thread.Sleep(1000); + + // Verify saved + workspacesList = Find<Custom>("WorkspacesList"); + var savedWorkspace = workspacesList.Find<Custom>(By.Name(tempName), timeoutMS: 2000); + Assert.IsNotNull(savedWorkspace, "Workspace should be saved with new name"); + } + + [TestMethod("WorkspacesEditingPage.NavigateWithoutSaving")] + [TestCategory("Workspaces Editing Page UI")] + public void TestNavigateToMainPageWithoutSaving() + { + // Make a change + var nameTextBox = Find<TextBox>("Workspace name"); + string originalName = nameTextBox.Text; + + nameTextBox.SetText(originalName + "_unsaved"); + Thread.Sleep(500); + + // Click on "Workspaces" navigation/breadcrumb + if (Has<NavigationViewItem>("Workspaces", timeoutMS: 1000)) + { + var workspacesNav = Find<NavigationViewItem>("Workspaces"); + workspacesNav.Click(); + Thread.Sleep(1000); + } + else if (Has<HyperlinkButton>("Workspaces", timeoutMS: 1000)) + { + var workspacesBreadcrumb = Find<HyperlinkButton>("Workspaces"); + workspacesBreadcrumb.Click(); + Thread.Sleep(1000); + } + + // If there's a confirmation dialog, handle it + if (Has<Button>("Discard", timeoutMS: 1000)) + { + Find<Button>("Discard").Click(); + Thread.Sleep(500); + } + + // Verify returned to main list + Assert.IsTrue(Has<Custom>("WorkspacesList"), "Should return to main list"); + + // Verify changes weren't saved + var workspacesList = Find<Custom>("WorkspacesList"); + var unsavedWorkspace = workspacesList.Find<Custom>(By.Name(originalName + "_unsaved"), timeoutMS: 1000); + Assert.IsNull(unsavedWorkspace, "Unsaved changes should not persist"); + } + + [TestMethod("WorkspacesEditingPage.CreateDesktopShortcut")] + [TestCategory("Workspaces Editing Page UI")] + public void TestCreateDesktopShortcut() + { + // Find desktop shortcut checkbox + var shortcutCheckbox = Find<CheckBox>("Create desktop shortcut"); + + // Get desktop path + string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); + + // Get workspace name to check for shortcut + var nameTextBox = Find<TextBox>("Workspace name"); + string workspaceName = nameTextBox.Text; + string shortcutPath = Path.Combine(desktopPath, $"{workspaceName}.lnk"); + + // Clean up any existing shortcut + if (File.Exists(shortcutPath)) + { + File.Delete(shortcutPath); + Thread.Sleep(500); + } + + // Check the checkbox + if (!shortcutCheckbox.IsChecked) + { + shortcutCheckbox.Click(); + Thread.Sleep(500); + } + + // Save + var saveButton = Find<Button>("Save"); + saveButton.Click(); + Thread.Sleep(2000); // Give time for shortcut creation + + // Verify shortcut was created + Assert.IsTrue(File.Exists(shortcutPath), "Desktop shortcut should be created"); + + // Clean up + if (File.Exists(shortcutPath)) + { + File.Delete(shortcutPath); + } + } + + [TestMethod("WorkspacesEditingPage.DesktopShortcutState")] + [TestCategory("Workspaces Editing Page UI")] + public void TestDesktopShortcutCheckboxState() + { + // Get workspace name + var nameTextBox = Find<TextBox>("Workspace name"); + string workspaceName = nameTextBox.Text; + string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); + string shortcutPath = Path.Combine(desktopPath, $"{workspaceName}.lnk"); + + // Find checkbox + var shortcutCheckbox = Find<CheckBox>("Create desktop shortcut"); + + // Test 1: When shortcut exists + if (!File.Exists(shortcutPath)) + { + // Create shortcut first + if (!shortcutCheckbox.IsChecked) + { + shortcutCheckbox.Click(); + Thread.Sleep(500); + } + + Find<Button>("Save").Click(); + Thread.Sleep(2000); + + // Navigate back to editing + NavigateToEditingPage(); + } + + shortcutCheckbox = Find<CheckBox>("Create desktop shortcut"); + Assert.IsTrue(shortcutCheckbox.IsChecked, "Checkbox should be checked when shortcut exists"); + + // Test 2: Remove shortcut + if (File.Exists(shortcutPath)) + { + File.Delete(shortcutPath); + Thread.Sleep(500); + } + + // Re-navigate to refresh state + Find<Button>("Cancel").Click(); + Thread.Sleep(1000); + NavigateToEditingPage(); + + shortcutCheckbox = Find<CheckBox>("Create desktop shortcut"); + Assert.IsFalse(shortcutCheckbox.IsChecked, "Checkbox should be unchecked when shortcut doesn't exist"); + } + + [TestMethod("WorkspacesEditingPage.LaunchAndEdit")] + [TestCategory("Workspaces Editing Page UI")] + public void TestLaunchAndEditCapture() + { + // Find Launch and Edit button + var launchEditButton = Find<Button>("Launch and Edit"); + launchEditButton.Click(); + Thread.Sleep(3000); // Wait for apps to launch + + // Open a new application + Process.Start("calc.exe"); + Thread.Sleep(2000); + + // Click Capture + var captureButton = Find<Button>("Capture"); + captureButton.Click(); + Thread.Sleep(2000); + + // Verify new app was added + var appList = Find<Custom>("AppList"); + var apps = appList.FindAll<Custom>(By.ClassName("AppItem")); + + bool foundCalculator = false; + foreach (var app in apps) + { + var appName = app.GetAttribute("Name"); + if (appName.Contains("Calculator", StringComparison.OrdinalIgnoreCase)) + { + foundCalculator = true; + break; + } + } + + Assert.IsTrue(foundCalculator, "Newly opened Calculator should be captured and added"); + + // Clean up + foreach (var process in Process.GetProcessesByName("CalculatorApp")) + { + process.Kill(); + } + + foreach (var process in Process.GetProcessesByName("Calculator")) + { + process.Kill(); + } + } + + // Helper methods + private void NavigateToEditingPage() + { + // Ensure we have at least one workspace + if (!Has<Custom>("WorkspacesList", timeoutMS: 1000)) + { + CreateTestWorkspace(); + } + + // Click on first workspace to edit + var workspacesList = Find<Custom>("WorkspacesList"); + var workspaceItems = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem")); + + if (workspaceItems.Count == 0) + { + CreateTestWorkspace(); + workspaceItems = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem")); + } + + workspaceItems[0].Click(); + Thread.Sleep(1000); + } + + private void CreateTestWorkspace() + { + // Open a test app + Process.Start("notepad.exe"); + Thread.Sleep(1000); + + // Create workspace + var createButton = Find<Button>("Create Workspace"); + createButton.Click(); + Thread.Sleep(1000); + + // Capture + var captureButton = Find<Button>("Capture"); + captureButton.Click(); + Thread.Sleep(2000); + + // Save with default name + var saveButton = Find<Button>("Save"); + saveButton.Click(); + Thread.Sleep(1000); + + // Close test app + foreach (var process in Process.GetProcessesByName("notepad")) + { + process.Kill(); + } + } +} diff --git a/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesEditorTests.cs b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesEditorTests.cs new file mode 100644 index 0000000000..00928d4ae9 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesEditorTests.cs @@ -0,0 +1,237 @@ +// 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.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace WorkspacesEditorUITest; + +[TestClass] +public class WorkspacesEditorTests : WorkspacesUiAutomationBase +{ + public WorkspacesEditorTests() + : base() + { + } + + [TestMethod("WorkspacesEditor.Items.Present")] + [TestCategory("Workspaces UI")] + public void TestItemsPresents() + { + Assert.IsTrue(this.Has<Button>("Create Workspace"), "Should have create workspace button"); + } + + /* + [TestMethod("WorkspacesEditor.Editor.NewWorkspaceAppearsInList")] + [TestCategory("Workspaces UI")] + public void TestNewWorkspaceAppearsInListAfterCapture() + { + // Open test application + OpenNotepad(); + Thread.Sleep(2000); + + // Create workspace + var createButton = Find<Button>("Create Workspace"); + createButton.Click(); + Thread.Sleep(1000); + + // Capture + var captureButton = Find<Button>("Capture"); + captureButton.Click(); + Thread.Sleep(2000); + + // Save workspace + var saveButton = Find<Button>("Save"); + saveButton.Click(); + Thread.Sleep(1000); + + // Verify workspace appears in list + var workspacesList = Find<Custom>("WorkspacesList"); + var workspaceItems = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem")); + Assert.IsTrue(workspaceItems.Count > 0, "New workspace should appear in the list"); + + CloseNotepad(); + } + + [TestMethod("WorkspacesEditor.Editor.CancelCaptureDoesNotAddWorkspace")] + [TestCategory("Workspaces UI")] + public void TestCancelCaptureDoesNotAddWorkspace() + { + // Count existing workspaces + var workspacesList = Find<Custom>("WorkspacesList"); + var initialCount = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem")).Count; + + // Create workspace + var createButton = Find<Button>("Create Workspace"); + createButton.Click(); + Thread.Sleep(1000); + + // Cancel + var cancelButton = Find<Button>("Cancel"); + cancelButton.Click(); + Thread.Sleep(1000); + + // Verify count hasn't changed + var finalCount = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem")).Count; + Assert.AreEqual(initialCount, finalCount, "Workspace count should not change after canceling"); + } + + [TestMethod("WorkspacesEditor.Editor.SearchFiltersWorkspaces")] + [TestCategory("Workspaces UI")] + public void TestSearchFiltersWorkspaces() + { + // Create test workspaces first + CreateTestWorkspace("TestWorkspace1"); + CreateTestWorkspace("TestWorkspace2"); + CreateTestWorkspace("DifferentName"); + + // Find search box + var searchBox = Find<TextBox>("Search"); + searchBox.SetText("TestWorkspace"); + Thread.Sleep(1000); + + // Verify filtered results + var workspacesList = Find<Custom>("WorkspacesList"); + var visibleItems = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem")); + + // Should only show items matching "TestWorkspace" + Assert.IsTrue(visibleItems.Count >= 2, "Should show at least 2 TestWorkspace items"); + + // Clear search + searchBox.SetText(string.Empty); + Thread.Sleep(500); + } + + [TestMethod("WorkspacesEditor.Editor.SortByWorks")] + [TestCategory("Workspaces UI")] + public void TestSortByFunctionality() + { + // Find sort dropdown + var sortDropdown = Find<ComboBox>("SortBy"); + sortDropdown.Click(); + Thread.Sleep(500); + + // Select different sort options + var sortOptions = FindAll<Custom>(By.ClassName("ComboBoxItem")); + if (sortOptions.Count > 1) + { + sortOptions[1].Click(); // Select second option + Thread.Sleep(1000); + + // Verify list is updated (we can't easily verify sort order in UI tests) + var workspacesList = Find<Custom>("WorkspacesList"); + Assert.IsNotNull(workspacesList, "Workspaces list should still be visible after sorting"); + } + } + + [TestMethod("WorkspacesEditor.Editor.SortByPersists")] + [TestCategory("Workspaces UI")] + public void TestSortByPersistsAfterRestart() + { + // Set sort option + var sortDropdown = Find<ComboBox>("SortBy"); + sortDropdown.Click(); + Thread.Sleep(500); + + var sortOptions = FindAll<Custom>(By.ClassName("ComboBoxItem")); + string selectedOption = string.Empty; + if (sortOptions.Count > 1) + { + sortOptions[1].Click(); // Select second option + selectedOption = sortDropdown.Text; + Thread.Sleep(1000); + } + + // Restart editor + RestartScopeExe(); + Thread.Sleep(2000); + + // Verify sort option persisted + sortDropdown = Find<ComboBox>("SortBy"); + Assert.AreEqual(selectedOption, sortDropdown.Text, "Sort option should persist after restart"); + } + + [TestMethod("WorkspacesEditor.Editor.RemoveWorkspace")] + [TestCategory("Workspaces UI")] + public void TestRemoveWorkspace() + { + // Create a test workspace + CreateTestWorkspace("WorkspaceToRemove"); + + // Find the workspace in the list + var workspacesList = Find<Custom>("WorkspacesList"); + var workspaceItem = workspacesList.Find<Custom>(By.Name("WorkspaceToRemove")); + + // Click remove button + var removeButton = workspaceItem.Find<Button>("Remove"); + removeButton.Click(); + Thread.Sleep(1000); + + // Confirm removal if dialog appears + if (Has<Button>("Yes")) + { + Find<Button>("Yes").Click(); + Thread.Sleep(1000); + } + + // Verify workspace is removed + Assert.IsFalse(Has(By.Name("WorkspaceToRemove")), "Workspace should be removed from list"); + } + + [TestMethod("WorkspacesEditor.Editor.EditOpensEditingPage")] + [TestCategory("Workspaces UI")] + public void TestEditOpensEditingPage() + { + // Create a test workspace if none exist + if (!Has<Custom>("WorkspacesList")) + { + CreateTestWorkspace("TestWorkspace"); + } + + // Find first workspace + var workspacesList = Find<Custom>("WorkspacesList"); + var workspaceItem = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem"))[0]; + + // Click edit button + var editButton = workspaceItem.Find<Button>("Edit"); + editButton.Click(); + Thread.Sleep(1000); + + // Verify editing page opened + Assert.IsTrue(Has<Button>("Save"), "Should have Save button on editing page"); + Assert.IsTrue(Has<Button>("Cancel"), "Should have Cancel button on editing page"); + + // Go back + Find<Button>("Cancel").Click(); + Thread.Sleep(1000); + } + + [TestMethod("WorkspacesEditor.Editor.ClickWorkspaceOpensEditingPage")] + [TestCategory("Workspaces UI")] + public void TestClickWorkspaceOpensEditingPage() + { + // Create a test workspace if none exist + if (!Has<Custom>("WorkspacesList")) + { + CreateTestWorkspace("TestWorkspace"); + } + + // Find first workspace + var workspacesList = Find<Custom>("WorkspacesList"); + var workspaceItem = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem"))[0]; + + // Click on the workspace item itself + workspaceItem.Click(); + Thread.Sleep(1000); + + // Verify editing page opened + Assert.IsTrue(Has<Button>("Save"), "Should have Save button on editing page"); + Assert.IsTrue(Has<Button>("Cancel"), "Should have Cancel button on editing page"); + + // Go back + Find<Button>("Cancel").Click(); + Thread.Sleep(1000); + } + */ +} diff --git a/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesLauncherTest.cs b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesLauncherTest.cs new file mode 100644 index 0000000000..18afa4eb7b --- /dev/null +++ b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesLauncherTest.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace WorkspacesEditorUITest; + +[TestClass] +[Ignore("NOT STABLE")] +public class WorkspacesLauncherTest : WorkspacesUiAutomationBase +{ + public WorkspacesLauncherTest() + : base() + { + } + + [TestMethod("WorkspacesEditor.Launcher.LaunchFromEditor")] + [TestCategory("Workspaces UI")] + public void TestLaunchWorkspaceFromEditor() + { + ClearWorkspaces(); + var uuid = Guid.NewGuid().ToString("N").Substring(0, 8); + CreateTestWorkspace(uuid); + + CloseNotepad(); + + var launchButton = Find<Button>(By.Name("Launch")); + launchButton.Click(); + + Task.Delay(2000).Wait(); + + var processes = System.Diagnostics.Process.GetProcessesByName("notepad"); + + Assert.IsTrue(processes?.Length > 0); + } + + [TestMethod("WorkspacesEditor.Launcher.CancelLaunch")] + [TestCategory("Workspaces UI")] + public void TestCancelLaunch() + { + // Create workspace with multiple apps + CreateWorkspaceWithApps(); + + // Launch workspace + var workspacesList = Find<Custom>("WorkspacesList"); + var workspaceItem = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem"))[0]; + var launchButton = workspaceItem.Find<Button>("Launch"); + launchButton.Click(); + Thread.Sleep(1000); + + // Cancel launch + if (Has<Button>("Cancel launch")) + { + Find<Button>("Cancel launch").Click(); + Thread.Sleep(1000); + + // Verify launcher closed + Assert.IsFalse(Has<Window>("Workspaces Launcher"), "Launcher window should close after cancel"); + } + + // Close any apps that may have launched + CloseTestApplications(); + } + + [TestMethod("WorkspacesEditor.Launcher.DismissKeepsLaunching")] + [TestCategory("Workspaces UI")] + public void TestDismissKeepsAppsLaunching() + { + // Create workspace with apps + CreateWorkspaceWithApps(); + + // Launch workspace + var workspacesList = Find<Custom>("WorkspacesList"); + var workspaceItem = workspacesList.FindAll<Custom>(By.ClassName("WorkspaceItem"))[0]; + var launchButton = workspaceItem.Find<Button>("Launch"); + launchButton.Click(); + Thread.Sleep(1000); + + // Dismiss launcher + if (Has<Button>("Dismiss")) + { + Find<Button>("Dismiss").Click(); + Thread.Sleep(1000); + + // Verify launcher closed but apps continue launching + Assert.IsFalse(Has<Window>("Workspaces Launcher"), "Launcher window should close after dismiss"); + + // Wait for apps to finish launching + Thread.Sleep(3000); + + // Verify apps launched (notepad should be open) + Assert.IsTrue(WindowHelper.IsWindowOpen("Notepad"), "Apps should continue launching after dismiss"); + } + + // Close launched apps + CloseTestApplications(); + } +} diff --git a/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesSettingsTests.cs b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesSettingsTests.cs new file mode 100644 index 0000000000..ffec104efe --- /dev/null +++ b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesSettingsTests.cs @@ -0,0 +1,195 @@ +// 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.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace WorkspacesEditorUITest; + +[TestClass] +public class WorkspacesSettingsTests : UITestBase +{ + public WorkspacesSettingsTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.Medium) + { + } + + [TestMethod("WorkspacesSettings.LaunchFromSettings")] + [TestCategory("Workspaces Settings UI")] + public void TestLaunchEditorFromSettingsPage() + { + GoToSettingsPageAndEnable(); + } + + [TestMethod("WorkspacesSettings.ActivationShortcut")] + [TestCategory("Workspaces Settings UI")] + public void TestActivationShortcutCustomization() + { + GoToSettingsPageAndEnable(); + + // Find the activation shortcut control + var shortcutButton = Find<Button>("Activation shortcut"); + Assert.IsNotNull(shortcutButton, "Activation shortcut control should exist"); + + // Test customizing the shortcut + shortcutButton.Click(); + + Task.Delay(1000).Wait(); + + // Send new key combination (Win+Ctrl+W) + SendKeys(Key.Win, Key.Ctrl, Key.W); + + var saveButton = Find<Button>("Save"); + + Assert.IsNotNull(saveButton, "Save button should exist after editing shortcut"); + + saveButton.Click(); + + var helpText = shortcutButton.HelpText; + Assert.AreEqual("Win + Ctrl + W", helpText, "Activation shortcut should be updated to Win + Ctrl + W"); + } + + [TestMethod("WorkspacesSettings.EnableToggle")] + [TestCategory("Workspaces Settings UI")] + public void TestEnableDisableModule() + { + GoToSettingsPageAndEnable(); + + // Find the enable toggle + var enableToggle = Find<ToggleSwitch>("Workspaces"); + Assert.IsNotNull(enableToggle, "Enable Workspaces toggle should exist"); + + Assert.IsTrue(enableToggle.IsOn, "Enable Workspaces toggle should be in the 'on' state"); + + // Toggle the state + enableToggle.Click(); + Task.Delay(500).Wait(); + + // Verify state changed + Assert.IsFalse(enableToggle.IsOn, "Toggle state should change"); + + // Verify related controls are enabled/disabled accordingly + var launchButton = Find<Button>("Launch editor"); + Assert.IsFalse(launchButton.Enabled, "Launch editor button should be disabled when module is disabled"); + } + + [TestMethod("WorkspacesSettings.LaunchByActivationShortcut")] + [TestCategory("Workspaces Settings UI")] + [Ignore("Wait until settings & runner can be connected in framework")] + public void TestLaunchEditorByActivationShortcut() + { + // Ensure module is enabled + var enableToggle = Find<ToggleSwitch>("Workspaces"); + if (!enableToggle.IsOn) + { + enableToggle.Click(); + Thread.Sleep(500); + } + + // Close settings window to test shortcut + ExitScopeExe(); + Thread.Sleep(1000); + + // Default shortcut is Win+Ctrl+` + SendKeys(Key.Win, Key.Ctrl, Key.W); + Thread.Sleep(2000); + + // Verify editor opened + Assert.IsTrue(WindowHelper.IsWindowOpen("Workspaces Editor"), "Workspaces Editor should open with activation shortcut"); + + // Reopen settings for next tests + RestartScopeExe(); + NavigateToWorkspacesSettings(); + } + + [TestMethod("WorkspacesSettings.DisableModuleNoLaunch")] + [TestCategory("Workspaces Settings UI")] + [Ignore("Wait until settings & runner can be connected in framework")] + public void TestDisabledModuleDoesNotLaunchByShortcut() + { + // Disable the module + var enableToggle = Find<ToggleSwitch>("Workspaces"); + if (enableToggle.IsOn) + { + enableToggle.Click(); + Thread.Sleep(500); + } + + // Close settings to test shortcut + ExitScopeExe(); + Thread.Sleep(1000); + + // Try to launch with shortcut + SendKeys(Key.Win, Key.Ctrl, Key.W); + Thread.Sleep(2000); + + // Verify editor did not open + Assert.IsFalse(WindowHelper.IsWindowOpen("Workspaces Editor"), "Workspaces Editor should not open when module is disabled"); + + // Reopen settings and re-enable the module + RestartScopeExe(); + NavigateToWorkspacesSettings(); + + enableToggle = Find<ToggleSwitch>("Workspaces"); + if (!enableToggle.IsOn) + { + enableToggle.Click(); + Thread.Sleep(500); + } + } + + [TestMethod("WorkspacesSettings.QuickAccessLaunch")] + [TestCategory("Workspaces Settings UI")] + [Ignore("Wait until tray icon supported is in framework")] + public void TestLaunchFromQuickAccess() + { + // This test verifies the "quick access" mention in settings + // Look for any quick access related UI elements + var quickAccessInfo = FindAll(By.LinkText("quick access")); + + if (quickAccessInfo.Count > 0) + { + Assert.IsTrue(quickAccessInfo.Count > 0, "Quick access information should be present in settings"); + } + + // Note: Actual system tray/quick access interaction would require + // more complex automation that might be platform-specific + } + + private void NavigateToWorkspacesSettings() + { + // Find and click Workspaces in the navigation + var workspacesNavItem = Find<NavigationViewItem>("Workspaces"); + workspacesNavItem.Click(); + Thread.Sleep(1000); + } + + private void GoToSettingsPageAndEnable() + { + if (this.FindAll<NavigationViewItem>("Workspaces").Count == 0) + { + this.Find<NavigationViewItem>("Windowing & Layouts").Click(); + } + + this.Find<NavigationViewItem>("Workspaces").Click(); + + var enableButton = this.Find<ToggleSwitch>("Workspaces"); + Assert.IsNotNull(enableButton, "Enable Workspaces toggle should exist"); + + if (!enableButton.IsOn) + { + enableButton.Click(); + Task.Delay(500).Wait(); // Wait for the toggle animation and state change + } + + // Verify it's now enabled + Assert.IsTrue(enableButton.IsOn, "Enable Workspaces toggle should be in the 'on' state"); + } + + private void AttachWorkspacesEditor() + { + Task.Delay(200).Wait(); + this.Session.Attach(PowerToysModule.Workspaces); + } +} diff --git a/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesSnapshotTests.cs b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesSnapshotTests.cs new file mode 100644 index 0000000000..d32e0a512f --- /dev/null +++ b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesSnapshotTests.cs @@ -0,0 +1,82 @@ +// 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.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using WorkspacesEditor.Utils; + +namespace WorkspacesEditorUITest; + +[TestClass] +public class WorkspacesSnapshotTests : WorkspacesUiAutomationBase +{ + public WorkspacesSnapshotTests() + : base() + { + } + + [TestMethod("WorkspacesSnapshot.CancelCapture")] + [TestCategory("Workspaces Snapshot UI")] + public void TestCaptureCancel() + { + AttachWorkspacesEditor(); + + var createButton = Find<Button>("Create Workspace"); + createButton.Click(); + + Task.Delay(1000).Wait(); + + AttachSnapshotWindow(); + + var cancelButton = Find<Button>("Cancel"); + + Assert.IsNotNull(cancelButton, "Capture button should exist"); + + cancelButton.Click(); + } + + [TestMethod("WorkspacesSnapshot.CapturePackagedApps")] + [TestCategory("Workspaces Snapshot UI")] + public void TestCapturePackagedApplications() + { + OpenCalculator(); + + // OpenWindowsSettings(); + Task.Delay(2000).Wait(); + + AttachWorkspacesEditor(); + var createButton = Find<Button>("Create Workspace"); + createButton.Click(); + Task.Delay(1000).Wait(); + + AttachSnapshotWindow(); + var captureButton = Find<Button>("Capture"); + captureButton.Click(); + Task.Delay(3000).Wait(); + + // Verify captured windows by reading the temporary workspaces file as the ground truth. + var editorIO = new WorkspacesEditorIO(); + var workspace = editorIO.ParseTempProject(); + + Assert.IsNotNull(workspace, "Workspace data should be deserialized."); + Assert.IsNotNull(workspace.Applications, "Workspace should contain a list of apps."); + + bool isCalculatorFound = workspace.Applications.Any(app => app.AppPath.Contains("Calculator", StringComparison.OrdinalIgnoreCase)); + + // bool isSettingsFound = workspace.Applications.Any(app => app.AppPath.Contains("Settings", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(isCalculatorFound, "Calculator should be captured in the workspace data."); + + // Assert.IsTrue(isSettingsFound, "Settings should be captured in the workspace data."); + + // Cancel to clean up + AttachWorkspacesEditor(); + Find<Button>("Cancel").Click(); + Task.Delay(1000).Wait(); + + // Close test applications + CloseCalculator(); + + // CloseWindowsSettings(); + } +} diff --git a/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesUiAutomationBase.cs b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesUiAutomationBase.cs new file mode 100644 index 0000000000..8a4c70034c --- /dev/null +++ b/src/modules/Workspaces/WorkspacesEditorUITest/WorkspacesUiAutomationBase.cs @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using Microsoft.PowerToys.UITest; + +namespace WorkspacesEditorUITest +{ + public class WorkspacesUiAutomationBase : UITestBase + { + public WorkspacesUiAutomationBase() + : base(PowerToysModule.Workspaces, WindowSize.Medium) + { + } + + protected void CreateTestWorkspace(string name) + { + // Open notepad for capture + OpenNotepad(); + Task.Delay(1000).Wait(); + + // Create workspace + AttachWorkspacesEditor(); + var createButton = Find<Button>("Create Workspace"); + createButton.Click(); + Task.Delay(500).Wait(); + + // Capture + AttachSnapshotWindow(); + var captureButton = Find<Button>("Capture"); + captureButton.Click(); + Task.Delay(5000).Wait(); + + // Set name + var nameTextBox = Find<TextBox>("EditNameTextBox"); + nameTextBox.SetText(name); + + // Save + Find<Button>("Save Workspace").Click(); + + // Close notepad + CloseNotepad(); + } + + // DO NOT USE UNTIL FRAMEWORK AVAILABLE, CAN'T FIND BUTTON FOR SECOND LOOP + protected void ClearWorkspaces() + { + while (true) + { + try + { + var root = Find<Element>(By.AccessibilityId("WorkspacesItemsControl")); + var buttons = root.FindAll<Button>(By.AccessibilityId("MoreButton")); + + Debug.WriteLine($"Found {buttons.Count} button"); + + var button = buttons[^1]; + + button.Click(); + + Task.Delay(500).Wait(); + + var remove = Find<Button>(By.Name("Remove")); + remove.Click(); + + Task.Delay(500).Wait(); + } + catch (Exception ex) + { + Debug.WriteLine(ex.Message); + break; + } + } + } + + protected void CreateWorkspaceWithApps() + { + // Open multiple test applications + OpenTestApplications(); + Thread.Sleep(3000); + + // Create workspace + var createButton = Find<Button>("Create Workspace"); + createButton.Click(); + Thread.Sleep(1000); + + // Capture + var captureButton = Find<Button>("Capture"); + captureButton.Click(); + Thread.Sleep(2000); + + // Save + Find<Button>("Save").Click(); + Thread.Sleep(1000); + + // Close test applications + CloseTestApplications(); + } + + protected void OpenTestApplications() + { + OpenNotepad(); + + // Could add more applications here + Thread.Sleep(1000); + } + + protected void CloseTestApplications() + { + CloseNotepad(); + } + + protected void CloseWorkspacesEditor() + { + // Find and close the Workspaces Editor window + if (WindowHelper.IsWindowOpen("Workspaces Editor")) + { + var editorWindow = Find<Window>("Workspaces Editor"); + editorWindow.Close(); + Thread.Sleep(1000); + } + } + + protected void ResetShortcutToDefault(Custom shortcutControl) + { + // Right-click on the shortcut control to open context menu + shortcutControl.Click(rightClick: true); + Thread.Sleep(500); + + // Look for a "Reset to default" or similar option in the context menu + try + { + // Try to find various possible menu item texts for reset option + var resetOption = Find("Reset to default"); + resetOption?.Click(); + } + catch + { + try + { + // Try alternative text + var resetOption = Find("Reset"); + resetOption?.Click(); + } + catch + { + try + { + // Try another alternative + var resetOption = Find("Default"); + resetOption?.Click(); + } + catch + { + // If context menu doesn't have reset option, try keyboard shortcut + // ESC to close any open menus first + SendKeys(Key.Esc); + Thread.Sleep(200); + + // Click on the control and try to clear it with standard shortcuts + shortcutControl.Click(); + Thread.Sleep(200); + + // Try Ctrl+A to select all, then Delete to clear + SendKeys(Key.Ctrl, Key.A); + Thread.Sleep(100); + SendKeys(Key.Delete); + Thread.Sleep(500); + } + } + } + } + + protected void OpenNotepad() + { + var process = System.Diagnostics.Process.Start("notepad.exe"); + Task.Delay(1000).Wait(); + } + + protected void CloseNotepad() + { + var processes = System.Diagnostics.Process.GetProcessesByName("notepad"); + foreach (var process in processes) + { + try + { + process.Kill(); + process.WaitForExit(); + } + catch + { + // ignore + } + } + } + + private void AttachPowertoySetting() + { + Task.Delay(200).Wait(); + this.Session.Attach(PowerToysModule.PowerToysSettings); + } + + protected void AttachWorkspacesEditor() + { + Task.Delay(200).Wait(); + this.Session.Attach(PowerToysModule.Workspaces); + } + + protected void AttachSnapshotWindow() + { + Task.Delay(5000).Wait(); + this.Session.Attach("Snapshot Creator"); + } + + protected void OpenCalculator() + { + Process.Start("calc.exe"); + Task.Delay(1000).Wait(); + } + + protected void CloseCalculator() + { + foreach (var process in Process.GetProcessesByName("CalculatorApp")) + { + process.Kill(); + } + + foreach (var process in Process.GetProcessesByName("Calculator")) + { + process.Kill(); + } + } + + protected void OpenWindowsSettings() + { + Process.Start(new ProcessStartInfo + { + FileName = "ms-settings:", + UseShellExecute = true, + }); + Task.Delay(500).Wait(); + } + + protected void CloseWindowsSettings() + { + foreach (var process in Process.GetProcessesByName("SystemSettings")) + { + process.Kill(); + } + } + } +} diff --git a/src/modules/Workspaces/WorkspacesLauncher/AppLauncher.cpp b/src/modules/Workspaces/WorkspacesLauncher/AppLauncher.cpp index 4d4b47ea12..615ba9c58a 100644 --- a/src/modules/Workspaces/WorkspacesLauncher/AppLauncher.cpp +++ b/src/modules/Workspaces/WorkspacesLauncher/AppLauncher.cpp @@ -25,6 +25,7 @@ namespace AppLauncher const std::wstring ChromeFilename = L"chrome.exe"; const std::wstring ChromePwaFilename = L"chrome_proxy.exe"; const std::wstring PwaCommandLineAddition = L"--profile-directory=Default --app-id="; + const std::wstring SteamProtocolPrefix = L"steam:"; } Result<SHELLEXECUTEINFO, std::wstring> LaunchApp(const std::wstring& appPath, const std::wstring& commandLineArgs, bool elevated) @@ -122,7 +123,7 @@ namespace AppLauncher // usage example: elevated Terminal if (!launched && !app.appUserModelId.empty() && !app.packageFullName.empty()) { - Logger::trace(L"Launching {} as {}", app.name, app.appUserModelId); + Logger::trace(L"Launching {} as {} - {app.packageFullName}", app.name, app.appUserModelId, app.packageFullName); auto res = LaunchApp(L"shell:AppsFolder\\" + app.appUserModelId, app.commandLineArgs, app.isElevated); if (res.isOk()) { @@ -134,6 +135,21 @@ namespace AppLauncher } } + // protocol launch for steam + if (!launched && !app.appUserModelId.empty() && app.appUserModelId.contains(NonLocalizable::SteamProtocolPrefix)) + { + Logger::trace(L"Launching {} as {}", app.name, app.appUserModelId); + auto res = LaunchApp(app.appUserModelId, app.commandLineArgs, app.isElevated); + if (res.isOk()) + { + launched = true; + } + else + { + launchErrors.push_back({ std::filesystem::path(app.path).filename(), res.error() }); + } + } + // packaged apps: try launching by package full name // doesn't work for elevated apps or apps with command line args if (!launched && !app.packageFullName.empty() && app.commandLineArgs.empty() && !app.isElevated) @@ -149,16 +165,52 @@ namespace AppLauncher if (!launched && !app.pwaAppId.empty()) { - std::filesystem::path appPath(app.path); - if (appPath.filename() == NonLocalizable::EdgeFilename) + int version = 0; + + if (app.version != L"") { - appPathFinal = appPath.parent_path() / NonLocalizable::EdgePwaFilename; - commandLineArgsFinal = NonLocalizable::PwaCommandLineAddition + app.pwaAppId + L" " + app.commandLineArgs; + try + { + version = std::stoi(app.version); + } + catch (const std::invalid_argument&) + { + Logger::error(L"Invalid version format: {}", app.version); + version = 0; + } + catch (const std::out_of_range&) + { + Logger::error(L"Version out of range: {}", app.version); + version = 0; + } } - if (appPath.filename() == NonLocalizable::ChromeFilename) + + if (version >= 1) { - appPathFinal = appPath.parent_path() / NonLocalizable::ChromePwaFilename; - commandLineArgsFinal = NonLocalizable::PwaCommandLineAddition + app.pwaAppId + L" " + app.commandLineArgs; + auto res = LaunchApp(L"shell:AppsFolder\\" + app.appUserModelId, app.commandLineArgs, app.isElevated); + if (res.isOk()) + { + launched = true; + } + else + { + launchErrors.push_back({ app.appUserModelId, res.error() }); + } + } + + if (!launched) + { + std::filesystem::path appPath(app.path); + if (appPath.filename() == NonLocalizable::EdgeFilename) + { + appPathFinal = appPath.parent_path() / NonLocalizable::EdgePwaFilename; + commandLineArgsFinal = NonLocalizable::PwaCommandLineAddition + app.pwaAppId + L" " + app.commandLineArgs; + } + if (appPath.filename() == NonLocalizable::ChromeFilename) + { + appPathFinal = appPath.parent_path() / NonLocalizable::ChromePwaFilename; + commandLineArgsFinal = NonLocalizable::PwaCommandLineAddition + app.pwaAppId + L" " + app.commandLineArgs; + } } } diff --git a/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj b/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj index 9d4fc4bcab..f32e679128 100644 --- a/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj +++ b/src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj @@ -2,7 +2,7 @@ <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <!-- Project configurations --> <!-- Props that should be disabled while building on CI server --> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h WorkspacesLauncherResource.base.rc WorkspacesLauncherResource.rc" /> </Target> @@ -13,7 +13,6 @@ <ConformanceMode>false</ConformanceMode> <TreatWarningAsError>true</TreatWarningAsError> <LanguageStandard>stdcpplatest</LanguageStandard> - <AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions> <PreprocessorDefinitions>_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> @@ -60,7 +59,7 @@ <!-- Props that are constant for both Debug and Release configurations --> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> </PropertyGroup> @@ -174,15 +173,15 @@ <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <Import Project="..\..\..\..\deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLauncher/packages.config b/src/modules/Workspaces/WorkspacesLauncher/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/Workspaces/WorkspacesLauncher/packages.config +++ b/src/modules/Workspaces/WorkspacesLauncher/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchData.cs b/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchData.cs index 6e9ad24379..1e9bf665c5 100644 --- a/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchData.cs +++ b/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchData.cs @@ -3,15 +3,12 @@ // See the LICENSE file in the project root for more information. using System.Text.Json.Serialization; - -using Workspaces.Data; - using static WorkspacesLauncherUI.Data.AppLaunchData; using static WorkspacesLauncherUI.Data.AppLaunchInfosData; namespace WorkspacesLauncherUI.Data { - public class AppLaunchData : WorkspacesUIData<AppLaunchDataWrapper> + public class AppLaunchData : WorkspacesCsharpLibrary.Data.WorkspacesEditorData<AppLaunchDataWrapper> { public struct AppLaunchDataWrapper { diff --git a/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchInfoData.cs b/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchInfoData.cs index c01ffaba8c..5a19ccde15 100644 --- a/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchInfoData.cs +++ b/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchInfoData.cs @@ -3,14 +3,11 @@ // See the LICENSE file in the project root for more information. using System.Text.Json.Serialization; - -using Workspaces.Data; - using static WorkspacesLauncherUI.Data.AppLaunchInfoData; namespace WorkspacesLauncherUI.Data { - public class AppLaunchInfoData : WorkspacesUIData<AppLaunchInfoWrapper> + public class AppLaunchInfoData : WorkspacesCsharpLibrary.Data.WorkspacesEditorData<AppLaunchInfoWrapper> { public struct AppLaunchInfoWrapper { diff --git a/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchInfosData.cs b/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchInfosData.cs index cb00cb4478..a656712d9a 100644 --- a/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchInfosData.cs +++ b/src/modules/Workspaces/WorkspacesLauncherUI/Data/AppLaunchInfosData.cs @@ -4,15 +4,12 @@ using System.Collections.Generic; using System.Text.Json.Serialization; - -using Workspaces.Data; - using static WorkspacesLauncherUI.Data.AppLaunchInfoData; using static WorkspacesLauncherUI.Data.AppLaunchInfosData; namespace WorkspacesLauncherUI.Data { - public class AppLaunchInfosData : WorkspacesUIData<AppLaunchInfoListWrapper> + public class AppLaunchInfosData : WorkspacesCsharpLibrary.Data.WorkspacesEditorData<AppLaunchInfoListWrapper> { public struct AppLaunchInfoListWrapper { diff --git a/src/modules/Workspaces/WorkspacesLauncherUI/Data/WorkspacesUIData`1.cs b/src/modules/Workspaces/WorkspacesLauncherUI/Data/WorkspacesUIData`1.cs deleted file mode 100644 index 5e9b88a728..0000000000 --- a/src/modules/Workspaces/WorkspacesLauncherUI/Data/WorkspacesUIData`1.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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 WorkspacesLauncherUI.Utils; - -namespace Workspaces.Data -{ - public class WorkspacesUIData<T> - { - protected JsonSerializerOptions JsonOptions - { - get - { - return new JsonSerializerOptions - { - PropertyNamingPolicy = new DashCaseNamingPolicy(), - WriteIndented = true, - }; - } - } - - public T Deserialize(string data) - { - return JsonSerializer.Deserialize<T>(data, JsonOptions); - } - - public string Serialize(T data) - { - return JsonSerializer.Serialize(data, JsonOptions); - } - } -} diff --git a/src/modules/Workspaces/WorkspacesLauncherUI/Themes/Dark.xaml b/src/modules/Workspaces/WorkspacesLauncherUI/Themes/Dark.xaml index 52a5bff77f..f0260aa053 100644 --- a/src/modules/Workspaces/WorkspacesLauncherUI/Themes/Dark.xaml +++ b/src/modules/Workspaces/WorkspacesLauncherUI/Themes/Dark.xaml @@ -1,4 +1,4 @@ -<ResourceDictionary +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:system="clr-namespace:System;assembly=System.Runtime"> diff --git a/src/modules/Workspaces/WorkspacesLauncherUI/ViewModels/MainViewModel.cs b/src/modules/Workspaces/WorkspacesLauncherUI/ViewModels/MainViewModel.cs index aa029d7ea2..5b358686de 100644 --- a/src/modules/Workspaces/WorkspacesLauncherUI/ViewModels/MainViewModel.cs +++ b/src/modules/Workspaces/WorkspacesLauncherUI/ViewModels/MainViewModel.cs @@ -6,13 +6,11 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; -using System.Diagnostics; using ManagedCommon; using WorkspacesCsharpLibrary; using WorkspacesLauncherUI.Data; using WorkspacesLauncherUI.Models; -using WorkspacesLauncherUI.Utils; namespace WorkspacesLauncherUI.ViewModels { diff --git a/src/modules/Workspaces/WorkspacesLauncherUI/WorkspacesLauncherUI.csproj b/src/modules/Workspaces/WorkspacesLauncherUI/WorkspacesLauncherUI.csproj index 839c08f90d..0f9cc3c1c0 100644 --- a/src/modules/Workspaces/WorkspacesLauncherUI/WorkspacesLauncherUI.csproj +++ b/src/modules/Workspaces/WorkspacesLauncherUI/WorkspacesLauncherUI.csproj @@ -1,102 +1,102 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> - - <PropertyGroup> - <AssemblyTitle>PowerToys.WorkspacesLauncherUI</AssemblyTitle> - <AssemblyDescription>PowerToys Workspaces Editor</AssemblyDescription> - <Description>PowerToys Workspaces Editor</Description> - <OutputType>WinExe</OutputType> - <UseWPF>true</UseWPF> - <UseWindowsForms>true</UseWindowsForms> - <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> - <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> - <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> - </PropertyGroup> - - <PropertyGroup> - <ProjectGuid>{9C53CC25-0623-4569-95BC-B05410675EE3}</ProjectGuid> - <ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids> - <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> - </PropertyGroup> +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> - <PropertyGroup> - <ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon> - </PropertyGroup> - <PropertyGroup> - <ApplicationManifest>app.manifest</ApplicationManifest> - <AssemblyName>PowerToys.WorkspacesLauncherUI</AssemblyName> - </PropertyGroup> - - <ItemGroup> - <Content Include="..\Assets\**\*.*"> - <Link>Assets\Workspaces\%(Filename)%(Extension)</Link> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - </ItemGroup> + <PropertyGroup> + <AssemblyTitle>PowerToys.WorkspacesLauncherUI</AssemblyTitle> + <AssemblyDescription>PowerToys Workspaces Launcher UI</AssemblyDescription> + <Description>PowerToys Workspaces Launcher UI</Description> + <OutputType>WinExe</OutputType> + <UseWPF>true</UseWPF> + <UseWindowsForms>true</UseWindowsForms> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> + </PropertyGroup> - <ItemGroup> - <COMReference Include="IWshRuntimeLibrary"> - <WrapperTool>tlbimp</WrapperTool> - <VersionMinor>0</VersionMinor> - <VersionMajor>1</VersionMajor> - <Guid>f935dc20-1cf0-11d0-adb9-00c04fd58a0b</Guid> - <Lcid>0</Lcid> - <Isolated>false</Isolated> - <EmbedInteropTypes>true</EmbedInteropTypes> - </COMReference> - <COMReference Include="Shell32"> - <WrapperTool>tlbimp</WrapperTool> - <VersionMinor>0</VersionMinor> - <VersionMajor>1</VersionMajor> - <Guid>50a7e9b0-70ef-11d1-b75a-00a0c90564fe</Guid> - <Lcid>0</Lcid> - <Isolated>false</Isolated> - <EmbedInteropTypes>true</EmbedInteropTypes> - </COMReference> - </ItemGroup> - - <ItemGroup> - <EmbeddedResource Update="Properties\Resources.resx"> - <Generator>PublicResXFileCodeGenerator</Generator> - <LastGenOutput>Resources.Designer.cs</LastGenOutput> - </EmbeddedResource> - <None Include="app.manifest" /> - </ItemGroup> + <PropertyGroup> + <ProjectGuid>{9C53CC25-0623-4569-95BC-B05410675EE3}</ProjectGuid> + <ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids> + <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> + </PropertyGroup> - <ItemGroup> - <PackageReference Include="ControlzEx" /> - <PackageReference Include="ModernWpfUI" /> - <PackageReference Include="System.IO.Abstractions" /> - </ItemGroup> + <PropertyGroup> + <ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon> + </PropertyGroup> + <PropertyGroup> + <ApplicationManifest>app.manifest</ApplicationManifest> + <AssemblyName>PowerToys.WorkspacesLauncherUI</AssemblyName> + </PropertyGroup> - <ItemGroup> - <ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" /> - <ProjectReference Include="..\..\..\common\GPOWrapperProjection\GPOWrapperProjection.csproj" /> - <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" /> - <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> - <ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> - <ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> - <ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" /> - </ItemGroup> - <ItemGroup> - <Compile Update="Properties\Resources.Designer.cs"> - <DesignTime>True</DesignTime> - <AutoGen>True</AutoGen> - <DependentUpon>Resources.resx</DependentUpon> - </Compile> - <Compile Update="Properties\Settings.Designer.cs"> - <DesignTimeSharedInput>True</DesignTimeSharedInput> - <AutoGen>True</AutoGen> - <DependentUpon>Settings.settings</DependentUpon> - </Compile> - </ItemGroup> - <ItemGroup> - <None Update="Properties\Settings.settings"> - <Generator>SettingsSingleFileGenerator</Generator> - <LastGenOutput>Settings.Designer.cs</LastGenOutput> - </None> - </ItemGroup> + <ItemGroup> + <Content Include="..\Assets\**\*.*"> + <Link>Assets\Workspaces\%(Filename)%(Extension)</Link> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + + <ItemGroup> + <COMReference Include="IWshRuntimeLibrary"> + <WrapperTool>tlbimp</WrapperTool> + <VersionMinor>0</VersionMinor> + <VersionMajor>1</VersionMajor> + <Guid>f935dc20-1cf0-11d0-adb9-00c04fd58a0b</Guid> + <Lcid>0</Lcid> + <Isolated>false</Isolated> + <EmbedInteropTypes>true</EmbedInteropTypes> + </COMReference> + <COMReference Include="Shell32"> + <WrapperTool>tlbimp</WrapperTool> + <VersionMinor>0</VersionMinor> + <VersionMajor>1</VersionMajor> + <Guid>50a7e9b0-70ef-11d1-b75a-00a0c90564fe</Guid> + <Lcid>0</Lcid> + <Isolated>false</Isolated> + <EmbedInteropTypes>true</EmbedInteropTypes> + </COMReference> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <Generator>PublicResXFileCodeGenerator</Generator> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + </EmbeddedResource> + <None Include="app.manifest" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="ControlzEx" /> + <PackageReference Include="ModernWpfUI" /> + <PackageReference Include="System.IO.Abstractions" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" /> + <ProjectReference Include="..\..\..\common\GPOWrapperProjection\GPOWrapperProjection.csproj" /> + <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" /> + <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> + <ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> + <ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" /> + </ItemGroup> + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>Resources.resx</DependentUpon> + </Compile> + <Compile Update="Properties\Settings.Designer.cs"> + <DesignTimeSharedInput>True</DesignTimeSharedInput> + <AutoGen>True</AutoGen> + <DependentUpon>Settings.settings</DependentUpon> + </Compile> + </ItemGroup> + <ItemGroup> + <None Update="Properties\Settings.settings"> + <Generator>SettingsSingleFileGenerator</Generator> + <LastGenOutput>Settings.Designer.cs</LastGenOutput> + </None> + </ItemGroup> </Project> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/AppUtilsTests.cpp b/src/modules/Workspaces/WorkspacesLib.UnitTests/AppUtilsTests.cpp new file mode 100644 index 0000000000..526db09d8c --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/AppUtilsTests.cpp @@ -0,0 +1,239 @@ +#include "pch.h" +#include <filesystem> // Add this line + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace WorkspacesLibUnitTests +{ + TEST_CLASS(AppUtilsTests) + { + public: + TEST_METHOD(GetCurrentFolder_ReturnsNonEmptyPath) + { + // Act + const std::wstring& result = Utils::Apps::GetCurrentFolder(); + + // Assert + Assert::IsFalse(result.empty()); + Assert::IsTrue(std::filesystem::exists(result)); + } + + TEST_METHOD(GetCurrentFolderUpper_ReturnsUppercasePath) + { + // Act + const std::wstring& currentFolder = Utils::Apps::GetCurrentFolder(); + const std::wstring& currentFolderUpper = Utils::Apps::GetCurrentFolderUpper(); + + // Assert + Assert::IsFalse(currentFolderUpper.empty()); + Assert::AreEqual(currentFolder.length(), currentFolderUpper.length()); + + // Verify it's actually uppercase + std::wstring expectedUpper = currentFolder; + std::transform(expectedUpper.begin(), expectedUpper.end(), expectedUpper.begin(), towupper); + Assert::AreEqual(expectedUpper, currentFolderUpper); + } + + TEST_METHOD(GetCurrentFolder_ConsistentResults) + { + // Act + const std::wstring& result1 = Utils::Apps::GetCurrentFolder(); + const std::wstring& result2 = Utils::Apps::GetCurrentFolder(); + + // Assert + Assert::AreEqual(result1, result2); + } + + TEST_METHOD(GetCurrentFolderUpper_ConsistentResults) + { + // Act + const std::wstring& result1 = Utils::Apps::GetCurrentFolderUpper(); + const std::wstring& result2 = Utils::Apps::GetCurrentFolderUpper(); + + // Assert + Assert::AreEqual(result1, result2); + } + + TEST_METHOD(AppData_IsEdge_EdgePath_ReturnsTrue) + { + // Arrange + Utils::Apps::AppData appData; + appData.installPath = L"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"; + + // Act + bool result = appData.IsEdge(); + + // Assert + Assert::IsTrue(result); + } + + TEST_METHOD(AppData_IsEdge_NonEdgePath_ReturnsFalse) + { + // Arrange + Utils::Apps::AppData appData; + appData.installPath = L"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"; + + // Act + bool result = appData.IsEdge(); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(AppData_IsEdge_EmptyPath_ReturnsFalse) + { + // Arrange + Utils::Apps::AppData appData; + appData.installPath = L""; + + // Act + bool result = appData.IsEdge(); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(AppData_IsChrome_ChromePath_ReturnsTrue) + { + // Arrange + Utils::Apps::AppData appData; + appData.installPath = L"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"; + + // Act + bool result = appData.IsChrome(); + + // Assert + Assert::IsTrue(result); + } + + TEST_METHOD(AppData_IsChrome_NonChromePath_ReturnsFalse) + { + // Arrange + Utils::Apps::AppData appData; + appData.installPath = L"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"; + + // Act + bool result = appData.IsChrome(); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(AppData_IsChrome_EmptyPath_ReturnsFalse) + { + // Arrange + Utils::Apps::AppData appData; + appData.installPath = L""; + + // Act + bool result = appData.IsChrome(); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(AppData_IsSteamGame_SteamProtocol_ReturnsTrue) + { + // Arrange + Utils::Apps::AppData appData; + appData.protocolPath = L"steam://run/123456"; + + // Act + bool result = appData.IsSteamGame(); + + // Assert + Assert::IsTrue(result); + } + + TEST_METHOD(AppData_IsSteamGame_NonSteamProtocol_ReturnsFalse) + { + // Arrange + Utils::Apps::AppData appData; + appData.protocolPath = L"https://example.com"; + + // Act + bool result = appData.IsSteamGame(); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(AppData_IsSteamGame_EmptyProtocol_ReturnsFalse) + { + // Arrange + Utils::Apps::AppData appData; + appData.protocolPath = L""; + + // Act + bool result = appData.IsSteamGame(); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(AppData_IsSteamGame_PartialSteamString_ReturnsFalse) + { + // Arrange + Utils::Apps::AppData appData; + appData.protocolPath = L"http://run/123456"; + + // Act + bool result = appData.IsSteamGame(); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(AppData_DefaultValues) + { + // Arrange & Act + Utils::Apps::AppData appData; + + // Assert + Assert::IsTrue(appData.name.empty()); + Assert::IsTrue(appData.installPath.empty()); + Assert::IsTrue(appData.packageFullName.empty()); + Assert::IsTrue(appData.appUserModelId.empty()); + Assert::IsTrue(appData.pwaAppId.empty()); + Assert::IsTrue(appData.protocolPath.empty()); + Assert::IsFalse(appData.canLaunchElevated); + } + + TEST_METHOD(AppData_MultipleBrowserDetection) + { + // Arrange + Utils::Apps::AppData edgeApp; + edgeApp.installPath = L"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"; + + Utils::Apps::AppData chromeApp; + chromeApp.installPath = L"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"; + + Utils::Apps::AppData otherApp; + otherApp.installPath = L"C:\\Program Files\\Firefox\\firefox.exe"; + + // Act & Assert + Assert::IsTrue(edgeApp.IsEdge()); + Assert::IsFalse(edgeApp.IsChrome()); + Assert::IsFalse(edgeApp.IsSteamGame()); + + Assert::IsFalse(chromeApp.IsEdge()); + Assert::IsTrue(chromeApp.IsChrome()); + Assert::IsFalse(chromeApp.IsSteamGame()); + + Assert::IsFalse(otherApp.IsEdge()); + Assert::IsFalse(otherApp.IsChrome()); + Assert::IsFalse(otherApp.IsSteamGame()); + } + + TEST_METHOD(GetAppsList_ReturnsAppList) + { + // Act + Utils::Apps::AppList apps = Utils::Apps::GetAppsList(); + + // Assert + // The list can be empty or non-empty depending on the system + // But it should not crash and should return a valid list + Assert::IsTrue(apps.size() >= 0); + } + }; +} \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/JsonUtilsTests.cpp b/src/modules/Workspaces/WorkspacesLib.UnitTests/JsonUtilsTests.cpp new file mode 100644 index 0000000000..863efb82bb --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/JsonUtilsTests.cpp @@ -0,0 +1,186 @@ +#include "pch.h" +#include <filesystem> +#include <fstream> + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace WorkspacesLibUnitTests +{ + TEST_CLASS (JsonUtilsTests) + { + private: + std::wstring CreateTempJsonFile(const std::wstring& content) + { + std::wstring tempPath = std::filesystem::temp_directory_path(); + tempPath += L"\\test_workspace_" + std::to_wstring(GetTickCount64()) + L".json"; + + std::wofstream file(tempPath); + file << content; + file.close(); + + return tempPath; + } + + void DeleteTempFile(const std::wstring& filePath) + { + if (std::filesystem::exists(filePath)) + { + std::filesystem::remove(filePath); + } + } + + public: + TEST_METHOD (ReadSingleWorkspace_NonExistentFile_ReturnsEmptyWorkspace) + { + // Arrange + std::wstring nonExistentFile = L"C:\\NonExistent\\File.json"; + + // Act + auto result = JsonUtils::ReadSingleWorkspace(nonExistentFile); + + // Assert + Assert::IsTrue(result.isOk()); + auto workspace = result.value(); + Assert::IsTrue(workspace.name.empty()); + } + + TEST_METHOD (ReadSingleWorkspace_InvalidJsonFile_ReturnsError) + { + // Arrange + std::wstring tempFile = CreateTempJsonFile(L"invalid json content {"); + + // Act + auto result = JsonUtils::ReadSingleWorkspace(tempFile); + + // Assert + Assert::IsTrue(result.isError()); + Assert::AreEqual(static_cast<int>(JsonUtils::WorkspacesFileError::IncorrectFileError), + static_cast<int>(result.error())); + + // Cleanup + DeleteTempFile(tempFile); + } + + TEST_METHOD (ReadWorkspaces_NonExistentFile_ReturnsEmptyVector) + { + // Arrange + std::wstring nonExistentFile = L"C:\\NonExistent\\File.json"; + + // Act + auto result = JsonUtils::ReadWorkspaces(nonExistentFile); + + // Assert + Assert::IsTrue(result.isError()); + Assert::AreEqual(static_cast<int>(JsonUtils::WorkspacesFileError::IncorrectFileError), + static_cast<int>(result.error())); + } + + TEST_METHOD (ReadWorkspaces_InvalidJsonFile_ReturnsError) + { + // Arrange + std::wstring tempFile = CreateTempJsonFile(L"invalid json content {"); + + // Act + auto result = JsonUtils::ReadWorkspaces(tempFile); + + // Assert + Assert::IsTrue(result.isError()); + Assert::AreEqual(static_cast<int>(JsonUtils::WorkspacesFileError::IncorrectFileError), + static_cast<int>(result.error())); + + // Cleanup + DeleteTempFile(tempFile); + } + + TEST_METHOD (Write_ValidWorkspace_ReturnsTrue) + { + // Arrange + std::wstring tempPath = std::filesystem::temp_directory_path(); + tempPath += L"\\test_write_workspace_" + std::to_wstring(GetTickCount64()) + L".json"; + + WorkspacesData::WorkspacesProject workspace; + workspace.name = L"Test Workspace"; + + // Convert string to time_t + std::tm tm = {}; + workspace.creationTime = std::mktime(&tm); + + // Act + bool result = JsonUtils::Write(tempPath, workspace); + + // Assert + Assert::IsTrue(result); + Assert::IsTrue(std::filesystem::exists(tempPath)); + + // Cleanup + DeleteTempFile(tempPath); + } + + TEST_METHOD (Write_ValidWorkspacesList_ReturnsTrue) + { + // Arrange + std::wstring tempPath = std::filesystem::temp_directory_path(); + tempPath += L"\\test_write_workspaces_" + std::to_wstring(GetTickCount64()) + L".json"; + + std::vector<WorkspacesData::WorkspacesProject> workspaces; + + WorkspacesData::WorkspacesProject workspace1; + workspace1.name = L"Test Workspace 1"; + workspace1.creationTime = std::time(nullptr); + + WorkspacesData::WorkspacesProject workspace2; + workspace2.name = L"Test Workspace 2"; + workspace2.creationTime = std::time(nullptr); + + workspaces.push_back(workspace1); + workspaces.push_back(workspace2); + + // Act + bool result = JsonUtils::Write(tempPath, workspaces); + + // Assert + Assert::IsTrue(result); + Assert::IsTrue(std::filesystem::exists(tempPath)); + + // Cleanup + DeleteTempFile(tempPath); + } + + TEST_METHOD (Write_EmptyWorkspacesList_ReturnsTrue) + { + // Arrange + std::wstring tempPath = std::filesystem::temp_directory_path(); + tempPath += L"\\test_write_empty_" + std::to_wstring(GetTickCount64()) + L".json"; + + std::vector<WorkspacesData::WorkspacesProject> emptyWorkspaces; + + // Act + bool result = JsonUtils::Write(tempPath, emptyWorkspaces); + + // Assert + Assert::IsTrue(result); + Assert::IsTrue(std::filesystem::exists(tempPath)); + + // Cleanup + DeleteTempFile(tempPath); + } + + /* + TEST_METHOD(Write_InvalidPath_ReturnsFalse) + { + // Arrange + std::wstring invalidPath = L"C:\\NonExistent\\Path\\workspace.json"; + + WorkspacesData::WorkspacesProject workspace; + workspace.name = L"Test Workspace"; + workspace.creationTime = std::time(nullptr); + + // Act + bool result = JsonUtils::Write(invalidPath, workspace); + + // Assert + Assert::IsFalse(result); + } + */ + }; +} \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/PwaHelperTests.cpp b/src/modules/Workspaces/WorkspacesLib.UnitTests/PwaHelperTests.cpp new file mode 100644 index 0000000000..63feca4b23 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/PwaHelperTests.cpp @@ -0,0 +1,101 @@ +#include "pch.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace WorkspacesLibUnitTests +{ + TEST_CLASS (PwaHelperTests) + { + public: + TEST_METHOD (PwaHelper_Constructor_DoesNotThrow) + { + // Act & Assert - Constructor should not crash when called + try + { + Utils::PwaHelper helper; + // If we get here, the constructor didn't throw + Assert::IsTrue(true); + } + catch (...) + { + Assert::Fail(L"PwaHelper constructor should not throw exceptions"); + } + } + + TEST_METHOD (PwaHelper_GetEdgeAppId_EmptyAumid_ReturnsEmpty) + { + // Arrange + Utils::PwaHelper helper; + std::wstring emptyAumid = L""; + + // Act + auto result = helper.GetEdgeAppId(emptyAumid); + + // Assert + Assert::IsFalse(result.has_value()); + } + + TEST_METHOD (PwaHelper_GetChromeAppId_EmptyAumid_ReturnsEmpty) + { + // Arrange + Utils::PwaHelper helper; + std::wstring emptyAumid = L""; + + // Act + auto result = helper.GetChromeAppId(emptyAumid); + + // Assert + Assert::IsFalse(result.has_value()); + } + + TEST_METHOD (PwaHelper_SearchPwaName_EmptyParameters_ReturnsEmpty) + { + // Arrange + Utils::PwaHelper helper; + std::wstring emptyPwaAppId = L""; + std::wstring emptyWindowAumid = L""; + + // Act + std::wstring result = helper.SearchPwaName(emptyPwaAppId, emptyWindowAumid); + + // Assert + Assert::IsTrue(result.empty()); + } + + TEST_METHOD (PwaHelper_SearchPwaName_NonExistentIds_ReturnsEmpty) + { + // Arrange + Utils::PwaHelper helper; + std::wstring nonExistentPwaAppId = L"nonexistent_app_id"; + std::wstring nonExistentWindowAumid = L"nonexistent_aumid"; + + // Act + std::wstring result = helper.SearchPwaName(nonExistentPwaAppId, nonExistentWindowAumid); + + // TODO: is it really expected? + Assert::IsTrue(result == nonExistentWindowAumid); + } + + TEST_METHOD (PwaHelper_GetEdgeAppId_ValidConstruction_DoesNotCrash) + { + // Arrange + Utils::PwaHelper helper; + std::wstring testAumid = L"Microsoft.MicrosoftEdge_8wekyb3d8bbwe!App"; + + // Act & Assert - Should not crash + auto result = helper.GetEdgeAppId(testAumid); + // Result can be empty or have value, but should not crash + } + + TEST_METHOD (PwaHelper_GetChromeAppId_ValidConstruction_DoesNotCrash) + { + // Arrange + Utils::PwaHelper helper; + std::wstring testAumid = L"Chrome.App.TestId"; + + // Act & Assert - Should not crash + auto result = helper.GetChromeAppId(testAumid); + // Result can be empty or have value, but should not crash + } + }; +} \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/StringUtilsTests.cpp b/src/modules/Workspaces/WorkspacesLib.UnitTests/StringUtilsTests.cpp new file mode 100644 index 0000000000..846ba71483 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/StringUtilsTests.cpp @@ -0,0 +1,115 @@ +#include "pch.h" +#include <StringUtils.h> + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace WorkspacesLibUnitTests +{ + TEST_CLASS(StringUtilsTests) + { + public: + TEST_METHOD(CaseInsensitiveEquals_SameStrings_ReturnsTrue) + { + // Arrange + std::wstring str1 = L"test"; + std::wstring str2 = L"test"; + + // Act + bool result = StringUtils::CaseInsensitiveEquals(str1, str2); + + // Assert + Assert::IsTrue(result); + } + + TEST_METHOD(CaseInsensitiveEquals_DifferentCase_ReturnsTrue) + { + // Arrange + std::wstring str1 = L"Test"; + std::wstring str2 = L"TEST"; + + // Act + bool result = StringUtils::CaseInsensitiveEquals(str1, str2); + + // Assert + Assert::IsTrue(result); + } + + TEST_METHOD(CaseInsensitiveEquals_MixedCase_ReturnsTrue) + { + // Arrange + std::wstring str1 = L"TeSt StRiNg"; + std::wstring str2 = L"test STRING"; + + // Act + bool result = StringUtils::CaseInsensitiveEquals(str1, str2); + + // Assert + Assert::IsTrue(result); + } + + TEST_METHOD(CaseInsensitiveEquals_DifferentStrings_ReturnsFalse) + { + // Arrange + std::wstring str1 = L"test"; + std::wstring str2 = L"different"; + + // Act + bool result = StringUtils::CaseInsensitiveEquals(str1, str2); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(CaseInsensitiveEquals_DifferentLengths_ReturnsFalse) + { + // Arrange + std::wstring str1 = L"test"; + std::wstring str2 = L"testing"; + + // Act + bool result = StringUtils::CaseInsensitiveEquals(str1, str2); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(CaseInsensitiveEquals_EmptyStrings_ReturnsTrue) + { + // Arrange + std::wstring str1 = L""; + std::wstring str2 = L""; + + // Act + bool result = StringUtils::CaseInsensitiveEquals(str1, str2); + + // Assert + Assert::IsTrue(result); + } + + TEST_METHOD(CaseInsensitiveEquals_OneEmpty_ReturnsFalse) + { + // Arrange + std::wstring str1 = L"test"; + std::wstring str2 = L""; + + // Act + bool result = StringUtils::CaseInsensitiveEquals(str1, str2); + + // Assert + Assert::IsFalse(result); + } + + TEST_METHOD(CaseInsensitiveEquals_SpecialCharacters_ReturnsTrue) + { + // Arrange + std::wstring str1 = L"Test-123_Special!"; + std::wstring str2 = L"test-123_special!"; + + // Act + bool result = StringUtils::CaseInsensitiveEquals(str1, str2); + + // Assert + Assert::IsTrue(result); + } + }; +} \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesDataTests.cpp b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesDataTests.cpp new file mode 100644 index 0000000000..9228dbba47 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesDataTests.cpp @@ -0,0 +1,194 @@ +#include "pch.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace WorkspacesLibUnitTests +{ + TEST_CLASS(WorkspacesDataTests) + { + public: + TEST_METHOD(WorkspacesFile_ReturnsValidPath) + { + // Act + std::wstring result = WorkspacesData::WorkspacesFile(); + + // Assert + Assert::IsFalse(result.empty()); + Assert::IsTrue(result.find(L"workspaces.json") != std::wstring::npos); + } + + TEST_METHOD(TempWorkspacesFile_ReturnsValidPath) + { + // Act + std::wstring result = WorkspacesData::TempWorkspacesFile(); + + // Assert + Assert::IsFalse(result.empty()); + Assert::IsTrue(result.find(L"temp-workspaces.json") != std::wstring::npos); + } + + TEST_METHOD(WorkspacesFile_TempWorkspacesFile_DifferentPaths) + { + // Act + std::wstring workspacesFile = WorkspacesData::WorkspacesFile(); + std::wstring tempWorkspacesFile = WorkspacesData::TempWorkspacesFile(); + + // Assert + Assert::AreNotEqual(workspacesFile, tempWorkspacesFile); + } + + TEST_METHOD(Position_ToRect_ConvertsCorrectly) + { + // Arrange + WorkspacesData::WorkspacesProject::Application::Position position; + position.x = 100; + position.y = 200; + position.width = 800; + position.height = 600; + + // Act + RECT rect = position.toRect(); + + // Assert + Assert::AreEqual(100, static_cast<int>(rect.left)); + Assert::AreEqual(200, static_cast<int>(rect.top)); + Assert::AreEqual(900, static_cast<int>(rect.right)); // x + width + Assert::AreEqual(800, static_cast<int>(rect.bottom)); // y + height + } + + TEST_METHOD(Position_ToRect_ZeroPosition) + { + // Arrange + WorkspacesData::WorkspacesProject::Application::Position position; + position.x = 0; + position.y = 0; + position.width = 0; + position.height = 0; + + // Act + RECT rect = position.toRect(); + + // Assert + Assert::AreEqual(0, static_cast<int>(rect.left)); + Assert::AreEqual(0, static_cast<int>(rect.top)); + Assert::AreEqual(0, static_cast<int>(rect.right)); + Assert::AreEqual(0, static_cast<int>(rect.bottom)); + } + + TEST_METHOD(Position_ToRect_NegativeCoordinates) + { + // Arrange + WorkspacesData::WorkspacesProject::Application::Position position; + position.x = -100; + position.y = -50; + position.width = 200; + position.height = 150; + + // Act + RECT rect = position.toRect(); + + // Assert + Assert::AreEqual(-100, static_cast<int>(rect.left)); + Assert::AreEqual(-50, static_cast<int>(rect.top)); + Assert::AreEqual(100, static_cast<int>(rect.right)); // -100 + 200 + Assert::AreEqual(100, static_cast<int>(rect.bottom)); // -50 + 150 + } + + TEST_METHOD(Application_DefaultValues) + { + // Arrange & Act + WorkspacesData::WorkspacesProject::Application app; + + // Assert + Assert::IsTrue(app.id.empty()); + Assert::IsTrue(app.name.empty()); + Assert::IsTrue(app.title.empty()); + Assert::IsTrue(app.path.empty()); + Assert::IsTrue(app.packageFullName.empty()); + Assert::IsTrue(app.appUserModelId.empty()); + Assert::IsTrue(app.pwaAppId.empty()); + Assert::IsTrue(app.commandLineArgs.empty()); + Assert::IsFalse(app.isElevated); + Assert::IsFalse(app.canLaunchElevated); + Assert::IsFalse(app.isMinimized); + Assert::IsFalse(app.isMaximized); + Assert::AreEqual(0, static_cast<int>(app.position.x)); + Assert::AreEqual(0, static_cast<int>(app.position.y)); + Assert::AreEqual(0, static_cast<int>(app.position.width)); + Assert::AreEqual(0, static_cast<int>(app.position.height)); + Assert::AreEqual(0u, static_cast<unsigned int>(app.monitor)); + } + + TEST_METHOD(Application_Comparison_EqualObjects) + { + // Arrange + WorkspacesData::WorkspacesProject::Application app1; + app1.id = L"test-id"; + app1.name = L"Test App"; + app1.position.x = 100; + app1.position.y = 200; + + WorkspacesData::WorkspacesProject::Application app2; + app2.id = L"test-id"; + app2.name = L"Test App"; + app2.position.x = 100; + app2.position.y = 200; + + // Act & Assert + Assert::IsTrue(app1 == app2); + } + + TEST_METHOD(Application_Comparison_DifferentObjects) + { + // Arrange + WorkspacesData::WorkspacesProject::Application app1; + app1.id = L"test-id-1"; + app1.name = L"Test App 1"; + + WorkspacesData::WorkspacesProject::Application app2; + app2.id = L"test-id-2"; + app2.name = L"Test App 2"; + + // Act & Assert + Assert::IsTrue(app1 != app2); + } + + TEST_METHOD(Position_Comparison_EqualPositions) + { + // Arrange + WorkspacesData::WorkspacesProject::Application::Position pos1; + pos1.x = 100; + pos1.y = 200; + pos1.width = 800; + pos1.height = 600; + + WorkspacesData::WorkspacesProject::Application::Position pos2; + pos2.x = 100; + pos2.y = 200; + pos2.width = 800; + pos2.height = 600; + + // Act & Assert + Assert::IsTrue(pos1 == pos2); + } + + TEST_METHOD(Position_Comparison_DifferentPositions) + { + // Arrange + WorkspacesData::WorkspacesProject::Application::Position pos1; + pos1.x = 100; + pos1.y = 200; + pos1.width = 800; + pos1.height = 600; + + WorkspacesData::WorkspacesProject::Application::Position pos2; + pos2.x = 150; + pos2.y = 200; + pos2.width = 800; + pos2.height = 600; + + // Act & Assert + Assert::IsTrue(pos1 != pos2); + } + }; +} \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj new file mode 100644 index 0000000000..2b762fac88 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj @@ -0,0 +1,76 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <ProjectGuid>{A85D4D9F-9A39-4B5D-8B5A-9F2D5C9A8B4C}</ProjectGuid> + <Keyword>Win32Proj</Keyword> + <RootNamespace>WorkspacesLibUnitTests</RootNamespace> + <ProjectName>Workspaces.Lib.UnitTests</ProjectName> + </PropertyGroup> + <PropertyGroup Label="Configuration"> + + </PropertyGroup> + <PropertyGroup> + <ConfigurationType>DynamicLibrary</ConfigurationType> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\tests\Workspaces\</OutDir> + </PropertyGroup> + <ItemDefinitionGroup> + <ClCompile> + <AdditionalIncludeDirectories>..\;..\WorkspacesLib\;$(RepoRoot)src\;$(RepoRoot)src\common;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <PreprocessorDefinitions>WIN32;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <UseFullPaths>true</UseFullPaths> + </ClCompile> + <Link> + <AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\\lib;$(RepoRoot)$(Platform)\\$(Configuration)\\;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> + <AdditionalDependencies>propsys.lib;comctl32.lib;pathcch.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;Pathcch.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClInclude Include="pch.h" /> + <ClInclude Include="targetver.h" /> + </ItemGroup> + <ItemGroup> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader>Create</PrecompiledHeader> + </ClCompile> + <ClCompile Include="WorkspacesDataTests.cpp" /> + <ClCompile Include="StringUtilsTests.cpp" /> + <ClCompile Include="JsonUtilsTests.cpp" /> + <ClCompile Include="AppUtilsTests.cpp" /> + <ClCompile Include="PwaHelperTests.cpp" /> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + <ProjectReference Include="..\WorkspacesLib\WorkspacesLib.vcxproj"> + <Project>{b31fcc55-b5a4-4ea7-b414-2dceae6af332}</Project> + </ProjectReference> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> +</Project> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj.filters b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj.filters new file mode 100644 index 0000000000..71be38ab78 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/WorkspacesLibUnitTests.vcxproj.filters @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;hm;inl;inc;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="targetver.h"> + <Filter>Header Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <ClCompile Include="pch.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="WorkspacesDataTests.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="StringUtilsTests.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="JsonUtilsTests.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="AppUtilsTests.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="PwaHelperTests.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/packages.config b/src/modules/Workspaces/WorkspacesLib.UnitTests/packages.config new file mode 100644 index 0000000000..7d3cbd2b91 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/packages.config @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> +</packages> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/pch.cpp b/src/modules/Workspaces/WorkspacesLib.UnitTests/pch.cpp new file mode 100644 index 0000000000..17305716aa --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/pch.h b/src/modules/Workspaces/WorkspacesLib.UnitTests/pch.h new file mode 100644 index 0000000000..3a6b3adb6b --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/pch.h @@ -0,0 +1,21 @@ +#pragma once + +#include "targetver.h" + +// Headers for CppUnitTest +#pragma warning(disable : 26466) +#include "CppUnitTest.h" + +// Windows headers +#include <windows.h> +#include <string> +#include <memory> +#include <vector> +#include <optional> + +// Workspaces headers +#include <WorkspacesLib/WorkspacesData.h> +#include <WorkspacesLib/JsonUtils.h> +#include <WorkspacesLib/Result.h> +#include <WorkspacesLib/AppUtils.h> +#include <WorkspacesLib/PwaHelper.h> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib.UnitTests/targetver.h b/src/modules/Workspaces/WorkspacesLib.UnitTests/targetver.h new file mode 100644 index 0000000000..5b1f29cad0 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib.UnitTests/targetver.h @@ -0,0 +1,8 @@ +#pragma once + +// Including SDKDDKVer.h defines the highest available Windows platform. + +// If you wish to build your application for a previous Windows platform, include WinSDKVer.h and +// set the _WIN32_WINNT macro to the platform you wish to support before including SDKDDKVer.h. + +#include <SDKDDKVer.h> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib/AppUtils.cpp b/src/modules/Workspaces/WorkspacesLib/AppUtils.cpp index 19b33214b7..a37d82f8ca 100644 --- a/src/modules/Workspaces/WorkspacesLib/AppUtils.cpp +++ b/src/modules/Workspaces/WorkspacesLib/AppUtils.cpp @@ -1,5 +1,6 @@ #include "pch.h" #include "AppUtils.h" +#include "SteamHelper.h" #include <atlbase.h> #include <propvarutil.h> @@ -34,6 +35,8 @@ namespace Utils constexpr const wchar_t* EdgeFilename = L"msedge.exe"; constexpr const wchar_t* ChromeFilename = L"chrome.exe"; + + constexpr const wchar_t* SteamUrlProtocol = L"steam:"; } AppList IterateAppsFolder() @@ -138,6 +141,34 @@ namespace Utils else if (prop == NonLocalizable::PackageInstallPathProp || prop == NonLocalizable::InstallPathProp) { data.installPath = propVariantString.m_pData; + + if (!data.installPath.empty()) + { + const bool isSteamProtocol = data.installPath.rfind(NonLocalizable::SteamUrlProtocol, 0) == 0; + + if (isSteamProtocol) + { + Logger::info(L"Found steam game: protocol path: {}", data.installPath); + data.protocolPath = data.installPath; + + try + { + auto gameId = Steam::GetGameIdFromUrlProtocolPath(data.installPath); + auto gameFolder = Steam::GetSteamGameInfoFromAcfFile(gameId); + + if (gameFolder) + { + data.installPath = gameFolder->gameInstallationPath; + Logger::info(L"Found steam game: physical path: {}", data.installPath); + } + } + catch (std::exception ex) + { + Logger::error(L"Failed to get installPath for game {}", data.installPath); + Logger::error("Error: {}", ex.what()); + } + } + } } } @@ -397,5 +428,10 @@ namespace Utils { return installPath.ends_with(NonLocalizable::ChromeFilename); } + + bool AppData::IsSteamGame() const + { + return protocolPath.rfind(NonLocalizable::SteamUrlProtocol, 0) == 0; + } } } \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib/AppUtils.h b/src/modules/Workspaces/WorkspacesLib/AppUtils.h index 3c81049f83..80b5e2fd49 100644 --- a/src/modules/Workspaces/WorkspacesLib/AppUtils.h +++ b/src/modules/Workspaces/WorkspacesLib/AppUtils.h @@ -13,10 +13,12 @@ namespace Utils std::wstring packageFullName; std::wstring appUserModelId; std::wstring pwaAppId; + std::wstring protocolPath; bool canLaunchElevated = false; bool IsEdge() const; bool IsChrome() const; + bool IsSteamGame() const; }; using AppList = std::vector<AppData>; diff --git a/src/modules/Workspaces/WorkspacesLib/PwaHelper.cpp b/src/modules/Workspaces/WorkspacesLib/PwaHelper.cpp index 41dfa9ef86..0c381d544b 100644 --- a/src/modules/Workspaces/WorkspacesLib/PwaHelper.cpp +++ b/src/modules/Workspaces/WorkspacesLib/PwaHelper.cpp @@ -1,5 +1,6 @@ #include "pch.h" #include "PwaHelper.h" +#include "WindowUtils.h" #include <filesystem> @@ -51,7 +52,7 @@ namespace Utils localFolder = L""; // Ensure it is explicitly set to empty on failure } } - + return localFolder; } @@ -193,7 +194,7 @@ namespace Utils return std::nullopt; } - + std::optional<std::wstring> PwaHelper::GetChromeAppId(const std::wstring& windowAumid) const { const auto appIdIndexStart = windowAumid.find(NonLocalizable::ChromeAppIdIdentifier); @@ -256,85 +257,4 @@ namespace Utils return result; } - - std::wstring PwaHelper::GetAUMIDFromWindow(HWND hwnd) const - { - std::wstring result{}; - if (hwnd == NULL) - { - return result; - } - - Microsoft::WRL::ComPtr<IPropertyStore> propertyStore; - HRESULT hr = SHGetPropertyStoreForWindow(hwnd, IID_PPV_ARGS(&propertyStore)); - if (FAILED(hr)) - { - return result; - } - - PROPVARIANT propVariant; - PropVariantInit(&propVariant); - - hr = propertyStore->GetValue(PKEY_AppUserModel_ID, &propVariant); - if (SUCCEEDED(hr) && propVariant.vt == VT_LPWSTR && propVariant.pwszVal != nullptr) - { - result = propVariant.pwszVal; - } - - PropVariantClear(&propVariant); - - Logger::info(L"Found a window with aumid {}", result); - return result; - } - - std::wstring PwaHelper::GetAUMIDFromProcessId(DWORD processId) const - { - HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId); - if (hProcess == NULL) - { - Logger::error(L"Failed to open process handle. Error: {}", get_last_error_or_default(GetLastError())); - return {}; - } - - // Get the package full name for the process - UINT32 packageFullNameLength = 0; - LONG rc = GetPackageFullName(hProcess, &packageFullNameLength, nullptr); - if (rc != ERROR_INSUFFICIENT_BUFFER) - { - Logger::error(L"Failed to get package full name length. Error code: {}", rc); - CloseHandle(hProcess); - return {}; - } - - std::vector<wchar_t> packageFullName(packageFullNameLength); - rc = GetPackageFullName(hProcess, &packageFullNameLength, packageFullName.data()); - if (rc != ERROR_SUCCESS) - { - Logger::error(L"Failed to get package full name. Error code: {}", rc); - CloseHandle(hProcess); - return {}; - } - - // Get the AUMID for the package - UINT32 appModelIdLength = 0; - rc = GetApplicationUserModelId(hProcess, &appModelIdLength, nullptr); - if (rc != ERROR_INSUFFICIENT_BUFFER) - { - Logger::error(L"Failed to get AppUserModelId length. Error code: {}", rc); - CloseHandle(hProcess); - return {}; - } - - std::vector<wchar_t> appModelId(appModelIdLength); - rc = GetApplicationUserModelId(hProcess, &appModelIdLength, appModelId.data()); - if (rc != ERROR_SUCCESS) - { - Logger::error(L"Failed to get AppUserModelId. Error code: {}", rc); - CloseHandle(hProcess); - return {}; - } - - CloseHandle(hProcess); - return std::wstring(appModelId.data()); - } } \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib/PwaHelper.h b/src/modules/Workspaces/WorkspacesLib/PwaHelper.h index d304cac91d..48c8c60fe5 100644 --- a/src/modules/Workspaces/WorkspacesLib/PwaHelper.h +++ b/src/modules/Workspaces/WorkspacesLib/PwaHelper.h @@ -12,19 +12,16 @@ namespace Utils PwaHelper(); ~PwaHelper() = default; - std::wstring GetAUMIDFromWindow(HWND hWnd) const; - std::optional<std::wstring> GetEdgeAppId(const std::wstring& windowAumid) const; - std::optional<std::wstring> GetChromeAppId(const std::wstring& windowAumid) const; + std::optional<std::wstring> GetChromeAppId(const std::wstring& windowAumid) const; std::wstring SearchPwaName(const std::wstring& pwaAppId, const std::wstring& windowAumid) const; - + private: void InitAppIds(const std::wstring& browserDataFolder, const std::wstring& browserDirPrefix, const std::function<void(const std::wstring&)>& addingAppIdCallback); void InitEdgeAppIds(); void InitChromeAppIds(); std::wstring GetAppIdFromCommandLineArgs(const std::wstring& commandLineArgs) const; - std::wstring GetAUMIDFromProcessId(DWORD processId) const; std::map<std::wstring, std::wstring> m_edgeAppIds; std::vector<std::wstring> m_chromeAppIds; diff --git a/src/modules/Workspaces/WorkspacesLib/SteamGameHelper.cpp b/src/modules/Workspaces/WorkspacesLib/SteamGameHelper.cpp new file mode 100644 index 0000000000..404002e284 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib/SteamGameHelper.cpp @@ -0,0 +1,171 @@ +#include "pch.h" +#include "SteamHelper.h" +#include <fstream> +#include <sstream> +#include <unordered_map> +#include <filesystem> +#include <regex> +#include <string> + +namespace Utils +{ + + static std::wstring Utf8ToWide(const std::string& utf8) + { + if (utf8.empty()) + return L""; + + int size = MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast<int>(utf8.size()), nullptr, 0); + if (size <= 0) + return L""; + + std::wstring wide(size, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, utf8.data(), static_cast<int>(utf8.size()), wide.data(), size); + return wide; + } + + namespace Steam + { + using namespace std; + namespace fs = std::filesystem; + + static std::optional<std::wstring> GetSteamExePathFromRegistry() + { + static std::optional<std::wstring> cachedPath; + if (cachedPath.has_value()) + { + return cachedPath; + } + + const std::vector<HKEY> roots = { HKEY_CLASSES_ROOT, HKEY_LOCAL_MACHINE, HKEY_USERS }; + const std::vector<std::wstring> subKeys = { + L"steam\\shell\\open\\command", + L"Software\\Classes\\steam\\shell\\open\\command", + }; + + for (HKEY root : roots) + { + for (const auto& subKey : subKeys) + { + HKEY hKey; + if (RegOpenKeyExW(root, subKey.c_str(), 0, KEY_READ, &hKey) == ERROR_SUCCESS) + { + wchar_t value[512]; + DWORD size = sizeof(value); + DWORD type = 0; + + if (RegQueryValueExW(hKey, nullptr, nullptr, &type, reinterpret_cast<LPBYTE>(value), &size) == ERROR_SUCCESS && + (type == REG_SZ || type == REG_EXPAND_SZ)) + { + std::wregex exeRegex(LR"delim("([^"]+steam\.exe)")delim"); + std::wcmatch match; + if (std::regex_search(value, match, exeRegex) && match.size() > 1) + { + RegCloseKey(hKey); + cachedPath = match[1].str(); + return cachedPath; + } + } + + RegCloseKey(hKey); + } + } + } + + cachedPath = std::nullopt; + return std::nullopt; + } + + static fs::path GetSteamBasePath() + { + auto steamFolderOpt = GetSteamExePathFromRegistry(); + if (!steamFolderOpt) + { + return {}; + } + + return fs::path(*steamFolderOpt).parent_path() / L"steamapps"; + } + + static fs::path GetAcfFilePath(const std::wstring& gameId) + { + auto steamFolderOpt = GetSteamExePathFromRegistry(); + if (!steamFolderOpt) + { + return {}; + } + + return GetSteamBasePath() / (L"appmanifest_" + gameId + L".acf"); + } + + static fs::path GetGameInstallPath(const std::wstring& gameFolderName) + { + auto steamFolderOpt = GetSteamExePathFromRegistry(); + if (!steamFolderOpt) + { + return {}; + } + + return GetSteamBasePath() / L"common" / gameFolderName; + } + + static unordered_map<wstring, wstring> ParseAcfFile(const fs::path& acfPath) + { + unordered_map<wstring, wstring> result; + + ifstream file(acfPath); + if (!file.is_open()) + return result; + + string line; + while (getline(file, line)) + { + smatch matches; + static const regex pattern(R"delim("([^"]+)"\s+"([^"]+)")delim"); + + if (regex_search(line, matches, pattern) && matches.size() == 3) + { + wstring key = Utf8ToWide(matches[1].str()); + wstring value = Utf8ToWide(matches[2].str()); + result[key] = value; + } + } + + return result; + } + + std::unique_ptr<Steam::SteamGame> GetSteamGameInfoFromAcfFile(const std::wstring& gameId) + { + fs::path acfPath = Steam::GetAcfFilePath(gameId); + + if (!fs::exists(acfPath)) + return nullptr; + + auto kv = ParseAcfFile(acfPath); + if (kv.empty() || kv.find(L"installdir") == kv.end()) + return nullptr; + + fs::path gamePath = Steam::GetGameInstallPath(kv[L"installdir"]); + if (!fs::exists(gamePath)) + return nullptr; + + auto game = std::make_unique<Steam::SteamGame>(); + game->gameId = gameId; + game->gameInstallationPath = gamePath.wstring(); + return game; + } + + std::wstring GetGameIdFromUrlProtocolPath(const std::wstring& urlPath) + { + const std::wstring steamGamePrefix = L"steam://rungameid/"; + + if (urlPath.rfind(steamGamePrefix, 0) == 0) + { + return urlPath.substr(steamGamePrefix.length()); + } + + return L""; + } + + } +} \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib/SteamHelper.h b/src/modules/Workspaces/WorkspacesLib/SteamHelper.h new file mode 100644 index 0000000000..a80a942f4a --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib/SteamHelper.h @@ -0,0 +1,24 @@ +#pragma once + +#include "pch.h" + +namespace Utils +{ + namespace NonLocalizable + { + const std::wstring AcfFileNameTemplate = L"appmanifest_<gameid>.acfs"; + } + + namespace Steam + { + struct SteamGame + { + std::wstring gameId; + std::wstring gameInstallationPath; + }; + + std::unique_ptr<SteamGame> GetSteamGameInfoFromAcfFile(const std::wstring& gameId); + + std::wstring GetGameIdFromUrlProtocolPath(const std::wstring& urlPath); + } +} diff --git a/src/modules/Workspaces/WorkspacesLib/StringUtils.cpp b/src/modules/Workspaces/WorkspacesLib/StringUtils.cpp new file mode 100644 index 0000000000..f7cf0ee109 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib/StringUtils.cpp @@ -0,0 +1,18 @@ +#include "pch.h" + +#include "StringUtils.h" + +namespace StringUtils +{ + bool CaseInsensitiveEquals(const std::wstring& str1, const std::wstring& str2) + { + if (str1.size() != str2.size()) + { + return false; + } + + return std::equal(str1.begin(), str1.end(), str2.begin(), [](wchar_t ch1, wchar_t ch2) { + return towupper(ch1) == towupper(ch2); + }); + } +} \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib/StringUtils.h b/src/modules/Workspaces/WorkspacesLib/StringUtils.h index ea53ed3f5b..4584ab6428 100644 --- a/src/modules/Workspaces/WorkspacesLib/StringUtils.h +++ b/src/modules/Workspaces/WorkspacesLib/StringUtils.h @@ -6,15 +6,5 @@ namespace StringUtils { - bool CaseInsensitiveEquals(const std::wstring& str1, const std::wstring& str2) - { - if (str1.size() != str2.size()) - { - return false; - } - - return std::equal(str1.begin(), str1.end(), str2.begin(), [](wchar_t ch1, wchar_t ch2) { - return towupper(ch1) == towupper(ch2); - }); - } + bool CaseInsensitiveEquals(const std::wstring& str1, const std::wstring& str2); } diff --git a/src/modules/Workspaces/WorkspacesLib/WindowUtils.cpp b/src/modules/Workspaces/WorkspacesLib/WindowUtils.cpp new file mode 100644 index 0000000000..76009ca118 --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib/WindowUtils.cpp @@ -0,0 +1,105 @@ +#include "pch.h" +#include "WindowUtils.h" +#include <filesystem> + +#include <appmodel.h> + +#include <shellapi.h> +#include <ShlObj.h> +#include <shobjidl.h> +#include <tlhelp32.h> +#include <wrl.h> +#include <propkey.h> + +#include <wil/com.h> + +#include <common/logger/logger.h> +#include <common/utils/winapi_error.h> + +#include <WorkspacesLib/AppUtils.h> +#include <WorkspacesLib/CommandLineArgsHelper.h> +#include <WorkspacesLib/StringUtils.h> + +namespace Utils +{ + std::wstring GetAUMIDFromProcessId(DWORD processId) + { + HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId); + if (hProcess == NULL) + { + Logger::error(L"Failed to open process handle. Error: {}", get_last_error_or_default(GetLastError())); + return {}; + } + + // Get the package full name for the process + UINT32 packageFullNameLength = 0; + LONG rc = GetPackageFullName(hProcess, &packageFullNameLength, nullptr); + if (rc != ERROR_INSUFFICIENT_BUFFER) + { + Logger::error(L"Failed to get package full name length. Error code: {}", rc); + CloseHandle(hProcess); + return {}; + } + + std::vector<wchar_t> packageFullName(packageFullNameLength); + rc = GetPackageFullName(hProcess, &packageFullNameLength, packageFullName.data()); + if (rc != ERROR_SUCCESS) + { + Logger::error(L"Failed to get package full name. Error code: {}", rc); + CloseHandle(hProcess); + return {}; + } + + // Get the AUMID for the package + UINT32 appModelIdLength = 0; + rc = GetApplicationUserModelId(hProcess, &appModelIdLength, nullptr); + if (rc != ERROR_INSUFFICIENT_BUFFER) + { + Logger::error(L"Failed to get AppUserModelId length. Error code: {}", rc); + CloseHandle(hProcess); + return {}; + } + + std::vector<wchar_t> appModelId(appModelIdLength); + rc = GetApplicationUserModelId(hProcess, &appModelIdLength, appModelId.data()); + if (rc != ERROR_SUCCESS) + { + Logger::error(L"Failed to get AppUserModelId. Error code: {}", rc); + CloseHandle(hProcess); + return {}; + } + + CloseHandle(hProcess); + return std::wstring(appModelId.data()); + } + + std::wstring GetAUMIDFromWindow(HWND hwnd) + { + std::wstring result{}; + if (hwnd == NULL) + { + return result; + } + + Microsoft::WRL::ComPtr<IPropertyStore> propertyStore; + HRESULT hr = SHGetPropertyStoreForWindow(hwnd, IID_PPV_ARGS(&propertyStore)); + if (FAILED(hr)) + { + return result; + } + + PROPVARIANT propVariant; + PropVariantInit(&propVariant); + + hr = propertyStore->GetValue(PKEY_AppUserModel_ID, &propVariant); + if (SUCCEEDED(hr) && propVariant.vt == VT_LPWSTR && propVariant.pwszVal != nullptr) + { + result = propVariant.pwszVal; + } + + PropVariantClear(&propVariant); + + Logger::info(L"Found a window with aumid {}", result); + return result; + } +} \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib/WindowUtils.h b/src/modules/Workspaces/WorkspacesLib/WindowUtils.h new file mode 100644 index 0000000000..e2d8b4c8ae --- /dev/null +++ b/src/modules/Workspaces/WorkspacesLib/WindowUtils.h @@ -0,0 +1,12 @@ +#pragma once + +#include <functional> + +#include <WorkspacesLib/AppUtils.h> +#include <wtypes.h> + +namespace Utils +{ + std::wstring GetAUMIDFromWindow(HWND hWnd); + std::wstring GetAUMIDFromProcessId(DWORD processId); +}; diff --git a/src/modules/Workspaces/WorkspacesLib/WorkspacesData.cpp b/src/modules/Workspaces/WorkspacesLib/WorkspacesData.cpp index 4b8b0ac4a5..434181210e 100644 --- a/src/modules/Workspaces/WorkspacesLib/WorkspacesData.cpp +++ b/src/modules/Workspaces/WorkspacesLib/WorkspacesData.cpp @@ -87,6 +87,7 @@ namespace WorkspacesData const static wchar_t* MaximizedID = L"maximized"; const static wchar_t* PositionID = L"position"; const static wchar_t* MonitorID = L"monitor"; + const static wchar_t* VersionID = L"version"; } json::JsonObject ToJson(const WorkspacesProject::Application& data) @@ -106,6 +107,7 @@ namespace WorkspacesData json.SetNamedValue(NonLocalizable::MaximizedID, json::value(data.isMaximized)); json.SetNamedValue(NonLocalizable::PositionID, PositionJSON::ToJson(data.position)); json.SetNamedValue(NonLocalizable::MonitorID, json::value(data.monitor)); + json.SetNamedValue(NonLocalizable::VersionID, json::value(data.version)); return json; } @@ -168,6 +170,11 @@ namespace WorkspacesData result.position = position.value(); } + + if (json.HasKey(NonLocalizable::VersionID)) + { + result.version = json.GetNamedString(NonLocalizable::VersionID); + } } catch (const winrt::hresult_error&) { @@ -286,6 +293,7 @@ namespace WorkspacesData const static wchar_t* MoveExistingWindowsID = L"move-existing-windows"; const static wchar_t* MonitorConfigurationID = L"monitor-configuration"; const static wchar_t* AppsID = L"applications"; + const static wchar_t* Version = L"version"; } json::JsonObject ToJson(const WorkspacesProject& data) diff --git a/src/modules/Workspaces/WorkspacesLib/WorkspacesData.h b/src/modules/Workspaces/WorkspacesLib/WorkspacesData.h index 272cf65d5a..a8e4dbb4b0 100644 --- a/src/modules/Workspaces/WorkspacesLib/WorkspacesData.h +++ b/src/modules/Workspaces/WorkspacesLib/WorkspacesData.h @@ -33,6 +33,9 @@ namespace WorkspacesData std::wstring appUserModelId; std::wstring pwaAppId; std::wstring commandLineArgs; + + // empty to 1, + std::wstring version; bool isElevated{}; bool canLaunchElevated{}; bool isMinimized{}; @@ -69,10 +72,10 @@ namespace WorkspacesData std::wstring id; std::wstring name; - time_t creationTime; + time_t creationTime{}; std::optional<time_t> lastLaunchedTime; - bool isShortcutNeeded; - bool moveExistingWindows; + bool isShortcutNeeded{}; + bool moveExistingWindows{}; std::vector<Monitor> monitors; std::vector<Application> apps; }; @@ -86,7 +89,7 @@ namespace WorkspacesData { WorkspacesData::WorkspacesProject::Application application; HWND window{}; - LaunchingState state { LaunchingState::Waiting }; + LaunchingState state{ LaunchingState::Waiting }; }; using LaunchingAppStateMap = std::map<WorkspacesData::WorkspacesProject::Application, LaunchingAppState>; diff --git a/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj b/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj index 27394e29ee..5c300ae8a0 100644 --- a/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj +++ b/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{b31fcc55-b5a4-4ea7-b414-2dceae6af332}</ProjectGuid> @@ -8,10 +9,9 @@ <RootNamespace>WorkspacesLib</RootNamespace> <ProjectName>WorkspacesLib</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -23,12 +23,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\;$(SolutionDir)src\;$(SolutionDir)src\common;$(SolutionDir)src\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;$(RepoRoot)src\;$(RepoRoot)src\common;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -41,9 +41,11 @@ <ClInclude Include="pch.h" /> <ClInclude Include="PwaHelper.h" /> <ClInclude Include="Result.h" /> + <ClInclude Include="SteamHelper.h" /> <ClInclude Include="StringUtils.h" /> <ClInclude Include="utils.h" /> <ClInclude Include="WbemHelper.h" /> + <ClInclude Include="WindowUtils.h" /> <ClInclude Include="WorkspacesData.h" /> <ClInclude Include="trace.h" /> </ItemGroup> @@ -57,16 +59,19 @@ <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> </ClCompile> <ClCompile Include="PwaHelper.cpp" /> + <ClCompile Include="SteamGameHelper.cpp" /> + <ClCompile Include="StringUtils.cpp" /> <ClCompile Include="two_way_pipe_message_ipc.cpp" /> <ClCompile Include="WbemHelper.cpp" /> + <ClCompile Include="WindowUtils.cpp" /> <ClCompile Include="WorkspacesData.cpp" /> <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\interop\PowerToys.Interop.vcxproj"> <Project>{f055103b-f80b-4d0c-bf48-057c55620033}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> </ItemGroup> @@ -74,17 +79,17 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj.filters b/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj.filters index b066c16a57..4f234009eb 100644 --- a/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj.filters +++ b/src/modules/Workspaces/WorkspacesLib/WorkspacesLib.vcxproj.filters @@ -53,6 +53,12 @@ <ClInclude Include="StringUtils.h"> <Filter>Header Files</Filter> </ClInclude> + <ClInclude Include="SteamHelper.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="WindowUtils.h"> + <Filter>Header Files</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <ClCompile Include="pch.cpp"> @@ -88,6 +94,15 @@ <ClCompile Include="WbemHelper.cpp"> <Filter>Source Files</Filter> </ClCompile> + <ClCompile Include="SteamGameHelper.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="WindowUtils.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="StringUtils.cpp"> + <Filter>Source Files</Filter> + </ClCompile> </ItemGroup> <ItemGroup> <None Include="packages.config" /> diff --git a/src/modules/Workspaces/WorkspacesLib/packages.config b/src/modules/Workspaces/WorkspacesLib/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/Workspaces/WorkspacesLib/packages.config +++ b/src/modules/Workspaces/WorkspacesLib/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj b/src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj index 28ee035180..9006bfc7d8 100644 --- a/src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj +++ b/src/modules/Workspaces/WorkspacesModuleInterface/WorkspacesModuleInterface.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{45285DF2-9742-4ECA-9AC9-58951FC26489}</ProjectGuid> @@ -8,12 +9,11 @@ <RootNamespace>workspaces</RootNamespace> <ProjectName>WorkspacesModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -23,13 +23,13 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.WorkspacesModuleInterface</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>PROJECTS_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -48,10 +48,10 @@ </ClCompile> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> <ProjectReference Include="..\WorkspacesLib\WorkspacesLib.vcxproj"> @@ -66,14 +66,14 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesModuleInterface/dllmain.cpp b/src/modules/Workspaces/WorkspacesModuleInterface/dllmain.cpp index 90c898fc5f..a4caf01649 100644 --- a/src/modules/Workspaces/WorkspacesModuleInterface/dllmain.cpp +++ b/src/modules/Workspaces/WorkspacesModuleInterface/dllmain.cpp @@ -201,7 +201,7 @@ public: Logger::error(message.value()); } } - m_toggleEditorEventWaiter = EventWaiter(CommonSharedConstants::WORKSPACES_LAUNCH_EDITOR_EVENT, [&](int err) { + m_toggleEditorEventWaiter.start(CommonSharedConstants::WORKSPACES_LAUNCH_EDITOR_EVENT, [&](DWORD err) { if (err == ERROR_SUCCESS) { Logger::trace(L"{} event was signaled", CommonSharedConstants::WORKSPACES_LAUNCH_EDITOR_EVENT); diff --git a/src/modules/Workspaces/WorkspacesModuleInterface/packages.config b/src/modules/Workspaces/WorkspacesModuleInterface/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/Workspaces/WorkspacesModuleInterface/packages.config +++ b/src/modules/Workspaces/WorkspacesModuleInterface/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesSnapshotTool/SnapshotUtils.cpp b/src/modules/Workspaces/WorkspacesSnapshotTool/SnapshotUtils.cpp index 1d5bc8a179..d55fca4412 100644 --- a/src/modules/Workspaces/WorkspacesSnapshotTool/SnapshotUtils.cpp +++ b/src/modules/Workspaces/WorkspacesSnapshotTool/SnapshotUtils.cpp @@ -11,6 +11,7 @@ #include <WorkspacesLib/AppUtils.h> #include <WorkspacesLib/PwaHelper.h> +#include <WorkspacesLib/WindowUtils.h> #include <WindowProperties/WorkspacesWindowPropertyUtils.h> #include "Generated Files/resource.h" @@ -71,6 +72,8 @@ namespace SnapshotUtils continue; } + Logger::info("Try to get window app:{}", reinterpret_cast<void*>(window)); + DWORD pid{}; GetWindowThreadProcessId(window, &pid); @@ -118,17 +121,26 @@ namespace SnapshotUtils auto data = Utils::Apps::GetApp(processPath, pid, installedApps); if (!data.has_value() || data->name.empty()) { - Logger::info(L"Installed app not found: {}", processPath); + Logger::info(L"Installed app not found:{},{}", reinterpret_cast<void*>(window), processPath); continue; } + if (!data->IsSteamGame() && !WindowUtils::HasThickFrame(window)) + { + // Only care about steam games if it has no thick frame to remain consistent with + // the behavior as before. + continue; + } + + Logger::info(L"Found app for window:{},{}", reinterpret_cast<void*>(window), processPath); + auto appData = data.value(); bool isEdge = appData.IsEdge(); bool isChrome = appData.IsChrome(); if (isEdge || isChrome) { - auto windowAumid = pwaHelper.GetAUMIDFromWindow(window); + auto windowAumid = Utils::GetAUMIDFromWindow(window); std::optional<std::wstring> pwaAppId{}; if (isEdge) @@ -147,6 +159,8 @@ namespace SnapshotUtils appData.pwaAppId = pwaAppId.value(); appData.name = pwaName + L" (" + appData.name + L")"; + // If it's pwa app, appUserModelId should be their own pwa id. + appData.appUserModelId = windowAumid; } } @@ -174,6 +188,7 @@ namespace SnapshotUtils .appUserModelId = appData.appUserModelId, .pwaAppId = appData.pwaAppId, .commandLineArgs = L"", + .version = L"1", .isElevated = IsProcessElevated(pid), .canLaunchElevated = appData.canLaunchElevated, .isMinimized = isMinimized, diff --git a/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj b/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj index 05e4241c1c..562ec78471 100644 --- a/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj +++ b/src/modules/Workspaces/WorkspacesSnapshotTool/WorkspacesSnapshotTool.vcxproj @@ -2,7 +2,7 @@ <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <!-- Project configurations --> <!-- Props that should be disabled while building on CI server --> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h WorkspacesSnapshotToolResources.base.rc WorkspacesSnapshotToolResources.rc" /> </Target> @@ -13,7 +13,6 @@ <ConformanceMode>false</ConformanceMode> <TreatWarningAsError>true</TreatWarningAsError> <LanguageStandard>stdcpplatest</LanguageStandard> - <AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions> <PreprocessorDefinitions>_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> @@ -60,7 +59,7 @@ <!-- Props that are constant for both Debug and Release configurations --> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> </PropertyGroup> @@ -166,15 +165,15 @@ <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <Import Project="..\..\..\..\deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesSnapshotTool/packages.config b/src/modules/Workspaces/WorkspacesSnapshotTool/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/Workspaces/WorkspacesSnapshotTool/packages.config +++ b/src/modules/Workspaces/WorkspacesSnapshotTool/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesWindowArranger/WindowArranger.cpp b/src/modules/Workspaces/WorkspacesWindowArranger/WindowArranger.cpp index 7b04135d1a..ce3fad0a0a 100644 --- a/src/modules/Workspaces/WorkspacesWindowArranger/WindowArranger.cpp +++ b/src/modules/Workspaces/WorkspacesWindowArranger/WindowArranger.cpp @@ -13,6 +13,7 @@ #include <WindowProperties/WorkspacesWindowPropertyUtils.h> #include <WorkspacesLib/PwaHelper.h> +#include <WorkspacesLib/WindowUtils.h> namespace NonLocalizable { @@ -200,6 +201,14 @@ std::optional<WindowWithDistance> WindowArranger::GetNearestWindow(const Workspa } auto data = Utils::Apps::GetApp(processPath, pid, m_installedApps); + + if (!data->IsSteamGame() && !WindowUtils::HasThickFrame(window)) + { + // Only care about steam games if it has no thick frame to remain consistent with + // the behavior as before. + continue; + } + if (!data.has_value()) { continue; @@ -212,7 +221,7 @@ std::optional<WindowWithDistance> WindowArranger::GetNearestWindow(const Workspa bool isChrome = appData.IsChrome(); if (isEdge || isChrome) { - auto windowAumid = pwaHelper.GetAUMIDFromWindow(window); + auto windowAumid = Utils::GetAUMIDFromWindow(window); std::optional<std::wstring> pwaAppId{}; if (isEdge) diff --git a/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj b/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj index 2451be2470..b56b3ff86f 100644 --- a/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj +++ b/src/modules/Workspaces/WorkspacesWindowArranger/WorkspacesWindowArranger.vcxproj @@ -2,7 +2,7 @@ <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <!-- Project configurations --> <!-- Props that should be disabled while building on CI server --> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h WorkspacesWindowArrangerResource.base.rc WorkspacesWindowArrangerResource.rc" /> </Target> @@ -13,7 +13,6 @@ <ConformanceMode>false</ConformanceMode> <TreatWarningAsError>true</TreatWarningAsError> <LanguageStandard>stdcpplatest</LanguageStandard> - <AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions> <PreprocessorDefinitions>_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> @@ -60,7 +59,7 @@ <!-- Props that are constant for both Debug and Release configurations --> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> </PropertyGroup> @@ -165,15 +164,15 @@ <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <Import Project="..\..\..\..\deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/Workspaces/WorkspacesWindowArranger/packages.config b/src/modules/Workspaces/WorkspacesWindowArranger/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/Workspaces/WorkspacesWindowArranger/packages.config +++ b/src/modules/Workspaces/WorkspacesWindowArranger/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/Workspaces/workspaces-common/WindowFilter.h b/src/modules/Workspaces/workspaces-common/WindowFilter.h index c76ad81237..8ae1a5411b 100644 --- a/src/modules/Workspaces/workspaces-common/WindowFilter.h +++ b/src/modules/Workspaces/workspaces-common/WindowFilter.h @@ -9,10 +9,12 @@ namespace WindowFilter { auto style = GetWindowLong(window, GWL_STYLE); bool isPopup = WindowUtils::HasStyle(style, WS_POPUP); - bool hasThickFrame = WindowUtils::HasStyle(style, WS_THICKFRAME); bool hasCaption = WindowUtils::HasStyle(style, WS_CAPTION); bool hasMinimizeMaximizeButtons = WindowUtils::HasStyle(style, WS_MINIMIZEBOX) || WindowUtils::HasStyle(style, WS_MAXIMIZEBOX); - if (isPopup && !(hasThickFrame && (hasCaption || hasMinimizeMaximizeButtons))) + + Logger::info("Style for window: {}, {:#x}", reinterpret_cast<void*>(window), style); + + if (isPopup && !(hasCaption || hasMinimizeMaximizeButtons)) { // popup windows we want to snap: e.g. Calculator, Telegram // popup windows we don't want to snap: start menu, notification popup, tray window, etc. diff --git a/src/modules/Workspaces/workspaces-common/WindowUtils.h b/src/modules/Workspaces/workspaces-common/WindowUtils.h index 8424591dfa..79051f4ea2 100644 --- a/src/modules/Workspaces/workspaces-common/WindowUtils.h +++ b/src/modules/Workspaces/workspaces-common/WindowUtils.h @@ -121,4 +121,11 @@ namespace WindowUtils return std::wstring(title); } + + + inline bool HasThickFrame(HWND window) + { + auto style = GetWindowLong(window, GWL_STYLE); + return WindowUtils::HasStyle(style, WS_THICKFRAME); + } } \ No newline at end of file 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 <wrl/client.h> 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<LoopbackCapture>(); + } + 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<double>(m_loopbackSampleRate) / static_cast<double>(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<winrt::MediaStreamSample> 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<HANDLE> events = { m_endEvent.get(), m_startEvent.get() }; + auto waitResult = WaitForMultipleObjectsEx(static_cast<DWORD>(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<winrt::MediaStreamSample> 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<winrt::MediaStreamSample>{}; + } 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<float> 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<float> resampledSamples; + while (true) + { + const uint32_t inputFrames = static_cast<uint32_t>(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<uint32_t>(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<float>(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<float>(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<float>(s1 * (1.0 - frac) + s2 * frac); + } + + resampledSamples.push_back(sample); + } + + m_resampleInputPos += m_resampleRatio; + } + + uint32_t consumedFrames = static_cast<uint32_t>(m_resampleInputPos); + if (consumedFrames > 0) + { + size_t samplesToErase = static_cast<size_t>(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<size_t>(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<float> rawLoopbackSamples; + { + std::vector<float> 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<uint32_t>(m_loopbackBuffer.size()); + std::vector<uint8_t> outputData(outputSampleCount * sizeof(float), 0); + float* outputFloats = reinterpret_cast<float*>(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<uint32_t>(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<int64_t>(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<std::pair<winrt::Windows::Storage::Streams::IBuffer, winrt::Windows::Foundation::TimeSpan>> 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<uint32_t>(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<uint32_t>(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<uint32_t>(totalBytes) / sizeof(float); + const uint32_t frames = (m_graphChannels > 0) ? (sampleCount / m_graphChannels) : 0; + const int64_t durationTicks = (m_graphSampleRate > 0) ? (static_cast<int64_t>(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<winrt::TimeSpan> 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<float> rawLoopbackSamples; + { + std::vector<float> 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<uint8_t> outputData(outputSampleCount * sizeof(float), 0); + float* outputFloats = reinterpret_cast<float*>(outputData.data()); + + { + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + uint32_t samplesToUse = min(outputSampleCount, static_cast<uint32_t>(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<uint32_t>(outputData.size())); + } + else if (m_captureMicrophone && numMicSamples > 0) + { + // Mix loopback into mic samples + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + float* bufferData = reinterpret_cast<float*>(sampleBuffer.data()); + uint32_t samplesToMix = min(numMicSamples, static_cast<uint32_t>(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<int64_t>(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<float> 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<LoopbackCapture> m_loopbackCapture; + std::vector<float> 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<float> 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<winrt::Windows::Media::Core::MediaStreamSample> m_samples; std::atomic<bool> m_initialized = false; std::atomic<bool> 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 new file mode 100644 index 0000000000..18b08b6cf5 --- /dev/null +++ b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp @@ -0,0 +1,611 @@ +//============================================================================== +// +// Zoomit +// Sysinternals - www.sysinternals.com +// +// GIF recording support using Windows Imaging Component (WIC) +// +//============================================================================== +#include "pch.h" +#include "GifRecordingSession.h" +#include "CaptureFrameWait.h" +#include <shcore.h> + +extern DWORD g_RecordScaling; + +namespace winrt +{ + using namespace Windows::Foundation; + using namespace Windows::Graphics; + using namespace Windows::Graphics::Capture; + using namespace Windows::Graphics::DirectX; + using namespace Windows::Graphics::DirectX::Direct3D11; + using namespace Windows::Storage; + using namespace Windows::UI::Composition; +} + +namespace util +{ + using namespace robmikh::common::uwp; +} + +const float CLEAR_COLOR[] = { 0.0f, 0.0f, 0.0f, 1.0f }; + +int32_t EnsureEvenGif(int32_t value) +{ + if (value % 2 == 0) + { + return value; + } + else + { + return value + 1; + } +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::GifRecordingSession +// +//---------------------------------------------------------------------------- +GifRecordingSession::GifRecordingSession( + winrt::IDirect3DDevice const& device, + winrt::GraphicsCaptureItem const& item, + RECT const cropRect, + uint32_t frameRate, + winrt::Streams::IRandomAccessStream const& stream) +{ + m_device = device; + m_d3dDevice = GetDXGIInterfaceFromObject<ID3D11Device>(m_device); + m_d3dDevice->GetImmediateContext(m_d3dContext.put()); + m_item = item; + m_frameRate = frameRate; + m_stream = stream; + + auto itemSize = item.Size(); + auto inputWidth = EnsureEvenGif(itemSize.Width); + auto inputHeight = EnsureEvenGif(itemSize.Height); + m_frameWait = std::make_shared<CaptureFrameWait>(m_device, m_item, winrt::SizeInt32{ inputWidth, inputHeight }); + auto weakPointer{ std::weak_ptr{ m_frameWait } }; + m_itemClosed = item.Closed(winrt::auto_revoke, [weakPointer](auto&, auto&) + { + auto sharedPointer{ weakPointer.lock() }; + if (sharedPointer) + { + sharedPointer->StopCapture(); + } + }); + + // Get crop dimension + if ((cropRect.right - cropRect.left) != 0) + { + m_rcCrop = cropRect; + m_frameWait->ShowCaptureBorder(false); + } + else + { + m_rcCrop.left = 0; + m_rcCrop.top = 0; + m_rcCrop.right = inputWidth; + m_rcCrop.bottom = inputHeight; + } + + // Apply scaling + constexpr int c_minimumSize = 34; + auto scaledWidth = MulDiv(m_rcCrop.right - m_rcCrop.left, g_RecordScaling, 100); + auto scaledHeight = MulDiv(m_rcCrop.bottom - m_rcCrop.top, g_RecordScaling, 100); + m_width = scaledWidth; + m_height = scaledHeight; + if (m_width < c_minimumSize) + { + m_width = c_minimumSize; + m_height = MulDiv(m_height, m_width, scaledWidth); + } + if (m_height < c_minimumSize) + { + m_height = c_minimumSize; + m_width = MulDiv(m_width, m_height, scaledHeight); + } + if (m_width > inputWidth) + { + m_width = inputWidth; + m_height = c_minimumSize, MulDiv(m_height, scaledWidth, m_width); + } + if (m_height > inputHeight) + { + m_height = inputHeight; + m_width = c_minimumSize, MulDiv(m_width, scaledHeight, m_height); + } + m_width = EnsureEvenGif(m_width); + m_height = EnsureEvenGif(m_height); + + m_frameDelay = (frameRate > 0) ? (100 / frameRate) : 15; + + // Initialize WIC + winrt::check_hresult(CoCreateInstance( + CLSID_WICImagingFactory, + nullptr, + CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(m_wicFactory.put()))); + + // Create WIC stream from IRandomAccessStream + winrt::check_hresult(m_wicFactory->CreateStream(m_wicStream.put())); + + // Get the IStream from the IRandomAccessStream + winrt::com_ptr<IStream> streamInterop; + winrt::check_hresult(CreateStreamOverRandomAccessStream( + winrt::get_unknown(stream), + IID_PPV_ARGS(streamInterop.put()))); + winrt::check_hresult(m_wicStream->InitializeFromIStream(streamInterop.get())); + + // Create GIF encoder + winrt::check_hresult(m_wicFactory->CreateEncoder( + GUID_ContainerFormatGif, + nullptr, + m_gifEncoder.put())); + + winrt::check_hresult(m_gifEncoder->Initialize(m_wicStream.get(), WICBitmapEncoderNoCache)); + + // Set global GIF metadata for looping (NETSCAPE2.0 application extension) + try + { + winrt::com_ptr<IWICMetadataQueryWriter> encoderMetadataWriter; + if (SUCCEEDED(m_gifEncoder->GetMetadataQueryWriter(encoderMetadataWriter.put())) && encoderMetadataWriter) + { + OutputDebugStringW(L"Setting NETSCAPE2.0 looping extension on encoder...\n"); + + // Set application extension + PROPVARIANT propValue; + PropVariantInit(&propValue); + propValue.vt = VT_UI1 | VT_VECTOR; + propValue.caub.cElems = 11; + propValue.caub.pElems = static_cast<UCHAR*>(CoTaskMemAlloc(11)); + if (propValue.caub.pElems != nullptr) + { + memcpy(propValue.caub.pElems, "NETSCAPE2.0", 11); + HRESULT hr = encoderMetadataWriter->SetMetadataByName(L"/appext/application", &propValue); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"Encoder application extension set successfully\n"); + } + else + { + OutputDebugStringW(L"Failed to set encoder application extension\n"); + } + PropVariantClear(&propValue); + + // Set loop count (0 = infinite) + PropVariantInit(&propValue); + propValue.vt = VT_UI1 | VT_VECTOR; + propValue.caub.cElems = 5; + propValue.caub.pElems = static_cast<UCHAR*>(CoTaskMemAlloc(5)); + if (propValue.caub.pElems != nullptr) + { + propValue.caub.pElems[0] = 3; + propValue.caub.pElems[1] = 1; + propValue.caub.pElems[2] = 0; + propValue.caub.pElems[3] = 0; + propValue.caub.pElems[4] = 0; + hr = encoderMetadataWriter->SetMetadataByName(L"/appext/data", &propValue); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"Encoder loop count set successfully\n"); + } + else + { + OutputDebugStringW(L"Failed to set encoder loop count\n"); + } + PropVariantClear(&propValue); + } + } + } + else + { + OutputDebugStringW(L"Failed to get encoder metadata writer\n"); + } + } + catch (...) + { + OutputDebugStringW(L"Warning: Failed to set GIF encoder looping metadata\n"); + } +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::~GifRecordingSession +// +//---------------------------------------------------------------------------- +GifRecordingSession::~GifRecordingSession() +{ + Close(); +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::Create +// +//---------------------------------------------------------------------------- +std::shared_ptr<GifRecordingSession> GifRecordingSession::Create( + winrt::IDirect3DDevice const& device, + winrt::GraphicsCaptureItem const& item, + RECT const& crop, + uint32_t frameRate, + winrt::Streams::IRandomAccessStream const& stream) +{ + return std::shared_ptr<GifRecordingSession>(new GifRecordingSession(device, item, crop, frameRate, stream)); +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::EncodeFrame +// +//---------------------------------------------------------------------------- +HRESULT GifRecordingSession::EncodeFrame(ID3D11Texture2D* frameTexture) +{ + std::lock_guard<std::mutex> 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 + D3D11_TEXTURE2D_DESC frameDesc; + frameTexture->GetDesc(&frameDesc); + + // GIF encoding with palette generation is VERY slow at high resolutions (4K takes 1 second per frame!) + + UINT targetWidth = frameDesc.Width; + UINT targetHeight = frameDesc.Height; + + if (frameDesc.Width > static_cast<uint32_t>(m_width) || frameDesc.Height > static_cast<uint32_t>(m_height)) + { + float scaleX = static_cast<float>(m_width) / frameDesc.Width; + float scaleY = static_cast<float>(m_height) / frameDesc.Height; + float scale = min(scaleX, scaleY); + + targetWidth = static_cast<UINT>(frameDesc.Width * scale); + targetHeight = static_cast<UINT>(frameDesc.Height * scale); + + // Ensure even dimensions for GIF + targetWidth = (targetWidth / 2) * 2; + targetHeight = (targetHeight / 2) * 2; + } + + D3D11_TEXTURE2D_DESC stagingDesc = frameDesc; + stagingDesc.Usage = D3D11_USAGE_STAGING; + stagingDesc.BindFlags = 0; + stagingDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + stagingDesc.MiscFlags = 0; + + winrt::com_ptr<ID3D11Texture2D> stagingTexture; + winrt::check_hresult(m_d3dDevice->CreateTexture2D(&stagingDesc, nullptr, stagingTexture.put())); + + // Copy the frame to staging texture + m_d3dContext->CopyResource(stagingTexture.get(), frameTexture); + + // Map the staging texture + D3D11_MAPPED_SUBRESOURCE mappedResource; + winrt::check_hresult(m_d3dContext->Map(stagingTexture.get(), 0, D3D11_MAP_READ, 0, &mappedResource)); + + // Create a new frame in the GIF + winrt::com_ptr<IWICBitmapFrameEncode> frameEncode; + winrt::com_ptr<IPropertyBag2> propertyBag; + winrt::check_hresult(m_gifEncoder->CreateNewFrame(frameEncode.put(), propertyBag.put())); + + // Initialize the frame encoder with property bag + winrt::check_hresult(frameEncode->Initialize(propertyBag.get())); + + // CRITICAL: For GIF, we MUST set size and pixel format BEFORE WriteSource + // Use target dimensions (may be downsampled) + winrt::check_hresult(frameEncode->SetSize(targetWidth, targetHeight)); + + // Set the pixel format to 8-bit indexed (required for GIF) + WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat8bppIndexed; + winrt::check_hresult(frameEncode->SetPixelFormat(&pixelFormat)); + + // Create a WIC bitmap from the BGRA texture data + winrt::com_ptr<IWICBitmap> sourceBitmap; + winrt::check_hresult(m_wicFactory->CreateBitmapFromMemory( + frameDesc.Width, + frameDesc.Height, + GUID_WICPixelFormat32bppBGRA, + mappedResource.RowPitch, + frameDesc.Height * mappedResource.RowPitch, + static_cast<BYTE*>(mappedResource.pData), + sourceBitmap.put())); + + // If we need downsampling, use WIC scaler + winrt::com_ptr<IWICBitmapSource> finalSource = sourceBitmap; + if (targetWidth != frameDesc.Width || targetHeight != frameDesc.Height) + { + winrt::com_ptr<IWICBitmapScaler> scaler; + winrt::check_hresult(m_wicFactory->CreateBitmapScaler(scaler.put())); + winrt::check_hresult(scaler->Initialize( + sourceBitmap.get(), + targetWidth, + targetHeight, + WICBitmapInterpolationModeHighQualityCubic)); + finalSource = scaler; + + OutputDebugStringW((L"Downsampled from " + std::to_wstring(frameDesc.Width) + L"x" + std::to_wstring(frameDesc.Height) + + L" to " + std::to_wstring(targetWidth) + L"x" + std::to_wstring(targetHeight) + L"\n").c_str()); + } + + // Use WriteSource - WIC will handle the BGRA to 8bpp indexed conversion + winrt::check_hresult(frameEncode->WriteSource(finalSource.get(), nullptr)); + + try + { + winrt::com_ptr<IWICMetadataQueryWriter> frameMetadataWriter; + if (SUCCEEDED(frameEncode->GetMetadataQueryWriter(frameMetadataWriter.put())) && frameMetadataWriter) + { + // Set the frame delay in the metadata (in hundredths of a second) + PROPVARIANT propValue; + PropVariantInit(&propValue); + propValue.vt = VT_UI2; + propValue.uiVal = static_cast<USHORT>(m_frameDelay); + frameMetadataWriter->SetMetadataByName(L"/grctlext/Delay", &propValue); + PropVariantClear(&propValue); + + // Set disposal method (2 = restore to background, needed for animation) + PropVariantInit(&propValue); + propValue.vt = VT_UI1; + propValue.bVal = 2; // Disposal method: restore to background color + frameMetadataWriter->SetMetadataByName(L"/grctlext/Disposal", &propValue); + PropVariantClear(&propValue); + } + } + catch (...) + { + // Metadata setting failed, continue anyway + OutputDebugStringW(L"Warning: Failed to set GIF frame metadata\n"); + } + + // Commit the frame + OutputDebugStringW(L"About to commit frame to encoder...\n"); + winrt::check_hresult(frameEncode->Commit()); + OutputDebugStringW(L"Frame committed successfully\n"); + + // Unmap the staging texture + m_d3dContext->Unmap(stagingTexture.get(), 0); + + // 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; + } + catch (const winrt::hresult_error& error) + { + OutputDebugStringW(error.message().c_str()); + return error.code(); + } +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::StartAsync +// +//---------------------------------------------------------------------------- +winrt::IAsyncAction GifRecordingSession::StartAsync() +{ + auto expected = false; + if (m_isRecording.compare_exchange_strong(expected, true)) + { + auto self = shared_from_this(); + + try + { + // Start capturing frames + auto frameStartTime = std::chrono::high_resolution_clock::now(); + int captureAttempts = 0; + int successfulCaptures = 0; + int duplicatedFrames = 0; + + // Keep track of the last frame to duplicate when needed + winrt::com_ptr<ID3D11Texture2D> lastCroppedTexture; + + while (m_isRecording && !m_closed) + { + 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<ID3D11Texture2D> croppedTexture; + + if (frame) + { + successfulCaptures++; + auto contentSize = frame->ContentSize; + auto frameTexture = GetDXGIInterfaceFromObject<ID3D11Texture2D>(frame->FrameTexture); + D3D11_TEXTURE2D_DESC desc = {}; + frameTexture->GetDesc(&desc); + + // Use the smaller of the crop size or content size + auto width = min(m_rcCrop.right - m_rcCrop.left, contentSize.Width); + auto height = min(m_rcCrop.bottom - m_rcCrop.top, contentSize.Height); + + D3D11_TEXTURE2D_DESC croppedDesc = {}; + croppedDesc.Width = width; + croppedDesc.Height = height; + croppedDesc.MipLevels = 1; + croppedDesc.ArraySize = 1; + croppedDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + croppedDesc.SampleDesc.Count = 1; + croppedDesc.Usage = D3D11_USAGE_DEFAULT; + croppedDesc.BindFlags = D3D11_BIND_RENDER_TARGET; + + winrt::check_hresult(m_d3dDevice->CreateTexture2D(&croppedDesc, nullptr, croppedTexture.put())); + + // Set the content region to copy and clamp the coordinates + D3D11_BOX region = {}; + region.left = std::clamp(m_rcCrop.left, static_cast<LONG>(0), static_cast<LONG>(desc.Width)); + region.right = std::clamp(m_rcCrop.left + width, static_cast<LONG>(0), static_cast<LONG>(desc.Width)); + region.top = std::clamp(m_rcCrop.top, static_cast<LONG>(0), static_cast<LONG>(desc.Height)); + region.bottom = std::clamp(m_rcCrop.top + height, static_cast<LONG>(0), static_cast<LONG>(desc.Height)); + region.back = 1; + + // Copy the cropped region + m_d3dContext->CopySubresourceRegion( + croppedTexture.get(), + 0, + 0, 0, 0, + frameTexture.get(), + 0, + ®ion); + + // Save this as the last frame for duplication + lastCroppedTexture = croppedTexture; + } + else if (lastCroppedTexture) + { + // No new frame, duplicate the last one + duplicatedFrames++; + croppedTexture = lastCroppedTexture; + } + + // Encode the frame (either new or duplicated) + if (croppedTexture) + { + HRESULT hr = EncodeFrame(croppedTexture.get()); + if (FAILED(hr)) + { + CloseInternal(); + break; + } + } + + // 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) + { + auto frameEndTime = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(frameEndTime - frameStartTime).count(); + + OutputDebugStringW(L"Recording stopped. Committing GIF encoder...\n"); + OutputDebugStringW((L"Total frames captured: " + std::to_wstring(m_frameCount) + L"\n").c_str()); + OutputDebugStringW((L"Capture attempts: " + std::to_wstring(captureAttempts) + L"\n").c_str()); + OutputDebugStringW((L"Successful captures: " + std::to_wstring(successfulCaptures) + L"\n").c_str()); + OutputDebugStringW((L"Duplicated frames: " + std::to_wstring(duplicatedFrames) + L"\n").c_str()); + OutputDebugStringW((L"Recording duration: " + std::to_wstring(duration) + L"ms\n").c_str()); + OutputDebugStringW((L"Actual FPS: " + std::to_wstring(m_frameCount * 1000.0 / duration) + L"\n").c_str()); + + winrt::check_hresult(m_gifEncoder->Commit()); + OutputDebugStringW(L"GIF encoder committed successfully\n"); + } + } + catch (const winrt::hresult_error& error) + { + OutputDebugStringW(L"Error in GIF recording: "); + OutputDebugStringW(error.message().c_str()); + OutputDebugStringW(L"\n"); + + // Try to commit the encoder even on error + if (m_gifEncoder) + { + try + { + m_gifEncoder->Commit(); + } + catch (...) {} + } + + 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; +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::Close +// +//---------------------------------------------------------------------------- +void GifRecordingSession::Close() +{ + OutputDebugStringW(L"[GIF] Close() called\n"); + auto expected = false; + if (m_closed.compare_exchange_strong(expected, true)) + { + 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"); + } +} + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::CloseInternal +// +//---------------------------------------------------------------------------- +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<std::mutex> 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 new file mode 100644 index 0000000000..2bffb94fd8 --- /dev/null +++ b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h @@ -0,0 +1,76 @@ +//============================================================================== +// +// Zoomit +// Sysinternals - www.sysinternals.com +// +// GIF recording support using Windows Imaging Component (WIC) +// +//============================================================================== +#pragma once + +#include "CaptureFrameWait.h" +#include <d3d11_4.h> +#include <vector> +#include <mutex> + +class GifRecordingSession : public std::enable_shared_from_this<GifRecordingSession> +{ +public: + [[nodiscard]] static std::shared_ptr<GifRecordingSession> Create( + winrt::Direct3D11::IDirect3DDevice const& device, + winrt::GraphicsCaptureItem const& item, + RECT const& cropRect, + uint32_t frameRate, + winrt::Streams::IRandomAccessStream const& stream); + ~GifRecordingSession(); + + winrt::IAsyncAction StartAsync(); + 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, + winrt::Capture::GraphicsCaptureItem const& item, + RECT const cropRect, + uint32_t frameRate, + winrt::Streams::IRandomAccessStream const& stream); + void CloseInternal(); + void ReleaseEncoderResources(); + HRESULT EncodeFrame(ID3D11Texture2D* texture); + +private: + winrt::Direct3D11::IDirect3DDevice m_device{ nullptr }; + winrt::com_ptr<ID3D11Device> m_d3dDevice; + winrt::com_ptr<ID3D11DeviceContext> m_d3dContext; + RECT m_rcCrop; + uint32_t m_frameRate; + + winrt::GraphicsCaptureItem m_item{ nullptr }; + winrt::GraphicsCaptureItem::Closed_revoker m_itemClosed; + std::shared_ptr<CaptureFrameWait> m_frameWait; + + winrt::Streams::IRandomAccessStream m_stream{ nullptr }; + + // WIC components for GIF encoding + winrt::com_ptr<IWICImagingFactory> m_wicFactory; + winrt::com_ptr<IWICStream> m_wicStream; + winrt::com_ptr<IWICBitmapEncoder> m_gifEncoder; + winrt::com_ptr<IWICMetadataQueryWriter> m_encoderMetadataWriter; + + std::atomic<bool> m_isRecording = false; + std::atomic<bool> m_closed = false; + std::atomic<bool> m_encoderReleased = false; + std::atomic<bool> m_hasAnyFrame = false; + std::mutex m_encoderMutex; + + uint32_t m_frameWidth=0; + uint32_t m_frameHeight=0; + uint32_t m_frameDelay=0; + uint32_t m_frameCount = 0; + + int32_t m_width=0; + int32_t m_height=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 <functiondiscoverykeys_devpkey.h> + +#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<float> samples; + + if (m_pwfx->wFormatTag == WAVE_FORMAT_IEEE_FLOAT || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast<WAVEFORMATEXTENSIBLE*>(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT)) + { + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + samples.resize(static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else + { + float* floatData = reinterpret_cast<float*>(pData); + samples.assign(floatData, floatData + (static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels)); + } + } + else if (m_pwfx->wFormatTag == WAVE_FORMAT_PCM || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast<WAVEFORMATEXTENSIBLE*>(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_PCM)) + { + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + samples.resize(static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else if (m_pwfx->wBitsPerSample == 16) + { + int16_t* pcmData = reinterpret_cast<int16_t*>(pData); + samples.resize(static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast<float>(pcmData[i]) / 32768.0f; + } + } + else if (m_pwfx->wBitsPerSample == 32) + { + int32_t* pcmData = reinterpret_cast<int32_t*>(pData); + samples.resize(static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast<float>(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<float> samples; + + // Convert to float samples + if (m_pwfx->wFormatTag == WAVE_FORMAT_IEEE_FLOAT || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast<WAVEFORMATEXTENSIBLE*>(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT)) + { + // Already float format + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + // Insert silence + samples.resize(static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else + { + float* floatData = reinterpret_cast<float*>(pData); + samples.assign(floatData, floatData + (static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels)); + } + } + else if (m_pwfx->wFormatTag == WAVE_FORMAT_PCM || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast<WAVEFORMATEXTENSIBLE*>(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_PCM)) + { + // Convert PCM to float + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + samples.resize(static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else if (m_pwfx->wBitsPerSample == 16) + { + int16_t* pcmData = reinterpret_cast<int16_t*>(pData); + samples.resize(static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast<float>(pcmData[i]) / 32768.0f; + } + } + else if (m_pwfx->wBitsPerSample == 32) + { + int32_t* pcmData = reinterpret_cast<int32_t*>(pData); + samples.resize(static_cast<size_t>(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast<float>(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<float>& 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 <mmdeviceapi.h> +#include <audioclient.h> +#include <atomic> +#include <vector> +#include <deque> +#include <wil/resource.h> + +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<float>& 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<IMMDeviceEnumerator> m_deviceEnumerator; + winrt::com_ptr<IMMDevice> m_device; + winrt::com_ptr<IAudioClient> m_audioClient; + winrt::com_ptr<IAudioCaptureClient> 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<std::vector<float>> m_sampleQueue; + + std::atomic<bool> m_initialized{ false }; + std::atomic<bool> 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 <string> + +#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<fnSetPreferredAppMode>( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(135))); + // Ordinal 133: AllowDarkModeForWindow + pAllowDarkModeForWindow = reinterpret_cast<fnAllowDarkModeForWindow>( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(133))); + // Ordinal 132: ShouldAppsUseDarkMode + pShouldAppsUseDarkMode = reinterpret_cast<fnShouldAppsUseDarkMode>( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(132))); + // Ordinal 104: RefreshImmersiveColorPolicyState + pRefreshImmersiveColorPolicyState = reinterpret_cast<fnRefreshImmersiveColorPolicyState>( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(104))); + // Ordinal 136: FlushMenuThemes + pFlushMenuThemes = reinterpret_cast<fnFlushMenuThemes>( + 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<LPBYTE>(&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<fnDwmSetWindowAttribute>( + 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<LPPOINT>(&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<HFONT>(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<WPARAM>(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<HFONT>(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<WPARAM>(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<LPPOINT>(&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<HFONT>(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<WPARAM>(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<const RECT*>(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 <uxtheme.h> + +// 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 <winrt/Windows.Graphics.Imaging.h> +#include <winrt/Windows.Media.h> +#include <cstdlib> +#include <filesystem> +#include <shlwapi.h> // For SHCreateStreamOnFileEx +#include <mmsystem.h> // 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<VideoRecordingSession::TrimDialogData::GifFrame>& 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<IWICImagingFactory> 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<IWICBitmapDecoder> 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<unsigned>(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<IStream> 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<IWICBitmapFrameDecode> frame; + if (FAILED(decoder->GetFrame(i, frame.put()))) + { + continue; + } + + if (i == 0) + { + frame->GetSize(&frameWidth, &frameHeight); + } + + UINT delayCs = kGifDefaultDelayCs; + try + { + winrt::com_ptr<IWICMetadataQueryReader> 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<double>(kGifMaxPreviewDimension) / static_cast<double>(targetWidth); + const double scaleY = static_cast<double>(kGifMaxPreviewDimension) / static_cast<double>(targetHeight); + const double scale = (std::min)(scaleX, scaleY); + targetWidth = static_cast<UINT>(std::lround(static_cast<double>(targetWidth) * scale)); + targetHeight = static_cast<UINT>(std::lround(static_cast<double>(targetHeight) * scale)); + targetWidth = (std::max)(1u, targetWidth); + targetHeight = (std::max)(1u, targetHeight); + } + + winrt::com_ptr<IWICBitmapSource> source = frame; + if (targetWidth != frameWidth || targetHeight != frameHeight) + { + winrt::com_ptr<IWICBitmapScaler> scaler; + if (SUCCEEDED(factory->CreateBitmapScaler(scaler.put()))) + { + if (SUCCEEDED(scaler->Initialize(frame.get(), targetWidth, targetHeight, WICBitmapInterpolationModeFant))) + { + source = scaler; + } + } + } + + winrt::com_ptr<IWICFormatConverter> 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<BYTE> buffer(static_cast<size_t>(stride) * convertedHeight); + if (FAILED(converter->CopyPixels(nullptr, stride, static_cast<UINT>(buffer.size()), buffer.data()))) + { + continue; + } + + BITMAPINFO bmi{}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = static_cast<LONG>(convertedWidth); + bmi.bmiHeader.biHeight = -static_cast<LONG>(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<BYTE*>(bits) + static_cast<size_t>(row) * stride, + buffer.data() + static_cast<size_t>(row) * stride, + stride); + } + + VideoRecordingSession::TrimDialogData::GifFrame gifFrame; + gifFrame.hBitmap = hBitmap; + gifFrame.start = winrt::TimeSpan{ cumulativeTicks }; + gifFrame.duration = winrt::TimeSpan{ static_cast<int64_t>(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<int> 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<ID3D11Device> device; + winrt::com_ptr<ID3D11DeviceContext> 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<ID3D11Texture2D> 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<ID3D11Texture2D> 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<int>((std::clamp)(static_cast<LONG>(newX), rcTarget.left, rcTarget.right - dlgWidth)); + } + + if (dlgHeight >= targetHeight) + { + newY = rcTarget.top; + } + else + { + newY = static_cast<int>((std::clamp)(static_cast<LONG>(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<int>(totalMilliseconds % 1000); + const int64_t totalSeconds = totalMilliseconds / 1000LL; + const int seconds = static_cast<int>(totalSeconds % 60LL); + const int64_t totalMinutes = totalSeconds / 60LL; + const int minutes = static_cast<int>(totalMinutes % 60LL); + const int hours = static_cast<int>(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::microseconds>( + 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<long long>(predictedTicks), static_cast<long long>(mediaTicks), static_cast<long long>(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<ID3D11Texture2D>(), backBuffer.put_void())); winrt::check_hresult(m_d3dDevice->CreateRenderTargetView(backBuffer.get(), nullptr, m_renderTargetView.put())); - if( captureAudio ) { - - m_audioGenerator = std::make_unique<AudioSampleGenerator>(); - } - else { - - m_audioGenerator = nullptr; - } + // Always create audio generator for loopback capture; captureAudio controls microphone + m_audioGenerator = std::make_unique<AudioSampleGenerator>(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<HRESULT>(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> VideoRecordingSession::Create( RECT const& crop, uint32_t frameRate, bool captureAudio, + bool captureSystemAudio, winrt::Streams::IRandomAccessStream const& stream) { - return std::shared_ptr<VideoRecordingSession>(new VideoRecordingSession(device, item, crop, frameRate, captureAudio, stream)); + return std::shared_ptr<VideoRecordingSession>(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<winrt::AudioStreamDescriptor>()) { - 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<IOleWindow> 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<LPARAM>(hIcon)); + SendMessage(hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(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<IFileDialog> pfd; + HWND hFileDlg = nullptr; + if (SUCCEEDED(pfdc->QueryInterface(IID_PPV_ARGS(&pfd)))) + { + wil::com_ptr<IOleWindow> 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<IFileDialogCustomize> 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<INT_PTR> 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<int64_t>( endTicks, 0, durationTicks ); + const int64_t clampedStart = std::clamp<int64_t>( 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<int64_t>(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<IWICImagingFactory> wicFactory; + if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory.put())))) + { + winrt::com_ptr<IStream> istream; + auto streamAsUnknown = static_cast<::IUnknown*>(winrt::get_abi(stream)); + if (SUCCEEDED(CreateStreamOverRandomAccessStream(streamAsUnknown, IID_PPV_ARGS(istream.put()))) && istream) + { + winrt::com_ptr<IWICBitmapDecoder> decoder; + if (SUCCEEDED(wicFactory->CreateDecoderFromStream(istream.get(), nullptr, WICDecodeMetadataCacheOnDemand, decoder.put()))) + { + winrt::com_ptr<IWICBitmapFrameDecode> frame; + if (SUCCEEDED(decoder->GetFrame(0, frame.put()))) + { + winrt::com_ptr<IWICFormatConverter> 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<LONG>(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<BYTE*>(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<LPARAM>(&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<int64_t>(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<int64_t>(requestTicks, 0, pData->videoDuration.count()); + if (!pData->gifFrames.empty()) + { + const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); + pData->gifLastFrameIndex = frameIndex; + { + std::lock_guard<std::mutex> 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<int64_t>(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<IWICImagingFactory> wicFactory; + if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory.put())))) + { + winrt::com_ptr<IStream> istream; + auto streamAsUnknown = static_cast<::IUnknown*>(winrt::get_abi(stream)); + if (SUCCEEDED(CreateStreamOverRandomAccessStream(streamAsUnknown, IID_PPV_ARGS(istream.put()))) && istream) + { + winrt::com_ptr<IWICBitmapDecoder> decoder; + if (SUCCEEDED(wicFactory->CreateDecoderFromStream(istream.get(), nullptr, WICDecodeMetadataCacheOnDemand, decoder.put()))) + { + winrt::com_ptr<IWICBitmapFrameDecode> frame; + if (SUCCEEDED(decoder->GetFrame(0, frame.put()))) + { + winrt::com_ptr<IWICFormatConverter> 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<LONG>(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<BYTE*>(bits)); + + { + std::lock_guard<std::mutex> 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<HBITMAP>(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<HPEN>(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<double, 5> 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<HPEN>(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<int>(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<HFONT>(SelectObject(hdcMem, hTimelineFont)); + + for (size_t i = 0; i < fractions.size(); ++i) + { + const double fraction = fractions[i]; + const int x = trackLeft + static_cast<int>(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<int64_t>(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<HPEN>(SelectObject(hdcMem, GetStockObject(NULL_PEN))); + HBRUSH hOldBrush2 = static_cast<HBRUSH>(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<HPEN>(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<HPEN>(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<HBRUSH>(SelectObject(hdcMem, hPositionBrush)); + HPEN hOldPenForEllipse = static_cast<HPEN>(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<int>(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<HFONT>(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<int64_t>(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<HWND>(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<DWORD_PTR>(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<std::mutex> 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<std::mutex> 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<HBITMAP>(SelectObject(hdcSrc, pData->hPreviewBitmap)); + HBITMAP hOldDst = static_cast<HBITMAP>(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<int64_t>(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<int64_t>(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<int64_t>(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<IDXGISurface> dxgiSurface; + if (dataPtr->previewFrameTexture) + { + dxgiSurface = dataPtr->previewFrameTexture.as<IDXGISurface>(); + } + + if (dxgiSurface) + { + winrt::com_ptr<IInspectable> inspectableSurface; + if (SUCCEEDED(CreateDirect3D11SurfaceFromDXGISurface(dxgiSurface.get(), inspectableSurface.put()))) + { + auto surface = inspectableSurface.as<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DSurface>(); + 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<LONG>(width); + bmi.bmiHeader.biHeight = -static_cast<LONG>(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<BYTE*>(bits); + const BYTE* src = static_cast<const BYTE*>(mapped.pData); + for (UINT y = 0; y < height; ++y) + { + memcpy(dest + static_cast<size_t>(y) * destStride, src + static_cast<size_t>(y) * rowPitch, destStride); + } + + { + std::lock_guard<std::mutex> 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<int64_t>(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<VideoRecordingSession::TrimDialogData*>(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<int64_t>( + 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<VideoRecordingSession::TrimDialogData*>(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<VideoRecordingSession::TrimDialogData*>(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<TrimDialogData*>(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<DWORD_PTR>(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<DWORD_PTR>(pData)); + } + HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); + if (hPlayPause) + { + SetWindowSubclass(hPlayPause, PlaybackButtonSubclassProc, 2, reinterpret_cast<DWORD_PTR>(pData)); + } + HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); + if (hRewind) + { + SetWindowSubclass(hRewind, PlaybackButtonSubclassProc, 3, reinterpret_cast<DWORD_PTR>(pData)); + } + HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); + if (hForward) + { + SetWindowSubclass(hForward, PlaybackButtonSubclassProc, 4, reinterpret_cast<DWORD_PTR>(pData)); + } + HWND hSkipStart = GetDlgItem(hDlg, IDC_TRIM_SKIP_START); + if (hSkipStart) + { + SetWindowSubclass(hSkipStart, PlaybackButtonSubclassProc, 5, reinterpret_cast<DWORD_PTR>(pData)); + } + HWND hSkipEnd = GetDlgItem(hDlg, IDC_TRIM_SKIP_END); + if (hSkipEnd) + { + SetWindowSubclass(hSkipEnd, PlaybackButtonSubclassProc, 6, reinterpret_cast<DWORD_PTR>(pData)); + } + HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); + if (hVolumeIcon) + { + SetWindowSubclass(hVolumeIcon, VolumeIconSubclassProc, 7, reinterpret_cast<DWORD_PTR>(pData)); + } + + // Initialize volume from saved setting + pData->volume = std::clamp(static_cast<double>(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<LPARAM>(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<int64_t>(endTicks, 0, durationTicks); + const int64_t clampedStart = std::clamp<int64_t>(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<int64_t>(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<int>(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<WPARAM>(pData->hTimeLabelFont), TRUE); + } + HWND hDuration = GetDlgItem(hDlg, IDC_TRIM_DURATION_LABEL); + if (hDuration) + { + SendMessage(hDuration, WM_SETFONT, reinterpret_cast<WPARAM>(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<int>(g_TrimDialogWidth), + static_cast<int>(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<HDC>(wParam); + HWND hCtrl = reinterpret_cast<HWND>(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast<INT_PTR>(hBrush); + } + break; + } + + case WM_CTLCOLORSTATIC: + { + HDC hdc = reinterpret_cast<HDC>(wParam); + HWND hCtrl = reinterpret_cast<HWND>(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<INT_PTR>(GetDarkModeBrush()); + } + } + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast<INT_PTR>(hBrush); + } + break; + } + + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast<HDC>(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<MINMAXINFO*>(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<TrimDialogData*>(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<TrimDialogData*>(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<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + UpdateVideoPreview(hDlg, pData); + } + return TRUE; + } + + case WMU_DURATION_CHANGED: + { + pData = reinterpret_cast<TrimDialogData*>(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<TrimDialogData*>(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<TrimDialogData*>(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<std::mutex> 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<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); + if (!pData) break; + + DRAWITEMSTRUCT* pDIS = reinterpret_cast<DRAWITEMSTRUCT *> (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<std::mutex> 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<HBITMAP>(SelectObject(hdcMem, hbmMem)); + + // Draw to memory DC + RECT rcMem = { 0, 0, controlWidth, controlHeight }; + FillRect(hdcMem, &rcMem, static_cast<HBRUSH>(GetStockObject(BLACK_BRUSH))); + + if (pData->hPreviewBitmap) + { + HDC hdcBitmap = CreateCompatibleDC(hdcMem); + HBITMAP hOldBitmap = static_cast<HBITMAP>(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<double>(controlWidth) / static_cast<double>(bm.bmWidth); + const double scaleY = static_cast<double>(controlHeight) / static_cast<double>(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<int>(std::lround(static_cast<double>(bm.bmWidth) * scale))); + destHeight = (std::max)(1, static_cast<int>(std::lround(static_cast<double>(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<TrimDialogData*>(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<DWORD>(rcDlg.right - rcDlg.left); + g_TrimDialogHeight = static_cast<DWORD>(rcDlg.bottom - rcDlg.top); + reg.WriteRegSettings(RegSettings); + } + + pData = reinterpret_cast<TrimDialogData*>(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<std::mutex> 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<TrimDialogData*>(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<int64_t>( + 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<std::chrono::milliseconds>(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<int64_t>(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<int64_t>( + 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<HWND>(lParam) == hVolumeSlider) + { + pData = reinterpret_cast<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + int pos = static_cast<int>(SendMessage(hVolumeSlider, TBM_GETPOS, 0, 0)); + pData->volume = pos / 100.0; + + // Persist volume setting + g_TrimDialogVolume = static_cast<DWORD>(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<TrimDialogData*>(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<LPARAM>(pData->volume * 100)); + // Force full redraw to avoid leftover thumb artifacts + InvalidateRect(hVolumeSlider, nullptr, TRUE); + } + + // Persist volume setting + g_TrimDialogVolume = static_cast<DWORD>(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<TrimDialogData*>(GetWindowLongPtr(hDlg, DWLP_USER)); + HandlePlaybackCommand(static_cast<int>(LOWORD(wParam)), pData); + return TRUE; + } + break; + } + + case IDOK: + pData = reinterpret_cast<TrimDialogData*>(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<TrimDialogData*>(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<winrt::hstring> 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<winrt::hstring> 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<IWICImagingFactory> 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<IStream> sourceIStream; + winrt::check_hresult(CreateStreamOverRandomAccessStream(winrt::get_unknown(sourceStream), IID_PPV_ARGS(sourceIStream.put()))); + + winrt::com_ptr<IWICBitmapDecoder> 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<IStream> outputIStream; + winrt::check_hresult(CreateStreamOverRandomAccessStream(winrt::get_unknown(outputStream), IID_PPV_ARGS(outputIStream.put()))); + + winrt::com_ptr<IWICBitmapEncoder> 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<IWICMetadataQueryWriter> 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<UCHAR*>(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<UCHAR*>(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<IWICBitmapFrameDecode> frame; + if (FAILED(decoder->GetFrame(i, frame.put()))) + { + continue; + } + + UINT delayCs = kGifDefaultDelayCs; + try + { + winrt::com_ptr<IWICMetadataQueryReader> 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<int64_t>(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<IWICBitmapFrameEncode> frameEncode; + winrt::com_ptr<IPropertyBag2> 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<IWICFormatConverter> 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<IWICMetadataQueryWriter> 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<USHORT>((std::max<int64_t>)(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 <d3d11_4.h> +#include <ppltasks.h> +#include <atomic> +#include <algorithm> +#include <chrono> +#include <mutex> +#include <vector> class VideoRecordingSession : public std::enable_shared_from_this<VideoRecordingSession> { @@ -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<bool> pendingInitialSeek{ false }; + std::atomic<int64_t> 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<bool> loadingPreview{ false }; + std::atomic<int64_t> latestPreviewRequest{ 0 }; + std::atomic<int64_t> lastRenderedPreview{ -1 }; + std::atomic<bool> isPlaying{ false }; + // Monotonic serial used to cancel in-flight StartPlaybackAsync work when the user + // immediately pauses after starting playback. + std::atomic<uint64_t> playbackCommandSerial{ 0 }; + std::atomic<bool> frameCopyInProgress{ false }; + std::atomic<bool> smoothActive{ false }; + std::atomic<int64_t> smoothBaseTicks{ 0 }; + std::atomic<int64_t> smoothLastSyncMicroseconds{ 0 }; + std::atomic<bool> smoothHasNonZeroSample{ false }; + std::mutex previewBitmapMutex; + winrt::event_token frameAvailableToken{}; + winrt::event_token positionChangedToken{}; + winrt::event_token stateChangedToken{}; + winrt::com_ptr<ID3D11Device> previewD3DDevice; + winrt::com_ptr<ID3D11DeviceContext> previewD3DContext; + winrt::com_ptr<ID3D11Texture2D> previewFrameTexture; + winrt::com_ptr<ID3D11Texture2D> 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<GifFrame> 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<double>(time.count()) / static_cast<double>(videoDuration.count()); + ratio = std::clamp(ratio, 0.0, 1.0); + return static_cast<int>(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<double>(clampedPixel) / static_cast<double>(timelineWidth); + return winrt::Windows::Foundation::TimeSpan{ static_cast<int64_t>(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<winrt::hstring> TrimVideoAsync( + const std::wstring& sourceVideoPath, + winrt::Windows::Foundation::TimeSpan trimTimeStart, + winrt::Windows::Foundation::TimeSpan trimTimeEnd); + static winrt::Windows::Foundation::IAsyncOperation<winrt::hstring> 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<bool> m_isRecording = false; std::atomic<bool> m_closed = false; + + // Set once the MediaStreamSource successfully returns at least one video sample. + std::atomic<bool> m_hasVideoSample = false; }; \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.h b/src/modules/ZoomIt/ZoomIt/ZoomIt.h index 5abbc21039..552af677ce 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.h +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.h @@ -39,6 +39,10 @@ type_pEnableThemeDialogTexture pEnableThemeDialogTexture; #define WIN7_VERSION 0x106 #define WIN10_VERSION 0x206 +// Default recording format frame rates +#define RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE 15 +#define RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE 30 + // Time that we'll cache live zoom window to avoid flicker // of live zooming on Vista/ws2k8 #define LIVEZOOM_WINDOW_TIMEOUT 2*3600*1000 @@ -96,7 +100,10 @@ typedef struct { #define SHALLOW_DESTROY 2 #define LIVE_DRAW_ZOOM 3 -#define PEN_COLOR_HIGHLIGHT(Pencolor) (Pencolor >> 24) != 0xFF +#define PEN_COLOR_HIGHLIGHT(Pencolor) ((Pencolor >> 24) != 0xFF) +#define PEN_COLOR_BLUR(Pencolor) ((Pencolor & 0x00FFFFFF) == COLOR_BLUR) + +#define CURSOR_SAVE_MARGIN 4 typedef BOOL (__stdcall *type_pGetMonitorInfo)( @@ -143,7 +150,14 @@ typedef BOOL(__stdcall *type_pMagSetWindowFilterList)( int count, HWND* pHWND ); -typedef BOOL (__stdcall *type_pMagInitialize)(VOID); +typedef BOOL(__stdcall* type_pMagSetLensUseBitmapSmoothing)( + _In_ HWND, + _In_ BOOL +); +typedef BOOL(__stdcall* type_MagSetFullscreenUseBitmapSmoothing)( + BOOL fUseBitmapSmoothing +); +typedef BOOL(__stdcall* type_pMagInitialize)(VOID); typedef BOOL(__stdcall *type_pGetPointerType)( _In_ UINT32 pointerId, diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc index c57e0ce94b..d3c5210744 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc @@ -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.0",IDC_VERSION,42,7,73,10 - LTEXT "Copyright � 2006-2024 Mark Russinovich",IDC_COPYRIGHT,42,17,166,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 "<a HREF=""https://www.sysinternals.com"">Sysinternals - www.sysinternals.com</a>",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 @@ -149,7 +149,8 @@ BEGIN CONTROL "",IDC_TIMER_POS7,"Button",BS_AUTORADIOBUTTON,63,108,10,10 CONTROL "",IDC_TIMER_POS8,"Button",BS_AUTORADIOBUTTON,79,108,10,10 CONTROL "",IDC_TIMER_POS9,"Button",BS_AUTORADIOBUTTON,97,108,10,10 - CONTROL "Show background bitmap:",IDC_CHECK_BACKGROUND_FILE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,3,122,99,10,WS_EX_RIGHT + CONTROL "Show background bitmap:",IDC_CHECK_BACKGROUND_FILE, + "Button",BS_AUTOCHECKBOX | WS_TABSTOP,3,122,99,10,WS_EX_RIGHT CONTROL "Use faded desktop as background",IDC_STATIC_DESKTOP_BACKGROUND, "Button",BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP,46,135,125,10 CONTROL "Use image file as background",IDC_STATIC_BACKGROUND_FILE, @@ -157,75 +158,76 @@ 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, 158 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU +ZOOM DIALOGEX 0, 0, 260, 170 +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,104,150,15,WS_EX_TRANSPARENT - LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,91,215,10 - LTEXT "1.25",IDC_STATIC,52,122,16,8 - LTEXT "1.5",IDC_STATIC,82,122,12,8 - LTEXT "1.75",IDC_STATIC,108,122,16,8 - LTEXT "2.0",IDC_STATIC,138,122,12,8 - LTEXT "3.0",IDC_STATIC,164,122,12,8 - LTEXT "4.0",IDC_STATIC,190,122,12,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,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 + LTEXT "2.0",IDC_STATIC,138,136,12,8 + LTEXT "3.0",IDC_STATIC,164,136,12,8 + 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 - 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,34,246,17 + 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,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 @@ -248,66 +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,137,83,10 - COMBOBOX IDC_MICROPHONE,81,152,172,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP - LTEXT "Microphone:",IDC_STATIC,32,154,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 + 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,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 @@ -321,7 +347,7 @@ GUIDELINES DESIGNINFO BEGIN "OPTIONS", DIALOG BEGIN - RIGHTMARGIN, 273 + RIGHTMARGIN, 293 BOTTOMMARGIN, 320 END @@ -334,7 +360,6 @@ BEGIN "ZOOM", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 151 END @@ -342,7 +367,6 @@ BEGIN "DRAW", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 221 END @@ -350,7 +374,6 @@ BEGIN "TYPE", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 97 END @@ -358,7 +381,6 @@ BEGIN "BREAK", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 116 END @@ -372,7 +394,6 @@ BEGIN "LIVEZOOM", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 127 END @@ -380,7 +401,6 @@ BEGIN "RECORD", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 164 END @@ -388,7 +408,6 @@ BEGIN "SNIP", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 61 END @@ -396,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 @@ -413,8 +435,8 @@ ACCELERATORS ACCELERATORS BEGIN "C", IDC_COPY, VIRTKEY, CONTROL, NOINVERT "S", IDC_SAVE, VIRTKEY, CONTROL, NOINVERT - "C", IDC_COPY_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT - "S", IDC_SAVE_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT + "C", IDC_COPY_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT + "S", IDC_SAVE_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT END @@ -468,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 d054d2b4bd..7055474f1f 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj @@ -1,6 +1,6 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|ARM64"> <Configuration>Debug</Configuration> @@ -216,6 +216,14 @@ <MultiProcessorCompilation Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">false</MultiProcessorCompilation> <MultiProcessorCompilation Condition="'$(Configuration)|$(Platform)'=='Release|x64'">false</MultiProcessorCompilation> </ClCompile> + <ClCompile Include="LoopbackCapture.cpp"> + <MultiProcessorCompilation Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">false</MultiProcessorCompilation> + <MultiProcessorCompilation Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">false</MultiProcessorCompilation> + <MultiProcessorCompilation Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">false</MultiProcessorCompilation> + <MultiProcessorCompilation Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">false</MultiProcessorCompilation> + <MultiProcessorCompilation Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">false</MultiProcessorCompilation> + <MultiProcessorCompilation Condition="'$(Configuration)|$(Platform)'=='Release|x64'">false</MultiProcessorCompilation> + </ClCompile> <ClCompile Include="$(MSBuildThisFileDirectory)..\..\..\common\sysinternals\dll.c"> <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">NotUsing</PrecompiledHeader> <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">NotUsing</PrecompiledHeader> @@ -240,6 +248,7 @@ <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">NotUsing</PrecompiledHeader> <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">NotUsing</PrecompiledHeader> </ClCompile> + <ClCompile Include="GifRecordingSession.cpp" /> <ClCompile Include="pch.cpp" /> <ClCompile Include="SelectRectangle.cpp"> <PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">Use</PrecompiledHeader> @@ -292,8 +301,10 @@ </ItemGroup> <ItemGroup> <ClInclude Include="AudioSampleGenerator.h" /> + <ClInclude Include="LoopbackCapture.h" /> <ClInclude Include="$(MSBuildThisFileDirectory)..\..\..\common\sysinternals\Eula\Eula.h" /> <ClInclude Include="$(MSBuildThisFileDirectory)..\ZoomItModuleInterface\Trace.h" /> + <ClInclude Include="GifRecordingSession.h" /> <ClInclude Include="pch.h" /> <ClInclude Include="Registry.h" /> <ClInclude Include="resource.h" /> @@ -360,11 +371,11 @@ <ErrorText>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}.</ErrorText> </PropertyGroup> <Error Condition="!Exists('..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> <Import Project="..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets" Condition="Exists('..\..\..\..\packages\robmikh.common.0.0.23-beta\build\native\robmikh.common.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> -</Project> \ No newline at end of file +</Project> diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters index 1754412d2d..2bd93a7095 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters @@ -33,6 +33,9 @@ <ClCompile Include="AudioSampleGenerator.cpp"> <Filter>Source Files</Filter> </ClCompile> + <ClCompile Include="LoopbackCapture.cpp"> + <Filter>Source Files</Filter> + </ClCompile> <ClCompile Include="DemoType.cpp"> <Filter>Source Files</Filter> </ClCompile> @@ -54,6 +57,9 @@ <ClCompile Include="$(MSBuildThisFileDirectory)..\..\..\common\sysinternals\WindowsVersions.cpp"> <Filter>Source Files</Filter> </ClCompile> + <ClCompile Include="GifRecordingSession.cpp"> + <Filter>Source Files</Filter> + </ClCompile> </ItemGroup> <ItemGroup> <ClInclude Include="Registry.h"> @@ -77,6 +83,9 @@ <ClInclude Include="AudioSampleGenerator.h"> <Filter>Header Files</Filter> </ClInclude> + <ClInclude Include="LoopbackCapture.h"> + <Filter>Header Files</Filter> + </ClInclude> <ClInclude Include="DemoType.h"> <Filter>Header Files</Filter> </ClInclude> @@ -95,6 +104,9 @@ <ClInclude Include="ZoomItSettings.h"> <Filter>Header Files</Filter> </ClInclude> + <ClInclude Include="GifRecordingSession.h"> + <Filter>Header Files</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <Image Include="appicon.ico"> diff --git a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h index d53136cf0a..e7176e6ec8 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h +++ b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h @@ -3,6 +3,13 @@ #include "Registry.h" #include "DemoType.h" +// Recording format enum +enum class RecordingFormat +{ + GIF = 0, + MP4 = 1 +}; + DWORD g_ToggleKey = (HOTKEYF_CONTROL << 8)| '1'; DWORD g_LiveZoomToggleKey = ((HOTKEYF_CONTROL) << 8)| '4'; DWORD g_DrawToggleKey = ((HOTKEYF_CONTROL) << 8)| '2'; @@ -14,6 +21,7 @@ DWORD g_SnipToggleKey = ((HOTKEYF_CONTROL) << 8) | '6'; DWORD g_ShowExpiredTime = 1; DWORD g_SliderZoomLevel = 3; BOOLEAN g_AnimateZoom = TRUE; +BOOLEAN g_SmoothImage = TRUE; DWORD g_PenColor = COLOR_RED; DWORD g_BreakPenColor = COLOR_RED; DWORD g_RootPenWidth = PEN_WIDTH; @@ -36,11 +44,20 @@ LOGFONT g_LogFont; BOOLEAN g_DemoTypeUserDriven = false; TCHAR g_DemoTypeFile[MAX_PATH] = {0}; DWORD g_DemoTypeSpeedSlider = static_cast<int>(((MIN_TYPING_SPEED - MAX_TYPING_SPEED) / 2) + MAX_TYPING_SPEED); -DWORD g_RecordFrameRate = 30; -// Divide by 100 to get actual scaling -DWORD g_RecordScaling = 100; +DWORD g_RecordFrameRate = 30; // We default to 30 here, but g_RecordFrameRate can be different depending on recording format and gets set accordingly +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<DOUBLE>(g_ToggleKey) }, @@ -70,14 +87,24 @@ REG_SETTING RegSettings[] = { { L"FontScale", SETTING_TYPE_DWORD, 0, &g_FontScale, static_cast<DOUBLE>(g_FontScale) }, { L"ShowExpiredTime", SETTING_TYPE_BOOLEAN, 0, &g_ShowExpiredTime, static_cast<DOUBLE>(g_ShowExpiredTime) }, { L"ShowTrayIcon", SETTING_TYPE_BOOLEAN, 0, &g_ShowTrayIcon, static_cast<DOUBLE>(g_ShowTrayIcon) }, + // NOTE: AnimateZoom is misspelled, but since it is a user setting stored in the registry we must continue to misspell it. { L"AnimnateZoom", SETTING_TYPE_BOOLEAN, 0, &g_AnimateZoom, static_cast<DOUBLE>(g_AnimateZoom) }, + { L"SmoothImage", SETTING_TYPE_BOOLEAN, 0, &g_SmoothImage, static_cast<DOUBLE>(g_SmoothImage) }, { L"TelescopeZoomOut", SETTING_TYPE_BOOLEAN, 0, &g_TelescopeZoomOut, static_cast<DOUBLE>(g_TelescopeZoomOut) }, { L"SnapToGrid", SETTING_TYPE_BOOLEAN, 0, &g_SnapToGrid, static_cast<DOUBLE>(g_SnapToGrid) }, { L"ZoominSliderLevel", SETTING_TYPE_DWORD, 0, &g_SliderZoomLevel, static_cast<DOUBLE>(g_SliderZoomLevel) }, { L"Font", SETTING_TYPE_BINARY, sizeof g_LogFont, &g_LogFont, static_cast<DOUBLE>(0) }, - { L"RecordFrameRate", SETTING_TYPE_DWORD, 0, &g_RecordFrameRate, static_cast<DOUBLE>(g_RecordFrameRate) }, - { L"RecordScaling", SETTING_TYPE_DWORD, 0, &g_RecordScaling, static_cast<DOUBLE>(g_RecordScaling) }, + { L"RecordingFormat", SETTING_TYPE_DWORD, 0, &g_RecordingFormat, static_cast<DOUBLE>(g_RecordingFormat) }, + { L"RecordScalingGIF", SETTING_TYPE_DWORD, 0, &g_RecordScalingGIF, static_cast<DOUBLE>(g_RecordScalingGIF) }, + { L"RecordScalingMP4", SETTING_TYPE_DWORD, 0, &g_RecordScalingMP4, static_cast<DOUBLE>(g_RecordScalingMP4) }, { L"CaptureAudio", SETTING_TYPE_BOOLEAN, 0, &g_CaptureAudio, static_cast<DOUBLE>(g_CaptureAudio) }, + { L"CaptureSystemAudio", SETTING_TYPE_BOOLEAN, 0, &g_CaptureSystemAudio, static_cast<DOUBLE>(g_CaptureSystemAudio) }, { L"MicrophoneDeviceId", SETTING_TYPE_STRING, sizeof(g_MicrophoneDeviceId), g_MicrophoneDeviceId, static_cast<DOUBLE>(0) }, + { L"RecordingSaveLocation", SETTING_TYPE_STRING, sizeof(g_RecordingSaveLocationBuffer), g_RecordingSaveLocationBuffer, static_cast<DOUBLE>(0) }, + { L"ScreenshotSaveLocation", SETTING_TYPE_STRING, sizeof(g_ScreenshotSaveLocationBuffer), g_ScreenshotSaveLocationBuffer, static_cast<DOUBLE>(0) }, + { L"Theme", SETTING_TYPE_DWORD, 0, &g_ThemeOverride, static_cast<DOUBLE>(g_ThemeOverride) }, + { L"TrimDialogWidth", SETTING_TYPE_DWORD, 0, &g_TrimDialogWidth, static_cast<DOUBLE>(0) }, + { L"TrimDialogHeight", SETTING_TYPE_DWORD, 0, &g_TrimDialogHeight, static_cast<DOUBLE>(0) }, + { L"TrimDialogVolume", SETTING_TYPE_DWORD, 0, &g_TrimDialogVolume, static_cast<DOUBLE>(g_TrimDialogVolume) }, { NULL, SETTING_TYPE_DWORD, 0, NULL, static_cast<DOUBLE>(0) } }; diff --git a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp index 2a0a517cfd..b3f736fd43 100644 --- a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp +++ b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp @@ -15,6 +15,7 @@ #include "Utility.h" #include "WindowsVersions.h" #include "ZoomItSettings.h" +#include "GifRecordingSession.h" #ifdef __ZOOMIT_POWERTOYS__ #include <common/interop/shared_constants.h> @@ -27,6 +28,20 @@ #include <common/utils/logger_helper.h> #include <common/utils/winapi_error.h> #include <common/utils/gpo.h> +#include <array> +#include <vector> +#endif // __ZOOMIT_POWERTOYS__ + +#ifdef __ZOOMIT_POWERTOYS__ +enum class ZoomItCommand +{ + Zoom, + Draw, + Break, + LiveZoom, + Snip, + Record, +}; #endif // __ZOOMIT_POWERTOYS__ namespace winrt @@ -68,6 +83,12 @@ COLORREF g_CustomColors[16]; #define SNIP_SAVE_HOTKEY 9 #define DEMOTYPE_HOTKEY 10 #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 @@ -89,6 +110,11 @@ OPTION_TABS g_OptionsTabs[] = { { _T("Snip"), NULL } }; +static const TCHAR* g_RecordingFormats[] = { + _T("GIF"), + _T("MP4") +}; + float g_ZoomLevels[] = { 1.25, 1.50, @@ -99,6 +125,8 @@ float g_ZoomLevels[] = { }; DWORD g_FramerateOptions[] = { + 15, + 24, 30, 60 }; @@ -120,7 +148,7 @@ const float STRONG_BLUR_RADIUS = 40; DWORD g_ToggleMod; DWORD g_LiveZoomToggleMod; DWORD g_DrawToggleMod; -DWORD g_BreakToggleMod; +DWORD g_BreakToggleMod; DWORD g_DemoTypeToggleMod; DWORD g_RecordToggleMod; DWORD g_SnipToggleMod; @@ -152,13 +180,17 @@ 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_ScreenshotSaveLocation; winrt::IDirect3DDevice g_RecordDevice{ nullptr }; std::shared_ptr<VideoRecordingSession> g_RecordingSession = nullptr; - +std::shared_ptr<GifRecordingSession> g_GifRecordingSession = nullptr; type_pGetMonitorInfo pGetMonitorInfo; type_MonitorFromPoint pMonitorFromPoint; type_pSHAutoComplete pSHAutoComplete; @@ -170,6 +202,8 @@ type_pMagSetFullscreenTransform pMagSetFullscreenTransform; type_pMagSetInputTransform pMagSetInputTransform; type_pMagShowSystemCursor pMagShowSystemCursor; type_pMagSetWindowFilterList pMagSetWindowFilterList; +type_MagSetFullscreenUseBitmapSmoothing pMagSetFullscreenUseBitmapSmoothing; +type_pMagSetLensUseBitmapSmoothing pMagSetLensUseBitmapSmoothing; type_pMagInitialize pMagInitialize; type_pDwmIsCompositionEnabled pDwmIsCompositionEnabled; type_pGetPointerType pGetPointerType; @@ -188,9 +222,77 @@ 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<IOleWindow> 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<LPARAM>(hIcon)); + SendMessage(hwndDialog, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(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. +// Saves specified filePath to clipboard. // //---------------------------------------------------------------------------- bool SaveToClipboard( const WCHAR* filePath, HWND hwnd ) @@ -205,18 +307,18 @@ bool SaveToClipboard( const WCHAR* filePath, HWND hwnd ) HDROP hDrop = static_cast<HDROP>(GlobalAlloc( GHND, size )); if (hDrop == NULL) { - return false; + return false; } DROPFILES* dFiles = static_cast<DROPFILES*>(GlobalLock( hDrop )); if (dFiles == NULL) { GlobalFree( hDrop ); - return false; + return false; } dFiles->pFiles = sizeof(DROPFILES); - dFiles->fWide = TRUE; + dFiles->fWide = TRUE; wcscpy( reinterpret_cast<WCHAR*>(& dFiles[1]), filePath); GlobalUnlock( hDrop ); @@ -323,7 +425,7 @@ void RestoreForeground() // If the main window is not visible, move foreground to the next window. if( !IsWindowVisible( g_hWndMain ) ) { - // Activate the next window by unhiding and hiding the main window. + // Activate the next window by showing and hiding the main window. MoveWindow( g_hWndMain, 0, 0, 0, 0, FALSE ); ShowWindow( g_hWndMain, SW_SHOWNA ); ShowWindow( g_hWndMain, SW_HIDE ); @@ -343,7 +445,7 @@ VOID ErrorDialog( HWND hParent, PCTSTR message, DWORD _Error ) TCHAR errmsg[1024]; FormatMessage( FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, - NULL, _Error, + NULL, _Error, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), reinterpret_cast<LPTSTR>(&lpMsgBuf), 0, NULL ); _stprintf( errmsg, L"%s: %s", message, lpMsgBuf ); @@ -389,7 +491,7 @@ VOID ErrorDialogString( HWND hParent, PCTSTR Message, const wchar_t *_Error ) // SetAutostartFilePath // // Sets the file path for later autostart config. -// +// //-------------------------------------------------------------------- void SetAutostartFilePath() { @@ -415,32 +517,32 @@ void SetAutostartFilePath() // ConfigureAutostart // // Enables or disables Zoomit autostart for the current image file. -// +// //-------------------------------------------------------------------- -bool ConfigureAutostart( HWND hParent, bool Enable ) +bool ConfigureAutostart( HWND hParent, bool Enable ) { HKEY hRunKey, hZoomit; DWORD error, length, type; TCHAR imageFile[MAX_PATH]; - error = RegOpenKeyEx( HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", + error = RegOpenKeyEx( HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, KEY_SET_VALUE, &hRunKey ); if( error == ERROR_SUCCESS ) { if( Enable ) { - - error = RegOpenKeyEx( HKEY_CURRENT_USER, _T("Software\\Sysinternals\\Zoomit"), 0, + + error = RegOpenKeyEx( HKEY_CURRENT_USER, _T("Software\\Sysinternals\\Zoomit"), 0, KEY_QUERY_VALUE, &hZoomit ); if( error == ERROR_SUCCESS ) { length = sizeof(imageFile); #ifdef _WIN64 // Unconditionally reset filepath in case this was already set by 32 bit version - SetAutostartFilePath(); + SetAutostartFilePath(); #endif error = RegQueryValueEx( hZoomit, _T( "Filepath" ), 0, &type, (BYTE *) imageFile, &length ); RegCloseKey( hZoomit ); - if( error == ERROR_SUCCESS ) { + if( error == ERROR_SUCCESS ) { error = RegSetValueEx( hRunKey, APPNAME, 0, REG_SZ, (BYTE *) imageFile, static_cast<DWORD>(_tcslen(imageFile)+1) * sizeof(TCHAR)); @@ -452,7 +554,7 @@ bool ConfigureAutostart( HWND hParent, bool Enable ) if( error == ERROR_FILE_NOT_FOUND ) error = ERROR_SUCCESS; } RegCloseKey( hRunKey ); - } + } if( error != ERROR_SUCCESS ) { ErrorDialog( hParent, L"Error configuring auto start", error ); @@ -466,15 +568,15 @@ bool ConfigureAutostart( HWND hParent, bool Enable ) // IsAutostartConfigured // // Is this version of zoomit configured to autostart. -// +// //-------------------------------------------------------------------- bool IsAutostartConfigured() { HKEY hRunKey; - TCHAR imageFile[MAX_PATH]; + TCHAR imageFile[MAX_PATH]; DWORD error, imageFileLength, type; - error = RegOpenKeyEx( HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", + error = RegOpenKeyEx( HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, KEY_QUERY_VALUE, &hRunKey ); if( error == ERROR_SUCCESS ) { @@ -494,15 +596,15 @@ bool IsAutostartConfigured() // // Returns true if this is the 32-bit version of the executable // and we're on 64-bit Windows. -// +// //-------------------------------------------------------------------- typedef BOOL (__stdcall *P_IS_WOW64PROCESS)( HANDLE hProcess, PBOOL Wow64Process ); -BOOL +BOOL RunningOnWin64( - VOID + VOID ) { P_IS_WOW64PROCESS pIsWow64Process; @@ -511,9 +613,9 @@ RunningOnWin64( pIsWow64Process = (P_IS_WOW64PROCESS) GetProcAddress(GetModuleHandle(_T("kernel32.dll")), "IsWow64Process"); if( pIsWow64Process ) { - + pIsWow64Process( GetCurrentProcess(), &isWow64 ); - } + } return isWow64; } @@ -522,7 +624,7 @@ RunningOnWin64( // // ExtractImageResource // -// Extracts the specified file that is located in a resource for +// Extracts the specified file that is located in a resource for // this executable. // //-------------------------------------------------------------------- @@ -530,15 +632,15 @@ BOOLEAN ExtractImageResource( PTCHAR ResourceName, PTCHAR TargetFile ) { HRSRC hResource; HGLOBAL hImageResource; - DWORD dwImageSize; + DWORD dwImageSize; LPVOID lpvImage; FILE *hFile; // Locate the resource - hResource = FindResource( NULL, ResourceName, _T("BINRES") ); - if( !hResource ) + hResource = FindResource( NULL, ResourceName, _T("BINRES") ); + if( !hResource ) return FALSE; - + hImageResource = LoadResource( NULL, hResource ); dwImageSize = SizeofResource( NULL, hResource ); lpvImage = LockResource( hImageResource ); @@ -560,10 +662,10 @@ BOOLEAN ExtractImageResource( PTCHAR ResourceName, PTCHAR TargetFile ) // // Returns true if this is the 32-bit version of the executable // and we're on 64-bit Windows. -// +// //-------------------------------------------------------------------- -DWORD -Run64bitVersion( +DWORD +Run64bitVersion( void ) { @@ -634,13 +736,13 @@ BOOLEAN IsPresentationMode() //---------------------------------------------------------------------------- // // EnableDisableSecondaryDisplay -// +// // Creates a second display on the secondary monitor for displaying the -// break timer. +// break timer. // //---------------------------------------------------------------------------- -LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, - PDEVMODE OriginalDevMode ) +LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, + PDEVMODE OriginalDevMode ) { LONG result; DEVMODE devMode{}; @@ -652,7 +754,7 @@ LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, // devMode.dmSize = sizeof(devMode); devMode.dmDriverExtra = 0; - EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, &devMode); + EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, &devMode); *OriginalDevMode = devMode; // @@ -665,7 +767,7 @@ LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFLAGS | - DM_DISPLAYFREQUENCY; + DM_DISPLAYFREQUENCY; result = ChangeDisplaySettingsEx( L"\\\\.\\DISPLAY2", &devMode, NULL, @@ -721,11 +823,11 @@ LONG EnableDisableSecondaryDisplay( HWND hWnd, BOOLEAN Enable, // GetLineBounds // // Gets the rectangle bounding a line, taking into account pen width -// +// //---------------------------------------------------------------------------- Gdiplus::Rect GetLineBounds( POINT p1, POINT p2, int penWidth ) { - Gdiplus::Rect rect( min(p1.x, p2.x), min(p1.y, p2.y), + Gdiplus::Rect rect( min(p1.x, p2.x), min(p1.y, p2.y), abs(p1.x - p2.x), abs( p1.y - p2.y)); rect.Inflate( penWidth, penWidth ); return rect; @@ -736,7 +838,7 @@ Gdiplus::Rect GetLineBounds( POINT p1, POINT p2, int penWidth ) // InvalidateGdiplusRect // // Invalidate portion of window specified by Gdiplus::Rect -// +// //---------------------------------------------------------------------------- void InvalidateGdiplusRect(HWND hWnd, Gdiplus::Rect BoundsRect) { @@ -754,8 +856,8 @@ void InvalidateGdiplusRect(HWND hWnd, Gdiplus::Rect BoundsRect) // // CreateGdiplusBitmap // -// Creates a gdiplus bitmap of the specified region of the HDC. -// +// Creates a gdiplus bitmap of the specified region of the HDC. +// //---------------------------------------------------------------------------- Gdiplus::Bitmap *CreateGdiplusBitmap( HDC hDc, int x, int y, int Width, int Height ) { @@ -770,7 +872,7 @@ Gdiplus::Bitmap *CreateGdiplusBitmap( HDC hDc, int x, int y, int Width, int Heig Gdiplus::Bitmap *blurBitmap = new Gdiplus::Bitmap(hBitmap, NULL); DeleteDC(hdcNewBitmap); DeleteObject(hBitmap); - return blurBitmap; + return blurBitmap; } @@ -779,7 +881,7 @@ Gdiplus::Bitmap *CreateGdiplusBitmap( HDC hDc, int x, int y, int Width, int Heig // CreateBitmapMemoryDIB // // Creates a memory DC and DIB for the specified region of the screen. -// +// //---------------------------------------------------------------------------- BYTE* CreateBitmapMemoryDIB(HDC hdcScreenCompat, HDC hBitmapDc, Gdiplus::Rect* lineBounds, HDC* hdcMem, HBITMAP* hDIBOrig, HBITMAP* hPreviousBitmap) @@ -817,8 +919,8 @@ BYTE* CreateBitmapMemoryDIB(HDC hdcScreenCompat, HDC hBitmapDc, Gdiplus::Rect* l // // LockGdiPlusBitmap // -// Locks the Gdi+ bitmap so that we can access its pixels in memory. -// +// Locks the Gdi+ bitmap so that we can access its pixels in memory. +// //---------------------------------------------------------------------------- #ifdef _MSC_VER // Analyzers want us to use a scoped object instead of new. But given all the operations done in Bitmaps it seems better to leave it as a heap object. @@ -833,7 +935,7 @@ Gdiplus::BitmapData* LockGdiPlusBitmap(Gdiplus::Bitmap* Bitmap) Gdiplus::Rect lineBitmapBounds(0, 0, Bitmap->GetWidth(), Bitmap->GetHeight()); Bitmap->LockBits(&lineBitmapBounds, Gdiplus::ImageLockModeRead, Bitmap->GetPixelFormat(), lineData); - return lineData; + return lineData; } #ifdef _MSC_VER #pragma warning(pop) @@ -844,11 +946,11 @@ Gdiplus::BitmapData* LockGdiPlusBitmap(Gdiplus::Bitmap* Bitmap) // // BlurScreen // -// Blur the portion of the screen by copying a blurred bitmap with the -// specified shape. -// +// Blur the portion of the screen by copying a blurred bitmap with the +// specified shape. +// //---------------------------------------------------------------------------- -void BlurScreen(HDC hdcScreenCompat, Gdiplus::Rect* lineBounds, +void BlurScreen(HDC hdcScreenCompat, Gdiplus::Rect* lineBounds, Gdiplus::Bitmap *BlurBitmap, BYTE* pPixels) { HDC hdcDIB; @@ -894,8 +996,8 @@ void BlurScreen(HDC hdcScreenCompat, Gdiplus::Rect* lineBounds, // // BitmapBlur // -// Blurs the bitmap. -// +// Blurs the bitmap. +// //---------------------------------------------------------------------------- void BitmapBlur(Gdiplus::Bitmap* hBitmap) { @@ -926,7 +1028,7 @@ void BitmapBlur(Gdiplus::Bitmap* hBitmap) // DrawBlurredShape // // Blur a shaped region of the screen. -// +// //---------------------------------------------------------------------------- void DrawBlurredShape( DWORD Shape, Gdiplus::Pen *pen, HDC hdcScreenCompat, Gdiplus::Graphics *dstGraphics, int x1, int y1, int x2, int y2) @@ -935,7 +1037,7 @@ void DrawBlurredShape( DWORD Shape, Gdiplus::Pen *pen, HDC hdcScreenCompat, Gdip Gdiplus::Rect lineBounds( min( x1, x2 ), min( y1, y2 ), abs( x2 - x1 ), abs( y2 - y1 ) ); // Expand for line drawing - if (Shape == DRAW_LINE) + if (Shape == DRAW_LINE) lineBounds.Inflate( static_cast<int>(g_PenWidth / 2), static_cast<int>(g_PenWidth / 2) ); Gdiplus::Bitmap* lineBitmap = new Gdiplus::Bitmap(lineBounds.Width, lineBounds.Height, PixelFormat32bppARGB); @@ -976,7 +1078,7 @@ void DrawBlurredShape( DWORD Shape, Gdiplus::Pen *pen, HDC hdcScreenCompat, Gdip // CreateDrawingBitmap // // Create a bitmap to draw on. -// +// //---------------------------------------------------------------------------- Gdiplus::Bitmap* CreateDrawingBitmap(Gdiplus::Rect lineBounds ) { @@ -990,8 +1092,8 @@ Gdiplus::Bitmap* CreateDrawingBitmap(Gdiplus::Rect lineBounds ) // // DrawBitmapLine // -// Creates a bitmap and draws a line on it. -// +// Creates a bitmap and draws a line on it. +// //---------------------------------------------------------------------------- Gdiplus::Bitmap* DrawBitmapLine(Gdiplus::Rect lineBounds, POINT p1, POINT p2, Gdiplus::Pen *pen) { @@ -1011,7 +1113,7 @@ Gdiplus::Bitmap* DrawBitmapLine(Gdiplus::Rect lineBounds, POINT p1, POINT p2, Gd // ColorFromColorRef // // Returns a color object from the colorRef that includes the alpha channel -// +// //---------------------------------------------------------------------------- Gdiplus::Color ColorFromColorRef(DWORD colorRef) { BYTE a = (colorRef >> 24) & 0xFF; // Extract the alpha channel value @@ -1026,8 +1128,8 @@ Gdiplus::Color ColorFromColorRef(DWORD colorRef) { // // AdjustHighlighterColor // -// Lighten the color. -// +// Lighten the color. +// //---------------------------------------------------------------------------- void AdjustHighlighterColor(BYTE* red, BYTE* green, BYTE* blue) { @@ -1042,8 +1144,8 @@ void AdjustHighlighterColor(BYTE* red, BYTE* green, BYTE* blue) { // BlendColors // // Blends two colors together using the alpha channel of the second color. -// The highlighter is the second color. -// +// The highlighter is the second color. +// //---------------------------------------------------------------------------- COLORREF BlendColors(COLORREF color1, const Gdiplus::Color& color2) { @@ -1066,7 +1168,7 @@ COLORREF BlendColors(COLORREF color1, const Gdiplus::Color& color2) { // int maxValue = max(red1, max(green1, blue1)); if(TRUE) { // red1 > 0x10 && red1 < 0xC0 && (maxValue - minValue < 0x40)) { - // This does a standard bright highlight + // This does a standard bright highlight alpha2 = 0; AdjustHighlighterColor( &red2, &green2, &blue2 ); redResult = red2 & red1; @@ -1093,12 +1195,14 @@ COLORREF BlendColors(COLORREF color1, const Gdiplus::Color& color2) { // Draws the shape with the highlighter color. // //---------------------------------------------------------------------------- -void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBrush, +void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBrush, Gdiplus::Pen *pPen, int x1, int y1, int x2, int y2) { // Create a new bitmap that's the size of the area covered by the line + 2 * g_PenWidth Gdiplus::Rect lineBounds(min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1)); + OutputDebug(L"DrawHighlightedShape\n"); + // Expand for line drawing if (Shape == DRAW_LINE) lineBounds.Inflate(static_cast<int>(g_PenWidth / 2), static_cast<int>(g_PenWidth / 2)); @@ -1111,7 +1215,7 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr break; case DRAW_ELLIPSE: lineGraphics.FillEllipse( pBrush, 0, 0, lineBounds.Width, lineBounds.Height); - break; + break; case DRAW_LINE: lineGraphics.DrawLine(pPen, x1 - lineBounds.X, y1 - lineBounds.Y, x2 - lineBounds.X, y2 - lineBounds.Y); break; @@ -1186,14 +1290,14 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr DeleteDC(hdcDIBOrig); // Invalidate the updated rectangle - // InvalidateGdiplusRect(hWnd, lineBounds); + //InvalidateGdiplusRect(hWnd, lineBounds); } //---------------------------------------------------------------------------- // // CreateFadedDesktopBackground // -// Creates a snapshot of the desktop that's faded and alpha blended with +// Creates a snapshot of the desktop that's faded and alpha blended with // black. // //---------------------------------------------------------------------------- @@ -1207,7 +1311,7 @@ HBITMAP CreateFadedDesktopBackground( HDC hdc, LPRECT rcScreen, LPRECT rcCrop ) HBITMAP hBitmap = CreateCompatibleBitmap( hdcScreen, width, height ); HBITMAP hOld = static_cast<HBITMAP>(SelectObject( hdcMem, hBitmap )); HBRUSH hBrush = CreateSolidBrush(RGB(0, 0, 0)); - + // start with black background FillRect( hdcMem, rcScreen, hBrush ); if(rcCrop != NULL && rcCrop->left != -1 ) { @@ -1223,8 +1327,8 @@ HBITMAP CreateFadedDesktopBackground( HDC hdc, LPRECT rcScreen, LPRECT rcCrop ) blend.BlendFlags = 0; blend.SourceConstantAlpha = 0x4F; blend.AlphaFormat = 0; - AlphaBlend( hdcMem,0, 0, width, height, - hdcScreen, rcScreen->left, rcScreen->top, + AlphaBlend( hdcMem,0, 0, width, height, + hdcScreen, rcScreen->left, rcScreen->top, width, height, blend ); SelectObject( hdcMem, hOld ); @@ -1245,9 +1349,9 @@ HBITMAP CreateFadedDesktopBackground( HDC hdc, LPRECT rcScreen, LPRECT rcCrop ) void AdjustToMoveBoundary( float zoomLevel, int *coordinate, int cursor, int size, int max ) { int diff = static_cast<int> (static_cast<float>(size)/ static_cast<float>(LIVEZOOM_MOVE_REGIONS)); - if( cursor - *coordinate < diff ) - *coordinate = max( 0, cursor - diff ); - else if( (*coordinate + size) - cursor < diff ) + if( cursor - *coordinate < diff ) + *coordinate = max( 0, cursor - diff ); + else if( (*coordinate + size) - cursor < diff ) *coordinate = min( cursor + diff - size, max - size ); } @@ -1274,17 +1378,22 @@ void GetZoomedTopLeftCoordinates( float zoomLevel, POINT *cursorPos, int *x, int // // ScaleImage // -// Use gdi+ for anti-aliased bitmap stretching. +// Use gdi+ for anti-aliased bitmap stretching. // //---------------------------------------------------------------------------- -void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst, +void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst, HBITMAP bmSrc, float xSrc, float ySrc, float wSrc, float hSrc ) { Gdiplus::Graphics dstGraphics( hdcDst ); { Gdiplus::Bitmap srcBitmap( bmSrc, NULL ); - dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeLowQuality ); + // Use high quality interpolation when smooth image is enabled + if (g_SmoothImage) { + dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeHighQuality ); + } else { + dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeLowQuality ); + } dstGraphics.SetPixelOffsetMode( Gdiplus::PixelOffsetModeHalf ); dstGraphics.DrawImage( &srcBitmap, Gdiplus::RectF(xDst,yDst,wDst,hDst), xSrc, ySrc, wSrc, hSrc, Gdiplus::UnitPixel ); @@ -1322,7 +1431,7 @@ using namespace Gdiplus; *pClsid = pImageCodecInfo[j].Clsid; free(pImageCodecInfo); return j; // Success - } + } } free(pImageCodecInfo); @@ -1330,20 +1439,20 @@ using namespace Gdiplus; } //---------------------------------------------------------------------- -// +// // ConvertToUnicode // //---------------------------------------------------------------------- -void -ConvertToUnicode( - PCHAR aString, - PWCHAR wString, - DWORD wStringLength +void +ConvertToUnicode( + PCHAR aString, + PWCHAR wString, + DWORD wStringLength ) { size_t len; - len = MultiByteToWideChar( CP_ACP, 0, aString, static_cast<int>(strlen(aString)), + len = MultiByteToWideChar( CP_ACP, 0, aString, static_cast<int>(strlen(aString)), wString, wStringLength ); wString[len] = 0; } @@ -1379,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; @@ -1402,14 +1511,14 @@ void EnableDisableTrayIcon( HWND hWnd, BOOLEAN Enable ) NOTIFYICONDATA tNotifyIconData; memset( &tNotifyIconData, 0, sizeof(tNotifyIconData)); - tNotifyIconData.cbSize = sizeof(NOTIFYICONDATA); - tNotifyIconData.hWnd = hWnd; - tNotifyIconData.uID = 1; - tNotifyIconData.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; - tNotifyIconData.uCallbackMessage = WM_USER_TRAY_ACTIVATE; - tNotifyIconData.hIcon = LoadIcon( g_hInstance, L"APPICON" ); + tNotifyIconData.cbSize = sizeof(NOTIFYICONDATA); + tNotifyIconData.hWnd = hWnd; + tNotifyIconData.uID = 1; + tNotifyIconData.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + tNotifyIconData.uCallbackMessage = WM_USER_TRAY_ACTIVATE; + tNotifyIconData.hIcon = LoadIcon( g_hInstance, L"APPICON" ); lstrcpyn(tNotifyIconData.szTip, APPNAME, sizeof(APPNAME)); - Shell_NotifyIcon(Enable ? NIM_ADD : NIM_DELETE, &tNotifyIconData); + Shell_NotifyIcon(Enable ? NIM_ADD : NIM_DELETE, &tNotifyIconData); } //---------------------------------------------------------------------------- @@ -1417,7 +1526,7 @@ void EnableDisableTrayIcon( HWND hWnd, BOOLEAN Enable ) // EnableDisableOpacity // //---------------------------------------------------------------------------- -void EnableDisableOpacity( HWND hWnd, BOOLEAN Enable ) +void EnableDisableOpacity( HWND hWnd, BOOLEAN Enable ) { DWORD exStyle; @@ -1444,11 +1553,11 @@ void EnableDisableOpacity( HWND hWnd, BOOLEAN Enable ) // EnableDisableScreenSaver // //---------------------------------------------------------------------------- -void EnableDisableScreenSaver( BOOLEAN Enable ) +void EnableDisableScreenSaver( BOOLEAN Enable ) { - SystemParametersInfo(SPI_SETSCREENSAVEACTIVE,Enable,0,0); - SystemParametersInfo(SPI_SETPOWEROFFACTIVE,Enable,0,0); - SystemParametersInfo(SPI_SETLOWPOWERACTIVE,Enable,0,0); + SystemParametersInfo(SPI_SETSCREENSAVEACTIVE,Enable,0,0); + SystemParametersInfo(SPI_SETPOWEROFFACTIVE,Enable,0,0); + SystemParametersInfo(SPI_SETLOWPOWERACTIVE,Enable,0,0); } //---------------------------------------------------------------------------- @@ -1461,25 +1570,25 @@ void EnableDisableStickyKeys( BOOLEAN Enable ) static STICKYKEYS prevStickyKeyValue = {0}; STICKYKEYS newStickyKeyValue = {0}; - // Need to do this on Vista tablet to stop sticky key popup when you + // Need to do this on Vista tablet to stop sticky key popup when you // hold down the shift key and draw with the pen. if( Enable ) { if( prevStickyKeyValue.cbSize == sizeof(STICKYKEYS)) { - SystemParametersInfo(SPI_SETSTICKYKEYS, + SystemParametersInfo(SPI_SETSTICKYKEYS, sizeof(STICKYKEYS), &prevStickyKeyValue, SPIF_SENDCHANGE); } } else { prevStickyKeyValue.cbSize = sizeof(STICKYKEYS); - if (SystemParametersInfo(SPI_GETSTICKYKEYS, sizeof(STICKYKEYS), + if (SystemParametersInfo(SPI_GETSTICKYKEYS, sizeof(STICKYKEYS), &prevStickyKeyValue, 0)) { newStickyKeyValue.cbSize = sizeof(STICKYKEYS); newStickyKeyValue.dwFlags = 0; - if( !SystemParametersInfo(SPI_SETSTICKYKEYS, + if( !SystemParametersInfo(SPI_SETSTICKYKEYS, sizeof(STICKYKEYS), &newStickyKeyValue, SPIF_SENDCHANGE)) { // DWORD error = GetLastError(); @@ -1511,24 +1620,33 @@ constexpr DWORD GetKeyMod( DWORD Key ) // AdvancedBreakProc // //---------------------------------------------------------------------------- -INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam ) +INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam ) { TCHAR opacity[10]; static TCHAR newSoundFile[MAX_PATH]; 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<LPARAM>(hIcon) ); + SendMessage( hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(hIcon) ); + } + } if( pSHAutoComplete ) { pSHAutoComplete( GetDlgItem( hDlg, IDC_SOUND_FILE), SHACF_FILESYSTEM ); pSHAutoComplete( GetDlgItem( hDlg, IDC_BACKGROUND_FILE), SHACF_FILESYSTEM ); } - CheckDlgButton( hDlg, IDC_CHECK_BACKGROUND_FILE, + CheckDlgButton( hDlg, IDC_CHECK_BACKGROUND_FILE, g_BreakShowBackgroundFile ? BST_CHECKED: BST_UNCHECKED ); - CheckDlgButton( hDlg, IDC_CHECK_SOUND_FILE, + CheckDlgButton( hDlg, IDC_CHECK_SOUND_FILE, g_BreakPlaySoundFile ? BST_CHECKED: BST_UNCHECKED ); CheckDlgButton( hDlg, IDC_CHECK_SHOW_EXPIRED, g_ShowExpiredTime ? BST_CHECKED : BST_UNCHECKED ); @@ -1566,7 +1684,7 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_BROWSE ), FALSE ); EnableWindow( GetDlgItem( hDlg, IDC_CHECK_BACKGROUND_STRETCH ), FALSE ); } - CheckDlgButton( hDlg, + CheckDlgButton( hDlg, g_BreakShowDesktop ? IDC_STATIC_DESKTOP_BACKGROUND : IDC_STATIC_BACKGROUND_FILE, BST_CHECKED ); _tcscpy( newBackgroundFile, g_BreakBackgroundFile ); SetDlgItemText( hDlg, IDC_BACKGROUND_FILE, g_BreakBackgroundFile ); @@ -1576,113 +1694,203 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR for( i = 10; i <= 100; i += 10) { _stprintf( opacity, L"%d%%", i ); - SendMessage( GetDlgItem( hDlg, IDC_OPACITY ), CB_ADDSTRING, 0, + SendMessage( GetDlgItem( hDlg, IDC_OPACITY ), CB_ADDSTRING, 0, reinterpret_cast<LPARAM>(opacity)); } - SendMessage( GetDlgItem( hDlg, IDC_OPACITY ), CB_SETCURSEL, + 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<HDC>(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<HDC>(wParam); + HWND hCtrl = reinterpret_cast<HWND>(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast<INT_PTR>(hBrush); + } + break; + } + case WM_COMMAND: switch ( HIWORD( wParam )) { case BN_CLICKED: if( LOWORD( wParam ) == IDC_CHECK_SOUND_FILE ) { - EnableWindow( GetDlgItem( hDlg, IDC_STATIC_SOUND_FILE ), + EnableWindow( GetDlgItem( hDlg, IDC_STATIC_SOUND_FILE ), IsDlgButtonChecked( hDlg, IDC_CHECK_SOUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_SOUND_FILE ), + EnableWindow( GetDlgItem( hDlg, IDC_SOUND_FILE ), + IsDlgButtonChecked( hDlg, IDC_CHECK_SOUND_FILE) == BST_CHECKED ); + EnableWindow( GetDlgItem( hDlg, IDC_SOUND_BROWSE ), IsDlgButtonChecked( hDlg, IDC_CHECK_SOUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_SOUND_BROWSE ), - IsDlgButtonChecked( hDlg, IDC_CHECK_SOUND_FILE) == BST_CHECKED ); } if( LOWORD( wParam ) == IDC_CHECK_BACKGROUND_FILE ) { - EnableWindow( GetDlgItem( hDlg, IDC_CHECK_BACKGROUND_STRETCH ), + EnableWindow( GetDlgItem( hDlg, IDC_CHECK_BACKGROUND_STRETCH ), IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_STATIC_DESKTOP_BACKGROUND ), + EnableWindow( GetDlgItem( hDlg, IDC_STATIC_DESKTOP_BACKGROUND ), IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_STATIC_BACKGROUND_FILE ), + EnableWindow( GetDlgItem( hDlg, IDC_STATIC_BACKGROUND_FILE ), IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_FILE ), + EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_FILE ), + IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); + EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_BROWSE ), IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); - EnableWindow( GetDlgItem( hDlg, IDC_BACKGROUND_BROWSE ), - IsDlgButtonChecked( hDlg, IDC_CHECK_BACKGROUND_FILE) == BST_CHECKED ); } break; } 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<HINSTANCE>(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<IFileOpenDialog>( 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<IShellItem> 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<IShellItem> 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<HINSTANCE>(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<IFileOpenDialog>( 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<IShellItem> 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<IShellItem> 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: @@ -1695,7 +1903,7 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR #endif if( g_BreakPlaySoundFile && GetFileAttributes( newSoundFile ) == -1 ) { - MessageBox( hDlg, L"The specified sound file is inaccessible", + MessageBox( hDlg, L"The specified sound file is inaccessible", L"Advanced Break Options Error", MB_ICONERROR ); break; } @@ -1706,7 +1914,7 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR if( !g_BreakShowDesktop && g_BreakShowBackgroundFile && GetFileAttributes( newBackgroundFile ) == -1 ) { - MessageBox( hDlg, L"The specified background file is inaccessible", + MessageBox( hDlg, L"The specified background file is inaccessible", L"Advanced Break Options Error", MB_ICONERROR ); break; } @@ -1720,7 +1928,7 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR break; } } - GetDlgItemText( hDlg, IDC_OPACITY, opacity, sizeof(opacity)/sizeof(opacity[0])); + GetDlgItemText( hDlg, IDC_OPACITY, opacity, sizeof(opacity)/sizeof(opacity[0])); _stscanf( opacity, L"%d%%", &g_BreakOpacity ); reg.WriteRegSettings( RegSettings ); EndDialog(hDlg, 0); @@ -1744,24 +1952,143 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR // OptionsTabProc // //---------------------------------------------------------------------------- -INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, - WPARAM wParam, LPARAM lParam ) + +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<LPARAM>(hIcon) ); + SendMessage( hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(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<HDC>(wParam); + HWND hCtrl = reinterpret_cast<HWND>(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast<UINT_PTR>(hBrush); + } + break; + } + } + + return 0; +} + +INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, + WPARAM wParam, LPARAM lParam ) { HDC hDC; LOGFONT lf; CHOOSEFONT chooseFont; HFONT hFont; - PAINTSTRUCT ps; + PAINTSTRUCT ps; 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<HDC>(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<HDC>(wParam); + HWND hCtrl = reinterpret_cast<HWND>(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast<INT_PTR>(hBrush); + } + break; + } + case WM_COMMAND: + // Handle combo box selection changes + if (HIWORD(wParam) == CBN_SELCHANGE) { + if (LOWORD(wParam) == IDC_RECORD_SCALING) { + + int format = static_cast<int>(SendMessage(GetDlgItem(hDlg, IDC_RECORD_FORMAT), CB_GETCURSEL, 0, 0)); + int scale = static_cast<int>(SendMessage(GetDlgItem(hDlg, IDC_RECORD_SCALING), CB_GETCURSEL, 0, 0)); + if(format == 0) + { + g_RecordScalingGIF = static_cast<BYTE>((scale + 1) * 10); + } + else + { + g_RecordScalingMP4 = static_cast<BYTE>((scale + 1) * 10); + } + } + else if (LOWORD(wParam) == IDC_RECORD_FORMAT) { + // Get the currently selected format + int selection = static_cast<int>(SendMessage(GetDlgItem(hDlg, IDC_RECORD_FORMAT), + CB_GETCURSEL, 0, 0)); + + // Get the selected text to check if it's GIF + TCHAR selectedText[32] = {0}; + SendMessage(GetDlgItem(hDlg, IDC_RECORD_FORMAT), + CB_GETLBTEXT, selection, reinterpret_cast<LPARAM>(selectedText)); + + // 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 + if (isGifSelected) { + g_RecordScaling = g_RecordScalingGIF; + + } else { + + g_RecordScaling = g_RecordScalingMP4; + } + + for (int i = 0; i < 10; i++) { + int scalingValue = (i + 1) * 10; + if (scalingValue == static_cast<int>(g_RecordScaling)) { + SendMessage(GetDlgItem(hDlg, IDC_RECORD_SCALING), + CB_SETCURSEL, i, 0); + break; + } + } + + // 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); + } + } + switch ( LOWORD( wParam )) { case IDC_ADVANCED_BREAK: DialogBox( g_hInstance, L"ADVANCED_BREAK", hDlg, AdvancedBreakProc ); @@ -1775,8 +2102,8 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, chooseFont.lStructSize = sizeof (CHOOSEFONT); chooseFont.hwndOwner = hDlg; chooseFont.lpLogFont = &lf; - chooseFont.Flags = CF_SCREENFONTS|CF_ENABLETEMPLATE| - CF_INITTOLOGFONTSTRUCT|CF_LIMITSIZE; + chooseFont.Flags = CF_SCREENFONTS|CF_ENABLETEMPLATE|CF_ENABLEHOOK| + CF_INITTOLOGFONTSTRUCT|CF_LIMITSIZE; chooseFont.rgbColors = RGB (0, 0, 0); chooseFont.lCustData = 0; chooseFont.nSizeMin = 16; @@ -1784,7 +2111,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, chooseFont.hInstance = g_hInstance; chooseFont.lpszStyle = static_cast<LPTSTR>(NULL); chooseFont.nFontType = SCREEN_FONTTYPE; - chooseFont.lpfnHook = reinterpret_cast<LPCFHOOKPROC>(static_cast<FARPROC>(NULL)); + chooseFont.lpfnHook = ChooseFontHookProc; chooseFont.lpTemplateName = static_cast<LPTSTR>(MAKEINTRESOURCE (FORMATDLGORD31)); if( ChooseFont( &chooseFont ) ) { g_LogFont = lf; @@ -1792,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<HINSTANCE>(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; - - if( GetOpenFileName( &openFileName ) ) + { + auto openDialog = wil::CoCreateInstance<IFileOpenDialog>( CLSID_FileOpenDialog ); + + 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<IShellItem> 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: @@ -1826,20 +2172,28 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, LOGFONT _lf = g_LogFont; _lf.lfHeight = -21; hFont = CreateFontIndirect( &_lf); - hDc = BeginPaint(hDlg, &ps); + hDc = BeginPaint(hDlg, &ps); SelectObject( hDc, hFont ); GetWindowRect( hTextPreview, &previewRc ); - MapWindowPoints( NULL, hDlg, reinterpret_cast<LPPOINT>(&previewRc), 2); + MapWindowPoints( NULL, hDlg, reinterpret_cast<LPPOINT>(&previewRc), 2); previewRc.top += 6; - DrawText( hDc, L"Sample", static_cast<int>(_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<int>(_tcslen(L"AaBbYyZz")), &previewRc, DT_CENTER|DT_VCENTER|DT_SINGLELINE ); EndPaint( hDlg, &ps ); DeleteObject( hFont ); } - break; + break; default: break; } @@ -1847,12 +2201,52 @@ 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<LPPOINT>(&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 // //---------------------------------------------------------------------------- -VOID OptionsAddTabs( HWND hOptionsDlg, HWND hTabCtrl ) +VOID OptionsAddTabs( HWND hOptionsDlg, HWND hTabCtrl ) { int i; TCITEM tcItem; @@ -1864,26 +2258,50 @@ VOID OptionsAddTabs( HWND hOptionsDlg, HWND hTabCtrl ) tcItem.mask = TCIF_TEXT; tcItem.pszText = g_OptionsTabs[i].TabTitle; TabCtrl_InsertItem( hTabCtrl, i, &tcItem ); - g_OptionsTabs[i].hPage = CreateDialog( g_hInstance, g_OptionsTabs[i].TabTitle, + 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<LPPOINT>(&pageRc), 2); + // Convert screen coords to parent dialog client coords. + MapWindowPoints( NULL, hOptionsDlg, reinterpret_cast<LPPOINT>(&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 ); + } } //---------------------------------------------------------------------------- @@ -1905,6 +2323,12 @@ void UnregisterAllHotkeys( HWND hWnd ) UnregisterHotKey( hWnd, SNIP_SAVE_HOTKEY); UnregisterHotKey( hWnd, DEMOTYPE_HOTKEY ); 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 ); } //---------------------------------------------------------------------------- @@ -1934,6 +2358,13 @@ void RegisterAllHotkeys(HWND hWnd) RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, (g_RecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF); RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, (g_RecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF); } + // 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 } @@ -1948,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<HFONT>(SendMessage( hChild, WM_GETFONT, 0, 0 )); + if( hBaseFont ) + { + break; + } + } + hChild = GetWindow( hChild, GW_HWNDNEXT ); + } + + if( !hBaseFont ) + { + hBaseFont = static_cast<HFONT>(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<HFONT>(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<WPARAM>(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<WPARAM>(headerFont), TRUE ); // Resize the control to fit the text GetWindowText( hHeader, text, sizeof( text ) / sizeof( text[0] ) ); @@ -1979,7 +2452,1104 @@ void UpdateDrawTabHeaderFont() DrawText( hDC, text, static_cast<int>(_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<HPEN>(SelectObject(hdc, hPen)); + HBRUSH hFillBrush = CreateSolidBrush(isChecked ? fillColor : DarkMode::SurfaceColor); + HBRUSH hOldBrush = static_cast<HBRUSH>(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<HBRUSH>(SelectObject(hdc, hInnerBrush)); + HPEN hNullPen = static_cast<HPEN>(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<HPEN>(SelectObject(hdc, hPen)); + HBRUSH hFillBrush = CreateSolidBrush(fillColor); + HBRUSH hOldBrush = static_cast<HBRUSH>(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<HPEN>(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<HFONT>(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = nullptr; + if (hFont) + { + hOldFont = static_cast<HFONT>(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<wchar_t>(vk); + } + else if (vk >= 'A' && vk <= 'Z') + { + text += static_cast<wchar_t>(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<wchar_t>(vk); + } + else if (vk >= 'A' && vk <= 'Z') + { + text += static_cast<wchar_t>(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<HFONT>(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = nullptr; + if (hFont) + { + hOldFont = static_cast<HFONT>(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<LPPOINT>(&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<HPEN>(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast<HBRUSH>(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<HFONT>(SendMessage(hEdit, WM_GETFONT, 0, 0)); + HFONT hOldFont = hFont ? static_cast<HFONT>(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<LPARAM>(&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<LPPOINT>(&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<HPEN>(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast<HBRUSH>(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<LPARAM>(&rcChannel)); + + RECT rcThumb = { 0 }; + SendMessage(hWnd, TBM_GETTHUMBRECT, 0, reinterpret_cast<LPARAM>(&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<HPEN>(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast<HBRUSH>(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<HDC>(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<HFONT>(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = hFont ? static_cast<HFONT>(SelectObject(hdc, hFont)) : nullptr; + + TCHAR szText[256] = { 0 }; + GetWindowText(hWnd, szText, _countof(szText)); + + // Measure text + SIZE textSize = { 0 }; + GetTextExtentPoint32(hdc, szText, static_cast<int>(_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<HPEN>(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<HFONT>(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<HFONT>(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<HFONT>(SendMessage(hWnd, WM_GETFONT, 0, 0)); + } + HFONT hOldFont = nullptr; + if (hFont) + { + hOldFont = static_cast<HFONT>(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<HDC>(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<HDC>(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<HDC>(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<HPEN>(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast<HBRUSH>(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<HFONT>(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = hFont ? static_cast<HFONT>(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); } //---------------------------------------------------------------------------- @@ -1987,21 +3557,175 @@ void UpdateDrawTabHeaderFont() // OptionsProc // //---------------------------------------------------------------------------- -INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, - WPARAM wParam, LPARAM lParam ) +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<std::pair<std::wstring, std::wstring>> 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<HFONT>(SendMessage( hVersion, WM_GETFONT, 0, 0 )); + if( !hBaseFont ) + { + hBaseFont = reinterpret_cast<HFONT>(SendMessage( hDlg, WM_GETFONT, 0, 0 )); + } + if( !hBaseFont ) + { + hBaseFont = static_cast<HFONT>(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<int>(dpi), 72 ); + hFontVersion = CreateFontIndirect( &lf ); + if( hFontVersion ) + { + SendMessage( hVersion, WM_SETFONT, reinterpret_cast<WPARAM>(hFontVersion), TRUE ); + // Also store in a property so our subclass paint can reliably retrieve it. + SetPropW( hVersion, L"ZoomIt.HeaderFont", reinterpret_cast<HANDLE>(hFontVersion) ); +#if _DEBUG + HFONT checkFont = static_cast<HFONT>(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<HFONT>(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<LPPOINT>(&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<HFONT>(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<LPPOINT>(&rc), 2 ); + SetWindowPos( hCtrl, nullptr, + rc.left, rc.top + deltaY, + 0, 0, + SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE ); + } + } + }; + switch ( message ) { case WM_INITDIALOG: { @@ -2015,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<LPARAM>(hIcon) ); + SendMessage( hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(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]; @@ -2039,15 +3775,22 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, #endif // Add tabs hTabCtrl = GetDlgItem( hDlg, IDC_TAB ); + + // 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 ); - InitializeFonts( hDlg, &hFontBold ); - UpdateDrawTabHeaderFont(); - // Configure options - SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_SETRULES, - static_cast<WPARAM>(HKCOMB_NONE), // invalid key combinations - MAKELPARAM(HOTKEYF_ALT, 0)); // add ALT to invalid entries + SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_SETRULES, + static_cast<WPARAM>(HKCOMB_NONE), // invalid key combinations + MAKELPARAM(HOTKEYF_ALT, 0)); // add ALT to invalid entries if( g_ToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_SETHOTKEY, g_ToggleKey, 0 ); if( pMagInitialize ) { @@ -2065,12 +3808,14 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, if( g_DemoTypeToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_SETHOTKEY, g_DemoTypeToggleKey, 0 ); if( g_RecordToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_SETHOTKEY, g_RecordToggleKey, 0 ); if( g_SnipToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_SETHOTKEY, g_SnipToggleKey, 0 ); - CheckDlgButton( hDlg, IDC_SHOW_TRAY_ICON, + CheckDlgButton( hDlg, IDC_SHOW_TRAY_ICON, g_ShowTrayIcon ? BST_CHECKED: BST_UNCHECKED ); - CheckDlgButton( hDlg, IDC_AUTOSTART, + CheckDlgButton( hDlg, IDC_AUTOSTART, IsAutostartConfigured() ? BST_CHECKED: BST_UNCHECKED ); - CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM, + CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM, g_AnimateZoom ? BST_CHECKED: BST_UNCHECKED ); + CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_SMOOTH_IMAGE, + g_SmoothImage ? BST_CHECKED: BST_UNCHECKED ); SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_SETRANGE, false, MAKELONG(0,_countof(g_ZoomLevels)-1) ); SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_SETPOS, true, g_SliderZoomLevel ); @@ -2078,21 +3823,27 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, _stprintf( text, L"%d", g_PenWidth ); SetDlgItemText( g_OptionsTabs[DRAW_PAGE].hPage, IDC_PEN_WIDTH, text ); SendMessage( GetDlgItem( g_OptionsTabs[DRAW_PAGE].hPage, IDC_PEN_WIDTH ), EM_LIMITTEXT, 1, 0 ); - SendMessage (GetDlgItem( g_OptionsTabs[DRAW_PAGE].hPage, IDC_SPIN), UDM_SETRANGE, 0L, + SendMessage (GetDlgItem( g_OptionsTabs[DRAW_PAGE].hPage, IDC_SPIN), UDM_SETRANGE, 0L, MAKELPARAM (19, 1)); _stprintf( text, L"%d", g_BreakTimeout ); SetDlgItemText( g_OptionsTabs[BREAK_PAGE].hPage, IDC_TIMER, text ); SendMessage( GetDlgItem( g_OptionsTabs[BREAK_PAGE].hPage, IDC_TIMER ), EM_LIMITTEXT, 2, 0 ); - SendMessage (GetDlgItem( g_OptionsTabs[BREAK_PAGE].hPage, IDC_SPIN_TIMER), UDM_SETRANGE, 0L, + SendMessage (GetDlgItem( g_OptionsTabs[BREAK_PAGE].hPage, IDC_SPIN_TIMER), UDM_SETRANGE, 0L, MAKELPARAM (99, 1)); CheckDlgButton( g_OptionsTabs[BREAK_PAGE].hPage, IDC_CHECK_SHOW_EXPIRED, g_ShowExpiredTime ? BST_CHECKED : BST_UNCHECKED ); - CheckDlgButton( g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO, + 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 ); - for (int i = 0; i < _countof(g_FramerateOptions); i++) { + // + // The framerate drop down list is not used in the current version (might be added in the future) + // + /*for (int i = 0; i < _countof(g_FramerateOptions); i++) { _stprintf(text, L"%d", g_FramerateOptions[i]); SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FRAME_RATE), static_cast<UINT>(CB_ADDSTRING), @@ -2101,13 +3852,34 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FRAME_RATE), CB_SETCURSEL, static_cast<WPARAM>(i), static_cast<LPARAM>(0)); } + }*/ + + // Add the recording format to the combo box and set the current selection + size_t selection = 0; + const wchar_t* currentFormatString = (g_RecordingFormat == RecordingFormat::GIF) ? L"GIF" : L"MP4"; + + for( size_t i = 0; i < (sizeof(g_RecordingFormats) / sizeof(g_RecordingFormats[0])); i++ ) + { + SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FORMAT ), static_cast<UINT>(CB_ADDSTRING), static_cast<WPARAM>(0), reinterpret_cast<LPARAM>(g_RecordingFormats[i]) ); + + if( selection == 0 && wcscmp( g_RecordingFormats[i], currentFormatString ) == 0 ) + { + selection = i; + } } + SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FORMAT ), CB_SETCURSEL, static_cast<WPARAM>(selection), static_cast<LPARAM>(0) ); + for(unsigned int i = 1; i < 11; i++) { _stprintf(text, L"%2.1f", (static_cast<double>(i)) / 10 ); SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_SCALING), static_cast<UINT>(CB_ADDSTRING), static_cast<WPARAM>(0), reinterpret_cast<LPARAM>(text)); - if (g_RecordScaling == i*10 ) { + + if (g_RecordingFormat == RecordingFormat::GIF && i*10 == g_RecordScalingGIF ) { + + SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_SCALING), CB_SETCURSEL, static_cast<WPARAM>(i)-1, static_cast<LPARAM>(0)); + } + if (g_RecordingFormat == RecordingFormat::MP4 && i*10 == g_RecordScalingMP4 ) { SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_SCALING), CB_SETCURSEL, static_cast<WPARAM>(i)-1, static_cast<LPARAM>(0)); } @@ -2125,7 +3897,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, // Add the microphone devices to the combo box and set the current selection SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE ), static_cast<UINT>(CB_ADDSTRING), static_cast<WPARAM>(0), reinterpret_cast<LPARAM>(L"Default")); - size_t selection = 0; + selection = 0; for( size_t i = 0; i < microphones.size(); i++ ) { SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE ), static_cast<UINT>(CB_ADDSTRING), static_cast<WPARAM>(0), reinterpret_cast<LPARAM>(microphones[i].second.c_str()) ); @@ -2136,6 +3908,13 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, } SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE ), CB_SETCURSEL, static_cast<WPARAM>(selection), static_cast<LPARAM>(0) ); + // 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_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 ) { memset( g_DemoTypeFile, 0, sizeof( g_DemoTypeFile ) ); @@ -2148,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 ); @@ -2160,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<HWND>(lParam) == GetDlgItem( hDlg, IDC_TITLE ) || - reinterpret_cast<HWND>(lParam) == GetDlgItem(hDlg, IDC_DRAWING) || - reinterpret_cast<HWND>(lParam) == GetDlgItem(hDlg, IDC_ZOOM) || - reinterpret_cast<HWND>(lParam) == GetDlgItem(hDlg, IDC_BREAK) || - reinterpret_cast<HWND>(lParam) == GetDlgItem( hDlg, IDC_TYPE )) { + RECT* suggested = reinterpret_cast<RECT*>(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<HDC>(wParam); - SetBkMode( hdc, TRANSPARENT ); - SelectObject( hdc, hFontBold ); - return PtrToLong(GetSysColorBrush( COLOR_BTNFACE )); + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast<HDC>(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<HDC>(wParam); + HWND hCtrl = reinterpret_cast<HWND>(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<INT_PTR>(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<INT_PTR>(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<INT_PTR>(GetSysColorBrush(COLOR_BTNFACE)); + } + break; + } + + case WM_SETTINGCHANGE: + // Handle theme change (dark/light mode toggle) + if (lParam && (wcscmp(reinterpret_cast<LPCWSTR>(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<DRAWITEMSTRUCT*>(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<PNMLINK>(lParam); if( notify->hdr.idFrom == IDC_LINK ) @@ -2210,6 +4235,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, } g_ShowTrayIcon = IsDlgButtonChecked( hDlg, IDC_SHOW_TRAY_ICON ) == BST_CHECKED; g_AnimateZoom = IsDlgButtonChecked( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM ) == BST_CHECKED; + g_SmoothImage = IsDlgButtonChecked( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_SMOOTH_IMAGE ) == BST_CHECKED; g_DemoTypeUserDriven = IsDlgButtonChecked( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_USER_DRIVEN ) == BST_CHECKED; newToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_GETHOTKEY, 0, 0 )); @@ -2232,12 +4258,14 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, g_DemoTypeSpeedSlider = static_cast<int>(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; newTimeout = _tstoi( text ); - g_RecordFrameRate = g_FramerateOptions[SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FRAME_RATE), static_cast<UINT>(CB_GETCURSEL), static_cast<WPARAM>(0), static_cast<LPARAM>(0))]; + g_RecordingFormat = static_cast<RecordingFormat>(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_FORMAT), static_cast<UINT>(CB_GETCURSEL), static_cast<WPARAM>(0), static_cast<LPARAM>(0))); + g_RecordFrameRate = (g_RecordingFormat == RecordingFormat::GIF) ? RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE : RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE; g_RecordScaling = static_cast<int>(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_SCALING), static_cast<UINT>(CB_GETCURSEL), static_cast<WPARAM>(0), static_cast<LPARAM>(0)) * 10 + 10); // Get the selected microphone @@ -2251,7 +4279,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, UnregisterAllHotkeys(GetParent( hDlg )); break; - } else if(newLiveZoomToggleKey && + } else if(newLiveZoomToggleKey && (!RegisterHotKey( GetParent( hDlg ), LIVE_HOTKEY, newLiveZoomToggleMod, newLiveZoomToggleKey & 0xFF ) || !RegisterHotKey(GetParent(hDlg), LIVE_DRAW_HOTKEY, (newLiveZoomToggleMod ^ MOD_SHIFT), newLiveZoomToggleKey & 0xFF))) { @@ -2274,7 +4302,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, UnregisterAllHotkeys(GetParent( hDlg )); break; - } else if( newDemoTypeToggleKey && + } else if( newDemoTypeToggleKey && (!RegisterHotKey( GetParent( hDlg ), DEMOTYPE_HOTKEY, newDemoTypeToggleMod, newDemoTypeToggleKey & 0xFF ) || !RegisterHotKey(GetParent(hDlg), DEMOTYPE_RESET_HOTKEY, (newDemoTypeToggleMod ^ MOD_SHIFT), newDemoTypeToggleKey & 0xFF))) { @@ -2284,7 +4312,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, break; } - else if (newSnipToggleKey && + else if (newSnipToggleKey && (!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF) || !RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, (newSnipToggleMod ^ MOD_SHIFT), newSnipToggleKey & 0xFF))) { @@ -2293,8 +4321,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, UnregisterAllHotkeys(GetParent(hDlg)); break; - } - else if( newRecordToggleKey && + } + else if( newRecordToggleKey && (!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) || !RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, (newRecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, newRecordToggleKey & 0xFF) || !RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, (newRecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, newRecordToggleKey & 0xFF))) { @@ -2305,7 +4333,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, break; } else { - + g_BreakTimeout = newTimeout; g_ToggleKey = newToggleKey; g_LiveZoomToggleKey = newLiveZoomToggleKey; @@ -2324,8 +4352,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, EnableDisableTrayIcon( GetParent( hDlg ), g_ShowTrayIcon ); hWndOptions = NULL; + CleanupFonts(); EndDialog( hDlg, 0 ); - return TRUE; + return TRUE; } break; } @@ -2333,14 +4362,16 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, case IDCANCEL: RegisterAllHotkeys(GetParent(hDlg)); hWndOptions = NULL; + CleanupFonts(); EndDialog( hDlg, 0 ); return TRUE; } - break; - + break; + case WM_CLOSE: hWndOptions = NULL; RegisterAllHotkeys(GetParent(hDlg)); + CleanupFonts(); EndDialog( hDlg, 0 ); return TRUE; @@ -2376,7 +4407,7 @@ void DeleteDrawUndoList( P_DRAW_UNDO *DrawUndoList ) // PopDrawUndo // //---------------------------------------------------------------------------- -BOOLEAN PopDrawUndo( HDC hDc, P_DRAW_UNDO *DrawUndoList, +BOOLEAN PopDrawUndo( HDC hDc, P_DRAW_UNDO *DrawUndoList, int width, int height ) { P_DRAW_UNDO nextUndo; @@ -2384,7 +4415,7 @@ BOOLEAN PopDrawUndo( HDC hDc, P_DRAW_UNDO *DrawUndoList, nextUndo = *DrawUndoList; if( nextUndo ) { - BitBlt( hDc, 0, 0, width, height, + BitBlt( hDc, 0, 0, width, height, nextUndo->hDc, 0, 0, SRCCOPY|CAPTUREBLT ); *DrawUndoList = nextUndo->Next; DeleteObject( nextUndo->hBitmap ); @@ -2432,7 +4463,7 @@ void DeleteOldestUndo( P_DRAW_UNDO *DrawUndoList ) //---------------------------------------------------------------------------- // // GetOldestUndo -// +// //---------------------------------------------------------------------------- P_DRAW_UNDO GetOldestUndo(P_DRAW_UNDO DrawUndoList) { @@ -2497,7 +4528,7 @@ void PushDrawUndo( HDC hDc, P_DRAW_UNDO *DrawUndoList, int width, int height ) newUndo->Next = *DrawUndoList; *DrawUndoList = newUndo; } - } + } } //---------------------------------------------------------------------------- @@ -2550,8 +4581,8 @@ void ClearTypingCursor( HDC hdcScreenCompat, HDC hdcScreenCursorCompat, RECT rc, } else { - BitBlt(hdcScreenCompat, rc.left, rc.top, rc.right - rc.left, - rc.bottom - rc.top, hdcScreenCursorCompat,0, 0, SRCCOPY|CAPTUREBLT ); + BitBlt(hdcScreenCompat, rc.left, rc.top, rc.right - rc.left, + rc.bottom - rc.top, hdcScreenCursorCompat,0, 0, SRCCOPY|CAPTUREBLT ); } } @@ -2606,7 +4637,7 @@ RECT BoundMouse( float zoomLevel, MONITORINFO *monInfo, int width, int height, int x, y; GetZoomedTopLeftCoordinates( zoomLevel, cursorPos, &x, width, &y, height ); - rc.left = monInfo->rcMonitor.left + x; + rc.left = monInfo->rcMonitor.left + x; rc.right = rc.left + static_cast<int>(width/zoomLevel); rc.top = monInfo->rcMonitor.top + y; rc.bottom = rc.top + static_cast<int>(height/zoomLevel); @@ -2617,7 +4648,7 @@ RECT BoundMouse( float zoomLevel, MONITORINFO *monInfo, int width, int height, rc.left, rc.top, rc.right, rc.bottom); OutputDebug( L"mon.left: %d mon.top: %d mon.right: %d mon.bottom: %d\n", monInfo->rcMonitor.left, monInfo->rcMonitor.top, monInfo->rcMonitor.right, monInfo->rcMonitor.bottom); - + ClipCursor( &rc ); return rc; } @@ -2645,7 +4676,7 @@ void DrawArrow( HDC hdc, int x1, int y1, int x2, int y2, double length, double w // get midpoint of base int xMid = x2 - static_cast<int>(length*dx+0.5); int yMid = y2 - static_cast<int>(length*dy+0.5); - + // get left wing int xLeft = xMid - static_cast<int>(dy*width+0.5); int yLeft = yMid + static_cast<int>(dx*width+0.5); @@ -2723,7 +4754,6 @@ VOID DrawShape( DWORD Shape, HDC hDc, RECT *Rect, bool UseGdiPlus = false ) bool isBlur = false; Gdiplus::Graphics dstGraphics(hDc); - if( ( GetWindowLong( g_hWndMain, GWL_EXSTYLE ) & WS_EX_LAYERED ) == 0 ) { dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); @@ -2746,12 +4776,13 @@ VOID DrawShape( DWORD Shape, HDC hDc, RECT *Rect, bool UseGdiPlus = false ) InflateRect(Rect, g_PenWidth / 2, g_PenWidth / 2); isBlur = true; } + OutputDebug(L"Draw shape: highlight: %d pbrush: %d\n", PEN_COLOR_HIGHLIGHT(g_PenColor), pBrush != NULL); switch (Shape) { case DRAW_RECTANGLE: if (UseGdiPlus) if(pBrush) - DrawHighlightedShape(DRAW_RECTANGLE, hDc, pBrush, NULL, + DrawHighlightedShape(DRAW_RECTANGLE, hDc, pBrush, NULL, static_cast<int>(Rect->left - 1), static_cast<int>(Rect->top - 1), static_cast<int>(Rect->right), static_cast<int>(Rect->bottom)); else if (isBlur) @@ -2828,7 +4859,7 @@ VOID SendPenMessage(HWND hWnd, UINT Message, LPARAM lParam) if(GetKeyState(VK_LCONTROL) < 0 ) { wParam |= MK_CONTROL; - } + } if( GetKeyState( VK_LSHIFT) < 0 || GetKeyState( VK_RSHIFT) < 0 ) { wParam |= MK_SHIFT; @@ -2841,10 +4872,10 @@ VOID SendPenMessage(HWND hWnd, UINT Message, LPARAM lParam) //---------------------------------------------------------------------------- // // ScalePenPosition -// +// // Maps pen input to mouse input coordinates based on zoom level. Returns // 0 if pen is active but we didn't send this message to ourselves (pen -// signature will be missing). +// signature will be missing). // //---------------------------------------------------------------------------- LPARAM ScalePenPosition( float zoomLevel, MONITORINFO *monInfo, RECT boundRc, @@ -2855,7 +4886,7 @@ LPARAM ScalePenPosition( float zoomLevel, MONITORINFO *monInfo, RECT boundRc, LPARAM extraInfo; extraInfo = GetMessageExtraInfo(); - if( g_PenDown ) { + if( g_PenDown ) { // ignore messages we didn't tag as pen if (extraInfo == MI_WP_SIGNATURE) { @@ -2878,7 +4909,7 @@ LPARAM ScalePenPosition( float zoomLevel, MONITORINFO *monInfo, RECT boundRc, OutputDebug(L"Ignore pen message we didn't send\n"); lParam = 0; } - + } else { if( !GetClipCursor( &rc )) { @@ -2888,7 +4919,7 @@ LPARAM ScalePenPosition( float zoomLevel, MONITORINFO *monInfo, RECT boundRc, OutputDebug( L"Mouse message\n"); } return lParam; -} +} //---------------------------------------------------------------------------- @@ -2915,15 +4946,15 @@ BOOLEAN DrawHighlightedCursor( float ZoomLevel, int Width, int Height ) // InvalidateCursorMoveArea // //---------------------------------------------------------------------------- -void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height, +void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height, POINT currentPt, POINT prevPt, POINT cursorPos ) { int x, y; RECT rc; - int invWidth = g_PenWidth; + int invWidth = g_PenWidth + CURSOR_SAVE_MARGIN; if( DrawHighlightedCursor( zoomLevel, width, height ) ) { - + invWidth = g_PenWidth * 3 + 1; } GetZoomedTopLeftCoordinates( zoomLevel, &cursorPos, &x, width, &y, height ); @@ -2945,7 +4976,7 @@ void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height void SaveCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt ) { OutputDebug( L"SaveCursorArea\n"); - int penWidth = g_PenWidth + 2; + int penWidth = g_PenWidth + CURSOR_SAVE_MARGIN; BitBlt( hDcTarget, 0, 0, penWidth +CURSOR_ARM_LENGTH*2, penWidth +CURSOR_ARM_LENGTH*2, hDcSource, static_cast<INT> (pt.x- penWidth /2)-CURSOR_ARM_LENGTH, static_cast<INT>(pt.y- penWidth /2)-CURSOR_ARM_LENGTH, SRCCOPY|CAPTUREBLT ); @@ -2959,7 +4990,7 @@ void SaveCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt ) void RestoreCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt ) { OutputDebug( L"RestoreCursorArea\n"); - int penWidth = g_PenWidth + 2; + int penWidth = g_PenWidth + CURSOR_SAVE_MARGIN; BitBlt( hDcTarget, static_cast<INT>(pt.x- penWidth /2)-CURSOR_ARM_LENGTH, static_cast<INT>(pt.y- penWidth /2)-CURSOR_ARM_LENGTH, penWidth +CURSOR_ARM_LENGTH*2, penWidth + CURSOR_ARM_LENGTH*2, hDcSource, 0, 0, SRCCOPY|CAPTUREBLT ); @@ -3046,7 +5077,7 @@ void DrawCursor( HDC hDcTarget, POINT pt, float ZoomLevel, int Width, int Height // //---------------------------------------------------------------------------- void ResizePen( HWND hWnd, HDC hdcScreenCompat, HDC hdcScreenCursorCompat, POINT prevPt, - BOOLEAN g_Tracing, BOOLEAN *g_Drawing, float g_LiveZoomLevel, + BOOLEAN g_Tracing, BOOLEAN *g_Drawing, float g_LiveZoomLevel, BOOLEAN isUser, int newWidth ) { if( !g_Tracing ) { @@ -3105,17 +5136,17 @@ bool IsPenInverted( WPARAM wParam ) //---------------------------------------------------------------------------- // // CaptureScreenshotAsync -// +// // Captures the specified screen using the capture APIs // //---------------------------------------------------------------------------- -std::future<winrt::com_ptr<ID3D11Texture2D>> CaptureScreenshotAsync(winrt::IDirect3DDevice const& device, winrt::GraphicsCaptureItem const& item, winrt::DirectXPixelFormat const& pixelFormat) +wil::task<winrt::com_ptr<ID3D11Texture2D>> CaptureScreenshotAsync(winrt::IDirect3DDevice const& device, winrt::GraphicsCaptureItem const& item, winrt::DirectXPixelFormat const& pixelFormat) { auto d3dDevice = GetDXGIInterfaceFromObject<ID3D11Device>(device); winrt::com_ptr<ID3D11DeviceContext> d3dContext; d3dDevice->GetImmediateContext(d3dContext.put()); - // Creating our frame pool with CreateFreeThreaded means that we + // Creating our frame pool with CreateFreeThreaded means that we // will be called back from the frame pool's internal worker thread // instead of the thread we are currently on. It also disables the // DispatcherQueue requirement. @@ -3145,15 +5176,13 @@ std::future<winrt::com_ptr<ID3D11Texture2D>> CaptureScreenshotAsync(winrt::IDire framePool.Close(); auto texture = GetDXGIInterfaceFromObject<ID3D11Texture2D>(frame.Surface()); - auto result = util::CopyD3DTexture(d3dDevice, texture, true); - - co_return result; + co_return util::CopyD3DTexture(d3dDevice, texture, true); } //---------------------------------------------------------------------------- // // CaptureScreenshot -// +// // Captures the specified screen using the capture APIs // //---------------------------------------------------------------------------- @@ -3174,19 +5203,16 @@ winrt::com_ptr<ID3D11Texture2D>CaptureScreenshot(winrt::DirectXPixelFormat const auto item = util::CreateCaptureItemForMonitor(hMon); - auto capture = CaptureScreenshotAsync(device, item, pixelFormat); - capture.wait(); - - return capture.get(); + return CaptureScreenshotAsync(device, item, pixelFormat).get(); } //---------------------------------------------------------------------------- // // CopyD3DTexture -// +// //---------------------------------------------------------------------------- -inline auto CopyD3DTexture(winrt::com_ptr<ID3D11Device> const& device, +inline auto CopyD3DTexture(winrt::com_ptr<ID3D11Device> const& device, winrt::com_ptr<ID3D11Texture2D> const& texture, bool asStagingTexture) { winrt::com_ptr<ID3D11DeviceContext> context; @@ -3212,13 +5238,12 @@ inline auto CopyD3DTexture(winrt::com_ptr<ID3D11Device> const& device, //---------------------------------------------------------------------------- // // PrepareStagingTexture -// +// //---------------------------------------------------------------------------- -inline auto PrepareStagingTexture(winrt::com_ptr<ID3D11Device> const& device, +inline auto PrepareStagingTexture(winrt::com_ptr<ID3D11Device> const& device, winrt::com_ptr<ID3D11Texture2D> 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) @@ -3232,7 +5257,7 @@ inline auto PrepareStagingTexture(winrt::com_ptr<ID3D11Device> const& device, //---------------------------------------------------------------------------- // // GetBytesPerPixel -// +// //---------------------------------------------------------------------------- inline size_t GetBytesPerPixel(DXGI_FORMAT pixelFormat) @@ -3330,7 +5355,7 @@ GetBytesPerPixel(DXGI_FORMAT pixelFormat) //---------------------------------------------------------------------------- // // CopyBytesFromTexture -// +// //---------------------------------------------------------------------------- inline auto CopyBytesFromTexture(winrt::com_ptr<ID3D11Texture2D> const& texture, uint32_t subresource = 0) { @@ -3373,14 +5398,24 @@ inline auto CopyBytesFromTexture(winrt::com_ptr<ID3D11Texture2D> 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(); + // NOTE: Do NOT null the session here - let the coroutine finish first } g_RecordToggle = FALSE; @@ -3400,6 +5435,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 @@ -3412,34 +5496,37 @@ void StopRecording() //---------------------------------------------------------------------------- auto GetUniqueRecordingFilename() { - std::filesystem::path path{ g_RecordingSaveLocation }; + const wchar_t* defaultFile = (g_RecordingFormat == RecordingFormat::GIF) + ? DEFAULT_GIF_RECORDING_FILE + : DEFAULT_RECORDING_FILE; - // 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() ); + return GetUniqueFilename(g_RecordingSaveLocation, defaultFile, FOLDERID_Videos); +} - 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); } //---------------------------------------------------------------------------- // // StartRecordingAsync -// +// // Starts the screen recording. // //---------------------------------------------------------------------------- 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 ); - auto file = co_await appFolder.CreateFileAsync( L"zoomit.mp4", winrt::CreationCollisionOption::ReplaceExisting ); + + // Choose temp file extension based on format + const wchar_t* tempFileName = (g_RecordingFormat == RecordingFormat::GIF) ? L"zoomit.gif" : L"zoomit.mp4"; + auto file = co_await appFolder.CreateFileAsync( tempFileName, winrt::CreationCollisionOption::ReplaceExisting ); // Get the device auto d3dDevice = util::CreateD3D11Device(); @@ -3448,7 +5535,7 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR // Get the active MONITOR capture device HMONITOR hMon = NULL; - POINT cursorPos = { 0, 0 }; + POINT cursorPos = { 0, 0 }; if( pMonitorFromPoint ) { GetCursorPos( &cursorPos ); @@ -3456,27 +5543,122 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR } winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{ nullptr }; - if( hWndRecord ) + if( hWndRecord ) item = util::CreateCaptureItemForWindow( hWndRecord ); else item = util::CreateCaptureItemForMonitor( hMon ); auto stream = co_await file.OpenAsync( winrt::FileAccessMode::ReadWrite ); - g_RecordingSession = VideoRecordingSession::Create( - g_RecordDevice, - item, - *rcCrop, - g_RecordFrameRate, - g_CaptureAudio, - stream ); - if( g_hWndLiveZoom != NULL ) - g_RecordingSession->EnableCursorCapture( false ); + // 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; - co_await g_RecordingSession->StartAsync(); + if (g_RecordingFormat == RecordingFormat::GIF) + { + g_GifRecordingSession = GifRecordingSession::Create( + g_RecordDevice, + item, + *rcCrop, + g_RecordFrameRate, + stream ); - // g_RecordingSession isn't null if we're aborting a recording - if( g_RecordingSession == nullptr ) { + recordingStarted = (g_GifRecordingSession != nullptr); + + if( g_hWndLiveZoom != NULL ) + g_GifRecordingSession->EnableCursorCapture( false ); + + 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 + { + g_RecordingSession = VideoRecordingSession::Create( + g_RecordDevice, + item, + *rcCrop, + g_RecordFrameRate, + g_CaptureAudio, + g_CaptureSystemAudio, + stream ); + + recordingStarted = (g_RecordingSession != nullptr); + + if( g_hWndLiveZoom != NULL ) + g_RecordingSession->EnableCursorCapture( false ); + + 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; + } + } + + // 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; @@ -3485,53 +5667,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<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() ); - saveDialog->SetDefaultExtension( L".mp4" ); - COMDLG_FILTERSPEC fileTypes[] = { - { L"MP4 Video", L"*.mp4" } - }; - saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); - - if( g_RecordingSaveLocation.size() == 0) { - - wil::com_ptr<IShellItem> shellItem; - wil::unique_cotaskmem_string folderPath; - if (SUCCEEDED(saveDialog->GetFolder(shellItem.put())) && SUCCEEDED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, folderPath.put()))) - g_RecordingSaveLocation = folderPath.get(); - g_RecordingSaveLocation = std::filesystem::path{ g_RecordingSaveLocation } /= DEFAULT_RECORDING_FILE; - } + // Show trim dialog option and save dialog + std::wstring trimmedFilePath; auto suggestedName = GetUniqueRecordingFilename(); - saveDialog->SetFileName( suggestedName.c_str() ); + auto finalPath = VideoRecordingSession::ShowSaveDialogWithTrim( + hWnd, + suggestedName, + std::wstring{ file.Path() }, + trimmedFilePath + ); - THROW_IF_FAILED( saveDialog->Show( hWnd ) ); - wil::com_ptr<IShellItem> 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() ); + if (!finalPath.empty()) + { + 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); - 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 + { + // User cancelled + hr = HRESULT_FROM_WIN32(ERROR_CANCELLED); + } + + //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() ); + + //// 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 ); + //} + + //// Peek the folder Windows has chosen to display + //static std::filesystem::path lastSaveFolder; + //wil::unique_cotaskmem_string chosenFolderPath; + //wil::com_ptr<IShellItem> 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; + // } + // } + //} + + //if( (g_RecordingFormat == RecordingFormat::GIF && g_RecordingSaveLocationGIF.size() == 0) || (g_RecordingFormat == RecordingFormat::MP4 && g_RecordingSaveLocation.size() == 0) || (bFolderChanged)) { + + // wil::com_ptr<IShellItem> 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; + // } + // } + // } + //} + + //// 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<IShellItem> 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 ) { - co_await file.DeleteAsync(); - } - else { - - co_await file.MoveAndReplaceAsync( destFile ); - g_RecordingSaveLocation = file.Path(); - SaveToClipboard(g_RecordingSaveLocation.c_str(), hWnd); + if (stream) { + stream.Close(); + stream = nullptr; + } + try { co_await file.DeleteAsync(); } catch (...) {} } g_bSaveInProgress = false; @@ -3542,13 +5820,19 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR if( FAILED( hr ) ) throw winrt::hresult_error( hr ); } - else { - co_await file.DeleteAsync(); - g_RecordingSession = 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 @@ -3661,9 +5945,9 @@ void ShowMainWindow(HWND hWnd, const MONITORINFO& monInfo, int width, int height // //---------------------------------------------------------------------------- LRESULT APIENTRY MainWndProc( - HWND hWnd, + HWND hWnd, UINT message, - WPARAM wParam, + WPARAM wParam, LPARAM lParam) { static int width, height; @@ -3722,7 +6006,7 @@ LRESULT APIENTRY MainWndProc( #endif bool isCaptureSupported = false; RECT rc, rc1; - PAINTSTRUCT ps; + PAINTSTRUCT ps; TCHAR timerText[16]; TCHAR negativeTimerText[16]; BOOLEAN penInverted; @@ -3731,9 +6015,9 @@ 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; const auto drawAllRightJustifiedLines = [&rc]( long lineHeight, bool doPop = false ) { rc.top = textPt.y - static_cast<LONG>(g_TextBufferPreviousLines.size()) * lineHeight; @@ -3760,19 +6044,133 @@ LRESULT APIENTRY MainWndProc( } }; + const auto doTelescopingZoomTimer = [hWnd, wParam, lParam, &x, &y]( bool invalidate = true ) { + if( zoomTelescopeStep != 0.0f ) + { + zoomLevel *= zoomTelescopeStep; + g_TelescopingZoomLastTick = GetTickCount64(); + if( (zoomTelescopeStep > 1 && zoomLevel >= zoomTelescopeTarget) || + (zoomTelescopeStep < 1 && zoomLevel <= zoomTelescopeTarget) ) + { + zoomLevel = zoomTelescopeTarget; + + g_TelescopingZoomLastTick = 0ull; + KillTimer( hWnd, wParam ); + OutputDebug( L"SETCURSOR mon_left: %x mon_top: %x x: %d y: %d\n", + monInfo.rcMonitor.left, + monInfo.rcMonitor.top, + cursorPos.x, + cursorPos.y ); + SetCursorPos( monInfo.rcMonitor.left + cursorPos.x, + monInfo.rcMonitor.top + cursorPos.y ); + } + } + else + { + // Case where we didn't zoom at all + g_TelescopingZoomLastTick = 0ull; + KillTimer( hWnd, wParam ); + } + 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 ); + cursorPos = ScalePointInRects( cursorPos, monInfo.rcMonitor, g_LiveZoomSourceRect ); + SetCursorPos( cursorPos.x, cursorPos.y ); + SendMessage( hWnd, WM_HOTKEY, LIVE_HOTKEY, 0 ); + } + else if( lParam != SHALLOW_ZOOM ) + { + // Figure out where final unzoomed cursor should be + if( g_Drawing ) + { + cursorPos = prevPt; + } + OutputDebug( L"FINAL MOUSE: x: %d y: %d\n", cursorPos.x, cursorPos.y ); + GetZoomedTopLeftCoordinates( zoomLevel, &cursorPos, &x, width, &y, height ); + cursorPos.x = monInfo.rcMonitor.left + x + static_cast<int>((cursorPos.x - x) * zoomLevel); + cursorPos.y = monInfo.rcMonitor.top + y + static_cast<int>((cursorPos.y - y) * zoomLevel); + SetCursorPos( cursorPos.x, cursorPos.y ); + } + if( hTargetWindow ) + { + SetWindowPos( hTargetWindow, HWND_BOTTOM, rcTargetWindow.left, rcTargetWindow.top, rcTargetWindow.right - rcTargetWindow.left, rcTargetWindow.bottom - rcTargetWindow.top, 0 ); + hTargetWindow = NULL; + } + DeleteDrawUndoList( &drawUndoList ); + + // Restore live zoom if we came from that mode + if( g_ZoomOnLiveZoom ) + { + SendMessage( g_hWndLiveZoom, WM_USER_SET_ZOOM, static_cast<WPARAM>(g_LiveZoomLevel), reinterpret_cast<LPARAM>(&g_LiveZoomSourceRect) ); + g_ZoomOnLiveZoom = FALSE; + forcePenResize = TRUE; + } + + SetForegroundWindow( g_ActiveWindow ); + ClipCursor( NULL ); + g_HaveDrawn = FALSE; + g_TypeMode = TypeModeOff; + g_HaveTyped = FALSE; + g_Drawing = FALSE; + EnableDisableStickyKeys( TRUE ); + DeleteObject( hTypingFont ); + DeleteDC( hdcScreen ); + DeleteDC( hdcScreenCompat ); + DeleteDC( hdcScreenCursorCompat ); + DeleteDC( hdcScreenSaveCompat ); + DeleteObject( hbmpCompat ); + DeleteObject (hbmpCursorCompat ); + DeleteObject( hbmpDrawingCompat ); + DeleteObject( hDrawingPen ); + + SetFocus( g_ActiveWindow ); + ShowWindow( hWnd, SW_HIDE ); + } + if( invalidate ) + { + InvalidateRect( hWnd, NULL, FALSE ); + } + }; + switch (message) { case WM_CREATE: // get default font - GetObject( GetStockObject(DEFAULT_GUI_FONT), sizeof g_LogFont, &g_LogFont ); + GetObject( GetStockObject(DEFAULT_GUI_FONT), sizeof g_LogFont, &g_LogFont ); g_LogFont.lfWeight = FW_NORMAL; hDc = CreateCompatibleDC( NULL ); g_LogFont.lfHeight = -MulDiv(8, GetDeviceCaps(hDc, LOGPIXELSY), 72); DeleteDC( hDc ); reg.ReadRegSettings( RegSettings ); - - // to support migrating from + + // 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; + g_RecordFrameRate = RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE; + } else { + g_RecordScaling = g_RecordScalingMP4; + g_RecordFrameRate = RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE; + } + + // to support migrating from if ((g_PenColor >> 24) == 0) { g_PenColor |= 0xFF << 24; } @@ -3802,7 +6200,7 @@ LRESULT APIENTRY MainWndProc( APPNAME, MB_ICONERROR ); showOptions = TRUE; - } else if( g_LiveZoomToggleKey && + } else if( g_LiveZoomToggleKey && (!RegisterHotKey( hWnd, LIVE_HOTKEY, g_LiveZoomToggleMod, g_LiveZoomToggleKey & 0xFF) || !RegisterHotKey(hWnd, LIVE_DRAW_HOTKEY, (g_LiveZoomToggleMod ^ MOD_SHIFT), g_LiveZoomToggleKey & 0xFF))) { @@ -3824,7 +6222,7 @@ LRESULT APIENTRY MainWndProc( showOptions = TRUE; } - else if( g_DemoTypeToggleKey && + else if( g_DemoTypeToggleKey && (!RegisterHotKey( hWnd, DEMOTYPE_HOTKEY, g_DemoTypeToggleMod, g_DemoTypeToggleKey & 0xFF ) || !RegisterHotKey(hWnd, DEMOTYPE_RESET_HOTKEY, (g_DemoTypeToggleMod ^ MOD_SHIFT), g_DemoTypeToggleKey & 0xFF))) { @@ -3833,7 +6231,7 @@ LRESULT APIENTRY MainWndProc( showOptions = TRUE; } - else if (g_SnipToggleKey && + else if (g_SnipToggleKey && (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) || !RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))) { @@ -3842,7 +6240,7 @@ LRESULT APIENTRY MainWndProc( showOptions = TRUE; } - else if (g_RecordToggleKey && + else if (g_RecordToggleKey && (!RegisterHotKey(hWnd, RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) || !RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, (g_RecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) || !RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, (g_RecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF))) { @@ -3965,12 +6363,16 @@ LRESULT APIENTRY MainWndProc( // Highlight is not supported in LiveDraw g_PenColor |= 0xFF << 24; } - } + } break; 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 ) ) @@ -4016,6 +6418,7 @@ LRESULT APIENTRY MainWndProc( // Now copy crop or copy+save if( LOWORD( wParam ) == SNIP_SAVE_HOTKEY ) { + // IDC_SAVE_CROP handles cursor hiding internally after region selection SendMessage( hWnd, WM_COMMAND, IDC_SAVE_CROP, ( zoomed ? 0 : SHALLOW_ZOOM ) ); } else @@ -4048,16 +6451,26 @@ LRESULT APIENTRY MainWndProc( OutputDebug( L"Exiting liveDraw after snip\n" ); SendMessage( hWnd, WM_KEYDOWN, VK_ESCAPE, 0 ); } - else - { - // Set wparam to 1 to exit without animation - OutputDebug(L"Exiting zoom after snip\n" ); - SendMessage( hWnd, WM_HOTKEY, ZOOM_HOTKEY, SHALLOW_DESTROY ); - } } 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 @@ -4139,7 +6552,7 @@ LRESULT APIENTRY MainWndProc( if( g_hWndLiveZoom == NULL ) { OutputDebug(L"Create LIVEZOOM\n"); g_hWndLiveZoom = CreateWindowEx( WS_EX_TOOLWINDOW | WS_EX_LAYERED | WS_EX_TRANSPARENT, - L"MagnifierClass", L"ZoomIt Live Zoom", + L"MagnifierClass", L"ZoomIt Live Zoom", WS_POPUP | WS_CLIPSIBLINGS, 0, 0, 0, 0, NULL, NULL, g_hInstance, static_cast<PVOID>(GetForegroundWindow()) ); pSetLayeredWindowAttributes( hWnd, 0, 0, LWA_ALPHA ); @@ -4162,10 +6575,10 @@ LRESULT APIENTRY MainWndProc( g_LiveZoomLevel = g_ZoomLevels[g_SliderZoomLevel]; #endif // Unzoom - SendMessage( g_hWndLiveZoom, WM_KEYDOWN, VK_ESCAPE, 0 ); + SendMessage( g_hWndLiveZoom, WM_KEYDOWN, VK_ESCAPE, 0 ); } else { - + OutputDebug(L"Show liveZoom\n"); ShowWindow( g_hWndLiveZoom, SW_SHOW ); } @@ -4173,6 +6586,11 @@ LRESULT APIENTRY MainWndProc( } #endif } + OutputDebug(L"LIVEDRAW SMOOTHING: %d\n", g_SmoothImage); + if (!pMagSetLensUseBitmapSmoothing(g_hWndLiveZoomMag, g_SmoothImage)) + { + OutputDebug(L"MagSetLensUseBitmapSmoothing failed: %d\n", GetLastError()); + } if ( g_RecordToggle ) { @@ -4184,6 +6602,8 @@ LRESULT APIENTRY MainWndProc( case RECORD_HOTKEY: case RECORD_CROP_HOTKEY: case RECORD_WINDOW_HOTKEY: + case RECORD_GIF_HOTKEY: + case RECORD_GIF_WINDOW_HOTKEY: // // Recording @@ -4199,7 +6619,13 @@ LRESULT APIENTRY MainWndProc( if( g_RecordCropping == TRUE ) { break; - } + } + + // Ignore recording hotkey when save dialog is open + if( g_bSaveInProgress ) + { + break; + } // Start screen recording try @@ -4221,7 +6647,7 @@ LRESULT APIENTRY MainWndProc( { // Already recording break; - } + } g_RecordCropping = TRUE; @@ -4312,8 +6738,8 @@ LRESULT APIENTRY MainWndProc( { cropRc = {}; - // if we're recording a window, get the window - if (wParam == RECORD_WINDOW_HOTKEY) + // if we're recording a window, get the window + if (wParam == RECORD_WINDOW_HOTKEY || wParam == RECORD_GIF_WINDOW_HOTKEY) { GetCursorPos(&cursorPos); hWndRecord = WindowFromPoint(cursorPos); @@ -4331,6 +6757,7 @@ LRESULT APIENTRY MainWndProc( if( g_RecordToggle == FALSE ) { g_RecordToggle = TRUE; + #ifdef __ZOOMIT_POWERTOYS__ if( g_StartedByPowerToys ) { @@ -4357,7 +6784,7 @@ LRESULT APIENTRY MainWndProc( break; } - + OutputDebug( L"ZOOM HOTKEY: %d\n", lParam); if( g_TimerActive ) { @@ -4403,6 +6830,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 ) { @@ -4422,9 +6855,9 @@ LRESULT APIENTRY MainWndProc( // Get screen DCs hdcScreen = CreateDC(L"DISPLAY", static_cast<PTCHAR>(NULL), static_cast<PTCHAR>(NULL), static_cast<CONST DEVMODE *>(NULL)); - hdcScreenCompat = CreateCompatibleDC(hdcScreen); - hdcScreenSaveCompat = CreateCompatibleDC(hdcScreen); - hdcScreenCursorCompat = CreateCompatibleDC(hdcScreen); + hdcScreenCompat = CreateCompatibleDC(hdcScreen); + hdcScreenSaveCompat = CreateCompatibleDC(hdcScreen); + hdcScreenCursorCompat = CreateCompatibleDC(hdcScreen); // Determine what monitor we're on GetCursorPos(&cursorPos); @@ -4439,13 +6872,13 @@ LRESULT APIENTRY MainWndProc( bmp.bmPlanes = static_cast<BYTE>(GetDeviceCaps(hdcScreen, PLANES)); bmp.bmWidth = width; bmp.bmHeight = height; - bmp.bmWidthBytes = ((bmp.bmWidth + 15) &~15)/8; - hbmpCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, + bmp.bmWidthBytes = ((bmp.bmWidth + 15) &~15)/8; + hbmpCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, bmp.bmPlanes, bmp.bmBitsPixel, static_cast<CONST VOID *>(NULL)); - SelectObject(hdcScreenCompat, hbmpCompat); + SelectObject(hdcScreenCompat, hbmpCompat); // Create saved bitmap - hbmpDrawingCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, + hbmpDrawingCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, bmp.bmPlanes, bmp.bmBitsPixel, static_cast<CONST VOID *>(NULL)); SelectObject(hdcScreenSaveCompat, hbmpDrawingCompat); @@ -4520,7 +6953,7 @@ LRESULT APIENTRY MainWndProc( g_TypeMode = TypeModeOff; g_HaveDrawn = FALSE; EnableDisableStickyKeys( TRUE ); - + // Go full screen g_ActiveWindow = GetForegroundWindow(); OutputDebug( L"active window: %x\n", PtrToLong(g_ActiveWindow) ); @@ -4530,7 +6963,7 @@ LRESULT APIENTRY MainWndProc( OutputDebug(L"Calling ShowMainWindow\n"); ShowMainWindow(hWnd, monInfo, width, height); } - + // Start telescoping zoom. Lparam is non-zero if this // was a real hotkey and not the message we send ourself to enter // unzoomed drawing mode. @@ -4548,7 +6981,7 @@ LRESULT APIENTRY MainWndProc( OutputDebug(L"Enter liveZoom draw\n"); g_LiveZoomSourceRect = *reinterpret_cast<RECT *>(SendMessage( g_hWndLiveZoom, WM_USER_GET_SOURCE_RECT, 0, 0 )); g_LiveZoomLevel = *reinterpret_cast<float*>(SendMessage(g_hWndLiveZoom, WM_USER_GET_ZOOM_LEVEL, 0, 0)); - + // Set live zoom level to 1 in preparation of us being full screen static zoomLevel = 1.0; zoomTelescopeTarget = 1.0; @@ -4591,8 +7024,11 @@ LRESULT APIENTRY MainWndProc( zoomTelescopeStep = ZOOM_LEVEL_STEP_IN; zoomTelescopeTarget = g_ZoomLevels[g_SliderZoomLevel]; - if( g_AnimateZoom ) - zoomLevel = static_cast<float>(1.0) * zoomTelescopeStep; + if( g_AnimateZoom ) + { + zoomLevel = static_cast<float>(1.0) * zoomTelescopeStep; + g_TelescopingZoomLastTick = GetTickCount64(); + } else zoomLevel = zoomTelescopeTarget; SetTimer( hWnd, 1, ZOOM_LEVEL_STEP_TIME, NULL ); @@ -4607,9 +7043,10 @@ LRESULT APIENTRY MainWndProc( if( lParam != SHALLOW_DESTROY && !g_ZoomOnLiveZoom && g_AnimateZoom && g_TelescopeZoomOut && zoomTelescopeTarget != 1 ) { - // Start telescoping zoom. + // Start telescoping zoom. zoomTelescopeStep = ZOOM_LEVEL_STEP_OUT; zoomTelescopeTarget = 1.0; + g_TelescopingZoomLastTick = GetTickCount64(); SetTimer( hWnd, 2, ZOOM_LEVEL_STEP_TIME, NULL ); } else { @@ -4660,7 +7097,7 @@ LRESULT APIENTRY MainWndProc( } break; - case WM_POINTERDOWN: + case WM_POINTERDOWN: OutputDebug(L"WM_POINTERDOWN\n"); penInverted = IsPenInverted(wParam); if (!penInverted) { @@ -4697,15 +7134,15 @@ LRESULT APIENTRY MainWndProc( // // Zoom or modify break timer // - if( GET_WHEEL_DELTA_WPARAM(wParam) < 0 ) + if( GET_WHEEL_DELTA_WPARAM(wParam) < 0 ) wParam -= (WHEEL_DELTA-1) << 16; - else + else wParam += (WHEEL_DELTA-1) << 16; delta = GET_WHEEL_DELTA_WPARAM(wParam)/WHEEL_DELTA; - OutputDebug( L"mousewheel: wParam: %d delta: %d\n", + OutputDebug( L"mousewheel: wParam: %d delta: %d\n", GET_WHEEL_DELTA_WPARAM(wParam), delta ); if( g_Zoomed ) { - + if( g_TypeMode == TypeModeOff ) { if( g_Drawing && (LOWORD( wParam ) & MK_CONTROL) ) { @@ -4724,7 +7161,7 @@ LRESULT APIENTRY MainWndProc( while( delta-- ) { if( zoomIn ) { - + if( zoomTelescopeTarget < ZOOM_LEVEL_MAX ) { if( zoomTelescopeTarget < 2 ) { @@ -4732,17 +7169,17 @@ LRESULT APIENTRY MainWndProc( zoomTelescopeTarget = 2; } else { - + // Start telescoping zoom - zoomTelescopeTarget = zoomTelescopeTarget * 2; + zoomTelescopeTarget = zoomTelescopeTarget * 2; } - zoomTelescopeStep = ZOOM_LEVEL_STEP_IN; - if( g_AnimateZoom ) - zoomLevel *= zoomTelescopeStep; + zoomTelescopeStep = ZOOM_LEVEL_STEP_IN; + if( g_AnimateZoom ) + zoomLevel *= zoomTelescopeStep; else zoomLevel = zoomTelescopeTarget; - if( zoomLevel > zoomTelescopeTarget ) + if( zoomLevel > zoomTelescopeTarget ) zoomLevel = zoomTelescopeTarget; else SetTimer( hWnd, 1, ZOOM_LEVEL_STEP_TIME, NULL ); @@ -4753,17 +7190,17 @@ LRESULT APIENTRY MainWndProc( // Let them more gradually zoom out from 2x to 1x if( zoomTelescopeTarget <= 2 ) { - zoomTelescopeTarget *= .75; - if( zoomTelescopeTarget < ZOOM_LEVEL_MIN ) + zoomTelescopeTarget *= .75; + if( zoomTelescopeTarget < ZOOM_LEVEL_MIN ) zoomTelescopeTarget = ZOOM_LEVEL_MIN; } else { - zoomTelescopeTarget = zoomTelescopeTarget/2; + zoomTelescopeTarget = zoomTelescopeTarget/2; } - zoomTelescopeStep = ZOOM_LEVEL_STEP_OUT; - if( g_AnimateZoom ) - zoomLevel *= zoomTelescopeStep; + zoomTelescopeStep = ZOOM_LEVEL_STEP_OUT; + if( g_AnimateZoom ) + zoomLevel *= zoomTelescopeStep; else zoomLevel = zoomTelescopeTarget; @@ -4787,11 +7224,11 @@ LRESULT APIENTRY MainWndProc( RestoreCursorArea( hdcScreenCompat, hdcScreenCursorCompat, prevPt ); } - //SetCursorPos( monInfo.rcMonitor.left + cursorPos.x, + //SetCursorPos( monInfo.rcMonitor.left + cursorPos.x, // monInfo.rcMonitor.top + cursorPos.y ); } InvalidateRect( hWnd, NULL, FALSE ); - } + } } } else { @@ -4805,7 +7242,7 @@ LRESULT APIENTRY MainWndProc( // Set lParam to 0 as part of message to keyup hander DeleteObject(hTypingFont); g_LogFont.lfHeight = max((int)(height / zoomLevel) / g_FontScale, 12); - if (g_LogFont.lfHeight < 20) + if (g_LogFont.lfHeight < 20) g_LogFont.lfQuality = NONANTIALIASED_QUALITY; else g_LogFont.lfQuality = ANTIALIASED_QUALITY; @@ -4912,7 +7349,7 @@ LRESULT APIENTRY MainWndProc( OutputDebug(L"Entering typing mode and resetting cursor position\n"); SendMessage( hWnd, WM_LBUTTONDOWN, 0, MAKELPARAM( cursorPos.x, cursorPos.y)); - } + } // Do they want to right-justify text? OutputDebug(L"Keyup Shift: %x\n", GetAsyncKeyState(VK_SHIFT)); @@ -4942,13 +7379,13 @@ LRESULT APIENTRY MainWndProc( g_LogFont.lfQuality = ANTIALIASED_QUALITY; hTypingFont = CreateFontIndirect( &g_LogFont ); SelectObject( hdcScreenCompat, hTypingFont ); - + // If lparam == 0 that means that we sent the message as part of a font resize if( g_Drawing && lParam != 0) { RestoreCursorArea( hdcScreenCompat, hdcScreenCursorCompat, prevPt ); PushDrawUndo( hdcScreenCompat, &drawUndoList, width, height ); - + } else if( !g_Drawing ) { textPt = cursorPos; @@ -4963,7 +7400,7 @@ LRESULT APIENTRY MainWndProc( case WM_KEYDOWN: if( (g_TypeMode != TypeModeOff) && g_HaveTyped && static_cast<char>(wParam) != VK_UP && static_cast<char>(wParam) != VK_DOWN && - (isprint( static_cast<char>(wParam)) || + (isprint( static_cast<char>(wParam)) || wParam == VK_RETURN || wParam == VK_DELETE || wParam == VK_BACK )) { if( wParam == VK_RETURN ) { @@ -5055,10 +7492,10 @@ LRESULT APIENTRY MainWndProc( } DrawTypingCursor( hWnd, &textPt, hdcScreenCompat, hdcScreenCursorCompat, &cursorRc ); } - } + } break; } - switch (wParam) { + switch (wParam) { case 'R': case 'B': case 'Y': @@ -5067,7 +7504,7 @@ LRESULT APIENTRY MainWndProc( case 'X': case 'P': if( (g_Zoomed || g_TimerActive) && (g_TypeMode == TypeModeOff)) { - + PDWORD penColor; if( g_TimerActive ) penColor = &g_BreakPenColor; @@ -5119,12 +7556,12 @@ LRESULT APIENTRY MainWndProc( SelectObject( hdcScreenCompat, hDrawingPen ); if( g_Drawing ) { - SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); - + SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); + } else if( g_TimerActive ) { - - InvalidateRect( hWnd, NULL, FALSE ); - + + InvalidateRect( hWnd, NULL, FALSE ); + } else if( g_TypeMode != TypeModeOff ) { ClearTypingCursor( hdcScreenCompat, hdcScreenCursorCompat, cursorRc, g_BlankedScreen ); @@ -5138,11 +7575,11 @@ LRESULT APIENTRY MainWndProc( if( (GetKeyState( VK_CONTROL ) & 0x8000 ) && g_HaveDrawn && !g_Tracing ) { if( PopDrawUndo( hdcScreenCompat, &drawUndoList, width, height )) { - + if( g_Drawing ) { SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, prevPt ); - SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); + SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); } else { @@ -5158,7 +7595,7 @@ LRESULT APIENTRY MainWndProc( SetCursorPos( boundRc.left + (boundRc.right - boundRc.left)/2, boundRc.top + (boundRc.bottom - boundRc.top)/2 ); - SendMessage( hWnd, WM_MOUSEMOVE, 0, + SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( (boundRc.right - boundRc.left)/2, (boundRc.bottom - boundRc.top)/2 )); } @@ -5193,7 +7630,7 @@ LRESULT APIENTRY MainWndProc( // Save area that's going to be occupied by new cursor position SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, prevPt ); - SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); + SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); } break; @@ -5221,18 +7658,18 @@ LRESULT APIENTRY MainWndProc( } InvalidateRect( hWnd, NULL, FALSE ); g_BlankedScreen = FALSE; - } + } break; case VK_UP: - SendMessage( hWnd, WM_MOUSEWHEEL, - MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 || GetAsyncKeyState( VK_RCONTROL ) != 0 ? + SendMessage( hWnd, WM_MOUSEWHEEL, + MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 || GetAsyncKeyState( VK_RCONTROL ) != 0 ? MK_CONTROL: 0, WHEEL_DELTA), 0 ); return TRUE; case VK_DOWN: - SendMessage( hWnd, WM_MOUSEWHEEL, - MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 || GetAsyncKeyState( VK_RCONTROL ) != 0 ? + SendMessage( hWnd, WM_MOUSEWHEEL, + MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 || GetAsyncKeyState( VK_RCONTROL ) != 0 ? MK_CONTROL: 0, -WHEEL_DELTA), 0 ); return TRUE; @@ -5251,13 +7688,13 @@ LRESULT APIENTRY MainWndProc( InvalidateRect( hWnd, NULL, TRUE ); } break; - - case VK_ESCAPE: + + case VK_ESCAPE: if( g_TypeMode != TypeModeOff) { // Turn off SendMessage( hWnd, WM_USER_TYPING_OFF, 0, 0 ); - + } else { forcePenResize = TRUE; @@ -5286,15 +7723,25 @@ LRESULT APIENTRY MainWndProc( g_Zoomed, g_Drawing, g_Tracing); OutputDebug(L"Window visible: %d Topmost: %d\n", IsWindowVisible(hWnd), GetWindowLong(hWnd, GWL_EXSTYLE)& WS_EX_TOPMOST); + if( g_Zoomed && g_TelescopingZoomLastTick != 0ull && !g_Drawing && !g_Tracing ) + { + ULONG64 now = GetTickCount64(); + if( now - g_TelescopingZoomLastTick >= ZOOM_LEVEL_STEP_TIME ) + { + doTelescopingZoomTimer( false ); + } + } if( g_Zoomed && (g_TypeMode == TypeModeOff) && !g_bSaveInProgress ) { if( g_Drawing ) { + OutputDebug(L"Mousemove: Drawing\n"); + POINT currentPt; // Are we in pen mode on a tablet? - lParam = ScalePenPosition( zoomLevel, &monInfo, boundRc, message, lParam); + lParam = ScalePenPosition( zoomLevel, &monInfo, boundRc, message, lParam); currentPt.x = LOWORD(lParam); currentPt.y = HIWORD(lParam); @@ -5306,10 +7753,10 @@ LRESULT APIENTRY MainWndProc( } else if(g_DrawingShape) { - SetROP2(hdcScreenCompat, R2_NOTXORPEN); - - // If a previous target rectangle exists, erase - // it by drawing another rectangle on top. + SetROP2(hdcScreenCompat, R2_NOTXORPEN); + + // If a previous target rectangle exists, erase + // it by drawing another rectangle on top. if( g_rcRectangle.top != g_rcRectangle.bottom || g_rcRectangle.left != g_rcRectangle.right ) { @@ -5329,16 +7776,24 @@ LRESULT APIENTRY MainWndProc( } else { - DrawShape( g_DrawingShape, hdcScreenCompat, &g_rcRectangle ); + if (PEN_COLOR_HIGHLIGHT(g_PenColor)) + { + // copy original bitmap to screen bitmap to erase previous highlight + BitBlt(hdcScreenCompat, 0, 0, bmp.bmWidth, bmp.bmHeight, drawUndoList->hDc, 0, 0, SRCCOPY | CAPTUREBLT); + } + else + { + DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle, PEN_COLOR_HIGHLIGHT(g_PenColor)); + } } } - // Save the coordinates of the target rectangle. + // Save the coordinates of the target rectangle. // Avoid invalid rectangles by ensuring that the - // value of the left coordinate is greater than - // that of the right, and that the value of the - // bottom coordinate is greater than that of - // the top. + // value of the left coordinate is greater than + // that of the right, and that the value of the + // bottom coordinate is greater than that of + // the top. if( g_DrawingShape == DRAW_LINE || g_DrawingShape == DRAW_ARROW ) { @@ -5347,36 +7802,36 @@ LRESULT APIENTRY MainWndProc( } else { - if ((g_RectangleAnchor.x < currentPt.x) && + if ((g_RectangleAnchor.x < currentPt.x) && (g_RectangleAnchor.y > currentPt.y)) { - SetRect(&g_rcRectangle, g_RectangleAnchor.x, currentPt.y, - currentPt.x, g_RectangleAnchor.y); + SetRect(&g_rcRectangle, g_RectangleAnchor.x, currentPt.y, + currentPt.x, g_RectangleAnchor.y); - } else if ((g_RectangleAnchor.x > currentPt.x) && + } else if ((g_RectangleAnchor.x > currentPt.x) && (g_RectangleAnchor.y > currentPt.y )) { - SetRect(&g_rcRectangle, currentPt.x, - currentPt.y, g_RectangleAnchor.x,g_RectangleAnchor.y); + SetRect(&g_rcRectangle, currentPt.x, + currentPt.y, g_RectangleAnchor.x,g_RectangleAnchor.y); - } else if ((g_RectangleAnchor.x > currentPt.x) && + } else if ((g_RectangleAnchor.x > currentPt.x) && (g_RectangleAnchor.y < currentPt.y )) { - SetRect(&g_rcRectangle, currentPt.x, g_RectangleAnchor.y, - g_RectangleAnchor.x, currentPt.y ); + SetRect(&g_rcRectangle, currentPt.x, g_RectangleAnchor.y, + g_RectangleAnchor.x, currentPt.y ); } else { - SetRect(&g_rcRectangle, g_RectangleAnchor.x, g_RectangleAnchor.y, - currentPt.x, currentPt.y ); + SetRect(&g_rcRectangle, g_RectangleAnchor.x, g_RectangleAnchor.y, + currentPt.x, currentPt.y ); } } if (g_rcRectangle.left != g_rcRectangle.right || g_rcRectangle.top != g_rcRectangle.bottom) { - // Draw the new target rectangle. - DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle); - OutputDebug(L"SHAPE: (%d, %d) - (%d, %d)\n", g_rcRectangle.left, g_rcRectangle.top, + // Draw the new target rectangle. + DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle, PEN_COLOR_HIGHLIGHT(g_PenColor)); + OutputDebug(L"SHAPE: (%d, %d) - (%d, %d)\n", g_rcRectangle.left, g_rcRectangle.top, g_rcRectangle.right, g_rcRectangle.bottom); } @@ -5413,11 +7868,8 @@ LRESULT APIENTRY MainWndProc( Gdiplus::BitmapData* lineData = LockGdiPlusBitmap(lineBitmap); BYTE* pPixels = static_cast<BYTE*>(lineData->Scan0); - // Copy the contents of the screen bitmap to the temporary bitmap - GetOldestUndo(drawUndoList); - // Create a GDI bitmap that's the size of the lineBounds rectangle - Gdiplus::Bitmap *blurBitmap = CreateGdiplusBitmap( hdcScreenCompat, // oldestUndo->hDc, + Gdiplus::Bitmap *blurBitmap = CreateGdiplusBitmap( hdcScreenCompat, // oldestUndo->hDc, lineBounds.X, lineBounds.Y, lineBounds.Width, lineBounds.Height); // Blur it @@ -5437,8 +7889,10 @@ LRESULT APIENTRY MainWndProc( // Draw new cursor DrawCursor(hdcScreenCompat, currentPt, zoomLevel, width, height); - } - else if(PEN_COLOR_HIGHLIGHT(g_PenColor)) { + } + else if(PEN_COLOR_HIGHLIGHT(g_PenColor)) { + + OutputDebug(L"HIGHLIGHT\n"); // This is a highlighting pen color Gdiplus::Rect lineBounds = GetLineBounds(prevPt, currentPt, g_PenWidth); @@ -5471,7 +7925,7 @@ LRESULT APIENTRY MainWndProc( HDC hdcDIBOrig; HBITMAP hDibOrigBitmap, hDibBitmap; P_DRAW_UNDO oldestUndo = GetOldestUndo(drawUndoList); - BYTE* pDestPixels2 = CreateBitmapMemoryDIB(hdcScreenCompat, oldestUndo->hDc, &lineBounds, + BYTE* pDestPixels2 = CreateBitmapMemoryDIB(hdcScreenCompat, oldestUndo->hDc, &lineBounds, &hdcDIBOrig, &hDibBitmap, &hDibOrigBitmap); for (int local_y = 0; local_y < lineBounds.Height; ++local_y) { @@ -5528,7 +7982,7 @@ LRESULT APIENTRY MainWndProc( // Restore area where cursor was previously RestoreCursorArea( hdcScreenCompat, hdcScreenCursorCompat, prevPt ); - + // Save area that's going to be occupied by new cursor position SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, currentPt ); @@ -5547,7 +8001,7 @@ LRESULT APIENTRY MainWndProc( } prevPt = currentPt; - // In liveDraw we an miss the mouse up + // In liveDraw we miss the mouse up if( GetWindowLong(hWnd, GWL_EXSTYLE) & WS_EX_LAYERED) { if((GetAsyncKeyState(VK_LBUTTON) & 0x8000) == 0) { @@ -5577,12 +8031,12 @@ LRESULT APIENTRY MainWndProc( #if 0 { static int index = 0; - OutputDebug( L"%d: foreground: %x focus: %x (hwnd: %x)\n", + OutputDebug( L"%d: foreground: %x focus: %x (hwnd: %x)\n", index++, (DWORD) PtrToUlong(GetForegroundWindow()), PtrToUlong(GetFocus()), PtrToUlong(hWnd)); } #endif return TRUE; - + case WM_LBUTTONDOWN: g_StraightDirection = 0; @@ -5595,10 +8049,10 @@ LRESULT APIENTRY MainWndProc( RestoreCursorArea( hdcScreenCompat, hdcScreenCursorCompat, prevPt ); } - + // don't push undo if we sent this to ourselves for a pen resize if( wParam != -1 ) { - + PushDrawUndo( hdcScreenCompat, &drawUndoList, width, height ); } else { @@ -5627,7 +8081,7 @@ LRESULT APIENTRY MainWndProc( if( wParam & MK_SHIFT && wParam & MK_CONTROL ) g_DrawingShape = DRAW_ARROW; - else if( wParam & MK_CONTROL ) + else if( wParam & MK_CONTROL ) g_DrawingShape = DRAW_RECTANGLE; else if( wParam & MK_SHIFT ) g_DrawingShape = DRAW_LINE; @@ -5635,8 +8089,8 @@ LRESULT APIENTRY MainWndProc( g_DrawingShape = DRAW_ELLIPSE; g_RectangleAnchor.x = LOWORD(lParam); g_RectangleAnchor.y = HIWORD(lParam); - SetRect(&g_rcRectangle, g_RectangleAnchor.x, g_RectangleAnchor.y, - g_RectangleAnchor.x, g_RectangleAnchor.y); + SetRect(&g_rcRectangle, g_RectangleAnchor.x, g_RectangleAnchor.y, + g_RectangleAnchor.x, g_RectangleAnchor.y); } else { @@ -5654,10 +8108,10 @@ LRESULT APIENTRY MainWndProc( } g_Tracing = TRUE; SetROP2( hdcScreenCompat, R2_COPYPEN ); - prevPt.x = LOWORD(lParam); - prevPt.y = HIWORD(lParam); + prevPt.x = LOWORD(lParam); + prevPt.y = HIWORD(lParam); g_HaveDrawn = TRUE; - + } else { OutputDebug(L"Tracing on\n"); @@ -5748,7 +8202,7 @@ LRESULT APIENTRY MainWndProc( return TRUE; case WM_LBUTTONUP: - OutputDebug(L"LBUTTONUP: zoomed: %d drawing: %d tracing: %d\n", + OutputDebug(L"LBUTTONUP: zoomed: %d drawing: %d tracing: %d\n", g_Zoomed, g_Drawing, g_Tracing); if( g_Zoomed && g_Drawing && g_Tracing ) { @@ -5769,27 +8223,40 @@ LRESULT APIENTRY MainWndProc( if( g_StraightDirection == -1 ) { adjustPos.x = prevPt.x; - + } else { adjustPos.y = prevPt.y; } - lParam = MAKELPARAM( adjustPos.x, adjustPos.y ); + lParam = MAKELPARAM( adjustPos.x, adjustPos.y ); if( !g_DrawingShape ) { - Gdiplus::Graphics dstGraphics(hdcScreenCompat); - if( ( GetWindowLong( g_hWndMain, GWL_EXSTYLE ) & WS_EX_LAYERED ) == 0 ) + // If the point has changed, draw a line to it + if (!PEN_COLOR_HIGHLIGHT(g_PenColor)) { - dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + if (prevPt.x != LOWORD(lParam) || prevPt.y != HIWORD(lParam)) + { + Gdiplus::Graphics dstGraphics(hdcScreenCompat); + if ((GetWindowLong(g_hWndMain, GWL_EXSTYLE) & WS_EX_LAYERED) == 0) + { + dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + } + Gdiplus::Color color = ColorFromColorRef(g_PenColor); + Gdiplus::Pen pen(color, static_cast<Gdiplus::REAL>(g_PenWidth)); + Gdiplus::GraphicsPath path; + pen.SetLineJoin(Gdiplus::LineJoinRound); + path.AddLine(prevPt.x, prevPt.y, LOWORD(lParam), HIWORD(lParam)); + dstGraphics.DrawPath(&pen, &path); + } + // Draw a dot at the current point, if the point hasn't changed + else + { + MoveToEx(hdcScreenCompat, prevPt.x, prevPt.y, NULL); + LineTo(hdcScreenCompat, LOWORD(lParam), HIWORD(lParam)); + InvalidateRect(hWnd, NULL, FALSE); + } } - Gdiplus::Color color = ColorFromColorRef(g_PenColor); - Gdiplus::Pen pen(color, static_cast<Gdiplus::REAL>(g_PenWidth)); - Gdiplus::GraphicsPath path; - pen.SetLineJoin(Gdiplus::LineJoinRound); - path.AddLine(prevPt.x, prevPt.y, LOWORD(lParam), HIWORD(lParam)); - dstGraphics.DrawPath(&pen, &path); - prevPt.x = LOWORD( lParam ); prevPt.y = HIWORD( lParam ); @@ -5799,13 +8266,16 @@ LRESULT APIENTRY MainWndProc( } SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, prevPt ); DrawCursor( hdcScreenCompat, prevPt, zoomLevel, width, height ); - + } else if (g_rcRectangle.top != g_rcRectangle.bottom || g_rcRectangle.left != g_rcRectangle.right ) { // erase previous - SetROP2(hdcScreenCompat, R2_NOTXORPEN); - DrawShape( g_DrawingShape, hdcScreenCompat, &g_rcRectangle ); + if (!PEN_COLOR_HIGHLIGHT(g_PenColor)) + { + SetROP2(hdcScreenCompat, R2_NOTXORPEN); + DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle); + } // Draw the final shape HBRUSH hBrush = static_cast<HBRUSH>(GetStockObject( NULL_BRUSH )); @@ -5865,18 +8335,18 @@ 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 ) { prevPt.x = cursorRc.left; prevPt.y = cursorRc.top; - SetCursorPos( monInfo.rcMonitor.left + prevPt.x, + SetCursorPos( monInfo.rcMonitor.left + prevPt.x, monInfo.rcMonitor.top + prevPt.y ); SaveCursorArea( hdcScreenCursorCompat, hdcScreenCompat, prevPt ); SendMessage( hWnd, WM_MOUSEMOVE, 0, MAKELPARAM( prevPt.x, prevPt.y )); - + } else if( !g_Drawing) { // FIX: would be nice to reset cursor so screen doesn't move @@ -5919,15 +8389,17 @@ 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; - } + } case WM_LBUTTONDBLCLK: if( !g_TimerActive ) { SendMessage( hWnd, WM_COMMAND, IDC_OPTIONS, 0 ); - + } else { SetForegroundWindow( hWnd ); @@ -6020,6 +8492,20 @@ 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; + } + else + { + g_RecordScaling = g_RecordScalingMP4; + g_RecordFrameRate = RECORDING_FORMAT_MP4_DEFAULT_FRAMERATE; + } + // Apply tray icon setting EnableDisableTrayIcon(hWnd, g_ShowTrayIcon); @@ -6100,6 +8586,13 @@ LRESULT APIENTRY MainWndProc( showOptions = TRUE; } } + // Register CTRL+8 for GIF recording and CTRL+ALT+8 for GIF window recording + if (!RegisterHotKey(hWnd, RECORD_GIF_HOTKEY, MOD_CONTROL | MOD_NOREPEAT, '8') || + !RegisterHotKey(hWnd, RECORD_GIF_WINDOW_HOTKEY, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, '8')) + { + MessageBox(hWnd, L"The specified GIF recording hotkey is already in use.\nSelect a different GIF recording hotkey.", APPNAME, MB_ICONERROR); + showOptions = TRUE; + } if (showOptions) { // To open the PowerToys settings in the ZoomIt page. @@ -6120,12 +8613,10 @@ LRESULT APIENTRY MainWndProc( GetCursorPos(&local_savedCursorPos); } - HBITMAP hInterimSaveBitmap; - HDC hInterimSaveDc; - HBITMAP hSaveBitmap; - HDC hSaveDc; - int copyX, copyY; - int copyWidth, copyHeight; + // Determine the user's desired save area in zoomed viewport coordinates. + // This will be the entire viewport if the user does not select a crop + // rectangle. + int copyX = 0, copyY = 0, copyWidth = width, copyHeight = height; if ( LOWORD( wParam ) == IDC_SAVE_CROP ) { @@ -6140,109 +8631,167 @@ LRESULT APIENTRY MainWndProc( } break; } + auto copyRc = selectRectangle.SelectedRect(); selectRectangle.Stop(); g_RecordCropping = FALSE; + copyX = copyRc.left; copyY = copyRc.top; copyWidth = copyRc.right - copyRc.left; copyHeight = copyRc.bottom - copyRc.top; } - else - { - copyX = 0; - copyY = 0; - copyWidth = width; - copyHeight = height; - } OutputDebug( L"***x: %d, y: %d, width: %d, height: %d\n", copyX, copyY, copyWidth, copyHeight ); RECT oldClipRect{}; GetClipCursor( &oldClipRect ); ClipCursor( NULL ); - // Capture the screen before displaying the save dialog - hInterimSaveDc = CreateCompatibleDC( hdcScreen ); - hInterimSaveBitmap = CreateCompatibleBitmap( hdcScreen, copyWidth, copyHeight ); - SelectObject( hInterimSaveDc, hInterimSaveBitmap ); + // Translate the viewport selection into coordinates for the 1:1 source + // bitmap hdcScreenCompat. + int viewportX, viewportY; + GetZoomedTopLeftCoordinates( + zoomLevel, &cursorPos, &viewportX, width, &viewportY, height ); - hSaveDc = CreateCompatibleDC( hdcScreen ); -#if SCALE_HALFTONE - SetStretchBltMode( hInterimSaveDc, HALFTONE ); - SetStretchBltMode( hSaveDc, HALFTONE ); -#else - SetStretchBltMode( hInterimSaveDc, COLORONCOLOR ); - SetStretchBltMode( hSaveDc, COLORONCOLOR ); -#endif - StretchBlt( hInterimSaveDc, - 0, 0, - copyWidth, copyHeight, - hdcScreen, - monInfo.rcMonitor.left + copyX, - monInfo.rcMonitor.top + copyY, - copyWidth, copyHeight, - SRCCOPY|CAPTUREBLT ); + int saveX = viewportX + static_cast<int>( copyX / zoomLevel ); + int saveY = viewportY + static_cast<int>( copyY / zoomLevel ); + int saveWidth = static_cast<int>( copyWidth / zoomLevel ); + int saveHeight = static_cast<int>( copyHeight / zoomLevel ); + // Create a pixel-accurate copy of the desired area from the source bitmap. + wil::unique_hdc hdcActualSize( CreateCompatibleDC( hdcScreen ) ); + wil::unique_hbitmap hbmActualSize( + CreateCompatibleBitmap( hdcScreen, saveWidth, saveHeight ) ); + // Note: we do not need to restore the existing context later. The objects + // are transient and not reused. + SelectObject( hdcActualSize.get(), hbmActualSize.get() ); + + // Perform a direct 1:1 copy from the backing bitmap. + BitBlt( hdcActualSize.get(), + 0, 0, + saveWidth, saveHeight, + hdcScreenCompat, + saveX, saveY, + SRCCOPY | CAPTUREBLT ); + + // 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<HINSTANCE>(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<IFileSaveDialog>( 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<IShellItem> 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<IShellItem> 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"; } - // Save image at screen size - if( openFileName.nFilterIndex == 1 ) + if( selectedFilterIndex == 2 ) { - SavePng( targetFilePath, hInterimSaveBitmap ); + // Save at actual size. + SavePng( targetFilePath.c_str(), hbmActualSize.get() ); } - // Save image scaled down to actual size else { - int saveWidth = static_cast<int>( copyWidth / zoomLevel ); - int saveHeight = static_cast<int>( copyHeight / zoomLevel ); + // Save zoomed-in image at screen resolution. +#if SCALE_HALFTONE + const int bltMode = HALFTONE; +#else + // Use HALFTONE for better quality when smooth image is enabled + const int bltMode = g_SmoothImage ? HALFTONE : COLORONCOLOR; +#endif + // Recreate the zoomed-in view by upscaling from our source bitmap. + wil::unique_hdc hdcZoomed( CreateCompatibleDC(hdcScreen) ); + wil::unique_hbitmap hbmZoomed( + CreateCompatibleBitmap( hdcScreen, copyWidth, copyHeight ) ); + SelectObject( hdcZoomed.get(), hbmZoomed.get() ); - hSaveBitmap = CreateCompatibleBitmap( hdcScreen, saveWidth, saveHeight ); - SelectObject( hSaveDc, hSaveBitmap ); + SetStretchBltMode( hdcZoomed.get(), bltMode ); - StretchBlt( hSaveDc, + StretchBlt( hdcZoomed.get(), + 0, 0, + copyWidth, copyHeight, + hdcActualSize.get(), 0, 0, saveWidth, saveHeight, - hInterimSaveDc, - 0, - 0, - copyWidth, copyHeight, SRCCOPY | CAPTUREBLT ); - - SavePng( targetFilePath, hSaveBitmap ); + + 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; - DeleteDC( hInterimSaveDc ); - DeleteDC( hSaveDc ); - if( lParam != SHALLOW_ZOOM ) { - SetCursorPos(local_savedCursorPos.x, local_savedCursorPos.y); + SetCursorPos( local_savedCursorPos.x, local_savedCursorPos.y ); } ClipCursor( &oldClipRect ); + break; } @@ -6295,7 +8844,12 @@ LRESULT APIENTRY MainWndProc( #if SCALE_HALFTONE SetStretchBltMode( hSaveDc, HALFTONE ); #else - SetStretchBltMode( hSaveDc, COLORONCOLOR ); + // Use HALFTONE for better quality when smooth image is enabled + if (g_SmoothImage) { + SetStretchBltMode( hSaveDc, HALFTONE ); + } else { + SetStretchBltMode( hSaveDc, COLORONCOLOR ); + } #endif StretchBlt( hSaveDc, 0, 0, @@ -6304,10 +8858,10 @@ LRESULT APIENTRY MainWndProc( monInfo.rcMonitor.left + copyX, monInfo.rcMonitor.top + copyY, copyWidth, copyHeight, - SRCCOPY|CAPTUREBLT ); + SRCCOPY|CAPTUREBLT ); if( OpenClipboard( hWnd )) { - + EmptyClipboard(); SetClipboardData( CF_BITMAP, hSaveBitmap ); CloseClipboard(); @@ -6317,7 +8871,7 @@ LRESULT APIENTRY MainWndProc( } break; - case IDC_DRAW: + case IDC_DRAW: PostMessage( hWnd, WM_HOTKEY, DRAW_HOTKEY, 1 ); break; @@ -6401,10 +8955,10 @@ LRESULT APIENTRY MainWndProc( ( !g_TimerActive || wcscmp( activeBreakBackgroundFile, g_BreakBackgroundFile ) ) ) { _tcscpy( activeBreakBackgroundFile, g_BreakBackgroundFile ); - + DeleteObject( g_hBackgroundBmp ); DeleteDC( g_hDcBackgroundFile ); - + g_hBackgroundBmp = NULL; g_hBackgroundBmp = LoadImageFile( g_BreakBackgroundFile ); if( g_hBackgroundBmp == NULL ) @@ -6446,15 +9000,15 @@ LRESULT APIENTRY MainWndProc( hNegativeTimerFont = CreateFontIndirect( &g_LogFont ); // Create backing bitmap - hdcScreenCompat = CreateCompatibleDC(hdcScreen); + hdcScreenCompat = CreateCompatibleDC(hdcScreen); bmp.bmBitsPixel = static_cast<BYTE>(GetDeviceCaps(hdcScreen, BITSPIXEL)); bmp.bmPlanes = static_cast<BYTE>(GetDeviceCaps(hdcScreen, PLANES)); bmp.bmWidth = width; bmp.bmHeight = height; - bmp.bmWidthBytes = ((bmp.bmWidth + 15) &~15)/8; - hbmpCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, - bmp.bmPlanes, bmp.bmBitsPixel, static_cast<CONST VOID *>(NULL)); - SelectObject(hdcScreenCompat, hbmpCompat); + bmp.bmWidthBytes = ((bmp.bmWidth + 15) &~15)/8; + hbmpCompat = CreateBitmap(bmp.bmWidth, bmp.bmHeight, + bmp.bmPlanes, bmp.bmBitsPixel, static_cast<CONST VOID *>(NULL)); + SelectObject(hdcScreenCompat, hbmpCompat); SetTextColor( hdcScreenCompat, g_BreakPenColor ); SetBkMode( hdcScreenCompat, TRANSPARENT ); @@ -6469,7 +9023,7 @@ LRESULT APIENTRY MainWndProc( BringWindowToTop( hWnd ); SetForegroundWindow( hWnd ); SetActiveWindow( hWnd ); - SetWindowPos( hWnd, HWND_NOTOPMOST, monInfo.rcMonitor.left, monInfo.rcMonitor.top, + SetWindowPos( hWnd, HWND_NOTOPMOST, monInfo.rcMonitor.left, monInfo.rcMonitor.top, width, height, SWP_SHOWWINDOW ); } break; @@ -6477,10 +9031,10 @@ LRESULT APIENTRY MainWndProc( case IDCANCEL: memset( &tNotifyIconData, 0, sizeof(tNotifyIconData)); - tNotifyIconData.cbSize = sizeof(NOTIFYICONDATA); - tNotifyIconData.hWnd = hWnd; - tNotifyIconData.uID = 1; - Shell_NotifyIcon(NIM_DELETE, &tNotifyIconData); + tNotifyIconData.cbSize = sizeof(NOTIFYICONDATA); + tNotifyIconData.hWnd = hWnd; + tNotifyIconData.uID = 1; + Shell_NotifyIcon(NIM_DELETE, &tNotifyIconData); reg.WriteRegSettings( RegSettings ); if( hWndOptions ) @@ -6503,93 +9057,12 @@ LRESULT APIENTRY MainWndProc( if( breakTimeout == 0 && g_BreakPlaySoundFile ) { PlaySound( g_BreakSoundFile, NULL, SND_FILENAME|SND_ASYNC ); - } + } break; case 2: case 1: - // - // Telescoping zoom timer - // - if( zoomTelescopeStep ) { - - zoomLevel *= zoomTelescopeStep; - if( (zoomTelescopeStep > 1 && zoomLevel >= zoomTelescopeTarget ) || - (zoomTelescopeStep < 1 && zoomLevel <= zoomTelescopeTarget )) { - - zoomLevel = zoomTelescopeTarget; - KillTimer( hWnd, wParam ); - OutputDebug( L"SETCURSOR mon_left: %x mon_top: %x x: %d y: %d\n", - monInfo.rcMonitor.left, monInfo.rcMonitor.top, cursorPos.x, cursorPos.y ); - SetCursorPos( monInfo.rcMonitor.left + cursorPos.x, - monInfo.rcMonitor.top + cursorPos.y ); - } - - } else { - - // Case where we didn't zoom at all - KillTimer( hWnd, wParam ); - } - if( wParam == 2 && zoomLevel == 1 ) { - - g_Zoomed = FALSE; - if( g_ZoomOnLiveZoom ) - { - GetCursorPos( &cursorPos ); - cursorPos = ScalePointInRects( cursorPos, monInfo.rcMonitor, g_LiveZoomSourceRect ); - SetCursorPos( cursorPos.x, cursorPos.y ); - SendMessage(hWnd, WM_HOTKEY, LIVE_HOTKEY, 0); - } - else if( lParam != SHALLOW_ZOOM ) - { - // Figure out where final unzoomed cursor should be - if (g_Drawing) { - cursorPos = prevPt; - } - OutputDebug(L"FINAL MOUSE: x: %d y: %d\n", cursorPos.x, cursorPos.y ); - GetZoomedTopLeftCoordinates(zoomLevel, &cursorPos, &x, width, &y, height); - cursorPos.x = monInfo.rcMonitor.left + x + static_cast<int>((cursorPos.x - x) * zoomLevel); - cursorPos.y = monInfo.rcMonitor.top + y + static_cast<int>((cursorPos.y - y) * zoomLevel); - SetCursorPos(cursorPos.x, cursorPos.y); - } - if( hTargetWindow ) { - - SetWindowPos( hTargetWindow, HWND_BOTTOM, rcTargetWindow.left, rcTargetWindow.top, - rcTargetWindow.right - rcTargetWindow.left, - rcTargetWindow.bottom - rcTargetWindow.top, 0 ); - hTargetWindow = NULL; - } - DeleteDrawUndoList( &drawUndoList ); - - // Restore live zoom if we came from that mode - if( g_ZoomOnLiveZoom ) { - - SendMessage( g_hWndLiveZoom, WM_USER_SET_ZOOM, static_cast<WPARAM>(g_LiveZoomLevel), reinterpret_cast<LPARAM>(&g_LiveZoomSourceRect) ); - g_ZoomOnLiveZoom = FALSE; - forcePenResize = TRUE; - } - - SetForegroundWindow( g_ActiveWindow ); - ClipCursor( NULL ); - g_HaveDrawn = FALSE; - g_TypeMode = TypeModeOff; - g_HaveTyped = FALSE; - g_Drawing = FALSE; - EnableDisableStickyKeys( TRUE ); - DeleteObject( hTypingFont ); - DeleteDC( hdcScreen ); - DeleteDC( hdcScreenCompat ); - DeleteDC( hdcScreenCursorCompat ); - DeleteDC( hdcScreenSaveCompat ); - DeleteObject( hbmpCompat ); - DeleteObject( hbmpCursorCompat ); - DeleteObject( hbmpDrawingCompat ); - DeleteObject( hDrawingPen ); - - SetFocus( g_ActiveWindow ); - ShowWindow( hWnd, SW_HIDE ); - } - InvalidateRect( hWnd, NULL, FALSE ); + doTelescopingZoomTimer(); break; case 3: @@ -6612,7 +9085,7 @@ LRESULT APIENTRY MainWndProc( case WM_PAINT: - hDc = BeginPaint(hWnd, &ps); + hDc = BeginPaint(hWnd, &ps); if( ( ( g_RecordCropping == FALSE ) || ( zoomLevel == 1 ) ) && g_Zoomed ) { @@ -6622,39 +9095,44 @@ LRESULT APIENTRY MainWndProc( #if SCALE_GDIPLUS if ( zoomLevel >= zoomTelescopeTarget ) { // do a high-quality render - extern void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst, + extern void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst, HBITMAP bmSrc, float xSrc, float ySrc, float wSrc, float hSrc ); - ScaleImage( ps.hdc, - 0, 0, - (float)bmp.bmWidth, (float)bmp.bmHeight, - hbmpCompat, - (float)x, (float)y, - width/zoomLevel, height/zoomLevel ); + ScaleImage( ps.hdc, + 0, 0, + (float)bmp.bmWidth, (float)bmp.bmHeight, + hbmpCompat, + (float)x, (float)y, + width/zoomLevel, height/zoomLevel ); } else { - // do a fast, less accurate render - SetStretchBltMode( hDc, HALFTONE ); - StretchBlt( ps.hdc, - 0, 0, - bmp.bmWidth, bmp.bmHeight, - hdcScreenCompat, - x, y, + // do a fast, less accurate render (but use smooth if enabled) + SetStretchBltMode( hDc, g_SmoothImage ? HALFTONE : COLORONCOLOR ); + StretchBlt( ps.hdc, + 0, 0, + bmp.bmWidth, bmp.bmHeight, + hdcScreenCompat, + x, y, (int) (width/zoomLevel), (int) (height/zoomLevel), - SRCCOPY); + SRCCOPY); } #else #if SCALE_HALFTONE SetStretchBltMode( hDc, zoomLevel == zoomTelescopeTarget ? HALFTONE : COLORONCOLOR ); #else - SetStretchBltMode( hDc, COLORONCOLOR ); + // Use HALFTONE for better quality when smooth image is enabled + if (g_SmoothImage) { + SetStretchBltMode( hDc, HALFTONE ); + } else { + SetStretchBltMode( hDc, COLORONCOLOR ); + } #endif - StretchBlt( ps.hdc, - 0, 0, - bmp.bmWidth, bmp.bmHeight, - hdcScreenCompat, - x, y, + StretchBlt( ps.hdc, + 0, 0, + bmp.bmWidth, bmp.bmHeight, + hdcScreenCompat, + x, y, static_cast<int>(width/zoomLevel), static_cast<int>(height/zoomLevel), - SRCCOPY|CAPTUREBLT ); + SRCCOPY|CAPTUREBLT ); #endif } else if( g_TimerActive ) { @@ -6669,12 +9147,12 @@ LRESULT APIENTRY MainWndProc( BITMAP local_bmp; GetObject(g_hBackgroundBmp, sizeof(local_bmp), &local_bmp); - SetStretchBltMode( hdcScreenCompat, HALFTONE ); + SetStretchBltMode( hdcScreenCompat, g_SmoothImage ? HALFTONE : COLORONCOLOR ); if( g_BreakBackgroundStretch ) { StretchBlt( hdcScreenCompat, 0, 0, width, height, g_hDcBackgroundFile, 0, 0, local_bmp.bmWidth, local_bmp.bmHeight, SRCCOPY|CAPTUREBLT ); } else { - BitBlt( hdcScreenCompat, width/2 - local_bmp.bmWidth/2, height/2 - local_bmp.bmHeight/2, + BitBlt( hdcScreenCompat, width/2 - local_bmp.bmWidth/2, height/2 - local_bmp.bmHeight/2, local_bmp.bmWidth, local_bmp.bmHeight, g_hDcBackgroundFile, 0, 0, SRCCOPY|CAPTUREBLT ); } } @@ -6683,13 +9161,13 @@ LRESULT APIENTRY MainWndProc( if( breakTimeout > 0 ) { _stprintf( timerText, L"% 2d:%02d", breakTimeout/60, breakTimeout % 60 ); - + } else { _tcscpy( timerText, L"0:00" ); } rc.left = rc.top = 0; - DrawText( hdcScreenCompat, timerText, -1, &rc, + DrawText( hdcScreenCompat, timerText, -1, &rc, DT_NOCLIP|DT_LEFT|DT_NOPREFIX|DT_CALCRECT ); rc1.left = rc1.right = rc1.bottom = rc1.top = 0; @@ -6698,7 +9176,7 @@ LRESULT APIENTRY MainWndProc( _stprintf( negativeTimerText, L"(-% 2d:%02d)", -breakTimeout/60, -breakTimeout % 60 ); HFONT prevFont = static_cast<HFONT>(SelectObject( hdcScreenCompat, hNegativeTimerFont )); - DrawText( hdcScreenCompat, negativeTimerText, -1, &rc1, + DrawText( hdcScreenCompat, negativeTimerText, -1, &rc1, DT_NOCLIP|DT_LEFT|DT_NOPREFIX|DT_CALCRECT ); SelectObject( hdcScreenCompat, prevFont ); } @@ -6750,7 +9228,7 @@ LRESULT APIENTRY MainWndProc( rc1.top = rc.bottom + 10; rc1.left = rc.left + ((rc.right - rc.left)-(rc1.right-rc1.left))/2; HFONT prevFont = static_cast<HFONT>(SelectObject( hdcScreenCompat, hNegativeTimerFont )); - DrawText( hdcScreenCompat, negativeTimerText, -1, &rc1, + DrawText( hdcScreenCompat, negativeTimerText, -1, &rc1, DT_NOCLIP|DT_LEFT|DT_NOPREFIX ); SelectObject( hdcScreenCompat, prevFont ); } @@ -6758,11 +9236,11 @@ LRESULT APIENTRY MainWndProc( // Copy to screen BitBlt( ps.hdc, 0, 0, width, height, hdcScreenCompat, 0, 0, SRCCOPY|CAPTUREBLT ); } - EndPaint(hWnd, &ps); + EndPaint(hWnd, &ps); return TRUE; case WM_DESTROY: - + CleanupDarkModeResources(); PostQuitMessage( 0 ); break; @@ -6828,7 +9306,6 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM WS_CHILD | MS_SHOWMAGNIFIEDCURSOR | WS_VISIBLE, 0, 0, 0, 0, hWnd, NULL, g_hInstance, NULL ); } - ShowWindow( hWnd, SW_SHOW ); InvalidateRect( g_hWndLiveZoomMag, NULL, TRUE ); @@ -6844,7 +9321,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM if( !startedInPresentationMode ) { SetTimer( hWnd, 1, LIVEZOOM_WINDOW_TIMEOUT, NULL ); - } + } } break; @@ -6872,7 +9349,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM UpdateWindow(hWnd); } - // Are we coming back from a static zoom that + // Are we coming back from a static zoom that // was started while we were live zoomed? if( g_ZoomOnLiveZoom ) { @@ -6912,7 +9389,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM SendMessage( hWnd, WM_TIMER, 0, 0); SetTimer( hWnd, 0, ZOOM_LEVEL_STEP_TIME, NULL ); - + } else { KillTimer( hWnd, 0 ); @@ -6950,14 +9427,14 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM GetCursorPos(&cursorPos); - // Reclaim topmost status, to prevent unmagnified menus from remaining in view. + // Reclaim topmost status, to prevent unmagnified menus from remaining in view. memset(&matrix, 0, sizeof(matrix)); if( !g_fullScreenWorkaround ) { pSetLayeredWindowAttributes( hWnd, 0, 255, LWA_ALPHA ); SetWindowPos(hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOSIZE); - + OutputDebug(L"LIVEZOOM RECLAIM\n"); } @@ -6966,7 +9443,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM moveWidth = sourceRectWidth/LIVEZOOM_MOVE_REGIONS; moveHeight = sourceRectHeight/LIVEZOOM_MOVE_REGIONS; curTickCount = GetTickCount(); - if( zoomLevel != zoomTelescopeTarget && + if( zoomLevel != zoomTelescopeTarget && (prevZoomStepTickCount == 0 || (curTickCount - prevZoomStepTickCount > ZOOM_LEVEL_STEP_TIME)) ) { prevZoomStepTickCount = curTickCount; @@ -6978,7 +9455,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM } else { zoomLevel *= zoomTelescopeStep; - } + } // Time to exit zoom mode? if( zoomTelescopeTarget == 1 && zoomLevel == 1 ) { @@ -6997,13 +9474,13 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM matrix.v[1][2] = (static_cast<float>(-lastSourceRect.top) * zoomLevel ); matrix.v[2][2] = 1.0f; } - + // // Pre-adjust for monitor boundary // adjustedCursorPos.x = cursorPos.x - monInfo.rcMonitor.left; adjustedCursorPos.y = cursorPos.y - monInfo.rcMonitor.top; - GetZoomedTopLeftCoordinates( zoomLevel, &adjustedCursorPos, reinterpret_cast<int *>(&zoomCenterPos.x), width, + GetZoomedTopLeftCoordinates( zoomLevel, &adjustedCursorPos, reinterpret_cast<int *>(&zoomCenterPos.x), width, reinterpret_cast<int *>(&zoomCenterPos.y), height ); // @@ -7016,11 +9493,11 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM int xOffset = cursorPos.x - lastSourceRect.left; int yOffset = cursorPos.y - lastSourceRect.top; - zoomCenterPos.x = 0; - zoomCenterPos.y = 0; - if( xOffset < moveWidth ) + zoomCenterPos.x = 0; + zoomCenterPos.y = 0; + if( xOffset < moveWidth ) zoomCenterPos.x = lastSourceRect.left + sourceRectWidth/2 - (moveWidth - xOffset); - else if( xOffset > moveWidth * (LIVEZOOM_MOVE_REGIONS-1) ) + else if( xOffset > moveWidth * (LIVEZOOM_MOVE_REGIONS-1) ) zoomCenterPos.x = lastSourceRect.left + sourceRectWidth/2 + (xOffset - moveWidth*(LIVEZOOM_MOVE_REGIONS-1)); if( yOffset < moveHeight ) zoomCenterPos.y = lastSourceRect.top + sourceRectHeight/2 - (moveHeight - yOffset); @@ -7028,8 +9505,8 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM zoomCenterPos.y = lastSourceRect.top + sourceRectHeight/2 + (yOffset - moveHeight*(LIVEZOOM_MOVE_REGIONS-1)); } if( matrix.v[0][0] || zoomCenterPos.x || zoomCenterPos.y ) { - - if( zoomCenterPos.y == 0 ) + + if( zoomCenterPos.y == 0 ) zoomCenterPos.y = lastSourceRect.top + sourceRectHeight/2; if( zoomCenterPos.x == 0 ) zoomCenterPos.x = lastSourceRect.left + sourceRectWidth/2; @@ -7040,14 +9517,14 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM sourceRect.top = zoomCenterPos.y - zoomHeight / 2; // Don't scroll outside desktop area. - if (sourceRect.left < monInfo.rcMonitor.left) + if (sourceRect.left < monInfo.rcMonitor.left) sourceRect.left = monInfo.rcMonitor.left; else if (sourceRect.left > monInfo.rcMonitor.right - zoomWidth ) sourceRect.left = monInfo.rcMonitor.right - zoomWidth; sourceRect.right = sourceRect.left + zoomWidth; - if (sourceRect.top < monInfo.rcMonitor.top) + if (sourceRect.top < monInfo.rcMonitor.top) sourceRect.top = monInfo.rcMonitor.top; - else if (sourceRect.top > monInfo.rcMonitor.bottom - zoomHeight) + else if (sourceRect.top > monInfo.rcMonitor.bottom - zoomHeight) sourceRect.top = monInfo.rcMonitor.bottom - zoomHeight; sourceRect.bottom = sourceRect.top + zoomHeight; @@ -7132,7 +9609,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM DestroyWindow( hWnd ); } - } + } } break; } @@ -7145,9 +9622,9 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM // Existing presentation mode DestroyWindow( hWnd ); - + } else if( !startedInPresentationMode && IsPresentationMode()) { - + // Kill the timer if one was configured, because now // we're going to go away when they exit presentation mode KillTimer( hWnd, 1 ); @@ -7159,19 +9636,19 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM float newZoomLevel = zoomLevel; switch( wParam ) { case 0: - // zoom in - if( newZoomLevel < ZOOM_LEVEL_MAX ) + // zoom in + if( newZoomLevel < ZOOM_LEVEL_MAX ) newZoomLevel *= 2; zoomTelescopeStep = ZOOM_LEVEL_STEP_IN; break; case 1: - if( newZoomLevel > 2 ) + if( newZoomLevel > 2 ) newZoomLevel /= 2; else { - newZoomLevel *= .75; - if( newZoomLevel < ZOOM_LEVEL_MIN ) + newZoomLevel *= .75; + if( newZoomLevel < ZOOM_LEVEL_MIN ) newZoomLevel = ZOOM_LEVEL_MIN; } zoomTelescopeStep = ZOOM_LEVEL_STEP_OUT; @@ -7198,12 +9675,12 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM break; case VK_UP: - SendMessage( hWnd, WM_MOUSEWHEEL, + SendMessage( hWnd, WM_MOUSEWHEEL, MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 ? MK_CONTROL: 0, WHEEL_DELTA), 0 ); return TRUE; case VK_DOWN: - SendMessage( hWnd, WM_MOUSEWHEEL, + SendMessage( hWnd, WM_MOUSEWHEEL, MAKEWPARAM( GetAsyncKeyState( VK_LCONTROL ) != 0 ? MK_CONTROL: 0, -WHEEL_DELTA), 0 ); return TRUE; } @@ -7211,10 +9688,10 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM case WM_DESTROY: g_hWndLiveZoom = NULL; break; - + case WM_SIZE: GetClientRect(hWnd, &rc); - SetWindowPos(g_hWndLiveZoomMag, NULL, + SetWindowPos(g_hWndLiveZoomMag, NULL, rc.left, rc.top, rc.right, rc.bottom, 0 ); break; @@ -7290,7 +9767,7 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM default: return DefWindowProc(hWnd, message, wParam, lParam); } - return 0; + return 0; } @@ -7345,7 +9822,7 @@ HRESULT __stdcall WrapD3D11CreateDevice( // InitInstance // //---------------------------------------------------------------------------- -HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) +HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) { WNDCLASS wcZoomIt; HWND hWndMain; @@ -7361,7 +9838,7 @@ HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) wcZoomIt.cbClsExtra = 0; wcZoomIt.cbWndExtra = 0; wcZoomIt.hInstance = hInstance; - wcZoomIt.hIcon = 0; + wcZoomIt.hIcon = 0; wcZoomIt.hCursor = LoadCursor(NULL, IDC_ARROW); wcZoomIt.hbrBackground = NULL; wcZoomIt.lpszMenuName = NULL; @@ -7373,40 +9850,87 @@ HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) g_LiveZoomToggleKey = 0; } - wcZoomIt.style = 0; - wcZoomIt.lpfnWndProc = (WNDPROC)MainWndProc; - wcZoomIt.cbClsExtra = 0; - wcZoomIt.cbWndExtra = 0; + wcZoomIt.style = 0; + wcZoomIt.lpfnWndProc = (WNDPROC)MainWndProc; + wcZoomIt.cbClsExtra = 0; + wcZoomIt.cbWndExtra = 0; wcZoomIt.hInstance = hInstance; wcZoomIt.hIcon = NULL; wcZoomIt.hCursor = LoadCursor( hInstance, L"NULLCURSOR" ); wcZoomIt.hbrBackground = NULL; - wcZoomIt.lpszMenuName = NULL; + wcZoomIt.lpszMenuName = NULL; wcZoomIt.lpszClassName = L"ZoomitClass"; if ( ! RegisterClass(&wcZoomIt) ) return FALSE; - hWndMain = CreateWindowEx( WS_EX_TOOLWINDOW, L"ZoomitClass", - L"Zoomit Zoom Window", + hWndMain = CreateWindowEx( WS_EX_TOOLWINDOW, L"ZoomitClass", + L"Zoomit Zoom Window", WS_POPUP, - 0, 0, 0, 0, - NULL, - NULL, - hInstance, + 0, 0, + NULL, + NULL, + hInstance, NULL); - // If window could not be created, return "failure" + // If window could not be created, return "failure" if (!hWndMain ) return NULL; - // Make the window visible; update its client area; and return "success" + // Make the window visible; update its client area; and return "success" ShowWindow(hWndMain, SW_HIDE); // Add tray icon EnableDisableTrayIcon( hWndMain, g_ShowTrayIcon ); - return hWndMain; + return hWndMain; -} +} + +// Dispatch commands coming from the PowerToys IPC channel. +#ifdef __ZOOMIT_POWERTOYS__ +void ZoomIt_DispatchCommand(ZoomItCommand cmd) +{ + auto post_hotkey = [](WPARAM id) + { + if (g_hWndMain != nullptr) + { + PostMessage(g_hWndMain, WM_HOTKEY, id, 0); + } + }; + + switch (cmd) + { + case ZoomItCommand::Zoom: + if (g_hWndMain != nullptr) + { + PostMessage(g_hWndMain, WM_COMMAND, IDC_ZOOM, 0); + } + Trace::ZoomItActivateZoom(); + break; + case ZoomItCommand::Draw: + post_hotkey(DRAW_HOTKEY); + Trace::ZoomItActivateDraw(); + break; + case ZoomItCommand::Break: + post_hotkey(BREAK_HOTKEY); + Trace::ZoomItActivateBreak(); + break; + case ZoomItCommand::LiveZoom: + post_hotkey(LIVE_HOTKEY); + Trace::ZoomItActivateLiveZoom(); + break; + case ZoomItCommand::Snip: + post_hotkey(SNIP_HOTKEY); + Trace::ZoomItActivateSnip(); + break; + case ZoomItCommand::Record: + post_hotkey(RECORD_HOTKEY); + Trace::ZoomItActivateRecord(); + break; + default: + break; + } +} +#endif //---------------------------------------------------------------------------- // @@ -7416,7 +9940,7 @@ HWND InitInstance( HINSTANCE hInstance, int nCmdShow ) int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ PWSTR lpCmdLine, _In_ int nCmdShow ) { - MSG msg; + MSG msg; HACCEL hAccel; if( !ShowEula( APPNAME, NULL, NULL )) return 1; @@ -7442,7 +9966,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance // Initialize logger LoggerHelpers::init_logger(L"ZoomIt", L"", LogSettings::zoomItLoggerName); - ProcessWaiter::OnProcessTerminate(pid, [mainThreadId](int err) { if (err != ERROR_SUCCESS) { @@ -7479,7 +10002,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance if( !CreateEvent( NULL, FALSE, FALSE, _T("Local\\ZoomitActive"))) { CreateEvent( NULL, FALSE, FALSE, _T("ZoomitActive")); - } + } if( GetLastError() == ERROR_ALREADY_EXISTS ) { if (g_StartedByPowerToys) { @@ -7500,7 +10023,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance if( local_hWndOptions ) { SetForegroundWindow( local_hWndOptions ); - SetWindowPos( local_hWndOptions, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW ); + SetWindowPos( local_hWndOptions, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW ); break; } Sleep( 100 ); @@ -7521,6 +10044,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), @@ -7541,6 +10069,10 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance "MagSetWindowTransform" ); pMagSetFullscreenTransform = (type_pMagSetFullscreenTransform)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), "MagSetFullscreenTransform"); + pMagSetFullscreenUseBitmapSmoothing = (type_MagSetFullscreenUseBitmapSmoothing)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), + "MagSetFullscreenUseBitmapSmoothing"); + pMagSetLensUseBitmapSmoothing = (type_pMagSetLensUseBitmapSmoothing)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), + "MagSetLensUseBitmapSmoothing"); pMagSetInputTransform = (type_pMagSetInputTransform)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), "MagSetInputTransform"); pMagShowSystemCursor = (type_pMagShowSystemCursor)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM), @@ -7567,7 +10099,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. // @@ -7597,27 +10129,63 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance #ifdef __ZOOMIT_POWERTOYS__ HANDLE m_reload_settings_event_handle = NULL; HANDLE m_exit_event_handle = NULL; + HANDLE m_zoom_event_handle = NULL; + HANDLE m_draw_event_handle = NULL; + HANDLE m_break_event_handle = NULL; + HANDLE m_live_zoom_event_handle = NULL; + HANDLE m_snip_event_handle = NULL; + HANDLE m_record_event_handle = NULL; std::thread m_event_triggers_thread; if( g_StartedByPowerToys ) { // Start a thread to listen to PowerToys Events. m_reload_settings_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::ZOOMIT_REFRESH_SETTINGS_EVENT); m_exit_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::ZOOMIT_EXIT_EVENT); - if (!m_reload_settings_event_handle || !m_exit_event_handle) + m_zoom_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::ZOOMIT_ZOOM_EVENT); + m_draw_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::ZOOMIT_DRAW_EVENT); + m_break_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::ZOOMIT_BREAK_EVENT); + m_live_zoom_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::ZOOMIT_LIVEZOOM_EVENT); + m_snip_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::ZOOMIT_SNIP_EVENT); + m_record_event_handle = CreateEventW(nullptr, false, false, CommonSharedConstants::ZOOMIT_RECORD_EVENT); + if (!m_reload_settings_event_handle || !m_exit_event_handle || !m_zoom_event_handle || !m_draw_event_handle || !m_break_event_handle || !m_live_zoom_event_handle || !m_snip_event_handle || !m_record_event_handle) { Logger::warn(L"Failed to create events. {}", get_last_error_or_default(GetLastError())); return 1; } - m_event_triggers_thread = std::thread([&]() { + const std::array<HANDLE, 8> event_handles{ + m_reload_settings_event_handle, + m_exit_event_handle, + m_zoom_event_handle, + m_draw_event_handle, + m_break_event_handle, + m_live_zoom_event_handle, + m_snip_event_handle, + m_record_event_handle, + }; + const DWORD handle_count = static_cast<DWORD>(event_handles.size()); + m_event_triggers_thread = std::thread([event_handles, handle_count]() { MSG msg; - HANDLE event_handles[2] = {m_reload_settings_event_handle, m_exit_event_handle}; while (g_running) { - DWORD dwEvt = MsgWaitForMultipleObjects(2, event_handles, false, INFINITE, QS_ALLINPUT); + DWORD dwEvt = MsgWaitForMultipleObjects(handle_count, event_handles.data(), false, INFINITE, QS_ALLINPUT); + if (dwEvt == WAIT_FAILED) + { + Logger::error(L"ZoomIt event wait failed. {}", get_last_error_or_default(GetLastError())); + break; + } if (!g_running) { break; } + if (dwEvt == WAIT_OBJECT_0 + handle_count) + { + if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + continue; + } switch (dwEvt) { case WAIT_OBJECT_0: @@ -7630,19 +10198,28 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance case WAIT_OBJECT_0 + 1: { // Exit Event - Logger::trace(L"Received an exit event."); PostMessage(g_hWndMain, WM_QUIT, 0, 0); break; } case WAIT_OBJECT_0 + 2: - if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) - { - TranslateMessage(&msg); - DispatchMessageW(&msg); - } + ZoomIt_DispatchCommand(ZoomItCommand::Zoom); break; - default: + case WAIT_OBJECT_0 + 3: + ZoomIt_DispatchCommand(ZoomItCommand::Draw); break; + case WAIT_OBJECT_0 + 4: + ZoomIt_DispatchCommand(ZoomItCommand::Break); + break; + case WAIT_OBJECT_0 + 5: + ZoomIt_DispatchCommand(ZoomItCommand::LiveZoom); + break; + case WAIT_OBJECT_0 + 6: + ZoomIt_DispatchCommand(ZoomItCommand::Snip); + break; + case WAIT_OBJECT_0 + 7: + ZoomIt_DispatchCommand(ZoomItCommand::Record); + break; + default: break; } } }); @@ -7672,6 +10249,12 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance SetEvent(m_reload_settings_event_handle); CloseHandle(m_reload_settings_event_handle); CloseHandle(m_exit_event_handle); + CloseHandle(m_zoom_event_handle); + CloseHandle(m_draw_event_handle); + CloseHandle(m_break_event_handle); + CloseHandle(m_live_zoom_event_handle); + CloseHandle(m_snip_event_handle); + CloseHandle(m_record_event_handle); m_event_triggers_thread.join(); } #endif // __ZOOMIT_POWERTOYS__ diff --git a/src/modules/ZoomIt/ZoomIt/Zoomit.exe.manifest b/src/modules/ZoomIt/ZoomIt/Zoomit.exe.manifest index 17abe5f212..f0fd786341 100644 --- a/src/modules/ZoomIt/ZoomIt/Zoomit.exe.manifest +++ b/src/modules/ZoomIt/ZoomIt/Zoomit.exe.manifest @@ -29,7 +29,7 @@ </trustInfo> <application> <windowsSettings> - <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">per monitor</dpiAware> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/ZoomIt/ZoomIt/packages.config b/src/modules/ZoomIt/ZoomIt/packages.config index 691158d1b2..51fbe043d6 100644 --- a/src/modules/ZoomIt/ZoomIt/packages.config +++ b/src/modules/ZoomIt/ZoomIt/packages.config @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> <package id="robmikh.common" version="0.0.23-beta" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomIt/pch.h b/src/modules/ZoomIt/ZoomIt/pch.h index 2afdc4e542..42d134704d 100644 --- a/src/modules/ZoomIt/ZoomIt/pch.h +++ b/src/modules/ZoomIt/ZoomIt/pch.h @@ -7,8 +7,10 @@ #include <shlobj.h> #include <tchar.h> #include <wincodec.h> +#include <shcore.h> #include <magnification.h> #include <Uxtheme.h> +#include <vssym32.h> #include <math.h> #include <shellapi.h> #include <shlwapi.h> @@ -41,12 +43,15 @@ #include <winrt/Windows.Graphics.DirectX.Direct3d11.h> #include <winrt/Windows.Media.h> #include <winrt/Windows.Media.Core.h> +#include <winrt/Windows.Media.Editing.h> +#include <winrt/Windows.Media.Playback.h> #include <winrt/Windows.Media.Transcoding.h> #include <winrt/Windows.Media.MediaProperties.h> #include <winrt/Windows.Media.Devices.h> #include <winrt/Windows.Storage.h> #include <winrt/Windows.Storage.Streams.h> #include <winrt/Windows.Storage.Pickers.h> +#include <winrt/Windows.Storage.FileProperties.h> #include <winrt/Windows.Devices.Enumeration.h> #include <filesystem> @@ -64,11 +69,15 @@ // WIL #include <wil/com.h> #include <wil/resource.h> +#include <wil/coroutine.h> // DirectX #include <d3d11_4.h> #include <dxgi1_6.h> #include <d2d1_3.h> +#include <mfapi.h> +#include <mfidl.h> +#include <mfreadwrite.h> // STL diff --git a/src/modules/ZoomIt/ZoomIt/resource.h b/src/modules/ZoomIt/ZoomIt/resource.h index c4c0cfad87..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 @@ -93,8 +94,23 @@ #define IDC_DEMOTYPE_SLIDER2 1074 #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 @@ -107,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 1076 +#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..021759297d 100644 --- a/src/modules/ZoomIt/ZoomItModuleInterface/ZoomItModuleInterface.vcxproj +++ b/src/modules/ZoomIt/ZoomItModuleInterface/ZoomItModuleInterface.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>17.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> @@ -8,17 +9,16 @@ <RootNamespace>ZoomItModuleInterface</RootNamespace> <ProjectName>ZoomItModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,12 +31,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.ZoomItModuleInterface</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\.;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -90,24 +90,24 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomItModuleInterface/dllmain.cpp b/src/modules/ZoomIt/ZoomItModuleInterface/dllmain.cpp index eea809a0a2..40158ed68f 100644 --- a/src/modules/ZoomIt/ZoomItModuleInterface/dllmain.cpp +++ b/src/modules/ZoomIt/ZoomItModuleInterface/dllmain.cpp @@ -8,6 +8,7 @@ #include <common/utils/logger_helper.h> #include <common/utils/resources.h> #include <common/utils/winapi_error.h> +#include <common/interop/shared_constants.h> #include <shellapi.h> #include <common/interop/shared_constants.h> diff --git a/src/modules/ZoomIt/ZoomItModuleInterface/packages.config b/src/modules/ZoomIt/ZoomItModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/ZoomIt/ZoomItModuleInterface/packages.config +++ b/src/modules/ZoomIt/ZoomItModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettings.cpp b/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettings.cpp index 3eb3e235da..25d9678ef2 100644 --- a/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettings.cpp +++ b/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettings.cpp @@ -14,6 +14,9 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation const unsigned int SPECIAL_SEMANTICS_SHORTCUT = 1; const unsigned int SPECIAL_SEMANTICS_COLOR = 2; const unsigned int SPECIAL_SEMANTICS_LOG_FONT = 3; + const unsigned int SPECIAL_SEMANTICS_RECORDING_FORMAT = 4; + const unsigned int SPECIAL_SEMANTICS_RECORD_SCALING_GIF = 5; + const unsigned int SPECIAL_SEMANTICS_RECORD_SCALING_MP4 = 6; std::vector<unsigned char> base64_decode(const std::wstring& base64_string) { @@ -72,6 +75,9 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation { L"PenColor", SPECIAL_SEMANTICS_COLOR }, { L"BreakPenColor", SPECIAL_SEMANTICS_COLOR }, { L"Font", SPECIAL_SEMANTICS_LOG_FONT }, + { L"RecordingFormat", SPECIAL_SEMANTICS_RECORDING_FORMAT }, + { L"RecordScalingGIF", SPECIAL_SEMANTICS_RECORD_SCALING_GIF }, + { L"RecordScalingMP4", SPECIAL_SEMANTICS_RECORD_SCALING_MP4 }, }; hstring ZoomItSettings::LoadSettingsJson() @@ -103,6 +109,11 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation value & 0xFF); _settings.add_property(curSetting->ValueName, hotkey.get_json()); } + else if (special_semantics->second == SPECIAL_SEMANTICS_RECORDING_FORMAT) + { + std::wstring formatString = (value == 0) ? L"GIF" : L"MP4"; + _settings.add_property(L"RecordFormat", formatString); + } else if (special_semantics->second == SPECIAL_SEMANTICS_COLOR) { /* PowerToys settings likes colors as #FFFFFF strings. @@ -156,6 +167,9 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation curSetting++; } + DWORD recordScaling = (g_RecordingFormat == static_cast<RecordingFormat>(0)) ? g_RecordScalingGIF : g_RecordScalingMP4; + _settings.add_property<DWORD>(L"RecordScaling", recordScaling); + return _settings.get_raw_json().Stringify(); } @@ -167,6 +181,8 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation PowerToysSettings::PowerToyValues valuesFromSettings = PowerToysSettings::PowerToyValues::from_json_string(json, L"ZoomIt"); + bool formatChanged = false; + PREG_SETTING curSetting = RegSettings; while (curSetting->ValueName) { @@ -212,6 +228,42 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation *static_cast<PDWORD>(curSetting->Setting) = value; } } + else if (special_semantics->second == SPECIAL_SEMANTICS_RECORDING_FORMAT) + { + // Convert string ("GIF" or "MP4") to DWORD enum value (0=GIF, 1=MP4) + auto possibleValue = valuesFromSettings.get_string_value(L"RecordFormat"); + if (possibleValue.has_value()) + { + RecordingFormat oldFormat = g_RecordingFormat; + DWORD formatValue = (possibleValue.value() == L"GIF") ? 0 : 1; + RecordingFormat newFormat = static_cast<RecordingFormat>(formatValue); + + *static_cast<PDWORD>(curSetting->Setting) = formatValue; + + if (oldFormat != newFormat) + { + formatChanged = true; + + if (oldFormat == static_cast<RecordingFormat>(0)) + { + g_RecordScalingGIF = g_RecordScaling; + } + else + { + g_RecordScalingMP4 = g_RecordScaling; + } + + if (newFormat == static_cast<RecordingFormat>(0)) + { + g_RecordScaling = g_RecordScalingGIF; + } + else + { + g_RecordScaling = g_RecordScalingMP4; + } + } + } + } else if (special_semantics->second == SPECIAL_SEMANTICS_COLOR) { /* PowerToys settings likes colors as #FFFFFF strings. @@ -275,6 +327,22 @@ namespace winrt::PowerToys::ZoomItSettingsInterop::implementation } curSetting++; } + + auto recordScalingValue = valuesFromSettings.get_uint_value(L"RecordScaling"); + if (recordScalingValue.has_value() && !formatChanged) + { + g_RecordScaling = recordScalingValue.value(); + + if (g_RecordingFormat == static_cast<RecordingFormat>(0)) + { + g_RecordScalingGIF = recordScalingValue.value(); + } + else + { + g_RecordScalingMP4 = recordScalingValue.value(); + } + } + reg.WriteRegSettings(RegSettings); } } diff --git a/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettingsInterop.vcxproj b/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettingsInterop.vcxproj index 21998a40da..4c30a44134 100644 --- a/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettingsInterop.vcxproj +++ b/src/modules/ZoomIt/ZoomItSettingsInterop/ZoomItSettingsInterop.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -16,10 +17,9 @@ <ApplicationType>Windows Store</ApplicationType> <ApplicationTypeRevision>10.0</ApplicationTypeRevision> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <GenerateManifest>false</GenerateManifest> </PropertyGroup> @@ -46,7 +46,7 @@ <PropertyGroup Label="UserMacros" /> <PropertyGroup> <TargetName>PowerToys.ZoomItSettingsInterop</TargetName> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> @@ -105,10 +105,10 @@ <None Include="PropertySheet.props" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -116,17 +116,17 @@ <ResourceCompile Include="ZoomItSettingsInterop.rc" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomItSettingsInterop/packages.config b/src/modules/ZoomIt/ZoomItSettingsInterop/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/ZoomIt/ZoomItSettingsInterop/packages.config +++ b/src/modules/ZoomIt/ZoomItSettingsInterop/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp index 8287ee1cce..f09cc2997f 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp @@ -153,9 +153,21 @@ LRESULT AlwaysOnTop::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lp { if (message == WM_HOTKEY) { + int hotkeyId = static_cast<int>(wparam); if (HWND fw{ GetForegroundWindow() }) { - ProcessCommand(fw); + if (hotkeyId == static_cast<int>(HotkeyId::Pin)) + { + ProcessCommand(fw); + } + else if (hotkeyId == static_cast<int>(HotkeyId::IncreaseOpacity)) + { + StepWindowTransparency(fw, Settings::transparencyStep); + } + else if (hotkeyId == static_cast<int>(HotkeyId::DecreaseOpacity)) + { + StepWindowTransparency(fw, -Settings::transparencyStep); + } } } else if (message == WM_PRIV_SETTINGS_CHANGED) @@ -191,6 +203,10 @@ void AlwaysOnTop::ProcessCommand(HWND window) m_topmostWindows.erase(iter); } + // Restore transparency when unpinning + RestoreWindowAlpha(window); + m_windowOriginalLayeredState.erase(window); + Trace::AlwaysOnTop::UnpinWindow(); } } @@ -200,6 +216,7 @@ void AlwaysOnTop::ProcessCommand(HWND window) { soundType = Sound::Type::On; AssignBorder(window); + Trace::AlwaysOnTop::PinWindow(); } } @@ -269,11 +286,22 @@ void AlwaysOnTop::RegisterHotkey() const { if (m_useCentralizedLLKH) { + // All hotkeys are handled by centralized LLKH return; } + // Register hotkeys only when not using centralized LLKH UnregisterHotKey(m_window, static_cast<int>(HotkeyId::Pin)); + UnregisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity)); + UnregisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity)); + + // Register pin hotkey RegisterHotKey(m_window, static_cast<int>(HotkeyId::Pin), AlwaysOnTopSettings::settings().hotkey.get_modifiers(), AlwaysOnTopSettings::settings().hotkey.get_code()); + + // Register transparency hotkeys using the same modifiers as the pin hotkey + UINT modifiers = AlwaysOnTopSettings::settings().hotkey.get_modifiers(); + RegisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity), modifiers, VK_OEM_PLUS); + RegisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity), modifiers, VK_OEM_MINUS); } void AlwaysOnTop::RegisterLLKH() @@ -285,6 +313,8 @@ void AlwaysOnTop::RegisterLLKH() m_hPinEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT); m_hTerminateEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_TERMINATE_EVENT); + m_hIncreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT); + m_hDecreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT); if (!m_hPinEvent) { @@ -298,30 +328,54 @@ void AlwaysOnTop::RegisterLLKH() return; } - HANDLE handles[2] = { m_hPinEvent, - m_hTerminateEvent }; + if (!m_hIncreaseOpacityEvent) + { + Logger::warn(L"Failed to create increaseOpacityEvent. {}", get_last_error_or_default(GetLastError())); + } + + if (!m_hDecreaseOpacityEvent) + { + Logger::warn(L"Failed to create decreaseOpacityEvent. {}", get_last_error_or_default(GetLastError())); + } + + HANDLE handles[4] = { m_hPinEvent, + m_hTerminateEvent, + m_hIncreaseOpacityEvent, + m_hDecreaseOpacityEvent }; m_thread = std::thread([this, handles]() { MSG msg; while (m_running) { - DWORD dwEvt = MsgWaitForMultipleObjects(2, handles, false, INFINITE, QS_ALLINPUT); + DWORD dwEvt = MsgWaitForMultipleObjects(4, handles, false, INFINITE, QS_ALLINPUT); if (!m_running) { break; } switch (dwEvt) { - case WAIT_OBJECT_0: + case WAIT_OBJECT_0: // Pin event if (HWND fw{ GetForegroundWindow() }) { ProcessCommand(fw); } break; - case WAIT_OBJECT_0 + 1: + case WAIT_OBJECT_0 + 1: // Terminate event PostThreadMessage(m_mainThreadId, WM_QUIT, 0, 0); break; - case WAIT_OBJECT_0 + 2: + case WAIT_OBJECT_0 + 2: // Increase opacity event + if (HWND fw{ GetForegroundWindow() }) + { + StepWindowTransparency(fw, Settings::transparencyStep); + } + break; + case WAIT_OBJECT_0 + 3: // Decrease opacity event + if (HWND fw{ GetForegroundWindow() }) + { + StepWindowTransparency(fw, -Settings::transparencyStep); + } + break; + case WAIT_OBJECT_0 + 4: // Message queue if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); @@ -370,9 +424,12 @@ void AlwaysOnTop::UnpinAll() { Logger::error(L"Unpinning topmost window failed"); } + // Restore transparency when unpinning all + RestoreWindowAlpha(topWindow); } m_topmostWindows.clear(); + m_windowOriginalLayeredState.clear(); } void AlwaysOnTop::CleanUp() @@ -456,6 +513,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept for (const auto window : toErase) { m_topmostWindows.erase(window); + m_windowOriginalLayeredState.erase(window); } switch (data->event) @@ -556,4 +614,166 @@ void AlwaysOnTop::RefreshBorders() } } } +} + +HWND AlwaysOnTop::ResolveTransparencyTargetWindow(HWND window) +{ + if (!window || !IsWindow(window)) + { + return nullptr; + } + + // Only allow transparency changes on pinned windows + if (!IsPinned(window)) + { + return nullptr; + } + + return window; +} + + +void AlwaysOnTop::StepWindowTransparency(HWND window, int delta) +{ + HWND targetWindow = ResolveTransparencyTargetWindow(window); + if (!targetWindow) + { + return; + } + + int currentTransparency = Settings::maxTransparencyPercentage; + LONG exStyle = GetWindowLong(targetWindow, GWL_EXSTYLE); + if (exStyle & WS_EX_LAYERED) + { + BYTE alpha = 255; + if (GetLayeredWindowAttributes(targetWindow, nullptr, &alpha, nullptr)) + { + currentTransparency = (alpha * 100) / 255; + } + } + + int newTransparency = (std::max)(Settings::minTransparencyPercentage, + (std::min)(Settings::maxTransparencyPercentage, currentTransparency + delta)); + + if (newTransparency != currentTransparency) + { + ApplyWindowAlpha(targetWindow, newTransparency); + + if (AlwaysOnTopSettings::settings().enableSound) + { + m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity); + } + + Logger::debug(L"Transparency adjusted to {}%", newTransparency); + } +} + +void AlwaysOnTop::ApplyWindowAlpha(HWND window, int percentage) +{ + if (!window || !IsWindow(window)) + { + return; + } + + percentage = (std::max)(Settings::minTransparencyPercentage, + (std::min)(Settings::maxTransparencyPercentage, percentage)); + + 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<BYTE>((255 * percentage) / 100); + SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED); + SetLayeredWindowAttributes(window, 0, alphaValue, LWA_ALPHA); +} + +void AlwaysOnTop::RestoreWindowAlpha(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 0505c837a2..438eaa64c4 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.h +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.h @@ -10,6 +10,7 @@ #include <common/hooks/WinHookEvent.h> #include <common/notifications/NotificationUtil.h> +#include <common/utils/window.h> class AlwaysOnTop : public SettingsObserver { @@ -38,6 +39,8 @@ private: enum class HotkeyId : int { Pin = 1, + IncreaseOpacity = 2, + DecreaseOpacity = 3, }; static inline AlwaysOnTop* s_instance = nullptr; @@ -48,8 +51,20 @@ private: HWND m_window{ nullptr }; HINSTANCE m_hinstance; std::map<HWND, std::unique_ptr<WindowBorder>> m_topmostWindows{}; + + // Store original window layered state for proper restoration + struct WindowLayeredState { + bool hadLayeredStyle = false; + BYTE originalAlpha = 255; + bool usedColorKey = false; + COLORREF colorKey = 0; + }; + std::map<HWND, WindowLayeredState> m_windowOriginalLayeredState{}; + HANDLE m_hPinEvent; HANDLE m_hTerminateEvent; + HANDLE m_hIncreaseOpacityEvent; + HANDLE m_hDecreaseOpacityEvent; DWORD m_mainThreadId; std::thread m_thread; const bool m_useCentralizedLLKH; @@ -78,6 +93,12 @@ private: bool AssignBorder(HWND window); void RefreshBorders(); + // Transparency methods + 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; static void CALLBACK WinHookProc(HWINEVENTHOOK winEventHook, diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj index bf3e5c6851..8b24f90bf9 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj @@ -1,10 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <!-- Project configurations --> <!-- Props that should be disabled while building on CI server --> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h AlwaysOnTop.base.rc AlwaysOnTop.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h AlwaysOnTop.base.rc AlwaysOnTop.rc" /> </Target> <!-- C++ source compile-specific things for all configurations --> <ItemDefinitionGroup> @@ -13,7 +14,6 @@ <ConformanceMode>false</ConformanceMode> <TreatWarningAsError>true</TreatWarningAsError> <LanguageStandard>stdcpplatest</LanguageStandard> - <AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions> <PreprocessorDefinitions>_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> @@ -60,11 +60,10 @@ <!-- Props that are constant for both Debug and Release configurations --> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <UseDebugLibraries>true</UseDebugLibraries> <LinkIncremental>true</LinkIncremental> @@ -85,7 +84,7 @@ <PropertyGroup Label="UserMacros" /> <PropertyGroup> <TargetName>PowerToys.$(MSBuildProjectName)</TargetName> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> <ClCompile> @@ -93,7 +92,7 @@ <SDLCheck>true</SDLCheck> <PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>./../;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src\common;$(SolutionDir)src\;./;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>./../;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src\common;$(RepoRoot)src\;./;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -109,7 +108,7 @@ <SDLCheck>true</SDLCheck> <PreprocessorDefinitions>NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>./../;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src\common;$(SolutionDir)src\;./;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>./../;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src\common;$(RepoRoot)src\;./;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -158,19 +157,19 @@ <None Include="packages.config" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\notifications\notifications.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\notifications\notifications.vcxproj"> <Project>{1d5be09d-78c0-4fd7-af00-ae7c1af7c525}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> </ItemGroup> @@ -185,17 +184,17 @@ <Image Include="..\Assets\AlwaysOnTop.ico" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file 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 <atomic> #include <mmsystem.h> // 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<bool> isPlaying; }; \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.cpp b/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.cpp index 763a9770f7..258beda57d 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.cpp +++ b/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.cpp @@ -246,9 +246,17 @@ LRESULT WindowBorder::WndProc(UINT message, WPARAM wparam, LPARAM lparam) noexce case WM_ERASEBKGND: return TRUE; - // prevent from beeping if the border was clicked + // Prevent from beeping if the border was clicked case WM_SETCURSOR: + { + HCURSOR hCursor = LoadCursorW(nullptr, IDC_ARROW); + if (hCursor) + { + SetCursor(hCursor); + } + return TRUE; + } default: { diff --git a/src/modules/alwaysontop/AlwaysOnTop/packages.config b/src/modules/alwaysontop/AlwaysOnTop/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/alwaysontop/AlwaysOnTop/packages.config +++ b/src/modules/alwaysontop/AlwaysOnTop/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj index 5f63a0e628..4bb032431e 100644 --- a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}</ProjectGuid> @@ -8,10 +9,9 @@ <RootNamespace>alwaysontop</RootNamespace> <ProjectName>AlwaysOnTopModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -22,7 +22,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.AlwaysOnTopModuleInterface</TargetName> @@ -30,7 +30,7 @@ <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -51,10 +51,10 @@ </ClCompile> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -66,17 +66,17 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.211019.2\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.211019.2\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.211019.2\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.211019.2\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp index 84cf9ed949..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(); } @@ -258,16 +300,6 @@ private: { Logger::info("AlwaysOnTop settings are empty"); } - - if (!m_hotkey.key) - { - Logger::info("AlwaysOnTop is going to use default shortcut"); - m_hotkey.win = true; - m_hotkey.alt = false; - m_hotkey.shift = false; - m_hotkey.ctrl = true; - m_hotkey.key = 'T'; - } } bool is_process_running() @@ -302,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/alwaysontop/AlwaysOnTopModuleInterface/packages.config b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/packages.config +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/awake/Awake.ModuleServices/Awake.ModuleServices.csproj b/src/modules/awake/Awake.ModuleServices/Awake.ModuleServices.csproj new file mode 100644 index 0000000000..75be8c685d --- /dev/null +++ b/src/modules/awake/Awake.ModuleServices/Awake.ModuleServices.csproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" /> + <ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" /> + <ProjectReference Include="..\..\..\common\PowerToys.ModuleContracts\PowerToys.ModuleContracts.csproj" /> + <ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/awake/Awake.ModuleServices/AwakeService.cs b/src/modules/awake/Awake.ModuleServices/AwakeService.cs new file mode 100644 index 0000000000..0ce08fa0c0 --- /dev/null +++ b/src/modules/awake/Awake.ModuleServices/AwakeService.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Text.Json; +using Common.UI; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerToys.ModuleContracts; + +namespace Awake.ModuleServices; + +/// <summary> +/// Provides CLI-based Awake control for reuse across hosts. +/// </summary> +public sealed class AwakeService : ModuleServiceBase, IAwakeService +{ + public static AwakeService Instance { get; } = new(); + + public override string Key => SettingsDeepLink.SettingsWindow.Awake.ToString(); + + protected override SettingsDeepLink.SettingsWindow SettingsWindow => SettingsDeepLink.SettingsWindow.Awake; + + public override Task<OperationResult> LaunchAsync(CancellationToken cancellationToken = default) + { + // Default launch -> indefinite, honoring Awake's own settings for display behavior. + return SetIndefiniteAsync(cancellationToken); + } + + public AwakeState GetCurrentState() + { + var isRunning = IsAwakeProcessRunning(); + var settings = ReadSettings(); + + if (settings is null) + { + return new AwakeState(isRunning, AwakeStateMode.Passive, false, null, null); + } + + var mode = settings.Properties.Mode switch + { + AwakeMode.PASSIVE => AwakeStateMode.Passive, + AwakeMode.INDEFINITE => AwakeStateMode.Indefinite, + AwakeMode.TIMED => AwakeStateMode.Timed, + AwakeMode.EXPIRABLE => AwakeStateMode.Expirable, + _ => AwakeStateMode.Passive, + }; + + TimeSpan? duration = null; + DateTimeOffset? expiration = null; + + switch (mode) + { + case AwakeStateMode.Timed: + duration = TimeSpan.FromHours(settings.Properties.IntervalHours) + TimeSpan.FromMinutes(settings.Properties.IntervalMinutes); + break; + case AwakeStateMode.Expirable: + expiration = settings.Properties.ExpirationDateTime; + break; + } + + return new AwakeState(isRunning, mode, settings.Properties.KeepDisplayOn, duration, expiration); + } + + public Task<OperationResult> SetIndefiniteAsync(CancellationToken cancellationToken = default) + { + return UpdateSettingsAsync( + settings => + { + settings.Properties.Mode = AwakeMode.INDEFINITE; + }, + cancellationToken); + } + + public Task<OperationResult> SetTimedAsync(int minutes, CancellationToken cancellationToken = default) + { + if (minutes <= 0) + { + return Task.FromResult(OperationResult.Fail("Minutes must be greater than zero.")); + } + + return UpdateSettingsAsync( + settings => + { + settings.Properties.Mode = AwakeMode.TIMED; + settings.Properties.IntervalHours = (uint)(minutes / 60); + settings.Properties.IntervalMinutes = (uint)(minutes % 60); + }, + cancellationToken); + } + + public Task<OperationResult> SetOffAsync(CancellationToken cancellationToken = default) + { + return UpdateSettingsAsync( + settings => + { + settings.Properties.Mode = AwakeMode.PASSIVE; + }, + cancellationToken); + } + + private static Task<OperationResult> UpdateSettingsAsync(Action<AwakeSettings> mutateSettings, CancellationToken cancellationToken) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var settingsUtils = SettingsUtils.Default; + var settings = settingsUtils.GetSettingsOrDefault<AwakeSettings>(AwakeSettings.ModuleName); + + mutateSettings(settings); + + settingsUtils.SaveSettings(JsonSerializer.Serialize(settings, AwakeServiceJsonContext.Default.AwakeSettings), AwakeSettings.ModuleName); + return Task.FromResult(OperationResult.Ok()); + } + catch (OperationCanceledException) + { + return Task.FromResult(OperationResult.Fail("Awake update was cancelled.")); + } + catch (Exception ex) + { + return Task.FromResult(OperationResult.Fail($"Failed to update Awake settings: {ex.Message}")); + } + } + + private static bool IsAwakeProcessRunning() + { + try + { + return Process.GetProcessesByName("PowerToys.Awake").Length > 0; + } + catch (Exception ex) + { + Logger.LogError($"Failed to check Awake process status: {ex.Message}"); + return false; + } + } + + private static AwakeSettings? ReadSettings() + { + try + { + var settingsUtils = SettingsUtils.Default; + return settingsUtils.GetSettingsOrDefault<AwakeSettings>(AwakeSettings.ModuleName); + } + catch (Exception ex) + { + Logger.LogError($"Failed to read Awake settings: {ex.Message}"); + return null; + } + } +} diff --git a/src/modules/awake/Awake.ModuleServices/AwakeServiceJsonContext.cs b/src/modules/awake/Awake.ModuleServices/AwakeServiceJsonContext.cs new file mode 100644 index 0000000000..cfaeff5bed --- /dev/null +++ b/src/modules/awake/Awake.ModuleServices/AwakeServiceJsonContext.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; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace Awake.ModuleServices; + +[JsonSerializable(typeof(AwakeSettings))] +internal sealed partial class AwakeServiceJsonContext : JsonSerializerContext +{ +} diff --git a/src/modules/awake/Awake.ModuleServices/AwakeState.cs b/src/modules/awake/Awake.ModuleServices/AwakeState.cs new file mode 100644 index 0000000000..4a59291732 --- /dev/null +++ b/src/modules/awake/Awake.ModuleServices/AwakeState.cs @@ -0,0 +1,26 @@ +// 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 Awake.ModuleServices; + +/// <summary> +/// Represents the current state of the Awake module. +/// </summary> +/// <param name="IsRunning">Whether the Awake process is currently running.</param> +/// <param name="Mode">The current Awake mode (Passive, Indefinite, Timed, Expirable).</param> +/// <param name="KeepDisplayOn">Whether the display is kept on.</param> +/// <param name="Duration">For timed mode, the configured duration.</param> +/// <param name="Expiration">For expirable mode, the expiration date/time.</param> +public readonly record struct AwakeState(bool IsRunning, AwakeStateMode Mode, bool KeepDisplayOn, TimeSpan? Duration, DateTimeOffset? Expiration); + +/// <summary> +/// The mode of the Awake module. +/// </summary> +public enum AwakeStateMode +{ + Passive = 0, + Indefinite = 1, + Timed = 2, + Expirable = 3, +} diff --git a/src/modules/awake/Awake.ModuleServices/IAwakeService.cs b/src/modules/awake/Awake.ModuleServices/IAwakeService.cs new file mode 100644 index 0000000000..1e600f52fe --- /dev/null +++ b/src/modules/awake/Awake.ModuleServices/IAwakeService.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using PowerToys.ModuleContracts; + +namespace Awake.ModuleServices; + +public interface IAwakeService : IModuleService +{ + Task<OperationResult> SetIndefiniteAsync(CancellationToken cancellationToken = default); + + Task<OperationResult> SetTimedAsync(int minutes, CancellationToken cancellationToken = default); + + Task<OperationResult> SetOffAsync(CancellationToken cancellationToken = default); + + /// <summary> + /// Gets the current state of the Awake module. + /// </summary> + AwakeState GetCurrentState(); +} diff --git a/src/modules/awake/Awake/Awake.csproj b/src/modules/awake/Awake/Awake.csproj index 5199709a11..605e105964 100644 --- a/src/modules/awake/Awake/Awake.csproj +++ b/src/modules/awake/Awake/Awake.csproj @@ -1,11 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <Nullable>enable</Nullable> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> diff --git a/src/modules/awake/Awake/Core/Constants.cs b/src/modules/awake/Awake/Core/Constants.cs index d6864712ee..7e9981e45d 100644 --- a/src/modules/awake/Awake/Core/Constants.cs +++ b/src/modules/awake/Awake/Core/Constants.cs @@ -17,6 +17,6 @@ namespace Awake.Core // Format of the build ID is: CODENAME_MMDDYYYY, where MMDDYYYY // is representative of the date when the last change was made before // the pull request is issued. - internal const string BuildId = "TILLSON_11272024"; + internal const string BuildId = "DIDACT_01182026"; } } diff --git a/src/modules/awake/Awake/Core/ExtensionMethods.cs b/src/modules/awake/Awake/Core/ExtensionMethods.cs index 626b9c6443..b07d632420 100644 --- a/src/modules/awake/Awake/Core/ExtensionMethods.cs +++ b/src/modules/awake/Awake/Core/ExtensionMethods.cs @@ -22,14 +22,13 @@ namespace Awake.Core public static string ToHumanReadableString(this TimeSpan timeSpan) { - // Get days, hours, minutes, and seconds from the TimeSpan - int days = timeSpan.Days; - int hours = timeSpan.Hours; - int minutes = timeSpan.Minutes; - int seconds = timeSpan.Seconds; + // Format as H:MM:SS or M:SS depending on total hours + if (timeSpan.TotalHours >= 1) + { + return $"{(int)timeSpan.TotalHours}:{timeSpan.Minutes:D2}:{timeSpan.Seconds:D2}"; + } - // Format the string based on the presence of days, hours, minutes, and seconds - return $"{days:D2}{Properties.Resources.AWAKE_LABEL_DAYS} {hours:D2}{Properties.Resources.AWAKE_LABEL_HOURS} {minutes:D2}{Properties.Resources.AWAKE_LABEL_MINUTES} {seconds:D2}{Properties.Resources.AWAKE_LABEL_SECONDS}"; + return $"{timeSpan.Minutes}:{timeSpan.Seconds:D2}"; } } } diff --git a/src/modules/awake/Awake/Core/Manager.cs b/src/modules/awake/Awake/Core/Manager.cs index 4ce733dfc3..3849d43268 100644 --- a/src/modules/awake/Awake/Core/Manager.cs +++ b/src/modules/awake/Awake/Core/Manager.cs @@ -37,7 +37,7 @@ namespace Awake.Core internal static SettingsUtils? ModuleSettings { get; set; } - private static AwakeMode CurrentOperatingMode { get; set; } + internal static AwakeMode CurrentOperatingMode { get; private set; } private static bool IsDisplayOn { get; set; } @@ -49,16 +49,19 @@ namespace Awake.Core private static DateTimeOffset ExpireAt { get; set; } + private static readonly CompositeFormat AwakeMinute = CompositeFormat.Parse(Resources.AWAKE_MINUTE); private static readonly CompositeFormat AwakeMinutes = CompositeFormat.Parse(Resources.AWAKE_MINUTES); + private static readonly CompositeFormat AwakeHour = CompositeFormat.Parse(Resources.AWAKE_HOUR); private static readonly CompositeFormat AwakeHours = CompositeFormat.Parse(Resources.AWAKE_HOURS); private static readonly BlockingCollection<ExecutionState> _stateQueue; - private static CancellationTokenSource _tokenSource; + private static CancellationTokenSource _monitorTokenSource; + private static IDisposable? _timerSubscription; static Manager() { - _tokenSource = new CancellationTokenSource(); + _monitorTokenSource = new CancellationTokenSource(); _stateQueue = []; - ModuleSettings = new SettingsUtils(); + ModuleSettings = SettingsUtils.Default; } internal static void StartMonitor() @@ -66,18 +69,36 @@ namespace Awake.Core Thread monitorThread = new(() => { Thread.CurrentThread.IsBackground = false; - while (true) + try { - ExecutionState state = _stateQueue.Take(); + while (!_monitorTokenSource.Token.IsCancellationRequested) + { + ExecutionState state = _stateQueue.Take(_monitorTokenSource.Token); - Logger.LogInfo($"Setting state to {state}"); + Logger.LogInfo($"Setting state to {state}"); - SetAwakeState(state); + if (!SetAwakeState(state)) + { + Logger.LogError($"Failed to set execution state to {state}. Reverting to passive mode."); + CurrentOperatingMode = AwakeMode.PASSIVE; + SetModeShellIcon(); + } + } + } + catch (OperationCanceledException) + { + Logger.LogInfo("Monitor thread received cancellation signal. Exiting gracefully."); } }); monitorThread.Start(); } + internal static void StopMonitor() + { + _monitorTokenSource.Cancel(); + _monitorTokenSource.Dispose(); + } + internal static void SetConsoleControlHandler(ConsoleEventHandler handler, bool addHandler) { Bridge.SetConsoleCtrlHandler(handler, addHandler); @@ -108,8 +129,9 @@ namespace Awake.Core ExecutionState stateResult = Bridge.SetThreadExecutionState(state); return stateResult != 0; } - catch + catch (Exception ex) { + Logger.LogError($"Failed to set awake state to {state}: {ex.Message}"); return false; } } @@ -121,26 +143,34 @@ namespace Awake.Core : ExecutionState.ES_SYSTEM_REQUIRED | ExecutionState.ES_CONTINUOUS; } + /// <summary> + /// Re-applies the current awake state after a power event. + /// Called when WM_POWERBROADCAST indicates system wake or power source change. + /// </summary> + internal static void ReapplyAwakeState() + { + if (CurrentOperatingMode == AwakeMode.PASSIVE) + { + // No need to reapply in passive mode + return; + } + + Logger.LogInfo($"Power event received. Reapplying awake state for mode: {CurrentOperatingMode}"); + _stateQueue.Add(ComputeAwakeState(IsDisplayOn)); + } + internal static void CancelExistingThread() { - Logger.LogInfo("Ensuring the thread is properly cleaned up..."); + Logger.LogInfo("Canceling existing timer and resetting state..."); - // Reset the thread state and handle cancellation. + // Reset the thread state. _stateQueue.Add(ExecutionState.ES_CONTINUOUS); - if (_tokenSource != null) - { - _tokenSource.Cancel(); - _tokenSource.Dispose(); - } - else - { - Logger.LogWarning("Token source is null."); - } + // Dispose the timer subscription to stop any running timer. + _timerSubscription?.Dispose(); + _timerSubscription = null; - _tokenSource = new CancellationTokenSource(); - - Logger.LogInfo("New token source and thread token instantiated."); + Logger.LogInfo("Timer subscription disposed."); } internal static void SetModeShellIcon(bool forceAdd = false) @@ -151,25 +181,25 @@ namespace Awake.Core switch (CurrentOperatingMode) { case AwakeMode.INDEFINITE: - string processText = ProcessId == 0 + string pidLine = ProcessId == 0 ? string.Empty - : $" - {Resources.AWAKE_TRAY_TEXT_PID_BINDING}: {ProcessId}"; - iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_INDEFINITE}{processText}][{ScreenStateString}]"; + : $"\nPID: {ProcessId}"; + iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_TEXT_INDEFINITE}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}{pidLine}"; icon = TrayHelper.IndefiniteIcon; break; case AwakeMode.PASSIVE: - iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_OFF}]"; + iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_SCREEN_OFF}"; icon = TrayHelper.DisabledIcon; break; case AwakeMode.EXPIRABLE: - iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_EXPIRATION}][{ScreenStateString}][{ExpireAt:yyyy-MM-dd HH:mm:ss}]"; + iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_UNTIL} {ExpireAt:MMM d, h:mm tt}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}"; icon = TrayHelper.ExpirableIcon; break; case AwakeMode.TIMED: - iconText = $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_TIMED}][{ScreenStateString}]"; + iconText = $"{Constants.FullAppName}\n{Resources.AWAKE_TRAY_TEXT_TIMED}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}"; icon = TrayHelper.TimedIcon; break; } @@ -278,23 +308,8 @@ namespace Awake.Core TimeSpan remainingTime = expireAt - DateTimeOffset.Now; - Observable.Timer(remainingTime).Subscribe( - _ => - { - Logger.LogInfo("Completed expirable keep-awake."); - CancelExistingThread(); - - if (IsUsingPowerToysConfig) - { - SetPassiveKeepAwake(); - } - else - { - Logger.LogInfo("Exiting after expirable keep awake."); - CompleteExit(Environment.ExitCode); - } - }, - _tokenSource.Token); + _timerSubscription = Observable.Timer(remainingTime).Subscribe( + _ => HandleTimerCompletion("expirable")); } internal static void SetTimedKeepAwake(uint seconds, bool keepDisplayOn = true, [CallerMemberName] string callerName = "") @@ -312,6 +327,8 @@ namespace Awake.Core TimeSpan timeSpan = TimeSpan.FromSeconds(seconds); uint totalHours = (uint)timeSpan.TotalHours; + + // Round up partial minutes to prevent timer from expiring before intended duration uint remainingMinutes = (uint)Math.Ceiling(timeSpan.TotalMinutes % 60); bool settingsChanged = currentSettings.Properties.Mode != AwakeMode.TIMED || @@ -346,49 +363,45 @@ namespace Awake.Core SetModeShellIcon(); - ulong desiredDuration = (ulong)seconds * 1000; - ulong targetDuration = Math.Min(desiredDuration, uint.MaxValue - 1) / 1000; + var targetExpiryTime = DateTimeOffset.Now.AddSeconds(seconds); - if (desiredDuration > uint.MaxValue) - { - Logger.LogInfo($"The desired interval of {seconds} seconds ({desiredDuration}ms) exceeds the limit. Defaulting to maximum possible value: {targetDuration} seconds. Read more about existing limits in the official documentation: https://aka.ms/powertoys/awake"); - } - - IObservable<long> timerObservable = Observable.Timer(TimeSpan.FromSeconds(targetDuration)); - IObservable<long> intervalObservable = Observable.Interval(TimeSpan.FromSeconds(1)).TakeUntil(timerObservable); - IObservable<long> combinedObservable = Observable.CombineLatest(intervalObservable, timerObservable.StartWith(0), (elapsedSeconds, _) => elapsedSeconds + 1); - - combinedObservable.Subscribe( - elapsedSeconds => - { - TimeRemaining = (uint)targetDuration - (uint)elapsedSeconds; - if (TimeRemaining >= 0) + _timerSubscription = Observable.Interval(TimeSpan.FromSeconds(1)) + .Select(_ => targetExpiryTime - DateTimeOffset.Now) + .TakeWhile(remaining => remaining.TotalSeconds > 0) + .Subscribe( + remainingTimeSpan => { + TimeRemaining = (uint)remainingTimeSpan.TotalSeconds; + TrayHelper.SetShellIcon( TrayHelper.WindowHandle, - $"{Constants.FullAppName} [{Resources.AWAKE_TRAY_TEXT_TIMED}][{ScreenStateString}][{TimeSpan.FromSeconds(TimeRemaining).ToHumanReadableString()}]", + $"{Constants.FullAppName}\n{remainingTimeSpan.ToHumanReadableString()} {Resources.AWAKE_TRAY_REMAINING}\n{Resources.AWAKE_TRAY_DISPLAY}: {ScreenStateString}", TrayHelper.TimedIcon, TrayIconAction.Update); - } - }, - () => - { - Logger.LogInfo("Completed timed thread."); - CancelExistingThread(); + }, + () => HandleTimerCompletion("timed")); + } - if (IsUsingPowerToysConfig) - { - // If we're using PowerToys settings, we need to make sure that - // we just switch over the Passive Keep-Awake. - SetPassiveKeepAwake(); - } - else - { - Logger.LogInfo("Exiting after timed keep-awake."); - CompleteExit(Environment.ExitCode); - } - }, - _tokenSource.Token); + /// <summary> + /// Handles the common logic that should execute when a keep-awake timer completes. Resets + /// the application state to Passive if configured; otherwise it exits. + /// </summary> + private static void HandleTimerCompletion(string timerType) + { + Logger.LogInfo($"Completed {timerType} keep-awake."); + CancelExistingThread(); + + if (IsUsingPowerToysConfig) + { + // If running under PowerToys settings, just revert to the default Passive state. + SetPassiveKeepAwake(); + } + else + { + // If running as a standalone process, exit cleanly. + Logger.LogInfo($"Exiting after {timerType} keep-awake."); + CompleteExit(Environment.ExitCode); + } } /// <summary> @@ -399,6 +412,16 @@ namespace Awake.Core { SetPassiveKeepAwake(updateSettings: false); + // Stop the monitor thread gracefully + StopMonitor(); + + // Dispose the timer subscription + _timerSubscription?.Dispose(); + _timerSubscription = null; + + // Dispose tray icons + TrayHelper.DisposeIcons(); + if (TrayHelper.WindowHandle != IntPtr.Zero) { // Delete the icon. @@ -451,7 +474,7 @@ namespace Awake.Core Dictionary<string, uint> optionsList = new() { { string.Format(CultureInfo.InvariantCulture, AwakeMinutes, 30), 1800 }, - { string.Format(CultureInfo.InvariantCulture, AwakeHours, 1), 3600 }, + { string.Format(CultureInfo.InvariantCulture, AwakeHour, 1), 3600 }, { string.Format(CultureInfo.InvariantCulture, AwakeHours, 2), 7200 }, }; return optionsList; @@ -511,15 +534,21 @@ namespace Awake.Core AwakeSettings currentSettings = ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName) ?? new AwakeSettings(); currentSettings.Properties.KeepDisplayOn = !currentSettings.Properties.KeepDisplayOn; - // We want to make sure that if the display setting changes (e.g., through the tray) - // then we do not reset the counter from zero. Because the settings are only storing - // hours and minutes, we round up the minutes value up when changes occur. + // For TIMED mode: update state directly without restarting timer + // This preserves the existing timer Observable subscription and targetExpiryTime if (CurrentOperatingMode == AwakeMode.TIMED && TimeRemaining > 0) { - TimeSpan timeSpan = TimeSpan.FromSeconds(TimeRemaining); + // Update internal state + IsDisplayOn = currentSettings.Properties.KeepDisplayOn; - currentSettings.Properties.IntervalHours = (uint)timeSpan.TotalHours; - currentSettings.Properties.IntervalMinutes = (uint)Math.Ceiling(timeSpan.TotalMinutes % 60); + // Update execution state without canceling timer + _stateQueue.Add(ComputeAwakeState(IsDisplayOn)); + + // Save settings - ProcessSettings will skip reinitialization + // since we're already in TIMED mode + ModuleSettings!.SaveSettings(JsonSerializer.Serialize(currentSettings), Constants.AppName); + + return; } ModuleSettings!.SaveSettings(JsonSerializer.Serialize(currentSettings), Constants.AppName); diff --git a/src/modules/awake/Awake/Core/Native/Bridge.cs b/src/modules/awake/Awake/Core/Native/Bridge.cs index 44812a4ef7..cfacd83180 100644 --- a/src/modules/awake/Awake/Core/Native/Bridge.cs +++ b/src/modules/awake/Awake/Core/Native/Bridge.cs @@ -28,6 +28,13 @@ namespace Awake.Core.Native [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool AllocConsole(); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool AttachConsole(int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern void FreeConsole(); + [DllImport("kernel32.dll", SetLastError = true)] internal static extern bool SetStdHandle(int nStdHandle, IntPtr hHandle); @@ -88,9 +95,6 @@ namespace Awake.Core.Native [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool GetCursorPos(out Point lpPoint); - [DllImport("user32.dll", SetLastError = true)] - internal static extern bool ScreenToClient(IntPtr hWnd, ref Point lpPoint); - [DllImport("user32.dll", SetLastError = true)] internal static extern bool GetMessage(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); diff --git a/src/modules/awake/Awake/Core/Native/Constants.cs b/src/modules/awake/Awake/Core/Native/Constants.cs index 8598854ba2..781a2413a1 100644 --- a/src/modules/awake/Awake/Core/Native/Constants.cs +++ b/src/modules/awake/Awake/Core/Native/Constants.cs @@ -15,6 +15,12 @@ namespace Awake.Core.Native internal const int WM_DESTROY = 0x0002; internal const int WM_LBUTTONDOWN = 0x0201; internal const int WM_RBUTTONDOWN = 0x0204; + internal const uint WM_POWERBROADCAST = 0x0218; + + // Power Broadcast Event Types + internal const int PBT_APMRESUMEAUTOMATIC = 0x0012; + internal const int PBT_APMRESUMESUSPEND = 0x0007; + internal const int PBT_APMPOWERSTATUSCHANGE = 0x000A; // Menu Flags internal const uint MF_BYPOSITION = 1024; @@ -49,5 +55,8 @@ namespace Awake.Core.Native // Menu Item Info Flags internal const uint MNS_AUTO_DISMISS = 0x10000000; internal const uint MIM_STYLE = 0x00000010; + + // Attach Console + internal const int ATTACH_PARENT_PROCESS = -1; } } diff --git a/src/modules/awake/Awake/Core/Threading/SingleThreadSynchronizationContext.cs b/src/modules/awake/Awake/Core/Threading/SingleThreadSynchronizationContext.cs index 04c28dfd34..63aa9cbc12 100644 --- a/src/modules/awake/Awake/Core/Threading/SingleThreadSynchronizationContext.cs +++ b/src/modules/awake/Awake/Core/Threading/SingleThreadSynchronizationContext.cs @@ -11,7 +11,7 @@ namespace Awake.Core.Threading { internal sealed class SingleThreadSynchronizationContext : SynchronizationContext { - private readonly Queue<Tuple<SendOrPostCallback, object?>?> queue = new(); + private readonly Queue<(SendOrPostCallback Callback, object? State)?> queue = new(); public override void Post(SendOrPostCallback d, object? state) { @@ -19,7 +19,7 @@ namespace Awake.Core.Threading lock (queue) { - queue.Enqueue(Tuple.Create(d, state)); + queue.Enqueue((d, state)); Monitor.Pulse(queue); } } @@ -28,7 +28,7 @@ namespace Awake.Core.Threading { while (true) { - Tuple<SendOrPostCallback, object?>? work; + (SendOrPostCallback Callback, object? State)? work; lock (queue) { while (queue.Count == 0) @@ -46,7 +46,7 @@ namespace Awake.Core.Threading try { - work.Item1(work.Item2); + work.Value.Callback(work.Value.State); } catch (Exception e) { diff --git a/src/modules/awake/Awake/Core/TrayHelper.cs b/src/modules/awake/Awake/Core/TrayHelper.cs index 37e2de8e48..142bb78720 100644 --- a/src/modules/awake/Awake/Core/TrayHelper.cs +++ b/src/modules/awake/Awake/Core/TrayHelper.cs @@ -45,12 +45,26 @@ namespace Awake.Core internal static readonly Icon IndefiniteIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/indefinite.ico")); internal static readonly Icon DisabledIcon = new(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Assets/Awake/disabled.ico")); + private const int TrayIconId = 1000; + static TrayHelper() { TrayMenu = IntPtr.Zero; WindowHandle = IntPtr.Zero; } + /// <summary> + /// Disposes of all icon resources to prevent GDI handle leaks. + /// </summary> + internal static void DisposeIcons() + { + DefaultAwakeIcon?.Dispose(); + TimedIcon?.Dispose(); + ExpirableIcon?.Dispose(); + IndefiniteIcon?.Dispose(); + DisabledIcon?.Dispose(); + } + private static void ShowContextMenu(IntPtr hWnd) { if (TrayMenu == IntPtr.Zero) @@ -61,9 +75,8 @@ namespace Awake.Core Bridge.SetForegroundWindow(hWnd); - // Get cursor position and convert it to client coordinates + // Get cursor position in screen coordinates Bridge.GetCursorPos(out Models.Point cursorPos); - Bridge.ScreenToClient(hWnd, ref cursorPos); // Set menu information MenuInfo menuInfo = new() @@ -173,7 +186,11 @@ namespace Awake.Core internal static void SetShellIcon(IntPtr hWnd, string text, Icon? icon, TrayIconAction action = TrayIconAction.Add, [CallerMemberName] string callerName = "") { - if (hWnd != IntPtr.Zero && icon != null) + // For Delete operations, we don't need an icon - only hWnd is required + // For Add/Update operations, we need both hWnd and icon + bool canProceed = hWnd != IntPtr.Zero && (action == TrayIconAction.Delete || icon != null); + + if (canProceed) { int message = Native.Constants.NIM_ADD; @@ -196,7 +213,7 @@ namespace Awake.Core { CbSize = Marshal.SizeOf<NotifyIconData>(), HWnd = hWnd, - UId = 1000, + UId = TrayIconId, UFlags = Native.Constants.NIF_ICON | Native.Constants.NIF_TIP | Native.Constants.NIF_MESSAGE, UCallbackMessage = (int)Native.Constants.WM_USER, HIcon = icon?.Handle ?? IntPtr.Zero, @@ -209,29 +226,54 @@ namespace Awake.Core { CbSize = Marshal.SizeOf<NotifyIconData>(), HWnd = hWnd, - UId = 1000, + UId = TrayIconId, UFlags = 0, }; } - for (int attempt = 1; attempt <= 3; attempt++) + // Retry configuration based on action type + // Add operations need longer delays as Explorer may still be initializing after Windows updates + int maxRetryAttempts; + int baseDelayMs; + + if (action == TrayIconAction.Add) + { + maxRetryAttempts = 10; + baseDelayMs = 500; // 500, 1000, 2000, 2000, 2000... (capped) + } + else + { + maxRetryAttempts = 3; + baseDelayMs = 100; // 100, 200, 400 (existing behavior) + } + + const int maxDelayMs = 2000; // Cap delay at 2 seconds + + for (int attempt = 1; attempt <= maxRetryAttempts; attempt++) { if (Bridge.Shell_NotifyIcon(message, ref _notifyIconData)) { + if (attempt > 1) + { + Logger.LogInfo($"Successfully set shell icon on attempt {attempt}. Action: {action}"); + } + break; } else { int errorCode = Marshal.GetLastWin32Error(); - Logger.LogInfo($"Could not set the shell icon. Action: {action}, error code: {errorCode}. HIcon handle is {icon?.Handle} and HWnd is {hWnd}. Invoked by {callerName}."); + Logger.LogInfo($"Could not set the shell icon. Action: {action}, error code: {errorCode}, attempt: {attempt}/{maxRetryAttempts}. HIcon handle is {icon?.Handle} and HWnd is {hWnd}. Invoked by {callerName}."); - if (attempt == 3) + if (attempt == maxRetryAttempts) { - Logger.LogError($"Failed to change tray icon after 3 attempts. Action: {action} and error code: {errorCode}. Invoked by {callerName}."); + Logger.LogError($"Failed to change tray icon after {maxRetryAttempts} attempts. Action: {action} and error code: {errorCode}. Invoked by {callerName}."); break; } - Thread.Sleep(100); + // Exponential backoff with cap + int delayMs = Math.Min(baseDelayMs * (1 << (attempt - 1)), maxDelayMs); + Thread.Sleep(delayMs); } } @@ -242,7 +284,7 @@ namespace Awake.Core } else { - Logger.LogInfo($"Cannot set the shell icon - parent window handle is zero or icon is not available. Text: {text} Action: {action}"); + Logger.LogInfo($"Cannot set the shell icon - parent window handle is zero{(action != TrayIconAction.Delete && icon == null ? " or icon is not available" : string.Empty)}. Text: {text} Action: {action}"); } } @@ -281,11 +323,9 @@ namespace Awake.Core Bridge.PostQuitMessage(0); break; case Native.Constants.WM_COMMAND: - int trayCommandsSize = Enum.GetNames<TrayCommands>().Length; + long targetCommandValue = wParam.ToInt64() & 0xFFFF; - long targetCommandIndex = wParam.ToInt64() & 0xFFFF; - - switch (targetCommandIndex) + switch (targetCommandValue) { case (uint)TrayCommands.TC_EXIT: { @@ -301,7 +341,7 @@ namespace Awake.Core case (uint)TrayCommands.TC_MODE_INDEFINITE: { - AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName); + AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName) ?? new AwakeSettings(); Manager.SetIndefiniteKeepAwake(keepDisplayOn: settings.Properties.KeepDisplayOn); break; } @@ -314,23 +354,43 @@ namespace Awake.Core default: { - if (targetCommandIndex >= trayCommandsSize) + // Custom tray time commands start at TC_TIME and increment by 1 for each entry. + // Check if this command falls within the custom time range. + if (targetCommandValue >= (uint)TrayCommands.TC_TIME) { - AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName); + AwakeSettings settings = Manager.ModuleSettings!.GetSettings<AwakeSettings>(Constants.AppName) ?? new AwakeSettings(); if (settings.Properties.CustomTrayTimes.Count == 0) { settings.Properties.CustomTrayTimes.AddRange(Manager.GetDefaultTrayOptions()); } - int index = (int)targetCommandIndex - (int)TrayCommands.TC_TIME; - uint targetTime = settings.Properties.CustomTrayTimes.ElementAt(index).Value; - Manager.SetTimedKeepAwake(targetTime, keepDisplayOn: settings.Properties.KeepDisplayOn); + int index = (int)targetCommandValue - (int)TrayCommands.TC_TIME; + + if (index >= 0 && index < settings.Properties.CustomTrayTimes.Count) + { + uint targetTime = settings.Properties.CustomTrayTimes.Values.Skip(index).First(); + Manager.SetTimedKeepAwake(targetTime, keepDisplayOn: settings.Properties.KeepDisplayOn); + } + else + { + Logger.LogError($"Custom tray time index {index} is out of range. Available entries: {settings.Properties.CustomTrayTimes.Count}"); + } } break; } } + break; + case Native.Constants.WM_POWERBROADCAST: + int eventType = wParam.ToInt32(); + if (eventType == Native.Constants.PBT_APMRESUMEAUTOMATIC || + eventType == Native.Constants.PBT_APMRESUMESUSPEND || + eventType == Native.Constants.PBT_APMPOWERSTATUSCHANGE) + { + Manager.ReapplyAwakeState(); + } + break; default: if (message == _taskbarCreatedMessage) @@ -358,7 +418,7 @@ namespace Awake.Core } catch (Exception e) { - Console.WriteLine("Error: " + e.Message); + Logger.LogError($"Error in tray thread execution: {e.Message}"); } }, null); @@ -440,9 +500,11 @@ namespace Awake.Core private static void CreateAwakeTimeSubMenu(Dictionary<string, uint> trayTimeShortcuts, bool isChecked = false) { nint awakeTimeMenu = Bridge.CreatePopupMenu(); - for (int i = 0; i < trayTimeShortcuts.Count; i++) + int i = 0; + foreach (var shortcut in trayTimeShortcuts) { - Bridge.InsertMenu(awakeTimeMenu, (uint)i, Native.Constants.MF_BYPOSITION | Native.Constants.MF_STRING, (uint)TrayCommands.TC_TIME + (uint)i, trayTimeShortcuts.ElementAt(i).Key); + Bridge.InsertMenu(awakeTimeMenu, (uint)i, Native.Constants.MF_BYPOSITION | Native.Constants.MF_STRING, (uint)TrayCommands.TC_TIME + (uint)i, shortcut.Key); + i++; } Bridge.InsertMenu(TrayMenu, 0, Native.Constants.MF_BYPOSITION | Native.Constants.MF_POPUP | (isChecked ? Native.Constants.MF_CHECKED : Native.Constants.MF_UNCHECKED), (uint)awakeTimeMenu, Resources.AWAKE_KEEP_ON_INTERVAL); diff --git a/src/modules/awake/Awake/Program.cs b/src/modules/awake/Awake/Program.cs index 23882b3018..6b65e9eea3 100644 --- a/src/modules/awake/Awake/Program.cs +++ b/src/modules/awake/Awake/Program.cs @@ -39,24 +39,42 @@ namespace Awake private static FileSystemWatcher? _watcher; private static SettingsUtils? _settingsUtils; + private static EventWaitHandle? _exitEventHandle; + private static RegisteredWaitHandle? _registeredWaitHandle; private static bool _startedFromPowerToys; public static Mutex? LockMutex { get; set; } -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - private static ConsoleEventHandler _handler; + private static ConsoleEventHandler? _handler; private static SystemPowerCapabilities _powerCapabilities; -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. private static async Task<int> Main(string[] args) { - _settingsUtils = new SettingsUtils(); + Logger.InitializeLogger(Path.Combine("\\", Core.Constants.AppName, "Logs")); + + var rootCommand = BuildRootCommand(); + + Bridge.AttachConsole(Core.Native.Constants.ATTACH_PARENT_PROCESS); + + var parseResult = rootCommand.Parse(args); + + if (parseResult.Tokens.Any(t => t.Value.ToLowerInvariant() is "--help" or "-h" or "-?")) + { + // Print help and exit. + return rootCommand.Invoke(args); + } + + if (parseResult.Errors.Count > 0) + { + // Shows errors and returns non-zero. + return rootCommand.Invoke(args); + } + + _settingsUtils = SettingsUtils.Default; LockMutex = new Mutex(true, Core.Constants.AppName, out bool instantiated); - Logger.InitializeLogger(Path.Combine("\\", Core.Constants.AppName, "Logs")); - try { string appLanguage = LanguageHelper.LoadLanguage(); @@ -107,92 +125,97 @@ namespace Awake Bridge.GetPwrCapabilities(out _powerCapabilities); Logger.LogInfo(JsonSerializer.Serialize(_powerCapabilities, _serializerOptions)); - Logger.LogInfo("Parsing parameters..."); - - Option<bool> configOption = new(_aliasesConfigOption, () => false, Resources.AWAKE_CMD_HELP_CONFIG_OPTION) - { - Arity = ArgumentArity.ZeroOrOne, - IsRequired = false, - }; - - Option<bool> displayOption = new(_aliasesDisplayOption, () => true, Resources.AWAKE_CMD_HELP_DISPLAY_OPTION) - { - Arity = ArgumentArity.ZeroOrOne, - IsRequired = false, - }; - - Option<uint> timeOption = new(_aliasesTimeOption, () => 0, Resources.AWAKE_CMD_HELP_TIME_OPTION) - { - Arity = ArgumentArity.ExactlyOne, - IsRequired = false, - }; - - Option<int> pidOption = new(_aliasesPidOption, () => 0, Resources.AWAKE_CMD_HELP_PID_OPTION) - { - Arity = ArgumentArity.ZeroOrOne, - IsRequired = false, - }; - - Option<string> expireAtOption = new(_aliasesExpireAtOption, () => string.Empty, Resources.AWAKE_CMD_HELP_EXPIRE_AT_OPTION) - { - Arity = ArgumentArity.ZeroOrOne, - IsRequired = false, - }; - - Option<bool> parentPidOption = new(_aliasesParentPidOption, () => false, Resources.AWAKE_CMD_PARENT_PID_OPTION) - { - Arity = ArgumentArity.ZeroOrOne, - IsRequired = false, - }; - - timeOption.AddValidator(result => - { - if (result.Tokens.Count != 0 && !uint.TryParse(result.Tokens[0].Value, out _)) - { - string errorMessage = $"Interval in --time-limit could not be parsed correctly. Check that the value is valid and doesn't exceed 4,294,967,295. Value used: {result.Tokens[0].Value}."; - Logger.LogError(errorMessage); - result.ErrorMessage = errorMessage; - } - }); - - pidOption.AddValidator(result => - { - if (result.Tokens.Count != 0 && !int.TryParse(result.Tokens[0].Value, out _)) - { - string errorMessage = $"PID value in --pid could not be parsed correctly. Check that the value is valid and falls within the boundaries of Windows PID process limits. Value used: {result.Tokens[0].Value}."; - Logger.LogError(errorMessage); - result.ErrorMessage = errorMessage; - } - }); - - expireAtOption.AddValidator(result => - { - if (result.Tokens.Count != 0 && !DateTimeOffset.TryParse(result.Tokens[0].Value, out _)) - { - string errorMessage = $"Date and time value in --expire-at could not be parsed correctly. Check that the value is valid date and time. Refer to https://aka.ms/powertoys/awake for format examples. Value used: {result.Tokens[0].Value}."; - Logger.LogError(errorMessage); - result.ErrorMessage = errorMessage; - } - }); - - RootCommand? rootCommand = - [ - configOption, - displayOption, - timeOption, - pidOption, - expireAtOption, - parentPidOption, - ]; - - rootCommand.Description = Core.Constants.AppName; - rootCommand.SetHandler(HandleCommandLineArguments, configOption, displayOption, timeOption, pidOption, expireAtOption, parentPidOption); - - return rootCommand.InvokeAsync(args).Result; + return await rootCommand.InvokeAsync(args); } } } + private static RootCommand BuildRootCommand() + { + Logger.LogInfo("Parsing parameters..."); + + Option<bool> configOption = new(_aliasesConfigOption, () => false, Resources.AWAKE_CMD_HELP_CONFIG_OPTION) + { + Arity = ArgumentArity.ZeroOrOne, + IsRequired = false, + }; + + Option<bool> displayOption = new(_aliasesDisplayOption, () => false, Resources.AWAKE_CMD_HELP_DISPLAY_OPTION) + { + Arity = ArgumentArity.ZeroOrOne, + IsRequired = false, + }; + + Option<uint> timeOption = new(_aliasesTimeOption, () => 0, Resources.AWAKE_CMD_HELP_TIME_OPTION) + { + Arity = ArgumentArity.ExactlyOne, + IsRequired = false, + }; + + Option<int> pidOption = new(_aliasesPidOption, () => 0, Resources.AWAKE_CMD_HELP_PID_OPTION) + { + Arity = ArgumentArity.ZeroOrOne, + IsRequired = false, + }; + + Option<string> expireAtOption = new(_aliasesExpireAtOption, () => string.Empty, Resources.AWAKE_CMD_HELP_EXPIRE_AT_OPTION) + { + Arity = ArgumentArity.ZeroOrOne, + IsRequired = false, + }; + + Option<bool> parentPidOption = new(_aliasesParentPidOption, () => false, Resources.AWAKE_CMD_PARENT_PID_OPTION) + { + Arity = ArgumentArity.ZeroOrOne, + IsRequired = false, + }; + + timeOption.AddValidator(result => + { + if (result.Tokens.Count != 0 && !uint.TryParse(result.Tokens[0].Value, out _)) + { + string errorMessage = $"Interval in --time-limit could not be parsed correctly. Check that the value is valid and doesn't exceed 4,294,967,295. Value used: {result.Tokens[0].Value}."; + Logger.LogError(errorMessage); + result.ErrorMessage = errorMessage; + } + }); + + pidOption.AddValidator(result => + { + if (result.Tokens.Count != 0 && !int.TryParse(result.Tokens[0].Value, out _)) + { + string errorMessage = $"PID value in --pid could not be parsed correctly. Check that the value is valid and falls within the boundaries of Windows PID process limits. Value used: {result.Tokens[0].Value}."; + Logger.LogError(errorMessage); + result.ErrorMessage = errorMessage; + } + }); + + expireAtOption.AddValidator(result => + { + if (result.Tokens.Count != 0 && !DateTimeOffset.TryParse(result.Tokens[0].Value, out _)) + { + string errorMessage = $"Date and time value in --expire-at could not be parsed correctly. Check that the value is valid date and time. Refer to https://aka.ms/powertoys/awake for format examples. Value used: {result.Tokens[0].Value}."; + Logger.LogError(errorMessage); + result.ErrorMessage = errorMessage; + } + }); + + RootCommand? rootCommand = + [ + configOption, + displayOption, + timeOption, + pidOption, + expireAtOption, + parentPidOption, + ]; + + rootCommand.Description = Core.Constants.AppName; + rootCommand.SetHandler(HandleCommandLineArguments, configOption, displayOption, timeOption, pidOption, expireAtOption, parentPidOption); + + return rootCommand; + } + private static void AwakeUnhandledExceptionCatcher(object sender, UnhandledExceptionEventArgs e) { if (e.ExceptionObject is Exception exception) @@ -212,15 +235,55 @@ namespace Awake private static void Exit(string message, int exitCode) { _etwTrace?.Dispose(); + DisposeFileSystemWatcher(); + _registeredWaitHandle?.Unregister(null); + _exitEventHandle?.Dispose(); Logger.LogInfo(message); Manager.CompleteExit(exitCode); } + private static void DisposeFileSystemWatcher() + { + if (_watcher != null) + { + _watcher.EnableRaisingEvents = false; + _watcher.Dispose(); + _watcher = null; + } + } + + private static bool ProcessExists(int processId) + { + if (processId <= 0) + { + return false; + } + + try + { + // Throws if the Process ID is not found. + using var p = Process.GetProcessById(processId); + return !p.HasExited; + } + catch (ArgumentException) + { + // Process with the specified ID is not running + return false; + } + catch (InvalidOperationException ex) + { + // Process has exited or cannot be accessed + Logger.LogInfo($"Process {processId} cannot be accessed: {ex.Message}"); + return false; + } + } + private static void HandleCommandLineArguments(bool usePtConfig, bool displayOn, uint timeLimit, int pid, string expireAt, bool useParentPid) { if (pid == 0 && !useParentPid) { Logger.LogInfo("No PID specified. Allocating console..."); + Bridge.FreeConsole(); AllocateLocalConsole(); } else @@ -239,12 +302,13 @@ namespace Awake // Start the monitor thread that will be used to track the current state. Manager.StartMonitor(); - EventWaitHandle eventHandle = new(false, EventResetMode.ManualReset, PowerToys.Interop.Constants.AwakeExitEvent()); - new Thread(() => - { - WaitHandle.WaitAny([eventHandle]); - Exit(Resources.AWAKE_EXIT_SIGNAL_MESSAGE, 0); - }).Start(); + _exitEventHandle = new EventWaitHandle(false, EventResetMode.ManualReset, PowerToys.Interop.Constants.AwakeExitEvent()); + _registeredWaitHandle = ThreadPool.RegisterWaitForSingleObject( + _exitEventHandle, + (state, timedOut) => Exit(Resources.AWAKE_EXIT_SIGNAL_MESSAGE, 0), + null, + Timeout.Infinite, + executeOnlyOnce: true); if (usePtConfig) { @@ -271,6 +335,12 @@ namespace Awake if (pid != 0) { + if (!ProcessExists(pid)) + { + Logger.LogError($"PID {pid} does not exist or is not accessible. Exiting."); + Exit(Resources.AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE, 1); + } + Logger.LogInfo($"Bound to target process while also using PowerToys settings: {pid}"); RunnerHelper.WaitForPowerToysRunner(pid, () => @@ -287,28 +357,7 @@ namespace Awake } else if (pid != 0 || useParentPid) { - // Second, we snap to process-based execution. Because this is something that - // is snapped to a running entity, we only want to enable the ability to set - // indefinite keep-awake with the display settings that the user wants to set. - // In this context, manual (explicit) PID takes precedence over parent PID. - int targetPid = pid != 0 ? pid : useParentPid ? Manager.GetParentProcess()?.Id ?? 0 : 0; - - if (targetPid != 0) - { - Logger.LogInfo($"Bound to target process: {targetPid}"); - - Manager.SetIndefiniteKeepAwake(displayOn, targetPid); - - RunnerHelper.WaitForPowerToysRunner(targetPid, () => - { - Logger.LogInfo($"Triggered PID-based exit handler for PID {targetPid}."); - Exit(Resources.AWAKE_EXIT_BINDING_HOOK_MESSAGE, 0); - }); - } - else - { - Logger.LogError("Not binding to any process."); - } + HandleProcessScopedKeepAwake(pid, useParentPid, displayOn); } else { @@ -344,11 +393,67 @@ namespace Awake } } + /// <summary> + /// Start a process-scoped keep-awake session. The application will keep the system awake + /// indefinitely until the target process terminates. + /// </summary> + /// <param name="pid">The explicit process ID to monitor.</param> + /// <param name="useParentPid">A flag indicating whether the application should monitor its + /// parent process.</param> + /// <param name="displayOn">Whether to keep the display on during the session.</param> + private static void HandleProcessScopedKeepAwake(int pid, bool useParentPid, bool displayOn) + { + int targetPid = 0; + + // We prioritize a user-provided PID over the parent PID. If both are given on the + // command line, the --pid value will be used. + if (pid != 0) + { + if (pid == Environment.ProcessId) + { + Logger.LogError("Awake cannot bind to itself, as this would lead to an indefinite keep-awake state."); + Exit(Resources.AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE, 1); + } + + if (!ProcessExists(pid)) + { + Logger.LogError($"PID {pid} does not exist or is not accessible. Exiting."); + Exit(Resources.AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE, 1); + } + + targetPid = pid; + } + else if (useParentPid) + { + targetPid = Manager.GetParentProcess()?.Id ?? 0; + + if (targetPid == 0) + { + // The parent process could not be identified. + Logger.LogError("Failed to identify a parent process for binding."); + Exit(Resources.AWAKE_EXIT_PARENT_BINDING_FAILURE_MESSAGE, 1); + } + } + + // We have a valid non-zero PID to monitor. + Logger.LogInfo($"Bound to target process: {targetPid}"); + + // Sets the keep-awake plan and updates the tray icon. + Manager.SetIndefiniteKeepAwake(displayOn, targetPid); + + // Synchronize with the target process, and trigger Exit() when it finishes. + RunnerHelper.WaitForPowerToysRunner(targetPid, () => + { + Logger.LogInfo($"Triggered PID-based exit handler for PID {targetPid}."); + Exit(Resources.AWAKE_EXIT_BINDING_HOOK_MESSAGE, 0); + }); + } + private static void AllocateLocalConsole() { Manager.AllocateConsole(); - _handler += new ConsoleEventHandler(ExitHandler); + _handler = new ConsoleEventHandler(ExitHandler); Manager.SetConsoleControlHandler(_handler, true); Trace.Listeners.Add(new ConsoleTraceListener()); @@ -444,6 +549,11 @@ namespace Awake { settings.Properties.ExpirationDateTime = DateTimeOffset.Now.AddMinutes(5); _settingsUtils.SaveSettings(JsonSerializer.Serialize(settings), Core.Constants.AppName); + + // Return here - the FileSystemWatcher will re-trigger ProcessSettings + // with the corrected expiration time, which will then call SetExpirableKeepAwake. + // This matches the pattern used by mode setters (e.g., SetExpirableKeepAwake line 292). + return; } Manager.SetExpirableKeepAwake(settings.Properties.ExpirationDateTime, settings.Properties.KeepDisplayOn); diff --git a/src/modules/awake/Awake/Properties/Resources.Designer.cs b/src/modules/awake/Awake/Properties/Resources.Designer.cs index 1e2b941a6b..27d77ddc2c 100644 --- a/src/modules/awake/Awake/Properties/Resources.Designer.cs +++ b/src/modules/awake/Awake/Properties/Resources.Designer.cs @@ -60,15 +60,6 @@ namespace Awake.Properties { } } - /// <summary> - /// Looks up a localized string similar to Checked. - /// </summary> - internal static string AWAKE_CHECKED { - get { - return ResourceManager.GetString("AWAKE_CHECKED", resourceCulture); - } - } - /// <summary> /// Looks up a localized string similar to Specifies whether Awake will be using the PowerToys configuration file for managing the state.. /// </summary> @@ -132,6 +123,15 @@ namespace Awake.Properties { } } + /// <summary> + /// Looks up a localized string similar to Exiting because the provided process ID is Awake's own.. + /// </summary> + internal static string AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE { + get { + return ResourceManager.GetString("AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Terminating from process binding hook.. /// </summary> @@ -150,6 +150,24 @@ namespace Awake.Properties { } } + /// <summary> + /// Looks up a localized string similar to Exiting because the parent process ID could not be found.. + /// </summary> + internal static string AWAKE_EXIT_PARENT_BINDING_FAILURE_MESSAGE { + get { + return ResourceManager.GetString("AWAKE_EXIT_PARENT_BINDING_FAILURE_MESSAGE", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Exiting because the requested process ID could not be found.. + /// </summary> + internal static string AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE { + get { + return ResourceManager.GetString("AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Received a signal to end the process. Making sure we quit.... /// </summary> @@ -159,6 +177,15 @@ namespace Awake.Properties { } } + /// <summary> + /// Looks up a localized string similar to {0} hour. + /// </summary> + internal static string AWAKE_HOUR { + get { + return ResourceManager.GetString("AWAKE_HOUR", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to {0} hours. /// </summary> @@ -205,38 +232,11 @@ namespace Awake.Properties { } /// <summary> - /// Looks up a localized string similar to d. + /// Looks up a localized string similar to {0} minute. /// </summary> - internal static string AWAKE_LABEL_DAYS { + internal static string AWAKE_MINUTE { get { - return ResourceManager.GetString("AWAKE_LABEL_DAYS", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to h. - /// </summary> - internal static string AWAKE_LABEL_HOURS { - get { - return ResourceManager.GetString("AWAKE_LABEL_HOURS", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to m. - /// </summary> - internal static string AWAKE_LABEL_MINUTES { - get { - return ResourceManager.GetString("AWAKE_LABEL_MINUTES", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to s. - /// </summary> - internal static string AWAKE_LABEL_SECONDS { - get { - return ResourceManager.GetString("AWAKE_LABEL_SECONDS", resourceCulture); + return ResourceManager.GetString("AWAKE_MINUTE", resourceCulture); } } @@ -275,7 +275,16 @@ namespace Awake.Properties { return ResourceManager.GetString("AWAKE_SCREEN_ON", resourceCulture); } } - + + /// <summary> + /// Looks up a localized string similar to Screen. + /// </summary> + internal static string AWAKE_TRAY_DISPLAY { + get { + return ResourceManager.GetString("AWAKE_TRAY_DISPLAY", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Expiring. /// </summary> @@ -284,7 +293,7 @@ namespace Awake.Properties { return ResourceManager.GetString("AWAKE_TRAY_TEXT_EXPIRATION", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Indefinite. /// </summary> @@ -293,7 +302,7 @@ namespace Awake.Properties { return ResourceManager.GetString("AWAKE_TRAY_TEXT_INDEFINITE", resourceCulture); } } - + /// <summary> /// Looks up a localized string similar to Passive. /// </summary> @@ -302,31 +311,31 @@ namespace Awake.Properties { return ResourceManager.GetString("AWAKE_TRAY_TEXT_OFF", resourceCulture); } } - + /// <summary> - /// Looks up a localized string similar to Bound to. - /// </summary> - internal static string AWAKE_TRAY_TEXT_PID_BINDING { - get { - return ResourceManager.GetString("AWAKE_TRAY_TEXT_PID_BINDING", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Interval. + /// Looks up a localized string similar to Timed. /// </summary> internal static string AWAKE_TRAY_TEXT_TIMED { get { return ResourceManager.GetString("AWAKE_TRAY_TEXT_TIMED", resourceCulture); } } - + /// <summary> - /// Looks up a localized string similar to Unchecked. + /// Looks up a localized string similar to Until. /// </summary> - internal static string AWAKE_UNCHECKED { + internal static string AWAKE_TRAY_UNTIL { get { - return ResourceManager.GetString("AWAKE_UNCHECKED", resourceCulture); + return ResourceManager.GetString("AWAKE_TRAY_UNTIL", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to remaining. + /// </summary> + internal static string AWAKE_TRAY_REMAINING { + get { + return ResourceManager.GetString("AWAKE_TRAY_REMAINING", resourceCulture); } } } diff --git a/src/modules/awake/Awake/Properties/Resources.resx b/src/modules/awake/Awake/Properties/Resources.resx index 375ac385d1..5820744cb2 100644 --- a/src/modules/awake/Awake/Properties/Resources.resx +++ b/src/modules/awake/Awake/Properties/Resources.resx @@ -117,12 +117,13 @@ <resheader name="writer"> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> </resheader> - <data name="AWAKE_CHECKED" xml:space="preserve"> - <value>Checked</value> - </data> <data name="AWAKE_EXIT" xml:space="preserve"> <value>Exit</value> </data> + <data name="AWAKE_HOUR" xml:space="preserve"> + <value>{0} hour</value> + <comment>{0} shouldn't be removed. It will be replaced by the number 1 at runtime by the application. Used for defining a period to keep the PC awake.</comment> + </data> <data name="AWAKE_HOURS" xml:space="preserve"> <value>{0} hours</value> <comment>{0} shouldn't be removed. It will be replaced by a number greater than 1 at runtime by the application. Used for defining a period to keep the PC awake.</comment> @@ -142,6 +143,10 @@ <value>Keep awake until expiration date and time</value> <comment>Keep the system awake until expiration date and time</comment> </data> + <data name="AWAKE_MINUTE" xml:space="preserve"> + <value>{0} minute</value> + <comment>{0} shouldn't be removed. It will be replaced by the number 1 at runtime by the application. Used for defining a period to keep the PC awake.</comment> + </data> <data name="AWAKE_MINUTES" xml:space="preserve"> <value>{0} minutes</value> <comment>{0} shouldn't be removed. It will be replaced by a number greater than 1 at runtime by the application. Used for defining a period to keep the PC awake.</comment> @@ -150,9 +155,6 @@ <value>Off (keep using the selected power plan)</value> <comment>Don't keep the system awake, use the selected system power plan</comment> </data> - <data name="AWAKE_UNCHECKED" xml:space="preserve"> - <value>Unchecked</value> - </data> <data name="AWAKE_CMD_HELP_CONFIG_OPTION" xml:space="preserve"> <value>Specifies whether Awake will be using the PowerToys configuration file for managing the state.</value> </data> @@ -187,35 +189,36 @@ <value>Passive</value> </data> <data name="AWAKE_TRAY_TEXT_TIMED" xml:space="preserve"> - <value>Interval</value> - </data> - <data name="AWAKE_LABEL_DAYS" xml:space="preserve"> - <value>d</value> - <comment>Used to display number of days in the system tray tooltip.</comment> - </data> - <data name="AWAKE_LABEL_HOURS" xml:space="preserve"> - <value>h</value> - <comment>Used to display number of hours in the system tray tooltip.</comment> - </data> - <data name="AWAKE_LABEL_MINUTES" xml:space="preserve"> - <value>m</value> - <comment>Used to display number of minutes in the system tray tooltip.</comment> - </data> - <data name="AWAKE_LABEL_SECONDS" xml:space="preserve"> - <value>s</value> - <comment>Used to display number of seconds in the system tray tooltip.</comment> + <value>Timed</value> </data> <data name="AWAKE_CMD_PARENT_PID_OPTION" xml:space="preserve"> <value>Uses the parent process as the bound target - once the process terminates, Awake stops.</value> </data> - <data name="AWAKE_TRAY_TEXT_PID_BINDING" xml:space="preserve"> - <value>Bound to</value> - <comment>Describes the process ID Awake is bound to when running.</comment> - </data> <data name="AWAKE_SCREEN_ON" xml:space="preserve"> <value>On</value> </data> <data name="AWAKE_SCREEN_OFF" xml:space="preserve"> <value>Off</value> </data> + <data name="AWAKE_EXIT_PARENT_BINDING_FAILURE_MESSAGE" xml:space="preserve"> + <value>Exiting because the parent process ID could not be found.</value> + </data> + <data name="AWAKE_EXIT_PROCESS_BINDING_FAILURE_MESSAGE" xml:space="preserve"> + <value>Exiting because the requested process ID could not be found.</value> + </data> + <data name="AWAKE_EXIT_BIND_TO_SELF_FAILURE_MESSAGE" xml:space="preserve"> + <value>Exiting because the provided process ID is Awake's own.</value> + </data> + <data name="AWAKE_TRAY_DISPLAY" xml:space="preserve"> + <value>Screen</value> + <comment>Label for the screen/display line in tray tooltip.</comment> + </data> + <data name="AWAKE_TRAY_UNTIL" xml:space="preserve"> + <value>Until</value> + <comment>Label for expiration mode showing end date/time.</comment> + </data> + <data name="AWAKE_TRAY_REMAINING" xml:space="preserve"> + <value>remaining</value> + <comment>Suffix for timed mode showing time remaining, e.g. "1:30:00 remaining".</comment> + </data> </root> \ No newline at end of file diff --git a/src/modules/awake/Awake/Telemetry/AwakeExpirableKeepAwakeEvent.cs b/src/modules/awake/Awake/Telemetry/AwakeExpirableKeepAwakeEvent.cs index 915d8f3cc4..624787f587 100644 --- a/src/modules/awake/Awake/Telemetry/AwakeExpirableKeepAwakeEvent.cs +++ b/src/modules/awake/Awake/Telemetry/AwakeExpirableKeepAwakeEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Awake.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AwakeExpirableKeepAwakeEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/awake/Awake/Telemetry/AwakeIndefinitelyKeepAwakeEvent.cs b/src/modules/awake/Awake/Telemetry/AwakeIndefinitelyKeepAwakeEvent.cs index 31684380b9..71c856fe6e 100644 --- a/src/modules/awake/Awake/Telemetry/AwakeIndefinitelyKeepAwakeEvent.cs +++ b/src/modules/awake/Awake/Telemetry/AwakeIndefinitelyKeepAwakeEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Awake.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AwakeIndefinitelyKeepAwakeEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/awake/Awake/Telemetry/AwakeNoKeepAwakeEvent.cs b/src/modules/awake/Awake/Telemetry/AwakeNoKeepAwakeEvent.cs index 82bae4719c..f23907b134 100644 --- a/src/modules/awake/Awake/Telemetry/AwakeNoKeepAwakeEvent.cs +++ b/src/modules/awake/Awake/Telemetry/AwakeNoKeepAwakeEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Awake.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] internal sealed class AwakeNoKeepAwakeEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/awake/Awake/Telemetry/AwakeTimedKeepAwakeEvent.cs b/src/modules/awake/Awake/Telemetry/AwakeTimedKeepAwakeEvent.cs index 7618dc7220..da5f30f553 100644 --- a/src/modules/awake/Awake/Telemetry/AwakeTimedKeepAwakeEvent.cs +++ b/src/modules/awake/Awake/Telemetry/AwakeTimedKeepAwakeEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Awake.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class AwakeTimedKeepAwakeEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/awake/Awake/app.manifest b/src/modules/awake/Awake/app.manifest index a3d1e52638..f76cb80bec 100644 --- a/src/modules/awake/Awake/app.manifest +++ b/src/modules/awake/Awake/app.manifest @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> <asmv3:application> - <asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings"> - <dpiAware>true</dpiAware> + <asmv3:windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </asmv3:windowsSettings> </asmv3:application> </assembly> \ No newline at end of file diff --git a/src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj b/src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj index e1ce05608a..67f639d3ea 100644 --- a/src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj +++ b/src/modules/awake/AwakeModuleInterface/AwakeModuleInterface.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{5e7360a8-d048-4ed3-8f09-0bfd64c5529a}</ProjectGuid> @@ -8,9 +9,8 @@ <RootNamespace>Awake</RootNamespace> <ProjectName>AwakeModuleInterface</ProjectName> <TargetName>PowerToys.AwakeModuleInterface</TargetName> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> </PropertyGroup> @@ -24,12 +24,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -49,10 +49,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -63,17 +63,17 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/awake/AwakeModuleInterface/packages.config b/src/modules/awake/AwakeModuleInterface/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/awake/AwakeModuleInterface/packages.config +++ b/src/modules/awake/AwakeModuleInterface/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/awake/README.md b/src/modules/awake/README.md new file mode 100644 index 0000000000..43ece7760a --- /dev/null +++ b/src/modules/awake/README.md @@ -0,0 +1,168 @@ +# PowerToys Awake Module + +A PowerToys utility that prevents Windows from sleeping and/or turning off the display. + +**Author:** [Den Delimarsky](https://den.dev) + +## Resources + +- [Awake Website](https://awake.den.dev) - Official documentation and guides +- [Microsoft Learn Documentation](https://learn.microsoft.com/windows/powertoys/awake) - Usage instructions and feature overview +- [GitHub Issues](https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+label%3AProduct-Awake) - Report bugs or request features + +## Overview + +The Awake module consists of three projects: + +| Project | Purpose | +|---------|---------| +| `Awake/` | Main WinExe application with CLI support | +| `Awake.ModuleServices/` | Service layer for PowerToys integration | +| `AwakeModuleInterface/` | C++ native module bridge | + +## How It Works + +The module uses the Win32 `SetThreadExecutionState()` API to signal Windows that the system should remain awake: + +- `ES_SYSTEM_REQUIRED` - Prevents system sleep +- `ES_DISPLAY_REQUIRED` - Prevents display sleep +- `ES_CONTINUOUS` - Maintains state until explicitly changed + +## Operating Modes + +| Mode | Description | +|------|-------------| +| **PASSIVE** | Normal power behavior (off) | +| **INDEFINITE** | Keep awake until manually stopped | +| **TIMED** | Keep awake for a specified duration | +| **EXPIRABLE** | Keep awake until a specific date/time | + +## Command-Line Usage + +Awake can be run standalone with the following options: + +``` +PowerToys.Awake.exe [options] + +Options: + -c, --use-pt-config Use PowerToys configuration file + -d, --display-on Keep display on (default: false) + -t, --time-limit Time limit in seconds + -p, --pid Process ID to bind to + -e, --expire-at Expiration date/time + -u, --use-parent-pid Bind to parent process +``` + +### Examples + +Keep system awake indefinitely: +```powershell +PowerToys.Awake.exe +``` + +Keep awake for 1 hour with display on: +```powershell +PowerToys.Awake.exe --time-limit 3600 --display-on +``` + +Keep awake until a specific time: +```powershell +PowerToys.Awake.exe --expire-at "2024-12-31 23:59:59" +``` + +Keep awake while another process is running: +```powershell +PowerToys.Awake.exe --pid 1234 +``` + +## Architecture + +### Design Highlights + +1. **Pure Win32 API for Tray UI** - No WPF/WinForms dependencies, keeping the binary small. Uses direct `Shell_NotifyIcon` API for tray icon management. + +2. **Reactive Extensions (Rx.NET)** - Used for timed operations via `Observable.Interval()` and `Observable.Timer()`. File system watching uses 25ms throttle to debounce rapid config changes. + +3. **Custom SynchronizationContext** - Queue-based message dispatch ensures tray operations run on a dedicated thread for thread-safe UI updates. + +4. **Dual-Mode Operation** + - Standalone: Command-line arguments only + - Integrated: PowerToys settings file + process binding + +5. **Process Binding** - The `--pid` parameter keeps the system awake only while a target process runs, with auto-exit when the parent PowerToys runner terminates. + +## Key Files + +| File | Purpose | +|------|---------| +| `Program.cs` | Entry point & CLI parsing | +| `Core/Manager.cs` | State orchestration & power management | +| `Core/TrayHelper.cs` | System tray UI management | +| `Core/Native/Bridge.cs` | Win32 P/Invoke declarations | +| `Core/Threading/SingleThreadSynchronizationContext.cs` | Threading utilities | + +## Building + +### Prerequisites + +- Visual Studio 2022 or 2026 with C++ and .NET workloads +- Windows SDK 10.0.26100.0 or later + +### Build Commands + +From the `src/modules/awake` directory: + +```powershell +# Using the build script +.\scripts\Build-Awake.ps1 + +# Or with specific configuration +.\scripts\Build-Awake.ps1 -Configuration Debug -Platform x64 +``` + +Or using MSBuild directly: + +```powershell +msbuild Awake\Awake.csproj /p:Configuration=Release /p:Platform=x64 +``` + +## Dependencies + +- **System.CommandLine** - Command-line parsing +- **System.Reactive** - Rx.NET for timer management +- **PowerToys.ManagedCommon** - Shared PowerToys utilities +- **PowerToys.Settings.UI.Lib** - Settings integration +- **PowerToys.Interop** - Native interop layer + +## Configuration + +When running with PowerToys (`--use-pt-config`), settings are stored in: +``` +%LOCALAPPDATA%\Microsoft\PowerToys\Awake\settings.json +``` + +## Known Limitations + +### Task Scheduler Idle Detection ([#44134](https://github.com/microsoft/PowerToys/issues/44134)) + +When "Keep display on" is enabled, Awake uses the `ES_DISPLAY_REQUIRED` flag which blocks Windows Task Scheduler from detecting the system as idle. This prevents scheduled maintenance tasks (like SSD TRIM, disk defragmentation, and other idle-triggered tasks) from running. + +Per [Microsoft's documentation](https://learn.microsoft.com/en-us/windows/win32/taskschd/task-idle-conditions): + +> "An exception would be for any presentation type application that sets the ES_DISPLAY_REQUIRED flag. This flag forces Task Scheduler to not consider the system as being idle, regardless of user activity or resource consumption." + +**Workarounds:** + +1. **Disable "Keep display on"** - With this setting off, Awake only uses `ES_SYSTEM_REQUIRED` which still prevents sleep but allows Task Scheduler to detect idle state. + +2. **Manually run maintenance tasks** - For example, to run TRIM manually: + ```powershell + # Run as Administrator + Optimize-Volume -DriveLetter C -ReTrim -Verbose + ``` + +## Telemetry + +The module emits telemetry events for: +- Keep-awake mode changes (indefinite, timed, expirable, passive) +- Privacy-compliant event tagging via `Microsoft.PowerToys.Telemetry` diff --git a/src/modules/awake/scripts/Build-Awake.ps1 b/src/modules/awake/scripts/Build-Awake.ps1 new file mode 100644 index 0000000000..3b4389917d --- /dev/null +++ b/src/modules/awake/scripts/Build-Awake.ps1 @@ -0,0 +1,456 @@ +<# +.SYNOPSIS + Builds the PowerToys Awake module. + +.DESCRIPTION + This script builds the Awake module and its dependencies using MSBuild. + It automatically locates the Visual Studio installation and uses the + appropriate MSBuild version. + +.PARAMETER Configuration + The build configuration. Valid values are 'Debug' or 'Release'. + Default: Release + +.PARAMETER Platform + The target platform. Valid values are 'x64' or 'ARM64'. + Default: x64 + +.PARAMETER Clean + If specified, cleans the build output before building. + +.PARAMETER Restore + If specified, restores NuGet packages before building. + +.EXAMPLE + .\Build-Awake.ps1 + Builds Awake in Release configuration for x64. + +.EXAMPLE + .\Build-Awake.ps1 -Configuration Debug + Builds Awake in Debug configuration for x64. + +.EXAMPLE + .\Build-Awake.ps1 -Clean -Restore + Cleans, restores packages, and builds Awake. + +.EXAMPLE + .\Build-Awake.ps1 -Platform ARM64 + Builds Awake for ARM64 architecture. +#> + +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + [ValidateSet('x64', 'ARM64')] + [string]$Platform = 'x64', + + [switch]$Clean, + + [switch]$Restore +) + +# Force UTF-8 output for Unicode characters +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$ErrorActionPreference = 'Stop' +$script:StartTime = Get-Date + +# Get script directory and project paths +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ModuleDir = Split-Path -Parent $ScriptDir +$RepoRoot = Resolve-Path (Join-Path $ModuleDir "..\..\..") | Select-Object -ExpandProperty Path +$AwakeProject = Join-Path $ModuleDir "Awake\Awake.csproj" +$ModuleServicesProject = Join-Path $ModuleDir "Awake.ModuleServices\Awake.ModuleServices.csproj" + +# ============================================================================ +# Modern UI Components +# ============================================================================ + +$script:Colors = @{ + Primary = "Cyan" + Success = "Green" + Error = "Red" + Warning = "Yellow" + Muted = "DarkGray" + Accent = "Magenta" + White = "White" +} + +# Box drawing characters (not emojis) +$script:UI = @{ + BoxH = [char]0x2500 # Horizontal line + BoxV = [char]0x2502 # Vertical line + BoxTL = [char]0x256D # Top-left corner (rounded) + BoxTR = [char]0x256E # Top-right corner (rounded) + BoxBL = [char]0x2570 # Bottom-left corner (rounded) + BoxBR = [char]0x256F # Bottom-right corner (rounded) + TreeL = [char]0x2514 # Tree last item + TreeT = [char]0x251C # Tree item +} + +# Braille spinner frames (the npm-style spinner) +$script:SpinnerFrames = @( + [char]0x280B, # ⠋ + [char]0x2819, # ⠙ + [char]0x2839, # ⠹ + [char]0x2838, # ⠸ + [char]0x283C, # ⠼ + [char]0x2834, # ⠴ + [char]0x2826, # ⠦ + [char]0x2827, # ⠧ + [char]0x2807, # ⠇ + [char]0x280F # ⠏ +) + +function Get-ElapsedTime { + $elapsed = (Get-Date) - $script:StartTime + if ($elapsed.TotalSeconds -lt 60) { + return "$([math]::Round($elapsed.TotalSeconds, 1))s" + } else { + return "$([math]::Floor($elapsed.TotalMinutes))m $($elapsed.Seconds)s" + } +} + +function Write-Header { + Write-Host "" + Write-Host " Awake Build" -ForegroundColor $Colors.White + Write-Host " $Platform / $Configuration" -ForegroundColor $Colors.Muted + Write-Host "" +} + +function Write-Phase { + param([string]$Name) + Write-Host "" + Write-Host " $Name" -ForegroundColor $Colors.Accent + Write-Host "" +} + +function Write-Task { + param([string]$Name, [switch]$Last) + $tree = if ($Last) { $UI.TreeL } else { $UI.TreeT } + Write-Host " $tree$($UI.BoxH)$($UI.BoxH) " -NoNewline -ForegroundColor $Colors.Muted + Write-Host $Name -NoNewline -ForegroundColor $Colors.White +} + +function Write-TaskStatus { + param([string]$Status, [string]$Time, [switch]$Failed) + if ($Failed) { + Write-Host " FAIL" -ForegroundColor $Colors.Error + } else { + Write-Host " " -NoNewline + Write-Host $Status -NoNewline -ForegroundColor $Colors.Success + if ($Time) { + Write-Host " ($Time)" -ForegroundColor $Colors.Muted + } else { + Write-Host "" + } + } +} + +function Write-BuildTree { + param([string[]]$Items) + $count = $Items.Count + for ($i = 0; $i -lt $count; $i++) { + $isLast = ($i -eq $count - 1) + $tree = if ($isLast) { $UI.TreeL } else { $UI.TreeT } + Write-Host " $tree$($UI.BoxH) " -NoNewline -ForegroundColor $Colors.Muted + Write-Host $Items[$i] -ForegroundColor $Colors.Muted + } +} + +function Write-SuccessBox { + param([string]$Time, [string]$Output, [string]$Size) + + $width = 44 + $lineChar = [string]$UI.BoxH + $line = $lineChar * ($width - 2) + + Write-Host "" + Write-Host " $($UI.BoxTL)$line$($UI.BoxTR)" -ForegroundColor $Colors.Success + + # Title row + $title = " BUILD SUCCESSFUL" + $titlePadding = $width - 2 - $title.Length + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success + Write-Host $title -NoNewline -ForegroundColor $Colors.White + Write-Host (" " * $titlePadding) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success + + # Empty row + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success + Write-Host (" " * ($width - 2)) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success + + # Time row + $timeText = " Completed in $Time" + $timePadding = $width - 2 - $timeText.Length + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success + Write-Host $timeText -NoNewline -ForegroundColor $Colors.Muted + Write-Host (" " * $timePadding) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success + + # Output row + $outText = " Output: $Output ($Size)" + if ($outText.Length -gt ($width - 2)) { + $outText = $outText.Substring(0, $width - 5) + "..." + } + $outPadding = $width - 2 - $outText.Length + if ($outPadding -lt 0) { $outPadding = 0 } + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Success + Write-Host $outText -NoNewline -ForegroundColor $Colors.Muted + Write-Host (" " * $outPadding) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Success + + Write-Host " $($UI.BoxBL)$line$($UI.BoxBR)" -ForegroundColor $Colors.Success + Write-Host "" +} + +function Write-ErrorBox { + param([string]$Message) + + $width = 44 + $lineChar = [string]$UI.BoxH + $line = $lineChar * ($width - 2) + + Write-Host "" + Write-Host " $($UI.BoxTL)$line$($UI.BoxTR)" -ForegroundColor $Colors.Error + $title = " BUILD FAILED" + $titlePadding = $width - 2 - $title.Length + Write-Host " $($UI.BoxV)" -NoNewline -ForegroundColor $Colors.Error + Write-Host $title -NoNewline -ForegroundColor $Colors.White + Write-Host (" " * $titlePadding) -NoNewline + Write-Host "$($UI.BoxV)" -ForegroundColor $Colors.Error + Write-Host " $($UI.BoxBL)$line$($UI.BoxBR)" -ForegroundColor $Colors.Error + Write-Host "" +} + +# ============================================================================ +# Build Functions +# ============================================================================ + +function Find-MSBuild { + $vsWherePath = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + + if (Test-Path $vsWherePath) { + $vsPath = & $vsWherePath -latest -requires Microsoft.Component.MSBuild -property installationPath + if ($vsPath) { + $msbuildPath = Join-Path $vsPath "MSBuild\Current\Bin\MSBuild.exe" + if (Test-Path $msbuildPath) { + return $msbuildPath + } + } + } + + $commonPaths = @( + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\MSBuild.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" + ) + + foreach ($path in $commonPaths) { + if (Test-Path $path) { + return $path + } + } + + throw "MSBuild not found. Please install Visual Studio 2022." +} + +function Invoke-BuildWithSpinner { + param( + [string]$TaskName, + [string]$MSBuildPath, + [string[]]$Arguments, + [switch]$ShowProjects, + [switch]$IsLast + ) + + $taskStart = Get-Date + $isInteractive = [Environment]::UserInteractive -and -not [Console]::IsOutputRedirected + + # Only write initial task line in interactive mode (will be overwritten by spinner) + if ($isInteractive) { + Write-Task $TaskName -Last:$IsLast + Write-Host " " -NoNewline + } + + # Start MSBuild process + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $MSBuildPath + $psi.Arguments = $Arguments -join " " + $psi.UseShellExecute = $false + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + $psi.WorkingDirectory = $RepoRoot + + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + + # Collect output asynchronously + $outputBuilder = [System.Text.StringBuilder]::new() + $errorBuilder = [System.Text.StringBuilder]::new() + + $outputHandler = { + if (-not [String]::IsNullOrEmpty($EventArgs.Data)) { + $Event.MessageData.AppendLine($EventArgs.Data) + } + } + + $outputEvent = Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -Action $outputHandler -MessageData $outputBuilder + $errorEvent = Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -Action $outputHandler -MessageData $errorBuilder + + $process.Start() | Out-Null + $process.BeginOutputReadLine() + $process.BeginErrorReadLine() + + # Animate spinner while process is running + $frameIndex = 0 + + while (-not $process.HasExited) { + if ($isInteractive) { + $frame = $script:SpinnerFrames[$frameIndex] + Write-Host "`r $($UI.TreeL)$($UI.BoxH)$($UI.BoxH) $TaskName $frame " -NoNewline + $frameIndex = ($frameIndex + 1) % $script:SpinnerFrames.Count + } + Start-Sleep -Milliseconds 80 + } + + $process.WaitForExit() + + Unregister-Event -SourceIdentifier $outputEvent.Name + Unregister-Event -SourceIdentifier $errorEvent.Name + Remove-Job -Name $outputEvent.Name -Force -ErrorAction SilentlyContinue + Remove-Job -Name $errorEvent.Name -Force -ErrorAction SilentlyContinue + + $exitCode = $process.ExitCode + $output = $outputBuilder.ToString() -split "`n" + $errors = $errorBuilder.ToString() + + $taskElapsed = (Get-Date) - $taskStart + $elapsed = "$([math]::Round($taskElapsed.TotalSeconds, 1))s" + + # Write final status line + $tree = if ($IsLast) { $UI.TreeL } else { $UI.TreeT } + if ($isInteractive) { + Write-Host "`r" -NoNewline + } + Write-Host " $tree$($UI.BoxH)$($UI.BoxH) " -NoNewline -ForegroundColor $Colors.Muted + Write-Host $TaskName -NoNewline -ForegroundColor $Colors.White + + if ($exitCode -ne 0) { + Write-TaskStatus "FAIL" -Failed + Write-Host "" + foreach ($line in $output) { + if ($line -match "error\s+\w+\d*:") { + Write-Host " x $line" -ForegroundColor $Colors.Error + } + } + return @{ Success = $false; Output = $output; ExitCode = $exitCode } + } + + Write-TaskStatus "done" $elapsed + + # Show built projects + if ($ShowProjects) { + $projects = @() + foreach ($line in $output) { + if ($line -match "^\s*(\S+)\s+->\s+(.+)$") { + $project = $Matches[1] + $fileName = Split-Path $Matches[2] -Leaf + $projects += "$project -> $fileName" + } + } + if ($projects.Count -gt 0) { + Write-BuildTree $projects + } + } + + return @{ Success = $true; Output = $output; ExitCode = 0 } +} + +# ============================================================================ +# Main +# ============================================================================ + +# Verify project exists +if (-not (Test-Path $AwakeProject)) { + Write-Host "" + Write-Host " x Project not found: $AwakeProject" -ForegroundColor $Colors.Error + exit 1 +} + +$MSBuild = Find-MSBuild + +# Display header +Write-Header + +# Build arguments base +$BaseArgs = @( + "/p:Configuration=$Configuration", + "/p:Platform=$Platform", + "/v:minimal", + "/nologo", + "/m" +) + +# Clean phase +if ($Clean) { + Write-Phase "Cleaning" + $cleanArgs = @($AwakeProject) + $BaseArgs + @("/t:Clean") + $result = Invoke-BuildWithSpinner -TaskName "Build artifacts" -MSBuildPath $MSBuild -Arguments $cleanArgs -IsLast + if (-not $result.Success) { + Write-ErrorBox + exit $result.ExitCode + } +} + +# Restore phase +if ($Restore) { + Write-Phase "Restoring" + $restoreArgs = @($AwakeProject) + $BaseArgs + @("/t:Restore") + $result = Invoke-BuildWithSpinner -TaskName "NuGet packages" -MSBuildPath $MSBuild -Arguments $restoreArgs -IsLast + if (-not $result.Success) { + Write-ErrorBox + exit $result.ExitCode + } +} + +# Build phase +Write-Phase "Building" + +$hasModuleServices = Test-Path $ModuleServicesProject + +# Build Awake +$awakeArgs = @($AwakeProject) + $BaseArgs + @("/t:Build") +$result = Invoke-BuildWithSpinner -TaskName "Awake" -MSBuildPath $MSBuild -Arguments $awakeArgs -ShowProjects -IsLast:(-not $hasModuleServices) +if (-not $result.Success) { + Write-ErrorBox + exit $result.ExitCode +} + +# Build ModuleServices +if ($hasModuleServices) { + $servicesArgs = @($ModuleServicesProject) + $BaseArgs + @("/t:Build") + $result = Invoke-BuildWithSpinner -TaskName "Awake.ModuleServices" -MSBuildPath $MSBuild -Arguments $servicesArgs -ShowProjects -IsLast + if (-not $result.Success) { + Write-ErrorBox + exit $result.ExitCode + } +} + +# Summary +$OutputDir = Join-Path $RepoRoot "$Platform\$Configuration" +$AwakeDll = Join-Path $OutputDir "PowerToys.Awake.dll" +$elapsed = Get-ElapsedTime + +if (Test-Path $AwakeDll) { + $size = "$([math]::Round((Get-Item $AwakeDll).Length / 1KB, 1)) KB" + Write-SuccessBox -Time $elapsed -Output "PowerToys.Awake.dll" -Size $size +} else { + Write-SuccessBox -Time $elapsed -Output $OutputDir -Size "N/A" +} diff --git a/src/modules/cmdNotFound/CmdNotFoundModuleInterface/CmdNotFoundModuleInterface.vcxproj b/src/modules/cmdNotFound/CmdNotFoundModuleInterface/CmdNotFoundModuleInterface.vcxproj index 34d556683a..20bc497960 100644 --- a/src/modules/cmdNotFound/CmdNotFoundModuleInterface/CmdNotFoundModuleInterface.vcxproj +++ b/src/modules/cmdNotFound/CmdNotFoundModuleInterface/CmdNotFoundModuleInterface.vcxproj @@ -1,16 +1,16 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>17.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{0014d652-901f-4456-8d65-06fc5f997fb0}</ProjectGuid> <RootNamespace>CmdNotFoundModuleInterface</RootNamespace> <TargetName>PowerToys.CmdNotFoundModuleInterface</TargetName> - <PlatformToolset>v143</PlatformToolset> + <ProjectName>CmdNotFoundModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> @@ -32,7 +32,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> <ClCompile> @@ -68,7 +68,7 @@ </ItemDefinitionGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -88,10 +88,10 @@ </ClCompile> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -102,15 +102,15 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/cmdNotFound/CmdNotFoundModuleInterface/packages.config b/src/modules/cmdNotFound/CmdNotFoundModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/cmdNotFound/CmdNotFoundModuleInterface/packages.config +++ b/src/modules/cmdNotFound/CmdNotFoundModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/cmdpal/.editorconfig b/src/modules/cmdpal/.editorconfig index f93166a809..281fbeeee7 100644 --- a/src/modules/cmdpal/.editorconfig +++ b/src/modules/cmdpal/.editorconfig @@ -2,28 +2,44 @@ # You can modify the rules from these initially generated values to suit your own policies. # You can learn more about editorconfig here: https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference. -[*.cs] +################################################## +# Global settings +################################################## -file_header_template = Copyright (c) Microsoft Corporation\r\nThe Microsoft Corporation licenses this file to you under the MIT license.\r\nSee the LICENSE file in the project root for more information. - -#Core editorconfig formatting - indentation - -#use soft tabs (spaces) for indentation +[*.{cs,vb}] +tab_width = 4 +indent_size = 4 +end_of_line = crlf indent_style = space +insert_final_newline = true +file_header_template = Copyright (c) Microsoft Corporation\nThe Microsoft Corporation licenses this file to you under the MIT license.\nSee the LICENSE file in the project root for more information. -#Formatting - new line options +################################################## +# C# specific formatting +################################################## + +[*.cs] +# ---------------------------------------------- +# Core editorconfig formatting - indentation +# ---------------------------------------------- #place else statements on a new line csharp_new_line_before_else = true #require braces to be on a new line for lambdas, methods, control_blocks, types, properties, and accessors (also known as "Allman" style) csharp_new_line_before_open_brace = all -#Formatting - organize using options +# ---------------------------------------------- +# Formatting - organize using options +# ---------------------------------------------- -#sort System.* using directives alphabetically, and place them before other usings +# sort System.* using directives alphabetically, and place them before other usings dotnet_sort_system_directives_first = true +# Do not place System.* using directives before other using directives. +dotnet_separate_import_directive_groups = false -#Formatting - spacing options +# ---------------------------------------------- +# Formatting - spacing options +# ---------------------------------------------- #require NO space between a cast and the value csharp_space_after_cast = false @@ -44,17 +60,29 @@ csharp_space_between_method_declaration_empty_parameter_list_parentheses = false #place a space character after the opening parenthesis and before the closing parenthesis of a method declaration parameter list. csharp_space_between_method_declaration_parameter_list_parentheses = false -#Formatting - wrapping options +# ---------------------------------------------- +# Formatting - wrapping options +# ---------------------------------------------- #leave code block on separate lines csharp_preserve_single_line_blocks = true +#put each statement on a separate line +csharp_preserve_single_line_statements = false -#Style - Code block preferences +################################################## +# C# style rules +################################################## + +# ---------------------------------------------- +# Style - Code block preferences +# ---------------------------------------------- #prefer curly braces even for one line of code csharp_prefer_braces = true:suggestion -#Style - expression bodied member options +# ---------------------------------------------- +# Style - expression bodied member options +# ---------------------------------------------- #prefer expression bodies for accessors csharp_style_expression_bodied_accessors = true:warning @@ -65,55 +93,73 @@ csharp_style_expression_bodied_methods = when_on_single_line:silent #prefer expression-bodied members for properties csharp_style_expression_bodied_properties = true:warning -#Style - expression level options +# ---------------------------------------------- +# Style - expression level options +# ---------------------------------------------- #prefer out variables to be declared before the method call csharp_style_inlined_variable_declaration = false:suggestion #prefer the language keyword for member access expressions, instead of the type name, for types that have a keyword to represent them dotnet_style_predefined_type_for_member_access = true:suggestion -#Style - Expression-level preferences +# ---------------------------------------------- +# Style - Expression-level preferences +# ---------------------------------------------- #prefer default over default(T) csharp_prefer_simple_default_expression = true:suggestion #prefer objects to be initialized using object initializers when possible dotnet_style_object_initializer = true:suggestion -#Style - implicit and explicit types +# ---------------------------------------------- +# Style - implicit and explicit types +# ---------------------------------------------- #prefer var over explicit type in all cases, unless overridden by another code style rule csharp_style_var_elsewhere = true:suggestion #prefer var is used to declare variables with built-in system types such as int -csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_for_built_in_types = true:warning #prefer var when the type is already mentioned on the right-hand side of a declaration expression csharp_style_var_when_type_is_apparent = true:suggestion -#Style - language keyword and framework type options +# ---------------------------------------------- +# Style - language keyword and framework type options +# ---------------------------------------------- #prefer the language keyword for local variables, method parameters, and class members, instead of the type name, for types that have a keyword to represent them dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -#Style - Language rules -csharp_style_implicit_object_creation_when_type_is_apparent = true:warning -csharp_style_var_for_built_in_types = true:warning +# ---------------------------------------------- +# Style - Language rules +# ---------------------------------------------- -#Style - modifier options +csharp_style_implicit_object_creation_when_type_is_apparent = true:warning + +# ---------------------------------------------- +# Style - modifier options +# ---------------------------------------------- #prefer accessibility modifiers to be declared except for public interface members. This will currently not differ from always and will act as future proofing for if C# adds default interface methods. dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion -#Style - Modifier preferences +# ---------------------------------------------- +# Style - Modifier preferences +# ---------------------------------------------- #when this rule is set to a list of modifiers, prefer the specified ordering. csharp_preferred_modifier_order = public,private,protected,internal,static,async,readonly,override,sealed,abstract,virtual:warning dotnet_style_readonly_field = true:warning -#Style - Pattern matching +# ---------------------------------------------- +# Style - Pattern matching +# ---------------------------------------------- #prefer pattern matching instead of is expression with type casts csharp_style_pattern_matching_over_as_with_null_check = true:warning -#Style - qualification options +# ---------------------------------------------- +# Style - qualification options +# ---------------------------------------------- #prefer events not to be prefaced with this. or Me. in Visual Basic dotnet_style_qualification_for_event = false:suggestion @@ -123,20 +169,26 @@ dotnet_style_qualification_for_field = false:suggestion dotnet_style_qualification_for_method = false:suggestion #prefer properties not to be prefaced with this. or Me. in Visual Basic dotnet_style_qualification_for_property = false:suggestion -csharp_indent_labels = one_less_than_current -csharp_using_directive_placement = outside_namespace:silent -csharp_prefer_simple_using_statement = true:warning -csharp_style_namespace_declarations = file_scoped:warning + +# ---------------------------------------------- +# Style - expression bodies +# ---------------------------------------------- csharp_style_expression_bodied_operators = false:silent csharp_style_expression_bodied_indexers = true:silent csharp_style_expression_bodied_lambdas = true:silent csharp_style_expression_bodied_local_functions = false:silent +# ---------------------------------------------- +# Style - Miscellaneous preferences +# ---------------------------------------------- + +csharp_indent_labels = one_less_than_current +csharp_using_directive_placement = outside_namespace:silent +csharp_prefer_simple_using_statement = true:warning +csharp_style_namespace_declarations = file_scoped:warning + [*.{cs,vb}] dotnet_style_operator_placement_when_wrapping = beginning_of_line -tab_width = 4 -indent_size = 4 -end_of_line = crlf dotnet_style_coalesce_expression = true:suggestion dotnet_style_null_propagation = true:suggestion dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion @@ -146,12 +198,13 @@ dotnet_style_collection_initializer = true:suggestion dotnet_style_prefer_simplified_boolean_expressions = true:suggestion dotnet_style_prefer_conditional_expression_over_assignment = true:silent dotnet_style_prefer_conditional_expression_over_return = true:silent -[*.{cs,vb}] - -#Style - Unnecessary code rules csharp_style_unused_value_assignment_preference = discard_variable:warning -#### Naming styles #### +################################################## +# Naming rules +################################################## + +[*.{cs,vb}] # Naming rules @@ -203,7 +256,11 @@ dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion dotnet_style_prefer_compound_assignment = true:warning dotnet_style_prefer_simplified_interpolation = true:suggestion -# Diagnostic configuration +################################################## +# Diagnostics +################################################## + +[*.{cs,vb}] # CS8305: Type is for evaluation purposes only and is subject to change or removal in future updates. dotnet_diagnostic.CS8305.severity = suggestion diff --git a/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.def b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.def new file mode 100644 index 0000000000..24e7c1235c --- /dev/null +++ b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.def @@ -0,0 +1,3 @@ +EXPORTS +DllCanUnloadNow = WINRT_CanUnloadNow PRIVATE +DllGetActivationFactory = WINRT_GetActivationFactory PRIVATE diff --git a/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj new file mode 100644 index 0000000000..8257a285eb --- /dev/null +++ b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj @@ -0,0 +1,183 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" ToolsVersion="15.0" + xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> + <CppWinRTGenerateWindowsMetadata>true</CppWinRTGenerateWindowsMetadata> + <ProjectGuid>{5f63c743-f6ce-4dba-a200-2b3f8a14e8c2}</ProjectGuid> + <ProjectName>CmdPalKeyboardService</ProjectName> + <RootNamespace>CmdPalKeyboardService</RootNamespace> + <AppxPackage>false</AppxPackage> + </PropertyGroup> + + <!-- BEGIN common.build.pre.props --> + <PropertyGroup Label="Configuration"> + <EnableHybridCRT>true</EnableHybridCRT> + <UseCrtSDKReferenceStaticWarning Condition="'$(EnableHybridCRT)'=='true'">false</UseCrtSDKReferenceStaticWarning> + </PropertyGroup> + <!-- END common.build.pre.props --> + <!-- BEGIN cppwinrt.build.pre.props --> + <PropertyGroup Label="Globals"> + <CppWinRTEnabled>true</CppWinRTEnabled> + <CppWinRTOptimized>true</CppWinRTOptimized> + <DefaultLanguage>en-US</DefaultLanguage> + <MinimumVisualStudioVersion>17.0</MinimumVisualStudioVersion> + <ApplicationTypeRevision>10.0</ApplicationTypeRevision> + </PropertyGroup> + <PropertyGroup> + <MinimalCoreWin>true</MinimalCoreWin> + <AppContainerApplication>true</AppContainerApplication> + <WindowsStoreApp>true</WindowsStoreApp> + <ApplicationType>Windows Store</ApplicationType> + <UseCrtSDKReference Condition="'$(EnableHybridCRT)'=='true'">false</UseCrtSDKReference> <!-- The SDK reference breaks the Hybrid CRT --> + </PropertyGroup> + <PropertyGroup> + <!-- We have to use the Desktop platform for Hybrid CRT to work. --> + <_VC_Target_Library_Platform>Desktop</_VC_Target_Library_Platform> + <_NoWinAPIFamilyApp>true</_NoWinAPIFamilyApp> + </PropertyGroup> + <!-- END cppwinrt.build.pre.props --> + + <PropertyGroup Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + + <CharacterSet>Unicode</CharacterSet> + <GenerateManifest>false</GenerateManifest> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> + <UseDebugLibraries>true</UseDebugLibraries> + <LinkIncremental>true</LinkIncremental> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> + <UseDebugLibraries>false</UseDebugLibraries> + <WholeProgramOptimization>true</WholeProgramOptimization> + <LinkIncremental>false</LinkIncremental> + </PropertyGroup> + + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="PropertySheet.props" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <TargetName>CmdPalKeyboardService</TargetName> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> + </PropertyGroup> + <ItemDefinitionGroup> + <ClCompile> + <PrecompiledHeaderOutputFile>$(IntDir)pch.pch</PrecompiledHeaderOutputFile> + <WarningLevel>Level4</WarningLevel> + <AdditionalOptions>%(AdditionalOptions) /bigobj</AdditionalOptions> + <PreprocessorDefinitions>_WINRT_DLL;WINRT_LEAN_AND_MEAN;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <AdditionalIncludeDirectories>../../..;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalUsingDirectories>$(WindowsSDK_WindowsMetadata);$(AdditionalUsingDirectories)</AdditionalUsingDirectories> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <GenerateWindowsMetadata>false</GenerateWindowsMetadata> + <ModuleDefinitionFile>CmdPalKeyboardService.def</ModuleDefinitionFile> + <AdditionalDependencies>Shell32.lib;user32.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> + <ClCompile> + <PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> + </ClCompile> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> + <ClCompile> + <PreprocessorDefinitions>NDEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> + </ClCompile> + <Link> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClInclude Include="pch.h" /> + <ClInclude Include="KeyboardListener.h"> + <DependentUpon>KeyboardListener.idl</DependentUpon> + </ClInclude> + </ItemGroup> + <ItemGroup> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader>Create</PrecompiledHeader> + </ClCompile> + <ClCompile Include="KeyboardListener.cpp"> + <DependentUpon>KeyboardListener.idl</DependentUpon> + </ClCompile> + <ClCompile Include="$(GeneratedFilesDir)module.g.cpp" /> + </ItemGroup> + <ItemGroup> + <Midl Include="KeyboardListener.idl" /> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + <None Include="CmdPalKeyboardService.def" /> + </ItemGroup> + <ItemGroup> + <None Include="PropertySheet.props" /> + </ItemGroup> + + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> + + <!-- BEGIN common.build.post.props --> + <!-- + The Hybrid CRT model statically links the runtime and STL and dynamically + links the UCRT instead of the VC++ CRT. The UCRT ships with Windows. + WinAppSDK asserts that this is "supported according to the CRT maintainer." + + This must come before Microsoft.Cpp.targets because it manipulates ClCompile.RuntimeLibrary. + --> + <ItemDefinitionGroup Condition="'$(EnableHybridCRT)'=='true' and '$(Configuration)'=='Debug'"> + <ClCompile> + <!-- We use MultiThreadedDebug, rather than MultiThreadedDebugDLL, to avoid DLL dependencies on VCRUNTIME140d.dll and MSVCP140d.dll. --> + <RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary> + </ClCompile> + <Link> + <!-- Link statically against the runtime and STL, but link dynamically against the CRT by ignoring the static CRT + lib and instead linking against the Universal CRT DLL import library. This "hybrid" linking mechanism is + supported according to the CRT maintainer. Dynamic linking against the CRT makes the binaries a bit smaller + than they would otherwise be if the CRT, runtime, and STL were all statically linked in. --> + <IgnoreSpecificDefaultLibraries>%(IgnoreSpecificDefaultLibraries);libucrtd.lib</IgnoreSpecificDefaultLibraries> + <AdditionalOptions>%(AdditionalOptions) /defaultlib:ucrtd.lib</AdditionalOptions> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(EnableHybridCRT)'=='true' and ('$(Configuration)'=='Release' or '$(Configuration)'=='AuditMode')"> + <ClCompile> + <!-- We use MultiThreaded, rather than MultiThreadedDLL, to avoid DLL dependencies on VCRUNTIME140.dll and MSVCP140.dll. --> + <RuntimeLibrary>MultiThreaded</RuntimeLibrary> + </ClCompile> + <Link> + <!-- Link statically against the runtime and STL, but link dynamically against the CRT by ignoring the static CRT + lib and instead linking against the Universal CRT DLL import library. This "hybrid" linking mechanism is + supported according to the CRT maintainer. Dynamic linking against the CRT makes the binaries a bit smaller + than they would otherwise be if the CRT, runtime, and STL were all statically linked in. --> + <IgnoreSpecificDefaultLibraries>%(IgnoreSpecificDefaultLibraries);libucrt.lib</IgnoreSpecificDefaultLibraries> + <AdditionalOptions>%(AdditionalOptions) /defaultlib:ucrt.lib</AdditionalOptions> + </Link> + </ItemDefinitionGroup> + <!-- END common.build.post.props --> + +</Project> \ No newline at end of file diff --git a/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj.filters b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj.filters new file mode 100644 index 0000000000..2309326e71 --- /dev/null +++ b/src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj.filters @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="15.0" + xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Resources"> + <UniqueIdentifier>accd3aa8-1ba0-4223-9bbe-0c431709210b</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tga;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + <Filter Include="Generated Files"> + <UniqueIdentifier>{926ab91d-31b4-48c3-b9a4-e681349f27f0}</UniqueIdentifier> + </Filter> + </ItemGroup> + <ItemGroup> + <ClCompile Include="pch.cpp" /> + <ClCompile Include="$(GeneratedFilesDir)module.g.cpp" /> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h" /> + </ItemGroup> + <ItemGroup> + <Midl Include="KeyboardListener.idl" /> + </ItemGroup> + <ItemGroup> + <None Include="CmdPalKeyboardService.def" /> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <None Include="PropertySheet.props" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/CmdPalKeyboardService/KeyboardListener.cpp b/src/modules/cmdpal/CmdPalKeyboardService/KeyboardListener.cpp new file mode 100644 index 0000000000..cf52fe1863 --- /dev/null +++ b/src/modules/cmdpal/CmdPalKeyboardService/KeyboardListener.cpp @@ -0,0 +1,165 @@ +#include "pch.h" +#include "KeyboardListener.h" +#include "KeyboardListener.g.cpp" + +// #include <common/logger/logger.h> +// #include <common/utils/logger_helper.h> +#include <common/utils/winapi_error.h> + +namespace +{ +} + +namespace winrt::CmdPalKeyboardService::implementation +{ + KeyboardListener::KeyboardListener() + { + s_instance = this; + } + + void KeyboardListener::Start() + { +#if defined(DISABLE_LOWLEVEL_HOOKS_WHEN_DEBUGGED) + const bool hook_disabled = IsDebuggerPresent(); +#else + const bool hook_disabled = false; +#endif + if (!hook_disabled) + { + if (!s_llKeyboardHook) + { + s_llKeyboardHook = SetWindowsHookExW(WH_KEYBOARD_LL, LowLevelKeyboardProc, NULL, NULL); + if (!s_llKeyboardHook) + { + DWORD errorCode = GetLastError(); + show_last_error_message(L"SetWindowsHookEx", errorCode, L"CmdPalKeyboardService"); + } + } + } + } + + void KeyboardListener::Stop() + { + if (s_llKeyboardHook && UnhookWindowsHookEx(s_llKeyboardHook)) + { + s_llKeyboardHook = NULL; + } + } + + void KeyboardListener::SetHotkeyAction(bool win, bool ctrl, bool shift, bool alt, uint8_t key, hstring const& id) + { + Hotkey hotkey = { .win = win, .ctrl = ctrl, .shift = shift, .alt = alt, .key = key }; + std::unique_lock lock{ mutex }; + + HotkeyDescriptor desc = { .hotkey = hotkey, .id = std::wstring(id) }; + hotkeyDescriptors.insert(desc); + } + + void KeyboardListener::ClearHotkey(hstring const& id) + { + { + std::unique_lock lock{ mutex }; + auto it = hotkeyDescriptors.begin(); + while (it != hotkeyDescriptors.end()) + { + if (it->id == id) + { + it = hotkeyDescriptors.erase(it); + } + else + { + ++it; + } + } + } + } + + void KeyboardListener::ClearHotkeys() + { + { + std::unique_lock lock{ mutex }; + auto it = hotkeyDescriptors.begin(); + while (it != hotkeyDescriptors.end()) + { + it = hotkeyDescriptors.erase(it); + } + } + } + + void KeyboardListener::SetProcessCommand(ProcessCommand processCommand) + { + m_processCommandCb = [trigger = std::move(processCommand)](hstring const& id) { + trigger(id); + }; + } + + LRESULT KeyboardListener::DoLowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) + { + const auto& keyPressInfo = *reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam); + + if ((wParam != WM_KEYDOWN) && (wParam != WM_SYSKEYDOWN)) + { + return CallNextHookEx(NULL, nCode, wParam, lParam); + } + + Hotkey hotkey{ + .win = (GetAsyncKeyState(VK_LWIN) & 0x8000) || (GetAsyncKeyState(VK_RWIN) & 0x8000), + .ctrl = static_cast<bool>(GetAsyncKeyState(VK_CONTROL) & 0x8000), + .shift = static_cast<bool>(GetAsyncKeyState(VK_SHIFT) & 0x8000), + .alt = static_cast<bool>(GetAsyncKeyState(VK_MENU) & 0x8000), + .key = static_cast<unsigned char>(keyPressInfo.vkCode) + }; + + if (hotkey == Hotkey{}) + { + return CallNextHookEx(NULL, nCode, wParam, lParam); + } + + bool do_action = false; + std::wstring actionId{}; + + { + // Hold the lock for the shortest possible duration + std::unique_lock lock{ mutex }; + HotkeyDescriptor dummy{ .hotkey = hotkey }; + auto it = hotkeyDescriptors.find(dummy); + if (it != hotkeyDescriptors.end()) + { + do_action = true; + actionId = it->id; + } + } + + if (do_action) + { + m_processCommandCb(hstring{ actionId }); + + // After invoking the hotkey send a dummy key to prevent Start Menu from activating + INPUT dummyEvent[1] = {}; + dummyEvent[0].type = INPUT_KEYBOARD; + dummyEvent[0].ki.wVk = 0xFF; + dummyEvent[0].ki.dwFlags = KEYEVENTF_KEYUP; + SendInput(1, dummyEvent, sizeof(INPUT)); + + // Swallow the key press + return 1; + } + + return CallNextHookEx(NULL, nCode, wParam, lParam); + } + + LRESULT KeyboardListener::LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) + { + if (s_instance == nullptr) + { + return CallNextHookEx(NULL, nCode, wParam, lParam); + } + + if (nCode < 0) + { + return CallNextHookEx(NULL, nCode, wParam, lParam); + } + + return s_instance->DoLowLevelKeyboardProc(nCode, wParam, lParam); + } +} diff --git a/src/modules/cmdpal/CmdPalKeyboardService/KeyboardListener.h b/src/modules/cmdpal/CmdPalKeyboardService/KeyboardListener.h new file mode 100644 index 0000000000..7cd82ba77c --- /dev/null +++ b/src/modules/cmdpal/CmdPalKeyboardService/KeyboardListener.h @@ -0,0 +1,67 @@ +#pragma once + +#include "KeyboardListener.g.h" +#include <mutex> +#include <spdlog/stopwatch.h> +#include <set> + +namespace winrt::CmdPalKeyboardService::implementation +{ + struct KeyboardListener : KeyboardListenerT<KeyboardListener> + { + struct Hotkey + { + bool win = false; + bool ctrl = false; + bool shift = false; + bool alt = false; + unsigned char key = 0; + + std::strong_ordering operator<=>(const Hotkey&) const = default; + }; + + struct HotkeyDescriptor + { + Hotkey hotkey; + std::wstring id; + + bool operator<(const HotkeyDescriptor& other) const + { + return hotkey < other.hotkey; + }; + }; + + KeyboardListener(); + + void Start(); + void Stop(); + void SetHotkeyAction(bool win, bool ctrl, bool shift, bool alt, uint8_t key, hstring const& id); + void ClearHotkey(hstring const& id); + void ClearHotkeys(); + void SetProcessCommand(ProcessCommand processCommand); + + static LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam); + + private: + LRESULT CALLBACK DoLowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam); + + static inline KeyboardListener* s_instance; + HHOOK s_llKeyboardHook = nullptr; + + // Max DWORD for key code to disable keys. + const DWORD VK_DISABLED = 0x100; + DWORD vkCodePressed = VK_DISABLED; + + std::multiset<HotkeyDescriptor> hotkeyDescriptors; + std::mutex mutex; + + std::function<void(hstring const&)> m_processCommandCb; + }; +} + +namespace winrt::CmdPalKeyboardService::factory_implementation +{ + struct KeyboardListener : KeyboardListenerT<KeyboardListener, implementation::KeyboardListener> + { + }; +} diff --git a/src/modules/cmdpal/CmdPalKeyboardService/KeyboardListener.idl b/src/modules/cmdpal/CmdPalKeyboardService/KeyboardListener.idl new file mode 100644 index 0000000000..8d808e386c --- /dev/null +++ b/src/modules/cmdpal/CmdPalKeyboardService/KeyboardListener.idl @@ -0,0 +1,16 @@ + +namespace CmdPalKeyboardService +{ + [version(1.0), uuid(78ab07cd-e128-4e73-86aa-e48e6b6d01ff)] delegate void ProcessCommand(String id); + + [default_interface] runtimeclass KeyboardListener { + KeyboardListener(); + void Start(); + void Stop(); + + void SetHotkeyAction(Boolean win, Boolean ctrl, Boolean shift, Boolean alt, UInt8 key, String id); + void ClearHotkey(String id); + void ClearHotkeys(); + void SetProcessCommand(ProcessCommand processCommand); + } +} diff --git a/src/modules/cmdpal/CmdPalKeyboardService/PropertySheet.props b/src/modules/cmdpal/CmdPalKeyboardService/PropertySheet.props new file mode 100644 index 0000000000..27ad40e537 --- /dev/null +++ b/src/modules/cmdpal/CmdPalKeyboardService/PropertySheet.props @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" + xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ImportGroup Label="PropertySheets" /> + <PropertyGroup Label="UserMacros" /> + <!-- + To customize common C++/WinRT project properties: + * right-click the project node + * expand the Common Properties item + * select the C++/WinRT property page + + For more advanced scenarios, and complete documentation, please see: + https://github.com/Microsoft/cppwinrt/tree/master/nuget + --> + <PropertyGroup /> + <ItemDefinitionGroup /> +</Project> \ No newline at end of file diff --git a/installer/PowerToysSetupCustomActions/packages.config b/src/modules/cmdpal/CmdPalKeyboardService/packages.config similarity index 59% rename from installer/PowerToysSetupCustomActions/packages.config rename to src/modules/cmdpal/CmdPalKeyboardService/packages.config index 09bfc449e2..f32f48b009 100644 --- a/installer/PowerToysSetupCustomActions/packages.config +++ b/src/modules/cmdpal/CmdPalKeyboardService/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/cmdpal/CmdPalKeyboardService/pch.cpp b/src/modules/cmdpal/CmdPalKeyboardService/pch.cpp new file mode 100644 index 0000000000..bcb5590be1 --- /dev/null +++ b/src/modules/cmdpal/CmdPalKeyboardService/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/cmdpal/CmdPalKeyboardService/pch.h b/src/modules/cmdpal/CmdPalKeyboardService/pch.h new file mode 100644 index 0000000000..b10d0155ca --- /dev/null +++ b/src/modules/cmdpal/CmdPalKeyboardService/pch.h @@ -0,0 +1,4 @@ +#pragma once +#include <Unknwn.h> +#include <winrt/Windows.Foundation.h> +#include <winrt/Windows.Foundation.Collections.h> diff --git a/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj b/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj index 4395e340fa..ebbeada4fd 100644 --- a/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj +++ b/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj @@ -1,7 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="..\Microsoft.CmdPal.UI\CmdPal.pre.props" Condition="Exists('..\Microsoft.CmdPal.UI\CmdPal.pre.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>17.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> @@ -10,17 +12,16 @@ <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion> <TargetName>PowerToys.CmdPalModuleInterface</TargetName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -34,31 +35,34 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\version\version.vcxproj"> - <Project>{cc6e41ac-8174-4e8a-8d22-85dd7f4851df}</Project> - </ProjectReference> </ItemGroup> <ItemDefinitionGroup> <ClCompile> - <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <PreprocessorDefinitions> + EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL; + %(PreprocessorDefinitions); + </PreprocessorDefinitions> + <PreprocessorDefinitions Condition="'$(CommandPaletteBranding)'=='' or '$(CommandPaletteBranding)'=='Dev'"> + IS_DEV_BRANDING;%(PreprocessorDefinitions) + </PreprocessorDefinitions> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> </Link> </ItemDefinitionGroup> + <ItemGroup> <ClInclude Include="pch.h" /> - <ClInclude Include="resource.h" /> </ItemGroup> <ItemGroup> <ClCompile Include="dllmain.cpp" /> @@ -66,22 +70,19 @@ <PrecompiledHeader>Create</PrecompiledHeader> </ClCompile> </ItemGroup> - <ItemGroup> - <ResourceCompile Include="CmdPalModuleInterface.rc" /> - </ItemGroup> <ItemGroup> <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> -</Project> \ No newline at end of file +</Project> diff --git a/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp b/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp index aceadabdd8..7c6a7926db 100644 --- a/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp +++ b/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp @@ -3,6 +3,7 @@ #include <interface/powertoy_module_interface.h> +#include <atomic> #include <common/logger/logger.h> #include <common/utils/logger_helper.h> #include <common/SettingsAPI/settings_helpers.h> @@ -10,10 +11,11 @@ #include <common/utils/resources.h> #include <common/utils/package.h> #include <common/utils/process_path.h> +#include <common/utils/winapi_error.h> #include <common/interop/shared_constants.h> #include <Psapi.h> #include <TlHelp32.h> -#include <common/utils/winapi_error.h> +#include <thread> HINSTANCE g_hInst_cmdPal = 0; @@ -37,8 +39,6 @@ BOOL APIENTRY DllMain(HMODULE hInstance, class CmdPal : public PowertoyModuleIface { private: - bool m_enabled = false; - std::wstring app_name; //contains the non localized key of the powertoy @@ -46,7 +46,10 @@ private: HANDLE m_hTerminateEvent; - void LaunchApp(const std::wstring& appPath, const std::wstring& commandLineArgs, bool elevated) + // Track if this is the first call to enable + bool firstEnableCall = true; + + static bool LaunchApp(const std::wstring& appPath, const std::wstring& commandLineArgs, bool elevated, bool silentFail) { std::wstring dir = std::filesystem::path(appPath).parent_path(); @@ -54,6 +57,10 @@ private: sei.cbSize = sizeof(SHELLEXECUTEINFO); sei.hwnd = nullptr; sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE; + if (silentFail) + { + sei.fMask = sei.fMask | SEE_MASK_FLAG_NO_UI; + } sei.lpVerb = elevated ? L"runas" : L"open"; sei.lpFile = appPath.c_str(); sei.lpParameters = commandLineArgs.c_str(); @@ -64,7 +71,11 @@ private: { std::wstring error = get_last_error_or_default(GetLastError()); Logger::error(L"Failed to launch process. {}", error); + return false; } + + m_launched.store(true); + return true; } std::vector<DWORD> GetProcessesIdByName(const std::wstring& processName) @@ -106,22 +117,27 @@ private: for (DWORD pid : processIds) { - HANDLE hProcess = OpenProcess(PROCESS_TERMINATE, FALSE, pid); + HANDLE hProcess = OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, FALSE, pid); if (hProcess != NULL) { SetEvent(m_hTerminateEvent); - // Wait for 1.5 seconds for the process to end correctly and stop etw tracer - WaitForSingleObject(hProcess, 1500); + // Wait for 1.5 seconds for the process to end correctly, allowing time for ETW tracer and extensions to stop + if (WaitForSingleObject(hProcess, 1500) == WAIT_TIMEOUT) + { + TerminateProcess(hProcess, 0); + } - TerminateProcess(hProcess, 0); CloseHandle(hProcess); } } } public: + static std::atomic<bool> m_enabled; + static std::atomic<bool> m_launched; + CmdPal() { app_name = L"CmdPal"; @@ -133,10 +149,7 @@ public: ~CmdPal() { - if (m_enabled) - { - } - m_enabled = false; + CmdPal::m_enabled.store(false); } // Destroy the powertoy and free memory @@ -203,16 +216,24 @@ public: { Logger::trace("CmdPal::enable()"); - m_enabled = true; + CmdPal::m_enabled.store(true); - try + std::wstring packageName = L"Microsoft.CommandPalette"; + // Launch CmdPal as normal user using explorer + std::wstring launchPath = L"explorer.exe"; + std::wstring launchArgs = L"x-cmdpal://background"; +#ifdef IS_DEV_BRANDING + packageName = L"Microsoft.CommandPalette.Dev"; +#endif + + if (!package::GetRegisteredPackage(packageName, false).has_value()) { - if (!package::GetRegisteredPackage(L"Microsoft.CommandPalette", false).has_value()) + try { Logger::info(L"CmdPal not installed. Installing..."); std::wstring installationFolder = get_module_folderpath(); -#if _DEBUG +#ifdef _DEBUG std::wstring archSubdir = L"x64"; #ifdef _M_ARM64 archSubdir = L"ARM64"; @@ -234,19 +255,34 @@ public: } } } - } - catch (std::exception& e) - { - std::string errorMessage{ "Exception thrown while trying to install CmdPal package: " }; - errorMessage += e.what(); - Logger::error(errorMessage); + catch (std::exception& e) + { + std::string errorMessage{ "Exception thrown while trying to install CmdPal package: " }; + errorMessage += e.what(); + Logger::error(errorMessage); + } } -#if _DEBUG - LaunchApp(std::wstring{ L"shell:AppsFolder\\" } + L"Microsoft.CommandPalette.Dev_8wekyb3d8bbwe!App", L"RunFromPT", false); -#else - LaunchApp(std::wstring{ L"shell:AppsFolder\\" } + L"Microsoft.CommandPalette_8wekyb3d8bbwe!App", L"RunFromPT", false); -#endif + if (!package::GetRegisteredPackage(packageName, false).has_value()) + { + Logger::error("Cmdpal is not registered, quit.."); + return; + } + + if (!firstEnableCall) + { + Logger::trace("Not first attempt, try to launch"); + LaunchApp(launchPath, launchArgs, false /*no elevated*/, false /*error pop up*/); + } + else + { + // If first time enable, do retry launch. + Logger::trace("First attempt, try to launch"); + std::thread launchThread(&CmdPal::RetryLaunch, launchPath, launchArgs); + launchThread.detach(); + } + + firstEnableCall = false; } virtual void disable() @@ -254,7 +290,44 @@ public: Logger::trace("CmdPal::disable()"); TerminateCmdPal(); - m_enabled = false; + CmdPal::m_enabled.store(false); + } + + static void RetryLaunch(std::wstring path, std::wstring cmdArgs) + { + const int base_delay_milliseconds = 1000; + int max_retry = 9; // 2**9 - 1 seconds. Control total wait time within 10 min. + int retry = 0; + do + { + auto launch_result = LaunchApp(path, cmdArgs, false, retry < max_retry); + if (launch_result) + { + Logger::info(L"CmdPal launched successfully after {} retries.", retry); + return; + } + else + { + Logger::error(L"Retry {} launch CmdPal launch failed.", retry); + } + + // When we got max retry, we don't need to wait for the next retry. + if (retry < max_retry) + { + int delay = base_delay_milliseconds * (1 << (retry)); + std::this_thread::sleep_for(std::chrono::milliseconds(delay)); + } + ++retry; + } while (retry <= max_retry && m_enabled.load() && !m_launched.load()); + + if (!m_enabled.load() || m_launched.load()) + { + Logger::error(L"Retry cancelled. CmdPal is disabled or already launched."); + } + else + { + Logger::error(L"CmdPal launch failed after {} attempts.", retry); + } } virtual bool on_hotkey(size_t) override @@ -269,10 +342,13 @@ public: virtual bool is_enabled() override { - return m_enabled; + return CmdPal::m_enabled.load(); } }; +std::atomic<bool> CmdPal::m_enabled{ false }; +std::atomic<bool> CmdPal::m_launched{ false }; + extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() { return new CmdPal(); diff --git a/src/modules/cmdpal/CmdPalModuleInterface/packages.config b/src/modules/cmdpal/CmdPalModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/cmdpal/CmdPalModuleInterface/packages.config +++ b/src/modules/cmdpal/CmdPalModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/cmdpal/CommandPalette.slnf b/src/modules/cmdpal/CommandPalette.slnf new file mode 100644 index 0000000000..c6ccbb7338 --- /dev/null +++ b/src/modules/cmdpal/CommandPalette.slnf @@ -0,0 +1,56 @@ +{ + "solution": { + "path": "..\\..\\..\\PowerToys.slnx", + "projects": [ + "src\\common\\CalculatorEngineCommon\\CalculatorEngineCommon.vcxproj", + "src\\common\\ManagedCommon\\ManagedCommon.csproj", + "src\\common\\ManagedCsWin32\\ManagedCsWin32.csproj", + "src\\common\\ManagedTelemetry\\Telemetry\\ManagedTelemetry.csproj", + "src\\common\\interop\\PowerToys.Interop.vcxproj", + "src\\common\\version\\version.vcxproj", + "src\\modules\\cmdpal\\CmdPalKeyboardService\\CmdPalKeyboardService.vcxproj", + "src\\modules\\cmdpal\\CmdPalModuleInterface\\CmdPalModuleInterface.vcxproj", + "src\\modules\\cmdpal\\Core\\Microsoft.CmdPal.Core.Common\\Microsoft.CmdPal.Core.Common.csproj", + "src\\modules\\cmdpal\\Core\\Microsoft.CmdPal.Core.ViewModels\\Microsoft.CmdPal.Core.ViewModels.csproj", + "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", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Registry.UnitTests\\Microsoft.CmdPal.Ext.Registry.UnitTests.csproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Shell.UnitTests\\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.System.UnitTests\\Microsoft.CmdPal.Ext.System.UnitTests.csproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.TimeDate.UnitTests\\Microsoft.CmdPal.Ext.TimeDate.UnitTests.csproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.UnitTestsBase\\Microsoft.CmdPal.Ext.UnitTestBase.csproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.WebSearch.UnitTests\\Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj", + "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", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.ClipboardHistory\\Microsoft.CmdPal.Ext.ClipboardHistory.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Indexer\\Microsoft.CmdPal.Ext.Indexer.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Registry\\Microsoft.CmdPal.Ext.Registry.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.RemoteDesktop\\Microsoft.CmdPal.Ext.RemoteDesktop.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Shell\\Microsoft.CmdPal.Ext.Shell.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.System\\Microsoft.CmdPal.Ext.System.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.TimeDate\\Microsoft.CmdPal.Ext.TimeDate.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.WebSearch\\Microsoft.CmdPal.Ext.WebSearch.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.WinGet\\Microsoft.CmdPal.Ext.WinGet.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.WindowWalker\\Microsoft.CmdPal.Ext.WindowWalker.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.WindowsServices\\Microsoft.CmdPal.Ext.WindowsServices.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.WindowsSettings\\Microsoft.CmdPal.Ext.WindowsSettings.csproj", + "src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.WindowsTerminal\\Microsoft.CmdPal.Ext.WindowsTerminal.csproj", + "src\\modules\\cmdpal\\ext\\ProcessMonitorExtension\\ProcessMonitorExtension.csproj", + "src\\modules\\cmdpal\\ext\\SamplePagesExtension\\SamplePagesExtension.csproj", + "src\\modules\\cmdpal\\extensionsdk\\Microsoft.CommandPalette.Extensions.Toolkit\\Microsoft.CommandPalette.Extensions.Toolkit.csproj", + "src\\modules\\cmdpal\\extensionsdk\\Microsoft.CommandPalette.Extensions\\Microsoft.CommandPalette.Extensions.vcxproj" + ] + } +} \ No newline at end of file diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/AppPackagingFlavor.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/AppPackagingFlavor.cs new file mode 100644 index 0000000000..2d23db30b3 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/AppPackagingFlavor.cs @@ -0,0 +1,26 @@ +// 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; + +/// <summary> +/// Represents the packaging flavor of the application. +/// </summary> +public enum AppPackagingFlavor +{ + /// <summary> + /// Application is packaged as a Windows MSIX package. + /// </summary> + Packaged, + + /// <summary> + /// Application is running unpackaged (native executable). + /// </summary> + Unpackaged, + + /// <summary> + /// Application is running as unpackaged portable (self-contained distribution). + /// </summary> + UnpackagedPortable, +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/CoreLogger.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/CoreLogger.cs new file mode 100644 index 0000000000..1863756c75 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/CoreLogger.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.CmdPal.Core.Common; + +public static class CoreLogger +{ + public static void InitializeLogger(ILogger implementation) + { + _logger = implementation; + } + + private static ILogger? _logger; + + public static void LogError(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogError(message, ex, memberName, sourceFilePath, sourceLineNumber); + } + + public static void LogError(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogError(message, memberName, sourceFilePath, sourceLineNumber); + } + + public static void LogWarning(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogWarning(message, memberName, sourceFilePath, sourceLineNumber); + } + + public static void LogInfo(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogInfo(message, memberName, sourceFilePath, sourceLineNumber); + } + + public static void LogDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogDebug(message, memberName, sourceFilePath, sourceLineNumber); + } + + public static void LogTrace([System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + _logger?.LogTrace(memberName, sourceFilePath, sourceLineNumber); + } +} + +public interface ILogger +{ + void LogError(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + void LogError(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + void LogWarning(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + void LogInfo(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + void LogDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); + + void LogTrace([System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/DiagnosticsHelper.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/DiagnosticsHelper.cs new file mode 100644 index 0000000000..ee28b94ac4 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/DiagnosticsHelper.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +/// <summary> +/// Provides utility methods for building diagnostic and error messages. +/// </summary> +public static class DiagnosticsHelper +{ + /// <summary> + /// Builds a comprehensive exception message with timestamp and detailed diagnostic information. + /// </summary> + /// <param name="exception">The exception that occurred.</param> + /// <param name="extensionHint">A hint about which extension caused the exception to help with debugging.</param> + /// <returns>A string containing the exception details, timestamp, and source information for diagnostic purposes.</returns> + public static string BuildExceptionMessage(Exception exception, string? extensionHint) + { + var locationHint = string.IsNullOrWhiteSpace(extensionHint) ? "application" : $"'{extensionHint}' extension"; + + // 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]"; + } + + // note: keep date time kind and format consistent with the log + return $""" + ============================================================ + 😢 An unexpected error occurred in the {locationHint}. + + Summary: + Message: {message} + Type: {exception.GetType().FullName} + Source: {exception.Source ?? "N/A"} + Time: {DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fffffff} + HRESULT: 0x{exception.HResult:X8} ({exception.HResult}) + + Stack Trace: + {exception.StackTrace ?? "[No stack trace available]"} + + ------------------ Full Exception Details ------------------ + {exception} + + ℹ️ If you need further assistance, please include this information in your support request. + ℹ️ Before sending, take a quick look to make sure it doesn't contain any personal or sensitive information. + ============================================================ + + """; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ExtensionHostInstance.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/ExtensionHostInstance.cs similarity index 93% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ExtensionHostInstance.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/ExtensionHostInstance.cs index 25ff815a69..4c1f690635 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/ExtensionHostInstance.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/ExtensionHostInstance.cs @@ -7,7 +7,7 @@ using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.Common; +namespace Microsoft.CmdPal.Core.Common; public partial class ExtensionHostInstance { @@ -24,7 +24,7 @@ public partial class ExtensionHostInstance /// <param name="message">The log message to send</param> public void LogMessage(ILogMessage message) { - if (Host != null) + if (Host is not null) { _ = Task.Run(async () => { @@ -47,7 +47,7 @@ public partial class ExtensionHostInstance public void ShowStatus(IStatusMessage message, StatusContext context) { - if (Host != null) + if (Host is not null) { _ = Task.Run(async () => { @@ -64,7 +64,7 @@ public partial class ExtensionHostInstance public void HideStatus(IStatusMessage message) { - if (Host != null) + if (Host is not null) { _ = Task.Run(async () => { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/IPrecomputedListItem.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/IPrecomputedListItem.cs new file mode 100644 index 0000000000..2847ee7b12 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/IPrecomputedListItem.cs @@ -0,0 +1,27 @@ +// 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.Text; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +/// <summary> +/// Represents an item that can provide precomputed fuzzy matching targets for its title and subtitle. +/// </summary> +public interface IPrecomputedListItem +{ + /// <summary> + /// Gets the fuzzy matching target for the item's title. + /// </summary> + /// <param name="matcher">The precomputed fuzzy matcher used to build the target.</param> + /// <returns>The fuzzy target for the title.</returns> + FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher); + + /// <summary> + /// Gets the fuzzy matching target for the item's subtitle. + /// </summary> + /// <param name="matcher">The precomputed fuzzy matcher used to build the target.</param> + /// <returns>The fuzzy target for the subtitle.</returns> + FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InterlockedBoolean.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InterlockedBoolean.cs new file mode 100644 index 0000000000..d098c33a8b --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InterlockedBoolean.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +/// <summary> +/// Thread-safe boolean implementation using atomic operations +/// </summary> +public struct InterlockedBoolean(bool initialValue = false) +{ + private int _value = initialValue ? 1 : 0; + + /// <summary> + /// Gets or sets the boolean value atomically + /// </summary> + public bool Value + { + get => Volatile.Read(ref _value) == 1; + set => Interlocked.Exchange(ref _value, value ? 1 : 0); + } + + /// <summary> + /// Atomically sets the value to true + /// </summary> + /// <returns>True if the value was previously false, false if it was already true</returns> + public bool Set() + { + return Interlocked.Exchange(ref _value, 1) == 0; + } + + /// <summary> + /// Atomically sets the value to false + /// </summary> + /// <returns>True if the value was previously true, false if it was already false</returns> + public bool Clear() + { + return Interlocked.Exchange(ref _value, 0) == 1; + } + + public override int GetHashCode() => Value.GetHashCode(); + + public override string ToString() => Value.ToString(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InternalListHelpers.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InternalListHelpers.cs new file mode 100644 index 0000000000..60d841aaf8 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/InternalListHelpers.cs @@ -0,0 +1,142 @@ +// 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.Buffers; +using System.Diagnostics; +using Microsoft.CmdPal.Core.Common.Text; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +public static partial class InternalListHelpers +{ + public static RoScored<T>[] FilterListWithScores<T>( + IEnumerable<T>? items, + in FuzzyQuery query, + in ScoringFunction<T> scoreFunction) + { + if (items == null) + { + return []; + } + + // Try to get initial capacity hint + var initialCapacity = items switch + { + ICollection<T> col => col.Count, + IReadOnlyCollection<T> rc => rc.Count, + _ => 64, + }; + + var buffer = ArrayPool<RoScored<T>>.Shared.Rent(initialCapacity); + var count = 0; + + try + { + foreach (var item in items) + { + var score = scoreFunction(in query, item); + if (score <= 0) + { + continue; + } + + if (count == buffer.Length) + { + GrowBuffer(ref buffer, count); + } + + buffer[count++] = new RoScored<T>(item, score); + } + + Array.Sort(buffer, 0, count, default(RoScoredDescendingComparer<T>)); + var result = GC.AllocateUninitializedArray<RoScored<T>>(count); + buffer.AsSpan(0, count).CopyTo(result); + return result; + } + finally + { + ArrayPool<RoScored<T>>.Shared.Return(buffer); + } + } + + private static void GrowBuffer<T>(ref RoScored<T>[] buffer, int count) + { + var newBuffer = ArrayPool<RoScored<T>>.Shared.Rent(buffer.Length * 2); + buffer.AsSpan(0, count).CopyTo(newBuffer); + ArrayPool<RoScored<T>>.Shared.Return(buffer); + buffer = newBuffer; + } + + public static T[] FilterList<T>(IEnumerable<T> items, in FuzzyQuery query, ScoringFunction<T> scoreFunction) + { + // Try to get initial capacity hint + var initialCapacity = items switch + { + ICollection<T> col => col.Count, + IReadOnlyCollection<T> rc => rc.Count, + _ => 64, + }; + + var buffer = ArrayPool<RoScored<T>>.Shared.Rent(initialCapacity); + var count = 0; + + try + { + foreach (var item in items) + { + var score = scoreFunction(in query, item); + if (score <= 0) + { + continue; + } + + if (count == buffer.Length) + { + GrowBuffer(ref buffer, count); + } + + buffer[count++] = new RoScored<T>(item, score); + } + + Array.Sort(buffer, 0, count, default(RoScoredDescendingComparer<T>)); + + var result = GC.AllocateUninitializedArray<T>(count); + for (var i = 0; i < count; i++) + { + result[i] = buffer[i].Item; + } + + return result; + } + finally + { + ArrayPool<RoScored<T>>.Shared.Return(buffer); + } + } + + private readonly struct RoScoredDescendingComparer<T> : IComparer<RoScored<T>> + { + public int Compare(RoScored<T> x, RoScored<T> y) => y.Score.CompareTo(x.Score); + } +} + +public delegate int ScoringFunction<in T>(in FuzzyQuery query, T item); + +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +public readonly struct RoScored<T> +{ + public readonly int Score; + public readonly T Item; + + public RoScored(T item, int score) + { + Score = score; + Item = item; + } + + private string GetDebuggerDisplay() + { + return "Score = " + Score + ", Item = " + Item; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/NativeEventWaiter.cs similarity index 89% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/NativeEventWaiter.cs index 6ec1885a4c..df644795f0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/NativeEventWaiter.cs @@ -7,9 +7,9 @@ using System.Threading; using Microsoft.UI.Dispatching; -namespace Microsoft.CmdPal.Common.Helpers; +namespace Microsoft.CmdPal.Core.Common.Helpers; -public static class NativeEventWaiter +public static partial class NativeEventWaiter { public static void WaitForEventLoop(string eventName, Action callback) { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/PathHelper.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/PathHelper.cs new file mode 100644 index 0000000000..75cfcac444 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/PathHelper.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using Windows.Win32; +using Windows.Win32.Storage.FileSystem; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +public static class PathHelper +{ + public static bool Exists(string path, out bool isDirectory) + { + isDirectory = false; + if (string.IsNullOrEmpty(path)) + { + return false; + } + + string? fullPath; + try + { + fullPath = Path.GetFullPath(path); + } + catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException) + { + return false; + } + + var result = ExistsCore(fullPath, out isDirectory); + if (result && IsDirectorySeparator(fullPath[^1])) + { + // Some sys-calls remove all trailing slashes and may give false positives for existing files. + // We want to make sure that if the path ends in a trailing slash, it's truly a directory. + return isDirectory; + } + + return result; + } + + /// <summary> + /// Normalize potential local/UNC file path text input: trim whitespace and surrounding quotes. + /// Windows file paths cannot contain quotes, but user input can include them. + /// </summary> + public static string Unquote(string? text) + { + return string.IsNullOrWhiteSpace(text) ? (text ?? string.Empty) : text.Trim().Trim('"'); + } + + /// <summary> + /// Quick heuristic to determine if the string looks like a Windows file path (UNC or drive-letter based). + /// </summary> + public static bool LooksLikeFilePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + // UNC path + if (path.StartsWith(@"\\", StringComparison.Ordinal)) + { + // Win32 File Namespaces \\?\ + if (path.StartsWith(@"\\?\", StringComparison.Ordinal)) + { + return IsSlow(path[4..]); + } + + // Basic UNC path validation: \\server\share or \\server\share\path + var parts = path[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries); + + return parts.Length >= 2; // At minimum: server and share + } + + // Drive letter path (e.g., C:\ or C:) + return path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':'; + } + + /// <summary> + /// Validates path syntax without performing any I/O by using Path.GetFullPath. + /// </summary> + public static bool HasValidPathSyntax(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + _ = Path.GetFullPath(path); + return true; + } + catch + { + return false; + } + } + + /// <summary> + /// Checks if a string represents a valid Windows file path (local or network) + /// using fast syntax validation only. Reuses LooksLikeFilePath and HasValidPathSyntax. + /// </summary> + public static bool IsValidFilePath(string? path) + { + return LooksLikeFilePath(path) && HasValidPathSyntax(path); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDirectorySeparator(char c) + { + return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + } + + private static bool ExistsCore(string fullPath, out bool isDirectory) + { + var attributes = PInvoke.GetFileAttributes(fullPath); + var result = attributes != PInvoke.INVALID_FILE_ATTRIBUTES; + isDirectory = result && (attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0; + return result; + } + + public static bool IsSlow(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + try + { + var root = Path.GetPathRoot(path); + if (!string.IsNullOrEmpty(root)) + { + if (root.Length > 2 && char.IsLetter(root[0]) && root[1] == ':') + { + return new DriveInfo(root).DriveType is not (DriveType.Fixed or DriveType.Ram); + } + else if (root.StartsWith(@"\\", StringComparison.Ordinal)) + { + return !root.StartsWith(@"\\?\", StringComparison.Ordinal) || IsSlow(root[4..]); + } + } + + return false; + } + catch + { + return false; + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs new file mode 100644 index 0000000000..f5e4d3e97b --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncGate.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +/// <summary> +/// An async gate that ensures only one operation runs at a time. +/// If ExecuteAsync is called while already executing, it cancels the current execution +/// and starts the operation again (superseding behavior). +/// </summary> +public sealed partial class SupersedingAsyncGate : IDisposable +{ + private readonly Func<CancellationToken, Task> _action; + private readonly Lock _lock = new(); + private int _callId; + private TaskCompletionSource<bool>? _currentTcs; + private CancellationTokenSource? _currentCancellationSource; + private Task? _executingTask; + + public SupersedingAsyncGate(Func<CancellationToken, Task> action) + { + ArgumentNullException.ThrowIfNull(action); + _action = action; + } + + /// <summary> + /// Executes the configured action. If another execution is running, this call will + /// cancel the current execution and restart the operation. + /// </summary> + /// <param name="cancellationToken">Optional external cancellation token</param> + public async Task ExecuteAsync(CancellationToken cancellationToken = default) + { + TaskCompletionSource<bool> tcs; + + lock (_lock) + { + _currentCancellationSource?.Cancel(); + _currentTcs?.TrySetException(new OperationCanceledException("Superseded by newer call")); + + tcs = new(); + _currentTcs = tcs; + _callId++; + + var shouldStartExecution = _executingTask is null; + if (shouldStartExecution) + { + _executingTask = Task.Run(ExecuteLoop, CancellationToken.None); + } + } + + await using var ctr = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)); + await tcs.Task; + } + + private async Task ExecuteLoop() + { + try + { + while (true) + { + TaskCompletionSource<bool>? currentTcs; + CancellationTokenSource? currentCts; + int currentCallId; + + lock (_lock) + { + currentTcs = _currentTcs; + currentCallId = _callId; + + if (currentTcs is null) + { + break; + } + + _currentCancellationSource?.Dispose(); + _currentCancellationSource = new(); + currentCts = _currentCancellationSource; + } + + try + { + await _action(currentCts.Token); + CompleteIfCurrent(currentTcs, currentCallId, static t => t.TrySetResult(true)); + } + catch (OperationCanceledException) + { + CompleteIfCurrent(currentTcs, currentCallId, tcs => tcs.TrySetCanceled(currentCts.Token)); + } + catch (Exception ex) + { + CompleteIfCurrent(currentTcs, currentCallId, tcs => tcs.TrySetException(ex)); + } + } + } + finally + { + lock (_lock) + { + _currentTcs = null; + _currentCancellationSource?.Dispose(); + _currentCancellationSource = null; + _executingTask = null; + } + } + } + + private void CompleteIfCurrent( + TaskCompletionSource<bool> candidate, + int id, + Action<TaskCompletionSource<bool>> complete) + { + lock (_lock) + { + if (_currentTcs == candidate && _callId == id) + { + complete(candidate); + _currentTcs = null; + } + } + } + + public void Dispose() + { + lock (_lock) + { + _currentCancellationSource?.Cancel(); + _currentCancellationSource?.Dispose(); + _currentTcs?.TrySetException(new ObjectDisposedException(nameof(SupersedingAsyncGate))); + _currentTcs = null; + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncValueGate`1.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncValueGate`1.cs new file mode 100644 index 0000000000..4fab6bf194 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/SupersedingAsyncValueGate`1.cs @@ -0,0 +1,189 @@ +// 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.Helpers; + +/// <summary> +/// An async gate that ensures only one value computation runs at a time. +/// If ExecuteAsync is called while already executing, it cancels the current computation +/// and starts the operation again (superseding behavior). +/// Once a value is successfully computed, it is applied (via the provided <see cref="Action{T}"/>). +/// The apply step uses its own lock so that long-running apply logic does not block the +/// computation / superseding pipeline, while still remaining serialized with respect to +/// other apply calls. +/// </summary> +/// <typeparam name="T">The type of the computed value.</typeparam> +public sealed partial class SupersedingAsyncValueGate<T> : IDisposable +{ + private readonly Func<CancellationToken, Task<T>> _valueFactory; + private readonly Action<T> _apply; + private readonly Lock _lock = new(); // Controls scheduling / superseding + private readonly Lock _applyLock = new(); // Serializes application of results + private int _callId; + private TaskCompletionSource<T>? _currentTcs; + private CancellationTokenSource? _currentCancellationSource; + private Task? _executingTask; + + public SupersedingAsyncValueGate( + Func<CancellationToken, Task<T>> valueFactory, + Action<T> apply) + { + ArgumentNullException.ThrowIfNull(valueFactory); + ArgumentNullException.ThrowIfNull(apply); + _valueFactory = valueFactory; + _apply = apply; + } + + /// <summary> + /// Executes the configured value computation. If another execution is running, this call will + /// cancel the current execution and restart the computation. The returned task completes when + /// (and only if) the computation associated with this invocation completes (or is canceled / superseded). + /// </summary> + /// <param name="cancellationToken">Optional external cancellation token.</param> + /// <returns>The computed value for this invocation.</returns> + public async Task<T> ExecuteAsync(CancellationToken cancellationToken = default) + { + TaskCompletionSource<T> tcs; + + lock (_lock) + { + // Supersede any in-flight computation. + _currentCancellationSource?.Cancel(); + _currentTcs?.TrySetException(new OperationCanceledException("Superseded by newer call")); + + tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + _currentTcs = tcs; + _callId++; + + if (_executingTask is null) + { + _executingTask = Task.Run(ExecuteLoop, CancellationToken.None); + } + } + + using var ctr = cancellationToken.Register(state => ((TaskCompletionSource<T>)state!).TrySetCanceled(cancellationToken), tcs); + return await tcs.Task.ConfigureAwait(false); + } + + private async Task ExecuteLoop() + { + try + { + while (true) + { + TaskCompletionSource<T>? currentTcs; + CancellationTokenSource? currentCts; + int currentCallId; + + lock (_lock) + { + currentTcs = _currentTcs; + currentCallId = _callId; + + if (currentTcs is null) + { + break; // Nothing pending. + } + + _currentCancellationSource?.Dispose(); + _currentCancellationSource = new(); + currentCts = _currentCancellationSource; + } + + try + { + var value = await _valueFactory(currentCts.Token).ConfigureAwait(false); + CompleteSuccessIfCurrent(currentTcs, currentCallId, value); + } + catch (OperationCanceledException) + { + CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetCanceled(currentCts.Token)); + } + catch (Exception ex) + { + CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetException(ex)); + } + } + } + finally + { + lock (_lock) + { + _currentTcs = null; + _currentCancellationSource?.Dispose(); + _currentCancellationSource = null; + _executingTask = null; + } + } + } + + private void CompleteSuccessIfCurrent(TaskCompletionSource<T> candidate, int id, T value) + { + var shouldApply = false; + lock (_lock) + { + if (_currentTcs == candidate && _callId == id) + { + // Mark as consumed so a new computation can start immediately. + _currentTcs = null; + shouldApply = true; + } + } + + if (!shouldApply) + { + return; // Superseded meanwhile. + } + + Exception? applyException = null; + try + { + lock (_applyLock) + { + _apply(value); + } + } + catch (Exception ex) + { + applyException = ex; + } + + if (applyException is null) + { + candidate.TrySetResult(value); + } + else + { + candidate.TrySetException(applyException); + } + } + + private void CompleteIfCurrent( + TaskCompletionSource<T> candidate, + int id, + Action<TaskCompletionSource<T>> complete) + { + lock (_lock) + { + if (_currentTcs == candidate && _callId == id) + { + complete(candidate); + _currentTcs = null; + } + } + } + + public void Dispose() + { + lock (_lock) + { + _currentCancellationSource?.Cancel(); + _currentCancellationSource?.Dispose(); + _currentTcs?.TrySetException(new ObjectDisposedException(nameof(SupersedingAsyncValueGate<T>))); + _currentTcs = null; + } + + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/VersionHelper.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/VersionHelper.cs new file mode 100644 index 0000000000..ad280b6c52 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/VersionHelper.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using Windows.ApplicationModel; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +/// <summary> +/// Helper class for retrieving application version information safely. +/// </summary> +internal static class VersionHelper +{ + /// <summary> + /// Gets the application version as a string in the format "Major.Minor.Build.Revision". + /// Falls back to assembly version if packaged version is unavailable, and returns a default value if both fail. + /// </summary> + /// <returns>The application version string, or a fallback value if retrieval fails.</returns> + public static string GetAppVersionSafe() + { + if (TryGetPackagedVersion(out var version)) + { + return version; + } + + if (TryGetAssemblyVersion(out version)) + { + return version; + } + + return "?"; + } + + /// <summary> + /// Attempts to retrieve the application version from the package manifest. + /// </summary> + /// <param name="version">The version string if successful, or an empty string if unsuccessful.</param> + /// <returns>True if the version was retrieved successfully; otherwise, false.</returns> + private static bool TryGetPackagedVersion(out string version) + { + version = string.Empty; + try + { + // Package.Current throws InvalidOperationException if the app is not packaged + var v = Package.Current.Id.Version; + version = $"{v.Major}.{v.Minor}.{v.Build}.{v.Revision}"; + return true; + } + catch (InvalidOperationException) + { + return false; + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to get version from the package", ex); + return false; + } + } + + /// <summary> + /// Attempts to retrieve the application version from the executable file. + /// </summary> + /// <param name="version">The version string if successful, or an empty string if unsuccessful.</param> + /// <returns>True if the version was retrieved successfully; otherwise, false.</returns> + private static bool TryGetAssemblyVersion(out string version) + { + version = string.Empty; + try + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath)) + { + return false; + } + + var info = FileVersionInfo.GetVersionInfo(processPath); + version = $"{info.FileMajorPart}.{info.FileMinorPart}.{info.FileBuildPart}.{info.FilePrivatePart}"; + return true; + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to get version from the executable", ex); + return false; + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/WellKnownKeyChords.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/WellKnownKeyChords.cs new file mode 100644 index 0000000000..19364ddbd6 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/WellKnownKeyChords.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +using Windows.System; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +/// <summary> +/// Well-known key chords used in the Command Palette and extensions. +/// </summary> +/// <remarks> +/// Assigned key chords should not conflict with system or application shortcuts. +/// However, the key chords in this class are not guaranteed to be unique and may conflict +/// with each other, especially when commands appear together in the same menu. +/// </remarks> +public static class WellKnownKeyChords +{ + /// <summary> + /// Gets the well-known key chord for opening the file location. Shortcut: Ctrl+Shift+E. + /// </summary> + public static KeyChord OpenFileLocation { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.E); + + /// <summary> + /// Gets the well-known key chord for copying the file path. Shortcut: Ctrl+Shift+C. + /// </summary> + public static KeyChord CopyFilePath { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.C); + + /// <summary> + /// Gets the well-known key chord for opening the current location in a console. Shortcut: Ctrl+Shift+R. + /// </summary> + public static KeyChord OpenInConsole { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.R); + + /// <summary> + /// Gets the well-known key chord for running the selected item as administrator. Shortcut: Ctrl+Shift+Enter. + /// </summary> + public static KeyChord RunAsAdministrator { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.Enter); + + /// <summary> + /// Gets the well-known key chord for running the selected item as a different user. Shortcut: Ctrl+Shift+U. + /// </summary> + public static KeyChord RunAsDifferentUser { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.U); + + /// <summary> + /// Gets the well-known key chord for toggling the pin state. Shortcut: Ctrl+P. + /// </summary> + public static KeyChord TogglePin { get; } = KeyChordHelpers.FromModifiers(ctrl: true, vkey: (int)VirtualKey.P); +} 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 new file mode 100644 index 0000000000..300264967d --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj @@ -0,0 +1,26 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="..\..\CoreCommonProps.props" /> + + <PropertyGroup> + <RootNamespace>Microsoft.CmdPal.Core.Common</RootNamespace> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Hosting" /> + </ItemGroup> + + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>Resources.resx</DependentUpon> + </Compile> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <Generator>ResXFileCodeGenerator</Generator> + </EmbeddedResource> + </ItemGroup> + +</Project> diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.json b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.json new file mode 100644 index 0000000000..59fa7259c4 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/NativeMethods.txt b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt similarity index 50% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/NativeMethods.txt rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt index 0d456bde31..03318381a6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/NativeMethods.txt +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt @@ -1,16 +1,7 @@ -EnableWindow -CoCreateInstance -FileOpenDialog -FileSaveDialog -IFileOpenDialog -IFileSaveDialog -SHCreateItemFromParsingName GetCurrentPackageFullName SetWindowLong GetWindowLong WINDOW_EX_STYLE -SHLoadIndirectString -StrFormatByteSizeEx SFBS_FLAGS MAX_PATH GetDpiForWindow @@ -18,3 +9,11 @@ GetWindowRect GetMonitorInfo SetWindowPos MonitorFromWindow + +SHOW_WINDOW_CMD +ShellExecuteEx +SEE_MASK_INVOKEIDLIST + +GetFileAttributes +FILE_FLAGS_AND_ATTRIBUTES +INVALID_FILE_ATTRIBUTES \ No newline at end of file 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 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// 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. +// </auto-generated> +//------------------------------------------------------------------------------ + +namespace Microsoft.CmdPal.Core.Common.Properties { + using System; + + + /// <summary> + /// A strongly-typed resource class, for looking up localized strings, etc. + /// </summary> + // 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() { + } + + /// <summary> + /// Returns the cached ResourceManager instance used by this class. + /// </summary> + [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; + } + } + + /// <summary> + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// <summary> + /// 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.). + /// </summary> + 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="ErrorReport_Global_Preamble" xml:space="preserve"> + <value>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.)</value> + </data> +</root> \ No newline at end of file diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/ApplicationInfoService.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/ApplicationInfoService.cs new file mode 100644 index 0000000000..0919c38946 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/ApplicationInfoService.cs @@ -0,0 +1,125 @@ +// 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.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.ApplicationModel; + +namespace Microsoft.CmdPal.Core.Common.Services; + +/// <summary> +/// Implementation of IApplicationInfoService providing application-wide information. +/// </summary> +public sealed class ApplicationInfoService : IApplicationInfoService +{ + private readonly Lazy<string> _configDirectory = new(() => Utilities.BaseSettingsPath("Microsoft.CmdPal")); + private readonly Lazy<bool> _isElevated; + private readonly Lazy<string> _logDirectory; + private readonly Lazy<AppPackagingFlavor> _packagingFlavor; + private Func<string>? _getLogDirectory; + + /// <summary> + /// Initializes a new instance of the <see cref="ApplicationInfoService"/> class. + /// The log directory delegate can be set later via <see cref="SetLogDirectory(Func{string})"/>. + /// </summary> + public ApplicationInfoService() + { + _packagingFlavor = new Lazy<AppPackagingFlavor>(DeterminePackagingFlavor); + _isElevated = new Lazy<bool>(DetermineElevationStatus); + _logDirectory = new Lazy<string>(() => _getLogDirectory?.Invoke() ?? "Not available"); + } + + /// <summary> + /// Initializes a new instance of the <see cref="ApplicationInfoService"/> class with an optional log directory provider. + /// </summary> + /// <param name="getLogDirectory">Optional delegate to retrieve the log directory path. If not provided, the log directory will be unavailable.</param> + public ApplicationInfoService(Func<string>? getLogDirectory) + : this() + { + _getLogDirectory = getLogDirectory; + } + + /// <summary> + /// Sets the log directory delegate to be used for retrieving the log directory path. + /// This allows deferred initialization of the logger path. + /// </summary> + /// <param name="getLogDirectory">Delegate to retrieve the log directory path.</param> + public void SetLogDirectory(Func<string> getLogDirectory) + { + ArgumentNullException.ThrowIfNull(getLogDirectory); + _getLogDirectory = getLogDirectory; + } + + public string AppVersion => VersionHelper.GetAppVersionSafe(); + + public AppPackagingFlavor PackagingFlavor => _packagingFlavor.Value; + + public string LogDirectory => _logDirectory.Value; + + public string ConfigDirectory => _configDirectory.Value; + + public bool IsElevated => _isElevated.Value; + + public string GetApplicationInfoSummary() + { + return $""" + Application: + App version: {AppVersion} + Packaging flavor: {PackagingFlavor} + Is elevated: {(IsElevated ? "yes" : "no")} + + 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} + + Paths: + Log directory: {LogDirectory} + Config directory: {ConfigDirectory} + """; + } + + private static AppPackagingFlavor DeterminePackagingFlavor() + { + // Try to determine if running as packaged + try + { + // If this doesn't throw, we're packaged + _ = Package.Current.Id.Version; + return AppPackagingFlavor.Packaged; + } + catch (InvalidOperationException) + { + // Not packaged, check if portable + // For now, we don't support portable yet, so return Unpackaged + // In the future, check for a marker file or environment variable + return AppPackagingFlavor.Unpackaged; + } + catch (Exception ex) + { + CoreLogger.LogError("Failed to determine packaging flavor", ex); + return AppPackagingFlavor.Unpackaged; + } + } + + private static bool DetermineElevationStatus() + { + try + { + var isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + return isElevated; + } + catch (Exception) + { + return false; + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IApplicationInfoService.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IApplicationInfoService.cs new file mode 100644 index 0000000000..f687333d20 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IApplicationInfoService.cs @@ -0,0 +1,49 @@ +// 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; + +/// <summary> +/// Provides access to application-wide information such as version, packaging flavor, and directory paths. +/// </summary> +public interface IApplicationInfoService +{ + /// <summary> + /// Gets the application version as a string in the format "Major.Minor.Build.Revision". + /// </summary> + string AppVersion { get; } + + /// <summary> + /// Gets the packaging flavor of the application. + /// </summary> + AppPackagingFlavor PackagingFlavor { get; } + + /// <summary> + /// Gets the directory path where application logs are stored. + /// </summary> + string LogDirectory { get; } + + /// <summary> + /// Gets the directory path where application configuration files are stored. + /// </summary> + string ConfigDirectory { get; } + + /// <summary> + /// Gets a value indicating whether the application is running with administrator privileges. + /// </summary> + bool IsElevated { get; } + + /// <summary> + /// Gets a formatted summary of application information suitable for logging. + /// </summary> + /// <returns>A formatted string containing application information.</returns> + string GetApplicationInfoSummary(); + + /// <summary> + /// Sets the log directory delegate to be used for retrieving the log directory path. + /// This allows deferred initialization of the logger path. + /// </summary> + /// <param name="getLogDirectory">Delegate to retrieve the log directory path.</param> + void SetLogDirectory(Func<string> getLogDirectory); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionService.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionService.cs similarity index 77% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionService.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionService.cs index 538b281be6..bc35a0d284 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionService.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionService.cs @@ -6,7 +6,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Windows.Foundation; -namespace Microsoft.CmdPal.Common.Services; +namespace Microsoft.CmdPal.Core.Common.Services; public interface IExtensionService { @@ -19,13 +19,13 @@ public interface IExtensionService Task SignalStopExtensionsAsync(); - public event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionAdded; + event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionAdded; - public event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionRemoved; + event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionRemoved; - public void EnableExtension(string extensionUniqueId); + void EnableExtension(string extensionUniqueId); - public void DisableExtension(string extensionUniqueId); + void DisableExtension(string extensionUniqueId); ///// <summary> ///// Gets a boolean indicating whether the extension was disabled due to the corresponding Windows optional feature diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionWrapper.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionWrapper.cs similarity index 98% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionWrapper.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionWrapper.cs index 61667366ba..3aaf4bdf52 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IExtensionWrapper.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IExtensionWrapper.cs @@ -8,7 +8,7 @@ using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Windows.ApplicationModel; -namespace Microsoft.CmdPal.Common.Services; +namespace Microsoft.CmdPal.Core.Common.Services; public interface IExtensionWrapper { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.cs new file mode 100644 index 0000000000..fd68b6e521 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/IRunHistoryService.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Services; + +public interface IRunHistoryService +{ + /// <summary> + /// Gets the run history. + /// </summary> + /// <returns>A list of run history items.</returns> + IReadOnlyList<string> GetRunHistory(); + + /// <summary> + /// Clears the run history. + /// </summary> + void ClearRunHistory(); + + /// <summary> + /// Adds a run history item. + /// </summary> + /// <param name="item">The run history item to add.</param> + void AddRunHistoryItem(string item); +} + +public interface ITelemetryService +{ + void LogRunQuery(string query, int resultCount, ulong durationMs); + + void LogRunCommand(string command, bool asAdmin, bool success); + + void LogOpenUri(string uri, bool isWeb, bool success); +} 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..c98740aaf5 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/ErrorReportBuilder.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +namespace Microsoft.CmdPal.Core.Common.Services.Reports; + +public sealed class ErrorReportBuilder : IErrorReportBuilder +{ + private readonly ErrorReportSanitizer _sanitizer = new(); + private readonly IApplicationInfoService _appInfoService; + + private static string Preamble => Properties.Resources.ErrorReport_Global_Preamble; + + /// <summary> + /// Initializes a new instance of the <see cref="ErrorReportBuilder"/> class. + /// </summary> + /// <param name="appInfoService">Optional application info service. If not provided, a default instance is created.</param> + public ErrorReportBuilder(IApplicationInfoService? appInfoService = null) + { + _appInfoService = appInfoService ?? new ApplicationInfoService(null); + } + + 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(); + + var applicationInfoSummary = GetAppInfoSafe(); + var applicationInfoSummarySanitized = redactPii ? _sanitizer.Sanitize(applicationInfoSummary) : applicationInfoSummary; + + // 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"} + + {applicationInfoSummarySanitized} + + Stack Trace: + {exception.StackTrace} + + ------------------ Full Exception Details ------------------ + {sanitizedFormattedException} + + ============================================================ + """; + + return $""" + {Preamble} + {technicalContent} + """; + } + + private string? GetAppInfoSafe() + { + try + { + return _appInfoService.GetApplicationInfoSummary(); + } + catch (Exception ex) + { + // Getting application info should never throw, but if it does, we don't want it to prevent the report from being generated + var message = CoalesceExceptionMessage(ex); + return $"Failed to get application info summary: {message}"; + } + } + + 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; + +/// <summary> +/// Defines a contract for creating human-readable error reports from exceptions, +/// suitable for logs, telemetry, or user-facing diagnostics. +/// </summary> +/// <remarks> +/// Implementations should ensure reports are consistent and optionally redact +/// personally identifiable or sensitive information when requested. +/// </remarks> +public interface IErrorReportBuilder +{ + /// <summary> + /// Builds a formatted error report for the specified <paramref name="exception"/> and <paramref name="context"/>. + /// </summary> + /// <param name="exception">The exception that triggered the error report.</param> + /// <param name="context"> + /// A short, human-readable description of where or what was being executed when the error occurred + /// (e.g., the operation name, component, or scenario). + /// </param> + /// <param name="redactPii"> + /// When true, attempts to remove or obfuscate personally identifiable or sensitive information + /// (such as file paths, emails, machine/usernames, tokens). Defaults to true. + /// </param> + /// <returns> + /// A formatted string containing the error report, suitable for logging or telemetry submission. + /// </returns> + 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; + +/// <summary> +/// 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. +/// </summary> +/// <remarks> +/// - Rules are applied in their registered order; rule ordering may affect the final output. +/// - Each rule should have a unique <c>description</c> that acts as its identifier. +/// </remarks> +/// <seealso cref="SanitizationRule"/> +public interface ITextSanitizer +{ + /// <summary> + /// Sanitizes the specified input by applying all registered rules in order. + /// </summary> + /// <param name="input">The input text to sanitize. Implementations should handle <see langword="null"/> safely.</param> + /// <returns>The sanitized text after all rules are applied.</returns> + string Sanitize(string? input); + + /// <summary> + /// Adds a sanitization rule using a .NET regular expression pattern and a replacement string. + /// </summary> + /// <param name="pattern">A .NET regular expression pattern used to match text to sanitize.</param> + /// <param name="replacement"> + /// The replacement text used by <c>Regex.Replace</c>. Supports standard regex replacement tokens, + /// including numbered groups (<c>$1</c>) and named groups (<c>${name}</c>). + /// </param> + /// <param name="description"> + /// A human-readable, unique identifier for the rule. Used to list, test, and remove the rule. + /// </param> + /// <remarks> + /// Implementations typically validate <paramref name="pattern"/> is a valid regex and may reject duplicate <paramref name="description"/> values. + /// </remarks> + void AddRule(string pattern, string replacement, string description = ""); + + /// <summary> + /// Removes a previously added rule identified by its <paramref name="description"/>. + /// </summary> + /// <param name="description">The unique description of the rule to remove.</param> + void RemoveRule(string description); + + /// <summary> + /// Gets a read-only snapshot of the currently registered sanitization rules in application order. + /// </summary> + /// <returns>A read-only list of <see cref="SanitizationRule"/> items.</returns> + IReadOnlyList<SanitizationRule> GetRules(); + + /// <summary> + /// Tests a single rule, identified by <paramref name="ruleDescription"/>, against the provided <paramref name="input"/>, + /// without applying other rules. + /// </summary> + /// <param name="input">The input text to test.</param> + /// <param name="ruleDescription">The description (identifier) of the rule to test.</param> + /// <returns>The result of applying only the specified rule to the input.</returns> + 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 ?? "<evaluator>"}"; +} 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<SanitizationRule> 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<SanitizationRule> GetRules() + { + List<SanitizationRule> 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; + +/// <summary> +/// Specific sanitizer used for error report content. Builds on top of the generic TextSanitizer. +/// </summary> +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<ISanitizationRuleProvider> 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<string> 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<SanitizationRule> 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<string> CommonFileStemExclusions = new[] + { + "settings", + "config", + "configuration", + "appsettings", + "options", + "prefs", + "preferences", + "squirrel", + "app", + "system", + "env", + "environment", + "manifest", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public IEnumerable<SanitizationRule> GetRules() + { + const string pattern = """ + (?<full> + (?: [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<SanitizationRule> 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<SanitizationRule> 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 + (?<![A-F0-9:]) # left edge + ( + (?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} | # 1:2:3:4:5:6:7:8 + (?:[A-F0-9]{1,4}:){1,7}: | # 1:: 1:2:...:7:: + (?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} | + (?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} | + (?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} | + (?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} | + (?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} | + [A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} | + :(?::[A-F0-9]{1,4}){1,7} | # ::, ::1, etc. + (?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} | # IPv4 tail + (?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} | + (?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} | + (?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} | + (?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} | + [A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} | + :(?:\d{1,3}\.){3}\d{1,3} + ) + (?:%\w+)? # optional zone id + (?![A-F0-9:]) # right edge + """, + SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex Ipv6Rx(); + + [GeneratedRegex( + """ + (?ix) + \[ + ( + (?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} | + (?:[A-F0-9]{1,4}:){1,7}: | + (?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} | + (?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} | + (?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} | + (?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} | + (?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} | + [A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} | + :(?::[A-F0-9]{1,4}){1,7} | + (?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} | + (?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} | + (?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} | + (?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} | + (?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} | + [A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} | + :(?:\d{1,3}\.){3}\d{1,3} + ) + (?:%\w+)? # optional zone id + \] + (?: : (?<port>\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<SanitizationRule> 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 ---------- + (?<!\w) # not after a letter/digit/underscore + (?<![A-Za-z0-9]-) # avoid starting inside hyphenated tokens (GUID middles, etc.) + + # ---------- global do-not-match guards ---------- + (?! # ISO date (yyyy-mm-dd / yyyy.mm.dd / yyyy/mm/dd) + (?:19|20)\d{2}[-./](?:0[1-9]|1[0-2])[-./](?:0[1-9]|[12]\d|3[01])\b + ) + (?! # EU date (dd-mm-yyyy / dd.mm.yyyy / dd/mm/yyyy) + (?:0[1-9]|[12]\d|3[01])[-./](?:0[1-9]|1[0-2])[-./](?:19|20)\d{2}\b + ) + (?! # ISO datetime like 2025-08-24T14:32[:ss][Z|±hh:mm] + (?:19|20)\d{2}-\d{2}-\d{2}[T\s]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?\b + ) + (?!\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b) # IPv4 with optional :port + (?!\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b) # GUID, lowercase + (?!\b[0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}\b) # GUID, uppercase + (?!\bv?\d+(?:\.\d+){2,}\b) # semantic/file versions like 1.2.3 or 10.0.22631.3448 + (?!\b(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}\b) # MAC address + + # ---------- digit budget ---------- + (?=(?:\D*\d){7,15}) # 7–15 digits in total + + # ---------- number body ---------- + (?: + # A with explicit country code, allow compact digits (E.164-ish) or grouped + (?:\+|00)[1-9]\d{0,2} + (?: + [\p{Zs}.\-\/]*\d{6,14} + | + [\p{Zs}.\-\/]* (?:\(\d{1,4}\)|\d{1,4}) + (?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6} + ) + | + # B no country code => 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}]* (?<ext>\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<string, string> _profilePaths = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet<string> _usernames = new(StringComparer.OrdinalIgnoreCase); + + private static readonly FrozenSet<string> CommonPathParts = new HashSet<string>(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<string> CommonWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { + "admin", "user", "test", "guest", "public", "system", "service", + "default", "temp", "local", "shared", "common", "data", "config", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public ProfilePathAndUsernameRuleProvider() + { + DetectSystemPaths(); + } + + public IEnumerable<SanitizationRule> GetRules() + { + List<SanitizationRule> 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<string, string> GetDetectedProfilePaths() => _profilePaths; + + public IReadOnlyCollection<string> 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<string> SecretKeys = new HashSet<string>(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<SanitizationRule> GetRules() + { + yield return BuildSecretKeyValueRule( + SecretKeys, + timeout: TimeSpan.FromSeconds(5), + starEverything: true); + } + + private static SanitizationRule BuildSecretKeyValueRule( + IEnumerable<string> 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<string>(); + + 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 + (?<![A-Za-z0-9]) + # Match and capture the key (from the provided list) + (?<key>(?:{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 '=') + (?<sep>{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 + (?<q>["'])(?<val>[^"']+)\k<q> + | + # Unquoted value: match up to the next whitespace + (?<val>[^{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; + +/// <summary> +/// Generic text sanitizer that applies a sequence of regex-based rules over input text. +/// </summary> +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<SanitizationRule> _rules = []; + private readonly double _guardrailThreshold; + private readonly Action<GuardrailEventArgs>? _onGuardrailTriggered; + + public TextSanitizer( + double guardrailThreshold = DefaultGuardrailThreshold, + Action<GuardrailEventArgs>? onGuardrailTriggered = null) + { + _guardrailThreshold = guardrailThreshold; + _onGuardrailTriggered = onGuardrailTriggered; + } + + public TextSanitizer( + IEnumerable<ISanitizationRuleProvider> providers, + double guardrailThreshold = DefaultGuardrailThreshold, + Action<GuardrailEventArgs>? 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<SanitizationRule> 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<SanitizationRule> 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<SanitizationRule> 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.Common/Text/BloomFilter.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/BloomFilter.cs new file mode 100644 index 0000000000..59255a1bae --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/BloomFilter.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +namespace Microsoft.CmdPal.Core.Common.Text; + +public sealed class BloomFilter : IBloomFilter +{ + public ulong Compute(string input) + { + ulong bloom = 0; + + foreach (var ch in input) + { + if (SymbolClassifier.Classify(ch) == SymbolKind.WordSeparator) + { + continue; + } + + var h = (uint)ch * 0x45d9f3b; + bloom |= 1UL << (int)(h & 31); + bloom |= 1UL << (int)(((h >> 16) & 31) + 32); + + if (bloom == ulong.MaxValue) + { + break; + } + } + + return bloom; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool MightContain(ulong candidateBloom, ulong queryBloom) + { + return (candidateBloom & queryBloom) == queryBloom; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyMatcherProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyMatcherProvider.cs new file mode 100644 index 0000000000..80c5fa9ace --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyMatcherProvider.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; + +namespace Microsoft.CmdPal.Core.Common.Text; + +public sealed class FuzzyMatcherProvider : IFuzzyMatcherProvider +{ + private readonly IBloomFilter _bloomCalculator = new BloomFilter(); + private readonly IStringFolder _normalizer = new StringFolder(); + + private IPrecomputedFuzzyMatcher _current; + + public FuzzyMatcherProvider(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin = null) + { + _current = CreateMatcher(core, pinyin); + } + + public IPrecomputedFuzzyMatcher Current => Volatile.Read(ref _current); + + public void UpdateSettings(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin = null) + { + Volatile.Write(ref _current, CreateMatcher(core, pinyin)); + } + + private IPrecomputedFuzzyMatcher CreateMatcher(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin) + { + return pinyin is null || !IsPinyinEnabled(pinyin) + ? new PrecomputedFuzzyMatcher(core, _normalizer, _bloomCalculator) + : new PrecomputedFuzzyMatcherWithPinyin(core, pinyin, _normalizer, _bloomCalculator); + } + + private static bool IsPinyinEnabled(PinyinFuzzyMatcherOptions o) + { + return o.Mode switch + { + PinyinMode.Off => false, + PinyinMode.On => true, + PinyinMode.AutoSimplifiedChineseUi => IsSimplifiedChineseUi(), + _ => false, + }; + } + + private static bool IsSimplifiedChineseUi() + { + var culture = CultureInfo.CurrentUICulture; + return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase) + || culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyQuery.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyQuery.cs new file mode 100644 index 0000000000..80de31bd7a --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyQuery.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Text; + +public readonly struct FuzzyQuery +{ + public readonly string Original; + + public readonly string Folded; + + public readonly ulong Bloom; + + public readonly int EffectiveLength; + + public readonly bool IsAllLowercaseAsciiOrNonLetter; + + public readonly string? SecondaryOriginal; + + public readonly string? SecondaryFolded; + + public readonly ulong SecondaryBloom; + + public readonly int SecondaryEffectiveLength; + + public readonly bool SecondaryIsAllLowercaseAsciiOrNonLetter; + + public int Length => Folded.Length; + + public bool HasSecondary => SecondaryFolded is not null; + + public ReadOnlySpan<char> OriginalSpan => Original.AsSpan(); + + public ReadOnlySpan<char> FoldedSpan => Folded.AsSpan(); + + public ReadOnlySpan<char> SecondaryOriginalSpan => SecondaryOriginal.AsSpan(); + + public ReadOnlySpan<char> SecondaryFoldedSpan => SecondaryFolded.AsSpan(); + + public FuzzyQuery( + string original, + string folded, + ulong bloom, + int effectiveLength, + bool isAllLowercaseAsciiOrNonLetter, + string? secondaryOriginal = null, + string? secondaryFolded = null, + ulong secondaryBloom = 0, + int secondaryEffectiveLength = 0, + bool secondaryIsAllLowercaseAsciiOrNonLetter = true) + { + Original = original; + Folded = folded; + Bloom = bloom; + EffectiveLength = effectiveLength; + IsAllLowercaseAsciiOrNonLetter = isAllLowercaseAsciiOrNonLetter; + + SecondaryOriginal = secondaryOriginal; + SecondaryFolded = secondaryFolded; + SecondaryBloom = secondaryBloom; + SecondaryEffectiveLength = secondaryEffectiveLength; + SecondaryIsAllLowercaseAsciiOrNonLetter = secondaryIsAllLowercaseAsciiOrNonLetter; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTarget.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTarget.cs new file mode 100644 index 0000000000..b0c2927f20 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTarget.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Text; + +public readonly struct FuzzyTarget +{ + public readonly string Original; + public readonly string Folded; + public readonly ulong Bloom; + + public readonly string? SecondaryOriginal; + public readonly string? SecondaryFolded; + public readonly ulong SecondaryBloom; + + public int Length => Folded.Length; + + public bool HasSecondary => SecondaryFolded is not null; + + public int SecondaryLength => SecondaryFolded?.Length ?? 0; + + public ReadOnlySpan<char> OriginalSpan => Original.AsSpan(); + + public ReadOnlySpan<char> FoldedSpan => Folded.AsSpan(); + + public ReadOnlySpan<char> SecondaryOriginalSpan => SecondaryOriginal.AsSpan(); + + public ReadOnlySpan<char> SecondaryFoldedSpan => SecondaryFolded.AsSpan(); + + public FuzzyTarget( + string original, + string folded, + ulong bloom, + string? secondaryOriginal = null, + string? secondaryFolded = null, + ulong secondaryBloom = 0) + { + Original = original; + Folded = folded; + Bloom = bloom; + SecondaryOriginal = secondaryOriginal; + SecondaryFolded = secondaryFolded; + SecondaryBloom = secondaryBloom; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTargetCache.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTargetCache.cs new file mode 100644 index 0000000000..dc5ec6e011 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/FuzzyTargetCache.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Text; + +public struct FuzzyTargetCache +{ + private string? _lastRaw; + private uint _schemaId; + private FuzzyTarget _target; + + public FuzzyTarget GetOrUpdate(IPrecomputedFuzzyMatcher matcher, string? raw) + { + raw ??= string.Empty; + + if (_schemaId == matcher.SchemaId && string.Equals(_lastRaw, raw, StringComparison.Ordinal)) + { + return _target; + } + + _target = matcher.PrecomputeTarget(raw); + _schemaId = matcher.SchemaId; + _lastRaw = raw; + return _target; + } + + public void Invalidate() + { + _lastRaw = null; + _target = default; + _schemaId = 0; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IBloomFilter.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IBloomFilter.cs new file mode 100644 index 0000000000..e9234e7adf --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IBloomFilter.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.Core.Common.Text; + +public interface IBloomFilter +{ + ulong Compute(string input); + + bool MightContain(ulong candidateBloom, ulong queryBloom); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IFuzzyMatcherProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IFuzzyMatcherProvider.cs new file mode 100644 index 0000000000..706dd0d8bf --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IFuzzyMatcherProvider.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.Core.Common.Text; + +public interface IFuzzyMatcherProvider +{ + IPrecomputedFuzzyMatcher Current { get; } + + void UpdateSettings(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin = null); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IPrecomputedFuzzyMatcher.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IPrecomputedFuzzyMatcher.cs new file mode 100644 index 0000000000..dfb8af378e --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IPrecomputedFuzzyMatcher.cs @@ -0,0 +1,16 @@ +// 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.Text; + +public interface IPrecomputedFuzzyMatcher +{ + uint SchemaId { get; } + + FuzzyQuery PrecomputeQuery(string? input); + + FuzzyTarget PrecomputeTarget(string? input); + + int Score(scoped in FuzzyQuery query, scoped in FuzzyTarget target); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IStringFolder.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IStringFolder.cs new file mode 100644 index 0000000000..6fcfbfaf61 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/IStringFolder.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.Core.Common.Text; + +public interface IStringFolder +{ + string Fold(string input, bool removeDiacritics); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinFuzzyMatcherOptions.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinFuzzyMatcherOptions.cs new file mode 100644 index 0000000000..c060c33c92 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinFuzzyMatcherOptions.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. + +namespace Microsoft.CmdPal.Core.Common.Text; + +public sealed class PinyinFuzzyMatcherOptions +{ + public PinyinMode Mode { get; init; } = PinyinMode.AutoSimplifiedChineseUi; + + /// <summary>Remove IME syllable separators (') for query secondary variant.</summary> + public bool RemoveApostrophesForQuery { get; init; } = true; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinMode.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinMode.cs new file mode 100644 index 0000000000..0da88e14c0 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PinyinMode.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.Core.Common.Text; + +public enum PinyinMode +{ + Off = 0, + AutoSimplifiedChineseUi = 1, + On = 2, +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcher.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcher.cs new file mode 100644 index 0000000000..0994f1d328 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcher.cs @@ -0,0 +1,575 @@ +// 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.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Core.Common.Text; + +public sealed class PrecomputedFuzzyMatcher : IPrecomputedFuzzyMatcher +{ + private const int NoMatchScore = 0; + private const int StackallocThresholdChars = 512; + private const int FolderSchemaVersion = 1; + private const int BloomSchemaVersion = 1; + + private readonly PrecomputedFuzzyMatcherOptions _options; + private readonly IStringFolder _stringFolder; + private readonly IBloomFilter _bloom; + + public PrecomputedFuzzyMatcher( + PrecomputedFuzzyMatcherOptions? options = null, + IStringFolder? normalization = null, + IBloomFilter? bloomCalculator = null) + { + _options = options ?? PrecomputedFuzzyMatcherOptions.Default; + _bloom = bloomCalculator ?? new BloomFilter(); + _stringFolder = normalization ?? new StringFolder(); + + SchemaId = ComputeSchemaId(_options); + } + + public uint SchemaId { get; } + + public FuzzyQuery PrecomputeQuery(string? input) => PrecomputeQuery(input, null); + + public FuzzyTarget PrecomputeTarget(string? input) => PrecomputeTarget(input, null); + + public int Score(in FuzzyQuery query, in FuzzyTarget target) + { + var qFold = query.FoldedSpan; + var tLen = target.Length; + + if (query.EffectiveLength == 0 || tLen == 0) + { + return NoMatchScore; + } + + var skipWordSeparators = _options.SkipWordSeparators; + var bestScore = 0; + + // 1. Primary → Primary + if (tLen >= query.EffectiveLength && _bloom.MightContain(target.Bloom, query.Bloom)) + { + if (CanMatchSubsequence(qFold, target.FoldedSpan, skipWordSeparators)) + { + bestScore = ScoreNonContiguous( + qRaw: query.OriginalSpan, + qFold: qFold, + qEffectiveLen: query.EffectiveLength, + tRaw: target.OriginalSpan, + tFold: target.FoldedSpan, + ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.IsAllLowercaseAsciiOrNonLetter); + } + } + + // 2. Secondary → Secondary + if (query.HasSecondary && target.HasSecondary) + { + var qSecFold = query.SecondaryFoldedSpan; + + if (target.SecondaryLength >= query.SecondaryEffectiveLength && + _bloom.MightContain(target.SecondaryBloom, query.SecondaryBloom) && + CanMatchSubsequence(qSecFold, target.SecondaryFoldedSpan, skipWordSeparators)) + { + var score = ScoreNonContiguous( + qRaw: query.SecondaryOriginalSpan, + qFold: qSecFold, + qEffectiveLen: query.SecondaryEffectiveLength, + tRaw: target.SecondaryOriginalSpan, + tFold: target.SecondaryFoldedSpan, + ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.SecondaryIsAllLowercaseAsciiOrNonLetter); + + if (score > bestScore) + { + bestScore = score; + } + } + } + + // 3. Primary query → Secondary target + if (target.HasSecondary && + target.SecondaryLength >= query.EffectiveLength && + _bloom.MightContain(target.SecondaryBloom, query.Bloom)) + { + if (CanMatchSubsequence(qFold, target.SecondaryFoldedSpan, skipWordSeparators)) + { + var score = ScoreNonContiguous( + qRaw: query.OriginalSpan, + qFold: qFold, + qEffectiveLen: query.EffectiveLength, + tRaw: target.SecondaryOriginalSpan, + tFold: target.SecondaryFoldedSpan, + ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.IsAllLowercaseAsciiOrNonLetter); + + if (score > bestScore) + { + bestScore = score; + } + } + } + + // 4. Secondary query → Primary target + if (query.HasSecondary && + tLen >= query.SecondaryEffectiveLength && + _bloom.MightContain(target.Bloom, query.SecondaryBloom)) + { + var qSecFold = query.SecondaryFoldedSpan; + + if (CanMatchSubsequence(qSecFold, target.FoldedSpan, skipWordSeparators)) + { + var score = ScoreNonContiguous( + qRaw: query.SecondaryOriginalSpan, + qFold: qSecFold, + qEffectiveLen: query.SecondaryEffectiveLength, + tRaw: target.OriginalSpan, + tFold: target.FoldedSpan, + ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.SecondaryIsAllLowercaseAsciiOrNonLetter); + + if (score > bestScore) + { + bestScore = score; + } + } + } + + return bestScore; + } + + private FuzzyQuery PrecomputeQuery(string? input, string? secondaryInput) + { + input ??= string.Empty; + + var folded = _stringFolder.Fold(input, _options.RemoveDiacritics); + var bloom = _bloom.Compute(folded); + var effectiveLength = _options.SkipWordSeparators + ? folded.Length - CountWordSeparators(folded) + : folded.Length; + + var isAllLowercase = IsAllLowercaseAsciiOrNonLetter(input); + + string? secondaryOriginal = null; + string? secondaryFolded = null; + ulong secondaryBloom = 0; + var secondaryEffectiveLength = 0; + var secondaryIsAllLowercase = true; + + if (!string.IsNullOrEmpty(secondaryInput)) + { + secondaryOriginal = secondaryInput; + secondaryFolded = _stringFolder.Fold(secondaryInput, _options.RemoveDiacritics); + secondaryBloom = _bloom.Compute(secondaryFolded); + secondaryEffectiveLength = _options.SkipWordSeparators + ? secondaryFolded.Length - CountWordSeparators(secondaryFolded) + : secondaryFolded.Length; + + secondaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(secondaryInput); + } + + return new FuzzyQuery( + original: input, + folded: folded, + bloom: bloom, + effectiveLength: effectiveLength, + isAllLowercaseAsciiOrNonLetter: isAllLowercase, + secondaryOriginal: secondaryOriginal, + secondaryFolded: secondaryFolded, + secondaryBloom: secondaryBloom, + secondaryEffectiveLength: secondaryEffectiveLength, + secondaryIsAllLowercaseAsciiOrNonLetter: secondaryIsAllLowercase); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static int CountWordSeparators(string s) + { + var count = 0; + foreach (var c in s) + { + if (SymbolClassifier.Classify(c) == SymbolKind.WordSeparator) + { + count++; + } + } + + return count; + } + } + + internal FuzzyTarget PrecomputeTarget(string? input, string? secondaryInput) + { + input ??= string.Empty; + + var folded = _stringFolder.Fold(input, _options.RemoveDiacritics); + var bloom = _bloom.Compute(folded); + + string? secondaryFolded = null; + ulong secondaryBloom = 0; + + if (!string.IsNullOrEmpty(secondaryInput)) + { + secondaryFolded = _stringFolder.Fold(secondaryInput, _options.RemoveDiacritics); + secondaryBloom = _bloom.Compute(secondaryFolded); + } + + return new FuzzyTarget( + input, + folded, + bloom, + secondaryInput, + secondaryFolded, + secondaryBloom); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsAllLowercaseAsciiOrNonLetter(string s) + { + foreach (var c in s) + { + if ((uint)(c - 'A') <= ('Z' - 'A')) + { + return false; + } + } + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool CanMatchSubsequence( + ReadOnlySpan<char> qFold, + ReadOnlySpan<char> tFold, + bool skipWordSeparators) + { + var qi = 0; + var ti = 0; + + while (qi < qFold.Length && ti < tFold.Length) + { + var qChar = qFold[qi]; + + if (skipWordSeparators && SymbolClassifier.Classify(qChar) == SymbolKind.WordSeparator) + { + qi++; + continue; + } + + if (qChar == tFold[ti]) + { + qi++; + } + + ti++; + } + + // Skip trailing word separators in query + if (skipWordSeparators) + { + while (qi < qFold.Length && SymbolClassifier.Classify(qFold[qi]) == SymbolKind.WordSeparator) + { + qi++; + } + } + + return qi == qFold.Length; + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + [SkipLocalsInit] + private int ScoreNonContiguous( + scoped in ReadOnlySpan<char> qRaw, + scoped in ReadOnlySpan<char> qFold, + int qEffectiveLen, + scoped in ReadOnlySpan<char> tRaw, + scoped in ReadOnlySpan<char> tFold, + bool ignoreSameCaseBonusForThisQuery) + { + Debug.Assert(qRaw.Length == qFold.Length, "Original and folded spans are traversed in lockstep: requires qRaw.Length == qFold.Length"); + Debug.Assert(tRaw.Length == tFold.Length, "Original and folded spans are traversed in lockstep: requires tRaw.Length == tFold.Length"); + Debug.Assert(qEffectiveLen <= qFold.Length, "Effective length must be less than or equal to folded length"); + + var qLen = qFold.Length; + var tLen = tFold.Length; + + // Copy options to local variables to avoid repeated field accesses + var charMatchBonus = _options.CharMatchBonus; + var sameCaseBonus = ignoreSameCaseBonusForThisQuery ? 0 : _options.SameCaseBonus; + var consecutiveMultiplier = _options.ConsecutiveMultiplier; + var camelCaseBonus = _options.CamelCaseBonus; + var startOfWordBonus = _options.StartOfWordBonus; + var pathSeparatorBonus = _options.PathSeparatorBonus; + var wordSeparatorBonus = _options.WordSeparatorBonus; + var separatorAlignmentBonus = _options.SeparatorAlignmentBonus; + var exactSeparatorBonus = _options.ExactSeparatorBonus; + var skipWordSeparators = _options.SkipWordSeparators; + + // DP buffer: two rows of length tLen + var bufferSize = tLen * 2; + int[]? rented = null; + + try + { + scoped Span<int> buffer; + if (bufferSize <= StackallocThresholdChars) + { + buffer = stackalloc int[bufferSize]; + } + else + { + rented = ArrayPool<int>.Shared.Rent(bufferSize); + buffer = rented.AsSpan(0, bufferSize); + } + + var scores = buffer[..tLen]; + var seqLens = buffer.Slice(tLen, tLen); + + scores.Clear(); + seqLens.Clear(); + + ref var scores0 = ref MemoryMarshal.GetReference(scores); + ref var seqLens0 = ref MemoryMarshal.GetReference(seqLens); + ref var qRaw0 = ref MemoryMarshal.GetReference(qRaw); + ref var qFold0 = ref MemoryMarshal.GetReference(qFold); + ref var tRaw0 = ref MemoryMarshal.GetReference(tRaw); + ref var tFold0 = ref MemoryMarshal.GetReference(tFold); + + var qiEffective = 0; + + for (var qi = 0; qi < qLen; qi++) + { + var qCharFold = Unsafe.Add(ref qFold0, qi); + var qCharKind = SymbolClassifier.Classify(qCharFold); + + if (skipWordSeparators && qCharKind == SymbolKind.WordSeparator) + { + continue; + } + + // Hoisted values + var qRawIsUpper = char.IsUpper(Unsafe.Add(ref qRaw0, qi)); + + // row computation + var leftScore = 0; + var diagScore = 0; + var diagSeqLen = 0; + + // limit ti to ensure enough remaining characters to match the rest of the query + var tiMax = tLen - qEffectiveLen + qiEffective; + + for (var ti = 0; ti <= tiMax; ti++) + { + var upScore = Unsafe.Add(ref scores0, ti); + var upSeqLen = Unsafe.Add(ref seqLens0, ti); + + var charScore = 0; + if (diagScore != 0 || qiEffective == 0) + { + charScore = ComputeCharScore( + qi, + ti, + qCharFold, + qCharKind, + diagSeqLen, + qRawIsUpper, + ref tRaw0, + ref qFold0, + ref tFold0); + } + + var candidateScore = diagScore + charScore; + if (charScore != 0 && candidateScore >= leftScore) + { + Unsafe.Add(ref scores0, ti) = candidateScore; + Unsafe.Add(ref seqLens0, ti) = diagSeqLen + 1; + leftScore = candidateScore; + } + else + { + Unsafe.Add(ref scores0, ti) = leftScore; + Unsafe.Add(ref seqLens0, ti) = 0; + /* leftScore remains unchanged */ + } + + diagScore = upScore; + diagSeqLen = upSeqLen; + } + + // Early exit: no match possible + if (leftScore == 0) + { + return NoMatchScore; + } + + // Advance effective query index + // Only counts non-separator characters if skipWordSeparators is enabled + qiEffective++; + + if (qiEffective == qEffectiveLen) + { + return leftScore; + } + } + + return scores[tLen - 1]; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + int ComputeCharScore( + int qi, + int ti, + char qCharFold, + SymbolKind qCharKind, + int seqLen, + bool qCharRawCurrIsUpper, + ref char tRaw0, + ref char qFold0, + ref char tFold0) + { + // Match check: + // - exact folded char match always ok + // - otherwise, allow equivalence only for word separators (e.g. '_' matches '-') + var tCharFold = Unsafe.Add(ref tFold0, ti); + if (qCharFold != tCharFold) + { + if (!skipWordSeparators) + { + return 0; + } + + if (qCharKind != SymbolKind.WordSeparator || + SymbolClassifier.Classify(tCharFold) != SymbolKind.WordSeparator) + { + return 0; + } + } + + // 0. Base char match bonus + var score = charMatchBonus; + + // 1. Consecutive match bonus + if (seqLen > 0) + { + score += seqLen * consecutiveMultiplier; + } + + // 2. Same case bonus + // Early outs to appease the branch predictor + if (sameCaseBonus != 0) + { + var tCharRawCurr = Unsafe.Add(ref tRaw0, ti); + var tCharRawCurrIsUpper = char.IsUpper(tCharRawCurr); + if (qCharRawCurrIsUpper == tCharRawCurrIsUpper) + { + score += sameCaseBonus; + } + + if (ti == 0) + { + score += startOfWordBonus; + return score; + } + + var tPrevFold = Unsafe.Add(ref tFold0, ti - 1); + var tPrevKind = SymbolClassifier.Classify(tPrevFold); + if (tPrevKind != SymbolKind.Other) + { + score += tPrevKind == SymbolKind.PathSeparator + ? pathSeparatorBonus + : wordSeparatorBonus; + + if (skipWordSeparators && seqLen == 0 && qi > 0) + { + var qPrevFold = Unsafe.Add(ref qFold0, qi - 1); + var qPrevKind = SymbolClassifier.Classify(qPrevFold); + + if (qPrevKind == SymbolKind.WordSeparator) + { + score += separatorAlignmentBonus; + + if (tPrevKind == SymbolKind.WordSeparator && qPrevFold == tPrevFold) + { + score += exactSeparatorBonus; + } + } + } + + return score; + } + + if (tCharRawCurrIsUpper && seqLen == 0) + { + score += camelCaseBonus; + return score; + } + + return score; + } + else + { + if (ti == 0) + { + score += startOfWordBonus; + return score; + } + + var tPrevFold = Unsafe.Add(ref tFold0, ti - 1); + var tPrevKind = SymbolClassifier.Classify(tPrevFold); + if (tPrevKind != SymbolKind.Other) + { + score += tPrevKind == SymbolKind.PathSeparator + ? pathSeparatorBonus + : wordSeparatorBonus; + + if (skipWordSeparators && seqLen == 0 && qi > 0) + { + var qPrevFold = Unsafe.Add(ref qFold0, qi - 1); + var qPrevKind = SymbolClassifier.Classify(qPrevFold); + + if (qPrevKind == SymbolKind.WordSeparator) + { + score += separatorAlignmentBonus; + + if (tPrevKind == SymbolKind.WordSeparator && qPrevFold == tPrevFold) + { + score += exactSeparatorBonus; + } + } + } + + return score; + } + + if (camelCaseBonus != 0 && seqLen == 0 && char.IsUpper(Unsafe.Add(ref tRaw0, ti))) + { + score += camelCaseBonus; + return score; + } + + return score; + } + } + } + finally + { + if (rented is not null) + { + ArrayPool<int>.Shared.Return(rented); + } + } + } + + // Schema ID is for cache invalidation of precomputed targets. + // Only includes options that affect folding/bloom, not scoring. + private static uint ComputeSchemaId(PrecomputedFuzzyMatcherOptions o) + { + const uint fnvOffset = 2166136261; + const uint fnvPrime = 16777619; + + var h = fnvOffset; + h = unchecked((h ^ FolderSchemaVersion) * fnvPrime); + h = unchecked((h ^ BloomSchemaVersion) * fnvPrime); + h = unchecked((h ^ (uint)(o.RemoveDiacritics ? 1 : 0)) * fnvPrime); + + return h; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherOptions.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherOptions.cs new file mode 100644 index 0000000000..b1b01d60f1 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Text; + +public sealed class PrecomputedFuzzyMatcherOptions +{ + public static PrecomputedFuzzyMatcherOptions Default { get; } = new(); + + /* + * Bonuses + */ + public int CharMatchBonus { get; init; } = 1; + + public int SameCaseBonus { get; init; } = 1; + + public int ConsecutiveMultiplier { get; init; } = 5; + + public int CamelCaseBonus { get; init; } = 2; + + public int StartOfWordBonus { get; init; } = 8; + + public int PathSeparatorBonus { get; init; } = 5; + + public int WordSeparatorBonus { get; init; } = 4; + + public int SeparatorAlignmentBonus { get; init; } = 2; + + public int ExactSeparatorBonus { get; init; } = 1; + + /* + * Settings + */ + public bool RemoveDiacritics { get; init; } = true; + + public bool SkipWordSeparators { get; init; } = true; + + public bool IgnoreSameCaseBonusIfQueryIsAllLowercase { get; init; } = true; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherWithPinyin.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherWithPinyin.cs new file mode 100644 index 0000000000..026328f2c5 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/PrecomputedFuzzyMatcherWithPinyin.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using System.Runtime.CompilerServices; +using ToolGood.Words.Pinyin; + +namespace Microsoft.CmdPal.Core.Common.Text; + +public sealed class PrecomputedFuzzyMatcherWithPinyin : IPrecomputedFuzzyMatcher +{ + private readonly IBloomFilter _bloom; + private readonly PrecomputedFuzzyMatcher _core; + + private readonly IStringFolder _stringFolder; + private readonly PinyinFuzzyMatcherOptions _pinyin; + + public PrecomputedFuzzyMatcherWithPinyin( + PrecomputedFuzzyMatcherOptions coreOptions, + PinyinFuzzyMatcherOptions pinyinOptions, + IStringFolder stringFolder, + IBloomFilter bloom) + { + _pinyin = pinyinOptions; + _stringFolder = stringFolder; + _bloom = bloom; + + _core = new PrecomputedFuzzyMatcher(coreOptions, stringFolder, bloom); + + SchemaId = CombineSchema(_core.SchemaId, _pinyin); + } + + public uint SchemaId { get; } + + public FuzzyQuery PrecomputeQuery(string? input) + { + input ??= string.Empty; + + var primary = _core.PrecomputeQuery(input); + + // Fast exit if effectively off (provider should already filter, but keep robust) + if (!IsPinyinEnabled(_pinyin)) + { + return primary; + } + + // Match legacy: remove apostrophes for query secondary + var queryForPinyin = _pinyin.RemoveApostrophesForQuery ? RemoveApostrophesIfAny(input) : input; + + var pinyin = WordsHelper.GetPinyin(queryForPinyin); + if (string.IsNullOrEmpty(pinyin)) + { + return primary; + } + + var secondary = _core.PrecomputeQuery(pinyin); + return new FuzzyQuery( + primary.Original, + primary.Folded, + primary.Bloom, + primary.EffectiveLength, + primary.IsAllLowercaseAsciiOrNonLetter, + secondary.Original, + secondary.Folded, + secondary.Bloom, + secondary.EffectiveLength, + secondary.SecondaryIsAllLowercaseAsciiOrNonLetter); + } + + public FuzzyTarget PrecomputeTarget(string? input) + { + input ??= string.Empty; + + var primary = _core.PrecomputeTarget(input); + + if (!IsPinyinEnabled(_pinyin)) + { + return primary; + } + + // Match legacy: only compute target pinyin when target contains Chinese + if (!ContainsToolGoodChinese(input)) + { + return primary; + } + + var pinyin = WordsHelper.GetPinyin(input); + if (string.IsNullOrEmpty(pinyin)) + { + return primary; + } + + var secondary = _core.PrecomputeTarget(pinyin); + return new FuzzyTarget( + primary.Original, + primary.Folded, + primary.Bloom, + secondary.Original, + secondary.Folded, + secondary.Bloom); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int Score(scoped in FuzzyQuery query, scoped in FuzzyTarget target) + => _core.Score(in query, in target); + + private static bool IsPinyinEnabled(PinyinFuzzyMatcherOptions o) => o.Mode switch + { + PinyinMode.Off => false, + PinyinMode.On => true, + PinyinMode.AutoSimplifiedChineseUi => IsSimplifiedChineseUi(), + _ => false, + }; + + private static bool IsSimplifiedChineseUi() + { + var culture = CultureInfo.CurrentUICulture; + return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase) + || culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase); + } + + private static bool ContainsToolGoodChinese(string s) + { + return WordsHelper.HasChinese(s); + } + + private static string RemoveApostrophesIfAny(string input) + { + var first = input.IndexOf('\''); + if (first < 0) + { + return input; + } + + var removeCount = 1; + for (var i = first + 1; i < input.Length; i++) + { + if (input[i] == '\'') + { + removeCount++; + } + } + + return string.Create(input.Length - removeCount, input, static (dst, src) => + { + var di = 0; + for (var i = 0; i < src.Length; i++) + { + var c = src[i]; + if (c == '\'') + { + continue; + } + + dst[di++] = c; + } + }); + } + + private static uint CombineSchema(uint coreSchemaId, PinyinFuzzyMatcherOptions p) + { + const uint fnvOffset = 2166136261; + const uint fnvPrime = 16777619; + + var h = fnvOffset; + h = unchecked((h ^ coreSchemaId) * fnvPrime); + h = unchecked((h ^ (uint)p.Mode) * fnvPrime); + h = unchecked((h ^ (p.RemoveApostrophesForQuery ? 1u : 0u)) * fnvPrime); + + // bump if you change formatting/conversion behavior + const uint pinyinAlgoVersion = 1; + h = unchecked((h ^ pinyinAlgoVersion) * fnvPrime); + + return h; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/StringFolder.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/StringFolder.cs new file mode 100644 index 0000000000..2d814be553 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/StringFolder.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; + +namespace Microsoft.CmdPal.Core.Common.Text; + +public sealed class StringFolder : IStringFolder +{ + // Cache for diacritic-stripped uppercase characters. + // Benign race: worst case is redundant computation writing the same value. + // 0 = uncached, else cachedChar + 1 + private static readonly ushort[] StripCacheUpper = new ushort[char.MaxValue + 1]; + + public string Fold(string input, bool removeDiacritics) + { + if (string.IsNullOrEmpty(input)) + { + return string.Empty; + } + + if (!removeDiacritics || Ascii.IsValid(input)) + { + if (IsAlreadyFoldedAndSlashNormalized(input)) + { + return input; + } + + return string.Create(input.Length, input, static (dst, src) => + { + for (var i = 0; i < src.Length; i++) + { + var c = src[i]; + dst[i] = c == '\\' ? '/' : char.ToUpperInvariant(c); + } + }); + } + + return string.Create(input.Length, input, static (dst, src) => + { + for (var i = 0; i < src.Length; i++) + { + var c = src[i]; + var upper = c == '\\' ? '/' : char.ToUpperInvariant(c); + dst[i] = StripDiacriticsFromUpper(upper); + } + }); + } + + private static bool IsAlreadyFoldedAndSlashNormalized(string input) + { + var sawNonAscii = false; + + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + + if (c == '\\') + { + return false; + } + + if ((uint)(c - 'a') <= 'z' - 'a') + { + return false; + } + + if (c > 0x7F) + { + sawNonAscii = true; + } + } + + if (sawNonAscii) + { + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + if (c <= 0x7F) + { + continue; + } + + var cat = CharUnicodeInfo.GetUnicodeCategory(c); + if (cat is UnicodeCategory.LowercaseLetter or UnicodeCategory.TitlecaseLetter) + { + return false; + } + } + } + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static char StripDiacriticsFromUpper(char upper) + { + if (upper <= 0x7F) + { + return upper; + } + + // Never attempt normalization on lone UTF-16 surrogates. + if (char.IsSurrogate(upper)) + { + return upper; + } + + var cachedPlus1 = StripCacheUpper[upper]; + if (cachedPlus1 != 0) + { + return (char)(cachedPlus1 - 1); + } + + var mapped = StripDiacriticsSlow(upper); + StripCacheUpper[upper] = (ushort)(mapped + 1); + return mapped; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static char StripDiacriticsSlow(char upper) + { + try + { + var baseChar = FirstNonMark(upper, NormalizationForm.FormD); + if (baseChar == '\0' || baseChar == upper) + { + var kd = FirstNonMark(upper, NormalizationForm.FormKD); + if (kd != '\0') + { + baseChar = kd; + } + } + + return char.ToUpperInvariant(baseChar == '\0' ? upper : baseChar); + } + catch + { + // Absolute safety: if globalization tables ever throw for some reason, + // degrade gracefully rather than failing hard. + return upper; + } + + static char FirstNonMark(char c, NormalizationForm form) + { + var normalized = c.ToString().Normalize(form); + + foreach (var ch in normalized) + { + var cat = CharUnicodeInfo.GetUnicodeCategory(ch); + if (cat is not (UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark)) + { + return ch; + } + } + + return '\0'; + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolClassifier.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolClassifier.cs new file mode 100644 index 0000000000..e1be786646 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolClassifier.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.Runtime.CompilerServices; + +namespace Microsoft.CmdPal.Core.Common.Text; + +internal static class SymbolClassifier +{ + // Embedded in .data section - no allocation, no static constructor + private static ReadOnlySpan<byte> Lookup => + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31 + 2, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 2, 1, // 32-47: space=2, "=2, '=2, -=2, .=2, /=1 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, // 48-63: :=2 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 64-79 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, // 80-95: _=2 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 96-111 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // 112-127 + ]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static SymbolKind Classify(char c) + { + return c > 0x7F ? SymbolKind.Other : (SymbolKind)Lookup[c]; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolKind.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolKind.cs new file mode 100644 index 0000000000..d2644be420 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Text/SymbolKind.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.Core.Common.Text; + +internal enum SymbolKind : byte +{ + Other = 0, + PathSeparator = 1, + WordSeparator = 2, +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs new file mode 100644 index 0000000000..125d8d78f4 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AppExtensionHost.cs @@ -0,0 +1,168 @@ +// 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.ObjectModel; +using System.Diagnostics; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public abstract partial class AppExtensionHost : IExtensionHost +{ + private static readonly GlobalLogPageContext _globalLogPageContext = new(); + + private static ulong _hostingHwnd; + + public static ObservableCollection<LogMessageViewModel> LogMessages { get; } = []; + + public ulong HostingHwnd => _hostingHwnd; + + public string LanguageOverride => string.Empty; + + public ObservableCollection<StatusMessageViewModel> StatusMessages { get; } = []; + + public static void SetHostHwnd(ulong hostHwnd) => _hostingHwnd = hostHwnd; + + public void DebugLog(string message) + { +#if DEBUG + this.ProcessLogMessage(new LogMessage(message)); +#endif + } + + public IAsyncAction HideStatus(IStatusMessage? message) + { + if (message is null) + { + return Task.CompletedTask.AsAsyncAction(); + } + + _ = Task.Run(() => + { + ProcessHideStatusMessage(message); + }); + return Task.CompletedTask.AsAsyncAction(); + } + + public void Log(string message) + { + this.ProcessLogMessage(new LogMessage(message)); + } + + public IAsyncAction LogMessage(ILogMessage? message) + { + if (message is null) + { + return Task.CompletedTask.AsAsyncAction(); + } + + CoreLogger.LogDebug(message.Message); + + _ = Task.Run(() => + { + ProcessLogMessage(message); + }); + + // We can't just make a LogMessageViewModel : ExtensionObjectViewModel + // because we don't necessarily know the page context. Butts. + return Task.CompletedTask.AsAsyncAction(); + } + + public void ProcessHideStatusMessage(IStatusMessage message) + { + Task.Factory.StartNew( + () => + { + try + { + var vm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault(); + if (vm is not null) + { + StatusMessages.Remove(vm); + } + } + catch + { + } + }, + CancellationToken.None, + TaskCreationOptions.None, + _globalLogPageContext.Scheduler); + } + + public void ProcessLogMessage(ILogMessage message) + { + var vm = new LogMessageViewModel(message, _globalLogPageContext); + vm.SafeInitializePropertiesSynchronous(); + + Task.Factory.StartNew( + () => + { + LogMessages.Add(vm); + }, + CancellationToken.None, + TaskCreationOptions.None, + _globalLogPageContext.Scheduler); + } + + public void ProcessStatusMessage(IStatusMessage message, StatusContext context) + { + // If this message is already in the list of messages, just bring it to the top + var oldVm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault(); + if (oldVm is not null) + { + Task.Factory.StartNew( + () => + { + StatusMessages.Remove(oldVm); + StatusMessages.Add(oldVm); + }, + CancellationToken.None, + TaskCreationOptions.None, + _globalLogPageContext.Scheduler); + return; + } + + var vm = new StatusMessageViewModel(message, new(_globalLogPageContext)); + vm.SafeInitializePropertiesSynchronous(); + + Task.Factory.StartNew( + () => + { + StatusMessages.Add(vm); + }, + CancellationToken.None, + TaskCreationOptions.None, + _globalLogPageContext.Scheduler); + } + + public IAsyncAction ShowStatus(IStatusMessage? message, StatusContext context) + { + if (message is null) + { + return Task.CompletedTask.AsAsyncAction(); + } + + Debug.WriteLine(message.Message); + + _ = Task.Run(() => + { + ProcessStatusMessage(message, context); + }); + + return Task.CompletedTask.AsAsyncAction(); + } + + public abstract string? GetExtensionDisplayName(); +} + +public interface IAppHostService +{ + AppExtensionHost GetDefaultHost(); + + AppExtensionHost GetHostForCommand(object? context, AppExtensionHost? currentHost); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AsyncNavigationRequest.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AsyncNavigationRequest.cs new file mode 100644 index 0000000000..189d566c8a --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/AsyncNavigationRequest.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.Core.ViewModels; + +/// <summary> +/// Encapsulates a navigation request within Command Palette view models. +/// </summary> +/// <param name="TargetViewModel">A view model that should be navigated to.</param> +/// <param name="NavigationToken"> A <see cref="CancellationToken"/> that can be used to cancel the pending navigation.</param> +public record AsyncNavigationRequest(object? TargetViewModel, CancellationToken NavigationToken); 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<IBatchUpdateTarget> DirtyQueue = []; + private static readonly Timer Timer = new(static _ => Flush(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + + private static InterlockedBoolean _isFlushScheduled; + + /// <summary> + /// Enqueue a target for batched processing. Safe to call from any thread (including COM callbacks). + /// </summary> + 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<IBatchUpdateTarget>(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<IBatchUpdateTarget> 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 +{ + /// <summary>UI scheduler (used by targets internally for UI marshaling). Kept here for diagnostics / consistency.</summary> + TaskScheduler UIScheduler { get; } + + /// <summary>Apply any coalesced updates. Must be safe to call on a background thread.</summary> + void ApplyPendingUpdates(); + + /// <summary>De-dupe gate: returns true only for the first enqueue until cleared.</summary> + bool TryMarkBatchQueued(); + + /// <summary>Clear the de-dupe gate so the item can be queued again.</summary> + void ClearBatchQueued(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandBarViewModel.cs similarity index 55% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandBarViewModel.cs index 9b5be8a973..010432a5e1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandBarViewModel.cs @@ -1,14 +1,14 @@ -// 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.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class CommandBarViewModel : ObservableObject, IRecipient<UpdateCommandBarMessage> @@ -25,6 +25,8 @@ public partial class CommandBarViewModel : ObservableObject, field = value; SetSelectedItem(value); + + OnPropertyChanged(nameof(SelectedItem)); } } @@ -32,13 +34,13 @@ public partial class CommandBarViewModel : ObservableObject, [NotifyPropertyChangedFor(nameof(HasPrimaryCommand))] public partial CommandItemViewModel? PrimaryCommand { get; set; } - public bool HasPrimaryCommand => PrimaryCommand != null && PrimaryCommand.ShouldBeVisible; + public bool HasPrimaryCommand => PrimaryCommand is not null && PrimaryCommand.ShouldBeVisible; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasSecondaryCommand))] public partial CommandItemViewModel? SecondaryCommand { get; set; } - public bool HasSecondaryCommand => SecondaryCommand != null; + public bool HasSecondaryCommand => SecondaryCommand is not null; [ObservableProperty] public partial bool ShouldShowContextMenu { get; set; } = false; @@ -46,9 +48,6 @@ public partial class CommandBarViewModel : ObservableObject, [ObservableProperty] public partial PageViewModel? CurrentPage { get; set; } - [ObservableProperty] - public partial ObservableCollection<CommandContextItemViewModel> ContextCommands { get; set; } = []; - public CommandBarViewModel() { WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this); @@ -58,14 +57,14 @@ public partial class CommandBarViewModel : ObservableObject, private void SetSelectedItem(ICommandBarContext? value) { - if (value != null) + if (value is not null) { PrimaryCommand = value.PrimaryCommand; value.PropertyChanged += SelectedItemPropertyChanged; } else { - if (SelectedItem != null) + if (SelectedItem is not null) { SelectedItem.PropertyChanged -= SelectedItemPropertyChanged; } @@ -88,7 +87,7 @@ public partial class CommandBarViewModel : ObservableObject, private void UpdateContextItems() { - if (SelectedItem == null) + if (SelectedItem is null) { SecondaryCommand = null; ShouldShowContextMenu = false; @@ -97,38 +96,71 @@ public partial class CommandBarViewModel : ObservableObject, SecondaryCommand = SelectedItem.SecondaryCommand; - if (SelectedItem.MoreCommands.Count() > 1) - { - ShouldShowContextMenu = true; - ContextCommands = [.. SelectedItem.AllCommands]; - } - else - { - ShouldShowContextMenu = false; - } + ShouldShowContextMenu = SelectedItem.MoreCommands + .OfType<CommandContextItemViewModel>() + .Count() > 1; + + OnPropertyChanged(nameof(HasSecondaryCommand)); + OnPropertyChanged(nameof(SecondaryCommand)); + OnPropertyChanged(nameof(ShouldShowContextMenu)); } // InvokeItemCommand is what this will be in Xaml due to source generator // this comes in when an item in the list is tapped - [RelayCommand] - private void InvokeItem(CommandContextItemViewModel item) => - WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command.Model, item.Model)); + // [RelayCommand] + public ContextKeybindingResult InvokeItem(CommandContextItemViewModel item) => + PerformCommand(item); // this comes in when the primary button is tapped public void InvokePrimaryCommand() { - if (PrimaryCommand != null) - { - WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(PrimaryCommand.Command.Model, PrimaryCommand.Model)); - } + PerformCommand(PrimaryCommand); } // this comes in when the secondary button is tapped public void InvokeSecondaryCommand() { - if (SecondaryCommand != null) + PerformCommand(SecondaryCommand); + } + + public ContextKeybindingResult CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) + { + var keybindings = SelectedItem?.Keybindings(); + if (keybindings is not null) { - WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(SecondaryCommand.Command.Model, SecondaryCommand.Model)); + // Does the pressed key match any of the keybindings? + var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); + if (keybindings.TryGetValue(pressedKeyChord, out var matchedItem)) + { + return matchedItem is not null ? PerformCommand(matchedItem) : ContextKeybindingResult.Unhandled; + } + } + + return ContextKeybindingResult.Unhandled; + } + + private ContextKeybindingResult PerformCommand(CommandItemViewModel? command) + { + if (command is null) + { + return ContextKeybindingResult.Unhandled; + } + + WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(command.Command.Model, command.Model)); + if (command.HasMoreCommands) + { + return ContextKeybindingResult.KeepOpen; + } + else + { + return ContextKeybindingResult.Hide; } } } + +public enum ContextKeybindingResult +{ + Unhandled, + Hide, + KeepOpen, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs similarity index 52% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs index f3475fd964..bc07fca640 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandContextItemViewModel.cs @@ -1,20 +1,27 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; -public partial class CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference<IPageContext> context) : CommandItemViewModel(new(contextItem), context) +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +public partial class CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference<IPageContext> context) : CommandItemViewModel(new(contextItem), context), IContextItemViewModel { + private readonly KeyChord nullKeyChord = new(0, 0, 0); + public new ExtensionObject<ICommandContextItem> Model { get; } = new(contextItem); public bool IsCritical { get; private set; } public KeyChord? RequestedShortcut { get; private set; } + public bool HasRequestedShortcut => RequestedShortcut is not null && (RequestedShortcut.Value != nullKeyChord); + public override void InitializeProperties() { if (IsInitialized) @@ -25,18 +32,16 @@ public partial class CommandContextItemViewModel(ICommandContextItem contextItem base.InitializeProperties(); var contextItem = Model.Unsafe; - if (contextItem == null) + if (contextItem is null) { return; // throw? } IsCritical = contextItem.IsCritical; - if (contextItem.RequestedShortcut != null) - { - RequestedShortcut = new( - contextItem.RequestedShortcut.Modifiers, - contextItem.RequestedShortcut.Vkey, - contextItem.RequestedShortcut.ScanCode); - } + + RequestedShortcut = new( + contextItem.RequestedShortcut.Modifiers, + contextItem.RequestedShortcut.Vkey, + contextItem.RequestedShortcut.ScanCode); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs similarity index 52% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs index 37d223b000..af10995cf9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandItemViewModel.cs @@ -1,20 +1,31 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.CmdPal.UI.ViewModels.Models; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Text; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.ApplicationModel.DataTransfer; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; -public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext, IPrecomputedListItem { public ExtensionObject<ICommandItem> Model => _commandItemModel; + private ExtensionObject<IExtendedAttributesProvider>? ExtendedAttributesProvider { get; set; } + private readonly ExtensionObject<ICommandItem> _commandItemModel = new(null); - private CommandContextItemViewModel? _defaultCommandContextItem; + private CommandContextItemViewModel? _defaultCommandContextItemViewModel; + + private FuzzyTargetCache _titleCache; + private FuzzyTargetCache _subtitleCache; internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized; @@ -40,33 +51,37 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa public string Subtitle { get; private set; } = string.Empty; - private IconInfoViewModel _listItemIcon = new(null); + private IconInfoViewModel _icon = new(null); - public IconInfoViewModel Icon => _listItemIcon.IsSet ? _listItemIcon : Command.Icon; + public IconInfoViewModel Icon => _icon.IsSet ? _icon : Command.Icon; public CommandViewModel Command { get; private set; } - public List<CommandContextItemViewModel> MoreCommands { get; private set; } = []; + public List<IContextItemViewModel> MoreCommands { get; private set; } = []; - IEnumerable<CommandContextItemViewModel> ICommandBarContext.MoreCommands => MoreCommands; + IEnumerable<IContextItemViewModel> IContextMenuContext.MoreCommands => MoreCommands; - public bool HasMoreCommands => MoreCommands.Count > 0; + private List<CommandContextItemViewModel> ActualCommands => MoreCommands.OfType<CommandContextItemViewModel>().ToList(); + + public bool HasMoreCommands => ActualCommands.Count > 0; public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty; public CommandItemViewModel? PrimaryCommand => this; - public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? MoreCommands[0] : null; + public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[0] : null; public bool ShouldBeVisible => !string.IsNullOrEmpty(Name); - public List<CommandContextItemViewModel> AllCommands + public DataPackageView? DataPackage { get; private set; } + + public List<IContextItemViewModel> AllCommands { get { - List<CommandContextItemViewModel> l = _defaultCommandContextItem == null ? + List<IContextItemViewModel> l = _defaultCommandContextItemViewModel is null ? new() : - [_defaultCommandContextItem]; + [_defaultCommandContextItemViewModel]; l.AddRange(MoreCommands); return l; @@ -96,7 +111,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } var model = _commandItemModel.Unsafe; - if (model == null) + if (model is null) { return; } @@ -106,6 +121,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa _itemTitle = model.Title; Subtitle = model.Subtitle; + _titleCache.Invalidate(); + _subtitleCache.Invalidate(); Initialized |= InitializedState.FastInitialized; } @@ -124,18 +141,18 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } var model = _commandItemModel.Unsafe; - if (model == null) + if (model is null) { return; } Command.InitializeProperties(); - var listIcon = model.Icon; - if (listIcon != null) + var icon = model.Icon; + if (icon is not null) { - _listItemIcon = new(listIcon); - _listItemIcon.InitializeProperties(); + _icon = new(icon); + _icon.InitializeProperties(); } // TODO: Do these need to go into FastInit? @@ -152,10 +169,17 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa // will never be able to load Hotkeys & aliases UpdateProperty(nameof(IsInitialized)); + if (model is IExtendedAttributesProvider extendedAttributesProvider) + { + ExtendedAttributesProvider = new ExtensionObject<IExtendedAttributesProvider>(extendedAttributesProvider); + var properties = extendedAttributesProvider.GetProperties(); + UpdateDataPackage(properties); + } + Initialized |= InitializedState.Initialized; } - public void SlowInitializeProperties() + public virtual void SlowInitializeProperties() { if (IsSelectedInitialized) { @@ -168,42 +192,47 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa } var model = _commandItemModel.Unsafe; - if (model == null) + if (model is null) { return; } var more = model.MoreCommands; - if (more != null) + if (more is not null) { MoreCommands = more - .Where(contextItem => contextItem is ICommandContextItem) - .Select(contextItem => (contextItem as ICommandContextItem)!) - .Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext)) + .Select<IContextItem, IContextItemViewModel>(item => + { + return item is ICommandContextItem contextItem ? new CommandContextItemViewModel(contextItem, PageContext) : new SeparatorViewModel(); + }) .ToList(); } // Here, we're already theoretically in the async context, so we can // use Initialize straight up - MoreCommands.ForEach(contextItem => - { - contextItem.InitializeProperties(); - }); + MoreCommands + .OfType<CommandContextItemViewModel>() + .ToList() + .ForEach(contextItem => + { + contextItem.SlowInitializeProperties(); + }); - _defaultCommandContextItem = new(new CommandContextItem(model.Command!), PageContext) + if (!string.IsNullOrEmpty(model.Command?.Name)) { - _itemTitle = Name, - Subtitle = Subtitle, - Command = Command, + _defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext) + { + _itemTitle = Name, + Subtitle = Subtitle, + Command = Command, - // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever - }; + // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever + // Anything we set manually here must stay in sync with the corresponding properties on CommandItemViewModel. + }; - // Only set the icon on the context item for us if our command didn't - // have its own icon - if (!Command.HasIcon) - { - _defaultCommandContextItem._listItemIcon = _listItemIcon; + // Only set the icon on the context item for us if our command didn't + // have its own icon + UpdateDefaultContextItemIcon(); } Initialized |= InitializedState.SelectionInitialized; @@ -219,13 +248,16 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa FastInitializeProperties(); return true; } - catch (Exception) + catch (Exception ex) { + CoreLogger.LogError("error fast initializing CommandItemViewModel", ex); Command = new(null, PageContext); _itemTitle = "Error"; Subtitle = "Item failed to load"; MoreCommands = []; - _listItemIcon = _errorIcon; + _icon = _errorIcon; + _titleCache.Invalidate(); + _subtitleCache.Invalidate(); Initialized |= InitializedState.Error; } @@ -239,9 +271,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa SlowInitializeProperties(); return true; } - catch (Exception) + catch (Exception ex) { Initialized |= InitializedState.Error; + CoreLogger.LogError("error slow initializing CommandItemViewModel", ex); } return false; @@ -254,13 +287,16 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa InitializeProperties(); return true; } - catch (Exception) + catch (Exception ex) { + CoreLogger.LogError("error initializing CommandItemViewModel", ex); Command = new(null, PageContext); _itemTitle = "Error"; Subtitle = "Item failed to load"; MoreCommands = []; - _listItemIcon = _errorIcon; + _icon = _errorIcon; + _titleCache.Invalidate(); + _subtitleCache.Invalidate(); Initialized |= InitializedState.Error; } @@ -282,7 +318,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa protected virtual void FetchProperty(string propertyName) { var model = this._commandItemModel.Unsafe; - if (model == null) + if (model is null) { return; // throw? } @@ -290,13 +326,19 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa switch (propertyName) { case nameof(Command): - if (Command != null) - { - Command.PropertyChanged -= Command_PropertyChanged; - } - + Command.PropertyChanged -= Command_PropertyChanged; Command = new(model.Command, PageContext); Command.InitializeProperties(); + Command.PropertyChanged += Command_PropertyChanged; + + // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command + // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK. + _itemTitle = model.Title; + + _defaultCommandContextItemViewModel?.Command = Command; + _defaultCommandContextItemViewModel?.UpdateTitle(_itemTitle); + UpdateDefaultContextItemIcon(); + UpdateProperty(nameof(Name)); UpdateProperty(nameof(Title)); UpdateProperty(nameof(Icon)); @@ -304,35 +346,51 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa case nameof(Title): _itemTitle = model.Title; + _titleCache.Invalidate(); break; case nameof(Subtitle): - this.Subtitle = model.Subtitle; + var modelSubtitle = model.Subtitle; + this.Subtitle = modelSubtitle; + _defaultCommandContextItemViewModel?.Subtitle = modelSubtitle; + _subtitleCache.Invalidate(); break; case nameof(Icon): - _listItemIcon = new(model.Icon); - _listItemIcon.InitializeProperties(); + var oldIcon = _icon; + _icon = new(model.Icon); + _icon.InitializeProperties(); + if (oldIcon.IsSet || _icon.IsSet) + { + UpdateProperty(nameof(Icon)); + } + + UpdateDefaultContextItemIcon(); + break; case nameof(model.MoreCommands): var more = model.MoreCommands; - if (more != null) + if (more is not null) { var newContextMenu = more - .Where(contextItem => contextItem is ICommandContextItem) - .Select(contextItem => (contextItem as ICommandContextItem)!) - .Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext)) + .Select<IContextItem, IContextItemViewModel>(item => + { + return item is ICommandContextItem contextItem ? new CommandContextItemViewModel(contextItem, PageContext) : new SeparatorViewModel(); + }) .ToList(); lock (MoreCommands) { ListHelpers.InPlaceUpdateList(MoreCommands, newContextMenu); } - newContextMenu.ForEach(contextItem => - { - contextItem.InitializeProperties(); - }); + newContextMenu + .OfType<CommandContextItemViewModel>() + .ToList() + .ForEach(contextItem => + { + contextItem.InitializeProperties(); + }); } else { @@ -346,6 +404,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa UpdateProperty(nameof(SecondaryCommandName)); UpdateProperty(nameof(HasMoreCommands)); + break; + case nameof(DataPackage): + UpdateDataPackage(ExtendedAttributesProvider?.Unsafe?.GetProperties()); break; } @@ -355,39 +416,90 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa private void Command_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { var propertyName = e.PropertyName; + var model = _commandItemModel.Unsafe; + if (model is null) + { + return; + } + switch (propertyName) { case nameof(Command.Name): - UpdateProperty(nameof(Title)); - UpdateProperty(nameof(Name)); + // Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command + // or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK. + _itemTitle = model.Title; + _titleCache.Invalidate(); + UpdateProperty(nameof(Title), nameof(Name)); + + _defaultCommandContextItemViewModel?.UpdateTitle(model.Command.Name); break; + case nameof(Command.Icon): + UpdateDefaultContextItemIcon(); UpdateProperty(nameof(Icon)); break; } } + private void UpdateDefaultContextItemIcon() + { + // Command icon takes precedence over our icon on the primary command + _defaultCommandContextItemViewModel?.UpdateIcon(Command.Icon.IsSet ? Command.Icon : _icon); + } + + private void UpdateTitle(string? title) + { + _itemTitle = title ?? string.Empty; + _titleCache.Invalidate(); + UpdateProperty(nameof(Title)); + } + + private void UpdateIcon(IIconInfo? iconInfo) + { + _icon = new(iconInfo); + _icon.InitializeProperties(); + UpdateProperty(nameof(Icon)); + } + + private void UpdateDataPackage(IDictionary<string, object?>? properties) + { + DataPackage = + properties?.TryGetValue(WellKnownExtensionAttributes.DataPackage, out var dataPackageView) == true && + dataPackageView is DataPackageView view + ? view + : null; + UpdateProperty(nameof(DataPackage)); + } + + public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) + => _titleCache.GetOrUpdate(matcher, Title); + + public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) + => _subtitleCache.GetOrUpdate(matcher, Subtitle); + protected override void UnsafeCleanup() { base.UnsafeCleanup(); lock (MoreCommands) { - MoreCommands.ForEach(c => c.SafeCleanup()); + MoreCommands.OfType<CommandContextItemViewModel>() + .ToList() + .ForEach(c => c.SafeCleanup()); MoreCommands.Clear(); } // _listItemIcon.SafeCleanup(); - _listItemIcon = new(null); // necessary? + _icon = new(null); // necessary? - _defaultCommandContextItem?.SafeCleanup(); - _defaultCommandContextItem = null; + _defaultCommandContextItemViewModel?.SafeCleanup(); + _defaultCommandContextItemViewModel = null; Command.PropertyChanged -= Command_PropertyChanged; Command.SafeCleanup(); var model = _commandItemModel.Unsafe; - if (model != null) + if (model is not null) { model.PropChanged -= Model_PropChanged; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandViewModel.cs similarity index 64% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandViewModel.cs index c1d3c4e6f9..6ec593bfa4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/CommandViewModel.cs @@ -1,16 +1,18 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class CommandViewModel : ExtensionObjectViewModel { public ExtensionObject<ICommand> Model { get; private set; } = new(null); + public bool IsSet => Model.Unsafe is not null; + protected bool IsInitialized { get; private set; } protected bool IsFastInitialized { get; private set; } @@ -29,6 +31,15 @@ public partial class CommandViewModel : ExtensionObjectViewModel public IconInfoViewModel Icon { get; private set; } + // UNDER NO CIRCUMSTANCES MAY SOMEONE WRITE TO THIS DICTIONARY. + // This is our copy of the data from the extension. + // Adding values to it does not add to the extension. + // Modifying it will not modify the extension + // (except it might, if the dictionary was passed by ref) + private Dictionary<string, ExtensionObject<object>>? _properties; + + public IReadOnlyDictionary<string, ExtensionObject<object>>? Properties => _properties?.AsReadOnly(); + public CommandViewModel(ICommand? command, WeakReference<IPageContext> pageContext) : base(pageContext) { @@ -44,7 +55,7 @@ public partial class CommandViewModel : ExtensionObjectViewModel } var model = Model.Unsafe; - if (model == null) + if (model is null) { return; } @@ -67,19 +78,24 @@ public partial class CommandViewModel : ExtensionObjectViewModel } var model = Model.Unsafe; - if (model == null) + if (model is null) { return; } var ico = model.Icon; - if (ico != null) + if (ico is not null) { Icon = new(ico); Icon.InitializeProperties(); UpdateProperty(nameof(Icon)); } + if (model is IExtendedAttributesProvider command2) + { + UpdatePropertiesFromExtension(command2); + } + model.PropChanged += Model_PropChanged; } @@ -98,7 +114,7 @@ public partial class CommandViewModel : ExtensionObjectViewModel protected void FetchProperty(string propertyName) { var model = Model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } @@ -125,9 +141,31 @@ public partial class CommandViewModel : ExtensionObjectViewModel Icon = new(null); // necessary? var model = Model.Unsafe; - if (model != null) + if (model is not null) { model.PropChanged -= Model_PropChanged; } } + + private void UpdatePropertiesFromExtension(IExtendedAttributesProvider? model) + { + var propertiesFromExtension = model?.GetProperties(); + if (propertiesFromExtension == null) + { + _properties = null; + return; + } + + _properties = []; + + // COPY the properties into us. + // The IDictionary that was passed to us may be marshalled by-ref or by-value, we _don't know_. + // + // If it's by-ref, the values are arbitrary objects that are out-of-proc. + // If it's bu-value, then everything is in-proc, and we can't mutate the data. + foreach (var property in propertiesFromExtension) + { + _properties.Add(property.Key, new(property.Value)); + } + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ConfirmResultViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ConfirmResultViewModel.cs similarity index 89% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ConfirmResultViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ConfirmResultViewModel.cs index 53466cb1ce..c653357ccd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ConfirmResultViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ConfirmResultViewModel.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class ConfirmResultViewModel(IConfirmationArgs _args, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context) @@ -25,7 +25,7 @@ public partial class ConfirmResultViewModel(IConfirmationArgs _args, WeakReferen public override void InitializeProperties() { var model = Model.Unsafe; - if (model == null) + if (model is null) { return; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs similarity index 64% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs index 73bb041b99..b30d02ce83 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentPageViewModel.cs @@ -7,12 +7,12 @@ using System.Diagnostics.CodeAnalysis; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class ContentPageViewModel : PageViewModel, ICommandBarContext { @@ -21,32 +21,33 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext [ObservableProperty] public partial ObservableCollection<ContentViewModel> Content { get; set; } = []; - public List<CommandContextItemViewModel> Commands { get; private set; } = []; + public List<IContextItemViewModel> Commands { get; private set; } = []; - public bool HasCommands => Commands.Count > 0; + public bool HasCommands => ActualCommands.Count > 0; public DetailsViewModel? Details { get; private set; } [MemberNotNullWhen(true, nameof(Details))] - public bool HasDetails => Details != null; + public bool HasDetails => Details is not null; /////// ICommandBarContext /////// - public IEnumerable<CommandContextItemViewModel> MoreCommands => Commands.Skip(1); + public IEnumerable<IContextItemViewModel> MoreCommands => Commands.Skip(1); - public bool HasMoreCommands => Commands.Count > 1; + private List<CommandContextItemViewModel> ActualCommands => Commands.OfType<CommandContextItemViewModel>().ToList(); + + public bool HasMoreCommands => ActualCommands.Count > 1; public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty; - public CommandItemViewModel? PrimaryCommand => HasCommands ? Commands[0] : null; + public CommandItemViewModel? PrimaryCommand => HasCommands ? ActualCommands[0] : null; - public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? Commands[1] : null; + public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[1] : null; - public List<CommandContextItemViewModel> AllCommands => Commands; - /////// /ICommandBarContext /////// + public List<IContextItemViewModel> AllCommands => Commands; // Remember - "observable" properties from the model (via PropChanged) // cannot be marked [ObservableProperty] - public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, CommandPaletteHost host) + public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host) : base(model, scheduler, host) { _model = new(model); @@ -66,7 +67,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext foreach (var item in newItems) { var viewModel = ViewModelFromContent(item, PageContext); - if (viewModel != null) + if (viewModel is not null) { viewModel.InitializeProperties(); newContent.Add(viewModel); @@ -79,6 +80,9 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext throw; } + var oneContent = newContent.Count == 1; + newContent.ForEach(c => c.OnlyControlOnPage = oneContent); + // Now, back to a UI thread to update the observable collection DoOnUiThread( () => @@ -87,16 +91,12 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext }); } - public static ContentViewModel? ViewModelFromContent(IContent content, WeakReference<IPageContext> context) + public virtual ContentViewModel? ViewModelFromContent(IContent content, WeakReference<IPageContext> context) { - ContentViewModel? viewModel = content switch - { - IFormContent form => new ContentFormViewModel(form, context), - IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context), - ITreeContent tree => new ContentTreeViewModel(tree, context), - _ => null, - }; - return viewModel; + // The core ContentPageViewModel doesn't actually handle any content, + // so we just return null here. + // The real content is handled by the derived class CommandPaletteContentPageViewModel + return null; } public override void InitializeProperties() @@ -104,23 +104,36 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext base.InitializeProperties(); var model = _model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } Commands = model.Commands - .Where(contextItem => contextItem is ICommandContextItem) - .Select(contextItem => (contextItem as ICommandContextItem)!) - .Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext)) - .ToList(); - Commands.ForEach(contextItem => - { - contextItem.InitializeProperties(); - }); + .ToList() + .Select<IContextItem, IContextItemViewModel>(item => + { + if (item is ICommandContextItem contextItem) + { + return new CommandContextItemViewModel(contextItem, PageContext); + } + else + { + return new SeparatorViewModel(); + } + }) + .ToList(); + + Commands + .OfType<CommandContextItemViewModel>() + .ToList() + .ForEach(contextItem => + { + contextItem.InitializeProperties(); + }); var extensionDetails = model.Details; - if (extensionDetails != null) + if (extensionDetails is not null) { Details = new(extensionDetails, PageContext); Details.InitializeProperties(); @@ -143,7 +156,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext base.FetchProperty(propertyName); var model = this._model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } @@ -153,22 +166,35 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext case nameof(Commands): var more = model.Commands; - if (more != null) + if (more is not null) { var newContextMenu = more - .Where(contextItem => contextItem is ICommandContextItem) - .Select(contextItem => (contextItem as ICommandContextItem)!) - .Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext)) - .ToList(); + .ToList() + .Select(item => + { + if (item is ICommandContextItem contextItem) + { + return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel; + } + else + { + return new SeparatorViewModel(); + } + }) + .ToList(); + lock (Commands) { ListHelpers.InPlaceUpdateList(Commands, newContextMenu); } - Commands.ForEach(contextItem => - { - contextItem.InitializeProperties(); - }); + Commands + .OfType<CommandContextItemViewModel>() + .ToList() + .ForEach(contextItem => + { + contextItem.SlowInitializeProperties(); + }); } else { @@ -190,7 +216,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext break; case nameof(Details): var extensionDetails = model.Details; - Details = extensionDetails != null ? new(extensionDetails, PageContext) : null; + Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null; UpdateDetails(); break; } @@ -222,7 +248,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext [RelayCommand] private void InvokePrimaryCommand(ContentPageViewModel page) { - if (PrimaryCommand != null) + if (PrimaryCommand is not null) { WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(PrimaryCommand.Command.Model, PrimaryCommand.Model)); } @@ -232,7 +258,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext [RelayCommand] private void InvokeSecondaryCommand(ContentPageViewModel page) { - if (SecondaryCommand != null) + if (SecondaryCommand is not null) { WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(SecondaryCommand.Command.Model, SecondaryCommand.Model)); } @@ -243,10 +269,11 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext base.UnsafeCleanup(); Details?.SafeCleanup(); - foreach (var item in Commands) - { - item.SafeCleanup(); - } + + Commands + .OfType<CommandContextItemViewModel>() + .ToList() + .ForEach(item => item.SafeCleanup()); Commands.Clear(); @@ -258,7 +285,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext Content.Clear(); var model = _model.Unsafe; - if (model != null) + if (model is not null) { model.ItemsChanged -= Model_ItemsChanged; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentViewModel.cs similarity index 66% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentViewModel.cs index 9ebf5495fa..42a2e5c6d9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContentViewModel.cs @@ -1,10 +1,11 @@ -// 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. -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public abstract partial class ContentViewModel(WeakReference<IPageContext> context) : ExtensionObjectViewModel(context) { + public bool OnlyControlOnPage { get; internal set; } } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs new file mode 100644 index 0000000000..83f314a11f --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs @@ -0,0 +1,243 @@ +// 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.ObjectModel; +using System.Runtime.CompilerServices; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Text; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class ContextMenuViewModel : ObservableObject, + IRecipient<UpdateCommandBarMessage> +{ + private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider; + + public ICommandBarContext? SelectedItem + { + get => field; + set + { + field = value; + UpdateContextItems(); + } + } + + [ObservableProperty] + private partial ObservableCollection<List<IContextItemViewModel>> ContextMenuStack { get; set; } = []; + + private List<IContextItemViewModel>? CurrentContextMenu => ContextMenuStack.LastOrDefault(); + + [ObservableProperty] + public partial ObservableCollection<IContextItemViewModel> FilteredItems { get; set; } = []; + + [ObservableProperty] + public partial bool FilterOnTop { get; set; } = false; + + private string _lastSearchText = string.Empty; + + public ContextMenuViewModel(IFuzzyMatcherProvider fuzzyMatcherProvider) + { + _fuzzyMatcherProvider = fuzzyMatcherProvider; + WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this); + } + + public void Receive(UpdateCommandBarMessage message) + { + SelectedItem = message.ViewModel; + } + + public void UpdateContextItems() + { + if (SelectedItem is not null) + { + if (SelectedItem.PrimaryCommand is not null || SelectedItem.HasMoreCommands) + { + ContextMenuStack.Clear(); + PushContextStack(SelectedItem.AllCommands); + } + } + } + + public void SetSearchText(string searchText) + { + if (searchText == _lastSearchText) + { + return; + } + + if (SelectedItem is null) + { + return; + } + + _lastSearchText = searchText; + + if (CurrentContextMenu is null) + { + ListHelpers.InPlaceUpdateList(FilteredItems, []); + return; + } + + if (string.IsNullOrEmpty(searchText)) + { + ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu); + return; + } + + var commands = CurrentContextMenu + .OfType<CommandContextItemViewModel>() + .Where(c => c.ShouldBeVisible); + + var query = _fuzzyMatcherProvider.Current.PrecomputeQuery(searchText); + var newResults = InternalListHelpers.FilterList(commands, in query, ScoreFunction); + ListHelpers.InPlaceUpdateList(FilteredItems, newResults); + } + + private int ScoreFunction(in FuzzyQuery query, CommandContextItemViewModel item) + { + if (string.IsNullOrWhiteSpace(query.Original)) + { + return 1; + } + + if (string.IsNullOrEmpty(item.Title)) + { + return 0; + } + + var fuzzyMatcher = _fuzzyMatcherProvider.Current; + var title = item.GetTitleTarget(fuzzyMatcher); + var subtitle = item.GetSubtitleTarget(fuzzyMatcher); + + var titleScore = fuzzyMatcher.Score(query, title); + var subtitleScore = (fuzzyMatcher.Score(query, subtitle) - 4) / 2; + + return Max3(titleScore, subtitleScore, 0); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int Max3(int a, int b, int c) + { + var m = a > b ? a : b; + return m > c ? m : c; + } + + /// <summary> + /// Generates a mapping of key -> command item for this particular item's + /// MoreCommands. (This won't include the primary Command, but it will + /// include the secondary one). This map can be used to quickly check if a + /// shortcut key was pressed. In case there are duplicate keybindings, the first + /// one is used and the rest are ignored. + /// </summary> + /// <returns>a dictionary of KeyChord -> Context commands, for all commands + /// that have a shortcut key set.</returns> + private Dictionary<KeyChord, CommandContextItemViewModel> Keybindings() + { + var result = new Dictionary<KeyChord, CommandContextItemViewModel>(); + + var menu = CurrentContextMenu; + if (menu is null) + { + return result; + } + + foreach (var item in menu) + { + if (item is CommandContextItemViewModel cmd && cmd.HasRequestedShortcut) + { + var key = cmd.RequestedShortcut ?? new KeyChord(0, 0, 0); + var added = result.TryAdd(key, cmd); + if (!added) + { + CoreLogger.LogWarning($"Ignoring duplicate keyboard shortcut {KeyChordHelpers.FormatForDebug(key)} on command '{cmd.Title ?? cmd.Name ?? "(unknown)"}'"); + } + } + } + + return result; + } + + public ContextKeybindingResult? CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) + { + var keybindings = Keybindings(); + + // Does the pressed key match any of the keybindings? + var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); + return keybindings.TryGetValue(pressedKeyChord, out var item) ? InvokeCommand(item) : null; + } + + public bool CanPopContextStack() + { + return ContextMenuStack.Count > 1; + } + + public void PopContextStack() + { + if (ContextMenuStack.Count > 1) + { + ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); + } + + OnPropertyChanging(nameof(CurrentContextMenu)); + OnPropertyChanged(nameof(CurrentContextMenu)); + + ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu!); + } + + private void PushContextStack(IEnumerable<IContextItemViewModel> commands) + { + ContextMenuStack.Add(commands.ToList()); + OnPropertyChanging(nameof(CurrentContextMenu)); + OnPropertyChanged(nameof(CurrentContextMenu)); + + ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu!); + } + + public void ResetContextMenu() + { + while (ContextMenuStack.Count > 1) + { + ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); + } + + OnPropertyChanging(nameof(CurrentContextMenu)); + OnPropertyChanged(nameof(CurrentContextMenu)); + + if (CurrentContextMenu is not null) + { + ListHelpers.InPlaceUpdateList(FilteredItems, CurrentContextMenu!); + } + } + + public ContextKeybindingResult InvokeCommand(CommandItemViewModel? command) + { + if (command is null) + { + return ContextKeybindingResult.Unhandled; + } + + if (command.HasMoreCommands) + { + // Display the commands child commands + PushContextStack(command.AllCommands); + OnPropertyChanging(nameof(FilteredItems)); + OnPropertyChanged(nameof(FilteredItems)); + return ContextKeybindingResult.KeepOpen; + } + else + { + WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(command.Command.Model, command.Model)); + UpdateContextItems(); + return ContextKeybindingResult.Hide; + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsCommandsViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsCommandsViewModel.cs new file mode 100644 index 0000000000..11a67603e9 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsCommandsViewModel.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 Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class DetailsCommandsViewModel( + IDetailsElement _detailsElement, + WeakReference<IPageContext> context) : DetailsElementViewModel(_detailsElement, context) +{ + public List<CommandViewModel> Commands { get; private set; } = []; + + public bool HasCommands => Commands.Count > 0; + + private readonly ExtensionObject<IDetailsCommands> _dataModel = + new(_detailsElement.Data as IDetailsCommands); + + public override void InitializeProperties() + { + base.InitializeProperties(); + var model = _dataModel.Unsafe; + if (model is null) + { + return; + } + + Commands = model + .Commands? + .Select(c => + { + var vm = new CommandViewModel(c, PageContext); + vm.InitializeProperties(); + return vm; + }) + .ToList() ?? []; + UpdateProperty(nameof(HasCommands)); + UpdateProperty(nameof(Commands)); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsDataViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsDataViewModel.cs similarity index 69% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsDataViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsDataViewModel.cs index 30084f7c7a..9165dde656 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsDataViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsDataViewModel.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public abstract partial class DetailsDataViewModel(IPageContext context) : ExtensionObjectViewModel(context) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsElementViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsElementViewModel.cs similarity index 81% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsElementViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsElementViewModel.cs index e836dbf180..9739220b65 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsElementViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsElementViewModel.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public abstract partial class DetailsElementViewModel(IDetailsElement _detailsElement, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context) { @@ -16,7 +16,7 @@ public abstract partial class DetailsElementViewModel(IDetailsElement _detailsEl public override void InitializeProperties() { var model = _model.Unsafe; - if (model == null) + if (model is null) { return; } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs new file mode 100644 index 0000000000..81fec6e363 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.Input; +using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class DetailsLinkViewModel( + IDetailsElement _detailsElement, + WeakReference<IPageContext> context) : DetailsElementViewModel(_detailsElement, context) +{ + private static readonly string[] _initProperties = [ + nameof(Text), + nameof(Link), + nameof(IsLink), + nameof(IsText), + nameof(NavigateCommand)]; + + private readonly ExtensionObject<IDetailsLink> _dataModel = + new(_detailsElement.Data as IDetailsLink); + + public string Text { get; private set; } = string.Empty; + + public Uri? Link { get; private set; } + + public bool IsLink => Link is not null; + + public bool IsText => !IsLink; + + public RelayCommand? NavigateCommand { get; private set; } + + public override void InitializeProperties() + { + base.InitializeProperties(); + var model = _dataModel.Unsafe; + if (model is null) + { + return; + } + + Text = model.Text ?? string.Empty; + Link = model.Link; + if (string.IsNullOrEmpty(Text) && Link is not null) + { + Text = Link.ToString(); + } + + if (Link is not null) + { + // Custom command to open a link in the default browser or app, + // depending on the link type. + // Binding Link to a Hyperlink(Button).NavigateUri works only for + // certain URI schemes (e.g., http, https) and cannot open file: + // scheme URIs or local files. + NavigateCommand = new RelayCommand( + () => ShellHelpers.OpenInShell(Link.ToString()), + () => Link is not null); + } + + UpdateProperty(_initProperties); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsSeparatorViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsSeparatorViewModel.cs similarity index 82% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsSeparatorViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsSeparatorViewModel.cs index 44b48f0802..e4c76918c0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsSeparatorViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsSeparatorViewModel.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class DetailsSeparatorViewModel( IDetailsElement _detailsElement, diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsTagsViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsTagsViewModel.cs similarity index 87% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsTagsViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsTagsViewModel.cs index e188983047..747a0a74c9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsTagsViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsTagsViewModel.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class DetailsTagsViewModel( IDetailsElement _detailsElement, @@ -22,7 +22,7 @@ public partial class DetailsTagsViewModel( { base.InitializeProperties(); var model = _dataModel.Unsafe; - if (model == null) + if (model is null) { return; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs similarity index 71% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs index 0d5233fa41..1e8642fff7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsViewModel.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class DetailsViewModel(IDetails _details, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context) { @@ -19,6 +19,8 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont public string Body { get; private set; } = string.Empty; + public ContentSize? Size { get; private set; } = ContentSize.Small; + // Metadata is an array of IDetailsElement, // where IDetailsElement = {IDetailsTags, IDetailsLink, IDetailsSeparator} public List<DetailsElementViewModel> Metadata { get; private set; } = []; @@ -26,7 +28,7 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont public override void InitializeProperties() { var model = _detailsModel.Unsafe; - if (model == null) + if (model is null) { return; } @@ -40,8 +42,23 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont UpdateProperty(nameof(Body)); UpdateProperty(nameof(HeroImage)); + if (model is IExtendedAttributesProvider provider) + { + if (provider.GetProperties()?.TryGetValue("Size", out var rawValue) == true) + { + if (rawValue is int sizeAsInt) + { + Size = (ContentSize)sizeAsInt; + } + } + } + + Size ??= ContentSize.Small; + + UpdateProperty(nameof(Size)); + var meta = model.Metadata; - if (meta != null) + if (meta is not null) { foreach (var element in meta) { @@ -49,10 +66,11 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont { IDetailsSeparator => new DetailsSeparatorViewModel(element, this.PageContext), IDetailsLink => new DetailsLinkViewModel(element, this.PageContext), + IDetailsCommands => new DetailsCommandsViewModel(element, this.PageContext), IDetailsTags => new DetailsTagsViewModel(element, this.PageContext), _ => null, }; - if (vm != null) + if (vm is not null) { vm.InitializeProperties(); Metadata.Add(vm); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs new file mode 100644 index 0000000000..53af586431 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ExtensionObjectViewModel.cs @@ -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. + +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, IBatchUpdateTarget, IBackgroundPropertyChangedNotification +{ + private const int InitialPropertyBatchingBufferSize = 16; + + // 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; + + private readonly ConcurrentQueue<string> _pendingProps = []; + + private readonly TaskScheduler _uiScheduler; + + private InterlockedBoolean _batchQueued; + + public WeakReference<IPageContext> 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) + { + if (this is not IPageContext) + { + 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<IPageContext>(context); + _uiScheduler = context.Scheduler; + + LogIfDefaultScheduler(); + } + + private protected ExtensionObjectViewModel(WeakReference<IPageContext> 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<IPageContext>(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 + { + InitializeProperties(); + } + catch (Exception ex) + { + ShowException(ex); + } + } + + public abstract void InitializeProperties(); + + protected void UpdateProperty(string propertyName) => MarkPropertyDirty(propertyName); + + protected void UpdateProperty(string propertyName1, string propertyName2) + { + MarkPropertyDirty(propertyName1); + MarkPropertyDirty(propertyName2); + } + + protected void UpdateProperty(params string[] propertyNames) + { + foreach (var p in propertyNames) + { + 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<string>.Shared.Rent(InitialPropertyBatchingBufferSize); + var count = 0; + var transferred = false; + + try + { + while (_pendingProps.TryDequeue(out var name)) + { + if (count == buffer.Length) + { + var bigger = ArrayPool<string>.Shared.Rent(buffer.Length * 2); + Array.Copy(buffer, bigger, buffer.Length); + ArrayPool<string>.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<string>.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<string>.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)) + { + Task.Factory.StartNew( + action, + CancellationToken.None, + TaskCreationOptions.None, + pageContext.Scheduler); + } + } + + protected virtual void UnsafeCleanup() + { + // base doesn't do anything, but sub-classes should override this. + } + + public virtual void SafeCleanup() + { + try + { + UnsafeCleanup(); + } + catch (Exception ex) + { + CoreLogger.LogDebug(ex.ToString()); + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs new file mode 100644 index 0000000000..45ea3e8a3a --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FilterItemViewModel.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class FilterItemViewModel : ExtensionObjectViewModel, IFilterItemViewModel +{ + private readonly ExtensionObject<IFilter> _model; + + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public IconInfoViewModel Icon { get; set; } = new(null); + + internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized; + + protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized); + + public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error); + + public FilterItemViewModel(IFilter filter, WeakReference<IPageContext> context) + : base(context) + { + _model = new(filter); + } + + public override void InitializeProperties() + { + if (IsInitialized) + { + return; + } + + var filter = _model.Unsafe; + if (filter == null) + { + return; // throw? + } + + Id = filter.Id; + Name = filter.Name; + Icon = new(filter.Icon); + if (Icon is not null) + { + Icon.InitializeProperties(); + } + + UpdateProperty(nameof(Id)); + UpdateProperty(nameof(Name)); + UpdateProperty(nameof(Icon)); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs new file mode 100644 index 0000000000..ba0d828dc3 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/FiltersViewModel.cs @@ -0,0 +1,112 @@ +// 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 Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class FiltersViewModel : ExtensionObjectViewModel +{ + private readonly ExtensionObject<IFilters> _filtersModel; + + public string CurrentFilterId { get; private set; } = string.Empty; + + public IFilterItemViewModel? CurrentFilter { get; private set; } + + public IFilterItemViewModel[] Filters { get; private set; } = []; + + public bool ShouldShowFilters => Filters.Length > 0; + + public FiltersViewModel(ExtensionObject<IFilters> filters, WeakReference<IPageContext> context) + : base(context) + { + _filtersModel = filters; + } + + public override void InitializeProperties() + { + try + { + if (_filtersModel.Unsafe is not null) + { + var filters = _filtersModel.Unsafe.GetFilters(); + var currentFilterId = _filtersModel.Unsafe.CurrentFilterId ?? string.Empty; + + var result = BuildFilters(filters ?? [], currentFilterId); + Filters = result.Items; + CurrentFilterId = currentFilterId; + CurrentFilter = result.Selected; + UpdateProperty(nameof(Filters), nameof(ShouldShowFilters), nameof(CurrentFilterId), nameof(CurrentFilter)); + + return; + } + } + catch (Exception ex) + { + ShowException(ex, _filtersModel.Unsafe?.GetType().Name); + } + + Filters = []; + CurrentFilterId = string.Empty; + CurrentFilter = null; + UpdateProperty(nameof(Filters), nameof(ShouldShowFilters), nameof(CurrentFilterId), nameof(CurrentFilter)); + } + + private (IFilterItemViewModel[] Items, IFilterItemViewModel? Selected) BuildFilters(IFilterItem[] filters, string currentFilterId) + { + if (filters is null || filters.Length == 0) + { + return ([], null); + } + + var items = new List<IFilterItemViewModel>(filters.Length); + FilterItemViewModel? firstFilterItem = null; + FilterItemViewModel? selectedFilterItem = null; + + foreach (var filter in filters) + { + if (filter is IFilter filterItem) + { + var filterItemViewModel = new FilterItemViewModel(filterItem, PageContext); + filterItemViewModel.InitializeProperties(); + + if (firstFilterItem is null) + { + firstFilterItem = filterItemViewModel; + } + + if (selectedFilterItem is null && filterItemViewModel.Id == currentFilterId) + { + selectedFilterItem = filterItemViewModel; + } + + items.Add(filterItemViewModel); + } + else + { + items.Add(new SeparatorViewModel()); + } + } + + return (items.ToArray(), selectedFilterItem ?? firstFilterItem); + } + + public override void SafeCleanup() + { + base.SafeCleanup(); + + foreach (var filter in Filters) + { + if (filter is FilterItemViewModel filterItemViewModel) + { + filterItemViewModel.SafeCleanup(); + } + } + + Filters = []; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs new file mode 100644 index 0000000000..85d85838ac --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public class GalleryGridPropertiesViewModel : IGridPropertiesViewModel +{ + private readonly ExtensionObject<IGalleryGridLayout> _model; + + public bool ShowTitle { get; private set; } + + public bool ShowSubtitle { get; private set; } + + public GalleryGridPropertiesViewModel(IGalleryGridLayout galleryGridLayout) + { + _model = new(galleryGridLayout); + } + + public void InitializeProperties() + { + var model = _model.Unsafe; + if (model is null) + { + return; // throw? + } + + ShowTitle = model.ShowTitle; + ShowSubtitle = model.ShowSubtitle; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GlobalLogPageContext.cs similarity index 92% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GlobalLogPageContext.cs index fde1a36817..228ccc5f4b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/GlobalLogPageContext.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GlobalLogPageContext.cs @@ -2,7 +2,7 @@ // 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; +namespace Microsoft.CmdPal.Core.ViewModels; public class GlobalLogPageContext : IPageContext { 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; + +/// <summary> +/// Provides a notification mechanism for property changes that fires +/// synchronously on the calling thread. +/// </summary> +public interface IBackgroundPropertyChangedNotification +{ + /// <summary> + /// Occurs when the value of a property changes. + /// </summary> + event PropertyChangedEventHandler? PropertyChangedBackground; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.cs new file mode 100644 index 0000000000..a8f65b2634 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IContextItemViewModel.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 System.Diagnostics.CodeAnalysis; + +namespace Microsoft.CmdPal.Core.ViewModels; + +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +public interface IContextItemViewModel +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/GoHomeMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs similarity index 70% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/GoHomeMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs index 2200fe2c56..fb324bb42f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/GoHomeMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IFilterItemViewModel.cs @@ -2,8 +2,8 @@ // 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.Messages; +namespace Microsoft.CmdPal.Core.ViewModels; -public record GoHomeMessage() +public interface IFilterItemViewModel { } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs new file mode 100644 index 0000000000..ec14bbdde3 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.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.ViewModels; + +public interface IGridPropertiesViewModel +{ + bool ShowTitle { get; } + + bool ShowSubtitle { get; } + + void InitializeProperties(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IRootPageService.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IRootPageService.cs new file mode 100644 index 0000000000..77eaecae4b --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IRootPageService.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.ViewModels; + +public interface IRootPageService +{ + /// <summary> + /// Gets the root page of the command palette. Return any IPage implementation that + /// represents the root view of this instance of the command palette. + /// </summary> + Microsoft.CommandPalette.Extensions.IPage GetRootPage(); + + /// <summary> + /// Pre-loads any necessary data or state before the root page is loaded. + /// This will be awaited before the root page and the user can do anything, + /// so ideally it should be quick and not block the UI thread for long. + /// </summary> + Task PreLoadAsync(); + + /// <summary> + /// Do any loading work that can be done after the root page is loaded and + /// displayed to the user. + /// This is run asynchronously, on a background thread. + /// </summary> + Task PostLoadRootPageAsync(); + + /// <summary> + /// Called when a command is performed. The context is the + /// sender context for the invoked command. This is typically the IListItem + /// or ICommandContextItem that was used to invoke the command. + /// </summary> + void OnPerformCommand(object? context, bool topLevel, AppExtensionHost? currentHost); + + void GoHome(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IconDataViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconDataViewModel.cs similarity index 55% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IconDataViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconDataViewModel.cs index 35d91f6739..4044800be6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IconDataViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconDataViewModel.cs @@ -1,13 +1,14 @@ -// 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 CommunityToolkit.Mvvm.ComponentModel; -using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Storage.Streams; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class IconDataViewModel : ObservableObject, IIconData { @@ -16,7 +17,7 @@ public partial class IconDataViewModel : ObservableObject, IIconData // If the extension previously gave us a Data, then died, the data will // throw if we actually try to read it, but the pointer itself won't be // null, so this is relatively safe. - public bool HasIcon => !string.IsNullOrEmpty(Icon) || Data.Unsafe != null; + public bool HasIcon => !string.IsNullOrEmpty(Icon) || Data.Unsafe is not null; // Locally cached properties from IIconData. public string Icon { get; private set; } = string.Empty; @@ -27,6 +28,8 @@ public partial class IconDataViewModel : ObservableObject, IIconData IRandomAccessStreamReference? IIconData.Data => Data.Unsafe; + public string? FontFamily { get; private set; } + public IconDataViewModel(IIconData? icon) { _model = new(icon); @@ -36,12 +39,29 @@ public partial class IconDataViewModel : ObservableObject, IIconData public void InitializeProperties() { var model = _model.Unsafe; - if (model == null) + if (model is null) { return; } Icon = model.Icon; Data = new(model.Data); + + if (model is IExtendedAttributesProvider icon2) + { + var props = icon2.GetProperties(); + + // From Raymond Chen: + // Make sure you don't try do do something like + // icon2.GetProperties().TryGetValue("awesomeKey", out var awesomeValue); + // icon2.GetProperties().TryGetValue("slackerKey", out var slackerValue); + // because each call to GetProperties() is a cross process hop, and if you + // marshal-by-value the property set, then you don't want to throw it away and + // re-marshal it for every property. MAKE SURE YOU CACHE IT. + if (props?.TryGetValue(WellKnownExtensionAttributes.FontFamily, out var family) ?? false) + { + FontFamily = family as string; + } + } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IconInfoViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconInfoViewModel.cs similarity index 88% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IconInfoViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconInfoViewModel.cs index 93f8ece969..aebe9b03aa 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/IconInfoViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IconInfoViewModel.cs @@ -1,12 +1,12 @@ -// 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 CommunityToolkit.Mvvm.ComponentModel; -using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class IconInfoViewModel : ObservableObject, IIconInfo { @@ -26,7 +26,7 @@ public partial class IconInfoViewModel : ObservableObject, IIconInfo public bool HasIcon(bool light) => IconForTheme(light).HasIcon; - public bool IsSet => _model.Unsafe != null; + public bool IsSet => _model.Unsafe is not null; IIconData? IIconInfo.Dark => Dark; @@ -43,7 +43,7 @@ public partial class IconInfoViewModel : ObservableObject, IIconInfo public void InitializeProperties() { var model = _model.Unsafe; - if (model == null) + if (model is null) { return; } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemType.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemType.cs new file mode 100644 index 0000000000..71e6970056 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemType.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.Core.ViewModels; + +public enum ListItemType +{ + Item, + SectionHeader, + Separator, +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs new file mode 100644 index 0000000000..bd3b505dc1 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs @@ -0,0 +1,311 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.CmdPal.Core.ViewModels.Commands; +using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class ListItemViewModel : CommandItemViewModel +{ + public new ExtensionObject<IListItem> Model { get; } + + public List<TagViewModel>? Tags { get; set; } + + // Remember - "observable" properties from the model (via PropChanged) + // cannot be marked [ObservableProperty] + public bool HasTags => (Tags?.Count ?? 0) > 0; + + public string TextToSuggest { get; private set; } = string.Empty; + + public string Section { get; private set; } = string.Empty; + + public ListItemType Type { get; private set; } + + public bool IsInteractive => Type == ListItemType.Item; + + public DetailsViewModel? Details { get; private set; } + + [MemberNotNullWhen(true, nameof(Details))] + public bool HasDetails => Details is not null; + + public string AccessibleName { get; private set; } = string.Empty; + + public bool ShowTitle { get; private set; } + + public bool ShowSubtitle { get; private set; } + + public bool LayoutShowsTitle + { + get; + set + { + if (SetProperty(ref field, value)) + { + UpdateShowsTitle(); + } + } + } + + public bool LayoutShowsSubtitle + { + get; + set + { + if (SetProperty(ref field, value)) + { + UpdateShowsSubtitle(); + } + } + } + + public ListItemViewModel(IListItem model, WeakReference<IPageContext> context) + : base(new(model), context) + { + Model = new ExtensionObject<IListItem>(model); + } + + public override void InitializeProperties() + { + if (IsInitialized) + { + return; + } + + // This sets IsInitialized = true + base.InitializeProperties(); + + var li = Model.Unsafe; + if (li is null) + { + return; // throw? + } + + UpdateTags(li.Tags); + Section = li.Section ?? string.Empty; + Type = EvaluateType(); + UpdateProperty(nameof(Section), nameof(Type), nameof(IsInteractive)); + + UpdateAccessibleName(); + } + + private ListItemType EvaluateType() + { + return Command.IsSet + ? ListItemType.Item + : string.IsNullOrEmpty(Section) ? ListItemType.Separator : ListItemType.SectionHeader; + } + + public override void SlowInitializeProperties() + { + base.SlowInitializeProperties(); + var model = Model.Unsafe; + if (model is null) + { + return; + } + + var extensionDetails = model.Details; + if (extensionDetails is not null) + { + Details = new(extensionDetails, PageContext); + Details.InitializeProperties(); + UpdateProperty(nameof(Details), nameof(HasDetails)); + } + + AddShowDetailsCommands(); + + TextToSuggest = model.TextToSuggest; + UpdateProperty(nameof(TextToSuggest)); + } + + protected override void FetchProperty(string propertyName) + { + base.FetchProperty(propertyName); + + var model = this.Model.Unsafe; + if (model is null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(model.Tags): + UpdateTags(model.Tags); + break; + case nameof(model.TextToSuggest): + TextToSuggest = model.TextToSuggest ?? string.Empty; + UpdateProperty(nameof(TextToSuggest)); + break; + case nameof(model.Section): + Section = model.Section ?? string.Empty; + Type = EvaluateType(); + UpdateProperty(nameof(Section), nameof(Type), nameof(IsInteractive)); + break; + case nameof(model.Command): + Type = EvaluateType(); + UpdateProperty(nameof(Type), nameof(IsInteractive)); + break; + case nameof(Details): + var extensionDetails = model.Details; + Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null; + Details?.InitializeProperties(); + UpdateProperty(nameof(Details), nameof(HasDetails)); + UpdateShowDetailsCommand(); + break; + case nameof(model.MoreCommands): + UpdateProperty(nameof(MoreCommands)); + AddShowDetailsCommands(); + break; + case nameof(model.Title): + UpdateProperty(nameof(Title)); + UpdateShowsTitle(); + UpdateAccessibleName(); + break; + case nameof(model.Subtitle): + UpdateProperty(nameof(Subtitle)); + UpdateShowsSubtitle(); + UpdateAccessibleName(); + break; + default: + UpdateProperty(propertyName); + break; + } + } + + // TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes? + // TODO: Do we want to save off the score here so we can sort by it in our ListViewModel? + public override string ToString() => $"{Name} ListItemViewModel"; + + public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model); + + public override int GetHashCode() => Model.GetHashCode(); + + private void AddShowDetailsCommands() + { + // If the parent page has ShowDetails = false and we have details, + // then we should add a show details action in the context menu. + if (HasDetails && + PageContext.TryGetTarget(out var pageContext) && + pageContext is ListViewModel listViewModel && + !listViewModel.ShowDetails) + { + // Check if "Show Details" action already exists to prevent duplicates + if (!MoreCommands.Any(cmd => cmd is CommandContextItemViewModel contextItemViewModel && + contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId)) + { + // Create the view model for the show details command + var showDetailsCommand = new ShowDetailsCommand(Details); + var showDetailsContextItem = new CommandContextItem(showDetailsCommand); + var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext); + showDetailsContextItemViewModel.SlowInitializeProperties(); + MoreCommands.Add(showDetailsContextItemViewModel); + } + + UpdateProperty(nameof(MoreCommands), nameof(AllCommands)); + } + } + + // This method is called when the details change to make sure we + // have the latest details in the show details command. + private void UpdateShowDetailsCommand() + { + // If the parent page has ShowDetails = false and we have details, + // then we should add a show details action in the context menu. + if (HasDetails && + PageContext.TryGetTarget(out var pageContext) && + pageContext is ListViewModel listViewModel && + !listViewModel.ShowDetails) + { + var existingCommand = MoreCommands.FirstOrDefault(cmd => + cmd is CommandContextItemViewModel contextItemViewModel && + contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId); + + // If the command already exists, remove it to update with the new details + if (existingCommand is not null) + { + MoreCommands.Remove(existingCommand); + } + + // Create the view model for the show details command + var showDetailsCommand = new ShowDetailsCommand(Details); + var showDetailsContextItem = new CommandContextItem(showDetailsCommand); + var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext); + showDetailsContextItemViewModel.SlowInitializeProperties(); + MoreCommands.Add(showDetailsContextItemViewModel); + + UpdateProperty(nameof(MoreCommands), nameof(AllCommands)); + } + } + + private void UpdateTags(ITag[]? newTagsFromModel) + { + var newTags = newTagsFromModel?.Select(t => + { + var vm = new TagViewModel(t, PageContext); + vm.InitializeProperties(); + return vm; + }) + .ToList() ?? []; + + DoOnUiThread( + () => + { + // Tags being an ObservableCollection instead of a List lead to + // many COM exception issues. + Tags = [.. newTags]; + + // We're already in UI thread, so just raise the events + OnPropertyChanged(nameof(Tags)); + OnPropertyChanged(nameof(HasTags)); + }); + } + + private void UpdateShowsTitle() + { + var oldShowTitle = ShowTitle; + ShowTitle = LayoutShowsTitle; + if (oldShowTitle != ShowTitle) + { + UpdateProperty(nameof(ShowTitle)); + } + } + + private void UpdateShowsSubtitle() + { + var oldShowSubtitle = ShowSubtitle; + ShowSubtitle = LayoutShowsSubtitle && !string.IsNullOrWhiteSpace(Subtitle); + if (oldShowSubtitle != ShowSubtitle) + { + UpdateProperty(nameof(ShowSubtitle)); + } + } + + protected override void UnsafeCleanup() + { + base.UnsafeCleanup(); + + // Tags don't have event handlers or anything to cleanup + Tags?.ForEach(t => t.SafeCleanup()); + Details?.SafeCleanup(); + + var model = Model.Unsafe; + if (model is not null) + { + // We don't need to revoke the PropChanged event handler here, + // because we are just overriding CommandItem's FetchProperty and + // piggy-backing off their PropChanged + } + } + + protected void UpdateAccessibleName() + { + AccessibleName = Title + ", " + Subtitle; + UpdateProperty(nameof(AccessibleName)); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs new file mode 100644 index 0000000000..bbfbcadcea --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListViewModel.cs @@ -0,0 +1,803 @@ +// 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.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class ListViewModel : PageViewModel, IDisposable +{ + // private readonly HashSet<ListItemViewModel> _itemCache = []; + private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler); + + // TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items? + + // Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change + // https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/observablegroupedcollections for grouping support + public ObservableCollection<ListItemViewModel> FilteredItems { get; } = []; + + public FiltersViewModel? Filters { get; set; } + + private ObservableCollection<ListItemViewModel> Items { get; set; } = []; + + private readonly ExtensionObject<IListPage> _model; + + private readonly Lock _listLock = new(); + + private InterlockedBoolean _isLoading; + private bool _isFetching; + + public event TypedEventHandler<ListViewModel, object>? ItemsUpdated; + + public bool ShowEmptyContent => + IsInitialized && + FilteredItems.Count == 0 && + (!_isFetching) && + IsLoading == false; + + public bool IsGridView { get; private set; } + + public IGridPropertiesViewModel? GridProperties { get; private set; } + + // Remember - "observable" properties from the model (via PropChanged) + // cannot be marked [ObservableProperty] + public bool ShowDetails { get; private set; } + + private string _modelPlaceholderText = string.Empty; + + public override string PlaceholderText => _modelPlaceholderText; + + public string SearchText { get; private set; } = string.Empty; + + public string InitialSearchText { get; private set; } = string.Empty; + + public CommandItemViewModel EmptyContent { get; private set; } + + public bool IsMainPage { get; init; } + + private bool _isDynamic; + + private Task? _initializeItemsTask; + + // For cancelling the task to load the properties from the items in the list + private CancellationTokenSource? _cancellationTokenSource; + + // For cancelling the task for calling GetItems on the extension + private CancellationTokenSource? _fetchItemsCancellationTokenSource; + + // For cancelling ongoing calls to update the extension's SearchText + private CancellationTokenSource? filterCancellationTokenSource; + + private ListItemViewModel? _lastSelectedItem; + + public override bool IsInitialized + { + get => base.IsInitialized; protected set + { + base.IsInitialized = value; + UpdateEmptyContent(); + } + } + + public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host) + : base(model, scheduler, host) + { + _model = new(model); + EmptyContent = new(new(null), PageContext); + } + + private void FiltersPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(FiltersViewModel.Filters)) + { + var filtersViewModel = sender as FiltersViewModel; + var hasFilters = filtersViewModel?.Filters.Length > 0; + HasFilters = hasFilters; + UpdateProperty(nameof(HasFilters)); + } + } + + // TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching? + private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(); + + protected override void OnSearchTextBoxUpdated(string searchTextBox) + { + //// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view + //// and manage filtering below, but we should be smarter about this and understand caching and other requirements... + //// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList? + + // Dynamic pages will handler their own filtering. They will tell us if + // something needs to change, by raising ItemsChanged. + if (_isDynamic) + { + filterCancellationTokenSource?.Cancel(); + filterCancellationTokenSource?.Dispose(); + filterCancellationTokenSource = new CancellationTokenSource(); + + // Hop off to an exclusive scheduler background thread to update the + // extension. We do this to ensure that all filter update requests + // are serialized and in-order, so providers know to cancel previous + // requests when a new one comes in. Otherwise, they may execute + // concurrently. + _ = filterTaskFactory.StartNew( + () => + { + filterCancellationTokenSource.Token.ThrowIfCancellationRequested(); + + try + { + if (_model.Unsafe is IDynamicListPage dynamic) + { + dynamic.SearchText = searchTextBox; + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + ShowException(ex, _model?.Unsafe?.Name); + } + }, + filterCancellationTokenSource.Token, + TaskCreationOptions.None, + filterTaskFactory.Scheduler!); + } + else + { + // But for all normal pages, we should run our fuzzy match on them. + lock (_listLock) + { + ApplyFilterUnderLock(); + } + + ItemsUpdated?.Invoke(this, EventArgs.Empty); + UpdateEmptyContent(); + _isLoading.Clear(); + } + } + + public void UpdateCurrentFilter(string currentFilterId) + { + // We're getting called on the UI thread. + // Hop off to a BG thread to update the extension. + _ = Task.Run(() => + { + try + { + if (_model.Unsafe is IListPage listPage) + { + listPage.Filters?.CurrentFilterId = currentFilterId; + } + } + catch (Exception ex) + { + ShowException(ex, _model?.Unsafe?.Name); + } + }); + } + + //// Run on background thread, from InitializeAsync or Model_ItemsChanged + private void FetchItems() + { + // Cancel any previous FetchItems operation + _fetchItemsCancellationTokenSource?.Cancel(); + _fetchItemsCancellationTokenSource?.Dispose(); + _fetchItemsCancellationTokenSource = new CancellationTokenSource(); + + var cancellationToken = _fetchItemsCancellationTokenSource.Token; + + // TEMPORARY: just plop all the items into a single group + // see 9806fe5d8 for the last commit that had this with sections + _isFetching = true; + + // Collect all the items into new viewmodels + Collection<ListItemViewModel> newViewModels = []; + + try + { + // Check for cancellation before starting expensive operations + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var newItems = _model.Unsafe!.GetItems(); + + // Check for cancellation after getting items from extension + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // TODO we can probably further optimize this by also keeping a + // HashSet of every ExtensionObject we currently have, and only + // building new viewmodels for the ones we haven't already built. + var showsTitle = GridProperties?.ShowTitle ?? true; + var showsSubtitle = GridProperties?.ShowSubtitle ?? true; + foreach (var item in newItems) + { + // Check for cancellation during item processing + if (cancellationToken.IsCancellationRequested) + { + return; + } + + ListItemViewModel viewModel = new(item, new(this)); + + // If an item fails to load, silently ignore it. + if (viewModel.SafeFastInit()) + { + viewModel.LayoutShowsTitle = showsTitle; + viewModel.LayoutShowsSubtitle = showsSubtitle; + newViewModels.Add(viewModel); + } + } + + // Check for cancellation before initializing first twenty items + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var firstTwenty = newViewModels.Take(20); + foreach (var item in firstTwenty) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + item?.SafeInitializeProperties(); + } + + // Cancel any ongoing search + _cancellationTokenSource?.Cancel(); + + // Check for cancellation before updating the list + if (cancellationToken.IsCancellationRequested) + { + return; + } + + List<ListItemViewModel> removedItems = []; + lock (_listLock) + { + // Now that we have new ViewModels for everything from the + // extension, smartly update our list of VMs + ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems); + + // DO NOT ThrowIfCancellationRequested AFTER THIS! If you do, + // you'll clean up list items that we've now transferred into + // .Items + } + + // If we removed items, we need to clean them up, to remove our event handlers + foreach (var removedItem in removedItems) + { + removedItem.SafeCleanup(); + } + + // TODO: Iterate over everything in Items, and prune items from the + // cache if we don't need them anymore + } + catch (OperationCanceledException) + { + // Cancellation is expected, don't treat as error + + // However, if we were cancelled, we didn't actually add these items to + // our Items list. Before we release them to the GC, make sure we clean + // them up + foreach (var vm in newViewModels) + { + vm.SafeCleanup(); + } + + return; + } + catch (Exception ex) + { + // TODO: Move this within the for loop, so we can catch issues with individual items + // Create a special ListItemViewModel for errors and use an ItemTemplateSelector in the ListPage to display error items differently. + ShowException(ex, _model?.Unsafe?.Name); + throw; + } + finally + { + _isFetching = false; + } + + _cancellationTokenSource = new CancellationTokenSource(); + + _initializeItemsTask = new Task(() => + { + InitializeItemsTask(_cancellationTokenSource.Token); + }); + _initializeItemsTask.Start(); + + DoOnUiThread( + () => + { + lock (_listLock) + { + // Now that our Items contains everything we want, it's time for us to + // re-evaluate our Filter on those items. + if (!_isDynamic) + { + // A static list? Great! Just run the filter. + ApplyFilterUnderLock(); + } + else + { + // A dynamic list? Even better! Just stick everything into + // FilteredItems. The extension already did any filtering it cared about. + ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState)); + } + + UpdateEmptyContent(); + } + + ItemsUpdated?.Invoke(this, EventArgs.Empty); + _isLoading.Clear(); + }); + } + + private void InitializeItemsTask(CancellationToken ct) + { + // Were we already canceled? + if (ct.IsCancellationRequested) + { + return; + } + + ListItemViewModel[] iterable; + lock (_listLock) + { + iterable = Items.ToArray(); + } + + foreach (var item in iterable) + { + if (ct.IsCancellationRequested) + { + return; + } + + // TODO: GH #502 + // We should probably remove the item from the list if it + // entered the error state. I had issues doing that without having + // multiple threads muck with `Items` (and possibly FilteredItems!) + // at once. + item.SafeInitializeProperties(); + + if (ct.IsCancellationRequested) + { + return; + } + } + } + + /// <summary> + /// Apply our current filter text to the list of items, and update + /// FilteredItems to match the results. + /// </summary> + private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, SearchTextBox)); + + /// <summary> + /// Helper to generate a weighting for a given list item, based on title, + /// subtitle, etc. Largely a copy of the version in ListHelpers, but + /// operating on ViewModels instead of extension objects. + /// </summary> + private static int ScoreListItem(string query, CommandItemViewModel listItem) + { + if (string.IsNullOrEmpty(query)) + { + return 1; + } + + var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title); + var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle); + return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max(); + } + + private struct ScoredListItemViewModel + { + public int Score; + public ListItemViewModel ViewModel; + } + + // Similarly stolen from ListHelpers.FilterList + public static IEnumerable<ListItemViewModel> FilterList(IEnumerable<ListItemViewModel> items, string query) + { + var scores = items + .Where(i => !i.IsInErrorState) + .Select(li => new ScoredListItemViewModel() { ViewModel = li, Score = ScoreListItem(query, li) }) + .Where(score => score.Score > 0) + .OrderByDescending(score => score.Score); + return scores + .Select(score => score.ViewModel); + } + + // InvokeItemCommand is what this will be in Xaml due to source generator + // This is what gets invoked when the user presses <enter> + [RelayCommand] + private void InvokeItem(ListItemViewModel? item) + { + if (item is not null) + { + WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command.Model, item.Model)); + } + else if (ShowEmptyContent && EmptyContent.PrimaryCommand?.Model.Unsafe is not null) + { + WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new( + EmptyContent.PrimaryCommand.Command.Model, + EmptyContent.PrimaryCommand.Model)); + } + } + + // This is what gets invoked when the user presses <ctrl+enter> + [RelayCommand] + private void InvokeSecondaryCommand(ListItemViewModel? item) + { + if (item is not null) + { + if (item.SecondaryCommand is not null) + { + WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.SecondaryCommand.Command.Model, item.Model)); + } + } + else if (ShowEmptyContent && EmptyContent.SecondaryCommand?.Model.Unsafe is not null) + { + WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new( + EmptyContent.SecondaryCommand.Command.Model, + EmptyContent.SecondaryCommand.Model)); + } + } + + [RelayCommand] + private void UpdateSelectedItem(ListItemViewModel? item) + { + if (_lastSelectedItem is not null) + { + _lastSelectedItem.PropertyChanged -= SelectedItemPropertyChanged; + } + + if (item is not null) + { + SetSelectedItem(item); + } + else + { + ClearSelectedItem(); + } + } + + private void SetSelectedItem(ListItemViewModel item) + { + if (!item.SafeSlowInit()) + { + // Even if initialization fails, we need to hide any previously shown details + DoOnUiThread(() => + { + WeakReferenceMessenger.Default.Send<HideDetailsMessage>(); + }); + return; + } + + // GH #322: + // For inexplicable reasons, if you try updating the command bar and + // the details on the same UI thread tick as updating the list, we'll + // explode + DoOnUiThread( + () => + { + WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item)); + + if (ShowDetails && item.HasDetails) + { + WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details)); + } + else + { + WeakReferenceMessenger.Default.Send<HideDetailsMessage>(); + } + + TextToSuggest = item.TextToSuggest; + WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest)); + }); + + _lastSelectedItem = item; + _lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged; + } + + private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + var item = _lastSelectedItem; + if (item is null) + { + return; + } + + // already on the UI thread here + switch (e.PropertyName) + { + case nameof(item.Command): + case nameof(item.SecondaryCommand): + case nameof(item.AllCommands): + case nameof(item.Name): + WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item)); + break; + case nameof(item.Details): + if (ShowDetails && item.HasDetails) + { + WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details)); + } + else + { + WeakReferenceMessenger.Default.Send<HideDetailsMessage>(); + } + + break; + case nameof(item.TextToSuggest): + TextToSuggest = item.TextToSuggest; + break; + } + } + + private void ClearSelectedItem() + { + // GH #322: + // For inexplicable reasons, if you try updating the command bar and + // the details on the same UI thread tick as updating the list, we'll + // explode + DoOnUiThread( + () => + { + WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)); + + WeakReferenceMessenger.Default.Send<HideDetailsMessage>(); + + WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty)); + + TextToSuggest = string.Empty; + }); + } + + public override void InitializeProperties() + { + base.InitializeProperties(); + + var model = _model.Unsafe; + if (model is null) + { + return; // throw? + } + + _isDynamic = model is IDynamicListPage; + + IsGridView = model.GridProperties is not null; + UpdateProperty(nameof(IsGridView)); + + GridProperties = LoadGridPropertiesViewModel(model.GridProperties); + GridProperties?.InitializeProperties(); + UpdateProperty(nameof(GridProperties)); + ApplyLayoutToItems(); + + ShowDetails = model.ShowDetails; + UpdateProperty(nameof(ShowDetails)); + + _modelPlaceholderText = model.PlaceholderText; + UpdateProperty(nameof(PlaceholderText)); + + InitialSearchText = SearchText = model.SearchText; + UpdateProperty(nameof(SearchText)); + UpdateProperty(nameof(InitialSearchText)); + + EmptyContent = new(new(model.EmptyContent), PageContext); + EmptyContent.SlowInitializeProperties(); + + Filters?.PropertyChanged -= FiltersPropertyChanged; + Filters = new(new(model.Filters), PageContext); + Filters?.PropertyChanged += FiltersPropertyChanged; + + Filters?.InitializeProperties(); + UpdateProperty(nameof(Filters)); + + FetchItems(); + model.ItemsChanged += Model_ItemsChanged; + } + + private static IGridPropertiesViewModel? LoadGridPropertiesViewModel(IGridProperties? gridProperties) + { + return gridProperties switch + { + IMediumGridLayout mediumGridLayout => new MediumGridPropertiesViewModel(mediumGridLayout), + IGalleryGridLayout galleryGridLayout => new GalleryGridPropertiesViewModel(galleryGridLayout), + ISmallGridLayout smallGridLayout => new SmallGridPropertiesViewModel(smallGridLayout), + _ => null, + }; + } + + public void LoadMoreIfNeeded() + { + var model = _model.Unsafe; + if (model is null) + { + return; + } + + if (!_isLoading.Set()) + { + return; + + // NOTE: May miss newly available items until next scroll if model + // state changes between our check and this reset + } + + _ = Task.Run(() => + { + // Execute all COM calls on background thread to avoid reentrancy issues with UI + // with the UI thread when COM starts inner message pump + try + { + if (model.HasMoreItems) + { + model.LoadMore(); + + // _isLoading flag will be set as a result of LoadMore, + // which must raise ItemsChanged to end the loading. + } + else + { + _isLoading.Clear(); + } + } + catch (Exception ex) + { + _isLoading.Clear(); + ShowException(ex, model.Name); + } + }); + } + + protected override void FetchProperty(string propertyName) + { + base.FetchProperty(propertyName); + + var model = _model.Unsafe; + if (model is null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(GridProperties): + IsGridView = model.GridProperties is not null; + GridProperties = LoadGridPropertiesViewModel(model.GridProperties); + GridProperties?.InitializeProperties(); + UpdateProperty(nameof(IsGridView)); + ApplyLayoutToItems(); + break; + case nameof(ShowDetails): + ShowDetails = model.ShowDetails; + break; + case nameof(PlaceholderText): + _modelPlaceholderText = model.PlaceholderText; + break; + case nameof(SearchText): + SearchText = model.SearchText; + break; + case nameof(EmptyContent): + EmptyContent = new(new(model.EmptyContent), PageContext); + EmptyContent.SlowInitializeProperties(); + break; + case nameof(Filters): + Filters?.PropertyChanged -= FiltersPropertyChanged; + Filters = new(new(model.Filters), PageContext); + Filters?.PropertyChanged += FiltersPropertyChanged; + Filters?.InitializeProperties(); + break; + case nameof(IsLoading): + UpdateEmptyContent(); + break; + } + + UpdateProperty(propertyName); + } + + private void UpdateEmptyContent() + { + UpdateProperty(nameof(ShowEmptyContent)); + if (!ShowEmptyContent || EmptyContent.Model.Unsafe is null) + { + return; + } + + UpdateProperty(nameof(EmptyContent)); + + DoOnUiThread( + () => + { + WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(EmptyContent)); + }); + } + + private void ApplyLayoutToItems() + { + lock (_listLock) + { + var showsTitle = GridProperties?.ShowTitle ?? true; + var showsSubtitle = GridProperties?.ShowSubtitle ?? true; + + foreach (var item in Items) + { + item.LayoutShowsTitle = showsTitle; + item.LayoutShowsSubtitle = showsSubtitle; + } + } + } + + public void Dispose() + { + GC.SuppressFinalize(this); + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + + filterCancellationTokenSource?.Cancel(); + filterCancellationTokenSource?.Dispose(); + filterCancellationTokenSource = null; + + _fetchItemsCancellationTokenSource?.Cancel(); + _fetchItemsCancellationTokenSource?.Dispose(); + _fetchItemsCancellationTokenSource = null; + } + + protected override void UnsafeCleanup() + { + base.UnsafeCleanup(); + + EmptyContent?.SafeCleanup(); + EmptyContent = new(new(null), PageContext); // necessary? + + _cancellationTokenSource?.Cancel(); + filterCancellationTokenSource?.Cancel(); + _fetchItemsCancellationTokenSource?.Cancel(); + + lock (_listLock) + { + foreach (var item in Items) + { + item.SafeCleanup(); + } + + Items.Clear(); + foreach (var item in FilteredItems) + { + item.SafeCleanup(); + } + + FilteredItems.Clear(); + } + + Filters?.PropertyChanged -= FiltersPropertyChanged; + Filters?.SafeCleanup(); + + var model = _model.Unsafe; + if (model is not null) + { + model.ItemsChanged -= Model_ItemsChanged; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LoadingPageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs similarity index 71% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LoadingPageViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.cs index 60571cbe73..3e2cd420c2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LoadingPageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LoadingPageViewModel.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 Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class LoadingPageViewModel : PageViewModel { - public LoadingPageViewModel(IPage? model, TaskScheduler scheduler) - : base(model, scheduler, CommandPaletteHost.Instance) + public LoadingPageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost host) + : base(model, scheduler, host) { ModelIsLoading = true; IsInitialized = false; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LogMessageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LogMessageViewModel.cs similarity index 80% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LogMessageViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LogMessageViewModel.cs index 60d065ac04..969bf60aea 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/LogMessageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/LogMessageViewModel.cs @@ -2,10 +2,10 @@ // 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.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class LogMessageViewModel : ExtensionObjectViewModel { @@ -13,8 +13,6 @@ public partial class LogMessageViewModel : ExtensionObjectViewModel public string Message { get; private set; } = string.Empty; - public string ExtensionPfn { get; set; } = string.Empty; - public LogMessageViewModel(ILogMessage message, IPageContext context) : base(context) { @@ -24,7 +22,7 @@ public partial class LogMessageViewModel : ExtensionObjectViewModel public override void InitializeProperties() { var model = _model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs new file mode 100644 index 0000000000..2059e1547b --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.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.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public class MediumGridPropertiesViewModel : IGridPropertiesViewModel +{ + private readonly ExtensionObject<IMediumGridLayout> _model; + + public bool ShowTitle { get; private set; } + + public bool ShowSubtitle => false; + + public MediumGridPropertiesViewModel(IMediumGridLayout mediumGridLayout) + { + _model = new(mediumGridLayout); + } + + public void InitializeProperties() + { + var model = _model.Unsafe; + if (model is null) + { + return; // throw? + } + + ShowTitle = model.ShowTitle; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ActivateSecondaryCommandMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSecondaryCommandMessage.cs similarity index 77% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ActivateSecondaryCommandMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSecondaryCommandMessage.cs index 1e35d6d796..1ecd7431d2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ActivateSecondaryCommandMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSecondaryCommandMessage.cs @@ -1,8 +1,8 @@ -// 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. -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; /// <summary> /// Used to perform a list item's secondary command when the user presses ctrl+enter in the search box diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ActivateSelectedListItemMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSelectedListItemMessage.cs similarity index 86% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ActivateSelectedListItemMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSelectedListItemMessage.cs index 339e0c1b3d..bdd1876d4e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ActivateSelectedListItemMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ActivateSelectedListItemMessage.cs @@ -2,7 +2,7 @@ // 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.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; /// <summary> /// Used to perform a list item's command when the user presses enter in the search box diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/BeginInvokeMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/BeginInvokeMessage.cs new file mode 100644 index 0000000000..8ed180a27c --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/BeginInvokeMessage.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.Core.ViewModels.Messages; + +public record BeginInvokeMessage; diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ClearSearchMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ClearSearchMessage.cs new file mode 100644 index 0000000000..47bd0e7be8 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ClearSearchMessage.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +public record ClearSearchMessage() +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenContextMenuMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CloseContextMenuMessage.cs similarity index 53% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenContextMenuMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CloseContextMenuMessage.cs index c35ab284af..5178e00631 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenContextMenuMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CloseContextMenuMessage.cs @@ -2,11 +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. -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; /// <summary> -/// Used to perform a list item's secondary command when the user presses ctrl+enter in the search box +/// Used to announce that a context menu should close /// </summary> -public record OpenContextMenuMessage +public record CloseContextMenuMessage { } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CmdPalInvokeResultMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CmdPalInvokeResultMessage.cs new file mode 100644 index 0000000000..da81db6e3c --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/CmdPalInvokeResultMessage.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.Core.ViewModels.Messages; + +public record CmdPalInvokeResultMessage(Microsoft.CommandPalette.Extensions.CommandResultKind Kind); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/DismissMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/DismissMessage.cs new file mode 100644 index 0000000000..8150ee6584 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/DismissMessage.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.Core.ViewModels.Messages; + +public record DismissMessage(bool ForceGoHome = false); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ErrorOccurredMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ErrorOccurredMessage.cs new file mode 100644 index 0000000000..bfd60de675 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ErrorOccurredMessage.cs @@ -0,0 +1,11 @@ +// 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.ViewModels.Messages; + +/// <summary> +/// Message sent when an error occurs during command execution. +/// Used to track session error count for telemetry. +/// </summary> +public record ErrorOccurredMessage(); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ExtensionInvokedMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ExtensionInvokedMessage.cs new file mode 100644 index 0000000000..a8a2ee0055 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ExtensionInvokedMessage.cs @@ -0,0 +1,11 @@ +// 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.ViewModels.Messages; + +/// <summary> +/// Message sent when an extension command or page is invoked. +/// Captures extension usage metrics for telemetry tracking. +/// </summary> +public record ExtensionInvokedMessage(string ExtensionId, string CommandId, string CommandName, bool Success, ulong ExecutionTimeMs); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/FocusSearchBoxMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/FocusSearchBoxMessage.cs similarity index 66% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/FocusSearchBoxMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/FocusSearchBoxMessage.cs index 4e6db37daa..73fad88301 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/FocusSearchBoxMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/FocusSearchBoxMessage.cs @@ -1,8 +1,8 @@ -// 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. -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; public record FocusSearchBoxMessage() { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoBackMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoBackMessage.cs new file mode 100644 index 0000000000..86872d6bb1 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoBackMessage.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.Core.ViewModels.Messages; + +public record GoBackMessage(bool WithAnimation = true, bool FocusSearch = true) +{ + // TODO! sticking these properties here feels like leaking the UI into the models +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoHomeMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoHomeMessage.cs new file mode 100644 index 0000000000..d99cbbdd16 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/GoHomeMessage.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.Core.ViewModels.Messages; + +// TODO! sticking these properties here feels like leaking the UI into the models +public record GoHomeMessage(bool WithAnimation = true, bool FocusSearch = true) +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/HandleCommandResultMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HandleCommandResultMessage.cs similarity index 66% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/HandleCommandResultMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HandleCommandResultMessage.cs index 3adf7ae30c..7c60f7cb4e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/HandleCommandResultMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HandleCommandResultMessage.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; public record HandleCommandResultMessage(ExtensionObject<ICommandResult> Result) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/HideDetailsMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HideDetailsMessage.cs similarity index 62% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/HideDetailsMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HideDetailsMessage.cs index a04455b519..43fa403731 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/HideDetailsMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/HideDetailsMessage.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; public record HideDetailsMessage() { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/LaunchUriMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/LaunchUriMessage.cs similarity index 62% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/LaunchUriMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/LaunchUriMessage.cs index dba45af1b7..3835b7cc7f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/LaunchUriMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/LaunchUriMessage.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; public record LaunchUriMessage(Uri Uri) { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateBackMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateBackMessage.cs new file mode 100644 index 0000000000..e6152b75d5 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateBackMessage.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.Core.ViewModels.Messages; + +public record NavigateBackMessage(bool FromBackspace = false); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateLeftCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateLeftCommand.cs new file mode 100644 index 0000000000..d352b552cf --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateLeftCommand.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.Core.ViewModels.Messages; + +/// <summary> +/// Used to navigate left in a grid view when pressing the Left arrow key in the SearchBox. +/// </summary> +public record NavigateLeftCommand; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigateNextCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateNextCommand.cs similarity index 76% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigateNextCommand.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateNextCommand.cs index c996e0eecf..88ee239c95 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigateNextCommand.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateNextCommand.cs @@ -1,8 +1,8 @@ -// 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. -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Commands; /// <summary> /// Used to navigate to the next command in the page when pressing the Down key in the SearchBox. diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.cs new file mode 100644 index 0000000000..6c11394382 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageDownCommand.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.Core.ViewModels.Messages; + +/// <summary> +/// Used to navigate down one page in the page when pressing the PageDown key in the SearchBox. +/// </summary> +public record NavigatePageDownCommand +{ +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.cs new file mode 100644 index 0000000000..1985c07438 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePageUpCommand.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.Core.ViewModels.Messages; + +/// <summary> +/// Used to navigate up one page in the page when pressing the PageUp key in the SearchBox. +/// </summary> +public record NavigatePageUpCommand +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigatePreviousCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePreviousCommand.cs similarity index 77% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigatePreviousCommand.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePreviousCommand.cs index 76f0f07908..4d28fce50b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigatePreviousCommand.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigatePreviousCommand.cs @@ -1,8 +1,8 @@ -// 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. -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; /// <summary> /// Used to navigate to the previous command in the page when pressing the Down key in the SearchBox. diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.cs new file mode 100644 index 0000000000..3cfb05913d --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateRightCommand.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.Core.ViewModels.Messages; + +/// <summary> +/// Used to navigate right in a grid view when pressing the Right arrow key in the SearchBox. +/// </summary> +public record NavigateRightCommand; diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateToPageMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateToPageMessage.cs new file mode 100644 index 0000000000..a9318a88d7 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigateToPageMessage.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.Core.ViewModels.Messages; + +public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigationDepthMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigationDepthMessage.cs new file mode 100644 index 0000000000..b916f28244 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/NavigationDepthMessage.cs @@ -0,0 +1,11 @@ +// 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.ViewModels.Messages; + +/// <summary> +/// Message containing the current navigation depth (BackStack count) when navigating to a page. +/// Used to track maximum navigation depth reached during a session for telemetry. +/// </summary> +public record NavigationDepthMessage(int Depth); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs similarity index 75% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs index 9bc0c730e8..cd07a51beb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/PerformCommandMessage.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; /// <summary> /// Used to do a command - navigate to a page or invoke it @@ -18,21 +18,12 @@ public record PerformCommandMessage public bool WithAnimation { get; set; } = true; - public CommandPaletteHost? ExtensionHost { get; private set; } - public PerformCommandMessage(ExtensionObject<ICommand> command) { Command = command; Context = null; } - public PerformCommandMessage(TopLevelViewModel topLevelCommand) - { - Command = topLevelCommand.CommandViewModel.Model; - Context = null; - ExtensionHost = topLevelCommand.ExtensionHost; - } - public PerformCommandMessage(ExtensionObject<ICommand> command, ExtensionObject<IListItem> context) { Command = command; @@ -51,6 +42,12 @@ public record PerformCommandMessage Context = context.Unsafe; } + public PerformCommandMessage(CommandContextItemViewModel contextCommand) + { + Command = contextCommand.Command.Model; + Context = contextCommand.Model.Unsafe; + } + public PerformCommandMessage(ConfirmResultViewModel vm) { Command = vm.PrimaryCommand.Model; diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/SearchQueryMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/SearchQueryMessage.cs new file mode 100644 index 0000000000..7516af0b34 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/SearchQueryMessage.cs @@ -0,0 +1,11 @@ +// 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.ViewModels.Messages; + +/// <summary> +/// Message sent when a search query is executed in the Command Palette. +/// Used to track session search activity for telemetry. +/// </summary> +public record SearchQueryMessage(); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/SessionDurationMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/SessionDurationMessage.cs new file mode 100644 index 0000000000..4b77a1fd06 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/SessionDurationMessage.cs @@ -0,0 +1,11 @@ +// 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.ViewModels.Messages; + +/// <summary> +/// Message containing session telemetry data from Command Palette launch to dismissal. +/// Used to aggregate metrics like duration, commands executed, pages visited, and search activity. +/// </summary> +public record SessionDurationMessage(ulong DurationMs, int CommandsExecuted, int PagesVisited, string DismissalReason, int SearchQueriesCount, int MaxNavigationDepth, int ErrorCount); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowConfirmationMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowConfirmationMessage.cs new file mode 100644 index 0000000000..fb7c5e88a8 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowConfirmationMessage.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +public record ShowConfirmationMessage(Microsoft.CommandPalette.Extensions.IConfirmationArgs? Args) +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ShowDetailsMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowDetailsMessage.cs similarity index 64% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ShowDetailsMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowDetailsMessage.cs index b51300d6d0..69dda7a589 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ShowDetailsMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowDetailsMessage.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; public record ShowDetailsMessage(DetailsViewModel Details) { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowToastMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowToastMessage.cs new file mode 100644 index 0000000000..869d169858 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowToastMessage.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +public record ShowToastMessage(string Message) +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ShowWindowMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowWindowMessage.cs similarity index 67% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ShowWindowMessage.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowWindowMessage.cs index 9e880c08f0..01c05ff9b7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ShowWindowMessage.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/ShowWindowMessage.cs @@ -1,8 +1,8 @@ -// 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. -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.Core.ViewModels.Messages; public record ShowWindowMessage(IntPtr Hwnd) { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TelemetryBeginInvokeMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TelemetryBeginInvokeMessage.cs new file mode 100644 index 0000000000..87a1ae8aef --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TelemetryBeginInvokeMessage.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.Core.ViewModels.Messages; + +/// <summary> +/// Telemetry message sent when command invocation begins. +/// </summary> +public record TelemetryBeginInvokeMessage; diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TelemetryExtensionInvokedMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TelemetryExtensionInvokedMessage.cs new file mode 100644 index 0000000000..464d5ae696 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TelemetryExtensionInvokedMessage.cs @@ -0,0 +1,11 @@ +// 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.ViewModels.Messages; + +/// <summary> +/// Telemetry message sent when an extension command or page is invoked. +/// Captures extension usage metrics for telemetry tracking. +/// </summary> +public record TelemetryExtensionInvokedMessage(string ExtensionId, string CommandId, string CommandName, bool Success, ulong ExecutionTimeMs); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TelemetryInvokeResultMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TelemetryInvokeResultMessage.cs new file mode 100644 index 0000000000..06e0b4fd53 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TelemetryInvokeResultMessage.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.Core.ViewModels.Messages; + +/// <summary> +/// Telemetry message sent when command invocation completes with a result. +/// </summary> +public record TelemetryInvokeResultMessage(Microsoft.CommandPalette.Extensions.CommandResultKind Kind); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TryCommandKeybindingMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TryCommandKeybindingMessage.cs new file mode 100644 index 0000000000..48f7305e0a --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/TryCommandKeybindingMessage.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 Windows.System; + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +public record TryCommandKeybindingMessage(bool Ctrl, bool Alt, bool Shift, bool Win, VirtualKey Key) +{ + public bool Handled { get; set; } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs new file mode 100644 index 0000000000..ad99b25933 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateCommandBarMessage.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +/// <summary> +/// Used to update the command bar at the bottom to reflect the commands for a list item +/// </summary> +public record UpdateCommandBarMessage(ICommandBarContext? ViewModel) +{ +} + +public interface IContextMenuContext : INotifyPropertyChanged +{ + public IEnumerable<IContextItemViewModel> MoreCommands { get; } + + public bool HasMoreCommands { get; } + + public List<IContextItemViewModel> AllCommands { get; } + + /// <summary> + /// Generates a mapping of key -> command item for this particular item's + /// MoreCommands. (This won't include the primary Command, but it will + /// include the secondary one). This map can be used to quickly check if a + /// shortcut key was pressed + /// </summary> + /// <returns>a dictionary of KeyChord -> Context commands, for all commands + /// that have a shortcut key set.</returns> + public Dictionary<KeyChord, CommandContextItemViewModel> Keybindings() + { + var result = new Dictionary<KeyChord, CommandContextItemViewModel>(); + + var menu = MoreCommands; + if (menu is null) + { + return result; + } + + foreach (var item in menu) + { + if (item is CommandContextItemViewModel cmd && cmd.HasRequestedShortcut) + { + var key = cmd.RequestedShortcut ?? new KeyChord(0, 0, 0); + var added = result.TryAdd(key, cmd); + if (!added) + { + CoreLogger.LogWarning($"Ignoring duplicate keyboard shortcut {KeyChordHelpers.FormatForDebug(key)} on command '{cmd.Title ?? cmd.Name ?? "(unknown)"}'"); + } + } + } + + return result; + } +} + +// Represents everything the command bar needs to know about to show command +// buttons at the bottom. +// +// This is implemented by both ListItemViewModel and ContentPageViewModel, +// the two things with sub-commands. +public interface ICommandBarContext : IContextMenuContext +{ + public string SecondaryCommandName { get; } + + public CommandItemViewModel? PrimaryCommand { get; } + + public CommandItemViewModel? SecondaryCommand { get; } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs new file mode 100644 index 0000000000..7e27056c4c --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Messages/UpdateSuggestionMessage.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.ViewModels.Messages; + +public record UpdateSuggestionMessage(string TextToSuggest) +{ +} 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 new file mode 100644 index 0000000000..6e1b224ecd --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Microsoft.CmdPal.Core.ViewModels.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="..\..\CoreCommonProps.props" /> + + <PropertyGroup> + <EnableCoreMrtTooling>false</EnableCoreMrtTooling> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="CommunityToolkit.Common" /> + <PackageReference Include="CommunityToolkit.Mvvm" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> + </ItemGroup> + + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>Resources.resx</DependentUpon> + </Compile> + </ItemGroup> + +</Project> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionObject`1.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Models/ExtensionObject`1.cs similarity index 83% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionObject`1.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Models/ExtensionObject`1.cs index 822229addc..c90225f344 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionObject`1.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Models/ExtensionObject`1.cs @@ -1,8 +1,8 @@ -// 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. -namespace Microsoft.CmdPal.UI.ViewModels.Models; +namespace Microsoft.CmdPal.Core.ViewModels.Models; public class ExtensionObject<T>(T? value) // where T : IInspectable { diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.json b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.json new file mode 100644 index 0000000000..59fa7259c4 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.txt similarity index 93% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.txt index c0c94348c5..981c7446f7 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NativeMethods.txt @@ -16,4 +16,4 @@ GetMonitorInfo SHCreateStreamOnFileEx CoAllowSetForegroundWindow SHCreateStreamOnFileEx -SHLoadIndirectString \ No newline at end of file +SHLoadIndirectString diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.cs new file mode 100644 index 0000000000..504eef6af1 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/NullPageViewModel.cs @@ -0,0 +1,8 @@ +// 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.ViewModels; + +internal sealed partial class NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost) + : PageViewModel(null, scheduler, extensionHost); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs similarity index 72% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs index a082f0acd2..3b08b9266b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/PageViewModel.cs @@ -1,14 +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.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class PageViewModel : ExtensionObjectViewModel, IPageContext { @@ -31,7 +32,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext // This is set from the SearchBar [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSuggestion))] - public partial string Filter { get; set; } = string.Empty; + public partial string SearchTextBox { get; set; } = string.Empty; [ObservableProperty] public virtual partial string PlaceholderText { get; private set; } = "Type here to search..."; @@ -40,12 +41,12 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext [NotifyPropertyChangedFor(nameof(ShowSuggestion))] public virtual partial string TextToSuggest { get; protected set; } = string.Empty; - public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != Filter; + public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != SearchTextBox; [ObservableProperty] - public partial CommandPaletteHost ExtensionHost { get; private set; } + public partial AppExtensionHost ExtensionHost { get; private set; } - public bool HasStatusMessage => MostRecentStatusMessage != null; + public bool HasStatusMessage => MostRecentStatusMessage is not null; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasStatusMessage))] @@ -63,18 +64,24 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext public string Title { get => string.IsNullOrEmpty(field) ? Name : field; protected set; } = string.Empty; + public string Id { get; protected set; } = string.Empty; + // This property maps to `IPage.IsLoading`, but we want to expose our own // `IsLoading` property as a combo of this value and `IsInitialized` public bool ModelIsLoading { get; protected set; } = true; + public bool HasSearchBox { get; protected set; } = true; + + public bool HasFilters { get; protected set; } + public IconInfoViewModel Icon { get; protected set; } - public PageViewModel(IPage? model, TaskScheduler scheduler, CommandPaletteHost extensionHost) - : base((IPageContext?)null) + public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost) + : base(scheduler) { + InitializeSelfAsPageContext(); _pageModel = new(model); Scheduler = scheduler; - PageContext = new(this); ExtensionHost = extensionHost; Icon = new(null); @@ -99,7 +106,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext //// Run on background thread from ListPage.xaml.cs [RelayCommand] - private Task<bool> InitializeAsync() + internal Task<bool> InitializeAsync() { // TODO: We may want a SemaphoreSlim lock here. @@ -132,23 +139,27 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext public override void InitializeProperties() { var page = _pageModel.Unsafe; - if (page == null) + if (page is null) { return; // throw? } + Id = page.Id; Name = page.Name; ModelIsLoading = page.IsLoading; Title = page.Title; Icon = new(page.Icon); Icon.InitializeProperties(); + HasSearchBox = page is IListPage; + // Let the UI know about our initial properties too. UpdateProperty(nameof(Name)); UpdateProperty(nameof(Title)); UpdateProperty(nameof(ModelIsLoading)); UpdateProperty(nameof(IsLoading)); UpdateProperty(nameof(Icon)); + UpdateProperty(nameof(HasSearchBox)); page.PropChanged += Model_PropChanged; } @@ -166,9 +177,9 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext } } - partial void OnFilterChanged(string oldValue, string newValue) => OnFilterUpdated(newValue); + partial void OnSearchTextBoxChanged(string oldValue, string newValue) => OnSearchTextBoxUpdated(newValue); - protected virtual void OnFilterUpdated(string filter) + protected virtual void OnSearchTextBoxUpdated(string searchTextBox) { // The base page has no notion of data, so we do nothing here... // subclasses should override. @@ -177,11 +188,12 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext protected virtual void FetchProperty(string propertyName) { var model = this._pageModel.Unsafe; - if (model == null) + if (model is null) { return; // throw? } + var updateProperty = true; switch (propertyName) { case nameof(Name): @@ -198,21 +210,34 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext case nameof(Icon): this.Icon = new(model.Icon); break; + default: + updateProperty = false; + break; } - UpdateProperty(propertyName); + // GH #38829: If we always UpdateProperty here, then there's a possible + // race condition, where we raise the PropertyChanged(SearchText) + // before the subclass actually retrieves the new SearchText from the + // model. In that race situation, if the UI thread handles the + // PropertyChanged before ListViewModel fetches the SearchText, it'll + // think that the old search text is the _new_ value. + if (updateProperty) + { + UpdateProperty(propertyName); + } } public new void ShowException(Exception ex, string? extensionHint = null) { // Set the extensionHint to the Page Title (if we have one, and one not provided). // extensionHint ??= _pageModel?.Unsafe?.Title; - extensionHint ??= ExtensionHost.Extension?.ExtensionDisplayName ?? Title; + extensionHint ??= ExtensionHost.GetExtensionDisplayName() ?? Title; Task.Factory.StartNew( () => - { - ErrorMessage += $"A bug occurred in {$"the \"{extensionHint}\"" ?? "an unknown's"} extension's code:\n{ex.Message}\n{ex.Source}\n{ex.StackTrace}\n\n"; - }, + { + var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint); + ErrorMessage += message; + }, CancellationToken.None, TaskCreationOptions.None, Scheduler); @@ -227,7 +252,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext ExtensionHost.StatusMessages.CollectionChanged -= StatusMessages_CollectionChanged; var model = _pageModel.Unsafe; - if (model != null) + if (model is not null) { model.PropChanged -= Model_PropChanged; } @@ -236,7 +261,19 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext public interface IPageContext { - public void ShowException(Exception ex, string? extensionHint = null); + void ShowException(Exception ex, string? extensionHint = null); - public TaskScheduler Scheduler { get; } + TaskScheduler Scheduler { get; } +} + +public interface IPageViewModelFactoryService +{ + /// <summary> + /// Creates a new instance of the page view model for the given page type. + /// </summary> + /// <param name="page">The page for which to create the view model.</param> + /// <param name="nested">Indicates whether the page is not the top-level page.</param> + /// <param name="host">The command palette host that will host the page (for status messages)</param> + /// <returns>A new instance of the page view model.</returns> + PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProgressViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ProgressViewModel.cs similarity index 89% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProgressViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ProgressViewModel.cs index 3a2d4512ed..4ddcfb22e7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProgressViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ProgressViewModel.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class ProgressViewModel : ExtensionObjectViewModel { @@ -24,7 +24,7 @@ public partial class ProgressViewModel : ExtensionObjectViewModel public override void InitializeProperties() { var model = Model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } @@ -50,7 +50,7 @@ public partial class ProgressViewModel : ExtensionObjectViewModel protected virtual void FetchProperty(string propertyName) { var model = this.Model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Calc/Properties/Resources.Designer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.Designer.cs similarity index 55% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Calc/Properties/Resources.Designer.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.Designer.cs index 72fdb55d3b..ebfc32b862 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Calc/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.Designer.cs @@ -8,7 +8,7 @@ // </auto-generated> //------------------------------------------------------------------------------ -namespace Microsoft.CmdPal.Ext.Calc.Properties { +namespace Microsoft.CmdPal.Core.ViewModels.Properties { using System; @@ -39,7 +39,7 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties { public static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Ext.Calc.Properties.Resources", typeof(Resources).Assembly); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Core.ViewModels.Properties.Resources", typeof(Resources).Assembly); resourceMan = temp; } return resourceMan; @@ -61,65 +61,11 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties { } /// <summary> - /// Looks up a localized string similar to Copy. + /// Looks up a localized string similar to Show details. /// </summary> - public static string calculator_copy_command_name { + public static string ShowDetailsCommand { get { - return ResourceManager.GetString("calculator_copy_command_name", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Calculator. - /// </summary> - public static string calculator_display_name { - get { - return ResourceManager.GetString("calculator_display_name", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Error: {0}. - /// </summary> - public static string calculator_error { - get { - return ResourceManager.GetString("calculator_error", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Type an equation.... - /// </summary> - public static string calculator_placeholder_text { - get { - return ResourceManager.GetString("calculator_placeholder_text", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Save. - /// </summary> - public static string calculator_save_command_name { - get { - return ResourceManager.GetString("calculator_save_command_name", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Calculator. - /// </summary> - public static string calculator_title { - get { - return ResourceManager.GetString("calculator_title", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Press = to type an equation. - /// </summary> - public static string calculator_top_level_subtitle { - get { - return ResourceManager.GetString("calculator_top_level_subtitle", resourceCulture); + return ResourceManager.GetString("ShowDetailsCommand", resourceCulture); } } } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx new file mode 100644 index 0000000000..560907942b --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/Properties/Resources.resx @@ -0,0 +1,124 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="ShowDetailsCommand" xml:space="preserve"> + <value>Show details</value> + <comment>Name for the command that shows details of an item</comment> + </data> +</root> \ No newline at end of file diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs new file mode 100644 index 0000000000..93fae9beff --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.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.Diagnostics.CodeAnalysis; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Core.ViewModels; + +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +public partial class SeparatorViewModel() : + CommandItem, + IContextItemViewModel, + IFilterItemViewModel, + ISeparatorContextItem, + ISeparatorFilterItem +{ +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs new file mode 100644 index 0000000000..62c70076ad --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShellViewModel.cs @@ -0,0 +1,502 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public partial class ShellViewModel : ObservableObject, + IDisposable, + IRecipient<PerformCommandMessage>, + IRecipient<HandleCommandResultMessage> +{ + private readonly IRootPageService _rootPageService; + private readonly IAppHostService _appHostService; + private readonly TaskScheduler _scheduler; + private readonly IPageViewModelFactoryService _pageViewModelFactory; + private readonly Lock _invokeLock = new(); + private Task? _handleInvokeTask; + + // Cancellation token source for page loading/navigation operations + private CancellationTokenSource? _navigationCts; + + [ObservableProperty] + public partial bool IsLoaded { get; set; } = false; + + [ObservableProperty] + public partial DetailsViewModel? Details { get; set; } + + [ObservableProperty] + public partial bool IsDetailsVisible { get; set; } + + [ObservableProperty] + public partial bool IsSearchBoxVisible { get; set; } = true; + + private PageViewModel _currentPage; + + public PageViewModel CurrentPage + { + get => _currentPage; + set + { + var oldValue = _currentPage; + if (SetProperty(ref _currentPage, value)) + { + oldValue.PropertyChanged -= CurrentPage_PropertyChanged; + value.PropertyChanged += CurrentPage_PropertyChanged; + + if (oldValue is IDisposable disposable) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + CoreLogger.LogError(ex.ToString()); + } + } + } + } + } + + private void CurrentPage_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(PageViewModel.HasSearchBox)) + { + IsSearchBoxVisible = CurrentPage.HasSearchBox; + } + } + + private IPage? _rootPage; + + private bool _isNested; + + public bool IsNested => _isNested; + + public PageViewModel NullPage { get; private set; } + + public ShellViewModel( + TaskScheduler scheduler, + IRootPageService rootPageService, + IPageViewModelFactoryService pageViewModelFactory, + IAppHostService appHostService) + { + _pageViewModelFactory = pageViewModelFactory; + _scheduler = scheduler; + _rootPageService = rootPageService; + _appHostService = appHostService; + + NullPage = new NullPageViewModel(_scheduler, appHostService.GetDefaultHost()); + _currentPage = new LoadingPageViewModel(null, _scheduler, appHostService.GetDefaultHost()); + + // Register to receive messages + WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this); + WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this); + } + + [RelayCommand] + public async Task<bool> LoadAsync() + { + // First, do any loading that the root page service needs to do before we can + // display the root page. For example, this might include loading + // the built-in commands, or loading the settings. + await _rootPageService.PreLoadAsync(); + + IsLoaded = true; + + // Now that the basics are set up, we can load the root page. + _rootPage = _rootPageService.GetRootPage(); + + // This sends a message to us to load the root page view model. + WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_rootPage))); + + // Now that the root page is loaded, do any post-load work that the root page service needs to do. + // This runs asynchronously, on a background thread. + // This might include starting extensions, for example. + // Note: We don't await this, so that we can return immediately. + // This is important because we don't want to block the UI thread. + _ = Task.Run(async () => + { + await _rootPageService.PostLoadRootPageAsync(); + }); + + return true; + } + + private async Task LoadPageViewModelAsync(PageViewModel viewModel, CancellationToken cancellationToken = default) + { + // Note: We removed the general loading state, extensions sometimes use their `IsLoading`, but it's inconsistently implemented it seems. + // IsInitialized is our main indicator of the general overall state of loading props/items from a page we use for the progress bar + // This triggers that load generally with the InitializeCommand asynchronously when we navigate to a page. + // We could re-track the page loading status, if we need it more granularly below again, but between the initialized and error message, we can infer some status. + // We could also maybe move this thread offloading we do for loading into PageViewModel and better communicate between the two... a few different options. + + ////LoadedState = ViewModelLoadedState.Loading; + if (!viewModel.IsInitialized + && viewModel.InitializeCommand is not null) + { + var outer = Task.Run( + async () => + { + // You know, this creates the situation where we wait for + // both loading page properties, AND the items, before we + // display anything. + // + // We almost need to do an async await on initialize, then + // just a fire-and-forget on FetchItems. + // RE: We do set the CurrentPage in ShellPage.xaml.cs as well, so, we kind of are doing two different things here. + // Definitely some more clean-up to do, but at least its centralized to one spot now. + viewModel.InitializeCommand.Execute(null); + + await viewModel.InitializeCommand.ExecutionTask!; + + if (viewModel.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion) + { + if (viewModel.InitializeCommand.ExecutionTask.Exception is AggregateException ex) + { + CoreLogger.LogError(ex.ToString()); + } + } + else + { + var t = Task.Factory.StartNew( + () => + { + if (cancellationToken.IsCancellationRequested) + { + if (viewModel is IDisposable disposable) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + CoreLogger.LogError(ex.ToString()); + } + } + + return; + } + + CurrentPage = viewModel; + }, + cancellationToken, + TaskCreationOptions.None, + _scheduler); + await t; + } + }, + cancellationToken); + await outer; + } + else + { + if (cancellationToken.IsCancellationRequested) + { + if (viewModel is IDisposable disposable) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + CoreLogger.LogError(ex.ToString()); + } + } + + return; + } + + CurrentPage = viewModel; + } + } + + public void Receive(PerformCommandMessage message) + { + PerformCommand(message); + } + + private void PerformCommand(PerformCommandMessage message) + { + // Create/replace the navigation cancellation token. + // If one already exists, cancel and dispose it first. + var newCts = new CancellationTokenSource(); + var oldCts = Interlocked.Exchange(ref _navigationCts, newCts); + if (oldCts is not null) + { + try + { + oldCts.Cancel(); + } + catch (Exception ex) + { + CoreLogger.LogError(ex.ToString()); + } + finally + { + oldCts.Dispose(); + } + } + + var navigationToken = newCts.Token; + + var command = message.Command.Unsafe; + if (command is null) + { + return; + } + + var host = _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost); + + _rootPageService.OnPerformCommand(message.Context, !CurrentPage.IsNested, host); + + try + { + if (command is IPage page) + { + CoreLogger.LogDebug($"Navigating to page"); + + var isMainPage = command == _rootPage; + _isNested = !isMainPage; + + // Telemetry: Track extension page navigation for session metrics + if (host is not null) + { + string extensionId = host.GetExtensionDisplayName() ?? "builtin"; + string commandId = command?.Id ?? "unknown"; + string commandName = command?.Name ?? "unknown"; + WeakReferenceMessenger.Default.Send<TelemetryExtensionInvokedMessage>( + new(extensionId, commandId, commandName, true, 0)); + } + + // Construct our ViewModel of the appropriate type and pass it the UI Thread context. + var pageViewModel = _pageViewModelFactory.TryCreatePageViewModel(page, _isNested, host!); + if (pageViewModel is null) + { + CoreLogger.LogError($"Failed to create ViewModel for page {page.GetType().Name}"); + throw new NotSupportedException(); + } + + // Clear command bar, ViewModel initialization can already set new commands if it wants to + OnUIThread(() => WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null))); + + // Kick off async loading of our ViewModel + LoadPageViewModelAsync(pageViewModel, navigationToken) + .ContinueWith( + (Task t) => + { + // clean up the navigation token if it's still ours + if (Interlocked.CompareExchange(ref _navigationCts, null, newCts) == newCts) + { + newCts.Dispose(); + } + }, + navigationToken, + TaskContinuationOptions.None, + _scheduler); + + // While we're loading in the background, immediately move to the next page. + WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation, navigationToken)); + + // Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above + // See RootFrame_Navigated event handler. + } + else if (command is IInvokableCommand invokable) + { + CoreLogger.LogDebug($"Invoking command"); + + WeakReferenceMessenger.Default.Send<TelemetryBeginInvokeMessage>(); + StartInvoke(message, invokable, host); + } + } + catch (Exception ex) + { + // TODO: It would be better to do this as a page exception, rather + // than a silent log message. + host?.Log(ex.Message); + } + } + + private void StartInvoke(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host) + { + // TODO GH #525 This needs more better locking. + lock (_invokeLock) + { + if (_handleInvokeTask is not null) + { + // do nothing - a command is already doing a thing + } + else + { + _handleInvokeTask = Task.Run(() => + { + SafeHandleInvokeCommandSynchronous(message, invokable, host); + }); + } + } + } + + private void SafeHandleInvokeCommandSynchronous(PerformCommandMessage message, IInvokableCommand invokable, AppExtensionHost? host) + { + // Telemetry: Track command execution time and success + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var command = message.Command.Unsafe; + string extensionId = host?.GetExtensionDisplayName() ?? "builtin"; + string commandId = command?.Id ?? "unknown"; + string commandName = command?.Name ?? "unknown"; + bool success = false; + + try + { + // Call out to extension process. + // * May fail! + // * May never return! + var result = invokable.Invoke(message.Context); + + // But if it did succeed, we need to handle the result. + UnsafeHandleCommandResult(result); + + success = true; + _handleInvokeTask = null; + } + catch (Exception ex) + { + success = false; + _handleInvokeTask = null; + + // Telemetry: Track errors for session metrics + WeakReferenceMessenger.Default.Send<ErrorOccurredMessage>(new()); + + // TODO: It would be better to do this as a page exception, rather + // than a silent log message. + host?.Log(ex.Message); + } + finally + { + // Telemetry: Send extension invocation metrics (always sent, even on failure) + stopwatch.Stop(); + WeakReferenceMessenger.Default.Send<TelemetryExtensionInvokedMessage>( + new(extensionId, commandId, commandName, success, (ulong)stopwatch.ElapsedMilliseconds)); + } + } + + private void UnsafeHandleCommandResult(ICommandResult? result) + { + if (result is null) + { + // No result, nothing to do. + return; + } + + var kind = result.Kind; + CoreLogger.LogDebug($"handling {kind.ToString()}"); + + WeakReferenceMessenger.Default.Send<TelemetryInvokeResultMessage>(new(kind)); + switch (kind) + { + case CommandResultKind.Dismiss: + { + // Reset the palette to the main page and dismiss + GoHome(withAnimation: false, focusSearch: false); + WeakReferenceMessenger.Default.Send(new DismissMessage()); + break; + } + + case CommandResultKind.GoHome: + { + // Go back to the main page, but keep it open + GoHome(); + break; + } + + case CommandResultKind.GoBack: + { + GoBack(); + break; + } + + case CommandResultKind.Hide: + { + // Keep this page open, but hide the palette. + WeakReferenceMessenger.Default.Send(new DismissMessage()); + break; + } + + case CommandResultKind.KeepOpen: + { + // Do nothing. + break; + } + + case CommandResultKind.Confirm: + { + if (result.Args is IConfirmationArgs a) + { + WeakReferenceMessenger.Default.Send<ShowConfirmationMessage>(new(a)); + } + + break; + } + + case CommandResultKind.ShowToast: + { + if (result.Args is IToastArgs a) + { + WeakReferenceMessenger.Default.Send<ShowToastMessage>(new(a.Message)); + UnsafeHandleCommandResult(a.Result); + } + + break; + } + } + } + + public void GoHome(bool withAnimation = true, bool focusSearch = true) + { + _rootPageService.GoHome(); + WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(withAnimation, focusSearch)); + } + + public void GoBack(bool withAnimation = true, bool focusSearch = true) + { + WeakReferenceMessenger.Default.Send<GoBackMessage>(new(withAnimation, focusSearch)); + } + + public void Receive(HandleCommandResultMessage message) + { + UnsafeHandleCommandResult(message.Result.Unsafe); + } + + private void OnUIThread(Action action) + { + _ = Task.Factory.StartNew( + action, + CancellationToken.None, + TaskCreationOptions.None, + _scheduler); + } + + public void CancelNavigation() + { + _navigationCts?.Cancel(); + } + + public void Dispose() + { + _handleInvokeTask?.Dispose(); + _navigationCts?.Dispose(); + + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShowDetailsCommand.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShowDetailsCommand.cs new file mode 100644 index 0000000000..64dc436119 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ShowDetailsCommand.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 CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Core.ViewModels.Commands; + +public sealed partial class ShowDetailsCommand : InvokableCommand +{ + public static string ShowDetailsCommandId { get; } = "com.microsoft.cmdpal.showDetails"; + + private static IconInfo IconInfo { get; } = new IconInfo("\uF000"); // KnowledgeArticle Icon + + private DetailsViewModel Details { get; set; } + + public ShowDetailsCommand(DetailsViewModel details) + { + Id = ShowDetailsCommandId; + Name = Properties.Resources.ShowDetailsCommand; + Icon = IconInfo; + Details = details; + } + + public override CommandResult Invoke() + { + // Send the ShowDetailsMessage when the action is invoked + WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(Details)); + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SmallGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SmallGridPropertiesViewModel.cs new file mode 100644 index 0000000000..3cc51d780e --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SmallGridPropertiesViewModel.cs @@ -0,0 +1,26 @@ +// 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.ViewModels.Models; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Core.ViewModels; + +public class SmallGridPropertiesViewModel : IGridPropertiesViewModel +{ + private readonly ExtensionObject<ISmallGridLayout> _model; + + public bool ShowTitle => false; + + public bool ShowSubtitle => false; + + public SmallGridPropertiesViewModel(ISmallGridLayout smallGridLayout) + { + _model = new(smallGridLayout); + } + + public void InitializeProperties() + { + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/StatusMessageViewModel.cs similarity index 80% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/StatusMessageViewModel.cs index 32328b0eb1..2c78ff407e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/StatusMessageViewModel.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class StatusMessageViewModel : ExtensionObjectViewModel { @@ -15,14 +15,10 @@ public partial class StatusMessageViewModel : ExtensionObjectViewModel public MessageState State { get; private set; } = MessageState.Info; - public string ExtensionPfn { get; set; } = string.Empty; - public ProgressViewModel? Progress { get; private set; } - public bool HasProgress => Progress != null; + public bool HasProgress => Progress is not null; - // public bool IsIndeterminate => Progress != null && Progress.IsIndeterminate; - // public double ProgressValue => (Progress?.ProgressPercent ?? 0) / 100.0; public StatusMessageViewModel(IStatusMessage message, WeakReference<IPageContext> context) : base(context) { @@ -32,7 +28,7 @@ public partial class StatusMessageViewModel : ExtensionObjectViewModel public override void InitializeProperties() { var model = Model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } @@ -40,7 +36,7 @@ public partial class StatusMessageViewModel : ExtensionObjectViewModel Message = model.Message; State = model.State; var modelProgress = model.Progress; - if (modelProgress != null) + if (modelProgress is not null) { Progress = new(modelProgress, this.PageContext); Progress.InitializeProperties(); @@ -65,7 +61,7 @@ public partial class StatusMessageViewModel : ExtensionObjectViewModel protected virtual void FetchProperty(string propertyName) { var model = this.Model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } @@ -80,7 +76,7 @@ public partial class StatusMessageViewModel : ExtensionObjectViewModel break; case nameof(Progress): var modelProgress = model.Progress; - if (modelProgress != null) + if (modelProgress is not null) { Progress = new(modelProgress, this.PageContext); Progress.InitializeProperties(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TagViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/TagViewModel.cs similarity index 90% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TagViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/TagViewModel.cs index 414f1882a5..5287cf441c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TagViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/TagViewModel.cs @@ -1,11 +1,11 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class TagViewModel(ITag _tag, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context) { @@ -28,7 +28,7 @@ public partial class TagViewModel(ITag _tag, WeakReference<IPageContext> context public override void InitializeProperties() { var model = _tagModel.Unsafe; - if (model == null) + if (model is null) { return; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ToastViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ToastViewModel.cs similarity index 81% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ToastViewModel.cs rename to src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ToastViewModel.cs index 156a47f557..719d7edd5d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ToastViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ToastViewModel.cs @@ -4,9 +4,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels.Messages; -namespace Microsoft.CmdPal.UI.ViewModels; +namespace Microsoft.CmdPal.Core.ViewModels; public partial class ToastViewModel : ObservableObject { diff --git a/src/modules/cmdpal/CoreCommonProps.props b/src/modules/cmdpal/CoreCommonProps.props new file mode 100644 index 0000000000..51d502a65d --- /dev/null +++ b/src/modules/cmdpal/CoreCommonProps.props @@ -0,0 +1,52 @@ +<Project> + <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="..\..\Common.Dotnet.AotCompatibility.props" /> + <PropertyGroup> + <Nullable>enable</Nullable> + <!-- For MVVM Toolkit Partial Properties/AOT support --> + <LangVersion>preview</LangVersion> + + <OutputPath>..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> + <ProjectPriFileName>$(RootNamespace).pri</ProjectPriFileName> + + <!-- Disable SA1313 for Primary Constructor fields conflict https://learn.microsoft.com/dotnet/csharp/programming-guide/classes-and-structs/instance-constructors#primary-constructors --> + <NoWarn>SA1313;</NoWarn> + + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + + <PropertyGroup> + <CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Windows.CsWin32"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="Microsoft.Web.WebView2" /> + <!-- This line forces the WebView2 version used by Windows App SDK to be the one we expect from Directory.Packages.props . --> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="$(MSBuildThisFileDirectory)\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <Generator>PublicResXFileCodeGenerator</Generator> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + </EmbeddedResource> + </ItemGroup> + + <ItemGroup> + <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute"> + <_Parameter1>$(AssemblyName).UnitTests</_Parameter1> + </AssemblyAttribute> + </ItemGroup> + +</Project> diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props index 782ec68bf5..4722a0974e 100644 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props +++ b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/Directory.Packages.props @@ -1,17 +1,18 @@ -<Project> +<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <ItemGroup> - <PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.1.0" /> + <PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" /> <PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" /> <PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" /> - <PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.2.46-beta" /> + <PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" /> <PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" /> - <PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.22621.2428" /> - <PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.6.250205002" /> + <PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" /> + <PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" /> + <PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.250907003" /> <PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" /> - <PackageVersion Include="System.Text.Json" Version="9.0.3" /> + <PackageVersion Include="System.Text.Json" Version="9.0.8" /> </ItemGroup> </Project> diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/Program.cs b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/Program.cs index 200ac6e71e..e6d9c694ce 100644 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/Program.cs +++ b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/Program.cs @@ -14,11 +14,12 @@ namespace TemplateCmdPalExtension; public class Program { [MTAThread] - public static async Task Main(string[] args) + public static void Main(string[] args) { if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") { - await using global::Shmuelie.WinRTServer.ComServer server = new(); + global::Shmuelie.WinRTServer.ComServer server = new(); + ManualResetEvent extensionDisposedEvent = new(false); // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called. @@ -31,6 +32,8 @@ public class Program // This will make the main thread wait until the event is signalled by the extension class. // Since we have single instance of the extension object, we exit as soon as it is disposed. extensionDisposedEvent.WaitOne(); + server.Stop(); + server.UnsafeDispose(); } else { diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj index 72e6d7400b..7d83967705 100644 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj +++ b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/TemplateCmdPalExtension.csproj @@ -1,11 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>WinExe</OutputType> <RootNamespace>TemplateCmdPalExtension</RootNamespace> <ApplicationManifest>app.manifest</ApplicationManifest> - <WindowsSdkPackageVersion>10.0.22621.57</WindowsSdkPackageVersion> - <TargetFramework>net9.0-windows10.0.22621.0</TargetFramework> + <WindowsSdkPackageVersion>10.0.26100.68-preview</WindowsSdkPackageVersion> + <TargetFramework>net9.0-windows10.0.26100.0</TargetFramework> <TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion> <SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion> <RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers> @@ -40,10 +40,13 @@ <ItemGroup> <PackageReference Include="Microsoft.CommandPalette.Extensions" /> <PackageReference Include="Microsoft.Windows.CsWinRT" /> - <PackageReference Include="Microsoft.WindowsAppSDK" /> - <PackageReference Include="Microsoft.Web.WebView2" /> - <PackageReference Include="System.Text.Json" /> <PackageReference Include="Shmuelie.WinRTServer" /> + + <!-- Needed to enable building an MSIX package --> + <PackageReference Include="Microsoft.Windows.SDK.BuildTools.MSIX"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> </ItemGroup> <!-- @@ -56,13 +59,38 @@ </PropertyGroup> <PropertyGroup> + <PublishSingleFile>true</PublishSingleFile> <IsAotCompatible>true</IsAotCompatible> + <CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled> <CsWinRTAotWarningLevel>2</CsWinRTAotWarningLevel> <!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection --> - <WarningsNotAsErrors>IL2081</WarningsNotAsErrors> + <WarningsNotAsErrors>IL2081;$(WarningsNotAsErrors)</WarningsNotAsErrors> + + <!-- When publishing trimmed, make sure to treat trimming warnings as build errors --> + <ILLinkTreatWarningsAsErrors>true</ILLinkTreatWarningsAsErrors> - <PublishTrimmed>true</PublishTrimmed> - <PublishSingleFile>true</PublishSingleFile> </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)'=='Debug'"> + <!-- In Debug builds, trimming is disabled by default, but all the trim & + AOT warnings are enabled. This gives debug builds a tighter inner loop, + while at least warning about future trim violations --> + <PublishTrimmed>false</PublishTrimmed> + + <EnableTrimAnalyzer>true</EnableTrimAnalyzer> + <EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer> + <EnableAotAnalyzer>true</EnableAotAnalyzer> + </PropertyGroup> + + <PropertyGroup Condition="'$(Configuration)'!='Debug'"> + <!-- In Release builds, trimming is enabled by default. + feel free to disable this if needed --> + <PublishTrimmed>true</PublishTrimmed> + + <!-- In release, also ignore the aforementioned ILLink warning --> + <ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors> + </PropertyGroup> + + </Project> diff --git a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/app.manifest b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/app.manifest index 930a1b5cfa..88d3cd6aaf 100644 --- a/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/app.manifest +++ b/src/modules/cmdpal/ExtensionTemplate/TemplateCmdPalExtension/TemplateCmdPalExtension/app.manifest @@ -13,6 +13,7 @@ <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs deleted file mode 100644 index be245bb48a..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Linq; -using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Apps; - -public partial class AllAppsCommandProvider : CommandProvider -{ - public static readonly AllAppsPage Page = new(); - - private readonly CommandItem _listItem; - - public AllAppsCommandProvider() - { - Id = "AllApps"; - DisplayName = Resources.installed_apps; - Icon = IconHelpers.FromRelativePath("Assets\\AllApps.svg"); - Settings = AllAppsSettings.Instance.Settings; - - _listItem = new(Page) - { - Subtitle = Resources.search_installed_apps, - MoreCommands = [new CommandContextItem(AllAppsSettings.Instance.Settings.SettingsPage)], - }; - } - - public override ICommandItem[] TopLevelCommands() => [_listItem]; - - public ICommandItem? LookupApp(string displayName) - { - var items = Page.GetItems(); - - // We're going to do this search in two directions: - // First, is this name a substring of any app... - var nameMatches = items.Where(i => i.Title.Contains(displayName)); - - // ... Then, does any app have this name as a substring ... - // Only get one of these - "Terminal Preview" contains both "Terminal" and "Terminal Preview", so just take the best one - var appMatches = items.Where(i => displayName.Contains(i.Title)).OrderByDescending(i => i.Title.Length).Take(1); - - // ... Now, combine those two - var both = nameMatches.Concat(appMatches); - - if (both.Count() == 1) - { - return both.First(); - } - else if (nameMatches.Count() == 1 && appMatches.Count() == 1) - { - if (nameMatches.First() == appMatches.First()) - { - return nameMatches.First(); - } - } - - return null; - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs deleted file mode 100644 index ed724794fd..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs +++ /dev/null @@ -1,119 +0,0 @@ -// 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.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Apps.Programs; -using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Apps; - -public sealed partial class AllAppsPage : ListPage -{ - private readonly Lock _listLock = new(); - private AppListItem[] allAppsSection = []; - - public AllAppsPage() - { - this.Name = Resources.all_apps; - this.Icon = IconHelpers.FromRelativePath("Assets\\AllApps.svg"); - this.ShowDetails = true; - this.IsLoading = true; - this.PlaceholderText = Resources.search_installed_apps_placeholder; - - Task.Run(() => - { - lock (_listLock) - { - BuildListItems(); - } - }); - } - - public override IListItem[] GetItems() - { - if (allAppsSection.Length == 0 || AppCache.Instance.Value.ShouldReload()) - { - lock (_listLock) - { - BuildListItems(); - } - } - - return allAppsSection; - } - - private void BuildListItems() - { - this.IsLoading = true; - - Stopwatch stopwatch = new(); - stopwatch.Start(); - - List<AppItem> apps = GetPrograms(); - - this.allAppsSection = apps - .Select((app) => new AppListItem(app, true)) - .ToArray(); - - this.IsLoading = false; - - AppCache.Instance.Value.ResetReloadFlag(); - - stopwatch.Stop(); - Logger.LogTrace($"{nameof(AllAppsPage)}.{nameof(BuildListItems)} took: {stopwatch.ElapsedMilliseconds} ms"); - } - - internal List<AppItem> GetPrograms() - { - IEnumerable<AppItem> uwpResults = AppCache.Instance.Value.UWPs - .Where((application) => application.Enabled) - .Select(app => - new AppItem() - { - Name = app.Name, - Subtitle = app.Description, - Type = UWPApplication.Type(), - IcoPath = app.LogoType != LogoType.Error ? app.LogoPath : string.Empty, - DirPath = app.Location, - UserModelId = app.UserModelId, - IsPackaged = true, - Commands = app.GetCommands(), - }); - - IEnumerable<AppItem> win32Results = AppCache.Instance.Value.Win32s - .Where((application) => application.Enabled && application.Valid) - .Select(app => - { - string icoPath = string.IsNullOrEmpty(app.IcoPath) ? - (app.AppType == Win32Program.ApplicationType.InternetShortcutApplication ? - app.IcoPath : - app.FullPath) : - app.IcoPath; - - // icoPath = icoPath.EndsWith(".lnk", System.StringComparison.InvariantCultureIgnoreCase) ? (icoPath + ",0") : icoPath; - icoPath = icoPath.EndsWith(".lnk", System.StringComparison.InvariantCultureIgnoreCase) ? - app.FullPath : - icoPath; - return new AppItem() - { - Name = app.Name, - Subtitle = app.Description, - Type = app.Type(), - IcoPath = icoPath, - ExePath = !string.IsNullOrEmpty(app.LnkFilePath) ? app.LnkFilePath : app.FullPath, - DirPath = app.Location, - Commands = app.GetCommands(), - }; - }); - - return uwpResults.Concat(win32Results).OrderBy(app => app.Name).ToList(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AppListItem.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AppListItem.cs deleted file mode 100644 index 1ab8df76ef..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AppListItem.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Storage.Streams; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -internal sealed partial class AppListItem : ListItem -{ - private readonly AppItem _app; - private static readonly Tag _appTag = new("App"); - - private readonly Lazy<Details> _details; - private readonly Lazy<IconInfo> _icon; - - public override IDetails? Details { get => _details.Value; set => base.Details = value; } - - public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } - - public AppListItem(AppItem app, bool useThumbnails) - : base(new AppCommand(app)) - { - _app = app; - Title = app.Name; - Subtitle = app.Subtitle; - Tags = [_appTag]; - MoreCommands = _app.Commands!.ToArray(); - - _details = new Lazy<Details>(() => BuildDetails()); - _icon = new Lazy<IconInfo>(() => - { - var t = FetchIcon(useThumbnails); - t.Wait(); - return t.Result; - }); - } - - private Details BuildDetails() - { - var metadata = new List<DetailsElement>(); - metadata.Add(new DetailsElement() { Key = "Type", Data = new DetailsTags() { Tags = [new Tag(_app.Type)] } }); - if (!_app.IsPackaged) - { - metadata.Add(new DetailsElement() { Key = "Path", Data = new DetailsLink() { Text = _app.ExePath } }); - } - - return new Details() - { - Title = this.Title, - HeroImage = this.Icon ?? new IconInfo(string.Empty), - Metadata = metadata.ToArray(), - }; - } - - public async Task<IconInfo> FetchIcon(bool useThumbnails) - { - IconInfo? icon = null; - if (_app.IsPackaged) - { - icon = new IconInfo(_app.IcoPath); - if (_details.IsValueCreated) - { - _details.Value.HeroImage = icon; - } - - return icon; - } - - if (useThumbnails) - { - try - { - var stream = await ThumbnailHelper.GetThumbnail(_app.ExePath); - if (stream != null) - { - var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); - icon = new IconInfo(data, data); - } - } - catch - { - } - - icon = icon ?? new IconInfo(_app.IcoPath); - } - else - { - icon = new IconInfo(_app.IcoPath); - } - - if (_details.IsValueCreated) - { - _details.Value.HeroImage = icon; - } - - return icon; - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/AppxFactory.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/AppxFactory.cs deleted file mode 100644 index 420128ef29..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/AppxFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -// Reference : https://stackoverflow.com/questions/32122679/getting-icon-of-modern-windows-app-from-a-desktop-application -[Guid("5842a140-ff9f-4166-8f5c-62f5b7b0c781")] -[ComImport] -public class AppxFactory -{ -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/AppxPackageHelper.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/AppxPackageHelper.cs deleted file mode 100644 index 83a9fbb146..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/AppxPackageHelper.cs +++ /dev/null @@ -1,45 +0,0 @@ -// 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.Runtime.InteropServices; -using Windows.Win32.System.Com; -using static Microsoft.CmdPal.Ext.Apps.Utils.Native; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -public static class AppxPackageHelper -{ - private static readonly IAppxFactory AppxFactory = (IAppxFactory)new AppxFactory(); - - // This function returns a list of attributes of applications - internal static IEnumerable<IAppxManifestApplication> GetAppsFromManifest(IStream stream) - { - var reader = AppxFactory.CreateManifestReader(stream); - var manifestApps = reader.GetApplications(); - - while (manifestApps.GetHasCurrent()) - { - var manifestApp = manifestApps.GetCurrent(); - var hr = manifestApp.GetStringValue("AppListEntry", out var appListEntry); - _ = CheckHRAndReturnOrThrow(hr, appListEntry); - if (appListEntry != "none") - { - yield return manifestApp; - } - - manifestApps.MoveNext(); - } - } - - internal static T CheckHRAndReturnOrThrow<T>(HRESULT hr, T result) - { - if (hr != HRESULT.S_OK) - { - Marshal.ThrowExceptionForHR((int)hr); - } - - return result; - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IApplicationActivationManager.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IApplicationActivationManager.cs deleted file mode 100644 index 32fb3f2890..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IApplicationActivationManager.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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.CompilerServices; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -// Reference : https://github.com/MicrosoftEdge/edge-launcher/blob/108e63df0b4cb5cd9d5e45aa7a264690851ec51d/MIcrosoftEdgeLauncherCsharp/Program.cs -[Flags] -public enum ActivateOptions -{ - None = 0x00000000, - DesignMode = 0x00000001, - NoErrorUI = 0x00000002, - NoSplashScreen = 0x00000004, -} - -// ApplicationActivationManager -[ComImport] -[Guid("2e941141-7f97-4756-ba1d-9decde894a3d")] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -public interface IApplicationActivationManager -{ - IntPtr ActivateApplication([In] string appUserModelId, [In] string arguments, [In] ActivateOptions options, [Out] out uint processId); - - IntPtr ActivateForFile([In] string appUserModelId, [In] IntPtr /*IShellItemArray* */ itemArray, [In] string verb, [Out] out uint processId); - - IntPtr ActivateForProtocol([In] string appUserModelId, [In] IntPtr /* IShellItemArray* */itemArray, [Out] out uint processId); -} - -// Application Activation Manager Class -[ComImport] -[Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] -public class ApplicationActivationManager : IApplicationActivationManager -{ - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)/*, PreserveSig*/] - public extern IntPtr ActivateApplication([In] string appUserModelId, [In] string arguments, [In] ActivateOptions options, [Out] out uint processId); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public extern IntPtr ActivateForFile([In] string appUserModelId, [In] IntPtr /*IShellItemArray* */ itemArray, [In] string verb, [Out] out uint processId); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public extern IntPtr ActivateForProtocol([In] string appUserModelId, [In] IntPtr /* IShellItemArray* */itemArray, [Out] out uint processId); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxFactory.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxFactory.cs deleted file mode 100644 index 7af82b74ab..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxFactory.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Runtime.InteropServices; -using Windows.Win32.System.Com; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -[Guid("BEB94909-E451-438B-B5A7-D79E767B75D8")] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -public interface IAppxFactory -{ - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Implements COM Interface")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Implements COM Interface")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Implements COM Interface")] - void _VtblGap0_2(); // skip 2 methods - - internal IAppxManifestReader CreateManifestReader(IStream inputStream); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestApplication.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestApplication.cs deleted file mode 100644 index 1ca12d3c29..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestApplication.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Runtime.InteropServices; -using static Microsoft.CmdPal.Ext.Apps.Utils.Native; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -[Guid("5DA89BF4-3773-46BE-B650-7E744863B7E8")] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -public interface IAppxManifestApplication -{ - [PreserveSig] - HRESULT GetStringValue([MarshalAs(UnmanagedType.LPWStr)] string name, [MarshalAs(UnmanagedType.LPWStr)] out string value); - - [PreserveSig] - HRESULT GetAppUserModelId([MarshalAs(UnmanagedType.LPWStr)] out string value); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestApplicationsEnumerator.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestApplicationsEnumerator.cs deleted file mode 100644 index f7152a0813..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestApplicationsEnumerator.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -[Guid("9EB8A55A-F04B-4D0D-808D-686185D4847A")] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -public interface IAppxManifestApplicationsEnumerator -{ - IAppxManifestApplication GetCurrent(); - - bool GetHasCurrent(); - - bool MoveNext(); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestProperties.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestProperties.cs deleted file mode 100644 index 4c61e6f069..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestProperties.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Runtime.InteropServices; -using static Microsoft.CmdPal.Ext.Apps.Utils.Native; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -[Guid("03FAF64D-F26F-4B2C-AAF7-8FE7789B8BCA")] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -public interface IAppxManifestProperties -{ - [PreserveSig] - HRESULT GetBoolValue([MarshalAs(UnmanagedType.LPWStr)] string name, out bool value); - - [PreserveSig] - HRESULT GetStringValue([MarshalAs(UnmanagedType.LPWStr)] string name, [MarshalAs(UnmanagedType.LPWStr)] out string value); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestReader.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestReader.cs deleted file mode 100644 index 20c7fb62f6..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IAppxManifestReader.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -[Guid("4E1BD148-55A0-4480-A3D1-15544710637C")] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -public interface IAppxManifestReader -{ - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Implements COM Interface")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Implements COM Interface")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Implements COM Interface")] - void _VtblGap0_1(); // skip 1 method - - IAppxManifestProperties GetProperties(); - - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Implements COM Interface")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Implements COM Interface")] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Implements COM Interface")] - void _VtblGap1_5(); // skip 5 methods - - IAppxManifestApplicationsEnumerator GetApplications(); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs deleted file mode 100644 index 6a4cf94480..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs +++ /dev/null @@ -1,608 +0,0 @@ -// 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.IO.Abstractions; -using System.Linq; -using System.Runtime.InteropServices; -using System.Text; -using System.Xml; -using Microsoft.CmdPal.Ext.Apps.Commands; -using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CmdPal.Ext.Apps.Utils; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using static Microsoft.CmdPal.Ext.Apps.Utils.Native; -using PackageVersion = Microsoft.CmdPal.Ext.Apps.Programs.UWP.PackageVersion; - -namespace Microsoft.CmdPal.Ext.Apps.Programs; - -[Serializable] -public class UWPApplication : IProgram -{ - private static readonly IFileSystem FileSystem = new FileSystem(); - private static readonly IPath Path = FileSystem.Path; - private static readonly IFile File = FileSystem.File; - - public string AppListEntry { get; set; } = string.Empty; - - public string UniqueIdentifier { get; set; } - - public string DisplayName { get; set; } - - public string Description { get; set; } - - public string UserModelId { get; set; } - - public string BackgroundColor { get; set; } - - public string EntryPoint { get; set; } - - public string Name => DisplayName; - - public string Location => Package.Location; - - // Localized path based on windows display language - public string LocationLocalized => Package.LocationLocalized; - - public bool Enabled { get; set; } - - public bool CanRunElevated { get; set; } - - public string LogoPath { get; set; } = string.Empty; - - public LogoType LogoType { get; set; } - - public UWP Package { get; set; } - - private string logoUri; - - private const string ContrastWhite = "contrast-white"; - - private const string ContrastBlack = "contrast-black"; - - // Function to set the subtitle based on the Type of application - public static string Type() - { - return Resources.packaged_application; - } - - public List<CommandContextItem> GetCommands() - { - List<CommandContextItem> commands = new List<CommandContextItem>(); - - if (CanRunElevated) - { - commands.Add( - new CommandContextItem( - new RunAsAdminCommand(UniqueIdentifier, string.Empty, true))); - - // We don't add context menu to 'run as different user', because UWP applications normally installed per user and not for all users. - } - - commands.Add( - new CommandContextItem( - new OpenPathCommand(Location) - { - Name = Resources.open_containing_folder, - Icon = new("\ue838"), - })); - - commands.Add( - new CommandContextItem( - new OpenInConsoleCommand(Package.Location))); - - return commands; - } - - public UWPApplication(IAppxManifestApplication manifestApp, UWP package) - { - ArgumentNullException.ThrowIfNull(manifestApp); - - var hr = manifestApp.GetAppUserModelId(out var tmpUserModelId); - UserModelId = AppxPackageHelper.CheckHRAndReturnOrThrow(hr, tmpUserModelId); - - hr = manifestApp.GetAppUserModelId(out var tmpUniqueIdentifier); - UniqueIdentifier = AppxPackageHelper.CheckHRAndReturnOrThrow(hr, tmpUniqueIdentifier); - - hr = manifestApp.GetStringValue("DisplayName", out var tmpDisplayName); - DisplayName = AppxPackageHelper.CheckHRAndReturnOrThrow(hr, tmpDisplayName); - - hr = manifestApp.GetStringValue("Description", out var tmpDescription); - Description = AppxPackageHelper.CheckHRAndReturnOrThrow(hr, tmpDescription); - - hr = manifestApp.GetStringValue("BackgroundColor", out var tmpBackgroundColor); - BackgroundColor = AppxPackageHelper.CheckHRAndReturnOrThrow(hr, tmpBackgroundColor); - - hr = manifestApp.GetStringValue("EntryPoint", out var tmpEntryPoint); - EntryPoint = AppxPackageHelper.CheckHRAndReturnOrThrow(hr, tmpEntryPoint); - - Package = package ?? throw new ArgumentNullException(nameof(package)); - - DisplayName = ResourceFromPri(package.FullName, DisplayName); - Description = ResourceFromPri(package.FullName, Description); - logoUri = LogoUriFromManifest(manifestApp); - - Enabled = true; - CanRunElevated = IfApplicationCanRunElevated(); - } - - private bool IfApplicationCanRunElevated() - { - if (EntryPoint == "Windows.FullTrustApplication") - { - return true; - } - else - { - var manifest = Package.Location + "\\AppxManifest.xml"; - if (File.Exists(manifest)) - { - try - { - // Check the manifest to verify if the Trust Level for the application is "mediumIL" - var file = File.ReadAllText(manifest); - var xmlDoc = new XmlDocument(); - xmlDoc.LoadXml(file); - var xmlRoot = xmlDoc.DocumentElement; - var namespaceManager = new XmlNamespaceManager(xmlDoc.NameTable); - namespaceManager.AddNamespace("uap10", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10"); - var trustLevelNode = xmlRoot?.SelectSingleNode("//*[local-name()='Application' and @uap10:TrustLevel]", namespaceManager); // According to https://learn.microsoft.com/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps#create-a-package-manifest-for-the-sparse-package and https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-application#attributes - - if (trustLevelNode?.Attributes?["uap10:TrustLevel"]?.Value == "mediumIL") - { - return true; - } - } - catch (Exception) - { - } - } - } - - return false; - } - - internal string ResourceFromPri(string packageFullName, string resourceReference) - { - const string prefix = "ms-resource:"; - - // Using OrdinalIgnoreCase since this is used internally - if (!string.IsNullOrWhiteSpace(resourceReference) && resourceReference.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - // magic comes from @talynone - // https://github.com/talynone/Wox.Plugin.WindowsUniversalAppLauncher/blob/master/StoreAppLauncher/Helpers/NativeApiHelper.cs#L139-L153 - var key = resourceReference.Substring(prefix.Length); - string parsed; - var parsedFallback = string.Empty; - - // Using Ordinal/OrdinalIgnoreCase since these are used internally - if (key.StartsWith("//", StringComparison.Ordinal)) - { - parsed = prefix + key; - } - else if (key.StartsWith('/')) - { - parsed = prefix + "//" + key; - } - else if (key.Contains("resources", StringComparison.OrdinalIgnoreCase)) - { - parsed = prefix + key; - } - else - { - parsed = prefix + "///resources/" + key; - - // e.g. for Windows Terminal version >= 1.12 DisplayName and Description resources are not in the 'resources' subtree - parsedFallback = prefix + "///" + key; - } - - var outBuffer = new StringBuilder(128); - var source = $"@{{{packageFullName}? {parsed}}}"; - var capacity = (uint)outBuffer.Capacity; - var hResult = SHLoadIndirectString(source, outBuffer, capacity, IntPtr.Zero); - if (hResult != HRESULT.S_OK) - { - if (!string.IsNullOrEmpty(parsedFallback)) - { - var sourceFallback = $"@{{{packageFullName}? {parsedFallback}}}"; - hResult = SHLoadIndirectString(sourceFallback, outBuffer, capacity, IntPtr.Zero); - if (hResult == HRESULT.S_OK) - { - var loaded = outBuffer.ToString(); - if (!string.IsNullOrEmpty(loaded)) - { - return loaded; - } - else - { - return string.Empty; - } - } - } - - // https://github.com/Wox-launcher/Wox/issues/964 - // known hresult 2147942522: - // 'Microsoft Corporation' violates pattern constraint of '\bms-resource:.{1,256}'. - // for - // Microsoft.MicrosoftOfficeHub_17.7608.23501.0_x64__8wekyb3d8bbwe: ms-resource://Microsoft.MicrosoftOfficeHub/officehubintl/AppManifest_GetOffice_Description - // Microsoft.BingFoodAndDrink_3.0.4.336_x64__8wekyb3d8bbwe: ms-resource:AppDescription - return string.Empty; - } - else - { - var loaded = outBuffer.ToString(); - if (!string.IsNullOrEmpty(loaded)) - { - return loaded; - } - else - { - return string.Empty; - } - } - } - else - { - return resourceReference; - } - } - - private static readonly Dictionary<PackageVersion, string> _logoKeyFromVersion = new Dictionary<PackageVersion, string> - { - { PackageVersion.Windows10, "Square44x44Logo" }, - { PackageVersion.Windows81, "Square30x30Logo" }, - { PackageVersion.Windows8, "SmallLogo" }, - }; - - internal string LogoUriFromManifest(IAppxManifestApplication app) - { - if (_logoKeyFromVersion.TryGetValue(Package.Version, out var key)) - { - var hr = app.GetStringValue(key, out var logoUriFromApp); - _ = AppxPackageHelper.CheckHRAndReturnOrThrow(hr, logoUriFromApp); - return logoUriFromApp; - } - else - { - return string.Empty; - } - } - - public void UpdateLogoPath(Theme theme) - { - LogoPathFromUri(logoUri, theme); - } - - // scale factors on win10: https://learn.microsoft.com/windows/uwp/controls-and-patterns/tiles-and-notifications-app-assets#asset-size-tables, - private static readonly Dictionary<PackageVersion, List<int>> _scaleFactors = new Dictionary<PackageVersion, List<int>> - { - { PackageVersion.Windows10, new List<int> { 100, 125, 150, 200, 400 } }, - { PackageVersion.Windows81, new List<int> { 100, 120, 140, 160, 180 } }, - { PackageVersion.Windows8, new List<int> { 100 } }, - }; - - private bool SetScaleIcons(string path, string colorscheme, bool highContrast = false) - { - var extension = Path.GetExtension(path); - if (extension != null) - { - var end = path.Length - extension.Length; - var prefix = path.Substring(0, end); - var paths = new List<string> { }; - - if (!highContrast) - { - paths.Add(path); - } - - if (_scaleFactors.TryGetValue(Package.Version, out var factors)) - { - foreach (var factor in factors) - { - if (highContrast) - { - paths.Add($"{prefix}.scale-{factor}_{colorscheme}{extension}"); - paths.Add($"{prefix}.{colorscheme}_scale-{factor}{extension}"); - } - else - { - paths.Add($"{prefix}.scale-{factor}{extension}"); - } - } - } - - var selectedIconPath = paths.FirstOrDefault(File.Exists); - if (!string.IsNullOrEmpty(selectedIconPath)) - { - LogoPath = selectedIconPath; - if (highContrast) - { - LogoType = LogoType.HighContrast; - } - else - { - LogoType = LogoType.Colored; - } - - return true; - } - } - - return false; - } - - private bool SetTargetSizeIcon(string path, string colorscheme, bool highContrast = false) - { - var extension = Path.GetExtension(path); - if (extension != null) - { - var end = path.Length - extension.Length; - var prefix = path.Substring(0, end); - var paths = new List<string> { }; - const int appIconSize = 36; - var targetSizes = new List<int> { 16, 24, 30, 36, 44, 60, 72, 96, 128, 180, 256 }.AsParallel(); - var pathFactorPairs = new Dictionary<string, int>(); - - foreach (var factor in targetSizes) - { - if (highContrast) - { - var suffixThemePath = $"{prefix}.targetsize-{factor}_{colorscheme}{extension}"; - var prefixThemePath = $"{prefix}.{colorscheme}_targetsize-{factor}{extension}"; - paths.Add(suffixThemePath); - paths.Add(prefixThemePath); - pathFactorPairs.Add(suffixThemePath, factor); - pathFactorPairs.Add(prefixThemePath, factor); - } - else - { - var simplePath = $"{prefix}.targetsize-{factor}{extension}"; - var altformUnPlatedPath = $"{prefix}.targetsize-{factor}_altform-unplated{extension}"; - paths.Add(simplePath); - paths.Add(altformUnPlatedPath); - pathFactorPairs.Add(simplePath, factor); - pathFactorPairs.Add(altformUnPlatedPath, factor); - } - } - - var selectedIconPath = paths.OrderBy(x => Math.Abs(pathFactorPairs.GetValueOrDefault(x) - appIconSize)).FirstOrDefault(File.Exists); - if (!string.IsNullOrEmpty(selectedIconPath)) - { - LogoPath = selectedIconPath; - if (highContrast) - { - LogoType = LogoType.HighContrast; - } - else - { - LogoType = LogoType.Colored; - } - - return true; - } - } - - return false; - } - - private bool SetColoredIcon(string path, string colorscheme) - { - var isSetColoredScaleIcon = SetScaleIcons(path, colorscheme); - if (isSetColoredScaleIcon) - { - return true; - } - - var isSetColoredTargetIcon = SetTargetSizeIcon(path, colorscheme); - if (isSetColoredTargetIcon) - { - return true; - } - - var isSetHighContrastScaleIcon = SetScaleIcons(path, colorscheme, true); - if (isSetHighContrastScaleIcon) - { - return true; - } - - var isSetHighContrastTargetIcon = SetTargetSizeIcon(path, colorscheme, true); - if (isSetHighContrastTargetIcon) - { - return true; - } - - return false; - } - - private bool SetHighContrastIcon(string path, string colorscheme) - { - var isSetHighContrastScaleIcon = SetScaleIcons(path, colorscheme, true); - if (isSetHighContrastScaleIcon) - { - return true; - } - - var isSetHighContrastTargetIcon = SetTargetSizeIcon(path, colorscheme, true); - if (isSetHighContrastTargetIcon) - { - return true; - } - - var isSetColoredScaleIcon = SetScaleIcons(path, colorscheme); - if (isSetColoredScaleIcon) - { - return true; - } - - var isSetColoredTargetIcon = SetTargetSizeIcon(path, colorscheme); - if (isSetColoredTargetIcon) - { - return true; - } - - return false; - } - - internal void LogoPathFromUri(string uri, Theme theme) - { - // all https://learn.microsoft.com/windows/uwp/controls-and-patterns/tiles-and-notifications-app-assets - // windows 10 https://msdn.microsoft.com/library/windows/apps/dn934817.aspx - // windows 8.1 https://msdn.microsoft.com/library/windows/apps/hh965372.aspx#target_size - // windows 8 https://msdn.microsoft.com/library/windows/apps/br211475.aspx - string path; - bool isLogoUriSet; - - // Using Ordinal since this is used internally with uri - if (uri.Contains('\\', StringComparison.Ordinal)) - { - path = Path.Combine(Package.Location, uri); - } - else - { - // for C:\Windows\MiracastView etc - path = Path.Combine(Package.Location, "Assets", uri); - } - - switch (theme) - { - case Theme.HighContrastBlack: - case Theme.HighContrastOne: - case Theme.HighContrastTwo: - isLogoUriSet = SetHighContrastIcon(path, ContrastBlack); - break; - case Theme.HighContrastWhite: - isLogoUriSet = SetHighContrastIcon(path, ContrastWhite); - break; - case Theme.Light: - isLogoUriSet = SetColoredIcon(path, ContrastWhite); - break; - default: - isLogoUriSet = SetColoredIcon(path, ContrastBlack); - break; - } - - if (!isLogoUriSet) - { - LogoPath = string.Empty; - LogoType = LogoType.Error; - } - } - - /* - public ImageSource Logo() - { - if (LogoType == LogoType.Colored) - { - var logo = ImageFromPath(LogoPath); - var platedImage = PlatedImage(logo); - return platedImage; - } - else - { - return ImageFromPath(LogoPath); - } - } - - private const int _dpiScale100 = 96; - - private ImageSource PlatedImage(BitmapImage image) - { - if (!string.IsNullOrEmpty(BackgroundColor)) - { - string currentBackgroundColor; - if (BackgroundColor == "transparent") - { - // Using InvariantCulture since this is internal - currentBackgroundColor = SystemParameters.WindowGlassBrush.ToString(CultureInfo.InvariantCulture); - } - else - { - currentBackgroundColor = BackgroundColor; - } - - var padding = 8; - var width = image.Width + (2 * padding); - var height = image.Height + (2 * padding); - var x = 0; - var y = 0; - - var group = new DrawingGroup(); - var converted = ColorConverter.ConvertFromString(currentBackgroundColor); - if (converted != null) - { - var color = (Color)converted; - var brush = new SolidColorBrush(color); - var pen = new Pen(brush, 1); - var backgroundArea = new Rect(0, 0, width, height); - var rectangleGeometry = new RectangleGeometry(backgroundArea, 8, 8); - var rectDrawing = new GeometryDrawing(brush, pen, rectangleGeometry); - group.Children.Add(rectDrawing); - - var imageArea = new Rect(x + padding, y + padding, image.Width, image.Height); - var imageDrawing = new ImageDrawing(image, imageArea); - group.Children.Add(imageDrawing); - - // http://stackoverflow.com/questions/6676072/get-system-drawing-bitmap-of-a-wpf-area-using-visualbrush - var visual = new DrawingVisual(); - var context = visual.RenderOpen(); - context.DrawDrawing(group); - context.Close(); - - var bitmap = new RenderTargetBitmap( - Convert.ToInt32(width), - Convert.ToInt32(height), - _dpiScale100, - _dpiScale100, - PixelFormats.Pbgra32); - - bitmap.Render(visual); - - return bitmap; - } - else - { - ProgramLogger.Exception($"Unable to convert background string {BackgroundColor} to color for {Package.Location}", new InvalidOperationException(), GetType(), Package.Location); - - return new BitmapImage(new Uri(Constant.ErrorIcon)); - } - } - else - { - // todo use windows theme as background - return image; - } - } - - private BitmapImage ImageFromPath(string path) - { - if (File.Exists(path)) - { - var memoryStream = new MemoryStream(); - using (var fileStream = File.OpenRead(path)) - { - fileStream.CopyTo(memoryStream); - memoryStream.Position = 0; - - var image = new BitmapImage(); - image.BeginInit(); - image.StreamSource = memoryStream; - image.EndInit(); - return image; - } - } - else - { - // ProgramLogger.Exception($"Unable to get logo for {UserModelId} from {path} and located in {Package.Location}", new FileNotFoundException(), GetType(), path); - return new BitmapImage(new Uri(ImageLoader.ErrorIconPath)); - } - } - */ - - public override string ToString() - { - return $"{DisplayName}: {Description}"; - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs deleted file mode 100644 index 9b3f54a21f..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.IO; -using System.Text.Json; -using System.Text.Json.Nodes; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Foundation; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class AddBookmarkForm : FormContent -{ - internal event TypedEventHandler<object, BookmarkData>? AddedCommand; - - private readonly BookmarkData? _bookmark; - - public AddBookmarkForm(BookmarkData? bookmark) - { - _bookmark = bookmark; - var name = _bookmark?.Name ?? string.Empty; - var url = _bookmark?.Bookmark ?? string.Empty; - TemplateJson = $$""" -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.5", - "body": [ - { - "type": "Input.Text", - "style": "text", - "id": "name", - "label": "{{Resources.bookmarks_form_name_label}}", - "value": {{JsonSerializer.Serialize(name)}}, - "isRequired": true, - "errorMessage": "{{Resources.bookmarks_form_name_required}}" - }, - { - "type": "Input.Text", - "style": "text", - "id": "bookmark", - "value": {{JsonSerializer.Serialize(url)}}, - "label": "{{Resources.bookmarks_form_bookmark_label}}", - "isRequired": true, - "errorMessage": "{{Resources.bookmarks_form_bookmark_required}}" - } - ], - "actions": [ - { - "type": "Action.Submit", - "title": "{{Resources.bookmarks_form_save}}", - "data": { - "name": "name", - "bookmark": "bookmark" - } - } - ] -} -"""; - } - - public override CommandResult SubmitForm(string payload) - { - var formInput = JsonNode.Parse(payload); - if (formInput == null) - { - return CommandResult.GoHome(); - } - - // get the name and url out of the values - var formName = formInput["name"] ?? string.Empty; - var formBookmark = formInput["bookmark"] ?? string.Empty; - var hasPlaceholder = formBookmark.ToString().Contains('{') && formBookmark.ToString().Contains('}'); - - // Determine the type of the bookmark - string bookmarkType; - - if (formBookmark.ToString().StartsWith("http://", StringComparison.OrdinalIgnoreCase) || formBookmark.ToString().StartsWith("https://", StringComparison.OrdinalIgnoreCase)) - { - bookmarkType = "web"; - } - else if (File.Exists(formBookmark.ToString())) - { - bookmarkType = "file"; - } - else if (Directory.Exists(formBookmark.ToString())) - { - bookmarkType = "folder"; - } - else - { - // Default to web if we can't determine the type - bookmarkType = "web"; - } - - var updated = _bookmark ?? new BookmarkData(); - updated.Name = formName.ToString(); - updated.Bookmark = formBookmark.ToString(); - updated.Type = bookmarkType; - - AddedCommand?.Invoke(this, updated); - return CommandResult.GoHome(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarkData.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarkData.cs deleted file mode 100644 index af6f1ef245..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarkData.cs +++ /dev/null @@ -1,19 +0,0 @@ -// 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.Ext.Bookmarks; - -public class BookmarkData -{ - public string Name { get; set; } = string.Empty; - - public string Bookmark { get; set; } = string.Empty; - - public string Type { get; set; } = string.Empty; - - [JsonIgnore] - public bool IsPlaceholder => Bookmark.Contains('{') && Bookmark.Contains('}'); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderForm.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderForm.cs deleted file mode 100644 index f5a5745e1b..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderForm.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.System; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class BookmarkPlaceholderForm : FormContent -{ - private static readonly CompositeFormat ErrorMessage = System.Text.CompositeFormat.Parse(Resources.bookmarks_required_placeholder); - - private readonly List<string> _placeholderNames; - - private readonly string _bookmark = string.Empty; - - // TODO pass in an array of placeholders - public BookmarkPlaceholderForm(string name, string url, string type) - { - _bookmark = url; - var r = new Regex(Regex.Escape("{") + "(.*?)" + Regex.Escape("}")); - var matches = r.Matches(url); - _placeholderNames = matches.Select(m => m.Groups[1].Value).ToList(); - var inputs = _placeholderNames.Select(p => - { - var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, p); - return $$""" -{ - "type": "Input.Text", - "style": "text", - "id": "{{p}}", - "label": "{{p}}", - "isRequired": true, - "errorMessage": "{{errorMessage}}" -} -"""; - }).ToList(); - - var allInputs = string.Join(",", inputs); - - TemplateJson = $$""" -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.5", - "body": [ -""" + allInputs + $$""" - ], - "actions": [ - { - "type": "Action.Submit", - "title": "{{Resources.bookmarks_form_open}}", - "data": { - "placeholder": "placeholder" - } - } - ] -} -"""; - } - - public override CommandResult SubmitForm(string payload) - { - var target = _bookmark; - - // parse the submitted JSON and then open the link - var formInput = JsonNode.Parse(payload); - var formObject = formInput?.AsObject(); - if (formObject == null) - { - return CommandResult.GoHome(); - } - - foreach (var (key, value) in formObject) - { - var placeholderString = $"{{{key}}}"; - var placeholderData = value?.ToString(); - target = target.Replace(placeholderString, placeholderData); - } - - try - { - var uri = UrlCommand.GetUri(target); - if (uri != null) - { - _ = Launcher.LaunchUriAsync(uri); - } - else - { - // throw new UriFormatException("The provided URL is not valid."); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error launching URL: {ex.Message}"); - } - - return CommandResult.GoHome(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderPage.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderPage.cs deleted file mode 100644 index d30f72bd95..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarkPlaceholderPage.cs +++ /dev/null @@ -1,27 +0,0 @@ -// 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.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class BookmarkPlaceholderPage : ContentPage -{ - private readonly FormContent _bookmarkPlaceholder; - - public override IContent[] GetContent() => [_bookmarkPlaceholder]; - - public BookmarkPlaceholderPage(BookmarkData data) - : this(data.Name, data.Bookmark, data.Type) - { - } - - public BookmarkPlaceholderPage(string name, string url, string type) - { - Name = name; - Icon = new IconInfo(UrlCommand.IconFromUrl(url, type)); - _bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url, type); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs deleted file mode 100644 index 7c3a1dd1e0..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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.IO; -using System.Text.Json; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -public sealed class Bookmarks -{ - public List<BookmarkData> Data { get; set; } = []; - - private static readonly JsonSerializerOptions _jsonOptions = new() - { - IncludeFields = true, - }; - - public static Bookmarks ReadFromFile(string path) - { - var data = new Bookmarks(); - - // if the file exists, load it and append the new item - if (File.Exists(path)) - { - var jsonStringReading = File.ReadAllText(path); - - if (!string.IsNullOrEmpty(jsonStringReading)) - { - data = JsonSerializer.Deserialize<Bookmarks>(jsonStringReading, _jsonOptions) ?? new Bookmarks(); - } - } - - return data; - } - - public static void WriteToFile(string path, Bookmarks data) - { - var jsonString = JsonSerializer.Serialize(data, _jsonOptions); - - File.WriteAllText(BookmarksCommandProvider.StateJsonPath(), jsonString); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs deleted file mode 100644 index 1ecf748a70..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs +++ /dev/null @@ -1,183 +0,0 @@ -// 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.Diagnostics; -using System.IO; -using System.Linq; -using Microsoft.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CmdPal.Ext.Indexer; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -public partial class BookmarksCommandProvider : CommandProvider -{ - private readonly List<CommandItem> _commands = []; - - private readonly AddBookmarkPage _addNewCommand = new(null); - - private Bookmarks? _bookmarks; - - public static IconInfo DeleteIcon { get; private set; } = new("\uE74D"); // Delete - - public static IconInfo EditIcon { get; private set; } = new("\uE70F"); // Edit - - public BookmarksCommandProvider() - { - Id = "Bookmarks"; - DisplayName = Resources.bookmarks_display_name; - Icon = new IconInfo("\uE718"); // Pin - - _addNewCommand.AddedCommand += AddNewCommand_AddedCommand; - } - - private void AddNewCommand_AddedCommand(object sender, BookmarkData args) - { - ExtensionHost.LogMessage($"Adding bookmark ({args.Name},{args.Bookmark})"); - if (_bookmarks != null) - { - _bookmarks.Data.Add(args); - } - - SaveAndUpdateCommands(); - } - - // In the edit path, `args` was already in _bookmarks, we just updated it - private void Edit_AddedCommand(object sender, BookmarkData args) - { - ExtensionHost.LogMessage($"Edited bookmark ({args.Name},{args.Bookmark})"); - - SaveAndUpdateCommands(); - } - - private void SaveAndUpdateCommands() - { - if (_bookmarks != null) - { - var jsonPath = BookmarksCommandProvider.StateJsonPath(); - Bookmarks.WriteToFile(jsonPath, _bookmarks); - } - - LoadCommands(); - RaiseItemsChanged(0); - } - - private void LoadCommands() - { - List<CommandItem> collected = []; - collected.Add(new CommandItem(_addNewCommand)); - - if (_bookmarks == null) - { - LoadBookmarksFromFile(); - } - - if (_bookmarks != null) - { - collected.AddRange(_bookmarks.Data.Select(BookmarkToCommandItem)); - } - - _commands.Clear(); - _commands.AddRange(collected); - } - - private void LoadBookmarksFromFile() - { - try - { - var jsonFile = StateJsonPath(); - if (File.Exists(jsonFile)) - { - _bookmarks = Bookmarks.ReadFromFile(jsonFile); - } - } - catch (Exception ex) - { - // debug log error - Debug.WriteLine($"Error loading commands: {ex.Message}"); - } - - if (_bookmarks == null) - { - _bookmarks = new(); - } - } - - private CommandItem BookmarkToCommandItem(BookmarkData bookmark) - { - ICommand command = bookmark.IsPlaceholder ? - new BookmarkPlaceholderPage(bookmark) : - new UrlCommand(bookmark); - - var listItem = new CommandItem(command) { Icon = command.Icon }; - - List<CommandContextItem> contextMenu = []; - - // Add commands for folder types - if (command is UrlCommand urlCommand) - { - if (urlCommand.Type == "folder") - { - contextMenu.Add( - new CommandContextItem(new DirectoryPage(urlCommand.Url))); - - contextMenu.Add( - new CommandContextItem(new OpenInTerminalCommand(urlCommand.Url))); - } - - listItem.Subtitle = urlCommand.Url; - } - - var edit = new AddBookmarkPage(bookmark) { Icon = EditIcon }; - edit.AddedCommand += Edit_AddedCommand; - contextMenu.Add(new CommandContextItem(edit)); - - var delete = new CommandContextItem( - title: Resources.bookmarks_delete_title, - name: Resources.bookmarks_delete_name, - action: () => - { - if (_bookmarks != null) - { - ExtensionHost.LogMessage($"Deleting bookmark ({bookmark.Name},{bookmark.Bookmark})"); - - _bookmarks.Data.Remove(bookmark); - - SaveAndUpdateCommands(); - } - }, - result: CommandResult.KeepOpen()) - { - IsCritical = true, - Icon = DeleteIcon, - }; - contextMenu.Add(delete); - - listItem.MoreCommands = contextMenu.ToArray(); - - return listItem; - } - - public override ICommandItem[] TopLevelCommands() - { - if (_commands.Count == 0) - { - LoadCommands(); - } - - return _commands.ToArray(); - } - - internal static string StateJsonPath() - { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); - - // now, the state is just next to the exe - return System.IO.Path.Combine(directory, "bookmarks.json"); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs deleted file mode 100644 index 04d474a31a..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs +++ /dev/null @@ -1,177 +0,0 @@ -// 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.Data; -using System.Globalization; -using System.Text; -using Microsoft.CmdPal.Ext.Calc.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Foundation; - -namespace Microsoft.CmdPal.Ext.Calc; - -public partial class CalculatorCommandProvider : CommandProvider -{ - private readonly ListItem _listItem = new(new CalculatorListPage()) { Subtitle = Resources.calculator_top_level_subtitle }; - private readonly FallbackCalculatorItem _fallback = new(); - - public CalculatorCommandProvider() - { - Id = "Calculator"; - DisplayName = Resources.calculator_display_name; - Icon = IconHelpers.FromRelativePath("Assets\\Calculator.svg"); - } - - public override ICommandItem[] TopLevelCommands() => [_listItem]; - - public override IFallbackCommandItem[] FallbackCommands() => [_fallback]; -} - -// The calculator page is a dynamic list page -// * The first command is where we display the results. Title=result, Subtitle=query -// - The default command is `SaveCommand`. -// - When you save, insert into list at spot 1 -// - change SearchText to the result -// - MoreCommands: a single `CopyCommand` to copy the result to the clipboard -// * The rest of the items are previously saved results -// - Command is a CopyCommand -// - Each item also sets the TextToSuggest to the result -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")] -public sealed partial class CalculatorListPage : DynamicListPage -{ - private readonly List<ListItem> _items = []; - private readonly SaveCommand _saveCommand = new(); - private readonly CopyTextCommand _copyContextCommand; - private readonly CommandContextItem _copyContextMenuItem; - private static readonly CompositeFormat ErrorMessage = System.Text.CompositeFormat.Parse(Properties.Resources.calculator_error); - - public CalculatorListPage() - { - Icon = IconHelpers.FromRelativePath("Assets\\Calculator.svg"); - Name = Resources.calculator_title; - PlaceholderText = Resources.calculator_placeholder_text; - Id = "com.microsoft.cmdpal.calculator"; - - _copyContextCommand = new CopyTextCommand(string.Empty); - _copyContextMenuItem = new CommandContextItem(_copyContextCommand); - - _items.Add(new(_saveCommand) { Icon = new IconInfo("\uE94E") }); - - UpdateSearchText(string.Empty, string.Empty); - - _saveCommand.SaveRequested += HandleSave; - } - - private void HandleSave(object sender, object args) - { - var lastResult = _items[0].Title; - if (!string.IsNullOrEmpty(lastResult)) - { - var li = new ListItem(new CopyTextCommand(lastResult)) - { - Title = _items[0].Title, - Subtitle = _items[0].Subtitle, - TextToSuggest = lastResult, - }; - _items.Insert(1, li); - _items[0].Subtitle = string.Empty; - SearchText = lastResult; - this.RaiseItemsChanged(this._items.Count); - } - } - - public override void UpdateSearchText(string oldSearch, string newSearch) - { - var firstItem = _items[0]; - if (string.IsNullOrEmpty(newSearch)) - { - firstItem.Title = Resources.calculator_placeholder_text; - firstItem.Subtitle = string.Empty; - firstItem.MoreCommands = []; - } - else - { - _copyContextCommand.Text = ParseQuery(newSearch, out var result) ? result : string.Empty; - firstItem.Title = result; - firstItem.Subtitle = newSearch; - firstItem.MoreCommands = [_copyContextMenuItem]; - } - } - - internal static bool ParseQuery(string equation, out string result) - { - try - { - var resultNumber = new DataTable().Compute(equation, null); - result = resultNumber.ToString() ?? string.Empty; - return true; - } - catch (Exception e) - { - result = string.Format(CultureInfo.CurrentCulture, ErrorMessage, e.Message); - return false; - } - } - - public override IListItem[] GetItems() => _items.ToArray(); -} - -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")] -public sealed partial class SaveCommand : InvokableCommand -{ - public event TypedEventHandler<object, object> SaveRequested; - - public SaveCommand() - { - Name = Resources.calculator_save_command_name; - } - - public override ICommandResult Invoke() - { - SaveRequested?.Invoke(this, this); - return CommandResult.KeepOpen(); - } -} - -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")] -internal sealed partial class FallbackCalculatorItem : FallbackCommandItem -{ - private readonly CopyTextCommand _copyCommand = new(string.Empty); - private static readonly IconInfo _cachedIcon = IconHelpers.FromRelativePath("Assets\\Calculator.svg"); - - public FallbackCalculatorItem() - : base(new NoOpCommand(), Resources.calculator_title) - { - Command = _copyCommand; - _copyCommand.Name = string.Empty; - Title = string.Empty; - Subtitle = Resources.calculator_placeholder_text; - Icon = _cachedIcon; - } - - public override void UpdateQuery(string query) - { - if (CalculatorListPage.ParseQuery(query, out var result)) - { - _copyCommand.Text = result; - _copyCommand.Name = string.IsNullOrWhiteSpace(query) ? string.Empty : Resources.calculator_copy_command_name; - Title = result; - - // we have to make the subtitle the equation, - // so that we will still string match the original query - // Otherwise, something like 1+2 will have a title of "3" and not match - Subtitle = query; - } - else - { - _copyCommand.Text = string.Empty; - _copyCommand.Name = string.Empty; - Title = string.Empty; - Subtitle = string.Empty; - } - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj deleted file mode 100644 index 1d583e279b..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj +++ /dev/null @@ -1,17 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> - <PropertyGroup> - <RootNamespace>Microsoft.CmdPal.Ext.ClipboardHistory</RootNamespace> - <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> - <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> - <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> - <Nullable>enable</Nullable> - </PropertyGroup> - <ItemGroup> - <ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" /> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> - </ItemGroup> - <ItemGroup> - <PackageReference Include="CommunityToolkit.Mvvm" /> - </ItemGroup> -</Project> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.cs deleted file mode 100644 index 94c5a86dc3..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.cs +++ /dev/null @@ -1,145 +0,0 @@ -// 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.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using Microsoft.CmdPal.Ext.ClipboardHistory.Commands; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.ApplicationModel.DataTransfer; -using Windows.Storage.Streams; - -namespace Microsoft.CmdPal.Ext.ClipboardHistory.Models; - -public class ClipboardItem -{ - public string? Content { get; set; } - - public required ClipboardHistoryItem Item { get; set; } - - public DateTimeOffset Timestamp => Item?.Timestamp ?? DateTimeOffset.MinValue; - - public RandomAccessStreamReference? ImageData { get; set; } - - public string GetDataType() - { - // Check if there is valid image data - if (IsImage) - { - return "Image"; - } - - // Check if there is valid text content - return IsText ? "Text" : "Unknown"; - } - - [MemberNotNullWhen(true, nameof(ImageData))] - private bool IsImage => ImageData != null; - - [MemberNotNullWhen(true, nameof(Content))] - private bool IsText => !string.IsNullOrEmpty(Content); - - public static List<string> ShiftLinesLeft(List<string> lines) - { - // Determine the minimum leading whitespace - var minLeadingWhitespace = lines - .Where(line => !string.IsNullOrWhiteSpace(line)) - .Min(line => line.TakeWhile(char.IsWhiteSpace).Count()); - - // Check if all lines have at least that much leading whitespace - if (lines.Any(line => line.TakeWhile(char.IsWhiteSpace).Count() < minLeadingWhitespace)) - { - return lines; // Return the original lines if any line doesn't have enough leading whitespace - } - - // Remove the minimum leading whitespace from each line - var shiftedLines = lines.Select(line => line.Substring(minLeadingWhitespace)).ToList(); - - return shiftedLines; - } - - public static List<string> StripLeadingWhitespace(List<string> lines) - { - // Determine the minimum leading whitespace - var minLeadingWhitespace = lines - .Min(line => line.TakeWhile(char.IsWhiteSpace).Count()); - - // Remove the minimum leading whitespace from each line - var shiftedLines = lines.Select(line => - line.Length >= minLeadingWhitespace - ? line.Substring(minLeadingWhitespace) - : line).ToList(); - - return shiftedLines; - } - - public ListItem ToListItem() - { - ListItem listItem; - - List<DetailsElement> metadata = []; - metadata.Add(new DetailsElement() - { - Key = "Copied on", - Data = new DetailsLink(Item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)), - }); - - if (IsImage) - { - var iconData = new IconData(ImageData); - var heroImage = new IconInfo(iconData, iconData); - listItem = new(new CopyCommand(this, ClipboardFormat.Image)) - { - // Placeholder subtitle as there’s no BitmapImage dimensions to retrieve - Title = "Image Data", - Details = new Details() - { - HeroImage = heroImage, - Title = GetDataType(), - Body = Timestamp.ToString(CultureInfo.InvariantCulture), - Metadata = metadata.ToArray(), - }, - MoreCommands = [ - new CommandContextItem(new PasteCommand(this, ClipboardFormat.Image)) - ], - }; - } - else if (IsText) - { - var splitContent = Content.Split("\n"); - var head = splitContent.AsSpan(0, Math.Min(3, splitContent.Length)).ToArray().ToList(); - var preview2 = string.Join( - "\n", - StripLeadingWhitespace(head)); - - listItem = new(new CopyCommand(this, ClipboardFormat.Text)) - { - Title = preview2, - - Details = new Details - { - Title = GetDataType(), - Body = $"```text\n{Content}\n```", - Metadata = metadata.ToArray(), - }, - MoreCommands = [ - new CommandContextItem(new PasteCommand(this, ClipboardFormat.Text)), - ], - }; - } - else - { - listItem = new(new NoOpCommand()) - { - Title = "Unknown", - Subtitle = GetDataType(), - Details = new Details { Title = GetDataType() }, - }; - } - - return listItem; - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/CopyPathCommand.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/CopyPathCommand.cs deleted file mode 100644 index 77338e5d45..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/CopyPathCommand.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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.Ext.Indexer.Data; -using Microsoft.CmdPal.Ext.Indexer.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Indexer.Commands; - -internal sealed partial class CopyPathCommand : InvokableCommand -{ - private readonly IndexerItem _item; - - internal CopyPathCommand(IndexerItem item) - { - this._item = item; - this.Name = Resources.Indexer_Command_CopyPath; - this.Icon = new IconInfo("\uE8c8"); - } - - public override CommandResult Invoke() - { - try - { - ClipboardHelper.SetText(_item.FullPath); - } - catch - { - } - - return CommandResult.KeepOpen(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenFileCommand.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenFileCommand.cs deleted file mode 100644 index 478f03ef3a..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenFileCommand.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.ComponentModel; -using System.Diagnostics; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Indexer.Data; -using Microsoft.CmdPal.Ext.Indexer.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Indexer.Commands; - -internal sealed partial class OpenFileCommand : InvokableCommand -{ - private readonly IndexerItem _item; - - internal OpenFileCommand(IndexerItem item) - { - this._item = item; - this.Name = Resources.Indexer_Command_OpenFile; - this.Icon = Icons.OpenFile; - } - - public override CommandResult Invoke() - { - using (var process = new Process()) - { - process.StartInfo.FileName = _item.FullPath; - process.StartInfo.UseShellExecute = true; - - try - { - process.Start(); - } - catch (Win32Exception ex) - { - Logger.LogError($"Unable to open {_item.FullPath}", ex); - } - } - - return CommandResult.GoHome(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenInConsoleCommand.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenInConsoleCommand.cs deleted file mode 100644 index cd9c5ce94b..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenInConsoleCommand.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Indexer.Data; -using Microsoft.CmdPal.Ext.Indexer.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Indexer.Commands; - -internal sealed partial class OpenInConsoleCommand : InvokableCommand -{ - private readonly IndexerItem _item; - - internal OpenInConsoleCommand(IndexerItem item) - { - this._item = item; - this.Name = Resources.Indexer_Command_OpenPathInConsole; - this.Icon = new IconInfo("\uE756"); - } - - public override CommandResult Invoke() - { - using (var process = new Process()) - { - process.StartInfo.WorkingDirectory = Path.GetDirectoryName(_item.FullPath); - process.StartInfo.FileName = "cmd.exe"; - - try - { - process.Start(); - } - catch (Win32Exception ex) - { - Logger.LogError($"Unable to open {_item.FullPath}", ex); - } - } - - return CommandResult.GoHome(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenPropertiesCommand.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenPropertiesCommand.cs deleted file mode 100644 index a6611cf6b3..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenPropertiesCommand.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Runtime.InteropServices; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Indexer.Data; -using Microsoft.CmdPal.Ext.Indexer.Native; -using Microsoft.CmdPal.Ext.Indexer.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Win32; -using Windows.Win32.Foundation; -using Windows.Win32.UI.Shell; -using Windows.Win32.UI.WindowsAndMessaging; - -namespace Microsoft.CmdPal.Ext.Indexer.Commands; - -internal sealed partial class OpenPropertiesCommand : InvokableCommand -{ - private readonly IndexerItem _item; - - private static unsafe bool ShowFileProperties(string filename) - { - var propertiesPtr = Marshal.StringToHGlobalUni("properties"); - var filenamePtr = Marshal.StringToHGlobalUni(filename); - - try - { - var filenamePCWSTR = new PCWSTR((char*)filenamePtr); - var propertiesPCWSTR = new PCWSTR((char*)propertiesPtr); - - var info = new SHELLEXECUTEINFOW - { - cbSize = (uint)Marshal.SizeOf<SHELLEXECUTEINFOW>(), - lpVerb = propertiesPCWSTR, - lpFile = filenamePCWSTR, - nShow = (int)SHOW_WINDOW_CMD.SW_SHOW, - fMask = NativeHelpers.SEEMASKINVOKEIDLIST, - }; - - return PInvoke.ShellExecuteEx(ref info); - } - finally - { - Marshal.FreeHGlobal(filenamePtr); - Marshal.FreeHGlobal(propertiesPtr); - } - } - - internal OpenPropertiesCommand(IndexerItem item) - { - this._item = item; - this.Name = Resources.Indexer_Command_OpenProperties; - this.Icon = new IconInfo("\uE90F"); - } - - public override CommandResult Invoke() - { - try - { - ShowFileProperties(_item.FullPath); - } - catch (Exception ex) - { - Logger.LogError("Error showing file properties: ", ex); - } - - return CommandResult.GoHome(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenWithCommand.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenWithCommand.cs deleted file mode 100644 index a9c431d32c..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Commands/OpenWithCommand.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Runtime.InteropServices; -using Microsoft.CmdPal.Ext.Indexer.Data; -using Microsoft.CmdPal.Ext.Indexer.Native; -using Microsoft.CmdPal.Ext.Indexer.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Win32; -using Windows.Win32.Foundation; -using Windows.Win32.UI.Shell; -using Windows.Win32.UI.WindowsAndMessaging; - -namespace Microsoft.CmdPal.Ext.Indexer.Commands; - -internal sealed partial class OpenWithCommand : InvokableCommand -{ - private readonly IndexerItem _item; - - private static unsafe bool OpenWith(string filename) - { - var filenamePtr = Marshal.StringToHGlobalUni(filename); - var verbPtr = Marshal.StringToHGlobalUni("openas"); - - try - { - var filenamePCWSTR = new PCWSTR((char*)filenamePtr); - var verbPCWSTR = new PCWSTR((char*)verbPtr); - - var info = new SHELLEXECUTEINFOW - { - cbSize = (uint)Marshal.SizeOf<SHELLEXECUTEINFOW>(), - lpVerb = verbPCWSTR, - lpFile = filenamePCWSTR, - nShow = (int)SHOW_WINDOW_CMD.SW_SHOWNORMAL, - fMask = NativeHelpers.SEEMASKINVOKEIDLIST, - }; - - return PInvoke.ShellExecuteEx(ref info); - } - finally - { - Marshal.FreeHGlobal(filenamePtr); - Marshal.FreeHGlobal(verbPtr); - } - } - - internal OpenWithCommand(IndexerItem item) - { - this._item = item; - this.Name = Resources.Indexer_Command_OpenWith; - this.Icon = new IconInfo("\uE7AC"); - } - - public override CommandResult Invoke() - { - OpenWith(_item.FullPath); - - return CommandResult.GoHome(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs deleted file mode 100644 index 57d399b668..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs +++ /dev/null @@ -1,57 +0,0 @@ -// 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 Microsoft.CmdPal.Ext.Indexer.Commands; -using Microsoft.CmdPal.Ext.Indexer.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Indexer.Data; - -internal sealed partial class IndexerListItem : ListItem -{ - internal string FilePath { get; private set; } - - public IndexerListItem( - IndexerItem indexerItem, - IncludeBrowseCommand browseByDefault = IncludeBrowseCommand.Include) - : base(new OpenFileCommand(indexerItem)) - { - FilePath = indexerItem.FullPath; - - Title = indexerItem.FileName; - Subtitle = indexerItem.FullPath; - List<CommandContextItem> context = []; - if (indexerItem.IsDirectory()) - { - var directoryPage = new DirectoryPage(indexerItem.FullPath); - if (browseByDefault == IncludeBrowseCommand.AsDefault) - { - // Swap the open file command into the context menu - context.Add(new CommandContextItem(Command)); - Command = directoryPage; - } - else if (browseByDefault == IncludeBrowseCommand.Include) - { - context.Add(new CommandContextItem(directoryPage)); - } - } - - MoreCommands = [ - ..context, - new CommandContextItem(new OpenWithCommand(indexerItem)), - new CommandContextItem(new ShowFileInFolderCommand(indexerItem.FullPath) { Name = Resources.Indexer_Command_ShowInFolder }), - new CommandContextItem(new CopyPathCommand(indexerItem)), - new CommandContextItem(new OpenInConsoleCommand(indexerItem)), - new CommandContextItem(new OpenPropertiesCommand(indexerItem)), - ]; - } -} - -internal enum IncludeBrowseCommand -{ - AsDefault = 0, - Include = 1, - Exclude = 2, -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowset.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowset.cs deleted file mode 100644 index 3127e2e563..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowset.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.OleDB; - -[ComImport] -[Guid("0c733a7c-2a1c-11ce-ade5-00aa0044773d")] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -public interface IRowset -{ - [PreserveSig] - int AddRefRows( - uint cRows, - [In, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] IntPtr[] rghRows, - [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] uint[] rgRefCounts, - [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] int[] rgRowStatus); - - [PreserveSig] - int GetData( - IntPtr hRow, - IntPtr hAccessor, - IntPtr pData); - - [PreserveSig] - int GetNextRows( - IntPtr hReserved, - long lRowsOffset, - long cRows, - out uint pcRowsObtained, - out IntPtr prghRows); - - [PreserveSig] - int ReleaseRows( - uint cRows, - [In, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] IntPtr[] rghRows, - IntPtr rgRowOptions, - [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] uint[] rgRefCounts, - [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] int[] rgRowStatus); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs deleted file mode 100644 index f835acafaa..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs +++ /dev/null @@ -1,39 +0,0 @@ -// 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 Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.Utils; - -internal sealed class QueryStringBuilder -{ - private const string Properties = "System.ItemUrl, System.ItemNameDisplay, path, System.Search.EntryID, System.Kind, System.KindText"; - private const string SystemIndex = "SystemIndex"; - private const string ScopeFileConditions = "SCOPE='file:'"; - private const string OrderConditions = "System.DateModified DESC"; - private const string SelectQueryWithScope = "SELECT " + Properties + " FROM " + SystemIndex + " WHERE (" + ScopeFileConditions + ")"; - private const string SelectQueryWithScopeAndOrderConditions = SelectQueryWithScope + " ORDER BY " + OrderConditions; - - private static ISearchQueryHelper queryHelper; - - public static string GeneratePrimingQuery() => SelectQueryWithScopeAndOrderConditions; - - public static string GenerateQuery(string searchText, uint whereId) - { - if (queryHelper == null) - { - var searchManager = new CSearchManager(); - ISearchCatalogManager catalogManager = searchManager.GetCatalog(SystemIndex); - queryHelper = catalogManager.GetQueryHelper(); - - queryHelper.QuerySelectColumns = Properties; - queryHelper.QueryContentProperties = "System.FileName"; - queryHelper.QuerySorting = OrderConditions; - } - - queryHelper.QueryWhereRestrictions = "AND " + ScopeFileConditions + "AND ReuseWhere(" + whereId.ToString(CultureInfo.InvariantCulture) + ")"; - return queryHelper.GenerateSQLFromUserQuery(searchText); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Native/NativeHelpers.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Native/NativeHelpers.cs deleted file mode 100644 index 9851573843..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Native/NativeHelpers.cs +++ /dev/null @@ -1,25 +0,0 @@ -// 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 Windows.Win32.UI.Shell.PropertiesSystem; - -namespace Microsoft.CmdPal.Ext.Indexer.Native; - -internal sealed class NativeHelpers -{ - public const uint SEEMASKINVOKEIDLIST = 12; - - internal static class PropertyKeys - { - public static readonly PROPERTYKEY PKEYItemNameDisplay = new() { fmtid = new System.Guid("B725F130-47EF-101A-A5F1-02608C9EEBAC"), pid = 10 }; - public static readonly PROPERTYKEY PKEYItemUrl = new() { fmtid = new System.Guid("49691C90-7E17-101A-A91C-08002B2ECDA9"), pid = 9 }; - public static readonly PROPERTYKEY PKEYKindText = new() { fmtid = new System.Guid("F04BEF95-C585-4197-A2B7-DF46FDC9EE6D"), pid = 100 }; - } - - internal static class OleDb - { - public static readonly Guid DbGuidDefault = new("C8B521FB-5CF3-11CE-ADE5-00AA0044773D"); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs deleted file mode 100644 index 3d277d9dcf..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs +++ /dev/null @@ -1,127 +0,0 @@ -// 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.Diagnostics; -using System.Threading.Tasks; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Indexer.Data; -using Microsoft.CmdPal.Ext.Indexer.Indexer; -using Microsoft.CmdPal.Ext.Indexer.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Storage.Streams; - -namespace Microsoft.CmdPal.Ext.Indexer; - -internal sealed partial class IndexerPage : DynamicListPage, IDisposable -{ - private readonly List<IListItem> _indexerListItems = []; - - private SearchQuery _searchQuery = new(); - - private uint _queryCookie = 10; - - public IndexerPage() - { - Id = "com.microsoft.indexer.fileSearch"; - Icon = Icons.FileExplorer; - Name = Resources.Indexer_Title; - PlaceholderText = Resources.Indexer_PlaceholderText; - } - - public override void UpdateSearchText(string oldSearch, string newSearch) - { - if (oldSearch != newSearch) - { - _ = Task.Run(() => - { - Query(newSearch); - LoadMore(); - }); - } - } - - public override IListItem[] GetItems() => [.. _indexerListItems]; - - public override void LoadMore() - { - IsLoading = true; - FetchItems(20); - IsLoading = false; - RaiseItemsChanged(_indexerListItems.Count); - } - - private void Query(string query) - { - ++_queryCookie; - _indexerListItems.Clear(); - _searchQuery.SearchResults.Clear(); - _searchQuery.CancelOutstandingQueries(); - - if (query == string.Empty) - { - return; - } - - Stopwatch stopwatch = new(); - stopwatch.Start(); - - _searchQuery.Execute(query, _queryCookie); - - stopwatch.Stop(); - Logger.LogDebug($"Query time: {stopwatch.ElapsedMilliseconds} ms, query: \"{query}\""); - } - - private void FetchItems(int limit) - { - if (_searchQuery != null) - { - var cookie = _searchQuery.Cookie; - if (cookie == _queryCookie) - { - var index = 0; - SearchResult result; - - var hasMoreItems = _searchQuery.FetchRows(_indexerListItems.Count, limit); - - while (!_searchQuery.SearchResults.IsEmpty && _searchQuery.SearchResults.TryDequeue(out result) && ++index <= limit) - { - IconInfo icon = null; - try - { - var stream = ThumbnailHelper.GetThumbnail(result.LaunchUri).Result; - if (stream != null) - { - var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); - icon = new IconInfo(data, data); - } - } - catch (Exception ex) - { - Logger.LogError("Failed to get the icon.", ex); - } - - _indexerListItems.Add(new IndexerListItem(new IndexerItem - { - FileName = result.ItemDisplayName, - FullPath = result.LaunchUri, - }) - { - Icon = icon, - }); - } - - HasMoreItems = hasMoreItems; - } - } - } - - public void Dispose() - { - _searchQuery = null; - GC.SuppressFinalize(this); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs deleted file mode 100644 index 3bc1ec09d7..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Commands/ExecuteItem.cs +++ /dev/null @@ -1,258 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using Microsoft.CmdPal.Ext.Shell.Helpers; -using Microsoft.CmdPal.Ext.Shell.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Shell.Commands; - -internal sealed partial class ExecuteItem : InvokableCommand -{ - private readonly SettingsManager _settings; - private readonly RunAsType _runas; - - public string Cmd { get; internal set; } = string.Empty; - - private static readonly char[] Separator = [' ']; - - public ExecuteItem(string cmd, SettingsManager settings, RunAsType type = RunAsType.None) - { - if (type == RunAsType.Administrator) - { - Name = Properties.Resources.cmd_run_as_administrator; - Icon = new IconInfo("\xE7EF"); // Admin Icon - } - else if (type == RunAsType.OtherUser) - { - Name = Properties.Resources.cmd_run_as_user; - Icon = new IconInfo("\xE7EE"); // User Icon - } - else - { - Name = Properties.Resources.generic_run_command; - Icon = new IconInfo("\uE751"); // Return Key Icon - } - - Cmd = cmd; - _settings = settings; - _runas = type; - } - - private static bool ExistInPath(string filename) - { - if (File.Exists(filename)) - { - return true; - } - else - { - var values = Environment.GetEnvironmentVariable("PATH"); - if (values != null) - { - foreach (var path in values.Split(';')) - { - var path1 = Path.Combine(path, filename); - var path2 = Path.Combine(path, filename + ".exe"); - if (File.Exists(path1) || File.Exists(path2)) - { - return true; - } - } - - return false; - } - else - { - return false; - } - } - } - - private void Execute(Func<ProcessStartInfo, Process?> startProcess, ProcessStartInfo info) - { - if (startProcess == null) - { - return; - } - - try - { - startProcess(info); - } - catch (FileNotFoundException e) - { - var name = "Plugin: " + Properties.Resources.cmd_plugin_name; - var message = $"{Properties.Resources.cmd_command_not_found}: {e.Message}"; - - // GH TODO #138 -- show this message once that's wired up - // _context.API.ShowMsg(name, message); - } - catch (Win32Exception e) - { - var name = "Plugin: " + Properties.Resources.cmd_plugin_name; - var message = $"{Properties.Resources.cmd_command_failed}: {e.Message}"; - ExtensionHost.LogMessage(new LogMessage() { Message = name + message }); - - // GH TODO #138 -- show this message once that's wired up - // _context.API.ShowMsg(name, message); - } - } - - public static ProcessStartInfo SetProcessStartInfo(string fileName, string workingDirectory = "", string arguments = "", string verb = "") - { - var info = new ProcessStartInfo - { - FileName = fileName, - WorkingDirectory = workingDirectory, - Arguments = arguments, - Verb = verb, - }; - - return info; - } - - private ProcessStartInfo PrepareProcessStartInfo(string command, RunAsType runAs = RunAsType.None) - { - command = Environment.ExpandEnvironmentVariables(command); - var workingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - - // Set runAsArg - var runAsVerbArg = string.Empty; - if (runAs == RunAsType.OtherUser) - { - runAsVerbArg = "runAsUser"; - } - else if (runAs == RunAsType.Administrator || _settings.RunAsAdministrator) - { - runAsVerbArg = "runAs"; - } - - if (Enum.TryParse<ExecutionShell>(_settings.ShellCommandExecution, out var executionShell)) - { - ProcessStartInfo info; - if (executionShell == ExecutionShell.Cmd) - { - var arguments = _settings.LeaveShellOpen ? $"/k \"{command}\"" : $"/c \"{command}\" & pause"; - - info = SetProcessStartInfo("cmd.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.Powershell) - { - var arguments = _settings.LeaveShellOpen - ? $"-NoExit \"{command}\"" - : $"\"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\""; - info = SetProcessStartInfo("powershell.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.PowerShellSeven) - { - var arguments = _settings.LeaveShellOpen - ? $"-NoExit -C \"{command}\"" - : $"-C \"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\""; - info = SetProcessStartInfo("pwsh.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.WindowsTerminalCmd) - { - var arguments = _settings.LeaveShellOpen ? $"cmd.exe /k \"{command}\"" : $"cmd.exe /c \"{command}\" & pause"; - info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.WindowsTerminalPowerShell) - { - var arguments = _settings.LeaveShellOpen ? $"powershell -NoExit -C \"{command}\"" : $"powershell -C \"{command}\""; - info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.WindowsTerminalPowerShellSeven) - { - var arguments = _settings.LeaveShellOpen ? $"pwsh.exe -NoExit -C \"{command}\"" : $"pwsh.exe -C \"{command}\""; - info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg); - } - else if (executionShell == ExecutionShell.RunCommand) - { - // Open explorer if the path is a file or directory - if (Directory.Exists(command) || File.Exists(command)) - { - info = SetProcessStartInfo("explorer.exe", arguments: command, verb: runAsVerbArg); - } - else - { - var parts = command.Split(Separator, 2); - if (parts.Length == 2) - { - var filename = parts[0]; - if (ExistInPath(filename)) - { - var arguments = parts[1]; - if (_settings.LeaveShellOpen) - { - // Wrap the command in a cmd.exe process - info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{filename} {arguments}\"", runAsVerbArg); - } - else - { - info = SetProcessStartInfo(filename, workingDirectory, arguments, runAsVerbArg); - } - } - else - { - if (_settings.LeaveShellOpen) - { - // Wrap the command in a cmd.exe process - info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg); - } - else - { - info = SetProcessStartInfo(command, verb: runAsVerbArg); - } - } - } - else - { - if (_settings.LeaveShellOpen) - { - // Wrap the command in a cmd.exe process - info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg); - } - else - { - info = SetProcessStartInfo(command, verb: runAsVerbArg); - } - } - } - } - else - { - throw new NotImplementedException(); - } - - info.UseShellExecute = true; - - _settings.AddCmdHistory(command); - - return info; - } - else - { - ExtensionHost.LogMessage(new LogMessage() { Message = "Error extracting setting" }); - throw new NotImplementedException(); - } - } - - public override CommandResult Invoke() - { - try - { - Execute(Process.Start, PrepareProcessStartInfo(Cmd, _runas)); - } - catch - { - ExtensionHost.LogMessage(new LogMessage() { Message = "Error starting the process " }); - } - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs deleted file mode 100644 index 4ce9076e80..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.Shell.Commands; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Shell.Helpers; - -public class ShellListPageHelpers -{ - private static readonly CompositeFormat CmdHasBeenExecutedTimes = System.Text.CompositeFormat.Parse(Properties.Resources.cmd_has_been_executed_times); - private readonly SettingsManager _settings; - - public ShellListPageHelpers(SettingsManager settings) - { - _settings = settings; - } - - private ListItem GetCurrentCmd(string cmd) - { - ListItem result = new ListItem(new ExecuteItem(cmd, _settings)) - { - Title = cmd, - Subtitle = Properties.Resources.cmd_plugin_name + ": " + Properties.Resources.cmd_execute_through_shell, - Icon = new IconInfo(string.Empty), - }; - - return result; - } - - private List<ListItem> GetHistoryCmds(string cmd, ListItem result) - { - IEnumerable<ListItem?> history = _settings.Count.Where(o => o.Key.Contains(cmd, StringComparison.CurrentCultureIgnoreCase)) - .OrderByDescending(o => o.Value) - .Select(m => - { - if (m.Key == cmd) - { - // Using CurrentCulture since this is user facing - result.Subtitle = Properties.Resources.cmd_plugin_name + ": " + string.Format(CultureInfo.CurrentCulture, CmdHasBeenExecutedTimes, m.Value); - return null; - } - - var ret = new ListItem(new ExecuteItem(m.Key, _settings)) - { - Title = m.Key, - - // Using CurrentCulture since this is user facing - Subtitle = Properties.Resources.cmd_plugin_name + ": " + string.Format(CultureInfo.CurrentCulture, CmdHasBeenExecutedTimes, m.Value), - Icon = new IconInfo("\uE81C"), - }; - return ret; - }).Where(o => o != null).Take(4); - return history.Select(o => o!).ToList(); - } - - public List<ListItem> Query(string query) - { - ArgumentNullException.ThrowIfNull(query); - - List<ListItem> results = new List<ListItem>(); - var cmd = query; - if (string.IsNullOrEmpty(cmd)) - { - results = ResultsFromlHistory(); - } - else - { - var queryCmd = GetCurrentCmd(cmd); - results.Add(queryCmd); - var history = GetHistoryCmds(cmd, queryCmd); - results.AddRange(history); - } - - foreach (var currItem in results) - { - currItem.MoreCommands = LoadContextMenus(currItem).ToArray(); - } - - return results; - } - - public List<CommandContextItem> LoadContextMenus(ListItem listItem) - { - var resultlist = new List<CommandContextItem> - { - new(new ExecuteItem(listItem.Title, _settings, RunAsType.Administrator)), - new(new ExecuteItem(listItem.Title, _settings, RunAsType.OtherUser )), - }; - - return resultlist; - } - - private List<ListItem> ResultsFromlHistory() - { - IEnumerable<ListItem> history = _settings.Count.OrderByDescending(o => o.Value) - .Select(m => new ListItem(new ExecuteItem(m.Key, _settings)) - { - Title = m.Key, - - // Using CurrentCulture since this is user facing - Subtitle = Properties.Resources.cmd_plugin_name + ": " + string.Format(CultureInfo.CurrentCulture, CmdHasBeenExecutedTimes, m.Value), - Icon = new IconInfo("\uE81C"), - }).Take(5); - - return history.ToList(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs deleted file mode 100644 index 51f6f8e636..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs +++ /dev/null @@ -1,28 +0,0 @@ -// 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.Ext.Shell.Helpers; -using Microsoft.CmdPal.Ext.Shell.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Shell.Pages; - -internal sealed partial class ShellListPage : DynamicListPage -{ - private readonly ShellListPageHelpers _helper; - - public ShellListPage(SettingsManager settingsManager) - { - Icon = Icons.RunV2; - Id = "com.microsoft.cmdpal.shell"; - Name = Resources.cmd_plugin_name; - PlaceholderText = Resources.list_placeholder_text; - _helper = new(settingsManager); - } - - public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0); - - public override IListItem[] GetItems() => [.. _helper.Query(SearchText)]; -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Icons.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Icons.cs deleted file mode 100644 index be64ddb181..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Icons.cs +++ /dev/null @@ -1,26 +0,0 @@ -// 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.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.System.Helpers; - -public static partial class Icons -{ - public static IconInfo FirmwareSettingsIcon { get; } = new IconInfo("\uE950"); - - public static IconInfo LockIcon { get; } = new IconInfo("\uE72E"); - - public static IconInfo LogoffIcon { get; } = new IconInfo("\uF3B1"); - - public static IconInfo NetworkAdapterIcon { get; } = new IconInfo("\uEDA3"); - - public static IconInfo RecycleBinIcon { get; } = new IconInfo("\uE74D"); - - public static IconInfo RestartIcon { get; } = new IconInfo("\uE777"); - - public static IconInfo ShutdownIcon { get; } = new IconInfo("\uE7E8"); - - public static IconInfo SleepIcon { get; } = new IconInfo("\uE708"); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Native.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Native.cs deleted file mode 100644 index 3daf2a4be4..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Native.cs +++ /dev/null @@ -1,144 +0,0 @@ -// 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.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.System.Helpers; - -[SuppressMessage("Interoperability", "CA1401:P/Invokes should not be visible", Justification = "We want plugins to share this NativeMethods class, instead of each one creating its own.")] -public sealed class Native -{ - public enum HRESULT : uint - { - /// <summary> - /// Operation successful. - /// </summary> - S_OK = 0x00000000, - - /// <summary> - /// Operation successful. (negative condition/no operation) - /// </summary> - S_FALSE = 0x00000001, - - /// <summary> - /// Not implemented. - /// </summary> - E_NOTIMPL = 0x80004001, - - /// <summary> - /// No such interface supported. - /// </summary> - E_NOINTERFACE = 0x80004002, - - /// <summary> - /// Pointer that is not valid. - /// </summary> - E_POINTER = 0x80004003, - - /// <summary> - /// Operation aborted. - /// </summary> - E_ABORT = 0x80004004, - - /// <summary> - /// Unspecified failure. - /// </summary> - E_FAIL = 0x80004005, - - /// <summary> - /// Unexpected failure. - /// </summary> - E_UNEXPECTED = 0x8000FFFF, - - /// <summary> - /// General access denied error. - /// </summary> - E_ACCESSDENIED = 0x80070005, - - /// <summary> - /// Handle that is not valid. - /// </summary> - E_HANDLE = 0x80070006, - - /// <summary> - /// Failed to allocate necessary memory. - /// </summary> - E_OUTOFMEMORY = 0x8007000E, - - /// <summary> - /// One or more arguments are not valid. - /// </summary> - E_INVALIDARG = 0x80070057, - - /// <summary> - /// The operation was canceled by the user. (Error source 7 means Win32.) - /// </summary> - /// <SeeAlso href="https://learn.microsoft.com/windows/win32/debug/system-error-codes--1000-1299-"/> - /// <SeeAlso href="https://en.wikipedia.org/wiki/HRESULT"/> - E_CANCELLED = 0x800704C7, - } - - public static class ShellItemTypeConstants - { - /// <summary> - /// Guid for type IShellItem. - /// </summary> - public static readonly Guid ShellItemGuid = new("43826d1e-e718-42ee-bc55-a1e261c37bfe"); - - /// <summary> - /// Guid for type IShellItem2. - /// </summary> - public static readonly Guid ShellItem2Guid = new("7E9FB0D3-919F-4307-AB2E-9B1860310C93"); - } - - /// <summary> - /// The following are ShellItem DisplayName types. - /// </summary> - [Flags] - public enum SIGDN : uint - { - NORMALDISPLAY = 0, - PARENTRELATIVEPARSING = 0x80018001, - PARENTRELATIVEFORADDRESSBAR = 0x8001c001, - DESKTOPABSOLUTEPARSING = 0x80028000, - PARENTRELATIVEEDITING = 0x80031001, - DESKTOPABSOLUTEEDITING = 0x8004c000, - FILESYSPATH = 0x80058000, - URL = 0x80068000, - } - - [ComImport] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - [Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")] - public interface IShellItem - { - void BindToHandler( - nint pbc, - [MarshalAs(UnmanagedType.LPStruct)] Guid bhid, - [MarshalAs(UnmanagedType.LPStruct)] Guid riid, - out nint ppv); - - void GetParent(out IShellItem ppsi); - - void GetDisplayName(SIGDN sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName); - - void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs); - - void Compare(IShellItem psi, uint hint, out int piOrder); - } - - /// <summary> - /// <see href="https://learn.microsoft.com/windows/win32/stg/stgm-constants">see all STGM values</see> - /// </summary> - [Flags] - public enum STGM : long - { - READ = 0x00000000L, - WRITE = 0x00000001L, - READWRITE = 0x00000002L, - CREATE = 0x00001000L, - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/SystemCommandsCache.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/SystemCommandsCache.cs deleted file mode 100644 index a2a61b2b50..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/SystemCommandsCache.cs +++ /dev/null @@ -1,51 +0,0 @@ -// 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.Threading.Tasks; -using Microsoft.CmdPal.Ext.System.Helpers; -using Microsoft.CommandPalette.Extensions; - -namespace Microsoft.CmdPal.Ext.System; - -public sealed partial class SystemCommandsCache -{ - public SystemCommandsCache(SettingsManager manager) - { - var list = new List<IListItem>(); - var listLock = new object(); - - var a = Task.Run(() => - { - var isBootedInUefiMode = Win32Helpers.GetSystemFirmwareType() == FirmwareType.Uefi; - - var separateEmptyRB = manager.HideEmptyRecycleBin; - var confirmSystemCommands = manager.ShowDialogToConfirmCommand; - var showSuccessOnEmptyRB = manager.ShowSuccessMessageAfterEmptyingRecycleBin; - - // normal system commands are fast and can be returned immediately - var systemCommands = Commands.GetSystemCommands(isBootedInUefiMode, separateEmptyRB, confirmSystemCommands, showSuccessOnEmptyRB); - lock (listLock) - { - list.AddRange(systemCommands); - } - }); - - var b = Task.Run(() => - { - // Network (ip and mac) results are slow with many network cards and returned delayed. - // On global queries the first word/part has to be 'ip', 'mac' or 'address' for network results - var networkConnectionResults = Commands.GetNetworkConnectionResults(manager); - lock (listLock) - { - list.AddRange(networkConnectionResults); - } - }); - - Task.WaitAll(a, b); - CachedCommands = list.ToArray(); - } - - public IListItem[] CachedCommands { get; } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeAndDateHelper.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeAndDateHelper.cs deleted file mode 100644 index 3450eda627..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeAndDateHelper.cs +++ /dev/null @@ -1,189 +0,0 @@ -// 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.Globalization; -using System.Text.RegularExpressions; - -namespace Microsoft.CmdPal.Ext.TimeDate.Helpers; - -internal static class TimeAndDateHelper -{ - /// <summary> - /// Get the format for the time string - /// </summary> - /// <param name="targetFormat">Type of format</param> - /// <param name="timeLong">Show date with weekday and name of month (long format)</param> - /// <param name="dateLong">Show time with seconds (long format)</param> - /// <returns>String that identifies the time/date format (<see href="https://learn.microsoft.com/dotnet/api/system.datetime.tostring"/>)</returns> - internal static string GetStringFormat(FormatStringType targetFormat, bool timeLong, bool dateLong) - { - switch (targetFormat) - { - case FormatStringType.Time: - return timeLong ? "T" : "t"; - case FormatStringType.Date: - return dateLong ? "D" : "d"; - case FormatStringType.DateTime: - if (timeLong & dateLong) - { - return "F"; // Friday, October 31, 2008 5:04:32 PM - } - else if (timeLong & !dateLong) - { - return "G"; // 10/31/2008 5:04:32 PM - } - else if (!timeLong & dateLong) - { - return "f"; // Friday, October 31, 2008 5:04 PM - } - else - { - // (!timeLong & !dateLong) - return "g"; // 10/31/2008 5:04 PM - } - - default: - return string.Empty; // Windows default based on current culture settings - } - } - - /// <summary> - /// Returns the number week in the month (Used code from 'David Morton' from <see href="https://social.msdn.microsoft.com/Forums/vstudio/bf504bba-85cb-492d-a8f7-4ccabdf882cb/get-week-number-for-month"/>) - /// </summary> - /// <param name="date">date</param> - /// <returns>Number of week in the month</returns> - internal static int GetWeekOfMonth(DateTime date, DayOfWeek formatSettingFirstDayOfWeek) - { - var beginningOfMonth = new DateTime(date.Year, date.Month, 1); - var adjustment = 1; // We count from 1 to 7 and not from 0 to 6 - - while (date.Date.AddDays(1).DayOfWeek != formatSettingFirstDayOfWeek) - { - date = date.AddDays(1); - } - - return (int)Math.Truncate((double)date.Subtract(beginningOfMonth).TotalDays / 7f) + adjustment; - } - - /// <summary> - /// Returns the number of the day in the week - /// </summary> - /// <param name="date">Date</param> - /// <returns>Number of the day in the week</returns> - internal static int GetNumberOfDayInWeek(DateTime date, DayOfWeek formatSettingFirstDayOfWeek) - { - var daysInWeek = 7; - var adjustment = 1; // We count from 1 to 7 and not from 0 to 6 - - return ((date.DayOfWeek + daysInWeek - formatSettingFirstDayOfWeek) % daysInWeek) + adjustment; - } - - /// <summary> - /// Convert input string to a <see cref="DateTime"/> object in local time - /// </summary> - /// <param name="input">String with date/time</param> - /// <param name="timestamp">The new <see cref="DateTime"/> object</param> - /// <returns>True on success, otherwise false</returns> - internal static bool ParseStringAsDateTime(in string input, out DateTime timestamp) - { - if (DateTime.TryParse(input, out timestamp)) - { - // Known date/time format - return true; - } - else if (Regex.IsMatch(input, @"^u[\+-]?\d{1,10}$") && long.TryParse(input.TrimStart('u'), out var secondsU)) - { - // Unix time stamp - // We use long instead of int, because int is too small after 03:14:07 UTC 2038-01-19 - timestamp = DateTimeOffset.FromUnixTimeSeconds(secondsU).LocalDateTime; - return true; - } - else if (Regex.IsMatch(input, @"^ums[\+-]?\d{1,13}$") && long.TryParse(input.TrimStart("ums".ToCharArray()), out var millisecondsUms)) - { - // Unix time stamp in milliseconds - // We use long instead of int because int is too small after 03:14:07 UTC 2038-01-19 - timestamp = DateTimeOffset.FromUnixTimeMilliseconds(millisecondsUms).LocalDateTime; - return true; - } - else if (Regex.IsMatch(input, @"^ft\d+$") && long.TryParse(input.TrimStart("ft".ToCharArray()), out var secondsFt)) - { - // Windows file time - // DateTime.FromFileTime returns as local time. - timestamp = DateTime.FromFileTime(secondsFt); - return true; - } - else - { - timestamp = new DateTime(1, 1, 1, 1, 1, 1); - return false; - } - } - - /// <summary> - /// Test if input is special parsing for Unix time, Unix time in milliseconds or File time. - /// </summary> - /// <param name="input">String with date/time</param> - /// <returns>True if yes, otherwise false</returns> - internal static bool IsSpecialInputParsing(string input) - { - return Regex.IsMatch(input, @"^.*(u|ums|ft)\d"); - } - - /// <summary> - /// Returns a CalendarWeekRule enum value based on the plugin setting. - /// </summary> - internal static CalendarWeekRule GetCalendarWeekRule(int pluginSetting) - { - switch (pluginSetting) - { - case 0: - return CalendarWeekRule.FirstDay; - case 1: - return CalendarWeekRule.FirstFullWeek; - case 2: - return CalendarWeekRule.FirstFourDayWeek; - default: - // Wrong json value and system setting (-1). - return DateTimeFormatInfo.CurrentInfo.CalendarWeekRule; - } - } - - /// <summary> - /// Returns a DayOfWeek enum value based on the FirstDayOfWeek plugin setting. - /// </summary> - internal static DayOfWeek GetFirstDayOfWeek(int pluginSetting) - { - switch (pluginSetting) - { - case 0: - return DayOfWeek.Sunday; - case 1: - return DayOfWeek.Monday; - case 2: - return DayOfWeek.Tuesday; - case 3: - return DayOfWeek.Wednesday; - case 4: - return DayOfWeek.Thursday; - case 5: - return DayOfWeek.Friday; - case 6: - return DayOfWeek.Saturday; - default: - // Wrong json value and system setting (-1). - return DateTimeFormatInfo.CurrentInfo.FirstDayOfWeek; - } - } -} - -/// <summary> -/// Type of time/date format -/// </summary> -internal enum FormatStringType -{ - Time, - Date, - DateTime, -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.png b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.png deleted file mode 100644 index ce22b2dd9c..0000000000 Binary files a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.png and /dev/null differ diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.svg b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.svg deleted file mode 100644 index 5028e6371f..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.svg +++ /dev/null @@ -1,46 +0,0 @@ -<svg width="17" height="18" viewBox="0 0 17 18" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M8.5 16.5C12.9183 16.5 16.5 12.9183 16.5 8.5C16.5 4.08172 12.9183 0.5 8.5 0.5C4.08172 0.5 0.5 4.08172 0.5 8.5C0.5 12.9183 4.08172 16.5 8.5 16.5Z" fill="url(#paint0_linear_1874_17772)"/> -<g style="mix-blend-mode:overlay"> -<mask id="mask0_1874_17772" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="17" height="17"> -<path d="M8.5 16.5C12.9183 16.5 16.5 12.9183 16.5 8.5C16.5 4.08172 12.9183 0.5 8.5 0.5C4.08172 0.5 0.5 4.08172 0.5 8.5C0.5 12.9183 4.08172 16.5 8.5 16.5Z" fill="url(#paint1_linear_1874_17772)"/> -</mask> -<g mask="url(#mask0_1874_17772)"> -<g opacity="0.4"> -<path d="M8.50093 16.9422C10.2322 16.9422 11.6356 13.1618 11.6356 8.49844C11.6356 3.83508 10.2322 0.0546875 8.50093 0.0546875C6.76967 0.0546875 5.36621 3.83508 5.36621 8.49844C5.36621 13.1618 6.76967 16.9422 8.50093 16.9422Z" stroke="white" stroke-width="0.888889" stroke-miterlimit="10"/> -<path d="M-1.27734 10.7227H17.3893" stroke="white" stroke-width="0.888889" stroke-miterlimit="10"/> -<path d="M-0.388672 6.27734H16.9447" stroke="white" stroke-width="0.888889" stroke-miterlimit="10"/> -</g> -</g> -</g> -<g filter="url(#filter0_dd_1874_17772)"> -<path fill-rule="evenodd" clip-rule="evenodd" d="M3.49414 8.79454C3.49414 10.7549 5.0596 12.318 6.95878 12.318C8.85796 12.318 10.4234 10.7549 10.4234 8.79454C10.4234 6.8342 8.85796 5.27105 6.95878 5.27105C5.0596 5.27105 3.49414 6.8342 3.49414 8.79454ZM2.27778 8.79454C2.27778 11.4123 4.37353 13.5344 6.95878 13.5344C8.07111 13.5344 9.09282 13.1415 9.89597 12.4854L13.1894 15.8203C13.4314 16.0653 13.8237 16.0653 14.0656 15.8203C14.3076 15.5753 14.3076 15.1781 14.0656 14.9331L10.7498 11.5756C11.3096 10.7944 11.6398 9.83353 11.6398 8.79454C11.6398 6.17679 9.54403 4.05469 6.95878 4.05469C4.37353 4.05469 2.27778 6.17679 2.27778 8.79454Z" fill="url(#paint2_linear_1874_17772)"/> -</g> -<defs> -<filter id="filter0_dd_1874_17772" x="1.5678" y="3.69991" width="13.3888" height="13.3683" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> -<feFlood flood-opacity="0" result="BackgroundImageFix"/> -<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> -<feOffset dy="0.354773"/> -<feGaussianBlur stdDeviation="0.354773"/> -<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0"/> -<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1874_17772"/> -<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> -<feOffset dy="0.0709546"/> -<feGaussianBlur stdDeviation="0.0354773"/> -<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/> -<feBlend mode="normal" in2="effect1_dropShadow_1874_17772" result="effect2_dropShadow_1874_17772"/> -<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_1874_17772" result="shape"/> -</filter> -<linearGradient id="paint0_linear_1874_17772" x1="4.05556" y1="3.16667" x2="15.6111" y2="13.8333" gradientUnits="userSpaceOnUse"> -<stop stop-color="#30CCD7"/> -<stop offset="1" stop-color="#0669BC"/> -</linearGradient> -<linearGradient id="paint1_linear_1874_17772" x1="4.05556" y1="3.16667" x2="15.6111" y2="13.8333" gradientUnits="userSpaceOnUse"> -<stop stop-color="#30CCD7"/> -<stop offset="1" stop-color="#0669BC"/> -</linearGradient> -<linearGradient id="paint2_linear_1874_17772" x1="3.84394" y1="4.05469" x2="14.6506" y2="16.7175" gradientUnits="userSpaceOnUse"> -<stop stop-color="#E7ECF1"/> -<stop offset="1" stop-color="#CBCCCD"/> -</linearGradient> -</defs> -</svg> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs deleted file mode 100644 index b83ba47a73..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text.Json; -using Microsoft.CmdPal.Ext.WebSearch.Commands; -using Microsoft.CmdPal.Ext.WebSearch.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; - -public class SettingsManager : JsonSettingsManager -{ - private readonly string _historyPath; - - private static readonly string _namespace = "websearch"; - - private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; - - private static readonly List<ChoiceSetSetting.Choice> _choices = - [ - new ChoiceSetSetting.Choice(Resources.history_none, Resources.history_none), - new ChoiceSetSetting.Choice(Resources.history_1, Resources.history_1), - new ChoiceSetSetting.Choice(Resources.history_5, Resources.history_5), - new ChoiceSetSetting.Choice(Resources.history_10, Resources.history_10), - new ChoiceSetSetting.Choice(Resources.history_20, Resources.history_20), - ]; - - private readonly ToggleSetting _globalIfURI = new( - Namespaced(nameof(GlobalIfURI)), - Resources.plugin_global_if_uri, - Resources.plugin_global_if_uri, - false); - - private readonly ChoiceSetSetting _showHistory = new( - Namespaced(nameof(ShowHistory)), - Resources.plugin_show_history, - Resources.plugin_show_history, - _choices); - - public bool GlobalIfURI => _globalIfURI.Value; - - public string ShowHistory => _showHistory.Value ?? string.Empty; - - internal static string SettingsJsonPath() - { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); - - // now, the state is just next to the exe - return Path.Combine(directory, "settings.json"); - } - - internal static string HistoryStateJsonPath() - { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); - - // now, the state is just next to the exe - return Path.Combine(directory, "websearch_history.json"); - } - - public void SaveHistory(HistoryItem historyItem) - { - if (historyItem == null) - { - return; - } - - try - { - List<HistoryItem> historyItems; - - // Check if the file exists and load existing history - if (File.Exists(_historyPath)) - { - var existingContent = File.ReadAllText(_historyPath); - historyItems = JsonSerializer.Deserialize<List<HistoryItem>>(existingContent) ?? []; - } - else - { - historyItems = []; - } - - // Add the new history item - historyItems.Add(historyItem); - - // Determine the maximum number of items to keep based on ShowHistory - if (int.TryParse(ShowHistory, out var maxHistoryItems) && maxHistoryItems > 0) - { - // Keep only the most recent `maxHistoryItems` items - while (historyItems.Count > maxHistoryItems) - { - historyItems.RemoveAt(0); // Remove the oldest item - } - } - - // Serialize the updated list back to JSON and save it - var historyJson = JsonSerializer.Serialize(historyItems); - File.WriteAllText(_historyPath, historyJson); - } - catch (Exception ex) - { - ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() }); - } - } - - public List<ListItem> LoadHistory() - { - try - { - if (!File.Exists(_historyPath)) - { - return []; - } - - // Read and deserialize JSON into a list of HistoryItem objects - var fileContent = File.ReadAllText(_historyPath); - var historyItems = JsonSerializer.Deserialize<List<HistoryItem>>(fileContent) ?? []; - - // Convert each HistoryItem to a ListItem - var listItems = new List<ListItem>(); - foreach (var historyItem in historyItems) - { - listItems.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, this)) - { - Title = historyItem.SearchString, - Subtitle = historyItem.Timestamp.ToString("g", CultureInfo.InvariantCulture), // Ensures consistent formatting - }); - } - - listItems.Reverse(); - return listItems; - } - catch (Exception ex) - { - ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() }); - return []; - } - } - - public SettingsManager() - { - FilePath = SettingsJsonPath(); - _historyPath = HistoryStateJsonPath(); - - Settings.Add(_globalIfURI); - Settings.Add(_showHistory); - - // Load settings from file upon initialization - LoadSettings(); - - Settings.SettingsChanged += (s, a) => this.SaveSettings(); - } - - private void ClearHistory() - { - try - { - if (File.Exists(_historyPath)) - { - // Delete the history file - File.Delete(_historyPath); - - // Log that the history was successfully cleared - ExtensionHost.LogMessage(new LogMessage() { Message = "History cleared successfully." }); - } - else - { - // Log that there was no history file to delete - ExtensionHost.LogMessage(new LogMessage() { Message = "No history file found to clear." }); - } - } - catch (Exception ex) - { - // Log any exception that occurs - ExtensionHost.LogMessage(new LogMessage() { Message = $"Failed to clear history: {ex}" }); - } - } - - public override void SaveSettings() - { - base.SaveSettings(); - try - { - if (ShowHistory == Resources.history_none) - { - ClearHistory(); - } - else if (int.TryParse(ShowHistory, out var maxHistoryItems) && maxHistoryItems > 0) - { - // Trim the history file if there are more items than the new limit - if (File.Exists(_historyPath)) - { - var existingContent = File.ReadAllText(_historyPath); - var historyItems = JsonSerializer.Deserialize<List<HistoryItem>>(existingContent) ?? []; - - // Check if trimming is needed - if (historyItems.Count > maxHistoryItems) - { - // Trim the list to keep only the most recent `maxHistoryItems` items - historyItems = historyItems.Skip(historyItems.Count - maxHistoryItems).ToList(); - - // Save the trimmed history back to the file - var trimmedHistoryJson = JsonSerializer.Serialize(historyItems); - File.WriteAllText(_historyPath, trimmedHistoryJson); - } - } - } - } - catch (Exception ex) - { - ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() }); - } - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs deleted file mode 100644 index 406f008a84..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using Microsoft.CmdPal.Ext.WebSearch.Commands; -using Microsoft.CmdPal.Ext.WebSearch.Helpers; -using Microsoft.CmdPal.Ext.WebSearch.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; - -namespace Microsoft.CmdPal.Ext.WebSearch.Pages; - -internal sealed partial class WebSearchListPage : DynamicListPage -{ - private readonly string _iconPath = string.Empty; - private readonly List<ListItem>? _historyItems; - private readonly SettingsManager _settingsManager; - private static readonly CompositeFormat PluginInBrowserName = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_in_browser_name); - private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); - private List<ListItem> allItems; - - public WebSearchListPage(SettingsManager settingsManager) - { - Name = Resources.command_item_title; - Title = Resources.command_item_title; - PlaceholderText = Resources.plugin_description; - Icon = IconHelpers.FromRelativePath("Assets\\WebSearch.png"); - allItems = [new(new NoOpCommand()) - { - Icon = IconHelpers.FromRelativePath("Assets\\WebSearch.png"), - Title = Properties.Resources.plugin_description, - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName), - } - ]; - Id = "com.microsoft.cmdpal.websearch"; - _settingsManager = settingsManager; - _historyItems = _settingsManager.ShowHistory != Resources.history_none ? _settingsManager.LoadHistory() : null; - if (_historyItems != null) - { - allItems.AddRange(_historyItems); - } - } - - public List<ListItem> Query(string query) - { - ArgumentNullException.ThrowIfNull(query); - IEnumerable<ListItem>? filteredHistoryItems = null; - - if (_historyItems != null) - { - filteredHistoryItems = _settingsManager.ShowHistory != Resources.history_none ? ListHelpers.FilterList(_historyItems, query).OfType<ListItem>() : null; - } - - var results = new List<ListItem>(); - - // empty query - if (string.IsNullOrEmpty(query)) - { - results.Add(new ListItem(new SearchWebCommand(string.Empty, _settingsManager)) - { - Title = Properties.Resources.plugin_description, - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginInBrowserName, BrowserInfo.Name ?? BrowserInfo.MSEdgeName), - Icon = new IconInfo(_iconPath), - }); - } - else - { - var searchTerm = query; - var result = new ListItem(new SearchWebCommand(searchTerm, _settingsManager)) - { - Title = searchTerm, - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName), - Icon = new IconInfo(_iconPath), - }; - results.Add(result); - } - - if (filteredHistoryItems != null) - { - results.AddRange(filteredHistoryItems); - } - - return results; - } - - public override void UpdateSearchText(string oldSearch, string newSearch) - { - allItems = [.. Query(newSearch)]; - RaiseItemsChanged(0); - } - - public override IListItem[] GetItems() => [.. allItems]; -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs deleted file mode 100644 index 7772d9b8b3..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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.Ext.WebSearch.Commands; -using Microsoft.CmdPal.Ext.WebSearch.Helpers; -using Microsoft.CmdPal.Ext.WebSearch.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.WebSearch; - -public partial class WebSearchCommandsProvider : CommandProvider -{ - private readonly SettingsManager _settingsManager = new(); - private readonly FallbackExecuteSearchItem _fallbackItem; - - public WebSearchCommandsProvider() - { - Id = "WebSearch"; - DisplayName = Resources.extension_name; - Icon = IconHelpers.FromRelativePath("Assets\\WebSearch.png"); - Settings = _settingsManager.Settings; - - _fallbackItem = new FallbackExecuteSearchItem(_settingsManager); - } - - public override ICommandItem[] TopLevelCommands() - { - return [new WebSearchTopLevelCommandItem(_settingsManager) - { - MoreCommands = [ - new CommandContextItem(Settings!.SettingsPage), - ], - } - ]; - } - - public override IFallbackCommandItem[]? FallbackCommands() => [_fallbackItem]; -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/SwitchToWindowCommand.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/SwitchToWindowCommand.cs deleted file mode 100644 index 0bfc1e0396..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/SwitchToWindowCommand.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Diagnostics; -using Microsoft.CmdPal.Ext.WindowWalker.Components; -using Microsoft.CmdPal.Ext.WindowWalker.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.WindowWalker.Commands; - -internal sealed partial class SwitchToWindowCommand : InvokableCommand -{ - private readonly Window? _window; - - public SwitchToWindowCommand(Window? window) - { - Name = Resources.switch_to_command_title; - _window = window; - if (_window != null) - { - var p = Process.GetProcessById((int)_window.Process.ProcessID); - if (p != null) - { - try - { - var processFileName = p.MainModule?.FileName; - Icon = new IconInfo(processFileName); - } - catch - { - } - } - } - } - - public override ICommandResult Invoke() - { - if (_window is null) - { - ExtensionHost.LogMessage(new LogMessage() { Message = "Cannot switch to the window, because it doesn't exist." }); - return CommandResult.Dismiss(); - } - - _window.SwitchToWindow(); - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/FuzzyMatching.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/FuzzyMatching.cs deleted file mode 100644 index 760cf9b4ec..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/FuzzyMatching.cs +++ /dev/null @@ -1,134 +0,0 @@ -// 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. - -// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; - -namespace Microsoft.CmdPal.Ext.WindowWalker.Components; - -/// <summary> -/// Class housing fuzzy matching methods -/// </summary> -internal static class FuzzyMatching -{ - /// <summary> - /// Finds the best match (the one with the most - /// number of letters adjacent to each other) and - /// returns the index location of each of the letters - /// of the matches - /// </summary> - /// <param name="text">The text to search inside of</param> - /// <param name="searchText">the text to search for</param> - /// <returns>returns the index location of each of the letters of the matches</returns> - internal static List<int> FindBestFuzzyMatch(string text, string searchText) - { - ArgumentNullException.ThrowIfNull(searchText); - - ArgumentNullException.ThrowIfNull(text); - - // Using CurrentCulture since this is user facing - searchText = searchText.ToLower(CultureInfo.CurrentCulture); - text = text.ToLower(CultureInfo.CurrentCulture); - - // Create a grid to march matches like - // eg. - // a b c a d e c f g - // a x x - // c x x - var matches = new bool[text.Length, searchText.Length]; - for (var firstIndex = 0; firstIndex < text.Length; firstIndex++) - { - for (var secondIndex = 0; secondIndex < searchText.Length; secondIndex++) - { - matches[firstIndex, secondIndex] = - searchText[secondIndex] == text[firstIndex] ? - true : - false; - } - } - - // use this table to get all the possible matches - List<List<int>> allMatches = GetAllMatchIndexes(matches); - - // return the score that is the max - var maxScore = allMatches.Count > 0 ? CalculateScoreForMatches(allMatches[0]) : 0; - List<int> bestMatch = allMatches.Count > 0 ? allMatches[0] : new List<int>(); - - foreach (var match in allMatches) - { - var score = CalculateScoreForMatches(match); - if (score > maxScore) - { - bestMatch = match; - maxScore = score; - } - } - - return bestMatch; - } - - /// <summary> - /// Gets all the possible matches to the search string with in the text - /// </summary> - /// <param name="matches"> a table showing the matches as generated by - /// a two dimensional array with the first dimension the text and the second - /// one the search string and each cell marked as an intersection between the two</param> - /// <returns>a list of the possible combinations that match the search text</returns> - internal static List<List<int>> GetAllMatchIndexes(bool[,] matches) - { - ArgumentNullException.ThrowIfNull(matches); - - List<List<int>> results = new List<List<int>>(); - - for (var secondIndex = 0; secondIndex < matches.GetLength(1); secondIndex++) - { - for (var firstIndex = 0; firstIndex < matches.GetLength(0); firstIndex++) - { - if (secondIndex == 0 && matches[firstIndex, secondIndex]) - { - results.Add(new List<int> { firstIndex }); - } - else if (matches[firstIndex, secondIndex]) - { - var tempList = results.Where(x => x.Count == secondIndex && x[x.Count - 1] < firstIndex).Select(x => x.ToList()).ToList(); - - foreach (var pathSofar in tempList) - { - pathSofar.Add(firstIndex); - } - - results.AddRange(tempList); - } - } - - results = results.Where(x => x.Count == secondIndex + 1).ToList(); - } - - return results.Where(x => x.Count == matches.GetLength(1)).ToList(); - } - - /// <summary> - /// Calculates the score for a string - /// </summary> - /// <param name="matches">the index of the matches</param> - /// <returns>an integer representing the score</returns> - internal static int CalculateScoreForMatches(List<int> matches) - { - ArgumentNullException.ThrowIfNull(matches); - - var score = 0; - - for (var currentIndex = 1; currentIndex < matches.Count; currentIndex++) - { - var previousIndex = currentIndex - 1; - - score -= matches[currentIndex] - matches[previousIndex]; - } - - return score == 0 ? -10000 : score; - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/SearchController.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/SearchController.cs deleted file mode 100644 index 2e5345bdfd..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/SearchController.cs +++ /dev/null @@ -1,150 +0,0 @@ -// 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. - -// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using Microsoft.CmdPal.Ext.WindowWalker.Helpers; - -namespace Microsoft.CmdPal.Ext.WindowWalker.Components; - -/// <summary> -/// Responsible for searching and finding matches for the strings provided. -/// Essentially the UI independent model of the application -/// </summary> -internal sealed class SearchController -{ - /// <summary> - /// the current search text - /// </summary> - private string searchText; - - /// <summary> - /// Open window search results - /// </summary> - private List<SearchResult>? searchMatches; - - /// <summary> - /// Singleton pattern - /// </summary> - private static SearchController? instance; - - /// <summary> - /// Gets or sets the current search text - /// </summary> - internal string SearchText - { - get => searchText; - - set => - searchText = value.ToLower(CultureInfo.CurrentCulture).Trim(); - } - - /// <summary> - /// Gets the open window search results - /// </summary> - internal List<SearchResult> SearchMatches => new List<SearchResult>(searchMatches ?? []).OrderByDescending(x => x.Score).ToList(); - - /// <summary> - /// Gets singleton Pattern - /// </summary> - internal static SearchController Instance - { - get - { - instance ??= new SearchController(); - - return instance; - } - } - - /// <summary> - /// Initializes a new instance of the <see cref="SearchController"/> class. - /// Initializes the search controller object - /// </summary> - private SearchController() - { - searchText = string.Empty; - } - - /// <summary> - /// Event handler for when the search text has been updated - /// </summary> - internal void UpdateSearchText(string searchText) - { - SearchText = searchText; - SyncOpenWindowsWithModel(); - } - - /// <summary> - /// Syncs the open windows with the OpenWindows Model - /// </summary> - internal void SyncOpenWindowsWithModel() - { - System.Diagnostics.Debug.Print("Syncing WindowSearch result with OpenWindows Model"); - - var snapshotOfOpenWindows = OpenWindows.Instance.Windows; - - searchMatches = string.IsNullOrWhiteSpace(SearchText) ? AllOpenWindows(snapshotOfOpenWindows) : FuzzySearchOpenWindows(snapshotOfOpenWindows); - } - - /// <summary> - /// Search method that matches the title of windows with the user search text - /// </summary> - /// <param name="openWindows">what windows are open</param> - /// <returns>Returns search results</returns> - private List<SearchResult> FuzzySearchOpenWindows(List<Window> openWindows) - { - List<SearchResult> result = []; - var searchStrings = new SearchString(searchText, SearchResult.SearchType.Fuzzy); - - foreach (var window in openWindows) - { - var titleMatch = FuzzyMatching.FindBestFuzzyMatch(window.Title, searchStrings.SearchText); - var processMatch = FuzzyMatching.FindBestFuzzyMatch(window.Process.Name ?? string.Empty, searchStrings.SearchText); - - if ((titleMatch.Count != 0 || processMatch.Count != 0) && window.Title.Length != 0) - { - result.Add(new SearchResult(window, titleMatch, processMatch, searchStrings.SearchType)); - } - } - - System.Diagnostics.Debug.Print("Found " + result.Count + " windows that match the search text"); - - return result; - } - - /// <summary> - /// Search method that matches all the windows with a title - /// </summary> - /// <param name="openWindows">what windows are open</param> - /// <returns>Returns search results</returns> - private List<SearchResult> AllOpenWindows(List<Window> openWindows) - { - List<SearchResult> result = []; - - foreach (var window in openWindows) - { - if (window.Title.Length != 0) - { - result.Add(new SearchResult(window)); - } - } - - return SettingsManager.Instance.InMruOrder - ? result.ToList() - : result - .OrderBy(w => w.Result.Title) - .ToList(); - } - - /// <summary> - /// Event args for a window list update event - /// </summary> - internal sealed class SearchResultUpdateEventArgs : EventArgs - { - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/SearchResult.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/SearchResult.cs deleted file mode 100644 index bfe51344ce..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/SearchResult.cs +++ /dev/null @@ -1,147 +0,0 @@ -// 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. - -// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/ -using System.Collections.Generic; - -namespace Microsoft.CmdPal.Ext.WindowWalker.Components; - -/// <summary> -/// Contains search result windows with each window including the reason why the result was included -/// </summary> -internal sealed class SearchResult -{ - /// <summary> - /// Gets the actual window reference for the search result - /// </summary> - internal Window Result - { - get; - private set; - } - - /// <summary> - /// Gets the list of indexes of the matching characters for the search in the title window - /// </summary> - internal List<int> SearchMatchesInTitle - { - get; - private set; - } - - /// <summary> - /// Gets the list of indexes of the matching characters for the search in the - /// name of the process - /// </summary> - internal List<int> SearchMatchesInProcessName - { - get; - private set; - } - - /// <summary> - /// Gets the type of match (shortcut, fuzzy or nothing) - /// </summary> - internal SearchType SearchResultMatchType - { - get; - private set; - } - - /// <summary> - /// Gets a score indicating how well this matches what we are looking for - /// </summary> - internal int Score - { - get; - private set; - } - - /// <summary> - /// Gets the source of where the best score was found - /// </summary> - internal TextType BestScoreSource - { - get; - private set; - } - - /// <summary> - /// Initializes a new instance of the <see cref="SearchResult"/> class. - /// Constructor - /// </summary> - internal SearchResult(Window window, List<int> matchesInTitle, List<int> matchesInProcessName, SearchType matchType) - { - Result = window; - SearchMatchesInTitle = matchesInTitle; - SearchMatchesInProcessName = matchesInProcessName; - SearchResultMatchType = matchType; - CalculateScore(); - } - - /// <summary> - /// Initializes a new instance of the <see cref="SearchResult"/> class. - /// </summary> - internal SearchResult(Window window) - { - Result = window; - SearchMatchesInTitle = new List<int>(); - SearchMatchesInProcessName = new List<int>(); - SearchResultMatchType = SearchType.Empty; - CalculateScore(); - } - - /// <summary> - /// Calculates the score for how closely this window matches the search string - /// </summary> - /// <remarks> - /// Higher Score is better - /// </remarks> - private void CalculateScore() - { - if (FuzzyMatching.CalculateScoreForMatches(SearchMatchesInProcessName) > - FuzzyMatching.CalculateScoreForMatches(SearchMatchesInTitle)) - { - Score = FuzzyMatching.CalculateScoreForMatches(SearchMatchesInProcessName); - BestScoreSource = TextType.ProcessName; - } - else - { - Score = FuzzyMatching.CalculateScoreForMatches(SearchMatchesInTitle); - BestScoreSource = TextType.WindowTitle; - } - } - - /// <summary> - /// The type of text that a string represents - /// </summary> - internal enum TextType - { - ProcessName, - WindowTitle, - } - - /// <summary> - /// The type of search - /// </summary> - internal enum SearchType - { - /// <summary> - /// the search string is empty, which means all open windows are - /// going to be returned - /// </summary> - Empty, - - /// <summary> - /// Regular fuzzy match search - /// </summary> - Fuzzy, - - /// <summary> - /// The user has entered text that has been matched to a shortcut - /// and the shortcut is now being searched - /// </summary> - Shortcut, - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/SearchString.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/SearchString.cs deleted file mode 100644 index 912dd04ceb..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/SearchString.cs +++ /dev/null @@ -1,45 +0,0 @@ -// 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. - -// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/ -namespace Microsoft.CmdPal.Ext.WindowWalker.Components; - -/// <summary> -/// A class to represent a search string -/// </summary> -/// <remarks>Class was added inorder to be able to attach various context data to -/// a search string</remarks> -internal sealed class SearchString -{ - /// <summary> - /// Gets where is the search string coming from (is it a shortcut - /// or direct string, etc...) - /// </summary> - internal SearchResult.SearchType SearchType - { - get; - private set; - } - - /// <summary> - /// Gets the actual text we are searching for - /// </summary> - internal string SearchText - { - get; - private set; - } - - /// <summary> - /// Initializes a new instance of the <see cref="SearchString"/> class. - /// Constructor - /// </summary> - /// <param name="searchText">text from search</param> - /// <param name="searchType">type of search</param> - internal SearchString(string searchText, SearchResult.SearchType searchType) - { - SearchText = searchText; - SearchType = searchType; - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Commands/CopySettingCommand.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Commands/CopySettingCommand.cs deleted file mode 100644 index 7f5fa1789e..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Commands/CopySettingCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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.Diagnostics; -using System.Linq; -using System.Resources; -using System.Text; -using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.WindowsSettings.Classes; -using Microsoft.CmdPal.Ext.WindowsSettings.Helpers; -using Microsoft.CmdPal.Ext.WindowsSettings.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.ApplicationModel.DataTransfer; -using Windows.Networking.NetworkOperators; -using Windows.UI; - -namespace Microsoft.CmdPal.Ext.WindowsSettings.Commands; - -internal sealed partial class CopySettingCommand : InvokableCommand -{ - private readonly WindowsSetting _entry; - - internal CopySettingCommand(WindowsSetting entry) - { - Name = Resources.CopyCommand; - Icon = new IconInfo("\xE8C8"); // Copy icon - _entry = entry; - } - - public override CommandResult Invoke() - { - ClipboardHelper.SetText(_entry.Command); - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Pages/WindowsSettingsListPage.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Pages/WindowsSettingsListPage.cs deleted file mode 100644 index 994db10445..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Pages/WindowsSettingsListPage.cs +++ /dev/null @@ -1,99 +0,0 @@ -// 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 Microsoft.CmdPal.Ext.WindowsSettings.Classes; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.WindowsSettings; - -internal sealed partial class WindowsSettingsListPage : DynamicListPage -{ - private readonly Classes.WindowsSettings _windowsSettings; - - public WindowsSettingsListPage(Classes.WindowsSettings windowsSettings) - { - Icon = IconHelpers.FromRelativePath("Assets\\WindowsSettings.svg"); - Name = "Windows Settings"; - Id = "com.microsoft.cmdpal.windowsSettings"; - _windowsSettings = windowsSettings; - } - - public List<ListItem> Query(string query) - { - if (_windowsSettings?.Settings is null) - { - return new List<ListItem>(0); - } - - var filteredList = _windowsSettings.Settings - .Where(Predicate) - .OrderBy(found => found.Name); - - var newList = ResultHelper.GetResultList(filteredList, query); - return newList; - - bool Predicate(WindowsSetting found) - { - if (string.IsNullOrWhiteSpace(query)) - { - // If no search string is entered skip query comparison. - return true; - } - - if (found.Name.Contains(query, StringComparison.CurrentCultureIgnoreCase)) - { - return true; - } - - if (!(found.Areas is null)) - { - foreach (var area in found.Areas) - { - // Search for areas on normal queries. - if (area.Contains(query, StringComparison.CurrentCultureIgnoreCase)) - { - return true; - } - - // Search for Area only on queries with action char. - if (area.Contains(query.Replace(":", string.Empty), StringComparison.CurrentCultureIgnoreCase) - && query.EndsWith(":", StringComparison.CurrentCultureIgnoreCase)) - { - return true; - } - } - } - - if (!(found.AltNames is null)) - { - foreach (var altName in found.AltNames) - { - if (altName.Contains(query, StringComparison.CurrentCultureIgnoreCase)) - { - return true; - } - } - } - - // Search by key char '>' for app name and settings path - return query.Contains('>') ? ResultHelper.FilterBySettingsPath(found, query) : false; - } - } - - public override void UpdateSearchText(string oldSearch, string newSearch) - { - RaiseItemsChanged(0); - } - - public override IListItem[] GetItems() - { - var items = Query(SearchText).ToArray(); - - return items; - } -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ApplicationActivationManager.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ApplicationActivationManager.cs deleted file mode 100644 index a074d9c86d..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ApplicationActivationManager.cs +++ /dev/null @@ -1,24 +0,0 @@ -// 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.CompilerServices; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; - -// Application Activation Manager Class -[ComImport] -[Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] -public class ApplicationActivationManager : IApplicationActivationManager -{ - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)/*, PreserveSig*/] - public extern IntPtr ActivateApplication([In] string appUserModelId, [In] string arguments, [In] ActivateOptions options, [Out] out uint processId); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public extern IntPtr ActivateForFile([In] string appUserModelId, [In] IntPtr /*IShellItemArray* */ itemArray, [In] string verb, [Out] out uint processId); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public extern IntPtr ActivateForProtocol([In] string appUserModelId, [In] IntPtr /* IShellItemArray* */itemArray, [Out] out uint processId); -} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs b/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs deleted file mode 100644 index 752aca5574..0000000000 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs +++ /dev/null @@ -1,86 +0,0 @@ -// 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 Microsoft.CmdPal.Ext.WindowsTerminal.Commands; -using Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; -using Microsoft.CmdPal.Ext.WindowsTerminal.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Microsoft.UI.Xaml.Media.Imaging; - -namespace Microsoft.CmdPal.Ext.WindowsTerminal.Pages; - -internal sealed partial class ProfilesListPage : ListPage -{ - private readonly TerminalQuery _terminalQuery = new(); - private readonly SettingsManager _terminalSettings; - private readonly Dictionary<string, BitmapImage> _logoCache = []; - - private bool showHiddenProfiles; - private bool openNewTab; - private bool openQuake; - - public ProfilesListPage(SettingsManager terminalSettings) - { - Icon = WindowsTerminalCommandsProvider.TerminalIcon; - Name = Resources.profiles_list_page_name; - _terminalSettings = terminalSettings; - } - -#pragma warning disable SA1108 - public List<ListItem> Query() - { - showHiddenProfiles = _terminalSettings.ShowHiddenProfiles; - openNewTab = _terminalSettings.OpenNewTab; - openQuake = _terminalSettings.OpenQuake; - - var profiles = _terminalQuery.GetProfiles(); - - var result = new List<ListItem>(); - - foreach (var profile in profiles) - { - if (profile.Hidden && !showHiddenProfiles) - { - continue; - } - - result.Add(new ListItem(new LaunchProfileCommand(profile.Terminal.AppUserModelId, profile.Name, profile.Terminal.LogoPath, openNewTab, openQuake)) - { - Title = profile.Name, - Subtitle = profile.Terminal.DisplayName, - MoreCommands = [ - new CommandContextItem(new LaunchProfileAsAdminCommand(profile.Terminal.AppUserModelId, profile.Name, openNewTab, openQuake)), - ], - - // Icon = () => GetLogo(profile.Terminal), - // Action = _ => - // { - // Launch(profile.Terminal.AppUserModelId, profile.Name); - // return true; - // }, - // ContextData = profile, -#pragma warning restore SA1108 - }); - } - - return result; - } - - public override IListItem[] GetItems() => Query().ToArray(); - - private BitmapImage GetLogo(TerminalPackage terminal) - { - var aumid = terminal.AppUserModelId; - - if (!_logoCache.TryGetValue(aumid, out var value)) - { - value = terminal.GetLogo(); - _logoCache.Add(aumid, value); - } - - return value; - } -} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSamplesPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSamplesPage.cs deleted file mode 100644 index 373a1f7891..0000000000 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSamplesPage.cs +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace SamplePagesExtension; - -public partial class EvilSamplesPage : ListPage -{ - private readonly IListItem[] _commands = [ - new ListItem(new EvilSampleListPage()) - { - Title = "List Page without items", - Subtitle = "Throws exception on GetItems", - }, - new ListItem(new ExplodeInFiveSeconds(false)) - { - Title = "Page that will throw an exception after loading it", - Subtitle = "Throws exception on GetItems _after_ a ItemsChanged", - }, - new ListItem(new ExplodeInFiveSeconds(true)) - { - Title = "Page that keeps throwing exceptions", - Subtitle = "Will throw every 5 seconds once you open it", - }, - new ListItem(new ExplodeOnPropChange()) - { - Title = "Throw in the middle of a PropChanged", - Subtitle = "Will throw every 5 seconds once you open it", - }, - new ListItem(new SelfImmolateCommand()) - { - Title = "Terminate this extension", - Subtitle = "Will exit this extension (while it's loaded!)", - }, - new ListItem(new NoOpCommand()) - { - Title = "I have lots of nulls", - Subtitle = null, - MoreCommands = null, - Tags = null, - Details = new Details() - { - Title = null, - HeroImage = null, - Metadata = null, - }, - }, - new ListItem(new NoOpCommand()) - { - Title = "I also have nulls", - Subtitle = null, - MoreCommands = null, - Details = new Details() - { - Title = null, - HeroImage = null, - Metadata = [new DetailsElement() { Key = "Oops all nulls", Data = new DetailsTags() { Tags = null } }], - }, - }, - new ListItem(new AnonymousCommand(action: () => - { - ToastStatusMessage toast = new("I should appear immediately"); - toast.Show(); - Thread.Sleep(5000); - }) { Result = CommandResult.KeepOpen() }) - { - Title = "I take just forever to return something", - Subtitle = "The toast should appear immediately.", - MoreCommands = null, - Details = new Details() - { - Body = "This is a test for GH#512. If it doesn't appear immediately, it's likely InvokeCommand is happening on the UI thread.", - }, - } - ]; - - public EvilSamplesPage() - { - Name = "Evil Samples"; - Icon = new IconInfo("👿"); // Info - } - - public override IListItem[] GetItems() => _commands; -} - -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] -internal sealed partial class ExplodeOnPropChange : ListPage -{ - private bool _explode; - - public override string Title - { - get => _explode ? Commands[9001].Title : base.Title; - set => base.Title = value; - } - - private IListItem[] Commands => [ - new ListItem(new NoOpCommand()) - { - Title = "This page will explode in five seconds!", - Subtitle = "I'll change my Name, then explode", - }, - ]; - - public ExplodeOnPropChange() - { - Icon = new IconInfo(string.Empty); - Name = "Open"; - } - - public override IListItem[] GetItems() - { - _ = Task.Run(() => - { - Thread.Sleep(1000); - Title = "Ready? 3..."; - Thread.Sleep(1000); - Title = "Ready? 2..."; - Thread.Sleep(1000); - Title = "Ready? 1..."; - Thread.Sleep(1000); - _explode = true; - Title = "boom"; - }); - return Commands; - } -} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleDynamicListPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleDynamicListPage.cs deleted file mode 100644 index c284c7d784..0000000000 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleDynamicListPage.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace SamplePagesExtension; - -internal sealed partial class SampleDynamicListPage : DynamicListPage -{ - public SampleDynamicListPage() - { - Icon = new IconInfo(string.Empty); - Name = "Dynamic List"; - IsLoading = true; - } - - public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(newSearch.Length); - - public override IListItem[] GetItems() - { - var items = SearchText.ToCharArray().Select(ch => new ListItem(new NoOpCommand()) { Title = ch.ToString() }).ToArray(); - if (items.Length == 0) - { - items = [new ListItem(new NoOpCommand()) { Title = "Start typing in the search box" }]; - } - - if (items.Length > 0) - { - items[0].Subtitle = "Notice how the number of items changes for this page when you type in the filter box"; - } - - return items; - } -} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs deleted file mode 100644 index 3f65bef942..0000000000 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs +++ /dev/null @@ -1,97 +0,0 @@ -// 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.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace SamplePagesExtension; - -internal sealed partial class SampleListPage : ListPage -{ - public SampleListPage() - { - Icon = new IconInfo("\uEA37"); - Name = "Sample List Page"; - } - - public override IListItem[] GetItems() - { - var confirmOnceArgs = new ConfirmationArgs - { - PrimaryCommand = new AnonymousCommand( - () => - { - var t = new ToastStatusMessage("The dialog was confirmed"); - t.Show(); - }) - { - Name = "Confirm", - Result = CommandResult.KeepOpen(), - }, - Title = "You can set a title for the dialog", - Description = "Are you really sure you want to do the thing?", - }; - var confirmTwiceArgs = new ConfirmationArgs - { - PrimaryCommand = new AnonymousCommand(() => { }) - { - Name = "How sure are you?", - Result = CommandResult.Confirm(confirmOnceArgs), - }, - Title = "You can ask twice too", - Description = "You probably don't want to though, that'd be annoying.", - }; - - return [ - new ListItem(new NoOpCommand()) - { - Title = "This is a basic item in the list", - Subtitle = "I don't do anything though", - }, - new ListItem(new SampleListPageWithDetails()) - { - Title = "This item will take you to another page", - Subtitle = "This allows for nested lists of items", - }, - new ListItem(new OpenUrlCommand("https://github.com/microsoft/powertoys")) - { - Title = "Or you can go to links", - Subtitle = "This takes you to the PowerToys repo on GitHub", - }, - new ListItem(new SampleMarkdownPage()) - { - Title = "Items can have tags", - Subtitle = "and I'll take you to a page with markdown content", - Tags = [new Tag("Sample Tag")], - }, - new ListItem(new SendMessageCommand()) - { - Title = "I send lots of messages", - Subtitle = "Status messages can be used to provide feedback to the user in-app", - }, - new SendSingleMessageItem(), - new ListItem(new IndeterminateProgressMessageCommand()) - { - Title = "Do a thing with a spinner", - Subtitle = "Messages can have progress spinners, to indicate something is happening in the background", - }, - new ListItem( - new AnonymousCommand(() => { }) - { - Result = CommandResult.Confirm(confirmOnceArgs), - }) - { - Title = "Confirm before doing something", - }, - new ListItem( - new AnonymousCommand(() => { }) - { - Result = CommandResult.Confirm(confirmTwiceArgs), - }) - { - Title = "Confirm twice before doing something", - } - ]; - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/IFileService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/IFileService.cs deleted file mode 100644 index b9348eb520..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/IFileService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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.Common.Contracts; - -public interface IFileService -{ - T Read<T>(string folderPath, string fileName); - - void Save<T>(string folderPath, string fileName, T content); - - void Delete(string folderPath, string fileName); -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/ILocalSettingsService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/ILocalSettingsService.cs deleted file mode 100644 index 2350050e3e..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/ILocalSettingsService.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Threading.Tasks; - -namespace Microsoft.CmdPal.Common.Contracts; - -public interface ILocalSettingsService -{ - Task<bool> HasSettingAsync(string key); - - Task<T?> ReadSettingAsync<T>(string key); - - Task SaveSettingAsync<T>(string key, T value); -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/ApplicationExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/ApplicationExtensions.cs deleted file mode 100644 index a975083c7c..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/ApplicationExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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.Common.Services; -using Microsoft.UI.Xaml; - -namespace Microsoft.CmdPal.Common.Extensions; - -/// <summary> -/// Extension class implementing extension methods for <see cref="Application"/>. -/// </summary> -public static class ApplicationExtensions -{ - /// <summary> - /// Get registered services at the application level from anywhere in the - /// application. - /// - /// Note: - /// https://learn.microsoft.com/uwp/api/windows.ui.xaml.application.current?view=winrt-22621#windows-ui-xaml-application-current - /// "Application is a singleton that implements the static Current property - /// to provide shared access to the Application instance for the current - /// application. The singleton pattern ensures that state managed by - /// Application, including shared resources and properties, is available - /// from a single, shared location." - /// - /// Example of usage: - /// <code> - /// Application.Current.GetService<T>() - /// </code> - /// </summary> - /// <typeparam name="T">Service type.</typeparam> - /// <param name="application">Current application.</param> - /// <returns>Service reference.</returns> - public static T GetService<T>(this Application application) - where T : class - { - return (application as IApp)!.GetService<T>(); - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/IHostExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/IHostExtensions.cs deleted file mode 100644 index 660dcd2931..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/IHostExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace Microsoft.CmdPal.Common.Extensions; - -public static class IHostExtensions -{ - /// <summary> - /// <inheritdoc cref="ActivatorUtilities.CreateInstance(IServiceProvider, Type, object[])"/> - /// </summary> - public static T CreateInstance<T>(this IHost host, params object[] parameters) - { - return ActivatorUtilities.CreateInstance<T>(host.Services, parameters); - } - - /// <summary> - /// Gets the service object for the specified type, or throws an exception - /// if type was not registered. - /// </summary> - /// <typeparam name="T">Service type</typeparam> - /// <param name="host">Host object</param> - /// <returns>Service object</returns> - /// <exception cref="ArgumentException">Throw an exception if the specified - /// type is not registered</exception> - public static T GetService<T>(this IHost host) - where T : class - { - if (host.Services.GetService(typeof(T)) is not T service) - { - throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices."); - } - - return service; - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/Json.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/Json.cs deleted file mode 100644 index d865e10bdb..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/Json.cs +++ /dev/null @@ -1,36 +0,0 @@ -// 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.IO; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; - -namespace Microsoft.CmdPal.Common.Helpers; - -public static class Json -{ - public static async Task<T> ToObjectAsync<T>(string value) - { - if (typeof(T) == typeof(bool)) - { - return (T)(object)bool.Parse(value); - } - - await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(value)); - return (await JsonSerializer.DeserializeAsync<T>(stream))!; - } - - public static async Task<string> StringifyAsync<T>(T value) - { - if (typeof(T) == typeof(bool)) - { - return value!.ToString()!.ToLowerInvariant(); - } - - await using var stream = new MemoryStream(); - await JsonSerializer.SerializeAsync(stream, value); - return Encoding.UTF8.GetString(stream.ToArray()); - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs deleted file mode 100644 index 46dce07e5e..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs +++ /dev/null @@ -1,56 +0,0 @@ -// 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.Security.Principal; -using Windows.Win32; -using Windows.Win32.Foundation; - -namespace Microsoft.CmdPal.Common.Helpers; - -public static class RuntimeHelper -{ - public static bool IsMSIX - { - get - { - // TODO: for whatever reason, when I ported this into the PT - // codebase, this no longer compiled. We're only ever using it for - // the hacked up settings and ignoring it anyways, so I'm leaving - // it commented out for now. - // - // See also: - // * https://github.com/microsoft/win32metadata/commit/6fee67ba73bfe1b126ce524f7de8d367f0317715 - // * https://github.com/microsoft/win32metadata/issues/1311 - // uint length = 0; - // return PInvoke.GetCurrentPackageFullName(ref length, null) != WIN32_ERROR.APPMODEL_ERROR_NO_PACKAGE; -#pragma warning disable IDE0025 // Use expression body for property - return true; -#pragma warning restore IDE0025 // Use expression body for property - } - } - - public static bool IsOnWindows11 - { - get - { - var version = Environment.OSVersion.Version; - return version.Major >= 10 && version.Build >= 22000; - } - } - - public static bool IsCurrentProcessRunningAsAdmin() - { - var identity = WindowsIdentity.GetCurrent(); - return identity.Owner?.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid) ?? false; - } - - public static void VerifyCurrentProcessRunningAsAdmin() - { - if (!IsCurrentProcessRunningAsAdmin()) - { - throw new UnauthorizedAccessException("This operation requires elevated privileges."); - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Microsoft.CmdPal.Common.csproj b/src/modules/cmdpal/Microsoft.CmdPal.Common/Microsoft.CmdPal.Common.csproj deleted file mode 100644 index 970df0df58..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Microsoft.CmdPal.Common.csproj +++ /dev/null @@ -1,33 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <PropertyGroup> - <RootNamespace>Microsoft.CmdPal.Common</RootNamespace> - <Nullable>enable</Nullable> - <UseWinUI>true</UseWinUI> - <LangVersion>preview</LangVersion> - - <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\</OutputPath> - <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> - <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> - <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> - <ProjectPriFileName>Microsoft.CmdPal.Common.pri</ProjectPriFileName> - </PropertyGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Hosting" /> - <PackageReference Include="System.Text.Json" /> - - <PackageReference Include="Microsoft.Windows.CsWin32"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> - </PackageReference> - <PackageReference Include="Microsoft.WindowsAppSDK" /> - <PackageReference Include="Microsoft.Web.WebView2" /> - <!-- This line forces the WebView2 version used by Windows App SDK to be the one we expect from Directory.Packages.props . --> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> - </ItemGroup> - -</Project> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Models/LocalSettingsOptions.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Models/LocalSettingsOptions.cs deleted file mode 100644 index bae7422878..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Models/LocalSettingsOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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.Common.Models; - -public class LocalSettingsOptions -{ - public string? ApplicationDataFolder - { - get; set; - } - - public string? LocalSettingsFile - { - get; set; - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/FileService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/FileService.cs deleted file mode 100644 index cc6ef96098..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/FileService.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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.IO; -using System.Text; -using System.Text.Json; -using Microsoft.CmdPal.Common.Contracts; - -namespace Microsoft.CmdPal.Common.Services; - -public class FileService : IFileService -{ - private static readonly Encoding _encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - -#pragma warning disable CS8603 // Possible null reference return. - public T Read<T>(string folderPath, string fileName) - { - var path = Path.Combine(folderPath, fileName); - if (File.Exists(path)) - { - using var fileStream = File.OpenText(path); - return JsonSerializer.Deserialize<T>(fileStream.BaseStream); - } - - return default; - } -#pragma warning restore CS8603 // Possible null reference return. - - public void Save<T>(string folderPath, string fileName, T content) - { - if (!Directory.Exists(folderPath)) - { - Directory.CreateDirectory(folderPath); - } - - var fileContent = JsonSerializer.Serialize(content); - File.WriteAllText(Path.Combine(folderPath, fileName), fileContent, _encoding); - } - - public void Delete(string folderPath, string fileName) - { - if (fileName != null && File.Exists(Path.Combine(folderPath, fileName))) - { - File.Delete(Path.Combine(folderPath, fileName)); - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IApp.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IApp.cs deleted file mode 100644 index 92980dfaff..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IApp.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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.Common.Services; - -/// <summary> -/// Interface for the current application singleton object exposing the API -/// that can be accessed from anywhere in the application. -/// </summary> -public interface IApp -{ - /// <summary> - /// Gets services registered at the application level. - /// </summary> - public T GetService<T>() - where T : class; -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/LocalSettingsService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/LocalSettingsService.cs deleted file mode 100644 index e4cd2a174b..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/LocalSettingsService.cs +++ /dev/null @@ -1,120 +0,0 @@ -// 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.IO; -using System.Threading.Tasks; -using Microsoft.CmdPal.Common.Contracts; -using Microsoft.CmdPal.Common.Helpers; -using Microsoft.CmdPal.Common.Models; -using Microsoft.Extensions.Options; -using Windows.Storage; - -namespace Microsoft.CmdPal.Common.Services; - -public class LocalSettingsService : ILocalSettingsService -{ - // TODO! for now, we're hardcoding the path as effectively: - // %localappdata%\CmdPal\LocalSettings.json - private const string DefaultApplicationDataFolder = "CmdPal"; - private const string DefaultLocalSettingsFile = "LocalSettings.json"; - - private readonly IFileService _fileService; - private readonly LocalSettingsOptions _options; - - private readonly string _localApplicationData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - private readonly string _applicationDataFolder; - private readonly string _localSettingsFile; - - private readonly bool _isMsix; - - private Dictionary<string, object> _settings; - private bool _isInitialized; - - public LocalSettingsService(IFileService fileService, IOptions<LocalSettingsOptions> options) - { - _isMsix = false; // RuntimeHelper.IsMSIX; - - _fileService = fileService; - _options = options.Value; - - _applicationDataFolder = Path.Combine(_localApplicationData, _options.ApplicationDataFolder ?? DefaultApplicationDataFolder); - _localSettingsFile = _options.LocalSettingsFile ?? DefaultLocalSettingsFile; - - _settings = new Dictionary<string, object>(); - } - - private async Task InitializeAsync() - { - if (!_isInitialized) - { - _settings = await Task.Run(() => _fileService.Read<Dictionary<string, object>>(_applicationDataFolder, _localSettingsFile)) ?? new Dictionary<string, object>(); - - _isInitialized = true; - } - } - - public async Task<bool> HasSettingAsync(string key) - { - if (_isMsix) - { - return ApplicationData.Current.LocalSettings.Values.ContainsKey(key); - } - else - { - await InitializeAsync(); - - if (_settings != null) - { - return _settings.ContainsKey(key); - } - } - - return false; - } - - public async Task<T?> ReadSettingAsync<T>(string key) - { - if (_isMsix) - { - if (ApplicationData.Current.LocalSettings.Values.TryGetValue(key, out var obj)) - { - return await Json.ToObjectAsync<T>((string)obj); - } - } - else - { - await InitializeAsync(); - - if (_settings != null && _settings.TryGetValue(key, out var obj)) - { - var s = obj.ToString(); - - if (s != null) - { - return await Json.ToObjectAsync<T>(s); - } - } - } - - return default; - } - - public async Task SaveSettingAsync<T>(string key, T value) - { - if (_isMsix) - { - ApplicationData.Current.LocalSettings.Values[key] = await Json.StringifyAsync(value!); - } - else - { - await InitializeAsync(); - - _settings[key] = await Json.StringifyAsync(value!); - - await Task.Run(() => _fileService.Save(_applicationDataFolder, _localSettingsFile, _settings)); - } - } -} 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 2d5fd66145..4c997266c6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs @@ -4,7 +4,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels.Messages; namespace Microsoft.CmdPal.UI.ViewModels; @@ -35,10 +35,11 @@ public partial class AliasManager : ObservableObject try { var topLevelCommand = _topLevelCommandManager.LookupCommand(alias.CommandId); - if (topLevelCommand != null) + if (topLevelCommand is not null) { WeakReferenceMessenger.Default.Send<ClearSearchMessage>(); - WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(topLevelCommand)); + + WeakReferenceMessenger.Default.Send<PerformCommandMessage>(topLevelCommand.GetPerformCommandMessage()); return true; } } @@ -87,7 +88,7 @@ public partial class AliasManager : ObservableObject } // If we already have _this exact alias_, do nothing - if (newAlias != null && + if (newAlias is not null && _aliases.TryGetValue(newAlias.SearchPrefix, out var existingAlias)) { if (existingAlias.CommandId == commandId) @@ -96,14 +97,27 @@ public partial class AliasManager : ObservableObject } } - // Look for the old alias, and remove it List<CommandAlias> toRemove = []; foreach (var kv in _aliases) { + // Look for the old aliases for the command, and remove it if (kv.Value.CommandId == commandId) { toRemove.Add(kv.Value); } + + // Look for the alias belonging to another command, and remove it + if (newAlias is not null && kv.Value.Alias == newAlias.Alias && kv.Value.CommandId != commandId) + { + toRemove.Add(kv.Value); + + // Remove alias from other TopLevelViewModels it may be assigned to + var topLevelCommand = _topLevelCommandManager.LookupCommand(kv.Value.CommandId); + if (topLevelCommand is not null) + { + topLevelCommand.AliasText = string.Empty; + } + } } foreach (var alias in toRemove) @@ -112,7 +126,7 @@ public partial class AliasManager : ObservableObject _aliases.Remove(alias.SearchPrefix); } - if (newAlias != null) + if (newAlias is not null) { AddAlias(newAlias); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs index 69d38a8655..b445bf881d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs @@ -3,10 +3,12 @@ // See the LICENSE file in the project root for more information. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CommunityToolkit.Mvvm.ComponentModel; +using ManagedCommon; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; @@ -21,7 +23,11 @@ public partial class AppStateModel : ObservableObject /////////////////////////////////////////////////////////////////////////// // STATE HERE - public RecentCommandsManager RecentCommands { get; private set; } = new(); + // Make sure that you make the setters public (JsonSerializer.Deserialize will fail silently otherwise)! + // Make sure that any new types you add are added to JsonSerializationContext! + public RecentCommandsManager RecentCommands { get; set; } = new(); + + public List<string> RunHistory { get; set; } = []; // END SETTINGS /////////////////////////////////////////////////////////////////////////// @@ -35,7 +41,7 @@ public partial class AppStateModel : ObservableObject { if (string.IsNullOrEmpty(FilePath)) { - throw new InvalidOperationException($"You must set a valid {nameof(SettingsModel.FilePath)} before calling {nameof(LoadState)}"); + throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(LoadState)}"); } if (!File.Exists(FilePath)) @@ -49,9 +55,9 @@ public partial class AppStateModel : ObservableObject // Read the JSON content from the file var jsonContent = File.ReadAllText(FilePath); - var loaded = JsonSerializer.Deserialize<AppStateModel>(jsonContent, _deserializerOptions); + var loaded = JsonSerializer.Deserialize<AppStateModel>(jsonContent, JsonSerializationContext.Default.AppStateModel); - Debug.WriteLine(loaded != null ? "Loaded settings file" : "Failed to parse"); + Debug.WriteLine(loaded is not null ? "Loaded settings file" : "Failed to parse"); return loaded ?? new(); } @@ -73,43 +79,84 @@ public partial class AppStateModel : ObservableObject try { // Serialize the main dictionary to JSON and save it to the file - var settingsJson = JsonSerializer.Serialize(model, _serializerOptions); + var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.AppStateModel!); - // Is it valid JSON? - if (JsonNode.Parse(settingsJson) is JsonObject newSettings) + // validate JSON + if (JsonNode.Parse(settingsJson) is not JsonObject newSettings) { - // Now, read the existing content from the file - var oldContent = File.Exists(FilePath) ? File.ReadAllText(FilePath) : "{}"; + Logger.LogError("Failed to parse app state as a JsonObject."); + return; + } - // Is it valid JSON? - if (JsonNode.Parse(oldContent) is JsonObject savedSettings) - { - foreach (var item in newSettings) - { - savedSettings[item.Key] = item.Value != null ? item.Value.DeepClone() : null; - } + // read previous settings + if (!TryReadSavedState(out var savedSettings)) + { + savedSettings = new JsonObject(); + } - var serialized = savedSettings.ToJsonString(_serializerOptions); - File.WriteAllText(FilePath, serialized); + // merge new settings into old ones + foreach (var item in newSettings) + { + savedSettings[item.Key] = item.Value?.DeepClone(); + } - // TODO: Instead of just raising the event here, we should - // have a file change watcher on the settings file, and - // reload the settings then - model.StateChanged?.Invoke(model, null); - } - else - { - Debug.WriteLine("Failed to parse settings file as JsonObject."); - } + var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.AppStateModel!.Options); + File.WriteAllText(FilePath, serialized); + + // TODO: Instead of just raising the event here, we should + // have a file change watcher on the settings file, and + // reload the settings then + model.StateChanged?.Invoke(model, null); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save application state to {FilePath}:", ex); + } + } + + private static bool TryReadSavedState([NotNullWhen(true)] out JsonObject? savedSettings) + { + savedSettings = null; + + // read existing content from the file + string oldContent; + try + { + if (File.Exists(FilePath)) + { + oldContent = File.ReadAllText(FilePath); } else { - Debug.WriteLine("Failed to parse settings file as JsonObject."); + // file doesn't exist (might not have been created yet), so consider this a success + // and return empty settings + savedSettings = new JsonObject(); + return true; } } catch (Exception ex) { - Debug.WriteLine(ex.ToString()); + Logger.LogWarning($"Failed to read app state file {FilePath}:\n{ex}"); + return false; + } + + // detect empty file, just for sake of logging + if (string.IsNullOrWhiteSpace(oldContent)) + { + Logger.LogInfo($"App state file is empty: {FilePath}"); + return false; + } + + // is it valid JSON? + try + { + savedSettings = JsonNode.Parse(oldContent) as JsonObject; + return savedSettings != null; + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to parse app state from {FilePath}:\n{ex}"); + return false; } } @@ -121,19 +168,4 @@ public partial class AppStateModel : ObservableObject // now, the settings is just next to the exe return Path.Combine(directory, "state.json"); } - - private static readonly JsonSerializerOptions _serializerOptions = new() - { - WriteIndented = true, - Converters = { new JsonStringEnumConverter() }, - }; - - private static readonly JsonSerializerOptions _deserializerOptions = new() - { - PropertyNameCaseInsensitive = true, - IncludeFields = true, - AllowTrailingCommas = true, - PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, - ReadCommentHandling = JsonCommentHandling.Skip, - }; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs new file mode 100644 index 0000000000..4de0215311 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs @@ -0,0 +1,534 @@ +// 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.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.WinUI; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; +using Windows.UI.ViewManagement; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable +{ + private static readonly Color DefaultTintColor = Color.FromArgb(255, 0, 120, 212); + + private static readonly ObservableCollection<Color> WindowsColorSwatches = [ + + // row 0 + Color.FromArgb(255, 255, 185, 0), // #ffb900 + Color.FromArgb(255, 255, 140, 0), // #ff8c00 + Color.FromArgb(255, 247, 99, 12), // #f7630c + Color.FromArgb(255, 202, 80, 16), // #ca5010 + Color.FromArgb(255, 218, 59, 1), // #da3b01 + Color.FromArgb(255, 239, 105, 80), // #ef6950 + + // row 1 + Color.FromArgb(255, 209, 52, 56), // #d13438 + Color.FromArgb(255, 255, 67, 67), // #ff4343 + Color.FromArgb(255, 231, 72, 86), // #e74856 + Color.FromArgb(255, 232, 17, 35), // #e81123 + Color.FromArgb(255, 234, 0, 94), // #ea005e + Color.FromArgb(255, 195, 0, 82), // #c30052 + + // row 2 + Color.FromArgb(255, 227, 0, 140), // #e3008c + Color.FromArgb(255, 191, 0, 119), // #bf0077 + Color.FromArgb(255, 194, 57, 179), // #c239b3 + Color.FromArgb(255, 154, 0, 137), // #9a0089 + Color.FromArgb(255, 0, 120, 212), // #0078d4 + Color.FromArgb(255, 0, 99, 177), // #0063b1 + + // row 3 + Color.FromArgb(255, 142, 140, 216), // #8e8cd8 + Color.FromArgb(255, 107, 105, 214), // #6b69d6 + Color.FromArgb(255, 135, 100, 184), // #8764b8 + Color.FromArgb(255, 116, 77, 169), // #744da9 + Color.FromArgb(255, 177, 70, 194), // #b146c2 + Color.FromArgb(255, 136, 23, 152), // #881798 + + // row 4 + Color.FromArgb(255, 0, 153, 188), // #0099bc + Color.FromArgb(255, 45, 125, 154), // #2d7d9a + Color.FromArgb(255, 0, 183, 195), // #00b7c3 + Color.FromArgb(255, 3, 131, 135), // #038387 + Color.FromArgb(255, 0, 178, 148), // #00b294 + Color.FromArgb(255, 1, 133, 116), // #018574 + + // row 5 + Color.FromArgb(255, 0, 204, 106), // #00cc6a + Color.FromArgb(255, 16, 137, 62), // #10893e + Color.FromArgb(255, 122, 117, 116), // #7a7574 + Color.FromArgb(255, 93, 90, 88), // #5d5a58 + Color.FromArgb(255, 104, 118, 138), // #68768a + Color.FromArgb(255, 81, 92, 107), // #515c6b + + // row 6 + Color.FromArgb(255, 86, 124, 115), // #567c73 + Color.FromArgb(255, 72, 104, 96), // #486860 + Color.FromArgb(255, 73, 130, 5), // #498205 + Color.FromArgb(255, 16, 124, 16), // #107c10 + Color.FromArgb(255, 118, 118, 118), // #767676 + Color.FromArgb(255, 76, 74, 72), // #4c4a48 + + // row 7 + Color.FromArgb(255, 105, 121, 126), // #69797e + Color.FromArgb(255, 74, 84, 89), // #4a5459 + Color.FromArgb(255, 100, 124, 100), // #647c64 + Color.FromArgb(255, 82, 94, 84), // #525e54 + Color.FromArgb(255, 132, 117, 69), // #847545 + Color.FromArgb(255, 126, 115, 95), // #7e735f + ]; + + private readonly SettingsModel _settings; + private readonly UISettings _uiSettings; + private readonly IThemeService _themeService; + private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); + private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread(); + + private ElementTheme? _elementThemeOverride; + private Color _currentSystemAccentColor; + + public ObservableCollection<Color> Swatches => WindowsColorSwatches; + + public int ThemeIndex + { + get => (int)_settings.Theme; + set => Theme = (UserTheme)value; + } + + public UserTheme Theme + { + get => _settings.Theme; + set + { + if (_settings.Theme != value) + { + _settings.Theme = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ThemeIndex)); + Save(); + } + } + } + + public ColorizationMode ColorizationMode + { + get => _settings.ColorizationMode; + set + { + if (_settings.ColorizationMode != value) + { + _settings.ColorizationMode = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ColorizationModeIndex)); + OnPropertyChanged(nameof(IsCustomTintVisible)); + OnPropertyChanged(nameof(IsColorIntensityVisible)); + OnPropertyChanged(nameof(IsImageTintIntensityVisible)); + OnPropertyChanged(nameof(EffectiveTintIntensity)); + OnPropertyChanged(nameof(IsBackgroundControlsVisible)); + OnPropertyChanged(nameof(IsNoBackgroundVisible)); + OnPropertyChanged(nameof(IsAccentColorControlsVisible)); + OnPropertyChanged(nameof(IsResetButtonVisible)); + + if (value == ColorizationMode.WindowsAccentColor) + { + ThemeColor = _currentSystemAccentColor; + } + + IsColorizationDetailsExpanded = value != ColorizationMode.None; + + Save(); + } + } + } + + public int ColorizationModeIndex + { + get => (int)_settings.ColorizationMode; + set => ColorizationMode = (ColorizationMode)value; + } + + public Color ThemeColor + { + get => _settings.CustomThemeColor; + set + { + if (_settings.CustomThemeColor != value) + { + _settings.CustomThemeColor = value; + + OnPropertyChanged(); + + if (ColorIntensity == 0) + { + ColorIntensity = 100; + } + + Save(); + } + } + } + + public int ColorIntensity + { + get => _settings.CustomThemeColorIntensity; + set + { + _settings.CustomThemeColorIntensity = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(EffectiveTintIntensity)); + Save(); + } + } + + public int BackgroundImageTintIntensity + { + get => _settings.BackgroundImageTintIntensity; + set + { + _settings.BackgroundImageTintIntensity = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(EffectiveTintIntensity)); + Save(); + } + } + + public string BackgroundImagePath + { + get => _settings.BackgroundImagePath ?? string.Empty; + set + { + if (_settings.BackgroundImagePath != value) + { + _settings.BackgroundImagePath = value; + OnPropertyChanged(); + + if (BackgroundImageOpacity == 0) + { + BackgroundImageOpacity = 100; + } + + Save(); + } + } + } + + public int BackgroundImageOpacity + { + get => _settings.BackgroundImageOpacity; + set + { + if (_settings.BackgroundImageOpacity != value) + { + _settings.BackgroundImageOpacity = value; + OnPropertyChanged(); + Save(); + } + } + } + + public int BackgroundImageBrightness + { + get => _settings.BackgroundImageBrightness; + set + { + if (_settings.BackgroundImageBrightness != value) + { + _settings.BackgroundImageBrightness = value; + OnPropertyChanged(); + Save(); + } + } + } + + public int BackgroundImageBlurAmount + { + get => _settings.BackgroundImageBlurAmount; + set + { + if (_settings.BackgroundImageBlurAmount != value) + { + _settings.BackgroundImageBlurAmount = value; + OnPropertyChanged(); + Save(); + } + } + } + + public BackgroundImageFit BackgroundImageFit + { + get => _settings.BackgroundImageFit; + set + { + if (_settings.BackgroundImageFit != value) + { + _settings.BackgroundImageFit = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(BackgroundImageFitIndex)); + Save(); + } + } + } + + public int BackgroundImageFitIndex + { + // Naming between UI facing string and enum is a bit confusing, but the enum fields + // are based on XAML Stretch enum values. So I'm choosing to keep the confusion here, close + // to the UI. + // - BackgroundImageFit.Fill corresponds to "Stretch" + // - BackgroundImageFit.UniformToFill corresponds to "Fill" + get => BackgroundImageFit switch + { + BackgroundImageFit.Fill => 1, + _ => 0, + }; + set => BackgroundImageFit = value switch + { + 1 => BackgroundImageFit.Fill, + _ => BackgroundImageFit.UniformToFill, + }; + } + + public int BackdropOpacity + { + get => _settings.BackdropOpacity; + set + { + if (_settings.BackdropOpacity != value) + { + _settings.BackdropOpacity = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(EffectiveBackdropStyle)); + OnPropertyChanged(nameof(EffectiveImageOpacity)); + Save(); + } + } + } + + public int BackdropStyleIndex + { + get => (int)_settings.BackdropStyle; + set + { + var newStyle = (BackdropStyle)value; + if (_settings.BackdropStyle != newStyle) + { + _settings.BackdropStyle = newStyle; + + OnPropertyChanged(); + OnPropertyChanged(nameof(IsBackdropOpacityVisible)); + OnPropertyChanged(nameof(IsMicaBackdropDescriptionVisible)); + OnPropertyChanged(nameof(IsBackgroundSettingsEnabled)); + OnPropertyChanged(nameof(IsBackgroundNotAvailableVisible)); + + if (!IsBackgroundSettingsEnabled) + { + IsColorizationDetailsExpanded = false; + } + + Save(); + } + } + } + + /// <summary> + /// Gets whether the backdrop opacity slider should be visible. + /// </summary> + public bool IsBackdropOpacityVisible => + BackdropStyles.Get(_settings.BackdropStyle).SupportsOpacity; + + /// <summary> + /// Gets whether the backdrop description (for styles without options) should be visible. + /// </summary> + public bool IsMicaBackdropDescriptionVisible => + !BackdropStyles.Get(_settings.BackdropStyle).SupportsOpacity; + + /// <summary> + /// Gets whether background/colorization settings are available. + /// </summary> + public bool IsBackgroundSettingsEnabled => + BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization; + + /// <summary> + /// Gets whether the "not available" message should be shown (inverse of IsBackgroundSettingsEnabled). + /// </summary> + public bool IsBackgroundNotAvailableVisible => + !BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization; + + public BackdropStyle? EffectiveBackdropStyle + { + get + { + // Return style when transparency/blur is visible (not fully opaque Acrylic) + // - Clear/Mica/MicaAlt/AcrylicThin always show their effect + // - Acrylic shows effect only when opacity < 100 + if (_settings.BackdropStyle != BackdropStyle.Acrylic || _settings.BackdropOpacity < 100) + { + return _settings.BackdropStyle; + } + + return null; + } + } + + public double EffectiveImageOpacity => + EffectiveBackdropStyle is not null + ? (BackgroundImageOpacity / 100f) * Math.Sqrt(_settings.BackdropOpacity / 100.0) + : (BackgroundImageOpacity / 100f); + + [ObservableProperty] + public partial bool IsColorizationDetailsExpanded { get; set; } + + public bool IsCustomTintVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image; + + public bool IsColorIntensityVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor; + + public bool IsImageTintIntensityVisible => _settings.ColorizationMode is ColorizationMode.Image; + + /// <summary> + /// Gets the effective tint intensity for the preview, based on the current colorization mode. + /// </summary> + public int EffectiveTintIntensity => _settings.ColorizationMode is ColorizationMode.Image + ? _settings.BackgroundImageTintIntensity + : _settings.CustomThemeColorIntensity; + + public bool IsBackgroundControlsVisible => _settings.ColorizationMode is ColorizationMode.Image; + + public bool IsNoBackgroundVisible => _settings.ColorizationMode is ColorizationMode.None; + + public bool IsAccentColorControlsVisible => _settings.ColorizationMode is ColorizationMode.WindowsAccentColor; + + public bool IsResetButtonVisible => _settings.ColorizationMode is ColorizationMode.Image; + + public BackdropParameters EffectiveBackdrop { get; private set; } = new(Colors.Black, Colors.Black, 0.5f, 0.5f); + + public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme; + + public Color EffectiveThemeColor => + !BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization + ? Colors.Transparent + : ColorizationMode switch + { + ColorizationMode.WindowsAccentColor => _currentSystemAccentColor, + ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor, + _ => Colors.Transparent, + }; + + // Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen). + public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f); + + public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0; + + public ImageSource? EffectiveBackgroundImageSource => + !BackdropStyles.Get(_settings.BackdropStyle).SupportsBackgroundImage + ? null + : ColorizationMode is ColorizationMode.Image + && !string.IsNullOrWhiteSpace(BackgroundImagePath) + && Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri) + ? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri) + : null; + + public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings) + { + _themeService = themeService; + _themeService.ThemeChanged += ThemeServiceOnThemeChanged; + _settings = settings; + + _uiSettings = new UISettings(); + _uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged; + UpdateAccentColor(_uiSettings); + + Reapply(); + + IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None && IsBackgroundSettingsEnabled; + } + + private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender)); + + private void UpdateAccentColor(UISettings sender) + { + _currentSystemAccentColor = sender.GetColorValue(UIColorType.Accent); + if (ColorizationMode == ColorizationMode.WindowsAccentColor) + { + ThemeColor = _currentSystemAccentColor; + } + } + + private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) + { + _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); + } + + private void Save() + { + SettingsModel.SaveSettings(_settings); + _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); + } + + private void Reapply() + { + // Theme services recalculates effective color and opacity based on current settings. + EffectiveBackdrop = _themeService.Current.BackdropParameters; + OnPropertyChanged(nameof(EffectiveBackdrop)); + OnPropertyChanged(nameof(EffectiveBackdropStyle)); + OnPropertyChanged(nameof(EffectiveImageOpacity)); + OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness)); + OnPropertyChanged(nameof(EffectiveBackgroundImageSource)); + OnPropertyChanged(nameof(EffectiveThemeColor)); + OnPropertyChanged(nameof(EffectiveBackgroundImageBlurAmount)); + + // LOAD BEARING: + // We need to cycle through the EffectiveTheme property to force reload of resources. + _elementThemeOverride = ElementTheme.Light; + OnPropertyChanged(nameof(EffectiveTheme)); + _elementThemeOverride = ElementTheme.Dark; + OnPropertyChanged(nameof(EffectiveTheme)); + _elementThemeOverride = null; + OnPropertyChanged(nameof(EffectiveTheme)); + } + + [RelayCommand] + private void ResetBackgroundImageProperties() + { + BackgroundImageBrightness = 0; + BackgroundImageBlurAmount = 0; + BackgroundImageFit = BackgroundImageFit.UniformToFill; + BackgroundImageOpacity = 100; + BackgroundImageTintIntensity = 0; + } + + [RelayCommand] + private void ResetAppearanceSettings() + { + // Reset theme + Theme = UserTheme.Default; + + // Reset backdrop settings + BackdropStyleIndex = (int)BackdropStyle.Acrylic; + BackdropOpacity = 100; + + // Reset background image settings + BackgroundImagePath = string.Empty; + ResetBackgroundImageProperties(); + + // Reset colorization + ColorizationMode = ColorizationMode.None; + ThemeColor = DefaultTintColor; + ColorIntensity = 100; + BackgroundImageTintIntensity = 0; + } + + public void Dispose() + { + _uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged; + _themeService.ThemeChanged -= ThemeServiceOnThemeChanged; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip index 27ff543eb6..7bfe8ce57b 100644 Binary files a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip and b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Assets/template.zip differ diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropControllerKind.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropControllerKind.cs new file mode 100644 index 0000000000..9d24d5d435 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropControllerKind.cs @@ -0,0 +1,41 @@ +// 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; + +/// <summary> +/// Specifies the type of system backdrop controller to use. +/// </summary> +public enum BackdropControllerKind +{ + /// <summary> + /// Solid color with alpha transparency (TransparentTintBackdrop). + /// </summary> + Solid, + + /// <summary> + /// Desktop Acrylic with default blur (DesktopAcrylicKind.Default). + /// </summary> + Acrylic, + + /// <summary> + /// Desktop Acrylic with thinner blur (DesktopAcrylicKind.Thin). + /// </summary> + AcrylicThin, + + /// <summary> + /// Mica effect (MicaKind.Base). + /// </summary> + Mica, + + /// <summary> + /// Mica alternate/darker variant (MicaKind.BaseAlt). + /// </summary> + MicaAlt, + + /// <summary> + /// Custom backdrop implementation. + /// </summary> + Custom, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropStyle.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropStyle.cs new file mode 100644 index 0000000000..72d2835e48 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropStyle.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. + +namespace Microsoft.CmdPal.UI.ViewModels; + +/// <summary> +/// Specifies the visual backdrop style for the window. +/// </summary> +public enum BackdropStyle +{ + /// <summary> + /// Standard desktop acrylic with blur effect. + /// </summary> + Acrylic, + + /// <summary> + /// Solid color with alpha transparency (no blur). + /// </summary> + Clear, + + /// <summary> + /// Mica effect that samples the desktop wallpaper. + /// </summary> + Mica, + + /// <summary> + /// Thinner acrylic variant with more transparency. + /// </summary> + AcrylicThin, + + /// <summary> + /// Mica alternate variant (darker). + /// </summary> + MicaAlt, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropStyleConfig.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropStyleConfig.cs new file mode 100644 index 0000000000..c9fa50a23d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropStyleConfig.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. + +namespace Microsoft.CmdPal.UI.ViewModels; + +/// <summary> +/// Configuration parameters for a backdrop style. +/// </summary> +public sealed record BackdropStyleConfig +{ + /// <summary> + /// Gets the type of system backdrop controller to use. + /// </summary> + public required BackdropControllerKind ControllerKind { get; init; } + + /// <summary> + /// Gets the base tint opacity before user adjustments. + /// </summary> + public required float BaseTintOpacity { get; init; } + + /// <summary> + /// Gets the base luminosity opacity before user adjustments. + /// </summary> + public required float BaseLuminosityOpacity { get; init; } + + /// <summary> + /// Gets the brush type to use for preview approximation. + /// </summary> + public required PreviewBrushKind PreviewBrush { get; init; } + + /// <summary> + /// Gets the fixed opacity for styles that don't support user adjustment (e.g., Mica). + /// When <see cref="SupportsOpacity"/> is false, this value is used as the effective opacity. + /// </summary> + public float FixedOpacity { get; init; } + + /// <summary> + /// Gets whether this backdrop style supports custom colorization (tint colors). + /// </summary> + public bool SupportsColorization { get; init; } = true; + + /// <summary> + /// Gets whether this backdrop style supports custom background images. + /// </summary> + public bool SupportsBackgroundImage { get; init; } = true; + + /// <summary> + /// Gets whether this backdrop style supports opacity adjustment. + /// </summary> + public bool SupportsOpacity { get; init; } = true; + + /// <summary> + /// Computes the effective tint opacity based on this style's configuration. + /// </summary> + /// <param name="userOpacity">User's backdrop opacity setting (0-1 normalized).</param> + /// <param name="baseTintOpacityOverride">Optional override for base tint opacity (used by colorful theme).</param> + /// <returns>The effective opacity to apply.</returns> + public float ComputeEffectiveOpacity(float userOpacity, float? baseTintOpacityOverride = null) + { + // For styles that don't support opacity (Mica), use FixedOpacity + if (!SupportsOpacity && FixedOpacity > 0) + { + return FixedOpacity; + } + + // For Solid: only user opacity matters (controls alpha of solid color) + if (ControllerKind == BackdropControllerKind.Solid) + { + return userOpacity; + } + + // For blur effects: multiply base opacity with user opacity + var baseTint = baseTintOpacityOverride ?? BaseTintOpacity; + return baseTint * userOpacity; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropStyles.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropStyles.cs new file mode 100644 index 0000000000..6ba46c156e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackdropStyles.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +/// <summary> +/// Central registry of backdrop style configurations. +/// </summary> +public static class BackdropStyles +{ + private static readonly Dictionary<BackdropStyle, BackdropStyleConfig> Configs = new() + { + [BackdropStyle.Acrylic] = new() + { + ControllerKind = BackdropControllerKind.Acrylic, + BaseTintOpacity = 0.5f, + BaseLuminosityOpacity = 0.9f, + PreviewBrush = PreviewBrushKind.Acrylic, + }, + [BackdropStyle.AcrylicThin] = new() + { + ControllerKind = BackdropControllerKind.AcrylicThin, + BaseTintOpacity = 0.0f, + BaseLuminosityOpacity = 0.85f, + PreviewBrush = PreviewBrushKind.Acrylic, + }, + [BackdropStyle.Mica] = new() + { + ControllerKind = BackdropControllerKind.Mica, + BaseTintOpacity = 0.0f, + BaseLuminosityOpacity = 1.0f, + PreviewBrush = PreviewBrushKind.Solid, + FixedOpacity = 0.96f, + SupportsOpacity = false, + }, + [BackdropStyle.MicaAlt] = new() + { + ControllerKind = BackdropControllerKind.MicaAlt, + BaseTintOpacity = 0.0f, + BaseLuminosityOpacity = 1.0f, + PreviewBrush = PreviewBrushKind.Solid, + FixedOpacity = 0.98f, + SupportsOpacity = false, + }, + [BackdropStyle.Clear] = new() + { + ControllerKind = BackdropControllerKind.Solid, + BaseTintOpacity = 1.0f, + BaseLuminosityOpacity = 1.0f, + PreviewBrush = PreviewBrushKind.Solid, + }, + }; + + /// <summary> + /// Gets the configuration for the specified backdrop style. + /// </summary> + public static BackdropStyleConfig Get(BackdropStyle style) => + Configs.TryGetValue(style, out var config) ? config : Configs[BackdropStyle.Acrylic]; + + /// <summary> + /// Gets all registered backdrop styles. + /// </summary> + public static IEnumerable<BackdropStyle> All => Configs.Keys; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs new file mode 100644 index 0000000000..52102df30a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/BackgroundImageFit.cs @@ -0,0 +1,11 @@ +// 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; + +public enum BackgroundImageFit +{ + Fill, + UniformToFill, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.cs new file mode 100644 index 0000000000..57a65f1882 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ColorizationMode.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. + +namespace Microsoft.CmdPal.UI.ViewModels; + +public enum ColorizationMode +{ + None, + WindowsAccentColor, + CustomColor, + Image, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs new file mode 100644 index 0000000000..e3cd8a92d7 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteContentPageViewModel.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class CommandPaletteContentPageViewModel : ContentPageViewModel +{ + public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host) + : base(model, scheduler, host) + { + } + + public override ContentViewModel? ViewModelFromContent(IContent content, WeakReference<IPageContext> context) + { + ContentViewModel? viewModel = content switch + { + IFormContent form => new ContentFormViewModel(form, context), + IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context), + ITreeContent tree => new ContentTreeViewModel(tree, context), + _ => null, + }; + return viewModel; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs index 9aa12dc037..af089b3edc 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs @@ -1,35 +1,19 @@ -// 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.ObjectModel; -using System.Diagnostics; -using ManagedCommon; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Foundation; namespace Microsoft.CmdPal.UI.ViewModels; -public sealed partial class CommandPaletteHost : IExtensionHost +public sealed partial class CommandPaletteHost : AppExtensionHost, IExtensionHost { // Static singleton, so that we can access this from anywhere // Post MVVM - this should probably be like, a dependency injection thing. public static CommandPaletteHost Instance { get; } = new(); - private static readonly GlobalLogPageContext _globalLogPageContext = new(); - - private static ulong _hostingHwnd; - - public ulong HostingHwnd => _hostingHwnd; - - public string LanguageOverride => string.Empty; - - public static ObservableCollection<LogMessageViewModel> LogMessages { get; } = []; - - public ObservableCollection<StatusMessageViewModel> StatusMessages { get; } = []; - public IExtensionWrapper? Extension { get; } private readonly ICommandProvider? _builtInProvider; @@ -48,145 +32,8 @@ public sealed partial class CommandPaletteHost : IExtensionHost _builtInProvider = builtInProvider; } - public IAsyncAction ShowStatus(IStatusMessage? message, StatusContext context) + public override string? GetExtensionDisplayName() { - if (message == null) - { - return Task.CompletedTask.AsAsyncAction(); - } - - Debug.WriteLine(message.Message); - - _ = Task.Run(() => - { - ProcessStatusMessage(message, context); - }); - - return Task.CompletedTask.AsAsyncAction(); - } - - public IAsyncAction HideStatus(IStatusMessage? message) - { - if (message == null) - { - return Task.CompletedTask.AsAsyncAction(); - } - - _ = Task.Run(() => - { - ProcessHideStatusMessage(message); - }); - return Task.CompletedTask.AsAsyncAction(); - } - - public IAsyncAction LogMessage(ILogMessage? message) - { - if (message == null) - { - return Task.CompletedTask.AsAsyncAction(); - } - - Logger.LogDebug(message.Message); - - _ = Task.Run(() => - { - ProcessLogMessage(message); - }); - - // We can't just make a LogMessageViewModel : ExtensionObjectViewModel - // because we don't necessarily know the page context. Butts. - return Task.CompletedTask.AsAsyncAction(); - } - - public void ProcessLogMessage(ILogMessage message) - { - var vm = new LogMessageViewModel(message, _globalLogPageContext); - vm.SafeInitializePropertiesSynchronous(); - - if (Extension != null) - { - vm.ExtensionPfn = Extension.PackageFamilyName; - } - - Task.Factory.StartNew( - () => - { - LogMessages.Add(vm); - }, - CancellationToken.None, - TaskCreationOptions.None, - _globalLogPageContext.Scheduler); - } - - public void ProcessStatusMessage(IStatusMessage message, StatusContext context) - { - // If this message is already in the list of messages, just bring it to the top - var oldVm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault(); - if (oldVm != null) - { - Task.Factory.StartNew( - () => - { - StatusMessages.Remove(oldVm); - StatusMessages.Add(oldVm); - }, - CancellationToken.None, - TaskCreationOptions.None, - _globalLogPageContext.Scheduler); - return; - } - - var vm = new StatusMessageViewModel(message, new(_globalLogPageContext)); - vm.SafeInitializePropertiesSynchronous(); - - if (Extension != null) - { - vm.ExtensionPfn = Extension.PackageFamilyName; - } - - Task.Factory.StartNew( - () => - { - StatusMessages.Add(vm); - }, - CancellationToken.None, - TaskCreationOptions.None, - _globalLogPageContext.Scheduler); - } - - public void ProcessHideStatusMessage(IStatusMessage message) - { - Task.Factory.StartNew( - () => - { - try - { - var vm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault(); - if (vm != null) - { - StatusMessages.Remove(vm); - } - } - catch - { - } - }, - CancellationToken.None, - TaskCreationOptions.None, - _globalLogPageContext.Scheduler); - } - - public static void SetHostHwnd(ulong hostHwnd) => _hostingHwnd = hostHwnd; - - public void DebugLog(string message) - { -#if DEBUG - this.ProcessLogMessage(new LogMessage(message)); -#endif - } - - public void Log(string message) - { - this.ProcessLogMessage(new LogMessage(message)); + return Extension?.ExtensionDisplayName ?? _builtInProvider?.DisplayName ?? _builtInProvider?.Id; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.cs new file mode 100644 index 0000000000..90e70d7fef --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPalettePageViewModelFactory.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 Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public class CommandPalettePageViewModelFactory + : IPageViewModelFactoryService +{ + private readonly TaskScheduler _scheduler; + + public CommandPalettePageViewModelFactory(TaskScheduler scheduler) + { + _scheduler = scheduler; + } + + public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host) + { + return page switch + { + IListPage listPage => new ListViewModel(listPage, _scheduler, host) { IsNested = nested }, + IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host), + _ => null, + }; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index f1e09f6728..d0f73f4a12 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -3,8 +3,10 @@ // See the LICENSE file in the project root for more information. using ManagedCommon; -using Microsoft.CmdPal.Common.Services; -using Microsoft.CmdPal.UI.ViewModels.Models; +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; @@ -14,7 +16,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public sealed class CommandProviderWrapper { - public bool IsExtension => Extension != null; + public bool IsExtension => Extension is not null; private readonly bool isValid; @@ -22,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; } = []; @@ -40,13 +44,9 @@ public sealed class CommandProviderWrapper public CommandSettingsViewModel? Settings { get; private set; } - public string ProviderId - { - get - { - return string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId; - } - } + public bool IsActive { get; private set; } + + public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId; public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread) { @@ -66,15 +66,19 @@ public sealed class CommandProviderWrapper DisplayName = provider.DisplayName; Icon = new(provider.Icon); Icon.InitializeProperties(); + + // Note: explicitly not InitializeProperties()ing the settings here. If + // we do that, then we'd regress GH #38321 Settings = new(provider.Settings, this, _taskScheduler); - Settings.InitializeProperties(); 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()) @@ -122,38 +126,55 @@ public sealed class CommandProviderWrapper { if (!isValid) { + IsActive = false; + RecallFromCache(); return; } var settings = serviceProvider.GetService<SettingsModel>()!; - if (!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<ICommandItem[]> t = new(model.TopLevelCommands); - t.Start(); - commands = await t.ConfigureAwait(false); + Task<ICommandItem[]> 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) + { + UnsafePreCacheApiAdditions(two); + } Id = model.Id; 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 Settings = new(model.Settings, this, _taskScheduler); - Settings.InitializeProperties(); + // We do need to explicitly initialize commands though InitializeCommands(commands, fallbacks, serviceProvider, pageContext); Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})"); @@ -163,30 +184,50 @@ 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; } } private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext) { var settings = serviceProvider.GetService<SettingsModel>()!; + var providerSettings = GetProviderSettings(settings); - Func<ICommandItem?, bool, TopLevelViewModel> 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, serviceProvider); - - topLevelViewModel.ItemViewModel.SlowInitializeProperties(); + TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ProviderId, settings, providerSettings, serviceProvider, i); + topLevelViewModel.InitializeProperties(); return topLevelViewModel; }; - if (commands != null) + + if (commands is not null) { TopLevelItems = commands .Select(c => makeAndAdd(c, false)) .ToArray(); } - if (fallbacks != null) + if (fallbacks is not null) { FallbackItems = fallbacks .Select(c => makeAndAdd(c, true)) @@ -194,20 +235,18 @@ public sealed class CommandProviderWrapper } } - /* This is a View/ExtensionHost piece - * public void AllowSetForeground(bool allow) + private void UnsafePreCacheApiAdditions(ICommandProvider2 provider) { - if (!IsExtension) + var apiExtensions = provider.GetApiExtensionStubs(); + Logger.LogDebug($"Provider supports {apiExtensions.Length} extensions"); + foreach (var a in apiExtensions) { - return; + if (a is IExtendedAttributesProvider command2) + { + Logger.LogDebug($"{ProviderId}: Found an IExtendedAttributesProvider"); + } } - - var iextn = extensionWrapper?.GetExtensionObject(); - unsafe - { - PInvoke.CoAllowSetForegroundWindow(iextn); - } - }*/ + } public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs index 0853dbd932..fe440cdaa7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandSettingsViewModel.cs @@ -2,29 +2,60 @@ // 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.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.UI.ViewModels; -public partial class CommandSettingsViewModel(ICommandSettings _unsafeSettings, CommandProviderWrapper provider, TaskScheduler mainThread) +public partial class CommandSettingsViewModel(ICommandSettings? _unsafeSettings, CommandProviderWrapper provider, TaskScheduler mainThread) { private readonly ExtensionObject<ICommandSettings> _model = new(_unsafeSettings); public ContentPageViewModel? SettingsPage { get; private set; } - public void InitializeProperties() + public bool Initialized { get; private set; } + + public bool HasSettings => + _model.Unsafe is not null && // We have a settings model AND + (!Initialized || SettingsPage is not null); // we weren't initialized, OR we were, and we do have a settings page + + private void UnsafeInitializeProperties() { var model = _model.Unsafe; - if (model == null) + if (model is null) { return; } - if (model.SettingsPage is IContentPage page) + if (model.SettingsPage is not null) { - SettingsPage = new(page, mainThread, provider.ExtensionHost); + SettingsPage = new CommandPaletteContentPageViewModel(model.SettingsPage, mainThread, provider.ExtensionHost); SettingsPage.InitializeProperties(); } } + + public void SafeInitializeProperties() + { + try + { + UnsafeInitializeProperties(); + } + catch (Exception ex) + { + CoreLogger.LogError($"Failed to load settings page", ex: ex); + } + + Initialized = true; + } + + public void DoOnUiThread(Action action) + { + Task.Factory.StartNew( + action, + CancellationToken.None, + TaskCreationOptions.None, + mainThread); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs index 9552e108ef..02da2b81f2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltInsCommandProvider.cs @@ -2,7 +2,6 @@ // 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.UI.ViewModels.Messages; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -11,7 +10,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; /// <summary> /// Built-in Provider for a top-level command which can quit the application. Invokes the <see cref="QuitCommand"/>, which sends a <see cref="QuitMessage"/>. /// </summary> -public partial class BuiltInsCommandProvider : CommandProvider +public sealed partial class BuiltInsCommandProvider : CommandProvider { private readonly OpenSettingsCommand openSettings = new(); private readonly QuitCommand quitCommand = new(); @@ -21,20 +20,26 @@ public partial class BuiltInsCommandProvider : CommandProvider public override ICommandItem[] TopLevelCommands() => [ - new CommandItem(openSettings) { Subtitle = Properties.Resources.builtin_open_settings_subtitle }, - new CommandItem(_newExtension) { Title = _newExtension.Title, Subtitle = Properties.Resources.builtin_new_extension_subtitle }, + new CommandItem(openSettings) { }, + new CommandItem(_newExtension) { Title = _newExtension.Title }, ]; public override IFallbackCommandItem[] FallbackCommands() => [ - new FallbackCommandItem(quitCommand, displayTitle: Properties.Resources.builtin_quit_subtitle) { Subtitle = Properties.Resources.builtin_quit_subtitle }, + new FallbackCommandItem( + quitCommand, + Properties.Resources.builtin_quit_subtitle, + quitCommand.Id) + { + Subtitle = Properties.Resources.builtin_quit_subtitle, + }, _fallbackReloadItem, _fallbackLogItem, ]; public BuiltInsCommandProvider() { - Id = "Core"; + Id = "com.microsoft.cmdpal.builtin.core"; DisplayName = Properties.Resources.builtin_display_name; Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png"); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltinsExtensionHost.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltinsExtensionHost.cs index f643f0fc84..c046d0c21a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltinsExtensionHost.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/BuiltinsExtensionHost.cs @@ -2,15 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Diagnostics.CodeAnalysis; -using System.IO.Compression; -using System.Text.Json; -using System.Text.Json.Nodes; -using Microsoft.CmdPal.Common; -using Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Foundation; +using Microsoft.CmdPal.Core.Common; namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/CreatedExtensionForm.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/CreatedExtensionForm.cs index 1939162662..4ab993d84a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/CreatedExtensionForm.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/CreatedExtensionForm.cs @@ -13,12 +13,13 @@ internal sealed partial class CreatedExtensionForm : NewExtensionFormBase { public CreatedExtensionForm(string name, string displayName, string path) { + var serializeString = (string? s) => JsonSerializer.Serialize(s, JsonSerializationContext.Default.String); TemplateJson = CardTemplate; DataJson = $$""" { - "name": {{JsonSerializer.Serialize(name)}}, - "directory": {{JsonSerializer.Serialize(path)}}, - "displayName": {{JsonSerializer.Serialize(displayName)}} + "name": {{serializeString(name)}}, + "directory": {{serializeString(path)}}, + "displayName": {{serializeString(displayName)}} } """; _name = name; @@ -28,13 +29,13 @@ internal sealed partial class CreatedExtensionForm : NewExtensionFormBase public override ICommandResult SubmitForm(string inputs, string data) { - JsonObject? dataInput = JsonNode.Parse(data)?.AsObject(); - if (dataInput == null) + var dataInput = JsonNode.Parse(data)?.AsObject(); + if (dataInput is null) { return CommandResult.KeepOpen(); } - string verb = dataInput["x"]?.AsValue()?.ToString() ?? string.Empty; + var verb = dataInput["x"]?.AsValue()?.ToString() ?? string.Empty; return verb switch { "sln" => OpenSolution(), @@ -47,7 +48,7 @@ internal sealed partial class CreatedExtensionForm : NewExtensionFormBase private ICommandResult OpenSolution() { string[] parts = [_path, _name, $"{_name}.sln"]; - string pathToSolution = Path.Combine(parts); + var pathToSolution = Path.Combine(parts); ShellHelpers.OpenInShell(pathToSolution); return CommandResult.Hide(); } @@ -55,7 +56,7 @@ internal sealed partial class CreatedExtensionForm : NewExtensionFormBase private ICommandResult OpenDirectory() { string[] parts = [_path, _name]; - string pathToDir = Path.Combine(parts); + var pathToDir = Path.Combine(parts); ShellHelpers.OpenInShell(pathToDir); return CommandResult.Hide(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackLogItem.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackLogItem.cs index 686dadbfbb..2f44b018e1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackLogItem.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackLogItem.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels.Commands; using Microsoft.CmdPal.UI.ViewModels.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -12,13 +13,19 @@ internal sealed partial class FallbackLogItem : FallbackCommandItem { private readonly LogMessagesPage _logMessagesPage; + private const string _id = "com.microsoft.cmdpal.log"; + public FallbackLogItem() - : base(new LogMessagesPage(), Resources.builtin_log_subtitle) + : base(new LogMessagesPage() { Id = _id }, Resources.builtin_log_subtitle, _id) { _logMessagesPage = (LogMessagesPage)Command!; Title = string.Empty; _logMessagesPage.Name = string.Empty; Subtitle = Properties.Resources.builtin_log_subtitle; + + var logPath = Logger.LogDirectoryPath("\\CmdPal\\Logs\\"); + var openLogCommand = new OpenFileCommand(logPath) { Name = Resources.builtin_log_folder_command_name }; + MoreCommands = [new CommandContextItem(openLogCommand)]; } public override void UpdateQuery(string query) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackReloadItem.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackReloadItem.cs index 22dfe77776..489c73a537 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackReloadItem.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/FallbackReloadItem.cs @@ -10,8 +10,13 @@ internal sealed partial class FallbackReloadItem : FallbackCommandItem { private readonly ReloadExtensionsCommand _reloadCommand; + private const string _id = "com.microsoft.cmdpal.reload"; + public FallbackReloadItem() - : base(new ReloadExtensionsCommand(), Properties.Resources.builtin_reload_display_title) + : base( + new ReloadExtensionsCommand() { Id = _id }, + Properties.Resources.builtin_reload_display_title, + _id) { _reloadCommand = (ReloadExtensionsCommand)Command!; Title = string.Empty; @@ -20,7 +25,7 @@ internal sealed partial class FallbackReloadItem : FallbackCommandItem public override void UpdateQuery(string query) { - _reloadCommand.Name = query.StartsWith('r') ? "Reload" : string.Empty; + _reloadCommand.Name = query.StartsWith("r", StringComparison.OrdinalIgnoreCase) ? "Reload" : string.Empty; Title = _reloadCommand.Name; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/LogMessagesPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/LogMessagesPage.cs index e0be3beb90..90dea58e5c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/LogMessagesPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/LogMessagesPage.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Collections.Specialized; +using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -10,7 +11,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Commands; public partial class LogMessagesPage : ListPage { - private readonly List<IListItem> _listItems = new(); + private readonly List<IListItem> _listItems = []; public LogMessagesPage() { @@ -22,7 +23,7 @@ public partial class LogMessagesPage : ListPage private void LogMessages_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null) + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems is not null) { foreach (var item in e.NewItems) { @@ -31,7 +32,6 @@ public partial class LogMessagesPage : ListPage var li = new ListItem(new NoOpCommand()) { Title = logMessageViewModel.Message, - Subtitle = logMessageViewModel.ExtensionPfn, }; _listItems.Insert(0, li); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index d60571643d..325f9b5ff8 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -4,12 +4,20 @@ using System.Collections.Immutable; using System.Collections.Specialized; +using System.Diagnostics; using CommunityToolkit.Mvvm.Messaging; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Text; +using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Ext.Apps; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.State; +using Microsoft.CmdPal.UI.ViewModels.Commands; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -using Microsoft.Extensions.DependencyInjection; namespace Microsoft.CmdPal.UI.ViewModels.MainPage; @@ -17,21 +25,54 @@ namespace Microsoft.CmdPal.UI.ViewModels.MainPage; /// This class encapsulates the data we load from built-in providers and extensions to use within the same extension-UI system for a <see cref="ListPage"/>. /// TODO: Need to think about how we structure/interop for the page -> section -> item between the main setup, the extensions, and our viewmodels. /// </summary> -public partial class MainListPage : DynamicListPage, +public sealed partial class MainListPage : DynamicListPage, IRecipient<ClearSearchMessage>, - IRecipient<UpdateFallbackItemsMessage> + IRecipient<UpdateFallbackItemsMessage>, IDisposable { - private readonly IServiceProvider _serviceProvider; - private readonly TopLevelCommandManager _tlcManager; - private IEnumerable<IListItem>? _filteredItems; + private readonly AliasManager _aliasManager; + private readonly SettingsModel _settings; + private readonly AppStateModel _appStateModel; + private readonly ScoringFunction<IListItem> _scoringFunction; + private readonly ScoringFunction<IListItem> _fallbackScoringFunction; + private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider; - public MainListPage(IServiceProvider serviceProvider) + private RoScored<IListItem>[]? _filteredItems; + private RoScored<IListItem>[]? _filteredApps; + + // Keep as IEnumerable for deferred execution. Fallback item titles are updated + // asynchronously, so scoring must happen lazily when GetItems is called. + private IEnumerable<RoScored<IListItem>>? _scoredFallbackItems; + private IEnumerable<RoScored<IListItem>>? _fallbackItems; + + private bool _includeApps; + private bool _filteredItemsIncludesApps; + private int _appResultLimit = 10; + + private InterlockedBoolean _refreshRunning; + private InterlockedBoolean _refreshRequested; + + private CancellationTokenSource? _cancellationTokenSource; + + public MainListPage( + TopLevelCommandManager topLevelCommandManager, + SettingsModel settings, + AliasManager aliasManager, + AppStateModel appStateModel, + IFuzzyMatcherProvider fuzzyMatcherProvider) { + Title = Resources.builtin_home_name; Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png"); - _serviceProvider = serviceProvider; + PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder; + + _settings = settings; + _aliasManager = aliasManager; + _appStateModel = appStateModel; + _tlcManager = topLevelCommandManager; + _fuzzyMatcherProvider = fuzzyMatcherProvider; + _scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateModel.RecentCommands, _fuzzyMatcherProvider.Current); + _fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settings.FallbackRanks); - _tlcManager = _serviceProvider.GetService<TopLevelCommandManager>()!; _tlcManager.PropertyChanged += TlcManager_PropertyChanged; _tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged; @@ -39,19 +80,19 @@ public partial class MainListPage : DynamicListPage, // We just want to know when it is done. var allApps = AllAppsCommandProvider.Page; allApps.PropChanged += (s, p) => - { - if (p.PropertyName == nameof(allApps.IsLoading)) { - IsLoading = ActuallyLoading(); - } - }; + if (p.PropertyName == nameof(allApps.IsLoading)) + { + IsLoading = ActuallyLoading(); + } + }; WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this); WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this); - var settings = _serviceProvider.GetService<SettingsModel>()!; settings.SettingsChanged += SettingsChangedHandler; HotReloadSettings(settings); + _includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId); IsLoading = true; } @@ -64,50 +105,193 @@ public partial class MainListPage : DynamicListPage, } } - private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => RaiseItemsChanged(_tlcManager.TopLevelCommands.Count); - - public override IListItem[] GetItems() + private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - if (string.IsNullOrEmpty(SearchText)) + _includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId); + if (_includeApps != _filteredItemsIncludesApps) { - lock (_tlcManager.TopLevelCommands) - { - return _tlcManager - .TopLevelCommands - .Where(tlc => !string.IsNullOrEmpty(tlc.Title)) - .ToArray(); - } + ReapplySearchInBackground(); } else { - lock (_tlcManager.TopLevelCommands) + RaiseItemsChanged(); + } + } + + private void ReapplySearchInBackground() + { + _refreshRequested.Set(); + if (!_refreshRunning.Set()) + { + return; + } + + _ = Task.Run(RunRefreshLoop); + } + + private void RunRefreshLoop() + { + try + { + do { - return _filteredItems?.ToArray() ?? []; + _refreshRequested.Clear(); + lock (_tlcManager.TopLevelCommands) + { + if (_filteredItemsIncludesApps == _includeApps) + { + break; + } + } + + var currentSearchText = SearchText; + UpdateSearchText(currentSearchText, currentSearchText); + } + while (_refreshRequested.Value); + } + catch (Exception e) + { + Logger.LogError("Failed to reload search", e); + } + finally + { + _refreshRunning.Clear(); + if (_refreshRequested.Value && _refreshRunning.Set()) + { + _ = Task.Run(RunRefreshLoop); } } } + public override IListItem[] GetItems() + { + lock (_tlcManager.TopLevelCommands) + { + // Either return the top-level commands (no search text), or the merged and + // filtered results. + if (string.IsNullOrWhiteSpace(SearchText)) + { + return _tlcManager.TopLevelCommands + .Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title)) + .ToArray(); + } + else + { + var validScoredFallbacks = _scoredFallbackItems? + .Where(s => !string.IsNullOrWhiteSpace(s.Item.Title)) + .ToList(); + + var validFallbacks = _fallbackItems? + .Where(s => !string.IsNullOrWhiteSpace(s.Item.Title)) + .ToList(); + + return MainListPageResultFactory.Create( + _filteredItems, + validScoredFallbacks, + _filteredApps, + validFallbacks, + _appResultLimit); + } + } + } + + private void ClearResults() + { + _filteredItems = null; + _filteredApps = null; + _fallbackItems = null; + _scoredFallbackItems = null; + } + public override void UpdateSearchText(string oldSearch, string newSearch) { + var stopwatch = Stopwatch.StartNew(); + + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = new CancellationTokenSource(); + + var token = _cancellationTokenSource.Token; + if (token.IsCancellationRequested) + { + return; + } + // Handle changes to the filter text here if (!string.IsNullOrEmpty(SearchText)) { - var aliases = _serviceProvider.GetService<AliasManager>()!; - if (aliases.CheckAlias(newSearch)) + var aliases = _aliasManager; + + if (token.IsCancellationRequested) { return; } + + if (aliases.CheckAlias(newSearch)) + { + if (_filteredItemsIncludesApps != _includeApps) + { + lock (_tlcManager.TopLevelCommands) + { + _filteredItemsIncludesApps = _includeApps; + ClearResults(); + } + } + + return; + } + } + + if (token.IsCancellationRequested) + { + return; } var commands = _tlcManager.TopLevelCommands; lock (commands) { - UpdateFallbacks(newSearch, commands.ToImmutableArray()); + if (token.IsCancellationRequested) + { + return; + } + + // prefilter fallbacks + var globalFallbacks = _settings.GetGlobalFallbacks(); + var specialFallbacks = new List<TopLevelViewModel>(globalFallbacks.Length); + var commonFallbacks = new List<TopLevelViewModel>(); + + foreach (var s in commands) + { + if (!s.IsFallback) + { + continue; + } + + if (globalFallbacks.Contains(s.Id)) + { + specialFallbacks.Add(s); + } + else + { + commonFallbacks.Add(s); + } + } + + // start update of fallbacks; update special fallbacks separately, + // so they can finish faster + UpdateFallbacks(SearchText, specialFallbacks, token); + UpdateFallbacks(SearchText, commonFallbacks, token); + + if (token.IsCancellationRequested) + { + return; + } // Cleared out the filter text? easy. Reset _filteredItems, and bail out. if (string.IsNullOrEmpty(newSearch)) { - _filteredItems = null; + _filteredItemsIncludesApps = _includeApps; + ClearResults(); RaiseItemsChanged(commands.Count); return; } @@ -116,54 +300,188 @@ public partial class MainListPage : DynamicListPage, // re-use previous results. Reset _filteredItems, and keep er moving. if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase)) { - _filteredItems = null; + ClearResults(); + } + + // If the internal state has changed, reset _filteredItems to reset the list. + if (_filteredItemsIncludesApps != _includeApps) + { + ClearResults(); + } + + if (token.IsCancellationRequested) + { + return; + } + + var newFilteredItems = Enumerable.Empty<IListItem>(); + var newFallbacks = Enumerable.Empty<IListItem>(); + var newApps = Enumerable.Empty<IListItem>(); + + if (_filteredItems is not null) + { + newFilteredItems = _filteredItems.Select(s => s.Item); + } + + if (token.IsCancellationRequested) + { + return; + } + + if (_filteredApps is not null) + { + newApps = _filteredApps.Select(s => s.Item); + } + + if (token.IsCancellationRequested) + { + return; + } + + if (_fallbackItems is not null) + { + newFallbacks = _fallbackItems.Select(s => s.Item); + } + + if (token.IsCancellationRequested) + { + return; } // If we don't have any previous filter results to work with, start // with a list of all our commands & apps. - if (_filteredItems == null) + if (!newFilteredItems.Any() && !newApps.Any()) { - IEnumerable<IListItem> apps = AllAppsCommandProvider.Page.GetItems(); - _filteredItems = commands.Concat(apps); + newFilteredItems = commands.Where(s => !s.IsFallback); + + // Fallbacks are always included in the list, even if they + // don't match the search text. But we don't want to + // consider them when filtering the list. + newFallbacks = commonFallbacks; + + if (token.IsCancellationRequested) + { + return; + } + + _filteredItemsIncludesApps = _includeApps; + + if (_includeApps) + { + var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList(); + + // We need to remove pinned apps from allNewApps so they don't show twice. + var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers(); + + if (pinnedApps.Length > 0) + { + newApps = allNewApps.Where(w => pinnedApps.IndexOf(w.AppIdentifier) < 0); + } + else + { + newApps = allNewApps; + } + } + + if (token.IsCancellationRequested) + { + return; + } } + var searchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText); + // Produce a list of everything that matches the current filter. - _filteredItems = ListHelpers.FilterList<IListItem>(_filteredItems, SearchText, ScoreTopLevelItem); - RaiseItemsChanged(_filteredItems.Count()); + _filteredItems = InternalListHelpers.FilterListWithScores(newFilteredItems, searchQuery, _scoringFunction); + + if (token.IsCancellationRequested) + { + return; + } + + IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && globalFallbacks.Contains(s.Id)); + _scoredFallbackItems = InternalListHelpers.FilterListWithScores(newFallbacksForScoring, searchQuery, _scoringFunction); + + if (token.IsCancellationRequested) + { + return; + } + + _fallbackItems = InternalListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], searchQuery, _fallbackScoringFunction); + + if (token.IsCancellationRequested) + { + return; + } + + // Produce a list of filtered apps with the appropriate limit + if (newApps.Any()) + { + _filteredApps = InternalListHelpers.FilterListWithScores(newApps, searchQuery, _scoringFunction); + + if (token.IsCancellationRequested) + { + return; + } + } + + var filterDoneTimestamp = stopwatch.ElapsedMilliseconds; + Logger.LogDebug($"Filter with '{newSearch}' in {filterDoneTimestamp}ms"); + + RaiseItemsChanged(); + + var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds; + Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms"); + + stopwatch.Stop(); } } - private void UpdateFallbacks(string newSearch, IReadOnlyList<TopLevelViewModel> commands) + private void UpdateFallbacks(string newSearch, IReadOnlyList<TopLevelViewModel> commands, CancellationToken token) { - // fire and forget - _ = Task.Run(() => + _ = Task.Run( + () => { var needsToUpdate = false; foreach (var command in commands) { + if (token.IsCancellationRequested) + { + return; + } + var changedVisibility = command.SafeUpdateFallbackTextSynchronous(newSearch); needsToUpdate = needsToUpdate || changedVisibility; } if (needsToUpdate) { + if (token.IsCancellationRequested) + { + return; + } + RaiseItemsChanged(); } - }); + }, + token); } private bool ActuallyLoading() { - var tlcManager = _serviceProvider.GetService<TopLevelCommandManager>()!; var allApps = AllAppsCommandProvider.Page; - return allApps.IsLoading || tlcManager.IsLoading; + return allApps.IsLoading || _tlcManager.IsLoading; } // Almost verbatim ListHelpers.ScoreListItem, but also accounting for the // fact that we want fallback handlers down-weighted, so that they don't // _always_ show up first. - private int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem) + internal static int ScoreTopLevelItem( + in FuzzyQuery query, + IListItem topLevelOrAppItem, + IRecentCommandsManager history, + IPrecomputedFuzzyMatcher precomputedFuzzyMatcher) { var title = topLevelOrAppItem.Title; if (string.IsNullOrWhiteSpace(title)) @@ -171,96 +489,106 @@ public partial class MainListPage : DynamicListPage, return 0; } - var isWhiteSpace = string.IsNullOrWhiteSpace(query); - var isFallback = false; var isAliasSubstringMatch = false; var isAliasMatch = false; var id = IdForTopLevelOrAppItem(topLevelOrAppItem); - var extensionDisplayName = string.Empty; + FuzzyTarget? extensionDisplayNameTarget = null; if (topLevelOrAppItem is TopLevelViewModel topLevel) { isFallback = topLevel.IsFallback; + extensionDisplayNameTarget = topLevel.GetExtensionNameTarget(precomputedFuzzyMatcher); + if (topLevel.HasAlias) { var alias = topLevel.AliasText; - isAliasMatch = alias == query; - isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query, StringComparison.CurrentCultureIgnoreCase); + isAliasMatch = alias == query.Original; + isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query.Original, StringComparison.CurrentCultureIgnoreCase); } - - extensionDisplayName = topLevel.ExtensionHost?.Extension?.PackageDisplayName ?? string.Empty; } - // StringMatcher.FuzzySearch will absolutely BEEF IT if you give it a - // whitespace-only query. - // - // in that scenario, we'll just use a simple string contains for the - // query. Maybe someone is really looking for things with a space in - // them, I don't know. - - // Title: - // * whitespace query: 1 point - // * otherwise full weight match - var nameMatch = isWhiteSpace ? - (title.Contains(query) ? 1 : 0) : - StringMatcher.FuzzySearch(query, title).Score; - - // Subtitle: - // * whitespace query: 1/2 point - // * otherwise ~half weight match. Minus a bit, because subtitles tend to be longer - var descriptionMatch = isWhiteSpace ? - (topLevelOrAppItem.Subtitle.Contains(query) ? .5 : 0) : - (StringMatcher.FuzzySearch(query, topLevelOrAppItem.Subtitle).Score - 4) / 2.0; - - // Extension title: despite not being visible, give the extension name itself some weight - // * whitespace query: 0 points - // * otherwise more weight than a subtitle, but not much - var extensionTitleMatch = isWhiteSpace ? 0 : StringMatcher.FuzzySearch(query, extensionDisplayName).Score / 1.5; - - var scores = new[] + // Handle whitespace query separately - FuzzySearch doesn't handle it well + if (string.IsNullOrWhiteSpace(query.Original)) { - nameMatch, - descriptionMatch, - isFallback ? 1 : 0, // Always give fallbacks a chance... - }; - var max = scores.Max(); + return ScoreWhitespaceQuery(query.Original, title, topLevelOrAppItem.Subtitle, isFallback); + } - // _Add_ the extension name. This will bubble items that match both - // title and extension name up above ones that just match title. - // e.g. "git" will up-weight "GitHub searches" from the GitHub extension - // above "git" from "whatever" - max = max + extensionTitleMatch; + // Get precomputed targets + var (titleTarget, subtitleTarget) = topLevelOrAppItem is IPrecomputedListItem precomputedItem + ? (precomputedItem.GetTitleTarget(precomputedFuzzyMatcher), precomputedItem.GetSubtitleTarget(precomputedFuzzyMatcher)) + : (precomputedFuzzyMatcher.PrecomputeTarget(title), precomputedFuzzyMatcher.PrecomputeTarget(topLevelOrAppItem.Subtitle)); - // ... but downweight them - var matchSomething = (max / (isFallback ? 3 : 1)) - + (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0)); + // Score components + var nameScore = precomputedFuzzyMatcher.Score(query, titleTarget); + var descriptionScore = (precomputedFuzzyMatcher.Score(query, subtitleTarget) - 4) / 2.0; + var extensionScore = extensionDisplayNameTarget is { } extTarget ? precomputedFuzzyMatcher.Score(query, extTarget) / 1.5 : 0; - // If we matched title, subtitle, or alias (something real), then - // here we add the recent command weight boost - // - // Otherwise something like `x` will still match everything you've run before - var finalScore = matchSomething; - if (matchSomething > 0) + // Take best match from title/description/fallback, then add extension score + // Extension adds to max so items matching both title AND extension bubble up + var baseScore = Math.Max(Math.Max(nameScore, descriptionScore), isFallback ? 1 : 0); + var matchScore = baseScore + extensionScore; + + // Apply a penalty to fallback items so they rank below direct matches. + // Fallbacks that dynamically match queries (like RDP connections) should + // appear after apps and direct command matches. + if (isFallback && matchScore > 1) { - var history = _serviceProvider.GetService<AppStateModel>()!.RecentCommands; - var recentWeightBoost = history.GetCommandHistoryWeight(id); - finalScore += recentWeightBoost; + // Reduce fallback scores by 50% to prioritize direct matches + matchScore = matchScore * 0.5; + } + + // Alias matching: exact match is overwhelming priority, substring match adds a small boost + var aliasBoost = isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0); + var totalMatch = matchScore + aliasBoost; + + // Apply scaling and history boost only if we matched something real + var finalScore = totalMatch * 10; + if (totalMatch > 0) + { + finalScore += history.GetCommandHistoryWeight(id); } return (int)finalScore; } + private static int ScoreWhitespaceQuery(string query, string title, string subtitle, bool isFallback) + { + // Simple contains check for whitespace queries + var nameMatch = title.Contains(query, StringComparison.Ordinal) ? 1.0 : 0; + var descriptionMatch = subtitle.Contains(query, StringComparison.Ordinal) ? 0.5 : 0; + var baseScore = Math.Max(Math.Max(nameMatch, descriptionMatch), isFallback ? 1 : 0); + + return (int)(baseScore * 10); + } + + private static int ScoreFallbackItem(IListItem topLevelOrAppItem, string[] fallbackRanks) + { + // Default to 1 so it always shows in list. + var finalScore = 1; + + if (topLevelOrAppItem is TopLevelViewModel topLevelViewModel) + { + var index = Array.IndexOf(fallbackRanks, topLevelViewModel.Id); + + if (index >= 0) + { + finalScore = fallbackRanks.Length - index + 1; + } + } + + return finalScore; + } + public void UpdateHistory(IListItem topLevelOrAppItem) { var id = IdForTopLevelOrAppItem(topLevelOrAppItem); - var state = _serviceProvider.GetService<AppStateModel>()!; - var history = state.RecentCommands; + var history = _appStateModel.RecentCommands; history.AddHistoryItem(id); - AppStateModel.SaveState(state); + AppStateModel.SaveState(_appStateModel); } - private string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) + private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) { if (topLevelOrAppItem is TopLevelViewModel topLevel) { @@ -280,4 +608,21 @@ public partial class MainListPage : DynamicListPage, private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender); private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails; + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + + _tlcManager.PropertyChanged -= TlcManager_PropertyChanged; + _tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged; + + if (_settings is not null) + { + _settings.SettingsChanged -= SettingsChangedHandler; + } + + WeakReferenceMessenger.Default.UnregisterAll(this); + GC.SuppressFinalize(this); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs new file mode 100644 index 0000000000..0c0d876179 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPageResultFactory.cs @@ -0,0 +1,169 @@ +// 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 warning disable IDE0007 // Use implicit type + +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.UI.ViewModels.Commands; + +internal static class MainListPageResultFactory +{ + /// <summary> + /// Creates a merged and ordered array of results from multiple scored input lists, + /// applying an application result limit and filtering fallback items as needed. + /// </summary> + public static IListItem[] Create( + IList<RoScored<IListItem>>? filteredItems, + IList<RoScored<IListItem>>? scoredFallbackItems, + IList<RoScored<IListItem>>? filteredApps, + IList<RoScored<IListItem>>? fallbackItems, + int appResultLimit) + { + if (appResultLimit < 0) + { + throw new ArgumentOutOfRangeException( + nameof(appResultLimit), "App result limit must be non-negative."); + } + + int len1 = filteredItems?.Count ?? 0; + + // Empty fallbacks are removed prior to this merge. + int len2 = scoredFallbackItems?.Count ?? 0; + + // Apps are pre-sorted, so we just need to take the top N, limited by appResultLimit. + int len3 = Math.Min(filteredApps?.Count ?? 0, appResultLimit); + + int nonEmptyFallbackCount = fallbackItems?.Count ?? 0; + + // Allocate the exact size of the result array. + // We'll add an extra slot for the fallbacks section header if needed. + int totalCount = len1 + len2 + len3 + nonEmptyFallbackCount + (nonEmptyFallbackCount > 0 ? 1 : 0); + + var result = new IListItem[totalCount]; + + // Three-way stable merge of already-sorted lists. + int idx1 = 0, idx2 = 0, idx3 = 0; + int writePos = 0; + + // Merge while all three lists have items. To maintain a stable sort, the + // priority is: list1 > list2 > list3 when scores are equal. + while (idx1 < len1 && idx2 < len2 && idx3 < len3) + { + // Using null-forgiving operator as we have already checked against lengths. + int score1 = filteredItems![idx1].Score; + int score2 = scoredFallbackItems![idx2].Score; + int score3 = filteredApps![idx3].Score; + + if (score1 >= score2 && score1 >= score3) + { + result[writePos++] = filteredItems[idx1++].Item; + } + else if (score2 >= score3) + { + result[writePos++] = scoredFallbackItems[idx2++].Item; + } + else + { + result[writePos++] = filteredApps[idx3++].Item; + } + } + + // Two-way merges for remaining pairs. + while (idx1 < len1 && idx2 < len2) + { + if (filteredItems![idx1].Score >= scoredFallbackItems![idx2].Score) + { + result[writePos++] = filteredItems[idx1++].Item; + } + else + { + result[writePos++] = scoredFallbackItems[idx2++].Item; + } + } + + while (idx1 < len1 && idx3 < len3) + { + if (filteredItems![idx1].Score >= filteredApps![idx3].Score) + { + result[writePos++] = filteredItems[idx1++].Item; + } + else + { + result[writePos++] = filteredApps[idx3++].Item; + } + } + + while (idx2 < len2 && idx3 < len3) + { + if (scoredFallbackItems![idx2].Score >= filteredApps![idx3].Score) + { + result[writePos++] = scoredFallbackItems[idx2++].Item; + } + else + { + result[writePos++] = filteredApps[idx3++].Item; + } + } + + // Drain remaining items from a non-empty list. + while (idx1 < len1) + { + result[writePos++] = filteredItems![idx1++].Item; + } + + while (idx2 < len2) + { + result[writePos++] = scoredFallbackItems![idx2++].Item; + } + + while (idx3 < len3) + { + result[writePos++] = filteredApps![idx3++].Item; + } + + // Append filtered fallback items. Fallback items are added post-sort so they are + // always at the end of the list and are sorted by user settings. + if (fallbackItems is not null) + { + // Create the fallbacks section header + if (fallbackItems.Count > 0) + { + result[writePos++] = new Separator(Properties.Resources.fallbacks); + } + + for (int i = 0; i < fallbackItems.Count; i++) + { + var item = fallbackItems[i].Item; + if (!string.IsNullOrEmpty(item.Title)) + { + result[writePos++] = item; + } + } + } + + return result; + } + + private static int GetNonEmptyFallbackItemsCount(IList<RoScored<IListItem>>? fallbackItems) + { + int fallbackItemsCount = 0; + + if (fallbackItems is not null) + { + for (int i = 0; i < fallbackItems.Count; i++) + { + if (!string.IsNullOrWhiteSpace(fallbackItems[i].Item.Title)) + { + fallbackItemsCount++; + } + } + } + + return fallbackItemsCount; + } +} +#pragma warning restore IDE0007 // Use implicit type diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionForm.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionForm.cs index 326b7ac2b3..62301714e9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionForm.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionForm.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.IO.Compression; +using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -28,77 +29,65 @@ internal sealed partial class NewExtensionForm : NewExtensionFormBase "body": [ { "type": "TextBlock", - "text": "{{Properties.Resources.builtin_create_extension_page_title}}", + "text": {{FormatJsonString(Properties.Resources.builtin_create_extension_page_title)}}, "size": "large" }, - { - "type": "TextBlock", - "text": "{{Properties.Resources.builtin_create_extension_page_text}}", - "wrap": true - }, - { - "type": "TextBlock", - "text": "{{Properties.Resources.builtin_create_extension_name_header}}", - "weight": "bolder", - "size": "default" - }, - { - "type": "TextBlock", - "text": "{{Properties.Resources.builtin_create_extension_name_description}}", - "wrap": true - }, { "type": "Input.Text", - "label": "{{Properties.Resources.builtin_create_extension_name_label}}", + "label": {{FormatJsonString(Properties.Resources.builtin_create_extension_name_label)}}, "isRequired": true, - "errorMessage": "{{Properties.Resources.builtin_create_extension_name_required}}", + "errorMessage": {{FormatJsonString(Properties.Resources.builtin_create_extension_name_required)}}, "id": "ExtensionName", "placeholder": "ExtensionName", - "regex": "^[^\\s]+$" + "regex": "^[a-zA-Z_][a-zA-Z0-9_]*$" }, { "type": "TextBlock", - "text": "{{Properties.Resources.builtin_create_extension_display_name_header}}", - "weight": "bolder", - "size": "default" - }, - { - "type": "TextBlock", - "text": "{{Properties.Resources.builtin_create_extension_display_name_description}}", - "wrap": true + "text": {{FormatJsonString(Properties.Resources.builtin_create_extension_name_description)}}, + "wrap": true, + "size": "small", + "isSubtle": true, + "spacing": "none" }, { "type": "Input.Text", - "label": "{{Properties.Resources.builtin_create_extension_display_name_label}}", + "label": {{FormatJsonString(Properties.Resources.builtin_create_extension_display_name_label)}}, "isRequired": true, - "errorMessage": "{{Properties.Resources.builtin_create_extension_display_name_required}}", + "errorMessage": {{FormatJsonString(Properties.Resources.builtin_create_extension_display_name_required)}}, "id": "DisplayName", - "placeholder": "My new extension" + "placeholder": "My new extension", + "spacing": "medium" }, { "type": "TextBlock", - "text": "{{Properties.Resources.builtin_create_extension_directory_header}}", - "weight": "bolder", - "size": "default" - }, - { - "type": "TextBlock", - "text": "{{Properties.Resources.builtin_create_extension_directory_description}}", - "wrap": true + "text": {{FormatJsonString(Properties.Resources.builtin_create_extension_display_name_description)}}, + "wrap": true, + "size": "small", + "isSubtle": true, + "spacing": "none" }, { "type": "Input.Text", - "label": "{{Properties.Resources.builtin_create_extension_directory_label}}", + "label": {{FormatJsonString(Properties.Resources.builtin_create_extension_directory_label)}}, "isRequired": true, - "errorMessage": "{{Properties.Resources.builtin_create_extension_directory_required}}", + "errorMessage": {{FormatJsonString(Properties.Resources.builtin_create_extension_directory_required)}}, "id": "OutputPath", - "placeholder": "C:\\users\\me\\dev" + "placeholder": "C:\\users\\me\\dev", + "spacing": "medium" + }, + { + "type": "TextBlock", + "text": {{FormatJsonString(Properties.Resources.builtin_create_extension_directory_description)}}, + "wrap": true, + "size": "small", + "isSubtle": true, + "spacing": "none" } ], "actions": [ { "type": "Action.Submit", - "title": "{{Properties.Resources.builtin_create_extension_submit}}", + "title": {{FormatJsonString(Properties.Resources.builtin_create_extension_submit)}}, "associatedInputs": "auto" } ] @@ -109,7 +98,7 @@ internal sealed partial class NewExtensionForm : NewExtensionFormBase public override CommandResult SubmitForm(string payload) { var formInput = JsonNode.Parse(payload)?.AsObject(); - if (formInput == null) + if (formInput is null) { return CommandResult.KeepOpen(); } @@ -192,4 +181,9 @@ internal sealed partial class NewExtensionForm : NewExtensionFormBase // Delete the temp dir Directory.Delete(tempDir, true); } + + private string FormatJsonString(string str) => + + // Escape the string for JSON + JsonSerializer.Serialize(str, JsonSerializationContext.Default.String); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionFormBase.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionFormBase.cs index e68fef10a4..5a68c2a2cf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionFormBase.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionFormBase.cs @@ -6,7 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Compression; using System.Text.Json; using System.Text.Json.Nodes; -using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionPage.cs index 8cfa9658d4..6bdb8a7330 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionPage.cs @@ -14,7 +14,7 @@ public partial class NewExtensionPage : ContentPage public override IContent[] GetContent() { - return _resultForm != null ? [_resultForm] : [_inputForm]; + return _resultForm is not null ? [_resultForm] : [_inputForm]; } public NewExtensionPage() @@ -28,13 +28,13 @@ public partial class NewExtensionPage : ContentPage private void FormSubmitted(NewExtensionFormBase sender, NewExtensionFormBase? args) { - if (_resultForm != null) + if (_resultForm is not null) { _resultForm.FormSubmitted -= FormSubmitted; } _resultForm = args; - if (_resultForm != null) + if (_resultForm is not null) { _resultForm.FormSubmitted += FormSubmitted; } 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 91d6d3c9e3..f0900ec72e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.Messages; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -19,7 +19,7 @@ public partial class OpenSettingsCommand : InvokableCommand public override ICommandResult Invoke() { - WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(); + WeakReferenceMessenger.Default.Send(new OpenSettingsMessage()); return CommandResult.KeepOpen(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/QuitAction.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/QuitAction.cs index d96f46a4a8..bd3cee3159 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/QuitAction.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/QuitAction.cs @@ -13,6 +13,7 @@ public partial class QuitCommand : InvokableCommand, IFallbackHandler { public QuitCommand() { + Id = "com.microsoft.cmdpal.quit"; Icon = new IconInfo("\uE711"); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/ReloadExtensionsCommand.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/ReloadExtensionsCommand.cs index c2fb9f916b..88024efe2f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/ReloadExtensionsCommand.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/ReloadExtensionsCommand.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs index e1bbe0b604..aea2125573 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs @@ -2,12 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using AdaptiveCards.ObjectModel.WinUI3; using AdaptiveCards.Templating; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.CmdPal.UI.ViewModels.Models; +using ManagedCommon; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Windows.Data.Json; @@ -28,47 +31,75 @@ public partial class ContentFormViewModel(IFormContent _form, WeakReference<IPag public AdaptiveCardParseResult? Card { get; private set; } + private static string Serialize(string? s) => + JsonSerializer.Serialize(s, JsonSerializationContext.Default.String); + + private static bool TryBuildCard( + string templateJson, + string dataJson, + out AdaptiveCardParseResult? card, + out Exception? error) + { + card = null; + error = null; + + try + { + var template = new AdaptiveCardTemplate(templateJson); + var cardJson = template.Expand(dataJson); + card = AdaptiveCard.FromJsonString(cardJson); + return true; + } + catch (Exception ex) + { + Logger.LogError("Error building card from template", ex); + error = ex; + return false; + } + } + public override void InitializeProperties() { var model = _formModel.Unsafe; - if (model == null) + if (model is null) { return; } - try - { - TemplateJson = model.TemplateJson; - StateJson = model.StateJson; - DataJson = model.DataJson; + TemplateJson = model.TemplateJson; + StateJson = model.StateJson; + DataJson = model.DataJson; - AdaptiveCardTemplate template = new(TemplateJson); - var cardJson = template.Expand(DataJson); - Card = AdaptiveCard.FromJsonString(cardJson); + if (TryBuildCard(TemplateJson, DataJson, out var builtCard, out var renderingError)) + { + Card = builtCard; + UpdateProperty(nameof(Card)); + return; } - catch (Exception e) - { - // If we fail to parse the card JSON, then display _our own card_ - // with the exception - AdaptiveCardTemplate template = new(ErrorCardJson); - // todo: we could probably stick Card.Errors in there too - var dataJson = $$""" -{ - "error_message": {{JsonSerializer.Serialize(e.Message)}}, - "error_stack": {{JsonSerializer.Serialize(e.StackTrace)}}, - "inner_exception": {{JsonSerializer.Serialize(e.InnerException?.Message)}}, - "template_json": {{JsonSerializer.Serialize(TemplateJson)}}, - "data_json": {{JsonSerializer.Serialize(DataJson)}} -} -"""; - var cardJson = template.Expand(dataJson); - Card = AdaptiveCard.FromJsonString(cardJson); + var errorPayload = $$""" + { + "error_message": {{Serialize(renderingError!.Message)}}, + "error_stack": {{Serialize(renderingError.StackTrace)}}, + "inner_exception": {{Serialize(renderingError.InnerException?.Message)}}, + "template_json": {{Serialize(TemplateJson)}}, + "data_json": {{Serialize(DataJson)}} + } + """; + + if (TryBuildCard(ErrorCardJson, errorPayload, out var errorCard, out var _)) + { + Card = errorCard; + UpdateProperty(nameof(Card)); + return; } UpdateProperty(nameof(Card)); } + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AdaptiveOpenUrlAction))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AdaptiveSubmitAction))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AdaptiveExecuteAction))] public void HandleSubmit(IAdaptiveActionElement action, JsonObject inputs) { if (action is AdaptiveOpenUrlAction openUrlAction) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentMarkdownViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentMarkdownViewModel.cs index c8508e414a..c747bfc231 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentMarkdownViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentMarkdownViewModel.cs @@ -2,7 +2,8 @@ // 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.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.UI.ViewModels; @@ -19,7 +20,7 @@ public partial class ContentMarkdownViewModel(IMarkdownContent _markdown, WeakRe public override void InitializeProperties() { var model = Model.Unsafe; - if (model == null) + if (model is null) { return; } @@ -46,7 +47,7 @@ public partial class ContentMarkdownViewModel(IMarkdownContent _markdown, WeakRe protected void FetchProperty(string propertyName) { var model = Model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } @@ -65,7 +66,7 @@ public partial class ContentMarkdownViewModel(IMarkdownContent _markdown, WeakRe { base.UnsafeCleanup(); var model = Model.Unsafe; - if (model != null) + if (model is not null) { model.PropChanged -= Model_PropChanged; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs index 77734921aa..cafb351d21 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs @@ -3,7 +3,8 @@ // See the LICENSE file in the project root for more information. using System.Collections.ObjectModel; -using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -24,20 +25,20 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference<IPag // This is the content that's actually bound in XAML. We needed a // collection, even if the collection is just a single item. - public ObservableCollection<ContentViewModel> Root => [RootContent]; + public ObservableCollection<ContentViewModel> Root => RootContent is not null ? [RootContent] : []; public override void InitializeProperties() { var model = Model.Unsafe; - if (model == null) + if (model is null) { return; } var root = model.RootContent; - if (root != null) + if (root is not null) { - RootContent = ContentPageViewModel.ViewModelFromContent(root, PageContext); + RootContent = ViewModelFromContent(root, PageContext); RootContent?.InitializeProperties(); UpdateProperty(nameof(RootContent)); UpdateProperty(nameof(Root)); @@ -48,6 +49,20 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference<IPag model.ItemsChanged += Model_ItemsChanged; } + // Theoretically, we should unify this with the one in CommandPalettePageViewModelFactory + // and maybe just have a ContentViewModelFactory or something + public ContentViewModel? ViewModelFromContent(IContent content, WeakReference<IPageContext> context) + { + ContentViewModel? viewModel = content switch + { + IFormContent form => new ContentFormViewModel(form, context), + IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context), + ITreeContent tree => new ContentTreeViewModel(tree, context), + _ => null, + }; + return viewModel; + } + // TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching? private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchContent(); @@ -67,7 +82,7 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference<IPag protected void FetchProperty(string propertyName) { var model = Model.Unsafe; - if (model == null) + if (model is null) { return; // throw? } @@ -76,9 +91,9 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference<IPag { case nameof(RootContent): var root = model.RootContent; - if (root != null) + if (root is not null) { - RootContent = ContentPageViewModel.ViewModelFromContent(root, PageContext); + RootContent = ViewModelFromContent(root, PageContext); } else { @@ -103,11 +118,11 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference<IPag foreach (var item in newItems) { - var viewModel = ContentPageViewModel.ViewModelFromContent(item, PageContext); - if (viewModel != null) + var viewModel = ViewModelFromContent(item, PageContext); + if (viewModel is not null) { viewModel.InitializeProperties(); - newContent.Add(viewModel); + newContent.Add((ContentViewModel)viewModel); } } } @@ -138,7 +153,7 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference<IPag Children.Clear(); var model = Model.Unsafe; - if (model != null) + if (model is not null) { model.PropChanged -= Model_PropChanged; model.ItemsChanged -= Model_ItemsChanged; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsLinkViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsLinkViewModel.cs deleted file mode 100644 index 0d600958da..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DetailsLinkViewModel.cs +++ /dev/null @@ -1,46 +0,0 @@ -// 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.UI.ViewModels.Models; -using Microsoft.CommandPalette.Extensions; - -namespace Microsoft.CmdPal.UI.ViewModels; - -public partial class DetailsLinkViewModel( - IDetailsElement _detailsElement, - WeakReference<IPageContext> context) : DetailsElementViewModel(_detailsElement, context) -{ - private readonly ExtensionObject<IDetailsLink> _dataModel = - new(_detailsElement.Data as IDetailsLink); - - public string Text { get; private set; } = string.Empty; - - public Uri? Link { get; private set; } - - public bool IsLink => Link != null; - - public bool IsText => !IsLink; - - public override void InitializeProperties() - { - base.InitializeProperties(); - var model = _dataModel.Unsafe; - if (model == null) - { - return; - } - - Text = model.Text ?? string.Empty; - Link = model.Link; - if (string.IsNullOrEmpty(Text) && Link != null) - { - Text = Link.ToString(); - } - - UpdateProperty(nameof(Text)); - UpdateProperty(nameof(Link)); - UpdateProperty(nameof(IsLink)); - UpdateProperty(nameof(IsText)); - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs deleted file mode 100644 index 08d1128106..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs +++ /dev/null @@ -1,90 +0,0 @@ -// 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 CommunityToolkit.Mvvm.ComponentModel; -using ManagedCommon; - -namespace Microsoft.CmdPal.UI.ViewModels; - -public abstract partial class ExtensionObjectViewModel : ObservableObject -{ - public WeakReference<IPageContext> PageContext { get; set; } - - public ExtensionObjectViewModel(IPageContext? context) - { - var realContext = context ?? (this is IPageContext c ? c : throw new ArgumentException("You need to pass in an IErrorContext")); - PageContext = new(realContext); - } - - public ExtensionObjectViewModel(WeakReference<IPageContext> context) - { - PageContext = context; - } - - public async virtual Task InitializePropertiesAsync() - { - var t = new Task(() => - { - SafeInitializePropertiesSynchronous(); - }); - t.Start(); - await t; - } - - public void SafeInitializePropertiesSynchronous() - { - try - { - InitializeProperties(); - } - catch (Exception ex) - { - ShowException(ex); - } - } - - public abstract void InitializeProperties(); - - protected void UpdateProperty(string propertyName) - { - DoOnUiThread(() => OnPropertyChanged(propertyName)); - } - - protected void ShowException(Exception ex, string? extensionHint = null) - { - if (PageContext.TryGetTarget(out var pageContext)) - { - pageContext.ShowException(ex, extensionHint); - } - } - - protected void DoOnUiThread(Action action) - { - if (PageContext.TryGetTarget(out var pageContext)) - { - Task.Factory.StartNew( - action, - CancellationToken.None, - TaskCreationOptions.None, - pageContext.Scheduler); - } - } - - protected virtual void UnsafeCleanup() - { - // base doesn't do anything, but sub-classes should override this. - } - - public virtual void SafeCleanup() - { - try - { - UnsafeCleanup(); - } - catch (Exception ex) - { - Logger.LogDebug(ex.ToString()); - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettings.cs new file mode 100644 index 0000000000..38b76957c3 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettings.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public class FallbackSettings +{ + public bool IsEnabled { get; set; } = true; + + public bool IncludeInGlobalResults { get; set; } + + public FallbackSettings() + { + } + + public FallbackSettings(bool isBuiltIn) + { + IncludeInGlobalResults = isBuiltIn; + } + + [JsonConstructor] + public FallbackSettings(bool isEnabled, bool includeInGlobalResults) + { + IsEnabled = isEnabled; + IncludeInGlobalResults = includeInGlobalResults; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs new file mode 100644 index 0000000000..fbba4ce3f4 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Messages; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class FallbackSettingsViewModel : ObservableObject +{ + private readonly SettingsModel _settings; + private readonly FallbackSettings _fallbackSettings; + + public string DisplayName { get; private set; } = string.Empty; + + public IconInfoViewModel Icon { get; private set; } = new(null); + + public string Id { get; private set; } = string.Empty; + + public bool IsEnabled + { + get => _fallbackSettings.IsEnabled; + set + { + if (value != _fallbackSettings.IsEnabled) + { + _fallbackSettings.IsEnabled = value; + + if (!_fallbackSettings.IsEnabled) + { + _fallbackSettings.IncludeInGlobalResults = false; + } + + Save(); + OnPropertyChanged(nameof(IsEnabled)); + } + } + } + + public bool IncludeInGlobalResults + { + get => _fallbackSettings.IncludeInGlobalResults; + set + { + if (value != _fallbackSettings.IncludeInGlobalResults) + { + _fallbackSettings.IncludeInGlobalResults = value; + + if (!_fallbackSettings.IsEnabled) + { + _fallbackSettings.IsEnabled = true; + } + + Save(); + OnPropertyChanged(nameof(IncludeInGlobalResults)); + } + } + } + + public FallbackSettingsViewModel( + TopLevelViewModel fallback, + FallbackSettings fallbackSettings, + SettingsModel settingsModel, + ProviderSettingsViewModel providerSettings) + { + _settings = settingsModel; + _fallbackSettings = fallbackSettings; + + Id = fallback.Id; + DisplayName = string.IsNullOrWhiteSpace(fallback.DisplayTitle) + ? (string.IsNullOrWhiteSpace(fallback.Title) ? providerSettings.DisplayName : fallback.Title) + : fallback.DisplayTitle; + + Icon = new(fallback.InitialIcon); + Icon.InitializeProperties(); + } + + private void Save() + { + SettingsModel.SaveSettings(_settings); + WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new()); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs index 36498cbcaa..7fcd2ec7fd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs @@ -29,7 +29,7 @@ public partial class HotkeyManager : ObservableObject } } - _commandHotkeys.RemoveAll(item => item.Hotkey == null); + _commandHotkeys.RemoveAll(item => item.Hotkey is null); foreach (var item in _commandHotkeys) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs deleted file mode 100644 index ab7990e781..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Diagnostics.CodeAnalysis; -using Microsoft.CmdPal.UI.ViewModels.Models; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.UI.ViewModels; - -public partial class ListItemViewModel(IListItem model, WeakReference<IPageContext> context) - : CommandItemViewModel(new(model), context) -{ - public new ExtensionObject<IListItem> Model { get; } = new(model); - - public List<TagViewModel>? Tags { get; set; } - - // Remember - "observable" properties from the model (via PropChanged) - // cannot be marked [ObservableProperty] - public bool HasTags => (Tags?.Count ?? 0) > 0; - - public string TextToSuggest { get; private set; } = string.Empty; - - public string Section { get; private set; } = string.Empty; - - public DetailsViewModel? Details { get; private set; } - - [MemberNotNullWhen(true, nameof(Details))] - public bool HasDetails => Details != null; - - public override void InitializeProperties() - { - if (IsInitialized) - { - return; - } - - // This sets IsInitialized = true - base.InitializeProperties(); - - var li = Model.Unsafe; - if (li == null) - { - return; // throw? - } - - UpdateTags(li.Tags); - - TextToSuggest = li.TextToSuggest; - Section = li.Section ?? string.Empty; - var extensionDetails = li.Details; - if (extensionDetails != null) - { - Details = new(extensionDetails, PageContext); - Details.InitializeProperties(); - UpdateProperty(nameof(Details)); - UpdateProperty(nameof(HasDetails)); - } - - UpdateProperty(nameof(TextToSuggest)); - UpdateProperty(nameof(Section)); - } - - protected override void FetchProperty(string propertyName) - { - base.FetchProperty(propertyName); - - var model = this.Model.Unsafe; - if (model == null) - { - return; // throw? - } - - switch (propertyName) - { - case nameof(Tags): - UpdateTags(model.Tags); - break; - case nameof(TextToSuggest): - this.TextToSuggest = model.TextToSuggest ?? string.Empty; - break; - case nameof(Section): - this.Section = model.Section ?? string.Empty; - break; - case nameof(Details): - var extensionDetails = model.Details; - Details = extensionDetails != null ? new(extensionDetails, PageContext) : null; - Details?.InitializeProperties(); - UpdateProperty(nameof(Details)); - UpdateProperty(nameof(HasDetails)); - break; - } - - UpdateProperty(propertyName); - } - - // TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes? - // TODO: Do we want to save off the score here so we can sort by it in our ListViewModel? - public bool MatchesFilter(string filter) => StringMatcher.FuzzySearch(filter, Title).Success || StringMatcher.FuzzySearch(filter, Subtitle).Success; - - public override string ToString() => $"{Name} ListItemViewModel"; - - public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model); - - public override int GetHashCode() => Model.GetHashCode(); - - private void UpdateTags(ITag[]? newTagsFromModel) - { - DoOnUiThread( - () => - { - var newTags = newTagsFromModel?.Select(t => - { - var vm = new TagViewModel(t, PageContext); - vm.InitializeProperties(); - return vm; - }) - .ToList() ?? []; - - // Tags being an ObservableCollection instead of a List lead to - // many COM exception issues. - Tags = new(newTags); - - UpdateProperty(nameof(Tags)); - UpdateProperty(nameof(HasTags)); - }); - } - - protected override void UnsafeCleanup() - { - base.UnsafeCleanup(); - - // Tags don't have event handlers or anything to cleanup - Tags?.ForEach(t => t.SafeCleanup()); - Details?.SafeCleanup(); - - var model = Model.Unsafe; - if (model != null) - { - // We don't need to revoke the PropChanged event handler here, - // because we are just overriding CommandItem's FetchProperty and - // piggy-backing off their PropChanged - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs deleted file mode 100644 index f1c6aa4695..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ /dev/null @@ -1,498 +0,0 @@ -// 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.ObjectModel; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.CmdPal.UI.ViewModels.Models; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Foundation; - -namespace Microsoft.CmdPal.UI.ViewModels; - -public partial class ListViewModel : PageViewModel, IDisposable -{ - // private readonly HashSet<ListItemViewModel> _itemCache = []; - - // TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items? - - // Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change - // https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/observablegroupedcollections for grouping support - [ObservableProperty] - public partial ObservableCollection<ListItemViewModel> FilteredItems { get; set; } = []; - - private ObservableCollection<ListItemViewModel> Items { get; set; } = []; - - private readonly ExtensionObject<IListPage> _model; - - private readonly Lock _listLock = new(); - - private bool _isLoading; - private bool _isFetching; - - public event TypedEventHandler<ListViewModel, object>? ItemsUpdated; - - public bool ShowEmptyContent => - IsInitialized && - FilteredItems.Count == 0 && - (!_isFetching) && - IsLoading == false; - - // Remember - "observable" properties from the model (via PropChanged) - // cannot be marked [ObservableProperty] - public bool ShowDetails { get; private set; } - - private string _modelPlaceholderText = string.Empty; - - public override string PlaceholderText => _modelPlaceholderText; - - public string SearchText { get; private set; } = string.Empty; - - public CommandItemViewModel EmptyContent { get; private set; } - - private bool _isDynamic; - - private Task? _initializeItemsTask; - private CancellationTokenSource? _cancellationTokenSource; - - public override bool IsInitialized - { - get => base.IsInitialized; protected set - { - base.IsInitialized = value; - UpdateEmptyContent(); - } - } - - public ListViewModel(IListPage model, TaskScheduler scheduler, CommandPaletteHost host) - : base(model, scheduler, host) - { - _model = new(model); - EmptyContent = new(new(null), PageContext); - } - - // TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching? - private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(); - - protected override void OnFilterUpdated(string filter) - { - //// TODO: Just temp testing, need to think about where we want to filter, as ACVS in View could be done, but then grouping need CVS, maybe we do grouping in view - //// and manage filtering below, but we should be smarter about this and understand caching and other requirements... - //// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList? - - // Dynamic pages will handler their own filtering. They will tell us if - // something needs to change, by raising ItemsChanged. - if (_isDynamic) - { - // We're getting called on the UI thread. - // Hop off to a BG thread to update the extension. - _ = Task.Run(() => - { - try - { - if (_model.Unsafe is IDynamicListPage dynamic) - { - dynamic.SearchText = filter; - } - } - catch (Exception ex) - { - ShowException(ex, _model?.Unsafe?.Name); - } - }); - } - else - { - // But for all normal pages, we should run our fuzzy match on them. - lock (_listLock) - { - ApplyFilterUnderLock(); - } - - ItemsUpdated?.Invoke(this, EventArgs.Empty); - UpdateEmptyContent(); - _isLoading = false; - } - } - - //// Run on background thread, from InitializeAsync or Model_ItemsChanged - private void FetchItems() - { - // TEMPORARY: just plop all the items into a single group - // see 9806fe5d8 for the last commit that had this with sections - _isFetching = true; - - try - { - IListItem[] newItems = _model.Unsafe!.GetItems(); - - // Collect all the items into new viewmodels - Collection<ListItemViewModel> newViewModels = []; - - // TODO we can probably further optimize this by also keeping a - // HashSet of every ExtensionObject we currently have, and only - // building new viewmodels for the ones we haven't already built. - foreach (IListItem? item in newItems) - { - ListItemViewModel viewModel = new(item, new(this)); - - // If an item fails to load, silently ignore it. - if (viewModel.SafeFastInit()) - { - newViewModels.Add(viewModel); - } - } - - IEnumerable<ListItemViewModel> firstTwenty = newViewModels.Take(20); - foreach (ListItemViewModel? item in firstTwenty) - { - item?.SafeInitializeProperties(); - } - - // Cancel any ongoing search - if (_cancellationTokenSource != null) - { - _cancellationTokenSource.Cancel(); - } - - lock (_listLock) - { - // Now that we have new ViewModels for everything from the - // extension, smartly update our list of VMs - ListHelpers.InPlaceUpdateList(Items, newViewModels); - } - - // TODO: Iterate over everything in Items, and prune items from the - // cache if we don't need them anymore - } - catch (Exception ex) - { - // TODO: Move this within the for loop, so we can catch issues with individual items - // Create a special ListItemViewModel for errors and use an ItemTemplateSelector in the ListPage to display error items differently. - ShowException(ex, _model?.Unsafe?.Name); - throw; - } - finally - { - _isFetching = false; - } - - _cancellationTokenSource = new CancellationTokenSource(); - - _initializeItemsTask = new Task(() => - { - try - { - InitializeItemsTask(_cancellationTokenSource.Token); - } - catch (OperationCanceledException) - { - } - }); - _initializeItemsTask.Start(); - - DoOnUiThread( - () => - { - lock (_listLock) - { - // Now that our Items contains everything we want, it's time for us to - // re-evaluate our Filter on those items. - if (!_isDynamic) - { - // A static list? Great! Just run the filter. - ApplyFilterUnderLock(); - } - else - { - // A dynamic list? Even better! Just stick everything into - // FilteredItems. The extension already did any filtering it cared about. - ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState)); - } - - UpdateEmptyContent(); - } - - ItemsUpdated?.Invoke(this, EventArgs.Empty); - _isLoading = false; - }); - } - - private void InitializeItemsTask(CancellationToken ct) - { - // Were we already canceled? - ct.ThrowIfCancellationRequested(); - - ListItemViewModel[] iterable; - lock (_listLock) - { - iterable = Items.ToArray(); - } - - foreach (ListItemViewModel item in iterable) - { - ct.ThrowIfCancellationRequested(); - - // TODO: GH #502 - // We should probably remove the item from the list if it - // entered the error state. I had issues doing that without having - // multiple threads muck with `Items` (and possibly FilteredItems!) - // at once. - item.SafeInitializeProperties(); - - ct.ThrowIfCancellationRequested(); - } - } - - /// <summary> - /// Apply our current filter text to the list of items, and update - /// FilteredItems to match the results. - /// </summary> - private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, Filter)); - - /// <summary> - /// Helper to generate a weighting for a given list item, based on title, - /// subtitle, etc. Largely a copy of the version in ListHelpers, but - /// operating on ViewModels instead of extension objects. - /// </summary> - private static int ScoreListItem(string query, CommandItemViewModel listItem) - { - if (string.IsNullOrEmpty(query)) - { - return 1; - } - - MatchResult nameMatch = StringMatcher.FuzzySearch(query, listItem.Title); - MatchResult descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle); - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); - } - - private struct ScoredListItemViewModel - { - public int Score; - public ListItemViewModel ViewModel; - } - - // Similarly stolen from ListHelpers.FilterList - public static IEnumerable<ListItemViewModel> FilterList(IEnumerable<ListItemViewModel> items, string query) - { - IOrderedEnumerable<ScoredListItemViewModel> scores = items - .Where(i => !i.IsInErrorState) - .Select(li => new ScoredListItemViewModel() { ViewModel = li, Score = ScoreListItem(query, li) }) - .Where(score => score.Score > 0) - .OrderByDescending(score => score.Score); - return scores - .Select(score => score.ViewModel); - } - - // InvokeItemCommand is what this will be in Xaml due to source generator - // This is what gets invoked when the user presses <enter> - [RelayCommand] - private void InvokeItem(ListItemViewModel? item) - { - if (item != null) - { - WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command.Model, item.Model)); - } - else if (ShowEmptyContent && EmptyContent.PrimaryCommand?.Model.Unsafe != null) - { - WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new( - EmptyContent.PrimaryCommand.Command.Model, - EmptyContent.PrimaryCommand.Model)); - } - } - - // This is what gets invoked when the user presses <ctrl+enter> - [RelayCommand] - private void InvokeSecondaryCommand(ListItemViewModel? item) - { - if (item != null) - { - if (item.SecondaryCommand != null) - { - WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.SecondaryCommand.Command.Model, item.Model)); - } - } - else if (ShowEmptyContent && EmptyContent.SecondaryCommand?.Model.Unsafe != null) - { - WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new( - EmptyContent.SecondaryCommand.Command.Model, - EmptyContent.SecondaryCommand.Model)); - } - } - - [RelayCommand] - private void UpdateSelectedItem(ListItemViewModel item) - { - if (!item.SafeSlowInit()) - { - return; - } - - // GH #322: - // For inexplicable reasons, if you try updating the command bar and - // the details on the same UI thread tick as updating the list, we'll - // explode - DoOnUiThread( - () => - { - WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item)); - - if (ShowDetails && item.HasDetails) - { - WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details)); - } - else - { - WeakReferenceMessenger.Default.Send<HideDetailsMessage>(); - } - - TextToSuggest = item.TextToSuggest; - }); - } - - public override void InitializeProperties() - { - base.InitializeProperties(); - - IListPage? model = _model.Unsafe; - if (model == null) - { - return; // throw? - } - - _isDynamic = model is IDynamicListPage; - - ShowDetails = model.ShowDetails; - UpdateProperty(nameof(ShowDetails)); - - _modelPlaceholderText = model.PlaceholderText; - UpdateProperty(nameof(PlaceholderText)); - - SearchText = model.SearchText; - UpdateProperty(nameof(SearchText)); - - EmptyContent = new(new(model.EmptyContent), PageContext); - EmptyContent.SlowInitializeProperties(); - - FetchItems(); - model.ItemsChanged += Model_ItemsChanged; - } - - public void LoadMoreIfNeeded() - { - IListPage? model = this._model.Unsafe; - if (model == null) - { - return; - } - - if (model.HasMoreItems && !_isLoading) - { - _isLoading = true; - _ = Task.Run(() => - { - try - { - model.LoadMore(); - } - catch (Exception ex) - { - ShowException(ex, model.Name); - } - }); - } - } - - protected override void FetchProperty(string propertyName) - { - base.FetchProperty(propertyName); - - IListPage? model = this._model.Unsafe; - if (model == null) - { - return; // throw? - } - - switch (propertyName) - { - case nameof(ShowDetails): - this.ShowDetails = model.ShowDetails; - break; - case nameof(PlaceholderText): - this._modelPlaceholderText = model.PlaceholderText; - break; - case nameof(SearchText): - this.SearchText = model.SearchText; - break; - case nameof(EmptyContent): - EmptyContent = new(new(model.EmptyContent), PageContext); - EmptyContent.InitializeProperties(); - break; - case nameof(IsLoading): - UpdateEmptyContent(); - break; - } - - UpdateProperty(propertyName); - } - - private void UpdateEmptyContent() - { - UpdateProperty(nameof(ShowEmptyContent)); - if (!ShowEmptyContent || EmptyContent.Model.Unsafe == null) - { - return; - } - - DoOnUiThread( - () => - { - WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(EmptyContent)); - }); - } - - public void Dispose() - { - GC.SuppressFinalize(this); - _cancellationTokenSource?.Cancel(); - _cancellationTokenSource?.Dispose(); - _cancellationTokenSource = null; - } - - protected override void UnsafeCleanup() - { - base.UnsafeCleanup(); - - EmptyContent?.SafeCleanup(); - EmptyContent = new(new(null), PageContext); // necessary? - - _cancellationTokenSource?.Cancel(); - - lock (_listLock) - { - foreach (ListItemViewModel item in Items) - { - item.SafeCleanup(); - } - - Items.Clear(); - foreach (ListItemViewModel item in FilteredItems) - { - item.SafeCleanup(); - } - - FilteredItems.Clear(); - } - - IListPage? model = _model.Unsafe; - if (model != null) - { - model.ItemsChanged -= Model_ItemsChanged; - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000000..4d775083f0 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainWindowViewModel.cs @@ -0,0 +1,98 @@ +// 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 CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class MainWindowViewModel : ObservableObject, IDisposable +{ + private readonly IThemeService _themeService; + private readonly DispatcherQueue _uiDispatcherQueue = DispatcherQueue.GetForCurrentThread()!; + + [ObservableProperty] + public partial ImageSource? BackgroundImageSource { get; private set; } + + [ObservableProperty] + public partial Stretch BackgroundImageStretch { get; private set; } = Stretch.Fill; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(EffectiveImageOpacity))] + public partial double BackgroundImageOpacity { get; private set; } + + [ObservableProperty] + public partial Color BackgroundImageTint { get; private set; } + + [ObservableProperty] + public partial double BackgroundImageTintIntensity { get; private set; } + + [ObservableProperty] + public partial int BackgroundImageBlurAmount { get; private set; } + + [ObservableProperty] + public partial double BackgroundImageBrightness { get; private set; } + + [ObservableProperty] + public partial bool ShowBackgroundImage { get; private set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(EffectiveBackdropStyle))] + [NotifyPropertyChangedFor(nameof(EffectiveImageOpacity))] + public partial BackdropStyle BackdropStyle { get; private set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(EffectiveBackdropStyle))] + [NotifyPropertyChangedFor(nameof(EffectiveImageOpacity))] + public partial float BackdropOpacity { get; private set; } = 1.0f; + + // Returns null when no transparency needed (BlurImageControl uses this to decide source type) + public BackdropStyle? EffectiveBackdropStyle => + BackdropStyle == BackdropStyle.Clear || + BackdropStyle == BackdropStyle.Mica || + BackdropOpacity < 1.0f + ? BackdropStyle + : null; + + // When transparency is enabled, use square root curve so image stays visible longer as backdrop fades + public double EffectiveImageOpacity => + EffectiveBackdropStyle is not null + ? BackgroundImageOpacity * Math.Sqrt(BackdropOpacity) + : BackgroundImageOpacity; + + public MainWindowViewModel(IThemeService themeService) + { + _themeService = themeService; + _themeService.ThemeChanged += ThemeService_ThemeChanged; + } + + private void ThemeService_ThemeChanged(object? sender, ThemeChangedEventArgs e) + { + _uiDispatcherQueue.TryEnqueue(() => + { + BackgroundImageSource = _themeService.Current.BackgroundImageSource; + BackgroundImageStretch = _themeService.Current.BackgroundImageStretch; + BackgroundImageOpacity = _themeService.Current.BackgroundImageOpacity; + + BackgroundImageBrightness = _themeService.Current.BackgroundBrightness; + BackgroundImageTint = _themeService.Current.Tint; + BackgroundImageTintIntensity = _themeService.Current.TintIntensity; + BackgroundImageBlurAmount = _themeService.Current.BlurAmount; + + BackdropStyle = _themeService.Current.BackdropParameters.Style; + BackdropOpacity = _themeService.Current.BackdropOpacity; + + ShowBackgroundImage = BackgroundImageSource != null; + }); + } + + public void Dispose() + { + _themeService.ThemeChanged -= ThemeService_ThemeChanged; + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigateBackMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigateBackMessage.cs deleted file mode 100644 index 5e19bfc57f..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigateBackMessage.cs +++ /dev/null @@ -1,9 +0,0 @@ -// 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.Messages; - -public record NavigateBackMessage(bool FromBackspace = false) -{ -} 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 115593d5ae..54909710a5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs @@ -1,11 +1,7 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; +namespace Microsoft.CmdPal.UI.Messages; -namespace Microsoft.CmdPal.UI.ViewModels.Messages; - -public record OpenSettingsMessage() -{ -} +public record OpenSettingsMessage(string SettingsPageTag = ""); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/QuitMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/QuitMessage.cs index 2951aa57fb..ae65782336 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/QuitMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/QuitMessage.cs @@ -1,9 +1,7 @@ -// 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 Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; - namespace Microsoft.CmdPal.UI.ViewModels.Messages; /// <summary> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ReloadCommandsMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ReloadCommandsMessage.cs index c427da4f9c..cba0fa3f56 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ReloadCommandsMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ReloadCommandsMessage.cs @@ -1,4 +1,4 @@ -// 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. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/DismissMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ReloadFinishedMessage.cs similarity index 71% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/DismissMessage.cs rename to src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ReloadFinishedMessage.cs index 273952897d..a6e2122edd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/DismissMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ReloadFinishedMessage.cs @@ -1,9 +1,7 @@ -// 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. namespace Microsoft.CmdPal.UI.ViewModels.Messages; -public record DismissMessage() -{ -} +public record ReloadFinishedMessage(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs deleted file mode 100644 index 0a540c7408..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs +++ /dev/null @@ -1,34 +0,0 @@ -// 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.UI.ViewModels.Messages; - -/// <summary> -/// Used to update the command bar at the bottom to reflect the commands for a list item -/// </summary> -public record UpdateCommandBarMessage(ICommandBarContext? ViewModel) -{ -} - -// Represents everything the command bar needs to know about to show command -// buttons at the bottom. -// -// This is implemented by both ListItemViewModel and ContentPageViewModel, -// the two things with sub-commands. -public interface ICommandBarContext : INotifyPropertyChanged -{ - public IEnumerable<CommandContextItemViewModel> MoreCommands { get; } - - public bool HasMoreCommands { get; } - - public string SecondaryCommandName { get; } - - public CommandItemViewModel? PrimaryCommand { get; } - - public CommandItemViewModel? SecondaryCommand { get; } - - public List<CommandContextItemViewModel> AllCommands { get; } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateFallbackItemsMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateFallbackItemsMessage.cs index c7503ce1fb..08e65c2213 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateFallbackItemsMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateFallbackItemsMessage.cs @@ -1,4 +1,4 @@ -// 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. diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj index ed8831915a..187c835e37 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj @@ -1,5 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> @@ -12,11 +14,23 @@ <NoWarn>SA1313;</NoWarn> </PropertyGroup> + <PropertyGroup> + <CsWinRTIncludes>AdaptiveCards.ObjectModel.WinUI3;AdaptiveCards.Rendering.WinUI3</CsWinRTIncludes> + <CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled> + </PropertyGroup> + <ItemGroup> <PackageReference Include="CommunityToolkit.Common" /> <PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="AdaptiveCards.Templating" /> - <PackageReference Include="AdaptiveCards.ObjectModel.WinUI3" GeneratePathProperty="true" /> + <PackageReference Include="CommunityToolkit.WinUI.Extensions" /> + <PackageReference Include="Microsoft.Bot.AdaptiveExpressions.Core" /> + <PackageReference Include="AdaptiveCards.ObjectModel.WinUI3" GeneratePathProperty="true"> + <ExcludeAssets>compile</ExcludeAssets> + </PackageReference> + <PackageReference Include="AdaptiveCards.Rendering.WinUI3" GeneratePathProperty="True"> + <ExcludeAssets>compile</ExcludeAssets> + </PackageReference> <PackageReference Include="Microsoft.Windows.CsWin32"> <PrivateAssets>all</PrivateAssets> @@ -26,12 +40,27 @@ <PackageReference Include="WyHash" /> </ItemGroup> - + <!-- <AdaptiveCardsWorkaround> --> + <!-- Workaround for Adaptive Cards not supporting correct RIDs when using .NET 8. + Don't forget GeneratePathProperty on the AdaptiveCards PackageReference's above --> + <PropertyGroup> + <AdaptiveCardsNative>runtimes\win10-$(Platform)\native</AdaptiveCardsNative> + </PropertyGroup> + <ItemGroup> + <CsWinRTInputs Include="$(PkgAdaptiveCards_ObjectModel_WinUI3)\lib\uap10.0\AdaptiveCards.ObjectModel.WinUI3.winmd" /> + <None Include="$(PkgAdaptiveCards_ObjectModel_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.ObjectModel.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> + <ItemGroup> + <CsWinRTInputs Include="$(PkgAdaptiveCards_Rendering_WinUI3)\lib\uap10.0\AdaptiveCards.Rendering.WinUI3.winmd" /> + <Content Include="$(PkgAdaptiveCards_Rendering_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.Rendering.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> + <ItemGroup> - <ProjectReference Include="..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" /> + <ProjectReference Include="..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> + <ProjectReference Include="..\Core\Microsoft.CmdPal.Core.ViewModels\Microsoft.CmdPal.Core.ViewModels.csproj" /> <ProjectReference Include="..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.Apps\Microsoft.CmdPal.Ext.Apps.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.Apps\Microsoft.CmdPal.Ext.Apps.csproj" /> <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> @@ -66,5 +95,4 @@ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> </ItemGroup> - </Project> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs index 576ea08140..d92068c71d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs @@ -2,7 +2,8 @@ // 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.Common.Services; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CommandPalette.Extensions; using Windows.ApplicationModel; using Windows.ApplicationModel.AppExtensions; @@ -11,7 +12,7 @@ using Windows.Foundation.Collections; namespace Microsoft.CmdPal.UI.ViewModels.Models; -public class ExtensionService : IExtensionService, IDisposable +public partial class ExtensionService : IExtensionService, IDisposable { public event TypedEventHandler<IExtensionService, IEnumerable<IExtensionWrapper>>? OnExtensionAdded; @@ -89,7 +90,7 @@ public class ExtensionService : IExtensionService, IDisposable }).Result; var isExtension = isCmdPalExtensionResult.IsExtension; var extension = isCmdPalExtensionResult.Extension; - if (isExtension && extension != null) + if (isExtension && extension is not null) { CommandPaletteHost.Instance.DebugLog($"Installed new extension app {extension.DisplayName}"); @@ -151,7 +152,7 @@ public class ExtensionService : IExtensionService, IDisposable { var (cmdPalProvider, classId) = await GetCmdPalExtensionPropertiesAsync(extension); - return new(cmdPalProvider != null && classId.Count != 0, extension); + return new(cmdPalProvider is not null && classId.Count != 0, extension); } } @@ -236,7 +237,7 @@ public class ExtensionService : IExtensionService, IDisposable { var (cmdPalProvider, classIds) = await GetCmdPalExtensionPropertiesAsync(extension); - if (cmdPalProvider == null || classIds.Count == 0) + if (cmdPalProvider is null || classIds.Count == 0) { return []; } @@ -287,9 +288,17 @@ public class ExtensionService : IExtensionService, IDisposable var installedExtensions = await GetInstalledExtensionsAsync(); foreach (var installedExtension in installedExtensions) { - if (installedExtension.IsRunning()) + Logger.LogDebug($"Signaling dispose to {installedExtension.ExtensionUniqueId}"); + try { - installedExtension.SignalDispose(); + if (installedExtension.IsRunning()) + { + installedExtension.SignalDispose(); + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to send dispose signal to extension {installedExtension.ExtensionUniqueId}", ex); } } } @@ -343,12 +352,12 @@ public class ExtensionService : IExtensionService, IDisposable { var propSetList = new List<string>(); var singlePropertySet = GetSubPropertySet(activationPropSet, CreateInstanceProperty); - if (singlePropertySet != null) + if (singlePropertySet is not null) { var classId = GetProperty(singlePropertySet, ClassIdProperty); // If the instance has a classId as a single string, then it's only supporting a single instance. - if (classId != null) + if (classId is not null) { propSetList.Add(classId); } @@ -356,7 +365,7 @@ public class ExtensionService : IExtensionService, IDisposable else { var propertySetArray = GetSubPropertySetArray(activationPropSet, CreateInstanceProperty); - if (propertySetArray != null) + if (propertySetArray is not null) { foreach (var prop in propertySetArray) { @@ -366,7 +375,7 @@ public class ExtensionService : IExtensionService, IDisposable } var classId = GetProperty(propertySet, ClassIdProperty); - if (classId != null) + if (classId is not null) { propSetList.Add(classId); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs index ed15268507..8d9f8eddb5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs @@ -1,10 +1,10 @@ -// 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.Runtime.InteropServices; using ManagedCommon; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CommandPalette.Extensions; using Windows.ApplicationModel; using Windows.ApplicationModel.AppExtensions; @@ -106,38 +106,44 @@ public class ExtensionWrapper : IExtensionWrapper { Logger.LogDebug($"Starting {ExtensionDisplayName} ({ExtensionClassId})"); - var extensionPtr = nint.Zero; - try + unsafe { - // -2147024809: E_INVALIDARG - // -2147467262: E_NOINTERFACE - // -2147024893: E_PATH_NOT_FOUND - var guid = typeof(IExtension).GUID; - var hr = PInvoke.CoCreateInstance(Guid.Parse(ExtensionClassId), null, CLSCTX.CLSCTX_LOCAL_SERVER, guid, out var extensionObj); - - if (hr.Value == -2147024893) + var extensionPtr = (void*)nint.Zero; + try { - Logger.LogDebug($"Failed to find {ExtensionDisplayName}: {hr}. It may have been uninstalled or deleted."); + // -2147024809: E_INVALIDARG + // -2147467262: E_NOINTERFACE + // -2147024893: E_PATH_NOT_FOUND + var guid = typeof(IExtension).GUID; - // We don't really need to throw this exception. - // We'll just return out nothing. - return; + var hr = PInvoke.CoCreateInstance(Guid.Parse(ExtensionClassId), null, CLSCTX.CLSCTX_LOCAL_SERVER, guid, out extensionPtr); + + if (hr.Value == -2147024893) + { + Logger.LogError($"Failed to find {ExtensionDisplayName}: {hr}. It may have been uninstalled or deleted."); + + // We don't really need to throw this exception. + // We'll just return out nothing. + return; + } + else if (hr.Value != 0) + { + Logger.LogError($"Failed to find {ExtensionDisplayName}: {hr.Value}"); + } + + // Marshal.ThrowExceptionForHR(hr); + _extensionObject = MarshalInterface<IExtension>.FromAbi((nint)extensionPtr); } - - extensionPtr = Marshal.GetIUnknownForObject(extensionObj); - if (hr < 0) + catch (Exception e) { - Logger.LogDebug($"Failed to instantiate {ExtensionDisplayName}: {hr}"); - Marshal.ThrowExceptionForHR(hr); + Logger.LogDebug($"Failed to start {ExtensionDisplayName}. ex: {e.Message}"); } - - _extensionObject = MarshalInterface<IExtension>.FromAbi(extensionPtr); - } - finally - { - if (extensionPtr != nint.Zero) + finally { - Marshal.Release(extensionPtr); + if ((nint)extensionPtr != nint.Zero) + { + Marshal.Release((nint)extensionPtr); + } } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.json b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.json new file mode 100644 index 0000000000..59fa7259c4 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.txt index 981c7446f7..77bf209218 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.txt @@ -17,3 +17,7 @@ SHCreateStreamOnFileEx CoAllowSetForegroundWindow SHCreateStreamOnFileEx SHLoadIndirectString +CoCancelCall +CoEnableCallCancellation +CoDisableCallCancellation +GetCurrentThreadId \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PreviewBrushKind.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PreviewBrushKind.cs new file mode 100644 index 0000000000..2e8a644a28 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PreviewBrushKind.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +/// <summary> +/// Specifies the brush type to use for backdrop preview approximation. +/// </summary> +public enum PreviewBrushKind +{ + /// <summary> + /// SolidColorBrush with computed alpha. + /// </summary> + Solid, + + /// <summary> + /// AcrylicBrush with blur effect. + /// </summary> + Acrylic, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/AssemblyInfo.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..24c453d64c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.UI.ViewModels.UnitTests")] diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs index 5552304ca2..31dc0f9b9c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { // 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", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -70,7 +70,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } /// <summary> - /// Looks up a localized string similar to Where should the new extension be created? This path will be created if it doesn't exist. + /// Looks up a localized string similar to Select the folder where the new extension will be created. The path will be created if it doesn't exist.. /// </summary> public static string builtin_create_extension_directory_description { get { @@ -78,15 +78,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } } - /// <summary> - /// Looks up a localized string similar to Output path. - /// </summary> - public static string builtin_create_extension_directory_header { - get { - return ResourceManager.GetString("builtin_create_extension_directory_header", resourceCulture); - } - } - /// <summary> /// Looks up a localized string similar to Output path. /// </summary> @@ -106,7 +97,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } /// <summary> - /// Looks up a localized string similar to The name of your extension as users will see it.. + /// Looks up a localized string similar to The name of the extension as it will appear to users.. /// </summary> public static string builtin_create_extension_display_name_description { get { @@ -114,15 +105,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } } - /// <summary> - /// Looks up a localized string similar to Display name. - /// </summary> - public static string builtin_create_extension_display_name_header { - get { - return ResourceManager.GetString("builtin_create_extension_display_name_header", resourceCulture); - } - } - /// <summary> /// Looks up a localized string similar to Display name. /// </summary> @@ -151,7 +133,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } /// <summary> - /// Looks up a localized string similar to This is the name of your new extension project. It should be a valid C# class name. Best practice is to also include the word 'Extension' in the name.. + /// Looks up a localized string similar to Enter a valid C# class name for the new extension project. It's recommended to include the word "Extension" in the name.. /// </summary> public static string builtin_create_extension_name_description { get { @@ -159,15 +141,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } } - /// <summary> - /// Looks up a localized string similar to Extension name. - /// </summary> - public static string builtin_create_extension_name_header { - get { - return ResourceManager.GetString("builtin_create_extension_name_header", resourceCulture); - } - } - /// <summary> /// Looks up a localized string similar to Extension name. /// </summary> @@ -178,7 +151,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } /// <summary> - /// Looks up a localized string similar to Extension name is required, without spaces. + /// Looks up a localized string similar to Extension name is required and must be a valid C# identifier (start with a letter or underscore, followed by letters, numbers, or underscores). /// </summary> public static string builtin_create_extension_name_required { get { @@ -205,16 +178,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } /// <summary> - /// Looks up a localized string similar to Use this page to create a new extension project.. - /// </summary> - public static string builtin_create_extension_page_text { - get { - return ResourceManager.GetString("builtin_create_extension_page_text", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Create your new extension. + /// Looks up a localized string similar to Create a new extension. /// </summary> public static string builtin_create_extension_page_title { get { @@ -241,7 +205,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } /// <summary> - /// Looks up a localized string similar to Create a new extension. + /// Looks up a localized string similar to Create extension. /// </summary> public static string builtin_create_extension_title { get { @@ -321,6 +285,60 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } } + /// <summary> + /// Looks up a localized string similar to Built-in. + /// </summary> + public static string builtin_extension_name { + get { + return ResourceManager.GetString("builtin_extension_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0}, {1} commands. + /// </summary> + public static string builtin_extension_subtext { + get { + return ResourceManager.GetString("builtin_extension_subtext", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0}, {1}. + /// </summary> + public static string builtin_extension_subtext_disabled { + get { + return ResourceManager.GetString("builtin_extension_subtext_disabled", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0}, {1} commands, {2} fallback commands. + /// </summary> + public static string builtin_extension_subtext_with_fallback { + get { + return ResourceManager.GetString("builtin_extension_subtext_with_fallback", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Home. + /// </summary> + public static string builtin_home_name { + get { + return ResourceManager.GetString("builtin_home_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to View log folder. + /// </summary> + public static string builtin_log_folder_command_name { + get { + return ResourceManager.GetString("builtin_log_folder_command_name", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to View log. /// </summary> @@ -358,7 +376,16 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } /// <summary> - /// Looks up a localized string similar to Creates a project for a new Command Palette extension. + /// Looks up a localized string similar to Search for apps, files and commands.... + /// </summary> + public static string builtin_main_list_page_searchbar_placeholder { + get { + return ResourceManager.GetString("builtin_main_list_page_searchbar_placeholder", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Generate a new Command Palette extension project. /// </summary> public static string builtin_new_extension_subtitle { get { @@ -367,7 +394,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } /// <summary> - /// Looks up a localized string similar to Open Settings. + /// Looks up a localized string similar to Open Command Palette settings. /// </summary> public static string builtin_open_settings_name { get { @@ -375,15 +402,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { } } - /// <summary> - /// Looks up a localized string similar to Open Command Palette settings. - /// </summary> - public static string builtin_open_settings_subtitle { - get { - return ResourceManager.GetString("builtin_open_settings_subtitle", resourceCulture); - } - } - /// <summary> /// Looks up a localized string similar to Exit Command Palette. /// </summary> @@ -419,5 +437,41 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties { return ResourceManager.GetString("builtin_reload_subtitle", resourceCulture); } } + + /// <summary> + /// Looks up a localized string similar to Pick background image. + /// </summary> + public static string builtin_settings_appearance_pick_background_image_title { + get { + return ResourceManager.GetString("builtin_settings_appearance_pick_background_image_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} extensions found. + /// </summary> + public static string builtin_settings_extension_n_extensions_found { + get { + return ResourceManager.GetString("builtin_settings_extension_n_extensions_found", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} extensions installed. + /// </summary> + public static string builtin_settings_extension_n_extensions_installed { + get { + return ResourceManager.GetString("builtin_settings_extension_n_extensions_installed", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Fallbacks. + /// </summary> + public static string fallbacks { + get { + return ResourceManager.GetString("fallbacks", resourceCulture); + } + } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx index 4dc5a1539f..92a5350d53 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Properties/Resources.resx @@ -117,11 +117,8 @@ <resheader name="writer"> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> </resheader> - <data name="builtin_open_settings_subtitle" xml:space="preserve"> - <value>Open Command Palette settings</value> - </data> <data name="builtin_new_extension_subtitle" xml:space="preserve"> - <value>Creates a project for a new Command Palette extension</value> + <value>Generate a new Command Palette extension project</value> </data> <data name="builtin_quit_subtitle" xml:space="preserve"> <value>Exit Command Palette</value> @@ -129,12 +126,30 @@ <data name="builtin_display_name" xml:space="preserve"> <value>Built-in commands</value> </data> + <data name="builtin_extension_name" xml:space="preserve"> + <value>Built-in</value> + </data> + <data name="builtin_extension_subtext" xml:space="preserve"> + <value>{0}, {1} commands</value> + <comment>{0}=extension name, {1}=number of commands</comment> + </data> + <data name="builtin_extension_subtext_with_fallback" xml:space="preserve"> + <value>{0}, {1} commands, {2} fallback commands</value> + <comment>{0}=extension name, {1}=number of commands, {2} number of fallback commands</comment> + </data> + <data name="builtin_extension_subtext_disabled" xml:space="preserve"> + <value>{0}, {1}</value> + <comment>{0}=extension name, {1}=message</comment> + </data> <data name="builtin_log_subtitle" xml:space="preserve"> <value>View log messages</value> </data> <data name="builtin_log_title" xml:space="preserve"> <value>View log</value> </data> + <data name="builtin_log_folder_command_name" xml:space="preserve"> + <value>View log folder</value> + </data> <data name="builtin_reload_subtitle" xml:space="preserve"> <value>Reload Command Palette extensions</value> </data> @@ -154,10 +169,10 @@ <value>Open</value> </data> <data name="builtin_create_extension_title" xml:space="preserve"> - <value>Create a new extension</value> + <value>Create extension</value> </data> <data name="builtin_open_settings_name" xml:space="preserve"> - <value>Open Settings</value> + <value>Open Command Palette settings</value> </data> <data name="builtin_create_extension_success" xml:space="preserve"> <value>Successfully created your new extension!</value> @@ -180,28 +195,19 @@ <value>Once you're ready to test deploy the package locally with Visual Studio, then run the \"Reload\" command in the Command Palette to load your new extension.</value> </data> <data name="builtin_create_extension_page_title" xml:space="preserve"> - <value>Create your new extension</value> - </data> - <data name="builtin_create_extension_page_text" xml:space="preserve"> - <value>Use this page to create a new extension project.</value> - </data> - <data name="builtin_create_extension_name_header" xml:space="preserve"> - <value>Extension name</value> + <value>Create a new extension</value> </data> <data name="builtin_create_extension_name_description" xml:space="preserve"> - <value>This is the name of your new extension project. It should be a valid C# class name. Best practice is to also include the word 'Extension' in the name.</value> + <value>Enter a valid C# class name for the new extension project. It's recommended to include the word "Extension" in the name.</value> </data> <data name="builtin_create_extension_name_label" xml:space="preserve"> <value>Extension name</value> </data> <data name="builtin_create_extension_name_required" xml:space="preserve"> - <value>Extension name is required, without spaces</value> - </data> - <data name="builtin_create_extension_display_name_header" xml:space="preserve"> - <value>Display name</value> + <value>Extension name is required and must be a valid C# identifier (start with a letter or underscore, followed by letters, numbers, or underscores)</value> </data> <data name="builtin_create_extension_display_name_description" xml:space="preserve"> - <value>The name of your extension as users will see it.</value> + <value>The name of the extension as it will appear to users.</value> </data> <data name="builtin_create_extension_display_name_label" xml:space="preserve"> <value>Display name</value> @@ -209,11 +215,8 @@ <data name="builtin_create_extension_display_name_required" xml:space="preserve"> <value>Display name is required</value> </data> - <data name="builtin_create_extension_directory_header" xml:space="preserve"> - <value>Output path</value> - </data> <data name="builtin_create_extension_directory_description" xml:space="preserve"> - <value>Where should the new extension be created? This path will be created if it doesn't exist</value> + <value>Select the folder where the new extension will be created. The path will be created if it doesn't exist.</value> </data> <data name="builtin_create_extension_directory_label" xml:space="preserve"> <value>Output path</value> @@ -239,4 +242,22 @@ <data name="builtin_disabled_extension" xml:space="preserve"> <value>Disabled</value> </data> + <data name="builtin_main_list_page_searchbar_placeholder" xml:space="preserve"> + <value>Search for apps, files and commands...</value> + </data> + <data name="builtin_home_name" xml:space="preserve"> + <value>Home</value> + </data> + <data name="builtin_settings_extension_n_extensions_found" xml:space="preserve"> + <value>{0} extensions found</value> + </data> + <data name="builtin_settings_extension_n_extensions_installed" xml:space="preserve"> + <value>{0} extensions installed</value> + </data> + <data name="builtin_settings_appearance_pick_background_image_title" xml:space="preserve"> + <value>Pick background image</value> + </data> + <data name="fallbacks" xml:space="preserve"> + <value>Fallbacks</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs index 139f50feb4..3bb9a43360 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettings.cs @@ -8,8 +8,16 @@ namespace Microsoft.CmdPal.UI.ViewModels; public class ProviderSettings { + // List of built-in fallbacks that should not have global results enabled by default + private readonly string[] _excludedBuiltInFallbacks = [ + "com.microsoft.cmdpal.builtin.indexer.fallback", + "com.microsoft.cmdpal.builtin.calculator.fallback", + ]; + public bool IsEnabled { get; set; } = true; + public Dictionary<string, FallbackSettings> FallbackCommands { get; set; } = new(); + [JsonIgnore] public string ProviderDisplayName { get; set; } = string.Empty; @@ -33,10 +41,22 @@ public class ProviderSettings public void Connect(CommandProviderWrapper wrapper) { ProviderId = wrapper.ProviderId; - IsBuiltin = wrapper.Extension == null; + IsBuiltin = wrapper.Extension is null; ProviderDisplayName = wrapper.DisplayName; + if (wrapper.FallbackItems.Length > 0) + { + foreach (var fallback in wrapper.FallbackItems) + { + if (!FallbackCommands.ContainsKey(fallback.Id)) + { + var enableGlobalResults = IsBuiltin && !_excludedBuiltInFallbacks.Contains(fallback.Id); + FallbackCommands[fallback.Id] = new FallbackSettings(enableGlobalResults); + } + } + } + if (string.IsNullOrEmpty(ProviderId)) { throw new InvalidDataException("Did you add a built-in command and forget to set the Id? Make sure you do that!"); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs index 180d77e629..1a2a82fa7f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs @@ -3,36 +3,66 @@ // See the LICENSE file in the project root for more information. using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Properties; -using Microsoft.Extensions.DependencyInjection; namespace Microsoft.CmdPal.UI.ViewModels; -public partial class ProviderSettingsViewModel( - CommandProviderWrapper _provider, - ProviderSettings _providerSettings, - IServiceProvider _serviceProvider) : ObservableObject +public partial class ProviderSettingsViewModel : ObservableObject { - private readonly SettingsModel _settings = _serviceProvider.GetService<SettingsModel>()!; + private static readonly IconInfoViewModel EmptyIcon = new(null); + private static readonly CompositeFormat ExtensionSubtextFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext); + private static readonly CompositeFormat ExtensionSubtextWithFallbackFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_with_fallback); + private static readonly CompositeFormat ExtensionSubtextDisabledFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_disabled); + + private readonly CommandProviderWrapper _provider; + private readonly ProviderSettings _providerSettings; + private readonly SettingsModel _settings; + private readonly Lock _initializeSettingsLock = new(); + + private Task? _initializeSettingsTask; + + public ProviderSettingsViewModel( + CommandProviderWrapper provider, + ProviderSettings providerSettings, + SettingsModel settings) + { + _provider = provider; + _providerSettings = providerSettings; + _settings = settings; + + LoadingSettings = _provider.Settings?.HasSettings ?? false; + + BuildFallbackViewModels(); + } public string DisplayName => _provider.DisplayName; - public string ExtensionName => _provider.Extension?.ExtensionDisplayName ?? "Built-in"; + public string ExtensionName => _provider.Extension?.ExtensionDisplayName ?? Resources.builtin_extension_name; - public string ExtensionSubtext => IsEnabled ? $"{ExtensionName}, {TopLevelCommands.Count} commands" : Resources.builtin_disabled_extension; + public string ExtensionSubtext => IsEnabled ? + HasFallbackCommands ? + string.Format(CultureInfo.CurrentCulture, ExtensionSubtextWithFallbackFormat, ExtensionName, TopLevelCommands.Count, _provider.FallbackItems?.Length ?? 0) : + string.Format(CultureInfo.CurrentCulture, ExtensionSubtextFormat, ExtensionName, TopLevelCommands.Count) : + string.Format(CultureInfo.CurrentCulture, ExtensionSubtextDisabledFormat, ExtensionName, Resources.builtin_disabled_extension); [MemberNotNullWhen(true, nameof(Extension))] - public bool IsFromExtension => _provider.Extension != null; + public bool IsFromExtension => _provider.Extension is not null; public IExtensionWrapper? Extension => _provider.Extension; 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; } public bool IsEnabled { @@ -46,6 +76,7 @@ public partial class ProviderSettingsViewModel( WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new()); OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(ExtensionSubtext)); + OnPropertyChanged(nameof(Icon)); } if (value == true) @@ -56,22 +87,67 @@ public partial class ProviderSettingsViewModel( } } - private void Provider_CommandsChanged(CommandProviderWrapper sender, CommandPalette.Extensions.IItemsChangedEventArgs args) + /// <summary> + /// Gets a value indicating whether returns true if we have a settings page + /// that's initialized, or we are still working on initializing that + /// settings page. If we don't have a settings object, or that settings + /// object doesn't have a settings page, then we'll return false. + /// </summary> + public bool HasSettings { - OnPropertyChanged(nameof(ExtensionSubtext)); - OnPropertyChanged(nameof(TopLevelCommands)); + get + { + if (_provider.Settings is null) + { + return false; + } + + if (_provider.Settings.Initialized) + { + return _provider.Settings.HasSettings; + } + + // settings still need to be loaded. + return LoadingSettings; + } } - public bool HasSettings => _provider.Settings != null && _provider.Settings.SettingsPage != null; + /// <summary> + /// Gets will return the settings page, if we have one, and have initialized it. + /// If we haven't initialized it, this will kick off a thread to start + /// initializing it. + /// </summary> + public ContentPageViewModel? SettingsPage + { + get + { + if (_provider.Settings is null) + { + return null; + } - public ContentPageViewModel? SettingsPage => HasSettings ? _provider?.Settings?.SettingsPage : null; + if (_provider.Settings.Initialized) + { + LoadingSettings = false; + return _provider.Settings.SettingsPage; + } + + // Don't load the settings if we're already working on it + lock (_initializeSettingsLock) + { + _initializeSettingsTask ??= Task.Run(InitializeSettingsPage); + } + + return null; + } + } [field: AllowNull] public List<TopLevelViewModel> TopLevelCommands { get { - if (field == null) + if (field is null) { field = BuildTopLevelViewModels(); } @@ -89,5 +165,57 @@ public partial class ProviderSettingsViewModel( return [.. providersCommands]; } + [field: AllowNull] + public List<FallbackSettingsViewModel> FallbackCommands { get; set; } = []; + + public bool HasFallbackCommands => _provider.FallbackItems?.Length > 0; + + private void BuildFallbackViewModels() + { + var thisProvider = _provider; + var providersFallbackCommands = thisProvider.FallbackItems; + + List<FallbackSettingsViewModel> fallbackViewModels = new(providersFallbackCommands.Length); + foreach (var fallbackItem in providersFallbackCommands) + { + if (_providerSettings.FallbackCommands.TryGetValue(fallbackItem.Id, out var fallbackSettings)) + { + fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, fallbackSettings, _settings, this)); + } + else + { + fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, new(), _settings, this)); + } + } + + FallbackCommands = fallbackViewModels; + } + private void Save() => SettingsModel.SaveSettings(_settings); + + private void InitializeSettingsPage() + { + if (_provider.Settings is null) + { + return; + } + + _provider.Settings.SafeInitializeProperties(); + _provider.Settings.DoOnUiThread(() => + { + // Changing these properties will try to update XAML, and that has + // to be handled on the UI thread, so we need to raise them on the + // UI thread + LoadingSettings = false; + OnPropertyChanged(nameof(HasSettings)); + OnPropertyChanged(nameof(LoadingSettings)); + OnPropertyChanged(nameof(SettingsPage)); + }); + } + + private void Provider_CommandsChanged(CommandProviderWrapper sender, CommandPalette.Extensions.IItemsChangedEventArgs args) + { + OnPropertyChanged(nameof(ExtensionSubtext)); + OnPropertyChanged(nameof(TopLevelCommands)); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs index 9e971ae510..c12d189445 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs @@ -7,10 +7,12 @@ using CommunityToolkit.Mvvm.ComponentModel; namespace Microsoft.CmdPal.UI.ViewModels; -public partial class RecentCommandsManager : ObservableObject +public partial class RecentCommandsManager : ObservableObject, IRecentCommandsManager { [JsonInclude] - private List<HistoryItem> History { get; set; } = []; + internal List<HistoryItem> History { get; set; } = []; + + private readonly Lock _lock = new(); public RecentCommandsManager() { @@ -18,57 +20,70 @@ public partial class RecentCommandsManager : ObservableObject public int GetCommandHistoryWeight(string commandId) { - var entry = History + lock (_lock) + { + var entry = History .Index() .Where(item => item.Item.CommandId == commandId) .FirstOrDefault(); - // These numbers are vaguely scaled so that "VS" will make "Visual Studio" the - // match after one use. - // Usually it has a weight of 84, compared to 109 for the VS cmd prompt - if (entry.Item != null) - { - var index = entry.Index; - - // First, add some weight based on how early in the list this appears - var bucket = index switch + // These numbers are vaguely scaled so that "VS" will make "Visual Studio" the + // match after one use. + // Usually it has a weight of 84, compared to 109 for the VS cmd prompt + if (entry.Item is not null) { - var i when index <= 2 => 35, - var i when index <= 10 => 25, - var i when index <= 15 => 15, - var i when index <= 35 => 10, - _ => 5, - }; + var index = entry.Index; - // Then, add weight for how often this is used, but cap the weight from usage. - var uses = Math.Min(entry.Item.Uses * 5, 35); + // First, add some weight based on how early in the list this appears + var bucket = index switch + { + var i when index <= 2 => 35, + var i when index <= 10 => 25, + var i when index <= 15 => 15, + var i when index <= 35 => 10, + _ => 5, + }; - return bucket + uses; + // Then, add weight for how often this is used, but cap the weight from usage. + var uses = Math.Min(entry.Item.Uses * 5, 35); + + return bucket + uses; + } + + return 0; } - - return 0; } public void AddHistoryItem(string commandId) { - var entry = History + lock (_lock) + { + var entry = History .Where(item => item.CommandId == commandId) .FirstOrDefault(); - if (entry == null) - { - var newitem = new HistoryItem() { CommandId = commandId, Uses = 1 }; - History.Insert(0, newitem); - } - else - { - History.Remove(entry); - entry.Uses++; - History.Insert(0, entry); - } + if (entry is null) + { + var newitem = new HistoryItem() { CommandId = commandId, Uses = 1 }; + History.Insert(0, newitem); + } + else + { + History.Remove(entry); + entry.Uses++; + History.Insert(0, entry); + } - if (History.Count > 50) - { - History.RemoveRange(50, History.Count - 50); + if (History.Count > 50) + { + History.RemoveRange(50, History.Count - 50); + } } } } + +public interface IRecentCommandsManager +{ + int GetCommandHistoryWeight(string commandId); + + void AddHistoryItem(string commandId); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/BackdropParameters.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/BackdropParameters.cs new file mode 100644 index 0000000000..dde4df0e0e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/BackdropParameters.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 Windows.UI; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// <summary> +/// Parameters for configuring the window backdrop appearance. +/// </summary> +/// <param name="TintColor">The tint color applied to the backdrop.</param> +/// <param name="FallbackColor">The fallback color when backdrop effects are unavailable.</param> +/// <param name="EffectiveOpacity"> +/// The effective opacity for the backdrop, pre-computed by the theme provider. +/// For Acrylic style: TintOpacity * BackdropOpacity. +/// For Clear style: BackdropOpacity (controls the solid color alpha). +/// </param> +/// <param name="EffectiveLuminosityOpacity"> +/// The effective luminosity opacity for Acrylic backdrop, pre-computed by the theme provider. +/// Computed as LuminosityOpacity * BackdropOpacity. +/// </param> +/// <param name="Style">The backdrop style (Acrylic or Clear).</param> +public sealed record BackdropParameters( + Color TintColor, + Color FallbackColor, + float EffectiveOpacity, + float EffectiveLuminosityOpacity, + BackdropStyle Style = BackdropStyle.Acrylic); 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<string, CommandProviderCacheItem> 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<string, CommandProviderCacheItem>))] +[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<string, CommandProviderCacheItem> _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<string, CommandProviderCacheItem> snapshot; + lock (_sync) + { + snapshot = new Dictionary<string, CommandProviderCacheItem>(_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/Services/IThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs new file mode 100644 index 0000000000..546742b8f4 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/IThemeService.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// <summary> +/// Provides theme-related values for the Command Palette and notifies listeners about +/// changes that affect visual appearance (theme, tint, background image, and backdrop). +/// </summary> +/// <remarks> +/// Implementations are expected to monitor system/app theme changes and raise +/// <see cref="ThemeChanged"/> accordingly. Consumers should call <see cref="Initialize"/> +/// once to hook required sources and then query properties/methods for the current visuals. +/// </remarks> +public interface IThemeService +{ + /// <summary> + /// Occurs when the effective theme or any visual-affecting setting changes. + /// </summary> + /// <remarks> + /// Triggered for changes such as app theme (light/dark/default), background image, + /// tint/accent, or backdrop parameters that would require UI to refresh styling. + /// </remarks> + event EventHandler<ThemeChangedEventArgs>? ThemeChanged; + + /// <summary> + /// Initializes the theme service and starts listening for theme-related changes. + /// </summary> + /// <remarks> + /// Safe to call once during application startup before consuming the service. + /// </remarks> + void Initialize(); + + /// <summary> + /// Gets the current theme settings. + /// </summary> + ThemeSnapshot Current { get; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs new file mode 100644 index 0000000000..96197dc376 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeChangedEventArgs.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// <summary> +/// Event arguments for theme-related changes. </summary> +public class ThemeChangedEventArgs : EventArgs; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.cs new file mode 100644 index 0000000000..a82484d52f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Services/ThemeSnapshot.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 Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.ViewModels.Services; + +/// <summary> +/// Represents a snapshot of theme-related visual settings, including accent color, theme preference, and background +/// image configuration, for use in rendering the Command Palette UI. +/// </summary> +public sealed class ThemeSnapshot +{ + /// <summary> + /// Gets the accent tint color used by the Command Palette visuals. + /// </summary> + public required Color Tint { get; init; } + + /// <summary> + /// Gets the accent tint color used by the Command Palette visuals. + /// </summary> + public required float TintIntensity { get; init; } + + /// <summary> + /// Gets the configured application theme preference. + /// </summary> + public required ElementTheme Theme { get; init; } + + /// <summary> + /// Gets the image source to render as the background, if any. + /// </summary> + /// <remarks> + /// Returns <see langword="null"/> when no background image is configured. + /// </remarks> + public required ImageSource? BackgroundImageSource { get; init; } + + /// <summary> + /// Gets the stretch mode used to lay out the background image. + /// </summary> + public required Stretch BackgroundImageStretch { get; init; } + + /// <summary> + /// Gets the opacity applied to the background image. + /// </summary> + /// <value> + /// A value in the range [0, 1], where 0 is fully transparent and 1 is fully opaque. + /// </value> + public required double BackgroundImageOpacity { get; init; } + + /// <summary> + /// Gets the effective backdrop parameters based on current settings and theme. + /// </summary> + /// <returns>The resolved <c>BackdropParameters</c> to apply.</returns> + public required BackdropParameters BackdropParameters { get; init; } + + /// <summary> + /// Gets the raw backdrop opacity setting (0-1 range). + /// Used for determining if transparency is enabled and for image opacity calculations. + /// </summary> + public required float BackdropOpacity { get; init; } + + public required int BlurAmount { get; init; } + + public required float BackgroundBrightness { get; init; } + + /// <summary> + /// Gets whether colorization is active (accent color, custom color, or image mode). + /// </summary> + public required bool HasColorization { get; init; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsExtensionsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsExtensionsViewModel.cs new file mode 100644 index 0000000000..8e14a83d19 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsExtensionsViewModel.cs @@ -0,0 +1,144 @@ +// 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.ObjectModel; +using System.Collections.Specialized; +using System.Globalization; +using System.Text; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; + +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.UI.ViewModels; + +/// <summary> +/// Provides filtering over the list of provider settings view models. +/// Intended to be used by the UI to bind a TextBox (SearchText) and an ItemsRepeater (FilteredProviders). +/// </summary> +public partial class SettingsExtensionsViewModel : ObservableObject +{ + private static readonly CompositeFormat LabelNumberExtensionFound + = CompositeFormat.Parse(Properties.Resources.builtin_settings_extension_n_extensions_found!); + + private static readonly CompositeFormat LabelNumberExtensionInstalled + = CompositeFormat.Parse(Properties.Resources.builtin_settings_extension_n_extensions_installed!); + + private readonly ObservableCollection<ProviderSettingsViewModel> _source; + private readonly TaskScheduler _uiScheduler; + + public ObservableCollection<ProviderSettingsViewModel> FilteredProviders { get; } = []; + + private string _searchText = string.Empty; + + public string SearchText + { + get => _searchText; + set + { + if (_searchText != value) + { + _searchText = value; + OnPropertyChanged(); + ApplyFilter(); + } + } + } + + public string ItemCounterText + { + get + { + var hasQuery = !string.IsNullOrWhiteSpace(_searchText); + var count = hasQuery ? FilteredProviders.Count : _source.Count; + var format = hasQuery ? LabelNumberExtensionFound : LabelNumberExtensionInstalled; + return string.Format(CultureInfo.CurrentCulture, format, count); + } + } + + public bool ShowManualReloadOverlay + { + get; + private set + { + if (field != value) + { + field = value; + OnPropertyChanged(); + } + } + } + + public bool ShowNoResultsPanel => !string.IsNullOrWhiteSpace(_searchText) && FilteredProviders.Count == 0; + + public bool HasResults => !ShowNoResultsPanel; + + public IRelayCommand ReloadExtensionsCommand { get; } + + public SettingsExtensionsViewModel(ObservableCollection<ProviderSettingsViewModel> source, TaskScheduler uiScheduler) + { + _source = source; + _uiScheduler = uiScheduler; + _source.CollectionChanged += Source_CollectionChanged; + ApplyFilter(); + + ReloadExtensionsCommand = new RelayCommand(ReloadExtensions); + + WeakReferenceMessenger.Default.Register<ReloadFinishedMessage>(this, (_, _) => + { + Task.Factory.StartNew(() => ShowManualReloadOverlay = false, CancellationToken.None, TaskCreationOptions.None, _uiScheduler); + }); + } + + private void Source_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + ApplyFilter(); + } + + private void ApplyFilter() + { + var query = _searchText; + var filtered = ListHelpers.FilterList(_source, query, Matches); + ListHelpers.InPlaceUpdateList(FilteredProviders, filtered); + OnPropertyChanged(nameof(ItemCounterText)); + OnPropertyChanged(nameof(HasResults)); + OnPropertyChanged(nameof(ShowNoResultsPanel)); + } + + private static int Matches(string query, ProviderSettingsViewModel item) + { + if (string.IsNullOrWhiteSpace(query)) + { + return 100; + } + + return Contains(item.DisplayName, query) + || Contains(item.ExtensionName, query) + || Contains(item.ExtensionSubtext, query) + ? 100 + : 0; + } + + private static bool Contains(string? haystack, string needle) + { + return !string.IsNullOrEmpty(haystack) && haystack.Contains(needle, StringComparison.OrdinalIgnoreCase); + } + + [RelayCommand] + private void OpenStoreWithExtension(string? query) + { + const string extensionsAssocUri = "ms-windows-store://assoc/?Tags=AppExtension-com.microsoft.commandpalette"; + ShellHelpers.OpenInShell(extensionsAssocUri); + } + + private void ReloadExtensions() + { + ShowManualReloadOverlay = true; + WeakReferenceMessenger.Default.Send<ClearSearchMessage>(); + WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index 785c27c3c9..c023c3daae 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -6,15 +6,21 @@ using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using CommunityToolkit.Mvvm.ComponentModel; +using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.UI; using Windows.Foundation; +using Windows.UI; namespace Microsoft.CmdPal.UI.ViewModels; public partial class SettingsModel : ObservableObject { + private const string DeprecatedHotkeyGoesHomeKey = "HotkeyGoesHome"; + [JsonIgnore] public static readonly string FilePath; @@ -26,9 +32,9 @@ public partial class SettingsModel : ObservableObject public HotkeySettings? Hotkey { get; set; } = DefaultActivationShortcut; - public bool ShowAppDetails { get; set; } + public bool UseLowLevelGlobalHotkey { get; set; } - public bool HotkeyGoesHome { get; set; } + public bool ShowAppDetails { get; set; } public bool BackspaceGoesBack { get; set; } @@ -36,14 +42,54 @@ public partial class SettingsModel : ObservableObject public bool HighlightSearchOnActivate { get; set; } = true; + public bool ShowSystemTrayIcon { get; set; } = true; + + public bool IgnoreShortcutWhenFullscreen { get; set; } + + public bool AllowExternalReload { get; set; } + public Dictionary<string, ProviderSettings> ProviderSettings { get; set; } = []; + public string[] FallbackRanks { get; set; } = []; + public Dictionary<string, CommandAlias> Aliases { get; set; } = []; public List<TopLevelHotkey> CommandHotkeys { get; set; } = []; public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse; + public bool DisableAnimations { get; set; } = true; + + public WindowPosition? LastWindowPosition { get; set; } + + public TimeSpan AutoGoHomeInterval { get; set; } = Timeout.InfiniteTimeSpan; + + public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack; + + public UserTheme Theme { get; set; } = UserTheme.Default; + + public ColorizationMode ColorizationMode { get; set; } + + public Color CustomThemeColor { get; set; } = Colors.Transparent; + + public int CustomThemeColorIntensity { get; set; } = 100; + + public int BackgroundImageTintIntensity { get; set; } + + public int BackgroundImageOpacity { get; set; } = 20; + + public int BackgroundImageBlurAmount { get; set; } + + public int BackgroundImageBrightness { get; set; } + + public BackgroundImageFit BackgroundImageFit { get; set; } + + public string? BackgroundImagePath { get; set; } + + public BackdropStyle BackdropStyle { get; set; } + + public int BackdropOpacity { get; set; } = 100; + // END SETTINGS /////////////////////////////////////////////////////////////////////////// @@ -69,6 +115,25 @@ public partial class SettingsModel : ObservableObject return settings; } + public string[] GetGlobalFallbacks() + { + var globalFallbacks = new HashSet<string>(); + + foreach (var provider in ProviderSettings.Values) + { + foreach (var fallback in provider.FallbackCommands) + { + var fallbackSetting = fallback.Value; + if (fallbackSetting.IsEnabled && fallbackSetting.IncludeInGlobalResults) + { + globalFallbacks.Add(fallback.Key); + } + } + } + + return globalFallbacks.ToArray(); + } + public static SettingsModel LoadSettings() { if (string.IsNullOrEmpty(FilePath)) @@ -86,12 +151,29 @@ public partial class SettingsModel : ObservableObject { // Read the JSON content from the file var jsonContent = File.ReadAllText(FilePath); + var loaded = JsonSerializer.Deserialize<SettingsModel>(jsonContent, JsonSerializationContext.Default.SettingsModel) ?? new(); - var loaded = JsonSerializer.Deserialize<SettingsModel>(jsonContent, _deserializerOptions); + var migratedAny = false; + try + { + if (JsonNode.Parse(jsonContent) is JsonObject root) + { + migratedAny |= ApplyMigrations(root, loaded); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Migration check failed: {ex}"); + } - Debug.WriteLine(loaded != null ? "Loaded settings file" : "Failed to parse"); + Debug.WriteLine("Loaded settings file"); - return loaded ?? new(); + if (migratedAny) + { + SaveSettings(loaded); + } + + return loaded; } catch (Exception ex) { @@ -101,6 +183,51 @@ public partial class SettingsModel : ObservableObject return new(); } + private static bool ApplyMigrations(JsonObject root, SettingsModel model) + { + var migrated = false; + + // Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan) + // The old 'HotkeyGoesHome' boolean indicated whether the "go home" action should happen immediately (true) or never (false). + // The new 'AutoGoHomeInterval' uses a TimeSpan: 'TimeSpan.Zero' means immediate, 'Timeout.InfiniteTimeSpan' means never. + migrated |= TryMigrate( + "Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)", + root, + model, + nameof(AutoGoHomeInterval), + DeprecatedHotkeyGoesHomeKey, + (settingsModel, goesHome) => settingsModel.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan, + JsonSerializationContext.Default.Boolean); + + return migrated; + } + + private static bool TryMigrate<T>(string migrationName, JsonObject root, SettingsModel model, string newKey, string oldKey, Action<SettingsModel, T> apply, JsonTypeInfo<T> jsonTypeInfo) + { + try + { + // If new key already present, skip migration + if (root.ContainsKey(newKey) && root[newKey] is not null) + { + return false; + } + + // If old key present, try to deserialize and apply + if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null) + { + var value = oldNode.Deserialize<T>(jsonTypeInfo); + apply(model, value!); + return true; + } + } + catch (Exception ex) + { + Logger.LogError($"Error during migration {migrationName}.", ex); + } + + return false; + } + public static void SaveSettings(SettingsModel model) { if (string.IsNullOrEmpty(FilePath)) @@ -111,7 +238,7 @@ public partial class SettingsModel : ObservableObject try { // Serialize the main dictionary to JSON and save it to the file - var settingsJson = JsonSerializer.Serialize(model, _serializerOptions); + var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.SettingsModel); // Is it valid JSON? if (JsonNode.Parse(settingsJson) is JsonObject newSettings) @@ -124,10 +251,13 @@ public partial class SettingsModel : ObservableObject { foreach (var item in newSettings) { - savedSettings[item.Key] = item.Value != null ? item.Value.DeepClone() : null; + savedSettings[item.Key] = item.Value?.DeepClone(); } - var serialized = savedSettings.ToJsonString(_serializerOptions); + // Remove deprecated keys + savedSettings.Remove(DeprecatedHotkeyGoesHomeKey); + + var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.Options); File.WriteAllText(FilePath, serialized); // TODO: Instead of just raising the event here, we should @@ -160,19 +290,37 @@ public partial class SettingsModel : ObservableObject return Path.Combine(directory, "settings.json"); } - private static readonly JsonSerializerOptions _serializerOptions = new() - { - WriteIndented = true, - Converters = { new JsonStringEnumConverter() }, - }; + // [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "<Pending>")] + // private static readonly JsonSerializerOptions _serializerOptions = new() + // { + // WriteIndented = true, + // Converters = { new JsonStringEnumConverter() }, + // }; + // private static readonly JsonSerializerOptions _deserializerOptions = new() + // { + // PropertyNameCaseInsensitive = true, + // IncludeFields = true, + // Converters = { new JsonStringEnumConverter() }, + // AllowTrailingCommas = true, + // }; +} - private static readonly JsonSerializerOptions _deserializerOptions = new() - { - PropertyNameCaseInsensitive = true, - IncludeFields = true, - Converters = { new JsonStringEnumConverter() }, - AllowTrailingCommas = true, - }; +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(HistoryItem))] +[JsonSerializable(typeof(SettingsModel))] +[JsonSerializable(typeof(WindowPosition))] +[JsonSerializable(typeof(AppStateModel))] +[JsonSerializable(typeof(RecentCommandsManager))] +[JsonSerializable(typeof(List<string>), TypeInfoPropertyName = "StringList")] +[JsonSerializable(typeof(List<HistoryItem>), TypeInfoPropertyName = "HistoryList")] +[JsonSerializable(typeof(Dictionary<string, object>), TypeInfoPropertyName = "Dictionary")] +[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")] +internal sealed partial class JsonSerializationContext : JsonSerializerContext +{ } public enum MonitorBehavior @@ -181,4 +329,13 @@ public enum MonitorBehavior ToPrimary = 1, ToFocusedWindow = 2, InPlace = 3, + ToLast = 4, +} + +public enum EscapeKeyBehavior +{ + ClearSearchFirstThenGoBack = 0, + AlwaysGoBack = 1, + AlwaysDismiss = 2, + AlwaysHide = 3, } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs index 190d75b0c3..947a025e69 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs @@ -4,18 +4,34 @@ using System.Collections.ObjectModel; using System.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.UI.ViewModels; public partial class SettingsViewModel : INotifyPropertyChanged { + private static readonly List<TimeSpan> AutoGoHomeIntervals = + [ + Timeout.InfiniteTimeSpan, + TimeSpan.Zero, + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(20), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(60), + TimeSpan.FromSeconds(90), + TimeSpan.FromSeconds(120), + TimeSpan.FromSeconds(180), + ]; + private readonly SettingsModel _settings; - private readonly IServiceProvider _serviceProvider; + private readonly TopLevelCommandManager _topLevelCommandManager; public event PropertyChangedEventHandler? PropertyChanged; + public AppearanceSettingsViewModel Appearance { get; } + public HotkeySettings? Hotkey { get => _settings.Hotkey; @@ -27,6 +43,27 @@ public partial class SettingsViewModel : INotifyPropertyChanged } } + public bool UseLowLevelGlobalHotkey + { + get => _settings.UseLowLevelGlobalHotkey; + set + { + _settings.UseLowLevelGlobalHotkey = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey))); + Save(); + } + } + + public bool AllowExternalReload + { + get => _settings.AllowExternalReload; + set + { + _settings.AllowExternalReload = value; + Save(); + } + } + public bool ShowAppDetails { get => _settings.ShowAppDetails; @@ -37,16 +74,6 @@ public partial class SettingsViewModel : INotifyPropertyChanged } } - public bool HotkeyGoesHome - { - get => _settings.HotkeyGoesHome; - set - { - _settings.HotkeyGoesHome = value; - Save(); - } - } - public bool BackspaceGoesBack { get => _settings.BackspaceGoesBack; @@ -87,31 +114,135 @@ public partial class SettingsViewModel : INotifyPropertyChanged } } - public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = []; + public bool ShowSystemTrayIcon + { + get => _settings.ShowSystemTrayIcon; + set + { + _settings.ShowSystemTrayIcon = value; + Save(); + } + } - public SettingsViewModel(SettingsModel settings, IServiceProvider serviceProvider, TaskScheduler scheduler) + public bool IgnoreShortcutWhenFullscreen + { + get => _settings.IgnoreShortcutWhenFullscreen; + set + { + _settings.IgnoreShortcutWhenFullscreen = value; + Save(); + } + } + + public bool DisableAnimations + { + get => _settings.DisableAnimations; + set + { + _settings.DisableAnimations = value; + Save(); + } + } + + public int AutoGoBackIntervalIndex + { + get + { + var index = AutoGoHomeIntervals.IndexOf(_settings.AutoGoHomeInterval); + return index >= 0 ? index : 0; + } + + set + { + if (value >= 0 && value < AutoGoHomeIntervals.Count) + { + _settings.AutoGoHomeInterval = AutoGoHomeIntervals[value]; + } + + Save(); + } + } + + public int EscapeKeyBehaviorIndex + { + get => (int)_settings.EscapeKeyBehaviorSetting; + set + { + _settings.EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value; + Save(); + } + } + + public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = new(); + + public ObservableCollection<FallbackSettingsViewModel> FallbackRankings { get; set; } = new(); + + public SettingsExtensionsViewModel Extensions { get; } + + public SettingsViewModel(SettingsModel settings, TopLevelCommandManager topLevelCommandManager, TaskScheduler scheduler, IThemeService themeService) { _settings = settings; - _serviceProvider = serviceProvider; + _topLevelCommandManager = topLevelCommandManager; + + Appearance = new AppearanceSettingsViewModel(themeService, _settings); var activeProviders = GetCommandProviders(); var allProviderSettings = _settings.ProviderSettings; + var fallbacks = new List<FallbackSettingsViewModel>(); + var currentRankings = _settings.FallbackRanks; + var needsSave = false; + foreach (var item in activeProviders) { var providerSettings = settings.GetProviderSettings(item); - var settingsModel = new ProviderSettingsViewModel(item, providerSettings, _serviceProvider); + var settingsModel = new ProviderSettingsViewModel(item, providerSettings, _settings); CommandProviders.Add(settingsModel); + + fallbacks.AddRange(settingsModel.FallbackCommands); + } + + var fallbackRankings = new List<Scored<FallbackSettingsViewModel>>(fallbacks.Count); + foreach (var fallback in fallbacks) + { + var index = currentRankings.IndexOf(fallback.Id); + var score = fallbacks.Count; + + if (index >= 0) + { + score = index; + } + + fallbackRankings.Add(new Scored<FallbackSettingsViewModel>() { Item = fallback, Score = score }); + + if (index == -1) + { + needsSave = true; + } + } + + FallbackRankings = new ObservableCollection<FallbackSettingsViewModel>(fallbackRankings.OrderBy(o => o.Score).Select(fr => fr.Item)); + Extensions = new SettingsExtensionsViewModel(CommandProviders, scheduler); + + if (needsSave) + { + ApplyFallbackSort(); } } private IEnumerable<CommandProviderWrapper> GetCommandProviders() { - var manager = _serviceProvider.GetService<TopLevelCommandManager>()!; - var allProviders = manager.CommandProviders; + var allProviders = _topLevelCommandManager.CommandProviders; return allProviders; } + public void ApplyFallbackSort() + { + _settings.FallbackRanks = FallbackRankings.Select(s => s.Id).ToArray(); + Save(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings))); + } + private void Save() => SettingsModel.SaveSettings(_settings); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs deleted file mode 100644 index 043598196b..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Runtime.InteropServices; -using System.Runtime.Versioning; -using CommunityToolkit.Common; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using CommunityToolkit.Mvvm.Messaging; -using ManagedCommon; -using Microsoft.CmdPal.Common.Services; -using Microsoft.CmdPal.UI.ViewModels.MainPage; -using Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.CmdPal.UI.ViewModels.Models; -using Microsoft.CommandPalette.Extensions; -using Microsoft.Extensions.DependencyInjection; -using WinRT; - -namespace Microsoft.CmdPal.UI.ViewModels; - -public partial class ShellViewModel(IServiceProvider _serviceProvider, TaskScheduler _scheduler) : ObservableObject -{ - [ObservableProperty] - public partial bool IsLoaded { get; set; } = false; - - [ObservableProperty] - public partial DetailsViewModel? Details { get; set; } - - [ObservableProperty] - public partial bool IsDetailsVisible { get; set; } - - [ObservableProperty] - public partial PageViewModel CurrentPage { get; set; } = new LoadingPageViewModel(null, _scheduler); - - private MainListPage? _mainListPage; - - private IExtensionWrapper? _activeExtension; - - [RelayCommand] - public async Task<bool> LoadAsync() - { - var tlcManager = _serviceProvider.GetService<TopLevelCommandManager>(); - await tlcManager!.LoadBuiltinsAsync(); - IsLoaded = true; - - // Built-ins have loaded. We can display our page at this point. - _mainListPage = new MainListPage(_serviceProvider); - WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_mainListPage))); - - _ = Task.Run(async () => - { - // After loading built-ins, and starting navigation, kick off a thread to load extensions. - tlcManager.LoadExtensionsCommand.Execute(null); - - await tlcManager.LoadExtensionsCommand.ExecutionTask!; - if (tlcManager.LoadExtensionsCommand.ExecutionTask.Status != TaskStatus.RanToCompletion) - { - // TODO: Handle failure case - } - }); - - return true; - } - - public void LoadPageViewModel(PageViewModel viewModel) - { - // Note: We removed the general loading state, extensions sometimes use their `IsLoading`, but it's inconsistently implemented it seems. - // IsInitialized is our main indicator of the general overall state of loading props/items from a page we use for the progress bar - // This triggers that load generally with the InitializeCommand asynchronously when we navigate to a page. - // We could re-track the page loading status, if we need it more granularly below again, but between the initialized and error message, we can infer some status. - // We could also maybe move this thread offloading we do for loading into PageViewModel and better communicate between the two... a few different options. - - ////LoadedState = ViewModelLoadedState.Loading; - if (!viewModel.IsInitialized - && viewModel.InitializeCommand != null) - { - _ = Task.Run(async () => - { - // You know, this creates the situation where we wait for - // both loading page properties, AND the items, before we - // display anything. - // - // We almost need to do an async await on initialize, then - // just a fire-and-forget on FetchItems. - // RE: We do set the CurrentPage in ShellPage.xaml.cs as well, so, we kind of are doing two different things here. - // Definitely some more clean-up to do, but at least its centralized to one spot now. - viewModel.InitializeCommand.Execute(null); - - await viewModel.InitializeCommand.ExecutionTask!; - - if (viewModel.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion) - { - // TODO: Handle failure case - if (viewModel.InitializeCommand.ExecutionTask.Exception is AggregateException ex) - { - Logger.LogError(ex.ToString()); - } - - // TODO GH #239 switch back when using the new MD text block - // _ = _queue.EnqueueAsync(() => - /*_queue.TryEnqueue(new(() => - { - LoadedState = ViewModelLoadedState.Error; - }));*/ - } - else - { - // TODO GH #239 switch back when using the new MD text block - // _ = _queue.EnqueueAsync(() => - _ = Task.Factory.StartNew( - () => - { - var result = (bool)viewModel.InitializeCommand.ExecutionTask.GetResultOrDefault()!; - - CurrentPage = viewModel; // result ? viewModel : null; - ////LoadedState = result ? ViewModelLoadedState.Loaded : ViewModelLoadedState.Error; - }, - CancellationToken.None, - TaskCreationOptions.None, - _scheduler); - } - }); - } - else - { - CurrentPage = viewModel; - ////LoadedState = ViewModelLoadedState.Loaded; - } - } - - public void PerformTopLevelCommand(PerformCommandMessage message) - { - if (_mainListPage == null) - { - return; - } - - if (message.Context is IListItem listItem) - { - _mainListPage.UpdateHistory(listItem); - } - } - - public void SetActiveExtension(IExtensionWrapper? extension) - { - if (extension != _activeExtension) - { - // There's not really a CoDisallowSetForegroundWindow, so we don't - // need to handle that - _activeExtension = extension; - - var extensionWinRtObject = _activeExtension?.GetExtensionObject(); - if (extensionWinRtObject != null) - { - try - { - unsafe - { - var winrtObj = (IWinRTObject)extensionWinRtObject; - var intPtr = winrtObj.NativeObject.ThisPtr; - var hr = Native.CoAllowSetForegroundWindow(intPtr); - if (hr != 0) - { - Logger.LogWarning($"Error giving foreground rights: 0x{hr.Value:X8}"); - } - } - } - catch (Exception ex) - { - Logger.LogError(ex.ToString()); - } - } - } - } - - public void GoHome() - { - SetActiveExtension(null); - } - - // You may ask yourself, why aren't we using CsWin32 for this? - // The CsWin32 projected version includes some object marshalling, like so: - // - // HRESULT CoAllowSetForegroundWindow([MarshalAs(UnmanagedType.IUnknown)] object pUnk,...) - // - // And if you do it like that, then the IForegroundTransfer interface isn't marshalled correctly - internal sealed class Native - { - [DllImport("OLE32.dll", ExactSpelling = true)] - [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] - [SupportedOSPlatform("windows5.0")] - internal static extern unsafe global::Windows.Win32.Foundation.HRESULT CoAllowSetForegroundWindow(nint pUnk, [Optional] void* lpvReserved); - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs index b33a515b7a..4473a1e144 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs @@ -4,12 +4,16 @@ using System.Collections.Immutable; using System.Collections.ObjectModel; +using System.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using ManagedCommon; -using Microsoft.CmdPal.Common.Services; +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; @@ -18,21 +22,27 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class TopLevelCommandManager : ObservableObject, IRecipient<ReloadCommandsMessage>, - IPageContext + IPageContext, + IDisposable { private readonly IServiceProvider _serviceProvider; + private readonly ICommandProviderCache _commandProviderCache; private readonly TaskScheduler _taskScheduler; private readonly List<CommandProviderWrapper> _builtInCommands = []; private readonly List<CommandProviderWrapper> _extensionCommandProviders = []; + private readonly Lock _commandProvidersLock = new(); + private readonly SupersedingAsyncGate _reloadCommandsGate; TaskScheduler IPageContext.Scheduler => _taskScheduler; - public TopLevelCommandManager(IServiceProvider serviceProvider) + public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache) { _serviceProvider = serviceProvider; + _commandProviderCache = commandProviderCache; _taskScheduler = _serviceProvider.GetService<TaskScheduler>()!; WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this); + _reloadCommandsGate = new(ReloadAllCommandsAsyncCore); } public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = []; @@ -40,11 +50,26 @@ public partial class TopLevelCommandManager : ObservableObject, [ObservableProperty] public partial bool IsLoading { get; private set; } = true; - public IEnumerable<CommandProviderWrapper> CommandProviders => _builtInCommands.Concat(_extensionCommandProviders); + public IEnumerable<CommandProviderWrapper> CommandProviders + { + get + { + lock (_commandProvidersLock) + { + return _builtInCommands.Concat(_extensionCommandProviders).ToList(); + } + } + } public async Task<bool> LoadBuiltinsAsync() { - _builtInCommands.Clear(); + var s = new Stopwatch(); + s.Start(); + + lock (_commandProvidersLock) + { + _builtInCommands.Clear(); + } // Load built-In commands first. These are all in-proc, and // owned by our ServiceProvider. @@ -52,47 +77,53 @@ public partial class TopLevelCommandManager : ObservableObject, foreach (var provider in builtInCommands) { CommandProviderWrapper wrapper = new(provider, _taskScheduler); - _builtInCommands.Add(wrapper); - await LoadTopLevelCommandsFromProvider(wrapper); + lock (_commandProvidersLock) + { + _builtInCommands.Add(wrapper); + } + + var commands = await LoadTopLevelCommandsFromProvider(wrapper); + lock (TopLevelCommands) + { + foreach (var c in commands) + { + TopLevelCommands.Add(c); + } + } } + s.Stop(); + + Logger.LogDebug($"Loading built-ins took {s.ElapsedMilliseconds}ms"); + return true; } // May be called from a background thread - private async Task LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider) + private async Task<IEnumerable<TopLevelViewModel>> LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider) { WeakReference<IPageContext> weakSelf = new(this); await commandProvider.LoadTopLevelCommands(_serviceProvider, weakSelf); - var settings = _serviceProvider.GetService<SettingsModel>()!; - var makeAndAdd = (ICommandItem? i, bool fallback) => - { - var commandItemViewModel = new CommandItemViewModel(new(i), weakSelf); - var topLevelViewModel = new TopLevelViewModel(commandItemViewModel, fallback, commandProvider.ExtensionHost, commandProvider.ProviderId, settings, _serviceProvider); - - lock (TopLevelCommands) - { - TopLevelCommands.Add(topLevelViewModel); - } - }; - - await Task.Factory.StartNew( + var commands = await Task.Factory.StartNew( () => { - lock (TopLevelCommands) + List<TopLevelViewModel> commands = []; + foreach (var item in commandProvider.TopLevelItems) { - foreach (var item in commandProvider.TopLevelItems) - { - TopLevelCommands.Add(item); - } + commands.Add(item); + } - foreach (var item in commandProvider.FallbackItems) + foreach (var item in commandProvider.FallbackItems) + { + if (item.IsEnabled) { - TopLevelCommands.Add(item); + commands.Add(item); } } + + return commands; }, CancellationToken.None, TaskCreationOptions.None, @@ -100,6 +131,8 @@ public partial class TopLevelCommandManager : ObservableObject, commandProvider.CommandsChanged -= CommandProvider_CommandsChanged; commandProvider.CommandsChanged += CommandProvider_CommandsChanged; + + return commands; } // By all accounts, we're already on a background thread (the COM call @@ -118,74 +151,77 @@ public partial class TopLevelCommandManager : ObservableObject, /// <returns>an awaitable task</returns> private async Task UpdateCommandsForProvider(CommandProviderWrapper sender, IItemsChangedEventArgs args) { - // Work on a clone of the list, so that we can just do one atomic - // update to the actual observable list at the end - List<TopLevelViewModel> clone = [.. TopLevelCommands]; - List<TopLevelViewModel> newItems = []; - var startIndex = -1; - var firstCommand = sender.TopLevelItems[0]; - var commandsToRemove = sender.TopLevelItems.Length + sender.FallbackItems.Length; - - // Tricky: all Commands from a single provider get added to the - // top-level list all together, in a row. So if we find just the first - // one, we can slice it out and insert the new ones there. - for (var i = 0; i < clone.Count; i++) - { - var wrapper = clone[i]; - try - { - var isTheSame = wrapper == firstCommand; - if (isTheSame) - { - startIndex = i; - break; - } - } - catch - { - } - } - WeakReference<IPageContext> weakSelf = new(this); - - // Fetch the new items await sender.LoadTopLevelCommands(_serviceProvider, weakSelf); - var settings = _serviceProvider.GetService<SettingsModel>()!; - - foreach (var i in sender.TopLevelItems) - { - newItems.Add(i); - } - + List<TopLevelViewModel> newItems = [.. sender.TopLevelItems]; foreach (var i in sender.FallbackItems) { - newItems.Add(i); + if (i.IsEnabled) + { + newItems.Add(i); + } } - // Slice out the old commands - if (startIndex != -1) + // modify the TopLevelCommands under shared lock; event if we clone it, we don't want + // TopLevelCommands to get modified while we're working on it. Otherwise, we might + // out clone would be stale at the end of this method. + lock (TopLevelCommands) { - clone.RemoveRange(startIndex, commandsToRemove); + // Work on a clone of the list, so that we can just do one atomic + // update to the actual observable list at the end + // TODO: just added a lock around all of this anyway, but keeping the clone + // while looking on some other ways to improve this; can be removed later. + List<TopLevelViewModel> clone = [.. TopLevelCommands]; + + var startIndex = FindIndexForFirstProviderItem(clone, sender.ProviderId); + clone.RemoveAll(item => item.CommandProviderId == sender.ProviderId); + clone.InsertRange(startIndex, newItems); + + ListHelpers.InPlaceUpdateList(TopLevelCommands, clone); } - else + + return; + + static int FindIndexForFirstProviderItem(List<TopLevelViewModel> topLevelItems, string providerId) { - // ... or, just stick them at the end (this is unexpected) - startIndex = clone.Count; + // Tricky: all Commands from a single provider get added to the + // top-level list all together, in a row. So if we find just the first + // one, we can slice it out and insert the new ones there. + for (var i = 0; i < topLevelItems.Count; i++) + { + var wrapper = topLevelItems[i]; + try + { + if (providerId == wrapper.CommandProviderId) + { + return i; + } + } + catch + { + } + } + + // If we didn't find any, then we just append the new commands to the end of the list. + return topLevelItems.Count; } - - // add the new commands into the list at the place we found the old ones - clone.InsertRange(startIndex, newItems); - - // now update the actual observable list with the new contents - ListHelpers.InPlaceUpdateList(TopLevelCommands, clone); } public async Task ReloadAllCommandsAsync() + { + // gate ensures that the reload is serialized and if multiple calls + // request a reload, only the first and the last one will be executed. + // this should be superseded with a cancellable version. + await _reloadCommandsGate.ExecuteAsync(CancellationToken.None); + } + + private async Task ReloadAllCommandsAsyncCore(CancellationToken cancellationToken) { IsLoading = true; var extensionService = _serviceProvider.GetService<IExtensionService>()!; await extensionService.SignalStopExtensionsAsync(); + lock (TopLevelCommands) { TopLevelCommands.Clear(); @@ -211,8 +247,12 @@ public partial class TopLevelCommandManager : ObservableObject, extensionService.OnExtensionRemoved -= ExtensionService_OnExtensionRemoved; var extensions = (await extensionService.GetInstalledExtensionsAsync()).ToImmutableList(); - _extensionCommandProviders.Clear(); - if (extensions != null) + lock (_commandProvidersLock) + { + _extensionCommandProviders.Clear(); + } + + if (extensions is not null) { await StartExtensionsAndGetCommands(extensions); } @@ -222,6 +262,9 @@ public partial class TopLevelCommandManager : ObservableObject, IsLoading = false; + // Send on the current thread; receivers should marshal to UI if needed + WeakReferenceMessenger.Default.Send<ReloadFinishedMessage>(); + return true; } @@ -239,25 +282,71 @@ public partial class TopLevelCommandManager : ObservableObject, private async Task StartExtensionsAndGetCommands(IEnumerable<IExtensionWrapper> extensions) { - // TODO This most definitely needs a lock - foreach (var extension in extensions) - { - Logger.LogDebug($"Starting {extension.PackageFullName}"); - try - { - // start it ... - await extension.StartExtensionAsync(); + var timer = new Stopwatch(); + timer.Start(); - // ... and fetch the command provider from it. - CommandProviderWrapper wrapper = new(extension, _taskScheduler); - _extensionCommandProviders.Add(wrapper); - await LoadTopLevelCommandsFromProvider(wrapper); - } - catch (Exception ex) + // Start all extensions in parallel + var startTasks = extensions.Select(StartExtensionWithTimeoutAsync); + + // Wait for all extensions to start + var wrappers = (await Task.WhenAll(startTasks)).Where(wrapper => wrapper is not null).Select(w => w!).ToList(); + + lock (_commandProvidersLock) + { + _extensionCommandProviders.AddRange(wrappers); + } + + // Load the commands from the providers in parallel + var loadTasks = wrappers.Select(LoadCommandsWithTimeoutAsync); + + var commandSets = (await Task.WhenAll(loadTasks)).Where(results => results is not null).Select(r => r!).ToList(); + + lock (TopLevelCommands) + { + foreach (var commands in commandSets) { - Logger.LogError(ex.ToString()); + foreach (var c in commands) + { + TopLevelCommands.Add(c); + } } } + + timer.Stop(); + Logger.LogDebug($"Loading extensions took {timer.ElapsedMilliseconds} ms"); + } + + private async Task<CommandProviderWrapper?> StartExtensionWithTimeoutAsync(IExtensionWrapper extension) + { + Logger.LogDebug($"Starting {extension.PackageFullName}"); + try + { + await extension.StartExtensionAsync().WaitAsync(TimeSpan.FromSeconds(10)); + return new CommandProviderWrapper(extension, _taskScheduler, _commandProviderCache); + } + catch (Exception ex) + { + Logger.LogError($"Failed to start extension {extension.PackageFullName}: {ex}"); + return null; // Return null for failed extensions + } + } + + private async Task<IEnumerable<TopLevelViewModel>?> LoadCommandsWithTimeoutAsync(CommandProviderWrapper wrapper) + { + try + { + return await LoadTopLevelCommandsFromProvider(wrapper!).WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (TimeoutException) + { + Logger.LogError($"Loading commands from {wrapper!.ExtensionHost?.Extension?.PackageFullName} timed out"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load commands for extension {wrapper!.ExtensionHost?.Extension?.PackageFullName}: {ex}"); + } + + return null; } private void ExtensionService_OnExtensionRemoved(IExtensionService sender, IEnumerable<IExtensionWrapper> extensions) @@ -327,7 +416,22 @@ public partial class TopLevelCommandManager : ObservableObject, void IPageContext.ShowException(Exception ex, string? extensionHint) { - var errorMessage = $"A bug occurred in {$"the \"{extensionHint}\"" ?? "an unknown's"} extension's code:\n{ex.Message}\n{ex.Source}\n{ex.StackTrace}\n\n"; - CommandPaletteHost.Instance.Log(errorMessage); + var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint ?? "TopLevelCommandManager"); + CommandPaletteHost.Instance.Log(message); + } + + internal bool IsProviderActive(string id) + { + lock (_commandProvidersLock) + { + return _builtInCommands.Any(wrapper => wrapper.Id == id && wrapper.IsActive) + || _extensionCommandProviders.Any(wrapper => wrapper.Id == id && wrapper.IsActive); + } + } + + public void Dispose() + { + _reloadCommandsGate.Dispose(); + GC.SuppressFinalize(this); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index 4b8a2b49a9..13b9423119 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -3,8 +3,13 @@ // See the LICENSE file in the project root for more information. using System.Collections.ObjectModel; +using System.Diagnostics; using CommunityToolkit.Mvvm.ComponentModel; using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Text; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -14,19 +19,28 @@ using WyHash; namespace Microsoft.CmdPal.UI.ViewModels; -public sealed partial class TopLevelViewModel : ObservableObject, IListItem +[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] +public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider, IPrecomputedListItem { private readonly SettingsModel _settings; + private readonly ProviderSettings _providerSettings; private readonly IServiceProvider _serviceProvider; private readonly CommandItemViewModel _commandItemViewModel; private readonly string _commandProviderId; - private string IdFromModel => _commandItemViewModel.Command.Id; + private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id; + + private string _fallbackId = string.Empty; private string _generatedId = string.Empty; private HotkeySettings? _hotkey; + private IIconInfo? _initialIcon; + + private FuzzyTargetCache _titleCache; + private FuzzyTargetCache _subtitleCache; + private FuzzyTargetCache _extensionNameCache; private CommandAlias? Alias { get; set; } @@ -35,7 +49,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem [ObservableProperty] public partial ObservableCollection<Tag> Tags { get; set; } = []; - public string Id => string.IsNullOrEmpty(IdFromModel) ? _generatedId : IdFromModel; + public string Id => string.IsNullOrWhiteSpace(IdFromModel) ? _generatedId : IdFromModel; public CommandPaletteHost ExtensionHost { get; private set; } @@ -43,6 +57,8 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem public CommandItemViewModel ItemViewModel => _commandItemViewModel; + public string CommandProviderId => _commandProviderId; + ////// ICommandItem public string Title => _commandItemViewModel.Title; @@ -50,9 +66,26 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem public IIconInfo Icon => _commandItemViewModel.Icon; + public IIconInfo InitialIcon => _initialIcon ?? _commandItemViewModel.Icon; + ICommand? ICommandItem.Command => _commandItemViewModel.Command.Model.Unsafe; - IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands.Select(i => i.Model.Unsafe).ToArray(); + IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands + .Select(item => + { + if (item is ISeparatorContextItem) + { + return item as IContextItem; + } + else if (item is CommandContextItemViewModel commandItem) + { + return commandItem.Model.Unsafe; + } + else + { + return null; + } + }).ToArray(); ////// IListItem ITag[] IListItem.Tags => Tags.ToArray(); @@ -66,6 +99,9 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem ////// INotifyPropChanged public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged; + // Fallback items + public string DisplayTitle { get; private set; } = string.Empty; + public HotkeySettings? Hotkey { get => _hotkey; @@ -85,6 +121,8 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem get => Alias?.Alias ?? string.Empty; set { + var previousAlias = Alias?.Alias ?? string.Empty; + if (string.IsNullOrEmpty(value)) { Alias = null; @@ -101,7 +139,13 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem } } - HandleChangeAlias(); + // Only call HandleChangeAlias if there was an actual change. + if (previousAlias != Alias?.Alias) + { + HandleChangeAlias(); + OnPropertyChanged(nameof(AliasText)); + OnPropertyChanged(nameof(IsDirectAlias)); + } } } @@ -116,46 +160,134 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem } HandleChangeAlias(); + OnPropertyChanged(nameof(IsDirectAlias)); } } + public bool IsEnabled + { + get + { + if (IsFallback) + { + if (_providerSettings.FallbackCommands.TryGetValue(_fallbackId, out var fallbackSettings)) + { + return fallbackSettings.IsEnabled; + } + + return true; + } + else + { + return _providerSettings.IsEnabled; + } + } + } + + public string ExtensionName => ExtensionHost.GetExtensionDisplayName() ?? string.Empty; + public TopLevelViewModel( CommandItemViewModel item, bool isFallback, CommandPaletteHost extensionHost, string commandProviderId, SettingsModel settings, - IServiceProvider serviceProvider) + ProviderSettings providerSettings, + IServiceProvider serviceProvider, + ICommandItem? commandItem) { _serviceProvider = serviceProvider; _settings = settings; + _providerSettings = providerSettings; _commandProviderId = commandProviderId; _commandItemViewModel = item; IsFallback = isFallback; ExtensionHost = extensionHost; + if (isFallback && commandItem is FallbackCommandItem fallback) + { + _fallbackId = fallback.Id; + } - item.PropertyChanged += Item_PropertyChanged; + item.PropertyChangedBackground += Item_PropertyChanged; // UpdateAlias(); // UpdateHotkey(); // UpdateTags(); } + internal void InitializeProperties() + { + ItemViewModel.SlowInitializeProperties(); + + if (IsFallback) + { + var model = _commandItemViewModel.Model.Unsafe; + + // RPC to check type + if (model is IFallbackCommandItem fallback) + { + DisplayTitle = fallback.DisplayTitle; + } + + UpdateInitialIcon(false); + } + } + private void Item_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { if (!string.IsNullOrEmpty(e.PropertyName)) { PropChanged?.Invoke(this, new PropChangedEventArgs(e.PropertyName)); - if (e.PropertyName == "IsInitialized") + if (e.PropertyName is nameof(CommandItemViewModel.Title) or nameof(CommandItemViewModel.Name)) + { + _titleCache.Invalidate(); + } + else if (e.PropertyName is nameof(CommandItemViewModel.Subtitle)) + { + _subtitleCache.Invalidate(); + } + + if (e.PropertyName is "IsInitialized" or nameof(CommandItemViewModel.Command)) { GenerateId(); - UpdateAlias(); + FetchAliasFromAliasManager(); UpdateHotkey(); UpdateTags(); + UpdateInitialIcon(); } + else if (e.PropertyName == nameof(CommandItem.Icon)) + { + UpdateInitialIcon(); + } + else if (e.PropertyName == nameof(CommandItem.DataPackage)) + { + DoOnUiThread(() => + { + OnPropertyChanged(nameof(CommandItem.DataPackage)); + }); + } + } + } + + private void UpdateInitialIcon(bool raiseNotification = true) + { + if (_initialIcon != null || !_commandItemViewModel.Icon.IsSet) + { + return; + } + + _initialIcon = _commandItemViewModel.Icon; + + if (raiseNotification) + { + DoOnUiThread( + () => + { + PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(InitialIcon))); + }); } } @@ -163,31 +295,38 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem private void HandleChangeAlias() { - SetAlias(Alias); + SetAlias(); Save(); } - public void SetAlias(CommandAlias? newAlias) + public void SetAlias() { - _serviceProvider.GetService<AliasManager>()!.UpdateAlias(Id, newAlias); - UpdateAlias(); + var commandAlias = Alias is null + ? null + : new CommandAlias(Alias.Alias, Alias.CommandId, Alias.IsDirect); + + _serviceProvider.GetService<AliasManager>()!.UpdateAlias(Id, commandAlias); UpdateTags(); } - private void UpdateAlias() + private void FetchAliasFromAliasManager() { - // Add tags for the alias, if we have one. - var aliases = _serviceProvider.GetService<AliasManager>(); - if (aliases != null) + var am = _serviceProvider.GetService<AliasManager>(); + if (am is not null) { - Alias = aliases.AliasFromId(Id); + var commandAlias = am.AliasFromId(Id); + if (commandAlias is not null) + { + // Decouple from the alias manager alias object + Alias = new CommandAlias(commandAlias.Alias, commandAlias.CommandId, commandAlias.IsDirect); + } } } private void UpdateHotkey() { var hotkey = _settings.CommandHotkeys.Where(hk => hk.CommandId == Id).FirstOrDefault(); - if (hotkey != null) + if (hotkey is not null) { _hotkey = hotkey.Hotkey; } @@ -195,14 +334,14 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem private void UpdateTags() { - List<Tag> tags = new(); + List<Tag> tags = []; - if (Hotkey != null) + if (Hotkey is not null) { tags.Add(new Tag() { Text = Hotkey.ToString() }); } - if (Alias != null) + if (Alias is not null) { tags.Add(new Tag() { Text = Alias.SearchPrefix }); } @@ -219,7 +358,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem { // Use WyHash64 to generate stable ID hashes. // manually seeding with 0, so that the hash is stable across launches - var result = WyHash64.ComputeHash64(_commandProviderId + Title + Subtitle, seed: 0); + var result = WyHash64.ComputeHash64(_commandProviderId + DisplayTitle + Title + Subtitle, seed: 0); _generatedId = $"{_commandProviderId}{result}"; } @@ -242,6 +381,11 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem return false; } + if (!IsEnabled) + { + return false; + } + try { return UnsafeUpdateFallbackSynchronous(newQuery); @@ -277,4 +421,36 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem return false; } + + public PerformCommandMessage GetPerformCommandMessage() + { + return new PerformCommandMessage(this.CommandViewModel.Model, new Core.ViewModels.Models.ExtensionObject<IListItem>(this)); + } + + public override string ToString() + { + return $"{nameof(TopLevelViewModel)}: {Id} ({Title}) - display: {DisplayTitle} - fallback: {IsFallback} - enabled: {IsEnabled}"; + } + + public IDictionary<string, object?> GetProperties() + { + return new Dictionary<string, object?> + { + [WellKnownExtensionAttributes.DataPackage] = _commandItemViewModel?.DataPackage, + }; + } + + public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) + => _titleCache.GetOrUpdate(matcher, Title); + + public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) + => _subtitleCache.GetOrUpdate(matcher, Subtitle); + + public FuzzyTarget GetExtensionNameTarget(IPrecomputedFuzzyMatcher matcher) + => _extensionNameCache.GetOrUpdate(matcher, ExtensionName); + + private string GetDebuggerDisplay() + { + return ToString(); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.cs new file mode 100644 index 0000000000..290668f3f5 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/UserTheme.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; + +public enum UserTheme +{ + Default, + Light, + Dark, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs new file mode 100644 index 0000000000..7963aec154 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.Graphics; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public sealed class WindowPosition +{ + /// <summary> + /// Gets or sets left position in device pixels. + /// </summary> + public int X { get; set; } + + /// <summary> + /// Gets or sets top position in device pixels. + /// </summary> + public int Y { get; set; } + + /// <summary> + /// Gets or sets width in device pixels. + /// </summary> + public int Width { get; set; } + + /// <summary> + /// Gets or sets height in device pixels. + /// </summary> + public int Height { get; set; } + + /// <summary> + /// Gets or sets width of the screen in device pixels where the window is located. + /// </summary> + public int ScreenWidth { get; set; } + + /// <summary> + /// Gets or sets height of the screen in device pixels where the window is located. + /// </summary> + public int ScreenHeight { get; set; } + + /// <summary> + /// Gets or sets DPI (dots per inch) of the display where the window is located. + /// </summary> + public int Dpi { get; set; } + + /// <summary> + /// Converts the window position properties to a <see cref="RectInt32"/> structure representing the physical window rectangle. + /// </summary> + public RectInt32 ToPhysicalWindowRectangle() + { + return new RectInt32(X, Y, Width, Height); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml index ffcca6b3a8..d8d4655291 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml @@ -1,25 +1,31 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8" ?> <Application x:Class="Microsoft.CmdPal.UI.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:local="using:Microsoft.CmdPal.UI"> + xmlns:controls="using:Microsoft.CmdPal.UI.Controls" + xmlns:local="using:Microsoft.CmdPal.UI" + xmlns:services="using:Microsoft.CmdPal.UI.Services"> <Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> <!-- Other merged dictionaries here --> - <ResourceDictionary Source="Styles/Button.xaml" /> - <ResourceDictionary Source="Styles/Colors.xaml" /> - <ResourceDictionary Source="Styles/TextBox.xaml" /> - <ResourceDictionary Source="Styles/Settings.xaml" /> - <ResourceDictionary Source="Controls/Tag.xaml" /> - <ResourceDictionary Source="Controls/KeyVisual/KeyVisual.xaml" /> + <ResourceDictionary Source="ms-appx:///Styles/Colors.xaml" /> + <ResourceDictionary Source="ms-appx:///Styles/TextBlock.xaml" /> + <ResourceDictionary Source="ms-appx:///Styles/TextBox.xaml" /> + <ResourceDictionary Source="ms-appx:///Styles/Settings.xaml" /> + <ResourceDictionary Source="ms-appx:///Controls/Tag.xaml" /> + <ResourceDictionary Source="ms-appx:///Controls/KeyVisual/KeyVisual.xaml" /> + <ResourceDictionary Source="ms-appx:///Controls/IsEnabledTextBlock.xaml" /> + <!-- Default theme dictionary --> + <ResourceDictionary Source="ms-appx:///Styles/Theme.Normal.xaml" /> + <services:MutableOverridesDictionary /> </ResourceDictionary.MergedDictionaries> <!-- Other app resources here --> <x:Double x:Key="SettingActionControlMinWidth">240</x:Double> - + <Style BasedOn="{StaticResource DefaultCheckBoxStyle}" TargetType="controls:CheckBoxWithDescriptionControl" /> </ResourceDictionary> </Application.Resources> </Application> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 478d03536e..5101891e6f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -2,13 +2,20 @@ // 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.Common.Helpers; -using Microsoft.CmdPal.Common.Services; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Core.Common.Text; +using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.Ext.Apps; using Microsoft.CmdPal.Ext.Bookmarks; using Microsoft.CmdPal.Ext.Calc; +using Microsoft.CmdPal.Ext.ClipboardHistory; using Microsoft.CmdPal.Ext.Indexer; +using Microsoft.CmdPal.Ext.PerformanceMonitor; using Microsoft.CmdPal.Ext.Registry; +using Microsoft.CmdPal.Ext.RemoteDesktop; using Microsoft.CmdPal.Ext.Shell; using Microsoft.CmdPal.Ext.System; using Microsoft.CmdPal.Ext.TimeDate; @@ -18,12 +25,16 @@ using Microsoft.CmdPal.Ext.WindowsSettings; using Microsoft.CmdPal.Ext.WindowsTerminal; using Microsoft.CmdPal.Ext.WindowWalker; using Microsoft.CmdPal.Ext.WinGet; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.Services; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; using Microsoft.CmdPal.UI.ViewModels.Models; +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, @@ -33,8 +44,10 @@ namespace Microsoft.CmdPal.UI; /// <summary> /// Provides application-specific behavior to supplement the default Application class. /// </summary> -public partial class App : Application +public partial class App : Application, IDisposable { + private readonly GlobalErrorHandler _globalErrorHandler = new(); + /// <summary> /// Gets the current <see cref="App"/> instance in use. /// </summary> @@ -56,71 +69,107 @@ public partial class App : Application /// </summary> public App() { - Services = ConfigureServices(); + var appInfoService = new ApplicationInfoService(); + +#if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER + _globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default, appInfoService); +#endif + + Services = ConfigureServices(appInfoService); + + IconCacheProvider.Initialize(Services); this.InitializeComponent(); + // Ensure types used in XAML are preserved for AOT compilation + TypePreservation.PreserveTypes(); + NativeEventWaiter.WaitForEventLoop( "Local\\PowerToysCmdPal-ExitEvent-eb73f6be-3f22-4b36-aee3-62924ba40bfd", () => { EtwTrace?.Dispose(); + AppWindow?.Close(); Environment.Exit(0); }); + + // Connect the PT logging to the core project's logging. + // This way, log statements from the core project will be captured by the PT logs + var logWrapper = new LogWrapper(); + CoreLogger.InitializeLogger(logWrapper); + + // Now that CoreLogger is initialized, initialize the logger delegate in ApplicationInfoService + appInfoService.SetLogDirectory(() => Logger.CurrentVersionLogDirectoryPath); } /// <summary> /// Invoked when the application is launched. /// </summary> /// <param name="args">Details about the launch request and process.</param> - protected override void OnLaunched(LaunchActivatedEventArgs args) + protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) { AppWindow = new MainWindow(); - var cmdArgs = Environment.GetCommandLineArgs(); - - bool runFromPT = false; - foreach (var arg in cmdArgs) - { - if (arg == "RunFromPT") - { - runFromPT = true; - break; - } - } - - if (!runFromPT) - { - AppWindow.Activate(); - } + var activatedEventArgs = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent().GetActivatedEventArgs(); + ((MainWindow)AppWindow).HandleLaunchNonUI(activatedEventArgs); } /// <summary> /// Configures the services for the application /// </summary> - private static ServiceProvider ConfigureServices() + private static ServiceProvider ConfigureServices(IApplicationInfoService appInfoService) { // TODO: It's in the Labs feed, but we can use Sergio's AOT-friendly source generator for this: https://github.com/CommunityToolkit/Labs-Windows/discussions/463 ServiceCollection services = new(); // Root services services.AddSingleton(TaskScheduler.FromCurrentSynchronizationContext()); + var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + AddBuiltInCommands(services); + + AddCoreServices(services, appInfoService); + + AddUIServices(services, dispatcherQueue); + + return services.BuildServiceProvider(); + } + + private static void AddBuiltInCommands(ServiceCollection services) + { // Built-in Commands. Order matters - this is the order they'll be presented by default. var allApps = new AllAppsCommandProvider(); - var winget = new WinGetExtensionCommandsProvider(); - var callback = allApps.LookupApp; - winget.SetAllLookup(callback); + var files = new IndexerCommandsProvider(); + files.SuppressFallbackWhen(ShellCommandsProvider.SuppressFileFallbackIf); services.AddSingleton<ICommandProvider>(allApps); + services.AddSingleton<ICommandProvider, ShellCommandsProvider>(); services.AddSingleton<ICommandProvider, CalculatorCommandProvider>(); - services.AddSingleton<ICommandProvider, IndexerCommandsProvider>(); - services.AddSingleton<ICommandProvider, BookmarksCommandProvider>(); + services.AddSingleton<ICommandProvider>(files); + services.AddSingleton<ICommandProvider, BookmarksCommandProvider>(_ => BookmarksCommandProvider.CreateWithDefaultStore()); - // TODO GH #527 re-enable the clipboard commands - // services.AddSingleton<ICommandProvider, ClipboardHistoryCommandsProvider>(); services.AddSingleton<ICommandProvider, WindowWalkerCommandsProvider>(); services.AddSingleton<ICommandProvider, WebSearchCommandsProvider>(); - services.AddSingleton<ICommandProvider>(winget); + services.AddSingleton<ICommandProvider, ClipboardHistoryCommandsProvider>(); + + // GH #38440: Users might not have WinGet installed! Or they might have + // a ridiculously old version. Or might be running as admin. + // We shouldn't explode in the App ctor if we fail to instantiate an + // instance of PackageManager, which will happen in the static ctor + // for WinGetStatics + try + { + var winget = new WinGetExtensionCommandsProvider(); + winget.SetAllLookup( + query => allApps.LookupAppByPackageFamilyName(query, requireSingleMatch: true), + query => allApps.LookupAppByProductCode(query, requireSingleMatch: true)); + services.AddSingleton<ICommandProvider>(winget); + } + catch (Exception ex) + { + Logger.LogError("Couldn't load winget"); + Logger.LogError(ex.ToString()); + } + services.AddSingleton<ICommandProvider, WindowsTerminalCommandsProvider>(); services.AddSingleton<ICommandProvider, WindowsSettingsCommandsProvider>(); services.AddSingleton<ICommandProvider, RegistryCommandsProvider>(); @@ -128,20 +177,57 @@ public partial class App : Application services.AddSingleton<ICommandProvider, BuiltInsCommandProvider>(); services.AddSingleton<ICommandProvider, TimeDateCommandsProvider>(); services.AddSingleton<ICommandProvider, SystemCommandExtensionProvider>(); + services.AddSingleton<ICommandProvider, RemoteDesktopCommandProvider>(); + services.AddSingleton<ICommandProvider, PerformanceMonitorCommandsProvider>(); + } + private static void AddUIServices(ServiceCollection services, DispatcherQueue dispatcherQueue) + { // Models - services.AddSingleton<TopLevelCommandManager>(); - services.AddSingleton<AliasManager>(); - services.AddSingleton<HotkeyManager>(); var sm = SettingsModel.LoadSettings(); services.AddSingleton(sm); var state = AppStateModel.LoadState(); services.AddSingleton(state); + + // Services + services.AddSingleton<ICommandProviderCache, DefaultCommandProviderCache>(); + services.AddSingleton<TopLevelCommandManager>(); + services.AddSingleton<AliasManager>(); + services.AddSingleton<HotkeyManager>(); + + services.AddSingleton<MainWindowViewModel>(); + services.AddSingleton<TrayIconService>(); + + services.AddSingleton<IThemeService, ThemeService>(); + services.AddSingleton<ResourceSwapper>(); + + services.AddIconServices(dispatcherQueue); + } + + private static void AddCoreServices(ServiceCollection services, IApplicationInfoService appInfoService) + { + // Core services + services.AddSingleton(appInfoService); + services.AddSingleton<IExtensionService, ExtensionService>(); + services.AddSingleton<IRunHistoryService, RunHistoryService>(); + + services.AddSingleton<IRootPageService, PowerToysRootPageService>(); + services.AddSingleton<IAppHostService, PowerToysAppHostService>(); + services.AddSingleton<ITelemetryService, TelemetryForwarder>(); + + services.AddSingleton<IFuzzyMatcherProvider, FuzzyMatcherProvider>( + _ => new FuzzyMatcherProvider(new PrecomputedFuzzyMatcherOptions(), new PinyinFuzzyMatcherOptions())); // ViewModels services.AddSingleton<ShellViewModel>(); + services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>(); + } - return services.BuildServiceProvider(); + 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/Assets/StoreLogo.dark.svg b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/StoreLogo.dark.svg new file mode 100644 index 0000000000..ec3cb933d8 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/StoreLogo.dark.svg @@ -0,0 +1,100 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_125_2801)"> +<g clip-path="url(#clip1_125_2801)"> +<path d="M0 5C0 4.44771 0.447715 4 1 4H22C22.5523 4 23 4.44772 23 5V18.5C23 20.9853 20.9853 23 18.5 23H4.5C2.01472 23 0 20.9853 0 18.5V5Z" fill="url(#paint0_linear_125_2801)"/> +<mask id="mask0_125_2801" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="4" width="23" height="19"> +<path d="M0 5C0 4.44771 0.447715 4 1 4H22C22.5523 4 23 4.44772 23 5V18.5C23 20.9853 20.9853 23 18.5 23H4.5C2.01472 23 0 20.9853 0 18.5V5Z" fill="url(#paint1_linear_125_2801)"/> +</mask> +<g mask="url(#mask0_125_2801)"> +<g filter="url(#filter0_dd_125_2801)"> +<path d="M6 5V1H17V5" stroke="url(#paint2_linear_125_2801)" stroke-width="2" stroke-linecap="round"/> +</g> +</g> +<rect x="6" y="8" width="11" height="11" rx="1" fill="white" fill-opacity="0.5"/> +<g filter="url(#filter1_d_125_2801)"> +<path d="M11 9H7V13H11V9Z" fill="#F25022"/> +<path d="M16 9H12V13H16V9Z" fill="#7FBA00"/> +<path d="M16 14H12V18H16V14Z" fill="#FFB900"/> +<path d="M11 14H7V18H11V14Z" fill="#00A4EF"/> +</g> +<path d="M6 5V2C6 1.44772 6.44772 1 7 1H16C16.5523 1 17 1.44772 17 2V5" stroke="url(#paint3_linear_125_2801)" stroke-width="2" stroke-linecap="round"/> +<rect x="7" width="9" height="2" fill="url(#paint4_linear_125_2801)"/> +<mask id="mask1_125_2801" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="7" y="0" width="9" height="2"> +<rect x="7" width="9" height="2" fill="url(#paint5_linear_125_2801)"/> +</mask> +<g mask="url(#mask1_125_2801)"> +<g filter="url(#filter2_dd_125_2801)"> +</g> +</g> +</g> +</g> +<defs> +<filter id="filter0_dd_125_2801" x="3.5" y="-1" width="16" height="9" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="0.25"/> +<feGaussianBlur stdDeviation="0.25"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_125_2801"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="0.5"/> +<feGaussianBlur stdDeviation="0.75"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_125_2801" result="effect2_dropShadow_125_2801"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_125_2801" result="shape"/> +</filter> +<filter id="filter1_d_125_2801" x="6" y="8.25" width="11" height="11" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="0.25"/> +<feGaussianBlur stdDeviation="0.5"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_125_2801"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_125_2801" result="shape"/> +</filter> +<filter id="filter2_dd_125_2801" x="3.5" y="-2.5" width="16" height="8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="-1"/> +<feGaussianBlur stdDeviation="0.25"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_125_2801"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="-1"/> +<feGaussianBlur stdDeviation="0.75"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_125_2801" result="effect2_dropShadow_125_2801"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_125_2801" result="shape"/> +</filter> +<linearGradient id="paint0_linear_125_2801" x1="5.22727" y1="1.19318" x2="9.5894" y2="22.315" gradientUnits="userSpaceOnUse"> +<stop offset="0.270833" stop-color="white"/> +<stop offset="1" stop-color="#DFDFDF"/> +</linearGradient> +<linearGradient id="paint1_linear_125_2801" x1="11.5" y1="4" x2="15.4941" y2="23.743" gradientUnits="userSpaceOnUse"> +<stop stop-color="#0078D4"/> +<stop offset="1" stop-color="#114A8B"/> +</linearGradient> +<linearGradient id="paint2_linear_125_2801" x1="11.5" y1="1" x2="11.8823" y2="5.29249" gradientUnits="userSpaceOnUse"> +<stop stop-color="#28AFEA"/> +<stop offset="1" stop-color="#0078D4"/> +</linearGradient> +<linearGradient id="paint3_linear_125_2801" x1="11.5" y1="1" x2="11.7158" y2="4.23049" gradientUnits="userSpaceOnUse"> +<stop stop-color="#30DAFF"/> +<stop offset="1" stop-color="#0094D4"/> +</linearGradient> +<linearGradient id="paint4_linear_125_2801" x1="11" y1="5.23303e-08" x2="11" y2="2" gradientUnits="userSpaceOnUse"> +<stop stop-color="#22BCFF"/> +<stop offset="1" stop-color="#0088F0"/> +</linearGradient> +<linearGradient id="paint5_linear_125_2801" x1="11" y1="5.23303e-08" x2="11" y2="2" gradientUnits="userSpaceOnUse"> +<stop stop-color="#28AFEA"/> +<stop offset="1" stop-color="#3CCBF4"/> +</linearGradient> +<clipPath id="clip0_125_2801"> +<rect width="24" height="24" fill="white"/> +</clipPath> +<clipPath id="clip1_125_2801"> +<rect width="24" height="24" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/StoreLogo.light.svg b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/StoreLogo.light.svg new file mode 100644 index 0000000000..9d74683716 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/StoreLogo.light.svg @@ -0,0 +1,100 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_125_2875)"> +<g clip-path="url(#clip1_125_2875)"> +<path d="M0 5C0 4.44771 0.447715 4 1 4H22C22.5523 4 23 4.44772 23 5V18.5C23 20.9853 20.9853 23 18.5 23H4.5C2.01472 23 0 20.9853 0 18.5V5Z" fill="url(#paint0_linear_125_2875)"/> +<mask id="mask0_125_2875" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="4" width="23" height="19"> +<path d="M0 5C0 4.44771 0.447715 4 1 4H22C22.5523 4 23 4.44772 23 5V18.5C23 20.9853 20.9853 23 18.5 23H4.5C2.01472 23 0 20.9853 0 18.5V5Z" fill="url(#paint1_linear_125_2875)"/> +</mask> +<g mask="url(#mask0_125_2875)"> +<g filter="url(#filter0_dd_125_2875)"> +<path d="M6 5V1H17V5" stroke="url(#paint2_linear_125_2875)" stroke-width="2" stroke-linecap="round"/> +</g> +</g> +<rect x="6" y="8" width="11" height="11" rx="1" fill="#243A5F" fill-opacity="0.5"/> +<g filter="url(#filter1_d_125_2875)"> +<path d="M11 9H7V13H11V9Z" fill="#F25022"/> +<path d="M16 9H12V13H16V9Z" fill="#7FBA00"/> +<path d="M16 14H12V18H16V14Z" fill="#FFB900"/> +<path d="M11 14H7V18H11V14Z" fill="#00A4EF"/> +</g> +<path d="M6 5V2C6 1.44772 6.44772 1 7 1H16C16.5523 1 17 1.44772 17 2V5" stroke="url(#paint3_linear_125_2875)" stroke-width="2" stroke-linecap="round"/> +<rect x="7" width="9" height="2" fill="url(#paint4_linear_125_2875)"/> +<mask id="mask1_125_2875" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="7" y="0" width="9" height="2"> +<rect x="7" width="9" height="2" fill="url(#paint5_linear_125_2875)"/> +</mask> +<g mask="url(#mask1_125_2875)"> +<g filter="url(#filter2_dd_125_2875)"> +</g> +</g> +</g> +</g> +<defs> +<filter id="filter0_dd_125_2875" x="3.5" y="-1" width="16" height="9" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="0.25"/> +<feGaussianBlur stdDeviation="0.25"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_125_2875"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="0.5"/> +<feGaussianBlur stdDeviation="0.75"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_125_2875" result="effect2_dropShadow_125_2875"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_125_2875" result="shape"/> +</filter> +<filter id="filter1_d_125_2875" x="6" y="8.25" width="11" height="11" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="0.25"/> +<feGaussianBlur stdDeviation="0.5"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_125_2875"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_125_2875" result="shape"/> +</filter> +<filter id="filter2_dd_125_2875" x="3.5" y="-2.5" width="16" height="8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="-1"/> +<feGaussianBlur stdDeviation="0.25"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_125_2875"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="-1"/> +<feGaussianBlur stdDeviation="0.75"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_125_2875" result="effect2_dropShadow_125_2875"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_125_2875" result="shape"/> +</filter> +<linearGradient id="paint0_linear_125_2875" x1="11.5" y1="4" x2="15.4941" y2="23.743" gradientUnits="userSpaceOnUse"> +<stop stop-color="#0669BC"/> +<stop offset="1" stop-color="#243A5F"/> +</linearGradient> +<linearGradient id="paint1_linear_125_2875" x1="11.5" y1="4" x2="15.4941" y2="23.743" gradientUnits="userSpaceOnUse"> +<stop stop-color="#0078D4"/> +<stop offset="1" stop-color="#114A8B"/> +</linearGradient> +<linearGradient id="paint2_linear_125_2875" x1="11.5" y1="1" x2="11.8823" y2="5.29249" gradientUnits="userSpaceOnUse"> +<stop stop-color="#28AFEA"/> +<stop offset="1" stop-color="#0078D4"/> +</linearGradient> +<linearGradient id="paint3_linear_125_2875" x1="11.5" y1="1" x2="11.7158" y2="4.23049" gradientUnits="userSpaceOnUse"> +<stop stop-color="#30DAFF"/> +<stop offset="1" stop-color="#0094D4"/> +</linearGradient> +<linearGradient id="paint4_linear_125_2875" x1="11" y1="5.23303e-08" x2="11" y2="2" gradientUnits="userSpaceOnUse"> +<stop stop-color="#22BCFF"/> +<stop offset="1" stop-color="#0088F0"/> +</linearGradient> +<linearGradient id="paint5_linear_125_2875" x1="11" y1="5.23303e-08" x2="11" y2="2" gradientUnits="userSpaceOnUse"> +<stop stop-color="#28AFEA"/> +<stop offset="1" stop-color="#3CCBF4"/> +</linearGradient> +<clipPath id="clip0_125_2875"> +<rect width="24" height="24" fill="white"/> +</clipPath> +<clipPath id="clip1_125_2875"> +<rect width="24" height="24" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/WinGetLogo.svg b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/WinGetLogo.svg new file mode 100644 index 0000000000..0bf3ebbaff --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Assets/WinGetLogo.svg @@ -0,0 +1,97 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_125_2968)"> +<path d="M3 1.5C3 0.671573 3.67157 0 4.5 0H19.5C20.3284 0 21 0.671573 21 1.5V16.5C21 17.3284 20.3284 18 19.5 18H4.5C3.67157 18 3 17.3284 3 16.5V1.5Z" fill="#9C640A"/> +<mask id="mask0_125_2968" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="3" y="0" width="18" height="18"> +<path d="M3 1.5C3 0.671573 3.67157 0 4.5 0H19.5C20.3284 0 21 0.671573 21 1.5V16.5C21 17.3284 20.3284 18 19.5 18H4.5C3.67157 18 3 17.3284 3 16.5V1.5Z" fill="#9C640A"/> +</mask> +<g mask="url(#mask0_125_2968)"> +<g filter="url(#filter0_dd_125_2968)"> +<path d="M1.5 4.5C1.5 3.67157 2.17157 3 3 3H21C21.8284 3 22.5 3.67157 22.5 4.5V19.5C22.5 20.3284 21.8284 21 21 21H3C2.17157 21 1.5 20.3284 1.5 19.5V4.5Z" fill="#BC822A"/> +</g> +</g> +<path d="M1.5 4.5C1.5 3.67157 2.17157 3 3 3H21C21.8284 3 22.5 3.67157 22.5 4.5V19.5C22.5 20.3284 21.8284 21 21 21H3C2.17157 21 1.5 20.3284 1.5 19.5V4.5Z" fill="#BC822A"/> +<mask id="mask1_125_2968" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="3" width="22" height="18"> +<path d="M1.5 4.5C1.5 3.67157 2.17157 3 3 3H21C21.8284 3 22.5 3.67157 22.5 4.5V19.5C22.5 20.3284 21.8284 21 21 21H3C2.17157 21 1.5 20.3284 1.5 19.5V4.5Z" fill="#BC822A"/> +</mask> +<g mask="url(#mask1_125_2968)"> +<g filter="url(#filter1_dd_125_2968)"> +<path d="M0 7.5C0 6.67157 0.671573 6 1.5 6H22.5C23.3284 6 24 6.67157 24 7.5V22.5C24 23.3284 23.3284 24 22.5 24H1.5C0.671573 24 0 23.3284 0 22.5V7.5Z" fill="#D9D9D9"/> +<path d="M0 7.5C0 6.67157 0.671573 6 1.5 6H22.5C23.3284 6 24 6.67157 24 7.5V22.5C24 23.3284 23.3284 24 22.5 24H1.5C0.671573 24 0 23.3284 0 22.5V7.5Z" fill="url(#paint0_linear_125_2968)"/> +<path d="M0 7.5C0 6.67157 0.671573 6 1.5 6H22.5C23.3284 6 24 6.67157 24 7.5V22.5C24 23.3284 23.3284 24 22.5 24H1.5C0.671573 24 0 23.3284 0 22.5V7.5Z" fill="url(#paint1_linear_125_2968)"/> +</g> +</g> +<path d="M0 7.5C0 6.67157 0.671573 6 1.5 6H22.5C23.3284 6 24 6.67157 24 7.5V22.5C24 23.3284 23.3284 24 22.5 24H1.5C0.671573 24 0 23.3284 0 22.5V7.5Z" fill="#D9D9D9"/> +<path d="M0 7.5C0 6.67157 0.671573 6 1.5 6H22.5C23.3284 6 24 6.67157 24 7.5V22.5C24 23.3284 23.3284 24 22.5 24H1.5C0.671573 24 0 23.3284 0 22.5V7.5Z" fill="url(#paint2_linear_125_2968)"/> +<path d="M0 7.5C0 6.67157 0.671573 6 1.5 6H22.5C23.3284 6 24 6.67157 24 7.5V22.5C24 23.3284 23.3284 24 22.5 24H1.5C0.671573 24 0 23.3284 0 22.5V7.5Z" fill="url(#paint3_linear_125_2968)"/> +<g filter="url(#filter2_dd_125_2968)"> +<path d="M12 9C12.8284 9 13.5 9.67157 13.5 10.5V15.8789L14.6895 14.6895C15.2752 14.1037 16.2248 14.1037 16.8105 14.6895C17.3963 15.2752 17.3963 16.2248 16.8105 16.8105L13.0605 20.5605C12.4748 21.1463 11.5252 21.1463 10.9395 20.5605L7.18945 16.8105C6.60367 16.2248 6.60367 15.2752 7.18945 14.6895C7.77524 14.1037 8.72476 14.1037 9.31055 14.6895L10.5 15.8789V10.5C10.5 9.67157 11.1716 9 12 9Z" fill="url(#paint4_linear_125_2968)"/> +</g> +</g> +<defs> +<filter id="filter0_dd_125_2968" x="0.5" y="1.505" width="23" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="-0.495"/> +<feGaussianBlur stdDeviation="0.5"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_125_2968"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="-0.105"/> +<feGaussianBlur stdDeviation="0.05"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_125_2968" result="effect2_dropShadow_125_2968"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_125_2968" result="shape"/> +</filter> +<filter id="filter1_dd_125_2968" x="-1" y="4.505" width="26" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="-0.495"/> +<feGaussianBlur stdDeviation="0.5"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_125_2968"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="-0.105"/> +<feGaussianBlur stdDeviation="0.05"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_125_2968" result="effect2_dropShadow_125_2968"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_125_2968" result="shape"/> +</filter> +<filter id="filter2_dd_125_2968" x="5.75" y="8.5" width="12.5" height="14" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="0.5"/> +<feGaussianBlur stdDeviation="0.5"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_125_2968"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="0.1"/> +<feGaussianBlur stdDeviation="0.05"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_125_2968" result="effect2_dropShadow_125_2968"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_125_2968" result="shape"/> +</filter> +<linearGradient id="paint0_linear_125_2968" x1="0" y1="6" x2="17.28" y2="29.04" gradientUnits="userSpaceOnUse"> +<stop stop-color="#DEB678"/> +<stop offset="1" stop-color="#C59141"/> +</linearGradient> +<linearGradient id="paint1_linear_125_2968" x1="6.75" y1="3.75" x2="14.25" y2="26.25" gradientUnits="userSpaceOnUse"> +<stop stop-color="#DEB678"/> +<stop offset="1" stop-color="#B57F2D"/> +</linearGradient> +<linearGradient id="paint2_linear_125_2968" x1="0" y1="6" x2="17.28" y2="29.04" gradientUnits="userSpaceOnUse"> +<stop stop-color="#DEB678"/> +<stop offset="1" stop-color="#C59141"/> +</linearGradient> +<linearGradient id="paint3_linear_125_2968" x1="6.75" y1="3.75" x2="14.25" y2="26.25" gradientUnits="userSpaceOnUse"> +<stop stop-color="#DEB678"/> +<stop offset="1" stop-color="#B57F2D"/> +</linearGradient> +<linearGradient id="paint4_linear_125_2968" x1="9.13631" y1="7.22729" x2="12.8104" y2="20.0865" gradientUnits="userSpaceOnUse"> +<stop offset="0.270833" stop-color="white"/> +<stop offset="1" stop-color="#F0F0F0"/> +</linearGradient> +<clipPath id="clip0_125_2968"> +<rect width="24" height="24" fill="white"/> +</clipPath> +</defs> +</svg> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.Branding.props b/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.Branding.props index d99688c081..298bcbd787 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.Branding.props +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.Branding.props @@ -24,7 +24,7 @@ <ItemGroup> <!-- Images --> - <Content Include="$(SolutionDir)\src\modules\cmdpal\Microsoft.CmdPal.UI\Assets\$(CmdPalAssetSuffix)\**\*"> + <Content Include=".\Assets\$(CmdPalAssetSuffix)\**\*"> <DeploymentContent>true</DeploymentContent> <Link>Assets\%(RecursiveDir)%(FileName)%(Extension)</Link> </Content> @@ -35,14 +35,10 @@ <!-- In the future, when we actually want to support "preview" and "canary", add a Package-Pre.appxmanifest, etc. --> - <AppxManifest Include="Package.appxmanifest" - Condition="'$(CommandPaletteBranding)'=='Release'" /> - <AppxManifest Include="Package.appxmanifest" - Condition="'$(CommandPaletteBranding)'=='Preview'" /> - <AppxManifest Include="Package.appxmanifest" - Condition="'$(CommandPaletteBranding)'=='Canary'" /> - <AppxManifest Include="Package-Dev.appxmanifest" - Condition="'$(CommandPaletteBranding)'=='' or '$(CommandPaletteBranding)'=='Dev'" /> + <AppxManifest Include="Package.appxmanifest" Condition="'$(CommandPaletteBranding)'=='Release'" /> + <AppxManifest Include="Package.appxmanifest" Condition="'$(CommandPaletteBranding)'=='Preview'" /> + <AppxManifest Include="Package.appxmanifest" Condition="'$(CommandPaletteBranding)'=='Canary'" /> + <AppxManifest Include="Package-Dev.appxmanifest" Condition="'$(CommandPaletteBranding)'=='' or '$(CommandPaletteBranding)'=='Dev'" /> </ItemGroup> </Project> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.pre.props b/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.pre.props index c732a29374..d65b4a2cc2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.pre.props +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/CmdPal.pre.props @@ -7,7 +7,9 @@ </PropertyGroup> <PropertyGroup> - <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> + <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> + <!-- Reset this because the Versioning task might have overwritten it before it knew about OutDir --> + <AppxPackageDir>$(OutputPath)\AppPackages\</AppxPackageDir> </PropertyGroup> </Project> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/AdaptiveCardsConfig.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/AdaptiveCardsConfig.cs index 05cc245fef..a882024be7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/AdaptiveCardsConfig.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/AdaptiveCardsConfig.cs @@ -38,7 +38,7 @@ public sealed class AdaptiveCardsConfig "fontFamily": "'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", "fontSizes": { "small": 12, - "default": 12, + "default": 14, "medium": 14, "large": 20, "extraLarge": 26 @@ -199,7 +199,7 @@ public sealed class AdaptiveCardsConfig "fontFamily": "'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", "fontSizes": { "small": 12, - "default": 12, + "default": 14, "medium": 14, "large": 20, "extraLarge": 26 diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs new file mode 100644 index 0000000000..3d52042751 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/BlurImageControl.cs @@ -0,0 +1,448 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Numerics; +using ManagedCommon; +using Microsoft.Graphics.Canvas.Effects; +using Microsoft.UI; +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +internal sealed partial class BlurImageControl : Control +{ + private const string ImageSourceParameterName = "ImageSource"; + + private const string BrightnessEffectName = "Brightness"; + private const string BrightnessOverlayEffectName = "BrightnessOverlay"; + private const string BlurEffectName = "Blur"; + private const string TintBlendEffectName = "TintBlend"; + private const string TintEffectName = "Tint"; + +#pragma warning disable CA1507 // Use nameof to express symbol names ... some of these refer to effect properties that are separate from the class properties + private static readonly string BrightnessSource1AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source1Amount"); + private static readonly string BrightnessSource2AmountEffectProperty = GetPropertyName(BrightnessEffectName, "Source2Amount"); + private static readonly string BrightnessOverlayColorEffectProperty = GetPropertyName(BrightnessOverlayEffectName, "Color"); + private static readonly string BlurBlurAmountEffectProperty = GetPropertyName(BlurEffectName, "BlurAmount"); + private static readonly string TintColorEffectProperty = GetPropertyName(TintEffectName, "Color"); +#pragma warning restore CA1507 + + private static readonly string[] AnimatableProperties = [ + BrightnessSource1AmountEffectProperty, + BrightnessSource2AmountEffectProperty, + BrightnessOverlayColorEffectProperty, + BlurBlurAmountEffectProperty, + TintColorEffectProperty + ]; + + public static readonly DependencyProperty ImageSourceProperty = + DependencyProperty.Register( + nameof(ImageSource), + typeof(ImageSource), + typeof(BlurImageControl), + new PropertyMetadata(null, OnImageChanged)); + + public static readonly DependencyProperty ImageStretchProperty = + DependencyProperty.Register( + nameof(ImageStretch), + typeof(Stretch), + typeof(BlurImageControl), + new PropertyMetadata(Stretch.UniformToFill, OnImageStretchChanged)); + + public static readonly DependencyProperty ImageOpacityProperty = + DependencyProperty.Register( + nameof(ImageOpacity), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(1.0, OnOpacityChanged)); + + public static readonly DependencyProperty ImageBrightnessProperty = + DependencyProperty.Register( + nameof(ImageBrightness), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(1.0, OnBrightnessChanged)); + + public static readonly DependencyProperty BlurAmountProperty = + DependencyProperty.Register( + nameof(BlurAmount), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(0.0, OnBlurAmountChanged)); + + public static readonly DependencyProperty TintColorProperty = + DependencyProperty.Register( + nameof(TintColor), + typeof(Color), + typeof(BlurImageControl), + new PropertyMetadata(Colors.Transparent, OnVisualPropertyChanged)); + + public static readonly DependencyProperty TintIntensityProperty = + DependencyProperty.Register( + nameof(TintIntensity), + typeof(double), + typeof(BlurImageControl), + new PropertyMetadata(0.0, OnVisualPropertyChanged)); + + private Compositor? _compositor; + private SpriteVisual? _effectVisual; + private CompositionEffectBrush? _effectBrush; + private CompositionSurfaceBrush? _imageBrush; + + public BlurImageControl() + { + this.DefaultStyleKey = typeof(BlurImageControl); + this.Loaded += OnLoaded; + this.SizeChanged += OnSizeChanged; + } + + public ImageSource ImageSource + { + get => (ImageSource)GetValue(ImageSourceProperty); + set => SetValue(ImageSourceProperty, value); + } + + public Stretch ImageStretch + { + get => (Stretch)GetValue(ImageStretchProperty); + set => SetValue(ImageStretchProperty, value); + } + + public double ImageOpacity + { + get => (double)GetValue(ImageOpacityProperty); + set => SetValue(ImageOpacityProperty, value); + } + + public double ImageBrightness + { + get => (double)GetValue(ImageBrightnessProperty); + set => SetValue(ImageBrightnessProperty, Math.Clamp(value, -1, 1)); + } + + public double BlurAmount + { + get => (double)GetValue(BlurAmountProperty); + set => SetValue(BlurAmountProperty, value); + } + + public Color TintColor + { + get => (Color)GetValue(TintColorProperty); + set => SetValue(TintColorProperty, value); + } + + public double TintIntensity + { + get => (double)GetValue(TintIntensityProperty); + set => SetValue(TintIntensityProperty, value); + } + + private static void OnImageStretchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._imageBrush != null) + { + control._imageBrush.Stretch = ConvertStretch((Stretch)e.NewValue); + } + } + + private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._compositor != null) + { + control.UpdateEffect(); + } + } + + private static void OnOpacityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._effectVisual != null) + { + control._effectVisual.Opacity = (float)(double)e.NewValue; + } + } + + private static void OnBlurAmountChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._effectBrush != null) + { + control.UpdateEffect(); + } + } + + private static void OnBrightnessChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is BlurImageControl control && control._effectBrush != null) + { + control.UpdateEffect(); + } + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + InitializeComposition(); + } + + private void OnSizeChanged(object sender, SizeChangedEventArgs e) + { + if (_effectVisual != null) + { + _effectVisual.Size = new Vector2( + (float)Math.Max(1, e.NewSize.Width), + (float)Math.Max(1, e.NewSize.Height)); + } + } + + private static void OnImageChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not BlurImageControl control) + { + return; + } + + control.EnsureEffect(force: true); + control.UpdateEffect(); + } + + private void InitializeComposition() + { + var visual = ElementCompositionPreview.GetElementVisual(this); + _compositor = visual.Compositor; + + _effectVisual = _compositor.CreateSpriteVisual(); + _effectVisual.Size = new Vector2( + (float)Math.Max(1, ActualWidth), + (float)Math.Max(1, ActualHeight)); + _effectVisual.Opacity = (float)ImageOpacity; + + ElementCompositionPreview.SetElementChildVisual(this, _effectVisual); + + UpdateEffect(); + } + + private void EnsureEffect(bool force = false) + { + if (_compositor is null) + { + return; + } + + if (_effectBrush is not null && !force) + { + return; + } + + var imageSource = new CompositionEffectSourceParameter(ImageSourceParameterName); + + // 1) Brightness via ArithmeticCompositeEffect + // We blend between the original image and either black or white, + // depending on whether we want to darken or brighten. BrightnessEffect isn't supported + // in the composition graph. + var brightnessEffect = new ArithmeticCompositeEffect + { + Name = BrightnessEffectName, + Source1 = imageSource, // original image + Source2 = new ColorSourceEffect + { + Name = BrightnessOverlayEffectName, + Color = Colors.Black, // we'll swap black/white via properties + }, + + MultiplyAmount = 0.0f, + Source1Amount = 1.0f, // original + Source2Amount = 0.0f, // overlay + Offset = 0.0f, + }; + + // 2) Blur + var blurEffect = new GaussianBlurEffect + { + Name = BlurEffectName, + BlurAmount = 0.0f, + BorderMode = EffectBorderMode.Hard, + Optimization = EffectOptimization.Balanced, + Source = brightnessEffect, + }; + + // 3) Tint (always in the chain; intensity via alpha) + var tintEffect = new BlendEffect + { + Name = TintBlendEffectName, + Background = blurEffect, + Foreground = new ColorSourceEffect + { + Name = TintEffectName, + Color = Colors.Transparent, + }, + Mode = BlendEffectMode.Multiply, + }; + + var effectFactory = _compositor.CreateEffectFactory(tintEffect, AnimatableProperties); + + _effectBrush?.Dispose(); + _effectBrush = effectFactory.CreateBrush(); + + if (ImageSource is not null) + { + _imageBrush ??= _compositor.CreateSurfaceBrush(); + LoadImageAsync(ImageSource); + _effectBrush.SetSourceParameter(ImageSourceParameterName, _imageBrush); + } + else + { + _effectBrush.SetSourceParameter(ImageSourceParameterName, _compositor.CreateBackdropBrush()); + } + + if (_effectVisual is not null) + { + _effectVisual.Brush = _effectBrush; + } + } + + private void UpdateEffect() + { + if (_compositor is null) + { + return; + } + + EnsureEffect(); + if (_effectBrush is null) + { + return; + } + + var props = _effectBrush.Properties; + + // Brightness + var b = (float)Math.Clamp(ImageBrightness, -1.0, 1.0); + + float source1Amount; + float source2Amount; + Color overlayColor; + + if (b >= 0) + { + // Brighten: blend towards white + overlayColor = Colors.White; + source1Amount = 1.0f - b; // original image contribution + source2Amount = b; // white overlay contribution + } + else + { + // Darken: blend towards black + overlayColor = Colors.Black; + var t = -b; // 0..1 + source1Amount = 1.0f - t; // original image + source2Amount = t; // black overlay + } + + props.InsertScalar(BrightnessSource1AmountEffectProperty, source1Amount); + props.InsertScalar(BrightnessSource2AmountEffectProperty, source2Amount); + props.InsertColor(BrightnessOverlayColorEffectProperty, overlayColor); + + // Blur + props.InsertScalar(BlurBlurAmountEffectProperty, (float)BlurAmount); + + // Tint + var tintColor = TintColor; + var clampedIntensity = (float)Math.Clamp(TintIntensity, 0.0, 1.0); + + var adjustedColor = Color.FromArgb( + (byte)(clampedIntensity * 255), + tintColor.R, + tintColor.G, + tintColor.B); + + props.InsertColor(TintColorEffectProperty, adjustedColor); + } + + private void LoadImageAsync(ImageSource imageSource) + { + try + { + if (imageSource is not Microsoft.UI.Xaml.Media.Imaging.BitmapImage bitmapImage) + { + return; + } + + _imageBrush ??= _compositor?.CreateSurfaceBrush(); + if (_imageBrush is null) + { + return; + } + + Logger.LogDebug($"Starting load of BlurImageControl from '{bitmapImage.UriSource}'"); + var loadedSurface = LoadedImageSurface.StartLoadFromUri(bitmapImage.UriSource); + loadedSurface.LoadCompleted += OnLoadedSurfaceOnLoadCompleted; + SetLoadedSurfaceToBrush(loadedSurface); + _effectBrush?.SetSourceParameter(ImageSourceParameterName, _imageBrush); + } + catch (Exception ex) + { + Logger.LogError("Failed to load image for BlurImageControl: {0}", ex); + } + + return; + + void OnLoadedSurfaceOnLoadCompleted(LoadedImageSurface loadedSurface, LoadedImageSourceLoadCompletedEventArgs e) + { + switch (e.Status) + { + case LoadedImageSourceLoadStatus.Success: + Logger.LogDebug($"BlurImageControl loaded successfully: has _imageBrush? {_imageBrush != null}"); + + try + { + SetLoadedSurfaceToBrush(loadedSurface); + } + catch (Exception ex) + { + Logger.LogError("Failed to set surface in BlurImageControl", ex); + throw; + } + + break; + case LoadedImageSourceLoadStatus.NetworkError: + case LoadedImageSourceLoadStatus.InvalidFormat: + case LoadedImageSourceLoadStatus.Other: + default: + Logger.LogError($"Failed to load image for BlurImageControl: Load status {e.Status}"); + break; + } + } + } + + private void SetLoadedSurfaceToBrush(LoadedImageSurface loadedSurface) + { + var surfaceBrush = _imageBrush; + if (surfaceBrush is null) + { + return; + } + + surfaceBrush.Surface = loadedSurface; + surfaceBrush.Stretch = ConvertStretch(ImageStretch); + surfaceBrush.BitmapInterpolationMode = CompositionBitmapInterpolationMode.Linear; + } + + private static CompositionStretch ConvertStretch(Stretch stretch) + { + return stretch switch + { + Stretch.None => CompositionStretch.None, + Stretch.Fill => CompositionStretch.Fill, + Stretch.Uniform => CompositionStretch.Uniform, + Stretch.UniformToFill => CompositionStretch.UniformToFill, + _ => CompositionStretch.UniformToFill, + }; + } + + private static string GetPropertyName(string effectName, string propertyName) => $"{effectName}.{propertyName}"; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CheckBoxWithDescriptionControl.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CheckBoxWithDescriptionControl.cs new file mode 100644 index 0000000000..df8711fe08 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CheckBoxWithDescriptionControl.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI.Controls; + +public partial class CheckBoxWithDescriptionControl : CheckBox +{ + private CheckBoxWithDescriptionControl _checkBoxSubTextControl; + + public CheckBoxWithDescriptionControl() + { + _checkBoxSubTextControl = (CheckBoxWithDescriptionControl)this; + this.Loaded += CheckBoxSubTextControl_Loaded; + } + + protected override void OnApplyTemplate() + { + Update(); + base.OnApplyTemplate(); + } + + private void Update() + { + if (!string.IsNullOrEmpty(Header)) + { + AutomationProperties.SetName(this, Header); + } + } + + private void CheckBoxSubTextControl_Loaded(object sender, RoutedEventArgs e) + { + StackPanel panel = new StackPanel() { Orientation = Orientation.Vertical }; + panel.Children.Add(new TextBlock() { Text = Header, TextWrapping = TextWrapping.WrapWholeWords }); + + // Add text box only if the description is not empty. Required for additional plugin options. + if (!string.IsNullOrWhiteSpace(Description)) + { + panel.Children.Add(new IsEnabledTextBlock() { Style = (Style)App.Current.Resources["SecondaryIsEnabledTextBlockStyle"], Text = Description }); + } + + _checkBoxSubTextControl.Content = panel; + } + + public static readonly DependencyProperty HeaderProperty = DependencyProperty.Register( + "Header", + typeof(string), + typeof(CheckBoxWithDescriptionControl), + new PropertyMetadata(default(string))); + + public static readonly DependencyProperty DescriptionProperty = DependencyProperty.Register( + "Description", + typeof(string), + typeof(CheckBoxWithDescriptionControl), + new PropertyMetadata(default(string))); + + [Localizable(true)] + public string Header + { + get => (string)GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + [Localizable(true)] + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml new file mode 100644 index 0000000000..105010bbd2 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml @@ -0,0 +1,216 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.CmdPal.UI.Controls.ColorPalette" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:CommunityToolkit.WinUI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.CmdPal.UI.Controls" + xmlns:localConverters="using:Microsoft.CmdPal.UI.Converters" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:toolkitConverters="using:CommunityToolkit.WinUI.Converters" + xmlns:ui="using:CommunityToolkit.WinUI" + mc:Ignorable="d"> + <UserControl.Resources> + + <toolkitConverters:ColorToDisplayNameConverter x:Key="ColorToDisplayNameConverter" /> + <localConverters:ContrastBrushConverter x:Key="ContrastBrushConverter" /> + + <Style x:Key="PaletteGridViewItemStyle" TargetType="GridViewItem"> + <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> + <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> + <Setter Property="Background" Value="Transparent" /> + <Setter Property="Foreground" Value="{ThemeResource SystemControlForegroundBaseHighBrush}" /> + <Setter Property="TabNavigation" Value="Local" /> + <Setter Property="IsHoldingEnabled" Value="True" /> + <Setter Property="HorizontalContentAlignment" Value="Stretch" /> + <Setter Property="VerticalContentAlignment" Value="Stretch" /> + <Setter Property="Margin" Value="0" /> + <Setter Property="Padding" Value="0" /> + <Setter Property="MinWidth" Value="32" /> + <Setter Property="MinHeight" Value="32" /> + <Setter Property="AllowDrop" Value="False" /> + <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> + <Setter Property="FocusVisualMargin" Value="-2" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="GridViewItem"> + <Grid + x:Name="ContentBorder" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + Control.IsTemplateFocusTarget="True" + CornerRadius="{TemplateBinding CornerRadius}" + FocusVisualMargin="{TemplateBinding FocusVisualMargin}" + RenderTransformOrigin="0.5,0.5"> + <Grid.RenderTransform> + <ScaleTransform x:Name="ContentBorderScale" /> + </Grid.RenderTransform> + <ContentPresenter + x:Name="ContentPresenter" + Margin="{TemplateBinding Padding}" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}" + ContentTransitions="{TemplateBinding ContentTransitions}" /> + <!-- + The 'Xg' text simulates the amount of space one line of text will occupy. + In the DataPlaceholder state, the Content is not loaded yet so we + approximate the size of the item using placeholder text. + --> + <TextBlock + x:Name="PlaceholderTextBlock" + Margin="{TemplateBinding Padding}" + AutomationProperties.AccessibilityView="Raw" + Foreground="{x:Null}" + IsHitTestVisible="False" + Text="Xg" + Visibility="Collapsed" /> + <Rectangle + x:Name="PlaceholderRect" + Fill="{ThemeResource ListViewItemPlaceholderBackground}" + Visibility="Collapsed" /> + <Rectangle + x:Name="BorderRectangle" + IsHitTestVisible="False" + Opacity="0" + RadiusX="6" + RadiusY="6" + Stroke="{ThemeResource SystemControlHighlightListAccentLowBrush}" + StrokeThickness="2" /> + <Border + x:Name="MultiSelectSquare" + Width="20" + Height="20" + Margin="0,2,2,0" + HorizontalAlignment="Right" + VerticalAlignment="Top" + Background="{ThemeResource SystemControlBackgroundChromeMediumBrush}" + CornerRadius="6" + Visibility="Collapsed"> + <FontIcon + x:Name="MultiSelectCheck" + FontFamily="{ThemeResource SymbolThemeFontFamily}" + FontSize="16" + Foreground="{ThemeResource SystemControlForegroundBaseMediumHighBrush}" + Glyph="" + Opacity="0" /> + </Border> + <Border + x:Name="MultiArrangeOverlayTextBorder" + Height="20" + MinWidth="20" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Background="{ThemeResource SystemControlBackgroundAccentBrush}" + BorderBrush="{ThemeResource SystemControlBackgroundChromeWhiteBrush}" + BorderThickness="2" + CornerRadius="6" + IsHitTestVisible="False" + Opacity="0"> + <TextBlock + x:Name="MultiArrangeOverlayText" + HorizontalAlignment="Center" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + IsHitTestVisible="False" + Opacity="0" + Style="{ThemeResource CaptionTextBlockStyle}" + Text="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.DragItemsCount}" /> + </Border> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="FocusStates"> + <VisualState x:Name="Focused"> + + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderRectangle" Storyboard.TargetProperty="Visibility"> + <DiscreteObjectKeyFrame KeyTime="0" Value="Collapsed" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + </VisualState> + <VisualState x:Name="Unfocused" /> + + </VisualStateGroup> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Normal"> + + <Storyboard> + <PointerUpThemeAnimation Storyboard.TargetName="ContentPresenter" /> + </Storyboard> + </VisualState> + + <VisualState x:Name="PointerOver"> + + <Storyboard> + <DoubleAnimation + Storyboard.TargetName="BorderRectangle" + Storyboard.TargetProperty="Opacity" + To="1" + Duration="0" /> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="BorderRectangle" Storyboard.TargetProperty="Stroke"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{Binding Converter={StaticResource ContrastBrushConverter}, ConverterParameter={ThemeResource TextControlForeground}}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{Binding Converter={StaticResource ContrastBrushConverter}, ConverterParameter={ThemeResource TextControlForeground}}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentBorder" Storyboard.TargetProperty="FocusVisualSecondaryBrush"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{Binding Converter={StaticResource ContrastBrushConverter}, ConverterParameter={ThemeResource TextControlForeground}}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentBorder" Storyboard.TargetProperty="FocusVisualSecondaryThickness"> + <DiscreteObjectKeyFrame KeyTime="0" Value="2" /> + </ObjectAnimationUsingKeyFrames> + <PointerUpThemeAnimation Storyboard.TargetName="ContentPresenter" /> + + <DoubleAnimation + Storyboard.TargetName="MultiSelectCheck" + Storyboard.TargetProperty="Opacity" + To="1" + Duration="0" /> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="MultiSelectSquare" Storyboard.TargetProperty="Background"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{Binding Converter={StaticResource ContrastBrushConverter}, ConverterParameter={ThemeResource TextControlForeground}}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + </VisualState> + + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> + + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + </UserControl.Resources> + + <Grid HorizontalAlignment="Stretch"> + <GridView + Margin="0" + Padding="0" + IsItemClickEnabled="True" + ItemClick="ListViewBase_OnItemClick" + ItemContainerStyle="{StaticResource PaletteGridViewItemStyle}" + ItemsSource="{x:Bind PaletteColors}" + SelectionMode="None"> + <GridView.ItemsPanel> + <ItemsPanelTemplate> + <controls:UniformGrid ui:FrameworkElementExtensions.AncestorType="local:ColorPalette" Columns="{Binding (ui:FrameworkElementExtensions.Ancestor).CustomPaletteColumnCount, RelativeSource={RelativeSource Self}}" /> + </ItemsPanelTemplate> + </GridView.ItemsPanel> + <GridView.ItemTemplate> + <DataTemplate x:DataType="Color"> + <Border + Margin="2" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + AutomationProperties.Name="{Binding Converter={StaticResource ColorToDisplayNameConverter}}" + CornerRadius="4" + ToolTipService.ToolTip="{Binding Converter={StaticResource ColorToDisplayNameConverter}}"> + <Border.Background> + <SolidColorBrush Color="{Binding}" /> + </Border.Background> + </Border> + </DataTemplate> + </GridView.ItemTemplate> + </GridView> + </Grid> +</UserControl> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs new file mode 100644 index 0000000000..7267e894fa --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPalette.xaml.cs @@ -0,0 +1,71 @@ +// 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.ObjectModel; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ColorPalette : UserControl +{ + public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection<Color>), typeof(ColorPalette), null!)!; + + public static readonly DependencyProperty CustomPaletteColumnCountProperty = DependencyProperty.Register(nameof(CustomPaletteColumnCount), typeof(int), typeof(ColorPalette), new PropertyMetadata(10))!; + + public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPalette), new PropertyMetadata(null!))!; + + public event EventHandler<Color?>? SelectedColorChanged; + + private Color? _selectedColor; + + public Color? SelectedColor + { + get => _selectedColor; + + set + { + if (_selectedColor != value) + { + _selectedColor = value; + if (value is not null) + { + SetValue(SelectedColorProperty, value); + } + else + { + ClearValue(SelectedColorProperty); + } + } + } + } + + public ObservableCollection<Color> PaletteColors + { + get => (ObservableCollection<Color>)GetValue(PaletteColorsProperty)!; + set => SetValue(PaletteColorsProperty, value); + } + + public int CustomPaletteColumnCount + { + get => (int)GetValue(CustomPaletteColumnCountProperty); + set => SetValue(CustomPaletteColumnCountProperty, value); + } + + public ColorPalette() + { + PaletteColors = []; + InitializeComponent(); + } + + private void ListViewBase_OnItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is Color color) + { + SelectedColor = color; + SelectedColorChanged?.Invoke(this, color); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml new file mode 100644 index 0000000000..92a556f7a7 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml @@ -0,0 +1,90 @@ +<UserControl + x:Class="Microsoft.CmdPal.UI.Controls.ColorPickerButton" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.CmdPal.UI.Controls" + xmlns:converters="using:CommunityToolkit.WinUI.Converters" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + d:DesignHeight="300" + d:DesignWidth="400" + mc:Ignorable="d"> + <UserControl.Resources> + <converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> + <converters:BoolToVisibilityConverter + x:Key="BoolToVisibilityInvertedConverter" + FalseValue="Visible" + TrueValue="Collapsed" /> + </UserControl.Resources> + + <StackPanel Orientation="Horizontal" Spacing="8"> + <DropDownButton Padding="{x:Bind ToDropDownPadding(HasSelectedColor), Mode=OneWay}"> + <Grid> + <TextBlock x:Uid="OptionalColorPickerButton_UnsetTextBlock" Visibility="{x:Bind HasSelectedColor, Mode=OneWay, Converter={StaticResource BoolToVisibilityInvertedConverter}}" /> + <Border + x:Name="ColorPreviewBorder" + Width="48" + Height="24" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="{ThemeResource ControlCornerRadius}" + Visibility="{x:Bind HasSelectedColor, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <Border.Background> + <SolidColorBrush Color="{x:Bind SelectedColor, Mode=OneWay}" /> + </Border.Background> + </Border> + </Grid> + <DropDownButton.Flyout> + <Flyout + x:Name="ColorPickerFlyout" + Opened="FlyoutBase_OnOpened" + Placement="Bottom" + ShouldConstrainToRootBounds="False"> + <Flyout.FlyoutPresenterStyle> + <Style BasedOn="{StaticResource DefaultFlyoutPresenterStyle}" TargetType="FlyoutPresenter"> + <Setter Property="MinWidth" Value="660" /> + </Style> + </Flyout.FlyoutPresenterStyle> + <StackPanel + x:Name="FlyoutRoot" + Orientation="Horizontal" + SizeChanged="FlyoutRoot_OnSizeChanged" + Spacing="20"> + <!-- Left column: Preset colors and reset button --> + <StackPanel Margin="2"> + <TextBlock + x:Uid="OptionalColorPickerButton_WindowsColorsSectionHeading" + Margin="0,0,0,12" + Style="{StaticResource BodyTextBlockStyle}" /> + + <controls:ColorPalette + HorizontalAlignment="Left" + VerticalAlignment="Top" + CustomPaletteColumnCount="9" + PaletteColors="{x:Bind PaletteColors}" + SelectedColorChanged="ColorPalette_OnSelectedColorChanged" /> + </StackPanel> + + <!-- Right column: Spectrum --> + <StackPanel> + <TextBlock + x:Uid="OptionalColorPickerButton_CustomColorsSectionHeading" + Margin="0,0,0,12" + Style="{StaticResource BodyTextBlockStyle}" /> + + <ColorPicker + IsAlphaEnabled="{x:Bind IsAlphaEnabled, Mode=OneWay}" + IsAlphaSliderVisible="{x:Bind IsAlphaEnabled, Mode=OneWay}" + IsAlphaTextInputVisible="{x:Bind IsAlphaEnabled, Mode=OneWay}" + IsColorChannelTextInputVisible="True" + IsColorSliderVisible="True" + IsHexInputVisible="True" + IsMoreButtonVisible="True" + Color="{x:Bind SelectedColor, Mode=TwoWay}" /> + </StackPanel> + </StackPanel> + </Flyout> + </DropDownButton.Flyout> + </DropDownButton> + </StackPanel> +</UserControl> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs new file mode 100644 index 0000000000..ff82fffd4e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ColorPickerButton.xaml.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using ManagedCommon; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ColorPickerButton : UserControl +{ + public static readonly DependencyProperty PaletteColorsProperty = DependencyProperty.Register(nameof(PaletteColors), typeof(ObservableCollection<Color>), typeof(ColorPickerButton), new PropertyMetadata(new ObservableCollection<Color>()))!; + + public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPickerButton), new PropertyMetadata(Colors.Black))!; + + public static readonly DependencyProperty IsAlphaEnabledProperty = DependencyProperty.Register(nameof(IsAlphaEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(defaultValue: false))!; + + public static readonly DependencyProperty IsValueEditorEnabledProperty = DependencyProperty.Register(nameof(IsValueEditorEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!; + + public static readonly DependencyProperty HasSelectedColorProperty = DependencyProperty.Register(nameof(HasSelectedColor), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(false))!; + + private Color _selectedColor; + + public Color SelectedColor + { + get + { + return _selectedColor; + } + + set + { + if (_selectedColor != value) + { + _selectedColor = value; + SetValue(SelectedColorProperty, value); + HasSelectedColor = true; + } + } + } + + public bool HasSelectedColor + { + get { return (bool)GetValue(HasSelectedColorProperty); } + set { SetValue(HasSelectedColorProperty, value); } + } + + public bool IsAlphaEnabled + { + get => (bool)GetValue(IsAlphaEnabledProperty); + set => SetValue(IsAlphaEnabledProperty, value); + } + + public bool IsValueEditorEnabled + { + get { return (bool)GetValue(IsValueEditorEnabledProperty); } + set { SetValue(IsValueEditorEnabledProperty, value); } + } + + public ObservableCollection<Color> PaletteColors + { + get { return (ObservableCollection<Color>)GetValue(PaletteColorsProperty); } + set { SetValue(PaletteColorsProperty, value); } + } + + public ColorPickerButton() + { + this.InitializeComponent(); + + IsEnabledChanged -= ColorPickerButton_IsEnabledChanged; + SetEnabledState(); + IsEnabledChanged += ColorPickerButton_IsEnabledChanged; + } + + private void ColorPickerButton_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + SetEnabledState(); + } + + private void SetEnabledState() + { + if (this.IsEnabled) + { + ColorPreviewBorder.Opacity = 1; + } + else + { + ColorPreviewBorder.Opacity = 0.2; + } + } + + private void ColorPalette_OnSelectedColorChanged(object? sender, Color? e) + { + if (e.HasValue) + { + HasSelectedColor = true; + SelectedColor = e.Value; + } + } + + private void FlyoutBase_OnOpened(object? sender, object e) + { + if (sender is not Flyout flyout || (flyout.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter) + { + return; + } + + FlyoutRoot!.UpdateLayout(); + flyoutPresenter.UpdateLayout(); + + // Logger.LogInfo($"FlyoutBase_OnOpened: {flyoutPresenter}, {FlyoutRoot!.ActualWidth}"); + flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth; + flyoutPresenter.MinWidth = 660; + flyoutPresenter.Width = FlyoutRoot!.ActualWidth; + } + + private void FlyoutRoot_OnSizeChanged(object sender, SizeChangedEventArgs e) + { + if ((ColorPickerFlyout!.Content as FrameworkElement)?.Parent is not FlyoutPresenter flyoutPresenter) + { + return; + } + + FlyoutRoot!.UpdateLayout(); + flyoutPresenter.UpdateLayout(); + + flyoutPresenter.MaxWidth = FlyoutRoot!.ActualWidth; + flyoutPresenter.MinWidth = 660; + flyoutPresenter.Width = FlyoutRoot!.ActualWidth; + } + + private Thickness ToDropDownPadding(bool hasColor) + { + return hasColor ? new Thickness(3, 3, 8, 3) : new Thickness(8, 4, 8, 4); + } + + private void ResetButton_Click(object sender, RoutedEventArgs e) + { + HasSelectedColor = false; + ColorPickerFlyout?.Hide(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml index 9f7b4d4071..bc78de7e67 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml @@ -6,12 +6,13 @@ xmlns:animations="using:CommunityToolkit.WinUI.Animations" xmlns:cmdpalUI="using:Microsoft.CmdPal.UI" xmlns:converters="using:CommunityToolkit.WinUI.Converters" + xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels" xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:help="using:Microsoft.CmdPal.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" Background="Transparent" mc:Ignorable="d"> @@ -27,63 +28,60 @@ TrueValue="Collapsed" /> <cmdpalUI:MessageStateToSeverityConverter x:Key="MessageStateToSeverityConverter" /> - <cmdpalUI:KeyChordToStringConverter x:Key="KeyChordToStringConverter" /> <StackLayout x:Name="VerticalStackLayout" Orientation="Vertical" Spacing="4" /> - <!-- Template for actions in the mode actions dropdown button --> - <DataTemplate x:Key="ContextMenuViewModelTemplate" x:DataType="viewmodels:CommandContextItemViewModel"> - <Grid> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="32" /> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - <cpcontrols:IconBox - Width="16" - Height="16" - Margin="4,0,0,0" - HorizontalAlignment="Left" - SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> - <TextBlock - Grid.Column="1" - VerticalAlignment="Center" - Text="{x:Bind Title, Mode=OneWay}" /> - <!--<TextBlock - Grid.Column="2" - Margin="16,0,0,0" - HorizontalAlignment="Right" - VerticalAlignment="Center" - Foreground="{ThemeResource MenuFlyoutItemKeyboardAcceleratorTextForeground}" - Style="{StaticResource CaptionTextBlockStyle}" - Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" />--> - </Grid> - </DataTemplate> + <Style + x:Name="ContextMenuFlyoutStyle" + BasedOn="{StaticResource DefaultFlyoutPresenterStyle}" + TargetType="FlyoutPresenter"> + <Style.Setters> + <Setter Property="Margin" Value="0" /> + <Setter Property="Padding" Value="0" /> + <Setter Property="Background" Value="{ThemeResource DesktopAcrylicTransparentBrush}" /> + <Setter Property="BorderBrush" Value="{ThemeResource DividerStrokeColorDefaultBrush}" /> + </Style.Setters> + </Style> - <animations:ImplicitAnimationSet x:Name="ShowAnimations"> - <animations:OpacityAnimation - From="0" - To="1.0" - Duration="0:0:0.340" /> - <animations:ScaleAnimation - From="0.85" - To="1" - Duration="0:0:0.350" /> - </animations:ImplicitAnimationSet> - <animations:ImplicitAnimationSet x:Name="HideAnimations"> - <animations:OpacityAnimation - From="1.0" - To="0" - Duration="0:0:0.240" /> - <animations:ScaleAnimation - From="1" - To="0.85" - Duration="0:0:0.350" /> - </animations:ImplicitAnimationSet> + <!-- Backdrop requires ShouldConstrainToRootBounds="False" --> + <Flyout + x:Name="ContextMenuFlyout" + FlyoutPresenterStyle="{StaticResource ContextMenuFlyoutStyle}" + Opened="ContextMenuFlyout_Opened" + ShouldConstrainToRootBounds="False" + SystemBackdrop="{ThemeResource AcrylicBackgroundFillColorDefaultBackdrop}"> + <cpcontrols:ContextMenu x:Name="ContextControl" /> + </Flyout> + + <Style x:Key="HotkeyStyle" TargetType="Border"> + <Style.Setters> + <Setter Property="Padding" Value="4" /> + <Setter Property="VerticalAlignment" Value="Center" /> + <Setter Property="Background" Value="{ThemeResource ControlFillColorSecondaryBrush}" /> + <Setter Property="BorderBrush" Value="{ThemeResource DividerStrokeColorDefaultBrush}" /> + <Setter Property="BorderThickness" Value="1" /> + <Setter Property="CornerRadius" Value="6" /> + <Setter Property="MinWidth" Value="20" /> + </Style.Setters> + </Style> + + <Style x:Key="HotkeyTextBlockStyle" TargetType="TextBlock"> + <Setter Property="FontSize" Value="10" /> + <Setter Property="CharacterSpacing" Value="4" /> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" /> + <Setter Property="HorizontalAlignment" Value="Center" /> + <Setter Property="VerticalAlignment" Value="Center" /> + </Style> + + <Style x:Key="HotkeyFontIconStyle" TargetType="FontIcon"> + <Setter Property="FontSize" Value="10" /> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" /> + <Setter Property="HorizontalAlignment" Value="Center" /> + <Setter Property="VerticalAlignment" Value="Center" /> + </Style> </ResourceDictionary> </UserControl.Resources> @@ -92,54 +90,51 @@ Padding="4" ColumnSpacing="8"> <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> <Grid x:Name="IconRoot" - Margin="8,0,0,0" - Tapped="PageIcon_Tapped" + Margin="3,0,-5,0" Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay}"> - <InfoBadge Visibility="{x:Bind CurrentPageViewModel.HasStatusMessage, Mode=OneWay}" Value="{x:Bind CurrentPageViewModel.StatusMessages.Count, Mode=OneWay}" /> - <Grid.ContextFlyout> - <Flyout x:Name="StatusMessagesFlyout" Placement="TopEdgeAlignedLeft"> - <ItemsRepeater - x:Name="MessagesDropdown" - Margin="-8" - ItemsSource="{x:Bind CurrentPageViewModel.StatusMessages, Mode=OneWay}" - Layout="{StaticResource VerticalStackLayout}"> - <ItemsRepeater.ItemTemplate> - <DataTemplate x:DataType="viewmodels:StatusMessageViewModel"> - <StackPanel - Grid.Row="0" - Margin="0" - HorizontalAlignment="Stretch" - VerticalAlignment="Bottom" - Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" - CornerRadius="0"> - <InfoBar - CornerRadius="{ThemeResource ControlCornerRadius}" - IsClosable="False" - IsOpen="True" - Message="{x:Bind Message, Mode=OneWay}" - Severity="{x:Bind State, Mode=OneWay, Converter={StaticResource MessageStateToSeverityConverter}}" /> - </StackPanel> - </DataTemplate> - </ItemsRepeater.ItemTemplate> - </ItemsRepeater> - </Flyout> - </Grid.ContextFlyout> + <Button + x:Name="StatusMessagesButton" + x:Uid="StatusMessagesButton" + Padding="4" + Style="{StaticResource SubtleButtonStyle}" + Visibility="{x:Bind CurrentPageViewModel.HasStatusMessage, Mode=OneWay}"> + <InfoBadge Value="{x:Bind CurrentPageViewModel.StatusMessages.Count, Mode=OneWay}" /> + <Button.Flyout> + <Flyout x:Name="StatusMessagesFlyout" Placement="TopEdgeAlignedLeft"> + <ItemsRepeater + x:Name="MessagesDropdown" + Margin="-8" + ItemsSource="{x:Bind CurrentPageViewModel.StatusMessages, Mode=OneWay}" + Layout="{StaticResource VerticalStackLayout}"> + <ItemsRepeater.ItemTemplate> + <DataTemplate x:DataType="coreViewModels:StatusMessageViewModel"> + <StackPanel HorizontalAlignment="Stretch" VerticalAlignment="Bottom"> + <InfoBar + CornerRadius="{ThemeResource ControlCornerRadius}" + IsClosable="False" + IsOpen="True" + Message="{x:Bind Message, Mode=OneWay}" + Severity="{x:Bind State, Mode=OneWay, Converter={StaticResource MessageStateToSeverityConverter}}" /> + </StackPanel> + </DataTemplate> + </ItemsRepeater.ItemTemplate> + </ItemsRepeater> + </Flyout> + </Button.Flyout> + </Button> </Grid> <Button x:Name="SettingsIconButton" x:Uid="SettingsButton" - animations:Implicit.HideAnimations="{StaticResource HideAnimations}" - animations:Implicit.ShowAnimations="{StaticResource ShowAnimations}" - ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" + Click="SettingsIcon_Clicked" Style="{StaticResource SubtleButtonStyle}" - Tapped="SettingsIcon_Tapped" Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}"> <StackPanel Orientation="Horizontal" Spacing="8"> <FontIcon @@ -147,9 +142,9 @@ FontSize="16" Glyph="" /> <TextBlock + x:Uid="SettingsButtonTextBlock" VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}" - Text="Settings" /> + Style="{StaticResource CaptionTextBlockStyle}" /> </StackPanel> </Button> <TextBlock @@ -157,6 +152,8 @@ VerticalAlignment="Center" Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind CurrentPageViewModel.Title, Mode=OneWay}" + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap" Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay}" /> <StackPanel Grid.Column="2" @@ -167,76 +164,51 @@ <Button x:Name="PrimaryButton" Padding="6,4,4,4" - animations:Implicit.HideAnimations="{StaticResource HideAnimations}" - animations:Implicit.ShowAnimations="{StaticResource ShowAnimations}" - ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" x:Load="{x:Bind IsLoaded, Mode=OneWay}" + AutomationProperties.AutomationId="PrimaryCommandButton" + AutomationProperties.Name="{x:Bind ViewModel.PrimaryCommand.Name, Mode=OneWay}" Background="Transparent" + Click="PrimaryButton_Clicked" Style="{StaticResource SubtleButtonStyle}" - Tapped="PrimaryButton_Tapped" Visibility="{x:Bind ViewModel.HasPrimaryCommand, Mode=OneWay}"> <StackPanel Orientation="Horizontal" Spacing="8"> <TextBlock + MaxWidth="160" VerticalAlignment="Center" + MaxLines="1" Style="{StaticResource CaptionTextBlockStyle}" - Text="{x:Bind ViewModel.PrimaryCommand.Name, Mode=OneWay}" /> - <Border - Padding="4" - Background="{ThemeResource SubtleFillColorSecondaryBrush}" - BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="4"> - <FontIcon - HorizontalAlignment="Left" - VerticalAlignment="Center" - FontSize="10" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Glyph="" /> + Text="{x:Bind ViewModel.PrimaryCommand.Name, Mode=OneWay}" + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap" /> + <Border Style="{StaticResource HotkeyStyle}"> + <FontIcon Glyph="" Style="{StaticResource HotkeyFontIconStyle}" /> </Border> </StackPanel> </Button> <Button x:Name="SecondaryButton" Padding="6,4,4,4" - animations:Implicit.HideAnimations="{StaticResource HideAnimations}" - animations:Implicit.ShowAnimations="{StaticResource ShowAnimations}" - ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" x:Load="{x:Bind IsLoaded, Mode=OneWay}" + AutomationProperties.AutomationId="SecondaryCommandButton" + AutomationProperties.Name="{x:Bind ViewModel.SecondaryCommand.Name, Mode=OneWay}" + Click="SecondaryButton_Clicked" Style="{StaticResource SubtleButtonStyle}" - Tapped="SecondaryButton_Tapped" Visibility="{x:Bind ViewModel.HasSecondaryCommand, Mode=OneWay}"> <StackPanel Orientation="Horizontal" Spacing="8"> <TextBlock + MaxWidth="160" VerticalAlignment="Center" + MaxLines="1" Style="{StaticResource CaptionTextBlockStyle}" - Text="{x:Bind ViewModel.SecondaryCommand.Name, Mode=OneWay}" /> - <StackPanel Orientation="Horizontal" Spacing="2"> - <Border - Padding="4,2,4,2" - VerticalAlignment="Center" - Background="{ThemeResource SubtleFillColorSecondaryBrush}" - BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="4"> - <TextBlock - CharacterSpacing="4" - FontSize="10" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Text="Ctrl" /> + Text="{x:Bind ViewModel.SecondaryCommand.Name, Mode=OneWay}" + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap" /> + <StackPanel Orientation="Horizontal" Spacing="4"> + <Border Padding="4,2,4,2" Style="{StaticResource HotkeyStyle}"> + <TextBlock Style="{StaticResource HotkeyTextBlockStyle}" Text="Ctrl" /> </Border> - <Border - Padding="4" - VerticalAlignment="Center" - Background="{ThemeResource SubtleFillColorSecondaryBrush}" - BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="4"> - <FontIcon - HorizontalAlignment="Left" - VerticalAlignment="Center" - FontSize="10" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Glyph="" /> + <Border Style="{StaticResource HotkeyStyle}"> + <FontIcon Glyph="" Style="{StaticResource HotkeyFontIconStyle}" /> </Border> </StackPanel> </StackPanel> @@ -244,38 +216,28 @@ <Button x:Name="MoreCommandsButton" x:Uid="MoreCommandsButton" - Padding="4" - animations:Implicit.HideAnimations="{StaticResource HideAnimations}" - animations:Implicit.ShowAnimations="{StaticResource ShowAnimations}" - ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" - Content="{ui:FontIcon Glyph=, - FontSize=16}" + Padding="6,4,4,4" + AutomationProperties.AutomationId="MoreContextMenuButton" + Click="MoreCommandsButton_Clicked" Style="{StaticResource SubtleButtonStyle}" - ToolTipService.ToolTip="Ctrl+k" + ToolTipService.ToolTip="Ctrl+K" Visibility="{x:Bind ViewModel.ShouldShowContextMenu, Mode=OneWay}"> - <Button.Flyout> - <Flyout Placement="TopEdgeAlignedRight"> - <ListView - x:Name="CommandsDropdown" - MinWidth="248" - Margin="-16,-12,-16,-12" - IsItemClickEnabled="True" - ItemClick="CommandsDropdown_ItemClick" - ItemTemplate="{StaticResource ContextMenuViewModelTemplate}" - ItemsSource="{x:Bind ViewModel.ContextCommands, Mode=OneWay}" - SelectionMode="None"> - <ListView.ItemContainerStyle> - <Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem"> - <Setter Property="MinHeight" Value="0" /> - <Setter Property="Padding" Value="12,7,12,7" /> - </Style> - </ListView.ItemContainerStyle> - <ListView.ItemContainerTransitions> - <TransitionCollection /> - </ListView.ItemContainerTransitions> - </ListView> - </Flyout> - </Button.Flyout> + <StackPanel Orientation="Horizontal" Spacing="8"> + <TextBlock + VerticalAlignment="Center" + Style="{StaticResource CaptionTextBlockStyle}" + Text="More" + TextTrimming="WordEllipsis" + TextWrapping="NoWrap" /> + <StackPanel Orientation="Horizontal" Spacing="4"> + <Border Padding="4,2,4,2" Style="{StaticResource HotkeyStyle}"> + <TextBlock Style="{StaticResource HotkeyTextBlockStyle}" Text="Ctrl" /> + </Border> + <Border Padding="4,2,4,2" Style="{StaticResource HotkeyStyle}"> + <TextBlock Style="{StaticResource HotkeyTextBlockStyle}" Text="K" /> + </Border> + </StackPanel> + </StackPanel> </Button> </StackPanel> </Grid> 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 382e7ba7ea..b15e8d3c6e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs @@ -3,21 +3,24 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels; -using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.Views; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Input; +using Windows.System; namespace Microsoft.CmdPal.UI.Controls; public sealed partial class CommandBar : UserControl, IRecipient<OpenContextMenuMessage>, + IRecipient<CloseContextMenuMessage>, + IRecipient<TryCommandKeybindingMessage>, ICurrentPageAware { - public CommandBarViewModel ViewModel { get; set; } = new(); + public CommandBarViewModel ViewModel { get; } = new(); public PageViewModel? CurrentPageViewModel { @@ -35,58 +38,117 @@ public sealed partial class CommandBar : UserControl, // RegisterAll isn't AOT compatible WeakReferenceMessenger.Default.Register<OpenContextMenuMessage>(this); + WeakReferenceMessenger.Default.Register<CloseContextMenuMessage>(this); + WeakReferenceMessenger.Default.Register<TryCommandKeybindingMessage>(this); } public void Receive(OpenContextMenuMessage message) + { + if (message.Element is null) + { + // This is invoked from the "More" button on the command bar + if (!ViewModel.ShouldShowContextMenu) + { + return; + } + + _ = DispatcherQueue.TryEnqueue( + () => + { + ContextMenuFlyout.ShowAt( + MoreCommandsButton, + new FlyoutShowOptions() + { + ShowMode = FlyoutShowMode.Standard, + Placement = FlyoutPlacementMode.TopEdgeAlignedRight, + }); + }); + } + else + { + // This is invoked from a specific element + _ = DispatcherQueue.TryEnqueue( + () => + { + ContextMenuFlyout.ShowAt( + message.Element!, + new FlyoutShowOptions() + { + ShowMode = FlyoutShowMode.Standard, + Placement = (FlyoutPlacementMode)message.FlyoutPlacementMode!, + Position = message.Point, + }); + }); + } + } + + public void Receive(CloseContextMenuMessage message) + { + if (ContextMenuFlyout.IsOpen) + { + ContextMenuFlyout.Hide(); + } + } + + public void Receive(TryCommandKeybindingMessage msg) { if (!ViewModel.ShouldShowContextMenu) { return; } - var options = new FlyoutShowOptions + var result = ViewModel?.CheckKeybinding(msg.Ctrl, msg.Alt, msg.Shift, msg.Win, msg.Key); + + if (result == ContextKeybindingResult.Hide) { - ShowMode = FlyoutShowMode.Standard, - }; - MoreCommandsButton.Flyout.ShowAt(MoreCommandsButton, options); - CommandsDropdown.SelectedIndex = 0; - CommandsDropdown.Focus(FocusState.Programmatic); + msg.Handled = true; + } + else if (result == ContextKeybindingResult.KeepOpen) + { + WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom)); + msg.Handled = true; + } + else if (result == ContextKeybindingResult.Unhandled) + { + msg.Handled = false; + } } [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS has a tendency to delete XAML bound methods over-aggressively")] - private void PrimaryButton_Tapped(object sender, TappedRoutedEventArgs e) + private void PrimaryButton_Clicked(object sender, RoutedEventArgs e) { ViewModel.InvokePrimaryCommand(); } [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS has a tendency to delete XAML bound methods over-aggressively")] - private void SecondaryButton_Tapped(object sender, TappedRoutedEventArgs e) + private void SecondaryButton_Clicked(object sender, RoutedEventArgs e) { ViewModel.InvokeSecondaryCommand(); } - private void PageIcon_Tapped(object sender, TappedRoutedEventArgs e) + private void SettingsIcon_Clicked(object sender, RoutedEventArgs e) { - if (CurrentPageViewModel?.StatusMessages.Count > 0) - { - StatusMessagesFlyout.ShowAt( - placementTarget: IconRoot, - showOptions: new FlyoutShowOptions() { ShowMode = FlyoutShowMode.Standard }); - } + WeakReferenceMessenger.Default.Send(new OpenSettingsMessage()); } - private void SettingsIcon_Tapped(object sender, TappedRoutedEventArgs e) + private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e) { - WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(); - e.Handled = true; + WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom)); } - private void CommandsDropdown_ItemClick(object sender, ItemClickEventArgs e) + /// <summary> + /// Sets focus to the "More" button after closing the context menu, + /// keeping keyboard navigation intuitive. + /// </summary> + public void FocusMoreCommandsButton() { - if (e.ClickedItem is CommandContextItemViewModel item) - { - ViewModel?.InvokeItemCommand.Execute(item); - MoreCommandsButton.Flyout.Hide(); - } + MoreCommandsButton?.Focus(FocusState.Programmatic); + } + + private void ContextMenuFlyout_Opened(object sender, object e) + { + // We need to wait until our flyout is opened to try and toss focus + // at its search box. The control isn't in the UI tree before that + ContextControl.FocusSearchBox(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml new file mode 100644 index 0000000000..33fbacb839 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml @@ -0,0 +1,89 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.CmdPal.UI.Controls.CommandPalettePreview" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:h="using:Microsoft.CmdPal.UI.Helpers" + xmlns:local="using:Microsoft.CmdPal.UI.Controls" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d"> + <Border + Width="200" + Height="120" + BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="8" + Translation="0,0,8"> + <Grid> + <!-- Clear style: SolidColorBrush with computed alpha (window backdrop) --> + <Border + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}" + BorderThickness="1" + Visibility="{x:Bind ClearVisibility, Mode=OneWay}"> + <Border.Background> + <SolidColorBrush Color="{x:Bind EffectiveClearColor, Mode=OneWay}" /> + </Border.Background> + </Border> + <!-- Acrylic/Mica style: AcrylicBrush with effective opacity (window backdrop) --> + <Border + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}" + BorderThickness="1" + Visibility="{x:Bind AcrylicVisibility, Mode=OneWay}"> + <Border.Background> + <AcrylicBrush + FallbackColor="{x:Bind PreviewBackgroundColor, Mode=OneWay}" + TintColor="{x:Bind PreviewBackgroundColor, Mode=OneWay}" + TintOpacity="{x:Bind PreviewEffectiveOpacity, Mode=OneWay}" /> + </Border.Background> + </Border> + <!-- Background image (inside window, on top of backdrop) --> + <local:BlurImageControl + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + BlurAmount="{x:Bind PreviewBackgroundImageBlurAmount, Mode=OneWay}" + ImageBrightness="{x:Bind PreviewBackgroundImageBrightness, Mode=OneWay}" + ImageOpacity="{x:Bind PreviewBackgroundImageOpacity, Mode=OneWay}" + ImageSource="{x:Bind PreviewBackgroundImageSource, Mode=OneWay}" + ImageStretch="{x:Bind ToStretch(PreviewBackgroundImageFit), Mode=OneWay}" + IsHitTestVisible="False" + TintColor="{x:Bind PreviewBackgroundImageTint, Mode=OneWay}" + TintIntensity="{x:Bind ToTintIntensity(PreviewBackgroundImageTintIntensity), Mode=OneWay}" + Visibility="{x:Bind ShowBackgroundImage, Mode=OneWay}" /> + + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="100" /> + <RowDefinition Height="20" /> + </Grid.RowDefinitions> + + <Border + x:Name="ContentPreview" + Grid.Row="0" + Background="{ThemeResource LayerOnAcrylicPrimaryBackgroundBrush}"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="20" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + + <Border BorderBrush="{ThemeResource CmdPal.TopBarBorderBrush}" BorderThickness="0,0,0,1" /> + </Grid> + + </Border> + + <Border + x:Name="CommandBarPreview" + Grid.Row="1" + Background="{ThemeResource LayerOnAcrylicSecondaryBackgroundBrush}" + BorderBrush="{ThemeResource CmdPal.CommandBarBorderBrush}" + BorderThickness="0,1,0,0" /> + </Grid> + + </Grid> + </Border> +</UserControl> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs new file mode 100644 index 0000000000..46760aa4cc --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandPalettePreview.xaml.cs @@ -0,0 +1,201 @@ +// 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.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class CommandPalettePreview : UserControl +{ + public static readonly DependencyProperty PreviewBackgroundColorProperty = DependencyProperty.Register(nameof(PreviewBackgroundColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color), OnBackdropPropertyChanged)); + + public static readonly DependencyProperty PreviewBackgroundImageSourceProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageSource), typeof(ImageSource), typeof(CommandPalettePreview), new PropertyMetadata(null, OnBackgroundImageSourceChanged)); + + public static readonly DependencyProperty PreviewBackgroundImageOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(1.0)); + + public static readonly DependencyProperty PreviewBackgroundImageFitProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageFit), typeof(BackgroundImageFit), typeof(CommandPalettePreview), new PropertyMetadata(default(BackgroundImageFit))); + + public static readonly DependencyProperty PreviewBackgroundImageBrightnessProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBrightness), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d)); + + public static readonly DependencyProperty PreviewBackgroundImageBlurAmountProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageBlurAmount), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d)); + + public static readonly DependencyProperty PreviewBackgroundImageTintProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTint), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color))); + + public static readonly DependencyProperty PreviewBackgroundImageTintIntensityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTintIntensity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0)); + + public static readonly DependencyProperty ShowBackgroundImageProperty = DependencyProperty.Register(nameof(ShowBackgroundImage), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed, OnVisibilityPropertyChanged)); + + public static readonly DependencyProperty PreviewBackdropStyleProperty = DependencyProperty.Register(nameof(PreviewBackdropStyle), typeof(BackdropStyle?), typeof(CommandPalettePreview), new PropertyMetadata(null, OnVisibilityPropertyChanged)); + + public static readonly DependencyProperty PreviewEffectiveOpacityProperty = DependencyProperty.Register(nameof(PreviewEffectiveOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(1.0, OnBackdropPropertyChanged)); + + // Computed read-only dependency properties + public static readonly DependencyProperty EffectiveClearColorProperty = DependencyProperty.Register(nameof(EffectiveClearColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color))); + + public static readonly DependencyProperty AcrylicVisibilityProperty = DependencyProperty.Register(nameof(AcrylicVisibility), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Visible)); + + public static readonly DependencyProperty ClearVisibilityProperty = DependencyProperty.Register(nameof(ClearVisibility), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed)); + + public BackgroundImageFit PreviewBackgroundImageFit + { + get { return (BackgroundImageFit)GetValue(PreviewBackgroundImageFitProperty); } + set { SetValue(PreviewBackgroundImageFitProperty, value); } + } + + public Color PreviewBackgroundColor + { + get { return (Color)GetValue(PreviewBackgroundColorProperty); } + set { SetValue(PreviewBackgroundColorProperty, value); } + } + + public ImageSource PreviewBackgroundImageSource + { + get { return (ImageSource)GetValue(PreviewBackgroundImageSourceProperty); } + set { SetValue(PreviewBackgroundImageSourceProperty, value); } + } + + public double PreviewBackgroundImageOpacity + { + get => (double)GetValue(PreviewBackgroundImageOpacityProperty); + set => SetValue(PreviewBackgroundImageOpacityProperty, value); + } + + public double PreviewBackgroundImageBrightness + { + get => (double)GetValue(PreviewBackgroundImageBrightnessProperty); + set => SetValue(PreviewBackgroundImageBrightnessProperty, value); + } + + public double PreviewBackgroundImageBlurAmount + { + get => (double)GetValue(PreviewBackgroundImageBlurAmountProperty); + set => SetValue(PreviewBackgroundImageBlurAmountProperty, value); + } + + public Color PreviewBackgroundImageTint + { + get => (Color)GetValue(PreviewBackgroundImageTintProperty); + set => SetValue(PreviewBackgroundImageTintProperty, value); + } + + public int PreviewBackgroundImageTintIntensity + { + get => (int)GetValue(PreviewBackgroundImageTintIntensityProperty); + set => SetValue(PreviewBackgroundImageTintIntensityProperty, value); + } + + public Visibility ShowBackgroundImage + { + get => (Visibility)GetValue(ShowBackgroundImageProperty); + set => SetValue(ShowBackgroundImageProperty, value); + } + + public BackdropStyle? PreviewBackdropStyle + { + get => (BackdropStyle?)GetValue(PreviewBackdropStyleProperty); + set => SetValue(PreviewBackdropStyleProperty, value); + } + + /// <summary> + /// Gets or sets the effective opacity for the backdrop, pre-computed by the theme provider. + /// For Acrylic style: used directly as TintOpacity. + /// For Clear style: used to compute the alpha channel of the solid color. + /// </summary> + public double PreviewEffectiveOpacity + { + get => (double)GetValue(PreviewEffectiveOpacityProperty); + set => SetValue(PreviewEffectiveOpacityProperty, value); + } + + // Computed read-only properties + public Color EffectiveClearColor + { + get => (Color)GetValue(EffectiveClearColorProperty); + private set => SetValue(EffectiveClearColorProperty, value); + } + + public Visibility AcrylicVisibility + { + get => (Visibility)GetValue(AcrylicVisibilityProperty); + private set => SetValue(AcrylicVisibilityProperty, value); + } + + public Visibility ClearVisibility + { + get => (Visibility)GetValue(ClearVisibilityProperty); + private set => SetValue(ClearVisibilityProperty, value); + } + + public CommandPalettePreview() + { + InitializeComponent(); + } + + private static void OnBackgroundImageSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not CommandPalettePreview preview) + { + return; + } + + preview.ShowBackgroundImage = e.NewValue is ImageSource ? Visibility.Visible : Visibility.Collapsed; + } + + private static void OnBackdropPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not CommandPalettePreview preview) + { + return; + } + + preview.UpdateComputedClearColor(); + } + + private static void OnVisibilityPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not CommandPalettePreview preview) + { + return; + } + + preview.UpdateComputedVisibilityProperties(); + preview.UpdateComputedClearColor(); + } + + private void UpdateComputedClearColor() + { + EffectiveClearColor = Color.FromArgb( + (byte)(PreviewEffectiveOpacity * 255), + PreviewBackgroundColor.R, + PreviewBackgroundColor.G, + PreviewBackgroundColor.B); + } + + private void UpdateComputedVisibilityProperties() + { + var config = BackdropStyles.Get(PreviewBackdropStyle ?? BackdropStyle.Acrylic); + + // Show backdrop effect based on style (on top of any background image) + AcrylicVisibility = config.PreviewBrush == PreviewBrushKind.Acrylic + ? Visibility.Visible : Visibility.Collapsed; + ClearVisibility = config.PreviewBrush == PreviewBrushKind.Solid + ? Visibility.Visible : Visibility.Collapsed; + } + + private double ToTintIntensity(int value) => value / 100.0; + + private Stretch ToStretch(BackgroundImageFit fit) + { + return fit switch + { + BackgroundImageFit.Fill => Stretch.Fill, + BackgroundImageFit.UniformToFill => Stretch.UniformToFill, + _ => Stretch.None, + }; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml index 48784a997f..d765237639 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml @@ -8,7 +8,7 @@ xmlns:local="using:Microsoft.CmdPal.UI.Controls" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" Background="Transparent" mc:Ignorable="d"> @@ -19,6 +19,5 @@ </Style> </ResourceDictionary> </UserControl.Resources> - <Grid x:Name="ContentGrid" /> </UserControl> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml.cs index 68209d750a..3301326883 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml.cs @@ -5,7 +5,9 @@ using AdaptiveCards.ObjectModel.WinUI3; using AdaptiveCards.Rendering.WinUI3; using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; namespace Microsoft.CmdPal.UI.Controls; @@ -16,7 +18,7 @@ public sealed partial class ContentFormControl : UserControl // LOAD-BEARING: if you don't hang onto a reference to the RenderedAdaptiveCard // then the GC might clean it up sometime, even while the card is in the UI - // tree. If this gets GC'd, then it'll revoke our Action handler, and the + // tree. If this gets GC'ed, then it'll revoke our Action handler, and the // form will do seemingly nothing. private RenderedAdaptiveCard? _renderedCard; @@ -42,7 +44,7 @@ public sealed partial class ContentFormControl : UserControl // 5% BODGY: if we set this multiple times over the lifetime of the app, // then the second call will explode, because "CardOverrideStyles is already the child of another element". // SO only set this once. - if (_renderer.OverrideStyles == null) + if (_renderer.OverrideStyles is null) { _renderer.OverrideStyles = CardOverrideStyles; } @@ -53,19 +55,19 @@ public sealed partial class ContentFormControl : UserControl private void AttachViewModel(ContentFormViewModel? vm) { - if (_viewModel != null) + if (_viewModel is not null) { _viewModel.PropertyChanged -= ViewModel_PropertyChanged; } _viewModel = vm; - if (_viewModel != null) + if (_viewModel is not null) { _viewModel.PropertyChanged += ViewModel_PropertyChanged; var c = _viewModel.Card; - if (c != null) + if (c is not null) { DisplayCard(c); } @@ -74,7 +76,7 @@ public sealed partial class ContentFormControl : UserControl private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { - if (ViewModel == null) + if (ViewModel is null) { return; } @@ -82,7 +84,7 @@ public sealed partial class ContentFormControl : UserControl if (e.PropertyName == nameof(ViewModel.Card)) { var c = ViewModel.Card; - if (c != null) + if (c is not null) { DisplayCard(c); } @@ -93,14 +95,68 @@ public sealed partial class ContentFormControl : UserControl { _renderedCard = _renderer.RenderAdaptiveCard(result.AdaptiveCard); ContentGrid.Children.Clear(); - if (_renderedCard.FrameworkElement != null) + if (_renderedCard.FrameworkElement is not null) { ContentGrid.Children.Add(_renderedCard.FrameworkElement); + + // Use the Loaded event to ensure we focus after the card is in the visual tree + _renderedCard.FrameworkElement.Loaded += OnFrameworkElementLoaded; } _renderedCard.Action += Rendered_Action; } + private void OnFrameworkElementLoaded(object sender, RoutedEventArgs e) + { + // Unhook the event handler to avoid multiple registrations + if (sender is FrameworkElement element) + { + element.Loaded -= OnFrameworkElementLoaded; + + if (!ViewModel?.OnlyControlOnPage ?? true) + { + return; + } + + // Focus on the first focusable element asynchronously to ensure the visual tree is fully built + element.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => + { + var focusableElement = FindFirstFocusableElement(element); + focusableElement?.Focus(FocusState.Programmatic); + }); + } + } + + private Control? FindFirstFocusableElement(DependencyObject parent) + { + var childCount = VisualTreeHelper.GetChildrenCount(parent); + + // Process children first (depth-first search) + for (var i = 0; i < childCount; i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + + // If the child is a focusable control like TextBox, ComboBox, etc. + if (child is Control control && + control.IsEnabled && + control.IsTabStop && + control.Visibility == Visibility.Visible && + control.AllowFocusOnInteraction) + { + return control; + } + + // Recursively check children + var result = FindFirstFocusableElement(child); + if (result is not null) + { + return result; + } + } + + return null; + } + private void Rendered_Action(RenderedAdaptiveCard sender, AdaptiveActionEventArgs args) => ViewModel?.HandleSubmit(args.Action, args.Inputs.AsJson()); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentIcon.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentIcon.cs index 1e12b12ebd..211d28b410 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentIcon.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentIcon.cs @@ -2,9 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Runtime.InteropServices; +using System.Diagnostics; using CommunityToolkit.WinUI; -using ManagedCommon; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -37,14 +36,18 @@ public partial class ContentIcon : FontIcon { if (this.FindDescendants().OfType<Grid>().FirstOrDefault() is Grid grid && Content is not null) { - try + if (grid.Children.Contains(Content)) { - grid.Children.Add(Content); + return; } - catch (COMException ex) + + if (Content is FrameworkElement element && element.Parent is not null) { - Logger.LogError(ex.ToString()); + Debug.Assert(false, $"IconBoxElement Content is already parented to {element.Parent.GetType().Name}"); + return; } + + grid.Children.Add(Content); } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml new file mode 100644 index 0000000000..4d72e91b6a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml @@ -0,0 +1,193 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.CmdPal.UI.Controls.ContextMenu" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:animations="using:CommunityToolkit.WinUI.Animations" + xmlns:cmdpalUI="using:Microsoft.CmdPal.UI" + xmlns:converters="using:CommunityToolkit.WinUI.Converters" + xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels" + xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:help="using:Microsoft.CmdPal.UI.Helpers" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" + xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" + PreviewKeyDown="UserControl_PreviewKeyDown" + mc:Ignorable="d"> + + <UserControl.Resources> + <ResourceDictionary> + <cmdpalUI:KeyChordToStringConverter x:Key="KeyChordToStringConverter" /> + <converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> + <Thickness x:Key="DefaultContextMenuItemPadding">12,8,12,8</Thickness> + <cmdpalUI:ContextItemTemplateSelector + x:Key="ContextItemTemplateSelector" + Critical="{StaticResource CriticalContextMenuViewModelTemplate}" + Default="{StaticResource DefaultContextMenuViewModelTemplate}" + Separator="{StaticResource SeparatorContextMenuViewModelTemplate}" /> + + <!-- Template for context items in the context item menu --> + <DataTemplate x:Key="DefaultContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel"> + <Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="32" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <cpcontrols:IconBox + Width="16" + Height="16" + Margin="4,0,0,0" + HorizontalAlignment="Left" + SourceKey="{x:Bind Icon, Mode=OneWay}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + <TextBlock + x:Name="TitleTextBlock" + Grid.Column="1" + MaxWidth="200" + HorizontalAlignment="Left" + VerticalAlignment="Center" + MaxLines="1" + Text="{x:Bind Title, Mode=OneWay}" + TextTrimming="WordEllipsis" + TextWrapping="NoWrap"> + <ToolTipService.ToolTip> + <ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> + </ToolTipService.ToolTip> + </TextBlock> + <TextBlock + Grid.Column="2" + Margin="16,0,0,0" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Foreground="{ThemeResource MenuFlyoutItemKeyboardAcceleratorTextForeground}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" /> + </Grid> + </DataTemplate> + + <!-- Template for context items flagged as critical --> + <DataTemplate x:Key="CriticalContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel"> + <Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="32" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <cpcontrols:IconBox + Width="16" + Height="16" + Margin="4,0,0,0" + HorizontalAlignment="Left" + Foreground="{ThemeResource SystemFillColorCriticalBrush}" + SourceKey="{x:Bind Icon, Mode=OneWay}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + <TextBlock + x:Name="TitleTextBlock" + Grid.Column="1" + MaxWidth="200" + HorizontalAlignment="Left" + VerticalAlignment="Center" + MaxLines="1" + Style="{StaticResource ContextItemTitleTextBlockCriticalStyle}" + Text="{x:Bind Title, Mode=OneWay}" + TextTrimming="WordEllipsis" + TextWrapping="NoWrap"> + <ToolTipService.ToolTip> + <ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> + </ToolTipService.ToolTip> + </TextBlock> + <TextBlock + Grid.Column="2" + Margin="16,0,0,0" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Style="{StaticResource ContextItemCaptionTextBlockCriticalStyle}" + Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" /> + </Grid> + </DataTemplate> + + <!-- Template for context item separators --> + <DataTemplate x:Key="SeparatorContextMenuViewModelTemplate" x:DataType="coreViewModels:SeparatorViewModel"> + <Rectangle + Height="1" + Margin="0,2,0,2" + Fill="{ThemeResource MenuFlyoutSeparatorBackground}" /> + </DataTemplate> + </ResourceDictionary> + </UserControl.Resources> + + <Grid x:Name="ContextMenuGrid"> + <Grid.RowDefinitions> + <RowDefinition /> + <RowDefinition /> + </Grid.RowDefinitions> + <ListView + x:Name="CommandsDropdown" + MinWidth="248" + Margin="0,4,0,2" + IsItemClickEnabled="True" + ItemClick="CommandsDropdown_ItemClick" + ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}" + ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}" + PreviewKeyDown="CommandsDropdown_PreviewKeyDown" + SelectionMode="Single"> + <ListView.Resources> + <x:Boolean x:Key="ListViewItemSelectionIndicatorVisualEnabled">False</x:Boolean> + </ListView.Resources> + <ListView.ItemContainerStyle> + <Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem"> + <Setter Property="MinHeight" Value="0" /> + <Setter Property="Padding" Value="0" /> + </Style> + </ListView.ItemContainerStyle> + <ListView.ItemContainerTransitions> + <TransitionCollection /> + </ListView.ItemContainerTransitions> + </ListView> + <Border BorderBrush="{ThemeResource MenuFlyoutSeparatorBackground}" BorderThickness="0,0,0,1" /> + <TextBox + x:Name="ContextFilterBox" + x:Uid="ContextFilterBox" + Margin="0" + Padding="10,7,6,8" + Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}" + BorderThickness="0,0,0,2" + CornerRadius="8, 8, 0, 0" + IsTextScaleFactorEnabled="True" + KeyDown="ContextFilterBox_KeyDown" + PreviewKeyDown="ContextFilterBox_PreviewKeyDown" + Style="{StaticResource SearchTextBoxStyle}" + TextChanged="ContextFilterBox_TextChanged" /> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="ContextMenuOrder"> + <VisualState x:Name="FilterOnTop"> + <VisualState.StateTriggers> + <ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="True" /> + </VisualState.StateTriggers> + <VisualState.Setters> + <Setter Target="CommandsDropdown.(Grid.Row)" Value="1" /> + <Setter Target="ContextFilterBox.(Grid.Row)" Value="0" /> + <Setter Target="CommandsDropdown.Margin" Value="0, 3, 0, 4" /> + <Setter Target="ContextFilterBox.CornerRadius" Value="8, 8, 0, 0" /> + <Setter Target="ContextFilterBox.Margin" Value="0,0,0,-1" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="FilterOnBottom"> + <VisualState.StateTriggers> + <ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="False" /> + </VisualState.StateTriggers> + <VisualState.Setters> + <Setter Target="CommandsDropdown.(Grid.Row)" Value="0" /> + <Setter Target="ContextFilterBox.(Grid.Row)" Value="1" /> + <Setter Target="CommandsDropdown.Margin" Value="0, 4, 0, 4" /> + <Setter Target="ContextFilterBox.CornerRadius" Value="0, 0, 8, 8" /> + <Setter Target="ContextFilterBox.Margin" Value="0,0,0,-2" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> +</UserControl> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs new file mode 100644 index 0000000000..58f7c6318f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContextMenu.xaml.cs @@ -0,0 +1,312 @@ +// 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 CommunityToolkit.Mvvm.Messaging; +using CommunityToolkit.WinUI; +using Microsoft.CmdPal.Core.Common.Text; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.UI.Messages; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; +using Windows.UI.Core; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ContextMenu : UserControl, + IRecipient<OpenContextMenuMessage>, + IRecipient<UpdateCommandBarMessage>, + IRecipient<TryCommandKeybindingMessage> +{ + public ContextMenuViewModel ViewModel { get; } + + public ContextMenu() + { + this.InitializeComponent(); + + ViewModel = new ContextMenuViewModel(App.Current.Services.GetRequiredService<IFuzzyMatcherProvider>()); + ViewModel.PropertyChanged += ViewModel_PropertyChanged; + + // RegisterAll isn't AOT compatible + WeakReferenceMessenger.Default.Register<OpenContextMenuMessage>(this); + WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this); + WeakReferenceMessenger.Default.Register<TryCommandKeybindingMessage>(this); + } + + public void Receive(OpenContextMenuMessage message) + { + ViewModel.FilterOnTop = message.ContextMenuFilterLocation == ContextMenuFilterLocation.Top; + ViewModel.ResetContextMenu(); + + UpdateUiForStackChange(); + } + + public void Receive(UpdateCommandBarMessage message) + { + UpdateUiForStackChange(); + } + + public void Receive(TryCommandKeybindingMessage msg) + { + var result = ViewModel?.CheckKeybinding(msg.Ctrl, msg.Alt, msg.Shift, msg.Win, msg.Key); + + if (result == ContextKeybindingResult.Hide) + { + msg.Handled = true; + WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>(); + UpdateUiForStackChange(); + } + else if (result == ContextKeybindingResult.KeepOpen) + { + UpdateUiForStackChange(); + msg.Handled = true; + } + else if (result == ContextKeybindingResult.Unhandled) + { + msg.Handled = false; + } + } + + private void CommandsDropdown_ItemClick(object sender, ItemClickEventArgs e) + { + if (e.ClickedItem is CommandContextItemViewModel item) + { + if (InvokeCommand(item) == ContextKeybindingResult.Hide) + { + WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>(); + } + + UpdateUiForStackChange(); + } + } + + private void CommandsDropdown_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Handled) + { + return; + } + + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); + var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); + + var result = ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + + if (result == ContextKeybindingResult.Hide) + { + e.Handled = true; + WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>(); + UpdateUiForStackChange(); + } + else if (result == ContextKeybindingResult.KeepOpen) + { + e.Handled = true; + } + else if (result == ContextKeybindingResult.Unhandled) + { + e.Handled = false; + } + } + + /// <summary> + /// Handles Escape to close the context menu and return focus to the "More" button. + /// </summary> + private void UserControl_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Escape) + { + // Close the context menu (if not already handled) + WeakReferenceMessenger.Default.Send(new CloseContextMenuMessage()); + + // Find the parent CommandBar and set focus to MoreCommandsButton + var parent = this.FindParent<CommandBar>(); + parent?.FocusMoreCommandsButton(); + + e.Handled = true; + } + } + + private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + var prop = e.PropertyName; + + if (prop == nameof(ContextMenuViewModel.FilteredItems)) + { + UpdateUiForStackChange(); + } + } + + private void ContextFilterBox_TextChanged(object sender, TextChangedEventArgs e) + { + ViewModel?.SetSearchText(ContextFilterBox.Text); + + if (CommandsDropdown.SelectedIndex == -1) + { + CommandsDropdown.SelectedIndex = 0; + } + } + + private void ContextFilterBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); + var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); + + if (e.Key == VirtualKey.Enter) + { + if (CommandsDropdown.SelectedItem is CommandContextItemViewModel item) + { + if (InvokeCommand(item) == ContextKeybindingResult.Hide) + { + WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>(); + } + + UpdateUiForStackChange(); + + e.Handled = true; + } + } + else if (e.Key == VirtualKey.Escape || + (e.Key == VirtualKey.Left && altPressed)) + { + if (ViewModel.CanPopContextStack()) + { + ViewModel.PopContextStack(); + UpdateUiForStackChange(); + } + else + { + WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>(); + WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>(); + UpdateUiForStackChange(); + } + + e.Handled = true; + } + } + + private void ContextFilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Up) + { + NavigateUp(); + + e.Handled = true; + } + else if (e.Key == VirtualKey.Down) + { + NavigateDown(); + + e.Handled = true; + } + + CommandsDropdown_PreviewKeyDown(sender, e); + } + + private void NavigateUp() + { + var newIndex = CommandsDropdown.SelectedIndex; + + if (CommandsDropdown.SelectedIndex > 0) + { + newIndex--; + + while ( + newIndex >= 0 && + IsSeparator(CommandsDropdown.Items[newIndex]) && + newIndex != CommandsDropdown.SelectedIndex) + { + newIndex--; + } + + if (newIndex < 0) + { + newIndex = CommandsDropdown.Items.Count - 1; + + while ( + newIndex >= 0 && + IsSeparator(CommandsDropdown.Items[newIndex]) && + newIndex != CommandsDropdown.SelectedIndex) + { + newIndex--; + } + } + } + else + { + newIndex = CommandsDropdown.Items.Count - 1; + } + + CommandsDropdown.SelectedIndex = newIndex; + } + + private void NavigateDown() + { + var newIndex = CommandsDropdown.SelectedIndex; + + if (CommandsDropdown.SelectedIndex == CommandsDropdown.Items.Count - 1) + { + newIndex = 0; + } + else + { + newIndex++; + + while ( + newIndex < CommandsDropdown.Items.Count && + IsSeparator(CommandsDropdown.Items[newIndex]) && + newIndex != CommandsDropdown.SelectedIndex) + { + newIndex++; + } + + if (newIndex >= CommandsDropdown.Items.Count) + { + newIndex = 0; + + while ( + newIndex < CommandsDropdown.Items.Count && + IsSeparator(CommandsDropdown.Items[newIndex]) && + newIndex != CommandsDropdown.SelectedIndex) + { + newIndex++; + } + } + } + + CommandsDropdown.SelectedIndex = newIndex; + } + + private bool IsSeparator(object item) + { + return item is SeparatorViewModel; + } + + private void UpdateUiForStackChange() + { + ContextFilterBox.Text = string.Empty; + ViewModel?.SetSearchText(string.Empty); + CommandsDropdown.SelectedIndex = 0; + } + + /// <summary> + /// Manually focuses our search box. This needs to be called after we're actually + /// In the UI tree - if we're in a Flyout, that's not until Opened() + /// </summary> + internal void FocusSearchBox() + { + ContextFilterBox.Focus(FocusState.Programmatic); + } + + private ContextKeybindingResult InvokeCommand(CommandItemViewModel command) => ViewModel.InvokeCommand(command); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml new file mode 100644 index 0000000000..e63e412ccf --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml @@ -0,0 +1,264 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.CmdPal.UI.Controls.DevRibbon" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:CommunityToolkit.WinUI.Controls" + xmlns:converters="using:CommunityToolkit.WinUI.Converters" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" + mc:Ignorable="d"> + + <UserControl.Resources> + <DataTemplate x:Key="LogEntryTemplate" x:DataType="viewModels:LogEntryViewModel"> + <controls:SettingsExpander Description="{x:Bind Description}" Header="{x:Bind Header}"> + <controls:SettingsExpander.HeaderIcon> + <FontIcon Glyph="{x:Bind SeverityGlyph}" /> + </controls:SettingsExpander.HeaderIcon> + <controls:SettingsExpander.Items> + <controls:SettingsCard HorizontalContentAlignment="Stretch" ContentAlignment="Vertical"> + <ScrollViewer + MaxWidth="1160" + HorizontalScrollMode="Auto" + VerticalScrollMode="Auto"> + <TextBlock + FontFamily="Consolas" + FontSize="11" + Foreground="{ThemeResource SystemControlPageTextBaseMediumBrush}" + IsTextSelectionEnabled="True" + Text="{x:Bind Details}" + TextWrapping="NoWrap" /> + </ScrollViewer> + </controls:SettingsCard> + </controls:SettingsExpander.Items> + </controls:SettingsExpander> + </DataTemplate> + + <converters:BoolToVisibilityConverter + x:Key="InvertedBoolToVisibilityConverter" + FalseValue="Visible" + TrueValue="Collapsed" /> + + </UserControl.Resources> + + <Grid> + <Border + x:Name="RootBorder" + Height="26" + HorizontalAlignment="Right" + VerticalAlignment="Top" + Background="{ThemeResource SettingsCardBackground}" + BorderBrush="{ThemeResource SurfaceStrokeColorFlyoutBrush}" + BorderThickness="1,0,1,1" + CornerRadius="0,0,8,8" + Opacity="0.3"> + <Button + Padding="0" + CornerRadius="0,0,8,8" + FontSize="11" + PointerEntered="DevRibbonButton_PointerEntered" + PointerExited="DevRibbonButton_PointerExited"> + <StackPanel Orientation="Horizontal"> + <StackPanel + Padding="8,4" + VerticalAlignment="Center" + Background="DarkOrange" + Orientation="Horizontal" + Visibility="{x:Bind VisibleIfGreaterThanZero(ViewModel.WarningCount), Mode=OneWay}"> + <FontIcon + Margin="0,0,8,0" + VerticalAlignment="Center" + FontFamily="{ThemeResource SymbolThemeFontFamily}" + FontSize="12" + Glyph="" /> + <TextBlock VerticalAlignment="Center"> + <Run Text="{x:Bind ViewModel.WarningCount, Mode=OneWay}" /> + </TextBlock> + </StackPanel> + <StackPanel + Padding="8,4" + VerticalAlignment="Center" + Background="Maroon" + Orientation="Horizontal" + Visibility="{x:Bind VisibleIfGreaterThanZero(ViewModel.ErrorCount), Mode=OneWay}"> + <FontIcon + Margin="0,0,8,0" + VerticalAlignment="Center" + FontFamily="{ThemeResource SymbolThemeFontFamily}" + FontSize="12" + Glyph="" /> + <TextBlock VerticalAlignment="Center"> + <Run Text="{x:Bind ViewModel.ErrorCount, Mode=OneWay}" /> + </TextBlock> + </StackPanel> + <Border Padding="8,4"> + <Border.Background> + <SolidColorBrush Color="{x:Bind ViewModel.TagColor}" /> + </Border.Background> + <TextBlock Padding="4" VerticalAlignment="Center"> + <Run Text="{x:Bind ViewModel.Tag}" /> + </TextBlock> + </Border> + </StackPanel> + + <Button.Flyout> + <Flyout + Placement="Bottom" + ShouldConstrainToRootBounds="False" + SystemBackdrop="{ThemeResource AcrylicBackgroundFillColorDefaultBackdrop}"> + <Flyout.FlyoutPresenterStyle> + <Style BasedOn="{StaticResource DefaultFlyoutPresenterStyle}" TargetType="FlyoutPresenter"> + <Setter Property="MinWidth" Value="600" /> + <Setter Property="MaxWidth" Value="1200" /> + <Setter Property="Padding" Value="0" /> + </Style> + </Flyout.FlyoutPresenterStyle> + + <Grid x:Name="FlyoutContent"> + <Grid.RowDefinitions> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + + <StackPanel Padding="16" Spacing="8"> + + <!-- Logs section --> + <TextBlock + Margin="1,0,0,6" + Style="{ThemeResource SettingsSectionHeaderTextBlockStyle}" + Text="Logs" /> + <ItemsControl ItemTemplate="{StaticResource LogEntryTemplate}" ItemsSource="{x:Bind ViewModel.LatestLogs, Mode=OneWay}" /> + <StackPanel Orientation="Horizontal" Spacing="8"> + <Button Command="{x:Bind ViewModel.OpenLogFileCommand}" Content="Open Log File" /> + <Button Command="{x:Bind ViewModel.OpenLogFolderCommand}" Content="Open Log Folder" /> + <Button Command="{x:Bind ViewModel.ResetErrorCountersCommand}" Content="Clear Counters" /> + </StackPanel> + + <!-- Build info section --> + <TextBlock Style="{ThemeResource SettingsSectionHeaderTextBlockStyle}" Text="Build Info" /> + <Border + Padding="16" + Background="{ThemeResource SettingsCardBackground}" + BorderBrush="{ThemeResource SettingsCardBorderBrush}" + BorderThickness="1"> + <Grid ColumnSpacing="8"> + <Grid.Resources> + <Style + x:Key="KeyTextBlockStyle" + BasedOn="{StaticResource CaptionTextBlockStyle}" + TargetType="TextBlock"> + <Setter Property="IsTextSelectionEnabled" Value="True" /> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" /> + </Style> + <Style + x:Key="ValueTextBlockStyle" + BasedOn="{StaticResource CaptionTextBlockStyle}" + TargetType="TextBlock"> + <Setter Property="IsTextSelectionEnabled" Value="True" /> + <Setter Property="TextAlignment" Value="Right" /> + </Style> + </Grid.Resources> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition /> + <RowDefinition /> + <RowDefinition /> + </Grid.RowDefinitions> + <TextBlock + Grid.Row="0" + Grid.Column="0" + Style="{StaticResource KeyTextBlockStyle}" + Text="Configuration:" /> + <TextBlock + Grid.Row="0" + Grid.Column="1" + Style="{StaticResource ValueTextBlockStyle}" + Text="{x:Bind ViewModel.BuildConfiguration, Mode=OneWay}" /> + + <TextBlock + Grid.Row="1" + Grid.Column="0" + Style="{StaticResource KeyTextBlockStyle}" + Text="AOT:" /> + <TextBlock + Grid.Row="1" + Grid.Column="1" + Style="{StaticResource ValueTextBlockStyle}" + Text="{x:Bind ViewModel.IsAot, Mode=OneWay}" /> + + <TextBlock + Grid.Row="2" + Grid.Column="0" + Style="{StaticResource KeyTextBlockStyle}" + Text="Trimmed:" /> + <TextBlock + Grid.Row="2" + Grid.Column="1" + Style="{StaticResource ValueTextBlockStyle}" + Text="{x:Bind ViewModel.IsPublishTrimmed, Mode=OneWay}" /> + + </Grid> + </Border> + + <!-- More section --> + <TextBlock Style="{ThemeResource SettingsSectionHeaderTextBlockStyle}" Text="More" /> + <StackPanel Orientation="Horizontal" Spacing="8"> + <Button Command="{x:Bind ViewModel.OpenInternalToolsCommand}" Content="Open internal tools" /> + <Button Command="{x:Bind ViewModel.ToggleDevRibbonVisibilityCommand}" Content="Hide me" /> + </StackPanel> + </StackPanel> + + <!-- Footer --> + <Border + Grid.Row="1" + Padding="16" + Background="{ThemeResource SettingsCardBackground}" + BorderBrush="{ThemeResource SettingsCardBorderBrush}" + BorderThickness="1" + CornerRadius="{ThemeResource ControlCornerRadius}" + Visibility="{x:Bind ViewModel.IsAotReleaseConfiguration, Mode=OneWay, Converter={StaticResource InvertedBoolToVisibilityConverter}}"> + <TextBlock Text="Warning: Test in Release/AOT configuration to verify everything works." TextWrapping="Wrap" /> + </Border> + + </Grid> + </Flyout> + </Button.Flyout> + </Button> + </Border> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Normal" /> + <VisualState x:Name="PointerOver"> + <Storyboard> + <DoubleAnimation + Storyboard.TargetName="RootBorder" + Storyboard.TargetProperty="Opacity" + To="1.0" + Duration="0:0:0.1" /> + </Storyboard> + </VisualState> + </VisualStateGroup> + <VisualStateGroup x:Name="SeverityStates"> + <VisualState x:Name="NoLog" /> + <VisualState x:Name="WarningLog"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="SeverityIcon" Storyboard.TargetProperty="Glyph"> + <DiscreteObjectKeyFrame KeyTime="0" Value="" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + </VisualState> + <VisualState x:Name="ErrorLog"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="SeverityIcon" Storyboard.TargetProperty="Glyph"> + <DiscreteObjectKeyFrame KeyTime="0" Value="" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> +</UserControl> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml.cs new file mode 100644 index 0000000000..1659f32d32 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; + +namespace Microsoft.CmdPal.UI.Controls; + +internal sealed partial class DevRibbon : UserControl +{ + public ViewModels.DevRibbonViewModel ViewModel { get; } + + public DevRibbon() + { + InitializeComponent(); + ViewModel = new ViewModels.DevRibbonViewModel(); + + if (FlyoutContent != null) + { + FlyoutContent.DataContext = ViewModel; + } + } + + private void DevRibbonButton_PointerEntered(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(this, "PointerOver", true); + } + + private void DevRibbonButton_PointerExited(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(this, "Normal", true); + } + + private Visibility VisibleIfGreaterThanZero(int value) + { + return value > 0 ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml new file mode 100644 index 0000000000..4ab2339c0b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.CmdPal.UI.Controls.FallbackRanker" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:cmdpalUI="using:Microsoft.CmdPal.UI" + xmlns:controls="using:CommunityToolkit.WinUI.Controls" + xmlns:converters="using:CommunityToolkit.WinUI.Converters" + xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels" + xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:helpers="using:Microsoft.CmdPal.UI.Helpers" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" + mc:Ignorable="d"> + + <Grid> + <ListView + Padding="12,0,24,0" + AllowDrop="True" + CanDragItems="True" + CanReorderItems="True" + DragItemsCompleted="ListView_DragItemsCompleted" + ItemsSource="{x:Bind viewModel.FallbackRankings, Mode=OneWay}" + SelectionMode="None"> + <ListView.ItemTemplate> + <DataTemplate x:DataType="viewModels:FallbackSettingsViewModel"> + <Grid Padding="4,0,0,0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <Viewbox Grid.Column="1" Height="18"> + <PathIcon Data="M15.5 17C16.3284 17 17 17.6716 17 18.5C17 19.3284 16.3284 20 15.5 20C14.6716 20 14 19.3284 14 18.5C14 17.6716 14.6716 17 15.5 17ZM8.5 17C9.32843 17 10 17.6716 10 18.5C10 19.3284 9.32843 20 8.5 20C7.67157 20 7 19.3284 7 18.5C7 17.6716 7.67157 17 8.5 17ZM15.5 10C16.3284 10 17 10.6716 17 11.5C17 12.3284 16.3284 13 15.5 13C14.6716 13 14 12.3284 14 11.5C14 10.6716 14.6716 10 15.5 10ZM8.5 10C9.32843 10 10 10.6716 10 11.5C10 12.3284 9.32843 13 8.5 13C7.67157 13 7 12.3284 7 11.5C7 10.6716 7.67157 10 8.5 10ZM15.5 3C16.3284 3 17 3.67157 17 4.5C17 5.32843 16.3284 6 15.5 6C14.6716 6 14 5.32843 14 4.5C14 3.67157 14.6716 3 15.5 3ZM8.5 3C9.32843 3 10 3.67157 10 4.5C10 5.32843 9.32843 6 8.5 6C7.67157 6 7 5.32843 7 4.5C7 3.67157 7.67157 3 8.5 3Z" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </Viewbox> + <controls:SettingsCard + Width="560" + MinHeight="0" + Padding="8" + Background="Transparent" + BorderThickness="0" + Header="{x:Bind DisplayName}" + ToolTipService.ToolTip="{x:Bind Id}"> + <controls:SettingsCard.HeaderIcon> + <cpcontrols:ContentIcon> + <cpcontrols:ContentIcon.Content> + <cpcontrols:IconBox + Width="16" + Height="16" + AutomationProperties.AccessibilityView="Raw" + SourceKey="{x:Bind Icon, Mode=OneWay}" + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> + </cpcontrols:ContentIcon.Content> + </cpcontrols:ContentIcon> + </controls:SettingsCard.HeaderIcon> + </controls:SettingsCard> + </Grid> + </DataTemplate> + </ListView.ItemTemplate> + <!-- Customize Size of Item Container from ListView --> + <ListView.ItemsPanel> + <ItemsPanelTemplate> + <StackPanel Orientation="Vertical" Spacing="0" /> + </ItemsPanelTemplate> + </ListView.ItemsPanel> + <ListView.ItemContainerStyle> + <Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem"> + <Setter Property="Margin" Value="0" /> + <Setter Property="Padding" Value="4" /> + </Style> + </ListView.ItemContainerStyle> + </ListView> + </Grid> +</UserControl> \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs new file mode 100644 index 0000000000..57ad2c1d8c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.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 Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class FallbackRanker : UserControl +{ + private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); + private SettingsViewModel? viewModel; + + public FallbackRanker() + { + this.InitializeComponent(); + + var settings = App.Current.Services.GetService<SettingsModel>()!; + var topLevelCommandManager = App.Current.Services.GetService<TopLevelCommandManager>()!; + var themeService = App.Current.Services.GetService<IThemeService>()!; + viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + } + + private void ListView_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args) + { + viewModel?.ApplyFallbackSort(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRankerDialog.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRankerDialog.xaml new file mode 100644 index 0000000000..bbdf2f85ca --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRankerDialog.xaml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.CmdPal.UI.Controls.FallbackRankerDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d"> + <ContentDialog + x:Name="FallbackRankerContentDialog" + Width="420" + MinWidth="420" + PrimaryButtonText="OK"> + <ContentDialog.Title> + <TextBlock x:Uid="ManageFallbackRank" /> + </ContentDialog.Title> + <ContentDialog.Resources> + <x:Double x:Key="ContentDialogMaxWidth">800</x:Double> + </ContentDialog.Resources> + <Grid Width="560" MinWidth="420"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <TextBlock + x:Uid="ManageFallbackOrderDialogDescription" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + <Rectangle + Grid.Row="1" + Height="1" + Margin="0,16,0,16" + HorizontalAlignment="Stretch" + Fill="{ThemeResource DividerStrokeColorDefaultBrush}" /> + <cpcontrols:FallbackRanker + x:Name="FallbackRanker" + Grid.Row="2" + Margin="-24,0,-24,0" /> + </Grid> + </ContentDialog> +</UserControl> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRankerDialog.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRankerDialog.xaml.cs new file mode 100644 index 0000000000..a5186609be --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRankerDialog.xaml.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; +using Windows.Foundation; +using Windows.Foundation.Collections; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class FallbackRankerDialog : UserControl +{ + public FallbackRankerDialog() + { + InitializeComponent(); + } + + public IAsyncOperation<ContentDialogResult> ShowAsync() + { + return FallbackRankerContentDialog!.ShowAsync()!; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml new file mode 100644 index 0000000000..e92dbd912e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.CmdPal.UI.Controls.FiltersDropDown" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:cmdpalUI="using:Microsoft.CmdPal.UI" + xmlns:converters="using:CommunityToolkit.WinUI.Converters" + xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels" + xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:help="using:Microsoft.CmdPal.UI.Helpers" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + Background="Transparent" + mc:Ignorable="d"> + + <UserControl.Resources> + <ResourceDictionary> + <converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> + + <cmdpalUI:FilterTemplateSelector + x:Key="FilterTemplateSelector" + Default="{StaticResource FilterItemViewModelTemplate}" + Separator="{StaticResource SeparatorViewModelTemplate}" /> + + <Style + x:Name="ComboBoxStyle" + BasedOn="{StaticResource DefaultComboBoxStyle}" + TargetType="ComboBox"> + <Style.Setters> + <Setter Property="Visibility" Value="Collapsed" /> + <Setter Property="Margin" Value="0,0,12,0" /> + <Setter Property="Padding" Value="16,4" /> + </Style.Setters> + </Style> + + <!-- Template for the filter items --> + <DataTemplate x:Key="FilterItemViewModelTemplate" x:DataType="coreViewModels:FilterItemViewModel"> + <Grid AutomationProperties.Name="{x:Bind Name, Mode=OneWay}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="32" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <cpcontrols:IconBox + Width="16" + Margin="4,0" + HorizontalAlignment="Left" + VerticalAlignment="Center" + SourceKey="{x:Bind Icon}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + <TextBlock + Grid.Column="1" + VerticalAlignment="Center" + Text="{x:Bind Name}" /> + </Grid> + </DataTemplate> + + <!-- Template for separators --> + <DataTemplate x:Key="SeparatorViewModelTemplate" x:DataType="coreViewModels:SeparatorViewModel"> + <Rectangle + Height="1" + Margin="-16,-12,-12,-12" + Fill="{ThemeResource MenuFlyoutSeparatorBackground}" /> + </DataTemplate> + + </ResourceDictionary> + </UserControl.Resources> + + <ComboBox + Name="FiltersComboBox" + x:Uid="FiltersComboBox" + MinWidth="200" + VerticalAlignment="Center" + ItemTemplateSelector="{StaticResource FilterTemplateSelector}" + ItemsSource="{x:Bind ViewModel.Filters, Mode=OneWay}" + PlaceholderText="Filters" + PreviewKeyDown="FiltersComboBox_PreviewKeyDown" + SelectedValue="{x:Bind ViewModel.CurrentFilter, Mode=OneWay}" + SelectionChanged="FiltersComboBox_SelectionChanged" + Style="{StaticResource ComboBoxStyle}" + Visibility="{x:Bind ViewModel.ShouldShowFilters, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}, FallbackValue=Collapsed}"> + <ComboBox.ItemContainerStyle> + <Style BasedOn="{StaticResource DefaultComboBoxItemStyle}" TargetType="ComboBoxItem"> + <Setter Property="MinHeight" Value="0" /> + <Setter Property="Padding" Value="12,8" /> + </Style> + </ComboBox.ItemContainerStyle> + <ComboBox.ItemContainerTransitions> + <TransitionCollection /> + </ComboBox.ItemContainerTransitions> + </ComboBox> +</UserControl> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs new file mode 100644 index 0000000000..b51376dfa3 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FiltersDropDown.xaml.cs @@ -0,0 +1,189 @@ +// 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.ViewModels; +using Microsoft.CmdPal.UI.Views; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class FiltersDropDown : UserControl, + ICurrentPageAware +{ + public PageViewModel? CurrentPageViewModel + { + get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); + set => SetValue(CurrentPageViewModelProperty, value); + } + + public static readonly DependencyProperty CurrentPageViewModelProperty = + DependencyProperty.Register(nameof(CurrentPageViewModel), typeof(PageViewModel), typeof(FiltersDropDown), new PropertyMetadata(null, OnCurrentPageViewModelChanged)); + + private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var @this = (FiltersDropDown)d; + + if (@this != null + && e.OldValue is PageViewModel old) + { + old.PropertyChanged -= @this.Page_PropertyChanged; + } + + // If this new page does not implement ListViewModel or if + // it doesn't contain Filters, we need to clear any filters + // that may have been set. + if (@this != null) + { + if (e.NewValue is ListViewModel listViewModel) + { + @this.ViewModel = listViewModel.Filters; + } + else + { + @this.ViewModel = null; + } + } + + if (@this != null + && e.NewValue is PageViewModel page) + { + page.PropertyChanged += @this.Page_PropertyChanged; + } + } + + public FiltersViewModel? ViewModel + { + get => (FiltersViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + public static readonly DependencyProperty ViewModelProperty = + DependencyProperty.Register(nameof(ViewModel), typeof(FiltersViewModel), typeof(FiltersDropDown), new PropertyMetadata(null)); + + public FiltersDropDown() + { + this.InitializeComponent(); + } + + // Used to handle the case when a ListPage's `Filters` may have changed + private void Page_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + var property = e.PropertyName; + + if (CurrentPageViewModel is ListViewModel list) + { + if (property == nameof(ListViewModel.Filters)) + { + ViewModel = list.Filters; + } + } + } + + private void FiltersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (CurrentPageViewModel is ListViewModel listViewModel && + FiltersComboBox.SelectedItem is FilterItemViewModel filterItem) + { + listViewModel.UpdateCurrentFilter(filterItem.Id); + } + } + + private void FiltersComboBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Up) + { + NavigateUp(); + + e.Handled = true; + } + else if (e.Key == VirtualKey.Down) + { + NavigateDown(); + + e.Handled = true; + } + } + + private void NavigateUp() + { + var newIndex = FiltersComboBox.SelectedIndex; + + if (FiltersComboBox.SelectedIndex > 0) + { + newIndex--; + + while ( + newIndex >= 0 && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex--; + } + + if (newIndex < 0) + { + newIndex = FiltersComboBox.Items.Count - 1; + + while ( + newIndex >= 0 && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex--; + } + } + } + else + { + newIndex = FiltersComboBox.Items.Count - 1; + } + + FiltersComboBox.SelectedIndex = newIndex; + } + + private void NavigateDown() + { + var newIndex = FiltersComboBox.SelectedIndex; + + if (FiltersComboBox.SelectedIndex == FiltersComboBox.Items.Count - 1) + { + newIndex = 0; + } + else + { + newIndex++; + + while ( + newIndex < FiltersComboBox.Items.Count && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex++; + } + + if (newIndex >= FiltersComboBox.Items.Count) + { + newIndex = 0; + + while ( + newIndex < FiltersComboBox.Items.Count && + IsSeparator(FiltersComboBox.Items[newIndex]) && + newIndex != FiltersComboBox.SelectedIndex) + { + newIndex++; + } + } + } + + FiltersComboBox.SelectedIndex = newIndex; + } + + private bool IsSeparator(object item) + { + return item is SeparatorViewModel; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconBox.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconBox.cs index 34f0683440..2fad25cec3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconBox.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconBox.cs @@ -2,11 +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 Microsoft.CmdPal.UI.Deferred; -using Microsoft.CmdPal.UI.ViewModels; -using Microsoft.UI.Dispatching; +using CommunityToolkit.WinUI.Deferred; +using ManagedCommon; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; using Windows.Foundation; namespace Microsoft.CmdPal.UI.Controls; @@ -16,7 +16,11 @@ namespace Microsoft.CmdPal.UI.Controls; /// </summary> public partial class IconBox : ContentControl { - private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); + private const double DefaultIconFontSize = 16.0; + + private double _lastScale; + private ElementTheme _lastTheme; + private double _lastFontSize; /// <summary> /// Gets or sets the <see cref="IconSource"/> to display within the <see cref="IconBox"/>. Overwritten, if <see cref="SourceKey"/> is used instead. @@ -44,116 +48,237 @@ public partial class IconBox : ContentControl public static readonly DependencyProperty SourceKeyProperty = DependencyProperty.Register(nameof(SourceKey), typeof(object), typeof(IconBox), new PropertyMetadata(null, OnSourceKeyPropertyChanged)); + private TypedEventHandler<IconBox, SourceRequestedEventArgs>? _sourceRequested; + /// <summary> /// Gets or sets the <see cref="SourceRequested"/> event handler to provide the value of the <see cref="IconSource"/> for the <see cref="Source"/> property from the provided <see cref="SourceKey"/>. /// </summary> - public event TypedEventHandler<IconBox, SourceRequestedEventArgs>? SourceRequested; + public event TypedEventHandler<IconBox, SourceRequestedEventArgs>? SourceRequested + { + add + { + _sourceRequested += value; + if (_sourceRequested?.GetInvocationList().Length == 1) + { + Refresh(); + } +#if DEBUG + if (_sourceRequested?.GetInvocationList().Length > 1) + { + Logger.LogWarning("There shouldn't be more than one handler for IconBox.SourceRequested"); + } +#endif + } + remove => _sourceRequested -= value; + } + + public IconBox() + { + TabFocusNavigation = KeyboardNavigationMode.Once; + IsTabStop = false; + HorizontalContentAlignment = HorizontalAlignment.Center; + VerticalContentAlignment = VerticalAlignment.Center; + + Loaded += OnLoaded; + Unloaded += OnUnloaded; + ActualThemeChanged += OnActualThemeChanged; + SizeChanged += OnSizeChanged; + + UpdateLastFontSize(); + } + + private void UpdateLastFontSize() + { + _lastFontSize = + Pick(Width) + ?? Pick(Height) + ?? Pick(ActualWidth) + ?? Pick(ActualHeight) + ?? DefaultIconFontSize; + + return; + + static double? Pick(double value) => double.IsFinite(value) && value > 0 ? value : null; + } + + private void OnSizeChanged(object s, SizeChangedEventArgs e) + { + UpdateLastFontSize(); + + if (Source is FontIconSource fontIcon) + { + fontIcon.FontSize = _lastFontSize; + UpdatePaddingForFontIcon(); + } + } + + private void UpdatePaddingForFontIcon() => Padding = new Thickness(Math.Round(_lastFontSize * -0.2)); + + private void OnActualThemeChanged(FrameworkElement sender, object args) + { + if (_lastTheme == ActualTheme) + { + return; + } + + _lastTheme = ActualTheme; + Refresh(); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + _lastTheme = ActualTheme; + UpdateLastFontSize(); + + if (XamlRoot is not null) + { + _lastScale = XamlRoot.RasterizationScale; + XamlRoot.Changed += OnXamlRootChanged; + } + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (XamlRoot is not null) + { + XamlRoot.Changed -= OnXamlRootChanged; + } + } + + private void OnXamlRootChanged(XamlRoot sender, XamlRootChangedEventArgs args) + { + var newScale = sender.RasterizationScale; + var changedLastTheme = _lastTheme != ActualTheme; + _lastTheme = ActualTheme; + if ((changedLastTheme || Math.Abs(newScale - _lastScale) > 0.01) && SourceKey is not null) + { + _lastScale = newScale; + UpdateSourceKey(this, SourceKey); + } + } + + private void Refresh() + { + UpdateSourceKey(this, SourceKey); + } private static void OnSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - if (d is IconBox @this) + if (d is not IconBox self) { - switch (e.NewValue) - { - case null: - @this.Content = null; - break; - case FontIconSource fontIco: - fontIco.FontSize = double.IsNaN(@this.Width) ? @this.Height : @this.Width; + return; + } + + switch (e.NewValue) + { + case null: + self.Content = null; + self.Padding = default; + break; + case FontIconSource fontIcon: + self.UpdateLastFontSize(); + if (self.Content is IconSourceElement iconSourceElement) + { + fontIcon.FontSize = self._lastFontSize; + iconSourceElement.IconSource = fontIcon; + } + else + { + fontIcon.FontSize = self._lastFontSize; // For inexplicable reasons, FontIconSource.CreateIconElement // doesn't work, so do it ourselves // TODO: File platform bug? IconSourceElement elem = new() { - IconSource = fontIco, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + IconSource = fontIcon, }; - @this.Content = elem; - break; - case IconSource source: - @this.Content = source.CreateIconElement(); - break; - default: - throw new InvalidOperationException($"New value of {e.NewValue} is not of type IconSource."); - } + self.Content = elem; + } + + self.UpdatePaddingForFontIcon(); + + break; + case BitmapIconSource bitmapIcon: + if (self.Content is IconSourceElement iconSourceElement2) + { + iconSourceElement2.IconSource = bitmapIcon; + } + else + { + self.Content = bitmapIcon.CreateIconElement(); + } + + self.Padding = default; + + break; + + case IconSource source: + self.Content = source.CreateIconElement(); + self.Padding = default; + break; + + default: + throw new InvalidOperationException($"New value of {e.NewValue} is not of type IconSource."); } } private static void OnSourceKeyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - if (d is IconBox @this) + if (d is not IconBox self) { - if (e.NewValue == null) + return; + } + + UpdateSourceKey(self, e.NewValue); + } + + private static void UpdateSourceKey(IconBox iconBox, object? sourceKey) + { + if (sourceKey is null) + { + iconBox.Source = null; + return; + } + + RequestIconFromSource(iconBox, sourceKey); + } + + private static async void RequestIconFromSource(IconBox iconBox, object? sourceKey) + { + try + { + var iconBoxSourceRequestedHandler = iconBox._sourceRequested; + + if (iconBoxSourceRequestedHandler is null) { - @this.Source = null; + return; } - else + + var eventArgs = new SourceRequestedEventArgs(sourceKey, iconBox._lastTheme, iconBox._lastScale); + await iconBoxSourceRequestedHandler.InvokeAsync(iconBox, eventArgs); + + // After the await: + // Is the icon we're looking up now, the one we still + // want to find? Since this IconBox might be used in a + // list virtualization situation, it's very possible we + // may have already been set to a new icon before we + // even got back from the await. + if (!ReferenceEquals(sourceKey, iconBox.SourceKey)) { - // TODO GH #239 switch back when using the new MD text block - // _ = @this._queue.EnqueueAsync(() => - @this._queue.TryEnqueue(new(async () => - { - var requestedTheme = @this.ActualTheme; - var eventArgs = new SourceRequestedEventArgs(e.NewValue, requestedTheme); - - if (@this.SourceRequested != null) - { - await @this.SourceRequested.InvokeAsync(@this, eventArgs); - - // After the await: - // Is the icon we're looking up now, the one we still - // want to find? Since this IconBox might be used in a - // list virtualization situation, it's very possible we - // may have already been set to a new icon before we - // even got back from the await. - if (eventArgs.Key != @this.SourceKey) - { - // If the requested icon has changed, then just bail - return; - } - - @this.Source = eventArgs.Value; - - // Here's a little lesson in trickery: - // Emoji are rendered just a bit bigger than Segoe Icons. - // Just enough bigger that they get clipped if you put - // them in a box at the same size. - // - // So, if the icon we get back was a font icon, - // and the glyph for that icon is NOT in the range of - // Segoe icons, then let's give the icon some extra space - @this.Padding = new Thickness(0); - - IconDataViewModel? iconData = null; - if (eventArgs.Key is IconDataViewModel) - { - iconData = eventArgs.Key as IconDataViewModel; - } - else if (eventArgs.Key is IconInfoViewModel info) - { - iconData = requestedTheme == ElementTheme.Light ? info.Light : info.Dark; - } - - if (iconData != null && - @this.Source is FontIconSource) - { - if (!string.IsNullOrEmpty(iconData.Icon) && iconData.Icon.Length <= 2) - { - var ch = iconData.Icon[0]; - - // The range of MDL2 Icons isn't explicitly defined, but - // we're using this based off the table on: - // https://docs.microsoft.com/en-us/windows/uwp/design/style/segoe-ui-symbol-font - var isMDL2Icon = ch is >= '\uE700' and <= '\uF8FF'; - if (!isMDL2Icon) - { - @this.Padding = new Thickness(-4); - } - } - } - } - })); + // If the requested icon has changed, then just bail + return; } + + iconBox.Source = eventArgs.Value; + } + catch (Exception ex) + { + // Exception from TryEnqueue bypasses the global error handler, + // and crashes the app. + Logger.LogError("Failed to set icon", ex); } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconMarginConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconMarginConverter.cs new file mode 100644 index 0000000000..a0fb5be87c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IconMarginConverter.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 Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class IconMarginConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + // Only include a margin if there is text to separate from the icon. + var text = value as string; + return string.IsNullOrEmpty(text) ? new Thickness(0) : new Thickness(0, 0, 4, 0); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IsEnabledTextBlock.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IsEnabledTextBlock.xaml new file mode 100644 index 0000000000..8218d5bf21 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IsEnabledTextBlock.xaml @@ -0,0 +1,43 @@ +<ResourceDictionary + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.CmdPal.UI.Controls"> + + <Style x:Key="DefaultIsEnabledTextBlockStyle" TargetType="controls:IsEnabledTextBlock"> + <Setter Property="Foreground" Value="{ThemeResource DefaultTextForegroundThemeBrush}" /> + <Setter Property="IsTabStop" Value="False" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="controls:IsEnabledTextBlock"> + <Grid> + <TextBlock + x:Name="Label" + FontFamily="{TemplateBinding FontFamily}" + FontSize="{TemplateBinding FontSize}" + FontWeight="{TemplateBinding FontWeight}" + Foreground="{TemplateBinding Foreground}" + Text="{TemplateBinding Text}" + TextWrapping="WrapWholeWords" /> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Normal" /> + <VisualState x:Name="Disabled"> + <VisualState.Setters> + <Setter Target="Label.Foreground" Value="{ThemeResource TextFillColorDisabledBrush}" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + <Style + x:Key="SecondaryIsEnabledTextBlockStyle" + BasedOn="{StaticResource DefaultIsEnabledTextBlockStyle}" + TargetType="controls:IsEnabledTextBlock"> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" /> + <Setter Property="FontSize" Value="12" /> + </Style> +</ResourceDictionary> \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IsEnabledTextBlock.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IsEnabledTextBlock.xaml.cs new file mode 100644 index 0000000000..ffe65bc9f9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/IsEnabledTextBlock.xaml.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI.Controls; + +[TemplateVisualState(Name = "Normal", GroupName = "CommonStates")] +[TemplateVisualState(Name = "Disabled", GroupName = "CommonStates")] +public partial class IsEnabledTextBlock : Control +{ + public IsEnabledTextBlock() + { + this.Style = (Style)App.Current.Resources["DefaultIsEnabledTextBlockStyle"]; + } + + protected override void OnApplyTemplate() + { + IsEnabledChanged -= IsEnabledTextBlock_IsEnabledChanged; + SetEnabledState(); + IsEnabledChanged += IsEnabledTextBlock_IsEnabledChanged; + base.OnApplyTemplate(); + } + + public static readonly DependencyProperty TextProperty = DependencyProperty.Register( + "Text", + typeof(string), + typeof(IsEnabledTextBlock), + null); + + [Localizable(true)] + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + private void IsEnabledTextBlock_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + SetEnabledState(); + } + + private void SetEnabledState() + { + VisualStateManager.GoToState(this, IsEnabled ? "Normal" : "Disabled", true); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/KeyVisual/KeyVisual.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/KeyVisual/KeyVisual.cs index 609bcec62e..ed7022fce9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/KeyVisual/KeyVisual.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/KeyVisual/KeyVisual.cs @@ -2,7 +2,6 @@ // 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.UI; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Markup; @@ -80,12 +79,12 @@ public sealed partial class KeyVisual : Control private void Update() { - if (_keyVisual == null) + if (_keyVisual is null) { return; } - if (_keyVisual.Content != null) + if (_keyVisual.Content is not null) { if (_keyVisual.Content.GetType() == typeof(string)) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml new file mode 100644 index 0000000000..58c4e890a6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.CmdPal.UI.Controls.ScreenPreview" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d"> + + <Border + x:Name="ScreenBorder" + HorizontalAlignment="Center" + VerticalAlignment="Center" + BorderBrush="Black" + BorderThickness="8" + CornerRadius="8" + UseLayoutRounding="True"> + <Viewbox Height="120" UseLayoutRounding="True"> + <Grid> + <Image + x:Name="WallpaperImage" + MaxHeight="200" + Stretch="Uniform" + UseLayoutRounding="True" /> + <ContentPresenter + x:Name="ContentPresenter" + Margin="40" + Content="{x:Bind PreviewContent}" + UseLayoutRounding="True" /> + </Grid> + </Viewbox> + </Border> + +</UserControl> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.cs new file mode 100644 index 0000000000..828fa76c74 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ScreenPreview.xaml.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.CmdPal.UI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; + +namespace Microsoft.CmdPal.UI.Controls; + +[ContentProperty(Name = nameof(PreviewContent))] +public sealed partial class ScreenPreview : UserControl +{ + public static readonly DependencyProperty PreviewContentProperty = + DependencyProperty.Register(nameof(PreviewContent), typeof(object), typeof(ScreenPreview), new PropertyMetadata(null!))!; + + public object PreviewContent + { + get => GetValue(PreviewContentProperty)!; + set => SetValue(PreviewContentProperty, value); + } + + public ScreenPreview() + { + InitializeComponent(); + + var wallpaperHelper = new WallpaperHelper(); + WallpaperImage!.Source = wallpaperHelper.GetWallpaperImage()!; + ScreenBorder!.Background = new SolidColorBrush(wallpaperHelper.GetWallpaperColor()); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml index 379ea6b03d..d248c24f89 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml @@ -4,9 +4,8 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:cmdpalUi="using:Microsoft.CmdPal.UI" - xmlns:converters="using:CommunityToolkit.WinUI.Converters" - xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:h="using:Microsoft.CmdPal.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> @@ -22,6 +21,8 @@ MinHeight="32" VerticalAlignment="Stretch" VerticalContentAlignment="Stretch" + h:TextBoxCaretColor.SyncWithForeground="True" + AutomationProperties.AutomationId="MainSearchBox" KeyDown="FilterBox_KeyDown" PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}" PreviewKeyDown="FilterBox_PreviewKeyDown" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index c1c679fc73..ca27af4719 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -2,12 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Diagnostics; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Commands; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.Ext.ClipboardHistory.Messages; using Microsoft.CmdPal.UI.ViewModels; -using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.Views; +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Dispatching; using Microsoft.UI.Input; using Microsoft.UI.Xaml; @@ -21,6 +24,7 @@ namespace Microsoft.CmdPal.UI.Controls; public sealed partial class SearchBar : UserControl, IRecipient<GoHomeMessage>, IRecipient<FocusSearchBoxMessage>, + IRecipient<UpdateSuggestionMessage>, ICurrentPageAware { private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); @@ -31,6 +35,24 @@ public sealed partial class SearchBar : UserControl, private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); private bool _isBackspaceHeld; + // Inline text suggestions + // In 0.4-0.5 we would replace the text of the search box with the TextToSuggest + // This was really cool for navigating paths in run and pretty much nowhere else. + // We'll have to try another approach, but for now, the code is still testable. + // You can test this by setting the CMDPAL_ENABLE_SUGGESTION_SELECTION env var to 1 + private bool _inSuggestion; + + private bool InSuggestion => _inSuggestion && IsTextToSuggestEnabled; + + private string? _lastText; + + private string? _deletedSuggestion; + + // 0.6+ suggestions + private string? _textToSuggest; + + private SettingsModel Settings => App.Current.Services.GetRequiredService<SettingsModel>(); + public PageViewModel? CurrentPageViewModel { get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); @@ -46,18 +68,18 @@ public sealed partial class SearchBar : UserControl, //// TODO: If the Debounce timer hasn't fired, we may want to store the current Filter in the OldValue/prior VM, but we don't want that to go actually do work... var @this = (SearchBar)d; - if (@this != null + if (@this is not null && e.OldValue is PageViewModel old) { old.PropertyChanged -= @this.Page_PropertyChanged; } - if (@this != null + if (@this is not null && e.NewValue is PageViewModel page) { // TODO: In some cases we probably want commands to clear a filter // somewhere in the process, so we need to figure out when that is. - @this.FilterBox.Text = page.Filter; + @this.FilterBox.Text = page.SearchTextBox; @this.FilterBox.Select(@this.FilterBox.Text.Length, 0); page.PropertyChanged += @this.Page_PropertyChanged; @@ -69,6 +91,7 @@ public sealed partial class SearchBar : UserControl, this.InitializeComponent(); WeakReferenceMessenger.Default.Register<GoHomeMessage>(this); WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this); + WeakReferenceMessenger.Default.Register<UpdateSuggestionMessage>(this); } public void ClearSearch() @@ -79,9 +102,9 @@ public sealed partial class SearchBar : UserControl, { this.FilterBox.Text = string.Empty; - if (CurrentPageViewModel != null) + if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = string.Empty; + CurrentPageViewModel.SearchTextBox = string.Empty; } })); } @@ -103,62 +126,48 @@ public sealed partial class SearchBar : UserControl, return; } - var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); - var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); - if (e.Key == VirtualKey.Down) + var ctrlPressed = (InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control) & CoreVirtualKeyStates.Down) == CoreVirtualKeyStates.Down; + if (ctrlPressed && e.Key == VirtualKey.I) { - WeakReferenceMessenger.Default.Send<NavigateNextCommand>(); - + // Today you learned that Ctrl+I in a TextBox will insert a tab + // We don't want that, so we'll suppress it, this way it can be used for other purposes e.Handled = true; } - else if (e.Key == VirtualKey.Up) - { - WeakReferenceMessenger.Default.Send<NavigatePreviousCommand>(); - - e.Handled = true; - } - else if (ctrlPressed && e.Key == VirtualKey.Enter) - { - // ctrl+enter - WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>(); - e.Handled = true; - } - else if (e.Key == VirtualKey.Enter) - { - WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>(); - e.Handled = true; - } - else if (ctrlPressed && e.Key == VirtualKey.K) - { - // ctrl+k - WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(); - e.Handled = true; - } - else if (e.Key == VirtualKey.Right) - { - if (CurrentPageViewModel != null && !string.IsNullOrEmpty(CurrentPageViewModel.TextToSuggest)) - { - FilterBox.Text = CurrentPageViewModel.TextToSuggest; - FilterBox.Select(FilterBox.Text.Length, 0); - e.Handled = true; - } - } else if (e.Key == VirtualKey.Escape) { - if (string.IsNullOrEmpty(FilterBox.Text)) + switch (Settings.EscapeKeyBehaviorSetting) { - WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); - } - else - { - // Clear the search box - FilterBox.Text = string.Empty; + case EscapeKeyBehavior.AlwaysGoBack: + WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); + break; - // hack TODO GH #245 - if (CurrentPageViewModel != null) - { - CurrentPageViewModel.Filter = FilterBox.Text; - } + case EscapeKeyBehavior.AlwaysDismiss: + WeakReferenceMessenger.Default.Send<DismissMessage>(new(ForceGoHome: true)); + break; + + case EscapeKeyBehavior.AlwaysHide: + WeakReferenceMessenger.Default.Send<HideWindowMessage>(new()); + break; + + case EscapeKeyBehavior.ClearSearchFirstThenGoBack: + default: + if (string.IsNullOrEmpty(FilterBox.Text)) + { + WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); + } + else + { + // Clear the search box + FilterBox.Text = string.Empty; + + // hack TODO GH #245 + if (CurrentPageViewModel is not null) + { + CurrentPageViewModel.SearchTextBox = FilterBox.Text; + } + } + + break; } e.Handled = true; @@ -166,15 +175,11 @@ public sealed partial class SearchBar : UserControl, else if (e.Key == VirtualKey.Back) { // hack TODO GH #245 - if (CurrentPageViewModel != null) + if (CurrentPageViewModel is not null) { - CurrentPageViewModel.Filter = FilterBox.Text; + CurrentPageViewModel.SearchTextBox = FilterBox.Text; } } - else if (e.Key == VirtualKey.Left && altPressed) - { - WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); - } } private void FilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) @@ -197,6 +202,124 @@ public sealed partial class SearchBar : UserControl, _isBackspaceHeld = true; } } + else if (e.Key == VirtualKey.Up) + { + WeakReferenceMessenger.Default.Send<NavigatePreviousCommand>(); + + e.Handled = true; + } + else if (e.Key == VirtualKey.Left) + { + // Check if we're in a grid view, and if so, send grid navigation command + var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true }; + + // Special handling is required if we're in grid view. + if (isGridView) + { + WeakReferenceMessenger.Default.Send<NavigateLeftCommand>(); + e.Handled = true; + } + } + else if (e.Key == VirtualKey.Right) + { + // Check if the "replace search text with suggestion" feature from 0.4-0.5 is enabled. + // If it isn't, then only use the suggestion when the caret is at the end of the input. + if (!IsTextToSuggestEnabled) + { + if (!string.IsNullOrEmpty(_textToSuggest) && + FilterBox.SelectionStart == FilterBox.Text.Length) + { + FilterBox.Text = _textToSuggest; + FilterBox.Select(_textToSuggest.Length, 0); + e.Handled = true; + return; + } + } + + // Here, we're using the "replace search text with suggestion" feature. + if (InSuggestion) + { + _inSuggestion = false; + _lastText = null; + DoFilterBoxUpdate(); + } + + // Wouldn't want to perform text completion *and* move the selected item, so only perform this if text suggestion wasn't performed. + if (!e.Handled) + { + // Check if we're in a grid view, and if so, send grid navigation command + var isGridView = CurrentPageViewModel is ListViewModel { IsGridView: true }; + + // Special handling is required if we're in grid view. + if (isGridView) + { + WeakReferenceMessenger.Default.Send<NavigateRightCommand>(); + e.Handled = true; + } + } + } + else if (e.Key == VirtualKey.Down) + { + WeakReferenceMessenger.Default.Send<NavigateNextCommand>(); + + e.Handled = true; + } + else if (e.Key == VirtualKey.PageDown) + { + WeakReferenceMessenger.Default.Send<NavigatePageDownCommand>(); + e.Handled = true; + } + else if (e.Key == VirtualKey.PageUp) + { + WeakReferenceMessenger.Default.Send<NavigatePageUpCommand>(); + e.Handled = true; + } + + if (InSuggestion) + { + if ( + e.Key == VirtualKey.Back || + e.Key == VirtualKey.Delete + ) + { + _deletedSuggestion = FilterBox.Text; + + FilterBox.Text = _lastText ?? string.Empty; + FilterBox.Select(FilterBox.Text.Length, 0); + + // Logger.LogInfo("deleting suggestion"); + _inSuggestion = false; + _lastText = null; + + e.Handled = true; + return; + } + + var ignoreLeave = + + e.Key == VirtualKey.Up || + e.Key == VirtualKey.Down || + e.Key == VirtualKey.Left || + e.Key == VirtualKey.Right || + + e.Key == VirtualKey.RightMenu || + e.Key == VirtualKey.LeftMenu || + e.Key == VirtualKey.Menu || + e.Key == VirtualKey.Shift || + e.Key == VirtualKey.RightShift || + e.Key == VirtualKey.LeftShift || + e.Key == VirtualKey.RightControl || + e.Key == VirtualKey.LeftControl || + e.Key == VirtualKey.Control; + if (ignoreLeave) + { + return; + } + + // Logger.LogInfo("leaving suggestion"); + _inSuggestion = false; + _lastText = null; + } } private void FilterBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e) @@ -210,7 +333,7 @@ public sealed partial class SearchBar : UserControl, private void FilterBox_TextChanged(object sender, TextChangedEventArgs e) { - Debug.WriteLine($"FilterBox_TextChanged: {FilterBox.Text}"); + // Logger.LogInfo($"FilterBox_TextChanged: {FilterBox.Text}"); // TERRIBLE HACK TODO GH #245 // There's weird wacky bugs with debounce currently. We're trying @@ -219,23 +342,22 @@ public sealed partial class SearchBar : UserControl, // (otherwise aliases just stop working) if (FilterBox.Text.Length == 1) { - if (CurrentPageViewModel != null) - { - CurrentPageViewModel.Filter = FilterBox.Text; - } + DoFilterBoxUpdate(); return; } + if (InSuggestion) + { + // Logger.LogInfo($"-- skipping, in suggestion --"); + return; + } + // TODO: We could encapsulate this in a Behavior if we wanted to bind to the Filter property. _debounceTimer.Debounce( () => { - // Actually plumb Filtering to the viewmodel - if (CurrentPageViewModel != null) - { - CurrentPageViewModel.Filter = FilterBox.Text; - } + DoFilterBoxUpdate(); }, //// Couldn't find a good recommendation/resource for value here. PT uses 50ms as default, so that is a reasonable default //// This seems like a useful testing site for typing times: https://keyboardtester.info/keyboard-latency-test/ @@ -245,27 +367,170 @@ public sealed partial class SearchBar : UserControl, immediate: FilterBox.Text.Length <= 1); } + private void DoFilterBoxUpdate() + { + if (InSuggestion) + { + // Logger.LogInfo($"--- skipping ---"); + return; + } + + // Actually plumb Filtering to the view model + if (CurrentPageViewModel is not null) + { + CurrentPageViewModel.SearchTextBox = FilterBox.Text; + + // Telemetry: Track search query count for session metrics (only non-empty queries) + if (!string.IsNullOrWhiteSpace(FilterBox.Text)) + { + WeakReferenceMessenger.Default.Send<SearchQueryMessage>(new()); + } + } + } + // Used to handle the case when a ListPage's `SearchText` may have changed private void Page_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { var property = e.PropertyName; - if (CurrentPageViewModel is ListViewModel list && - property == nameof(ListViewModel.SearchText)) - { - // Only if the text actually changed... - // (sometimes this triggers on a round-trip of the SearchText) - if (FilterBox.Text != list.SearchText) - { - // ... Update our displayed text, and... - FilterBox.Text = list.SearchText; - // ... Move the cursor to the end of the input - FilterBox.Select(FilterBox.Text.Length, 0); + if (CurrentPageViewModel is ListViewModel list) + { + if (property == nameof(ListViewModel.SearchText)) + { + // Only if the text actually changed... + // (sometimes this triggers on a round-trip of the SearchText) + if (FilterBox.Text != list.SearchText) + { + // ... Update our displayed text, and... + FilterBox.Text = list.SearchText; + + // ... Move the cursor to the end of the input + FilterBox.Select(FilterBox.Text.Length, 0); + } + } + else if (property == nameof(ListViewModel.InitialSearchText)) + { + // GH #38712: + // The ListPage will notify us of the `InitialSearchText` when + // we first load the view model. We can use that as an + // opportunity to immediately select the search text. That lets + // the user start typing a new search without manually + // selecting the old one. + SelectSearch(); } } } public void Receive(GoHomeMessage message) => ClearSearch(); - public void Receive(FocusSearchBoxMessage message) => this.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + public void Receive(FocusSearchBoxMessage message) => FilterBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + + public void Receive(UpdateSuggestionMessage message) + { + if (!IsTextToSuggestEnabled) + { + _textToSuggest = message.TextToSuggest; + return; + } + + var suggestion = message.TextToSuggest; + + _queue.TryEnqueue(new(() => + { + var clearSuggestion = string.IsNullOrEmpty(suggestion); + + if (clearSuggestion && _inSuggestion) + { + // Logger.LogInfo($"Cleared suggestion \"{_lastText}\" to {suggestion}"); + _inSuggestion = false; + FilterBox.Text = _lastText ?? string.Empty; + _lastText = null; + return; + } + + if (clearSuggestion) + { + _deletedSuggestion = null; + return; + } + + if (suggestion == _deletedSuggestion) + { + return; + } + else + { + _deletedSuggestion = null; + } + + var currentText = _lastText ?? FilterBox.Text; + + _lastText = currentText; + + // if (_inSuggestion) + // { + // Logger.LogInfo($"Suggestion from \"{_lastText}\" to {suggestion}"); + // } + // else + // { + // Logger.LogInfo($"Entering suggestion from \"{_lastText}\" to {suggestion}"); + // } + _inSuggestion = true; + + var matchedChars = 0; + var suggestionStartsWithQuote = suggestion.Length > 0 && suggestion[0] == '"'; + var currentStartsWithQuote = currentText.Length > 0 && currentText[0] == '"'; + var skipCheckingFirst = suggestionStartsWithQuote && !currentStartsWithQuote; + for (int i = skipCheckingFirst ? 1 : 0, j = 0; + i < suggestion.Length && j < currentText.Length; + i++, j++) + { + if (string.Equals( + suggestion[i].ToString(), + currentText[j].ToString(), + StringComparison.OrdinalIgnoreCase)) + { + matchedChars++; + } + else + { + break; + } + } + + var first = skipCheckingFirst ? "\"" : string.Empty; + var second = currentText.AsSpan(0, matchedChars); + var third = suggestion.AsSpan(matchedChars + (skipCheckingFirst ? 1 : 0)); + + var newText = string.Concat( + first, + second, + third); + + FilterBox.Text = newText; + + var wrappedInQuotes = suggestionStartsWithQuote && suggestion.Last() == '"'; + if (wrappedInQuotes) + { + FilterBox.Select( + (skipCheckingFirst ? 1 : 0) + matchedChars, + Math.Max(0, suggestion.Length - matchedChars - 1 + (skipCheckingFirst ? -1 : 0))); + } + else + { + FilterBox.Select(matchedChars, suggestion.Length - matchedChars); + } + })); + } + + private static bool IsTextToSuggestEnabled => _textToSuggestEnabled.Value; + + private static Lazy<bool> _textToSuggestEnabled = new(() => QueryTextToSuggestEnabled()); + + private static bool QueryTextToSuggestEnabled() + { + var env = System.Environment.GetEnvironmentVariable("CMDPAL_ENABLE_SUGGESTION_SELECTION"); + return !string.IsNullOrEmpty(env) && + (env == "1" || env.Equals("true", System.StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/HotkeySettingsControlHook.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/HotkeySettingsControlHook.cs index 66f482f502..6ea1c708a7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/HotkeySettingsControlHook.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/HotkeySettingsControlHook.cs @@ -12,7 +12,7 @@ public delegate bool IsActive(); public delegate bool FilterAccessibleKeyboardEvents(int key, UIntPtr extraInfo); -public class HotkeySettingsControlHook : IDisposable +public partial class HotkeySettingsControlHook : IDisposable { private const int WmKeyDown = 0x100; private const int WmKeyUp = 0x101; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/NativeKeyboardHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/NativeKeyboardHelper.cs index 9d3961f907..48b02c4412 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/NativeKeyboardHelper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/NativeKeyboardHelper.cs @@ -5,82 +5,81 @@ using System; using System.Runtime.InteropServices; -namespace Microsoft.PowerToys.Settings.UI.Helpers +namespace Microsoft.PowerToys.Settings.UI.Helpers; + +internal static class NativeKeyboardHelper { - internal static class NativeKeyboardHelper + [StructLayout(LayoutKind.Sequential)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + internal struct INPUT { - [StructLayout(LayoutKind.Sequential)] - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] - internal struct INPUT - { - internal INPUTTYPE type; - internal InputUnion data; + internal INPUTTYPE type; + internal InputUnion data; - internal static int Size - { - get { return Marshal.SizeOf(typeof(INPUT)); } - } - } - - [StructLayout(LayoutKind.Explicit)] - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] - internal struct InputUnion + internal static int Size { - [FieldOffset(0)] - internal MOUSEINPUT mi; - [FieldOffset(0)] - internal KEYBDINPUT ki; - [FieldOffset(0)] - internal HARDWAREINPUT hi; - } - - [StructLayout(LayoutKind.Sequential)] - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] - internal struct MOUSEINPUT - { - internal int dx; - internal int dy; - internal int mouseData; - internal uint dwFlags; - internal uint time; - internal UIntPtr dwExtraInfo; - } - - [StructLayout(LayoutKind.Sequential)] - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] - internal struct KEYBDINPUT - { - internal short wVk; - internal short wScan; - internal uint dwFlags; - internal int time; - internal UIntPtr dwExtraInfo; - } - - [StructLayout(LayoutKind.Sequential)] - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] - internal struct HARDWAREINPUT - { - internal int uMsg; - internal short wParamL; - internal short wParamH; - } - - internal enum INPUTTYPE : uint - { - INPUT_MOUSE = 0, - INPUT_KEYBOARD = 1, - INPUT_HARDWARE = 2, - } - - [Flags] - internal enum KeyEventF - { - KeyDown = 0x0000, - ExtendedKey = 0x0001, - KeyUp = 0x0002, - Unicode = 0x0004, - Scancode = 0x0008, + get { return Marshal.SizeOf<INPUT>(); } } } + + [StructLayout(LayoutKind.Explicit)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + internal struct InputUnion + { + [FieldOffset(0)] + internal MOUSEINPUT mi; + [FieldOffset(0)] + internal KEYBDINPUT ki; + [FieldOffset(0)] + internal HARDWAREINPUT hi; + } + + [StructLayout(LayoutKind.Sequential)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + internal struct MOUSEINPUT + { + internal int dx; + internal int dy; + internal int mouseData; + internal uint dwFlags; + internal uint time; + internal UIntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + internal struct KEYBDINPUT + { + internal short wVk; + internal short wScan; + internal uint dwFlags; + internal int time; + internal UIntPtr dwExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] + internal struct HARDWAREINPUT + { + internal int uMsg; + internal short wParamL; + internal short wParamH; + } + + internal enum INPUTTYPE : uint + { + INPUT_MOUSE = 0, + INPUT_KEYBOARD = 1, + INPUT_HARDWARE = 2, + } + + [Flags] + internal enum KeyEventF + { + KeyDown = 0x0000, + ExtendedKey = 0x0001, + KeyUp = 0x0002, + Unicode = 0x0004, + Scancode = 0x0008, + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/NativeMethods.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/NativeMethods.cs index f89b11391b..38a50a0b94 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/NativeMethods.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/NativeMethods.cs @@ -7,7 +7,7 @@ using System.Text; namespace Microsoft.PowerToys.Settings.UI.Helpers; -public static class NativeMethods +public static partial class NativeMethods { private const int WS_POPUP = 1 << 31; // 0x80000000 internal const int GWL_STYLE = -16; @@ -26,11 +26,11 @@ public static class NativeMethods [DllImport("user32.dll")] internal static extern bool GetWindowPlacement(IntPtr hWnd, out WINDOWPLACEMENT lpwndpl); - [DllImport("user32.dll")] - internal static extern uint SendInput(uint nInputs, NativeKeyboardHelper.INPUT[] pInputs, int cbSize); + [LibraryImport("user32.dll")] + internal static partial uint SendInput(uint nInputs, NativeKeyboardHelper.INPUT[] pInputs, int cbSize); - [DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall, SetLastError = true)] - internal static extern short GetAsyncKeyState(int vKey); + [LibraryImport("user32.dll")] + internal static partial short GetAsyncKeyState(int vKey); [DllImport("user32.dll", SetLastError = true)] internal static extern int GetWindowLong(IntPtr hWnd, int nIndex); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutControl.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutControl.xaml.cs index b89a627d70..e7fa721277 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutControl.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutControl.xaml.cs @@ -39,13 +39,13 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie private static void OnAllowDisableChanged(DependencyObject d, DependencyPropertyChangedEventArgs? e) { var me = d as ShortcutControl; - if (me == null) + if (me is null) { return; } var description = me.c?.FindDescendant<TextBlock>(); - if (description == null) + if (description is null) { return; } @@ -431,7 +431,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie private void ShortcutDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) { - if (lastValidSettings != null && ComboIsValid(lastValidSettings)) + if (lastValidSettings is not null && ComboIsValid(lastValidSettings)) { HotkeySettings = lastValidSettings with { }; } @@ -458,7 +458,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie private static bool ComboIsValid(HotkeySettings? settings) { - return settings != null && (settings.IsValid() || settings.IsEmpty()); + return settings is not null && (settings.IsValid() || settings.IsEmpty()); } public void Receive(WindowActivatedEventArgs message) => DoWindowActivated(message); @@ -466,12 +466,12 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie private void DoWindowActivated(WindowActivatedEventArgs args) { args.Handled = true; - if (args.WindowActivationState != WindowActivationState.Deactivated && (hook == null || hook.GetDisposedState() == true)) + if (args.WindowActivationState != WindowActivationState.Deactivated && (hook is null || hook.GetDisposedState() == true)) { // If the PT settings window gets focussed/activated again, we enable the keyboard hook to catch the keyboard input. hook = new HotkeySettingsControlHook(Hotkey_KeyDown, Hotkey_KeyUp, Hotkey_IsActive, FilterAccessibleKeyboardEvents); } - else if (args.WindowActivationState == WindowActivationState.Deactivated && hook != null && hook.GetDisposedState() == false) + else if (args.WindowActivationState == WindowActivationState.Deactivated && hook is not null && hook.GetDisposedState() == false) { // If the PT settings window lost focus/activation, we disable the keyboard hook to allow keyboard input on other windows. hook.Dispose(); @@ -490,7 +490,7 @@ public sealed partial class ShortcutControl : UserControl, IDisposable, IRecipie { if (disposing) { - if (hook != null) + if (hook is not null) { hook.Dispose(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutDialogContentControl.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutDialogContentControl.xaml index 7932a27e91..0000685ac3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutDialogContentControl.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutDialogContentControl.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" x:Name="ShortcutContentControl" mc:Ignorable="d"> <Grid MinWidth="498" MinHeight="220"> @@ -15,7 +15,7 @@ <RowDefinition Height="Auto" /> </Grid.RowDefinitions> - <TextBlock Grid.Row="0" /> + <TextBlock Grid.Row="0" IsTabStop="True" /> <ItemsControl x:Name="KeysControl" @@ -66,7 +66,7 @@ IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}" Severity="Warning" /> </Grid> - <tk7controls:MarkdownTextBlock + <tkcontrols:MarkdownTextBlock x:Uid="InvalidShortcutWarningLabel" Background="Transparent" FontSize="12" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml index e7bc518dee..c6f2a9fe2a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" d:DesignHeight="300" d:DesignWidth="400" mc:Ignorable="d"> @@ -36,7 +36,7 @@ </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> - <tk7controls:MarkdownTextBlock + <tkcontrols:MarkdownTextBlock Grid.Column="1" VerticalAlignment="Center" Background="Transparent" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SourceRequestedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SourceRequestedEventArgs.cs index 670bf13a7a..5528217f89 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SourceRequestedEventArgs.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SourceRequestedEventArgs.cs @@ -11,11 +11,13 @@ namespace Microsoft.CmdPal.UI.Controls; /// <summary> /// See <see cref="IconBox.SourceRequested"/> event. /// </summary> -public class SourceRequestedEventArgs(object? key, ElementTheme requestedTheme) : DeferredEventArgs +public class SourceRequestedEventArgs(object? key, ElementTheme requestedTheme, double scale = 1.0) : DeferredEventArgs { public object? Key { get; private set; } = key; public IconSource? Value { get; set; } public ElementTheme Theme => requestedTheme; + + public double Scale => scale; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml index 54f3c69b23..14386896f3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8" ?> <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" @@ -6,14 +6,14 @@ <ResourceDictionary.ThemeDictionaries> <ResourceDictionary x:Key="Default"> - <StaticResource x:Key="TagBackground" ResourceKey="ControlSolidFillColorDefaultBrush" /> - <StaticResource x:Key="TagBorderBrush" ResourceKey="ControlStrokeColorSecondaryBrush" /> + <StaticResource x:Key="TagBackground" ResourceKey="ControlFillColorSecondaryBrush" /> + <StaticResource x:Key="TagBorderBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> <StaticResource x:Key="TagForeground" ResourceKey="TextFillColorTertiaryBrush" /> </ResourceDictionary> <ResourceDictionary x:Key="Light"> - <StaticResource x:Key="TagBackground" ResourceKey="ControlSolidFillColorDefaultBrush" /> - <StaticResource x:Key="TagBorderBrush" ResourceKey="ControlStrokeColorSecondaryBrush" /> + <StaticResource x:Key="TagBackground" ResourceKey="ControlFillColorSecondaryBrush" /> + <StaticResource x:Key="TagBorderBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> <StaticResource x:Key="TagForeground" ResourceKey="TextFillColorTertiaryBrush" /> </ResourceDictionary> @@ -27,6 +27,8 @@ <Thickness x:Key="TagPadding">4,2,4,2</Thickness> <Thickness x:Key="TagBorderThickness">1</Thickness> + <local:IconMarginConverter x:Key="IconMarginConverter" /> + <Style BasedOn="{StaticResource DefaultTagStyle}" TargetType="local:Tag" /> <Style x:Key="DefaultTagStyle" TargetType="local:Tag"> @@ -38,6 +40,7 @@ <Setter Property="IsTabStop" Value="False" /> <Setter Property="HorizontalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" /> + <Setter Property="AutomationProperties.AutomationControlType" Value="Custom" /> <Setter Property="BackgroundSizing" Value="InnerBorderEdge" /> <Setter Property="Padding" Value="{ThemeResource TagPadding}" /> <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> @@ -56,6 +59,7 @@ Padding="{TemplateBinding Padding}" HorizontalAlignment="{TemplateBinding HorizontalAlignment}" VerticalAlignment="{TemplateBinding VerticalAlignment}" + AutomationProperties.Name="{TemplateBinding Text}" Background="{TemplateBinding Background}" BackgroundSizing="{TemplateBinding BackgroundSizing}" BorderBrush="{TemplateBinding BorderBrush}" @@ -68,8 +72,9 @@ <local:IconBox x:Name="PART_Icon" Grid.Column="0" + Width="12" Height="12" - Margin="0,0,4,0" + Margin="{Binding Text, RelativeSource={RelativeSource TemplatedParent}, Converter={StaticResource IconMarginConverter}}" SourceKey="{TemplateBinding Icon}" /> <TextBlock Grid.Column="1" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs index ec1be70904..405d4341a9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/Tag.xaml.cs @@ -2,8 +2,8 @@ // 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.ViewModels; using Microsoft.CmdPal.UI.Helpers; -using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CommandPalette.Extensions; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -72,7 +72,7 @@ public partial class Tag : Control if (GetTemplateChild(TagIconBox) is IconBox iconBox) { - iconBox.SourceRequested += IconCacheProvider.SourceRequested; + iconBox.SourceRequested += IconCacheProvider.SourceRequested20; iconBox.Visibility = HasIcon ? Visibility.Visible : Visibility.Collapsed; } } @@ -84,7 +84,7 @@ public partial class Tag : Control return; } - if (tag.ForegroundColor != null && + if (tag.ForegroundColor is not null && OptionalColorBrushCacheProvider.Convert(tag.ForegroundColor.Value) is SolidColorBrush brush) { tag.Foreground = brush; @@ -114,7 +114,7 @@ public partial class Tag : Control return; } - if (tag.BackgroundColor != null && + if (tag.BackgroundColor is not null && OptionalColorBrushCacheProvider.Convert(tag.BackgroundColor.Value) is SolidColorBrush brush) { tag.Background = brush; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs new file mode 100644 index 0000000000..2d7567c346 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Controls; + +internal sealed class UVBounds +{ + public double UMin { get; } + + public double UMax { get; } + + public double VMin { get; } + + public double VMax { get; } + + public UVBounds(Orientation orientation, Rect rect) + { + if (orientation == Orientation.Horizontal) + { + UMin = rect.Left; + UMax = rect.Right; + VMin = rect.Top; + VMax = rect.Bottom; + } + else + { + UMin = rect.Top; + UMax = rect.Bottom; + VMin = rect.Left; + VMax = rect.Right; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs new file mode 100644 index 0000000000..1b75c31564 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Controls; + +[DebuggerDisplay("U = {U} V = {V}")] +internal struct UvMeasure +{ + internal double U { get; set; } + + internal double V { get; set; } + + internal static UvMeasure Zero => default(UvMeasure); + + public UvMeasure(Orientation orientation, Size size) + : this(orientation, size.Width, size.Height) + { + } + + public UvMeasure(Orientation orientation, double width, double height) + { + if (orientation == Orientation.Horizontal) + { + U = width; + V = height; + } + else + { + U = height; + V = width; + } + } + + public UvMeasure Add(double u, double v) + { + UvMeasure result = default(UvMeasure); + result.U = U + u; + result.V = V + v; + return result; + } + + public UvMeasure Add(UvMeasure measure) + { + return Add(measure.U, measure.V); + } + + public Size ToSize(Orientation orientation) + { + if (orientation != Orientation.Horizontal) + { + return new Size(V, U); + } + + return new Size(U, V); + } + + public Point GetPoint(Orientation orientation) + { + return orientation is Orientation.Horizontal ? new Point(U, V) : new Point(V, U); + } + + public Size GetSize(Orientation orientation) + { + return orientation is Orientation.Horizontal ? new Size(U, V) : new Size(V, U); + } + + public static bool operator ==(UvMeasure measure1, UvMeasure measure2) + { + return measure1.U == measure2.U && measure1.V == measure2.V; + } + + public static bool operator !=(UvMeasure measure1, UvMeasure measure2) + { + return !(measure1 == measure2); + } + + public override bool Equals(object? obj) + { + return obj is UvMeasure measure && this == measure; + } + + public bool Equals(UvMeasure value) + { + return this == value; + } + + public override int GetHashCode() + { + return base.GetHashCode(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs new file mode 100644 index 0000000000..24aa437f18 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs @@ -0,0 +1,416 @@ +// 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 CommunityToolkit.WinUI.Controls; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +using ToolkitStretchChild = CommunityToolkit.WinUI.Controls.StretchChild; + +namespace Microsoft.CmdPal.UI.Controls; + +/// <summary> +/// Arranges elements by wrapping them to fit the available space. +/// When <see cref="Orientation"/> is set to Orientation.Horizontal, element are arranged in rows until the available width is reached and then to a new row. +/// When <see cref="Orientation"/> is set to Orientation.Vertical, element are arranged in columns until the available height is reached. +/// </summary> +public sealed partial class WrapPanel : Panel +{ + private struct UvRect + { + public UvMeasure Position { get; set; } + + public UvMeasure Size { get; set; } + + public Rect ToRect(Orientation orientation) + { + return orientation switch + { + Orientation.Vertical => new Rect(Position.V, Position.U, Size.V, Size.U), + Orientation.Horizontal => new Rect(Position.U, Position.V, Size.U, Size.V), + _ => ThrowArgumentException(), + }; + } + + private static Rect ThrowArgumentException() + { + throw new ArgumentException("The input orientation is not valid."); + } + } + + private struct Row + { + public List<UvRect> ChildrenRects { get; } + + public UvMeasure Size { get; set; } + + public UvRect Rect + { + get + { + UvRect result; + if (ChildrenRects.Count <= 0) + { + result = default(UvRect); + result.Position = UvMeasure.Zero; + result.Size = Size; + return result; + } + + result = default(UvRect); + result.Position = ChildrenRects.First().Position; + result.Size = Size; + return result; + } + } + + public Row(List<UvRect> childrenRects, UvMeasure size) + { + ChildrenRects = childrenRects; + Size = size; + } + + public void Add(UvMeasure position, UvMeasure size) + { + ChildrenRects.Add(new UvRect + { + Position = position, + Size = size, + }); + + Size = new UvMeasure + { + U = position.U + size.U, + V = Math.Max(Size.V, size.V), + }; + } + } + + /// <summary> + /// Gets or sets a uniform Horizontal distance (in pixels) between items when <see cref="Orientation"/> is set to Horizontal, + /// or between columns of items when <see cref="Orientation"/> is set to Vertical. + /// </summary> + public double HorizontalSpacing + { + get { return (double)GetValue(HorizontalSpacingProperty); } + set { SetValue(HorizontalSpacingProperty, value); } + } + + /// <summary> + /// Identifies the <see cref="HorizontalSpacing"/> dependency property. + /// </summary> + public static readonly DependencyProperty HorizontalSpacingProperty = + DependencyProperty.Register( + nameof(HorizontalSpacing), + typeof(double), + typeof(WrapPanel), + new PropertyMetadata(0d, LayoutPropertyChanged)); + + /// <summary> + /// Gets or sets a uniform Vertical distance (in pixels) between items when <see cref="Orientation"/> is set to Vertical, + /// or between rows of items when <see cref="Orientation"/> is set to Horizontal. + /// </summary> + public double VerticalSpacing + { + get { return (double)GetValue(VerticalSpacingProperty); } + set { SetValue(VerticalSpacingProperty, value); } + } + + /// <summary> + /// Identifies the <see cref="VerticalSpacing"/> dependency property. + /// </summary> + public static readonly DependencyProperty VerticalSpacingProperty = + DependencyProperty.Register( + nameof(VerticalSpacing), + typeof(double), + typeof(WrapPanel), + new PropertyMetadata(0d, LayoutPropertyChanged)); + + /// <summary> + /// Gets or sets the orientation of the WrapPanel. + /// Horizontal means that child controls will be added horizontally until the width of the panel is reached, then a new row is added to add new child controls. + /// Vertical means that children will be added vertically until the height of the panel is reached, then a new column is added. + /// </summary> + public Orientation Orientation + { + get { return (Orientation)GetValue(OrientationProperty); } + set { SetValue(OrientationProperty, value); } + } + + /// <summary> + /// Identifies the <see cref="Orientation"/> dependency property. + /// </summary> + public static readonly DependencyProperty OrientationProperty = + DependencyProperty.Register( + nameof(Orientation), + typeof(Orientation), + typeof(WrapPanel), + new PropertyMetadata(Orientation.Horizontal, LayoutPropertyChanged)); + + /// <summary> + /// Gets or sets the distance between the border and its child object. + /// </summary> + /// <returns> + /// The dimensions of the space between the border and its child as a Thickness value. + /// Thickness is a structure that stores dimension values using pixel measures. + /// </returns> + public Thickness Padding + { + get { return (Thickness)GetValue(PaddingProperty); } + set { SetValue(PaddingProperty, value); } + } + + /// <summary> + /// Identifies the Padding dependency property. + /// </summary> + /// <returns>The identifier for the <see cref="Padding"/> dependency property.</returns> + public static readonly DependencyProperty PaddingProperty = + DependencyProperty.Register( + nameof(Padding), + typeof(Thickness), + typeof(WrapPanel), + new PropertyMetadata(default(Thickness), LayoutPropertyChanged)); + + /// <summary> + /// Gets or sets a value indicating how to arrange child items + /// </summary> + public ToolkitStretchChild StretchChild + { + get { return (ToolkitStretchChild)GetValue(StretchChildProperty); } + set { SetValue(StretchChildProperty, value); } + } + + /// <summary> + /// Identifies the <see cref="StretchChild"/> dependency property. + /// </summary> + /// <returns>The identifier for the <see cref="StretchChild"/> dependency property.</returns> + public static readonly DependencyProperty StretchChildProperty = + DependencyProperty.Register( + nameof(StretchChild), + typeof(ToolkitStretchChild), + typeof(WrapPanel), + new PropertyMetadata(ToolkitStretchChild.None, LayoutPropertyChanged)); + + /// <summary> + /// Identifies the IsFullLine attached dependency property. + /// If true, the child element will occupy the entire width of the panel and force a line break before and after itself. + /// </summary> + public static readonly DependencyProperty IsFullLineProperty = + DependencyProperty.RegisterAttached( + "IsFullLine", + typeof(bool), + typeof(WrapPanel), + new PropertyMetadata(false, OnIsFullLineChanged)); + + public static bool GetIsFullLine(DependencyObject obj) + { + return (bool)obj.GetValue(IsFullLineProperty); + } + + public static void SetIsFullLine(DependencyObject obj, bool value) + { + obj.SetValue(IsFullLineProperty, value); + } + + private static void OnIsFullLineChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (FindVisualParentWrapPanel(d) is WrapPanel wp) + { + wp.InvalidateMeasure(); + } + } + + private static WrapPanel? FindVisualParentWrapPanel(DependencyObject child) + { + var parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(child); + + while (parent != null) + { + if (parent is WrapPanel wrapPanel) + { + return wrapPanel; + } + + parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(parent); + } + + return null; + } + + private static void LayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is WrapPanel wp) + { + wp.InvalidateMeasure(); + wp.InvalidateArrange(); + } + } + + private readonly List<Row> _rows = new List<Row>(); + + /// <inheritdoc /> + protected override Size MeasureOverride(Size availableSize) + { + var childAvailableSize = new Size( + availableSize.Width - Padding.Left - Padding.Right, + availableSize.Height - Padding.Top - Padding.Bottom); + foreach (var child in Children) + { + child.Measure(childAvailableSize); + } + + var requiredSize = UpdateRows(availableSize); + return requiredSize; + } + + /// <inheritdoc /> + protected override Size ArrangeOverride(Size finalSize) + { + if ((Orientation == Orientation.Horizontal && finalSize.Width < DesiredSize.Width) || + (Orientation == Orientation.Vertical && finalSize.Height < DesiredSize.Height)) + { + // We haven't received our desired size. We need to refresh the rows. + UpdateRows(finalSize); + } + + if (_rows.Count > 0) + { + // Now that we have all the data, we do the actual arrange pass + var childIndex = 0; + foreach (var row in _rows) + { + foreach (var rect in row.ChildrenRects) + { + var child = Children[childIndex++]; + while (child.Visibility == Visibility.Collapsed) + { + // Collapsed children are not added into the rows, + // we skip them. + child = Children[childIndex++]; + } + + var arrangeRect = new UvRect + { + Position = rect.Position, + Size = new UvMeasure { U = rect.Size.U, V = row.Size.V }, + }; + + var finalRect = arrangeRect.ToRect(Orientation); + child.Arrange(finalRect); + } + } + } + + return finalSize; + } + + private Size UpdateRows(Size availableSize) + { + _rows.Clear(); + + var paddingStart = new UvMeasure(Orientation, Padding.Left, Padding.Top); + var paddingEnd = new UvMeasure(Orientation, Padding.Right, Padding.Bottom); + + if (Children.Count == 0) + { + return paddingStart.Add(paddingEnd).ToSize(Orientation); + } + + var parentMeasure = new UvMeasure(Orientation, availableSize.Width, availableSize.Height); + var spacingMeasure = new UvMeasure(Orientation, HorizontalSpacing, VerticalSpacing); + var position = new UvMeasure(Orientation, Padding.Left, Padding.Top); + + var currentRow = new Row(new List<UvRect>(), default); + var finalMeasure = new UvMeasure(Orientation, width: 0.0, height: 0.0); + + void CommitRow() + { + // Only adds if the row has a content + if (currentRow.ChildrenRects.Count > 0) + { + _rows.Add(currentRow); + + position.V += currentRow.Size.V + spacingMeasure.V; + } + + position.U = paddingStart.U; + + currentRow = new Row(new List<UvRect>(), default); + } + + void Arrange(UIElement child, bool isLast = false) + { + if (child.Visibility == Visibility.Collapsed) + { + return; + } + + var isFullLine = GetIsFullLine(child); + var desiredMeasure = new UvMeasure(Orientation, child.DesiredSize); + + if (isFullLine) + { + if (currentRow.ChildrenRects.Count > 0) + { + CommitRow(); + } + + // Forces the width to fill all the available space + // (Total width - Padding Left - Padding Right) + desiredMeasure.U = parentMeasure.U - paddingStart.U - paddingEnd.U; + + // Adds the Section Header to the row + currentRow.Add(position, desiredMeasure); + + // Updates the global measures + position.U += desiredMeasure.U + spacingMeasure.U; + finalMeasure.U = Math.Max(finalMeasure.U, position.U); + + CommitRow(); + } + else + { + // Checks if the item can fit in the row + if ((desiredMeasure.U + position.U + paddingEnd.U) > parentMeasure.U) + { + CommitRow(); + } + + if (isLast) + { + desiredMeasure.U = parentMeasure.U - position.U; + } + + currentRow.Add(position, desiredMeasure); + + position.U += desiredMeasure.U + spacingMeasure.U; + finalMeasure.U = Math.Max(finalMeasure.U, position.U); + } + } + + var lastIndex = Children.Count - 1; + for (var i = 0; i < lastIndex; i++) + { + Arrange(Children[i]); + } + + Arrange(Children[lastIndex], StretchChild == ToolkitStretchChild.Last); + + if (currentRow.ChildrenRects.Count > 0) + { + _rows.Add(currentRow); + } + + if (_rows.Count == 0) + { + return paddingStart.Add(paddingEnd).ToSize(Orientation); + } + + var lastRowRect = _rows.Last().Rect; + finalMeasure.V = lastRowRect.Position.V + lastRowRect.Size.V; + return finalMeasure.Add(paddingEnd).ToSize(Orientation); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContentTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContentTemplateSelector.cs index ddcf4e0de5..9f05246597 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContentTemplateSelector.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContentTemplateSelector.cs @@ -2,6 +2,7 @@ // 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.ViewModels; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs new file mode 100644 index 0000000000..09fa902a4b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContextItemTemplateSelector.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.CmdPal.UI; + +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +internal sealed partial class ContextItemTemplateSelector : DataTemplateSelector +{ + public DataTemplate? Default { get; set; } + + public DataTemplate? Critical { get; set; } + + public DataTemplate? Separator { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject) + { + DataTemplate? dataTemplate = Default; + + if (dependencyObject is ListViewItem li) + { + li.IsEnabled = true; + + if (item is SeparatorViewModel) + { + li.IsEnabled = false; + li.AllowFocusWhenDisabled = false; + li.AllowFocusOnInteraction = false; + dataTemplate = Separator; + } + else if (item is CommandContextItemViewModel commandItem) + { + dataTemplate = commandItem.IsCritical ? Critical : Default; + } + else + { + // Fallback for unknown types + dataTemplate = Default; + } + } + + return dataTemplate; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs new file mode 100644 index 0000000000..5f54682aaf --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ContrastBrushConverter.cs @@ -0,0 +1,121 @@ +// 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; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Converters; + +/// <summary> +/// Gets a color, either black or white, depending on the brightness of the supplied color. +/// </summary> +public sealed partial class ContrastBrushConverter : IValueConverter +{ + /// <summary> + /// Gets or sets the alpha channel threshold below which a default color is used instead of black/white. + /// </summary> + public byte AlphaThreshold { get; set; } = 128; + + /// <inheritdoc /> + public object Convert( + object value, + Type targetType, + object parameter, + string language) + { + Color comparisonColor; + Color? defaultColor = null; + + // Get the changing color to compare against + if (value is Color valueColor) + { + comparisonColor = valueColor; + } + else if (value is SolidColorBrush valueBrush) + { + comparisonColor = valueBrush.Color; + } + else + { + // Invalid color value provided + return DependencyProperty.UnsetValue; + } + + // Get the default color when transparency is high + if (parameter is Color parameterColor) + { + defaultColor = parameterColor; + } + else if (parameter is SolidColorBrush parameterBrush) + { + defaultColor = parameterBrush.Color; + } + + if (comparisonColor.A < AlphaThreshold && + defaultColor.HasValue) + { + // If the transparency is less than 50 %, just use the default brush + // This can commonly be something like the TextControlForeground brush + return new SolidColorBrush(defaultColor.Value); + } + else + { + // Chose a white/black brush based on contrast to the base color + return UseLightContrastColor(comparisonColor) + ? new SolidColorBrush(Colors.White) + : new SolidColorBrush(Colors.Black); + } + } + + /// <inheritdoc /> + public object ConvertBack( + object value, + Type targetType, + object parameter, + string language) + { + return DependencyProperty.UnsetValue; + } + + /// <summary> + /// Determines whether a light or dark contrast color should be used with the given displayed color. + /// </summary> + /// <remarks> + /// This code is using the WinUI algorithm. + /// </remarks> + private bool UseLightContrastColor(Color displayedColor) + { + // The selection ellipse should be light if and only if the chosen color + // contrasts more with black than it does with white. + // To find how much something contrasts with white, we use the equation + // for relative luminance, which is given by + // + // L = 0.2126 * Rg + 0.7152 * Gg + 0.0722 * Bg + // + // where Xg = { X/3294 if X <= 10, (R/269 + 0.0513)^2.4 otherwise } + // + // If L is closer to 1, then the color is closer to white; if it is closer to 0, + // then the color is closer to black. This is based on the fact that the human + // eye perceives green to be much brighter than red, which in turn is perceived to be + // brighter than blue. + // + // If the third dimension is value, then we won't be updating the spectrum's displayed colors, + // so in that case we should use a value of 1 when considering the backdrop + // for the selection ellipse. + var rg = displayedColor.R <= 10 + ? displayedColor.R / 3294.0 + : Math.Pow((displayedColor.R / 269.0) + 0.0513, 2.4); + var gg = displayedColor.G <= 10 + ? displayedColor.G / 3294.0 + : Math.Pow((displayedColor.G / 269.0) + 0.0513, 2.4); + var bg = displayedColor.B <= 10 + ? displayedColor.B / 3294.0 + : Math.Pow((displayedColor.B / 269.0) + 0.0513, 2.4); + + return (0.2126 * rg) + (0.7152 * gg) + (0.0722 * bg) <= 0.5; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsDataTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsDataTemplateSelector.cs index 328e0ca01e..58ca7c783d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsDataTemplateSelector.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsDataTemplateSelector.cs @@ -2,7 +2,7 @@ // 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.UI.ViewModels; +using Microsoft.CmdPal.Core.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -18,6 +18,8 @@ public partial class DetailsDataTemplateSelector : DataTemplateSelector public DataTemplate? TagTemplate { get; set; } + public DataTemplate? CommandTemplate { get; set; } + protected override DataTemplate? SelectTemplateCore(object item) { if (item is DetailsElementViewModel element) @@ -27,6 +29,7 @@ public partial class DetailsDataTemplateSelector : DataTemplateSelector { DetailsSeparatorViewModel => SeparatorTemplate, DetailsLinkViewModel => LinkTemplate, + DetailsCommandsViewModel => CommandTemplate, DetailsTagsViewModel => TagTemplate, _ => null, }; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs new file mode 100644 index 0000000000..033c03a0b9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/DetailsSizeToGridLengthConverter.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.CmdPal.UI; + +public partial class DetailsSizeToGridLengthConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is ContentSize size) + { + // This converter calculates the Star width for the LIST. + // + // The input 'size' (ContentSize) represents the TARGET WIDTH desired for the DETAILS PANEL. + // + // To ensure the Details Panel achieves its target size (e.g. ContentSize.Large), + // we must shrink the List and let the Details fill the available space. + // (e.g., A larger target size for Details results in a smaller Star value for the List). + var starValue = size switch + { + ContentSize.Small => 3.0, + ContentSize.Medium => 2.0, + ContentSize.Large => 1.0, + _ => 3.0, + }; + + return new GridLength(starValue, GridUnitType.Star); + } + + return new GridLength(3.0, GridUnitType.Star); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs new file mode 100644 index 0000000000..80f2fd2f9f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/FilterTemplateSelector.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +internal sealed partial class FilterTemplateSelector : DataTemplateSelector +{ + public DataTemplate? Default { get; set; } + + public DataTemplate? Separator { get; set; } + + [DynamicDependency(DynamicallyAccessedMemberTypes.All, "Microsoft.UI.Xaml.Controls.ComboBoxItem", "Microsoft.WinUI")] + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject) + { + DataTemplate? dataTemplate = Default; + + if (dependencyObject is ComboBoxItem comboBoxItem) + { + comboBoxItem.IsEnabled = true; + + if (item is SeparatorViewModel) + { + comboBoxItem.IsEnabled = false; + comboBoxItem.AllowFocusWhenDisabled = false; + comboBoxItem.AllowFocusOnInteraction = false; + dataTemplate = Separator; + } + } + + return dataTemplate; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemContainerStyleSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemContainerStyleSelector.cs new file mode 100644 index 0000000000..dc8d0d0d77 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemContainerStyleSelector.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 Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +internal sealed partial class GridItemContainerStyleSelector : StyleSelector +{ + public IGridPropertiesViewModel? GridProperties { get; set; } + + public Style? Small { get; set; } + + public Style? Medium { get; set; } + + public Style? Gallery { get; set; } + + public Style? Section { get; set; } + + public Style? Separator { get; set; } + + protected override Style? SelectStyleCore(object item, DependencyObject container) + { + if (item is not ListItemViewModel element) + { + return Medium; + } + + switch (element.Type) + { + case ListItemType.Separator: + return Separator; + case ListItemType.SectionHeader: + return Section; + default: + break; + } + + return GridProperties switch + { + SmallGridPropertiesViewModel => Small, + MediumGridPropertiesViewModel => Medium, + GalleryGridPropertiesViewModel => Gallery, + _ => Medium, + }; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs new file mode 100644 index 0000000000..1e568981c7 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +internal sealed partial class GridItemTemplateSelector : DataTemplateSelector +{ + public IGridPropertiesViewModel? GridProperties { get; set; } + + public DataTemplate? Small { get; set; } + + public DataTemplate? Medium { get; set; } + + public DataTemplate? Gallery { get; set; } + + public DataTemplate? Section { get; set; } + + public DataTemplate? Separator { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject) + { + if (item is not ListItemViewModel element) + { + return Medium; + } + + switch (element.Type) + { + case ListItemType.Separator: + return Separator; + case ListItemType.SectionHeader: + return Section; + default: + break; + } + + return GridProperties switch + { + SmallGridPropertiesViewModel => Small, + MediumGridPropertiesViewModel => Medium, + GalleryGridPropertiesViewModel => Gallery, + _ => Medium, + }; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemContainerStyleSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemContainerStyleSelector.cs new file mode 100644 index 0000000000..dd2d515417 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemContainerStyleSelector.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +internal sealed partial class ListItemContainerStyleSelector : StyleSelector +{ + public Style? Default { get; set; } + + public Style? Section { get; set; } + + public Style? Separator { get; set; } + + protected override Style? SelectStyleCore(object item, DependencyObject container) + { + if (item is not ListItemViewModel element) + { + return Default; + } + + switch (element.Type) + { + case ListItemType.Separator: + return Separator; + case ListItemType.SectionHeader: + return Section; + case ListItemType.Item: + default: + return Default; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs new file mode 100644 index 0000000000..47e12c148a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +public sealed partial class ListItemTemplateSelector : DataTemplateSelector +{ + public DataTemplate? ListItem { get; set; } + + public DataTemplate? Separator { get; set; } + + public DataTemplate? Section { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container) + { + if (item is not ListItemViewModel element) + { + return ListItem; + } + + switch (element.Type) + { + case ListItemType.Separator: + return Separator; + case ListItemType.SectionHeader: + return Section; + case ListItemType.Item: + default: + return ListItem; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/BeginInvoke.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/BeginInvoke.cs index d6e8fcd423..54dd49fdc0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/BeginInvoke.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/BeginInvoke.cs @@ -2,15 +2,21 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; -using Microsoft.CommandPalette.Extensions; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.CmdPal.UI.Events; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class BeginInvoke : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public BeginInvoke() + { + EventName = "CmdPal_BeginInvoke"; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalDismissedOnEsc.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalDismissedOnEsc.cs index 3e550639ad..2a6b74e0ef 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalDismissedOnEsc.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalDismissedOnEsc.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; using Microsoft.PowerToys.Telemetry; @@ -10,6 +11,7 @@ using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.CmdPal.UI.Events; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class CmdPalDismissedOnEsc : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalDismissedOnLostFocus.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalDismissedOnLostFocus.cs index 0b760a8c36..77942dc213 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalDismissedOnLostFocus.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalDismissedOnLostFocus.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; using Microsoft.CommandPalette.Extensions; using Microsoft.PowerToys.Telemetry; @@ -10,6 +11,7 @@ using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.CmdPal.UI.Events; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class CmdPalDismissedOnLostFocus : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalExtensionInvoked.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalExtensionInvoked.cs new file mode 100644 index 0000000000..0113a4ad27 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalExtensionInvoked.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.CmdPal.UI.Events; + +/// <summary> +/// Tracks extension usage with extension name and invocation details. +/// Purpose: Identify popular vs. unused plugins and track extension performance. +/// </summary> +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalExtensionInvoked : EventBase, IEvent +{ + /// <summary> + /// Gets or sets the unique identifier of the extension provider. + /// </summary> + public string ExtensionId { get; set; } + + /// <summary> + /// Gets or sets the non-localized identifier of the command being invoked. + /// </summary> + public string CommandId { get; set; } + + /// <summary> + /// Gets or sets the localized display name of the command being invoked. + /// </summary> + public string CommandName { get; set; } + + /// <summary> + /// Gets or sets whether the command executed successfully. + /// </summary> + public bool Success { get; set; } + + /// <summary> + /// Gets or sets the execution time in milliseconds. + /// </summary> + public ulong ExecutionTimeMs { get; set; } + + public CmdPalExtensionInvoked(string extensionId, string commandId, string commandName, bool success, ulong executionTimeMs) + { + EventName = "CmdPal_ExtensionInvoked"; + ExtensionId = extensionId; + CommandId = commandId; + CommandName = commandName; + Success = success; + ExecutionTimeMs = executionTimeMs; + } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalHotkeySummoned.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalHotkeySummoned.cs index 2023783151..581e15cc71 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalHotkeySummoned.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalHotkeySummoned.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; using Microsoft.CommandPalette.Extensions; using Microsoft.PowerToys.Telemetry; @@ -10,6 +11,7 @@ using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.CmdPal.UI.Events; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class CmdPalHotkeySummoned : EventBase, IEvent { public bool Global { get; set; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalInvokeResult.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalInvokeResult.cs index a1b30a8b96..287471f977 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalInvokeResult.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalInvokeResult.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; using Microsoft.CommandPalette.Extensions; using Microsoft.PowerToys.Telemetry; @@ -10,12 +11,14 @@ using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.CmdPal.UI.Events; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class CmdPalInvokeResult : EventBase, IEvent { public string ResultKind { get; set; } public CmdPalInvokeResult(CommandResultKind resultKind) { + EventName = "CmdPal_InvokeResult"; ResultKind = resultKind.ToString(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalProcessStarted.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalProcessStarted.cs index 322dd60319..9dada92d8c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalProcessStarted.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalProcessStarted.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; using Microsoft.CommandPalette.Extensions; using Microsoft.PowerToys.Telemetry; @@ -10,6 +11,7 @@ using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.CmdPal.UI.Events; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class CmdPalProcessStarted : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSessionDuration.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSessionDuration.cs new file mode 100644 index 0000000000..357cb9db53 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSessionDuration.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.CmdPal.UI.Events; + +/// <summary> +/// Tracks Command Palette session duration from launch to close. +/// Purpose: Understand user engagement patterns - quick actions vs. browsing behavior. +/// </summary> +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalSessionDuration : EventBase, IEvent +{ + /// <summary> + /// Gets or sets the session duration in milliseconds. + /// </summary> + public ulong DurationMs { get; set; } + + /// <summary> + /// Gets or sets the number of commands executed during the session. + /// </summary> + public int CommandsExecuted { get; set; } + + /// <summary> + /// Gets or sets the number of pages visited during the session. + /// </summary> + public int PagesVisited { get; set; } + + /// <summary> + /// Gets or sets the reason for dismissal (Escape, LostFocus, Command, etc.). + /// </summary> + public string DismissalReason { get; set; } + + /// <summary> + /// Gets or sets the number of search queries executed during the session. + /// </summary> + public int SearchQueriesCount { get; set; } + + /// <summary> + /// Gets or sets the maximum navigation depth reached during the session. + /// </summary> + public int MaxNavigationDepth { get; set; } + + /// <summary> + /// Gets or sets the number of errors encountered during the session. + /// </summary> + public int ErrorCount { get; set; } + + public CmdPalSessionDuration(ulong durationMs, int commandsExecuted, int pagesVisited, string dismissalReason, int searchQueriesCount, int maxNavigationDepth, int errorCount) + { + EventName = "CmdPal_SessionDuration"; + DurationMs = durationMs; + CommandsExecuted = commandsExecuted; + PagesVisited = pagesVisited; + DismissalReason = dismissalReason; + SearchQueriesCount = searchQueriesCount; + MaxNavigationDepth = maxNavigationDepth; + ErrorCount = errorCount; + } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/ColdLaunch.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/ColdLaunch.cs index cc85a4ec3c..ec45f8bf54 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/ColdLaunch.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/ColdLaunch.cs @@ -2,15 +2,21 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; -using Microsoft.CommandPalette.Extensions; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.CmdPal.UI.Events; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class ColdLaunch : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public ColdLaunch() + { + EventName = "CmdPal_ColdLaunch"; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs index 6510c06651..ac941b7724 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/OpenPage.cs @@ -2,21 +2,27 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; -using Microsoft.CommandPalette.Extensions; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.CmdPal.UI.Events; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class OpenPage : EventBase, IEvent { public int PageDepth { get; set; } - public OpenPage(int pageDepth) + public string Id { get; set; } + + public OpenPage(int pageDepth, string id) { PageDepth = pageDepth; + Id = id; + + EventName = "CmdPal_OpenPage"; } public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/ReactivateInstance.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/ReactivateInstance.cs index 7387881f26..e502899786 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/ReactivateInstance.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/ReactivateInstance.cs @@ -2,15 +2,21 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; -using Microsoft.CommandPalette.Extensions; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.CmdPal.UI.Events; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class ReactivateInstance : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public ReactivateInstance() + { + EventName = "CmdPal_ReactivateInstance"; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs new file mode 100644 index 0000000000..f5889ab05c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.CmdPal.UI.Events; + +// Just put all the run events in one file for simplicity. +#pragma warning disable SA1402 // File may only contain a single type +#pragma warning disable SA1649 // File name should match first type name + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalRunQuery : EventBase, IEvent +{ + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public string Query { get; set; } + + public int ResultCount { get; set; } + + public ulong DurationMs { get; set; } + + public CmdPalRunQuery(string query, int resultCount, ulong durationMs) + { + EventName = "CmdPal_RunQuery"; + Query = query; + ResultCount = resultCount; + DurationMs = durationMs; + } +} + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalRunCommand : EventBase, IEvent +{ + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public string Command { get; set; } + + public bool AsAdmin { get; set; } + + public bool Success { get; set; } + + public CmdPalRunCommand(string command, bool asAdmin, bool success) + { + EventName = "CmdPal_RunCommand"; + Command = command; + AsAdmin = asAdmin; + Success = success; + } +} + +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalOpenUri : EventBase, IEvent +{ + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public string Uri { get; set; } + + public bool IsWeb { get; set; } + + public bool Success { get; set; } + + public CmdPalOpenUri(string uri, bool isWeb, bool success) + { + EventName = "CmdPal_OpenUri"; + Uri = uri; + IsWeb = isWeb; + Success = success; + } +} + +#pragma warning restore SA1649 // File name should match first type name +#pragma warning restore SA1402 // File may only contain a single type diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/CleanupHelper.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/CleanupHelper.xaml.cs index 3436e46481..1b8f35a4e0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/CleanupHelper.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/CleanupHelper.xaml.cs @@ -2,16 +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.Diagnostics; -using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels; -using Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; namespace Microsoft.CmdPal.UI; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml index 8cc1174a1b..a5b4d47dba 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml @@ -9,16 +9,28 @@ xmlns:cmdpalUI="using:Microsoft.CmdPal.UI" xmlns:controls="using:CommunityToolkit.WinUI.Controls" xmlns:converters="using:CommunityToolkit.WinUI.Converters" + xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:Microsoft.CmdPal.UI" + xmlns:markdownImageProviders="using:Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:toolkit="using:CommunityToolkit.WinUI.UI.Controls" - xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" Background="Transparent" mc:Ignorable="d"> <Page.Resources> <ResourceDictionary> + <tkcontrols:MarkdownThemes + x:Key="DefaultMarkdownThemeConfig" + H3FontSize="12" + H3FontWeight="Normal" /> + <markdownImageProviders:ImageProvider x:Key="ImageProvider" /> + <tkcontrols:MarkdownConfig + x:Key="DefaultMarkdownConfig" + ImageProvider="{StaticResource ImageProvider}" + Themes="{StaticResource DefaultMarkdownThemeConfig}" /> + <StackLayout x:Name="VerticalStackLayout" Orientation="Vertical" @@ -35,43 +47,35 @@ MarkdownTemplate="{StaticResource NestedMarkdownContentTemplate}" TreeTemplate="{StaticResource TreeContentTemplate}" /> - <DataTemplate x:Key="FormContentTemplate" x:DataType="viewmodels:ContentFormViewModel"> + <DataTemplate x:Key="FormContentTemplate" x:DataType="viewModels:ContentFormViewModel"> <Grid Margin="0,4,4,4" Padding="12,8,8,8"> <cmdPalControls:ContentFormControl ViewModel="{x:Bind}" /> </Grid> </DataTemplate> - <DataTemplate x:Key="MarkdownContentTemplate" x:DataType="viewmodels:ContentMarkdownViewModel"> + <DataTemplate x:Key="MarkdownContentTemplate" x:DataType="viewModels:ContentMarkdownViewModel"> <Grid Margin="0,4,4,4" Padding="12,8,8,8"> - <toolkit:MarkdownTextBlock - Background="Transparent" - Header3FontSize="12" - Header3FontWeight="Normal" - Header3Foreground="{ThemeResource TextFillColorSecondaryBrush}" - IsTextSelectionEnabled="True" - Text="{x:Bind Body, Mode=OneWay}" /> + <tkcontrols:MarkdownTextBlock + Config="{StaticResource DefaultMarkdownConfig}" + Text="{x:Bind Body, Mode=OneWay}" + UseEmphasisExtras="True" + UsePipeTables="True" /> </Grid> </DataTemplate> - <DataTemplate x:Key="NestedFormContentTemplate" x:DataType="viewmodels:ContentFormViewModel"> + <DataTemplate x:Key="NestedFormContentTemplate" x:DataType="viewModels:ContentFormViewModel"> <Grid> <cmdPalControls:ContentFormControl ViewModel="{x:Bind}" /> </Grid> </DataTemplate> - <DataTemplate x:Key="NestedMarkdownContentTemplate" x:DataType="viewmodels:ContentMarkdownViewModel"> + <DataTemplate x:Key="NestedMarkdownContentTemplate" x:DataType="viewModels:ContentMarkdownViewModel"> <Grid> - <toolkit:MarkdownTextBlock - Background="Transparent" - Header3FontSize="12" - Header3FontWeight="Normal" - Header3Foreground="{ThemeResource TextFillColorSecondaryBrush}" - IsTextSelectionEnabled="True" - Text="{x:Bind Body, Mode=OneWay}" /> + <tkcontrols:MarkdownTextBlock Config="{StaticResource DefaultMarkdownConfig}" Text="{x:Bind Body, Mode=OneWay}" /> </Grid> </DataTemplate> - <DataTemplate x:Key="TreeContentTemplate" x:DataType="viewmodels:ContentTreeViewModel"> + <DataTemplate x:Key="TreeContentTemplate" x:DataType="viewModels:ContentTreeViewModel"> <StackPanel Margin="0,4,4,4" Padding="12,8,8,8" @@ -108,7 +112,7 @@ </ResourceDictionary> </Page.Resources> - <Grid Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"> + <Grid> <ScrollView VerticalAlignment="Top" VerticalScrollMode="Enabled"> <ItemsRepeater VerticalAlignment="Stretch" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs index 1b53a41339..c022d82b34 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.UI.ViewModels; -using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -34,15 +34,38 @@ public sealed partial class ContentPage : Page, public ContentPage() { this.InitializeComponent(); - WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this); - WeakReferenceMessenger.Default.Register<ActivateSecondaryCommandMessage>(this); + this.Unloaded += OnUnloaded; + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + // Unhook from everything to ensure nothing can reach us + // between this point and our complete and utter destruction. + WeakReferenceMessenger.Default.UnregisterAll(this); } protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.Parameter is ContentPageViewModel vm) + if (e.Parameter is not AsyncNavigationRequest navigationRequest) { - ViewModel = vm; + throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}"); + } + + if (navigationRequest.TargetViewModel is not ContentPageViewModel contentPageViewModel) + { + throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ContentPageViewModel)}"); + } + + ViewModel = contentPageViewModel; + + if (!WeakReferenceMessenger.Default.IsRegistered<ActivateSelectedListItemMessage>(this)) + { + WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this); + } + + if (!WeakReferenceMessenger.Default.IsRegistered<ActivateSecondaryCommandMessage>(this)) + { + WeakReferenceMessenger.Default.Register<ActivateSecondaryCommandMessage>(this); } base.OnNavigatedTo(e); @@ -55,6 +78,12 @@ public sealed partial class ContentPage : Page, WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(this); // Clean-up event listeners + if (e.NavigationMode != NavigationMode.New) + { + ViewModel?.SafeCleanup(); + CleanupHelper.Cleanup(this); + } + ViewModel = null; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml index 395bd0ebe6..022b214c79 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml @@ -3,46 +3,288 @@ x:Class="Microsoft.CmdPal.UI.ListPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:cmdpalUI="using:Microsoft.CmdPal.UI" xmlns:controls="using:CommunityToolkit.WinUI.Controls" - xmlns:converters="using:CommunityToolkit.WinUI.Converters" + xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels" xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:help="using:Microsoft.CmdPal.UI.Helpers" - xmlns:local="using:Microsoft.CmdPal.UI" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels" + x:Name="PageRoot" Background="Transparent" + DataContext="{x:Bind ViewModel, Mode=OneWay}" mc:Ignorable="d"> <Page.Resources> - <!-- TODO: Figure out what we want to do here for filtering/grouping and where --> - <!-- https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.data.collectionviewsource --> - <!--<CollectionViewSource - x:Name="ItemsCVS" - IsSourceGrouped="True" - Source="{x:Bind ViewModel.Items, Mode=OneWay}" />--> - <converters:StringVisibilityConverter - x:Key="StringVisibilityConverter" - EmptyValue="Collapsed" - NotEmptyValue="Visible" /> + <!-- + GridViewItemCornerRadius is the corner radius defined in GridView template; make + it bigger to match the radii of the gallery + --> + <CornerRadius x:Key="GalleryGridViewItemContainerCornerRadius">6</CornerRadius> + <CornerRadius x:Key="IconGridViewItemContainerCornerRadius">4</CornerRadius> + <CornerRadius x:Key="GalleryGridViewItemRadius">4</CornerRadius> + <CornerRadius x:Key="SmallGridViewItemCornerRadius">8</CornerRadius> + <CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius> - <DataTemplate x:Key="TagTemplate" x:DataType="viewmodels:TagViewModel"> - <ItemContainer> - <cpcontrols:Tag - BackgroundColor="{x:Bind Background, Mode=OneWay}" - FontSize="12" - ForegroundColor="{x:Bind Foreground, Mode=OneWay}" - Icon="{x:Bind Icon, Mode=OneWay}" - Text="{x:Bind Text, Mode=OneWay}" - ToolTipService.ToolTip="{x:Bind ToolTip, Mode=OneWay}" /> - </ItemContainer> + <x:Double x:Key="SmallGridSize">32</x:Double> + <x:Double x:Key="MediumGridSize">48</x:Double> + <x:Double x:Key="MediumGridContainerSize">100</x:Double> + <x:Double x:Key="GalleryGridSize">160</x:Double> + + <x:Double x:Key="ListViewItemMinHeight">40</x:Double> + <x:Double x:Key="ListViewSectionMinHeight">0</x:Double> + <x:Double x:Key="ListViewSeparatorMinHeight">0</x:Double> + + <Style x:Key="IconGridViewItemStyle" TargetType="GridViewItem"> + <Setter Property="HorizontalContentAlignment" Value="Stretch" /> + <Setter Property="VerticalContentAlignment" Value="Center" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="GridViewItem"> + <ListViewItemPresenter + x:Name="Root" + HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}" + CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}" + CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}" + CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}" + CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}" + CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}" + CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}" + CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}" + CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}" + CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}" + CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}" + CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}" + CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}" + CheckBrush="{ThemeResource GridViewItemCheckBrush}" + CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}" + CheckMode="{ThemeResource GridViewItemCheckMode}" + CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}" + ContentMargin="{TemplateBinding Padding}" + ContentTransitions="{TemplateBinding ContentTransitions}" + Control.IsTemplateFocusTarget="True" + CornerRadius="{StaticResource IconGridViewItemContainerCornerRadius}" + DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}" + DragBackground="{ThemeResource GridViewItemDragBackground}" + DragForeground="{ThemeResource GridViewItemDragForeground}" + DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}" + FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}" + FocusVisualMargin="{TemplateBinding FocusVisualMargin}" + FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}" + FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}" + FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}" + FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}" + PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}" + PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}" + PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}" + PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}" + PressedBackground="{ThemeResource GridViewItemBackgroundPressed}" + ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}" + SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}" + SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}" + SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}" + SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}" + SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}" + SelectedForeground="{ThemeResource GridViewItemForegroundSelected}" + SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}" + SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}" + SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}" + SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}" + SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}" + SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" /> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style x:Key="GalleryGridViewItemStyle" TargetType="GridViewItem"> + <Setter Property="HorizontalContentAlignment" Value="Stretch" /> + <Setter Property="VerticalContentAlignment" Value="Center" /> + <Setter Property="Margin" Value="0" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="GridViewItem"> + <ListViewItemPresenter + x:Name="Root" + HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" + CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}" + CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}" + CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}" + CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}" + CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}" + CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}" + CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}" + CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}" + CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}" + CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}" + CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}" + CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}" + CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}" + CheckBrush="{ThemeResource GridViewItemCheckBrush}" + CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}" + CheckMode="{ThemeResource GridViewItemCheckMode}" + CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}" + ContentMargin="{TemplateBinding Padding}" + ContentTransitions="{TemplateBinding ContentTransitions}" + Control.IsTemplateFocusTarget="True" + CornerRadius="{StaticResource GalleryGridViewItemContainerCornerRadius}" + DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}" + DragBackground="{ThemeResource GridViewItemDragBackground}" + DragForeground="{ThemeResource GridViewItemDragForeground}" + DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}" + FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}" + FocusVisualMargin="{TemplateBinding FocusVisualMargin}" + FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}" + FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}" + FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}" + FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}" + PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}" + PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}" + PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}" + PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}" + PressedBackground="{ThemeResource GridViewItemBackgroundPressed}" + ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}" + SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}" + SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}" + SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}" + SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}" + SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}" + SelectedForeground="{ThemeResource GridViewItemForegroundSelected}" + SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}" + SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}" + SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}" + SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}" + SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}" + SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" /> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <Style + x:Key="GridViewSectionItemStyle" + BasedOn="{StaticResource DefaultGridViewItemStyle}" + TargetType="GridViewItem"> + <Setter Property="IsHitTestVisible" Value="False" /> + <Setter Property="IsTabStop" Value="False" /> + <Setter Property="IsHoldingEnabled" Value="False" /> + <Setter Property="Padding" Value="4,8,12,0" /> + <Setter Property="Margin" Value="0" /> + <Setter Property="HorizontalContentAlignment" Value="Stretch" /> + <Setter Property="VerticalContentAlignment" Value="Bottom" /> + <Setter Property="MinHeight" Value="{StaticResource ListViewSectionMinHeight}" /> + <Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" /> + </Style> + + <Style + x:Key="GridViewSeparatorItemStyle" + BasedOn="{StaticResource DefaultGridViewItemStyle}" + TargetType="GridViewItem"> + <Setter Property="IsHitTestVisible" Value="False" /> + <Setter Property="IsTabStop" Value="False" /> + <Setter Property="IsHoldingEnabled" Value="False" /> + <Setter Property="Padding" Value="4,4,12,4" /> + <Setter Property="Margin" Value="0" /> + <Setter Property="HorizontalContentAlignment" Value="Stretch" /> + <Setter Property="VerticalContentAlignment" Value="Center" /> + <Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorMinHeight}" /> + <Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" /> + </Style> + + <Style + x:Key="ListDefaultContainerStyle" + BasedOn="{StaticResource DefaultListViewItemStyle}" + TargetType="ListViewItem"> + <Setter Property="MinHeight" Value="{StaticResource ListViewItemMinHeight}" /> + </Style> + + <Style + x:Key="ListSectionContainerStyle" + BasedOn="{StaticResource DefaultListViewItemStyle}" + TargetType="ListViewItem"> + <Setter Property="IsEnabled" Value="False" /> + <Setter Property="AllowFocusWhenDisabled" Value="False" /> + <Setter Property="AllowFocusOnInteraction" Value="False" /> + <Setter Property="IsHitTestVisible" Value="False" /> + <Setter Property="IsTabStop" Value="False" /> + <Setter Property="IsHoldingEnabled" Value="False" /> + <Setter Property="AllowDrop" Value="False" /> + <Setter Property="Padding" Value="16,8,12,0" /> + <Setter Property="HorizontalContentAlignment" Value="Stretch" /> + <Setter Property="VerticalContentAlignment" Value="Bottom" /> + <Setter Property="MinHeight" Value="{StaticResource ListViewSectionMinHeight}" /> + </Style> + + <Style + x:Key="ListSeparatorContainerStyle" + BasedOn="{StaticResource DefaultListViewItemStyle}" + TargetType="ListViewItem"> + <Setter Property="IsEnabled" Value="False" /> + <Setter Property="AllowFocusWhenDisabled" Value="False" /> + <Setter Property="AllowFocusOnInteraction" Value="False" /> + <Setter Property="IsHitTestVisible" Value="False" /> + <Setter Property="IsTabStop" Value="False" /> + <Setter Property="IsHoldingEnabled" Value="False" /> + <Setter Property="AllowDrop" Value="False" /> + <Setter Property="Padding" Value="16,4,12,4" /> + <Setter Property="HorizontalContentAlignment" Value="Stretch" /> + <Setter Property="VerticalContentAlignment" Value="Center" /> + <Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorMinHeight}" /> + </Style> + + <DataTemplate x:Key="TagTemplate" x:DataType="coreViewModels:TagViewModel"> + <cpcontrols:Tag + AutomationProperties.Name="{x:Bind Text, Mode=OneWay}" + BackgroundColor="{x:Bind Background, Mode=OneWay}" + FontSize="12" + ForegroundColor="{x:Bind Foreground, Mode=OneWay}" + Icon="{x:Bind Icon, Mode=OneWay}" + Text="{x:Bind Text, Mode=OneWay}" + ToolTipService.ToolTip="{x:Bind ToolTip, Mode=OneWay}" /> </DataTemplate> - <!-- https://learn.microsoft.com/windows/apps/design/controls/itemsview#specify-the-look-of-the-items --> - <DataTemplate x:Key="ListItemViewModelTemplate" x:DataType="viewmodels:ListItemViewModel"> + <cmdpalUI:ListItemTemplateSelector + x:Key="ListItemTemplateSelector" + x:DataType="coreViewModels:ListItemViewModel" + ListItem="{StaticResource ListItemViewModelTemplate}" + Section="{StaticResource ListSectionViewModelTemplate}" + Separator="{StaticResource ListSeparatorViewModelTemplate}" /> - <Grid Padding="0,12,0,12" ColumnSpacing="12"> + <cmdpalUI:ListItemContainerStyleSelector + x:Key="ListItemContainerStyleSelector" + Default="{StaticResource ListDefaultContainerStyle}" + Section="{StaticResource ListSectionContainerStyle}" + Separator="{StaticResource ListSeparatorContainerStyle}" /> + + <cmdpalUI:GridItemTemplateSelector + x:Key="GridItemTemplateSelector" + x:DataType="coreViewModels:ListItemViewModel" + Gallery="{StaticResource GalleryGridItemViewModelTemplate}" + GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}" + Medium="{StaticResource MediumGridItemViewModelTemplate}" + Section="{StaticResource ListSectionViewModelTemplate}" + Separator="{StaticResource GridSeparatorViewModelTemplate}" + Small="{StaticResource SmallGridItemViewModelTemplate}" /> + + <cmdpalUI:GridItemContainerStyleSelector + x:Key="GridItemContainerStyleSelector" + Gallery="{StaticResource GalleryGridViewItemStyle}" + GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}" + Medium="{StaticResource IconGridViewItemStyle}" + Section="{StaticResource GridViewSectionItemStyle}" + Separator="{StaticResource GridViewSeparatorItemStyle}" + Small="{StaticResource IconGridViewItemStyle}" /> + + <!-- https://learn.microsoft.com/windows/apps/design/controls/itemsview#specify-the-look-of-the-items --> + <DataTemplate x:Key="ListItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel"> + <Grid + Padding="0,12,0,12" + AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" + ColumnSpacing="12"> <Grid.ColumnDefinitions> <ColumnDefinition Width="28" /> <ColumnDefinition Width="*" /> @@ -55,9 +297,10 @@ Width="20" Height="20" Margin="4,0,4,0" + AutomationProperties.AccessibilityView="Raw" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> <StackPanel Grid.Column="1" @@ -78,7 +321,7 @@ Text="{x:Bind Subtitle, Mode=OneWay}" TextTrimming="CharacterEllipsis" TextWrapping="NoWrap" - Visibility="{x:Bind Subtitle, Mode=OneWay, Converter={StaticResource StringVisibilityConverter}}" /> + Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" /> </StackPanel> <ItemsControl @@ -99,6 +342,176 @@ </ItemsControl> </Grid> </DataTemplate> + + <DataTemplate x:Key="ListSeparatorViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel"> + <Grid ColumnSpacing="12"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="28" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Rectangle + Grid.Column="1" + Height="1" + Fill="{ThemeResource DividerStrokeColorDefaultBrush}" /> + </Grid> + </DataTemplate> + + <DataTemplate x:Key="ListSectionViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel"> + <Grid + Margin="0,8,0,0" + VerticalAlignment="Center" + cpcontrols:WrapPanel.IsFullLine="True" + ColumnSpacing="8" + IsTabStop="False" + IsTapEnabled="True"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <TextBlock + Grid.Column="0" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{ThemeResource CaptionTextBlockStyle}" + Text="{x:Bind Section}" /> + </Grid> + </DataTemplate> + + <!-- Grid item templates for visual grid representation --> + <DataTemplate x:Key="SmallGridItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel"> + <StackPanel + HorizontalAlignment="Center" + VerticalAlignment="Center" + AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" + BorderThickness="0" + CornerRadius="{StaticResource SmallGridViewItemCornerRadius}" + Orientation="Vertical" + ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}"> + + <cpcontrols:IconBox + x:Name="GridIconBorder" + Width="{StaticResource SmallGridSize}" + Height="{StaticResource SmallGridSize}" + Margin="0" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Foreground="{ThemeResource TextFillColorPrimary}" + SourceKey="{x:Bind Icon, Mode=OneWay}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested32}" /> + </StackPanel> + </DataTemplate> + + <DataTemplate x:Key="MediumGridItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel"> + <Grid + Width="{StaticResource MediumGridContainerSize}" + Height="{StaticResource MediumGridContainerSize}" + Padding="8" + AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" + CornerRadius="{StaticResource MediumGridViewItemCornerRadius}" + ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}"> + <Grid.RowDefinitions> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <cpcontrols:IconBox + x:Name="GridIconBorder" + Grid.Row="0" + Width="{StaticResource MediumGridSize}" + Height="{StaticResource MediumGridSize}" + HorizontalAlignment="Center" + VerticalAlignment="Center" + CharacterSpacing="12" + FontSize="14" + Foreground="{ThemeResource TextFillColorPrimary}" + SourceKey="{x:Bind Icon, Mode=OneWay}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}" /> + <TextBlock + x:Name="TitleTextBlock" + Grid.Row="1" + Height="32" + Margin="0,8,0,0" + HorizontalAlignment="Center" + CharacterSpacing="12" + FontSize="12" + Text="{x:Bind Title, Mode=OneWay}" + TextAlignment="Center" + TextTrimming="WordEllipsis" + TextWrapping="Wrap" + Visibility="{x:Bind LayoutShowsTitle, Mode=OneWay}" /> + </Grid> + </DataTemplate> + + <DataTemplate x:Key="GalleryGridItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel"> + <StackPanel + Width="{StaticResource GalleryGridSize}" + Margin="4" + Padding="0" + HorizontalAlignment="Center" + VerticalAlignment="Center" + AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" + BorderThickness="0" + CornerRadius="{StaticResource GalleryGridViewItemRadius}" + Orientation="Vertical" + ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}"> + + <Grid + Width="{StaticResource GalleryGridSize}" + Height="{StaticResource GalleryGridSize}" + Margin="0" + HorizontalAlignment="Center" + VerticalAlignment="Center" + CornerRadius="{StaticResource GalleryGridViewItemRadius}"> + <Viewbox + HorizontalAlignment="Center" + Stretch="UniformToFill" + StretchDirection="Both"> + <cpcontrols:IconBox + CornerRadius="4" + Foreground="{ThemeResource TextFillColorPrimary}" + SourceKey="{x:Bind Icon, Mode=OneWay}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested256}" /> + </Viewbox> + </Grid> + + <StackPanel + Padding="4" + Orientation="Vertical" + Spacing="4" + Visibility="{x:Bind help:BindTransformers.VisibleWhenAny(ShowTitle, ShowSubtitle)}"> + <TextBlock + x:Name="TitleTextBlock" + MaxWidth="152" + MaxHeight="40" + HorizontalAlignment="Left" + VerticalAlignment="Center" + CharacterSpacing="12" + FontSize="14" + Foreground="{ThemeResource TextFillColorPrimary}" + Text="{x:Bind Title, Mode=OneWay}" + TextAlignment="Center" + TextTrimming="WordEllipsis" + TextWrapping="NoWrap" + Visibility="{x:Bind ShowTitle, Mode=OneWay}" /> + <TextBlock + x:Name="SubTitleTextBlock" + MaxWidth="152" + MaxHeight="40" + HorizontalAlignment="Left" + VerticalAlignment="Center" + CharacterSpacing="11" + FontSize="11" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind Subtitle, Mode=OneWay}" + TextAlignment="Center" + TextTrimming="WordEllipsis" + TextWrapping="NoWrap" + Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" /> + </StackPanel> + </StackPanel> + </DataTemplate> + + <DataTemplate x:Key="GridSeparatorViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel"> + <Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" /> + </DataTemplate> </Page.Resources> <Grid> @@ -107,59 +520,96 @@ TargetType="x:Boolean" Value="{x:Bind ViewModel.ShowEmptyContent, Mode=OneWay}"> <controls:Case Value="False"> - <ListView - x:Name="ItemsList" - Padding="0,2,0,0" - DoubleTapped="ItemsList_DoubleTapped" - IsDoubleTapEnabled="True" - IsItemClickEnabled="True" - ItemClick="ItemsList_ItemClick" - ItemTemplate="{StaticResource ListItemViewModelTemplate}" - ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}" - SelectionChanged="ItemsList_SelectionChanged"> - <ListView.ItemContainerTransitions> - <TransitionCollection /> - </ListView.ItemContainerTransitions> - <!--<ListView.GroupStyle> - <GroupStyle HidesIfEmpty="True"> - <GroupStyle.HeaderTemplate> - <DataTemplate> - <TextBlock - Margin="0,16,0,0" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Text="{Binding Key, Mode=OneWay}" /> - </DataTemplate> - </GroupStyle.HeaderTemplate> - </GroupStyle> - </ListView.GroupStyle>--> - </ListView> + <controls:SwitchPresenter + HorizontalAlignment="Stretch" + TargetType="x:Boolean" + Value="{x:Bind ViewModel.IsGridView, Mode=OneWay}"> + <controls:Case Value="False"> + <ListView + x:Name="ItemsList" + Padding="0,2,0,0" + CanDragItems="True" + ContextCanceled="Items_OnContextCanceled" + ContextRequested="Items_OnContextRequested" + DoubleTapped="Items_DoubleTapped" + DragItemsCompleted="Items_DragItemsCompleted" + DragItemsStarting="Items_DragItemsStarting" + IsDoubleTapEnabled="True" + IsItemClickEnabled="True" + ItemClick="Items_ItemClick" + ItemContainerStyleSelector="{StaticResource ListItemContainerStyleSelector}" + ItemTemplateSelector="{StaticResource ListItemTemplateSelector}" + ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}" + RightTapped="Items_RightTapped" + SelectionChanged="Items_SelectionChanged"> + <ListView.ItemContainerTransitions> + <TransitionCollection /> + </ListView.ItemContainerTransitions> + </ListView> + </controls:Case> + <controls:Case Value="True"> + <GridView + x:Name="ItemsGrid" + Padding="16,16" + CanDragItems="True" + ContextCanceled="Items_OnContextCanceled" + ContextRequested="Items_OnContextRequested" + DoubleTapped="Items_DoubleTapped" + DragItemsCompleted="Items_DragItemsCompleted" + DragItemsStarting="Items_DragItemsStarting" + IsDoubleTapEnabled="True" + IsItemClickEnabled="True" + ItemClick="Items_ItemClick" + ItemContainerStyleSelector="{StaticResource GridItemContainerStyleSelector}" + ItemTemplateSelector="{StaticResource GridItemTemplateSelector}" + ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}" + RightTapped="Items_RightTapped" + SelectionChanged="Items_SelectionChanged"> + <GridView.ItemsPanel> + <ItemsPanelTemplate> + <cpcontrols:WrapPanel + HorizontalSpacing="8" + Orientation="Horizontal" + VerticalSpacing="8" /> + </ItemsPanelTemplate> + </GridView.ItemsPanel> + <GridView.ItemContainerTransitions> + <TransitionCollection /> + </GridView.ItemContainerTransitions> + </GridView> + </controls:Case> + </controls:SwitchPresenter> </controls:Case> <controls:Case Value="True"> <StackPanel + Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Vertical" Spacing="4"> <cpcontrols:IconBox x:Name="IconBorder" - Width="56" - Height="56" + Width="48" + Height="48" Margin="8" Foreground="{ThemeResource TextFillColorSecondaryBrush}" SourceKey="{x:Bind ViewModel.EmptyContent.Icon, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}" /> <TextBlock Margin="0,4,0,0" HorizontalAlignment="Center" FontWeight="SemiBold" - Text="{x:Bind ViewModel.EmptyContent.Title, Mode=OneWay}" /> + Text="{x:Bind ViewModel.EmptyContent.Title, Mode=OneWay}" + TextAlignment="Center" + TextWrapping="Wrap" /> <TextBlock HorizontalAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Text="{x:Bind ViewModel.EmptyContent.Subtitle, Mode=OneWay}" /> + Text="{x:Bind ViewModel.EmptyContent.Subtitle, Mode=OneWay}" + TextAlignment="Center" + TextWrapping="Wrap" /> </StackPanel> </controls:Case> </controls:SwitchPresenter> - </Grid> </Page> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs index 9e4832abf1..a220303fcf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs @@ -5,24 +5,38 @@ using System.Diagnostics; using CommunityToolkit.Mvvm.Messaging; using ManagedCommon; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Commands; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.ViewModels; -using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; +using Windows.ApplicationModel.DataTransfer; +using Windows.Foundation; +using Windows.System; namespace Microsoft.CmdPal.UI; public sealed partial class ListPage : Page, IRecipient<NavigateNextCommand>, IRecipient<NavigatePreviousCommand>, + IRecipient<NavigateLeftCommand>, + IRecipient<NavigateRightCommand>, + IRecipient<NavigatePageDownCommand>, + IRecipient<NavigatePageUpCommand>, IRecipient<ActivateSelectedListItemMessage>, IRecipient<ActivateSecondaryCommandMessage> { - private ListViewModel? ViewModel + private InputSource _lastInputSource; + + internal ListViewModel? ViewModel { get => (ListViewModel?)GetValue(ViewModelProperty); set => SetValue(ViewModelProperty, value); @@ -32,31 +46,58 @@ public sealed partial class ListPage : Page, public static readonly DependencyProperty ViewModelProperty = DependencyProperty.Register(nameof(ViewModel), typeof(ListViewModel), typeof(ListPage), new PropertyMetadata(null, OnViewModelChanged)); + private ListViewBase ItemView + { + get + { + return ViewModel?.IsGridView == true ? ItemsGrid : ItemsList; + } + } + public ListPage() { this.InitializeComponent(); this.NavigationCacheMode = NavigationCacheMode.Disabled; - this.ItemsList.Loaded += ItemsList_Loaded; + this.ItemView.Loaded += Items_Loaded; + this.ItemView.PreviewKeyDown += Items_PreviewKeyDown; + this.ItemView.PointerPressed += Items_PointerPressed; } protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.Parameter is ListViewModel lvm) + if (e.Parameter is not AsyncNavigationRequest navigationRequest) { - ViewModel = lvm; + throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}"); } - if (e.NavigationMode == NavigationMode.Back - || (e.NavigationMode == NavigationMode.New && ItemsList.Items.Count > 0)) + if (navigationRequest.TargetViewModel is not ListViewModel listViewModel) { - // Upon navigating _back_ to this page, immediately select the - // first item in the list - ItemsList.SelectedIndex = 0; + throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ListViewModel)}"); + } + + ViewModel = listViewModel; + + if (e.NavigationMode == NavigationMode.Back) + { + // Must dispatch the selection to run at a lower priority; otherwise, GetFirstSelectableIndex + // may return an incorrect index because item containers are not yet rendered. + _ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + var firstUsefulIndex = GetFirstSelectableIndex(); + if (firstUsefulIndex != -1) + { + ItemView.SelectedIndex = firstUsefulIndex; + } + }); } // RegisterAll isn't AOT compatible WeakReferenceMessenger.Default.Register<NavigateNextCommand>(this); WeakReferenceMessenger.Default.Register<NavigatePreviousCommand>(this); + WeakReferenceMessenger.Default.Register<NavigateLeftCommand>(this); + WeakReferenceMessenger.Default.Register<NavigateRightCommand>(this); + WeakReferenceMessenger.Default.Register<NavigatePageDownCommand>(this); + WeakReferenceMessenger.Default.Register<NavigatePageUpCommand>(this); WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this); WeakReferenceMessenger.Default.Register<ActivateSecondaryCommandMessage>(this); @@ -69,10 +110,14 @@ public sealed partial class ListPage : Page, WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this); WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this); + WeakReferenceMessenger.Default.Unregister<NavigateLeftCommand>(this); + WeakReferenceMessenger.Default.Unregister<NavigateRightCommand>(this); + WeakReferenceMessenger.Default.Unregister<NavigatePageDownCommand>(this); + WeakReferenceMessenger.Default.Unregister<NavigatePageUpCommand>(this); WeakReferenceMessenger.Default.Unregister<ActivateSelectedListItemMessage>(this); WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(this); - if (ViewModel != null) + if (ViewModel is not null) { ViewModel.PropertyChanged -= ViewModel_PropertyChanged; ViewModel.ItemsUpdated -= Page_ItemsUpdated; @@ -82,7 +127,6 @@ public sealed partial class ListPage : Page, { ViewModel?.SafeCleanup(); CleanupHelper.Cleanup(this); - Bindings.StopTracking(); } // Clean-up event listeners @@ -91,11 +135,40 @@ public sealed partial class ListPage : Page, GC.Collect(); } + /// <summary> + /// Finds the index of the first item in the list that is not a separator. + /// Returns -1 if the list is empty or only contains separators. + /// </summary> + private int GetFirstSelectableIndex() + { + var items = ItemView.Items; + if (items is null || items.Count == 0) + { + return -1; + } + + for (var i = 0; i < items.Count; i++) + { + if (!IsSeparator(items[i])) + { + return i; + } + } + + return -1; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")] - private void ItemsList_ItemClick(object sender, ItemClickEventArgs e) + private void Items_ItemClick(object sender, ItemClickEventArgs e) { if (e.ClickedItem is ListItemViewModel item) { + if (_lastInputSource == InputSource.Keyboard) + { + ViewModel?.InvokeItemCommand.Execute(item); + return; + } + var settings = App.Current.Services.GetService<SettingsModel>()!; if (settings.SingleClickActivates) { @@ -109,9 +182,9 @@ public sealed partial class ListPage : Page, } } - private void ItemsList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e) + private void Items_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e) { - if (ItemsList.SelectedItem is ListItemViewModel vm) + if (ItemView.SelectedItem is ListItemViewModel vm) { var settings = App.Current.Services.GetService<SettingsModel>()!; if (!settings.SingleClickActivates) @@ -122,16 +195,14 @@ public sealed partial class ListPage : Page, } [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")] - private void ItemsList_SelectionChanged(object sender, SelectionChangedEventArgs e) + private void Items_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (ItemsList.SelectedItem is ListItemViewModel item) + var vm = ViewModel; + var li = ItemView.SelectedItem as ListItemViewModel; + _ = Task.Run(() => { - var vm = ViewModel; - _ = Task.Run(() => - { - vm?.UpdateSelectedItemCommand.Execute(item); - }); - } + vm?.UpdateSelectedItemCommand.Execute(li); + }); // There's mysterious behavior here, where the selection seemingly // changes to _nothing_ when we're backspacing to a single character. @@ -142,18 +213,71 @@ public sealed partial class ListPage : Page, // here, then in Page_ItemsUpdated trying to select that cached item if // it's in the list (otherwise, clear the cache), but that seems // aggressively BODGY for something that mostly just works today. - if (ItemsList.SelectedItem != null) + if (ItemView.SelectedItem is not null && !IsSeparator(ItemView.SelectedItem)) { - ItemsList.ScrollIntoView(ItemsList.SelectedItem); + var items = ItemView.Items; + var firstUsefulIndex = GetFirstSelectableIndex(); + var shouldScroll = false; + + if (e.RemovedItems.Count > 0) + { + shouldScroll = true; + } + else if (ItemView.SelectedIndex > firstUsefulIndex) + { + shouldScroll = true; + } + + if (shouldScroll) + { + ItemView.ScrollIntoView(ItemView.SelectedItem); + } + + // Automation notification for screen readers + var listViewPeer = Microsoft.UI.Xaml.Automation.Peers.ListViewAutomationPeer.CreatePeerForElement(ItemView); + if (listViewPeer is not null && li is not null) + { + UIHelper.AnnounceActionForAccessibility( + ItemsList, + li.Title, + "CommandPaletteSelectedItemChanged"); + } } } - private void ItemsList_Loaded(object sender, RoutedEventArgs e) + private void Items_RightTapped(object sender, RightTappedRoutedEventArgs e) { - // Find the ScrollViewer in the ListView - var listViewScrollViewer = FindScrollViewer(this.ItemsList); + if (e.OriginalSource is FrameworkElement element && + element.DataContext is ListItemViewModel item) + { + if (ItemView.SelectedItem != item) + { + ItemView.SelectedItem = item; + } - if (listViewScrollViewer != null) + ViewModel?.UpdateSelectedItemCommand.Execute(item); + + var pos = e.GetPosition(element); + + _ = DispatcherQueue.TryEnqueue( + () => + { + WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>( + new OpenContextMenuMessage( + element, + Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft, + pos, + ContextMenuFilterLocation.Top)); + }); + } + } + + private void Items_Loaded(object sender, RoutedEventArgs e) + { + // Find the ScrollViewer in the ItemView (ItemsList or ItemsGrid) + var listViewScrollViewer = FindScrollViewer(this.ItemView); + + if (listViewScrollViewer is not null) { listViewScrollViewer.ViewChanged += ListViewScrollViewer_ViewChanged; } @@ -162,7 +286,7 @@ public sealed partial class ListPage : Page, private void ListViewScrollViewer_ViewChanged(object? sender, ScrollViewerViewChangedEventArgs e) { var scrollView = sender as ScrollViewer; - if (scrollView == null) + if (scrollView is null) { return; } @@ -183,17 +307,56 @@ public sealed partial class ListPage : Page, // And then have these commands manipulate that state being bound to the UI instead // We may want to see how other non-list UIs need to behave to make this decision // At least it's decoupled from the SearchBox now :) - if (ItemsList.SelectedIndex < ItemsList.Items.Count - 1) + if (ViewModel?.IsGridView == true) { - ItemsList.SelectedIndex++; + // For grid views, use spatial navigation (down) + HandleGridArrowNavigation(VirtualKey.Down); + } + else + { + // For list views, use simple linear navigation + NavigateDown(); } } public void Receive(NavigatePreviousCommand message) { - if (ItemsList.SelectedIndex > 0) + if (ViewModel?.IsGridView == true) { - ItemsList.SelectedIndex--; + // For grid views, use spatial navigation (up) + HandleGridArrowNavigation(VirtualKey.Up); + } + else + { + NavigateUp(); + } + } + + public void Receive(NavigateLeftCommand message) + { + // For grid views, use spatial navigation. For list views, just move up. + if (ViewModel?.IsGridView == true) + { + HandleGridArrowNavigation(VirtualKey.Left); + } + else + { + // In list view, left arrow doesn't navigate + // This maintains consistency with the SearchBar behavior + } + } + + public void Receive(NavigateRightCommand message) + { + // For grid views, use spatial navigation. For list views, just move down. + if (ViewModel?.IsGridView == true) + { + HandleGridArrowNavigation(VirtualKey.Right); + } + else + { + // In list view, right arrow doesn't navigate + // This maintains consistency with the SearchBar behavior } } @@ -203,7 +366,7 @@ public sealed partial class ListPage : Page, { ViewModel?.InvokeItemCommand.Execute(null); } - else if (ItemsList.SelectedItem is ListItemViewModel item) + else if (ItemView.SelectedItem is ListItemViewModel item) { ViewModel?.InvokeItemCommand.Execute(item); } @@ -215,12 +378,154 @@ public sealed partial class ListPage : Page, { ViewModel?.InvokeSecondaryCommandCommand.Execute(null); } - else if (ItemsList.SelectedItem is ListItemViewModel item) + else if (ItemView.SelectedItem is ListItemViewModel item) { ViewModel?.InvokeSecondaryCommandCommand.Execute(item); } } + public void Receive(NavigatePageDownCommand message) + { + var indexes = CalculateTargetIndexPageUpDownScrollTo(true); + if (indexes is null) + { + return; + } + + if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) + { + ItemView.SelectedIndex = indexes.Value.TargetIndex; + if (ItemView.SelectedItem is not null) + { + ItemView.ScrollIntoView(ItemView.SelectedItem); + } + } + } + + public void Receive(NavigatePageUpCommand message) + { + var indexes = CalculateTargetIndexPageUpDownScrollTo(false); + if (indexes is null) + { + return; + } + + if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex) + { + ItemView.SelectedIndex = indexes.Value.TargetIndex; + if (ItemView.SelectedItem is not null) + { + ItemView.ScrollIntoView(ItemView.SelectedItem); + } + } + } + + /// <summary> + /// Calculates the item index to target when performing a page up or page down + /// navigation. The calculation attempts to estimate how many items fit into + /// the visible viewport by measuring actual container heights currently visible + /// within the internal ScrollViewer. If measurements are not available a + /// fallback estimate is used. + /// </summary> + /// <param name="isPageDown">True to calculate a page-down target, false for page-up.</param> + /// <returns> + /// A tuple containing the current index and the calculated target index, or null + /// if a valid calculation could not be performed (for example, missing ScrollViewer). + /// </returns> + private (int CurrentIndex, int TargetIndex)? CalculateTargetIndexPageUpDownScrollTo(bool isPageDown) + { + var scroll = FindScrollViewer(ItemView); + if (scroll is null) + { + return null; + } + + var viewportHeight = scroll.ViewportHeight; + if (viewportHeight <= 0) + { + return null; + } + + var currentIndex = ItemView.SelectedIndex < 0 ? 0 : ItemView.SelectedIndex; + var itemCount = ItemView.Items.Count; + + // Compute visible item heights within the ScrollViewer viewport + const int firstVisibleIndexNotFound = -1; + var firstVisibleIndex = firstVisibleIndexNotFound; + var visibleHeights = new List<double>(itemCount); + + for (var i = 0; i < itemCount; i++) + { + if (ItemView.ContainerFromIndex(i) is FrameworkElement container) + { + try + { + var transform = container.TransformToVisual(scroll); + var topLeft = transform.TransformPoint(new Point(0, 0)); + var bottom = topLeft.Y + container.ActualHeight; + + // If any part of the container is inside the viewport, consider it visible + if (topLeft.Y >= 0 && bottom <= viewportHeight) + { + if (firstVisibleIndex == firstVisibleIndexNotFound) + { + firstVisibleIndex = i; + } + + visibleHeights.Add(container.ActualHeight > 0 ? container.ActualHeight : 0); + } + } + catch + { + // ignore transform errors and continue + } + } + } + + var itemsPerPage = 0; + + // Calculate how many items fit in the viewport based on their actual heights + if (visibleHeights.Count > 0) + { + double accumulated = 0; + for (var i = 0; i < visibleHeights.Count; i++) + { + accumulated += visibleHeights[i] <= 0 ? 1 : visibleHeights[i]; + itemsPerPage++; + if (accumulated >= viewportHeight) + { + break; + } + } + } + else + { + // fallback: estimate using first measured container height + double itemHeight = 0; + for (var i = currentIndex; i < itemCount; i++) + { + if (ItemView.ContainerFromIndex(i) is FrameworkElement { ActualHeight: > 0 } c) + { + itemHeight = c.ActualHeight; + break; + } + } + + if (itemHeight <= 0) + { + itemHeight = 1; + } + + itemsPerPage = Math.Max(1, (int)Math.Floor(viewportHeight / itemHeight)); + } + + var targetIndex = isPageDown + ? Math.Min(itemCount - 1, currentIndex + Math.Max(1, itemsPerPage)) + : Math.Max(0, currentIndex - Math.Max(1, itemsPerPage)); + + return (currentIndex, targetIndex); + } + private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is ListPage @this) @@ -236,9 +541,9 @@ public sealed partial class ListPage : Page, page.PropertyChanged += @this.ViewModel_PropertyChanged; page.ItemsUpdated += @this.Page_ItemsUpdated; } - else if (e.NewValue == null) + else if (e.NewValue is null) { - Logger.LogDebug("cleared viewmodel"); + Logger.LogDebug("cleared view model"); } } } @@ -251,13 +556,68 @@ public sealed partial class ListPage : Page, // // It's important to do this here, because once there's no selection // (which can happen as the list updates) we won't get an - // ItemsList_SelectionChanged again to give us another chance to change + // ItemView_SelectionChanged again to give us another chance to change // the selection from null -> something. Better to just update the // selection once, at the end of all the updating. - if (ItemsList.SelectedItem == null) + // The selection logic must be deferred to the DispatcherQueue + // to ensure the UI has processed the updated ItemsSource binding, + // preventing ItemView.Items from appearing empty/null immediately after update. + _ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => { - ItemsList.SelectedIndex = 0; - } + var items = ItemView.Items; + + // If the list is null or empty, clears the selection and return + if (items is null || items.Count == 0) + { + ItemView.SelectedIndex = -1; + return; + } + + // Finds the first item that is not a separator + var firstUsefulIndex = GetFirstSelectableIndex(); + + // If there is only separators in the list, don't select anything. + if (firstUsefulIndex == -1) + { + ItemView.SelectedIndex = -1; + + return; + } + + var shouldUpdateSelection = false; + + // If it's a top level list update we force the reset to the top useful item + if (!sender.IsNested) + { + shouldUpdateSelection = true; + } + + // No current selection or current selection is null + else if (ItemView.SelectedItem is null) + { + shouldUpdateSelection = true; + } + + // The current selected item is a separator + else if (IsSeparator(ItemView.SelectedItem)) + { + shouldUpdateSelection = true; + } + + // The selected item does not exist in the new list + else if (!items.Contains(ItemView.SelectedItem)) + { + shouldUpdateSelection = true; + } + + if (shouldUpdateSelection) + { + if (firstUsefulIndex != -1) + { + ItemView.SelectedIndex = firstUsefulIndex; + } + } + }); } private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) @@ -265,22 +625,22 @@ public sealed partial class ListPage : Page, var prop = e.PropertyName; if (prop == nameof(ViewModel.FilteredItems)) { - Debug.WriteLine($"ViewModel.FilteredItems {ItemsList.SelectedItem}"); + Debug.WriteLine($"ViewModel.FilteredItems {ItemView.SelectedItem}"); } } - private ScrollViewer? FindScrollViewer(DependencyObject parent) + private static ScrollViewer? FindScrollViewer(DependencyObject parent) { - if (parent is ScrollViewer) + if (parent is ScrollViewer viewer) { - return (ScrollViewer)parent; + return viewer; } for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) { var child = VisualTreeHelper.GetChild(parent, i); var result = FindScrollViewer(child); - if (result != null) + if (result is not null) { return result; } @@ -288,4 +648,390 @@ public sealed partial class ListPage : Page, return null; } + + // Find a logical neighbor in the requested direction using containers' positions. + private void HandleGridArrowNavigation(VirtualKey key) + { + if (ItemView.Items.Count == 0) + { + // No items, goodbye. + return; + } + + var currentIndex = ItemView.SelectedIndex; + if (currentIndex < 0) + { + // -1 is a valid value (no item currently selected) + currentIndex = 0; + ItemView.SelectedIndex = 0; + } + + try + { + // Try to compute using container positions; if not available, fall back to simple +/-1. + var currentContainer = ItemView.ContainerFromIndex(currentIndex) as FrameworkElement; + if (currentContainer is not null && currentContainer.ActualWidth != 0 && currentContainer.ActualHeight != 0) + { + // Use center of current container as reference + var curPoint = currentContainer.TransformToVisual(ItemView).TransformPoint(new Point(0, 0)); + var curCenterX = curPoint.X + (currentContainer.ActualWidth / 2.0); + var curCenterY = curPoint.Y + (currentContainer.ActualHeight / 2.0); + + var bestScore = double.MaxValue; + var bestIndex = currentIndex; + + for (var i = 0; i < ItemView.Items.Count; i++) + { + if (i == currentIndex) + { + continue; + } + + if (IsSeparator(ItemView.Items[i])) + { + continue; + } + + if (ItemView.ContainerFromIndex(i) is FrameworkElement c && c.ActualWidth > 0 && c.ActualHeight > 0) + { + var p = c.TransformToVisual(ItemView).TransformPoint(new Point(0, 0)); + var centerX = p.X + (c.ActualWidth / 2.0); + var centerY = p.Y + (c.ActualHeight / 2.0); + + var dx = centerX - curCenterX; + var dy = centerY - curCenterY; + + var candidate = false; + var score = double.MaxValue; + + switch (key) + { + case VirtualKey.Left: + if (dx < 0) + { + candidate = true; + score = Math.Abs(dy) + (Math.Abs(dx) * 0.7); + } + + break; + case VirtualKey.Right: + if (dx > 0) + { + candidate = true; + score = Math.Abs(dy) + (Math.Abs(dx) * 0.7); + } + + break; + case VirtualKey.Up: + if (dy < 0) + { + candidate = true; + score = Math.Abs(dx) + (Math.Abs(dy) * 0.7); + } + + break; + case VirtualKey.Down: + if (dy > 0) + { + candidate = true; + score = Math.Abs(dx) + (Math.Abs(dy) * 0.7); + } + + break; + } + + if (candidate && score < bestScore) + { + bestScore = score; + bestIndex = i; + } + } + } + + if (bestIndex != currentIndex) + { + ItemView.SelectedIndex = bestIndex; + ItemView.ScrollIntoView(ItemView.SelectedItem); + } + + return; + } + } + catch + { + // ignore transform errors and fall back + } + + // fallback linear behavior + var fallback = key switch + { + VirtualKey.Left => Math.Max(0, currentIndex - 1), + VirtualKey.Right => Math.Min(ItemView.Items.Count - 1, currentIndex + 1), + VirtualKey.Up => Math.Max(0, currentIndex - 1), + VirtualKey.Down => Math.Min(ItemView.Items.Count - 1, currentIndex + 1), + _ => currentIndex, + }; + if (fallback != currentIndex) + { + ItemView.SelectedIndex = fallback; + ItemView.ScrollIntoView(ItemView.SelectedItem); + } + } + + private void Items_OnContextRequested(UIElement sender, ContextRequestedEventArgs e) + { + var (item, element) = e.OriginalSource switch + { + // caused by keyboard shortcut (e.g. Context menu key or Shift+F10) + SelectorItem selectorItem => (ItemView.ItemFromContainer(selectorItem) as ListItemViewModel, selectorItem), + + // caused by right-click on the ListViewItem + FrameworkElement { DataContext: ListItemViewModel itemViewModel } frameworkElement => (itemViewModel, frameworkElement), + + _ => (null, null), + }; + + if (item is null || element is null) + { + return; + } + + if (ItemView.SelectedItem != item) + { + ItemView.SelectedItem = item; + } + + if (!e.TryGetPosition(element, out var pos)) + { + pos = new(0, element.ActualHeight); + } + + _ = DispatcherQueue.TryEnqueue( + () => + { + WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>( + new OpenContextMenuMessage( + element, + Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedLeft, + pos, + ContextMenuFilterLocation.Top)); + }); + e.Handled = true; + } + + private void Items_OnContextCanceled(UIElement sender, RoutedEventArgs e) + { + _ = DispatcherQueue.TryEnqueue(() => WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>()); + } + + private void Items_PointerPressed(object sender, PointerRoutedEventArgs e) => _lastInputSource = InputSource.Pointer; + + private void Items_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + // Track keyboard as the last input source for activation logic. + if (e.Key is VirtualKey.Enter or VirtualKey.Space) + { + _lastInputSource = InputSource.Keyboard; + return; + } + + // Handle arrow navigation when we're showing a grid. + if (ViewModel?.IsGridView == true) + { + switch (e.Key) + { + case VirtualKey.Left: + case VirtualKey.Right: + case VirtualKey.Up: + case VirtualKey.Down: + _lastInputSource = InputSource.Keyboard; + HandleGridArrowNavigation(e.Key); + e.Handled = true; + break; + } + } + } + + /// <summary> + /// Code stealed from <see cref="Controls.ContextMenu.NavigateUp"/> + /// </summary> + private void NavigateUp() + { + var newIndex = ItemView.SelectedIndex; + + if (ItemView.SelectedIndex > 0) + { + newIndex--; + + while ( + newIndex >= 0 && + IsSeparator(ItemView.Items[newIndex]) && + newIndex != ItemView.SelectedIndex) + { + newIndex--; + } + + if (newIndex < 0) + { + newIndex = ItemView.Items.Count - 1; + + while ( + newIndex >= 0 && + IsSeparator(ItemView.Items[newIndex]) && + newIndex != ItemView.SelectedIndex) + { + newIndex--; + } + } + } + else + { + newIndex = ItemView.Items.Count - 1; + } + + ItemView.SelectedIndex = newIndex; + } + + private void Items_DragItemsStarting(object sender, DragItemsStartingEventArgs e) + { + try + { + if (e.Items.FirstOrDefault() is not ListItemViewModel item || item.DataPackage is null) + { + e.Cancel = true; + return; + } + + // copy properties + foreach (var (key, value) in item.DataPackage.Properties) + { + try + { + e.Data.Properties[key] = value; + } + catch (Exception) + { + // noop - skip any properties that fail + } + } + + // setup e.Data formats as deferred renderers to read from the item's DataPackage + foreach (var format in item.DataPackage.AvailableFormats) + { + try + { + e.Data.SetDataProvider(format, request => DelayRenderer(request, item, format)); + } + catch (Exception) + { + // noop - skip any formats that fail + } + } + + WeakReferenceMessenger.Default.Send(new DragStartedMessage()); + } + catch (Exception ex) + { + WeakReferenceMessenger.Default.Send(new DragCompletedMessage()); + Logger.LogError("Failed to start dragging an item", ex); + } + } + + private static void DelayRenderer(DataProviderRequest request, ListItemViewModel item, string format) + { + var deferral = request.GetDeferral(); + try + { + item.DataPackage?.GetDataAsync(format) + .AsTask() + .ContinueWith(dataTask => + { + try + { + if (dataTask.IsCompletedSuccessfully) + { + request.SetData(dataTask.Result); + } + else if (dataTask.IsFaulted && dataTask.Exception is not null) + { + Logger.LogError($"Failed to get data for format '{format}' during drag-and-drop", dataTask.Exception); + } + } + finally + { + deferral.Complete(); + } + }); + } + catch (Exception ex) + { + Logger.LogError($"Failed to set data for format '{format}' during drag-and-drop", ex); + deferral.Complete(); + } + } + + private void Items_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args) + { + WeakReferenceMessenger.Default.Send(new DragCompletedMessage()); + } + + /// <summary> + /// Code stealed from <see cref="Controls.ContextMenu.NavigateDown"/> + /// </summary> + private void NavigateDown() + { + var newIndex = ItemView.SelectedIndex; + + if (ItemView.SelectedIndex == ItemView.Items.Count - 1) + { + newIndex = 0; + while ( + newIndex < ItemView.Items.Count && + IsSeparator(ItemView.Items[newIndex])) + { + newIndex++; + } + + if (newIndex >= ItemView.Items.Count) + { + return; + } + } + else + { + newIndex++; + + while ( + newIndex < ItemView.Items.Count && + IsSeparator(ItemView.Items[newIndex]) && + newIndex != ItemView.SelectedIndex) + { + newIndex++; + } + + if (newIndex >= ItemView.Items.Count) + { + newIndex = 0; + + while ( + newIndex < ItemView.Items.Count && + IsSeparator(ItemView.Items[newIndex]) && + newIndex != ItemView.SelectedIndex) + { + newIndex++; + } + } + } + + ItemView.SelectedIndex = newIndex; + } + + private bool IsSeparator(object? item) => item is ListItemViewModel li && !li.IsInteractive; + + private enum InputSource + { + None, + Keyboard, + Pointer, + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/AdaptiveCache`2.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/AdaptiveCache`2.cs new file mode 100644 index 0000000000..847fd7a9cb --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/AdaptiveCache`2.cs @@ -0,0 +1,299 @@ +// 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 System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using Microsoft.CmdPal.Core.Common.Helpers; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// <summary> +/// A high-performance, near-lock-free adaptive cache optimized for UI Icons. +/// Eviction merely drops references to allow the GC to manage UI-bound lifetimes. +/// </summary> +internal sealed class AdaptiveCache<TKey, TValue> + where TKey : IEquatable<TKey> +{ + private readonly int _capacity; + private readonly double _decayFactor; + private readonly TimeSpan _decayInterval; + + private readonly ConcurrentDictionary<TKey, CacheEntry> _map; + private readonly ConcurrentStack<CacheEntry> _pool = []; + private readonly WaitCallback _maintenanceCallback; + + private long _currentTick; + private long _lastDecayTicks = DateTime.UtcNow.Ticks; + private InterlockedBoolean _maintenanceSwitch = new(false); + + public AdaptiveCache(int capacity = 384, TimeSpan? decayInterval = null, double decayFactor = 0.5) + { + _capacity = capacity; + _decayInterval = decayInterval ?? TimeSpan.FromMinutes(5); + _decayFactor = decayFactor; + _map = new ConcurrentDictionary<TKey, CacheEntry>(Environment.ProcessorCount, capacity); + + _maintenanceCallback = static state => + { + var cache = (AdaptiveCache<TKey, TValue>)state!; + try + { + cache.PerformCleanup(); + } + finally + { + cache._maintenanceSwitch.Clear(); + } + }; + } + + public TValue GetOrAdd<TArg>(TKey key, Func<TKey, TArg, TValue> factory, TArg arg) + { + if (_map.TryGetValue(key, out var entry)) + { + entry.Update(Interlocked.Increment(ref _currentTick)); + return entry.Value!; + } + + if (!_pool.TryPop(out var newEntry)) + { + newEntry = new CacheEntry(); + } + + var value = factory(key, arg); + var tick = Interlocked.Increment(ref _currentTick); + newEntry.Initialize(key, value, 1.0, tick); + + if (!_map.TryAdd(key, newEntry)) + { + newEntry.Clear(); + _pool.Push(newEntry); + + if (_map.TryGetValue(key, out var existing)) + { + existing.Update(tick); + return existing.Value!; + } + } + + if (ShouldMaintenanceRun()) + { + TryRunMaintenance(); + } + + return value; + } + + public bool TryGet(TKey key, [MaybeNullWhen(false)] out TValue value) + { + if (_map.TryGetValue(key, out var entry)) + { + entry.Update(Interlocked.Increment(ref _currentTick)); + value = entry.Value; + return true; + } + + value = default; + return false; + } + + public void Add(TKey key, TValue value) + { + var tick = Interlocked.Increment(ref _currentTick); + + if (_map.TryGetValue(key, out var existing)) + { + existing.Update(tick); + existing.SetValue(value); + return; + } + + if (!_pool.TryPop(out var newEntry)) + { + newEntry = new CacheEntry(); + } + + newEntry.Initialize(key, value, 1.0, tick); + + if (!_map.TryAdd(key, newEntry)) + { + newEntry.Clear(); + _pool.Push(newEntry); + } + + if (ShouldMaintenanceRun()) + { + TryRunMaintenance(); + } + } + + public bool TryRemove(TKey key) + { + if (_map.TryRemove(key, out var evicted)) + { + evicted.Clear(); + _pool.Push(evicted); + return true; + } + + return false; + } + + public void Clear() + { + foreach (var key in _map.Keys) + { + TryRemove(key); + } + + Interlocked.Exchange(ref _currentTick, 0); + } + + private bool ShouldMaintenanceRun() + { + return _map.Count > _capacity || (DateTime.UtcNow.Ticks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks; + } + + private void TryRunMaintenance() + { + if (_maintenanceSwitch.Set()) + { + ThreadPool.UnsafeQueueUserWorkItem(_maintenanceCallback, this); + } + } + + private void PerformCleanup() + { + var nowTicks = DateTime.UtcNow.Ticks; + var isDecay = (nowTicks - Interlocked.Read(ref _lastDecayTicks)) > _decayInterval.Ticks; + if (isDecay) + { + Interlocked.Exchange(ref _lastDecayTicks, nowTicks); + } + + var currentTick = Interlocked.Read(ref _currentTick); + + foreach (var (key, entry) in _map) + { + if (isDecay) + { + entry.Decay(_decayFactor); + } + + var score = CalculateScore(entry, currentTick); + + if (score < 0.1 || _map.Count > _capacity) + { + if (_map.TryRemove(key, out var evicted)) + { + evicted.Clear(); + _pool.Push(evicted); + } + } + } + } + + /// <summary> + /// Calculates the survival score of an entry. + /// Higher score = stay in cache; Lower score = priority for eviction. + /// </summary> + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double CalculateScore(CacheEntry entry, long currentTick) + { + // Tuning parameter: How much weight to give recency vs frequency. + // - a larger ageWeight makes the cache behave more like LRU (Least Recently Used). + // - a smaller ageWeight makes it behave more like LFU (Least Frequently Used). + const double ageWeight = 0.001; + + var frequency = entry.GetFrequency(); + var age = currentTick - entry.GetLastAccess(); + + return frequency - (age * ageWeight); + } + + /// <summary> + /// Represents a single pooled entry in the cache, containing the value and + /// atomic metadata for adaptive eviction logic. + /// </summary> + private sealed class CacheEntry + { + /// <summary> + /// Gets the key associated with this entry. Used primarily for identification during cleanup. + /// </summary> + public TKey Key { get; private set; } = default!; + + /// <summary> + /// Gets the cached value. This reference is cleared on eviction to allow GC collection. + /// </summary> + public TValue Value { get; private set; } = default!; + + /// <summary> + /// Stores the frequency count as double bits to allow for Interlocked atomic math. + /// Frequencies are decayed over time to ensure the cache adapts to new usage patterns. + /// </summary> + /// <remarks> + /// This allows the use of Interlocked.CompareExchange to perform thread-safe floating point + /// arithmetic without a global lock. + /// </remarks> + private long _frequencyBits; + + /// <summary> + /// The tick (monotonically increasing counter) of the last time this entry was accessed. + /// </summary> + private long _lastAccessTick; + + public void Initialize(TKey key, TValue value, double frequency, long lastAccessTick) + { + Key = key; + Value = value; + _frequencyBits = BitConverter.DoubleToInt64Bits(frequency); + _lastAccessTick = lastAccessTick; + } + + public void SetValue(TValue value) + { + Value = value; + } + + public void Clear() + { + Key = default!; + Value = default!; + } + + public void Update(long tick) + { + Interlocked.Exchange(ref _lastAccessTick, tick); + long initial, updated; + do + { + initial = Interlocked.Read(ref _frequencyBits); + updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) + 1.0); + } + while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial); + } + + public void Decay(double factor) + { + long initial, updated; + do + { + initial = Interlocked.Read(ref _frequencyBits); + updated = BitConverter.DoubleToInt64Bits(BitConverter.Int64BitsToDouble(initial) * factor); + } + while (Interlocked.CompareExchange(ref _frequencyBits, updated, initial) != initial); + } + + public double GetFrequency() + { + return BitConverter.Int64BitsToDouble(Interlocked.Read(ref _frequencyBits)); + } + + public long GetLastAccess() + { + return Interlocked.Read(ref _lastAccessTick); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs new file mode 100644 index 0000000000..8712f3d744 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.CmdPal.UI.Helpers; + +internal static class BindTransformers +{ + public static bool Negate(bool value) => !value; + + public static Visibility NegateVisibility(Visibility value) => value == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; + + public static Visibility EmptyToCollapsed(string? input) + => string.IsNullOrEmpty(input) ? Visibility.Collapsed : Visibility.Visible; + + public static Visibility EmptyOrWhitespaceToCollapsed(string? input) + => string.IsNullOrWhiteSpace(input) ? Visibility.Collapsed : Visibility.Visible; + + public static Visibility EmptyOrWhitespaceToVisible(string? input) + => string.IsNullOrWhiteSpace(input) ? Visibility.Visible : Visibility.Collapsed; + + public static Visibility VisibleWhenAny(bool value1, bool value2) + => (value1 || value2) ? Visibility.Visible : Visibility.Collapsed; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BuildInfo.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BuildInfo.cs new file mode 100644 index 0000000000..a276d38f47 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BuildInfo.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal static class BuildInfo +{ +#if DEBUG + public const string Configuration = "Debug"; +#else + public const string Configuration = "Release"; +#endif + + // Runtime AOT detection + public static bool IsNativeAot => !RuntimeFeature.IsDynamicCodeSupported; + + // build-time values + public static bool PublishTrimmed + { + get + { +#if BUILD_INFO_PUBLISH_TRIMMED + return true; +#else + return false; +#endif + } + } + + // build-time values + public static bool PublishAot + { + get + { +#if BUILD_INFO_PUBLISH_AOT + return true; +#else + return false; +#endif + } + } + + public static bool IsCiBuild + { + get + { +#if BUILD_INFO_CIBUILD + return true; +#else + return false; +#endif + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs new file mode 100644 index 0000000000..2492f7f7c9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/ColorExtensions.cs @@ -0,0 +1,132 @@ +// 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 CommunityToolkit.WinUI.Helpers; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// <summary> +/// Extension methods for <see cref="Color"/>. +/// </summary> +internal static class ColorExtensions +{ + /// <param name="color">Input color.</param> + public static double CalculateBrightness(this Color color) + { + return color.ToHsv().V; + } + + /// <summary> + /// Allows to change the brightness by a factor based on the HSV color space. + /// </summary> + /// <param name="color">Input color.</param> + /// <param name="brightnessFactor">The brightness adjustment factor, ranging from -1 to 1.</param> + /// <returns>Updated color.</returns> + public static Color UpdateBrightness(this Color color, double brightnessFactor) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1); + + var hsvColor = color.ToHsv(); + return ColorHelper.FromHsv(hsvColor.H, hsvColor.S, Math.Clamp(hsvColor.V + brightnessFactor, 0, 1), hsvColor.A); + } + + /// <summary> + /// Updates the color by adjusting brightness, saturation, and luminance factors. + /// </summary> + /// <param name="color">Input color.</param> + /// <param name="brightnessFactor">The brightness adjustment factor, ranging from -1 to 1.</param> + /// <param name="saturationFactor">The saturation adjustment factor, ranging from -1 to 1. Defaults to 0.</param> + /// <param name="luminanceFactor">The luminance adjustment factor, ranging from -1 to 1. Defaults to 0.</param> + /// <returns>Updated color.</returns> + public static Color Update(this Color color, double brightnessFactor, double saturationFactor = 0, double luminanceFactor = 0) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(brightnessFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(brightnessFactor, -1); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(saturationFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(saturationFactor, -1); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(luminanceFactor, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(luminanceFactor, -1); + + var hsv = color.ToHsv(); + + var rgb = ColorHelper.FromHsv( + hsv.H, + Clamp01(hsv.S + saturationFactor), + Clamp01(hsv.V + brightnessFactor)); + + if (luminanceFactor == 0) + { + return rgb; + } + + var hsl = rgb.ToHsl(); + var lightness = Clamp01(hsl.L + luminanceFactor); + return ColorHelper.FromHsl(hsl.H, hsl.S, lightness); + } + + /// <summary> + /// Linearly interpolates between two colors in HSV space. + /// Hue is blended along the shortest arc on the color wheel (wrap-aware). + /// Saturation, Value, and Alpha are blended linearly. + /// </summary> + /// <param name="a">Start color.</param> + /// <param name="b">End color.</param> + /// <param name="t">Interpolation factor in [0,1].</param> + /// <returns>Interpolated color.</returns> + public static Color LerpHsv(this Color a, Color b, double t) + { + t = Clamp01(t); + + // Convert to HSV + var hslA = a.ToHsv(); + var hslB = b.ToHsv(); + + var h1 = hslA.H; + var h2 = hslB.H; + + // Handle near-gray hues (undefined hue) by inheriting the other's hue + const double satEps = 1e-4f; + if (hslA.S < satEps && hslB.S >= satEps) + { + h1 = h2; + } + else if (hslB.S < satEps && hslA.S >= satEps) + { + h2 = h1; + } + + return ColorHelper.FromHsv( + hue: LerpHueDegrees(h1, h2, t), + saturation: Lerp(hslA.S, hslB.S, t), + value: Lerp(hslA.V, hslB.V, t), + alpha: (byte)Math.Round(Lerp(hslA.A, hslB.A, t))); + } + + private static double LerpHueDegrees(double a, double b, double t) + { + a = Mod360(a); + b = Mod360(b); + var delta = ((b - a + 540f) % 360f) - 180f; + return Mod360(a + (delta * t)); + } + + private static double Mod360(double angle) + { + angle %= 360f; + if (angle < 0f) + { + angle += 360f; + } + + return angle; + } + + private static double Lerp(double a, double b, double t) => a + ((b - a) * t); + + private static double Clamp01(double x) => Math.Clamp(x, 0, 1); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs new file mode 100644 index 0000000000..12454d16c3 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GlobalErrorHandler.cs @@ -0,0 +1,182 @@ +// 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 ManagedCommon; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Core.Common.Services.Reports; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; +using SystemUnhandledExceptionEventArgs = System.UnhandledExceptionEventArgs; +using XamlUnhandledExceptionEventArgs = Microsoft.UI.Xaml.UnhandledExceptionEventArgs; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// <summary> +/// Global error handler for Command Palette. +/// </summary> +internal sealed partial class GlobalErrorHandler : IDisposable +{ + private ErrorReportBuilder? _errorReportBuilder; + private Options? _options; + private App? _app; + + // GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available. + internal void Register(App app, Options options, IApplicationInfoService? appInfoService = null) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(options); + + _options = options; + _app = app; + _errorReportBuilder = new ErrorReportBuilder(appInfoService); + + _app.UnhandledException += App_UnhandledException; + TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + } + + private void App_UnhandledException(object sender, XamlUnhandledExceptionEventArgs e) + { + // Exceptions thrown on the main UI thread are handled here. + if (e.Exception != null) + { + HandleException(e.Exception, Context.MainThreadException); + } + } + + private void CurrentDomain_UnhandledException(object sender, SystemUnhandledExceptionEventArgs e) + { + // Exceptions thrown on background threads are handled here. + if (e.ExceptionObject is Exception ex) + { + HandleException(ex, Context.AppDomainUnhandledException); + } + } + + private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + // This event is raised only when a faulted Task is garbage-collected + // without its exception being observed. It is NOT raised immediately + // when the Task faults; timing depends on GC finalization. + e.SetObserved(); + HandleException(e.Exception, Context.UnobservedTaskException); + } + + private void HandleException(Exception ex, Context context) + { + Logger.LogError($"Unhandled exception detected ({context})", ex); + + if (context == Context.MainThreadException) + { + var report = _errorReportBuilder!.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true); + + StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true); + + string message; + string caption; + try + { + message = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Message"); + caption = ResourceLoaderInstance.GetString("GlobalErrorHandler_CrashMessageBox_Caption"); + } + catch + { + // The resource loader may not be available if the exception occurred during startup. + // Fall back to hardcoded strings in that case. + message = "Command Palette has encountered a fatal error and must close."; + caption = "Command Palette - Fatal error"; + } + + PInvoke.MessageBox( + HWND.Null, + message, + caption, + MESSAGEBOX_STYLE.MB_ICONERROR); + } + } + + private static string? StoreReport(string report, bool storeOnDesktop) + { + // Generate a unique name for the report file; include timestamp and a random zero-padded number to avoid collisions + // in case of crash storm. + var name = FormattableString.Invariant($"CmdPal_ErrorReport_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{Random.Shared.Next(100000):D5}.log"); + + // Always store a copy in log directory, this way it is available for Bug Report Tool + string? reportPath = null; + if (Logger.CurrentVersionLogDirectoryPath != null) + { + reportPath = Save(report, name, static () => Logger.CurrentVersionLogDirectoryPath); + } + + // Optionally store a copy on the desktop for user (in)convenience + if (storeOnDesktop) + { + var path = Save(report, name, static () => Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory)); + + // show the desktop copy if both succeeded + if (path != null) + { + reportPath = path; + } + } + + return reportPath; + + static string? Save(string reportContent, string reportFileName, Func<string> directory) + { + try + { + var logDirectory = directory(); + Directory.CreateDirectory(logDirectory); + var reportFilePath = Path.Combine(logDirectory, reportFileName); + File.WriteAllText(reportFilePath, reportContent); + return reportFilePath; + } + catch (Exception ex) + { + Logger.LogError("Failed to store exception report", ex); + return null; + } + } + } + + public void Dispose() + { + _app?.UnhandledException -= App_UnhandledException; + TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException; + AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException; + } + + private enum Context + { + Unknown = 0, + MainThreadException, + BackgroundThreadException, + UnobservedTaskException, + AppDomainUnhandledException, + } + + /// <summary> + /// Configuration options controlling how <see cref="GlobalErrorHandler"/> reacts to exceptions + /// (what to log, what to show to the user, and where to store reports). + /// </summary> + internal sealed record Options + { + /// <summary> + /// Gets the default configuration. + /// </summary> + public static Options Default { get; } = new(); + + /// <summary> + /// Gets a value indicating whether Personally Identifiable Information (PII) should be redacted in error reports. + /// </summary> + public bool RedactPii { get; init; } = true; + + /// <summary> + /// Gets a value indicating whether to store the error report on the user's desktop in addition to the log directory. + /// </summary> + public bool StoreReportOnUserDesktop { get; init; } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GpoValueChecker.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GpoValueChecker.cs index fcd8ba1590..1c713c17c6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GpoValueChecker.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/GpoValueChecker.cs @@ -2,13 +2,6 @@ // 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.Text; -using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.Bookmarks; -using Microsoft.UI.Xaml.Documents; using Microsoft.Win32; namespace Microsoft.CmdPal.UI.Helpers; @@ -63,7 +56,7 @@ internal static class GpoValueChecker { using (RegistryKey? key = rootKey.OpenSubKey(subKeyPath, false)) { - if (key == null) + if (key is null) { return null; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheProvider.cs deleted file mode 100644 index 01c9f05f4d..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheProvider.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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.UI.Controls; -using Microsoft.CmdPal.UI.ViewModels; - -namespace Microsoft.CmdPal.UI.Helpers; - -/// <summary> -/// Common async event handler provides the cache lookup function for the <see cref="IconBox.SourceRequested"/> deferred event. -/// </summary> -public static partial class IconCacheProvider -{ - private static readonly IconCacheService IconService = new(Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()); - -#pragma warning disable IDE0060 // Remove unused parameter - public static async void SourceRequested(IconBox sender, SourceRequestedEventArgs args) -#pragma warning restore IDE0060 // Remove unused parameter - { - if (args.Key == null) - { - return; - } - - if (args.Key is IconDataViewModel iconData) - { - var deferral = args.GetDeferral(); - - args.Value = await IconService.GetIconSource(iconData); - - deferral.Complete(); - } - else if (args.Key is IconInfoViewModel iconInfo) - { - var deferral = args.GetDeferral(); - - var data = args.Theme == Microsoft.UI.Xaml.ElementTheme.Dark ? iconInfo.Dark : iconInfo.Light; - args.Value = await IconService.GetIconSource(data); - - deferral.Complete(); - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheService.cs deleted file mode 100644 index acf41fee1e..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/IconCacheService.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Diagnostics; -using Microsoft.CmdPal.UI.ViewModels; -using Microsoft.Terminal.UI; -using Microsoft.UI.Dispatching; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media.Imaging; -using Windows.Storage.Streams; - -namespace Microsoft.CmdPal.UI.Helpers; - -public sealed class IconCacheService(DispatcherQueue dispatcherQueue) -{ - public Task<IconSource?> GetIconSource(IconDataViewModel icon) => - - // todo: actually implement a cache of some sort - IconToSource(icon); - - private async Task<IconSource?> IconToSource(IconDataViewModel icon) - { - try - { - if (!string.IsNullOrEmpty(icon.Icon)) - { - var source = IconPathConverter.IconSourceMUX(icon.Icon, false); - return source; - } - else if (icon.Data != null) - { - try - { - return await StreamToIconSource(icon.Data.Unsafe!); - } - catch - { - Debug.WriteLine("Failed to load icon from stream"); - } - } - } - catch - { - } - - return null; - } - - private async Task<IconSource?> StreamToIconSource(IRandomAccessStreamReference iconStreamRef) - { - if (iconStreamRef == null) - { - return null; - } - - var bitmap = await IconStreamToBitmapImageAsync(iconStreamRef); - var icon = new ImageIconSource() { ImageSource = bitmap }; - return icon; - } - - private async Task<BitmapImage> IconStreamToBitmapImageAsync(IRandomAccessStreamReference iconStreamRef) - { - // Return the bitmap image via TaskCompletionSource. Using WCT's EnqueueAsync does not suffice here, since if - // we're already on the thread of the DispatcherQueue then it just directly calls the function, with no async involved. - var completionSource = new TaskCompletionSource<BitmapImage>(); - dispatcherQueue.TryEnqueue(async () => - { - using var bitmapStream = await iconStreamRef.OpenReadAsync(); - var itemImage = new BitmapImage(); - await itemImage.SetSourceAsync(bitmapStream); - completionSource.TrySetResult(itemImage); - }); - - var bitmapImage = await completionSource.Task; - - return bitmapImage; - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/CachedIconSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/CachedIconSourceProvider.cs new file mode 100644 index 0000000000..589e40d10f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/CachedIconSourceProvider.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal sealed class CachedIconSourceProvider : IIconSourceProvider +{ + private readonly AdaptiveCache<IconCacheKey, Task<IconSource?>> _cache; + private readonly Size _iconSize; + private readonly IconLoaderService _loader; + private readonly Lock _lock = new(); + + public CachedIconSourceProvider(IconLoaderService loader, Size iconSize, int cacheSize) + { + _loader = loader; + _iconSize = iconSize; + _cache = new AdaptiveCache<IconCacheKey, Task<IconSource?>>(cacheSize, TimeSpan.FromMinutes(60)); + } + + public CachedIconSourceProvider(IconLoaderService loader, int iconSize, int cacheSize) + : this(loader, new Size(iconSize, iconSize), cacheSize) + { + } + + public Task<IconSource?> GetIconSource(IconDataViewModel icon, double scale) + { + var key = new IconCacheKey(icon, scale); + + return _cache.TryGet(key, out var existingTask) + ? existingTask + : GetOrCreateSlowPath(key, icon, scale); + } + + private Task<IconSource?> GetOrCreateSlowPath(IconCacheKey key, IconDataViewModel icon, double scale) + { + lock (_lock) + { + if (_cache.TryGet(key, out var existingTask)) + { + return existingTask; + } + + var tcs = new TaskCompletionSource<IconSource?>(TaskCreationOptions.RunContinuationsAsynchronously); + + _loader.EnqueueLoad( + icon.Icon, + icon.FontFamily, + icon.Data?.Unsafe, + _iconSize, + scale, + tcs); + + var task = tcs.Task; + + _ = task.ContinueWith( + _ => + { + lock (_lock) + { + _cache.TryRemove(key); + } + }, + TaskContinuationOptions.OnlyOnFaulted); + + _cache.Add(key, task); + return task; + } + } + + private readonly struct IconCacheKey : IEquatable<IconCacheKey> + { + private readonly string? _icon; + private readonly string? _fontFamily; + private readonly int _streamRefHashCode; + private readonly int _scale; + + public IconCacheKey(IconDataViewModel icon, double scale) + { + _icon = icon.Icon; + _fontFamily = icon.FontFamily; + _streamRefHashCode = icon.Data?.Unsafe is { } stream + ? RuntimeHelpers.GetHashCode(stream) + : 0; + _scale = (int)(100 * Math.Round(scale, 2)); + } + + public bool Equals(IconCacheKey other) => + _icon == other._icon && + _fontFamily == other._fontFamily && + _streamRefHashCode == other._streamRefHashCode && + _scale == other._scale; + + public override bool Equals(object? obj) => obj is IconCacheKey other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(_icon, _fontFamily, _streamRefHashCode, _scale); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconLoaderService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconLoaderService.cs new file mode 100644 index 0000000000..c7f7f27e1b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconLoaderService.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal interface IIconLoaderService : IAsyncDisposable +{ + void EnqueueLoad( + string? iconString, + string? fontFamily, + IRandomAccessStreamReference? streamRef, + Size iconSize, + double scale, + TaskCompletionSource<IconSource?> tcs, + IconLoadPriority priority); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconSourceProvider.cs new file mode 100644 index 0000000000..1d3f3fc646 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IIconSourceProvider.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 Microsoft.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal interface IIconSourceProvider +{ + Task<IconSource?> GetIconSource(IconDataViewModel icon, double scale); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconCacheProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconCacheProvider.cs new file mode 100644 index 0000000000..40de39b5ab --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconCacheProvider.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.UI.Controls; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// <summary> +/// Common async event handler provides the cache lookup function for the <see cref="IconBox.SourceRequested"/> deferred event. +/// </summary> +public static partial class IconCacheProvider +{ + /* + Memory Usage Considerations (raw estimates): + | Icon Size | Per Icon | Count | Total | Per Icon @ 200% | Total @ 200% | Per Icon @ 300% | Total @ 300% | + | --------- | -------: | ----: | -------: | --------------: | -----------: | --------------: | -----------: | + | 20×20 | 1.6 KB | 1024 | 1.6 MB | 6.4 KB | 6.4 MB | 14.4 KB | 14.4 MB | + | 32×32 | 4.0 KB | 512 | 2.0 MB | 16 KB | 8.0 MB | 36.0 KB | 18.0 MB | + | 48×48 | 9.0 KB | 256 | 2.3 MB | 36 KB | 9.0 MB | 81.0 KB | 20.3 MB | + | 64×64 | 16.0 KB | 64 | 1.0 MB | 64 KB | 4.0 MB | 144.0 KB | 9.0 MB | + | 256×256 | 256.0 KB | 64 | 16.0 MB | 1 MB | 64.0 MB | 2.3 MB | 144 MB | + */ + + private static IIconSourceProvider _provider20 = null!; + private static IIconSourceProvider _provider32 = null!; + private static IIconSourceProvider _provider64 = null!; + private static IIconSourceProvider _provider256 = null!; + + public static void Initialize(IServiceProvider serviceProvider) + { + _provider20 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size20); + _provider32 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size32); + _provider64 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size64); + _provider256 = serviceProvider.GetRequiredKeyedService<IIconSourceProvider>(WellKnownIconSize.Size256); + } + + private static async void SourceRequestedCore(IIconSourceProvider service, SourceRequestedEventArgs args) + { + if (args.Key is null) + { + return; + } + + var deferral = args.GetDeferral(); + + try + { + args.Value = args.Key switch + { + IconDataViewModel iconData => await service.GetIconSource(iconData, args.Scale), + IconInfoViewModel iconInfo => await service.GetIconSource( + args.Theme == Microsoft.UI.Xaml.ElementTheme.Light ? iconInfo.Light : iconInfo.Dark, + args.Scale), + _ => null, + }; + } + finally + { + deferral.Complete(); + } + } + +#pragma warning disable IDE0060 // Remove unused parameter + public static void SourceRequested20(IconBox sender, SourceRequestedEventArgs args) + => SourceRequestedCore(_provider20, args); + + public static void SourceRequested32(IconBox sender, SourceRequestedEventArgs args) + => SourceRequestedCore(_provider32, args); + + public static void SourceRequested64(IconBox sender, SourceRequestedEventArgs args) + => SourceRequestedCore(_provider64, args); + + public static void SourceRequested256(IconBox sender, SourceRequestedEventArgs args) + => SourceRequestedCore(_provider256, args); +#pragma warning restore IDE0060 // Remove unused parameter +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ClearSearchMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadPriority.cs similarity index 68% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ClearSearchMessage.cs rename to src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadPriority.cs index e3b7ee831e..ff824da548 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ClearSearchMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoadPriority.cs @@ -2,8 +2,10 @@ // 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.Messages; +namespace Microsoft.CmdPal.UI.Helpers; -public record ClearSearchMessage() +internal enum IconLoadPriority { + Low, + High, } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs new file mode 100644 index 0000000000..6935802b50 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconLoaderService.cs @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading.Channels; +using CommunityToolkit.WinUI; +using ManagedCommon; +using Microsoft.Terminal.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Foundation; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal sealed partial class IconLoaderService : IIconLoaderService +{ + private const DispatcherQueuePriority LoadingPriorityOnDispatcher = DispatcherQueuePriority.Low; + private const int DefaultIconSize = 256; + private const int MaxWorkerCount = 4; + + private static readonly int WorkerCount = Math.Clamp(Environment.ProcessorCount / 2, 1, MaxWorkerCount); + + private readonly Channel<Func<Task>> _highPriorityQueue = Channel.CreateBounded<Func<Task>>(32); + private readonly Channel<Func<Task>> _lowPriorityQueue = Channel.CreateUnbounded<Func<Task>>(); + private readonly Task[] _workers; + private readonly DispatcherQueue _dispatcherQueue; + + public IconLoaderService(DispatcherQueue dispatcherQueue) + { + _dispatcherQueue = dispatcherQueue; + _workers = new Task[WorkerCount]; + + for (var i = 0; i < WorkerCount; i++) + { + _workers[i] = Task.Run(ProcessQueueAsync); + } + } + + public void EnqueueLoad( + string? iconString, + string? fontFamily, + IRandomAccessStreamReference? streamRef, + Size iconSize, + double scale, + TaskCompletionSource<IconSource?> tcs, + IconLoadPriority priority = IconLoadPriority.Low) + { + var workItem = () => LoadAndCompleteAsync(iconString, fontFamily, streamRef, iconSize, scale, tcs); + + if (priority == IconLoadPriority.High) + { + if (_highPriorityQueue.Writer.TryWrite(workItem)) + { + return; + } + +#if DEBUG + Logger.LogDebug("High priority icon queue full, falling back to low priority"); +#endif + } + + _lowPriorityQueue.Writer.TryWrite(workItem); + } + + public async ValueTask DisposeAsync() + { + _highPriorityQueue.Writer.Complete(); + _lowPriorityQueue.Writer.Complete(); + + await Task.WhenAll(_workers).ConfigureAwait(false); + } + + private async Task ProcessQueueAsync() + { + while (true) + { + Func<Task>? workItem; + + if (_highPriorityQueue.Reader.TryRead(out workItem)) + { + await ExecuteWork(workItem).ConfigureAwait(false); + continue; + } + + var highWait = _highPriorityQueue.Reader.WaitToReadAsync().AsTask(); + var lowWait = _lowPriorityQueue.Reader.WaitToReadAsync().AsTask(); + + await Task.WhenAny(highWait, lowWait).ConfigureAwait(false); + + // Check if both channels are completed (disposal) + if (_highPriorityQueue.Reader.Completion.IsCompleted && + _lowPriorityQueue.Reader.Completion.IsCompleted) + { + // Drain any remaining items + while (_highPriorityQueue.Reader.TryRead(out workItem)) + { + await ExecuteWork(workItem).ConfigureAwait(false); + } + + while (_lowPriorityQueue.Reader.TryRead(out workItem)) + { + await ExecuteWork(workItem).ConfigureAwait(false); + } + + break; + } + + if (_highPriorityQueue.Reader.TryRead(out workItem) || + _lowPriorityQueue.Reader.TryRead(out workItem)) + { + await ExecuteWork(workItem).ConfigureAwait(false); + } + } + + static async Task ExecuteWork(Func<Task> workItem) + { + try + { + await workItem().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError("Failed to load icon", ex); + } + } + } + + private async Task LoadAndCompleteAsync( + string? iconString, + string? fontFamily, + IRandomAccessStreamReference? streamRef, + Size iconSize, + double scale, + TaskCompletionSource<IconSource?> tcs) + { + try + { + var result = await LoadIconCoreAsync(iconString, fontFamily, streamRef, iconSize, scale).ConfigureAwait(false); + tcs.TrySetResult(result); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + } + + private async Task<IconSource?> LoadIconCoreAsync( + string? iconString, + string? fontFamily, + IRandomAccessStreamReference? streamRef, + Size iconSize, + double scale) + { + var scaledSize = new Size(iconSize.Width * scale, iconSize.Height * scale); + + if (!string.IsNullOrEmpty(iconString)) + { + return await _dispatcherQueue + .EnqueueAsync(() => GetStringIconSource(iconString, fontFamily, scaledSize), LoadingPriorityOnDispatcher) + .ConfigureAwait(false); + } + + if (streamRef != null) + { + try + { + using var bitmapStream = await streamRef.OpenReadAsync().AsTask().ConfigureAwait(false); + + return await _dispatcherQueue + .EnqueueAsync(BuildImageSource, LoadingPriorityOnDispatcher) + .ConfigureAwait(false); + + async Task<IconSource?> BuildImageSource() + { + var bitmap = new BitmapImage(); + ApplyDecodeSize(bitmap, scaledSize); + await bitmap.SetSourceAsync(bitmapStream); + return new ImageIconSource { ImageSource = bitmap }; + } + } +#pragma warning disable CS0168 // Variable is declared but never used + catch (Exception ex) +#pragma warning restore CS0168 // Variable is declared but never used + { +#if DEBUG + Logger.LogDebug($"Failed to open icon stream: {ex}"); +#endif + return null; + } + } + + return null; + } + + private static void ApplyDecodeSize(BitmapImage bitmap, Size size) + { + if (size.IsEmpty) + { + return; + } + + if (size.Width >= size.Height) + { + bitmap.DecodePixelWidth = (int)size.Width; + } + else + { + bitmap.DecodePixelHeight = (int)size.Height; + } + } + + private static IconSource? GetStringIconSource(string iconString, string? fontFamily, Size size) + { + var iconSize = (int)Math.Max(size.Width, size.Height); + if (iconSize == 0) + { + iconSize = DefaultIconSize; + } + + return IconPathConverter.IconSourceMUX(iconString, fontFamily, iconSize); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconServiceRegistration.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconServiceRegistration.cs new file mode 100644 index 0000000000..c5f3709276 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconServiceRegistration.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Dispatching; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal static class IconServiceRegistration +{ + public static IServiceCollection AddIconServices(this IServiceCollection services, DispatcherQueue dispatcherQueue) + { + // Single shared loader + var loader = new IconLoaderService(dispatcherQueue); + services.AddSingleton<IIconLoaderService>(loader); + + // Keyed providers by size + services.AddKeyedSingleton<IIconSourceProvider>( + WellKnownIconSize.Size20, + (_, _) => new CachedIconSourceProvider(loader, 20, 1024)); + + services.AddKeyedSingleton<IIconSourceProvider>( + WellKnownIconSize.Size32, + (_, _) => new IconSourceProvider(loader, 32)); + + services.AddKeyedSingleton<IIconSourceProvider>( + WellKnownIconSize.Size64, + (_, _) => new CachedIconSourceProvider(loader, 64, 256)); + + services.AddKeyedSingleton<IIconSourceProvider>( + WellKnownIconSize.Size256, + (_, _) => new CachedIconSourceProvider(loader, 256, 64)); + + return services; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconSourceProvider.cs new file mode 100644 index 0000000000..13c6ed764a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/IconSourceProvider.cs @@ -0,0 +1,41 @@ +// 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.ViewModels; +using Microsoft.UI.Xaml.Controls; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal sealed class IconSourceProvider : IIconSourceProvider +{ + private readonly IconLoaderService _loader; + private readonly Size _iconSize; + + public IconSourceProvider(IconLoaderService loader, Size iconSize) + { + _loader = loader; + _iconSize = iconSize; + } + + public IconSourceProvider(IconLoaderService loader, int iconSize) + : this(loader, new Size(iconSize, iconSize)) + { + } + + public Task<IconSource?> GetIconSource(IconDataViewModel icon, double scale) + { + var tcs = new TaskCompletionSource<IconSource?>(TaskCreationOptions.RunContinuationsAsynchronously); + + _loader.EnqueueLoad( + icon.Icon, + icon.FontFamily, + icon.Data?.Unsafe, + _iconSize, + scale, + tcs); + + return tcs.Task; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/WellKnownIconSize.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/WellKnownIconSize.cs new file mode 100644 index 0000000000..e35cbd46f5 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/Icons/WellKnownIconSize.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. + +namespace Microsoft.CmdPal.UI.Helpers; + +internal enum WellKnownIconSize +{ + Size20 = 20, + Size32 = 32, + Size64 = 64, + Size256 = 256, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListener.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListener.cs new file mode 100644 index 0000000000..788127a5d1 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListener.cs @@ -0,0 +1,157 @@ +// 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 ManagedCommon; + +using Windows.System; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// <summary> +/// A class that listens for local keyboard events using a Windows hook. +/// </summary> +internal sealed partial class LocalKeyboardListener : IDisposable +{ + /// <summary> + /// Event that is raised when a key is pressed down. + /// </summary> + public event EventHandler<LocalKeyboardListenerKeyPressedEventArgs>? KeyPressed; + + private bool _disposed; + private UnhookWindowsHookExSafeHandle? _handle; + private HOOKPROC? _hookProc; // Keep reference to prevent GC collection + + /// <summary> + /// Registers a global keyboard hook to listen for key down events. + /// </summary> + /// <exception cref="InvalidOperationException"> + /// Throws if the hook could not be registered, which may happen if the system is unable to set the hook. + /// </exception> + public void RegisterKeyboardHook() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_handle is not null && !_handle.IsInvalid) + { + // Hook is already set + return; + } + + _hookProc = KeyEventHook; + if (!SetWindowKeyHook(_hookProc)) + { + throw new InvalidOperationException("Failed to register keyboard hook."); + } + } + + /// <summary> + /// Attempts to register a global keyboard hook to listen for key down events. + /// </summary> + /// <returns> + /// <see langword="true"/> if the keyboard hook was successfully registered; otherwise, <see langword="false"/>. + /// </returns> + public bool Start() + { + if (_disposed) + { + return false; + } + + try + { + RegisterKeyboardHook(); + return true; + } + catch (Exception ex) + { + Logger.LogError("Failed to register hook", ex); + return false; + } + } + + private void UnregisterKeyboardHook() + { + if (_handle is not null && !_handle.IsInvalid) + { + // The SafeHandle should automatically call UnhookWindowsHookEx when disposed + _handle.Dispose(); + _handle = null; + } + + _hookProc = null; + } + + private bool SetWindowKeyHook(HOOKPROC hookProc) + { + if (_handle is not null && !_handle.IsInvalid) + { + // Hook is already set + return false; + } + + _handle = PInvoke.SetWindowsHookEx( + WINDOWS_HOOK_ID.WH_KEYBOARD, + hookProc, + PInvoke.GetModuleHandle(null), + PInvoke.GetCurrentThreadId()); + + // Check if the hook was successfully set + return _handle is not null && !_handle.IsInvalid; + } + + private static bool IsKeyDownHook(LPARAM lParam) + { + // The 30th bit tells what the previous key state is with 0 being the "UP" state + // For more info see https://learn.microsoft.com/windows/win32/winmsg/keyboardproc#lparam-in + return ((lParam.Value >> 30) & 1) == 0; + } + + private LRESULT KeyEventHook(int nCode, WPARAM wParam, LPARAM lParam) + { + try + { + if (nCode >= 0 && IsKeyDownHook(lParam)) + { + InvokeKeyDown((VirtualKey)wParam.Value); + } + } + catch (Exception ex) + { + Logger.LogError("Failed when invoking key down keyboard hook event", ex); + } + + // Call next hook in chain - pass null as first parameter for current hook + return PInvoke.CallNextHookEx(null, nCode, wParam, lParam); + } + + private void InvokeKeyDown(VirtualKey virtualKey) + { + if (!_disposed) + { + KeyPressed?.Invoke(this, new LocalKeyboardListenerKeyPressedEventArgs(virtualKey)); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + UnregisterKeyboardHook(); + } + + _disposed = true; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListenerKeyPressedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListenerKeyPressedEventArgs.cs new file mode 100644 index 0000000000..ce26cd037f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/LocalKeyboardListenerKeyPressedEventArgs.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 Windows.System; + +namespace Microsoft.CmdPal.UI.Helpers; + +public class LocalKeyboardListenerKeyPressedEventArgs(VirtualKey key) : EventArgs +{ + public VirtualKey Key { get; } = key; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/CompositeImageSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/CompositeImageSourceProvider.cs new file mode 100644 index 0000000000..3579d89cd9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/CompositeImageSourceProvider.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 Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +internal sealed partial class CompositeImageSourceProvider : IImageSourceProvider +{ + private readonly IImageSourceProvider[] _imageProviders = + [ + new HttpImageSourceProvider(), + new LocalImageSourceProvider(), + new DataImageSourceProvider() + ]; + + public Task<ImageSourceInfo> GetImageSource(string url) + { + var provider = _imageProviders.FirstOrDefault(p => p.ShouldUseThisProvider(url)); + if (provider == null) + { + throw new NotSupportedException($"No image provider found for URL: {url}"); + } + + return provider.GetImageSource(url); + } + + public bool ShouldUseThisProvider(string url) + { + return _imageProviders.Any(provider => provider.ShouldUseThisProvider(url)); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/DataImageSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/DataImageSourceProvider.cs new file mode 100644 index 0000000000..73d8fc96e4 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/DataImageSourceProvider.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +internal sealed partial class DataImageSourceProvider : IImageSourceProvider +{ + private readonly ImageSourceFactory.ImageDecodeOptions _decodeOptions; + + public DataImageSourceProvider(ImageSourceFactory.ImageDecodeOptions? decodeOptions = null) + => _decodeOptions = decodeOptions ?? new ImageSourceFactory.ImageDecodeOptions(); + + public bool ShouldUseThisProvider(string url) + => url.StartsWith("data:", StringComparison.OrdinalIgnoreCase); + + public async Task<ImageSourceInfo> GetImageSource(string url) + { + if (!ShouldUseThisProvider(url)) + { + throw new ArgumentException("URL is not a data: URI.", nameof(url)); + } + + // data:[<media type>][;base64],<data> + var comma = url.IndexOf(','); + if (comma < 0) + { + throw new FormatException("Invalid data URI: missing comma separator."); + } + + var header = url[5..comma]; // after "data:" + var payload = url[(comma + 1)..]; // after comma + + // Parse header + string? contentType = null; + var isBase64 = false; + + if (!string.IsNullOrEmpty(header)) + { + var parts = header.Split(';'); + + // first token may be media type + if (!string.IsNullOrWhiteSpace(parts[0]) && parts[0].Contains('/')) + { + contentType = parts[0]; + } + + isBase64 = parts.Any(static p => p.Equals("base64", StringComparison.OrdinalIgnoreCase)); + } + + var bytes = isBase64 + ? Convert.FromBase64String(payload) + : Encoding.UTF8.GetBytes(Uri.UnescapeDataString(payload)); + + var mem = new InMemoryRandomAccessStream(); + using (var writer = new DataWriter(mem.GetOutputStreamAt(0))) + { + writer.WriteBytes(bytes); + await writer.StoreAsync()!; + } + + mem.Seek(0); + + var imagePayload = new ImageSourceFactory.ImagePayload(mem, contentType, null); + var imageSource = await ImageSourceFactory.CreateAsync(imagePayload, _decodeOptions); + return new ImageSourceInfo(imageSource, new ImageHints + { + DownscaleOnly = true, + }); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/HttpImageSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/HttpImageSourceProvider.cs new file mode 100644 index 0000000000..357b55c7bd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/HttpImageSourceProvider.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.Runtime.InteropServices.WindowsRuntime; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +/// <summary> +/// Implementation of IImageProvider to handle http/https images, but adds +/// a new functionality to handle image scaling. +/// </summary> +internal sealed partial class HttpImageSourceProvider : IImageSourceProvider +{ + private readonly HttpClient _http; + + public HttpImageSourceProvider(HttpClient? httpClient = null) + => _http = httpClient ?? SharedHttpClient.Instance; + + public bool ShouldUseThisProvider(string url) + => Uri.TryCreate(url, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + + public async Task<ImageSourceInfo> GetImageSource(string url) + { + if (!ShouldUseThisProvider(url)) + { + throw new ArgumentException("URL must be absolute http/https.", nameof(url)); + } + + using var req = new HttpRequestMessage(HttpMethod.Get, url); + + req.Headers.TryAddWithoutValidation("Accept", "image/*,text/xml;q=0.9,application/xml;q=0.9,*/*;q=0.8"); + + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); + resp.EnsureSuccessStatusCode(); + + var contentType = resp.Content.Headers.ContentType?.MediaType; + + using var mem = new InMemoryRandomAccessStream(); + await CopyToRandomAccessStreamAsync(resp, mem); + + var hints = ImageHints.ParseHintsFromUri(new Uri(url)); + var imageSource = await ImageSourceFactory.CreateAsync( + new ImageSourceFactory.ImagePayload(mem, contentType, new Uri(url)), + new ImageSourceFactory.ImageDecodeOptions { SniffContent = true }); + + return new ImageSourceInfo(imageSource, hints); + } + + private static async Task CopyToRandomAccessStreamAsync(HttpResponseMessage resp, InMemoryRandomAccessStream mem) + { + var data = await resp.Content.ReadAsByteArrayAsync(); + await mem.WriteAsync(data.AsBuffer()); + mem.Seek(0); + } + + private static class SharedHttpClient + { + public static readonly HttpClient Instance = Create(); + + private static HttpClient Create() + { + var c = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30), + }; + c.DefaultRequestHeaders.UserAgent.ParseAdd("CommandPalette/1.0"); + return c; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/IImageSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/IImageSourceProvider.cs new file mode 100644 index 0000000000..f7aa48ff35 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/IImageSourceProvider.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.Helpers.MarkdownImageProviders; + +internal interface IImageSourceProvider +{ + Task<ImageSourceInfo> GetImageSource(string url); + + bool ShouldUseThisProvider(string url); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageHints.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageHints.cs new file mode 100644 index 0000000000..223ce94965 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageHints.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +internal sealed class ImageHints +{ + public static ImageHints Empty { get; } = new(); + + public double? DesiredPixelWidth { get; init; } + + public double? DesiredPixelHeight { get; init; } + + public double? MaxPixelWidth { get; init; } + + public double? MaxPixelHeight { get; init; } + + public bool? DownscaleOnly { get; init; } + + public string? FitMode { get; init; } // fit=fit + + public static ImageHints ParseHintsFromUri(Uri? uri) + { + if (uri is null || string.IsNullOrEmpty(uri.Query)) + { + return Empty; + } + + var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + foreach (var p in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = p.Split('=', 2); + var k = Uri.UnescapeDataString(kv[0]); + var v = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : string.Empty; + dict[k] = v; + } + + return new ImageHints + { + DesiredPixelWidth = GetInt("--x-cmdpal-width"), + DesiredPixelHeight = GetInt("--x-cmdpal-height"), + MaxPixelWidth = GetInt("--x-cmdpal-maxwidth"), + MaxPixelHeight = GetInt("--x-cmdpal-maxheight"), + DownscaleOnly = GetBool("--x-cmdpal-downscaleOnly") ?? (GetBool("--x-cmdpal-upscale") is bool u ? !u : (bool?)null), + FitMode = dict.TryGetValue("--x-cmdpal-fit", out var f) ? f : null, + }; + + int? GetInt(params string[] keys) + { + foreach (var k in keys) + { + if (dict.TryGetValue(k, out var v) && int.TryParse(v, out var n)) + { + return n; + } + } + + return null; + } + + bool? GetBool(params string[] keys) + { + foreach (var k in keys) + { + if (dict.TryGetValue(k, out var v) && (v.Equals("true", StringComparison.OrdinalIgnoreCase) || v == "1")) + { + return true; + } + else if (dict.TryGetValue(k, out var v2) && (v2.Equals("false", StringComparison.OrdinalIgnoreCase) || v2 == "0")) + { + return false; + } + } + + return null; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageProvider.cs new file mode 100644 index 0000000000..6f3d3b446c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageProvider.cs @@ -0,0 +1,43 @@ +// 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 CommunityToolkit.WinUI.Controls; +using ManagedCommon; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +internal sealed partial class ImageProvider : IImageProvider +{ + private readonly CompositeImageSourceProvider _compositeProvider = new(); + + public async Task<Image> GetImage(string url) + { + try + { + ImageSourceFactory.Initialize(); + + var imageSource = await _compositeProvider.GetImageSource(url); + return RtbInlineImageFactory.Create(imageSource.ImageSource, new RtbInlineImageFactory.InlineImageOptions + { + DownscaleOnly = imageSource.Hints.DownscaleOnly ?? true, + FitColumnWidth = imageSource.Hints.FitMode == "fit", + MaxWidthDip = imageSource.Hints.MaxPixelWidth, + MaxHeightDip = imageSource.Hints.MaxPixelHeight, + WidthDip = imageSource.Hints.DesiredPixelWidth, + HeightDip = imageSource.Hints.DesiredPixelHeight, + }); + } + catch (Exception ex) + { + Logger.LogError($"Failed to provide an image from URI '{url}'", ex); + return null!; + } + } + + public bool ShouldUseThisProvider(string url) + { + return _compositeProvider.ShouldUseThisProvider(url); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageSourceFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageSourceFactory.cs new file mode 100644 index 0000000000..61a596f221 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageSourceFactory.cs @@ -0,0 +1,145 @@ +// 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.Xml.Linq; +using CommunityToolkit.WinUI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Foundation; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +/// <summary> +/// Creates a new image source. +/// </summary> +internal static class ImageSourceFactory +{ + private static DispatcherQueue? _dispatcherQueue; + + public static void Initialize() + { + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + } + + internal sealed record ImagePayload( + IRandomAccessStream Stream, + string? ContentType, + Uri? SourceUri); + + internal sealed class ImageDecodeOptions + { + public bool SniffContent { get; init; } = true; + + public int? DecodePixelWidth { get; init; } + + public int? DecodePixelHeight { get; init; } + } + + public static async Task<ImageSource> CreateAsync(ImagePayload payload, ImageDecodeOptions? options = null) + { + options ??= new ImageDecodeOptions(); + + var isSvg = + IsSvgByHeaderOrUrl(payload.ContentType, payload.SourceUri) || + (options.SniffContent && SniffSvg(payload.Stream)); + + payload.Stream.Seek(0); + + return await _dispatcherQueue!.EnqueueAsync(async () => + { + if (isSvg) + { + var size = GetSvgSize(payload.Stream.AsStreamForRead()); + payload.Stream.Seek(0); + + var svg = new SvgImageSource(); + await svg.SetSourceAsync(payload.Stream); + svg.RasterizePixelWidth = size.Width; + svg.RasterizePixelHeight = size.Height; + return svg; + } + else + { + var bmp = new BitmapImage(); + if (options.DecodePixelWidth is int w and > 0) + { + bmp.DecodePixelWidth = w; + } + + if (options.DecodePixelHeight is int h and > 0) + { + bmp.DecodePixelHeight = h; + } + + await bmp.SetSourceAsync(payload.Stream); + return (ImageSource)bmp; + } + }); + } + + public static Size GetSvgSize(Stream stream) + { + // Parse the SVG string as an XML document + var svgDocument = XDocument.Load(stream); + + // Get the root element of the document +#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. + var svgElement = svgDocument.Root; + + // Get the height and width attributes of the root element +#pragma warning disable CS8602 // Dereference of a possibly null reference. + var heightAttribute = svgElement.Attribute("height"); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + var widthAttribute = svgElement.Attribute("width"); +#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type. + + // Convert the attribute values to double + double.TryParse(heightAttribute?.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var height); + double.TryParse(widthAttribute?.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var width); + + // Return the height and width as a tuple + return new(width, height); + } + + private static bool IsSvgByHeaderOrUrl(string? contentType, Uri? uri) + { + if (!string.IsNullOrEmpty(contentType) && + contentType.StartsWith("image/svg+xml", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var s = uri?.ToString(); + return !string.IsNullOrEmpty(s) && s.Contains(".svg", StringComparison.OrdinalIgnoreCase); + } + + private static bool SniffSvg(IRandomAccessStream ras) + { + try + { + const int maxProbe = 1024; + ras.Seek(0); + var s = ras.AsStreamForRead(); + var toRead = (int)Math.Min(ras.Size, maxProbe); + var buf = new byte[toRead]; + var read = s.Read(buf, 0, toRead); + if (read <= 0) + { + return false; + } + + var head = System.Text.Encoding.UTF8.GetString(buf, 0, read); + ras.Seek(0); + return head.Contains("<svg", StringComparison.OrdinalIgnoreCase); + } + catch + { + ras.Seek(0); + return false; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageSourceInfo.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageSourceInfo.cs new file mode 100644 index 0000000000..563e9029ca --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/ImageSourceInfo.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml.Media; + +namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +internal sealed record ImageSourceInfo(ImageSource ImageSource, ImageHints Hints); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/LocalImageSourceProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/LocalImageSourceProvider.cs new file mode 100644 index 0000000000..92fcf67d1a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/LocalImageSourceProvider.cs @@ -0,0 +1,113 @@ +// 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.Media; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +internal sealed partial class LocalImageSourceProvider : IImageSourceProvider +{ + private readonly ImageSourceFactory.ImageDecodeOptions _decodeOptions; + + public LocalImageSourceProvider(ImageSourceFactory.ImageDecodeOptions? decodeOptions = null) + => _decodeOptions = decodeOptions ?? new ImageSourceFactory.ImageDecodeOptions(); + + public bool ShouldUseThisProvider(string url) + { + if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) && uri.IsAbsoluteUri) + { + var scheme = uri.Scheme.ToLowerInvariant(); + return scheme is "file" or "ms-appx" or "ms-appdata"; + } + + return false; + } + + public async Task<ImageSourceInfo> GetImageSource(string url) + { + if (!ShouldUseThisProvider(url)) + { + throw new ArgumentException("Not a local URL/path (file, ms-appx, ms-appdata).", nameof(url)); + } + + // Absolute URI? + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.IsAbsoluteUri) + { + var scheme = uri.Scheme.ToLowerInvariant(); + + var hints = ImageHints.ParseHintsFromUri(uri); + + if (scheme is "ms-appx" or "ms-appdata") + { + // Load directly from the package/appdata URI + var rasRef = RandomAccessStreamReference.CreateFromUri(uri); + using var ras = await rasRef.OpenReadAsync(); + var payload = new ImageSourceFactory.ImagePayload(ras, ImageContentTypeHelper.GuessFromPathOrUri(uri.AbsoluteUri), uri); + return new ImageSourceInfo(await ImageSourceFactory.CreateAsync(payload, _decodeOptions), hints); + } + + if (scheme is "file") + { + var path = uri.LocalPath; + return new ImageSourceInfo(await FromFilePathAsync(path, uri, _decodeOptions), hints); + } + } + + throw new InvalidOperationException("Unsupported local URL/path."); + } + + private static async Task<ImageSource> FromFilePathAsync(string path, Uri sourceUri, ImageSourceFactory.ImageDecodeOptions decodeOptions) + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, useAsync: true); + using var mem = new InMemoryRandomAccessStream(); + using var outStream = mem.AsStreamForWrite(); + await fs.CopyToAsync(outStream).ConfigureAwait(true); + await outStream.FlushAsync().ConfigureAwait(true); + + mem.Seek(0); + + var payload = new ImageSourceFactory.ImagePayload(mem, ImageContentTypeHelper.GuessFromPathOrUri(path), sourceUri); + return await ImageSourceFactory.CreateAsync(payload, decodeOptions).ConfigureAwait(true); + } + + private static class ImageContentTypeHelper + { + public static string? GuessFromPathOrUri(string? pathOrUri) + { + if (string.IsNullOrEmpty(pathOrUri)) + { + return null; + } + + // Try to get extension from path/uri + var ext = Path.GetExtension(pathOrUri); + if (string.IsNullOrEmpty(ext)) + { + // try query-less URI path portion + if (Uri.TryCreate(pathOrUri, UriKind.RelativeOrAbsolute, out var u)) + { + ext = Path.GetExtension(u.IsAbsoluteUri ? u.AbsolutePath : u.OriginalString); + } + } + + ext = ext?.Trim().ToLowerInvariant(); + return ext switch + { + ".svg" => "image/svg+xml", + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".bmp" => "image/bmp", + ".ico" => "image/x-icon", + ".tif" => "image/tiff", + ".tiff" => "image/tiff", + ".avif" => "image/avif", + _ => null, + }; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/RtbInlineImageFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/RtbInlineImageFactory.cs new file mode 100644 index 0000000000..3f0f027566 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/MarkdownImageProviders/RtbInlineImageFactory.cs @@ -0,0 +1,274 @@ +// 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; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders; + +/// <summary> +/// Creates a new image configured to behave well as an inline image in a RichTextBlock. +/// </summary> +internal static class RtbInlineImageFactory +{ + public sealed class InlineImageOptions + { + public double? WidthDip { get; init; } + + public double? HeightDip { get; init; } + + public double? MaxWidthDip { get; init; } + + public double? MaxHeightDip { get; init; } + + public bool FitColumnWidth { get; init; } = true; + + public bool DownscaleOnly { get; init; } = true; + + public Stretch Stretch { get; init; } = Stretch.None; + } + + internal static Image Create(ImageSource source, InlineImageOptions? options = null) + { + options ??= new InlineImageOptions(); + + var img = new Image + { + Source = source, + Stretch = options.Stretch, + HorizontalAlignment = HorizontalAlignment.Stretch, + }; + + // Track host RTB and subscribe once + RichTextBlock? rtb = null; + long paddingToken = 0; + var paddingSubscribed = false; + + SizeChangedEventHandler? rtbSizeChangedHandler = null; + TypedEventHandler<XamlRoot, XamlRootChangedEventArgs>? xamlRootChangedHandler = null; + + // If Source is replaced later, recompute + var sourceToken = img.RegisterPropertyChangedCallback(Image.SourceProperty!, (_, __) => Update()); + + img.Loaded += OnLoaded; + img.Unloaded += OnUnloaded; + img.ImageOpened += (_, __) => Update(); + + return img; + + void OnLoaded(object? s, RoutedEventArgs e) + { + // store image initial Width and Height if they are force upon the image by + // MarkdownControl itself as result of parsing <img width="123" height="123" /> + // If user sets Width/Height in options, that takes precedence + img.Tag ??= (img.Width, img.Height); + + rtb ??= FindAncestor<RichTextBlock>(img); + if (rtb != null && !paddingSubscribed) + { + rtbSizeChangedHandler ??= (_, __) => Update(); + rtb.SizeChanged += rtbSizeChangedHandler; + + paddingToken = rtb.RegisterPropertyChangedCallback(Control.PaddingProperty!, (_, __) => Update()); + paddingSubscribed = true; + } + + if (img.XamlRoot != null) + { + xamlRootChangedHandler ??= (_, __) => Update(); + img.XamlRoot.Changed += xamlRootChangedHandler; + } + + Update(); + } + + void OnUnloaded(object? s, RoutedEventArgs e) + { + if (rtb != null && rtbSizeChangedHandler is not null) + { + rtb.SizeChanged -= rtbSizeChangedHandler; + } + + if (rtb != null && paddingSubscribed) + { + rtb.UnregisterPropertyChangedCallback(Control.PaddingProperty!, paddingToken); + paddingSubscribed = false; + } + + if (img.XamlRoot != null && xamlRootChangedHandler is not null) + { + img.XamlRoot.Changed -= xamlRootChangedHandler; + } + + img.UnregisterPropertyChangedCallback(Image.SourceProperty!, sourceToken); + } + + void Update() + { + if (rtb is null) + { + return; + } + + double? externalWidth = null; + double? externalHeight = null; + if (img.Tag != null) + { + (externalWidth, externalHeight) = ((double, double))img.Tag; + } + + var pad = rtb.Padding; + var columnDip = Math.Max(0.0, rtb.ActualWidth - pad.Left - pad.Right); + var scale = img.XamlRoot?.RasterizationScale is double s1 and > 0 ? s1 : 1.0; + + var isSvg = img.Source is SvgImageSource; + var naturalPxW = GetNaturalPixelWidth(img.Source); + var naturalDipW = naturalPxW > 0 && naturalPxW != int.MaxValue ? naturalPxW / scale : double.PositiveInfinity; // SVG => ∞ + + double? desiredWidth = null; + if (externalWidth.HasValue && !double.IsNaN(externalWidth.Value)) + { + img.Width = externalWidth.Value; + desiredWidth = externalWidth.Value; + } + else + { + if (options.WidthDip is double forcedW) + { + desiredWidth = options.DownscaleOnly && naturalPxW != int.MaxValue + ? Math.Min(forcedW, naturalPxW) + : forcedW; + } + else if (options.FitColumnWidth) + { + desiredWidth = options.DownscaleOnly && naturalPxW != int.MaxValue + ? Math.Min(columnDip, naturalPxW) + : columnDip; + } + else + { + desiredWidth = naturalPxW; + } + + // Apply MaxWidth (never exceed column width by default) + double maxW; + var maxConstraint = options.FitColumnWidth ? columnDip : (isSvg ? 256 : double.PositiveInfinity); + if (options.MaxWidthDip.HasValue) + { + maxW = Math.Min(options.MaxWidthDip.Value, maxConstraint); + } + else if (options.DownscaleOnly) + { + maxW = Math.Min(naturalPxW, maxConstraint); + } + else + { + maxW = maxConstraint; + } + + // Commit sizes + if (desiredWidth.HasValue) + { + img.Width = Math.Max(0, desiredWidth.Value); + } + + img.MaxWidth = maxW > 0 ? maxW : maxConstraint; + } + + if (externalHeight.HasValue && !double.IsNaN(externalHeight.Value)) + { + img.Height = externalHeight.Value; + } + else + { + // ---- Height & MaxHeight ---- + var desiredHeight = options.HeightDip; + var maxH = options.MaxHeightDip; + + if (desiredHeight is double h) + { + img.Height = Math.Max(0, h); + } + + if (maxH is double mh && mh > 0) + { + img.MaxHeight = mh; + } + } + + if (options.FitColumnWidth + || options.WidthDip is not null + || options.HeightDip is not null + || options.MaxWidthDip is not null + || options.MaxHeightDip is not null + || externalWidth.HasValue + || externalHeight.HasValue) + { + img.Stretch = Stretch.Uniform; + } + else + { + img.Stretch = Stretch.None; + } + + // Decode/rasterization hints + if (isSvg && img.Source is SvgImageSource svg) + { + var targetW = desiredWidth ?? Math.Min(img.MaxWidth, columnDip); + var pxW = Math.Max(1, (int)Math.Round(targetW * scale)); + if ((int)svg.RasterizePixelWidth != pxW) + { + svg.RasterizePixelWidth = pxW; + } + + if (options.HeightDip is double forcedH) + { + var pxH = Math.Max(1, (int)Math.Round(forcedH * scale)); + if ((int)svg.RasterizePixelHeight != pxH) + { + svg.RasterizePixelHeight = pxH; + } + } + } + else if (img.Source is BitmapImage bi && naturalPxW > 0) + { + var widthToUse = desiredWidth ?? Math.Min(img.MaxWidth, columnDip); + if (widthToUse > 0) + { + var desiredPx = (int)Math.Round(Math.Min(naturalPxW, widthToUse * scale)); + if (desiredPx > 0 && bi.DecodePixelWidth != desiredPx) + { + bi.DecodePixelWidth = desiredPx; + } + } + } + } + } + + private static int GetNaturalPixelWidth(ImageSource? src) => src switch + { + BitmapSource bs when bs.PixelWidth > 0 => bs.PixelWidth, // raster + SvgImageSource sis => sis.RasterizePixelWidth > 0 ? (int)sis.RasterizePixelWidth : int.MaxValue, // vector => infinite + _ => 0, + }; + + private static T? FindAncestor<T>(DependencyObject start) + where T : DependencyObject + { + var cur = (DependencyObject?)start; + while (cur != null) + { + cur = VisualTreeHelper.GetParent(cur); + if (cur is T hit) + { + return hit; + } + } + + return null; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/NativeMethods.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/NativeMethods.cs new file mode 100644 index 0000000000..a3227ca77c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/NativeMethods.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using System.Security; + +namespace Microsoft.CmdPal.UI.Helpers; + +[SuppressUnmanagedCodeSecurity] +internal static class NativeMethods +{ + [DllImport("shell32.dll")] + public static extern int SHQueryUserNotificationState(out UserNotificationState state); +} + +internal enum UserNotificationState : int +{ + QUNS_NOT_PRESENT = 1, + QUNS_BUSY, + QUNS_RUNNING_D3D_FULL_SCREEN, + QUNS_PRESENTATION_MODE, + QUNS_ACCEPTS_NOTIFICATIONS, + QUNS_QUIET_TIME, + QUNS_APP, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs new file mode 100644 index 0000000000..37139bb982 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs @@ -0,0 +1,95 @@ +// 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 CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.UI.Events; +using Microsoft.CommandPalette.Extensions; +using Microsoft.PowerToys.Telemetry; + +namespace Microsoft.CmdPal.UI; + +/// <summary> +/// TelemetryForwarder is responsible for forwarding telemetry events from the +/// command palette to PowerToys Telemetry. +/// Listens to telemetry-specific messages from the core layer and logs them to PowerToys telemetry. +/// Also implements ITelemetryService for dependency injection in extensions. +/// </summary> +internal sealed class TelemetryForwarder : + ITelemetryService, + IRecipient<TelemetryBeginInvokeMessage>, + IRecipient<TelemetryInvokeResultMessage>, + IRecipient<TelemetryExtensionInvokedMessage> +{ + public TelemetryForwarder() + { + WeakReferenceMessenger.Default.Register<TelemetryBeginInvokeMessage>(this); + WeakReferenceMessenger.Default.Register<TelemetryInvokeResultMessage>(this); + WeakReferenceMessenger.Default.Register<TelemetryExtensionInvokedMessage>(this); + } + + // Message handlers for telemetry events from core layer + public void Receive(TelemetryBeginInvokeMessage message) + { + PowerToysTelemetry.Log.WriteEvent(new BeginInvoke()); + } + + public void Receive(TelemetryInvokeResultMessage message) + { + PowerToysTelemetry.Log.WriteEvent(new CmdPalInvokeResult(message.Kind)); + } + + public void Receive(TelemetryExtensionInvokedMessage message) + { + PowerToysTelemetry.Log.WriteEvent(new CmdPalExtensionInvoked( + message.ExtensionId, + message.CommandId, + message.CommandName, + message.Success, + message.ExecutionTimeMs)); + + // Increment session counter for commands executed + if (App.Current.AppWindow is MainWindow mainWindow) + { + mainWindow.IncrementCommandsExecuted(); + } + } + + // Static method for logging session duration from UI layer + public static void LogSessionDuration( + ulong durationMs, + int commandsExecuted, + int pagesVisited, + string dismissalReason, + int searchQueriesCount, + int maxNavigationDepth, + int errorCount) + { + PowerToysTelemetry.Log.WriteEvent(new CmdPalSessionDuration( + durationMs, + commandsExecuted, + pagesVisited, + dismissalReason, + searchQueriesCount, + maxNavigationDepth, + errorCount)); + } + + // ITelemetryService implementation for dependency injection in extensions + public void LogRunQuery(string query, int resultCount, ulong durationMs) + { + PowerToysTelemetry.Log.WriteEvent(new CmdPalRunQuery(query, resultCount, durationMs)); + } + + public void LogRunCommand(string command, bool asAdmin, bool success) + { + PowerToysTelemetry.Log.WriteEvent(new CmdPalRunCommand(command, asAdmin, success)); + } + + public void LogOpenUri(string uri, bool isWeb, bool success) + { + PowerToysTelemetry.Log.WriteEvent(new CmdPalOpenUri(uri, isWeb, success)); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs new file mode 100644 index 0000000000..f5103b9efc --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TextBoxCaretColor.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using CommunityToolkit.WinUI; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Rectangle = Microsoft.UI.Xaml.Shapes.Rectangle; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// <summary> +/// Attached property to color internal caret/overlay rectangles inside a TextBox +/// so they follow the TextBox's actual Foreground brush. +/// </summary> +public static class TextBoxCaretColor +{ + public static readonly DependencyProperty SyncWithForegroundProperty = + DependencyProperty.RegisterAttached("SyncWithForeground", typeof(bool), typeof(TextBoxCaretColor), new PropertyMetadata(false, OnSyncCaretRectanglesChanged))!; + + private static readonly ConditionalWeakTable<TextBox, State> States = []; + + public static void SetSyncWithForeground(DependencyObject obj, bool value) + { + obj.SetValue(SyncWithForegroundProperty, value); + } + + public static bool GetSyncWithForeground(DependencyObject obj) + { + return (bool)obj.GetValue(SyncWithForegroundProperty); + } + + private static void OnSyncCaretRectanglesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not TextBox tb) + { + return; + } + + if ((bool)e.NewValue) + { + Attach(tb); + } + else + { + Detach(tb); + } + } + + private static void Attach(TextBox tb) + { + if (States.TryGetValue(tb, out var st) && st.IsHooked) + { + return; + } + + st ??= new State(); + st.IsHooked = true; + States.Remove(tb); + States.Add(tb, st); + + tb.Loaded += TbOnLoaded; + tb.Unloaded += TbOnUnloaded; + tb.GotFocus += TbOnGotFocus; + + st.ForegroundToken = tb.RegisterPropertyChangedCallback(Control.ForegroundProperty!, (_, _) => Apply(tb)); + + if (tb.IsLoaded) + { + Apply(tb); + } + } + + private static void Detach(TextBox tb) + { + if (!States.TryGetValue(tb, out var st)) + { + return; + } + + tb.Loaded -= TbOnLoaded; + tb.Unloaded -= TbOnUnloaded; + tb.GotFocus -= TbOnGotFocus; + + if (st.ForegroundToken != 0) + { + tb.UnregisterPropertyChangedCallback(Control.ForegroundProperty!, st.ForegroundToken); + st.ForegroundToken = 0; + } + + st.IsHooked = false; + } + + private static void TbOnLoaded(object sender, RoutedEventArgs e) + { + if (sender is TextBox tb) + { + Apply(tb); + } + } + + private static void TbOnUnloaded(object sender, RoutedEventArgs e) + { + if (sender is TextBox tb) + { + Detach(tb); + } + } + + private static void TbOnGotFocus(object sender, RoutedEventArgs e) + { + if (sender is TextBox tb) + { + Apply(tb); + } + } + + private static void Apply(TextBox tb) + { + try + { + ApplyCore(tb); + } + catch (COMException) + { + // ignore + } + } + + private static void ApplyCore(TextBox tb) + { + // Ensure template is realized + tb.ApplyTemplate(); + + // Find the internal ScrollContentPresenter within the TextBox template + var scp = tb.FindDescendant<ScrollContentPresenter>(s => s.Name == "ScrollContentPresenter"); + if (scp is null) + { + return; + } + + var brush = tb.Foreground; // use the actual current foreground brush + if (brush == null) + { + brush = new SolidColorBrush(Colors.Black); + } + + foreach (var rect in scp.FindDescendants().OfType<Rectangle>()) + { + try + { + rect.Fill = brush; + rect.CompositeMode = ElementCompositeMode.SourceOver; + rect.Opacity = 0.9; + } + catch + { + // best-effort; some rectangles might be template-owned + } + } + } + + private sealed class State + { + public long ForegroundToken { get; set; } + + public bool IsHooked { get; set; } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs new file mode 100644 index 0000000000..d291172297 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.UI.Messages; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.UI.Xaml; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.WindowsAndMessaging; +using WinRT.Interop; +using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; + +namespace Microsoft.CmdPal.UI.Helpers; + +[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_*")] +[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_*")] +internal sealed partial class TrayIconService +{ + private const uint MY_NOTIFY_ID = 1000; + private const uint WM_TRAY_ICON = PInvoke.WM_USER + 1; + + private readonly SettingsModel _settingsModel; + private readonly uint WM_TASKBAR_RESTART; + + private Window? _window; + private HWND _hwnd; + private WNDPROC? _originalWndProc; + private WNDPROC? _trayWndProc; + private NOTIFYICONDATAW? _trayIconData; + private DestroyIconSafeHandle? _largeIcon; + private DestroyMenuSafeHandle? _popupMenu; + + public TrayIconService(SettingsModel settingsModel) + { + _settingsModel = settingsModel; + + // TaskbarCreated is the message that's broadcast when explorer.exe + // restarts. We need to know when that happens to be able to bring our + // notification area icon back + WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); + } + + public void SetupTrayIcon(bool? showSystemTrayIcon = null) + { + if (showSystemTrayIcon ?? _settingsModel.ShowSystemTrayIcon) + { + if (_window is null) + { + _window = new Window(); + _hwnd = new HWND(WindowNative.GetWindowHandle(_window)); + + // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a + // member (and instead like, use a local), then the pointer we marshal + // into the WindowLongPtr will be useless after we leave this function, + // and our **WindProc will explode**. + _trayWndProc = WindowProc; + var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_trayWndProc); + _originalWndProc = Marshal.GetDelegateForFunctionPointer<WNDPROC>(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer)); + } + + if (_trayIconData is null) + { + // We need to stash this handle, so it doesn't clean itself up. If + // explorer restarts, we'll come back through here, and we don't + // really need to re-load the icon in that case. We can just use + // the handle from the first time. + _largeIcon = GetAppIconHandle(); + _trayIconData = new NOTIFYICONDATAW() + { + cbSize = (uint)Marshal.SizeOf<NOTIFYICONDATAW>(), + hWnd = _hwnd, + uID = MY_NOTIFY_ID, + uFlags = NOTIFY_ICON_DATA_FLAGS.NIF_MESSAGE | NOTIFY_ICON_DATA_FLAGS.NIF_ICON | NOTIFY_ICON_DATA_FLAGS.NIF_TIP, + uCallbackMessage = WM_TRAY_ICON, + hIcon = (HICON)_largeIcon.DangerousGetHandle(), + szTip = RS_.GetString("AppStoreName"), + }; + } + + var d = (NOTIFYICONDATAW)_trayIconData; + + // Add the notification icon + PInvoke.Shell_NotifyIcon(NOTIFY_ICON_MESSAGE.NIM_ADD, in d); + + if (_popupMenu is null) + { + _popupMenu = PInvoke.CreatePopupMenu_SafeHandle(); + PInvoke.InsertMenu(_popupMenu, 0, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 1, RS_.GetString("TrayMenu_Settings")); + PInvoke.InsertMenu(_popupMenu, 1, MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING, PInvoke.WM_USER + 2, RS_.GetString("TrayMenu_Close")); + } + } + else + { + Destroy(); + } + } + + public void Destroy() + { + if (_trayIconData is not null) + { + var d = (NOTIFYICONDATAW)_trayIconData; + if (PInvoke.Shell_NotifyIcon(NOTIFY_ICON_MESSAGE.NIM_DELETE, in d)) + { + _trayIconData = null; + } + } + + if (_popupMenu is not null) + { + _popupMenu.Close(); + _popupMenu = null; + } + + if (_largeIcon is not null) + { + _largeIcon.Close(); + _largeIcon = null; + } + + if (_window is not null) + { + _window.Close(); + _window = null; + _hwnd = HWND.Null; + } + } + + private DestroyIconSafeHandle GetAppIconHandle() + { + var exePath = Path.Combine(AppContext.BaseDirectory, "Microsoft.CmdPal.UI.exe"); + DestroyIconSafeHandle largeIcon; + PInvoke.ExtractIconEx(exePath, 0, out largeIcon, out _, 1); + return largeIcon; + } + + private LRESULT WindowProc( + HWND hwnd, + uint uMsg, + WPARAM wParam, + LPARAM lParam) + { + switch (uMsg) + { + case PInvoke.WM_COMMAND: + { + if (wParam == PInvoke.WM_USER + 1) + { + WeakReferenceMessenger.Default.Send(new OpenSettingsMessage()); + } + else if (wParam == PInvoke.WM_USER + 2) + { + WeakReferenceMessenger.Default.Send<QuitMessage>(); + } + } + + break; + + // Shell_NotifyIcon can fail when we invoke it during the time explorer.exe isn't present/ready to handle it. + // We'll also never receive WM_TASKBAR_RESTART message if the first call to Shell_NotifyIcon failed, so we use + // WM_WINDOWPOSCHANGING which is always received on explorer startup sequence. + case PInvoke.WM_WINDOWPOSCHANGING: + { + if (_trayIconData is null) + { + SetupTrayIcon(); + } + } + + break; + default: + // WM_TASKBAR_RESTART isn't a compile-time constant, so we can't + // use it in a case label + if (uMsg == WM_TASKBAR_RESTART) + { + // Handle the case where explorer.exe restarts. + // Even if we created it before, do it again + SetupTrayIcon(); + } + else if (uMsg == WM_TRAY_ICON) + { + switch ((uint)lParam.Value) + { + case PInvoke.WM_RBUTTONUP: + { + if (_popupMenu is not null) + { + PInvoke.GetCursorPos(out var cursorPos); + PInvoke.SetForegroundWindow(_hwnd); + PInvoke.TrackPopupMenuEx(_popupMenu, (uint)TRACK_POPUP_MENU_FLAGS.TPM_LEFTALIGN | (uint)TRACK_POPUP_MENU_FLAGS.TPM_BOTTOMALIGN, cursorPos.X, cursorPos.Y, _hwnd, null); + } + } + + break; + case PInvoke.WM_LBUTTONUP: + case PInvoke.WM_LBUTTONDBLCLK: + WeakReferenceMessenger.Default.Send<HotkeySummonMessage>(new(string.Empty, HWND.Null)); + break; + } + } + + break; + } + + return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs deleted file mode 100644 index 561ad4592d..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs +++ /dev/null @@ -1,72 +0,0 @@ -// 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 CommunityToolkit.Common.Deferred; -using Windows.Foundation; - -// Pilfered from CommunityToolkit.WinUI.Deferred -namespace Microsoft.CmdPal.UI.Deferred; - -/// <summary> -/// Extensions to <see cref="TypedEventHandler{TSender, TResult}"/> for Deferred Events. -/// </summary> -public static class TypedEventHandlerExtensions -{ - /// <summary> - /// Use to invoke an async <see cref="TypedEventHandler{TSender, TResult}"/> using <see cref="DeferredEventArgs"/>. - /// </summary> - /// <typeparam name="S">Type of sender.</typeparam> - /// <typeparam name="R"><see cref="EventArgs"/> type.</typeparam> - /// <param name="eventHandler"><see cref="TypedEventHandler{TSender, TResult}"/> to be invoked.</param> - /// <param name="sender">Sender of the event.</param> - /// <param name="eventArgs"><see cref="EventArgs"/> instance.</param> - /// <returns><see cref="Task"/> to wait on deferred event handler.</returns> -#pragma warning disable CA1715 // Identifiers should have correct prefix -#pragma warning disable SA1314 // Type parameter names should begin with T - public static Task InvokeAsync<S, R>(this TypedEventHandler<S, R> eventHandler, S sender, R eventArgs) -#pragma warning restore SA1314 // Type parameter names should begin with T -#pragma warning restore CA1715 // Identifiers should have correct prefix - where R : DeferredEventArgs => InvokeAsync(eventHandler, sender, eventArgs, CancellationToken.None); - - /// <summary> - /// Use to invoke an async <see cref="TypedEventHandler{TSender, TResult}"/> using <see cref="DeferredEventArgs"/> with a <see cref="CancellationToken"/>. - /// </summary> - /// <typeparam name="S">Type of sender.</typeparam> - /// <typeparam name="R"><see cref="EventArgs"/> type.</typeparam> - /// <param name="eventHandler"><see cref="TypedEventHandler{TSender, TResult}"/> to be invoked.</param> - /// <param name="sender">Sender of the event.</param> - /// <param name="eventArgs"><see cref="EventArgs"/> instance.</param> - /// <param name="cancellationToken"><see cref="CancellationToken"/> option.</param> - /// <returns><see cref="Task"/> to wait on deferred event handler.</returns> -#pragma warning disable CA1715 // Identifiers should have correct prefix -#pragma warning disable SA1314 // Type parameter names should begin with T - public static Task InvokeAsync<S, R>(this TypedEventHandler<S, R> eventHandler, S sender, R eventArgs, CancellationToken cancellationToken) -#pragma warning restore SA1314 // Type parameter names should begin with T -#pragma warning restore CA1715 // Identifiers should have correct prefix - where R : DeferredEventArgs - { - if (eventHandler == null) - { - return Task.CompletedTask; - } - - var tasks = eventHandler.GetInvocationList() - .OfType<TypedEventHandler<S, R>>() - .Select(invocationDelegate => - { - cancellationToken.ThrowIfCancellationRequested(); - - invocationDelegate(sender, eventArgs); - -#pragma warning disable CS0618 // Type or member is obsolete - var deferral = eventArgs.GetCurrentDeferralAndReset(); - - return deferral?.WaitForCompletion(cancellationToken) ?? Task.CompletedTask; -#pragma warning restore CS0618 // Type or member is obsolete - }) - .ToArray(); - - return Task.WhenAll(tasks); - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/UIHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/UIHelper.cs new file mode 100644 index 0000000000..071b33c28a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/UIHelper.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation.Peers; + +namespace Microsoft.CmdPal.UI.Helpers; + +public static partial class UIHelper +{ + static UIHelper() + { + } + + public static void AnnounceActionForAccessibility(UIElement ue, string announcement, string activityID) + { + if (FrameworkElementAutomationPeer.FromElement(ue) is AutomationPeer peer) + { + peer.RaiseNotificationEvent( + AutomationNotificationKind.ActionCompleted, + AutomationNotificationProcessing.ImportantMostRecent, + announcement, + activityID); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs new file mode 100644 index 0000000000..9772d33b1d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WallpaperHelper.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using ManagedCommon; +using ManagedCsWin32; +using Microsoft.UI; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Helpers; + +/// <summary> +/// Lightweight helper to access wallpaper information. +/// </summary> +internal sealed partial class WallpaperHelper +{ + private readonly IDesktopWallpaper? _desktopWallpaper; + + public WallpaperHelper() + { + try + { + var desktopWallpaper = ComHelper.CreateComInstance<IDesktopWallpaper>( + ref Unsafe.AsRef(in CLSID.DesktopWallpaper), + CLSCTX.ALL); + + _desktopWallpaper = desktopWallpaper; + } + catch (Exception ex) + { + // If COM initialization fails, keep helper usable with safe fallbacks + Logger.LogError("Failed to initialize DesktopWallpaper COM interface", ex); + _desktopWallpaper = null; + } + } + + private string? GetWallpaperPathForFirstMonitor() + { + try + { + if (_desktopWallpaper is null) + { + return null; + } + + _desktopWallpaper.GetMonitorDevicePathCount(out var monitorCount); + + for (uint i = 0; monitorCount != 0 && i < monitorCount; i++) + { + _desktopWallpaper.GetMonitorDevicePathAt(i, out var monitorId); + if (string.IsNullOrEmpty(monitorId)) + { + continue; + } + + _desktopWallpaper.GetWallpaper(monitorId, out var wallpaperPath); + + if (!string.IsNullOrWhiteSpace(wallpaperPath) && File.Exists(wallpaperPath)) + { + return wallpaperPath; + } + } + } + catch (Exception ex) + { + Logger.LogError("Failed to query wallpaper path", ex); + } + + return null; + } + + /// <summary> + /// Gets the wallpaper background color. + /// </summary> + /// <returns>The wallpaper background color, or black if it cannot be determined.</returns> + public Color GetWallpaperColor() + { + try + { + if (_desktopWallpaper is null) + { + return Colors.Black; + } + + _desktopWallpaper.GetBackgroundColor(out var colorref); + var r = (byte)(colorref.Value & 0x000000FF); + var g = (byte)((colorref.Value & 0x0000FF00) >> 8); + var b = (byte)((colorref.Value & 0x00FF0000) >> 16); + return Color.FromArgb(255, r, g, b); + } + catch (Exception ex) + { + Logger.LogError("Failed to load wallpaper color", ex); + return Colors.Black; + } + } + + /// <summary> + /// Gets the wallpaper image for the primary monitor. + /// </summary> + /// <returns>The wallpaper image, or null if it cannot be determined.</returns> + public BitmapImage? GetWallpaperImage() + { + try + { + var path = GetWallpaperPathForFirstMonitor(); + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var image = new BitmapImage(); + using var stream = File.OpenRead(path); + var randomAccessStream = stream.AsRandomAccessStream(); + if (randomAccessStream == null) + { + Logger.LogError("Failed to convert file stream to RandomAccessStream for wallpaper image."); + return null; + } + + image.SetSource(randomAccessStream); + return image; + } + catch (Exception ex) + { + Logger.LogError("Failed to load wallpaper image", ex); + return null; + } + } + + // blittable type for COM interop + [StructLayout(LayoutKind.Sequential)] + internal readonly partial struct COLORREF + { + internal readonly uint Value; + } + + // blittable type for COM interop + [StructLayout(LayoutKind.Sequential)] + internal readonly partial struct RECT + { + internal readonly int Left; + internal readonly int Top; + internal readonly int Right; + internal readonly int Bottom; + } + + // COM interface for IDesktopWallpaper, GeneratedComInterface to be AOT compatible + [GeneratedComInterface] + [Guid("B92B56A9-8B55-4E14-9A89-0199BBB6F93B")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal partial interface IDesktopWallpaper + { + void SetWallpaper( + [MarshalAs(UnmanagedType.LPWStr)] string? monitorId, + [MarshalAs(UnmanagedType.LPWStr)] string wallpaper); + + void GetWallpaper( + [MarshalAs(UnmanagedType.LPWStr)] string? monitorId, + [MarshalAs(UnmanagedType.LPWStr)] out string wallpaper); + + void GetMonitorDevicePathAt(uint monitorIndex, [MarshalAs(UnmanagedType.LPWStr)] out string monitorId); + + void GetMonitorDevicePathCount(out uint count); + + void GetMonitorRECT([MarshalAs(UnmanagedType.LPWStr)] string? monitorId, out RECT rect); + + void SetBackgroundColor(COLORREF color); + + void GetBackgroundColor(out COLORREF color); + + // Other methods omitted for brevity + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowExtensions.cs index 406f261e6c..ee782766bc 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowExtensions.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowExtensions.cs @@ -5,10 +5,14 @@ using Microsoft.UI; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; +using Windows.Win32.UI.WindowsAndMessaging; namespace Microsoft.CmdPal.UI.Helpers; -public static class WindowExtensions +internal static class WindowExtensions { public static void SetIcon(this Window window) { @@ -17,4 +21,65 @@ public static class WindowExtensions AppWindow appWindow = AppWindow.GetFromWindowId(windowId); appWindow.SetIcon(@"Assets\icon.ico"); } + + public static HWND GetWindowHwnd(this Window window) + { + return window is null + ? throw new ArgumentNullException(nameof(window)) + : new HWND(WinRT.Interop.WindowNative.GetWindowHandle(window)); + } + + /// <summary> + /// Toggles the specified extended window style on or off for the supplied <see cref="Window"/>. + /// </summary> + /// <param name="window">The <see cref="Window"/> whose extended window styles will be modified. Cannot be null.</param> + /// <param name="style">The <see cref="WINDOW_EX_STYLE"/> flag(s) to set or clear.</param> + /// <param name="isStyleSet">When true, the specified <paramref name="style"/> bit(s) will be set (added). When false, the bit(s) will be cleared (removed).</param> + /// <returns>True if the call to SetWindowLong succeeded and the style was applied; otherwise false.</returns> + /// <exception cref="ArgumentNullException">Thrown if <paramref name="window"/> is null.</exception> + internal static bool ToggleExtendedWindowStyle(this Window window, WINDOW_EX_STYLE style, bool isStyleSet) + { + var hWnd = GetWindowHwnd(window); + var currentStyle = PInvoke.GetWindowLong(hWnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE); + + if (isStyleSet) + { + currentStyle |= (int)style; + } + else + { + currentStyle &= ~(int)style; + } + + var wasSet = PInvoke.SetWindowLong(hWnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE, currentStyle) != 0; + + // SWP_FRAMECHANGED - invalidate cached window style + PInvoke.SetWindowPos(hWnd, new HWND(IntPtr.Zero), 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED | SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER | SET_WINDOW_POS_FLAGS.SWP_NOOWNERZORDER); + + return wasSet; + } + + /// <summary> + /// Sets the window corner preference + /// </summary> + /// <param name="window">The window</param> + /// <param name="cornerPreference">The desired corner preference</param> + /// <returns>True if the operation succeeded</returns> + public static bool SetCornerPreference(this Window window, DWM_WINDOW_CORNER_PREFERENCE cornerPreference) + { + return window.GetWindowHwnd().SetDwmWindowAttribute(DWMWINDOWATTRIBUTE.DWMWA_WINDOW_CORNER_PREFERENCE, cornerPreference); + } + + /// <summary> + /// Unified wrapper for DwmSetWindowAttribute calls with enum values + /// </summary> + private static bool SetDwmWindowAttribute<T>(this HWND hwnd, DWMWINDOWATTRIBUTE attribute, T value) + where T : unmanaged, Enum + { + unsafe + { + var result = PInvoke.DwmSetWindowAttribute(hwnd, attribute, &value, (uint)sizeof(T)); + return result.Succeeded; + } + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowHelper.cs new file mode 100644 index 0000000000..deddf13d5d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowHelper.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal sealed partial class WindowHelper +{ + public static bool IsWindowFullscreen() + { + UserNotificationState state; + + // https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ne-shellapi-query_user_notification_state + if (Marshal.GetExceptionForHR(NativeMethods.SHQueryUserNotificationState(out state)) is null) + { + if (state == UserNotificationState.QUNS_RUNNING_D3D_FULL_SCREEN || + state == UserNotificationState.QUNS_BUSY || + state == UserNotificationState.QUNS_PRESENTATION_MODE) + { + return true; + } + } + + return false; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs new file mode 100644 index 0000000000..bf8af589a6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/WindowPositionHelper.cs @@ -0,0 +1,152 @@ +// 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; +using Microsoft.UI.Windowing; +using Windows.Graphics; +using Windows.Win32; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.HiDpi; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal static class WindowPositionHelper +{ + private const int DefaultWidth = 800; + private const int DefaultHeight = 480; + private const int MinimumVisibleSize = 100; + private const int DefaultDpi = 96; + + public static PointInt32? CalculateCenteredPosition(DisplayArea? displayArea, SizeInt32 windowSize, int windowDpi) + { + if (displayArea is null) + { + return null; + } + + var workArea = displayArea.WorkArea; + if (workArea.Width <= 0 || workArea.Height <= 0) + { + return null; + } + + var targetDpi = GetDpiForDisplay(displayArea); + var predictedSize = ScaleSize(windowSize, windowDpi, targetDpi); + + // Clamp to work area + var width = Math.Min(predictedSize.Width, workArea.Width); + var height = Math.Min(predictedSize.Height, workArea.Height); + + return new PointInt32( + workArea.X + ((workArea.Width - width) / 2), + workArea.Y + ((workArea.Height - height) / 2)); + } + + /// <summary> + /// Adjusts a saved window rect to ensure it's visible on the nearest display, + /// accounting for DPI changes and work area differences. + /// </summary> + /// + public static RectInt32 AdjustRectForVisibility(RectInt32 savedRect, SizeInt32 savedScreenSize, int savedDpi) + { + var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest); + if (displayArea is null) + { + return savedRect; + } + + var workArea = displayArea.WorkArea; + if (workArea.Width <= 0 || workArea.Height <= 0) + { + return savedRect; + } + + var targetDpi = GetDpiForDisplay(displayArea); + if (savedDpi <= 0) + { + savedDpi = targetDpi; + } + + var hasInvalidSize = savedRect.Width <= 0 || savedRect.Height <= 0; + if (hasInvalidSize) + { + savedRect = savedRect with { Width = DefaultWidth, Height = DefaultHeight }; + } + + if (targetDpi != savedDpi) + { + savedRect = ScaleRect(savedRect, savedDpi, targetDpi); + } + + var clampedSize = ClampSize(savedRect.Width, savedRect.Height, workArea); + + var shouldRecenter = hasInvalidSize || + IsOffscreen(savedRect, workArea) || + savedScreenSize.Width != workArea.Width || + savedScreenSize.Height != workArea.Height; + + if (shouldRecenter) + { + return CenterRectInWorkArea(clampedSize, workArea); + } + + return new RectInt32(savedRect.X, savedRect.Y, clampedSize.Width, clampedSize.Height); + } + + private static int GetDpiForDisplay(DisplayArea displayArea) + { + var hMonitor = Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId); + if (hMonitor == IntPtr.Zero) + { + return DefaultDpi; + } + + var hr = PInvoke.GetDpiForMonitor( + new HMONITOR(hMonitor), + MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, + out var dpiX, + out _); + + return hr.Succeeded && dpiX > 0 ? (int)dpiX : DefaultDpi; + } + + private static SizeInt32 ScaleSize(SizeInt32 size, int fromDpi, int toDpi) + { + if (fromDpi <= 0 || toDpi <= 0 || fromDpi == toDpi) + { + return size; + } + + var scale = (double)toDpi / fromDpi; + return new SizeInt32( + (int)Math.Round(size.Width * scale), + (int)Math.Round(size.Height * scale)); + } + + private static RectInt32 ScaleRect(RectInt32 rect, int fromDpi, int toDpi) + { + var scale = (double)toDpi / fromDpi; + return new RectInt32( + (int)Math.Round(rect.X * scale), + (int)Math.Round(rect.Y * scale), + (int)Math.Round(rect.Width * scale), + (int)Math.Round(rect.Height * scale)); + } + + private static SizeInt32 ClampSize(int width, int height, RectInt32 workArea) => + new(Math.Min(width, workArea.Width), Math.Min(height, workArea.Height)); + + private static RectInt32 CenterRectInWorkArea(SizeInt32 size, RectInt32 workArea) => + new( + workArea.X + ((workArea.Width - size.Width) / 2), + workArea.Y + ((workArea.Height - size.Height) / 2), + size.Width, + size.Height); + + private static bool IsOffscreen(RectInt32 rect, RectInt32 workArea) => + rect.X + MinimumVisibleSize > workArea.X + workArea.Width || + rect.X + rect.Width - MinimumVisibleSize < workArea.X || + rect.Y + MinimumVisibleSize > workArea.Y + workArea.Height || + rect.Y + rect.Height - MinimumVisibleSize < workArea.Y; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/HiddenOwnerWindowBehavior.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/HiddenOwnerWindowBehavior.cs new file mode 100644 index 0000000000..7b01afd20a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/HiddenOwnerWindowBehavior.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.UI.Xaml; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Microsoft.CmdPal.UI; + +/// <summary> +/// Provides behavior to control taskbar and Alt+Tab presence by assigning a hidden owner +/// and toggling extended window styles for a target window. +/// </summary> +internal sealed class HiddenOwnerWindowBehavior +{ + private HWND _hiddenOwnerHwnd; + private Window? _hiddenWindow; + + /// <summary> + /// Shows or hides a window in the taskbar (and Alt+Tab) by updating ownership and extended window styles. + /// </summary> + /// <param name="target">The <see cref="Microsoft.UI.Xaml.Window"/> to update.</param> + /// <param name="isVisibleInTaskbar"> True to show the window in the taskbar (and Alt+Tab); false to hide it from both. </param> + /// <remarks> + /// When hiding the window, a hidden owner is assigned and <see cref="WINDOW_EX_STYLE.WS_EX_TOOLWINDOW"/> + /// is enabled to keep it out of the taskbar and Alt+Tab. When showing, the owner is cleared and + /// <see cref="WINDOW_EX_STYLE.WS_EX_APPWINDOW"/> is enabled to ensure taskbar presence. Since tool + /// windows use smaller corner radii, the normal rounded corners are enforced via + /// <see cref="DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUND"/>. + /// </remarks> + /// <seealso href="https://learn.microsoft.com/en-us/windows/win32/shell/taskbar#managing-taskbar-buttons" /> + public void ShowInTaskbar(Window target, bool isVisibleInTaskbar) + { + /* + * There are the three main ways to control whether a window appears on the taskbar: + * https://learn.microsoft.com/en-us/windows/win32/shell/taskbar#managing-taskbar-buttons + * + * 1. Set the window's owner. Owned windows do not appear on the taskbar: + * Turns out this is the most reliable way to hide a window from the taskbar and ALT+TAB. WinForms and WPF uses this method + * to back their ShowInTaskbar property as well. + * + * 2. Use the WS_EX_TOOLWINDOW extended window style: + * This mostly works, with some reports that it silently fails in some cases. The biggest issue + * is that for certain Windows settings (like Multitasking -> Show taskbar buttons on all displays = On all desktops), + * the taskbar button is always shown even for tool windows. + * + * 3. Using ITaskbarList: + * This is what AppWindow.IsShownInSwitchers uses, but it's COM-based and more complex, and can + * fail if Explorer isn't running or responding. It could be a good backup, if needed. + */ + + var visibleHwnd = target.GetWindowHwnd(); + + if (isVisibleInTaskbar) + { + // remove any owner window + PInvoke.SetWindowLongPtr(visibleHwnd, WINDOW_LONG_PTR_INDEX.GWLP_HWNDPARENT, HWND.Null); + } + else + { + // Set the hidden window as the owner of the target window + var hiddenHwnd = EnsureHiddenOwner(); + PInvoke.SetWindowLongPtr(visibleHwnd, WINDOW_LONG_PTR_INDEX.GWLP_HWNDPARENT, hiddenHwnd); + } + + // Tool windows don't show up in ALT+TAB, and don't show up in the taskbar + // Tool window and app window styles are mutually exclusive, change both just to be safe + target.ToggleExtendedWindowStyle(WINDOW_EX_STYLE.WS_EX_TOOLWINDOW, !isVisibleInTaskbar); + target.ToggleExtendedWindowStyle(WINDOW_EX_STYLE.WS_EX_APPWINDOW, isVisibleInTaskbar); + + // Since tool windows have smaller corner radii, we need to force the normal ones + target.SetCornerPreference(DWM_WINDOW_CORNER_PREFERENCE.DWMWCP_ROUND); + } + + private HWND EnsureHiddenOwner() + { + if (_hiddenOwnerHwnd.IsNull) + { + _hiddenWindow = new Window(); + _hiddenOwnerHwnd = _hiddenWindow.GetWindowHwnd(); + } + + return _hiddenOwnerHwnd; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/LocalSuppressions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/LocalSuppressions.cs new file mode 100644 index 0000000000..c466c4b304 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/LocalSuppressions.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. +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Interoperability", "CsWinRT1028: Class should be marked partial", Justification = "CsWin32 generated code; not used across WinRT boundary", Scope = "type", Target = "~T:Windows.Win32.DestroyIconSafeHandle")] +[assembly: SuppressMessage("Interoperability", "CsWinRT1028: Class should be marked partial", Justification = "CsWin32 generated code; not used across WinRT boundary", Scope = "type", Target = "~T:Windows.Win32.DestroyMenuSafeHandle")] +[assembly: SuppressMessage("Interoperability", "CsWinRT1028: Class should be marked partial", Justification = "CsWin32 generated code; not used across WinRT boundary", Scope = "type", Target = "~T:Windows.Win32.FreeLibrarySafeHandle")] +[assembly: SuppressMessage("Interoperability", "CsWinRT1028: Class should be marked partial", Justification = "CsWin32 generated code; not used across WinRT boundary", Scope = "type", Target = "~T:Windows.Win32.UnhookWindowsHookExSafeHandle")] +[assembly: SuppressMessage("Interoperability", "CsWinRT1028: Class should be marked partial", Justification = "CsWin32 generated code; not used across WinRT boundary", Scope = "type", Target = "~T:Windows.Win32.DeleteObjectSafeHandle")] diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/LogWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/LogWrapper.cs new file mode 100644 index 0000000000..89dcce3983 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/LogWrapper.cs @@ -0,0 +1,41 @@ +// 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 ManagedCommon; +using Microsoft.CmdPal.Core.Common; + +namespace Microsoft.CmdPal.UI; + +internal sealed class LogWrapper : ILogger +{ + public void LogError(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + Logger.LogError(message, ex, memberName, sourceFilePath, sourceLineNumber); + } + + public void LogError(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + Logger.LogError(message, memberName, sourceFilePath, sourceLineNumber); + } + + public void LogWarning(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + Logger.LogWarning(message, memberName, sourceFilePath, sourceLineNumber); + } + + public void LogInfo(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + Logger.LogInfo(message, memberName, sourceFilePath, sourceLineNumber); + } + + public void LogDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + Logger.LogDebug(message, memberName, sourceFilePath, sourceLineNumber); + } + + public void LogTrace([System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + Logger.LogTrace(memberName, sourceFilePath, sourceLineNumber); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml index 4e5e8259a3..ee11551c5a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml @@ -1,13 +1,36 @@ -<Window +<winuiex:WindowEx x:Class="Microsoft.CmdPal.UI.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pages="using:Microsoft.CmdPal.UI.Pages" - xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" + xmlns:winuiex="using:WinUIEx" + Width="800" + Height="480" + MinWidth="320" + MinHeight="240" Activated="MainWindow_Activated" Closed="MainWindow_Closed" mc:Ignorable="d"> - <pages:ShellPage x:Name="RootShellPage" /> -</Window> + <Grid x:Name="RootElement"> + + <controls:BlurImageControl + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + BlurAmount="{x:Bind ViewModel.BackgroundImageBlurAmount, Mode=OneWay}" + ImageBrightness="{x:Bind ViewModel.BackgroundImageBrightness, Mode=OneWay}" + ImageOpacity="{x:Bind ViewModel.EffectiveImageOpacity, Mode=OneWay}" + ImageSource="{x:Bind ViewModel.BackgroundImageSource, Mode=OneWay}" + ImageStretch="{x:Bind ViewModel.BackgroundImageStretch, Mode=OneWay}" + IsHitTestVisible="False" + IsHoldingEnabled="False" + TintColor="{x:Bind ViewModel.BackgroundImageTint, Mode=OneWay}" + TintIntensity="{x:Bind ViewModel.BackgroundImageTintIntensity, Mode=OneWay}" + Visibility="{x:Bind ViewModel.ShowBackgroundImage, Mode=OneWay}" /> + + <pages:ShellPage HostWindow="{x:Bind}" /> + </Grid> +</winuiex:WindowEx> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index ac1b0e0860..a6900773ab 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -2,15 +2,23 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics; using System.Runtime.InteropServices; +using CmdPalKeyboardService; using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.Common.Helpers; -using Microsoft.CmdPal.Common.Messages; -using Microsoft.CmdPal.Common.Services; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.Ext.ClipboardHistory.Messages; +using Microsoft.CmdPal.UI.Controls; using Microsoft.CmdPal.UI.Events; using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.Messages; +using Microsoft.CmdPal.UI.Services; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Composition; @@ -18,70 +26,123 @@ using Microsoft.UI.Composition.SystemBackdrops; using Microsoft.UI.Input; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using Microsoft.Windows.AppLifecycle; +using Windows.ApplicationModel.Activation; using Windows.Foundation; using Windows.Graphics; +using Windows.System; using Windows.UI; -using Windows.UI.WindowManagement; using Windows.Win32; using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; using Windows.Win32.UI.Input.KeyboardAndMouse; -using Windows.Win32.UI.Shell; using Windows.Win32.UI.WindowsAndMessaging; using WinRT; +using WinUIEx; using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; namespace Microsoft.CmdPal.UI; -public sealed partial class MainWindow : Window, +public sealed partial class MainWindow : WindowEx, IRecipient<DismissMessage>, IRecipient<ShowWindowMessage>, IRecipient<HideWindowMessage>, - IRecipient<QuitMessage> + IRecipient<QuitMessage>, + IRecipient<NavigateToPageMessage>, + IRecipient<NavigationDepthMessage>, + IRecipient<SearchQueryMessage>, + IRecipient<ErrorOccurredMessage>, + IRecipient<DragStartedMessage>, + IRecipient<DragCompletedMessage>, + IRecipient<ToggleDevRibbonMessage>, + IDisposable, + IHostWindow { + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")] + private readonly uint WM_TASKBAR_RESTART; private readonly HWND _hwnd; + private readonly DispatcherTimer _autoGoHomeTimer; private readonly WNDPROC? _hotkeyWndProc; private readonly WNDPROC? _originalWndProc; private readonly List<TopLevelHotkey> _hotkeys = []; + private readonly KeyboardListener _keyboardListener; + private readonly LocalKeyboardListener _localKeyboardListener; + private readonly HiddenOwnerWindowBehavior _hiddenOwnerBehavior = new(); + private readonly IThemeService _themeService; + private readonly WindowThemeSynchronizer _windowThemeSynchronizer; + private bool _ignoreHotKeyWhenFullScreen = true; + private bool _themeServiceInitialized; - // Stylistically, window messages are WM_* -#pragma warning disable SA1310 // Field names should not contain underscore -#pragma warning disable SA1306 // Field names should begin with lower-case letter - private const uint MY_NOTIFY_ID = 1000; - private const uint WM_TRAY_ICON = PInvoke.WM_USER + 1; - private readonly uint WM_TASKBAR_RESTART; -#pragma warning restore SA1306 // Field names should begin with lower-case letter -#pragma warning restore SA1310 // Field names should not contain underscore - - // Notification Area ("Tray") icon data - private NOTIFYICONDATAW? _trayIconData; - private bool _createdIcon; - private DestroyIconSafeHandle? _largeIcon; + // Session tracking for telemetry + private Stopwatch? _sessionStopwatch; + private int _sessionCommandsExecuted; + private int _sessionPagesVisited; + private int _sessionSearchQueriesCount; + private int _sessionMaxNavigationDepth; + private int _sessionErrorCount; private DesktopAcrylicController? _acrylicController; + private MicaController? _micaController; private SystemBackdropConfiguration? _configurationSource; + private bool _isUpdatingBackdrop; + private TimeSpan _autoGoHomeInterval = Timeout.InfiniteTimeSpan; + + private WindowPosition _currentWindowPosition = new(); + + private bool _preventHideWhenDeactivated; + + private DevRibbon? _devRibbon; + + private MainWindowViewModel ViewModel { get; } + + public bool IsVisibleToUser { get; private set; } = true; public MainWindow() { InitializeComponent(); - _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); - CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value); + ViewModel = App.Current.Services.GetService<MainWindowViewModel>()!; - // TaskbarCreated is the message that's broadcast when explorer.exe - // restarts. We need to know when that happens to be able to bring our - // notification area icon back - WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); + _autoGoHomeTimer = new DispatcherTimer(); + _autoGoHomeTimer.Tick += OnAutoGoHomeTimerOnTick; + + _themeService = App.Current.Services.GetRequiredService<IThemeService>(); + _themeService.ThemeChanged += ThemeServiceOnThemeChanged; + _windowThemeSynchronizer = new WindowThemeSynchronizer(_themeService, this); + + _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); + + unsafe + { + CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value); + } + + InitializeBackdropSupport(); + + _hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached); + + _keyboardListener = new KeyboardListener(); + _keyboardListener.Start(); + + _keyboardListener.SetProcessCommand(new CmdPalKeyboardService.ProcessCommand(HandleSummon)); this.SetIcon(); AppWindow.Title = RS_.GetString("AppName"); - AppWindow.Resize(new SizeInt32 { Width = 1000, Height = 620 }); - PositionCentered(); - SetAcrylic(); + RestoreWindowPosition(); + UpdateWindowPositionInMemory(); WeakReferenceMessenger.Default.Register<DismissMessage>(this); WeakReferenceMessenger.Default.Register<QuitMessage>(this); WeakReferenceMessenger.Default.Register<ShowWindowMessage>(this); WeakReferenceMessenger.Default.Register<HideWindowMessage>(this); + WeakReferenceMessenger.Default.Register<NavigateToPageMessage>(this); + WeakReferenceMessenger.Default.Register<NavigationDepthMessage>(this); + WeakReferenceMessenger.Default.Register<SearchQueryMessage>(this); + WeakReferenceMessenger.Default.Register<ErrorOccurredMessage>(this); + WeakReferenceMessenger.Default.Register<DragStartedMessage>(this); + WeakReferenceMessenger.Default.Register<DragCompletedMessage>(this); + WeakReferenceMessenger.Default.Register<ToggleDevRibbonMessage>(this); // Hide our titlebar. // We need to both ExtendsContentIntoTitleBar, then set the height to Collapsed @@ -90,7 +151,9 @@ public sealed partial class MainWindow : Window, ExtendsContentIntoTitleBar = true; AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed; SizeChanged += WindowSizeChanged; - RootShellPage.Loaded += RootShellPage_Loaded; + RootElement.Loaded += RootElementLoaded; + + WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a // member (and instead like, use a local), then the pointer we marshal @@ -99,29 +162,65 @@ public sealed partial class MainWindow : Window, _hotkeyWndProc = HotKeyPrc; var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc); _originalWndProc = Marshal.GetDelegateForFunctionPointer<WNDPROC>(PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, hotKeyPrcPointer)); - AddNotificationIcon(); // Load our settings, and then also wire up a settings changed handler HotReloadSettings(); App.Current.Services.GetService<SettingsModel>()!.SettingsChanged += SettingsChangedHandler; // Make sure that we update the acrylic theme when the OS theme changes - RootShellPage.ActualThemeChanged += (s, e) => UpdateAcrylic(); + RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateBackdrop); // Hardcoding event name to avoid bringing in the PowerToys.interop dependency. Event name must match CMDPAL_SHOW_EVENT from shared_constants.h NativeEventWaiter.WaitForEventLoop("Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a", () => { Summon(string.Empty); }); + + _localKeyboardListener = new LocalKeyboardListener(); + _localKeyboardListener.KeyPressed += LocalKeyboardListener_OnKeyPressed; + _localKeyboardListener.Start(); + + // Force window to be created, and then cloaked. This will offset initial animation when the window is shown. + HideWindow(); + } + + private void OnAutoGoHomeTimerOnTick(object? s, object e) + { + _autoGoHomeTimer.Stop(); + + // BEAR LOADING: Focus Search must be suppressed here; otherwise it may steal focus (for example, from the system tray icon) + // and prevent the user from opening its context menu. + WeakReferenceMessenger.Default.Send(new GoHomeMessage(WithAnimation: false, FocusSearch: false)); + } + + private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) + { + UpdateBackdrop(); + } + + private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e) + { + if (e.Key == VirtualKey.GoBack) + { + WeakReferenceMessenger.Default.Send(new GoBackMessage()); + } } private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(); - private void RootShellPage_Loaded(object sender, RoutedEventArgs e) => - + private void RootElementLoaded(object sender, RoutedEventArgs e) + { // Now that our content has loaded, we can update our draggable regions UpdateRegionsForCustomTitleBar(); + // Add dev ribbon if enabled + if (!BuildInfo.IsCiBuild) + { + _devRibbon = new DevRibbon { Margin = new Thickness(-1, -1, 120, -1) }; + RootElement.Children.Add(_devRibbon); + } + } + private void WindowSizeChanged(object sender, WindowSizeChangedEventArgs args) => UpdateRegionsForCustomTitleBar(); private void PositionCentered() @@ -132,101 +231,285 @@ public sealed partial class MainWindow : Window, private void PositionCentered(DisplayArea displayArea) { - if (displayArea is not null) - { - var centeredPosition = AppWindow.Position; - centeredPosition.X = (displayArea.WorkArea.Width - AppWindow.Size.Width) / 2; - centeredPosition.Y = (displayArea.WorkArea.Height - AppWindow.Size.Height) / 2; + var position = WindowPositionHelper.CalculateCenteredPosition( + displayArea, + AppWindow.Size, + (int)this.GetDpiForWindow()); - centeredPosition.X += displayArea.WorkArea.X; - centeredPosition.Y += displayArea.WorkArea.Y; - AppWindow.Move(centeredPosition); + if (position is not null) + { + // Use Move(), not MoveAndResize(). Windows auto-resizes on DPI change via WM_DPICHANGED; + // the helper already accounts for this when calculating the centered position. + AppWindow.Move((PointInt32)position); } } + private void RestoreWindowPosition() + { + var settings = App.Current.Services.GetService<SettingsModel>(); + if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition) + { + PositionCentered(); + return; + } + + // MoveAndResize is safe here—we're restoring a saved state at startup, + // not moving a live window between displays. + var newRect = WindowPositionHelper.AdjustRectForVisibility( + savedPosition.ToPhysicalWindowRectangle(), + new SizeInt32(savedPosition.ScreenWidth, savedPosition.ScreenHeight), + savedPosition.Dpi); + + AppWindow.MoveAndResize(newRect); + } + + private void UpdateWindowPositionInMemory() + { + var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest) ?? DisplayArea.Primary; + _currentWindowPosition = new WindowPosition + { + X = AppWindow.Position.X, + Y = AppWindow.Position.Y, + Width = AppWindow.Size.Width, + Height = AppWindow.Size.Height, + Dpi = (int)this.GetDpiForWindow(), + ScreenWidth = displayArea.WorkArea.Width, + ScreenHeight = displayArea.WorkArea.Height, + }; + } + private void HotReloadSettings() { var settings = App.Current.Services.GetService<SettingsModel>()!; SetupHotkey(settings); + App.Current.Services.GetService<TrayIconService>()!.SetupTrayIcon(settings.ShowSystemTrayIcon); - // This will prevent our window from appearing in alt+tab or the taskbar. - // You'll _need_ to use the hotkey to summon it. - AppWindow.IsShownInSwitchers = System.Diagnostics.Debugger.IsAttached; + _ignoreHotKeyWhenFullScreen = settings.IgnoreShortcutWhenFullscreen; + + _autoGoHomeInterval = settings.AutoGoHomeInterval; + _autoGoHomeTimer.Interval = _autoGoHomeInterval; } - // We want to use DesktopAcrylicKind.Thin and custom colors as this is the default material - // other Shell surfaces are using, this cannot be set in XAML however. - private void SetAcrylic() + private void InitializeBackdropSupport() { - if (DesktopAcrylicController.IsSupported()) + if (DesktopAcrylicController.IsSupported() || MicaController.IsSupported()) { - // Hooking up the policy object. _configurationSource = new SystemBackdropConfiguration { - // Initial configuration state. IsInputActive = true, }; - UpdateAcrylic(); } } - private void UpdateAcrylic() + private void UpdateBackdrop() { - _acrylicController = GetAcrylicConfig(Content); + // Prevent re-entrance when backdrop changes trigger ActualThemeChanged + if (_isUpdatingBackdrop) + { + return; + } - // Enable the system backdrop. - // Note: Be sure to have "using WinRT;" to support the Window.As<...>() call. + _isUpdatingBackdrop = true; + + var backdrop = _themeService.Current.BackdropParameters; + var isImageMode = ViewModel.ShowBackgroundImage; + var config = BackdropStyles.Get(backdrop.Style); + + try + { + switch (config.ControllerKind) + { + case BackdropControllerKind.Solid: + CleanupBackdropControllers(); + var tintColor = Color.FromArgb( + (byte)(backdrop.EffectiveOpacity * 255), + backdrop.TintColor.R, + backdrop.TintColor.G, + backdrop.TintColor.B); + SetupTransparentBackdrop(tintColor); + break; + + case BackdropControllerKind.Mica: + case BackdropControllerKind.MicaAlt: + SetupMica(backdrop, isImageMode, config.ControllerKind); + break; + + case BackdropControllerKind.Acrylic: + case BackdropControllerKind.AcrylicThin: + default: + SetupDesktopAcrylic(backdrop, isImageMode, config.ControllerKind); + break; + } + } + catch (Exception ex) + { + Logger.LogError("Failed to update backdrop", ex); + } + finally + { + _isUpdatingBackdrop = false; + } + } + + private void SetupTransparentBackdrop(Color tintColor) + { + if (SystemBackdrop is TransparentTintBackdrop existingBackdrop) + { + existingBackdrop.TintColor = tintColor; + } + else + { + SystemBackdrop = new TransparentTintBackdrop { TintColor = tintColor }; + } + } + + private void CleanupBackdropControllers() + { + if (_acrylicController is not null) + { + _acrylicController.RemoveAllSystemBackdropTargets(); + _acrylicController.Dispose(); + _acrylicController = null; + } + + if (_micaController is not null) + { + _micaController.RemoveAllSystemBackdropTargets(); + _micaController.Dispose(); + _micaController = null; + } + } + + private void SetupDesktopAcrylic(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind) + { + CleanupBackdropControllers(); + + // Fall back to solid color if acrylic not supported + if (_configurationSource is null || !DesktopAcrylicController.IsSupported()) + { + SetupTransparentBackdrop(backdrop.FallbackColor); + return; + } + + // DesktopAcrylicController and SystemBackdrop can't be active simultaneously + SystemBackdrop = null; + + // Image mode: no tint here, BlurImageControl handles it (avoids double-tinting) + var effectiveTintOpacity = isImageMode + ? 0.0f + : backdrop.EffectiveOpacity; + + _acrylicController = new DesktopAcrylicController + { + Kind = kind == BackdropControllerKind.AcrylicThin + ? DesktopAcrylicKind.Thin + : DesktopAcrylicKind.Default, + TintColor = backdrop.TintColor, + TintOpacity = effectiveTintOpacity, + FallbackColor = backdrop.FallbackColor, + LuminosityOpacity = backdrop.EffectiveLuminosityOpacity, + }; + + // Requires "using WinRT;" for Window.As<>() _acrylicController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>()); _acrylicController.SetSystemBackdropConfiguration(_configurationSource); } - private static DesktopAcrylicController GetAcrylicConfig(UIElement content) + private void SetupMica(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind) { - var feContent = content as FrameworkElement; + CleanupBackdropControllers(); - return feContent?.ActualTheme == ElementTheme.Light - ? new DesktopAcrylicController() - { - Kind = DesktopAcrylicKind.Thin, - TintColor = Color.FromArgb(255, 243, 243, 243), - LuminosityOpacity = 0.90f, - TintOpacity = 0.0f, - FallbackColor = Color.FromArgb(255, 238, 238, 238), - } - : new DesktopAcrylicController() - { - Kind = DesktopAcrylicKind.Thin, - TintColor = Color.FromArgb(255, 32, 32, 32), - LuminosityOpacity = 0.96f, - TintOpacity = 0.5f, - FallbackColor = Color.FromArgb(255, 28, 28, 28), - }; + // Fall back to solid color if Mica not supported + if (_configurationSource is null || !MicaController.IsSupported()) + { + SetupTransparentBackdrop(backdrop.FallbackColor); + return; + } + + // MicaController and SystemBackdrop can't be active simultaneously + SystemBackdrop = null; + _configurationSource.Theme = _themeService.Current.Theme == ElementTheme.Dark + ? SystemBackdropTheme.Dark + : SystemBackdropTheme.Light; + + var hasColorization = _themeService.Current.HasColorization || isImageMode; + + _micaController = new MicaController + { + Kind = kind == BackdropControllerKind.MicaAlt + ? MicaKind.BaseAlt + : MicaKind.Base, + }; + + // Only set tint properties when colorization is active + // Otherwise let system handle light/dark theme defaults automatically + if (hasColorization) + { + // Image mode: no tint here, BlurImageControl handles it (avoids double-tinting) + _micaController.TintColor = backdrop.TintColor; + _micaController.TintOpacity = isImageMode ? 0.0f : backdrop.EffectiveOpacity; + _micaController.FallbackColor = backdrop.FallbackColor; + _micaController.LuminosityOpacity = backdrop.EffectiveLuminosityOpacity; + } + + _micaController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>()); + _micaController.SetSystemBackdropConfiguration(_configurationSource); } private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target) { - var hwnd = new HWND(hwndValue); + StopAutoGoHome(); + + var hwnd = new HWND(hwndValue != 0 ? hwndValue : _hwnd); // Remember, IsIconic == "minimized", which is entirely different state // from "show/hide" // If we're currently minimized, restore us first, before we reveal - // our window. Otherwise we'd just be showing a minimized window - + // our window. Otherwise, we'd just be showing a minimized window - // which would remain not visible to the user. if (PInvoke.IsIconic(hwnd)) { + // Make sure our HWND is cloaked before any possible window manipulations + Cloak(); + PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_RESTORE); } - var display = GetScreen(hwnd, target); - PositionCentered(display); + if (target == MonitorBehavior.ToLast) + { + var originalScreen = new SizeInt32(_currentWindowPosition.ScreenWidth, _currentWindowPosition.ScreenHeight); + var newRect = WindowPositionHelper.AdjustRectForVisibility(_currentWindowPosition.ToPhysicalWindowRectangle(), originalScreen, _currentWindowPosition.Dpi); + AppWindow.MoveAndResize(newRect); + } + else + { + var display = GetScreen(hwnd, target); + PositionCentered(display); + } + // Check if the debugger is attached. If it is, we don't want to apply the tool window style, + // because that would make it hard to debug the app + if (Debugger.IsAttached) + { + _hiddenOwnerBehavior.ShowInTaskbar(this, true); + } + + // Just to be sure, SHOW our hwnd. PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_SHOW); + + // Once we're done, uncloak to avoid all animations + Uncloak(); + PInvoke.SetForegroundWindow(hwnd); PInvoke.SetActiveWindow(hwnd); + + // Push our window to the top of the Z-order and make it the topmost, so that it appears above all other windows. + // We want to remove the topmost status when we hide the window (because we cloak it instead of hiding it). + PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE); } - private DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target) + private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target) { // Leaving a note here, in case we ever need it: // https://github.com/microsoft/microsoft-ui-xaml/issues/6454 @@ -276,73 +559,251 @@ public sealed partial class MainWindow : Window, { var settings = App.Current.Services.GetService<SettingsModel>()!; + // Start session tracking + _sessionStopwatch = Stopwatch.StartNew(); + _sessionCommandsExecuted = 0; + _sessionPagesVisited = 0; + ShowHwnd(message.Hwnd, settings.SummonOn); } public void Receive(HideWindowMessage message) { - PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE); + // This might come in off the UI thread. Make sure to hop back. + DispatcherQueue.TryEnqueue(() => + { + EndSession("Hide"); + HideWindow(); + }); } - public void Receive(QuitMessage message) - { + public void Receive(QuitMessage message) => + // This might come in on a background thread DispatcherQueue.TryEnqueue(() => Close()); + + public void Receive(DismissMessage message) + { + if (message.ForceGoHome) + { + WeakReferenceMessenger.Default.Send(new GoHomeMessage(false, false)); + } + + // This might come in off the UI thread. Make sure to hop back. + DispatcherQueue.TryEnqueue(() => + { + EndSession("Dismiss"); + HideWindow(); + }); } - public void Receive(DismissMessage message) => + // Session telemetry: Track metrics during the Command Palette session + // These receivers increment counters that are sent when EndSession is called + public void Receive(NavigateToPageMessage message) + { + _sessionPagesVisited++; + } + + public void Receive(NavigationDepthMessage message) + { + if (message.Depth > _sessionMaxNavigationDepth) + { + _sessionMaxNavigationDepth = message.Depth; + } + } + + public void Receive(SearchQueryMessage message) + { + _sessionSearchQueriesCount++; + } + + public void Receive(ErrorOccurredMessage message) + { + _sessionErrorCount++; + } + + /// <summary> + /// Ends the current telemetry session and emits the CmdPal_SessionDuration event. + /// Aggregates all session metrics collected since ShowWindow and sends them to telemetry. + /// </summary> + /// <param name="dismissalReason">The reason the session ended (e.g., Dismiss, Hide, LostFocus).</param> + private void EndSession(string dismissalReason) + { + if (_sessionStopwatch is not null) + { + _sessionStopwatch.Stop(); + TelemetryForwarder.LogSessionDuration( + (ulong)_sessionStopwatch.ElapsedMilliseconds, + _sessionCommandsExecuted, + _sessionPagesVisited, + dismissalReason, + _sessionSearchQueriesCount, + _sessionMaxNavigationDepth, + _sessionErrorCount); + _sessionStopwatch = null; + } + } + + /// <summary> + /// Increments the session commands executed counter for telemetry. + /// Called by TelemetryForwarder when an extension command is invoked. + /// </summary> + internal void IncrementCommandsExecuted() + { + _sessionCommandsExecuted++; + } + + private void HideWindow() + { + // Cloak our HWND to avoid all animations. + var cloaked = Cloak(); + + // Then hide our HWND, to make sure that the OS gives the FG / focus back to another app + // (there's no way for us to guess what the right hwnd might be, only the OS can do it right) PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE); + if (cloaked) + { + // TRICKY: show our HWND again. This will trick XAML into painting our + // HWND again, so that we avoid the "flicker" caused by a WinUI3 app + // window being first shown + // SW_SHOWNA will prevent us for trying to fight the focus back + PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNA); + + // Intentionally leave the window cloaked. So our window is "visible", + // but also cloaked, so you can't see it. + + // If the window was not cloaked, then leave it hidden. + // Sure, it's not ideal, but at least it's not visible. + } + + // Start auto-go-home timer + RestartAutoGoHome(); + } + + private void StopAutoGoHome() + { + _autoGoHomeTimer.Stop(); + } + + private void RestartAutoGoHome() + { + if (_autoGoHomeInterval == Timeout.InfiniteTimeSpan) + { + return; + } + + _autoGoHomeTimer.Stop(); + _autoGoHomeTimer.Start(); + } + + private bool Cloak() + { + bool wasCloaked; + unsafe + { + BOOL value = true; + var hr = PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL)); + if (hr.Failed) + { + Logger.LogWarning($"DWM cloaking of the main window failed. HRESULT: {hr.Value}."); + } + else + { + IsVisibleToUser = false; + } + + wasCloaked = hr.Succeeded; + } + + return wasCloaked; + } + + private void Uncloak() + { + unsafe + { + BOOL value = false; + PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL)); + IsVisibleToUser = true; + } + } + internal void MainWindow_Closed(object sender, WindowEventArgs args) { var serviceProvider = App.Current.Services; + UpdateWindowPositionInMemory(); + + var settings = serviceProvider.GetService<SettingsModel>(); + if (settings is not null) + { + settings.LastWindowPosition = new WindowPosition + { + X = _currentWindowPosition.X, + Y = _currentWindowPosition.Y, + Width = _currentWindowPosition.Width, + Height = _currentWindowPosition.Height, + Dpi = _currentWindowPosition.Dpi, + ScreenWidth = _currentWindowPosition.ScreenWidth, + ScreenHeight = _currentWindowPosition.ScreenHeight, + }; + + SettingsModel.SaveSettings(settings); + } + var extensionService = serviceProvider.GetService<IExtensionService>()!; extensionService.SignalStopExtensionsAsync(); - RemoveNotificationIcon(); + App.Current.Services.GetService<TrayIconService>()!.Destroy(); // WinUI bug is causing a crash on shutdown when FailFastOnErrors is set to true (#51773592). // Workaround by turning it off before shutdown. App.Current.DebugSettings.FailFastOnErrors = false; + _localKeyboardListener.Dispose(); DisposeAcrylic(); + + _keyboardListener.Stop(); + Environment.Exit(0); } private void DisposeAcrylic() { - if (_acrylicController != null) - { - _acrylicController.Dispose(); - _acrylicController = null!; - _configurationSource = null!; - } + CleanupBackdropControllers(); + _configurationSource = null!; } // Updates our window s.t. the top of the window is draggable. private void UpdateRegionsForCustomTitleBar() { + var xamlRoot = RootElement.XamlRoot; + if (xamlRoot is null) + { + return; + } + // Specify the interactive regions of the title bar. - var scaleAdjustment = RootShellPage.XamlRoot.RasterizationScale; + var scaleAdjustment = xamlRoot.RasterizationScale; // Get the rectangle around our XAML content. We're going to mark this // rectangle as "Passthrough", so that the normal window operations // (resizing, dragging) don't apply in this space. - var transform = RootShellPage.TransformToVisual(null); + var transform = RootElement.TransformToVisual(null); // Reserve 16px of space at the top for dragging. var topHeight = 16; var bounds = transform.TransformBounds(new Rect( 0, topHeight, - RootShellPage.ActualWidth, - RootShellPage.ActualHeight)); + RootElement.ActualWidth, + RootElement.ActualHeight)); var contentRect = GetRect(bounds, scaleAdjustment); var rectArray = new RectInt32[] { contentRect }; var nonClientInputSrc = InputNonClientPointerSource.GetForWindowId(this.AppWindow.Id); nonClientInputSrc.SetRegionRects(NonClientRegionKind.Passthrough, rectArray); // Add a drag-able region on top - var w = RootShellPage.ActualWidth; - _ = RootShellPage.ActualHeight; + var w = RootElement.ActualWidth; + _ = RootElement.ActualHeight; var dragSides = new RectInt32[] { GetRect(new Rect(0, 0, w, topHeight), scaleAdjustment), // the top, {topHeight=16} tall @@ -361,44 +822,153 @@ public sealed partial class MainWindow : Window, internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args) { + if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated) + { + try + { + _themeService.Initialize(); + _themeServiceInitialized = true; + } + catch (Exception ex) + { + Logger.LogError("Failed to initialize ThemeService", ex); + } + } + if (args.WindowActivationState == WindowActivationState.Deactivated) { + // Save the current window position before hiding the window + UpdateWindowPositionInMemory(); + // If there's a debugger attached... if (System.Diagnostics.Debugger.IsAttached) { // ... then don't hide the window when it loses focus. return; } - else - { - PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE); - PowerToysTelemetry.Log.WriteEvent(new CmdPalDismissedOnLostFocus()); + // Are we disabled? If we are, then we don't want to dismiss on focus lost. + // This can happen if an extension wanted to show a modal dialog on top of our + // window i.e. in the case of an MSAL auth window. + if (PInvoke.IsWindowEnabled(_hwnd) == 0) + { + return; } + + // We're doing something that requires us to lose focus, but we don't want to hide the window + if (_preventHideWhenDeactivated) + { + return; + } + + // This will DWM cloak our window: + EndSession("LostFocus"); + HideWindow(); + + PowerToysTelemetry.Log.WriteEvent(new CmdPalDismissedOnLostFocus()); } - if (_configurationSource != null) + if (_configurationSource is not null) { _configurationSource.IsInputActive = args.WindowActivationState != WindowActivationState.Deactivated; } } - public void Summon(string commandId) + public void HandleLaunchNonUI(AppActivationArguments? activatedEventArgs) { + // LOAD BEARING + // Any reading and processing of the activation arguments must be done + // synchronously in this method, before it returns. The sending instance + // remains blocked until this returns; afterward it may quit, causing + // the activation arguments to be lost. + if (activatedEventArgs is null) + { + Summon(string.Empty); + return; + } + + try + { + if (activatedEventArgs.Kind == ExtendedActivationKind.StartupTask) + { + return; + } + + if (activatedEventArgs.Kind == ExtendedActivationKind.Protocol) + { + if (activatedEventArgs.Data is IProtocolActivatedEventArgs protocolArgs) + { + if (protocolArgs.Uri.ToString() is string uri) + { + // was the URI "x-cmdpal://background" ? + if (uri.StartsWith("x-cmdpal://background", StringComparison.OrdinalIgnoreCase)) + { + // we're running, we don't want to activate our window. bail + return; + } + else if (uri.StartsWith("x-cmdpal://settings", StringComparison.OrdinalIgnoreCase)) + { + WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(new()); + return; + } + else if (uri.StartsWith("x-cmdpal://reload", StringComparison.OrdinalIgnoreCase)) + { + var settings = App.Current.Services.GetService<SettingsModel>(); + if (settings?.AllowExternalReload == true) + { + Logger.LogInfo("External Reload triggered"); + WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>(new()); + } + else + { + Logger.LogInfo("External Reload is disabled"); + } + + return; + } + } + } + } + } + catch (COMException ex) + { + // https://learn.microsoft.com/en-us/windows/win32/rpc/rpc-return-values + const int RPC_S_SERVER_UNAVAILABLE = -2147023174; + const int RPC_S_CALL_FAILED = 2147023170; + + // Accessing properties activatedEventArgs.Kind and activatedEventArgs.Data might cause COMException + // if the args are not valid or not passed correctly. + if (ex.HResult is RPC_S_SERVER_UNAVAILABLE or RPC_S_CALL_FAILED) + { + Logger.LogWarning( + $"COM exception (HRESULT {ex.HResult}) when accessing activation arguments. " + + $"This might be due to the calling application not passing them correctly or exiting before we could read them. " + + $"The application will continue running and fall back to showing the Command Palette window."); + } + else + { + Logger.LogError( + $"COM exception (HRESULT {ex.HResult}) when activating the application. " + + $"The application will continue running and fall back to showing the Command Palette window.", + ex); + } + } + + Summon(string.Empty); + } + + public void Summon(string commandId) => + // The actual showing and hiding of the window will be done by the // ShellPage. This is because we don't want to show the window if the // user bound a hotkey to just an invokable command, which we can't // know till the message is being handled. WeakReferenceMessenger.Default.Send<HotkeySummonMessage>(new(commandId, _hwnd)); - } - -#pragma warning disable SA1310 // Field names should not contain underscore - private const uint DOT_KEY = 0xBE; - private const uint WM_HOTKEY = 0x0312; -#pragma warning restore SA1310 // Field names should not contain underscore private void UnregisterHotkeys() { + _keyboardListener.ClearHotkeys(); + while (_hotkeys.Count > 0) { PInvoke.UnregisterHotKey(_hwnd, _hotkeys.Count - 1); @@ -411,46 +981,122 @@ public sealed partial class MainWindow : Window, UnregisterHotkeys(); var globalHotkey = settings.Hotkey; - if (globalHotkey != null) + if (globalHotkey is not null) { - var vk = globalHotkey.Code; - var modifiers = - (globalHotkey.Alt ? HOT_KEY_MODIFIERS.MOD_ALT : 0) | - (globalHotkey.Ctrl ? HOT_KEY_MODIFIERS.MOD_CONTROL : 0) | - (globalHotkey.Shift ? HOT_KEY_MODIFIERS.MOD_SHIFT : 0) | - (globalHotkey.Win ? HOT_KEY_MODIFIERS.MOD_WIN : 0) - ; - - var success = PInvoke.RegisterHotKey(_hwnd, _hotkeys.Count, modifiers, (uint)vk); - if (success) + if (settings.UseLowLevelGlobalHotkey) { + _keyboardListener.SetHotkeyAction(globalHotkey.Win, globalHotkey.Ctrl, globalHotkey.Shift, globalHotkey.Alt, (byte)globalHotkey.Code, string.Empty); + _hotkeys.Add(new(globalHotkey, string.Empty)); } + else + { + var vk = globalHotkey.Code; + var modifiers = + (globalHotkey.Alt ? HOT_KEY_MODIFIERS.MOD_ALT : 0) | + (globalHotkey.Ctrl ? HOT_KEY_MODIFIERS.MOD_CONTROL : 0) | + (globalHotkey.Shift ? HOT_KEY_MODIFIERS.MOD_SHIFT : 0) | + (globalHotkey.Win ? HOT_KEY_MODIFIERS.MOD_WIN : 0) + ; + + var success = PInvoke.RegisterHotKey(_hwnd, _hotkeys.Count, modifiers, (uint)vk); + if (success) + { + _hotkeys.Add(new(globalHotkey, string.Empty)); + } + } } foreach (var commandHotkey in settings.CommandHotkeys) { var key = commandHotkey.Hotkey; - if (key != null) + if (key is not null) { - var vk = key.Code; - var modifiers = - (key.Alt ? HOT_KEY_MODIFIERS.MOD_ALT : 0) | - (key.Ctrl ? HOT_KEY_MODIFIERS.MOD_CONTROL : 0) | - (key.Shift ? HOT_KEY_MODIFIERS.MOD_SHIFT : 0) | - (key.Win ? HOT_KEY_MODIFIERS.MOD_WIN : 0) - ; - - var success = PInvoke.RegisterHotKey(_hwnd, _hotkeys.Count, modifiers, (uint)vk); - if (success) + if (settings.UseLowLevelGlobalHotkey) { - _hotkeys.Add(commandHotkey); + _keyboardListener.SetHotkeyAction(key.Win, key.Ctrl, key.Shift, key.Alt, (byte)key.Code, commandHotkey.CommandId); + + _hotkeys.Add(new(globalHotkey, string.Empty)); + } + else + { + var vk = key.Code; + var modifiers = + (key.Alt ? HOT_KEY_MODIFIERS.MOD_ALT : 0) | + (key.Ctrl ? HOT_KEY_MODIFIERS.MOD_CONTROL : 0) | + (key.Shift ? HOT_KEY_MODIFIERS.MOD_SHIFT : 0) | + (key.Win ? HOT_KEY_MODIFIERS.MOD_WIN : 0) + ; + + var success = PInvoke.RegisterHotKey(_hwnd, _hotkeys.Count, modifiers, (uint)vk); + if (success) + { + _hotkeys.Add(commandHotkey); + } } } } } + private void HandleSummon(string commandId) + { + if (_ignoreHotKeyWhenFullScreen) + { + // If we're in full screen mode, ignore the hotkey + if (WindowHelper.IsWindowFullscreen()) + { + return; + } + } + + HandleSummonCore(commandId); + } + + private void HandleSummonCore(string commandId) + { + var isRootHotkey = string.IsNullOrEmpty(commandId); + PowerToysTelemetry.Log.WriteEvent(new CmdPalHotkeySummoned(isRootHotkey)); + + var isVisible = this.Visible; + + unsafe + { + // We need to check if our window is cloaked or not. A cloaked window is still + // technically visible, because SHOW/HIDE != iconic (minimized) != cloaked + // (these are all separate states) + long attr = 0; + PInvoke.DwmGetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAKED, &attr, sizeof(long)); + if (attr == 1 /* DWM_CLOAKED_APP */) + { + isVisible = false; + } + } + + // Note to future us: the wParam will have the index of the hotkey we registered. + // We can use that in the future to differentiate the hotkeys we've pressed + // so that we can bind hotkeys to individual commands + if (!isVisible || !isRootHotkey) + { + Summon(commandId); + } + else if (isRootHotkey) + { + // If there's a debugger attached... + if (System.Diagnostics.Debugger.IsAttached) + { + // ... then manually hide our window. When debugged, we won't get the cool cloaking, + // but that's the price to pay for having the HWND not light-dismiss while we're debugging. + Cloak(); + this.Hide(); + + return; + } + + HideWindow(); + } + } + private LRESULT HotKeyPrc( HWND hwnd, uint uMsg, @@ -459,64 +1105,25 @@ public sealed partial class MainWindow : Window, { switch (uMsg) { - case WM_HOTKEY: + // Prevent the window from maximizing when double-clicking the title bar area + case PInvoke.WM_NCLBUTTONDBLCLK: + return (LRESULT)IntPtr.Zero; + case PInvoke.WM_HOTKEY: { var hotkeyIndex = (int)wParam.Value; if (hotkeyIndex < _hotkeys.Count) { var hotkey = _hotkeys[hotkeyIndex]; - var isRootHotkey = string.IsNullOrEmpty(hotkey.CommandId); - PowerToysTelemetry.Log.WriteEvent(new CmdPalHotkeySummoned(isRootHotkey)); - - // Note to future us: the wParam will have the index of the hotkey we registered. - // We can use that in the future to differentiate the hotkeys we've pressed - // so that we can bind hotkeys to individual commands - if (!this.Visible || !isRootHotkey) - { - Activate(); - - Summon(hotkey.CommandId); - } - else if (isRootHotkey) - { - PInvoke.ShowWindow(hwnd, SHOW_WINDOW_CMD.SW_HIDE); - } + HandleSummon(hotkey.CommandId); } return (LRESULT)IntPtr.Zero; } - // Shell_NotifyIcon can fail when we invoke it during the time explorer.exe isn't present/ready to handle it. - // We'll also never receive WM_TASKBAR_RESTART message if the first call to Shell_NotifyIcon failed, so we use - // WM_WINDOWPOSCHANGING which is always received on explorer startup sequence. - case PInvoke.WM_WINDOWPOSCHANGING: - { - if (!_createdIcon) - { - AddNotificationIcon(); - } - } - - break; default: - // WM_TASKBAR_RESTART isn't a compile-time constant, so we can't - // use it in a case label if (uMsg == WM_TASKBAR_RESTART) { - // Handle the case where explorer.exe restarts. - // Even if we created it before, do it again - AddNotificationIcon(); - } - else if (uMsg == WM_TRAY_ICON) - { - switch ((uint)lParam.Value) - { - case PInvoke.WM_RBUTTONUP: - case PInvoke.WM_LBUTTONUP: - case PInvoke.WM_LBUTTONDBLCLK: - Summon(string.Empty); - break; - } + HotReloadSettings(); } break; @@ -525,55 +1132,55 @@ public sealed partial class MainWindow : Window, return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam); } - private void AddNotificationIcon() + public void Dispose() { - // We only need to build the tray data once. - if (_trayIconData == null) - { - // We need to stash this handle, so it doesn't clean itself up. If - // explorer restarts, we'll come back through here, and we don't - // really need to re-load the icon in that case. We can just use - // the handle from the first time. - _largeIcon = GetAppIconHandle(); - _trayIconData = new NOTIFYICONDATAW() - { - cbSize = (uint)Marshal.SizeOf(typeof(NOTIFYICONDATAW)), - hWnd = _hwnd, - uID = MY_NOTIFY_ID, - uFlags = NOTIFY_ICON_DATA_FLAGS.NIF_MESSAGE | NOTIFY_ICON_DATA_FLAGS.NIF_ICON | NOTIFY_ICON_DATA_FLAGS.NIF_TIP, - uCallbackMessage = WM_TRAY_ICON, - hIcon = (HICON)_largeIcon.DangerousGetHandle(), - szTip = RS_.GetString("AppStoreName"), - }; - } - - var d = (NOTIFYICONDATAW)_trayIconData; - - // Add the notification icon - if (PInvoke.Shell_NotifyIcon(NOTIFY_ICON_MESSAGE.NIM_ADD, in d)) - { - _createdIcon = true; - } + _localKeyboardListener.Dispose(); + _windowThemeSynchronizer.Dispose(); + DisposeAcrylic(); } - private void RemoveNotificationIcon() + public void Receive(ToggleDevRibbonMessage message) { - if (_trayIconData != null && _createdIcon) - { - var d = (NOTIFYICONDATAW)_trayIconData; - if (PInvoke.Shell_NotifyIcon(NOTIFY_ICON_MESSAGE.NIM_DELETE, in d)) - { - _createdIcon = false; - } - } + _devRibbon?.Visibility = _devRibbon.Visibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible; } - private DestroyIconSafeHandle GetAppIconHandle() + public void Receive(DragStartedMessage message) { - var exePath = System.Reflection.Assembly.GetExecutingAssembly().Location; - DestroyIconSafeHandle largeIcon; - DestroyIconSafeHandle smallIcon; - PInvoke.ExtractIconEx(exePath, 0, out largeIcon, out smallIcon, 1); - return largeIcon; + _preventHideWhenDeactivated = true; + } + + public void Receive(DragCompletedMessage message) + { + _preventHideWhenDeactivated = false; + Task.Delay(200).ContinueWith(_ => + { + DispatcherQueue.TryEnqueue(StealForeground); + }); + } + + private unsafe void StealForeground() + { + var foregroundWindow = PInvoke.GetForegroundWindow(); + if (foregroundWindow == _hwnd) + { + return; + } + + // This is bad, evil, and I'll have to forgo today's dinner dessert to punish myself + // for writing this. But there's no way to make this work without it. + // If the window is not reactivated, the UX breaks down: a deactivated window has to + // be activated and then deactivated again to hide. + var currentThreadId = PInvoke.GetCurrentThreadId(); + var foregroundThreadId = PInvoke.GetWindowThreadProcessId(foregroundWindow, null); + if (foregroundThreadId != currentThreadId) + { + PInvoke.AttachThreadInput(currentThreadId, foregroundThreadId, true); + PInvoke.SetForegroundWindow(_hwnd); + PInvoke.AttachThreadInput(currentThreadId, foregroundThreadId, false); + } + else + { + PInvoke.SetForegroundWindow(_hwnd); + } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/DragCompletedMessage.cs similarity index 69% rename from src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs rename to src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/DragCompletedMessage.cs index ed698d1024..c2bd5300ed 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/DragCompletedMessage.cs @@ -2,8 +2,6 @@ // 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.Common.Messages; +namespace Microsoft.CmdPal.UI.Messages; -public record HideWindowMessage() -{ -} +public record DragCompletedMessage; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/DragStartedMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/DragStartedMessage.cs new file mode 100644 index 0000000000..84b9915fc6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/DragStartedMessage.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.Messages; + +public record DragStartedMessage; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/HotkeySummonMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/HotkeySummonMessage.cs similarity index 69% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/HotkeySummonMessage.cs rename to src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/HotkeySummonMessage.cs index bff7af364e..65f6b27adb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/HotkeySummonMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/HotkeySummonMessage.cs @@ -1,8 +1,8 @@ -// 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. -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.UI.Messages; public record HotkeySummonMessage(string CommandId, IntPtr Hwnd) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/OpenContextMenuMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/OpenContextMenuMessage.cs new file mode 100644 index 0000000000..01a8a93125 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/OpenContextMenuMessage.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls.Primitives; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.Messages; + +/// <summary> +/// Used to announce the context menu should open +/// </summary> +public record OpenContextMenuMessage(FrameworkElement? Element, FlyoutPlacementMode? FlyoutPlacementMode, Point? Point, ContextMenuFilterLocation ContextMenuFilterLocation) +{ +} + +public enum ContextMenuFilterLocation +{ + Top, + Bottom, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/SettingsWindowClosedMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/SettingsWindowClosedMessage.cs similarity index 67% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/SettingsWindowClosedMessage.cs rename to src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/SettingsWindowClosedMessage.cs index 6627ab6192..57ea5b8c1d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/SettingsWindowClosedMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/SettingsWindowClosedMessage.cs @@ -1,8 +1,8 @@ -// 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. -namespace Microsoft.CmdPal.UI.ViewModels.Messages; +namespace Microsoft.CmdPal.UI.Messages; public record SettingsWindowClosedMessage { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/ToggleDevRibbonMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/ToggleDevRibbonMessage.cs new file mode 100644 index 0000000000..deb5aecc2b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Messages/ToggleDevRibbonMessage.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.Messages; + +public record ToggleDevRibbonMessage; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj index 9a4061f6bc..a80b174cec 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj @@ -1,6 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\CmdPalVersion.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="$(RepoRoot)src\CmdPalVersion.props" /> <Import Project="CmdPal.pre.props" /> <Import Project="CmdPal.Branding.props" /> @@ -13,6 +14,8 @@ <EnableMsixTooling>true</EnableMsixTooling> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> + <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> + <LangVersion>preview</LangVersion> <Version>$(CmdPalVersion)</Version> @@ -20,37 +23,72 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <UseWinRT>true</UseWinRT> </PropertyGroup> - <PropertyGroup Condition="'$(CIBuild)'=='true'"> + <!-- For debugging purposes, uncomment this block to enable AOT builds --> + <!--<PropertyGroup> + <EnableCmdPalAOT>true</EnableCmdPalAOT> + <GeneratePackageLocally>true</GeneratePackageLocally> + </PropertyGroup>--> + + <PropertyGroup Condition="'$(EnableCmdPalAOT)' == 'true'"> + <SelfContained>true</SelfContained> + <PublishSingleFile>false</PublishSingleFile> + <DisableRuntimeMarshalling>false</DisableRuntimeMarshalling> + <PublishAot>true</PublishAot> + </PropertyGroup> + + <PropertyGroup Condition="'$(CIBuild)' == 'true' or '$(GeneratePackageLocally)' == 'true'"> <GenerateAppxPackageOnBuild>true</GenerateAppxPackageOnBuild> + <AppxBundle>Never</AppxBundle> + <AppxPackageTestDir>$(OutputPath)\AppPackages\Microsoft.CmdPal.UI_$(Version)_Test\</AppxPackageTestDir> </PropertyGroup> <PropertyGroup> - <!-- This lets us actually reference types from Microsoft.Terminal.UI --> - <CsWinRTIncludes>Microsoft.Terminal.UI</CsWinRTIncludes> + <!-- This lets us actually reference types from Microsoft.Terminal.UI and CmdPalKeyboardService --> + <CsWinRTIncludes>Microsoft.Terminal.UI;CmdPalKeyboardService</CsWinRTIncludes> <CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir> </PropertyGroup> <PropertyGroup> <!-- This disables the auto-generated main, so we can be single-instanced --> - <DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants> + <DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants> </PropertyGroup> + <!-- BODGY: XES Versioning and WinAppSDK get into a fight about the app manifest, which breaks WinAppSDK. --> + <Target Name="RearrangeXefVersioningAndWinAppSDKResourceGeneration" DependsOnTargets="GetNewAppManifestValues;CreateWinRTRegistration" BeforeTargets="XesWriteVersionInfoResourceFile"> + <PropertyGroup> + <!-- XES uses this property to store the "final" location of the app manifest, before it erases it. + We have to update it to the value WinAppSDK set it to. --> + <OriginalApplicationManifest>$(ApplicationManifest.Replace("$(MSBuildProjectDirectory)\",""))</OriginalApplicationManifest> + </PropertyGroup> + <Message Importance="High" Text="Updated final manifest path to $(OriginalApplicationManifest)" /> + </Target> + <ItemGroup> <None Remove="Controls\ActionBar.xaml" /> + <None Remove="Controls\ColorPalette.xaml" /> + <None Remove="Controls\CommandPalettePreview.xaml" /> + <None Remove="Controls\DevRibbon.xaml" /> + <None Remove="Controls\FallbackRankerDialog.xaml" /> + <None Remove="Controls\KeyVisual\KeyCharPresenter.xaml" /> + <None Remove="Controls\ScreenPreview.xaml" /> <None Remove="Controls\SearchBar.xaml" /> + <None Remove="IsEnabledTextBlock.xaml" /> <None Remove="ListDetailPage.xaml" /> <None Remove="LoadingPage.xaml" /> <None Remove="MainPage.xaml" /> <None Remove="Pages\Settings\ExtensionsPage.xaml" /> <None Remove="Pages\Settings\GeneralPage.xaml" /> <None Remove="SettingsWindow.xaml" /> + <None Remove="Settings\AppearancePage.xaml" /> + <None Remove="Settings\InternalPage.xaml" /> <None Remove="ShellPage.xaml" /> - <None Remove="Styles\Button.xaml" /> <None Remove="Styles\Colors.xaml" /> <None Remove="Styles\Settings.xaml" /> <None Remove="Styles\TextBox.xaml" /> + <None Remove="Styles\Theme.Normal.xaml" /> </ItemGroup> @@ -60,24 +98,19 @@ <PackageReference Include="CommunityToolkit.WinUI.Converters" /> <PackageReference Include="CommunityToolkit.WinUI.Animations" /> <PackageReference Include="CommunityToolkit.WinUI.Extensions" /> - <PackageReference Include="CommunityToolkit.WinUI.UI.Controls.Markdown" /> + <PackageReference Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" /> + <PackageReference Include="Microsoft.Graphics.Win2D" /> <PackageReference Include="Microsoft.Windows.SDK.BuildTools" /> <PackageReference Include="Microsoft.WindowsAppSDK" /> <PackageReference Include="Microsoft.Xaml.Behaviors.WinUI.Managed" /> - + <PackageReference Include="WinUIEx" /> <PackageReference Include="Microsoft.Windows.CsWin32"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> <PackageReference Include="Microsoft.Extensions.Hosting" /> - <PackageReference Include="System.Text.Json" /> - - <!-- LOAD BEARING: GeneratePathProperty=true on BOTH the AC dependencies. Don't forget the AdaptiveCardsWorkaround below --> - <PackageReference Include="AdaptiveCards.ObjectModel.WinUI3" GeneratePathProperty="true" /> - <PackageReference Include="AdaptiveCards.Rendering.WinUI3" GeneratePathProperty="True" /> - <PackageReference Include="AdaptiveCards.Templating" /> </ItemGroup> <!-- @@ -89,30 +122,46 @@ <ProjectCapability Include="Msix" /> </ItemGroup> + <ItemGroup> + <SDKReference Include="Microsoft.VCLibs.Desktop, Version=14.0" /> + </ItemGroup> + <ItemGroup> <ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.System\Microsoft.CmdPal.Ext.System.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.WebSearch\Microsoft.CmdPal.Ext.WebSearch.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" /> - <ProjectReference Include="..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.ClipboardHistory\Microsoft.CmdPal.Ext.ClipboardHistory.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.RemoteDesktop\Microsoft.CmdPal.Ext.RemoteDesktop.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.System\Microsoft.CmdPal.Ext.System.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.WebSearch\Microsoft.CmdPal.Ext.WebSearch.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" /> + <ProjectReference Include="..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> <ProjectReference Include="..\Microsoft.CmdPal.UI.ViewModels\Microsoft.CmdPal.UI.ViewModels.csproj" /> <ProjectReference Include="..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.Apps\Microsoft.CmdPal.Ext.Apps.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.Bookmark\Microsoft.CmdPal.Ext.Bookmarks.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.Calc\Microsoft.CmdPal.Ext.Calc.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.ClipboardHistory\Microsoft.CmdPal.Ext.ClipboardHistory.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.Registry\Microsoft.CmdPal.Ext.Registry.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.Shell\Microsoft.CmdPal.Ext.Shell.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.TimeDate\Microsoft.CmdPal.Ext.TimeDate.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.WindowsServices\Microsoft.CmdPal.Ext.WindowsServices.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.WindowsSettings\Microsoft.CmdPal.Ext.WindowsSettings.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.WindowsTerminal\Microsoft.CmdPal.Ext.WindowsTerminal.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.WindowWalker\Microsoft.CmdPal.Ext.WindowWalker.csproj" /> - <ProjectReference Include="..\Exts\Microsoft.CmdPal.Ext.WinGet\Microsoft.CmdPal.Ext.WinGet.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.Apps\Microsoft.CmdPal.Ext.Apps.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.Bookmark\Microsoft.CmdPal.Ext.Bookmarks.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.Calc\Microsoft.CmdPal.Ext.Calc.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.PerformanceMonitor\Microsoft.CmdPal.Ext.PerformanceMonitor.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.Registry\Microsoft.CmdPal.Ext.Registry.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.Shell\Microsoft.CmdPal.Ext.Shell.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.TimeDate\Microsoft.CmdPal.Ext.TimeDate.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.WindowsServices\Microsoft.CmdPal.Ext.WindowsServices.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.WindowsSettings\Microsoft.CmdPal.Ext.WindowsSettings.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.WindowsTerminal\Microsoft.CmdPal.Ext.WindowsTerminal.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.WindowWalker\Microsoft.CmdPal.Ext.WindowWalker.csproj" /> + <ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.WinGet\Microsoft.CmdPal.Ext.WinGet.csproj" /> - <ProjectReference Include="..\Microsoft.Terminal.UI\Microsoft.Terminal.UI.vcxproj"> + <ProjectReference Include="$(ProjectDir)\..\Microsoft.Terminal.UI\Microsoft.Terminal.UI.vcxproj"> + <ReferenceOutputAssembly>False</ReferenceOutputAssembly> + <BuildProject>True</BuildProject> + </ProjectReference> + <!-- WinRT metadata reference --> + <CsWinRTInputs Include="$(OutputPath)\Microsoft.Terminal.UI.winmd" /> + <!-- Native implementation DLL --> + <None Include="$(OutputPath)\Microsoft.Terminal.UI.dll"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <ProjectReference Include="..\CmdPalKeyboardService\CmdPalKeyboardService.vcxproj"> <ReferenceOutputAssembly>True</ReferenceOutputAssembly> <Private>True</Private> <CopyLocalSatelliteAssemblies>True</CopyLocalSatelliteAssemblies> @@ -135,8 +184,8 @@ <Page Update="Controls\SearchBar.xaml"> <Generator>MSBuild:Compile</Generator> </Page> - <Page Update="Styles\Button.xaml"> - <XamlRuntime>$(DefaultXamlRuntime)</XamlRuntime> + <Page Update="Controls\DevRibbon.xaml"> + <Generator>MSBuild:Compile</Generator> </Page> <Page Update="Styles\TextBox.xaml"> <XamlRuntime>$(DefaultXamlRuntime)</XamlRuntime> @@ -152,21 +201,75 @@ <HasPackageAndPublishMenu>true</HasPackageAndPublishMenu> </PropertyGroup> - - <!-- <AdaptiveCardsWorkaround> --> - <!-- Workaround for Adaptive Cards not supporting correct RIDs when using .NET 8. - Don't forget GeneratePathProperty on the AdaptiveCards PackageReference's above --> - <PropertyGroup> - <AdaptiveCardsNative>runtimes\win10-$(Platform)\native</AdaptiveCardsNative> - </PropertyGroup> - <ItemGroup> - <Content Include="$(PkgAdaptiveCards_ObjectModel_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.ObjectModel.WinUI3.dll" Link="AdaptiveCards.ObjectModel.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" /> - <Content Include="$(PkgAdaptiveCards_Rendering_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.Rendering.WinUI3.dll" Link="AdaptiveCards.Rendering.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" /> - </ItemGroup> <ItemGroup> <Content Update="..\Microsoft.CmdPal.UI.ViewModels\Assets\template.zip"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> + <Content Update="Assets\StoreLogo.dark.svg"> + <CopyToOutputDirectory>Never</CopyToOutputDirectory> + </Content> + <Content Update="Assets\StoreLogo.light.svg"> + <CopyToOutputDirectory>Never</CopyToOutputDirectory> + </Content> + <Content Update="Assets\WinGetLogo.svg"> + <CopyToOutputDirectory>Never</CopyToOutputDirectory> + </Content> + </ItemGroup> + + <ItemGroup> + <Page Update="Controls\CommandPalettePreview.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + + <ItemGroup> + <Page Update="Controls\ScreenPreview.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + + <ItemGroup> + <Page Update="Controls\ColorPalette.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + + <ItemGroup> + <Page Update="Controls\FallbackRankerDialog.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + + <ItemGroup> + <Page Update="Styles\Theme.Colorful.xaml"> + <SubType>Designer</SubType> + </Page> + <Page Update="Styles\Theme.Normal.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + + <ItemGroup> + <Page Update="Settings\AppearancePage.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + + <ItemGroup> + <Page Update="IsEnabledTextBlock.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + + <ItemGroup> + <Page Update="Controls\KeyVisual\KeyCharPresenter.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + <ItemGroup> + <Page Update="Settings\InternalPage.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> </ItemGroup> <ItemGroup> <Page Update="Styles\Colors.xaml"> @@ -195,4 +298,15 @@ </ItemGroup> <!-- </AdaptiveCardsWorkaround> --> + <!-- Build information --> + <PropertyGroup Condition=" '$(PublishAot)' == 'true' "> + <DefineConstants>$(DefineConstants);BUILD_INFO_PUBLISH_AOT</DefineConstants> + </PropertyGroup> + <PropertyGroup Condition=" '$(PublishTrimmed)' == 'true' "> + <DefineConstants>$(DefineConstants);BUILD_INFO_PUBLISH_TRIMMED</DefineConstants> + </PropertyGroup> + <PropertyGroup Condition=" '$(CIBuild)' == 'true' "> + <DefineConstants>$(DefineConstants);BUILD_INFO_CIBUILD</DefineConstants> + </PropertyGroup> + </Project> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt index 6186b45ef8..513db65b1a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt @@ -1,12 +1,13 @@ GetPhysicallyInstalledSystemMemory GlobalMemoryStatusEx GetSystemInfo -CoCreateInstance GetForegroundWindow SetForegroundWindow GetWindowRect GetCursorPos SetWindowPos +HWND_TOPMOST +HWND_BOTTOM IsIconic RegisterHotKey UnregisterHotKey @@ -15,22 +16,54 @@ CallWindowProc ShowWindow SetForegroundWindow EnableWindow +IsWindowEnabled SetFocus SetActiveWindow MonitorFromWindow GetMonitorInfo -SHCreateStreamOnFileEx -CoAllowSetForegroundWindow -SHCreateStreamOnFileEx -SHLoadIndirectString +GetDpiForMonitor +WM_HOTKEY +WM_NCLBUTTONDBLCLK Shell_NotifyIcon LoadIcon WM_USER WM_WINDOWPOSCHANGING RegisterWindowMessageW -GetModuleHandleW ExtractIconEx +TRACK_POPUP_MENU_FLAGS +WM_COMMAND WM_RBUTTONUP WM_LBUTTONUP WM_LBUTTONDBLCLK +CreatePopupMenu +TrackPopupMenuEx +InsertMenu + +MessageBox +DwmGetWindowAttribute +DwmSetWindowAttribute +DWM_CLOAKED_APP +DWM_WINDOW_CORNER_PREFERENCE + +CoWaitForMultipleObjects +INFINITE +CWMO_FLAGS + +GetCurrentThreadId +SetWindowsHookEx +UnhookWindowsHookEx +CallNextHookEx +GetModuleHandle + +GetWindowLong +SetWindowLong +WINDOW_EX_STYLE +CreateWindowEx +WNDCLASSEXW +RegisterClassEx +GetStockObject +GetModuleHandle + +GetWindowThreadProcessId +AttachThreadInput \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Package-Dev.appxmanifest b/src/modules/cmdpal/Microsoft.CmdPal.UI/Package-Dev.appxmanifest index 0d8114eb0c..f8a16a433b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Package-Dev.appxmanifest +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Package-Dev.appxmanifest @@ -70,6 +70,13 @@ DisplayName="ms-resource:StartupTaskNameDev" /> </uap5:Extension> + <uap:Extension Category="windows.protocol"> + <uap:Protocol Name="x-cmdpal"> + <uap:Logo>Assets\StoreLogo.png</uap:Logo> + <uap:DisplayName>Command Palette Dev URI scheme</uap:DisplayName> + </uap:Protocol> + </uap:Extension> + </Extensions> </Application> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Package.appxmanifest b/src/modules/cmdpal/Microsoft.CmdPal.UI/Package.appxmanifest index 9dec756883..4020c10ead 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Package.appxmanifest +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Package.appxmanifest @@ -70,6 +70,14 @@ DisplayName="ms-resource:StartupTaskName" /> </uap5:Extension> + + <uap:Extension Category="windows.protocol"> + <uap:Protocol Name="x-cmdpal"> + <uap:Logo>Assets\StoreLogo.png</uap:Logo> + <uap:DisplayName>Command Palette URI scheme</uap:DisplayName> + </uap:Protocol> + </uap:Extension> + </Extensions> </Application> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/LoadingPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/LoadingPage.xaml index 2d63992e8d..374b45f44f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/LoadingPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/LoadingPage.xaml @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8" ?> <Page x:Class="Microsoft.CmdPal.UI.Pages.LoadingPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/LoadingPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/LoadingPage.xaml.cs index 66eff31c8a..7422efdbce 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/LoadingPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/LoadingPage.xaml.cs @@ -2,7 +2,7 @@ // 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.UI.ViewModels; +using Microsoft.CmdPal.Core.ViewModels; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Navigation; @@ -23,24 +23,30 @@ public sealed partial class LoadingPage : Page protected override void OnNavigatedTo(NavigationEventArgs e) { - if (e.Parameter is ShellViewModel shellVM - && shellVM.LoadCommand != null) + if (e.Parameter is not AsyncNavigationRequest request) { - // This will load the built-in commands, then navigate to the main page. - // Once the mainpage loads, we'll start loading extensions. - shellVM.LoadCommand.Execute(null); - - _ = Task.Run(async () => - { - await shellVM.LoadCommand.ExecutionTask!; - - if (shellVM.LoadCommand.ExecutionTask.Status != TaskStatus.RanToCompletion) - { - // TODO: Handle failure case - } - }); + throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}"); } + if (request.TargetViewModel is not ShellViewModel shellVM) + { + throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ShellViewModel)}"); + } + + // This will load the built-in commands, then navigate to the main page. + // Once the mainpage loads, we'll start loading extensions. + shellVM.LoadCommand.Execute(null); + + _ = Task.Run(async () => + { + await shellVM.LoadCommand.ExecutionTask!; + + if (shellVM.LoadCommand.ExecutionTask.Status != TaskStatus.RanToCompletion) + { + // TODO: Handle failure case + } + }); + base.OnNavigatedTo(e); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index e8ee5b27bb..18bc15298d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -6,13 +6,16 @@ xmlns:animations="using:CommunityToolkit.WinUI.Animations" xmlns:cmdpalUI="using:Microsoft.CmdPal.UI" xmlns:converters="using:CommunityToolkit.WinUI.Converters" + xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels" xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:h="using:Microsoft.CmdPal.UI.Helpers" xmlns:help="using:Microsoft.CmdPal.UI.Helpers" + xmlns:markdownImageProviders="using:Microsoft.CmdPal.UI.Helpers.MarkdownImageProviders" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:toolkit="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" Background="Transparent" mc:Ignorable="d"> @@ -23,12 +26,12 @@ EmptyValue="Collapsed" NotEmptyValue="Visible" /> - <converters:BoolNegationConverter x:Key="BoolNegationConverter" /> - + <cmdpalUI:DetailsSizeToGridLengthConverter x:Key="SizeToWidthConverter" /> <cmdpalUI:MessageStateToSeverityConverter x:Key="MessageStateToSeverityConverter" /> <cmdpalUI:DetailsDataTemplateSelector x:Key="DetailsDataTemplateSelector" + CommandTemplate="{StaticResource DetailsCommandsTemplate}" LinkTemplate="{StaticResource DetailsLinkTemplate}" SeparatorTemplate="{StaticResource DetailsSeparatorTemplate}" TagTemplate="{StaticResource DetailsTagsTemplate}" /> @@ -38,9 +41,35 @@ FalseValue="Visible" TrueValue="Collapsed" /> - <DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel"> + <Style + x:Key="DetailKeyTextBlockStyle" + BasedOn="{StaticResource CaptionTextBlockStyle}" + TargetType="TextBlock"> + <Setter Property="IsTextSelectionEnabled" Value="True" /> + <Setter Property="TextWrapping" Value="WrapWholeWords" /> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" /> + </Style> + + <Style + x:Key="SeparatorKeyTextBlockStyle" + BasedOn="{StaticResource BodyStrongTextBlockStyle}" + TargetType="TextBlock"> + <Setter Property="IsTextSelectionEnabled" Value="True" /> + <Setter Property="TextWrapping" Value="WrapWholeWords" /> + </Style> + + <Style + x:Key="DetailValueTextBlockStyle" + BasedOn="{StaticResource BodyTextBlockStyle}" + TargetType="TextBlock"> + <Setter Property="IsTextSelectionEnabled" Value="True" /> + <Setter Property="TextWrapping" Value="WrapWholeWords" /> + </Style> + + <DataTemplate x:Key="TagTemplate" x:DataType="coreViewModels:TagViewModel"> <cpcontrols:Tag HorizontalAlignment="Left" + AutomationProperties.Name="{x:Bind Text, Mode=OneWay}" BackgroundColor="{x:Bind Background, Mode=OneWay}" FontSize="12" ForegroundColor="{x:Bind Foreground, Mode=OneWay}" @@ -49,48 +78,69 @@ ToolTipService.ToolTip="{x:Bind ToolTip, Mode=OneWay}" /> </DataTemplate> - <DataTemplate x:Key="DetailsLinkTemplate" x:DataType="viewModels:DetailsLinkViewModel"> + <DataTemplate x:Key="CommandTemplate" x:DataType="coreViewModels:CommandViewModel"> <StackPanel Orientation="Vertical"> + <Button + Name="Command" + HorizontalAlignment="Stretch" + HorizontalContentAlignment="Left" + Click="Command_Click" + Style="{StaticResource SubtleButtonStyle}"> + <StackPanel VerticalAlignment="Center" Orientation="Horizontal"> + <cpcontrols:IconBox + Width="16" + Height="16" + Margin="0,3,8,0" + SourceKey="{x:Bind Icon, Mode=OneWay}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" /> + <TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="{x:Bind Name}" /> + </StackPanel> + </Button> + </StackPanel> + </DataTemplate> + + <DataTemplate x:Key="DetailsLinkTemplate" x:DataType="coreViewModels:DetailsLinkViewModel"> + <StackPanel Orientation="Vertical"> + <TextBlock Style="{StaticResource DetailKeyTextBlockStyle}" Text="{x:Bind Key, Mode=OneWay}" /> <TextBlock - IsTextSelectionEnabled="True" - Text="{x:Bind Key, Mode=OneWay}" - TextWrapping="WrapWholeWords" /> - <TextBlock - FontSize="12" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - IsTextSelectionEnabled="True" + Style="{StaticResource DetailValueTextBlockStyle}" Text="{x:Bind Text, Mode=OneWay}" - TextWrapping="WrapWholeWords" Visibility="{x:Bind IsText, Mode=OneWay}" /> <HyperlinkButton Padding="0" - Content="{x:Bind Text, Mode=OneWay}" - FontSize="12" + Command="{x:Bind NavigateCommand, Mode=OneWay}" NavigateUri="{x:Bind Link, Mode=OneWay}" - Visibility="{x:Bind IsLink, Mode=OneWay}" /> + Visibility="{x:Bind IsLink, Mode=OneWay}"> + <TextBlock Text="{x:Bind Text, Mode=OneWay}" TextWrapping="Wrap" /> + </HyperlinkButton> </StackPanel> </DataTemplate> - <DataTemplate x:Key="DetailsSeparatorTemplate" x:DataType="viewModels:DetailsSeparatorViewModel"> - <StackPanel Margin="0,8,8,0" Orientation="Vertical"> - <Border - Margin="8,0,0,0" - BorderBrush="{ThemeResource TextFillColorSecondaryBrush}" - BorderThickness="0,0,0,2"> - <TextBlock - Margin="-8,0,0,8" - FontWeight="SemiBold" - IsTextSelectionEnabled="True" - Text="{x:Bind Key, Mode=OneWay}" - TextWrapping="WrapWholeWords" /> - </Border> - </StackPanel> - </DataTemplate> - <DataTemplate x:Key="DetailsTagsTemplate" x:DataType="viewModels:DetailsTagsViewModel"> + <DataTemplate x:Key="DetailsCommandsTemplate" x:DataType="coreViewModels:DetailsCommandsViewModel"> <StackPanel Orientation="Vertical" Spacing="4"> + <TextBlock Style="{StaticResource DetailKeyTextBlockStyle}" Text="{x:Bind Key, Mode=OneWay}" /> + <ItemsControl + ItemTemplate="{StaticResource CommandTemplate}" + ItemsSource="{x:Bind Commands, Mode=OneWay}" + Visibility="{x:Bind HasCommands, Mode=OneWay}" /> + </StackPanel> + </DataTemplate> + <DataTemplate x:Key="DetailsSeparatorTemplate" x:DataType="coreViewModels:DetailsSeparatorViewModel"> + <StackPanel Margin="0,8,8,0" Orientation="Vertical"> <TextBlock - IsTextSelectionEnabled="True" + Margin="0,8,0,0" + Style="{StaticResource SeparatorKeyTextBlockStyle}" Text="{x:Bind Key, Mode=OneWay}" - TextWrapping="WrapWholeWords" /> + Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToCollapsed(Key), Mode=OneWay, FallbackValue=Collapsed}" /> + <Border + Margin="0,0,0,0" + BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderThickness="0,0,0,1" + Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToVisible(Key), Mode=OneWay, FallbackValue=Collapsed}" /> + </StackPanel> + </DataTemplate> + <DataTemplate x:Key="DetailsTagsTemplate" x:DataType="coreViewModels:DetailsTagsViewModel"> + <StackPanel Orientation="Vertical" Spacing="4"> + <TextBlock Style="{StaticResource DetailKeyTextBlockStyle}" Text="{x:Bind Key, Mode=OneWay}" /> <ItemsControl ItemTemplate="{StaticResource TagTemplate}" ItemsSource="{x:Bind Tags, Mode=OneWay}" @@ -109,6 +159,15 @@ </ItemsControl> </StackPanel> </DataTemplate> + <tkcontrols:MarkdownThemes + x:Key="DefaultMarkdownThemeConfig" + H3FontSize="12" + H3FontWeight="Normal" /> + <markdownImageProviders:ImageProvider x:Key="ImageProvider" /> + <tkcontrols:MarkdownConfig + x:Key="DefaultMarkdownConfig" + ImageProvider="{StaticResource ImageProvider}" + Themes="{StaticResource DefaultMarkdownThemeConfig}" /> </ResourceDictionary> </Page.Resources> @@ -118,7 +177,7 @@ <RowDefinition Height="Auto" /> </Grid.RowDefinitions> - <Grid Background="{ThemeResource LayerOnAcrylicPrimaryBackgroundBrush}"> + <Grid Grid.Row="0" Background="{ThemeResource LayerOnAcrylicPrimaryBackgroundBrush}"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> @@ -127,12 +186,16 @@ <!-- Back button and search box --> <Grid + x:Name="TopBarGrid" Padding="0,12,0,12" HorizontalAlignment="Stretch" - VerticalAlignment="Stretch"> + VerticalAlignment="Stretch" + BorderBrush="{ThemeResource CmdPal.TopBarBorderBrush}" + BorderThickness="0,0,0,1"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> <!-- Back button --> @@ -147,66 +210,82 @@ Visibility="{x:Bind ViewModel.CurrentPage.IsNested, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}"> <animations:Implicit.ShowAnimations> <animations:OpacityAnimation + EasingMode="EaseIn" + EasingType="Cubic" From="0" To="1.0" - Duration="0:0:0.340" /> + Duration="0:0:0.187" /> <animations:ScaleAnimation + EasingMode="EaseIn" + EasingType="Cubic" From="0.5" To="1" - Duration="0:0:0.350" /> + Duration="0:0:0.187" /> </animations:Implicit.ShowAnimations> <animations:Implicit.HideAnimations> <animations:OpacityAnimation + EasingMode="EaseOut" + EasingType="Cubic" From="1.0" To="0" - Duration="0:0:0.240" /> + Duration="0:0:0.187" /> <animations:ScaleAnimation + EasingMode="EaseOut" + EasingType="Cubic" From="1" To="0.5" - Duration="0:0:0.350" /> + Duration="0:0:0.187" /> </animations:Implicit.HideAnimations> </Image> <Button x:Name="BackButton" + x:Uid="BackButton" Margin="4,0,4,0" Padding="4" HorizontalAlignment="Center" VerticalAlignment="Center" ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" + Click="BackButton_Clicked" Content="{ui:FontIcon Glyph=, FontSize=14}" FontSize="16" Style="{StaticResource SubtleButtonStyle}" - Tapped="BackButton_Tapped" - ToolTipService.ToolTip="Back" Visibility="{x:Bind ViewModel.CurrentPage.IsNested, Mode=OneWay}"> <animations:Implicit.ShowAnimations> <animations:OpacityAnimation + EasingMode="EaseIn" + EasingType="Cubic" From="0" To="1.0" - Duration="0:0:0.340" /> + Duration="0:0:0.333" /> <animations:ScaleAnimation From="0.5" To="1" - Duration="0:0:0.350" /> + Duration="0:0:0.333" /> <animations:TranslationAnimation From="16,0,0" To="0,0,0" - Duration="0:0:0.350" /> + Duration="0:0:0.333" /> </animations:Implicit.ShowAnimations> <animations:Implicit.HideAnimations> <animations:OpacityAnimation + EasingMode="EaseOut" + EasingType="Cubic" From="1.0" To="0" - Duration="0:0:0.340" /> + Duration="0:0:0.333" /> <animations:ScaleAnimation + EasingMode="EaseOut" + EasingType="Cubic" From="1" To="0.5" - Duration="0:0:0.350" /> + Duration="0:0:0.333" /> <animations:TranslationAnimation + EasingMode="EaseOut" + EasingType="Cubic" From="0,0,0" To="16,0,0" - Duration="0:0:0.350" /> + Duration="0:0:0.187" /> </animations:Implicit.HideAnimations> </Button> <cpcontrols:IconBox @@ -217,35 +296,35 @@ 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}"> <animations:Implicit.ShowAnimations> <animations:OpacityAnimation From="0" To="1.0" - Duration="0:0:0.450" /> + Duration="0:0:0.333" /> <animations:ScaleAnimation From="0.8" To="1" - Duration="0:0:0.500" /> + Duration="0:0:0.333" /> <animations:TranslationAnimation From="8,0,0" To="0,0,0" - Duration="0:0:0.400" /> + Duration="0:0:0.187" /> </animations:Implicit.ShowAnimations> <animations:Implicit.HideAnimations> <animations:OpacityAnimation From="1.0" To="0" - Duration="0:0:0.340" /> + Duration="0:0:0.333" /> <animations:ScaleAnimation From="1" To="0.8" - Duration="0:0:0.350" /> + Duration="0:0:0.333" /> <animations:TranslationAnimation From="0,0,0" To="8,0,0" - Duration="0:0:0.350" /> + Duration="0:0:0.187" /> </animations:Implicit.HideAnimations> </cpcontrols:IconBox> </StackPanel> @@ -261,6 +340,13 @@ </TransitionCollection> </Grid.Transitions> </Grid> + <!-- Filter: wrapped in a grid to enable RepositionThemeTransitions --> + <Grid Grid.Column="2" HorizontalAlignment="Right"> + <cpcontrols:FiltersDropDown + x:Name="FiltersDropDown" + HorizontalAlignment="Right" + CurrentPageViewModel="{x:Bind ViewModel.CurrentPage, Mode=OneWay}" /> + </Grid> </Grid> <ProgressBar @@ -272,29 +358,28 @@ <animations:OpacityAnimation From="0" To="1.0" - Duration="0:0:0.340" /> + Duration="0:0:0.333" /> </animations:Implicit.ShowAnimations> <animations:Implicit.HideAnimations> <animations:OpacityAnimation From="1.0" To="0" - Duration="0:0:0.340" /> + Duration="0:0:0.333" /> </animations:Implicit.HideAnimations> </ProgressBar> - <Grid - x:Name="ContentGrid" - Grid.Row="1" - BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" - BorderThickness="0,1,0,1"> + <Grid x:Name="ContentGrid" Grid.Row="1"> <Grid.ColumnDefinitions> - <ColumnDefinition Width="3*" /> + <ColumnDefinition Width="{x:Bind ViewModel.Details.Size, Mode=OneWay, Converter={StaticResource SizeToWidthConverter}}" /> <ColumnDefinition x:Name="DetailsColumn" Width="Auto" /> </Grid.ColumnDefinitions> <Frame Name="RootFrame" + AutomationProperties.AutomationControlType="Pane" + AutomationProperties.LandmarkType="Navigation" + AutomationProperties.LiveSetting="Assertive" IsNavigationStackEnabled="True" Navigated="RootFrame_Navigated" /> @@ -305,7 +390,7 @@ HorizontalAlignment="Stretch" ui:VisualExtensions.NormalizedCenterPoint="0.5,0.5" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderBrush="{ThemeResource CmdPal.DividerStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="{StaticResource ControlCornerRadius}" Visibility="Collapsed"> @@ -313,57 +398,56 @@ <animations:OpacityAnimation From="0" To="1.0" - Duration="0:0:0.270" /> + Duration="0:0:0.187" /> <animations:TranslationAnimation From="24,0,0" To="0,0,0" - Duration="0:0:0.280" /> + Duration="0:0:0.187" /> </animations:Implicit.ShowAnimations> <animations:Implicit.HideAnimations> <animations:OpacityAnimation From="1.0" To="0" - Duration="0:0:0.180" /> + Duration="0:0:0.187" /> <animations:TranslationAnimation From="0,0,0" To="24,0,0" - Duration="0:0:0.220" /> + Duration="0:0:0.187" /> </animations:Implicit.HideAnimations> - <Grid Margin="12"> + <Grid Margin="16"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> - <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> <cpcontrols:IconBox x:Name="HeroImageBorder" - MinWidth="64" - MinHeight="64" - MaxHeight="96" - HorizontalAlignment="Center" + Width="64" + Margin="0,0,16,16" + HorizontalAlignment="Left" + AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind ViewModel.Details.HeroImage, Mode=OneWay}" - SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" + SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested64}" Visibility="{x:Bind HasHeroImage, Mode=OneWay}" /> <TextBlock Grid.Row="1" - HorizontalAlignment="Center" - Style="{StaticResource SubtitleTextBlockStyle}" + FontSize="18" + FontWeight="SemiBold" Text="{x:Bind ViewModel.Details.Title, Mode=OneWay}" TextWrapping="WrapWholeWords" Visibility="{x:Bind ViewModel.Details.Title, Converter={StaticResource StringNotEmptyToVisibilityConverter}, Mode=OneWay}" /> - <toolkit:MarkdownTextBlock + <tkcontrols:MarkdownTextBlock Grid.Row="2" - Margin="0,12,0,24" + Margin="0,4,0,24" Background="Transparent" - Header3FontSize="12" - Header3FontWeight="Normal" - Header3Foreground="{ThemeResource TextFillColorSecondaryBrush}" - IsTextSelectionEnabled="True" - Text="{x:Bind ViewModel.Details.Body, Mode=OneWay}" /> + Config="{StaticResource DefaultMarkdownConfig}" + Text="{x:Bind ViewModel.Details.Body, Mode=OneWay}" + UseEmphasisExtras="True" + UsePipeTables="True" /> <ItemsRepeater Grid.Row="3" @@ -431,7 +515,11 @@ </InfoBar> </StackPanel> - <Grid Grid.Row="1" Background="{ThemeResource LayerOnAcrylicSecondaryBackgroundBrush}"> + <Grid + Grid.Row="1" + Background="{ThemeResource LayerOnAcrylicSecondaryBackgroundBrush}" + BorderBrush="{ThemeResource CmdPal.DividerStrokeColorDefaultBrush}" + BorderThickness="0,1,0,0"> <cpcontrols:CommandBar CurrentPageViewModel="{x:Bind ViewModel.CurrentPage, Mode=OneWay}" /> </Grid> @@ -448,6 +536,26 @@ </VisualState.Setters> </VisualState> </VisualStateGroup> + <VisualStateGroup> + <!-- + Why disable it and make it transparent? + To prevent the container from collapsing, ensuring the layout metrics remain unchanged. + --> + <VisualStateGroup.Transitions> + <VisualTransition GeneratedDuration="0:0:0.187" To="SearchBarCollapsed" /> + </VisualStateGroup.Transitions> + <VisualState x:Name="SearchBarVisible" /> + <VisualState x:Name="SearchBarCollapsed"> + <VisualState.StateTriggers> + <StateTrigger IsActive="{x:Bind h:BindTransformers.Negate(ViewModel.IsSearchBoxVisible), Mode=OneWay}" /> + </VisualState.StateTriggers> + <VisualState.Setters> + <Setter Target="SearchBox.IsEnabled" Value="False" /> + <Setter Target="SearchBox.Opacity" Value="0" /> + <Setter Target="TopBarGrid.BorderBrush" Value="Transparent" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> </VisualStateManager.VisualStateGroups> </Grid> </Page> \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 36c9394ece..210592c1ac 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -3,22 +3,31 @@ // See the LICENSE file in the project root for more information. using System.ComponentModel; +using System.Globalization; +using System.Text; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI; using ManagedCommon; -using Microsoft.CmdPal.Common.Services; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.UI.Events; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.Messages; +using Microsoft.CmdPal.UI.Services; using Microsoft.CmdPal.UI.Settings; using Microsoft.CmdPal.UI.ViewModels; -using Microsoft.CmdPal.UI.ViewModels.MainPage; -using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CommandPalette.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Dispatching; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media.Animation; +using Windows.UI.Core; using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; +using VirtualKey = Windows.System.VirtualKey; namespace Microsoft.CmdPal.UI.Pages; @@ -27,16 +36,20 @@ namespace Microsoft.CmdPal.UI.Pages; /// </summary> public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, IRecipient<NavigateBackMessage>, - IRecipient<PerformCommandMessage>, IRecipient<OpenSettingsMessage>, IRecipient<HotkeySummonMessage>, IRecipient<ShowDetailsMessage>, IRecipient<HideDetailsMessage>, IRecipient<ClearSearchMessage>, - IRecipient<HandleCommandResultMessage>, IRecipient<LaunchUriMessage>, IRecipient<SettingsWindowClosedMessage>, - INotifyPropertyChanged + IRecipient<GoHomeMessage>, + IRecipient<GoBackMessage>, + IRecipient<ShowConfirmationMessage>, + IRecipient<ShowToastMessage>, + IRecipient<NavigateToPageMessage>, + INotifyPropertyChanged, + IDisposable { private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); @@ -49,22 +62,25 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, private readonly ToastWindow _toast = new(); - private readonly Lock _invokeLock = new(); - private Task? _handleInvokeTask; + private readonly CompositeFormat _pageNavigatedAnnouncement; + private SettingsWindow? _settingsWindow; + private CancellationTokenSource? _focusAfterLoadedCts; + private WeakReference<Page>? _lastNavigatedPageRef; + public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService<ShellViewModel>()!; public event PropertyChangedEventHandler? PropertyChanged; + public IHostWindow? HostWindow { get; set; } + public ShellPage() { this.InitializeComponent(); // how we are doing navigation around WeakReferenceMessenger.Default.Register<NavigateBackMessage>(this); - WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this); - WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this); WeakReferenceMessenger.Default.Register<OpenSettingsMessage>(this); WeakReferenceMessenger.Default.Register<HotkeySummonMessage>(this); WeakReferenceMessenger.Default.Register<SettingsWindowClosedMessage>(this); @@ -75,7 +91,32 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this); WeakReferenceMessenger.Default.Register<LaunchUriMessage>(this); - RootFrame.Navigate(typeof(LoadingPage), ViewModel); + WeakReferenceMessenger.Default.Register<GoHomeMessage>(this); + WeakReferenceMessenger.Default.Register<GoBackMessage>(this); + WeakReferenceMessenger.Default.Register<ShowConfirmationMessage>(this); + WeakReferenceMessenger.Default.Register<ShowToastMessage>(this); + WeakReferenceMessenger.Default.Register<NavigateToPageMessage>(this); + + AddHandler(PreviewKeyDownEvent, new KeyEventHandler(ShellPage_OnPreviewKeyDown), true); + AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false); + AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true); + + RootFrame.Navigate(typeof(LoadingPage), new AsyncNavigationRequest(ViewModel, CancellationToken.None)); + + var pageAnnouncementFormat = ResourceLoaderInstance.GetString("ScreenReader_Announcement_NavigatedToPage0"); + _pageNavigatedAnnouncement = CompositeFormat.Parse(pageAnnouncementFormat); + } + + /// <summary> + /// Gets the default page animation, depending on the settings + /// </summary> + private NavigationTransitionInfo DefaultPageAnimation + { + get + { + var settings = App.Current.Services.GetService<SettingsModel>()!; + return settings.DisableAnimations ? _noAnimation : _slideRightTransition; + } } public void Receive(NavigateBackMessage message) @@ -95,202 +136,79 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, if (!message.FromBackspace) { // If we can't go back then we must be at the top and thus escape again should quit. - WeakReferenceMessenger.Default.Send<DismissMessage>(); + WeakReferenceMessenger.Default.Send(new DismissMessage()); PowerToysTelemetry.Log.WriteEvent(new CmdPalDismissedOnEsc()); } } } - public void Receive(PerformCommandMessage message) + public void Receive(NavigateToPageMessage message) { - PerformCommand(message); + // TODO GH #526 This needs more better locking too + _ = _queue.TryEnqueue(() => + { + // Also hide our details pane about here, if we had one + HideDetails(); + + // Navigate to the appropriate host page for that VM + RootFrame.Navigate( + message.Page switch + { + ListViewModel => typeof(ListPage), + ContentPageViewModel => typeof(ContentPage), + _ => throw new NotSupportedException(), + }, + new AsyncNavigationRequest(message.Page, message.CancellationToken), + message.WithAnimation ? DefaultPageAnimation : _noAnimation); + + PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth, message.Page.Id)); + + // Telemetry: Send navigation depth for session max depth tracking + WeakReferenceMessenger.Default.Send(new NavigationDepthMessage(RootFrame.BackStackDepth)); + + if (!ViewModel.IsNested) + { + // todo BODGY + RootFrame.BackStack.Clear(); + } + }); } - private void PerformCommand(PerformCommandMessage message) + public void Receive(ShowConfirmationMessage message) { - var command = message.Command.Unsafe; - if (command == null) + DispatcherQueue.TryEnqueue(async () => + { + try + { + await HandleConfirmArgsOnUiThread(message.Args); + } + catch (Exception ex) + { + Logger.LogError(ex.ToString()); + } + }); + } + + public void Receive(ShowToastMessage message) + { + DispatcherQueue.TryEnqueue(() => + { + _toast.ShowToast(message.Message); + }); + } + + // This gets called from the UI thread + private async Task HandleConfirmArgsOnUiThread(IConfirmationArgs? args) + { + if (args is null) { return; } - if (!ViewModel.CurrentPage.IsNested) - { - // on the main page here - ViewModel.PerformTopLevelCommand(message); - } - - IExtensionWrapper? extension = null; - - // TODO: Actually loading up the page, or invoking the command - - // that might belong in the model, not the view? - // Especially considering the try/catch concerns around the fact that the - // COM call might just fail. - // Or the command may be a stub. Future us problem. - try - { - // In the case that we're coming from a top-level command, the - // current page's host is the global instance. We only really want - // to use that as the host of last resort. - var pageHost = ViewModel.CurrentPage?.ExtensionHost; - if (pageHost == CommandPaletteHost.Instance) - { - pageHost = null; - } - - var messageHost = message.ExtensionHost; - - // Use the host from the current page if it has one, else use the - // one specified in the PerformMessage for a top-level command, - // else just use the global one. - CommandPaletteHost host; - - // Top level items can come through without a Extension set on the - // message. In that case, the `Context` is actually the - // TopLevelViewModel itself, and we can use that to get at the - // extension object. - extension = pageHost?.Extension ?? messageHost?.Extension ?? null; - if (extension == null && message.Context is TopLevelViewModel topLevelViewModel) - { - extension = topLevelViewModel.ExtensionHost?.Extension; - host = pageHost ?? messageHost ?? topLevelViewModel?.ExtensionHost ?? CommandPaletteHost.Instance; - } - else - { - host = pageHost ?? messageHost ?? CommandPaletteHost.Instance; - } - - if (extension != null) - { - if (messageHost != null) - { - Logger.LogDebug($"Activated top-level command from {extension.ExtensionDisplayName}"); - } - else - { - Logger.LogDebug($"Activated command from {extension.ExtensionDisplayName}"); - } - } - - ViewModel.SetActiveExtension(extension); - - if (command is IPage page) - { - Logger.LogDebug($"Navigating to page"); - - // TODO GH #526 This needs more better locking too - _ = _queue.TryEnqueue(() => - { - // Also hide our details pane about here, if we had one - HideDetails(); - - WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)); - - var isMainPage = command is MainListPage; - - // Construct our ViewModel of the appropriate type and pass it the UI Thread context. - PageViewModel pageViewModel = page switch - { - IListPage listPage => new ListViewModel(listPage, _mainTaskScheduler, host) - { - IsNested = !isMainPage, - }, - IContentPage contentPage => new ContentPageViewModel(contentPage, _mainTaskScheduler, host), - _ => throw new NotSupportedException(), - }; - - // Kick off async loading of our ViewModel - ViewModel.LoadPageViewModel(pageViewModel); - - // Navigate to the appropriate host page for that VM - RootFrame.Navigate( - page switch - { - IListPage => typeof(ListPage), - IContentPage => typeof(ContentPage), - _ => throw new NotSupportedException(), - }, - pageViewModel, - message.WithAnimation ? _slideRightTransition : _noAnimation); - - PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth)); - - // Refocus on the Search for continual typing on the next search request - SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); - - if (isMainPage) - { - // todo BODGY - RootFrame.BackStack.Clear(); - } - - // Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above - // See RootFrame_Navigated event handler. - }); - } - else if (command is IInvokableCommand invokable) - { - Logger.LogDebug($"Invoking command"); - PowerToysTelemetry.Log.WriteEvent(new BeginInvoke()); - HandleInvokeCommand(message, invokable); - } - } - catch (Exception ex) - { - // TODO: It would be better to do this as a page exception, rather - // than a silent log message. - CommandPaletteHost.Instance.Log(ex.Message); - } - } - - private void HandleInvokeCommand(PerformCommandMessage message, IInvokableCommand invokable) - { - // TODO GH #525 This needs more better locking. - lock (_invokeLock) - { - if (_handleInvokeTask != null) - { - // do nothing - a command is already doing a thing - } - else - { - _handleInvokeTask = Task.Run(() => - { - try - { - var result = invokable.Invoke(message.Context); - DispatcherQueue.TryEnqueue(() => - { - try - { - HandleCommandResultOnUiThread(result); - } - finally - { - _handleInvokeTask = null; - } - }); - } - catch (Exception ex) - { - _handleInvokeTask = null; - - // TODO: It would be better to do this as a page exception, rather - // than a silent log message. - CommandPaletteHost.Instance.Log(ex.Message); - } - }); - } - } - } - - // This gets called from the UI thread - private void HandleConfirmArgs(IConfirmationArgs args) - { ConfirmResultViewModel vm = new(args, new(ViewModel.CurrentPage)); var initializeDialogTask = Task.Run(() => { InitializeConfirmationDialog(vm); }); - initializeDialogTask.Wait(); + await initializeDialogTask; var resourceLoader = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance.ResourceLoader; var confirmText = resourceLoader.GetString("ConfirmationDialog_ConfirmButtonText"); @@ -321,19 +239,16 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, // }; } - DispatcherQueue.TryEnqueue(async () => + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary) { - var result = await dialog.ShowAsync(); - if (result == ContentDialogResult.Primary) - { - var performMessage = new PerformCommandMessage(vm); - PerformCommand(performMessage); - } - else - { - // cancel - } - }); + var performMessage = new PerformCommandMessage(vm); + WeakReferenceMessenger.Default.Send(performMessage); + } + else + { + // cancel + } } private void InitializeConfirmationDialog(ConfirmResultViewModel vm) @@ -341,139 +256,74 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, vm.SafeInitializePropertiesSynchronous(); } - private void HandleCommandResultOnUiThread(ICommandResult? result) - { - try - { - if (result != null) - { - var kind = result.Kind; - Logger.LogDebug($"handling {kind.ToString()}"); - PowerToysTelemetry.Log.WriteEvent(new CmdPalInvokeResult(kind)); - switch (kind) - { - case CommandResultKind.Dismiss: - { - // Reset the palette to the main page and dismiss - GoHome(withAnimation: false, focusSearch: false); - WeakReferenceMessenger.Default.Send<DismissMessage>(); - break; - } - - case CommandResultKind.GoHome: - { - // Go back to the main page, but keep it open - GoHome(); - break; - } - - case CommandResultKind.GoBack: - { - GoBack(); - break; - } - - case CommandResultKind.Hide: - { - // Keep this page open, but hide the palette. - WeakReferenceMessenger.Default.Send<DismissMessage>(); - break; - } - - case CommandResultKind.KeepOpen: - { - // Do nothing. - break; - } - - case CommandResultKind.Confirm: - { - if (result.Args is IConfirmationArgs a) - { - HandleConfirmArgs(a); - } - - break; - } - - case CommandResultKind.ShowToast: - { - if (result.Args is IToastArgs a) - { - _toast.ShowToast(a.Message); - HandleCommandResultOnUiThread(a.Result); - } - - break; - } - } - } - } - catch - { - } - } - public void Receive(OpenSettingsMessage message) { _ = DispatcherQueue.TryEnqueue(() => { - // Also hide our details pane about here, if we had one - HideDetails(); - - if (_settingsWindow == null) - { - _settingsWindow = new SettingsWindow(); - } - - _settingsWindow.Activate(); - - WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)); + OpenSettings(message.SettingsPageTag); }); } + public void OpenSettings(string pageTag) + { + if (_settingsWindow is null) + { + _settingsWindow = new SettingsWindow(); + } + + _settingsWindow.Activate(); + _settingsWindow.BringToFront(); + _settingsWindow.Navigate(pageTag); + } + public void Receive(ShowDetailsMessage message) { - // TERRIBLE HACK TODO GH #245 - // There's weird wacky bugs with debounce currently. - if (!ViewModel.IsDetailsVisible) + if (ViewModel is not null && + ViewModel.CurrentPage is not null) { - ViewModel.Details = message.Details; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage))); - ViewModel.IsDetailsVisible = true; - return; + if (ViewModel.CurrentPage.PageContext.TryGetTarget(out var pageContext)) + { + Task.Factory.StartNew( + () => + { + // TERRIBLE HACK TODO GH #245 + // There's weird wacky bugs with debounce currently. + if (!ViewModel.IsDetailsVisible) + { + ViewModel.Details = message.Details; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage))); + ViewModel.IsDetailsVisible = true; + return; + } + + // GH #322: + // For inexplicable reasons, if you try to change the details too fast, + // we'll explode. This seemingly only happens if you change the details + // while we're also scrolling a new list view item into view. + _debounceTimer.Debounce( + () => + { + ViewModel.Details = message.Details; + + // Trigger a re-evaluation of whether we have a hero image based on + // the current theme + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage))); + }, + interval: TimeSpan.FromMilliseconds(50), + immediate: ViewModel.IsDetailsVisible == false); + ViewModel.IsDetailsVisible = true; + }, + CancellationToken.None, + TaskCreationOptions.None, + pageContext.Scheduler); + } } - - // GH #322: - // For inexplicable reasons, if you try to change the details too fast, - // we'll explode. This seemingly only happens if you change the details - // while we're also scrolling a new list view item into view. - _debounceTimer.Debounce( - () => - { - ViewModel.Details = message.Details; - - // Trigger a re-evaluation of whether we have a hero image based on - // the current theme - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage))); - }, - interval: TimeSpan.FromMilliseconds(50), - immediate: ViewModel.IsDetailsVisible == false); - ViewModel.IsDetailsVisible = true; } public void Receive(HideDetailsMessage message) => HideDetails(); public void Receive(LaunchUriMessage message) => _ = global::Windows.System.Launcher.LaunchUriAsync(message.Uri); - public void Receive(HandleCommandResultMessage message) - { - DispatcherQueue.TryEnqueue(() => - { - HandleCommandResultOnUiThread(message.Result.Unsafe); - }); - } - private void HideDetails() { ViewModel.Details = null; @@ -502,7 +352,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, // Depending on the settings, either // * Go home, or // * Select the search text (if we should remain open on this page) - if (settings.HotkeyGoesHome) + if (settings.AutoGoHomeInterval == TimeSpan.Zero) { GoHome(false); } @@ -519,7 +369,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, // command from our list of toplevel commands. var tlcManager = App.Current.Services.GetService<TopLevelCommandManager>()!; var topLevelCommand = tlcManager.LookupCommand(commandId); - if (topLevelCommand != null) + if (topLevelCommand is not null) { var command = topLevelCommand.CommandViewModel.Model.Unsafe; var isPage = command is not IInvokableCommand; @@ -536,7 +386,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, WeakReferenceMessenger.Default.Send<ShowWindowMessage>(new(message.Hwnd)); } - var msg = new PerformCommandMessage(topLevelCommand) { WithAnimation = false }; + var msg = topLevelCommand.GetPerformCommandMessage(); + msg.WithAnimation = false; WeakReferenceMessenger.Default.Send<PerformCommandMessage>(msg); // we can't necessarily SelectSearch() here, because when the page is loaded, @@ -553,10 +404,17 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>(); } + public void Receive(GoBackMessage message) + { + _ = DispatcherQueue.TryEnqueue(() => GoBack(message.WithAnimation, message.FocusSearch)); + } + private void GoBack(bool withAnimation = true, bool focusSearch = true) { HideDetails(); + ViewModel.CancelNavigation(); + // Note: That we restore the VM state below in RootFrame_Navigated call back after this occurs. // In the future, we may want to manage the back stack ourselves vs. relying on Frame // We could replace Frame with a ContentPresenter, but then have to manage transition animations ourselves. @@ -580,7 +438,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, if (!RootFrame.CanGoBack) { - ViewModel.GoHome(); + ViewModel.GoHome(withAnimation, focusSearch); } if (focusSearch) @@ -590,29 +448,184 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, } } + public void Receive(GoHomeMessage message) + { + _ = DispatcherQueue.TryEnqueue(() => GoHome(withAnimation: message.WithAnimation, focusSearch: message.FocusSearch)); + } + private void GoHome(bool withAnimation = true, bool focusSearch = true) { while (RootFrame.CanGoBack) { - GoBack(withAnimation, focusSearch); + // don't focus on each step, just at the end + GoBack(withAnimation, focusSearch: false); } - WeakReferenceMessenger.Default.Send<GoHomeMessage>(); + // focus search box, even if we were already home + if (focusSearch) + { + SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + SearchBox.SelectSearch(); + } } - private void BackButton_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); + private void BackButton_Clicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); private void RootFrame_Navigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) { // This listens to the root frame to ensure that we also track the content's page VM as well that we passed as a parameter. // This is currently used for both forward and backward navigation. // As when we go back that we restore ourselves to the proper state within our VM - if (e.Parameter is PageViewModel page) + if (e.Parameter is AsyncNavigationRequest request) { - // Note, this shortcuts and fights a bit with our LoadPageViewModel above, but we want to better fast display and incrementally load anyway - // We just need to reconcile our loading systems a bit more in the future. - ViewModel.CurrentPage = page; + if (request.NavigationToken.IsCancellationRequested && e.NavigationMode is not (Microsoft.UI.Xaml.Navigation.NavigationMode.Back or Microsoft.UI.Xaml.Navigation.NavigationMode.Forward)) + { + return; + } + + switch (request.TargetViewModel) + { + case PageViewModel pageViewModel: + ViewModel.CurrentPage = pageViewModel; + break; + case ShellViewModel: + // This one is an exception, for now (LoadingPage is tied to ShellViewModel, + // but ShellViewModel is not PageViewModel. + ViewModel.CurrentPage = ViewModel.NullPage; + break; + default: + ViewModel.CurrentPage = ViewModel.NullPage; + Logger.LogWarning($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(PageViewModel)}"); + break; + } } + else + { + Logger.LogWarning("Unrecognized target for shell navigation: " + e.Parameter); + } + + if (e.Content is Page element) + { + _lastNavigatedPageRef = new WeakReference<Page>(element); + element.Loaded += FocusAfterLoaded; + } + } + + private void FocusAfterLoaded(object sender, RoutedEventArgs e) + { + var page = (Page)sender; + page.Loaded -= FocusAfterLoaded; + + // Only handle focus for the latest navigated page + if (_lastNavigatedPageRef is null || !_lastNavigatedPageRef.TryGetTarget(out var last) || !ReferenceEquals(page, last)) + { + return; + } + + // Cancel any previous pending focus work + _focusAfterLoadedCts?.Cancel(); + _focusAfterLoadedCts?.Dispose(); + _focusAfterLoadedCts = new CancellationTokenSource(); + var token = _focusAfterLoadedCts.Token; + + AnnounceNavigationToPage(page); + + var shouldSearchBoxBeVisible = ViewModel.CurrentPage?.HasSearchBox ?? false; + + if (shouldSearchBoxBeVisible || page is not ContentPage) + { + if (HostWindow?.IsVisibleToUser != true) + { + return; + } + + ViewModel.IsSearchBoxVisible = shouldSearchBoxBeVisible; + SearchBox.Focus(FocusState.Programmatic); + SearchBox.SelectSearch(); + } + else + { + _ = Task.Run( + async () => + { + if (token.IsCancellationRequested) + { + return; + } + + try + { + if (HostWindow?.IsVisibleToUser != true) + { + return; + } + + await page.DispatcherQueue.EnqueueAsync( + async () => + { + // I hate this so much, but it can take a while for the page to be ready to accept focus; + // focusing page with MarkdownTextBlock takes up to 5 attempts (* 100ms delay between attempts) + for (var i = 0; i < 10; i++) + { + token.ThrowIfCancellationRequested(); + + if (HostWindow?.IsVisibleToUser != true) + { + break; + } + + if (FocusManager.FindFirstFocusableElement(page) is FrameworkElement frameworkElement) + { + var set = frameworkElement.Focus(FocusState.Programmatic); + if (set) + { + break; + } + } + + await Task.Delay(100, token); + } + + token.ThrowIfCancellationRequested(); + + // Update the search box visibility based on the current page: + // - We do this here after navigation so the focus is not jumping around too much, + // it messes with screen readers if we do it too early + // - Since this should hide the search box on content pages, it's not a problem if we + // wait for the code above to finish trying to focus the content + ViewModel.IsSearchBoxVisible = ViewModel.CurrentPage?.HasSearchBox ?? false; + }); + } + catch (OperationCanceledException) + { + // Swallow cancellation - another FocusAfterLoaded invocation superseded this one + } + catch (Exception ex) + { + Logger.LogError("Error during FocusAfterLoaded async focus work", ex); + } + }, + token); + } + } + + private void AnnounceNavigationToPage(Page page) + { + var pageTitle = page switch + { + ListPage listPage => listPage.ViewModel?.Title, + ContentPage contentPage => contentPage.ViewModel?.Title, + _ => null, + }; + + if (string.IsNullOrEmpty(pageTitle)) + { + pageTitle = ResourceLoaderInstance.GetString("UntitledPageTitle"); + } + + var announcement = string.Format(CultureInfo.CurrentCulture, _pageNavigatedAnnouncement.Format, pageTitle); + + UIHelper.AnnounceActionForAccessibility(RootFrame, announcement, "CommandPalettePageNavigatedTo"); } /// <summary> @@ -629,4 +642,103 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, return iconInfoVM?.HasIcon(requestedTheme == Microsoft.UI.Xaml.ElementTheme.Light) ?? false; } } + + private void Command_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + if (sender is Button button && button.DataContext is CommandViewModel commandViewModel) + { + WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(commandViewModel.Model)); + } + } + + private static void ShellPage_OnPreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); + var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); + + var onlyAlt = altPressed && !ctrlPressed && !shiftPressed && !winPressed; + var onlyCtrl = !altPressed && ctrlPressed && !shiftPressed && !winPressed; + switch (e.Key) + { + case VirtualKey.Left when onlyAlt: // Alt+Left arrow + WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); + e.Handled = true; + break; + case VirtualKey.Home when onlyAlt: // Alt+Home + WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(WithAnimation: false)); + e.Handled = true; + break; + case (VirtualKey)188 when onlyCtrl: // Ctrl+, + WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(new()); + e.Handled = true; + break; + default: + { + // The CommandBar is responsible for handling all the item keybindings, + // since the bound context item may need to then show another + // context menu + TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + WeakReferenceMessenger.Default.Send(msg); + e.Handled = msg.Handled; + break; + } + } + } + + private static void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e) + { + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + if (ctrlPressed && e.Key == VirtualKey.Enter) + { + // ctrl+enter + WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>(); + e.Handled = true; + } + else if (e.Key == VirtualKey.Enter) + { + WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>(); + e.Handled = true; + } + else if (ctrlPressed && e.Key == VirtualKey.K) + { + // ctrl+k + WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom)); + e.Handled = true; + } + else if (e.Key == VirtualKey.Escape) + { + WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new()); + e.Handled = true; + } + } + + private void ShellPage_OnPointerPressed(object sender, PointerRoutedEventArgs e) + { + try + { + var ptr = e.Pointer; + if (ptr.PointerDeviceType == PointerDeviceType.Mouse) + { + var ptrPt = e.GetCurrentPoint(this); + if (ptrPt.Properties.IsXButton1Pressed) + { + WeakReferenceMessenger.Default.Send(new NavigateBackMessage()); + } + } + } + catch (Exception ex) + { + Logger.LogError("Error handling mouse button press event", ex); + } + } + + public void Dispose() + { + _focusAfterLoadedCts?.Cancel(); + _focusAfterLoadedCts?.Dispose(); + _focusAfterLoadedCts = null; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.cs new file mode 100644 index 0000000000..7edf0a34c9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysAppHostService.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 Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.UI.ViewModels; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. +namespace Microsoft.CmdPal.UI; + +internal sealed class PowerToysAppHostService : IAppHostService +{ + public AppExtensionHost GetDefaultHost() + { + return CommandPaletteHost.Instance; + } + + public AppExtensionHost GetHostForCommand(object? context, AppExtensionHost? currentHost) + { + AppExtensionHost? topLevelHost = null; + if (context is TopLevelViewModel topLevelViewModel) + { + topLevelHost = topLevelViewModel.ExtensionHost; + } + + return topLevelHost ?? currentHost ?? CommandPaletteHost.Instance; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs new file mode 100644 index 0000000000..23d8b413e0 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Core.Common.Text; +using Microsoft.CmdPal.Core.ViewModels; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.MainPage; +using Microsoft.CommandPalette.Extensions; +using WinRT; + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. +namespace Microsoft.CmdPal.UI; + +internal sealed class PowerToysRootPageService : IRootPageService +{ + private readonly TopLevelCommandManager _tlcManager; + + private IExtensionWrapper? _activeExtension; + private Lazy<MainListPage> _mainListPage; + + public PowerToysRootPageService(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel, IFuzzyMatcherProvider fuzzyMatcherProvider) + { + _tlcManager = topLevelCommandManager; + + _mainListPage = new Lazy<MainListPage>(() => + { + return new MainListPage(_tlcManager, settings, aliasManager, appStateModel, fuzzyMatcherProvider); + }); + } + + public async Task PreLoadAsync() + { + await _tlcManager.LoadBuiltinsAsync(); + } + + public Microsoft.CommandPalette.Extensions.IPage GetRootPage() + { + return _mainListPage.Value; + } + + public async Task PostLoadRootPageAsync() + { + // After loading built-ins, and starting navigation, kick off a thread to load extensions. + _tlcManager.LoadExtensionsCommand.Execute(null); + + await _tlcManager.LoadExtensionsCommand.ExecutionTask!; + if (_tlcManager.LoadExtensionsCommand.ExecutionTask.Status != TaskStatus.RanToCompletion) + { + // TODO: Handle failure case + } + } + + private void OnPerformTopLevelCommand(object? context) + { + try + { + if (context is IListItem listItem) + { + _mainListPage.Value.UpdateHistory(listItem); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to update history in PowerToysRootPageService"); + Logger.LogError(ex.ToString()); + } + } + + public void OnPerformCommand(object? context, bool topLevel, AppExtensionHost? currentHost) + { + if (topLevel) + { + OnPerformTopLevelCommand(context); + } + + if (currentHost is CommandPaletteHost host) + { + SetActiveExtension(host.Extension); + } + else + { + throw new InvalidOperationException("This must be a programming error - everything in Command Palette should have a CommandPaletteHost"); + } + } + + public void SetActiveExtension(IExtensionWrapper? extension) + { + if (extension != _activeExtension) + { + // There's not really a CoDisallowSetForegroundWindow, so we don't + // need to handle that + _activeExtension = extension; + + var extensionWinRtObject = _activeExtension?.GetExtensionObject(); + if (extensionWinRtObject is not null) + { + try + { + unsafe + { + var winrtObj = (IWinRTObject)extensionWinRtObject; + var intPtr = winrtObj.NativeObject.ThisPtr; + var hr = Native.CoAllowSetForegroundWindow(intPtr); + if (hr != 0) + { + Logger.LogWarning($"Error giving foreground rights: 0x{hr.Value:X8}"); + } + } + } + catch (Exception ex) + { + ManagedCommon.Logger.LogError(ex.ToString()); + } + } + } + } + + public void GoHome() + { + SetActiveExtension(null); + } + + // You may ask yourself, why aren't we using CsWin32 for this? + // The CsWin32 projected version includes some object marshalling, like so: + // + // HRESULT CoAllowSetForegroundWindow([MarshalAs(UnmanagedType.IUnknown)] object pUnk,...) + // + // And if you do it like that, then the IForegroundTransfer interface isn't marshalled correctly + internal sealed class Native + { + [DllImport("OLE32.dll", ExactSpelling = true)] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + [SupportedOSPlatform("windows5.0")] + internal static extern unsafe global::Windows.Win32.Foundation.HRESULT CoAllowSetForegroundWindow(nint pUnk, [Optional] void* lpvReserved); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Program.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Program.cs index 2e1f916115..3426fb4777 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Program.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Program.cs @@ -2,10 +2,17 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Runtime.InteropServices; using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.UI.Events; using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Dispatching; using Microsoft.Windows.AppLifecycle; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.UI.WindowsAndMessaging; namespace Microsoft.CmdPal.UI; @@ -14,6 +21,7 @@ namespace Microsoft.CmdPal.UI; // https://github.com/microsoft/WindowsAppSDK-Samples/tree/main/Samples/AppLifecycle/Instancing/cs2/cs-winui-packaged/CsWinUiDesktopInstancing internal sealed class Program { + private static DispatcherQueueSynchronizationContext? uiContext; private static App? app; // LOAD BEARING @@ -30,8 +38,53 @@ internal sealed class Program return 0; } - Logger.InitializeLogger("\\CmdPal\\Logs\\"); + try + { + Logger.InitializeLogger("\\CmdPal\\Logs\\"); + } + catch (COMException e) + { + // This is unexpected. For the sake of debugging: + // pop a message box + PInvoke.MessageBox( + (HWND)IntPtr.Zero, + $"Failed to initialize the logger. COMException: \r{e.Message}", + "Command Palette", + MESSAGEBOX_STYLE.MB_OK | MESSAGEBOX_STYLE.MB_ICONERROR); + return 0; + } + catch (Exception e2) + { + // This is unexpected. For the sake of debugging: + // pop a message box + PInvoke.MessageBox( + (HWND)IntPtr.Zero, + $"Failed to initialize the logger. Unknown Exception: \r{e2.Message}", + "Command Palette", + MESSAGEBOX_STYLE.MB_OK | MESSAGEBOX_STYLE.MB_ICONERROR); + return 0; + } + Logger.LogDebug($"Starting at {DateTime.UtcNow}"); + + // Log application startup information + try + { + var appInfoService = new ApplicationInfoService(() => Logger.CurrentVersionLogDirectoryPath); + var startupMessage = $""" + ============================================================ + Hello World! Command Palette is starting. + + {appInfoService.GetApplicationInfoSummary()} + ============================================================ + """; + Logger.LogInfo(startupMessage); + } + catch (Exception ex) + { + Logger.LogError("Failed to log application startup information", ex); + } + PowerToysTelemetry.Log.WriteEvent(new CmdPalProcessStarted()); WinRT.ComWrappersSupport.InitializeComWrappers(); @@ -40,8 +93,8 @@ internal sealed class Program { Microsoft.UI.Xaml.Application.Start((p) => { - Microsoft.UI.Dispatching.DispatcherQueueSynchronizationContext context = new(Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()); - SynchronizationContext.SetSynchronizationContext(context); + uiContext = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()); + SynchronizationContext.SetSynchronizationContext(uiContext); app = new App(); }); } @@ -64,23 +117,62 @@ internal sealed class Program { isRedirect = true; PowerToysTelemetry.Log.WriteEvent(new ReactivateInstance()); - keyInstance.RedirectActivationToAsync(args).AsTask().ConfigureAwait(false); + RedirectActivationTo(args, keyInstance); } return isRedirect; } + 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(32); + + _ = Task.Run(() => + { + using var cts = new CancellationTokenSource(redirectTimeout); + try + { + keyInstance.RedirectActivationToAsync(args) + .AsTask(cts.Token) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) + { + Logger.LogError($"Failed to activate existing instance; timed out after {redirectTimeout}."); + } + catch (Exception ex) + { + Logger.LogError("Failed to activate existing instance", ex); + } + finally + { + redirectSemaphore.Release(); + } + }); + + _ = PInvoke.CoWaitForMultipleObjects( + (uint)CWMO_FLAGS.CWMO_DEFAULT, + PInvoke.INFINITE, + [new HANDLE(redirectSemaphore.SafeWaitHandle.DangerousGetHandle())], + out _); + } + private static void OnActivated(object? sender, AppActivationArguments args) { // If we already have a form, display the message now. // Otherwise, add it to the collection for displaying later. - if (App.Current is App thisApp) + if (App.Current?.AppWindow is MainWindow mainWindow) { - if (thisApp.AppWindow is not null and - MainWindow mainWindow) - { - mainWindow.Summon(string.Empty); - } + // LOAD BEARING + // This must be synchronous to ensure the method does not return + // before the activation is fully handled and the parameters are processed. + // The sending instance remains blocked until this returns; afterward it may quit, + // causing the activation arguments to be lost. + mainWindow.HandleLaunchNonUI(args); } } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-arm64.pubxml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-arm64.pubxml index b95afb7240..a7bece87c1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-arm64.pubxml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-arm64.pubxml @@ -11,9 +11,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121. <PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir> <SelfContained>true</SelfContained> <PublishSingleFile>False</PublishSingleFile> - <PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun> - <PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun> - <PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed> - <PublishTrimmed Condition="'$(Configuration)' != 'Debug'">False</PublishTrimmed> </PropertyGroup> -</Project> \ No newline at end of file +</Project> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-x64.pubxml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-x64.pubxml index 5ff16b291b..73aa1ac98f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-x64.pubxml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/PublishProfiles/win-x64.pubxml @@ -11,9 +11,5 @@ https://go.microsoft.com/fwlink/?LinkID=208121. <PublishDir>bin\$(Configuration)\$(TargetFramework)\$(RuntimeIdentifier)\publish\</PublishDir> <SelfContained>true</SelfContained> <PublishSingleFile>False</PublishSingleFile> - <PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun> - <PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun> - <PublishTrimmed Condition="'$(Configuration)' == 'Debug'">False</PublishTrimmed> - <PublishTrimmed Condition="'$(Configuration)' != 'Debug'">False</PublishTrimmed> </PropertyGroup> -</Project> \ No newline at end of file +</Project> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json index 68daa60f79..4631e9aeaf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Properties/launchSettings.json @@ -2,7 +2,13 @@ "profiles": { "Microsoft.CmdPal.UI (Package)": { "commandName": "MsixPackage", - "nativeDebugging": false + "nativeDebugging": false, + "doNotLaunchApp": false + }, + "Microsoft.CmdPal.UI (Package) + Native debugging": { + "commandName": "MsixPackage", + "nativeDebugging": true, + "doNotLaunchApp": false }, "Microsoft.CmdPal.UI (Unpackaged)": { "commandName": "Project" diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs new file mode 100644 index 0000000000..d32256efed --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.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 Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.UI.ViewModels; + +namespace Microsoft.CmdPal.UI; + +internal sealed class RunHistoryService : IRunHistoryService +{ + private readonly AppStateModel _appStateModel; + + public RunHistoryService(AppStateModel appStateModel) + { + _appStateModel = appStateModel; + } + + public IReadOnlyList<string> GetRunHistory() + { + if (_appStateModel.RunHistory.Count == 0) + { + var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory(); + _appStateModel.RunHistory.AddRange(history); + } + + return _appStateModel.RunHistory; + } + + public void ClearRunHistory() + { + _appStateModel.RunHistory.Clear(); + } + + public void AddRunHistoryItem(string item) + { + // insert at the beginning of the list + if (string.IsNullOrWhiteSpace(item)) + { + return; // Do not add empty or whitespace items + } + + _appStateModel.RunHistory.Remove(item); + + // Add the item to the front of the history + _appStateModel.RunHistory.Insert(0, item); + + AppStateModel.SaveState(_appStateModel); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs new file mode 100644 index 0000000000..8e2a5748bd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ColorfulThemeProvider.cs @@ -0,0 +1,227 @@ +// 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 CommunityToolkit.WinUI.Helpers; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Xaml; +using Windows.UI; +using Windows.UI.ViewManagement; + +namespace Microsoft.CmdPal.UI.Services; + +/// <summary> +/// Provides theme appropriate for colorful (accented) appearance. +/// </summary> +internal sealed class ColorfulThemeProvider : IThemeProvider +{ + // Fluent dark: #202020 + private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32); + + // Fluent light: #F3F3F3 + private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243); + + private readonly UISettings _uiSettings; + + public string ThemeKey => "colorful"; + + public string ResourcePath => "ms-appx:///Styles/Theme.Colorful.xaml"; + + public ColorfulThemeProvider(UISettings uiSettings) + { + ArgumentNullException.ThrowIfNull(uiSettings); + _uiSettings = uiSettings; + } + + public BackdropParameters GetBackdropParameters(ThemeContext context) + { + var isLight = context.Theme == ElementTheme.Light || + (context.Theme == ElementTheme.Default && + _uiSettings.GetColorValue(UIColorType.Background).R > 128); + + var baseColor = isLight ? LightBaseColor : DarkBaseColor; + + // Windows is warping the hue of accent colors and running it through some curves to produce their accent shades. + // This will attempt to mimic that behavior. + var accentShades = AccentShades.Compute(context.Tint.LerpHsv(WindowsAccentHueWarpTransform.Transform(context.Tint), 0.5f)); + var blended = isLight ? accentShades.Light3 : accentShades.Dark2; + var colorIntensityUser = (context.ColorIntensity ?? 100) / 100f; + + // For light theme, we want to reduce intensity a bit, and also we need to keep the color fairly light, + // to avoid issues with text box caret. + var colorIntensity = isLight ? 0.6f * colorIntensityUser : colorIntensityUser; + var effectiveBgColor = ColorBlender.Blend(baseColor, blended, colorIntensity); + + var transparencyMode = context.BackdropStyle ?? BackdropStyle.Acrylic; + var config = BackdropStyles.Get(transparencyMode); + + // For colorful theme, boost tint opacity to show color better through blur + // But not for styles with fixed opacity (Mica) - they handle their own opacity + var baseTintOpacity = config.ControllerKind == BackdropControllerKind.Solid || !config.SupportsOpacity + ? (float?)null // Use default + : Math.Max(config.BaseTintOpacity, 0.8f); + + var effectiveOpacity = config.ComputeEffectiveOpacity(context.BackdropOpacity, baseTintOpacity); + var effectiveLuminosityOpacity = config.SupportsOpacity + ? config.BaseLuminosityOpacity * context.BackdropOpacity + : config.BaseLuminosityOpacity; + + return new BackdropParameters( + TintColor: effectiveBgColor, + FallbackColor: effectiveBgColor, + EffectiveOpacity: effectiveOpacity, + EffectiveLuminosityOpacity: effectiveLuminosityOpacity, + Style: transparencyMode); + } + + private static class ColorBlender + { + /// <summary> + /// Blends a semitransparent tint color over an opaque base color using alpha compositing. + /// </summary> + /// <param name="baseColor">The opaque base color (background)</param> + /// <param name="tintColor">The semitransparent tint color (foreground)</param> + /// <param name="intensity">The intensity of the tint (0.0 - 1.0)</param> + /// <returns>The resulting blended color</returns> + public static Color Blend(Color baseColor, Color tintColor, float intensity) + { + // Normalize alpha to 0.0 - 1.0 range + intensity = Math.Clamp(intensity, 0f, 1f); + + // Alpha compositing formula: result = tint * alpha + base * (1 - alpha) + var r = (byte)((tintColor.R * intensity) + (baseColor.R * (1 - intensity))); + var g = (byte)((tintColor.G * intensity) + (baseColor.G * (1 - intensity))); + var b = (byte)((tintColor.B * intensity) + (baseColor.B * (1 - intensity))); + + // Result is fully opaque since base is opaque + return Color.FromArgb(255, r, g, b); + } + } + + private static class WindowsAccentHueWarpTransform + { + private static readonly (double HIn, double HOut)[] HueMap = + [ + (0, 0), + (10, 1), + (20, 6), + (30, 10), + (40, 14), + (50, 19), + (60, 36), + (70, 94), + (80, 112), + (90, 120), + (100, 120), + (110, 120), + (120, 120), + (130, 120), + (140, 120), + (150, 125), + (160, 135), + (170, 142), + (180, 178), + (190, 205), + (200, 220), + (210, 229), + (220, 237), + (230, 241), + (240, 243), + (250, 244), + (260, 245), + (270, 248), + (280, 252), + (290, 276), + (300, 293), + (310, 313), + (320, 330), + (330, 349), + (340, 353), + (350, 357) + ]; + + public static Color Transform(Color input, Options? opt = null) + { + opt ??= new Options(); + var hsv = input.ToHsv(); + return ColorHelper.FromHsv( + RemapHueLut(hsv.H), + Clamp01(Math.Pow(hsv.S, opt.SaturationGamma) * opt.SaturationGain), + Clamp01((opt.ValueScaleA * hsv.V) + opt.ValueBiasB), + input.A); + } + + // Hue LUT remap (piecewise-linear with cyclic wrap) + private static double RemapHueLut(double hDeg) + { + // Normalize to [0,360) + hDeg = Mod(hDeg, 360.0); + + // Handle wrap-around case: hDeg is between last entry (350°) and 360° + var last = HueMap[^1]; + var first = HueMap[0]; + if (hDeg >= last.HIn) + { + // Interpolate between last entry and first entry (wrapped by 360°) + var t = (hDeg - last.HIn) / (first.HIn + 360.0 - last.HIn + 1e-12); + var ho = Lerp(last.HOut, first.HOut + 360.0, t); + return Mod(ho, 360.0); + } + + // Find segment [i, i+1] where HueMap[i].HIn <= hDeg < HueMap[i+1].HIn + for (var i = 0; i < HueMap.Length - 1; i++) + { + var a = HueMap[i]; + var b = HueMap[i + 1]; + + if (hDeg >= a.HIn && hDeg < b.HIn) + { + var t = (hDeg - a.HIn) / (b.HIn - a.HIn + 1e-12); + return Lerp(a.HOut, b.HOut, t); + } + } + + // Fallback (shouldn't happen) + return hDeg; + } + + private static double Lerp(double a, double b, double t) => a + ((b - a) * t); + + private static double Mod(double x, double m) => ((x % m) + m) % m; + + private static double Clamp01(double x) => x < 0 ? 0 : (x > 1 ? 1 : x); + + public sealed class Options + { + // Saturation boost (1.0 = no change). Typical: 1.3–1.8 + public double SaturationGain { get; init; } = 1.0; + + // Optional saturation gamma (1.0 = linear). <1.0 raises low S a bit; >1.0 preserves low S. + public double SaturationGamma { get; init; } = 1.0; + + // Value (V) remap: V' = a*V + b (tone curve; clamp applied) + // Example that lifts blacks & compresses whites slightly: a=0.50, b=0.08 + public double ValueScaleA { get; init; } = 0.6; + + public double ValueBiasB { get; init; } = 0.01; + } + } + + private static class AccentShades + { + public static (Color Light3, Color Light2, Color Light1, Color Dark1, Color Dark2, Color Dark3) Compute(Color accent) + { + var light1 = accent.Update(brightnessFactor: 0.15, saturationFactor: -0.12); + var light2 = accent.Update(brightnessFactor: 0.30, saturationFactor: -0.24); + var light3 = accent.Update(brightnessFactor: 0.45, saturationFactor: -0.36); + + var dark1 = accent.UpdateBrightness(brightnessFactor: -0.05f); + var dark2 = accent.UpdateBrightness(brightnessFactor: -0.01f); + var dark3 = accent.UpdateBrightness(brightnessFactor: -0.015f); + + return (light3, light2, light1, dark1, dark2, dark3); + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IHostWindow.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IHostWindow.cs new file mode 100644 index 0000000000..59e5f8f95a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IHostWindow.cs @@ -0,0 +1,16 @@ +// 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.Services; + +/// <summary> +/// Represents abstract host window functionality. +/// </summary> +public interface IHostWindow +{ + /// <summary> + /// Gets a value indicating whether the window is visible to the user, taking account not only window visibility but also cloaking. + /// </summary> + bool IsVisibleToUser { get; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs new file mode 100644 index 0000000000..af7e869e20 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/IThemeProvider.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.UI.ViewModels.Services; + +namespace Microsoft.CmdPal.UI.Services; + +/// <summary> +/// Provides theme identification, resource path resolution, and creation of backdrop +/// parameters based on the current <see cref="ThemeContext"/>. +/// </summary> +/// <remarks> +/// Implementations should expose a stable <see cref="ThemeKey"/> and a valid XAML resource +/// dictionary path via <see cref="ResourcePath"/>. The +/// <see cref="GetBackdropParameters(ThemeContext)"/> method computes +/// <see cref="BackdropParameters"/> using the supplied theme context. +/// </remarks> +internal interface IThemeProvider +{ + /// <summary> + /// Gets the unique key identifying this theme provider. + /// </summary> + string ThemeKey { get; } + + /// <summary> + /// Gets the resource dictionary path for this theme. + /// </summary> + string ResourcePath { get; } + + /// <summary> + /// Creates backdrop parameters based on the provided theme context. + /// </summary> + /// <param name="context">The current theme context, including theme, tint, transparency mode, and optional background details.</param> + /// <returns>The computed <see cref="BackdropParameters"/> for the backdrop.</returns> + BackdropParameters GetBackdropParameters(ThemeContext context); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.cs new file mode 100644 index 0000000000..8177326259 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MutableOverridesDictionary.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 Microsoft.UI.Xaml; + +namespace Microsoft.CmdPal.UI.Services; + +/// <summary> +/// Dedicated ResourceDictionary for dynamic overrides that win over base theme resources. Since +/// we can't use a key or name to identify the dictionary in Application resources, we use a dedicated type. +/// </summary> +internal sealed partial class MutableOverridesDictionary : ResourceDictionary; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs new file mode 100644 index 0000000000..cee8aa86b2 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/NormalThemeProvider.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Xaml; +using Windows.UI; +using Windows.UI.ViewManagement; + +namespace Microsoft.CmdPal.UI.Services; + +/// <summary> +/// Provides theme resources and acrylic backdrop parameters matching the default Command Palette theme. +/// </summary> +internal sealed class NormalThemeProvider : IThemeProvider +{ + private static readonly Color DarkBaseColor = Color.FromArgb(255, 32, 32, 32); + private static readonly Color LightBaseColor = Color.FromArgb(255, 243, 243, 243); + private readonly UISettings _uiSettings; + + public NormalThemeProvider(UISettings uiSettings) + { + ArgumentNullException.ThrowIfNull(uiSettings); + _uiSettings = uiSettings; + } + + public string ThemeKey => "normal"; + + public string ResourcePath => "ms-appx:///Styles/Theme.Normal.xaml"; + + public BackdropParameters GetBackdropParameters(ThemeContext context) + { + var isLight = context.Theme == ElementTheme.Light || + (context.Theme == ElementTheme.Default && + _uiSettings.GetColorValue(UIColorType.Background).R > 128); + + var backdropStyle = context.BackdropStyle ?? BackdropStyle.Acrylic; + var config = BackdropStyles.Get(backdropStyle); + + // Apply light/dark theme adjustment to luminosity + var baseLuminosityOpacity = isLight + ? config.BaseLuminosityOpacity + : Math.Min(config.BaseLuminosityOpacity + 0.06f, 1.0f); + + var effectiveOpacity = config.ComputeEffectiveOpacity(context.BackdropOpacity); + var effectiveLuminosityOpacity = baseLuminosityOpacity * context.BackdropOpacity; + + return new BackdropParameters( + TintColor: isLight ? LightBaseColor : DarkBaseColor, + FallbackColor: isLight ? LightBaseColor : DarkBaseColor, + EffectiveOpacity: effectiveOpacity, + EffectiveLuminosityOpacity: effectiveLuminosityOpacity, + Style: backdropStyle); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs new file mode 100644 index 0000000000..6d0a6f01dd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourceSwapper.cs @@ -0,0 +1,332 @@ +// 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 Microsoft.CmdPal.UI.Services; + +/// <summary> +/// Simple theme switcher that swaps application ResourceDictionaries at runtime. +/// Can also operate in event-only mode for consumers to apply resources themselves. +/// Exposes a dedicated override dictionary that stays merged and is cleared on theme changes. +/// </summary> +internal sealed partial class ResourceSwapper +{ + private readonly Lock _resourceSwapGate = new(); + private readonly Dictionary<string, Uri> _themeUris = new(StringComparer.OrdinalIgnoreCase); + private ResourceDictionary? _activeDictionary; + private string? _currentThemeName; + private Uri? _currentThemeUri; + + private ResourceDictionary? _overrideDictionary; + + /// <summary> + /// Raised after a theme has been activated. + /// </summary> + public event EventHandler<ResourcesSwappedEventArgs>? ResourcesSwapped; + + /// <summary> + /// Gets or sets a value indicating whether when true (default) ResourceSwapper updates Application.Current.Resources. When false, it only raises ResourcesSwapped. + /// </summary> + public bool ApplyToAppResources { get; set; } = true; + + /// <summary> + /// Gets name of the currently selected theme (if any). + /// </summary> + public string? CurrentThemeName + { + get + { + lock (_resourceSwapGate) + { + return _currentThemeName; + } + } + } + + /// <summary> + /// Initializes ResourceSwapper by checking Application resources for an already merged theme dictionary. + /// </summary> + public void Initialize() + { + // Find merged dictionary in Application resources that matches a registered theme by URI + // This allows ResourceSwapper to pick up an initial theme set in XAML + var app = Application.Current; + var resourcesMergedDictionaries = app?.Resources?.MergedDictionaries; + if (resourcesMergedDictionaries == null) + { + return; + } + + foreach (var dict in resourcesMergedDictionaries) + { + var uri = dict.Source; + if (uri is null) + { + continue; + } + + var name = GetNameForUri(uri); + if (name is null) + { + continue; + } + + lock (_resourceSwapGate) + { + _currentThemeName = name; + _currentThemeUri = uri; + _activeDictionary = dict; + } + + break; + } + } + + /// <summary> + /// Gets uri of the currently selected theme dictionary (if any). + /// </summary> + public Uri? CurrentThemeUri + { + get + { + lock (_resourceSwapGate) + { + return _currentThemeUri; + } + } + } + + public static ResourceDictionary GetOverrideDictionary(bool clear = false) + { + var app = Application.Current ?? throw new InvalidOperationException("App is null"); + + if (app.Resources == null) + { + throw new InvalidOperationException("Application.Resources is null"); + } + + // (Re)locate the slot – Hot Reload may rebuild Application.Resources. + var slot = app.Resources!.MergedDictionaries! + .OfType<MutableOverridesDictionary>() + .FirstOrDefault(); + + if (slot is null) + { + // If the slot vanished (Hot Reload), create it again at the end so it wins precedence. + slot = new MutableOverridesDictionary(); + app.Resources.MergedDictionaries!.Add(slot); + } + + // Ensure the slot has exactly one child RD we can swap safely. + if (slot.MergedDictionaries!.Count == 0) + { + slot.MergedDictionaries.Add(new ResourceDictionary()); + } + else if (slot.MergedDictionaries.Count > 1) + { + // Normalize to a single child to keep semantics predictable. + var keep = slot.MergedDictionaries[^1]; + slot.MergedDictionaries.Clear(); + slot.MergedDictionaries.Add(keep); + } + + if (clear) + { + // Swap the child dictionary instead of Clear() to avoid reentrancy issues. + var fresh = new ResourceDictionary(); + slot.MergedDictionaries[0] = fresh; + return fresh; + } + + return slot.MergedDictionaries[0]!; + } + + /// <summary> + /// Registers a theme name mapped to a XAML ResourceDictionary URI (e.g. ms-appx:///Themes/Red.xaml) + /// </summary> + public void RegisterTheme(string name, Uri dictionaryUri) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Theme name is required", nameof(name)); + } + + lock (_resourceSwapGate) + { + _themeUris[name] = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri)); + } + } + + /// <summary> + /// Registers a theme with a string URI. + /// </summary> + public void RegisterTheme(string name, string dictionaryUri) + { + ArgumentNullException.ThrowIfNull(dictionaryUri); + RegisterTheme(name, new Uri(dictionaryUri)); + } + + /// <summary> + /// Removes a previously registered theme. + /// </summary> + public bool UnregisterTheme(string name) + { + lock (_resourceSwapGate) + { + return _themeUris.Remove(name); + } + } + + /// <summary> + /// Gets the names of all registered themes. + /// </summary> + public IEnumerable<string> GetRegisteredThemes() + { + lock (_resourceSwapGate) + { + // return a copy to avoid external mutation + return new List<string>(_themeUris.Keys); + } + } + + /// <summary> + /// Activates a theme by name. The dictionary for the given name must be registered first. + /// </summary> + public void ActivateTheme(string theme) + { + if (string.IsNullOrWhiteSpace(theme)) + { + throw new ArgumentException("Theme name is required", nameof(theme)); + } + + Uri uri; + lock (_resourceSwapGate) + { + if (!_themeUris.TryGetValue(theme, out uri!)) + { + throw new KeyNotFoundException($"Theme '{theme}' is not registered."); + } + } + + ActivateThemeInternal(theme, uri); + } + + /// <summary> + /// Tries to activate a theme by name without throwing. + /// </summary> + public bool TryActivateTheme(string theme) + { + if (string.IsNullOrWhiteSpace(theme)) + { + return false; + } + + Uri uri; + lock (_resourceSwapGate) + { + if (!_themeUris.TryGetValue(theme, out uri!)) + { + return false; + } + } + + ActivateThemeInternal(theme, uri); + return true; + } + + /// <summary> + /// Activates a theme by URI to a ResourceDictionary. + /// </summary> + public void ActivateTheme(Uri dictionaryUri) + { + ArgumentNullException.ThrowIfNull(dictionaryUri); + + ActivateThemeInternal(GetNameForUri(dictionaryUri), dictionaryUri); + } + + /// <summary> + /// Clears the currently active theme ResourceDictionary. Also clears the override dictionary. + /// </summary> + public void ClearActiveTheme() + { + lock (_resourceSwapGate) + { + var app = Application.Current; + if (app is null) + { + return; + } + + if (_activeDictionary is not null && ApplyToAppResources) + { + _ = app.Resources.MergedDictionaries.Remove(_activeDictionary); + _activeDictionary = null; + } + + // Clear overrides but keep the override dictionary merged for future updates + _overrideDictionary?.Clear(); + + _currentThemeName = null; + _currentThemeUri = null; + } + } + + private void ActivateThemeInternal(string? name, Uri dictionaryUri) + { + lock (_resourceSwapGate) + { + _currentThemeName = name; + _currentThemeUri = dictionaryUri; + } + + if (ApplyToAppResources) + { + ActivateThemeCore(dictionaryUri); + } + + OnResourcesSwapped(new(name, dictionaryUri)); + } + + private void ActivateThemeCore(Uri dictionaryUri) + { + var app = Application.Current ?? throw new InvalidOperationException("Application.Current is null"); + + // Remove previously applied base theme dictionary + if (_activeDictionary is not null) + { + _ = app.Resources.MergedDictionaries.Remove(_activeDictionary); + _activeDictionary = null; + } + + // Load and merge the new base theme dictionary + var newDict = new ResourceDictionary { Source = dictionaryUri }; + app.Resources.MergedDictionaries.Add(newDict); + _activeDictionary = newDict; + + // Ensure override dictionary exists and is merged last, then clear it to avoid leaking stale overrides + _overrideDictionary = GetOverrideDictionary(clear: true); + } + + private string? GetNameForUri(Uri dictionaryUri) + { + lock (_resourceSwapGate) + { + foreach (var (key, value) in _themeUris) + { + if (Uri.Compare(value, dictionaryUri, UriComponents.AbsoluteUri, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0) + { + return key; + } + } + + return null; + } + } + + private void OnResourcesSwapped(ResourcesSwappedEventArgs e) + { + ResourcesSwapped?.Invoke(this, e); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.cs new file mode 100644 index 0000000000..0a5cc15de6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ResourcesSwappedEventArgs.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.Services; + +public sealed class ResourcesSwappedEventArgs(string? name, Uri dictionaryUri) : EventArgs +{ + public string? Name { get; } = name; + + public Uri DictionaryUri { get; } = dictionaryUri ?? throw new ArgumentNullException(nameof(dictionaryUri)); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.cs new file mode 100644 index 0000000000..9862e6aa35 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeContext.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 Microsoft.CmdPal.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.CmdPal.UI.Services; + +/// <summary> +/// Input parameters for theme computation, passed to theme providers. +/// </summary> +internal sealed record ThemeContext +{ + public ElementTheme Theme { get; init; } + + public Color Tint { get; init; } + + public ImageSource? BackgroundImageSource { get; init; } + + public Stretch BackgroundImageStretch { get; init; } + + public double BackgroundImageOpacity { get; init; } + + public int? ColorIntensity { get; init; } + + public BackdropStyle? BackdropStyle { get; init; } + + public float BackdropOpacity { get; init; } = 1.0f; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs new file mode 100644 index 0000000000..eb344780f0 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs @@ -0,0 +1,279 @@ +// 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 CommunityToolkit.WinUI; +using ManagedCommon; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Windows.UI.ViewManagement; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; + +namespace Microsoft.CmdPal.UI.Services; + +/// <summary> +/// ThemeService is a hub that translates user settings and system preferences into concrete +/// theme resources and notifies listeners of changes. +/// </summary> +internal sealed partial class ThemeService : IThemeService, IDisposable +{ + private static readonly TimeSpan ReloadDebounceInterval = TimeSpan.FromMilliseconds(500); + + private readonly UISettings _uiSettings; + private readonly SettingsModel _settings; + private readonly ResourceSwapper _resourceSwapper; + private readonly NormalThemeProvider _normalThemeProvider; + private readonly ColorfulThemeProvider _colorfulThemeProvider; + + private DispatcherQueue? _dispatcherQueue; + private DispatcherQueueTimer? _dispatcherQueueTimer; + private bool _isInitialized; + private bool _disposed; + private InternalThemeState _currentState; + + public event EventHandler<ThemeChangedEventArgs>? ThemeChanged; + + public ThemeSnapshot Current => Volatile.Read(ref _currentState).Snapshot; + + /// <summary> + /// Initializes the theme service. Must be called after the application window is activated and on UI thread. + /// </summary> + public void Initialize() + { + if (_isInitialized) + { + return; + } + + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + if (_dispatcherQueue is null) + { + throw new InvalidOperationException("Failed to get DispatcherQueue for the current thread. Ensure Initialize is called on the UI thread after window activation."); + } + + _dispatcherQueueTimer = _dispatcherQueue.CreateTimer(); + + _resourceSwapper.Initialize(); + _isInitialized = true; + Reload(); + } + + private void Reload() + { + if (!_isInitialized) + { + return; + } + + // provider selection + var themeColorIntensity = Math.Clamp(_settings.CustomThemeColorIntensity, 0, 100); + var imageTintIntensity = Math.Clamp(_settings.BackgroundImageTintIntensity, 0, 100); + var effectiveColorIntensity = _settings.ColorizationMode == ColorizationMode.Image + ? imageTintIntensity + : themeColorIntensity; + + IThemeProvider provider = UseColorfulProvider(effectiveColorIntensity) ? _colorfulThemeProvider : _normalThemeProvider; + + // Calculate values + var tint = _settings.ColorizationMode switch + { + ColorizationMode.CustomColor => _settings.CustomThemeColor, + ColorizationMode.WindowsAccentColor => _uiSettings.GetColorValue(UIColorType.Accent), + ColorizationMode.Image => _settings.CustomThemeColor, + _ => Colors.Transparent, + }; + var effectiveTheme = GetElementTheme((ElementTheme)_settings.Theme); + var imageSource = _settings.ColorizationMode == ColorizationMode.Image + ? LoadImageSafe(_settings.BackgroundImagePath) + : null; + var stretch = _settings.BackgroundImageFit switch + { + BackgroundImageFit.Fill => Stretch.Fill, + _ => Stretch.UniformToFill, + }; + var opacity = Math.Clamp(_settings.BackgroundImageOpacity, 0, 100) / 100.0; + + // create input and offload to actual theme provider + var context = new ThemeContext + { + Tint = tint, + ColorIntensity = effectiveColorIntensity, + Theme = effectiveTheme, + BackgroundImageSource = imageSource, + BackgroundImageStretch = stretch, + BackgroundImageOpacity = opacity, + BackdropStyle = _settings.BackdropStyle, + BackdropOpacity = Math.Clamp(_settings.BackdropOpacity, 0, 100) / 100f, + }; + var backdrop = provider.GetBackdropParameters(context); + var blur = _settings.BackgroundImageBlurAmount; + var brightness = _settings.BackgroundImageBrightness; + + // Create public snapshot (no provider!) + var hasColorization = effectiveColorIntensity > 0 + && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image; + + var snapshot = new ThemeSnapshot + { + Tint = tint, + TintIntensity = effectiveColorIntensity / 100f, + Theme = effectiveTheme, + BackgroundImageSource = imageSource, + BackgroundImageStretch = stretch, + BackgroundImageOpacity = opacity, + BackdropParameters = backdrop, + BackdropOpacity = context.BackdropOpacity, + BlurAmount = blur, + BackgroundBrightness = brightness / 100f, + HasColorization = hasColorization, + }; + + // Bundle with provider for internal use + var newState = new InternalThemeState + { + Snapshot = snapshot, + Provider = provider, + }; + + // Atomic swap + Interlocked.Exchange(ref _currentState, newState); + + _resourceSwapper.TryActivateTheme(provider.ThemeKey); + ThemeChanged?.Invoke(this, new ThemeChangedEventArgs()); + } + + private bool UseColorfulProvider(int effectiveColorIntensity) + { + return _settings.ColorizationMode == ColorizationMode.Image + || (effectiveColorIntensity > 0 && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor); + } + + private static BitmapImage? LoadImageSafe(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + // If it looks like a file path and exists, prefer absolute file URI + if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri)) + { + return null; + } + + if (!uri.IsAbsoluteUri && File.Exists(path)) + { + uri = new Uri(Path.GetFullPath(path)); + } + + return new BitmapImage(uri); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to load background image '{path}'. {ex.Message}"); + return null; + } + } + + public ThemeService(SettingsModel settings, ResourceSwapper resourceSwapper) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(resourceSwapper); + + _settings = settings; + _settings.SettingsChanged += SettingsOnSettingsChanged; + + _resourceSwapper = resourceSwapper; + + _uiSettings = new UISettings(); + _uiSettings.ColorValuesChanged += UiSettings_ColorValuesChanged; + + _normalThemeProvider = new NormalThemeProvider(_uiSettings); + _colorfulThemeProvider = new ColorfulThemeProvider(_uiSettings); + List<IThemeProvider> providers = [_normalThemeProvider, _colorfulThemeProvider]; + + foreach (var provider in providers) + { + _resourceSwapper.RegisterTheme(provider.ThemeKey, provider.ResourcePath); + } + + _currentState = new InternalThemeState + { + Snapshot = new ThemeSnapshot + { + Tint = Colors.Transparent, + Theme = ElementTheme.Light, + BackdropParameters = new BackdropParameters(Colors.Black, Colors.Black, EffectiveOpacity: 0.5f, EffectiveLuminosityOpacity: 0.5f), + BackdropOpacity = 1.0f, + BackgroundImageOpacity = 1, + BackgroundImageSource = null, + BackgroundImageStretch = Stretch.Fill, + BlurAmount = 0, + TintIntensity = 1.0f, + BackgroundBrightness = 0, + HasColorization = false, + }, + Provider = _normalThemeProvider, + }; + } + + private void RequestReload() + { + if (!_isInitialized || _dispatcherQueueTimer is null) + { + return; + } + + _dispatcherQueueTimer.Debounce(Reload, ReloadDebounceInterval); + } + + private ElementTheme GetElementTheme(ElementTheme theme) + { + return theme switch + { + ElementTheme.Light => ElementTheme.Light, + ElementTheme.Dark => ElementTheme.Dark, + _ => _uiSettings.GetColorValue(UIColorType.Background).CalculateBrightness() < 0.5 + ? ElementTheme.Dark + : ElementTheme.Light, + }; + } + + private void SettingsOnSettingsChanged(SettingsModel sender, object? args) + { + RequestReload(); + } + + private void UiSettings_ColorValuesChanged(UISettings sender, object args) + { + RequestReload(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _dispatcherQueueTimer?.Stop(); + _uiSettings.ColorValuesChanged -= UiSettings_ColorValuesChanged; + _settings.SettingsChanged -= SettingsOnSettingsChanged; + } + + private sealed class InternalThemeState + { + public required ThemeSnapshot Snapshot { get; init; } + + public required IThemeProvider Provider { get; init; } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs new file mode 100644 index 0000000000..5c250b94ef --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/WindowThemeSynchronizer.cs @@ -0,0 +1,70 @@ +// 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; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.UI.Xaml; + +namespace Microsoft.CmdPal.UI.Services; + +/// <summary> +/// Synchronizes a window's theme with <see cref="IThemeService"/>. +/// </summary> +internal sealed partial class WindowThemeSynchronizer : IDisposable +{ + private readonly IThemeService _themeService; + private readonly Window _window; + + /// <summary> + /// Initializes a new instance of the <see cref="WindowThemeSynchronizer"/> class and subscribes to theme changes. + /// </summary> + /// <param name="themeService">The theme service to monitor for changes.</param> + /// <param name="window">The window to synchronize.</param> + /// <exception cref="ArgumentNullException">Thrown when <paramref name="themeService"/> or <paramref name="window"/> is null.</exception> + public WindowThemeSynchronizer(IThemeService themeService, Window window) + { + _themeService = themeService ?? throw new ArgumentNullException(nameof(themeService)); + _window = window ?? throw new ArgumentNullException(nameof(window)); + _themeService.ThemeChanged += ThemeServiceOnThemeChanged; + } + + /// <summary> + /// Unsubscribes from theme change events. + /// </summary> + public void Dispose() + { + _themeService.ThemeChanged -= ThemeServiceOnThemeChanged; + } + + /// <summary> + /// Applies the current theme to the window when theme changes occur. + /// </summary> + private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) + { + if (_window.Content is not FrameworkElement fe) + { + return; + } + + var dispatcherQueue = fe.DispatcherQueue; + + if (dispatcherQueue is not null && dispatcherQueue.HasThreadAccess) + { + ApplyRequestedTheme(fe); + } + else + { + dispatcherQueue?.TryEnqueue(() => ApplyRequestedTheme(fe)); + } + } + + private void ApplyRequestedTheme(FrameworkElement fe) + { + // LOAD BEARING: Changing the RequestedTheme to Dark then Light then target forces + // a refresh of the theme. + fe.RequestedTheme = ElementTheme.Dark; + fe.RequestedTheme = ElementTheme.Light; + fe.RequestedTheme = _themeService.Current.Theme; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml new file mode 100644 index 0000000000..8a66b54d89 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml @@ -0,0 +1,297 @@ +<?xml version="1.0" encoding="utf-8" ?> +<Page + x:Class="Microsoft.CmdPal.UI.Settings.AppearancePage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:CommunityToolkit.WinUI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:helpers="using:Microsoft.CmdPal.UI.Helpers" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:ptControls="using:Microsoft.CmdPal.UI.Controls" + xmlns:ui="using:CommunityToolkit.WinUI" + mc:Ignorable="d"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <ScrollViewer Grid.Row="1"> + <Grid Padding="16"> + <StackPanel + MaxWidth="1000" + HorizontalAlignment="Stretch" + Spacing="{StaticResource SettingsCardSpacing}"> + + <StackPanel + Margin="0,0,0,16" + HorizontalAlignment="Left" + Orientation="Horizontal" + Spacing="16"> + <ptControls:ScreenPreview> + <ptControls:CommandPalettePreview + PreviewBackdropStyle="{x:Bind ViewModel.Appearance.EffectiveBackdropStyle, Mode=OneWay}" + PreviewBackgroundColor="{x:Bind ViewModel.Appearance.EffectiveBackdrop.TintColor, Mode=OneWay}" + PreviewBackgroundImageBlurAmount="{x:Bind ViewModel.Appearance.EffectiveBackgroundImageBlurAmount, Mode=OneWay}" + PreviewBackgroundImageBrightness="{x:Bind ViewModel.Appearance.EffectiveBackgroundImageBrightness, Mode=OneWay}" + PreviewBackgroundImageFit="{x:Bind ViewModel.Appearance.BackgroundImageFit, Mode=OneWay}" + PreviewBackgroundImageOpacity="{x:Bind ViewModel.Appearance.EffectiveImageOpacity, Mode=OneWay}" + PreviewBackgroundImageSource="{x:Bind ViewModel.Appearance.EffectiveBackgroundImageSource, Mode=OneWay}" + PreviewBackgroundImageTint="{x:Bind ViewModel.Appearance.EffectiveThemeColor, Mode=OneWay}" + PreviewBackgroundImageTintIntensity="{x:Bind ViewModel.Appearance.EffectiveTintIntensity, Mode=OneWay}" + PreviewEffectiveOpacity="{x:Bind ViewModel.Appearance.EffectiveBackdrop.EffectiveOpacity, Mode=OneWay}" + RequestedTheme="{x:Bind ViewModel.Appearance.EffectiveTheme, Mode=OneWay}" /> + </ptControls:ScreenPreview> + <StackPanel VerticalAlignment="Bottom" Spacing="8"> + <Button + x:Uid="Settings_AppearancePage_OpenCommandPaletteButton" + MinWidth="200" + HorizontalContentAlignment="Left" + Click="OpenCommandPalette_Click" + Style="{StaticResource SubtleButtonStyle}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="Settings_AppearancePage_OpenCommandPaletteButton_Text" /> + </StackPanel> + </Button> + <Button + x:Uid="Settings_AppearancePage_ResetAppearanceButton" + MinWidth="200" + HorizontalContentAlignment="Left" + Command="{x:Bind ViewModel.Appearance.ResetAppearanceSettingsCommand}" + Style="{StaticResource SubtleButtonStyle}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="Settings_AppearancePage_ResetAppearanceButton_Text" /> + </StackPanel> + </Button> + </StackPanel> + </StackPanel> + + <controls:SettingsCard x:Uid="Settings_GeneralPage_AppTheme_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.Appearance.ThemeIndex, Mode=TwoWay}"> + + <ComboBoxItem x:Uid="Settings_GeneralPage_AppTheme_Mode_System_Automation" Tag="Default"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="Settings_GeneralPage_AppTheme_Mode_System" /> + </StackPanel> + </ComboBoxItem> + + <ComboBoxItem x:Uid="Settings_GeneralPage_AppTheme_Mode_Light_Automation" Tag="Light"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="Settings_GeneralPage_AppTheme_Mode_Light" /> + </StackPanel> + </ComboBoxItem> + + <ComboBoxItem x:Uid="Settings_GeneralPage_AppTheme_Mode_Dark_Automation" Tag="Dark"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="Settings_GeneralPage_AppTheme_Mode_Dark" /> + </StackPanel> + </ComboBoxItem> + + </ComboBox> + </controls:SettingsCard> + + <controls:SettingsExpander + x:Uid="Settings_GeneralPage_BackdropStyle_SettingsCard" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="{x:Bind ViewModel.Appearance.IsBackdropOpacityVisible, Mode=OneWay}"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.Appearance.BackdropStyleIndex, Mode=TwoWay}"> + <ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_Acrylic" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_Transparent" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_Mica" /> + <!-- Hidden: preview not working well, kept to preserve index mapping --> + <ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_AcrylicThin" Visibility="Collapsed" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_MicaAlt" /> + </ComboBox> + <controls:SettingsExpander.Items> + <!-- Mica description (no opacity control) --> + <controls:SettingsCard + x:Uid="Settings_GeneralPage_MicaBackdrop_SettingsCard" + HorizontalContentAlignment="Stretch" + ContentAlignment="Vertical" + Visibility="{x:Bind ViewModel.Appearance.IsMicaBackdropDescriptionVisible, Mode=OneWay}"> + <TextBlock + x:Uid="Settings_GeneralPage_MicaBackdrop_DescriptionTextBlock" + Margin="24" + HorizontalAlignment="Stretch" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + HorizontalTextAlignment="Center" + TextAlignment="Center" + TextWrapping="WrapWholeWords" /> + </controls:SettingsCard> + <!-- Opacity slider (for non-Mica styles) --> + <controls:SettingsCard x:Uid="Settings_GeneralPage_BackdropOpacity_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsBackdropOpacityVisible, Mode=OneWay}"> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + Maximum="100" + Minimum="0" + StepFrequency="1" + Value="{x:Bind ViewModel.Appearance.BackdropOpacity, Mode=TwoWay}" /> + </controls:SettingsCard> + </controls:SettingsExpander.Items> + </controls:SettingsExpander> + + <controls:SettingsExpander + x:Uid="Settings_GeneralPage_Background_SettingsExpander" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.Appearance.IsBackgroundSettingsEnabled, Mode=OneWay}" + IsExpanded="{x:Bind ViewModel.Appearance.IsColorizationDetailsExpanded, Mode=TwoWay}"> + <Grid> + <ComboBox + x:Uid="Settings_GeneralPage_ColorizationMode" + MinWidth="{StaticResource SettingActionControlMinWidth}" + SelectedIndex="{x:Bind ViewModel.Appearance.ColorizationModeIndex, Mode=TwoWay}" + Visibility="{x:Bind ViewModel.Appearance.IsBackgroundSettingsEnabled, Mode=OneWay}"> + <ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_None" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_WindowsAccent" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_CustomColor" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_Image" /> + </ComboBox> + <TextBlock + x:Uid="Settings_GeneralPage_Background_NotAvailable" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Visibility="{x:Bind ViewModel.Appearance.IsBackgroundNotAvailableVisible, Mode=OneWay}" /> + </Grid> + <controls:SettingsExpander.Items> + <!-- none --> + <controls:SettingsCard + x:Uid="Settings_GeneralPage_NoBackground_SettingsCard" + HorizontalContentAlignment="Stretch" + ContentAlignment="Vertical" + Visibility="{x:Bind ViewModel.Appearance.IsNoBackgroundVisible, Mode=OneWay}"> + <TextBlock + x:Uid="Settings_GeneralPage_NoBackground_DescriptionTextBlock" + Margin="24" + HorizontalAlignment="Stretch" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + HorizontalTextAlignment="Center" + TextAlignment="Center" + TextWrapping="WrapWholeWords" /> + </controls:SettingsCard> + + <!-- system accent color --> + <controls:SettingsCard x:Uid="Settings_GeneralPage_WindowsAccentColor_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsAccentColorControlsVisible, Mode=OneWay}"> + <controls:SettingsCard.Description> + <TextBlock> + <Run x:Uid="Settings_GeneralPage_WindowsAccentColor_SettingsCard_Description1" /> + <Hyperlink + Click="OpenWindowsColorsSettings_Click" + TextDecorations="None" + UnderlineStyle="None"> + <Run x:Uid="Settings_GeneralPage_WindowsAccentColor_OpenWindowsColorsLinkText" /> + </Hyperlink> + </TextBlock> + </controls:SettingsCard.Description> + <controls:SettingsCard.Content> + <Border + MinWidth="32" + MinHeight="32" + CornerRadius="{ThemeResource ControlCornerRadius}"> + <Border.Background> + <SolidColorBrush Color="{x:Bind ViewModel.Appearance.EffectiveThemeColor, Mode=OneWay}" /> + </Border.Background> + </Border> + </controls:SettingsCard.Content> + </controls:SettingsCard> + + <!-- background --> + <controls:SettingsCard + x:Uid="Settings_GeneralPage_BackgroundImage_SettingsCard" + Description="{x:Bind ViewModel.Appearance.BackgroundImagePath, Mode=OneWay}" + Visibility="{x:Bind ViewModel.Appearance.IsBackgroundControlsVisible, Mode=OneWay}"> + <Button x:Uid="Settings_GeneralPage_BackgroundImage_ChooseImageButton" Click="PickBackgroundImage_Click" /> + </controls:SettingsCard> + <controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundImageBrightness_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsBackgroundControlsVisible, Mode=OneWay}"> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + Maximum="100" + Minimum="-100" + StepFrequency="1" + Value="{x:Bind ViewModel.Appearance.BackgroundImageBrightness, Mode=TwoWay}" /> + </controls:SettingsCard> + <controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundImageBlur_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsBackgroundControlsVisible, Mode=OneWay}"> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + Maximum="50" + Minimum="0" + StepFrequency="1" + Value="{x:Bind ViewModel.Appearance.BackgroundImageBlurAmount, Mode=TwoWay}" /> + </controls:SettingsCard> + <controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundImageFit_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsBackgroundControlsVisible, Mode=OneWay}"> + <ComboBox SelectedIndex="{x:Bind ViewModel.Appearance.BackgroundImageFitIndex, Mode=TwoWay}"> + <ComboBoxItem x:Uid="BackgroundImageFit_ComboBoxItem_Fill" /> + <ComboBoxItem x:Uid="BackgroundImageFit_ComboBoxItem_Stretch" /> + </ComboBox> + </controls:SettingsCard> + + <!-- Background tint color and intensity --> + <controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundTint_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsCustomTintVisible, Mode=OneWay}"> + <ptControls:ColorPickerButton + HasSelectedColor="True" + IsAlphaEnabled="False" + PaletteColors="{x:Bind ViewModel.Appearance.Swatches}" + SelectedColor="{x:Bind ViewModel.Appearance.ThemeColor, Mode=TwoWay}" /> + </controls:SettingsCard> + <controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundTintIntensity_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsColorIntensityVisible, Mode=OneWay}"> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + Maximum="100" + Minimum="1" + StepFrequency="1" + Value="{x:Bind ViewModel.Appearance.ColorIntensity, Mode=TwoWay}" /> + </controls:SettingsCard> + <controls:SettingsCard x:Uid="Settings_GeneralPage_ImageTintIntensity_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsImageTintIntensityVisible, Mode=OneWay}"> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + Maximum="100" + Minimum="0" + StepFrequency="1" + Value="{x:Bind ViewModel.Appearance.BackgroundImageTintIntensity, Mode=TwoWay}" /> + </controls:SettingsCard> + + <!-- Reset appearance properties --> + <controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundImage_ResetProperties_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsResetButtonVisible, Mode=OneWay}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <Button x:Uid="Settings_GeneralPage_Background_ResetImagePropertiesButton" Command="{x:Bind ViewModel.Appearance.ResetBackgroundImagePropertiesCommand}" /> + </StackPanel> + </controls:SettingsCard> + + </controls:SettingsExpander.Items> + </controls:SettingsExpander> + + <!-- 'Behavior' section --> + + <TextBlock x:Uid="BehaviorSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" /> + + <controls:SettingsCard x:Uid="Settings_GeneralPage_ShowAppDetails_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.ShowAppDetails, Mode=TwoWay}" /> + </controls:SettingsCard> + + <controls:SettingsCard x:Uid="Settings_GeneralPage_BackspaceGoesBack_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.BackspaceGoesBack, Mode=TwoWay}" /> + </controls:SettingsCard> + + <controls:SettingsCard x:Uid="Settings_GeneralPage_EscapeKeyBehavior_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.EscapeKeyBehaviorIndex, Mode=TwoWay}"> + <ComboBoxItem x:Uid="Settings_GeneralPage_EscapeKeyBehavior_Option_DismissEmptySearchOrGoBack" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_EscapeKeyBehavior_Option_AlwaysGoBack" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_EscapeKeyBehavior_Option_AlwaysDismiss" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_EscapeKeyBehavior_Option_AlwaysHide" /> + </ComboBox> + </controls:SettingsCard> + + <controls:SettingsCard x:Uid="Settings_GeneralPage_SingleClickActivation_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.SingleClickActivates, Mode=TwoWay}" /> + </controls:SettingsCard> + + <controls:SettingsCard x:Uid="Settings_GeneralPage_DisableAnimations_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.DisableAnimations, Mode=TwoWay}" /> + </controls:SettingsCard> + </StackPanel> + </Grid> + </ScrollViewer> + </Grid> +</Page> \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs new file mode 100644 index 0000000000..427c7225bb --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using CommunityToolkit.Mvvm.Messaging; +using ManagedCommon; +using Microsoft.CmdPal.Core.ViewModels.Messages; +using Microsoft.CmdPal.UI.Events; +using Microsoft.CmdPal.UI.Messages; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; +using Microsoft.Windows.Storage.Pickers; +using Windows.Win32.Foundation; + +namespace Microsoft.CmdPal.UI.Settings; + +/// <summary> +/// An empty page that can be used on its own or navigated to within a Frame. +/// </summary> +public sealed partial class AppearancePage : Page +{ + private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); + + internal SettingsViewModel ViewModel { get; } + + public AppearancePage() + { + InitializeComponent(); + + var settings = App.Current.Services.GetService<SettingsModel>()!; + var themeService = App.Current.Services.GetRequiredService<IThemeService>(); + var topLevelCommandManager = App.Current.Services.GetService<TopLevelCommandManager>()!; + ViewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + } + + private async void PickBackgroundImage_Click(object sender, RoutedEventArgs e) + { + try + { + if (XamlRoot?.ContentIslandEnvironment is null) + { + return; + } + + var windowId = XamlRoot?.ContentIslandEnvironment?.AppWindowId ?? new WindowId(0); + + var picker = new FileOpenPicker(windowId) + { + CommitButtonText = ViewModels.Properties.Resources.builtin_settings_appearance_pick_background_image_title!, + SuggestedStartLocation = PickerLocationId.PicturesLibrary, + ViewMode = PickerViewMode.Thumbnail, + }; + + string[] extensions = [".png", ".bmp", ".jpg", ".jpeg", ".jfif", ".gif", ".tiff", ".tif", ".webp", ".jxr"]; + foreach (var ext in extensions) + { + picker.FileTypeFilter!.Add(ext); + } + + var file = await picker.PickSingleFileAsync()!; + if (file != null) + { + ViewModel.Appearance.BackgroundImagePath = file.Path ?? string.Empty; + } + } + catch (Exception ex) + { + Logger.LogError("Failed to pick background image file", ex); + } + } + + private void OpenWindowsColorsSettings_Click(Hyperlink sender, HyperlinkClickEventArgs args) + { + // LOAD BEARING (or BEAR LOADING?): Process.Start with UseShellExecute inside a XAML input event can trigger WinUI reentrancy + // and cause FailFast crashes. Task.Run moves the call off the UI thread to prevent hard process termination. + Task.Run(() => + { + try + { + _ = Process.Start(new ProcessStartInfo("ms-settings:colors") { UseShellExecute = true }); + } + catch (Exception ex) + { + Logger.LogError("Failed to open Windows Settings", ex); + } + }); + } + + private void OpenCommandPalette_Click(object sender, RoutedEventArgs e) + { + WeakReferenceMessenger.Default.Send<HotkeySummonMessage>(new(string.Empty, HWND.Null)); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml index 53168961ce..df60c83362 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml @@ -12,7 +12,7 @@ xmlns:local="using:Microsoft.CmdPal.UI.Settings" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" mc:Ignorable="d"> <Page.Resources> @@ -33,6 +33,10 @@ x:Key="StringEmptyToBoolConverter" EmptyValue="False" NotEmptyValue="True" /> + <converters:BoolToObjectConverter + x:Key="BoolToOptionConverter" + FalseValue="1" + TrueValue="0" /> </ResourceDictionary> </Page.Resources> @@ -47,25 +51,37 @@ MaxWidth="1000" HorizontalAlignment="Stretch" Spacing="{StaticResource SettingsCardSpacing}"> - - <controls:SettingsCard x:Uid="ExtensionEnableCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:SettingsCard x:Uid="ExtensionEnableCard"> + <controls:SettingsCard.HeaderIcon> + <cpcontrols:ContentIcon> + <cpcontrols:ContentIcon.Content> + <cpcontrols:IconBox + Width="20" + Height="20" + AutomationProperties.AccessibilityView="Raw" + SourceKey="{x:Bind ViewModel.Icon, Mode=OneWay}" + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> + </cpcontrols:ContentIcon.Content> + </cpcontrols:ContentIcon> + </controls:SettingsCard.HeaderIcon> + <controls:SettingsCard.Header> + <TextBlock> + <Run x:Uid="ExtensionEnable" Text="Enable" /> + <Run Text="{x:Bind ViewModel.DisplayName}" /> + </TextBlock> + </controls:SettingsCard.Header> <ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> </controls:SettingsCard> - <controls:SwitchPresenter HorizontalAlignment="Stretch" TargetType="x:Boolean" Value="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <controls:Case Value="True"> - <StackPanel Orientation="Vertical"> - <TextBlock x:Uid="ExtensionCommandsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" /> - <ItemsRepeater ItemsSource="{x:Bind ViewModel.TopLevelCommands, Mode=OneWay}" Layout="{StaticResource VerticalStackLayout}"> <ItemsRepeater.ItemTemplate> - <DataTemplate x:DataType="viewmodels:TopLevelViewModel"> + <DataTemplate x:DataType="viewModels:TopLevelViewModel"> <controls:SettingsExpander DataContext="{x:Bind}" Description="{x:Bind Subtitle, Mode=OneWay}" @@ -78,29 +94,91 @@ Height="20" AutomationProperties.AccessibilityView="Raw" SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" /> + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> </cpcontrols:ContentIcon.Content> </cpcontrols:ContentIcon> </controls:SettingsExpander.HeaderIcon> <!-- Content goes here --> - <controls:SettingsExpander.Items> - <controls:SettingsCard x:Uid="Settings_ExtensionPage_GlobalHotkey_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:SettingsCard x:Uid="Settings_ExtensionPage_GlobalHotkey_SettingsCard"> <cpcontrols:ShortcutControl HotkeySettings="{x:Bind Hotkey, Mode=TwoWay}" /> </controls:SettingsCard> - - <controls:SettingsCard x:Uid="Settings_ExtensionPage_Alias_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> - <StackPanel Orientation="Vertical"> - <TextBox Text="{x:Bind AliasText, Mode=TwoWay}" /> - <ToggleSwitch - IsEnabled="{x:Bind AliasText, Converter={StaticResource StringEmptyToBoolConverter}, Mode=OneWay}" - IsOn="{x:Bind IsDirectAlias, Mode=TwoWay}" - OffContent="Indirect" - OnContent="Direct" /> - </StackPanel> + <controls:SettingsCard x:Uid="Settings_ExtensionPage_Alias_SettingsCard"> + <TextBox + x:Uid="Settings_ExtensionPage_Alias_PlaceholderText" + MinWidth="{StaticResource SettingActionControlMinWidth}" + Text="{x:Bind AliasText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> </controls:SettingsCard> + <controls:SettingsCard x:Uid="Settings_ExtensionPage_AliasActivation_SettingsCard" IsEnabled="{x:Bind AliasText, Converter={StaticResource StringEmptyToBoolConverter}, Mode=OneWay}"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind IsDirectAlias, Converter={StaticResource BoolToOptionConverter}, Mode=TwoWay}"> + <ComboBoxItem x:Uid="Settings_ExtensionPage_Alias_DirectComboBox" /> + <ComboBoxItem x:Uid="Settings_ExtensionPage_Alias_IndirectComboBox" /> + </ComboBox> + </controls:SettingsCard> + </controls:SettingsExpander.Items> + </controls:SettingsExpander> + </DataTemplate> + </ItemsRepeater.ItemTemplate> + </ItemsRepeater> + <Grid Visibility="{x:Bind ViewModel.HasFallbackCommands}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <TextBlock x:Uid="ExtensionFallbackCommandsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" /> + <HyperlinkButton + x:Uid="ManageFallbackRankAutomation" + Grid.Column="1" + Margin="0,0,0,4" + Padding="0" + VerticalAlignment="Bottom" + Click="RankButton_Click"> + <HyperlinkButton.Content> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon + AutomationProperties.AccessibilityView="Raw" + FontSize="16" + Glyph="" /> + <TextBlock x:Uid="ManageFallbackRank" /> + </StackPanel> + </HyperlinkButton.Content> + </HyperlinkButton> + </Grid> + + <ItemsRepeater + ItemsSource="{x:Bind ViewModel.FallbackCommands, Mode=OneWay}" + Layout="{StaticResource VerticalStackLayout}" + Visibility="{x:Bind ViewModel.HasFallbackCommands}"> + <ItemsRepeater.ItemTemplate> + <DataTemplate x:DataType="viewModels:FallbackSettingsViewModel"> + <controls:SettingsExpander + Grid.Column="1" + Header="{x:Bind DisplayName, Mode=OneWay}" + IsExpanded="False"> + <controls:SettingsExpander.HeaderIcon> + <cpcontrols:ContentIcon> + <cpcontrols:ContentIcon.Content> + <cpcontrols:IconBox + Width="20" + Height="20" + AutomationProperties.AccessibilityView="Raw" + SourceKey="{x:Bind Icon, Mode=OneWay}" + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> + </cpcontrols:ContentIcon.Content> + </cpcontrols:ContentIcon> + </controls:SettingsExpander.HeaderIcon> + + <ToggleSwitch IsOn="{x:Bind IsEnabled, Mode=TwoWay}" /> + + <controls:SettingsExpander.Items> + <controls:SettingsCard ContentAlignment="Left"> + <cpcontrols:CheckBoxWithDescriptionControl + x:Uid="Settings_FallbacksPage_GlobalResults_SettingsCard" + IsChecked="{x:Bind IncludeInGlobalResults, Mode=TwoWay}" + IsEnabled="{x:Bind IsEnabled, Mode=OneWay}" /> + </controls:SettingsCard> </controls:SettingsExpander.Items> </controls:SettingsExpander> </DataTemplate> @@ -112,8 +190,29 @@ Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Visibility="{x:Bind ViewModel.HasSettings}" /> - <Frame x:Name="SettingsFrame" Visibility="{x:Bind ViewModel.HasSettings}"> - <cmdpalUI:ContentPage ViewModel="{x:Bind ViewModel.SettingsPage, Mode=OneWay}" /> + <Frame + x:Name="SettingsFrame" + Background="{ThemeResource SettingsCardBackground}" + BorderBrush="{ThemeResource SettingsCardBorderBrush}" + BorderThickness="{ThemeResource SettingsCardBorderThickness}" + CornerRadius="{StaticResource ControlCornerRadius}" + Visibility="{x:Bind ViewModel.HasSettings}"> + <controls:SwitchPresenter + HorizontalAlignment="Stretch" + TargetType="x:Boolean" + Value="{x:Bind ViewModel.LoadingSettings, Mode=OneWay}"> + <controls:Case Value="True"> + <ProgressRing + Width="36" + Height="36" + HorizontalAlignment="Center" + VerticalAlignment="Center" + IsIndeterminate="True" /> + </controls:Case> + <controls:Case Value="False"> + <cmdpalUI:ContentPage ViewModel="{x:Bind ViewModel.SettingsPage, Mode=OneWay}" /> + </controls:Case> + </controls:SwitchPresenter> </Frame> <TextBlock @@ -131,7 +230,6 @@ Text="{x:Bind ViewModel.ExtensionVersion}" /> </controls:SettingsCard> </StackPanel> - </controls:Case> <controls:Case Value="False"> @@ -147,10 +245,9 @@ Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Visibility="{x:Bind ViewModel.IsFromExtension, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" /> <controls:SettingsCard x:Uid="Settings_ExtensionPage_Builtin_SettingsCard" Visibility="{x:Bind ViewModel.IsFromExtension, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" /> - - </StackPanel> </Grid> </ScrollViewer> + <cpcontrols:FallbackRankerDialog x:Name="FallbackRankerDialog" /> </Grid> </Page> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml.cs index 2bfdc1bcb3..ac05f356b3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionPage.xaml.cs @@ -25,4 +25,9 @@ public sealed partial class ExtensionPage : Page ? vm : throw new ArgumentException($"{nameof(ExtensionPage)} navigation args should be passed a {nameof(ProviderSettingsViewModel)}"); } + + private async void RankButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + await FallbackRankerDialog.ShowAsync(); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml index 88b23044d5..e01f26b571 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml @@ -11,24 +11,215 @@ xmlns:local="using:Microsoft.CmdPal.UI.Settings" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" mc:Ignorable="d"> + <Page.Resources> + <ResourceDictionary> + <ResourceDictionary.ThemeDictionaries> + <!-- ControlFillColorQuarternaryBrush does not exist (yet) in WinUI, only in Windows Visual Library (Figma) --> + <ResourceDictionary x:Key="Default"> + <LinearGradientBrush x:Key="CardGradient2Brush" StartPoint="0,0" EndPoint="0.5, 1"> + <GradientStop Offset="0" Color="#38C8AEC4" /> + <GradientStop Offset="1" Color="#383286EE" /> + </LinearGradientBrush> + <ImageSource x:Key="StoreLogo">ms-appx:///Assets/StoreLogo.dark.svg</ImageSource> + </ResourceDictionary> + <ResourceDictionary x:Key="Light"> + <LinearGradientBrush x:Key="CardGradient2Brush" StartPoint="0,0" EndPoint="1, 1"> + <!--<GradientStop Offset="0" Color="#E6F0FC" /> + <GradientStop Offset="0.4" Color="#E6F0FC" /> + <GradientStop Offset="1" Color="#F0F0F7" />--> + <GradientStop Offset="0.0" Color="#FFF6F9FF" /> + <!-- Light cool white --> + <GradientStop Offset="0.4" Color="#FFEFF5FF" /> + <!-- Hinted lavender/blue --> + <GradientStop Offset="0.7" Color="#FFF7FAFD" /> + <!-- Very soft neutral white --> + <GradientStop Offset="1.0" Color="#FFF5F8FA" /> + <!-- Slight bluish-gray tint --> + <!-- Faint peach glow --> + <!-- Soft peach --> + </LinearGradientBrush> + <ImageSource x:Key="StoreLogo">ms-appx:///Assets/StoreLogo.light.svg</ImageSource> + </ResourceDictionary> + <ResourceDictionary x:Key="HighContrast"> + <SolidColorBrush x:Key="CardGradient2Brush" Color="Transparent" /> + <SolidColorBrush x:Key="CardGradient1Brush" Color="Transparent" /> + <ImageSource x:Key="StoreLogo">ms-appx:///Assets/StoreLogo.dark.svg</ImageSource> + </ResourceDictionary> + </ResourceDictionary.ThemeDictionaries> + <converters:BoolNegationConverter x:Key="InvertedBoolConverter" /> + <converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> + </ResourceDictionary> + </Page.Resources> + <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - <ScrollViewer Grid.Row="1"> + + <ScrollViewer x:Name="RootScrollViewer" Grid.Row="1"> <Grid Padding="16"> <StackPanel MaxWidth="1000" HorizontalAlignment="Stretch" Spacing="{StaticResource SettingsCardSpacing}"> + <!-- Banner --> + <Grid + Padding="16" + Background="{ThemeResource CardGradient2Brush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <StackPanel x:Name="BannerDescriptionPanel" Orientation="Vertical"> + <TextBlock x:Uid="Settings_ExtensionsPage_Banner_Header" FontWeight="SemiBold" /> + <TextBlock + x:Uid="Settings_ExtensionsPage_Banner_Description" + Grid.Row="1" + MaxWidth="520" + HorizontalAlignment="Left" + TextWrapping="Wrap" /> + <HyperlinkButton + x:Uid="Settings_ExtensionsPage_Banner_Hyperlink" + Padding="0" + Content="Learn how to create your own extensions" + NavigateUri="https://learn.microsoft.com/windows/powertoys/command-palette/overview" /> + </StackPanel> + <StackPanel + x:Name="ButtonPanel" + Grid.Column="1" + Orientation="Horizontal" + Spacing="8"> + <Button x:Uid="Settings_ExtensionsPage_FindExtensions_MicrosoftStore" Command="{x:Bind viewModel.Extensions.OpenStoreWithExtensionCommand}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <Viewbox Width="16"> + <Image AutomationProperties.AccessibilityView="Raw" Source="{ThemeResource StoreLogo}" /> + </Viewbox> + <TextBlock Text="Microsoft Store" /> + </StackPanel> + </Button> + <!--<Button x:Uid="Settings_ExtensionsPage_FindExtensions_WinGet" Command="{x:Bind viewModel.Extensions.OpenStoreWithExtensionCommand}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <Viewbox Width="16"> + <Image AutomationProperties.AccessibilityView="Raw" Source="ms-appx:///Assets/WinGet.svg" /> + </Viewbox> + <TextBlock Text="WinGet" /> + </StackPanel> + </Button>--> + </StackPanel> + </Grid> - <ItemsRepeater ItemsSource="{x:Bind viewModel.CommandProviders, Mode=OneWay}" Layout="{StaticResource VerticalStackLayout}"> + <!-- Search --> + <Grid Padding="0,24,0,8" ColumnSpacing="8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" MaxWidth="320" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <AutoSuggestBox + x:Name="SearchBox" + x:Uid="Settings_ExtensionsPage_SearchBox_Placeholder" + HorizontalAlignment="Stretch" + Text="{x:Bind viewModel.Extensions.SearchText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"> + <AutoSuggestBox.QueryIcon> + <SymbolIcon Symbol="Find" /> + </AutoSuggestBox.QueryIcon> + <AutoSuggestBox.KeyboardAccelerators> + <KeyboardAccelerator + Key="F" + Invoked="OnFindInvoked" + Modifiers="Control" /> + </AutoSuggestBox.KeyboardAccelerators> + </AutoSuggestBox> + + <StackPanel + Grid.Column="1" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Orientation="Horizontal" + Spacing="8"> + <TextBlock + x:Name="ItemCounterTextBlock" + VerticalAlignment="Center" + Style="{StaticResource BodyTextBlockStyle}" + Text="{x:Bind viewModel.Extensions.ItemCounterText, Mode=OneWay}" /> + <StackPanel + VerticalAlignment="Center" + Orientation="Horizontal" + Spacing="8" + Visibility="{x:Bind viewModel.Extensions.ShowManualReloadOverlay, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <ProgressRing + Width="16" + Height="16" + IsActive="True" /> + <TextBlock + x:Uid="Settings_ExtensionsPage_Reloading_Text" + VerticalAlignment="Center" + Style="{StaticResource BodyTextBlockStyle}" /> + </StackPanel> + <Button + x:Name="MoreButton" + x:Uid="Settings_ExtensionsPage_More_Button" + Style="{StaticResource SubtleButtonStyle}"> + <Button.Flyout> + <MenuFlyout Placement="BottomEdgeAlignedRight"> + <MenuFlyoutItem x:Uid="Settings_ExtensionsPage_More_Reload_MenuFlyoutItem" Command="{x:Bind viewModel.Extensions.ReloadExtensionsCommand}"> + <MenuFlyoutItem.Icon> + <FontIcon Glyph="" /> + </MenuFlyoutItem.Icon> + </MenuFlyoutItem> + <MenuFlyoutSeparator /> + <MenuFlyoutItem x:Uid="Settings_ExtensionsPage_More_ReorderFallbacks_MenuFlyoutItem" Click="MenuFlyoutItem_OnClick"> + <MenuFlyoutItem.Icon> + <FontIcon Glyph="" /> + </MenuFlyoutItem.Icon> + </MenuFlyoutItem> + </MenuFlyout> + </Button.Flyout> + <FontIcon + AutomationProperties.AccessibilityView="Raw" + FontSize="16" + Glyph="" /> + </Button> + </StackPanel> + </Grid> + + <!-- Empty state when no results match the current search --> + <Grid + x:Name="NoResultsPanel" + Padding="48" + x:Load="{x:Bind viewModel.Extensions.ShowNoResultsPanel, Mode=OneWay}" + CornerRadius="4"> + <StackPanel + HorizontalAlignment="Center" + VerticalAlignment="Center" + Spacing="8"> + <TextBlock + x:Uid="Settings_ExtensionsPage_NoResults_Primary" + Style="{StaticResource BodyStrongTextBlockStyle}" + TextAlignment="Center" /> + <TextBlock + x:Uid="Settings_ExtensionsPage_NoResults_Secondary" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextAlignment="Center" /> + </StackPanel> + </Grid> + <ItemsRepeater + x:Name="ProvidersRepeater" + x:Load="{x:Bind viewModel.Extensions.HasResults, Mode=OneWay}" + ItemsSource="{x:Bind viewModel.Extensions.FilteredProviders, Mode=OneWay}" + Layout="{StaticResource VerticalStackLayout}"> <ItemsRepeater.ItemTemplate> - <DataTemplate x:DataType="viewmodels:ProviderSettingsViewModel"> + <DataTemplate x:DataType="viewModels:ProviderSettingsViewModel"> <controls:SettingsCard Click="SettingsCard_Click" DataContext="{x:Bind}" @@ -38,18 +229,30 @@ <controls:SettingsCard.HeaderIcon> <cpcontrols:ContentIcon> <cpcontrols:ContentIcon.Content> - <cpcontrols:IconBox - Width="20" - Height="20" + <controls:SwitchPresenter AutomationProperties.AccessibilityView="Raw" - SourceKey="{x:Bind Icon, Mode=OneWay}" - SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested}" /> + TargetType="x:Boolean" + Value="{x:Bind Icon.IsSet, FallbackValue=x:False, Mode=OneWay}"> + <controls:Case Value="True"> + <cpcontrols:IconBox + Width="20" + Height="20" + AutomationProperties.AccessibilityView="Raw" + SourceKey="{x:Bind Icon, Mode=OneWay}" + SourceRequested="{x:Bind helpers:IconCacheProvider.SourceRequested20}" /> + </controls:Case> + <controls:Case Value="False"> + <Image + Width="20" + Height="20" + AutomationProperties.AccessibilityView="Raw" + Source="ms-appx:///Assets/Icons/ExtensionIconPlaceholder.png" /> + </controls:Case> + </controls:SwitchPresenter> </cpcontrols:ContentIcon.Content> </cpcontrols:ContentIcon> </controls:SettingsCard.HeaderIcon> - <ToggleSwitch IsOn="{x:Bind IsEnabled, Mode=TwoWay}" /> - </controls:SettingsCard> </DataTemplate> </ItemsRepeater.ItemTemplate> @@ -57,5 +260,26 @@ </StackPanel> </Grid> </ScrollViewer> + <cpcontrols:FallbackRankerDialog x:Name="FallbackRankerDialog" /> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="LayoutVisualStates"> + <VisualState x:Name="WideLayout"> + <VisualState.StateTriggers> + <AdaptiveTrigger MinWindowWidth="720" /> + </VisualState.StateTriggers> + </VisualState> + <VisualState x:Name="NarrowLayout"> + <VisualState.StateTriggers> + <AdaptiveTrigger MinWindowWidth="0" /> + </VisualState.StateTriggers> + <VisualState.Setters> + <Setter Target="ButtonPanel.(Grid.Row)" Value="1" /> + <Setter Target="BannerDescriptionPanel.(Grid.ColumnSpan)" Value="2" /> + <Setter Target="ButtonPanel.(Grid.Column)" Value="0" /> + <Setter Target="ButtonPanel.Margin" Value="0,12,0,0" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> </Grid> </Page> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs index e164638ebb..f19be9f0cf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs @@ -4,10 +4,13 @@ using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI.Controls; +using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; namespace Microsoft.CmdPal.UI.Settings; @@ -22,7 +25,9 @@ public sealed partial class ExtensionsPage : Page this.InitializeComponent(); var settings = App.Current.Services.GetService<SettingsModel>()!; - viewModel = new SettingsViewModel(settings, App.Current.Services, _mainTaskScheduler); + var topLevelCommandManager = App.Current.Services.GetService<TopLevelCommandManager>()!; + var themeService = App.Current.Services.GetService<IThemeService>()!; + viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); } private void SettingsCard_Click(object sender, RoutedEventArgs e) @@ -35,4 +40,22 @@ public sealed partial class ExtensionsPage : Page } } } + + private void OnFindInvoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + SearchBox?.Focus(FocusState.Keyboard); + args.Handled = true; + } + + private async void MenuFlyoutItem_OnClick(object sender, RoutedEventArgs e) + { + try + { + await FallbackRankerDialog!.ShowAsync(); + } + catch (Exception ex) + { + Logger.LogError("Error when showing FallbackRankerDialog", ex); + } + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml index e8a36e618a..53994b345f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml @@ -10,7 +10,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ptControls="using:Microsoft.CmdPal.UI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewmodels="using:Microsoft.CmdPal.UI.ViewModels" + xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" mc:Ignorable="d"> <Grid> <Grid.RowDefinitions> @@ -34,13 +34,35 @@ <RepositionThemeTransition IsStaggeringEnabled="False" /> </StackPanel.ChildrenTransitions>--> - <TextBlock x:Uid="ActivationSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" /> + <!-- 'Activation' section --> - <controls:SettingsCard x:Uid="Settings_GeneralPage_ActivationKey_SettingsExpander" HeaderIcon="{ui:FontIcon Glyph=}"> + <TextBlock x:Uid="ActivationSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" /> + <controls:SettingsExpander + x:Uid="Settings_GeneralPage_ActivationKey_SettingsExpander" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> <ptControls:ShortcutControl HotkeySettings="{x:Bind viewModel.Hotkey, Mode=TwoWay}" /> - </controls:SettingsCard> - <controls:SettingsCard x:Uid="Settings_GeneralPage_GoHome_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> - <ToggleSwitch IsOn="{x:Bind viewModel.HotkeyGoesHome, Mode=TwoWay}" /> + <controls:SettingsExpander.Items> + <controls:SettingsCard ContentAlignment="Left"> + <ptControls:CheckBoxWithDescriptionControl x:Uid="Settings_GeneralPage_LowLevelHook_SettingsCard" IsChecked="{x:Bind viewModel.UseLowLevelGlobalHotkey, Mode=TwoWay}" /> + </controls:SettingsCard> + <controls:SettingsCard ContentAlignment="Left"> + <ptControls:CheckBoxWithDescriptionControl x:Uid="Settings_GeneralPage_IgnoreShortcutWhenFullscreen_SettingsCard" IsChecked="{x:Bind viewModel.IgnoreShortcutWhenFullscreen, Mode=TwoWay}" /> + </controls:SettingsCard> + </controls:SettingsExpander.Items> + </controls:SettingsExpander> + <controls:SettingsCard x:Uid="Settings_GeneralPage_AutoGoHome_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind viewModel.AutoGoBackIntervalIndex, Mode=TwoWay}"> + <ComboBoxItem x:Uid="Settings_GeneralPage_AutoGoHome_Item_Never" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_AutoGoHome_Item_Immediately" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_AutoGoHome_Item_After10Seconds" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_AutoGoHome_Item_After20Seconds" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_AutoGoHome_Item_After30Seconds" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_AutoGoHome_Item_After60Seconds" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_AutoGoHome_Item_After90Seconds" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_AutoGoHome_Item_After120Seconds" /> + <ComboBoxItem x:Uid="Settings_GeneralPage_AutoGoHome_Item_After180Seconds" /> + </ComboBox> </controls:SettingsCard> <controls:SettingsCard x:Uid="Settings_GeneralPage_HighlightSearch_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch IsOn="{x:Bind viewModel.HighlightSearchOnActivate, Mode=TwoWay}" /> @@ -51,24 +73,28 @@ <ComboBoxItem x:Uid="Run_Radio_Position_Primary_Monitor" /> <ComboBoxItem x:Uid="Run_Radio_Position_Focus" /> <ComboBoxItem x:Uid="Run_Radio_Position_In_Place" /> + <ComboBoxItem x:Uid="Run_Radio_Position_LastPosition" /> </ComboBox> </controls:SettingsCard> + <!-- 'Behavior' section --> + <TextBlock x:Uid="BehaviorSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" /> - <controls:SettingsCard x:Uid="Settings_GeneralPage_ShowAppDetails_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> - <ToggleSwitch IsOn="{x:Bind viewModel.ShowAppDetails, Mode=TwoWay}" /> + <controls:SettingsCard x:Uid="Settings_GeneralPage_ShowSystemTrayIcon_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind viewModel.ShowSystemTrayIcon, Mode=TwoWay}" /> </controls:SettingsCard> - <controls:SettingsCard x:Uid="Settings_GeneralPage_BackspaceGoesBack_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> - <ToggleSwitch IsOn="{x:Bind viewModel.BackspaceGoesBack, Mode=TwoWay}" /> + <!-- 'For Developers' section --> + + <TextBlock x:Uid="ForDevelopersSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" /> + + <controls:SettingsCard x:Uid="Settings_GeneralPage_AllowExternalReload_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind viewModel.AllowExternalReload, Mode=TwoWay}" /> </controls:SettingsCard> - <controls:SettingsCard x:Uid="Settings_GeneralPage_SingleClickActivation_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> - <ToggleSwitch IsOn="{x:Bind viewModel.SingleClickActivates, Mode=TwoWay}" /> - </controls:SettingsCard> + <!-- 'About' section --> - <!-- Example 'About' section --> <TextBlock x:Uid="AboutSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" /> <controls:SettingsExpander x:Uid="Settings_GeneralPage_About_SettingsExpander" HeaderIcon="{ui:BitmapIcon Source=ms-appx:///Assets/StoreLogo.png}"> @@ -80,7 +106,7 @@ <controls:SettingsCard HorizontalContentAlignment="Left" ContentAlignment="Left"> <StackPanel Margin="-12,0,0,0" Orientation="Vertical"> <HyperlinkButton x:Uid="Settings_GeneralPage_About_GithubLink_Hyperlink" NavigateUri="https://go.microsoft.com/fwlink/?linkid=2310837" /> - <HyperlinkButton x:Uid="Settings_GeneralPage_About_SDKDocs_Hyperlink" NavigateUri="https://go.microsoft.com/fwlink/?linkid=2310639" /> + <HyperlinkButton x:Uid="Settings_GeneralPage_About_SDKDocs_Hyperlink" NavigateUri="https://aka.ms/cmdpalextensions-devdocs" /> </StackPanel> </controls:SettingsCard> </controls:SettingsExpander.Items> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs index d732600c4e..cb9157021f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs @@ -2,10 +2,13 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Globalization; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.UI.Helpers; using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml.Controls; -using Windows.ApplicationModel; namespace Microsoft.CmdPal.UI.Settings; @@ -14,21 +17,26 @@ public sealed partial class GeneralPage : Page private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); private readonly SettingsViewModel? viewModel; + private readonly IApplicationInfoService _appInfoService; public GeneralPage() { this.InitializeComponent(); var settings = App.Current.Services.GetService<SettingsModel>()!; - viewModel = new SettingsViewModel(settings, App.Current.Services, _mainTaskScheduler); + var topLevelCommandManager = App.Current.Services.GetService<TopLevelCommandManager>()!; + var themeService = App.Current.Services.GetService<IThemeService>()!; + _appInfoService = App.Current.Services.GetRequiredService<IApplicationInfoService>(); + viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); } public string ApplicationVersion { get { - var version = Package.Current.Id.Version; - return $"Version {version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; + var versionNo = ResourceLoaderInstance.GetString("Settings_GeneralPage_VersionNo"); + var version = _appInfoService.AppVersion; + return string.Format(CultureInfo.CurrentCulture, versionNo, version); } } } 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 <jane.doe@contoso.com> + 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..bfb3768db6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.xaml @@ -0,0 +1,82 @@ +<?xml version="1.0" encoding="utf-8" ?> +<Page + x:Class="Microsoft.CmdPal.UI.Settings.InternalPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:CommunityToolkit.WinUI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:ui="using:CommunityToolkit.WinUI" + mc:Ignorable="d"> + + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <ScrollViewer Grid.Row="1"> + <Grid Padding="16"> + <StackPanel + MaxWidth="1000" + HorizontalAlignment="Stretch" + Spacing="{StaticResource SettingsCardSpacing}"> + + <TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="Tools on this page are for internal use only. This page is not visible in CI builds." /> + + <!-- Exception Handling Section --> + <TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Exception Handling" /> + <controls:SettingsExpander + Description="Actions for testing global exception handling from the application" + Header="Throw exceptions" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <controls:SettingsExpander.Items> + <controls:SettingsCard Header="Throw an unhandled exception from the UI thread"> + <Button Click="ThrowPlainMainThreadException_Click" Content="Throw" /> + </controls:SettingsCard> + <controls:SettingsCard Header="Throw an unhandled exception from the UI thread (with PII)"> + <Button Click="ThrowPlainMainThreadExceptionPii_Click" Content="Throw" /> + </controls:SettingsCard> + <controls:SettingsCard Description="Throw with delay, when the task is collected by the GC" Header="Throw unobserved exception from a task"> + <Button Click="ThrowExceptionInUnobservedTask_Click" Content="Throw" /> + </controls:SettingsCard> + </controls:SettingsExpander.Items> + </controls:SettingsExpander> + + <!-- Diagnostics Section --> + <TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Diagnostics" /> + <controls:SettingsCard + x:Name="LogsSettingsCard" + Header="Logs folder" + HeaderIcon="{ui:FontIcon Glyph=}"> + <Button Click="OpenLogsCardClicked" Content="Open folder" /> + </controls:SettingsCard> + <controls:SettingsCard + x:Name="CurrentLogFileSettingsCard" + Header="Current log file" + HeaderIcon="{ui:FontIcon Glyph=}"> + <Button Click="OpenCurrentLogCardClicked" Content="Open log" /> + </controls:SettingsCard> + <controls:SettingsCard + x:Name="ToggleDevRibbonVisibilitySettingsCard" + Description="This is only temporary and state is not saved" + Header="Toggle dev ribbon visibility" + HeaderIcon="{ui:FontIcon Glyph=}"> + <Button Click="ToggleDevRibbonClicked" Content="Toggle dev ribbon" /> + </controls:SettingsCard> + + <!-- Data Section --> + <TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Data and Files" /> + <controls:SettingsCard + x:Name="ConfigurationFolderSettingsCard" + Header="Configuration folder" + HeaderIcon="{ui:FontIcon Glyph=}"> + <Button Click="OpenConfigFolderCardClick" Content="Open folder" /> + </controls:SettingsCard> + + + </StackPanel> + </Grid> + </ScrollViewer> + </Grid> +</Page> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.xaml.cs new file mode 100644 index 0000000000..627f3c5574 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.xaml.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CommunityToolkit.Mvvm.Messaging; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.UI.Messages; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml; +using Windows.System; +using Page = Microsoft.UI.Xaml.Controls.Page; + +namespace Microsoft.CmdPal.UI.Settings; + +/// <summary> +/// An empty page that can be used on its own or navigated to within a Frame. +/// </summary> +public sealed partial class InternalPage : Page +{ + private readonly IApplicationInfoService _appInfoService; + + public InternalPage() + { + InitializeComponent(); + + _appInfoService = App.Current.Services.GetRequiredService<IApplicationInfoService>(); + } + + private void ThrowPlainMainThreadException_Click(object sender, RoutedEventArgs e) + { + Logger.LogDebug("Throwing test exception from the UI thread"); + throw new NotImplementedException("Test exception; thrown from the UI thread"); + } + + private void ThrowExceptionInUnobservedTask_Click(object sender, RoutedEventArgs e) + { + Logger.LogDebug("Starting a task that will throw test exception"); + Task.Run(() => + { + Logger.LogDebug("Throwing test exception from a task"); + throw new InvalidOperationException("Test exception; thrown from a task"); + }); + } + + private void ThrowPlainMainThreadExceptionPii_Click(object sender, RoutedEventArgs e) + { + Logger.LogDebug("Throwing test exception from the UI thread (PII)"); + throw new InvalidOperationException(SampleData.ExceptionMessageWithPii); + } + + private async void OpenLogsCardClicked(object sender, RoutedEventArgs e) + { + try + { + var logFolderPath = _appInfoService.LogDirectory; + if (Directory.Exists(logFolderPath)) + { + await Launcher.LaunchFolderPathAsync(logFolderPath); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to open directory in Explorer", ex); + } + } + + private async void OpenCurrentLogCardClicked(object sender, RoutedEventArgs e) + { + try + { + var logPath = Logger.CurrentLogFile; + if (File.Exists(logPath)) + { + await Launcher.LaunchUriAsync(new Uri(logPath)); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to open log file", ex); + } + } + + private async void OpenConfigFolderCardClick(object sender, RoutedEventArgs e) + { + try + { + var directory = _appInfoService.ConfigDirectory; + if (Directory.Exists(directory)) + { + await Launcher.LaunchFolderPathAsync(directory); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to open directory in Explorer", ex); + } + } + + private void ToggleDevRibbonClicked(object sender, RoutedEventArgs e) + { + WeakReferenceMessenger.Default.Send(new ToggleDevRibbonMessage()); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml index d9a81dc2ef..e47ae1dc92 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml @@ -1,103 +1,110 @@ <?xml version="1.0" encoding="utf-8" ?> -<Window +<winuiex:WindowEx x:Class="Microsoft.CmdPal.UI.Settings.SettingsWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:Microsoft.CmdPal.UI.Settings" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:text="using:Windows.UI.Text" xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:winuiex="using:WinUIEx" Title="SettingsWindow" + Width="1280" + Height="720" + MinWidth="480" + MinHeight="480" Activated="Window_Activated" Closed="Window_Closed" mc:Ignorable="d"> - <Window.SystemBackdrop> + <winuiex:WindowEx.SystemBackdrop> <MicaBackdrop /> - </Window.SystemBackdrop> - <Grid> + </winuiex:WindowEx.SystemBackdrop> + <Grid x:Name="RootElement"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - <!-- TO DO: Replace this with WinUI TitleBar once that ships. --> - <Button - x:Name="PaneToggleBtn" - Width="48" - HorizontalAlignment="Left" - VerticalAlignment="Center" - Click="PaneToggleBtn_Click" - Style="{StaticResource PaneToggleButtonStyle}" /> - <StackPanel + <TitleBar x:Name="AppTitleBar" - Grid.Row="0" - Height="48" - Orientation="Horizontal"> - <Image - Width="16" - Height="16" - Source="ms-appx:///Assets/icon.svg" /> - <TextBlock - Margin="12,0,0,0" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}" - Text="Command Palette Settings" /> - </StackPanel> + BackRequested="TitleBar_BackRequested" + IsBackButtonVisible="{x:Bind NavFrame.CanGoBack, Mode=OneWay}" + IsTabStop="False" + PaneToggleRequested="AppTitleBar_PaneToggleRequested"> + <!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource --> + <TitleBar.LeftHeader> + <ImageIcon + x:Name="WorkAroundIcon" + Height="16" + Margin="16,0,0,0" + Source="ms-appx:///Assets/icon.svg" /> + </TitleBar.LeftHeader> + </TitleBar> <NavigationView x:Name="NavView" Grid.Row="1" + CompactModeThresholdWidth="1007" DisplayModeChanged="NavView_DisplayModeChanged" + ExpandedModeThresholdWidth="1007" IsBackButtonVisible="Collapsed" + IsPaneToggleButtonVisible="False" IsSettingsVisible="False" ItemInvoked="NavView_ItemInvoked" - Loaded="NavView_Loaded" - OpenPaneLength="200"> + Loaded="NavView_Loaded"> <NavigationView.Resources> <SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" /> <SolidColorBrush x:Key="NavigationViewContentGridBorderBrush" Color="Transparent" /> <Thickness x:Key="NavigationViewHeaderMargin">15,0,0,0</Thickness> </NavigationView.Resources> - <NavigationView.MenuItems> <NavigationViewItem + x:Name="GeneralPageNavItem" x:Uid="Settings_GeneralPage_NavigationViewItem_General" Icon="{ui:FontIcon Glyph=}" Tag="General" /> <NavigationViewItem + x:Name="AppearancePageNavItem" + x:Uid="Settings_GeneralPage_NavigationViewItem_Appearance" + Icon="{ui:FontIcon Glyph=}" + Tag="Appearance" /> + <NavigationViewItem + x:Name="ExtensionPageNavItem" x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions" Icon="{ui:FontIcon Glyph=}" Tag="Extensions" /> + <!-- "Internal Tools" page item is added dynamically from code --> </NavigationView.MenuItems> - <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - - <BreadcrumbBar - x:Name="NavigationBreadcrumbBar" - Grid.Row="0" - MaxWidth="1000" - Margin="16,0,0,0" - ItemClicked="NavigationBreadcrumbBar_ItemClicked" - ItemsSource="{x:Bind BreadCrumbs, Mode=OneWay}"> - <BreadcrumbBar.ItemTemplate> - <DataTemplate x:DataType="local:Crumb"> - <TextBlock Text="{x:Bind Label, Mode=OneWay}" /> - </DataTemplate> - </BreadcrumbBar.ItemTemplate> - <BreadcrumbBar.Resources> - <ResourceDictionary> - <x:Double x:Key="BreadcrumbBarItemThemeFontSize">28</x:Double> - <Thickness x:Key="BreadcrumbBarChevronPadding">7,4,8,0</Thickness> - <FontWeight x:Key="BreadcrumbBarItemFontWeight">SemiBold</FontWeight> - <x:Double x:Key="BreadcrumbBarChevronFontSize">16</x:Double> - </ResourceDictionary> - </BreadcrumbBar.Resources> - </BreadcrumbBar> - - <Frame x:Name="NavFrame" Grid.Row="1" /> + <Grid Padding="16,0"> + <BreadcrumbBar + x:Name="NavigationBreadcrumbBar" + MaxWidth="1000" + ItemClicked="NavigationBreadcrumbBar_ItemClicked" + ItemsSource="{x:Bind BreadCrumbs, Mode=OneWay}"> + <BreadcrumbBar.ItemTemplate> + <DataTemplate x:DataType="local:Crumb"> + <TextBlock Text="{x:Bind Label}" /> + </DataTemplate> + </BreadcrumbBar.ItemTemplate> + <BreadcrumbBar.Resources> + <ResourceDictionary> + <x:Double x:Key="BreadcrumbBarItemThemeFontSize">28</x:Double> + <Thickness x:Key="BreadcrumbBarChevronPadding">7,4,8,0</Thickness> + <text:FontWeight x:Key="BreadcrumbBarItemFontWeight">SemiBold</text:FontWeight> + <x:Double x:Key="BreadcrumbBarChevronFontSize">16</x:Double> + </ResourceDictionary> + </BreadcrumbBar.Resources> + </BreadcrumbBar> + </Grid> + <Frame + x:Name="NavFrame" + Grid.Row="1" + Navigated="NavFrame_OnNavigated" /> </Grid> </NavigationView> </Grid> -</Window> +</winuiex:WindowEx> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs index f4294983d5..a24f4c4694 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/SettingsWindow.xaml.cs @@ -4,40 +4,106 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.Messaging; +using ManagedCommon; using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.UI.Input; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Windows.Graphics; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Navigation; +using Windows.System; +using Windows.UI.Core; +using WinUIEx; using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; +using TitleBar = Microsoft.UI.Xaml.Controls.TitleBar; namespace Microsoft.CmdPal.UI.Settings; -public sealed partial class SettingsWindow : Window, +public sealed partial class SettingsWindow : WindowEx, + IDisposable, IRecipient<NavigateToExtensionSettingsMessage>, IRecipient<QuitMessage> { + private readonly LocalKeyboardListener _localKeyboardListener; + + private readonly NavigationViewItem? _internalNavItem; + public ObservableCollection<Crumb> BreadCrumbs { get; } = []; + // Gets or sets optional action invoked after NavigationView is loaded. + public Action NavigationViewLoaded { get; set; } = () => { }; + public SettingsWindow() { this.InitializeComponent(); this.ExtendsContentIntoTitleBar = true; this.SetIcon(); - this.AppWindow.Title = RS_.GetString("SettingsWindowTitle"); + var title = RS_.GetString("SettingsWindowTitle"); + this.AppWindow.Title = title; this.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall; + this.AppTitleBar.Title = title; PositionCentered(); WeakReferenceMessenger.Default.Register<NavigateToExtensionSettingsMessage>(this); WeakReferenceMessenger.Default.Register<QuitMessage>(this); + + _localKeyboardListener = new LocalKeyboardListener(); + _localKeyboardListener.KeyPressed += LocalKeyboardListener_OnKeyPressed; + _localKeyboardListener.Start(); + Closed += SettingsWindow_Closed; + RootElement.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(RootElement_OnPointerPressed), true); + + if (!BuildInfo.IsCiBuild) + { + _internalNavItem = new NavigationViewItem + { + Content = "Internal Tools", + Icon = new FontIcon { Glyph = "\uEC7A" }, + Tag = "Internal", + }; + NavView.MenuItems.Add(_internalNavItem); + } + else + { + _internalNavItem = null; + } + + Navigate("General"); } + private void SettingsWindow_Closed(object sender, WindowEventArgs args) + { + Dispose(); + } + + // Handles NavigationView loaded event. + // Sets up initial navigation and accessibility notifications. private void NavView_Loaded(object sender, RoutedEventArgs e) { - NavView.SelectedItem = NavView.MenuItems[0]; - Navigate("General"); + // Delay necessary to ensure NavigationView visual state can match navigation + Task.Delay(500).ContinueWith(_ => this.NavigationViewLoaded?.Invoke(), TaskScheduler.FromCurrentSynchronizationContext()); + + if (sender is NavigationView navigationView) + { + // Register for pane open/close changes to announce to screen readers + navigationView.RegisterPropertyChangedCallback(NavigationView.IsPaneOpenProperty, AnnounceNavigationPaneStateChanged); + } + } + + // Announces navigation pane open/close state to screen readers for accessibility. + private void AnnounceNavigationPaneStateChanged(DependencyObject sender, DependencyProperty dp) + { + if (sender is NavigationView navigationView) + { + UIHelper.AnnounceActionForAccessibility( + ue: (UIElement)sender, + (sender as NavigationView)?.IsPaneOpen == true ? RS_.GetString("NavigationPaneOpened") : RS_.GetString("NavigationPaneClosed"), + "NavigationViewPaneIsOpenChangeNotificationId"); + } } private void NavView_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args) @@ -46,18 +112,36 @@ public sealed partial class SettingsWindow : Window, Navigate((selectedItem.Tag as string)!); } - private void Navigate(string page) + internal void Navigate(string page) { - var pageType = page switch + Type? pageType; + switch (page) { - "General" => typeof(GeneralPage), - "Extensions" => typeof(ExtensionsPage), - _ => null, - }; + case "General": + pageType = typeof(GeneralPage); + break; + case "Appearance": + pageType = typeof(AppearancePage); + break; + case "Extensions": + pageType = typeof(ExtensionsPage); + break; + case "Internal": + pageType = typeof(InternalPage); + break; + case "": + // intentional no-op: empty tag means no navigation + pageType = null; + break; + default: + // unknown page, no-op and log + pageType = null; + Logger.LogError($"Unknown settings page tag '{page}'"); + break; + } + if (pageType is not null) { - BreadCrumbs.Clear(); - BreadCrumbs.Add(new(page, page)); NavFrame.Navigate(pageType); } } @@ -65,12 +149,10 @@ public sealed partial class SettingsWindow : Window, private void Navigate(ProviderSettingsViewModel extension) { NavFrame.Navigate(typeof(ExtensionPage), extension); - BreadCrumbs.Add(new(extension.DisplayName, string.Empty)); } private void PositionCentered() { - AppWindow.Resize(new SizeInt32 { Width = 1280, Height = 720 }); var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest); if (displayArea is not null) { @@ -97,34 +179,29 @@ public sealed partial class SettingsWindow : Window, } } - private void Window_Activated(object sender, WindowActivatedEventArgs args) + private void Window_Activated(object sender, Microsoft.UI.Xaml.WindowActivatedEventArgs args) { - WeakReferenceMessenger.Default.Send<WindowActivatedEventArgs>(args); + WeakReferenceMessenger.Default.Send<Microsoft.UI.Xaml.WindowActivatedEventArgs>(args); } private void Window_Closed(object sender, WindowEventArgs args) { WeakReferenceMessenger.Default.Send<SettingsWindowClosedMessage>(); - } - private void PaneToggleBtn_Click(object sender, RoutedEventArgs e) - { - NavView.IsPaneOpen = !NavView.IsPaneOpen; + WeakReferenceMessenger.Default.UnregisterAll(this); } private void NavView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args) { - if (args.DisplayMode == NavigationViewDisplayMode.Compact || args.DisplayMode == NavigationViewDisplayMode.Minimal) + if (args.DisplayMode is NavigationViewDisplayMode.Compact or NavigationViewDisplayMode.Minimal) { - PaneToggleBtn.Visibility = Visibility.Visible; - NavView.IsPaneToggleButtonVisible = false; - AppTitleBar.Margin = new Thickness(48, 0, 0, 0); + AppTitleBar.IsPaneToggleButtonVisible = true; + WorkAroundIcon.Margin = new Thickness(8, 0, 16, 0); // Required for workaround, see XAML comment } else { - PaneToggleBtn.Visibility = Visibility.Collapsed; - NavView.IsPaneToggleButtonVisible = true; - AppTitleBar.Margin = new Thickness(16, 0, 0, 0); + AppTitleBar.IsPaneToggleButtonVisible = false; + WorkAroundIcon.Margin = new Thickness(16, 0, 8, 0); // Required for workaround, see XAML comment } } @@ -133,6 +210,110 @@ public sealed partial class SettingsWindow : Window, // This might come in on a background thread DispatcherQueue.TryEnqueue(() => Close()); } + + private void AppTitleBar_PaneToggleRequested(TitleBar sender, object args) + { + NavView.IsPaneOpen = !NavView.IsPaneOpen; + } + + private void TryGoBack() + { + if (NavFrame.CanGoBack) + { + NavFrame.GoBack(); + } + } + + private void TitleBar_BackRequested(TitleBar sender, object args) + { + TryGoBack(); + } + + private void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e) + { + switch (e.Key) + { + case VirtualKey.GoBack: + case VirtualKey.XButton1: + TryGoBack(); + break; + + case VirtualKey.Left: + var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); + if (altPressed) + { + TryGoBack(); + } + + break; + } + } + + private void RootElement_OnPointerPressed(object sender, PointerRoutedEventArgs e) + { + try + { + if (e.Pointer.PointerDeviceType == PointerDeviceType.Mouse) + { + var ptrPt = e.GetCurrentPoint(RootElement); + if (ptrPt.Properties.IsXButton1Pressed) + { + TryGoBack(); + } + } + } + catch (Exception ex) + { + Logger.LogError("Error handling mouse button press event", ex); + } + } + + public void Dispose() + { + _localKeyboardListener?.Dispose(); + } + + private void NavFrame_OnNavigated(object sender, NavigationEventArgs e) + { + BreadCrumbs.Clear(); + + if (e.SourcePageType == typeof(GeneralPage)) + { + NavView.SelectedItem = GeneralPageNavItem; + var pageType = RS_.GetString("Settings_PageTitles_GeneralPage"); + BreadCrumbs.Add(new(pageType, pageType)); + } + else if (e.SourcePageType == typeof(AppearancePage)) + { + NavView.SelectedItem = AppearancePageNavItem; + var pageType = RS_.GetString("Settings_PageTitles_AppearancePage"); + BreadCrumbs.Add(new(pageType, pageType)); + } + else if (e.SourcePageType == typeof(ExtensionsPage)) + { + NavView.SelectedItem = ExtensionPageNavItem; + var pageType = RS_.GetString("Settings_PageTitles_ExtensionsPage"); + BreadCrumbs.Add(new(pageType, pageType)); + } + else if (e.SourcePageType == typeof(ExtensionPage) && e.Parameter is ProviderSettingsViewModel vm) + { + NavView.SelectedItem = ExtensionPageNavItem; + var extensionsPageType = RS_.GetString("Settings_PageTitles_ExtensionsPage"); + BreadCrumbs.Add(new(extensionsPageType, extensionsPageType)); + BreadCrumbs.Add(new(vm.DisplayName, vm)); + } + else if (e.SourcePageType == typeof(InternalPage) && _internalNavItem is not null) + { + NavView.SelectedItem = _internalNavItem; + var pageType = "Internal"; + BreadCrumbs.Add(new(pageType, pageType)); + } + else + { + BreadCrumbs.Add(new($"[{e.SourcePageType?.Name}]", string.Empty)); + Logger.LogError($"Unknown breadcrumb for page type '{e.SourcePageType}'"); + } + } } public readonly struct Crumb diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index 03e8c40eaa..6a196840f5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -241,6 +241,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Commands</value> <comment>A section header for information about the app</comment> </data> + <data name="ExtensionFallbackCommandsHeader.Text" xml:space="preserve"> + <value>Fallback commands</value> + <comment>A section header for information about the commands presented to the user when the search text doesn't exactly match the name of a command.</comment> + </data> <data name="ExtensionDisabledHeader.Text" xml:space="preserve"> <value>This extension is disabled</value> <comment>A header to inform the user that an extension is not currently active</comment> @@ -253,8 +257,8 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Disabled</value> <comment>Displayed when an extension is disabled</comment> </data> - <data name="ExtensionEnableCard.Header" xml:space="preserve"> - <value>Enable this extension</value> + <data name="ExtensionEnable.Text" xml:space="preserve"> + <value>Enable</value> <comment>Displayed on a toggle controlling the extension's enabled / disabled state</comment> </data> <data name="ExtensionEnableCard.Description" xml:space="preserve"> @@ -308,7 +312,13 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Alias</value> </data> <data name="Settings_ExtensionPage_Alias_SettingsCard.Description" xml:space="preserve"> - <value>Typing this alias will navigate to this command. Direct aliases navigate as soon as you type the alias. Indirect aliases navigate after typing a trailing space.</value> + <value>A short keyword used to navigate to this command.</value> + </data> + <data name="Settings_ExtensionPage_AliasActivation_SettingsCard.Header" xml:space="preserve"> + <value>Alias activation</value> + </data> + <data name="Settings_ExtensionPage_AliasActivation_SettingsCard.Description" xml:space="preserve"> + <value>Choose when the alias runs. Direct runs as soon as you type the alias. Indirect runs after a trailing space.</value> </data> <data name="Settings_ExtensionPage_Builtin_SettingsCard.Header" xml:space="preserve"> <value>Built-in</value> @@ -322,11 +332,17 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <data name="Settings_GeneralPage_ActivationKey_SettingsExpander.Description" xml:space="preserve"> <value>This key will open the Command Palette.</value> </data> - <data name="Settings_GeneralPage_GoHome_SettingsCard.Header" xml:space="preserve"> - <value>Go home when activated</value> + <data name="Settings_GeneralPage_LowLevelHook_SettingsCard.Header" xml:space="preserve"> + <value>Use low-level keyboard hook</value> </data> - <data name="Settings_GeneralPage_GoHome_SettingsCard.Description" xml:space="preserve"> - <value>Automatically opens the home page upon activation</value> + <data name="Settings_GeneralPage_LowLevelHook_SettingsCard.Description" xml:space="preserve"> + <value>Try this if there are issues with the shortcut (Command Palette might not get focus when triggered from an elevated window)</value> + </data> + <data name="Settings_GeneralPage_IgnoreShortcutWhenFullscreen_SettingsCard.Header" xml:space="preserve"> + <value>Ignore shortcut in fullscreen mode</value> + </data> + <data name="Settings_GeneralPage_IgnoreShortcutWhenFullscreen_SettingsCard.Description" xml:space="preserve"> + <value>Preventing disruption of the program running in fullscreen by unintentional activation of shortcut</value> </data> <data name="Settings_GeneralPage_HighlightSearch_SettingsCard.Header" xml:space="preserve"> <value>Highlight search on activate</value> @@ -356,7 +372,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Windows Command Palette</value> </data> <data name="Settings_GeneralPage_About_SettingsExpander.Description" xml:space="preserve"> - <value>© 2025. All rights reserved.</value> + <value>© 2026. All rights reserved.</value> </data> <data name="Settings_GeneralPage_About_GithubLink_Hyperlink.Content" xml:space="preserve"> <value>View GitHub repository</value> @@ -373,9 +389,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <data name="Settings_GeneralPage_NavigationViewItem_Extensions.Content" xml:space="preserve"> <value>Extensions</value> </data> - <data name="MoreCommandsButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>More commands</value> - </data> <data name="SettingsButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> <value>Open Command Palette settings</value> </data> @@ -385,4 +398,383 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <data name="BehaviorSettingsHeader.Text" xml:space="preserve"> <value>Behavior</value> </data> + <data name="ContextFilterBox.PlaceholderText" xml:space="preserve"> + <value>Search commands...</value> + </data> + <data name="Settings_GeneralPage_ShowSystemTrayIcon_SettingsCard.Header" xml:space="preserve"> + <value>Show system tray icon</value> + </data> + <data name="Settings_GeneralPage_ShowSystemTrayIcon_SettingsCard.Description" xml:space="preserve"> + <value>Choose if Command Palette is visible in the system tray</value> + </data> + <data name="Settings_GeneralPage_DisableAnimations_SettingsCard.Header" xml:space="preserve"> + <value>Disable animations</value> + </data> + <data name="Settings_GeneralPage_DisableAnimations_SettingsCard.Description" xml:space="preserve"> + <value>Disable animations when switching between pages</value> + </data> + <data name="BackButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Back</value> + </data> + <data name="BackButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve"> + <value>Back (Alt + Left arrow)</value> + </data> + <data name="MoreCommandsButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>More</value> + </data> + <data name="Run_Radio_Position_LastPosition.Content" xml:space="preserve"> + <value>Last position</value> + <comment>Reopen the window where it was last closed</comment> + </data> + <data name="TrayMenu_Settings" xml:space="preserve"> + <value>Settings</value> + </data> + <data name="TrayMenu_Close" xml:space="preserve"> + <value>Close</value> + <comment>Close as a verb, as in Close the application</comment> + </data> + <data name="Settings_ExtensionPage_Alias_DirectComboBox.Content" xml:space="preserve"> + <value>Direct</value> + </data> + <data name="Settings_ExtensionPage_Alias_InDirectComboBox.Content" xml:space="preserve"> + <value>Indirect</value> + </data> + <data name="Settings_ExtensionPage_Alias_PlaceholderText.PlaceholderText" xml:space="preserve"> + <value>Enter alias</value> + </data> + <data name="StatusMessagesButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Show status messages</value> + </data> + <data name="StatusMessagesButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve"> + <value>Show status messages</value> + </data> + <data name="NavigationPaneClosed" xml:space="preserve"> + <value>Navigation pane closed</value> + </data> + <data name="NavigationPaneOpened" xml:space="preserve"> + <value>Navigation page opened</value> + </data> + <data name="Settings_GeneralPage_AllowExternalReload_SettingsCard.Header" xml:space="preserve"> + <value>Enable external reload</value> + </data> + <data name="Settings_GeneralPage_AllowExternalReload_SettingsCard.Description" xml:space="preserve"> + <value>Trigger reload of the extension externally with the x-cmdpal://reload command</value> + </data> + <data name="ForDevelopersSettingsHeader.Text" xml:space="preserve"> + <value>For developers</value> + </data> + <data name="UntitledPageTitle" xml:space="preserve"> + <value>an untitled</value> + </data> + <data name="ScreenReader_Announcement_NavigatedToPage0" xml:space="preserve"> + <value>Navigated to {0} page</value> + </data> + <data name="SettingsButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve"> + <value>Settings (Ctrl+,)</value> + </data> + <data name="Settings_ExtensionsPage_NoResults_Primary.Text" xml:space="preserve"> + <value>No extensions found</value> + </data> + <data name="Settings_ExtensionsPage_NoResults_Secondary.Text" xml:space="preserve"> + <value>Try a different search term</value> + </data> + <data name="Settings_ExtensionsPage_More_Button.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve"> + <value>More options</value> + </data> + <data name="Settings_ExtensionsPage_More_Reload_MenuFlyoutItem.Text" xml:space="preserve"> + <value>Reload extensions</value> + </data> + <data name="Settings_ExtensionsPage_Reloading_Text.Text" xml:space="preserve"> + <value>Reloading extensions..</value> + </data> + <data name="Settings_ExtensionsPage_Banner_Header.Text" xml:space="preserve"> + <value>Discover more extensions</value> + </data> + <data name="Settings_ExtensionsPage_Banner_Description.Text" xml:space="preserve"> + <value>Find more extensions on the Microsoft Store or WinGet.</value> + </data> + <data name="Settings_ExtensionsPage_Banner_Hyperlink.Content" xml:space="preserve"> + <value>Learn how to create your own extensions</value> + </data> + <data name="Settings_ExtensionsPage_FindExtensions_MicrosoftStore.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve"> + <value>Find extensions on the Microsoft Store</value> + </data> + <data name="Settings_ExtensionsPage_FindExtensions_MicrosoftStore.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Microsoft Store</value> + </data> + <data name="Settings_ExtensionsPage_FindExtensions_WinGet.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve"> + <value>Find extensions on WinGet</value> + </data> + <data name="Settings_ExtensionsPage_FindExtensions_WinGet.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Microsoft Store</value> + </data> + <data name="Settings_ExtensionsPage_SearchBox_Placeholder.PlaceholderText" xml:space="preserve"> + <value>Search extensions</value> + </data> + <data name="GlobalErrorHandler_CrashMessageBox_Message" xml:space="preserve"> + <value>Command Palette has encountered a fatal error and must close.</value> + </data> + <data name="GlobalErrorHandler_CrashMessageBox_Caption" xml:space="preserve"> + <value>Command Palette - Fatal error</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_Item_Never.Content" xml:space="preserve"> + <value>Never</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_Item_Immediately.Content" xml:space="preserve"> + <value>Immediately</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_Item_After10Seconds.Content" xml:space="preserve"> + <value>10 seconds</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_Item_After20Seconds.Content" xml:space="preserve"> + <value>20 seconds</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_Item_After30Seconds.Content" xml:space="preserve"> + <value>30 seconds</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_Item_After60Seconds.Content" xml:space="preserve"> + <value>60 seconds</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_Item_After90Seconds.Content" xml:space="preserve"> + <value>90 seconds</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_Item_After120Seconds.Content" xml:space="preserve"> + <value>2 minutes</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_Item_After180Seconds.Content" xml:space="preserve"> + <value>3 minutes</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_SettingsCard.Header" xml:space="preserve"> + <value>Automatically return home</value> + </data> + <data name="Settings_GeneralPage_AutoGoHome_SettingsCard.Description" xml:space="preserve"> + <value>Automatically returns to home page after a period of inactivity when Command Palette is closed</value> + </data> + <data name="Settings_GeneralPage_NavigationViewItem_Appearance.Content" xml:space="preserve"> + <value>Personalization</value> + </data> + <data name="Settings_GeneralPage_AppTheme_SettingsCard.Header" xml:space="preserve"> + <value>App theme mode</value> + </data> + <data name="Settings_GeneralPage_AppTheme_SettingsCard.Description" xml:space="preserve"> + <value>Select which app theme to display</value> + </data> + <data name="AppearanceSettingsHeader.Text" xml:space="preserve"> + <value>Appearance</value> + </data> + <data name="Settings_GeneralPage_AppTheme_Mode_System.Text" xml:space="preserve"> + <value>Use system settings</value> + </data> + <data name="Settings_GeneralPage_AppTheme_Mode_Light.Text" xml:space="preserve"> + <value>Light</value> + </data> + <data name="Settings_GeneralPage_AppTheme_Mode_Dark.Text" xml:space="preserve"> + <value>Dark</value> + </data> + <data name="Settings_GeneralPage_BackgroundTint_SettingsCard.Header" xml:space="preserve"> + <value>Color tint</value> + </data> + <data name="Settings_GeneralPage_BackgroundTintIntensity_SettingsCard.Header" xml:space="preserve"> + <value>Color intensity</value> + </data> + <data name="Settings_GeneralPage_ImageTintIntensity_SettingsCard.Header" xml:space="preserve"> + <value>Color intensity</value> + </data> + <data name="OptionalColorPickerButton_UnsetTextBlock.Text" xml:space="preserve"> + <value>Choose color</value> + </data> + <data name="OptionalColorPickerButton_ResetButton.Content" xml:space="preserve"> + <value>Use default</value> + </data> + <data name="OptionalColorPickerButton_TransparentColorButton.Content" xml:space="preserve"> + <value>Use default color</value> + </data> + <data name="OptionalColorPickerButton_WindowsColorsSectionHeading.Text" xml:space="preserve"> + <value>Windows colors</value> + </data> + <data name="Settings_GeneralPage_BackgroundImage_SettingsExpander.Header" xml:space="preserve"> + <value>Background image</value> + </data> + <data name="Settings_GeneralPage_BackgroundImageOpacity_SettingsCard.Header" xml:space="preserve"> + <value>Background image opacity</value> + </data> + <data name="Settings_GeneralPage_BackgroundImageFit_SettingsCard.Header" xml:space="preserve"> + <value>Background image fit</value> + </data> + <data name="BackgroundImageFit_ComboBoxItem_Fill.Content" xml:space="preserve"> + <value>Fill</value> + </data> + <data name="BackgroundImageFit_ComboBoxItem_Fit.Content" xml:space="preserve"> + <value>Fit</value> + </data> + <data name="BackgroundImageFit_ComboBoxItem_Stretch.Content" xml:space="preserve"> + <value>Stretch</value> + </data> + <data name="Settings_PageTitles_GeneralPage" xml:space="preserve"> + <value>General</value> + </data> + <data name="Settings_PageTitles_AppearancePage" xml:space="preserve"> + <value>Personalization</value> + </data> + <data name="Settings_PageTitles_ExtensionsPage" xml:space="preserve"> + <value>Extensions</value> + </data> + <data name="Settings_GeneralPage_EscapeKeyBehavior_Option_DismissEmptySearchOrGoBack.Content" xml:space="preserve"> + <value>Clear search first, then go back</value> + </data> + <data name="Settings_GeneralPage_EscapeKeyBehavior_Option_AlwaysGoBack.Content" xml:space="preserve"> + <value>Go back</value> + </data> + <data name="Settings_GeneralPage_EscapeKeyBehavior_Option_AlwaysDismiss.Content" xml:space="preserve"> + <value>Hide window and go home</value> + </data> + <data name="Settings_GeneralPage_EscapeKeyBehavior_Option_AlwaysHide.Content" xml:space="preserve"> + <value>Hide window</value> + </data> + <data name="Settings_GeneralPage_EscapeKeyBehavior_SettingsCard.Header" xml:space="preserve"> + <value>Escape key behavior</value> + </data> + <data name="Settings_GeneralPage_EscapeKeyBehavior_SettingsCard.Description" xml:space="preserve"> + <value>Choose how Escape key behaves</value> + </data> + <data name="SettingsButtonTextBlock.Text" xml:space="preserve"> + <value>Settings</value> + </data> + <data name="OptionalColorPickerButton_CustomColorsSectionHeading.Text" xml:space="preserve"> + <value>Custom colors</value> + </data> + <data name="Settings_GeneralPage_ColorizationMode_None.Content" xml:space="preserve"> + <value>None</value> + </data> + <data name="Settings_GeneralPage_ColorizationMode_CustomColor.Content" xml:space="preserve"> + <value>Custom color</value> + </data> + <data name="Settings_GeneralPage_ColorizationMode_WindowsAccent.Content" xml:space="preserve"> + <value>Accent color</value> + </data> + <data name="Settings_GeneralPage_ColorizationMode_Image.Content" xml:space="preserve"> + <value>Image</value> + </data> + <data name="Settings_GeneralPage_BackgroundImage_ChooseImageButton.Content" xml:space="preserve"> + <value>Browse...</value> + </data> + <data name="Settings_GeneralPage_BackgroundImage_ResetImageButton.Content" xml:space="preserve"> + <value>Remove image</value> + </data> + <data name="Settings_GeneralPage_BackgroundColor_SettingsExpander.Header" xml:space="preserve"> + <value>Background color</value> + </data> + <data name="Settings_GeneralPage_BackgroundColor_SettingsExpander.Description" xml:space="preserve"> + <value>Choose a custom background color or use the current accent color</value> + </data> + <data name="Settings_GeneralPage_BackgroundImage_SettingsCard.Header" xml:space="preserve"> + <value>Background image</value> + </data> + <data name="Settings_GeneralPage_NoBackground_DescriptionTextBlock.Text" xml:space="preserve"> + <value>No additional settings are available.</value> + </data> + <data name="Settings_GeneralPage_Background_SettingsExpander.Header" xml:space="preserve"> + <value>Background</value> + </data> + <data name="Settings_GeneralPage_Background_SettingsExpander.Description" xml:space="preserve"> + <value>Choose a custom background color or image</value> + </data> + <data name="Settings_GeneralPage_Background_NotAvailable.Text" xml:space="preserve"> + <value>Not available with Mica</value> + </data> + <data name="Settings_GeneralPage_WindowsAccentColor_SettingsCard.Header" xml:space="preserve"> + <value>System accent color</value> + </data> + <data name="Settings_GeneralPage_WindowsAccentColor_OpenWindowsColorsLinkText.Text" xml:space="preserve"> + <value>Personalization › Colors</value> + </data> + <data name="Settings_GeneralPage_BackgroundImageBlur_SettingsCard.Header" xml:space="preserve"> + <value>Background image blur</value> + </data> + <data name="Settings_GeneralPage_BackgroundImageBrightness_SettingsCard.Header" xml:space="preserve"> + <value>Background image brightness</value> + </data> + <data name="Settings_GeneralPage_BackgroundImage_ResetProperties_SettingsCard.Header" xml:space="preserve"> + <value>Restore defaults</value> + </data> + <data name="Settings_GeneralPage_Background_ResetImagePropertiesButton.Content" xml:space="preserve"> + <value>Reset image settings</value> + </data> + <data name="Settings_GeneralPage_WindowsAccentColor_SettingsCard_Description1.Text" xml:space="preserve"> + <value>Change the system accent in Windows Settings:</value> + </data> + <data name="Settings_GeneralPage_AppTheme_Mode_Light_Automation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Light</value> + </data> + <data name="Settings_GeneralPage_AppTheme_Mode_Dark_Automation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Dark</value> + </data> + <data name="Settings_GeneralPage_AppTheme_Mode_System_Automation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Use system settings</value> + </data> + <data name="Settings_FallbacksPage_GlobalResults_SettingsCard.Header" xml:space="preserve"> + <value>Include in the Global result</value> + </data> + <data name="Settings_FallbacksPage_GlobalResults_SettingsCard.Description" xml:space="preserve"> + <value>Show results on queries without direct activation command</value> + </data> + <data name="ManageFallbackRankAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Manage fallback order</value> + </data> + <data name="ManageFallbackRank.Text" xml:space="preserve"> + <value>Manage fallback order</value> + </data> + <data name="ManageFallbackOrderDialogDescription.Text" xml:space="preserve"> + <value>Drag items to set which fallback commands run first; commands at the top take priority.</value> + </data> + <data name="Settings_ExtensionsPage_More_ReorderFallbacks_MenuFlyoutItem.Text" xml:space="preserve"> + <value>Manage fallback order</value> + </data> + <data name="Settings_GeneralPage_VersionNo" xml:space="preserve"> + <value>Version {0}</value> + </data> + <data name="Settings_GeneralPage_BackdropOpacity_SettingsCard.Header" xml:space="preserve"> + <value>Opacity</value> + </data> + <data name="Settings_GeneralPage_BackdropStyle_SettingsCard.Header" xml:space="preserve"> + <value>Material</value> + </data> + <data name="Settings_GeneralPage_BackdropStyle_SettingsCard.Description" xml:space="preserve"> + <value>Select the visual material used for the window background</value> + </data> + <data name="Settings_GeneralPage_BackdropStyle_Acrylic.Content" xml:space="preserve"> + <value>Acrylic (default)</value> + </data> + <data name="Settings_GeneralPage_BackdropStyle_Transparent.Content" xml:space="preserve"> + <value>Transparent</value> + </data> + <data name="Settings_GeneralPage_BackdropStyle_Mica.Content" xml:space="preserve"> + <value>Mica</value> + </data> + <data name="Settings_GeneralPage_BackdropStyle_AcrylicThin.Content" xml:space="preserve"> + <value>Thin Acrylic</value> + </data> + <data name="Settings_GeneralPage_BackdropStyle_MicaAlt.Content" xml:space="preserve"> + <value>Mica Alt</value> + </data> + <data name="Settings_GeneralPage_MicaBackdrop_DescriptionTextBlock.Text" xml:space="preserve"> + <value>Mica automatically adapts to your desktop wallpaper. Custom backgrounds and opacity settings are not available for this material.</value> + </data> + <data name="Settings_AppearancePage_OpenCommandPaletteButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Open Command Palette</value> + <comment>Button to open the Command Palette window to preview appearance changes</comment> + </data> + <data name="Settings_AppearancePage_OpenCommandPaletteButton_Text.Text" xml:space="preserve"> + <value>Open Command Palette</value> + </data> + <data name="Settings_AppearancePage_ResetAppearanceButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Reset appearance settings</value> + <comment>Button to reset all appearance settings to their default values</comment> + </data> + <data name="Settings_AppearancePage_ResetAppearanceButton_Text.Text" xml:space="preserve"> + <value>Reset to defaults</value> + </data> + <data name="Settings_ExtensionsPage_More_Button.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>More options</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Button.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Button.xaml deleted file mode 100644 index 3c091b7ff3..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Button.xaml +++ /dev/null @@ -1,158 +0,0 @@ -<?xml version="1.0" encoding="utf-8" ?> -<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - <ResourceDictionary.MergedDictionaries> - <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> - <!-- Other merged dictionaries here --> - </ResourceDictionary.MergedDictionaries> - - <ResourceDictionary.ThemeDictionaries> - <ResourceDictionary x:Key="Default"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="Light"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="HighContrast"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SystemColorHighlightTextColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SystemControlBackgroundBaseLowBrush" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SystemColorGrayTextColor" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="SystemColorButtonTextColorBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="SystemControlDisabledBaseMediumLowBrush" /> - </ResourceDictionary> - - </ResourceDictionary.ThemeDictionaries> - <Style x:Key="SubtleButtonStyle" TargetType="Button"> - <Setter Property="Background" Value="{ThemeResource SubtleButtonBackground}" /> - <Setter Property="BackgroundSizing" Value="InnerBorderEdge" /> - <Setter Property="Foreground" Value="{ThemeResource SubtleButtonForeground}" /> - <Setter Property="BorderBrush" Value="{ThemeResource SubtleButtonBorderBrush}" /> - <Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" /> - <Setter Property="Padding" Value="{StaticResource ButtonPadding}" /> - <Setter Property="HorizontalAlignment" Value="Left" /> - <Setter Property="VerticalAlignment" Value="Center" /> - <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> - <Setter Property="FontWeight" Value="Normal" /> - <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> - <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> - <Setter Property="FocusVisualMargin" Value="-3" /> - <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> - <Setter Property="Template"> - <Setter.Value> - <ControlTemplate TargetType="Button"> - <ContentPresenter - x:Name="ContentPresenter" - Padding="{TemplateBinding Padding}" - HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" - VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" - AnimatedIcon.State="Normal" - AutomationProperties.AccessibilityView="Raw" - Background="{TemplateBinding Background}" - BackgroundSizing="{TemplateBinding BackgroundSizing}" - BorderBrush="{TemplateBinding BorderBrush}" - BorderThickness="{TemplateBinding BorderThickness}" - Content="{TemplateBinding Content}" - ContentTemplate="{TemplateBinding ContentTemplate}" - ContentTransitions="{TemplateBinding ContentTransitions}" - CornerRadius="{TemplateBinding CornerRadius}" - Foreground="{TemplateBinding Foreground}"> - <ContentPresenter.BackgroundTransition> - <BrushTransition Duration="0:0:0.083" /> - </ContentPresenter.BackgroundTransition> - <VisualStateManager.VisualStateGroups> - <VisualStateGroup x:Name="CommonStates"> - <VisualState x:Name="Normal" /> - <VisualState x:Name="PointerOver"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="PointerOver" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Pressed"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Pressed" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Disabled"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <!-- DisabledVisual Should be handled by the control, not the animated icon. --> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Normal" /> - </VisualState.Setters> - </VisualState> - </VisualStateGroup> - </VisualStateManager.VisualStateGroups> - </ContentPresenter> - </ControlTemplate> - </Setter.Value> - </Setter> - </Style> -</ResourceDictionary> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml index 880e9f4eb0..728cd3ef4e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Colors.xaml @@ -1,40 +1,8 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8" ?> <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <ResourceDictionary.MergedDictionaries> <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> - <!-- Other merged dictionaries here --> </ResourceDictionary.MergedDictionaries> - - <ResourceDictionary.ThemeDictionaries> - <!-- For slightly adjust the LayerOnAcrylicFillColorDefault color so that the cursor of the searchbox shows --> - <ResourceDictionary x:Key="Default"> - <!-- This is a local copy of LayerOnAcrylicFillColorDefaultBrush --> - <SolidColorBrush - x:Key="LayerOnAcrylicPrimaryBackgroundBrush" - Opacity="0.3" - Color="#222222" /> - <SolidColorBrush - x:Key="LayerOnAcrylicSecondaryBackgroundBrush" - Opacity="0.0" - Color="#222222" /> - </ResourceDictionary> - <ResourceDictionary x:Key="Light"> - <SolidColorBrush - x:Key="LayerOnAcrylicPrimaryBackgroundBrush" - Opacity="0.65" - Color="#FFFFFF" /> - <!-- Because we are tweaking the LayerOnAcrylicPrimaryBackgroundBrush, we need to tweak the command bar background too. If not, it's too bright. --> - <SolidColorBrush - x:Key="LayerOnAcrylicSecondaryBackgroundBrush" - Opacity="0.4" - Color="#FFFFFF" /> - </ResourceDictionary> - <ResourceDictionary x:Key="HighContrast"> - <!-- This is a local copy of LayerOnAcrylicFillColorDefaultBrush --> - <SolidColorBrush x:Key="LayerOnAcrylicPrimaryBackgroundBrush" Color="{ThemeResource LayerOnAcrylicFillColorDefault}" /> - <SolidColorBrush x:Key="LayerOnAcrylicSecondaryBackgroundBrush" Color="Transparent" /> - </ResourceDictionary> - </ResourceDictionary.ThemeDictionaries> </ResourceDictionary> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Settings.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Settings.xaml index f145be5483..89c01814eb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Settings.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Settings.xaml @@ -1,8 +1,8 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8" ?> <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <ResourceDictionary.MergedDictionaries> <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> - <!-- Other merged dictionaries here --> + <ResourceDictionary Source="ms-appx:///CommunityToolkit.WinUI.Controls.SettingsControls/Themes/Generic.xaml" /> </ResourceDictionary.MergedDictionaries> <x:Double x:Key="SettingsCardSpacing">4</x:Double> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBlock.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBlock.xaml new file mode 100644 index 0000000000..6160585127 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBlock.xaml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:converters="using:CommunityToolkit.WinUI.Converters"> + + <ResourceDictionary.MergedDictionaries> + <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> + <!-- Other merged dictionaries here --> + </ResourceDictionary.MergedDictionaries> + + <Style + x:Key="ContextItemTitleTextBlockCriticalStyle" + BasedOn="{StaticResource BaseTextBlockStyle}" + TargetType="TextBlock"> + <Setter Property="Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" /> + <Setter Property="FontWeight" Value="Normal" /> + </Style> + + <Style + x:Key="ContextItemCaptionTextBlockCriticalStyle" + BasedOn="{StaticResource CaptionTextBlockStyle}" + TargetType="TextBlock"> + <Setter Property="Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" /> + </Style> + +</ResourceDictionary> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBox.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBox.xaml index 26e61dda4e..167636e8ec 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBox.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/TextBox.xaml @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8" ?> <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" @@ -186,6 +186,8 @@ x:Load="False" AutomationProperties.AccessibilityView="Raw" CharacterSpacing="15" + FontFamily="{TemplateBinding FontFamily}" + FontSize="{TemplateBinding FontSize}" Foreground="{Binding PlaceholderForeground, RelativeSource={RelativeSource TemplatedParent}, TargetNullValue={ThemeResource TextControlPlaceholderForeground}}" Text="{TemplateBinding Description}" TextWrapping="{TemplateBinding TextWrapping}" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml new file mode 100644 index 0000000000..e1dfe7f45c --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Colorful.xaml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + <ResourceDictionary.ThemeDictionaries> + <ResourceDictionary x:Key="Dark"> + <StaticResource x:Key="LayerOnAcrylicPrimaryBackgroundBrush" ResourceKey="LayerOnAccentAcrylicFillColorDefaultBrush" /> + <SolidColorBrush x:Key="LayerOnAcrylicSecondaryBackgroundBrush" Color="Transparent" /> + <StaticResource x:Key="CmdPal.CommandBarBorderBrush" ResourceKey="CardStrokeColorDefaultBrush" /> + <StaticResource x:Key="CmdPal.TopBarBorderBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> + <StaticResource x:Key="CmdPal.DividerStrokeColorDefaultBrush" ResourceKey="CardStrokeColorDefaultBrush" /> + </ResourceDictionary> + <ResourceDictionary x:Key="Light"> + <SolidColorBrush x:Key="LayerOnAcrylicPrimaryBackgroundBrush" Color="#A0FFFFFF" /> + <SolidColorBrush x:Key="LayerOnAcrylicSecondaryBackgroundBrush" Color="Transparent" /> + <StaticResource x:Key="CmdPal.CommandBarBorderBrush" ResourceKey="CardStrokeColorDefaultBrush" /> + <StaticResource x:Key="CmdPal.TopBarBorderBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> + <StaticResource x:Key="CmdPal.DividerStrokeColorDefaultBrush" ResourceKey="CardStrokeColorDefaultBrush" /> + </ResourceDictionary> + <ResourceDictionary x:Key="HighContrast"> + <SolidColorBrush x:Key="LayerOnAcrylicPrimaryBackgroundBrush" Color="{ThemeResource SystemColorWindowColor}" /> + <SolidColorBrush x:Key="LayerOnAcrylicSecondaryBackgroundBrush" Color="{ThemeResource SystemColorWindowColor}" /> + <SolidColorBrush x:Key="CmdPal.CommandBarBorderBrush" Color="{ThemeResource SystemColorWindowTextColor}" /> + <SolidColorBrush x:Key="CmdPal.TopBarBorderBrush" Color="{ThemeResource SystemColorWindowTextColor}" /> + <SolidColorBrush x:Key="CmdPal.DividerStrokeColorDefaultBrush" Color="{ThemeResource SystemColorWindowTextColor}" /> + </ResourceDictionary> + </ResourceDictionary.ThemeDictionaries> + +</ResourceDictionary> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml new file mode 100644 index 0000000000..53b46d39d6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Styles/Theme.Normal.xaml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + <ResourceDictionary.ThemeDictionaries> + <ResourceDictionary x:Key="Dark"> + <SolidColorBrush x:Key="LayerOnAcrylicPrimaryBackgroundBrush" Color="#50202020" /> + <SolidColorBrush x:Key="LayerOnAcrylicSecondaryBackgroundBrush" Color="Transparent" /> + <StaticResource x:Key="CmdPal.CommandBarBorderBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> + <StaticResource x:Key="CmdPal.TopBarBorderBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> + <StaticResource x:Key="CmdPal.DividerStrokeColorDefaultBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> + </ResourceDictionary> + <ResourceDictionary x:Key="Light"> + <!-- + TextBox caret is rendered as inverted and needs clearly-defined background + https://github.com/zadjii-msft/PowerToys/issues/348 + Because we are tweaking the LayerOnAcrylicPrimaryBackgroundBrush, we need to tweak the command bar background too. If not, it's too bright. + --> + <SolidColorBrush x:Key="LayerOnAcrylicPrimaryBackgroundBrush" Color="#A0FFFFFF" /> + <SolidColorBrush x:Key="LayerOnAcrylicSecondaryBackgroundBrush" Color="#66FFFFFF" /> + <StaticResource x:Key="CmdPal.CommandBarBorderBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> + <StaticResource x:Key="CmdPal.TopBarBorderBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> + <StaticResource x:Key="CmdPal.DividerStrokeColorDefaultBrush" ResourceKey="DividerStrokeColorDefaultBrush" /> + </ResourceDictionary> + <ResourceDictionary x:Key="HighContrast"> + <SolidColorBrush x:Key="LayerOnAcrylicPrimaryBackgroundBrush" Color="{ThemeResource SystemColorWindowColor}" /> + <SolidColorBrush x:Key="LayerOnAcrylicSecondaryBackgroundBrush" Color="{ThemeResource SystemColorWindowColor}" /> + <SolidColorBrush x:Key="CmdPal.CommandBarBorderBrush" Color="{ThemeResource SystemColorWindowTextColor}" /> + <SolidColorBrush x:Key="CmdPal.TopBarBorderBrush" Color="{ThemeResource SystemColorWindowTextColor}" /> + <SolidColorBrush x:Key="CmdPal.DividerStrokeColorDefaultBrush" Color="{ThemeResource SystemColorWindowTextColor}" /> + </ResourceDictionary> + </ResourceDictionary.ThemeDictionaries> + +</ResourceDictionary> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Themes/Generic.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Themes/Generic.xaml deleted file mode 100644 index 28fe813db3..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Themes/Generic.xaml +++ /dev/null @@ -1 +0,0 @@ -<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml index 06bcdd66fc..7e76927a16 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8" ?> -<Window +<winuiex:WindowEx x:Class="Microsoft.CmdPal.UI.ToastWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" @@ -7,17 +7,18 @@ xmlns:local="using:Microsoft.CmdPal.UI" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:winuiex="using:WinUIEx" Title="Command Palette Toast" mc:Ignorable="d"> - <Window.SystemBackdrop> + <winuiex:WindowEx.SystemBackdrop> <DesktopAcrylicBackdrop /> - </Window.SystemBackdrop> + </winuiex:WindowEx.SystemBackdrop> <Grid x:Name="ToastGrid"> <!-- This padding is used to calculate the dimensions of the ToastWindow --> <TextBlock x:Name="ToastText" - Padding="16,16,36,24" + Padding="12,12,24,20" Text="{x:Bind ViewModel.ToastMessage, Mode=OneWay}" TextAlignment="Center" /> </Grid> -</Window> +</winuiex:WindowEx> \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs index e21c8dd55d..1a49fec8e0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ToastWindow.xaml.cs @@ -4,39 +4,42 @@ using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI; +using ManagedCommon; +using Microsoft.CmdPal.Core.ViewModels; using Microsoft.CmdPal.UI.Helpers; -using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.UI.Dispatching; using Microsoft.UI.Windowing; -using Microsoft.UI.Xaml; -using Windows.Graphics; using Windows.Win32; using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Dwm; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.HiDpi; using Windows.Win32.UI.WindowsAndMessaging; +using WinUIEx; using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance; namespace Microsoft.CmdPal.UI; -public sealed partial class ToastWindow : Window, +public sealed partial class ToastWindow : WindowEx, IRecipient<QuitMessage> { private readonly HWND _hwnd; + private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); + private readonly HiddenOwnerWindowBehavior _hiddenOwnerWindowBehavior = new(); public ToastViewModel ViewModel { get; } = new(); - private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); - public ToastWindow() { this.InitializeComponent(); AppWindow.Hide(); - AppWindow.IsShownInSwitchers = false; ExtendsContentIntoTitleBar = true; AppWindow.SetPresenter(AppWindowPresenterKind.CompactOverlay); this.SetIcon(); AppWindow.Title = RS_.GetString("ToastWindowTitle"); AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed; + _hiddenOwnerWindowBehavior.ShowInTaskbar(this, false); _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); PInvoke.EnableWindow(_hwnd, false); @@ -44,14 +47,24 @@ public sealed partial class ToastWindow : Window, WeakReferenceMessenger.Default.Register<QuitMessage>(this); } + private static double GetScaleFactor(HWND hwnd) + { + try + { + var monitor = PInvoke.MonitorFromWindow(hwnd, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); + _ = PInvoke.GetDpiForMonitor(monitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var dpiX, out _); + return dpiX / 96.0; + } + catch (Exception ex) + { + Logger.LogError($"Failed to get scale factor, error: {ex.Message}"); + return 1.0; + } + } + private void PositionCentered() { - var intSize = new SizeInt32 - { - Width = Convert.ToInt32(ToastText.ActualWidth), - Height = Convert.ToInt32(ToastText.ActualHeight), - }; - AppWindow.Resize(intSize); + this.SetWindowSize(ToastText.ActualWidth, ToastText.ActualHeight); var displayArea = DisplayArea.GetFromWindowId(AppWindow.Id, DisplayAreaFallback.Nearest); if (displayArea is not null) @@ -61,7 +74,7 @@ public sealed partial class ToastWindow : Window, var monitorHeight = displayArea.WorkArea.Height; var windowHeight = AppWindow.Size.Height; - centeredPosition.Y = monitorHeight - (windowHeight * 2); + centeredPosition.Y = monitorHeight - (windowHeight + 8); // Align with other shell toasts, like the volume indicator. AppWindow.Move(centeredPosition); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/TypePreservation.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/TypePreservation.cs new file mode 100644 index 0000000000..c358c708ae --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/TypePreservation.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +/// <summary> +/// This class ensures types used in XAML are preserved during AOT compilation. +/// Framework types cannot have attributes added directly to their definitions since they're external types. +/// Application types that require runtime type checking should also be preserved here if needed. +/// </summary> +internal static class TypePreservation +{ + /// <summary> + /// This method ensures critical types are preserved for AOT compilation. + /// These types are used dynamically in XAML and would otherwise be trimmed. + /// </summary> + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.FontIconSource))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.PathIcon))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.DataTemplate))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.DataTemplateSelector))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ListViewItem))] + public static void PreserveTypes() + { + // This method exists only to hold the DynamicDependency attributes above. + // It must be called to ensure the types are not trimmed during AOT compilation. + + // Note: We cannot add [DynamicallyAccessedMembers] directly to framework types + // since we don't own their source code. DynamicDependency is the correct approach + // for preserving external types that are used dynamically (e.g., in XAML). + + // For application types that require runtime type checking (e.g., in template selectors), + // prefer adding [DynamicallyAccessedMembers] attributes directly on the type definitions. + // Only use DynamicDependency here for types we cannot modify directly. + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/DevRibbonViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/DevRibbonViewModel.cs new file mode 100644 index 0000000000..aa77772f11 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/DevRibbonViewModel.cs @@ -0,0 +1,204 @@ +// 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.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using ManagedCommon; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.Messages; +using Microsoft.UI; +using Windows.System; +using Windows.UI; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; + +namespace Microsoft.CmdPal.UI.ViewModels; + +internal sealed partial class DevRibbonViewModel : ObservableObject +{ + private const int MaxLogEntries = 2; + private const string Release = "Release"; + private const string Debug = "Debug"; + + private static readonly Color ReleaseAotColor = ColorHelper.FromArgb(255, 124, 58, 237); + private static readonly Color ReleaseColor = ColorHelper.FromArgb(255, 51, 65, 85); + private static readonly Color DebugAotColor = ColorHelper.FromArgb(255, 99, 102, 241); + private static readonly Color DebugColor = ColorHelper.FromArgb(255, 107, 114, 128); + + private readonly DispatcherQueue _dispatcherQueue; + + public DevRibbonViewModel() + { + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + Trace.Listeners.Add(new DevRibbonTraceListener(this)); + + var configLabel = BuildConfiguration == Release ? "RLS" : "DBG"; /* #no-spell-check-line */ + var aotLabel = BuildInfo.IsNativeAot ? "⚡AOT" : "NO AOT"; + Tag = $"{configLabel} | {aotLabel}"; + + TagColor = (BuildConfiguration, BuildInfo.IsNativeAot) switch + { + (Release, true) => ReleaseAotColor, + (Release, false) => ReleaseColor, + (Debug, true) => DebugAotColor, + (Debug, false) => DebugColor, + _ => Colors.Fuchsia, + }; + } + + public string BuildConfiguration => BuildInfo.Configuration; + + public bool IsAotReleaseConfiguration => BuildConfiguration == Release && BuildInfo.IsNativeAot; + + public bool IsAot => BuildInfo.IsNativeAot; + + public bool IsPublishTrimmed => BuildInfo.PublishTrimmed; + + public ObservableCollection<LogEntryViewModel> LatestLogs { get; } = []; + + [ObservableProperty] + public partial int WarningCount { get; private set; } + + [ObservableProperty] + public partial int ErrorCount { get; private set; } + + [ObservableProperty] + public partial string Tag { get; private set; } + + [ObservableProperty] + public partial Color TagColor { get; private set; } + + [RelayCommand] + private async Task OpenLogFileAsync() + { + var logPath = Logger.CurrentLogFile; + if (File.Exists(logPath)) + { + await Launcher.LaunchUriAsync(new Uri(logPath)); + } + } + + [RelayCommand] + private async Task OpenLogFolderAsync() + { + var logFolderPath = Logger.CurrentVersionLogDirectoryPath; + if (Directory.Exists(logFolderPath)) + { + await Launcher.LaunchFolderPathAsync(logFolderPath); + } + } + + [RelayCommand] + private void ResetErrorCounters() + { + WarningCount = 0; + ErrorCount = 0; + LatestLogs.Clear(); + } + + [RelayCommand] + private void OpenInternalTools() + { + WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Internal")); + } + + [RelayCommand] + private void ToggleDevRibbonVisibility() + { + WeakReferenceMessenger.Default.Send(new ToggleDevRibbonMessage()); + } + + private sealed partial class DevRibbonTraceListener(DevRibbonViewModel viewModel) : TraceListener + { + private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff"; + + [GeneratedRegex(@"^\[(?<timestamp>.*?)\] \[(?<severity>.*?)\] (?<message>.*)")] + private static partial Regex LogRegex(); + + private readonly Lock _lock = new(); + private LogEntryViewModel? _latestLogEntry; + + public override void Write(string? message) + { + // Not required for this scenario. + } + + public override void WriteLine(string? message) + { + if (message is null) + { + return; + } + + lock (_lock) + { + var match = LogRegex().Match(message); + if (match.Success) + { + var severity = match.Groups["severity"].Value; + var isWarning = severity.Equals("Warning", StringComparison.OrdinalIgnoreCase); + var isError = severity.Equals("Error", StringComparison.OrdinalIgnoreCase); + + if (isWarning || isError) + { + var timestampStr = match.Groups["timestamp"].Value; + var timestamp = DateTimeOffset.TryParseExact( + timestampStr, + TimestampFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out var parsed) + ? parsed + : DateTimeOffset.Now; + + var logEntry = new LogEntryViewModel( + timestamp, + severity, + match.Groups["message"].Value, + string.Empty); + + _latestLogEntry = logEntry; + + viewModel._dispatcherQueue.TryEnqueue(() => + { + if (isWarning) + { + viewModel.WarningCount++; + } + else + { + viewModel.ErrorCount++; + } + + viewModel.LatestLogs.Insert(0, logEntry); + + while (viewModel.LatestLogs.Count > MaxLogEntries) + { + viewModel.LatestLogs.RemoveAt(viewModel.LatestLogs.Count - 1); + } + }); + } + else + { + _latestLogEntry = null; + } + + return; + } + + if (IndentLevel > 0 && _latestLogEntry is { } latest) + { + viewModel._dispatcherQueue.TryEnqueue(() => + { + latest.AppendDetails(message); + }); + } + } + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/LogEntryViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/LogEntryViewModel.cs new file mode 100644 index 0000000000..5f9ed8db68 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/LogEntryViewModel.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.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Microsoft.CmdPal.UI.ViewModels; + +internal sealed partial class LogEntryViewModel : ObservableObject +{ + private const int HeaderMaxLength = 80; + private const string WarningGlyph = "\uE7BA"; + private const string ErrorGlyph = "\uEA39"; + private const string TimestampFormat = "HH:mm:ss"; + + private DateTimeOffset Timestamp { get; } + + private string Severity { get; } + + private string Message { get; } + + private string FormattedTimestamp { get; } + + public string SeverityGlyph { get; } + + [ObservableProperty] + public partial string Header { get; private set; } + + [ObservableProperty] + public partial string Description { get; private set; } + + [ObservableProperty] + public partial string Details { get; private set; } + + public LogEntryViewModel(DateTimeOffset timestamp, string severity, string message, string details) + { + Timestamp = timestamp; + Severity = severity; + Message = message; + Details = details; + + SeverityGlyph = severity.ToUpperInvariant() switch + { + "WARNING" => WarningGlyph, + "ERROR" => ErrorGlyph, + _ => string.Empty, + }; + + FormattedTimestamp = timestamp.ToString(TimestampFormat, CultureInfo.CurrentCulture); + Description = $"{FormattedTimestamp} • {Message}"; + Header = Message; + } + + public void AppendDetails(string? message) + { + if (string.IsNullOrEmpty(message)) + { + return; + } + + Details += Environment.NewLine + message; + + // Make header the second line of details (because that's actually the message itself): + var detailsLines = Details.Split([Environment.NewLine], StringSplitOptions.None); + if (detailsLines.Length < 2) + { + return; + } + + Header = detailsLines[1].Trim(); + if (Header.Length > HeaderMaxLength) + { + Header = Header[..(HeaderMaxLength - 1)] + "…"; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Views/ICurrentPageAware.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Views/ICurrentPageAware.cs index 2aa93bafb3..a66a397e42 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Views/ICurrentPageAware.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Views/ICurrentPageAware.cs @@ -2,11 +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 Microsoft.CmdPal.UI.ViewModels; +using Microsoft.CmdPal.Core.ViewModels; namespace Microsoft.CmdPal.UI.Views; public interface ICurrentPageAware { - public PageViewModel? CurrentPageViewModel { get; set; } + PageViewModel? CurrentPageViewModel { get; set; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/app.manifest b/src/modules/cmdpal/Microsoft.CmdPal.UI/app.manifest index 0cd29eccd5..f5dd7ff036 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/app.manifest +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/app.manifest @@ -13,6 +13,7 @@ <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.cpp b/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.cpp new file mode 100644 index 0000000000..e6cb46457b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.cpp @@ -0,0 +1,177 @@ +#include "pch.h" +#include "FontIconGlyphClassifier.h" +#include "FontIconGlyphClassifier.g.cpp" + +#include <icu.h> +#include <utility> + +namespace winrt::Microsoft::Terminal::UI::implementation +{ + namespace + { + // Check if the code point is in the Private Use Area range used by Fluent UI icons. + [[nodiscard]] constexpr bool _isFluentIconPua(const UChar32 cp) noexcept + { + constexpr UChar32 fluentIconsPrivateUseAreaStart = 0xE700; + constexpr UChar32 fluentIconsPrivateUseAreaEnd = 0xF8FF; + return cp >= fluentIconsPrivateUseAreaStart && cp <= fluentIconsPrivateUseAreaEnd; + } + + // Determine if the given text (as a sequence of UChar code units) is emoji + [[nodiscard]] bool _isEmoji(const UChar* p, const int32_t length) noexcept + { + if (!p || length < 1) + { + return false; + } + + // https://www.unicode.org/reports/tr51/#Emoji_Variation_Selector_Notes + constexpr UChar32 vs15CodePoint = 0xFE0E; // Variation Selectors 15: text variation selector + constexpr UChar32 vs16CodePoint = 0xFE0F; // Variation Selectors: 16 emoji variation selector + + // Decode the first code point correctly (surrogate-safe) + int32_t i0{ 0 }; + UChar32 first{ 0 }; + U16_NEXT(p, i0, length, first); + + for (int32_t i = 0; i < length;) + { + UChar32 cp{ 0 }; + U16_NEXT(p, i, length, cp); + + if (cp == vs16CodePoint) { return true; } + if (cp == vs15CodePoint) { return false; } + } + + return !U_IS_SURROGATE(first) && u_hasBinaryProperty(first, UCHAR_EMOJI_PRESENTATION); + } + } + + bool FontIconGlyphClassifier::IsLikelyToBeEmojiOrSymbolIcon(const hstring& text) + { + if (text.empty()) + { + return false; + } + + if (text.size() == 1 && !IS_HIGH_SURROGATE(text[0])) + { + // If it's a single code unit, it's definitely either zero or one grapheme clusters. + // If it turns out to be illegal Unicode, we don't really care. + return true; + } + + if (text.size() >= 2 && text[0] <= 0x7F && text[1] <= 0x7F) + { + // Two adjacent ASCII characters (as seen in most file paths) aren't a single + // grapheme cluster. + return false; + } + + // Use ICU to determine whether text is composed of a single grapheme cluster. + int32_t off{ 0 }; + UErrorCode status{ U_ZERO_ERROR }; + + UBreakIterator* const bi{ ubrk_open(UBRK_CHARACTER, + nullptr, + reinterpret_cast<const UChar*>(text.data()), + static_cast<int>(text.size()), + &status) }; + if (bi) + { + if (U_SUCCESS(status)) + { + off = ubrk_next(bi); + } + ubrk_close(bi); + } + return std::cmp_equal(off, text.size()); + } + + FontIconGlyphKind FontIconGlyphClassifier::Classify(hstring const& text) noexcept + { + if (text.empty()) + { + return FontIconGlyphKind::None; + } + + const size_t textSize{ text.size() }; + const auto* buffer{ reinterpret_cast<const UChar*>(text.c_str()) }; + + // Fast path 1: Single UTF-16 code unit (most common case) + if (textSize == 1) + { + const UChar ch{ buffer[0] }; + + if (IS_HIGH_SURROGATE(ch)) + { + return FontIconGlyphKind::Invalid; + } + + if (_isFluentIconPua(ch)) + { + return FontIconGlyphKind::FluentSymbol; + } + + if (_isEmoji(&ch, 1)) + { + return FontIconGlyphKind::Emoji; + } + + return FontIconGlyphKind::Other; + } + + // Fast path 2: Common file path pattern - two ASCII printable characters + if (textSize >= 2 && buffer[0] <= 0x7F && buffer[1] <= 0x7F) + { + // Definitely multiple graphemes + return FontIconGlyphKind::Invalid; + } + + // Expensive path: Use ICU to determine grapheme boundaries + UErrorCode status{ U_ZERO_ERROR }; + + UBreakIterator* bi{ ubrk_open(UBRK_CHARACTER, + nullptr, + buffer, + static_cast<int32_t>(textSize), + &status) }; + + if (U_FAILURE(status) || !bi) + { + return FontIconGlyphKind::Invalid; + } + + const int32_t start{ ubrk_first(bi) }; + const int32_t end{ ubrk_next(bi) }; // end of first grapheme + ubrk_close(bi); + + // No graphemes found + if (end == UBRK_DONE || end <= start) + { + return FontIconGlyphKind::None; + } + + // If there's more than one grapheme, it's not a valid icon glyph + if (std::cmp_not_equal(end, textSize)) + { + return FontIconGlyphKind::Invalid; + } + + // Exactly one grapheme: classify + const UChar* grapheme = buffer + start; + const int32_t graphemeLength = end - start; + + if (graphemeLength == 1 && _isFluentIconPua(grapheme[0])) + { + return FontIconGlyphKind::FluentSymbol; + } + + if (_isEmoji(grapheme, graphemeLength)) + { + return FontIconGlyphKind::Emoji; + } + + return FontIconGlyphKind::Other; + } +} \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.h b/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.h new file mode 100644 index 0000000000..e51ca42c17 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.h @@ -0,0 +1,18 @@ +#pragma once + +#include "FontIconGlyphClassifier.g.h" + +namespace winrt::Microsoft::Terminal::UI::implementation +{ + struct FontIconGlyphClassifier + { + [[nodiscard]] static bool IsLikelyToBeEmojiOrSymbolIcon(const winrt::hstring& text); + + [[nodiscard]] static FontIconGlyphKind Classify(winrt::hstring const& text) noexcept; + }; +} + +namespace winrt::Microsoft::Terminal::UI::factory_implementation +{ + BASIC_FACTORY(FontIconGlyphClassifier); +} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.idl b/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.idl new file mode 100644 index 0000000000..1adccb9d44 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/FontIconGlyphClassifier.idl @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.Terminal.UI +{ + /// <summary> + /// Categorizes the type of a single grapheme cluster or input text. + /// Used to determine how the input should be handled or rendered (for example, + /// whether it should be treated as an emoji, an icon from a symbol font, plain text, etc.). + /// </summary> + enum FontIconGlyphKind + { + /// <summary> + /// Input is invalid or contains more than one grapheme cluster and therefore cannot be + /// treated as a single symbol. Typical for multi-character text like file paths + /// or composed strings that include separators. + /// </summary> + Invalid = -1, + + /// <summary> + /// No grapheme present (empty string). Indicates absence of a symbol. + /// </summary> + None = 0, + + /// <summary> + /// A single emoji grapheme cluster. This may consist of multiple Unicode code + /// points combined into one visible glyph (e.g., emoji with modifiers or ZWJ sequences). + /// </summary> + Emoji = 1, + + /// <summary> + /// A single glyph from the Segoe Fluent Icons / MDL2 Assets Private Use Area (PUA), + /// typically in the Unicode range U+E700–U+F8FF. These are font-based icons (Fluent/MDL2). + /// </summary> + FluentSymbol = 2, + + /// <summary> + /// A single non-emoji grapheme that is not a Fluent/MDL2 PUA symbol. + /// Covers ordinary characters, letters, numbers, or other single glyph symbols. + /// </summary> + Other = 3, + }; + + /// <summary> + /// Static utility class for text and icon analysis + /// </summary> + static runtimeclass FontIconGlyphClassifier + { + /// <summary> + /// Determines if text represents a single grapheme cluster (emoji/symbol icon). + /// Uses ICU for Unicode boundary detection to distinguish icons from file paths. + /// </summary> + /// <param name="text">Text to analyze</param> + /// <returns>True if single grapheme cluster, false for multi-character text or paths</returns> + static Boolean IsLikelyToBeEmojiOrSymbolIcon(String text); + + /// <summary> + /// Classifies the input into a glyph kind suitable for icon or text rendering. + /// </summary> + static FontIconGlyphKind Classify(String text); + }; +} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp index f35394f0fe..a1dee068e8 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.cpp @@ -2,7 +2,7 @@ #include "IconPathConverter.h" #include "IconPathConverter.g.cpp" -// #include "Utils.h" + #include "FontIconGlyphClassifier.h" #include <Shlobj.h> #include <Shlobj_core.h> @@ -94,40 +94,49 @@ namespace winrt::Microsoft::Terminal::UI::implementation // - <TIconSource>: The type of IconSource (MUX, WUX) to generate. // Arguments: // - path: the full, expanded path to the icon. + // - targetSize: the target size for decoding/rasterizing the icon. // Return Value: // - An IconElement with its IconSource set, if possible. template<typename TIconSource> - TIconSource _getColoredBitmapIcon(const winrt::hstring& path, bool monochrome) + TIconSource _getColoredBitmapIcon(const winrt::hstring& path, int targetSize) { // FontIcon uses glyphs in the private use area, whereas valid URIs only contain ASCII characters. // To skip throwing on Uri construction, we can quickly check if the first character is ASCII. - if (!path.empty() && path.front() < 128) + if (path.empty() || path.front() >= 128) { - try - { - winrt::Windows::Foundation::Uri iconUri{ path }; - - if (til::equals_insensitive_ascii(iconUri.Extension(), L".svg")) - { - typename ImageIconSource<TIconSource>::type iconSource; - winrt::Microsoft::UI::Xaml::Media::Imaging::SvgImageSource source{ iconUri }; - iconSource.ImageSource(source); - return iconSource; - } - else - { - typename BitmapIconSource<TIconSource>::type iconSource; - // Make sure to set this to false, so we keep the RGB data of the - // image. Otherwise, the icon will be white for all the - // non-transparent pixels in the image. - iconSource.ShowAsMonochrome(monochrome); - iconSource.UriSource(iconUri); - return iconSource; - } - } - CATCH_LOG(); + return nullptr; } + try + { + winrt::Windows::Foundation::Uri iconUri{ path }; + + if (til::equals_insensitive_ascii(iconUri.Extension(), L".svg")) + { + typename ImageIconSource<TIconSource>::type iconSource; + winrt::Microsoft::UI::Xaml::Media::Imaging::SvgImageSource source{ iconUri }; + source.RasterizePixelWidth(static_cast<double>(targetSize)); + // Set only single dimension here; the image might not be square and + // this will preserve the aspect ratio (for the price of keeping height unbound). + // source.RasterizePixelHeight(static_cast<double>(targetSize)); + iconSource.ImageSource(source); + return iconSource; + } + else + { + typename ImageIconSource<TIconSource>::type iconSource; + winrt::Microsoft::UI::Xaml::Media::Imaging::BitmapImage bitmapImage; + bitmapImage.DecodePixelWidth(targetSize); + // Set only single dimension here; the image might not be square and + // this will preserve the aspect ratio (for the price of keeping height unbound). + // bitmapImage.DecodePixelHeight(targetSize); + bitmapImage.UriSource(iconUri); + iconSource.ImageSource(bitmapImage); + return iconSource; + } + } + CATCH_LOG(); + return nullptr; } @@ -158,47 +167,57 @@ namespace winrt::Microsoft::Terminal::UI::implementation // Return Value: // - An IconElement with its IconSource set, if possible. template<typename TIconSource> - TIconSource _getIconSource(const winrt::hstring& iconPath, bool monochrome, const int targetSize) + TIconSource _getIconSource(const winrt::hstring& iconPath, const winrt::hstring& fontFamily, const int targetSize) { TIconSource iconSource{ nullptr }; if (iconPath.size() != 0) { const auto expandedIconPath{ _expandIconPath(iconPath) }; - iconSource = _getColoredBitmapIcon<TIconSource>(expandedIconPath, monochrome); + iconSource = _getColoredBitmapIcon<TIconSource>(expandedIconPath, targetSize); // If we fail to set the icon source using the "icon" as a path, // let's try it as a symbol/emoji. - // - // Anything longer than 2 wchar_t's _isn't_ an emoji or symbol, so - // don't do this if it's just an invalid path. - if (!iconSource && iconPath.size() <= 2) + if (!iconSource) { try { - typename FontIconSource<TIconSource>::type icon; - const auto ch = til::at(iconPath, 0); + const auto glyph_kind = FontIconGlyphClassifier::Classify(iconPath); - // The range of MDL2 Icons isn't explicitly defined, but - // we're using this based off the table on: - // https://docs.microsoft.com/en-us/windows/uwp/design/style/segoe-ui-symbol-font - const auto isMDL2Icon = ch >= L'\uE700' && ch <= L'\uF8FF'; - if (isMDL2Icon) + winrt::hstring family; + if (glyph_kind == FontIconGlyphKind::Invalid) { - icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ L"Segoe Fluent Icons, Segoe MDL2 Assets" }); + family = L"Segoe UI"; + } + else if (!fontFamily.empty()) + { + family = fontFamily; + } + else if (glyph_kind == FontIconGlyphKind::FluentSymbol) + { + family = L"Segoe Fluent Icons, Segoe MDL2 Assets"; + } + else if (glyph_kind == FontIconGlyphKind::Emoji) + { + // Emoji and other symbols go in the Segoe UI Emoji font. + // Some emojis (e.g. 2️⃣) would be rendered as emoji glyphs otherwise. + family = L"Segoe UI Emoji, Segoe UI"; } else { - // Note: you _do_ need to manually set the font here. - icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ L"Segoe UI" }); + family = L"Segoe UI"; } + + typename FontIconSource<TIconSource>::type icon; + icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ family }); icon.FontSize(targetSize); - icon.Glyph(iconPath); + icon.Glyph(glyph_kind == FontIconGlyphKind::Invalid ? L"\u25CC" : iconPath); iconSource = icon; } CATCH_LOG(); } } + if (!iconSource) { // Set the default IconSource to a BitmapIconSource with a null source @@ -225,9 +244,9 @@ namespace winrt::Microsoft::Terminal::UI::implementation // return _getIconSource<Windows::UI::Xaml::Controls::IconSource>(path, false); // } - static Microsoft::UI::Xaml::Controls::IconSource _IconSourceMUX(const hstring& path, bool monochrome, const int targetSize) + static Microsoft::UI::Xaml::Controls::IconSource _IconSourceMUX(const hstring& path, const winrt::hstring& fontFamily, const int targetSize) { - return _getIconSource<Microsoft::UI::Xaml::Controls::IconSource>(path, monochrome, targetSize); + return _getIconSource<Microsoft::UI::Xaml::Controls::IconSource>(path, fontFamily, targetSize); } static SoftwareBitmap _convertToSoftwareBitmap(HICON hicon, @@ -321,7 +340,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation } static winrt::Microsoft::UI::Xaml::Media::Imaging::SoftwareBitmapSource _getImageIconSourceForBinary(std::wstring_view iconPathWithoutIndex, - int index, + int index, int targetSize) { // Try: @@ -342,14 +361,14 @@ namespace winrt::Microsoft::Terminal::UI::implementation } MUX::Controls::IconSource IconPathConverter::IconSourceMUX(const winrt::hstring& iconPath, - const bool monochrome, + const winrt::hstring& fontFamily, const int targetSize) { std::wstring_view iconPathWithoutIndex; const auto indexOpt = _getIconIndex(iconPath, iconPathWithoutIndex); if (!indexOpt.has_value()) { - return _IconSourceMUX(iconPath, monochrome, targetSize); + return _IconSourceMUX(iconPath, fontFamily, targetSize); } const auto bitmapSource = _getImageIconSourceForBinary(iconPathWithoutIndex, indexOpt.value(), targetSize); @@ -363,13 +382,14 @@ namespace winrt::Microsoft::Terminal::UI::implementation Microsoft::UI::Xaml::Controls::IconElement IconPathConverter::IconMUX(const winrt::hstring& iconPath) { return IconMUX(iconPath, 24); } + Microsoft::UI::Xaml::Controls::IconElement IconPathConverter::IconMUX(const winrt::hstring& iconPath, const int targetSize) { std::wstring_view iconPathWithoutIndex; const auto indexOpt = _getIconIndex(iconPath, iconPathWithoutIndex); if (!indexOpt.has_value()) { - auto source = IconSourceMUX(iconPath, false, targetSize); + auto source = IconSourceMUX(iconPath, L"", targetSize); Microsoft::UI::Xaml::Controls::IconSourceElement icon; icon.IconSource(source); return icon; @@ -383,4 +403,5 @@ namespace winrt::Microsoft::Terminal::UI::implementation icon.Height(targetSize); return icon; } + } diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.h b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.h index 8c637ef371..de3698a923 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.h +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.h @@ -10,9 +10,10 @@ namespace winrt::Microsoft::Terminal::UI::implementation //static Windows::UI::Xaml::Controls::IconElement IconWUX(const winrt::hstring& iconPath); //static Windows::UI::Xaml::Controls::IconSource IconSourceWUX(const winrt::hstring& iconPath); - static Microsoft::UI::Xaml::Controls::IconSource IconSourceMUX(const winrt::hstring& iconPath, bool convertToGrayscale, const int targetSize=24); + static Microsoft::UI::Xaml::Controls::IconSource IconSourceMUX(const winrt::hstring& iconPath, const winrt::hstring& fontFamily, const int targetSize=24); static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath); static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath, const int targetSize); + }; } diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.idl b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.idl index 5b6f677003..41bde322b9 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.idl +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/IconPathConverter.idl @@ -7,7 +7,7 @@ namespace Microsoft.Terminal.UI { // static Windows.UI.Xaml.Controls.IconElement IconWUX(String path); // static Windows.UI.Xaml.Controls.IconSource IconSourceWUX(String path); - static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, Boolean convertToGrayscale); + static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, String fontFamily, Int32 targetSize); static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path); static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path, Int32 targetSize); }; diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj index 039bf41817..4e06dd60fc 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/Microsoft.Terminal.UI.vcxproj @@ -1,11 +1,16 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> - <PropertyGroup> - <PathToRoot>..\..\..\..\</PathToRoot> - <WasdkNuget>$(PathToRoot)packages\Microsoft.WindowsAppSDK.1.6.250205002</WasdkNuget> + <PropertyGroup Label="NuGet"> + <!-- Tell NuGet this is PackageReference style --> + <RestoreProjectStyle>PackageReference</RestoreProjectStyle> + <!-- Tell NuGet we're a native project --> + <NuGetTargetMoniker>native,Version=v0.0</NuGetTargetMoniker> + <!-- Tell NuGet we target Windows (use your existing WindowsTargetPlatformVersion) --> + <NuGetTargetPlatformIdentifier>Windows</NuGetTargetPlatformIdentifier> + <NuGetTargetPlatformVersion>$(WindowsTargetPlatformVersion)</NuGetTargetPlatformVersion> + </PropertyGroup> + <PropertyGroup> </PropertyGroup> - <Import Project="$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.props" Condition="Exists('$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.props')" /> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -19,13 +24,15 @@ <AppContainerApplication>false</AppContainerApplication> <ApplicationType>Windows Store</ApplicationType> <ApplicationTypeRevision>10.0</ApplicationTypeRevision> - <WindowsTargetPlatformVersion Condition=" '$(WindowsTargetPlatformVersion)' == '' ">10.0.22621.0</WindowsTargetPlatformVersion> + <WindowsTargetPlatformVersion Condition=" '$(WindowsTargetPlatformVersion)' == '' ">10.0.26100.0</WindowsTargetPlatformVersion> <WindowsTargetPlatformMinVersion>10.0.19041.0</WindowsTargetPlatformMinVersion> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <ItemGroup> - <ResourceCompile Include="version.rc" /> + <PackageReference Include="Microsoft.WindowsAppSDK" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.CppWinRT" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.ImplementationLibrary" GeneratePathProperty="true" /> </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|ARM64"> <Configuration>Debug</Configuration> @@ -44,16 +51,10 @@ <Platform>x64</Platform> </ProjectConfiguration> </ItemGroup> - <PropertyGroup> - <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutDir> - <IntDir>obj\$(Platform)\$(Configuration)\</IntDir> - </PropertyGroup> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> - <PlatformToolset Condition="'$(VisualStudioVersion)' == '16.0'">v142</PlatformToolset> - <PlatformToolset Condition="'$(VisualStudioVersion)' == '15.0'">v141</PlatformToolset> - <PlatformToolset Condition="'$(VisualStudioVersion)' == '14.0'">v140</PlatformToolset> + + <CharacterSet>Unicode</CharacterSet> <GenerateManifest>false</GenerateManifest> <DesktopCompatible>true</DesktopCompatible> @@ -156,10 +157,15 @@ <ClInclude Include="IconPathConverter.h"> <DependentUpon>IconPathConverter.idl</DependentUpon> </ClInclude> - <ClInclude Include="resource.h" /> + <ClInclude Include="RunHistory.h"> + <DependentUpon>RunHistory.idl</DependentUpon> + </ClInclude> <ClInclude Include="ResourceString.h"> <DependentUpon>ResourceString.idl</DependentUpon> </ClInclude> + <ClInclude Include="FontIconGlyphClassifier.h"> + <DependentUpon>FontIconGlyphClassifier.idl</DependentUpon> + </ClInclude> </ItemGroup> <ItemGroup> <ClCompile Include="init.cpp" /> @@ -172,42 +178,31 @@ <ClCompile Include="IconPathConverter.cpp"> <DependentUpon>IconPathConverter.idl</DependentUpon> </ClCompile> + <ClCompile Include="RunHistory.cpp"> + <DependentUpon>RunHistory.idl</DependentUpon> + </ClCompile> <ClCompile Include="ResourceString.cpp"> <DependentUpon>ResourceString.idl</DependentUpon> </ClCompile> <ClCompile Include="$(GeneratedFilesDir)module.g.cpp" /> + <ClCompile Include="FontIconGlyphClassifier.cpp"> + <DependentUpon>FontIconGlyphClassifier.idl</DependentUpon> + </ClCompile> </ItemGroup> <ItemGroup> <Midl Include="Converters.idl" /> <Midl Include="IconPathConverter.idl" /> + <Midl Include="RunHistory.idl" /> <Midl Include="IDirectKeyListener.idl" /> <Midl Include="ResourceString.idl" /> + <Midl Include="FontIconGlyphClassifier.idl" /> </ItemGroup> <ItemGroup> - <None Include="packages.config" /> <None Include="Microsoft.Terminal.UI.def" /> </ItemGroup> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\</OutDir> + <IntDir>obj\$(Platform)\$(Configuration)\</IntDir> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <ImportGroup Label="ExtensionTargets"> - <Import Project="$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.targets" Condition="Exists('$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(MSBuildThisFileDirectory)..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - </ImportGroup> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>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}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.props')" Text="$([System.String]::Format('$(ErrorText)', '$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.props'))" /> - <Error Condition="!Exists('$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', 'Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - </Target> - <ItemGroup> - <ProjectReference Include="..\..\..\common\version\version.vcxproj"> - <Project>{cc6e41ac-8174-4e8a-8d22-85dd7f4851df}</Project> - </ProjectReference> - </ItemGroup> </Project> \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/RunHistory.cpp b/src/modules/cmdpal/Microsoft.Terminal.UI/RunHistory.cpp new file mode 100644 index 0000000000..9da950ff9b --- /dev/null +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/RunHistory.cpp @@ -0,0 +1,87 @@ +#include "pch.h" +#include "RunHistory.h" +#include "RunHistory.g.cpp" + + +using namespace winrt::Windows; + +namespace winrt::Microsoft::Terminal::UI::implementation +{ + // Run history + // Largely copied from the Run work circa 2022. + + winrt::Windows::Foundation::Collections::IVector<hstring> RunHistory::CreateRunHistory() + { + // Load MRU history + std::vector<hstring> history; + + wil::unique_hmodule _comctl; + HANDLE(WINAPI* _createMRUList)(MRUINFO* lpmi); + int(WINAPI* _enumMRUList)(HANDLE hMRU,int nItem,void* lpData,UINT uLen); + void(WINAPI *_freeMRUList)(HANDLE hMRU); + int(WINAPI *_addMRUString)(HANDLE hMRU, LPCWSTR szString); + + // Lazy load comctl32.dll + // Theoretically, we could cache this into a magic static, but we shouldn't need to actually do this more than once in CmdPal + _comctl.reset(LoadLibraryExW(L"comctl32.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32)); + + _createMRUList = reinterpret_cast<decltype(_createMRUList)>(GetProcAddress(_comctl.get(), "CreateMRUListW")); + FAIL_FAST_LAST_ERROR_IF(!_createMRUList); + + _enumMRUList = reinterpret_cast<decltype(_enumMRUList)>(GetProcAddress(_comctl.get(), "EnumMRUListW")); + FAIL_FAST_LAST_ERROR_IF(!_enumMRUList); + + _freeMRUList = reinterpret_cast<decltype(_freeMRUList)>(GetProcAddress(_comctl.get(), "FreeMRUList")); + FAIL_FAST_LAST_ERROR_IF(!_freeMRUList); + + _addMRUString = reinterpret_cast<decltype(_addMRUString)>(GetProcAddress(_comctl.get(), "AddMRUStringW")); + FAIL_FAST_LAST_ERROR_IF(!_addMRUString); + + static const WCHAR c_szRunMRU[] = REGSTR_PATH_EXPLORER L"\\RunMRU"; + MRUINFO mi = { + sizeof(mi), + 26, + MRU_CACHEWRITE, + HKEY_CURRENT_USER, + c_szRunMRU, + NULL // NOTE: use default string compare + // since this is a GLOBAL MRU + }; + + if (const auto hMruList = _createMRUList(&mi)) + { + auto freeMRUList = wil::scope_exit([=]() { + _freeMRUList(hMruList); + }); + + for (int nMax = _enumMRUList(hMruList, -1, NULL, 0), i = 0; i < nMax; ++i) + { + WCHAR szCommand[MAX_PATH + 2]; + + const auto length = _enumMRUList(hMruList, i, szCommand, ARRAYSIZE(szCommand)); + if (length > 1) + { + // clip off the null-terminator + std::wstring_view text{ szCommand, wil::safe_cast<size_t>(length - 1) }; +//#pragma disable warning(C26493) +#pragma warning( push ) +#pragma warning( disable : 26493 ) + if (text.back() == L'\\') + { + // old MRU format has a slash at the end with the show cmd + text = { szCommand, wil::safe_cast<size_t>(length - 2) }; +#pragma warning( pop ) + if (text.empty()) + { + continue; + } + } + history.emplace_back(text); + } + } + } + + // Update dropdown & initial value + return winrt::single_threaded_observable_vector<winrt::hstring>(std::move(history)); + } +} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/RunHistory.h b/src/modules/cmdpal/Microsoft.Terminal.UI/RunHistory.h new file mode 100644 index 0000000000..191ddf2aa9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/RunHistory.h @@ -0,0 +1,21 @@ +#pragma once + +#include "RunHistory.g.h" +#include "types.h" + +namespace winrt::Microsoft::Terminal::UI::implementation +{ + struct RunHistory + { + RunHistory() = default; + static winrt::Windows::Foundation::Collections::IVector<hstring> CreateRunHistory(); + + private: + winrt::Windows::Foundation::Collections::IVector<hstring> _mruHistory; + }; +} + +namespace winrt::Microsoft::Terminal::UI::factory_implementation +{ + BASIC_FACTORY(RunHistory); +} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/RunHistory.idl b/src/modules/cmdpal/Microsoft.Terminal.UI/RunHistory.idl new file mode 100644 index 0000000000..c41d6c8112 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/RunHistory.idl @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace Microsoft.Terminal.UI +{ + static runtimeclass RunHistory + { + static Windows.Foundation.Collections.IVector<String> CreateRunHistory(); + }; + +} diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/packages.config b/src/modules/cmdpal/Microsoft.Terminal.UI/packages.config deleted file mode 100644 index a516cb061d..0000000000 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/packages.config +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- The packages.config acts as the global version for all of the NuGet packages contained within. --> -<packages> - <package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> - <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> -</packages> \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/pch.h b/src/modules/cmdpal/Microsoft.Terminal.UI/pch.h index f87ee3dbdd..9647d69fcc 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/pch.h +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/pch.h @@ -64,6 +64,8 @@ // WIL #include <wil/com.h> +#include <wil/resource.h> +#include <wil/safecast.h> #include <wil/stl.h> #include <wil/filesystem.h> // Due to the use of RESOURCE_SUPPRESS_STL in result.h, we need to include resource.h first, which happens @@ -90,6 +92,7 @@ #include <winrt/Windows.ApplicationModel.Resources.h> #include <winrt/Windows.Foundation.h> +#include <winrt/Windows.Foundation.Collections.h> #include <winrt/Windows.Graphics.Imaging.h> #include <Windows.Graphics.Imaging.Interop.h> diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/types.h b/src/modules/cmdpal/Microsoft.Terminal.UI/types.h new file mode 100644 index 0000000000..b991cabb80 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.Terminal.UI/types.h @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#pragma once + +#define MRU_CACHEWRITE 0x0002 +#define REGSTR_PATH_EXPLORER TEXT("Software\\Microsoft\\Windows\\CurrentVersion\\Explorer") + +// https://learn.microsoft.com/en-us/windows/win32/shell/mrucmpproc +typedef int(CALLBACK* MRUCMPPROC)( + LPCTSTR pString1, + LPCTSTR pString2); + +// https://learn.microsoft.com/en-us/windows/win32/shell/mruinfo +struct MRUINFO +{ + DWORD cbSize; + UINT uMax; + UINT fFlags; + HKEY hKey; + LPCTSTR lpszSubKey; + MRUCMPPROC lpfnCompare; +}; diff --git a/src/modules/cmdpal/README.md b/src/modules/cmdpal/README.md index 83f37f0c16..65d4bc3bb4 100644 --- a/src/modules/cmdpal/README.md +++ b/src/modules/cmdpal/README.md @@ -1,17 +1,16 @@ # ![cmdpal logo](./Microsoft.CmdPal.UI/Assets/Stable/StoreLogo.scale-100.png) Command Palette -Windows Command Palette ("CmdPal") is the next iteration of PowerToys Run. With extensibility at it's core, the Command Palette is your one-stop launcher to start _anything_. - -By default, CmdPal is bound to <kbd>Win+Alt+Space</kbd>. +Windows Command Palette ("CmdPal") is the next iteration of PowerToys Run. With extensibility at its core, the Command Palette is your one-stop launcher to start _anything_. +By default, CmdPal is bound to <kbd>Win+Alt+Space</kbd>. ## Creating an extension -The fastest way to get started is just to run the "Create extension" command in the palette itself. That'll prompt you for a project name and a Display Name, and where you want to place your project. Then just open the `sln` it produces. You should be ready to go 🙂. +The fastest way to get started is just to run the "Create extension" command in the palette itself. That'll prompt you for a project name and a Display Name, and where you want to place your project. Then just open the `sln` it produces. You should be ready to go 🙂. -The official API documentation can be found [on this docs site](TODO! Add docs link when we have one) +The official API documentation can be found [on this docs site](https://learn.microsoft.com/windows/powertoys/command-palette/extensibility-overview). -We've also got samples, so that you can see how the APIs in-action. +We've also got samples, so that you can see how the APIs in-action. * We've got [generic samples] in the repo * We've got [real samples] in the repo too @@ -22,14 +21,22 @@ We've also got samples, so that you can see how the APIs in-action. ## Building CmdPal -The Command Palette is included as a part of PowerToys. To get started building, open up the root `PowerToys.sln`, to get started building. +### Install & Build PowerToys + +1. Follow the install and build instructions for [PowerToys](https://github.com/microsoft/PowerToys/tree/main/doc/devdocs#compiling-powertoys) + +### Load & Build + +1. In Visual Studio, in the Solution Explorer Pane, confirm that all of the files/projects in `src\modules\CommandPalette` and `src\common\CalculatorEngineCommon` do not have `(unloaded)` on the right side + 1. If any file has `(unloaded)`, right click on file and select `Reload Project` +1. Now you can right click on one of the project below to `Build` and then `Deploy`: Projects of interest are: * `Microsoft.CmdPal.UI`: This is the main project for CmdPal. Build and run this to get the CmdPal. * `Microsoft.CommandPalette.Extensions`: This is the official extension interface. * This is designed to be language-agnostic. Any programming language which supports implementing WinRT interfaces should be able to implement the WinRT interface. * `Microsoft.CommandPalette.Extensions.Toolkit`: This is a C# helper library for creating extensions. This makes writing extensions easier. -* Everything under "SampleExtensions": These are example plugins to demo how to author extensions. Deploy any number of these, to get a feel for how the extension API works. +* Everything under "SampleExtensions": These are example plugins to demo how to author extensions. Deploy any number of these, to get a feel for how the extension API works. ### Footnotes and other links @@ -39,8 +46,8 @@ Projects of interest are: [Initial SDK Spec]: ./doc/initial-sdk-spec/initial-sdk-spec.md -[generic samples]: ./Exts/SamplePagesExtension -[real samples]: .Exts/ProcessMonitorExtension +[generic samples]: ./ext/SamplePagesExtension +[real samples]: ./ext/ProcessMonitorExtension [real extensions that we've "shipped" already]: https://github.com/zadjii/CmdPalExtensions/blob/main/src/extensions diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/GlobalUsings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000000..022cf98f31 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/GlobalUsings.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +global using System; +global using System.Collections.Generic; +global using System.Diagnostics.CodeAnalysis; +global using System.Linq; +global using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj new file mode 100644 index 0000000000..3718a02b32 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Common.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <Nullable>enable</Nullable> + <LangVersion>preview</LangVersion> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ConnectionStringRuleProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ConnectionStringRuleProviderTests.cs new file mode 100644 index 0000000000..dadfa63669 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ConnectionStringRuleProviderTests.cs @@ -0,0 +1,107 @@ +// 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.Common.UnitTests.TestUtils; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer; + +[TestClass] +public class ConnectionStringRuleProviderTests +{ + [TestMethod] + public void GetRules_ShouldReturnExpectedRules() + { + // Arrange + var provider = new ConnectionStringRuleProvider(); + + // Act + var rules = provider.GetRules(); + + // Assert + var ruleList = new List<SanitizationRule>(rules); + Assert.AreEqual(1, ruleList.Count); + Assert.AreEqual("Connection string parameters", ruleList[0].Description); + } + + [DataTestMethod] + [DataRow("Server=localhost;Database=mydb;User ID=admin;Password=secret123", "Server=[REDACTED];Database=[REDACTED];User ID=[REDACTED];Password=[REDACTED]")] + [DataRow("Data Source=server.example.com;Initial Catalog=testdb;Uid=user;Pwd=pass", "Data Source=[REDACTED];Initial Catalog=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED]")] + [DataRow("Server=localhost;Password=my_secret", "Server=[REDACTED];Password=[REDACTED]")] + [DataRow("No connection string here", "No connection string here")] + public void ConnectionStringRules_ShouldMaskConnectionStringParameters(string input, string expected) + { + // Arrange + var provider = new ConnectionStringRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("Password=\"complexPassword123!\"", "Password=[REDACTED]")] + [DataRow("Password='myPassword'", "Password=[REDACTED]")] + [DataRow("Password=unquotedSecret", "Password=[REDACTED]")] + public void ConnectionStringRules_ShouldHandleQuotedAndUnquotedValues(string input, string expected) + { + // Arrange + var provider = new ConnectionStringRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("SERVER=server1;PASSWORD=pass1", "SERVER=[REDACTED];PASSWORD=[REDACTED]")] + [DataRow("server=server1;password=pass1", "server=[REDACTED];password=[REDACTED]")] + [DataRow("Server=server1;Password=pass1", "Server=[REDACTED];Password=[REDACTED]")] + public void ConnectionStringRules_ShouldBeCaseInsensitive(string input, string expected) + { + // Arrange + var provider = new ConnectionStringRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("User ID=admin;Username=john;Password=secret", "User ID=[REDACTED];Username=[REDACTED];Password=[REDACTED]")] + [DataRow("Database=mydb;Uid=user1;Pwd=pass1;Server=localhost", "Database=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED];Server=[REDACTED]")] + public void ConnectionStringRules_ShouldHandleMultipleParameters(string input, string expected) + { + // Arrange + var provider = new ConnectionStringRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("Server = localhost ; Password = secret123", "Server=[REDACTED] ; Password=[REDACTED]")] + [DataRow("Initial Catalog=db; User ID=admin; Password=pass", "Initial Catalog=[REDACTED]; User ID=[REDACTED]; Password=[REDACTED]")] + public void ConnectionStringRules_ShouldHandleWhitespace(string input, string expected) + { + // Arrange + var provider = new ConnectionStringRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs new file mode 100644 index 0000000000..6d27172fa2 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer; + +public partial class ErrorReportSanitizerTests +{ + private static class TestData + { + internal static string Input => + $""" + HRESULT: 0x80004005 + HRESULT: -2147467259 + + Here is e-mail address <jane.doe@contoso.com> + 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)}\RandomFolder + 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 + """; + + public const string Expected = + $""" + HRESULT: 0x80004005 + HRESULT: -2147467259 + + Here is e-mail address <[EMAIL_REDACTED]> + IPv4 address: [IP4_REDACTED] + IPv4 loopback address: [IP4_REDACTED] + MAC address: [MAC_ADDRESS_REDACTED] + IPv6 address: [IP6_REDACTED] + IPv6 loopback address: [IP6_REDACTED] + Password: [REDACTED] + Password= [REDACTED] + Api key: [REDACTED] + PostgreSQL connection string: [REDACTED] + InstrumentationKey= [REDACTED] + X-API-key: [REDACTED] + Pet-Shop-Subscription-Key: [REDACTED] + Here is a user name [USERNAME_REDACTED] + And here is a profile path [USER_PROFILE_DIR]RandomFolder + Here is a local app data path [LOCALAPPLICATIONDATA_DIR]Microsoft\PowerToys\CmdPal + Here is machine name [MACHINE_NAME_REDACTED] + JWT token: [REDACTED] + User email [EMAIL_REDACTED] failed validation + File not found: [MYDOCUMENTS_DIR]se****.txt + Connection string: [REDACTED] ID=[REDACTED];Password= [REDACTED] + Phone number [PHONE_REDACTED] is invalid + API key [TOKEN_REDACTED] expired + Failed to connect to [URL_REDACTED] + Error accessing [URL_REDACTED] + JDBC connection failed: [URL_REDACTED] + FTP upload error: [URL_REDACTED] + Email service error: mailto:[EMAIL_REDACTED]?subject=Alert + """; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs new file mode 100644 index 0000000000..1ab57acd2e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.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 Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer; + +[TestClass] +public partial class ErrorReportSanitizerTests +{ + [TestMethod] + public void Sanitize_ShouldMaskPiiInErrorReport() + { + // Arrange + var reportSanitizer = new ErrorReportSanitizer(); + var input = TestData.Input; + + // Act + var result = reportSanitizer.Sanitize(input); + + // Assert + Assert.AreEqual(TestData.Expected, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs new file mode 100644 index 0000000000..ac490f5a6b --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Common.UnitTests.TestUtils; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer; + +[TestClass] +public class PiiRuleProviderTests +{ + [TestMethod] + public void GetRules_ShouldReturnExpectedRules() + { + // Arrange + var provider = new PiiRuleProvider(); + + // Act + var rules = provider.GetRules(); + + // Assert + var ruleList = new List<SanitizationRule>(rules); + Assert.AreEqual(4, ruleList.Count); + Assert.AreEqual("Email addresses", ruleList[0].Description); + Assert.AreEqual("Social Security Numbers", ruleList[1].Description); + Assert.AreEqual("Credit card numbers", ruleList[2].Description); + Assert.AreEqual("Phone numbers", ruleList[3].Description); + } + + [DataTestMethod] + [DataRow("Contact me at john.doe@contoso.com", "Contact me at [EMAIL_REDACTED]")] + [DataRow("Contact me at a_b-c%2@foo-bar.example.co.uk", "Contact me at [EMAIL_REDACTED]")] + [DataRow("My email is john@sub-domain.contoso.com.", "My email is [EMAIL_REDACTED].")] + [DataRow("Two: a@b.com and c@d.org", "Two: [EMAIL_REDACTED] and [EMAIL_REDACTED]")] + [DataRow("No email here", "No email here")] + public void EmailRules_ShouldMaskEmailAddresses(string input, string expected) + { + // Arrange + var provider = new PiiRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("Call me at 123-456-7890", "Call me at [PHONE_REDACTED]")] + [DataRow("My number is (123) 456-7890.", "My number is [PHONE_REDACTED].")] + [DataRow("Office: +1 123 456 7890", "Office: [PHONE_REDACTED]")] + [DataRow("Two numbers: 123-456-7890 and +420 777123456", "Two numbers: [PHONE_REDACTED] and [PHONE_REDACTED]")] + [DataRow("Czech phone +420 777 123 456", "Czech phone [PHONE_REDACTED]")] + [DataRow("Slovak phone +421 777 12 34 56", "Slovak phone [PHONE_REDACTED]")] + [DataRow("No phone number here", "No phone number here")] + public void PhoneRules_ShouldMaskPhoneNumbers(string input, string expected) + { + // Arrange + var provider = new PiiRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("My SSN is 123-45-6789", "My SSN is [SSN_REDACTED]")] + [DataRow("No SSN here", "No SSN here")] + public void SsnRules_ShouldMaskSsn(string input, string expected) + { + // Arrange + var provider = new PiiRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("My credit card number is 1234-5678-9012-3456", "My credit card number is [CARD_REDACTED]")] + [DataRow("My credit card number is 1234567890123456", "My credit card number is [CARD_REDACTED]")] + [DataRow("No credit card here", "No credit card here")] + public void CreditCardRules_ShouldMaskCreditCardNumbers(string input, string expected) + { + // Arrange + var provider = new PiiRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("Error code: 0x80070005", "Error code: 0x80070005")] + [DataRow("Error code: -2147467262", "Error code: -2147467262")] + [DataRow("GUID: 123e4567-e89b-12d3-a456-426614174000", "GUID: 123e4567-e89b-12d3-a456-426614174000")] + [DataRow("Timestamp: 2023-10-05T14:32:10Z", "Timestamp: 2023-10-05T14:32:10Z")] + [DataRow("Version: 1.2.3", "Version: 1.2.3")] + [DataRow("Version: 10.0.22631.3448", "Version: 10.0.22631.3448")] + [DataRow("MAC: 00:1A:2B:3C:4D:5E", "MAC: 00:1A:2B:3C:4D:5E")] + [DataRow("Date: 2023-10-05", "Date: 2023-10-05")] + [DataRow("Date: 05/10/2023", "Date: 05/10/2023")] + public void PiiRuleProvider_ShouldNotOverRedact(string input, string expected) + { + // Arrange + var provider = new PiiRuleProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/SecretKeyValueRulesProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/SecretKeyValueRulesProviderTests.cs new file mode 100644 index 0000000000..7cfb75fff0 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/SecretKeyValueRulesProviderTests.cs @@ -0,0 +1,266 @@ +// 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.Common.UnitTests.TestUtils; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer; + +[TestClass] +public class SecretKeyValueRulesProviderTests +{ + [TestMethod] + public void GetRules_ShouldReturnExpectedRules() + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var rules = provider.GetRules(); + + // Assert + var ruleList = new List<SanitizationRule>(rules); + Assert.AreEqual(1, ruleList.Count); + Assert.AreEqual("Sensitive key/value pairs", ruleList[0].Description); + } + + [DataTestMethod] + [DataRow("password=secret123", "password= [REDACTED]")] + [DataRow("passphrase=myPassphrase", "passphrase= [REDACTED]")] + [DataRow("pwd=test", "pwd= [REDACTED]")] + [DataRow("passwd=pass1234", "passwd= [REDACTED]")] + public void SecretKeyValueRules_ShouldMaskPasswordSecrets(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("token=abc123def456", "token= [REDACTED]")] + [DataRow("access_token=token_value", "access_token= [REDACTED]")] + [DataRow("refresh-token=refresh_value", "refresh-token= [REDACTED]")] + [DataRow("id token=id_token_value", "id token= [REDACTED]")] + [DataRow("bearer token=bearer_value", "bearer token= [REDACTED]")] + [DataRow("session token=session_value", "session token= [REDACTED]")] + public void SecretKeyValueRules_ShouldMaskTokens(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("api key=my_api_key", "api key= [REDACTED]")] + [DataRow("api-key=key123", "api-key= [REDACTED]")] + [DataRow("api_key=secret_key", "api_key= [REDACTED]")] + [DataRow("x-api-key=api123", "x-api-key= [REDACTED]")] + [DataRow("x api key=key456", "x api key= [REDACTED]")] + [DataRow("client id=client123", "client id= [REDACTED]")] + [DataRow("client-secret=secret123", "client-secret= [REDACTED]")] + [DataRow("consumer secret=secret456", "consumer secret= [REDACTED]")] + public void SecretKeyValueRules_ShouldMaskApiCredentials(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("subscription key=sub_key_123", "subscription key= [REDACTED]")] + [DataRow("instrumentation key=instr_key", "instrumentation key= [REDACTED]")] + [DataRow("account key=account123", "account key= [REDACTED]")] + [DataRow("storage account key=storage_key", "storage account key= [REDACTED]")] + [DataRow("shared access key=sak123", "shared access key= [REDACTED]")] + [DataRow("SAS token=sas123", "SAS token= [REDACTED]")] + public void SecretKeyValueRules_ShouldMaskCloudPlatformKeys(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("connection string=Server=localhost;Pwd=pass", "connection string= [REDACTED]")] + [DataRow("conn string=conn_value", "conn string= [REDACTED]")] + [DataRow("storage connection string=connection_value", "storage connection string= [REDACTED]")] + public void SecretKeyValueRules_ShouldMaskConnectionStrings(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("private key=pk123", "private key= [REDACTED]")] + [DataRow("certificate password=cert_pass", "certificate password= [REDACTED]")] + [DataRow("client certificate password=cert123", "client certificate password= [REDACTED]")] + [DataRow("pfx password=pfx_pass", "pfx password= [REDACTED]")] + public void SecretKeyValueRules_ShouldMaskCertificateSecrets(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("aws access key id=AKIAIOSFODNN7EXAMPLE", "aws access key id= [REDACTED]")] + [DataRow("aws secret access key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "aws secret access key= [REDACTED]")] + [DataRow("aws session token=session_token_value", "aws session token= [REDACTED]")] + public void SecretKeyValueRules_ShouldMaskAwsKeys(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("password=\"complexPassword123!\"", "password= \"[REDACTED]\"")] + [DataRow("api-key='secret-key'", "api-key= '[REDACTED]'")] + [DataRow("token=\"bearer_token_value\"", "token= \"[REDACTED]\"")] + public void SecretKeyValueRules_ShouldPreserveQuotesAroundRedactedValue(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("PASSWORD=secret", "PASSWORD= [REDACTED]")] + [DataRow("Api-Key=key123", "Api-Key= [REDACTED]")] + [DataRow("CLIENT_ID=client123", "CLIENT_ID= [REDACTED]")] + [DataRow("Pwd=pass123", "Pwd= [REDACTED]")] + public void SecretKeyValueRules_ShouldBeCaseInsensitive(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("regularKey=regularValue", "regularKey=regularValue")] + [DataRow("config=myConfig", "config=myConfig")] + [DataRow("hostname=server.example.com", "hostname=server.example.com")] + [DataRow("port=8080", "port=8080")] + public void SecretKeyValueRules_ShouldNotRedactNonSecretKeyValuePairs(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("password:secret123", "password: [REDACTED]")] + [DataRow("api key:api_key_value", "api key: [REDACTED]")] + [DataRow("client_secret:secret_value", "client_secret: [REDACTED]")] + public void SecretKeyValueRules_ShouldSupportColonSeparator(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("password = secret123", "password= [REDACTED]")] + [DataRow("api key = api_key_value", "api key= [REDACTED]")] + [DataRow("token : token_value", "token: [REDACTED]")] + public void SecretKeyValueRules_ShouldHandleWhitespace(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("password=secret API_KEY=key config=myConfig", "password= [REDACTED] API_KEY= [REDACTED] config=myConfig")] + [DataRow("client_id=id123 name=admin pwd=pass123", "client_id= [REDACTED] name=admin pwd= [REDACTED]")] + public void SecretKeyValueRules_ShouldHandleMultipleKeyValuePairsInSingleString(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("cosmos db key=cosmos_key", "cosmos db key= [REDACTED]")] + [DataRow("service principal secret=sp_secret", "service principal secret= [REDACTED]")] + [DataRow("shared access signature=sas_signature", "shared access signature= [REDACTED]")] + public void SecretKeyValueRules_ShouldMaskServiceSpecificSecrets(string input, string expected) + { + // Arrange + var provider = new SecretKeyValueRulesProvider(); + + // Act + var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules()); + + // Assert + Assert.AreEqual(expected, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/TestUtils/SanitizerTestHelper.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/TestUtils/SanitizerTestHelper.cs new file mode 100644 index 0000000000..d800874252 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/TestUtils/SanitizerTestHelper.cs @@ -0,0 +1,110 @@ +// 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.Common.UnitTests.TestUtils; + +/// <summary> +/// Test-only helpers for applying SanitizationRule sets without relying on production ITextSanitizer implementation. +/// </summary> +public static class SanitizerTestHelper +{ + /// <summary> + /// Applies the provided rules to the input, in order, mimicking the production sanitizer behavior closely + /// but without any external dependencies. + /// </summary> + public static string ApplyRules(string? input, IEnumerable<SanitizationRule> rules) + { + 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 ?? string.Empty) + : rule.Regex.Replace(previous, rule.Evaluator); + + // Guardrail to avoid accidental mass-redaction from a faulty rule + if (result.Length < previous.Length * 0.3) + { + result = previous; + } + } + catch (RegexMatchTimeoutException) + { + // Ignore timeouts in tests + } + } + + return result; + } + + /// <summary> + /// Creates a lightweight sanitizer instance backed by the given rules. + /// Useful when a component expects an ITextSanitizer, but you want deterministic behavior in tests. + /// </summary> + public static ITextSanitizer CreateSanitizer(IEnumerable<SanitizationRule> rules) + => new InlineSanitizer(rules); + + private sealed class InlineSanitizer : ITextSanitizer + { + private readonly List<SanitizationRule> _rules; + + public InlineSanitizer(IEnumerable<SanitizationRule> rules) + { + _rules = rules?.ToList() ?? []; + } + + public string Sanitize(string? input) => ApplyRules(input, _rules); + + public void AddRule(string pattern, string replacement, string description = "") + { + var rx = new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant); + _rules.Add(new SanitizationRule(rx, replacement, description)); + } + + public void RemoveRule(string description) + { + _rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase)); + } + + public IReadOnlyList<SanitizationRule> 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 for test determinism + } + + return input; + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherEmojiTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherEmojiTests.cs new file mode 100644 index 0000000000..fc85834b2e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherEmojiTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Text; + +namespace Microsoft.CmdPal.Common.UnitTests.Text; + +[TestClass] +public sealed class PrecomputedFuzzyMatcherEmojiTests +{ + private readonly PrecomputedFuzzyMatcher _matcher = new(); + + [TestMethod] + public void ExactMatch_SimpleEmoji_ReturnsScore() + { + const string needle = "🚀"; + const string haystack = "Launch 🚀 sequence"; + + var query = _matcher.PrecomputeQuery(needle); + var target = _matcher.PrecomputeTarget(haystack); + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected match for simple emoji"); + } + + [TestMethod] + public void ExactMatch_SkinTone_ReturnsScore() + { + const string needle = "👍🏽"; // Medium skin tone + const string haystack = "Thumbs up 👍🏽 here"; + + var query = _matcher.PrecomputeQuery(needle); + var target = _matcher.PrecomputeTarget(haystack); + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected match for emoji with skin tone"); + } + + [TestMethod] + public void ZWJSequence_Family_Match() + { + const string needle = "👨‍👩‍👧‍👦"; // Family: Man, Woman, Girl, Boy + const string haystack = "Emoji 👨‍👩‍👧‍👦 Test"; + + var query = _matcher.PrecomputeQuery(needle); + var target = _matcher.PrecomputeTarget(haystack); + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected match for ZWJ sequence"); + } + + [TestMethod] + public void Flags_Match() + { + const string needle = "🇺🇸"; // US Flag (Regional Indicator U + Regional Indicator S) + const string haystack = "USA 🇺🇸"; + + var query = _matcher.PrecomputeQuery(needle); + var target = _matcher.PrecomputeTarget(haystack); + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected match for flag emoji"); + } + + [TestMethod] + public void Emoji_MixedWithText_Search() + { + const string needle = "t🌮o"; // "t" + taco + "o" + const string haystack = "taco 🌮 on tuesday"; + + var query = _matcher.PrecomputeQuery(needle); + var target = _matcher.PrecomputeTarget(haystack); + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected match for emoji mixed with text"); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherOptionsTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherOptionsTests.cs new file mode 100644 index 0000000000..b5798986ff --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherOptionsTests.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 Microsoft.CmdPal.Core.Common.Text; + +namespace Microsoft.CmdPal.Common.UnitTests.Text; + +[TestClass] +public sealed class PrecomputedFuzzyMatcherOptionsTests +{ + [TestMethod] + public void Score_RemoveDiacriticsOption_AffectsMatching() + { + var withDiacriticsRemoved = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true }); + var withoutDiacriticsRemoved = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = false }); + + const string needle = "cafe"; + const string haystack = "CAFÉ"; + + var scoreWithRemoval = withDiacriticsRemoved.Score( + withDiacriticsRemoved.PrecomputeQuery(needle), + withDiacriticsRemoved.PrecomputeTarget(haystack)); + var scoreWithoutRemoval = withoutDiacriticsRemoved.Score( + withoutDiacriticsRemoved.PrecomputeQuery(needle), + withoutDiacriticsRemoved.PrecomputeTarget(haystack)); + + Assert.IsTrue(scoreWithRemoval > 0, "Expected match when diacritics are removed."); + Assert.AreEqual(0, scoreWithoutRemoval, "Expected no match when diacritics are preserved."); + } + + [TestMethod] + public void Score_SkipWordSeparatorsOption_AffectsMatching() + { + var skipSeparators = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions { SkipWordSeparators = true }); + var keepSeparators = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions { SkipWordSeparators = false }); + + const string needle = "a b"; + const string haystack = "ab"; + + var scoreSkip = skipSeparators.Score( + skipSeparators.PrecomputeQuery(needle), + skipSeparators.PrecomputeTarget(haystack)); + var scoreKeep = keepSeparators.Score( + keepSeparators.PrecomputeQuery(needle), + keepSeparators.PrecomputeTarget(haystack)); + + Assert.IsTrue(scoreSkip > 0, "Expected match when word separators are skipped."); + Assert.AreEqual(0, scoreKeep, "Expected no match when word separators are preserved."); + } + + [TestMethod] + public void Score_IgnoreSameCaseBonusOption_AffectsLowercaseQuery() + { + var ignoreSameCase = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions + { + IgnoreSameCaseBonusIfQueryIsAllLowercase = true, + SameCaseBonus = 10, + }); + var applySameCase = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions + { + IgnoreSameCaseBonusIfQueryIsAllLowercase = false, + SameCaseBonus = 10, + }); + + const string needle = "test"; + const string haystack = "test"; + + var scoreIgnore = ignoreSameCase.Score( + ignoreSameCase.PrecomputeQuery(needle), + ignoreSameCase.PrecomputeTarget(haystack)); + var scoreApply = applySameCase.Score( + applySameCase.PrecomputeQuery(needle), + applySameCase.PrecomputeTarget(haystack)); + + Assert.IsTrue(scoreApply > scoreIgnore, "Expected same-case bonus to apply when not ignored."); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherSecondaryInputTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherSecondaryInputTests.cs new file mode 100644 index 0000000000..70c86a4598 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherSecondaryInputTests.cs @@ -0,0 +1,227 @@ +// 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.Text; + +namespace Microsoft.CmdPal.Common.UnitTests.Text; + +[TestClass] +public sealed class PrecomputedFuzzyMatcherSecondaryInputTests +{ + private readonly PrecomputedFuzzyMatcher _matcher = new(); + private readonly StringFolder _folder = new(); + private readonly BloomFilter _bloom = new(); + + [TestMethod] + public void Score_PrimaryQueryMatchesSecondaryTarget_ShouldMatch() + { + // Scenario: Searching for "calc" should match a file "calculator.exe" where primary is filename, secondary is path + var query = CreateQuery("calc"); + var target = CreateTarget(primary: "important.txt", secondary: "C:\\Programs\\Calculator\\"); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected primary query to match secondary target"); + } + + [TestMethod] + public void Score_SecondaryQueryMatchesPrimaryTarget_ShouldMatch() + { + // Scenario: User types "documents\\report" and we want to match against filename + var query = CreateQuery(primary: "documents", secondary: "report"); + var target = CreateTarget(primary: "report.docx"); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected secondary query to match primary target"); + } + + [TestMethod] + public void Score_SecondaryQueryMatchesSecondaryTarget_ShouldMatch() + { + // Scenario: Both query and target have secondary info that matches + var query = CreateQuery(primary: "test", secondary: "documents"); + var target = CreateTarget(primary: "something.txt", secondary: "C:\\Users\\Documents\\"); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected secondary query to match secondary target"); + } + + [TestMethod] + public void Score_PrimaryQueryMatchesBothTargets_ShouldReturnBestScore() + { + // The same query matches both primary and secondary of target + var query = CreateQuery("test"); + var target = CreateTarget(primary: "test.txt", secondary: "test_folder\\"); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected query to match when it appears in both primary and secondary"); + } + + [TestMethod] + public void Score_NoSecondaryInQuery_MatchesSecondaryTarget() + { + // Query without secondary can still match target's secondary + var query = CreateQuery("downloads"); + var target = CreateTarget(primary: "file.txt", secondary: "C:\\Downloads\\"); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected primary query to match secondary target"); + } + + [TestMethod] + public void Score_NoSecondaryInTarget_SecondaryQueryShouldNotMatch() + { + // Query with secondary but target without secondary - secondary query shouldn't interfere + var query = CreateQuery(primary: "test", secondary: "extra"); + var target = CreateTarget(primary: "test.txt"); + + var score = _matcher.Score(query, target); + + // Primary should still match, secondary query just doesn't contribute + Assert.IsTrue(score > 0, "Expected primary query to match primary target"); + } + + [TestMethod] + public void Score_SecondaryQueryNoMatch_PrimaryCanStillMatch() + { + // Secondary doesn't match anything, but primary does + var query = CreateQuery(primary: "file", secondary: "nomatch"); + var target = CreateTarget(primary: "myfile.txt", secondary: "C:\\Documents\\"); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected primary query to match even when secondary doesn't"); + } + + [TestMethod] + public void Score_OnlySecondaryMatches_ShouldReturnScore() + { + // Only the secondary parts match, primary doesn't + var query = CreateQuery(primary: "xyz", secondary: "documents"); + var target = CreateTarget(primary: "abc.txt", secondary: "C:\\Users\\Documents\\"); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected match when only secondary parts match"); + } + + [TestMethod] + public void Score_BothQueriesMatchDifferentTargets_ShouldReturnBestScore() + { + // Primary query matches secondary target, secondary query matches primary target + var query = CreateQuery(primary: "docs", secondary: "report"); + var target = CreateTarget(primary: "report.pdf", secondary: "C:\\Documents\\"); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected match when queries cross-match with targets"); + } + + [TestMethod] + public void Score_CompletelyDifferent_ShouldNotMatch() + { + var query = CreateQuery(primary: "xyz", secondary: "abc"); + var target = CreateTarget(primary: "hello", secondary: "world"); + + var score = _matcher.Score(query, target); + + Assert.AreEqual(0, score, "Expected no match when nothing matches"); + } + + [TestMethod] + public void Score_EmptySecondaryInputs_ShouldMatchOnPrimary() + { + var query = CreateQuery(primary: "test", secondary: string.Empty); + var target = CreateTarget(primary: "test.txt", secondary: string.Empty); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected match on primary when secondaries are empty"); + } + + [TestMethod] + public void Score_WordSeparatorMatching_AcrossSecondary() + { + // Test that "Power Point" matches "PowerPoint" using secondary + var query = CreateQuery(primary: "power", secondary: "point"); + var target = CreateTarget(primary: "PowerPoint.exe"); + + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected 'power' + 'point' to match 'PowerPoint'"); + } + + private FuzzyQuery CreateQuery(string primary, string? secondary = null) + { + var primaryFolded = _folder.Fold(primary, removeDiacritics: true); + var primaryBloom = _bloom.Compute(primaryFolded); + var primaryEffectiveLength = primaryFolded.Length; + var primaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(primary); + + string? secondaryFolded = null; + ulong secondaryBloom = 0; + var secondaryEffectiveLength = 0; + var secondaryIsAllLowercase = true; + + if (!string.IsNullOrEmpty(secondary)) + { + secondaryFolded = _folder.Fold(secondary, removeDiacritics: true); + secondaryBloom = _bloom.Compute(secondaryFolded); + secondaryEffectiveLength = secondaryFolded.Length; + secondaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(secondary); + } + + return new FuzzyQuery( + original: primary, + folded: primaryFolded, + bloom: primaryBloom, + effectiveLength: primaryEffectiveLength, + isAllLowercaseAsciiOrNonLetter: primaryIsAllLowercase, + secondaryOriginal: secondary, + secondaryFolded: secondaryFolded, + secondaryBloom: secondaryBloom, + secondaryEffectiveLength: secondaryEffectiveLength, + secondaryIsAllLowercaseAsciiOrNonLetter: secondaryIsAllLowercase); + } + + private FuzzyTarget CreateTarget(string primary, string? secondary = null) + { + var primaryFolded = _folder.Fold(primary, removeDiacritics: true); + var primaryBloom = _bloom.Compute(primaryFolded); + + string? secondaryFolded = null; + ulong secondaryBloom = 0; + + if (!string.IsNullOrEmpty(secondary)) + { + secondaryFolded = _folder.Fold(secondary, removeDiacritics: true); + secondaryBloom = _bloom.Compute(secondaryFolded); + } + + return new FuzzyTarget( + original: primary, + folded: primaryFolded, + bloom: primaryBloom, + secondaryOriginal: secondary, + secondaryFolded: secondaryFolded, + secondaryBloom: secondaryBloom); + } + + private static bool IsAllLowercaseAsciiOrNonLetter(string s) + { + foreach (var c in s) + { + if ((uint)(c - 'A') <= ('Z' - 'A')) + { + return false; + } + } + + return true; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherTests.cs new file mode 100644 index 0000000000..bdd3898ac9 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherTests.cs @@ -0,0 +1,209 @@ +// 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.Text; + +namespace Microsoft.CmdPal.Common.UnitTests.Text; + +[TestClass] +public class PrecomputedFuzzyMatcherTests +{ + private readonly PrecomputedFuzzyMatcher _matcher = new(); + + public static IEnumerable<object[]> MatchData => + [ + ["a", "a"], + ["abc", "abc"], + ["a", "ab"], + ["b", "ab"], + ["abc", "axbycz"], + ["pt", "PowerToys"], + ["calc", "Calculator"], + ["vs", "Visual Studio"], + ["code", "Visual Studio Code"], + + // Diacritics + ["abc", "ÁBC"], + + // Separators + ["p/t", "power\\toys"], + ]; + + public static IEnumerable<object[]> NonMatchData => + [ + ["z", "abc"], + ["verylongstring", "short"], + ]; + + [TestMethod] + [DynamicData(nameof(MatchData))] + public void Score_Matches_ShouldHavePositiveScore(string needle, string haystack) + { + var query = _matcher.PrecomputeQuery(needle); + var target = _matcher.PrecomputeTarget(haystack); + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, $"Expected positive score for needle='{needle}', haystack='{haystack}'"); + } + + [TestMethod] + [DynamicData(nameof(NonMatchData))] + public void Score_NonMatches_ShouldHaveZeroScore(string needle, string haystack) + { + var query = _matcher.PrecomputeQuery(needle); + var target = _matcher.PrecomputeTarget(haystack); + var score = _matcher.Score(query, target); + + Assert.AreEqual(0, score, $"Expected 0 score for needle='{needle}', haystack='{haystack}'"); + } + + [TestMethod] + public void Score_EmptyQuery_ReturnsZero() + { + var query = _matcher.PrecomputeQuery(string.Empty); + var target = _matcher.PrecomputeTarget("something"); + Assert.AreEqual(0, _matcher.Score(query, target)); + } + + [TestMethod] + public void Score_EmptyTarget_ReturnsZero() + { + var query = _matcher.PrecomputeQuery("something"); + var target = _matcher.PrecomputeTarget(string.Empty); + Assert.AreEqual(0, _matcher.Score(query, target)); + } + + [TestMethod] + public void SchemaId_DefaultMatcher_IsConsistent() + { + var matcher1 = new PrecomputedFuzzyMatcher(); + var matcher2 = new PrecomputedFuzzyMatcher(); + + Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Default matchers should have the same SchemaId"); + } + + [TestMethod] + public void SchemaId_SameOptions_ProducesSameId() + { + var options = new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true }; + var matcher1 = new PrecomputedFuzzyMatcher(options); + var matcher2 = new PrecomputedFuzzyMatcher(options); + + Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Matchers with same options should have the same SchemaId"); + } + + [TestMethod] + public void SchemaId_DifferentRemoveDiacriticsOption_ProducesDifferentId() + { + var matcherWithDiacriticsRemoval = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true }); + var matcherWithoutDiacriticsRemoval = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = false }); + + Assert.AreNotEqual( + matcherWithDiacriticsRemoval.SchemaId, + matcherWithoutDiacriticsRemoval.SchemaId, + "Different RemoveDiacritics option should produce different SchemaId"); + } + + [TestMethod] + public void SchemaId_ScoringOptionsDoNotAffectId() + { + // SchemaId should only be affected by options that affect folding/bloom, not scoring + var matcher1 = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions { CharMatchBonus = 1, CamelCaseBonus = 2 }); + var matcher2 = new PrecomputedFuzzyMatcher( + new PrecomputedFuzzyMatcherOptions { CharMatchBonus = 100, CamelCaseBonus = 200 }); + + Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Scoring options should not affect SchemaId"); + } + + [TestMethod] + public void Score_WordSeparatorMatching_PowerPoint() + { + // Test that "Power Point" can match "PowerPoint" when word separators are skipped + var query = _matcher.PrecomputeQuery("Power Point"); + var target = _matcher.PrecomputeTarget("PowerPoint"); + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected 'Power Point' to match 'PowerPoint'"); + } + + [TestMethod] + public void Score_WordSeparatorMatching_UnderscoreDash() + { + // Test that different word separators match each other + var query = _matcher.PrecomputeQuery("hello_world"); + var target = _matcher.PrecomputeTarget("hello-world"); + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected 'hello_world' to match 'hello-world'"); + } + + [TestMethod] + public void Score_WordSeparatorMatching_MixedSeparators() + { + // Test multiple different separators + var query = _matcher.PrecomputeQuery("my.file_name"); + var target = _matcher.PrecomputeTarget("my-file.name"); + var score = _matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected mixed separators to match"); + } + + [TestMethod] + public void Score_PrecomputedQueryReuse_ShouldWorkConsistently() + { + // Test that precomputed query can be reused across multiple targets + var query = _matcher.PrecomputeQuery("test"); + var target1 = _matcher.PrecomputeTarget("test123"); + var target2 = _matcher.PrecomputeTarget("mytest"); + var target3 = _matcher.PrecomputeTarget("unrelated"); + + var score1 = _matcher.Score(query, target1); + var score2 = _matcher.Score(query, target2); + var score3 = _matcher.Score(query, target3); + + Assert.IsTrue(score1 > 0, "Expected query to match first target"); + Assert.IsTrue(score2 > 0, "Expected query to match second target"); + Assert.AreEqual(0, score3, "Expected query not to match third target"); + } + + [TestMethod] + public void Score_PrecomputedTargetReuse_ShouldWorkConsistently() + { + // Test that precomputed target can be reused across multiple queries + var target = _matcher.PrecomputeTarget("calculator"); + var query1 = _matcher.PrecomputeQuery("calc"); + var query2 = _matcher.PrecomputeQuery("lator"); + var query3 = _matcher.PrecomputeQuery("xyz"); + + var score1 = _matcher.Score(query1, target); + var score2 = _matcher.Score(query2, target); + var score3 = _matcher.Score(query3, target); + + Assert.IsTrue(score1 > 0, "Expected first query to match target"); + Assert.IsTrue(score2 > 0, "Expected second query to match target"); + Assert.AreEqual(0, score3, "Expected third query not to match target"); + } + + [TestMethod] + public void Score_CaseInsensitiveMatching_Works() + { + // Test various case combinations + var query1 = _matcher.PrecomputeQuery("test"); + var query2 = _matcher.PrecomputeQuery("TEST"); + var query3 = _matcher.PrecomputeQuery("TeSt"); + + var target = _matcher.PrecomputeTarget("TestFile"); + + var score1 = _matcher.Score(query1, target); + var score2 = _matcher.Score(query2, target); + var score3 = _matcher.Score(query3, target); + + Assert.IsTrue(score1 > 0, "Expected lowercase query to match"); + Assert.IsTrue(score2 > 0, "Expected uppercase query to match"); + Assert.IsTrue(score3 > 0, "Expected mixed case query to match"); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherUnicodeTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherUnicodeTests.cs new file mode 100644 index 0000000000..8cdf39bc82 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherUnicodeTests.cs @@ -0,0 +1,124 @@ +// 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.Text; + +namespace Microsoft.CmdPal.Common.UnitTests.Text; + +[TestClass] +public sealed class PrecomputedFuzzyMatcherUnicodeTests +{ + private readonly PrecomputedFuzzyMatcher _defaultMatcher = new(); + + [TestMethod] + public void UnpairedHighSurrogateInNeedle_ShouldNotThrow() + { + const string needle = "\uD83D"; // high surrogate (unpaired) + const string haystack = "abc"; + + var q = _defaultMatcher.PrecomputeQuery(needle); + var t = _defaultMatcher.PrecomputeTarget(haystack); + _ = _defaultMatcher.Score(q, t); + } + + [TestMethod] + public void UnpairedLowSurrogateInNeedle_ShouldNotThrow() + { + const string needle = "\uDC00"; // low surrogate (unpaired) + const string haystack = "abc"; + + var q = _defaultMatcher.PrecomputeQuery(needle); + var t = _defaultMatcher.PrecomputeTarget(haystack); + _ = _defaultMatcher.Score(q, t); + } + + [TestMethod] + public void UnpairedHighSurrogateInHaystack_ShouldNotThrow() + { + const string needle = "a"; + const string haystack = "a\uD83D" + "bc"; // inject unpaired high surrogate + + var q = _defaultMatcher.PrecomputeQuery(needle); + var t = _defaultMatcher.PrecomputeTarget(haystack); + _ = _defaultMatcher.Score(q, t); + } + + [TestMethod] + public void MixedSurrogatesAndMarks_ShouldNotThrow() + { + // "Garbage smoothie": unpaired surrogate + combining mark + emoji surrogate pair + const string needle = "a\uD83D\u0301"; // 'a' + unpaired high surrogate + combining acute + const string haystack = "a\u0301 \U0001F600"; // 'a' + combining acute + space + 😀 (valid pair) + + var q = _defaultMatcher.PrecomputeQuery(needle); + var t = _defaultMatcher.PrecomputeTarget(haystack); + _ = _defaultMatcher.Score(q, t); + } + + [TestMethod] + public void ValidEmojiSurrogatePair_ShouldNotThrow_AndCanMatch() + { + // 😀 U+1F600 encoded as surrogate pair in UTF-16 + const string needle = "\U0001F600"; + const string haystack = "x \U0001F600 y"; + + var q = _defaultMatcher.PrecomputeQuery(needle); + var t = _defaultMatcher.PrecomputeTarget(haystack); + var score = _defaultMatcher.Score(q, t); + + Assert.IsTrue(score > 0, "Expected emoji to produce a match score > 0."); + } + + [TestMethod] + public void RandomUtf16Garbage_ShouldNotThrow() + { + // Deterministic pseudo-random "UTF-16 garbage", including surrogates. + var s1 = MakeDeterministicGarbage(seed: 1234, length: 512); + var s2 = MakeDeterministicGarbage(seed: 5678, length: 1024); + + var q = _defaultMatcher.PrecomputeQuery(s1); + var t = _defaultMatcher.PrecomputeTarget(s2); + _ = _defaultMatcher.Score(q, t); + } + + [TestMethod] + public void HighSurrogateAtEndOfHaystack_ShouldNotThrow() + { + const string needle = "a"; + const string haystack = "abc\uD83D"; // Ends with high surrogate + + var q = _defaultMatcher.PrecomputeQuery(needle); + var t = _defaultMatcher.PrecomputeTarget(haystack); + _ = _defaultMatcher.Score(q, t); + } + + [TestMethod] + public void VeryLongStrings_ShouldNotThrow() + { + var needle = new string('a', 100); + var haystack = new string('b', 10000) + needle + new string('c', 10000); + + var q = _defaultMatcher.PrecomputeQuery(needle); + var t = _defaultMatcher.PrecomputeTarget(haystack); + _ = _defaultMatcher.Score(q, t); + } + + private static string MakeDeterministicGarbage(int seed, int length) + { + // LCG for deterministic generation without Random’s platform/version surprises. + var x = (uint)seed; + var chars = length <= 2048 ? stackalloc char[length] : new char[length]; + + for (var i = 0; i < chars.Length; i++) + { + // LCG: x = (a*x + c) mod 2^32 + x = unchecked((1664525u * x) + 1013904223u); + + // Take top 16 bits as UTF-16 code unit (includes surrogates). + chars[i] = (char)(x >> 16); + } + + return new string(chars); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherWithPinyinTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherWithPinyinTests.cs new file mode 100644 index 0000000000..3e811c050a --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/PrecomputedFuzzyMatcherWithPinyinTests.cs @@ -0,0 +1,117 @@ +// 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 Microsoft.CmdPal.Core.Common.Text; + +namespace Microsoft.CmdPal.Common.UnitTests.Text; + +[TestClass] +public class PrecomputedFuzzyMatcherWithPinyinTests +{ + private PrecomputedFuzzyMatcherWithPinyin CreateMatcher(PinyinMode mode = PinyinMode.On, bool removeApostrophes = true) + { + return new PrecomputedFuzzyMatcherWithPinyin( + new PrecomputedFuzzyMatcherOptions(), + new PinyinFuzzyMatcherOptions { Mode = mode, RemoveApostrophesForQuery = removeApostrophes }, + new StringFolder(), + new BloomFilter()); + } + + [TestMethod] + [DataRow("bj", "北京")] + [DataRow("sh", "上海")] + [DataRow("nihao", "你好")] + [DataRow("beijing", "北京")] + [DataRow("ce", "测试")] + public void Score_PinyinMatches_ShouldHavePositiveScore(string needle, string haystack) + { + var matcher = CreateMatcher(PinyinMode.On); + var query = matcher.PrecomputeQuery(needle); + var target = matcher.PrecomputeTarget(haystack); + var score = matcher.Score(query, target); + + Assert.IsTrue(score > 0, $"Expected positive score for needle='{needle}', haystack='{haystack}'"); + } + + [TestMethod] + public void Score_PinyinOff_ShouldNotMatchPinyin() + { + var matcher = CreateMatcher(PinyinMode.Off); + var needle = "bj"; + var haystack = "北京"; + + var query = matcher.PrecomputeQuery(needle); + var target = matcher.PrecomputeTarget(haystack); + var score = matcher.Score(query, target); + + Assert.AreEqual(0, score, "Pinyin match should be disabled."); + } + + [TestMethod] + public void Score_StandardMatch_WorksWithPinyinMatcher() + { + var matcher = CreateMatcher(PinyinMode.On); + var needle = "abc"; + var haystack = "abc"; + + var query = matcher.PrecomputeQuery(needle); + var target = matcher.PrecomputeTarget(haystack); + var score = matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Standard match should still work."); + } + + [TestMethod] + public void Score_ApostropheRemoval_Works() + { + var matcher = CreateMatcher(PinyinMode.On, removeApostrophes: true); + var needle = "xi'an"; + + // "xi'an" -> "xian" -> matches "西安" (Xi An) + var haystack = "西安"; + + var query = matcher.PrecomputeQuery(needle); + var target = matcher.PrecomputeTarget(haystack); + var score = matcher.Score(query, target); + + Assert.IsTrue(score > 0, "Expected match for 'xi'an' -> '西安' with apostrophe removal."); + } + + [TestMethod] + public void AutoMode_EnablesForChineseCulture() + { + var originalCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("zh-CN"); + var matcher = CreateMatcher(PinyinMode.AutoSimplifiedChineseUi); + + var score = matcher.Score(matcher.PrecomputeQuery("bj"), matcher.PrecomputeTarget("北京")); + Assert.IsTrue(score > 0, "Should match when UI culture is zh-CN"); + } + finally + { + CultureInfo.CurrentUICulture = originalCulture; + } + } + + [TestMethod] + public void AutoMode_DisablesForNonChineseCulture() + { + var originalCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = new CultureInfo("en-US"); + var matcher = CreateMatcher(PinyinMode.AutoSimplifiedChineseUi); + + var score = matcher.Score(matcher.PrecomputeQuery("bj"), matcher.PrecomputeTarget("北京")); + Assert.AreEqual(0, score, "Should NOT match when UI culture is en-US"); + } + finally + { + CultureInfo.CurrentUICulture = originalCulture; + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/StringFolderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/StringFolderTests.cs new file mode 100644 index 0000000000..076636f2fb --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Text/StringFolderTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Text; + +namespace Microsoft.CmdPal.Common.UnitTests.Text; + +[TestClass] +public class StringFolderTests +{ + private readonly StringFolder _folder = new(); + + [TestMethod] + [DataRow(null, "")] + [DataRow("", "")] + [DataRow("abc", "ABC")] + [DataRow("ABC", "ABC")] + [DataRow("a\\b", "A/B")] + [DataRow("a/b", "A/B")] + [DataRow("ÁBC", "ABC")] // Diacritic removal + [DataRow("ñ", "N")] + [DataRow("hello world", "HELLO WORLD")] + public void Fold_RemoveDiacritics_Works(string input, string expected) + { + Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: true)); + } + + [TestMethod] + [DataRow("abc", "ABC")] + [DataRow("ÁBC", "ÁBC")] // No diacritic removal + [DataRow("a\\b", "A/B")] + public void Fold_KeepDiacritics_Works(string input, string expected) + { + Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: false)); + } + + [TestMethod] + public void Fold_IsAlreadyFolded_ReturnsSameInstance() + { + var input = "ALREADY/FOLDED"; + var result = _folder.Fold(input, removeDiacritics: true); + Assert.AreSame(input, result); + } + + [TestMethod] + public void Fold_WithNonAsciiButNoDiacritics_ReturnsFolded() + { + // E.g. Cyrillic or other scripts that might not decompose in a simple way or just upper case + // "привет" -> "ПРИВЕТ" + var input = "привет"; + var expected = "ПРИВЕТ"; + Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: true)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs new file mode 100644 index 0000000000..cc24433931 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsCommandProviderTests.cs @@ -0,0 +1,119 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +[TestClass] +public class AllAppsCommandProviderTests : AppsTestBase +{ + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var provider = new AllAppsCommandProvider(); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var provider = new AllAppsCommandProvider(); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var provider = new AllAppsCommandProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } + + [TestMethod] + public void LookupAppWithEmptyNameReturnsNotNull() + { + // Setup + var mockApp = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe"); + MockCache.AddWin32Program(mockApp); + var page = new AllAppsPage(MockCache); + + var provider = new AllAppsCommandProvider(page); + + // Act + var result = provider.LookupAppByDisplayName(string.Empty); + + // Assert + Assert.IsNotNull(result); + } + + [TestMethod] + public async Task ProviderWithMockData_LookupApp_ReturnsCorrectApp() + { + // Arrange + var testApp = TestDataHelper.CreateTestWin32Program("TestApp", "C:\\TestApp.exe"); + MockCache.AddWin32Program(testApp); + + var provider = new AllAppsCommandProvider(Page); + + // Wait for initialization to complete + await WaitForPageInitializationAsync(); + + // Act + var result = provider.LookupAppByDisplayName("TestApp"); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("TestApp", result.Title); + } + + [TestMethod] + public async Task ProviderWithMockData_LookupApp_ReturnsNullForNonExistentApp() + { + // Arrange + var testApp = TestDataHelper.CreateTestWin32Program("TestApp", "C:\\TestApp.exe"); + MockCache.AddWin32Program(testApp); + + var provider = new AllAppsCommandProvider(Page); + + // Wait for initialization to complete + await WaitForPageInitializationAsync(); + + // Act + var result = provider.LookupAppByDisplayName("NonExistentApp"); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void ProviderWithMockData_TopLevelCommands_IncludesListItem() + { + // Arrange + var provider = new AllAppsCommandProvider(Page); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length >= 1); // At least the list item should be present + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs new file mode 100644 index 0000000000..3ac1eaff68 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AllAppsPageTests.cs @@ -0,0 +1,97 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +[TestClass] +public class AllAppsPageTests : AppsTestBase +{ + [TestMethod] + public void AllAppsPage_Constructor_ThrowsOnNullAppCache() + { + // Act & Assert + Assert.ThrowsException<ArgumentNullException>(() => new AllAppsPage(null!)); + } + + [TestMethod] + public void AllAppsPage_WithMockCache_InitializesSuccessfully() + { + // Arrange + var mockCache = new MockAppCache(); + + // Act + var page = new AllAppsPage(mockCache); + + // Assert + Assert.IsNotNull(page); + Assert.IsNotNull(page.Name); + Assert.IsNotNull(page.Icon); + } + + [TestMethod] + public async Task AllAppsPage_GetItems_ReturnsEmptyWithEmptyCache() + { + // Act - Wait for initialization to complete + await WaitForPageInitializationAsync(); + var items = Page.GetItems(); + + // Assert + Assert.IsNotNull(items); + Assert.AreEqual(0, items.Length); + } + + [TestMethod] + public async Task AllAppsPage_GetItems_ReturnsAppsFromCacheAsync() + { + // Arrange + var mockCache = new MockAppCache(); + var win32App = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe"); + var uwpApp = TestDataHelper.CreateTestUWPApplication("Calculator"); + + mockCache.AddWin32Program(win32App); + mockCache.AddUWPApplication(uwpApp); + + var page = new AllAppsPage(mockCache); + + // Wait a bit for initialization to complete + await Task.Delay(100); + + // Act + var items = page.GetItems(); + + // Assert + Assert.IsNotNull(items); + Assert.AreEqual(2, items.Length); + + // we need to loop the items to ensure we got the correct ones + Assert.IsTrue(items.Any(i => i.Title == "Notepad")); + Assert.IsTrue(items.Any(i => i.Title == "Calculator")); + } + + [TestMethod] + public async Task AllAppsPage_GetPinnedApps_ReturnsEmptyWhenNoAppsArePinned() + { + // Arrange + var mockCache = new MockAppCache(); + var app = TestDataHelper.CreateTestWin32Program("TestApp", "C:\\TestApp.exe"); + mockCache.AddWin32Program(app); + + var page = new AllAppsPage(mockCache); + + // Wait a bit for initialization to complete + await Task.Delay(100); + + // Act + var pinnedApps = page.GetPinnedApps(); + + // Assert + Assert.IsNotNull(pinnedApps); + Assert.AreEqual(0, pinnedApps.Length); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AppsTestBase.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AppsTestBase.cs new file mode 100644 index 0000000000..4d1210db7b --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/AppsTestBase.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +/// <summary> +/// Base class for Apps unit tests that provides common setup and teardown functionality. +/// </summary> +public abstract class AppsTestBase +{ + /// <summary> + /// Gets the mock application cache used in tests. + /// </summary> + protected MockAppCache MockCache { get; private set; } = null!; + + /// <summary> + /// Gets the AllAppsPage instance used in tests. + /// </summary> + protected AllAppsPage Page { get; private set; } = null!; + + /// <summary> + /// Sets up the test environment before each test method. + /// </summary> + /// <returns>A task representing the asynchronous setup operation.</returns> + [TestInitialize] + public virtual async Task Setup() + { + MockCache = new MockAppCache(); + Page = new AllAppsPage(MockCache); + + // Ensure initialization is complete + await MockCache.RefreshAsync(); + } + + /// <summary> + /// Cleans up the test environment after each test method. + /// </summary> + [TestCleanup] + public virtual void Cleanup() + { + MockCache?.Dispose(); + } + + /// <summary> + /// Forces synchronous initialization of the page for testing. + /// </summary> + protected void EnsurePageInitialized() + { + // Trigger BuildListItems by accessing items + _ = Page.GetItems(); + } + + /// <summary> + /// Waits for page initialization with timeout. + /// </summary> + /// <param name="timeoutMs">The timeout in milliseconds.</param> + /// <returns>A task representing the asynchronous wait operation.</returns> + protected async Task WaitForPageInitializationAsync(int timeoutMs = 1000) + { + await MockCache.RefreshAsync(); + EnsurePageInitialized(); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj new file mode 100644 index 0000000000..ed86ab834b --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Ext.Apps.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Apps\Microsoft.CmdPal.Ext.Apps.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockAppCache.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockAppCache.cs new file mode 100644 index 0000000000..03530cb5ce --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockAppCache.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Apps.Programs; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +/// <summary> +/// Mock implementation of IAppCache for unit testing. +/// </summary> +public class MockAppCache : IAppCache +{ + private readonly List<Win32Program> _win32s = new(); + private readonly List<IUWPApplication> _uwps = new(); + private bool _disposed; + private bool _shouldReload; + + /// <summary> + /// Gets the collection of Win32 programs. + /// </summary> + public IList<Win32Program> Win32s => _win32s.AsReadOnly(); + + /// <summary> + /// Gets the collection of UWP applications. + /// </summary> + public IList<IUWPApplication> UWPs => _uwps.AsReadOnly(); + + /// <summary> + /// Determines whether the cache should be reloaded. + /// </summary> + /// <returns>True if cache should be reloaded, false otherwise.</returns> + public bool ShouldReload() => _shouldReload; + + /// <summary> + /// Resets the reload flag. + /// </summary> + public void ResetReloadFlag() => _shouldReload = false; + + /// <summary> + /// Asynchronously refreshes the cache. + /// </summary> + /// <returns>A task representing the asynchronous refresh operation.</returns> + public async Task RefreshAsync() + { + // Simulate minimal async operation for testing + await Task.Delay(1); + } + + /// <summary> + /// Adds a Win32 program to the cache. + /// </summary> + /// <param name="program">The Win32 program to add.</param> + /// <exception cref="ArgumentNullException">Thrown when program is null.</exception> + public void AddWin32Program(Win32Program program) + { + ArgumentNullException.ThrowIfNull(program); + + _win32s.Add(program); + } + + /// <summary> + /// Adds a UWP application to the cache. + /// </summary> + /// <param name="app">The UWP application to add.</param> + /// <exception cref="ArgumentNullException">Thrown when app is null.</exception> + public void AddUWPApplication(IUWPApplication app) + { + ArgumentNullException.ThrowIfNull(app); + + _uwps.Add(app); + } + + /// <summary> + /// Clears all applications from the cache. + /// </summary> + public void ClearAll() + { + _win32s.Clear(); + _uwps.Clear(); + } + + /// <summary> + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// </summary> + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Releases unmanaged and - optionally - managed resources. + /// </summary> + /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // Clean up managed resources + _win32s.Clear(); + _uwps.Clear(); + } + + _disposed = true; + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockUWPApplication.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockUWPApplication.cs new file mode 100644 index 0000000000..ae39e70fef --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/MockUWPApplication.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Utils; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +/// <summary> +/// Mock implementation of IUWPApplication for unit testing. +/// </summary> +public class MockUWPApplication : IUWPApplication +{ + /// <summary> + /// Gets or sets the app list entry. + /// </summary> + public string AppListEntry { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the unique identifier. + /// </summary> + public string UniqueIdentifier { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the display name. + /// </summary> + public string DisplayName { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the description. + /// </summary> + public string Description { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the user model ID. + /// </summary> + public string UserModelId { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the background color. + /// </summary> + public string BackgroundColor { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the entry point. + /// </summary> + public string EntryPoint { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets a value indicating whether the application is enabled. + /// </summary> + public bool Enabled { get; set; } = true; + + /// <summary> + /// Gets or sets a value indicating whether the application can run elevated. + /// </summary> + public bool CanRunElevated { get; set; } + + /// <summary> + /// Gets or sets the logo path. + /// </summary> + public string LogoPath { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the logo type. + /// </summary> + public LogoType LogoType { get; set; } = LogoType.Colored; + + /// <summary> + /// Gets or sets the UWP package. + /// </summary> + public UWP Package { get; set; } = null!; + + /// <summary> + /// Gets the name of the application. + /// </summary> + public string Name => DisplayName; + + /// <summary> + /// Gets the location of the application. + /// </summary> + public string Location => Package?.Location ?? string.Empty; + + /// <summary> + /// Gets the localized location of the application. + /// </summary> + public string LocationLocalized => Package?.LocationLocalized ?? string.Empty; + + /// <summary> + /// Gets the application identifier. + /// </summary> + /// <returns>The user model ID of the application.</returns> + public string GetAppIdentifier() + { + return UserModelId; + } + + /// <summary> + /// Gets the commands available for this application. + /// </summary> + /// <returns>A list of context items.</returns> + public List<IContextItem> GetCommands() + { + return new List<IContextItem>(); + } + + /// <summary> + /// Updates the logo path based on the specified theme. + /// </summary> + /// <param name="theme">The theme to use for the logo.</param> + public void UpdateLogoPath(Theme theme) + { + // Mock implementation - no-op for testing + } + + /// <summary> + /// Converts this UWP application to an AppItem. + /// </summary> + /// <returns>An AppItem representation of this UWP application.</returns> + public AppItem ToAppItem() + { + var iconPath = LogoType != LogoType.Error ? LogoPath : string.Empty; + return new AppItem() + { + Name = Name, + Subtitle = Description, + Type = "Packaged Application", // Equivalent to UWPApplication.Type() + IcoPath = iconPath, + DirPath = Location, + UserModelId = UserModelId, + IsPackaged = true, + Commands = GetCommands(), + AppIdentifier = GetAppIdentifier(), + }; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..e04c678b58 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/QueryTests.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.Linq; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [TestMethod] + public void QueryReturnsExpectedResults() + { + // Arrange + var mockCache = new MockAppCache(); + var win32App = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe"); + var uwpApp = TestDataHelper.CreateTestUWPApplication("Calculator"); + mockCache.AddWin32Program(win32App); + mockCache.AddUWPApplication(uwpApp); + + for (var i = 0; i < 10; i++) + { + mockCache.AddWin32Program(TestDataHelper.CreateTestWin32Program($"App{i}")); + mockCache.AddUWPApplication(TestDataHelper.CreateTestUWPApplication($"UWP App {i}")); + } + + var page = new AllAppsPage(mockCache); + var provider = new AllAppsCommandProvider(page); + + // Act + var allItems = page.GetItems(); + + // Assert + var notepadResult = Query("notepad", allItems).FirstOrDefault(); + Assert.IsNotNull(notepadResult); + Assert.AreEqual("Notepad", notepadResult.Title); + + var calculatorResult = Query("cal", allItems).FirstOrDefault(); + Assert.IsNotNull(calculatorResult); + Assert.AreEqual("Calculator", calculatorResult.Title); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Settings.cs new file mode 100644 index 0000000000..b48abaf32a --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Settings.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.Apps.Helpers; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +public class Settings : ISettingsInterface +{ + private readonly bool enableStartMenuSource; + private readonly bool enableDesktopSource; + private readonly bool enableRegistrySource; + private readonly bool enablePathEnvironmentVariableSource; + private readonly List<string> programSuffixes; + private readonly List<string> runCommandSuffixes; + + public Settings( + bool enableStartMenuSource = true, + bool enableDesktopSource = true, + bool enableRegistrySource = true, + bool enablePathEnvironmentVariableSource = true, + List<string> programSuffixes = null, + List<string> runCommandSuffixes = null) + { + this.enableStartMenuSource = enableStartMenuSource; + this.enableDesktopSource = enableDesktopSource; + this.enableRegistrySource = enableRegistrySource; + this.enablePathEnvironmentVariableSource = enablePathEnvironmentVariableSource; + this.programSuffixes = programSuffixes ?? new List<string> { "bat", "appref-ms", "exe", "lnk", "url" }; + this.runCommandSuffixes = runCommandSuffixes ?? new List<string> { "bat", "appref-ms", "exe", "lnk", "url", "cpl", "msc" }; + } + + public bool EnableStartMenuSource => enableStartMenuSource; + + public bool EnableDesktopSource => enableDesktopSource; + + public bool EnableRegistrySource => enableRegistrySource; + + public bool EnablePathEnvironmentVariableSource => enablePathEnvironmentVariableSource; + + public List<string> ProgramSuffixes => programSuffixes; + + public List<string> RunCommandSuffixes => runCommandSuffixes; + + public static Settings CreateDefaultSettings() => new Settings(); + + public static Settings CreateDisabledSourcesSettings() => new Settings( + enableStartMenuSource: false, + enableDesktopSource: false, + enableRegistrySource: false, + enablePathEnvironmentVariableSource: false); + + public static Settings CreateCustomSuffixesSettings() => new Settings( + programSuffixes: new List<string> { "exe", "bat" }, + runCommandSuffixes: new List<string> { "exe", "bat", "cmd" }); +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/TestDataHelper.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/TestDataHelper.cs new file mode 100644 index 0000000000..88936e4285 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/TestDataHelper.cs @@ -0,0 +1,128 @@ +// 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.Ext.Apps.Programs; + +namespace Microsoft.CmdPal.Ext.Apps.UnitTests; + +/// <summary> +/// Helper class to create test data for unit tests. +/// </summary> +public static class TestDataHelper +{ + /// <summary> + /// Creates a test Win32 program with the specified parameters. + /// </summary> + /// <param name="name">The name of the application.</param> + /// <param name="fullPath">The full path to the application executable.</param> + /// <param name="enabled">A value indicating whether the application is enabled.</param> + /// <param name="valid">A value indicating whether the application is valid.</param> + /// <returns>A new Win32Program instance with the specified parameters.</returns> + public static Win32Program CreateTestWin32Program( + string name = "Test App", + string fullPath = "C:\\TestApp\\app.exe", + bool enabled = true, + bool valid = true) + { + return new Win32Program + { + Name = name, + FullPath = fullPath, + Enabled = enabled, + Valid = valid, + UniqueIdentifier = $"win32_{name}", + Description = $"Test description for {name}", + ExecutableName = "app.exe", + ParentDirectory = "C:\\TestApp", + AppType = Win32Program.ApplicationType.Win32Application, + }; + } + + /// <summary> + /// Creates a test UWP application with the specified parameters. + /// </summary> + /// <param name="displayName">The display name of the application.</param> + /// <param name="userModelId">The user model ID of the application.</param> + /// <param name="enabled">A value indicating whether the application is enabled.</param> + /// <returns>A new IUWPApplication instance with the specified parameters.</returns> + public static IUWPApplication CreateTestUWPApplication( + string displayName = "Test UWP App", + string userModelId = "TestPublisher.TestUWPApp_1.0.0.0_neutral__8wekyb3d8bbwe", + bool enabled = true) + { + return new MockUWPApplication + { + DisplayName = displayName, + UserModelId = userModelId, + Enabled = enabled, + UniqueIdentifier = $"uwp_{userModelId}", + Description = $"Test UWP description for {displayName}", + AppListEntry = "default", + BackgroundColor = "#000000", + EntryPoint = "TestApp.App", + CanRunElevated = false, + LogoPath = string.Empty, + Package = CreateMockUWPPackage(displayName, userModelId), + }; + } + + /// <summary> + /// Creates a mock UWP package for testing purposes. + /// </summary> + /// <param name="displayName">The display name of the package.</param> + /// <param name="userModelId">The user model ID of the package.</param> + /// <returns>A new UWP package instance.</returns> + private static UWP CreateMockUWPPackage(string displayName, string userModelId) + { + var mockPackage = new MockPackage + { + Name = displayName, + FullName = userModelId, + FamilyName = $"{displayName}_8wekyb3d8bbwe", + InstalledLocation = $"C:\\Program Files\\WindowsApps\\{displayName}", + }; + + return new UWP(mockPackage) + { + Location = mockPackage.InstalledLocation, + LocationLocalized = mockPackage.InstalledLocation, + }; + } + + /// <summary> + /// Mock implementation of IPackage for testing purposes. + /// </summary> + private sealed class MockPackage : IPackage + { + /// <summary> + /// Gets or sets the name of the package. + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the full name of the package. + /// </summary> + public string FullName { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the family name of the package. + /// </summary> + public string FamilyName { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets a value indicating whether the package is a framework package. + /// </summary> + public bool IsFramework { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the package is in development mode. + /// </summary> + public bool IsDevelopmentMode { get; set; } + + /// <summary> + /// Gets or sets the installed location of the package. + /// </summary> + public string InstalledLocation { get; set; } = string.Empty; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs new file mode 100644 index 0000000000..a813ac4464 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkJsonParserTests.cs @@ -0,0 +1,391 @@ +// 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 Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class BookmarkJsonParserTests +{ + private BookmarkJsonParser _parser; + + [TestInitialize] + public void Setup() + { + _parser = new BookmarkJsonParser(); + } + + [TestMethod] + public void ParseBookmarks_ValidJson_ReturnsBookmarks() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "Google", + "Bookmark": "https://www.google.com" + }, + { + "Name": "Local File", + "Bookmark": "C:\\temp\\file.txt" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Data.Count); + Assert.AreEqual("Google", result.Data[0].Name); + Assert.AreEqual("https://www.google.com", result.Data[0].Bookmark); + Assert.AreEqual("Local File", result.Data[1].Name); + Assert.AreEqual("C:\\temp\\file.txt", result.Data[1].Bookmark); + } + + [TestMethod] + public void ParseBookmarks_EmptyJson_ReturnsEmptyBookmarks() + { + // Arrange + var json = "{}"; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_NullJson_ReturnsEmptyBookmarks() + { + // Act + var result = _parser.ParseBookmarks(null); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_WhitespaceJson_ReturnsEmptyBookmarks() + { + // Act + var result = _parser.ParseBookmarks(" "); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_EmptyString_ReturnsEmptyBookmarks() + { + // Act + var result = _parser.ParseBookmarks(string.Empty); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_InvalidJson_ReturnsEmptyBookmarks() + { + // Arrange + var invalidJson = "{invalid json}"; + + // Act + var result = _parser.ParseBookmarks(invalidJson); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_MalformedJson_ReturnsEmptyBookmarks() + { + // Arrange + var malformedJson = """ + { + "Data": [ + { + "Name": "Google", + "Bookmark": "https://www.google.com" + }, + { + "Name": "Incomplete entry" + """; + + // Act + var result = _parser.ParseBookmarks(malformedJson); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_JsonWithTrailingCommas_ParsesSuccessfully() + { + // Arrange - JSON with trailing commas (should be handled by AllowTrailingCommas option) + var json = """ + { + "Data": [ + { + "Name": "Google", + "Bookmark": "https://www.google.com", + }, + { + "Name": "Local File", + "Bookmark": "C:\\temp\\file.txt", + }, + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Data.Count); + Assert.AreEqual("Google", result.Data[0].Name); + Assert.AreEqual("https://www.google.com", result.Data[0].Bookmark); + } + + [TestMethod] + public void ParseBookmarks_JsonWithDifferentCasing_ParsesSuccessfully() + { + // Arrange - JSON with different property name casing (should be handled by PropertyNameCaseInsensitive option) + var json = """ + { + "data": [ + { + "name": "Google", + "bookmark": "https://www.google.com" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(1, result.Data.Count); + Assert.AreEqual("Google", result.Data[0].Name); + Assert.AreEqual("https://www.google.com", result.Data[0].Bookmark); + } + + [TestMethod] + public void SerializeBookmarks_ValidBookmarks_ReturnsJsonString() + { + // Arrange + var bookmarks = new BookmarksData + { + Data = new List<BookmarkData> + { + new BookmarkData { Name = "Google", Bookmark = "https://www.google.com" }, + new BookmarkData { Name = "Local File", Bookmark = "C:\\temp\\file.txt" }, + }, + }; + + // Act + var result = _parser.SerializeBookmarks(bookmarks); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Contains("Google")); + Assert.IsTrue(result.Contains("https://www.google.com")); + Assert.IsTrue(result.Contains("Local File")); + Assert.IsTrue(result.Contains("C:\\\\temp\\\\file.txt")); // Escaped backslashes in JSON + Assert.IsTrue(result.Contains("Data")); + } + + [TestMethod] + public void SerializeBookmarks_EmptyBookmarks_ReturnsValidJson() + { + // Arrange + var bookmarks = new BookmarksData(); + + // Act + var result = _parser.SerializeBookmarks(bookmarks); + + // Assert + Assert.IsNotNull(result); + Assert.IsTrue(result.Contains("Data")); + Assert.IsTrue(result.Contains("[]")); + } + + [TestMethod] + public void SerializeBookmarks_NullBookmarks_ReturnsEmptyString() + { + // Act + var result = _parser.SerializeBookmarks(null); + + // Assert + Assert.AreEqual(string.Empty, result); + } + + [TestMethod] + public void ParseBookmarks_RoundTripSerialization_PreservesData() + { + // Arrange + var originalBookmarks = new BookmarksData + { + Data = new List<BookmarkData> + { + new BookmarkData { Name = "Google", Bookmark = "https://www.google.com" }, + new BookmarkData { Name = "Local File", Bookmark = "C:\\temp\\file.txt" }, + new BookmarkData { Name = "Placeholder", Bookmark = "Open {file} in editor" }, + }, + }; + + // Act - Serialize then parse + var serializedJson = _parser.SerializeBookmarks(originalBookmarks); + var parsedBookmarks = _parser.ParseBookmarks(serializedJson); + + // Assert + Assert.IsNotNull(parsedBookmarks); + Assert.AreEqual(originalBookmarks.Data.Count, parsedBookmarks.Data.Count); + + for (var i = 0; i < originalBookmarks.Data.Count; i++) + { + Assert.AreEqual(originalBookmarks.Data[i].Name, parsedBookmarks.Data[i].Name); + Assert.AreEqual(originalBookmarks.Data[i].Bookmark, parsedBookmarks.Data[i].Bookmark); + } + } + + [TestMethod] + public void ParseBookmarks_JsonWithPlaceholderBookmarks_CorrectlyIdentifiesPlaceholders() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "Regular URL", + "Bookmark": "https://www.google.com" + }, + { + "Name": "Placeholder Command", + "Bookmark": "notepad {file}" + }, + { + "Name": "Multiple Placeholders", + "Bookmark": "copy {source} {destination}" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(3, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_IsPlaceholder_CorrectlyIdentifiesPlaceholders() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "Simple Placeholder", + "Bookmark": "notepad {file}" + }, + { + "Name": "Multiple Placeholders", + "Bookmark": "copy {source} to {destination}" + }, + { + "Name": "Web URL with Placeholder", + "Bookmark": "https://search.com?q={query}" + }, + { + "Name": "Complex Placeholder", + "Bookmark": "cmd /c echo {message} > {output_file}" + }, + { + "Name": "No Placeholder - Regular URL", + "Bookmark": "https://www.google.com" + }, + { + "Name": "No Placeholder - Local File", + "Bookmark": "C:\\temp\\file.txt" + }, + { + "Name": "False Positive - Only Opening Brace", + "Bookmark": "test { incomplete" + }, + { + "Name": "False Positive - Only Closing Brace", + "Bookmark": "test } incomplete" + }, + { + "Name": "Empty Placeholder", + "Bookmark": "command {}" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(9, result.Data.Count); + } + + [TestMethod] + public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesPlaceholder() + { + // Arrange + var json = """ + { + "Data": [ + { + "Name": "Web URL with Placeholder", + "Bookmark": "https://google.com/search?q={query}" + }, + { + "Name": "Web URL without Placeholder", + "Bookmark": "https://github.com" + }, + { + "Name": "Local File with Placeholder", + "Bookmark": "notepad {file}" + }, + { + "Name": "Local File without Placeholder", + "Bookmark": "C:\\Windows\\notepad.exe" + } + ] + } + """; + + // Act + var result = _parser.ParseBookmarks(json); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(4, result.Data.Count); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs new file mode 100644 index 0000000000..b4e533d66d --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkManagerTests.cs @@ -0,0 +1,233 @@ +// 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.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class BookmarkManagerTests +{ + [TestMethod] + public void BookmarkManager_CanBeInstantiated() + { + // Arrange & Act + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + + // Assert + Assert.IsNotNull(bookmarkManager); + } + + [TestMethod] + public void BookmarkManager_InitialBookmarksEmpty() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + + // Act + var bookmarks = bookmarkManager.Bookmarks; + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(0, bookmarks.Count); + } + + [TestMethod] + public void BookmarkManager_InitialBookmarksCorruptedData() + { + // Arrange + var json = "@*>$ß Corrupted data. Hey, this is not JSON!"; + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json)); + + // Act + var bookmarks = bookmarkManager.Bookmarks; + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(0, bookmarks.Count); + } + + [TestMethod] + public void BookmarkManager_InitializeWithExistingData() + { + // Arrange + const string json = """ + { + "Data":[ + {"Id":"d290f1ee-6c54-4b01-90e6-d701748f0851","Name":"Bookmark1","Bookmark":"C:\\Path1"}, + {"Id":"c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a","Name":"Bookmark2","Bookmark":"D:\\Path2"} + ] + } + """; + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json)); + + // Act + var bookmarks = bookmarkManager.Bookmarks?.ToList(); + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(2, bookmarks.Count); + + Assert.AreEqual("Bookmark1", bookmarks[0].Name); + Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark); + Assert.AreEqual(Guid.Parse("d290f1ee-6c54-4b01-90e6-d701748f0851"), bookmarks[0].Id); + + Assert.AreEqual("Bookmark2", bookmarks[1].Name); + Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark); + Assert.AreEqual(Guid.Parse("c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a"), bookmarks[1].Id); + } + + [TestMethod] + public void BookmarkManager_InitializeWithLegacyData_GeneratesIds() + { + // Arrange + const string json = """ + { + "Data": + [ + { "Name":"Bookmark1", "Bookmark":"C:\\Path1" }, + { "Name":"Bookmark2", "Bookmark":"D:\\Path2" } + ] + } + """; + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json)); + + // Act + var bookmarks = bookmarkManager.Bookmarks?.ToList(); + + // Assert + Assert.IsNotNull(bookmarks); + Assert.AreEqual(2, bookmarks.Count); + + Assert.AreEqual("Bookmark1", bookmarks[0].Name); + Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark); + Assert.AreNotEqual(Guid.Empty, bookmarks[0].Id); + + Assert.AreEqual("Bookmark2", bookmarks[1].Name); + Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark); + Assert.AreNotEqual(Guid.Empty, bookmarks[1].Id); + + Assert.AreNotEqual(bookmarks[0].Id, bookmarks[1].Id); + } + + [TestMethod] + public void BookmarkManager_AddBookmark_WorksCorrectly() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + var bookmarkAddedEventFired = false; + bookmarkManager.BookmarkAdded += (bookmark) => + { + bookmarkAddedEventFired = true; + Assert.AreEqual("TestBookmark", bookmark.Name); + Assert.AreEqual("C:\\TestPath", bookmark.Bookmark); + }; + + // Act + var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath"); + + // Assert + var bookmarks = bookmarkManager.Bookmarks; + Assert.AreEqual(1, bookmarks.Count); + Assert.AreEqual(addedBookmark, bookmarks.First()); + Assert.IsTrue(bookmarkAddedEventFired); + } + + [TestMethod] + public void BookmarkManager_RemoveBookmark_WorksCorrectly() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath"); + var bookmarkRemovedEventFired = false; + bookmarkManager.BookmarkRemoved += (bookmark) => + { + bookmarkRemovedEventFired = true; + Assert.AreEqual(addedBookmark, bookmark); + }; + + // Act + var removeResult = bookmarkManager.Remove(addedBookmark.Id); + + // Assert + var bookmarks = bookmarkManager.Bookmarks; + Assert.IsTrue(removeResult); + Assert.AreEqual(0, bookmarks.Count); + Assert.IsTrue(bookmarkRemovedEventFired); + } + + [TestMethod] + public void BookmarkManager_UpdateBookmark_WorksCorrectly() + { + // Arrange + var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource()); + var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath"); + var bookmarkUpdatedEventFired = false; + bookmarkManager.BookmarkUpdated += (data, bookmarkData) => + { + bookmarkUpdatedEventFired = true; + Assert.AreEqual(addedBookmark, data); + Assert.AreEqual("UpdatedBookmark", bookmarkData.Name); + Assert.AreEqual("D:\\UpdatedPath", bookmarkData.Bookmark); + }; + + // Act + var updatedBookmark = bookmarkManager.Update(addedBookmark.Id, "UpdatedBookmark", "D:\\UpdatedPath"); + + // Assert + var bookmarks = bookmarkManager.Bookmarks; + Assert.IsNotNull(updatedBookmark); + Assert.AreEqual(1, bookmarks.Count); + Assert.AreEqual(updatedBookmark, bookmarks.First()); + Assert.AreEqual("UpdatedBookmark", updatedBookmark.Name); + Assert.AreEqual("D:\\UpdatedPath", updatedBookmark.Bookmark); + Assert.IsTrue(bookmarkUpdatedEventFired); + } + + [TestMethod] + public void BookmarkManager_LegacyData_IdsArePersistedAcrossLoads() + { + // Arrange + const string json = """ + { + "Data": + [ + { "Name": "C:\\","Bookmark": "C:\\" }, + { "Name": "Bing.com","Bookmark": "https://bing.com" } + ] + } + """; + + var dataSource = new MockBookmarkDataSource(json); + + // First load: IDs should be generated for legacy entries + var manager1 = new BookmarksManager(dataSource); + var firstLoad = manager1.Bookmarks.ToList(); + Assert.AreEqual(2, firstLoad.Count); + Assert.AreNotEqual(Guid.Empty, firstLoad[0].Id); + Assert.AreNotEqual(Guid.Empty, firstLoad[1].Id); + + // Keep a name->id map to be insensitive to ordering + var firstIdsByName = firstLoad.ToDictionary(b => b.Name, b => b.Id); + + // Wait deterministically for async persistence to complete + var wasSaved = dataSource.WaitForSave(1, 5000); + Assert.IsTrue(wasSaved, "Data was not saved within the expected time."); + + // Second load: should read back the same IDs from persisted data + var manager2 = new BookmarksManager(dataSource); + var secondLoad = manager2.Bookmarks.ToList(); + Assert.AreEqual(2, secondLoad.Count); + + var secondIdsByName = secondLoad.ToDictionary(b => b.Name, b => b.Id); + + foreach (var kvp in firstIdsByName) + { + Assert.IsTrue(secondIdsByName.ContainsKey(kvp.Key), $"Missing bookmark '{kvp.Key}' after reload."); + Assert.AreEqual(kvp.Value, secondIdsByName[kvp.Key], $"Bookmark '{kvp.Key}' upgraded ID was not persisted across loads."); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Common.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Common.cs new file mode 100644 index 0000000000..9be4a187e8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Common.cs @@ -0,0 +1,303 @@ +// 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.IO; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public partial class BookmarkResolverTests +{ + [DataTestMethod] + [DynamicData(nameof(CommonClassificationData.CommonCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateCommonClassification(PlaceholderClassificationCase c) => await RunShared(c); + + [DataTestMethod] + [DynamicData(nameof(CommonClassificationData.UwpAumidCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateUwpAumidClassification(PlaceholderClassificationCase c) => await RunShared(c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedRelativePaths), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateUnquotedRelativePathScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedShellProtocol), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Common_ValidateUnquotedShellProtocolScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + private static class CommonClassificationData + { + public static IEnumerable<object[]> CommonCases() + { + return + [ + [ + new PlaceholderClassificationCase( + Name: "HTTPS URL", + Input: "https://microsoft.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://microsoft.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "WWW URL without scheme", + Input: "www.example.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://www.example.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "HTTP URL with query", + Input: "http://yahoo.com?p=search", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "http://yahoo.com?p=search", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Mailto protocol", + Input: "mailto:user@example.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "mailto:user@example.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "MS-Settings protocol", + Input: "ms-settings:display", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "ms-settings:display", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Custom protocol", + Input: "myapp:doit", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "myapp:doit", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Not really a valid protocol", + Input: "this is not really a protocol myapp: doit", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "this", + ExpectedArguments: "is not really a protocol myapp: doit", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Drive", + Input: "C:\\.", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: "C:\\", + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Non-existing path with extension", + Input: "C:\\this-folder-should-not-exist-12345\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\this-folder-should-not-exist-12345\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Unknown fallback", + Input: "some_unlikely_command_name_12345", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "some_unlikely_command_name_12345", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + + [new PlaceholderClassificationCase( + Name: "Simple unquoted executable path", + Input: "C:\\Windows\\System32\\notepad.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Windows\\System32\\notepad.exe", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Unquoted document path (non existed file)", + Input: "C:\\Users\\John\\Documents\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Users\\John\\Documents\\file.txt", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ] + ]; + } + + public static IEnumerable<object[]> UwpAumidCases() => + [ + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID with AppsFolder prefix", + Input: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID with AppsFolder prefix and argument (Trap)", + Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID via AppsFolder", + Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + ]; + + public static IEnumerable<object[]> UnquotedShellProtocol() => + [ + [ + new PlaceholderClassificationCase( + Name: "Shell for This PC (shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D})", + Input: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell for This PC (::{20D04FE0-3AEA-1069-A2D8-08002B30309D})", + Input: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell protocol for My Documents (shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + Input: "shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell protocol for My Documents (::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + Input: "::{450D8FBA-AD25-11D0-98A8-0800361B1103}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Shell protocol for AppData (shell:appdata)", + Input: "shell:appdata", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + [ + + // let's pray this works on all systems + new PlaceholderClassificationCase( + Name: "Shell protocol for AppData + subpath (shell:appdata\\microsoft)", + Input: "shell:appdata\\microsoft", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft"), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false), + ], + ]; + + public static IEnumerable<object[]> UnquotedRelativePaths() => + [ + [ + new PlaceholderClassificationCase( + Name: "Unquoted relative current path", + Input: ".\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], +#if CMDPAL_ENABLE_UNSAFE_TESTS + It's not really a good idea blindly write to directory out of user profile + [ + new PlaceholderClassificationCase( + Name: "Unquoted relative parent path", + Input: "..\\parent folder\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], +#endif // CMDPAL_ENABLE_UNSAFE_TESTS + [ + new PlaceholderClassificationCase( + Name: "Unquoted relative home folder", + Input: $"~\\{_testDirName}\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: Path.Combine(_testDirPath, "app.exe"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Placeholders.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Placeholders.cs new file mode 100644 index 0000000000..c4c455d5a9 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Placeholders.cs @@ -0,0 +1,369 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public partial class BookmarkResolverTests +{ + [DataTestMethod] + [DynamicData(nameof(PlaceholderClassificationData.PlaceholderCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Placeholders_ValidatePlaceholderClassification(PlaceholderClassificationCase c) => await RunShared(c); + + [DataTestMethod] + [DynamicData(nameof(PlaceholderClassificationData.EdgeCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Placeholders_ValidatePlaceholderEdgeCases(PlaceholderClassificationCase c) + { + // Arrange + IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser()); + + // Act & Assert - Should not throw exceptions + var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None); + + Assert.IsNotNull(classification); + Assert.AreEqual(c.ExpectSuccess, classification.Success); + + if (c.ExpectSuccess && classification.Result != null) + { + Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder); + Assert.AreEqual(c.Input, classification.Result.Input, "OriginalInput should be preserved"); + } + } + + private static class PlaceholderClassificationData + { + public static IEnumerable<object[]> PlaceholderCases() + { + // UWP/AUMID with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "UWP AUMID with package placeholder", + Input: "shell:AppsFolder\\{packageFamily}!{appId}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\{packageFamily}!{appId}", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + + // Expects no special handling + new PlaceholderClassificationCase( + Name: "Bare UWP AUMID with placeholders", + Input: "{packageFamily}!{appId}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{packageFamily}!{appId}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Web URLs with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "HTTPS URL with domain placeholder", + Input: "https://{domain}/path", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://{domain}/path", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "WWW URL with site placeholder", + Input: "www.{site}.com", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "https://www.{site}.com", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "WWW URL - Yahoo with Search", + Input: "http://yahoo.com?p={search}", + ExpectSuccess: true, + ExpectedKind: CommandKind.WebUrl, + ExpectedTarget: "http://yahoo.com?p={search}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Protocol URLs with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Mailto protocol with email placeholder", + Input: "mailto:{email}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "mailto:{email}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "MS-Settings protocol with category placeholder", + Input: "ms-settings:{category}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Protocol, + ExpectedTarget: "ms-settings:{category}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // File executables with placeholders - These might classify as Unknown currently + // due to nonexistent paths, but should preserve placeholder flag + yield return + [ + new PlaceholderClassificationCase( + Name: "Executable with profile path placeholder", + Input: "{userProfile}\\Documents\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist + ExpectedTarget: "{userProfile}\\Documents\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Executable with program files placeholder", + Input: "{programFiles}\\MyApp\\tool.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist + ExpectedTarget: "{programFiles}\\MyApp\\tool.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Commands with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Command with placeholder and arguments", + Input: "{editor} {filename}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, // Likely Unknown since command won't be found in PATH + ExpectedTarget: "{editor}", + ExpectedArguments: "{filename}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Directory paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Directory with user profile placeholder", + Input: "{userProfile}\\Documents", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, // May be Unknown if path doesn't exist during classification + ExpectedTarget: "{userProfile}\\Documents", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Complex quoted paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Quoted executable path with placeholders and args", + Input: "\"{programFiles}\\{appName}\\{executable}.exe\" --verbose", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // Likely Unknown due to nonexistent path + ExpectedTarget: "{programFiles}\\{appName}\\{executable}.exe", + ExpectedArguments: "--verbose", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Shell paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Shell folder with placeholder", + Input: "shell:{folder}\\{filename}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "shell:{folder}\\{filename}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Shell paths with placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Shell folder with placeholder", + Input: "shell:knownFolder\\{filename}", + ExpectSuccess: true, + ExpectedKind: CommandKind.VirtualShellItem, + ExpectedTarget: "shell:knownFolder\\{filename}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + yield return + [ + + // cmd /K {param1} + new PlaceholderClassificationCase( + Name: "Command with braces in arguments", + Input: "cmd /K {param1}", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE", + ExpectedArguments: "/K {param1}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Mixed literal and placeholder paths + yield return + [ + new PlaceholderClassificationCase( + Name: "Mixed literal and placeholder path", + Input: "C:\\{folder}\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, // Behavior depends on partial path resolution + ExpectedTarget: "C:\\{folder}\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Multiple placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Multiple placeholders in path", + Input: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + } + + public static IEnumerable<object[]> EdgeCases() + { + // Empty and malformed placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Empty placeholder", + Input: "{} file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{} file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Unclosed placeholder", + Input: "{unclosed file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{unclosed file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Placeholder with spaces", + Input: "{with spaces}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{with spaces}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Nested placeholders", + Input: "{outer{inner}}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{outer{inner}}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Only closing brace", + Input: "file} something", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "file} something", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ]; + + // Very long placeholder names + yield return + [ + new PlaceholderClassificationCase( + Name: "Very long placeholder name", + Input: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + // Special characters in placeholders + yield return + [ + new PlaceholderClassificationCase( + Name: "Placeholder with underscores", + Input: "{user_profile}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{user_profile}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + + yield return + [ + new PlaceholderClassificationCase( + Name: "Placeholder with numbers", + Input: "{path123}\\file.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "{path123}\\file.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: true) + ]; + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Quoted.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Quoted.cs new file mode 100644 index 0000000000..ceda208996 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.Quoted.cs @@ -0,0 +1,669 @@ +// 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.IO; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public partial class BookmarkResolverTests +{ + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.MixedQuotesScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateMixedQuotesScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EscapedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateEscapedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.PartialMalformedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidatePartialMalformedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EnvironmentVariablesWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateEnvironmentVariablesWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.ShellProtocolPathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateShellProtocolPathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.CommandFlagsAndOptions), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateCommandFlagsAndOptions(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.NetworkPathsUnc), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateNetworkPathsUnc(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RelativePathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateRelativePathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EmptyAndWhitespaceCases), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateEmptyAndWhitespaceCases(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RealWorldCommandScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateRealWorldCommandScenarios(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.SpecialCharactersInPaths), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateSpecialCharactersInPaths(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsCurrentlyBroken), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateQuotedPathsCurrentlyBroken(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsInCommands), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateQuotedPathsInCommands(PlaceholderClassificationCase c) => await RunShared(c: c); + + [DataTestMethod] + [DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedAumid), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))] + public async Task Quoted_ValidateQuotedUwpAppAumidCommands(PlaceholderClassificationCase c) => await RunShared(c: c); + + public static class QuotedClassificationData + { + public static IEnumerable<object[]> MixedQuotesScenarios() => + [ + [ + new PlaceholderClassificationCase( + Name: "Executable with quoted argument", + Input: "C:\\Windows\\notepad.exe \"C:\\my file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Windows\\notepad.exe", + ExpectedArguments: "\"C:\\my file.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "App with quoted argument containing spaces", + Input: "app.exe \"argument with spaces\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "app.exe", + ExpectedArguments: "\"argument with spaces\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Tool with input flag and quoted file", + Input: "C:\\tool.exe -input \"data file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\tool.exe", + ExpectedArguments: "-input \"data file.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Multiple quoted arguments after path", + Input: "\"C:\\Program Files\\app.exe\" -file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\app.exe", + ExpectedArguments: "-file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Command with two quoted paths", + Input: "cmd /c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE", + ExpectedArguments: "/c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> EscapedQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Path with escaped quotes in folder name", + Input: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with trailing escaped quote", + Input: "\"C:\\Windows\\\\\\\"\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: "C:\\Windows\\", + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> PartialMalformedQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Unclosed quote at start", + Input: "\"C:\\Program Files\\app.exe", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quote in middle of unquoted path", + Input: "C:\\Some\\\"Path\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Some\\\"Path\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Unclosed quote - never ends", + Input: "\"Starts quoted but never ends", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "Starts quoted but never ends", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> EnvironmentVariablesWithQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted environment variable path with spaces", + Input: "\"%ProgramFiles%\\MyApp\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "MyApp", "app.exe"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted USERPROFILE with document path", + Input: "\"%USERPROFILE%\\Documents\\file with spaces.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents", "file with spaces.txt"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Environment variable with trailing args", + Input: "\"%ProgramFiles%\\App\" with args", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"), + ExpectedArguments: "with args", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Environment variable with trailing args", + Input: "%ProgramFiles%\\App with args", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"), + ExpectedArguments: "with args", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + ]; + + public static IEnumerable<object[]> ShellProtocolPathsWithQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted shell:Downloads", + Input: "\"shell:Downloads\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted shell:Downloads with subpath", + Input: "\"shell:Downloads\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "file.txt"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Shell Desktop with subpath", + Input: "shell:Desktop\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "file.txt"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted shell path with trailing text", + Input: "\"shell:Programs\" extra", + ExpectSuccess: true, + ExpectedKind: CommandKind.Directory, + ExpectedTarget: Path.Combine(paths: Environment.GetFolderPath(Environment.SpecialFolder.Programs)), + ExpectedLaunch: LaunchMethod.ExplorerOpen, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> CommandFlagsAndOptions() => + [ + [ + new PlaceholderClassificationCase( + Name: "Path followed by flag with quoted value", + Input: "C:\\app.exe -flag \"value\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\app.exe", + ExpectedArguments: "-flag \"value\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted tool with equals-style flag", + Input: "\"C:\\Program Files\\tool.exe\" --input=file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\tool.exe", + ExpectedArguments: "--input=file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Path with slash option and quoted value", + Input: "C:\\tool.exe /option \"quoted value\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\tool.exe", + ExpectedArguments: "/option \"quoted value\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Flag before quoted path", + Input: "--path \"C:\\Program Files\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "--path", + ExpectedArguments: "\"C:\\Program Files\\app.exe\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> NetworkPathsUnc() => + [ + [ + new PlaceholderClassificationCase( + Name: "UNC path unquoted", + Input: "\\\\server\\share\\folder\\file.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "\\\\server\\share\\folder\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted UNC path with spaces", + Input: "\"\\\\server\\share with spaces\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "\\\\server\\share with spaces\\file.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "UNC path with trailing args", + Input: "\"\\\\server\\share\\\" with args", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: "\\\\server\\share\\", + ExpectedArguments: "with args", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted UNC app with flag", + Input: "\"\\\\server\\My Share\\app.exe\" --flag", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "\\\\server\\My Share\\app.exe", + ExpectedArguments: "--flag", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> RelativePathsWithQuotes() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted relative current path", + Input: "\".\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted relative parent path", + Input: "\"..\\parent folder\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted relative home folder", + Input: "\"~\\current folder\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "current folder\\app.exe"), + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> EmptyAndWhitespaceCases() => + [ + [ + new PlaceholderClassificationCase( + Name: "Empty string", + Input: string.Empty, + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Only whitespace", + Input: " ", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: " ", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Just empty quotes", + Input: "\"\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted single space", + Input: "\" \"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Unknown, + ExpectedTarget: " ", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> RealWorldCommandScenarios() => + [ +#if CMDPAL_ENABLE_UNSAFE_TESTS + [ + new PlaceholderClassificationCase( + Name: "Git clone command with full exe path with quoted path", + Input: "\"C:\\Program Files\\Git\\bin\\git.exe\" clone repo", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\Git\\bin\\git.exe", + ExpectedArguments: "clone repo", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Git clone command with quoted path", + Input: "git clone repo", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Program Files\\Git\\cmd\\git.EXE", + ExpectedArguments: "clone repo", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Visual Studio devenv with solution", + Input: "\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe\" solution.sln", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe", + ExpectedArguments: "solution.sln", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Double-quoted Windows cmd pattern", + Input: "cmd /c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedArguments: "/c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"", + ExpectedIsPlaceholder: false) + ], +#endif + [ + new PlaceholderClassificationCase( + Name: "PowerShell script with execution policy", + Input: "powershell -ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\PowerShell.exe", + ExpectedArguments: "-ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + ]; + + public static IEnumerable<object[]> SpecialCharactersInPaths() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted path with square brackets", + Input: "\"C:\\Path\\file[1].txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Path\\file[1].txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with parentheses", + Input: "\"C:\\Folder (2)\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Folder (2)\\app.exe", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with hyphens and underscores", + Input: "\"C:\\Path\\file_name-123.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Path\\file_name-123.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> QuotedPathsCurrentlyBroken() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted path with spaces - complete path", + Input: "\"C:\\Program Files\\MyApp\\app.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\MyApp\\app.exe", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with spaces in user folder", + Input: "\"C:\\Users\\John Doe\\Documents\\file.txt\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\Users\\John Doe\\Documents\\file.txt", + ExpectedArguments: string.Empty, + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with trailing arguments", + Input: "\"C:\\Program Files\\app.exe\" --flag", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Program Files\\app.exe", + ExpectedArguments: "--flag", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with multiple arguments", + Input: "\"C:\\My Documents\\file.txt\" -output result.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileDocument, + ExpectedTarget: "C:\\My Documents\\file.txt", + ExpectedArguments: "-output result.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted path with trailing flag and value", + Input: "\"C:\\Tools\\converter.exe\" input.txt output.txt", + ExpectSuccess: true, + ExpectedKind: CommandKind.FileExecutable, + ExpectedTarget: "C:\\Tools\\converter.exe", + ExpectedArguments: "input.txt output.txt", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> QuotedPathsInCommands() => + [ + [ + new PlaceholderClassificationCase( + Name: "cmd /c with quoted path", + Input: "cmd /c \"C:\\Program Files\\tool.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\cmd.exe", + ExpectedArguments: "/c \"C:\\Program Files\\tool.exe\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "PowerShell with quoted script path", + Input: "powershell -File \"C:\\Scripts\\my script.ps1\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", path4: "powershell.exe"), + ExpectedArguments: "-File \"C:\\Scripts\\my script.ps1\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ], + [ + new PlaceholderClassificationCase( + Name: "runas with quoted executable", + Input: "runas /user:admin \"C:\\Windows\\System32\\cmd.exe\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.PathCommand, + ExpectedTarget: "C:\\Windows\\system32\\runas.exe", + ExpectedArguments: "/user:admin \"C:\\Windows\\System32\\cmd.exe\"", + ExpectedLaunch: LaunchMethod.ShellExecute, + ExpectedIsPlaceholder: false) + ] + ]; + + public static IEnumerable<object[]> QuotedAumid() => + [ + [ + new PlaceholderClassificationCase( + Name: "Quoted UWP AUMID via AppsFolder", + Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\"", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + [ + new PlaceholderClassificationCase( + Name: "Quoted UWP AUMID with AppsFolder prefix and argument", + Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\" --maximized", + ExpectSuccess: true, + ExpectedKind: CommandKind.Aumid, + ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", + ExpectedArguments: "--maximized", + ExpectedLaunch: LaunchMethod.ActivateAppId, + ExpectedIsPlaceholder: false), + ], + ]; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.cs new file mode 100644 index 0000000000..16378a7cd7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarkResolverTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public partial class BookmarkResolverTests +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private static string _testDirPath; + private static string _userHomeDirPath; + private static string _testDirName; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + [ClassInitialize] + public static void ClassSetup(TestContext context) + { + _userHomeDirPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + _testDirName = "CmdPalBookmarkTests" + Guid.NewGuid().ToString("N"); + _testDirPath = Path.Combine(_userHomeDirPath, _testDirName); + Directory.CreateDirectory(_testDirPath); + + // test files in user home + File.WriteAllText(Path.Combine(_userHomeDirPath, "file.txt"), "This is a test text file."); + + // test files in test dir + File.WriteAllText(Path.Combine(_testDirPath, "file.txt"), "This is a test text file."); + File.WriteAllText(Path.Combine(_testDirPath, "app.exe"), "This is a test text file."); + } + + [ClassCleanup] + public static void ClassCleanup() + { + if (Directory.Exists(_testDirPath)) + { + Directory.Delete(_testDirPath, true); + } + + if (File.Exists(Path.Combine(_userHomeDirPath, "file.txt"))) + { + File.Delete(Path.Combine(_userHomeDirPath, "file.txt")); + } + } + + // must be public static to be used as DataTestMethod data source + public static string FromCase(MethodInfo method, object[] data) + => data is [PlaceholderClassificationCase c] + ? c.Name + : $"{method.Name}({string.Join(", ", data.Select(row => row.ToString()))})"; + + private static async Task RunShared(PlaceholderClassificationCase c) + { + // Arrange + IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser()); + + // Act + var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None); + + // Assert + Assert.IsNotNull(classification); + Assert.AreEqual(c.ExpectSuccess, classification.Success, "Success flag mismatch."); + + if (c.ExpectSuccess) + { + Assert.IsNotNull(classification.Result, "Result should not be null for successful classification."); + Assert.AreEqual(c.ExpectedKind, classification.Result.Kind, $"CommandKind mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedTarget, classification.Result.Target, StringComparer.OrdinalIgnoreCase, $"Target mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedLaunch, classification.Result.Launch, $"LaunchMethod mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedArguments, classification.Result.Arguments, $"Arguments mismatch for input: {c.Input}"); + Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder, $"IsPlaceholder mismatch for input: {c.Input}"); + + if (c.ExpectedDisplayName != null) + { + Assert.AreEqual(c.ExpectedDisplayName, classification.Result.DisplayName, $"DisplayName mismatch for input: {c.Input}"); + } + } + } + + public sealed record PlaceholderClassificationCase( + string Name, // Friendly name for Test Explorer + string Input, // Input string passed to classifier + bool ExpectSuccess, // Expected Success flag + CommandKind ExpectedKind, // Expected Result.Kind + string ExpectedTarget, // Expected Result.Target (normalized) + LaunchMethod ExpectedLaunch, // Expected Result.Launch + bool ExpectedIsPlaceholder, // Expected Result.IsPlaceholder + string ExpectedArguments = "", // Expected Result.Arguments + string? ExpectedDisplayName = null // Expected Result.DisplayName + ); +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs new file mode 100644 index 0000000000..82b961649c --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/BookmarksCommandProviderTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class BookmarksCommandProviderTests +{ + [TestMethod] + public void ProviderHasCorrectId() + { + // Setup + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); + + // Assert + Assert.AreEqual("Bookmarks", provider.Id); + } + + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } + + [TestMethod] + [Timeout(5000)] + public async Task ProviderWithMockData_LoadsBookmarksCorrectly() + { + // Arrange + var mockBookmarkManager = new MockBookmarkManager( + new BookmarkData("Test Bookmark", "http://test.com"), + new BookmarkData("Another Bookmark", "http://another.com")); + var provider = new BookmarksCommandProvider(mockBookmarkManager); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands, "commands != null"); + + // Should have three commands:Add + two custom bookmarks + Assert.AreEqual(3, commands.Length); + + // Wait until all BookmarkListItem commands are initialized + await Task.WhenAll(commands.OfType<Pages.BookmarkListItem>().Select(t => t.IsInitialized)); + + var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark")); + var testBookmark = commands.FirstOrDefault(c => c.Title.Contains("Test Bookmark")); + + Assert.IsNotNull(addCommand, "addCommand != null"); + Assert.IsNotNull(testBookmark, "testBookmark != null"); + } + + [TestMethod] + public void ProviderWithEmptyData_HasOnlyAddCommand() + { + // Arrange + var mockBookmarkManager = new MockBookmarkManager(); + var provider = new BookmarksCommandProvider(mockBookmarkManager); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + + // Only have Add command + Assert.AreEqual(1, commands.Length); + + var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark")); + Assert.IsNotNull(addCommand); + } + + [TestMethod] + public void ProviderWithInvalidData_HandlesGracefully() + { + // Arrange + var dataSource = new MockBookmarkDataSource("invalid json"); + var provider = new BookmarksCommandProvider(new MockBookmarkManager()); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + + // Only have one command. Will ignore json parse error. + Assert.AreEqual(1, commands.Length); + + var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark")); + Assert.IsNotNull(addCommand); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/CommandLineHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/CommandLineHelperTests.cs new file mode 100644 index 0000000000..977f3b5006 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/CommandLineHelperTests.cs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable +using System; +using System.IO; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class CommandLineHelperTests +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + private static string _tempTestDir; + + private static string _tempTestFile; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + [ClassInitialize] + public static void ClassSetup(TestContext context) + { + // Create temporary test directory and file + _tempTestDir = Path.Combine(Path.GetTempPath(), "CommandLineHelperTests_" + Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempTestDir); + + _tempTestFile = Path.Combine(_tempTestDir, "testfile.txt"); + File.WriteAllText(_tempTestFile, "test"); + } + + [ClassCleanup] + public static void ClassCleanup() + { + // Clean up test directory + if (Directory.Exists(_tempTestDir)) + { + Directory.Delete(_tempTestDir, true); + } + } + + [TestMethod] + [DataRow("%TEMP%", false, true, DisplayName = "Expands TEMP environment variable")] + [DataRow("%USERPROFILE%", false, true, DisplayName = "Expands USERPROFILE environment variable")] + [DataRow("%SystemRoot%", false, true, DisplayName = "Expands SystemRoot environment variable")] + public void Expand_WithEnvironmentVariables_ExpandsCorrectly(string input, bool expandShell, bool shouldExist) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + Assert.AreEqual(shouldExist, result, $"Expected result {shouldExist} for input '{input}'"); + if (shouldExist) + { + Assert.IsFalse(full.Contains('%'), "Output should not contain % symbols after expansion"); + Assert.IsTrue(Path.Exists(full), $"Expanded path '{full}' should exist"); + } + } + + [TestMethod] + [DataRow("shell:Downloads", true, DisplayName = "Expands shell:Downloads when expandShell is true")] + [DataRow("shell:Desktop", true, DisplayName = "Expands shell:Desktop when expandShell is true")] + [DataRow("shell:Documents", true, DisplayName = "Expands shell:Documents when expandShell is true")] + public void Expand_WithShellPaths_ExpandsWhenFlagIsTrue(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + if (result) + { + Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved"); + Assert.IsTrue(Path.Exists(full), $"Expanded shell path '{full}' should exist"); + } + + // Note: Result may be false if ShellNames.TryGetFileSystemPath fails + } + + [TestMethod] + [DataRow("shell:Personal", false, DisplayName = "Does not expand shell: when expandShell is false")] + public void Expand_WithShellPaths_DoesNotExpandWhenFlagIsFalse(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert - shell: paths won't exist as literal paths + Assert.IsFalse(result, "Should return false for unexpanded shell path"); + Assert.AreEqual(input, full, "Output should match input when not expanding shell paths"); + } + + [TestMethod] + [DataRow("shell:Personal\\subfolder", true, "\\subfolder", DisplayName = "Expands shell path with subfolder")] + [DataRow("shell:Desktop\\test.txt", true, "\\test.txt", DisplayName = "Expands shell path with file")] + public void Expand_WithShellPathsAndSubpaths_CombinesCorrectly(string input, bool expandShell, string expectedEnding) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Note: Result depends on whether the combined path exists + if (result) + { + Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved"); + Assert.IsTrue(full.EndsWith(expectedEnding, StringComparison.OrdinalIgnoreCase), "Output should end with the subpath"); + } + } + + [TestMethod] + public void Expand_WithExistingDirectory_ReturnsFullPath() + { + // Arrange + var input = _tempTestDir; + + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full); + + // Assert + Assert.IsTrue(result, "Should return true for existing directory"); + Assert.AreEqual(Path.GetFullPath(_tempTestDir), full, "Should return full path"); + } + + [TestMethod] + public void Expand_WithExistingFile_ReturnsFullPath() + { + // Arrange + var input = _tempTestFile; + + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full); + + // Assert + Assert.IsTrue(result, "Should return true for existing file"); + Assert.AreEqual(Path.GetFullPath(_tempTestFile), full, "Should return full path"); + } + + [TestMethod] + [DataRow("C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", false, "C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", DisplayName = "Nonexistent absolute path")] + [DataRow("NonExistentFile.txt", false, "NonExistentFile.txt", DisplayName = "Nonexistent relative path")] + public void Expand_WithNonExistentPath_ReturnsFalse(string input, bool expandShell, string expectedFull) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + Assert.IsFalse(result, "Should return false for nonexistent path"); + Assert.AreEqual(expectedFull, full, "Output should be empty string"); + } + + [TestMethod] + [DataRow("", false, DisplayName = "Empty string")] + [DataRow(" ", false, DisplayName = "Whitespace only")] + public void Expand_WithEmptyOrWhitespace_ReturnsFalse(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + Assert.IsFalse(result, "Should return false for empty/whitespace input"); + } + + [TestMethod] + [DataRow("%TEMP%\\testsubdir", false, DisplayName = "Env var with subdirectory")] + [DataRow("%USERPROFILE%\\Desktop", false, DisplayName = "USERPROFILE with Desktop")] + public void Expand_WithEnvironmentVariableAndSubpath_ExpandsCorrectly(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Result depends on whether the path exists + if (result) + { + Assert.IsFalse(full.Contains('%'), "Should expand environment variables"); + Assert.IsTrue(Path.Exists(full), "Expanded path should exist"); + } + } + + [TestMethod] + public void Expand_WithRelativePath_ConvertsToAbsoluteWhenExists() + { + // Arrange + var relativePath = Path.GetRelativePath(Environment.CurrentDirectory, _tempTestDir); + + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(relativePath, false, out var full); + + // Assert + if (result) + { + Assert.IsTrue(Path.IsPathRooted(full), "Output should be absolute path"); + Assert.IsTrue(Path.Exists(full), "Expanded path should exist"); + } + } + + [TestMethod] + [DataRow("InvalidShell:Path", true, DisplayName = "Invalid shell path format")] + public void Expand_WithInvalidShellPath_ReturnsFalse(string input, bool expandShell) + { + // Act + var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full); + + // Assert + // If ShellNames.TryGetFileSystemPath returns false, method returns false + Assert.IsFalse(result || Path.Exists(full), "Should return false or path should not exist"); + } + + [DataTestMethod] + + // basic + [DataRow("cmd ping", "cmd", "ping")] + [DataRow("cmd ping pong", "cmd", "ping pong")] + [DataRow("cmd \"ping pong\"", "cmd", "\"ping pong\"")] + + // no tail / trailing whitespace after head + [DataRow("cmd", "cmd", "")] + [DataRow("cmd ", "cmd", "")] + + // spacing & tabs between args should be preserved in tail + [DataRow("cmd ping pong", "cmd", "ping pong")] + [DataRow("cmd\tping\tpong", "cmd", "ping\tpong")] + + // leading whitespace before head + [DataRow(" cmd ping", "", "cmd ping")] + [DataRow("\t cmd ping", "", "cmd ping")] + + // quoted tail variants + [DataRow("cmd \"\"", "cmd", "\"\"")] + [DataRow("cmd \"a \\\"quoted\\\" arg\" b", "cmd", "\"a \\\"quoted\\\" arg\" b")] + + // quoted head (spaces in path) + [DataRow(@"""C:\Program Files\nodejs\node.exe"" -v", @"C:\Program Files\nodejs\node.exe", "-v")] + [DataRow(@"""C:\Program Files\Git\bin\bash.exe""", @"C:\Program Files\Git\bin\bash.exe", "")] + [DataRow(@" ""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""", @"", @"""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""")] + [DataRow(@"""C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe"" Test.sln", @"C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe", "Test.sln")] + + // quoted simple head (still should strip quotes for head) + [DataRow(@"""cmd"" ping", "cmd", "ping")] + + // common CLI shapes + [DataRow("git --version", "git", "--version")] + [DataRow("dotnet build -c Release", "dotnet", "build -c Release")] + + // UNC paths + [DataRow("\"\\\\server\\share\\\" with args", "\\\\server\\share\\", "with args")] + public void SplitHeadAndArgs(string input, string expectedHead, string expectedTail) + { + // Act + var result = CommandLineHelper.SplitHeadAndArgs(input); + + // Assert + // If ShellNames.TryGetFileSystemPath returns false, method returns false + Assert.AreEqual(expectedHead, result.Head); + Assert.AreEqual(expectedTail, result.Tail); + } + + [DataTestMethod] + [DataRow(@"C:\program files\myapp\app.exe -param ""1"" -param 2", @"C:\program files\myapp\app.exe -param", @"""1"" -param 2")] + [DataRow(@"git commit -m test", "git commit -m test", "")] + [DataRow(@"""C:\Program Files\App\app.exe"" -v", "", @"""C:\Program Files\App\app.exe"" -v")] + [DataRow(@"tool a\\\""b c ""d e"" f", @"tool a\\\""b c", @"""d e"" f")] // escaped quote before first real one + [DataRow("C:\\Some\\\"Path\\file.txt", "C:\\Some\\\"Path\\file.txt", "")] + [DataRow(@" ""C:\p\app.exe"" -v", "", @"""C:\p\app.exe"" -v")] // first token is quoted + public void SplitLongestHeadBeforeQuotedArg_Tests(string input, string expectedHead, string expectedTail) + { + var (head, tail) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input); + Assert.AreEqual(expectedHead, head); + Assert.AreEqual(expectedTail, tail); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj new file mode 100644 index 0000000000..22577866f4 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Ext.Bookmarks.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Bookmark\Microsoft.CmdPal.Ext.Bookmarks.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs new file mode 100644 index 0000000000..02d71d6d77 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkDataSource.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +internal sealed class MockBookmarkDataSource : IBookmarkDataSource +{ + private string _jsonData; + private int _saveCount; + + public MockBookmarkDataSource(string initialJsonData = "[]") + { + _jsonData = initialJsonData; + } + + public string GetBookmarkData() + { + return _jsonData; + } + + public void SaveBookmarkData(string jsonData) + { + _jsonData = jsonData; + Interlocked.Increment(ref _saveCount); + } + + public int SaveCount => Volatile.Read(ref _saveCount); + + // Waits until at least expectedMinSaves have occurred or the timeout elapses. + // Returns true if the condition was met, false on timeout. + public bool WaitForSave(int expectedMinSaves = 1, int timeoutMs = 2000) + { + var start = Environment.TickCount; + while (Volatile.Read(ref _saveCount) < expectedMinSaves) + { + if (Environment.TickCount - start > timeoutMs) + { + return false; + } + + Thread.Sleep(50); + } + + return true; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkManager.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkManager.cs new file mode 100644 index 0000000000..b3e48db791 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/MockBookmarkManager.cs @@ -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. + +using System; +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +#pragma warning disable CS0067 + +internal sealed class MockBookmarkManager : IBookmarksManager +{ + private readonly List<BookmarkData> _bookmarks; + + public event Action<BookmarkData> BookmarkAdded; + + public event Action<BookmarkData, BookmarkData> BookmarkUpdated; + + public event Action<BookmarkData> BookmarkRemoved; + + public IReadOnlyCollection<BookmarkData> Bookmarks => _bookmarks; + + public BookmarkData Add(string name, string bookmark) => throw new NotImplementedException(); + + public bool Remove(Guid id) => throw new NotImplementedException(); + + public BookmarkData Update(Guid id, string name, string bookmark) => throw new NotImplementedException(); + + public MockBookmarkManager(params IEnumerable<BookmarkData> bookmarks) + { + _bookmarks = [.. bookmarks]; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderInfoNameEqualityComparerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderInfoNameEqualityComparerTests.cs new file mode 100644 index 0000000000..b7e5933aa8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderInfoNameEqualityComparerTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class PlaceholderInfoNameEqualityComparerTests +{ + [TestMethod] + public void Equals_BothNull_ReturnsTrue() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + + var result = comparer.Equals(null, null); + + Assert.IsTrue(result); + } + + [TestMethod] + public void Equals_OneNull_ReturnsFalse() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p = new PlaceholderInfo("name", 0); + + Assert.IsFalse(comparer.Equals(p, null)); + Assert.IsFalse(comparer.Equals(null, p)); + } + + [TestMethod] + public void Equals_SameNameDifferentIndex_ReturnsTrue() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("name", 0); + var p2 = new PlaceholderInfo("name", 10); + + Assert.IsTrue(comparer.Equals(p1, p2)); + } + + [TestMethod] + public void Equals_DifferentNameSameIndex_ReturnsFalse() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("first", 3); + var p2 = new PlaceholderInfo("second", 3); + + Assert.IsFalse(comparer.Equals(p1, p2)); + } + + [TestMethod] + public void Equals_CaseInsensitive_ReturnsTrue() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("Name", 0); + var p2 = new PlaceholderInfo("name", 5); + + Assert.IsTrue(comparer.Equals(p1, p2)); + Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2)); + } + + [TestMethod] + public void GetHashCode_SameNameDifferentIndex_SameHash() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + var p1 = new PlaceholderInfo("same", 1); + var p2 = new PlaceholderInfo("same", 99); + + Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2)); + } + + [TestMethod] + public void GetHashCode_Null_ThrowsArgumentNullException() + { + var comparer = PlaceholderInfoNameEqualityComparer.Instance; + Assert.ThrowsException<ArgumentNullException>(() => comparer.GetHashCode(null!)); + } + + [TestMethod] + public void Instance_ReturnsSingleton() + { + var a = PlaceholderInfoNameEqualityComparer.Instance; + var b = PlaceholderInfoNameEqualityComparer.Instance; + + Assert.IsNotNull(a); + Assert.AreSame(a, b); + } + + [TestMethod] + public void HashSet_UsesNameEquality_IgnoresIndex() + { + var set = new HashSet<PlaceholderInfo>(PlaceholderInfoNameEqualityComparer.Instance) + { + new("dup", 0), + new("DUP", 10), + new("unique", 0), + }; + + Assert.AreEqual(2, set.Count); + Assert.IsTrue(set.Contains(new PlaceholderInfo("dup", 123))); + Assert.IsTrue(set.Contains(new PlaceholderInfo("UNIQUE", 999))); + Assert.IsFalse(set.Contains(new PlaceholderInfo("missing", 0))); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderParserTests.cs new file mode 100644 index 0000000000..31abeb0195 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/PlaceholderParserTests.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class PlaceholderParserTests +{ + private IPlaceholderParser _parser; + + [TestInitialize] + public void Setup() + { + _parser = new PlaceholderParser(); + } + + public static IEnumerable<object[]> ValidPlaceholderTestData => + [ + [ + "Hello {name}!", + true, + "Hello ", + new[] { "name" }, + new[] { 6 } + ], + [ + "User {user_name} has {count} items", + true, + "User ", + new[] { "user_name", "count" }, + new[] { 5, 21 } + ], + [ + "Order {order-id} for {name} by {name}", + true, + "Order ", + new[] { "order-id", "name", "name" }, + new[] { 6, 21, 31 } + ], + [ + "{start} and {end}", + true, + string.Empty, + new[] { "start", "end" }, + new[] { 0, 12 } + ], + [ + "Number {123} and text {abc}", + true, + "Number ", + new[] { "123", "abc" }, + new[] { 7, 22 } + ] + ]; + + public static IEnumerable<object[]> InvalidPlaceholderTestData => + [ + [string.Empty, false, string.Empty, Array.Empty<string>()], + ["No placeholders here", false, "No placeholders here", Array.Empty<string>()], + ["GUID: {550e8400-e29b-41d4-a716-446655440000}", false, "GUID: {550e8400-e29b-41d4-a716-446655440000}", Array.Empty<string>()], + ["Invalid {user.name} placeholder", false, "Invalid {user.name} placeholder", Array.Empty<string>()], + ["Empty {} placeholder", false, "Empty {} placeholder", Array.Empty<string>()], + ["Unclosed {placeholder", false, "Unclosed {placeholder", Array.Empty<string>()], + ["No opening brace placeholder}", false, "No opening brace placeholder}", Array.Empty<string>()], + ["Invalid chars {user@domain}", false, "Invalid chars {user@domain}", Array.Empty<string>()], + ["Spaces { name }", false, "Spaces { name }", Array.Empty<string>()] + ]; + + [TestMethod] + [DynamicData(nameof(ValidPlaceholderTestData))] + public void ParsePlaceholders_ValidInput_ReturnsExpectedResults( + string input, + bool expectedResult, + string expectedHead, + string[] expectedPlaceholderNames, + int[] expectedIndexes) + { + // Act + var result = _parser.ParsePlaceholders(input, out var head, out var placeholders); + + // Assert + Assert.AreEqual(expectedResult, result); + Assert.AreEqual(expectedHead, head); + Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count); + + var actualNames = placeholders.Select(p => p.Name).ToArray(); + var actualIndexes = placeholders.Select(p => p.Index).ToArray(); + + // Validate names and indexes (allow duplicates, ignore order) + CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames); + CollectionAssert.AreEquivalent(expectedIndexes, actualIndexes); + + // Validate name-index pairing exists for each expected placeholder occurrence + for (var i = 0; i < expectedPlaceholderNames.Length; i++) + { + var expectedName = expectedPlaceholderNames[i]; + var expectedIndex = expectedIndexes[i]; + Assert.IsTrue( + placeholders.Any(p => p.Name == expectedName && p.Index == expectedIndex), + $"Expected placeholder '{{{expectedName}}}' at index {expectedIndex} was not found."); + } + } + + [TestMethod] + [DynamicData(nameof(InvalidPlaceholderTestData))] + public void ParsePlaceholders_InvalidInput_ReturnsExpectedResults( + string input, + bool expectedResult, + string expectedHead, + string[] expectedPlaceholderNames) + { + // Act + var result = _parser.ParsePlaceholders(input, out var head, out var placeholders); + + // Assert + Assert.AreEqual(expectedResult, result); + Assert.AreEqual(expectedHead, head); + Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count); + + var actualNames = placeholders.Select(p => p.Name).ToArray(); + CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames); + } + + [TestMethod] + public void ParsePlaceholders_NullInput_ThrowsArgumentNullException() + { + Assert.ThrowsException<ArgumentNullException>(() => _parser.ParsePlaceholders(null!, out _, out _)); + } + + [TestMethod] + public void Placeholder_Equality_WorksCorrectly() + { + // Arrange + var placeholder1 = new PlaceholderInfo("name", 0); + var placeholder2 = new PlaceholderInfo("name", 0); + var placeholder3 = new PlaceholderInfo("other", 0); + var placeholder4 = new PlaceholderInfo("name", 1); + + // Assert + Assert.AreEqual(placeholder1, placeholder2); + Assert.AreNotEqual(placeholder1, placeholder3); + Assert.AreEqual(placeholder1.GetHashCode(), placeholder2.GetHashCode()); + Assert.AreNotEqual(placeholder1, placeholder4); + Assert.AreNotEqual(placeholder1.GetHashCode(), placeholder4.GetHashCode()); + } + + [TestMethod] + public void Placeholder_ToString_ReturnsName() + { + // Arrange + var placeholder = new PlaceholderInfo("userName", 0); + + // Assert + Assert.AreEqual("userName", placeholder.ToString()); + } + + [TestMethod] + public void Placeholder_Constructor_ThrowsOnNull() + { + // Assert + Assert.ThrowsException<ArgumentNullException>(() => new PlaceholderInfo(null!, 0)); + } + + [TestMethod] + public void Placeholder_Constructor_ThrowsArgumentOutOfRange() + { + // Assert + Assert.ThrowsException<ArgumentOutOfRangeException>(() => new PlaceholderInfo("Name", -1)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..e079be0655 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/QueryTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [TestMethod] + public void ValidateBookmarksCreation() + { + // Setup + var bookmarks = Settings.CreateDefaultBookmarks(); + + // Assert + Assert.IsNotNull(bookmarks); + Assert.IsNotNull(bookmarks.Data); + Assert.AreEqual(2, bookmarks.Data.Count); + } + + [TestMethod] + public void ValidateBookmarkData() + { + // Setup + var bookmarks = Settings.CreateDefaultBookmarks(); + + // Act + var microsoftBookmark = bookmarks.Data.FirstOrDefault(b => b.Name == "Microsoft"); + var githubBookmark = bookmarks.Data.FirstOrDefault(b => b.Name == "GitHub"); + + // Assert + Assert.IsNotNull(microsoftBookmark); + Assert.AreEqual("https://www.microsoft.com", microsoftBookmark.Bookmark); + + Assert.IsNotNull(githubBookmark); + Assert.AreEqual("https://github.com", githubBookmark.Bookmark); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs new file mode 100644 index 0000000000..3bfd7391d0 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/Settings.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +public static class Settings +{ + public static BookmarksData CreateDefaultBookmarks() + { + var bookmarks = new BookmarksData(); + + // Add some test bookmarks + bookmarks.Data.Add(new BookmarkData + { + Name = "Microsoft", + Bookmark = "https://www.microsoft.com", + }); + + bookmarks.Data.Add(new BookmarkData + { + Name = "GitHub", + Bookmark = "https://github.com", + }); + + return bookmarks; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/UriHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/UriHelperTests.cs new file mode 100644 index 0000000000..4731cfeddc --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Bookmarks.UnitTests/UriHelperTests.cs @@ -0,0 +1,120 @@ +// 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.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests; + +[TestClass] +public class UriHelperTests +{ + private static bool TryGetScheme(ReadOnlySpan<char> input, out string scheme, out string remainder) + { + return UriHelper.TryGetScheme(input, out scheme, out remainder); + } + + [DataTestMethod] + [DataRow("http://example.com", "http", "//example.com")] + [DataRow("ftp:", "ftp", "")] + [DataRow("my-app:payload", "my-app", "payload")] + [DataRow("x-cmdpal://settings/", "x-cmdpal", "//settings/")] + [DataRow("custom+ext.-scheme:xyz", "custom+ext.-scheme", "xyz")] + [DataRow("MAILTO:foo@bar", "MAILTO", "foo@bar")] + [DataRow("a:b", "a", "b")] + public void TryGetScheme_ValidSchemes_ReturnsTrueAndSplits(string input, string expectedScheme, string expectedRemainder) + { + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok, "Expected valid scheme."); + Assert.AreEqual(expectedScheme, scheme); + Assert.AreEqual(expectedRemainder, remainder); + } + + [TestMethod] + public void TryGetScheme_OnlySchemeAndColon_ReturnsEmptyRemainder() + { + var ok = TryGetScheme("http:".AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual("http", scheme); + Assert.AreEqual(string.Empty, remainder); + } + + [DataTestMethod] + [DataRow("123:http")] // starts with digit + [DataRow(":nope")] // colon at start + [DataRow("noColon")] // no colon at all + [DataRow("bad_scheme:")] // underscore not allowed + [DataRow("bad*scheme:")] // asterisk not allowed + [DataRow(":")] // syntactically invalid literal just for completeness; won't compile, example only + public void TryGetScheme_InvalidInputs_ReturnsFalse(string input) + { + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsFalse(ok); + Assert.AreEqual(string.Empty, scheme); + Assert.AreEqual(string.Empty, remainder); + } + + [TestMethod] + public void TryGetScheme_MultipleColons_SplitsOnFirst() + { + const string input = "shell:::{645FF040-5081-101B-9F08-00AA002F954E}"; + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual("shell", scheme); + Assert.AreEqual("::{645FF040-5081-101B-9F08-00AA002F954E}", remainder); + } + + [TestMethod] + public void TryGetScheme_MinimumLength_OneLetterAndColon() + { + var ok = TryGetScheme("a:".AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual("a", scheme); + Assert.AreEqual(string.Empty, remainder); + } + + [TestMethod] + public void TryGetScheme_TooShort_ReturnsFalse() + { + Assert.IsFalse(TryGetScheme("a".AsSpan(), out _, out _), "No colon."); + Assert.IsFalse(TryGetScheme(":".AsSpan(), out _, out _), "Colon at start; no scheme."); + } + + [DataTestMethod] + [DataRow("HTTP://x", "HTTP", "//x")] + [DataRow("hTtP:rest", "hTtP", "rest")] + public void TryGetScheme_CaseIsPreserved(string input, string expectedScheme, string expectedRemainder) + { + var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder); + + Assert.IsTrue(ok); + Assert.AreEqual(expectedScheme, scheme); + Assert.AreEqual(expectedRemainder, remainder); + } + + [TestMethod] + public void TryGetScheme_WhitespaceInsideScheme_Fails() + { + Assert.IsFalse(TryGetScheme("ht tp:rest".AsSpan(), out _, out _)); + } + + [TestMethod] + public void TryGetScheme_PlusMinusDot_AllowedInMiddleOnly() + { + Assert.IsTrue(TryGetScheme("a+b.c-d:rest".AsSpan(), out var s1, out var r1)); + Assert.AreEqual("a+b.c-d", s1); + Assert.AreEqual("rest", r1); + + // The first character must be a letter; plus is not allowed as first char + Assert.IsFalse(TryGetScheme("+abc:rest".AsSpan(), out _, out _)); + Assert.IsFalse(TryGetScheme(".abc:rest".AsSpan(), out _, out _)); + Assert.IsFalse(TryGetScheme("-abc:rest".AsSpan(), out _, out _)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/BracketHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/BracketHelperTests.cs new file mode 100644 index 0000000000..0209f37ec9 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/BracketHelperTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +[TestClass] +public class BracketHelperTests +{ + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("\t \r\n")] + [DataRow("none")] + [DataRow("()")] + [DataRow("(())")] + [DataRow("()()")] + [DataRow("(()())")] + [DataRow("([][])")] + [DataRow("([(()[])[](([]()))])")] + public void IsBracketComplete_TestValid_WhenCalled(string input) + { + // Arrange + + // Act + var result = BracketHelper.IsBracketComplete(input); + + // Assert + Assert.IsTrue(result); + } + + [DataTestMethod] + [DataRow("((((", "only opening brackets")] + [DataRow("]]]", "only closing brackets")] + [DataRow("([)(])", "inner bracket mismatch")] + [DataRow(")(", "opening and closing reversed")] + [DataRow("(]", "mismatch in bracket type")] + public void IsBracketComplete_TestInvalid_WhenCalled(string input, string invalidReason) + { + // Arrange + + // Act + var result = BracketHelper.IsBracketComplete(input); + + // Assert + Assert.IsFalse(result, invalidReason); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/CloseOnEnterTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/CloseOnEnterTests.cs new file mode 100644 index 0000000000..04771fc621 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/CloseOnEnterTests.cs @@ -0,0 +1,64 @@ +// 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.Linq; +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +[TestClass] +public class CloseOnEnterTests +{ + [TestMethod] + public void PrimaryIsCopy_WhenCloseOnEnterTrue() + { + var settings = new Settings(closeOnEnter: true); + TypedEventHandler<object, object> handleSave = (s, e) => { }; + TypedEventHandler<object, object> handleReplace = (s, e) => { }; + + var item = ResultHelper.CreateResult( + 4m, + CultureInfo.CurrentCulture, + CultureInfo.CurrentCulture, + "2+2", + settings, + handleSave, + handleReplace); + + Assert.IsNotNull(item); + Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand)); + + var firstMore = item.MoreCommands.First(); + Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem)); + Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(SaveCommand)); + } + + [TestMethod] + public void PrimaryIsSave_WhenCloseOnEnterFalse() + { + var settings = new Settings(closeOnEnter: false); + TypedEventHandler<object, object> handleSave = (s, e) => { }; + TypedEventHandler<object, object> handleReplace = (s, e) => { }; + + var item = ResultHelper.CreateResult( + 4m, + CultureInfo.CurrentCulture, + CultureInfo.CurrentCulture, + "2+2", + settings, + handleSave, + handleReplace); + + Assert.IsNotNull(item); + Assert.IsInstanceOfType(item.Command, typeof(SaveCommand)); + + var firstMore = item.MoreCommands.First(); + Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem)); + Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(CopyTextCommand)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/ExtendedCalculatorParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/ExtendedCalculatorParserTests.cs new file mode 100644 index 0000000000..85b712fe20 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/ExtendedCalculatorParserTests.cs @@ -0,0 +1,413 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +[TestClass] +public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase +{ + [DataTestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void InputValid_ThrowError_WhenCalledNullOrEmpty(string input) + { + // Act + Assert.IsTrue(!CalculateHelper.InputValid(input)); + } + + [DataTestMethod] + [DataRow("test")] + [DataRow("[10,10]")] // '[10,10]' is interpreted as array by mages engine + public void Interpret_NoResult_WhenCalled(string input) + { + var settings = new Settings(); + + var result = CalculateEngine.Interpret(settings, input, CultureInfo.CurrentCulture, out _); + + // Assert + Assert.AreEqual(default(CalculateResult), result); + } + + private static IEnumerable<object[]> Interpret_NoErrors_WhenCalledWithRounding_Data => + [ + ["2 * 2", 4M], + ["-2 ^ 2", -4M], + ["-(2 ^ 2)", -4M], + ["2 * pi", 6.28318530717959M], + ["round(2 * pi)", 6M], + + // ["1 == 2", default(decimal)], + ["pi * ( sin ( cos ( 2)))", -1.26995475603563M], + ["5.6/2", 2.8M], + ["123 * 4.56", 560.88M], + ["1 - 9.0 / 10", 0.1M], + ["0.5 * ((2*-395.2)+198.2)", -296.1M], + ["2+2.11", 4.11M], + ["8.43 + 4.43 - 12.86", 0M], + ["8.43 + 4.43 - 12.8", 0.06M], + ["exp(5)", 148.413159102577M], + ["e^5", 148.413159102577M], + ["e*2", 5.43656365691809M], + ["ln(3)", 1.09861228866811M], + ["log(3)", 0.47712125471966M], + ["log2(3)", 1.58496250072116M], + ["log10(3)", 0.47712125471966M], + ["ln(e)", 1M], + ["cosh(0)", 1M], + ["1*10^(-5)", 0.00001M], + ["1*10^(-15)", 0.0000000000000001M], + ["1*10^(-16)", 0M], + ]; + + [DataTestMethod] + [DynamicData(nameof(Interpret_NoErrors_WhenCalledWithRounding_Data))] + public void Interpret_NoErrors_WhenCalledWithRounding(string input, decimal expectedResult) + { + var settings = new Settings(); + + // Act + // Using InvariantCulture since this is internal + var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out _); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(CalculateEngine.FormatMax15Digits(expectedResult, new CultureInfo("en-US")), result.RoundedResult); + } + + private static IEnumerable<object[]> Interpret_GreaterPrecision_WhenCalled_Data => + [ + ["0.100000000000000000000", 0.1M], + ["0.200000000000000000000000", 0.2M], + ]; + + [DynamicData(nameof(Interpret_GreaterPrecision_WhenCalled_Data))] + [DataTestMethod] + public void Interpret_GreaterPrecision_WhenCalled(string input, decimal expectedResult) + { + // Arrange + var settings = new Settings(); + + // Act + // Using InvariantCulture since this is internal + var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out _); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result.Result); + } + + private static IEnumerable<object[]> Interpret_DifferentCulture_WhenCalled_Data => + [ + ["4.5/3", 1.5M, "nl-NL"], + ["4.5/3", 1.5M, "en-EN"], + ["4.5/3", 1.5M, "de-DE"], + ]; + + [DataTestMethod] + [DynamicData(nameof(Interpret_DifferentCulture_WhenCalled_Data))] + public void Interpret_DifferentCulture_WhenCalled(string input, decimal expectedResult, string cultureName) + { + // Arrange + var cultureInfo = CultureInfo.GetCultureInfo(cultureName); + var settings = new Settings(); + + // Act + var result = CalculateEngine.Interpret(settings, input, cultureInfo, out _); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(CalculateEngine.Round(expectedResult), result.RoundedResult); + } + + [DataTestMethod] + [DataRow("log(3)", true)] + [DataRow("ln(3)", true)] + [DataRow("log2(3)", true)] + [DataRow("log10(3)", true)] + [DataRow("log2", false)] + [DataRow("log10", false)] + [DataRow("log", false)] + [DataRow("ln", false)] + [DataRow("ceil(2 * (pi ^ 2))", true)] + [DataRow("((1 * 2)", false)] + [DataRow("(1 * 2)))", false)] + [DataRow("abcde", false)] + [DataRow("1 + 2 +", false)] + [DataRow("1+2*", false)] + [DataRow("1+2/", false)] + [DataRow("1+2%", false)] + [DataRow("1 && 3 &&", false)] + [DataRow("sqrt( 36)", true)] + [DataRow("max 4", false)] + [DataRow("sin(0)", true)] + [DataRow("sinh(1)", true)] + [DataRow("tanh(0)", true)] + [DataRow("artanh(pi/2)", true)] + [DataRow("cosh", false)] + [DataRow("cos", false)] + [DataRow("abs", false)] + [DataRow("1+1.1e3", true)] + [DataRow("randi(8)", true)] + [DataRow("randi()", false)] + [DataRow("randi(0.5)", true)] + [DataRow("rand()", true)] + [DataRow("rand(0.5)", false)] + [DataRow("0X78AD+0o123", true)] + [DataRow("0o9", false)] + public void InputValid_TestValid_WhenCalled(string input, bool valid) + { + // Act + var result = CalculateHelper.InputValid(input); + + // Assert + Assert.AreEqual(valid, result); + } + + [DataTestMethod] + [DataRow("1-1")] + [DataRow("sin(0)")] + [DataRow("sinh(0)")] + public void Interpret_MustReturnResult_WhenResultIsZero(string input) + { + // Arrange + var settings = new Settings(); + + // Act + // Using InvariantCulture since this is internal + var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out _); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0.0M, result.Result); + } + + private static IEnumerable<object[]> Interpret_MustReturnExpectedResult_WhenCalled_Data => + [ + + ["factorial(5)", 120M], + ["5!", 120M], + ["(2+3)!", 120M], + ["sign(-2)", -1M], + ["sign(2)", +1M], + ["abs(-2)", 2M], + ["abs(2)", 2M], + ["0+(1*2)/(0+1)", 2M], // Validate that division by "(0+1)" is not interpret as division by zero. + ["0+(1*2)/0.5", 4M], // Validate that division by number with decimal digits is not interpret as division by zero. + ]; + + [DataTestMethod] + [DynamicData(nameof(Interpret_MustReturnExpectedResult_WhenCalled_Data))] + public void Interpret_MustReturnExpectedResult_WhenCalled(string input, decimal expectedResult) + { + // Arrange + var settings = new Settings(); + + // Act + // Using en-us culture to have a fixed number style + var result = CalculateEngine.Interpret(settings, input, new CultureInfo("en-us", false), out _); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result.Result); + } + + private static IEnumerable<object[]> Interpret_TestScientificNotation_WhenCalled_Data => + [ + ["0.2E1", "en-US", 2M], + ["0,2E1", "pt-PT", 2M], + ["3.5e3 + 2.5E2", "en-US", 3750M], + ["3,5e3 + 2,5E2", "fr-FR", 3750M], + ["1E3-1E3/1.5", "en-US", 333.333333333333371M], + ]; + + [DataTestMethod] + [DynamicData(nameof(Interpret_TestScientificNotation_WhenCalled_Data))] + public void Interpret_TestScientificNotation_WhenCalled(string input, string sourceCultureName, decimal expectedResult) + { + // Arrange + var translator = NumberTranslator.Create(new CultureInfo(sourceCultureName, false), new CultureInfo("en-US", false)); + var settings = new Settings(); + + // Act + // Using en-us culture to have a fixed number style + var translatedInput = translator.Translate(input); + var result = CalculateEngine.Interpret(settings, translatedInput, new CultureInfo("en-US", false), out _); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result.Result); + } + + [DataTestMethod] + [DataRow("sin(90)", "sin((pi / 180) * (90))")] + [DataRow("arcsin(0.5)", "(180 / pi) * (arcsin(0.5))")] + [DataRow("sin(sin(30))", "sin((pi / 180) * (sin((pi / 180) * (30))))")] + [DataRow("cos(tan(45))", "cos((pi / 180) * (tan((pi / 180) * (45))))")] + [DataRow("arctan(sin(30))", "(180 / pi) * (arctan(sin((pi / 180) * (30))))")] + [DataRow("sin(cos(tan(30)))", "sin((pi / 180) * (cos((pi / 180) * (tan((pi / 180) * (30))))))")] + [DataRow("sin(arcsin(0.5))", "sin((pi / 180) * ((180 / pi) * (arcsin(0.5))))")] + [DataRow("sin(30) + cos(60)", "sin((pi / 180) * (30)) + cos((pi / 180) * (60))")] + [DataRow("sin(30 + 15)", "sin((pi / 180) * (30 + 15))")] + [DataRow("sin(45) * cos(45) - tan(30)", "sin((pi / 180) * (45)) * cos((pi / 180) * (45)) - tan((pi / 180) * (30))")] + [DataRow("arcsin(arccos(0.5))", "(180 / pi) * (arcsin((180 / pi) * (arccos(0.5))))")] + [DataRow("sin(sin(sin(30)))", "sin((pi / 180) * (sin((pi / 180) * (sin((pi / 180) * (30))))))")] + [DataRow("log(10)", "log(10)")] + [DataRow("sin(30) + pi", "sin((pi / 180) * (30)) + pi")] + [DataRow("sin(-30)", "sin((pi / 180) * (-30))")] + [DataRow("sin((30))", "sin((pi / 180) * ((30)))")] + [DataRow("arcsin(1) * 2", "(180 / pi) * (arcsin(1)) * 2")] + [DataRow("cos(1/2)", "cos((pi / 180) * (1/2))")] + [DataRow("sin ( 90 )", "sin ((pi / 180) * ( 90 ))")] + [DataRow("cos(arcsin(sin(45)))", "cos((pi / 180) * ((180 / pi) * (arcsin(sin((pi / 180) * (45))))))")] + public void UpdateTrigFunctions_Degrees(string input, string expectedResult) + { + // Call UpdateTrigFunctions in degrees mode + var result = CalculateHelper.UpdateTrigFunctions(input, CalculateEngine.TrigMode.Degrees); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } + + [DataTestMethod] + [DataRow("sin(90)", "sin((pi / 200) * (90))")] + [DataRow("arcsin(0.5)", "(200 / pi) * (arcsin(0.5))")] + [DataRow("sin(sin(30))", "sin((pi / 200) * (sin((pi / 200) * (30))))")] + [DataRow("cos(tan(45))", "cos((pi / 200) * (tan((pi / 200) * (45))))")] + [DataRow("arctan(sin(30))", "(200 / pi) * (arctan(sin((pi / 200) * (30))))")] + [DataRow("sin(cos(tan(30)))", "sin((pi / 200) * (cos((pi / 200) * (tan((pi / 200) * (30))))))")] + [DataRow("sin(arcsin(0.5))", "sin((pi / 200) * ((200 / pi) * (arcsin(0.5))))")] + [DataRow("sin(30) + cos(60)", "sin((pi / 200) * (30)) + cos((pi / 200) * (60))")] + [DataRow("sin(30 + 15)", "sin((pi / 200) * (30 + 15))")] + [DataRow("sin(45) * cos(45) - tan(30)", "sin((pi / 200) * (45)) * cos((pi / 200) * (45)) - tan((pi / 200) * (30))")] + [DataRow("arcsin(arccos(0.5))", "(200 / pi) * (arcsin((200 / pi) * (arccos(0.5))))")] + [DataRow("sin(sin(sin(30)))", "sin((pi / 200) * (sin((pi / 200) * (sin((pi / 200) * (30))))))")] + [DataRow("log(10)", "log(10)")] + [DataRow("sin(30) + pi", "sin((pi / 200) * (30)) + pi")] + [DataRow("sin(-30)", "sin((pi / 200) * (-30))")] + [DataRow("sin((30))", "sin((pi / 200) * ((30)))")] + [DataRow("arcsin(1) * 2", "(200 / pi) * (arcsin(1)) * 2")] + [DataRow("cos(1/2)", "cos((pi / 200) * (1/2))")] + [DataRow("sin ( 90 )", "sin ((pi / 200) * ( 90 ))")] + [DataRow("cos(arcsin(sin(45)))", "cos((pi / 200) * ((200 / pi) * (arcsin(sin((pi / 200) * (45))))))")] + public void UpdateTrigFunctions_Gradians(string input, string expectedResult) + { + // Call UpdateTrigFunctions in gradians mode + var result = CalculateHelper.UpdateTrigFunctions(input, CalculateEngine.TrigMode.Gradians); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } + + [DataTestMethod] + [DataRow("rad(30)", "(180 / pi) * (30)")] + [DataRow("rad( 30 )", "(180 / pi) * ( 30 )")] + [DataRow("deg(30)", "(30)")] + [DataRow("grad(30)", "(9 / 10) * (30)")] + [DataRow("rad( 30)", "(180 / pi) * ( 30)")] + [DataRow("rad(30 )", "(180 / pi) * (30 )")] + [DataRow("rad( 30 )", "(180 / pi) * ( 30 )")] + [DataRow("rad(deg(30))", "(180 / pi) * ((30))")] + [DataRow("deg(rad(30))", "((180 / pi) * (30))")] + [DataRow("grad(rad(30))", "(9 / 10) * ((180 / pi) * (30))")] + [DataRow("rad(grad(30))", "(180 / pi) * ((9 / 10) * (30))")] + [DataRow("rad(30) + deg(45)", "(180 / pi) * (30) + (45)")] + [DataRow("sin(rad(30))", "sin((180 / pi) * (30))")] + [DataRow("cos( rad( 45 ) )", "cos( (180 / pi) * ( 45 ) )")] + [DataRow("tan(rad(grad(90)))", "tan((180 / pi) * ((9 / 10) * (90)))")] + [DataRow("rad(30) + rad(45)", "(180 / pi) * (30) + (180 / pi) * (45)")] + [DataRow("rad(30) * grad(90)", "(180 / pi) * (30) * (9 / 10) * (90)")] + [DataRow("rad(30)/rad(45)", "(180 / pi) * (30)/(180 / pi) * (45)")] + public void ExpandTrigConversions_Degrees(string input, string expectedResult) + { + // Call ExpandTrigConversions in degrees mode + var result = CalculateHelper.ExpandTrigConversions(input, CalculateEngine.TrigMode.Degrees); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } + + [DataTestMethod] + [DataRow("rad(30)", "(30)")] + [DataRow("rad( 30 )", "( 30 )")] + [DataRow("deg(30)", "(pi / 180) * (30)")] + [DataRow("grad(30)", "(pi / 200) * (30)")] + [DataRow("rad( 30)", "( 30)")] + [DataRow("rad(30 )", "(30 )")] + [DataRow("rad( 30 )", "( 30 )")] + [DataRow("rad(deg(30))", "((pi / 180) * (30))")] + [DataRow("deg(rad(30))", "(pi / 180) * ((30))")] + [DataRow("grad(rad(30))", "(pi / 200) * ((30))")] + [DataRow("rad(grad(30))", "((pi / 200) * (30))")] + [DataRow("rad(30) + deg(45)", "(30) + (pi / 180) * (45)")] + [DataRow("sin(rad(30))", "sin((30))")] + [DataRow("cos( rad( 45 ) )", "cos( ( 45 ) )")] + [DataRow("tan(rad(grad(90)))", "tan(((pi / 200) * (90)))")] + [DataRow("rad(30) + rad(45)", "(30) + (45)")] + [DataRow("rad(30) * grad(90)", "(30) * (pi / 200) * (90)")] + [DataRow("rad(30)/rad(45)", "(30)/(45)")] + public void ExpandTrigConversions_Radians(string input, string expectedResult) + { + // Call ExpandTrigConversions in radians mode + var result = CalculateHelper.ExpandTrigConversions(input, CalculateEngine.TrigMode.Radians); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } + + [DataTestMethod] + [DataRow("rad(30)", "(200 / pi) * (30)")] + [DataRow("rad( 30 )", "(200 / pi) * ( 30 )")] + [DataRow("deg(30)", "(10 / 9) * (30)")] + [DataRow("grad(30)", "(30)")] + [DataRow("rad( 30)", "(200 / pi) * ( 30)")] + [DataRow("rad(30 )", "(200 / pi) * (30 )")] + [DataRow("rad( 30 )", "(200 / pi) * ( 30 )")] + [DataRow("rad(deg(30))", "(200 / pi) * ((10 / 9) * (30))")] + [DataRow("deg(rad(30))", "(10 / 9) * ((200 / pi) * (30))")] + [DataRow("grad(rad(30))", "((200 / pi) * (30))")] + [DataRow("rad(grad(30))", "(200 / pi) * ((30))")] + [DataRow("rad(30) + deg(45)", "(200 / pi) * (30) + (10 / 9) * (45)")] + [DataRow("sin(rad(30))", "sin((200 / pi) * (30))")] + [DataRow("cos( rad( 45 ) )", "cos( (200 / pi) * ( 45 ) )")] + [DataRow("tan(rad(grad(90)))", "tan((200 / pi) * ((90)))")] + [DataRow("rad(30) + rad(45)", "(200 / pi) * (30) + (200 / pi) * (45)")] + [DataRow("rad(30) * grad(90)", "(200 / pi) * (30) * (90)")] + [DataRow("rad(30)/rad(45)", "(200 / pi) * (30)/(200 / pi) * (45)")] + public void ExpandTrigConversions_Gradians(string input, string expectedResult) + { + // Call ExpandTrigConversions in gradians mode + var result = CalculateHelper.ExpandTrigConversions(input, CalculateEngine.TrigMode.Gradians); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } + + [DataTestMethod] + [DataRow("171!")] + [DataRow("1000!")] + public void Interpret_ReturnsError_WhenValueOverflowsDecimal(string input) + { + var settings = new Settings(); + + CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error); + + Assert.IsFalse(string.IsNullOrEmpty(error)); + Assert.AreNotEqual(null, error); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/IncompleteQueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/IncompleteQueryTests.cs new file mode 100644 index 0000000000..894660ade0 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/IncompleteQueryTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +[TestClass] +public class IncompleteQueryTests +{ + [DataTestMethod] + [DataRow("2+2+", "2+2")] + [DataRow("2+2*", "2+2")] + [DataRow("sin(30", "sin(30)")] + [DataRow("((1+2)", "((1+2))")] + [DataRow("2*(3+4", "2*(3+4)")] + [DataRow("(1+2", "(1+2)")] + [DataRow("2*(", "2")] + [DataRow("2*(((", "2")] + public void TestTryGetIncompleteQuerySuccess(string input, string expected) + { + var result = QueryHelper.TryGetIncompleteQuery(input, out var newQuery); + Assert.IsTrue(result); + Assert.AreEqual(expected, newQuery); + } + + [DataTestMethod] + [DataRow("")] + [DataRow(" ")] + public void TestTryGetIncompleteQueryFail(string input) + { + var result = QueryHelper.TryGetIncompleteQuery(input, out var newQuery); + Assert.IsFalse(result); + Assert.AreEqual(input, newQuery); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/Microsoft.CmdPal.Ext.Calc.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/Microsoft.CmdPal.Ext.Calc.UnitTests.csproj new file mode 100644 index 0000000000..2080cafec7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/Microsoft.CmdPal.Ext.Calc.UnitTests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <RootNamespace>Microsoft.CmdPal.Ext.Calc.UnitTests</RootNamespace> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Calc\Microsoft.CmdPal.Ext.Calc.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/NumberTranslatorTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/NumberTranslatorTests.cs new file mode 100644 index 0000000000..d6dbfc0f02 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/NumberTranslatorTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +[TestClass] +public class NumberTranslatorTests +{ + [DataTestMethod] + [DataRow(null, "en-US")] + [DataRow("de-DE", null)] + public void Create_ThrowError_WhenCalledNullOrEmpty(string sourceCultureName, string targetCultureName) + { + // Arrange + CultureInfo sourceCulture = sourceCultureName is not null ? new CultureInfo(sourceCultureName) : null; + CultureInfo targetCulture = targetCultureName is not null ? new CultureInfo(targetCultureName) : null; + + // Act + Assert.ThrowsException<ArgumentNullException>(() => NumberTranslator.Create(sourceCulture, targetCulture)); + } + + [DataTestMethod] + [DataRow("en-US", "en-US")] + [DataRow("en-EN", "en-US")] + [DataRow("de-DE", "en-US")] + public void Create_WhenCalled(string sourceCultureName, string targetCultureName) + { + // Arrange + CultureInfo sourceCulture = new CultureInfo(sourceCultureName); + CultureInfo targetCulture = new CultureInfo(targetCultureName); + + // Act + var translator = NumberTranslator.Create(sourceCulture, targetCulture); + + // Assert + Assert.IsNotNull(translator); + } + + [DataTestMethod] + [DataRow(null)] + public void Translate_ThrowError_WhenCalledNull(string input) + { + // Arrange + var translator = NumberTranslator.Create(new CultureInfo("de-DE", false), new CultureInfo("en-US", false)); + + // Act + Assert.ThrowsException<ArgumentNullException>(() => translator.Translate(input)); + } + + [DataTestMethod] + [DataRow("")] + [DataRow(" ")] + public void Translate_WhenCalledEmpty(string input) + { + // Arrange + var translator = NumberTranslator.Create(new CultureInfo("de-DE", false), new CultureInfo("en-US", false)); + + // Act + var result = translator.Translate(input); + + // Assert + Assert.AreEqual(input, result); + } + + [DataTestMethod] + [DataRow("2,0 * 2", "2.0 * 2")] + [DataRow("4 * 3,6 + 9", "4 * 3.6 + 9")] + [DataRow("5,2+6", "5.2+6")] + [DataRow("round(2,5)", "round(2.5)")] + [DataRow("3,3333", "3.3333")] + [DataRow("max(2;3)", "max(2,3)")] + public void Translate_NoErrors_WhenCalled(string input, string expectedResult) + { + // Arrange + var translator = NumberTranslator.Create(new CultureInfo("de-DE", false), new CultureInfo("en-US", false)); + + // Act + var result = translator.Translate(input); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } + + [DataTestMethod] + [DataRow("2.0 * 2", "2,0 * 2")] + [DataRow("4 * 3.6 + 9", "4 * 3,6 + 9")] + [DataRow("5.2+6", "5,2+6")] + [DataRow("round(2.5)", "round(2,5)")] + [DataRow("3.3333", "3,3333")] + public void TranslateBack_NoErrors_WhenCalled(string input, string expectedResult) + { + // Arrange + var translator = NumberTranslator.Create(new CultureInfo("de-DE", false), new CultureInfo("en-US", false)); + + // Act + var result = translator.TranslateBack(input); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } + + [DataTestMethod] + [DataRow(".", ",", "2,000,000", "2000000")] + [DataRow(".", ",", "2,000,000.6", "2000000.6")] + [DataRow(",", ".", "2.000.000", "2000000")] + [DataRow(",", ".", "2.000.000,6", "2000000.6")] + public void Translate_RemoveNumberGroupSeparator_WhenCalled(string decimalSeparator, string groupSeparator, string input, string expectedResult) + { + // Arrange + var sourceCulture = new CultureInfo("en-US", false) + { + NumberFormat = + { + NumberDecimalSeparator = decimalSeparator, + NumberGroupSeparator = groupSeparator, + }, + }; + var translator = NumberTranslator.Create(sourceCulture, new CultureInfo("en-US", false)); + + // Act + var result = translator.Translate(input); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } + + [DataTestMethod] + [DataRow("de-DE", "12,0004", "12.0004")] + [DataRow("de-DE", "0xF000", "61440")] + [DataRow("de-DE", "0", "0")] + [DataRow("de-DE", "00", "0")] + [DataRow("de-DE", "12.004", "12004")] // . is the group separator in de-DE + [DataRow("de-DE", "12.04", "1204")] + [DataRow("de-DE", "12.4", "124")] + [DataRow("de-DE", "3.004.044.444,05", "3004044444.05")] + [DataRow("de-DE", "123.01 + 52.30", "12301 + 5230")] + [DataRow("de-DE", "123.001 + 52.30", "123001 + 5230")] + [DataRow("fr-FR", "0", "0")] + [DataRow("fr-FR", "00", "0")] + [DataRow("fr-FR", "12.004", "12.004")] // . is not decimal or group separator in fr-FR + [DataRow("fr-FR", "12.04", "12.04")] + [DataRow("fr-FR", "12.4", "12.4")] + [DataRow("fr-FR", "12.0004", "12.0004")] + + // [DataRow("fr-FR", "123.01 + 52.30", "123.01 + 52.30")] + // [DataRow("fr-FR", "123.001 + 52.30", "123.001 + 52.30")] passed locally, failed in CI + public void Translate_NoRemovalOfLeadingZeroesOnEdgeCases(string sourceCultureName, string input, string expectedResult) + { + // Arrange + var translator = NumberTranslator.Create(new CultureInfo(sourceCultureName, false), new CultureInfo("en-US", false)); + + // Act + var result = translator.Translate(input); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } + + [DataTestMethod] + [DataRow("en-US", "0xF000", "61440")] + [DataRow("en-US", "0xf4572220", "4099351072")] + [DataRow("en-US", "0x12345678", "305419896")] + public void Translate_LargeHexadecimalNumbersToDecimal(string sourceCultureName, string input, string expectedResult) + { + // Arrange + var translator = NumberTranslator.Create(new CultureInfo(sourceCultureName, false), new CultureInfo("en-US", false)); + + // Act + var result = translator.Translate(input); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(expectedResult, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/QueryHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/QueryHelperTests.cs new file mode 100644 index 0000000000..c152dd1f45 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/QueryHelperTests.cs @@ -0,0 +1,27 @@ +// 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.Ext.Calc.Helper; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +[TestClass] +public class QueryHelperTests +{ + [DataTestMethod] + [DataRow("2²", "4")] + [DataRow("2³", "8")] + [DataRow("2!", "2")] + [DataRow("2\u00A0*\u00A02", "4")] // Non-breaking space + [DataRow("20:10", "2")] // Colon as division + public void Interpret_HandlesNormalizedInputs(string input, string expected) + { + var settings = new Settings(); + var result = QueryHelper.Query(input, settings, false, out _, (_, _) => { }); + + Assert.IsNotNull(result); + Assert.AreEqual(expected, result.Title); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..fa8a441d43 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/QueryTests.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 System.Linq; +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.CmdPal.Ext.Calc.Pages; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [DataTestMethod] + [DataRow("2+2", "4")] + [DataRow("5*3", "15")] + [DataRow("10/2", "5")] + [DataRow("sqrt(16)", "4")] + [DataRow("2^3", "8")] + public void TopLevelPageQueryTest(string input, string expectedResult) + { + var settings = new Settings(); + var page = new CalculatorListPage(settings); + + // Simulate query execution + page.UpdateSearchText(string.Empty, input); + var result = page.GetItems(); + + Assert.IsTrue(result.Length == 1, "Valid input should always return result"); + + var firstResult = result.FirstOrDefault(); + + Assert.IsNotNull(result); + Assert.IsTrue( + firstResult.Title.Contains(expectedResult), + $"Expected result to contain '{expectedResult}' but got '{firstResult.Title}'"); + } + + [TestMethod] + public void EmptyQueryTest() + { + var settings = new Settings(); + var page = new CalculatorListPage(settings); + page.UpdateSearchText("abc", string.Empty); + var results = page.GetItems(); + Assert.IsNotNull(results); + + var firstItem = results.FirstOrDefault(); + Assert.AreEqual("Type an equation...", firstItem.Title); + } + + [TestMethod] + public void InvalidExpressionTest() + { + var settings = new Settings(); + + var page = new CalculatorListPage(settings); + + // Simulate query execution + page.UpdateSearchText(string.Empty, "invalid expression"); + var result = page.GetItems().FirstOrDefault(); + + Assert.AreEqual("Type an equation...", result.Title); + } + + [DataTestMethod] + [DataRow("sin(60)", "-0.30481", CalculateEngine.TrigMode.Radians)] + [DataRow("sin(60)", "0.866025", CalculateEngine.TrigMode.Degrees)] + [DataRow("sin(60)", "0.809016", CalculateEngine.TrigMode.Gradians)] + public void TrigModeSettingsTest(string input, string expected, CalculateEngine.TrigMode trigMode) + { + var settings = new Settings(trigUnit: trigMode, outputUseEnglishFormat: true); + + var page = new CalculatorListPage(settings); + + page.UpdateSearchText(string.Empty, input); + var result = page.GetItems().FirstOrDefault(); + + Assert.IsNotNull(result); + + Assert.IsTrue(result.Title.Contains(expected, System.StringComparison.Ordinal), $"Calc trigMode convert result isn't correct. Current result: {result.Title}"); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/Settings.cs new file mode 100644 index 0000000000..767b040fe4 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/Settings.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 Microsoft.CmdPal.Ext.Calc.Helper; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +public class Settings : ISettingsInterface +{ + private readonly CalculateEngine.TrigMode trigUnit; + private readonly bool inputUseEnglishFormat; + private readonly bool outputUseEnglishFormat; + private readonly bool closeOnEnter; + private readonly bool copyResultToSearchBarIfQueryEndsWithEqualSign; + private readonly bool autoFixQuery; + private readonly bool inputNormalization; + + public Settings( + CalculateEngine.TrigMode trigUnit = CalculateEngine.TrigMode.Radians, + bool inputUseEnglishFormat = false, + bool outputUseEnglishFormat = false, + bool closeOnEnter = true, + bool copyResultToSearchBarIfQueryEndsWithEqualSign = true, + bool autoFixQuery = true, + bool inputNormalization = true) + { + this.trigUnit = trigUnit; + this.inputUseEnglishFormat = inputUseEnglishFormat; + this.outputUseEnglishFormat = outputUseEnglishFormat; + this.closeOnEnter = closeOnEnter; + this.copyResultToSearchBarIfQueryEndsWithEqualSign = copyResultToSearchBarIfQueryEndsWithEqualSign; + this.autoFixQuery = autoFixQuery; + this.inputNormalization = inputNormalization; + } + + public CalculateEngine.TrigMode TrigUnit => trigUnit; + + public bool InputUseEnglishFormat => inputUseEnglishFormat; + + public bool OutputUseEnglishFormat => outputUseEnglishFormat; + + public bool CloseOnEnter => closeOnEnter; + + public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => copyResultToSearchBarIfQueryEndsWithEqualSign; + + public bool AutoFixQuery => autoFixQuery; + + public bool InputNormalization => inputNormalization; +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/SettingsManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/SettingsManagerTests.cs new file mode 100644 index 0000000000..a59fda15d4 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Calc.UnitTests/SettingsManagerTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Calc.UnitTests; + +[TestClass] +public class SettingsManagerTests +{ + [TestMethod] + public void SettingsManagerInitializationTest() + { + // Act + var settingsManager = new SettingsManager(); + + // Assert + Assert.IsNotNull(settingsManager); + Assert.IsNotNull(settingsManager.Settings); + } + + [TestMethod] + public void SettingsInterfaceTest() + { + // Act + ISettingsInterface settings = new SettingsManager(); + + // Assert + Assert.IsNotNull(settings); + Assert.IsTrue(settings.TrigUnit == CalculateEngine.TrigMode.Radians); + Assert.IsFalse(settings.InputUseEnglishFormat); + Assert.IsFalse(settings.OutputUseEnglishFormat); + Assert.IsTrue(settings.CloseOnEnter); + } + + [TestMethod] + public void MockSettingsTest() + { + // Act + var settings = new Settings( + trigUnit: CalculateEngine.TrigMode.Degrees, + inputUseEnglishFormat: true, + outputUseEnglishFormat: true, + closeOnEnter: false); + + // Assert + Assert.IsNotNull(settings); + Assert.AreEqual(CalculateEngine.TrigMode.Degrees, settings.TrigUnit); + Assert.IsTrue(settings.InputUseEnglishFormat); + Assert.IsTrue(settings.OutputUseEnglishFormat); + Assert.IsFalse(settings.CloseOnEnter); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj new file mode 100644 index 0000000000..6c03a216c7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <RootNamespace>Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests</RootNamespace> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.ClipboardHistory\Microsoft.CmdPal.Ext.ClipboardHistory.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/UrlHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/UrlHelperTests.cs new file mode 100644 index 0000000000..8635a5e3c5 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests/UrlHelperTests.cs @@ -0,0 +1,274 @@ +// 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.CmdPal.Ext.ClipboardHistory.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests; + +[TestClass] +public class UrlHelperTests +{ + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("\t")] + [DataRow("\r\n")] + public void IsValidUrl_ReturnsFalse_WhenUrlIsNullOrWhitespace(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow("test\nurl")] + [DataRow("test\rurl")] + [DataRow("http://example.com\nmalicious")] + [DataRow("https://test.com\r\nheader")] + public void IsValidUrl_ReturnsFalse_WhenUrlContainsNewlines(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow("com")] + [DataRow("org")] + [DataRow("localhost")] + [DataRow("test")] + [DataRow("http")] + [DataRow("https")] + public void IsValidUrl_ReturnsFalse_WhenUrlDoesNotContainDot(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow("https://www.example.com")] + [DataRow("http://test.org")] + [DataRow("ftp://files.example.net")] + [DataRow("file://localhost/path/to/file.txt")] + [DataRow("https://subdomain.example.co.uk")] + [DataRow("http://192.168.1.1")] + [DataRow("https://example.com:8080/path")] + public void IsValidUrl_ReturnsTrue_WhenUrlIsWellFormedAbsolute(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("www.example.com")] + [DataRow("example.org")] + [DataRow("subdomain.test.net")] + [DataRow("github.com/user/repo")] + [DataRow("stackoverflow.com/questions/123")] + [DataRow("192.168.1.1")] + public void IsValidUrl_ReturnsTrue_WhenUrlIsValidWithoutProtocol(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("not a url")] + [DataRow("invalid..url")] + [DataRow("http://")] + [DataRow("https://")] + [DataRow("://example.com")] + [DataRow("ht tp://example.com")] + public void IsValidUrl_ReturnsFalse_WhenUrlIsInvalid(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(" https://www.example.com ")] + [DataRow("\t\tgithub.com\t\t")] + [DataRow(" \r\n stackoverflow.com \r\n ")] + public void IsValidUrl_TrimsWhitespace_BeforeValidation(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("tel:+1234567890")] + [DataRow("javascript:alert('test')")] + public void IsValidUrl_ReturnsFalse_ForNonWebProtocols(string url) + { + // Act + var result = UrlHelper.IsValidUrl(url); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + public void NormalizeUrl_ReturnsInput_WhenUrlIsNullOrWhitespace(string url) + { + // Act + var result = UrlHelper.NormalizeUrl(url); + + // Assert + Assert.AreEqual(url, result); + } + + [TestMethod] + [DataRow("https://www.example.com")] + [DataRow("http://test.org")] + [DataRow("ftp://files.example.net")] + [DataRow("file://localhost/path/to/file.txt")] + public void NormalizeUrl_ReturnsUnchanged_WhenUrlIsAlreadyWellFormed(string url) + { + // Act + var result = UrlHelper.NormalizeUrl(url); + + // Assert + Assert.AreEqual(url, result); + } + + [TestMethod] + [DataRow("www.example.com", "https://www.example.com")] + [DataRow("example.org", "https://example.org")] + [DataRow("github.com/user/repo", "https://github.com/user/repo")] + [DataRow("stackoverflow.com/questions/123", "https://stackoverflow.com/questions/123")] + public void NormalizeUrl_AddsHttpsPrefix_WhenNoProtocolPresent(string input, string expected) + { + // Act + var result = UrlHelper.NormalizeUrl(input); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(" www.example.com ", "https://www.example.com")] + [DataRow("\t\tgithub.com\t\t", "https://github.com")] + [DataRow(" \r\n stackoverflow.com \r\n ", "https://stackoverflow.com")] + public void NormalizeUrl_TrimsWhitespace_BeforeNormalizing(string input, string expected) + { + // Act + var result = UrlHelper.NormalizeUrl(input); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(@"C:\Users\Test\Documents\file.txt")] + [DataRow(@"D:\Projects\MyProject\readme.md")] + [DataRow(@"E:\")] + [DataRow(@"F:")] + [DataRow(@"G:\folder\subfolder")] + public void IsValidUrl_ReturnsTrue_ForValidLocalPaths(string path) + { + // Act + var result = UrlHelper.IsValidUrl(path); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow(@"\\server\share")] + [DataRow(@"\\server\share\folder")] + [DataRow(@"\\192.168.1.100\public")] + [DataRow(@"\\myserver\documents\file.docx")] + [DataRow(@"\\domain.com\share\folder\file.pdf")] + public void IsValidUrl_ReturnsTrue_ForValidNetworkPaths(string path) + { + // Act + var result = UrlHelper.IsValidUrl(path); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow(@"\\")] + [DataRow(@":")] + [DataRow(@"Z")] + [DataRow(@"folder")] + [DataRow(@"folder\file.txt")] + [DataRow(@"documents\project\readme.md")] + [DataRow(@"./config/settings.json")] + [DataRow(@"../data/input.csv")] + public void IsValidUrl_ReturnsFalse_ForInvalidPathsAndRelativePaths(string path) + { + // Act + var result = UrlHelper.IsValidUrl(path); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + [DataRow(@"C:\Users\Test\Documents\file.txt")] + [DataRow(@"D:\Projects\MyProject")] + [DataRow(@"E:\")] + public void NormalizeUrl_ConvertsLocalPathToFileUri_WhenValidLocalPath(string path) + { + // Act + var result = UrlHelper.NormalizeUrl(path); + + // Assert + Assert.IsTrue(result.StartsWith("file:///", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(result.Contains(path.Replace('\\', '/'))); + } + + [TestMethod] + [DataRow(@"\\server\share")] + [DataRow(@"\\192.168.1.100\public")] + [DataRow(@"\\myserver\documents")] + public void NormalizeUrl_ConvertsNetworkPathToFileUri_WhenValidNetworkPath(string path) + { + // Act + var result = UrlHelper.NormalizeUrl(path); + + // Assert + Assert.IsTrue(result.StartsWith("file://", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(result.Contains(path.Replace('\\', '/'))); + } + + [TestMethod] + [DataRow("file:///C:/Users/Test/file.txt")] + [DataRow("file://server/share/folder")] + public void NormalizeUrl_ReturnsUnchanged_WhenAlreadyFileUri(string fileUri) + { + // Act + var result = UrlHelper.NormalizeUrl(fileUri); + + // Assert + Assert.AreEqual(fileUri, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/BasicStructureTest.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/BasicStructureTest.cs new file mode 100644 index 0000000000..1d72775eb3 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/BasicStructureTest.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Registry.UnitTests; + +[TestClass] +public class BasicStructureTest +{ + [TestMethod] + public void CanCreateTestClass() + { + // This is a basic test to verify the test project structure is correct + Assert.IsTrue(true); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/KeyNameTest.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/KeyNameTest.cs new file mode 100644 index 0000000000..46c5de495f --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/KeyNameTest.cs @@ -0,0 +1,26 @@ +// 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.Ext.Registry.Constants; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Registry.UnitTests; + +[TestClass] +public class KeyNameTest +{ + [TestMethod] + [DataRow("HKEY", KeyName.FirstPart)] + [DataRow("HKEY_", KeyName.FirstPartUnderscore)] + [DataRow("HKCR", KeyName.ClassRootShort)] + [DataRow("HKCC", KeyName.CurrentConfigShort)] + [DataRow("HKCU", KeyName.CurrentUserShort)] + [DataRow("HKLM", KeyName.LocalMachineShort)] + [DataRow("HKPD", KeyName.PerformanceDataShort)] + [DataRow("HKU", KeyName.UsersShort)] + public void TestConstants(string shortName, string baseName) + { + Assert.AreEqual(shortName, baseName); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Microsoft.CmdPal.Ext.Registry.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Microsoft.CmdPal.Ext.Registry.UnitTests.csproj new file mode 100644 index 0000000000..070fc9b91e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Microsoft.CmdPal.Ext.Registry.UnitTests.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Ext.Registry.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Registry\Microsoft.CmdPal.Ext.Registry.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/QueryHelperTest.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/QueryHelperTest.cs new file mode 100644 index 0000000000..238d03d9cf --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/QueryHelperTest.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Registry.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Registry.UnitTests; + +[TestClass] +public class QueryHelperTest +{ + [TestMethod] + [DataRow(@"HKLM", false, @"HKLM", "")] + [DataRow(@"HKLM\", false, @"HKLM\", "")] + [DataRow(@"HKLM\\", true, @"HKLM", "")] + [DataRow(@"HKLM\\Test", true, @"HKLM", "Test")] + [DataRow(@"HKLM\Test\\TestTest", true, @"HKLM\Test", "TestTest")] + [DataRow(@"HKLM\Test\\\TestTest", true, @"HKLM\Test", @"\TestTest")] + [DataRow("HKLM/\"Software\"/", false, @"HKLM\Software\", "")] + [DataRow("HKLM/\"Software\"//test", true, @"HKLM\Software", "test")] + [DataRow("HKLM/\"Software\"//test/123", true, @"HKLM\Software", "test/123")] + [DataRow("HKLM/\"Software\"//test\\123", true, @"HKLM\Software", @"test\123")] + [DataRow("HKLM/\"Software\"/test", false, @"HKLM\Software\test", "")] + [DataRow("HKLM\\Software\\\"test\"", false, @"HKLM\Software\test", "")] + [DataRow("HKLM\\\"Software\"\\\"test\"", false, @"HKLM\Software\test", "")] + [DataRow("HKLM\\\"Software\"\\\"test/software\"", false, @"HKLM\Software\test/software", "")] + [DataRow("HKLM\\\"Software\"/\"test\"\\hello", false, @"HKLM\Software\test\hello", "")] + [DataRow("HKLM\\\"Software\"\\\"test\"\\hello\\\\\"some/value\"", true, @"HKLM\Software\test\hello", "some/value")] + [DataRow("HKLM\\\"Software\"\\\"test\"/hello\\\\\"some/value\"", true, @"HKLM\Software\test\hello", "some/value")] + [DataRow("HKLM\\\"Software\"\\\"test\"\\hello\\\\some\\value", true, @"HKLM\Software\test\hello", @"some\value")] + public void GetQueryPartsTest(string query, bool expectedHasValueName, string expectedQueryKey, string expectedQueryValueName) + { + var hasValueName = QueryHelper.GetQueryParts(query, out var queryKey, out var queryValueName); + + Assert.AreEqual(expectedHasValueName, hasValueName); + Assert.AreEqual(expectedQueryKey, queryKey); + Assert.AreEqual(expectedQueryValueName, queryValueName); + } + + [TestMethod] + [DataRow(@"HKCR\*\OpenWithList", @"HKEY_CLASSES_ROOT\*\OpenWithList")] + [DataRow(@"HKCU\Control Panel\Accessibility", @"HKEY_CURRENT_USER\Control Panel\Accessibility")] + [DataRow(@"HKLM\HARDWARE\UEFI", @"HKEY_LOCAL_MACHINE\HARDWARE\UEFI")] + [DataRow(@"HKU\.DEFAULT\Environment", @"HKEY_USERS\.DEFAULT\Environment")] + [DataRow(@"HKCC\System\CurrentControlSet\Control", @"HKEY_CURRENT_CONFIG\System\CurrentControlSet\Control")] + [DataRow(@"HKPD\???", @"HKEY_PERFORMANCE_DATA\???")] + public void GetShortBaseKeyTest(string registryKeyShort, string registryKeyFull) + { + Assert.AreEqual(registryKeyShort, QueryHelper.GetKeyWithShortBaseKey(registryKeyFull)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..796b3b1b32 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/QueryTests.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.CmdPal.Ext.Registry.Helpers; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Registry.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [DataTestMethod] + [DataRow("HKLM", "HKEY_LOCAL_MACHINE")] + [DataRow("HKCU", "HKEY_CURRENT_USER")] + [DataRow("HKCR", "HKEY_CLASSES_ROOT")] + [DataRow("HKU", "HKEY_USERS")] + [DataRow("HKCC", "HKEY_CURRENT_CONFIG")] + public void TopLevelPageQueryTest(string input, string expectedKeyName) + { + var settings = new Settings(); + var page = new RegistryListPage(settings); + var results = page.Query(input); + + Assert.IsNotNull(results); + Assert.IsTrue(results.Count > 0, "No items matched the query."); + + var firstItem = results.FirstOrDefault(); + Assert.IsNotNull(firstItem, "No items matched the query."); + Assert.IsTrue( + firstItem.Title.Contains(expectedKeyName, System.StringComparison.OrdinalIgnoreCase), + $"Expected to match '{expectedKeyName}' but got '{firstItem.Title}'"); + } + + [TestMethod] + public void EmptyQueryTest() + { + var settings = new Settings(); + var page = new RegistryListPage(settings); + var results = page.Query(string.Empty); + + Assert.IsNotNull(results); + + // Empty query should return all base keys + Assert.IsTrue(results.Count >= 5, "Expected at least 5 base registry keys."); + } + + [TestMethod] + public void NullQueryTest() + { + var settings = new Settings(); + var page = new RegistryListPage(settings); + var results = page.Query(null); + + Assert.IsNotNull(results); + Assert.AreEqual(0, results.Count, "Null query should return empty results."); + } + + [TestMethod] + public void InvalidBaseKeyTest() + { + var settings = new Settings(); + var page = new RegistryListPage(settings); + var results = page.Query("INVALID_KEY"); + + Assert.IsNotNull(results); + + Assert.AreEqual(0, results.Count, "Invalid query should return empty results."); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/RegistryHelperTest.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/RegistryHelperTest.cs new file mode 100644 index 0000000000..44431753ae --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/RegistryHelperTest.cs @@ -0,0 +1,75 @@ +// 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; +using System.Linq; + +using Microsoft.CmdPal.Ext.Registry.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Registry.UnitTests; + +[TestClass] +public class RegistryHelperTest +{ + [TestMethod] + [DataRow(@"HKCC\System\CurrentControlSet\Control", "HKEY_CURRENT_CONFIG")] + [DataRow(@"HKCR\*\OpenWithList", "HKEY_CLASSES_ROOT")] + [DataRow(@"HKCU\Control Panel\Accessibility", "HKEY_CURRENT_USER")] + [DataRow(@"HKLM\HARDWARE\UEFI", "HKEY_LOCAL_MACHINE")] + [DataRow(@"HKPD\???", "HKEY_PERFORMANCE_DATA")] + [DataRow(@"HKU\.DEFAULT\Environment", "HKEY_USERS")] + public void GetRegistryBaseKeyTestOnlyOneBaseKey(string query, string expectedBaseKey) + { + var (baseKeyList, _) = RegistryHelper.GetRegistryBaseKey(query); + Assert.IsNotNull(baseKeyList); + Assert.IsTrue(baseKeyList.Count() == 1); + Assert.AreEqual(expectedBaseKey, baseKeyList.First().Name); + } + + [TestMethod] + public void GetRegistryBaseKeyTestMoreThanOneBaseKey() + { + var (baseKeyList, _) = RegistryHelper.GetRegistryBaseKey("HKC\\Control Panel\\Accessibility"); /* #no-spell-check-line */ + + Assert.IsNotNull(baseKeyList); + Assert.IsTrue(baseKeyList.Count() > 1); + + var list = baseKeyList.Select(found => found.Name); + Assert.IsTrue(list.Contains("HKEY_CLASSES_ROOT")); + Assert.IsTrue(list.Contains("HKEY_CURRENT_CONFIG")); + Assert.IsTrue(list.Contains("HKEY_CURRENT_USER")); + } + + [TestMethod] + [DataRow(@"HKCR\*\OpenWithList", @"*\OpenWithList")] + [DataRow(@"HKCU\Control Panel\Accessibility", @"Control Panel\Accessibility")] + [DataRow(@"HKLM\HARDWARE\UEFI", @"HARDWARE\UEFI")] + [DataRow(@"HKU\.DEFAULT\Environment", @".DEFAULT\Environment")] + [DataRow(@"HKCC\System\CurrentControlSet\Control", @"System\CurrentControlSet\Control")] + [DataRow(@"HKPD\???", @"???")] + public void GetRegistryBaseKeyTestSubKey(string query, string expectedSubKey) + { + var (_, subKey) = RegistryHelper.GetRegistryBaseKey(query); + Assert.AreEqual(expectedSubKey, subKey); + } + + [TestMethod] + public void GetAllBaseKeysTest() + { + var list = RegistryHelper.GetAllBaseKeys(); + + CollectionAssert.AllItemsAreNotNull((ICollection)list); + CollectionAssert.AllItemsAreUnique((ICollection)list); + + var keys = list.Select(found => found.Key).ToList() as ICollection; + + CollectionAssert.Contains(keys, Win32.Registry.ClassesRoot); + CollectionAssert.Contains(keys, Win32.Registry.CurrentConfig); + CollectionAssert.Contains(keys, Win32.Registry.CurrentUser); + CollectionAssert.Contains(keys, Win32.Registry.LocalMachine); + CollectionAssert.Contains(keys, Win32.Registry.PerformanceData); + CollectionAssert.Contains(keys, Win32.Registry.Users); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/ResultHelperTest.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/ResultHelperTest.cs new file mode 100644 index 0000000000..78377960cf --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/ResultHelperTest.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; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.Registry.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Registry.UnitTests; + +[TestClass] +public class ResultHelperTest +{ + [TestMethod] + [DataRow(@"HKEY_CLASSES_ROOT\*\OpenWithList", @"HKEY_CLASSES_ROOT\*\OpenWithList")] + [DataRow(@"HKEY_CURRENT_USER\Control Panel\Accessibility", @"HKEY_CURRENT_USER\Control Panel\Accessibility")] + [DataRow(@"HKEY_LOCAL_MACHINE\HARDWARE\UEFI", @"HKEY_LOCAL_MACHINE\HARDWARE\UEFI")] + [DataRow(@"HKEY_USERS\.DEFAULT\Environment", @"HKEY_USERS\.DEFAULT\Environment")] + [DataRow(@"HKCC\System\CurrentControlSet\Control", @"HKEY_CURRENT_CONFIG\System\CurrentControlSet\Control")] + [DataRow(@"HKEY_PERFORMANCE_DATA\???", @"HKEY_PERFORMANCE_DATA\???")] + [DataRow(@"HKCR\*\shell\Open with VS Code\command", @"HKEY_CLASSES_ROOT\*\shell\Open with VS Code\command")] + [DataRow(@"...ndows\CurrentVersion\Explorer\StartupApproved", @"HKEY_CURRENT_USER\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved")] + [DataRow(@"...p\Upgrade\NetworkDriverBackup\Control\Network", @"HKEY_LOCAL_MACHINE\SYSTEM\Setup\Upgrade\NetworkDriverBackup\Control\Network")] + [DataRow(@"...anel\International\User Profile System Backup", @"HKEY_USERS\.DEFAULT\Control Panel\International\User Profile System Backup")] + [DataRow(@"...stem\CurrentControlSet\Control\Print\Printers", @"HKEY_CURRENT_CONFIG\System\CurrentControlSet\Control\Print\Printers")] + public void GetTruncatedTextTest_StandardCases(string registryKeyShort, string registryKeyFull) + { + Assert.AreEqual(registryKeyShort, ResultHelper.GetTruncatedText(registryKeyFull, 45)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Settings.cs new file mode 100644 index 0000000000..f999dd4c29 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Settings.cs @@ -0,0 +1,15 @@ +// 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.Ext.Registry.Helpers; + +namespace Microsoft.CmdPal.Ext.Registry.UnitTests; + +public class Settings : ISettingsInterface +{ + public Settings() + { + // Currently no specific settings for Registry extension + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs new file mode 100644 index 0000000000..f42520e6de --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs @@ -0,0 +1,121 @@ +// 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.Reflection; +using System.Text; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +[TestClass] +public class FallbackRemoteDesktopItemTests +{ + private static readonly CompositeFormat OpenHostCompositeFormat = CompositeFormat.Parse(Resources.remotedesktop_open_host); + + [TestMethod] + public void UpdateQuery_WhenMatchingConnectionExists_UsesConnectionName() + { + var connectionName = "my-rdp-server"; + + // Arrange + var setup = CreateFallback(connectionName); + var fallback = setup.Fallback; + + // Act + fallback.UpdateQuery("my-rdp-server"); + + // Assert + Assert.AreEqual(connectionName, fallback.Title); + var expectedSubtitle = string.Format(CultureInfo.CurrentCulture, OpenHostCompositeFormat, connectionName); + Assert.AreEqual(expectedSubtitle, fallback.Subtitle); + + var command = fallback.Command as OpenRemoteDesktopCommand; + Assert.IsNotNull(command); + Assert.AreEqual(Resources.remotedesktop_command_connect, command.Name); + Assert.AreEqual(connectionName, GetCommandHost(command)); + } + + [TestMethod] + public void UpdateQuery_WhenQueryIsValidHostWithoutExistingConnection_UsesQuery() + { + // Arrange + var setup = CreateFallback(); + var fallback = setup.Fallback; + const string hostname = "test.corp"; + + // Act + fallback.UpdateQuery(hostname); + + // Assert + var expectedTitle = string.Format(CultureInfo.CurrentCulture, OpenHostCompositeFormat, hostname); + Assert.AreEqual(expectedTitle, fallback.Title); + Assert.AreEqual(Resources.remotedesktop_title, fallback.Subtitle); + + var command = fallback.Command as OpenRemoteDesktopCommand; + Assert.IsNotNull(command); + Assert.AreEqual(Resources.remotedesktop_command_connect, command.Name); + Assert.AreEqual(hostname, GetCommandHost(command)); + } + + [TestMethod] + public void UpdateQuery_WhenQueryIsWhitespace_ResetsCommand() + { + // Arrange + var setup = CreateFallback("rdp-server-two"); + var fallback = setup.Fallback; + + // Act + fallback.UpdateQuery(" "); + + // Assert + Assert.AreEqual(string.Empty, fallback.Title); + Assert.AreEqual(string.Empty, fallback.Subtitle); + + var command = fallback.Command as OpenRemoteDesktopCommand; + Assert.IsNull(command); + } + + [TestMethod] + public void UpdateQuery_WhenQueryIsInvalidHost_ClearsCommand() + { + // Arrange + var setup = CreateFallback("rdp-server-three"); + var fallback = setup.Fallback; + + // Act + fallback.UpdateQuery("not a valid host"); + + // Assert + Assert.AreEqual(string.Empty, fallback.Title); + Assert.AreEqual(string.Empty, fallback.Subtitle); + + var command = fallback.Command as OpenRemoteDesktopCommand; + Assert.IsNull(command); + } + + private static string GetCommandHost(OpenRemoteDesktopCommand command) + { + var field = typeof(OpenRemoteDesktopCommand).GetField("_rdpHost", BindingFlags.NonPublic | BindingFlags.Instance); + if (field is null) + { + return string.Empty; + } + + return field.GetValue(command) as string ?? string.Empty; + } + + private static (FallbackRemoteDesktopItem Fallback, IRdpConnectionsManager Manager) CreateFallback(params string[] connectionNames) + { + var settingsManager = new MockSettingsManager(connectionNames); + var connectionsManager = new MockRdpConnectionsManager(settingsManager); + + var fallback = new FallbackRemoteDesktopItem(connectionsManager); + + return (fallback, connectionsManager); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj new file mode 100644 index 0000000000..2e083ceab7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.RemoteDesktop\Microsoft.CmdPal.Ext.RemoteDesktop.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRdpConnectionsManager.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRdpConnectionsManager.cs new file mode 100644 index 0000000000..be1c961523 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRdpConnectionsManager.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Settings; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +internal sealed class MockRdpConnectionsManager : IRdpConnectionsManager +{ + private readonly List<ConnectionListItem> _connections = new(); + + public IReadOnlyCollection<ConnectionListItem> Connections => _connections.AsReadOnly(); + + public MockRdpConnectionsManager(ISettingsInterface settingsManager) + { + _connections.AddRange(settingsManager.PredefinedConnections.Select(ConnectionHelpers.MapToResult)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockSettingsManager.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockSettingsManager.cs new file mode 100644 index 0000000000..1a81dcc7ee --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockSettingsManager.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.RemoteDesktop.Settings; +using ToolkitSettings = Microsoft.CommandPalette.Extensions.Toolkit.Settings; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +internal sealed class MockSettingsManager : ISettingsInterface +{ + private readonly List<string> _connections; + + public IReadOnlyCollection<string> PredefinedConnections => _connections; + + public ToolkitSettings Settings { get; } = new(); + + public MockSettingsManager(params string[] predefinedConnections) + { + _connections = new(predefinedConnections); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RdpConnectionsManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RdpConnectionsManagerTests.cs new file mode 100644 index 0000000000..a8a48ba79c --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RdpConnectionsManagerTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +[TestClass] +public class RdpConnectionsManagerTests +{ + [TestMethod] + public void Constructor_AddsOpenCommandItem() + { + // Act + var manager = new RdpConnectionsManager(new MockSettingsManager(["test.local"])); + + // Assert + Assert.IsTrue(manager.Connections.Any(item => string.IsNullOrEmpty(item.ConnectionName))); + } + + [TestMethod] + public void FindConnection_ReturnsExactMatch() + { + // Arrange + var connectionName = "rdp-test"; + var connection = new ConnectionListItem(connectionName); + + // Act + var result = ConnectionHelpers.FindConnection(connectionName, new[] { connection }); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(connectionName, result.ConnectionName); + } + + [TestMethod] + public void FindConnection_ReturnsNullForWhitespaceQuery() + { + // Arrange + var connection = new ConnectionListItem("rdp-test"); + + // Act + var result = ConnectionHelpers.FindConnection(" ", new[] { connection }); + + // Assert + Assert.IsNull(result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RemoteDesktopCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RemoteDesktopCommandProviderTests.cs new file mode 100644 index 0000000000..54698997ee --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RemoteDesktopCommandProviderTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Pages; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +[TestClass] +public class RemoteDesktopCommandProviderTests +{ + [TestMethod] + public void ProviderHasCorrectId() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Assert + Assert.AreEqual("com.microsoft.cmdpal.builtin.remotedesktop", provider.Id); + } + + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } + + [TestMethod] + public void FallbackCommandsNotEmpty() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Act + var commands = provider.FallbackCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } + + [TestMethod] + public void TopLevelCommandsContainListPageCommand() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.AreEqual(1, commands.Length); + Assert.IsInstanceOfType(commands.Single().Command, typeof(RemoteDesktopListPage)); + } + + [TestMethod] + public void FallbackCommandsContainFallbackItem() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Act + var commands = provider.FallbackCommands(); + + // Assert + Assert.AreEqual(1, commands.Length); + Assert.IsInstanceOfType(commands.Single(), typeof(FallbackRemoteDesktopItem)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj new file mode 100644 index 0000000000..aaa86d2f07 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Microsoft.CmdPal.Ext.Shell.UnitTests.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Ext.Shell.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Shell\Microsoft.CmdPal.Ext.Shell.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs new file mode 100644 index 0000000000..919790f198 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/NormalizeCommandLineTests.cs @@ -0,0 +1,71 @@ +// 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.Ext.Shell.Helpers; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +[TestClass] +public class NormalizeCommandLineTests : CommandPaletteUnitTestBase +{ + private void NormalizeTestCore(string input, string expectedExe, string expectedArgs = "") + { + ShellListPageHelpers.NormalizeCommandLineAndArgs(input, out var exe, out var args); + + Assert.AreEqual(expectedExe, exe, ignoreCase: true, culture: System.Globalization.CultureInfo.InvariantCulture); + Assert.AreEqual(expectedArgs, args); + } + + [TestMethod] + [DataRow("ping bing.com", "c:\\Windows\\system32\\ping.exe", "bing.com")] + [DataRow("curl bing.com", "c:\\Windows\\system32\\curl.exe", "bing.com")] + [DataRow("ipconfig /all", "c:\\Windows\\system32\\ipconfig.exe", "/all")] + [DataRow("ipconfig a b \"c d\"", "c:\\Windows\\system32\\ipconfig.exe", "a b \"c d\"")] + public void NormalizeCommandLineSimple(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("\"C:\\Program Files\\Windows Defender\\MsMpEng.exe\"", "C:\\Program Files\\Windows Defender\\MsMpEng.exe")] + [DataRow("C:\\Program Files\\Windows Defender\\MsMpEng.exe", "C:\\Program Files\\Windows Defender\\MsMpEng.exe")] + public void NormalizeCommandLineSpacesInExecutablePath(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("%SystemRoot%\\system32\\cmd.exe", "C:\\Windows\\System32\\cmd.exe")] + public void NormalizeWithEnvVar(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("cmd --run --test", "C:\\Windows\\System32\\cmd.exe", "--run --test")] + [DataRow("cmd --run --test ", "C:\\Windows\\System32\\cmd.exe", "--run --test")] + [DataRow("cmd \"--run --test\" --pass", "C:\\Windows\\System32\\cmd.exe", "\"--run --test\" --pass")] + public void NormalizeArgsWithSpaces(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("ThereIsNoWayYouHaveAnExecutableNamedThisOnThePipeline", "ThereIsNoWayYouHaveAnExecutableNamedThisOnThePipeline", "")] + [DataRow("C:\\ThisPathDoesNotExist\\NoExecutable.exe", "C:\\ThisPathDoesNotExist\\NoExecutable.exe", "")] + public void NormalizeNonExistentExecutable(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } + + [TestMethod] + [DataRow("C:\\Windows", "c:\\Windows", "")] + [DataRow("C:\\Windows foo /bar", "c:\\Windows", "foo /bar")] + public void NormalizeDirectoryAsExecutable(string input, string expectedExe, string expectedArgs = "") + { + NormalizeTestCore(input, expectedExe, expectedArgs); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..8618c815ba --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/QueryTests.cs @@ -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. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Ext.Shell.Pages; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CommandPalette.Extensions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + private static Mock<IRunHistoryService> CreateMockHistoryService(IList<string> historyItems = null) + { + var mockHistoryService = new Mock<IRunHistoryService>(); + var history = historyItems ?? new List<string>(); + + mockHistoryService.Setup(x => x.GetRunHistory()) + .Returns(() => history.ToList().AsReadOnly()); + + mockHistoryService.Setup(x => x.AddRunHistoryItem(It.IsAny<string>())) + .Callback<string>(item => + { + if (!string.IsNullOrWhiteSpace(item)) + { + history.Remove(item); + history.Insert(0, item); + } + }); + + mockHistoryService.Setup(x => x.ClearRunHistory()) + .Callback(() => history.Clear()); + + return mockHistoryService; + } + + private static Mock<IRunHistoryService> CreateMockHistoryServiceWithCommonCommands() + { + var commonCommands = new List<string> + { + "ping google.com", + "ipconfig /all", + "curl https://api.github.com", + "dir", + "cd ..", + "git status", + "npm install", + "python --version", + }; + + return CreateMockHistoryService(commonCommands); + } + + [TestMethod] + public void ValidateHistoryFunctionality() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + + // Act + settings.AddCmdHistory("test-command"); + + // Assert + Assert.AreEqual(1, settings.Count["test-command"]); + } + + [TestMethod] + [DataRow("ping bing.com", "ping.exe")] + [DataRow("curl bing.com", "curl.exe")] + [DataRow("ipconfig /all", "ipconfig.exe")] + [DataRow("\"C:\\Program Files\\Windows Defender\\MsMpEng.exe\"", "MsMpEng.exe")] + [DataRow("C:\\Program Files\\Windows Defender\\MsMpEng.exe", "MsMpEng.exe")] + public async Task QueryWithoutHistoryCommand(string command, string exeName) + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistory = CreateMockHistoryService(); + + var pages = new ShellListPage(settings, mockHistory.Object, telemetryService: null); + + await UpdatePageAndWaitForItems(pages, () => + { + // Test: Search for a command that exists in history + pages.UpdateSearchText(string.Empty, command); + }); + + var commandList = pages.GetItems(); + + Assert.AreEqual(1, commandList.Length); + + var listItem = commandList.FirstOrDefault(); + Assert.IsNotNull(listItem); + + var runExeListItem = listItem as RunExeItem; + Assert.IsNotNull(runExeListItem); + Assert.AreEqual(exeName, runExeListItem.Exe); + Assert.IsTrue(listItem.Title.Contains(exeName), $"expect ${exeName} but got ${listItem.Title}"); + Assert.IsNotNull(listItem.Icon); + } + + [TestMethod] + [DataRow("ping bing.com", "ping.exe")] + [DataRow("curl bing.com", "curl.exe")] + [DataRow("ipconfig /all", "ipconfig.exe")] + public async Task QueryWithHistoryCommands(string command, string exeName) + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); + + var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + await UpdatePageAndWaitForItems(pages, () => + { + // Test: Search for a command that exists in history + pages.UpdateSearchText(string.Empty, command); + }); + + var commandList = pages.GetItems(); + + // Should find at least the ping command from history + Assert.IsTrue(commandList.Length > 1); + + var expectedCommand = commandList.FirstOrDefault(); + Assert.IsNotNull(expectedCommand); + Assert.IsNotNull(expectedCommand.Icon); + Assert.IsTrue(expectedCommand.Title.Contains(exeName), $"expect ${exeName} but got ${expectedCommand.Title}"); + } + + [TestMethod] + public async Task EmptyQueryWithHistoryCommands() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryServiceWithCommonCommands(); + + var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + await UpdatePageAndWaitForItems(pages, () => + { + // Test: Search for a command that exists in history + pages.UpdateSearchText("abcdefg", string.Empty); + }); + + var commandList = pages.GetItems(); + + // Should find at least the ping command from history + Assert.IsTrue(commandList.Length > 1); + } + + [TestMethod] + public async Task TestCacheBackToSameDirectory() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryService(); + + var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + // Load up everything in c:\, for the sake of comparing: + var filesInC = Directory.EnumerateFileSystemEntries("C:\\"); + + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; }); + + var commandList = page.GetItems(); + + // Should find only items for what's in c:\ + Assert.IsTrue(commandList.Length == filesInC.Count()); + + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Win"; }); + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; }); + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; }); + + commandList = page.GetItems(); + + // Should still find everything + Assert.IsTrue(commandList.Length == filesInC.Count()); + + await TypeStringIntoPage(page, "c:\\Windows\\Pro"); + await BackspaceSearchText(page, "c:\\Windows\\Pro", 3); // 3 characters for c:\ + + commandList = page.GetItems(); + + // Should still find everything + Assert.IsTrue(commandList.Length == filesInC.Count()); + } + + private async Task TypeStringIntoPage(IDynamicListPage page, string searchText) + { + // type the string one character at a time + for (var i = 0; i < searchText.Length; i++) + { + var substr = searchText[..i]; + await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; }); + } + } + + private async Task BackspaceSearchText(IDynamicListPage page, string originalSearchText, int finalStringLength) + { + var originalLength = originalSearchText.Length; + for (var i = originalLength; i >= finalStringLength; i--) + { + var substr = originalSearchText[..i]; + await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; }); + } + } + + [TestMethod] + public async Task TestCacheSameDirectorySlashy() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryService(); + + var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + // Load up everything in c:\, for the sake of comparing: + var filesInC = Directory.EnumerateFileSystemEntries("C:\\"); + var filesInWindows = Directory.EnumerateFileSystemEntries("C:\\Windows"); + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; }); + + var commandList = page.GetItems(); + Assert.IsTrue(commandList.Length == filesInC.Count()); + + // First navigate to c:\Windows. This should match everything that matches "windows" inside of C:\ + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; }); + var cWindowsCommandsPre = page.GetItems(); + + // Then go into c:\windows\. This will only have the results in c:\windows\ + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows\\"; }); + var windowsCommands = page.GetItems(); + Assert.IsTrue(windowsCommands.Length != cWindowsCommandsPre.Length); + + // now go back to c:\windows. This should match the results from the last time we entered this string + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; }); + var cWindowsCommandsPost = page.GetItems(); + Assert.IsTrue(cWindowsCommandsPre.Length == cWindowsCommandsPost.Length); + } + + [TestMethod] + public async Task TestPathWithSpaces() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryService(); + + var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + // Load up everything in c:\, for the sake of comparing: + var filesInC = Directory.EnumerateFileSystemEntries("C:\\"); + var filesInProgramFiles = Directory.EnumerateFileSystemEntries("C:\\Program Files"); + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; }); + + var commandList = page.GetItems(); + Assert.IsTrue(commandList.Length == filesInProgramFiles.Count()); + } + + [TestMethod] + public async Task TestNoWrapSuggestionsWithSpaces() + { + // Setup + var settings = Settings.CreateDefaultSettings(); + var mockHistoryService = CreateMockHistoryService(); + + var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null); + + await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; }); + + var commandList = page.GetItems(); + + foreach (var item in commandList) + { + Assert.IsTrue(!string.IsNullOrEmpty(item.TextToSuggest)); + Assert.IsFalse(item.TextToSuggest.StartsWith('"')); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs new file mode 100644 index 0000000000..953f252be8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/Settings.cs @@ -0,0 +1,49 @@ +// 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 Microsoft.CmdPal.Ext.Shell.Helpers; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +public class Settings : ISettingsInterface +{ + private readonly bool leaveShellOpen; + private readonly string shellCommandExecution; + private readonly bool runAsAdministrator; + private readonly Dictionary<string, int> count; + + public Settings( + bool leaveShellOpen = false, + string shellCommandExecution = "0", + bool runAsAdministrator = false, + Dictionary<string, int> count = null) + { + this.leaveShellOpen = leaveShellOpen; + this.shellCommandExecution = shellCommandExecution; + this.runAsAdministrator = runAsAdministrator; + this.count = count ?? new Dictionary<string, int>(); + } + + public bool LeaveShellOpen => leaveShellOpen; + + public string ShellCommandExecution => shellCommandExecution; + + public bool RunAsAdministrator => runAsAdministrator; + + public Dictionary<string, int> Count => count; + + public void AddCmdHistory(string cmdName) + { + count[cmdName] = count.TryGetValue(cmdName, out var currentCount) ? currentCount + 1 : 1; + } + + public static Settings CreateDefaultSettings() => new Settings(); + + public static Settings CreateLeaveShellOpenSettings() => new Settings(leaveShellOpen: true); + + public static Settings CreatePowerShellSettings() => new Settings(shellCommandExecution: "1"); + + public static Settings CreateAdministratorSettings() => new Settings(runAsAdministrator: true); +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs new file mode 100644 index 0000000000..24a3252255 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Shell.UnitTests/ShellCommandProviderTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.Ext.Shell.UnitTests; + +[TestClass] +public class ShellCommandProviderTests +{ + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var mockHistoryService = new Mock<IRunHistoryService>(); + var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var mockHistoryService = new Mock<IRunHistoryService>(); + var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var mockHistoryService = new Mock<IRunHistoryService>(); + var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/BasicTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/BasicTests.cs new file mode 100644 index 0000000000..43662a2145 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/BasicTests.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; +using Microsoft.CmdPal.Ext.System.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.System.UnitTests; + +[TestClass] +public class BasicTests +{ + [TestMethod] + public void IconsHelperTest() + { + // Assert + Assert.IsNotNull(Icons.FirmwareSettingsIcon); + Assert.IsNotNull(Icons.LockIcon); + Assert.IsNotNull(Icons.LogoffIcon); + Assert.IsNotNull(Icons.NetworkAdapterIcon); + Assert.IsNotNull(Icons.RecycleBinIcon); + Assert.IsNotNull(Icons.RestartIcon); + Assert.IsNotNull(Icons.RestartShellIcon); + Assert.IsNotNull(Icons.ShutdownIcon); + Assert.IsNotNull(Icons.SleepIcon); + } + + [TestMethod] + public void Win32HelpersTest() + { + // Setup & Act + // These methods should not throw exceptions + var firmwareType = Win32Helpers.GetSystemFirmwareType(); + + // Assert + // Just testing that they don't throw exceptions + Assert.IsTrue(Enum.IsDefined(typeof(FirmwareType), firmwareType)); + } + + [TestMethod] + public void NetworkConnectionPropertiesTest() + { + // Test that network connection properties can be accessed without throwing exceptions + try + { + var networkPropertiesList = NetworkConnectionProperties.GetList(); + + // If we have network connections, test accessing their properties + if (networkPropertiesList.Count > 0) + { + var networkProperties = networkPropertiesList[0]; + + // Access properties (these used to be methods) + var ipv4 = networkProperties.IPv4; + var ipv6 = networkProperties.IPv6Primary; + var macAddress = networkProperties.PhysicalAddress; + + // Test passes if no exceptions are thrown + Assert.IsTrue(true); + } + else + { + // If no network connections, test still passes + Assert.IsTrue(true); + } + } + catch + { + Assert.Fail("Network properties should not throw exceptions"); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/ImageTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/ImageTests.cs new file mode 100644 index 0000000000..38723b9bdb --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/ImageTests.cs @@ -0,0 +1,41 @@ +// 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.Linq; +using System.Reflection; +using Microsoft.CmdPal.Ext.System.Helpers; +using Microsoft.CmdPal.Ext.System.Pages; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.System.UnitTests; + +[TestClass] +public class ImageTests +{ + [DataRow(true)] + [DataRow(false)] + [TestMethod] + public void IconThemeTest(bool isDarkIcon) + { + var systemPage = new SystemCommandPage(new Settings()); + var commands = systemPage.GetItems(); + + foreach (var item in commands) + { + var icon = item.Icon; + Assert.IsNotNull(icon, $"Icon for '{item.Title}' should not be null."); + if (isDarkIcon) + { + Assert.IsNotEmpty(icon.Dark.Icon, $"Icon for '{item.Title}' should not be empty."); + } + else + { + Assert.IsNotEmpty(icon.Light.Icon, $"Icon for '{item.Title}' should not be empty."); + } + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Microsoft.CmdPal.Ext.System.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Microsoft.CmdPal.Ext.System.UnitTests.csproj new file mode 100644 index 0000000000..9fa2332949 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Microsoft.CmdPal.Ext.System.UnitTests.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Ext.System.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.System\Microsoft.CmdPal.Ext.System.csproj" /> + <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..af17ad8ec3 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/QueryTests.cs @@ -0,0 +1,148 @@ +// 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.Linq; +using Microsoft.CmdPal.Ext.System.Helpers; +using Microsoft.CmdPal.Ext.System.Pages; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.System.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [DataTestMethod] + [DataRow("shutdown", "Shutdown")] + [DataRow("restart", "Restart")] + [DataRow("sign out", "Sign out")] + [DataRow("lock", "Lock")] + [DataRow("sleep", "Sleep")] + [DataRow("hibernate", "Hibernate")] + [DataRow("open recycle", "Open Recycle Bin")] + [DataRow("empty recycle", "Empty Recycle Bin")] + [DataRow("uefi", "UEFI firmware settings")] + public void TopLevelPageQueryTest(string input, string matchedTitle) + { + var settings = new Settings(); + var pages = new SystemCommandPage(settings); + var allCommands = pages.GetItems(); + + var result = Query(input, allCommands); + + // Empty recycle bin command should exist + Assert.IsNotNull(result); + + var firstItem = result.FirstOrDefault(); + + Assert.IsNotNull(firstItem, "No items matched the query."); + Assert.AreEqual(matchedTitle, firstItem.Title, $"Expected to match '{input}' but got '{firstItem.Title}'"); + } + + [TestMethod] + public void RecycleBinCommandTest() + { + var settings = new Settings(hideEmptyRecycleBin: true); + var pages = new SystemCommandPage(settings); + var allCommands = pages.GetItems(); + + var result = Query("recycle", allCommands); + + // Empty recycle bin command should exist + Assert.IsNotNull(result); + + foreach (var item in result) + { + if (item.Title.Contains("Open Recycle Bin") || item.Title.Contains("Empty Recycle Bin")) + { + Assert.Fail("Recycle Bin commands should not be available when hideEmptyRecycleBin is true."); + } + } + + var firstItem = result.FirstOrDefault(); + Assert.IsNotNull(firstItem, "No items matched the query."); + Assert.IsTrue( + firstItem.Title.Contains("Recycle Bin", StringComparison.OrdinalIgnoreCase), + $"Expected to match 'Recycle Bin' but got '{firstItem.Title}'"); + } + + [TestMethod] + public void NetworkCommandsTest() + { + var settings = new Settings(); + var pages = new SystemCommandPage(settings); + var allCommands = pages.GetItems(); + + var ipv4Result = Query("IPv4", allCommands); + + Assert.IsNotNull(ipv4Result); + Assert.IsTrue(ipv4Result.Length > 0, "No IPv4 commands matched the query."); + + var ipv6Result = Query("IPv6", allCommands); + Assert.IsNotNull(ipv6Result); + Assert.IsTrue(ipv6Result.Length > 0, "No IPv6 commands matched the query."); + + var macResult = Query("MAC", allCommands); + Assert.IsNotNull(macResult); + Assert.IsTrue(macResult.Length > 0, "No MAC commands matched the query."); + + var findDisconnectedMACResult = false; + foreach (var item in macResult) + { + if (item.Details.Body.Contains("Disconnected")) + { + findDisconnectedMACResult = true; + break; + } + } + + Assert.IsTrue(findDisconnectedMACResult, "No disconnected MAC address found in the results."); + } + + [TestMethod] + public void HideDisconnectedNetworkInfoTest() + { + var settings = new Settings(hideDisconnectedNetworkInfo: true); + var pages = new SystemCommandPage(settings); + var allCommands = pages.GetItems(); + + var macResult = Query("MAC", allCommands); + Assert.IsNotNull(macResult); + Assert.IsTrue(macResult.Length > 0, "No MAC commands matched the query."); + + var findDisconnectedMACResult = false; + foreach (var item in macResult) + { + if (item.Details.Body.Contains("Disconnected")) + { + findDisconnectedMACResult = true; + break; + } + } + + Assert.IsTrue(!findDisconnectedMACResult, "Disconnected MAC address found in the results."); + } + + [TestMethod] + [DataRow(FirmwareType.Uefi, true)] + [DataRow(FirmwareType.Bios, false)] + [DataRow(FirmwareType.Max, false)] + [DataRow(FirmwareType.Unknown, false)] + public void FirmwareSettingsTest(FirmwareType firmwareType, bool hasCommand) + { + var settings = new Settings(firmwareType: firmwareType); + var pages = new SystemCommandPage(settings); + var allCommands = pages.GetItems(); + var result = Query("UEFI", allCommands); + + // UEFI Firmware Settings command should exist + Assert.IsNotNull(result); + var firstItem = result.FirstOrDefault(); + var firstItemIsUefiCommand = firstItem?.Title.Contains("UEFI", StringComparison.OrdinalIgnoreCase) ?? false; + Assert.AreEqual(hasCommand, firstItemIsUefiCommand, $"Expected to match (or not match) 'UEFI firmware settings' but got '{firstItem?.Title}'"); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Settings.cs new file mode 100644 index 0000000000..6e2a0dc221 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.System.UnitTests/Settings.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.System.Helpers; + +namespace Microsoft.CmdPal.Ext.System.UnitTests; + +public class Settings : ISettingsInterface +{ + private bool hideDisconnectedNetworkInfo; + private bool hideEmptyRecycleBin; + private bool showDialogToConfirmCommand; + private bool showSuccessMessageAfterEmptyingRecycleBin; + private FirmwareType firmwareType; + + public Settings(bool hideDisconnectedNetworkInfo = false, bool hideEmptyRecycleBin = false, bool showDialogToConfirmCommand = false, bool showSuccessMessageAfterEmptyingRecycleBin = false, FirmwareType firmwareType = FirmwareType.Uefi) + { + this.hideDisconnectedNetworkInfo = hideDisconnectedNetworkInfo; + this.hideEmptyRecycleBin = hideEmptyRecycleBin; + this.showDialogToConfirmCommand = showDialogToConfirmCommand; + this.showSuccessMessageAfterEmptyingRecycleBin = showSuccessMessageAfterEmptyingRecycleBin; + this.firmwareType = firmwareType; + } + + public bool HideDisconnectedNetworkInfo() => hideDisconnectedNetworkInfo; + + public bool HideEmptyRecycleBin() => hideEmptyRecycleBin; + + public bool ShowDialogToConfirmCommand() => showDialogToConfirmCommand; + + public bool ShowSuccessMessageAfterEmptyingRecycleBin() => showSuccessMessageAfterEmptyingRecycleBin; + + public FirmwareType GetSystemFirmwareType() => firmwareType; +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/AvailableResultsListTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/AvailableResultsListTests.cs new file mode 100644 index 0000000000..1c5b7a981c --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/AvailableResultsListTests.cs @@ -0,0 +1,494 @@ +// 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.Globalization; +using System.Linq; +using Microsoft.CmdPal.Ext.TimeDate.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests; + +[TestClass] +public class AvailableResultsListTests +{ + private CultureInfo originalCulture; + private CultureInfo originalUiCulture; + + [TestInitialize] + public void Setup() + { + // Set culture to 'en-us' + originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-us", false); + originalUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo("en-us", false); + } + + [TestCleanup] + public void CleanUp() + { + // Set culture to original value + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + + private DateTime GetDateTimeForTest(bool embedUtc = false) + { + var dateTime = new DateTime(2022, 03, 02, 22, 30, 45); + if (embedUtc) + { + return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); + } + else + { + return dateTime; + } + } + + [DataTestMethod] + [DataRow("time", "10:30 PM")] + [DataRow("date", "3/2/2022")] + [DataRow("date and time", "3/2/2022 10:30 PM")] + [DataRow("hour", "22")] + [DataRow("minute", "30")] + [DataRow("second", "45")] + [DataRow("millisecond", "0")] + [DataRow("day (week day)", "Wednesday")] + [DataRow("day of the week (week day)", "4")] + [DataRow("day of the month", "2")] + [DataRow("day of the year", "61")] + [DataRow("week of the month", "1")] + [DataRow("week of the year (calendar week, week number)", "10")] + [DataRow("month", "March")] + [DataRow("month of the year", "3")] + [DataRow("month and day", "March 2")] + [DataRow("year", "2022")] + [DataRow("month and year", "March 2022")] + [DataRow("ISO 8601", "2022-03-02T22:30:45")] + [DataRow("ISO 8601 with time zone", "2022-03-02T22:30:45")] + [DataRow("RFC1123", "Wed, 02 Mar 2022 22:30:45 GMT")] + [DataRow("Date and time in filename-compatible format", "2022-03-02_22-30-45")] + public void LocalFormatsWithShortTimeAndShortDate(string formatLabel, string expectedResult) + { + // Setup + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, false, false, GetDateTimeForTest()); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value, $"Culture {CultureInfo.CurrentCulture.Name}, Culture UI: {CultureInfo.CurrentUICulture.Name}, Calendar: {CultureInfo.CurrentCulture.Calendar}, Region: {RegionInfo.CurrentRegion.Name}"); + } + + [TestMethod] + public void GetList_WithKeywordSearch_ReturnsResults() + { + // Setup + var settings = new SettingsManager(); + + // Act + var results = AvailableResultsList.GetList(true, settings); + + // Assert + Assert.IsNotNull(results); + Assert.IsTrue(results.Count > 0, "Should return at least some results for keyword search"); + } + + [TestMethod] + public void GetList_WithoutKeywordSearch_ReturnsResults() + { + // Setup + var settings = new SettingsManager(); + + // Act + var results = AvailableResultsList.GetList(false, settings); + + // Assert + Assert.IsNotNull(results); + Assert.IsTrue(results.Count > 0, "Should return at least some results for non-keyword search"); + } + + [TestMethod] + public void GetList_WithSpecificDateTime_ReturnsFormattedResults() + { + // Setup + var settings = new SettingsManager(); + var specificDateTime = GetDateTimeForTest(); + + // Act + var results = AvailableResultsList.GetList(true, settings, null, null, specificDateTime); + + // Assert + Assert.IsNotNull(results); + Assert.IsTrue(results.Count > 0, "Should return results for specific datetime"); + + // Verify that all results have values + foreach (var result in results) + { + Assert.IsNotNull(result.Label, "Result label should not be null"); + Assert.IsNotNull(result.Value, "Result value should not be null"); + } + } + + [TestMethod] + public void GetList_ResultsHaveRequiredProperties() + { + // Setup + var settings = new SettingsManager(); + + // Act + var results = AvailableResultsList.GetList(true, settings); + + // Assert + Assert.IsTrue(results.Count > 0, "Should have results"); + + foreach (var result in results) + { + Assert.IsNotNull(result.Label, "Each result should have a label"); + Assert.IsNotNull(result.Value, "Each result should have a value"); + Assert.IsFalse(string.IsNullOrWhiteSpace(result.Label), "Label should not be empty"); + Assert.IsFalse(string.IsNullOrWhiteSpace(result.Value), "Value should not be empty"); + } + } + + [TestMethod] + public void GetList_WithDifferentCalendarSettings_ReturnsResults() + { + // Setup + var settings = new SettingsManager(); + + // Act & Assert - Test with different settings + var results1 = AvailableResultsList.GetList(true, settings); + Assert.IsNotNull(results1); + Assert.IsTrue(results1.Count > 0); + + // Test that the method can handle different calendar settings + var results2 = AvailableResultsList.GetList(false, settings); + Assert.IsNotNull(results2); + Assert.IsTrue(results2.Count > 0); + } + + [DataTestMethod] + [DataRow("time", "10:30 PM")] + [DataRow("date", "Wednesday, March 2, 2022")] + [DataRow("date and time", "Wednesday, March 2, 2022 10:30 PM")] + [DataRow("hour", "22")] + [DataRow("minute", "30")] + [DataRow("second", "45")] + [DataRow("millisecond", "0")] + [DataRow("day (week day)", "Wednesday")] + [DataRow("day of the week (week day)", "4")] + [DataRow("day of the month", "2")] + [DataRow("day of the year", "61")] + [DataRow("week of the month", "1")] + [DataRow("week of the year (calendar week, week number)", "10")] + [DataRow("month", "March")] + [DataRow("month of the year", "3")] + [DataRow("month and day", "March 2")] + [DataRow("year", "2022")] + [DataRow("month and year", "March 2022")] + [DataRow("ISO 8601", "2022-03-02T22:30:45")] + [DataRow("ISO 8601 with time zone", "2022-03-02T22:30:45")] + [DataRow("RFC1123", "Wed, 02 Mar 2022 22:30:45 GMT")] + [DataRow("Date and time in filename-compatible format", "2022-03-02_22-30-45")] + public void LocalFormatsWithShortTimeAndLongDate(string formatLabel, string expectedResult) + { + // Setup + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, false, true, GetDateTimeForTest()); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [DataTestMethod] + [DataRow("time", "10:30:45 PM")] + [DataRow("date", "3/2/2022")] + [DataRow("date and time", "3/2/2022 10:30:45 PM")] + [DataRow("hour", "22")] + [DataRow("minute", "30")] + [DataRow("second", "45")] + [DataRow("millisecond", "0")] + [DataRow("day (week day)", "Wednesday")] + [DataRow("day of the week (week day)", "4")] + [DataRow("day of the month", "2")] + [DataRow("day of the year", "61")] + [DataRow("week of the month", "1")] + [DataRow("week of the year (calendar week, week number)", "10")] + [DataRow("month", "March")] + [DataRow("month of the year", "3")] + [DataRow("month and day", "March 2")] + [DataRow("year", "2022")] + [DataRow("month and year", "March 2022")] + [DataRow("ISO 8601", "2022-03-02T22:30:45")] + [DataRow("ISO 8601 with time zone", "2022-03-02T22:30:45")] + [DataRow("RFC1123", "Wed, 02 Mar 2022 22:30:45 GMT")] + [DataRow("Date and time in filename-compatible format", "2022-03-02_22-30-45")] + public void LocalFormatsWithLongTimeAndShortDate(string formatLabel, string expectedResult) + { + // Setup + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, true, false, GetDateTimeForTest()); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [DataTestMethod] + [DataRow("time", "10:30:45 PM")] + [DataRow("date", "Wednesday, March 2, 2022")] + [DataRow("date and time", "Wednesday, March 2, 2022 10:30:45 PM")] + [DataRow("hour", "22")] + [DataRow("minute", "30")] + [DataRow("second", "45")] + [DataRow("millisecond", "0")] + [DataRow("day (week day)", "Wednesday")] + [DataRow("day of the week (week day)", "4")] + [DataRow("day of the month", "2")] + [DataRow("day of the year", "61")] + [DataRow("week of the month", "1")] + [DataRow("week of the year (calendar week, week number)", "10")] + [DataRow("month", "March")] + [DataRow("month of the year", "3")] + [DataRow("month and day", "March 2")] + [DataRow("year", "2022")] + [DataRow("month and year", "March 2022")] + [DataRow("ISO 8601", "2022-03-02T22:30:45")] + [DataRow("ISO 8601 with time zone", "2022-03-02T22:30:45")] + [DataRow("RFC1123", "Wed, 02 Mar 2022 22:30:45 GMT")] + [DataRow("Date and time in filename-compatible format", "2022-03-02_22-30-45")] + public void LocalFormatsWithLongTimeAndLongDate(string formatLabel, string expectedResult) + { + // Setup + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, true, true, GetDateTimeForTest()); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [DataTestMethod] + [DataRow("time utc", "t")] + [DataRow("date and time utc", "g")] + [DataRow("ISO 8601 UTC", "yyyy-MM-ddTHH:mm:ss")] + [DataRow("ISO 8601 UTC with time zone", "yyyy-MM-ddTHH:mm:ss'Z'")] + [DataRow("Universal time format: YYYY-MM-DD hh:mm:ss", "u")] + [DataRow("Date and time in filename-compatible format", "yyyy-MM-dd_HH-mm-ss")] + public void UtcFormatsWithShortTimeAndShortDate(string formatLabel, string expectedFormat) + { + // Setup + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, false, false, GetDateTimeForTest(true)); + var expectedResult = GetDateTimeForTest().ToString(expectedFormat, CultureInfo.CurrentCulture); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [DataTestMethod] + [DataRow("time utc", "t")] + [DataRow("date and time utc", "f")] + [DataRow("ISO 8601 UTC", "yyyy-MM-ddTHH:mm:ss")] + [DataRow("ISO 8601 UTC with time zone", "yyyy-MM-ddTHH:mm:ss'Z'")] + [DataRow("Universal time format: YYYY-MM-DD hh:mm:ss", "u")] + [DataRow("Date and time in filename-compatible format", "yyyy-MM-dd_HH-mm-ss")] + public void UtcFormatsWithShortTimeAndLongDate(string formatLabel, string expectedFormat) + { + // Setup + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, false, true, GetDateTimeForTest(true)); + var expectedResult = GetDateTimeForTest().ToString(expectedFormat, CultureInfo.CurrentCulture); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [DataTestMethod] + [DataRow("time utc", "T")] + [DataRow("date and time utc", "G")] + [DataRow("ISO 8601 UTC", "yyyy-MM-ddTHH:mm:ss")] + [DataRow("ISO 8601 UTC with time zone", "yyyy-MM-ddTHH:mm:ss'Z'")] + [DataRow("Universal time format: YYYY-MM-DD hh:mm:ss", "u")] + [DataRow("Date and time in filename-compatible format", "yyyy-MM-dd_HH-mm-ss")] + public void UtcFormatsWithLongTimeAndShortDate(string formatLabel, string expectedFormat) + { + // Setup + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, true, false, GetDateTimeForTest(true)); + var expectedResult = GetDateTimeForTest().ToString(expectedFormat, CultureInfo.CurrentCulture); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [DataTestMethod] + [DataRow("time utc", "T")] + [DataRow("date and time utc", "F")] + [DataRow("ISO 8601 UTC", "yyyy-MM-ddTHH:mm:ss")] + [DataRow("ISO 8601 UTC with time zone", "yyyy-MM-ddTHH:mm:ss'Z'")] + [DataRow("Universal time format: YYYY-MM-DD hh:mm:ss", "u")] + [DataRow("Date and time in filename-compatible format", "yyyy-MM-dd_HH-mm-ss")] + public void UtcFormatsWithLongTimeAndLongDate(string formatLabel, string expectedFormat) + { + // Setup + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, true, true, GetDateTimeForTest(true)); + var expectedResult = GetDateTimeForTest().ToString(expectedFormat, CultureInfo.CurrentCulture); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [TestMethod] + public void UnixTimestampSecondsFormat() + { + // Setup + var formatLabel = "Unix epoch time"; + DateTime timeValue = DateTime.Now.ToUniversalTime(); + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, null, null, timeValue); + var expectedResult = (long)timeValue.Subtract(new DateTime(1970, 1, 1)).TotalSeconds; + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult.ToString(CultureInfo.CurrentCulture), result?.Value); + } + + [TestMethod] + public void UnixTimestampMillisecondsFormat() + { + // Setup + var formatLabel = "Unix epoch time in milliseconds"; + DateTime timeValue = DateTime.Now.ToUniversalTime(); + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, null, null, timeValue); + var expectedResult = (long)timeValue.Subtract(new DateTime(1970, 1, 1)).TotalMilliseconds; + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult.ToString(CultureInfo.CurrentCulture), result?.Value); + } + + [TestMethod] + public void WindowsFileTimeFormat() + { + // Setup + var formatLabel = "Windows file time (Int64 number)"; + DateTime timeValue = DateTime.Now; + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, null, null, timeValue); + var expectedResult = timeValue.ToFileTime().ToString(CultureInfo.CurrentCulture); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [TestMethod] + public void ValidateEraResult() + { + // Setup + var formatLabel = "Era"; + DateTime timeValue = DateTime.Now; + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, null, null, timeValue); + var expectedResult = DateTimeFormatInfo.CurrentInfo.GetEraName(CultureInfo.CurrentCulture.Calendar.GetEra(timeValue)); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [TestMethod] + public void ValidateEraAbbreviationResult() + { + // Setup + var formatLabel = "Era abbreviation"; + DateTime timeValue = DateTime.Now; + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, null, null, timeValue); + var expectedResult = DateTimeFormatInfo.CurrentInfo.GetAbbreviatedEraName(CultureInfo.CurrentCulture.Calendar.GetEra(timeValue)); + + // Act + var result = helperResults.FirstOrDefault(x => x.Label.Equals(formatLabel, StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedResult, result?.Value); + } + + [DataTestMethod] + [DataRow(CalendarWeekRule.FirstDay, "3")] + [DataRow(CalendarWeekRule.FirstFourDayWeek, "2")] + [DataRow(CalendarWeekRule.FirstFullWeek, "2")] + public void DifferentFirstWeekSettingConfigurations(CalendarWeekRule weekRule, string expectedWeekOfYear) + { + // Setup + DateTime timeValue = new DateTime(2021, 1, 12); + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, null, null, timeValue, weekRule, DayOfWeek.Sunday); + + // Act + var resultWeekOfYear = helperResults.FirstOrDefault(x => x.Label.Equals("week of the year (calendar week, week number)", StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedWeekOfYear, resultWeekOfYear?.Value); + } + + [DataTestMethod] + [DataRow(DayOfWeek.Monday, "2", "2", "5")] + [DataRow(DayOfWeek.Tuesday, "3", "3", "4")] + [DataRow(DayOfWeek.Wednesday, "3", "3", "3")] + [DataRow(DayOfWeek.Thursday, "3", "3", "2")] + [DataRow(DayOfWeek.Friday, "3", "3", "1")] + [DataRow(DayOfWeek.Saturday, "2", "2", "7")] + [DataRow(DayOfWeek.Sunday, "2", "2", "6")] + public void DifferentFirstDayOfWeekSettingConfigurations(DayOfWeek dayOfWeek, string expectedWeekOfYear, string expectedWeekOfMonth, string expectedDayInWeek) + { + // Setup + DateTime timeValue = new DateTime(2024, 1, 12); // Friday + var settings = new SettingsManager(); + var helperResults = AvailableResultsList.GetList(true, settings, null, null, timeValue, CalendarWeekRule.FirstDay, dayOfWeek); + + // Act + var resultWeekOfYear = helperResults.FirstOrDefault(x => x.Label.Equals("week of the year (calendar week, week number)", StringComparison.OrdinalIgnoreCase)); + var resultWeekOfMonth = helperResults.FirstOrDefault(x => x.Label.Equals("week of the month", StringComparison.OrdinalIgnoreCase)); + var resultDayInWeek = helperResults.FirstOrDefault(x => x.Label.Equals("day of the week (week day)", StringComparison.OrdinalIgnoreCase)); + + // Assert + Assert.AreEqual(expectedWeekOfYear, resultWeekOfYear?.Value); + Assert.AreEqual(expectedWeekOfMonth, resultWeekOfMonth?.Value); + Assert.AreEqual(expectedDayInWeek, resultDayInWeek?.Value); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/FallbackTimeDateItemTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/FallbackTimeDateItemTests.cs new file mode 100644 index 0000000000..3f13336b0b --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/FallbackTimeDateItemTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using Microsoft.CmdPal.Ext.TimeDate.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests; + +[TestClass] +public class FallbackTimeDateItemTests +{ + private CultureInfo originalCulture; + private CultureInfo originalUiCulture; + + [TestInitialize] + public void Setup() + { + // Set culture to 'en-us' + originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-us", false); + originalUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo("en-us", false); + } + + [TestCleanup] + public void Cleanup() + { + // Restore original culture + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + + [DataTestMethod] + [DataRow("time", "12:00 PM")] + [DataRow("date", "7/1/2025")] + [DataRow("week", "27")] + public void FallbackQueryTests(string query, string expectedTitle) + { + // Setup + var settingsManager = new Settings(); + DateTime now = new DateTime(2025, 7, 1, 12, 0, 0); // Fixed date for testing + var fallbackItem = new FallbackTimeDateItem(settingsManager, now); + + // Act & Assert - Test that UpdateQuery doesn't throw exceptions + try + { + fallbackItem.UpdateQuery(query); + Assert.IsTrue( + fallbackItem.Title.Contains(expectedTitle, StringComparison.OrdinalIgnoreCase), + $"Expected title to contain '{expectedTitle}', but got '{fallbackItem.Title}'"); + Assert.IsNotNull(fallbackItem.Subtitle, "Subtitle should not be null"); + Assert.IsNotNull(fallbackItem.Icon, "Icon should not be null"); + } + catch (Exception ex) + { + Assert.Fail($"UpdateQuery should not throw exceptions: {ex.Message}"); + } + } + + [DataTestMethod] + [DataRow(null)] + [DataRow("invalid input")] + public void InvalidQueryTests(string query) + { + // Setup + var settingsManager = new Settings(); + DateTime now = new DateTime(2025, 7, 1, 12, 0, 0); // Fixed date for testing + var fallbackItem = new FallbackTimeDateItem(settingsManager, now); + + // Act & Assert - Test that UpdateQuery doesn't throw exceptions + try + { + fallbackItem.UpdateQuery(query); + + Assert.AreEqual(string.Empty, fallbackItem.Title, "Title should be empty for invalid queries"); + Assert.AreEqual(string.Empty, fallbackItem.Subtitle, "Subtitle should be empty for invalid queries"); + } + catch (Exception ex) + { + Assert.Fail($"UpdateQuery should not throw exceptions: {ex.Message}"); + } + } + + [DataTestMethod] + public void DisableFallbackItemTest() + { + // Setup + var settingsManager = new Settings(enableFallbackItems: false); + DateTime now = new DateTime(2025, 7, 1, 12, 0, 0); // Fixed date for testing + var fallbackItem = new FallbackTimeDateItem(settingsManager, now); + + // Act & Assert - Test that UpdateQuery doesn't throw exceptions + try + { + fallbackItem.UpdateQuery("now"); + + Assert.AreEqual(string.Empty, fallbackItem.Title, "Title should be empty when disable fallback item"); + Assert.AreEqual(string.Empty, fallbackItem.Subtitle, "Subtitle should be empty when disable fallback item"); + } + catch (Exception ex) + { + Assert.Fail($"UpdateQuery should not throw exceptions: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/Microsoft.CmdPal.Ext.TimeDate.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/Microsoft.CmdPal.Ext.TimeDate.UnitTests.csproj new file mode 100644 index 0000000000..bd785d94da --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/Microsoft.CmdPal.Ext.TimeDate.UnitTests.csproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Ext.TimeDate.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.TimeDate\Microsoft.CmdPal.Ext.TimeDate.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..cba7614044 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/QueryTests.cs @@ -0,0 +1,222 @@ +// 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.Globalization; +using System.Linq; +using Microsoft.CmdPal.Ext.TimeDate.Helpers; +using Microsoft.CmdPal.Ext.TimeDate.Pages; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + private CultureInfo originalCulture; + private CultureInfo originalUiCulture; + + [TestInitialize] + public void Setup() + { + // Set culture to 'en-us' + originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-us", false); + originalUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo("en-us", false); + } + + [TestCleanup] + public void CleanUp() + { + // Set culture to original value + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + + [DataTestMethod] + [DataRow("time", 1)] // Common time queries should return results + [DataRow("date", 1)] // Common date queries should return results + [DataRow("now", 1)] // Now should return multiple results + [DataRow("current", 1)] // Current should return multiple results + [DataRow("year", 1)] // Year-related queries should return results + [DataRow("time::10:10:10", 1)] // Specific time format should return results + [DataRow("date::10/10/10", 1)] // Specific date format should return results + public void CountBasicQueries(string query, int expectedMinResultCount) + { + // Setup + var settings = new Settings(); + + // Act + var results = TimeDateCalculator.ExecuteSearch(settings, query); + + // Assert + Assert.IsTrue( + results.Count >= expectedMinResultCount, + $"Expected at least {expectedMinResultCount} results for query '{query}', but got {results.Count}"); + } + + [DataTestMethod] + [DataRow("time", "time")] + [DataRow("date", "date")] + [DataRow("year", "year")] + [DataRow("now", "now")] + [DataRow("year", "year")] + public void BasicQueryTest(string input, string expectedMatchTerm) + { + var settings = new Settings(); + var page = new TimeDateExtensionPage(settings); + page.UpdateSearchText(string.Empty, input); + var resultLists = page.GetItems(); + + var result = Query(input, resultLists); + + Assert.IsNotNull(result); + Assert.IsTrue(result.Length > 0, "No items matched the query."); + + var firstItem = result.FirstOrDefault(); + Assert.IsNotNull(firstItem, "No items matched the query."); + Assert.IsTrue( + firstItem.Title.Contains(expectedMatchTerm, System.StringComparison.OrdinalIgnoreCase) || + firstItem.Subtitle.Contains(expectedMatchTerm, System.StringComparison.OrdinalIgnoreCase), + $"Expected to match '{expectedMatchTerm}' in title or subtitle but got '{firstItem.Title}' - '{firstItem.Subtitle}'"); + } + + [DataTestMethod] + [DataRow("unix", "Unix epoch time")] + [DataRow("unix epoch time in milli", "Unix epoch time in milliseconds")] + [DataRow("file", "Windows file time (Int64 number)")] + [DataRow("hour", "Hour")] + [DataRow("minute", "Minute")] + [DataRow("second", "Second")] + [DataRow("millisecond", "Millisecond")] + [DataRow("day", "Day (Week day)")] + [DataRow("day of week", "Day of the week (Week day)")] + [DataRow("day of month", "Day of the month")] + [DataRow("day of year", "Day of the year")] + [DataRow("week of month", "Week of the month")] + [DataRow("week of year", "Week of the year (Calendar week, Week number)")] + [DataRow("month", "Month")] + [DataRow("month of year", "Month of the year")] + [DataRow("month and d", "Month and day")] + [DataRow("year", "Year")] + [DataRow("universal", "Universal time format: YYYY-MM-DD hh:mm:ss")] + [DataRow("rfc", "RFC1123")] + [DataRow("time::12:30", "Time")] + [DataRow("date::10.10.2022", "Date")] + [DataRow("time::u1646408119", "Time")] + [DataRow("time::ft637820085517321977", "Time")] + [DataRow("week day", "Day (Week day)")] + [DataRow("cal week", "Week of the year (Calendar week, Week number)")] + [DataRow("week num", "Week of the year (Calendar week, Week number)")] + [DataRow("days in mo", "Days in month")] + [DataRow("Leap y", "Leap year")] + public void FormatDateQueryTest(string input, string expectedMatchTerm) + { + var settings = new Settings(); + var page = new TimeDateExtensionPage(settings); + page.UpdateSearchText(string.Empty, input); + var resultLists = page.GetItems(); + + var firstItem = resultLists.FirstOrDefault(); + Assert.IsNotNull(firstItem, "No items matched the query."); + Assert.IsTrue( + firstItem.Title.Contains(expectedMatchTerm, System.StringComparison.OrdinalIgnoreCase) || + firstItem.Subtitle.Contains(expectedMatchTerm, System.StringComparison.OrdinalIgnoreCase), + $"Expected to match '{expectedMatchTerm}' in title or subtitle but got '{firstItem.Title}' - '{firstItem.Subtitle}'"); + } + + [DataTestMethod] + [DataRow("abcdefg")] + [DataRow("timmmmeeee")] + [DataRow("timtaaaetetaae::u1646408119")] + [DataRow("time:eeee")] + [DataRow("time::eeee")] + [DataRow("time//eeee")] + [DataRow("ug1646408119")] // Invalid prefix + [DataRow("u9999999999999")] // Unix number + prefix is longer than 12 characters + [DataRow("ums999999999999999")] // Unix number in milliseconds + prefix is longer than 17 characters + [DataRow("-u99999999999")] // Unix number with wrong placement of - sign + [DataRow("+ums9999999999")] // Unix number in milliseconds with wrong placement of + sign + [DataRow("0123456")] // Missing prefix + [DataRow("ft63782008ab55173dasdas21977")] // Number contains letters + [DataRow("ft63782008ab55173dasdas")] // Number contains letters at the end + [DataRow("ft12..548")] // Number contains wrong punctuation + [DataRow("ft12..54//8")] // Number contains wrong punctuation and other characters + [DataRow("time::ft12..54//8")] // Number contains wrong punctuation and other characters + [DataRow("ut2ed.5555")] // Number contains letters + [DataRow("12..54//8")] // Number contains punctuation and other characters, but no special prefix + [DataRow("ft::1288gg8888")] // Number contains delimiter and letters, but no special prefix + [DataRow("date::12::55")] + [DataRow("date::12:aa:55")] + [DataRow("10.aa.22")] + [DataRow("12::55")] + [DataRow("12:aa:55")] + public void InvalidInputShowsErrorResults(string query) + { + var settings = new Settings(); + var page = new TimeDateExtensionPage(settings); + page.UpdateSearchText(string.Empty, query); + var results = page.GetItems(); + + // Assert + Assert.IsNotNull(results, $"Results should not be null for query '{query}'"); + Assert.IsTrue(results.Length > 0, $"Query '{query}' should return at least one result"); + + var firstItem = results.FirstOrDefault(); + Assert.IsTrue(firstItem.Title.StartsWith("Error: Invalid input", StringComparison.CurrentCulture), $"Query '{query}' should return an error result for invalid input"); + } + + [DataTestMethod] + [DataRow("")] + [DataRow(null)] + public void EmptyQueryReturnsAllResults(string input) + { + var settings = new Settings(); + var page = new TimeDateExtensionPage(settings); + page.UpdateSearchText("abc", input); + var results = page.GetItems(); + + // Assert + Assert.IsTrue(results.Length > 0, $"Empty query should return results"); + } + + [DataTestMethod] + [DataRow("time u", "Time UTC")] + [DataRow("now u", "Now UTC")] + [DataRow("iso utc", "ISO 8601 UTC")] + [DataRow("iso zone", "ISO 8601 with time zone")] + [DataRow("iso utc zone", "ISO 8601 UTC with time zone")] + public void TimeZoneQuery(string query, string expectedSubtitle) + { + var settings = new Settings(); + var page = new TimeDateExtensionPage(settings); + page.UpdateSearchText(string.Empty, query); + var resultsList = page.GetItems(); + var results = Query(query, resultsList); + + // Assert + Assert.IsNotNull(results); + var firstResult = results.FirstOrDefault(); + Assert.IsTrue(firstResult.Subtitle.StartsWith(expectedSubtitle, StringComparison.CurrentCulture), $"Could not find result with subtitle starting with '{expectedSubtitle}' for query '{query}'"); + } + + [DataTestMethod] + [DataRow("time::12:30:45", "12:30 PM")] + [DataRow("date::2023-12-25", "12/25/2023")] + [DataRow("now::u1646408119", "132908817190000000")] + public void DelimiterQueriesReturnResults(string query, string expectedResult) + { + var settings = new Settings(); + var page = new TimeDateExtensionPage(settings); + page.UpdateSearchText(string.Empty, query); + var resultsList = page.GetItems(); + + // Assert + Assert.IsNotNull(resultsList); + var firstResult = resultsList.FirstOrDefault(); + Assert.IsTrue(firstResult.Title.Contains(expectedResult, StringComparison.CurrentCulture), $"Delimiter query '{query}' result not match {expectedResult} current result {firstResult.Title}"); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/ResultHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/ResultHelperTests.cs new file mode 100644 index 0000000000..1a7fdd3038 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/ResultHelperTests.cs @@ -0,0 +1,143 @@ +// 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.Globalization; +using Microsoft.CmdPal.Ext.TimeDate.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests; + +[TestClass] +public class ResultHelperTests +{ + private CultureInfo originalCulture; + private CultureInfo originalUiCulture; + + [TestInitialize] + public void Setup() + { + // Set culture to 'en-us' + originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-us", false); + originalUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo("en-us", false); + } + + [TestCleanup] + public void CleanUp() + { + // Set culture to original value + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + + [TestMethod] + public void ResultHelper_CreateListItem_ReturnsValidItem() + { + // Setup + var availableResult = new AvailableResult + { + Label = "Test Label", + Value = "Test Value", + }; + + // Act + var listItem = availableResult.ToListItem(); + + // Assert + Assert.IsNotNull(listItem); + Assert.AreEqual("Test Value", listItem.Title); + Assert.AreEqual("Test Label", listItem.Subtitle); + } + + [TestMethod] + public void ResultHelper_CreateListItem_HandlesNullInput() + { + AvailableResult availableResult = null; + + // Act & Assert + Assert.ThrowsException<System.NullReferenceException>(() => availableResult.ToListItem()); + } + + [TestMethod] + public void ResultHelper_CreateListItem_HandlesEmptyValues() + { + // Setup + var availableResult = new AvailableResult + { + Label = string.Empty, + Value = string.Empty, + }; + + // Act + var listItem = availableResult.ToListItem(); + + // Assert + Assert.IsNotNull(listItem); + Assert.AreEqual("Copy", listItem.Title); + Assert.AreEqual(string.Empty, listItem.Subtitle); + } + + [TestMethod] + public void ResultHelper_CreateListItem_WithIcon() + { + // Setup + var availableResult = new AvailableResult + { + Label = "Test Label", + Value = "Test Value", + IconType = ResultIconType.Date, + }; + + // Act + var listItem = availableResult.ToListItem(); + + // Assert + Assert.IsNotNull(listItem); + Assert.AreEqual("Test Value", listItem.Title); + Assert.AreEqual("Test Label", listItem.Subtitle); + Assert.IsNotNull(listItem.Icon); + } + + [TestMethod] + public void ResultHelper_CreateListItem_WithLongText() + { + // Setup + var longText = new string('A', 1000); + var availableResult = new AvailableResult + { + Label = longText, + Value = longText, + }; + + // Act + var listItem = availableResult.ToListItem(); + + // Assert + Assert.IsNotNull(listItem); + Assert.AreEqual(longText, listItem.Title); + Assert.AreEqual(longText, listItem.Subtitle); + } + + [TestMethod] + public void ResultHelper_CreateListItem_WithSpecialCharacters() + { + // Setup + var specialText = "Test & < > \" ' \n \t"; + var availableResult = new AvailableResult + { + Label = specialText, + Value = specialText, + }; + + // Act + var listItem = availableResult.ToListItem(); + + // Assert + Assert.IsNotNull(listItem); + Assert.AreEqual(specialText, listItem.Title); + Assert.AreEqual(specialText, listItem.Subtitle); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/Settings.cs new file mode 100644 index 0000000000..ce412a7377 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/Settings.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.TimeDate.Helpers; + +namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests; + +public class Settings : ISettingsInterface +{ + private readonly int firstWeekOfYear; + private readonly int firstDayOfWeek; + private readonly bool enableFallbackItems; + private readonly bool timeWithSecond; + private readonly bool dateWithWeekday; + private readonly List<string> customFormats; + + public Settings( + int firstWeekOfYear = -1, + int firstDayOfWeek = -1, + bool enableFallbackItems = true, + bool timeWithSecond = false, + bool dateWithWeekday = false, + List<string> customFormats = null) + { + this.firstWeekOfYear = firstWeekOfYear; + this.firstDayOfWeek = firstDayOfWeek; + this.enableFallbackItems = enableFallbackItems; + this.timeWithSecond = timeWithSecond; + this.dateWithWeekday = dateWithWeekday; + this.customFormats = customFormats ?? new List<string>(); + } + + public int FirstWeekOfYear => firstWeekOfYear; + + public int FirstDayOfWeek => firstDayOfWeek; + + public bool EnableFallbackItems => enableFallbackItems; + + public bool TimeWithSecond => timeWithSecond; + + public bool DateWithWeekday => dateWithWeekday; + + public List<string> CustomFormats => customFormats; +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/StringParserTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/StringParserTests.cs new file mode 100644 index 0000000000..16c69c5a39 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/StringParserTests.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; +using System.Globalization; +using Microsoft.CmdPal.Ext.TimeDate.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests; + +[TestClass] +public class StringParserTests +{ + private CultureInfo originalCulture; + private CultureInfo originalUiCulture; + + [TestInitialize] + public void Setup() + { + // Set culture to 'en-us' + originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-us", false); + originalUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo("en-us", false); + } + + [DataTestMethod] + [DataRow("10/29/2022 17:05:10", true, "G", "10/29/2022 5:05:10 PM")] + [DataRow("Saturday, October 29, 2022 5:05:10 PM", true, "G", "10/29/2022 5:05:10 PM")] + [DataRow("10/29/2022", true, "d", "10/29/2022")] + [DataRow("Saturday, October 29, 2022", true, "d", "10/29/2022")] + [DataRow("17:05:10", true, "T", "5:05:10 PM")] + [DataRow("5:05:10 PM", true, "T", "5:05:10 PM")] + [DataRow("10456", false, "", "")] + [DataRow("u10456", true, "", "")] // Value is UTC and can be different based on system + [DataRow("u-10456", true, "", "")] // Value is UTC and can be different based on system + [DataRow("u+10456", true, "", "")] // Value is UTC and can be different based on system + [DataRow("ums10456", true, "", "")] // Value is UTC and can be different based on system + [DataRow("ums-10456", true, "", "")] // Value is UTC and can be different based on system + [DataRow("ums+10456", true, "", "")] // Value is UTC and can be different based on system + [DataRow("ft10456", true, "", "")] // Value is UTC and can be different based on system + [DataRow("oa-657434.99999999", true, "G", "1/1/0100 11:59:59 PM")] + [DataRow("oa2958465.99999999", true, "G", "12/31/9999 11:59:59 PM")] + [DataRow("oa-657435", false, "", "")] // Value to low + [DataRow("oa2958466", false, "", "")] // Value to large + [DataRow("exc1.99998843", true, "G", "1/1/1900 11:59:59 PM")] + [DataRow("exc59.99998843", true, "G", "2/28/1900 11:59:59 PM")] + [DataRow("exc61", true, "G", "3/1/1900 12:00:00 AM")] + [DataRow("exc62.99998843", true, "G", "3/2/1900 11:59:59 PM")] + [DataRow("exc2958465.99998843", true, "G", "12/31/9999 11:59:59 PM")] + [DataRow("exc0", false, "", "")] // Day 0 means in Excel 0/1/1900 and this is a fake date. + [DataRow("exc0.99998843", false, "", "")] // Day 0 means in Excel 0/1/1900 and this is a fake date. + [DataRow("exc60.99998843", false, "", "")] // Day 60 means in Excel 2/29/1900 and this is a fake date in Excel which we cannot support. + [DataRow("exc60", false, "", "")] // Day 60 means in Excel 2/29/1900 and this is a fake date in Excel which we cannot support. + [DataRow("exc-1", false, "", "")] // Value to low + [DataRow("exc2958466", false, "", "")] // Value to large + [DataRow("exf0.99998843", true, "G", "1/1/1904 11:59:59 PM")] + [DataRow("exf2957003.99998843", true, "G", "12/31/9999 11:59:59 PM")] + [DataRow("exf-0.5", false, "", "")] // Value to low + [DataRow("exf2957004", false, "", "")] // Value to large + public void ConvertStringToDateTime(string typedString, bool expectedBool, string stringType, string expectedString) + { + // Act + var boolResult = TimeAndDateHelper.ParseStringAsDateTime(in typedString, out DateTime result, out _); + + // Assert + Assert.AreEqual(expectedBool, boolResult); + if (!string.IsNullOrEmpty(expectedString)) + { + Assert.AreEqual(expectedString, result.ToString(stringType, CultureInfo.CurrentCulture)); + } + } + + [TestMethod] + public void ParseStringAsDateTime_BasicTest() + { + // Test basic string parsing functionality + var testCases = new[] + { + ("2023-12-25", true), + ("12/25/2023", true), + ("invalid date", false), + (string.Empty, false), + }; + + foreach (var (input, expectedSuccess) in testCases) + { + // Act + var result = TimeAndDateHelper.ParseStringAsDateTime(in input, out DateTime dateTime, out var errorMessage); + + // Assert + Assert.AreEqual(expectedSuccess, result, $"Failed for input: {input}"); + if (!expectedSuccess) + { + Assert.IsFalse(string.IsNullOrEmpty(errorMessage), $"Error message should not be empty for invalid input: {input}"); + } + } + } + + [TestMethod] + public void ParseStringAsDateTime_UnixTimestampTest() + { + // Test Unix timestamp parsing + var unixTimestamp = "u1640995200"; // 2022-01-01 00:00:00 UTC + + // Act + var result = TimeAndDateHelper.ParseStringAsDateTime(in unixTimestamp, out DateTime dateTime, out var errorMessage); + + // Assert + Assert.IsTrue(result, "Unix timestamp parsing should succeed"); + Assert.IsTrue(string.IsNullOrEmpty(errorMessage), "Error message should be empty for valid Unix timestamp"); + } + + [TestMethod] + public void ParseStringAsDateTime_FileTimeTest() + { + // Test Windows file time parsing + var fileTime = "ft132857664000000000"; // Some valid file time + + // Act + var result = TimeAndDateHelper.ParseStringAsDateTime(in fileTime, out DateTime dateTime, out var errorMessage); + + // Assert + Assert.IsTrue(result, "File time parsing should succeed"); + } + + [TestCleanup] + public void CleanUp() + { + // Set culture to original value + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeAndDateHelperTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeAndDateHelperTests.cs new file mode 100644 index 0000000000..90ec826a95 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeAndDateHelperTests.cs @@ -0,0 +1,132 @@ +// 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.Globalization; +using Microsoft.CmdPal.Ext.TimeDate.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests; + +[TestClass] +public class TimeAndDateHelperTests +{ + private CultureInfo originalCulture; + private CultureInfo originalUiCulture; + + [TestInitialize] + public void Setup() + { + // Set culture to 'en-us' + originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-us", false); + originalUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo("en-us", false); + } + + [TestCleanup] + public void Cleanup() + { + // Restore original culture + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + + [DataTestMethod] + [DataRow(-1, null)] // default setting + [DataRow(0, CalendarWeekRule.FirstDay)] + [DataRow(1, CalendarWeekRule.FirstFullWeek)] + [DataRow(2, CalendarWeekRule.FirstFourDayWeek)] + [DataRow(30, null)] // wrong setting + public void GetCalendarWeekRuleBasedOnPluginSetting(int setting, CalendarWeekRule? valueExpected) + { + // Act + var result = TimeAndDateHelper.GetCalendarWeekRule(setting); + + // Assert + if (valueExpected is null) + { + // falls back to system setting. + Assert.AreEqual(DateTimeFormatInfo.CurrentInfo.CalendarWeekRule, result); + } + else + { + Assert.AreEqual(valueExpected, result); + } + } + + [DataTestMethod] + [DataRow(-1, null)] // default setting + [DataRow(0, DayOfWeek.Sunday)] + [DataRow(1, DayOfWeek.Monday)] + [DataRow(2, DayOfWeek.Tuesday)] + [DataRow(3, DayOfWeek.Wednesday)] + [DataRow(4, DayOfWeek.Thursday)] + [DataRow(5, DayOfWeek.Friday)] + [DataRow(6, DayOfWeek.Saturday)] + [DataRow(30, null)] // wrong setting + public void GetFirstDayOfWeekBasedOnPluginSetting(int setting, DayOfWeek? valueExpected) + { + // Act + var result = TimeAndDateHelper.GetFirstDayOfWeek(setting); + + // Assert + if (valueExpected is null) + { + // falls back to system setting. + Assert.AreEqual(DateTimeFormatInfo.CurrentInfo.FirstDayOfWeek, result); + } + else + { + Assert.AreEqual(valueExpected, result); + } + } + + [DataTestMethod] + [DataRow("yyyy-MM-dd", "2023-12-25")] + [DataRow("MM/dd/yyyy", "12/25/2023")] + [DataRow("dd.MM.yyyy", "25.12.2023")] + public void GetDateTimeFormatTest(string format, string expectedPattern) + { + // Setup + var testDate = new DateTime(2023, 12, 25); + + // Act + var result = testDate.ToString(format, CultureInfo.CurrentCulture); + + // Assert + Assert.AreEqual(expectedPattern, result); + } + + [TestMethod] + public void GetCurrentTimeFormatTest() + { + // Setup + var testDateTime = new DateTime(2023, 12, 25, 14, 30, 45); + + // Act + var timeResult = testDateTime.ToString("T", CultureInfo.CurrentCulture); + var dateResult = testDateTime.ToString("d", CultureInfo.CurrentCulture); + + // Assert + Assert.AreEqual("2:30:45 PM", timeResult); + Assert.AreEqual("12/25/2023", dateResult); + } + + [DataTestMethod] + [DataRow("yyyy-MM-dd HH:mm:ss", "2023-12-25 14:30:45")] + [DataRow("dddd, MMMM dd, yyyy", "Monday, December 25, 2023")] + [DataRow("HH:mm:ss tt", "14:30:45 PM")] + public void ValidateCustomDateTimeFormats(string format, string expectedResult) + { + // Setup + var testDate = new DateTime(2023, 12, 25, 14, 30, 45); + + // Act + var result = testDate.ToString(format, CultureInfo.CurrentCulture); + + // Assert + Assert.AreEqual(expectedResult, result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCalculatorTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCalculatorTests.cs new file mode 100644 index 0000000000..8dbd4173fd --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCalculatorTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CmdPal.Ext.TimeDate.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests; + +[TestClass] +public class TimeDateCalculatorTests +{ + private CultureInfo originalCulture; + private CultureInfo originalUiCulture; + + [TestInitialize] + public void Setup() + { + // Set culture to 'en-us' + originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-us", false); + originalUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo("en-us", false); + } + + [TestCleanup] + public void Cleanup() + { + // Restore original culture + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + + [TestMethod] + public void CountAllResults() + { + // Setup + var settings = new SettingsManager(); + + // Act + var results = TimeDateCalculator.ExecuteSearch(settings, string.Empty); + + // Assert + Assert.IsTrue(results.Count > 0); + } + + [TestMethod] + public void ValidateEmptyQuery() + { + // Setup + var settings = new SettingsManager(); + + // Act + var results = TimeDateCalculator.ExecuteSearch(settings, string.Empty); + + // Assert + Assert.IsNotNull(results); + } + + [TestMethod] + public void ValidateNullQuery() + { + // Setup + var settings = new SettingsManager(); + + // Act + var results = TimeDateCalculator.ExecuteSearch(settings, null); + + // Assert + Assert.IsNotNull(results); + } + + [TestMethod] + public void ValidateTimeParsing() + { + // Setup + var settings = new SettingsManager(); + var query = "time::10:30:45"; + + // Act + var results = TimeDateCalculator.ExecuteSearch(settings, query); + + // Assert + Assert.IsNotNull(results); + Assert.IsTrue(results.Count >= 0); // May have 0 results due to invalid format, but shouldn't crash + } + + [TestMethod] + public void ValidateDateParsing() + { + // Setup + var settings = new SettingsManager(); + var query = "date::12/25/2023"; + + // Act + var results = TimeDateCalculator.ExecuteSearch(settings, query); + + // Assert + Assert.IsNotNull(results); + Assert.IsTrue(results.Count >= 0); // May have 0 results due to invalid format, but shouldn't crash + } + + [TestMethod] + public void ValidateCommonQueries() + { + // Setup + var settings = new SettingsManager(); + var queries = new[] { "time", "date", "now", "current" }; + + foreach (var query in queries) + { + // Act + var results = TimeDateCalculator.ExecuteSearch(settings, query); + + // Assert + Assert.IsNotNull(results, $"Results should not be null for query: {query}"); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs new file mode 100644 index 0000000000..2006154600 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.TimeDate.UnitTests/TimeDateCommandsProviderTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests +{ + [TestClass] + public class TimeDateCommandsProviderTests + { + private CultureInfo originalCulture; + private CultureInfo originalUiCulture; + + [TestInitialize] + public void Setup() + { + // Set culture to 'en-us' + originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-us", false); + originalUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo("en-us", false); + } + + [TestCleanup] + public void Cleanup() + { + // Restore original culture + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } + + [TestMethod] + public void TimeDateCommandsProviderInitializationTest() + { + // Act + var provider = new TimeDateCommandsProvider(); + + // Assert + Assert.IsNotNull(provider); + Assert.IsNotNull(provider.DisplayName); + Assert.AreEqual("com.microsoft.cmdpal.builtin.datetime", provider.Id); + Assert.IsNotNull(provider.Icon); + Assert.IsNotNull(provider.Settings); + } + + [TestMethod] + public void TopLevelCommandsTest() + { + // Setup + var provider = new TimeDateCommandsProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.AreEqual(1, commands.Length); + Assert.IsNotNull(commands[0]); + Assert.IsNotNull(commands[0].Title); + Assert.IsNotNull(commands[0].Icon); + } + + [TestMethod] + public void FallbackCommandsTest() + { + // Setup + var provider = new TimeDateCommandsProvider(); + + // Act + var fallbackCommands = provider.FallbackCommands(); + + // Assert + Assert.IsNotNull(fallbackCommands); + Assert.AreEqual(1, fallbackCommands.Length); + Assert.IsNotNull(fallbackCommands[0]); + } + + [TestMethod] + public void DisplayNameTest() + { + // Setup + var provider = new TimeDateCommandsProvider(); + + // Act + var displayName = provider.DisplayName; + + // Assert + Assert.IsFalse(string.IsNullOrEmpty(displayName)); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs new file mode 100644 index 0000000000..e00f198ab6 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.UnitTestBase; + +public class CommandPaletteUnitTestBase +{ + private bool MatchesFilter(string filter, IListItem item) => + FuzzyStringMatcher.ScoreFuzzy(filter, item.Title) > 0 || + FuzzyStringMatcher.ScoreFuzzy(filter, item.Subtitle) > 0; + + public IListItem[] Query(string query, IListItem[] candidates) + { + var listItems = candidates + .Where(item => MatchesFilter(query, item)) + .ToArray(); + + return listItems; + } + + public async Task UpdatePageAndWaitForItems(IDynamicListPage page, Action modification) + { + // Add an event handler for the ItemsChanged event, + // Then call the modification action, + // and wait for the event to be raised. + var tcs = new TaskCompletionSource<object>(); + + TypedEventHandler<object, IItemsChangedEventArgs> handleItemsChanged = (object s, IItemsChangedEventArgs e) => + { + tcs.TrySetResult(e); + }; + + page.ItemsChanged += handleItemsChanged; + modification(); + + await tcs.Task; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/Microsoft.CmdPal.Ext.UnitTestBase.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/Microsoft.CmdPal.Ext.UnitTestBase.csproj new file mode 100644 index 0000000000..9e12557b9a --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/Microsoft.CmdPal.Ext.UnitTestBase.csproj @@ -0,0 +1,19 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <RootNamespace>Microsoft.CmdPal.Ext.UnitTestsBase</RootNamespace> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj new file mode 100644 index 0000000000..601d3d147f --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/Microsoft.CmdPal.Ext.WebSearch.UnitTests.csproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Ext.WebSearch.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.WebSearch\Microsoft.CmdPal.Ext.WebSearch.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs new file mode 100644 index 0000000000..ee27aa737e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.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.Ext.WebSearch.Helpers.Browser; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +public class MockBrowserInfoService : IBrowserInfoService +{ + public BrowserInfo GetDefaultBrowser() => new() { Name = "mocked browser", Path = "C:\\mockery\\mock.exe" }; +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs new file mode 100644 index 0000000000..1e5f0533c7 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +public class MockSettingsInterface : ISettingsInterface +{ + private readonly List<HistoryItem> _historyItems; + + public event EventHandler HistoryChanged; + + public bool GlobalIfURI { get; set; } + + public int HistoryItemCount { get; set; } + + public string CustomSearchUri { get; } + + public IReadOnlyList<HistoryItem> HistoryItems => _historyItems; + + public MockSettingsInterface(int historyItemCount = 0, bool globalIfUri = true, List<HistoryItem> mockHistory = null) + { + _historyItems = mockHistory ?? new List<HistoryItem>(); + GlobalIfURI = globalIfUri; + HistoryItemCount = historyItemCount; + } + + public void AddHistoryItem(HistoryItem historyItem) + { + if (historyItem is null) + { + return; + } + + _historyItems.Add(historyItem); + + // Simulate the same logic as SettingsManager + if (HistoryItemCount > 0) + { + while (_historyItems.Count > HistoryItemCount) + { + _historyItems.RemoveAt(0); + } + } + + HistoryChanged?.Invoke(this, EventArgs.Empty); + } + + // Helper method for testing + public void ClearHistory() + { + _historyItems.Clear(); + HistoryChanged?.Invoke(this, EventArgs.Empty); + } + + // Helper method for testing + public int GetHistoryCount() + { + return _historyItems.Count; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs new file mode 100644 index 0000000000..63e35314cd --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CmdPal.Ext.WebSearch.Commands; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Pages; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +[TestClass] +public class QueryTests : CommandPaletteUnitTestBase +{ + [TestMethod] + [DataRow("microsoft")] + [DataRow("windows")] + public async Task SearchInWebSearchPage(string query) + { + // Setup + var settings = new MockSettingsInterface(); + var browserInfoService = new MockBrowserInfoService(); + + var page = new WebSearchListPage(settings, browserInfoService); + + // Act + page.UpdateSearchText(string.Empty, query); + await Task.Delay(1000); + + var listItem = page.GetItems(); + Assert.IsNotNull(listItem); + Assert.AreEqual(1, listItem.Length); + + var expectedItem = listItem.FirstOrDefault(); + + Assert.IsNotNull(expectedItem); + Assert.IsTrue(expectedItem.Subtitle.Contains("Search the web in"), $"Expected \"search the web in chrome/edge\" but got {expectedItem.Subtitle}"); + Assert.AreEqual(query, expectedItem.Title); + } + + [TestMethod] + public async Task HistoryReturnsExpectedItems() + { + // Setup + var mockHistoryItems = new List<HistoryItem> + { + new HistoryItem("test search", DateTime.Parse("2024-01-01 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search", DateTime.Parse("2024-01-02 13:00:00", CultureInfo.CurrentCulture)), + }; + + var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, historyItemCount: 5); + var browserInfoService = new MockBrowserInfoService(); + + var page = new WebSearchListPage(settings, browserInfoService); + + // Act + page.UpdateSearchText("abcdef", string.Empty); + await Task.Delay(1000); + + var listItem = page.GetItems(); + + // Assert + Assert.IsNotNull(listItem); + Assert.AreEqual(2, listItem.Length); + + foreach (var item in listItem) + { + Assert.IsNotNull(item); + Assert.IsNotEmpty(item.Title); + Assert.IsNotEmpty(item.Subtitle); + } + } + + [TestMethod] + public async Task HistoryExceedingLimitReturnsMaxItems() + { + // Setup + var mockHistoryItems = new List<HistoryItem> + { + new HistoryItem("test search", DateTime.Parse("2024-01-01 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search1", DateTime.Parse("2024-01-02 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search2", DateTime.Parse("2024-01-03 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search3", DateTime.Parse("2024-01-04 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search4", DateTime.Parse("2024-01-05 13:00:00", CultureInfo.CurrentCulture)), + }; + + var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, historyItemCount: 5); + var browserInfoService = new MockBrowserInfoService(); + + var page = new WebSearchListPage(settings, browserInfoService); + + mockHistoryItems.Add(new HistoryItem("another search5", DateTime.Parse("2024-01-06 13:00:00", CultureInfo.CurrentCulture))); + + // Act + page.UpdateSearchText("abcdef", string.Empty); + await Task.Delay(1000); + + var listItem = page.GetItems(); + + // Assert + Assert.IsNotNull(listItem); + + // Make sure only load five item. + Assert.AreEqual(5, listItem.Length); + } + + [TestMethod] + public async Task HistoryWhenSetToNoneReturnEmptyList() + { + // Setup + var mockHistoryItems = new List<HistoryItem> + { + new HistoryItem("test search", DateTime.Parse("2024-01-01 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search1", DateTime.Parse("2024-01-02 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search2", DateTime.Parse("2024-01-03 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search3", DateTime.Parse("2024-01-04 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search4", DateTime.Parse("2024-01-05 13:00:00", CultureInfo.CurrentCulture)), + new HistoryItem("another search5", DateTime.Parse("2024-01-06 13:00:00", CultureInfo.CurrentCulture)), + }; + + var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, historyItemCount: 0); + var browserInfoService = new MockBrowserInfoService(); + + var page = new WebSearchListPage(settings, browserInfoService); + + // Act + page.UpdateSearchText("abcdef", string.Empty); + await Task.Delay(1000); + + var listItem = page.GetItems(); + + // Assert + Assert.IsNotNull(listItem); + + // Make sure only load five item. + Assert.AreEqual(0, listItem.Length); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs new file mode 100644 index 0000000000..2ec5546daa --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.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; +using System.Threading.Tasks; + +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Pages; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +[TestClass] +public class SettingsManagerTests : CommandPaletteUnitTestBase +{ + [TestMethod] + public async Task HistoryChangedEventIsRaisedWhenItemIsAdded() + { + // Setup + var settings = new MockSettingsInterface(historyItemCount: 5); + var browserInfoService = new MockBrowserInfoService(); + + var page = new WebSearchListPage(settings, browserInfoService); + + var eventRaised = false; + + try + { + settings.HistoryChanged += Handler; + + // Act + settings.AddHistoryItem(new HistoryItem("test event", DateTime.UtcNow)); + await Task.Delay(50); + + // Assert + Assert.IsTrue(eventRaised, "Expected HistoryChanged to be raised when saving history."); + } + finally + { + settings.HistoryChanged -= Handler; + page.Dispose(); + } + + return; + + void Handler(object s, EventArgs e) => eventRaised = true; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs new file mode 100644 index 0000000000..ef8b56a1b8 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/WebSearchCommandProviderTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +[TestClass] +public class WebSearchCommandProviderTests +{ + [TestMethod] + public void ProviderHasCorrectId() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Assert + Assert.AreEqual("com.microsoft.cmdpal.builtin.websearch", provider.Id); + } + + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var provider = new WebSearchCommandsProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests.csproj new file mode 100644 index 0000000000..ec165aa5fc --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.Ext.WindowWalker.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.WindowWalker\Microsoft.CmdPal.Ext.WindowWalker.csproj" /> + <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Settings.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Settings.cs new file mode 100644 index 0000000000..cbbe365a1b --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WindowWalker.UnitTests/Settings.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.WindowWalker.Helpers; + +namespace Microsoft.CmdPal.Ext.WindowWalker.UnitTests; + +public class Settings : ISettingsInterface +{ + private readonly bool resultsFromVisibleDesktopOnly; + private readonly bool subtitleShowPid; + private readonly bool subtitleShowDesktopName; + private readonly bool confirmKillProcess; + private readonly bool killProcessTree; + private readonly bool openAfterKillAndClose; + private readonly bool hideKillProcessOnElevatedProcesses; + private readonly bool hideExplorerSettingInfo; + private readonly bool inMruOrder; + private readonly bool useWindowIcon; + + public Settings( + bool resultsFromVisibleDesktopOnly = false, + bool subtitleShowPid = false, + bool subtitleShowDesktopName = true, + bool confirmKillProcess = true, + bool killProcessTree = false, + bool openAfterKillAndClose = false, + bool hideKillProcessOnElevatedProcesses = false, + bool hideExplorerSettingInfo = true, + bool inMruOrder = true, + bool useWindowIcon = true) + { + this.resultsFromVisibleDesktopOnly = resultsFromVisibleDesktopOnly; + this.subtitleShowPid = subtitleShowPid; + this.subtitleShowDesktopName = subtitleShowDesktopName; + this.confirmKillProcess = confirmKillProcess; + this.killProcessTree = killProcessTree; + this.openAfterKillAndClose = openAfterKillAndClose; + this.hideKillProcessOnElevatedProcesses = hideKillProcessOnElevatedProcesses; + this.hideExplorerSettingInfo = hideExplorerSettingInfo; + this.inMruOrder = inMruOrder; + this.useWindowIcon = useWindowIcon; + } + + public bool ResultsFromVisibleDesktopOnly => resultsFromVisibleDesktopOnly; + + public bool SubtitleShowPid => subtitleShowPid; + + public bool SubtitleShowDesktopName => subtitleShowDesktopName; + + public bool ConfirmKillProcess => confirmKillProcess; + + public bool KillProcessTree => killProcessTree; + + public bool OpenAfterKillAndClose => openAfterKillAndClose; + + public bool HideKillProcessOnElevatedProcesses => hideKillProcessOnElevatedProcesses; + + public bool HideExplorerSettingInfo => hideExplorerSettingInfo; + + public bool InMruOrder => inMruOrder; + + public bool UseWindowIcon => useWindowIcon; +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs new file mode 100644 index 0000000000..5cb60ca03b --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/MainListPageResultFactoryTests.cs @@ -0,0 +1,158 @@ +// 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 Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.UI.ViewModels.Commands; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +public partial class MainListPageResultFactoryTests +{ + private sealed partial class MockListItem : IListItem + { + public string Title { get; set; } = string.Empty; + + public string Subtitle { get; set; } = string.Empty; + + public ICommand Command => new NoOpCommand(); + + public IDetails? Details => null; + + public IIconInfo? Icon => null; + + public string Section => throw new NotImplementedException(); + + public ITag[] Tags => throw new NotImplementedException(); + + public string TextToSuggest => throw new NotImplementedException(); + + public IContextItem[] MoreCommands => throw new NotImplementedException(); + +#pragma warning disable CS0067 // The event is never used + public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged; +#pragma warning restore CS0067 // The event is never used + + public override string ToString() => Title; + } + + private static RoScored<IListItem> S(string title, int score) + { + return new RoScored<IListItem>(score: score, item: new MockListItem { Title = title }); + } + + [TestMethod] + public void Merge_PrioritizesListsCorrectly() + { + var filtered = new List<RoScored<IListItem>> + { + S("F1", 100), + S("F2", 50), + }; + + var scoredFallback = new List<RoScored<IListItem>> + { + S("SF1", 100), + S("SF2", 60), + }; + + var apps = new List<RoScored<IListItem>> + { + S("A1", 100), + S("A2", 55), + }; + + // Fallbacks are not scored. + var fallbacks = new List<RoScored<IListItem>> + { + S("FB1", 0), + S("FB2", 0), + }; + + var result = MainListPageResultFactory.Create( + filtered, + scoredFallback, + apps, + fallbacks, + appResultLimit: 10); + + // Expected order: + // 100: F1, SF1, A1 + // 60: SF2 + // 55: A2 + // 50: F2 + // Then fallbacks in original order: FB1, FB2 + var titles = result.Select(r => r.Title).ToArray(); +#pragma warning disable CA1861 // Avoid constant arrays as arguments + CollectionAssert.AreEqual( + new[] { "F1", "SF1", "A1", "SF2", "A2", "F2", "Fallbacks", "FB1", "FB2" }, + titles); +#pragma warning restore CA1861 // Avoid constant arrays as arguments + } + + [TestMethod] + public void Merge_AppliesAppLimit() + { + var apps = new List<RoScored<IListItem>> + { + S("A1", 100), + S("A2", 90), + S("A3", 80), + }; + + var result = MainListPageResultFactory.Create( + null, + null, + apps, + null, + 2); + + Assert.AreEqual(2, result.Length); + Assert.AreEqual("A1", result[0].Title); + Assert.AreEqual("A2", result[1].Title); + } + + [TestMethod] + public void Merge_FiltersEmptyFallbacks() + { + var fallbacks = new List<RoScored<IListItem>> + { + S("FB1", 0), + S("FB3", 0), + }; + + var result = MainListPageResultFactory.Create( + null, + null, + null, + fallbacks, + appResultLimit: 10); + + Assert.AreEqual(3, result.Length); + Assert.AreEqual("Fallbacks", result[0].Title); + Assert.AreEqual("FB1", result[1].Title); + Assert.AreEqual("FB3", result[2].Title); + } + + [TestMethod] + public void Merge_HandlesNullLists() + { + var result = MainListPageResultFactory.Create( + null, + null, + null, + null, + appResultLimit: 10); + + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Length); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj new file mode 100644 index 0000000000..fa7a95b5fb --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CmdPal.UI.ViewModels.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + <PackageReference Include="WyHash" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\Microsoft.CmdPal.UI.ViewModels\Microsoft.CmdPal.UI.ViewModels.csproj" /> + <ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs new file mode 100644 index 0000000000..ec93373f74 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs @@ -0,0 +1,461 @@ +// 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 Microsoft.CmdPal.Core.Common.Text; +using Microsoft.CmdPal.Ext.UnitTestBase; +using Microsoft.CmdPal.UI.ViewModels.MainPage; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Foundation; +using WyHash; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +public partial class RecentCommandsTests : CommandPaletteUnitTestBase +{ + private static RecentCommandsManager CreateHistory(IList<string>? commandIds = null) + { + var history = new RecentCommandsManager(); + if (commandIds != null) + { + foreach (var item in commandIds) + { + history.AddHistoryItem(item); + } + } + + return history; + } + + private static RecentCommandsManager CreateBasicHistoryService() + { + var commonCommands = new List<string> + { + "com.microsoft.cmdpal.shell", + "com.microsoft.cmdpal.windowwalker", + "Visual Studio 2022 Preview_6533433915015224980", + "com.microsoft.cmdpal.reload", + "com.microsoft.cmdpal.shell", + }; + + return CreateHistory(commonCommands); + } + + [TestMethod] + public void ValidateHistoryFunctionality() + { + // Setup + var history = CreateHistory(); + + // Act + history.AddHistoryItem("com.microsoft.cmdpal.shell"); + + // Assert + Assert.IsTrue(history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell") > 0); + } + + [TestMethod] + public void ValidateHistoryWeighting() + { + // Setup + var history = CreateBasicHistoryService(); + + // Act + var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell"); + var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker"); + var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980"); + var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload"); + var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command"); + + // Assert + Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell should be weighted higher than Window Walker, more uses"); + Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio should be weighted higher than Window Walker, because recency"); + Assert.AreEqual(reloadWeight, vsWeight, "both reload and VS were used in the last three commands, same weight"); + Assert.IsTrue(shellWeight > vsWeight, "VS and run were both used in the last 3, but shell has 2 more frequency"); + Assert.AreEqual(0, nonExistentWeight, "Nonexistent command should have zero weight"); + } + + private sealed partial record ListItemMock( + string Title, + string? Subtitle = "", + string? GivenId = "", + string? ProviderId = "") : IListItem + { + public string Id => string.IsNullOrEmpty(GivenId) ? GenerateId() : GivenId; + + public IDetails Details => throw new System.NotImplementedException(); + + public string Section => throw new System.NotImplementedException(); + + public ITag[] Tags => throw new System.NotImplementedException(); + + public string TextToSuggest => throw new System.NotImplementedException(); + + public ICommand Command => new NoOpCommand() { Id = Id }; + + public IIconInfo Icon => throw new System.NotImplementedException(); + + public IContextItem[] MoreCommands => throw new System.NotImplementedException(); + +#pragma warning disable CS0067 + public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged; +#pragma warning restore CS0067 + + private string GenerateId() + { + // Use WyHash64 to generate stable ID hashes. + // manually seeding with 0, so that the hash is stable across launches + var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0); + return $"{ProviderId}{result}"; + } + } + + private static RecentCommandsManager CreateHistory(IList<ListItemMock> items) + { + var history = new RecentCommandsManager(); + foreach (var item in items) + { + history.AddHistoryItem(item.Id); + } + + return history; + } + + [TestMethod] + public void ValidateMocksWork() + { + // Setup + var items = new List<ListItemMock> + { + new("Command A", "Subtitle A", "idA", "providerA"), + new("Command B", "Subtitle B", GivenId: "idB"), + new("Command C", "Subtitle C", ProviderId: "providerC"), + new("Command A", "Subtitle A", "idA", "providerA"), // Duplicate to test incrementing uses + }; + + // Act + var history = CreateHistory(items); + + // Assert + foreach (var item in items) + { + var weight = history.GetCommandHistoryWeight(item.Id); + Assert.IsTrue(weight > 0, $"Item {item.Title} should have a weight greater than zero."); + } + + // Check that the duplicate item has a higher weight due to increased uses + var weightA = history.GetCommandHistoryWeight("idA"); + var weightB = history.GetCommandHistoryWeight("idB"); + var weightC = history.GetCommandHistoryWeight(items[2].Id); // providerC generated ID + Assert.IsTrue(weightA > weightB, "Item A should have a higher weight than Item B due to more uses."); + Assert.IsTrue(weightA > weightC, "Item A should have a higher weight than Item C due to more uses."); + Assert.AreEqual(weightC, weightB, "Item C and Item B were used in the last 3 commands"); + } + + [TestMethod] + public void ValidateHistoryBuckets() + { + // Setup + // (these will be checked in reverse order, so that A is the most recent) + var items = new List<ListItemMock> + { + new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0 + new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0 + new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0 + new("Command D", "Subtitle D", GivenId: "idD"), // #3 -> bucket 1 + new("Command E", "Subtitle E", GivenId: "idE"), // #4 -> bucket 1 + new("Command F", "Subtitle F", GivenId: "idF"), // #5 -> bucket 1 + new("Command G", "Subtitle G", GivenId: "idG"), // #6 -> bucket 1 + new("Command H", "Subtitle H", GivenId: "idH"), // #7 -> bucket 1 + new("Command I", "Subtitle I", GivenId: "idI"), // #8 -> bucket 1 + new("Command J", "Subtitle J", GivenId: "idJ"), // #9 -> bucket 1 + new("Command K", "Subtitle K", GivenId: "idK"), // #10 -> bucket 1 + new("Command L", "Subtitle L", GivenId: "idL"), // #11 -> bucket 2 + new("Command M", "Subtitle M", GivenId: "idM"), // #12 -> bucket 2 + new("Command N", "Subtitle N", GivenId: "idN"), // #13 -> bucket 2 + new("Command O", "Subtitle O", GivenId: "idO"), // #14 -> bucket 2 + }; + + for (var i = items.Count; i <= 50; i++) + { + items.Add(new ListItemMock($"Command #{i}", GivenId: $"id{i}")); + } + + // Act + var history = CreateHistory(items.Reverse<ListItemMock>().ToList()); + + // Assert + // First three items should be in the top bucket + var weightA = history.GetCommandHistoryWeight("idA"); + var weightB = history.GetCommandHistoryWeight("idB"); + var weightC = history.GetCommandHistoryWeight("idC"); + + Assert.AreEqual(weightA, weightB, "Items A and B were used in the last 3 commands"); + Assert.AreEqual(weightB, weightC, "Items B and C were used in the last 3 commands"); + + // Next eight items (3-10 inclusive) should be in the second bucket + var weightD = history.GetCommandHistoryWeight("idD"); + var weightE = history.GetCommandHistoryWeight("idE"); + var weightF = history.GetCommandHistoryWeight("idF"); + var weightG = history.GetCommandHistoryWeight("idG"); + var weightH = history.GetCommandHistoryWeight("idH"); + var weightI = history.GetCommandHistoryWeight("idI"); + var weightJ = history.GetCommandHistoryWeight("idJ"); + var weightK = history.GetCommandHistoryWeight("idK"); + + Assert.AreEqual(weightD, weightE, "Items D and E were used in the last 10 commands"); + Assert.AreEqual(weightE, weightF, "Items E and F were used in the last 10 commands"); + Assert.AreEqual(weightF, weightG, "Items F and G were used in the last 10 commands"); + Assert.AreEqual(weightG, weightH, "Items G and H were used in the last 10 commands"); + Assert.AreEqual(weightH, weightI, "Items H and I were used in the last 10 commands"); + Assert.AreEqual(weightI, weightJ, "Items I and J were used in the last 10 commands"); + Assert.AreEqual(weightJ, weightK, "Items J and K were used in the last 10 commands"); + + // Items up to the 15th should be in the third bucket + var weightL = history.GetCommandHistoryWeight("idL"); + var weightM = history.GetCommandHistoryWeight("idM"); + var weightN = history.GetCommandHistoryWeight("idN"); + var weightO = history.GetCommandHistoryWeight("idO"); + var weight15 = history.GetCommandHistoryWeight("id15"); + Assert.AreEqual(weightL, weightM, "Items L and M were used in the last 15 commands"); + Assert.AreEqual(weightM, weightN, "Items M and N were used in the last 15 commands"); + Assert.AreEqual(weightN, weightO, "Items N and O were used in the last 15 commands"); + Assert.AreEqual(weightO, weight15, "Items O and 15 were used in the last 15 commands"); + + // Items after that should be in the lowest buckets + var weight0 = history.GetCommandHistoryWeight(items[0].Id); + var weight3 = history.GetCommandHistoryWeight(items[3].Id); + var weight11 = history.GetCommandHistoryWeight(items[11].Id); + var weight16 = history.GetCommandHistoryWeight("id16"); + var weight20 = history.GetCommandHistoryWeight("id20"); + var weight30 = history.GetCommandHistoryWeight("id30"); + var weight40 = history.GetCommandHistoryWeight("id40"); + var weight49 = history.GetCommandHistoryWeight("id49"); + + Assert.IsTrue(weight0 > weight3); + Assert.IsTrue(weight3 > weight11); + Assert.IsTrue(weight11 > weight16); + + Assert.AreEqual(weight16, weight20); + Assert.AreEqual(weight20, weight30); + Assert.IsTrue(weight30 > weight40); + Assert.AreEqual(weight40, weight49); + + // The 50th item has fallen out of the list now + var weight50 = history.GetCommandHistoryWeight("id50"); + Assert.AreEqual(0, weight50, "Item 50 should have fallen out of the history list"); + } + + [TestMethod] + public void ValidateSimpleScoring() + { + // Setup + var items = new List<ListItemMock> + { + new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0 + new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0 + new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0 + }; + + var history = CreateHistory(items.Reverse<ListItemMock>().ToList()); + var fuzzyMatcher = CreateMatcher(); + var q = fuzzyMatcher.PrecomputeQuery("C"); + + var scoreA = MainListPage.ScoreTopLevelItem(q, items[0], history, fuzzyMatcher); + var scoreB = MainListPage.ScoreTopLevelItem(q, items[1], history, fuzzyMatcher); + var scoreC = MainListPage.ScoreTopLevelItem(q, items[2], history, fuzzyMatcher); + + // Assert + // All of these equally match the query, and they're all in the same bucket, + // so they should all have the same score. + Assert.AreEqual(scoreA, scoreB, "Items A and B should have the same score"); + Assert.AreEqual(scoreB, scoreC, "Items B and C should have the same score"); + } + + private static List<ListItemMock> CreateMockHistoryItems() + { + var items = new List<ListItemMock> + { + new("Visual Studio 2022"), // #0 -> bucket 0 + new("Visual Studio Code"), // #1 -> bucket 0 + new("Explore Mastodon", GivenId: "social.mastodon.explore"), // #2 -> bucket 0 + new("Run commands", Subtitle: "Executes commands (e.g. ping, cmd)", GivenId: "com.microsoft.cmdpal.run"), // #3 -> bucket 1 + new("Windows Settings"), // #4 -> bucket 1 + new("Command Prompt"), // #5 -> bucket 1 + new("Terminal Canary"), // #6 -> bucket 1 + }; + return items; + } + + private static RecentCommandsManager CreateMockHistoryService(List<ListItemMock>? items = null) + { + var history = CreateHistory((items ?? CreateMockHistoryItems()).Reverse<ListItemMock>().ToList()); + return history; + } + + private static IPrecomputedFuzzyMatcher CreateMatcher() + { + return new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions()); + } + + private sealed record ScoredItem(ListItemMock Item, int Score) + { + public string Title => Item.Title; + + public override string ToString() => $"[{Score}]{Title}"; + } + + private static IEnumerable<ScoredItem> TieScoresToMatches(List<ListItemMock> items, List<int> scores) + { + if (items.Count != scores.Count) + { + throw new ArgumentException("Items and scores must have the same number of elements"); + } + + for (var i = 0; i < items.Count; i++) + { + yield return new ScoredItem(items[i], scores[i]); + } + } + + private static IEnumerable<ScoredItem> GetMatches(IEnumerable<ScoredItem> scoredItems) + { + var matches = scoredItems + .Where(x => x.Score > 0) + .OrderByDescending(x => x.Score) + .ToList(); + + return matches; + } + + private static IEnumerable<ScoredItem> GetMatches(List<ListItemMock> items, List<int> scores) + { + return GetMatches(TieScoresToMatches(items, scores)); + } + + [TestMethod] + public void ValidateScoredWeightingSimple() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + var fuzzyMatcher = CreateMatcher(); + + var q = fuzzyMatcher.PrecomputeQuery("C"); + var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, emptyHistory, fuzzyMatcher)).ToList(); + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList(); + Assert.AreEqual(unweightedScores.Count, weightedScores.Count, "Both score lists should have the same number of items"); + for (var i = 0; i < unweightedScores.Count; i++) + { + var unweighted = unweightedScores[i]; + var weighted = weightedScores[i]; + var item = items[i]; + if (item.Title.Contains('C', System.StringComparison.CurrentCultureIgnoreCase)) + { + Assert.IsTrue(unweighted >= 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero"); + Assert.IsTrue(weighted > unweighted, $"Item {item.Title} should have a higher weighted ({weighted}) score than unweighted ({unweighted})"); + } + else + { + Assert.AreEqual(unweighted, 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero"); + Assert.AreEqual(unweighted, weighted); + } + } + + var unweightedMatches = GetMatches(items, unweightedScores).ToList(); + Assert.AreEqual(4, unweightedMatches.Count); + Assert.AreEqual("Command Prompt", unweightedMatches[0].Title, "Command Prompt should be the top match"); + Assert.AreEqual("Visual Studio Code", unweightedMatches[1].Title, "Visual Studio Code should be the second match"); + Assert.AreEqual("Terminal Canary", unweightedMatches[2].Title); + Assert.AreEqual("Run commands", unweightedMatches[3].Title); + + // Even after weighting for 1 use, Command Prompt should still be the top match. + var weightedMatches = GetMatches(items, weightedScores).ToList(); + Assert.AreEqual(4, weightedMatches.Count); + Assert.AreEqual("Command Prompt", weightedMatches[0].Title); + Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title); + Assert.AreEqual("Terminal Canary", weightedMatches[2].Title); + Assert.AreEqual("Run commands", weightedMatches[3].Title); + } + + [TestMethod] + public void ValidateTitlesAreMoreImportantThanHistory() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + var fuzzyMatcher = CreateMatcher(); + var q = fuzzyMatcher.PrecomputeQuery("te"); + + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList(); + var weightedMatches = GetMatches(items, weightedScores).ToList(); + + Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands"); + + // Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches + // the title better + Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match"); + Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal"); + Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle"); + } + + [TestMethod] + public void ValidateTitlesAreMoreImportantThanUsage() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + var fuzzyMatcher = CreateMatcher(); + var q = fuzzyMatcher.PrecomputeQuery("te"); + + // Add extra uses of VS Code to try and push it above Terminal + for (var i = 0; i < 10; i++) + { + history.AddHistoryItem(items[1].Id); + } + + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList(); + var weightedMatches = GetMatches(items, weightedScores).ToList(); + + Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands"); + + // Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches + // the title better + Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match"); + Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal"); + Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle"); + } + + [TestMethod] + public void ValidateUsageEventuallyHelps() + { + var items = CreateMockHistoryItems(); + var emptyHistory = CreateMockHistoryService(new()); + var history = CreateMockHistoryService(items); + var fuzzyMatcher = CreateMatcher(); + var q = fuzzyMatcher.PrecomputeQuery("C"); + + // We're gonna run this test and keep adding more uses of VS Code till + // it breaks past Command Prompt + var vsCodeId = items[1].Id; + for (var i = 0; i < 10; i++) + { + history.AddHistoryItem(vsCodeId); + + var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList(); + var weightedMatches = GetMatches(items, weightedScores).ToList(); + Assert.AreEqual(4, weightedMatches.Count); + + var expectedCmdIndex = i < 5 ? 0 : 1; + var expectedCodeIndex = i < 5 ? 1 : 0; + Assert.AreEqual("Command Prompt", weightedMatches[expectedCmdIndex].Title); + Assert.AreEqual("Visual Studio Code", weightedMatches[expectedCodeIndex].Title); + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs new file mode 100644 index 0000000000..172f07562a --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/BasicTests.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.UITests; + +[TestClass] +public class BasicTests : CommandPaletteTestBase +{ + public BasicTests() + { + } + + [TestMethod] + public void BasicFileSearchTest() + { + SetSearchBox("files"); + + var searchFileItem = this.Find<NavigationViewItem>("Search files"); + Assert.AreEqual(searchFileItem.Name, "Search files"); + searchFileItem.DoubleClick(); + + SetFilesExtensionSearchBox("AppData"); + + Assert.IsNotNull(this.Find<NavigationViewItem>("AppData")); + } + + [TestMethod] + public void BasicCalculatorTest() + { + SetSearchBox("calculator"); + + var searchFileItem = this.Find<NavigationViewItem>("Calculator"); + Assert.AreEqual(searchFileItem.Name, "Calculator"); + searchFileItem.DoubleClick(); + + SetCalculatorExtensionSearchBox("1+2"); + + Assert.IsNotNull(this.Find<NavigationViewItem>("3")); + } + + [TestMethod] + public void BasicTimeAndDateTest() + { + SetSearchBox("time and date"); + + var searchFileItem = this.Find<NavigationViewItem>("Time and date"); + Assert.AreEqual(searchFileItem.Name, "Time and date"); + searchFileItem.DoubleClick(); + + SetTimeAndDaterExtensionSearchBox("year"); + + Assert.IsNotNull(this.Find<NavigationViewItem>("2026")); + } + + [TestMethod] + public void BasicWindowsTerminalTest() + { + SetSearchBox("Windows Terminal"); + + var searchFileItem = this.Find<NavigationViewItem>("Open Windows Terminal profiles"); + Assert.AreEqual(searchFileItem.Name, "Open Windows Terminal profiles"); + searchFileItem.DoubleClick(); + + // SetSearchBox("PowerShell"); + // Assert.IsNotNull(this.Find<NavigationViewItem>("PowerShell")); + } + + [TestMethod] + public void BasicWindowsSettingsTest() + { + SetSearchBox("Windows settings"); + + var searchFileItem = this.Find<NavigationViewItem>("Windows settings"); + Assert.AreEqual(searchFileItem.Name, "Windows settings"); + searchFileItem.DoubleClick(); + + SetSearchBox("power"); + + Assert.IsNotNull(this.Find<NavigationViewItem>("Power and sleep")); + } + + [TestMethod] + public void BasicRegistryTest() + { + SetSearchBox("Registry"); + + var searchFileItem = this.Find<NavigationViewItem>("Registry"); + Assert.AreEqual(searchFileItem.Name, "Registry"); + searchFileItem.DoubleClick(); + + // Type the string will cause strange behavior.so comment it out for now. + // SetSearchBox(@"HKEY_LOCAL_MACHINE"); + // Assert.IsNotNull(this.Find<NavigationViewItem>(@"HKEY_LOCAL_MACHINE\SECURITY")); + } + + [TestMethod] + public void BasicWindowsServicesTest() + { + SetSearchBox("Windows Services"); + + var searchFileItem = this.Find<NavigationViewItem>("Windows Services"); + Assert.AreEqual(searchFileItem.Name, "Windows Services"); + searchFileItem.DoubleClick(); + + SetSearchBox("hyper-v"); + + Assert.IsNotNull(this.Find<NavigationViewItem>("Hyper-V Heartbeat Service")); + } + + [TestMethod] + public void BasicWindowsSystemCommandsTest() + { + SetSearchBox("Windows System Commands"); + + var searchFileItem = this.Find<NavigationViewItem>("Windows System Commands"); + Assert.AreEqual(searchFileItem.Name, "Windows System Commands"); + searchFileItem.DoubleClick(); + + SetSearchBox("Sleep"); + + Assert.IsNotNull(this.Find<NavigationViewItem>("Sleep")); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs new file mode 100644 index 0000000000..a839c3e999 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/CommandPaletteTestBase.cs @@ -0,0 +1,64 @@ +// 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.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.UITests; + +public class CommandPaletteTestBase : UITestBase +{ + public CommandPaletteTestBase() + : base(PowerToysModule.CommandPalette) + { + } + + protected void SetSearchBox(string text) => SetSearchBoxText(text); + + protected void SetFilesExtensionSearchBox(string text) => SetSearchBoxText(text); + + protected void SetCalculatorExtensionSearchBox(string text) => SetSearchBoxText(text); + + protected void SetTimeAndDaterExtensionSearchBox(string text) => SetSearchBoxText(text); + + private void SetSearchBoxText(string text) + { + Assert.AreEqual(this.Find<TextBox>(By.AccessibilityId("MainSearchBox")).SetText(text, true).Text, text); + } + + protected void OpenContextMenu() + { + var contextMenuButton = this.Find<Button>(By.AccessibilityId("MoreContextMenuButton")); + Assert.IsNotNull(contextMenuButton, "Context menu button not found."); + contextMenuButton.Click(); + } + + protected void FindDefaultAppDialogAndClickButton() + { + try + { + // win11 + var chooseDialog = FindByClassName("NamedContainerAutomationPeer", global: true); + + chooseDialog.Find<Button>("Just once").Click(); + } + catch + { + try + { + // win10 + var chooseDialog = FindByClassName("Shell_Flyout", global: true); + chooseDialog.Find<Button>("OK").Click(); + } + catch + { + } + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/IndexerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/IndexerTests.cs new file mode 100644 index 0000000000..c60ca6bf5f --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/IndexerTests.cs @@ -0,0 +1,233 @@ +// 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.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.UITests; + +[TestClass] +public class IndexerTests : CommandPaletteTestBase +{ + private const string TestFileContent = "This is Indexer UI test sample"; + private const string TestFileName = "indexer_test_item.txt"; + private const string TestFileBaseName = "indexer_test_item"; + private const string TestFolderName = "Downloads"; + + public IndexerTests() + : base() + { + // create a empty file in Downloads folder + // to ensure that the indexer has something to search for + var downloadsPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "\\Downloads"; + var emptyFilePath = System.IO.Path.Combine(downloadsPath, TestFileName); + if (!System.IO.File.Exists(emptyFilePath)) + { + using (var fileStream = System.IO.File.Create(emptyFilePath)) + { + var content = TestFileContent; + var contentBytes = Encoding.UTF8.GetBytes(content); + fileStream.Write(contentBytes, 0, contentBytes.Length); + } + } + } + + public void EnterIndexerExtension() + { + SetSearchBox("files"); + + var searchFileItem = this.Find<NavigationViewItem>("Search files"); + Assert.AreEqual(searchFileItem.Name, "Search files"); + searchFileItem.DoubleClick(); + } + + [TestMethod] + public void BasicIndexerSearchTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox("Downloads"); + Assert.IsNotNull(this.Find<NavigationViewItem>("Downloads")); + } + + [TestMethod] + public void IndexerOpenFileTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox(TestFileName); + + var searchItem = this.Find<NavigationViewItem>(TestFileName); + + Assert.IsNotNull(searchItem); + + searchItem.Click(); + + var openButton = this.Find<Button>(By.AccessibilityId("PrimaryCommandButton")); + Assert.IsNotNull(openButton); + + openButton.Click(); + + FindDefaultAppDialogAndClickButton(); + + var notepadWindow = FindNotepadWindow(TestFileBaseName, global: true); + + Assert.IsNotNull(notepadWindow); + } + + [TestMethod] + public void IndexerDoubleClickOpenFileTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox(TestFileName); + + var searchItem = this.Find<NavigationViewItem>(TestFileName); + + Assert.IsNotNull(searchItem); + + searchItem.DoubleClick(); + + FindDefaultAppDialogAndClickButton(); + + var notepadWindow = FindNotepadWindow(TestFileBaseName, global: true); + + Assert.IsNotNull(notepadWindow); + } + + [TestMethod] + public void IndexerOpenFolderTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox(TestFolderName); + + var searchItem = this.Find<NavigationViewItem>(TestFolderName); + Assert.IsNotNull(searchItem); + searchItem.Click(); + + var openButton = this.Find<Button>("Open"); + Assert.IsNotNull(openButton); + + openButton.Click(); + var fileExplorer = FindExplorerWindow(TestFolderName, global: true); + + Assert.IsNotNull(fileExplorer); + } + + [TestMethod] + public void IndexerDoubleClickOpenFolderTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox(TestFolderName); + + var searchItem = this.Find<NavigationViewItem>(TestFolderName); + Assert.IsNotNull(searchItem); + searchItem.DoubleClick(); + + var fileExplorer = FindExplorerWindow(TestFolderName, global: true); + + Assert.IsNotNull(fileExplorer); + } + + [TestMethod] + public void IndexerBrowseFolderTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox(TestFolderName); + + var searchItem = this.Find<NavigationViewItem>(TestFolderName); + Assert.IsNotNull(searchItem); + searchItem.Click(); + + var openButton = this.Find<Button>(By.AccessibilityId("SecondaryCommandButton")); + Assert.IsNotNull(openButton); + + openButton.Click(); + + var testItem = this.Find<NavigationViewItem>(TestFileName); + Assert.IsNotNull(testItem); + } + + [STATestMethod] + [TestMethod] + public void IndexerCopyPathTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox(TestFileName); + + var searchItem = this.Find<NavigationViewItem>(TestFileName); + Assert.IsNotNull(searchItem); + searchItem.Click(); + + OpenContextMenu(); + var copyPathButton = this.Find<NavigationViewItem>("Copy path"); + Assert.IsNotNull(copyPathButton); + copyPathButton.Click(); + + var clipboardContent = System.Windows.Forms.Clipboard.GetText(); + Assert.IsTrue(clipboardContent.Contains(TestFileName), $"Clipboard content does not contain the expected file name. clipboard: {clipboardContent}"); + } + + [TestMethod] + public void IndexerShowInFolderTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox(TestFileName); + + var searchItem = this.Find<NavigationViewItem>(TestFileName); + Assert.IsNotNull(searchItem); + searchItem.Click(); + + OpenContextMenu(); + var showInFolderButton = this.Find<NavigationViewItem>("Show in folder"); + Assert.IsNotNull(showInFolderButton); + showInFolderButton.Click(); + + var fileExplorer = FindExplorerWindow(TestFolderName, global: true, timeoutMS: 20000); + + Assert.IsNotNull(fileExplorer); + } + + [TestMethod] + public void IndexerOpenPathInConsoleTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox(TestFileName); + + var searchItem = this.Find<NavigationViewItem>(TestFileName); + Assert.IsNotNull(searchItem); + searchItem.Click(); + + OpenContextMenu(); + var copyPathButton = this.Find<NavigationViewItem>("Open path in console"); + Assert.IsNotNull(copyPathButton); + copyPathButton.Click(); + + var textItem = FindByPartialName("C:\\Windows\\system32\\cmd.exe", global: true); + Assert.IsNotNull(textItem, "The console did not open with the expected path."); + } + + [TestMethod] + public void IndexerOpenPropertiesTest() + { + EnterIndexerExtension(); + SetFilesExtensionSearchBox(TestFileName); + + var searchItem = this.Find<NavigationViewItem>(TestFileName); + Assert.IsNotNull(searchItem); + searchItem.Click(); + + OpenContextMenu(); + var copyPathButton = this.Find<NavigationViewItem>("Properties"); + Assert.IsNotNull(copyPathButton); + copyPathButton.Click(); + + var propertiesWindow = FindByClassNameAndNamePattern<Window>("#32770", "Properties", global: true); + Assert.IsNotNull(propertiesWindow, "The properties window did not open for the selected file."); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/Microsoft.CmdPal.UITests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/Microsoft.CmdPal.UITests.csproj new file mode 100644 index 0000000000..ef3206262b --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UITests/Microsoft.CmdPal.UITests.csproj @@ -0,0 +1,26 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <RootNamespace>Microsoft.CmdPal.UITests</RootNamespace> + <AssemblyName>Microsoft.CmdPal.UITests</AssemblyName> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + <!-- This is a UI test, so don't run as part of MSBuild --> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\Microsoft.CmdPal.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> + <ProjectReference Include="..\..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherComparisonTests.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherComparisonTests.cs new file mode 100644 index 0000000000..99a6af73af --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherComparisonTests.cs @@ -0,0 +1,239 @@ +// 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 Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.Legacy; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests; + +[TestClass] +public class FuzzyMatcherComparisonTests +{ + public static IEnumerable<object[]> TestData => + [ + ["a", "a"], + ["a", "A"], + ["A", "a"], + ["abc", "abc"], + ["abc", "axbycz"], + ["abc", "abxcyz"], + ["sln", "solution.sln"], + ["vs", "visualstudio"], + ["test", "Test"], + ["pt", "PowerToys"], + ["p/t", "power\\toys"], + ["p\\t", "power/toys"], + ["c/w", "c:\\windows"], + ["foo", "bar"], + ["verylongstringthatdoesnotmatch", "short"], + [string.Empty, "anything"], + ["something", string.Empty], + ["git", "git"], + ["em", "Emmy"], + ["my", "Emmy"], + ["word", "word"], + ["wd", "word"], + ["w d", "word"], + ["a", "ba"], + ["a", "ab"], + ["a", "bab"], + ["z", "abcdefg"], + ["CC", "CamelCase"], + ["cc", "camelCase"], + ["cC", "camelCase"], + ["some", "awesome"], + ["some", "somewhere"], + ["1", "1"], + ["1", "2"], + [".", "."], + ["f.t", "file.txt"], + ["excel", "Excel"], + ["Excel", "excel"], + ["PowerPoint", "Power Point"], + ["power point", "PowerPoint"], + ["visual studio code", "Visual Studio Code"], + ["vsc", "Visual Studio Code"], + ["code", "Visual Studio Code"], + ["vs code", "Visual Studio Code"], + ["word", "Microsoft Word"], + ["ms word", "Microsoft Word"], + ["browser", "Internet Explorer"], + ["chrome", "Google Chrome"], + ["edge", "Microsoft Edge"], + ["term", "Windows Terminal"], + ["cmd", "Command Prompt"], + ["calc", "Calculator"], + ["snipping", "Snipping Tool"], + ["note", "Notepad"], + ["file expl", "File Explorer"], + ["settings", "Settings"], + ["p t", "PowerToys"], + ["p t", "PowerToys"], + [" v ", " Visual Studio "], + [" a b ", " a b c d "], + [string.Empty, string.Empty], + [" ", " "], + [" ", " "], + [" ", "abc"], + ["abc", " "], + [" ", " "], + [" ", " a b "], + ["sh", "ShangHai"], + ["bj", "BeiJing"], + ["bj", "北京"], + ["sh", "上海"], + ["nh", "你好"], + ["bj", "Beijing"], + ["hello", "你好"], + ["nihao", "你好"], + ["rmb", "人民币"], + ["zwr", "中文"], + ["zw", "中文"], + ["fbr", "foobar"], + ["w11", "windows 11"], + ["pwr", "powershell"], + ["vm", "void main"], + ["ps", "PowerShell"], + ["az", "Azure"], + ["od", "onedrive"], + ["gc", "google chrome"], + ["ff", "firefox"], + ["fs", "file_system"], + ["pt", "power-toys"], + ["jt", "json.test"], + ["ps", "power shell"], + ["ps", "power'shell"], + ["ps", "power\"shell"], + ["hw", "hello:world"], + ["abc", "a_b_c"], + ["abc", "a-b-c"], + ["abc", "a.b.c"], + ["abc", "a b c"], + ["abc", "a'b'c"], + ["abc", "a\"b\"c"], + ["abc", "a:b:c"], + ["_a", "_a"], + ["a_", "a_"], + ["-a", "-a"], + ["a-", "a-"], + ["🐿️", "🐿️"], // Squirrel emoji + ["\U0001F44D", "\U0001F44D\U0001F3FB"], // Base thumbs-up vs thumbs-up with LIGHT skin tone modifier + ["\U0001F44D\U0001F3FB", "\U0001F44D\U0001F3FB"], // Thumbs-up with LIGHT skin tone vs itself (exact same sequence) + ["\U0001F44D\U0001F3FB", "\U0001F44D\U0001F3FF"], // Thumbs-up with LIGHT skin tone vs thumbs-up with DARK skin tone + ]; + + [TestMethod] + [DynamicData(nameof(TestData))] + public void CompareScores(string needle, string haystack) + { + var legacyScore = LegacyFuzzyStringMatcher.ScoreFuzzy(needle, haystack); + var newScore = FuzzyStringMatcher.ScoreFuzzy(needle, haystack); + + Assert.AreEqual(legacyScore, newScore, $"Score mismatch for needle='{needle}', haystack='{haystack}'"); + } + + [TestMethod] + [DynamicData(nameof(TestData))] + public void ComparePositions(string needle, string haystack) + { + var (legacyScore, legacyPos) = LegacyFuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true); + var (newScore, newPos) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true); + + Assert.AreEqual(legacyScore, newScore, $"Score mismatch (with pos) for needle='{needle}', haystack='{haystack}'"); + + // Ensure lists are not null + legacyPos ??= []; + newPos ??= []; + + // Compare list contents + var legacyPosStr = string.Join(',', legacyPos); + var newPosStr = string.Join(',', newPos); + + Assert.AreEqual(legacyPos.Count, newPos.Count, $"Position count mismatch: Legacy=[{legacyPosStr}], New=[{newPosStr}]"); + + for (var i = 0; i < legacyPos.Count; i++) + { + Assert.AreEqual(legacyPos[i], newPos[i], $"Position mismatch at index {i}: Legacy=[{legacyPosStr}], New=[{newPosStr}]"); + } + } + + [TestMethod] + [DynamicData(nameof(TestData))] + public void CompareScores_ContiguousOnly(string needle, string haystack) + { + var legacyScore = LegacyFuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: false); + var newScore = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: false); + + Assert.AreEqual(legacyScore, newScore, $"Score mismatch (contiguous only) for needle='{needle}', haystack='{haystack}'"); + } + + [TestMethod] + [DynamicData(nameof(TestData))] + public void CompareScores_PinyinEnabled(string needle, string haystack) + { + var originalNew = FuzzyStringMatcher.ChinesePinYinSupport; + var originalLegacy = LegacyFuzzyStringMatcher.ChinesePinYinSupport; + try + { + FuzzyStringMatcher.ChinesePinYinSupport = true; + LegacyFuzzyStringMatcher.ChinesePinYinSupport = true; + + var legacyScore = LegacyFuzzyStringMatcher.ScoreFuzzy(needle, haystack); + var newScore = FuzzyStringMatcher.ScoreFuzzy(needle, haystack); + + Assert.AreEqual(legacyScore, newScore, $"Score mismatch (Pinyin enabled) for needle='{needle}', haystack='{haystack}'"); + } + finally + { + FuzzyStringMatcher.ChinesePinYinSupport = originalNew; + LegacyFuzzyStringMatcher.ChinesePinYinSupport = originalLegacy; + } + } + + [TestMethod] + [DynamicData(nameof(TestData))] + public void ComparePositions_PinyinEnabled(string needle, string haystack) + { + var originalNew = FuzzyStringMatcher.ChinesePinYinSupport; + var originalLegacy = LegacyFuzzyStringMatcher.ChinesePinYinSupport; + try + { + FuzzyStringMatcher.ChinesePinYinSupport = true; + LegacyFuzzyStringMatcher.ChinesePinYinSupport = true; + + var (legacyScore, legacyPos) = LegacyFuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true); + var (newScore, newPos) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true); + + Assert.AreEqual(legacyScore, newScore, $"Score mismatch (with pos, Pinyin enabled) for needle='{needle}', haystack='{haystack}'"); + + // Ensure lists are not null + legacyPos ??= []; + newPos ??= []; + + // If newPos is empty but newScore > 0, it means it's a secondary match (like Pinyin) + // which we don't return positions for in the new matcher. + if (newScore > 0 && newPos.Count == 0 && legacyPos.Count > 0) + { + return; + } + + // Compare list contents + var legacyPosStr = string.Join(',', legacyPos); + var newPosStr = string.Join(',', newPos); + + Assert.AreEqual(legacyPos.Count, newPos.Count, $"Position count mismatch: Legacy=[{legacyPosStr}], New=[{newPosStr}]"); + + for (var i = 0; i < legacyPos.Count; i++) + { + Assert.AreEqual(legacyPos[i], newPos[i], $"Position mismatch at index {i}: Legacy=[{legacyPosStr}], New=[{newPosStr}]"); + } + } + finally + { + FuzzyStringMatcher.ChinesePinYinSupport = originalNew; + LegacyFuzzyStringMatcher.ChinesePinYinSupport = originalLegacy; + } + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherComplexEmojiTests.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherComplexEmojiTests.cs new file mode 100644 index 0000000000..f418402aed --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherComplexEmojiTests.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 Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests; + +[TestClass] +public sealed class FuzzyMatcherComplexEmojiTests +{ + [TestMethod] + [Ignore("For now this is not supported")] + public void Mismatch_DifferentSkinTone_PartialMatch() + { + // "👍🏻" (Light) vs "👍🏿" (Dark) + // They share the base "👍". + const string needle = "👍🏻"; + const string haystack = "👍🏿"; + + var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true); + + // Should have a positive score because of the base emoji match + Assert.IsTrue(result.Score > 0, "Expected partial match based on base emoji"); + + // Should match the base emoji (2 chars) + Assert.AreEqual(2, result.Positions.Count, "Expected match on base emoji only"); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherDiacriticsTests.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherDiacriticsTests.cs new file mode 100644 index 0000000000..d4b6b8614f --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherDiacriticsTests.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.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests; + +[TestClass] +public class FuzzyMatcherDiacriticsTests +{ + [TestMethod] + public void ScoreFuzzy_WithDiacriticsRemoval_MatchesWithDiacritics() + { + // "eco" should match "école" when diacritics are removed (é -> E) + var score = FuzzyStringMatcher.ScoreFuzzy("eco", "école", allowNonContiguousMatches: true, removeDiacritics: true); + Assert.IsTrue(score > 0, "Should match 'école' with 'eco' when diacritics are removed"); + + // "uber" should match "über" + score = FuzzyStringMatcher.ScoreFuzzy("uber", "über", allowNonContiguousMatches: true, removeDiacritics: true); + Assert.IsTrue(score > 0, "Should match 'über' with 'uber' when diacritics are removed"); + } + + [TestMethod] + public void ScoreFuzzy_WithoutDiacriticsRemoval_DoesNotMatchWhenCharactersDiffer() + { + // "eco" should NOT match "école" if 'é' is treated as distinct from 'e' and order is strict + // 'é' (index 0) != 'e'. 'e' (index 4) is after 'c' (index 1) and 'o' (index 2). + // Since needle is "e-c-o", to match "école": + // 'e' matches 'e' at 4. + // 'c' must show up after. No. + // So no match. + var score = FuzzyStringMatcher.ScoreFuzzy("eco", "école", allowNonContiguousMatches: true, removeDiacritics: false); + Assert.AreEqual(0, score, "Should not match 'école' with 'eco' when diacritics are NOT removed"); + + // "uber" vs "über" + // u != ü. + // b (index 1) match b (index 2). e (2) match e (3). r (3) match r (4). + // but 'u' has no match. + score = FuzzyStringMatcher.ScoreFuzzy("uber", "über", allowNonContiguousMatches: true, removeDiacritics: false); + Assert.AreEqual(0, score, "Should not match 'über' with 'uber' when diacritics are NOT removed"); + } + + [TestMethod] + public void ScoreFuzzy_DefaultRemovesDiacritics() + { + // Now default is true, so "eco" vs "école" should match + var score = FuzzyStringMatcher.ScoreFuzzy("eco", "école"); + Assert.IsTrue(score > 0, "Default should remove diacritics and match 'école'"); + } + + [DataTestMethod] + [DataRow("a", "à", true)] + [DataRow("e", "é", true)] + [DataRow("i", "ï", true)] + [DataRow("o", "ô", true)] + [DataRow("u", "ü", true)] + [DataRow("c", "ç", true)] + [DataRow("n", "ñ", true)] + [DataRow("s", "ß", false)] // ß doesn't strip to s via simple invalid-uppercasing + public void VerifySpecificCharacters(string needle, string haystack, bool expectingMatch) + { + var score = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: true, removeDiacritics: true); + if (expectingMatch) + { + Assert.IsTrue(score > 0, $"Expected match for '{needle}' in '{haystack}' with diacritics removal"); + } + else + { + Assert.AreEqual(0, score, $"Expected NO match for '{needle}' in '{haystack}' even with diacritics removal"); + } + } + + [TestMethod] + public void VerifyBothPathsWorkSameForASCII() + { + var needle = "test"; + var haystack = "TestString"; + + var score1 = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: true, removeDiacritics: true); + var score2 = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: true, removeDiacritics: false); + + Assert.AreEqual(score1, score2, "Scores should be identical for ASCII strings regardless of diacritics setting"); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherEmojiTests.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherEmojiTests.cs new file mode 100644 index 0000000000..623325f3fc --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherEmojiTests.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 Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests; + +[TestClass] +public sealed class FuzzyMatcherEmojiTests +{ + [TestMethod] + public void ExactMatch_SimpleEmoji_ReturnsScore() + { + const string needle = "🚀"; + const string haystack = "Launch 🚀 sequence"; + + var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true); + + Assert.IsTrue(result.Score > 0, "Expected match for simple emoji"); + + // 🚀 is 2 chars (surrogates) + Assert.AreEqual(2, result.Positions.Count, "Expected 2 matched characters positions for the emoji"); + } + + [TestMethod] + public void ExactMatch_SkinTone_ReturnsScore() + { + const string needle = "👍🏽"; // Medium skin tone + const string haystack = "Thumbs up 👍🏽 here"; + + var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true); + + Assert.IsTrue(result.Score > 0, "Expected match for emoji with skin tone"); + + // 👍🏽 is 4 chars: U+1F44D (2 chars) + U+1F3FD (2 chars) + Assert.AreEqual(4, result.Positions.Count, "Expected 4 matched characters positions for the emoji with modifier"); + } + + [TestMethod] + public void ZWJSequence_Family_Match() + { + const string needle = "👨‍👩‍👧‍👦"; // Family: Man, Woman, Girl, Boy + const string haystack = "Emoji 👨‍👩‍👧‍👦 Test"; + + var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true); + + Assert.IsTrue(result.Score > 0, "Expected match for ZWJ sequence"); + + // This emoji is 11 code points? No. + // Man (2) + ZWJ (1) + Woman (2) + ZWJ (1) + Girl (2) + ZWJ (1) + Boy (2) = 11 chars? + // Let's just check score > 0. + Assert.IsTrue(result.Positions.Count > 0); + } + + [TestMethod] + public void Flags_Match() + { + const string needle = "🇺🇸"; // US Flag (Regional Indicator U + Regional Indicator S) + const string haystack = "USA 🇺🇸"; + + var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true); + + Assert.IsTrue(result.Score > 0, "Expected match for flag emoji"); + + // 2 code points, each is surrogate pair? + // U+1F1FA (REGIONAL INDICATOR SYMBOL LETTER U) -> 2 chars + // U+1F1F8 (REGIONAL INDICATOR SYMBOL LETTER S) -> 2 chars + // Total 4 chars. + Assert.AreEqual(4, result.Positions.Count); + } + + [TestMethod] + public void Emoji_MixedWithText_Search() + { + const string needle = "t🌮o"; // "t" + taco + "o" + const string haystack = "taco 🌮 on tuesday"; + + var result = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches: true); + + Assert.IsTrue(result.Score > 0); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherNormalizationTests.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherNormalizationTests.cs new file mode 100644 index 0000000000..ccc5174f00 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherNormalizationTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests; + +[TestClass] +public sealed class FuzzyMatcherNormalizationTests +{ + [TestMethod] + public void Normalization_ShouldBeLengthPreserving_GermanEszett() + { + // "Straße" (6 chars) + // Standard "SS" expansion would change length to 7. + // Our normalizer must preserve length. + var input = "Straße"; + var expectedLength = input.Length; + + // Case 1: Remove Diacritics = true + var normalized = Fold(input, removeDiacritics: true); + Assert.AreEqual(expectedLength, normalized.Length, "Normalization (removeDiacritics=true) must be length preserving for 'Straße'"); + + // Verify expected mapping: ß -> ß (length 1) + Assert.AreEqual("STRAßE", normalized); + + // Case 2: Remove Diacritics = false + var normalizedKeep = Fold(input, removeDiacritics: false); + Assert.AreEqual(expectedLength, normalizedKeep.Length, "Normalization (removeDiacritics=false) must be length preserving for 'Straße'"); + + // ß maps to ß in invariant culture (length 1) + Assert.AreEqual("STRAßE", normalizedKeep); + } + + [TestMethod] + public void Normalization_ShouldBeLengthPreserving_CommonDiacritics() + { + var input = "Crème Brûlée"; + var expected = "CREME BRULEE"; + + var normalized = Fold(input, removeDiacritics: true); + + Assert.AreEqual(input.Length, normalized.Length); + Assert.AreEqual(expected, normalized); + } + + [TestMethod] + public void Normalization_ShouldBeLengthPreserving_MixedComposed() + { + // "Ångström" -> A + ring, o + umlaut /* #no-spell-check-line */ + var input = "Ångström"; /* #no-spell-check-line */ + var expected = "ANGSTROM"; + + var normalized = Fold(input, removeDiacritics: true); + + Assert.AreEqual(input.Length, normalized.Length); + Assert.AreEqual(expected, normalized); + } + + [TestMethod] + public void Normalization_ShouldNormalizeSlashes() + { + var input = @"Folder\File.txt"; + var expected = "FOLDER/FILE.TXT"; + + var normalized = Fold(input, removeDiacritics: true); + + Assert.AreEqual(input.Length, normalized.Length); + Assert.AreEqual(expected, normalized); + } + + private string Fold(string input, bool removeDiacritics) + { + return FuzzyStringMatcher.Folding.FoldForComparison(input, removeDiacritics); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherPinyinLogicTests.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherPinyinLogicTests.cs new file mode 100644 index 0000000000..8898fe5035 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherPinyinLogicTests.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests; + +[TestClass] +public class FuzzyMatcherPinyinLogicTests +{ + [TestInitialize] + public void Setup() + { + FuzzyStringMatcher.ChinesePinYinSupport = true; + FuzzyStringMatcher.ClearCache(); + } + + [TestCleanup] + public void Cleanup() + { + FuzzyStringMatcher.ChinesePinYinSupport = false; // Reset to default state + FuzzyStringMatcher.ClearCache(); + } + + [DataTestMethod] + [DataRow("bj", "北京")] + [DataRow("sh", "上海")] + [DataRow("nihao", "你好")] + [DataRow("北京", "北京")] + [DataRow("北京", "Beijing")] + [DataRow("北", "北京")] + [DataRow("你好", "nihao")] + public void PinyinMatch_DataDriven(string needle, string haystack) + { + Assert.IsTrue(FuzzyStringMatcher.ScoreFuzzy(needle, haystack) > 0, $"Expected match for '{needle}' in '{haystack}'"); + } + + [TestMethod] + public void PinyinPositions_ShouldBeEmpty() + { + var (score, positions) = FuzzyStringMatcher.ScoreFuzzyWithPositions("bj", "北京", true); + Assert.IsTrue(score > 0); + Assert.AreEqual(0, positions.Count); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherUnicodeGarbageTests.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherUnicodeGarbageTests.cs new file mode 100644 index 0000000000..4532f19b71 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherUnicodeGarbageTests.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests; + +[TestClass] +public sealed class FuzzyMatcherUnicodeGarbageTests +{ + [TestMethod] + public void UnpairedHighSurrogateInNeedle_RemoveDiacritics_ShouldNotThrow() + { + const string needle = "\uD83D"; // high surrogate (unpaired) + const string haystack = "abc"; + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + } + + [TestMethod] + public void UnpairedLowSurrogateInNeedle_RemoveDiacritics_ShouldNotThrow() + { + const string needle = "\uDC00"; // low surrogate (unpaired) + const string haystack = "abc"; + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + } + + [TestMethod] + public void UnpairedHighSurrogateInHaystack_RemoveDiacritics_ShouldNotThrow() + { + const string needle = "a"; + const string haystack = "a\uD83D" + "bc"; // inject unpaired high surrogate + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + } + + [TestMethod] + public void UnpairedLowSurrogateInHaystack_RemoveDiacritics_ShouldNotThrow() + { + const string needle = "a"; + const string haystack = "a\uDC00" + "bc"; // inject unpaired low surrogate + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + } + + [TestMethod] + public void MixedSurrogatesAndMarks_RemoveDiacritics_ShouldNotThrow() + { + // "Garbage smoothie": unpaired surrogate + combining mark + emoji surrogate pair + const string needle = "a\uD83D\u0301"; // 'a' + unpaired high surrogate + combining acute + const string haystack = "a\u0301 \U0001F600"; // 'a' + combining acute + space + 😀 (valid pair) + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + } + + [TestMethod] + public void ValidEmojiSurrogatePair_RemoveDiacritics_ShouldNotThrow_AndCanMatch() + { + // 😀 U+1F600 encoded as surrogate pair in UTF-16 + const string needle = "\U0001F600"; + const string haystack = "x \U0001F600 y"; + + var result = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + + // Keep assertions minimal: just ensure it doesn't act like "no match". + // If your API returns score=0 for no match, this is stable. + Assert.IsTrue(result.Score > 0, "Expected emoji to produce a match score > 0."); + Assert.IsTrue(result.Positions.Count > 0, "Expected at least one matched position."); + } + + [TestMethod] + public void DiacriticStripping_StillWorks_OnBMPNonSurrogate() + { + // This is a regression guard: we fixed surrogates; don't break diacritic stripping. + // "é" should fold like "e" when removeDiacritics=true. + const string needle = "cafe"; + const string haystack = "CAFÉ"; + + var withDiacriticsRemoved = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + + var withoutDiacriticsRemoved = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: false); + + Assert.IsTrue(withDiacriticsRemoved.Score >= withoutDiacriticsRemoved.Score, "Removing diacritics should not make matching worse for 'CAFÉ' vs 'cafe'."); + Assert.IsTrue(withDiacriticsRemoved.Score > 0, "Expected a match when diacritics are removed."); + } + + [TestMethod] + public void RandomUtf16Garbage_RemoveDiacritics_ShouldNotThrow() + { + // Deterministic pseudo-random "UTF-16 garbage", including surrogates. + // This is a quick fuzz-lite test that’s stable across runs. + var s1 = MakeDeterministicGarbage(seed: 1234, length: 512); + var s2 = MakeDeterministicGarbage(seed: 5678, length: 1024); + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + s1, + s2, + allowNonContiguousMatches: true, + removeDiacritics: true); + } + + [TestMethod] + public void RandomUtf16Garbage_NoDiacritics_ShouldNotThrow() + { + var s1 = MakeDeterministicGarbage(seed: 42, length: 512); + var s2 = MakeDeterministicGarbage(seed: 43, length: 1024); + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + s1, + s2, + allowNonContiguousMatches: true, + removeDiacritics: false); + } + + [TestMethod] + public void HighSurrogateAtEndOfHaystack_RemoveDiacritics_ShouldNotThrow() + { + const string needle = "a"; + const string haystack = "abc\uD83D"; // Ends with high surrogate + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + } + + [TestMethod] + public void ComplexEmojiSequence_RemoveDiacritics_ShouldNotThrow() + { + // Family: Man, Woman, Girl, Boy + // U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466 + const string needle = "\U0001F468"; + const string haystack = "Info: \U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466 family"; + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + } + + [TestMethod] + public void NullOrEmptyInputs_ShouldNotThrow() + { + // Empty needle + var result1 = FuzzyStringMatcher.ScoreFuzzyWithPositions(string.Empty, "abc", true, true); + Assert.AreEqual(0, result1.Score); + + // Empty haystack + var result2 = FuzzyStringMatcher.ScoreFuzzyWithPositions("abc", string.Empty, true, true); + Assert.AreEqual(0, result2.Score); + + // Null haystack + var result3 = FuzzyStringMatcher.ScoreFuzzyWithPositions("abc", null!, true, true); + Assert.AreEqual(0, result3.Score); + } + + [TestMethod] + public void VeryLongStrings_ShouldNotThrow() + { + var needle = new string('a', 100); + var haystack = new string('b', 10000) + needle + new string('c', 10000); + + _ = FuzzyStringMatcher.ScoreFuzzyWithPositions( + needle, + haystack, + allowNonContiguousMatches: true, + removeDiacritics: true); + } + + private static string MakeDeterministicGarbage(int seed, int length) + { + // LCG for deterministic generation without Random’s platform/version surprises. + var x = (uint)seed; + var chars = length <= 2048 ? stackalloc char[length] : new char[length]; + + for (var i = 0; i < chars.Length; i++) + { + // LCG: x = (a*x + c) mod 2^32 + x = unchecked((1664525u * x) + 1013904223u); + + // Take top 16 bits as UTF-16 code unit (includes surrogates). + chars[i] = (char)(x >> 16); + } + + return new string(chars); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherValidationTests.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherValidationTests.cs new file mode 100644 index 0000000000..a03c2ccbb6 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/FuzzyMatcherValidationTests.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests; + +[TestClass] +public class FuzzyMatcherValidationTests +{ + [DataTestMethod] + [DataRow(null, "haystack")] + [DataRow("", "haystack")] + [DataRow("needle", null)] + [DataRow("needle", "")] + [DataRow(null, null)] + public void ScoreFuzzy_HandlesIncorrectInputs(string needle, string haystack) + { + Assert.AreEqual(0, FuzzyStringMatcher.ScoreFuzzy(needle!, haystack!)); + Assert.AreEqual(0, FuzzyStringMatcher.ScoreFuzzy(needle!, haystack!, allowNonContiguousMatches: true, removeDiacritics: true)); + Assert.AreEqual(0, FuzzyStringMatcher.ScoreFuzzy(needle!, haystack!, allowNonContiguousMatches: false, removeDiacritics: false)); + } + + [DataTestMethod] + [DataRow(null, "haystack")] + [DataRow("", "haystack")] + [DataRow("needle", null)] + [DataRow("needle", "")] + [DataRow(null, null)] + public void ScoreFuzzyWithPositions_HandlesIncorrectInputs(string needle, string haystack) + { + var (score1, pos1) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle!, haystack!, true); + Assert.AreEqual(0, score1); + Assert.IsNotNull(pos1); + Assert.AreEqual(0, pos1.Count); + + var (score2, pos2) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle!, haystack!, allowNonContiguousMatches: true, removeDiacritics: true); + Assert.AreEqual(0, score2); + Assert.IsNotNull(pos2); + Assert.AreEqual(0, pos2.Count); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Legacy/LegacyFuzzyStringMatcher.cs b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Legacy/LegacyFuzzyStringMatcher.cs new file mode 100644 index 0000000000..9cb2f4556d --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Legacy/LegacyFuzzyStringMatcher.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using ToolGood.Words.Pinyin; + +namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.Legacy; + +// Inspired by the fuzzy.rs from edit.exe +public static class LegacyFuzzyStringMatcher +{ + private const int NOMATCH = 0; + + /// <summary> + /// Gets or sets a value indicating whether to support Chinese PinYin. + /// Automatically enabled when the system UI culture is Simplified Chinese. + /// </summary> + public static bool ChinesePinYinSupport { get; set; } = IsSimplifiedChinese(); + + private static bool IsSimplifiedChinese() + { + var culture = CultureInfo.CurrentUICulture; + + // Detect Simplified Chinese: zh-CN, zh-Hans, zh-Hans-* + return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase) + || culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase); + } + + public static int ScoreFuzzy(string needle, string haystack, bool allowNonContiguousMatches = true) + { + var (s, _) = ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches); + return s; + } + + public static (int Score, List<int> Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches) + => ScoreAllFuzzyWithPositions(needle, haystack, allowNonContiguousMatches).MaxBy(i => i.Score); + + public static IEnumerable<(int Score, List<int> Positions)> ScoreAllFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches) + { + List<string> needles = [needle]; + List<string> haystacks = [haystack]; + + if (ChinesePinYinSupport) + { + // Remove IME composition split characters. + var input = needle.Replace("'", string.Empty); + needles.Add(WordsHelper.GetPinyin(input)); + if (WordsHelper.HasChinese(haystack)) + { + haystacks.Add(WordsHelper.GetPinyin(haystack)); + } + } + + return needles.SelectMany(i => haystacks.Select(j => ScoreFuzzyWithPositionsInternal(i, j, allowNonContiguousMatches))); + } + + private static (int Score, List<int> Positions) ScoreFuzzyWithPositionsInternal(string needle, string haystack, bool allowNonContiguousMatches) + { + if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle)) + { + return (NOMATCH, new List<int>()); + } + + var target = haystack.ToCharArray(); + var query = needle.ToCharArray(); + + if (target.Length < query.Length) + { + return (NOMATCH, new List<int>()); + } + + var targetUpper = FoldCase(haystack); + var queryUpper = FoldCase(needle); + var targetUpperChars = targetUpper.ToCharArray(); + var queryUpperChars = queryUpper.ToCharArray(); + + var area = query.Length * target.Length; + var scores = new int[area]; + var matches = new int[area]; + + for (var qi = 0; qi < query.Length; qi++) + { + var qiOffset = qi * target.Length; + var qiPrevOffset = qi > 0 ? (qi - 1) * target.Length : 0; + + for (var ti = 0; ti < target.Length; ti++) + { + var currentIndex = qiOffset + ti; + var diagIndex = (qi > 0 && ti > 0) ? qiPrevOffset + ti - 1 : 0; + var leftScore = ti > 0 ? scores[currentIndex - 1] : 0; + var diagScore = (qi > 0 && ti > 0) ? scores[diagIndex] : 0; + var matchSeqLen = (qi > 0 && ti > 0) ? matches[diagIndex] : 0; + + var score = (diagScore == 0 && qi != 0) ? 0 : + ComputeCharScore( + query[qi], + queryUpperChars[qi], + ti != 0 ? target[ti - 1] : null, + target[ti], + targetUpperChars[ti], + matchSeqLen); + + var isValidScore = score != 0 && diagScore + score >= leftScore && + (allowNonContiguousMatches || qi > 0 || + targetUpperChars.Skip(ti).Take(queryUpperChars.Length).SequenceEqual(queryUpperChars)); + + if (isValidScore) + { + matches[currentIndex] = matchSeqLen + 1; + scores[currentIndex] = diagScore + score; + } + else + { + matches[currentIndex] = NOMATCH; + scores[currentIndex] = leftScore; + } + } + } + + var positions = new List<int>(); + if (query.Length > 0 && target.Length > 0) + { + var qi = query.Length - 1; + var ti = target.Length - 1; + + while (true) + { + var index = (qi * target.Length) + ti; + if (matches[index] == NOMATCH) + { + if (ti == 0) + { + break; + } + + ti--; + } + else + { + positions.Add(ti); + if (qi == 0 || ti == 0) + { + break; + } + + qi--; + ti--; + } + } + + positions.Reverse(); + } + + return (scores[area - 1], positions); + } + + private static string FoldCase(string input) + { + return input.ToUpperInvariant(); + } + + private static int ComputeCharScore( + char query, + char queryLower, + char? targetPrev, + char targetCurr, + char targetLower, + int matchSeqLen) + { + if (!ConsiderAsEqual(queryLower, targetLower)) + { + return 0; + } + + var score = 1; // Character match bonus + + if (matchSeqLen > 0) + { + score += matchSeqLen * 5; // Consecutive match bonus + } + + if (query == targetCurr) + { + score += 1; // Same case bonus + } + + if (targetPrev.HasValue) + { + var sepBonus = ScoreSeparator(targetPrev.Value); + if (sepBonus > 0) + { + score += sepBonus; + } + else if (char.IsUpper(targetCurr) && matchSeqLen == 0) + { + score += 2; // CamelCase bonus + } + } + else + { + score += 8; // Start of word bonus + } + + return score; + } + + private static bool ConsiderAsEqual(char a, char b) + { + return a == b || (a == '/' && b == '\\') || (a == '\\' && b == '/'); + } + + private static int ScoreSeparator(char ch) + { + return ch switch + { + '/' or '\\' => 5, + '_' or '-' or '.' or ' ' or '\'' or '"' or ':' => 4, + _ => 0, + }; + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj new file mode 100644 index 0000000000..91d423031a --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj @@ -0,0 +1,30 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <RepoRoot>$(MSBuildThisFileDirectory)..\..\..\..\..\</RepoRoot> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>Microsoft.CommandPalette.Extensions.Toolkit.UnitTests</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + </ItemGroup> + + <PropertyGroup Condition="'$(CIBuild)'=='true'"> + <SignAssembly>true</SignAssembly> + <DelaySign>true</DelaySign> + <AssemblyOriginatorKeyFile>$(RepoRoot).pipelines\272MSSharedLibSN2048.snk</AssemblyOriginatorKeyFile> + </PropertyGroup> +</Project> \ No newline at end of file diff --git a/src/modules/cmdpal/custom.props b/src/modules/cmdpal/custom.props new file mode 100644 index 0000000000..05579649cf --- /dev/null +++ b/src/modules/cmdpal/custom.props @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <!-- This file is read by XES, which we use in our Release builds. --> + <PropertyGroup Label="Version"> + <XesUseOneStoreVersioning>true</XesUseOneStoreVersioning> + <XesBaseYearForStoreVersion>2025</XesBaseYearForStoreVersion> + <VersionMajor>0</VersionMajor> + <VersionMinor>8</VersionMinor> + <VersionInfoProductName>Microsoft Command Palette</VersionInfoProductName> + </PropertyGroup> +</Project> diff --git a/src/modules/cmdpal/doc/command-pal-anatomy/command-palette-anatomy.md b/src/modules/cmdpal/doc/command-pal-anatomy/command-palette-anatomy.md index 2af4771e16..d46c5c8f8e 100644 --- a/src/modules/cmdpal/doc/command-pal-anatomy/command-palette-anatomy.md +++ b/src/modules/cmdpal/doc/command-pal-anatomy/command-palette-anatomy.md @@ -152,7 +152,7 @@ We've made it easy to build a new extension. Just follow these steps: 2. Run the following PowerShell script, replacing "MastodonExtension" with the `Name` of your extension and "Mastodon extension for cmdpal" with the `DisplayName` of the [command that will show up in the root view](#root-view) of the Command Palette: ```powershell -.\Exts\NewExtension.ps1 -name MastodonExtension -DisplayName "Mastodon extension for cmdpal" +.\ext\NewExtension.ps1 -name MastodonExtension -DisplayName "Mastodon extension for cmdpal" ``` 3. Open the solution in Visual Studio. @@ -204,7 +204,7 @@ Let's see what this currently looks like in the Command Palette. First, deploy y ![alt text](image-3.png) -Then, open the Command Palette by pressing `Win+Ctl+.` and search for "Search SSH Keys". You should see the command displayed in the root view of the Command Palette like this: +Then, open the Command Palette by pressing `Win+Ctrl+.` and search for "Search SSH Keys". You should see the command displayed in the root view of the Command Palette like this: ![alt text](image-4.png) diff --git a/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md b/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md index 37c0bc1a24..131129bd2d 100644 --- a/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md +++ b/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md @@ -1,7 +1,7 @@ --- author: Mike Griese created on: 2024-07-19 -last updated: 2025-03-10 +last updated: 2025-08-08 issue id: n/a --- @@ -266,7 +266,7 @@ As some examples: that once, we don't need to `CreateProcess` just to find that command title. This is a **frozen** extension. * Similarly for something like the GitHub extension - it's got multiple - top-level commands (My issues, Issue search, Repo search, etc), but these + top-level commands (My issues, Issue search, Repo search, etc.), but these top-level commands never change. This is a **frozen** extension. * The "Quick Links" extension has a dynamic list of top-level commands. This is a **fresh** extension.[^3] @@ -392,7 +392,7 @@ command), we need to quickly load that app and get the command for it. 1. If the cached command had an `id`, try to look up the command with `ICommandProvider.GetCommand(id)`, passing the `id`. If that returns an item, we can move on to the next stem - 2. Otherwise (the command wasn't assigned an ID, or `GetCommand` returned + 2. Otherwise, (the command wasn't assigned an ID, or `GetCommand` returned null): all `TopLevelItems` on that `CommandProvider`. * Search through all the returned commands with the same `id` or `icon/title/subtitle/name`, and return that one. @@ -457,7 +457,7 @@ it be cheap from an engineering standpoint. ### From winget -Winget on the other hand, does allow packages to specify arbitrary tags, and let +WinGet on the other hand, does allow packages to specify arbitrary tags, and let apps query them easily. We can use that as a system to load a list of packages available via winget directly in DevPal. We'll specify a well-known tag that developers can use in their winget package manifest to specify that their @@ -611,7 +611,7 @@ This will create a single command in DevPal that, when selected, will open Hacker News in the user's default web browser. Commands can also be `Page`s, which represent additional "nested" pages within -DevPal. When the user selects an command that implements `IPage`, DevPal will +DevPal. When the user selects a command that implements `IPage`, DevPal will navigate to a page for that command, rather than calling `Invoke` on it. Skip ahead to [Pages](#Pages) for more information on the different types of pages. @@ -628,7 +628,7 @@ different types depending on where the command is being used: * `IListPage.GetItems` * Sender is the `IListItem` for the list item selected for that command * `ICommandItem.MoreCommands` (context menus) - * Sender is the `IListItem` which the command was attached to for a list page, or + * Sender is the `IListItem` which the command was attached to a list page, or * the `ICommandItem` of the top-level command (if this is a context item on a top level command) * `IContentPage.Commands` * Sender is the `IContentPage` itself @@ -656,7 +656,7 @@ Use cases for each `CommandResultKind`: * `Dismiss` - Close DevPal after the action is executed. All current state is dismissed as well. On the next launch, DevPal will start from the main page with a blank query. - * Ex: An action that opens an application. The Puser doesn't need DevPal + * Ex: An action that opens an application. The user doesn't need DevPal open after the application is opened, nor do they need the query they used to find the action. * `GoHome` - Navigate back to the main page of DevPal, but keep it open. @@ -752,7 +752,7 @@ which the user can quickly filter and search through. Lists can be either "static" or "dynamic": * A **static** list leaves devpal in charge of filtering the list of items, - based on the query the user typed. + based on the query that the user typed. * These are implementations of the default `IListPage`. * In this case, DevPal will use a fuzzy string match over the `Name` of the action, the `Subtitle`, and any `Text` on the `Tag`s. @@ -959,7 +959,7 @@ as the user navigates the list. Consider the Windows Registry command. When the page is initially loaded, it displays only the top-level registry keys (`HKEY_CURRENT_USER`, -`HKEY_LOCAL_MACHINE`, etc). If the user types `HKC`, the command will filter the +`HKEY_LOCAL_MACHINE`, etc.). If the user types `HKC`, the command will filter the results down to just `HKEY_CURRENT_USER`, `HKEY_CLASSES_ROOT` and `HKEY_CURRENT_CONFIG`. However, if the user at this point taps the right-arrow key, DevPall will use the `TextToSuggest` from the `HKEY_CURRENT_USER` @@ -1375,7 +1375,7 @@ app's icon. ![](https://miro.medium.com/v2/resize:fit:720/format:webp/1*Nd5fvJM8LUQ1w3DAWN-pvA.gif) -(However, the buttons in the gif for "Open", "Uninstall", etc, are not part of +(However, the buttons in the gif for "Open", "Uninstall", etc., are not part of the `Details`, they are part of the "more commands" dropdown. **It's a mockup**) <!-- This block needs to appear in the idl _before_ IListItem, but from a doc @@ -1410,8 +1410,8 @@ interface IDetailsLink requires IDetailsData { Windows.Foundation.Uri Link { get; }; String Text { get; }; } -interface IDetailsCommand requires IDetailsData { - ICommand Command { get; }; +interface IDetailsCommands requires IDetailsData { + ICommand[] Commands { get; }; } [uuid("58070392-02bb-4e89-9beb-47ceb8c3d741")] interface IDetailsSeparator requires IDetailsData {} @@ -1509,7 +1509,7 @@ settings for your extension being lost. Providers may also specify a set of `FallbackCommands`[^2]. These are special top-level items which allow extensions to have dynamic top-level items which -respond to the text the user types on the main list page. +respond to the text that the user types on the main list page. These are implemented with a special `IFallbackHandler` interface. This is an object that will be informed whenever the query changes in List page hosting it. @@ -1545,13 +1545,12 @@ public class SpongebotPage : Microsoft.CommandPalette.Extensions.Toolkit.Markdow this.Name = ""; this.Icon = new("https://imgflip.com/s/meme/Mocking-Spongebob.jpg"); } - public void IFallbackHandler.UpdateQuery(string query) { + public void UpdateQuery(string query) { if (string.IsNullOrEmpty(query)) { this.Name = ""; } else { this.Name = ConvertToAlternatingCase(query); } - return Task.CompletedTask.AsAsyncCommand(); } static string ConvertToAlternatingCase(string input) { StringBuilder sb = new StringBuilder(); @@ -1936,6 +1935,115 @@ When displaying a page: * The title will be `IPage.Title ?? ICommand.Name` * The icon will be `ICommand.Icon` +## Addenda I: API additions (ICommandProvider2) + +In experiments with extending our API, we've found some quirks with the way +that we use WinRT's metadata-based marshalling (MBM). Typically, you'd add +another contract version, add the new runtimeclass under the new contract +version, and then have the client app just check if that contract is available. + +However, we're not using `runtimeclass`es that are exposed from the extensions. +Everything is being transferred over MBM, based on the +`Microsoft.CommandPalette.Extensions.winmd`. And out-of-proc MBM has some +limitations. You can essentially only have a linear chain of requires for +extension interfaces. + +> E.g. if it implements `IWidget2` and `IWidget2 requires IWidget`, and the object's `GetRuntimeClassName` gives `IWidget2`, we know to look at `IWidget2` directly and `IWidget` due to requires. +> +> The unfortunate thing for the developer experience when authoring an extension with cppwinrt/CsWinRT implementations of interfaces, is they implement each interface separately. So the `IInspectable::GetRuntimeClassName` method inherited by `Interface1` gives `"Interface1"` and the method inherited by `Interface2` gives `"Interface2"`. +> +> Only one of these interfaces can be what the object responds to with a QI for `IInspectable`, and that's the implementation that MBM calls. + +That means we can't just add another interface easily. But what we can do: + +> It might be possible to prefill the cache with the interfaces in question by +> marshaling objects that implement each of the interfaces in a way that +> registration-free MBM can work with. +> +> E.g. to keep it simple, marshal an +> instance of a separate implementation class per interface that "implements" +> each interface + +So that's exactly what we're going to do, because it works. As an example, +we're going to add the following interface to our API: + +```csharp +interface IExtendedAttributesProvider +{ + Windows.Foundation.Collections.IMap<String, Object> GetProperties(); +}; + +interface ICommandProvider2 requires ICommandProvider +{ + Object[] GetApiExtensionStubs(); +}; +``` + +`IExtendedAttributesProvider` is just a simple interface, indicating that there's some +property bag of additional values that the host could read. We're starting with +this, because it's a helpful tool for us to add arbitrary properties to object +in an experimental fashion. We can continue to add more things we read from +this property set, without breaking the ABI. + +As an example, `ICommand` proves uniquely challenging to extend, because it has +both the `IInvokableCommand` and `IPage` family trees of interfaces which +extend from it. Typically, it would be impossible for a class to be defined as + +```cs +class MyCommandWithProperties : IInvokableCommand, IExtendedAttributesProvider { ... } +``` + +because Command Palette would only ever see the _first_ interface +(`IInvokableCommand`) via MBM, and would never be able to check if an extension +object was an `IExtendedAttributesProvider`. But a class defined like + +```cs +class CommandWithOnlyProperties : IExtendedAttributesProvider { ... } +``` + +will populate the WinRT type cache in Command Palette with the type information +for `ICommandWithProperties`. In fact, if Command Palette has the +`IExtendedAttributesProvider` type info in it's cache, and then later receives a new +`MyCommandWithProperties` object, it'll actually be able to know that +`MyCommandWithProperties` is an `IExtendedAttributesProvider`. WinRT is just weird +like that some times. + +`ICommandProvider2` is where the magic happens. This is a _linear_ addition to +`ICommandProvider`, which merely adds a method to return a set of objects. +Extensions can implement that method, by returning out stub implementations of +all the future additions to the API that we may add. In so doing, CmdPal will +be able to ask each extension for these stubs, pre-load the type cache for each +extension, and then never have to worry in the future. + +As an example: + +```cs +public partial class SamplePagesCommandsProvider : CommandProvider, ICommandProvider2 { + public SamplePagesCommandsProvider() { + DisplayName = "Sample Pages Commands"; + Icon = new IconInfo("\uE82D"); + } + public override ICommandItem[] TopLevelCommands() { + return [ + new CommandItem(new SamplesListPage()) { Title = "Sample Pages", Subtitle = "View example commands" }, + ]; + } + + // Here is where we enable support for future additions to the API + public object[] GetApiExtensionStubs() { + return [new SupportCommandsWithProperties()]; + } + private sealed partial class SupportCommandsWithProperties : IExtendedAttributesProvider { + public IDictionary<string, object>? GetProperties() => null; + } +} + +``` + +Fortunately, we can put all of that (`GetApiExtensionStubs`, +`SupportCommandsWithProperties`) directly in `Toolkit.CommandProvider`, so +developers won't have to do anything. The toolkit will just do the right thing +for them. ## Class diagram @@ -2210,6 +2318,8 @@ this prevents us from being able to use `[contract]` attributes to add to the interfaces. We'll instead need to rely on the tried-and-true method of adding a `IFoo2` when we want to add methods to `IFoo`. +[Addenda I](#addenda-i-api-additions-icommandprovider2) talks a little more on some of the challenges with adding more APIs. + [^1]: In this example, as in other places, I've referenced a `Microsoft.DevPal.Extensions.InvokableCommand` class, as the base for that action. Our SDK will include partial class implementations for interfaces like diff --git a/src/modules/cmdpal/ext/Common.ExtDependencies.props b/src/modules/cmdpal/ext/Common.ExtDependencies.props new file mode 100644 index 0000000000..d7f4982099 --- /dev/null +++ b/src/modules/cmdpal/ext/Common.ExtDependencies.props @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project> + +<!-- + Common external dependencies for all CmdPal extensions: + - Microsoft.WindowsAppSDK + - Microsoft.Web.WebView2 + - Microsoft.CommandPalette.Extensions.Toolkit (via project reference) + + We need the WASDK reference because without it, our image assets won't get + placed into our final package correctly. + +--> + + <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="Microsoft.Web.WebView2" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs new file mode 100644 index 0000000000..317087847e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs @@ -0,0 +1,191 @@ +// 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 Microsoft.CmdPal.Ext.Apps.Helpers; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Properties; +using Microsoft.CmdPal.Ext.Apps.State; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps; + +public partial class AllAppsCommandProvider : CommandProvider +{ + public const string WellKnownId = "AllApps"; + + public static readonly AllAppsPage Page = new(); + + private readonly AllAppsPage _page; + private readonly CommandItem _listItem; + + public AllAppsCommandProvider() + : this(Page) + { + } + + public AllAppsCommandProvider(AllAppsPage page) + { + _page = page ?? throw new ArgumentNullException(nameof(page)); + Id = WellKnownId; + DisplayName = Resources.installed_apps; + Icon = Icons.AllAppsIcon; + Settings = AllAppsSettings.Instance.Settings; + + _listItem = new(_page) + { + MoreCommands = [new CommandContextItem(AllAppsSettings.Instance.Settings.SettingsPage)], + }; + + // Subscribe to pin state changes to refresh the command provider + PinnedAppsManager.Instance.PinStateChanged += OnPinStateChanged; + } + + public static int TopLevelResultLimit + { + get + { + var limitSetting = AllAppsSettings.Instance.SearchResultLimit; + + if (limitSetting is null) + { + return 10; + } + + var quantity = 10; + + if (int.TryParse(limitSetting, out var result)) + { + quantity = result < 0 ? quantity : result; + } + + return quantity; + } + } + + public override ICommandItem[] TopLevelCommands() => [_listItem, .. _page.GetPinnedApps()]; + + public ICommandItem? LookupAppByPackageFamilyName(string packageFamilyName, bool requireSingleMatch) + { + if (string.IsNullOrEmpty(packageFamilyName)) + { + return null; + } + + var items = _page.GetItems(); + List<ICommandItem> matches = []; + + foreach (var item in items) + { + if (item is AppListItem appItem && string.Equals(packageFamilyName, appItem.App.PackageFamilyName, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(item); + if (!requireSingleMatch) + { + // Return early if we don't require uniqueness. + return item; + } + } + } + + return requireSingleMatch && matches.Count == 1 ? matches[0] : null; + } + + public ICommandItem? LookupAppByProductCode(string productCode, bool requireSingleMatch) + { + if (string.IsNullOrEmpty(productCode)) + { + return null; + } + + if (!UninstallRegistryAppLocator.TryGetInstallInfo(productCode, out _, out var candidates) || candidates.Count <= 0) + { + return null; + } + + var items = _page.GetItems(); + List<ICommandItem> matches = []; + + foreach (var item in items) + { + if (item is not AppListItem appListItem || string.IsNullOrEmpty(appListItem.App.FullExecutablePath)) + { + continue; + } + + foreach (var candidate in candidates) + { + if (string.Equals(appListItem.App.FullExecutablePath, candidate, StringComparison.OrdinalIgnoreCase)) + { + matches.Add(item); + if (!requireSingleMatch) + { + return item; + } + } + } + } + + return requireSingleMatch && matches.Count == 1 ? matches[0] : null; + } + + public ICommandItem? LookupAppByDisplayName(string displayName) + { + var items = _page.GetItems(); + + var nameMatches = new List<ICommandItem>(); + ICommandItem? bestAppMatch = null; + var bestLength = -1; + + foreach (var item in items) + { + if (item.Title is null) + { + continue; + } + + // We're going to do this search in two directions: + // First, is this name a substring of any app... + if (item.Title.Contains(displayName)) + { + nameMatches.Add(item); + } + + // ... Then, does any app have this name as a substring ... + // Only get one of these - "Terminal Preview" contains both "Terminal" and "Terminal Preview", so just take the best one + if (displayName.Contains(item.Title)) + { + if (item.Title.Length > bestLength) + { + bestLength = item.Title.Length; + bestAppMatch = item; + } + } + } + + // ... Now, combine those two + List<ICommandItem> both = bestAppMatch is null ? nameMatches : [.. nameMatches, bestAppMatch]; + + if (both.Count == 1) + { + return both[0]; + } + else if (nameMatches.Count == 1 && bestAppMatch is not null) + { + if (nameMatches[0] == bestAppMatch) + { + return nameMatches[0]; + } + } + + return null; + } + + private void OnPinStateChanged(object? sender, System.EventArgs e) + { + RaiseItemsChanged(0); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs new file mode 100644 index 0000000000..2a264f70c2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsPage.cs @@ -0,0 +1,208 @@ +// 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.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Properties; +using Microsoft.CmdPal.Ext.Apps.State; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps; + +public sealed partial class AllAppsPage : ListPage +{ + private readonly Lock _listLock = new(); + private readonly IAppCache _appCache; + + private AppItem[] allApps = []; + private AppListItem[] unpinnedApps = []; + private AppListItem[] pinnedApps = []; + + public AllAppsPage() + : this(AppCache.Instance.Value) + { + } + + public AllAppsPage(IAppCache appCache) + { + _appCache = appCache ?? throw new ArgumentNullException(nameof(appCache)); + this.Name = Resources.all_apps; + this.Icon = Icons.AllAppsIcon; + this.ShowDetails = true; + this.IsLoading = true; + this.PlaceholderText = Resources.search_installed_apps_placeholder; + + // Subscribe to pin state changes to refresh the command provider + PinnedAppsManager.Instance.PinStateChanged += OnPinStateChanged; + + Task.Run(() => + { + lock (_listLock) + { + BuildListItems(); + } + }); + } + + internal AppListItem[] GetPinnedApps() + { + BuildListItems(); + return pinnedApps; + } + + public override IListItem[] GetItems() + { + // Build or update the list if needed + BuildListItems(); + + AppListItem[] allApps = [.. pinnedApps, .. unpinnedApps]; + return allApps; + } + + private void BuildListItems() + { + if (allApps.Length == 0 || _appCache.ShouldReload()) + { + lock (_listLock) + { + this.IsLoading = true; + + Stopwatch stopwatch = new(); + stopwatch.Start(); + + var apps = GetPrograms(); + this.allApps = apps.AllApps; + this.pinnedApps = apps.PinnedItems; + this.unpinnedApps = apps.UnpinnedItems; + + this.IsLoading = false; + + _appCache.ResetReloadFlag(); + + stopwatch.Stop(); + Logger.LogTrace($"{nameof(AllAppsPage)}.{nameof(BuildListItems)} took: {stopwatch.ElapsedMilliseconds} ms"); + } + } + } + + private AppItem[] GetAllApps() + { + List<AppItem> allApps = new(); + + foreach (var uwpApp in _appCache.UWPs) + { + if (uwpApp.Enabled) + { + allApps.Add(uwpApp.ToAppItem()); + } + } + + foreach (var win32App in _appCache.Win32s) + { + if (win32App.Enabled && win32App.Valid) + { + allApps.Add(win32App.ToAppItem()); + } + } + + return [.. allApps]; + } + + internal (AppItem[] AllApps, AppListItem[] PinnedItems, AppListItem[] UnpinnedItems) GetPrograms() + { + var allApps = GetAllApps(); + var pinned = new List<AppListItem>(); + var unpinned = new List<AppListItem>(); + + foreach (var app in allApps) + { + var isPinned = PinnedAppsManager.Instance.IsAppPinned(app.AppIdentifier); + var appListItem = new AppListItem(app, true, isPinned); + + if (isPinned) + { + appListItem.Tags = [.. appListItem.Tags, new Tag() { Icon = Icons.PinIcon }]; + pinned.Add(appListItem); + } + else + { + unpinned.Add(appListItem); + } + } + + pinned.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal)); + unpinned.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal)); + + return ( + allApps, + pinned.ToArray(), + unpinned.ToArray() + ); + } + + private void OnPinStateChanged(object? sender, PinStateChangedEventArgs e) + { + /* + * Rebuilding all the lists is pretty expensive. + * So, instead, we'll just compare pinned items to move existing + * items between the two lists. + */ + AppItem? existingAppItem = null; + + foreach (var app in allApps) + { + if (app.AppIdentifier == e.AppIdentifier) + { + existingAppItem = app; + break; + } + } + + if (existingAppItem is not null) + { + var appListItem = new AppListItem(existingAppItem, true, e.IsPinned); + + var newPinned = new List<AppListItem>(pinnedApps); + var newUnpinned = new List<AppListItem>(unpinnedApps); + + if (e.IsPinned) + { + newPinned.Add(appListItem); + + foreach (var app in newUnpinned) + { + if (app.AppIdentifier == e.AppIdentifier) + { + newUnpinned.Remove(app); + break; + } + } + } + else + { + newUnpinned.Add(appListItem); + + foreach (var app in newPinned) + { + if (app.AppIdentifier == e.AppIdentifier) + { + newPinned.Remove(app); + break; + } + } + } + + pinnedApps = newPinned.ToArray(); + unpinnedApps = newUnpinned.ToArray(); + } + + RaiseItemsChanged(0); + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs similarity index 74% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs index 91d5cd5736..bf326221f9 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs @@ -5,19 +5,26 @@ using System; using System.Collections.Generic; using System.IO; +using Microsoft.CmdPal.Ext.Apps.Helpers; using Microsoft.CmdPal.Ext.Apps.Programs; using Microsoft.CmdPal.Ext.Apps.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Apps; -public class AllAppsSettings : JsonSettingsManager +public class AllAppsSettings : JsonSettingsManager, ISettingsInterface { private static readonly string _namespace = "apps"; private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; - private static string Experimental(string propertyName) => $"{_namespace}.experimental.{propertyName}"; + private static readonly List<ChoiceSetSetting.Choice> _searchResultLimitChoices = + [ + new ChoiceSetSetting.Choice(Resources.limit_0, "0"), + new ChoiceSetSetting.Choice(Resources.limit_1, "1"), + new ChoiceSetSetting.Choice(Resources.limit_5, "5"), + new ChoiceSetSetting.Choice(Resources.limit_10, "10"), + ]; #pragma warning disable SA1401 // Fields should be private internal static AllAppsSettings Instance = new(); @@ -41,28 +48,36 @@ public class AllAppsSettings : JsonSettingsManager public bool EnablePathEnvironmentVariableSource => _enablePathEnvironmentVariableSource.Value; + private readonly ChoiceSetSetting _searchResultLimitSource = new( + Namespaced(nameof(SearchResultLimit)), + Resources.limit_fallback_results_source, + Resources.limit_fallback_results_source_description, + _searchResultLimitChoices); + + public string SearchResultLimit => _searchResultLimitSource.Value ?? string.Empty; + private readonly ToggleSetting _enableStartMenuSource = new( Namespaced(nameof(EnableStartMenuSource)), Resources.enable_start_menu_source, - Resources.enable_start_menu_source, + string.Empty, true); private readonly ToggleSetting _enableDesktopSource = new( Namespaced(nameof(EnableDesktopSource)), Resources.enable_desktop_source, - Resources.enable_desktop_source, + string.Empty, true); private readonly ToggleSetting _enableRegistrySource = new( Namespaced(nameof(EnableRegistrySource)), Resources.enable_registry_source, - Resources.enable_registry_source, + string.Empty, false); // This one is very noisy private readonly ToggleSetting _enablePathEnvironmentVariableSource = new( Namespaced(nameof(EnablePathEnvironmentVariableSource)), Resources.enable_path_environment_variable_source, - Resources.enable_path_environment_variable_source, + string.Empty, false); // this one is very VERY noisy public double MinScoreThreshold { get; set; } = 0.75; @@ -71,7 +86,7 @@ public class AllAppsSettings : JsonSettingsManager internal static string SettingsJsonPath() { - string directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); Directory.CreateDirectory(directory); // now, the state is just next to the exe @@ -86,6 +101,7 @@ public class AllAppsSettings : JsonSettingsManager Settings.Add(_enableDesktopSource); Settings.Add(_enableRegistrySource); Settings.Add(_enablePathEnvironmentVariableSource); + Settings.Add(_searchResultLimitSource); // Load settings from file upon initialization LoadSettings(); diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AppCache.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs similarity index 66% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AppCache.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs index cb80c5e8c5..f2476dae61 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AppCache.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCache.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Microsoft.CmdPal.Ext.Apps.Programs; using Microsoft.CmdPal.Ext.Apps.Storage; @@ -12,7 +11,7 @@ using Microsoft.CmdPal.Ext.Apps.Utils; namespace Microsoft.CmdPal.Ext.Apps; -public sealed class AppCache : IDisposable +public sealed partial class AppCache : IAppCache, IDisposable { private Win32ProgramFileSystemWatchers _win32ProgramRepositoryHelper; @@ -24,14 +23,17 @@ public sealed class AppCache : IDisposable public IList<Win32Program> Win32s => _win32ProgramRepository.Items; - public IList<UWPApplication> UWPs => _packageRepository.Items; + public IList<IUWPApplication> UWPs => _packageRepository.Items; public static readonly Lazy<AppCache> Instance = new(() => new()); public AppCache() { _win32ProgramRepositoryHelper = new Win32ProgramFileSystemWatchers(); - _win32ProgramRepository = new Win32ProgramRepository(_win32ProgramRepositoryHelper.FileSystemWatchers.Cast<IFileSystemWatcherWrapper>().ToList(), AllAppsSettings.Instance, _win32ProgramRepositoryHelper.PathsToWatch); + + var watchers = new List<IFileSystemWatcherWrapper>(_win32ProgramRepositoryHelper.FileSystemWatchers); + + _win32ProgramRepository = new Win32ProgramRepository(watchers, AllAppsSettings.Instance, _win32ProgramRepositoryHelper.PathsToWatch); _packageRepository = new PackageRepository(new PackageCatalogWrapper()); @@ -46,18 +48,37 @@ public sealed class AppCache : IDisposable UpdateUWPIconPath(ThemeHelper.GetCurrentTheme()); }); - Task.WaitAll(a, b); + try + { + Task.WaitAll(a, b); + } + catch (AggregateException ex) + { + ManagedCommon.Logger.LogError("One or more errors occurred while indexing apps"); + + foreach (var inner in ex.InnerExceptions) + { + ManagedCommon.Logger.LogError(inner.Message, inner); + } + } AllAppsSettings.Instance.LastIndexTime = DateTime.Today; } private void UpdateUWPIconPath(Theme theme) { - if (_packageRepository != null) + if (_packageRepository is not null) { foreach (UWPApplication app in _packageRepository) { - app.UpdateLogoPath(theme); + try + { + app.UpdateLogoPath(theme); + } + catch (Exception ex) + { + ManagedCommon.Logger.LogError($"Failed to update icon path for app {app.Name}", ex); + } } } } diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/AppCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCommand.cs similarity index 51% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/AppCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCommand.cs index 682c79bdb8..5fadf89bd6 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/AppCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppCommand.cs @@ -2,11 +2,16 @@ // 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; using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.Apps.Programs; +using ManagedCommon; using Microsoft.CmdPal.Ext.Apps.Properties; +using Microsoft.CmdPal.Ext.Apps.Utils; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Win32; +using Windows.Win32.System.Com; +using Windows.Win32.UI.Shell; using WyHash; namespace Microsoft.CmdPal.Ext.Apps; @@ -15,42 +20,55 @@ internal sealed partial class AppCommand : InvokableCommand { private readonly AppItem _app; - internal AppCommand(AppItem app) + public AppCommand(AppItem app) { _app = app; - - Name = Resources.run_command_action; + Name = Resources.run_command_action!; Id = GenerateId(); + Icon = Icons.GenericAppIcon; } - internal static async Task StartApp(string aumid) + private static async Task StartApp(string aumid) { - var appManager = new ApplicationActivationManager(); - const ActivateOptions noFlags = ActivateOptions.None; await Task.Run(() => { - try - { - appManager.ActivateApplication(aumid, /*queryArguments*/ string.Empty, noFlags, out var unusedPid); - } - catch (System.Exception) + unsafe { + IApplicationActivationManager* appManager = null; + try + { + PInvoke.CoCreateInstance(typeof(ApplicationActivationManager).GUID, null, CLSCTX.CLSCTX_INPROC_SERVER, out appManager).ThrowOnFailure(); + using var handle = new SafeComHandle((IntPtr)appManager); + appManager->ActivateApplication( + aumid, + string.Empty, + ACTIVATEOPTIONS.AO_NONE, + out var unusedPid); + } + catch (System.Exception ex) + { + Logger.LogError(ex.Message); + } } }).ConfigureAwait(false); } - internal static async Task StartExe(string path) + private static async Task StartExe(string path) { - var appManager = new ApplicationActivationManager(); - - // const ActivateOptions noFlags = ActivateOptions.None; await Task.Run(() => { - Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + try + { + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + } + catch (System.Exception ex) + { + Logger.LogError(ex.Message); + } }); } - internal async Task Launch() + private async Task Launch() { if (_app.IsPackaged) { diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AppItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs similarity index 68% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AppItem.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs index 4bb26bed67..7b111c922b 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/AppItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppItem.cs @@ -3,12 +3,11 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; -using Microsoft.CmdPal.Ext.Apps.Programs; -using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.Ext.Apps; -internal sealed class AppItem +public sealed class AppItem { public string Name { get; set; } = string.Empty; @@ -26,7 +25,15 @@ internal sealed class AppItem public bool IsPackaged { get; set; } - public List<CommandContextItem>? Commands { get; set; } + public List<IContextItem>? Commands { get; set; } + + public string AppIdentifier { get; set; } = string.Empty; + + public string? PackageFamilyName { get; set; } + + public string? FullExecutablePath { get; set; } + + public string? JumboIconPath { get; set; } public AppItem() { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs new file mode 100644 index 0000000000..5d1c413281 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AppListItem.cs @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Core.Common.Text; +using Microsoft.CmdPal.Ext.Apps.Commands; +using Microsoft.CmdPal.Ext.Apps.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.Programs; + +public sealed partial class AppListItem : ListItem, IPrecomputedListItem +{ + private readonly AppCommand _appCommand; + private readonly AppItem _app; + + private readonly Lazy<Task<IconInfo?>> _iconLoadTask; + private readonly Lazy<Task<Details>> _detailsLoadTask; + + private InterlockedBoolean _isLoadingIcon; + private InterlockedBoolean _isLoadingDetails; + + private FuzzyTargetCache _titleCache; + private FuzzyTargetCache _subtitleCache; + + public override string Title + { + get => base.Title; + set + { + if (!string.Equals(base.Title, value, StringComparison.Ordinal)) + { + base.Title = value; + _titleCache.Invalidate(); + } + } + } + + public override string Subtitle + { + get => base.Subtitle; + set + { + if (!string.Equals(value, base.Subtitle, StringComparison.Ordinal)) + { + base.Subtitle = value; + _subtitleCache.Invalidate(); + } + } + } + + public override IDetails? Details + { + get + { + if (_isLoadingDetails.Set()) + { + _ = LoadDetailsAsync(); + } + + return base.Details; + } + set => base.Details = value; + } + + public override IIconInfo? Icon + { + get + { + if (_isLoadingIcon.Set()) + { + _ = LoadIconAsync(); + } + + return base.Icon; + } + set => base.Icon = value; + } + + public string AppIdentifier => _app.AppIdentifier; + + public AppItem App => _app; + + public AppListItem(AppItem app, bool useThumbnails, bool isPinned) + { + Command = _appCommand = new AppCommand(app); + _app = app; + Title = app.Name; + Subtitle = app.Subtitle; + Icon = Icons.GenericAppIcon; + + MoreCommands = AddPinCommands(_app.Commands!, isPinned); + + _detailsLoadTask = new Lazy<Task<Details>>(BuildDetails); + _iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FetchIcon(useThumbnails).ConfigureAwait(false)); + } + + private async Task LoadDetailsAsync() + { + try + { + Details = await _detailsLoadTask.Value; + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to load details for {AppIdentifier}\n{ex}"); + } + } + + private async Task LoadIconAsync() + { + try + { + Icon = _appCommand.Icon = CoalesceIcon(await _iconLoadTask.Value); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to load icon for {AppIdentifier}\n{ex}"); + } + } + + private static IconInfo CoalesceIcon(IconInfo? value) + { + return CoalesceIcon(value, Icons.GenericAppIcon)!; + } + + private static IconInfo? CoalesceIcon(IconInfo? value, IconInfo? replacement) + { + return IconIsNullOrEmpty(value) ? replacement : value; + } + + private static bool IconIsNullOrEmpty(IconInfo? value) + { + return value == null || (string.IsNullOrEmpty(value.Light?.Icon) && value.Light?.Data is null) || (string.IsNullOrEmpty(value.Dark?.Icon) && value.Dark?.Data is null); + } + + private async Task<Details> BuildDetails() + { + // Build metadata, with app type, path, etc. + var metadata = new List<DetailsElement>(); + metadata.Add(new DetailsElement() { Key = "Type", Data = new DetailsTags() { Tags = [new Tag(_app.Type)] } }); + if (!_app.IsPackaged) + { + metadata.Add(new DetailsElement() { Key = "Path", Data = new DetailsLink() { Text = _app.ExePath } }); + } + +#if DEBUG + metadata.Add(new DetailsElement() { Key = "[DEBUG] AppIdentifier", Data = new DetailsLink() { Text = _app.AppIdentifier } }); + metadata.Add(new DetailsElement() { Key = "[DEBUG] ExePath", Data = new DetailsLink() { Text = _app.ExePath } }); + metadata.Add(new DetailsElement() { Key = "[DEBUG] IcoPath", Data = new DetailsLink() { Text = _app.IcoPath } }); + metadata.Add(new DetailsElement() { Key = "[DEBUG] JumboIconPath", Data = new DetailsLink() { Text = _app.JumboIconPath ?? "(null)" } }); +#endif + + // Icon + IconInfo? heroImage = null; + if (_app.IsPackaged) + { + heroImage = new IconInfo(_app.JumboIconPath ?? _app.IcoPath); + } + else + { + // Get the icon from the system + if (!string.IsNullOrEmpty(_app.JumboIconPath)) + { + var randomAccessStream = await IconExtractor.GetIconStreamAsync(_app.JumboIconPath, 64); + if (randomAccessStream != null) + { + heroImage = IconInfo.FromStream(randomAccessStream); + } + } + + if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.IcoPath)) + { + var randomAccessStream = await IconExtractor.GetIconStreamAsync(_app.IcoPath, 64); + if (randomAccessStream != null) + { + heroImage = IconInfo.FromStream(randomAccessStream); + } + } + + // do nothing if we fail to load an icon. + // Logging it would be too NOISY, there's really no need. + if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.JumboIconPath)) + { + heroImage = await TryLoadThumbnail(_app.JumboIconPath, jumbo: true, logOnFailure: false); + } + + if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.IcoPath)) + { + heroImage = await TryLoadThumbnail(_app.IcoPath, jumbo: true, logOnFailure: false); + } + + if (IconIsNullOrEmpty(heroImage) && !string.IsNullOrEmpty(_app.ExePath)) + { + heroImage = await TryLoadThumbnail(_app.ExePath, jumbo: true, logOnFailure: false); + } + } + + return new Details() + { + Title = this.Title, + HeroImage = CoalesceIcon(CoalesceIcon(heroImage, this.Icon as IconInfo)), + Metadata = [..metadata], + }; + } + + private async Task<IconInfo> FetchIcon(bool useThumbnails) + { + IconInfo? icon = null; + if (_app.IsPackaged) + { + icon = new IconInfo(_app.IcoPath); + return icon; + } + + if (useThumbnails) + { + if (!string.IsNullOrEmpty(_app.IcoPath)) + { + icon = await TryLoadThumbnail(_app.IcoPath, jumbo: false, logOnFailure: true); + } + + if (IconIsNullOrEmpty(icon) && !string.IsNullOrEmpty(_app.ExePath)) + { + icon = await TryLoadThumbnail(_app.ExePath, jumbo: false, logOnFailure: true); + } + } + + icon ??= new IconInfo(_app.IcoPath); + + return icon; + } + + private IContextItem[] AddPinCommands(List<IContextItem> commands, bool isPinned) + { + var newCommands = new List<IContextItem>(); + newCommands.AddRange(commands); + + newCommands.Add(new Separator()); + + if (isPinned) + { + newCommands.Add( + new CommandContextItem( + new UnpinAppCommand(this.AppIdentifier)) + { + RequestedShortcut = KeyChords.TogglePin, + }); + } + else + { + newCommands.Add( + new CommandContextItem( + new PinAppCommand(this.AppIdentifier)) + { + RequestedShortcut = KeyChords.TogglePin, + }); + } + + return newCommands.ToArray(); + } + + private async Task<IconInfo?> TryLoadThumbnail(string path, bool jumbo, bool logOnFailure) + { + return await Task.Run(async () => + { + try + { + var stream = await ThumbnailHelper.GetThumbnail(path, jumbo).ConfigureAwait(false); + if (stream is not null) + { + return IconInfo.FromStream(stream); + } + } + catch (Exception ex) + { + if (logOnFailure) + { + Logger.LogDebug($"Failed to load icon {path} for {AppIdentifier}:\n{ex}"); + } + } + + return null; + }).ConfigureAwait(false); + } + + public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) + => _titleCache.GetOrUpdate(matcher, Title); + + public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) + => _subtitleCache.GetOrUpdate(matcher, Subtitle); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Assets/AllApps.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Assets/AllApps.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Assets/AllApps.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Assets/AllApps.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Assets/AllApps.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Assets/AllApps.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Assets/AllApps.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Assets/AllApps.svg diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/PinAppCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/PinAppCommand.cs new file mode 100644 index 0000000000..8311e36cfc --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/PinAppCommand.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.CmdPal.Ext.Apps.Properties; +using Microsoft.CmdPal.Ext.Apps.State; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.Commands; + +internal sealed partial class PinAppCommand : InvokableCommand +{ + private readonly string _appIdentifier; + + public PinAppCommand(string appIdentifier) + { + _appIdentifier = appIdentifier; + Name = Resources.pin_app; + Icon = Icons.PinIcon; + } + + public override CommandResult Invoke() + { + PinnedAppsManager.Instance.PinApp(_appIdentifier); + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/RunAsAdminCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/RunAsAdminCommand.cs similarity index 95% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/RunAsAdminCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/RunAsAdminCommand.cs index 0a6d43f608..887f669454 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/RunAsAdminCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/RunAsAdminCommand.cs @@ -13,8 +13,6 @@ namespace Microsoft.CmdPal.Ext.Apps.Commands; internal sealed partial class RunAsAdminCommand : InvokableCommand { - private static readonly IconInfo TheIcon = new("\uE7EF"); - private readonly string _target; private readonly string _parentDir; private readonly bool _packaged; @@ -22,7 +20,7 @@ internal sealed partial class RunAsAdminCommand : InvokableCommand public RunAsAdminCommand(string target, string parentDir, bool packaged) { Name = Resources.run_as_administrator; - Icon = TheIcon; + Icon = Icons.RunAsAdminIcon; _target = target; _parentDir = parentDir; diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/RunAsUserCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/RunAsUserCommand.cs similarity index 82% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/RunAsUserCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/RunAsUserCommand.cs index c897aed560..89e2d3e8ae 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/RunAsUserCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/RunAsUserCommand.cs @@ -13,21 +13,19 @@ namespace Microsoft.CmdPal.Ext.Apps.Commands; internal sealed partial class RunAsUserCommand : InvokableCommand { - private static readonly IconInfo TheIcon = new("\uE7EE"); - private readonly string _target; private readonly string _parentDir; public RunAsUserCommand(string target, string parentDir) { Name = Resources.run_as_different_user; - Icon = TheIcon; + Icon = Icons.RunAsUserIcon; _target = target; _parentDir = parentDir; } - internal static async Task RunAsAdmin(string target, string parentDir) + internal static async Task RunAsUser(string target, string parentDir) { await Task.Run(() => { @@ -39,7 +37,7 @@ internal sealed partial class RunAsUserCommand : InvokableCommand public override CommandResult Invoke() { - _ = RunAsAdmin(_target, _parentDir).ConfigureAwait(false); + _ = RunAsUser(_target, _parentDir).ConfigureAwait(false); return CommandResult.Dismiss(); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationCommand.cs new file mode 100644 index 0000000000..aea17631c6 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationCommand.cs @@ -0,0 +1,130 @@ +// 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; +using System.Globalization; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Management.Deployment; + +namespace Microsoft.CmdPal.Ext.Apps.Commands; + +internal sealed partial class UninstallApplicationCommand : InvokableCommand +{ + // This is a ms-settings URI that opens the Apps & Features page in Windows Settings. + // It's correct and follows the Microsoft documentation: + // https://learn.microsoft.com/en-us/windows/apps/develop/launch/launch-settings-app#apps + private const string AppsFeaturesUri = "ms-settings:appsfeatures"; + + private readonly UWPApplication? _uwpTarget; + private readonly Win32Program? _win32Target; + + public UninstallApplicationCommand(UWPApplication target) + { + Name = Resources.uninstall_application; + Icon = Icons.UninstallApplicationIcon; + _uwpTarget = target ?? throw new ArgumentNullException(nameof(target)); + } + + public UninstallApplicationCommand(Win32Program target) + { + Name = Resources.uninstall_application; + Icon = Icons.UninstallApplicationIcon; + _win32Target = target ?? throw new ArgumentNullException(nameof(target)); + } + + private async Task<CommandResult> UninstallUwpAppAsync(UWPApplication app) + { + if (string.IsNullOrWhiteSpace(app.Package.FullName)) + { + Logger.LogError($"Critical error while uninstalling: packageFullName cannot be null or empty."); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + + try + { + // Which timeout to use for the uninstallation operation? + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60))) + { + var packageManager = new PackageManager(); + var result = await packageManager.RemovePackageAsync(app.Package.FullName).AsTask(cts.Token); + + if (result.ErrorText is not null && result.ErrorText.Length > 0) + { + Logger.LogError($"Failed to uninstall {app.Package.FullName}: {result.ErrorText}"); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + } + + // TODO: Update the Search results after uninstalling the app - unsure how to do this yet. + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_successful), app.DisplayName), + Result = CommandResult.GoHome(), + }); + } + catch (OperationCanceledException) + { + Logger.LogError($"Timeout exceeded while uninstalling {app.Package.FullName}"); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + catch (UnauthorizedAccessException ex) + { + Logger.LogError($"Permission denied to uninstall {app.Package.FullName}. Elevated privileges may be required. Error: {ex.Message}"); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + catch (Exception ex) + { + Logger.LogError($"An unexpected error occurred during uninstallation of {app.Package.FullName}: {ex.Message}"); + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_failed), app.DisplayName), + Result = CommandResult.KeepOpen(), + }); + } + } + + public override CommandResult Invoke() + { + if (_uwpTarget is not null) + { + return UninstallUwpAppAsync(_uwpTarget).ConfigureAwait(false).GetAwaiter().GetResult(); + } + + if (_win32Target is not null) + { + Process.Start(new ProcessStartInfo + { + FileName = AppsFeaturesUri, + UseShellExecute = true, + }); + return CommandResult.Dismiss(); + } + + Logger.LogError("UninstallApplicationCommand invoked with no target."); + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationConfirmation.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationConfirmation.cs new file mode 100644 index 0000000000..b0444178e0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UninstallApplicationConfirmation.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.Text; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.Commands; + +internal sealed partial class UninstallApplicationConfirmation : InvokableCommand +{ + private readonly UWPApplication? _uwpTarget; + private readonly Win32Program? _win32Target; + + public UninstallApplicationConfirmation(UWPApplication target) + { + Name = Resources.uninstall_application; + Icon = Icons.UninstallApplicationIcon; + _uwpTarget = target ?? throw new ArgumentNullException(nameof(target)); + } + + public UninstallApplicationConfirmation(Win32Program target) + { + Name = Resources.uninstall_application; + Icon = Icons.UninstallApplicationIcon; + _win32Target = target ?? throw new ArgumentNullException(nameof(target)); + } + + public override CommandResult Invoke() + { + UninstallApplicationCommand uninstallCommand; + + var applicationTitle = Resources.uninstall_application; + + if (_uwpTarget is not null) + { + uninstallCommand = new UninstallApplicationCommand(_uwpTarget); + applicationTitle = _uwpTarget.DisplayName; + } + else if (_win32Target is not null) + { + uninstallCommand = new UninstallApplicationCommand(_win32Target); + applicationTitle = _win32Target.Name; + } + else + { + Logger.LogError("UninstallApplicationCommand invoked with no target."); + return CommandResult.Dismiss(); + } + + var confirmArgs = new ConfirmationArgs() + { + Title = string.Format(CultureInfo.CurrentCulture, CompositeFormat.Parse(Resources.uninstall_application_confirm_title), applicationTitle), + Description = Resources.uninstall_application_confirm_description, + PrimaryCommand = uninstallCommand, + IsPrimaryCommandCritical = true, + }; + + return CommandResult.Confirm(confirmArgs); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UnpinAppCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UnpinAppCommand.cs new file mode 100644 index 0000000000..fcba03f3d3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Commands/UnpinAppCommand.cs @@ -0,0 +1,27 @@ +// 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.Ext.Apps.Properties; +using Microsoft.CmdPal.Ext.Apps.State; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.Commands; + +internal sealed partial class UnpinAppCommand : InvokableCommand +{ + private readonly string _appIdentifier; + + public UnpinAppCommand(string appIdentifier) + { + _appIdentifier = appIdentifier; + Name = Resources.unpin_app; + Icon = Icons.UnpinIcon; + } + + public override CommandResult Invoke() + { + PinnedAppsManager.Instance.UnpinApp(_appIdentifier); + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/AppxIconLoader.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/AppxIconLoader.cs new file mode 100644 index 0000000000..589cff5214 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/AppxIconLoader.cs @@ -0,0 +1,295 @@ +// 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.IO; +using Microsoft.CmdPal.Ext.Apps.Programs; +using Microsoft.CmdPal.Ext.Apps.Utils; + +namespace Microsoft.CmdPal.Ext.Apps.Helpers; + +internal static class AppxIconLoader +{ + private const string ContrastWhite = "contrast-white"; + private const string ContrastBlack = "contrast-black"; + + private static readonly Dictionary<UWP.PackageVersion, List<int>> _scaleFactors = new() + { + { UWP.PackageVersion.Windows10, [100, 125, 150, 200, 400] }, + { UWP.PackageVersion.Windows81, [100, 120, 140, 160, 180] }, + { UWP.PackageVersion.Windows8, [100] }, + }; + + private static readonly List<int> TargetSizes = [16, 24, 30, 36, 44, 60, 72, 96, 128, 180, 256]; + + private static IconSearchResult GetScaleIcons( + string path, + string colorscheme, + UWP.PackageVersion packageVersion, + bool highContrast = false) + { + var extension = Path.GetExtension(path); + if (extension is null) + { + return IconSearchResult.NotFound(); + } + + var end = path.Length - extension.Length; + var prefix = path[..end]; + + if (!_scaleFactors.TryGetValue(packageVersion, out var factors)) + { + return IconSearchResult.NotFound(); + } + + var logoType = highContrast ? LogoType.HighContrast : LogoType.Colored; + + // Check from highest scale factor to lowest for best quality + for (var i = factors.Count - 1; i >= 0; i--) + { + var factor = factors[i]; + string[] pathsToTry = highContrast + ? + [ + $"{prefix}.scale-{factor}_{colorscheme}{extension}", + $"{prefix}.{colorscheme}_scale-{factor}{extension}", + ] + : + [ + $"{prefix}.scale-{factor}{extension}", + ]; + + foreach (var p in pathsToTry) + { + if (File.Exists(p)) + { + return IconSearchResult.FoundScaled(p, logoType); + } + } + } + + // Check base path (100% scale) as last resort + if (!highContrast && File.Exists(path)) + { + return IconSearchResult.FoundScaled(path, logoType); + } + + return IconSearchResult.NotFound(); + } + + private static IconSearchResult GetTargetSizeIcon( + string path, + string colorscheme, + bool highContrast = false, + int appIconSize = 36, + double maxSizeCoefficient = 8.0) + { + var extension = Path.GetExtension(path); + if (extension is null) + { + return IconSearchResult.NotFound(); + } + + var end = path.Length - extension.Length; + var prefix = path[..end]; + var pathSizePairs = new List<(string Path, int Size)>(); + + foreach (var size in TargetSizes) + { + if (highContrast) + { + pathSizePairs.Add(($"{prefix}.targetsize-{size}_{colorscheme}{extension}", size)); + pathSizePairs.Add(($"{prefix}.{colorscheme}_targetsize-{size}{extension}", size)); + } + else + { + pathSizePairs.Add(($"{prefix}.targetsize-{size}_altform-unplated{extension}", size)); + pathSizePairs.Add(($"{prefix}.targetsize-{size}{extension}", size)); + } + } + + var maxAllowedSize = (int)(appIconSize * maxSizeCoefficient); + var logoType = highContrast ? LogoType.HighContrast : LogoType.Colored; + + string? bestLargerPath = null; + var bestLargerSize = int.MaxValue; + + string? bestSmallerPath = null; + var bestSmallerSize = 0; + + foreach (var (p, size) in pathSizePairs) + { + if (!File.Exists(p)) + { + continue; + } + + if (size >= appIconSize && size <= maxAllowedSize) + { + if (size < bestLargerSize) + { + bestLargerSize = size; + bestLargerPath = p; + } + } + else if (size < appIconSize) + { + if (size > bestSmallerSize) + { + bestSmallerSize = size; + bestSmallerPath = p; + } + } + } + + if (bestLargerPath is not null) + { + return IconSearchResult.FoundTargetSize(bestLargerPath, logoType, bestLargerSize); + } + + if (bestSmallerPath is not null) + { + return IconSearchResult.FoundTargetSize(bestSmallerPath, logoType, bestSmallerSize); + } + + return IconSearchResult.NotFound(); + } + + private static IconSearchResult GetColoredIcon( + string path, + string colorscheme, + int iconSize, + UWP package) + { + // First priority: targetsize icons (we know the exact size) + var targetResult = GetTargetSizeIcon(path, colorscheme, highContrast: false, appIconSize: iconSize); + if (targetResult.MeetsMinimumSize(iconSize)) + { + return targetResult; + } + + var hcTargetResult = GetTargetSizeIcon(path, colorscheme, highContrast: true, appIconSize: iconSize); + if (hcTargetResult.MeetsMinimumSize(iconSize)) + { + return hcTargetResult; + } + + // Second priority: scale icons (size unknown, but higher scale = likely better) + var scaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: false); + if (scaleResult.IsFound) + { + return scaleResult; + } + + var hcScaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: true); + if (hcScaleResult.IsFound) + { + return hcScaleResult; + } + + // Last resort: return undersized targetsize if we found one + if (targetResult.IsFound) + { + return targetResult; + } + + if (hcTargetResult.IsFound) + { + return hcTargetResult; + } + + return IconSearchResult.NotFound(); + } + + private static IconSearchResult SetHighContrastIcon( + string path, + string colorscheme, + int iconSize, + UWP package) + { + // First priority: HC targetsize icons (we know the exact size) + var hcTargetResult = GetTargetSizeIcon(path, colorscheme, highContrast: true, appIconSize: iconSize); + if (hcTargetResult.MeetsMinimumSize(iconSize)) + { + return hcTargetResult; + } + + var targetResult = GetTargetSizeIcon(path, colorscheme, highContrast: false, appIconSize: iconSize); + if (targetResult.MeetsMinimumSize(iconSize)) + { + return targetResult; + } + + // Second priority: scale icons + var hcScaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: true); + if (hcScaleResult.IsFound) + { + return hcScaleResult; + } + + var scaleResult = GetScaleIcons(path, colorscheme, package.Version, highContrast: false); + if (scaleResult.IsFound) + { + return scaleResult; + } + + // Last resort: undersized targetsize + if (hcTargetResult.IsFound) + { + return hcTargetResult; + } + + if (targetResult.IsFound) + { + return targetResult; + } + + return IconSearchResult.NotFound(); + } + + /// <summary> + /// Loads an icon from a UWP package, attempting to find the best match for the requested size. + /// </summary> + /// <param name="uri">The relative URI to the logo asset.</param> + /// <param name="theme">The current theme.</param> + /// <param name="iconSize">The requested icon size in pixels.</param> + /// <param name="package">The UWP package.</param> + /// <returns> + /// An IconSearchResult. Use <see cref="IconSearchResult.MeetsMinimumSize"/> to check if + /// the icon is confirmed to be large enough, or <see cref="IconSearchResult.IsTargetSizeIcon"/> + /// to determine if the size is known. + /// </returns> + internal static IconSearchResult LogoPathFromUri( + string uri, + Theme theme, + int iconSize, + UWP package) + { + var path = Path.Combine(package.Location, uri); + var logo = Probe(theme, path, iconSize, package); + if (!logo.IsFound && !uri.Contains('\\', StringComparison.Ordinal)) + { + path = Path.Combine(package.Location, "Assets", uri); + logo = Probe(theme, path, iconSize, package); + } + + return logo; + } + + private static IconSearchResult Probe(Theme theme, string path, int iconSize, UWP package) + { + return theme switch + { + Theme.HighContrastBlack or Theme.HighContrastOne or Theme.HighContrastTwo + => SetHighContrastIcon(path, ContrastBlack, iconSize, package), + Theme.HighContrastWhite + => SetHighContrastIcon(path, ContrastWhite, iconSize, package), + Theme.Light + => GetColoredIcon(path, ContrastWhite, iconSize, package), + _ + => GetColoredIcon(path, ContrastBlack, iconSize, package), + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..b6328f3c10 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/ISettingsInterface.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace Microsoft.CmdPal.Ext.Apps.Helpers; + +public interface ISettingsInterface +{ + public bool EnableStartMenuSource { get; } + + public bool EnableDesktopSource { get; } + + public bool EnableRegistrySource { get; } + + public bool EnablePathEnvironmentVariableSource { get; } + + public List<string> ProgramSuffixes { get; } + + public List<string> RunCommandSuffixes { get; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconExtractor.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconExtractor.cs new file mode 100644 index 0000000000..c1d04e286c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconExtractor.cs @@ -0,0 +1,132 @@ +// 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.WindowsRuntime; +using System.Threading.Tasks; +using ManagedCommon; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.Shell; + +namespace Microsoft.CmdPal.Ext.Apps.Helpers; + +internal static class IconExtractor +{ + public static async Task<IRandomAccessStream?> GetIconStreamAsync(string path, int size) + { + var bitmap = GetIcon(path, size); + if (bitmap == null) + { + return null; + } + + var stream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); + encoder.SetSoftwareBitmap(bitmap); + await encoder.FlushAsync(); + + stream.Seek(0); + return stream; + } + + public static unsafe SoftwareBitmap? GetIcon(string path, int size) + { + IShellItemImageFactory* factory = null; + HBITMAP hBitmap = default; + + try + { + fixed (char* pPath = path) + { + var iid = IShellItemImageFactory.IID_Guid; + var hr = PInvoke.SHCreateItemFromParsingName( + pPath, + null, + &iid, + (void**)&factory); + + if (hr.Failed || factory == null) + { + return null; + } + } + + var requestedSize = new SIZE { cx = size, cy = size }; + var hr2 = factory->GetImage( + requestedSize, + SIIGBF.SIIGBF_ICONONLY | SIIGBF.SIIGBF_BIGGERSIZEOK | SIIGBF.SIIGBF_CROPTOSQUARE, + &hBitmap); + + if (hr2.Failed || hBitmap.IsNull) + { + return null; + } + + return CreateSoftwareBitmap(hBitmap, size); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load icon from path='{path}',size={size}", ex); + return null; + } + finally + { + if (!hBitmap.IsNull) + { + PInvoke.DeleteObject(hBitmap); + } + + if (factory != null) + { + factory->Release(); + } + } + } + + private static unsafe SoftwareBitmap CreateSoftwareBitmap(HBITMAP hBitmap, int size) + { + var pixels = new byte[size * size * 4]; + + var bmi = new BITMAPINFO + { + bmiHeader = new BITMAPINFOHEADER + { + biSize = (uint)sizeof(BITMAPINFOHEADER), + biWidth = size, + biHeight = -size, + biPlanes = 1, + biBitCount = 32, + biCompression = 0, + }, + }; + + var hdc = PInvoke.GetDC(default); + try + { + fixed (byte* pPixels = pixels) + { + _ = PInvoke.GetDIBits( + hdc, + hBitmap, + 0, + (uint)size, + pPixels, + &bmi, + DIB_USAGE.DIB_RGB_COLORS); + } + } + finally + { + _ = PInvoke.ReleaseDC(default, hdc); + } + + var bitmap = new SoftwareBitmap(BitmapPixelFormat.Bgra8, size, size, BitmapAlphaMode.Premultiplied); + bitmap.CopyFromBuffer(pixels.AsBuffer()); + return bitmap; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconSearchResult.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconSearchResult.cs new file mode 100644 index 0000000000..51c8a142cf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/IconSearchResult.cs @@ -0,0 +1,44 @@ +// 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.Ext.Apps.Programs; + +namespace Microsoft.CmdPal.Ext.Apps.Helpers; + +/// <summary> +/// Result of an icon search operation. +/// </summary> +internal readonly record struct IconSearchResult( + string? LogoPath, + LogoType LogoType, + bool IsTargetSizeIcon, + int? KnownSize = null) +{ + /// <summary> + /// Gets a value indicating whether an icon was found. + /// </summary> + public bool IsFound => LogoPath is not null; + + /// <summary> + /// Returns true if we can confirm the icon meets the minimum size. + /// Only possible for targetsize icons where the size is encoded in the filename. + /// </summary> + public bool MeetsMinimumSize(int minimumSize) => + IsTargetSizeIcon && KnownSize >= minimumSize; + + /// <summary> + /// Returns true if we know the icon is undersized. + /// Returns false if not found, or if size is unknown (scale-based icons). + /// </summary> + public bool IsKnownUndersized(int minimumSize) => + IsTargetSizeIcon && KnownSize < minimumSize; + + public static IconSearchResult NotFound() => new(null, default, false); + + public static IconSearchResult FoundTargetSize(string path, LogoType logoType, int size) + => new(path, logoType, IsTargetSizeIcon: true, size); + + public static IconSearchResult FoundScaled(string path, LogoType logoType) + => new(path, logoType, IsTargetSizeIcon: false); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/UninstallRegistryAppLocator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/UninstallRegistryAppLocator.cs new file mode 100644 index 0000000000..8e59a26395 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Helpers/UninstallRegistryAppLocator.cs @@ -0,0 +1,205 @@ +// 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.IO; +using System.Linq; +using Microsoft.Win32; + +namespace Microsoft.CmdPal.Ext.Apps.Helpers; + +internal static class UninstallRegistryAppLocator +{ + private static readonly string[] UninstallBaseKeys = + [ + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", + ]; + + /// <summary> + /// Tries to find install directory and a list of plausible main EXEs from an uninstall key + /// (e.g. Inno Setup keys like "{guid}_is1"). + /// <paramref name="exeCandidates"/> may be empty if we couldn't pick any safe EXEs. + /// </summary> + /// <returns> + /// Returns true if the uninstall key is found and an install directory is resolved. + /// </returns> + public static bool TryGetInstallInfo( + string uninstallKeyName, + out string? installDir, + out IReadOnlyList<string> exeCandidates, + string? expectedExeName = null) + { + installDir = null; + exeCandidates = []; + + if (string.IsNullOrWhiteSpace(uninstallKeyName)) + { + throw new ArgumentException("Key name must not be null or empty.", nameof(uninstallKeyName)); + } + + uninstallKeyName = uninstallKeyName.Trim(); + + foreach (var baseKeyPath in UninstallBaseKeys) + { + // HKLM + using (var key = Registry.LocalMachine.OpenSubKey($"{baseKeyPath}\\{uninstallKeyName}")) + { + if (TryFromUninstallKey(key, expectedExeName, out installDir, out exeCandidates)) + { + return true; + } + } + + // HKCU + using (var key = Registry.CurrentUser.OpenSubKey($"{baseKeyPath}\\{uninstallKeyName}")) + { + if (TryFromUninstallKey(key, expectedExeName, out installDir, out exeCandidates)) + { + return true; + } + } + } + + return false; + } + + private static bool TryFromUninstallKey( + RegistryKey? key, + string? expectedExeName, + out string? installDir, + out IReadOnlyList<string> exeCandidates) + { + installDir = null; + exeCandidates = []; + + if (key is null) + { + return false; + } + + var location = (key.GetValue("InstallLocation") as string)?.Trim('"', ' ', '\t'); + if (string.IsNullOrEmpty(location)) + { + location = (key.GetValue("Inno Setup: App Path") as string)?.Trim('"', ' ', '\t'); + } + + if (string.IsNullOrEmpty(location)) + { + var uninstall = key.GetValue("UninstallString") as string; + var uninsExe = ExtractFirstPath(uninstall); + if (!string.IsNullOrEmpty(uninsExe)) + { + var dir = Path.GetDirectoryName(uninsExe); + if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) + { + location = dir; + } + } + } + + if (string.IsNullOrEmpty(location) || !Directory.Exists(location)) + { + return false; + } + + installDir = location; + + // Collect safe EXE candidates; may be empty if ambiguous or only uninstall exes exist. + exeCandidates = GetExeCandidates(location, expectedExeName); + return true; + } + + private static IReadOnlyList<string> GetExeCandidates(string root, string? expectedExeName) + { + // Look at root and a "bin" subfolder (very common pattern) + var allExes = Directory.EnumerateFiles(root, "*.exe", SearchOption.TopDirectoryOnly) + .Concat(GetBinExes(root)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (allExes.Length == 0) + { + return []; + } + + var result = new List<string>(); + + // 1) Exact match on expected exe name (if provided), ignoring case, and not uninstall/setup-like. + if (!string.IsNullOrWhiteSpace(expectedExeName)) + { + foreach (var exe in allExes) + { + if (string.Equals(Path.GetFileName(exe), expectedExeName, StringComparison.OrdinalIgnoreCase) && + !LooksLikeUninstallerOrSetup(exe)) + { + result.Add(exe); + } + } + } + + // 2) All other non-uninstall/setup exes + foreach (var exe in allExes) + { + if (LooksLikeUninstallerOrSetup(exe)) + { + continue; + } + + // Skip ones already added as expectedExeName matches + if (result.Contains(exe, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + result.Add(exe); + } + + // 3) We intentionally do NOT add uninstall/setup/update exes here. + // If you ever want them, you can add a separate API to expose them. + return result; + } + + private static IEnumerable<string> GetBinExes(string root) + { + var bin = Path.Combine(root, "bin"); + return !Directory.Exists(bin) + ? [] + : Directory.EnumerateFiles(bin, "*.exe", SearchOption.TopDirectoryOnly); + } + + private static bool LooksLikeUninstallerOrSetup(string path) + { + var name = Path.GetFileName(path); + return name.StartsWith("unins", StringComparison.OrdinalIgnoreCase) // e.g. Inno: unins000.exe + || name.Contains("setup", StringComparison.OrdinalIgnoreCase) // setup.exe + || name.Contains("installer", StringComparison.OrdinalIgnoreCase) // installer.exe / MyAppInstaller.exe + || name.Contains("update", StringComparison.OrdinalIgnoreCase); // updater/updater.exe + } + + private static string? ExtractFirstPath(string? commandLine) + { + if (string.IsNullOrWhiteSpace(commandLine)) + { + return null; + } + + commandLine = commandLine.Trim(); + + if (commandLine.StartsWith('"')) + { + var endQuote = commandLine.IndexOf('"', 1); + if (endQuote > 1) + { + return commandLine[1..endQuote]; + } + } + + var firstSpace = commandLine.IndexOf(' '); + var candidate = firstSpace > 0 ? commandLine[..firstSpace] : commandLine; + candidate = candidate.Trim('"'); + return candidate.Length > 0 ? candidate : null; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.cs new file mode 100644 index 0000000000..b6e2b94ef5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/IAppCache.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; +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.Apps.Programs; + +namespace Microsoft.CmdPal.Ext.Apps; + +/// <summary> +/// Interface for application cache that provides access to Win32 and UWP applications. +/// </summary> +public interface IAppCache : IDisposable +{ + /// <summary> + /// Gets the collection of Win32 programs. + /// </summary> + IList<Win32Program> Win32s { get; } + + /// <summary> + /// Gets the collection of UWP applications. + /// </summary> + IList<IUWPApplication> UWPs { get; } + + /// <summary> + /// Determines whether the cache should be reloaded. + /// </summary> + /// <returns>True if cache should be reloaded, false otherwise.</returns> + bool ShouldReload(); + + /// <summary> + /// Resets the reload flag. + /// </summary> + void ResetReloadFlag(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs new file mode 100644 index 0000000000..47e012dcc2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Icons.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps; + +internal static class Icons +{ + internal static IconInfo AllAppsIcon { get; } = IconHelpers.FromRelativePath("Assets\\AllApps.svg"); + + internal static IconInfo RunAsUserIcon { get; } = new("\uE7EE"); // OtherUser icon + + internal static IconInfo RunAsAdminIcon { get; } = new("\uE7EF"); // Admin icon + + internal static IconInfo OpenPathIcon { get; } = new("\ue838"); // Folder Open icon + + internal static IconInfo CopyIcon { get; } = new("\ue8c8"); // Copy icon + + public static IconInfo UnpinIcon { get; } = new("\uE77A"); // Unpin icon + + public static IconInfo PinIcon { get; } = new("\uE840"); // Pin icon + + public static IconInfo UninstallApplicationIcon { get; } = new("\uE74D"); // Uninstall icon + + public static IconInfo GenericAppIcon { get; } = new("\uE737"); // Favicon +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs new file mode 100644 index 0000000000..0db868222c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/JsonSerializationContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.CmdPal.Ext.Apps.State; + +namespace Microsoft.CmdPal.Ext.Apps; + +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(PinnedApps))] +[JsonSerializable(typeof(List<string>), TypeInfoPropertyName = "StringList")] +[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] +internal sealed partial class JsonSerializationContext : JsonSerializerContext +{ +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/KeyChords.cs new file mode 100644 index 0000000000..14ca0cf1c7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/KeyChords.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Apps; + +internal static class KeyChords +{ + internal static KeyChord OpenFileLocation { get; } = WellKnownKeyChords.OpenFileLocation; + + internal static KeyChord CopyFilePath { get; } = WellKnownKeyChords.CopyFilePath; + + internal static KeyChord OpenInConsole { get; } = WellKnownKeyChords.OpenInConsole; + + internal static KeyChord RunAsAdministrator { get; } = WellKnownKeyChords.RunAsAdministrator; + + internal static KeyChord RunAsDifferentUser { get; } = WellKnownKeyChords.RunAsDifferentUser; + + internal static KeyChord TogglePin { get; } = WellKnownKeyChords.TogglePin; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/LocalSuppressions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/LocalSuppressions.cs new file mode 100644 index 0000000000..87a0cf5673 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/LocalSuppressions.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Interoperability", "CsWinRT1028: Class should be marked partial", Justification = "CsWin32 generated code; not used across WinRT boundary", Scope = "type", Target = "~T:Windows.Win32.SysFreeStringSafeHandle")] diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj similarity index 64% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj index 24782335ed..39f0e46f76 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj @@ -1,11 +1,17 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.Apps</RootNamespace> <Nullable>enable</Nullable> <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <DisableRuntimeMarshalling>true</DisableRuntimeMarshalling> + + <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> + <ProjectPriFileName>Microsoft.CmdPal.Ext.Apps.pri</ProjectPriFileName> </PropertyGroup> <ItemGroup> @@ -18,8 +24,9 @@ <PackageReference Include="WyHash" /> <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> - - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" /> + <ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> + <!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props --> </ItemGroup> <ItemGroup> @@ -49,4 +56,9 @@ <LastGenOutput>Resources.Designer.cs</LastGenOutput> </EmbeddedResource> </ItemGroup> + + <ItemGroup> + <AdditionalFiles Include="NativeMethods.txt" /> + <AdditionalFiles Include="NativeMethods.json" /> + </ItemGroup> </Project> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.json new file mode 100644 index 0000000000..b1156c41b7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false, + "comInterop": { + "preserveSigMethods": [ "*" ] + } +} \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt new file mode 100644 index 0000000000..86138d3fb2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/NativeMethods.txt @@ -0,0 +1,26 @@ +IStream +CoCreateInstance +IApplicationActivationManager +ApplicationActivationManager +SHCreateStreamOnFileEx +SHCreateItemFromParsingName +IShellItem +ISequentialStream +SHLoadIndirectString +IAppxFactory +AppxFactory +IAppxManifestReader +IAppxManifestApplicationsEnumerator +IAppxManifestApplication +IAppxManifestProperties +IShellLinkW +ShellLink +IPersistFile +CoTaskMemFree +IUnknown +IShellItemImageFactory +DeleteObject +GetDIBits +GetDC +ReleaseDC +SIIGBF diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/AppxPackageHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/AppxPackageHelper.cs new file mode 100644 index 0000000000..61f581d2dd --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/AppxPackageHelper.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Apps.Utils; +using Windows.Win32; +using Windows.Win32.Storage.Packaging.Appx; +using Windows.Win32.System.Com; + +namespace Microsoft.CmdPal.Ext.Apps.Programs; + +public static class AppxPackageHelper +{ + internal static unsafe List<IntPtr> GetAppsFromManifest(IStream* stream) + { + PInvoke.CoCreateInstance(typeof(AppxFactory).GUID, null, CLSCTX.CLSCTX_INPROC_SERVER, out IAppxFactory* appxFactory).ThrowOnFailure(); + using var handle = new SafeComHandle((IntPtr)appxFactory); + + IAppxManifestReader* reader = null; + IAppxManifestApplicationsEnumerator* manifestApps = null; + var result = new List<IntPtr>(); + + appxFactory->CreateManifestReader(stream, &reader); + using var readerHandle = new SafeComHandle((IntPtr)reader); + reader->GetApplications(&manifestApps); + using var manifestAppsHandle = new SafeComHandle((IntPtr)manifestApps); + + while (true) + { + manifestApps->GetHasCurrent(out var hasCurrent); + if (hasCurrent == false) + { + break; + } + + IAppxManifestApplication* manifestApp = null; + + try + { + manifestApps->GetCurrent(&manifestApp).ThrowOnFailure(); + + var hr = manifestApp->GetStringValue("AppListEntry", out var appListEntryPtr); + var appListEntry = ComFreeHelper.GetStringAndFree(hr, appListEntryPtr); + + if (appListEntry != "none") + { + result.Add((IntPtr)manifestApp); + } + else if (manifestApp is not null) + { + manifestApp->Release(); + } + } + catch (Exception ex) + { + if (manifestApp is not null) + { + manifestApp->Release(); + } + + Logger.LogError($"Failed to get current application from manifest: {ex.Message}"); + } + + manifestApps->MoveNext(out var hasNext); + if (hasNext == false) + { + break; + } + } + + return result; + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/DisabledProgramSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/DisabledProgramSource.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/DisabledProgramSource.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/DisabledProgramSource.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/IFileVersionInfoWrapper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IFileVersionInfoWrapper.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/IFileVersionInfoWrapper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IFileVersionInfoWrapper.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IPackage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IPackage.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IPackage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IPackage.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/IPackageCatalog.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IPackageCatalog.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/IPackageCatalog.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IPackageCatalog.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IPackageManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IPackageManager.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IPackageManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IPackageManager.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IProgram.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IProgram.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/IProgram.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IProgram.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IUWPApplication.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IUWPApplication.cs new file mode 100644 index 0000000000..775bcaab4a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/IUWPApplication.cs @@ -0,0 +1,43 @@ +// 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 Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.Programs; + +/// <summary> +/// Interface for UWP applications to enable testing and mocking +/// </summary> +public interface IUWPApplication : IProgram +{ + string AppListEntry { get; set; } + + string DisplayName { get; set; } + + string UserModelId { get; set; } + + string BackgroundColor { get; set; } + + string EntryPoint { get; set; } + + bool CanRunElevated { get; set; } + + string LogoPath { get; set; } + + LogoType LogoType { get; set; } + + UWP Package { get; set; } + + string LocationLocalized { get; } + + string GetAppIdentifier(); + + List<IContextItem> GetCommands(); + + void UpdateLogoPath(Utils.Theme theme); + + AppItem ToAppItem(); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/LogoType.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/LogoType.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/LogoType.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/LogoType.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/PackageCatalogWrapper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageCatalogWrapper.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/PackageCatalogWrapper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageCatalogWrapper.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs similarity index 68% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs index be70a0ba95..79a7ee14fc 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageManagerWrapper.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; -using System.Linq; using System.Security.Principal; using Windows.Management.Deployment; @@ -22,13 +21,23 @@ public class PackageManagerWrapper : IPackageManager { var user = WindowsIdentity.GetCurrent().User; - if (user != null) + if (user is not null) { var pkgs = _packageManager.FindPackagesForUser(user.Value); - return pkgs.Select(PackageWrapper.GetWrapperFromPackage).Where(package => package != null); + ICollection<IPackage> packages = []; + + foreach (var package in pkgs) + { + if (package is not null) + { + packages.Add(PackageWrapper.GetWrapperFromPackage(package)); + } + } + + return packages; } - return Enumerable.Empty<IPackage>(); + return []; } } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/PackageWrapper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageWrapper.cs similarity index 99% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/PackageWrapper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageWrapper.cs index 2de128c05c..108195390e 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/PackageWrapper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/PackageWrapper.cs @@ -4,6 +4,7 @@ using System; using System.IO; +using ManagedCommon; using Windows.Foundation.Metadata; using Package = Windows.ApplicationModel.Package; diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/ProgramSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/ProgramSource.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/ProgramSource.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/ProgramSource.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/ReparsePoint.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/ReparsePoint.cs similarity index 50% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/ReparsePoint.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/ReparsePoint.cs index b7fbca811d..bdbce1bfc3 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Programs/ReparsePoint.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/ReparsePoint.cs @@ -3,20 +3,19 @@ // See the LICENSE file in the project root for more information. using System; -using System.ComponentModel; using System.IO; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; - +using ManagedCsWin32; using Microsoft.Win32.SafeHandles; -using Windows.Storage.Streams; namespace Microsoft.CmdPal.Ext.Apps.Programs; /// <summary> /// Provides access to NTFS reparse points in .Net. /// </summary> -public static class ReparsePoint +public static partial class ReparsePoint { #pragma warning disable SA1310 // Field names should not contain underscore @@ -35,118 +34,6 @@ public static class ReparsePoint private const int E_INVALID_PROTOCOL_FORMAT = unchecked((int)0x83760002); #pragma warning restore SA1310 // Field names should not contain underscore - [Flags] - private enum FileAccessType : uint - { - DELETE = 0x00010000, - READ_CONTROL = 0x00020000, - WRITE_DAC = 0x00040000, - WRITE_OWNER = 0x00080000, - SYNCHRONIZE = 0x00100000, - - STANDARD_RIGHTS_REQUIRED = 0x000F0000, - - STANDARD_RIGHTS_READ = READ_CONTROL, - STANDARD_RIGHTS_WRITE = READ_CONTROL, - STANDARD_RIGHTS_EXECUTE = READ_CONTROL, - - STANDARD_RIGHTS_ALL = 0x001F0000, - - SPECIFIC_RIGHTS_ALL = 0x0000FFFF, - - ACCESS_SYSTEM_SECURITY = 0x01000000, - - MAXIMUM_ALLOWED = 0x02000000, - - GENERIC_READ = 0x80000000, - GENERIC_WRITE = 0x40000000, - GENERIC_EXECUTE = 0x20000000, - GENERIC_ALL = 0x10000000, - - FILE_READ_DATA = 0x0001, - FILE_WRITE_DATA = 0x0002, - FILE_APPEND_DATA = 0x0004, - FILE_READ_EA = 0x0008, - FILE_WRITE_EA = 0x0010, - FILE_EXECUTE = 0x0020, - FILE_READ_ATTRIBUTES = 0x0080, - FILE_WRITE_ATTRIBUTES = 0x0100, - - FILE_ALL_ACCESS = - STANDARD_RIGHTS_REQUIRED | - SYNCHRONIZE - | 0x1FF, - - FILE_GENERIC_READ = - STANDARD_RIGHTS_READ | - FILE_READ_DATA | - FILE_READ_ATTRIBUTES | - FILE_READ_EA | - SYNCHRONIZE, - - FILE_GENERIC_WRITE = - STANDARD_RIGHTS_WRITE | - FILE_WRITE_DATA | - FILE_WRITE_ATTRIBUTES | - FILE_WRITE_EA | - FILE_APPEND_DATA | - SYNCHRONIZE, - - FILE_GENERIC_EXECUTE = - STANDARD_RIGHTS_EXECUTE | - FILE_READ_ATTRIBUTES | - FILE_EXECUTE | - SYNCHRONIZE, - } - - [Flags] - private enum FileShareType : uint - { - None = 0x00000000, - Read = 0x00000001, - Write = 0x00000002, - Delete = 0x00000004, - } - - private enum CreationDisposition : uint - { - New = 1, - CreateAlways = 2, - OpenExisting = 3, - OpenAlways = 4, - TruncateExisting = 5, - } - - [Flags] - private enum FileAttributes : uint - { - Readonly = 0x00000001, - Hidden = 0x00000002, - System = 0x00000004, - Directory = 0x00000010, - Archive = 0x00000020, - Device = 0x00000040, - Normal = 0x00000080, - Temporary = 0x00000100, - SparseFile = 0x00000200, - ReparsePoint = 0x00000400, - Compressed = 0x00000800, - Offline = 0x00001000, - NotContentIndexed = 0x00002000, - Encrypted = 0x00004000, - Write_Through = 0x80000000, - Overlapped = 0x40000000, - NoBuffering = 0x20000000, - RandomAccess = 0x10000000, - SequentialScan = 0x08000000, - DeleteOnClose = 0x04000000, - BackupSemantics = 0x02000000, - PosixSemantics = 0x01000000, - OpenReparsePoint = 0x00200000, - OpenNoRecall = 0x00100000, - FirstPipeInstance = 0x00080000, - } - private enum AppExecutionAliasReparseTagBufferLayoutVersion : uint { Invalid = 0, @@ -195,27 +82,6 @@ public static class ReparsePoint public AppExecutionAliasReparseTagBufferLayoutVersion Version; } - [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] - private static extern bool DeviceIoControl( - IntPtr hDevice, - uint dwIoControlCode, - IntPtr inBuffer, - int nInBufferSize, - IntPtr outBuffer, - int nOutBufferSize, - out int pBytesReturned, - IntPtr lpOverlapped); - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - private static extern IntPtr CreateFile( - string lpFileName, - FileAccessType dwDesiredAccess, - FileShareType dwShareMode, - IntPtr lpSecurityAttributes, - CreationDisposition dwCreationDisposition, - FileAttributes dwFlagsAndAttributes, - IntPtr hTemplateFile); - /// <summary> /// Gets the target of the specified reparse point. /// </summary> @@ -229,13 +95,13 @@ public static class ReparsePoint public static string? GetTarget(string reparsePoint) { using (SafeFileHandle reparsePointHandle = new SafeFileHandle( - CreateFile( + Kernel32.CreateFile( reparsePoint, FileAccessType.FILE_READ_ATTRIBUTES | FileAccessType.FILE_READ_EA, FileShareType.Delete | FileShareType.Read | FileShareType.Write, IntPtr.Zero, CreationDisposition.OpenExisting, - FileAttributes.OpenReparsePoint, + ManagedCsWin32.FileAttributes.OpenReparsePoint, IntPtr.Zero), true)) { @@ -253,7 +119,7 @@ public static class ReparsePoint for (var i = 0; i < 2; ++i) { int bytesReturned; - var result = DeviceIoControl( + var result = Kernel32.DeviceIoControl( reparsePointHandle.DangerousGetHandle(), FSCTL_GET_REPARSE_POINT, IntPtr.Zero, @@ -284,15 +150,18 @@ public static class ReparsePoint ThrowLastWin32Error("Unable to get information about reparse point."); } - AppExecutionAliasReparseTagHeader aliasReparseHeader = Marshal.PtrToStructure<AppExecutionAliasReparseTagHeader>(outBuffer); - - if (aliasReparseHeader.ReparseTag == IO_REPARSE_TAG_APPEXECLINK) + unsafe { - var metadata = AppExecutionAliasMetadata.FromPersistedRepresentationIntPtr( - outBuffer, - aliasReparseHeader.Version); + var aliasReparseHeader = Unsafe.Read<AppExecutionAliasReparseTagHeader>((void*)outBuffer); - return metadata.ExePath; + if (aliasReparseHeader.ReparseTag == IO_REPARSE_TAG_APPEXECLINK) + { + var metadata = AppExecutionAliasMetadata.FromPersistedRepresentationIntPtr( + outBuffer, + aliasReparseHeader.Version); + + return metadata.ExePath; + } } return null; @@ -319,61 +188,65 @@ public static class ReparsePoint public static AppExecutionAliasMetadata FromPersistedRepresentationIntPtr(IntPtr reparseDataBufferPtr, AppExecutionAliasReparseTagBufferLayoutVersion version) { - var dataOffset = Marshal.SizeOf(typeof(AppExecutionAliasReparseTagHeader)); - var dataBufferPtr = reparseDataBufferPtr + dataOffset; - - string? packageFullName = null; - string? packageFamilyName = null; - string? aumid = null; - string? exePath = null; - - VerifyVersion(version); - - switch (version) + unsafe { - case AppExecutionAliasReparseTagBufferLayoutVersion.Initial: - packageFullName = Marshal.PtrToStringUni(dataBufferPtr); - if (packageFullName is not null) - { - dataBufferPtr += Encoding.Unicode.GetByteCount(packageFullName) + Encoding.Unicode.GetByteCount("\0"); - aumid = Marshal.PtrToStringUni(dataBufferPtr); + var dataOffset = Unsafe.SizeOf<AppExecutionAliasReparseTagHeader>(); - if (aumid is not null) + var dataBufferPtr = reparseDataBufferPtr + dataOffset; + + string? packageFullName = null; + string? packageFamilyName = null; + string? aumid = null; + string? exePath = null; + + VerifyVersion(version); + + switch (version) + { + case AppExecutionAliasReparseTagBufferLayoutVersion.Initial: + packageFullName = Marshal.PtrToStringUni(dataBufferPtr); + if (packageFullName is not null) { - dataBufferPtr += Encoding.Unicode.GetByteCount(aumid) + Encoding.Unicode.GetByteCount("\0"); - exePath = Marshal.PtrToStringUni(dataBufferPtr); + dataBufferPtr += Encoding.Unicode.GetByteCount(packageFullName) + Encoding.Unicode.GetByteCount("\0"); + aumid = Marshal.PtrToStringUni(dataBufferPtr); + + if (aumid is not null) + { + dataBufferPtr += Encoding.Unicode.GetByteCount(aumid) + Encoding.Unicode.GetByteCount("\0"); + exePath = Marshal.PtrToStringUni(dataBufferPtr); + } } - } - break; + break; - case AppExecutionAliasReparseTagBufferLayoutVersion.PackageFamilyName: - case AppExecutionAliasReparseTagBufferLayoutVersion.MultiAppTypeSupport: - packageFamilyName = Marshal.PtrToStringUni(dataBufferPtr); + case AppExecutionAliasReparseTagBufferLayoutVersion.PackageFamilyName: + case AppExecutionAliasReparseTagBufferLayoutVersion.MultiAppTypeSupport: + packageFamilyName = Marshal.PtrToStringUni(dataBufferPtr); - if (packageFamilyName is not null) - { - dataBufferPtr += Encoding.Unicode.GetByteCount(packageFamilyName) + Encoding.Unicode.GetByteCount("\0"); - aumid = Marshal.PtrToStringUni(dataBufferPtr); - - if (aumid is not null) + if (packageFamilyName is not null) { - dataBufferPtr += Encoding.Unicode.GetByteCount(aumid) + Encoding.Unicode.GetByteCount("\0"); + dataBufferPtr += Encoding.Unicode.GetByteCount(packageFamilyName) + Encoding.Unicode.GetByteCount("\0"); + aumid = Marshal.PtrToStringUni(dataBufferPtr); - exePath = Marshal.PtrToStringUni(dataBufferPtr); + if (aumid is not null) + { + dataBufferPtr += Encoding.Unicode.GetByteCount(aumid) + Encoding.Unicode.GetByteCount("\0"); + + exePath = Marshal.PtrToStringUni(dataBufferPtr); + } } - } - break; + break; + } + + return new AppExecutionAliasMetadata + { + PackageFullName = packageFullName ?? string.Empty, + PackageFamilyName = packageFamilyName ?? string.Empty, + Aumid = aumid ?? string.Empty, + ExePath = exePath ?? string.Empty, + }; } - - return new AppExecutionAliasMetadata - { - PackageFullName = packageFullName ?? string.Empty, - PackageFamilyName = packageFamilyName ?? string.Empty, - Aumid = aumid ?? string.Empty, - ExePath = exePath ?? string.Empty, - }; } private static void VerifyVersion(AppExecutionAliasReparseTagBufferLayoutVersion version) diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs similarity index 50% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs index beba2b185a..faca2c2b39 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWP.cs @@ -3,15 +3,16 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO.Abstractions; -using System.Linq; +using System.Threading.Tasks; using System.Xml.Linq; +using ManagedCommon; using Microsoft.CmdPal.Ext.Apps.Utils; using Windows.Win32; -using Windows.Win32.Foundation; +using Windows.Win32.Storage.Packaging.Appx; using Windows.Win32.System.Com; -using static Microsoft.CmdPal.Ext.Apps.Utils.Native; namespace Microsoft.CmdPal.Ext.Apps.Programs; @@ -53,7 +54,7 @@ public partial class UWP FamilyName = package.FamilyName; } - public void InitializeAppInfo(string installedLocation) + public unsafe void InitializeAppInfo(string installedLocation) { Location = installedLocation; LocationLocalized = ShellLocalization.Instance.GetLocalizedPath(installedLocation); @@ -63,42 +64,61 @@ public partial class UWP InitPackageVersion(namespaces); const uint noAttribute = 0x80; - - var access = (uint)STGM.READ; - var hResult = PInvoke.SHCreateStreamOnFileEx(path, access, noAttribute, false, null, out IStream stream); - - // S_OK - if (hResult == 0) + const uint STGMREAD = 0x00000000; + try { - Apps = AppxPackageHelper.GetAppsFromManifest(stream).Select(appInManifest => new UWPApplication(appInManifest, this)).Where(a => - { - var valid = - !string.IsNullOrEmpty(a.UserModelId) && - !string.IsNullOrEmpty(a.DisplayName) && - a.AppListEntry != "none"; + IStream* stream = null; + PInvoke.SHCreateStreamOnFileEx(path, STGMREAD, noAttribute, false, null, &stream).ThrowOnFailure(); + using var streamHandle = new SafeComHandle((IntPtr)stream); - return valid; - }).ToList(); + var appsInManifest = AppxPackageHelper.GetAppsFromManifest(stream); + + foreach (var appInManifest in appsInManifest) + { + using var appHandle = new SafeComHandle(appInManifest); + var uwpApp = new UWPApplication((IAppxManifestApplication*)appInManifest, this); + + if (!string.IsNullOrEmpty(uwpApp.UserModelId) && + !string.IsNullOrEmpty(uwpApp.DisplayName) && + uwpApp.AppListEntry != "none") + { + Apps.Add(uwpApp); + } + } } - else + catch (Exception ex) { Apps = Array.Empty<UWPApplication>(); + Logger.LogError($"Failed to initialize UWP app info for {Name} ({FullName}): {ex.Message}"); + return; } } - // http://www.hanselman.com/blog/GetNamespacesFromAnXMLDocumentWithXPathDocumentAndLINQToXML.aspx private static string[] XmlNamespaces(string path) { var z = XDocument.Load(path); - if (z.Root != null) + if (z.Root is not null) { - var namespaces = z.Root.Attributes(). - Where(a => a.IsNamespaceDeclaration). - GroupBy( - a => a.Name.Namespace == XNamespace.None ? string.Empty : a.Name.LocalName, - a => XNamespace.Get(a.Value)).Select( - g => g.First().ToString()).ToArray(); - return namespaces; + var namespaces = new HashSet<string>(); + + var attributes = z.Root.Attributes(); + foreach (var attribute in attributes) + { + if (attribute.IsNamespaceDeclaration) + { + // Extract namespace + var key = attribute.Name.Namespace == XNamespace.None ? string.Empty : attribute.Name.LocalName; + XNamespace ns = XNamespace.Get(attribute.Value); + var nsString = ns.ToString(); + + // Use HashSet to check for duplicates + namespaces.Add(nsString); + } + } + + var uniqueNamespaces = new string[namespaces.Count]; + namespaces.CopyTo(uniqueNamespaces); + return uniqueNamespaces; } else { @@ -108,10 +128,13 @@ public partial class UWP private void InitPackageVersion(string[] namespaces) { - foreach (var n in _versionFromNamespace.Keys.Where(namespaces.Contains)) + foreach (var n in _versionFromNamespace.Keys) { - Version = _versionFromNamespace[n]; - return; + if (Array.IndexOf(namespaces, n) >= 0) + { + Version = _versionFromNamespace[n]; + return; + } } Version = PackageVersion.Unknown; @@ -119,53 +142,67 @@ public partial class UWP public static UWPApplication[] All() { - var windows10 = new Version(10, 0); - var support = Environment.OSVersion.Version.Major >= windows10.Major; - if (support) + var appsBag = new ConcurrentBag<UWPApplication>(); + + Parallel.ForEach(CurrentUserPackages(), p => { - var applications = CurrentUserPackages().AsParallel().SelectMany(p => + try { - UWP u; - try + var u = new UWP(p); + u.InitializeAppInfo(p.InstalledLocation); + + foreach (var app in u.Apps) { - u = new UWP(p); - u.InitializeAppInfo(p.InstalledLocation); - } - catch (Exception ) - { - return Array.Empty<UWPApplication>(); + var isDisabled = false; + + foreach (var disabled in AllAppsSettings.Instance.DisabledProgramSources) + { + if (disabled.UniqueIdentifier == app.UniqueIdentifier) + { + isDisabled = true; + break; + } + } + + if (!isDisabled) + { + appsBag.Add(app); + } } + } + catch (Exception ex) + { + Logger.LogError(ex.Message); + } + }); - return u.Apps; - }); - - var updatedListWithoutDisabledApps = applications - .Where(t1 => AllAppsSettings.Instance.DisabledProgramSources.All(x => x.UniqueIdentifier != t1.UniqueIdentifier)) - .Select(x => x); - - return updatedListWithoutDisabledApps.ToArray(); - } - else - { - return Array.Empty<UWPApplication>(); - } + return appsBag.ToArray(); } private static IEnumerable<IPackage> CurrentUserPackages() { - return PackageManagerWrapper.FindPackagesForCurrentUser().Where(p => + var currentUsersPackages = PackageManagerWrapper.FindPackagesForCurrentUser(); + ICollection<IPackage> packagesToReturn = []; + + foreach (var pkg in currentUsersPackages) { try { - var f = p.IsFramework; - var path = p.InstalledLocation; - return !f && !string.IsNullOrEmpty(path); + var f = pkg.IsFramework; + var path = pkg.InstalledLocation; + + if (!f && !string.IsNullOrEmpty(path)) + { + packagesToReturn.Add(pkg); + } } - catch (Exception ) + catch (Exception ex) { - return false; + Logger.LogError(ex.Message); } - }); + } + + return packagesToReturn; } public override string ToString() diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs new file mode 100644 index 0000000000..91b08d3b86 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/UWPApplication.cs @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Xml; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Apps.Commands; +using Microsoft.CmdPal.Ext.Apps.Helpers; +using Microsoft.CmdPal.Ext.Apps.Properties; +using Microsoft.CmdPal.Ext.Apps.Utils; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; +using Windows.Win32; +using Windows.Win32.Storage.Packaging.Appx; +using PackageVersion = Microsoft.CmdPal.Ext.Apps.Programs.UWP.PackageVersion; +using Theme = Microsoft.CmdPal.Ext.Apps.Utils.Theme; + +namespace Microsoft.CmdPal.Ext.Apps.Programs; + +[Serializable] +public class UWPApplication : IUWPApplication +{ + private const int ListIconSize = 20; + private const int JumboIconSize = 64; + + private static readonly IFileSystem FileSystem = new FileSystem(); + private static readonly IFile File = FileSystem.File; + + public string AppListEntry { get; set; } = string.Empty; + + public string UniqueIdentifier { get; set; } + + public string DisplayName { get; set; } + + public string Description { get; set; } + + public string UserModelId { get; set; } + + public string BackgroundColor { get; set; } + + public string EntryPoint { get; set; } + + public string Name => DisplayName; + + public string Location => Package.Location; + + // Localized path based on windows display language + public string LocationLocalized => Package.LocationLocalized; + + public bool Enabled { get; set; } + + public bool CanRunElevated { get; set; } + + public string LogoPath { get; set; } = string.Empty; + + public LogoType LogoType { get; set; } + + public string JumboLogoPath { get; set; } = string.Empty; + + public LogoType JumboLogoType { get; set; } + + public UWP Package { get; set; } + + private string _logoUri; + + private string _jumboLogoUri; + + // Function to set the subtitle based on the Type of application + public static string Type() + { + return Resources.packaged_application; + } + + public string GetAppIdentifier() + { + // Use UserModelId for UWP apps as it's unique + return UserModelId; + } + + public List<IContextItem> GetCommands() + { + List<IContextItem> commands = []; + + if (CanRunElevated) + { + commands.Add( + new CommandContextItem( + new RunAsAdminCommand(UniqueIdentifier, string.Empty, true)) + { + RequestedShortcut = KeyChords.RunAsAdministrator, + }); + + // We don't add context menu to 'run as different user', because UWP applications normally installed per user and not for all users. + } + + commands.Add( + new CommandContextItem( + new CopyPathCommand(Location)) + { + RequestedShortcut = KeyChords.CopyFilePath, + }); + + commands.Add( + new CommandContextItem( + new OpenFileCommand(Location) + { + Icon = new("\uE838"), + Name = Resources.open_location, + }) + { + RequestedShortcut = KeyChords.OpenFileLocation, + }); + + commands.Add( + new CommandContextItem( + new OpenInConsoleCommand(Package.Location)) + { + RequestedShortcut = KeyChords.OpenInConsole, + }); + + commands.Add( + new CommandContextItem( + new UninstallApplicationConfirmation(this)) + { + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete), + IsCritical = true, + }); + + return commands; + } + + internal unsafe UWPApplication(IAppxManifestApplication* manifestApp, UWP package) + { + ArgumentNullException.ThrowIfNull(manifestApp); + + var hr = manifestApp->GetAppUserModelId(out var tmpUserModelIdPtr); + UserModelId = ComFreeHelper.GetStringAndFree(hr, tmpUserModelIdPtr); + + manifestApp->GetAppUserModelId(out var tmpUniqueIdentifierPtr); + UniqueIdentifier = ComFreeHelper.GetStringAndFree(hr, tmpUniqueIdentifierPtr); + + manifestApp->GetStringValue("DisplayName", out var tmpDisplayNamePtr); + DisplayName = ComFreeHelper.GetStringAndFree(hr, tmpDisplayNamePtr); + + manifestApp->GetStringValue("Description", out var tmpDescriptionPtr); + Description = ComFreeHelper.GetStringAndFree(hr, tmpDescriptionPtr); + + manifestApp->GetStringValue("BackgroundColor", out var tmpBackgroundColorPtr); + BackgroundColor = ComFreeHelper.GetStringAndFree(hr, tmpBackgroundColorPtr); + + manifestApp->GetStringValue("EntryPoint", out var tmpEntryPointPtr); + EntryPoint = ComFreeHelper.GetStringAndFree(hr, tmpEntryPointPtr); + + Package = package ?? throw new ArgumentNullException(nameof(package)); + + DisplayName = ResourceFromPri(package.FullName, DisplayName); + Description = ResourceFromPri(package.FullName, Description); + _logoUri = LogoUriFromManifest(manifestApp); + _jumboLogoUri = LogoUriFromManifest(manifestApp, jumbo: true); + + Enabled = true; + CanRunElevated = IfApplicationCanRunElevated(); + } + + private bool IfApplicationCanRunElevated() + { + if (EntryPoint == "Windows.FullTrustApplication") + { + return true; + } + else + { + var manifest = Package.Location + "\\AppxManifest.xml"; + if (File.Exists(manifest)) + { + try + { + // Check the manifest to verify if the Trust Level for the application is "mediumIL" + var file = File.ReadAllText(manifest); + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(file); + var xmlRoot = xmlDoc.DocumentElement; + var namespaceManager = new XmlNamespaceManager(xmlDoc.NameTable); + namespaceManager.AddNamespace("uap10", "http://schemas.microsoft.com/appx/manifest/uap/windows10/10"); + var trustLevelNode = xmlRoot?.SelectSingleNode("//*[local-name()='Application' and @uap10:TrustLevel]", namespaceManager); // According to https://learn.microsoft.com/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps#create-a-package-manifest-for-the-sparse-package and https://learn.microsoft.com/uwp/schemas/appxpackage/uapmanifestschema/element-application#attributes + + if (trustLevelNode?.Attributes?["uap10:TrustLevel"]?.Value == "mediumIL") + { + return true; + } + } + catch (Exception ex) + { + Logger.LogError(ex.Message); + } + } + } + + return false; + } + + private static string TryLoadIndirectString(string source, Span<char> buffer, string errorContext) + { + try + { + PInvoke.SHLoadIndirectString(source, buffer).ThrowOnFailure(); + + var len = buffer.IndexOf('\0'); + var loaded = len >= 0 + ? buffer[..len].ToString() + : buffer.ToString(); + return string.IsNullOrEmpty(loaded) ? string.Empty : loaded; + } + catch (Exception ex) + { + Logger.LogError($"Unable to load resource {source} : {errorContext} : {ex.Message}"); + return string.Empty; + } + } + + internal unsafe string ResourceFromPri(string packageFullName, string resourceReference) + { + const string prefix = "ms-resource:"; + + // Using OrdinalIgnoreCase since this is used internally + if (!string.IsNullOrWhiteSpace(resourceReference) && resourceReference.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + // magic comes from @talynone + // https://github.com/talynone/Wox.Plugin.WindowsUniversalAppLauncher/blob/master/StoreAppLauncher/Helpers/NativeApiHelper.cs#L139-L153 + var key = resourceReference.Substring(prefix.Length); + string parsed; + var parsedFallback = string.Empty; + + // Using Ordinal/OrdinalIgnoreCase since these are used internally + if (key.StartsWith("//", StringComparison.Ordinal)) + { + parsed = prefix + key; + } + else if (key.StartsWith('/')) + { + parsed = prefix + "//" + key; + } + else if (key.Contains("resources", StringComparison.OrdinalIgnoreCase)) + { + parsed = prefix + key; + } + else + { + parsed = prefix + "///resources/" + key; + + // e.g. for Windows Terminal version >= 1.12 DisplayName and Description resources are not in the 'resources' subtree + parsedFallback = prefix + "///" + key; + } + + Span<char> outBuffer = stackalloc char[1024]; + var source = $"@{{{packageFullName}? {parsed}}}"; + + var loaded = TryLoadIndirectString(source, outBuffer, resourceReference); + + if (!string.IsNullOrEmpty(loaded)) + { + return loaded; + } + + if (string.IsNullOrEmpty(parsedFallback)) + { + // https://github.com/Wox-launcher/Wox/issues/964 + // known hresult 2147942522: + // 'Microsoft Corporation' violates pattern constraint of '\bms-resource:.{1,256}'. + // for + // Microsoft.MicrosoftOfficeHub_17.7608.23501.0_x64__8wekyb3d8bbwe: ms-resource://Microsoft.MicrosoftOfficeHub/officehubintl/AppManifest_GetOffice_Description + // Microsoft.BingFoodAndDrink_3.0.4.336_x64__8wekyb3d8bbwe: ms-resource:AppDescription + return string.Empty; + } + + var sourceFallback = $"@{{{packageFullName}?{parsedFallback}}}"; + return TryLoadIndirectString(sourceFallback, outBuffer, $"{resourceReference} (fallback)"); + } + else + { + return resourceReference; + } + } + + private static readonly Dictionary<PackageVersion, string> _smallLogoKeyFromVersion = new Dictionary<PackageVersion, string> + { + { PackageVersion.Windows10, "Square44x44Logo" }, + { PackageVersion.Windows81, "Square30x30Logo" }, + { PackageVersion.Windows8, "SmallLogo" }, + }; + + private static readonly Dictionary<PackageVersion, string> _largeLogoKeyFromVersion = new Dictionary<PackageVersion, string> + { + { PackageVersion.Windows10, "Square150x150Logo" }, + { PackageVersion.Windows81, "Square150x150Logo" }, + { PackageVersion.Windows8, "Logo" }, + }; + + internal unsafe string LogoUriFromManifest(IAppxManifestApplication* app, bool jumbo = false) + { + var logoMap = jumbo ? _largeLogoKeyFromVersion : _smallLogoKeyFromVersion; + if (logoMap.TryGetValue(Package.Version, out var key)) + { + var hr = app->GetStringValue(key, out var logoUriFromAppPtr); + return ComFreeHelper.GetStringAndFree(hr, logoUriFromAppPtr); + } + else + { + return string.Empty; + } + } + + public void UpdateLogoPath(Theme theme) + { + // Update small logo + var logo = AppxIconLoader.LogoPathFromUri(_logoUri, theme, ListIconSize, Package); + if (logo.IsFound) + { + LogoPath = logo.LogoPath!; + LogoType = logo.LogoType; + } + else + { + LogoPath = string.Empty; + LogoType = LogoType.Error; + } + + // Jumbo logo ... small logo can actually provide better result + var jumboLogo = AppxIconLoader.LogoPathFromUri(_logoUri, theme, JumboIconSize, Package); + if (jumboLogo.IsFound) + { + JumboLogoPath = jumboLogo.LogoPath!; + JumboLogoType = jumboLogo.LogoType; + } + else + { + JumboLogoPath = string.Empty; + JumboLogoType = LogoType.Error; + } + + if (!jumboLogo.MeetsMinimumSize(JumboIconSize) || !jumboLogo.IsFound) + { + var jumboLogoAlt = AppxIconLoader.LogoPathFromUri(_jumboLogoUri, theme, JumboIconSize, Package); + if (jumboLogoAlt.IsFound) + { + JumboLogoPath = jumboLogoAlt.LogoPath!; + JumboLogoType = jumboLogoAlt.LogoType; + } + } + } + + public AppItem ToAppItem() + { + var app = this; + var iconPath = app.LogoType != LogoType.Error ? app.LogoPath : string.Empty; + var jumboIconPath = app.JumboLogoType != LogoType.Error ? app.JumboLogoPath : string.Empty; + var item = new AppItem + { + Name = app.Name, + Subtitle = app.Description, + Type = UWPApplication.Type(), + IcoPath = iconPath, + JumboIconPath = jumboIconPath, + DirPath = app.Location, + UserModelId = app.UserModelId, + IsPackaged = true, + Commands = app.GetCommands(), + AppIdentifier = app.GetAppIdentifier(), + PackageFamilyName = app.Package.FamilyName, + }; + return item; + } + + public override string ToString() + { + return $"{DisplayName}: {Description}"; + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs similarity index 74% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs index 48dfaa2f7e..1de01b408e 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Programs/Win32Program.cs @@ -3,30 +3,28 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.ComponentModel.Design; -using System.Diagnostics; -using System.Globalization; using System.IO; using System.IO.Abstractions; -using System.Linq; -using System.Reflection; using System.Security; using System.Text.RegularExpressions; using System.Threading.Tasks; -using System.Windows.Input; +using ManagedCommon; using Microsoft.CmdPal.Ext.Apps.Commands; using Microsoft.CmdPal.Ext.Apps.Properties; using Microsoft.CmdPal.Ext.Apps.Utils; +using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.Win32; +using Windows.System; namespace Microsoft.CmdPal.Ext.Apps.Programs; [Serializable] public class Win32Program : IProgram { - public static readonly Win32Program InvalidProgram = new Win32Program { Valid = false, Enabled = false }; + public static readonly Win32Program InvalidProgram = new() { Valid = false, Enabled = false }; private static readonly IFileSystem FileSystem = new FileSystem(); private static readonly IPath Path = FileSystem.Path; @@ -85,7 +83,7 @@ public class Win32Program : IProgram private const string ShortcutExtension = "lnk"; private const string ApplicationReferenceExtension = "appref-ms"; private const string InternetShortcutExtension = "url"; - private static readonly HashSet<string> ExecutableApplicationExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "exe", "bat", "bin", "com", "cpl", "msc", "msi", "cmd", "ps1", "job", "msp", "mst", "sct", "ws", "wsh", "wsf" }; + private static readonly HashSet<string> ExecutableApplicationExtensions = new(StringComparer.OrdinalIgnoreCase) { "exe", "bat", "bin", "com", "cpl", "msc", "msi", "cmd", "ps1", "job", "msp", "mst", "sct", "ws", "wsh", "wsf" }; private const string ProxyWebApp = "_proxy.exe"; private const string AppIdArgument = "--app-id"; @@ -171,7 +169,7 @@ public class Win32Program : IProgram public bool QueryEqualsNameForRunCommands(string query) { - if (query != null && AppType == ApplicationType.RunCommand) + if (query is not null && AppType == ApplicationType.RunCommand) { // Using OrdinalIgnoreCase since this is used internally if (!query.Equals(Name, StringComparison.OrdinalIgnoreCase) && !query.Equals(ExecutableName, StringComparison.OrdinalIgnoreCase)) @@ -183,24 +181,55 @@ public class Win32Program : IProgram return true; } - public List<CommandContextItem> GetCommands() + public List<IContextItem> GetCommands() { - List<CommandContextItem> commands = new List<CommandContextItem>(); + List<IContextItem> commands = []; if (AppType != ApplicationType.InternetShortcutApplication && AppType != ApplicationType.Folder && AppType != ApplicationType.GenericFile) { commands.Add(new CommandContextItem( - new RunAsAdminCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath, ParentDirectory, false))); + new RunAsAdminCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath, ParentDirectory, false)) + { + RequestedShortcut = KeyChords.RunAsAdministrator, + }); commands.Add(new CommandContextItem( - new RunAsUserCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath, ParentDirectory))); + new RunAsUserCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath, ParentDirectory)) + { + RequestedShortcut = KeyChords.RunAsDifferentUser, + }); } commands.Add(new CommandContextItem( - new OpenPathCommand(ParentDirectory))); + new CopyPathCommand(FullPath)) + { + RequestedShortcut = KeyChords.CopyFilePath, + }); commands.Add(new CommandContextItem( - new OpenInConsoleCommand(ParentDirectory))); + new ShowFileInFolderCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath) + { + Name = Resources.open_location, + }) + { + RequestedShortcut = KeyChords.OpenFileLocation, + }); + + commands.Add(new CommandContextItem( + new OpenInConsoleCommand(ParentDirectory)) + { + RequestedShortcut = KeyChords.OpenInConsole, + }); + + if (AppType == ApplicationType.ShortcutApplication || AppType == ApplicationType.ApprefApplication || AppType == ApplicationType.Win32Application) + { + commands.Add(new CommandContextItem( + new UninstallApplicationConfirmation(this)) + { + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete), + IsCritical = true, + }); + } return commands; } @@ -210,6 +239,12 @@ public class Win32Program : IProgram return ExecutableName; } + public string GetAppIdentifier() + { + // Use a combination of name and path to create a unique identifier + return $"{Name}|{FullPath}"; + } + private static Win32Program CreateWin32Program(string path) { try @@ -239,15 +274,17 @@ public class Win32Program : IProgram } catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) { + Logger.LogError(e.Message); return InvalidProgram; } - catch (Exception) + catch (Exception e) { + Logger.LogError(e.Message); return InvalidProgram; } } - private static readonly Regex InternetShortcutURLPrefixes = new Regex(@"^steam:\/\/(rungameid|run|open)\/|^com\.epicgames\.launcher:\/\/apps\/", RegexOptions.Compiled); + private static readonly Regex InternetShortcutURLPrefixes = new(@"^steam:\/\/(rungameid|run|open)\/|^com\.epicgames\.launcher:\/\/apps\/", RegexOptions.Compiled); // This function filters Internet Shortcut programs private static Win32Program InternetShortcutProgram(string path) @@ -317,11 +354,13 @@ public class Win32Program : IProgram } catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) { + Logger.LogError(e.Message); return InvalidProgram; } } - catch (Exception) + catch (Exception e) { + Logger.LogError(e.Message); return InvalidProgram; } } @@ -374,15 +413,17 @@ public class Win32Program : IProgram return program; } - catch (System.IO.FileLoadException) + catch (System.IO.FileLoadException e) { + Logger.LogError(e.Message); return InvalidProgram; } // Only do a catch all in production. This is so make developer aware of any unhandled exception and add the exception handling in. // Error caused likely due to trying to get the description of the program - catch (Exception) + catch (Exception e) { + Logger.LogError(e.Message); return InvalidProgram; } } @@ -402,14 +443,17 @@ public class Win32Program : IProgram } catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) { + Logger.LogError(e.Message); return InvalidProgram; } - catch (FileNotFoundException) + catch (FileNotFoundException e) { + Logger.LogError(e.Message); return InvalidProgram; } - catch (Exception) + catch (Exception e) { + Logger.LogError(e.Message); return InvalidProgram; } } @@ -515,16 +559,19 @@ public class Win32Program : IProgram { files.AddRange(Directory.EnumerateFiles(currentDirectory, $"*.{suffix}", SearchOption.TopDirectoryOnly)); } - catch (DirectoryNotFoundException) + catch (DirectoryNotFoundException e) { + Logger.LogError(e.Message); } } } catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) { + Logger.LogError(e.Message); } - catch (Exception) + catch (Exception e) { + Logger.LogError(e.Message); } try @@ -548,9 +595,11 @@ public class Win32Program : IProgram } catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) { + Logger.LogError(e.Message); } - catch (Exception) + catch (Exception e) { + Logger.LogError(e.Message); } } while (folderQueue.Count > 0); @@ -569,9 +618,24 @@ public class Win32Program : IProgram } private static IEnumerable<string> CustomProgramPaths(IEnumerable<ProgramSource> sources, IList<string> suffixes) - => sources?.Where(programSource => Directory.Exists(programSource.Location) && programSource.Enabled) - .SelectMany(programSource => ProgramPaths(programSource.Location, suffixes)) - .ToList() ?? Enumerable.Empty<string>(); + { + if (sources is not null) + { + var paths = new List<string>(); + + foreach (var programSource in sources) + { + if (Directory.Exists(programSource.Location) && programSource.Enabled) + { + paths.AddRange(ProgramPaths(programSource.Location, suffixes)); + } + } + + return paths; + } + + return []; + } // Function to obtain the list of applications, the locations of which have been added to the env variable PATH private static List<string> PathEnvironmentProgramPaths(IList<string> suffixes) @@ -600,9 +664,15 @@ public class Win32Program : IProgram } private static List<string> IndexPath(IList<string> suffixes, List<string> indexLocations) - => indexLocations - .SelectMany(indexLocation => ProgramPaths(indexLocation, suffixes)) - .ToList(); + { + var paths = new List<string>(); + foreach (var indexLocation in indexLocations) + { + paths.AddRange(ProgramPaths(indexLocation, suffixes)); + } + + return paths; + } private static List<string> StartMenuProgramPaths(IList<string> suffixes) { @@ -630,7 +700,7 @@ public class Win32Program : IProgram var paths = new List<string>(); using (var root = Registry.LocalMachine.OpenSubKey(appPaths)) { - if (root != null) + if (root is not null) { paths.AddRange(GetPathsFromRegistry(root)); } @@ -638,23 +708,57 @@ public class Win32Program : IProgram using (var root = Registry.CurrentUser.OpenSubKey(appPaths)) { - if (root != null) + if (root is not null) { paths.AddRange(GetPathsFromRegistry(root)); } } - return paths - .Where(path => suffixes.Any(suffix => path.EndsWith(suffix, StringComparison.InvariantCultureIgnoreCase))) - .Select(ExpandEnvironmentVariables) - .Where(path => path is not null) - .ToList(); + var returnedPaths = new List<string>(); + foreach (var path in paths) + { + var matchesSuffix = false; + foreach (var suffix in suffixes) + { + if (path.EndsWith(suffix, StringComparison.InvariantCultureIgnoreCase)) + { + matchesSuffix = true; + break; + } + } + + if (matchesSuffix) + { + var expandedPath = ExpandEnvironmentVariables(path); + if (expandedPath is not null) + { + returnedPaths.Add(expandedPath); + } + } + } + + return returnedPaths; } private static IEnumerable<string> GetPathsFromRegistry(RegistryKey root) - => root - .GetSubKeyNames() - .Select(x => GetPathFromRegistrySubkey(root, x)); + { + var result = new List<string>(); + + // Get all subkey names + var subKeyNames = root.GetSubKeyNames(); + + // Process each subkey to extract the path + foreach (var subkeyName in subKeyNames) + { + var path = GetPathFromRegistrySubkey(root, subkeyName); + if (!string.IsNullOrEmpty(path)) + { + result.Add(path); + } + } + + return result; + } private static string GetPathFromRegistrySubkey(RegistryKey root, string subkey) { @@ -663,7 +767,7 @@ public class Win32Program : IProgram { using (var key = root.OpenSubKey(subkey)) { - if (key == null) + if (key is null) { return string.Empty; } @@ -682,6 +786,7 @@ public class Win32Program : IProgram } catch (Exception e) when (e is SecurityException || e is UnauthorizedAccessException) { + Logger.LogError(e.Message); return string.Empty; } } @@ -700,17 +805,14 @@ public class Win32Program : IProgram private sealed class Win32ProgramEqualityComparer : IEqualityComparer<Win32Program> { - public static readonly Win32ProgramEqualityComparer Default = new Win32ProgramEqualityComparer(); + public static readonly Win32ProgramEqualityComparer Default = new(); public bool Equals(Win32Program? app1, Win32Program? app2) { - if (app1 == null && app2 == null) - { - return true; - } - - return app1 != null - && app2 != null + return app1 is null && app2 is null + ? true + : app1 is not null + && app2 is not null && (app1.Name?.ToUpperInvariant(), app1.ExecutableName?.ToUpperInvariant(), app1.FullPath?.ToUpperInvariant()) .Equals((app2.Name?.ToUpperInvariant(), app2.ExecutableName?.ToUpperInvariant(), app2.FullPath?.ToUpperInvariant())); } @@ -720,7 +822,28 @@ public class Win32Program : IProgram } public static List<Win32Program> DeduplicatePrograms(IEnumerable<Win32Program> programs) - => new HashSet<Win32Program>(programs, Win32ProgramEqualityComparer.Default).ToList(); + { + // Create a HashSet with the custom equality comparer to automatically deduplicate programs + var uniquePrograms = new HashSet<Win32Program>(Win32ProgramEqualityComparer.Default); + + // Filter out invalid programs and add valid ones to the HashSet + foreach (var program in programs) + { + if (program?.Valid == true) + { + uniquePrograms.Add(program); + } + } + + // Convert the HashSet to a List for return + var result = new List<Win32Program>(uniquePrograms.Count); + foreach (var program in uniquePrograms) + { + result.Add(program); + } + + return result; + } private static Win32Program GetProgramFromPath(string path) { @@ -769,8 +892,9 @@ public class Win32Program : IProgram icoPath = ExpandEnvironmentVariables(redirectionPath); return true; } - catch (IOException) + catch (IOException e) { + Logger.LogError(e.Message); } icoPath = null; @@ -806,7 +930,7 @@ public class Win32Program : IProgram var paths = new HashSet<string>(defaultHashsetSize); var runCommandPaths = new HashSet<string>(defaultHashsetSize); - // Parallelize multiple sources, and priority based on paths which most likely contain .lnks which are formatted + // Parallelize multiple sources, and priority based on paths which most likely contain .lnk files which are formatted var sources = new (bool IsEnabled, Func<IEnumerable<string>> GetPaths)[] { (true, () => CustomProgramPaths(settings.ProgramSources, settings.ProgramSuffixes)), @@ -824,24 +948,121 @@ public class Win32Program : IProgram var disabledProgramsList = settings.DisabledProgramSources; // Get all paths but exclude all normal .Executables - paths.UnionWith(sources - .AsParallel() - .SelectMany(source => source.IsEnabled ? source.GetPaths() : Enumerable.Empty<string>()) - .Where(programPath => disabledProgramsList.All(x => x.UniqueIdentifier != programPath)) - .Where(path => !ExecutableApplicationExtensions.Contains(Extension(path)))); - runCommandPaths.UnionWith(runCommandSources - .AsParallel() - .SelectMany(source => source.IsEnabled ? source.GetPaths() : Enumerable.Empty<string>()) - .Where(programPath => disabledProgramsList.All(x => x.UniqueIdentifier != programPath))); + var pathBag = new ConcurrentBag<string>(); - var programs = paths.AsParallel().Select(source => GetProgramFromPath(source)); - var runCommandPrograms = runCommandPaths.AsParallel().Select(source => GetRunCommandProgramFromPath(source)); + Parallel.ForEach(sources, source => + { + if (!source.IsEnabled) + { + return; + } - return DeduplicatePrograms(programs.Concat(runCommandPrograms).Where(program => program?.Valid == true)); + foreach (var path in source.GetPaths()) + { + if (ExecutableApplicationExtensions.Contains(Extension(path))) + { + continue; + } + + var isDisabled = false; + foreach (var disabledProgram in disabledProgramsList) + { + if (disabledProgram.UniqueIdentifier == path) + { + isDisabled = true; + break; + } + } + + if (!isDisabled) + { + pathBag.Add(path); + } + } + }); + + paths.UnionWith(pathBag); + + var runCommandPathBag = new ConcurrentBag<string>(); + + Parallel.ForEach(runCommandSources, source => + { + if (!source.IsEnabled) + { + return; + } + + foreach (var path in source.GetPaths()) + { + var isDisabled = false; + foreach (var disabledProgram in disabledProgramsList) + { + if (disabledProgram.UniqueIdentifier == path) + { + isDisabled = true; + break; + } + } + + if (!isDisabled) + { + runCommandPathBag.Add(path); + } + } + }); + + runCommandPaths.UnionWith(runCommandPathBag); + + var programsList = new ConcurrentBag<Win32Program>(); + Parallel.ForEach(paths, source => + { + var program = GetProgramFromPath(source); + if (program is not null) + { + programsList.Add(program); + } + }); + + var runCommandProgramsList = new ConcurrentBag<Win32Program>(); + Parallel.ForEach(runCommandPaths, source => + { + var program = GetRunCommandProgramFromPath(source); + if (program is not null) + { + runCommandProgramsList.Add(program); + } + }); + + List<Win32Program> allPrograms = [.. programsList, .. runCommandProgramsList]; + return DeduplicatePrograms(allPrograms); } - catch (Exception) + catch (Exception e) { + Logger.LogError(e.Message); return Array.Empty<Win32Program>(); } } + + internal AppItem ToAppItem() + { + var app = this; + var icoPath = string.IsNullOrEmpty(app.IcoPath) ? + (app.AppType == Win32Program.ApplicationType.InternetShortcutApplication ? + app.IcoPath : + app.FullPath) : + app.IcoPath; + + return new AppItem() + { + Name = app.Name, + Subtitle = app.Description, + Type = app.Type(), + IcoPath = icoPath, + ExePath = !string.IsNullOrEmpty(app.LnkFilePath) ? app.LnkFilePath : app.FullPath, + DirPath = app.Location, + Commands = app.GetCommands(), + AppIdentifier = app.GetAppIdentifier(), + FullExecutablePath = app.FullPath, + }; + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..b0c7ecb93e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.Apps.UnitTests")] diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs similarity index 65% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs index 33531ba62f..9fdf833b0a 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.Designer.cs @@ -61,7 +61,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } /// <summary> - /// Looks up a localized string similar to All Apps. + /// Looks up a localized string similar to Search apps. /// </summary> internal static string all_apps { get { @@ -78,6 +78,15 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } } + /// <summary> + /// Looks up a localized string similar to Copy path. + /// </summary> + internal static string copy_path { + get { + return ResourceManager.GetString("copy_path", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Include apps found on the desktop. /// </summary> @@ -150,6 +159,78 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } } + /// <summary> + /// Looks up a localized string similar to 0. + /// </summary> + internal static string limit_0 { + get { + return ResourceManager.GetString("limit_0", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to 1. + /// </summary> + internal static string limit_1 { + get { + return ResourceManager.GetString("limit_1", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to 10. + /// </summary> + internal static string limit_10 { + get { + return ResourceManager.GetString("limit_10", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to 20. + /// </summary> + internal static string limit_20 { + get { + return ResourceManager.GetString("limit_20", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to 5. + /// </summary> + internal static string limit_5 { + get { + return ResourceManager.GetString("limit_5", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Limit the number of applications returned from the top level. + /// </summary> + internal static string limit_fallback_results_source { + get { + return ResourceManager.GetString("limit_fallback_results_source", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Limit fallback results to n apps. + /// </summary> + internal static string limit_fallback_results_source_description { + get { + return ResourceManager.GetString("limit_fallback_results_source_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Unlimited. + /// </summary> + internal static string limit_none { + get { + return ResourceManager.GetString("limit_none", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Open containing folder. /// </summary> @@ -160,7 +241,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } /// <summary> - /// Looks up a localized string similar to Open location. + /// Looks up a localized string similar to Open file location. /// </summary> internal static string open_location { get { @@ -186,6 +267,15 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } } + /// <summary> + /// Looks up a localized string similar to Pin. + /// </summary> + internal static string pin_app { + get { + return ResourceManager.GetString("pin_app", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Run as administrator. /// </summary> @@ -223,16 +313,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } /// <summary> - /// Looks up a localized string similar to Search installed apps. - /// </summary> - internal static string search_installed_apps { - get { - return ResourceManager.GetString("search_installed_apps", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Search installed apps.... + /// Looks up a localized string similar to Search apps.... /// </summary> internal static string search_installed_apps_placeholder { get { @@ -240,6 +321,60 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties { } } + /// <summary> + /// Looks up a localized string similar to Uninstall. + /// </summary> + internal static string uninstall_application { + get { + return ResourceManager.GetString("uninstall_application", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This app and its related information will be removed.. + /// </summary> + internal static string uninstall_application_confirm_description { + get { + return ResourceManager.GetString("uninstall_application_confirm_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Uninstall "{0}"?. + /// </summary> + internal static string uninstall_application_confirm_title { + get { + return ResourceManager.GetString("uninstall_application_confirm_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Error while uninstalling '{0}'. + /// </summary> + internal static string uninstall_application_failed { + get { + return ResourceManager.GetString("uninstall_application_failed", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to '{0}' has been successfully uninstalled.. + /// </summary> + internal static string uninstall_application_successful { + get { + return ResourceManager.GetString("uninstall_application_successful", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Unpin. + /// </summary> + internal static string unpin_app { + get { + return ResourceManager.GetString("unpin_app", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Experimental: When enabled, Command Palette will load thumbnails from the Windows Shell. Using thumbnails may cause the app to crash on launch. /// </summary> diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx similarity index 82% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx index 98212f1066..758c61e20f 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Properties/Resources.resx @@ -124,14 +124,11 @@ <data name="installed_apps" xml:space="preserve"> <value>Installed apps</value> </data> - <data name="search_installed_apps" xml:space="preserve"> - <value>Search installed apps</value> - </data> <data name="all_apps" xml:space="preserve"> - <value>All Apps</value> + <value>Search apps</value> </data> <data name="search_installed_apps_placeholder" xml:space="preserve"> - <value>Search installed apps...</value> + <value>Search apps...</value> </data> <data name="open_path_in_console" xml:space="preserve"> <value>Open path in console</value> @@ -161,7 +158,10 @@ <value>File</value> </data> <data name="open_location" xml:space="preserve"> - <value>Open location</value> + <value>Open file location</value> + </data> + <data name="copy_path" xml:space="preserve"> + <value>Copy path</value> </data> <data name="run_as_administrator" xml:space="preserve"> <value>Run as administrator</value> @@ -189,4 +189,49 @@ <value>Experimental: When enabled, Command Palette will load thumbnails from the Windows Shell. Using thumbnails may cause the app to crash on launch</value> <comment>A description for "use_thumbnails_setting_label"</comment> </data> + <data name="pin_app" xml:space="preserve"> + <value>Pin</value> + </data> + <data name="unpin_app" xml:space="preserve"> + <value>Unpin</value> + </data> + <data name="uninstall_application" xml:space="preserve"> + <value>Uninstall</value> + </data> + <data name="uninstall_application_successful" xml:space="preserve"> + <value>'{0}' has been successfully uninstalled.</value> + </data> + <data name="uninstall_application_failed" xml:space="preserve"> + <value>Error while uninstalling '{0}'</value> + </data> + <data name="uninstall_application_confirm_description" xml:space="preserve"> + <value>This app and its related information will be removed.</value> + </data> + <data name="uninstall_application_confirm_title" xml:space="preserve"> + <value>Uninstall "{0}"?</value> + </data> + <data name="limit_1" xml:space="preserve"> + <value>1</value> + </data> + <data name="limit_5" xml:space="preserve"> + <value>5</value> + </data> + <data name="limit_10" xml:space="preserve"> + <value>10</value> + </data> + <data name="limit_20" xml:space="preserve"> + <value>20</value> + </data> + <data name="limit_fallback_results_source" xml:space="preserve"> + <value>Limit the number of applications returned from the top level</value> + </data> + <data name="limit_fallback_results_source_description" xml:space="preserve"> + <value>Limit fallback results to n apps</value> + </data> + <data name="limit_0" xml:space="preserve"> + <value>0</value> + </data> + <data name="limit_none" xml:space="preserve"> + <value>Unlimited</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinStateChangedEventArgs.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinStateChangedEventArgs.cs new file mode 100644 index 0000000000..7590ba3ad9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinStateChangedEventArgs.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.CmdPal.Ext.Apps.State; + +public class PinStateChangedEventArgs : EventArgs +{ + /// <summary> + /// Gets the identifier of the application whose pin state has changed. + /// </summary> + public string AppIdentifier { get; } + + /// <summary> + /// Gets a value indicating whether the specified app identifier was pinned or not. + /// </summary> + public bool IsPinned { get; } + + /// <summary> + /// Initializes a new instance of the <see cref="PinStateChangedEventArgs"/> class. + /// </summary> + /// <param name="appIdentifier">The identifier of the application whose pin state has changed.</param> + public PinStateChangedEventArgs(string appIdentifier, bool isPinned) + { + AppIdentifier = appIdentifier ?? throw new ArgumentNullException(nameof(appIdentifier)); + IsPinned = isPinned; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedApps.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedApps.cs new file mode 100644 index 0000000000..ff76043bf1 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedApps.cs @@ -0,0 +1,47 @@ +// 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.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text.Json; + +namespace Microsoft.CmdPal.Ext.Apps.State; + +public sealed class PinnedApps +{ + public List<string> PinnedAppIdentifiers { get; set; } = []; + + public static PinnedApps ReadFromFile(string path) + { + if (!File.Exists(path)) + { + return new PinnedApps(); + } + + try + { + var jsonString = File.ReadAllText(path); + var result = JsonSerializer.Deserialize<PinnedApps>(jsonString, JsonSerializationContext.Default.PinnedApps); + return result ?? new PinnedApps(); + } + catch + { + return new PinnedApps(); + } + } + + public static void WriteToFile(string path, PinnedApps data) + { + try + { + var jsonString = JsonSerializer.Serialize(data, JsonSerializationContext.Default.PinnedApps); + File.WriteAllText(path, jsonString); + } + catch + { + // Silently fail - we don't want pinning issues to crash the extension + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs new file mode 100644 index 0000000000..0fdc0a934c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/State/PinnedAppsManager.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using ManagedCommon; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Apps.State; + +public sealed class PinnedAppsManager +{ + private static readonly Lazy<PinnedAppsManager> _instance = new(() => new PinnedAppsManager()); + private readonly string _pinnedAppsFilePath; + + public static PinnedAppsManager Instance => _instance.Value; + + private PinnedApps _pinnedApps = new(); + + // Add event for when pinning state changes + public event EventHandler<PinStateChangedEventArgs>? PinStateChanged; + + private PinnedAppsManager() + { + _pinnedAppsFilePath = GetPinnedAppsFilePath(); + LoadPinnedApps(); + } + + public bool IsAppPinned(string appIdentifier) + { + return _pinnedApps.PinnedAppIdentifiers.IndexOf(appIdentifier) >= 0; + } + + public void PinApp(string appIdentifier) + { + if (!IsAppPinned(appIdentifier)) + { + _pinnedApps.PinnedAppIdentifiers.Add(appIdentifier); + SavePinnedApps(); + Logger.LogTrace($"Pinned app: {appIdentifier}"); + PinStateChanged?.Invoke(this, new PinStateChangedEventArgs(appIdentifier, true)); + } + } + + public string[] GetPinnedAppIdentifiers() + { + return _pinnedApps.PinnedAppIdentifiers.ToArray(); + } + + public void UnpinApp(string appIdentifier) + { + var removed = _pinnedApps.PinnedAppIdentifiers.RemoveAll(id => + string.Equals(id, appIdentifier, StringComparison.OrdinalIgnoreCase)); + + if (removed > 0) + { + SavePinnedApps(); + Logger.LogTrace($"Unpinned app: {appIdentifier}"); + PinStateChanged?.Invoke(this, new PinStateChangedEventArgs(appIdentifier, false)); + } + } + + private void LoadPinnedApps() + { + _pinnedApps = PinnedApps.ReadFromFile(_pinnedAppsFilePath); + } + + private void SavePinnedApps() + { + PinnedApps.WriteToFile(_pinnedAppsFilePath, _pinnedApps); + } + + private static string GetPinnedAppsFilePath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, "apps.pinned.json"); + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/EventHandler.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/EventHandler.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/EventHandler.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/EventHandler.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/FileSystemWatcherWrapper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/FileSystemWatcherWrapper.cs similarity index 84% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/FileSystemWatcherWrapper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/FileSystemWatcherWrapper.cs index c8568451c3..f7fac6e12d 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/FileSystemWatcherWrapper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/FileSystemWatcherWrapper.cs @@ -8,7 +8,7 @@ using System.IO; namespace Microsoft.CmdPal.Ext.Apps.Storage; // File System Watcher Wrapper class which implements the IFileSystemWatcherWrapper interface -public sealed class FileSystemWatcherWrapper : FileSystemWatcher, IFileSystemWatcherWrapper +public sealed partial class FileSystemWatcherWrapper : FileSystemWatcher, IFileSystemWatcherWrapper { public FileSystemWatcherWrapper() { @@ -19,7 +19,7 @@ public sealed class FileSystemWatcherWrapper : FileSystemWatcher, IFileSystemWat get => this.Filters; set { - if (value != null) + if (value is not null) { foreach (var filter in value) { diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/IFileSystemWatcherWrapper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/IFileSystemWatcherWrapper.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/IFileSystemWatcherWrapper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/IFileSystemWatcherWrapper.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/IProgramRepository.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/IProgramRepository.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/IProgramRepository.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/IProgramRepository.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/IRepository`1.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/IRepository`1.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/IRepository`1.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/IRepository`1.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs similarity index 78% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs index aea8590c58..4c12ac0fc7 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/ListRepository`1.cs @@ -6,20 +6,29 @@ using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; +using ManagedCommon; namespace Microsoft.CmdPal.Ext.Apps.Storage; /// <summary> /// The intent of this class is to provide a basic subset of 'list' like operations, without exposing callers to the internal representation -/// of the data structure. Currently this is implemented as a list for it's simplicity. +/// of the data structure. Currently this is implemented as a list for its simplicity. /// </summary> /// <typeparam name="T">typeof</typeparam> public class ListRepository<T> : IRepository<T>, IEnumerable<T> { public IList<T> Items { - get { return _items.Values.ToList(); } + get + { + var items = new List<T>(_items.Count); + foreach (var item in _items.Values) + { + items.Add(item); + } + + return items; + } } private ConcurrentDictionary<int, T> _items = new ConcurrentDictionary<int, T>(); @@ -33,12 +42,20 @@ public class ListRepository<T> : IRepository<T>, IEnumerable<T> // enforce that internal representation try { + var result = new ConcurrentDictionary<int, T>(); + + foreach (var item in list) + { #pragma warning disable CS8602 // Dereference of a possibly null reference. - _items = new ConcurrentDictionary<int, T>(list.ToDictionary(i => i.GetHashCode())); + result.TryAdd(item.GetHashCode(), item); #pragma warning restore CS8602 // Dereference of a possibly null reference. + } + + _items = result; } - catch (ArgumentException) + catch (ArgumentException ex) { + Logger.LogInfo(ex.Message); } } @@ -67,11 +84,6 @@ public class ListRepository<T> : IRepository<T>, IEnumerable<T> } } - public ParallelQuery<T> AsParallel() - { - return _items.Values.AsParallel(); - } - public bool Contains(T item) { if (item is not null) diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs similarity index 82% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs index 0632d931e1..e9708899c8 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/PackageRepository.cs @@ -3,9 +3,8 @@ // See the LICENSE file in the project root for more information. using System; -using System.Linq; +using ManagedCommon; using Microsoft.CmdPal.Ext.Apps.Programs; -using Microsoft.CmdPal.Ext.Apps.Storage; using Microsoft.CmdPal.Ext.Apps.Utils; using Windows.ApplicationModel; @@ -15,7 +14,7 @@ namespace Microsoft.CmdPal.Ext.Apps.Storage; /// A repository for storing packaged applications such as UWP apps or appx packaged desktop apps. /// This repository will also monitor for changes to the PackageCatalog and update the repository accordingly /// </summary> -internal sealed class PackageRepository : ListRepository<UWPApplication>, IProgramRepository +internal sealed partial class PackageRepository : ListRepository<IUWPApplication>, IProgramRepository { private readonly IPackageCatalog _packageCatalog; @@ -92,9 +91,10 @@ internal sealed class PackageRepository : ListRepository<UWPApplication>, IProgr // InitializeAppInfo will throw if there is no AppxManifest.xml for the package. // Note there are sometimes multiple packages per product and this doesn't necessarily mean that we haven't found the app. - // eg. "Could not find file 'C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminalPreview_2020.616.45.0_neutral_~_8wekyb3d8bbwe\\AppxManifest.xml'." - catch (System.IO.FileNotFoundException) + // e.g. "Could not find file 'C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminalPreview_2020.616.45.0_neutral_~_8wekyb3d8bbwe\\AppxManifest.xml'." + catch (System.IO.FileNotFoundException ex) { + Logger.LogError(ex.Message); } } @@ -103,10 +103,14 @@ internal sealed class PackageRepository : ListRepository<UWPApplication>, IProgr // find apps associated with this package. var packageWrapper = PackageWrapper.GetWrapperFromPackage(package); var uwp = new UWP(packageWrapper); - var apps = Items.Where(a => a.Package.Equals(uwp)).ToArray(); - foreach (var app in apps) + foreach (var app in Items) { + if (!app.Package.Equals(uwp)) + { + continue; + } + Remove(app); _isDirty = true; } @@ -114,11 +118,7 @@ internal sealed class PackageRepository : ListRepository<UWPApplication>, IProgr public void IndexPrograms() { - var windows10 = new Version(10, 0); - var support = Environment.OSVersion.Version.Major >= windows10.Major; - - var applications = support ? Programs.UWP.All() : Array.Empty<UWPApplication>(); - + var applications = UWP.All(); SetList(applications); } } diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs similarity index 83% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs index 18023edffb..b17568ea73 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramFileSystemWatchers.cs @@ -5,11 +5,11 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; +using ManagedCommon; namespace Microsoft.CmdPal.Ext.Apps.Storage; -internal sealed class Win32ProgramFileSystemWatchers : IDisposable +internal sealed partial class Win32ProgramFileSystemWatchers : IDisposable { public string[] PathsToWatch { get; set; } @@ -47,13 +47,23 @@ internal sealed class Win32ProgramFileSystemWatchers : IDisposable { Directory.GetFiles(path); } - catch (Exception) + catch (Exception e) { + Logger.LogError(e.Message); invalidPaths.Add(path); } } - return paths.Except(invalidPaths).ToArray(); + var validPaths = new List<string>(); + foreach (var path in paths) + { + if (!invalidPaths.Contains(path)) + { + validPaths.Add(path); + } + } + + return validPaths.ToArray(); } public void Dispose() diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramRepository.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramRepository.cs similarity index 91% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramRepository.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramRepository.cs index 1308556893..98da723743 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramRepository.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Storage/Win32ProgramRepository.cs @@ -6,19 +6,17 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.IO; -using System.IO.Abstractions; using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.Apps.Programs; +using ManagedCommon; using Win32Program = Microsoft.CmdPal.Ext.Apps.Programs.Win32Program; namespace Microsoft.CmdPal.Ext.Apps.Storage; -internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Program>, IProgramRepository +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] +internal sealed partial class Win32ProgramRepository : ListRepository<Programs.Win32Program>, IProgramRepository { - private static readonly IFileSystem FileSystem = new FileSystem(); - private static readonly IPath Path = FileSystem.Path; - private const string LnkExtension = ".lnk"; private const string UrlExtension = ".url"; @@ -54,7 +52,7 @@ internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Prog if (!string.IsNullOrEmpty(appPath)) { Win32Program? app = Win32Program.GetAppFromPath(appPath); - if (app != null) + if (app is not null) { Add(app); _isDirty = true; @@ -108,7 +106,7 @@ internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Prog // fix for https://github.com/microsoft/PowerToys/issues/34391 // the msi installer creates a shortcut, which is detected by the PT Run and ends up in calling this OnAppRenamed method - // the thread needs to be halted for a short time to avoid locking the new shortcut file as we read it, otherwise the lock causes + // the thread needs to be halted for a short time to avoid locking the new shortcut file as we read it; otherwise, the lock causes // in the issue scenario that a warning is popping up during the msi install process. await Task.Delay(1000).ConfigureAwait(false); @@ -132,12 +130,13 @@ internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Prog oldApp = Win32Program.GetAppFromPath(oldPath); } } - catch (Exception) + catch (Exception ex) { + Logger.LogError(ex.Message); } // To remove the old app which has been renamed and to add the new application. - if (oldApp != null) + if (oldApp is not null) { if (string.IsNullOrWhiteSpace(oldApp.Name) || string.IsNullOrWhiteSpace(oldApp.ExecutableName) || string.IsNullOrWhiteSpace(oldApp.FullPath)) { @@ -149,7 +148,7 @@ internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Prog } } - if (newApp != null) + if (newApp is not null) { Add(newApp); _isDirty = true; @@ -177,7 +176,7 @@ internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Prog if (extension.Equals(LnkExtension, StringComparison.OrdinalIgnoreCase)) { app = GetAppWithSameLnkFilePath(path); - if (app == null) + if (app is null) { // Cancelled links won't have a resolved path. app = GetAppWithSameNameAndExecutable(Path.GetFileNameWithoutExtension(path), Path.GetFileName(path)); @@ -192,11 +191,12 @@ internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Prog app = Programs.Win32Program.GetAppFromPath(path); } } - catch (Exception) + catch (Exception ex) { + Logger.LogError(ex.Message); } - if (app != null) + if (app is not null) { Remove(app); _isDirty = true; @@ -204,12 +204,12 @@ internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Prog } // When a URL application is deleted, we can no longer get the HashCode directly from the path because the FullPath a Url app is the URL obtained from reading the file - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1309:Use ordinal string comparison", Justification = "Using CurrentCultureIgnoreCase since application names could be dependent on currentculture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1309:Use ordinal string comparison", Justification = "Using CurrentCultureIgnoreCase since application names could be dependent on current culture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190")] private Win32Program? GetAppWithSameNameAndExecutable(string name, string executableName) { foreach (Win32Program app in Items) { - // Using CurrentCultureIgnoreCase since application names could be dependent on currentculture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190 + // Using CurrentCultureIgnoreCase since application names could be dependent on current culture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190 if (name.Equals(app.Name, StringComparison.CurrentCultureIgnoreCase) && executableName.Equals(app.ExecutableName, StringComparison.CurrentCultureIgnoreCase)) { return app; @@ -243,7 +243,7 @@ internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Prog if (!Path.GetExtension(path).Equals(UrlExtension, StringComparison.OrdinalIgnoreCase) && !Path.GetExtension(path).Equals(LnkExtension, StringComparison.OrdinalIgnoreCase)) { Programs.Win32Program? app = Programs.Win32Program.GetAppFromPath(path); - if (app != null) + if (app is not null) { Add(app); _isDirty = true; @@ -266,8 +266,7 @@ internal sealed class Win32ProgramRepository : ListRepository<Programs.Win32Prog public void IndexPrograms() { - var applications = Programs.Win32Program.All(_settings); - + var applications = Win32Program.All(_settings); SetList(applications); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ComFreeHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ComFreeHelper.cs new file mode 100644 index 0000000000..d298f05663 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ComFreeHelper.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; + +namespace Microsoft.CmdPal.Ext.Apps.Utils; + +public static class ComFreeHelper +{ + internal static unsafe string GetStringAndFree(HRESULT hr, PWSTR ptr) + { + hr.ThrowOnFailure(); + try + { + return ptr.ToString(); + } + finally + { + PInvoke.CoTaskMemFree(ptr); + } + } + + public static unsafe void ComObjectRelease<T>(T* comPtr) + where T : unmanaged + { + if (comPtr is not null) + { + ((IUnknown*)comPtr)->Release(); + } + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/IShellLinkHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/IShellLinkHelper.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/IShellLinkHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/IShellLinkHelper.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/SafeComHandle.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/SafeComHandle.cs new file mode 100644 index 0000000000..86db1caf33 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/SafeComHandle.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Apps.Utils; + +public partial class SafeComHandle : SafeHandle +{ + public SafeComHandle() + : base(IntPtr.Zero, ownsHandle: true) + { + } + + public SafeComHandle(IntPtr handle) + : base(IntPtr.Zero, ownsHandle: true) + { + SetHandle(handle); + } + + public override bool IsInvalid => handle == IntPtr.Zero; + + protected override bool ReleaseHandle() + { + var count = Marshal.Release(handle); + return true; + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs similarity index 95% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs index d63afb2180..dbe7a694aa 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellCommand.cs @@ -2,10 +2,7 @@ // 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; -using System.Text; -using System.Threading; namespace Microsoft.CmdPal.Ext.Apps.Utils; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellLinkHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellLinkHelper.cs new file mode 100644 index 0000000000..11652c7524 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellLinkHelper.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; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.UI.Shell; + +namespace Microsoft.CmdPal.Ext.Apps.Utils; + +public class ShellLinkHelper : IShellLinkHelper +{ + // Contains the description of the app + public string Description { get; set; } = string.Empty; + + // Contains the arguments to the app + public string Arguments { get; set; } = string.Empty; + + public bool HasArguments { get; set; } + + // Retrieve the target path using Shell Link + public unsafe string RetrieveTargetPath(string path) + { + var target = string.Empty; + const int MAX_PATH = 260; + IShellLinkW* link = null; + + PInvoke.CoCreateInstance(typeof(ShellLink).GUID, null, CLSCTX.CLSCTX_INPROC_SERVER, out link).ThrowOnFailure(); + using var linkHandle = new SafeComHandle((IntPtr)link); + + const int STGMREAD = 0; + + IPersistFile* persistFile = null; + Guid iid = typeof(IPersistFile).GUID; + ((IUnknown*)link)->QueryInterface(&iid, (void**)&persistFile); + if (persistFile is not null) + { + using var persistFileHandle = new SafeComHandle((IntPtr)persistFile); + try + { + persistFile->Load(path, STGMREAD); + } + catch (System.IO.FileNotFoundException) + { + // Log.Exception($"Failed to load {path}, {e.Message}", e, GetType()); + return string.Empty; + } + } + + var hwnd = HWND.Null; + const uint SLR_NO_UI = 0x1; + link->Resolve(hwnd, SLR_NO_UI); + + var buffer = stackalloc char[MAX_PATH]; + + var hr = link->GetPath((PWSTR)buffer, MAX_PATH, null, 0x1); + + target = hr.Succeeded ? new string(buffer) : string.Empty; + + // To set the app description + if (!string.IsNullOrEmpty(target)) + { + var descBuffer = stackalloc char[MAX_PATH]; + var desHr = link->GetDescription(descBuffer, MAX_PATH); + Description = desHr.Succeeded ? new string(descBuffer) : string.Empty; + + var argsBuffer = stackalloc char[MAX_PATH]; + var argHr = link->GetArguments(argsBuffer, MAX_PATH); + + Arguments = argHr.Succeeded ? new string(argsBuffer) : string.Empty; + + // Set variable to true if the program takes in any arguments + if (Arguments.Length != 0) + { + HasArguments = true; + } + } + + return target; + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ShellLocalization.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellLocalization.cs similarity index 74% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ShellLocalization.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellLocalization.cs index 0a3337e0a8..c157847861 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ShellLocalization.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ShellLocalization.cs @@ -4,7 +4,8 @@ using System; using System.Collections.Concurrent; using System.IO; -using static Microsoft.CmdPal.Ext.Apps.Utils.Native; +using Windows.Win32; +using Windows.Win32.UI.Shell; namespace Microsoft.CmdPal.Ext.Apps.Utils; @@ -23,7 +24,7 @@ public class ShellLocalization /// </summary> /// <param name="path">Path to the shell item (e. g. shortcut 'File Explorer.lnk').</param> /// <returns>The localized name as string or <see cref="string.Empty"/>.</returns> - public string GetLocalizedName(string path) + public unsafe string GetLocalizedName(string path) { var lowerInvariantPath = path.ToLowerInvariant(); @@ -33,18 +34,29 @@ public class ShellLocalization return value; } - var shellItemType = ShellItemTypeConstants.ShellItemGuid; - var retCode = SHCreateItemFromParsingName(path, nint.Zero, ref shellItemType, out var shellItem); - if (retCode != 0) + void* shellItemPtrVoid = null; + try + { + var retCode = PInvoke.SHCreateItemFromParsingName(path, null, typeof(IShellItem).GUID, out shellItemPtrVoid).ThrowOnFailure(); + using var shellItemHandle = new SafeComHandle((IntPtr)shellItemPtrVoid); + IShellItem* shellItemPtr = (IShellItem*)shellItemPtrVoid; + + var hr = shellItemPtr->GetDisplayName(SIGDN.SIGDN_NORMALDISPLAY, out var filenamePtr); + + var filename = ComFreeHelper.GetStringAndFree(hr, filenamePtr); + + if (filename is null) + { + return string.Empty; + } + + _ = _localizationCache.TryAdd(lowerInvariantPath, filename); + return filename; + } + catch (Exception) { return string.Empty; } - - shellItem.GetDisplayName(SIGDN.NORMALDISPLAY, out var filename); - - _ = _localizationCache.TryAdd(lowerInvariantPath, filename); - - return filename; } /// <summary> diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs similarity index 76% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs index f668fe7bf6..04fecb8379 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/Theme.cs @@ -2,12 +2,6 @@ // 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.Text; -using System.Threading.Tasks; - namespace Microsoft.CmdPal.Ext.Apps.Utils; public enum Theme diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs similarity index 71% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs index 8175667d0a..243e0e9a91 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Utils/ThemeHelper.cs @@ -4,7 +4,6 @@ using System; using System.Globalization; -using System.Linq; using Microsoft.Win32; @@ -32,7 +31,7 @@ public static class ThemeHelper // Retrieve the registry value, which is a DWORD (0 or 1) var registryValueObj = Registry.GetValue(registryKey, registryValue, null); - if (registryValueObj != null) + if (registryValueObj is not null) { // 0 = Dark mode, 1 = Light mode var isLightMode = Convert.ToBoolean((int)registryValueObj, CultureInfo.InvariantCulture); @@ -56,15 +55,25 @@ public static class ThemeHelper return Theme.Light; // Default to light theme if missing } - var theme = themePath.Split('\\').Last().Split('.').First().ToLowerInvariant(); - - return theme switch + var splitThemePath = themePath.Split('\\'); + if (splitThemePath.Length > 0) { - "hc1" => Theme.HighContrastOne, - "hc2" => Theme.HighContrastTwo, - "hcwhite" => Theme.HighContrastWhite, - "hcblack" => Theme.HighContrastBlack, - _ => Theme.Light, - }; + var lastSegment = splitThemePath[splitThemePath.Length - 1]; + var splitSegment = lastSegment.Split('.'); + if (splitSegment.Length > 0) + { + var themeVariant = splitSegment[0].ToLowerInvariant(); + return themeVariant switch + { + "hc1" => Theme.HighContrastOne, + "hc2" => Theme.HighContrastTwo, + "hcwhite" => Theme.HighContrastWhite, + "hcblack" => Theme.HighContrastBlack, + _ => Theme.Light, + }; + } + } + + return Theme.Light; } } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Assets/Bookmark.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Assets/Bookmark.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Assets/Bookmark.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Assets/Bookmark.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Assets/Bookmark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Assets/Bookmark.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Assets/Bookmark.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Assets/Bookmark.svg diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs new file mode 100644 index 0000000000..df926129fb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksCommandProvider.cs @@ -0,0 +1,130 @@ +// 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.Contracts; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.CmdPal.Ext.Bookmarks.Pages; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +public sealed partial class BookmarksCommandProvider : CommandProvider +{ + private const int LoadStateNotLoaded = 0; + private const int LoadStateLoading = 1; + private const int LoadStateLoaded = 2; + + private readonly IPlaceholderParser _placeholderParser = new PlaceholderParser(); + private readonly IBookmarksManager _bookmarksManager; + private readonly IBookmarkResolver _commandResolver; + private readonly IBookmarkIconLocator _iconLocator = new IconLocator(); + + private readonly ListItem _addNewItem; + private readonly Lock _bookmarksLock = new(); + + private ICommandItem[] _commands = []; + private List<BookmarkListItem> _bookmarks = []; + private int _loadState; + + private static string StateJsonPath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, "bookmarks.json"); + } + + public static BookmarksCommandProvider CreateWithDefaultStore() + { + return new BookmarksCommandProvider(new BookmarksManager(new FileBookmarkDataSource(StateJsonPath()))); + } + + internal BookmarksCommandProvider(IBookmarksManager bookmarksManager) + { + ArgumentNullException.ThrowIfNull(bookmarksManager); + _bookmarksManager = bookmarksManager; + _bookmarksManager.BookmarkAdded += OnBookmarkAdded; + _bookmarksManager.BookmarkRemoved += OnBookmarkRemoved; + + _commandResolver = new BookmarkResolver(_placeholderParser); + + Id = "Bookmarks"; + DisplayName = Resources.bookmarks_display_name; + Icon = Icons.PinIcon; + + var addBookmarkPage = new AddBookmarkPage(null); + addBookmarkPage.AddedCommand += (_, e) => _bookmarksManager.Add(e.Name, e.Bookmark); + _addNewItem = new ListItem(addBookmarkPage); + } + + private void OnBookmarkAdded(BookmarkData bookmarkData) + { + var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser); + lock (_bookmarksLock) + { + _bookmarks.Add(newItem); + } + + NotifyChange(); + } + + private void OnBookmarkRemoved(BookmarkData bookmarkData) + { + lock (_bookmarksLock) + { + _bookmarks.RemoveAll(t => t.BookmarkId == bookmarkData.Id); + } + + NotifyChange(); + } + + public override ICommandItem[] TopLevelCommands() + { + if (Volatile.Read(ref _loadState) != LoadStateLoaded) + { + if (Interlocked.CompareExchange(ref _loadState, LoadStateLoading, LoadStateNotLoaded) == LoadStateNotLoaded) + { + try + { + lock (_bookmarksLock) + { + _bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser))]; + _commands = BuildTopLevelCommandsUnsafe(); + } + + Volatile.Write(ref _loadState, LoadStateLoaded); + RaiseItemsChanged(); + } + catch + { + Volatile.Write(ref _loadState, LoadStateNotLoaded); + throw; + } + } + } + + return _commands; + } + + private void NotifyChange() + { + if (Volatile.Read(ref _loadState) != LoadStateLoaded) + { + return; + } + + lock (_bookmarksLock) + { + _commands = BuildTopLevelCommandsUnsafe(); + } + + RaiseItemsChanged(); + } + + [Pure] + private ICommandItem[] BuildTopLevelCommandsUnsafe() => [_addNewItem, .. _bookmarks]; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs new file mode 100644 index 0000000000..fde574360f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarksManager.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal sealed partial class BookmarksManager : IDisposable, IBookmarksManager +{ + private readonly IBookmarkDataSource _dataSource; + private readonly BookmarkJsonParser _parser = new(); + private readonly SupersedingAsyncGate _savingGate; + private readonly Lock _lock = new(); + private BookmarksData _bookmarksData = new(); + + public event Action<BookmarkData>? BookmarkAdded; + + public event Action<BookmarkData, BookmarkData>? BookmarkUpdated; // old, new + + public event Action<BookmarkData>? BookmarkRemoved; + + public IReadOnlyCollection<BookmarkData> Bookmarks + { + get + { + lock (_lock) + { + return _bookmarksData.Data.ToList().AsReadOnly(); + } + } + } + + public BookmarksManager(IBookmarkDataSource dataSource) + { + ArgumentNullException.ThrowIfNull(dataSource); + _dataSource = dataSource; + _savingGate = new SupersedingAsyncGate(WriteData); + LoadBookmarksFromFile(); + } + + public BookmarkData Add(string name, string bookmark) + { + var newBookmark = new BookmarkData(name, bookmark); + + lock (_lock) + { + _bookmarksData.Data.Add(newBookmark); + _ = SaveChangesAsync(); + BookmarkAdded?.Invoke(newBookmark); + return newBookmark; + } + } + + public bool Remove(Guid id) + { + lock (_lock) + { + var bookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id); + if (bookmark != null && _bookmarksData.Data.Remove(bookmark)) + { + _ = SaveChangesAsync(); + BookmarkRemoved?.Invoke(bookmark); + return true; + } + + return false; + } + } + + public BookmarkData? Update(Guid id, string name, string bookmark) + { + lock (_lock) + { + var existingBookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id); + if (existingBookmark != null) + { + var updatedBookmark = existingBookmark with + { + Name = name, + Bookmark = bookmark, + }; + + var index = _bookmarksData.Data.IndexOf(existingBookmark); + _bookmarksData.Data[index] = updatedBookmark; + + _ = SaveChangesAsync(); + BookmarkUpdated?.Invoke(existingBookmark, updatedBookmark); + return updatedBookmark; + } + + return null; + } + } + + private void LoadBookmarksFromFile() + { + try + { + var jsonData = _dataSource.GetBookmarkData(); + var bookmarksData = _parser.ParseBookmarks(jsonData); + + // Upgrade old bookmarks if necessary + // Pre .95 versions did not assign IDs to bookmarks + var upgraded = false; + for (var index = 0; index < bookmarksData.Data.Count; index++) + { + var bookmark = bookmarksData.Data[index]; + if (bookmark.Id == Guid.Empty) + { + bookmarksData.Data[index] = bookmark with { Id = Guid.NewGuid() }; + upgraded = true; + } + } + + lock (_lock) + { + _bookmarksData = bookmarksData; + } + + // LOAD BEARING: Save upgraded data back to file + // This ensures that old bookmarks are not repeatedly upgraded on each load, + // as the hotkeys and aliases are tied to the generated bookmark IDs. + if (upgraded) + { + _ = SaveChangesAsync(); + } + } + catch (Exception ex) + { + Logger.LogError(ex.Message); + } + } + + private Task WriteData(CancellationToken arg) + { + List<BookmarkData> dataToSave; + lock (_lock) + { + dataToSave = _bookmarksData.Data.ToList(); + } + + try + { + var jsonData = _parser.SerializeBookmarks(new BookmarksData { Data = dataToSave }); + _dataSource.SaveBookmarkData(jsonData); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save bookmarks: {ex.Message}"); + } + + return Task.CompletedTask; + } + + private async Task SaveChangesAsync() + { + await _savingGate.ExecuteAsync(CancellationToken.None); + } + + public void Dispose() => _savingGate.Dispose(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/DeleteBookmarkCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/DeleteBookmarkCommand.cs new file mode 100644 index 0000000000..d6087b1481 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/DeleteBookmarkCommand.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Commands; + +internal sealed partial class DeleteBookmarkCommand : InvokableCommand +{ + private readonly BookmarkData _bookmark; + private readonly IBookmarksManager _bookmarksManager; + + public DeleteBookmarkCommand(BookmarkData bookmark, IBookmarksManager bookmarksManager) + { + ArgumentNullException.ThrowIfNull(bookmark); + ArgumentNullException.ThrowIfNull(bookmarksManager); + + _bookmark = bookmark; + _bookmarksManager = bookmarksManager; + Name = Resources.bookmarks_delete_name; + Icon = Icons.DeleteIcon; + } + + public override CommandResult Invoke() + { + _bookmarksManager.Remove(_bookmark.Id); + return CommandResult.GoHome(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/LaunchBookmarkCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/LaunchBookmarkCommand.cs new file mode 100644 index 0000000000..a5b3c460ba --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Commands/LaunchBookmarkCommand.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.Globalization; +using System.Text; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Commands; + +internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokableCommand, IDisposable +{ + private static readonly CompositeFormat FailedToOpenMessageFormat = CompositeFormat.Parse(Resources.bookmark_toast_failed_open_text!); + + private readonly BookmarkData _bookmarkData; + private readonly Dictionary<string, string>? _placeholders; + private readonly IBookmarkResolver _bookmarkResolver; + private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate; + private readonly Classification _classification; + + private IIconInfo? _icon; + + public IIconInfo Icon => _icon ?? Icons.Reloading; + + public string Name { get; } + + public string Id { get; } + + public LaunchBookmarkCommand(BookmarkData bookmarkData, Classification classification, IBookmarkIconLocator iconLocator, IBookmarkResolver bookmarkResolver, Dictionary<string, string>? placeholders = null) + { + ArgumentNullException.ThrowIfNull(bookmarkData); + ArgumentNullException.ThrowIfNull(classification); + + _bookmarkData = bookmarkData; + _classification = classification; + _placeholders = placeholders; + _bookmarkResolver = bookmarkResolver; + + Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id); + Name = Resources.bookmarks_command_name_open; + + _iconReloadGate = new( + async ct => await iconLocator.GetIconForPath(_classification, ct), + icon => + { + _icon = icon; + OnPropertyChanged(nameof(Icon)); + }); + + RequestIconReloadAsync(); + } + + private void RequestIconReloadAsync() + { + _icon = null; + OnPropertyChanged(nameof(Icon)); + _ = _iconReloadGate.ExecuteAsync(); + } + + public ICommandResult Invoke(object sender) + { + var bookmarkAddress = ReplacePlaceholders(_bookmarkData.Bookmark); + var classification = _bookmarkResolver.ClassifyOrUnknown(bookmarkAddress); + + var success = CommandLauncher.Launch(classification); + + return success + ? CommandResult.Dismiss() + : CommandResult.ShowToast(new ToastArgs + { + Message = !string.IsNullOrWhiteSpace(_bookmarkData.Name) + ? string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, _bookmarkData.Name + ": " + bookmarkAddress) + : string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, bookmarkAddress), + Result = CommandResult.KeepOpen(), + }); + } + + private string ReplacePlaceholders(string input) + { + var result = input; + if (_placeholders?.Count > 0) + { + foreach (var (key, value) in _placeholders) + { + var placeholderString = $"{{{key}}}"; + + var encodedValue = value; + if (_classification.Kind is CommandKind.Protocol or CommandKind.WebUrl) + { + encodedValue = Uri.EscapeDataString(value); + } + + result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase); + } + } + + return result; + } + + public void Dispose() + { + _iconReloadGate.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/GlobalUsings.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/GlobalUsings.cs new file mode 100644 index 0000000000..c391ea8586 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/GlobalUsings.cs @@ -0,0 +1,8 @@ +// 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. + +global using System; +global using System.Collections.Generic; +global using Microsoft.CmdPal.Ext.Bookmarks.Properties; +global using Microsoft.CommandPalette.Extensions.Toolkit; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/Classification.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/Classification.cs new file mode 100644 index 0000000000..a9b1cb4837 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/Classification.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. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +public sealed record Classification( + CommandKind Kind, + string Input, + string Target, + string Arguments, + LaunchMethod Launch, + string? WorkingDirectory, + bool IsPlaceholder, + string? FileSystemTarget = null, + string? DisplayName = null) +{ + public static Classification Unknown(string rawInput) => + new(CommandKind.Unknown, rawInput, rawInput, string.Empty, LaunchMethod.ShellExecute, string.Empty, false, null, null); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandIds.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandIds.cs new file mode 100644 index 0000000000..57d82b6e30 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandIds.cs @@ -0,0 +1,15 @@ +// 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.Ext.Bookmarks.Helpers; + +internal static class CommandIds +{ + /// <summary> + /// Returns id of a command associated with a bookmark item. This id is for a command that launches the bookmark - regardless of whether + /// the bookmark type of if it is a placeholder bookmark or not. + /// </summary> + /// <param name="id">Bookmark ID</param> + public static string GetLaunchBookmarkItemId(Guid id) => "Bookmarks.Launch." + id; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandKind.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandKind.cs new file mode 100644 index 0000000000..9c9f0f053d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandKind.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +/// <summary> +/// Classifies a command or bookmark target type. +/// </summary> +public enum CommandKind +{ + /// <summary> + /// Unknown or unsupported target. + /// </summary> + Unknown = 0, + + /// <summary> + /// HTTP/HTTPS URL. + /// </summary> + WebUrl, + + /// <summary> + /// Any non-file URI scheme (e.g., mailto:, ms-settings:, wt:, myapp:). + /// </summary> + Protocol, + + /// <summary> + /// Application User Model ID (e.g., shell:AppsFolder\AUMID or pkgfamily!app). + /// </summary> + Aumid, + + /// <summary> + /// Existing folder path. + /// </summary> + Directory, + + /// <summary> + /// Existing executable file (e.g., .exe, .bat, .cmd). + /// </summary> + FileExecutable, + + /// <summary> + /// Existing document file. + /// </summary> + FileDocument, + + /// <summary> + /// Windows shortcut file (*.lnk). + /// </summary> + Shortcut, + + /// <summary> + /// Internet shortcut file (*.url). + /// </summary> + InternetShortcut, + + /// <summary> + /// Bare command resolved via PATH/PATHEXT (e.g., "wt", "git"). + /// </summary> + PathCommand, + + /// <summary> + /// Shell item not matching other types (e.g., Control Panel item, purely virtual directory). + /// </summary> + VirtualShellItem, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLauncher.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLauncher.cs new file mode 100644 index 0000000000..742e272f4b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLauncher.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using ManagedCommon; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static class CommandLauncher +{ + /// <summary> + /// Launches the classified item. + /// </summary> + /// <param name="classification">Classification produced by CommandClassifier.</param> + /// <param name="runAsAdmin">Optional: force elevation if possible.</param> + public static bool Launch(Classification classification, bool runAsAdmin = false) + { + switch (classification.Launch) + { + case LaunchMethod.ExplorerOpen: + // Folders and shell: URIs are best handled by explorer.exe + // You can notice the difference with Recycle Bin for example: + // - "explorer ::{645FF040-5081-101B-9F08-00AA002F954E}" + // - "::{645FF040-5081-101B-9F08-00AA002F954E}" + return ShellHelpers.OpenInShell("explorer.exe", classification.Target); + + case LaunchMethod.ActivateAppId: + return ActivateAppId(classification.Target, classification.Arguments); + + case LaunchMethod.ShellExecute: + default: + return ShellHelpers.OpenInShell(classification.Target, classification.Arguments, classification.WorkingDirectory, runAsAdmin ? ShellHelpers.ShellRunAsType.Administrator : ShellHelpers.ShellRunAsType.None); + } + } + + private static bool ActivateAppId(string aumidOrAppsFolder, string? arguments) + { + const string shellAppsFolder = "shell:AppsFolder\\"; + try + { + if (aumidOrAppsFolder.StartsWith(shellAppsFolder, StringComparison.OrdinalIgnoreCase)) + { + aumidOrAppsFolder = aumidOrAppsFolder[shellAppsFolder.Length..]; + } + + ApplicationActivationManager.ActivateApplication(aumidOrAppsFolder, arguments, 0, out _); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Can't activate AUMID using app store '{aumidOrAppsFolder}'", ex); + } + + try + { + ShellHelpers.OpenInShell(shellAppsFolder + aumidOrAppsFolder, arguments); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Can't activate AUMID using shell '{aumidOrAppsFolder}'", ex); + } + + return false; + } + + private static class ApplicationActivationManager + { + public static void ActivateApplication(string aumid, string? args, int options, out uint pid) + { + var mgr = (IApplicationActivationManager)new _ApplicationActivationManager(); + var hr = mgr.ActivateApplication(aumid, args ?? string.Empty, options, out pid); + if (hr < 0) + { + throw new Win32Exception(hr); + } + } + + [ComImport] + [Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Private class")] + private class _ApplicationActivationManager; + + [ComImport] + [Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IApplicationActivationManager + { + int ActivateApplication( + [MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, + [MarshalAs(UnmanagedType.LPWStr)] string arguments, + int options, + out uint processId); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLineHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLineHelper.cs new file mode 100644 index 0000000000..1d7cd1aca2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/CommandLineHelper.cs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +/// <summary> +/// Provides helper methods for parsing command lines and expanding paths. +/// </summary> +/// <remarks> +/// Warning: This code handles parsing specifically for Bookmarks, and is NOT a general-purpose command line parser. +/// In some cases it mimics system rules (e.g. CreateProcess, CommandLineToArgvW) but in other cases it uses, but it can also +/// bend the rules to be more forgiving. +/// </remarks> +internal static partial class CommandLineHelper +{ + private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + + public static string[] SplitCommandLine(string commandLine) + { + ArgumentNullException.ThrowIfNull(commandLine); + + var argv = NativeMethods.CommandLineToArgvW(commandLine, out var argc); + if (argv == IntPtr.Zero) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + try + { + var result = new string[argc]; + for (var i = 0; i < argc; i++) + { + var p = Marshal.ReadIntPtr(argv, i * IntPtr.Size); + result[i] = Marshal.PtrToStringUni(p)!; + } + + return result; + } + finally + { + NativeMethods.LocalFree(argv); + } + } + + /// <summary> + /// Splits the raw command line into the first argument (Head) and the remainder (Tail). This method follows the rules + /// of CommandLineToArgvW. + /// </summary> + /// <remarks> + /// This is a mental support for SplitLongestHeadBeforeQuotedArg. + /// + /// Rules: + /// - If the input starts with any whitespace, Head is an empty string (per CommandLineToArgvW behavior for first segment, handles by CreateProcess rules). + /// - Otherwise, Head uses the CreateProcess "program name" rule: + /// - If the first char is a quote, Head is everything up to the next quote (backslashes do NOT escape it). + /// - Else, Head is the run up to the first whitespace. + /// - Tail starts at the first non-whitespace character after Head (or is empty if nothing remains). + /// No normalization is performed; returned slices preserve the original text (no un/escaping). + /// </remarks> + public static (string Head, string Tail) SplitHeadAndArgs(string input) + { + ArgumentNullException.ThrowIfNull(input); + + if (input.Length == 0) + { + return (string.Empty, string.Empty); + } + + var s = input.AsSpan(); + var n = s.Length; + var i = 0; + + // Leading whitespace -> empty argv[0] + if (char.IsWhiteSpace(s[0])) + { + while (i < n && char.IsWhiteSpace(s[i])) + { + i++; + } + + var tailAfterWs = i < n ? input[i..] : string.Empty; + return (string.Empty, tailAfterWs); + } + + string head; + if (s[i] == '"') + { + // Quoted program name: everything up to the next unescaped quote (CreateProcess rule: slashes don't escape here) + i++; + var start = i; + while (i < n && s[i] != '"') + { + i++; + } + + head = input.Substring(start, i - start); + if (i < n && s[i] == '"') + { + i++; // consume closing quote + } + } + else + { + // Unquoted program name: read to next whitespace + var start = i; + while (i < n && !char.IsWhiteSpace(s[i])) + { + i++; + } + + head = input.Substring(start, i - start); + } + + // Skip inter-argument whitespace; tail begins at the next non-ws char (or is empty) + while (i < n && char.IsWhiteSpace(s[i])) + { + i++; + } + + var tail = i < n ? input[i..] : string.Empty; + + return (head, tail); + } + + /// <summary> + /// Returns the longest possible head (may include spaces) and the tail that starts at the + /// first *quoted argument*. + /// + /// Definition of "quoted argument start": + /// - A token boundary (start-of-line or preceded by whitespace), + /// - followed by zero or more backslashes, + /// - followed by a double-quote ("), + /// - where the number of immediately preceding backslashes is EVEN (so the quote toggles quoting). + /// + /// Notes: + /// - Quotes appearing mid-token (e.g., C:\Some\"Path\file.txt) do NOT stop the head. + /// - Trailing spaces before the quoted arg are not included in Head; Tail begins at that quote. + /// - Leading whitespace before the first token is ignored (Head starts from first non-ws). + /// Examples: + /// C:\app exe -p "1" -q -> Head: "C:\app exe -p", Tail: "\"1\" -q" + /// "\\server\share\" with args -> Head: "", Tail: "\"\\\\server\\share\\\" with args" + /// C:\Some\"Path\file.txt -> Head: "C:\\Some\\\"Path\\file.txt", Tail: "" + /// </summary> + public static (string Head, string Tail) SplitLongestHeadBeforeQuotedArg(string input) + { + ArgumentNullException.ThrowIfNull(input); + + if (input.Length == 0) + { + return (string.Empty, string.Empty); + } + + var s = input.AsSpan(); + var n = s.Length; + + // Start at first non-whitespace (we don't treat leading ws as part of Head here) + var start = 0; + while (start < n && char.IsWhiteSpace(s[start])) + { + start++; + } + + if (start >= n) + { + return (string.Empty, string.Empty); + } + + // Scan for a quote that OPENS a quoted argument at a token boundary. + for (var i = start; i < n; i++) + { + if (s[i] != '"') + { + continue; + } + + // Count immediate backslashes before this quote + int j = i - 1, backslashes = 0; + while (j >= start && s[j] == '\\') + { + backslashes++; + j--; + } + + // The quote is at a token boundary if the char before the backslashes is start-of-line or whitespace. + var atTokenBoundary = j < start || char.IsWhiteSpace(s[j]); + + // Even number of backslashes -> this quote toggles quoting (opens if at boundary). + if (atTokenBoundary && (backslashes % 2 == 0)) + { + // Trim trailing spaces off Head so Tail starts exactly at the opening quote + var headEnd = i; + while (headEnd > start && char.IsWhiteSpace(s[headEnd - 1])) + { + headEnd--; + } + + var head = input[start..headEnd]; + var tail = input[headEnd..]; // starts at the opening quote + return (head, tail.Trim()); + } + } + + // No quoted-arg start found: entire remainder (trimmed right) is the Head + var wholeHead = input[start..].TrimEnd(); + return (wholeHead, string.Empty); + } + + /// <summary> + /// Attempts to expand the path to full physical path, expanding environment variables and shell: monikers. + /// </summary> + internal static bool ExpandPathToPhysicalFile(string input, bool expandShell, out string full) + { + if (string.IsNullOrEmpty(input)) + { + full = string.Empty; + return false; + } + + var expanded = Environment.ExpandEnvironmentVariables(input); + + var firstSegment = GetFirstPathSegment(expanded); + if (expandShell && HasShellPrefix(firstSegment) && TryExpandShellMoniker(expanded, out var shellExpanded)) + { + expanded = shellExpanded; + } + else if (firstSegment is "~" or "." or "..") + { + expanded = ExpandUserRelative(firstSegment, expanded); + } + + if (Path.Exists(expanded)) + { + full = Path.GetFullPath(expanded); + return true; + } + + full = expanded; // return the attempted expansion even if it doesn't exist + return false; + } + + private static bool TryExpandShellMoniker(string input, out string expanded) + { + var separatorIndex = input.IndexOfAny(PathSeparators); + var shellFolder = separatorIndex > 0 ? input[..separatorIndex] : input; + var relativePath = separatorIndex > 0 ? input[(separatorIndex + 1)..] : string.Empty; + + if (ShellNames.TryGetFileSystemPath(shellFolder, out var fsPath)) + { + expanded = Path.GetFullPath(Path.Combine(fsPath, relativePath)); + return true; + } + + expanded = input; + return false; + } + + private static string ExpandUserRelative(string firstSegment, string input) + { + // Treat relative paths as relative to the user home directory. + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (firstSegment == "~") + { + // Remove "~" (+ optional following separator) before combining. + var skip = 1; + if (input.Length > 1 && IsSeparator(input[1])) + { + skip++; + } + + input = input[skip..]; + } + + return Path.GetFullPath(Path.Combine(homeDirectory, input)); + } + + private static bool IsSeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + + private static string GetFirstPathSegment(string input) + { + var separatorIndex = input.IndexOfAny(PathSeparators); + return separatorIndex > 0 ? input[..separatorIndex] : input; + } + + internal static bool HasShellPrefix(string input) + { + return input.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || input.StartsWith("::", StringComparison.Ordinal); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/LaunchMethod.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/LaunchMethod.cs new file mode 100644 index 0000000000..eaedb88aea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/LaunchMethod.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.Ext.Bookmarks.Helpers; + +public enum LaunchMethod +{ + ShellExecute, // UseShellExecute = true (Explorer/associations/protocols) + ExplorerOpen, // explorer.exe <folder/shell:uri> + ActivateAppId, // IApplicationActivationManager (AUMID / pkgfamily!app) +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/NativeMethods.cs new file mode 100644 index 0000000000..9cba2aba74 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/NativeMethods.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +internal static partial class NativeMethods +{ + [LibraryImport("shell32.dll", EntryPoint = "SHParseDisplayName", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int SHParseDisplayName( + string pszName, + nint pbc, + out nint ppidl, + uint sfgaoIn, + nint psfgaoOut); + + [LibraryImport("shell32.dll", EntryPoint = "SHGetNameFromIDList", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int SHGetNameFromIDList( + nint pidl, + SIGDN sigdnName, + out nint ppszName); + + [LibraryImport("ole32.dll")] + internal static partial void CoTaskMemFree(nint pv); + + [LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + internal static partial IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs); + + [LibraryImport("kernel32.dll")] + internal static partial IntPtr LocalFree(IntPtr hMem); + + internal enum SIGDN : uint + { + NORMALDISPLAY = 0x00000000, + DESKTOPABSOLUTEPARSING = 0x80028000, + DESKTOPABSOLUTEEDITING = 0x8004C000, + FILESYSPATH = 0x80058000, + URL = 0x80068000, + PARENTRELATIVE = 0x80080001, + PARENTRELATIVEFORADDRESSBAR = 0x8007C001, + PARENTRELATIVEPARSING = 0x80018001, + PARENTRELATIVEEDITING = 0x80031001, + PARENTRELATIVEFORUI = 0x80094001, + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/ShellNames.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/ShellNames.cs new file mode 100644 index 0000000000..d290deff47 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/ShellNames.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.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +/// <summary> +/// Helpers for getting user-friendly shell names and paths. +/// </summary> +internal static class ShellNames +{ + /// <summary> + /// Tries to get a localized friendly name (e.g. "This PC", "Downloads") for a shell path like: + /// - "shell:Downloads" + /// - "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}" + /// - "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}" + /// </summary> + public static bool TryGetFriendlyName(string shellPath, [NotNullWhen(true)] out string? displayName) + { + displayName = null; + + // Normalize a bare GUID to the "::" moniker if someone passes only "{GUID}" + if (shellPath.Length > 0 && shellPath[0] == '{' && shellPath[^1] == '}') + { + shellPath = "::" + shellPath; + } + + nint pidl = 0; + try + { + var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0); + if (hr != 0 || pidl == 0) + { + return false; + } + + // Ask for the human-friendly localized name + nint psz; + hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.NORMALDISPLAY, out psz); + if (hr != 0 || psz == 0) + { + return false; + } + + try + { + displayName = Marshal.PtrToStringUni(psz); + return !string.IsNullOrWhiteSpace(displayName); + } + finally + { + NativeMethods.CoTaskMemFree(psz); + } + } + finally + { + if (pidl != 0) + { + NativeMethods.CoTaskMemFree(pidl); + } + } + } + + /// <summary> + /// Optionally, also try to obtain a filesystem path (if the item represents one). + /// Returns false for purely virtual items like "This PC". + /// </summary> + public static bool TryGetFileSystemPath(string shellPath, [NotNullWhen(true)] out string? fileSystemPath) + { + fileSystemPath = null; + + nint pidl = 0; + try + { + var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0); + if (hr != 0 || pidl == 0) + { + return false; + } + + nint psz; + hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.FILESYSPATH, out psz); + if (hr != 0 || psz == 0) + { + return false; + } + + try + { + fileSystemPath = Marshal.PtrToStringUni(psz); + return !string.IsNullOrWhiteSpace(fileSystemPath); + } + finally + { + NativeMethods.CoTaskMemFree(psz); + } + } + finally + { + if (pidl != 0) + { + NativeMethods.CoTaskMemFree(pidl); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/UriHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/UriHelper.cs new file mode 100644 index 0000000000..14befe9a68 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Helpers/UriHelper.cs @@ -0,0 +1,53 @@ +// 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.Ext.Bookmarks.Helpers; + +internal static class UriHelper +{ + /// <summary> + /// Tries to split a URI string into scheme and remainder. + /// Scheme must be valid per RFC 3986 and followed by ':'. + /// </summary> + public static bool TryGetScheme(ReadOnlySpan<char> input, out string scheme, out string remainder) + { + // https://datatracker.ietf.org/doc/html/rfc3986#page-17 + scheme = string.Empty; + remainder = string.Empty; + + if (input.Length < 2) + { + return false; // must have at least "a:" + } + + // Must contain ':' delimiter + var colonIndex = input.IndexOf(':'); + if (colonIndex <= 0) + { + return false; // no colon or colon at start + } + + // First char must be a letter + var first = input[0]; + if (!char.IsLetter(first)) + { + return false; + } + + // Validate scheme part + for (var i = 1; i < colonIndex; i++) + { + var c = input[i]; + if (!(char.IsLetterOrDigit(c) || c == '+' || c == '-' || c == '.')) + { + return false; + } + } + + // Extract scheme and remainder + scheme = input[..colonIndex].ToString(); + remainder = colonIndex + 1 < input.Length ? input[(colonIndex + 1)..].ToString() : string.Empty; + return true; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarksManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarksManager.cs new file mode 100644 index 0000000000..74ab025d0f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/IBookmarksManager.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 Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal interface IBookmarksManager +{ + event Action<BookmarkData>? BookmarkAdded; + + event Action<BookmarkData, BookmarkData>? BookmarkUpdated; + + event Action<BookmarkData>? BookmarkRemoved; + + IReadOnlyCollection<BookmarkData> Bookmarks { get; } + + BookmarkData Add(string name, string bookmark); + + bool Remove(Guid id); + + BookmarkData? Update(Guid id, string name, string bookmark); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.cs new file mode 100644 index 0000000000..6e7d955606 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Icons.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. + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal static class Icons +{ + internal static IconInfo BookmarkIcon { get; } = IconHelpers.FromRelativePath("Assets\\Bookmark.svg"); + + internal static IconInfo DeleteIcon { get; } = new("\uE74D"); // Delete + + internal static IconInfo EditIcon { get; } = new("\uE70F"); // Edit + + internal static IconInfo PinIcon { get; } = new IconInfo("\uE718"); // Pin + + internal static IconInfo Reloading { get; } = new IconInfo("\uF16A"); // ProgressRing + + internal static IconInfo CopyPath { get; } = new IconInfo("\uE8C8"); // Copy + + internal static class BookmarkTypes + { + internal static IconInfo WebUrl { get; } = new("\uE774"); // Globe + + internal static IconInfo FilePath { get; } = new("\uE8A5"); // OpenFile + + internal static IconInfo FolderPath { get; } = new("\uE8B7"); // OpenFolder + + internal static IconInfo Application { get; } = new("\uE737"); // Favicon (~looks like empty window) + + internal static IconInfo Command { get; } = new("\uE756"); // CommandPrompt + + internal static IconInfo Unknown { get; } = new("\uE71B"); // Link + + internal static IconInfo Game { get; } = new("\uE7FC"); // Game controller + } + + private static IconInfo DualColorFromRelativePath(string name) + { + return IconHelpers.FromRelativePaths($"Assets\\Icons\\{name}.light.svg", $"Assets\\Icons\\{name}.dark.svg"); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/KeyChords.cs new file mode 100644 index 0000000000..18d818b727 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/KeyChords.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 Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CommandPalette.Extensions; +using Windows.System; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +internal static class KeyChords +{ + internal static KeyChord CopyPath => WellKnownKeyChords.CopyFilePath; + + internal static KeyChord OpenFileLocation => WellKnownKeyChords.OpenFileLocation; + + internal static KeyChord OpenInConsole => WellKnownKeyChords.OpenInConsole; + + internal static KeyChord DeleteBookmark => KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj similarity index 71% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj index eaa4ac6b42..f3194986d6 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj @@ -1,5 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.Bookmarks</RootNamespace> <Nullable>enable</Nullable> @@ -10,13 +11,15 @@ <ProjectPriFileName>Microsoft.CmdPal.Ext.Bookmarks.pri</ProjectPriFileName> </PropertyGroup> <ItemGroup> - <None Remove="Assets\Bookmark.svg" /> + <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> - <ProjectReference Include="..\..\Exts\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" /> + <Content Update="Assets\**\*.*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> </ItemGroup> - + <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> <DependentUpon>Resources.resx</DependentUpon> @@ -24,20 +27,15 @@ <AutoGen>True</AutoGen> </Compile> </ItemGroup> - - <ItemGroup> - <Content Update="Assets\Bookmark.png"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Update="Assets\Bookmark.svg"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - </ItemGroup> + <ItemGroup> <EmbeddedResource Update="Properties\Resources.resx"> <LastGenOutput>Resources.Designer.cs</LastGenOutput> <Generator>PublicResXFileCodeGenerator</Generator> </EmbeddedResource> </ItemGroup> - + + <ItemGroup> + <InternalsVisibleTo Include="Microsoft.CmdPal.Ext.Bookmarks.UnitTests" /> + </ItemGroup> </Project> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs new file mode 100644 index 0000000000..e165bfd0d4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkForm.cs @@ -0,0 +1,125 @@ +// 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 System.Text.Json.Nodes; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class AddBookmarkForm : FormContent +{ + private readonly BookmarkData? _bookmark; + + internal event TypedEventHandler<object, BookmarkData>? AddedCommand; + + public AddBookmarkForm(BookmarkData? bookmark) + { + _bookmark = bookmark; + var name = bookmark?.Name ?? string.Empty; + var url = bookmark?.Bookmark ?? string.Empty; + TemplateJson = $$""" +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "Input.Text", + "style": "text", + "id": "bookmark", + "value": {{EncodeString(url)}}, + "label": {{EncodeString(Resources.bookmarks_form_bookmark_label)}}, + "isRequired": true, + "errorMessage": {{EncodeString(Resources.bookmarks_form_bookmark_required)}}, + "placeholder": {{EncodeString(Resources.bookmarks_form_bookmark_placeholder)}} + }, + { + "type": "Input.Text", + "style": "text", + "id": "name", + "label": {{EncodeString(Resources.bookmarks_form_name_label)}}, + "value": {{EncodeString(name)}}, + "isRequired": false + }, + { + "type": "RichTextBlock", + "inlines": [ + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text1)}}, + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": " ", + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text2)}}, + "fontType": "Monospace", + "size": "Small" + }, + { + "type": "TextRun", + "text": " ", + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text3)}}, + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": " ", + "isSubtle": true, + "size": "Small" + }, + { + "type": "TextRun", + "text": {{EncodeString(Resources.bookmarks_form_hint_text4)}}, + "fontType": "Monospace", + "size": "Small" + } + ] + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": {{EncodeString(Resources.bookmarks_form_save)}}, + "data": { + "name": "name", + "bookmark": "bookmark" + } + } + ] +} +"""; + } + + private static string EncodeString(string s) => JsonSerializer.Serialize(s, BookmarkSerializationContext.Default.String); + + public override CommandResult SubmitForm(string payload) + { + var formInput = JsonNode.Parse(payload); + if (formInput is null) + { + return CommandResult.GoHome(); + } + + // get the name and url out of the values + var formName = formInput["name"] ?? string.Empty; + var formBookmark = formInput["bookmark"] ?? string.Empty; + AddedCommand?.Invoke(this, new BookmarkData(formName.ToString(), formBookmark.ToString()) { Id = _bookmark?.Id ?? Guid.Empty }); + return CommandResult.GoHome(); + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkPage.cs similarity index 63% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkPage.cs index a4f9ba0050..927044e77c 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/AddBookmarkPage.cs @@ -2,33 +2,33 @@ // 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.Ext.Bookmarks.Properties; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; -namespace Microsoft.CmdPal.Ext.Bookmarks; +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; internal sealed partial class AddBookmarkPage : ContentPage { - private readonly AddBookmarkForm _addBookmark; - internal event TypedEventHandler<object, BookmarkData>? AddedCommand { - add => _addBookmark.AddedCommand += value; - remove => _addBookmark.AddedCommand -= value; + add => _addBookmarkForm.AddedCommand += value; + remove => _addBookmarkForm.AddedCommand -= value; } - public override IContent[] GetContent() => [_addBookmark]; + private readonly AddBookmarkForm _addBookmarkForm; public AddBookmarkPage(BookmarkData? bookmark) { var name = bookmark?.Name ?? string.Empty; var url = bookmark?.Bookmark ?? string.Empty; - Icon = IconHelpers.FromRelativePath("Assets\\Bookmark.svg"); + + Icon = Icons.BookmarkIcon; var isAdd = string.IsNullOrEmpty(name) && string.IsNullOrEmpty(url); Title = isAdd ? Resources.bookmarks_add_title : Resources.bookmarks_edit_name; Name = isAdd ? Resources.bookmarks_add_name : Resources.bookmarks_edit_name; - _addBookmark = new(bookmark); + _addBookmarkForm = new AddBookmarkForm(bookmark); } + + public override IContent[] GetContent() => [_addBookmarkForm]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkListItem.cs new file mode 100644 index 0000000000..fe1e56c66e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkListItem.cs @@ -0,0 +1,304 @@ +// 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.IO; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Common.Commands; +using Microsoft.CmdPal.Core.Common.Commands; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Commands; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CmdPal.Ext.Indexer; +using Microsoft.CommandPalette.Extensions; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class BookmarkListItem : ListItem, IDisposable +{ + private readonly IBookmarksManager _bookmarksManager; + private readonly IBookmarkResolver _commandResolver; + private readonly IBookmarkIconLocator _iconLocator; + private readonly IPlaceholderParser _placeholderParser; + private readonly SupersedingAsyncValueGate<BookmarkListItemReclassifyResult> _classificationGate; + private readonly TaskCompletionSource _initializationTcs = new(); + + private BookmarkData _bookmark; + + public Task IsInitialized => _initializationTcs.Task; + + public string BookmarkAddress => _bookmark.Bookmark; + + public string BookmarkTitle => _bookmark.Name; + + public Guid BookmarkId => _bookmark.Id; + + public BookmarkListItem(BookmarkData bookmark, IBookmarksManager bookmarksManager, IBookmarkResolver commandResolver, IBookmarkIconLocator iconLocator, IPlaceholderParser placeholderParser) + { + ArgumentNullException.ThrowIfNull(bookmark); + ArgumentNullException.ThrowIfNull(bookmarksManager); + ArgumentNullException.ThrowIfNull(commandResolver); + + _bookmark = bookmark; + _bookmarksManager = bookmarksManager; + _bookmarksManager.BookmarkUpdated += BookmarksManagerOnBookmarkUpdated; + _commandResolver = commandResolver; + _iconLocator = iconLocator; + _placeholderParser = placeholderParser; + _classificationGate = new SupersedingAsyncValueGate<BookmarkListItemReclassifyResult>(ClassifyAsync, ApplyClassificationResult); + _ = _classificationGate.ExecuteAsync(); + } + + private void BookmarksManagerOnBookmarkUpdated(BookmarkData original, BookmarkData @new) + { + if (original.Id == _bookmark.Id) + { + Update(@new); + } + } + + public void Dispose() + { + _classificationGate.Dispose(); + var existing = Command; + if (existing != null) + { + existing.PropChanged -= CommandPropertyChanged; + } + } + + private void Update(BookmarkData data) + { + ArgumentNullException.ThrowIfNull(data); + + try + { + _bookmark = data; + OnPropertyChanged(nameof(BookmarkTitle)); + OnPropertyChanged(nameof(BookmarkAddress)); + + Subtitle = Resources.bookmarks_item_refreshing; + _ = _classificationGate.ExecuteAsync(); + } + catch (Exception ex) + { + Logger.LogError("Failed to update bookmark", ex); + } + } + + private async Task<BookmarkListItemReclassifyResult> ClassifyAsync(CancellationToken ct) + { + TypedEventHandler<object, BookmarkData> bookmarkSavedHandler = BookmarkSaved; + List<IContextItem> contextMenu = []; + + var classification = (await _commandResolver.TryClassifyAsync(_bookmark.Bookmark, ct)).Result; + + var title = BuildTitle(_bookmark, classification); + var subtitle = BuildSubtitle(_bookmark, classification); + + ICommand command = classification.IsPlaceholder + ? new BookmarkPlaceholderPage(_bookmark, _iconLocator, _commandResolver, _placeholderParser) + : new LaunchBookmarkCommand(_bookmark, classification, _iconLocator, _commandResolver); + + BuildSpecificContextMenuItems(classification, contextMenu); + AddCommonContextMenuItems(_bookmark, _bookmarksManager, bookmarkSavedHandler, contextMenu); + + return new BookmarkListItemReclassifyResult( + command, + title, + subtitle, + contextMenu.ToArray()); + } + + private void ApplyClassificationResult(BookmarkListItemReclassifyResult classificationResult) + { + var existing = Command; + if (existing != null) + { + existing.PropChanged -= CommandPropertyChanged; + } + + classificationResult.Command.PropChanged += CommandPropertyChanged; + Command = classificationResult.Command; + OnPropertyChanged(nameof(Icon)); + Title = classificationResult.Title; + Subtitle = classificationResult.Subtitle; + MoreCommands = classificationResult.MoreCommands; + + _initializationTcs.TrySetResult(); + } + + private void CommandPropertyChanged(object sender, IPropChangedEventArgs args) => + OnPropertyChanged(args.PropertyName); + + private static void BuildSpecificContextMenuItems(Classification classification, List<IContextItem> contextMenu) + { + // TODO: unify across all built-in extensions + var bookmarkTargetType = classification.Kind; + + // TODO: add "Run as administrator" for executables/shortcuts + if (!classification.IsPlaceholder) + { + if (bookmarkTargetType == CommandKind.FileDocument && File.Exists(classification.Target)) + { + contextMenu.Add(new CommandContextItem(new OpenWithCommand(classification.Input))); + } + } + + string? directoryPath = null; + var targetPath = classification.Target; + switch (bookmarkTargetType) + { + case CommandKind.Directory: + directoryPath = targetPath; + contextMenu.Add(new CommandContextItem(new DirectoryPage(directoryPath))); // Browse + break; + case CommandKind.FileExecutable: + case CommandKind.FileDocument: + case CommandKind.Shortcut: + case CommandKind.InternetShortcut: + try + { + directoryPath = Path.GetDirectoryName(targetPath); + } + catch + { + // ignore any path parsing errors + } + + break; + case CommandKind.WebUrl: + case CommandKind.Protocol: + case CommandKind.Aumid: + case CommandKind.PathCommand: + case CommandKind.Unknown: + default: + break; + } + + // Add "Copy Path" or "Copy Address" command + if (!string.IsNullOrWhiteSpace(classification.Input)) + { + var copyCommand = new CopyPathCommand(targetPath) + { + Name = bookmarkTargetType is CommandKind.WebUrl or CommandKind.Protocol + ? Resources.bookmarks_copy_address_name + : Resources.bookmarks_copy_path_name, + Icon = Icons.CopyPath, + }; + + contextMenu.Add(new CommandContextItem(copyCommand) { RequestedShortcut = KeyChords.CopyPath }); + } + + // Add "Open in Console" and "Show in Folder" commands if we have a valid directory path + if (!string.IsNullOrWhiteSpace(directoryPath) && Directory.Exists(directoryPath)) + { + contextMenu.Add(new CommandContextItem(new ShowFileInFolderCommand(targetPath)) { RequestedShortcut = KeyChords.OpenFileLocation }); + contextMenu.Add(new CommandContextItem(OpenInConsoleCommand.FromDirectory(directoryPath)) { RequestedShortcut = KeyChords.OpenInConsole }); + } + + if (!string.IsNullOrWhiteSpace(targetPath) && (File.Exists(targetPath) || Directory.Exists(targetPath))) + { + contextMenu.Add(new CommandContextItem(new OpenPropertiesCommand(targetPath))); + } + } + + private static string BuildSubtitle(BookmarkData bookmark, Classification classification) + { + var subtitle = BuildSubtitleCore(bookmark, classification); +#if DEBUG + subtitle = $" ({classification.Kind}) • " + subtitle; +#endif + return subtitle; + } + + private static string BuildSubtitleCore(BookmarkData bookmark, Classification classification) + { + if (classification.Kind == CommandKind.Unknown) + { + return bookmark.Bookmark; + } + + if (classification.Kind is CommandKind.VirtualShellItem && + ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName)) + { + return friendlyName; + } + + if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) && + !string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + + return bookmark.Bookmark; + } + + private static string BuildTitle(BookmarkData bookmark, Classification classification) + { + if (!string.IsNullOrWhiteSpace(bookmark.Name)) + { + return bookmark.Name; + } + + if (classification.Kind is CommandKind.Unknown or CommandKind.WebUrl or CommandKind.Protocol) + { + return bookmark.Bookmark; + } + + if (ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName)) + { + return friendlyName; + } + + if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) && + !string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + + return bookmark.Bookmark; + } + + private static void AddCommonContextMenuItems( + BookmarkData bookmark, + IBookmarksManager bookmarksManager, + TypedEventHandler<object, BookmarkData> bookmarkSavedHandler, + List<IContextItem> contextMenu) + { + contextMenu.Add(new Separator()); + + var edit = new AddBookmarkPage(bookmark) { Icon = Icons.EditIcon }; + edit.AddedCommand += bookmarkSavedHandler; + contextMenu.Add(new CommandContextItem(edit)); + + var confirmableCommand = new ConfirmableCommand + { + Command = new DeleteBookmarkCommand(bookmark, bookmarksManager), + ConfirmationTitle = Resources.bookmarks_delete_prompt_title!, + ConfirmationMessage = Resources.bookmarks_delete_prompt_message!, + Name = Resources.bookmarks_delete_name, + Icon = Icons.DeleteIcon, + }; + var delete = new CommandContextItem(confirmableCommand) { IsCritical = true, RequestedShortcut = KeyChords.DeleteBookmark }; + contextMenu.Add(delete); + } + + private void BookmarkSaved(object sender, BookmarkData args) + { + ExtensionHost.LogMessage($"Saving bookmark ({args.Name},{args.Bookmark})"); + _bookmarksManager.Update(args.Id, args.Name, args.Bookmark); + } + + private readonly record struct BookmarkListItemReclassifyResult( + ICommand Command, + string Title, + string Subtitle, + IContextItem[] MoreCommands + ); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderForm.cs new file mode 100644 index 0000000000..8064474fab --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderForm.cs @@ -0,0 +1,119 @@ +// 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.Linq; +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class BookmarkPlaceholderForm : FormContent +{ + private static readonly CompositeFormat ErrorMessage = CompositeFormat.Parse(Resources.bookmarks_required_placeholder); + + private readonly BookmarkData _bookmarkData; + private readonly IBookmarkResolver _commandResolver; + + public BookmarkPlaceholderForm(BookmarkData data, IBookmarkResolver commandResolver, IPlaceholderParser placeholderParser) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentNullException.ThrowIfNull(commandResolver); + + _bookmarkData = data; + _commandResolver = commandResolver; + placeholderParser.ParsePlaceholders(data.Bookmark, out _, out var placeholders); + var inputs = placeholders.Distinct(PlaceholderInfoNameEqualityComparer.Instance).Select(placeholder => + { + var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, placeholder.Name); + return $$""" + { + "type": "Input.Text", + "style": "text", + "id": "{{placeholder.Name}}", + "label": "{{placeholder.Name}}", + "isRequired": true, + "errorMessage": "{{errorMessage}}" + } + """; + }).ToList(); + + var allInputs = string.Join(",", inputs); + + TemplateJson = $$""" + { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "size": "Medium", + "weight": "Bolder", + "text": "{{_bookmarkData.Name}}" + }, + {{allInputs}} + ], + "actions": [ + { + "type": "Action.Submit", + "title": "{{Resources.bookmarks_form_open}}", + "data": { + "placeholder": "placeholder" + } + } + ] + } + """; + } + + public override CommandResult SubmitForm(string payload) + { + // parse the submitted JSON and then open the link + var formInput = JsonNode.Parse(payload); + var formObject = formInput?.AsObject(); + if (formObject is null) + { + return CommandResult.GoHome(); + } + + // we need to classify this twice: + // first we need to know if the original bookmark is a URL or protocol, because that determines how we encode the placeholders + // then we need to classify the final target to be sure the classification didn't change by adding the placeholders + var placeholderClassification = _commandResolver.ClassifyOrUnknown(_bookmarkData.Bookmark); + + var placeholders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in formObject) + { + var placeholderData = value?.ToString(); + placeholders[key] = placeholderData ?? string.Empty; + } + + var target = ReplacePlaceholders(_bookmarkData.Bookmark, placeholders, placeholderClassification); + var classification = _commandResolver.ClassifyOrUnknown(target); + var success = CommandLauncher.Launch(classification); + return success ? CommandResult.Dismiss() : CommandResult.KeepOpen(); + } + + private static string ReplacePlaceholders(string input, Dictionary<string, string> placeholders, Classification classification) + { + var result = input; + foreach (var (key, value) in placeholders) + { + var placeholderString = $"{{{key}}}"; + var encodedValue = value; + if (classification.Kind is CommandKind.Protocol or CommandKind.WebUrl) + { + encodedValue = Uri.EscapeDataString(value); + } + + result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase); + } + + return result; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderPage.cs new file mode 100644 index 0000000000..06b23c5252 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Pages/BookmarkPlaceholderPage.cs @@ -0,0 +1,48 @@ +// 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.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CmdPal.Ext.Bookmarks.Persistence; +using Microsoft.CmdPal.Ext.Bookmarks.Services; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Pages; + +internal sealed partial class BookmarkPlaceholderPage : ContentPage, IDisposable +{ + private readonly FormContent _bookmarkPlaceholder; + private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate; + + public BookmarkPlaceholderPage(BookmarkData bookmarkData, IBookmarkIconLocator iconLocator, IBookmarkResolver resolver, IPlaceholderParser placeholderParser) + { + Name = Resources.bookmarks_command_name_open; + Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id); + + _bookmarkPlaceholder = new BookmarkPlaceholderForm(bookmarkData, resolver, placeholderParser); + + _iconReloadGate = new( + async ct => + { + var c = resolver.ClassifyOrUnknown(bookmarkData.Bookmark); + return await iconLocator.GetIconForPath(c, ct); + }, + icon => + { + Icon = icon as IconInfo ?? Icons.PinIcon; + }); + RequestIconReloadAsync(); + } + + public override IContent[] GetContent() => [_bookmarkPlaceholder]; + + private void RequestIconReloadAsync() + { + Icon = Icons.Reloading; + OnPropertyChanged(nameof(Icon)); + _ = _iconReloadGate.ExecuteAsync(); + } + + public void Dispose() => _iconReloadGate.Dispose(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs new file mode 100644 index 0000000000..b577f9cb35 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkData.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +public sealed record BookmarkData +{ + public Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Bookmark { get; init; } + + [JsonConstructor] + [SetsRequiredMembers] + public BookmarkData(Guid id, string? name, string? bookmark) + { + Id = id; + Name = name ?? string.Empty; + Bookmark = bookmark ?? string.Empty; + } + + [SetsRequiredMembers] + public BookmarkData(string? name, string? bookmark) + : this(Guid.NewGuid(), name, bookmark) + { + } + + [SetsRequiredMembers] + public BookmarkData() + : this(Guid.NewGuid(), string.Empty, string.Empty) + { + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkJsonParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkJsonParser.cs new file mode 100644 index 0000000000..c0eb26b7b7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkJsonParser.cs @@ -0,0 +1,43 @@ +// 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; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +public class BookmarkJsonParser +{ + public BookmarkJsonParser() + { + } + + public BookmarksData ParseBookmarks(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new BookmarksData(); + } + + try + { + var bookmarks = JsonSerializer.Deserialize<BookmarksData>(json, BookmarkSerializationContext.Default.BookmarksData); + return bookmarks ?? new BookmarksData(); + } + catch (JsonException ex) + { + ExtensionHost.LogMessage($"parse bookmark data failed. ex: {ex.Message}"); + return new BookmarksData(); + } + } + + public string SerializeBookmarks(BookmarksData? bookmarks) + { + if (bookmarks == null) + { + return string.Empty; + } + + return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.BookmarksData); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkSerializationContext.cs new file mode 100644 index 0000000000..66c5c69455 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarkSerializationContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(BookmarkData))] +[JsonSerializable(typeof(BookmarksData))] +[JsonSerializable(typeof(List<BookmarkData>), TypeInfoPropertyName = "BookmarkList")] +[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] +internal sealed partial class BookmarkSerializationContext : JsonSerializerContext; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarksData.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarksData.cs new file mode 100644 index 0000000000..81d0f21578 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/BookmarksData.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.Ext.Bookmarks.Persistence; + +public sealed class BookmarksData +{ + public List<BookmarkData> Data { get; set; } = []; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/FileBookmarkDataSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/FileBookmarkDataSource.cs new file mode 100644 index 0000000000..69dd934e2c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/FileBookmarkDataSource.cs @@ -0,0 +1,47 @@ +// 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.IO; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence; + +public sealed partial class FileBookmarkDataSource : IBookmarkDataSource +{ + private readonly string _filePath; + + public FileBookmarkDataSource(string filePath) + { + _filePath = filePath; + } + + public string GetBookmarkData() + { + if (!File.Exists(_filePath)) + { + return string.Empty; + } + + try + { + return File.ReadAllText(_filePath); + } + catch (Exception ex) + { + ExtensionHost.LogMessage($"Read bookmark data failed. ex: {ex.Message}"); + return string.Empty; + } + } + + public void SaveBookmarkData(string jsonData) + { + try + { + File.WriteAllText(_filePath, jsonData); + } + catch (Exception ex) + { + ExtensionHost.LogMessage($"Failed to save bookmark data: {ex}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/IBookmarkDataSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/IBookmarkDataSource.cs new file mode 100644 index 0000000000..890d3683ba --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Persistence/IBookmarkDataSource.cs @@ -0,0 +1,11 @@ +// 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.Ext.Bookmarks.Persistence; + +internal interface IBookmarkDataSource +{ + string GetBookmarkData(); + + void SaveBookmarkData(string jsonData); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a74d97eeca --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.Bookmarks.UnitTests")] diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs similarity index 60% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs index 6dddb9c32b..02f95cf479 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { // 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", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -60,6 +60,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// <summary> + /// Looks up a localized string similar to Failed to open {0}. + /// </summary> + public static string bookmark_toast_failed_open_text { + get { + return ResourceManager.GetString("bookmark_toast_failed_open_text", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Add bookmark. /// </summary> @@ -78,6 +87,33 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// <summary> + /// Looks up a localized string similar to Open. + /// </summary> + public static string bookmarks_command_name_open { + get { + return ResourceManager.GetString("bookmarks_command_name_open", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy address. + /// </summary> + public static string bookmarks_copy_address_name { + get { + return ResourceManager.GetString("bookmarks_copy_address_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy path. + /// </summary> + public static string bookmarks_copy_path_name { + get { + return ResourceManager.GetString("bookmarks_copy_path_name", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Delete. /// </summary> @@ -87,6 +123,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// <summary> + /// Looks up a localized string similar to Are you sure you want to delete this bookmark?. + /// </summary> + public static string bookmarks_delete_prompt_message { + get { + return ResourceManager.GetString("bookmarks_delete_prompt_message", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Delete bookmark?. + /// </summary> + public static string bookmarks_delete_prompt_title { + get { + return ResourceManager.GetString("bookmarks_delete_prompt_title", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Delete bookmark. /// </summary> @@ -123,6 +177,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// <summary> + /// Looks up a localized string similar to Enter URL or file path, you can use {placeholders}, e.g. https://www.bing.com/search?q={Query}. + /// </summary> + public static string bookmarks_form_bookmark_placeholder { + get { + return ResourceManager.GetString("bookmarks_form_bookmark_placeholder", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to URL or file path is required. /// </summary> @@ -133,7 +196,44 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } /// <summary> - /// Looks up a localized string similar to Name. + /// Looks up a localized string similar to You can add placeholders to bookmarks, and Command Palette will prompt you to enter their values when you open the bookmark. + ///A placeholder looks like this:. + /// </summary> + public static string bookmarks_form_hint_text1 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text1", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {placeholder}. + /// </summary> + public static string bookmarks_form_hint_text2 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text2", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to — for example:. + /// </summary> + public static string bookmarks_form_hint_text3 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text3", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to https://www.bing.com/search?q={Query}. + /// </summary> + public static string bookmarks_form_hint_text4 { + get { + return ResourceManager.GetString("bookmarks_form_hint_text4", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Name (optional). /// </summary> public static string bookmarks_form_name_label { get { @@ -168,6 +268,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { } } + /// <summary> + /// Looks up a localized string similar to (Refreshing bookmark...). + /// </summary> + public static string bookmarks_item_refreshing { + get { + return ResourceManager.GetString("bookmarks_item_refreshing", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Open in Terminal. /// </summary> @@ -185,5 +294,14 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties { return ResourceManager.GetString("bookmarks_required_placeholder", resourceCulture); } } + + /// <summary> + /// Looks up a localized string similar to Unpin. + /// </summary> + public static string bookmarks_unpin_name { + get { + return ResourceManager.GetString("bookmarks_unpin_name", resourceCulture); + } + } } } diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx similarity index 80% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx index 5fe1e74e62..45f57c0d77 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Properties/Resources.resx @@ -140,7 +140,7 @@ <comment>"Terminal" should be the localized name of the Windows Terminal</comment> </data> <data name="bookmarks_form_name_label" xml:space="preserve"> - <value>Name</value> + <value>Name (optional)</value> </data> <data name="bookmarks_form_save" xml:space="preserve"> <value>Save</value> @@ -148,6 +148,9 @@ <data name="bookmarks_form_open" xml:space="preserve"> <value>Open</value> </data> + <data name="bookmarks_command_name_open" xml:space="preserve"> + <value>Open</value> + </data> <data name="bookmarks_form_name_required" xml:space="preserve"> <value>Name is required</value> </data> @@ -161,4 +164,41 @@ <value>{0} is required</value> <comment>{0} will be replaced by a parameter name provided by the user</comment> </data> + <data name="bookmarks_item_refreshing" xml:space="preserve"> + <value>(Refreshing bookmark...)</value> + </data> + <data name="bookmarks_delete_prompt_title" xml:space="preserve"> + <value>Delete bookmark?</value> + </data> + <data name="bookmarks_delete_prompt_message" xml:space="preserve"> + <value>Are you sure you want to delete this bookmark?</value> + </data> + <data name="bookmarks_copy_path_name" xml:space="preserve"> + <value>Copy path</value> + </data> + <data name="bookmarks_copy_address_name" xml:space="preserve"> + <value>Copy address</value> + </data> + <data name="bookmarks_unpin_name" xml:space="preserve"> + <value>Unpin</value> + </data> + <data name="bookmark_toast_failed_open_text" xml:space="preserve"> + <value>Failed to open {0}</value> + </data> + <data name="bookmarks_form_hint_text1" xml:space="preserve"> + <value>You can add placeholders to bookmarks, and Command Palette will prompt you to enter their values when you open the bookmark. +A placeholder looks like this:</value> + </data> + <data name="bookmarks_form_hint_text2" xml:space="preserve"> + <value>{placeholder}</value> + </data> + <data name="bookmarks_form_hint_text3" xml:space="preserve"> + <value>— for example:</value> + </data> + <data name="bookmarks_form_hint_text4" xml:space="preserve"> + <value>https://www.bing.com/search?q={Query}</value> + </data> + <data name="bookmarks_form_bookmark_placeholder" xml:space="preserve"> + <value>Enter URL or file path, you can use {placeholders}, e.g. https://www.bing.com/search?q={Query}</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/BookmarkResolver.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/BookmarkResolver.cs new file mode 100644 index 0000000000..fd2736ebaf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/BookmarkResolver.cs @@ -0,0 +1,547 @@ +// 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.IO; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +internal sealed partial class BookmarkResolver : IBookmarkResolver +{ + private readonly IPlaceholderParser _placeholderParser; + + private const string UriSchemeShell = "shell"; + + public BookmarkResolver(IPlaceholderParser placeholderParser) + { + ArgumentNullException.ThrowIfNull(placeholderParser); + _placeholderParser = placeholderParser; + } + + public async Task<(bool Success, Classification Result)> TryClassifyAsync( + string? input, + CancellationToken cancellationToken = default) + { + try + { + var result = await Task.Run( + () => TryClassify(input, out var classification) + ? classification + : Classification.Unknown(input ?? string.Empty), + cancellationToken); + return (true, result); + } + catch (Exception ex) + { + Logger.LogError("Failed to classify", ex); + var result = Classification.Unknown(input ?? string.Empty); + return (false, result); + } + } + + public Classification ClassifyOrUnknown(string input) + { + return TryClassify(input, out var c) ? c : Classification.Unknown(input); + } + + private bool TryClassify(string? input, out Classification result) + { + try + { + bool success; + + if (string.IsNullOrWhiteSpace(input)) + { + result = Classification.Unknown(input ?? string.Empty); + success = false; + } + else + { + input = input.Trim(); + + // is placeholder? + var isPlaceholder = _placeholderParser.ParsePlaceholders(input, out var inputUntilFirstPlaceholder, out _); + success = ClassifyCore(input, out result, isPlaceholder, inputUntilFirstPlaceholder, _placeholderParser); + } + + return success; + } + catch (Exception ex) + { + Logger.LogError($"Failed to classify bookmark \"{input}\"", ex); + result = Classification.Unknown(input ?? string.Empty); + return false; + } + } + + private static bool ClassifyCore(string input, out Classification result, bool isPlaceholder, string inputUntilFirstPlaceholder, IPlaceholderParser placeholderParser) + { + // 1) Try URI parsing first (accepts custom schemes, e.g., shell:, ms-settings:) + // File URIs must start with "file:" to avoid confusion with local paths - which are handled below, in more sophisticated ways - + // as TryCreate would automatically add "file://" to bare paths like "C:\path\to\file.txt" which we don't want. + if (Uri.TryCreate(input, UriKind.Absolute, out var uri) + && !string.IsNullOrWhiteSpace(uri.Scheme) + && (uri.Scheme != Uri.UriSchemeFile || input.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + && uri.Scheme != UriSchemeShell) + { + // http/https → Url; any other scheme → Protocol (mailto:, ms-settings:, slack://, etc.) + var isWeb = uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; + + result = new Classification( + isWeb ? CommandKind.WebUrl : CommandKind.Protocol, + input, + input, + string.Empty, + LaunchMethod.ShellExecute, // Shell picks the right handler + null, + isPlaceholder); + + return true; + } + + // 1a) We're a placeholder and start look like a protocol scheme (e.g. "myapp:{{placeholder}}") + if (isPlaceholder && UriHelper.TryGetScheme(inputUntilFirstPlaceholder, out var scheme, out _)) + { + // single letter schemes are probably drive letters, ignore, file and shell protocols are handled elsewhere + if (scheme.Length > 1 && scheme != Uri.UriSchemeFile && scheme != UriSchemeShell) + { + var isWeb = scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + + result = new Classification( + isWeb ? CommandKind.WebUrl : CommandKind.Protocol, + input, + input, + string.Empty, + LaunchMethod.ShellExecute, // Shell picks the right handler + null, + isPlaceholder); + return true; + } + } + + // 2) Existing file/dir or "longest plausible prefix" + // Try to grow head (only for unquoted original) to include spaces until a path exists. + + // Find longest unquoted argument string + var (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input); + if (longestUnquotedHead == string.Empty) + { + (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitHeadAndArgs(input); + } + + var (headPath, tailArgs) = ExpandToBestExistingPath(longestUnquotedHead, tailAfterLongestUnquotedHead, isPlaceholder, placeholderParser); + if (headPath is not null) + { + var args = tailArgs ?? string.Empty; + + if (Directory.Exists(headPath)) + { + result = new Classification( + CommandKind.Directory, + input, + headPath, + string.Empty, + LaunchMethod.ExplorerOpen, + headPath, + isPlaceholder); + + return true; + } + + var ext = Path.GetExtension(headPath); + if (ShellHelpers.IsExecutableExtension(ext)) + { + result = new Classification( + CommandKind.FileExecutable, + input, + headPath, + args, + LaunchMethod.ShellExecute, // direct exec; or ShellExecute if you want verb support + Path.GetDirectoryName(headPath), + isPlaceholder); + + return true; + } + + var isShellLink = ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase); + var isUrlLink = ext.Equals(".url", StringComparison.OrdinalIgnoreCase); + if (isShellLink || isUrlLink) + { + // In the future we can fetch data out of the link + result = new Classification( + isUrlLink ? CommandKind.InternetShortcut : CommandKind.Shortcut, + input, + headPath, + string.Empty, + LaunchMethod.ShellExecute, + Path.GetDirectoryName(headPath), + isPlaceholder); + + return true; + } + + result = new Classification( + CommandKind.FileDocument, + input, + headPath, + args, + LaunchMethod.ShellExecute, + Path.GetDirectoryName(headPath), + isPlaceholder); + + return true; + } + + if (TryGetAumid(longestUnquotedHead, out var aumid)) + { + result = new Classification( + CommandKind.Aumid, + longestUnquotedHead, + aumid, + tailAfterLongestUnquotedHead, + LaunchMethod.ActivateAppId, + null, + isPlaceholder); + + return true; + } + + // 3) Bare command resolution via PATH + executable ext + // At this point 'head' is our best intended command token. + var (firstHead, tail) = SplitHeadAndArgs(input); + CommandLineHelper.ExpandPathToPhysicalFile(firstHead, true, out var head); + + // 3.1) UWP/AppX via AppsFolder/AUMID or pkgfamily!app + // Since the AUMID can be actually anything, we either take a full shell:AppsFolder\AUMID + // as entered and we try to detect packaged app ids (pkgfamily!app). + if (TryGetAumid(head, out var aumid2)) + { + result = new Classification( + CommandKind.Aumid, + head, + aumid2, + tail, + LaunchMethod.ActivateAppId, + null, + isPlaceholder); + + return true; + } + + // 3.2) It's a virtual shell item (e.g. Control Panel, Recycle Bin, This PC) + // Shell items that are backed by filesystem paths (e.g. Downloads) should be already handled above. + if (CommandLineHelper.HasShellPrefix(head)) + { + ShellNames.TryGetFriendlyName(input, out var displayName); + ShellNames.TryGetFileSystemPath(input, out var fsPath); + result = new Classification( + CommandKind.VirtualShellItem, + input, + input, + string.Empty, + LaunchMethod.ShellExecute, + fsPath is not null && Directory.Exists(fsPath) ? fsPath : null, + isPlaceholder, + fsPath, + displayName); + return true; + } + + // 3.3) Search paths for the file name (with or without ext) + // If head is a file name with extension, we look only for that. If there's no extension + // we go and follow Windows Shell resolution rules. + if (TryResolveViaPath(head, out var resolvedFilePath)) + { + result = new Classification( + CommandKind.PathCommand, + input, + resolvedFilePath, + tail, + LaunchMethod.ShellExecute, + null, + isPlaceholder); + + return true; + } + + // 3.4) If it looks like a path with ext but missing file, treat as document (Shell will handle assoc / error) + if (LooksPathy(head) && Path.HasExtension(head)) + { + var extension = Path.GetExtension(head); + + // if the path extension contains placeholders, we can't assume what it is so, skip it and treat it as unknown + var hasSpecificExtension = !isPlaceholder || !extension.Contains('{'); + if (hasSpecificExtension) + { + result = new Classification( + ShellHelpers.IsExecutableExtension(extension) ? CommandKind.FileExecutable : CommandKind.FileDocument, + input, + head, + tail, + LaunchMethod.ShellExecute, + HasDir(head) ? Path.GetDirectoryName(head) : null, + isPlaceholder); + + return true; + } + } + + // 4) looks like a web URL without scheme, but not like a file with extension + if (head.Contains('.', StringComparison.OrdinalIgnoreCase) && head.StartsWith("www", StringComparison.OrdinalIgnoreCase)) + { + // treat as URL, add https:// + var url = "https://" + input; + result = new Classification( + CommandKind.WebUrl, + input, + url, + string.Empty, + LaunchMethod.ShellExecute, + null, + isPlaceholder); + return true; + } + + // 5) Fallback: let ShellExecute try the whole input + result = new Classification( + CommandKind.Unknown, + input, + head, + tail, + LaunchMethod.ShellExecute, + null, + isPlaceholder); + + return true; + } + + private static (string Head, string Tail) SplitHeadAndArgs(string input) => CommandLineHelper.SplitHeadAndArgs(input); + + // Finds the best existing path prefix in an *unquoted* input by scanning + // whitespace boundaries. Prefers files to directories; for same kind, + // prefers the longer path. + // Returns (head, tail) or (null, null) if nothing found. + private static (string? Head, string? Tail) ExpandToBestExistingPath(string head, string tail, bool containsPlaceholders, IPlaceholderParser placeholderParser) + { + try + { + // This goes greedy from the longest head down to shortest; exactly opposite of what + // CreateProcess rules are for the first token. But here we operate with a slightly different goal. + var (greedyHead, greedyTail) = GreedyFind(head, containsPlaceholders, placeholderParser); + + // put tails back together: + return (Head: greedyHead, string.Join(" ", greedyTail, tail).Trim()); + } + catch (Exception ex) + { + Logger.LogError("Failed to find best path", ex); + throw; + } + } + + private static (string? Head, string? Tail) GreedyFind(string input, bool containsPlaceholders, IPlaceholderParser placeholderParser) + { + // Be greedy: try to find the longest existing path prefix + for (var i = input.Length; i >= 0; i--) + { + if (i < input.Length && !char.IsWhiteSpace(input[i])) + { + continue; + } + + var candidate = input.AsSpan(0, i).TrimEnd().ToString(); + if (candidate.Length == 0) + { + continue; + } + + // If we have placeholders, check if this candidate would contain a non-path placeholder + if (containsPlaceholders && ContainsNonPathPlaceholder(candidate, placeholderParser)) + { + continue; // Skip this candidate, try a shorter one + } + + try + { + if (CommandLineHelper.ExpandPathToPhysicalFile(candidate, true, out var full)) + { + var tail = i < input.Length ? input[i..].TrimStart() : string.Empty; + return (full, tail); + } + } + catch + { + // Ignore malformed paths; keep scanning + } + } + + return (null, null); + } + + // Attempts to guess if any placeholders in the candidate string are likely not part of a filesystem path. + private static bool ContainsNonPathPlaceholder(string candidate, IPlaceholderParser placeholderParser) + { + placeholderParser.ParsePlaceholders(candidate, out _, out var placeholders); + foreach (var match in placeholders) + { + var placeholderContext = GuessPlaceholderContextInFileSystemPath(candidate, match.Index); + + // If placeholder appears after what looks like a command-line flag/option + if (placeholderContext.IsAfterFlag) + { + return true; + } + + // If placeholder doesn't look like a typical path component + if (!placeholderContext.LooksLikePathComponent) + { + return true; + } + } + + return false; + } + + // Heuristically determines the context of a placeholder inside a filesystem-like input string. + // Sets: + // - IsAfterFlag: true if immediately preceded by a token that looks like a command-line flag prefix (" -", " /", " --"). + // - LooksLikePathComponent: true if (a) not after a flag or (b) nearby text shows path separators. + private static PlaceholderContext GuessPlaceholderContextInFileSystemPath(string input, int placeholderIndex) + { + var beforePlaceholder = input[..placeholderIndex].TrimEnd(); + + var isAfterFlag = beforePlaceholder.EndsWith(" -", StringComparison.OrdinalIgnoreCase) || + beforePlaceholder.EndsWith(" /", StringComparison.OrdinalIgnoreCase) || + beforePlaceholder.EndsWith(" --", StringComparison.OrdinalIgnoreCase); + + var looksLikePathComponent = !isAfterFlag; + + var nearbyText = input.Substring(Math.Max(0, placeholderIndex - 20), Math.Min(40, input.Length - Math.Max(0, placeholderIndex - 20))); + var hasPathSeparators = nearbyText.Contains('\\') || nearbyText.Contains('/'); + + if (!hasPathSeparators && isAfterFlag) + { + looksLikePathComponent = false; + } + + return new PlaceholderContext(isAfterFlag, looksLikePathComponent); + } + + private static bool TryGetAumid(string input, out string aumid) + { + // App ids are a lot of fun, since they can look like anything. + // And yes, they can contain spaces too, like Zoom: + // shell:AppsFolder\zoom.us.Zoom Video Meetings + // so unless that thing is quoted, we can't just assume the first token is the AUMID. + const string appsFolder = "shell:AppsFolder\\"; + + // Guard against null or empty input + if (string.IsNullOrEmpty(input)) + { + aumid = string.Empty; + return false; + } + + // Already a fully qualified AUMID path + if (input.StartsWith(appsFolder, StringComparison.OrdinalIgnoreCase)) + { + aumid = input; + return true; + } + + aumid = string.Empty; + return false; + } + + private static bool LooksPathy(string input) + { + // Basic: drive:\, UNC, relative with . or .., or has dir separator + if (input.Contains('\\') || input.Contains('/')) + { + return true; + } + + if (input is [_, ':', ..]) + { + return true; + } + + if (input.StartsWith(@"\\", StringComparison.InvariantCulture) || input.StartsWith("./", StringComparison.InvariantCulture) || input.StartsWith(".\\", StringComparison.InvariantCulture) || input.StartsWith("..\\", StringComparison.InvariantCulture)) + { + return true; + } + + return false; + } + + private static bool HasDir(string path) => !string.IsNullOrEmpty(Path.GetDirectoryName(path)); + + private static bool TryResolveViaPath(string head, out string resolvedFile) + { + resolvedFile = string.Empty; + + if (string.IsNullOrWhiteSpace(head)) + { + return false; + } + + if (Path.HasExtension(head) && ShellHelpers.FileExistInPath(head, out resolvedFile)) + { + return true; + } + + // If head has dir, treat as path probe + if (HasDir(head)) + { + if (Path.HasExtension(head)) + { + var p = TryProbe(Environment.CurrentDirectory, head); + if (p is not null) + { + resolvedFile = p; + return true; + } + + return false; + } + + foreach (var ext in ShellHelpers.ExecutableExtensions) + { + var p = TryProbe(null, head + ext); + if (p is not null) + { + resolvedFile = p; + return true; + } + } + + return false; + } + + return ShellHelpers.TryResolveExecutableAsShell(head, out resolvedFile); + } + + private static string? TryProbe(string? dir, string name) + { + try + { + var path = dir is null ? name : Path.Combine(dir, name); + if (File.Exists(path)) + { + return Path.GetFullPath(path); + } + } + catch + { + /* ignore */ + } + + return null; + } + + private record PlaceholderContext(bool IsAfterFlag, bool LooksLikePathComponent); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/FaviconLoader.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/FaviconLoader.cs new file mode 100644 index 0000000000..541ecdf19d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/FaviconLoader.cs @@ -0,0 +1,157 @@ +// 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.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public sealed partial class FaviconLoader : IFaviconLoader, IDisposable +{ + private readonly HttpClient _http = CreateClient(); + private bool _disposed; + + private static HttpClient CreateClient() + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, + }; + + var client = new HttpClient(handler, disposeHandler: true); + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) WindowsCommandPalette/1.0"); + client.DefaultRequestHeaders.Accept.ParseAdd("image/*"); + + return client; + } + + public async Task<IRandomAccessStream?> TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default) + { + if (siteUri.Scheme != Uri.UriSchemeHttp && siteUri.Scheme != Uri.UriSchemeHttps) + { + return null; + } + + // 1) First attempt: favicon on the original authority (preserves port). + var first = BuildFaviconUri(siteUri); + + // Try download; if this fails (non-image or path lost), retry on final host. + var stream = await TryDownloadImageAsync(first, ct).ConfigureAwait(false); + if (stream is not null) + { + return stream; + } + + // 2) If the server redirected and "lost" the path, try /favicon.ico on the *final* host. + // We discover the final host by doing a HEAD/GET to the original URL and inspecting the final RequestUri. + var finalAuthority = await ResolveFinalAuthorityAsync(first, ct).ConfigureAwait(false); + if (finalAuthority is null || UriEqualsAuthority(first, finalAuthority)) + { + return null; + } + + var second = BuildFaviconUri(finalAuthority); + if (second == first) + { + return null; // nothing new to try + } + + return await TryDownloadImageAsync(second, ct).ConfigureAwait(false); + } + + private static Uri BuildFaviconUri(Uri anyUriOnSite) + { + var b = new UriBuilder(anyUriOnSite.Scheme, anyUriOnSite.Host) + { + Port = anyUriOnSite.IsDefaultPort ? -1 : anyUriOnSite.Port, + Path = "/favicon.ico", + }; + return b.Uri; + } + + private async Task<Uri?> ResolveFinalAuthorityAsync(Uri url, CancellationToken ct) + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + + // We only need headers to learn the final RequestUri after redirects + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + var final = resp.RequestMessage?.RequestUri; + return final is null ? null : new UriBuilder(final.Scheme, final.Host) + { + Port = final.IsDefaultPort ? -1 : final.Port, + Path = "/", + }.Uri; + } + + private async Task<IRandomAccessStream?> TryDownloadImageAsync(Uri url, CancellationToken ct) + { + try + { + using var req = new HttpRequestMessage(HttpMethod.Get, url); + using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + + if (!resp.IsSuccessStatusCode) + { + return null; + } + + // If the redirect chain dumped us on an HTML page (common for root), bail. + var mediaType = resp.Content.Headers.ContentType?.MediaType; + if (mediaType is not null && + !mediaType.StartsWith("image", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var bytes = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + var stream = new InMemoryRandomAccessStream(); + + using (var output = stream.GetOutputStreamAt(0)) + using (var writer = new DataWriter(output)) + { + writer.WriteBytes(bytes); + await writer.StoreAsync().AsTask(ct); + await writer.FlushAsync().AsTask(ct); + } + + stream.Seek(0); + return stream; + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } + + private static bool UriEqualsAuthority(Uri a, Uri b) + => a.Scheme.Equals(b.Scheme, StringComparison.OrdinalIgnoreCase) + && a.Host.Equals(b.Host, StringComparison.OrdinalIgnoreCase) + && (a.IsDefaultPort ? -1 : a.Port) == (b.IsDefaultPort ? -1 : b.Port); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _http.Dispose(); + _disposed = true; + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkIconLocator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkIconLocator.cs new file mode 100644 index 0000000000..5ed8133277 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkIconLocator.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CommandPalette.Extensions; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public interface IBookmarkIconLocator +{ + Task<IIconInfo> GetIconForPath(Classification classification, CancellationToken cancellationToken = default); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkResolver.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkResolver.cs new file mode 100644 index 0000000000..225c99d5a8 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IBookmarkResolver.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +internal interface IBookmarkResolver +{ + Task<(bool Success, Classification Result)> TryClassifyAsync(string input, CancellationToken cancellationToken = default); + + Classification ClassifyOrUnknown(string input); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IFaviconLoader.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IFaviconLoader.cs new file mode 100644 index 0000000000..cd9c3007de --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IFaviconLoader.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; +using System.Threading.Tasks; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +/// <summary> +/// Service to load favicons for websites. +/// </summary> +public interface IFaviconLoader +{ + Task<IRandomAccessStream?> TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IPlaceholderParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IPlaceholderParser.cs new file mode 100644 index 0000000000..c357c7235b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IPlaceholderParser.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.Ext.Bookmarks.Services; + +public interface IPlaceholderParser +{ + bool ParsePlaceholders(string input, out string head, out List<PlaceholderInfo> placeholders); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IconLocator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IconLocator.cs new file mode 100644 index 0000000000..0a855f5886 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/IconLocator.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Bookmarks.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.Win32; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +internal class IconLocator : IBookmarkIconLocator +{ + private readonly IFaviconLoader _faviconLoader; + + public IconLocator() + : this(new FaviconLoader()) + { + } + + private IconLocator(IFaviconLoader faviconLoader) + { + ArgumentNullException.ThrowIfNull(faviconLoader); + _faviconLoader = faviconLoader; + } + + public async Task<IIconInfo> GetIconForPath( + Classification classification, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(classification); + + var icon = classification.Kind switch + { + CommandKind.WebUrl => await TryGetWebIcon(classification.Target), + CommandKind.Protocol => await TryGetProtocolIcon(classification.Target), + CommandKind.FileExecutable => await TryGetExecutableIcon(classification.Target), + CommandKind.Unknown => FallbackIcon(classification), + _ => await MaybeGetIconForPath(classification.Target), + }; + + return icon ?? FallbackIcon(classification); + } + + private async Task<IIconInfo?> TryGetWebIcon(string target) + { + // Get the base url up to the first placeholder + var placeholderIndex = target.IndexOf('{'); + var baseString = placeholderIndex > 0 ? target[..placeholderIndex] : target; + try + { + var uri = new Uri(baseString); + var iconStream = await _faviconLoader.TryGetFaviconAsync(uri, CancellationToken.None); + if (iconStream != null) + { + return IconInfo.FromStream(iconStream); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to get web bookmark favicon for " + baseString, ex); + } + + return null; + } + + private static async Task<IIconInfo?> TryGetExecutableIcon(string target) + { + IIconInfo? icon = null; + var exeExists = false; + var fullExePath = string.Empty; + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + + // Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation + var pathResolutionTask = Task.Run( + () => + { + // Don't check cancellation token here - let the Task timeout handle it + exeExists = ShellHelpers.FileExistInPath(target, out fullExePath); + }, + CancellationToken.None); + + // Wait for either completion or timeout + pathResolutionTask.Wait(cts.Token); + } + catch (OperationCanceledException) + { + // Debug.WriteLine("Operation was canceled."); + } + + if (exeExists) + { + // If the executable exists, try to get the icon from the file + icon = await MaybeGetIconForPath(fullExePath); + if (icon is not null) + { + return icon; + } + } + + return icon; + } + + private static async Task<IconInfo?> TryGetProtocolIcon(string target) + { + // Special case for steam: protocol - use game icon + // Steam protocol have only a file name (steam.exe) associated with it, but is not + // in PATH or AppPaths. So we can't resolve it to an executable. But at the same time, + // this is a very common protocol, so we special-case it here. + if (target.StartsWith("steam:", StringComparison.OrdinalIgnoreCase)) + { + return Icons.BookmarkTypes.Game; + } + + // extract protocol from classification.Target (until the first ':'): + IconInfo? icon = null; + var colonIndex = target.IndexOf(':'); + string protocol; + if (colonIndex > 0) + { + protocol = target[..colonIndex]; + } + else + { + return icon; + } + + icon = await ThumbnailHelper.GetProtocolIconStream(protocol, true) is { } stream + ? IconInfo.FromStream(stream) + : null; + + if (icon is null) + { + var protocolIconPath = ProtocolIconResolver.GetIconString(protocol); + if (protocolIconPath is not null) + { + icon = new IconInfo(protocolIconPath); + } + } + + return icon; + } + + private static IconInfo FallbackIcon(Classification classification) + { + return classification.Kind switch + { + CommandKind.FileExecutable => Icons.BookmarkTypes.Application, + CommandKind.FileDocument => Icons.BookmarkTypes.FilePath, + CommandKind.Directory => Icons.BookmarkTypes.FolderPath, + CommandKind.PathCommand => Icons.BookmarkTypes.Command, + CommandKind.Aumid => Icons.BookmarkTypes.Application, + CommandKind.Shortcut => Icons.BookmarkTypes.Application, + CommandKind.InternetShortcut => Icons.BookmarkTypes.WebUrl, + CommandKind.WebUrl => Icons.BookmarkTypes.WebUrl, + CommandKind.Protocol => Icons.BookmarkTypes.Application, + _ => Icons.BookmarkTypes.Unknown, + }; + } + + private static async Task<IconInfo?> MaybeGetIconForPath(string target) + { + try + { + var stream = await ThumbnailHelper.GetThumbnail(target); + if (stream is not null) + { + return IconInfo.FromStream(stream); + } + + if (ShellNames.TryGetFileSystemPath(target, out var fileSystemPath)) + { + stream = await ThumbnailHelper.GetThumbnail(fileSystemPath); + if (stream is not null) + { + return IconInfo.FromStream(stream); + } + } + } + catch (Exception ex) + { + Logger.LogDebug($"Failed to load icon for {target}\n" + ex); + } + + return null; + } + + internal static class ProtocolIconResolver + { + /// <summary> + /// Gets the icon resource string for a given URI protocol (e.g. "steam" or "mailto"). + /// Returns something like "C:\Path\app.exe,0" or null if not found. + /// </summary> + public static string? GetIconString(string protocol) + { + try + { + if (string.IsNullOrWhiteSpace(protocol)) + { + return null; + } + + protocol = protocol.TrimEnd(':').ToLowerInvariant(); + + // Try HKCR\<protocol>\DefaultIcon + using (var di = Registry.ClassesRoot.OpenSubKey(protocol + "\\DefaultIcon")) + { + var value = di?.GetValue(null) as string; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + // Fallback: HKCR\<protocol>\shell\open\command + using (var cmd = Registry.ClassesRoot.OpenSubKey(protocol + "\\shell\\open\\command")) + { + var command = cmd?.GetValue(null) as string; + if (!string.IsNullOrWhiteSpace(command)) + { + var exe = ExtractExecutable(command); + if (!string.IsNullOrWhiteSpace(exe)) + { + return exe; // default index 0 implied + } + } + } + } + catch (Exception ex) + { + Logger.LogError("Failed to get protocol information from registry; will return nothing instead", ex); + } + + return null; + } + + private static string ExtractExecutable(string command) + { + command = command.Trim(); + + if (command.StartsWith('\"')) + { + var end = command.IndexOf('"', 1); + if (end > 1) + { + return command[1..end]; + } + } + + var space = command.IndexOf(' '); + return space > 0 ? command[..space] : command; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfo.cs new file mode 100644 index 0000000000..1a8254a33a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfo.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public sealed class PlaceholderInfo +{ + public string Name { get; } + + public int Index { get; } + + public PlaceholderInfo(string name, int index) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentOutOfRangeException.ThrowIfLessThan(index, 0); + + Name = name; + Index = index; + } + + private bool Equals(PlaceholderInfo other) => Name == other.Name && Index == other.Index; + + public override bool Equals(object? obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((PlaceholderInfo)obj); + } + + public override int GetHashCode() => HashCode.Combine(Name, Index); + + public static bool operator ==(PlaceholderInfo? left, PlaceholderInfo? right) + { + return Equals(left, right); + } + + public static bool operator !=(PlaceholderInfo? left, PlaceholderInfo? right) + { + return !Equals(left, right); + } + + public override string ToString() => Name; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfoNameEqualityComparer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfoNameEqualityComparer.cs new file mode 100644 index 0000000000..7841e91c47 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderInfoNameEqualityComparer.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public class PlaceholderInfoNameEqualityComparer : IEqualityComparer<PlaceholderInfo> +{ + public static PlaceholderInfoNameEqualityComparer Instance { get; } = new(); + + public bool Equals(PlaceholderInfo? x, PlaceholderInfo? y) + { + if (x is null && y is null) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode(PlaceholderInfo obj) + { + ArgumentNullException.ThrowIfNull(obj); + return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderParser.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderParser.cs new file mode 100644 index 0000000000..17c88a1ddf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Services/PlaceholderParser.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.Bookmarks.Services; + +public class PlaceholderParser : IPlaceholderParser +{ + public bool ParsePlaceholders(string input, out string head, out List<PlaceholderInfo> placeholders) + { + ArgumentNullException.ThrowIfNull(input); + + head = string.Empty; + placeholders = []; + + if (string.IsNullOrEmpty(input)) + { + head = string.Empty; + return false; + } + + var foundPlaceholders = new List<PlaceholderInfo>(); + var searchStart = 0; + var firstPlaceholderStart = -1; + var hasValidPlaceholder = false; + + while (searchStart < input.Length) + { + var openBrace = input.IndexOf('{', searchStart); + if (openBrace == -1) + { + break; + } + + var closeBrace = input.IndexOf('}', openBrace + 1); + if (closeBrace == -1) + { + break; + } + + // Extract potential placeholder name + var placeholderContent = input.Substring(openBrace + 1, closeBrace - openBrace - 1); + + // Check if it's a valid placeholder + if (!string.IsNullOrEmpty(placeholderContent) && + !IsGuidFormat(placeholderContent) && + IsValidPlaceholderName(placeholderContent)) + { + // Valid placeholder found + foundPlaceholders.Add(new PlaceholderInfo(placeholderContent, openBrace)); + hasValidPlaceholder = true; + + // Remember the first valid placeholder position + if (firstPlaceholderStart == -1) + { + firstPlaceholderStart = openBrace; + } + } + + // Continue searching after this brace pair + searchStart = closeBrace + 1; + } + + // Convert to Placeholder objects + placeholders = foundPlaceholders; + + if (hasValidPlaceholder) + { + head = input[..firstPlaceholderStart]; + return true; + } + else + { + head = input; + return false; + } + } + + private static bool IsValidPlaceholderName(string name) + { + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (!(char.IsLetterOrDigit(c) || c == '_' || c == '-')) + { + return false; + } + } + + return true; + } + + private static bool IsGuidFormat(string content) => Guid.TryParse(content, out _); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/Assets/Calculator.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Assets/Calculator.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/Assets/Calculator.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Assets/Calculator.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/Assets/Calculator.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Assets/Calculator.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/Assets/Calculator.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Assets/Calculator.svg diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs new file mode 100644 index 0000000000..2fe90c6121 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.CmdPal.Ext.Calc.Pages; +using Microsoft.CmdPal.Ext.Calc.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Calc; + +public partial class CalculatorCommandProvider : CommandProvider +{ + private static ISettingsInterface settings = new SettingsManager(); + private readonly ListItem _listItem = new(new CalculatorListPage(settings)) + { + MoreCommands = [new CommandContextItem(((SettingsManager)settings).Settings.SettingsPage)], + }; + + private readonly FallbackCalculatorItem _fallback = new(settings); + + public CalculatorCommandProvider() + { + Id = "com.microsoft.cmdpal.builtin.calculator"; + DisplayName = Resources.calculator_display_name; + Icon = Icons.CalculatorIcon; + Settings = ((SettingsManager)settings).Settings; + } + + public override ICommandItem[] TopLevelCommands() => [_listItem]; + + public override IFallbackCommandItem[] FallbackCommands() => [_fallback]; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/BracketHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/BracketHelper.cs new file mode 100644 index 0000000000..e3e6a3a3fb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/BracketHelper.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; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public static class BracketHelper +{ + public static bool IsBracketComplete(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return true; + } + + var valueTuples = query + .Select(BracketTrail) + .Where(r => r != default); + + var trailTest = new Stack<TrailType>(); + + foreach (var (direction, type) in valueTuples) + { + switch (direction) + { + case TrailDirection.Open: + trailTest.Push(type); + break; + case TrailDirection.Close: + // Try to get item out of stack + if (!trailTest.TryPop(out var popped)) + { + return false; + } + + if (type != popped) + { + return false; + } + + continue; + default: + { + throw new ArgumentOutOfRangeException($"Can't process value (Parameter direction: {direction})"); + } + } + } + + return trailTest.Count == 0; + } + + public static string BalanceBrackets(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return query ?? string.Empty; + } + + var openBrackets = new Stack<TrailType>(); + + for (var i = 0; i < query.Length; i++) + { + var (direction, type) = BracketTrail(query[i]); + + if (direction == TrailDirection.None) + { + continue; + } + + if (direction == TrailDirection.Open) + { + openBrackets.Push(type); + } + else if (direction == TrailDirection.Close) + { + // Only pop if we have a matching open bracket + if (openBrackets.Count > 0 && openBrackets.Peek() == type) + { + openBrackets.Pop(); + } + } + } + + if (openBrackets.Count == 0) + { + return query; + } + + // Build closing brackets in LIFO order + var closingBrackets = new char[openBrackets.Count]; + var index = 0; + + while (openBrackets.Count > 0) + { + var type = openBrackets.Pop(); + closingBrackets[index++] = type == TrailType.Round ? ')' : ']'; + } + + return query + new string(closingBrackets); + } + + private static (TrailDirection Direction, TrailType Type) BracketTrail(char @char) + { + switch (@char) + { + case '(': + return (TrailDirection.Open, TrailType.Round); + case ')': + return (TrailDirection.Close, TrailType.Round); + case '[': + return (TrailDirection.Open, TrailType.Bracket); + case ']': + return (TrailDirection.Close, TrailType.Bracket); + default: + return default; + } + } + + private enum TrailDirection + { + None, + Open, + Close, + } + + private enum TrailType + { + None, + Bracket, + Round, + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/CalculateEngine.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/CalculateEngine.cs new file mode 100644 index 0000000000..fea869a497 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/CalculateEngine.cs @@ -0,0 +1,137 @@ +// 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.Globalization; +using System.Text.RegularExpressions; +using CalculatorEngineCommon; +using Windows.Foundation.Collections; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public static class CalculateEngine +{ + private static readonly PropertySet _constants = new() + { + { "pi", Math.PI }, + { "π", Math.PI }, + { "e", Math.E }, + }; + + private static readonly Calculator _calculator = new Calculator(_constants); + + public const int RoundingDigits = 10; + + public enum TrigMode + { + Radians, + Degrees, + Gradians, + } + + /// <summary> + /// Interpret + /// </summary> + /// <param name="cultureInfo">Use CultureInfo.CurrentCulture if something is user facing</param> + public static CalculateResult Interpret(ISettingsInterface settings, string input, CultureInfo cultureInfo, out string error) + { + error = default; + + if (!CalculateHelper.InputValid(input)) + { + return default; + } + + // check for division by zero + // We check if the string contains a slash followed by space (optional) and zero. Whereas the zero must not be followed by a dot, comma, 'b', 'o' or 'x' as these indicate a number with decimal digits or a binary/octal/hexadecimal value respectively. The zero must also not be followed by other digits. + if (new Regex("\\/\\s*0(?!(?:[,\\.0-9]|[box]0*[1-9a-f]))", RegexOptions.IgnoreCase).Match(input).Success) + { + error = Properties.Resources.calculator_division_by_zero; + return default; + } + + // mages has quirky log representation + // mage has log == ln vs log10 + input = input. + Replace("log(", "log10(", true, CultureInfo.CurrentCulture). + Replace("ln(", "log(", true, CultureInfo.CurrentCulture); + + input = CalculateHelper.FixHumanMultiplicationExpressions(input); + + input = CalculateHelper.UpdateFactorialFunctions(input); + + // Get the user selected trigonometry unit + TrigMode trigMode = settings.TrigUnit; + + // Modify trig functions depending on angle unit setting + input = CalculateHelper.UpdateTrigFunctions(input, trigMode); + + // Expand conversions between trig units + input = CalculateHelper.ExpandTrigConversions(input, trigMode); + + var result = _calculator.EvaluateExpression(input); + + // This could happen for some incorrect queries, like pi(2) + if (result == "NaN") + { + error = Properties.Resources.calculator_expression_not_complete; + return default; + } + + // If we're out of bounds + if (result is "inf" or "-inf") + { + error = Properties.Resources.calculator_not_covert_to_decimal; + return default; + } + + if (string.IsNullOrEmpty(result)) + { + return default; + } + + var decimalResult = Convert.ToDecimal(result, new CultureInfo("en-US")); + + var roundedResult = FormatMax15Digits(decimalResult, cultureInfo); + + return new CalculateResult() + { + Result = decimalResult, + RoundedResult = roundedResult, + }; + } + + public static decimal Round(decimal value) + { + return Math.Round(value, RoundingDigits, MidpointRounding.AwayFromZero); + } + + /// <summary> + /// Format a decimal so that the output contains **at most 15 total digits** + /// (integer + fraction, not counting the decimal point or minus sign). + /// Any extra fractional digits are rounded using “away-from-zero” rounding. + /// Trailing zeros in the fractional part—and a dangling decimal point—are removed. + /// Examples + /// 1.9999999999 → "1.9999999999" + /// 100000.9999999999 → "100001" + /// 1234567890123.45 → "1234567890123.45" + /// </summary> + public static decimal FormatMax15Digits(decimal value, CultureInfo cultureInfo) + { + const int maxDisplayDigits = 15; + + if (value == 0m) + { + return 0m; + } + + var absValue = Math.Abs(value); + var integerDigits = absValue >= 1 ? (int)Math.Floor(Math.Log10((double)absValue)) + 1 : 1; + + var maxDecimalDigits = Math.Max(0, maxDisplayDigits - integerDigits); + + var rounded = Math.Round(value, maxDecimalDigits, MidpointRounding.AwayFromZero); + return rounded / 1.000000000000000000000000000000000m; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/CalculateHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/CalculateHelper.cs new file mode 100644 index 0000000000..0ad44bedd7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/CalculateHelper.cs @@ -0,0 +1,537 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public static partial class CalculateHelper +{ + private static readonly Regex RegValidExpressChar = new Regex( + @"^(" + + @"%|" + + @"ceil\s*\(|floor\s*\(|exp\s*\(|max\s*\(|min\s*\(|abs\s*\(|log(?:2|10)?\s*\(|ln\s*\(|sqrt\s*\(|pow\s*\(|" + + @"factorial\s*\(|sign\s*\(|round\s*\(|rand\s*\(\)|randi\s*\([^\)]|" + + @"sin\s*\(|cos\s*\(|tan\s*\(|arcsin\s*\(|arccos\s*\(|arctan\s*\(|" + + @"sinh\s*\(|cosh\s*\(|tanh\s*\(|arsinh\s*\(|arcosh\s*\(|artanh\s*\(|" + + @"rad\s*\(|deg\s*\(|grad\s*\(|" + /* trigonometry unit conversion macros */ + @"pi|" + + @"==|~=|&&|\|\||" + + @"((\d+(?:\.\d*)?|\.\d+)[eE](-?\d+))|" + /* expression from CheckScientificNotation between parenthesis */ + @"e|[0-9]|0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+|[\+\-\*\/\^\., ""]|[\(\)\|\!\[\]]" + + @")+$", + RegexOptions.Compiled); + + private const string DegToRad = "(pi / 180) * "; + private const string DegToGrad = "(10 / 9) * "; + private const string GradToRad = "(pi / 200) * "; + private const string GradToDeg = "(9 / 10) * "; + private const string RadToDeg = "(180 / pi) * "; + private const string RadToGrad = "(200 / pi) * "; + + // replacements from the user input to displayed query + private static readonly Dictionary<string, string> QueryReplacements = new() + { + { "%", "%" }, { "﹪", "%" }, + { "−", "-" }, { "–", "-" }, { "—", "-" }, + { "!", "!" }, + { "*", "×" }, { "∗", "×" }, { "·", "×" }, { "⊗", "×" }, { "⋅", "×" }, { "✕", "×" }, { "✖", "×" }, { "\u2062", "×" }, + { "/", "÷" }, { "∕", "÷" }, { "➗", "÷" }, { ":", "÷" }, + }; + + // replacements from a query to engine input + private static readonly Dictionary<string, string> EngineReplacements = new() + { + { "×", "*" }, + { "÷", "/" }, + }; + + private static readonly Dictionary<string, string> SuperscriptReplacements = new() + { + { "²", "^2" }, { "³", "^3" }, + }; + + private static readonly HashSet<char> StandardOperators = [ + + // binary operators; doesn't make sense for them to be at the end of a query + '+', '-', '*', '/', '%', '^', '=', '&', '|', '\\', + + // parentheses + '(', '[', + ]; + + private static readonly HashSet<char> SuffixOperators = [ + + // unary operators; can appear at the end of a query + ')', ']', '!', + ]; + + private static readonly Regex ReplaceScientificNotationRegex = CreateReplaceScientificNotationRegex(); + + public static char[] GetQueryOperators() + { + var ops = new HashSet<char>(StandardOperators); + ops.ExceptWith(SuffixOperators); + return [.. ops]; + } + + /// <summary> + /// Normalizes the query for display + /// This replaces standard operators with more visually appealing ones (e.g., '*' -> '×') if enabled. + /// Always applies safe normalizations (standardizing variants like minus, percent, etc.). + /// </summary> + /// <param name="input">The query string to normalize.</param> + public static string NormalizeCharsForDisplayQuery(string input) + { + // 1. Safe/Trivial replacements (Variant -> Standard) + // These are always applied to ensure consistent behavior for non-math symbols (spaces) and + // operator variants like minus, percent, and exclamation mark. + foreach (var (key, value) in QueryReplacements) + { + input = input.Replace(key, value); + } + + return input; + } + + /// <summary> + /// Normalizes the query for the calculation engine. + /// This replaces all supported operator variants (visual or standard) with the specific + /// ASCII operators required by the engine (e.g., '×' -> '*'). + /// It duplicates and expands upon replacements in NormalizeQuery to ensure the engine + /// receives valid input regardless of whether NormalizeQuery was executed. + /// </summary> + public static string NormalizeCharsToEngine(string input) + { + foreach (var (key, value) in EngineReplacements) + { + input = input.Replace(key, value); + } + + // Replace superscript characters with their engine equivalents (e.g., '²' -> '^2') + foreach (var (key, value) in SuperscriptReplacements) + { + input = input.Replace(key, value); + } + + return input; + } + + public static bool InputValid(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + if (!RegValidExpressChar.IsMatch(input)) + { + return false; + } + + if (!BracketHelper.IsBracketComplete(input)) + { + return false; + } + + // If the input ends with a binary operator then it is not a valid input to mages and the Interpret function would throw an exception. Because we expect here that the user has not finished typing we block those inputs. + var trimmedInput = input.TrimEnd(); + if (EndsWithBinaryOperator(trimmedInput)) + { + return false; + } + + return true; + } + + private static bool EndsWithBinaryOperator(string input) + { + var operators = GetQueryOperators(); + if (string.IsNullOrEmpty(input)) + { + return false; + } + + var lastChar = input[^1]; + return Array.Exists(operators, op => op == lastChar); + } + + public static string FixHumanMultiplicationExpressions(string input) + { + var output = CheckScientificNotation(input); + output = CheckNumberOrConstantThenParenthesisExpr(output); + output = CheckNumberOrConstantThenFunc(output); + output = CheckParenthesisExprThenFunc(output); + output = CheckParenthesisExprThenParenthesisExpr(output); + output = CheckNumberThenConstant(output); + output = CheckConstantThenConstant(output); + return output; + } + + private static string CheckScientificNotation(string input) + { + return ReplaceScientificNotationRegex.Replace(input, "($1 * 10^($2))"); + } + + /* + * num (exp) + * const (exp) + */ + private static string CheckNumberOrConstantThenParenthesisExpr(string input) + { + var output = input; + do + { + input = output; + output = Regex.Replace(input, @"(\d+|pi|e)\s*(\()", m => + { + if (m.Index > 0 && char.IsLetter(input[m.Index - 1])) + { + return m.Value; + } + + return $"{m.Groups[1].Value} * {m.Groups[2].Value}"; + }); + } + while (output != input); + + return output; + } + + /* + * num func + * const func + */ + private static string CheckNumberOrConstantThenFunc(string input) + { + var output = input; + do + { + input = output; + output = Regex.Replace(input, @"(\d+|pi|e)\s*([a-zA-Z]+[0-9]*\s*\()", m => + { + if (input[m.Index] == 'e' && input[m.Index + 1] == 'x' && input[m.Index + 2] == 'p') + { + return m.Value; + } + + if (m.Index > 0 && char.IsLetter(input[m.Index - 1])) + { + return m.Value; + } + + return $"{m.Groups[1].Value} * {m.Groups[2].Value}"; + }); + } + while (output != input); + + return output; + } + + /* + * (exp) func + * func func + */ + private static string CheckParenthesisExprThenFunc(string input) + { + var p = @"(\))\s*([a-zA-Z]+[0-9]*\s*\()"; + var r = "$1 * $2"; + return Regex.Replace(input, p, r); + } + + /* + * (exp) (exp) + * func (exp) + */ + private static string CheckParenthesisExprThenParenthesisExpr(string input) + { + var p = @"(\))\s*(\()"; + var r = "$1 * $2"; + return Regex.Replace(input, p, r); + } + + /* + * num const + */ + private static string CheckNumberThenConstant(string input) + { + var output = input; + do + { + input = output; + output = Regex.Replace(input, @"(\d+)\s*(pi|e)", m => + { + if (m.Index > 0 && char.IsLetter(input[m.Index - 1])) + { + return m.Value; + } + + return $"{m.Groups[1].Value} * {m.Groups[2].Value}"; + }); + } + while (output != input); + + return output; + } + + /* + * const const + */ + private static string CheckConstantThenConstant(string input) + { + var output = input; + do + { + input = output; + output = Regex.Replace(input, @"(pi|e)\s*(pi|e)", m => + { + if (m.Index > 0 && char.IsLetter(input[m.Index - 1])) + { + return m.Value; + } + + return $"{m.Groups[1].Value} * {m.Groups[2].Value}"; + }); + } + while (output != input); + + return output; + } + + // Gets the index of the closing bracket of a function + private static int FindClosingBracketIndex(string input, int start) + { + var bracketCount = 0; // Set count to zero + for (var i = start; i < input.Length; i++) + { + if (input[i] == '(') + { + bracketCount++; + } + else if (input[i] == ')') + { + bracketCount--; + if (bracketCount == 0) + { + return i; + } + } + } + + return -1; // Unmatched brackets + } + + private static string ModifyTrigFunction(string input, string function, string modification) + { + // Get the RegEx pattern to match, depending on whether the function is inverse or normal + var pattern = function.StartsWith("arc", StringComparison.Ordinal) ? string.Empty : @"(?<!c)"; + pattern += $@"{function}\s*\("; + + var index = 0; // Index for match to ensure that the same match is not found twice + + Regex regex = new Regex(pattern); + Match match; + + while ((match = regex.Match(input, index)).Success) + { + index = match.Index + match.Groups[0].Length + modification.Length; // Get the next index to look from for further matches + + var endIndex = FindClosingBracketIndex(input, match.Index + match.Groups[0].Length - 1); // Find the index of the closing bracket of the function + + // If no valid bracket index was found, try the next match + if (endIndex == -1) + { + continue; + } + + var argument = input.Substring(match.Index + match.Groups[0].Length, endIndex - (match.Index + match.Groups[0].Length)); // Extract the argument between the brackets + var replaced = function.StartsWith("arc", StringComparison.Ordinal) ? $"{modification}({match.Groups[0].Value}{argument}))" : $"{match.Groups[0].Value}{modification}({argument}))"; // The string to substitute in, handles differing formats of inverse functions + + input = input.Remove(match.Index, endIndex - match.Index + 1); // Remove the match from the input + input = input.Insert(match.Index, replaced); // Substitute with the new string + } + + return input; + } + + public static string UpdateTrigFunctions(string input, CalculateEngine.TrigMode mode) + { + var modifiedInput = input; + if (mode == CalculateEngine.TrigMode.Degrees) + { + modifiedInput = ModifyTrigFunction(modifiedInput, "sin", DegToRad); + modifiedInput = ModifyTrigFunction(modifiedInput, "cos", DegToRad); + modifiedInput = ModifyTrigFunction(modifiedInput, "tan", DegToRad); + modifiedInput = ModifyTrigFunction(modifiedInput, "arcsin", RadToDeg); + modifiedInput = ModifyTrigFunction(modifiedInput, "arccos", RadToDeg); + modifiedInput = ModifyTrigFunction(modifiedInput, "arctan", RadToDeg); + } + else if (mode == CalculateEngine.TrigMode.Gradians) + { + modifiedInput = ModifyTrigFunction(modifiedInput, "sin", GradToRad); + modifiedInput = ModifyTrigFunction(modifiedInput, "cos", GradToRad); + modifiedInput = ModifyTrigFunction(modifiedInput, "tan", GradToRad); + modifiedInput = ModifyTrigFunction(modifiedInput, "arcsin", RadToGrad); + modifiedInput = ModifyTrigFunction(modifiedInput, "arccos", RadToGrad); + modifiedInput = ModifyTrigFunction(modifiedInput, "arctan", RadToGrad); + } + + return modifiedInput; + } + + public static string UpdateFactorialFunctions(string input) + { + // Handle n! -> factorial(n) + int startSearch = 0; + while (true) + { + var index = input.IndexOf('!', startSearch); + if (index == -1) + { + break; + } + + // Ignore != + if (index + 1 < input.Length && input[index + 1] == '=') + { + startSearch = index + 2; + continue; + } + + if (index == 0) + { + startSearch = index + 1; + continue; + } + + // Scan backwards + var endArg = index - 1; + while (endArg >= 0 && char.IsWhiteSpace(input[endArg])) + { + endArg--; + } + + if (endArg < 0) + { + startSearch = index + 1; + continue; + } + + var startArg = endArg; + if (input[endArg] == ')') + { + // Find matching '(' + startArg = FindOpeningBracketIndexInFrontOfIndex(input, endArg); + if (startArg == -1) + { + startSearch = index + 1; + continue; + } + } + else + { + // Scan back for number or word + while (startArg >= 0 && (char.IsLetterOrDigit(input[startArg]) || input[startArg] == '.')) + { + startArg--; + } + + startArg++; // Move back to first valid char + } + + if (startArg > endArg) + { + // No argument found + startSearch = index + 1; + continue; + } + + // Extract argument + var arg = input.Substring(startArg, endArg - startArg + 1); + + // Replace <arg><whitespace>! with factorial(<arg>) + input = input.Remove(startArg, index - startArg + 1); + input = input.Insert(startArg, $"factorial({arg})"); + + startSearch = 0; // Reset search because string changed + } + + return input; + } + + private static string ModifyMathFunction(string input, string function, string modification) + { + // Create the pattern to match the function, opening bracket, and any spaces in between + var pattern = $@"{function}\s*\("; + return Regex.Replace(input, pattern, modification + "("); + } + + public static string ExpandTrigConversions(string input, CalculateEngine.TrigMode mode) + { + var modifiedInput = input; + + // Expand "rad", "deg" and "grad" to their respective conversions for the current trig unit + if (mode == CalculateEngine.TrigMode.Radians) + { + modifiedInput = ModifyMathFunction(modifiedInput, "deg", DegToRad); + modifiedInput = ModifyMathFunction(modifiedInput, "grad", GradToRad); + modifiedInput = ModifyMathFunction(modifiedInput, "rad", string.Empty); + } + else if (mode == CalculateEngine.TrigMode.Degrees) + { + modifiedInput = ModifyMathFunction(modifiedInput, "deg", string.Empty); + modifiedInput = ModifyMathFunction(modifiedInput, "grad", GradToDeg); + modifiedInput = ModifyMathFunction(modifiedInput, "rad", RadToDeg); + } + else if (mode == CalculateEngine.TrigMode.Gradians) + { + modifiedInput = ModifyMathFunction(modifiedInput, "deg", DegToGrad); + modifiedInput = ModifyMathFunction(modifiedInput, "grad", string.Empty); + modifiedInput = ModifyMathFunction(modifiedInput, "rad", RadToGrad); + } + + return modifiedInput; + } + + private static int FindOpeningBracketIndexInFrontOfIndex(string input, int end) + { + var bracketCount = 0; + for (var i = end; i >= 0; i--) + { + switch (input[i]) + { + case ')': + bracketCount++; + break; + case '(': + { + bracketCount--; + if (bracketCount == 0) + { + return i; + } + + break; + } + } + } + + return -1; + } + + /* + * NOTE: By the time that the expression gets to us, it's already in English format. + * + * Regex explanation: + * (-?(\d+({0}\d*)?)|-?({0}\d+)): Used to capture one of two types: + * -?(\d+({0}\d*)?): Captures a decimal number starting with a number (e.g. "-1.23") + * -?({0}\d+): Captures a decimal number without leading number (e.g. ".23") + * e: Captures 'e' or 'E' + * (?\d+): Captures an integer number (e.g. "-1" or "23") + */ + [GeneratedRegex(@"(\d+(?:\.\d*)?|\.\d+)e(-?\d+)", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex CreateReplaceScientificNotationRegex(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/CalculateResult.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/CalculateResult.cs new file mode 100644 index 0000000000..96328b5696 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/CalculateResult.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public struct CalculateResult : IEquatable<CalculateResult> +{ + public decimal? Result { get; set; } + + public decimal? RoundedResult { get; set; } + + public bool Equals(CalculateResult other) + { + return Result == other.Result && RoundedResult == other.RoundedResult; + } + + public override bool Equals(object obj) + { + return obj is CalculateResult other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Result, RoundedResult); + } + + public static bool operator ==(CalculateResult left, CalculateResult right) + { + return left.Equals(right); + } + + public static bool operator !=(CalculateResult left, CalculateResult right) + { + return !(left == right); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ErrorHandler.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ErrorHandler.cs new file mode 100644 index 0000000000..bfcf49a556 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ErrorHandler.cs @@ -0,0 +1,53 @@ +// 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.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +internal static class ErrorHandler +{ + /// <summary> + /// Method to handles errors while calculating + /// </summary> + /// <param name="isFallbackSearch">Bool to indicate if it is a fallback query.</param> + /// <param name="queryInput">User input as string including the action keyword.</param> + /// <param name="errorMessage">Error message if applicable.</param> + /// <param name="exception">Exception if applicable.</param> + /// <returns>List of results to show. Either an error message or an empty list.</returns> + /// <exception cref="ArgumentException">Thrown if <paramref name="errorMessage"/> and <paramref name="exception"/> are both filled with their default values.</exception> + internal static ListItem OnError(bool isFallbackSearch, string queryInput, string errorMessage, Exception exception = default) + { + string userMessage; + + if (errorMessage != default) + { + Logger.LogError($"Failed to calculate <{queryInput}>: {errorMessage}"); + userMessage = errorMessage; + } + else if (exception != default) + { + Logger.LogError($"Exception when query for <{queryInput}>", exception); + userMessage = exception.Message; + } + else + { + throw new ArgumentException("The arguments error and exception have default values. One of them has to be filled with valid error data (error message/exception)!"); + } + + return isFallbackSearch ? null : CreateErrorResult(userMessage); + } + + private static ListItem CreateErrorResult(string errorMessage) + { + return new ListItem(new NoOpCommand()) + { + Title = Properties.Resources.calculator_calculation_failed_title, + Subtitle = errorMessage, + Icon = Icons.ErrorIcon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ISettingsInterface.cs new file mode 100644 index 0000000000..f0639aaa29 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ISettingsInterface.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. + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public interface ISettingsInterface +{ + public CalculateEngine.TrigMode TrigUnit { get; } + + public bool InputUseEnglishFormat { get; } + + public bool OutputUseEnglishFormat { get; } + + public bool CloseOnEnter { get; } + + public bool CopyResultToSearchBarIfQueryEndsWithEqualSign { get; } + + public bool AutoFixQuery { get; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/NumberTranslator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/NumberTranslator.cs new file mode 100644 index 0000000000..34da2872cf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/NumberTranslator.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +/// <summary> +/// Tries to convert all numbers in a text from one culture format to another. +/// </summary> +public class NumberTranslator +{ + private readonly CultureInfo sourceCulture; + private readonly CultureInfo targetCulture; + private readonly Regex splitRegexForSource; + private readonly Regex splitRegexForTarget; + + private NumberTranslator(CultureInfo sourceCulture, CultureInfo targetCulture) + { + this.sourceCulture = sourceCulture; + this.targetCulture = targetCulture; + + splitRegexForSource = GetSplitRegex(this.sourceCulture); + splitRegexForTarget = GetSplitRegex(this.targetCulture); + } + + /// <summary> + /// Create a new <see cref="NumberTranslator"/>. + /// </summary> + /// <param name="sourceCulture">source culture</param> + /// <param name="targetCulture">target culture</param> + /// <returns>Number translator for target culture</returns> + public static NumberTranslator Create(CultureInfo sourceCulture, CultureInfo targetCulture) + { + ArgumentNullException.ThrowIfNull(sourceCulture); + + ArgumentNullException.ThrowIfNull(targetCulture); + + return new NumberTranslator(sourceCulture, targetCulture); + } + + /// <summary> + /// Translate from source to target culture. + /// </summary> + /// <param name="input">input string to translate</param> + /// <returns>translated string</returns> + public string Translate(string input) + { + return Translate(input, sourceCulture, targetCulture, splitRegexForSource); + } + + /// <summary> + /// Translate from target to source culture. + /// </summary> + /// <param name="input">input string to translate back to source culture</param> + /// <returns>source culture string</returns> + public string TranslateBack(string input) + { + return Translate(input, targetCulture, sourceCulture, splitRegexForTarget); + } + + private static string ConvertBaseLiteral(string token, CultureInfo cultureTo) + { + var prefixes = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) + { + { "0x", 16 }, + { "0b", 2 }, + { "0o", 8 }, + }; + + foreach (var (prefix, numberBase) in prefixes) + { + if (token.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + try + { + var num = Convert.ToInt64(token.Substring(prefix.Length), numberBase); + return num.ToString(cultureTo); + } + catch + { + return null; // fallback + } + } + } + + return null; + } + + private static string Translate(string input, CultureInfo cultureFrom, CultureInfo cultureTo, Regex splitRegex) + { + var outputBuilder = new StringBuilder(); + + // Match numbers in hexadecimal (0x..), binary (0b..), or octal (0o..) format, + // and convert them to decimal form for compatibility with ExprTk (which only supports decimal input). + var baseNumberRegex = new Regex(@"(0[xX][\da-fA-F]+|0[bB][0-9]+|0[oO][0-9]+)"); + + var tokens = baseNumberRegex.Split(input); + + foreach (var token in tokens) + { + // Currently, we only convert base literals (hexadecimal, binary, octal) to decimal. + var converted = ConvertBaseLiteral(token, cultureTo); + + if (converted is not null) + { + outputBuilder.Append(converted); + continue; + } + + foreach (var inner in splitRegex.Split(token)) + { + var leadingZeroCount = 0; + + // Count leading zero characters. + foreach (var c in inner) + { + if (c != '0') + { + break; + } + + leadingZeroCount++; + } + + // number is all zero characters. no need to add zero characters at the end. + if (inner.Length == leadingZeroCount) + { + leadingZeroCount = 0; + } + + decimal number; + + outputBuilder.Append( + decimal.TryParse(inner, NumberStyles.Number, cultureFrom, out number) + ? (new string('0', leadingZeroCount) + number.ToString(cultureTo)) + : inner.Replace(cultureFrom.TextInfo.ListSeparator, cultureTo.TextInfo.ListSeparator)); + } + } + + return outputBuilder.ToString(); + } + + private static Regex GetSplitRegex(CultureInfo culture) + { + var groupSeparator = culture.NumberFormat.NumberGroupSeparator; + + // if the group separator is a no-break space, we also add a normal space to the regex + if (groupSeparator == "\u00a0") + { + groupSeparator = "\u0020\u00a0"; + } + + var splitPattern = $"([0-9{Regex.Escape(culture.NumberFormat.NumberDecimalSeparator)}" + + $"{Regex.Escape(groupSeparator)}]+)"; + return new Regex(splitPattern); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/QueryHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/QueryHelper.cs new file mode 100644 index 0000000000..2dc6ff9b3f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/QueryHelper.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public static partial class QueryHelper +{ + public static ListItem Query( + string query, + ISettingsInterface settings, + bool isFallbackSearch, + out string displayQuery, + TypedEventHandler<object, object> handleSave = null, + TypedEventHandler<object, object> handleReplace = null) + { + ArgumentNullException.ThrowIfNull(query); + if (!isFallbackSearch) + { + ArgumentNullException.ThrowIfNull(handleSave); + } + + CultureInfo inputCulture = + settings.InputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture; + CultureInfo outputCulture = + settings.OutputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture; + + // In case the user pastes a query with a leading = + query = query.TrimStart('=').TrimStart(); + + // Enables better looking characters for multiplication and division (e.g., '×' and '÷') + displayQuery = CalculateHelper.NormalizeCharsForDisplayQuery(query); + + // Happens if the user has only typed the action key so far + if (string.IsNullOrEmpty(displayQuery)) + { + return null; + } + + // Normalize query to engine format (e.g., replace '×' with '*', converts superscripts to functions) + // This must be done before any further normalization to avoid losing information + var engineQuery = CalculateHelper.NormalizeCharsToEngine(displayQuery); + + // Cleanup rest of the Unicode characters, whitespace + var queryForEngine2 = engineQuery.Normalize(NormalizationForm.FormKC); + + // Translate numbers from input culture to en-US culture for the calculation engine + var translator = NumberTranslator.Create(inputCulture, new CultureInfo("en-US")); + + // Translate the input query + var input = translator.Translate(queryForEngine2); + + if (string.IsNullOrWhiteSpace(input)) + { + return ErrorHandler.OnError(isFallbackSearch, query, Properties.Resources.calculator_expression_empty); + } + + // normalize again to engine chars after translation + input = CalculateHelper.NormalizeCharsToEngine(input); + + // Auto fix incomplete queries (if enabled) + if (settings.AutoFixQuery && TryGetIncompleteQuery(input, out var newInput)) + { + input = newInput; + } + + if (!CalculateHelper.InputValid(input)) + { + return null; + } + + try + { + // Using CurrentUICulture since this is user facing + var result = CalculateEngine.Interpret(settings, input, outputCulture, out var errorMessage); + + // This could happen for some incorrect queries, like pi(2) + if (result.Equals(default(CalculateResult))) + { + // If errorMessage is not default then do error handling + return errorMessage == default ? null : ErrorHandler.OnError(isFallbackSearch, query, errorMessage); + } + + if (isFallbackSearch) + { + // Fallback search + return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery); + } + + return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery, settings, handleSave, handleReplace); + } + catch (OverflowException) + { + // Result to big to convert to decimal + return ErrorHandler.OnError(isFallbackSearch, query, Properties.Resources.calculator_not_covert_to_decimal); + } + catch (Exception e) + { + // Any other crash occurred + // We want to keep the process alive if any the mages library throws any exceptions. + return ErrorHandler.OnError(isFallbackSearch, query, default, e); + } + } + + public static bool TryGetIncompleteQuery(string query, out string newQuery) + { + newQuery = query; + + var trimmed = query.TrimEnd(); + if (string.IsNullOrEmpty(trimmed)) + { + return false; + } + + // 1. Trim trailing operators + var operators = CalculateHelper.GetQueryOperators(); + while (trimmed.Length > 0 && Array.IndexOf(operators, trimmed[^1]) > -1) + { + trimmed = trimmed[..^1].TrimEnd(); + } + + if (trimmed.Length == 0) + { + return false; + } + + // 2. Fix brackets + newQuery = BracketHelper.BalanceBrackets(trimmed); + + return true; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ReplaceQueryCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ReplaceQueryCommand.cs new file mode 100644 index 0000000000..2dfb17bd16 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ReplaceQueryCommand.cs @@ -0,0 +1,26 @@ +// 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public sealed partial class ReplaceQueryCommand : InvokableCommand +{ + public event TypedEventHandler<object, object> ReplaceRequested; + + public ReplaceQueryCommand() + { + Name = "Replace query"; + Icon = new IconInfo("\uE70F"); // Edit icon + } + + public override ICommandResult Invoke() + { + ReplaceRequested?.Invoke(this, null); + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ResultHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ResultHelper.cs new file mode 100644 index 0000000000..0147f73c07 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/ResultHelper.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using ManagedCommon; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public static class ResultHelper +{ + public static ListItem CreateResult( + decimal? roundedResult, + CultureInfo inputCulture, + CultureInfo outputCulture, + string query, + ISettingsInterface settings, + TypedEventHandler<object, object> handleSave, + TypedEventHandler<object, object> handleReplace) + { + // Return null when the expression is not a valid calculator query. + if (roundedResult is null) + { + return null; + } + + var result = roundedResult?.ToString(outputCulture); + + // Create a SaveCommand and subscribe to the SaveRequested event + // This can append the result to the history list. + var saveCommand = new SaveCommand(result); + saveCommand.SaveRequested += handleSave; + + var replaceCommand = new ReplaceQueryCommand(); + replaceCommand.ReplaceRequested += handleReplace; + + var copyCommandItem = CreateResult(roundedResult, inputCulture, outputCulture, query); + + // No TextToSuggest on the main save command item. We don't want to keep suggesting what the result is, + // as the user is typing it. + return new ListItem(settings.CloseOnEnter ? copyCommandItem.Command : saveCommand) + { + // Using CurrentCulture since this is user facing + Icon = Icons.ResultIcon, + Title = result, + Subtitle = query, + MoreCommands = [ + new CommandContextItem(settings.CloseOnEnter ? saveCommand : copyCommandItem.Command), + new CommandContextItem(replaceCommand) { RequestedShortcut = KeyChords.CopyResultToSearchBox, }, + ..copyCommandItem.MoreCommands, + ], + }; + } + + public static ListItem CreateResult(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query) + { + // Return null when the expression is not a valid calculator query. + if (roundedResult is null) + { + return null; + } + + var decimalResult = roundedResult?.ToString(outputCulture); + + List<IContextItem> context = []; + + if (decimal.IsInteger((decimal)roundedResult)) + { + context.Add(new Separator()); + + var i = decimal.ToInt64((decimal)roundedResult); + + // hexadecimal + try + { + var hexResult = "0x" + i.ToString("X", outputCulture); + context.Add(new CommandContextItem(new CopyTextCommand(hexResult) { Name = Properties.Resources.calculator_copy_hex }) + { + Title = hexResult, + }); + } + catch (Exception ex) + { + Logger.LogError("Error converting to hex format", ex); + } + + // binary + try + { + var binaryResult = "0b" + i.ToString("B", outputCulture); + context.Add(new CommandContextItem(new CopyTextCommand(binaryResult) { Name = Properties.Resources.calculator_copy_binary }) + { + Title = binaryResult, + }); + } + catch (Exception ex) + { + Logger.LogError("Error converting to binary format", ex); + } + + // octal + try + { + var octalResult = "0o" + Convert.ToString(i, 8); + context.Add(new CommandContextItem(new CopyTextCommand(octalResult) { Name = Properties.Resources.calculator_copy_octal }) + { + Title = octalResult, + }); + } + catch (Exception ex) + { + Logger.LogError("Error converting to octal format", ex); + } + } + + return new ListItem(new CopyTextCommand(decimalResult)) + { + // Using CurrentCulture since this is user facing + Title = decimalResult, + Subtitle = query, + TextToSuggest = decimalResult, + MoreCommands = context.ToArray(), + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/SaveCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/SaveCommand.cs new file mode 100644 index 0000000000..d2605e6f92 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/SaveCommand.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 Microsoft.CmdPal.Ext.Calc.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public sealed partial class SaveCommand : InvokableCommand +{ + private readonly string _result; + + public event TypedEventHandler<object, object> SaveRequested; + + public SaveCommand(string result) + { + Name = Resources.calculator_save_command_name; + Icon = Icons.SaveIcon; + _result = result; + } + + public override ICommandResult Invoke() + { + SaveRequested?.Invoke(this, this); + ClipboardHelper.SetText(_result); + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/SettingsManager.cs new file mode 100644 index 0000000000..245af25da7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Helper/SettingsManager.cs @@ -0,0 +1,125 @@ +// 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.IO; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Calc.Helper; + +public class SettingsManager : JsonSettingsManager, ISettingsInterface +{ + private static readonly string _namespace = "calculator"; + + private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; + + private static readonly List<ChoiceSetSetting.Choice> _trigUnitChoices = new() + { + new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_trig_unit_radians, "0"), + new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_trig_unit_degrees, "1"), + new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_trig_unit_gradians, "2"), + }; + + private readonly ChoiceSetSetting _trigUnit = new( + Namespaced(nameof(TrigUnit)), + Properties.Resources.calculator_settings_trig_unit_mode, + Properties.Resources.calculator_settings_trig_unit_mode_description, + _trigUnitChoices); + + private readonly ToggleSetting _inputUseEnNumberFormat = new( + Namespaced(nameof(InputUseEnglishFormat)), + Properties.Resources.calculator_settings_in_en_format, + Properties.Resources.calculator_settings_in_en_format_description, + false); + + private readonly ToggleSetting _outputUseEnNumberFormat = new( + Namespaced(nameof(OutputUseEnglishFormat)), + Properties.Resources.calculator_settings_out_en_format, + Properties.Resources.calculator_settings_out_en_format_description, + false); + + private readonly ToggleSetting _closeOnEnter = new( + Namespaced(nameof(CloseOnEnter)), + Properties.Resources.calculator_settings_close_on_enter, + Properties.Resources.calculator_settings_close_on_enter_description, + true); + + private readonly ToggleSetting _copyResultToSearchBarIfQueryEndsWithEqualSign = new( + Namespaced(nameof(CopyResultToSearchBarIfQueryEndsWithEqualSign)), + Properties.Resources.calculator_settings_copy_result_to_search_bar, + Properties.Resources.calculator_settings_copy_result_to_search_bar_description, + false); + + private readonly ToggleSetting _autoFixQuery = new( + Namespaced(nameof(AutoFixQuery)), + Properties.Resources.calculator_settings_auto_fix_query, + Properties.Resources.calculator_settings_auto_fix_query_description, + true); + + public CalculateEngine.TrigMode TrigUnit + { + get + { + if (_trigUnit.Value == null || string.IsNullOrEmpty(_trigUnit.Value)) + { + return CalculateEngine.TrigMode.Radians; + } + + var success = int.TryParse(_trigUnit.Value, out var result); + + if (!success) + { + return CalculateEngine.TrigMode.Radians; + } + + switch (result) + { + case 0: + return CalculateEngine.TrigMode.Radians; + case 1: + return CalculateEngine.TrigMode.Degrees; + case 2: + return CalculateEngine.TrigMode.Gradians; + default: + return CalculateEngine.TrigMode.Radians; + } + } + } + + public bool InputUseEnglishFormat => _inputUseEnNumberFormat.Value; + + public bool OutputUseEnglishFormat => _outputUseEnNumberFormat.Value; + + public bool CloseOnEnter => _closeOnEnter.Value; + + public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => _copyResultToSearchBarIfQueryEndsWithEqualSign.Value; + + public bool AutoFixQuery => _autoFixQuery.Value; + + internal static string SettingsJsonPath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + + // now, the state is just next to the exe + return Path.Combine(directory, "settings.json"); + } + + public SettingsManager() + { + FilePath = SettingsJsonPath(); + + Settings.Add(_trigUnit); + Settings.Add(_inputUseEnNumberFormat); + Settings.Add(_outputUseEnNumberFormat); + Settings.Add(_closeOnEnter); + Settings.Add(_copyResultToSearchBarIfQueryEndsWithEqualSign); + Settings.Add(_autoFixQuery); + + // Load settings from file upon initialization + LoadSettings(); + + Settings.SettingsChanged += (s, a) => this.SaveSettings(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Icons.cs new file mode 100644 index 0000000000..f7d1a613f1 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Icons.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Calc; + +internal sealed class Icons +{ + internal static IconInfo CalculatorIcon => IconHelpers.FromRelativePath("Assets\\Calculator.svg"); + + internal static IconInfo ResultIcon => new("\uE94E"); // CalculatorEqualTo icon + + internal static IconInfo SaveIcon => new("\uE74E"); // Save icon + + internal static IconInfo ErrorIcon => new("\uE783"); // Error icon +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/KeyChords.cs new file mode 100644 index 0000000000..32bf117d90 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/KeyChords.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 Microsoft.CommandPalette.Extensions; +using Windows.System; + +namespace Microsoft.CmdPal.Ext.Calc; + +internal static class KeyChords +{ + internal static KeyChord CopyResultToSearchBox { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, (int)VirtualKey.Enter, 0); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Microsoft.CmdPal.Ext.Calc.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Microsoft.CmdPal.Ext.Calc.csproj new file mode 100644 index 0000000000..2f22891c61 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Microsoft.CmdPal.Ext.Calc.csproj @@ -0,0 +1,58 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> + <PropertyGroup> + <RootNamespace>Microsoft.CmdPal.Ext.Calc</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> + <ProjectPriFileName>Microsoft.CmdPal.Ext.Calc.pri</ProjectPriFileName> + </PropertyGroup> + <PropertyGroup> + <CsWinRTIncludes>CalculatorEngineCommon</CsWinRTIncludes> + <CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\CalculatorEngineCommon\CalculatorEngineCommon.vcxproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props --> + </ItemGroup> + <ItemGroup> + <CsWinRTInputs Include="..\..\..\..\..\$(Platform)\$(Configuration)\CalculatorEngineCommon.winmd" /> + <Content Include="..\..\..\..\..\$(Platform)\$(Configuration)\CalculatorEngineCommon.winmd" Link="CalculatorEngineCommon.winmd"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Include="..\..\..\..\..\$(Platform)\$(Configuration)\CalculatorEngineCommon.dll"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <DependentUpon>Resources.resx</DependentUpon> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + </Compile> + </ItemGroup> + + <ItemGroup> + <None Remove="Assets\Calculator.svg" /> + </ItemGroup> + <ItemGroup> + <Content Update="Assets\Calculator.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Calculator.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + <Generator>PublicResXFileCodeGenerator</Generator> + </EmbeddedResource> + </ItemGroup> + +</Project> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Pages/CalculatorListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Pages/CalculatorListPage.cs new file mode 100644 index 0000000000..70023794e9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Pages/CalculatorListPage.cs @@ -0,0 +1,161 @@ +// 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.Threading; +using Microsoft.CmdPal.Ext.Calc.Helper; +using Microsoft.CmdPal.Ext.Calc.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Calc.Pages; + +// The calculator page is a dynamic list page +// * The first command is where we display the results. Title=result, Subtitle=query +// - The default command is `SaveCommand`. +// - When you save, insert into list at spot 1 +// - change SearchText to the result +// - MoreCommands: a single `CopyCommand` to copy the result to the clipboard +// * The rest of the items are previously saved results +// - Command is a CopyCommand +// - Each item also sets the TextToSuggest to the result +public sealed partial class CalculatorListPage : DynamicListPage +{ + private readonly Lock _resultsLock = new(); + private readonly ISettingsInterface _settingsManager; + private readonly List<ListItem> _items = []; + private readonly List<ListItem> _history = []; + private readonly ListItem _emptyItem; + + // This is the text that saved when the user click the result. + // We need to avoid the double calculation. This may cause some wierd behaviors. + private string _skipQuerySearchText = string.Empty; + + public CalculatorListPage(ISettingsInterface settings) + { + _settingsManager = settings; + Icon = Icons.CalculatorIcon; + Name = Resources.calculator_title; + PlaceholderText = Resources.calculator_placeholder_text; + Id = "com.microsoft.cmdpal.calculator"; + + _emptyItem = new ListItem(new NoOpCommand()) + { + Title = Resources.calculator_placeholder_text, + Icon = Icons.ResultIcon, + }; + EmptyContent = new CommandItem(new NoOpCommand()) + { + Icon = Icons.CalculatorIcon, + Title = Resources.calculator_placeholder_text, + }; + + UpdateSearchText(string.Empty, string.Empty); + } + + private void HandleReplaceQuery(object sender, object args) + { + var lastResult = _items[0].Title; + if (!string.IsNullOrEmpty(lastResult)) + { + _skipQuerySearchText = lastResult; + SearchText = lastResult; + OnPropertyChanged(nameof(SearchText)); + } + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + if (oldSearch == newSearch) + { + return; + } + + if (!string.IsNullOrEmpty(_skipQuerySearchText) && newSearch == _skipQuerySearchText) + { + // only skip once. + _skipQuerySearchText = string.Empty; + return; + } + + var copyResultToSearchText = false; + if (_settingsManager.CopyResultToSearchBarIfQueryEndsWithEqualSign && newSearch.EndsWith('=')) + { + newSearch = newSearch.TrimEnd('=').TrimEnd(); + copyResultToSearchText = true; + } + + _skipQuerySearchText = string.Empty; + + _emptyItem.Subtitle = newSearch; + + var result = QueryHelper.Query(newSearch, _settingsManager, isFallbackSearch: false, out var displayQuery, HandleSave, HandleReplaceQuery); + + UpdateResult(result); + + if (copyResultToSearchText && result is not null) + { + _skipQuerySearchText = result.Title; + SearchText = result.Title; + + // LOAD BEARING: The SearchText setter does not raise a PropertyChanged notification, + // so we must raise it explicitly to ensure the UI updates correctly. + OnPropertyChanged(nameof(SearchText)); + } + } + + private void UpdateResult(ListItem result) + { + lock (_resultsLock) + { + this._items.Clear(); + + if (result is not null) + { + this._items.Add(result); + } + else + { + _items.Add(_emptyItem); + } + + this._items.AddRange(_history); + } + + RaiseItemsChanged(this._items.Count); + } + + private void HandleSave(object sender, object args) + { + var lastResult = _items[0].Title; + if (!string.IsNullOrEmpty(lastResult)) + { + var li = new ListItem(new CopyTextCommand(lastResult)) + { + Title = _items[0].Title, + Subtitle = _items[0].Subtitle, + TextToSuggest = lastResult, + }; + + _history.Insert(0, li); + _items.Insert(1, li); + + // Why we need to clean the query record? Removed, but if necessary, please move it back. + // _items[0].Subtitle = string.Empty; + + // this change will call the UpdateSearchText again. + // We need to avoid it. + _skipQuerySearchText = lastResult; + SearchText = lastResult; + + // LOAD BEARING: The SearchText setter does not raise a PropertyChanged notification, + // so we must raise it explicitly to ensure the UI updates correctly. + OnPropertyChanged(nameof(SearchText)); + + RaiseItemsChanged(this._items.Count); + } + } + + public override IListItem[] GetItems() => _items.ToArray(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Pages/FallbackCalculatorItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Pages/FallbackCalculatorItem.cs new file mode 100644 index 0000000000..935c338a94 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Pages/FallbackCalculatorItem.cs @@ -0,0 +1,53 @@ +// 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.Ext.Calc.Helper; +using Microsoft.CmdPal.Ext.Calc.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Calc.Pages; + +public sealed partial class FallbackCalculatorItem : FallbackCommandItem +{ + private const string _id = "com.microsoft.cmdpal.builtin.calculator.fallback"; + private readonly CopyTextCommand _copyCommand = new(string.Empty); + private readonly ISettingsInterface _settings; + + public FallbackCalculatorItem(ISettingsInterface settings) + : base(new NoOpCommand(), Resources.calculator_title, _id) + { + Command = _copyCommand; + _copyCommand.Name = string.Empty; + Title = string.Empty; + Subtitle = Resources.calculator_placeholder_text; + Icon = Icons.CalculatorIcon; + _settings = settings; + } + + public override void UpdateQuery(string query) + { + var result = QueryHelper.Query(query, _settings, true, out _); + + if (result is null) + { + _copyCommand.Text = string.Empty; + _copyCommand.Name = string.Empty; + Title = string.Empty; + Subtitle = string.Empty; + MoreCommands = []; + return; + } + + _copyCommand.Text = result.Title; + _copyCommand.Name = string.IsNullOrWhiteSpace(query) ? string.Empty : Resources.calculator_copy_command_name; + Title = result.Title; + + // we have to make the subtitle into an equation, + // so that we will still string match the original query + // Otherwise, something like 1+2 will have a title of "3" and not match + Subtitle = query; + + MoreCommands = result.MoreCommands; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..6dedfe2169 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Properties/Resources.Designer.cs @@ -0,0 +1,387 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// 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. +// </auto-generated> +//------------------------------------------------------------------------------ + +namespace Microsoft.CmdPal.Ext.Calc.Properties { + using System; + + + /// <summary> + /// A strongly-typed resource class, for looking up localized strings, etc. + /// </summary> + // 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()] + public 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() { + } + + /// <summary> + /// Returns the cached ResourceManager instance used by this class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Ext.Calc.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// <summary> + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// <summary> + /// Looks up a localized string similar to Failed to calculate the input. + /// </summary> + public static string calculator_calculation_failed_title { + get { + return ResourceManager.GetString("calculator_calculation_failed_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy binary. + /// </summary> + public static string calculator_copy_binary { + get { + return ResourceManager.GetString("calculator_copy_binary", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy. + /// </summary> + public static string calculator_copy_command_name { + get { + return ResourceManager.GetString("calculator_copy_command_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy hexadecimal. + /// </summary> + public static string calculator_copy_hex { + get { + return ResourceManager.GetString("calculator_copy_hex", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy octal. + /// </summary> + public static string calculator_copy_octal { + get { + return ResourceManager.GetString("calculator_copy_octal", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Calculator. + /// </summary> + public static string calculator_display_name { + get { + return ResourceManager.GetString("calculator_display_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Expression contains division by zero. + /// </summary> + public static string calculator_division_by_zero { + get { + return ResourceManager.GetString("calculator_division_by_zero", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Unsupported use of square brackets. + /// </summary> + public static string calculator_double_array_returned { + get { + return ResourceManager.GetString("calculator_double_array_returned", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Error: {0}. + /// </summary> + public static string calculator_error { + get { + return ResourceManager.GetString("calculator_error", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Please enter an expression. + /// </summary> + public static string calculator_expression_empty { + get { + return ResourceManager.GetString("calculator_expression_empty", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Expression wrong or incomplete. + /// </summary> + public static string calculator_expression_not_complete { + get { + return ResourceManager.GetString("calculator_expression_not_complete", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Calculation result is not a valid number (NaN). + /// </summary> + public static string calculator_not_a_number { + get { + return ResourceManager.GetString("calculator_not_a_number", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Result value was either too large or too small for a decimal number. + /// </summary> + public static string calculator_not_covert_to_decimal { + get { + return ResourceManager.GetString("calculator_not_covert_to_decimal", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Type an equation.... + /// </summary> + public static string calculator_placeholder_text { + get { + return ResourceManager.GetString("calculator_placeholder_text", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Save. + /// </summary> + public static string calculator_save_command_name { + get { + return ResourceManager.GetString("calculator_save_command_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Fix incomplete calculations automatically. + /// </summary> + public static string calculator_settings_auto_fix_query { + get { + return ResourceManager.GetString("calculator_settings_auto_fix_query", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Attempt to evaluate incomplete calculations by ignoring extra operators or symbols. + /// </summary> + public static string calculator_settings_auto_fix_query_description { + get { + return ResourceManager.GetString("calculator_settings_auto_fix_query_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Close on Enter. + /// </summary> + public static string calculator_settings_close_on_enter { + get { + return ResourceManager.GetString("calculator_settings_close_on_enter", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Makes Copy and close the primary command. + /// </summary> + public static string calculator_settings_close_on_enter_description { + get { + return ResourceManager.GetString("calculator_settings_close_on_enter_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Replace query with result on equals. + /// </summary> + public static string calculator_settings_copy_result_to_search_bar { + get { + return ResourceManager.GetString("calculator_settings_copy_result_to_search_bar", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Updates the query to the result when (=) is entered. + /// </summary> + public static string calculator_settings_copy_result_to_search_bar_description { + get { + return ResourceManager.GetString("calculator_settings_copy_result_to_search_bar_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Use English (United States) number format for input. + /// </summary> + public static string calculator_settings_in_en_format { + get { + return ResourceManager.GetString("calculator_settings_in_en_format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Ignores your system setting and expects numbers in the format '{0}'.. + /// </summary> + public static string calculator_settings_in_en_format_description { + get { + return ResourceManager.GetString("calculator_settings_in_en_format_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Handle extra operators and symbols. + /// </summary> + public static string calculator_settings_input_normalization { + get { + return ResourceManager.GetString("calculator_settings_input_normalization", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Enable advanced input normalization and extra symbols (e.g. ÷, ×, π). + /// </summary> + public static string calculator_settings_input_normalization_description { + get { + return ResourceManager.GetString("calculator_settings_input_normalization_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Use English (United States) number format for output. + /// </summary> + public static string calculator_settings_out_en_format { + get { + return ResourceManager.GetString("calculator_settings_out_en_format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Ignores your system setting and returns numbers in the format '{0}'.. + /// </summary> + public static string calculator_settings_out_en_format_description { + get { + return ResourceManager.GetString("calculator_settings_out_en_format_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Replace input if query ends with '='. + /// </summary> + public static string calculator_settings_replace_input { + get { + return ResourceManager.GetString("calculator_settings_replace_input", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to When using direct activation, appending '=' to the expression will replace the input with the calculated result (e.g. '=5*3-2=' will change the query to '=13').. + /// </summary> + public static string calculator_settings_replace_input_description { + get { + return ResourceManager.GetString("calculator_settings_replace_input_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Degrees. + /// </summary> + public static string calculator_settings_trig_unit_degrees { + get { + return ResourceManager.GetString("calculator_settings_trig_unit_degrees", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Gradians. + /// </summary> + public static string calculator_settings_trig_unit_gradians { + get { + return ResourceManager.GetString("calculator_settings_trig_unit_gradians", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Trigonometry Unit. + /// </summary> + public static string calculator_settings_trig_unit_mode { + get { + return ResourceManager.GetString("calculator_settings_trig_unit_mode", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Specifies the angle unit to use for trigonometry operations. + /// </summary> + public static string calculator_settings_trig_unit_mode_description { + get { + return ResourceManager.GetString("calculator_settings_trig_unit_mode_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Radians. + /// </summary> + public static string calculator_settings_trig_unit_radians { + get { + return ResourceManager.GetString("calculator_settings_trig_unit_radians", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Calculator. + /// </summary> + public static string calculator_title { + get { + return ResourceManager.GetString("calculator_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Press = to type an equation. + /// </summary> + public static string calculator_top_level_subtitle { + get { + return ResourceManager.GetString("calculator_top_level_subtitle", resourceCulture); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Properties/Resources.resx new file mode 100644 index 0000000000..72e1cc84a0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/Properties/Resources.resx @@ -0,0 +1,232 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="calculator_display_name" xml:space="preserve"> + <value>Calculator</value> + </data> + <data name="calculator_title" xml:space="preserve"> + <value>Calculator</value> + </data> + <data name="calculator_top_level_subtitle" xml:space="preserve"> + <value>Press = to type an equation</value> + <comment>{Locked="="}</comment> + </data> + <data name="calculator_placeholder_text" xml:space="preserve"> + <value>Type an equation...</value> + </data> + <data name="calculator_error" xml:space="preserve"> + <value>Error: {0}</value> + <comment>{0} will be replaced by an error message from an invalid equation</comment> + </data> + <data name="calculator_save_command_name" xml:space="preserve"> + <value>Save</value> + </data> + <data name="calculator_copy_command_name" xml:space="preserve"> + <value>Copy</value> + </data> + <data name="calculator_calculation_failed_title" xml:space="preserve"> + <value>Failed to calculate the input</value> + </data> + <data name="calculator_division_by_zero" xml:space="preserve"> + <value>Expression contains division by zero</value> + </data> + <data name="calculator_expression_not_complete" xml:space="preserve"> + <value>Expression wrong or incomplete</value> + </data> + <data name="calculator_not_a_number" xml:space="preserve"> + <value>Calculation result is not a valid number (NaN)</value> + </data> + <data name="calculator_double_array_returned" xml:space="preserve"> + <value>Unsupported use of square brackets</value> + </data> + <data name="calculator_settings_trig_unit_gradians" xml:space="preserve"> + <value>Gradians</value> + </data> + <data name="calculator_settings_trig_unit_degrees" xml:space="preserve"> + <value>Degrees</value> + </data> + <data name="calculator_settings_trig_unit_radians" xml:space="preserve"> + <value>Radians</value> + </data> + <data name="calculator_settings_trig_unit_mode" xml:space="preserve"> + <value>Trigonometry Unit</value> + </data> + <data name="calculator_settings_trig_unit_mode_description" xml:space="preserve"> + <value>Specifies the angle unit to use for trigonometry operations</value> + </data> + <data name="calculator_settings_out_en_format" xml:space="preserve"> + <value>Use English (United States) number format for output</value> + </data> + <data name="calculator_settings_out_en_format_description" xml:space="preserve"> + <value>Ignores your system setting and returns numbers in the format '{0}'.</value> + <comment>{0} is a placeholder and will be replaced in code.</comment> + </data> + <data name="calculator_settings_in_en_format" xml:space="preserve"> + <value>Use English (United States) number format for input</value> + </data> + <data name="calculator_settings_in_en_format_description" xml:space="preserve"> + <value>Ignores your system setting and expects numbers in the format '{0}'.</value> + <comment>{0} is a placeholder and will be replaced in code.</comment> + </data> + <data name="calculator_settings_close_on_enter" xml:space="preserve"> + <value>Close on Enter</value> + </data> + <data name="calculator_settings_close_on_enter_description" xml:space="preserve"> + <value>Makes Copy and close the primary command</value> + </data> + <data name="calculator_settings_replace_input" xml:space="preserve"> + <value>Replace input if query ends with '='</value> + </data> + <data name="calculator_settings_replace_input_description" xml:space="preserve"> + <value>When using direct activation, appending '=' to the expression will replace the input with the calculated result (e.g. '=5*3-2=' will change the query to '=13').</value> + </data> + <data name="calculator_not_covert_to_decimal" xml:space="preserve"> + <value>Result value was either too large or too small for a decimal number</value> + </data> + <data name="calculator_copy_hex" xml:space="preserve"> + <value>Copy hexadecimal</value> + </data> + <data name="calculator_copy_binary" xml:space="preserve"> + <value>Copy binary</value> + </data> + <data name="calculator_expression_empty" xml:space="preserve"> + <value>Please enter an expression</value> + </data> + <data name="calculator_settings_copy_result_to_search_bar" xml:space="preserve"> + <value>Replace query with result on equals</value> + </data> + <data name="calculator_settings_copy_result_to_search_bar_description" xml:space="preserve"> + <value>Updates the query to the result when (=) is entered</value> + </data> + <data name="calculator_settings_auto_fix_query" xml:space="preserve"> + <value>Fix incomplete calculations automatically</value> + </data> + <data name="calculator_settings_auto_fix_query_description" xml:space="preserve"> + <value>Attempt to evaluate incomplete calculations by ignoring extra operators or symbols</value> + </data> + <data name="calculator_settings_input_normalization" xml:space="preserve"> + <value>Handle extra operators and symbols</value> + </data> + <data name="calculator_settings_input_normalization_description" xml:space="preserve"> + <value>Enable advanced input normalization and extra symbols (e.g. ÷, ×, π)</value> + </data> + <data name="calculator_copy_octal" xml:space="preserve"> + <value>Copy octal</value> + </data> +</root> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/ClipboardHistory.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/ClipboardHistory.png new file mode 100644 index 0000000000..2dbdeb30ac Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/ClipboardHistory.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/ClipboardHistory.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/ClipboardHistory.svg new file mode 100644 index 0000000000..3abe18e1a4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/ClipboardHistory.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 125 125" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M15.657,19.602c-0,-6.424 5.286,-11.71 11.71,-11.71l70.266,-0c6.424,-0 11.71,5.286 11.71,11.71l0,93.687c0,6.425 -5.286,11.711 -11.71,11.711l-70.266,0c-6.424,0 -11.71,-5.286 -11.71,-11.711l-0,-93.687Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/><path d="M15.657,19.602c-0,-6.424 5.286,-11.71 11.71,-11.71l70.266,-0c6.424,-0 11.71,5.286 11.71,11.71l0,93.687c0,6.425 -5.286,11.711 -11.71,11.711l-70.266,0c-6.424,0 -11.71,-5.286 -11.71,-11.711l-0,-93.687Z" style="fill:url(#_Radial2);fill-rule:nonzero;"/><path d="M15.657,23.75c-0,-6.172 5.286,-11.25 11.71,-11.25l70.266,0c6.424,0 11.71,5.078 11.71,11.25l0,90c0,6.172 -5.286,11.25 -11.71,11.25l-70.266,0c-6.424,0 -11.71,-5.078 -11.71,-11.25l-0,-90Z" style="fill:url(#_Radial3);fill-rule:nonzero;"/><path d="M37.5,12.537c0,6.858 5.643,12.5 12.5,12.5l25,0c6.857,0 12.5,-5.642 12.5,-12.5c0,-6.857 -5.643,-12.5 -12.5,-12.5l-25,0c-6.857,0 -12.5,5.643 -12.5,12.5" style="fill:url(#_Linear4);fill-rule:nonzero;"/><g><clipPath id="_clip5"><path d="M31.25,50c-0,-3.429 4.107,-6.25 6.25,-6.25l50,-0c2.143,-0 6.25,2.821 6.25,6.25c-0,3.429 -4.107,6.25 -6.25,6.25l-50,-0c-2.143,-0 -6.25,-2.821 -6.25,-6.25" clip-rule="nonzero"/></clipPath><g clip-path="url(#_clip5)"><use xlink:href="#_Image6" x="31.25" y="43.75" width="63px" height="13px"/></g><clipPath id="_clip7"><path d="M31.25,100c-0,-3.429 4.107,-6.25 6.25,-6.25l50,-0c2.143,-0 6.25,2.821 6.25,6.25c-0,3.429 -4.107,6.25 -6.25,6.25l-50,0c-2.143,0 -6.25,-2.821 -6.25,-6.25" clip-rule="nonzero"/></clipPath><g clip-path="url(#_clip7)"><use xlink:href="#_Image8" x="31.25" y="93.75" width="63px" height="13px"/></g><clipPath id="_clip9"><path d="M37.5,68.75c-1.954,-0 -6.25,2.821 -6.25,6.25c0,3.429 4.296,6.25 6.25,6.25l31.25,-0c1.954,-0 6.25,-2.821 6.25,-6.25c0,-3.429 -4.296,-6.25 -6.25,-6.25l-31.25,-0Z" clip-rule="nonzero"/></clipPath><g clip-path="url(#_clip9)"><use xlink:href="#_Image10" x="31.25" y="68.75" width="44px" height="13px"/></g></g><defs><linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(93.6867,105.398,-105.398,93.6867,15.6566,19.6024)"><stop offset="0" style="stop-color:#36dff1;stop-opacity:1"/><stop offset="1" style="stop-color:#0094f0;stop-opacity:1"/></linearGradient><radialGradient id="_Radial2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,40.1352,-36.2304,0,62.5,1.38815)"><stop offset="0" style="stop-color:#0a1852;stop-opacity:0.7"/><stop offset="0.97" style="stop-color:#0a1852;stop-opacity:0"/><stop offset="1" style="stop-color:#0a1852;stop-opacity:0"/></radialGradient><radialGradient id="_Radial3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0,17.2931,-28.7282,0,62.5,18.005)"><stop offset="0" style="stop-color:#0a1852;stop-opacity:0.4"/><stop offset="1" style="stop-color:#0a1852;stop-opacity:0"/></radialGradient><linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.53081e-15,25,-25,1.53081e-15,62.5,0.0375)"><stop offset="0" style="stop-color:#ffe06b;stop-opacity:1"/><stop offset="1" style="stop-color:#fab500;stop-opacity:1"/></linearGradient><image id="_Image6" width="63px" height="13px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD8AAAANCAYAAAAe7bZ5AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABAElEQVRIid2VQQ7CMAwEF///m3yj5kKQ46wdO4ULlXpx2s7sBpTHpfpUAKoAAKi/3/MLZl3nZ9K58m9O753MbzoAgFSCs49H8woU9r0xPww+favhoAoIEwkL2QSf5gl0mQfBw0I8PypE12ftXCKZErAQ3F5Z8FuFkJDM2TtIR2YXvLsznvXBdAoxXoyXOUgH6oObDC1oVlSrkCT44kYc5C50W4gp46uFbByiDbFry8/+V9CKDC0k2hTm3HRYjroTKJrQciGBQ3Sk0UISlvz7Wc4cxiV+8d/OcsYad/qfTwshIlXoUSEkJHMOCyEO4qG74N2d8dBMJgsOxLxdSd59XC9nF+YzHhofvAAAAABJRU5ErkJggg=="/><image id="_Image8" width="63px" height="13px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD8AAAANCAYAAAAe7bZ5AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABCklEQVRIic2VSw7CMAxEH7n/vTgJJ2BPWPCRO504SUsrLCGkqWy/sV31cr3X2wOgQmX9m9IrADxefwv9m2e0TO/1ivmzURaNtVADsAejxlf6oHHHEPn2GIe3+ZnN2IHIFkaMt3St64wr79YoESYFSXTwgKr3jMc42ji4szfFpwdijKN5kt9jING3RsmabhpIsjFUd8Y1L2HYG6W7nZmBDJxqz3gseKRxCOabMBjdDcRcAUaPvYb0XzsOUUZgYA0zdK6tgSS9nPEjtg5QtOmnWQ+Gnt6oe/a3PAv/zjtw2cKI8Zaude3l1GONQ3znM8D4XABV/7dveRblC2JgUt0YR/PwA9GaMc4yDvAEgtIMyYMBJcwAAAAASUVORK5CYII="/><image id="_Image10" width="44px" height="13px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAANCAYAAADSdIySAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAA1ElEQVRIibWUQRLDIAhFf7n/KTvTQ3RTukiwil+Dki6YJAxP4IN5PN/6UgCqgOIwAPjg+DBfsQsfgnElXs9cwRxikCUzEB4gB+H0wbHRpuq802IrEwqN1Kie08Z2OSaIg4UmyajhpgX8prAliIvrCqbrcTFith6RpnYEkYyKTWNRFZOCCGrHoooYxI2aCguCnrV3mYEYQLPDRxw1JoiDfaxkd0q1L/LOS+bZ5rcWhdh6RJrKCNKsxCq0pOJNghgrO9A/Lhkc23Gs4CjE4j0XscItrMcX8c8F8aCuZQoAAAAASUVORK5CYII="/></defs></svg> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.dark.svg new file mode 100644 index 0000000000..8865472f05 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.dark.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M7.08535 3C7.29127 2.4174 7.84689 2 8.5 2H11.5C12.1531 2 12.7087 2.4174 12.9146 3H14.5C15.3284 3 16 3.67157 16 4.5V16.5C16 17.3284 15.3284 18 14.5 18H5.5C4.67157 18 4 17.3284 4 16.5V4.5C4 3.67157 4.67157 3 5.5 3H7.08535ZM8.5 3C8.22386 3 8 3.22386 8 3.5C8 3.77614 8.22386 4 8.5 4H11.5C11.7761 4 12 3.77614 12 3.5C12 3.22386 11.7761 3 11.5 3H8.5ZM7.08535 4H5.5C5.22386 4 5 4.22386 5 4.5V16.5C5 16.7761 5.22386 17 5.5 17H14.5C14.7761 17 15 16.7761 15 16.5V4.5C15 4.22386 14.7761 4 14.5 4H12.9146C12.7087 4.5826 12.1531 5 11.5 5H8.5C7.84689 5 7.29127 4.5826 7.08535 4Z" fill="#D0D0D0"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.light.svg new file mode 100644 index 0000000000..f39c0b594d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_20_regular.light.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M7.08535 3C7.29127 2.4174 7.84689 2 8.5 2H11.5C12.1531 2 12.7087 2.4174 12.9146 3H14.5C15.3284 3 16 3.67157 16 4.5V16.5C16 17.3284 15.3284 18 14.5 18H5.5C4.67157 18 4 17.3284 4 16.5V4.5C4 3.67157 4.67157 3 5.5 3H7.08535ZM8.5 3C8.22386 3 8 3.22386 8 3.5C8 3.77614 8.22386 4 8.5 4H11.5C11.7761 4 12 3.77614 12 3.5C12 3.22386 11.7761 3 11.5 3H8.5ZM7.08535 4H5.5C5.22386 4 5 4.22386 5 4.5V16.5C5 16.7761 5.22386 17 5.5 17H14.5C14.7761 17 15 16.7761 15 16.5V4.5C15 4.22386 14.7761 4 14.5 4H12.9146C12.7087 4.5826 12.1531 5 11.5 5H8.5C7.84689 5 7.29127 4.5826 7.08535 4Z" fill="#212121"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.dark.svg new file mode 100644 index 0000000000..d2658c1fde --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.dark.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M7.08535 3C7.29127 2.4174 7.84689 2 8.5 2H11.5C12.1531 2 12.7087 2.4174 12.9146 3H14.5C15.3284 3 16 3.67157 16 4.5V9H15V4.5C15 4.22386 14.7761 4 14.5 4H12.9146C12.7087 4.5826 12.1531 5 11.5 5H8.5C7.84689 5 7.29127 4.5826 7.08535 4H5.5C5.22386 4 5 4.22386 5 4.5V16.5C5 16.7761 5.22386 17 5.5 17H9.03544C9.08595 17.3531 9.18915 17.6891 9.33682 18H5.5C4.67157 18 4 17.3284 4 16.5V4.5C4 3.67157 4.67157 3 5.5 3H7.08535ZM8.5 3C8.22386 3 8 3.22386 8 3.5C8 3.77614 8.22386 4 8.5 4H11.5C11.7761 4 12 3.77614 12 3.5C12 3.22386 11.7761 3 11.5 3H8.5ZM10 12.5C10 11.1193 11.1193 10 12.5 10H16.5C17.8807 10 19 11.1193 19 12.5V16.5C19 17.0095 18.8476 17.4835 18.5858 17.8787L15.5607 14.8536C14.9749 14.2678 14.0251 14.2678 13.4393 14.8536L10.4142 17.8787C10.1524 17.4835 10 17.0095 10 16.5V12.5ZM17 12.75C17 12.3358 16.6642 12 16.25 12C15.8358 12 15.5 12.3358 15.5 12.75C15.5 13.1642 15.8358 13.5 16.25 13.5C16.6642 13.5 17 13.1642 17 12.75ZM11.1213 18.5858C11.5165 18.8476 11.9905 19 12.5 19H16.5C17.0095 19 17.4835 18.8476 17.8787 18.5858L14.8536 15.5607C14.6583 15.3654 14.3417 15.3654 14.1464 15.5607L11.1213 18.5858Z" fill="#D0D0D0"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.light.svg new file mode 100644 index 0000000000..4485e39cfa --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_image_20_regular.light.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M7.08535 3C7.29127 2.4174 7.84689 2 8.5 2H11.5C12.1531 2 12.7087 2.4174 12.9146 3H14.5C15.3284 3 16 3.67157 16 4.5V9H15V4.5C15 4.22386 14.7761 4 14.5 4H12.9146C12.7087 4.5826 12.1531 5 11.5 5H8.5C7.84689 5 7.29127 4.5826 7.08535 4H5.5C5.22386 4 5 4.22386 5 4.5V16.5C5 16.7761 5.22386 17 5.5 17H9.03544C9.08595 17.3531 9.18915 17.6891 9.33682 18H5.5C4.67157 18 4 17.3284 4 16.5V4.5C4 3.67157 4.67157 3 5.5 3H7.08535ZM8.5 3C8.22386 3 8 3.22386 8 3.5C8 3.77614 8.22386 4 8.5 4H11.5C11.7761 4 12 3.77614 12 3.5C12 3.22386 11.7761 3 11.5 3H8.5ZM10 12.5C10 11.1193 11.1193 10 12.5 10H16.5C17.8807 10 19 11.1193 19 12.5V16.5C19 17.0095 18.8476 17.4835 18.5858 17.8787L15.5607 14.8536C14.9749 14.2678 14.0251 14.2678 13.4393 14.8536L10.4142 17.8787C10.1524 17.4835 10 17.0095 10 16.5V12.5ZM17 12.75C17 12.3358 16.6642 12 16.25 12C15.8358 12 15.5 12.3358 15.5 12.75C15.5 13.1642 15.8358 13.5 16.25 13.5C16.6642 13.5 17 13.1642 17 12.75ZM11.1213 18.5858C11.5165 18.8476 11.9905 19 12.5 19H16.5C17.0095 19 17.4835 18.8476 17.8787 18.5858L14.8536 15.5607C14.6583 15.3654 14.3417 15.3654 14.1464 15.5607L11.1213 18.5858Z" fill="#212121"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.dark.svg new file mode 100644 index 0000000000..3e5845fac9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.dark.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M7.08535 3C7.29127 2.4174 7.84689 2 8.5 2H11.5C12.1531 2 12.7087 2.4174 12.9146 3H14.5C15.3284 3 16 3.67157 16 4.5V10.337L15.3698 8.89819C15.2826 8.69904 15.1554 8.52562 15 8.38568V4.5C15 4.22386 14.7761 4 14.5 4H12.9146C12.7087 4.5826 12.1531 5 11.5 5H8.5C7.84689 5 7.29127 4.5826 7.08535 4H5.5C5.22386 4 5 4.22386 5 4.5V16.5C5 16.7761 5.22386 17 5.5 17H9.08561C8.9672 17.3338 8.97447 17.6857 9.08567 18H5.5C4.67157 18 4 17.3284 4 16.5V4.5C4 3.67157 4.67157 3 5.5 3H7.08535ZM8.5 3C8.22386 3 8 3.22386 8 3.5C8 3.77614 8.22386 4 8.5 4H11.5C11.7761 4 12 3.77614 12 3.5C12 3.22386 11.7761 3 11.5 3H8.5ZM14.4538 9.2994C14.3741 9.11744 14.1942 8.99992 13.9956 9C13.797 9.00008 13.6172 9.11776 13.5376 9.29979L10.0417 17.2998C9.93114 17.5528 10.0466 17.8476 10.2997 17.9582C10.5527 18.0687 10.8475 17.9533 10.958 17.7002L12.138 15H15.859L17.0419 17.7006C17.1527 17.9535 17.4475 18.0688 17.7005 17.958C17.9534 17.8472 18.0687 17.5523 17.9579 17.2994L14.4538 9.2994ZM15.421 14H12.575L13.9963 10.7474L15.421 14Z" fill="#D0D0D0"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.light.svg new file mode 100644 index 0000000000..476f97953c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_clipboard_letter_20_regular.light.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M7.08535 3C7.29127 2.4174 7.84689 2 8.5 2H11.5C12.1531 2 12.7087 2.4174 12.9146 3H14.5C15.3284 3 16 3.67157 16 4.5V10.337L15.3698 8.89819C15.2826 8.69904 15.1554 8.52562 15 8.38568V4.5C15 4.22386 14.7761 4 14.5 4H12.9146C12.7087 4.5826 12.1531 5 11.5 5H8.5C7.84689 5 7.29127 4.5826 7.08535 4H5.5C5.22386 4 5 4.22386 5 4.5V16.5C5 16.7761 5.22386 17 5.5 17H9.08561C8.9672 17.3338 8.97447 17.6857 9.08567 18H5.5C4.67157 18 4 17.3284 4 16.5V4.5C4 3.67157 4.67157 3 5.5 3H7.08535ZM8.5 3C8.22386 3 8 3.22386 8 3.5C8 3.77614 8.22386 4 8.5 4H11.5C11.7761 4 12 3.77614 12 3.5C12 3.22386 11.7761 3 11.5 3H8.5ZM14.4538 9.2994C14.3741 9.11744 14.1942 8.99992 13.9956 9C13.797 9.00008 13.6172 9.11776 13.5376 9.29979L10.0417 17.2998C9.93114 17.5528 10.0466 17.8476 10.2997 17.9582C10.5527 18.0687 10.8475 17.9533 10.958 17.7002L12.138 15H15.859L17.0419 17.7006C17.1527 17.9535 17.4475 18.0688 17.7005 17.958C17.9534 17.8472 18.0687 17.5523 17.9579 17.2994L14.4538 9.2994ZM15.421 14H12.575L13.9963 10.7474L15.421 14Z" fill="#212121"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.dark.svg new file mode 100644 index 0000000000..f79782da20 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.dark.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8 2C6.89543 2 6 2.89543 6 4V14C6 15.1046 6.89543 16 8 16H14C15.1046 16 16 15.1046 16 14V4C16 2.89543 15.1046 2 14 2H8ZM7 4C7 3.44772 7.44772 3 8 3H14C14.5523 3 15 3.44772 15 4V14C15 14.5523 14.5523 15 14 15H8C7.44772 15 7 14.5523 7 14V4ZM4 6.00001C4 5.25973 4.4022 4.61339 5 4.26758V14.5C5 15.8807 6.11929 17 7.5 17H13.7324C13.3866 17.5978 12.7403 18 12 18H7.5C5.567 18 4 16.433 4 14.5V6.00001Z" fill="#D0D0D0"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.light.svg new file mode 100644 index 0000000000..75bba0c080 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_copy_20_regular.light.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8 2C6.89543 2 6 2.89543 6 4V14C6 15.1046 6.89543 16 8 16H14C15.1046 16 16 15.1046 16 14V4C16 2.89543 15.1046 2 14 2H8ZM7 4C7 3.44772 7.44772 3 8 3H14C14.5523 3 15 3.44772 15 4V14C15 14.5523 14.5523 15 14 15H8C7.44772 15 7 14.5523 7 14V4ZM4 6.00001C4 5.25973 4.4022 4.61339 5 4.26758V14.5C5 15.8807 6.11929 17 7.5 17H13.7324C13.3866 17.5978 12.7403 18 12 18H7.5C5.567 18 4 16.433 4 14.5V6.00001Z" fill="#212121"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.dark.svg new file mode 100644 index 0000000000..6f34f9daa7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.dark.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6 4C6 2.89543 6.89543 2 8 2H11.5858C11.9836 2 12.3651 2.15804 12.6464 2.43934L16.5607 6.35355C16.842 6.63486 17 7.01639 17 7.41421V14C17 15.1046 16.1046 16 15 16H8C6.89543 16 6 15.1046 6 14V4ZM8 3C7.44772 3 7 3.44772 7 4V14C7 14.5523 7.44772 15 8 15H15C15.5523 15 16 14.5523 16 14V8H12.5C11.6716 8 11 7.32843 11 6.5V3H8ZM12 3.20711V6.5C12 6.77614 12.2239 7 12.5 7H15.7929L12 3.20711ZM4 5C4 4.44772 4.44772 4 5 4V14C5 15.6569 6.34315 17 8 17L15 17C15 17.5523 14.5523 18 14 18H7.93939C5.76373 18 4 16.2363 4 14.0606V5Z" fill="#D0D0D0"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.light.svg new file mode 100644 index 0000000000..fb380fe84f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_document_copy_20_regular.light.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6 4C6 2.89543 6.89543 2 8 2H11.5858C11.9836 2 12.3651 2.15804 12.6464 2.43934L16.5607 6.35355C16.842 6.63486 17 7.01639 17 7.41421V14C17 15.1046 16.1046 16 15 16H8C6.89543 16 6 15.1046 6 14V4ZM8 3C7.44772 3 7 3.44772 7 4V14C7 14.5523 7.44772 15 8 15H15C15.5523 15 16 14.5523 16 14V8H12.5C11.6716 8 11 7.32843 11 6.5V3H8ZM12 3.20711V6.5C12 6.77614 12.2239 7 12.5 7H15.7929L12 3.20711ZM4 5C4 4.44772 4.44772 4 5 4V14C5 15.6569 6.34315 17 8 17L15 17C15 17.5523 14.5523 18 14 18H7.93939C5.76373 18 4 16.2363 4 14.0606V5Z" fill="#212121"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.dark.svg new file mode 100644 index 0000000000..162dedad90 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.dark.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8.49751 7.4966C9.04842 7.4966 9.49502 7.05 9.49502 6.4991C9.49502 5.94819 9.04842 5.50159 8.49751 5.50159C7.9466 5.50159 7.5 5.94819 7.5 6.4991C7.5 7.05 7.9466 7.4966 8.49751 7.4966ZM5 6C5 4.34315 6.34315 3 8 3H14C15.6569 3 17 4.34315 17 6V12C17 13.6569 15.6569 15 14 15H8C6.34315 15 5 13.6569 5 12V6ZM8 4C6.89543 4 6 4.89543 6 6V12C6 12.3709 6.10097 12.7182 6.27692 13.016L9.79085 9.50207C10.4586 8.83427 11.5414 8.83427 12.2092 9.50207L15.7231 13.016C15.899 12.7182 16 12.3709 16 12V6C16 4.89543 15.1046 4 14 4H8ZM15.016 13.7231L11.502 10.2092C11.2248 9.9319 10.7752 9.9319 10.498 10.2092L6.98403 13.7231C7.28178 13.899 7.6291 14 8 14H14C14.3709 14 14.7182 13.899 15.016 13.7231ZM12 17C12.8885 17 13.6868 16.6138 14.2361 16H7.5C5.68782 16 4.1973 14.6228 4.01807 12.8579C4.00612 12.7402 4 12.6208 4 12.5V5.76392C3.38625 6.31324 3 7.11152 3 8.00002V12.5C3 14.9853 5.01472 17 7.5 17H12Z" fill="#D0D0D0"/> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.light.svg new file mode 100644 index 0000000000..7aff1a515e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Assets/Icons/ic_fluent_image_copy_20_regular.light.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M8.49751 7.4966C9.04842 7.4966 9.49502 7.05 9.49502 6.4991C9.49502 5.94819 9.04842 5.50159 8.49751 5.50159C7.9466 5.50159 7.5 5.94819 7.5 6.4991C7.5 7.05 7.9466 7.4966 8.49751 7.4966ZM5 6C5 4.34315 6.34315 3 8 3H14C15.6569 3 17 4.34315 17 6V12C17 13.6569 15.6569 15 14 15H8C6.34315 15 5 13.6569 5 12V6ZM8 4C6.89543 4 6 4.89543 6 6V12C6 12.3709 6.10097 12.7182 6.27692 13.016L9.79085 9.50207C10.4586 8.83427 11.5414 8.83427 12.2092 9.50207L15.7231 13.016C15.899 12.7182 16 12.3709 16 12V6C16 4.89543 15.1046 4 14 4H8ZM15.016 13.7231L11.502 10.2092C11.2248 9.9319 10.7752 9.9319 10.498 10.2092L6.98403 13.7231C7.28178 13.899 7.6291 14 8 14H14C14.3709 14 14.7182 13.899 15.016 13.7231ZM12 17C12.8885 17 13.6868 16.6138 14.2361 16H7.5C5.68782 16 4.1973 14.6228 4.01807 12.8579C4.00612 12.7402 4 12.6208 4 12.5V5.76392C3.38625 6.31324 3 7.11152 3 8.00002V12.5C3 14.9853 5.01472 17 7.5 17H12Z" fill="#212121"/> +</svg> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/ClipboardHistoryCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/ClipboardHistoryCommandsProvider.cs similarity index 60% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/ClipboardHistoryCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/ClipboardHistoryCommandsProvider.cs index 85fa7e90d1..6620902eca 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/ClipboardHistoryCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/ClipboardHistoryCommandsProvider.cs @@ -2,6 +2,7 @@ // 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.Ext.ClipboardHistory.Helpers; using Microsoft.CmdPal.Ext.ClipboardHistory.Pages; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -11,18 +12,24 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory; public partial class ClipboardHistoryCommandsProvider : CommandProvider { private readonly ListItem _clipboardHistoryListItem; + private readonly SettingsManager _settingsManager = new(); public ClipboardHistoryCommandsProvider() { - _clipboardHistoryListItem = new ListItem(new ClipboardHistoryListPage()) + _clipboardHistoryListItem = new ListItem(new ClipboardHistoryListPage(_settingsManager)) { - Title = "Search Clipboard History", - Icon = new IconInfo("\xE8C8"), // Copy icon + Title = Properties.Resources.list_item_title, + Icon = Icons.ClipboardListIcon, + MoreCommands = [ + new CommandContextItem(_settingsManager.Settings.SettingsPage), + ], }; - DisplayName = $"Clipboard History"; - Icon = new IconInfo("\xE8C8"); // Copy icon + DisplayName = Properties.Resources.provider_display_name; + Icon = Icons.ClipboardListIcon; Id = "Windows.ClipboardHistory"; + + Settings = _settingsManager.Settings; } public override IListItem[] TopLevelCommands() diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/CopyCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/CopyCommand.cs similarity index 81% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/CopyCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/CopyCommand.cs index de019f1699..d55f87c9c6 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/CopyCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/CopyCommand.cs @@ -16,20 +16,20 @@ internal sealed partial class CopyCommand : InvokableCommand { _clipboardItem = clipboardItem; _clipboardFormat = clipboardFormat; - Name = "Copy"; + Name = Properties.Resources.copy_command_name; if (clipboardFormat == ClipboardFormat.Text) { - Icon = new("\xE8C8"); // Copy icon + Icon = Icons.CopyIcon; } else { - Icon = new("\xE8B9"); // Picture icon + Icon = Icons.PictureIcon; } } public override CommandResult Invoke() { ClipboardHelper.SetClipboardContent(_clipboardItem, _clipboardFormat); - return CommandResult.ShowToast("Copied to clipboard"); + return CommandResult.ShowToast(Properties.Resources.copied_toast_text); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/DeleteItemCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/DeleteItemCommand.cs new file mode 100644 index 0000000000..e6b028f820 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/DeleteItemCommand.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 Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.ApplicationModel.DataTransfer; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Commands; + +internal sealed partial class DeleteItemCommand : InvokableCommand +{ + private readonly ClipboardItem _clipboardItem; + + internal DeleteItemCommand(ClipboardItem clipboardItem) + { + _clipboardItem = clipboardItem; + Name = Properties.Resources.delete_command_name; + Icon = Icons.DeleteIcon; + } + + public override CommandResult Invoke() + { + Clipboard.DeleteItemFromHistory(_clipboardItem.Item); + return CommandResult.ShowToast(new ToastArgs + { + Message = Properties.Resources.delete_toast_text, + Result = CommandResult.KeepOpen(), + }); + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs similarity index 72% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs index f104fd424e..ed2a02e8d5 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Commands/PasteCommand.cs @@ -3,10 +3,10 @@ // See the LICENSE file in the project root for more information. using CommunityToolkit.Mvvm.Messaging; -using Microsoft.CmdPal.Common.Messages; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Messages; using Microsoft.CmdPal.Ext.ClipboardHistory.Models; using Microsoft.CommandPalette.Extensions.Toolkit; - using Windows.ApplicationModel.DataTransfer; namespace Microsoft.CmdPal.Ext.ClipboardHistory.Commands; @@ -15,13 +15,15 @@ internal sealed partial class PasteCommand : InvokableCommand { private readonly ClipboardItem _clipboardItem; private readonly ClipboardFormat _clipboardFormat; + private readonly ISettingOptions _settings; - internal PasteCommand(ClipboardItem clipboardItem, ClipboardFormat clipboardFormat) + internal PasteCommand(ClipboardItem clipboardItem, ClipboardFormat clipboardFormat, ISettingOptions settings) { _clipboardItem = clipboardItem; _clipboardFormat = clipboardFormat; - Name = "Paste"; - Icon = new("\xE8C8"); // Copy icon + _settings = settings; + Name = Properties.Resources.paste_command_name; + Icon = Icons.PasteIcon; } private void HideWindow() @@ -37,8 +39,14 @@ internal sealed partial class PasteCommand : InvokableCommand { ClipboardHelper.SetClipboardContent(_clipboardItem, _clipboardFormat); HideWindow(); + ClipboardHelper.SendPasteKeyCombination(); - Clipboard.DeleteItemFromHistory(_clipboardItem.Item); - return CommandResult.ShowToast("Pasting"); + + if (!_settings.KeepAfterPaste) + { + Clipboard.DeleteItemFromHistory(_clipboardItem.Item); + } + + return CommandResult.ShowToast(Properties.Resources.paste_toast_text); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs new file mode 100644 index 0000000000..9b73ade32b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs @@ -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. + +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// <summary> +/// Abstraction for providers that can extract metadata and offer actions for a clipboard context. +/// </summary> +internal interface IClipboardMetadataProvider +{ + /// <summary> + /// Gets the section title to show in the UI for this provider's metadata. + /// </summary> + string SectionTitle { get; } + + /// <summary> + /// Returns true if this provider can produce metadata for the given item. + /// </summary> + bool CanHandle(ClipboardItem item); + + /// <summary> + /// Returns metadata elements for the UI. Caller decides section grouping. + /// </summary> + IEnumerable<DetailsElement> GetDetails(ClipboardItem item); + + /// <summary> + /// Returns context actions to be appended to MoreCommands. Use unique IDs for de-duplication. + /// </summary> + IEnumerable<ProviderAction> GetActions(ClipboardItem item); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs new file mode 100644 index 0000000000..429f6341f3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.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.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed record ImageMetadata( + uint Width, + uint Height, + double DpiX, + double DpiY, + ulong? StorageSize); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs new file mode 100644 index 0000000000..e69a7d3d9c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal static class ImageMetadataAnalyzer +{ + /// <summary> + /// Reads image metadata from a RandomAccessStreamReference without decoding pixels. + /// Returns oriented dimensions (EXIF rotation applied). + /// </summary> + public static async Task<ImageMetadata> GetAsync(RandomAccessStreamReference reference) + { + ArgumentNullException.ThrowIfNull(reference); + + using IRandomAccessStream ras = await reference.OpenReadAsync().AsTask().ConfigureAwait(false); + var sizeBytes = TryGetSize(ras); + + // BitmapDecoder does not decode pixel data unless you ask it to, + // so this is fast and memory-friendly. + var decoder = await BitmapDecoder.CreateAsync(ras).AsTask().ConfigureAwait(false); + + // OrientedPixelWidth/Height account for EXIF orientation + var width = decoder.OrientedPixelWidth; + var height = decoder.OrientedPixelHeight; + + return new ImageMetadata( + Width: width, + Height: height, + DpiX: decoder.DpiX, + DpiY: decoder.DpiY, + StorageSize: sizeBytes); + } + + private static ulong? TryGetSize(IRandomAccessStream s) + { + try + { + // On file-backed streams this is accurate. + // On some URI/virtual streams this may be unsupported or 0. + var size = s.Size; + return size == 0 ? (ulong?)0 : size; + } + catch + { + return null; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs new file mode 100644 index 0000000000..09a3f33f2e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using ManagedCommon; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed class ImageMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Image metadata"; + + public bool CanHandle(ClipboardItem item) => item.IsImage; + + public IEnumerable<DetailsElement> GetDetails(ClipboardItem item) + { + var result = new List<DetailsElement>(); + if (!CanHandle(item) || item.ImageData is null) + { + return result; + } + + try + { + var metadata = ImageMetadataAnalyzer.GetAsync(item.ImageData).GetAwaiter().GetResult(); + + result.Add(new DetailsElement + { + Key = "Dimensions", + Data = new DetailsLink($"{metadata.Width} x {metadata.Height}"), + }); + result.Add(new DetailsElement + { + Key = "DPI", + Data = new DetailsLink($"{metadata.DpiX:0.###} x {metadata.DpiY:0.###}"), + }); + + if (metadata.StorageSize != null) + { + result.Add(new DetailsElement + { + Key = "Storage size", + Data = new DetailsLink(SizeFormatter.FormatSize(metadata.StorageSize.Value)), + }); + } + } + catch (Exception ex) + { + Logger.LogDebug("Failed to retrieve image metadata:" + ex); + } + + return result; + } + + public IEnumerable<ProviderAction> GetActions(ClipboardItem item) => []; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs new file mode 100644 index 0000000000..1274d1ace9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.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.Ext.ClipboardHistory.Helpers.Analyzers; + +internal enum LineEndingType +{ + None, + Windows, // \r\n (CRLF) + Unix, // \n (LF) + Mac, // \r (CR) + Mixed, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs new file mode 100644 index 0000000000..1827fa8744 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.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. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// <summary> +/// Represents an action exposed by a metadata provider. +/// </summary> +/// <param name="Id">Unique identifier for de-duplication (case-insensitive).</param> +/// <param name="Action">The actual context menu item to be shown.</param> +internal readonly record struct ProviderAction(string Id, CommandContextItem Action); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs new file mode 100644 index 0000000000..a08ab32bc2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs @@ -0,0 +1,49 @@ +// 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; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// <summary> +/// Utility for formatting byte sizes to a human-readable string. +/// </summary> +internal static class SizeFormatter +{ + private const long KB = 1024; + private const long MB = 1024 * KB; + private const long GB = 1024 * MB; + + public static string FormatSize(long bytes) + { + return bytes switch + { + >= GB => string.Format(CultureInfo.CurrentCulture, "{0:F2} GB", (double)bytes / GB), + >= MB => string.Format(CultureInfo.CurrentCulture, "{0:F2} MB", (double)bytes / MB), + >= KB => string.Format(CultureInfo.CurrentCulture, "{0:F2} KB", (double)bytes / KB), + _ => string.Format(CultureInfo.CurrentCulture, "{0} B", bytes), + }; + } + + public static string FormatSize(ulong bytes) + { + // Use double for division to avoid overflow; thresholds mirror long version + if (bytes >= (ulong)GB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} GB", bytes / (double)GB); + } + + if (bytes >= (ulong)MB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} MB", bytes / (double)MB); + } + + if (bytes >= (ulong)KB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} KB", bytes / (double)KB); + } + + return string.Format(CultureInfo.CurrentCulture, "{0} B", bytes); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs new file mode 100644 index 0000000000..a51444a3af --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// <summary> +/// Detects when text content is a valid existing file or directory path and exposes basic metadata. +/// </summary> +internal sealed class TextFileSystemMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "File"; + + public bool CanHandle(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return false; + } + + var text = PathHelper.Unquote(item.Content); + return PathHelper.IsValidFilePath(text); + } + + public IEnumerable<DetailsElement> GetDetails(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + var result = new List<DetailsElement>(); + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return result; + } + + var path = PathHelper.Unquote(item.Content); + + if (PathHelper.IsSlow(path) || !PathHelper.Exists(path, out var isDirectory)) + { + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(Path.GetFileName(path)) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(path), path) }); + return result; + } + + try + { + if (!isDirectory) + { + var fi = new FileInfo(path); + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(fi.Name) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(fi.FullName), fi.FullName) }); + result.Add(new DetailsElement { Key = "Type", Data = new DetailsLink(fi.Extension) }); + result.Add(new DetailsElement { Key = "Size", Data = new DetailsLink(SizeFormatter.FormatSize(fi.Length)) }); + result.Add(new DetailsElement { Key = "Modified", Data = new DetailsLink(fi.LastWriteTime.ToString(CultureInfo.CurrentCulture)) }); + result.Add(new DetailsElement { Key = "Created", Data = new DetailsLink(fi.CreationTime.ToString(CultureInfo.CurrentCulture)) }); + } + else + { + var di = new DirectoryInfo(path); + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(di.Name) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(di.FullName), di.FullName) }); + result.Add(new DetailsElement { Key = "Type", Data = new DetailsLink("Folder") }); + result.Add(new DetailsElement { Key = "Modified", Data = new DetailsLink(di.LastWriteTime.ToString(CultureInfo.CurrentCulture)) }); + result.Add(new DetailsElement { Key = "Created", Data = new DetailsLink(di.CreationTime.ToString(CultureInfo.CurrentCulture)) }); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to retrieve file system metadata.", ex); + } + + return result; + } + + public IEnumerable<ProviderAction> GetActions(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + yield break; + } + + var path = PathHelper.Unquote(item.Content); + + if (PathHelper.IsSlow(path) || !PathHelper.Exists(path, out var isDirectory)) + { + // One anything + var open = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + + yield break; + } + + if (!isDirectory) + { + // Open file + var open = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + + // Show in folder (select) + var show = new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = WellKnownKeyChords.OpenFileLocation }; + yield return new ProviderAction(WellKnownActionIds.OpenLocation, show); + + // Copy path + var copy = new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = WellKnownKeyChords.CopyFilePath }; + yield return new ProviderAction(WellKnownActionIds.CopyPath, copy); + + // Open in console at file location + var openConsole = new CommandContextItem(OpenInConsoleCommand.FromFile(path)) { RequestedShortcut = WellKnownKeyChords.OpenInConsole }; + yield return new ProviderAction(WellKnownActionIds.OpenConsole, openConsole); + } + else + { + // Open folder + var openFolder = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, openFolder); + + // Open in console + var openConsole = new CommandContextItem(OpenInConsoleCommand.FromDirectory(path)) { RequestedShortcut = WellKnownKeyChords.OpenInConsole }; + yield return new ProviderAction(WellKnownActionIds.OpenConsole, openConsole); + + // Copy path + var copy = new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = WellKnownKeyChords.CopyFilePath }; + yield return new ProviderAction(WellKnownActionIds.CopyPath, copy); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs new file mode 100644 index 0000000000..726a15c37e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.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. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed record TextMetadata +{ + public int CharacterCount { get; init; } + + public int WordCount { get; init; } + + public int SentenceCount { get; init; } + + public int LineCount { get; init; } + + public int ParagraphCount { get; init; } + + public LineEndingType LineEnding { get; init; } + + public override string ToString() + { + return $"Characters: {CharacterCount}, Words: {WordCount}, Sentences: {SentenceCount}, Lines: {LineCount}, Paragraphs: {ParagraphCount}, Line Ending: {LineEnding}"; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs new file mode 100644 index 0000000000..83992f6428 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.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; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal partial class TextMetadataAnalyzer +{ + public TextMetadata Analyze(string input) + { + ArgumentNullException.ThrowIfNull(input); + + return new TextMetadata + { + CharacterCount = input.Length, + WordCount = CountWords(input), + SentenceCount = CountSentences(input), + LineCount = CountLines(input), + ParagraphCount = CountParagraphs(input), + LineEnding = DetectLineEnding(input), + }; + } + + private LineEndingType DetectLineEnding(string text) + { + var crlfCount = Regex.Matches(text, "\r\n").Count; + var lfCount = Regex.Matches(text, "(?<!\r)\n").Count; + var crCount = Regex.Matches(text, "\r(?!\n)").Count; + + var endingTypes = (crlfCount > 0 ? 1 : 0) + (lfCount > 0 ? 1 : 0) + (crCount > 0 ? 1 : 0); + + if (endingTypes > 1) + { + return LineEndingType.Mixed; + } + + if (crlfCount > 0) + { + return LineEndingType.Windows; + } + + if (lfCount > 0) + { + return LineEndingType.Unix; + } + + if (crCount > 0) + { + return LineEndingType.Mac; + } + + return LineEndingType.None; + } + + private int CountLines(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + return text.Count(c => c == '\n') + 1; + } + + private int CountParagraphs(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var paragraphs = ParagraphsRegex() + .Split(text) + .Count(static p => !string.IsNullOrWhiteSpace(p)); + + return paragraphs > 0 ? paragraphs : 1; + } + + private int CountWords(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + return Regex.Matches(text, @"\b\w+\b").Count; + } + + private int CountSentences(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var matches = SentencesRegex().Matches(text); + return matches.Count > 0 ? matches.Count : (text.Trim().Length > 0 ? 1 : 0); + } + + [GeneratedRegex(@"(\r?\n){2,}")] + private static partial Regex ParagraphsRegex(); + + [GeneratedRegex(@"[.!?]+(?=\s|$)")] + private static partial Regex SentencesRegex(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs new file mode 100644 index 0000000000..86e2a32270 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs @@ -0,0 +1,63 @@ +// 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.Globalization; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed class TextMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Text statistics"; + + public bool CanHandle(ClipboardItem item) => item.IsText; + + public IEnumerable<DetailsElement> GetDetails(ClipboardItem item) + { + var result = new List<DetailsElement>(); + if (!CanHandle(item) || string.IsNullOrEmpty(item.Content)) + { + return result; + } + + var r = new TextMetadataAnalyzer().Analyze(item.Content); + + result.Add(new DetailsElement + { + Key = "Characters", + Data = new DetailsLink(r.CharacterCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Words", + Data = new DetailsLink(r.WordCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Sentences", + Data = new DetailsLink(r.SentenceCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Lines", + Data = new DetailsLink(r.LineCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Paragraphs", + Data = new DetailsLink(r.ParagraphCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Line Ending", + Data = new DetailsLink(r.LineEnding.ToString()), + }); + + return result; + } + + public IEnumerable<ProviderAction> GetActions(ClipboardItem item) => []; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs new file mode 100644 index 0000000000..0a2afc3e01 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// <summary> +/// Detects web links in text and shows normalized URL and key parts. +/// </summary> +internal sealed class WebLinkMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Link"; + + public bool CanHandle(ClipboardItem item) + { + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return false; + } + + if (!UrlHelper.IsValidUrl(item.Content)) + { + return false; + } + + var normalized = UrlHelper.NormalizeUrl(item.Content); + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + return false; + } + + // Exclude file: scheme; it's handled by TextFileSystemMetadataProvider + return !uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + } + + public IEnumerable<DetailsElement> GetDetails(ClipboardItem item) + { + var result = new List<DetailsElement>(); + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return result; + } + + try + { + var normalized = UrlHelper.NormalizeUrl(item.Content); + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + return result; + } + + // Skip file: at runtime as well (defensive) + if (uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + { + return result; + } + + result.Add(new DetailsElement { Key = "URL", Data = new DetailsLink(normalized) }); + result.Add(new DetailsElement { Key = "Host", Data = new DetailsLink(uri.Host) }); + + if (!uri.IsDefaultPort) + { + result.Add(new DetailsElement { Key = "Port", Data = new DetailsLink(uri.Port.ToString(CultureInfo.CurrentCulture)) }); + } + + if (!string.IsNullOrEmpty(uri.AbsolutePath) && uri.AbsolutePath != "/") + { + result.Add(new DetailsElement { Key = "Path", Data = new DetailsLink(uri.AbsolutePath) }); + } + + if (!string.IsNullOrEmpty(uri.Query)) + { + var q = uri.Query; + var count = q.Count(static c => c == '&') + (q.Length > 1 ? 1 : 0); + result.Add(new DetailsElement { Key = "Query params", Data = new DetailsLink(count.ToString(CultureInfo.CurrentCulture)) }); + } + + if (!string.IsNullOrEmpty(uri.Fragment)) + { + result.Add(new DetailsElement { Key = "Fragment", Data = new DetailsLink(uri.Fragment) }); + } + } + catch + { + // ignore malformed inputs + } + + return result; + } + + public IEnumerable<ProviderAction> GetActions(ClipboardItem item) + { + if (!CanHandle(item)) + { + yield break; + } + + var normalized = UrlHelper.NormalizeUrl(item.Content!); + + var open = new CommandContextItem(new OpenUrlCommand(normalized)) + { + RequestedShortcut = KeyChords.OpenUrl, + }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs new file mode 100644 index 0000000000..7fa2a74aea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs @@ -0,0 +1,16 @@ +// 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.Ext.ClipboardHistory.Helpers.Analyzers; + +/// <summary> +/// Well-known action id constants used to de-duplicate provider actions. +/// </summary> +internal static class WellKnownActionIds +{ + public const string Open = "open"; + public const string OpenLocation = "openLocation"; + public const string CopyPath = "copyPath"; + public const string OpenConsole = "openConsole"; +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ClipboardHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ClipboardHelper.cs similarity index 85% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ClipboardHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ClipboardHelper.cs index bbfec3c491..87937c8100 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ClipboardHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ClipboardHelper.cs @@ -30,6 +30,8 @@ internal static class ClipboardHelper (StandardDataFormats.Bitmap, ClipboardFormat.Image), ]; + private static readonly ClipboardThreadQueue ClipboardThreadQueue = new ClipboardThreadQueue(); + internal static async Task<ClipboardFormat> GetAvailableClipboardFormatsAsync(DataPackageView clipboardData) { var availableClipboardFormats = DataFormats.Aggregate( @@ -57,10 +59,13 @@ internal static class ClipboardHelper output.SetText(text); try { - // Clipboard.SetContentWithOptions(output, null); - Clipboard.SetContent(output); - Flush(); - ExtensionHost.LogMessage(new LogMessage() { Message = "Copied text to clipboard" }); + ClipboardThreadQueue.EnqueueTask(() => + { + Clipboard.SetContent(output); + + Flush(); + ExtensionHost.LogMessage(new LogMessage() { Message = "Copied text to clipboard" }); + }); } catch (COMException ex) { @@ -74,27 +79,32 @@ internal static class ClipboardHelper // TODO(stefan): For some reason Flush() fails from time to time when directly activated via hotkey. // Calling inside a loop makes it work. // Exception is: The operation is not permitted because the calling application is not the owner of the data on the clipboard. - const int maxAttempts = 5; - for (var i = 1; i <= maxAttempts; i++) + ClipboardThreadQueue.EnqueueTask(() => { - try + const int maxAttempts = 5; + + for (var i = 1; i <= maxAttempts; i++) { - Task.Run(Clipboard.Flush).Wait(); - return true; - } - catch (Exception ex) - { - if (i == maxAttempts) + try { - ExtensionHost.LogMessage(new LogMessage() + Clipboard.Flush(); + return; + } + catch (Exception ex) + { + if (i == maxAttempts) { - Message = $"{nameof(Clipboard)}.{nameof(Flush)}() failed: {ex}", - }); + ExtensionHost.LogMessage(new LogMessage() + { + Message = $"{nameof(Clipboard)}.{nameof(Flush)}() failed: {ex}", + }); + } } } - } + }); - return false; + // We cannot get the real result of the Flush() call here, as it is executed in a different thread. + return true; } private static async Task<bool> FlushAsync() => await Task.Run(Flush); @@ -105,7 +115,7 @@ internal static class ClipboardHelper DataPackage output = new(); output.SetStorageItems([storageFile]); - Clipboard.SetContent(output); + ClipboardThreadQueue.EnqueueTask(() => Clipboard.SetContent(output)); await FlushAsync(); } @@ -118,7 +128,7 @@ internal static class ClipboardHelper { DataPackage output = new(); output.SetBitmap(image); - Clipboard.SetContentWithOptions(output, null); + ClipboardThreadQueue.EnqueueTask(() => Clipboard.SetContentWithOptions(output, null)); Flush(); } @@ -129,7 +139,7 @@ internal static class ClipboardHelper switch (clipboardFormat) { case ClipboardFormat.Text: - if (clipboardItem.Content == null) + if (clipboardItem.Content is null) { ExtensionHost.LogMessage(new LogMessage() { Message = "No valid clipboard content" }); return; @@ -142,7 +152,7 @@ internal static class ClipboardHelper break; case ClipboardFormat.Image: - if (clipboardItem.ImageData == null) + if (clipboardItem.ImageData is null) { ExtensionHost.LogMessage(new LogMessage() { Message = "No valid clipboard content" }); return; @@ -230,7 +240,7 @@ internal static class ClipboardHelper internal static async Task<SoftwareBitmap?> GetClipboardImageContentAsync(DataPackageView clipboardData) { using var stream = await GetClipboardImageStreamAsync(clipboardData); - if (stream != null) + if (stream is not null) { var decoder = await BitmapDecoder.CreateAsync(stream); return await decoder.GetSoftwareBitmapAsync(); @@ -245,7 +255,7 @@ internal static class ClipboardHelper { var storageItems = await clipboardData.GetStorageItemsAsync(); var file = storageItems.Count == 1 ? storageItems[0] as StorageFile : null; - if (file != null) + if (file is not null) { return await file.OpenReadAsync(); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ClipboardThreadScheduler.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ClipboardThreadScheduler.cs new file mode 100644 index 0000000000..0f36f66453 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ClipboardThreadScheduler.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; + +public partial class ClipboardThreadQueue : IDisposable +{ + private readonly Thread _thread; + private readonly ConcurrentQueue<Action> _taskQueue = new ConcurrentQueue<Action>(); + private readonly AutoResetEvent _taskAvailable = new AutoResetEvent(false); + private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource(); + + public ClipboardThreadQueue() + { + _thread = new Thread(() => + { + var hr = NativeMethods.CoInitialize(IntPtr.Zero); + if (hr != 0) + { + ExtensionHost.LogMessage($"CoInitialize failed with HRESULT: {hr}"); + } + + while (true) + { + _taskAvailable.WaitOne(); + + if (cancellationToken.IsCancellationRequested) + { + break; + } + + while (_taskQueue.TryDequeue(out var task)) + { + try + { + task(); + } + catch (Exception ex) + { + ExtensionHost.LogMessage($"Error executing task in ClipboardThreadQueue: {ex.Message}"); + } + } + } + + NativeMethods.CoUninitialize(); + }); + + _thread.SetApartmentState(ApartmentState.STA); + _thread.IsBackground = true; + _thread.Start(); + } + + public void EnqueueTask(Action task) + { + _taskQueue.Enqueue(task); + _taskAvailable.Set(); + } + + public void Dispose() + { + cancellationToken.Cancel(); + _taskAvailable.Set(); + _thread.Join(); // Wait for the thread to finish processing tasks + + _taskAvailable.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ISettingOptions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ISettingOptions.cs new file mode 100644 index 0000000000..b98c7c0d83 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/ISettingOptions.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.Ext.ClipboardHistory.Helpers; + +public interface ISettingOptions +{ + bool KeepAfterPaste { get; } + + bool DeleteFromHistoryRequiresConfirmation { get; } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs similarity index 92% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs index 50ff346103..1e0a46f030 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs @@ -8,7 +8,7 @@ using Windows.Foundation; namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; -internal static class NativeMethods +public static partial class NativeMethods { [StructLayout(LayoutKind.Sequential)] [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")] @@ -17,7 +17,7 @@ internal static class NativeMethods internal INPUTTYPE type; internal InputUnion data; - internal static int Size => Marshal.SizeOf(typeof(INPUT)); + internal static int Size => Marshal.SizeOf<INPUT>(); } [StructLayout(LayoutKind.Explicit)] @@ -98,4 +98,10 @@ internal static class NativeMethods [DllImport("user32.dll")] internal static extern bool GetCursorPos(out PointInter lpPoint); + + [LibraryImport("ole32.dll")] + internal static partial int CoInitialize(IntPtr pvReserved); + + [LibraryImport("ole32.dll")] + internal static partial void CoUninitialize(); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/PrimaryAction.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/PrimaryAction.cs new file mode 100644 index 0000000000..11d7bd0d5d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/PrimaryAction.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.Ext.ClipboardHistory.Helpers; + +internal enum PrimaryAction +{ + Default, + Paste, + Copy, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/SettingsManager.cs new file mode 100644 index 0000000000..40fda696a2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/SettingsManager.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using Microsoft.CmdPal.Ext.ClipboardHistory.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; + +internal sealed class SettingsManager : JsonSettingsManager, ISettingOptions +{ + private const string Namespace = "clipboardHistory"; + + private static string Namespaced(string propertyName) => $"{Namespace}.{propertyName}"; + + private readonly ToggleSetting _keepAfterPaste = new( + Namespaced(nameof(KeepAfterPaste)), + Resources.settings_keep_after_paste_title!, + Resources.settings_keep_after_paste_description!, + false); + + private readonly ToggleSetting _confirmDelete = new( + Namespaced(nameof(DeleteFromHistoryRequiresConfirmation)), + Resources.settings_confirm_delete_title!, + Resources.settings_confirm_delete_description!, + true); + + private readonly ChoiceSetSetting _primaryAction = new( + Namespaced(nameof(PrimaryAction)), + Resources.settings_primary_action_title!, + Resources.settings_primary_action_description!, + [ + new ChoiceSetSetting.Choice(Resources.settings_primary_action_default!, PrimaryAction.Default.ToString("G")), + new ChoiceSetSetting.Choice(Resources.settings_primary_action_paste!, PrimaryAction.Paste.ToString("G")), + new ChoiceSetSetting.Choice(Resources.settings_primary_action_copy!, PrimaryAction.Copy.ToString("G")) + ]); + + public bool KeepAfterPaste => _keepAfterPaste.Value; + + public bool DeleteFromHistoryRequiresConfirmation => _confirmDelete.Value; + + public PrimaryAction PrimaryAction => Enum.TryParse<PrimaryAction>(_primaryAction.Value, out var action) ? action : PrimaryAction.Default; + + private static string SettingsJsonPath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + + // now, the state is just next to the exe + return Path.Combine(directory, "settings.json"); + } + + public SettingsManager() + { + FilePath = SettingsJsonPath(); + + Settings.Add(_keepAfterPaste); + Settings.Add(_confirmDelete); + Settings.Add(_primaryAction); + + // Load settings from file upon initialization + LoadSettings(); + + Settings.SettingsChanged += (_, _) => SaveSettings(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs new file mode 100644 index 0000000000..fe160e4c1b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.CmdPal.Core.Common.Helpers; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; + +internal static class UrlHelper +{ + /// <summary> + /// Validates if a string is a valid URL or file path + /// </summary> + /// <param name="url">The string to validate</param> + /// <returns>True if the string is a valid URL or file path, false otherwise</returns> + internal static bool IsValidUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + // Trim whitespace for validation + url = url.Trim(); + + // URLs should not contain newlines + if (url.Contains('\n', StringComparison.Ordinal) || url.Contains('\r', StringComparison.Ordinal)) + { + return false; + } + + // Check if it's a valid file path (local or network) + if (PathHelper.IsValidFilePath(url)) + { + return true; + } + + if (!url.Contains('.', StringComparison.OrdinalIgnoreCase)) + { + // eg: 'com', 'org'. We don't think it's a valid url. + // This can simplify the logic of checking if the url is valid. + return false; + } + + if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + return true; + } + + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + if (Uri.IsWellFormedUriString("https://" + url, UriKind.Absolute)) + { + return true; + } + } + + return false; + } + + /// <summary> + /// Normalizes a URL or file path by adding appropriate schema if none is present + /// </summary> + /// <param name="url">The URL or file path to normalize</param> + /// <returns>Normalized URL or file path with schema</returns> + internal static string NormalizeUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return url; + } + + // Trim whitespace + url = url.Trim(); + + // If it's a valid file path, convert to file:// URI + if (!url.StartsWith("file://", StringComparison.OrdinalIgnoreCase) && PathHelper.IsValidFilePath(url)) + { + try + { + // Convert to file URI (path is already absolute since we only accept absolute paths) + return new Uri(url).ToString(); + } + catch + { + // If conversion fails, return original + return url; + } + } + + if (!Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + url = "https://" + url; + } + } + + return url; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Icons.cs new file mode 100644 index 0000000000..4bb4c30586 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Icons.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory; + +internal static class Icons +{ + internal static IconInfo CopyIcon { get; } = new("\xE8C8"); + + internal static IconInfo PictureIcon { get; } = new("\xE8B9"); + + internal static IconInfo PasteIcon { get; } = new("\uE77F"); + + internal static IconInfo DeleteIcon { get; } = new("\uE74D"); + + internal static IconInfo ClipboardListIcon { get; } = IconHelpers.FromRelativePath("Assets\\ClipboardHistory.svg"); + + internal static IconInfo Clipboard { get; } = Create("ic_fluent_clipboard_20_regular"); + + internal static IconInfo ClipboardImage { get; } = Create("ic_fluent_clipboard_image_20_regular"); + + internal static IconInfo ClipboardLetter { get; } = Create("ic_fluent_clipboard_letter_20_regular"); + + internal static IconInfo Copy { get; } = Create(" ic_fluent_copy_20_regular"); + + internal static IconInfo DocumentCopy { get; } = Create("ic_fluent_document_copy_20_regular"); + + internal static IconInfo ImageCopy { get; } = Create("ic_fluent_image_copy_20_regular"); + + private static IconInfo Create(string name) + { + return IconHelpers.FromRelativePaths($"Assets\\Icons\\{name}.light.svg", $"Assets\\Icons\\{name}.dark.svg"); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs new file mode 100644 index 0000000000..e30969b56c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/KeyChords.cs @@ -0,0 +1,16 @@ +// 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory; + +internal static class KeyChords +{ + internal static KeyChord DeleteEntry { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete); + + internal static KeyChord OpenUrl { get; } = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.O); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Messages/HideWindowMessage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Messages/HideWindowMessage.cs new file mode 100644 index 0000000000..3b7f5f2260 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Messages/HideWindowMessage.cs @@ -0,0 +1,15 @@ +// 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.Ext.ClipboardHistory.Messages; + +/// <summary> +/// Message to request hiding the window. +/// +/// Yes, it's a little weird that this lives in the ClipboardHistory extension. +/// Until we need it somewhere else, this is good enough. +/// </summary> +public partial record HideWindowMessage() +{ +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj new file mode 100644 index 0000000000..5e1abb1a2e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj @@ -0,0 +1,80 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> + <PropertyGroup> + <RootNamespace>Microsoft.CmdPal.Ext.ClipboardHistory</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <Nullable>enable</Nullable> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="CommunityToolkit.Mvvm" /> + <!-- WASDK, WebView2, CmdPal Toolkit references now included via Common.ExtDependencies.props --> + </ItemGroup> + + <!-- String resources --> + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <DependentUpon>Resources.resx</DependentUpon> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + </Compile> + </ItemGroup> + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + <Generator>PublicResXFileCodeGenerator</Generator> + </EmbeddedResource> + </ItemGroup> + + <ItemGroup> + <Content Update="Assets\ClipboardHistory.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\ClipboardHistory.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_clipboard_20_regular.dark.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_clipboard_20_regular.light.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_clipboard_image_20_regular.dark.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_clipboard_image_20_regular.light.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_clipboard_letter_20_regular.dark.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_clipboard_letter_20_regular.light.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_copy_20_regular.dark.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_copy_20_regular.light.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_document_copy_20_regular.dark.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_document_copy_20_regular.light.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_image_copy_20_regular.dark.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\Icons\ic_fluent_image_copy_20_regular.light.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> +</Project> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardFormat.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardFormat.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardFormat.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardFormat.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.cs new file mode 100644 index 0000000000..f6c89f53e6 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Models/ClipboardItem.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; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Models; + +public class ClipboardItem +{ + public string? Content { get; init; } + + public required ClipboardHistoryItem Item { get; init; } + + public required ISettingOptions Settings { get; init; } + + public DateTimeOffset Timestamp => Item?.Timestamp ?? DateTimeOffset.MinValue; + + public RandomAccessStreamReference? ImageData { get; set; } + + public string GetDataType() + { + // Check if there is valid image data + if (IsImage) + { + return "Image"; + } + + // Check if there is valid text content + return IsText ? "Text" : "Unknown"; + } + + [MemberNotNullWhen(true, nameof(ImageData))] + internal bool IsImage => ImageData is not null; + + [MemberNotNullWhen(true, nameof(Content))] + internal bool IsText => !string.IsNullOrEmpty(Content); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs similarity index 82% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs index e8c4884950..d17f6f5844 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardHistoryListPage.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; using Microsoft.CmdPal.Ext.ClipboardHistory.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -17,15 +18,19 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Pages; internal sealed partial class ClipboardHistoryListPage : ListPage { + private readonly SettingsManager _settingsManager; private readonly ObservableCollection<ClipboardItem> clipboardHistory; private readonly string _defaultIconPath; - public ClipboardHistoryListPage() + public ClipboardHistoryListPage(SettingsManager settingsManager) { + ArgumentNullException.ThrowIfNull(settingsManager); + + _settingsManager = settingsManager; clipboardHistory = []; _defaultIconPath = string.Empty; - Icon = new("\uF0E3"); // ClipboardList icon - Name = "Clipboard History"; + Icon = Icons.ClipboardListIcon; + Name = Properties.Resources.clipboard_history_page_name; Id = "com.microsoft.cmdpal.clipboardHistory"; ShowDetails = true; @@ -54,7 +59,7 @@ internal sealed partial class ClipboardHistoryListPage : ListPage try { var allowClipboardHistory = Registry.GetValue(registryKey, "AllowClipboardHistory", null); - return allowClipboardHistory != null ? (int)allowClipboardHistory == 0 : false; + return allowClipboardHistory is not null ? (int)allowClipboardHistory == 0 : false; } catch (Exception) { @@ -84,11 +89,11 @@ internal sealed partial class ClipboardHistoryListPage : ListPage if (item.Content.Contains(StandardDataFormats.Text)) { var text = await item.Content.GetTextAsync(); - items.Add(new ClipboardItem { Content = text, Item = item }); + items.Add(new ClipboardItem { Settings = _settingsManager, Content = text, Item = item }); } else if (item.Content.Contains(StandardDataFormats.Bitmap)) { - items.Add(new ClipboardItem { Item = item }); + items.Add(new ClipboardItem { Settings = _settingsManager, Item = item }); } } @@ -100,7 +105,7 @@ internal sealed partial class ClipboardHistoryListPage : ListPage { var imageReceived = await item.Item.Content.GetBitmapAsync(); - if (imageReceived != null) + if (imageReceived is not null) { item.ImageData = imageReceived; } @@ -113,7 +118,7 @@ internal sealed partial class ClipboardHistoryListPage : ListPage { // TODO GH #108 We need to figure out some logging // Logger.LogError("Loading clipboard history failed", ex); - ExtensionHost.ShowStatus(new StatusMessage() { Message = "Loading clipboard history failed", State = MessageState.Error }, StatusContext.Page); + ExtensionHost.ShowStatus(new StatusMessage() { Message = Properties.Resources.clipboard_failed_to_load, State = MessageState.Error }, StatusContext.Page); ExtensionHost.LogMessage(ex.ToString()); } } @@ -141,9 +146,9 @@ internal sealed partial class ClipboardHistoryListPage : ListPage for (var i = 0; i < clipboardHistory.Count; i++) { var item = clipboardHistory[i]; - if (item != null) + if (item is not null) { - listItems.Add(item.ToListItem()); + listItems.Add(new ClipboardListItem(item, _settingsManager)); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs new file mode 100644 index 0000000000..bd1ad3d1c1 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CmdPal.Common.Commands; +using Microsoft.CmdPal.Ext.ClipboardHistory.Commands; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.ApplicationModel.DataTransfer; +using WinRT; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Models; + +internal sealed partial class ClipboardListItem : ListItem +{ + private static readonly IClipboardMetadataProvider[] MetadataProviders = + [ + new ImageMetadataProvider(), + new TextFileSystemMetadataProvider(), + new WebLinkMetadataProvider(), + new TextMetadataProvider(), + ]; + + private readonly SettingsManager _settingsManager; + private readonly ClipboardItem _item; + + private readonly CommandContextItem _deleteContextMenuItem; + private readonly CommandContextItem? _pasteCommand; + private readonly CommandContextItem? _copyCommand; + private readonly Lazy<Details> _lazyDetails; + + public override IDetails? Details + { + get => _lazyDetails.Value; + set + { + } + } + + public ClipboardListItem(ClipboardItem item, SettingsManager settingsManager) + { + _item = item; + _settingsManager = settingsManager; + _settingsManager.Settings.SettingsChanged += SettingsOnSettingsChanged; + + _lazyDetails = new(() => CreateDetails()); + + var deleteConfirmationCommand = new ConfirmableCommand + { + Command = new DeleteItemCommand(_item), + ConfirmationTitle = Properties.Resources.delete_confirmation_title!, + ConfirmationMessage = Properties.Resources.delete_confirmation_message!, + IsConfirmationRequired = () => _settingsManager.DeleteFromHistoryRequiresConfirmation, + }; + _deleteContextMenuItem = new CommandContextItem(deleteConfirmationCommand) + { + IsCritical = true, + RequestedShortcut = KeyChords.DeleteEntry, + }; + + DataPackageView = _item.Item.Content; + + if (item.IsImage) + { + Title = "Image"; + + _pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Image, _settingsManager)); + _copyCommand = new CommandContextItem(new CopyCommand(_item, ClipboardFormat.Image)); + } + else if (item.IsText) + { + var splitContent = _item.Content?.Split("\n") ?? []; + var head = splitContent.Take(3); + var preview2 = string.Join( + "\n", + StripLeadingWhitespace(head)); + + Title = preview2; + + _pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Text, _settingsManager)); + _copyCommand = new CommandContextItem(new CopyCommand(_item, ClipboardFormat.Text)); + } + else + { + _pasteCommand = null; + _copyCommand = null; + } + + RefreshCommands(); + } + + private void SettingsOnSettingsChanged(object sender, Settings args) + { + RefreshCommands(); + } + + private void RefreshCommands() + { + if (_item is { IsText: false, IsImage: false }) + { + MoreCommands = [_deleteContextMenuItem]; + Icon = _settingsManager.PrimaryAction == PrimaryAction.Paste ? Icons.Clipboard : Icons.Copy; + } + + switch (_settingsManager.PrimaryAction) + { + case PrimaryAction.Paste: + Command = _pasteCommand?.Command; + MoreCommands = BuildMoreCommands(_copyCommand); + + if (_item.IsText) + { + Icon = Icons.ClipboardLetter; + } + else if (_item.IsImage) + { + Icon = Icons.ClipboardImage; + } + else + { + Icon = Icons.ClipboardImage; + } + + break; + case PrimaryAction.Default: + case PrimaryAction.Copy: + default: + Command = _copyCommand?.Command; + MoreCommands = BuildMoreCommands(_pasteCommand); + + if (_item.IsText) + { + Icon = Icons.DocumentCopy; + } + else if (_item.IsImage) + { + Icon = Icons.ImageCopy; + } + else + { + Icon = Icons.Copy; + } + + break; + } + } + + private IContextItem[] BuildMoreCommands(CommandContextItem? firstCommand) + { + var commands = new List<IContextItem>(); + + if (firstCommand != null) + { + commands.Add(firstCommand); + } + + var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var temp = new List<IContextItem>(); + foreach (var provider in MetadataProviders) + { + if (!provider.CanHandle(_item)) + { + continue; + } + + foreach (var action in provider.GetActions(_item)) + { + if (string.IsNullOrEmpty(action.Id) || !seen.Add(action.Id)) + { + continue; + } + + temp.Add(action.Action); + } + } + + if (temp.Count > 0) + { + if (commands.Count > 0) + { + commands.Add(new Separator()); + } + + commands.AddRange(temp); + } + + commands.Add(new Separator()); + commands.Add(_deleteContextMenuItem); + + return [.. commands]; + } + + private Details CreateDetails() + { + List<IDetailsElement> metadata = []; + + foreach (var provider in MetadataProviders) + { + if (provider.CanHandle(_item)) + { + var details = provider.GetDetails(_item); + if (details.Any()) + { + metadata.Add(new DetailsElement + { + Key = provider.SectionTitle, + Data = new DetailsSeparator(), + }); + + metadata.AddRange(details); + } + } + } + + metadata.Add(new DetailsElement + { + Key = "General", + Data = new DetailsSeparator(), + }); + metadata.Add(new DetailsElement + { + Key = "Copied", + Data = new DetailsLink(_item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)), + }); + + if (_item.IsImage) + { + var iconData = new IconData(_item.ImageData); + var heroImage = new IconInfo(iconData); + return new Details + { + Title = _item.GetDataType(), + HeroImage = heroImage, + Metadata = [.. metadata], + }; + } + + if (_item.IsText) + { + return new Details + { + Title = _item.GetDataType(), + Body = $"```text\n{_item.Content}\n```", + Metadata = [.. metadata], + }; + } + + return new Details { Title = _item.GetDataType() }; + } + + private static List<string> StripLeadingWhitespace(IEnumerable<string> lines) + { + // Determine the minimum leading whitespace + var minLeadingWhitespace = lines + .Min(static line => line.TakeWhile(char.IsWhiteSpace).Count()); + + // Remove the minimum leading whitespace from each line + var shiftedLines = lines.Select(line => + line.Length >= minLeadingWhitespace + ? line[minLeadingWhitespace..] + : line).ToList(); + + return shiftedLines; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..fbc2b32860 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..f8695cae1b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.Designer.cs @@ -0,0 +1,270 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// 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. +// </auto-generated> +//------------------------------------------------------------------------------ + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties { + using System; + + + /// <summary> + /// A strongly-typed resource class, for looking up localized strings, etc. + /// </summary> + // 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()] + public 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() { + } + + /// <summary> + /// Returns the cached ResourceManager instance used by this class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Ext.ClipboardHistory.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// <summary> + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// <summary> + /// Looks up a localized string similar to Loading clipboard history failed. + /// </summary> + public static string clipboard_failed_to_load { + get { + return ResourceManager.GetString("clipboard_failed_to_load", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open. + /// </summary> + public static string clipboard_history_page_name { + get { + return ResourceManager.GetString("clipboard_history_page_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copied to clipboard. + /// </summary> + public static string copied_toast_text { + get { + return ResourceManager.GetString("copied_toast_text", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy. + /// </summary> + public static string copy_command_name { + get { + return ResourceManager.GetString("copy_command_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Delete. + /// </summary> + public static string delete_command_name { + get { + return ResourceManager.GetString("delete_command_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Are you sure you want to delete this item from clipboard history? This action cannot be undone.. + /// </summary> + public static string delete_confirmation_message { + get { + return ResourceManager.GetString("delete_confirmation_message", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Delete item?. + /// </summary> + public static string delete_confirmation_title { + get { + return ResourceManager.GetString("delete_confirmation_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Deleted from clipboard history. + /// </summary> + public static string delete_toast_text { + get { + return ResourceManager.GetString("delete_toast_text", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy, paste, and search items on the clipboard. + /// </summary> + public static string list_item_subtitle { + get { + return ResourceManager.GetString("list_item_subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Clipboard History. + /// </summary> + public static string list_item_title { + get { + return ResourceManager.GetString("list_item_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open URL. + /// </summary> + public static string open_url_command_name { + get { + return ResourceManager.GetString("open_url_command_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Paste. + /// </summary> + public static string paste_command_name { + get { + return ResourceManager.GetString("paste_command_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Pasting. + /// </summary> + public static string paste_toast_text { + get { + return ResourceManager.GetString("paste_toast_text", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Clipboard History. + /// </summary> + public static string provider_display_name { + get { + return ResourceManager.GetString("provider_display_name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Show a confirmation dialog when manually deleting an item. + /// </summary> + public static string settings_confirm_delete_description { + get { + return ResourceManager.GetString("settings_confirm_delete_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Ask for confirmation before deleting items. + /// </summary> + public static string settings_confirm_delete_title { + get { + return ResourceManager.GetString("settings_confirm_delete_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Keep items in clipboard history after pasting. + /// </summary> + public static string settings_keep_after_paste_description { + get { + return ResourceManager.GetString("settings_keep_after_paste_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Keep items after pasting. + /// </summary> + public static string settings_keep_after_paste_title { + get { + return ResourceManager.GetString("settings_keep_after_paste_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy to Clipboard. + /// </summary> + public static string settings_primary_action_copy { + get { + return ResourceManager.GetString("settings_primary_action_copy", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Default (Copy to Clipboard). + /// </summary> + public static string settings_primary_action_default { + get { + return ResourceManager.GetString("settings_primary_action_default", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Primary action (Enter key). + /// </summary> + public static string settings_primary_action_description { + get { + return ResourceManager.GetString("settings_primary_action_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Paste. + /// </summary> + public static string settings_primary_action_paste { + get { + return ResourceManager.GetString("settings_primary_action_paste", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Primary action. + /// </summary> + public static string settings_primary_action_title { + get { + return ResourceManager.GetString("settings_primary_action_title", resourceCulture); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx new file mode 100644 index 0000000000..56d0805871 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Properties/Resources.resx @@ -0,0 +1,189 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="copy_command_name" xml:space="preserve"> + <value>Copy</value> + </data> + <data name="paste_command_name" xml:space="preserve"> + <value>Paste</value> + </data> + <data name="paste_toast_text" xml:space="preserve"> + <value>Pasting</value> + </data> + <data name="copied_toast_text" xml:space="preserve"> + <value>Copied to clipboard</value> + </data> + <data name="list_item_title" xml:space="preserve"> + <value>Clipboard History</value> + </data> + <data name="list_item_subtitle" xml:space="preserve"> + <value>Copy, paste, and search items on the clipboard</value> + </data> + <data name="provider_display_name" xml:space="preserve"> + <value>Clipboard History</value> + </data> + <data name="clipboard_history_page_name" xml:space="preserve"> + <value>Open</value> + </data> + <data name="clipboard_failed_to_load" xml:space="preserve"> + <value>Loading clipboard history failed</value> + </data> + <data name="delete_command_name" xml:space="preserve"> + <value>Delete</value> + </data> + <data name="delete_toast_text" xml:space="preserve"> + <value>Deleted from clipboard history</value> + </data> + <data name="settings_keep_after_paste_description" xml:space="preserve"> + <value>Keep items in clipboard history after pasting</value> + </data> + <data name="settings_keep_after_paste_title" xml:space="preserve"> + <value>Keep items after pasting</value> + </data> + <data name="settings_confirm_delete_title" xml:space="preserve"> + <value>Ask for confirmation before deleting items</value> + </data> + <data name="settings_confirm_delete_description" xml:space="preserve"> + <value>Show a confirmation dialog when manually deleting an item</value> + </data> + <data name="delete_confirmation_title" xml:space="preserve"> + <value>Delete item?</value> + </data> + <data name="delete_confirmation_message" xml:space="preserve"> + <value>Are you sure you want to delete this item from clipboard history? This action cannot be undone.</value> + </data> + <data name="settings_primary_action_title" xml:space="preserve"> + <value>Primary action</value> + </data> + <data name="settings_primary_action_description" xml:space="preserve"> + <value>Primary action (Enter key)</value> + </data> + <data name="settings_primary_action_default" xml:space="preserve"> + <value>Default (Copy to Clipboard)</value> + </data> + <data name="settings_primary_action_paste" xml:space="preserve"> + <value>Paste</value> + </data> + <data name="settings_primary_action_copy" xml:space="preserve"> + <value>Copy to Clipboard</value> + </data> + <data name="open_url_command_name" xml:space="preserve"> + <value>Open URL</value> + </data> +</root> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Actions/ActionRuntimeFactory.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Actions/ActionRuntimeFactory.cs new file mode 100644 index 0000000000..6fd708f265 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Actions/ActionRuntimeFactory.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using ManagedCsWin32; +using WinRT; + +namespace Microsoft.CmdPal.Ext.Indexer.Data; + +internal static class ActionRuntimeFactory +{ + private const string ActionRuntimeClsidStr = "C36FEF7E-35F3-4192-9F2C-AF1FD425FB85"; + + // typeof(Windows.AI.Actions.IActionRuntime).GUID + private static readonly Guid IActionRuntimeIID = Guid.Parse("206EFA2C-C909-508A-B4B0-9482BE96DB9C"); + + public static unsafe global::Windows.AI.Actions.ActionRuntime CreateActionRuntime() + { + IntPtr abiPtr = default; + try + { + Guid classId = Guid.Parse(ActionRuntimeClsidStr); + Guid iid = IActionRuntimeIID; + + var hresult = Ole32.CoCreateInstance(ref Unsafe.AsRef(in classId), IntPtr.Zero, CLSCTX.LocalServer, ref iid, out abiPtr); + Marshal.ThrowExceptionForHR((int)hresult); + + return MarshalInterface<global::Windows.AI.Actions.ActionRuntime>.FromAbi(abiPtr); + } + finally + { + MarshalInspectable<object>.DisposeAbi(abiPtr); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Actions/ActionRuntimeManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Actions/ActionRuntimeManager.cs new file mode 100644 index 0000000000..995c8a5f6c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Actions/ActionRuntimeManager.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Windows.AI.Actions; + +namespace Microsoft.CmdPal.Ext.Indexer.Data; + +public static class ActionRuntimeManager +{ + private static readonly Lazy<Task<ActionRuntime>> _lazyRuntime = new(InitializeAsync); + + public static Task<ActionRuntime> InstanceAsync => _lazyRuntime.Value; + + private static async Task<ActionRuntime> InitializeAsync() + { + // If we tried 3 times and failed, should we think the action runtime is not working? + // then we should not use it anymore. + const int maxAttempts = 3; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var runtime = ActionRuntimeFactory.CreateActionRuntime(); + await Task.Delay(500); + + return runtime; + } + catch (Exception ex) + { + Logger.LogError($"Attempt {attempt} to initialize ActionRuntime failed: {ex.Message}"); + + if (attempt == maxAttempts) + { + Logger.LogError($"Failed to initialize ActionRuntime: {ex.Message}"); + } + } + } + + return null; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/Actions.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/Actions.png new file mode 100644 index 0000000000..6aaddfda85 Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/Actions.png differ diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Assets/FileExplorer.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/FileExplorer.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Assets/FileExplorer.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/FileExplorer.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Assets/FileExplorer.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/FileExplorer.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Assets/FileExplorer.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/FileExplorer.svg diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/Peek.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/Peek.png new file mode 100644 index 0000000000..3beeccaf17 Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Assets/Peek.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Commands/ExecuteActionCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Commands/ExecuteActionCommand.cs new file mode 100644 index 0000000000..bf523d5792 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Commands/ExecuteActionCommand.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.AI.Actions.Hosting; + +namespace Microsoft.CmdPal.Ext.Indexer.Commands; + +public sealed partial class ExecuteActionCommand : InvokableCommand +{ + private readonly ActionInstance actionInstance; + + public ExecuteActionCommand(ActionInstance actionInstance) + { + this.actionInstance = actionInstance; + this.Name = actionInstance.DisplayInfo.Description; + this.Icon = new IconInfo(actionInstance.Definition.IconFullPath); + } + + public override CommandResult Invoke() + { + var task = Task.Run(InvokeAsync); + task.Wait(); + + return task.Result; + } + + private async Task<CommandResult> InvokeAsync() + { + try + { + await actionInstance.InvokeAsync(); + return CommandResult.GoHome(); + } + catch (Exception ex) + { + return CommandResult.ShowToast("Failed to invoke action " + actionInstance.Definition.Id + ": " + ex.Message); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Commands/PeekFileCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Commands/PeekFileCommand.cs new file mode 100644 index 0000000000..808daa567f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Commands/PeekFileCommand.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; +using System.Diagnostics; +using System.IO; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Indexer.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Indexer.Commands; + +/// <summary> +/// Command to preview a file using PowerToys Peek. +/// </summary> +public sealed partial class PeekFileCommand : InvokableCommand +{ + private const string PeekExecutable = @"WinUI3Apps\PowerToys.Peek.UI.exe"; + + private static readonly Lazy<string> _peekPath = new(GetPeekExecutablePath); + + private readonly string _fullPath; + + public PeekFileCommand(string fullPath) + { + _fullPath = fullPath; + Name = Resources.Indexer_Command_Peek; + Icon = Icons.PeekIcon; + } + + /// <summary> + /// Gets a value indicating whether Peek is available on this system. + /// </summary> + public static bool IsPeekAvailable => !string.IsNullOrEmpty(_peekPath.Value); + + public override CommandResult Invoke() + { + var peekExe = _peekPath.Value; + if (string.IsNullOrEmpty(peekExe)) + { + return CommandResult.ShowToast(Resources.Indexer_Command_Peek_NotAvailable); + } + + try + { + using var process = new Process(); + process.StartInfo.FileName = peekExe; + process.StartInfo.Arguments = $"\"{_fullPath}\""; + process.StartInfo.UseShellExecute = false; + process.Start(); + } + catch (Exception ex) + { + ExtensionHost.LogMessage($"Unable to launch Peek for {_fullPath}\n{ex}"); + return CommandResult.ShowToast(Resources.Indexer_Command_Peek_Failed); + } + + return CommandResult.Dismiss(); + } + + private static string GetPeekExecutablePath() + { + var installPath = PowerToysPathResolver.GetPowerToysInstallPath(); + if (string.IsNullOrEmpty(installPath)) + { + return string.Empty; + } + + var peekPath = Path.Combine(installPath, PeekExecutable); + return File.Exists(peekPath) ? peekPath : string.Empty; + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Data/IndexerItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerItem.cs similarity index 80% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Data/IndexerItem.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerItem.cs index 5b65cc9ef8..00222149f9 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Data/IndexerItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerItem.cs @@ -12,6 +12,16 @@ internal sealed class IndexerItem internal string FileName { get; init; } + internal IndexerItem() + { + } + + internal IndexerItem(string fullPath) + { + FullPath = fullPath; + FileName = Path.GetFileName(fullPath); + } + internal bool IsDirectory() { if (!Path.Exists(FullPath)) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs new file mode 100644 index 0000000000..54d2744abd --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Data/IndexerListItem.cs @@ -0,0 +1,130 @@ +// 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.IO; +using System.Linq; +using Microsoft.CmdPal.Core.Common.Commands; +using Microsoft.CmdPal.Ext.Indexer.Commands; +using Microsoft.CmdPal.Ext.Indexer.Helpers; +using Microsoft.CmdPal.Ext.Indexer.Pages; +using Microsoft.CmdPal.Ext.Indexer.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation.Metadata; +using FileAttributes = System.IO.FileAttributes; + +namespace Microsoft.CmdPal.Ext.Indexer.Data; + +internal sealed partial class IndexerListItem : ListItem +{ + internal static readonly bool IsActionsFeatureEnabled = GetFeatureFlag(); + + private static bool GetFeatureFlag() + { + var env = System.Environment.GetEnvironmentVariable("CMDPAL_ENABLE_ACTIONS_LIST"); + return !string.IsNullOrEmpty(env) && + (env == "1" || env.Equals("true", System.StringComparison.OrdinalIgnoreCase)); + } + + internal string FilePath { get; private set; } + + public IndexerListItem( + IndexerItem indexerItem, + IncludeBrowseCommand browseByDefault = IncludeBrowseCommand.Include) + : base() + { + FilePath = indexerItem.FullPath; + + Title = indexerItem.FileName; + Subtitle = indexerItem.FullPath; + + DataPackage = DataPackageHelper.CreateDataPackageForPath(this, FilePath); + + var commands = FileCommands(indexerItem.FullPath, browseByDefault); + if (commands.Any()) + { + Command = commands.First().Command; + MoreCommands = commands.Skip(1).ToArray(); + } + } + + public static IEnumerable<CommandContextItem> FileCommands(string fullPath) + { + return FileCommands(fullPath, IncludeBrowseCommand.Include); + } + + internal static IEnumerable<CommandContextItem> FileCommands( + string fullPath, + IncludeBrowseCommand browseByDefault = IncludeBrowseCommand.Include) + { + List<CommandContextItem> commands = []; + if (!Path.Exists(fullPath)) + { + return commands; + } + + // detect whether it is a directory or file + var attr = File.GetAttributes(fullPath); + var isDir = (attr & FileAttributes.Directory) == FileAttributes.Directory; + + var openCommand = new OpenFileCommand(fullPath) { Name = Resources.Indexer_Command_OpenFile }; + if (isDir) + { + var directoryPage = new DirectoryPage(fullPath); + if (browseByDefault == IncludeBrowseCommand.AsDefault) + { + // AsDefault: browse dir first, then open in explorer + commands.Add(new CommandContextItem(directoryPage)); + commands.Add(new CommandContextItem(openCommand)); + } + else if (browseByDefault == IncludeBrowseCommand.Include) + { + // AsDefault: open in explorer first, then browse + commands.Add(new CommandContextItem(openCommand)); + commands.Add(new CommandContextItem(directoryPage)); + } + else if (browseByDefault == IncludeBrowseCommand.Exclude) + { + // AsDefault: Just open in explorer + commands.Add(new CommandContextItem(openCommand)); + } + } + else + { + commands.Add(new CommandContextItem(openCommand)); + } + + commands.Add(new CommandContextItem(new OpenWithCommand(fullPath))); + + // Add Peek command if available (only for files, not directories) + if (!isDir && PeekFileCommand.IsPeekAvailable) + { + commands.Add(new CommandContextItem(new PeekFileCommand(fullPath)) { RequestedShortcut = KeyChords.Peek }); + } + + commands.Add(new CommandContextItem(new ShowFileInFolderCommand(fullPath) { Name = Resources.Indexer_Command_ShowInFolder }) { RequestedShortcut = KeyChords.OpenFileLocation }); + commands.Add(new CommandContextItem(new CopyPathCommand(fullPath) { Name = Resources.Indexer_Command_CopyPath }) { RequestedShortcut = KeyChords.CopyFilePath }); + commands.Add(new CommandContextItem(new OpenInConsoleCommand(fullPath)) { RequestedShortcut = KeyChords.OpenInConsole }); + commands.Add(new CommandContextItem(new OpenPropertiesCommand(fullPath))); + + if (IsActionsFeatureEnabled && ApiInformation.IsApiContractPresent("Windows.AI.Actions.ActionsContract", 4)) + { + var actionsListContextItem = new ActionsListContextItem(fullPath); + if (actionsListContextItem.AnyActions()) + { + commands.Add(actionsListContextItem); + } + } + + return commands; + } +} + +internal enum IncludeBrowseCommand +{ + AsDefault = 0, + Include = 1, + Exclude = 2, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs new file mode 100644 index 0000000000..96f66729ac --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Indexer.Data; +using Microsoft.CmdPal.Ext.Indexer.Helpers; +using Microsoft.CmdPal.Ext.Indexer.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.Indexer; + +internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, IDisposable +{ + private const string CommandId = "com.microsoft.cmdpal.builtin.indexer.fallback"; + + // Cookie to identify our queries; since we replace the SearchEngine on each search, + // this can be a constant. + private const uint HardQueryCookie = 10; + private static readonly NoOpCommand BaseCommandWithId = new() { Id = CommandId }; + + private readonly CompositeFormat _fallbackItemSearchPageTitleFormat = CompositeFormat.Parse(Resources.Indexer_fallback_searchPage_title); + private readonly CompositeFormat _fallbackItemSearchSubtitleMultipleResults = CompositeFormat.Parse(Resources.Indexer_Fallback_MultipleResults_Subtitle); + private readonly Lock _querySwitchLock = new(); + private readonly Lock _resultLock = new(); + + private CancellationTokenSource? _currentQueryCts; + private Func<string, bool>? _suppressCallback; + + public FallbackOpenFileItem() + : base(BaseCommandWithId, Resources.Indexer_Find_Path_fallback_display_title, CommandId) + { + Title = string.Empty; + Subtitle = string.Empty; + Icon = Icons.FileExplorerIcon; + } + + public override void UpdateQuery(string query) + { + UpdateQueryCore(query); + } + + private void UpdateQueryCore(string query) + { + // Calling this will cancel any ongoing query processing. We always use a new SearchEngine + // instance per query, as SearchEngine.Query cancels/reinitializes internally. + CancellationToken cancellationToken; + + lock (_querySwitchLock) + { + _currentQueryCts?.Cancel(); + _currentQueryCts?.Dispose(); + _currentQueryCts = new CancellationTokenSource(); + cancellationToken = _currentQueryCts.Token; + } + + var suppressCallback = _suppressCallback; + if (string.IsNullOrWhiteSpace(query) || (suppressCallback is not null && suppressCallback(query))) + { + ClearResultForCurrentQuery(cancellationToken); + return; + } + + try + { + var exists = Path.Exists(query); + if (exists) + { + ProcessDirectPath(query, cancellationToken); + } + else + { + ProcessSearchQuery(query, cancellationToken); + } + } + catch (OperationCanceledException) + { + // Query was superseded by a newer one - discard silently. + } + catch + { + if (!cancellationToken.IsCancellationRequested) + { + ClearResultForCurrentQuery(cancellationToken); + } + } + } + + private void ProcessDirectPath(string query, CancellationToken ct) + { + var item = new IndexerItem(fullPath: query); + var indexerListItem = new IndexerListItem(item, IncludeBrowseCommand.AsDefault); + + ct.ThrowIfCancellationRequested(); + UpdateResultForCurrentQuery(indexerListItem, skipIcon: true, ct); + _ = LoadIconAsync(item.FullPath, ct); + } + + private void ProcessSearchQuery(string query, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + // for now the SearchEngine and SearchQuery are not thread-safe, so we create a new instance per query + // since SearchEngine will re-initialize on a new query anyway, it doesn't seem to be a big overhead for now + var searchEngine = new SearchEngine(); + + try + { + searchEngine.Query(query, queryCookie: HardQueryCookie); + ct.ThrowIfCancellationRequested(); + + // We only need to know whether there are 0, 1, or more than one result + var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, noIcons: true); + var count = results.Count; + + if (count == 0) + { + ClearResultForCurrentQuery(ct); + } + else if (count == 1) + { + if (results[0] is IndexerListItem indexerListItem) + { + UpdateResultForCurrentQuery(indexerListItem, skipIcon: true, ct); + _ = LoadIconAsync(indexerListItem.FilePath, ct); + } + else + { + ClearResultForCurrentQuery(ct); + } + } + else + { + var indexerPage = new IndexerPage(query); + + var set = UpdateResultForCurrentQuery( + string.Format(CultureInfo.CurrentCulture, _fallbackItemSearchPageTitleFormat, query), + string.Format(CultureInfo.CurrentCulture, _fallbackItemSearchSubtitleMultipleResults), + Icons.FileExplorerIcon, + indexerPage, + MoreCommands, + DataPackage, + skipIcon: false, + ct); + + if (!set) + { + // if we failed to set the result (query was cancelled), dispose the page and search engine + indexerPage.Dispose(); + } + } + } + finally + { + searchEngine?.Dispose(); + } + } + + private async Task LoadIconAsync(string path, CancellationToken ct) + { + try + { + var stream = await ThumbnailHelper.GetThumbnail(path).ConfigureAwait(false); + if (stream is null || ct.IsCancellationRequested) + { + return; + } + + var thumbnailStream = RandomAccessStreamReference.CreateFromStream(stream); + if (ct.IsCancellationRequested) + { + return; + } + + var data = new IconData(thumbnailStream); + UpdateIconForCurrentQuery(new IconInfo(data), ct); + } + catch + { + // ignore - keep default icon + UpdateIconForCurrentQuery(Icons.FileExplorerIcon, ct); + } + } + + private bool ClearResultForCurrentQuery(CancellationToken ct) + { + return UpdateResultForCurrentQuery(string.Empty, string.Empty, Icons.FileExplorerIcon, BaseCommandWithId, null, null, false, ct); + } + + private bool UpdateResultForCurrentQuery(IndexerListItem listItem, bool skipIcon, CancellationToken ct) + { + return UpdateResultForCurrentQuery( + listItem.Title, + listItem.Subtitle, + listItem.Icon, + listItem.Command, + listItem.MoreCommands, + DataPackageHelper.CreateDataPackageForPath(listItem, listItem.FilePath), + skipIcon, + ct); + } + + private bool UpdateResultForCurrentQuery(string title, string subtitle, IIconInfo? iconInfo, ICommand? command, IContextItem[]? moreCommands, DataPackage? dataPackage, bool skipIcon, CancellationToken ct) + { + lock (_resultLock) + { + if (ct.IsCancellationRequested) + { + return false; + } + + Title = title; + Subtitle = subtitle; + if (!skipIcon) + { + Icon = iconInfo!; + } + + MoreCommands = moreCommands!; + DataPackage = dataPackage; + Command = command; + return true; + } + } + + private void UpdateIconForCurrentQuery(IIconInfo icon, CancellationToken ct) + { + lock (_resultLock) + { + if (ct.IsCancellationRequested) + { + return; + } + + Icon = icon; + } + } + + public void Dispose() + { + _currentQueryCts?.Cancel(); + _currentQueryCts?.Dispose(); + GC.SuppressFinalize(this); + } + + public void SuppressFallbackWhen(Func<string, bool> callback) + { + _suppressCallback = callback; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Helpers/DataPackageHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Helpers/DataPackageHelper.cs new file mode 100644 index 0000000000..a4e7873189 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Helpers/DataPackageHelper.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage; +using File = System.IO.File; + +namespace Microsoft.CmdPal.Ext.Indexer.Helpers; + +internal static class DataPackageHelper +{ + public static DataPackage? CreateDataPackageForPath(ICommandItem listItem, string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + // Capture now; don't rely on listItem still being valid later. + var title = listItem.Title; + var description = listItem.Subtitle; + var capturedPath = path; + + var dataPackage = new DataPackage + { + RequestedOperation = DataPackageOperation.Copy, + Properties = + { + Title = title, + Description = description, + }, + }; + + // Cheap + immediate. + dataPackage.SetText(capturedPath); + + // Expensive + only computed if the consumer asks for StorageItems. + dataPackage.SetDataProvider(StandardDataFormats.StorageItems, async void (request) => + { + var deferral = request.GetDeferral(); + try + { + var items = await TryGetStorageItemAsync(capturedPath).ConfigureAwait(false); + if (items is not null) + { + request.SetData(items); + } + + // If null: just don't provide StorageItems. Text still works. + } + catch + { + // Swallow: better to provide partial data (text) than fail the whole package. + } + finally + { + deferral.Complete(); + } + }); + + return dataPackage; + } + + private static async Task<IStorageItem[]?> TryGetStorageItemAsync(string filePath) + { + try + { + if (File.Exists(filePath)) + { + var file = await StorageFile.GetFileFromPathAsync(filePath); + return [file]; + } + + if (Directory.Exists(filePath)) + { + var folder = await StorageFolder.GetFolderFromPathAsync(filePath); + return [folder]; + } + + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + catch + { + return null; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs new file mode 100644 index 0000000000..1e92d9f323 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Icons.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Indexer; + +internal static class Icons +{ + internal static IconInfo FileExplorerSegoeIcon { get; } = new("\uEC50"); + + internal static IconInfo FileExplorerIcon { get; } = IconHelpers.FromRelativePath("Assets\\FileExplorer.png"); + + internal static IconInfo ActionsIcon { get; } = IconHelpers.FromRelativePath("Assets\\Actions.png"); + + internal static IconInfo OpenFileIcon { get; } = new("\uE8E5"); // OpenFile + + internal static IconInfo DocumentIcon { get; } = new("\uE8A5"); // Document + + internal static IconInfo FolderOpenIcon { get; } = new("\uE838"); // FolderOpen + + internal static IconInfo FilesIcon { get; } = new("\uF571"); // PrintAllPages + + internal static IconInfo FilterIcon { get; } = new("\uE71C"); // Filter + + internal static IconInfo PeekIcon { get; } = IconHelpers.FromRelativePath("Assets\\Peek.png"); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/DataSourceManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/DataSourceManager.cs similarity index 51% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/DataSourceManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/DataSourceManager.cs index e186251825..1e2119f0ab 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/DataSourceManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/DataSourceManager.cs @@ -3,22 +3,20 @@ // See the LICENSE file in the project root for more information. using System; +using System.Runtime.CompilerServices; using ManagedCommon; -using Windows.Win32; -using Windows.Win32.System.Com; -using Windows.Win32.System.Search; +using ManagedCsWin32; +using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; namespace Microsoft.CmdPal.Ext.Indexer.Indexer; internal static class DataSourceManager { - private static readonly Guid CLSIDCollatorDataSource = new("9E175B8B-F52A-11D8-B9A5-505054503030"); - private static IDBInitialize _dataSource; public static IDBInitialize GetDataSource() { - if (_dataSource == null) + if (_dataSource is null) { InitializeDataSource(); } @@ -28,20 +26,18 @@ internal static class DataSourceManager private static bool InitializeDataSource() { - var hr = PInvoke.CoCreateInstance(CLSIDCollatorDataSource, null, CLSCTX.CLSCTX_INPROC_SERVER, typeof(IDBInitialize).GUID, out var dataSourceObj); - if (hr != 0) + var riid = typeof(IDBInitialize).GUID; + + try { - Logger.LogError("CoCreateInstance failed: " + hr); + _dataSource = ComHelper.CreateComInstance<IDBInitialize>(ref Unsafe.AsRef(in CLSID.CollatorDataSource), CLSCTX.InProcServer); + } + catch (Exception e) + { + Logger.LogError($"Failed to create datasource. ex: {e.Message}"); return false; } - if (dataSourceObj == null) - { - Logger.LogError("CoCreateInstance failed: dataSourceObj is null"); - return false; - } - - _dataSource = (IDBInitialize)dataSourceObj; _dataSource.Initialize(); return true; diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROP.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROP.cs similarity index 88% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROP.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROP.cs index d4be1b967f..054e3d504c 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROP.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROP.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information. using System.Runtime.InteropServices; +using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; using Windows.Win32.Storage.IndexServer; -using Windows.Win32.System.Com.StructuredStorage; namespace Microsoft.CmdPal.Ext.Indexer.Indexer.OleDB; @@ -16,6 +16,6 @@ internal struct DBPROP public uint dwOptions; public uint dwStatus; public DBID colid; - public PROPVARIANT vValue; + public PropVariant vValue; #pragma warning restore SA1307 // Accessible fields should begin with upper-case letter } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROPIDSET.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROPIDSET.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROPIDSET.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROPIDSET.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROPSET.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROPSET.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROPSET.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/DBPROPSET.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowset.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowset.cs new file mode 100644 index 0000000000..75502f56c2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowset.cs @@ -0,0 +1,41 @@ +// 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.Runtime.InteropServices.Marshalling; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.OleDB; + +[Guid("0c733a7c-2a1c-11ce-ade5-00aa0044773d")] +[GeneratedComInterface] +public partial interface IRowset +{ + void AddRefRows( + uint cRows, + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] IntPtr[] rghRows, + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] uint[] rgRefCounts, + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] int[] rgRowStatus); + + void GetData( + IntPtr hRow, + IntPtr hAccessor, + IntPtr pData); + + void GetNextRows( + IntPtr hReserved, + long lRowsOffset, + long cRows, + out uint pcRowsObtained, + out IntPtr prghRows); + + void ReleaseRows( + uint cRows, + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] IntPtr[] rghRows, + IntPtr rgRowOptions, + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] uint[] rgRefCounts, + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] int[] rgRowStatus); + + void RestartPosition(nuint hReserved); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowsetInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowsetInfo.cs similarity index 59% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowsetInfo.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowsetInfo.cs index 5c891c8036..bf1406179b 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowsetInfo.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/OleDB/IRowsetInfo.cs @@ -4,29 +4,29 @@ using System; using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; namespace Microsoft.CmdPal.Ext.Indexer.Indexer.OleDB; -[ComImport] [Guid("0C733A55-2A1C-11CE-ADE5-00AA0044773D")] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -public interface IRowsetInfo +[GeneratedComInterface] +public partial interface IRowsetInfo { [PreserveSig] int GetProperties( uint cPropertyIDSets, - [In, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] DBPROPIDSET[] rgPropertyIDSets, + [MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] DBPROPIDSET[] rgPropertyIDSets, out ulong pcPropertySets, out IntPtr prgPropertySets); [PreserveSig] int GetReferencedRowset( uint iOrdinal, - [In] ref Guid riid, - [Out, MarshalAs(UnmanagedType.Interface)] out object ppReferencedRowset); + ref Guid riid, + [MarshalAs(UnmanagedType.Interface)] out object ppReferencedRowset); [PreserveSig] int GetSpecification( - [In] ref Guid riid, - [Out, MarshalAs(UnmanagedType.Interface)] out object ppSpecification); + ref Guid riid, + [MarshalAs(UnmanagedType.Interface)] out object ppSpecification); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs new file mode 100644 index 0000000000..bf2e5dc451 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchFilters.cs @@ -0,0 +1,27 @@ +// 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.Ext.Indexer.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer; + +internal sealed partial class SearchFilters : Filters +{ + public SearchFilters() + { + CurrentFilterId = "all"; + } + + public override IFilterItem[] GetFilters() + { + return [ + new Filter() { Id = "all", Name = Resources.Indexer_Filter_All, Icon = Icons.FilterIcon }, + new Separator(), + new Filter() { Id = "folders", Name = Resources.Indexer_Filter_Folders_Only, Icon = Icons.FolderOpenIcon }, + new Filter() { Id = "files", Name = Resources.Indexer_Filter_Files_Only, Icon = Icons.FilesIcon }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs new file mode 100644 index 0000000000..6dd3137dbb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using ManagedCommon; +using ManagedCsWin32; +using Microsoft.CmdPal.Ext.Indexer.Indexer.OleDB; +using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; +using Microsoft.CmdPal.Ext.Indexer.Indexer.Utils; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer; + +internal sealed partial class SearchQuery : IDisposable +{ + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Indexing Service constant")] + private const int QUERY_E_ALLNOISE = unchecked((int)0x80041605); + + private readonly Lock _lockObject = new(); + + private IRowset _currentRowset; + + public QueryState State { get; private set; } = QueryState.NotStarted; + + private int? LastHResult { get; set; } + + private string LastErrorMessage { get; set; } + + public uint Cookie { get; private set; } + + public string SearchText { get; private set; } + + public ConcurrentQueue<SearchResult> SearchResults { get; private set; } = []; + + public void CancelOutstandingQueries() + { + Logger.LogDebug("Cancel query " + SearchText); + + // Are we currently doing work? If so, let's cancel + lock (_lockObject) + { + State = QueryState.Cancelled; + } + } + + public void Execute(string searchText, uint cookie) + { + SearchText = searchText; + Cookie = cookie; + ExecuteSyncInternal(); + } + + private void ExecuteSyncInternal() + { + lock (_lockObject) + { + State = QueryState.Running; + LastHResult = null; + LastErrorMessage = null; + + var queryStr = QueryStringBuilder.GenerateQuery(SearchText); + try + { + var result = ExecuteCommand(queryStr); + _currentRowset = result.Rowset; + State = result.State; + LastHResult = result.HResult; + LastErrorMessage = result.ErrorMessage; + + SearchResults.Clear(); + } + catch (Exception ex) + { + State = QueryState.ExecuteFailed; + LastHResult = ex.HResult; + LastErrorMessage = ex.Message; + Logger.LogError("Error executing query", ex); + } + } + } + + private bool HandleRow(IGetRow getRow, nuint rowHandle) + { + try + { + getRow.GetRowFromHROW(null, rowHandle, ref Unsafe.AsRef(in IID.IPropertyStore), out var propertyStore); + + if (propertyStore is null) + { + Logger.LogError("Failed to get IPropertyStore interface"); + return false; + } + + var searchResult = SearchResult.Create(propertyStore); + if (searchResult is null) + { + Logger.LogError("Failed to create search result"); + return false; + } + + SearchResults.Enqueue(searchResult); + return true; + } + catch (Exception ex) + { + Logger.LogError("Error handling row", ex); + return false; + } + } + + public bool FetchRows(int offset, int limit) + { + if (_currentRowset is null) + { + var message = $"No rowset to fetch rows from. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'"; + + switch (State) + { + case QueryState.NoResults: + case QueryState.AllNoise: + Logger.LogDebug(message); + break; + case QueryState.NotStarted: + case QueryState.Cancelled: + case QueryState.Running: + Logger.LogInfo(message); + break; + default: + Logger.LogError(message); + break; + } + + return false; + } + + IGetRow getRow; + + try + { + getRow = (IGetRow)_currentRowset; + } + catch (Exception ex) + { + Logger.LogInfo($"Reset the current rowset. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'"); + Logger.LogError("Failed to cast current rowset to IGetRow", ex); + + ExecuteSyncInternal(); + + if (_currentRowset is null) + { + var message = $"Failed to reset rowset. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'"; + + switch (State) + { + case QueryState.NoResults: + case QueryState.AllNoise: + Logger.LogDebug(message); + break; + default: + Logger.LogError(message); + break; + } + + return false; + } + + getRow = (IGetRow)_currentRowset; + } + + var prghRows = IntPtr.Zero; + + try + { + _currentRowset.GetNextRows(IntPtr.Zero, offset, limit, out var rowCountReturned, out prghRows); + + if (rowCountReturned == 0) + { + // No more rows to fetch + return false; + } + + // Marshal the row handles + var rowHandles = new IntPtr[rowCountReturned]; + Marshal.Copy(prghRows, rowHandles, 0, (int)rowCountReturned); + + for (var i = 0; i < rowCountReturned; i++) + { + var rowHandle = Marshal.ReadIntPtr(prghRows, i * IntPtr.Size); + if (!HandleRow(getRow, (nuint)rowHandle)) + { + break; + } + } + + _currentRowset.ReleaseRows(rowCountReturned, rowHandles, IntPtr.Zero, null, null); + + Marshal.FreeCoTaskMem(prghRows); + prghRows = IntPtr.Zero; + + return true; + } + catch (Exception ex) + { + Logger.LogError("Error fetching rows", ex); + return false; + } + finally + { + if (prghRows != IntPtr.Zero) + { + Marshal.FreeCoTaskMem(prghRows); + } + } + } + + private static ExecuteCommandResult ExecuteCommand(string queryStr) + { + if (string.IsNullOrEmpty(queryStr)) + { + return new ExecuteCommandResult(Rowset: null, State: QueryState.ExecuteFailed, HResult: null, ErrorMessage: "Query string was empty."); + } + + try + { + var dataSource = DataSourceManager.GetDataSource(); + if (dataSource is null) + { + Logger.LogError("GetDataSource returned null"); + return new ExecuteCommandResult(Rowset: null, State: QueryState.NullDataSource, HResult: null, ErrorMessage: "GetDataSource returned null."); + } + + var session = (IDBCreateSession)dataSource; + var guid = typeof(IDBCreateCommand).GUID; + session.CreateSession(IntPtr.Zero, ref guid, out var ppDBSession); + + if (ppDBSession is null) + { + Logger.LogError("CreateSession failed"); + return new ExecuteCommandResult(Rowset: null, State: QueryState.CreateSessionFailed, HResult: null, ErrorMessage: "CreateSession returned null session."); + } + + var createCommand = (IDBCreateCommand)ppDBSession; + guid = typeof(ICommandText).GUID; + createCommand.CreateCommand(IntPtr.Zero, ref guid, out var commandText); + + if (commandText is null) + { + Logger.LogError("Failed to get ICommandText interface"); + return new ExecuteCommandResult(Rowset: null, State: QueryState.CreateCommandFailed, HResult: null, ErrorMessage: "CreateCommand returned null command."); + } + + var riid = NativeHelpers.OleDb.DbGuidDefault; + var irowSetRiid = typeof(IRowset).GUID; + + commandText.SetCommandText(ref riid, queryStr); + commandText.Execute(null, ref irowSetRiid, null, out _, out var rowsetPointer); + + return rowsetPointer is null + ? new ExecuteCommandResult(Rowset: null, State: QueryState.NoResults, HResult: null, ErrorMessage: null) + : new ExecuteCommandResult(Rowset: rowsetPointer, State: QueryState.Completed, HResult: null, ErrorMessage: null); + } + catch (COMException ex) when (ex.HResult == QUERY_E_ALLNOISE) + { + Logger.LogDebug($"Query returned all noise, no results. ({queryStr})"); + return new ExecuteCommandResult(Rowset: null, State: QueryState.AllNoise, HResult: ex.HResult, ErrorMessage: ex.Message); + } + catch (COMException ex) + { + Logger.LogError($"Unexpected COM error for query '{queryStr}'.", ex); + return new ExecuteCommandResult(Rowset: null, State: QueryState.ExecuteFailed, HResult: ex.HResult, ErrorMessage: ex.Message); + } + catch (Exception ex) + { + Logger.LogError($"Unexpected error for query '{queryStr}'.", ex); + return new ExecuteCommandResult(Rowset: null, State: QueryState.ExecuteFailed, HResult: ex.HResult, ErrorMessage: ex.Message); + } + } + + public void Dispose() + { + CancelOutstandingQueries(); + } + + internal enum QueryState + { + NotStarted = 0, + Running, + Completed, + NoResults, + AllNoise, + NullDataSource, + CreateSessionFailed, + CreateCommandFailed, + ExecuteFailed, + Cancelled, + } + + private readonly record struct ExecuteCommandResult( + IRowset Rowset, + QueryState State, + int? HResult, + string ErrorMessage); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchResult.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchResult.cs similarity index 65% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchResult.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchResult.cs index 1840338b73..fa2cf31704 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchResult.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchResult.cs @@ -3,12 +3,10 @@ // See the LICENSE file in the project root for more information. using System; +using System.Runtime.InteropServices; using ManagedCommon; +using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; using Microsoft.CmdPal.Ext.Indexer.Indexer.Utils; -using Microsoft.CmdPal.Ext.Indexer.Native; -using Windows.Win32.System.Com; -using Windows.Win32.System.Com.StructuredStorage; -using Windows.Win32.UI.Shell.PropertiesSystem; namespace Microsoft.CmdPal.Ext.Indexer.Indexer; @@ -28,7 +26,7 @@ internal sealed class SearchResult ItemUrl = url; IsFolder = isFolder; - if (LaunchUri == null || LaunchUri.Length == 0) + if (LaunchUri is null || LaunchUri.Length == 0) { // Launch the file with the default app, so use the file path LaunchUri = filePath; @@ -39,14 +37,9 @@ internal sealed class SearchResult { try { - var key = NativeHelpers.PropertyKeys.PKEYItemNameDisplay; - propStore.GetValue(&key, out var itemNameDisplay); - - key = NativeHelpers.PropertyKeys.PKEYItemUrl; - propStore.GetValue(&key, out var itemUrl); - - key = NativeHelpers.PropertyKeys.PKEYKindText; - propStore.GetValue(&key, out var kindText); + propStore.GetValue(NativeHelpers.PropertyKeys.PKEYItemNameDisplay, out var itemNameDisplay); + propStore.GetValue(NativeHelpers.PropertyKeys.PKEYItemUrl, out var itemUrl); + propStore.GetValue(NativeHelpers.PropertyKeys.PKEYKindText, out var kindText); var filePath = GetFilePath(ref itemUrl); var isFolder = IsFoder(ref kindText); @@ -67,28 +60,34 @@ internal sealed class SearchResult } } - private static bool IsFoder(ref PROPVARIANT kindText) + private static bool IsFoder(ref PropVariant kindText) { var kindString = GetStringFromPropVariant(ref kindText); return string.Equals(kindString, "Folder", StringComparison.OrdinalIgnoreCase); } - private static string GetFilePath(ref PROPVARIANT itemUrl) + private static string GetFilePath(ref PropVariant itemUrl) { var filePath = GetStringFromPropVariant(ref itemUrl); filePath = UrlToFilePathConverter.Convert(filePath); return filePath; } - private static string GetStringFromPropVariant(ref PROPVARIANT propVariant) + private static string GetStringFromPropVariant(ref PropVariant propVariant) { - if (propVariant.Anonymous.Anonymous.vt == VARENUM.VT_LPWSTR) + if (propVariant.VarType == System.Runtime.InteropServices.VarEnum.VT_LPWSTR) { - var pwszVal = propVariant.Anonymous.Anonymous.Anonymous.pwszVal; - if (pwszVal != null) + var pwszVal = propVariant._ptr; + + if (pwszVal == IntPtr.Zero) { - return pwszVal.ToString(); + return string.Empty; } + + // convert to string + var str = Marshal.PtrToStringUni(pwszVal); + + return str; } return string.Empty; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ICommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ICommand.cs new file mode 100644 index 0000000000..e04da52d1c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ICommand.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using Microsoft.CmdPal.Ext.Indexer.Indexer.OleDB; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("0C733A63-2A1C-11CE-ADE5-00AA0044773D")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +public partial interface ICommand +{ + void Cancel(); + + void Execute([MarshalAs(UnmanagedType.Interface)] object pUnkOuter, ref Guid riid, [Optional][MarshalAs(UnmanagedType.Interface)] object pParams, [Optional]out int pcRowsAffected, out IRowset ppRowset); + + void GetDBSession(ref Guid riid, out IntPtr ppSession); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ICommandText.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ICommandText.cs new file mode 100644 index 0000000000..f01aa48810 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ICommandText.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using System.Text; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Indexer.Indexer.OleDB; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("0C733A27-2A1C-11CE-ADE5-00AA0044773D")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +public partial interface ICommandText : ICommand +{ + void GetCommandText([Optional] ref Guid pguidDialect, out IntPtr ppwszCommand); + + void SetCommandText(ref Guid rguidDialect, string pwszCommand); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IDBCreateCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IDBCreateCommand.cs new file mode 100644 index 0000000000..25bd1c1c4e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IDBCreateCommand.cs @@ -0,0 +1,16 @@ +// 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.Runtime.InteropServices.Marshalling; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("0C733A1D-2A1C-11CE-ADE5-00AA0044773D")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +public partial interface IDBCreateCommand +{ + void CreateCommand(IntPtr pUnkOuter, ref Guid riid, out ICommandText ppCommand); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IDBCreateSession.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IDBCreateSession.cs new file mode 100644 index 0000000000..6d2f0cafef --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IDBCreateSession.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; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("0C733A5D-2A1C-11CE-ADE5-00AA0044773D")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +public partial interface IDBCreateSession +{ + void CreateSession(IntPtr pUnkOuter, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out object ppDBSession); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IDBInitialize.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IDBInitialize.cs new file mode 100644 index 0000000000..40f8532427 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IDBInitialize.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("0C733A8B-2A1C-11CE-ADE5-00AA0044773D")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +public partial interface IDBInitialize +{ + void Initialize(); + + void Uninitialize(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IGetRow.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IGetRow.cs new file mode 100644 index 0000000000..581ee9e8ec --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IGetRow.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; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("0C733AAF-2A1C-11CE-ADE5-00AA0044773D")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +public partial interface IGetRow +{ + unsafe void GetRowFromHROW([MarshalAs(UnmanagedType.Interface)] object pUnkOuter, nuint hRow, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IPropertyStore ppUnk); + + unsafe string GetURLFromHROW(nuint hRow); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IPropertyStore.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IPropertyStore.cs new file mode 100644 index 0000000000..1bd733f7b9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/IPropertyStore.cs @@ -0,0 +1,26 @@ +// 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.Runtime.InteropServices.Marshalling; + +[assembly: System.Runtime.CompilerServices.DisableRuntimeMarshalling] + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +public partial interface IPropertyStore +{ + uint GetCount(); + + PropertyKey GetAt(uint iProp); + + void GetValue(in PropertyKey pkey, out PropVariant pv); + + void SetValue(in PropertyKey pkey, in PropVariant pv); + + void Commit(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchCatalogManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchCatalogManager.cs new file mode 100644 index 0000000000..f89c882669 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchCatalogManager.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF50")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Please do not change the function name")] +public partial interface ISearchCatalogManager +{ + string get_Name(); + + void GetParameter(string pszName, out IntPtr pValue); + + void SetParameter(string pszName, ref IntPtr pValue); + + void GetCatalogStatus(out uint pdwStatus, out uint pdwPausedReason); + + void Reset(); + + void Reindex(); + + void ReindexMatchingURLs(string pszPattern); + + void ReindexSearchRoot(string pszRoot); + + uint get_ConnectTimeout(); + + void put_ConnectTimeout(uint dwTimeout); + + uint get_DataTimeout(); + + void put_DataTimeout(uint dwTimeout); + + uint NumberOfItems(); + + uint NumberOfItemsToIndex(); + + [return: MarshalAs(UnmanagedType.LPWStr)] + string URLBeingIndexed(); + + void GetURLIndexingState(string pszURL, out uint pdwState); + + IntPtr GetPersistentItemsChangedSink(); + + void RegisterViewForNotification(string pszView, IntPtr pViewNotify, out uint pdwCookie); + + IntPtr GetItemsChangedSink(); + + void UnregisterViewForNotification(uint dwCookie); + + void SetExtensionClusion(string pszExtension, [MarshalAs(UnmanagedType.Bool)] bool fExclude); + + void EnumerateExcludedExtensions(); + + [return: MarshalAs(UnmanagedType.Interface)] + [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] + ISearchQueryHelper GetQueryHelper(); + + [return: MarshalAs(UnmanagedType.Bool)] + bool get_DiacriticSensitivity(); + + void put_DiacriticSensitivity([MarshalAs(UnmanagedType.Bool)] bool fDiacriticSensitive); + + IntPtr GetCrawlScopeManager(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchManager.cs new file mode 100644 index 0000000000..c8e7a37544 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchManager.cs @@ -0,0 +1,48 @@ +// 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.Runtime.InteropServices.Marshalling; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF69")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Please do not change the function name")] +public partial interface ISearchManager +{ + string GetIndexerVersionStr(); + + void GetIndexerVersion(out uint pdwMajor, out uint pdwMinor); + + void GetParameter(string pszName, [MarshalAs(UnmanagedType.Interface)] out object pValue); + + void SetParameter(string pszName, [MarshalAs(UnmanagedType.Interface)] ref object pValue); + + [return: MarshalAs(UnmanagedType.Bool)] + bool get_UseProxy(); + + string get_BypassList(); + + void SetProxy( + string pszProxyName, + [MarshalAs(UnmanagedType.Bool)] bool fLocalBypass, + string pszBypassList, + uint dwPortNumber); + + [return: MarshalAs(UnmanagedType.Interface)] + ISearchCatalogManager GetCatalog(string pszCatalog); + + string get_UserAgent(); + + void put_UserAgent(string pszUserAgent); + + string get_ProxyName(); + + [return: MarshalAs(UnmanagedType.Bool)] + bool get_LocalBypass(); + + uint get_PortNumber(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchQueryHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchQueryHelper.cs new file mode 100644 index 0000000000..d1a49e83bd --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchQueryHelper.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF63")] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "I don't want to change the name")] +public partial interface ISearchQueryHelper +{ + string GetConnectionString(); + + void SetQueryContentLocale(int lcid); + + uint GetQueryContentLocale(); + + void SetQueryKeywordLocale(int lcid); + + uint GetQueryKeywordLocale(); + + void SetQueryTermExpansion(SEARCH_TERM_EXPANSION expandTerms); + + void GetQueryTermExpansion(out SEARCH_TERM_EXPANSION pExpandTerms); + + void SetQuerySyntax(SEARCH_QUERY_SYNTAX querySyntax); + + [return: MarshalAs(UnmanagedType.Interface)] + object GetQuerySyntax(); + + void SetQueryContentProperties(string pszContentProperties); + + string GetQueryContentProperties(); + + void SetQuerySelectColumns(string pszColumns); + + string GetQuerySelectColumns(); + + void SetQueryWhereRestrictions(string pszRestrictions); + + string GetQueryWhereRestrictions(); + + void SetQuerySorting(string pszSorting); + + string GetQuerySorting(); + + string GenerateSQLFromUserQuery(string pszQuery); + + void WriteProperties( + int itemID, + uint dwNumberOfColumns, + [MarshalAs(UnmanagedType.Interface)] ref object pColumns, + [MarshalAs(UnmanagedType.Interface)] ref object pValues, + [MarshalAs(UnmanagedType.Interface)] ref object pftGatherModifiedTime); + + void SetQueryMaxResults(int lMaxResults); + + int GetQueryMaxResults(); +} + +public enum SEARCH_TERM_EXPANSION +{ + SEARCH_TERM_NO_EXPANSION, + SEARCH_TERM_PREFIX_ALL, + SEARCH_TERM_STEM_ALL, +} + +public enum SEARCH_QUERY_SYNTAX +{ + SEARCH_NO_QUERY_SYNTAX, + SEARCH_ADVANCED_QUERY_SYNTAX, + SEARCH_NATURAL_QUERY_SYNTAX, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/PropVariant.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/PropVariant.cs new file mode 100644 index 0000000000..0a48a42ee5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/PropVariant.cs @@ -0,0 +1,70 @@ +// 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.Runtime.InteropServices; +using VARTYPE = System.Runtime.InteropServices.VarEnum; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +#pragma warning disable SA1307 // Accessible fields should begin with upper-case letter +[StructLayout(LayoutKind.Explicit, Pack = 8)] +public struct PropVariant +{ + /// <summary>Value type tag.</summary> + [FieldOffset(0)] + public ushort vt; + + [FieldOffset(2)] + public ushort wReserved1; + + /// <summary>Reserved for future use.</summary> + [FieldOffset(4)] + public ushort wReserved2; + + /// <summary>Reserved for future use.</summary> + [FieldOffset(6)] + public ushort wReserved3; + + /// <summary>The decimal value when VT_DECIMAL.</summary> + [FieldOffset(0)] + internal decimal _decimal; + + /// <summary>The raw data pointer.</summary> + [FieldOffset(8)] + internal IntPtr _ptr; + + /// <summary>The FILETIME when VT_FILETIME.</summary> + [FieldOffset(8)] + internal System.Runtime.InteropServices.ComTypes.FILETIME _ft; + + [FieldOffset(8)] + internal BLOB _blob; + + /// <summary>The value when a numeric value less than 8 bytes.</summary> + [FieldOffset(8)] + internal ulong _ulong; + + public PropVariant(string value) + { + ArgumentNullException.ThrowIfNull(value); + vt = (ushort)VarEnum.VT_LPWSTR; + _ptr = Marshal.StringToCoTaskMemUni(value); + } + + public VarEnum VarType { get => (VarEnum)vt; set => vt = (ushort)(VARTYPE)value; } +} + +[StructLayout(LayoutKind.Sequential, Pack = 0)] +public struct BLOB +{ + /// <summary>The count of bytes</summary> + public uint cbSize; + + /// <summary>A pointer to the allocated array of bytes.</summary> + public IntPtr pBlobData; +} + +#pragma warning restore SA1307 // Accessible fields should begin with upper-case letter diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/PropertyKey.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/PropertyKey.cs new file mode 100644 index 0000000000..e37177d759 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/PropertyKey.cs @@ -0,0 +1,27 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +[StructLayout(LayoutKind.Sequential)] +public struct PropertyKey +{ + public Guid FmtID; + + public uint PID; + + public PropertyKey(Guid fmtid, uint pid) + { + this.FmtID = fmtid; + this.PID = pid; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/NativeHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/NativeHelpers.cs new file mode 100644 index 0000000000..ec7604a7b3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/NativeHelpers.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.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.Utils; + +public sealed partial class NativeHelpers +{ + public const uint SEEMASKINVOKEIDLIST = 12; + + public struct PropertyKeys + { + public static readonly PropertyKey PKEYItemNameDisplay = new() { FmtID = new Guid("B725F130-47EF-101A-A5F1-02608C9EEBAC"), PID = 10 }; + public static readonly PropertyKey PKEYItemUrl = new() { FmtID = new Guid("49691C90-7E17-101A-A91C-08002B2ECDA9"), PID = 9 }; + public static readonly PropertyKey PKEYKindText = new() { FmtID = new Guid("F04BEF95-C585-4197-A2B7-DF46FDC9EE6D"), PID = 100 }; + } + + public static class OleDb + { + public static readonly Guid DbGuidDefault = new("C8B521FB-5CF3-11CE-ADE5-00AA0044773D"); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs new file mode 100644 index 0000000000..52b130ee68 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/QueryStringBuilder.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.CompilerServices; +using ManagedCommon; +using ManagedCsWin32; +using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; + +namespace Microsoft.CmdPal.Ext.Indexer.Indexer.Utils; + +internal static class QueryStringBuilder +{ + private const string Properties = "System.ItemUrl, System.ItemNameDisplay, path, System.Search.EntryID, System.Kind, System.KindText"; + private const string SystemIndex = "SystemIndex"; + private const string ScopeFileConditions = "SCOPE='file:'"; + private const string OrderConditions = "System.DateModified DESC"; + + private static ISearchQueryHelper queryHelper; + + public static string GenerateQuery(string searchText) + { + if (queryHelper is null) + { + ISearchManager searchManager; + + try + { + searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer); + } + catch (Exception ex) + { + Logger.LogError($"Failed to create searchManager. ex: {ex.Message}"); + throw; + } + + var catalogManager = searchManager.GetCatalog(SystemIndex); + if (catalogManager is null) + { + throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}"); + } + + queryHelper = catalogManager.GetQueryHelper(); + if (queryHelper is null) + { + throw new ArgumentException("Failed to get query helper from catalog manager"); + } + + queryHelper.SetQuerySelectColumns(Properties); + queryHelper.SetQueryContentProperties("System.FileName"); + queryHelper.SetQuerySorting(OrderConditions); + } + + queryHelper.SetQueryWhereRestrictions($"AND {ScopeFileConditions}"); + return queryHelper.GenerateSQLFromUserQuery(searchText); + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/UrlToFilePathConverter.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/UrlToFilePathConverter.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/UrlToFilePathConverter.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Indexer/Utils/UrlToFilePathConverter.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs similarity index 67% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs index c31d7c5176..ca1f215d4c 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/IndexerCommandsProvider.cs @@ -2,9 +2,12 @@ // 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.CmdPal.Ext.Indexer.Data; using Microsoft.CmdPal.Ext.Indexer.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation.Metadata; namespace Microsoft.CmdPal.Ext.Indexer; @@ -16,7 +19,12 @@ public partial class IndexerCommandsProvider : CommandProvider { Id = "Files"; DisplayName = Resources.IndexerCommandsProvider_DisplayName; - Icon = Icons.FileExplorer; + Icon = Icons.FileExplorerIcon; + + if (IndexerListItem.IsActionsFeatureEnabled && ApiInformation.IsApiContractPresent("Windows.AI.Actions.ActionsContract", 4)) + { + _ = ActionRuntimeManager.InstanceAsync; + } } public override ICommandItem[] TopLevelCommands() @@ -25,7 +33,6 @@ public partial class IndexerCommandsProvider : CommandProvider new CommandItem(new IndexerPage()) { Title = Resources.Indexer_Title, - Subtitle = Resources.Indexer_Subtitle, } ]; } @@ -34,4 +41,9 @@ public partial class IndexerCommandsProvider : CommandProvider [ _fallbackFileItem ]; + + public void SuppressFallbackWhen(Func<string, bool> callback) + { + _fallbackFileItem.SuppressFallbackWhen(callback); + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/KeyChords.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/KeyChords.cs new file mode 100644 index 0000000000..aa44696a7b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/KeyChords.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; + +namespace Microsoft.CmdPal.Ext.Indexer; + +internal static class KeyChords +{ + internal static KeyChord OpenFileLocation { get; } = WellKnownKeyChords.OpenFileLocation; + + internal static KeyChord CopyFilePath { get; } = WellKnownKeyChords.CopyFilePath; + + internal static KeyChord OpenInConsole { get; } = WellKnownKeyChords.OpenInConsole; + + internal static KeyChord Peek { get; } = KeyChordHelpers.FromModifiers(ctrl: true, vkey: (int)VirtualKey.Space); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj similarity index 50% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj index 70e6a10c5a..193c4ea203 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Microsoft.CmdPal.Ext.Indexer.csproj @@ -1,22 +1,24 @@ <Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.Indexer</RootNamespace> - <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> - + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.Windows.CsWin32"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> - - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <PackageReference Include="Microsoft.Windows.CsWin32"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" /> + <ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> + <!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props --> </ItemGroup> <ItemGroup> @@ -34,9 +36,15 @@ <Content Update="Assets\FileExplorer.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> + <Content Update="Assets\Actions.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> <Content Update="Assets\FileExplorer.svg"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> + <Content Update="Assets\Peek.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> </ItemGroup> <ItemGroup> @@ -46,4 +54,9 @@ </EmbeddedResource> </ItemGroup> + <ItemGroup> + <AdditionalFiles Include="NativeMethods.txt" /> + <AdditionalFiles Include="NativeMethods.json" /> + </ItemGroup> + </Project> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/NativeMethods.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/NativeMethods.json new file mode 100644 index 0000000000..02fff599f2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/NativeMethods.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false, + "comInterop": { + "preserveSigMethods": [ "*" ] + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/NativeMethods.txt new file mode 100644 index 0000000000..029b6fe6c2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/NativeMethods.txt @@ -0,0 +1,2 @@ +DBID +SHOW_WINDOW_CMD \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ActionsListContextItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ActionsListContextItem.cs new file mode 100644 index 0000000000..9f37f94c1b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ActionsListContextItem.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; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Indexer.Commands; +using Microsoft.CmdPal.Ext.Indexer.Data; +using Microsoft.CmdPal.Ext.Indexer.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.AI.Actions; +using Windows.System; + +namespace Microsoft.CmdPal.Ext.Indexer.Pages; + +internal sealed partial class ActionsListContextItem : CommandContextItem, IDisposable +{ + private readonly string fullPath; + private readonly List<CommandContextItem> actions = []; + private static readonly Lock UpdateMoreCommandsLock = new(); + private static ActionRuntime actionRuntime; + + public ActionsListContextItem(string fullPath) + : base(new NoOpCommand()) + { + Title = Resources.Indexer_Command_Actions; + Icon = Icons.ActionsIcon; + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A); + this.fullPath = fullPath; + UpdateMoreCommands(); + } + + public bool AnyActions() => actions.Count != 0; + + private void ActionCatalog_Changed(global::Windows.AI.Actions.Hosting.ActionCatalog sender, object args) + { + UpdateMoreCommands(); + } + + private void UpdateMoreCommands() + { + lock (UpdateMoreCommandsLock) + { + if (actionRuntime is null) + { + actionRuntime = ActionRuntimeManager.InstanceAsync.GetAwaiter().GetResult(); + } + + if (actionRuntime is null) + { + return; + } + + actionRuntime.ActionCatalog.Changed -= ActionCatalog_Changed; + actionRuntime.ActionCatalog.Changed += ActionCatalog_Changed; + } + + try + { + var extension = System.IO.Path.GetExtension(fullPath).ToLower(CultureInfo.InvariantCulture); + ActionEntity entity = null; + if (extension is not null) + { + if (extension == ".jpg" || extension == ".jpeg" || extension == ".png") + { + entity = actionRuntime.EntityFactory.CreatePhotoEntity(fullPath); + } + else if (extension == ".docx" || extension == ".doc" || extension == ".pdf" || extension == ".txt") + { + entity = actionRuntime.EntityFactory.CreateDocumentEntity(fullPath); + } + } + + if (entity is null) + { + entity = actionRuntime.EntityFactory.CreateFileEntity(fullPath); + } + + lock (actions) + { + actions.Clear(); + foreach (var actionInstance in actionRuntime.ActionCatalog.GetActionsForInputs([entity])) + { + actions.Add(new CommandContextItem(new ExecuteActionCommand(actionInstance))); + } + + MoreCommands = [.. actions]; + } + } + catch (Exception ex) + { + Logger.LogError($"Error updating commands: {ex.Message}"); + } + } + + public void Dispose() + { + lock (UpdateMoreCommandsLock) + { + if (actionRuntime is not null) + { + actionRuntime.ActionCatalog.Changed -= ActionCatalog_Changed; + } + } + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryExplorePage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryExplorePage.cs similarity index 93% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryExplorePage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryExplorePage.cs index 531398685b..3bdbe1b0ce 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryExplorePage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryExplorePage.cs @@ -28,21 +28,21 @@ public sealed partial class DirectoryExplorePage : DynamicListPage public DirectoryExplorePage(string path) { _path = path; - Icon = Icons.FileExplorer; + Icon = Icons.FileExplorerIcon; Name = Resources.Indexer_Command_Browse; Title = path; } public override void UpdateSearchText(string oldSearch, string newSearch) { - if (_directoryContents == null) + if (_directoryContents is null) { return; } if (string.IsNullOrEmpty(newSearch)) { - if (_filteredContents != null) + if (_filteredContents is not null) { _filteredContents = null; RaiseItemsChanged(-1); @@ -58,7 +58,7 @@ public sealed partial class DirectoryExplorePage : DynamicListPage newSearch, (s, i) => ListHelpers.ScoreListItem(s, i)); - if (_filteredContents != null) + if (_filteredContents is not null) { lock (_filteredContents) { @@ -75,12 +75,12 @@ public sealed partial class DirectoryExplorePage : DynamicListPage public override IListItem[] GetItems() { - if (_filteredContents != null) + if (_filteredContents is not null) { return _filteredContents.ToArray(); } - if (_directoryContents != null) + if (_directoryContents is not null) { return _directoryContents.ToArray(); } @@ -120,7 +120,7 @@ public sealed partial class DirectoryExplorePage : DynamicListPage try { var stream = ThumbnailHelper.GetThumbnail(item.FilePath).Result; - if (stream != null) + if (stream is not null) { var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); icon = new IconInfo(data, data); diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryPage.cs similarity index 93% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryPage.cs index e71b8a089a..91e78d87fd 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/DirectoryPage.cs @@ -24,14 +24,14 @@ public sealed partial class DirectoryPage : ListPage public DirectoryPage(string path) { _path = path; - Icon = Icons.FileExplorer; + Icon = Icons.FileExplorerIcon; Name = Resources.Indexer_Command_Browse; Title = path; } public override IListItem[] GetItems() { - if (_directoryContents != null) + if (_directoryContents is not null) { return _directoryContents.ToArray(); } @@ -52,7 +52,7 @@ public sealed partial class DirectoryPage : ListPage EmptyContent = new CommandItem( title: Resources.Indexer_File_Is_File_Not_Folder, subtitle: $"{_path}") { - Icon = Icons.Document, + Icon = Icons.DocumentIcon, }; return []; } @@ -66,7 +66,7 @@ public sealed partial class DirectoryPage : ListPage EmptyContent = new CommandItem( title: Resources.Indexer_Folder_Is_Empty, subtitle: $"{_path}") { - Icon = Icons.FolderOpen, + Icon = Icons.FolderOpenIcon, Command = listItemForUs.Command, MoreCommands = listItemForUs.MoreCommands, }; @@ -86,7 +86,7 @@ public sealed partial class DirectoryPage : ListPage try { var stream = ThumbnailHelper.GetThumbnail(item.FilePath).Result; - if (stream != null) + if (stream is not null) { var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); icon = new IconInfo(data, data); diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs similarity index 85% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs index 501e2b96f3..9bd575e87a 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/ExploreListItem.cs @@ -3,8 +3,9 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; -using Microsoft.CmdPal.Ext.Indexer.Commands; +using Microsoft.CmdPal.Core.Common.Commands; using Microsoft.CmdPal.Ext.Indexer.Data; +using Microsoft.CmdPal.Ext.Indexer.Helpers; using Microsoft.CmdPal.Ext.Indexer.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; @@ -28,6 +29,9 @@ internal sealed partial class ExploreListItem : ListItem Title = indexerItem.FileName; Subtitle = indexerItem.FullPath; + + DataPackage = DataPackageHelper.CreateDataPackageForPath(this, FilePath); + List<CommandContextItem> context = []; if (indexerItem.IsDirectory()) { @@ -41,16 +45,16 @@ internal sealed partial class ExploreListItem : ListItem } else { - Command = new OpenFileCommand(indexerItem); + Command = new OpenFileCommand(indexerItem.FullPath); } MoreCommands = [ ..context, - new CommandContextItem(new OpenWithCommand(indexerItem)), + new CommandContextItem(new OpenWithCommand(indexerItem.FullPath)), new CommandContextItem(new ShowFileInFolderCommand(indexerItem.FullPath) { Name = Resources.Indexer_Command_ShowInFolder }), - new CommandContextItem(new CopyPathCommand(indexerItem)), - new CommandContextItem(new OpenInConsoleCommand(indexerItem)), - new CommandContextItem(new OpenPropertiesCommand(indexerItem)), + new CommandContextItem(new CopyPathCommand(indexerItem.FullPath)), + new CommandContextItem(new OpenInConsoleCommand(indexerItem.FullPath)), + new CommandContextItem(new OpenPropertiesCommand(indexerItem.FullPath)), ]; } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs new file mode 100644 index 0000000000..f355db27bc --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Pages/IndexerPage.cs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.Indexer.Indexer; +using Microsoft.CmdPal.Ext.Indexer.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Indexer; + +internal sealed partial class IndexerPage : DynamicListPage, IDisposable +{ + // Cookie to identify our queries; since we replace the SearchEngine on each search, + // this can be a constant. + private const uint HardQueryCookie = 10; + + private readonly List<IListItem> _indexerListItems = []; + private readonly Lock _searchLock = new(); + + private SearchEngine? _searchEngine; + + private CancellationTokenSource? _searchCts; + private string _initialQuery = string.Empty; + private bool _isEmptyQuery = true; + + private CommandItem? _noSearchEmptyContent; + private CommandItem? _nothingFoundEmptyContent; + + private bool _deferredLoad; + + public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent! : _nothingFoundEmptyContent!; + + public IndexerPage() + { + Id = "com.microsoft.indexer.fileSearch"; + Icon = Icons.FileExplorerIcon; + Name = Resources.Indexer_Title; + PlaceholderText = Resources.Indexer_PlaceholderText; + + _searchEngine = new(); + + var filters = new SearchFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; + + CreateEmptyContent(); + } + + public IndexerPage(string query) + { + Icon = Icons.FileExplorerIcon; + Name = Resources.Indexer_Title; + + _searchEngine = new(); + + _initialQuery = query; + SearchText = query; + + var filters = new SearchFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; + + CreateEmptyContent(); + IsLoading = true; + _deferredLoad = true; + } + + private void CreateEmptyContent() + { + _noSearchEmptyContent = new CommandItem(new NoOpCommand()) + { + Icon = Icon, + Subtitle = Resources.Indexer_NoSearchQueryMessageTip, + }; + + _nothingFoundEmptyContent = new CommandItem(new AnonymousCommand(StartManualSearch) { Name = Resources.Indexer_Command_SearchAllFiles! }) + { + Icon = Icon, + Title = Resources.Indexer_NoResultsMessage, + Subtitle = Resources.Indexer_NoResultsMessageTip, + MoreCommands = [ + new CommandContextItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! }) + { + Title = Resources.Indexer_Command_SearchAllFiles!, + }, + ], + }; + } + + private void StartManualSearch() + { + // {20D04FE0-3AEA-1069-A2D8-08002B30309D} is CLSID for "This PC" + const string template = "search-ms:query={0}&crumb=location:::{{20D04FE0-3AEA-1069-A2D8-08002B30309D}}"; + var fullSearchText = FullSearchString(SearchText); + var encodedSearchText = UrlEncoder.Default.Encode(fullSearchText); + var command = string.Format(CultureInfo.CurrentCulture, template, encodedSearchText); + ShellHelpers.OpenInShell(command); + } + + private void Filters_PropChanged(object sender, IPropChangedEventArgs args) + { + PerformSearch(SearchText); + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + if (oldSearch != newSearch && newSearch != _initialQuery) + { + PerformSearch(newSearch); + } + } + + public override IListItem[] GetItems() + { + if (_deferredLoad) + { + PerformSearch(_initialQuery); + _deferredLoad = false; + } + + return [.. _indexerListItems]; + } + + private string FullSearchString(string query) + { + switch (Filters?.CurrentFilterId) + { + case "folders": + return $"System.Kind:folders {query}"; + case "files": + return $"System.Kind:NOT folders {query}"; + case "all": + default: + return query; + } + } + + public override void LoadMore() + { + var ct = Volatile.Read(ref _searchCts)?.Token; + + IsLoading = true; + + var hasMore = false; + SearchEngine? searchEngine; + int offset; + + lock (_searchLock) + { + searchEngine = _searchEngine; + offset = _indexerListItems.Count; + } + + var results = searchEngine?.FetchItems(offset, 20, queryCookie: HardQueryCookie, out hasMore) ?? []; + + if (ct?.IsCancellationRequested == true) + { + IsLoading = false; + return; + } + + lock (_searchLock) + { + if (ct?.IsCancellationRequested == true) + { + IsLoading = false; + return; + } + + _indexerListItems.AddRange(results); + HasMoreItems = hasMore; + IsLoading = false; + RaiseItemsChanged(_indexerListItems.Count); + } + } + + private void Query(string query) + { + lock (_searchLock) + { + _indexerListItems.Clear(); + _searchEngine?.Query(query, queryCookie: HardQueryCookie); + } + } + + private void ReplaceSearchEngine(SearchEngine newSearchEngine) + { + SearchEngine? oldEngine; + + lock (_searchLock) + { + oldEngine = _searchEngine; + _searchEngine = newSearchEngine; + } + + oldEngine?.Dispose(); + } + + private void PerformSearch(string newSearch) + { + var actualSearch = FullSearchString(newSearch); + + var newCts = new CancellationTokenSource(); + var oldCts = Interlocked.Exchange(ref _searchCts, newCts); + oldCts?.Cancel(); + oldCts?.Dispose(); + + var ct = newCts.Token; + + _ = Task.Run( + () => + { + ct.ThrowIfCancellationRequested(); + + lock (_searchLock) + { + // If the user hasn't provided any base query text, results should be empty + // regardless of the currently selected filter. + _isEmptyQuery = string.IsNullOrWhiteSpace(newSearch); + + if (_isEmptyQuery) + { + _indexerListItems.Clear(); + HasMoreItems = false; + IsLoading = false; + RaiseItemsChanged(0); + OnPropertyChanged(nameof(EmptyContent)); + _initialQuery = string.Empty; + return; + } + + // Track the most recent query we initiated, so UpdateSearchText doesn't + // spuriously suppress a search when SearchText gets set programmatically. + _initialQuery = newSearch; + } + + ct.ThrowIfCancellationRequested(); + ReplaceSearchEngine(new SearchEngine()); + + ct.ThrowIfCancellationRequested(); + Query(actualSearch); + + ct.ThrowIfCancellationRequested(); + LoadMore(); + + ct.ThrowIfCancellationRequested(); + + lock (_searchLock) + { + OnPropertyChanged(nameof(EmptyContent)); + } + }, + ct); + } + + public void Dispose() + { + var cts = Interlocked.Exchange(ref _searchCts, null); + cts?.Cancel(); + cts?.Dispose(); + + SearchEngine? searchEngine; + + lock (_searchLock) + { + searchEngine = _searchEngine; + _searchEngine = null; + _indexerListItems.Clear(); + } + + searchEngine?.Dispose(); + + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..1dfde65a28 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs @@ -0,0 +1,352 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// 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. +// </auto-generated> +//------------------------------------------------------------------------------ + +namespace Microsoft.CmdPal.Ext.Indexer.Properties { + using System; + + + /// <summary> + /// A strongly-typed resource class, for looking up localized strings, etc. + /// </summary> + // 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() { + } + + /// <summary> + /// Returns the cached ResourceManager instance used by this class. + /// </summary> + [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.Ext.Indexer.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// <summary> + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// <summary> + /// Looks up a localized string similar to Actions.... + /// </summary> + internal static string Indexer_Command_Actions { + get { + return ResourceManager.GetString("Indexer_Command_Actions", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Browse. + /// </summary> + internal static string Indexer_Command_Browse { + get { + return ResourceManager.GetString("Indexer_Command_Browse", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy path. + /// </summary> + internal static string Indexer_Command_CopyPath { + get { + return ResourceManager.GetString("Indexer_Command_CopyPath", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open. + /// </summary> + internal static string Indexer_Command_OpenFile { + get { + return ResourceManager.GetString("Indexer_Command_OpenFile", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Windows Search settings. + /// </summary> + internal static string Indexer_Command_OpenIndexerSettings { + get { + return ResourceManager.GetString("Indexer_Command_OpenIndexerSettings", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open path in console. + /// </summary> + internal static string Indexer_Command_OpenPathInConsole { + get { + return ResourceManager.GetString("Indexer_Command_OpenPathInConsole", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Properties. + /// </summary> + internal static string Indexer_Command_OpenProperties { + get { + return ResourceManager.GetString("Indexer_Command_OpenProperties", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open with. + /// </summary> + internal static string Indexer_Command_OpenWith { + get { + return ResourceManager.GetString("Indexer_Command_OpenWith", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Peek preview. + /// </summary> + internal static string Indexer_Command_Peek { + get { + return ResourceManager.GetString("Indexer_Command_Peek", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Failed to launch Peek. + /// </summary> + internal static string Indexer_Command_Peek_Failed { + get { + return ResourceManager.GetString("Indexer_Command_Peek_Failed", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PowerToys Peek is not available. + /// </summary> + internal static string Indexer_Command_Peek_NotAvailable { + get { + return ResourceManager.GetString("Indexer_Command_Peek_NotAvailable", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Search all files. + /// </summary> + internal static string Indexer_Command_SearchAllFiles { + get { + return ResourceManager.GetString("Indexer_Command_SearchAllFiles", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Show in folder. + /// </summary> + internal static string Indexer_Command_ShowInFolder { + get { + return ResourceManager.GetString("Indexer_Command_ShowInFolder", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The query matches multiple items. + /// </summary> + internal static string Indexer_Fallback_MultipleResults_Subtitle { + get { + return ResourceManager.GetString("Indexer_Fallback_MultipleResults_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Search for "{0}" in files. + /// </summary> + internal static string Indexer_fallback_searchPage_title { + get { + return ResourceManager.GetString("Indexer_fallback_searchPage_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This file doesn't exist. + /// </summary> + internal static string Indexer_File_Does_Not_Exist { + get { + return ResourceManager.GetString("Indexer_File_Does_Not_Exist", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This is a file, not a folder. + /// </summary> + internal static string Indexer_File_Is_File_Not_Folder { + get { + return ResourceManager.GetString("Indexer_File_Is_File_Not_Folder", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Files and folders. + /// </summary> + internal static string Indexer_Filter_All { + get { + return ResourceManager.GetString("Indexer_Filter_All", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Files. + /// </summary> + internal static string Indexer_Filter_Files_Only { + get { + return ResourceManager.GetString("Indexer_Filter_Files_Only", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Folders. + /// </summary> + internal static string Indexer_Filter_Folders_Only { + get { + return ResourceManager.GetString("Indexer_Filter_Folders_Only", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Find file from path. + /// </summary> + internal static string Indexer_Find_Path_fallback_display_title { + get { + return ResourceManager.GetString("Indexer_Find_Path_fallback_display_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This folder is empty. + /// </summary> + internal static string Indexer_Folder_Is_Empty { + get { + return ResourceManager.GetString("Indexer_Folder_Is_Empty", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No items found. + /// </summary> + internal static string Indexer_NoResultsMessage { + get { + return ResourceManager.GetString("Indexer_NoResultsMessage", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Nothing was found in the indexed locations. + ///You can try searching all files on this PC or adjust your indexing settings.. + /// </summary> + internal static string Indexer_NoResultsMessageTip { + get { + return ResourceManager.GetString("Indexer_NoResultsMessageTip", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Tip: Refine your search using filters, just like in File Explorer (e.g., type:directory).. + /// </summary> + internal static string Indexer_NoSearchQueryMessageTip { + get { + return ResourceManager.GetString("Indexer_NoSearchQueryMessageTip", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Search for files and folders.... + /// </summary> + internal static string Indexer_PlaceholderText { + get { + return ResourceManager.GetString("Indexer_PlaceholderText", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Always on. + /// </summary> + internal static string Indexer_Settings_FallbackCommand_AlwaysOn { + get { + return ResourceManager.GetString("Indexer_Settings_FallbackCommand_AlwaysOn", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Only when file path exist. + /// </summary> + internal static string Indexer_Settings_FallbackCommand_FilePathExist { + get { + return ResourceManager.GetString("Indexer_Settings_FallbackCommand_FilePathExist", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Shows file search results on the top-level search results. + /// </summary> + internal static string Indexer_Settings_FallbackCommand_Mode { + get { + return ResourceManager.GetString("Indexer_Settings_FallbackCommand_Mode", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Always off. + /// </summary> + internal static string Indexer_Settings_FallbackCommand_Off { + get { + return ResourceManager.GetString("Indexer_Settings_FallbackCommand_Off", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Search files. + /// </summary> + internal static string Indexer_Title { + get { + return ResourceManager.GetString("Indexer_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to File search. + /// </summary> + internal static string IndexerCommandsProvider_DisplayName { + get { + return ResourceManager.GetString("IndexerCommandsProvider_DisplayName", resourceCulture); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx new file mode 100644 index 0000000000..6c7e6483c9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx @@ -0,0 +1,217 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="IndexerCommandsProvider_DisplayName" xml:space="preserve"> + <value>File search</value> + </data> + <data name="Indexer_Command_Browse" xml:space="preserve"> + <value>Browse</value> + </data> + <data name="Indexer_Command_CopyPath" xml:space="preserve"> + <value>Copy path</value> + </data> + <data name="Indexer_Command_OpenFile" xml:space="preserve"> + <value>Open</value> + </data> + <data name="Indexer_Command_OpenPathInConsole" xml:space="preserve"> + <value>Open path in console</value> + </data> + <data name="Indexer_Command_OpenProperties" xml:space="preserve"> + <value>Properties</value> + </data> + <data name="Indexer_Command_OpenWith" xml:space="preserve"> + <value>Open with</value> + </data> + <data name="Indexer_Command_Actions" xml:space="preserve"> + <value>Actions...</value> + </data> + <data name="Indexer_Command_ShowInFolder" xml:space="preserve"> + <value>Show in folder</value> + </data> + <data name="Indexer_File_Does_Not_Exist" xml:space="preserve"> + <value>This file doesn't exist</value> + </data> + <data name="Indexer_File_Is_File_Not_Folder" xml:space="preserve"> + <value>This is a file, not a folder</value> + </data> + <data name="Indexer_Find_Path_fallback_display_title" xml:space="preserve"> + <value>Find file from path</value> + </data> + <data name="Indexer_Folder_Is_Empty" xml:space="preserve"> + <value>This folder is empty</value> + </data> + <data name="Indexer_PlaceholderText" xml:space="preserve"> + <value>Search for files and folders...</value> + </data> + <data name="Indexer_Settings_FallbackCommand_AlwaysOn" xml:space="preserve"> + <value>Always on</value> + </data> + <data name="Indexer_Settings_FallbackCommand_Mode" xml:space="preserve"> + <value>Shows file search results on the top-level search results</value> + </data> + <data name="Indexer_Settings_FallbackCommand_Off" xml:space="preserve"> + <value>Always off</value> + </data> + <data name="Indexer_Settings_FallbackCommand_FilePathExist" xml:space="preserve"> + <value>Only when file path exist</value> + </data> + <data name="Indexer_Title" xml:space="preserve"> + <value>Search files</value> + </data> + <data name="Indexer_fallback_searchPage_title" xml:space="preserve"> + <value>Search for "{0}" in files</value> + </data> + <data name="Indexer_NoResultsMessage" xml:space="preserve"> + <value>No items found</value> + </data> + <data name="Indexer_NoResultsMessageTip" xml:space="preserve"> + <value>Nothing was found in the indexed locations. +You can try searching all files on this PC or adjust your indexing settings.</value> + </data> + <data name="Indexer_NoSearchQueryMessageTip" xml:space="preserve"> + <value>Tip: Refine your search using filters, just like in File Explorer (e.g., type:directory).</value> + </data> + <data name="Indexer_Command_OpenIndexerSettings" xml:space="preserve"> + <value>Open Windows Search settings</value> + </data> + <data name="Indexer_Command_SearchAllFiles" xml:space="preserve"> + <value>Search all files</value> + </data> + <data name="Indexer_Filter_All" xml:space="preserve"> + <value>Files and folders</value> + </data> + <data name="Indexer_Filter_Folders_Only" xml:space="preserve"> + <value>Folders</value> + </data> + <data name="Indexer_Filter_Files_Only" xml:space="preserve"> + <value>Files</value> + </data> + <data name="Indexer_Command_Peek" xml:space="preserve"> + <value>Peek preview</value> + </data> + <data name="Indexer_Command_Peek_NotAvailable" xml:space="preserve"> + <value>PowerToys Peek is not available</value> + </data> + <data name="Indexer_Command_Peek_Failed" xml:space="preserve"> + <value>Failed to launch Peek</value> + </data> + <data name="Indexer_Fallback_MultipleResults_Subtitle" xml:space="preserve"> + <value>The query matches multiple items</value> + </data> +</root> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/SearchEngine.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/SearchEngine.cs new file mode 100644 index 0000000000..15fff442c1 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Indexer/SearchEngine.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using ManagedCommon; +using Microsoft.CmdPal.Ext.Indexer.Data; +using Microsoft.CmdPal.Ext.Indexer.Indexer; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.Indexer; + +public sealed partial class SearchEngine : IDisposable +{ + private SearchQuery? _searchQuery = new(); + + public void Query(string query, uint queryCookie) + { + var searchQuery = _searchQuery; + if (searchQuery is null) + { + return; + } + + searchQuery.SearchResults.Clear(); + searchQuery.CancelOutstandingQueries(); + + if (string.IsNullOrWhiteSpace(query)) + { + return; + } + + Stopwatch stopwatch = new(); + stopwatch.Start(); + + searchQuery.Execute(query, queryCookie); + + stopwatch.Stop(); + Logger.LogDebug($"Query time: {stopwatch.ElapsedMilliseconds} ms, query: \"{query}\""); + } + + public IList<IListItem> FetchItems(int offset, int limit, uint queryCookie, out bool hasMore, bool noIcons = false) + { + hasMore = false; + + var searchQuery = _searchQuery; + if (searchQuery is null) + { + return []; + } + + var cookie = searchQuery.Cookie; + if (cookie != queryCookie) + { + return []; + } + + var results = new List<IListItem>(); + var index = 0; + var hasMoreItems = searchQuery.FetchRows(offset, limit); + + while (!searchQuery.SearchResults.IsEmpty && searchQuery.SearchResults.TryDequeue(out var result) && ++index <= limit) + { + var indexerListItem = new IndexerListItem(new IndexerItem + { + FileName = result.ItemDisplayName, + FullPath = result.LaunchUri, + }); + + if (!noIcons) + { + IconInfo? icon = null; + try + { + var stream = ThumbnailHelper.GetThumbnail(result.LaunchUri).Result; + if (stream is not null) + { + var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); + icon = new IconInfo(data, data); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to get the icon.", ex); + } + + indexerListItem.Icon = icon; + } + + results.Add(indexerListItem); + } + + hasMore = hasMoreItems; + return results; + } + + public void Dispose() + { + var searchQuery = _searchQuery; + _searchQuery = null; + + searchQuery?.Dispose(); + + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Enums/WidgetDataState.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Enums/WidgetDataState.cs new file mode 100644 index 0000000000..544c6aaf2f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Enums/WidgetDataState.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. + +namespace CoreWidgetProvider.Widgets.Enums; + +public enum WidgetDataState +{ + Unknown, + Requested, // Request is out, waiting on a response. Current data is stale. + Okay, // Received and updated data, stable state. + Failed, // Failed retrieving data. +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Enums/WidgetPageState.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Enums/WidgetPageState.cs new file mode 100644 index 0000000000..b832e1ce30 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Enums/WidgetPageState.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. + +namespace CoreWidgetProvider.Widgets.Enums; + +public enum WidgetPageState +{ + Unknown, + Configure, + Loading, + Content, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/CPUStats.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/CPUStats.cs new file mode 100644 index 0000000000..99a02376ea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/CPUStats.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace CoreWidgetProvider.Helpers; + +internal sealed partial class CPUStats : IDisposable +{ + // CPU counters + private readonly PerformanceCounter _procPerf = new("Processor Information", "% Processor Utility", "_Total"); + private readonly PerformanceCounter _procPerformance = new("Processor Information", "% Processor Performance", "_Total"); + private readonly PerformanceCounter _procFrequency = new("Processor Information", "Processor Frequency", "_Total"); + private readonly Dictionary<Process, PerformanceCounter> _cpuCounters = new(); + + internal sealed class ProcessStats + { + public Process? Process { get; set; } + + public float CpuUsage { get; set; } + } + + public float CpuUsage { get; set; } + + public float CpuSpeed { get; set; } + + public ProcessStats[] ProcessCPUStats { get; set; } + + public List<float> CpuChartValues { get; set; } = new(); + + public CPUStats() + { + CpuUsage = 0; + ProcessCPUStats = + [ + new ProcessStats(), + new ProcessStats(), + new ProcessStats() + ]; + + InitCPUPerfCounters(); + } + + private void InitCPUPerfCounters() + { + var allProcesses = Process.GetProcesses().Where(p => (long)p.MainWindowHandle != 0); + + foreach (var process in allProcesses) + { + _cpuCounters.Add(process, new PerformanceCounter("Process", "% Processor Time", process.ProcessName, true)); + } + } + + public void GetData(bool includeTopProcesses) + { + var timer = Stopwatch.StartNew(); + CpuUsage = _procPerf.NextValue() / 100; + var usageMs = timer.ElapsedMilliseconds; + CpuSpeed = _procFrequency.NextValue() * (_procPerformance.NextValue() / 100); + var speedMs = timer.ElapsedMilliseconds - usageMs; + lock (CpuChartValues) + { + ChartHelper.AddNextChartValue(CpuUsage * 100, CpuChartValues); + } + + var chartMs = timer.ElapsedMilliseconds - speedMs; + + var processCPUUsages = new Dictionary<Process, float>(); + + if (includeTopProcesses) + { + foreach (var processCounter in _cpuCounters) + { + try + { + // process might be terminated + processCPUUsages.Add(processCounter.Key, processCounter.Value.NextValue() / Environment.ProcessorCount); + } + catch (InvalidOperationException) + { + // _log.Information($"ProcessCounter Key {processCounter.Key} no longer exists, removing from _cpuCounters."); + _cpuCounters.Remove(processCounter.Key); + } + catch (Exception) + { + // _log.Error(ex, "Error going through process counters."); + } + } + + var cpuIndex = 0; + foreach (var processCPUValue in processCPUUsages.OrderByDescending(x => x.Value).Take(3)) + { + ProcessCPUStats[cpuIndex].Process = processCPUValue.Key; + ProcessCPUStats[cpuIndex].CpuUsage = processCPUValue.Value; + cpuIndex++; + } + } + + timer.Stop(); + var total = timer.ElapsedMilliseconds; + var processesMs = total - chartMs; + + // CoreLogger.LogDebug($"[{usageMs}]+[{speedMs}]+[{chartMs}]+[{processesMs}]=[{total}]"); + } + + internal string CreateCPUImageUrl() + { + return ChartHelper.CreateImageUrl(CpuChartValues, ChartHelper.ChartType.CPU); + } + + internal string GetCpuProcessText(int cpuProcessIndex) + { + if (cpuProcessIndex >= ProcessCPUStats.Length) + { + return "no data"; + } + + return $"{ProcessCPUStats[cpuProcessIndex].Process?.ProcessName} ({ProcessCPUStats[cpuProcessIndex].CpuUsage / 100:p})"; + } + + internal void KillTopProcess(int cpuProcessIndex) + { + if (cpuProcessIndex >= ProcessCPUStats.Length) + { + return; + } + + ProcessCPUStats[cpuProcessIndex].Process?.Kill(); + } + + public void Dispose() + { + _procPerf.Dispose(); + _procPerformance.Dispose(); + _procFrequency.Dispose(); + + foreach (var counter in _cpuCounters.Values) + { + counter.Dispose(); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/ChartHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/ChartHelper.cs new file mode 100644 index 0000000000..bec3398c8b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/ChartHelper.cs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Xml.Linq; + +namespace CoreWidgetProvider.Helpers; + +internal sealed class ChartHelper +{ + public enum ChartType + { + CPU, + GPU, + Mem, + Net, + } + + public const int ChartHeight = 86; + public const int ChartWidth = 268; + + private const string LightGrayBoxStyle = "fill:none;stroke:lightgrey;stroke-width:1"; + + private const string CPULineStyle = "fill:none;stroke:rgb(57,184,227);stroke-width:1"; + private const string GPULineStyle = "fill:none;stroke:rgb(222,104,242);stroke-width:1"; + private const string MemLineStyle = "fill:none;stroke:rgb(92,158,250);stroke-width:1"; + private const string NetLineStyle = "fill:none;stroke:rgb(245,98,142);stroke-width:1"; + + private const string FillStyle = "fill:url(#gradientId);stroke:transparent"; + + private const string CPUBrushStop1Style = "stop-color:rgb(57,184,227);stop-opacity:0.4"; + private const string CPUBrushStop2Style = "stop-color:rgb(0,86,110);stop-opacity:0.25"; + + private const string GPUBrushStop1Style = "stop-color:rgb(222,104,242);stop-opacity:0.4"; + private const string GPUBrushStop2Style = "stop-color:rgb(125,0,138);stop-opacity:0.25"; + + private const string MemBrushStop1Style = "stop-color:rgb(92,158,250);stop-opacity:0.4"; + private const string MemBrushStop2Style = "stop-color:rgb(0,34,92);stop-opacity:0.25"; + + private const string NetBrushStop1Style = "stop-color:rgb(245,98,142);stop-opacity:0.4"; + private const string NetBrushStop2Style = "stop-color:rgb(130,0,47);stop-opacity:0.25"; + + private const string SvgElement = "svg"; + private const string RectElement = "rect"; + private const string PolylineElement = "polyline"; + private const string DefsElement = "defs"; + private const string LinearGradientElement = "linearGradient"; + private const string StopElement = "stop"; + + private const string HeightAttr = "height"; + private const string WidthAttr = "width"; + private const string StyleAttr = "style"; + private const string PointsAttr = "points"; + private const string OffsetAttr = "offset"; + private const string X1Attr = "x1"; + private const string X2Attr = "x2"; + private const string Y1Attr = "y1"; + private const string Y2Attr = "y2"; + private const string IdAttr = "id"; + + private const int MaxChartValues = 34; + + public static string CreateImageUrl(List<float> chartValues, ChartType type) + { + var chartStr = CreateChart(chartValues, type); + return "data:image/svg+xml;utf8," + chartStr; + } + + /// <summary> + /// Creates an SVG image for the chart. + /// </summary> + /// <param name="chartValues">The values to plot on the chart</param> + /// <param name="type">The type of chart. Each chart type uses different colors.</param> + /// <remarks> + /// The SVG is made of three shapes: <br/> + /// 1. A colored line, plotting the points on the graph <br/> + /// 2. A transparent line, outlining the gradient under the graph <br/> + /// 3. A grey box, outlining the entire image <br/> + /// The SVG also contains a definition for the fill gradient. + /// </remarks> + /// <returns>A string representing the chart as an SVG image.</returns> + public static string CreateChart(List<float> chartValues, ChartType type) + { + // The SVG created by this method will look similar to this: + /* + <svg height="102" width="264"> + <defs> + <linearGradient x1="0%" x2="0%" y1="0%" y2="100%" id="gradientId"> + <stop offset="0%" style="stop-color:rgb(222,104,242);stop-opacity:0.4" /> + <stop offset="95%" style="stop-color:rgb(125,0,138);stop-opacity:0.25" /> + </linearGradient> + </defs> + <polyline points="1,91 10,71 253,51 262,31 262,101 1,101" style="fill:url(#gradientId);stroke:transparent" /> + <polyline points="1,91 10,71 253,51 262,31" style="fill:none;stroke:rgb(222,104,242);stroke-width:1" /> + <rect height="102" width="264" style="fill:none;stroke:lightgrey;stroke-width:1" /> + </svg> + */ + + // The following code can be uncommented for testing when a static image is desired. + /* chartValues.Clear(); + chartValues = new List<float> + { + 10, 30, 20, 40, 30, 50, 40, 60, 50, 100, + 10, 30, 20, 40, 30, 50, 40, 60, 50, 70, + 0, 30, 20, 40, 30, 50, 40, 60, 50, 70, + };*/ + + var chartDoc = new XDocument(); + + lock (chartValues) + { + var svgElement = CreateBlankSvg(ChartHeight, ChartWidth); + + // Create the line that will show the points on the graph. + var lineElement = new XElement(PolylineElement); + var points = TransformPointsToLine(chartValues, out var startX, out var finalX); + lineElement.SetAttributeValue(PointsAttr, points.ToString()); + lineElement.SetAttributeValue(StyleAttr, GetLineStyle(type)); + + // Create the line that will contain the gradient fill. + TransformPointsToLoop(points, startX, finalX); + var fillElement = new XElement(PolylineElement); + fillElement.SetAttributeValue(PointsAttr, points.ToString()); + fillElement.SetAttributeValue(StyleAttr, FillStyle); + + // Add the gradient definition and the three shapes to the svg. + svgElement.Add(CreateGradientDefinition(type)); + svgElement.Add(fillElement); + svgElement.Add(lineElement); + svgElement.Add(CreateBorderBox(ChartHeight, ChartWidth)); + + chartDoc.Add(svgElement); + } + + return chartDoc.ToString(); + } + + private static XElement CreateBlankSvg(int height, int width) + { + var svgElement = new XElement(SvgElement); + svgElement.SetAttributeValue(HeightAttr, height); + svgElement.SetAttributeValue(WidthAttr, width); + return svgElement; + } + + private static XElement CreateGradientDefinition(ChartType type) + { + var defsElement = new XElement(DefsElement); + var gradientElement = new XElement(LinearGradientElement); + + // Vertical gradients are created when x1 and x2 are equal and y1 and y2 differ. + gradientElement.SetAttributeValue(X1Attr, "0%"); + gradientElement.SetAttributeValue(X2Attr, "0%"); + gradientElement.SetAttributeValue(Y1Attr, "0%"); + gradientElement.SetAttributeValue(Y2Attr, "100%"); + gradientElement.SetAttributeValue(IdAttr, "gradientId"); + + string stop1Style; + string stop2Style; + switch (type) + { + case ChartType.GPU: + stop1Style = GPUBrushStop1Style; + stop2Style = GPUBrushStop2Style; + break; + case ChartType.Mem: + stop1Style = MemBrushStop1Style; + stop2Style = MemBrushStop2Style; + break; + case ChartType.Net: + stop1Style = NetBrushStop1Style; + stop2Style = NetBrushStop2Style; + break; + case ChartType.CPU: + default: + stop1Style = CPUBrushStop1Style; + stop2Style = CPUBrushStop2Style; + break; + } + + var stop1 = new XElement(StopElement); + stop1.SetAttributeValue(OffsetAttr, "0%"); + stop1.SetAttributeValue(StyleAttr, stop1Style); + + var stop2 = new XElement(StopElement); + stop2.SetAttributeValue(OffsetAttr, "95%"); + stop2.SetAttributeValue(StyleAttr, stop2Style); + + gradientElement.Add(stop1); + gradientElement.Add(stop2); + defsElement.Add(gradientElement); + + return defsElement; + } + + private static XElement CreateBorderBox(int height, int width) + { + var boxElement = new XElement(RectElement); + boxElement.SetAttributeValue(HeightAttr, height); + boxElement.SetAttributeValue(WidthAttr, width); + boxElement.SetAttributeValue(StyleAttr, LightGrayBoxStyle); + return boxElement; + } + + private static string GetLineStyle(ChartType type) + { + var lineStyle = type switch + { + ChartType.CPU => CPULineStyle, + ChartType.GPU => GPULineStyle, + ChartType.Mem => MemLineStyle, + ChartType.Net => NetLineStyle, + _ => CPULineStyle, + }; + + return lineStyle; + } + + private static StringBuilder TransformPointsToLine(List<float> chartValues, out int startX, out int finalX) + { + var points = new StringBuilder(); + + // The X value where the graph starts must be adjusted so that the graph is right-aligned. + // The max available width of the widget is 268. Since there is a 1 px border around the chart, the width of the chart's line must be <=266. + // To create a chart of exactly the right size, we'll have 34 points with 8 pixels in between: + // 1 px left border + 1 px for first point + 33 segments * 8 px per segment + 1 px right border = 267 pixels total in width. + const int pxBetweenPoints = 8; + + // When the chart doesn't have all points yet, move the chart over to the right by increasing the starting X coordinate. + // For a chart with only 1 point, the svg will not render a polyline. + // For a chart with 2 points, starting X coordinate == 2 + (34 - 2) * 8 == 1 + 32 * 8 == 1 + 256 == 257 + // For a chart with 30 points, starting X coordinate == 2 + (34 - 34) * 8 == 1 + 0 * 8 == 1 + 0 == 2 + startX = 2 + ((MaxChartValues - chartValues.Count) * pxBetweenPoints); + finalX = startX; + + // Extend graph by one pixel to cover gap on the left when the chart is otherwise full. + if (startX == 2) + { + var invertedHeight = 100 - chartValues[0]; + var finalY = (invertedHeight * (ChartHeight / 100.0)) - 1; + points.Append(CultureInfo.InvariantCulture, $"1,{finalY} "); + } + + foreach (var origY in chartValues) + { + // We receive the height as a number up from the X axis (bottom of the chart), but we have to invert it + // since the Y coordinate is relative to the top of the chart. + var invertedHeight = 100 - origY; + + // Scale the final Y to whatever the chart height is. + var finalY = (invertedHeight * (ChartHeight / 100.0)) - 1; + + points.Append(CultureInfo.InvariantCulture, $"{finalX},{finalY} "); + finalX += pxBetweenPoints; + } + + // Remove the trailing space. + if (points.Length > 0) + { + points.Remove(points.Length - 1, 1); + finalX -= pxBetweenPoints; + } + + return points; + } + + private static void TransformPointsToLoop(StringBuilder points, int startX, int finalX) + { + // Close the loop. + // Add a point at the most recent X value that corresponds with y = 0 + points.Append(CultureInfo.InvariantCulture, $" {finalX},{ChartHeight - 1}"); + + // Add a point at the start of the chart that corresponds with y = 0 + points.Append(CultureInfo.InvariantCulture, $" {startX},{ChartHeight - 1}"); + } + + public static void AddNextChartValue(float value, List<float> chartValues) + { + if (chartValues.Count >= MaxChartValues) + { + chartValues.RemoveAt(0); + } + + chartValues.Add(value); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataManager.cs new file mode 100644 index 0000000000..940411a6b7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataManager.cs @@ -0,0 +1,147 @@ +// 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 Timer = System.Timers.Timer; + +namespace CoreWidgetProvider.Helpers; + +internal sealed partial class DataManager : IDisposable +{ + private readonly SystemData _systemData; + private readonly DataType _dataType; + private readonly Timer _updateTimer; + private readonly Action _updateAction; + + private const int OneSecondInMilliseconds = 1000; + + public DataManager(DataType type, Action updateWidget) + { + _systemData = new SystemData(); + _updateAction = updateWidget; + _dataType = type; + + _updateTimer = new Timer(OneSecondInMilliseconds); + _updateTimer.Elapsed += UpdateTimer_Elapsed; + _updateTimer.AutoReset = true; + _updateTimer.Enabled = false; + } + + private void GetMemoryData() + { + lock (SystemData.MemStats) + { + SystemData.MemStats.GetData(); + } + } + + private void GetNetworkData() + { + lock (SystemData.NetStats) + { + SystemData.NetStats.GetData(); + } + } + + private void GetGPUData() + { + lock (SystemData.GPUStats) + { + SystemData.GPUStats.GetData(); + } + } + + private void GetCPUData(bool includeTopProcesses) + { + lock (SystemData.CpuStats) + { + SystemData.CpuStats.GetData(includeTopProcesses); + } + } + + private void UpdateTimer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) + { + switch (_dataType) + { + case DataType.CPU: + case DataType.CpuWithTopProcesses: + { + // CPU + GetCPUData(_dataType == DataType.CpuWithTopProcesses); + break; + } + + case DataType.GPU: + { + // gpu + GetGPUData(); + break; + } + + case DataType.Memory: + { + // memory + GetMemoryData(); + break; + } + + case DataType.Network: + { + // network + GetNetworkData(); + break; + } + } + + _updateAction?.Invoke(); + } + + internal MemoryStats GetMemoryStats() + { + lock (SystemData.MemStats) + { + return SystemData.MemStats; + } + } + + internal NetworkStats GetNetworkStats() + { + lock (SystemData.NetStats) + { + return SystemData.NetStats; + } + } + + internal GPUStats GetGPUStats() + { + lock (SystemData.GPUStats) + { + return SystemData.GPUStats; + } + } + + internal CPUStats GetCPUStats() + { + lock (SystemData.CpuStats) + { + return SystemData.CpuStats; + } + } + + public void Start() + { + _updateTimer.Start(); + } + + public void Stop() + { + _updateTimer.Stop(); + } + + public void Dispose() + { + _systemData.Dispose(); + _updateTimer.Dispose(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataType.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataType.cs new file mode 100644 index 0000000000..27462da6fa --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/DataType.cs @@ -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. + +namespace CoreWidgetProvider.Helpers; + +public enum DataType +{ + /// <summary> + /// CPU related data. + /// </summary> + CPU, + + /// <summary> + /// CPU related data, including the top processes. + /// Calculating the top processes takes a lot longer, + /// so by default we don't. + /// </summary> + CpuWithTopProcesses, + + /// <summary> + /// Memory related data. + /// </summary> + Memory, + + /// <summary> + /// GPU related data. + /// </summary> + GPU, + + /// <summary> + /// Network related data. + /// </summary> + Network, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/GPUStats.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/GPUStats.cs new file mode 100644 index 0000000000..36805cdf83 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/GPUStats.cs @@ -0,0 +1,283 @@ +// 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.Diagnostics; +using System.Globalization; +using System.Linq; + +namespace CoreWidgetProvider.Helpers; + +internal sealed partial class GPUStats : IDisposable +{ + // GPU counters + private readonly Dictionary<int, List<PerformanceCounter>> _gpuCounters = new(); + + private readonly List<Data> _stats = new(); + + public sealed class Data + { + public string? Name { get; set; } + + public int PhysId { get; set; } + + public float Usage { get; set; } + + public float Temperature { get; set; } + + public List<float> GpuChartValues { get; set; } = new(); + } + + public GPUStats() + { + GetGPUPerfCounters(); + LoadGPUsFromCounters(); + } + + public void GetGPUPerfCounters() + { + // There are really 4 different things we should be tracking the usage + // of. Similar to how the instance name ends with `3D`, the following + // suffixes are important. + // + // * `3D` + // * `VideoEncode` + // * `VideoDecode` + // * `VideoProcessing` + // + // We could totally put each of those sets of counters into their own + // set. That's what we should do, so that we can report the sum of those + // numbers as the total utilization, and then have them broken out in + // the card template and in the details metadata. + _gpuCounters.Clear(); + + var perfCounterCategory = new PerformanceCounterCategory("GPU Engine"); + var instanceNames = perfCounterCategory.GetInstanceNames(); + + foreach (var instanceName in instanceNames) + { + if (!instanceName.EndsWith("3D", StringComparison.InvariantCulture)) + { + continue; + } + + var utilizationCounters = perfCounterCategory.GetCounters(instanceName) + .Where(x => x.CounterName.StartsWith("Utilization Percentage", StringComparison.InvariantCulture)); + + foreach (var counter in utilizationCounters) + { + var counterKey = counter.InstanceName; + + // skip these values + GetKeyValueFromCounterKey("pid", ref counterKey); + GetKeyValueFromCounterKey("luid", ref counterKey); + + int phys; + var success = int.TryParse(GetKeyValueFromCounterKey("phys", ref counterKey), out phys); + if (success) + { + GetKeyValueFromCounterKey("eng", ref counterKey); + var engtype = GetKeyValueFromCounterKey("engtype", ref counterKey); + if (engtype != "3D") + { + continue; + } + + if (!_gpuCounters.TryGetValue(phys, out var value)) + { + value = new(); + _gpuCounters.Add(phys, value); + } + + value.Add(counter); + } + } + } + } + + public void LoadGPUsFromCounters() + { + // The old dev home code tracked GPU stats by querying WMI for the list + // of GPUs, and then matching them up with the performance counter IDs. + // + // We can't use WMI here, because it drags in a dependency on + // Microsoft.Management.Infrastructure, which is not compatible with + // AOT. + // + // For now, we'll just use the indices as the GPU names. + _stats.Clear(); + foreach (var (k, v) in _gpuCounters) + { + var id = k; + var counters = v; + _stats.Add(new Data() { PhysId = id, Name = "GPU " + id }); + } + } + + public void GetData() + { + foreach (var gpu in _stats) + { + List<PerformanceCounter>? counters; + var success = _gpuCounters.TryGetValue(gpu.PhysId, out counters); + + if (success && counters != null) + { + // TODO: This outer try/catch should be replaced with more secure locking around shared resources. + try + { + var sum = 0.0f; + var countersToRemove = new List<PerformanceCounter>(); + foreach (var counter in counters) + { + try + { + // NextValue() can throw an InvalidOperationException if the counter is no longer there. + sum += counter.NextValue(); + } + catch (InvalidOperationException) + { + // We can't modify the list during the loop, so save it to remove at the end. + // _log.Information(ex, "Failed to get next value, remove"); + countersToRemove.Add(counter); + } + catch (Exception) + { + // _log.Error(ex, "Error going through process counters."); + } + } + + foreach (var counter in countersToRemove) + { + counters.Remove(counter); + counter.Dispose(); + } + + gpu.Usage = sum / 100; + lock (gpu.GpuChartValues) + { + ChartHelper.AddNextChartValue(sum, gpu.GpuChartValues); + } + } + catch (Exception) + { + // _log.Error(ex, "Error summing process counters."); + } + } + } + } + + internal string CreateGPUImageUrl(int gpuChartIndex) + { + return ChartHelper.CreateImageUrl(_stats.ElementAt(gpuChartIndex).GpuChartValues, ChartHelper.ChartType.GPU); + } + + internal string GetGPUName(int gpuActiveIndex) + { + if (_stats.Count <= gpuActiveIndex) + { + return string.Empty; + } + + return _stats[gpuActiveIndex].Name ?? string.Empty; + } + + internal int GetPrevGPUIndex(int gpuActiveIndex) + { + if (_stats.Count == 0) + { + return 0; + } + + if (gpuActiveIndex == 0) + { + return _stats.Count - 1; + } + + return gpuActiveIndex - 1; + } + + internal int GetNextGPUIndex(int gpuActiveIndex) + { + if (_stats.Count == 0) + { + return 0; + } + + if (gpuActiveIndex == _stats.Count - 1) + { + return 0; + } + + return gpuActiveIndex + 1; + } + + internal float GetGPUUsage(int gpuActiveIndex, string gpuActiveEngType) + { + if (_stats.Count <= gpuActiveIndex) + { + return 0; + } + + return _stats[gpuActiveIndex].Usage; + } + + internal string GetGPUTemperature(int gpuActiveIndex) + { + // MG Jan 2026: This code was lifted from the old Dev Home codebase. + // However, the performance counters for GPU temperature are not being + // collected. So this function always returns "--" for now. + // + // I have not done the code archeology to figure out why they were + // removed. + if (_stats.Count <= gpuActiveIndex) + { + return "--"; + } + + var temperature = _stats[gpuActiveIndex].Temperature; + if (temperature == 0) + { + return "--"; + } + + return temperature.ToString("0.", CultureInfo.InvariantCulture) + " \x00B0C"; + } + + private string GetKeyValueFromCounterKey(string key, ref string counterKey) + { + if (!counterKey.StartsWith(key, StringComparison.InvariantCulture)) + { + return "error"; + } + + counterKey = counterKey.Substring(key.Length + 1); + if (key.Equals("engtype", StringComparison.Ordinal)) + { + return counterKey; + } + + var pos = counterKey.IndexOf('_'); + if (key.Equals("luid", StringComparison.Ordinal)) + { + pos = counterKey.IndexOf('_', pos + 1); + } + + var retValue = counterKey.Substring(0, pos); + counterKey = counterKey.Substring(pos + 1); + return retValue; + } + + public void Dispose() + { + foreach (var counterPair in _gpuCounters) + { + foreach (var counter in counterPair.Value) + { + counter.Dispose(); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/MemoryStats.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/MemoryStats.cs new file mode 100644 index 0000000000..bb371353f0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/MemoryStats.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using Windows.Win32; + +namespace CoreWidgetProvider.Helpers; + +internal sealed partial class MemoryStats : IDisposable +{ + private readonly PerformanceCounter _memCommitted = new("Memory", "Committed Bytes", string.Empty); + private readonly PerformanceCounter _memCached = new("Memory", "Cache Bytes", string.Empty); + private readonly PerformanceCounter _memCommittedLimit = new("Memory", "Commit Limit", string.Empty); + private readonly PerformanceCounter _memPoolPaged = new("Memory", "Pool Paged Bytes", string.Empty); + private readonly PerformanceCounter _memPoolNonPaged = new("Memory", "Pool Nonpaged Bytes", string.Empty); + + public float MemUsage + { + get; set; + } + + public ulong AllMem + { + get; set; + } + + public ulong UsedMem + { + get; set; + } + + public ulong MemCommitted + { + get; set; + } + + public ulong MemCommitLimit + { + get; set; + } + + public ulong MemCached + { + get; set; + } + + public ulong MemPagedPool + { + get; set; + } + + public ulong MemNonPagedPool + { + get; set; + } + + public List<float> MemChartValues { get; set; } = new(); + + public void GetData() + { + Windows.Win32.System.SystemInformation.MEMORYSTATUSEX memStatus = default; + memStatus.dwLength = (uint)Marshal.SizeOf<Windows.Win32.System.SystemInformation.MEMORYSTATUSEX>(); + if (PInvoke.GlobalMemoryStatusEx(ref memStatus)) + { + AllMem = memStatus.ullTotalPhys; + var availableMem = memStatus.ullAvailPhys; + UsedMem = AllMem - availableMem; + + MemUsage = (float)UsedMem / AllMem; + lock (MemChartValues) + { + ChartHelper.AddNextChartValue(MemUsage * 100, MemChartValues); + } + } + + MemCached = (ulong)_memCached.NextValue(); + MemCommitted = (ulong)_memCommitted.NextValue(); + MemCommitLimit = (ulong)_memCommittedLimit.NextValue(); + MemPagedPool = (ulong)_memPoolPaged.NextValue(); + MemNonPagedPool = (ulong)_memPoolNonPaged.NextValue(); + } + + public string CreateMemImageUrl() + { + return ChartHelper.CreateImageUrl(MemChartValues, ChartHelper.ChartType.Mem); + } + + public void Dispose() + { + _memCommitted.Dispose(); + _memCached.Dispose(); + _memCommittedLimit.Dispose(); + _memPoolPaged.Dispose(); + _memPoolNonPaged.Dispose(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/NetworkStats.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/NetworkStats.cs new file mode 100644 index 0000000000..d5dc3ac15f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/NetworkStats.cs @@ -0,0 +1,169 @@ +// 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.Diagnostics; +using System.Linq; + +namespace CoreWidgetProvider.Helpers; + +internal sealed partial class NetworkStats : IDisposable +{ + private readonly Dictionary<string, List<PerformanceCounter>> _networkCounters = new(); + + private Dictionary<string, Data> NetworkUsages { get; set; } = new(); + + private Dictionary<string, List<float>> NetChartValues { get; set; } = new(); + + public sealed class Data + { + public float Usage + { + get; set; + } + + public float Sent + { + get; set; + } + + public float Received + { + get; set; + } + } + + public NetworkStats() + { + InitNetworkPerfCounters(); + } + + private void InitNetworkPerfCounters() + { + var perfCounterCategory = new PerformanceCounterCategory("Network Interface"); + var instanceNames = perfCounterCategory.GetInstanceNames(); + foreach (var instanceName in instanceNames) + { + var instanceCounters = new List<PerformanceCounter>(); + instanceCounters.Add(new PerformanceCounter("Network Interface", "Bytes Sent/sec", instanceName)); + instanceCounters.Add(new PerformanceCounter("Network Interface", "Bytes Received/sec", instanceName)); + instanceCounters.Add(new PerformanceCounter("Network Interface", "Current Bandwidth", instanceName)); + _networkCounters.Add(instanceName, instanceCounters); + NetChartValues.Add(instanceName, new List<float>()); + NetworkUsages.Add(instanceName, new Data()); + } + } + + public void GetData() + { + float maxUsage = 0; + foreach (var networkCounterWithName in _networkCounters) + { + try + { + var sent = networkCounterWithName.Value[0].NextValue(); + var received = networkCounterWithName.Value[1].NextValue(); + var bandWidth = networkCounterWithName.Value[2].NextValue(); + if (bandWidth == 0) + { + continue; + } + + var usage = 8 * (sent + received) / bandWidth; + var name = networkCounterWithName.Key; + NetworkUsages[name].Sent = sent; + NetworkUsages[name].Received = received; + NetworkUsages[name].Usage = usage; + + var chartValues = NetChartValues[name]; + lock (chartValues) + { + ChartHelper.AddNextChartValue(usage * 100, chartValues); + } + + if (usage > maxUsage) + { + maxUsage = usage; + } + } + catch (Exception) + { + // Log.Error(ex, "Error getting network data."); + } + } + } + + public string CreateNetImageUrl(int netChartIndex) + { + return ChartHelper.CreateImageUrl(NetChartValues.ElementAt(netChartIndex).Value, ChartHelper.ChartType.Net); + } + + public string GetNetworkName(int networkIndex) + { + if (NetChartValues.Count <= networkIndex) + { + return string.Empty; + } + + return NetChartValues.ElementAt(networkIndex).Key; + } + + public Data GetNetworkUsage(int networkIndex) + { + if (NetChartValues.Count <= networkIndex) + { + return new Data(); + } + + var currNetworkName = NetChartValues.ElementAt(networkIndex).Key; + if (!NetworkUsages.TryGetValue(currNetworkName, out var value)) + { + return new Data(); + } + + return value; + } + + public int GetPrevNetworkIndex(int networkIndex) + { + if (NetChartValues.Count == 0) + { + return 0; + } + + if (networkIndex == 0) + { + return NetChartValues.Count - 1; + } + + return networkIndex - 1; + } + + public int GetNextNetworkIndex(int networkIndex) + { + if (NetChartValues.Count == 0) + { + return 0; + } + + if (networkIndex == NetChartValues.Count - 1) + { + return 0; + } + + return networkIndex + 1; + } + + public void Dispose() + { + foreach (var counterPair in _networkCounters) + { + foreach (var counter in counterPair.Value) + { + counter.Dispose(); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/Resources.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/Resources.cs new file mode 100644 index 0000000000..cf51804da9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/Resources.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. + +using System; +using System.Text; +using Microsoft.CmdPal.Core.Common; + +namespace CoreWidgetProvider.Helpers; + +// This class was pilfered from devhome, but changed much more substantially to +// get the resources out of our resources.pri the way we need. +public static class Resources +{ + private static readonly Windows.ApplicationModel.Resources.Core.ResourceMap? _map; + + private static readonly string ResourcesPath = "Microsoft.CmdPal.Ext.PerformanceMonitor/Resources"; + + static Resources() + { + try + { + var currentResourceManager = Windows.ApplicationModel.Resources.Core.ResourceManager.Current; + if (currentResourceManager.MainResourceMap is not null) + { + _map = currentResourceManager.MainResourceMap; + } + } + catch (Exception) + { + // Resource map not available (e.g., during unit tests) + _map = null; + } + } + + public static string GetResource(string identifier, ILogger? log = null) + { + if (_map is null) + { + return identifier; + } + + var fullKey = $"{ResourcesPath}/{identifier}"; + + var val = _map.GetValue(fullKey); +#if DEBUG + if (val == null) + { + log?.LogError($"Failed loading resource: {identifier}"); + + DebugResources(log); + } +#endif + return val!.ValueAsString; + } + + public static string ReplaceIdentifersFast( + string original) + { + // walk the string, looking for a pair of '%' characters + StringBuilder sb = new(); + var length = original.Length; + for (var i = 0; i < length; i++) + { + if (original[i] == '%') + { + var end = original.IndexOf('%', i + 1); + if (end > i) + { + var identifier = original.Substring(i + 1, end - i - 1); + var resourceString = GetResource(identifier); + sb.Append(resourceString); + i = end; // move index to the end '%' + continue; + } + } + + sb.Append(original[i]); + } + + return sb.ToString(); + } + + private static void DebugResources(ILogger? log) + { + var currentResourceManager = Windows.ApplicationModel.Resources.Core.ResourceManager.Current; + StringBuilder sb = new(); + + foreach (var (k, v) in currentResourceManager.AllResourceMaps) + { + sb.AppendLine(k); + foreach (var (k2, v2) in v) + { + sb.Append('\t'); + sb.AppendLine(k2); + } + + sb.AppendLine(); + } + + log?.LogDebug($"Resource maps:"); + log?.LogDebug(sb.ToString()); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/SystemData.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/SystemData.cs new file mode 100644 index 0000000000..52d0b2c536 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Helpers/SystemData.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace CoreWidgetProvider.Helpers; + +internal sealed partial class SystemData : IDisposable +{ + public static MemoryStats MemStats { get; set; } = new MemoryStats(); + + public static NetworkStats NetStats { get; set; } = new NetworkStats(); + + public static GPUStats GPUStats { get; set; } = new GPUStats(); + + public static CPUStats CpuStats { get; set; } = new CPUStats(); + + public SystemData() + { + } + + public void Dispose() + { + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/README.md b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/README.md new file mode 100644 index 0000000000..01ae14f4f5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/README.md @@ -0,0 +1,14 @@ +The code in this directory was largely lifted from the [DevHome repo]. + +The specific directory we're using is +https://github.com/microsoft/devhome/tree/main/extensions/CoreWidgetProvider +This has code for all the DevHome performance widgets. + +Minimal changes have been made to match our style guidelines. +Additionally, a much larger change was made to Resources.cs, to match our own +resource loading needs. + +The code was lifted as of commit d52734ce0e33a82af3313d24c3c2979c37b68bab + + +[DevHome repo]: https://github.com/microsoft/devhome/ \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/LoadingTemplate.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/LoadingTemplate.json new file mode 100644 index 0000000000..f931feea31 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/LoadingTemplate.json @@ -0,0 +1,20 @@ +{ + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "Container", + "items": [ + { + "type": "TextBlock", + "text": "%Widget_Template/Loading%", + "wrap": true, + "horizontalAlignment": "center" + } + ], + "verticalContentAlignment": "center", + "height": "stretch" + } + ] +} \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemCPUUsageTemplate.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemCPUUsageTemplate.json new file mode 100644 index 0000000000..749ca059e2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemCPUUsageTemplate.json @@ -0,0 +1,99 @@ +{ + "type": "AdaptiveCard", + "body": [ + { + "type": "Container", + "$when": "${errorMessage != null}", + "items": [ + { + "type": "TextBlock", + "text": "${errorMessage}", + "wrap": true, + "size": "small" + } + ], + "style": "warning" + }, + { + "type": "Container", + "$when": "${errorMessage == null}", + "items": [ + { + "type": "Image", + "url": "${cpuGraphUrl}", + "height": "${chartHeight}", + "width": "${chartWidth}", + "$when": "${$host.widgetSize != \"small\"}", + "horizontalAlignment": "center" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "isSubtle": true, + "text": "%CPUUsage_Widget_Template/CPU_Usage%" + }, + { + "type": "TextBlock", + "size": "large", + "weight": "bolder", + "text": "${cpuUsage}" + } + ] + }, + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "isSubtle": true, + "horizontalAlignment": "right", + "text": "%CPUUsage_Widget_Template/CPU_Speed%" + }, + { + "type": "TextBlock", + "size": "large", + "horizontalAlignment": "right", + "text": "${cpuSpeed}" + } + ] + } + ] + }, + { + "type": "Container", + "$when": false, + "items": [ + { + "type": "TextBlock", + "isSubtle": true, + "text": "%CPUUsage_Widget_Template/Processes%", + "wrap": true + }, + { + "type": "TextBlock", + "size": "medium", + "text": "${cpuProc1}" + }, + { + "type": "TextBlock", + "size": "medium", + "text": "${cpuProc2}" + }, + { + "type": "TextBlock", + "size": "medium", + "text": "${cpuProc3}" + } + ] + } + ] + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5" +} \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemGPUUsageTemplate.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemGPUUsageTemplate.json new file mode 100644 index 0000000000..24cd7a268e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemGPUUsageTemplate.json @@ -0,0 +1,86 @@ +{ + "type": "AdaptiveCard", + "body": [ + { + "type": "Container", + "$when": "${errorMessage != null}", + "items": [ + { + "type": "TextBlock", + "text": "${errorMessage}", + "wrap": true, + "size": "small" + } + ], + "style": "warning" + }, + { + "type": "Container", + "$when": "${errorMessage == null}", + "items": [ + { + "type": "Image", + "url": "${gpuGraphUrl}", + "height": "${chartHeight}", + "width": "${chartWidth}", + "$when": "${$host.widgetSize != \"small\"}", + "horizontalAlignment": "center" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "text": "%GPUUsage_Widget_Template/GPU_Usage%", + "type": "TextBlock", + "size": "small", + "isSubtle": true + }, + { + "text": "${gpuUsage}", + "type": "TextBlock", + "size": "large", + "weight": "bolder" + } + ] + }, + { + "type": "Column", + "items": [ + { + "text": "%GPUUsage_Widget_Template/GPU_Temperature%", + "type": "TextBlock", + "size": "small", + "isSubtle": true, + "horizontalAlignment": "right" + }, + { + "text": "${gpuTemp}", + "type": "TextBlock", + "size": "large", + "weight": "bolder", + "horizontalAlignment": "right" + } + ] + } + ] + }, + { + "text": "%GPUUsage_Widget_Template/GPU_Name%", + "type": "TextBlock", + "size": "small", + "isSubtle": true + }, + { + "text": "${gpuName}", + "type": "TextBlock", + "size": "medium" + } + ] + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5" +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemMemoryTemplate.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemMemoryTemplate.json new file mode 100644 index 0000000000..188da82fdc --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemMemoryTemplate.json @@ -0,0 +1,178 @@ +{ + "type": "AdaptiveCard", + "body": [ + { + "type": "Container", + "$when": "${errorMessage != null}", + "items": [ + { + "type": "TextBlock", + "text": "${errorMessage}", + "wrap": true, + "size": "small" + } + ], + "style": "warning" + }, + { + "type": "Container", + "$when": "${errorMessage == null}", + "items": [ + { + "type": "Image", + "url": "${memGraphUrl}", + "height": "${chartHeight}", + "width": "${chartWidth}", + "$when": "${$host.widgetSize != \"small\"}", + "horizontalAlignment": "center" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "text": "%Memory_Widget_Template/UsedMemory%", + "type": "TextBlock", + "size": "small", + "isSubtle": true + }, + { + "text": "${usedMem}", + "type": "TextBlock", + "size": "${if($host.widgetSize == \"small\", \"medium\", \"large\")}", + "weight": "bolder" + } + ] + }, + { + "type": "Column", + "items": [ + { + "text": "%Memory_Widget_Template/AllMemory%", + "type": "TextBlock", + "size": "small", + "isSubtle": true, + "horizontalAlignment": "right" + }, + { + "text": "${allMem}", + "type": "TextBlock", + "size": "${if($host.widgetSize == \"small\", \"medium\", \"large\")}", + "weight": "bolder", + "horizontalAlignment": "right" + } + ] + } + ] + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "text": "%Memory_Widget_Template/Committed%", + "type": "TextBlock", + "size": "small", + "isSubtle": true + }, + { + "text": "${committedMem}/${committedLimitMem}", + "type": "TextBlock", + "size": "medium" + } + ] + }, + { + "type": "Column", + "items": [ + { + "text": "%Memory_Widget_Template/Cached%", + "type": "TextBlock", + "size": "small", + "isSubtle": true, + "horizontalAlignment": "right" + }, + { + "text": "${cachedMem}", + "type": "TextBlock", + "size": "medium", + "horizontalAlignment": "right" + } + ] + } + ] + }, + { + "type": "ColumnSet", + "$when": "${$host.widgetSize == \"large\"}", + "columns": [ + { + "type": "Column", + "items": [ + { + "text": "%Memory_Widget_Template/PagedPool%", + "type": "TextBlock", + "size": "small", + "isSubtle": true + }, + { + "text": "${pagedPoolMem}", + "type": "TextBlock", + "size": "medium" + } + ] + }, + { + "type": "Column", + "items": [ + { + "text": "%Memory_Widget_Template/NonPagedPool%", + "type": "TextBlock", + "size": "small", + "isSubtle": true, + "horizontalAlignment": "right" + }, + { + "text": "${nonPagedPoolMem}", + "type": "TextBlock", + "size": "medium", + "horizontalAlignment": "right" + } + ] + } + ] + }, + { + "type": "ColumnSet", + "$when": "${$host.widgetSize != \"small\"}", + "columns": [ + { + "type": "Column", + "items": [ + { + "text": "%Memory_Widget_Template/MemoryUsage%", + "type": "TextBlock", + "size": "small", + "isSubtle": true, + "horizontalAlignment": "right" + }, + { + "text": "${memUsage}", + "type": "TextBlock", + "size": "medium", + "horizontalAlignment": "right" + } + ] + } + ] + } + ] + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5" +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemNetworkUsageTemplate.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemNetworkUsageTemplate.json new file mode 100644 index 0000000000..e96a611148 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/DevHome/Templates/SystemNetworkUsageTemplate.json @@ -0,0 +1,88 @@ +{ + "type": "AdaptiveCard", + "body": [ + { + "type": "Container", + "$when": "${errorMessage != null}", + "items": [ + { + "type": "TextBlock", + "text": "${errorMessage}", + "wrap": true, + "size": "small" + } + ], + "style": "warning" + }, + { + "type": "Container", + "$when": "${errorMessage == null}", + "items": [ + { + "type": "Image", + "url": "${netGraphUrl}", + "height": "${chartHeight}", + "width": "${chartWidth}", + "$when": "${$host.widgetSize != \"small\"}", + "horizontalAlignment": "center" + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "text": "%NetworkUsage_Widget_Template/Sent%", + "type": "TextBlock", + "spacing": "none", + "size": "small", + "isSubtle": true + }, + { + "text": "${netSent}", + "type": "TextBlock", + "size": "large", + "weight": "bolder" + } + ] + }, + { + "type": "Column", + "items": [ + { + "text": "%NetworkUsage_Widget_Template/Received%", + "type": "TextBlock", + "spacing": "none", + "size": "small", + "isSubtle": true, + "horizontalAlignment": "right" + }, + { + "text": "${netReceived}", + "type": "TextBlock", + "size": "large", + "weight": "bolder", + "horizontalAlignment": "right" + } + ] + } + ] + }, + { + "text": "%NetworkUsage_Widget_Template/Network_Name%", + "type": "TextBlock", + "size": "small", + "isSubtle": true + }, + { + "text": "${networkName}", + "type": "TextBlock", + "size": "medium" + } + ] + } + ], + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5" +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Icons.cs new file mode 100644 index 0000000000..3d2fe49e70 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Icons.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 Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.PerformanceMonitor; + +internal sealed class Icons +{ + internal static IconInfo CpuIcon => new("\uE9D9"); // CPU icon + + internal static IconInfo MemoryIcon => new("\uE964"); // Memory icon + + internal static IconInfo DiskIcon => new("\uE977"); // PC1 icon + + internal static IconInfo HardDriveIcon => new("\uEDA2"); // HardDrive icon + + internal static IconInfo NetworkIcon => new("\uEC05"); // Network icon + + internal static IconInfo StackedAreaIcon => new("\uE9D2"); // StackedArea icon + + internal static IconInfo GpuIcon => new("\uE950"); // Component icon + + internal static IconInfo NavigateBackwardIcon => new("\uE72B"); // Previous icon + + internal static IconInfo NavigateForwardIcon => new("\uE72A"); // Next icon +} + + +#pragma warning restore SA1402 // File may only contain a single type diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Microsoft.CmdPal.Ext.PerformanceMonitor.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Microsoft.CmdPal.Ext.PerformanceMonitor.csproj new file mode 100644 index 0000000000..0e6823c805 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Microsoft.CmdPal.Ext.PerformanceMonitor.csproj @@ -0,0 +1,58 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="..\..\..\..\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> + <PropertyGroup> + <RootNamespace>Microsoft.CmdPal.Ext.PerformanceMonitor</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> + <ProjectPriFileName>Microsoft.CmdPal.Ext.PerformanceMonitor.pri</ProjectPriFileName> + <nullable>enable</nullable> + <LangVersion>preview</LangVersion> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="System.Diagnostics.PerformanceCounter" /> + <PackageReference Include="Microsoft.Windows.CsWin32"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> + <!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props --> + </ItemGroup> + + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <DependentUpon>Resources.resx</DependentUpon> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + </Compile> + </ItemGroup> + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + <Generator>PublicResXFileCodeGenerator</Generator> + </EmbeddedResource> + </ItemGroup> + <ItemGroup> + <None Update="DevHome\Templates\SystemCPUUsageTemplate.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <None Update="DevHome\Templates\SystemGPUUsageTemplate.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <None Update="DevHome\Templates\SystemMemoryTemplate.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <None Update="DevHome\Templates\SystemNetworkUsageTemplate.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + +</Project> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/NativeMethods.txt new file mode 100644 index 0000000000..fbc7e91105 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/NativeMethods.txt @@ -0,0 +1 @@ +GlobalMemoryStatusEx diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/OnLoadStaticPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/OnLoadStaticPage.cs new file mode 100644 index 0000000000..0c4242240e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/OnLoadStaticPage.cs @@ -0,0 +1,123 @@ +// 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.PerformanceMonitor; + +#pragma warning disable SA1402 // File may only contain a single type + +/// <summary> +/// Helper class for creating ListPage's which can listen for when they're +/// loaded and unloaded. This works because CmdPal will attach an event handler +/// to the ItemsChanged event when the page is added to the UI, and remove it +/// when the page is removed from the UI. +/// +/// Subclasses should override the Loaded and Unloaded methods to start/stop +/// any background work needed to populate the page. +/// </summary> +internal abstract partial class OnLoadStaticListPage : OnLoadBasePage, IListPage +{ + private string _searchText = string.Empty; + + public virtual string PlaceholderText { get; set => SetProperty(ref field, value); } = string.Empty; + + public virtual string SearchText { get => _searchText; set => SetProperty(ref _searchText, value); } + + public virtual bool ShowDetails { get; set => SetProperty(ref field, value); } + + public virtual bool HasMoreItems { get; set => SetProperty(ref field, value); } + + public virtual IFilters? Filters { get; set => SetProperty(ref field, value); } + + public virtual IGridProperties? GridProperties { get; set => SetProperty(ref field, value); } + + public virtual ICommandItem? EmptyContent { get; set => SetProperty(ref field, value); } + + public void LoadMore() + { + } + + protected void SetSearchNoUpdate(string newSearchText) + { + _searchText = newSearchText; + } + + public abstract IListItem[] GetItems(); +} + +/// <summary> +/// Helper class for creating ContentPage's which can listen for when they're +/// loaded and unloaded. This works because CmdPal will attach an event handler +/// to the ItemsChanged event when the page is added to the UI, and remove it +/// when the page is removed from the UI. +/// +/// Subclasses should override the Loaded and Unloaded methods to start/stop +/// any background work needed to populate the page. +/// </summary> +internal abstract partial class OnLoadContentPage : OnLoadBasePage, IContentPage +{ + public virtual IDetails? Details { get; set => SetProperty(ref field, value); } + + public virtual IContextItem[] Commands { get; set => SetProperty(ref field, value); } = []; + + public abstract IContent[] GetContent(); +} + +internal abstract partial class OnLoadBasePage : Page +{ + private int _loadCount; + +#pragma warning disable CS0067 // The event is never used + + private event TypedEventHandler<object, IItemsChangedEventArgs>? InternalItemsChanged; +#pragma warning restore CS0067 // The event is never used + + public event TypedEventHandler<object, IItemsChangedEventArgs> ItemsChanged + { + add + { + InternalItemsChanged += value; + if (_loadCount == 0) + { + Loaded(); + } + + _loadCount++; + } + + remove + { + InternalItemsChanged -= value; + _loadCount--; + _loadCount = Math.Max(0, _loadCount); + if (_loadCount == 0) + { + Unloaded(); + } + } + } + + protected abstract void Loaded(); + + protected abstract void Unloaded(); + + protected void RaiseItemsChanged(int totalItems = -1) + { + try + { + // TODO #181 - This is the same thing that BaseObservable has to deal with. + InternalItemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + } + catch + { + } + } +} + + +#pragma warning restore SA1402 // File may only contain a single type diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorCommandsProvider.cs new file mode 100644 index 0000000000..7249c97b7d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceMonitorCommandsProvider.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using CoreWidgetProvider.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.PerformanceMonitor; + +public partial class PerformanceMonitorCommandsProvider : CommandProvider +{ + private readonly ICommandItem[] _commands; + private readonly ICommandItem _band; + + public PerformanceMonitorCommandsProvider() + { + DisplayName = Resources.GetResource("Performance_Monitor_Title"); + Id = "PerformanceMonitor"; + Icon = Icons.StackedAreaIcon; + + var page = new PerformanceWidgetsPage(false); + var band = new PerformanceWidgetsPage(true); + _band = new CommandItem(band) { Title = DisplayName }; + _commands = [ + new CommandItem(page) { Title = DisplayName }, + ]; + } + + public override ICommandItem[] TopLevelCommands() + { + return _commands; + } + + // Soon... + // public override ICommandItem[]? GetDockBands() + // { + // return new ICommandItem[] { _band }; + // } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceWidgetsPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceWidgetsPage.cs new file mode 100644 index 0000000000..4793c44889 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/PerformanceWidgetsPage.cs @@ -0,0 +1,926 @@ +// 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.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json.Nodes; +using CoreWidgetProvider.Helpers; +using CoreWidgetProvider.Widgets.Enums; +using Microsoft.CmdPal.Core.Common; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.ApplicationModel; + +namespace Microsoft.CmdPal.Ext.PerformanceMonitor; + +#pragma warning disable SA1402 // File may only contain a single type + +/// <summary> +/// Page for displaying performance monitor widgets. Can be used as both a list +/// in the main window, or as a band in the dock. +/// By using OnLoadStaticListPage, we can get onload/onunload events to start/stop +/// the data gathering. +/// </summary> +internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDisposable +{ + public override string Id => "com.microsoft.cmdpal.performanceWidget"; + + public override string Title => Resources.GetResource("Performance_Monitor_Title"); + + public override IconInfo Icon => Icons.StackedAreaIcon; + + private readonly bool _isBandPage; + + private readonly SystemCPUUsageWidgetPage _cpuPage = new(); + private readonly ListItem _cpuItem; + + private readonly SystemMemoryUsageWidgetPage _memoryPage = new(); + private readonly ListItem _memoryItem; + + private readonly SystemNetworkUsageWidgetPage _networkPage = new(); + private readonly ListItem _networkItem; + + private readonly SystemGPUUsageWidgetPage _gpuPage = new(); + private readonly ListItem _gpuItem; + + // For bands, we want two bands, one for up and one for down + private ListItem? _networkUpItem; + private ListItem? _networkDownItem; + private string _networkUpSpeed = string.Empty; + private string _networkDownSpeed = string.Empty; + + public PerformanceWidgetsPage(bool isBandPage = false) + { + _isBandPage = isBandPage; + _cpuItem = new ListItem(_cpuPage) + { + Title = _cpuPage.GetItemTitle(isBandPage), + MoreCommands = _cpuPage.Commands, + }; + + _cpuPage.Updated += (s, e) => + { + _cpuItem.Title = _cpuPage.GetItemTitle(isBandPage); + }; + + _memoryItem = new ListItem(_memoryPage) + { + Title = _memoryPage.GetItemTitle(isBandPage), + MoreCommands = _memoryPage.Commands, + }; + + _memoryPage.Updated += (s, e) => + { + _memoryItem.Title = _memoryPage.GetItemTitle(isBandPage); + }; + + _networkItem = new ListItem(_networkPage) + { + Title = _networkPage.GetItemTitle(isBandPage), + MoreCommands = _networkPage.Commands, + }; + + _networkPage.Updated += (s, e) => + { + _networkItem.Title = _networkPage.GetItemTitle(isBandPage); + _networkUpSpeed = _networkPage.GetUpSpeed(); + _networkDownSpeed = _networkPage.GetDownSpeed(); + _networkDownItem?.Title = $"{_networkDownSpeed}"; + _networkUpItem?.Title = $"{_networkUpSpeed}"; + }; + + _gpuItem = new ListItem(_gpuPage) + { + Title = _gpuPage.GetItemTitle(isBandPage), + MoreCommands = _gpuPage.Commands, + }; + + _gpuPage.Updated += (s, e) => + { + _gpuItem.Title = _gpuPage.GetItemTitle(isBandPage); + }; + + if (_isBandPage) + { + // add subtitles to them all + _cpuItem.Subtitle = Resources.GetResource("CPU_Usage_Subtitle"); + _memoryItem.Subtitle = Resources.GetResource("Memory_Usage_Subtitle"); + _networkItem.Subtitle = Resources.GetResource("Network_Usage_Subtitle"); + _gpuItem.Subtitle = Resources.GetResource("GPU_Usage_Subtitle"); + } + } + + protected override void Loaded() + { + _cpuPage.PushActivate(); + _memoryPage.PushActivate(); + _networkPage.PushActivate(); + _gpuPage.PushActivate(); + } + + protected override void Unloaded() + { + _cpuPage.PopActivate(); + _memoryPage.PopActivate(); + _networkPage.PopActivate(); + _gpuPage.PopActivate(); + } + + public override IListItem[] GetItems() + { + if (!_isBandPage) + { + // TODO add details + return new[] { _cpuItem, _memoryItem, _networkItem, _gpuItem }; + } + else + { + _networkUpItem = new ListItem(_networkPage) + { + Title = $"{_networkUpSpeed}", + Subtitle = Resources.GetResource("Network_Send_Subtitle"), + MoreCommands = _networkPage.Commands, + }; + + _networkDownItem = new ListItem(_networkPage) + { + Title = $"{_networkDownSpeed}", + Subtitle = Resources.GetResource("Network_Receive_Subtitle"), + MoreCommands = _networkPage.Commands, + }; + + return new[] { _cpuItem, _memoryItem, _networkDownItem, _networkUpItem, _gpuItem }; + } + } + + public void Dispose() + { + _cpuPage.Dispose(); + _memoryPage.Dispose(); + _networkPage.Dispose(); + _gpuPage.Dispose(); + } +} + +/// <summary> +/// Base class for all the performance monitor widget pages. +/// This handles common stuff like loading their widget JSON +/// and updating it when needed. +/// </summary> +internal abstract partial class WidgetPage : OnLoadContentPage +{ + internal event EventHandler? Updated; + + protected Dictionary<string, string> ContentData { get; } = new(); + + protected WidgetPageState Page { get; set; } = WidgetPageState.Unknown; + + protected Dictionary<WidgetPageState, string> Template { get; set; } = new(); + + protected JsonObject ContentDataJson + { + get + { + var json = new JsonObject(); + lock (ContentData) + { + foreach (var kvp in ContentData) + { + if (kvp.Value is not null) + { + json[kvp.Key] = kvp.Value; + } + } + } + + return json; + } + } + + private readonly FormContent _formContent = new(); + + public void UpdateWidget() + { + lock (ContentData) + { + LoadContentData(); + } + + _formContent.DataJson = ContentDataJson.ToJsonString(); + + Updated?.Invoke(this, EventArgs.Empty); + } + + protected abstract void LoadContentData(); + + protected abstract string GetTemplatePath(WidgetPageState page); + + protected string GetTemplateForPage(WidgetPageState page) + { + if (Template.TryGetValue(page, out var value)) + { + CoreLogger.LogDebug($"Using cached template for {page}"); + return value; + } + + try + { + var path = Path.Combine(Package.Current.EffectivePath, GetTemplatePath(page)); + var template = File.ReadAllText(path, Encoding.Default) ?? throw new FileNotFoundException(path); + + template = Resources.ReplaceIdentifersFast(template); + CoreLogger.LogDebug($"Caching template for {page}"); + Template[page] = template; + return template; + } + catch (Exception e) + { + CoreLogger.LogError("Error getting template.", e); + return string.Empty; + } + } + + public override IContent[] GetContent() + { + _formContent.TemplateJson = GetTemplateForPage(WidgetPageState.Content); + + return [_formContent]; + } + + /// <summary> + /// Increment our tracker of how many pages have needed us active. This is a + /// little wackier than just OnLoad/Unload. Both the ListPage for + /// PerformanceWidgetsPage itself, AND the widget itself need the stats to + /// be updating. So we use a counter to track how many "clients" need us + /// active. When either is activated, we'll start updating. When both are + /// removed, we'll stop updating. + /// </summary> + internal virtual void PushActivate() + { + _loadCount++; + } + + internal virtual void PopActivate() + { + _loadCount--; + } + + private int _loadCount; + + protected bool IsActive => _loadCount > 0; + + protected override void Loaded() + { + PushActivate(); + } + + protected override void Unloaded() + { + PopActivate(); + } + + internal static string FloatToPercentString(float value) + { + return ((int)(value * 100)).ToString(CultureInfo.InvariantCulture) + "%"; + } +} + +internal sealed partial class SystemCPUUsageWidgetPage : WidgetPage, IDisposable +{ + public override string Title => Resources.GetResource("CPU_Usage_Title"); + + public override string Id => "com.microsoft.cmdpal.cpu_widget"; + + public override IconInfo Icon => Icons.CpuIcon; + + private readonly DataManager _dataManager; + + public SystemCPUUsageWidgetPage() + { + _dataManager = new(DataType.CPU, () => UpdateWidget()); + Commands = [ + new CommandContextItem(OpenTaskManagerCommand.Instance), + ]; + } + + protected override void LoadContentData() + { + // CoreLogger.LogDebug("Getting CPU stats"); + try + { + ContentData.Clear(); + + var timer = Stopwatch.StartNew(); + + var currentData = _dataManager.GetCPUStats(); + + var dataDuration = timer.ElapsedMilliseconds; + + ContentData["cpuUsage"] = FloatToPercentString(currentData.CpuUsage); + ContentData["cpuSpeed"] = SpeedToString(currentData.CpuSpeed); + ContentData["cpuGraphUrl"] = currentData.CreateCPUImageUrl(); + ContentData["chartHeight"] = ChartHelper.ChartHeight + "px"; + ContentData["chartWidth"] = ChartHelper.ChartWidth + "px"; + + // ContentData["cpuProc1"] = currentData.GetCpuProcessText(0); + // ContentData["cpuProc2"] = currentData.GetCpuProcessText(1); + // ContentData["cpuProc3"] = currentData.GetCpuProcessText(2); + var contentDuration = timer.ElapsedMilliseconds - dataDuration; + + // CoreLogger.LogDebug($"CPU stats retrieved in {dataDuration} ms, content prepared in {contentDuration} ms. (Total {timer.ElapsedMilliseconds} ms)"); + // DataState = WidgetDataState.Okay; + } + catch (Exception e) + { + // Log.Error(e, "Error retrieving stats."); + ContentData.Clear(); + ContentData["errorMessage"] = e.Message; + + // ContentData = content.ToJsonString(); + // DataState = WidgetDataState.Failed; + return; + } + } + + protected override string GetTemplatePath(WidgetPageState page) + { + return page switch + { + WidgetPageState.Content => @"DevHome\Templates\SystemCPUUsageTemplate.json", + WidgetPageState.Loading => @"DevHome\Templates\SystemCPUUsageTemplate.json", + _ => throw new NotImplementedException(), + }; + } + + public string GetItemTitle(bool isBandPage) + { + if (ContentData.TryGetValue("cpuUsage", out var usage)) + { + return isBandPage ? usage : string.Format(CultureInfo.CurrentCulture, Resources.GetResource("CPU_Usage_Label"), usage); + } + else + { + return isBandPage ? Resources.GetResource("CPU_Usage_Unknown") : Resources.GetResource("CPU_Usage_Unknown_Label"); + } + } + + private string SpeedToString(float cpuSpeed) + { + return string.Format(CultureInfo.InvariantCulture, "{0:0.00} GHz", cpuSpeed / 1000); + } + + internal override void PushActivate() + { + base.PushActivate(); + if (IsActive) + { + _dataManager.Start(); + } + } + + internal override void PopActivate() + { + base.PopActivate(); + if (!IsActive) + { + _dataManager.Stop(); + } + } + + public void Dispose() + { + _dataManager.Dispose(); + } +} + +internal sealed partial class SystemMemoryUsageWidgetPage : WidgetPage, IDisposable +{ + public override string Id => "com.microsoft.cmdpal.memory_widget"; + + public override string Title => Resources.GetResource("Memory_Usage_Title"); + + public override IconInfo Icon => Icons.MemoryIcon; + + private readonly DataManager _dataManager; + + public SystemMemoryUsageWidgetPage() + { + _dataManager = new(DataType.Memory, () => UpdateWidget()); + Commands = [ + new CommandContextItem(OpenTaskManagerCommand.Instance), + ]; + } + + protected override void LoadContentData() + { + // CoreLogger.LogDebug("Getting Memory stats"); + try + { + ContentData.Clear(); + + var timer = Stopwatch.StartNew(); + + var currentData = _dataManager.GetMemoryStats(); + + var dataDuration = timer.ElapsedMilliseconds; + + ContentData["allMem"] = MemUlongToString(currentData.AllMem); + ContentData["usedMem"] = MemUlongToString(currentData.UsedMem); + ContentData["memUsage"] = FloatToPercentString(currentData.MemUsage); + ContentData["committedMem"] = MemUlongToString(currentData.MemCommitted); + ContentData["committedLimitMem"] = MemUlongToString(currentData.MemCommitLimit); + ContentData["cachedMem"] = MemUlongToString(currentData.MemCached); + ContentData["pagedPoolMem"] = MemUlongToString(currentData.MemPagedPool); + ContentData["nonPagedPoolMem"] = MemUlongToString(currentData.MemNonPagedPool); + ContentData["memGraphUrl"] = currentData.CreateMemImageUrl(); + ContentData["chartHeight"] = ChartHelper.ChartHeight + "px"; + ContentData["chartWidth"] = ChartHelper.ChartWidth + "px"; + + var contentDuration = timer.ElapsedMilliseconds - dataDuration; + + // CoreLogger.LogDebug($"Memory stats retrieved in {dataDuration} ms, content prepared in {contentDuration} ms. (Total {timer.ElapsedMilliseconds} ms)"); + } + catch (Exception e) + { + ContentData.Clear(); + ContentData["errorMessage"] = e.Message; + return; + } + } + + protected override string GetTemplatePath(WidgetPageState page) + { + return page switch + { + WidgetPageState.Content => @"DevHome\Templates\SystemMemoryTemplate.json", + WidgetPageState.Loading => @"DevHome\Templates\SystemMemoryTemplate.json", + _ => throw new NotImplementedException(), + }; + } + + public string GetItemTitle(bool isBandPage) + { + if (ContentData.TryGetValue("memUsage", out var usage)) + { + return isBandPage ? usage : string.Format(CultureInfo.CurrentCulture, Resources.GetResource("Memory_Usage_Label"), usage); + } + else + { + return isBandPage ? Resources.GetResource("Memory_Usage_Unknown") : Resources.GetResource("Memory_Usage_Unknown_Label"); + } + } + + private string MemUlongToString(ulong memBytes) + { + if (memBytes < 1024) + { + return memBytes.ToString(CultureInfo.InvariantCulture) + " B"; + } + + var memSize = memBytes / 1024.0; + if (memSize < 1024) + { + return memSize.ToString("0.00", CultureInfo.InvariantCulture) + " kB"; + } + + memSize /= 1024; + if (memSize < 1024) + { + return memSize.ToString("0.00", CultureInfo.InvariantCulture) + " MB"; + } + + memSize /= 1024; + return memSize.ToString("0.00", CultureInfo.InvariantCulture) + " GB"; + } + + internal override void PushActivate() + { + base.PushActivate(); + if (IsActive) + { + _dataManager.Start(); + } + } + + internal override void PopActivate() + { + base.PopActivate(); + if (!IsActive) + { + _dataManager.Stop(); + } + } + + public void Dispose() + { + _dataManager.Dispose(); + } +} + +internal sealed partial class SystemNetworkUsageWidgetPage : WidgetPage, IDisposable +{ + public override string Id => "com.microsoft.cmdpal.network_widget"; + + public override string Title => Resources.GetResource("Network_Usage_Title"); + + public override IconInfo Icon => Icons.NetworkIcon; + + private readonly DataManager _dataManager; + private int _networkIndex; + + public SystemNetworkUsageWidgetPage() + { + _dataManager = new(DataType.Network, () => UpdateWidget()); + Commands = [ + new CommandContextItem(new PrevNetworkCommand(this) { Name = Resources.GetResource("Previous_Network_Title") }), + new CommandContextItem(new NextNetworkCommand(this) { Name = Resources.GetResource("Next_Network_Title") }), + new CommandContextItem(OpenTaskManagerCommand.Instance), + ]; + } + + protected override void LoadContentData() + { + // CoreLogger.LogDebug("Getting Network stats"); + try + { + ContentData.Clear(); + + var timer = Stopwatch.StartNew(); + + var currentData = _dataManager.GetNetworkStats(); + + var dataDuration = timer.ElapsedMilliseconds; + + var netName = currentData.GetNetworkName(_networkIndex); + var networkStats = currentData.GetNetworkUsage(_networkIndex); + + ContentData["networkUsage"] = FloatToPercentString(networkStats.Usage); + ContentData["netSent"] = BytesToBitsPerSecString(networkStats.Sent); + ContentData["netReceived"] = BytesToBitsPerSecString(networkStats.Received); + ContentData["networkName"] = netName; + ContentData["netGraphUrl"] = currentData.CreateNetImageUrl(_networkIndex); + ContentData["chartHeight"] = ChartHelper.ChartHeight + "px"; + ContentData["chartWidth"] = ChartHelper.ChartWidth + "px"; + + var contentDuration = timer.ElapsedMilliseconds - dataDuration; + + // CoreLogger.LogDebug($"Network stats retrieved in {dataDuration} ms, content prepared in {contentDuration} ms. (Total {timer.ElapsedMilliseconds} ms)"); + } + catch (Exception e) + { + ContentData.Clear(); + ContentData["errorMessage"] = e.Message; + return; + } + } + + protected override string GetTemplatePath(WidgetPageState page) + { + return page switch + { + WidgetPageState.Content => @"DevHome\Templates\SystemNetworkUsageTemplate.json", + WidgetPageState.Loading => @"DevHome\Templates\SystemNetworkUsageTemplate.json", + _ => throw new NotImplementedException(), + }; + } + + public string GetItemTitle(bool isBandPage) + { + if (ContentData.TryGetValue("networkName", out var name) && ContentData.TryGetValue("networkUsage", out var usage)) + { + return isBandPage ? usage : string.Format(CultureInfo.CurrentCulture, Resources.GetResource("Network_Usage_Label"), name, usage); + } + else + { + return isBandPage ? Resources.GetResource("Network_Usage_Unknown") : Resources.GetResource("Network_Usage_Unknown_Label"); + } + } + + // up/down speed is always used for bands + public string GetUpSpeed() + { + if (ContentData.TryGetValue("netSent", out var upSpeed)) + { + return upSpeed; + } + else + { + return "???"; + } + } + + public string GetDownSpeed() + { + if (ContentData.TryGetValue("netReceived", out var downSpeed)) + { + return downSpeed; + } + else + { + return "???"; + } + } + + private string BytesToBitsPerSecString(float value) + { + // Bytes to bits + value *= 8; + + // bits to Kbits + value /= 1024; + if (value < 1024) + { + if (value < 100) + { + return string.Format(CultureInfo.InvariantCulture, "{0:0.0} Kbps", value); + } + + return string.Format(CultureInfo.InvariantCulture, "{0:0} Kbps", value); + } + + // Kbits to Mbits + value /= 1024; + if (value < 1024) + { + if (value < 100) + { + return string.Format(CultureInfo.InvariantCulture, "{0:0.0} Mbps", value); + } + + return string.Format(CultureInfo.InvariantCulture, "{0:0} Mbps", value); + } + + // Mbits to Gbits + value /= 1024; + if (value < 100) + { + return string.Format(CultureInfo.InvariantCulture, "{0:0.0} Gbps", value); + } + + return string.Format(CultureInfo.InvariantCulture, "{0:0} Gbps", value); + } + + internal override void PushActivate() + { + base.PushActivate(); + if (IsActive) + { + _dataManager.Start(); + } + } + + internal override void PopActivate() + { + base.PopActivate(); + if (!IsActive) + { + _dataManager.Stop(); + } + } + + private void HandlePrevNetwork() + { + _networkIndex = _dataManager.GetNetworkStats().GetPrevNetworkIndex(_networkIndex); + UpdateWidget(); + } + + private void HandleNextNetwork() + { + _networkIndex = _dataManager.GetNetworkStats().GetNextNetworkIndex(_networkIndex); + UpdateWidget(); + } + + public void Dispose() + { + _dataManager.Dispose(); + } + + private sealed partial class PrevNetworkCommand : InvokableCommand + { + private readonly SystemNetworkUsageWidgetPage _page; + + public PrevNetworkCommand(SystemNetworkUsageWidgetPage page) + { + _page = page; + } + + public override string Id => "com.microsoft.cmdpal.network_widget.prev"; + + public override IconInfo Icon => Icons.NavigateBackwardIcon; + + public override ICommandResult Invoke() + { + _page.HandlePrevNetwork(); + return CommandResult.KeepOpen(); + } + } + + private sealed partial class NextNetworkCommand : InvokableCommand + { + private readonly SystemNetworkUsageWidgetPage _page; + + public NextNetworkCommand(SystemNetworkUsageWidgetPage page) + { + _page = page; + } + + public override string Id => "com.microsoft.cmdpal.network_widget.next"; + + public override IconInfo Icon => Icons.NavigateForwardIcon; + + public override ICommandResult Invoke() + { + _page.HandleNextNetwork(); + return CommandResult.KeepOpen(); + } + } +} + +internal sealed partial class SystemGPUUsageWidgetPage : WidgetPage, IDisposable +{ + public override string Id => "com.microsoft.cmdpal.gpu_widget"; + + public override string Title => Resources.GetResource("GPU_Usage_Title"); + + public override IconInfo Icon => Icons.GpuIcon; + + private readonly DataManager _dataManager; + private readonly string _gpuActiveEngType = "3D"; + private int _gpuActiveIndex; + + public SystemGPUUsageWidgetPage() + { + _dataManager = new(DataType.GPU, () => UpdateWidget()); + + Commands = [ + new CommandContextItem(new PrevGPUCommand(this) { Name = Resources.GetResource("Previous_GPU_Title") }), + new CommandContextItem(new NextGPUCommand(this) { Name = Resources.GetResource("Next_GPU_Title") }), + new CommandContextItem(OpenTaskManagerCommand.Instance), + ]; + } + + protected override void LoadContentData() + { + // CoreLogger.LogDebug("Getting GPU stats"); + try + { + ContentData.Clear(); + + var timer = Stopwatch.StartNew(); + + var stats = _dataManager.GetGPUStats(); + + var dataDuration = timer.ElapsedMilliseconds; + + var gpuName = stats.GetGPUName(_gpuActiveIndex); + + ContentData["gpuUsage"] = FloatToPercentString(stats.GetGPUUsage(_gpuActiveIndex, _gpuActiveEngType)); + ContentData["gpuName"] = gpuName; + ContentData["gpuTemp"] = stats.GetGPUTemperature(_gpuActiveIndex); + ContentData["gpuGraphUrl"] = stats.CreateGPUImageUrl(_gpuActiveIndex); + ContentData["chartHeight"] = ChartHelper.ChartHeight + "px"; + ContentData["chartWidth"] = ChartHelper.ChartWidth + "px"; + + var contentDuration = timer.ElapsedMilliseconds - dataDuration; + + // CoreLogger.LogDebug($"GPU stats retrieved in {dataDuration} ms, content prepared in {contentDuration} ms. (Total {timer.ElapsedMilliseconds} ms)"); + } + catch (Exception e) + { + ContentData.Clear(); + ContentData["errorMessage"] = e.Message; + return; + } + } + + protected override string GetTemplatePath(WidgetPageState page) + { + return page switch + { + WidgetPageState.Content => @"DevHome\Templates\SystemGPUUsageTemplate.json", + WidgetPageState.Loading => @"DevHome\Templates\SystemGPUUsageTemplate.json", + _ => throw new NotImplementedException(), + }; + } + + public string GetItemTitle(bool isBandPage) + { + if (ContentData.TryGetValue("gpuName", out var name) && ContentData.TryGetValue("gpuUsage", out var usage)) + { + return isBandPage ? usage : string.Format(CultureInfo.CurrentCulture, Resources.GetResource("GPU_Usage_Label"), name, usage); + } + else + { + return isBandPage ? Resources.GetResource("GPU_Usage_Unknown") : Resources.GetResource("GPU_Usage_Unknown_Label"); + } + } + + internal override void PushActivate() + { + base.PushActivate(); + if (IsActive) + { + _dataManager.Start(); + } + } + + internal override void PopActivate() + { + base.PopActivate(); + if (!IsActive) + { + _dataManager.Stop(); + } + } + + private void HandlePrevGPU() + { + _gpuActiveIndex = _dataManager.GetGPUStats().GetPrevGPUIndex(_gpuActiveIndex); + UpdateWidget(); + } + + private void HandleNextGPU() + { + _gpuActiveIndex = _dataManager.GetGPUStats().GetNextGPUIndex(_gpuActiveIndex); + UpdateWidget(); + } + + public void Dispose() + { + _dataManager.Dispose(); + } + + private sealed partial class PrevGPUCommand : InvokableCommand + { + private readonly SystemGPUUsageWidgetPage _page; + + public PrevGPUCommand(SystemGPUUsageWidgetPage page) + { + _page = page; + } + + public override string Id => "com.microsoft.cmdpal.gpu_widget.prev"; + + public override IconInfo Icon => Icons.NavigateBackwardIcon; + + public override ICommandResult Invoke() + { + _page.HandlePrevGPU(); + return CommandResult.KeepOpen(); + } + } + + private sealed partial class NextGPUCommand : InvokableCommand + { + private readonly SystemGPUUsageWidgetPage _page; + + public NextGPUCommand(SystemGPUUsageWidgetPage page) + { + _page = page; + } + + public override string Id => "com.microsoft.cmdpal.gpu_widget.next"; + + public override IconInfo Icon => Icons.NavigateForwardIcon; + + public override ICommandResult Invoke() + { + _page.HandleNextGPU(); + return CommandResult.KeepOpen(); + } + } +} + +internal sealed partial class OpenTaskManagerCommand : InvokableCommand +{ + internal static readonly OpenTaskManagerCommand Instance = new(); + + public override string Id => "com.microsoft.cmdpal.open_task_manager"; + + public override IconInfo Icon => Icons.StackedAreaIcon; // StackedAreaIcon looks like task manager's icon + + public override string Name => Resources.GetResource("Open_Task_Manager_Title"); + + public override ICommandResult Invoke() + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "taskmgr.exe", + UseShellExecute = true, + }); + } + catch (Exception e) + { + CoreLogger.LogError("Error launching Task Manager.", e); + } + + return CommandResult.Hide(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Strings/en-US/Resources.resw b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Strings/en-US/Resources.resw new file mode 100644 index 0000000000..bcbbbfc001 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PerformanceMonitor/Strings/en-US/Resources.resw @@ -0,0 +1,253 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="Widget_Template.Loading" xml:space="preserve"> + <value>Loading...</value> + <comment>Shown in Widget, when loading config file content</comment> + </data> + <data name="Widget_Template_Tooltip.Submit" xml:space="preserve"> + <value>Submit</value> + <comment>Shown in Widget, Tooltip text</comment> + </data> + <data name="SSH_Widget_Template.Name" xml:space="preserve"> + <value>SSH keychain</value> + </data> + <data name="SSH_Widget_Template.Target" xml:space="preserve"> + <value>Local</value> + </data> + <data name="SSH_Widget_Template.ConfigFilePath" xml:space="preserve"> + <value>Config file path</value> + </data> + <data name="SSH_Widget_Template.ConfigFileNotFound" xml:space="preserve"> + <value>File not found</value> + </data> + <data name="SSH_Widget_Template.EmptyHosts" xml:space="preserve"> + <value>There are no hosts in this config file.</value> + </data> + <data name="SSH_Widget_Template.NumOfHosts" xml:space="preserve"> + <value>Number of hosts found</value> + </data> + <data name="SSH_Widget_Template.Connect" xml:space="preserve"> + <value>Connect</value> + </data> + <data name="SSH_Widget_Template.ErrorProcessingConfigFile" xml:space="preserve"> + <value>Processing config file failed</value> + </data> + <data name="Memory_Widget_Template.SystemMemory" xml:space="preserve"> + <value>System memory</value> + </data> + <data name="Memory_Widget_Template.MemoryUsage" xml:space="preserve"> + <value>Utilization</value> + </data> + <data name="Memory_Widget_Template.AllMemory" xml:space="preserve"> + <value>All memory</value> + </data> + <data name="Memory_Widget_Template.UsedMemory" xml:space="preserve"> + <value>In use (compressed)</value> + </data> + <data name="Memory_Widget_Template.Committed" xml:space="preserve"> + <value>Committed</value> + </data> + <data name="Memory_Widget_Template.Cached" xml:space="preserve"> + <value>Cached</value> + </data> + <data name="Memory_Widget_Template.NonPagedPool" xml:space="preserve"> + <value>Non-paged pool</value> + </data> + <data name="Memory_Widget_Template.PagedPool" xml:space="preserve"> + <value>Paged pool</value> + </data> + <data name="NetworkUsage_Widget_Template.Network_Usage" xml:space="preserve"> + <value>Utilization</value> + </data> + <data name="NetworkUsage_Widget_Template.Sent" xml:space="preserve"> + <value>Send</value> + </data> + <data name="NetworkUsage_Widget_Template.Received" xml:space="preserve"> + <value>Receive</value> + </data> + <data name="NetworkUsage_Widget_Template.Network_Name" xml:space="preserve"> + <value>Name</value> + </data> + <data name="Previous_Network_Title" xml:space="preserve"> + <value>Previous network</value> + </data> + <data name="Next_Network_Title" xml:space="preserve"> + <value>Next network</value> + </data> + <data name="NetworkUsage_Widget_Template.Ethernet_Heading" xml:space="preserve"> + <value>Ethernet</value> + </data> + <data name="GPUUsage_Widget_Template.GPU_Usage" xml:space="preserve"> + <value>Utilization</value> + </data> + <data name="GPUUsage_Widget_Template.GPU_Name" xml:space="preserve"> + <value>Name</value> + </data> + <data name="GPUUsage_Widget_Template.GPU_Temperature" xml:space="preserve"> + <value>Temperature</value> + </data> + <data name="Previous_GPU_Title" xml:space="preserve"> + <value>Previous GPU</value> + </data> + <data name="Next_GPU_Title" xml:space="preserve"> + <value>Next GPU</value> + </data> + <data name="CPUUsage_Widget_Template.CPU_Usage" xml:space="preserve"> + <value>Utilization</value> + </data> + <data name="CPUUsage_Widget_Template.CPU_Speed" xml:space="preserve"> + <value>Speed</value> + </data> + <data name="CPUUsage_Widget_Template.Processes" xml:space="preserve"> + <value>Processes</value> + </data> + <data name="CPUUsage_Widget_Template.End_Process" xml:space="preserve"> + <value>End process</value> + </data> + <data name="Widget_Template_Button.Preview" xml:space="preserve"> + <value>Preview</value> + <comment>Shown in Widget, Button text</comment> + </data> + <data name="Widget_Template_Button.Save" xml:space="preserve"> + <value>Save</value> + <comment>Shown in Widget, Button text</comment> + </data> + <data name="Widget_Template_Button.Cancel" xml:space="preserve"> + <value>Cancel</value> + <comment>Shown in Widget, Button text</comment> + </data> + <data name="CPU_Usage_Subtitle" xml:space="preserve"> + <value>CPU</value> + </data> + <data name="Memory_Usage_Subtitle" xml:space="preserve"> + <value>Memory</value> + </data> + <data name="Network_Usage_Subtitle" xml:space="preserve"> + <value>Network</value> + </data> + <data name="GPU_Usage_Subtitle" xml:space="preserve"> + <value>GPU</value> + </data> + <data name="Performance_Monitor_Title" xml:space="preserve"> + <value>Performance monitor</value> + </data> + <data name="CPU_Usage_Title" xml:space="preserve"> + <value>CPU Usage</value> + </data> + <data name="CPU_Usage_Label" xml:space="preserve"> + <value>CPU Usage: {0}</value> + <comment>{0} is the CPU usage percentage</comment> + </data> + <data name="CPU_Usage_Unknown" xml:space="preserve"> + <value>???</value> + </data> + <data name="CPU_Usage_Unknown_Label" xml:space="preserve"> + <value>CPU Usage: ???</value> + </data> + <data name="Memory_Usage_Title" xml:space="preserve"> + <value>Memory Usage</value> + </data> + <data name="Memory_Usage_Label" xml:space="preserve"> + <value>Memory Usage: {0}</value> + <comment>{0} is the memory usage percentage</comment> + </data> + <data name="Memory_Usage_Unknown" xml:space="preserve"> + <value>???</value> + </data> + <data name="Memory_Usage_Unknown_Label" xml:space="preserve"> + <value>Memory Usage: ???</value> + </data> + <data name="Network_Usage_Title" xml:space="preserve"> + <value>Network Usage</value> + </data> + <data name="Network_Usage_Label" xml:space="preserve"> + <value>Network ({0}): {1}</value> + <comment>{0} is the network adapter name, {1} is the usage percentage</comment> + </data> + <data name="Network_Usage_Unknown" xml:space="preserve"> + <value>???</value> + </data> + <data name="Network_Usage_Unknown_Label" xml:space="preserve"> + <value>Network Usage: ???</value> + </data> + <data name="GPU_Usage_Title" xml:space="preserve"> + <value>GPU Usage</value> + </data> + <data name="GPU_Usage_Label" xml:space="preserve"> + <value>GPU ({0}): {1}</value> + <comment>{0} is the GPU name, {1} is the usage percentage</comment> + </data> + <data name="GPU_Usage_Unknown" xml:space="preserve"> + <value>???</value> + </data> + <data name="GPU_Usage_Unknown_Label" xml:space="preserve"> + <value>GPU Usage: ???</value> + </data> + <data name="Open_Task_Manager_Title" xml:space="preserve"> + <value>Open Task Manager</value> + </data> + <data name="Network_Send_Subtitle" xml:space="preserve"> + <value>Send ↑</value> + </data> + <data name="Network_Receive_Subtitle" xml:space="preserve"> + <value>Receive ↓</value> + </data> +</root> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/AdvancedPaste/OpenAdvancedPasteCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/AdvancedPaste/OpenAdvancedPasteCommand.cs new file mode 100644 index 0000000000..4e738a7342 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/AdvancedPaste/OpenAdvancedPasteCommand.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; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Opens Advanced Paste UI by signaling the module's show event. +/// The DLL interface handles starting the process if it's not running. +/// </summary> +internal sealed partial class OpenAdvancedPasteCommand : InvokableCommand +{ + public OpenAdvancedPasteCommand() + { + Name = "Open Advanced Paste"; + } + + public override CommandResult Invoke() + { + try + { + using var showEvent = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.AdvancedPasteShowUIEvent()); + showEvent.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to open Advanced Paste: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Awake/RefreshAwakeStatusCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Awake/RefreshAwakeStatusCommand.cs new file mode 100644 index 0000000000..7327090fd3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Awake/RefreshAwakeStatusCommand.cs @@ -0,0 +1,26 @@ +// 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.CommandPalette.Extensions.Toolkit; + +namespace PowerToysExtension.Commands; + +internal sealed partial class RefreshAwakeStatusCommand : InvokableCommand +{ + private readonly Action _refreshAction; + + internal RefreshAwakeStatusCommand(Action refreshAction) + { + ArgumentNullException.ThrowIfNull(refreshAction); + _refreshAction = refreshAction; + Name = "Refresh Awake status"; + } + + public override CommandResult Invoke() + { + _refreshAction(); + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Awake/StartAwakeCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Awake/StartAwakeCommand.cs new file mode 100644 index 0000000000..d4695535ff --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Awake/StartAwakeCommand.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.ModuleContracts; + +namespace PowerToysExtension.Commands; + +internal sealed partial class StartAwakeCommand : InvokableCommand +{ + private readonly Func<Task<OperationResult>> _action; + private readonly string _successToast; + private readonly Action? _onSuccess; + + internal StartAwakeCommand(string title, Func<Task<OperationResult>> action, string successToast = "", Action? onSuccess = null) + { + ArgumentNullException.ThrowIfNull(action); + ArgumentException.ThrowIfNullOrWhiteSpace(title); + + _action = action; + _successToast = successToast ?? string.Empty; + _onSuccess = onSuccess; + Name = title; + } + + public override CommandResult Invoke() + { + try + { + var result = _action().GetAwaiter().GetResult(); + if (!result.Success) + { + return ShowToastKeepOpen(result.Error ?? "Failed to start Awake."); + } + + _onSuccess?.Invoke(); + + return string.IsNullOrWhiteSpace(_successToast) + ? CommandResult.KeepOpen() + : ShowToastKeepOpen(_successToast); + } + catch (Exception ex) + { + return ShowToastKeepOpen($"Launching Awake failed: {ex.Message}"); + } + } + + private static CommandResult ShowToastKeepOpen(string message) + { + return CommandResult.ShowToast(new ToastArgs() + { + Message = message, + Result = CommandResult.KeepOpen(), + }); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Awake/StopAwakeCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Awake/StopAwakeCommand.cs new file mode 100644 index 0000000000..426c039437 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Awake/StopAwakeCommand.cs @@ -0,0 +1,48 @@ +// 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 Awake.ModuleServices; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PowerToysExtension.Commands; + +internal sealed partial class StopAwakeCommand : InvokableCommand +{ + private readonly Action? _onSuccess; + + internal StopAwakeCommand(Action? onSuccess = null) + { + _onSuccess = onSuccess; + Name = "Set Awake to Off"; + } + + public override CommandResult Invoke() + { + try + { + var result = AwakeService.Instance.SetOffAsync().GetAwaiter().GetResult(); + if (result.Success) + { + _onSuccess?.Invoke(); + return ShowToastKeepOpen("Awake switched to Off."); + } + + return ShowToastKeepOpen(result.Error ?? "Awake does not appear to be running."); + } + catch (Exception ex) + { + return ShowToastKeepOpen($"Failed to switch Awake off: {ex.Message}"); + } + } + + private static CommandResult ShowToastKeepOpen(string message) + { + return CommandResult.ShowToast(new ToastArgs() + { + Message = message, + Result = CommandResult.KeepOpen(), + }); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ColorPicker/CopySavedColorCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ColorPicker/CopySavedColorCommand.cs new file mode 100644 index 0000000000..96b43a9a17 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ColorPicker/CopySavedColorCommand.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using ColorPicker.ModuleServices; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Copies a saved color in a chosen format. +/// </summary> +internal sealed partial class CopySavedColorCommand : InvokableCommand +{ + private readonly SavedColor _color; + private readonly string _copyValue; + + public CopySavedColorCommand(SavedColor color, string copyValue) + { + _color = color; + _copyValue = copyValue; + Name = $"Copy {_color.Hex}"; + } + + public override CommandResult Invoke() + { + try + { + ClipboardHelper.SetText(_copyValue); + + return CommandResult.ShowToast($"Copied {_copyValue}"); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to copy color: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ColorPicker/OpenColorPickerCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ColorPicker/OpenColorPickerCommand.cs new file mode 100644 index 0000000000..6982c5dffe --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ColorPicker/OpenColorPickerCommand.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using ColorPicker.ModuleServices; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Opens the Color Picker picker session via shared event. +/// </summary> +internal sealed partial class OpenColorPickerCommand : InvokableCommand +{ + public OpenColorPickerCommand() + { + Name = "Open Color Picker"; + } + + public override CommandResult Invoke() + { + try + { + var result = ColorPickerService.Instance.OpenPickerAsync().GetAwaiter().GetResult(); + if (!result.Success) + { + return CommandResult.ShowToast(result.Error ?? "Failed to open Color Picker."); + } + + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to open Color Picker: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/CropAndLock/CropAndLockReparentCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/CropAndLock/CropAndLockReparentCommand.cs new file mode 100644 index 0000000000..2c7bfe6868 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/CropAndLock/CropAndLockReparentCommand.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Triggers Crop and Lock reparent mode via the shared event. +/// </summary> +internal sealed partial class CropAndLockReparentCommand : InvokableCommand +{ + public CropAndLockReparentCommand() + { + Name = "Crop and Lock (Reparent)"; + } + + public override CommandResult Invoke() + { + Task.Run(async () => + { + await Task.Delay(500); + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.CropAndLockReparentEvent()); + evt.Set(); + } + catch + { + // Ignore errors after dismissing + } + }); + + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/CropAndLock/CropAndLockScreenshotCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/CropAndLock/CropAndLockScreenshotCommand.cs new file mode 100644 index 0000000000..1b6e295144 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/CropAndLock/CropAndLockScreenshotCommand.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Triggers Crop and Lock screenshot mode via the shared event. +/// </summary> +internal sealed partial class CropAndLockScreenshotCommand : InvokableCommand +{ + public CropAndLockScreenshotCommand() + { + Name = "Crop and Lock (Screenshot)"; + } + + public override CommandResult Invoke() + { + Task.Run(async () => + { + await Task.Delay(500); + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.CropAndLockScreenshotEvent()); + evt.Set(); + } + catch + { + // Ignore errors after dismissing + } + }); + + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/CropAndLock/CropAndLockThumbnailCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/CropAndLock/CropAndLockThumbnailCommand.cs new file mode 100644 index 0000000000..7b1ce62e56 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/CropAndLock/CropAndLockThumbnailCommand.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Triggers Crop and Lock thumbnail mode via the shared event. +/// </summary> +internal sealed partial class CropAndLockThumbnailCommand : InvokableCommand +{ + public CropAndLockThumbnailCommand() + { + Name = "Crop and Lock (Thumbnail)"; + } + + public override CommandResult Invoke() + { + Task.Run(async () => + { + await Task.Delay(500); + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.CropAndLockThumbnailEvent()); + evt.Set(); + } + catch + { + // Ignore errors after dismissing + } + }); + + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/EnvironmentVariables/OpenEnvironmentVariablesAdminCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/EnvironmentVariables/OpenEnvironmentVariablesAdminCommand.cs new file mode 100644 index 0000000000..6961783325 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/EnvironmentVariables/OpenEnvironmentVariablesAdminCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Launches Environment Variables (admin) via the shared event. +/// </summary> +internal sealed partial class OpenEnvironmentVariablesAdminCommand : InvokableCommand +{ + public OpenEnvironmentVariablesAdminCommand() + { + Name = "Open Environment Variables (Admin)"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowEnvironmentVariablesAdminSharedEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to open Environment Variables (Admin): {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/EnvironmentVariables/OpenEnvironmentVariablesCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/EnvironmentVariables/OpenEnvironmentVariablesCommand.cs new file mode 100644 index 0000000000..71bb81068d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/EnvironmentVariables/OpenEnvironmentVariablesCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Launches Environment Variables (user) via the shared event. +/// </summary> +internal sealed partial class OpenEnvironmentVariablesCommand : InvokableCommand +{ + public OpenEnvironmentVariablesCommand() + { + Name = "Open Environment Variables"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowEnvironmentVariablesSharedEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to open Environment Variables: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/ApplyFancyZonesLayoutCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/ApplyFancyZonesLayoutCommand.cs new file mode 100644 index 0000000000..b4ef1e55c9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/ApplyFancyZonesLayoutCommand.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 Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; + +namespace PowerToysExtension.Commands; + +internal sealed partial class ApplyFancyZonesLayoutCommand : InvokableCommand +{ + private readonly FancyZonesLayoutDescriptor _layout; + private readonly FancyZonesMonitorDescriptor? _targetMonitor; + + public ApplyFancyZonesLayoutCommand(FancyZonesLayoutDescriptor layout, FancyZonesMonitorDescriptor? monitor) + { + _layout = layout; + _targetMonitor = monitor; + + Name = monitor is null ? "Apply to all monitors" : $"Apply to Monitor {monitor.Value.Title}"; + + Icon = new IconInfo("\uF78C"); + } + + public override CommandResult Invoke() + { + var monitor = _targetMonitor; + var (success, message) = monitor is null + ? FancyZonesDataService.ApplyLayoutToAllMonitors(_layout) + : FancyZonesDataService.ApplyLayoutToMonitor(_layout, monitor.Value); + + return success + ? CommandResult.Dismiss() + : CommandResult.ShowToast(message); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/FancyZonesLayoutListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/FancyZonesLayoutListItem.cs new file mode 100644 index 0000000000..b2e7077fee --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/FancyZonesLayoutListItem.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; + +namespace PowerToysExtension.Pages; + +internal sealed partial class FancyZonesLayoutListItem : ListItem +{ + private readonly Lazy<Task<IconInfo?>> _iconLoadTask; + private readonly string _layoutId; + private readonly string _layoutTitle; + + private int _isLoadingIcon; + + public override IIconInfo? Icon + { + get + { + if (Interlocked.Exchange(ref _isLoadingIcon, 1) == 0) + { + _ = LoadIconAsync(); + } + + return base.Icon; + } + set => base.Icon = value; + } + + public FancyZonesLayoutListItem(ICommand command, FancyZonesLayoutDescriptor layout, IconInfo fallbackIcon) + : base(command) + { + Title = layout.Title; + Subtitle = layout.Subtitle; + Icon = fallbackIcon; + _layoutId = layout.Id; + _layoutTitle = layout.Title; + + _iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FancyZonesThumbnailRenderer.RenderLayoutIconAsync(layout)); + } + + private async Task LoadIconAsync() + { + try + { + Logger.LogDebug($"FancyZones layout icon load starting. LayoutId={_layoutId} Title=\"{_layoutTitle}\""); + var icon = await _iconLoadTask.Value; + if (icon is not null) + { + Icon = icon; + Logger.LogDebug($"FancyZones layout icon load succeeded. LayoutId={_layoutId} Title=\"{_layoutTitle}\""); + } + else + { + Logger.LogDebug($"FancyZones layout icon load returned null. LayoutId={_layoutId} Title=\"{_layoutTitle}\""); + } + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones layout icon load failed. LayoutId={_layoutId} Title=\"{_layoutTitle}\" Exception={ex}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/FancyZonesMonitorListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/FancyZonesMonitorListItem.cs new file mode 100644 index 0000000000..b8d6082ee4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/FancyZonesMonitorListItem.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; + +namespace PowerToysExtension.Pages; + +internal sealed partial class FancyZonesMonitorListItem : ListItem +{ + public FancyZonesMonitorListItem(FancyZonesMonitorDescriptor monitor, string subtitle, IconInfo icon) + : base(new IdentifyFancyZonesMonitorCommand(monitor)) + { + Title = monitor.Title; + Subtitle = subtitle; + Icon = icon; + + Details = BuildMonitorDetails(monitor); + + var pickerPage = new FancyZonesMonitorLayoutPickerPage(monitor) + { + Name = Resources.FancyZones_SetActiveLayout, + }; + + MoreCommands = + [ + new CommandContextItem(pickerPage) + { + Title = Resources.FancyZones_SetActiveLayout, + Subtitle = Resources.FancyZones_PickLayoutForMonitor, + }, + ]; + } + + public static Details BuildMonitorDetails(FancyZonesMonitorDescriptor monitor) + { + var currentVirtualDesktop = FancyZonesVirtualDesktop.GetCurrentVirtualDesktopIdString(); + + // Calculate physical resolution from logical pixels and DPI + var scaleFactor = monitor.Data.Dpi > 0 ? monitor.Data.Dpi / 96.0 : 1.0; + var physicalWidth = (int)Math.Round(monitor.Data.MonitorWidth * scaleFactor); + var physicalHeight = (int)Math.Round(monitor.Data.MonitorHeight * scaleFactor); + var resolution = $"{physicalWidth}\u00D7{physicalHeight}"; + + var tags = new List<IDetailsElement> + { + DetailTag(Resources.FancyZones_Monitor, monitor.Data.Monitor), + DetailTag(Resources.FancyZones_Number, monitor.Data.MonitorNumber.ToString(CultureInfo.InvariantCulture)), + DetailTag(Resources.FancyZones_VirtualDesktop, currentVirtualDesktop), + DetailTag(Resources.FancyZones_Resolution, resolution), + DetailTag(Resources.FancyZones_DPI, monitor.Data.Dpi.ToString(CultureInfo.InvariantCulture)), + }; + + return new Details + { + Title = monitor.Title, + HeroImage = FancyZonesMonitorPreviewRenderer.TryRenderMonitorHeroImage(monitor) ?? + PowerToysResourcesHelper.IconFromSettingsIcon("FancyZones.png"), + Metadata = tags.ToArray(), + }; + } + + private static DetailsElement DetailTag(string key, string? value) + { + return new DetailsElement + { + Key = key, + Data = new DetailsTags + { + Tags = [new Tag(string.IsNullOrWhiteSpace(value) ? Resources.Common_NotAvailable : value)], + }, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/IdentifyFancyZonesMonitorCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/IdentifyFancyZonesMonitorCommand.cs new file mode 100644 index 0000000000..6e4b8b45c5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/IdentifyFancyZonesMonitorCommand.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; + +namespace PowerToysExtension.Commands; + +internal sealed partial class IdentifyFancyZonesMonitorCommand : InvokableCommand +{ + private readonly FancyZonesMonitorDescriptor _monitor; + + public IdentifyFancyZonesMonitorCommand(FancyZonesMonitorDescriptor monitor) + { + _monitor = monitor; + Name = $"Identify {_monitor.Title}"; + Icon = new IconInfo("\uE773"); + } + + public override CommandResult Invoke() + { + if (!FancyZonesDataService.TryGetMonitors(out var monitors, out var error)) + { + return CommandResult.ShowToast(error); + } + + var monitor = monitors.FirstOrDefault(m => m.Data.MonitorInstanceId == _monitor.Data.MonitorInstanceId); + + if (monitor == null) + { + return CommandResult.ShowToast("Monitor not found."); + } + + FancyZonesMonitorIdentifier.Show( + monitor.Data.LeftCoordinate, + monitor.Data.TopCoordinate, + monitor.Data.WorkAreaWidth, + monitor.Data.WorkAreaHeight, + _monitor.Title, + durationMs: 1200); + + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/OpenFancyZonesEditorCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/OpenFancyZonesEditorCommand.cs new file mode 100644 index 0000000000..9376fba709 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/FancyZones/OpenFancyZonesEditorCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Launches the FancyZones layout editor via the shared event. +/// </summary> +internal sealed partial class OpenFancyZonesEditorCommand : InvokableCommand +{ + public OpenFancyZonesEditorCommand() + { + Name = "Open FancyZones Editor"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.FZEToggleEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to open FancyZones editor: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Hosts/OpenHostsEditorAdminCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Hosts/OpenHostsEditorAdminCommand.cs new file mode 100644 index 0000000000..63bd74d62a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Hosts/OpenHostsEditorAdminCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Launches Hosts File Editor (Admin) via the shared event. +/// </summary> +internal sealed partial class OpenHostsEditorAdminCommand : InvokableCommand +{ + public OpenHostsEditorAdminCommand() + { + Name = "Open Hosts File Editor (Admin)"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowHostsAdminSharedEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to open Hosts File Editor (Admin): {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Hosts/OpenHostsEditorCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Hosts/OpenHostsEditorCommand.cs new file mode 100644 index 0000000000..fdf5c807d0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Hosts/OpenHostsEditorCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Launches Hosts File Editor via the shared event. +/// </summary> +internal sealed partial class OpenHostsEditorCommand : InvokableCommand +{ + public OpenHostsEditorCommand() + { + Name = "Open Hosts File Editor"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowHostsSharedEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to open Hosts File Editor: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/LaunchModuleCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/LaunchModuleCommand.cs new file mode 100644 index 0000000000..3101a73030 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/LaunchModuleCommand.cs @@ -0,0 +1,121 @@ +// 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; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Launches a PowerToys module either by raising its shared event or starting the backing executable. +/// </summary> +internal sealed partial class LaunchModuleCommand : InvokableCommand +{ + private readonly string _moduleName; + private readonly string _eventName; + private readonly string _executableName; + private readonly string _arguments; + + internal LaunchModuleCommand( + string moduleName, + string eventName = "", + string executableName = "", + string arguments = "", + string displayName = "") + { + if (string.IsNullOrWhiteSpace(moduleName)) + { + throw new ArgumentException("Module name is required", nameof(moduleName)); + } + + _moduleName = moduleName; + _eventName = eventName ?? string.Empty; + _executableName = executableName ?? string.Empty; + _arguments = arguments ?? string.Empty; + Name = string.IsNullOrWhiteSpace(displayName) ? $"Launch {moduleName}" : displayName; + } + + public override CommandResult Invoke() + { + try + { + if (TrySignalEvent()) + { + return CommandResult.Hide(); + } + + if (TryLaunchExecutable()) + { + return CommandResult.Hide(); + } + + return CommandResult.ShowToast($"Unable to launch {_moduleName}."); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Launching {_moduleName} failed: {ex.Message}"); + } + } + + private bool TrySignalEvent() + { + if (string.IsNullOrEmpty(_eventName)) + { + return false; + } + + try + { + using var existingHandle = EventWaitHandle.OpenExisting(_eventName); + return existingHandle.Set(); + } + catch (WaitHandleCannotBeOpenedException) + { + try + { + using var newHandle = new EventWaitHandle(false, EventResetMode.AutoReset, _eventName, out _); + return newHandle.Set(); + } + catch + { + return false; + } + } + catch + { + return false; + } + } + + private bool TryLaunchExecutable() + { + if (string.IsNullOrEmpty(_executableName)) + { + return false; + } + + var executablePath = PowerToysPathResolver.TryResolveExecutable(_executableName); + if (string.IsNullOrEmpty(executablePath)) + { + return false; + } + + var startInfo = new ProcessStartInfo(executablePath) + { + UseShellExecute = true, + }; + + if (!string.IsNullOrWhiteSpace(_arguments)) + { + startInfo.Arguments = _arguments; + startInfo.UseShellExecute = false; + } + + Process.Start(startInfo); + return true; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/LightSwitch/ToggleLightSwitchCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/LightSwitch/ToggleLightSwitchCommand.cs new file mode 100644 index 0000000000..8702d7630a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/LightSwitch/ToggleLightSwitchCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Toggles Light Switch via the shared event. +/// </summary> +internal sealed partial class ToggleLightSwitchCommand : InvokableCommand +{ + public ToggleLightSwitchCommand() + { + Name = "Toggle Light Switch"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.LightSwitchToggleEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to toggle Light Switch: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ShowMouseJumpPreviewCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ShowMouseJumpPreviewCommand.cs new file mode 100644 index 0000000000..f0c3b9af32 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ShowMouseJumpPreviewCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Shows Mouse Jump preview via the shared event. +/// </summary> +internal sealed partial class ShowMouseJumpPreviewCommand : InvokableCommand +{ + public ShowMouseJumpPreviewCommand() + { + Name = "Show Mouse Jump Preview"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.MouseJumpShowPreviewEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to show Mouse Jump preview: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleCursorWrapCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleCursorWrapCommand.cs new file mode 100644 index 0000000000..9e6d9e6817 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleCursorWrapCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Toggles Cursor Wrap via the shared trigger event. +/// </summary> +internal sealed partial class ToggleCursorWrapCommand : InvokableCommand +{ + public ToggleCursorWrapCommand() + { + Name = "Toggle Cursor Wrap"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.CursorWrapTriggerEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to toggle Cursor Wrap: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleFindMyMouseCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleFindMyMouseCommand.cs new file mode 100644 index 0000000000..f8bb115789 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleFindMyMouseCommand.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; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Triggers Find My Mouse via the shared event. +/// </summary> +internal sealed partial class ToggleFindMyMouseCommand : InvokableCommand +{ + public ToggleFindMyMouseCommand() + { + Name = "Trigger Find My Mouse"; + } + + public override CommandResult Invoke() + { + // Delay the trigger so the Command Palette dismisses first + _ = Task.Run(async () => + { + await Task.Delay(200).ConfigureAwait(false); + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.FindMyMouseTriggerEvent()); + evt.Set(); + } + catch + { + // Ignore errors in background task + } + }); + + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleMouseCrosshairsCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleMouseCrosshairsCommand.cs new file mode 100644 index 0000000000..2209a60d58 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleMouseCrosshairsCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Toggles Mouse Pointer Crosshairs via the shared event. +/// </summary> +internal sealed partial class ToggleMouseCrosshairsCommand : InvokableCommand +{ + public ToggleMouseCrosshairsCommand() + { + Name = "Toggle Mouse Crosshairs"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.MouseCrosshairsTriggerEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to toggle Mouse Crosshairs: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleMouseHighlighterCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleMouseHighlighterCommand.cs new file mode 100644 index 0000000000..1485885723 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/MouseUtils/ToggleMouseHighlighterCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Toggles Mouse Highlighter via the shared event. +/// </summary> +internal sealed partial class ToggleMouseHighlighterCommand : InvokableCommand +{ + public ToggleMouseHighlighterCommand() + { + Name = "Toggle Mouse Highlighter"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.MouseHighlighterTriggerEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to toggle Mouse Highlighter: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/OpenInSettingsCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/OpenInSettingsCommand.cs new file mode 100644 index 0000000000..65860c249a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/OpenInSettingsCommand.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Common.UI; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Opens the PowerToys settings page for the given module via SettingsDeepLink. +/// </summary> +internal sealed partial class OpenInSettingsCommand : InvokableCommand +{ + private readonly SettingsDeepLink.SettingsWindow _module; + + internal OpenInSettingsCommand(SettingsDeepLink.SettingsWindow module, string title = "") + { + _module = module; + Name = string.IsNullOrWhiteSpace(title) ? $"Open {_module} settings" : title; + } + + public override CommandResult Invoke() + { + SettingsDeepLink.OpenSettings(_module); + return CommandResult.Hide(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/OpenPowerToysSettingsCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/OpenPowerToysSettingsCommand.cs new file mode 100644 index 0000000000..bd865fcab1 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/OpenPowerToysSettingsCommand.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. + +using System; +using System.Diagnostics; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Opens the PowerToys settings application deep linked to a specific module. +/// </summary> +internal sealed partial class OpenPowerToysSettingsCommand : InvokableCommand +{ + private readonly string _moduleName; + private readonly string _settingsKey; + + internal OpenPowerToysSettingsCommand(string moduleName, string settingsKey) + { + if (string.IsNullOrWhiteSpace(moduleName)) + { + throw new ArgumentException("Module name is required", nameof(moduleName)); + } + + if (string.IsNullOrWhiteSpace(settingsKey)) + { + throw new ArgumentException("Settings key is required", nameof(settingsKey)); + } + + _moduleName = moduleName; + _settingsKey = settingsKey; + Name = $"Open {_moduleName} settings"; + } + + public override CommandResult Invoke() + { + try + { + var powerToysPath = PowerToysPathResolver.TryResolveExecutable("PowerToys.exe"); + if (string.IsNullOrEmpty(powerToysPath)) + { + return CommandResult.ShowToast("Unable to locate PowerToys."); + } + + var startInfo = new ProcessStartInfo(powerToysPath) + { + Arguments = $"--open-settings={_settingsKey}", + UseShellExecute = false, + }; + + Process.Start(startInfo); + return CommandResult.Hide(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Opening {_moduleName} settings failed: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/RegistryPreview/OpenRegistryPreviewCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/RegistryPreview/OpenRegistryPreviewCommand.cs new file mode 100644 index 0000000000..6df382256f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/RegistryPreview/OpenRegistryPreviewCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Launches Registry Preview via the shared event. +/// </summary> +internal sealed partial class OpenRegistryPreviewCommand : InvokableCommand +{ + public OpenRegistryPreviewCommand() + { + Name = "Open Registry Preview"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.RegistryPreviewTriggerEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to open Registry Preview: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ScreenRuler/ToggleScreenRulerCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ScreenRuler/ToggleScreenRulerCommand.cs new file mode 100644 index 0000000000..889cb5d7b9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ScreenRuler/ToggleScreenRulerCommand.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; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +internal sealed partial class ToggleScreenRulerCommand : InvokableCommand +{ + public ToggleScreenRulerCommand() + { + Name = "Toggle Screen Ruler"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.MeasureToolTriggerEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to toggle Screen Ruler: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ShortcutGuide/ToggleShortcutGuideCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ShortcutGuide/ToggleShortcutGuideCommand.cs new file mode 100644 index 0000000000..4c6d056eaf --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ShortcutGuide/ToggleShortcutGuideCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Toggles the Shortcut Guide UI via the shared trigger event. +/// </summary> +internal sealed partial class ToggleShortcutGuideCommand : InvokableCommand +{ + public ToggleShortcutGuideCommand() + { + Name = "Toggle Shortcut Guide"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShortcutGuideTriggerEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to toggle Shortcut Guide: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/TextExtractor/ToggleTextExtractorCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/TextExtractor/ToggleTextExtractorCommand.cs new file mode 100644 index 0000000000..615fb0e395 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/TextExtractor/ToggleTextExtractorCommand.cs @@ -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. + +using System; +using System.Threading; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +/// <summary> +/// Triggers the Text Extractor UI via the existing show event. +/// </summary> +internal sealed partial class ToggleTextExtractorCommand : InvokableCommand +{ + public ToggleTextExtractorCommand() + { + Name = "Toggle Text Extractor"; + } + + public override CommandResult Invoke() + { + try + { + using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowPowerOCRSharedEvent()); + evt.Set(); + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to toggle Text Extractor: {ex.Message}"); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Workspaces/LaunchWorkspaceCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Workspaces/LaunchWorkspaceCommand.cs new file mode 100644 index 0000000000..4372e5b7ff --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Workspaces/LaunchWorkspaceCommand.cs @@ -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. + +using Microsoft.CommandPalette.Extensions.Toolkit; +using Workspaces.ModuleServices; + +namespace PowerToysExtension.Commands; + +internal sealed partial class LaunchWorkspaceCommand : InvokableCommand +{ + private readonly string _workspaceId; + + internal LaunchWorkspaceCommand(string workspaceId) + { + _workspaceId = workspaceId; + Name = "Launch workspace"; + } + + public override CommandResult Invoke() + { + if (string.IsNullOrEmpty(_workspaceId)) + { + return CommandResult.KeepOpen(); + } + + var result = WorkspaceService.Instance.LaunchWorkspaceAsync(_workspaceId).GetAwaiter().GetResult(); + if (!result.Success) + { + return CommandResult.ShowToast(result.Error ?? "Launching workspace failed."); + } + + return CommandResult.Hide(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Workspaces/OpenWorkspaceEditorCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Workspaces/OpenWorkspaceEditorCommand.cs new file mode 100644 index 0000000000..63150902a6 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Workspaces/OpenWorkspaceEditorCommand.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; +using Workspaces.ModuleServices; + +namespace PowerToysExtension.Commands; + +internal sealed partial class OpenWorkspaceEditorCommand : InvokableCommand +{ + public override CommandResult Invoke() + { + var result = WorkspaceService.Instance.LaunchEditorAsync().GetAwaiter().GetResult(); + if (!result.Success) + { + return CommandResult.ShowToast(result.Error ?? "Unable to launch the Workspaces editor."); + } + + return CommandResult.Hide(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Workspaces/WorkspaceListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Workspaces/WorkspaceListItem.cs new file mode 100644 index 0000000000..de1409e823 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/Workspaces/WorkspaceListItem.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using WorkspacesCsharpLibrary.Data; + +namespace PowerToysExtension.Commands; + +internal sealed partial class WorkspaceListItem : ListItem +{ + private static readonly CompositeFormat ApplicationsFormat = CompositeFormat.Parse(Resources.Workspaces_Applications_Format); + private static readonly CompositeFormat LastLaunchedFormat = CompositeFormat.Parse(Resources.Workspaces_LastLaunched_Format); + private static readonly CompositeFormat ApplicationsCountFormat = CompositeFormat.Parse(Resources.Workspaces_ApplicationsCount_Format); + private static readonly CompositeFormat MinAgoFormat = CompositeFormat.Parse(Resources.Workspaces_MinAgo_Format); + private static readonly CompositeFormat HrAgoFormat = CompositeFormat.Parse(Resources.Workspaces_HrAgo_Format); + private static readonly CompositeFormat DaysAgoFormat = CompositeFormat.Parse(Resources.Workspaces_DaysAgo_Format); + + public WorkspaceListItem(ProjectWrapper workspace, IconInfo icon) + : base(new LaunchWorkspaceCommand(workspace.Id)) + { + Title = workspace.Name; + Subtitle = BuildSubtitle(workspace); + Icon = icon; + Details = BuildDetails(workspace, icon); + } + + private static string BuildSubtitle(ProjectWrapper workspace) + { + var appCount = workspace.Applications?.Count ?? 0; + var appsText = appCount switch + { + 0 => Resources.Workspaces_NoApplications, + _ => string.Format(CultureInfo.CurrentCulture, ApplicationsFormat, appCount), + }; + + var lastLaunched = workspace.LastLaunchedTime > 0 + ? string.Format(CultureInfo.CurrentCulture, LastLaunchedFormat, FormatRelativeTime(workspace.LastLaunchedTime)) + : Resources.Workspaces_NeverLaunched; + + return $"{appsText} \u2022 {lastLaunched}"; + } + + private static Details BuildDetails(ProjectWrapper workspace, IconInfo icon) + { + var appCount = workspace.Applications?.Count ?? 0; + var body = appCount switch + { + 0 => Resources.Workspaces_NoApplicationsInWorkspace, + 1 => Resources.Workspaces_OneApplication, + _ => string.Format(CultureInfo.CurrentCulture, ApplicationsCountFormat, appCount), + }; + + return new Details + { + HeroImage = icon, + Title = workspace.Name ?? Resources.Workspaces_Workspace, + Body = body, + Metadata = BuildAppMetadata(workspace), + }; + } + + private static IDetailsElement[] BuildAppMetadata(ProjectWrapper workspace) + { + if (workspace.Applications is null || workspace.Applications.Count == 0) + { + return Array.Empty<IDetailsElement>(); + } + + var elements = new List<IDetailsElement>(); + foreach (var app in workspace.Applications) + { + var appName = string.IsNullOrWhiteSpace(app.Application) ? Resources.Workspaces_App : app.Application; + var title = string.IsNullOrWhiteSpace(app.Title) ? appName : app.Title; + + var tags = new List<ITag>(); + + if (!string.IsNullOrWhiteSpace(app.ApplicationPath)) + { + tags.Add(new Tag(app.ApplicationPath)); + } + else + { + tags.Add(new Tag(appName)); + } + + elements.Add(new DetailsElement + { + Key = title, + Data = new DetailsTags { Tags = tags.ToArray() }, + }); + } + + return elements.ToArray(); + } + + private static string FormatRelativeTime(long unixSeconds) + { + var lastLaunch = DateTimeOffset.FromUnixTimeSeconds(unixSeconds).UtcDateTime; + var delta = DateTime.UtcNow - lastLaunch; + + if (delta.TotalMinutes < 1) + { + return Resources.Workspaces_JustNow; + } + + if (delta.TotalMinutes < 60) + { + return string.Format(CultureInfo.CurrentCulture, MinAgoFormat, (int)delta.TotalMinutes); + } + + if (delta.TotalHours < 24) + { + return string.Format(CultureInfo.CurrentCulture, HrAgoFormat, (int)delta.TotalHours); + } + + return string.Format(CultureInfo.CurrentCulture, DaysAgoFormat, (int)delta.TotalDays); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ZoomIt/ZoomItActionCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ZoomIt/ZoomItActionCommand.cs new file mode 100644 index 0000000000..44b5ea447a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Commands/ZoomIt/ZoomItActionCommand.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; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToys.Interop; + +namespace PowerToysExtension.Commands; + +internal sealed partial class ZoomItActionCommand : InvokableCommand +{ + private readonly string _action; + private readonly string _title; + + public ZoomItActionCommand(string action, string title) + { + _action = action; + _title = title; + Name = title; + } + + public override CommandResult Invoke() + { + try + { + if (!TryGetEventName(_action, out var eventName)) + { + return CommandResult.ShowToast($"Unknown ZoomIt action: {_action}."); + } + + var evt = EventWaitHandle.OpenExisting(eventName); + _ = Task.Run(async () => + { + using (evt) + { + // Hide CmdPal first, then signal shortly after so UI like snip/zoom won't capture it. + await Task.Delay(50).ConfigureAwait(false); + evt.Set(); + } + }); + + return CommandResult.Hide(); + } + catch (WaitHandleCannotBeOpenedException) + { + return CommandResult.ShowToast("ZoomIt is not running. Please start it from PowerToys and try again."); + } + catch (Exception ex) + { + return CommandResult.ShowToast($"Failed to invoke ZoomIt ({_title}): {ex.Message}"); + } + } + + private static bool TryGetEventName(string action, out string eventName) + { + switch (action.ToLowerInvariant()) + { + case "zoom": + eventName = Constants.ZoomItZoomEvent(); + return true; + case "draw": + eventName = Constants.ZoomItDrawEvent(); + return true; + case "break": + eventName = Constants.ZoomItBreakEvent(); + return true; + case "livezoom": + eventName = Constants.ZoomItLiveZoomEvent(); + return true; + case "snip": + eventName = Constants.ZoomItSnipEvent(); + return true; + case "record": + eventName = Constants.ZoomItRecordEvent(); + return true; + default: + eventName = string.Empty; + return false; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/AwakeStatusService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/AwakeStatusService.cs new file mode 100644 index 0000000000..102c9e90fc --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/AwakeStatusService.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Awake.ModuleServices; +using Common.UI; + +namespace PowerToysExtension.Helpers; + +internal static class AwakeStatusService +{ + internal static string GetStatusSubtitle() + { + var state = AwakeService.Instance.GetCurrentState(); + if (!state.IsRunning) + { + return "Awake is idle"; + } + + if (state.Mode == AwakeStateMode.Passive) + { + // When the PowerToys Awake module is enabled, the Awake process stays resident + // even in passive mode. In that case "idle" is correct. If the module is disabled, + // a running process implies a standalone/session keep-awake, so report as active. + return ModuleEnablementService.IsModuleEnabled(SettingsDeepLink.SettingsWindow.Awake) + ? "Awake is idle" + : "Active - session running"; + } + + return state.Mode switch + { + AwakeStateMode.Indefinite => "Active - indefinite", + AwakeStateMode.Timed => state.Duration is { } span + ? $"Active - timer {FormatDuration(span)}" + : "Active - timer", + AwakeStateMode.Expirable => state.Expiration is { } expiry + ? $"Active - until {expiry.ToLocalTime():t}" + : "Active - scheduled", + _ => "Awake is running", + }; + } + + private static string FormatDuration(TimeSpan span) + { + if (span.TotalHours >= 1) + { + var hours = (int)Math.Floor(span.TotalHours); + var minutes = span.Minutes; + return minutes > 0 ? $"{hours}h {minutes}m" : $"{hours}h"; + } + + if (span.TotalMinutes >= 1) + { + return $"{(int)Math.Round(span.TotalMinutes)}m"; + } + + return span.TotalSeconds >= 1 ? $"{(int)Math.Round(span.TotalSeconds)}s" : "\u2014"; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/ColorSwatchIconFactory.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/ColorSwatchIconFactory.cs new file mode 100644 index 0000000000..3fde50882f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/ColorSwatchIconFactory.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.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Storage.Streams; + +namespace PowerToysExtension.Helpers; + +internal static class ColorSwatchIconFactory +{ + public static IconInfo Create(byte r, byte g, byte b, byte a) + { + try + { + using var bmp = new Bitmap(32, 32, PixelFormat.Format32bppArgb); + using var gfx = Graphics.FromImage(bmp); + gfx.SmoothingMode = SmoothingMode.AntiAlias; + gfx.Clear(Color.Transparent); + + using var brush = new SolidBrush(Color.FromArgb(a, r, g, b)); + const int padding = 4; + gfx.FillEllipse(brush, padding, padding, bmp.Width - (padding * 2), bmp.Height - (padding * 2)); + + using var ms = new MemoryStream(); + bmp.Save(ms, ImageFormat.Png); + var ras = new InMemoryRandomAccessStream(); + var writer = new DataWriter(ras); + writer.WriteBytes(ms.ToArray()); + writer.StoreAsync().GetResults(); + ras.Seek(0); + return IconInfo.FromStream(ras); + } + catch + { + // Fallback to a simple colored glyph when drawing fails. + return new IconInfo("\u25CF"); // Black circle glyph + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesDataService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesDataService.cs new file mode 100644 index 0000000000..d81a6f9e11 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesDataService.cs @@ -0,0 +1,543 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; +using ManagedCommon; +using PowerToysExtension.Properties; + +using FZPaths = FancyZonesEditorCommon.Data.FancyZonesPaths; + +namespace PowerToysExtension.Helpers; + +internal static class FancyZonesDataService +{ + private const string ZeroUuid = "{00000000-0000-0000-0000-000000000000}"; + + private static readonly CompositeFormat ReadMonitorDataFailedFormat = CompositeFormat.Parse(Resources.FancyZones_ReadMonitorDataFailed_Format); + private static readonly CompositeFormat WriteAppliedLayoutsFailedFormat = CompositeFormat.Parse(Resources.FancyZones_WriteAppliedLayoutsFailed_Format); + private static readonly CompositeFormat LayoutAppliedNotifyFailedFormat = CompositeFormat.Parse(Resources.FancyZones_LayoutAppliedNotifyFailed_Format); + private static readonly CompositeFormat TemplateFormat = CompositeFormat.Parse(Resources.FancyZones_Template_Format); + private static readonly CompositeFormat ZonesFormat = CompositeFormat.Parse(Resources.FancyZones_Zones_Format); + private static readonly CompositeFormat CustomGridZonesFormat = CompositeFormat.Parse(Resources.FancyZones_CustomGrid_Zones_Format); + private static readonly CompositeFormat CustomCanvasZonesFormat = CompositeFormat.Parse(Resources.FancyZones_CustomCanvas_Zones_Format); + private static readonly CompositeFormat CustomZonesFormat = CompositeFormat.Parse(Resources.FancyZones_Custom_Zones_Format); + + public static bool TryGetMonitors(out IReadOnlyList<FancyZonesMonitorDescriptor> monitors, out string error) + { + monitors = Array.Empty<FancyZonesMonitorDescriptor>(); + error = string.Empty; + + Logger.LogInfo($"TryGetMonitors: Starting. EditorParametersPath={FZPaths.EditorParameters}"); + + try + { + // Request FancyZones to save current monitor configuration. + // The editor-parameters.json file is only written when: + // 1. Opening the FancyZones Editor + // 2. Receiving the WM_PRIV_SAVE_EDITOR_PARAMETERS message + // Without this, monitor changes (plug/unplug) won't be reflected in the file. + var editorParams = ReadEditorParametersWithRefresh(); + Logger.LogInfo($"TryGetMonitors: ReadEditorParametersWithRefreshWithRefresh returned. Monitors={editorParams.Monitors?.Count ?? -1}"); + + var editorMonitors = editorParams.Monitors; + if (editorMonitors is null || editorMonitors.Count == 0) + { + error = Resources.FancyZones_NoFancyZonesMonitorsFound; + Logger.LogWarning($"TryGetMonitors: No monitors in file."); + return false; + } + + monitors = editorMonitors + .Select((monitor, i) => new FancyZonesMonitorDescriptor(i + 1, monitor)) + .ToArray(); + Logger.LogInfo($"TryGetMonitors: Succeeded. MonitorCount={monitors.Count}"); + return true; + } + catch (Exception ex) + { + error = string.Format(CultureInfo.CurrentCulture, ReadMonitorDataFailedFormat, ex.Message); + Logger.LogError($"TryGetMonitors: Exception. Message={ex.Message} Stack={ex.StackTrace}"); + return false; + } + } + + /// <summary> + /// Requests FancyZones to save the current monitor configuration and reads the file. + /// This is a best-effort approach for performance: we send the save request and immediately + /// read the file without waiting. If the file hasn't been updated yet, the next call will + /// see the updated data since FancyZones processes the message asynchronously. + /// </summary> + private static EditorParameters.ParamsWrapper ReadEditorParametersWithRefresh() + { + // Request FancyZones to save the current monitor configuration. + // This is fire-and-forget for performance - we don't wait for the save to complete. + // If this is the first call after a monitor change, we may read stale data, but the + // next call will see the updated file since FancyZones will have processed the message. + FancyZonesNotifier.NotifySaveEditorParameters(); + + return FancyZonesDataIO.ReadEditorParameters(); + } + + public static IReadOnlyList<FancyZonesLayoutDescriptor> GetLayouts() + { + Logger.LogInfo($"GetLayouts: Starting. LayoutTemplatesPath={FZPaths.LayoutTemplates} CustomLayoutsPath={FZPaths.CustomLayouts}"); + var layouts = new List<FancyZonesLayoutDescriptor>(); + try + { + var templates = GetTemplateLayouts().ToArray(); + Logger.LogInfo($"GetLayouts: GetTemplateLayouts returned {templates.Length} layouts"); + layouts.AddRange(templates); + } + catch (Exception ex) + { + Logger.LogError($"GetLayouts: GetTemplateLayouts failed. Message={ex.Message} Stack={ex.StackTrace}"); + } + + try + { + var customLayouts = GetCustomLayouts().ToArray(); + Logger.LogInfo($"GetLayouts: GetCustomLayouts returned {customLayouts.Length} layouts"); + layouts.AddRange(customLayouts); + } + catch (Exception ex) + { + Logger.LogError($"GetLayouts: GetCustomLayouts failed. Message={ex.Message} Stack={ex.StackTrace}"); + } + + Logger.LogInfo($"GetLayouts: Total layouts={layouts.Count}"); + return layouts; + } + + public static bool TryGetAppliedLayoutForMonitor(EditorParameters.NativeMonitorDataWrapper monitor, out AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper? appliedLayout) + => TryGetAppliedLayoutForMonitor(monitor, FancyZonesVirtualDesktop.GetCurrentVirtualDesktopIdString(), out appliedLayout); + + public static bool TryGetAppliedLayoutForMonitor(EditorParameters.NativeMonitorDataWrapper monitor, string virtualDesktopId, out AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper? appliedLayout) + { + appliedLayout = null; + + if (!TryReadAppliedLayouts(out var file)) + { + return false; + } + + var match = FindAppliedLayoutEntry(file, monitor, virtualDesktopId); + if (match is not null) + { + appliedLayout = match.Value.AppliedLayout; + return true; + } + + return false; + } + + public static (bool Success, string Message) ApplyLayoutToAllMonitors(FancyZonesLayoutDescriptor layout) + { + if (!TryGetMonitors(out var monitors, out var error)) + { + return (false, error); + } + + return ApplyLayoutToMonitors(layout, monitors.Select(m => m.Data)); + } + + public static (bool Success, string Message) ApplyLayoutToMonitor(FancyZonesLayoutDescriptor layout, FancyZonesMonitorDescriptor monitor) + { + if (!TryGetMonitors(out var monitors, out var error)) + { + return (false, error); + } + + EditorParameters.NativeMonitorDataWrapper? monitorData = null; + foreach (var candidate in monitors) + { + if (candidate.Data.MonitorInstanceId == monitor.Data.MonitorInstanceId) + { + monitorData = candidate.Data; + break; + } + } + + if (monitorData is null) + { + return (false, "Monitor not found."); + } + + return ApplyLayoutToMonitors(layout, [monitorData.Value]); + } + + private static (bool Success, string Message) ApplyLayoutToMonitors(FancyZonesLayoutDescriptor layout, IEnumerable<EditorParameters.NativeMonitorDataWrapper> monitors) + { + AppliedLayouts.AppliedLayoutsListWrapper appliedFile; + if (!TryReadAppliedLayouts(out var existingFile)) + { + appliedFile = new AppliedLayouts.AppliedLayoutsListWrapper { AppliedLayouts = new List<AppliedLayouts.AppliedLayoutWrapper>() }; + } + else + { + appliedFile = existingFile; + } + + appliedFile.AppliedLayouts ??= new List<AppliedLayouts.AppliedLayoutWrapper>(); + + var currentVirtualDesktop = FancyZonesVirtualDesktop.GetCurrentVirtualDesktopIdString(); + + foreach (var monitor in monitors) + { + var existingEntry = FindAppliedLayoutEntry(appliedFile, monitor, currentVirtualDesktop); + if (existingEntry is not null) + { + // Remove the existing entry so we can add a new one + appliedFile.AppliedLayouts.Remove(existingEntry.Value); + } + + var newEntry = new AppliedLayouts.AppliedLayoutWrapper + { + Device = new AppliedLayouts.AppliedLayoutWrapper.DeviceIdWrapper + { + Monitor = monitor.Monitor, + MonitorInstance = monitor.MonitorInstanceId ?? string.Empty, + SerialNumber = monitor.MonitorSerialNumber ?? string.Empty, + MonitorNumber = monitor.MonitorNumber, + VirtualDesktop = currentVirtualDesktop, + }, + AppliedLayout = new AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper + { + Uuid = layout.ApplyLayout.Uuid, + Type = layout.ApplyLayout.Type, + ZoneCount = layout.ApplyLayout.ZoneCount, + ShowSpacing = layout.ApplyLayout.ShowSpacing, + Spacing = layout.ApplyLayout.Spacing, + SensitivityRadius = layout.ApplyLayout.SensitivityRadius, + }, + }; + + appliedFile.AppliedLayouts.Add(newEntry); + } + + try + { + FancyZonesDataIO.WriteAppliedLayouts(appliedFile); + } + catch (Exception ex) + { + return (false, string.Format(CultureInfo.CurrentCulture, WriteAppliedLayoutsFailedFormat, ex.Message)); + } + + try + { + FancyZonesNotifier.NotifyAppliedLayoutsChanged(); + } + catch (Exception ex) + { + return (true, string.Format(CultureInfo.CurrentCulture, LayoutAppliedNotifyFailedFormat, ex.Message)); + } + + return (true, Resources.FancyZones_LayoutApplied); + } + + private static AppliedLayouts.AppliedLayoutWrapper? FindAppliedLayoutEntry(AppliedLayouts.AppliedLayoutsListWrapper file, EditorParameters.NativeMonitorDataWrapper monitor, string virtualDesktopId) + { + if (file.AppliedLayouts is null) + { + return null; + } + + return file.AppliedLayouts.FirstOrDefault(e => + string.Equals(e.Device.Monitor, monitor.Monitor, StringComparison.OrdinalIgnoreCase) && + string.Equals(e.Device.MonitorInstance ?? string.Empty, monitor.MonitorInstanceId ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + string.Equals(e.Device.SerialNumber ?? string.Empty, monitor.MonitorSerialNumber ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + e.Device.MonitorNumber == monitor.MonitorNumber && + string.Equals(e.Device.VirtualDesktop, virtualDesktopId, StringComparison.OrdinalIgnoreCase)); + } + + private static bool TryReadAppliedLayouts(out AppliedLayouts.AppliedLayoutsListWrapper file) + { + file = default; + try + { + if (!File.Exists(FZPaths.AppliedLayouts)) + { + return false; + } + + file = FancyZonesDataIO.ReadAppliedLayouts(); + return true; + } + catch + { + return false; + } + } + + private static IEnumerable<FancyZonesLayoutDescriptor> GetTemplateLayouts() + { + Logger.LogInfo($"GetTemplateLayouts: Starting. Path={FZPaths.LayoutTemplates} Exists={File.Exists(FZPaths.LayoutTemplates)}"); + + LayoutTemplates.TemplateLayoutsListWrapper templates; + try + { + if (!File.Exists(FZPaths.LayoutTemplates)) + { + Logger.LogWarning($"GetTemplateLayouts: File not found."); + yield break; + } + + templates = FancyZonesDataIO.ReadLayoutTemplates(); + Logger.LogInfo($"GetTemplateLayouts: ReadLayoutTemplates succeeded. Count={templates.LayoutTemplates?.Count ?? -1}"); + } + catch (Exception ex) + { + Logger.LogError($"GetTemplateLayouts: ReadLayoutTemplates failed. Message={ex.Message} Stack={ex.StackTrace}"); + yield break; + } + + var templateLayouts = templates.LayoutTemplates; + if (templateLayouts is null) + { + Logger.LogWarning($"GetTemplateLayouts: LayoutTemplates is null."); + yield break; + } + + foreach (var template in templateLayouts) + { + if (string.IsNullOrWhiteSpace(template.Type)) + { + continue; + } + + var type = template.Type.Trim(); + var zoneCount = type.Equals("blank", StringComparison.OrdinalIgnoreCase) + ? 0 + : template.ZoneCount > 0 ? template.ZoneCount : 3; + var title = string.Format(CultureInfo.CurrentCulture, TemplateFormat, type); + var subtitle = string.Format(CultureInfo.CurrentCulture, ZonesFormat, zoneCount); + + yield return new FancyZonesLayoutDescriptor + { + Id = $"template:{type.ToLowerInvariant()}", + Source = FancyZonesLayoutSource.Template, + Title = title, + Subtitle = subtitle, + Template = template, + ApplyLayout = new AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper + { + Type = type.ToLowerInvariant(), + Uuid = ZeroUuid, + ZoneCount = zoneCount, + ShowSpacing = template.ShowSpacing, + Spacing = template.Spacing, + SensitivityRadius = template.SensitivityRadius, + }, + }; + } + } + + private static IEnumerable<FancyZonesLayoutDescriptor> GetCustomLayouts() + { + CustomLayouts.CustomLayoutListWrapper customLayouts; + try + { + if (!File.Exists(FZPaths.CustomLayouts)) + { + yield break; + } + + customLayouts = FancyZonesDataIO.ReadCustomLayouts(); + } + catch + { + yield break; + } + + var layouts = customLayouts.CustomLayouts; + if (layouts is null) + { + yield break; + } + + foreach (var custom in layouts) + { + if (string.IsNullOrWhiteSpace(custom.Uuid) || string.IsNullOrWhiteSpace(custom.Name)) + { + continue; + } + + var uuid = custom.Uuid.Trim(); + var customType = custom.Type?.Trim().ToLowerInvariant() ?? string.Empty; + + if (!TryBuildAppliedLayoutForCustom(custom, out var applied)) + { + continue; + } + + var title = custom.Name.Trim(); + var subtitle = customType switch + { + "grid" => string.Format(CultureInfo.CurrentCulture, CustomGridZonesFormat, applied.ZoneCount), + "canvas" => string.Format(CultureInfo.CurrentCulture, CustomCanvasZonesFormat, applied.ZoneCount), + _ => string.Format(CultureInfo.CurrentCulture, CustomZonesFormat, applied.ZoneCount), + }; + + yield return new FancyZonesLayoutDescriptor + { + Id = $"custom:{uuid}", + Source = FancyZonesLayoutSource.Custom, + Title = title, + Subtitle = subtitle, + Custom = custom, + ApplyLayout = applied, + }; + } + } + + private static bool TryBuildAppliedLayoutForCustom(CustomLayouts.CustomLayoutWrapper custom, out AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper applied) + { + applied = new AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper + { + Type = "custom", + Uuid = custom.Uuid.Trim(), + ShowSpacing = false, + Spacing = 0, + ZoneCount = 0, + SensitivityRadius = 20, + }; + + if (custom.Info.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return false; + } + + var customType = custom.Type?.Trim().ToLowerInvariant() ?? string.Empty; + if (customType == "grid") + { + if (!TryParseCustomGridInfo(custom.Info, out var zoneCount, out var showSpacing, out var spacing, out var sensitivity)) + { + return false; + } + + applied.ZoneCount = zoneCount; + applied.ShowSpacing = showSpacing; + applied.Spacing = spacing; + applied.SensitivityRadius = sensitivity; + return true; + } + + if (customType == "canvas") + { + if (!TryParseCustomCanvasInfo(custom.Info, out var zoneCount, out var sensitivity)) + { + return false; + } + + applied.ZoneCount = zoneCount; + applied.SensitivityRadius = sensitivity; + applied.ShowSpacing = false; + applied.Spacing = 0; + return true; + } + + return false; + } + + internal static bool TryParseCustomGridInfo(JsonElement info, out int zoneCount, out bool showSpacing, out int spacing, out int sensitivityRadius) + { + zoneCount = 0; + showSpacing = false; + spacing = 0; + sensitivityRadius = 20; + + if (!info.TryGetProperty("rows", out var rowsProp) || + !info.TryGetProperty("columns", out var columnsProp) || + rowsProp.ValueKind != JsonValueKind.Number || + columnsProp.ValueKind != JsonValueKind.Number) + { + return false; + } + + var rows = rowsProp.GetInt32(); + var columns = columnsProp.GetInt32(); + if (rows <= 0 || columns <= 0) + { + return false; + } + + if (info.TryGetProperty("cell-child-map", out var cellMap) && cellMap.ValueKind == JsonValueKind.Array) + { + var max = -1; + foreach (var row in cellMap.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var cell in row.EnumerateArray()) + { + if (cell.ValueKind == JsonValueKind.Number && cell.TryGetInt32(out var value)) + { + max = Math.Max(max, value); + } + } + } + + zoneCount = max + 1; + } + else + { + zoneCount = rows * columns; + } + + if (zoneCount <= 0) + { + return false; + } + + if (info.TryGetProperty("show-spacing", out var showSpacingProp) && + (showSpacingProp.ValueKind == JsonValueKind.True || showSpacingProp.ValueKind == JsonValueKind.False)) + { + showSpacing = showSpacingProp.GetBoolean(); + } + + if (info.TryGetProperty("spacing", out var spacingProp) && spacingProp.ValueKind == JsonValueKind.Number) + { + spacing = spacingProp.GetInt32(); + } + + if (info.TryGetProperty("sensitivity-radius", out var sensitivityProp) && sensitivityProp.ValueKind == JsonValueKind.Number) + { + sensitivityRadius = sensitivityProp.GetInt32(); + } + + return true; + } + + internal static bool TryParseCustomCanvasInfo(JsonElement info, out int zoneCount, out int sensitivityRadius) + { + zoneCount = 0; + sensitivityRadius = 20; + + if (!info.TryGetProperty("zones", out var zones) || zones.ValueKind != JsonValueKind.Array) + { + return false; + } + + zoneCount = zones.GetArrayLength(); + + if (info.TryGetProperty("sensitivity-radius", out var sensitivityProp) && sensitivityProp.ValueKind == JsonValueKind.Number) + { + sensitivityRadius = sensitivityProp.GetInt32(); + } + + return zoneCount >= 0; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesLayoutDescriptor.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesLayoutDescriptor.cs new file mode 100644 index 0000000000..0c99dcc8f4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesLayoutDescriptor.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 FancyZonesEditorCommon.Data; + +namespace PowerToysExtension.Helpers; + +internal sealed class FancyZonesLayoutDescriptor +{ + public required string Id { get; init; } // "template:<type>" or "custom:<uuid>" + + public required FancyZonesLayoutSource Source { get; init; } + + public required string Title { get; init; } + + public required string Subtitle { get; init; } + + public required AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper ApplyLayout { get; init; } + + public LayoutTemplates.TemplateLayoutWrapper? Template { get; init; } + + public CustomLayouts.CustomLayoutWrapper? Custom { get; init; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesLayoutDescriptor1.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesLayoutDescriptor1.cs new file mode 100644 index 0000000000..194a9a206c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesLayoutDescriptor1.cs @@ -0,0 +1,5 @@ +// 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. + +// Intentionally empty: this file was an accidental duplicate of FancyZonesLayoutDescriptor.cs. diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesLayoutSource.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesLayoutSource.cs new file mode 100644 index 0000000000..d75e3faccd --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesLayoutSource.cs @@ -0,0 +1,11 @@ +// 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 PowerToysExtension.Helpers; + +internal enum FancyZonesLayoutSource +{ + Template, + Custom, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesMonitorDescriptor.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesMonitorDescriptor.cs new file mode 100644 index 0000000000..e0716f2a56 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesMonitorDescriptor.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.Globalization; + +using FancyZonesEditorCommon.Data; + +namespace PowerToysExtension.Helpers; + +internal readonly record struct FancyZonesMonitorDescriptor( + int Index, + EditorParameters.NativeMonitorDataWrapper Data) +{ + public string Title => Data.Monitor; + + public string Subtitle + { + get + { + // MonitorWidth/Height are logical (DPI-scaled) pixels, calculate physical resolution + var scaleFactor = Data.Dpi > 0 ? Data.Dpi / 96.0 : 1.0; + var physicalWidth = (int)Math.Round(Data.MonitorWidth * scaleFactor); + var physicalHeight = (int)Math.Round(Data.MonitorHeight * scaleFactor); + var size = $"{physicalWidth}×{physicalHeight}"; + var scaling = Data.Dpi > 0 ? string.Format(CultureInfo.InvariantCulture, "{0}%", (int)Math.Round(scaleFactor * 100)) : "n/a"; + return $"{size} \u2022 {scaling}"; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesMonitorIdentifier.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesMonitorIdentifier.cs new file mode 100644 index 0000000000..afa908e08b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesMonitorIdentifier.cs @@ -0,0 +1,461 @@ +// 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.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace PowerToysExtension.Helpers; + +internal static class FancyZonesMonitorIdentifier +{ + private const string WindowClassName = "PowerToys_FancyZones_MonitorIdentify"; + + private const uint WsExToolWindow = 0x00000080; + private const uint WsExTopmost = 0x00000008; + private const uint WsExTransparent = 0x00000020; + private const uint WsPopup = 0x80000000; + + private const uint WmDestroy = 0x0002; + private const uint WmPaint = 0x000F; + private const uint WmTimer = 0x0113; + + private const uint CsVRedraw = 0x0001; + private const uint CsHRedraw = 0x0002; + + private const int SwShowNoActivate = 4; + + private const int Transparent = 1; + + private const int BaseFontHeightPx = 52; + private const int BaseDpi = 96; + + private const uint DtCenter = 0x00000001; + private const uint DtVCenter = 0x00000004; + private const uint DtSingleLine = 0x00000020; + + private const uint MonitorDefaultToNearest = 2; + + private static readonly nint DpiAwarenessContextUnaware = new(-1); + + private static readonly object Sync = new(); + private static bool classRegistered; + + private static GCHandle? currentPinnedTextHandle; + + public static void Show(int left, int top, int width, int height, string text, int durationMs = 1200) + { + if (string.IsNullOrWhiteSpace(text)) + { + text = "Monitor"; + } + + _ = Task.Run(() => RunWindow(left, top, width, height, text, durationMs)) + .ContinueWith(static t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted); + } + + private static unsafe void RunWindow(int left, int top, int width, int height, string text, int durationMs) + { + EnsureClassRegistered(); + + var workArea = TryGetWorkAreaFromFancyZonesCoordinates(left, top, width, height, out var resolvedWorkArea) + ? resolvedWorkArea + : new RECT + { + Left = left, + Top = top, + Right = left + width, + Bottom = top + height, + }; + + var workAreaWidth = Math.Max(0, workArea.Right - workArea.Left); + var workAreaHeight = Math.Max(0, workArea.Bottom - workArea.Top); + + var overlayWidth = Math.Clamp(workAreaWidth / 4, 220, 420); + var overlayHeight = Math.Clamp(workAreaHeight / 6, 120, 240); + + var x = workArea.Left + ((workAreaWidth - overlayWidth) / 2); + var y = workArea.Top + ((workAreaHeight - overlayHeight) / 2); + + lock (Sync) + { + currentPinnedTextHandle?.Free(); + currentPinnedTextHandle = GCHandle.Alloc(text, GCHandleType.Pinned); + } + + var hwnd = CreateWindowExW( + WsExToolWindow | WsExTopmost | WsExTransparent, + WindowClassName, + "MonitorIdentify", + WsPopup, + x, + y, + overlayWidth, + overlayHeight, + nint.Zero, + nint.Zero, + GetModuleHandleW(null), + nint.Zero); + + if (hwnd == nint.Zero) + { + return; + } + + _ = ShowWindow(hwnd, SwShowNoActivate); + _ = UpdateWindow(hwnd); + + _ = SetTimer(hwnd, 1, (uint)durationMs, nint.Zero); + + MSG msg; + while (GetMessageW(out msg, nint.Zero, 0, 0) != 0) + { + _ = TranslateMessage(in msg); + _ = DispatchMessageW(in msg); + } + + lock (Sync) + { + currentPinnedTextHandle?.Free(); + currentPinnedTextHandle = null; + } + } + + private static unsafe void EnsureClassRegistered() + { + lock (Sync) + { + if (classRegistered) + { + return; + } + + fixed (char* className = WindowClassName ?? string.Empty) + { + var wc = new WNDCLASSEXW + { + CbSize = (uint)sizeof(WNDCLASSEXW), + Style = CsHRedraw | CsVRedraw, + LpfnWndProc = &WndProc, + HInstance = GetModuleHandleW(null), + HCursor = LoadCursorW(nint.Zero, new IntPtr(32512)), // IDC_ARROW + LpszClassName = className, + }; + + _ = RegisterClassExW(in wc); + classRegistered = true; + } + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static unsafe nint WndProc(nint hwnd, uint msg, nuint wParam, nint lParam) + { + switch (msg) + { + case WmTimer: + _ = KillTimer(hwnd, 1); + _ = DestroyWindow(hwnd); + return nint.Zero; + + case WmDestroy: + PostQuitMessage(0); + return nint.Zero; + + case WmPaint: + { + var hdc = BeginPaint(hwnd, out var ps); + + _ = GetClientRect(hwnd, out var rect); + + var bgBrush = CreateSolidBrush(0x202020); + _ = FillRect(hdc, in rect, bgBrush); + + _ = SetBkMode(hdc, Transparent); + _ = SetTextColor(hdc, 0xFFFFFF); + + var dpi = GetDpiForWindow(hwnd); + var fontHeight = -MulDiv(BaseFontHeightPx, (int)dpi, BaseDpi); + var font = CreateFontW( + fontHeight, + 0, + 0, + 0, + 700, + 0, + 0, + 0, + 1, // DEFAULT_CHARSET + 0, // OUT_DEFAULT_PRECIS + 0, // CLIP_DEFAULT_PRECIS + 5, // CLEARTYPE_QUALITY + 0x20, // FF_SWISS + "Segoe UI"); + + var oldFont = SelectObject(hdc, font); + + var textPtr = GetPinnedTextPointer(); + if (textPtr is not null) + { + var textNint = (nint)textPtr; + _ = DrawTextW(hdc, textNint, -1, ref rect, DtCenter | DtVCenter | DtSingleLine); + } + + _ = SelectObject(hdc, oldFont); + _ = DeleteObject(font); + _ = DeleteObject(bgBrush); + + _ = EndPaint(hwnd, ref ps); + return nint.Zero; + } + } + + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + + private static unsafe char* GetPinnedTextPointer() + { + lock (Sync) + { + if (!currentPinnedTextHandle.HasValue || !currentPinnedTextHandle.Value.IsAllocated) + { + return null; + } + + return (char*)currentPinnedTextHandle.Value.AddrOfPinnedObject(); + } + } + + private static bool TryGetWorkAreaFromFancyZonesCoordinates(int left, int top, int width, int height, out RECT workArea) + { + workArea = default; + + if (width <= 0 || height <= 0) + { + return false; + } + + var logicalRect = new RECT + { + Left = left, + Top = top, + Right = left + width, + Bottom = top + height, + }; + + var previousContext = SetThreadDpiAwarenessContext(DpiAwarenessContextUnaware); + nint monitor; + try + { + monitor = MonitorFromRect(ref logicalRect, MonitorDefaultToNearest); + } + finally + { + _ = SetThreadDpiAwarenessContext(previousContext); + } + + if (monitor == nint.Zero) + { + return false; + } + + var mi = new MONITORINFOEXW + { + CbSize = (uint)Marshal.SizeOf<MONITORINFOEXW>(), + }; + + if (!GetMonitorInfoW(monitor, ref mi)) + { + return false; + } + + workArea = mi.RcWork; + return true; + } + + [StructLayout(LayoutKind.Sequential)] + private unsafe struct WNDCLASSEXW + { + public uint CbSize; + public uint Style; + public delegate* unmanaged[Stdcall]<nint, uint, nuint, nint, nint> LpfnWndProc; + public int CbClsExtra; + public int CbWndExtra; + public nint HInstance; + public nint HIcon; + public nint HCursor; + public nint HbrBackground; + public char* LpszMenuName; + public char* LpszClassName; + public nint HIconSm; + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MSG + { + public nint Hwnd; + public uint Message; + public nuint WParam; + public nint LParam; + public uint Time; + public POINT Pt; + public uint LPrivate; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct MONITORINFOEXW + { + public uint CbSize; + public RECT RcMonitor; + public RECT RcWork; + public uint DwFlags; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string SzDevice; + } + + [StructLayout(LayoutKind.Sequential)] + private unsafe struct PAINTSTRUCT + { + public nint Hdc; + public int FErase; + public RECT RcPaint; + public int FRestore; + public int FIncUpdate; + public fixed byte RgbReserved[32]; + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint GetModuleHandleW(string? lpModuleName); + + [DllImport("kernel32.dll")] + private static extern int MulDiv(int nNumber, int nNumerator, int nDenominator); + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(nint hwnd); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool UpdateWindow(nint hWnd); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool ShowWindow(nint hWnd, int nCmdShow); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyWindow(nint hWnd); + + [DllImport("user32.dll")] + private static extern void PostQuitMessage(int nExitCode); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool KillTimer(nint hWnd, nuint uIDEvent); + + [DllImport("user32.dll", SetLastError = true)] + private static extern nuint SetTimer(nint hWnd, nuint nIDEvent, uint uElapse, nint lpTimerFunc); + + [DllImport("user32.dll")] + private static extern int GetMessageW(out MSG lpMsg, nint hWnd, uint wMsgFilterMin, uint wMsgFilterMax); + + [DllImport("user32.dll")] + private static extern bool TranslateMessage(in MSG lpMsg); + + [DllImport("user32.dll")] + private static extern nint DispatchMessageW(in MSG lpMsg); + + [DllImport("user32.dll")] + private static extern nint SetThreadDpiAwarenessContext(nint dpiContext); + + [DllImport("user32.dll")] + private static extern nint MonitorFromRect(ref RECT lprc, uint dwFlags); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool GetMonitorInfoW(nint hMonitor, ref MONITORINFOEXW lpmi); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern ushort RegisterClassExW(in WNDCLASSEXW lpwcx); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint CreateWindowExW( + uint dwExStyle, + string lpClassName, + string lpWindowName, + uint dwStyle, + int x, + int y, + int nWidth, + int nHeight, + nint hWndParent, + nint hMenu, + nint hInstance, + nint lpParam); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint LoadCursorW(nint hInstance, nint lpCursorName); + + [DllImport("user32.dll")] + private static extern nint DefWindowProcW(nint hWnd, uint msg, nuint wParam, nint lParam); + + [DllImport("user32.dll")] + private static extern nint BeginPaint(nint hWnd, out PAINTSTRUCT lpPaint); + + [DllImport("user32.dll")] + private static extern bool EndPaint(nint hWnd, ref PAINTSTRUCT lpPaint); + + [DllImport("user32.dll")] + private static extern bool GetClientRect(nint hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + private static extern int FillRect(nint hDC, in RECT lprc, nint hbr); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int DrawTextW(nint hdc, nint lpchText, int cchText, ref RECT lprc, uint format); + + [DllImport("gdi32.dll")] + private static extern nint CreateSolidBrush(uint colorRef); + + [DllImport("gdi32.dll")] + private static extern int SetBkMode(nint hdc, int mode); + + [DllImport("gdi32.dll")] + private static extern uint SetTextColor(nint hdc, uint colorRef); + + [DllImport("gdi32.dll", CharSet = CharSet.Unicode)] + private static extern nint CreateFontW( + int nHeight, + int nWidth, + int nEscapement, + int nOrientation, + int fnWeight, + uint fdwItalic, + uint fdwUnderline, + uint fdwStrikeOut, + uint fdwCharSet, + uint fdwOutputPrecision, + uint fdwClipPrecision, + uint fdwQuality, + uint fdwPitchAndFamily, + string lpszFace); + + [DllImport("gdi32.dll")] + private static extern nint SelectObject(nint hdc, nint hgdiobj); + + [DllImport("gdi32.dll")] + private static extern bool DeleteObject(nint hObject); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesMonitorPreviewRenderer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesMonitorPreviewRenderer.cs new file mode 100644 index 0000000000..fc22b1a0cd --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesMonitorPreviewRenderer.cs @@ -0,0 +1,399 @@ +// 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.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +using FancyZonesEditorCommon.Data; + +using ManagedCommon; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +using InteropConstants = PowerToys.Interop.Constants; + +namespace PowerToysExtension.Helpers; + +internal static class FancyZonesMonitorPreviewRenderer +{ + public static IconInfo? TryRenderMonitorHeroImage(FancyZonesMonitorDescriptor monitor) + { + try + { + var cached = TryGetCachedIcon(monitor); + if (cached is not null) + { + return cached; + } + + var icon = RenderMonitorHeroImageAsync(monitor).GetAwaiter().GetResult(); + return icon; + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones monitor hero render failed. Monitor={monitor.Data.Monitor} Index={monitor.Index} Exception={ex}"); + return null; + } + } + + private static IconInfo? TryGetCachedIcon(FancyZonesMonitorDescriptor monitor) + { + var cachePath = GetCachePath(monitor); + if (string.IsNullOrEmpty(cachePath)) + { + return null; + } + + try + { + if (File.Exists(cachePath)) + { + return new IconInfo(cachePath); + } + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones monitor hero cache check failed. Path=\"{cachePath}\" Monitor={monitor.Data.Monitor} Index={monitor.Index} Exception={ex}"); + } + + return null; + } + + private static async Task<IconInfo?> RenderMonitorHeroImageAsync(FancyZonesMonitorDescriptor monitor) + { + var cachePath = GetCachePath(monitor); + if (string.IsNullOrEmpty(cachePath)) + { + return null; + } + + var (widthPx, heightPx) = ComputeCanvasSize(monitor.Data); + Logger.LogDebug($"FancyZones monitor hero render starting. Monitor={monitor.Data.Monitor} Index={monitor.Index} Size={widthPx}x{heightPx}"); + + var (layoutRectangles, spacing) = GetLayoutRectangles(monitor.Data); + var pixelBytes = RenderMonitorPreviewBgra(widthPx, heightPx, layoutRectangles, spacing); + + var stream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); + encoder.SetPixelData( + BitmapPixelFormat.Bgra8, + BitmapAlphaMode.Premultiplied, + (uint)widthPx, + (uint)heightPx, + 96, + 96, + pixelBytes); + await encoder.FlushAsync(); + stream.Seek(0); + + try + { + var tempPath = FormattableString.Invariant($"{cachePath}.{Guid.NewGuid():N}.tmp"); + Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); + await WriteStreamToFileAsync(stream, tempPath); + File.Move(tempPath, cachePath, overwrite: true); + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones monitor hero cache write failed. Path=\"{cachePath}\" Monitor={monitor.Data.Monitor} Index={monitor.Index} Exception={ex}"); + return null; + } + + Logger.LogDebug($"FancyZones monitor hero render succeeded. Monitor={monitor.Data.Monitor} Index={monitor.Index} Path=\"{cachePath}\""); + return new IconInfo(cachePath); + } + + private static (int WidthPx, int HeightPx) ComputeCanvasSize(EditorParameters.NativeMonitorDataWrapper monitor) + { + const int maxDim = 320; + var w = monitor.WorkAreaWidth > 0 ? monitor.WorkAreaWidth : monitor.MonitorWidth; + var h = monitor.WorkAreaHeight > 0 ? monitor.WorkAreaHeight : monitor.MonitorHeight; + + if (w <= 0 || h <= 0) + { + return (maxDim, 180); + } + + var aspect = (float)w / h; + if (aspect >= 1) + { + var height = (int)Math.Clamp(Math.Round(maxDim / aspect), 90, maxDim); + return (maxDim, height); + } + else + { + var width = (int)Math.Clamp(Math.Round(maxDim * aspect), 90, maxDim); + return (width, maxDim); + } + } + + private static (List<FancyZonesThumbnailRenderer.NormalizedRect> Rects, int Spacing) GetLayoutRectangles(EditorParameters.NativeMonitorDataWrapper monitor) + { + if (!FancyZonesDataService.TryGetAppliedLayoutForMonitor(monitor, out var applied) || applied is null) + { + return ([], 0); + } + + var layout = FindLayoutDescriptor(applied.Value); + if (layout is null) + { + return ([], 0); + } + + var rects = FancyZonesThumbnailRenderer.GetNormalizedRectsForLayout(layout); + var spacing = layout.ApplyLayout.ShowSpacing && layout.ApplyLayout.Spacing > 0 ? layout.ApplyLayout.Spacing : 0; + return (rects, spacing); + } + + private static FancyZonesLayoutDescriptor? FindLayoutDescriptor(AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper applied) + { + try + { + var layouts = FancyZonesDataService.GetLayouts(); + + if (!string.IsNullOrWhiteSpace(applied.Uuid) && + !applied.Uuid.Equals("{00000000-0000-0000-0000-000000000000}", StringComparison.OrdinalIgnoreCase)) + { + return layouts.FirstOrDefault(l => l.Source == FancyZonesLayoutSource.Custom && + l.Custom is not null && + string.Equals(l.Custom.Value.Uuid?.Trim(), applied.Uuid.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + var type = applied.Type?.Trim().ToLowerInvariant() ?? string.Empty; + var zoneCount = applied.ZoneCount; + return layouts.FirstOrDefault(l => + l.Source == FancyZonesLayoutSource.Template && + string.Equals(l.ApplyLayout.Type, type, StringComparison.OrdinalIgnoreCase) && + l.ApplyLayout.ZoneCount == zoneCount && + l.ApplyLayout.ShowSpacing == applied.ShowSpacing && + l.ApplyLayout.Spacing == applied.Spacing); + } + catch + { + return null; + } + } + + private static string? GetCachePath(FancyZonesMonitorDescriptor monitor) + { + try + { + var basePath = InteropConstants.AppDataPath(); + if (string.IsNullOrWhiteSpace(basePath)) + { + return null; + } + + var cacheFolder = Path.Combine(basePath, "CmdPal", "PowerToysExtension", "Cache", "FancyZones", "MonitorPreviews"); + var fileName = ComputeMonitorHash(monitor) + ".png"; + return Path.Combine(cacheFolder, fileName); + } + catch + { + return null; + } + } + + private static string ComputeMonitorHash(FancyZonesMonitorDescriptor monitor) + { + var currentVirtualDesktop = FancyZonesVirtualDesktop.GetCurrentVirtualDesktopIdString(); + var appliedFingerprint = string.Empty; + if (FancyZonesDataService.TryGetAppliedLayoutForMonitor(monitor.Data, out var applied) && applied is not null) + { + appliedFingerprint = FormattableString.Invariant($"{applied.Value.Type}|{applied.Value.Uuid}|{applied.Value.ZoneCount}|{applied.Value.ShowSpacing}|{applied.Value.Spacing}"); + } + + var identity = FormattableString.Invariant( + $"{monitor.Data.Monitor}|{monitor.Data.MonitorInstanceId}|{monitor.Data.MonitorSerialNumber}|{monitor.Data.MonitorNumber}|{currentVirtualDesktop}|{monitor.Data.WorkAreaWidth}x{monitor.Data.WorkAreaHeight}|{monitor.Data.MonitorWidth}x{monitor.Data.MonitorHeight}|{appliedFingerprint}"); + + var bytes = Encoding.UTF8.GetBytes(identity); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static byte[] RenderMonitorPreviewBgra( + int widthPx, + int heightPx, + IReadOnlyList<FancyZonesThumbnailRenderer.NormalizedRect> rects, + int spacing) + { + var pixels = new byte[widthPx * heightPx * 4]; + + var frame = Premultiply(new BgraColor(0x80, 0x80, 0x80, 0xFF)); + var bezelFill = Premultiply(new BgraColor(0x20, 0x20, 0x20, 0x18)); + var screenFill = Premultiply(new BgraColor(0x00, 0x00, 0x00, 0x00)); + var border = Premultiply(new BgraColor(0xFF, 0xD8, 0x8C, 0xFF)); + var fill = Premultiply(new BgraColor(0xFF, 0xD8, 0x8C, 0xC0)); + var background = Premultiply(new BgraColor(0x00, 0x00, 0x00, 0x00)); + + for (var i = 0; i < pixels.Length; i += 4) + { + pixels[i + 0] = background.B; + pixels[i + 1] = background.G; + pixels[i + 2] = background.R; + pixels[i + 3] = background.A; + } + + DrawRectBorder(pixels, widthPx, heightPx, 0, 0, widthPx, heightPx, frame); + + const int bezel = 3; + FillRect(pixels, widthPx, heightPx, 1, 1, widthPx - 1, heightPx - 1, bezelFill); + FillRect(pixels, widthPx, heightPx, 1 + bezel, 1 + bezel, widthPx - 1 - bezel, heightPx - 1 - bezel, screenFill); + + var innerLeft = 1 + bezel; + var innerTop = 1 + bezel; + var innerRight = widthPx - 1 - bezel; + var innerBottom = heightPx - 1 - bezel; + var innerWidth = Math.Max(1, innerRight - innerLeft); + var innerHeight = Math.Max(1, innerBottom - innerTop); + + var gapPx = spacing > 0 ? Math.Clamp(spacing / 8, 1, 3) : 0; + foreach (var rect in rects) + { + var (x1, y1, x2, y2) = ToPixelBounds(rect, innerLeft, innerTop, innerWidth, innerHeight, gapPx); + if (x2 <= x1 || y2 <= y1) + { + continue; + } + + FillRect(pixels, widthPx, heightPx, x1, y1, x2, y2, fill); + DrawRectBorder(pixels, widthPx, heightPx, x1, y1, x2, y2, border); + } + + return pixels; + } + + private static (int X1, int Y1, int X2, int Y2) ToPixelBounds( + FancyZonesThumbnailRenderer.NormalizedRect rect, + int originX, + int originY, + int widthPx, + int heightPx, + int gapPx) + { + var x1 = originX + (int)MathF.Round(rect.X * widthPx); + var y1 = originY + (int)MathF.Round(rect.Y * heightPx); + var x2 = originX + (int)MathF.Round((rect.X + rect.Width) * widthPx); + var y2 = originY + (int)MathF.Round((rect.Y + rect.Height) * heightPx); + + x1 = Math.Clamp(x1 + gapPx, originX, originX + widthPx - 1); + y1 = Math.Clamp(y1 + gapPx, originY, originY + heightPx - 1); + x2 = Math.Clamp(x2 - gapPx, originX + 1, originX + widthPx); + y2 = Math.Clamp(y2 - gapPx, originY + 1, originY + heightPx); + + if (x2 <= x1 + 1) + { + x2 = Math.Min(originX + widthPx, x1 + 2); + } + + if (y2 <= y1 + 1) + { + y2 = Math.Min(originY + heightPx, y1 + 2); + } + + return (x1, y1, x2, y2); + } + + private static void FillRect(byte[] pixels, int widthPx, int heightPx, int x1, int y1, int x2, int y2, BgraColor color) + { + for (var y = y1; y < y2; y++) + { + if ((uint)y >= (uint)heightPx) + { + continue; + } + + var rowStart = y * widthPx * 4; + for (var x = x1; x < x2; x++) + { + if ((uint)x >= (uint)widthPx) + { + continue; + } + + var i = rowStart + (x * 4); + pixels[i + 0] = color.B; + pixels[i + 1] = color.G; + pixels[i + 2] = color.R; + pixels[i + 3] = color.A; + } + } + } + + private static void DrawRectBorder(byte[] pixels, int widthPx, int heightPx, int x1, int y1, int x2, int y2, BgraColor color) + { + var left = x1; + var right = x2 - 1; + var top = y1; + var bottom = y2 - 1; + + for (var x = left; x <= right; x++) + { + SetPixel(pixels, widthPx, heightPx, x, top, color); + SetPixel(pixels, widthPx, heightPx, x, bottom, color); + } + + for (var y = top; y <= bottom; y++) + { + SetPixel(pixels, widthPx, heightPx, left, y, color); + SetPixel(pixels, widthPx, heightPx, right, y, color); + } + } + + private static void SetPixel(byte[] pixels, int widthPx, int heightPx, int x, int y, BgraColor color) + { + if ((uint)x >= (uint)widthPx || (uint)y >= (uint)heightPx) + { + return; + } + + var i = ((y * widthPx) + x) * 4; + pixels[i + 0] = color.B; + pixels[i + 1] = color.G; + pixels[i + 2] = color.R; + pixels[i + 3] = color.A; + } + + private static BgraColor Premultiply(BgraColor color) + { + if (color.A == 0 || color.A == 255) + { + return color; + } + + byte Premul(byte c) => (byte)(((c * color.A) + 127) / 255); + return new BgraColor(Premul(color.B), Premul(color.G), Premul(color.R), color.A); + } + + private readonly record struct BgraColor(byte B, byte G, byte R, byte A); + + private static async Task WriteStreamToFileAsync(IRandomAccessStream stream, string filePath) + { + stream.Seek(0); + var size = stream.Size; + if (size == 0) + { + File.WriteAllBytes(filePath, Array.Empty<byte>()); + return; + } + + if (size > int.MaxValue) + { + throw new InvalidOperationException("Icon stream too large."); + } + + using var input = stream.GetInputStreamAt(0); + using var reader = new DataReader(input); + await reader.LoadAsync((uint)size); + var bytes = new byte[(int)size]; + reader.ReadBytes(bytes); + File.WriteAllBytes(filePath, bytes); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesNotifier.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesNotifier.cs new file mode 100644 index 0000000000..256331a951 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesNotifier.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace PowerToysExtension.Helpers; + +internal static class FancyZonesNotifier +{ + private const string AppliedLayoutsFileUpdateMessage = "{2ef2c8a7-e0d5-4f31-9ede-52aade2d284d}"; + private const string SaveEditorParametersMessage = "{d8f9c0e3-5d77-4e83-8a4f-7c704c2bfb4a}"; + + private static readonly uint WmPrivAppliedLayoutsFileUpdate = RegisterWindowMessageW(AppliedLayoutsFileUpdateMessage); + private static readonly uint WmPrivSaveEditorParameters = RegisterWindowMessageW(SaveEditorParametersMessage); + + public static void NotifyAppliedLayoutsChanged() + { + _ = PostMessageW(new IntPtr(0xFFFF), WmPrivAppliedLayoutsFileUpdate, UIntPtr.Zero, IntPtr.Zero); + } + + /// <summary> + /// Notifies FancyZones to save the current monitor configuration to editor-parameters.json. + /// This is needed because FancyZones only writes this file when opening the editor or when explicitly requested. + /// </summary> + public static void NotifySaveEditorParameters() + { + _ = PostMessageW(new IntPtr(0xFFFF), WmPrivSaveEditorParameters, UIntPtr.Zero, IntPtr.Zero); + } + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern uint RegisterWindowMessageW(string lpString); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool PostMessageW(IntPtr hWnd, uint msg, UIntPtr wParam, IntPtr lParam); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesThumbnailRenderer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesThumbnailRenderer.cs new file mode 100644 index 0000000000..514693c26e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesThumbnailRenderer.cs @@ -0,0 +1,725 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using FancyZonesEditorCommon.Data; +using ManagedCommon; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +using InteropConstants = PowerToys.Interop.Constants; + +namespace PowerToysExtension.Helpers; + +internal static class FancyZonesThumbnailRenderer +{ + internal readonly record struct NormalizedRect(float X, float Y, float Width, float Height); + + private readonly record struct BgraColor(byte B, byte G, byte R, byte A); + + public static async Task<IconInfo?> RenderLayoutIconAsync(FancyZonesLayoutDescriptor layout, int sizePx = 72) + { + try + { + Logger.LogDebug($"FancyZones thumbnail render starting. LayoutId={layout.Id} Type={layout.ApplyLayout.Type} ZoneCount={layout.ApplyLayout.ZoneCount} Source={layout.Source}"); + if (sizePx < 16) + { + sizePx = 16; + } + + var cachedIcon = TryGetCachedIcon(layout); + if (cachedIcon is not null) + { + Logger.LogDebug($"FancyZones thumbnail cache hit. LayoutId={layout.Id}"); + return cachedIcon; + } + + var rects = GetNormalizedRectsForLayout(layout); + Logger.LogDebug($"FancyZones thumbnail rects computed. LayoutId={layout.Id} RectCount={rects.Count}"); + var pixelBytes = RenderBgra(rects, sizePx, layout.ApplyLayout.ShowSpacing && layout.ApplyLayout.Spacing > 0 ? layout.ApplyLayout.Spacing : 0); + var stream = new InMemoryRandomAccessStream(); + + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); + encoder.SetPixelData( + BitmapPixelFormat.Bgra8, + BitmapAlphaMode.Premultiplied, + (uint)sizePx, + (uint)sizePx, + 96, + 96, + pixelBytes); + + await encoder.FlushAsync(); + stream.Seek(0); + + var cachePath = GetCachePath(layout); + if (!string.IsNullOrEmpty(cachePath)) + { + try + { + var tempPath = FormattableString.Invariant($"{cachePath}.{Guid.NewGuid():N}.tmp"); + Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!); + await WriteStreamToFileAsync(stream, tempPath); + File.Move(tempPath, cachePath, overwrite: true); + + var fileIcon = new IconInfo(cachePath); + Logger.LogDebug($"FancyZones thumbnail render succeeded (file cache). LayoutId={layout.Id} Path=\"{cachePath}\""); + return fileIcon; + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones thumbnail write cache failed. LayoutId={layout.Id} Path=\"{cachePath}\" Exception={ex}"); + } + } + + // Fallback: return an in-memory stream icon. This may not marshal reliably cross-proc, + // so prefer the file-cached path above. + stream.Seek(0); + var inMemoryIcon = IconInfo.FromStream(stream); + Logger.LogDebug($"FancyZones thumbnail render succeeded (in-memory). LayoutId={layout.Id}"); + return inMemoryIcon; + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones thumbnail render failed. LayoutId={layout.Id} Type={layout.ApplyLayout.Type} ZoneCount={layout.ApplyLayout.ZoneCount} Source={layout.Source} Exception={ex}"); + return null; + } + } + + private static IconInfo? TryGetCachedIcon(FancyZonesLayoutDescriptor layout) + { + var cachePath = GetCachePath(layout); + if (string.IsNullOrEmpty(cachePath)) + { + return null; + } + + try + { + if (File.Exists(cachePath)) + { + return new IconInfo(cachePath); + } + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones thumbnail cache check failed. LayoutId={layout.Id} Path=\"{cachePath}\" Exception={ex}"); + } + + return null; + } + + /// <summary> + /// Removes cached thumbnail files that no longer correspond to any current layout. + /// Call this on startup or periodically to prevent unbounded cache growth. + /// </summary> + public static void PurgeOrphanedCache() + { + try + { + var cacheFolder = GetCacheFolder(); + if (string.IsNullOrEmpty(cacheFolder) || !Directory.Exists(cacheFolder)) + { + return; + } + + // Get all current layouts and compute their expected cache file names + var layouts = FancyZonesDataService.GetLayouts(); + var validHashes = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + foreach (var layout in layouts) + { + validHashes.Add(ComputeLayoutHash(layout) + ".png"); + } + + // Delete any .png files not in the valid set + var deletedCount = 0; + foreach (var filePath in Directory.EnumerateFiles(cacheFolder, "*.png")) + { + var fileName = Path.GetFileName(filePath); + if (!validHashes.Contains(fileName)) + { + try + { + File.Delete(filePath); + deletedCount++; + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones thumbnail cache purge: failed to delete \"{filePath}\". Exception={ex.Message}"); + } + } + } + + if (deletedCount > 0) + { + Logger.LogInfo($"FancyZones thumbnail cache purge: deleted {deletedCount} orphaned file(s)."); + } + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones thumbnail cache purge failed. Exception={ex}"); + } + } + + private static string? GetCacheFolder() + { + var basePath = InteropConstants.AppDataPath(); + if (string.IsNullOrWhiteSpace(basePath)) + { + return null; + } + + return Path.Combine(basePath, "CmdPal", "PowerToysExtension", "Cache", "FancyZones", "LayoutThumbnails"); + } + + private static string? GetCachePath(FancyZonesLayoutDescriptor layout) + { + try + { + var cacheFolder = GetCacheFolder(); + if (string.IsNullOrEmpty(cacheFolder)) + { + return null; + } + + var fileName = ComputeLayoutHash(layout) + ".png"; + return Path.Combine(cacheFolder, fileName); + } + catch (Exception ex) + { + Logger.LogWarning($"FancyZones thumbnail cache path failed. LayoutId={layout.Id} Exception={ex}"); + return null; + } + } + + private static string ComputeLayoutHash(FancyZonesLayoutDescriptor layout) + { + var customType = layout.Custom?.Type?.Trim() ?? string.Empty; + var customInfo = layout.Custom is not null && layout.Custom.Value.Info.ValueKind is not JsonValueKind.Undefined and not JsonValueKind.Null + ? layout.Custom.Value.Info.GetRawText() + : string.Empty; + + var fingerprint = FormattableString.Invariant( + $"{layout.Id}|{layout.Source}|{layout.ApplyLayout.Type}|{layout.ApplyLayout.ZoneCount}|{layout.ApplyLayout.ShowSpacing}|{layout.ApplyLayout.Spacing}|{customType}|{customInfo}"); + + var bytes = Encoding.UTF8.GetBytes(fingerprint); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static async Task WriteStreamToFileAsync(IRandomAccessStream stream, string filePath) + { + stream.Seek(0); + var size = stream.Size; + if (size == 0) + { + File.WriteAllBytes(filePath, Array.Empty<byte>()); + return; + } + + if (size > int.MaxValue) + { + throw new InvalidOperationException("Icon stream too large."); + } + + using var input = stream.GetInputStreamAt(0); + using var reader = new DataReader(input); + await reader.LoadAsync((uint)size); + var bytes = new byte[(int)size]; + reader.ReadBytes(bytes); + File.WriteAllBytes(filePath, bytes); + } + + internal static List<NormalizedRect> GetNormalizedRectsForLayout(FancyZonesLayoutDescriptor layout) + { + var type = layout.ApplyLayout.Type.ToLowerInvariant(); + if (layout.Source == FancyZonesLayoutSource.Custom && layout.Custom is not null) + { + return GetCustomRects(layout.Custom.Value); + } + + return type switch + { + "columns" => GetColumnsRects(layout.ApplyLayout.ZoneCount), + "rows" => GetRowsRects(layout.ApplyLayout.ZoneCount), + "grid" => GetGridRects(layout.ApplyLayout.ZoneCount), + "priority-grid" => GetPriorityGridRects(layout.ApplyLayout.ZoneCount), + "focus" => GetFocusRects(layout.ApplyLayout.ZoneCount), + "blank" => new List<NormalizedRect>(), + _ => GetGridRects(layout.ApplyLayout.ZoneCount), + }; + } + + private static List<NormalizedRect> GetCustomRects(CustomLayouts.CustomLayoutWrapper custom) + { + var type = custom.Type?.Trim().ToLowerInvariant() ?? string.Empty; + if (custom.Info.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return new List<NormalizedRect>(); + } + + return type switch + { + "grid" => GetCustomGridRects(custom.Info), + "canvas" => GetCustomCanvasRects(custom.Info), + _ => new List<NormalizedRect>(), + }; + } + + private static List<NormalizedRect> GetCustomCanvasRects(JsonElement info) + { + if (!info.TryGetProperty("ref-width", out var refWidthProp) || + !info.TryGetProperty("ref-height", out var refHeightProp) || + !info.TryGetProperty("zones", out var zonesProp)) + { + return new List<NormalizedRect>(); + } + + if (refWidthProp.ValueKind != JsonValueKind.Number || refHeightProp.ValueKind != JsonValueKind.Number || zonesProp.ValueKind != JsonValueKind.Array) + { + return new List<NormalizedRect>(); + } + + var refWidth = Math.Max(1, refWidthProp.GetInt32()); + var refHeight = Math.Max(1, refHeightProp.GetInt32()); + var rects = new List<NormalizedRect>(zonesProp.GetArrayLength()); + + foreach (var zone in zonesProp.EnumerateArray()) + { + if (zone.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!zone.TryGetProperty("X", out var xProp) || + !zone.TryGetProperty("Y", out var yProp) || + !zone.TryGetProperty("width", out var wProp) || + !zone.TryGetProperty("height", out var hProp)) + { + continue; + } + + if (xProp.ValueKind != JsonValueKind.Number || + yProp.ValueKind != JsonValueKind.Number || + wProp.ValueKind != JsonValueKind.Number || + hProp.ValueKind != JsonValueKind.Number) + { + continue; + } + + var x = xProp.GetSingle() / refWidth; + var y = yProp.GetSingle() / refHeight; + var w = wProp.GetSingle() / refWidth; + var h = hProp.GetSingle() / refHeight; + rects.Add(NormalizeRect(x, y, w, h)); + } + + return rects; + } + + private static List<NormalizedRect> GetCustomGridRects(JsonElement info) + { + if (!TryGetGridDefinition(info, out var rows, out var cols, out var rowsPercents, out var colsPercents, out var cellMap)) + { + return new List<NormalizedRect>(); + } + + return BuildRectsFromGridDefinition(rows, cols, rowsPercents, colsPercents, cellMap); + } + + private static bool TryGetGridDefinition( + JsonElement info, + out int rows, + out int cols, + out int[] rowPercents, + out int[] colPercents, + out int[][] cellChildMap) + { + rows = 0; + cols = 0; + rowPercents = Array.Empty<int>(); + colPercents = Array.Empty<int>(); + cellChildMap = Array.Empty<int[]>(); + + if (!info.TryGetProperty("rows", out var rowsProp) || + !info.TryGetProperty("columns", out var colsProp) || + !info.TryGetProperty("rows-percentage", out var rowsPercentsProp) || + !info.TryGetProperty("columns-percentage", out var colsPercentsProp) || + !info.TryGetProperty("cell-child-map", out var cellMapProp)) + { + return false; + } + + if (rowsProp.ValueKind != JsonValueKind.Number || + colsProp.ValueKind != JsonValueKind.Number || + rowsPercentsProp.ValueKind != JsonValueKind.Array || + colsPercentsProp.ValueKind != JsonValueKind.Array || + cellMapProp.ValueKind != JsonValueKind.Array) + { + return false; + } + + rows = rowsProp.GetInt32(); + cols = colsProp.GetInt32(); + if (rows <= 0 || cols <= 0) + { + return false; + } + + rowPercents = rowsPercentsProp.EnumerateArray().Where(v => v.ValueKind == JsonValueKind.Number).Select(v => v.GetInt32()).ToArray(); + colPercents = colsPercentsProp.EnumerateArray().Where(v => v.ValueKind == JsonValueKind.Number).Select(v => v.GetInt32()).ToArray(); + + if (rowPercents.Length != rows || colPercents.Length != cols) + { + return false; + } + + var mapRows = new List<int[]>(rows); + foreach (var row in cellMapProp.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Array) + { + return false; + } + + var cells = row.EnumerateArray().Where(v => v.ValueKind == JsonValueKind.Number).Select(v => v.GetInt32()).ToArray(); + if (cells.Length != cols) + { + return false; + } + + mapRows.Add(cells); + } + + if (mapRows.Count != rows) + { + return false; + } + + cellChildMap = mapRows.ToArray(); + return true; + } + + private static List<NormalizedRect> GetColumnsRects(int zoneCount) + { + zoneCount = Math.Clamp(zoneCount, 1, 16); + var rects = new List<NormalizedRect>(zoneCount); + for (var i = 0; i < zoneCount; i++) + { + rects.Add(new NormalizedRect(i / (float)zoneCount, 0, 1f / zoneCount, 1f)); + } + + return rects; + } + + private static List<NormalizedRect> GetRowsRects(int zoneCount) + { + zoneCount = Math.Clamp(zoneCount, 1, 16); + var rects = new List<NormalizedRect>(zoneCount); + for (var i = 0; i < zoneCount; i++) + { + rects.Add(new NormalizedRect(0, i / (float)zoneCount, 1f, 1f / zoneCount)); + } + + return rects; + } + + private static List<NormalizedRect> GetGridRects(int zoneCount) + { + zoneCount = Math.Clamp(zoneCount, 1, 25); + var rows = 1; + while (zoneCount / rows >= rows) + { + rows++; + } + + rows--; + var cols = zoneCount / rows; + if (zoneCount % rows != 0) + { + cols++; + } + + var rowPercents = Enumerable.Repeat(10000 / rows, rows).ToArray(); + var colPercents = Enumerable.Repeat(10000 / cols, cols).ToArray(); + var cellMap = new int[rows][]; + + var index = 0; + for (var r = 0; r < rows; r++) + { + cellMap[r] = new int[cols]; + for (var c = 0; c < cols; c++) + { + cellMap[r][c] = index; + index++; + if (index == zoneCount) + { + index--; + } + } + } + + return BuildRectsFromGridDefinition(rows, cols, rowPercents, colPercents, cellMap); + } + + private static List<NormalizedRect> GetPriorityGridRects(int zoneCount) + { + zoneCount = Math.Clamp(zoneCount, 1, 25); + + if (zoneCount is >= 1 and <= 11 && PriorityGrid.TryGetValue(zoneCount, out var def)) + { + return BuildRectsFromGridDefinition(def.Rows, def.Cols, def.RowPercents, def.ColPercents, def.CellMap); + } + + return GetGridRects(zoneCount); + } + + private static List<NormalizedRect> GetFocusRects(int zoneCount) + { + // Focus layout parameters from FancyZonesEditor CanvasLayoutModel: + // - DefaultOffset = 100px from top-left (normalized: ~0.05 for typical screen) + // - OffsetShift = 50px per zone (normalized: ~0.025) + // - ZoneSizeMultiplier = 0.4 (zones are 40% of screen) + zoneCount = Math.Clamp(zoneCount, 1, 8); + var rects = new List<NormalizedRect>(zoneCount); + + const float defaultOffset = 0.05f; // ~100px on 1920px screen + const float offsetShift = 0.025f; // ~50px on 1920px screen + const float zoneSize = 0.4f; // 40% of screen + + for (var i = 0; i < zoneCount; i++) + { + var offset = i * offsetShift; + rects.Add(new NormalizedRect(defaultOffset + offset, defaultOffset + offset, zoneSize, zoneSize)); + } + + return rects; + } + + private static List<NormalizedRect> BuildRectsFromGridDefinition(int rows, int cols, int[] rowPercents, int[] colPercents, int[][] cellChildMap) + { + const float multiplier = 10000f; + + var rowPrefix = new float[rows + 1]; + var colPrefix = new float[cols + 1]; + + for (var r = 0; r < rows; r++) + { + rowPrefix[r + 1] = rowPrefix[r] + (rowPercents[r] / multiplier); + } + + for (var c = 0; c < cols; c++) + { + colPrefix[c + 1] = colPrefix[c] + (colPercents[c] / multiplier); + } + + var maxZone = -1; + for (var r = 0; r < rows; r++) + { + for (var c = 0; c < cols; c++) + { + maxZone = Math.Max(maxZone, cellChildMap[r][c]); + } + } + + var rects = new List<NormalizedRect>(maxZone + 1); + for (var i = 0; i <= maxZone; i++) + { + rects.Add(new NormalizedRect(1, 1, 0, 0)); + } + + for (var r = 0; r < rows; r++) + { + for (var c = 0; c < cols; c++) + { + var zoneId = cellChildMap[r][c]; + if (zoneId < 0 || zoneId >= rects.Count) + { + continue; + } + + var x1 = colPrefix[c]; + var y1 = rowPrefix[r]; + var x2 = colPrefix[c + 1]; + var y2 = rowPrefix[r + 1]; + + var existing = rects[zoneId]; + if (existing.Width <= 0 || existing.Height <= 0) + { + rects[zoneId] = new NormalizedRect(x1, y1, x2 - x1, y2 - y1); + } + else + { + var ex2 = existing.X + existing.Width; + var ey2 = existing.Y + existing.Height; + var nx1 = Math.Min(existing.X, x1); + var ny1 = Math.Min(existing.Y, y1); + var nx2 = Math.Max(ex2, x2); + var ny2 = Math.Max(ey2, y2); + rects[zoneId] = new NormalizedRect(nx1, ny1, nx2 - nx1, ny2 - ny1); + } + } + } + + return rects + .Where(r => r.Width > 0 && r.Height > 0) + .Select(r => NormalizeRect(r.X, r.Y, r.Width, r.Height)) + .ToList(); + } + + private static NormalizedRect NormalizeRect(float x, float y, float w, float h) + { + x = Math.Clamp(x, 0, 1); + y = Math.Clamp(y, 0, 1); + w = Math.Clamp(w, 0, 1 - x); + h = Math.Clamp(h, 0, 1 - y); + return new NormalizedRect(x, y, w, h); + } + + private static byte[] RenderBgra(IReadOnlyList<NormalizedRect> rects, int sizePx, int spacing) + { + var pixels = new byte[sizePx * sizePx * 4]; + + var border = Premultiply(new BgraColor(0x30, 0x30, 0x30, 0xFF)); + var frame = Premultiply(new BgraColor(0x40, 0x40, 0x40, 0xA0)); + var fill = Premultiply(new BgraColor(0xFF, 0xD8, 0x8C, 0xC0)); // light-ish blue with alpha + var background = Premultiply(new BgraColor(0x00, 0x00, 0x00, 0x00)); + + for (var i = 0; i < pixels.Length; i += 4) + { + pixels[i + 0] = background.B; + pixels[i + 1] = background.G; + pixels[i + 2] = background.R; + pixels[i + 3] = background.A; + } + + DrawRectBorder(pixels, sizePx, 1, 1, sizePx - 1, sizePx - 1, frame); + + var gapPx = spacing > 0 ? Math.Clamp(spacing / 8, 1, 3) : 0; + foreach (var rect in rects) + { + var (x1, y1, x2, y2) = ToPixelBounds(rect, sizePx, gapPx); + if (x2 <= x1 || y2 <= y1) + { + continue; + } + + FillRect(pixels, sizePx, x1, y1, x2, y2, fill); + DrawRectBorder(pixels, sizePx, x1, y1, x2, y2, border); + } + + return pixels; + } + + private static (int X1, int Y1, int X2, int Y2) ToPixelBounds(NormalizedRect rect, int sizePx, int gapPx) + { + var x1 = (int)MathF.Round(rect.X * sizePx); + var y1 = (int)MathF.Round(rect.Y * sizePx); + var x2 = (int)MathF.Round((rect.X + rect.Width) * sizePx); + var y2 = (int)MathF.Round((rect.Y + rect.Height) * sizePx); + + x1 = Math.Clamp(x1 + gapPx, 0, sizePx - 1); + y1 = Math.Clamp(y1 + gapPx, 0, sizePx - 1); + x2 = Math.Clamp(x2 - gapPx, 1, sizePx); + y2 = Math.Clamp(y2 - gapPx, 1, sizePx); + + if (x2 <= x1 + 1) + { + x2 = Math.Min(sizePx, x1 + 2); + } + + if (y2 <= y1 + 1) + { + y2 = Math.Min(sizePx, y1 + 2); + } + + return (x1, y1, x2, y2); + } + + private static void FillRect(byte[] pixels, int sizePx, int x1, int y1, int x2, int y2, BgraColor color) + { + for (var y = y1; y < y2; y++) + { + var rowStart = y * sizePx * 4; + for (var x = x1; x < x2; x++) + { + var i = rowStart + (x * 4); + pixels[i + 0] = color.B; + pixels[i + 1] = color.G; + pixels[i + 2] = color.R; + pixels[i + 3] = color.A; + } + } + } + + private static void DrawRectBorder(byte[] pixels, int sizePx, int x1, int y1, int x2, int y2, BgraColor color) + { + var left = x1; + var right = x2 - 1; + var top = y1; + var bottom = y2 - 1; + + for (var x = left; x <= right; x++) + { + SetPixel(pixels, sizePx, x, top, color); + SetPixel(pixels, sizePx, x, bottom, color); + } + + for (var y = top; y <= bottom; y++) + { + SetPixel(pixels, sizePx, left, y, color); + SetPixel(pixels, sizePx, right, y, color); + } + } + + private static void SetPixel(byte[] pixels, int sizePx, int x, int y, BgraColor color) + { + if ((uint)x >= (uint)sizePx || (uint)y >= (uint)sizePx) + { + return; + } + + var i = ((y * sizePx) + x) * 4; + pixels[i + 0] = color.B; + pixels[i + 1] = color.G; + pixels[i + 2] = color.R; + pixels[i + 3] = color.A; + } + + private static BgraColor Premultiply(BgraColor color) + { + if (color.A == 0 || color.A == 255) + { + return color; + } + + byte Premul(byte c) => (byte)(((c * color.A) + 127) / 255); + return new BgraColor(Premul(color.B), Premul(color.G), Premul(color.R), color.A); + } + + private sealed record PriorityGridDefinition(int Rows, int Cols, int[] RowPercents, int[] ColPercents, int[][] CellMap); + + private static readonly IReadOnlyDictionary<int, PriorityGridDefinition> PriorityGrid = new Dictionary<int, PriorityGridDefinition> + { + [1] = new PriorityGridDefinition(1, 1, [10000], [10000], [[0]]), + [2] = new PriorityGridDefinition(1, 2, [10000], [6667, 3333], [[0, 1]]), + [3] = new PriorityGridDefinition(1, 3, [10000], [2500, 5000, 2500], [[0, 1, 2]]), + [4] = new PriorityGridDefinition(2, 3, [5000, 5000], [2500, 5000, 2500], [[0, 1, 2], [0, 1, 3]]), + [5] = new PriorityGridDefinition(2, 3, [5000, 5000], [2500, 5000, 2500], [[0, 1, 2], [3, 1, 4]]), + [6] = new PriorityGridDefinition(3, 3, [3333, 3334, 3333], [2500, 5000, 2500], [[0, 1, 2], [0, 1, 3], [4, 1, 5]]), + [7] = new PriorityGridDefinition(3, 3, [3333, 3334, 3333], [2500, 5000, 2500], [[0, 1, 2], [3, 1, 4], [5, 1, 6]]), + [8] = new PriorityGridDefinition(3, 4, [3333, 3334, 3333], [2500, 2500, 2500, 2500], [[0, 1, 2, 3], [4, 1, 2, 5], [6, 1, 2, 7]]), + [9] = new PriorityGridDefinition(3, 4, [3333, 3334, 3333], [2500, 2500, 2500, 2500], [[0, 1, 2, 3], [4, 1, 2, 5], [6, 1, 7, 8]]), + [10] = new PriorityGridDefinition(3, 4, [3333, 3334, 3333], [2500, 2500, 2500, 2500], [[0, 1, 2, 3], [4, 1, 5, 6], [7, 1, 8, 9]]), + [11] = new PriorityGridDefinition(3, 4, [3333, 3334, 3333], [2500, 2500, 2500, 2500], [[0, 1, 2, 3], [4, 1, 5, 6], [7, 8, 9, 10]]), + }; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesVirtualDesktop.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesVirtualDesktop.cs new file mode 100644 index 0000000000..274b6ef5c7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/FancyZonesVirtualDesktop.cs @@ -0,0 +1,103 @@ +// 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 Microsoft.Win32; + +namespace PowerToysExtension.Helpers; + +internal static class FancyZonesVirtualDesktop +{ + private const string VirtualDesktopsKey = @"Software\Microsoft\Windows\CurrentVersion\Explorer\VirtualDesktops"; + private const string SessionVirtualDesktopsKeyPrefix = @"Software\Microsoft\Windows\CurrentVersion\Explorer\SessionInfo\"; + private const string SessionVirtualDesktopsKeySuffix = @"\VirtualDesktops"; + private const string CurrentVirtualDesktopValue = "CurrentVirtualDesktop"; + private const string VirtualDesktopIdsValue = "VirtualDesktopIDs"; + + public static string GetCurrentVirtualDesktopIdString() + { + var id = TryGetCurrentVirtualDesktopId() + ?? TryGetCurrentVirtualDesktopIdFromSession() + ?? TryGetFirstVirtualDesktopId() + ?? Guid.Empty; + + return "{" + id.ToString().ToUpperInvariant() + "}"; + } + + private static Guid? TryGetCurrentVirtualDesktopId() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(VirtualDesktopsKey, writable: false); + var bytes = key?.GetValue(CurrentVirtualDesktopValue) as byte[]; + return TryGetGuid(bytes); + } + catch + { + return null; + } + } + + private static Guid? TryGetCurrentVirtualDesktopIdFromSession() + { + try + { + if (!ProcessIdToSessionId((uint)Environment.ProcessId, out var sessionId)) + { + return null; + } + + var path = SessionVirtualDesktopsKeyPrefix + sessionId + SessionVirtualDesktopsKeySuffix; + using var key = Registry.CurrentUser.OpenSubKey(path, writable: false); + var bytes = key?.GetValue(CurrentVirtualDesktopValue) as byte[]; + return TryGetGuid(bytes); + } + catch + { + return null; + } + } + + private static Guid? TryGetFirstVirtualDesktopId() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey(VirtualDesktopsKey, writable: false); + var bytes = key?.GetValue(VirtualDesktopIdsValue) as byte[]; + if (bytes is null || bytes.Length < 16) + { + return null; + } + + var first = new byte[16]; + Array.Copy(bytes, 0, first, 0, 16); + return TryGetGuid(first); + } + catch + { + return null; + } + } + + private static Guid? TryGetGuid(byte[]? bytes) + { + try + { + if (bytes is null || bytes.Length < 16) + { + return null; + } + + return new Guid(bytes.AsSpan(0, 16)); + } + catch + { + return null; + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ProcessIdToSessionId(uint dwProcessId, out uint pSessionId); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/GpoEnablementService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/GpoEnablementService.cs new file mode 100644 index 0000000000..244870ebe5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/GpoEnablementService.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.Win32; + +namespace PowerToysExtension.Helpers; + +internal enum GpoRuleConfiguredValue +{ + WrongValue = -3, + Unavailable = -2, + NotConfigured = -1, + Disabled = 0, + Enabled = 1, +} + +/// <summary> +/// Lightweight GPO reader for module/feature enablement policies. +/// Mirrors the logic in src/common/utils/gpo.h but avoids taking a dependency on the full GPOWrapper. +/// </summary> +internal static class GpoEnablementService +{ + private const string PoliciesPath = @"SOFTWARE\Policies\PowerToys"; + private const string PolicyConfigureEnabledGlobalAllUtilities = "ConfigureGlobalUtilityEnabledState"; + + internal static GpoRuleConfiguredValue GetUtilityEnabledValue(string individualPolicyValueName) + { + if (!string.IsNullOrEmpty(individualPolicyValueName)) + { + var individual = GetConfiguredValue(individualPolicyValueName); + if (individual is GpoRuleConfiguredValue.Disabled or GpoRuleConfiguredValue.Enabled) + { + return individual; + } + } + + return GetConfiguredValue(PolicyConfigureEnabledGlobalAllUtilities); + } + + private static GpoRuleConfiguredValue GetConfiguredValue(string registryValueName) + { + try + { + // Machine scope has priority over user scope. + var value = ReadRegistryValue(Registry.LocalMachine, registryValueName); + value ??= ReadRegistryValue(Registry.CurrentUser, registryValueName); + + if (!value.HasValue) + { + return GpoRuleConfiguredValue.NotConfigured; + } + + return value.Value switch + { + 0 => GpoRuleConfiguredValue.Disabled, + 1 => GpoRuleConfiguredValue.Enabled, + _ => GpoRuleConfiguredValue.WrongValue, + }; + } + catch + { + return GpoRuleConfiguredValue.Unavailable; + } + } + + private static int? ReadRegistryValue(RegistryKey rootKey, string valueName) + { + try + { + using var key = rootKey.OpenSubKey(PoliciesPath, writable: false); + if (key is null) + { + return null; + } + + var value = key.GetValue(valueName); + return value as int?; + } + catch + { + return null; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/ModuleCommandCatalog.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/ModuleCommandCatalog.cs new file mode 100644 index 0000000000..679c94bde0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/ModuleCommandCatalog.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Modules; + +namespace PowerToysExtension.Helpers; + +/// <summary> +/// Aggregates commands exposed by individual module providers and applies fuzzy filtering. +/// </summary> +internal static class ModuleCommandCatalog +{ + private static readonly ModuleCommandProvider[] Providers = + [ + new AwakeModuleCommandProvider(), + new AdvancedPasteModuleCommandProvider(), + new WorkspacesModuleCommandProvider(), + new LightSwitchModuleCommandProvider(), + new PowerToysRunModuleCommandProvider(), + new ScreenRulerModuleCommandProvider(), + new ShortcutGuideModuleCommandProvider(), + new TextExtractorModuleCommandProvider(), + new ZoomItModuleCommandProvider(), + new ColorPickerModuleCommandProvider(), + new AlwaysOnTopModuleCommandProvider(), + new CropAndLockModuleCommandProvider(), + new FancyZonesModuleCommandProvider(), + new KeyboardManagerModuleCommandProvider(), + new MouseUtilsModuleCommandProvider(), + new MouseWithoutBordersModuleCommandProvider(), + new QuickAccentModuleCommandProvider(), + new FileExplorerAddonsModuleCommandProvider(), + new FileLocksmithModuleCommandProvider(), + new ImageResizerModuleCommandProvider(), + new NewPlusModuleCommandProvider(), + new PeekModuleCommandProvider(), + new PowerRenameModuleCommandProvider(), + new CommandNotFoundModuleCommandProvider(), + new EnvironmentVariablesModuleCommandProvider(), + new HostsModuleCommandProvider(), + new RegistryPreviewModuleCommandProvider(), + ]; + + public static IListItem[] GetAllItems() + { + return [.. Providers.SelectMany(provider => provider.BuildCommands())]; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/ModuleEnablementService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/ModuleEnablementService.cs new file mode 100644 index 0000000000..fccf5b8687 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/ModuleEnablementService.cs @@ -0,0 +1,162 @@ +// 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.IO; +using System.Text.Json; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Helpers; + +/// <summary> +/// Reads PowerToys module enablement flags from the global settings.json. +/// </summary> +internal static class ModuleEnablementService +{ + internal static string SettingsFilePath { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys", + "settings.json"); + + internal static bool IsModuleEnabled(SettingsWindow module) + { + var key = GetEnabledKey(module); + if (string.IsNullOrEmpty(key)) + { + var globalRule = GpoEnablementService.GetUtilityEnabledValue(string.Empty); + return globalRule != GpoRuleConfiguredValue.Disabled; + } + + return IsKeyEnabled(key); + } + + internal static bool IsKeyEnabled(string enabledKey) + { + if (string.IsNullOrWhiteSpace(enabledKey)) + { + return true; + } + + var gpoPolicy = GetGpoPolicyForEnabledKey(enabledKey); + var gpoRule = GpoEnablementService.GetUtilityEnabledValue(gpoPolicy); + if (gpoRule == GpoRuleConfiguredValue.Disabled) + { + return false; + } + + if (gpoRule == GpoRuleConfiguredValue.Enabled) + { + return true; + } + + try + { + var enabled = ReadEnabledFlags(); + return enabled is null || !enabled.TryGetValue(enabledKey, out var value) || value; + } + catch + { + return true; + } + } + + private static Dictionary<string, bool>? ReadEnabledFlags() + { + if (!File.Exists(SettingsFilePath)) + { + return null; + } + + var json = File.ReadAllText(SettingsFilePath).Trim('\0'); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("enabled", out var enabledRoot) || + enabledRoot.ValueKind != JsonValueKind.Object) + { + return null; + } + + var result = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase); + foreach (var prop in enabledRoot.EnumerateObject()) + { + if (prop.Value.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + result[prop.Name] = prop.Value.GetBoolean(); + } + } + + return result; + } + + private static string GetEnabledKey(SettingsWindow module) => module switch + { + SettingsWindow.Awake => "Awake", + SettingsWindow.AdvancedPaste => "AdvancedPaste", + SettingsWindow.AlwaysOnTop => "AlwaysOnTop", + SettingsWindow.ColorPicker => "ColorPicker", + SettingsWindow.CropAndLock => "CropAndLock", + SettingsWindow.EnvironmentVariables => "EnvironmentVariables", + SettingsWindow.FancyZones => "FancyZones", + SettingsWindow.FileExplorer => "File Explorer Preview", + SettingsWindow.FileLocksmith => "FileLocksmith", + SettingsWindow.Hosts => "Hosts", + SettingsWindow.ImageResizer => "Image Resizer", + SettingsWindow.KBM => "Keyboard Manager", + SettingsWindow.LightSwitch => "LightSwitch", + SettingsWindow.MeasureTool => "Measure Tool", + SettingsWindow.MouseWithoutBorders => "MouseWithoutBorders", + SettingsWindow.NewPlus => "NewPlus", + SettingsWindow.Peek => "Peek", + SettingsWindow.PowerAccent => "QuickAccent", + SettingsWindow.PowerLauncher => "PowerToys Run", + SettingsWindow.Run => "PowerToys Run", + SettingsWindow.PowerRename => "PowerRename", + SettingsWindow.PowerOCR => "TextExtractor", + SettingsWindow.RegistryPreview => "RegistryPreview", + SettingsWindow.ShortcutGuide => "Shortcut Guide", + SettingsWindow.Workspaces => "Workspaces", + SettingsWindow.ZoomIt => "ZoomIt", + SettingsWindow.CmdNotFound => "CmdNotFound", + SettingsWindow.CmdPal => "CmdPal", + _ => string.Empty, + }; + + private static string GetGpoPolicyForEnabledKey(string enabledKey) => enabledKey switch + { + "AdvancedPaste" => "ConfigureEnabledUtilityAdvancedPaste", + "AlwaysOnTop" => "ConfigureEnabledUtilityAlwaysOnTop", + "Awake" => "ConfigureEnabledUtilityAwake", + "CmdNotFound" => "ConfigureEnabledUtilityCmdNotFound", + "CmdPal" => "ConfigureEnabledUtilityCmdPal", + "ColorPicker" => "ConfigureEnabledUtilityColorPicker", + "CropAndLock" => "ConfigureEnabledUtilityCropAndLock", + "CursorWrap" => "ConfigureEnabledUtilityCursorWrap", + "EnvironmentVariables" => "ConfigureEnabledUtilityEnvironmentVariables", + "FancyZones" => "ConfigureEnabledUtilityFancyZones", + "FileLocksmith" => "ConfigureEnabledUtilityFileLocksmith", + "FindMyMouse" => "ConfigureEnabledUtilityFindMyMouse", + "Hosts" => "ConfigureEnabledUtilityHostsFileEditor", + "Image Resizer" => "ConfigureEnabledUtilityImageResizer", + "Keyboard Manager" => "ConfigureEnabledUtilityKeyboardManager", + "LightSwitch" => "ConfigureEnabledUtilityLightSwitch", + "Measure Tool" => "ConfigureEnabledUtilityScreenRuler", + "MouseHighlighter" => "ConfigureEnabledUtilityMouseHighlighter", + "MouseJump" => "ConfigureEnabledUtilityMouseJump", + "MousePointerCrosshairs" => "ConfigureEnabledUtilityMousePointerCrosshairs", + "MouseWithoutBorders" => "ConfigureEnabledUtilityMouseWithoutBorders", + "NewPlus" => "ConfigureEnabledUtilityNewPlus", + "Peek" => "ConfigureEnabledUtilityPeek", + "PowerRename" => "ConfigureEnabledUtilityPowerRename", + "PowerToys Run" => "ConfigureEnabledUtilityPowerLauncher", + "QuickAccent" => "ConfigureEnabledUtilityQuickAccent", + "RegistryPreview" => "ConfigureEnabledUtilityRegistryPreview", + "Shortcut Guide" => "ConfigureEnabledUtilityShortcutGuide", + "TextExtractor" => "ConfigureEnabledUtilityTextExtractor", + "Workspaces" => "ConfigureEnabledUtilityWorkspaces", + "ZoomIt" => "ConfigureEnabledUtilityZoomIt", + _ => string.Empty, + }; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/PowerToysFallbackCommandItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/PowerToysFallbackCommandItem.cs new file mode 100644 index 0000000000..7ce4d2b27b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/PowerToysFallbackCommandItem.cs @@ -0,0 +1,88 @@ +// 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 Common.Search.FuzzSearch; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PowerToysExtension.Helpers; + +/// <summary> +/// A fallback item that filters itself based on the landing-page query. +/// It hides itself (empty Title) when the query doesn't fuzzy-match the title or subtitle. +/// </summary> +internal sealed partial class PowerToysFallbackCommandItem : FallbackCommandItem, IFallbackHandler +{ + private readonly string _baseTitle; + private readonly string _baseSubtitle; + private readonly string _baseName; + private readonly Command? _mutableCommand; + + public PowerToysFallbackCommandItem(ICommand command, string title, string subtitle, IIconInfo? icon, IContextItem[]? moreCommands) + : base(command, title) + { + _baseTitle = title ?? string.Empty; + _baseSubtitle = subtitle ?? string.Empty; + _baseName = command?.Name ?? string.Empty; + _mutableCommand = command as Command; + + // Start hidden; we only surface when the query matches + Title = string.Empty; + Subtitle = string.Empty; + if (_mutableCommand is not null) + { + _mutableCommand.Name = string.Empty; + } + + if (icon != null) + { + Icon = icon; + } + + MoreCommands = moreCommands ?? Array.Empty<IContextItem>(); + + // Ensure fallback updates route to this instance + FallbackHandler = this; + } + + public override void UpdateQuery(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + Title = string.Empty; + Subtitle = string.Empty; + if (_mutableCommand is not null) + { + _mutableCommand.Name = string.Empty; + } + + return; + } + + // Simple fuzzy match against title/subtitle; hide if neither match + var titleMatch = Common.Search.FuzzSearch.StringMatcher.FuzzyMatch(query, _baseTitle); + var subtitleMatch = Common.Search.FuzzSearch.StringMatcher.FuzzyMatch(query, _baseSubtitle); + var matches = (titleMatch.Success && titleMatch.Score > 0) || (subtitleMatch.Success && subtitleMatch.Score > 0); + + if (matches) + { + Title = _baseTitle; + Subtitle = _baseSubtitle; + if (_mutableCommand is not null) + { + _mutableCommand.Name = _baseName; + } + } + else + { + Title = string.Empty; + Subtitle = string.Empty; + if (_mutableCommand is not null) + { + _mutableCommand.Name = string.Empty; + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/PowerToysPathResolver.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/PowerToysPathResolver.cs new file mode 100644 index 0000000000..d9ac9443fe --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/PowerToysPathResolver.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using Microsoft.Win32; + +namespace PowerToysExtension.Helpers; + +/// <summary> +/// Helper methods for locating the installed PowerToys binaries. +/// </summary> +internal static class PowerToysPathResolver +{ + private const string PowerToysProtocolKey = @"Software\Classes\powertoys"; + private const string PowerToysUserKey = @"Software\Microsoft\PowerToys"; + + internal static string GetPowerToysInstallPath() + { + var perUser = GetInstallPathFromRegistry(RegistryHive.CurrentUser); + if (!string.IsNullOrEmpty(perUser)) + { + return perUser; + } + + return GetInstallPathFromRegistry(RegistryHive.LocalMachine); + } + + internal static string TryResolveExecutable(string executableName) + { + if (string.IsNullOrEmpty(executableName)) + { + return string.Empty; + } + + var baseDirectory = GetPowerToysInstallPath(); + if (string.IsNullOrEmpty(baseDirectory)) + { + return string.Empty; + } + + var candidate = Path.Combine(baseDirectory, executableName); + return File.Exists(candidate) ? candidate : string.Empty; + } + + private static string GetInstallPathFromRegistry(RegistryHive hive) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, RegistryView.Registry64); + + var protocolPath = GetPathFromProtocolRegistration(baseKey); + if (!string.IsNullOrEmpty(protocolPath)) + { + return protocolPath; + } + + if (hive == RegistryHive.CurrentUser) + { + var userPath = GetPathFromUserRegistration(baseKey); + if (!string.IsNullOrEmpty(userPath)) + { + return userPath; + } + } + } + catch + { + // Ignore registry access failures and fall back to other checks. + } + + return string.Empty; + } + + private static string GetPathFromProtocolRegistration(RegistryKey baseKey) + { + try + { + using var commandKey = baseKey.OpenSubKey($@"{PowerToysProtocolKey}\shell\open\command"); + if (commandKey == null) + { + return string.Empty; + } + + var command = commandKey.GetValue(string.Empty)?.ToString() ?? string.Empty; + if (string.IsNullOrEmpty(command)) + { + return string.Empty; + } + + return ExtractInstallDirectory(command); + } + catch + { + return string.Empty; + } + } + + private static string GetPathFromUserRegistration(RegistryKey baseKey) + { + try + { + using var userKey = baseKey.OpenSubKey(PowerToysUserKey); + if (userKey == null) + { + return string.Empty; + } + + var installedValue = userKey.GetValue("installed"); + if (installedValue != null && installedValue.ToString() == "1") + { + return GetPathFromProtocolRegistration(baseKey); + } + } + catch + { + // Ignore registry access failures. + } + + return string.Empty; + } + + private static string ExtractInstallDirectory(string command) + { + if (string.IsNullOrEmpty(command)) + { + return string.Empty; + } + + try + { + if (command.StartsWith('"')) + { + var closingQuote = command.IndexOf('"', 1); + if (closingQuote > 1) + { + var quotedPath = command.Substring(1, closingQuote - 1); + if (File.Exists(quotedPath)) + { + return Path.GetDirectoryName(quotedPath) ?? string.Empty; + } + } + } + else + { + var parts = command.Split(' '); + if (parts.Length > 0 && File.Exists(parts[0])) + { + return Path.GetDirectoryName(parts[0]) ?? string.Empty; + } + } + } + catch + { + // Fall through and report no path. + } + + return string.Empty; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/PowerToysResourcesHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/PowerToysResourcesHelper.cs new file mode 100644 index 0000000000..91dd3f05b1 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/PowerToysResourcesHelper.cs @@ -0,0 +1,99 @@ +// 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.CommandPalette.Extensions.Toolkit; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Helpers; + +internal static class PowerToysResourcesHelper +{ + private const string SettingsIconRoot = "WinUI3Apps\\Assets\\Settings\\Icons\\"; + + internal static IconInfo IconFromSettingsIcon(string fileName) => IconHelpers.FromRelativePath($"{SettingsIconRoot}{fileName}"); + + public static IconInfo ProviderIcon() => IconFromSettingsIcon("PowerToys.png"); + + public static IconInfo ModuleIcon(this SettingsWindow module) + { + var iconFile = module switch + { + SettingsWindow.ColorPicker => "ColorPicker.png", + SettingsWindow.FancyZones => "FancyZones.png", + SettingsWindow.Hosts => "Hosts.png", + SettingsWindow.PowerOCR => "TextExtractor.png", + SettingsWindow.RegistryPreview => "RegistryPreview.png", + SettingsWindow.MeasureTool => "ScreenRuler.png", + SettingsWindow.ShortcutGuide => "ShortcutGuide.png", + SettingsWindow.CropAndLock => "CropAndLock.png", + SettingsWindow.EnvironmentVariables => "EnvironmentVariables.png", + SettingsWindow.Awake => "Awake.png", + SettingsWindow.PowerRename => "PowerRename.png", + SettingsWindow.Run => "PowerToysRun.png", + SettingsWindow.ImageResizer => "ImageResizer.png", + SettingsWindow.KBM => "KeyboardManager.png", + SettingsWindow.MouseUtils => "MouseUtils.png", + SettingsWindow.Workspaces => "Workspaces.png", + SettingsWindow.AdvancedPaste => "AdvancedPaste.png", + SettingsWindow.CmdPal => "CmdPal.png", + SettingsWindow.ZoomIt => "ZoomIt.png", + SettingsWindow.FileExplorer => "FileExplorerPreview.png", + SettingsWindow.FileLocksmith => "FileLocksmith.png", + SettingsWindow.NewPlus => "NewPlus.png", + SettingsWindow.Peek => "Peek.png", + SettingsWindow.LightSwitch => "LightSwitch.png", + SettingsWindow.AlwaysOnTop => "AlwaysOnTop.png", + SettingsWindow.CmdNotFound => "CommandNotFound.png", + SettingsWindow.MouseWithoutBorders => "MouseWithoutBorders.png", + SettingsWindow.PowerAccent => "QuickAccent.png", + SettingsWindow.PowerLauncher => "PowerToysRun.png", + SettingsWindow.PowerPreview => "FileExplorerPreview.png", + SettingsWindow.Overview => "PowerToys.png", + SettingsWindow.Dashboard => "PowerToys.png", + _ => "PowerToys.png", + }; + + return IconFromSettingsIcon(iconFile); + } + + public static string ModuleDisplayName(this SettingsWindow module) + { + return module switch + { + SettingsWindow.ColorPicker => "Color Picker", + SettingsWindow.FancyZones => "FancyZones", + SettingsWindow.Hosts => "Hosts File Editor", + SettingsWindow.PowerOCR => "Text Extractor", + SettingsWindow.RegistryPreview => "Registry Preview", + SettingsWindow.MeasureTool => "Screen Ruler", + SettingsWindow.ShortcutGuide => "Shortcut Guide", + SettingsWindow.CropAndLock => "Crop And Lock", + SettingsWindow.EnvironmentVariables => "Environment Variables", + SettingsWindow.Awake => "Awake", + SettingsWindow.PowerRename => "PowerRename", + SettingsWindow.Run => "PowerToys Run", + SettingsWindow.ImageResizer => "Image Resizer", + SettingsWindow.KBM => "Keyboard Manager", + SettingsWindow.MouseUtils => "Mouse Utilities", + SettingsWindow.Workspaces => "Workspaces", + SettingsWindow.AdvancedPaste => "Advanced Paste", + SettingsWindow.CmdPal => "Command Palette", + SettingsWindow.ZoomIt => "ZoomIt", + SettingsWindow.FileExplorer => "File Explorer Add-ons", + SettingsWindow.FileLocksmith => "File Locksmith", + SettingsWindow.NewPlus => "New+", + SettingsWindow.Peek => "Peek", + SettingsWindow.LightSwitch => "Light Switch", + SettingsWindow.AlwaysOnTop => "Always On Top", + SettingsWindow.CmdNotFound => "Command Not Found", + SettingsWindow.MouseWithoutBorders => "Mouse Without Borders", + SettingsWindow.PowerAccent => "Quick Accent", + SettingsWindow.Overview => "General", + SettingsWindow.Dashboard => "Dashboard", + SettingsWindow.PowerLauncher => "PowerToys Run", + SettingsWindow.PowerPreview => "File Explorer Add-ons", + _ => module.ToString(), + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/SettingsChangeNotifier.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/SettingsChangeNotifier.cs new file mode 100644 index 0000000000..c271bc853b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Helpers/SettingsChangeNotifier.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Threading; +using ManagedCommon; + +namespace PowerToysExtension.Helpers; + +/// <summary> +/// Watches the global PowerToys settings.json and notifies listeners when it changes. +/// </summary> +internal static class SettingsChangeNotifier +{ + private static readonly object Sync = new(); + private static FileSystemWatcher? _watcher; + private static Timer? _debounceTimer; + + internal static event Action? SettingsChanged; + + static SettingsChangeNotifier() + { + TryStartWatcher(); + } + + private static void TryStartWatcher() + { + try + { + var filePath = ModuleEnablementService.SettingsFilePath; + var directory = Path.GetDirectoryName(filePath); + var fileName = Path.GetFileName(filePath); + + if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName)) + { + return; + } + + _watcher = new FileSystemWatcher(directory) + { + Filter = fileName, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + IncludeSubdirectories = false, + EnableRaisingEvents = true, + }; + + _watcher.Changed += (_, _) => ScheduleRaise(); + _watcher.Created += (_, _) => ScheduleRaise(); + _watcher.Deleted += (_, _) => ScheduleRaise(); + _watcher.Renamed += (_, _) => ScheduleRaise(); + } + catch (Exception ex) + { + Logger.LogError($"SettingsChangeNotifier failed to start: {ex.Message}"); + } + } + + private static void ScheduleRaise() + { + lock (Sync) + { + _debounceTimer?.Dispose(); + _debounceTimer = new Timer( + _ => SettingsChanged?.Invoke(), + null, + 200, + Timeout.Infinite); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Microsoft.CmdPal.Ext.PowerToys.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Microsoft.CmdPal.Ext.PowerToys.csproj new file mode 100644 index 0000000000..8479699538 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Microsoft.CmdPal.Ext.PowerToys.csproj @@ -0,0 +1,88 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="$(RepoRoot)src\CmdPalVersion.props" /> + + <PropertyGroup> + <OutputType>WinExe</OutputType> + <RootNamespace>PowerToysExtension</RootNamespace> + <ApplicationManifest>app.manifest</ApplicationManifest> + <ApplicationIcon>..\..\..\..\runner\svgs\icon.ico</ApplicationIcon> + <PublishProfile>win-$(Platform).pubxml</PublishProfile> + <EnableMsixTooling>false</EnableMsixTooling> + <WindowsPackageType>None</WindowsPackageType> + <GenerateAppxPackageOnBuild>false</GenerateAppxPackageOnBuild> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <Nullable>enable</Nullable> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> + <Version>$(CmdPalVersion)</Version> + </PropertyGroup> + + <PropertyGroup> + <RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == '' and '$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == '' and '$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + </PropertyGroup> + + <ItemGroup> + <Content Include="..\..\..\..\settings-ui\Settings.UI\Assets\Settings\Icons\*.png" Link="WinUI3Apps\Assets\Settings\Icons\%(Filename)%(Extension)"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="Microsoft.CommandPalette.Extensions" /> + <PackageReference Include="Microsoft.Windows.CsWin32"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> + <PackageReference Include="Shmuelie.WinRTServer" /> + </ItemGroup> + + <!-- Enable Single-project MSIX packaging support --> + <ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'"> + <ProjectCapability Include="Msix" /> + </ItemGroup> + + <PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'"> + <HasPackageAndPublishMenu>true</HasPackageAndPublishMenu> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\..\..\common\Common.Search\Common.Search.csproj" /> + <ProjectReference Include="..\..\..\..\common\Common.UI\Common.UI.csproj" /> + <ProjectReference Include="..\..\..\..\common\interop\PowerToys.Interop.vcxproj" /> + <ProjectReference Include="..\..\..\Awake\Awake.ModuleServices\Awake.ModuleServices.csproj" /> + <ProjectReference Include="..\..\..\colorPicker\ColorPicker.ModuleServices\ColorPicker.ModuleServices.csproj" /> + <ProjectReference Include="..\..\..\fancyzones\FancyZonesEditorCommon\FancyZonesEditorCommon.csproj" /> + <ProjectReference Include="..\..\..\Workspaces\Workspaces.ModuleServices\Workspaces.ModuleServices.csproj" /> + </ItemGroup> + + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>Resources.resx</DependentUpon> + </Compile> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <Generator>ResXFileCodeGenerator</Generator> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + </EmbeddedResource> + </ItemGroup> + + <PropertyGroup> + <!-- Always build/publish AOT so the extension ships as native code --> + <SelfContained>true</SelfContained> + <PublishAot>true</PublishAot> + <PublishSingleFile>false</PublishSingleFile> + <PublishTrimmed>true</PublishTrimmed> + <DisableRuntimeMarshalling>false</DisableRuntimeMarshalling> + </PropertyGroup> +</Project> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/AdvancedPasteModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/AdvancedPasteModuleCommandProvider.cs new file mode 100644 index 0000000000..0dd79ba1c3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/AdvancedPasteModuleCommandProvider.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Common.UI; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; + +namespace PowerToysExtension.Modules; + +internal sealed class AdvancedPasteModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsDeepLink.SettingsWindow.AdvancedPaste; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + yield return new ListItem(new OpenAdvancedPasteCommand()) + { + Title = Resources.AdvancedPaste_Open_Title, + Subtitle = Resources.AdvancedPaste_Open_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.AdvancedPaste_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/AlwaysOnTopModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/AlwaysOnTopModuleCommandProvider.cs new file mode 100644 index 0000000000..5974359b5a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/AlwaysOnTopModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class AlwaysOnTopModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.AlwaysOnTop.ModuleDisplayName(); + var icon = SettingsWindow.AlwaysOnTop.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.AlwaysOnTop, title)) + { + Title = title, + Subtitle = Resources.AlwaysOnTop_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/AwakeModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/AwakeModuleCommandProvider.cs new file mode 100644 index 0000000000..5d958da38a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/AwakeModuleCommandProvider.cs @@ -0,0 +1,92 @@ +// 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 Awake.ModuleServices; +using Common.UI; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Pages; +using PowerToysExtension.Properties; + +namespace PowerToysExtension.Modules; + +internal sealed class AwakeModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var items = new List<ListItem>(); + var module = SettingsDeepLink.SettingsWindow.Awake; + var title = module.ModuleDisplayName(); + var icon = PowerToysResourcesHelper.IconFromSettingsIcon("Awake.png"); + var moduleIcon = module.ModuleIcon(); + + items.Add(new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.Awake_Settings_Subtitle, + Icon = moduleIcon, + }); + + if (!ModuleEnablementService.IsModuleEnabled(module)) + { + return items; + } + + // Direct commands surfaced in the PowerToys list page. + ListItem? statusItem = null; + Action refreshStatus = () => + { + if (statusItem is not null) + { + statusItem.Subtitle = AwakeStatusService.GetStatusSubtitle(); + } + }; + + var refreshCommand = new RefreshAwakeStatusCommand(refreshStatus); + + statusItem = new ListItem(new CommandItem(refreshCommand)) + { + Title = Resources.Awake_Status_Title, + Subtitle = AwakeStatusService.GetStatusSubtitle(), + Icon = icon, + }; + items.Add(statusItem); + + items.Add(new ListItem(new StartAwakeCommand(Resources.Awake_KeepIndefinite_Title, () => AwakeService.Instance.SetIndefiniteAsync(), Resources.Awake_SetIndefinite_Toast, refreshStatus)) + { + Title = Resources.Awake_KeepIndefinite_Title, + Subtitle = Resources.Awake_KeepIndefinite_Subtitle, + Icon = icon, + }); + items.Add(new ListItem(new StartAwakeCommand(Resources.Awake_Keep30Min_Title, () => AwakeService.Instance.SetTimedAsync(30), Resources.Awake_Set30Min_Toast, refreshStatus)) + { + Title = Resources.Awake_Keep30Min_Title, + Subtitle = Resources.Awake_Keep30Min_Subtitle, + Icon = icon, + }); + items.Add(new ListItem(new StartAwakeCommand(Resources.Awake_Keep1Hour_Title, () => AwakeService.Instance.SetTimedAsync(60), Resources.Awake_Set1Hour_Toast, refreshStatus)) + { + Title = Resources.Awake_Keep1Hour_Title, + Subtitle = Resources.Awake_Keep1Hour_Subtitle, + Icon = icon, + }); + items.Add(new ListItem(new StartAwakeCommand(Resources.Awake_Keep2Hours_Title, () => AwakeService.Instance.SetTimedAsync(120), Resources.Awake_Set2Hours_Toast, refreshStatus)) + { + Title = Resources.Awake_Keep2Hours_Title, + Subtitle = Resources.Awake_Keep2Hours_Subtitle, + Icon = icon, + }); + items.Add(new ListItem(new StopAwakeCommand(refreshStatus)) + { + Title = Resources.Awake_TurnOff_Title, + Subtitle = Resources.Awake_TurnOff_Subtitle, + Icon = icon, + }); + + return items; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ColorPickerModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ColorPickerModuleCommandProvider.cs new file mode 100644 index 0000000000..6c2a593ff2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ColorPickerModuleCommandProvider.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Common.UI; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Pages; +using PowerToysExtension.Properties; + +namespace PowerToysExtension.Modules; + +internal sealed class ColorPickerModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsDeepLink.SettingsWindow.ColorPicker; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + var commands = new List<ListItem>(); + + commands.Add(new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.ColorPicker_Settings_Subtitle, + Icon = icon, + }); + + if (!ModuleEnablementService.IsModuleEnabled(module)) + { + return commands; + } + + // Direct entries in the module list. + commands.Add(new ListItem(new OpenColorPickerCommand()) + { + Title = Resources.ColorPicker_Open_Title, + Subtitle = Resources.ColorPicker_Open_Subtitle, + Icon = icon, + }); + + commands.Add(new ListItem(new CommandItem(new ColorPickerSavedColorsPage())) + { + Title = Resources.ColorPicker_SavedColors_Title, + Subtitle = Resources.ColorPicker_SavedColors_Subtitle, + Icon = icon, + }); + + return commands; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/CommandNotFoundModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/CommandNotFoundModuleCommandProvider.cs new file mode 100644 index 0000000000..48d6701924 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/CommandNotFoundModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class CommandNotFoundModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.CmdNotFound.ModuleDisplayName(); + var icon = SettingsWindow.CmdNotFound.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.CmdNotFound, title)) + { + Title = title, + Subtitle = Resources.CommandNotFound_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/CropAndLockModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/CropAndLockModuleCommandProvider.cs new file mode 100644 index 0000000000..e39eb8ebef --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/CropAndLockModuleCommandProvider.cs @@ -0,0 +1,53 @@ +// 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 Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class CropAndLockModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.CropAndLock; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + yield return new ListItem(new CropAndLockReparentCommand()) + { + Title = Resources.CropAndLock_Reparent_Title, + Subtitle = Resources.CropAndLock_Reparent_Subtitle, + Icon = icon, + }; + + yield return new ListItem(new CropAndLockThumbnailCommand()) + { + Title = Resources.CropAndLock_Thumbnail_Title, + Subtitle = Resources.CropAndLock_Thumbnail_Subtitle, + Icon = icon, + }; + + yield return new ListItem(new CropAndLockScreenshotCommand()) + { + Title = Resources.CropAndLock_Screenshot_Title, + Subtitle = Resources.CropAndLock_Screenshot_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.CropAndLock_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/EnvironmentVariablesModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/EnvironmentVariablesModuleCommandProvider.cs new file mode 100644 index 0000000000..ad18153001 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/EnvironmentVariablesModuleCommandProvider.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class EnvironmentVariablesModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.EnvironmentVariables; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + yield return new ListItem(new OpenEnvironmentVariablesCommand()) + { + Title = Resources.EnvironmentVariables_Open_Title, + Subtitle = Resources.EnvironmentVariables_Open_Subtitle, + Icon = icon, + }; + + yield return new ListItem(new OpenEnvironmentVariablesAdminCommand()) + { + Title = Resources.EnvironmentVariables_OpenAdmin_Title, + Subtitle = Resources.EnvironmentVariables_OpenAdmin_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.EnvironmentVariables_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/FancyZonesModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/FancyZonesModuleCommandProvider.cs new file mode 100644 index 0000000000..f6a6d10524 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/FancyZonesModuleCommandProvider.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Pages; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class FancyZonesModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.FancyZones; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + yield return new ListItem(new CommandItem(new FancyZonesLayoutsPage())) + { + Title = Resources.FancyZones_Layouts_Title, + Subtitle = Resources.FancyZones_Layouts_Subtitle, + Icon = icon, + }; + + yield return new ListItem(new CommandItem(new FancyZonesMonitorsPage())) + { + Title = Resources.FancyZones_Monitors_Title, + Subtitle = Resources.FancyZones_Monitors_Subtitle, + Icon = icon, + }; + + yield return new ListItem(new OpenFancyZonesEditorCommand()) + { + Title = Resources.FancyZones_OpenEditor_Title, + Subtitle = Resources.FancyZones_OpenEditor_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.FancyZones_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/FileExplorerAddonsModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/FileExplorerAddonsModuleCommandProvider.cs new file mode 100644 index 0000000000..e64ab1414a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/FileExplorerAddonsModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class FileExplorerAddonsModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.FileExplorer.ModuleDisplayName(); + var icon = SettingsWindow.FileExplorer.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.FileExplorer, title)) + { + Title = title, + Subtitle = Resources.FileExplorerAddons_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/FileLocksmithModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/FileLocksmithModuleCommandProvider.cs new file mode 100644 index 0000000000..5c8f7f367d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/FileLocksmithModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class FileLocksmithModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.FileLocksmith.ModuleDisplayName(); + var icon = SettingsWindow.FileLocksmith.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.FileLocksmith, title)) + { + Title = title, + Subtitle = Resources.FileLocksmith_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/HostsModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/HostsModuleCommandProvider.cs new file mode 100644 index 0000000000..c9dd6b04ed --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/HostsModuleCommandProvider.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class HostsModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.Hosts; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + yield return new ListItem(new OpenHostsEditorCommand()) + { + Title = Resources.Hosts_Open_Title, + Subtitle = Resources.Hosts_Open_Subtitle, + Icon = icon, + }; + + yield return new ListItem(new OpenHostsEditorAdminCommand()) + { + Title = Resources.Hosts_OpenAdmin_Title, + Subtitle = Resources.Hosts_OpenAdmin_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.Hosts_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ImageResizerModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ImageResizerModuleCommandProvider.cs new file mode 100644 index 0000000000..627cd0f2a7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ImageResizerModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class ImageResizerModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.ImageResizer.ModuleDisplayName(); + var icon = SettingsWindow.ImageResizer.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.ImageResizer, title)) + { + Title = title, + Subtitle = Resources.ImageResizer_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/KeyboardManagerModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/KeyboardManagerModuleCommandProvider.cs new file mode 100644 index 0000000000..2742db9904 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/KeyboardManagerModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class KeyboardManagerModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.KBM.ModuleDisplayName(); + var icon = SettingsWindow.KBM.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.KBM, title)) + { + Title = title, + Subtitle = Resources.KeyboardManager_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/LightSwitchModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/LightSwitchModuleCommandProvider.cs new file mode 100644 index 0000000000..f7a9b33744 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/LightSwitchModuleCommandProvider.cs @@ -0,0 +1,43 @@ +// 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 Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class LightSwitchModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.LightSwitch; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + var items = new List<ListItem>(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + items.Add(new ListItem(new ToggleLightSwitchCommand()) + { + Title = Resources.LightSwitch_Toggle_Title, + Subtitle = Resources.LightSwitch_Toggle_Subtitle, + Icon = icon, + }); + } + + items.Add(new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.LightSwitch_Settings_Subtitle, + Icon = icon, + }); + + return items; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ModuleCommandProvider.cs new file mode 100644 index 0000000000..4e06731a2d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ModuleCommandProvider.cs @@ -0,0 +1,16 @@ +// 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 Microsoft.CommandPalette.Extensions.Toolkit; + +namespace PowerToysExtension.Modules; + +/// <summary> +/// Base contract for a PowerToys module to expose its command palette entries. +/// </summary> +internal abstract class ModuleCommandProvider +{ + public abstract IEnumerable<ListItem> BuildCommands(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/MouseUtilsModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/MouseUtilsModuleCommandProvider.cs new file mode 100644 index 0000000000..bce2c86e5e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/MouseUtilsModuleCommandProvider.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class MouseUtilsModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.MouseUtils; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsKeyEnabled("FindMyMouse")) + { + yield return new ListItem(new ToggleFindMyMouseCommand()) + { + Title = Resources.MouseUtils_FindMyMouse_Title, + Subtitle = Resources.MouseUtils_FindMyMouse_Subtitle, + Icon = icon, + }; + } + + if (ModuleEnablementService.IsKeyEnabled("MouseHighlighter")) + { + yield return new ListItem(new ToggleMouseHighlighterCommand()) + { + Title = Resources.MouseUtils_Highlighter_Title, + Subtitle = Resources.MouseUtils_Highlighter_Subtitle, + Icon = icon, + }; + } + + if (ModuleEnablementService.IsKeyEnabled("MousePointerCrosshairs")) + { + yield return new ListItem(new ToggleMouseCrosshairsCommand()) + { + Title = Resources.MouseUtils_Crosshairs_Title, + Subtitle = Resources.MouseUtils_Crosshairs_Subtitle, + Icon = icon, + }; + } + + if (ModuleEnablementService.IsKeyEnabled("CursorWrap")) + { + yield return new ListItem(new ToggleCursorWrapCommand()) + { + Title = Resources.MouseUtils_CursorWrap_Title, + Subtitle = Resources.MouseUtils_CursorWrap_Subtitle, + Icon = icon, + }; + } + + if (ModuleEnablementService.IsKeyEnabled("MouseJump")) + { + yield return new ListItem(new ShowMouseJumpPreviewCommand()) + { + Title = Resources.MouseUtils_MouseJump_Title, + Subtitle = Resources.MouseUtils_MouseJump_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.MouseUtils_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/MouseWithoutBordersModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/MouseWithoutBordersModuleCommandProvider.cs new file mode 100644 index 0000000000..d7e96fbf68 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/MouseWithoutBordersModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class MouseWithoutBordersModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.MouseWithoutBorders.ModuleDisplayName(); + var icon = SettingsWindow.MouseWithoutBorders.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.MouseWithoutBorders, title)) + { + Title = title, + Subtitle = Resources.MouseWithoutBorders_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/NewPlusModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/NewPlusModuleCommandProvider.cs new file mode 100644 index 0000000000..7dec548765 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/NewPlusModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class NewPlusModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.NewPlus.ModuleDisplayName(); + var icon = SettingsWindow.NewPlus.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.NewPlus, title)) + { + Title = title, + Subtitle = Resources.NewPlus_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/PeekModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/PeekModuleCommandProvider.cs new file mode 100644 index 0000000000..8c65b95276 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/PeekModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class PeekModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.Peek.ModuleDisplayName(); + var icon = SettingsWindow.Peek.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.Peek, title)) + { + Title = title, + Subtitle = Resources.Peek_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/PowerRenameModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/PowerRenameModuleCommandProvider.cs new file mode 100644 index 0000000000..2cebee25cd --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/PowerRenameModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class PowerRenameModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.PowerRename.ModuleDisplayName(); + var icon = SettingsWindow.PowerRename.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.PowerRename, title)) + { + Title = title, + Subtitle = Resources.PowerRename_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/PowerToysRunModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/PowerToysRunModuleCommandProvider.cs new file mode 100644 index 0000000000..35f75467b7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/PowerToysRunModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class PowerToysRunModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.PowerLauncher.ModuleDisplayName(); + var icon = SettingsWindow.PowerLauncher.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.PowerLauncher, title)) + { + Title = title, + Subtitle = Resources.PowerToysRun_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/QuickAccentModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/QuickAccentModuleCommandProvider.cs new file mode 100644 index 0000000000..b0c97dfa99 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/QuickAccentModuleCommandProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class QuickAccentModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var title = SettingsWindow.PowerAccent.ModuleDisplayName(); + var icon = SettingsWindow.PowerAccent.ModuleIcon(); + + yield return new ListItem(new OpenInSettingsCommand(SettingsWindow.PowerAccent, title)) + { + Title = title, + Subtitle = Resources.QuickAccent_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/RegistryPreviewModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/RegistryPreviewModuleCommandProvider.cs new file mode 100644 index 0000000000..9069931a82 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/RegistryPreviewModuleCommandProvider.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class RegistryPreviewModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.RegistryPreview; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + yield return new ListItem(new OpenRegistryPreviewCommand()) + { + Title = Resources.RegistryPreview_Open_Title, + Subtitle = Resources.RegistryPreview_Open_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.RegistryPreview_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ScreenRulerModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ScreenRulerModuleCommandProvider.cs new file mode 100644 index 0000000000..62591f542f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ScreenRulerModuleCommandProvider.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class ScreenRulerModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.MeasureTool; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + yield return new ListItem(new ToggleScreenRulerCommand()) + { + Title = Resources.ScreenRuler_Toggle_Title, + Subtitle = Resources.ScreenRuler_Toggle_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.ScreenRuler_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ShortcutGuideModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ShortcutGuideModuleCommandProvider.cs new file mode 100644 index 0000000000..1c194b7d14 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ShortcutGuideModuleCommandProvider.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class ShortcutGuideModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.ShortcutGuide; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + yield return new ListItem(new ToggleShortcutGuideCommand()) + { + Title = Resources.ShortcutGuide_Toggle_Title, + Subtitle = Resources.ShortcutGuide_Toggle_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.ShortcutGuide_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/TextExtractorModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/TextExtractorModuleCommandProvider.cs new file mode 100644 index 0000000000..ea72d7611b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/TextExtractorModuleCommandProvider.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class TextExtractorModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.PowerOCR; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + yield return new ListItem(new ToggleTextExtractorCommand()) + { + Title = Resources.TextExtractor_Toggle_Title, + Subtitle = Resources.TextExtractor_Toggle_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.TextExtractor_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/WorkspacesModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/WorkspacesModuleCommandProvider.cs new file mode 100644 index 0000000000..0cc315bbf9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/WorkspacesModuleCommandProvider.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Common.UI; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using Workspaces.ModuleServices; +using WorkspacesCsharpLibrary.Data; + +namespace PowerToysExtension.Modules; + +internal sealed class WorkspacesModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var items = new List<ListItem>(); + var module = SettingsDeepLink.SettingsWindow.Workspaces; + var title = module.ModuleDisplayName(); + var icon = PowerToysResourcesHelper.IconFromSettingsIcon("Workspaces.png"); + var moduleIcon = module.ModuleIcon(); + + items.Add(new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.Workspaces_Settings_Subtitle, + Icon = moduleIcon, + }); + + if (!ModuleEnablementService.IsModuleEnabled(module)) + { + return items; + } + + // Settings entry plus common actions. + items.Add(new ListItem(new OpenWorkspaceEditorCommand()) + { + Title = Resources.Workspaces_OpenEditor_Title, + Subtitle = Resources.Workspaces_OpenEditor_Subtitle, + Icon = icon, + }); + + // Per-workspace entries via the shared service. + foreach (var workspace in LoadWorkspaces()) + { + if (string.IsNullOrWhiteSpace(workspace.Id) || string.IsNullOrWhiteSpace(workspace.Name)) + { + continue; + } + + items.Add(new WorkspaceListItem(workspace, icon)); + } + + return items; + } + + private static IReadOnlyList<ProjectWrapper> LoadWorkspaces() + { + var result = WorkspaceService.Instance.GetWorkspacesAsync().GetAwaiter().GetResult(); + return result.Success && result.Value is not null ? result.Value : System.Array.Empty<ProjectWrapper>(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ZoomItModuleCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ZoomItModuleCommandProvider.cs new file mode 100644 index 0000000000..0392e4a759 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Modules/ZoomItModuleCommandProvider.cs @@ -0,0 +1,70 @@ +// 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 Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; +using static Common.UI.SettingsDeepLink; + +namespace PowerToysExtension.Modules; + +internal sealed class ZoomItModuleCommandProvider : ModuleCommandProvider +{ + public override IEnumerable<ListItem> BuildCommands() + { + var module = SettingsWindow.ZoomIt; + var title = module.ModuleDisplayName(); + var icon = module.ModuleIcon(); + + if (ModuleEnablementService.IsModuleEnabled(module)) + { + // Action commands via ZoomIt IPC + yield return new ListItem(new ZoomItActionCommand("zoom", Resources.ZoomIt_Zoom_Title)) + { + Title = Resources.ZoomIt_Zoom_Title, + Subtitle = Resources.ZoomIt_Zoom_Subtitle, + Icon = icon, + }; + yield return new ListItem(new ZoomItActionCommand("draw", Resources.ZoomIt_Draw_Title)) + { + Title = Resources.ZoomIt_Draw_Title, + Subtitle = Resources.ZoomIt_Draw_Subtitle, + Icon = icon, + }; + yield return new ListItem(new ZoomItActionCommand("break", Resources.ZoomIt_Break_Title)) + { + Title = Resources.ZoomIt_Break_Title, + Subtitle = Resources.ZoomIt_Break_Subtitle, + Icon = icon, + }; + yield return new ListItem(new ZoomItActionCommand("liveZoom", Resources.ZoomIt_LiveZoom_Title)) + { + Title = Resources.ZoomIt_LiveZoom_Title, + Subtitle = Resources.ZoomIt_LiveZoom_Subtitle, + Icon = icon, + }; + yield return new ListItem(new ZoomItActionCommand("snip", Resources.ZoomIt_Snip_Title)) + { + Title = Resources.ZoomIt_Snip_Title, + Subtitle = Resources.ZoomIt_Snip_Subtitle, + Icon = icon, + }; + yield return new ListItem(new ZoomItActionCommand("record", Resources.ZoomIt_Record_Title)) + { + Title = Resources.ZoomIt_Record_Title, + Subtitle = Resources.ZoomIt_Record_Subtitle, + Icon = icon, + }; + } + + yield return new ListItem(new OpenInSettingsCommand(module, title)) + { + Title = title, + Subtitle = Resources.ZoomIt_Settings_Subtitle, + Icon = icon, + }; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/ColorPickerSavedColorsPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/ColorPickerSavedColorsPage.cs new file mode 100644 index 0000000000..54dc15c0c5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/ColorPickerSavedColorsPage.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.Linq; +using System.Text; +using ColorPicker.ModuleServices; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; + +namespace PowerToysExtension.Pages; + +internal sealed partial class ColorPickerSavedColorsPage : DynamicListPage +{ + private static readonly CompositeFormat NoMatchingSavedColorsFormat = CompositeFormat.Parse(Resources.ColorPicker_NoMatchingSavedColors_Subtitle); + + private readonly CommandItem _emptyContent; + + public ColorPickerSavedColorsPage() + { + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("ColorPicker.png"); + Title = Resources.ColorPicker_SavedColors_Title; + Name = "ColorPickerSavedColors"; + Id = "com.microsoft.powertoys.colorpicker.savedColors"; + + _emptyContent = new CommandItem() + { + Title = Resources.ColorPicker_NoSavedColors_Title, + Subtitle = Resources.ColorPicker_NoSavedColors_Subtitle, + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("ColorPicker.png"), + }; + + EmptyContent = _emptyContent; + } + + public override IListItem[] GetItems() + { + var result = ColorPickerService.Instance.GetSavedColorsAsync().GetAwaiter().GetResult(); + if (!result.Success || result.Value is null || result.Value.Count == 0) + { + return Array.Empty<IListItem>(); + } + + var search = SearchText; + var filtered = string.IsNullOrWhiteSpace(search) + ? result.Value + : result.Value.Where(saved => + saved.Hex.Contains(search, StringComparison.OrdinalIgnoreCase) || + saved.Formats.Any(f => f.Value.Contains(search, StringComparison.OrdinalIgnoreCase) || + f.Format.Contains(search, StringComparison.OrdinalIgnoreCase))); + + var items = filtered.Select(saved => + { + var copyValue = SelectPreferredFormat(saved); + var subtitle = BuildSubtitle(saved); + + var command = new CopySavedColorCommand(saved, copyValue); + return (IListItem)new ListItem(new CommandItem(command)) + { + Title = saved.Hex, + Subtitle = subtitle, + Icon = ColorSwatchIconFactory.Create(saved.R, saved.G, saved.B, saved.A), + }; + }).ToArray(); + + return items; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + _emptyContent.Subtitle = string.IsNullOrWhiteSpace(newSearch) + ? Resources.ColorPicker_NoSavedColors_Subtitle + : string.Format(CultureInfo.CurrentCulture, NoMatchingSavedColorsFormat, newSearch); + + RaiseItemsChanged(0); + } + + private static string SelectPreferredFormat(SavedColor saved) => saved.Hex; + + private static string BuildSubtitle(SavedColor saved) + { + var sb = new StringBuilder(); + foreach (var format in saved.Formats.Take(3)) + { + if (sb.Length > 0) + { + sb.Append(" · "); + } + + sb.Append(format.Value); + } + + return sb.Length > 0 ? sb.ToString() : saved.Hex; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/FancyZonesLayoutsPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/FancyZonesLayoutsPage.cs new file mode 100644 index 0000000000..897b7fa46a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/FancyZonesLayoutsPage.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. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; + +namespace PowerToysExtension.Pages; + +internal sealed partial class FancyZonesLayoutsPage : DynamicListPage +{ + private readonly CommandItem _emptyMessage; + + public FancyZonesLayoutsPage() + { + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("FancyZones.png"); + Name = Title = Resources.FancyZones_Layouts_Title; + Id = "com.microsoft.cmdpal.powertoys.fancyzones.layouts"; + + _emptyMessage = new CommandItem() + { + Title = Resources.FancyZones_NoLayoutsFound_Title, + Subtitle = Resources.FancyZones_NoLayoutsFound_Subtitle, + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("FancyZones.png"), + }; + EmptyContent = _emptyMessage; + + // Purge orphaned cache files in background (non-blocking) + Task.Run(FancyZonesThumbnailRenderer.PurgeOrphanedCache); + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + RaiseItemsChanged(0); + } + + public override IListItem[] GetItems() + { + try + { + var layouts = FancyZonesDataService.GetLayouts(); + if (!string.IsNullOrWhiteSpace(SearchText)) + { + layouts = layouts + .Where(l => l.Title.Contains(SearchText, StringComparison.CurrentCultureIgnoreCase) || + l.Subtitle.Contains(SearchText, StringComparison.CurrentCultureIgnoreCase)) + .ToArray(); + } + + if (layouts.Count == 0) + { + return Array.Empty<IListItem>(); + } + + _ = FancyZonesDataService.TryGetMonitors(out var monitors, out _); + var fallbackIcon = PowerToysResourcesHelper.IconFromSettingsIcon("FancyZones.png"); + + var items = new List<IListItem>(layouts.Count); + foreach (var layout in layouts) + { + var defaultCommand = new ApplyFancyZonesLayoutCommand(layout, monitor: null); + + var item = new FancyZonesLayoutListItem(defaultCommand, layout, fallbackIcon) + { + MoreCommands = BuildLayoutContext(layout, monitors), + }; + + items.Add(item); + } + + return items.ToArray(); + } + catch (Exception ex) + { + _emptyMessage.Subtitle = ex.Message; + return Array.Empty<IListItem>(); + } + } + + private static IContextItem[] BuildLayoutContext(FancyZonesLayoutDescriptor layout, IReadOnlyList<FancyZonesMonitorDescriptor> monitors) + { + var commands = new List<IContextItem>(monitors.Count); + + for (var i = 0; i < monitors.Count; i++) + { + var monitor = monitors[i]; + commands.Add(new CommandContextItem(new ApplyFancyZonesLayoutCommand(layout, monitor)) + { + Title = string.Format(CultureInfo.CurrentCulture, "Apply to {0}", monitor.Title), + Subtitle = monitor.Subtitle, + }); + } + + return commands.ToArray(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/FancyZonesMonitorLayoutPickerPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/FancyZonesMonitorLayoutPickerPage.cs new file mode 100644 index 0000000000..d798bce768 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/FancyZonesMonitorLayoutPickerPage.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; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; + +namespace PowerToysExtension.Pages; + +internal sealed partial class FancyZonesMonitorLayoutPickerPage : DynamicListPage +{ + private static readonly CompositeFormat SetActiveLayoutForFormat = CompositeFormat.Parse(Resources.FancyZones_SetActiveLayoutFor_Format); + + private readonly FancyZonesMonitorDescriptor _monitor; + private readonly CommandItem _emptyMessage; + + public FancyZonesMonitorLayoutPickerPage(FancyZonesMonitorDescriptor monitor) + { + _monitor = monitor; + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("FancyZones.png"); + Name = Title = string.Format(CultureInfo.CurrentCulture, SetActiveLayoutForFormat, _monitor.Title); + Id = $"com.microsoft.cmdpal.powertoys.fancyzones.monitor.{_monitor.Index}.layouts"; + + _emptyMessage = new CommandItem() + { + Title = Resources.FancyZones_NoLayoutsFound_Title, + Subtitle = Resources.FancyZones_NoLayoutsFound_Subtitle, + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("FancyZones.png"), + }; + EmptyContent = _emptyMessage; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + RaiseItemsChanged(0); + } + + public override IListItem[] GetItems() + { + var layouts = FancyZonesDataService.GetLayouts(); + if (!string.IsNullOrWhiteSpace(SearchText)) + { + layouts = layouts + .Where(l => l.Title.Contains(SearchText, StringComparison.CurrentCultureIgnoreCase) || + l.Subtitle.Contains(SearchText, StringComparison.CurrentCultureIgnoreCase)) + .ToArray(); + } + + if (layouts.Count == 0) + { + return Array.Empty<IListItem>(); + } + + var fallbackIcon = PowerToysResourcesHelper.IconFromSettingsIcon("FancyZones.png"); + var items = new List<IListItem>(layouts.Count); + foreach (var layout in layouts) + { + var command = new ApplyFancyZonesLayoutCommand(layout, _monitor); + var item = new FancyZonesLayoutListItem(command, layout, fallbackIcon); + items.Add(item); + } + + return [.. items]; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/FancyZonesMonitorsPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/FancyZonesMonitorsPage.cs new file mode 100644 index 0000000000..1da279422b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/FancyZonesMonitorsPage.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; + +namespace PowerToysExtension.Pages; + +internal sealed partial class FancyZonesMonitorsPage : DynamicListPage +{ + private static readonly CompositeFormat CurrentLayoutFormat = CompositeFormat.Parse(Resources.FancyZones_CurrentLayout_Format); + + private readonly CommandItem _emptyMessage; + + public FancyZonesMonitorsPage() + { + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("FancyZones.png"); + Name = Title = Resources.FancyZones_Monitors_Title; + Id = "com.microsoft.cmdpal.powertoys.fancyzones.monitors"; + + _emptyMessage = new CommandItem() + { + Title = Resources.FancyZones_NoMonitorsFound_Title, + Subtitle = Resources.FancyZones_NoMonitorsFound_Subtitle, + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("FancyZones.png"), + }; + EmptyContent = _emptyMessage; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + RaiseItemsChanged(0); + } + + public override IListItem[] GetItems() + { + if (!FancyZonesDataService.TryGetMonitors(out var monitors, out var error)) + { + _emptyMessage.Subtitle = error; + return Array.Empty<IListItem>(); + } + + var monitorIcon = new IconInfo("\uE7F4"); + var items = new List<IListItem>(monitors.Count); + + foreach (var monitor in monitors) + { + if (!string.IsNullOrWhiteSpace(SearchText) && + !monitor.Title.Contains(SearchText, StringComparison.CurrentCultureIgnoreCase) && + !monitor.Subtitle.Contains(SearchText, StringComparison.CurrentCultureIgnoreCase)) + { + continue; + } + + var layoutDescription = FancyZonesDataService.TryGetAppliedLayoutForMonitor(monitor.Data, out var applied) && applied is not null + ? string.Format(CultureInfo.CurrentCulture, CurrentLayoutFormat, applied.Value.Type) + : Resources.FancyZones_CurrentLayout_Unknown; + + var item = new FancyZonesMonitorListItem(monitor, layoutDescription, monitorIcon); + items.Add(item); + } + + return [.. items]; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/PowerToysExtensionPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/PowerToysExtensionPage.cs new file mode 100644 index 0000000000..7082169629 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/PowerToysExtensionPage.cs @@ -0,0 +1,47 @@ +// 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 Awake.ModuleServices; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Commands; +using PowerToysExtension.Properties; + +namespace PowerToysExtension; + +internal sealed partial class PowerToysExtensionPage : ListPage +{ + public PowerToysExtensionPage() + { + Icon = Helpers.PowerToysResourcesHelper.IconFromSettingsIcon("PowerToys.png"); + Title = Resources.PowerToys_DisplayName; + Name = Resources.PowerToysExtension_CommandsName; + } + + public override IListItem[] GetItems() + { + return [ + new ListItem(new LaunchModuleCommand("PowerToys", executableName: "PowerToys.exe", displayName: Resources.PowerToysExtension_OpenPowerToys_Title)) + { + Title = Resources.PowerToysExtension_OpenPowerToys_Title, + Subtitle = Resources.PowerToysExtension_OpenPowerToys_Subtitle, + }, + new ListItem(new OpenPowerToysSettingsCommand("PowerToys", "General")) + { + Title = Resources.PowerToysExtension_OpenSettings_Title, + Subtitle = Resources.PowerToysExtension_OpenSettings_Subtitle, + }, + new ListItem(new OpenPowerToysSettingsCommand("Workspaces", "Workspaces")) + { + Title = Resources.PowerToysExtension_OpenWorkspacesSettings_Title, + Subtitle = Resources.PowerToysExtension_OpenWorkspacesSettings_Subtitle, + }, + new ListItem(new OpenWorkspaceEditorCommand()) + { + Title = Resources.PowerToysExtension_OpenWorkspacesEditor_Title, + Subtitle = Resources.PowerToysExtension_OpenWorkspacesEditor_Subtitle, + }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/PowerToysListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/PowerToysListPage.cs new file mode 100644 index 0000000000..b91c8e9a5c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Pages/PowerToysListPage.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; + +namespace PowerToysExtension.Pages; + +internal sealed partial class PowerToysListPage : ListPage +{ + private readonly CommandItem _empty; + + public PowerToysListPage() + { + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("PowerToys.png"); + Name = Title = Resources.PowerToys_DisplayName; + Id = "com.microsoft.cmdpal.powertoys"; + SettingsChangeNotifier.SettingsChanged += OnSettingsChanged; + _empty = new CommandItem() + { + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("PowerToys.png"), + Title = Resources.PowerToys_NoMatchingModule, + Subtitle = SearchText, + }; + EmptyContent = _empty; + } + + private void OnSettingsChanged() + { + RaiseItemsChanged(0); + } + + public override IListItem[] GetItems() => ModuleCommandCatalog.GetAllItems(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/PowerToysCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/PowerToysCommandsProvider.cs new file mode 100644 index 0000000000..f3d22c7e5a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/PowerToysCommandsProvider.cs @@ -0,0 +1,47 @@ +// 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 ManagedCommon; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; + +namespace PowerToysExtension; + +public sealed partial class PowerToysCommandsProvider : CommandProvider +{ + public PowerToysCommandsProvider() + { + DisplayName = Resources.PowerToys_DisplayName; + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("PowerToys.png"); + } + + public override ICommandItem[] TopLevelCommands() => + [ + new CommandItem(new Pages.PowerToysListPage()) + { + Title = Resources.PowerToys_DisplayName, + Subtitle = Resources.PowerToys_Subtitle, + } + ]; + + public override IFallbackCommandItem[] FallbackCommands() + { + var items = ModuleCommandCatalog.GetAllItems(); + var fallbacks = new List<IFallbackCommandItem>(items.Length); + foreach (var item in items) + { + if (item?.Command is not ICommand cmd) + { + continue; + } + + fallbacks.Add(new PowerToysFallbackCommandItem(cmd, item.Title, item.Subtitle, item.Icon, item.MoreCommands)); + } + + return fallbacks.ToArray(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/PowerToysExtension.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/PowerToysExtension.cs new file mode 100644 index 0000000000..f4100db51a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/PowerToysExtension.cs @@ -0,0 +1,41 @@ +// 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 ManagedCommon; +using Microsoft.CommandPalette.Extensions; + +namespace PowerToysExtension; + +[Guid("7EC02C7D-8F98-4A2E-9F23-B58C2C2F2B17")] +public sealed partial class PowerToysExtension : IExtension, IDisposable +{ + private readonly ManualResetEvent _extensionDisposedEvent; + + private readonly PowerToysExtensionCommandsProvider _provider = new(); + + public PowerToysExtension(ManualResetEvent extensionDisposedEvent) + { + this._extensionDisposedEvent = extensionDisposedEvent; + Logger.LogInfo($"PowerToysExtension constructed. ProcArch={RuntimeInformation.ProcessArchitecture} OSArch={RuntimeInformation.OSArchitecture} BaseDir={AppContext.BaseDirectory}"); + } + + public object? GetProvider(ProviderType providerType) + { + Logger.LogInfo($"GetProvider requested: {providerType}"); + return providerType switch + { + ProviderType.Commands => _provider, + _ => null, + }; + } + + public void Dispose() + { + Logger.LogInfo("PowerToysExtension disposing; signalling exit."); + this._extensionDisposedEvent.Set(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/PowerToysExtensionCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/PowerToysExtensionCommandsProvider.cs new file mode 100644 index 0000000000..a6c3dc727e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/PowerToysExtensionCommandsProvider.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using PowerToysExtension.Helpers; +using PowerToysExtension.Properties; + +namespace PowerToysExtension; + +public partial class PowerToysExtensionCommandsProvider : CommandProvider +{ + private readonly ICommandItem[] _commands; + + public PowerToysExtensionCommandsProvider() + { + DisplayName = Resources.PowerToys_DisplayName; + Icon = PowerToysResourcesHelper.IconFromSettingsIcon("PowerToys.png"); + _commands = [ + new CommandItem(new Pages.PowerToysListPage()) + { + Title = Resources.PowerToys_DisplayName, + Subtitle = Resources.PowerToys_Subtitle, + }, + ]; + } + + public override ICommandItem[] TopLevelCommands() + { + return _commands; + } + + public override IFallbackCommandItem[] FallbackCommands() + { + var items = ModuleCommandCatalog.GetAllItems(); + var fallbacks = new List<IFallbackCommandItem>(items.Length); + foreach (var item in items) + { + if (item?.Command is not ICommand cmd) + { + continue; + } + + fallbacks.Add(new PowerToysFallbackCommandItem(cmd, item.Title, item.Subtitle, item.Icon, item.MoreCommands)); + } + + return fallbacks.ToArray(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Program.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Program.cs new file mode 100644 index 0000000000..2706f50f90 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Program.cs @@ -0,0 +1,70 @@ +// 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 ManagedCommon; +using Microsoft.CommandPalette.Extensions; +using Shmuelie.WinRTServer; +using Shmuelie.WinRTServer.CsWinRT; + +namespace PowerToysExtension; + +public class Program +{ + [MTAThread] + public static void Main(string[] args) + { + try + { + // Initialize per-extension log under CmdPal/PowerToysExtension. + Logger.InitializeLogger("\\CmdPal\\PowerToysExtension\\Logs"); + Logger.LogInfo($"PowerToysExtension starting. Args=\"{string.Join(' ', args)}\" ProcArch={RuntimeInformation.ProcessArchitecture} OSArch={RuntimeInformation.OSArchitecture} BaseDir={AppContext.BaseDirectory}"); + } + catch + { + // Continue even if logging fails. + } + + try + { + if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") + { + Logger.LogInfo("RegisterProcessAsComServer mode detected."); + ComServer server = new(); + ManualResetEvent extensionDisposedEvent = new(false); + try + { + PowerToysExtension extensionInstance = new(extensionDisposedEvent); + Logger.LogInfo("Registering extension via Shmuelie.WinRTServer."); + server.RegisterClass<PowerToysExtension, IExtension>(() => extensionInstance); + server.Start(); + Logger.LogInfo("Extension instance registered; waiting for disposal signal."); + + extensionDisposedEvent.WaitOne(); + Logger.LogInfo("Extension disposed signal received; exiting server loop."); + } + finally + { + server.Stop(); + server.UnsafeDispose(); + } + } + else + { + Console.WriteLine("Not being launched as a Extension... exiting."); + Logger.LogInfo("Exited: not launched with -RegisterProcessAsComServer."); + } + } + catch (Exception ex) + { + Logger.LogError("Unhandled exception in PowerToysExtension.Main", ex); + throw; + } + finally + { + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..d561fcd7a4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Properties/Resources.Designer.cs @@ -0,0 +1,1521 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// 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. +// </auto-generated> +//------------------------------------------------------------------------------ + +namespace PowerToysExtension.Properties { + using System; + + + /// <summary> + /// A strongly-typed resource class, for looking up localized strings, etc. + /// </summary> + // 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", "17.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() { + } + + /// <summary> + /// Returns the cached ResourceManager instance used by this class. + /// </summary> + [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("PowerToysExtension.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// <summary> + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// <summary> + /// Looks up a localized string similar to Launch the Advanced Paste UI. + /// </summary> + internal static string AdvancedPaste_Open_Subtitle { + get { + return ResourceManager.GetString("AdvancedPaste_Open_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Advanced Paste. + /// </summary> + internal static string AdvancedPaste_Open_Title { + get { + return ResourceManager.GetString("AdvancedPaste_Open_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Advanced Paste settings. + /// </summary> + internal static string AdvancedPaste_Settings_Subtitle { + get { + return ResourceManager.GetString("AdvancedPaste_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Always On Top settings. + /// </summary> + internal static string AlwaysOnTop_Settings_Subtitle { + get { + return ResourceManager.GetString("AlwaysOnTop_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Run Awake timed for 1 hour. + /// </summary> + internal static string Awake_Keep1Hour_Subtitle { + get { + return ResourceManager.GetString("Awake_Keep1Hour_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake: Keep awake for 1 hour. + /// </summary> + internal static string Awake_Keep1Hour_Title { + get { + return ResourceManager.GetString("Awake_Keep1Hour_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Run Awake timed for 2 hours. + /// </summary> + internal static string Awake_Keep2Hours_Subtitle { + get { + return ResourceManager.GetString("Awake_Keep2Hours_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake: Keep awake for 2 hours. + /// </summary> + internal static string Awake_Keep2Hours_Title { + get { + return ResourceManager.GetString("Awake_Keep2Hours_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Run Awake timed for 30 minutes. + /// </summary> + internal static string Awake_Keep30Min_Subtitle { + get { + return ResourceManager.GetString("Awake_Keep30Min_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake: Keep awake for 30 minutes. + /// </summary> + internal static string Awake_Keep30Min_Title { + get { + return ResourceManager.GetString("Awake_Keep30Min_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Run Awake in indefinite mode. + /// </summary> + internal static string Awake_KeepIndefinite_Subtitle { + get { + return ResourceManager.GetString("Awake_KeepIndefinite_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake: Keep awake indefinitely. + /// </summary> + internal static string Awake_KeepIndefinite_Title { + get { + return ResourceManager.GetString("Awake_KeepIndefinite_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake set for 1 hour. + /// </summary> + internal static string Awake_Set1Hour_Toast { + get { + return ResourceManager.GetString("Awake_Set1Hour_Toast", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake set for 2 hours. + /// </summary> + internal static string Awake_Set2Hours_Toast { + get { + return ResourceManager.GetString("Awake_Set2Hours_Toast", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake set for 30 minutes. + /// </summary> + internal static string Awake_Set30Min_Toast { + get { + return ResourceManager.GetString("Awake_Set30Min_Toast", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake set to indefinite. + /// </summary> + internal static string Awake_SetIndefinite_Toast { + get { + return ResourceManager.GetString("Awake_SetIndefinite_Toast", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Awake settings. + /// </summary> + internal static string Awake_Settings_Subtitle { + get { + return ResourceManager.GetString("Awake_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake: Current status. + /// </summary> + internal static string Awake_Status_Title { + get { + return ResourceManager.GetString("Awake_Status_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Switch Awake back to Off. + /// </summary> + internal static string Awake_TurnOff_Subtitle { + get { + return ResourceManager.GetString("Awake_TurnOff_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Awake: Turn off. + /// </summary> + internal static string Awake_TurnOff_Title { + get { + return ResourceManager.GetString("Awake_TurnOff_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No saved colors matching '{0}'. + /// </summary> + internal static string ColorPicker_NoMatchingSavedColors_Subtitle { + get { + return ResourceManager.GetString("ColorPicker_NoMatchingSavedColors_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Pick a color first, then try again.. + /// </summary> + internal static string ColorPicker_NoSavedColors_Subtitle { + get { + return ResourceManager.GetString("ColorPicker_NoSavedColors_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No saved colors. + /// </summary> + internal static string ColorPicker_NoSavedColors_Title { + get { + return ResourceManager.GetString("ColorPicker_NoSavedColors_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Start a color pick session. + /// </summary> + internal static string ColorPicker_Open_Subtitle { + get { + return ResourceManager.GetString("ColorPicker_Open_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Color Picker. + /// </summary> + internal static string ColorPicker_Open_Title { + get { + return ResourceManager.GetString("ColorPicker_Open_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Browse and copy saved colors. + /// </summary> + internal static string ColorPicker_SavedColors_Subtitle { + get { + return ResourceManager.GetString("ColorPicker_SavedColors_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Saved colors. + /// </summary> + internal static string ColorPicker_SavedColors_Title { + get { + return ResourceManager.GetString("ColorPicker_SavedColors_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Color Picker settings. + /// </summary> + internal static string ColorPicker_Settings_Subtitle { + get { + return ResourceManager.GetString("ColorPicker_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Command Not Found settings. + /// </summary> + internal static string CommandNotFound_Settings_Subtitle { + get { + return ResourceManager.GetString("CommandNotFound_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to n/a. + /// </summary> + internal static string Common_NotAvailable { + get { + return ResourceManager.GetString("Common_NotAvailable", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Create a cropped reparented window. + /// </summary> + internal static string CropAndLock_Reparent_Subtitle { + get { + return ResourceManager.GetString("CropAndLock_Reparent_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Crop and Lock (Reparent). + /// </summary> + internal static string CropAndLock_Reparent_Title { + get { + return ResourceManager.GetString("CropAndLock_Reparent_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Crop and Lock settings. + /// </summary> + internal static string CropAndLock_Settings_Subtitle { + get { + return ResourceManager.GetString("CropAndLock_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Create a cropped thumbnail window. + /// </summary> + internal static string CropAndLock_Thumbnail_Subtitle { + get { + return ResourceManager.GetString("CropAndLock_Thumbnail_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Crop and Lock (Thumbnail). + /// </summary> + internal static string CropAndLock_Thumbnail_Title { + get { + return ResourceManager.GetString("CropAndLock_Thumbnail_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Crop and Lock (Screenshot). + /// </summary> + internal static string CropAndLock_Screenshot_Title { + get { + return ResourceManager.GetString("CropAndLock_Screenshot_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Create a cropped screenshot window. + /// </summary> + internal static string CropAndLock_Screenshot_Subtitle { + get { + return ResourceManager.GetString("CropAndLock_Screenshot_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Launch Environment Variables editor. + /// </summary> + internal static string EnvironmentVariables_Open_Subtitle { + get { + return ResourceManager.GetString("EnvironmentVariables_Open_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Environment Variables. + /// </summary> + internal static string EnvironmentVariables_Open_Title { + get { + return ResourceManager.GetString("EnvironmentVariables_Open_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Launch Environment Variables editor as admin. + /// </summary> + internal static string EnvironmentVariables_OpenAdmin_Subtitle { + get { + return ResourceManager.GetString("EnvironmentVariables_OpenAdmin_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Environment Variables (Admin). + /// </summary> + internal static string EnvironmentVariables_OpenAdmin_Title { + get { + return ResourceManager.GetString("EnvironmentVariables_OpenAdmin_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Environment Variables settings. + /// </summary> + internal static string EnvironmentVariables_Settings_Subtitle { + get { + return ResourceManager.GetString("EnvironmentVariables_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Apply to {0}. + /// </summary> + internal static string FancyZones_ApplyTo_Format { + get { + return ResourceManager.GetString("FancyZones_ApplyTo_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Current layout: {0}. + /// </summary> + internal static string FancyZones_CurrentLayout_Format { + get { + return ResourceManager.GetString("FancyZones_CurrentLayout_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Current layout: unknown. + /// </summary> + internal static string FancyZones_CurrentLayout_Unknown { + get { + return ResourceManager.GetString("FancyZones_CurrentLayout_Unknown", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Custom • {0} zones. + /// </summary> + internal static string FancyZones_Custom_Zones_Format { + get { + return ResourceManager.GetString("FancyZones_Custom_Zones_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Custom canvas • {0} zones. + /// </summary> + internal static string FancyZones_CustomCanvas_Zones_Format { + get { + return ResourceManager.GetString("FancyZones_CustomCanvas_Zones_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Custom grid • {0} zones. + /// </summary> + internal static string FancyZones_CustomGrid_Zones_Format { + get { + return ResourceManager.GetString("FancyZones_CustomGrid_Zones_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to DPI. + /// </summary> + internal static string FancyZones_DPI { + get { + return ResourceManager.GetString("FancyZones_DPI", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Instance. + /// </summary> + internal static string FancyZones_Instance { + get { + return ResourceManager.GetString("FancyZones_Instance", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Layout applied.. + /// </summary> + internal static string FancyZones_LayoutApplied { + get { + return ResourceManager.GetString("FancyZones_LayoutApplied", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Layout applied, but FancyZones could not be notified: {0}. + /// </summary> + internal static string FancyZones_LayoutAppliedNotifyFailed_Format { + get { + return ResourceManager.GetString("FancyZones_LayoutAppliedNotifyFailed_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Apply a layout to all monitors or a specific monitor. + /// </summary> + internal static string FancyZones_Layouts_Subtitle { + get { + return ResourceManager.GetString("FancyZones_Layouts_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to FancyZones: Layouts. + /// </summary> + internal static string FancyZones_Layouts_Title { + get { + return ResourceManager.GetString("FancyZones_Layouts_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to FancyZones Layouts. + /// </summary> + internal static string FancyZones_LayoutsPage_Title { + get { + return ResourceManager.GetString("FancyZones_LayoutsPage_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Monitor. + /// </summary> + internal static string FancyZones_Monitor { + get { + return ResourceManager.GetString("FancyZones_Monitor", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to FancyZones monitor data not found. Open FancyZones Editor once to initialize.. + /// </summary> + internal static string FancyZones_MonitorDataNotFound { + get { + return ResourceManager.GetString("FancyZones_MonitorDataNotFound", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Identify monitors and apply layouts. + /// </summary> + internal static string FancyZones_Monitors_Subtitle { + get { + return ResourceManager.GetString("FancyZones_Monitors_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to FancyZones: Monitors. + /// </summary> + internal static string FancyZones_Monitors_Title { + get { + return ResourceManager.GetString("FancyZones_Monitors_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to FancyZones Monitors. + /// </summary> + internal static string FancyZones_MonitorsPage_Title { + get { + return ResourceManager.GetString("FancyZones_MonitorsPage_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No FancyZones monitors found.. + /// </summary> + internal static string FancyZones_NoFancyZonesMonitorsFound { + get { + return ResourceManager.GetString("FancyZones_NoFancyZonesMonitorsFound", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open FancyZones Editor once to initialize layouts.. + /// </summary> + internal static string FancyZones_NoLayoutsFound_Subtitle { + get { + return ResourceManager.GetString("FancyZones_NoLayoutsFound_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No layouts found. + /// </summary> + internal static string FancyZones_NoLayoutsFound_Title { + get { + return ResourceManager.GetString("FancyZones_NoLayoutsFound_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open FancyZones Editor once to initialize monitor data.. + /// </summary> + internal static string FancyZones_NoMonitorsFound_Subtitle { + get { + return ResourceManager.GetString("FancyZones_NoMonitorsFound_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No monitors found. + /// </summary> + internal static string FancyZones_NoMonitorsFound_Title { + get { + return ResourceManager.GetString("FancyZones_NoMonitorsFound_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Number. + /// </summary> + internal static string FancyZones_Number { + get { + return ResourceManager.GetString("FancyZones_Number", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Launch layout editor. + /// </summary> + internal static string FancyZones_OpenEditor_Subtitle { + get { + return ResourceManager.GetString("FancyZones_OpenEditor_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open FancyZones Editor. + /// </summary> + internal static string FancyZones_OpenEditor_Title { + get { + return ResourceManager.GetString("FancyZones_OpenEditor_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Pick a layout for this monitor. + /// </summary> + internal static string FancyZones_PickLayoutForMonitor { + get { + return ResourceManager.GetString("FancyZones_PickLayoutForMonitor", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Failed to read FancyZones monitor data: {0}. + /// </summary> + internal static string FancyZones_ReadMonitorDataFailed_Format { + get { + return ResourceManager.GetString("FancyZones_ReadMonitorDataFailed_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Resolution. + /// </summary> + internal static string FancyZones_Resolution { + get { + return ResourceManager.GetString("FancyZones_Resolution", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Serial. + /// </summary> + internal static string FancyZones_Serial { + get { + return ResourceManager.GetString("FancyZones_Serial", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Set active layout. + /// </summary> + internal static string FancyZones_SetActiveLayout { + get { + return ResourceManager.GetString("FancyZones_SetActiveLayout", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Set active layout for {0}. + /// </summary> + internal static string FancyZones_SetActiveLayoutFor_Format { + get { + return ResourceManager.GetString("FancyZones_SetActiveLayoutFor_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open FancyZones settings. + /// </summary> + internal static string FancyZones_Settings_Subtitle { + get { + return ResourceManager.GetString("FancyZones_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Template: {0}. + /// </summary> + internal static string FancyZones_Template_Format { + get { + return ResourceManager.GetString("FancyZones_Template_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Virtual desktop. + /// </summary> + internal static string FancyZones_VirtualDesktop { + get { + return ResourceManager.GetString("FancyZones_VirtualDesktop", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Work area. + /// </summary> + internal static string FancyZones_WorkArea { + get { + return ResourceManager.GetString("FancyZones_WorkArea", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Failed to write applied layouts: {0}. + /// </summary> + internal static string FancyZones_WriteAppliedLayoutsFailed_Format { + get { + return ResourceManager.GetString("FancyZones_WriteAppliedLayoutsFailed_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} zones. + /// </summary> + internal static string FancyZones_Zones_Format { + get { + return ResourceManager.GetString("FancyZones_Zones_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open File Explorer add-ons settings. + /// </summary> + internal static string FileExplorerAddons_Settings_Subtitle { + get { + return ResourceManager.GetString("FileExplorerAddons_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open File Locksmith settings. + /// </summary> + internal static string FileLocksmith_Settings_Subtitle { + get { + return ResourceManager.GetString("FileLocksmith_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Launch Hosts File Editor. + /// </summary> + internal static string Hosts_Open_Subtitle { + get { + return ResourceManager.GetString("Hosts_Open_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Hosts File Editor. + /// </summary> + internal static string Hosts_Open_Title { + get { + return ResourceManager.GetString("Hosts_Open_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Launch Hosts File Editor as admin. + /// </summary> + internal static string Hosts_OpenAdmin_Subtitle { + get { + return ResourceManager.GetString("Hosts_OpenAdmin_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Hosts File Editor (Admin). + /// </summary> + internal static string Hosts_OpenAdmin_Title { + get { + return ResourceManager.GetString("Hosts_OpenAdmin_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Hosts File Editor settings. + /// </summary> + internal static string Hosts_Settings_Subtitle { + get { + return ResourceManager.GetString("Hosts_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Image Resizer settings. + /// </summary> + internal static string ImageResizer_Settings_Subtitle { + get { + return ResourceManager.GetString("ImageResizer_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Keyboard Manager settings. + /// </summary> + internal static string KeyboardManager_Settings_Subtitle { + get { + return ResourceManager.GetString("KeyboardManager_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Light Switch settings. + /// </summary> + internal static string LightSwitch_Settings_Subtitle { + get { + return ResourceManager.GetString("LightSwitch_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Toggle system/apps theme immediately. + /// </summary> + internal static string LightSwitch_Toggle_Subtitle { + get { + return ResourceManager.GetString("LightSwitch_Toggle_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Light Switch: Toggle theme. + /// </summary> + internal static string LightSwitch_Toggle_Title { + get { + return ResourceManager.GetString("LightSwitch_Toggle_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Enable or disable pointer crosshairs. + /// </summary> + internal static string MouseUtils_Crosshairs_Subtitle { + get { + return ResourceManager.GetString("MouseUtils_Crosshairs_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Toggle Mouse Crosshairs. + /// </summary> + internal static string MouseUtils_Crosshairs_Title { + get { + return ResourceManager.GetString("MouseUtils_Crosshairs_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Wrap the cursor across monitor edges. + /// </summary> + internal static string MouseUtils_CursorWrap_Subtitle { + get { + return ResourceManager.GetString("MouseUtils_CursorWrap_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Toggle Cursor Wrap. + /// </summary> + internal static string MouseUtils_CursorWrap_Title { + get { + return ResourceManager.GetString("MouseUtils_CursorWrap_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Focus the mouse pointer. + /// </summary> + internal static string MouseUtils_FindMyMouse_Subtitle { + get { + return ResourceManager.GetString("MouseUtils_FindMyMouse_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Trigger Find My Mouse. + /// </summary> + internal static string MouseUtils_FindMyMouse_Title { + get { + return ResourceManager.GetString("MouseUtils_FindMyMouse_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Highlight mouse clicks. + /// </summary> + internal static string MouseUtils_Highlighter_Subtitle { + get { + return ResourceManager.GetString("MouseUtils_Highlighter_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Toggle Mouse Highlighter. + /// </summary> + internal static string MouseUtils_Highlighter_Title { + get { + return ResourceManager.GetString("MouseUtils_Highlighter_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Jump the pointer to a target. + /// </summary> + internal static string MouseUtils_MouseJump_Subtitle { + get { + return ResourceManager.GetString("MouseUtils_MouseJump_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Show Mouse Jump Preview. + /// </summary> + internal static string MouseUtils_MouseJump_Title { + get { + return ResourceManager.GetString("MouseUtils_MouseJump_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Mouse Utilities settings. + /// </summary> + internal static string MouseUtils_Settings_Subtitle { + get { + return ResourceManager.GetString("MouseUtils_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Mouse Without Borders settings. + /// </summary> + internal static string MouseWithoutBorders_Settings_Subtitle { + get { + return ResourceManager.GetString("MouseWithoutBorders_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open New+ settings. + /// </summary> + internal static string NewPlus_Settings_Subtitle { + get { + return ResourceManager.GetString("NewPlus_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Peek settings. + /// </summary> + internal static string Peek_Settings_Subtitle { + get { + return ResourceManager.GetString("Peek_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open PowerRename settings. + /// </summary> + internal static string PowerRename_Settings_Subtitle { + get { + return ResourceManager.GetString("PowerRename_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PowerToys. + /// </summary> + internal static string PowerToys_DisplayName { + get { + return ResourceManager.GetString("PowerToys_DisplayName", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No matching module found. + /// </summary> + internal static string PowerToys_NoMatchingModule { + get { + return ResourceManager.GetString("PowerToys_NoMatchingModule", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PowerToys commands and settings. + /// </summary> + internal static string PowerToys_Subtitle { + get { + return ResourceManager.GetString("PowerToys_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PowerToys commands. + /// </summary> + internal static string PowerToysExtension_CommandsName { + get { + return ResourceManager.GetString("PowerToysExtension_CommandsName", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Launch the PowerToys shell. + /// </summary> + internal static string PowerToysExtension_OpenPowerToys_Subtitle { + get { + return ResourceManager.GetString("PowerToysExtension_OpenPowerToys_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open PowerToys. + /// </summary> + internal static string PowerToysExtension_OpenPowerToys_Title { + get { + return ResourceManager.GetString("PowerToysExtension_OpenPowerToys_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open the main PowerToys settings window. + /// </summary> + internal static string PowerToysExtension_OpenSettings_Subtitle { + get { + return ResourceManager.GetString("PowerToysExtension_OpenSettings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open PowerToys settings. + /// </summary> + internal static string PowerToysExtension_OpenSettings_Title { + get { + return ResourceManager.GetString("PowerToysExtension_OpenSettings_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Launch the Workspaces editor. + /// </summary> + internal static string PowerToysExtension_OpenWorkspacesEditor_Subtitle { + get { + return ResourceManager.GetString("PowerToysExtension_OpenWorkspacesEditor_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Workspaces editor. + /// </summary> + internal static string PowerToysExtension_OpenWorkspacesEditor_Title { + get { + return ResourceManager.GetString("PowerToysExtension_OpenWorkspacesEditor_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Jump directly to Workspaces settings. + /// </summary> + internal static string PowerToysExtension_OpenWorkspacesSettings_Subtitle { + get { + return ResourceManager.GetString("PowerToysExtension_OpenWorkspacesSettings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Workspaces settings. + /// </summary> + internal static string PowerToysExtension_OpenWorkspacesSettings_Title { + get { + return ResourceManager.GetString("PowerToysExtension_OpenWorkspacesSettings_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open PowerToys Run settings. + /// </summary> + internal static string PowerToysRun_Settings_Subtitle { + get { + return ResourceManager.GetString("PowerToysRun_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Quick Accent settings. + /// </summary> + internal static string QuickAccent_Settings_Subtitle { + get { + return ResourceManager.GetString("QuickAccent_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Launch Registry Preview. + /// </summary> + internal static string RegistryPreview_Open_Subtitle { + get { + return ResourceManager.GetString("RegistryPreview_Open_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Registry Preview. + /// </summary> + internal static string RegistryPreview_Open_Title { + get { + return ResourceManager.GetString("RegistryPreview_Open_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Registry Preview settings. + /// </summary> + internal static string RegistryPreview_Settings_Subtitle { + get { + return ResourceManager.GetString("RegistryPreview_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Screen Ruler settings. + /// </summary> + internal static string ScreenRuler_Settings_Subtitle { + get { + return ResourceManager.GetString("ScreenRuler_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Start or close Screen Ruler. + /// </summary> + internal static string ScreenRuler_Toggle_Subtitle { + get { + return ResourceManager.GetString("ScreenRuler_Toggle_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Toggle Screen Ruler. + /// </summary> + internal static string ScreenRuler_Toggle_Title { + get { + return ResourceManager.GetString("ScreenRuler_Toggle_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Shortcut Guide settings. + /// </summary> + internal static string ShortcutGuide_Settings_Subtitle { + get { + return ResourceManager.GetString("ShortcutGuide_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Show or hide Shortcut Guide. + /// </summary> + internal static string ShortcutGuide_Toggle_Subtitle { + get { + return ResourceManager.GetString("ShortcutGuide_Toggle_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Toggle Shortcut Guide. + /// </summary> + internal static string ShortcutGuide_Toggle_Title { + get { + return ResourceManager.GetString("ShortcutGuide_Toggle_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Text Extractor settings. + /// </summary> + internal static string TextExtractor_Settings_Subtitle { + get { + return ResourceManager.GetString("TextExtractor_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Start or close Text Extractor. + /// </summary> + internal static string TextExtractor_Toggle_Subtitle { + get { + return ResourceManager.GetString("TextExtractor_Toggle_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Toggle Text Extractor. + /// </summary> + internal static string TextExtractor_Toggle_Title { + get { + return ResourceManager.GetString("TextExtractor_Toggle_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to App. + /// </summary> + internal static string Workspaces_App { + get { + return ResourceManager.GetString("Workspaces_App", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} applications. + /// </summary> + internal static string Workspaces_Applications_Format { + get { + return ResourceManager.GetString("Workspaces_Applications_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} applications. + /// </summary> + internal static string Workspaces_ApplicationsCount_Format { + get { + return ResourceManager.GetString("Workspaces_ApplicationsCount_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} days ago. + /// </summary> + internal static string Workspaces_DaysAgo_Format { + get { + return ResourceManager.GetString("Workspaces_DaysAgo_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} hr ago. + /// </summary> + internal static string Workspaces_HrAgo_Format { + get { + return ResourceManager.GetString("Workspaces_HrAgo_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to just now. + /// </summary> + internal static string Workspaces_JustNow { + get { + return ResourceManager.GetString("Workspaces_JustNow", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Last launched {0}. + /// </summary> + internal static string Workspaces_LastLaunched_Format { + get { + return ResourceManager.GetString("Workspaces_LastLaunched_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0} min ago. + /// </summary> + internal static string Workspaces_MinAgo_Format { + get { + return ResourceManager.GetString("Workspaces_MinAgo_Format", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Never launched. + /// </summary> + internal static string Workspaces_NeverLaunched { + get { + return ResourceManager.GetString("Workspaces_NeverLaunched", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No applications. + /// </summary> + internal static string Workspaces_NoApplications { + get { + return ResourceManager.GetString("Workspaces_NoApplications", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No applications in this workspace. + /// </summary> + internal static string Workspaces_NoApplicationsInWorkspace { + get { + return ResourceManager.GetString("Workspaces_NoApplicationsInWorkspace", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to 1 application. + /// </summary> + internal static string Workspaces_OneApplication { + get { + return ResourceManager.GetString("Workspaces_OneApplication", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Create or edit workspaces. + /// </summary> + internal static string Workspaces_OpenEditor_Subtitle { + get { + return ResourceManager.GetString("Workspaces_OpenEditor_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Workspaces: Open editor. + /// </summary> + internal static string Workspaces_OpenEditor_Title { + get { + return ResourceManager.GetString("Workspaces_OpenEditor_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Workspaces settings. + /// </summary> + internal static string Workspaces_Settings_Subtitle { + get { + return ResourceManager.GetString("Workspaces_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Workspace. + /// </summary> + internal static string Workspaces_Workspace { + get { + return ResourceManager.GetString("Workspaces_Workspace", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Enter break timer. + /// </summary> + internal static string ZoomIt_Break_Subtitle { + get { + return ResourceManager.GetString("ZoomIt_Break_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to ZoomIt: Break. + /// </summary> + internal static string ZoomIt_Break_Title { + get { + return ResourceManager.GetString("ZoomIt_Break_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Enter drawing mode. + /// </summary> + internal static string ZoomIt_Draw_Subtitle { + get { + return ResourceManager.GetString("ZoomIt_Draw_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to ZoomIt: Draw. + /// </summary> + internal static string ZoomIt_Draw_Title { + get { + return ResourceManager.GetString("ZoomIt_Draw_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Toggle live zoom. + /// </summary> + internal static string ZoomIt_LiveZoom_Subtitle { + get { + return ResourceManager.GetString("ZoomIt_LiveZoom_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to ZoomIt: Live Zoom. + /// </summary> + internal static string ZoomIt_LiveZoom_Title { + get { + return ResourceManager.GetString("ZoomIt_LiveZoom_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Start recording. + /// </summary> + internal static string ZoomIt_Record_Subtitle { + get { + return ResourceManager.GetString("ZoomIt_Record_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to ZoomIt: Record. + /// </summary> + internal static string ZoomIt_Record_Title { + get { + return ResourceManager.GetString("ZoomIt_Record_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open ZoomIt settings. + /// </summary> + internal static string ZoomIt_Settings_Subtitle { + get { + return ResourceManager.GetString("ZoomIt_Settings_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Enter snip mode. + /// </summary> + internal static string ZoomIt_Snip_Subtitle { + get { + return ResourceManager.GetString("ZoomIt_Snip_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to ZoomIt: Snip. + /// </summary> + internal static string ZoomIt_Snip_Title { + get { + return ResourceManager.GetString("ZoomIt_Snip_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Enter zoom mode. + /// </summary> + internal static string ZoomIt_Zoom_Subtitle { + get { + return ResourceManager.GetString("ZoomIt_Zoom_Subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to ZoomIt: Zoom. + /// </summary> + internal static string ZoomIt_Zoom_Title { + get { + return ResourceManager.GetString("ZoomIt_Zoom_Title", resourceCulture); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Properties/Resources.resx new file mode 100644 index 0000000000..acd99a55fc --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Properties/Resources.resx @@ -0,0 +1,636 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <!-- PowerToys CommandsProvider --> + <data name="PowerToys_DisplayName" xml:space="preserve"> + <value>PowerToys</value> + </data> + <data name="PowerToys_Subtitle" xml:space="preserve"> + <value>PowerToys commands and settings</value> + </data> + <data name="PowerToys_NoMatchingModule" xml:space="preserve"> + <value>No matching module found</value> + </data> + <!-- PowerToys Extension Page --> + <data name="PowerToysExtension_CommandsName" xml:space="preserve"> + <value>PowerToys commands</value> + </data> + <data name="PowerToysExtension_OpenPowerToys_Title" xml:space="preserve"> + <value>Open PowerToys</value> + </data> + <data name="PowerToysExtension_OpenPowerToys_Subtitle" xml:space="preserve"> + <value>Launch the PowerToys shell</value> + </data> + <data name="PowerToysExtension_OpenSettings_Title" xml:space="preserve"> + <value>Open PowerToys settings</value> + </data> + <data name="PowerToysExtension_OpenSettings_Subtitle" xml:space="preserve"> + <value>Open the main PowerToys settings window</value> + </data> + <data name="PowerToysExtension_OpenWorkspacesSettings_Title" xml:space="preserve"> + <value>Open Workspaces settings</value> + </data> + <data name="PowerToysExtension_OpenWorkspacesSettings_Subtitle" xml:space="preserve"> + <value>Jump directly to Workspaces settings</value> + </data> + <data name="PowerToysExtension_OpenWorkspacesEditor_Title" xml:space="preserve"> + <value>Open Workspaces editor</value> + </data> + <data name="PowerToysExtension_OpenWorkspacesEditor_Subtitle" xml:space="preserve"> + <value>Launch the Workspaces editor</value> + </data> + <!-- Advanced Paste Module --> + <data name="AdvancedPaste_Open_Title" xml:space="preserve"> + <value>Open Advanced Paste</value> + </data> + <data name="AdvancedPaste_Open_Subtitle" xml:space="preserve"> + <value>Launch the Advanced Paste UI</value> + </data> + <data name="AdvancedPaste_Settings_Subtitle" xml:space="preserve"> + <value>Open Advanced Paste settings</value> + </data> + <!-- Always On Top Module --> + <data name="AlwaysOnTop_Settings_Subtitle" xml:space="preserve"> + <value>Open Always On Top settings</value> + </data> + <!-- Awake Module --> + <data name="Awake_Settings_Subtitle" xml:space="preserve"> + <value>Open Awake settings</value> + </data> + <data name="Awake_Status_Title" xml:space="preserve"> + <value>Awake: Current status</value> + </data> + <data name="Awake_KeepIndefinite_Title" xml:space="preserve"> + <value>Awake: Keep awake indefinitely</value> + </data> + <data name="Awake_KeepIndefinite_Subtitle" xml:space="preserve"> + <value>Run Awake in indefinite mode</value> + </data> + <data name="Awake_Keep30Min_Title" xml:space="preserve"> + <value>Awake: Keep awake for 30 minutes</value> + </data> + <data name="Awake_Keep30Min_Subtitle" xml:space="preserve"> + <value>Run Awake timed for 30 minutes</value> + </data> + <data name="Awake_Keep1Hour_Title" xml:space="preserve"> + <value>Awake: Keep awake for 1 hour</value> + </data> + <data name="Awake_Keep1Hour_Subtitle" xml:space="preserve"> + <value>Run Awake timed for 1 hour</value> + </data> + <data name="Awake_Keep2Hours_Title" xml:space="preserve"> + <value>Awake: Keep awake for 2 hours</value> + </data> + <data name="Awake_Keep2Hours_Subtitle" xml:space="preserve"> + <value>Run Awake timed for 2 hours</value> + </data> + <data name="Awake_TurnOff_Title" xml:space="preserve"> + <value>Awake: Turn off</value> + </data> + <data name="Awake_TurnOff_Subtitle" xml:space="preserve"> + <value>Switch Awake back to Off</value> + </data> + <data name="Awake_SetIndefinite_Toast" xml:space="preserve"> + <value>Awake set to indefinite</value> + </data> + <data name="Awake_Set30Min_Toast" xml:space="preserve"> + <value>Awake set for 30 minutes</value> + </data> + <data name="Awake_Set1Hour_Toast" xml:space="preserve"> + <value>Awake set for 1 hour</value> + </data> + <data name="Awake_Set2Hours_Toast" xml:space="preserve"> + <value>Awake set for 2 hours</value> + </data> + <!-- Color Picker Module --> + <data name="ColorPicker_Settings_Subtitle" xml:space="preserve"> + <value>Open Color Picker settings</value> + </data> + <data name="ColorPicker_Open_Title" xml:space="preserve"> + <value>Open Color Picker</value> + </data> + <data name="ColorPicker_Open_Subtitle" xml:space="preserve"> + <value>Start a color pick session</value> + </data> + <data name="ColorPicker_SavedColors_Title" xml:space="preserve"> + <value>Saved colors</value> + </data> + <data name="ColorPicker_SavedColors_Subtitle" xml:space="preserve"> + <value>Browse and copy saved colors</value> + </data> + <data name="ColorPicker_NoSavedColors_Title" xml:space="preserve"> + <value>No saved colors</value> + </data> + <data name="ColorPicker_NoSavedColors_Subtitle" xml:space="preserve"> + <value>Pick a color first, then try again.</value> + </data> + <data name="ColorPicker_NoMatchingSavedColors_Subtitle" xml:space="preserve"> + <value>No saved colors matching '{0}'</value> + </data> + <!-- Command Not Found Module --> + <data name="CommandNotFound_Settings_Subtitle" xml:space="preserve"> + <value>Open Command Not Found settings</value> + </data> + <!-- Crop And Lock Module --> + <data name="CropAndLock_Reparent_Title" xml:space="preserve"> + <value>Crop and Lock (Reparent)</value> + </data> + <data name="CropAndLock_Reparent_Subtitle" xml:space="preserve"> + <value>Create a cropped reparented window</value> + </data> + <data name="CropAndLock_Thumbnail_Title" xml:space="preserve"> + <value>Crop and Lock (Thumbnail)</value> + </data> + <data name="CropAndLock_Thumbnail_Subtitle" xml:space="preserve"> + <value>Create a cropped thumbnail window</value> + </data> + <data name="CropAndLock_Screenshot_Title" xml:space="preserve"> + <value>Crop and Lock (Screenshot)</value> + </data> + <data name="CropAndLock_Screenshot_Subtitle" xml:space="preserve"> + <value>Create a cropped screenshot window</value> + </data> + <data name="CropAndLock_Settings_Subtitle" xml:space="preserve"> + <value>Open Crop and Lock settings</value> + </data> + <!-- Environment Variables Module --> + <data name="EnvironmentVariables_Open_Title" xml:space="preserve"> + <value>Open Environment Variables</value> + </data> + <data name="EnvironmentVariables_Open_Subtitle" xml:space="preserve"> + <value>Launch Environment Variables editor</value> + </data> + <data name="EnvironmentVariables_OpenAdmin_Title" xml:space="preserve"> + <value>Open Environment Variables (Admin)</value> + </data> + <data name="EnvironmentVariables_OpenAdmin_Subtitle" xml:space="preserve"> + <value>Launch Environment Variables editor as admin</value> + </data> + <data name="EnvironmentVariables_Settings_Subtitle" xml:space="preserve"> + <value>Open Environment Variables settings</value> + </data> + <!-- FancyZones Module --> + <data name="FancyZones_Layouts_Title" xml:space="preserve"> + <value>FancyZones: Layouts</value> + </data> + <data name="FancyZones_Layouts_Subtitle" xml:space="preserve"> + <value>Apply a layout to all monitors or a specific monitor</value> + </data> + <data name="FancyZones_Monitors_Title" xml:space="preserve"> + <value>FancyZones: Monitors</value> + </data> + <data name="FancyZones_Monitors_Subtitle" xml:space="preserve"> + <value>Identify monitors and apply layouts</value> + </data> + <data name="FancyZones_OpenEditor_Title" xml:space="preserve"> + <value>Open FancyZones Editor</value> + </data> + <data name="FancyZones_OpenEditor_Subtitle" xml:space="preserve"> + <value>Launch layout editor</value> + </data> + <data name="FancyZones_Settings_Subtitle" xml:space="preserve"> + <value>Open FancyZones settings</value> + </data> + <data name="FancyZones_LayoutsPage_Title" xml:space="preserve"> + <value>FancyZones Layouts</value> + </data> + <data name="FancyZones_NoLayoutsFound_Title" xml:space="preserve"> + <value>No layouts found</value> + </data> + <data name="FancyZones_NoLayoutsFound_Subtitle" xml:space="preserve"> + <value>Open FancyZones Editor once to initialize layouts.</value> + </data> + <data name="FancyZones_ApplyTo_Format" xml:space="preserve"> + <value>Apply to {0}</value> + </data> + <data name="FancyZones_MonitorsPage_Title" xml:space="preserve"> + <value>FancyZones Monitors</value> + </data> + <data name="FancyZones_NoMonitorsFound_Title" xml:space="preserve"> + <value>No monitors found</value> + </data> + <data name="FancyZones_NoMonitorsFound_Subtitle" xml:space="preserve"> + <value>Open FancyZones Editor once to initialize monitor data.</value> + </data> + <data name="FancyZones_SetActiveLayout" xml:space="preserve"> + <value>Set active layout</value> + </data> + <data name="FancyZones_PickLayoutForMonitor" xml:space="preserve"> + <value>Pick a layout for this monitor</value> + </data> + <data name="FancyZones_SetActiveLayoutFor_Format" xml:space="preserve"> + <value>Set active layout for {0}</value> + </data> + <data name="FancyZones_CurrentLayout_Format" xml:space="preserve"> + <value>Current layout: {0}</value> + </data> + <data name="FancyZones_CurrentLayout_Unknown" xml:space="preserve"> + <value>Current layout: unknown</value> + </data> + <data name="FancyZones_Template_Format" xml:space="preserve"> + <value>Template: {0}</value> + </data> + <data name="FancyZones_Zones_Format" xml:space="preserve"> + <value>{0} zones</value> + </data> + <data name="FancyZones_CustomGrid_Zones_Format" xml:space="preserve"> + <value>Custom grid • {0} zones</value> + </data> + <data name="FancyZones_CustomCanvas_Zones_Format" xml:space="preserve"> + <value>Custom canvas • {0} zones</value> + </data> + <data name="FancyZones_Custom_Zones_Format" xml:space="preserve"> + <value>Custom • {0} zones</value> + </data> + <data name="FancyZones_LayoutApplied" xml:space="preserve"> + <value>Layout applied.</value> + </data> + <data name="FancyZones_LayoutAppliedNotifyFailed_Format" xml:space="preserve"> + <value>Layout applied, but FancyZones could not be notified: {0}</value> + </data> + <data name="FancyZones_WriteAppliedLayoutsFailed_Format" xml:space="preserve"> + <value>Failed to write applied layouts: {0}</value> + </data> + <data name="FancyZones_MonitorDataNotFound" xml:space="preserve"> + <value>FancyZones monitor data not found. Open FancyZones Editor once to initialize.</value> + </data> + <data name="FancyZones_NoFancyZonesMonitorsFound" xml:space="preserve"> + <value>No FancyZones monitors found.</value> + </data> + <data name="FancyZones_ReadMonitorDataFailed_Format" xml:space="preserve"> + <value>Failed to read FancyZones monitor data: {0}</value> + </data> + <!-- File Explorer Addons Module --> + <data name="FileExplorerAddons_Settings_Subtitle" xml:space="preserve"> + <value>Open File Explorer add-ons settings</value> + </data> + <!-- File Locksmith Module --> + <data name="FileLocksmith_Settings_Subtitle" xml:space="preserve"> + <value>Open File Locksmith settings</value> + </data> + <!-- Hosts Module --> + <data name="Hosts_Open_Title" xml:space="preserve"> + <value>Open Hosts File Editor</value> + </data> + <data name="Hosts_Open_Subtitle" xml:space="preserve"> + <value>Launch Hosts File Editor</value> + </data> + <data name="Hosts_OpenAdmin_Title" xml:space="preserve"> + <value>Open Hosts File Editor (Admin)</value> + </data> + <data name="Hosts_OpenAdmin_Subtitle" xml:space="preserve"> + <value>Launch Hosts File Editor as admin</value> + </data> + <data name="Hosts_Settings_Subtitle" xml:space="preserve"> + <value>Open Hosts File Editor settings</value> + </data> + <!-- Image Resizer Module --> + <data name="ImageResizer_Settings_Subtitle" xml:space="preserve"> + <value>Open Image Resizer settings</value> + </data> + <!-- Keyboard Manager Module --> + <data name="KeyboardManager_Settings_Subtitle" xml:space="preserve"> + <value>Open Keyboard Manager settings</value> + </data> + <!-- Light Switch Module --> + <data name="LightSwitch_Toggle_Title" xml:space="preserve"> + <value>Light Switch: Toggle theme</value> + </data> + <data name="LightSwitch_Toggle_Subtitle" xml:space="preserve"> + <value>Toggle system/apps theme immediately</value> + </data> + <data name="LightSwitch_Settings_Subtitle" xml:space="preserve"> + <value>Open Light Switch settings</value> + </data> + <!-- Mouse Utils Module --> + <data name="MouseUtils_FindMyMouse_Title" xml:space="preserve"> + <value>Trigger Find My Mouse</value> + </data> + <data name="MouseUtils_FindMyMouse_Subtitle" xml:space="preserve"> + <value>Focus the mouse pointer</value> + </data> + <data name="MouseUtils_Highlighter_Title" xml:space="preserve"> + <value>Toggle Mouse Highlighter</value> + </data> + <data name="MouseUtils_Highlighter_Subtitle" xml:space="preserve"> + <value>Highlight mouse clicks</value> + </data> + <data name="MouseUtils_Crosshairs_Title" xml:space="preserve"> + <value>Toggle Mouse Crosshairs</value> + </data> + <data name="MouseUtils_Crosshairs_Subtitle" xml:space="preserve"> + <value>Enable or disable pointer crosshairs</value> + </data> + <data name="MouseUtils_CursorWrap_Title" xml:space="preserve"> + <value>Toggle Cursor Wrap</value> + </data> + <data name="MouseUtils_CursorWrap_Subtitle" xml:space="preserve"> + <value>Wrap the cursor across monitor edges</value> + </data> + <data name="MouseUtils_MouseJump_Title" xml:space="preserve"> + <value>Show Mouse Jump Preview</value> + </data> + <data name="MouseUtils_MouseJump_Subtitle" xml:space="preserve"> + <value>Jump the pointer to a target</value> + </data> + <data name="MouseUtils_Settings_Subtitle" xml:space="preserve"> + <value>Open Mouse Utilities settings</value> + </data> + <!-- Mouse Without Borders Module --> + <data name="MouseWithoutBorders_Settings_Subtitle" xml:space="preserve"> + <value>Open Mouse Without Borders settings</value> + </data> + <!-- New+ Module --> + <data name="NewPlus_Settings_Subtitle" xml:space="preserve"> + <value>Open New+ settings</value> + </data> + <!-- Peek Module --> + <data name="Peek_Settings_Subtitle" xml:space="preserve"> + <value>Open Peek settings</value> + </data> + <!-- PowerRename Module --> + <data name="PowerRename_Settings_Subtitle" xml:space="preserve"> + <value>Open PowerRename settings</value> + </data> + <!-- PowerToys Run Module --> + <data name="PowerToysRun_Settings_Subtitle" xml:space="preserve"> + <value>Open PowerToys Run settings</value> + </data> + <!-- Quick Accent Module --> + <data name="QuickAccent_Settings_Subtitle" xml:space="preserve"> + <value>Open Quick Accent settings</value> + </data> + <!-- Registry Preview Module --> + <data name="RegistryPreview_Open_Title" xml:space="preserve"> + <value>Open Registry Preview</value> + </data> + <data name="RegistryPreview_Open_Subtitle" xml:space="preserve"> + <value>Launch Registry Preview</value> + </data> + <data name="RegistryPreview_Settings_Subtitle" xml:space="preserve"> + <value>Open Registry Preview settings</value> + </data> + <!-- Screen Ruler Module --> + <data name="ScreenRuler_Toggle_Title" xml:space="preserve"> + <value>Toggle Screen Ruler</value> + </data> + <data name="ScreenRuler_Toggle_Subtitle" xml:space="preserve"> + <value>Start or close Screen Ruler</value> + </data> + <data name="ScreenRuler_Settings_Subtitle" xml:space="preserve"> + <value>Open Screen Ruler settings</value> + </data> + <!-- Shortcut Guide Module --> + <data name="ShortcutGuide_Toggle_Title" xml:space="preserve"> + <value>Toggle Shortcut Guide</value> + </data> + <data name="ShortcutGuide_Toggle_Subtitle" xml:space="preserve"> + <value>Show or hide Shortcut Guide</value> + </data> + <data name="ShortcutGuide_Settings_Subtitle" xml:space="preserve"> + <value>Open Shortcut Guide settings</value> + </data> + <!-- Text Extractor Module --> + <data name="TextExtractor_Toggle_Title" xml:space="preserve"> + <value>Toggle Text Extractor</value> + </data> + <data name="TextExtractor_Toggle_Subtitle" xml:space="preserve"> + <value>Start or close Text Extractor</value> + </data> + <data name="TextExtractor_Settings_Subtitle" xml:space="preserve"> + <value>Open Text Extractor settings</value> + </data> + <!-- Workspaces Module --> + <data name="Workspaces_Settings_Subtitle" xml:space="preserve"> + <value>Open Workspaces settings</value> + </data> + <data name="Workspaces_OpenEditor_Title" xml:space="preserve"> + <value>Workspaces: Open editor</value> + </data> + <data name="Workspaces_OpenEditor_Subtitle" xml:space="preserve"> + <value>Create or edit workspaces</value> + </data> + <data name="Workspaces_NoApplications" xml:space="preserve"> + <value>No applications</value> + </data> + <data name="Workspaces_Applications_Format" xml:space="preserve"> + <value>{0} applications</value> + </data> + <data name="Workspaces_LastLaunched_Format" xml:space="preserve"> + <value>Last launched {0}</value> + </data> + <data name="Workspaces_NeverLaunched" xml:space="preserve"> + <value>Never launched</value> + </data> + <data name="Workspaces_NoApplicationsInWorkspace" xml:space="preserve"> + <value>No applications in this workspace</value> + </data> + <data name="Workspaces_OneApplication" xml:space="preserve"> + <value>1 application</value> + </data> + <data name="Workspaces_ApplicationsCount_Format" xml:space="preserve"> + <value>{0} applications</value> + </data> + <data name="Workspaces_Workspace" xml:space="preserve"> + <value>Workspace</value> + </data> + <data name="Workspaces_App" xml:space="preserve"> + <value>App</value> + </data> + <data name="Workspaces_JustNow" xml:space="preserve"> + <value>just now</value> + </data> + <data name="Workspaces_MinAgo_Format" xml:space="preserve"> + <value>{0} min ago</value> + </data> + <data name="Workspaces_HrAgo_Format" xml:space="preserve"> + <value>{0} hr ago</value> + </data> + <data name="Workspaces_DaysAgo_Format" xml:space="preserve"> + <value>{0} days ago</value> + </data> + <!-- ZoomIt Module --> + <data name="ZoomIt_Zoom_Title" xml:space="preserve"> + <value>ZoomIt: Zoom</value> + </data> + <data name="ZoomIt_Zoom_Subtitle" xml:space="preserve"> + <value>Enter zoom mode</value> + </data> + <data name="ZoomIt_Draw_Title" xml:space="preserve"> + <value>ZoomIt: Draw</value> + </data> + <data name="ZoomIt_Draw_Subtitle" xml:space="preserve"> + <value>Enter drawing mode</value> + </data> + <data name="ZoomIt_Break_Title" xml:space="preserve"> + <value>ZoomIt: Break</value> + </data> + <data name="ZoomIt_Break_Subtitle" xml:space="preserve"> + <value>Enter break timer</value> + </data> + <data name="ZoomIt_LiveZoom_Title" xml:space="preserve"> + <value>ZoomIt: Live Zoom</value> + </data> + <data name="ZoomIt_LiveZoom_Subtitle" xml:space="preserve"> + <value>Toggle live zoom</value> + </data> + <data name="ZoomIt_Snip_Title" xml:space="preserve"> + <value>ZoomIt: Snip</value> + </data> + <data name="ZoomIt_Snip_Subtitle" xml:space="preserve"> + <value>Enter snip mode</value> + </data> + <data name="ZoomIt_Record_Title" xml:space="preserve"> + <value>ZoomIt: Record</value> + </data> + <data name="ZoomIt_Record_Subtitle" xml:space="preserve"> + <value>Start recording</value> + </data> + <data name="ZoomIt_Settings_Subtitle" xml:space="preserve"> + <value>Open ZoomIt settings</value> + </data> + <!-- FancyZones Monitor Details --> + <data name="FancyZones_Monitor" xml:space="preserve"> + <value>Monitor</value> + </data> + <data name="FancyZones_Instance" xml:space="preserve"> + <value>Instance</value> + </data> + <data name="FancyZones_Serial" xml:space="preserve"> + <value>Serial</value> + </data> + <data name="FancyZones_Number" xml:space="preserve"> + <value>Number</value> + </data> + <data name="FancyZones_VirtualDesktop" xml:space="preserve"> + <value>Virtual desktop</value> + </data> + <data name="FancyZones_WorkArea" xml:space="preserve"> + <value>Work area</value> + </data> + <data name="FancyZones_Resolution" xml:space="preserve"> + <value>Resolution</value> + </data> + <data name="FancyZones_DPI" xml:space="preserve"> + <value>DPI</value> + </data> + <data name="Common_NotAvailable" xml:space="preserve"> + <value>N/A</value> + </data> +</root> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Public/README.md b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Public/README.md new file mode 100644 index 0000000000..99bded1694 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Public/README.md @@ -0,0 +1,6 @@ +# PowerToys Command Palette Extension + +This folder is exposed to the Windows Command Palette host via the +`PublicFolder` attribute in the AppExtension registration. It intentionally +contains only documentation today, but can be used for additional metadata in +the future without requiring code changes. diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/app.manifest b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/app.manifest new file mode 100644 index 0000000000..013aaee199 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/app.manifest @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="utf-8"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> + <assemblyIdentity version="1.0.0.0" name="PowerToysExtension.app" /> + <msix xmlns="urn:schemas-microsoft-com:msix.v1" + packageName="Microsoft.PowerToys.SparseApp" + applicationId="PowerToys.CmdPalExtension" + publisher="CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US" /> + + <asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> + <asmv3:windowsSettings> + <ws2:dpiAwareness xmlns:ws2="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</ws2:dpiAwareness> + </asmv3:windowsSettings> + </asmv3:application> + + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> + </application> + </compatibility> + +</assembly> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Assets/Registry.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Assets/Registry.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Assets/Registry.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Assets/Registry.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Assets/Registry.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Assets/Registry.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Assets/Registry.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Assets/Registry.svg diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Classes/RegistryEntry.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Classes/RegistryEntry.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Classes/RegistryEntry.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Classes/RegistryEntry.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Commands/CopyRegistryInfoCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Commands/CopyRegistryInfoCommand.cs similarity index 89% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Commands/CopyRegistryInfoCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Commands/CopyRegistryInfoCommand.cs index d876eb7efc..2252c4c1fe 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Commands/CopyRegistryInfoCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Commands/CopyRegistryInfoCommand.cs @@ -21,19 +21,19 @@ internal sealed partial class CopyRegistryInfoCommand : InvokableCommand if (typeToCopy == CopyType.Key) { Name = Resources.CopyKeyNamePath; - Icon = new IconInfo("\xE8C8"); // Copy Icon + Icon = Icons.CopyIcon; _stringToCopy = entry.GetRegistryKey(); } else if (typeToCopy == CopyType.ValueData) { Name = Resources.CopyValueData; - Icon = new IconInfo("\xF413"); // CopyTo Icon + Icon = Icons.CopyToIcon; _stringToCopy = entry.GetValueData(); } else if (typeToCopy == CopyType.ValueName) { Name = Resources.CopyValueName; - Icon = new IconInfo("\xE8C8"); // Copy Icon + Icon = Icons.CopyIcon; _stringToCopy = entry.GetValueNameWithKey(); } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Commands/OpenKeyInEditorCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Commands/OpenKeyInEditorCommand.cs similarity index 88% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Commands/OpenKeyInEditorCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Commands/OpenKeyInEditorCommand.cs index d10b994318..855b55b37d 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Commands/OpenKeyInEditorCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Commands/OpenKeyInEditorCommand.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Resources; using System.Text; using System.Threading.Tasks; +using ManagedCommon; using Microsoft.CmdPal.Ext.Registry.Classes; using Microsoft.CmdPal.Ext.Registry.Helpers; using Microsoft.CmdPal.Ext.Registry.Properties; @@ -26,7 +27,7 @@ internal sealed partial class OpenKeyInEditorCommand : InvokableCommand internal OpenKeyInEditorCommand(RegistryEntry entry) { Name = Resources.OpenKeyInRegistryEditor; - Icon = new IconInfo("\xE8A7"); // OpenInNewWindow icon + Icon = Icons.OpenInNewWindowIcon; _entry = entry; } @@ -37,7 +38,7 @@ internal sealed partial class OpenKeyInEditorCommand : InvokableCommand RegistryHelper.OpenRegistryKey(entry.Key?.Name ?? entry.KeyPath); return true; } - catch (System.ComponentModel.Win32Exception) + catch (System.ComponentModel.Win32Exception ex) { // TODO GH #118 We need a convenient way to show errors to a user // MessageBox.Show( @@ -45,13 +46,13 @@ internal sealed partial class OpenKeyInEditorCommand : InvokableCommand // Resources.OpenInRegistryEditorAccessExceptionTitle, // MessageBoxButton.OK, // MessageBoxImage.Error); + Logger.LogError(ex.Message); return false; } #pragma warning disable CS0168, IDE0059 catch (Exception exception) { - // TODO GH #108: Logging - // Log.Exception("Error on opening Windows registry editor", exception, typeof(Main)); + Logger.LogError(exception.Message); return false; } #pragma warning restore CS0168, IDE0059 diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Constants/KeyName.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Constants/KeyName.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Constants/KeyName.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Constants/KeyName.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Constants/MaxTextLength.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Constants/MaxTextLength.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Constants/MaxTextLength.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Constants/MaxTextLength.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/CopyType.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/CopyType.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/CopyType.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/CopyType.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Enumerations/TruncateSide.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Enumerations/TruncateSide.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Enumerations/TruncateSide.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Enumerations/TruncateSide.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/ContextMenuHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ContextMenuHelper.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/ContextMenuHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ContextMenuHelper.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..bec1fb3271 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ISettingsInterface.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.Ext.Registry.Helpers; + +public interface ISettingsInterface +{ + // Add registry-specific settings methods here if needed + // For now, this can be empty if there are no settings for Registry +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/QueryHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/QueryHelper.cs similarity index 98% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/QueryHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/QueryHelper.cs index d197038eb8..60dca889b0 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/QueryHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/QueryHelper.cs @@ -55,7 +55,7 @@ internal static partial class QueryHelper /// <param name="query">The query that could contain parts</param> /// <param name="queryKey">The key part of the query</param> /// <param name="queryValueName">The value name part of the query</param> - /// <returns><see langword="true"/> when the query search for a key and a value name, otherwise <see langword="false"/></returns> + /// <returns><see langword="true"/> when the query search for a key and a value name; otherwise, <see langword="false"/></returns> internal static bool GetQueryParts(in string query, out string queryKey, out string queryValueName) { var sanitizedQuery = SanitizeQuery(query); diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/RegistryHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/RegistryHelper.cs similarity index 98% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/RegistryHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/RegistryHelper.cs index f010793c20..b64894baaf 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/RegistryHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/RegistryHelper.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; - using Microsoft.CmdPal.Ext.Registry.Classes; using Microsoft.CmdPal.Ext.Registry.Constants; using Microsoft.CmdPal.Ext.Registry.Properties; @@ -118,7 +117,7 @@ internal static class RegistryHelper subKey = result.First().Key; } - if (result.Count > 1 || subKey == null) + if (result.Count > 1 || subKey is null) { break; } @@ -183,7 +182,7 @@ internal static class RegistryHelper if (string.Equals(subKey, searchSubKey, StringComparison.OrdinalIgnoreCase)) { var key = parentKey.OpenSubKey(subKey, RegistryKeyPermissionCheck.ReadSubTree); - if (key != null) + if (key is not null) { list.Add(new RegistryEntry(key)); } @@ -194,7 +193,7 @@ internal static class RegistryHelper try { var key = parentKey.OpenSubKey(subKey, RegistryKeyPermissionCheck.ReadSubTree); - if (key != null) + if (key is not null) { list.Add(new RegistryEntry(key)); } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/ResultHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ResultHelper.cs similarity index 97% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/ResultHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ResultHelper.cs index 791d242d00..0ac3159531 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/ResultHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ResultHelper.cs @@ -5,7 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; - +using ManagedCommon; using Microsoft.CmdPal.Ext.Registry.Classes; using Microsoft.CmdPal.Ext.Registry.Commands; using Microsoft.CmdPal.Ext.Registry.Constants; @@ -88,7 +88,7 @@ internal static class ResultHelper foreach (var valueName in valueNames) { var value = key.GetValue(valueName); - if (value != null) + if (value is not null) { valueList.Add(KeyValuePair.Create(valueName, value)); } @@ -96,11 +96,12 @@ internal static class ResultHelper } catch (Exception valueException) { + Logger.LogError(valueException.Message); var registryEntry = new RegistryEntry(key.Name, valueException); resultList.Add(new ListItem(new OpenKeyInEditorCommand(registryEntry)) { - Icon = RegistryListPage.RegistryIcon, + Icon = Icons.RegistryIcon, Subtitle = GetTruncatedText(valueException.Message, MaxTextLength.MaximumSubTitleLengthWithThreeSymbols, TruncateSide.OnlyFromRight), Title = GetTruncatedText(key.Name, MaxTextLength.MaximumTitleLengthWithThreeSymbols), MoreCommands = ContextMenuHelper.GetContextMenu(registryEntry).ToArray(), @@ -129,7 +130,7 @@ internal static class ResultHelper resultList.Add(new ListItem(new OpenKeyInEditorCommand(registryEntry)) { - Icon = RegistryListPage.RegistryIcon, + Icon = Icons.RegistryIcon, Subtitle = GetTruncatedText(GetSubTileForRegistryValue(key, valueEntry), MaxTextLength.MaximumSubTitleLengthWithThreeSymbols, TruncateSide.OnlyFromRight), Title = GetTruncatedText(valueName, MaxTextLength.MaximumTitleLengthWithThreeSymbols), MoreCommands = ContextMenuHelper.GetContextMenu(registryEntry).ToArray(), @@ -144,7 +145,7 @@ internal static class ResultHelper resultList.Add(new ListItem(new OpenKeyInEditorCommand(registryEntry)) { - Icon = RegistryListPage.RegistryIcon, + Icon = Icons.RegistryIcon, Subtitle = GetTruncatedText(exception.Message, MaxTextLength.MaximumSubTitleLengthWithThreeSymbols, TruncateSide.OnlyFromRight), Title = GetTruncatedText(key.Name, MaxTextLength.MaximumTitleLengthWithThreeSymbols), }); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/SettingsManager.cs new file mode 100644 index 0000000000..aaf5d2cce0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/SettingsManager.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.IO; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Registry.Helpers; + +public class SettingsManager : JsonSettingsManager, ISettingsInterface +{ + private static readonly string _namespace = "registry"; + + private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; + + internal static string SettingsJsonPath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + + // now, the state is just next to the exe + return Path.Combine(directory, "settings.json"); + } + + public SettingsManager() + { + FilePath = SettingsJsonPath(); + + // Add settings here when needed + // Settings.Add(setting); + + // Load settings from file upon initialization + LoadSettings(); + + Settings.SettingsChanged += (s, a) => this.SaveSettings(); + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/ValueHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ValueHelper.cs similarity index 98% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/ValueHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ValueHelper.cs index e0e6eaf951..f25bc56064 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Helpers/ValueHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ValueHelper.cs @@ -26,7 +26,7 @@ internal static class ValueHelper { var unformattedValue = key.GetValue(valueName); - if (unformattedValue == null) + if (unformattedValue is null) { throw new InvalidOperationException($"Cannot proceed when {nameof(unformattedValue)} is null."); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Icons.cs new file mode 100644 index 0000000000..7e77abc757 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Icons.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Registry; + +internal sealed class Icons +{ + internal static IconInfo RegistryIcon { get; } = IconHelpers.FromRelativePath("Assets\\Registry.svg"); + + internal static IconInfo OpenInNewWindowIcon { get; } = new IconInfo("\xE8A7"); // OpenInNewWindow icon + + internal static IconInfo CopyIcon { get; } = new IconInfo("\xE8C8"); // Copy icon + + internal static IconInfo CopyToIcon { get; } = new IconInfo("\xF413"); // CopyTo Icon +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Microsoft.CmdPal.Ext.Registry.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Microsoft.CmdPal.Ext.Registry.csproj similarity index 78% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Microsoft.CmdPal.Ext.Registry.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Microsoft.CmdPal.Ext.Registry.csproj index 2ff67f859f..cfcf15f8cc 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Microsoft.CmdPal.Ext.Registry.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Microsoft.CmdPal.Ext.Registry.csproj @@ -1,5 +1,8 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> + <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.Registry</RootNamespace> <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> @@ -8,11 +11,13 @@ <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> <ProjectPriFileName>Microsoft.CmdPal.Ext.Registry.pri</ProjectPriFileName> </PropertyGroup> + <ItemGroup> <PackageReference Include="System.ServiceProcess.ServiceController" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props --> </ItemGroup> <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Pages/RegistryListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Pages/RegistryListPage.cs similarity index 91% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Pages/RegistryListPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Pages/RegistryListPage.cs index 34ca4e5d21..b37f0bc313 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Pages/RegistryListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Pages/RegistryListPage.cs @@ -18,15 +18,17 @@ internal sealed partial class RegistryListPage : DynamicListPage public static IconInfo RegistryIcon { get; } = new("\uE74C"); // OEM private readonly CommandItem _emptyMessage; + private readonly ISettingsInterface _settingsManager; - public RegistryListPage() + public RegistryListPage(ISettingsInterface settingsManager) { - Icon = IconHelpers.FromRelativePath("Assets\\Registry.svg"); + Icon = Icons.RegistryIcon; Name = Title = Resources.Registry_Page_Title; Id = "com.microsoft.cmdpal.registry"; + _settingsManager = settingsManager; _emptyMessage = new CommandItem() { - Icon = IconHelpers.FromRelativePath("Assets\\Registry.svg"), + Icon = Icons.RegistryIcon, Title = Resources.Registry_Key_Not_Found, Subtitle = SearchText, }; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..25f72ca88b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.Registry.UnitTests")] diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Properties/Resources.Designer.cs similarity index 95% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Properties/Resources.Designer.cs index 181bdc875d..f6edc5121e 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Registry.Properties { // 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", "17.0.0.0")] + [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 { @@ -177,6 +177,15 @@ namespace Microsoft.CmdPal.Ext.Registry.Properties { } } + /// <summary> + /// Looks up a localized string similar to Browse the Windows registry. + /// </summary> + internal static string RegistryProvider_BrowseTitle { + get { + return ResourceManager.GetString("RegistryProvider_BrowseTitle", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Windows Registry. /// </summary> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Properties/Resources.resx similarity index 98% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Properties/Resources.resx index bfa8d29787..1d13752f05 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Properties/Resources.resx @@ -188,4 +188,7 @@ <data name="RegistryProvider_DisplayName" xml:space="preserve"> <value>Windows Registry</value> </data> + <data name="RegistryProvider_BrowseTitle" xml:space="preserve"> + <value>Browse the Windows registry</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs similarity index 70% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs index cca02e8d77..72b88fadb7 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/RegistryCommandsProvider.cs @@ -2,6 +2,7 @@ // 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.Ext.Registry.Helpers; using Microsoft.CmdPal.Ext.Registry.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -10,20 +11,21 @@ namespace Microsoft.CmdPal.Ext.Registry; public partial class RegistryCommandsProvider : CommandProvider { + private static readonly ISettingsInterface _settingsManager = new SettingsManager(); + public RegistryCommandsProvider() { Id = "Windows.Registry"; DisplayName = Resources.RegistryProvider_DisplayName; - Icon = IconHelpers.FromRelativePath("Assets\\Registry.svg"); + Icon = Icons.RegistryIcon; } public override ICommandItem[] TopLevelCommands() { return [ - new CommandItem(new RegistryListPage()) + new CommandItem(new RegistryListPage(_settingsManager)) { - Title = "Registry", - Subtitle = "Navigate the Windows registry", + Title = Resources.RegistryProvider_BrowseTitle, } ]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.png new file mode 100644 index 0000000000..52d97dbfe9 Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.svg new file mode 100644 index 0000000000..e683f4d040 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.svg @@ -0,0 +1,21 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="3" y="3.55078" width="9.36537" height="9.36537" rx="0.720413" fill="url(#paint0_linear_2155_27162)"/> +<rect x="1" y="2" width="13" height="9" rx="0.722222" fill="url(#paint1_linear_2155_27162)"/> +<circle cx="11.4" cy="9.4" r="4.4" fill="url(#paint2_radial_2155_27162)"/> +<path d="M13.8703 11.2497C13.964 11.3434 14.116 11.3434 14.2097 11.2497C14.3034 11.156 14.3034 11.004 14.2097 10.9103L12.4594 9.16L14.2097 7.40971C14.3034 7.31598 14.3034 7.16402 14.2097 7.07029C14.116 6.97657 13.964 6.97657 13.8703 7.07029L11.9503 8.9903C11.8566 9.08402 11.8566 9.23598 11.9503 9.32971L13.8703 11.2497ZM9.40971 8.0303C9.31598 7.93657 9.16402 7.93657 9.07029 8.0303C8.97657 8.12402 8.97657 8.27598 9.07029 8.36971L10.8206 10.12L9.07029 11.8703C8.97657 11.964 8.97657 12.116 9.07029 12.2097C9.16402 12.3034 9.31598 12.3034 9.40971 12.2097L11.3297 10.2897C11.4234 10.196 11.4234 10.044 11.3297 9.95031L9.40971 8.0303Z" fill="#666666" stroke="#666666" stroke-width="0.146667"/> +<defs> +<linearGradient id="paint0_linear_2155_27162" x1="3.22298" y1="3.55078" x2="8.52487" y2="6.68847" gradientUnits="userSpaceOnUse"> +<stop stop-color="#246FB0"/> +<stop offset="1" stop-color="#14518A"/> +</linearGradient> +<linearGradient id="paint1_linear_2155_27162" x1="1.15476" y1="1.66667" x2="14.867" y2="9.90553" gradientUnits="userSpaceOnUse"> +<stop stop-color="#86D6F9"/> +<stop offset="1" stop-color="#1FA3E4"/> +</linearGradient> +<radialGradient id="paint2_radial_2155_27162" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(10.9111 6.22222) rotate(90) scale(7.57778)"> +<stop stop-color="#E7ECF1"/> +<stop offset="0.84" stop-color="#D2D4D6"/> +<stop offset="1" stop-color="#A9ABAC"/> +</radialGradient> +</defs> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/ConnectionListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/ConnectionListItem.cs new file mode 100644 index 0000000000..888a1d2f71 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/ConnectionListItem.cs @@ -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. + +using System.Globalization; +using System.Text; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Commands; + +internal sealed partial class ConnectionListItem : ListItem +{ + public ConnectionListItem(string connectionName) + { + ConnectionName = connectionName; + + if (string.IsNullOrEmpty(connectionName)) + { + Title = Resources.remotedesktop_open_rdp; + Subtitle = Resources.remotedesktop_subtitle; + } + else + { + Title = connectionName; + CompositeFormat remoteDesktopOpenHostFormat = CompositeFormat.Parse(Resources.remotedesktop_open_host); + Subtitle = string.Format(CultureInfo.CurrentCulture, remoteDesktopOpenHostFormat, connectionName); + } + + Icon = Icons.RDPIcon; + Command = new OpenRemoteDesktopCommand(connectionName); + } + + public string ConnectionName { get; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs new file mode 100644 index 0000000000..287a697c31 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.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 System.Globalization; +using System.Linq; +using System.Text; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Commands; + +internal sealed partial class FallbackRemoteDesktopItem : FallbackCommandItem +{ + private const string _id = "com.microsoft.cmdpal.builtin.remotedesktop.fallback"; + + private static readonly UriHostNameType[] ValidUriHostNameTypes = [ + UriHostNameType.IPv6, + UriHostNameType.IPv4, + UriHostNameType.Dns + ]; + + private static readonly CompositeFormat RemoteDesktopOpenHostFormat = CompositeFormat.Parse(Resources.remotedesktop_open_host); + + private readonly IRdpConnectionsManager _rdpConnectionsManager; + private readonly NoOpCommand _emptyCommand = new NoOpCommand(); + + public FallbackRemoteDesktopItem(IRdpConnectionsManager rdpConnectionsManager) + : base(Resources.remotedesktop_title, _id) + { + _rdpConnectionsManager = rdpConnectionsManager; + + Command = _emptyCommand; + Title = string.Empty; + Subtitle = string.Empty; + Icon = Icons.RDPIcon; + } + + public override void UpdateQuery(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + Title = string.Empty; + Subtitle = string.Empty; + Command = _emptyCommand; + return; + } + + var connections = _rdpConnectionsManager.Connections.Where(w => !string.IsNullOrWhiteSpace(w.ConnectionName)); + + var queryConnection = ConnectionHelpers.FindConnection(query, connections); + + if (queryConnection is not null && !string.IsNullOrWhiteSpace(queryConnection.ConnectionName)) + { + var connectionName = queryConnection.ConnectionName; + + Command = new OpenRemoteDesktopCommand(connectionName); + Title = connectionName; + Subtitle = string.Format(CultureInfo.CurrentCulture, RemoteDesktopOpenHostFormat, connectionName); + } + else if (ValidUriHostNameTypes.Contains(Uri.CheckHostName(query))) + { + var connectionName = query.Trim(); + Command = new OpenRemoteDesktopCommand(connectionName); + Title = string.Format(CultureInfo.CurrentCulture, RemoteDesktopOpenHostFormat, connectionName); + Subtitle = Resources.remotedesktop_title; + } + else + { + Title = string.Empty; + Subtitle = string.Empty; + Command = _emptyCommand; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/OpenRemoteDesktopCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/OpenRemoteDesktopCommand.cs new file mode 100644 index 0000000000..39c53fe9d2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/OpenRemoteDesktopCommand.cs @@ -0,0 +1,82 @@ +// 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; +using System.Text; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Commands; + +internal sealed partial class OpenRemoteDesktopCommand : BaseObservable, IInvokableCommand +{ + private static readonly CompositeFormat ProcessErrorFormat = + CompositeFormat.Parse(Resources.remotedesktop_log_mstsc_error); + + private static readonly CompositeFormat InvalidHostnameFormat = + CompositeFormat.Parse(Resources.remotedesktop_log_invalid_hostname); + + public string Name { get; } + + public string Id { get; } = "com.microsoft.cmdpal.builtin.remotedesktop.openrdp"; + + public IIconInfo Icon => Icons.RDPIcon; + + private readonly string _rdpHost; + + public OpenRemoteDesktopCommand(string rdpHost) + { + _rdpHost = rdpHost; + + Name = string.IsNullOrWhiteSpace(_rdpHost) ? + Resources.remotedesktop_command_open : + Resources.remotedesktop_command_connect; + } + + public ICommandResult Invoke(object sender) + { + using var process = new Process(); + process.StartInfo.UseShellExecute = false; + process.StartInfo.WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + process.StartInfo.FileName = "mstsc"; + + if (!string.IsNullOrWhiteSpace(_rdpHost)) + { + // validate that _rdpHost is a proper hostname or IP address + if (Uri.CheckHostName(_rdpHost) == UriHostNameType.Unknown) + { + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + InvalidHostnameFormat, + _rdpHost), + Result = CommandResult.KeepOpen(), + }); + } + + process.StartInfo.Arguments = $"/v:{_rdpHost}"; + } + + try + { + process.Start(); + + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ProcessErrorFormat, + ex.Message), + Result = CommandResult.KeepOpen(), + }); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/ConnectionHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/ConnectionHelpers.cs new file mode 100644 index 0000000000..5fac986169 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/ConnectionHelpers.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Helper; + +internal static class ConnectionHelpers +{ + public static ConnectionListItem MapToResult(string item) => new(item); + + public static ConnectionListItem? FindConnection(string query, IEnumerable<ConnectionListItem> connections) + { + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + var matchedConnection = ListHelpers.FilterList( + connections, + query, + (s, i) => ListHelpers.ScoreListItem(s, i)) + .FirstOrDefault(); + return matchedConnection; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRdpConnectionsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRdpConnectionsManager.cs new file mode 100644 index 0000000000..2968e15c9c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRdpConnectionsManager.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.Collections.Generic; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Helper; + +internal interface IRdpConnectionsManager +{ + IReadOnlyCollection<ConnectionListItem> Connections { get; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RdpConnectionsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RdpConnectionsManager.cs new file mode 100644 index 0000000000..6e357e27d9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RdpConnectionsManager.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Settings; +using Microsoft.Win32; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Helper; + +internal class RdpConnectionsManager : IRdpConnectionsManager +{ + private readonly ISettingsInterface _settingsManager; + private readonly ConnectionListItem _openRdpCommandListItem = new(string.Empty); + + private ReadOnlyCollection<ConnectionListItem> _connections = new(Array.Empty<ConnectionListItem>()); + + private const int MinutesToCache = 1; + private DateTime? _connectionsLastLoaded; + + public RdpConnectionsManager(ISettingsInterface settingsManager) + { + _settingsManager = settingsManager; + _settingsManager.Settings.SettingsChanged += (s, e) => + { + _connectionsLastLoaded = null; + }; + } + + public IReadOnlyCollection<ConnectionListItem> Connections + { + get + { + if (!_connectionsLastLoaded.HasValue || + (DateTime.Now - _connectionsLastLoaded.Value).TotalMinutes >= MinutesToCache) + { + var registryConnections = GetRdpConnectionsFromRegistry(); + var predefinedConnections = GetPredefinedConnectionsFromSettings(); + _connectionsLastLoaded = DateTime.Now; + + var newConnections = new List<ConnectionListItem>(registryConnections.Count + predefinedConnections.Count + 1); + newConnections.AddRange(registryConnections); + newConnections.AddRange(predefinedConnections); + newConnections.Insert(0, _openRdpCommandListItem); + + Interlocked.Exchange(ref _connections, new ReadOnlyCollection<ConnectionListItem>(newConnections)); + } + + return _connections; + } + } + + private List<ConnectionListItem> GetRdpConnectionsFromRegistry() + { + using var key = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Terminal Server Client\Default"); + + var validConnections = new List<ConnectionListItem>(); + + if (key is not null) + { + validConnections = key.GetValueNames() + .Select(name => key.GetValue(name)) + .OfType<string>() // Keep only string values + .Select(v => v.Trim()) // Normalize + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Distinct() // Remove dupes if any + .Select(ConnectionHelpers.MapToResult) + .ToList(); + } + + return validConnections; + } + + private List<ConnectionListItem> GetPredefinedConnectionsFromSettings() + { + var validConnections = _settingsManager.PredefinedConnections + .Select(s => s.Trim()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(ConnectionHelpers.MapToResult) + .ToList(); + + return validConnections; + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Icons.cs similarity index 57% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/Icons.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Icons.cs index 7586d466fd..eec9e48e24 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/Icons.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Icons.cs @@ -4,9 +4,9 @@ using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.Ext.Shell; +namespace Microsoft.CmdPal.Ext.RemoteDesktop; -internal sealed class Icons +internal static class Icons { - internal static IconInfo RunV2 { get; } = IconHelpers.FromRelativePath("Assets\\Run.svg"); + internal static IconInfo RDPIcon { get; } = IconHelpers.FromRelativePath("Assets\\RemoteDesktop.svg"); } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/Microsoft.CmdPal.Ext.Calc.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Microsoft.CmdPal.Ext.RemoteDesktop.csproj similarity index 61% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/Microsoft.CmdPal.Ext.Calc.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Microsoft.CmdPal.Ext.RemoteDesktop.csproj index 68a38c1ca2..3a45115bbb 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Calc/Microsoft.CmdPal.Ext.Calc.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Microsoft.CmdPal.Ext.RemoteDesktop.csproj @@ -1,17 +1,21 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> <PropertyGroup> - <RootNamespace>Microsoft.CmdPal.Ext.Calc</RootNamespace> + <RootNamespace>Microsoft.CmdPal.Ext.RemoteDesktop</RootNamespace> <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> - <ProjectPriFileName>Microsoft.CmdPal.Ext.Calc.pri</ProjectPriFileName> + <ProjectPriFileName>Microsoft.CmdPal.Ext.RemoteDesktop.pri</ProjectPriFileName> + <nullable>enable</nullable> </PropertyGroup> - <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> - </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props --> + </ItemGroup> <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> <DependentUpon>Resources.resx</DependentUpon> @@ -19,23 +23,22 @@ <AutoGen>True</AutoGen> </Compile> </ItemGroup> - - <ItemGroup> - <None Remove="Assets\Calculator.svg" /> - </ItemGroup> - <ItemGroup> - <Content Update="Assets\Calculator.png"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Update="Assets\Calculator.svg"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - </ItemGroup> <ItemGroup> <EmbeddedResource Update="Properties\Resources.resx"> <LastGenOutput>Resources.Designer.cs</LastGenOutput> <Generator>PublicResXFileCodeGenerator</Generator> </EmbeddedResource> </ItemGroup> + <ItemGroup> + <Folder Include="Assets\" /> + </ItemGroup> + <ItemGroup> + <Content Update="Assets\RemoteDesktop.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\RemoteDesktop.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> </Project> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs new file mode 100644 index 0000000000..c6ba2b3187 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Pages; + +internal sealed partial class RemoteDesktopListPage : ListPage +{ + private readonly IRdpConnectionsManager _rdpConnectionsManager; + + public RemoteDesktopListPage(IRdpConnectionsManager rdpConnectionsManager) + { + Icon = Icons.RDPIcon; + Name = Resources.remotedesktop_title; + Id = "com.microsoft.cmdpal.builtin.remotedesktop"; + + _rdpConnectionsManager = rdpConnectionsManager; + } + + public override IListItem[] GetItems() => _rdpConnectionsManager.Connections.ToArray(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..4a6c84ddea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..f3262d526c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.Designer.cs @@ -0,0 +1,154 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// 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. +// </auto-generated> +//------------------------------------------------------------------------------ + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Properties { + using System; + + + /// <summary> + /// A strongly-typed resource class, for looking up localized strings, etc. + /// </summary> + // 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()] + public 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() { + } + + /// <summary> + /// Returns the cached ResourceManager instance used by this class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Ext.RemoteDesktop.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// <summary> + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// <summary> + /// Looks up a localized string similar to Connect. + /// </summary> + public static string remotedesktop_command_connect { + get { + return ResourceManager.GetString("remotedesktop_command_connect", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open. + /// </summary> + public static string remotedesktop_command_open { + get { + return ResourceManager.GetString("remotedesktop_command_open", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to The hostname '{0}' was invalid. Ensure you're using a valid hostname or IP address.. + /// </summary> + public static string remotedesktop_log_invalid_hostname { + get { + return ResourceManager.GetString("remotedesktop_log_invalid_hostname", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Unable to initialize Microsoft Terminal Service Client. Ensure it is enabled in Windows Settings. + ///{0}. + /// </summary> + public static string remotedesktop_log_mstsc_error { + get { + return ResourceManager.GetString("remotedesktop_log_mstsc_error", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Connect to {0}. + /// </summary> + public static string remotedesktop_open_host { + get { + return ResourceManager.GetString("remotedesktop_open_host", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Remote Desktop Client. + /// </summary> + public static string remotedesktop_open_rdp { + get { + return ResourceManager.GetString("remotedesktop_open_rdp", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to A list of connections to include in the query results by default. + /// </summary> + public static string remotedesktop_settings_predefined_connections_description { + get { + return ResourceManager.GetString("remotedesktop_settings_predefined_connections_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Predefined connections. + /// </summary> + public static string remotedesktop_settings_predefined_connections_title { + get { + return ResourceManager.GetString("remotedesktop_settings_predefined_connections_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Establish Remote Desktop connections. + /// </summary> + public static string remotedesktop_subtitle { + get { + return ResourceManager.GetString("remotedesktop_subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Remote Desktop. + /// </summary> + public static string remotedesktop_title { + get { + return ResourceManager.GetString("remotedesktop_title", resourceCulture); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.resx new file mode 100644 index 0000000000..75603679d4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.resx @@ -0,0 +1,151 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="remotedesktop_title" xml:space="preserve"> + <value>Remote Desktop</value> + </data> + <data name="remotedesktop_subtitle" xml:space="preserve"> + <value>Establish Remote Desktop connections</value> + </data> + <data name="remotedesktop_command_open" xml:space="preserve"> + <value>Open</value> + </data> + <data name="remotedesktop_open_host" xml:space="preserve"> + <value>Connect to {0}</value> + </data> + <data name="remotedesktop_command_connect" xml:space="preserve"> + <value>Connect</value> + </data> + <data name="remotedesktop_open_rdp" xml:space="preserve"> + <value>Open Remote Desktop Client</value> + </data> + <data name="remotedesktop_settings_predefined_connections_title" xml:space="preserve"> + <value>Predefined connections</value> + </data> + <data name="remotedesktop_settings_predefined_connections_description" xml:space="preserve"> + <value>A list of connections to include in the query results by default</value> + </data> + <data name="remotedesktop_log_mstsc_error" xml:space="preserve"> + <value>Unable to initialize Microsoft Terminal Service Client. Ensure it is enabled in Windows Settings. +{0}</value> + </data> + <data name="remotedesktop_log_invalid_hostname" xml:space="preserve"> + <value>The hostname '{0}' was invalid. Ensure you're using a valid hostname or IP address.</value> + </data> +</root> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs new file mode 100644 index 0000000000..005c93c7e1 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs @@ -0,0 +1,44 @@ +// 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.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Pages; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CmdPal.Ext.RemoteDesktop.Settings; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop; + +public partial class RemoteDesktopCommandProvider : CommandProvider +{ + private readonly CommandItem listPageCommand; + private readonly FallbackRemoteDesktopItem fallback; + + public RemoteDesktopCommandProvider() + { + Id = "com.microsoft.cmdpal.builtin.remotedesktop"; + DisplayName = Resources.remotedesktop_title; + Icon = Icons.RDPIcon; + + var settingsManager = new SettingsManager(); + var rdpConnectionsManager = new RdpConnectionsManager(settingsManager); + var listPage = new RemoteDesktopListPage(rdpConnectionsManager); + + fallback = new FallbackRemoteDesktopItem(rdpConnectionsManager); + + listPageCommand = new CommandItem(listPage) + { + Icon = Icons.RDPIcon, + MoreCommands = [ + new CommandContextItem(settingsManager.Settings.SettingsPage), + ], + }; + } + + public override ICommandItem[] TopLevelCommands() => [listPageCommand]; + + public override IFallbackCommandItem[] FallbackCommands() => [fallback]; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/ISettingsInterface.cs new file mode 100644 index 0000000000..dbca0d3833 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/ISettingsInterface.cs @@ -0,0 +1,15 @@ +// 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 ToolkitSettings = Microsoft.CommandPalette.Extensions.Toolkit.Settings; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Settings; + +internal interface ISettingsInterface +{ + public IReadOnlyCollection<string> PredefinedConnections { get; } + + public ToolkitSettings Settings { get; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/SettingsManager.cs new file mode 100644 index 0000000000..1469e448d7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/SettingsManager.cs @@ -0,0 +1,53 @@ +// 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.IO; +using System.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Settings; + +internal class SettingsManager : JsonSettingsManager, ISettingsInterface +{ + // Line break character used in WinUI3 TextBox and TextBlock. + private const char TEXTBOXNEWLINE = '\r'; + + private static readonly string _namespace = "com.microsoft.cmdpal.builtin.remotedesktop"; + + private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; + + private readonly TextSetting _predefinedConnections = new( + Namespaced(nameof(PredefinedConnections)), + Resources.remotedesktop_settings_predefined_connections_title, + Resources.remotedesktop_settings_predefined_connections_description, + string.Empty) + { + Multiline = true, + Placeholder = $"server1.domain.com{TEXTBOXNEWLINE}server2.domain.com{TEXTBOXNEWLINE}192.168.1.1", + }; + + public IReadOnlyCollection<string> PredefinedConnections => _predefinedConnections.Value?.Split(TEXTBOXNEWLINE).ToList() ?? []; + + internal static string SettingsJsonPath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + + return Path.Combine(directory, "settings.json"); + } + + public SettingsManager() + { + FilePath = SettingsJsonPath(); + + Settings.Add(_predefinedConnections); + + // Load settings from file upon initialization + LoadSettings(); + + Settings.SettingsChanged += (s, a) => this.SaveSettings(); + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Assets/Run.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Assets/Run.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Assets/Run.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Assets/Run.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Assets/Run.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Assets/Run.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Assets/Run.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Assets/Run.svg diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/Assets/Run_V2_2x.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Assets/Run_V2_2x.svg similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/Assets/Run_V2_2x.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Assets/Run_V2_2x.svg diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/OpenUrlWithHistoryCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/OpenUrlWithHistoryCommand.cs new file mode 100644 index 0000000000..f1fc878558 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Commands/OpenUrlWithHistoryCommand.cs @@ -0,0 +1,43 @@ +// 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; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Shell; + +internal sealed partial class OpenUrlWithHistoryCommand : OpenUrlCommand +{ + private readonly Action<string>? _addToHistory; + private readonly string _url; + private readonly ITelemetryService? _telemetryService; + + public OpenUrlWithHistoryCommand(string url, Action<string>? addToHistory = null, ITelemetryService? telemetryService = null) + : base(url) + { + _addToHistory = addToHistory; + _url = url; + _telemetryService = telemetryService; + } + + public override CommandResult Invoke() + { + _addToHistory?.Invoke(_url); + + var success = ShellHelpers.OpenInShell(_url); + var isWebUrl = false; + + if (Uri.TryCreate(_url, UriKind.Absolute, out var uri)) + { + if (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) + { + isWebUrl = true; + } + } + + _telemetryService?.LogOpenUri(_url, isWebUrl, success); + + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs new file mode 100644 index 0000000000..46e86bfeed --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs @@ -0,0 +1,224 @@ +// 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; +using Microsoft.CmdPal.Ext.Shell.Helpers; +using Microsoft.CmdPal.Ext.Shell.Pages; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Shell; + +internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDisposable +{ + private const string _id = "com.microsoft.cmdpal.builtin.shell.fallback"; + private static readonly char[] _systemDirectoryRoots = ['\\', '/']; + + private readonly Action<string>? _addToHistory; + private readonly ITelemetryService _telemetryService; + private CancellationTokenSource? _cancellationTokenSource; + + public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory, ITelemetryService telemetryService) + : base( + new NoOpCommand() { Id = _id }, + ResourceLoaderInstance.GetString("shell_command_display_title"), + _id) + { + Title = string.Empty; + Subtitle = ResourceLoaderInstance.GetString("generic_run_command"); + Icon = Icons.RunV2Icon; // Defined in Icons.cs and contains the execute command icon. + _addToHistory = addToHistory; + _telemetryService = telemetryService; + } + + public override void UpdateQuery(string query) + { + // Cancel any ongoing query processing + _cancellationTokenSource?.Cancel(); + + _cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = _cancellationTokenSource.Token; + + try + { + DoUpdateQuery(query, cancellationToken); + } + catch (Exception) + { + // Handle other exceptions + return; + } + } + + private void DoUpdateQuery(string query, CancellationToken cancellationToken) + { + // Check for cancellation at the start + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var searchText = query.Trim(); + Expand(ref searchText); + + if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText)) + { + Command = null; + Title = string.Empty; + return; + } + + ShellListPageHelpers.NormalizeCommandLineAndArgs(searchText, out var exe, out var args); + + // Check for cancellation before file system operations + cancellationToken.ThrowIfCancellationRequested(); + + var exeExists = false; + var fullExePath = string.Empty; + var pathIsDir = false; + + try + { + // Create a timeout for file system operations (200ms) + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + var timeoutToken = combinedCts.Token; + + exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath, cancellationToken); + pathIsDir = Directory.Exists(exe); + } + catch (TimeoutException) + { + // Timeout occurred - use defaults + return; + } + catch (OperationCanceledException) + { + // Timeout occurred (from WaitAsync) - use defaults + return; + } + catch (Exception) + { + // Handle any other exceptions that might bubble up + return; + } + + // Check for cancellation before updating UI properties + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (exeExists) + { + // TODO we need to probably get rid of the settings for this provider entirely + var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory, telemetryService: _telemetryService); + Title = exeItem.Title; + Subtitle = exeItem.Subtitle; + Icon = exeItem.Icon; + Command = exeItem.Command; + MoreCommands = exeItem.MoreCommands; + } + else if (pathIsDir) + { + var pathItem = new PathListItem(exe, query, _addToHistory, _telemetryService); + Command = pathItem.Command; + MoreCommands = pathItem.MoreCommands; + Title = pathItem.Title; + Subtitle = pathItem.Subtitle; + Icon = pathItem.Icon; + } + else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) + { + Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory, _telemetryService) { Result = CommandResult.Dismiss() }; + Title = searchText; + } + else + { + Command = null; + Title = string.Empty; + } + + // Final cancellation check + if (cancellationToken.IsCancellationRequested) + { + return; + } + } + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + } + + internal static bool SuppressFileFallbackIf(string query) + { + var searchText = query.Trim(); + Expand(ref searchText); + + if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText)) + { + return false; + } + + ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args); + var exeExists = ShellListPageHelpers.FileExistInPath(exe, out var fullExePath); + var pathIsDir = Directory.Exists(exe); + + return exeExists || pathIsDir; + } + + private static void Expand(ref string searchText) + { + if (searchText.Length == 0) + { + return; + } + + var singleCharQuery = searchText.Length == 1; + + searchText = Environment.ExpandEnvironmentVariables(searchText); + + if (!TryExpandHome(ref searchText)) + { + TryExpandRoot(ref searchText); + } + } + + private static bool TryExpandHome(ref string searchText) + { + if (searchText[0] == '~') + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (searchText.Length == 1) + { + searchText = home; + } + else if (_systemDirectoryRoots.Contains(searchText[1])) + { + searchText = Path.Combine(home, searchText[2..]); + } + + return true; + } + + return false; + } + + private static bool TryExpandRoot(ref string searchText) + { + if (_systemDirectoryRoots.Contains(searchText[0]) && (searchText.Length == 1 || !_systemDirectoryRoots.Contains(searchText[1]))) + { + var root = Path.GetPathRoot(Environment.SystemDirectory); + if (root != null) + { + searchText = searchText.Length == 1 ? root : Path.Combine(root, searchText[1..]); + return true; + } + } + + return false; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs new file mode 100644 index 0000000000..6a9fe10562 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/CommandLineNormalizer.cs @@ -0,0 +1,266 @@ +// 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; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Storage.FileSystem; + +namespace Microsoft.CmdPal.Ext.Shell.Helpers; + +/// <summary> +/// Provides command line normalization functionality compatible with .NET +/// Native AOT. This is a C# port of the Profile::NormalizeCommandLine function +/// from the Windows Terminal codebase. +/// +/// It was ported from 7055b99ac on 2025-09-25 +/// </summary> +public static class CommandLineNormalizer +{ +#pragma warning disable SA1310 // Field names should not contain underscore + private const uint INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF; + + private const int MAX_PATH = 260; +#pragma warning restore SA1310 // Field names should not contain underscore + + /// <summary> + /// Normalizes a command line string by expanding environment variables, resolving executable paths, + /// and standardizing the format for comparison purposes. + /// </summary> + /// <param name="commandLine">The command line string to normalize</param> + /// <returns>A normalized command line string</returns> + /// <remarks> + /// This function performs the following operations: + /// 1. Expands environment variables (e.g., %SystemRoot% -> C:\WINDOWS) + /// 2. Parses the command line into arguments, stripping quotes + /// 3. Resolves the executable path to an absolute, canonical path + /// 4. Reconstructs the command line with null separators between arguments + /// + /// Given a commandLine like: + /// * "C:\WINDOWS\System32\cmd.exe" + /// * "pwsh -WorkingDirectory ~" + /// * "C:\Program Files\PowerShell\7\pwsh.exe" + /// * "C:\Program Files\PowerShell\7\pwsh.exe -WorkingDirectory ~" + /// + /// This function returns: + /// * "C:\Windows\System32\cmd.exe" + /// * "C:\Program Files\PowerShell\7\pwsh.exe\0-WorkingDirectory\0~" + /// * "C:\Program Files\PowerShell\7\pwsh.exe" + /// * "C:\Program Files\PowerShell\7\pwsh.exe\0-WorkingDirectory\0~" + /// + /// The resulting strings are used for comparisons in profile matching. + /// </remarks> + public static string NormalizeCommandLine(string commandLine, bool allowDirectory) + { + if (string.IsNullOrEmpty(commandLine)) + { + return string.Empty; + } + + // Turn "%SystemRoot%\System32\cmd.exe" into "C:\WINDOWS\System32\cmd.exe". + // We do this early, as environment variables might occur anywhere in the commandLine. + var normalized = ExpandEnvironmentVariables(commandLine); + + // One of the most important things this function does is to strip quotes. + // That way the commandLine "foo.exe -bar" and "\"foo.exe\" \"-bar\"" appear identical. + // We'll use CommandLineToArgvW for that as it's close to what CreateProcessW uses. + var argv = ParseCommandLineToArguments(normalized); + + if (argv.Length == 0) + { + return normalized; + } + + // The index of the first argument in argv after our executable in argv[0]. + // Given {"C:\Program Files\PowerShell\7\pwsh.exe", "-WorkingDirectory", "~"} this will be 1. + var startOfArguments = 1; + + // The given commandLine should start with an executable name or path. + // This loop tries to resolve relative paths, as well as executable names in %PATH% + // into absolute paths and normalizes them. + var executablePath = ResolveExecutablePath(argv, allowDirectory, ref startOfArguments); + + // We've (hopefully) finished resolving the path to the executable. + // We're now going to append all remaining arguments to the resulting string. + // If argv is {"C:\Program Files\PowerShell\7\pwsh.exe", "-WorkingDirectory", "~"}, + // then we'll get "C:\Program Files\PowerShell\7\pwsh.exe\0-WorkingDirectory\0~" + var result = new StringBuilder(executablePath); + + for (var i = startOfArguments; i < argv.Length; i++) + { + result.Append('\0'); + result.Append(argv[i]); + } + + return result.ToString(); + } + + /// <summary> + /// Expands environment variables in a string using Windows API. + /// </summary> + private static string ExpandEnvironmentVariables(string input) + { + const int initialBufferSize = 1024; + var buffer = new char[initialBufferSize]; + + var result = PInvoke.ExpandEnvironmentStrings(input, buffer); + + if (result == 0) + { + // Failed to expand, return original string + return input; + } + + if (result > buffer.Length) + { + // Buffer was too small, resize and try again + buffer = new char[result]; + result = PInvoke.ExpandEnvironmentStrings(input, buffer); + + if (result == 0) + { + return input; + } + } + + return new string(buffer, 0, (int)result - 1); // -1 to exclude null terminator + } + + /// <summary> + /// Parses a command line string into arguments using CommandLineToArgvW. + /// </summary> + private static string[] ParseCommandLineToArguments(string commandLine) + { + unsafe + { + var argv = PInvoke.CommandLineToArgv(commandLine, out var argc); + + if (argv == null || argc == 0) + { + return Array.Empty<string>(); + } + + try + { + var args = new string[argc]; + + for (var i = 0; i < argc; i++) + { + args[i] = new string(argv[i]); + } + + return args; + } + finally + { + PInvoke.LocalFree(new HLOCAL(argv)); + } + } + } + + /// <summary> + /// Resolves the executable path from the command line arguments. + /// Handles cases where the path contains spaces and was split during parsing. + /// </summary> + private static string ResolveExecutablePath(string[] argv, bool allowDirectory, ref int startOfArguments) + { + if (argv.Length == 0) + { + return string.Empty; + } + + // Try to resolve the executable path, handling cases where spaces in paths + // might have caused the path to be split across multiple arguments + for (var pathLength = 1; pathLength <= argv.Length; pathLength++) + { + // Build potential executable path by combining arguments + var pathBuilder = new StringBuilder(argv[0]); + for (var i = 1; i < pathLength; i++) + { + pathBuilder.Append(' '); + pathBuilder.Append(argv[i]); + } + + var candidatePath = pathBuilder.ToString(); + var resolvedPath = TryResolveExecutable(candidatePath, allowDirectory); + + if (!string.IsNullOrEmpty(resolvedPath)) + { + startOfArguments = pathLength; + return GetCanonicalPath(resolvedPath); + } + } + + // If we couldn't resolve the path, return the first argument as-is + startOfArguments = 1; + return argv[0]; + } + + /// <summary> + /// Attempts to resolve an executable path using SearchPathW. + /// </summary> + private static string TryResolveExecutable(string executableName, bool allowDirectory) + { + var buffer = new char[MAX_PATH]; + + unsafe + { + var outParam = default(PWSTR); // ultimately discarded + + var result = PInvoke.SearchPath( + null, // Use default search path + executableName, + ".exe", // Default extension + buffer, + &outParam); // We don't need the file part + + if (result == 0) + { + return string.Empty; + } + + if (result > buffer.Length) + { + // Buffer was too small, resize and try again + buffer = new char[result]; + result = PInvoke.SearchPath(null, executableName, ".exe", buffer, &outParam); + + if (result == 0) + { + return string.Empty; + } + } + + var resolvedPath = new string(buffer, 0, (int)result); + + // Verify the resolved path exists... + var attributes = PInvoke.GetFileAttributes(resolvedPath); + + // ... and if we don't want to allow directories, reject paths that are dirs + var rejectDirectory = !allowDirectory && + (attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0; + + return attributes == INVALID_FILE_ATTRIBUTES || + rejectDirectory ? + string.Empty : + resolvedPath; + } + } + + /// <summary> + /// Gets the canonical (absolute, normalized) path for a file. + /// </summary> + private static string GetCanonicalPath(string path) + { + try + { + return Path.GetFullPath(path); + } + catch + { + // If canonicalization fails, return the original path + return path; + } + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/Helpers/ExecutionShell.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ExecutionShell.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/Helpers/ExecutionShell.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ExecutionShell.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Helpers/RunAsType.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/RunAsType.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Helpers/RunAsType.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/RunAsType.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs new file mode 100644 index 0000000000..15330a2751 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Helpers/ShellListPageHelpers.cs @@ -0,0 +1,217 @@ +// 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; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Ext.Shell.Pages; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Shell.Helpers; + +public class ShellListPageHelpers +{ + internal static bool FileExistInPath(string filename) + { + return FileExistInPath(filename, out var _); + } + + internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null) + { + return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None); + } + + internal static ListItem? ListItemForCommandString(string query, Action<string>? addToHistory, ITelemetryService? telemetryService) + { + var li = new ListItem(); + + var searchText = query.Trim(); + var expanded = Environment.ExpandEnvironmentVariables(searchText); + searchText = expanded; + if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText)) + { + return null; + } + + ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args); + + var exeExists = false; + var pathIsDir = false; + var fullExePath = string.Empty; + + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + + // Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation + var pathResolutionTask = Task.Run( + () => + { + // Don't check cancellation token here - let the Task timeout handle it + exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath); + pathIsDir = Directory.Exists(expanded); + }, + CancellationToken.None); // Use None here since we're handling timeout differently + + // Wait for either completion or timeout + pathResolutionTask.Wait(cts.Token); + } + catch (OperationCanceledException) + { + } + + if (exeExists) + { + // TODO we need to probably get rid of the settings for this provider entirely + var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory, telemetryService); + li.Command = exeItem.Command; + li.Title = exeItem.Title; + li.Subtitle = exeItem.Subtitle; + li.Icon = exeItem.Icon; + li.MoreCommands = exeItem.MoreCommands; + } + else if (pathIsDir) + { + var pathItem = new PathListItem(exe, query, addToHistory, telemetryService); + li.Command = pathItem.Command; + li.Title = pathItem.Title; + li.Subtitle = pathItem.Subtitle; + li.Icon = pathItem.Icon; + li.MoreCommands = pathItem.MoreCommands; + } + else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) + { + li.Command = new OpenUrlWithHistoryCommand(searchText, addToHistory, telemetryService) { Result = CommandResult.Dismiss() }; + li.Title = searchText; + } + else + { + return null; + } + + if (li is not null) + { + li.TextToSuggest = searchText; + } + + return li; + } + + /// <summary> + /// This is a version of ParseExecutableAndArgs that handles whitespace in + /// paths better. It will try to find the first matching executable in the + /// input string. + /// + /// If the input is quoted, it will treat everything inside the quotes as + /// the executable. If the input is not quoted, it will try to find the + /// first segment that matches + /// </summary> + public static void NormalizeCommandLineAndArgs(string input, out string executable, out string arguments) + { + var normalized = CommandLineNormalizer.NormalizeCommandLine(input, allowDirectory: true); + var segments = normalized.Split('\0', StringSplitOptions.RemoveEmptyEntries); + executable = string.Empty; + arguments = string.Empty; + if (segments.Length == 0) + { + return; + } + + executable = segments[0]; + if (segments.Length > 1) + { + arguments = ArgumentBuilder.BuildArguments(segments[1..]); + } + } + + private static class ArgumentBuilder + { + internal static string BuildArguments(string[] arguments) + { + if (arguments.Length <= 0) + { + return string.Empty; + } + + var stringBuilder = new StringBuilder(); + foreach (var argument in arguments) + { + AppendArgument(stringBuilder, argument); + } + + return stringBuilder.ToString(); + } + + private static void AppendArgument(StringBuilder stringBuilder, string argument) + { + if (stringBuilder.Length > 0) + { + stringBuilder.Append(' '); + } + + if (argument.Length == 0 || ShouldBeQuoted(argument)) + { + stringBuilder.Append('\"'); + var index = 0; + while (index < argument.Length) + { + var c = argument[index++]; + if (c == '\\') + { + var numBackSlash = 1; + while (index < argument.Length && argument[index] == '\\') + { + index++; + numBackSlash++; + } + + if (index == argument.Length) + { + stringBuilder.Append('\\', numBackSlash * 2); + } + else if (argument[index] == '\"') + { + stringBuilder.Append('\\', (numBackSlash * 2) + 1); + stringBuilder.Append('\"'); + index++; + } + else + { + stringBuilder.Append('\\', numBackSlash); + } + + continue; + } + + if (c == '\"') + { + stringBuilder.Append('\\'); + stringBuilder.Append('\"'); + continue; + } + + stringBuilder.Append(c); + } + + stringBuilder.Append('\"'); + } + else + { + stringBuilder.Append(argument); + } + } + + private static bool ShouldBeQuoted(string s) + { + foreach (var c in s) + { + if (char.IsWhiteSpace(c) || c == '\"') + { + return true; + } + } + + return false; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Icons.cs new file mode 100644 index 0000000000..f83cdb18ae --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Icons.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Shell; + +internal sealed class Icons +{ + internal static IconInfo RunV2Icon { get; } = IconHelpers.FromRelativePath("Assets\\Run.svg"); + + internal static IconInfo FolderIcon { get; } = new IconInfo("📁"); + + internal static IconInfo AdminIcon { get; } = new IconInfo("\xE7EF"); // Admin Icon + + internal static IconInfo UserIcon { get; } = new IconInfo("\xE7EE"); // User Icon +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/LocalSuppressions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/LocalSuppressions.cs new file mode 100644 index 0000000000..dc699880c9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/LocalSuppressions.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Interoperability", "CsWinRT1028: Class should be marked partial", Justification = "CsWin32 generated code; not used across WinRT boundary", Scope = "type", Target = "~T:Windows.Win32.LocalFreeSafeHandle")] diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj similarity index 82% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj index c95f2a93b2..dcb618ec89 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Microsoft.CmdPal.Ext.Shell.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="..\..\CoreCommonProps.props" /> + <PropertyGroup> - <Nullable>enable</Nullable> <RootNamespace>Microsoft.CmdPal.Ext.Shell</RootNamespace> <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> @@ -11,8 +11,12 @@ <None Remove="Assets\Run.svg" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> </ItemGroup> + <ItemGroup> + <PackageReference Include="System.CommandLine" /> + </ItemGroup> + <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> <DependentUpon>Resources.resx</DependentUpon> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json new file mode 100644 index 0000000000..b1156c41b7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false, + "comInterop": { + "preserveSigMethods": [ "*" ] + } +} \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt new file mode 100644 index 0000000000..ea62d0c662 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/NativeMethods.txt @@ -0,0 +1,22 @@ +GetCurrentPackageFullName +SetWindowLong +GetWindowLong +WINDOW_EX_STYLE +SFBS_FLAGS +MAX_PATH +GetDpiForWindow +GetWindowRect +GetMonitorInfo +SetWindowPos +MonitorFromWindow + +SHOW_WINDOW_CMD +ShellExecuteEx +SEE_MASK_INVOKEIDLIST + +ExpandEnvironmentStringsW +CommandLineToArgvW +SearchPathW +GetFileAttributesW +LocalFree +FILE_FLAGS_AND_ATTRIBUTES diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/PathListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/PathListItem.cs new file mode 100644 index 0000000000..b937ec7796 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/PathListItem.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Commands; +using Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; + +namespace Microsoft.CmdPal.Ext.Shell; + +internal sealed partial class PathListItem : ListItem +{ + private readonly Lazy<bool> fetchedIcon; + private readonly bool isDirectory; + private readonly string path; + + public override IIconInfo? Icon { get => fetchedIcon.Value ? _icon : _icon; set => base.Icon = value; } + + private IIconInfo? _icon; + + internal bool IsDirectory => isDirectory; + + public PathListItem(string path, string originalDir, Action<string>? addToHistory, ITelemetryService? telemetryService = null) + : base(new OpenUrlWithHistoryCommand(path, addToHistory, telemetryService)) + { + var fileName = Path.GetFileName(path); + if (string.IsNullOrEmpty(fileName)) + { + fileName = Path.GetFileName(Path.GetDirectoryName(path)) ?? string.Empty; + } + + isDirectory = Directory.Exists(path); + if (isDirectory) + { + if (!path.EndsWith('\\')) + { + path = path + "\\"; + } + + if (!fileName.EndsWith('\\')) + { + fileName = fileName + "\\"; + } + } + + this.path = path; + + Title = fileName; // Just the name of the file is the Title + Subtitle = path; // What the user typed is the subtitle + + TextToSuggest = path; + + MoreCommands = [ + new CommandContextItem(new OpenWithCommand(path)), + new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.E) }, + new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) }, + new CommandContextItem(new OpenInConsoleCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R) }, + new CommandContextItem(new OpenPropertiesCommand(path)), + ]; + + fetchedIcon = new Lazy<bool>(() => + { + _ = Task.Run(FetchIconAsync); + return true; + }); + } + + private async Task FetchIconAsync() + { + var iconStream = await ThumbnailHelper.GetThumbnail(path); + var icon = iconStream != null ? + IconInfo.FromStream(iconStream) : + isDirectory ? Icons.FolderIcon : Icons.RunV2Icon; + _icon = icon; + OnPropertyChanged(nameof(Icon)); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs new file mode 100644 index 0000000000..1a37093c77 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/RunExeItem.cs @@ -0,0 +1,129 @@ +// 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; +using Microsoft.CmdPal.Ext.Shell.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Storage.Streams; +using Windows.System; + +namespace Microsoft.CmdPal.Ext.Shell.Pages; + +internal sealed partial class RunExeItem : ListItem +{ + private readonly Lazy<IconInfo> _icon; + private readonly Action<string>? _addToHistory; + private readonly ITelemetryService? _telemetryService; + + public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; } + + internal string FullExePath { get; private set; } + + internal string Exe { get; private set; } + + private string _args = string.Empty; + + private string FullString => string.IsNullOrEmpty(_args) ? Exe : $"{Exe} {_args}"; + + public RunExeItem( + string exe, + string args, + string fullExePath, + Action<string>? addToHistory, + ITelemetryService? telemetryService = null) + { + FullExePath = fullExePath; + Exe = exe; + var command = new AnonymousCommand(Run) + { + Name = ResourceLoaderInstance.GetString("generic_run_command"), + Result = CommandResult.Dismiss(), + }; + Command = command; + Subtitle = FullExePath; + + _icon = new Lazy<IconInfo>(() => + { + var t = FetchIcon(); + t.Wait(); + return t.Result; + }); + + _addToHistory = addToHistory; + _telemetryService = telemetryService; + + UpdateArgs(args); + + MoreCommands = [ + new CommandContextItem( + new AnonymousCommand(RunAsAdmin) + { + Name = ResourceLoaderInstance.GetString("cmd_run_as_administrator"), + Icon = Icons.AdminIcon, + }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter) }, + new CommandContextItem( + new AnonymousCommand(RunAsOther) + { + Name = ResourceLoaderInstance.GetString("cmd_run_as_user"), + Icon = Icons.UserIcon, + }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.U) }, + ]; + } + + internal void UpdateArgs(string args) + { + _args = args; + Title = string.IsNullOrEmpty(_args) ? Exe : Exe + " " + _args; // todo! you're smarter than this + } + + public async Task<IconInfo> FetchIcon() + { + IconInfo? icon = null; + + try + { + var stream = await ThumbnailHelper.GetThumbnail(FullExePath); + if (stream is not null) + { + var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); + icon = new IconInfo(data, data); + ((AnonymousCommand?)Command)!.Icon = icon; + } + } + catch + { + } + + icon = icon ?? new IconInfo(FullExePath); + return icon; + } + + public void Run() + { + _addToHistory?.Invoke(FullString); + + var success = ShellHelpers.OpenInShell(FullExePath, _args); + + _telemetryService?.LogRunCommand(FullString, false, success); + } + + public void RunAsAdmin() + { + _addToHistory?.Invoke(FullString); + + var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator); + + _telemetryService?.LogRunCommand(FullString, true, success); + } + + public void RunAsOther() + { + _addToHistory?.Invoke(FullString); + + var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser); + + _telemetryService?.LogRunCommand(FullString, false, success); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs new file mode 100644 index 0000000000..82c6c2ddbc --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.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 Microsoft.CmdPal.Core.Common.Services; +using Microsoft.CmdPal.Ext.Shell.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.Shell.Pages; + +internal sealed partial class ShellListPage : DynamicListPage, IDisposable +{ + private readonly Dictionary<string, ListItem> _historyItems = []; + private readonly List<ListItem> _currentHistoryItems = []; + + private readonly IRunHistoryService _historyService; + private readonly ITelemetryService? _telemetryService; + + private readonly Dictionary<string, ListItem> _currentPathItems = new(); + + private ListItem? _exeItem; + private List<ListItem> _pathItems = []; + private ListItem? _uriItem; + + private CancellationTokenSource? _cancellationTokenSource; + private Task? _currentSearchTask; + + private bool _loadedInitialHistory; + + private string _currentSubdir = string.Empty; + + public ShellListPage( + ISettingsInterface settingsManager, + IRunHistoryService runHistoryService, + ITelemetryService? telemetryService) + { + Icon = Icons.RunV2Icon; + Id = "com.microsoft.cmdpal.shell"; + Name = ResourceLoaderInstance.GetString("cmd_plugin_name"); + PlaceholderText = ResourceLoaderInstance.GetString("list_placeholder_text"); + _historyService = runHistoryService; + _telemetryService = telemetryService; + + EmptyContent = new CommandItem() + { + Title = ResourceLoaderInstance.GetString("cmd_plugin_name"), + Icon = Icons.RunV2Icon, + Subtitle = ResourceLoaderInstance.GetString("list_placeholder_text"), + }; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + if (newSearch == oldSearch) + { + return; + } + + DoUpdateSearchText(newSearch); + } + + private void DoUpdateSearchText(string newSearch) + { + // Cancel any ongoing search + _cancellationTokenSource?.Cancel(); + + _cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = _cancellationTokenSource.Token; + + try + { + // Save the latest search task + _currentSearchTask = BuildListItemsForSearchAsync(newSearch, cancellationToken); + } + catch (OperationCanceledException) + { + // DO NOTHING HERE + return; + } + catch (Exception) + { + // Handle other exceptions + return; + } + + // Await the task to ensure only the latest one gets processed + _ = ProcessSearchResultsAsync(_currentSearchTask, newSearch); + } + + private async Task ProcessSearchResultsAsync(Task searchTask, string newSearch) + { + try + { + await searchTask; + + // Ensure this is still the latest task + if (_currentSearchTask == searchTask) + { + // The search results have already been updated in BuildListItemsForSearchAsync + IsLoading = false; + RaiseItemsChanged(); + } + } + catch (OperationCanceledException) + { + // Handle cancellation gracefully + } + catch (Exception) + { + // Handle other exceptions + IsLoading = false; + } + } + + private async Task BuildListItemsForSearchAsync(string newSearch, CancellationToken cancellationToken) + { + var timer = System.Diagnostics.Stopwatch.StartNew(); + + // Check for cancellation at the start + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // If the search text is the start of a path to a file (it might be a + // UNC path), then we want to list all the files that start with that text: + + // 1. Check if the search text is a valid path + // 2. If it is, then list all the files that start with that text + var searchText = newSearch.Trim(); + + var expanded = Environment.ExpandEnvironmentVariables(searchText); + + // Check for cancellation after environment expansion + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // TODO we can be smarter about only re-reading the filesystem if the + // new search is just the oldSearch+some chars + if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText)) + { + _pathItems.Clear(); + _exeItem = null; + _uriItem = null; + + _currentHistoryItems.Clear(); + _currentHistoryItems.AddRange(_historyItems.Values); + + return; + } + + // Reset the path resolution flag + var couldResolvePath = false; + + var exe = string.Empty; + var args = string.Empty; + + var exeExists = false; + var fullExePath = string.Empty; + var pathIsDir = false; + + try + { + // Create a timeout for file system operations (200ms) + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + var timeoutToken = combinedCts.Token; + + // Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation + var pathResolutionTask = Task.Run( + () => + { + ShellListPageHelpers.NormalizeCommandLineAndArgs(expanded, out exe, out args); + + // Don't check cancellation token here - let the Task timeout handle it + exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath); + pathIsDir = Directory.Exists(expanded); + couldResolvePath = true; + }, + CancellationToken.None); // Use None here since we're handling timeout differently + + // Wait for either completion or timeout + await pathResolutionTask.WaitAsync(timeoutToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Main cancellation token was cancelled, re-throw + throw; + } + catch (TimeoutException) + { + // Timeout occurred + couldResolvePath = false; + } + catch (OperationCanceledException) + { + // Timeout occurred (from WaitAsync) + couldResolvePath = false; + } + catch (Exception) + { + // Handle any other exceptions that might bubble up + couldResolvePath = false; + } + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + _pathItems.Clear(); + + // We want to show path items: + // * If there's no args, AND (the path doesn't exist OR the path is a dir) + if (string.IsNullOrEmpty(args) + && (!exeExists || pathIsDir) + && couldResolvePath) + { + IsLoading = true; + await CreatePathItemsAsync(expanded, searchText, cancellationToken); + } + + // Check for cancellation before creating exe items + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (couldResolvePath && exeExists) + { + CreateAndAddExeItems(exe, args, fullExePath); + } + else + { + _exeItem = null; + } + + // Only create the URI item if we didn't make a file or exe item for it. + if (!exeExists && !pathIsDir) + { + CreateUriItems(searchText); + } + else + { + _uriItem = null; + } + + var histItemsNotInSearch = + _historyItems + .Where(kv => !kv.Key.Equals(newSearch, StringComparison.OrdinalIgnoreCase)); + if (_exeItem is not null) + { + // If we have an exe item, we want to remove it from the history items + histItemsNotInSearch = histItemsNotInSearch + .Where(kv => !kv.Value.Title.Equals(_exeItem.Title, StringComparison.OrdinalIgnoreCase)); + } + + if (_uriItem is not null) + { + // If we have an uri item, we want to remove it from the history items + histItemsNotInSearch = histItemsNotInSearch + .Where(kv => !kv.Value.Title.Equals(_uriItem.Title, StringComparison.OrdinalIgnoreCase)); + } + + // Filter the history items based on the search text + var filterHistory = (string query, KeyValuePair<string, ListItem> pair) => + { + // Fuzzy search on the key (command string) + var score = FuzzyStringMatcher.ScoreFuzzy(query, pair.Key); + return score; + }; + + var filteredHistory = + ListHelpers.FilterList<KeyValuePair<string, ListItem>>( + histItemsNotInSearch, + searchText, + filterHistory) + .Select(p => p.Value); + + _currentHistoryItems.Clear(); + _currentHistoryItems.AddRange(filteredHistory); + + // Final cancellation check + if (cancellationToken.IsCancellationRequested) + { + return; + } + + timer.Stop(); + _telemetryService?.LogRunQuery(newSearch, GetItems().Length, (ulong)timer.ElapsedMilliseconds); + } + + private static ListItem PathToListItem(string path, string originalPath, string args = "", Action<string>? addToHistory = null, ITelemetryService? telemetryService = null) + { + var pathItem = new PathListItem(path, originalPath, addToHistory, telemetryService); + + if (pathItem.IsDirectory) + { + return pathItem; + } + + // Is this path an executable? If so, then make a RunExeItem + if (IsExecutable(path)) + { + var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory, telemetryService) + { + TextToSuggest = path, + }; + + exeItem.MoreCommands = [ + .. exeItem.MoreCommands, + .. pathItem.MoreCommands]; + return exeItem; + } + + return pathItem; + } + + public override IListItem[] GetItems() + { + if (!_loadedInitialHistory) + { + LoadInitialHistory(); + } + + List<ListItem> uriItems = _uriItem is not null ? [_uriItem] : []; + List<ListItem> exeItems = _exeItem is not null ? [_exeItem] : []; + + return + exeItems + .Concat(_currentHistoryItems) + .Concat(_pathItems) + .Concat(uriItems) + .ToArray(); + } + + internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory, ITelemetryService? telemetryService) + { + // PathToListItem will return a RunExeItem if it can find a executable. + // It will ALSO add the file search commands to the RunExeItem. + return PathToListItem(fullExePath, exe, args, addToHistory, telemetryService); + } + + private void CreateAndAddExeItems(string exe, string args, string fullExePath) + { + // If we already have an exe item, and the exe is the same, we can just update it + if (_exeItem is RunExeItem exeItem && exeItem.FullExePath.Equals(fullExePath, StringComparison.OrdinalIgnoreCase)) + { + exeItem.UpdateArgs(args); + } + else + { + _exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory, _telemetryService); + } + } + + private static bool IsExecutable(string path) + { + // Is this path an executable? + // check all the extensions in PATHEXT + var extensions = Environment.GetEnvironmentVariable("PATHEXT")?.Split(';') ?? Array.Empty<string>(); + var extension = Path.GetExtension(path); + return string.IsNullOrEmpty(extension) || extensions.Any(ext => string.Equals(extension, ext, StringComparison.OrdinalIgnoreCase)); + } + + private async Task CreatePathItemsAsync(string searchPath, string originalPath, CancellationToken cancellationToken) + { + var directoryPath = string.Empty; + var searchPattern = string.Empty; + + var startsWithQuote = searchPath.Length > 0 && searchPath[0] == '"'; + var endsWithQuote = searchPath.Last() == '"'; + var trimmed = (startsWithQuote && endsWithQuote) ? searchPath.Substring(1, searchPath.Length - 2) : searchPath; + var isDriveRoot = trimmed.Length == 2 && trimmed[1] == ':'; + + // we should also handle just drive roots, ala c:\ or d:\ + // we need to handle this case first, because "C:" does exist, but we need to append the "\" in that case + if (isDriveRoot) + { + directoryPath = trimmed + "\\"; + searchPattern = $"*"; + } + + // Easiest case: text is literally already a full directory + else if (Directory.Exists(trimmed) && trimmed.EndsWith('\\')) + { + directoryPath = trimmed; + searchPattern = $"*"; + } + + // Check if the search text is a valid path + else if (Path.IsPathRooted(trimmed) && Path.GetDirectoryName(trimmed) is string directoryName) + { + directoryPath = directoryName; + searchPattern = $"{Path.GetFileName(trimmed)}*"; + } + + // Check if the search text is a valid UNC path + else if (trimmed.StartsWith(@"\\", System.StringComparison.CurrentCultureIgnoreCase) && + trimmed.Contains(@"\\")) + { + directoryPath = trimmed; + searchPattern = $"*"; + } + + // Check for cancellation before directory operations + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var dirExists = Directory.Exists(directoryPath); + + // searchPath is fully expanded, and originalPath is not. We might get: + // * original: X%Y%Z\partial + // * search: X_foo_Z\partial + // and we want the result `X_foo_Z\partialOne` to use the suggestion `X%Y%Z\partialOne` + // + // To do this: + // * Get the directoryPath + // * trim that out of the beginning of searchPath -> searchPathTrailer + // * everything left from searchPath? remove searchPathTrailer from the end of originalPath + // that gets us the expanded original dir + + // Check if the directory exists + if (dirExists) + { + // Check for cancellation before file system enumeration + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // If the directory we're in changed, then first rebuild the cache + // of all the items in the directory, _then_ filter them below. + if (directoryPath != _currentSubdir) + { + // Get all the files in the directory. + // Run this on a background thread to avoid blocking + var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath), cancellationToken); + + // Check for cancellation after file enumeration + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length)); + var originalBeginning = originalPath.EndsWith(searchPathTrailer, StringComparison.CurrentCultureIgnoreCase) ? + originalPath.Remove(originalPath.Length - searchPathTrailer.Length) : + originalPath; + + if (isDriveRoot) + { + originalBeginning = string.Concat(originalBeginning, '\\'); + } + + // Create a list of commands for each file + var newPathItems = files + .Select(f => PathToListItem(f, originalBeginning)) + .ToDictionary(item => item.Title, item => item); + + // Final cancellation check before updating results + if (cancellationToken.IsCancellationRequested) + { + return; + } + + // Add the commands to the list + _pathItems.Clear(); + _currentSubdir = directoryPath; + _currentPathItems.Clear(); + foreach ((var k, IListItem v) in newPathItems) + { + _currentPathItems[k] = (ListItem)v; + } + } + + // Filter the items from this directory + var fuzzyString = searchPattern.TrimEnd('*'); + var newMatchedPathItems = new List<ListItem>(); + + foreach (var kv in _currentPathItems) + { + var score = string.IsNullOrEmpty(fuzzyString) ? + 1 : + FuzzyStringMatcher.ScoreFuzzy(fuzzyString, kv.Key); + if (score > 0) + { + newMatchedPathItems.Add(kv.Value); + } + } + + ListHelpers.InPlaceUpdateList(_pathItems, newMatchedPathItems); + } + else + { + _pathItems.Clear(); + } + } + + internal void CreateUriItems(string searchText) + { + if (!System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri)) + { + _uriItem = null; + return; + } + + var command = new OpenUrlCommand(searchText) { Result = CommandResult.Dismiss() }; + _uriItem = new ListItem(command) + { + Title = searchText, + }; + } + + private void LoadInitialHistory() + { + var hist = _historyService.GetRunHistory(); + var histItems = hist + .Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory, _telemetryService))) + .Where(tuple => tuple.Item2 is not null) + .Select(tuple => (tuple.h, tuple.Item2!)) + .ToList(); + _historyItems.Clear(); + + // Add all the history items to the _historyItems dictionary + foreach (var (h, item) in histItems) + { + _historyItems[h] = item; + } + + _currentHistoryItems.Clear(); + _currentHistoryItems.AddRange(histItems.Select(tuple => tuple.Item2)); + + _loadedInitialHistory = true; + } + + internal void AddToHistory(string commandString) + { + if (string.IsNullOrWhiteSpace(commandString)) + { + return; // Do not add empty or whitespace items + } + + _historyService.AddRunHistoryItem(commandString); + LoadInitialHistory(); + DoUpdateSearchText(SearchText); + } + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..e308b0e6cc --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.Shell.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.cs new file mode 100644 index 0000000000..75e5d64dc3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/ResourceLoaderInstance.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. + +namespace Microsoft.CmdPal.Ext.Shell; + +internal static class ResourceLoaderInstance +{ + public static string GetString(string resourceKey) + { + return Properties.Resources.ResourceManager.GetString(resourceKey, Properties.Resources.Culture) ?? throw new InvalidOperationException($"Resource key '{resourceKey}' not found."); + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs similarity index 96% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs index a43f2350fc..e5949f8a02 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.Designer.cs @@ -97,7 +97,7 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties { } /// <summary> - /// Looks up a localized string similar to Executes commands (e.g. 'ping', 'cmd'). + /// Looks up a localized string similar to Execute system commands like 'ping' and 'cmd'. /// </summary> public static string cmd_plugin_description { get { @@ -132,6 +132,15 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties { } } + /// <summary> + /// Looks up a localized string similar to Copy path. + /// </summary> + public static string copy_path_command_name { + get { + return ResourceManager.GetString("copy_path_command_name", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Find and run the executable file. /// </summary> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx similarity index 98% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx index 3c31b6d167..991869b8da 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Properties/Resources.resx @@ -121,7 +121,7 @@ <value>Run commands</value> </data> <data name="cmd_plugin_description" xml:space="preserve"> - <value>Executes commands (e.g. 'ping', 'cmd')</value> + <value>Execute system commands like 'ping' and 'cmd'</value> </data> <data name="cmd_has_been_executed_times" xml:space="preserve"> <value>this command has been executed {0} times</value> @@ -190,4 +190,7 @@ <data name="shell_command_display_title" xml:space="preserve"> <value>Run commands</value> </data> + <data name="copy_path_command_name" xml:space="preserve"> + <value>Copy path</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/ISettingsInterface.cs new file mode 100644 index 0000000000..4a03d55d3d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/ISettingsInterface.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.Collections.Generic; + +namespace Microsoft.CmdPal.Ext.Shell.Helpers; + +public interface ISettingsInterface +{ + public bool LeaveShellOpen { get; } + + public string ShellCommandExecution { get; } + + public bool RunAsAdministrator { get; } + + public Dictionary<string, int> Count { get; } + + public void AddCmdHistory(string cmdName); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/SettingsManager.cs similarity index 97% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/SettingsManager.cs index a39e723338..9d58bc939d 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Settings/SettingsManager.cs @@ -9,7 +9,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.Shell.Helpers; -public class SettingsManager : JsonSettingsManager +public class SettingsManager : JsonSettingsManager, ISettingsInterface { private static readonly string _namespace = "shell"; diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs similarity index 58% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs index 9e98e036d4..20c81d7832 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs @@ -2,6 +2,7 @@ // 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; using Microsoft.CmdPal.Ext.Shell.Helpers; using Microsoft.CmdPal.Ext.Shell.Pages; using Microsoft.CmdPal.Ext.Shell.Properties; @@ -13,23 +14,31 @@ namespace Microsoft.CmdPal.Ext.Shell; public partial class ShellCommandsProvider : CommandProvider { private readonly CommandItem _shellPageItem; - private readonly SettingsManager _settingsManager = new(); - private readonly FallbackCommandItem _fallbackItem; - public ShellCommandsProvider() + private readonly SettingsManager _settingsManager = new(); + private readonly ShellListPage _shellListPage; + private readonly FallbackCommandItem _fallbackItem; + private readonly IRunHistoryService _historyService; + private readonly ITelemetryService _telemetryService; + + public ShellCommandsProvider(IRunHistoryService runHistoryService, ITelemetryService telemetryService) { - Id = "Run"; + _historyService = runHistoryService; + _telemetryService = telemetryService; + + Id = "com.microsoft.cmdpal.builtin.run"; DisplayName = Resources.cmd_plugin_name; - Icon = Icons.RunV2; + Icon = Icons.RunV2Icon; Settings = _settingsManager.Settings; - _fallbackItem = new FallbackExecuteItem(_settingsManager); + _shellListPage = new ShellListPage(_settingsManager, _historyService, _telemetryService); - _shellPageItem = new CommandItem(new ShellListPage(_settingsManager)) + _fallbackItem = new FallbackExecuteItem(_settingsManager, _shellListPage.AddToHistory, _telemetryService); + + _shellPageItem = new CommandItem(_shellListPage) { - Icon = Icons.RunV2, + Icon = Icons.RunV2Icon, Title = Resources.shell_command_name, - Subtitle = Resources.cmd_plugin_description, MoreCommands = [ new CommandContextItem(Settings.SettingsPage), ], @@ -39,4 +48,6 @@ public partial class ShellCommandsProvider : CommandProvider public override ICommandItem[] TopLevelCommands() => [_shellPageItem]; public override IFallbackCommandItem[]? FallbackCommands() => [_fallbackItem]; + + public static bool SuppressFileFallbackIf(string query) => FallbackExecuteItem.SuppressFileFallbackIf(query); } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Assets/SystemCommand.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Assets/SystemCommand.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Assets/SystemCommand.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Assets/SystemCommand.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Assets/SystemCommand.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Assets/SystemCommand.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Assets/SystemCommand.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Assets/SystemCommand.svg diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/EmptyRecycleBinCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/EmptyRecycleBinCommand.cs similarity index 95% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/EmptyRecycleBinCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/EmptyRecycleBinCommand.cs index 932e421e42..a62dd720b9 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/EmptyRecycleBinCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/EmptyRecycleBinCommand.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using Microsoft.CmdPal.Ext.System.Helpers; using Microsoft.CommandPalette.Extensions.Toolkit; -namespace Microsoft.CmdPal.Ext.Shell; +namespace Microsoft.CmdPal.Ext.System; public sealed partial class EmptyRecycleBinCommand : InvokableCommand { diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/EmptyRecycleBinConfirmation.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/EmptyRecycleBinConfirmation.cs similarity index 97% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/EmptyRecycleBinConfirmation.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/EmptyRecycleBinConfirmation.cs index c15247d8cb..558cd6b893 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/EmptyRecycleBinConfirmation.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/EmptyRecycleBinConfirmation.cs @@ -2,7 +2,6 @@ // 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.Ext.Shell; using Microsoft.CmdPal.Ext.System.Helpers; using Microsoft.CommandPalette.Extensions.Toolkit; diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/ExecuteCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommand.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/ExecuteCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommand.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/ExecuteCommandConfirmation.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommandConfirmation.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/ExecuteCommandConfirmation.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/ExecuteCommandConfirmation.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs new file mode 100644 index 0000000000..8624953891 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.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 System.Collections.Generic; +using Microsoft.CmdPal.Ext.System.Helpers; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.System; + +internal sealed partial class FallbackSystemCommandItem : FallbackCommandItem +{ + private const string _id = "com.microsoft.cmdpal.builtin.system.fallback"; + + public FallbackSystemCommandItem(ISettingsInterface settings) + : base(new NoOpCommand(), Resources.Microsoft_plugin_ext_fallback_display_title, _id) + { + Title = string.Empty; + Subtitle = string.Empty; + Icon = Icons.LockIcon; + + var isBootedInUefiMode = settings.GetSystemFirmwareType() == FirmwareType.Uefi; + var hideEmptyRB = settings.HideEmptyRecycleBin(); + var confirmSystemCommands = settings.ShowDialogToConfirmCommand(); + var showSuccessOnEmptyRB = settings.ShowSuccessMessageAfterEmptyingRecycleBin(); + + systemCommands = Commands.GetSystemCommands(isBootedInUefiMode, hideEmptyRB, confirmSystemCommands, showSuccessOnEmptyRB); + } + + private readonly List<IListItem> systemCommands; + + public override void UpdateQuery(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + Command = null; + Title = string.Empty; + Subtitle = string.Empty; + return; + } + + IListItem? result = null; + var resultScore = 0; + + // find the max score for the query + foreach (var command in systemCommands) + { + var title = command.Title; + var subTitle = command.Subtitle; + var titleScore = FuzzyStringMatcher.ScoreFuzzy(query, title); + var subTitleScore = FuzzyStringMatcher.ScoreFuzzy(query, subTitle); + + var maxScore = Math.Max(titleScore, subTitleScore); + if (maxScore > resultScore) + { + resultScore = maxScore; + result = command; + } + } + + if (result is null) + { + Command = null; + Title = string.Empty; + Subtitle = string.Empty; + + return; + } + + Title = result.Title; + Subtitle = result.Subtitle; + Icon = result.Icon; + Command = result.Command; + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs similarity index 89% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs index 1459b83bc6..9448cc0f2f 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Commands.cs @@ -42,13 +42,13 @@ internal static class Commands var results = new List<IListItem>(); results.AddRange(new[] { - new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_shutdown, confirmCommands, Resources.Microsoft_plugin_sys_shutdown_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/s /hybrid /t 0"))) + new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_shutdown, confirmCommands, Resources.Microsoft_plugin_sys_shutdown_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/s /hybrid /t 0", runWithHiddenWindow: true))) { Title = Resources.Microsoft_plugin_sys_shutdown_computer, Subtitle = Resources.Microsoft_plugin_sys_shutdown_computer_description, Icon = Icons.ShutdownIcon, }, - new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_restart, confirmCommands, Resources.Microsoft_plugin_sys_restart_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/g /t 0"))) + new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_command_name_restart, confirmCommands, Resources.Microsoft_plugin_sys_restart_computer_confirmation, () => OpenInShellHelper.OpenInShell("shutdown", "/g /t 0", runWithHiddenWindow: true))) { Title = Resources.Microsoft_plugin_sys_restart_computer, Subtitle = Resources.Microsoft_plugin_sys_restart_computer_description, @@ -85,7 +85,7 @@ internal static class Commands { results.AddRange(new[] { - new ListItem(new OpenInShellCommand(Resources.Microsoft_plugin_command_name_empty, "explorer.exe", "shell:RecycleBinFolder")) + new ListItem(new OpenInShellCommand(Resources.Microsoft_plugin_command_name_open, "explorer.exe", "shell:RecycleBinFolder")) { Title = Resources.Microsoft_plugin_sys_RecycleBinOpen, Subtitle = Resources.Microsoft_plugin_sys_RecycleBin_description, @@ -102,7 +102,7 @@ internal static class Commands else { results.Add( - new ListItem(new OpenInShellCommand(Resources.Microsoft_plugin_command_name_empty, "explorer.exe", "shell:RecycleBinFolder")) + new ListItem(new OpenInShellCommand(Resources.Microsoft_plugin_command_name_open, "explorer.exe", "shell:RecycleBinFolder")) { Title = Resources.Microsoft_plugin_sys_RecycleBin, Subtitle = Resources.Microsoft_plugin_sys_RecycleBin_description, @@ -110,6 +110,13 @@ internal static class Commands }); } + results.Add(new ListItem(new ExecuteCommandConfirmation(Resources.Microsoft_plugin_sys_RestartShell_name!, confirmCommands, Resources.Microsoft_plugin_sys_RestartShell_confirmation!, static () => OpenInShellHelper.OpenInShell("cmd", "/C tskill explorer && start explorer", runWithHiddenWindow: true))) + { + Title = Resources.Microsoft_plugin_sys_RestartShell!, + Subtitle = Resources.Microsoft_plugin_sys_RestartShell_description!, + Icon = Icons.RestartShellIcon, + }); + // UEFI command/result. It is only available on systems booted in UEFI mode. if (isUefi) { @@ -129,7 +136,7 @@ internal static class Commands /// </summary> /// <param name="manager">The tSettingsManager instance</param> /// <returns>The list of available results</returns> - public static List<IListItem> GetNetworkConnectionResults(SettingsManager manager) + public static List<IListItem> GetNetworkConnectionResults(ISettingsInterface manager) { var results = new List<IListItem>(); @@ -144,7 +151,7 @@ internal static class Commands CompositeFormat sysIpv4DescriptionCompositeFormate = CompositeFormat.Parse(Resources.Microsoft_plugin_sys_ip4_description); CompositeFormat sysIpv6DescriptionCompositeFormate = CompositeFormat.Parse(Resources.Microsoft_plugin_sys_ip6_description); CompositeFormat sysMacDescriptionCompositeFormate = CompositeFormat.Parse(Resources.Microsoft_plugin_sys_mac_description); - var hideDisconnectedNetworkInfo = manager.HideDisconnectedNetworkInfo; + var hideDisconnectedNetworkInfo = manager.HideDisconnectedNetworkInfo(); foreach (NetworkConnectionProperties intInfo in networkPropertiesCache) { @@ -193,7 +200,7 @@ internal static class Commands return results; } - public static List<IListItem> GetAllCommands(SettingsManager manager) + public static List<IListItem> GetAllCommands(ISettingsInterface manager) { var list = new List<IListItem>(); var listLock = new object(); @@ -202,11 +209,11 @@ internal static class Commands // On global queries the first word/part has to be 'ip', 'mac' or 'address' for network results var networkConnectionResults = Commands.GetNetworkConnectionResults(manager); - var isBootedInUefiMode = Win32Helpers.GetSystemFirmwareType() == FirmwareType.Uefi; + var isBootedInUefiMode = manager.GetSystemFirmwareType() == FirmwareType.Uefi; - var hideEmptyRB = manager.HideEmptyRecycleBin; - var confirmSystemCommands = manager.ShowDialogToConfirmCommand; - var showSuccessOnEmptyRB = manager.ShowSuccessMessageAfterEmptyingRecycleBin; + var hideEmptyRB = manager.HideEmptyRecycleBin(); + var confirmSystemCommands = manager.ShowDialogToConfirmCommand(); + var showSuccessOnEmptyRB = manager.ShowSuccessMessageAfterEmptyingRecycleBin(); // normal system commands are fast and can be returned immediately var systemCommands = Commands.GetSystemCommands(isBootedInUefiMode, hideEmptyRB, confirmSystemCommands, showSuccessOnEmptyRB); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..1690007126 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/ISettingsInterface.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 System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.Ext.System.Helpers; + +public interface ISettingsInterface +{ + public bool ShowDialogToConfirmCommand(); + + public bool ShowSuccessMessageAfterEmptyingRecycleBin(); + + public bool HideEmptyRecycleBin(); + + public bool HideDisconnectedNetworkInfo(); + + public FirmwareType GetSystemFirmwareType(); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/MessageBoxHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/MessageBoxHelper.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/MessageBoxHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/MessageBoxHelper.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NativeMethods.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/NativeMethods.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NativeMethods.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs similarity index 92% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs index 9afb39b6f1..f14011440d 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs @@ -27,6 +27,14 @@ internal sealed class NetworkConnectionProperties /// <seealso cref="https://github.com/xoofx/markdig/blob/master/src/Markdig/Extensions/Emoji/EmojiMapping.cs"/> private const int GreenCircleCharacter = 128994; + /// <summary> + /// Decimal unicode value for green circle emoji. + /// We need to generate it in the code because it does not render using Markdown emoji syntax or Unicode character syntax. + /// </summary> + /// <seealso cref="https://github.com/CommunityToolkit/Labs-Windows/blob/main/components/MarkdownTextBlock/samples/MarkdownTextBlock.md"/> + /// <seealso cref="https://github.com/xoofx/markdig/blob/master/src/Markdig/Extensions/Emoji/EmojiMapping.cs"/> + private const int RedCircleCharacter = 128308; + /// <summary> /// Gets the name of the adapter /// </summary> @@ -155,7 +163,7 @@ internal sealed class NetworkConnectionProperties internal static List<NetworkConnectionProperties> GetList() { var interfaces = NetworkInterface.GetAllNetworkInterfaces() - .Where(x => x.NetworkInterfaceType != NetworkInterfaceType.Loopback && x.GetPhysicalAddress() != null) + .Where(x => x.NetworkInterfaceType != NetworkInterfaceType.Loopback && x.GetPhysicalAddress() is not null) .Select(i => new NetworkConnectionProperties(i)) .OrderByDescending(i => i.IPv4) // list IPv4 first .ThenBy(i => i.IPv6Primary) // then IPv6 @@ -170,7 +178,7 @@ internal sealed class NetworkConnectionProperties internal string GetAdapterDetails() { return $"**{Resources.Microsoft_plugin_sys_AdapterName}:** {Adapter}" + - $"\n\n**{Resources.Microsoft_plugin_sys_State}:** " + (State == OperationalStatus.Up ? char.ConvertFromUtf32(GreenCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Connected : ":red_circle: " + Resources.Microsoft_plugin_sys_Disconnected) + + $"\n\n**{Resources.Microsoft_plugin_sys_State}:** " + (State == OperationalStatus.Up ? char.ConvertFromUtf32(GreenCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Connected : char.ConvertFromUtf32(RedCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Disconnected) + $"\n\n**{Resources.Microsoft_plugin_sys_PhysicalAddress}:** {PhysicalAddress}" + $"\n\n**{Resources.Microsoft_plugin_sys_Speed}:** {GetFormattedSpeedValue(Speed)}" + $"\n\n**{Resources.Microsoft_plugin_sys_Type}:** {GetAdapterTypeAsString(Type)}" + @@ -184,7 +192,7 @@ internal sealed class NetworkConnectionProperties internal string GetConnectionDetails() { return $"**{Resources.Microsoft_plugin_sys_ConnectionName}:** {ConnectionName}" + - $"\n\n**{Resources.Microsoft_plugin_sys_State}:** " + (State == OperationalStatus.Up ? char.ConvertFromUtf32(GreenCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Connected : ":red_circle: " + Resources.Microsoft_plugin_sys_Disconnected) + + $"\n\n**{Resources.Microsoft_plugin_sys_State}:** " + (State == OperationalStatus.Up ? char.ConvertFromUtf32(GreenCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Connected : char.ConvertFromUtf32(RedCircleCharacter) + " " + Resources.Microsoft_plugin_sys_Disconnected) + $"\n\n**{Resources.Microsoft_plugin_sys_Type}:** {GetAdapterTypeAsString(Type)}" + $"\n\n**{Resources.Microsoft_plugin_sys_Suffix}:** {Suffix}" + CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Ip4Address}:** ", IPv4) + @@ -195,9 +203,9 @@ internal sealed class NetworkConnectionProperties CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Ip6Site}:**\n\n* ", IPv6SiteLocal) + CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Ip6Unique}:**\n\n* ", IPv6UniqueLocal) + CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Gateways}:**\n\n* ", Gateways) + - CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Dhcp}:**\n\n* ", DhcpServers == null ? string.Empty : DhcpServers) + - CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Dns}:**\n\n* ", DnsServers == null ? string.Empty : DnsServers) + - CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Wins}:**\n\n* ", WinsServers == null ? string.Empty : WinsServers) + + CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Dhcp}:**\n\n* ", DhcpServers is null ? string.Empty : DhcpServers) + + CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Dns}:**\n\n* ", DnsServers is null ? string.Empty : DnsServers) + + CreateIpInfoForDetailsText($"**{Resources.Microsoft_plugin_sys_Wins}:**\n\n* ", WinsServers is null ? string.Empty : WinsServers) + $"\n\n**{Resources.Microsoft_plugin_sys_AdapterName}:** {Adapter}" + $"\n\n**{Resources.Microsoft_plugin_sys_PhysicalAddress}:** {PhysicalAddress}" + $"\n\n**{Resources.Microsoft_plugin_sys_Speed}:** {GetFormattedSpeedValue(Speed)}"; @@ -311,14 +319,14 @@ internal sealed class NetworkConnectionProperties { switch (property) { - case string: - return string.IsNullOrWhiteSpace(property) ? string.Empty : $"\n\n{title}{property}"; + case string str: + return string.IsNullOrWhiteSpace(str) ? string.Empty : $"\n\n{title}{str}"; case List<string> listString: - return listString.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", property)}"; + return listString.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", listString)}"; case List<IPAddress> listIP: - return listIP.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", property)}"; + return listIP.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", listIP)}"; case IPAddressCollection collectionIP: - return collectionIP.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", property)}"; + return collectionIP.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", collectionIP)}"; case null: return string.Empty; default: diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/OpenInShellHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/OpenInShellHelper.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/OpenInShellHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/OpenInShellHelper.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/ResultHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/ResultHelper.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/ResultHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/ResultHelper.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs similarity index 82% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs index 9d51c42615..952b68d7aa 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/SettingsManager.cs @@ -7,7 +7,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.System.Helpers; -public class SettingsManager : JsonSettingsManager +public class SettingsManager : JsonSettingsManager, ISettingsInterface { private static readonly string _namespace = "system"; @@ -37,14 +37,6 @@ public class SettingsManager : JsonSettingsManager Resources.Microsoft_plugin_ext_settings_hideDisconnectedNetworkInfo, false); - public bool ShowDialogToConfirmCommand => _showDialogToConfirmCommand.Value; - - public bool ShowSuccessMessageAfterEmptyingRecycleBin => _showSuccessMessageAfterEmptyingRecycleBin.Value; - - public bool HideEmptyRecycleBin => _hideEmptyRecycleBin.Value; - - public bool HideDisconnectedNetworkInfo => _hideDisconnectedNetworkInfo.Value; - internal static string SettingsJsonPath() { var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); @@ -54,6 +46,16 @@ public class SettingsManager : JsonSettingsManager return Path.Combine(directory, "settings.json"); } + public bool ShowDialogToConfirmCommand() => _showDialogToConfirmCommand.Value; + + public bool ShowSuccessMessageAfterEmptyingRecycleBin() => _showSuccessMessageAfterEmptyingRecycleBin.Value; + + public bool HideEmptyRecycleBin() => _hideEmptyRecycleBin.Value; + + public bool HideDisconnectedNetworkInfo() => _hideDisconnectedNetworkInfo.Value; + + public FirmwareType GetSystemFirmwareType() => Win32Helpers.GetSystemFirmwareType(); + public SettingsManager() { FilePath = SettingsJsonPath(); diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/SystemPluginContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/SystemPluginContext.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/SystemPluginContext.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/SystemPluginContext.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Win32Helpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Win32Helpers.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Helpers/Win32Helpers.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/Win32Helpers.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Icons.cs new file mode 100644 index 0000000000..9993220dee --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Icons.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.System; + +internal sealed class Icons +{ + internal static IconInfo FirmwareSettingsIcon { get; } = new IconInfo("\uE950"); + + internal static IconInfo LockIcon { get; } = new IconInfo("\uE72E"); + + internal static IconInfo LogoffIcon { get; } = new IconInfo("\uF3B1"); + + internal static IconInfo NetworkAdapterIcon { get; } = new IconInfo("\uEDA3"); + + internal static IconInfo RecycleBinIcon { get; } = new IconInfo("\uE74D"); + + internal static IconInfo RestartIcon { get; } = new IconInfo("\uE777"); + + internal static IconInfo RestartShellIcon { get; } = new IconInfo("\uEC50"); + + internal static IconInfo ShutdownIcon { get; } = new IconInfo("\uE7E8"); + + internal static IconInfo SleepIcon { get; } = new IconInfo("\uE708"); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj similarity index 78% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj index 4c619bc5e5..699af993c3 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj @@ -1,5 +1,8 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> + <PropertyGroup> <Nullable>enable</Nullable> <RootNamespace>Microsoft.CmdPal.Ext.System</RootNamespace> @@ -7,9 +10,9 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> </PropertyGroup> - <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> - </ItemGroup> + + <!-- WASDK, WebView2, CmdPal Toolkit references now included via Common.ExtDependencies.props --> + <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> <DependentUpon>Resources.resx</DependentUpon> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/OpenInShellCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/OpenInShellCommand.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/OpenInShellCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/OpenInShellCommand.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Pages/SystemCommandPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Pages/SystemCommandPage.cs similarity index 73% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Pages/SystemCommandPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Pages/SystemCommandPage.cs index 7bfd7bb4e4..bb7deb1c9e 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Pages/SystemCommandPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Pages/SystemCommandPage.cs @@ -10,12 +10,12 @@ namespace Microsoft.CmdPal.Ext.System.Pages; public sealed partial class SystemCommandPage : ListPage { - private SettingsManager _settingsManager; + private readonly ISettingsInterface _settingsManager; - public SystemCommandPage(SettingsManager settingsManager) + public SystemCommandPage(ISettingsInterface settingsManager) { - Title = Resources.Microsoft_plugin_ext_system_page_name; - Name = Resources.Microsoft_plugin_ext_system_page_name; + Title = Resources.Microsoft_plugin_ext_system_page_title; + Name = Resources.Microsoft_plugin_command_name_open; Icon = IconHelpers.FromRelativePath("Assets\\SystemCommand.svg"); _settingsManager = settingsManager; ShowDetails = true; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..1de8abc6de --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.System.UnitTests")] diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs similarity index 92% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs index 6aa26a7b0d..0b1e1fe121 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.Designer.cs @@ -160,7 +160,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// <summary> - /// Looks up a localized string similar to Adapter Details. + /// Looks up a localized string similar to Adapter details. /// </summary> public static string Microsoft_plugin_ext_adapter_details { get { @@ -169,7 +169,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// <summary> - /// Looks up a localized string similar to Connection Details. + /// Looks up a localized string similar to Connection details. /// </summary> public static string Microsoft_plugin_ext_connection_details { get { @@ -186,6 +186,15 @@ namespace Microsoft.CmdPal.Ext.System { } } + /// <summary> + /// Looks up a localized string similar to Open system command. + /// </summary> + public static string Microsoft_plugin_ext_fallback_display_title { + get { + return ResourceManager.GetString("Microsoft_plugin_ext_fallback_display_title", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Hide disconnected network info. /// </summary> @@ -196,7 +205,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// <summary> - /// Looks up a localized string similar to Windows System Command. + /// Looks up a localized string similar to System commands. /// </summary> public static string Microsoft_plugin_ext_system_page_name { get { @@ -204,6 +213,15 @@ namespace Microsoft.CmdPal.Ext.System { } } + /// <summary> + /// Looks up a localized string similar to Windows system commands. + /// </summary> + public static string Microsoft_plugin_ext_system_page_title { + get { + return ResourceManager.GetString("Microsoft_plugin_ext_system_page_title", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Adapter name. /// </summary> @@ -610,7 +628,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// <summary> - /// Looks up a localized string similar to You are about to restart this computer, are you sure?. + /// Looks up a localized string similar to You are about to restart this computer. Are you sure?. /// </summary> public static string Microsoft_plugin_sys_restart_computer_confirmation { get { @@ -627,6 +645,42 @@ namespace Microsoft.CmdPal.Ext.System { } } + /// <summary> + /// Looks up a localized string similar to Restart Windows Explorer. + /// </summary> + public static string Microsoft_plugin_sys_RestartShell { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_RestartShell", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to You are about to restart Windows Explorer, are you sure?. + /// </summary> + public static string Microsoft_plugin_sys_RestartShell_confirmation { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_RestartShell_confirmation", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to End and restart the Windows Explorer shell process. + /// </summary> + public static string Microsoft_plugin_sys_RestartShell_description { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_RestartShell_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Restart. + /// </summary> + public static string Microsoft_plugin_sys_RestartShell_name { + get { + return ResourceManager.GetString("Microsoft_plugin_sys_RestartShell_name", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to ip; mac; address. /// </summary> @@ -736,7 +790,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// <summary> - /// Looks up a localized string similar to DNS Suffix. + /// Looks up a localized string similar to DNS suffix. /// </summary> public static string Microsoft_plugin_sys_Suffix { get { @@ -763,7 +817,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// <summary> - /// Looks up a localized string similar to UEFI Firmware Settings. + /// Looks up a localized string similar to UEFI firmware settings. /// </summary> public static string Microsoft_plugin_sys_uefi { get { @@ -772,7 +826,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// <summary> - /// Looks up a localized string similar to You are about to reboot this computer into UEFI Firmware Settings menu, are you sure?. + /// Looks up a localized string similar to You are about to reboot this computer into UEFI firmware settings menu, are you sure?. /// </summary> public static string Microsoft_plugin_sys_uefi_confirmation { get { @@ -781,7 +835,7 @@ namespace Microsoft.CmdPal.Ext.System { } /// <summary> - /// Looks up a localized string similar to Reboot computer into UEFI Firmware Settings (Requires administrative permissions.). + /// Looks up a localized string similar to Reboot computer into UEFI firmware Settings (requires administrative permissions.). /// </summary> public static string Microsoft_plugin_sys_uefi_description { get { diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx similarity index 94% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx index e71e8a8d01..bc8ea5ec38 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Properties/Resources.resx @@ -149,10 +149,10 @@ <value>Shutdown</value> </data> <data name="Microsoft_plugin_ext_connection_details" xml:space="preserve"> - <value>Connection Details</value> + <value>Connection details</value> </data> <data name="Microsoft_plugin_ext_adapter_details" xml:space="preserve"> - <value>Adapter Details</value> + <value>Adapter details</value> </data> <data name="Microsoft_plugin_ext_copy" xml:space="preserve"> <value>Copy to clipboard</value> @@ -160,8 +160,11 @@ <data name="Microsoft_plugin_ext_settings_hideDisconnectedNetworkInfo" xml:space="preserve"> <value>Hide disconnected network info</value> </data> + <data name="Microsoft_plugin_ext_system_page_title" xml:space="preserve"> + <value>Windows system commands</value> + </data> <data name="Microsoft_plugin_ext_system_page_name" xml:space="preserve"> - <value>Windows System Command</value> + <value>System commands</value> </data> <data name="Microsoft_plugin_sys_AdapterName" xml:space="preserve"> <value>Adapter name</value> @@ -324,7 +327,7 @@ <comment>This should align to the action in Windows of a restarting your computer.</comment> </data> <data name="Microsoft_plugin_sys_restart_computer_confirmation" xml:space="preserve"> - <value>You are about to restart this computer, are you sure?</value> + <value>You are about to restart this computer. Are you sure?</value> <comment>This should align to the action in Windows of a restarting your computer.</comment> </data> <data name="Microsoft_plugin_sys_restart_computer_description" xml:space="preserve"> @@ -378,7 +381,7 @@ <value>State</value> </data> <data name="Microsoft_plugin_sys_Suffix" xml:space="preserve"> - <value>DNS Suffix</value> + <value>DNS suffix</value> </data> <data name="Microsoft_plugin_sys_TunnelConnection" xml:space="preserve"> <value>Tunnel</value> @@ -388,15 +391,15 @@ <comment>Means type like category. Here it means network interface type (ethernet, wifi, ...).</comment> </data> <data name="Microsoft_plugin_sys_uefi" xml:space="preserve"> - <value>UEFI Firmware Settings</value> + <value>UEFI firmware settings</value> <comment>This should align to the action in Windows Recovery Environment that restart into uefi settings.</comment> </data> <data name="Microsoft_plugin_sys_uefi_confirmation" xml:space="preserve"> - <value>You are about to reboot this computer into UEFI Firmware Settings menu, are you sure?</value> + <value>You are about to reboot this computer into UEFI firmware settings menu, are you sure?</value> <comment>This should align to the action in Windows Recovery Environment that restart into uefi settings.</comment> </data> <data name="Microsoft_plugin_sys_uefi_description" xml:space="preserve"> - <value>Reboot computer into UEFI Firmware Settings (Requires administrative permissions.)</value> + <value>Reboot computer into UEFI firmware Settings (requires administrative permissions.)</value> <comment>This should align to the action in Windows Recovery Environment that restart into uefi settings.</comment> </data> <data name="Microsoft_plugin_sys_Unknown" xml:space="preserve"> @@ -411,4 +414,19 @@ <data name="Microsoft_plugin_command_name_sleep" xml:space="preserve"> <value>Sleep</value> </data> + <data name="Microsoft_plugin_ext_fallback_display_title" xml:space="preserve"> + <value>Execute system commands</value> + </data> + <data name="Microsoft_plugin_sys_RestartShell" xml:space="preserve"> + <value>Restart Windows Explorer</value> + </data> + <data name="Microsoft_plugin_sys_RestartShell_description" xml:space="preserve"> + <value>End and restart the Windows Explorer shell process</value> + </data> + <data name="Microsoft_plugin_sys_RestartShell_name" xml:space="preserve"> + <value>Restart</value> + </data> + <data name="Microsoft_plugin_sys_RestartShell_confirmation" xml:space="preserve"> + <value>You are about to restart Windows Explorer, are you sure?</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs similarity index 78% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs index be89cf75fd..4bc86c209d 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/SystemCommandExtensionProvider.cs @@ -9,20 +9,21 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.System; -public partial class SystemCommandExtensionProvider : CommandProvider +public sealed partial class SystemCommandExtensionProvider : CommandProvider { private readonly ICommandItem[] _commands; private static readonly SettingsManager _settingsManager = new(); public static readonly SystemCommandPage Page = new(_settingsManager); + private readonly FallbackSystemCommandItem _fallbackSystemItem = new(_settingsManager); public SystemCommandExtensionProvider() { DisplayName = Resources.Microsoft_plugin_ext_system_page_name; - Id = "System"; + Id = "com.microsoft.cmdpal.builtin.system"; _commands = [ new CommandItem(Page) { - Title = Resources.Microsoft_plugin_ext_system_page_name, + Title = Resources.Microsoft_plugin_ext_system_page_title, Icon = Page.Icon, MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)], }, @@ -36,4 +37,6 @@ public partial class SystemCommandExtensionProvider : CommandProvider { return _commands; } + + public override IFallbackCommandItem[] FallbackCommands() => [_fallbackSystemItem]; } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Assets/TimeDate.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Assets/TimeDate.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Assets/TimeDate.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Assets/TimeDate.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Assets/TimeDate.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Assets/TimeDate.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Assets/TimeDate.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Assets/TimeDate.svg diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Assets/Warning.dark.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Assets/Warning.dark.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Assets/Warning.dark.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Assets/Warning.dark.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Assets/Warning.light.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Assets/Warning.light.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Assets/Warning.light.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Assets/Warning.light.png diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/FallbackTimeDateItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/FallbackTimeDateItem.cs new file mode 100644 index 0000000000..899163e621 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/FallbackTimeDateItem.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CmdPal.Ext.TimeDate.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.TimeDate; + +internal sealed partial class FallbackTimeDateItem : FallbackCommandItem +{ + private const string _id = "com.microsoft.cmdpal.builtin.timedate.fallback"; + private readonly HashSet<string> _validOptions; + private ISettingsInterface _settingsManager; + private DateTime? _timestamp; + + public FallbackTimeDateItem(ISettingsInterface settings, DateTime? timestamp = null) + : base(new NoOpCommand(), Resources.Microsoft_plugin_timedate_fallback_display_title, _id) + { + Title = string.Empty; + Subtitle = string.Empty; + Icon = Icons.TimeDateIcon; + _settingsManager = settings; + _timestamp = timestamp; + + _validOptions = new(StringComparer.OrdinalIgnoreCase) + { + Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDate", CultureInfo.CurrentCulture), + Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDateNow", CultureInfo.CurrentCulture), + + Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTime", CultureInfo.CurrentCulture), + Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTimeNow", CultureInfo.CurrentCulture), + + Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormat", CultureInfo.CurrentCulture), + Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormatNow", CultureInfo.CurrentCulture), + + Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagWeek", CultureInfo.CurrentCulture), + }; + } + + public override void UpdateQuery(string query) + { + if (!_settingsManager.EnableFallbackItems || string.IsNullOrWhiteSpace(query) || !IsValidQuery(query)) + { + Title = string.Empty; + Subtitle = string.Empty; + Command = new NoOpCommand(); + return; + } + + var availableResults = AvailableResultsList.GetList(false, _settingsManager, timestamp: _timestamp); + ListItem result = null; + var maxScore = 0; + + foreach (var f in availableResults) + { + var score = f.Score(query, f.Label, f.AlternativeSearchTag); + if (score > maxScore) + { + maxScore = score; + result = f.ToListItem(); + } + } + + if (result is not null) + { + Title = result.Title; + Subtitle = result.Subtitle; + Icon = result.Icon; + Command = result.Command; + } + else + { + Title = string.Empty; + Subtitle = string.Empty; + Command = new NoOpCommand(); + } + } + + private bool IsValidQuery(string query) + { + if (_validOptions.Contains(query)) + { + return true; + } + + foreach (var option in _validOptions) + { + if (option is null) + { + continue; + } + + var parts = option.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + if (parts.Any(part => string.Equals(part, query, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + + return false; + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs similarity index 73% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs index 9393d82b6f..6938875f80 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs @@ -1,14 +1,11 @@ // Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Runtime.CompilerServices; using Microsoft.CommandPalette.Extensions.Toolkit; -[assembly: InternalsVisibleTo("Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests")] - namespace Microsoft.CmdPal.Ext.TimeDate.Helpers; -internal class AvailableResult +internal sealed class AvailableResult { /// <summary> /// Gets or sets the time/date value @@ -30,6 +27,11 @@ internal class AvailableResult /// </summary> internal ResultIconType IconType { get; set; } + /// <summary> + /// Gets or sets a value to show additional error details + /// </summary> + internal string ErrorDetails { get; set; } = string.Empty; + /// <summary> /// Returns the path to the icon /// </summary> @@ -39,9 +41,10 @@ internal class AvailableResult { return IconType switch { - ResultIconType.Time => ResultHelper.TimeIcon, - ResultIconType.Date => ResultHelper.CalendarIcon, - ResultIconType.DateTime => ResultHelper.TimeDateIcon, + ResultIconType.Time => Icons.TimeIcon, + ResultIconType.Date => Icons.CalendarIcon, + ResultIconType.DateTime => Icons.TimeDateIcon, + ResultIconType.Error => Icons.ErrorIcon, _ => null, }; } @@ -53,18 +56,19 @@ internal class AvailableResult Title = this.Value, Subtitle = this.Label, Icon = this.GetIconInfo(), + Details = string.IsNullOrEmpty(this.ErrorDetails) ? null : new Details() { Body = this.ErrorDetails }, }; } public int Score(string query, string label, string tags) { // Get match for label (or for tags if label score is <1) - var score = StringMatcher.FuzzySearch(query, label).Score; + var score = FuzzyStringMatcher.ScoreFuzzy(query, label); if (score < 1) { foreach (var t in tags.Split(";")) { - var tagScore = StringMatcher.FuzzySearch(query, t.Trim()).Score / 2; + var tagScore = FuzzyStringMatcher.ScoreFuzzy(query, t.Trim()) / 2; if (tagScore > score) { score = tagScore / 2; @@ -81,4 +85,5 @@ public enum ResultIconType Time, Date, DateTime, + Error, } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResultsList.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResultsList.cs similarity index 72% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResultsList.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResultsList.cs index f01e6b8b07..5666ff6fa3 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResultsList.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResultsList.cs @@ -6,6 +6,8 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; +using ManagedCommon; namespace Microsoft.CmdPal.Ext.TimeDate.Helpers; @@ -20,18 +22,19 @@ internal static class AvailableResultsList /// <param name="firstWeekOfYear">Required for UnitTest: Use custom first week of the year instead of the plugin setting.</param> /// <param name="firstDayOfWeek">Required for UnitTest: Use custom first day of the week instead the plugin setting.</param> /// <returns>List of results</returns> - internal static List<AvailableResult> GetList(bool isKeywordSearch, SettingsManager settings, bool? timeLongFormat = null, bool? dateLongFormat = null, DateTime? timestamp = null, CalendarWeekRule? firstWeekOfYear = null, DayOfWeek? firstDayOfWeek = null) + internal static List<AvailableResult> GetList(bool isKeywordSearch, ISettingsInterface settings, bool? timeLongFormat = null, bool? dateLongFormat = null, DateTime? timestamp = null, CalendarWeekRule? firstWeekOfYear = null, DayOfWeek? firstDayOfWeek = null) { var results = new List<AvailableResult>(); var calendar = CultureInfo.CurrentCulture.Calendar; var timeExtended = timeLongFormat ?? settings.TimeWithSecond; var dateExtended = dateLongFormat ?? settings.DateWithWeekday; - var isSystemDateTime = timestamp == null; + var isSystemDateTime = timestamp is null; var dateTimeNow = timestamp ?? DateTime.Now; var dateTimeNowUtc = dateTimeNow.ToUniversalTime(); var firstWeekRule = firstWeekOfYear ?? TimeAndDateHelper.GetCalendarWeekRule(settings.FirstWeekOfYear); var firstDayOfTheWeek = firstDayOfWeek ?? TimeAndDateHelper.GetFirstDayOfWeek(settings.FirstDayOfWeek); + var weekOfYear = calendar.GetWeekOfYear(dateTimeNow, firstWeekRule, firstDayOfTheWeek); results.AddRange(new[] { @@ -58,17 +61,106 @@ internal static class AvailableResultsList AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagFormat"), IconType = ResultIconType.DateTime, }, + new AvailableResult() + { + Value = weekOfYear.ToString(CultureInfo.CurrentCulture), + Label = Resources.Microsoft_plugin_timedate_WeekOfYear, + AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagDate"), + IconType = ResultIconType.Date, + }, }); - if (isKeywordSearch || !settings.OnlyDateTimeNowGlobal) + if (isKeywordSearch) { // We use long instead of int for unix time stamp because int is too small after 03:14:07 UTC 2038-01-19 var unixTimestamp = ((DateTimeOffset)dateTimeNowUtc).ToUnixTimeSeconds(); var unixTimestampMilliseconds = ((DateTimeOffset)dateTimeNowUtc).ToUnixTimeMilliseconds(); - var weekOfYear = calendar.GetWeekOfYear(dateTimeNow, firstWeekRule, firstDayOfTheWeek); var era = DateTimeFormatInfo.CurrentInfo.GetEraName(calendar.GetEra(dateTimeNow)); var eraShort = DateTimeFormatInfo.CurrentInfo.GetAbbreviatedEraName(calendar.GetEra(dateTimeNow)); + // Custom formats + foreach (var f in settings.CustomFormats) + { + var formatParts = f.Split("=", 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var formatSyntax = formatParts.Length == 2 ? formatParts[1] : string.Empty; + var searchTags = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagCustom"); + var dtObject = dateTimeNow; + + // If Length = 0 then empty string. + if (formatParts.Length >= 1) + { + try + { + // Verify and check input and update search tags + if (formatParts.Length == 1) + { + throw new FormatException("Format syntax part after equal sign is missing."); + } + + var containsCustomSyntax = TimeAndDateHelper.StringContainsCustomFormatSyntax(formatSyntax); + if (formatSyntax.StartsWith("UTC:", StringComparison.InvariantCulture)) + { + searchTags = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagCustomUtc"); + dtObject = dateTimeNowUtc; + } + + // Get formatted date + var value = TimeAndDateHelper.ConvertToCustomFormat(dtObject, unixTimestamp, unixTimestampMilliseconds, weekOfYear, eraShort, Regex.Replace(formatSyntax, "^UTC:", string.Empty), firstWeekRule, firstDayOfTheWeek); + try + { + value = dtObject.ToString(value, CultureInfo.CurrentCulture); + } + catch (Exception ex) + { + if (!containsCustomSyntax) + { + Logger.LogError($"Unable to format date time with format: {value}. Error: {ex.Message}"); + throw; + } + else + { + // Do not fail as we have custom format syntax. Instead fix backslashes. + value = Regex.Replace(value, @"(?<!\\)\\", string.Empty).Replace("\\\\", "\\"); + } + } + + // Add result + results.Add(new AvailableResult() + { + Value = value, + Label = formatParts[0], + AlternativeSearchTag = searchTags, + IconType = ResultIconType.DateTime, + }); + } + catch (ArgumentOutOfRangeException e) + { + Logger.LogError($"ArgumentOutOfRangeException with format: {formatSyntax}. Error: {e.Message}"); + results.Add(new AvailableResult() + { + Value = Resources.Microsoft_plugin_timedate_ErrorConvertCustomFormat, + Label = formatParts[0] + " - " + Resources.Microsoft_plugin_timedate_show_details, + AlternativeSearchTag = searchTags, + IconType = ResultIconType.Error, + ErrorDetails = e.Message, + }); + } + catch (Exception e) + { + Logger.LogError($"Exception with format: {formatSyntax}. Error: {e.Message}"); + results.Add(new AvailableResult() + { + Value = Resources.Microsoft_plugin_timedate_InvalidCustomFormat + " " + formatSyntax, + Label = formatParts[0] + " - " + Resources.Microsoft_plugin_timedate_show_details, + AlternativeSearchTag = searchTags, + IconType = ResultIconType.Error, + ErrorDetails = e.Message, + }); + } + } + } + + // Predefined formats results.AddRange(new[] { new AvailableResult() @@ -149,6 +241,13 @@ internal static class AvailableResultsList IconType = ResultIconType.Date, }, new AvailableResult() + { + Value = DateTime.DaysInMonth(dateTimeNow.Year, dateTimeNow.Month).ToString(CultureInfo.CurrentCulture), + Label = Resources.Microsoft_plugin_timedate_DaysInMonth, + AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagDate"), + IconType = ResultIconType.Date, + }, + new AvailableResult() { Value = dateTimeNow.DayOfYear.ToString(CultureInfo.CurrentCulture), Label = Resources.Microsoft_plugin_timedate_DayOfYear, @@ -163,13 +262,6 @@ internal static class AvailableResultsList IconType = ResultIconType.Date, }, new AvailableResult() - { - Value = weekOfYear.ToString(CultureInfo.CurrentCulture), - Label = Resources.Microsoft_plugin_timedate_WeekOfYear, - AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagDate"), - IconType = ResultIconType.Date, - }, - new AvailableResult() { Value = DateTimeFormatInfo.CurrentInfo.GetMonthName(dateTimeNow.Month), Label = Resources.Microsoft_plugin_timedate_Month, @@ -198,6 +290,13 @@ internal static class AvailableResultsList IconType = ResultIconType.Date, }, new AvailableResult() + { + Value = DateTime.IsLeapYear(dateTimeNow.Year) ? Resources.Microsoft_plugin_timedate_LeapYear : Resources.Microsoft_plugin_timedate_NoLeapYear, + Label = Resources.Microsoft_plugin_timedate_LeapYear, + AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagDate"), + IconType = ResultIconType.Date, + }, + new AvailableResult() { Value = era, Label = Resources.Microsoft_plugin_timedate_Era, @@ -218,13 +317,32 @@ internal static class AvailableResultsList AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagDate"), IconType = ResultIconType.Date, }, - new AvailableResult() + }); + + try + { + results.Add(new AvailableResult() { Value = dateTimeNow.ToFileTime().ToString(CultureInfo.CurrentCulture), Label = Resources.Microsoft_plugin_timedate_WindowsFileTime, AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagFormat"), IconType = ResultIconType.DateTime, - }, + }); + } + catch (Exception ex) + { + Logger.LogError($"Unable to convert to Windows file time: {ex.Message}"); + results.Add(new AvailableResult() + { + Value = Resources.Microsoft_plugin_timedate_ErrorConvertWft, + Label = Resources.Microsoft_plugin_timedate_WindowsFileTime, + AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagFormat"), + IconType = ResultIconType.Error, + }); + } + + results.AddRange(new[] + { new AvailableResult() { Value = dateTimeNowUtc.ToString("u"), diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..12e53ccf11 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/ISettingsInterface.cs @@ -0,0 +1,26 @@ +// 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.Text; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.Ext.TimeDate.Helpers; + +public interface ISettingsInterface +{ + public int FirstWeekOfYear { get; } + + public int FirstDayOfWeek { get; } + + public bool EnableFallbackItems { get; } + + public bool TimeWithSecond { get; } + + public bool DateWithWeekday { get; } + + public List<string> CustomFormats { get; } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/ResultHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/ResultHelper.cs similarity index 62% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/ResultHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/ResultHelper.cs index 7051894223..896bbd6b84 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/ResultHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/ResultHelper.cs @@ -2,9 +2,8 @@ // 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.Globalization; -using System.IO; +using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.TimeDate.Helpers; @@ -27,27 +26,22 @@ internal static class ResultHelper : Resources.ResourceManager.GetString(stringId + "Now", CultureInfo.CurrentUICulture) ?? string.Empty; } - public static IconInfo TimeIcon { get; } = new IconInfo("\uE823"); - - public static IconInfo CalendarIcon { get; } = new IconInfo("\uE787"); - - public static IconInfo TimeDateIcon { get; } = new IconInfo("\uEC92"); - /// <summary> - /// Gets a result with an error message that only numbers can't be parsed + /// Gets a result with an error message that input can't be parsed /// </summary> /// <returns>Element of type <see cref="Result"/>.</returns> - internal static ListItem CreateNumberErrorResult() => new ListItem(new NoOpCommand()) - { - Title = Resources.Microsoft_plugin_timedate_ErrorResultTitle, - Subtitle = Resources.Microsoft_plugin_timedate_ErrorResultSubTitle, - Icon = IconHelpers.FromRelativePaths("Microsoft.CmdPal.Ext.TimeDate\\Assets\\Warning.light.png", "Microsoft.CmdPal.Ext.TimeDate\\Assets\\Warning.dark.png"), - }; - +#pragma warning disable CA1863 // Use 'CompositeFormat' internal static ListItem CreateInvalidInputErrorResult() => new ListItem(new NoOpCommand()) { Title = Resources.Microsoft_plugin_timedate_InvalidInput_ErrorMessageTitle, - Subtitle = Resources.Microsoft_plugin_timedate_InvalidInput_ErrorMessageSubTitle, - Icon = IconHelpers.FromRelativePaths("Microsoft.CmdPal.Ext.TimeDate\\Assets\\Warning.light.png", "Microsoft.CmdPal.Ext.TimeDate\\Assets\\Warning.dark.png"), + Icon = Icons.ErrorIcon, + Details = new Details() + { + Title = Resources.Microsoft_plugin_timedate_InvalidInput_DetailsHeader, + + // Because of translation we can't use 'CompositeFormat'. + Body = string.Format(CultureInfo.CurrentCulture, Resources.Microsoft_plugin_timedate_InvalidInput_SupportedInput, "**", "\n\n", "\n\n* "), + }, }; +#pragma warning restore CA1863 // Use 'CompositeFormat' } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/SettingsManager.cs similarity index 78% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/SettingsManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/SettingsManager.cs index d0de0017b8..4bd8bf4d7c 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/SettingsManager.cs @@ -11,8 +11,13 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.TimeDate.Helpers; -public class SettingsManager : JsonSettingsManager +public class SettingsManager : JsonSettingsManager, ISettingsInterface { + // Line break character used in WinUI3 TextBox and TextBlock. + private const char TEXTBOXNEWLINE = '\r'; + + private const string CUSTOMFORMATPLACEHOLDER = "MyFormat=dd-MMM-yyyy\rMySecondFormat=dddd (Da\\y nu\\mber: DOW)\rMyUtcFormat=UTC:hh:mm:ss"; + private static readonly string _namespace = "timeDate"; private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; @@ -70,11 +75,11 @@ public class SettingsManager : JsonSettingsManager Resources.Microsoft_plugin_timedate_SettingFirstDayOfWeek, _firstDayOfWeekChoices); - private readonly ToggleSetting _onlyDateTimeNowGlobal = new( - Namespaced(nameof(OnlyDateTimeNowGlobal)), - Resources.Microsoft_plugin_timedate_SettingOnlyDateTimeNowGlobal, - Resources.Microsoft_plugin_timedate_SettingOnlyDateTimeNowGlobal_Description, - true); // TODO -- double check default value + private readonly ToggleSetting _enableFallbackItems = new( + Namespaced(nameof(EnableFallbackItems)), + Resources.Microsoft_plugin_timedate_SettingEnableFallbackItems, + Resources.Microsoft_plugin_timedate_SettingEnableFallbackItems_Description, + true); private readonly ToggleSetting _timeWithSeconds = new( Namespaced(nameof(TimeWithSecond)), @@ -88,17 +93,17 @@ public class SettingsManager : JsonSettingsManager Resources.Microsoft_plugin_timedate_SettingDateWithWeekday_Description, false); // TODO -- double check default value - private readonly ToggleSetting _hideNumberMessageOnGlobalQuery = new( - Namespaced(nameof(HideNumberMessageOnGlobalQuery)), - Resources.Microsoft_plugin_timedate_SettingHideNumberMessageOnGlobalQuery, - Resources.Microsoft_plugin_timedate_SettingHideNumberMessageOnGlobalQuery, - true); // TODO -- double check default value + private readonly TextSetting _customFormats = new( + Namespaced(nameof(CustomFormats)), + Resources.Microsoft_plugin_timedate_Setting_CustomFormats, + Resources.Microsoft_plugin_timedate_Setting_CustomFormats + TEXTBOXNEWLINE + string.Format(CultureInfo.CurrentCulture, Resources.Microsoft_plugin_timedate_Setting_CustomFormatsDescription.ToString(), "DOW", "DIM", "WOM", "WOY", "EAB", "WFT", "UXT", "UMS", "OAD", "EXC", "EXF", "UTC:"), + string.Empty); public int FirstWeekOfYear { get { - if (_firstWeekOfYear.Value == null || string.IsNullOrEmpty(_firstWeekOfYear.Value)) + if (_firstWeekOfYear.Value is null || string.IsNullOrEmpty(_firstWeekOfYear.Value)) { return -1; } @@ -118,7 +123,7 @@ public class SettingsManager : JsonSettingsManager { get { - if (_firstDayOfWeek.Value == null || string.IsNullOrEmpty(_firstDayOfWeek.Value)) + if (_firstDayOfWeek.Value is null || string.IsNullOrEmpty(_firstDayOfWeek.Value)) { return -1; } @@ -134,13 +139,13 @@ public class SettingsManager : JsonSettingsManager } } - public bool OnlyDateTimeNowGlobal => _onlyDateTimeNowGlobal.Value; + public bool EnableFallbackItems => _enableFallbackItems.Value; public bool TimeWithSecond => _timeWithSeconds.Value; public bool DateWithWeekday => _dateWithWeekday.Value; - public bool HideNumberMessageOnGlobalQuery => _hideNumberMessageOnGlobalQuery.Value; + public List<string> CustomFormats => _customFormats.Value.Split(TEXTBOXNEWLINE).ToList(); internal static string SettingsJsonPath() { @@ -155,12 +160,15 @@ public class SettingsManager : JsonSettingsManager { FilePath = SettingsJsonPath(); - Settings.Add(_firstWeekOfYear); - Settings.Add(_firstDayOfWeek); - Settings.Add(_onlyDateTimeNowGlobal); + Settings.Add(_enableFallbackItems); Settings.Add(_timeWithSeconds); Settings.Add(_dateWithWeekday); - Settings.Add(_hideNumberMessageOnGlobalQuery); + Settings.Add(_firstWeekOfYear); + Settings.Add(_firstDayOfWeek); + + _customFormats.Multiline = true; + _customFormats.Placeholder = CUSTOMFORMATPLACEHOLDER; + Settings.Add(_customFormats); // Load settings from file upon initialization LoadSettings(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeAndDateHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeAndDateHelper.cs new file mode 100644 index 0000000000..092cd53e47 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeAndDateHelper.cs @@ -0,0 +1,456 @@ +// 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.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using ManagedCommon; + +namespace Microsoft.CmdPal.Ext.TimeDate.Helpers; + +internal static class TimeAndDateHelper +{ + /* htcfreek:Currently not used. + * private static readonly Regex _regexSpecialInputFormats = new Regex(@"^.*(u|ums|ft|oa|exc|exf)\d"); */ + + private static readonly Regex _regexCustomDateTimeFormats = new Regex(@"(?<!\\)(DOW|DIM|WOM|WOY|EAB|WFT|UXT|UMS|OAD|EXC|EXF)"); + private static readonly Regex _regexCustomDateTimeDim = new Regex(@"(?<!\\)DIM"); + private static readonly Regex _regexCustomDateTimeDow = new Regex(@"(?<!\\)DOW"); + private static readonly Regex _regexCustomDateTimeWom = new Regex(@"(?<!\\)WOM"); + private static readonly Regex _regexCustomDateTimeWoy = new Regex(@"(?<!\\)WOY"); + private static readonly Regex _regexCustomDateTimeEab = new Regex(@"(?<!\\)EAB"); + private static readonly Regex _regexCustomDateTimeWft = new Regex(@"(?<!\\)WFT"); + private static readonly Regex _regexCustomDateTimeUxt = new Regex(@"(?<!\\)UXT"); + private static readonly Regex _regexCustomDateTimeUms = new Regex(@"(?<!\\)UMS"); + private static readonly Regex _regexCustomDateTimeOad = new Regex(@"(?<!\\)OAD"); + private static readonly Regex _regexCustomDateTimeExc = new Regex(@"(?<!\\)EXC"); + private static readonly Regex _regexCustomDateTimeExf = new Regex(@"(?<!\\)EXF"); + + private const long UnixTimeSecondsMin = -62135596800; + private const long UnixTimeSecondsMax = 253402300799; + private const long UnixTimeMillisecondsMin = -62135596800000; + private const long UnixTimeMillisecondsMax = 253402300799999; + private const long WindowsFileTimeMin = 0; + private const long WindowsFileTimeMax = 2650467707991000000; + private const double OADateMin = -657434.99999999; + private const double OADateMax = 2958465.99999999; + private const double Excel1900DateMin = 1; + private const double Excel1900DateMax = 2958465.99998843; + private const double Excel1904DateMin = 0; + private const double Excel1904DateMax = 2957003.99998843; + + /// <summary> + /// Get the format for the time string + /// </summary> + /// <param name="targetFormat">Type of format</param> + /// <param name="timeLong">Show date with weekday and name of month (long format)</param> + /// <param name="dateLong">Show time with seconds (long format)</param> + /// <returns>String that identifies the time/date format (<see href="https://learn.microsoft.com/dotnet/api/system.datetime.tostring"/>)</returns> + internal static string GetStringFormat(FormatStringType targetFormat, bool timeLong, bool dateLong) + { + switch (targetFormat) + { + case FormatStringType.Time: + return timeLong ? "T" : "t"; + case FormatStringType.Date: + return dateLong ? "D" : "d"; + case FormatStringType.DateTime: + if (timeLong & dateLong) + { + return "F"; // Friday, October 31, 2008 5:04:32 PM + } + else if (timeLong & !dateLong) + { + return "G"; // 10/31/2008 5:04:32 PM + } + else if (!timeLong & dateLong) + { + return "f"; // Friday, October 31, 2008 5:04 PM + } + else + { + // (!timeLong & !dateLong) + return "g"; // 10/31/2008 5:04 PM + } + + default: + return string.Empty; // Windows default based on current culture settings + } + } + + /// <summary> + /// Returns the number week in the month (Used code from 'David Morton' from <see href="https://social.msdn.microsoft.com/Forums/vstudio/bf504bba-85cb-492d-a8f7-4ccabdf882cb/get-week-number-for-month"/>) + /// </summary> + /// <param name="date">date</param> + /// <param name="formatSettingFirstDayOfWeek">Setting for the first day in the week.</param> + /// <returns>Number of week in the month</returns> + internal static int GetWeekOfMonth(DateTime date, DayOfWeek formatSettingFirstDayOfWeek) + { + var weekCount = 1; + + for (var i = 1; i <= date.Day; i++) + { + DateTime d = new(date.Year, date.Month, i); + + // Count week number +1 if day is the first day of a week and not day 1 of the month. + // (If we count on day one of a month we would start the month with week number 2.) + if (i > 1 && d.DayOfWeek == formatSettingFirstDayOfWeek) + { + weekCount += 1; + } + } + + return weekCount; + } + + /// <summary> + /// Returns the number of the day in the week + /// </summary> + /// <param name="date">Date</param> + /// <returns>Number of the day in the week</returns> + internal static int GetNumberOfDayInWeek(DateTime date, DayOfWeek formatSettingFirstDayOfWeek) + { + var daysInWeek = 7; + var adjustment = 1; // We count from 1 to 7 and not from 0 to 6 + + return ((date.DayOfWeek + daysInWeek - formatSettingFirstDayOfWeek) % daysInWeek) + adjustment; + } + + internal static double ConvertToOleAutomationFormat(DateTime date, OADateFormats type) + { + var v = date.ToOADate(); + + switch (type) + { + case OADateFormats.Excel1904: + // Excel with base 1904: Adjust by -1462 + v -= 1462; + + // Date starts at 1/1/1904 = 0 + if (Math.Truncate(v) < 0) + { + throw new ArgumentOutOfRangeException("Not a valid Excel date.", innerException: null); + } + + return v; + case OADateFormats.Excel1900: + // Excel with base 1900: Adjust by -1 if v < 61 + v = v < 61 ? v - 1 : v; + + // Date starts at 1/1/1900 = 1 + if (Math.Truncate(v) < 1) + { + throw new ArgumentOutOfRangeException("Not a valid Excel date.", innerException: null); + } + + return v; + default: + // OLE Automation date: Return as is. + return v; + } + } + + /// <summary> + /// Convert input string to a <see cref="DateTime"/> object in local time + /// </summary> + /// <param name="input">String with date/time</param> + /// <param name="timestamp">The new <see cref="DateTime"/> object</param> + /// <param name="inputParsingErrorMsg">Error message shown to the user</param> + /// <returns>True on success; otherwise, false</returns> + internal static bool ParseStringAsDateTime(in string input, out DateTime timestamp, out string inputParsingErrorMsg) + { + inputParsingErrorMsg = string.Empty; + CompositeFormat errorMessage = CompositeFormat.Parse(Resources.Microsoft_plugin_timedate_InvalidInput_SupportedRange); + + if (DateTime.TryParse(input, out timestamp)) + { + // Known date/time format + Logger.LogDebug($"Successfully parsed standard date/time format: '{input}' as {timestamp}"); + return true; + } + else if (Regex.IsMatch(input, @"^u[\+-]?\d+$")) + { + // Unix time stamp + // We use long instead of int, because int is too small after 03:14:07 UTC 2038-01-19 + var canParse = long.TryParse(input.TrimStart('u'), out var secondsU); + + // Value has to be in the range from -62135596800 to 253402300799 + if (!canParse || secondsU < UnixTimeSecondsMin || secondsU > UnixTimeSecondsMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_Unix, UnixTimeSecondsMin, UnixTimeSecondsMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + Logger.LogError($"Failed to parse unix timestamp: '{input}'. Value out of range."); + return false; + } + + timestamp = DateTimeOffset.FromUnixTimeSeconds(secondsU).LocalDateTime; + Logger.LogDebug($"Successfully parsed unix timestamp: '{input}' as {timestamp}"); + return true; + } + else if (Regex.IsMatch(input, @"^ums[\+-]?\d+$")) + { + // Unix time stamp in milliseconds + // We use long instead of int because int is too small after 03:14:07 UTC 2038-01-19 + var canParse = long.TryParse(input.TrimStart("ums".ToCharArray()), out var millisecondsUms); + + // Value has to be in the range from -62135596800000 to 253402300799999 + if (!canParse || millisecondsUms < UnixTimeMillisecondsMin || millisecondsUms > UnixTimeMillisecondsMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_Unix_Milliseconds, UnixTimeMillisecondsMin, UnixTimeMillisecondsMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + Logger.LogError($"Failed to parse unix millisecond timestamp: '{input}'. Value out of range."); + return false; + } + + timestamp = DateTimeOffset.FromUnixTimeMilliseconds(millisecondsUms).LocalDateTime; + Logger.LogDebug($"Successfully parsed unix millisecond timestamp: '{input}' as {timestamp}"); + return true; + } + else if (Regex.IsMatch(input, @"^ft\d+$")) + { + var canParse = long.TryParse(input.TrimStart("ft".ToCharArray()), out var secondsFt); + + // Windows file time + // Value has to be in the range from 0 to 2650467707991000000 + if (!canParse || secondsFt < WindowsFileTimeMin || secondsFt > WindowsFileTimeMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_WindowsFileTime, WindowsFileTimeMin, WindowsFileTimeMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + Logger.LogError($"Failed to parse Windows file time: '{input}'. Value out of range."); + return false; + } + + // DateTime.FromFileTime returns as local time. + timestamp = DateTime.FromFileTime(secondsFt); + Logger.LogDebug($"Successfully parsed Windows file time: '{input}' as {timestamp}"); + return true; + } + else if (Regex.IsMatch(input, @"^oa[+-]?\d+[,.0-9]*$")) + { + var canParse = double.TryParse(input.TrimStart("oa".ToCharArray()), out var oADate); + + // OLE Automation date + // Input has to be in the range from -657434.99999999 to 2958465.99999999 + // DateTime.FromOADate returns as local time. + if (!canParse || oADate < OADateMin || oADate > OADateMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_OADate, OADateMin, OADateMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + Logger.LogError($"Failed to parse OLE Automation date: '{input}'. Value out of range."); + return false; + } + + timestamp = DateTime.FromOADate(oADate); + Logger.LogDebug($"Successfully parsed OLE Automation date: '{input}' as {timestamp}"); + return true; + } + else if (Regex.IsMatch(input, @"^exc[+-]?\d+[,.0-9]*$")) + { + var canParse = double.TryParse(input.TrimStart("exc".ToCharArray()), out var excDate); + + // Excel's 1900 date value + // Input has to be in the range from 1 (0 = Fake date) to 2958465.99998843 and not 60 whole number + // Because of a bug in Excel and the way it behaves before 3/1/1900 we have to adjust all inputs lower than 61 for +1 + // DateTime.FromOADate returns as local time. + if (!canParse || excDate < 0 || excDate > Excel1900DateMax) + { + // For the if itself we use 0 as min value that we can show a special message if input is 0. + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_Excel1900, Excel1900DateMin, Excel1900DateMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + Logger.LogError($"Failed to parse Excel 1900 date value: '{input}'. Value out of range."); + return false; + } + + if (Math.Truncate(excDate) == 0 || Math.Truncate(excDate) == 60) + { + inputParsingErrorMsg = Resources.Microsoft_plugin_timedate_InvalidInput_FakeExcel1900; + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + Logger.LogError($"Failed to parse Excel 1900 date value: '{input}'. Invalid date (0 or 60)."); + return false; + } + + excDate = excDate <= 60 ? excDate + 1 : excDate; + timestamp = DateTime.FromOADate(excDate); + Logger.LogDebug($"Successfully parsed Excel 1900 date value: '{input}' as {timestamp}"); + return true; + } + else if (Regex.IsMatch(input, @"^exf[+-]?\d+[,.0-9]*$")) + { + var canParse = double.TryParse(input.TrimStart("exf".ToCharArray()), out var exfDate); + + // Excel's 1904 date value + // Input has to be in the range from 0 to 2957003.99998843 + // Because Excel uses 01/01/1904 as base we need to adjust for +1462 + // DateTime.FromOADate returns as local time. + if (!canParse || exfDate < Excel1904DateMin || exfDate > Excel1904DateMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_Excel1904, Excel1904DateMin, Excel1904DateMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + Logger.LogError($"Failed to parse Excel 1904 date value: '{input}'. Value out of range."); + return false; + } + + timestamp = DateTime.FromOADate(exfDate + 1462); + Logger.LogDebug($"Successfully parsed Excel 1904 date value: '{input}' as {timestamp}"); + return true; + } + else + { + inputParsingErrorMsg = Resources.Microsoft_plugin_timedate_InvalidInput_ErrorMessageTitle; + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + Logger.LogWarning($"Failed to parse input: '{input}'. Format not recognized."); + return false; + } + } + + /* htcfreek:Currently not required + /// <summary> + /// Test if input is special parsing for Unix time, Unix time in milliseconds, file time, ... + /// </summary> + /// <param name="input">String with date/time</param> + /// <returns>True if yes; otherwise, false</returns> + internal static bool IsSpecialInputParsing(string input) + { + return _regexSpecialInputFormats.IsMatch(input); + }*/ + + /// <summary> + /// Converts a DateTime object based on the format string + /// </summary> + /// <param name="date">Date/time object.</param> + /// <param name="unix">Value for replacing "Unix Time Stamp".</param> + /// <param name="unixMilliseconds">Value for replacing "Unix Time Stamp in milliseconds".</param> + /// <param name="calWeek">Value for relacing calendar week.</param> + /// <param name="eraShortFormat">Era abbreviation.</param> + /// <param name="format">Format definition.</param> + /// <returns>Formated date/time string.</returns> + internal static string ConvertToCustomFormat(DateTime date, long unix, long unixMilliseconds, int calWeek, string eraShortFormat, string format, CalendarWeekRule firstWeekRule, DayOfWeek firstDayOfTheWeek) + { + var result = format; + + // DOW: Number of day in week + result = _regexCustomDateTimeDow.Replace(result, GetNumberOfDayInWeek(date, firstDayOfTheWeek).ToString(CultureInfo.CurrentCulture)); + + // DIM: Days in Month + result = _regexCustomDateTimeDim.Replace(result, DateTime.DaysInMonth(date.Year, date.Month).ToString(CultureInfo.CurrentCulture)); + + // WOM: Week of Month + result = _regexCustomDateTimeWom.Replace(result, GetWeekOfMonth(date, firstDayOfTheWeek).ToString(CultureInfo.CurrentCulture)); + + // WOY: Week of Year + result = _regexCustomDateTimeWoy.Replace(result, calWeek.ToString(CultureInfo.CurrentCulture)); + + // EAB: Era abbreviation + result = _regexCustomDateTimeEab.Replace(result, eraShortFormat); + + // WFT: Week of Month + if (_regexCustomDateTimeWft.IsMatch(result)) + { + // Special handling as very early dates can't convert. + result = _regexCustomDateTimeWft.Replace(result, date.ToFileTime().ToString(CultureInfo.CurrentCulture)); + } + + // UXT: Unix time stamp + result = _regexCustomDateTimeUxt.Replace(result, unix.ToString(CultureInfo.CurrentCulture)); + + // UMS: Unix time stamp milli seconds + result = _regexCustomDateTimeUms.Replace(result, unixMilliseconds.ToString(CultureInfo.CurrentCulture)); + + // OAD: OLE Automation date + result = _regexCustomDateTimeOad.Replace(result, ConvertToOleAutomationFormat(date, OADateFormats.OLEAutomation).ToString(CultureInfo.CurrentCulture)); + + // EXC: Excel date value with base 1900 + if (_regexCustomDateTimeExc.IsMatch(result)) + { + // Special handling as very early dates can't convert. + result = _regexCustomDateTimeExc.Replace(result, ConvertToOleAutomationFormat(date, OADateFormats.Excel1900).ToString(CultureInfo.CurrentCulture)); + } + + // EXF: Excel date value with base 1904 + if (_regexCustomDateTimeExf.IsMatch(result)) + { + // Special handling as very early dates can't convert. + result = _regexCustomDateTimeExf.Replace(result, ConvertToOleAutomationFormat(date, OADateFormats.Excel1904).ToString(CultureInfo.CurrentCulture)); + } + + return result; + } + + /// <summary> + /// Test a string for our custom date and time format syntax + /// </summary> + /// <param name="str">String to test.</param> + /// <returns>True if yes and otherwise false</returns> + internal static bool StringContainsCustomFormatSyntax(string str) + { + return _regexCustomDateTimeFormats.IsMatch(str); + } + + /// <summary> + /// Returns a CalendarWeekRule enum value based on the plugin setting. + /// </summary> + internal static CalendarWeekRule GetCalendarWeekRule(int pluginSetting) + { + switch (pluginSetting) + { + case 0: + return CalendarWeekRule.FirstDay; + case 1: + return CalendarWeekRule.FirstFullWeek; + case 2: + return CalendarWeekRule.FirstFourDayWeek; + default: + // Wrong json value and system setting (-1). + return DateTimeFormatInfo.CurrentInfo.CalendarWeekRule; + } + } + + /// <summary> + /// Returns a DayOfWeek enum value based on the FirstDayOfWeek plugin setting. + /// </summary> + internal static DayOfWeek GetFirstDayOfWeek(int pluginSetting) + { + switch (pluginSetting) + { + case 0: + return DayOfWeek.Sunday; + case 1: + return DayOfWeek.Monday; + case 2: + return DayOfWeek.Tuesday; + case 3: + return DayOfWeek.Wednesday; + case 4: + return DayOfWeek.Thursday; + case 5: + return DayOfWeek.Friday; + case 6: + return DayOfWeek.Saturday; + default: + // Wrong json value and system setting (-1). + return DateTimeFormatInfo.CurrentInfo.FirstDayOfWeek; + } + } +} + +/// <summary> +/// Type of time/date format +/// </summary> +internal enum FormatStringType +{ + Time, + Date, + DateTime, +} + +/// <summary> +/// Different versions of Date formats based on OLE Automation date +/// </summary> +internal enum OADateFormats +{ + OLEAutomation, + Excel1900, + Excel1904, +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs similarity index 72% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs index 5022236678..2884cbbad2 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/TimeDateCalculator.cs @@ -17,27 +17,25 @@ public sealed partial class TimeDateCalculator /// </summary> private const string InputDelimiter = "::"; - /// <summary> - /// A list of conjunctions that we ignore on search - /// </summary> - private static readonly string[] _conjunctionList = Resources.Microsoft_plugin_timedate_Search_ConjunctionList.Split("; "); - /// <summary> /// Searches for results /// </summary> /// <param name="query">Search query object</param> /// <returns>List of Wox <see cref="Result"/>s.</returns> - public static List<ListItem> ExecuteSearch(SettingsManager settings, string query) + public static List<ListItem> ExecuteSearch(ISettingsInterface settings, string query) { - var isEmptySearchInput = string.IsNullOrEmpty(query); + var isEmptySearchInput = string.IsNullOrWhiteSpace(query); List<AvailableResult> availableFormats = new List<AvailableResult>(); List<ListItem> results = new List<ListItem>(); // currently, all of the search in V2 is keyword search. var isKeywordSearch = true; + // Last input parsing error + var lastInputParsingErrorMsg = string.Empty; + // Switch search type - if (isEmptySearchInput || (!isKeywordSearch && settings.OnlyDateTimeNowGlobal)) + if (isEmptySearchInput || (!isKeywordSearch)) { // Return all results for system time/date on empty keyword search // or only time, date and now results for system time on global queries if the corresponding setting is enabled @@ -47,13 +45,13 @@ public sealed partial class TimeDateCalculator { // Search for specified format with specified time/date value var userInput = query.Split(InputDelimiter); - if (TimeAndDateHelper.ParseStringAsDateTime(userInput[1], out DateTime timestamp)) + if (TimeAndDateHelper.ParseStringAsDateTime(userInput[1], out DateTime timestamp, out lastInputParsingErrorMsg)) { availableFormats.AddRange(AvailableResultsList.GetList(isKeywordSearch, settings, null, null, timestamp)); query = userInput[0]; } } - else if (TimeAndDateHelper.ParseStringAsDateTime(query, out DateTime timestamp)) + else if (TimeAndDateHelper.ParseStringAsDateTime(query, out DateTime timestamp, out lastInputParsingErrorMsg)) { // Return all formats for specified time/date value availableFormats.AddRange(AvailableResultsList.GetList(isKeywordSearch, settings, null, null, timestamp)); @@ -76,31 +74,33 @@ public sealed partial class TimeDateCalculator } else { + List<(int Score, AvailableResult Item)> itemScores = []; + // Generate filtered list of results foreach (var f in availableFormats) { var score = f.Score(query, f.Label, f.AlternativeSearchTag); - if (score > 0) { - results.Add(f.ToListItem()); + itemScores.Add((score, f)); } } - } - // If search term is only a number that can't be parsed return an error message - if (!isEmptySearchInput && results.Count == 0 && Regex.IsMatch(query, @"\w+\d+.*$") && !query.Any(char.IsWhiteSpace) && (TimeAndDateHelper.IsSpecialInputParsing(query) || !Regex.IsMatch(query, @"\d+[\.:/]\d+"))) - { - // Without plugin key word show only if message is not hidden by setting - if (!settings.HideNumberMessageOnGlobalQuery) - { - results.Add(ResultHelper.CreateNumberErrorResult()); - } + results = itemScores + .OrderByDescending(s => s.Score) + .Select(s => s.Item.ToListItem()) + .ToList(); } if (results.Count == 0) { - results.Add(ResultHelper.CreateInvalidInputErrorResult()); + var er = ResultHelper.CreateInvalidInputErrorResult(); + if (!string.IsNullOrEmpty(lastInputParsingErrorMsg)) + { + er.Details = new Details() { Body = lastInputParsingErrorMsg }; + } + + results.Add(er); } return results; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Icons.cs new file mode 100644 index 0000000000..e0464b780f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Icons.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 Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.TimeDate; + +internal sealed class Icons +{ + internal static IconInfo TimeDateExtIcon { get; } = IconHelpers.FromRelativePath("Assets\\TimeDate.svg"); + + internal static IconInfo TimeIcon { get; } = new IconInfo("\uE823"); + + internal static IconInfo CalendarIcon { get; } = new IconInfo("\uE787"); + + internal static IconInfo TimeDateIcon { get; } = new IconInfo("\uEC92"); + + internal static IconInfo ErrorIcon { get; } = IconHelpers.FromRelativePaths("Microsoft.CmdPal.Ext.TimeDate\\Assets\\Warning.light.png", "Microsoft.CmdPal.Ext.TimeDate\\Assets\\Warning.dark.png"); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Microsoft.CmdPal.Ext.TimeDate.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Microsoft.CmdPal.Ext.TimeDate.csproj similarity index 70% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Microsoft.CmdPal.Ext.TimeDate.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Microsoft.CmdPal.Ext.TimeDate.csproj index 733ed8634e..c1291472f1 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Microsoft.CmdPal.Ext.TimeDate.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Microsoft.CmdPal.Ext.TimeDate.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\Microsoft.CmdPal.UI\CmdPal.pre.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> + <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.TimeDate</RootNamespace> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> @@ -18,7 +21,8 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props --> </ItemGroup> <ItemGroup> @@ -30,8 +34,10 @@ </ItemGroup> <ItemGroup> - <None Include="Assets\TimeDate.png" /> + <None Remove="Assets\TimeDate.png" /> + <None Remove="Assets\TimeDate.svg" /> </ItemGroup> + <ItemGroup> <Content Update="Assets\TimeDate.png"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Pages/TimeDateExtensionPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Pages/TimeDateExtensionPage.cs similarity index 56% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Pages/TimeDateExtensionPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Pages/TimeDateExtensionPage.cs index bb5b58adb6..36eb39461f 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Pages/TimeDateExtensionPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Pages/TimeDateExtensionPage.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; using Microsoft.CmdPal.Ext.TimeDate.Helpers; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -12,32 +14,58 @@ namespace Microsoft.CmdPal.Ext.TimeDate.Pages; internal sealed partial class TimeDateExtensionPage : DynamicListPage { - private SettingsManager _settingsManager; + private readonly Lock _resultsLock = new(); - public TimeDateExtensionPage(SettingsManager settingsManager) + private IList<ListItem> _results = new List<ListItem>(); + private bool _dataLoaded; + + private ISettingsInterface _settingsManager; + + public TimeDateExtensionPage(ISettingsInterface settingsManager) { - Icon = IconHelpers.FromRelativePath("Assets\\TimeDate.svg"); + Icon = Icons.TimeDateExtIcon; Title = Resources.Microsoft_plugin_timedate_main_page_title; Name = Resources.Microsoft_plugin_timedate_main_page_name; PlaceholderText = Resources.Microsoft_plugin_timedate_placeholder_text; Id = "com.microsoft.cmdpal.timedate"; _settingsManager = settingsManager; + ShowDetails = true; } - public override IListItem[] GetItems() => DoExecuteSearch(SearchText).ToArray(); + public override IListItem[] GetItems() + { + ListItem[] results; + lock (_resultsLock) + { + if (_dataLoaded) + { + results = _results.ToArray(); + _dataLoaded = false; + return results; + } + } + + DoExecuteSearch(string.Empty); + + lock (_resultsLock) + { + results = _results.ToArray(); + _dataLoaded = false; + return results; + } + } public override void UpdateSearchText(string oldSearch, string newSearch) { DoExecuteSearch(newSearch); - RaiseItemsChanged(0); } - private List<ListItem> DoExecuteSearch(string query) + private void DoExecuteSearch(string query) { try { var result = TimeDateCalculator.ExecuteSearch(_settingsManager, query); - return result; + UpdateResult(result); } catch (Exception) { @@ -51,7 +79,18 @@ internal sealed partial class TimeDateExtensionPage : DynamicListPage ResultHelper.CreateInvalidInputErrorResult(), }; - return items; + UpdateResult(items); } } + + private void UpdateResult(IList<ListItem> result) + { + lock (_resultsLock) + { + this._results = result; + _dataLoaded = true; + } + + RaiseItemsChanged(this._results.Count); + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..1f571c145a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.TimeDate.UnitTests")] diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs similarity index 76% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs index 8891e81c2b..e3443c3ee6 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.Designer.cs @@ -150,6 +150,15 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } + /// <summary> + /// Looks up a localized string similar to Days in month. + /// </summary> + public static string Microsoft_plugin_timedate_DaysInMonth { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_DaysInMonth", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Era. /// </summary> @@ -169,20 +178,47 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// <summary> - /// Looks up a localized string similar to Valid prefixes: 'u' for Unix Timestamp, 'ums' for Unix Timestamp in milliseconds, 'ft' for Windows file time. + /// Looks up a localized string similar to Failed to convert into custom format. /// </summary> - public static string Microsoft_plugin_timedate_ErrorResultSubTitle { + public static string Microsoft_plugin_timedate_ErrorConvertCustomFormat { get { - return ResourceManager.GetString("Microsoft_plugin_timedate_ErrorResultSubTitle", resourceCulture); + return ResourceManager.GetString("Microsoft_plugin_timedate_ErrorConvertCustomFormat", resourceCulture); } } /// <summary> - /// Looks up a localized string similar to Error: Invalid number input. + /// Looks up a localized string similar to Not a valid Windows file time. /// </summary> - public static string Microsoft_plugin_timedate_ErrorResultTitle { + public static string Microsoft_plugin_timedate_ErrorConvertWft { get { - return ResourceManager.GetString("Microsoft_plugin_timedate_ErrorResultTitle", resourceCulture); + return ResourceManager.GetString("Microsoft_plugin_timedate_ErrorConvertWft", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Excel's 1900 date value. + /// </summary> + public static string Microsoft_plugin_timedate_Excel1900 { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_Excel1900", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Excel's 1904 date value. + /// </summary> + public static string Microsoft_plugin_timedate_Excel1904 { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_Excel1904", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open Time Data Command. + /// </summary> + public static string Microsoft_plugin_timedate_fallback_display_title { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_fallback_display_title", resourceCulture); } } @@ -205,11 +241,20 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// <summary> - /// Looks up a localized string similar to Valid prefixes: 'u' for Unix Timestamp, 'ums' for Unix Timestamp in milliseconds, 'ft' for Windows file time. + /// Looks up a localized string similar to Invalid custom format:. /// </summary> - public static string Microsoft_plugin_timedate_InvalidInput_ErrorMessageSubTitle { + public static string Microsoft_plugin_timedate_InvalidCustomFormat { get { - return ResourceManager.GetString("Microsoft_plugin_timedate_InvalidInput_ErrorMessageSubTitle", resourceCulture); + return ResourceManager.GetString("Microsoft_plugin_timedate_InvalidCustomFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Supported input. + /// </summary> + public static string Microsoft_plugin_timedate_InvalidInput_DetailsHeader { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_InvalidInput_DetailsHeader", resourceCulture); } } @@ -222,6 +267,33 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } + /// <summary> + /// Looks up a localized string similar to Cannot parse the input as Excel's 1900 date value because it is a fake date. (In Excel 0 stands for 0/1/1900 and this date doesn't exist. And 60 stands for 2/29/1900 and this date only exists in Excel for compatibility with Lotus 123.). + /// </summary> + public static string Microsoft_plugin_timedate_InvalidInput_FakeExcel1900 { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_InvalidInput_FakeExcel1900", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to A {0}format name{0}, a {0}valid date or time value{0}, or a {0}prefixed number{0}. To search for a format in a specific date/time please use the syntax {0}format::date/time/number{0}.{1}Supported prefixes:{2}'{0}u{0}' for Unix Timestamp{2}'{0}ums{0}' for Unix Timestamp in milliseconds{2}'{0}ft{0}' for Windows file time{2}'{0}oa{0}' for OLE Automation Date{2}'{0}exc{0}' for Excel's 1900 date value{2}'{0}exf{0}' for Excel's 1904 date value. + /// </summary> + public static string Microsoft_plugin_timedate_InvalidInput_SupportedInput { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_InvalidInput_SupportedInput", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Your input for {0} is outside the range **from {1} to {2}**.. + /// </summary> + public static string Microsoft_plugin_timedate_InvalidInput_SupportedRange { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_InvalidInput_SupportedRange", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to ISO 8601. /// </summary> @@ -258,6 +330,15 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } + /// <summary> + /// Looks up a localized string similar to Leap year. + /// </summary> + public static string Microsoft_plugin_timedate_LeapYear { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_LeapYear", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Open. /// </summary> @@ -268,7 +349,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// <summary> - /// Looks up a localized string similar to Time and Date. + /// Looks up a localized string similar to Time and date. /// </summary> public static string Microsoft_plugin_timedate_main_page_title { get { @@ -321,6 +402,15 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } + /// <summary> + /// Looks up a localized string similar to Not a leap year. + /// </summary> + public static string Microsoft_plugin_timedate_NoLeapYear { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_NoLeapYear", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Now. /// </summary> @@ -339,6 +429,15 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } + /// <summary> + /// Looks up a localized string similar to OLE Automation Date. + /// </summary> + public static string Microsoft_plugin_timedate_OADate { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_OADate", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Search values or type a custom time stamp.... /// </summary> @@ -349,7 +448,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// <summary> - /// Looks up a localized string similar to Provides time and date values in different formats. + /// Looks up a localized string similar to Show time and date values in different formats. /// </summary> public static string Microsoft_plugin_timedate_plugin_description { get { @@ -385,7 +484,7 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// <summary> - /// Looks up a localized string similar to Time and Date. + /// Looks up a localized string similar to Time and date. /// </summary> public static string Microsoft_plugin_timedate_plugin_name { get { @@ -403,11 +502,38 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } /// <summary> - /// Looks up a localized string similar to for; and; nor; but; or; so. + /// Looks up a localized string similar to Date and time; Time and Date; Custom format. /// </summary> - public static string Microsoft_plugin_timedate_Search_ConjunctionList { + public static string Microsoft_plugin_timedate_SearchTagCustom { get { - return ResourceManager.GetString("Microsoft_plugin_timedate_Search_ConjunctionList", resourceCulture); + return ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagCustom", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Current date and time; Current time and date; Now; Custom format. + /// </summary> + public static string Microsoft_plugin_timedate_SearchTagCustomNow { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagCustomNow", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Date and time UTC; Time UTC and Date; Custom UTC format. + /// </summary> + public static string Microsoft_plugin_timedate_SearchTagCustomUtc { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagCustomUtc", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Current date and time UTC; Current time UTC and date; Now UTC; Custom UTC format. + /// </summary> + public static string Microsoft_plugin_timedate_SearchTagCustomUtcNow { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagCustomUtcNow", resourceCulture); } } @@ -483,6 +609,15 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } + /// <summary> + /// Looks up a localized string similar to Current Week; Calendar week; Week of the year; Week. + /// </summary> + public static string Microsoft_plugin_timedate_SearchTagWeek { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagWeek", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Second. /// </summary> @@ -492,6 +627,24 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } + /// <summary> + /// Looks up a localized string similar to Custom formats. + /// </summary> + public static string Microsoft_plugin_timedate_Setting_CustomFormats { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_Setting_CustomFormats", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Use date and time string format syntax and {0} (Day of Week), {1} (Days in Month), {2} (Week of Month), {3} (Week of the year), {4} (Era abbreviation), {5} (Windows File Time), {6} (Unix Time), {7} (Unix Time in milliseconds), {8} (OLE Automation date), {9} (Excel's 1900 based date value), {10} (Excel's 1904 based date value). If the format starts with {11}, then Universal Time (UTC) is used. (Use a backslash to escape format sequences and the backslash character as text.). + /// </summary> + public static string Microsoft_plugin_timedate_Setting_CustomFormatsDescription { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_Setting_CustomFormatsDescription", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Use system setting. /// </summary> @@ -519,6 +672,24 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } + /// <summary> + /// Looks up a localized string similar to Enable fallback items for TimeDate (week, year, now, time, date). + /// </summary> + public static string Microsoft_plugin_timedate_SettingEnableFallbackItems { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SettingEnableFallbackItems", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Show time and date results when typing keywords like "week", "year", "now", "time", or "date". + /// </summary> + public static string Microsoft_plugin_timedate_SettingEnableFallbackItems_Description { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SettingEnableFallbackItems_Description", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to First day of the week. /// </summary> @@ -636,33 +807,6 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } - /// <summary> - /// Looks up a localized string similar to Hide 'Invalid number input' error message on global queries. - /// </summary> - public static string Microsoft_plugin_timedate_SettingHideNumberMessageOnGlobalQuery { - get { - return ResourceManager.GetString("Microsoft_plugin_timedate_SettingHideNumberMessageOnGlobalQuery", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Show only 'Time', 'Date' and 'Now' result for system time on global queries. - /// </summary> - public static string Microsoft_plugin_timedate_SettingOnlyDateTimeNowGlobal { - get { - return ResourceManager.GetString("Microsoft_plugin_timedate_SettingOnlyDateTimeNowGlobal", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Regardless of this setting, for global queries the first word of the query has to be a complete match.. - /// </summary> - public static string Microsoft_plugin_timedate_SettingOnlyDateTimeNowGlobal_Description { - get { - return ResourceManager.GetString("Microsoft_plugin_timedate_SettingOnlyDateTimeNowGlobal_Description", resourceCulture); - } - } - /// <summary> /// Looks up a localized string similar to Show time with seconds. /// </summary> @@ -681,6 +825,15 @@ namespace Microsoft.CmdPal.Ext.TimeDate { } } + /// <summary> + /// Looks up a localized string similar to Select for more details.. + /// </summary> + public static string Microsoft_plugin_timedate_show_details { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_show_details", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Select or press Ctrl+C to copy. /// </summary> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx similarity index 76% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx index a3974911c4..eb248e3b1a 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Properties/Resources.resx @@ -155,11 +155,8 @@ <data name="Microsoft_plugin_timedate_EraAbbreviation" xml:space="preserve"> <value>Era abbreviation</value> </data> - <data name="Microsoft_plugin_timedate_ErrorResultSubTitle" xml:space="preserve"> - <value>Valid prefixes: 'u' for Unix Timestamp, 'ums' for Unix Timestamp in milliseconds, 'ft' for Windows file time</value> - </data> - <data name="Microsoft_plugin_timedate_ErrorResultTitle" xml:space="preserve"> - <value>Error: Invalid number input</value> + <data name="Microsoft_plugin_timedate_InvalidInput_DetailsHeader" xml:space="preserve"> + <value>Supported input</value> </data> <data name="Microsoft_plugin_timedate_Hour" xml:space="preserve"> <value>Hour</value> @@ -205,7 +202,7 @@ <comment>'UTC' means here 'Universal Time Convention'</comment> </data> <data name="Microsoft_plugin_timedate_plugin_description" xml:space="preserve"> - <value>Provides time and date values in different formats</value> + <value>Show time and date values in different formats</value> <comment>Do not translate the placeholders like '{0}' because it will be replaced in code.</comment> </data> <data name="Microsoft_plugin_timedate_plugin_description_example_calendarWeek" xml:space="preserve"> @@ -218,7 +215,7 @@ <value>Time</value> </data> <data name="Microsoft_plugin_timedate_plugin_name" xml:space="preserve"> - <value>Time and Date</value> + <value>Time and date</value> </data> <data name="Microsoft_plugin_timedate_Rfc1123" xml:space="preserve"> <value>RFC1123</value> @@ -255,10 +252,6 @@ <value>Current Time; Now</value> <comment>Don't change order</comment> </data> - <data name="Microsoft_plugin_timedate_Search_ConjunctionList" xml:space="preserve"> - <value>for; and; nor; but; or; so</value> - <comment>List of conjunctions. We don't add 'yet' because this can be a synonym of 'now' which might be problematic on localized searches.</comment> - </data> <data name="Microsoft_plugin_timedate_Second" xml:space="preserve"> <value>Second</value> </data> @@ -268,15 +261,6 @@ <data name="Microsoft_plugin_timedate_SettingDateWithWeekday_Description" xml:space="preserve"> <value>This setting applies to the 'Date' and 'Now' result.</value> </data> - <data name="Microsoft_plugin_timedate_SettingHideNumberMessageOnGlobalQuery" xml:space="preserve"> - <value>Hide 'Invalid number input' error message on global queries</value> - </data> - <data name="Microsoft_plugin_timedate_SettingOnlyDateTimeNowGlobal" xml:space="preserve"> - <value>Show only 'Time', 'Date' and 'Now' result for system time on global queries</value> - </data> - <data name="Microsoft_plugin_timedate_SettingOnlyDateTimeNowGlobal_Description" xml:space="preserve"> - <value>Regardless of this setting, for global queries the first word of the query has to be a complete match.</value> - </data> <data name="Microsoft_plugin_timedate_SettingTimeWithSeconds" xml:space="preserve"> <value>Show time with seconds</value> </data> @@ -370,9 +354,82 @@ <value>Error: Invalid input</value> </data> <data name="Microsoft_plugin_timedate_main_page_title" xml:space="preserve"> - <value>Time and Date</value> + <value>Time and date</value> </data> - <data name="Microsoft_plugin_timedate_InvalidInput_ErrorMessageSubTitle" xml:space="preserve"> - <value>Valid prefixes: 'u' for Unix Timestamp, 'ums' for Unix Timestamp in milliseconds, 'ft' for Windows file time</value> + <data name="Microsoft_plugin_timedate_InvalidInput_SupportedInput" xml:space="preserve"> + <value>A {0}format name{0}, a {0}valid date or time value{0}, or a {0}prefixed number{0}. To search for a format in a specific date/time please use the syntax {0}format::date/time/number{0}.{1}Supported prefixes:{2}'{0}u{0}' for Unix Timestamp{2}'{0}ums{0}' for Unix Timestamp in milliseconds{2}'{0}ft{0}' for Windows file time{2}'{0}oa{0}' for OLE Automation Date{2}'{0}exc{0}' for Excel's 1900 date value{2}'{0}exf{0}' for Excel's 1904 date value</value> + <comment>The placed holders are replaced with formatting syntax in code.</comment> + </data> + <data name="Microsoft_plugin_timedate_SearchTagCustom" xml:space="preserve"> + <value>Date and time; Time and Date; Custom format</value> + <comment>Don't change order</comment> + </data> + <data name="Microsoft_plugin_timedate_SearchTagCustomUtc" xml:space="preserve"> + <value>Date and time UTC; Time UTC and Date; Custom UTC format</value> + <comment>Don't change order</comment> + </data> + <data name="Microsoft_plugin_timedate_SearchTagCustomNow" xml:space="preserve"> + <value>Current date and time; Current time and date; Now; Custom format</value> + <comment>Don't change order</comment> + </data> + <data name="Microsoft_plugin_timedate_SearchTagCustomUtcNow" xml:space="preserve"> + <value>Current date and time UTC; Current time UTC and date; Now UTC; Custom UTC format</value> + <comment>Don't change order</comment> + </data> + <data name="Microsoft_plugin_timedate_InvalidCustomFormat" xml:space="preserve"> + <value>Invalid custom format:</value> + </data> + <data name="Microsoft_plugin_timedate_Setting_CustomFormats" xml:space="preserve"> + <value>Custom formats</value> + </data> + <data name="Microsoft_plugin_timedate_Setting_CustomFormatsDescription" xml:space="preserve"> + <value>Use date and time string format syntax and {0} (Day of Week), {1} (Days in Month), {2} (Week of Month), {3} (Week of the year), {4} (Era abbreviation), {5} (Windows File Time), {6} (Unix Time), {7} (Unix Time in milliseconds), {8} (OLE Automation date), {9} (Excel's 1900 based date value), {10} (Excel's 1904 based date value). If the format starts with {11}, then Universal Time (UTC) is used. (Use a backslash to escape format sequences and the backslash character as text.)</value> + <comment>The {n} parts are place holders and get replaced in the code.</comment> + </data> + <data name="Microsoft_plugin_timedate_show_details" xml:space="preserve"> + <value>Select for more details.</value> + </data> + <data name="Microsoft_plugin_timedate_ErrorConvertCustomFormat" xml:space="preserve"> + <value>Failed to convert into custom format</value> + </data> + <data name="Microsoft_plugin_timedate_ErrorConvertWft" xml:space="preserve"> + <value>Not a valid Windows file time</value> + </data> + <data name="Microsoft_plugin_timedate_InvalidInput_SupportedRange" xml:space="preserve"> + <value>Your input for {0} is outside the range **from {1} to {2}**.</value> + <comment>The placeholder will be replace in code.</comment> + </data> + <data name="Microsoft_plugin_timedate_InvalidInput_FakeExcel1900" xml:space="preserve"> + <value>Cannot parse the input as Excel's 1900 date value because it is a fake date. (In Excel 0 stands for 0/1/1900 and this date doesn't exist. And 60 stands for 2/29/1900 and this date only exists in Excel for compatibility with Lotus 123.)</value> + </data> + <data name="Microsoft_plugin_timedate_OADate" xml:space="preserve"> + <value>OLE Automation Date</value> + </data> + <data name="Microsoft_plugin_timedate_Excel1900" xml:space="preserve"> + <value>Excel's 1900 date value</value> + </data> + <data name="Microsoft_plugin_timedate_Excel1904" xml:space="preserve"> + <value>Excel's 1904 date value</value> + </data> + <data name="Microsoft_plugin_timedate_LeapYear" xml:space="preserve"> + <value>Leap year</value> + </data> + <data name="Microsoft_plugin_timedate_NoLeapYear" xml:space="preserve"> + <value>Not a leap year</value> + </data> + <data name="Microsoft_plugin_timedate_DaysInMonth" xml:space="preserve"> + <value>Days in month</value> + </data> + <data name="Microsoft_plugin_timedate_fallback_display_title" xml:space="preserve"> + <value>Open Time Data Command</value> + </data> + <data name="Microsoft_plugin_timedate_SearchTagWeek" xml:space="preserve"> + <value>Current Week; Calendar week; Week of the year; Week</value> + </data> + <data name="Microsoft_plugin_timedate_SettingEnableFallbackItems" xml:space="preserve"> + <value>Enable fallback items for TimeDate (week, year, now, time, date)</value> + </data> + <data name="Microsoft_plugin_timedate_SettingEnableFallbackItems_Description" xml:space="preserve"> + <value>Show time and date results when typing keywords like "week", "year", "now", "time", or "date"</value> </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs similarity index 87% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs index 05597c4553..8689d51078 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/TimeDateCommandsProvider.cs @@ -12,22 +12,22 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.TimeDate; -public partial class TimeDateCommandsProvider : CommandProvider +public sealed partial class TimeDateCommandsProvider : CommandProvider { private readonly CommandItem _command; - private static readonly SettingsManager _settingsManager = new(); + private static readonly SettingsManager _settingsManager = new SettingsManager(); private static readonly CompositeFormat MicrosoftPluginTimedatePluginDescription = System.Text.CompositeFormat.Parse(Resources.Microsoft_plugin_timedate_plugin_description); private static readonly TimeDateExtensionPage _timeDateExtensionPage = new(_settingsManager); + private readonly FallbackTimeDateItem _fallbackTimeDateItem = new(_settingsManager); public TimeDateCommandsProvider() { DisplayName = Resources.Microsoft_plugin_timedate_plugin_name; - Id = "DateTime"; + Id = "com.microsoft.cmdpal.builtin.datetime"; _command = new CommandItem(_timeDateExtensionPage) { Icon = _timeDateExtensionPage.Icon, Title = Resources.Microsoft_plugin_timedate_plugin_name, - Subtitle = GetTranslatedPluginDescription(), MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)], }; @@ -45,4 +45,6 @@ public partial class TimeDateCommandsProvider : CommandProvider } public override ICommandItem[] TopLevelCommands() => [_command]; + + public override IFallbackCommandItem[] FallbackCommands() => [_fallbackTimeDateItem]; } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.png new file mode 100644 index 0000000000..ff56efcd57 Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.svg new file mode 100644 index 0000000000..8253a598d5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.dark.svg @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 40 40" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <g> + <g transform="matrix(2.25,0,0,2.2493,-0.5,-4.49859)"> + <path d="M10,18C14.389,18 18,14.389 18,10C18,5.611 14.389,2 10,2C5.611,2 2,5.611 2,10C2,14.389 5.611,18 10,18" style="fill:url(#_Linear1);fill-rule:nonzero;"/> + </g> + <g transform="matrix(2.25,0,0,2.2493,-0.49775,-4.48735)"> + <path d="M7.853,2.291C7.52,2.759 7.246,3.266 7.037,3.801C6.669,4.707 6.387,5.796 6.211,7L2.58,7C2.45,7.323 2.34,7.657 2.25,8L6.09,8C5.969,9.331 5.969,10.669 6.091,12L2.251,12C2.341,12.343 2.451,12.677 2.581,13L6.211,13C6.387,14.204 6.669,15.293 7.037,16.199C7.246,16.734 7.52,17.241 7.853,17.709C8.552,17.903 9.274,18.001 10,18C10.726,18.001 11.448,17.903 12.147,17.709C12.48,17.241 12.754,16.734 12.963,16.199C13.331,15.293 13.613,14.204 13.789,13L17.419,13C17.549,12.677 17.659,12.343 17.748,12L13.908,12C14.032,10.67 14.032,9.33 13.908,8L17.748,8C17.66,7.66 17.55,7.326 17.418,7L13.79,7C13.614,5.796 13.332,4.707 12.964,3.801C12.755,3.266 12.481,2.759 12.148,2.291C11.449,2.097 10.726,1.999 10,2C9.274,1.999 8.552,2.097 7.853,2.291M7.223,7C7.389,5.924 7.643,4.965 7.963,4.178C8.261,3.445 8.605,2.886 8.966,2.518C9.324,2.153 9.672,2 10,2C10.328,2 10.676,2.153 11.034,2.518C11.394,2.886 11.739,3.445 12.037,4.178C12.357,4.965 12.611,5.924 12.777,7L7.223,7ZM10,18C10.328,18 10.676,17.847 11.034,17.482C11.394,17.114 11.739,16.555 12.037,15.822C12.357,15.035 12.611,14.076 12.777,13L7.223,13C7.39,14.076 7.644,15.035 7.964,15.822C8.262,16.555 8.606,17.114 8.967,17.482C9.325,17.847 9.673,18 10.001,18M7.001,10C7.001,10.692 7.034,11.362 7.097,12L12.905,12C12.968,11.335 13,10.668 13,10C13,9.308 12.967,8.638 12.904,8L7.096,8C7.032,8.665 7,9.332 7,10" style="fill:url(#_Radial2);"/> + </g> + <g transform="matrix(-2.25,0,0,2.22013,44.5,-3.97361)"> + <path d="M10,18C14.389,18 18,14.389 18,10C18,5.611 14.389,2 10,2C5.611,2 2,5.611 2,10C2,14.389 5.611,18 10,18" style="fill:url(#_Radial3);fill-rule:nonzero;"/> + </g> + <g transform="matrix(-2.25,0,0,2.22013,44.5,-3.97361)"> + <path d="M10,18C14.389,18 18,14.389 18,10C18,5.611 14.389,2 10,2C5.611,2 2,5.611 2,10C2,14.389 5.611,18 10,18" style="fill:url(#_Radial4);fill-rule:nonzero;"/> + </g> + <g transform="matrix(1.07186,0,0,1.07219,-1186.62,-806.111)"> + <g transform="matrix(1.71182,0,0,1.71128,1106.73,763.62)"> + <path d="M9.978,11.087C8.977,11.846 7.756,12.257 6.5,12.257C3.342,12.257 0.744,9.658 0.744,6.5C0.744,3.342 3.342,0.744 6.5,0.744C9.658,0.744 12.257,3.342 12.257,6.5C12.257,7.756 11.846,8.977 11.087,9.978L14.119,13.01C14.279,13.158 14.369,13.367 14.369,13.584C14.369,14.015 14.015,14.369 13.584,14.369C13.367,14.369 13.158,14.279 13.01,14.119L9.978,11.087Z" style="fill:url(#_Linear5);"/> + </g> + <g transform="matrix(1.71182,0,0,1.71128,1106.73,763.62)"> + <path d="M9.978,11.087C8.977,11.846 7.756,12.257 6.5,12.257C3.342,12.257 0.744,9.658 0.744,6.5C0.744,3.342 3.342,0.744 6.5,0.744C9.658,0.744 12.257,3.342 12.257,6.5C12.257,7.756 11.846,8.977 11.087,9.978L14.119,13.01C14.279,13.158 14.369,13.367 14.369,13.584C14.369,14.015 14.015,14.369 13.584,14.369C13.367,14.369 13.158,14.279 13.01,14.119L9.978,11.087Z" style="fill:url(#_Linear6);"/> + </g> + </g> + </g> + <defs> + <linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0.000311608" gradientUnits="userSpaceOnUse" gradientTransform="matrix(11.555,10.666,-10.666,11.555,5.556,4.667)"><stop offset="0" style="stop-color:rgb(41,195,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(32,82,203);stop-opacity:1"/></linearGradient> + <radialGradient id="_Radial2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(4.20189,4.66481,-4.8129,4.338,6.091,12)"><stop offset="0" style="stop-color:rgb(59,213,255);stop-opacity:0"/><stop offset="0.45" style="stop-color:rgb(59,213,255);stop-opacity:0"/><stop offset="0.82" style="stop-color:rgb(59,213,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(59,213,255);stop-opacity:1"/></radialGradient> + <radialGradient id="_Radial3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-6.72116,-6.88408e-15,6.70252e-15,-6.72116,14,13.9512)"><stop offset="0" style="stop-color:rgb(0,53,128);stop-opacity:0.7"/><stop offset="1" style="stop-color:rgb(0,53,128);stop-opacity:0"/></radialGradient> + <radialGradient id="_Radial4" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(8.37502,6.37502,-6.91548,9.08503,13.2097,13.1926)"><stop offset="0" style="stop-color:rgb(27,68,177);stop-opacity:0.2"/><stop offset="0.41" style="stop-color:rgb(27,68,177);stop-opacity:0.2"/><stop offset="1" style="stop-color:rgb(27,68,177);stop-opacity:0"/></radialGradient> + <linearGradient id="_Linear5" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-10.1905,10.1905,-10.1905,-10.1905,12.7341,0.475165)"><stop offset="0" style="stop-color:rgb(239,253,252);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(188,227,247);stop-opacity:1"/></linearGradient> + <linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-10.1905,10.1905,-10.1905,-10.1905,12.7341,0.475165)"><stop offset="0" style="stop-color:rgb(239,253,252);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(188,227,247);stop-opacity:1"/></linearGradient> + </defs> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.png new file mode 100644 index 0000000000..12d96a6d0e Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.svg new file mode 100644 index 0000000000..311f98cdef --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Assets/WebSearch.light.svg @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 40 40" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <g> + <g transform="matrix(2.25,0,0,2.2493,-0.5,-4.49859)"> + <path d="M10,18C14.389,18 18,14.389 18,10C18,5.611 14.389,2 10,2C5.611,2 2,5.611 2,10C2,14.389 5.611,18 10,18" style="fill:url(#_Linear1);fill-rule:nonzero;"/> + </g> + <g transform="matrix(2.25,0,0,2.2493,-0.49775,-4.48735)"> + <path d="M7.853,2.291C7.52,2.759 7.246,3.266 7.037,3.801C6.669,4.707 6.387,5.796 6.211,7L2.58,7C2.45,7.323 2.34,7.657 2.25,8L6.09,8C5.969,9.331 5.969,10.669 6.091,12L2.251,12C2.341,12.343 2.451,12.677 2.581,13L6.211,13C6.387,14.204 6.669,15.293 7.037,16.199C7.246,16.734 7.52,17.241 7.853,17.709C8.552,17.903 9.274,18.001 10,18C10.726,18.001 11.448,17.903 12.147,17.709C12.48,17.241 12.754,16.734 12.963,16.199C13.331,15.293 13.613,14.204 13.789,13L17.419,13C17.549,12.677 17.659,12.343 17.748,12L13.908,12C14.032,10.67 14.032,9.33 13.908,8L17.748,8C17.66,7.66 17.55,7.326 17.418,7L13.79,7C13.614,5.796 13.332,4.707 12.964,3.801C12.755,3.266 12.481,2.759 12.148,2.291C11.449,2.097 10.726,1.999 10,2C9.274,1.999 8.552,2.097 7.853,2.291M7.223,7C7.389,5.924 7.643,4.965 7.963,4.178C8.261,3.445 8.605,2.886 8.966,2.518C9.324,2.153 9.672,2 10,2C10.328,2 10.676,2.153 11.034,2.518C11.394,2.886 11.739,3.445 12.037,4.178C12.357,4.965 12.611,5.924 12.777,7L7.223,7ZM10,18C10.328,18 10.676,17.847 11.034,17.482C11.394,17.114 11.739,16.555 12.037,15.822C12.357,15.035 12.611,14.076 12.777,13L7.223,13C7.39,14.076 7.644,15.035 7.964,15.822C8.262,16.555 8.606,17.114 8.967,17.482C9.325,17.847 9.673,18 10.001,18M7.001,10C7.001,10.692 7.034,11.362 7.097,12L12.905,12C12.968,11.335 13,10.668 13,10C13,9.308 12.967,8.638 12.904,8L7.096,8C7.032,8.665 7,9.332 7,10" style="fill:url(#_Radial2);"/> + </g> + <g transform="matrix(-2.25,0,0,2.22013,44.5,-3.97361)"> + <path d="M10,18C14.389,18 18,14.389 18,10C18,5.611 14.389,2 10,2C5.611,2 2,5.611 2,10C2,14.389 5.611,18 10,18" style="fill:url(#_Radial3);fill-rule:nonzero;"/> + </g> + <g transform="matrix(-2.25,0,0,2.22013,44.5,-3.97361)"> + <path d="M10,18C14.389,18 18,14.389 18,10C18,5.611 14.389,2 10,2C5.611,2 2,5.611 2,10C2,14.389 5.611,18 10,18" style="fill:url(#_Radial4);fill-rule:nonzero;"/> + </g> + <g transform="matrix(1.07186,0,0,1.07219,-1186.62,-806.111)"> + <g transform="matrix(1.71182,0,0,1.71128,1106.73,763.62)"> + <path d="M9.978,11.087C8.977,11.846 7.756,12.257 6.5,12.257C3.342,12.257 0.744,9.658 0.744,6.5C0.744,3.342 3.342,0.744 6.5,0.744C9.658,0.744 12.257,3.342 12.257,6.5C12.257,7.756 11.846,8.977 11.087,9.978L14.119,13.01C14.279,13.158 14.369,13.367 14.369,13.584C14.369,14.015 14.015,14.369 13.584,14.369C13.367,14.369 13.158,14.279 13.01,14.119L9.978,11.087Z" style="fill:url(#_Linear5);"/> + </g> + <g transform="matrix(1.71182,0,0,1.71128,1106.73,763.62)"> + <path d="M9.978,11.087C8.977,11.846 7.756,12.257 6.5,12.257C3.342,12.257 0.744,9.658 0.744,6.5C0.744,3.342 3.342,0.744 6.5,0.744C9.658,0.744 12.257,3.342 12.257,6.5C12.257,7.756 11.846,8.977 11.087,9.978L14.119,13.01C14.279,13.158 14.369,13.367 14.369,13.584C14.369,14.015 14.015,14.369 13.584,14.369C13.367,14.369 13.158,14.279 13.01,14.119L9.978,11.087Z" style="fill:url(#_Linear6);"/> + </g> + </g> + </g> + <defs> + <linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0.000311608" gradientUnits="userSpaceOnUse" gradientTransform="matrix(11.555,10.666,-10.666,11.555,5.556,4.667)"><stop offset="0" style="stop-color:rgb(41,195,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(32,82,203);stop-opacity:1"/></linearGradient> + <radialGradient id="_Radial2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(4.20189,4.66481,-4.8129,4.338,6.091,12)"><stop offset="0" style="stop-color:rgb(59,213,255);stop-opacity:0"/><stop offset="0.45" style="stop-color:rgb(59,213,255);stop-opacity:0"/><stop offset="0.82" style="stop-color:rgb(59,213,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(59,213,255);stop-opacity:1"/></radialGradient> + <radialGradient id="_Radial3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-6.72116,-6.88408e-15,6.70252e-15,-6.72116,14,13.9512)"><stop offset="0" style="stop-color:rgb(0,53,128);stop-opacity:0.7"/><stop offset="1" style="stop-color:rgb(0,53,128);stop-opacity:0"/></radialGradient> + <radialGradient id="_Radial4" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(8.37502,6.37502,-6.91548,9.08503,13.2097,13.1926)"><stop offset="0" style="stop-color:rgb(27,68,177);stop-opacity:0.2"/><stop offset="0.41" style="stop-color:rgb(27,68,177);stop-opacity:0.2"/><stop offset="1" style="stop-color:rgb(27,68,177);stop-opacity:0"/></radialGradient> + <linearGradient id="_Linear5" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-9.10051,9.10051,-9.10051,-9.10051,11.6441,1.56519)"><stop offset="0" style="stop-color:rgb(203,247,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(153,209,239);stop-opacity:1"/></linearGradient> + <linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-9.10051,9.10051,-9.10051,-9.10051,11.6441,1.56519)"><stop offset="0" style="stop-color:rgb(203,247,244);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(153,209,239);stop-opacity:1"/></linearGradient> + </defs> +</svg> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs new file mode 100644 index 0000000000..937be16ac2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.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 Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Commands; + +internal sealed partial class OpenURLCommand : InvokableCommand +{ + private readonly IBrowserInfoService _browserInfoService; + + public string Url { get; internal set; } = string.Empty; + + internal OpenURLCommand(string url, IBrowserInfoService browserInfoService) + { + _browserInfoService = browserInfoService; + Url = url; + Icon = Icons.WebSearch; + Name = string.Empty; + } + + public override CommandResult Invoke() + { + // TODO GH# 138 --> actually display feedback from the extension somewhere. + return _browserInfoService.Open(Url) ? CommandResult.Dismiss() : CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs new file mode 100644 index 0000000000..1f5fdb8598 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs @@ -0,0 +1,71 @@ +// 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.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; +using Microsoft.CmdPal.Ext.WebSearch.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Commands; + +internal sealed partial class SearchWebCommand : InvokableCommand +{ + private readonly ISettingsInterface _settingsManager; + private readonly IBrowserInfoService _browserInfoService; + + public string Arguments { get; internal set; } + + internal SearchWebCommand(string arguments, ISettingsInterface settingsManager, IBrowserInfoService browserInfoService) + { + Arguments = arguments; + Icon = Icons.WebSearch; + Name = Resources.open_in_default_browser; + _settingsManager = settingsManager; + _browserInfoService = browserInfoService; + } + + public override CommandResult Invoke() + { + var uri = BuildUri(); + + if (!_browserInfoService.Open(uri)) + { + // TODO GH# 138 --> actually display feedback from the extension somewhere. + return CommandResult.KeepOpen(); + } + + // remember only the query, not the full URI + if (_settingsManager.HistoryItemCount != 0) + { + _settingsManager.AddHistoryItem(new HistoryItem(Arguments, DateTime.Now)); + } + + return CommandResult.Dismiss(); + } + + private string BuildUri() + { + if (string.IsNullOrWhiteSpace(_settingsManager.CustomSearchUri)) + { + return $"? " + Arguments; + } + + // if the custom search URI contains query placeholder, replace it with the actual query + // otherwise append the query to the end of the URI + // support {query}, %query% or %s as placeholder + var placeholderVariants = new[] { "{query}", "%query%", "%s" }; + foreach (var placeholder in placeholderVariants) + { + if (_settingsManager.CustomSearchUri.Contains(placeholder, StringComparison.OrdinalIgnoreCase)) + { + return _settingsManager.CustomSearchUri.Replace(placeholder, Uri.EscapeDataString(Arguments), StringComparison.OrdinalIgnoreCase); + } + } + + // is this too smart? + var separator = _settingsManager.CustomSearchUri.Contains('?') ? '&' : '?'; + return $"{_settingsManager.CustomSearchUri}{separator}q={Uri.EscapeDataString(Arguments)}"; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs new file mode 100644 index 0000000000..8ce2349e3c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.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.Globalization; +using System.Text; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; +using Microsoft.CmdPal.Ext.WebSearch.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Commands; + +internal sealed partial class FallbackExecuteSearchItem : FallbackCommandItem +{ + private const string _id = "com.microsoft.cmdpal.builtin.websearch.execute.fallback"; + private readonly SearchWebCommand _executeItem; + private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); + private static readonly CompositeFormat SubtitleText = System.Text.CompositeFormat.Parse(Properties.Resources.web_search_fallback_subtitle); + + private readonly IBrowserInfoService _browserInfoService; + + public FallbackExecuteSearchItem(ISettingsInterface settings, IBrowserInfoService browserInfoService) + : base(new SearchWebCommand(string.Empty, settings, browserInfoService) { Id = _id }, Resources.command_item_title, _id) + { + _executeItem = (SearchWebCommand)Command!; + _browserInfoService = browserInfoService; + Title = string.Empty; + Subtitle = string.Empty; + _executeItem.Name = string.Empty; + Icon = Icons.WebSearch; + } + + private static string UpdateBrowserName(IBrowserInfoService browserInfoService) + { + var browserName = browserInfoService.GetDefaultBrowser()?.Name; + return string.IsNullOrWhiteSpace(browserName) + ? Resources.open_in_default_browser + : string.Format(CultureInfo.CurrentCulture, PluginOpen, browserName); + } + + public override void UpdateQuery(string query) + { + _executeItem.Arguments = query; + var isEmpty = string.IsNullOrEmpty(query); + _executeItem.Name = isEmpty ? string.Empty : Resources.open_in_default_browser; + Title = isEmpty ? string.Empty : UpdateBrowserName(_browserInfoService); + Subtitle = string.Format(CultureInfo.CurrentCulture, SubtitleText, query); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs new file mode 100644 index 0000000000..cbb83c121d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs @@ -0,0 +1,97 @@ +// 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.Globalization; +using System.Text; +using Microsoft.CmdPal.Ext.WebSearch.Commands; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; +using Microsoft.CmdPal.Ext.WebSearch.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch; + +internal sealed partial class FallbackOpenURLItem : FallbackCommandItem +{ + private const string _id = "com.microsoft.cmdpal.builtin.websearch.openurl.fallback"; + private readonly IBrowserInfoService _browserInfoService; + private readonly OpenURLCommand _executeItem; + private static readonly CompositeFormat PluginOpenURL = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open_url); + private static readonly CompositeFormat PluginOpenUrlInBrowser = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open_url_in_browser); + + public FallbackOpenURLItem(ISettingsInterface settings, IBrowserInfoService browserInfoService) + : base(new OpenURLCommand(string.Empty, browserInfoService), Resources.open_url_fallback_title, _id) + { + ArgumentNullException.ThrowIfNull(browserInfoService); + + _browserInfoService = browserInfoService; + _executeItem = (OpenURLCommand)Command!; + Title = string.Empty; + _executeItem.Name = string.Empty; + Subtitle = string.Empty; + Icon = Icons.WebSearch; + } + + public override void UpdateQuery(string query) + { + if (!IsValidUrl(query)) + { + _executeItem.Url = string.Empty; + _executeItem.Name = string.Empty; + Title = string.Empty; + Subtitle = string.Empty; + return; + } + + var success = Uri.TryCreate(query, UriKind.Absolute, out _); + + // if url not contain schema, add http:// by default. + if (!success) + { + query = "https://" + query; + } + + _executeItem.Url = query; + _executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Resources.open_in_default_browser; + + Title = string.Format(CultureInfo.CurrentCulture, PluginOpenURL, query); + + var browserName = _browserInfoService.GetDefaultBrowser()?.Name; + Subtitle = string.IsNullOrWhiteSpace(browserName) ? Resources.open_in_default_browser : string.Format(CultureInfo.CurrentCulture, PluginOpenUrlInBrowser, browserName); + } + + private static bool IsValidUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + if (!url.Contains('.', StringComparison.OrdinalIgnoreCase)) + { + // eg: 'com', 'org'. We don't think it's a valid url. + // This can simplify the logic of checking if the url is valid. + return false; + } + + if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + return true; + } + + if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("ftp://", StringComparison.OrdinalIgnoreCase) && + !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + { + if (Uri.IsWellFormedUriString("https://" + url, UriKind.Absolute)) + { + return true; + } + } + + return false; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs new file mode 100644 index 0000000000..9da978f481 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.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.Ext.WebSearch.Helpers.Browser; + +public record BrowserInfo +{ + public required string Path { get; init; } + + public required string Name { get; init; } + + public string? ArgumentsPattern { get; init; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs new file mode 100644 index 0000000000..1614273d83 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.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 Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +/// <summary> +/// Extension methods for <see cref="IBrowserInfoService"/>. +/// </summary> +/// <seealso cref="IBrowserInfoService"/> +internal static class BrowserInfoServiceExtensions +{ + /// <summary> + /// Opens the specified URL in the system's default web browser. + /// </summary> + /// <param name="browserInfoService">The browser information service used to resolve the system's default browser.</param> + /// <param name="url">The URL to open.</param> + /// <returns> + /// <see langword="true"/> if a default browser is found and the URL launch command is issued successfully; + /// otherwise, <see langword="false"/>. + /// </returns> + /// <remarks> + /// Returns <see langword="false"/> if the default browser cannot be determined. + /// </remarks> + public static bool Open(this IBrowserInfoService browserInfoService, string url) + { + var defaultBrowser = browserInfoService.GetDefaultBrowser(); + return defaultBrowser != null && ShellHelpers.OpenCommandInShell(defaultBrowser.Path, defaultBrowser.ArgumentsPattern, url); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs new file mode 100644 index 0000000000..51312fe4c0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using ManagedCommon; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +/// <summary> +/// Service to get information about the default browser. +/// </summary> +internal class DefaultBrowserInfoService : IBrowserInfoService +{ + private static readonly IDefaultBrowserProvider[] Providers = + [ + new ShellAssociationProvider(), + new LegacyRegistryAssociationProvider(), + new FallbackMsEdgeBrowserProvider(), + ]; + + private readonly Lock _updateLock = new(); + + private readonly Dictionary<Type, string> _lastLoggedErrors = []; + + private const long UpdateTimeout = 3000; + private long _lastUpdateTickCount = -UpdateTimeout; + + private BrowserInfo? _defaultBrowser; + + public BrowserInfo? GetDefaultBrowser() + { + try + { + UpdateIfTimePassed(); + } + catch (Exception) + { + // exception is already logged at this point + } + + return _defaultBrowser; + } + + /// <summary> + /// Updates only if at least more than 3000ms has passed since the last update, to avoid multiple calls to <see cref="UpdateCore"/>. + /// (because of multiple plugins calling update at the same time.) + /// </summary> + private void UpdateIfTimePassed() + { + lock (_updateLock) + { + var curTickCount = Environment.TickCount64; + if (curTickCount - _lastUpdateTickCount < UpdateTimeout && _defaultBrowser != null) + { + return; + } + + var newDefaultBrowser = UpdateCore(); + _defaultBrowser = newDefaultBrowser; + _lastUpdateTickCount = curTickCount; + } + } + + /// <summary> + /// Consider using <see cref="UpdateIfTimePassed"/> to avoid updating multiple times. + /// (because of multiple plugins calling update at the same time.) + /// </summary> + private BrowserInfo UpdateCore() + { + foreach (var provider in Providers) + { + try + { + var result = provider.GetDefaultBrowserInfo(); +#if DEBUG + result = result with { Name = result.Name + " (" + provider.GetType().Name + ")" }; +#endif + return result; + } + catch (Exception ex) + { + // since we run this fairly often, avoid logging the same error multiple times + var lastLoggedError = _lastLoggedErrors.GetValueOrDefault(provider.GetType()); + var error = ex.ToString(); + if (error != lastLoggedError) + { + _lastLoggedErrors[provider.GetType()] = error; + Logger.LogError($"Exception when retrieving browser using provider {provider.GetType()}", ex); + } + } + } + + throw new InvalidOperationException("Unable to determine default browser"); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs new file mode 100644 index 0000000000..5d82193e5d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +/// <summary> +/// Provides functionality to retrieve information about the system's default web browser. +/// </summary> +public interface IBrowserInfoService +{ + /// <summary> + /// Gets information about the system's default web browser. + /// </summary> + /// <returns></returns> + BrowserInfo? GetDefaultBrowser(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs new file mode 100644 index 0000000000..3c6ba74d67 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.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.Ext.WebSearch.Helpers.Browser.Providers; + +internal record AssociatedApp(string? Command, string? FriendlyName); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs new file mode 100644 index 0000000000..43ed130401 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using Windows.Win32; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// <summary> +/// Base class for providers that determine the default browser via application associations. +/// </summary> +internal abstract class AssociationProviderBase : IDefaultBrowserProvider +{ + protected abstract AssociatedApp? FindAssociation(); + + public BrowserInfo GetDefaultBrowserInfo() + { + var appAssociation = FindAssociation(); + if (appAssociation is null) + { + throw new ArgumentNullException(nameof(appAssociation), "Could not determine default browser application."); + } + + var commandPattern = appAssociation.Command; + var appAndArgs = SplitAppAndArgs(commandPattern); + + if (string.IsNullOrEmpty(appAndArgs.Path)) + { + throw new ArgumentOutOfRangeException(nameof(appAndArgs.Path), "Default browser program path could not be determined."); + } + + // Packaged applications could be an URI. Example: shell:AppsFolder\Microsoft.MicrosoftEdge.Stable_8wekyb3d8bbwe!App + if (!Path.Exists(appAndArgs.Path) && !Uri.TryCreate(appAndArgs.Path, UriKind.Absolute, out _)) + { + throw new ArgumentException($"Command validation failed: {commandPattern}", nameof(commandPattern)); + } + + return new BrowserInfo + { + Path = appAndArgs.Path, + Name = appAssociation.FriendlyName ?? Path.GetFileNameWithoutExtension(appAndArgs.Path), + ArgumentsPattern = appAndArgs.Arguments, + }; + } + + private static (string? Path, string? Arguments) SplitAppAndArgs(string? commandPattern) + { + if (string.IsNullOrEmpty(commandPattern)) + { + throw new ArgumentOutOfRangeException(nameof(commandPattern), "Default browser program command is not specified."); + } + + commandPattern = GetIndirectString(commandPattern); + + // HACK: for firefox installed through Microsoft store + // When installed through Microsoft Firefox the commandPattern does not have + // quotes for the path. As the Program Files does have a space + // the extracted path would be invalid, here we add the quotes to fix it + const string FirefoxExecutableName = "firefox.exe"; + if (commandPattern.Contains(FirefoxExecutableName) && commandPattern.Contains(@"\WindowsApps\") && + !commandPattern.StartsWith('\"')) + { + var pathEndIndex = commandPattern.IndexOf(FirefoxExecutableName, StringComparison.Ordinal) + + FirefoxExecutableName.Length; + commandPattern = commandPattern.Insert(pathEndIndex, "\""); + commandPattern = commandPattern.Insert(0, "\""); + } + + if (commandPattern.StartsWith('\"')) + { + var endQuoteIndex = commandPattern.IndexOf('\"', 1); + if (endQuoteIndex != -1) + { + return (commandPattern[1..endQuoteIndex], commandPattern[(endQuoteIndex + 1)..].Trim()); + } + } + else + { + var spaceIndex = commandPattern.IndexOf(' '); + if (spaceIndex != -1) + { + return (commandPattern[..spaceIndex], commandPattern[(spaceIndex + 1)..].Trim()); + } + } + + return (null, null); + } + + protected static string GetIndirectString(string str) + { + if (string.IsNullOrEmpty(str) || str[0] != '@') + { + return str; + } + + const int initialCapacity = 128; + const int maxCapacity = 8192; // Reasonable upper limit + int hresult; + + unsafe + { + // Try with stack allocation first for common cases + var stackBuffer = stackalloc char[initialCapacity]; + + fixed (char* pszSource = str) + { + hresult = PInvoke.SHLoadIndirectString( + pszSource, + stackBuffer, + initialCapacity, + null); + + // S_OK (0) means success + if (hresult == 0) + { + return new string(stackBuffer); + } + + // STRSAFE_E_INSUFFICIENT_BUFFER (0x8007007A) means buffer too small + // Try with progressively larger heap buffers + if (unchecked((uint)hresult) == 0x8007007A) + { + for (var capacity = initialCapacity * 2; capacity <= maxCapacity; capacity *= 2) + { + var heapBuffer = new char[capacity]; + fixed (char* pBuffer = heapBuffer) + { + hresult = PInvoke.SHLoadIndirectString( + pszSource, + pBuffer, + (uint)capacity, + null); + + if (hresult == 0) + { + return new string(pBuffer); + } + + if (unchecked((uint)hresult) != 0x8007007A) + { + break; // Different error, stop retrying + } + } + } + } + } + } + + throw new InvalidOperationException( + $"Could not load indirect string. HRESULT: 0x{unchecked((uint)hresult):X8}"); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs new file mode 100644 index 0000000000..8489362004 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.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.IO; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// <summary> +/// Provides a fallback implementation of the default browser provider that returns information for Microsoft Edge. +/// </summary> +/// <remarks>This class is used when no other default browser provider is available. It supplies the path, +/// arguments pattern, and name for Microsoft Edge as the default browser information.</remarks> +internal sealed class FallbackMsEdgeBrowserProvider : IDefaultBrowserProvider +{ + private const string MsEdgeArgumentsPattern = "--single-argument %1"; + + private const string MsEdgeName = "Microsoft Edge"; + + private static string MsEdgePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + @"Microsoft\Edge\Application\msedge.exe"); + + public BrowserInfo GetDefaultBrowserInfo() => new() + { + Path = MsEdgePath, + ArgumentsPattern = MsEdgeArgumentsPattern, + Name = MsEdgeName, + }; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs new file mode 100644 index 0000000000..82a0b679fb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.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. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// <summary> +/// Retrieves information about the default browser. +/// </summary> +internal interface IDefaultBrowserProvider +{ + BrowserInfo GetDefaultBrowserInfo(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs new file mode 100644 index 0000000000..28fe40f995 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.Win32; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// <summary> +/// Provides the default web browser by reading registry keys. This is a legacy method and may not work on all systems. +/// </summary> +internal sealed class LegacyRegistryAssociationProvider : AssociationProviderBase +{ + protected override AssociatedApp? FindAssociation() + { + var progId = GetRegistryValue( + @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoiceLatest\ProgId", + "ProgId") + ?? GetRegistryValue( + @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", + "ProgId"); + var appName = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\Application", "ApplicationName") + ?? GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}", "FriendlyTypeName"); + + if (appName is not null) + { + appName = GetIndirectString(appName); + appName = appName + .Replace("URL", null, StringComparison.OrdinalIgnoreCase) + .Replace("HTML", null, StringComparison.OrdinalIgnoreCase) + .Replace("Document", null, StringComparison.OrdinalIgnoreCase) + .Replace("Web", null, StringComparison.OrdinalIgnoreCase) + .TrimEnd(); + } + + var commandPattern = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\shell\open\command", null); + + return commandPattern is null ? null : new AssociatedApp(commandPattern, appName); + + static string? GetRegistryValue(string registryLocation, string? valueName) + { + return Registry.GetValue(registryLocation, valueName, null) as string; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs new file mode 100644 index 0000000000..a70c3476d4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs @@ -0,0 +1,64 @@ +// 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.Ext.WebSearch.Helpers.Browser.Providers; + +/// <summary> +/// Retrieves the default web browser using the system shell functions. +/// </summary> +internal sealed class ShellAssociationProvider : AssociationProviderBase +{ + private static readonly string[] Protocols = ["https", "http"]; + + protected override AssociatedApp FindAssociation() + { + foreach (var protocol in Protocols) + { + var command = AssocQueryStringSafe(NativeMethods.AssocStr.Command, protocol); + if (string.IsNullOrWhiteSpace(command)) + { + continue; + } + + var appName = AssocQueryStringSafe(NativeMethods.AssocStr.FriendlyAppName, protocol); + + return new AssociatedApp(command, appName); + } + + return new AssociatedApp(null, null); + } + + private static unsafe string? AssocQueryStringSafe(NativeMethods.AssocStr what, string protocol) + { + uint cch = 0; + + // First call: get required length (incl. null) + _ = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, null, ref cch); + if (cch == 0) + { + return null; + } + + // Small buffers on stack; large on heap + var span = cch <= 512 ? stackalloc char[(int)cch] : new char[(int)cch]; + + fixed (char* p = span) + { + var hr = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, p, ref cch); + if (hr != 0 || cch == 0) + { + return null; + } + + // cch includes the null terminator; slice it off + var len = (int)cch - 1; + if (len < 0) + { + len = 0; + } + + return new string(span[..len]); + } + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs similarity index 80% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs index 84a1c249ba..d381c1e4cc 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs @@ -13,5 +13,5 @@ public class HistoryItem(string searchString, DateTime timestamp) public DateTime Timestamp { get; private set; } = timestamp; - public string ToJson() => JsonSerializer.Serialize(this); + public string ToJson() => JsonSerializer.Serialize(this, WebSearchJsonSerializationContext.Default.HistoryItem); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryStore.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryStore.cs new file mode 100644 index 0000000000..a8cba5af01 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryStore.cs @@ -0,0 +1,119 @@ +// 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.IO; +using System.Text.Json; +using System.Threading; +using ManagedCommon; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; + +internal sealed class HistoryStore +{ + private readonly string _filePath; + private readonly List<HistoryItem> _items = []; + private readonly Lock _lock = new(); + + private int _capacity; + + public event EventHandler? Changed; + + public HistoryStore(string filePath, int capacity) + { + ArgumentNullException.ThrowIfNull(filePath); + ArgumentOutOfRangeException.ThrowIfNegative(capacity); + + _filePath = filePath; + _capacity = capacity; + + _items.AddRange(LoadFromDiskSafe()); + TrimNoLock(); + } + + public IReadOnlyList<HistoryItem> HistoryItems + { + get + { + lock (_lock) + { + return [.. _items]; + } + } + } + + public void Add(HistoryItem item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (_lock) + { + _items.Add(item); + _ = TrimNoLock(); + SaveNoLock(); + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + public void SetCapacity(int capacity) + { + ArgumentOutOfRangeException.ThrowIfNegative(capacity); + + bool trimmed; + lock (_lock) + { + _capacity = capacity; + trimmed = TrimNoLock(); + if (trimmed) + { + SaveNoLock(); + } + } + + if (trimmed) + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + + private bool TrimNoLock() + { + var max = _capacity; + if (_items.Count > max) + { + _items.RemoveRange(0, _items.Count - max); + return true; + } + + return false; + } + + private List<HistoryItem> LoadFromDiskSafe() + { + try + { + if (!File.Exists(_filePath)) + { + return []; + } + + var fileContent = File.ReadAllText(_filePath); + var historyItems = JsonSerializer.Deserialize<List<HistoryItem>>(fileContent, WebSearchJsonSerializationContext.Default.ListHistoryItem) ?? []; + return historyItems; + } + catch (Exception ex) + { + Logger.LogError("Unable to load history", ex); + return []; + } + } + + private void SaveNoLock() + { + var json = JsonSerializer.Serialize(_items, WebSearchJsonSerializationContext.Default.ListHistoryItem); + File.WriteAllText(_filePath, json); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..cff6f8919d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.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 System.Collections.Generic; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; + +public interface ISettingsInterface +{ + event EventHandler? HistoryChanged; + + public bool GlobalIfURI { get; } + + public int HistoryItemCount { get; } + + public IReadOnlyList<HistoryItem> HistoryItems { get; } + + string CustomSearchUri { get; } + + public void AddHistoryItem(HistoryItem historyItem); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs new file mode 100644 index 0000000000..dee5b33fc5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; + +internal static partial class NativeMethods +{ + [LibraryImport("shlwapi.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)] + internal static unsafe partial int AssocQueryStringW( + AssocF flags, + AssocStr str, + string pszAssoc, + string? pszExtra, + char* pszOut, + ref uint pcchOut); + + [Flags] + public enum AssocF : uint + { + None = 0, + IsProtocol = 0x00001000, + } + + public enum AssocStr + { + Command = 1, + Executable, + FriendlyDocName, + FriendlyAppName, + NoOpen, + ShellNewValue, + DDECommand, + DDEIfExec, + DDEApplication, + DDETopic, + InfoTip, + QuickTip, + TileInfo, + ContentType, + DefaultIcon, + ShellExtension, + DropTarget, + DelegateExecute, + SupportedUriProtocols, + ProgId, + AppId, + AppPublisher, + AppIconReference, // sometimes present, but DefaultIcon is most common + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs new file mode 100644 index 0000000000..0af19e14c2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs @@ -0,0 +1,128 @@ +// 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.IO; +using ManagedCommon; +using Microsoft.CmdPal.Ext.WebSearch.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; + +public class SettingsManager : JsonSettingsManager, ISettingsInterface +{ + private const string HistoryItemCountLegacySettingsKey = "ShowHistory"; + private static readonly string _namespace = "websearch"; + + public event EventHandler? HistoryChanged + { + add => _history.Changed += value; + remove => _history.Changed -= value; + } + + private readonly HistoryStore _history; + + private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; + + private static readonly List<ChoiceSetSetting.Choice> _choices = + [ + new ChoiceSetSetting.Choice(Resources.history_none, "None"), + new ChoiceSetSetting.Choice(Resources.history_1, "1"), + new ChoiceSetSetting.Choice(Resources.history_5, "5"), + new ChoiceSetSetting.Choice(Resources.history_10, "10"), + new ChoiceSetSetting.Choice(Resources.history_20, "20"), + ]; + + private readonly ToggleSetting _globalIfURI = new( + Namespaced(nameof(GlobalIfURI)), + Resources.plugin_global_if_uri, + Resources.plugin_global_if_uri, + false); + + private readonly TextSetting _customSearchUri = new( + Namespaced(nameof(CustomSearchUri)), + Resources.plugin_custom_search_uri, + Resources.plugin_custom_search_uri, + string.Empty) + { + Placeholder = Resources.plugin_custom_search_uri_placeholder, + }; + + private readonly ChoiceSetSetting _historyItemCount = new( + Namespaced(HistoryItemCountLegacySettingsKey), + Resources.plugin_history_item_count, + Resources.plugin_history_item_count, + _choices); + + public bool GlobalIfURI => _globalIfURI.Value; + + public int HistoryItemCount => int.TryParse(_historyItemCount.Value, out var value) && value >= 0 ? value : 0; + + public string CustomSearchUri => _customSearchUri.Value ?? string.Empty; + + public IReadOnlyList<HistoryItem> HistoryItems => _history.HistoryItems; + + public SettingsManager() + { + FilePath = SettingsJsonPath(); + + Settings.Add(_globalIfURI); + Settings.Add(_historyItemCount); + Settings.Add(_customSearchUri); + + LoadSettings(); + + // Initialize history store after loading settings to get the correct capacity + _history = new HistoryStore(HistoryStateJsonPath(), HistoryItemCount); + + Settings.SettingsChanged += (_, _) => SaveSettings(); + } + + private static string SettingsJsonPath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + + // now, the state is just next to the exe + return Path.Combine(directory, "settings.json"); + } + + private static string HistoryStateJsonPath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + + // now, the state is just next to the exe + return Path.Combine(directory, "websearch_history.json"); + } + + public void AddHistoryItem(HistoryItem historyItem) + { + try + { + _history.Add(historyItem); + } + catch (Exception ex) + { + Logger.LogError("Failed to add item to the search history", ex); + ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() }); + } + } + + public override void SaveSettings() + { + base.SaveSettings(); + + try + { + _history.SetCapacity(HistoryItemCount); + } + catch (Exception ex) + { + Logger.LogError("Failed to save the search history", ex); + ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() }); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/WebSearchJsonSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/WebSearchJsonSerializationContext.cs new file mode 100644 index 0000000000..443c9cdf40 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/WebSearchJsonSerializationContext.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.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; + +namespace Microsoft.CmdPal.Ext.WebSearch; + +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(HistoryItem))] +[JsonSerializable(typeof(List<HistoryItem>))] +[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] +internal sealed partial class WebSearchJsonSerializationContext : JsonSerializerContext +{ +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Icons.cs new file mode 100644 index 0000000000..856d7614a2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Icons.cs @@ -0,0 +1,16 @@ +// 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.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch; + +internal static class Icons +{ + internal static IconInfo WebSearch { get; } = IconHelpers.FromRelativePaths("Assets\\WebSearch.light.png", "Assets\\WebSearch.dark.png"); + + internal static IconInfo Search { get; } = new("\uE721"); + + internal static IconInfo History { get; } = new("\uE81C"); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj similarity index 71% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj index 3ddedfcd71..843f5cf49e 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj @@ -1,5 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.WebSearch</RootNamespace> <Nullable>enable</Nullable> @@ -9,7 +11,7 @@ </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> </ItemGroup> <ItemGroup> @@ -29,13 +31,7 @@ </ItemGroup> <ItemGroup> - <None Remove="Assets\WebSearch.svg" /> - </ItemGroup> - <ItemGroup> - <Content Update="Assets\WebSearch.png"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Update="Assets\WebSearch.svg"> + <Content Update="Assets\**\*.*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> </ItemGroup> diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/NativeMethods.txt similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/NativeMethods.txt rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/NativeMethods.txt diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs new file mode 100644 index 0000000000..bf21f7c912 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Threading; +using Microsoft.CmdPal.Ext.WebSearch.Commands; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; +using Microsoft.CmdPal.Ext.WebSearch.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Pages; + +internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable +{ + private readonly ISettingsInterface _settingsManager; + private readonly IBrowserInfoService _browserInfoService; + private readonly Lock _sync = new(); + private static readonly CompositeFormat PluginInBrowserName = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_in_browser_name); + private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); + private IListItem[] _allItems = []; + private List<ListItem> _historyItems = []; + + public WebSearchListPage(ISettingsInterface settingsManager, IBrowserInfoService browserInfoService) + { + ArgumentNullException.ThrowIfNull(settingsManager); + + Name = Resources.command_item_title; + Title = Resources.command_item_title; + Icon = Icons.WebSearch; + Id = "com.microsoft.cmdpal.websearch"; + + _settingsManager = settingsManager; + _browserInfoService = browserInfoService; + _settingsManager.HistoryChanged += SettingsManagerOnHistoryChanged; + + // It just looks viewer to have string twice on the page, and default placeholder is good enough + PlaceholderText = _allItems.Length > 0 ? Resources.plugin_description : string.Empty; + + EmptyContent = new CommandItem(new NoOpCommand()) + { + Icon = Icon, + Title = Resources.plugin_description, + Subtitle = string.Format(CultureInfo.CurrentCulture, PluginInBrowserName, browserInfoService.GetDefaultBrowser()?.Name ?? Resources.default_browser), + }; + + UpdateHistory(); + RequeryAndUpdateItems(SearchText); + } + + private void SettingsManagerOnHistoryChanged(object? sender, EventArgs e) + { + UpdateHistory(); + RequeryAndUpdateItems(SearchText); + } + + private void UpdateHistory() + { + List<ListItem> history = []; + + if (_settingsManager.HistoryItemCount > 0) + { + var items = _settingsManager.HistoryItems; + for (var index = items.Count - 1; index >= 0; index--) + { + var historyItem = items[index]; + history.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, _settingsManager, _browserInfoService)) + { + Icon = Icons.History, + Title = historyItem.SearchString, + Subtitle = historyItem.Timestamp.ToString("g", CultureInfo.InvariantCulture), + }); + } + } + + lock (_sync) + { + _historyItems = history; + } + } + + private static IListItem[] Query(string query, List<ListItem> historySnapshot, ISettingsInterface settingsManager, IBrowserInfoService browserInfoService) + { + ArgumentNullException.ThrowIfNull(query); + + var filteredHistoryItems = settingsManager.HistoryItemCount > 0 + ? ListHelpers.FilterList(historySnapshot, query) + : []; + + var results = new List<IListItem>(); + + if (!string.IsNullOrEmpty(query)) + { + var searchTerm = query; + var result = new ListItem(new SearchWebCommand(searchTerm, settingsManager, browserInfoService)) + { + Title = searchTerm, + Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, browserInfoService.GetDefaultBrowser()?.Name ?? Resources.default_browser), + Icon = Icons.Search, + }; + results.Add(result); + } + + results.AddRange(filteredHistoryItems); + + return [.. results]; + } + + private void RequeryAndUpdateItems(string search) + { + List<ListItem> historySnapshot; + lock (_sync) + { + historySnapshot = _historyItems; + } + + var items = Query(search ?? string.Empty, historySnapshot, _settingsManager, _browserInfoService); + + lock (_sync) + { + _allItems = items; + } + + RaiseItemsChanged(); + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + RequeryAndUpdateItems(newSearch); + } + + public override IListItem[] GetItems() + { + lock (_sync) + { + return _allItems; + } + } + + public void Dispose() + { + _settingsManager.HistoryChanged -= SettingsManagerOnHistoryChanged; + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..b66aababe0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.WebSearch.UnitTests")] diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs similarity index 76% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs index e138b74ecc..9db0a40cac 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { // 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", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -69,6 +69,15 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { } } + /// <summary> + /// Looks up a localized string similar to default browser. + /// </summary> + public static string default_browser { + get { + return ResourceManager.GetString("default_browser", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Web Search. /// </summary> @@ -132,6 +141,15 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { } } + /// <summary> + /// Looks up a localized string similar to Open URL. + /// </summary> + public static string open_url_fallback_title { + get { + return ResourceManager.GetString("open_url_fallback_title", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to the default browser. /// </summary> @@ -141,6 +159,24 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { } } + /// <summary> + /// Looks up a localized string similar to Custom search engine URL. + /// </summary> + public static string plugin_custom_search_uri { + get { + return ResourceManager.GetString("plugin_custom_search_uri", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Use {query} or %s as the search query placeholder; e.g. https://www.bing.com/search?q={query}. + /// </summary> + public static string plugin_custom_search_uri_placeholder { + get { + return ResourceManager.GetString("plugin_custom_search_uri_placeholder", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Searches the web with your default search engine. /// </summary> @@ -159,6 +195,15 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { } } + /// <summary> + /// Looks up a localized string similar to Determines the number of history items to show from previous searches. + /// </summary> + public static string plugin_history_item_count { + get { + return ResourceManager.GetString("plugin_history_item_count", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to In the default browser. /// </summary> @@ -195,6 +240,24 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { } } + /// <summary> + /// Looks up a localized string similar to Open "{0}". + /// </summary> + public static string plugin_open_url { + get { + return ResourceManager.GetString("plugin_open_url", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open url in {0}. + /// </summary> + public static string plugin_open_url_in_browser { + get { + return ResourceManager.GetString("plugin_open_url_in_browser", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Failed to open {0}.. /// </summary> @@ -204,15 +267,6 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { } } - /// <summary> - /// Looks up a localized string similar to Determines the number of history items to show from previous searches. - /// </summary> - public static string plugin_show_history { - get { - return ResourceManager.GetString("plugin_show_history", resourceCulture); - } - } - /// <summary> /// Looks up a localized string similar to Settings. /// </summary> @@ -221,5 +275,14 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { return ResourceManager.GetString("settings_page_name", resourceCulture); } } + + /// <summary> + /// Looks up a localized string similar to Search for "{0}". + /// </summary> + public static string web_search_fallback_subtitle { + get { + return ResourceManager.GetString("web_search_fallback_subtitle", resourceCulture); + } + } } } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx similarity index 89% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx index 9caaca6c2f..c7f424c6f9 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx @@ -163,13 +163,34 @@ <data name="plugin_open" xml:space="preserve"> <value>Search the web in {0}</value> </data> + <data name="plugin_open_url" xml:space="preserve"> + <value>Open "{0}"</value> + </data> + <data name="plugin_open_url_in_browser" xml:space="preserve"> + <value>Open url in {0}</value> + </data> <data name="plugin_search_failed" xml:space="preserve"> <value>Failed to open {0}.</value> </data> - <data name="plugin_show_history" xml:space="preserve"> + <data name="plugin_history_item_count" xml:space="preserve"> <value>Determines the number of history items to show from previous searches</value> </data> <data name="settings_page_name" xml:space="preserve"> <value>Settings</value> </data> + <data name="web_search_fallback_subtitle" xml:space="preserve"> + <value>Search for "{0}"</value> + </data> + <data name="open_url_fallback_title" xml:space="preserve"> + <value>Open URL</value> + </data> + <data name="default_browser" xml:space="preserve"> + <value>default browser</value> + </data> + <data name="plugin_custom_search_uri" xml:space="preserve"> + <value>Custom search engine URL</value> + </data> + <data name="plugin_custom_search_uri_placeholder" xml:space="preserve"> + <value>Use {query} or %s as the search query placeholder; e.g. https://www.bing.com/search?q={query}</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs new file mode 100644 index 0000000000..89cfe5a183 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.CmdPal.Ext.WebSearch.Commands; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; +using Microsoft.CmdPal.Ext.WebSearch.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch; + +public sealed partial class WebSearchCommandsProvider : CommandProvider +{ + private readonly SettingsManager _settingsManager = new(); + private readonly FallbackExecuteSearchItem _fallbackItem; + private readonly FallbackOpenURLItem _openUrlFallbackItem; + private readonly WebSearchTopLevelCommandItem _webSearchTopLevelItem; + private readonly ICommandItem[] _topLevelItems; + private readonly IFallbackCommandItem[] _fallbackCommands; + private readonly IBrowserInfoService _browserInfoService = new DefaultBrowserInfoService(); + + public WebSearchCommandsProvider() + { + Id = "com.microsoft.cmdpal.builtin.websearch"; + DisplayName = Resources.extension_name; + Icon = Icons.WebSearch; + Settings = _settingsManager.Settings; + + _fallbackItem = new FallbackExecuteSearchItem(_settingsManager, _browserInfoService); + _openUrlFallbackItem = new FallbackOpenURLItem(_settingsManager, _browserInfoService); + + _webSearchTopLevelItem = new WebSearchTopLevelCommandItem(_settingsManager, _browserInfoService) + { + MoreCommands = + [ + new CommandContextItem(Settings!.SettingsPage), + ], + }; + _topLevelItems = [_webSearchTopLevelItem]; + _fallbackCommands = [_openUrlFallbackItem, _fallbackItem]; + } + + public override ICommandItem[] TopLevelCommands() => _topLevelItems; + + public override IFallbackCommandItem[]? FallbackCommands() => _fallbackCommands; + + public override void Dispose() + { + _webSearchTopLevelItem?.Dispose(); + + base.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs similarity index 57% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs index d1d2e5ccad..b2aaa95f5e 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs @@ -3,9 +3,9 @@ // See the LICENSE file in the project root for more information. using System; -using System.IO; using Microsoft.CmdPal.Ext.WebSearch.Commands; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Pages; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions; @@ -13,31 +13,45 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WebSearch; -public partial class WebSearchTopLevelCommandItem : CommandItem, IFallbackHandler +public partial class WebSearchTopLevelCommandItem : CommandItem, IFallbackHandler, IDisposable { private readonly SettingsManager _settingsManager; + private readonly IBrowserInfoService _browserInfoService; - public WebSearchTopLevelCommandItem(SettingsManager settingsManager) - : base(new WebSearchListPage(settingsManager)) + public WebSearchTopLevelCommandItem(SettingsManager settingsManager, IBrowserInfoService browserInfoService) + : base(new WebSearchListPage(settingsManager, browserInfoService)) { - Icon = IconHelpers.FromRelativePath("Assets\\WebSearch.png"); + Icon = Icons.WebSearch; SetDefaultTitle(); _settingsManager = settingsManager; + _browserInfoService = browserInfoService; } private void SetDefaultTitle() => Title = Resources.command_item_title; + private void ReplaceCommand(ICommand newCommand) + { + (Command as IDisposable)?.Dispose(); + Command = newCommand; + } + public void UpdateQuery(string query) { if (string.IsNullOrEmpty(query)) { SetDefaultTitle(); - Command = new WebSearchListPage(_settingsManager); + ReplaceCommand(new WebSearchListPage(_settingsManager, _browserInfoService)); } else { Title = query; - Command = new SearchWebCommand(query, _settingsManager); + ReplaceCommand(new SearchWebCommand(query, _settingsManager, _browserInfoService)); } } + + public void Dispose() + { + (Command as IDisposable)?.Dispose(); + GC.SuppressFinalize(this); + } } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Extension.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Extension.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Extension.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Extension.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Extension.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Extension.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Extension.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Extension.svg diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Store.dark.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Store.dark.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Store.dark.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Store.dark.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Store.dark.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Store.dark.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Store.dark.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Store.dark.svg diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Store.light.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Store.light.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Store.light.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Store.light.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Store.light.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Store.light.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/Store.light.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/Store.light.svg diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/WinGet.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/WinGet.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/WinGet.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/WinGet.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/WinGet.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/WinGet.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WinGet/Assets/WinGet.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Assets/WinGet.svg diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Icons.cs new file mode 100644 index 0000000000..524bc25581 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Icons.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 Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WinGet; + +internal sealed class Icons +{ + internal static IconInfo WinGetIcon { get; } = IconHelpers.FromRelativePath("Assets\\WinGet.svg"); + + internal static IconInfo ExtensionsIcon { get; } = IconHelpers.FromRelativePath("Assets\\Extension.svg"); + + internal static IconInfo StoreIcon { get; } = IconHelpers.FromRelativePaths("Assets\\Store.light.svg", "Assets\\Store.dark.svg"); + + internal static IconInfo CompletedIcon { get; } = new("\uE930"); // Completed + + internal static IconInfo UpdateIcon { get; } = new("\uE74A"); // Up + + internal static IconInfo DownloadIcon { get; } = new("\uE896"); // Download + + internal static IconInfo DeleteIcon { get; } = new("\uE74D"); // Delete +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj similarity index 78% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj index 7c5c6d2dd3..5b109bf34b 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Microsoft.CmdPal.Ext.WinGet.csproj @@ -1,5 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="..\Common.ExtDependencies.props" /> <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.WinGet</RootNamespace> <ApplicationManifest>app.manifest</ApplicationManifest> @@ -32,10 +33,14 @@ <Manifest Include="$(ApplicationManifest)" /> </ItemGroup> + <ItemGroup> + <AdditionalFiles Include="NativeMethods.txt" /> + <AdditionalFiles Include="NativeMethods.json" /> + </ItemGroup> + <ItemGroup> <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> - <ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" /> + <ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" /> </ItemGroup> <!-- @@ -83,25 +88,11 @@ </ItemGroup> <ItemGroup> - <Content Update="Assets\Extension.png"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Update="Assets\Extension.svg"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Update="Assets\Store.dark.png"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Update="Assets\Store.dark.svg"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Update="Assets\Store.light.png"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Update="Assets\Store.light.svg"> + <Content Update="Assets\**\*.*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> </ItemGroup> + <ItemGroup> <EmbeddedResource Update="Properties\Resources.resx"> <LastGenOutput>Resources.Designer.cs</LastGenOutput> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/NativeMethods.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/NativeMethods.json new file mode 100644 index 0000000000..02fff599f2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/NativeMethods.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false, + "comInterop": { + "preserveSigMethods": [ "*" ] + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/NativeMethods.txt b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/NativeMethods.txt similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/NativeMethods.txt rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/NativeMethods.txt diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs similarity index 83% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs index 1aa22bfa26..7dbe740d95 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageCommand.cs @@ -5,7 +5,6 @@ using System; using System.Globalization; using System.Text; -using System.Threading; using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -22,13 +21,7 @@ public partial class InstallPackageCommand : InvokableCommand private IAsyncOperationWithProgress<UninstallResult, UninstallProgress>? _unInstallAction; private Task? _installTask; - public bool IsInstalled { get; private set; } - - public static IconInfo CompletedIcon { get; } = new("\uE930"); // Completed - - public static IconInfo DownloadIcon { get; } = new("\uE896"); // Download - - public static IconInfo DeleteIcon { get; } = new("\uE74D"); // Delete + public PackageInstallCommandState InstallCommandState { get; private set; } public event EventHandler<InstallPackageCommand>? InstallStateChanged; @@ -44,35 +37,53 @@ public partial class InstallPackageCommand : InvokableCommand internal bool SkipDependencies { get; set; } - public InstallPackageCommand(CatalogPackage package, bool isInstalled) + public InstallPackageCommand(CatalogPackage package, PackageInstallCommandState isInstalled) { _package = package; - IsInstalled = isInstalled; + InstallCommandState = isInstalled; UpdateAppearance(); } internal void FakeChangeStatus() { - IsInstalled = !IsInstalled; + InstallCommandState = InstallCommandState switch + { + PackageInstallCommandState.Install => PackageInstallCommandState.Uninstall, + PackageInstallCommandState.Update => PackageInstallCommandState.Uninstall, + PackageInstallCommandState.Uninstall => PackageInstallCommandState.Install, + _ => throw new NotImplementedException(), + }; UpdateAppearance(); } private void UpdateAppearance() { - Icon = IsInstalled ? CompletedIcon : DownloadIcon; - Name = IsInstalled ? Properties.Resources.winget_uninstall_name : Properties.Resources.winget_install_name; + Icon = InstallCommandState switch + { + PackageInstallCommandState.Install => Icons.DownloadIcon, + PackageInstallCommandState.Update => Icons.UpdateIcon, + PackageInstallCommandState.Uninstall => Icons.DeleteIcon, + _ => throw new NotImplementedException(), + }; + Name = InstallCommandState switch + { + PackageInstallCommandState.Install => Properties.Resources.winget_install_name, + PackageInstallCommandState.Update => Properties.Resources.winget_update_name, + PackageInstallCommandState.Uninstall => Properties.Resources.winget_uninstall_name, + _ => throw new NotImplementedException(), + }; } public override ICommandResult Invoke() { // TODO: LOCK in here, so this can only be invoked once until the // install / uninstall is done. Just use like, an atomic - if (_installTask != null) + if (_installTask is not null) { return CommandResult.KeepOpen(); } - if (IsInstalled) + if (InstallCommandState == PackageInstallCommandState.Uninstall) { // Uninstall _installBanner.State = MessageState.Info; @@ -88,7 +99,8 @@ public partial class InstallPackageCommand : InvokableCommand _installTask = Task.Run(() => TryDoInstallOperation(_unInstallAction)); } - else + else if (InstallCommandState is PackageInstallCommandState.Install or + PackageInstallCommandState.Update) { // Install _installBanner.State = MessageState.Info; @@ -117,7 +129,8 @@ public partial class InstallPackageCommand : InvokableCommand try { await action.AsTask(); - _installBanner.Message = IsInstalled ? + + _installBanner.Message = InstallCommandState == PackageInstallCommandState.Uninstall ? string.Format(CultureInfo.CurrentCulture, UninstallPackageFinished, _package.Name) : string.Format(CultureInfo.CurrentCulture, InstallPackageFinished, _package.Name); @@ -125,10 +138,11 @@ public partial class InstallPackageCommand : InvokableCommand _installBanner.State = MessageState.Success; _installTask = null; - _ = Task.Run(() => + _ = Task.Run(async () => { - Thread.Sleep(2500); - if (_installTask == null) + await Task.Delay(2500).ConfigureAwait(false); + + if (_installTask is null) { WinGetExtensionHost.Instance.HideStatus(_installBanner); } @@ -228,3 +242,10 @@ public partial class InstallPackageCommand : InvokableCommand } } } + +public enum PackageInstallCommandState +{ + Uninstall = 0, + Update = 1, + Install = 2, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs new file mode 100644 index 0000000000..eda0d3fee3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs @@ -0,0 +1,338 @@ +// 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.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.Management.Deployment; +using Windows.Foundation.Metadata; + +namespace Microsoft.CmdPal.Ext.WinGet.Pages; + +public partial class InstallPackageListItem : ListItem +{ + private readonly CatalogPackage _package; + + // Lazy-init the details + private readonly Lazy<Details?> _details; + + public override IDetails? Details { get => _details.Value; set => base.Details = value; } + + private InstallPackageCommand? _installCommand; + + public InstallPackageListItem(CatalogPackage package) + : base(new NoOpCommand()) + { + _package = package; + + PackageVersionInfo? version = null; + try + { + version = _package.DefaultInstallVersion ?? _package.InstalledVersion; + } + catch (Exception e) + { + Logger.LogError("Could not get package version", e); + } + + var versionTagText = "Unknown"; + if (version is not null) + { + versionTagText = version.Version == "Unknown" && version.PackageCatalog.Info.Id == "StoreEdgeFD" ? "msstore" : version.Version; + } + + Title = _package.Name; + Subtitle = _package.Id; + Tags = [new Tag() { Text = versionTagText }]; + + _details = new Lazy<Details?>(() => BuildDetails(version)); + + _ = Task.Run(UpdatedInstalledStatus); + } + + private Details? BuildDetails(PackageVersionInfo? version) + { + CatalogPackageMetadata? metadata = null; + try + { + metadata = version?.GetCatalogPackageMetadata(); + } + catch (COMException ex) + { + Logger.LogWarning($"GetCatalogPackageMetadata error {ex.ErrorCode}"); + } + + if (metadata is not null) + { + for (var i = 0; i < metadata.Tags.Count; i++) + { + if (metadata.Tags[i].Equals(WinGetExtensionPage.ExtensionsTag, StringComparison.OrdinalIgnoreCase)) + { + if (_installCommand is not null) + { + _installCommand.SkipDependencies = true; + } + + break; + } + } + + var description = string.IsNullOrEmpty(metadata.Description) ? + metadata.ShortDescription : + metadata.Description; + var detailsBody = $""" + +{description} +"""; + IconInfo heroIcon = new(string.Empty); + var icons = metadata.Icons; + if (icons.Count > 0) + { + // There's also a .Theme property we could probably use to + // switch between default or individual icons. + heroIcon = new IconInfo(icons[0].Url); + } + + return new Details() + { + Body = detailsBody, + Title = metadata.PackageName, + HeroImage = heroIcon, + Metadata = GetDetailsMetadata(metadata).ToArray(), + }; + } + + return null; + } + + private List<IDetailsElement> GetDetailsMetadata(CatalogPackageMetadata metadata) + { + List<IDetailsElement> detailsElements = []; + + // key -> {text, url} + Dictionary<string, (string, string)> simpleData = new() + { + { Properties.Resources.winget_author, (metadata.Author, string.Empty) }, + { Properties.Resources.winget_publisher, (metadata.Publisher, metadata.PublisherUrl) }, + { Properties.Resources.winget_copyright, (metadata.Copyright, metadata.CopyrightUrl) }, + { Properties.Resources.winget_license, (metadata.License, metadata.LicenseUrl) }, + { Properties.Resources.winget_publisher_support, (string.Empty, metadata.PublisherSupportUrl) }, + + // The link to the release notes will only show up if there is an + // actual URL for the release notes + { Properties.Resources.winget_view_release_notes, (string.IsNullOrEmpty(metadata.ReleaseNotesUrl) ? string.Empty : Properties.Resources.winget_view_online, metadata.ReleaseNotesUrl) }, + + // These can be l o n g + { Properties.Resources.winget_release_notes, (metadata.ReleaseNotes, string.Empty) }, + }; + + try + { + var docs = metadata.Documentations; + var count = docs.Count; + for (var i = 0; i < count; i++) + { + var item = docs[i]; + simpleData.Add(item.DocumentLabel, (string.Empty, item.DocumentUrl)); + } + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to retrieve documentations from metadata: {ex.Message}"); + } + + UriCreationOptions options = default; + foreach (var kv in simpleData) + { + var text = string.IsNullOrEmpty(kv.Value.Item1) ? kv.Value.Item2 : kv.Value.Item1; + var target = kv.Value.Item2; + if (!string.IsNullOrEmpty(text)) + { + Uri? uri = null; + Uri.TryCreate(target, options, out uri); + + DetailsElement pair = new() + { + Key = kv.Key, + Data = new DetailsLink() { Link = uri, Text = text }, + }; + detailsElements.Add(pair); + } + } + + try + { + if (metadata.Tags.Count > 0) + { + DetailsElement pair = new() + { + Key = "Tags", + Data = new DetailsTags() { Tags = metadata.Tags.Select(t => new Tag(t)).ToArray() }, + }; + detailsElements.Add(pair); + } + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to retrieve tags from metadata: {ex.Message}"); + } + + return detailsElements; + } + + private async void UpdatedInstalledStatus() + { + try + { + var status = await _package.CheckInstalledStatusAsync(); + } + catch (OperationCanceledException) + { + // DO NOTHING HERE + return; + } + catch (Exception ex) + { + // Handle other exceptions + ExtensionHost.LogMessage($"[WinGet] UpdatedInstalledStatus throw exception: {ex.Message}"); + Logger.LogError($"[WinGet] UpdatedInstalledStatus throw exception", ex); + return; + } + + var isInstalled = _package.InstalledVersion is not null; + + var installedState = isInstalled ? + (_package.IsUpdateAvailable ? PackageInstallCommandState.Update : PackageInstallCommandState.Uninstall) : + PackageInstallCommandState.Install; + + // might be an uninstall command + InstallPackageCommand installCommand = new(_package, installedState); + + if (_package.InstalledVersion is not null) + { +#if DEBUG + var installerType = _package.InstalledVersion.GetMetadata(PackageVersionMetadataField.InstallerType); + Subtitle = installerType + " | " + Subtitle; +#endif + + List<IContextItem> contextMenu = []; + Command = installCommand; + Icon = installedState switch + { + PackageInstallCommandState.Install => Icons.DownloadIcon, + PackageInstallCommandState.Update => Icons.UpdateIcon, + PackageInstallCommandState.Uninstall => Icons.CompletedIcon, + _ => Icons.DownloadIcon, + }; + + TryLocateAndAppendActionForApp(contextMenu); + + MoreCommands = contextMenu.ToArray(); + } + else + { + _installCommand = new InstallPackageCommand(_package, installedState); + _installCommand.InstallStateChanged += InstallStateChangedHandler; + Command = _installCommand; + Icon = _installCommand.Icon; + } + } + + private void TryLocateAndAppendActionForApp(List<IContextItem> contextMenu) + { + try + { + // Let's try to connect it to an installed app if possible + // This is a bit of dark magic, since there's no direct link between + // WinGet packages and installed apps. + var lookupByPackageName = WinGetStatics.AppSearchByPackageFamilyNameCallback; + if (lookupByPackageName is not null) + { + var names = _package.InstalledVersion.PackageFamilyNames; + for (var i = 0; i < names.Count; i++) + { + var installedAppByPfn = lookupByPackageName(names[i]); + if (installedAppByPfn is not null) + { + contextMenu.Add(new Separator()); + contextMenu.Add(new CommandContextItem(installedAppByPfn.Command)); + foreach (var item in installedAppByPfn.MoreCommands) + { + contextMenu.Add(item); + } + + return; + } + } + } + + var lookupByProductCode = WinGetStatics.AppSearchByProductCodeCallback; + if (lookupByProductCode is not null) + { + var productCodes = _package.InstalledVersion.ProductCodes; + for (var i = 0; i < productCodes.Count; i++) + { + var installedAppByProductCode = lookupByProductCode(productCodes[i]); + if (installedAppByProductCode is not null) + { + contextMenu.Add(new Separator()); + contextMenu.Add(new CommandContextItem(installedAppByProductCode.Command)); + foreach (var item in installedAppByProductCode.MoreCommands) + { + contextMenu.Add(item); + } + + return; + } + } + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to retrieve app context menu items for package '{_package?.Name ?? "Unknown"}'", ex); + } + } + + private void InstallStateChangedHandler(object? sender, InstallPackageCommand e) + { + if (!ApiInformation.IsApiContractPresent("Microsoft.Management.Deployment.WindowsPackageManagerContract", 12)) + { + Logger.LogError($"RefreshPackageCatalogAsync isn't available"); + e.FakeChangeStatus(); + Command = e; + Icon = (IconInfo?)Command.Icon; + return; + } + + _ = Task.Run(() => + { + Stopwatch s = new(); + Logger.LogDebug($"Starting RefreshPackageCatalogAsync"); + s.Start(); + var refs = WinGetStatics.AvailableCatalogs; + for (var i = 0; i < refs.Count; i++) + { + var catalog = refs[i]; + var operation = catalog.RefreshPackageCatalogAsync(); + operation.Wait(); + } + + s.Stop(); + Logger.LogDebug($"RefreshPackageCatalogAsync took {s.ElapsedMilliseconds}ms"); + }).ContinueWith((previous) => + { + if (previous.IsCompletedSuccessfully) + { + Logger.LogDebug($"Updating InstalledStatus"); + UpdatedInstalledStatus(); + } + }); + } +} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs similarity index 54% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs index a645ff9e8e..e84802b8fa 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Pages/WinGetExtensionPage.cs @@ -28,15 +28,12 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable public bool HasTag => !string.IsNullOrEmpty(_tag); private readonly Lock _resultsLock = new(); + private readonly Lock _taskLock = new(); - private CancellationTokenSource? _cancellationTokenSource; - private Task<IEnumerable<CatalogPackage>>? _currentSearchTask; + private string? _nextSearchQuery; + private bool _isTaskRunning; - private IEnumerable<CatalogPackage>? _results; - - public static IconInfo WinGetIcon { get; } = IconHelpers.FromRelativePath("Assets\\WinGet.svg"); - - public static IconInfo ExtensionsIcon { get; } = IconHelpers.FromRelativePath("Assets\\Extension.svg"); + private List<CatalogPackage>? _results; public static string ExtensionsTag => "windows-commandpalette-extension"; @@ -44,7 +41,7 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable public WinGetExtensionPage(string tag = "") { - Icon = tag == ExtensionsTag ? ExtensionsIcon : WinGetIcon; + Icon = tag == ExtensionsTag ? Icons.ExtensionsIcon : Icons.WinGetIcon; Name = Properties.Resources.winget_page_name; _tag = tag; ShowDetails = true; @@ -52,12 +49,11 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable public override IListItem[] GetItems() { - IListItem[] items = []; lock (_resultsLock) { // emptySearchForTag === // we don't have results yet, we haven't typed anything, and we're searching for a tag - bool emptySearchForTag = _results == null && + var emptySearchForTag = _results is null && string.IsNullOrEmpty(SearchText) && HasTag; @@ -65,28 +61,44 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable { IsLoading = true; DoUpdateSearchText(string.Empty); - return items; + return []; } - if (_results != null && _results.Any()) + if (_results is not null && _results.Count != 0) { - ListItem[] results = _results.Select(PackageToListItem).ToArray(); - IsLoading = false; + var stopwatch = Stopwatch.StartNew(); + var count = _results.Count; + var results = new ListItem[count]; + var next = 0; + for (var i = 0; i < count; i++) + { + try + { + var li = PackageToListItem(_results[i]); + results[next] = li; + next++; + } + catch (Exception ex) + { + Logger.LogError("error converting result to listitem", ex); + } + } + + stopwatch.Stop(); + Logger.LogDebug($"Building ListItems took {stopwatch.ElapsedMilliseconds}ms", memberName: nameof(GetItems)); return results; } } EmptyContent = new CommandItem(new NoOpCommand()) { - Icon = WinGetIcon, + Icon = Icons.WinGetIcon, Title = (string.IsNullOrEmpty(SearchText) && !HasTag) ? Properties.Resources.winget_placeholder_text : Properties.Resources.winget_no_packages_found, }; - IsLoading = false; - - return items; + return []; } private static ListItem PackageToListItem(CatalogPackage p) => new InstallPackageListItem(p); @@ -103,50 +115,70 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable private void DoUpdateSearchText(string newSearch) { - // Cancel any ongoing search - if (_cancellationTokenSource != null) + lock (_taskLock) { - Logger.LogDebug("Cancelling old search", memberName: nameof(DoUpdateSearchText)); - _cancellationTokenSource.Cancel(); - } - - _cancellationTokenSource = new CancellationTokenSource(); - - CancellationToken cancellationToken = _cancellationTokenSource.Token; - - IsLoading = true; - - // Save the latest search task - _currentSearchTask = DoSearchAsync(newSearch, cancellationToken); - - // Await the task to ensure only the latest one gets processed - _ = ProcessSearchResultsAsync(_currentSearchTask, newSearch); - } - - private async Task ProcessSearchResultsAsync( - Task<IEnumerable<CatalogPackage>> searchTask, - string newSearch) - { - try - { - IEnumerable<CatalogPackage> results = await searchTask; - - // Ensure this is still the latest task - if (_currentSearchTask == searchTask) + if (_isTaskRunning) { - // Process the results (e.g., update UI) - UpdateWithResults(results, newSearch); + // If a task is running, queue the next search query + // Keep IsLoading = true since we still have work to do + Logger.LogDebug($"Task is running, queueing next search: '{newSearch}'", memberName: nameof(DoUpdateSearchText)); + _nextSearchQuery = newSearch; + } + else + { + // No task is running, start a new search + Logger.LogDebug($"Starting new search: '{newSearch}'", memberName: nameof(DoUpdateSearchText)); + _isTaskRunning = true; + _nextSearchQuery = null; + IsLoading = true; + + _ = ExecuteSearchChainAsync(newSearch); } } - catch (OperationCanceledException) + } + + private async Task ExecuteSearchChainAsync(string query) + { + while (true) { - // Handle cancellation gracefully (e.g., log or ignore) - Logger.LogDebug($" Cancelled search for '{newSearch}'"); - } - catch (Exception ex) - { - // Handle other exceptions - Logger.LogError(ex.Message); + try + { + Logger.LogDebug($"Executing search for '{query}'", memberName: nameof(ExecuteSearchChainAsync)); + + var results = await DoSearchAsync(query); + + // Update UI with results + UpdateWithResults(results, query); + } + catch (Exception ex) + { + Logger.LogError($"Unexpected error while searching for '{query}'", ex); + } + + // Check if there's a next query to process + string? nextQuery; + lock (_taskLock) + { + if (_nextSearchQuery is not null) + { + // There's a queued search, execute it + nextQuery = _nextSearchQuery; + _nextSearchQuery = null; + + Logger.LogDebug($"Found queued search, continuing with: '{nextQuery}'", memberName: nameof(ExecuteSearchChainAsync)); + } + else + { + // No more searches queued, mark task as completed + _isTaskRunning = false; + IsLoading = false; + Logger.LogDebug("No more queued searches, task chain completed", memberName: nameof(ExecuteSearchChainAsync)); + break; + } + } + + // Continue with the next query + query = nextQuery; } } @@ -155,17 +187,14 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable Logger.LogDebug($"Completed search for '{query}'"); lock (_resultsLock) { - this._results = results; + this._results = results.ToList(); } - RaiseItemsChanged(this._results.Count()); + RaiseItemsChanged(); } - private async Task<IEnumerable<CatalogPackage>> DoSearchAsync(string query, CancellationToken ct) + private async Task<IEnumerable<CatalogPackage>> DoSearchAsync(string query) { - // Were we already canceled? - ct.ThrowIfCancellationRequested(); - Stopwatch stopwatch = new(); stopwatch.Start(); @@ -175,17 +204,17 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable return []; } - string searchDebugText = $"{query}{(HasTag ? "+" : string.Empty)}{_tag}"; + var searchDebugText = $"{query}{(HasTag ? "+" : string.Empty)}{_tag}"; Logger.LogDebug($"Starting search for '{searchDebugText}'"); HashSet<CatalogPackage> results = new(new PackageIdCompare()); // Default selector: this is the way to do a `winget search <query>` - PackageMatchFilter selector = WinGetStatics.WinGetFactory.CreatePackageMatchFilter(); + var selector = WinGetStatics.WinGetFactory.CreatePackageMatchFilter(); selector.Field = Microsoft.Management.Deployment.PackageMatchField.CatalogDefault; selector.Value = query; selector.Option = PackageFieldMatchOption.ContainsCaseInsensitive; - FindPackagesOptions opts = WinGetStatics.WinGetFactory.CreateFindPackagesOptions(); + var opts = WinGetStatics.WinGetFactory.CreateFindPackagesOptions(); opts.Selectors.Add(selector); // testing @@ -194,7 +223,7 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable // Selectors is "OR", Filters is "AND" if (HasTag) { - PackageMatchFilter tagFilter = WinGetStatics.WinGetFactory.CreatePackageMatchFilter(); + var tagFilter = WinGetStatics.WinGetFactory.CreatePackageMatchFilter(); tagFilter.Field = Microsoft.Management.Deployment.PackageMatchField.Tag; tagFilter.Value = _tag; tagFilter.Option = PackageFieldMatchOption.ContainsCaseInsensitive; @@ -202,16 +231,13 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable opts.Filters.Add(tagFilter); } - // Clean up here, then... - ct.ThrowIfCancellationRequested(); - - Lazy<Task<PackageCatalog>> catalogTask = HasTag ? WinGetStatics.CompositeWingetCatalog : WinGetStatics.CompositeAllCatalog; + var catalogTask = HasTag ? WinGetStatics.CompositeWingetCatalog : WinGetStatics.CompositeAllCatalog; // Both these catalogs should have been instantiated by the // WinGetStatics static ctor when we were created. - PackageCatalog catalog = await catalogTask.Value; + var catalog = await catalogTask.Value; - if (catalog == null) + if (catalog is null) { // This error should have already been displayed by WinGetStatics return []; @@ -219,14 +245,19 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable // foreach (var catalog in connections) { + Stopwatch findPackages_stopwatch = new(); + findPackages_stopwatch.Start(); Logger.LogDebug($" Searching {catalog.Info.Name} ({query})", memberName: nameof(DoSearchAsync)); - ct.ThrowIfCancellationRequested(); + Logger.LogDebug($"Preface for \"{searchDebugText}\" took {stopwatch.ElapsedMilliseconds}ms", memberName: nameof(DoSearchAsync)); // BODGY, re: microsoft/winget-cli#5151 // FindPackagesAsync isn't actually async. - Task<FindPackagesResult> internalSearchTask = Task.Run(() => catalog.FindPackages(opts), ct); - FindPackagesResult searchResults = await internalSearchTask; + var internalSearchTask = Task.Run(() => catalog.FindPackages(opts)); + var searchResults = await internalSearchTask; + + findPackages_stopwatch.Stop(); + Logger.LogDebug($"FindPackages for \"{searchDebugText}\" took {findPackages_stopwatch.ElapsedMilliseconds}ms", memberName: nameof(DoSearchAsync)); // TODO more error handling like this: if (searchResults.Status != FindPackagesResultStatus.Ok) @@ -237,13 +268,15 @@ internal sealed partial class WinGetExtensionPage : DynamicListPage, IDisposable } Logger.LogDebug($" got results for ({query})", memberName: nameof(DoSearchAsync)); - foreach (Management.Deployment.MatchResult? match in searchResults.Matches.ToArray()) + + // FYI Using .ToArray or any other kind of enumerable loop + // on arrays returned by the winget API are NOT trim safe + var count = searchResults.Matches.Count; + for (var i = 0; i < count; i++) { - ct.ThrowIfCancellationRequested(); - - // Print the packages - CatalogPackage package = match.CatalogPackage; + var match = searchResults.Matches[i]; + var package = match.CatalogPackage; results.Add(package); } diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs similarity index 96% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs index f62a291d36..e4567a30d4 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.Designer.cs @@ -124,16 +124,7 @@ namespace Microsoft.CmdPal.Ext.WinGet.Properties { } /// <summary> - /// Looks up a localized string similar to Search for extensions on WinGet. - /// </summary> - public static string winget_install_extensions_subtitle { - get { - return ResourceManager.GetString("winget_install_extensions_subtitle", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Install Command Palette extensions. + /// Looks up a localized string similar to Add Command Palette extensions from WinGet. /// </summary> public static string winget_install_extensions_title { get { @@ -205,7 +196,7 @@ namespace Microsoft.CmdPal.Ext.WinGet.Properties { } /// <summary> - /// Looks up a localized string similar to Search WinGet. + /// Looks up a localized string similar to Find apps on WinGet. /// </summary> public static string winget_page_name { get { @@ -268,7 +259,7 @@ namespace Microsoft.CmdPal.Ext.WinGet.Properties { } /// <summary> - /// Looks up a localized string similar to Search for extensions in the Store. + /// Looks up a localized string similar to Add Command Palette extensions from the Microsoft Store. /// </summary> public static string winget_search_store_title { get { @@ -330,6 +321,15 @@ namespace Microsoft.CmdPal.Ext.WinGet.Properties { } } + /// <summary> + /// Looks up a localized string similar to Update. + /// </summary> + public static string winget_update_name { + get { + return ResourceManager.GetString("winget_update_name", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to View online. /// </summary> diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx similarity index 97% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx index 2be07cec68..4a6df0b518 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/Properties/Resources.resx @@ -127,19 +127,15 @@ <comment></comment> </data> <data name="winget_install_extensions_title" xml:space="preserve"> - <value>Install Command Palette extensions</value> - <comment></comment> -</data> -<data name="winget_install_extensions_subtitle" xml:space="preserve"> - <value>Search for extensions on WinGet</value> + <value>Add Command Palette extensions from WinGet</value> <comment></comment> </data> <data name="winget_search_store_title" xml:space="preserve"> - <value>Search for extensions in the Store</value> + <value>Add Command Palette extensions from the Microsoft Store</value> <comment></comment> </data> <data name="winget_page_name" xml:space="preserve"> - <value>Search WinGet</value> + <value>Find apps on WinGet</value> <comment></comment> </data> <data name="winget_create_catalog_error" xml:space="preserve"> @@ -154,6 +150,10 @@ <value>Install</value> <comment></comment> </data> +<data name="winget_update_name" xml:space="preserve"> + <value>Update</value> + <comment></comment> +</data> <data name="winget_uninstalling_package" xml:space="preserve"> <value>Uninstalling {0}...</value> <comment>{0} will be replaced by the name of an app package</comment> diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs similarity index 79% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs index 28586b07ee..a2608ef8a8 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionCommandsProvider.cs @@ -15,7 +15,7 @@ public partial class WinGetExtensionCommandsProvider : CommandProvider { DisplayName = Properties.Resources.winget_display_name; Id = "WinGet"; - Icon = WinGetExtensionPage.WinGetIcon; + Icon = Icons.WinGetIcon; _ = WinGetStatics.Manager; } @@ -27,14 +27,13 @@ public partial class WinGetExtensionCommandsProvider : CommandProvider new WinGetExtensionPage(WinGetExtensionPage.ExtensionsTag) { Title = Properties.Resources.winget_install_extensions_title }) { Title = Properties.Resources.winget_install_extensions_title, - Subtitle = Properties.Resources.winget_install_extensions_subtitle, }, new ListItem( new OpenUrlCommand("ms-windows-store://assoc/?Tags=AppExtension-com.microsoft.commandpalette")) { Title = Properties.Resources.winget_search_store_title, - Icon = IconHelpers.FromRelativePaths("Assets\\Store.light.svg", "Assets\\Store.dark.svg"), + Icon = Icons.StoreIcon, }, ]; @@ -42,5 +41,9 @@ public partial class WinGetExtensionCommandsProvider : CommandProvider public override void InitializeWithHost(IExtensionHost host) => WinGetExtensionHost.Instance.Initialize(host); - public void SetAllLookup(Func<string, ICommandItem?> callback) => WinGetStatics.AppSearchCallback = callback; + public void SetAllLookup(Func<string, ICommandItem?> lookupByPackageName, Func<string, ICommandItem?> lookupByProductCode) + { + WinGetStatics.AppSearchByPackageFamilyNameCallback = lookupByPackageName; + WinGetStatics.AppSearchByProductCodeCallback = lookupByProductCode; + } } diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionHost.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionHost.cs similarity index 90% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionHost.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionHost.cs index 4a8d28ac32..d1c2ea7e1f 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionHost.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetExtensionHost.cs @@ -2,7 +2,7 @@ // 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.Common; +using Microsoft.CmdPal.Core.Common; namespace Microsoft.CmdPal.Ext.WinGet; diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs similarity index 88% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs index 409017167f..001ba5539d 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WinGetStatics.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.Management.Deployment; +using Windows.Foundation.Metadata; using WindowsPackageManager.Interop; namespace Microsoft.CmdPal.Ext.WinGet; @@ -33,7 +34,9 @@ internal static class WinGetStatics private static readonly StatusMessage _errorMessage = new() { State = MessageState.Error }; - public static Func<string, ICommandItem?>? AppSearchCallback { get; set; } + public static Func<string, ICommandItem?>? AppSearchByPackageFamilyNameCallback { get; set; } + + public static Func<string, ICommandItem?>? AppSearchByProductCodeCallback { get; set; } private static readonly CompositeFormat CreateCatalogErrorMessage = System.Text.CompositeFormat.Parse(Properties.Resources.winget_create_catalog_error); @@ -51,9 +54,12 @@ internal static class WinGetStatics _storeCatalog, ]; - foreach (var catalogReference in AvailableCatalogs) + if (ApiInformation.IsApiContractPresent("Microsoft.Management.Deployment.WindowsPackageManagerContract", 8)) { - catalogReference.PackageCatalogBackgroundUpdateInterval = new(0); + foreach (var catalogReference in AvailableCatalogs) + { + catalogReference.PackageCatalogBackgroundUpdateInterval = new(0); + } } // Immediately start the lazy-init of the all packages catalog, but diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClassModel.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClassModel.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClassModel.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClassModel.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClassesDefinition.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClassesDefinition.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClassesDefinition.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClassesDefinition.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClsidContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClsidContext.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClsidContext.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/ClsidContext.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerFactory.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerFactory.cs similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerFactory.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerFactory.cs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerStandardFactory.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerStandardFactory.cs similarity index 54% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerStandardFactory.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerStandardFactory.cs index d8b6064a86..677f688ac6 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerStandardFactory.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/WindowsPackageManager.Interop/WindowsPackageManagerStandardFactory.cs @@ -20,20 +20,25 @@ public class WindowsPackageManagerStandardFactory : WindowsPackageManagerFactory protected override T CreateInstance<T>(Guid clsid, Guid iid) { var pUnknown = IntPtr.Zero; - try + unsafe { - var hr = PInvoke.CoCreateInstance(clsid, null, CLSCTX.CLSCTX_ALL, iid, out var result); - Marshal.ThrowExceptionForHR(hr); - pUnknown = Marshal.GetIUnknownForObject(result); - return MarshalGeneric<T>.FromAbi(pUnknown); - } - finally - { - // CoCreateInstance and FromAbi both AddRef on the native object. - // Release once to prevent memory leak. - if (pUnknown != IntPtr.Zero) + try { - Marshal.Release(pUnknown); + var hr = PInvoke.CoCreateInstance(clsid, null, CLSCTX.CLSCTX_ALL, iid, out var result); + Marshal.ThrowExceptionForHR(hr); + + pUnknown = new IntPtr(result); + + return MarshalGeneric<T>.FromAbi(pUnknown); + } + finally + { + // CoCreateInstance and FromAbi both AddRef on the native object. + // Release once to prevent memory leak. + if (pUnknown != IntPtr.Zero) + { + Marshal.Release(pUnknown); + } } } } diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/app.manifest b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/app.manifest similarity index 91% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/app.manifest rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/app.manifest index bcafb9bc5b..16492c3b79 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/app.manifest +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WinGet/app.manifest @@ -13,6 +13,7 @@ <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Assets/WindowWalker.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Assets/WindowWalker.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Assets/WindowWalker.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Assets/WindowWalker.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Assets/WindowWalker.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Assets/WindowWalker.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Assets/WindowWalker.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Assets/WindowWalker.svg diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/CloseWindowCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/CloseWindowCommand.cs similarity index 96% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/CloseWindowCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/CloseWindowCommand.cs index 46475fafd0..ea2480918c 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/CloseWindowCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/CloseWindowCommand.cs @@ -20,7 +20,7 @@ internal sealed partial class CloseWindowCommand : InvokableCommand public CloseWindowCommand(Window window) { - Icon = new IconInfo("\xE8BB"); + Icon = Icons.CloseWindow; Name = $"{Resources.windowwalker_Close}"; _window = window; } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/KillProcessCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/EndTaskCommand.cs similarity index 92% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/KillProcessCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/EndTaskCommand.cs index 5559e1428d..993429d305 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/KillProcessCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/EndTaskCommand.cs @@ -15,13 +15,13 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WindowWalker.Commands; -internal sealed partial class KillProcessCommand : InvokableCommand +internal sealed partial class EndTaskCommand : InvokableCommand { private readonly Window _window; - public KillProcessCommand(Window window) + public EndTaskCommand(Window window) { - Icon = new IconInfo("\xE74D"); // Delete symbol + Icon = Icons.EndTask; Name = $"{Resources.windowwalker_Kill}"; _window = window; } @@ -30,7 +30,7 @@ internal sealed partial class KillProcessCommand : InvokableCommand /// Method to initiate killing the process of a window /// </summary> /// <param name="window">Window data</param> - /// <returns>True if the PT Run window should close, otherwise false.</returns> + /// <returns>True if the PT Run window should close; otherwise, false.</returns> private static bool KillProcess(Window window) { // Validate process diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/ExplorerInfoResultCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/ExplorerInfoResultCommand.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Commands/ExplorerInfoResultCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/ExplorerInfoResultCommand.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/SwitchToWindowCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/SwitchToWindowCommand.cs new file mode 100644 index 0000000000..c215d0f300 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Commands/SwitchToWindowCommand.cs @@ -0,0 +1,88 @@ +// 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; +using System.Drawing; +using System.IO; +using Microsoft.CmdPal.Ext.WindowWalker.Components; +using Microsoft.CmdPal.Ext.WindowWalker.Helpers; +using Microsoft.CmdPal.Ext.WindowWalker.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.WindowWalker.Commands; + +internal sealed partial class SwitchToWindowCommand : InvokableCommand +{ + private readonly Window? _window; + + public SwitchToWindowCommand(Window? window) + { + Icon = Icons.GenericAppIcon; // Fallback to default icon + Name = Resources.switch_to_command_title; + _window = window; + if (_window is not null) + { + // Use window icon + if (SettingsManager.Instance.UseWindowIcon) + { + if (_window.TryGetWindowIcon(out var icon) && icon is not null) + { + try + { + using var bitmap = icon.ToBitmap(); + using var memoryStream = new MemoryStream(); + bitmap.Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); + var raStream = new InMemoryRandomAccessStream(); + using var outputStream = raStream.GetOutputStreamAt(0); + using var dataWriter = new DataWriter(outputStream); + dataWriter.WriteBytes(memoryStream.ToArray()); + dataWriter.StoreAsync().AsTask().Wait(); + dataWriter.FlushAsync().AsTask().Wait(); + Icon = IconInfo.FromStream(raStream); + } + catch + { + } + finally + { + icon.Dispose(); + } + } + } + + // Use process icon + else + { + var p = Process.GetProcessById((int)_window.Process.ProcessID); + if (p is not null) + { + try + { + var processFileName = p.MainModule?.FileName; + Icon = new IconInfo(processFileName); + } + catch + { + } + } + } + } + } + + public override ICommandResult Invoke() + { + if (_window is null) + { + ExtensionHost.LogMessage(new LogMessage() { Message = "Cannot switch to the window, because it doesn't exist." }); + return CommandResult.Dismiss(); + } + + _window.SwitchToWindow(); + + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/ContextMenuHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/ContextMenuHelper.cs similarity index 86% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/ContextMenuHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/ContextMenuHelper.cs index cbadadc699..cf2625c3a7 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/ContextMenuHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/ContextMenuHelper.cs @@ -30,12 +30,13 @@ internal sealed class ContextMenuHelper // Hide menu if Explorer.exe is the shell process or the process name is ApplicationFrameHost.exe // In the first case we would crash the windows ui and in the second case we would kill the generic process for uwp apps. - if (!windowData.Process.IsShellProcess && !(windowData.Process.IsUwpApp && string.Equals(windowData.Process.Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase)) + if (!windowData.Process.IsShellProcess && !(windowData.Process.IsUwpAppFrameHost && string.Equals(windowData.Process.Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase)) && !(windowData.Process.IsFullAccessDenied && SettingsManager.Instance.HideKillProcessOnElevatedProcesses)) { - contextMenu.Add(new CommandContextItem(new KillProcessCommand(windowData)) + contextMenu.Add(new CommandContextItem(new EndTaskCommand(windowData)) { RequestedShortcut = KeyChordHelpers.FromModifiers(true, false, false, false, (int)VirtualKey.Delete, 0), + IsCritical = true, }); } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/LivePreview.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/LivePreview.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/LivePreview.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/LivePreview.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/OpenWindows.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/OpenWindows.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/OpenWindows.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/OpenWindows.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/ResultHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/ResultHelper.cs similarity index 62% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/ResultHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/ResultHelper.cs index 092f1544de..f2428fd6c4 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/ResultHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/ResultHelper.cs @@ -3,7 +3,7 @@ // 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 Microsoft.CmdPal.Ext.WindowWalker.Commands; using Microsoft.CmdPal.Ext.WindowWalker.Helpers; using Microsoft.CmdPal.Ext.WindowWalker.Properties; @@ -19,33 +19,58 @@ internal static class ResultHelper /// <summary> /// Returns a list of all results for the query. /// </summary> - /// <param name="searchControllerResults">List with all search controller matches</param> + /// <param name="scoredWindows">List with all search controller matches</param> /// <returns>List of results</returns> - internal static List<WindowWalkerListItem> GetResultList(List<SearchResult> searchControllerResults, bool isKeywordSearch) + internal static WindowWalkerListItem[] GetResultList(ICollection<Scored<Window>>? scoredWindows) { - if (searchControllerResults == null || searchControllerResults.Count == 0) + if (scoredWindows is null || scoredWindows.Count == 0) { return []; } - var resultsList = new List<WindowWalkerListItem>(searchControllerResults.Count); - var addExplorerInfo = searchControllerResults.Any(x => - string.Equals(x.Result.Process.Name, "explorer.exe", StringComparison.OrdinalIgnoreCase) && - x.Result.Process.IsShellProcess); + var list = scoredWindows as IList<Scored<Window>> ?? new List<Scored<Window>>(scoredWindows); - // Process each SearchResult to convert it into a Result. - // Using parallel processing if the operation is CPU-bound and the list is large. - resultsList = searchControllerResults - .AsParallel() - .Select(x => CreateResultFromSearchResult(x)) - .ToList(); + var addExplorerInfo = false; + for (var i = 0; i < list.Count; i++) + { + var window = list[i].Item; + if (window?.Process is null) + { + continue; + } + + if (string.Equals(window.Process.Name, "explorer.exe", StringComparison.OrdinalIgnoreCase) && window.Process.IsShellProcess) + { + addExplorerInfo = true; + break; + } + } + + var projected = new WindowWalkerListItem[list.Count]; + if (list.Count >= 32) + { + Parallel.For(0, list.Count, i => + { + projected[i] = CreateResultFromSearchResult(list[i]); + }); + } + else + { + for (var i = 0; i < list.Count; i++) + { + projected[i] = CreateResultFromSearchResult(list[i]); + } + } if (addExplorerInfo && !SettingsManager.Instance.HideExplorerSettingInfo) { - resultsList.Insert(0, GetExplorerInfoResult()); + var withInfo = new WindowWalkerListItem[projected.Length + 1]; + withInfo[0] = GetExplorerInfoResult(); + Array.Copy(projected, 0, withInfo, 1, projected.Length); + return withInfo; } - return resultsList; + return projected; } /// <summary> @@ -53,16 +78,15 @@ internal static class ResultHelper /// </summary> /// <param name="searchResult">The SearchResult object to convert.</param> /// <returns>A Result object populated with data from the SearchResult.</returns> - private static WindowWalkerListItem CreateResultFromSearchResult(SearchResult searchResult) + private static WindowWalkerListItem CreateResultFromSearchResult(Scored<Window> searchResult) { - var item = new WindowWalkerListItem(searchResult.Result) + var item = new WindowWalkerListItem(searchResult.Item) { - Title = searchResult.Result.Title, - Subtitle = GetSubtitle(searchResult.Result), - Tags = GetTags(searchResult.Result), + Title = searchResult.Item.Title, + Subtitle = GetSubtitle(searchResult.Item), + Tags = GetTags(searchResult.Item), }; item.MoreCommands = ContextMenuHelper.GetContextMenuResults(item).ToArray(); - return item; } @@ -119,7 +143,7 @@ internal static class ResultHelper return new WindowWalkerListItem(null) { Title = Resources.windowwalker_ExplorerInfoTitle, - Icon = new IconInfo("\uE946"), // Info + Icon = Icons.Info, Subtitle = Resources.windowwalker_ExplorerInfoSubTitle, Command = new ExplorerInfoResultCommand(), }; diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs similarity index 85% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs index 276951b30b..c071d55e80 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs @@ -163,7 +163,7 @@ internal sealed class Window { if (!NativeMethods.ShowWindow(Hwnd, ShowWindowCommand.Restore)) { - // ShowWindow doesn't work if the process is running elevated: fallback to SendMessage + // ShowWindow doesn't work if the process is running elevated: fall back to SendMessage _ = NativeMethods.SendMessage(Hwnd, Win32Constants.WM_SYSCOMMAND, Win32Constants.SC_RESTORE); } } @@ -188,6 +188,62 @@ internal sealed class Window thread.Start(); } + /// <summary> + /// Tries to get the window icon. + /// </summary> + /// <param name="icon">The window icon if found; otherwise, null.</param> + /// <returns>True if an icon was found; otherwise, false.</returns> + internal bool TryGetWindowIcon(out System.Drawing.Icon? icon) + { + icon = null; + + if (hwnd == IntPtr.Zero) + { + return false; + } + + // Try WM_GETICON with SendMessageTimeout + if (NativeMethods.SendMessageTimeout(hwnd, Win32Constants.WM_GETICON, (UIntPtr)Win32Constants.ICON_BIG, IntPtr.Zero, Win32Constants.SMTO_ABORTIFHUNG, 100, out var result) != 0 && result != 0) + { + icon = System.Drawing.Icon.FromHandle((IntPtr)result); + NativeMethods.DestroyIcon((IntPtr)result); + return true; + } + + if (NativeMethods.SendMessageTimeout(hwnd, Win32Constants.WM_GETICON, (UIntPtr)Win32Constants.ICON_SMALL, IntPtr.Zero, Win32Constants.SMTO_ABORTIFHUNG, 100, out result) != 0 && result != 0) + { + icon = System.Drawing.Icon.FromHandle((IntPtr)result); + NativeMethods.DestroyIcon((IntPtr)result); + return true; + } + + if (NativeMethods.SendMessageTimeout(hwnd, Win32Constants.WM_GETICON, (UIntPtr)Win32Constants.ICON_SMALL2, IntPtr.Zero, Win32Constants.SMTO_ABORTIFHUNG, 100, out result) != 0 && result != 0) + { + icon = System.Drawing.Icon.FromHandle((IntPtr)result); + NativeMethods.DestroyIcon((IntPtr)result); + return true; + } + + // Fallback to GetClassLongPtr + var iconHandle = NativeMethods.GetClassLongPtr(hwnd, Win32Constants.GCLP_HICON); + if (iconHandle != IntPtr.Zero) + { + icon = System.Drawing.Icon.FromHandle(iconHandle); + NativeMethods.DestroyIcon((IntPtr)iconHandle); + return true; + } + + iconHandle = NativeMethods.GetClassLongPtr(hwnd, Win32Constants.GCLP_HICONSM); + if (iconHandle != IntPtr.Zero) + { + icon = System.Drawing.Icon.FromHandle(iconHandle); + NativeMethods.DestroyIcon((IntPtr)iconHandle); + return true; + } + + return false; + } + /// <summary> /// Converts the window name to string along with the process name /// </summary> @@ -324,7 +380,7 @@ internal sealed class Window // Correct the process data if the window belongs to a uwp app hosted by 'ApplicationFrameHost.exe' // (This only works if the window isn't minimized. For minimized windows the required child window isn't assigned.) - if (string.Equals(_handlesToProcessCache[hWindow].Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase)) + if (_handlesToProcessCache[hWindow].IsUwpAppFrameHost) { new Task(() => { diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs similarity index 90% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs index f0e1639d68..2dfbbcf429 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/WindowProcess.cs @@ -23,7 +23,7 @@ internal sealed class WindowProcess /// <summary> /// An indicator if the window belongs to an 'Universal Windows Platform (UWP)' process /// </summary> - private readonly bool _isUwpApp; + private bool _isUwpAppFrameHost; /// <summary> /// Gets the id of the process @@ -42,7 +42,8 @@ internal sealed class WindowProcess { try { - return Process.GetProcessById((int)ProcessID).Responding; + // Process.Responding doesn't work on UWP apps + return ProcessType.Kind == ProcessPackagingKind.UwpApp || Process.GetProcessById((int)ProcessID).Responding; } catch (InvalidOperationException) { @@ -76,7 +77,7 @@ internal sealed class WindowProcess /// <summary> /// Gets a value indicating whether the window belongs to an 'Universal Windows Platform (UWP)' process /// </summary> - internal bool IsUwpApp => _isUwpApp; + public bool IsUwpAppFrameHost => _isUwpAppFrameHost; /// <summary> /// Gets a value indicating whether this is the shell process or not @@ -125,6 +126,14 @@ internal sealed class WindowProcess get; private set; } + /// <summary> + /// Gets the type of the process (UWP app, packaged Win32 app, unpackaged Win32 app, ...). + /// </summary> + internal ProcessPackagingInfo ProcessType + { + get; private set; + } + /// <summary> /// Initializes a new instance of the <see cref="WindowProcess"/> class. /// </summary> @@ -133,8 +142,8 @@ internal sealed class WindowProcess /// <param name="name">New process name.</param> internal WindowProcess(uint pid, uint tid, string name) { + ProcessType = ProcessPackagingInfo.Empty; UpdateProcessInfo(pid, tid, name); - _isUwpApp = string.Equals(Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase); } /// <summary> @@ -152,6 +161,10 @@ internal sealed class WindowProcess // Process can be elevated only if process id is not 0 (Dummy value on error) IsFullAccessDenied = (pid != 0) ? TestProcessAccessUsingAllAccessFlag(pid) : false; + + // Update process type + ProcessType = ProcessPackagingInspector.Inspect((int)pid); + _isUwpAppFrameHost = string.Equals(Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase); } /// <summary> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/CVirtualDesktopManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/CVirtualDesktopManager.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/CVirtualDesktopManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/CVirtualDesktopManager.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ISettingsInterface.cs new file mode 100644 index 0000000000..de3827c76a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ISettingsInterface.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.WindowWalker.Helpers; + +public interface ISettingsInterface +{ + public bool ResultsFromVisibleDesktopOnly { get; } + + public bool SubtitleShowPid { get; } + + public bool SubtitleShowDesktopName { get; } + + public bool ConfirmKillProcess { get; } + + public bool KillProcessTree { get; } + + public bool OpenAfterKillAndClose { get; } + + public bool HideKillProcessOnElevatedProcesses { get; } + + public bool HideExplorerSettingInfo { get; } + + public bool InMruOrder { get; } + + public bool UseWindowIcon { get; } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/IVirtualDesktopManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/IVirtualDesktopManager.cs similarity index 57% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/IVirtualDesktopManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/IVirtualDesktopManager.cs index 070e24cc26..75170a56cc 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/IVirtualDesktopManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/IVirtualDesktopManager.cs @@ -4,6 +4,7 @@ using System; using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; namespace Microsoft.CmdPal.Ext.WindowWalker.Helpers; @@ -11,18 +12,16 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Helpers; /// Interface for accessing Virtual Desktop Manager. /// Code used from <see href="https://learn.microsoft.com/archive/blogs/winsdk/virtual-desktop-switching-in-windows-10"./> /// </summary> -[ComImport] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +[GeneratedComInterface] [Guid("a5cd92ff-29be-454c-8d04-d82879fb3f1b")] -[System.Security.SuppressUnmanagedCodeSecurity] -internal interface IVirtualDesktopManager +public partial interface IVirtualDesktopManager { [PreserveSig] - int IsWindowOnCurrentVirtualDesktop([In] IntPtr hTopLevelWindow, [Out] out int onCurrentDesktop); + int IsWindowOnCurrentVirtualDesktop(IntPtr hTopLevelWindow, out int onCurrentDesktop); [PreserveSig] - int GetWindowDesktopId([In] IntPtr hTopLevelWindow, [Out] out Guid desktop); + int GetWindowDesktopId(IntPtr hTopLevelWindow, out Guid desktop); [PreserveSig] - int MoveWindowToDesktop([In] IntPtr hTopLevelWindow, [MarshalAs(UnmanagedType.LPStruct)][In] Guid desktop); + int MoveWindowToDesktop(IntPtr hTopLevelWindow, ref Guid desktop); } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/NativeMethods.cs similarity index 92% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/NativeMethods.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/NativeMethods.cs index e60cb262fe..57d65a305e 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/NativeMethods.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/NativeMethods.cs @@ -5,7 +5,7 @@ using System; using System.Runtime.InteropServices; using System.Text; - +using Microsoft.Win32.SafeHandles; using SuppressMessageAttribute = System.Diagnostics.CodeAnalysis.SuppressMessageAttribute; #pragma warning disable SA1649, CA1051, CA1707, CA1028, CA1714, CA1069, SA1402 @@ -13,7 +13,7 @@ using SuppressMessageAttribute = System.Diagnostics.CodeAnalysis.SuppressMessage namespace Microsoft.CmdPal.Ext.WindowWalker.Helpers; [SuppressMessage("Interoperability", "CA1401:P/Invokes should not be visible", Justification = "We want plugins to share this NativeMethods class, instead of each one creating its own.")] -public static class NativeMethods +public static partial class NativeMethods { [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int EnumWindows(EnumWindowsProc callPtr, IntPtr lParam); @@ -84,6 +84,12 @@ public static class NativeMethods [DllImport("user32.dll")] public static extern int SendMessageTimeout(IntPtr hWnd, uint msg, UIntPtr wParam, IntPtr lParam, int fuFlags, int uTimeout, out int lpdwResult); + [DllImport("user32.dll", EntryPoint = "GetClassLongPtr")] + public static extern IntPtr GetClassLongPtr(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + internal static extern bool DestroyIcon(IntPtr hIcon); + [DllImport("kernel32.dll")] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool CloseHandle(IntPtr hObject); @@ -99,31 +105,24 @@ public static class NativeMethods [return: MarshalAs(UnmanagedType.Bool)] public static extern bool GetFirmwareType(ref FirmwareType FirmwareType); - [DllImport("user32.dll")] + [DllImport("advapi32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool ExitWindowsEx(uint uFlags, uint dwReason); + internal static extern bool OpenProcessToken(SafeProcessHandle processHandle, TokenAccess desiredAccess, out SafeAccessTokenHandle tokenHandle); - [DllImport("user32")] - public static extern void LockWorkStation(); - - [DllImport("Powrprof.dll", CharSet = CharSet.Auto, ExactSpelling = true)] + [DllImport("advapi32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent); + internal static extern bool GetTokenInformation( + SafeAccessTokenHandle tokenHandle, + TOKEN_INFORMATION_CLASS tokenInformationClass, + out int tokenInformation, + int tokenInformationLength, + out int returnLength); - [DllImport("Shell32.dll", CharSet = CharSet.Unicode)] - public static extern uint SHEmptyRecycleBin(IntPtr hWnd, uint dwFlags); - - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] - public static extern HRESULT SHLoadIndirectString(string pszSource, StringBuilder pszOutBuf, uint cchOutBuf, IntPtr ppvReserved); - - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] - public static extern HRESULT SHCreateStreamOnFileEx(string fileName, STGM grfMode, uint attributes, bool create, System.Runtime.InteropServices.ComTypes.IStream reserved, out System.Runtime.InteropServices.ComTypes.IStream stream); - - [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - public static extern int SHCreateItemFromParsingName([MarshalAs(UnmanagedType.LPWStr)] string path, IntPtr pbc, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IShellItem shellItem); - - [DllImport("rpcrt4.dll")] - public static extern int UuidCreateSequential(out GUIDDATA Uuid); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "GetPackageFullName")] + internal static extern int GetPackageFullName( + SafeProcessHandle hProcess, + ref uint packageFullNameLength, + StringBuilder? packageFullName); } [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "These are the names used by win32.")] @@ -150,6 +149,41 @@ public static class Win32Constants /// </summary> public const int SC_CLOSE = 0xF060; + /// <summary> + /// Sent to a window to retrieve a handle to the large or small icon associated with a window. + /// </summary> + public const uint WM_GETICON = 0x007F; + + /// <summary> + /// Retrieve the large icon for the window. + /// </summary> + public const int ICON_BIG = 1; + + /// <summary> + /// Retrieve the small icon for the window. + /// </summary> + public const int ICON_SMALL = 0; + + /// <summary> + /// Retrieve the small icon provided by the application. + /// </summary> + public const int ICON_SMALL2 = 2; + + /// <summary> + /// The function returns if the receiving thread does not respond within the timeout period. + /// </summary> + public const int SMTO_ABORTIFHUNG = 0x0002; + + /// <summary> + /// Retrieves a handle to the icon associated with the class. + /// </summary> + public const int GCLP_HICON = -14; + + /// <summary> + /// Retrieves a handle to the small icon associated with the class. + /// </summary> + public const int GCLP_HICONSM = -34; + /// <summary> /// RPC call succeeded /// </summary> @@ -161,19 +195,6 @@ public static class Win32Constants public const int RPC_S_UUID_LOCAL_ONLY = 0x720; } -public static class ShellItemTypeConstants -{ - /// <summary> - /// Guid for type IShellItem. - /// </summary> - public static readonly Guid ShellItemGuid = new("43826d1e-e718-42ee-bc55-a1e261c37bfe"); - - /// <summary> - /// Guid for type IShellItem2. - /// </summary> - public static readonly Guid ShellItem2Guid = new("7E9FB0D3-919F-4307-AB2E-9B1860310C93"); -} - public enum HRESULT : uint { /// <summary> @@ -422,7 +443,7 @@ public enum ShowWindowCommand /// <summary> /// Displays a window in its most recent size and position. This value - /// is similar to <see cref="Win32.ShowWindowCommand.Normal"/>, except + /// is similar to <see cref="ShowWindowCommand.Normal"/>, except /// the window is not activated. /// </summary> ShowNoActivate = 4, @@ -440,14 +461,14 @@ public enum ShowWindowCommand /// <summary> /// Displays the window as a minimized window. This value is similar to - /// <see cref="Win32.ShowWindowCommand.ShowMinimized"/>, except the + /// <see cref="ShowWindowCommand.ShowMinimized"/>, except the /// window is not activated. /// </summary> ShowMinNoActive = 7, /// <summary> /// Displays the window in its current size and position. This value is - /// similar to <see cref="Win32.ShowWindowCommand.Show"/>, except the + /// similar to <see cref="ShowWindowCommand.Show"/>, except the /// window is not activated. /// </summary> ShowNA = 8, @@ -1032,7 +1053,7 @@ public enum ExtendedWindowStyles : uint /// <summary> /// The window has generic "right-aligned" properties. This depends on the window class. This style has - /// an effect only if the shell language supports reading-order alignment, otherwise is ignored. + /// an effect only if the shell language supports reading-order alignment; otherwise, is ignored. /// </summary> WS_EX_RIGHT = 0x1000, @@ -1124,26 +1145,6 @@ public enum ExtendedWindowStyles : uint WS_EX_NOACTIVATE = 0x8000000, } -[ComImport] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -[Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")] -public interface IShellItem -{ - void BindToHandler( - IntPtr pbc, - [MarshalAs(UnmanagedType.LPStruct)] Guid bhid, - [MarshalAs(UnmanagedType.LPStruct)] Guid riid, - out IntPtr ppv); - - void GetParent(out IShellItem ppsi); - - void GetDisplayName(SIGDN sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName); - - void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs); - - void Compare(IShellItem psi, uint hint, out int piOrder); -} - /// <summary> /// The following are ShellItem DisplayName types. /// </summary> @@ -1159,3 +1160,14 @@ public enum SIGDN : uint FILESYSPATH = 0x80058000, URL = 0x80068000, } + +internal enum TOKEN_INFORMATION_CLASS +{ + TokenIsAppContainer = 29, +} + +[Flags] +internal enum TokenAccess : uint +{ + TOKEN_QUERY = 0x0008, +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/OSVersionHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/OSVersionHelper.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/OSVersionHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/OSVersionHelper.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInfo.cs new file mode 100644 index 0000000000..1a1321a9d6 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInfo.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.WindowWalker.Helpers; + +internal sealed record ProcessPackagingInfo( + int Pid, + ProcessPackagingKind Kind, + bool HasPackageIdentity, + bool IsAppContainer, + string? PackageFullName, + int? LastError +) +{ + public static ProcessPackagingInfo Empty { get; } = new( + Pid: 0, + Kind: ProcessPackagingKind.Unknown, + HasPackageIdentity: false, + IsAppContainer: false, + PackageFullName: null, + LastError: null); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInspector.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInspector.cs new file mode 100644 index 0000000000..43d77d1aba --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingInspector.cs @@ -0,0 +1,123 @@ +// 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.Text; +using Microsoft.Win32.SafeHandles; + +namespace Microsoft.CmdPal.Ext.WindowWalker.Helpers; + +internal static class ProcessPackagingInspector +{ +#pragma warning disable SA1310 // Field names should not contain underscore + private const int ERROR_INSUFFICIENT_BUFFER = 122; + private const int APPMODEL_ERROR_NO_PACKAGE = 15700; +#pragma warning restore SA1310 // Field names should not contain underscore + + /// <summary> + /// Inspect a process by PID and classify its packaging. + /// </summary> + public static ProcessPackagingInfo Inspect(int pid) + { + var hProcess = NativeMethods.OpenProcess(ProcessAccessFlags.QueryLimitedInformation, false, pid); + using var process = new SafeProcessHandle(hProcess, true); + if (process.IsInvalid) + { + return new ProcessPackagingInfo( + pid, + ProcessPackagingKind.Unknown, + HasPackageIdentity: false, + IsAppContainer: false, + PackageFullName: null, + LastError: Marshal.GetLastPInvokeError()); + } + + // 1) Check package identity + var hasPackage = TryGetPackageFullName(process, out var packageFullName, out _); + + // 2) If packaged, check AppContainer -> strict UWP + var isAppContainer = false; + int? tokenErr = null; + if (hasPackage) + { + isAppContainer = TryIsAppContainer(process, out tokenErr); + } + + var kind = + !hasPackage ? ProcessPackagingKind.UnpackagedWin32 : + isAppContainer ? ProcessPackagingKind.UwpApp : + ProcessPackagingKind.PackagedWin32; + + return new ProcessPackagingInfo( + pid, + kind, + HasPackageIdentity: hasPackage, + IsAppContainer: isAppContainer, + PackageFullName: packageFullName, + LastError: null); + } + + private static bool TryGetPackageFullName(SafeProcessHandle hProcess, out string? packageFullName, out int? lastError) + { + packageFullName = null; + lastError = null; + + uint len = 0; + var rc = NativeMethods.GetPackageFullName(hProcess, ref len, null); + if (rc == APPMODEL_ERROR_NO_PACKAGE) + { + return false; // no package identity + } + + if (rc != ERROR_INSUFFICIENT_BUFFER && rc != 0) + { + lastError = rc; + return false; // unexpected error + } + + if (len == 0) + { + return false; + } + + var sb = new StringBuilder((int)len); + rc = NativeMethods.GetPackageFullName(hProcess, ref len, sb); + if (rc == 0) + { + packageFullName = sb.ToString(); + return true; + } + + lastError = rc; + return false; + } + + private static bool TryIsAppContainer(SafeProcessHandle hProcess, out int? lastError) + { + lastError = null; + + if (!NativeMethods.OpenProcessToken(hProcess, TokenAccess.TOKEN_QUERY, out var token)) + { + lastError = Marshal.GetLastPInvokeError(); + return false; // can't decide; treat as not-UWP for classification + } + + using (token) + { + if (!NativeMethods.GetTokenInformation( + token, + TOKEN_INFORMATION_CLASS.TokenIsAppContainer, + out var val, + sizeof(int), + out _)) + { + lastError = Marshal.GetLastPInvokeError(); + return false; + } + + return val != 0; // true => AppContainer (UWP) + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingKind.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingKind.cs new file mode 100644 index 0000000000..dddfeaeb26 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ProcessPackagingKind.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. + +namespace Microsoft.CmdPal.Ext.WindowWalker.Helpers; + +internal enum ProcessPackagingKind +{ + Unknown = 0, + UnpackagedWin32, + PackagedWin32, + UwpApp, +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/SettingsManager.cs similarity index 91% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/SettingsManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/SettingsManager.cs index 6f541d28df..1b223fea9b 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/SettingsManager.cs @@ -8,7 +8,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WindowWalker.Helpers; -public class SettingsManager : JsonSettingsManager +public class SettingsManager : JsonSettingsManager, ISettingsInterface { private static readonly string _namespace = "windowWalker"; @@ -70,6 +70,12 @@ public class SettingsManager : JsonSettingsManager Resources.windowwalker_SettingInMruOrder_Description, true); + private readonly ToggleSetting _useWindowIcon = new( + Namespaced(nameof(UseWindowIcon)), + Resources.windowwalker_SettingUseWindowIcon, + Resources.windowwalker_SettingUseWindowIcon_Description, + true); + public bool ResultsFromVisibleDesktopOnly => _resultsFromVisibleDesktopOnly.Value; public bool SubtitleShowPid => _subtitleShowPid.Value; @@ -88,6 +94,8 @@ public class SettingsManager : JsonSettingsManager public bool InMruOrder => _inMruOrder.Value; + public bool UseWindowIcon => _useWindowIcon.Value; + internal static string SettingsJsonPath() { var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); @@ -110,6 +118,7 @@ public class SettingsManager : JsonSettingsManager Settings.Add(_hideKillProcessOnElevatedProcesses); Settings.Add(_hideExplorerSettingInfo); Settings.Add(_inMruOrder); + Settings.Add(_useWindowIcon); // Load settings from file upon initialization LoadSettings(); diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ShellCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ShellCommand.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ShellCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/ShellCommand.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VDesktop.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VDesktop.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VDesktop.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VDesktop.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VirtualDesktopHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VirtualDesktopHelper.cs similarity index 95% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VirtualDesktopHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VirtualDesktopHelper.cs index 023bd8ed33..131ec7ae82 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VirtualDesktopHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/VirtualDesktopHelper.cs @@ -5,9 +5,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; using System.Text; - +using ManagedCsWin32; using Microsoft.CmdPal.Ext.WindowWalker.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.Win32; @@ -61,9 +63,11 @@ public class VirtualDesktopHelper /// <param name="desktopListUpdate">Setting to configure if the list of available desktops should update automatically or only when calling <see cref="UpdateDesktopList"/>. Per default this is set to manual update (false) to have less registry queries.</param> public VirtualDesktopHelper(bool desktopListUpdate = false) { + var cw = new StrategyBasedComWrappers(); + try { - _virtualDesktopManager = (IVirtualDesktopManager)new CVirtualDesktopManager(); + _virtualDesktopManager = ComHelper.CreateComInstance<IVirtualDesktopManager>(ref Unsafe.AsRef(in CLSID.VirtualDesktopManager), CLSCTX.InProcServer); } catch (COMException ex) { @@ -79,7 +83,7 @@ public class VirtualDesktopHelper /// <summary> /// Gets a value indicating whether the Virtual Desktop Manager is initialized successfully /// </summary> - public bool VirtualDesktopManagerInitialized => _virtualDesktopManager != null; + public bool VirtualDesktopManagerInitialized => _virtualDesktopManager is not null; /// <summary> /// Method to update the list of Virtual Desktops from Registry @@ -94,12 +98,12 @@ public class VirtualDesktopHelper // List of all desktops using RegistryKey? virtualDesktopKey = Registry.CurrentUser.OpenSubKey(registryExplorerVirtualDesktops, false); - if (virtualDesktopKey != null) + if (virtualDesktopKey is not null) { var allDeskValue = (byte[]?)virtualDesktopKey.GetValue("VirtualDesktopIDs", null) ?? Array.Empty<byte>(); - if (allDeskValue != null) + if (allDeskValue is not null) { - // We clear only, if we can read from registry. Otherwise we keep the existing values. + // We clear only, if we can read from registry. Otherwise, we keep the existing values. _availableDesktops.Clear(); // Each guid has a length of 16 elements @@ -120,17 +124,17 @@ public class VirtualDesktopHelper // Guid for current desktop var virtualDesktopsKeyName = _isWindowsEleven ? registryExplorerVirtualDesktops : registrySessionVirtualDesktops; using RegistryKey? virtualDesktopsKey = Registry.CurrentUser.OpenSubKey(virtualDesktopsKeyName, false); - if (virtualDesktopsKey != null) + if (virtualDesktopsKey is not null) { var currentVirtualDesktopValue = virtualDesktopsKey.GetValue("CurrentVirtualDesktop", null); - if (currentVirtualDesktopValue != null) + if (currentVirtualDesktopValue is not null) { _currentDesktop = new Guid((byte[])currentVirtualDesktopValue); } else { // The registry value is missing when the user hasn't switched the desktop at least one time before reading the registry. In this case we can set it to desktop one. - // We can only set it to desktop one, if we have at least one desktop in the desktops list. Otherwise we keep the existing value. + // We can only set it to desktop one, if we have at least one desktop in the desktops list. Otherwise, we keep the existing value. ExtensionHost.LogMessage(new LogMessage() { Message = "VirtualDesktopHelper.UpdateDesktopList() failed to read the id for the current desktop form registry." }); _currentDesktop = _availableDesktops.Count >= 1 ? _availableDesktops[0] : _currentDesktop; } @@ -232,7 +236,7 @@ public class VirtualDesktopHelper /// Returns the number (position) of a desktop. /// </summary> /// <param name="desktop">The guid of the desktop.</param> - /// <returns>Number of the desktop, if found. Otherwise a value of zero.</returns> + /// <returns>Number of the desktop, if found. Otherwise, a value of zero.</returns> public int GetDesktopNumber(Guid desktop) { if (_desktopListAutoUpdate) @@ -264,7 +268,7 @@ public class VirtualDesktopHelper using RegistryKey? deskSubKey = Registry.CurrentUser.OpenSubKey(registryPath, false); var desktopName = deskSubKey?.GetValue("Name"); - return (desktopName != null) ? (string)desktopName : defaultName; + return (desktopName is not null) ? (string)desktopName : defaultName; } /// <summary> @@ -309,7 +313,7 @@ public class VirtualDesktopHelper /// <returns>HResult of the called method as integer.</returns> public int GetWindowDesktopId(IntPtr hWindow, out Guid desktopId) { - if (_virtualDesktopManager == null) + if (_virtualDesktopManager is null) { ExtensionHost.LogMessage(new LogMessage() { Message = "VirtualDesktopHelper.GetWindowDesktopId() failed: The instance of <IVirtualDesktopHelper> isn't available." }); desktopId = Guid.Empty; @@ -326,7 +330,7 @@ public class VirtualDesktopHelper /// <returns>An instance of <see cref="VDesktop"/> for the desktop where the window is assigned to, or an empty instance of <see cref="VDesktop"/> on failure.</returns> public VDesktop GetWindowDesktop(IntPtr hWindow) { - if (_virtualDesktopManager == null) + if (_virtualDesktopManager is null) { ExtensionHost.LogMessage(new LogMessage() { Message = "VirtualDesktopHelper.GetWindowDesktop() failed: The instance of <IVirtualDesktopHelper> isn't available." }); return CreateVDesktopInstance(Guid.Empty); @@ -344,7 +348,7 @@ public class VirtualDesktopHelper /// <returns>Type of <see cref="VirtualDesktopAssignmentType"/>.</returns> public VirtualDesktopAssignmentType GetWindowDesktopAssignmentType(IntPtr hWindow, Guid? desktop = null) { - if (_virtualDesktopManager == null) + if (_virtualDesktopManager is null) { ExtensionHost.LogMessage(new LogMessage() { Message = "VirtualDesktopHelper.GetWindowDesktopAssignmentType() failed: The instance of <IVirtualDesktopHelper> isn't available." }); return VirtualDesktopAssignmentType.Unknown; @@ -409,15 +413,15 @@ public class VirtualDesktopHelper /// <param name="hWindow">Handle of the top level window.</param> /// <param name="desktopId">Guid of the target desktop.</param> /// <returns><see langword="True"/> on success and <see langword="false"/> on failure.</returns> - public bool MoveWindowToDesktop(IntPtr hWindow, in Guid desktopId) + public bool MoveWindowToDesktop(IntPtr hWindow, ref Guid desktopId) { - if (_virtualDesktopManager == null) + if (_virtualDesktopManager is null) { ExtensionHost.LogMessage(new LogMessage() { Message = "VirtualDesktopHelper.MoveWindowToDesktop() failed: The instance of <IVirtualDesktopHelper> isn't available." }); return false; } - var hr = _virtualDesktopManager.MoveWindowToDesktop(hWindow, desktopId); + var hr = _virtualDesktopManager.MoveWindowToDesktop(hWindow, ref desktopId); if (hr != (int)HRESULT.S_OK) { ExtensionHost.LogMessage(new LogMessage() { Message = "VirtualDesktopHelper.MoveWindowToDesktop() failed: An exception was thrown when moving the window ({hWindow}) to another desktop ({desktopId})." }); @@ -455,7 +459,7 @@ public class VirtualDesktopHelper } Guid newDesktop = _availableDesktops[windowDesktopNumber - 1]; - return MoveWindowToDesktop(hWindow, newDesktop); + return MoveWindowToDesktop(hWindow, ref newDesktop); } /// <summary> @@ -486,7 +490,7 @@ public class VirtualDesktopHelper } Guid newDesktop = _availableDesktops[windowDesktopNumber + 1]; - return MoveWindowToDesktop(hWindow, newDesktop); + return MoveWindowToDesktop(hWindow, ref newDesktop); } /// <summary> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/Win32Helpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/Win32Helpers.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Helpers/Win32Helpers.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Helpers/Win32Helpers.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Icons.cs new file mode 100644 index 0000000000..dd2ae9b1cb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Icons.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 Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WindowWalker; + +internal sealed class Icons +{ + internal static IconInfo WindowWalkerIcon { get; } = IconHelpers.FromRelativePath("Assets\\WindowWalker.svg"); + + internal static IconInfo EndTask { get; } = new IconInfo("\uF140"); // StatusCircleBlock + + internal static IconInfo CloseWindow { get; } = new IconInfo("\uE894"); // Clear + + internal static IconInfo Info { get; } = new IconInfo("\uE946"); // Info + + internal static IconInfo GenericAppIcon { get; } = new("\uE737"); // Favicon +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Microsoft.CmdPal.Ext.WindowWalker.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Microsoft.CmdPal.Ext.WindowWalker.csproj similarity index 74% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Microsoft.CmdPal.Ext.WindowWalker.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Microsoft.CmdPal.Ext.WindowWalker.csproj index 2ed59ad6a3..f5233a47e4 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Microsoft.CmdPal.Ext.WindowWalker.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Microsoft.CmdPal.Ext.WindowWalker.csproj @@ -1,5 +1,8 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> + <PropertyGroup> <Nullable>enable</Nullable> <RootNamespace>Microsoft.CmdPal.Ext.WindowWalker</RootNamespace> @@ -11,7 +14,9 @@ <None Remove="Assets\WindowWalker.svg" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" /> + <!-- WASDK, WebView2, CmdPal Toolkit references now included via Common.ExtDependencies.props --> </ItemGroup> <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Pages/WindowWalkerListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Pages/WindowWalkerListPage.cs similarity index 52% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Pages/WindowWalkerListPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Pages/WindowWalkerListPage.cs index 96556d8fd5..b9531163f9 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Pages/WindowWalkerListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Pages/WindowWalkerListPage.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; using Microsoft.CmdPal.Ext.WindowWalker.Components; +using Microsoft.CmdPal.Ext.WindowWalker.Helpers; using Microsoft.CmdPal.Ext.WindowWalker.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -19,16 +19,25 @@ internal sealed partial class WindowWalkerListPage : DynamicListPage, IDisposabl public WindowWalkerListPage() { - Icon = IconHelpers.FromRelativePath("Assets\\WindowWalker.svg"); + Icon = Icons.WindowWalkerIcon; Name = Resources.windowwalker_name; Id = "com.microsoft.cmdpal.windowwalker"; PlaceholderText = Resources.windowwalker_PlaceholderText; + + EmptyContent = new CommandItem(new NoOpCommand()) + { + Icon = Icon, + Title = Resources.window_walker_top_level_command_title, + Subtitle = Resources.windowwalker_NoResultsMessage, + }; } - public override void UpdateSearchText(string oldSearch, string newSearch) => + public override void UpdateSearchText(string oldSearch, string newSearch) + { RaiseItemsChanged(0); + } - public List<WindowWalkerListItem> Query(string query) + private WindowWalkerListItem[] Query(string query) { ArgumentNullException.ThrowIfNull(query); @@ -38,13 +47,37 @@ internal sealed partial class WindowWalkerListPage : DynamicListPage, IDisposabl WindowWalkerCommandsProvider.VirtualDesktopHelperInstance.UpdateDesktopList(); OpenWindows.Instance.UpdateOpenWindowsList(_cancellationTokenSource.Token); - SearchController.Instance.UpdateSearchText(query); - var searchControllerResults = SearchController.Instance.SearchMatches; - return ResultHelper.GetResultList(searchControllerResults, !string.IsNullOrEmpty(query)); + var windows = OpenWindows.Instance.Windows; + + if (string.IsNullOrWhiteSpace(query)) + { + if (!SettingsManager.Instance.InMruOrder) + { + windows.Sort(static (a, b) => string.Compare(a?.Title, b?.Title, StringComparison.OrdinalIgnoreCase)); + } + + var results = new Scored<Window>[windows.Count]; + for (var i = 0; i < windows.Count; i++) + { + results[i] = new Scored<Window> { Item = windows[i], Score = 100 }; + } + + return ResultHelper.GetResultList(results); + } + + var scored = ListHelpers.FilterListWithScores(windows, query, ScoreFunction); + return ResultHelper.GetResultList([.. scored]); } - public override IListItem[] GetItems() => Query(SearchText).ToArray(); + private static int ScoreFunction(string q, Window window) + { + var titleScore = FuzzyStringMatcher.ScoreFuzzy(q, window.Title); + var processNameScore = FuzzyStringMatcher.ScoreFuzzy(q, window.Process?.Name ?? string.Empty); + return Math.Max(titleScore, processNameScore); + } + + public override IListItem[] GetItems() => Query(SearchText); public void Dispose() { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..54807d07a7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/AssemblyInfo.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. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.WindowWalker.UnitTests")] diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs similarity index 90% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs index 3edc393e4f..0d0cce13c6 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.Designer.cs @@ -70,7 +70,7 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Properties { } /// <summary> - /// Looks up a localized string similar to On all Desktops. + /// Looks up a localized string similar to On all desktops. /// </summary> public static string VirtualDesktopHelper_AllDesktops { get { @@ -124,7 +124,7 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Properties { } /// <summary> - /// Looks up a localized string similar to Info: Killing the Explorer process isn't possible.. + /// Looks up a localized string similar to Info: Ending the Explorer process isn't possible.. /// </summary> public static string windowwalker_ExplorerInfoTitle { get { @@ -133,7 +133,7 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Properties { } /// <summary> - /// Looks up a localized string similar to Kill process. + /// Looks up a localized string similar to End task. /// </summary> public static string windowwalker_Kill { get { @@ -142,7 +142,7 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Properties { } /// <summary> - /// Looks up a localized string similar to Your are going to kill the following process:. + /// Looks up a localized string similar to The following process will be ended:. /// </summary> public static string windowwalker_KillMessage { get { @@ -160,7 +160,7 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Properties { } /// <summary> - /// Looks up a localized string similar to Kill process confirmation. + /// Looks up a localized string similar to End task confirmation. /// </summary> public static string windowwalker_KillMessageTitle { get { @@ -187,7 +187,16 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Properties { } /// <summary> - /// Looks up a localized string similar to Not Responding. + /// Looks up a localized string similar to No open windows found. + /// </summary> + public static string windowwalker_NoResultsMessage { + get { + return ResourceManager.GetString("windowwalker_NoResultsMessage", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Not responding. /// </summary> public static string windowwalker_NotResponding { get { @@ -392,5 +401,23 @@ namespace Microsoft.CmdPal.Ext.WindowWalker.Properties { return ResourceManager.GetString("windowwalker_SettingTagPid", resourceCulture); } } + + /// <summary> + /// Looks up a localized string similar to Use window icons. + /// </summary> + public static string windowwalker_SettingUseWindowIcon { + get { + return ResourceManager.GetString("windowwalker_SettingUseWindowIcon", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Show the actual window icon instead of the process icon. + /// </summary> + public static string windowwalker_SettingUseWindowIcon_Description { + get { + return ResourceManager.GetString("windowwalker_SettingUseWindowIcon_Description", resourceCulture); + } + } } } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx similarity index 94% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx index 12781a6a24..cbfa9b69b0 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Properties/Resources.resx @@ -166,23 +166,23 @@ <comment>Explorer is here the program File Explorer</comment> </data> <data name="windowwalker_ExplorerInfoTitle" xml:space="preserve"> - <value>Info: Killing the Explorer process isn't possible.</value> + <value>Info: Ending the Explorer process isn't possible.</value> <comment>Explorer is here the program File Explorer</comment> </data> <data name="windowwalker_Close" xml:space="preserve"> <value>Close window</value> </data> <data name="windowwalker_Kill" xml:space="preserve"> - <value>Kill process</value> + <value>End task</value> </data> <data name="windowwalker_KillMessage" xml:space="preserve"> - <value>Your are going to kill the following process:</value> + <value>The following process will be ended:</value> </data> <data name="windowwalker_KillMessageQuestion" xml:space="preserve"> <value>Continue?</value> </data> <data name="windowwalker_KillMessageTitle" xml:space="preserve"> - <value>Kill process confirmation</value> + <value>End task confirmation</value> </data> <data name="windowwalker_KillMessageUwp" xml:space="preserve"> <value>Because this is an app process, all instances of the app will be killed. Continue?</value> @@ -209,7 +209,7 @@ <value>When disabled, windows will be sorted by title</value> </data> <data name="windowwalker_NotResponding" xml:space="preserve"> - <value>Not Responding</value> + <value>Not responding</value> </data> <data name="window_walker_top_level_command_title" xml:space="preserve"> <value>Switch between open windows</value> @@ -218,7 +218,7 @@ <value>Switch to</value> </data> <data name="VirtualDesktopHelper_AllDesktops" xml:space="preserve"> - <value>On all Desktops</value> + <value>On all desktops</value> </data> <data name="VirtualDesktopHelper_Desktop" xml:space="preserve"> <value>Desktop {0}</value> @@ -232,4 +232,13 @@ <data name="windowwalker_PlaceholderText" xml:space="preserve"> <value>Search open windows...</value> </data> + <data name="windowwalker_NoResultsMessage" xml:space="preserve"> + <value>No open windows found</value> + </data> + <data name="windowwalker_SettingUseWindowIcon" xml:space="preserve"> + <value>Use window icons</value> + </data> + <data name="windowwalker_SettingUseWindowIcon_Description" xml:space="preserve"> + <value>Show the actual window icon instead of the process icon</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerCommandsProvider.cs similarity index 90% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerCommandsProvider.cs index a1dd46cad1..4862261a12 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerCommandsProvider.cs @@ -20,13 +20,12 @@ public partial class WindowWalkerCommandsProvider : CommandProvider { Id = "WindowWalker"; DisplayName = Resources.windowwalker_name; - Icon = IconHelpers.FromRelativePath("Assets\\WindowWalker.svg"); + Icon = Icons.WindowWalkerIcon; Settings = SettingsManager.Instance.Settings; _windowWalkerPageItem = new CommandItem(new WindowWalkerListPage()) { Title = Resources.window_walker_top_level_command_title, - Subtitle = Resources.windowwalker_name, MoreCommands = [ new CommandContextItem(Settings.SettingsPage), ], diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerListItem.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerListItem.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/WindowWalkerListItem.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Action.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Action.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Action.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Action.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Assets/Services.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/Services.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Assets/Services.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/Services.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Assets/Services.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/Services.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Assets/Services.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/Services.svg diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/service_paused.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/service_paused.png new file mode 100644 index 0000000000..a1727ded73 Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/service_paused.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/service_running.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/service_running.png new file mode 100644 index 0000000000..67d5a556c4 Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/service_running.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/service_stopped.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/service_stopped.png new file mode 100644 index 0000000000..daeea91519 Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Assets/service_stopped.png differ diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Commands/OpenServicesCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Commands/OpenServicesCommand.cs similarity index 94% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Commands/OpenServicesCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Commands/OpenServicesCommand.cs index 312a3fc33d..8c7e952ce6 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Commands/OpenServicesCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Commands/OpenServicesCommand.cs @@ -25,7 +25,7 @@ internal sealed partial class OpenServicesCommand : InvokableCommand { _serviceResult = serviceResult; Name = Resources.wox_plugin_service_open_services; - Icon = new IconInfo("\xE8A7"); // OpenInNewWindow icon + Icon = Icons.OpenIcon; } public override CommandResult Invoke() diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Commands/RestartServiceCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Commands/RestartServiceCommand.cs similarity index 95% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Commands/RestartServiceCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Commands/RestartServiceCommand.cs index 48b2a861ee..882697acf2 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Commands/RestartServiceCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Commands/RestartServiceCommand.cs @@ -25,7 +25,7 @@ internal sealed partial class RestartServiceCommand : InvokableCommand { _serviceResult = serviceResult; Name = Resources.wox_plugin_service_restart; - Icon = new IconInfo("\xE72C"); // Refresh icon + Icon = Icons.RefreshIcon; } public override CommandResult Invoke() diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Commands/ServiceCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Commands/ServiceCommand.cs similarity index 66% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Commands/ServiceCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Commands/ServiceCommand.cs index 29a59fafaa..d8b8eec842 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Commands/ServiceCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Commands/ServiceCommand.cs @@ -3,16 +3,10 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Resources; -using System.Text; using System.Threading.Tasks; using Microsoft.CmdPal.Ext.WindowsServices.Helpers; -using Microsoft.CommandPalette.Extensions; +using Microsoft.CmdPal.Ext.WindowsServices.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.UI; namespace Microsoft.CmdPal.Ext.WindowsServices.Commands; @@ -25,15 +19,15 @@ internal sealed partial class ServiceCommand : InvokableCommand { _serviceResult = serviceResult; _action = action; - Name = action.ToString(); - if (serviceResult.IsRunning) + Name = action switch { - Icon = new IconInfo("\xE71A"); // Stop icon - } - else - { - Icon = new IconInfo("\xEDB5"); // Playbadge12 icon - } + Action.Start => Resources.wox_plugin_service_start, + Action.Stop => Resources.wox_plugin_service_stop, + Action.Restart => Resources.wox_plugin_service_restart, + _ => throw new ArgumentOutOfRangeException(nameof(action), action, null), + }; + + Icon = serviceResult.IsRunning ? Icons.StopIcon : Icons.PlayIcon; } public override CommandResult Invoke() diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs similarity index 90% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs index ca10db6a9c..fdbf08dfa8 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Helpers/ServiceHelper.cs @@ -8,6 +8,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.ServiceProcess; +using ManagedCommon; using Microsoft.CmdPal.Ext.WindowsServices.Commands; using Microsoft.CmdPal.Ext.WindowsServices.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -18,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Helpers; public static class ServiceHelper { - public static IEnumerable<ListItem> Search(string search) + public static IEnumerable<ListItem> Search(string search, string filterId) { var services = ServiceController.GetServices().OrderBy(s => s.DisplayName); IEnumerable<ServiceController> serviceList = []; @@ -43,10 +44,25 @@ public static class ServiceHelper serviceList = servicesStartsWith.Concat(servicesContains); } + switch (filterId) + { + case "running": + serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Running); + break; + case "stopped": + serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Stopped); + break; + case "paused": + serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Paused); + break; + case "all": + break; + } + var result = serviceList.Select(s => { var serviceResult = ServiceResult.CreateServiceController(s); - if (serviceResult == null) + if (serviceResult is null) { return null; } @@ -72,16 +88,16 @@ public static class ServiceHelper ]; } - IconInfo icon = new("\U0001f7e2"); // unicode LARGE GREEN CIRCLE + IconInfo icon = Icons.PlayIcon; switch (s.Status) { case ServiceControllerStatus.Stopped: - icon = new("\U0001F534"); // unicode LARGE RED CIRCLE + icon = Icons.StopIcon; break; case ServiceControllerStatus.Running: break; case ServiceControllerStatus.Paused: - icon = new("\u23F8"); // unicode DOUBLE VERTICAL BAR, aka, "Pause" + icon = Icons.PauseIcon; break; } @@ -97,7 +113,7 @@ public static class ServiceHelper // ToolTipData = new ToolTipData(serviceResult.DisplayName, serviceResult.ServiceName), // IcoPath = icoPath, }; - }).Where(s => s != null); + }).Where(s => s is not null); return result; } @@ -147,12 +163,14 @@ public static class ServiceHelper // TODO GH #108 We need to figure out some logging // contextAPI.ShowNotification(GetLocalizedErrorMessage(action), serviceResult.DisplayName); // Log.Error($"The command returned {exitCode}", MethodBase.GetCurrentMethod().DeclaringType); + Logger.LogError($"The command returned {exitCode}"); } } catch (Win32Exception ex) { // TODO GH #108 We need to figure out some logging // Log.Error(ex.Message, MethodBase.GetCurrentMethod().DeclaringType); + Logger.LogError($"Failed to change service '{serviceResult.DisplayName}' status to {action}: {ex.Message}"); } } #pragma warning restore IDE0059, CS0168, SA1005 @@ -173,6 +191,7 @@ public static class ServiceHelper catch (Exception ex) { // TODO GH #108 We need to figure out some logging + Logger.LogError($"Failed to open services.msc: {ex.Message}"); } } #pragma warning restore IDE0059, CS0168, SA1005 diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Icons.cs new file mode 100644 index 0000000000..e726dbeebd --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Icons.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WindowsServices; + +internal sealed class Icons +{ + internal static IconInfo ServicesIcon { get; } = IconHelpers.FromRelativePath("Assets\\Services.svg"); + + internal static IconInfo StopIcon { get; } = IconHelpers.FromRelativePath("Assets\\service_stopped.png"); + + internal static IconInfo PlayIcon { get; } = IconHelpers.FromRelativePath("Assets\\service_running.png"); + + internal static IconInfo RefreshIcon { get; } = new IconInfo("\xE72C"); // Refresh icon + + internal static IconInfo OpenIcon { get; } = new IconInfo("\xE8A7"); // OpenInNewWindow icon + + internal static IconInfo PauseIcon { get; } = IconHelpers.FromRelativePath("Assets\\service_paused.png"); +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Microsoft.CmdPal.Ext.WindowsServices.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Microsoft.CmdPal.Ext.WindowsServices.csproj similarity index 64% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Microsoft.CmdPal.Ext.WindowsServices.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Microsoft.CmdPal.Ext.WindowsServices.csproj index 2b7b9345ec..681b788cac 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Microsoft.CmdPal.Ext.WindowsServices.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Microsoft.CmdPal.Ext.WindowsServices.csproj @@ -1,5 +1,8 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> + <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.WindowsServices</RootNamespace> <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> @@ -13,9 +16,10 @@ </ItemGroup> <ItemGroup> <PackageReference Include="System.ServiceProcess.ServiceController" /> + <!-- WASDK, WebView2, CmdPal Toolkit references now included via Common.ExtDependencies.props --> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> </ItemGroup> <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> @@ -31,6 +35,15 @@ <Content Update="Assets\Services.svg"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> + <Content Update="Assets\service_paused.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\service_running.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Update="Assets\service_stopped.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> </ItemGroup> <ItemGroup> <EmbeddedResource Update="Properties\Resources.resx"> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs new file mode 100644 index 0000000000..9bc2e391ae --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServiceFilters.cs @@ -0,0 +1,27 @@ +// 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.Ext.WindowsServices; +using Microsoft.CmdPal.Ext.WindowsServices.Properties; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public partial class ServiceFilters : Filters +{ + public ServiceFilters() + { + CurrentFilterId = "all"; + } + + public override IFilterItem[] GetFilters() + { + return [ + new Filter() { Id = "all", Name = Resources.Filters_All_Name }, + new Separator(), + new Filter() { Id = "running", Name = Resources.Filters_Running_Name, Icon = Icons.PlayIcon }, + new Filter() { Id = "stopped", Name = Resources.Filters_Stopped_Name, Icon = Icons.StopIcon }, + new Filter() { Id = "paused", Name = Resources.Filters_Paused_Name, Icon = Icons.PauseIcon }, + ]; + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs similarity index 60% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs index 1d7b4d43f1..e9ca178f73 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Pages/ServicesListPage.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information. using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; using Microsoft.CmdPal.Ext.WindowsServices.Helpers; +using Microsoft.CmdPal.Ext.WindowsServices.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -14,15 +14,21 @@ internal sealed partial class ServicesListPage : DynamicListPage { public ServicesListPage() { - Icon = WindowsServicesCommandsProvider.ServicesIcon; - Name = "Windows Services"; + Icon = Icons.ServicesIcon; + Name = Resources.ServicesListPage_Name; + + var filters = new ServiceFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; } + private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged(); + public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0); public override IListItem[] GetItems() { - var items = ServiceHelper.Search(SearchText).ToArray(); + var items = ServiceHelper.Search(SearchText, Filters.CurrentFilterId).ToArray(); return items; } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.Designer.cs similarity index 85% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.Designer.cs index c20800d1c1..2a40d1789a 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Properties { // 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", "17.0.0.0")] + [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 { @@ -60,6 +60,60 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Properties { } } + /// <summary> + /// Looks up a localized string similar to All Services. + /// </summary> + internal static string Filters_All_Name { + get { + return ResourceManager.GetString("Filters_All_Name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Paused. + /// </summary> + internal static string Filters_Paused_Name { + get { + return ResourceManager.GetString("Filters_Paused_Name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Running. + /// </summary> + internal static string Filters_Running_Name { + get { + return ResourceManager.GetString("Filters_Running_Name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Stopped. + /// </summary> + internal static string Filters_Stopped_Name { + get { + return ResourceManager.GetString("Filters_Stopped_Name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Manage Windows services. + /// </summary> + internal static string ManageWindowsServicesCommand_Title { + get { + return ResourceManager.GetString("ManageWindowsServicesCommand_Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Windows Services. + /// </summary> + internal static string ServicesListPage_Name { + get { + return ResourceManager.GetString("ServicesListPage_Name", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Windows Services. /// </summary> @@ -88,7 +142,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Properties { } /// <summary> - /// Looks up a localized string similar to Open services (Ctrl+O). + /// Looks up a localized string similar to Open services. /// </summary> internal static string wox_plugin_service_open_services { get { @@ -133,7 +187,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Properties { } /// <summary> - /// Looks up a localized string similar to Restart (Ctrl+R). + /// Looks up a localized string similar to Restart. /// </summary> internal static string wox_plugin_service_restart { get { @@ -169,7 +223,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Properties { } /// <summary> - /// Looks up a localized string similar to Start (Enter). + /// Looks up a localized string similar to Start. /// </summary> internal static string wox_plugin_service_start { get { @@ -286,7 +340,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Properties { } /// <summary> - /// Looks up a localized string similar to Stop (Enter). + /// Looks up a localized string similar to Stop. /// </summary> internal static string wox_plugin_service_stop { get { diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.resx similarity index 90% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.resx index dd757f4330..ad50181513 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/Properties/Resources.resx @@ -117,17 +117,37 @@ <resheader name="writer"> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> </resheader> + <data name="Filters_All_Name" xml:space="preserve"> + <value>All Services</value> + </data> + <data name="Filters_Paused_Name" xml:space="preserve"> + <value>Paused</value> + </data> + <data name="Filters_Running_Name" xml:space="preserve"> + <value>Running</value> + </data> + <data name="Filters_Stopped_Name" xml:space="preserve"> + <value>Stopped</value> + </data> + <data name="ManageWindowsServicesCommand_Title" xml:space="preserve"> + <value>Manage Windows services</value> + </data> + <data name="ServicesListPage_Name" xml:space="preserve"> + <value>Windows Services</value> + </data> <data name="WindowsServicesProvider_DisplayName" xml:space="preserve"> <value>Windows Services</value> </data> <data name="wox_plugin_service_continue_pending" xml:space="preserve"> <value>Continue</value> + <comment>Verb, stop selected service</comment> </data> <data name="wox_plugin_service_name" xml:space="preserve"> <value>Name</value> </data> <data name="wox_plugin_service_open_services" xml:space="preserve"> <value>Open services</value> + <comment>Verb (to open Windows Services MSC)</comment> </data> <data name="wox_plugin_service_paused" xml:space="preserve"> <value>Paused</value> @@ -143,6 +163,7 @@ </data> <data name="wox_plugin_service_restart" xml:space="preserve"> <value>Restart</value> + <comment>Verb, restart selected service</comment> </data> <data name="wox_plugin_service_restarted_notification" xml:space="preserve"> <value>The service has been restarted</value> @@ -154,7 +175,8 @@ <value>Running</value> </data> <data name="wox_plugin_service_start" xml:space="preserve"> - <value>Start (Enter)</value> + <value>Start</value> + <comment>Verb, start selected service</comment> </data> <data name="wox_plugin_service_started" xml:space="preserve"> <value>Started</value> @@ -193,7 +215,8 @@ <value>Status</value> </data> <data name="wox_plugin_service_stop" xml:space="preserve"> - <value>Stop (Enter)</value> + <value>Stop</value> + <comment>Verb, stop selected service</comment> </data> <data name="wox_plugin_service_stopped" xml:space="preserve"> <value>Stopped</value> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/ServiceResult.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/ServiceResult.cs similarity index 89% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/ServiceResult.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/ServiceResult.cs index ff7942a055..5d8740fa62 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/ServiceResult.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/ServiceResult.cs @@ -4,6 +4,7 @@ using System; using System.ServiceProcess; +using ManagedCommon; namespace Microsoft.CmdPal.Ext.WindowsServices; @@ -35,10 +36,11 @@ public class ServiceResult return result; } - catch (Exception) + catch (Exception ex) { // try to log the exception in the future // retrieve properties from serviceController will throw exception. Such as PlatformNotSupportedException. + Logger.LogError($"Failed to create ServiceController: {ex.GetType().Name} - {ex.Message}"); } return null; diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs similarity index 78% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs index 57110128da..48bbe9deee 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsServices/WindowsServicesCommandsProvider.cs @@ -11,13 +11,11 @@ namespace Microsoft.CmdPal.Ext.WindowsServices; public partial class WindowsServicesCommandsProvider : CommandProvider { // For giggles, "%windir%\\system32\\filemgmt.dll" also _just works_. - public static IconInfo ServicesIcon { get; } = IconHelpers.FromRelativePath("Assets\\Services.svg"); - public WindowsServicesCommandsProvider() { Id = "Windows.Services"; DisplayName = Resources.WindowsServicesProvider_DisplayName; - Icon = ServicesIcon; + Icon = Icons.ServicesIcon; } public override ICommandItem[] TopLevelCommands() @@ -25,8 +23,7 @@ public partial class WindowsServicesCommandsProvider : CommandProvider return [ new CommandItem(new ServicesListPage()) { - Title = "Windows Services", - Subtitle = "Manage Windows Services", + Title = Resources.ManageWindowsServicesCommand_Title, } ]; } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Assets/WindowsSettings.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Assets/WindowsSettings.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Assets/WindowsSettings.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Assets/WindowsSettings.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Assets/WindowsSettings.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Assets/WindowsSettings.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Assets/WindowsSettings.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Assets/WindowsSettings.svg diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSetting.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSetting.cs similarity index 92% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSetting.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSetting.cs index fa6485d138..b276d3a876 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSetting.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSetting.cs @@ -19,7 +19,7 @@ internal sealed class WindowsSetting Name = string.Empty; Command = string.Empty; Type = string.Empty; - ShowAsFirstResult = false; + AppHomepageScore = 0; } /// <summary> @@ -65,9 +65,9 @@ internal sealed class WindowsSetting public uint? DeprecatedInBuild { get; set; } /// <summary> - /// Gets or sets a value indicating whether to use a higher score as normal for this setting to show it as one of the first results. + /// Gets or sets the score for entries if they are a settings app (homepage). If the score is higher 0 they are shown on empty query. /// </summary> - public bool ShowAsFirstResult { get; set; } + public int AppHomepageScore { get; set; } /// <summary> /// Gets or sets the value with the generated area path as string. diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSettings.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSettings.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSettings.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Classes/WindowsSettings.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Commands/OpenSettingsCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Commands/OpenSettingsCommand.cs similarity index 96% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Commands/OpenSettingsCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Commands/OpenSettingsCommand.cs index 9460cd9240..fd94bc63da 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Commands/OpenSettingsCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Commands/OpenSettingsCommand.cs @@ -10,7 +10,6 @@ using System.Resources; using System.Text; using System.Threading.Tasks; using Microsoft.CmdPal.Ext.WindowsSettings.Classes; -using Microsoft.CmdPal.Ext.WindowsSettings.Helpers; using Microsoft.CmdPal.Ext.WindowsSettings.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -27,7 +26,7 @@ internal sealed partial class OpenSettingsCommand : InvokableCommand internal OpenSettingsCommand(WindowsSetting entry) { Name = Resources.OpenSettings; - Icon = new IconInfo("\xE8C8"); + Icon = Icons.CopyIcon; _entry = entry; } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ContextMenuHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ContextMenuHelper.cs similarity index 91% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ContextMenuHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ContextMenuHelper.cs index 83a0d2eb8c..855a47e1a7 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ContextMenuHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ContextMenuHelper.cs @@ -23,7 +23,7 @@ internal static class ContextMenuHelper { var list = new List<CommandContextItem>(1) { - new(new CopySettingCommand(entry)), + new(new CopyTextCommand(entry.Command) { Name = Resources.CopyCommand }), }; return list; diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs similarity index 80% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs index f99f0ce3b3..e240c3fadc 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs @@ -4,10 +4,9 @@ using System; using System.IO; -using System.Linq; using System.Reflection; using System.Text.Json; -using System.Text.Json.Serialization; +using ManagedCommon; namespace Microsoft.CmdPal.Ext.WindowsSettings.Helpers; @@ -21,6 +20,8 @@ internal static class JsonSettingsListHelper /// </summary> private const string _settingsFile = "WindowsSettings.json"; + private const string _extTypeNamespace = "Microsoft.CmdPal.Ext.WindowsSettings"; + private static readonly JsonSerializerOptions _serializerOptions = new() { }; @@ -32,7 +33,6 @@ internal static class JsonSettingsListHelper internal static Classes.WindowsSettings ReadAllPossibleSettings() { var assembly = Assembly.GetExecutingAssembly(); - var type = assembly.GetTypes().FirstOrDefault(x => x.Name == nameof(WindowsSettingsCommandsProvider)); #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. Classes.WindowsSettings? settings = null; @@ -40,7 +40,7 @@ internal static class JsonSettingsListHelper try { - var resourceName = $"{type?.Namespace}.{_settingsFile}"; + var resourceName = $"{_extTypeNamespace}.{_settingsFile}"; using var stream = assembly.GetManifestResourceStream(resourceName); if (stream is null) { @@ -48,18 +48,20 @@ internal static class JsonSettingsListHelper } var options = _serializerOptions; - options.Converters.Add(new JsonStringEnumConverter()); + // Why we need it? I don't see any enum usage in WindowsSettings + // options.Converters.Add(new JsonStringEnumConverter()); using var reader = new StreamReader(stream); var text = reader.ReadToEnd(); - settings = JsonSerializer.Deserialize<Classes.WindowsSettings>(text, options); + settings = JsonSerializer.Deserialize(text, WindowsSettingsJsonSerializationContext.Default.WindowsSettings); } #pragma warning disable CS0168 catch (Exception exception) { // TODO GH #108 Logging is something we have to take care of // Log.Exception("Error loading settings JSON file", exception, typeof(JsonSettingsListHelper)); + Logger.LogError($"Error loading settings JSON file: {exception.Message}"); } #pragma warning restore CS0168 return settings ?? new Classes.WindowsSettings(); diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ResultHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ResultHelper.cs similarity index 96% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ResultHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ResultHelper.cs index 70d6c48ac2..fb4828688c 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ResultHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ResultHelper.cs @@ -8,7 +8,6 @@ using System.Diagnostics; using System.Globalization; using System.Linq; using System.Text; - using Microsoft.CmdPal.Ext.WindowsSettings.Commands; using Microsoft.CmdPal.Ext.WindowsSettings.Helpers; using Microsoft.CmdPal.Ext.WindowsSettings.Properties; @@ -22,8 +21,7 @@ namespace Microsoft.CmdPal.Ext.WindowsSettings; internal static class ResultHelper { internal static List<ListItem> GetResultList( - in IEnumerable<Classes.WindowsSetting> list, - string query) + in IEnumerable<Classes.WindowsSetting> list) { var resultList = new List<ListItem>(list.Count()); @@ -31,7 +29,7 @@ internal static class ResultHelper { var result = new ListItem(new OpenSettingsCommand(entry)) { - Icon = IconHelpers.FromRelativePath("Assets\\WindowsSettings.svg"), + Icon = Icons.WindowsSettingsIcon, Subtitle = entry.JoinedFullSettingsPath, Title = entry.Name, MoreCommands = ContextMenuHelper.GetContextMenu(entry).ToArray(), diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ScoringHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ScoringHelper.cs new file mode 100644 index 0000000000..bf720e01cd --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/ScoringHelper.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.CmdPal.Ext.WindowsSettings.Classes; + +namespace Microsoft.CmdPal.Ext.WindowsSettings.Helpers; + +internal static class ScoringHelper +{ + // Rank settings by how they matched the search query. Order is: + // 1. Exact Name (10 points) + // 2. Name Starts With (8 points) + // 3. Name (5 points) + // 4. Area (4 points) + // 5. AltName (2 points) + // 6. Settings path (1 point) + internal static (WindowsSetting Setting, int Score) SearchScoringPredicate(string query, WindowsSetting setting) + { + if (string.IsNullOrWhiteSpace(query)) + { + // If no search string is entered skip query comparison. + return (setting, 0); + } + + if (string.Equals(setting.Name, query, StringComparison.OrdinalIgnoreCase)) + { + return (setting, 10); + } + + if (setting.Name.StartsWith(query, StringComparison.CurrentCultureIgnoreCase)) + { + return (setting, 8); + } + + if (setting.Name.Contains(query, StringComparison.CurrentCultureIgnoreCase)) + { + return (setting, 5); + } + + if (!(setting.Areas is null)) + { + foreach (var area in setting.Areas) + { + // Search for areas on normal queries. + if (area.Contains(query, StringComparison.CurrentCultureIgnoreCase)) + { + return (setting, 4); + } + + // Search for Area only on queries with action char. + if (area.Contains(query.Replace(":", string.Empty), StringComparison.CurrentCultureIgnoreCase) + && query.EndsWith(":", StringComparison.CurrentCultureIgnoreCase)) + { + return (setting, 4); + } + } + } + + if (!(setting.AltNames is null)) + { + foreach (var altName in setting.AltNames) + { + if (altName.Contains(query, StringComparison.CurrentCultureIgnoreCase)) + { + return (setting, 2); + } + } + } + + // Search by key char '>' for app name and settings path + if (query.Contains('>') && ResultHelper.FilterBySettingsPath(setting, query)) + { + return (setting, 1); + } + + return (setting, 0); + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/TranslationHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/TranslationHelper.cs similarity index 90% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/TranslationHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/TranslationHelper.cs index b5a8c29845..c15159d69b 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/TranslationHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/TranslationHelper.cs @@ -7,6 +7,7 @@ using System.Collections.ObjectModel; using System.Globalization; using System.Linq; +using ManagedCommon; using Microsoft.CmdPal.Ext.WindowsSettings.Properties; namespace Microsoft.CmdPal.Ext.WindowsSettings.Helpers; @@ -36,6 +37,7 @@ internal static class TranslationHelper if (string.IsNullOrEmpty(name)) { // Log.Warn($"Resource string for [{settings.Name}] not found", typeof(TranslationHelper)); + Logger.LogWarning($"Resource string for [{settings.Name}] not found"); } settings.Name = name ?? settings.Name ?? string.Empty; @@ -48,6 +50,7 @@ internal static class TranslationHelper if (string.IsNullOrEmpty(type)) { // Log.Warn($"Resource string for [{settings.Type}] not found", typeof(TranslationHelper)); + Logger.LogWarning($"Resource string for [{settings.Type}] not found"); } settings.Type = type ?? settings.Type ?? string.Empty; @@ -69,6 +72,7 @@ internal static class TranslationHelper if (string.IsNullOrEmpty(translatedArea)) { // Log.Warn($"Resource string for [{area}] not found", typeof(TranslationHelper)); + Logger.LogWarning($"Resource string for [{area}] not found"); } translatedAreas.Add(translatedArea ?? area); @@ -93,6 +97,7 @@ internal static class TranslationHelper if (string.IsNullOrEmpty(translatedAltName)) { // Log.Warn($"Resource string for [{altName}] not found", typeof(TranslationHelper)); + Logger.LogWarning($"Resource string for [{altName}] not found"); } translatedAltNames.Add(translatedAltName ?? altName); @@ -108,6 +113,7 @@ internal static class TranslationHelper if (string.IsNullOrEmpty(note)) { // Log.Warn($"Resource string for [{settings.Note}] not found", typeof(TranslationHelper)); + Logger.LogWarning($"Resource string for [{settings.Note}] not found"); } settings.Note = note ?? settings.Note ?? string.Empty; diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/UnsupportedSettingsHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/UnsupportedSettingsHelper.cs similarity index 91% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/UnsupportedSettingsHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/UnsupportedSettingsHelper.cs index b0e8a76ae9..c53844a005 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/UnsupportedSettingsHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/UnsupportedSettingsHelper.cs @@ -4,6 +4,7 @@ using System; using System.Linq; +using ManagedCommon; namespace Microsoft.CmdPal.Ext.WindowsSettings.Helpers; @@ -40,6 +41,7 @@ internal static class UnsupportedSettingsHelper // TODO GH #108 Logging is something we have to take care of // Log.Warn(warningMessage, typeof(UnsupportedSettingsHelper)); + Logger.LogWarning(warningMessage); } var currentWindowsBuild = currentBuild != uint.MinValue @@ -47,8 +49,8 @@ internal static class UnsupportedSettingsHelper : currentBuildNumber; var filteredSettingsList = windowsSettings.Settings.Where(found - => (found.DeprecatedInBuild == null || currentWindowsBuild < found.DeprecatedInBuild) - && (found.IntroducedInBuild == null || currentWindowsBuild >= found.IntroducedInBuild)); + => (found.DeprecatedInBuild is null || currentWindowsBuild < found.DeprecatedInBuild) + && (found.IntroducedInBuild is null || currentWindowsBuild >= found.IntroducedInBuild)); filteredSettingsList = filteredSettingsList.OrderBy(found => found.Name); @@ -71,12 +73,9 @@ internal static class UnsupportedSettingsHelper { registryValueData = Win32.Registry.GetValue(registryKey, valueName, uint.MinValue); } - catch + catch (Exception ex) { - // Log.Exception( - // $"Can't get registry value for '{valueName}'", - // exception, - // typeof(UnsupportedSettingsHelper)); + Logger.LogError($"Can't get registry value for '{valueName}' - {ex.Message}"); return uint.MinValue; } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/WindowsSettingsPathHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/WindowsSettingsPathHelper.cs similarity index 85% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/WindowsSettingsPathHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/WindowsSettingsPathHelper.cs index d7866d892f..b42b450fc3 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/WindowsSettingsPathHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/WindowsSettingsPathHelper.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Linq; +using ManagedCommon; namespace Microsoft.CmdPal.Ext.WindowsSettings.Helpers; @@ -34,6 +35,7 @@ internal static class WindowsSettingsPathHelper { // TODO GH #108 Logging is something we have to take care of // Log.Warn($"The type property is not set for setting [{settings.Name}] in json. Skipping generating of settings path.", typeof(WindowsSettingsPathHelper)); + Logger.LogWarning($"The type property is not set for setting [{settings.Name}] in json. Skipping generating of settings path."); continue; } @@ -41,12 +43,14 @@ internal static class WindowsSettingsPathHelper if (!string.IsNullOrEmpty(settings.JoinedAreaPath)) { // Log.Debug($"The property [JoinedAreaPath] of setting [{settings.Name}] was filled from the json. This value is not used and will be overwritten.", typeof(WindowsSettingsPathHelper)); + Logger.LogDebug($"The property [JoinedAreaPath] of setting [{settings.Name}] was filled from the json. This value is not used and will be overwritten."); } if (!string.IsNullOrEmpty(settings.JoinedFullSettingsPath)) { // TODO GH #108 Logging is something we have to take care of // Log.Debug($"The property [JoinedFullSettingsPath] of setting [{settings.Name}] was filled from the json. This value is not used and will be overwritten.", typeof(WindowsSettingsPathHelper)); + Logger.LogDebug($"The property [JoinedFullSettingsPath] of setting [{settings.Name}] was filled from the json. This value is not used and will be overwritten."); } // Generating path values. diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Icons.cs new file mode 100644 index 0000000000..9534523f60 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Icons.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. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WindowsSettings; + +internal sealed class Icons +{ + internal static IconInfo WindowsSettingsIcon { get; } = IconHelpers.FromRelativePath("Assets\\WindowsSettings.svg"); + + internal static IconInfo CopyIcon { get; } = new IconInfo("\xE8C8"); // Copy icon +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Images/WindowsSettings.dark.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Images/WindowsSettings.dark.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Images/WindowsSettings.dark.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Images/WindowsSettings.dark.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Images/WindowsSettings.light.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Images/WindowsSettings.light.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Images/WindowsSettings.light.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Images/WindowsSettings.light.png diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/JsonSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/JsonSerializationContext.cs new file mode 100644 index 0000000000..e267e8f52e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/JsonSerializationContext.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.Ext.WindowsSettings; + +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(Classes.WindowsSettings))] +[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] +internal sealed partial class WindowsSettingsJsonSerializationContext : JsonSerializerContext +{ +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj similarity index 79% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj index d44e907606..85b5d386ad 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj @@ -1,5 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.WindowsSettings</RootNamespace> <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> @@ -17,9 +19,10 @@ </ItemGroup> <ItemGroup> <PackageReference Include="System.ServiceProcess.ServiceController" /> + <!-- WASDK, WebView2, CmdPal Toolkit references now included via Common.ExtDependencies.props --> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> </ItemGroup> <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Pages/FallbackWindowsSettingsItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Pages/FallbackWindowsSettingsItem.cs new file mode 100644 index 0000000000..dc7d320a75 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Pages/FallbackWindowsSettingsItem.cs @@ -0,0 +1,88 @@ +// 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.Linq; +using Microsoft.CmdPal.Ext.WindowsSettings.Commands; +using Microsoft.CmdPal.Ext.WindowsSettings.Helpers; +using Microsoft.CmdPal.Ext.WindowsSettings.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WindowsSettings.Pages; + +internal sealed partial class FallbackWindowsSettingsItem : FallbackCommandItem +{ + private const string _id = "com.microsoft.cmdpal.builtin.windows.settings.fallback"; + + private readonly Classes.WindowsSettings _windowsSettings; + + private readonly string _title = Resources.settings_fallback_title; + private readonly string _subtitle = Resources.settings_fallback_subtitle; + + public FallbackWindowsSettingsItem(Classes.WindowsSettings windowsSettings) + : base(new NoOpCommand(), Resources.settings_title, _id) + { + Icon = Icons.WindowsSettingsIcon; + _windowsSettings = windowsSettings; + } + + public override void UpdateQuery(string query) + { + Command = new NoOpCommand(); + Title = string.Empty; + Subtitle = string.Empty; + Icon = null; + MoreCommands = null; + + if (string.IsNullOrWhiteSpace(query) || + _windowsSettings?.Settings is null) + { + return; + } + + var filteredList = _windowsSettings.Settings + .Select(setting => ScoringHelper.SearchScoringPredicate(query, setting)) + .Where(scoredSetting => scoredSetting.Score > 0) + .OrderByDescending(scoredSetting => scoredSetting.Score); + + if (!filteredList.Any()) + { + return; + } + + if (filteredList.Count() == 1 || + filteredList.Any(a => a.Score == 10)) + { + var setting = filteredList.First().Setting; + + Title = setting.Name; + Subtitle = setting.JoinedFullSettingsPath; + Icon = Icons.WindowsSettingsIcon; + Command = new OpenSettingsCommand(setting) + { + Icon = Icons.WindowsSettingsIcon, + Name = setting.Name, + }; + + // There is a case with MMC snap-ins where we don't have .msc files fort them. Then we need to show the note for this results in subtitle too. + // These results have mmc.exe as command and their note property is filled. + if (setting.Command == "mmc.exe" && !string.IsNullOrEmpty(setting.Note)) + { + Subtitle += $"\u0020\u0020\u002D\u0020\u0020{Resources.Note}: {setting.Note}"; // "\u0020\u0020\u002D\u0020\u0020" = "<space><space><minus><space><space>" + } + + return; + } + + // We found more than one result. Make our command take + // us to the Windows Settings search page, prepopulated with this search. + var settingsPage = new WindowsSettingsListPage(_windowsSettings, query); + Title = string.Format(CultureInfo.CurrentCulture, _title, query); + Icon = Icons.WindowsSettingsIcon; + Subtitle = _subtitle; + Command = settingsPage; + + return; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Pages/WindowsSettingsListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Pages/WindowsSettingsListPage.cs new file mode 100644 index 0000000000..1196de0b31 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Pages/WindowsSettingsListPage.cs @@ -0,0 +1,82 @@ +// 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 Microsoft.CmdPal.Ext.WindowsSettings.Classes; +using Microsoft.CmdPal.Ext.WindowsSettings.Helpers; +using Microsoft.CmdPal.Ext.WindowsSettings.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WindowsSettings; + +internal sealed partial class WindowsSettingsListPage : DynamicListPage +{ + private readonly Classes.WindowsSettings _windowsSettings; + + public WindowsSettingsListPage(Classes.WindowsSettings windowsSettings) + { + Icon = Icons.WindowsSettingsIcon; + Name = Resources.settings_title; + Id = "com.microsoft.cmdpal.windowsSettings"; + _windowsSettings = windowsSettings; + + EmptyContent = new CommandItem(new NoOpCommand()) + { + Icon = Icon, + Title = Resources.settings_subtitle, + Subtitle = Resources.PluginNoResultsMessage + "\n\n" + Resources.PluginNoResultsMessageHelp, + }; + } + + public WindowsSettingsListPage(Classes.WindowsSettings windowsSettings, string query) + : this(windowsSettings) + { + SearchText = query; + } + + public List<ListItem> Query(string query) + { + if (_windowsSettings?.Settings is null) + { + return new List<ListItem>(0); + } + + var filteredList = _windowsSettings.Settings; + if (!string.IsNullOrEmpty(query)) + { + filteredList = filteredList + .Select(setting => ScoringHelper.SearchScoringPredicate(query, setting)) + .Where(scoredSetting => scoredSetting.Score > 0) + .OrderByDescending(scoredSetting => scoredSetting.Score) + .Select(scoredSetting => scoredSetting.Setting); + } + else + { + filteredList = filteredList + .Where(s => s.AppHomepageScore > 0) + .OrderByDescending(s => s.AppHomepageScore); + } + + var newList = ResultHelper.GetResultList(filteredList); + return newList; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + if (oldSearch != newSearch) + { + RaiseItemsChanged(0); + } + } + + public override IListItem[] GetItems() + { + var items = Query(SearchText).ToArray(); + + return items; + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.Designer.cs similarity index 98% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.Designer.cs index 0a421820e8..114ff4912a 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.Designer.cs @@ -322,7 +322,7 @@ namespace Microsoft.CmdPal.Ext.WindowsSettings.Properties { } /// <summary> - /// Looks up a localized string similar to System settings. + /// Looks up a localized string similar to Settings app. /// </summary> internal static string AppSettingsApp { get { @@ -3049,7 +3049,7 @@ namespace Microsoft.CmdPal.Ext.WindowsSettings.Properties { } /// <summary> - /// Looks up a localized string similar to Control Panel (Application homepage). + /// Looks up a localized string similar to Open Control Panel. /// </summary> internal static string OpenControlPanel { get { @@ -3058,7 +3058,16 @@ namespace Microsoft.CmdPal.Ext.WindowsSettings.Properties { } /// <summary> - /// Looks up a localized string similar to Open Settings. + /// Looks up a localized string similar to Open Microsoft Management Console. + /// </summary> + internal static string OpenMMC { + get { + return ResourceManager.GetString("OpenMMC", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open. /// </summary> internal static string OpenSettings { get { @@ -3067,7 +3076,7 @@ namespace Microsoft.CmdPal.Ext.WindowsSettings.Properties { } /// <summary> - /// Looks up a localized string similar to Settings (Application homepage). + /// Looks up a localized string similar to Open Settings app. /// </summary> internal static string OpenSettingsApp { get { @@ -3345,6 +3354,24 @@ namespace Microsoft.CmdPal.Ext.WindowsSettings.Properties { } } + /// <summary> + /// Looks up a localized string similar to No settings found. + /// </summary> + internal static string PluginNoResultsMessage { + get { + return ResourceManager.GetString("PluginNoResultsMessage", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Tip: Use ':' to search for setting categories (e.g., Update:), and > to search by setting path (e.g., Settings app>Apps).. + /// </summary> + internal static string PluginNoResultsMessageHelp { + get { + return ResourceManager.GetString("PluginNoResultsMessageHelp", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Windows settings. /// </summary> @@ -3840,6 +3867,42 @@ namespace Microsoft.CmdPal.Ext.WindowsSettings.Properties { } } + /// <summary> + /// Looks up a localized string similar to Search Windows settings for this device. + /// </summary> + internal static string settings_fallback_subtitle { + get { + return ResourceManager.GetString("settings_fallback_subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Search for "{0}" in Windows settings. + /// </summary> + internal static string settings_fallback_title { + get { + return ResourceManager.GetString("settings_fallback_title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Navigate to specific Windows settings. + /// </summary> + internal static string settings_subtitle { + get { + return ResourceManager.GetString("settings_subtitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Windows Settings. + /// </summary> + internal static string settings_title { + get { + return ResourceManager.GetString("settings_title", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Settings app. /// </summary> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.resx similarity index 98% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.resx index c097ed0841..95b4b5a174 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Properties/Resources.resx @@ -228,7 +228,7 @@ <comment>Area Apps</comment> </data> <data name="AppSettingsApp" xml:space="preserve"> - <value>System settings</value> + <value>Settings app</value> <comment>Type of the setting is a "Modern Windows settings". We use the same term as used in start menu search at the moment. </comment> </data> <data name="AppsForWebsites" xml:space="preserve"> @@ -1319,11 +1319,11 @@ <value>On-Screen</value> </data> <data name="OpenControlPanel" xml:space="preserve"> - <value>Control Panel (Application homepage)</value> + <value>Open Control Panel</value> <comment>'Control Panel' is here the name of the legacy settings app.</comment> </data> <data name="OpenSettingsApp" xml:space="preserve"> - <value>Settings (Application homepage)</value> + <value>Open Settings app</value> <comment>'Settings' is here the name of the modern settings app.</comment> </data> <data name="Os" xml:space="preserve"> @@ -2080,9 +2080,31 @@ <comment>Mean zooming of things via a magnifier</comment> </data> <data name="OpenSettings" xml:space="preserve"> - <value>Open Settings</value> + <value>Open</value> + <comment>Open 'the setting' in Settings app, Control Panel or MMC.</comment> </data> <data name="WindowsSettingsProvider_DisplayName" xml:space="preserve"> <value>Windows Settings</value> </data> + <data name="settings_title" xml:space="preserve"> + <value>Windows Settings</value> + </data> + <data name="settings_subtitle" xml:space="preserve"> + <value>Navigate to specific Windows settings</value> + </data> + <data name="settings_fallback_title" xml:space="preserve"> + <value>Search for "{0}" in Windows settings</value> + </data> + <data name="settings_fallback_subtitle" xml:space="preserve"> + <value>Search Windows settings for this device</value> + </data> + <data name="PluginNoResultsMessageHelp" xml:space="preserve"> + <value>Tip: Use ':' to search for setting categories (e.g., Update:), and > to search by setting path (e.g., Settings app>Apps).</value> + </data> + <data name="PluginNoResultsMessage" xml:space="preserve"> + <value>No settings found</value> + </data> + <data name="OpenMMC" xml:space="preserve"> + <value>Open Microsoft Management Console</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.json similarity index 99% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.json rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.json index 2695b7c1d2..794dbcc280 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.json +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.json @@ -6,13 +6,13 @@ "Type": "AppSettingsApp", "AltNames": [ "SettingsApp", "AppSettingsApp" ], "Command": "ms-settings:", - "ShowAsFirstResult": true + "AppHomepageScore": 30 }, { "Name": "OpenControlPanel", "Type": "AppControlPanel", "Command": "control.exe", - "ShowAsFirstResult": true + "AppHomepageScore": 20 }, { "Name": "AccessWorkOrSchool", @@ -295,7 +295,7 @@ "Areas": [ "AreaEaseOfAccess" ], "Type": "AppSettingsApp", "AltNames": [ "TouchFeedback" ], - "Command": "ms-settings:easeofaccess-MousePointer" + "Command": "ms-settings:easeofaccess-mousepointer" }, { "Name": "Display", @@ -1834,11 +1834,11 @@ "Command": "ms-settings-connectabledevices:devicediscovery" }, { - "Name": "AppMMC", + "Name": "OpenMMC", "Type": "AppMMC", "AltNames": [ "MMC_mmcexe" ], "Command": "mmc.exe", - "ShowAsFirstResult" : true + "AppHomepageScore" : 10 }, { "Name": "AuthorizationManager", diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.schema.json b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.schema.json similarity index 91% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.schema.json rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.schema.json index a60e5c5ffd..ad7f84b4bd 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.schema.json +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettings.schema.json @@ -62,9 +62,10 @@ "minimum": 0, "maximum": 4294967295 }, - "ShowAsFirstResult": { - "description": "Use a higher score as normal for this setting to show it as one of the first results.", - "type": "boolean" + "AppHomepageScore": { + "description": "Order score for the result if it is a settings app (homepage). Use a score > 0.", + "type": "integer", + "minimum": 1 } } } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs similarity index 77% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs index bf4ccfb36e..13728f3992 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/WindowsSettingsCommandsProvider.cs @@ -3,13 +3,14 @@ // See the LICENSE file in the project root for more information. using Microsoft.CmdPal.Ext.WindowsSettings.Helpers; +using Microsoft.CmdPal.Ext.WindowsSettings.Pages; using Microsoft.CmdPal.Ext.WindowsSettings.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.WindowsSettings; -public partial class WindowsSettingsCommandsProvider : CommandProvider +public sealed partial class WindowsSettingsCommandsProvider : CommandProvider { private readonly CommandItem _searchSettingsListItem; @@ -17,18 +18,20 @@ public partial class WindowsSettingsCommandsProvider : CommandProvider private readonly WindowsSettings.Classes.WindowsSettings? _windowsSettings; #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + private readonly FallbackWindowsSettingsItem _fallback; + public WindowsSettingsCommandsProvider() { - Id = "Windows.Settings"; + Id = "com.microsoft.cmdpal.builtin.windowssettings"; DisplayName = Resources.WindowsSettingsProvider_DisplayName; - Icon = IconHelpers.FromRelativePath("Assets\\WindowsSettings.svg"); + Icon = Icons.WindowsSettingsIcon; _windowsSettings = JsonSettingsListHelper.ReadAllPossibleSettings(); _searchSettingsListItem = new CommandItem(new WindowsSettingsListPage(_windowsSettings)) { - Title = "Windows Settings", - Subtitle = "Navigate to specific Windows settings", + Title = Resources.settings_title, }; + _fallback = new(_windowsSettings); UnsupportedSettingsHelper.FilterByBuild(_windowsSettings); @@ -42,4 +45,6 @@ public partial class WindowsSettingsCommandsProvider : CommandProvider _searchSettingsListItem ]; } + + public override IFallbackCommandItem[] FallbackCommands() => [_fallback]; } diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.dark.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.dark.png similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.dark.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.dark.png diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.light.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.light.png similarity index 100% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.light.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.light.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.png similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.png rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.png diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.svg similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.svg rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Assets/WindowsTerminal.svg diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileAsAdminCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileAsAdminCommand.cs similarity index 67% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileAsAdminCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileAsAdminCommand.cs index fd8354495a..4a0604a995 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileAsAdminCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileAsAdminCommand.cs @@ -3,17 +3,14 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Resources; -using System.Text; -using System.Threading.Tasks; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using ManagedCommon; +using ManagedCsWin32; using Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; using Microsoft.CmdPal.Ext.WindowsTerminal.Properties; -using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.UI; namespace Microsoft.CmdPal.Ext.WindowsTerminal.Commands; @@ -23,16 +20,18 @@ internal sealed partial class LaunchProfileAsAdminCommand : InvokableCommand private readonly string _profile; private readonly bool _openNewTab; private readonly bool _openQuake; + private readonly AppSettingsManager _appSettingsManager; - internal LaunchProfileAsAdminCommand(string id, string profile, bool openNewTab, bool openQuake) + internal LaunchProfileAsAdminCommand(string id, string profile, bool openNewTab, bool openQuake, AppSettingsManager appSettingsManager) { this._id = id; this._profile = profile; this._openNewTab = openNewTab; this._openQuake = openQuake; + this._appSettingsManager = appSettingsManager; this.Name = Resources.launch_profile_as_admin; - this.Icon = new IconInfo("\xE7EF"); // Admin icon + this.Icon = Icons.AdminIcon; } private void LaunchElevated(string id, string profile) @@ -60,13 +59,36 @@ internal sealed partial class LaunchProfileAsAdminCommand : InvokableCommand //var message = Resources.run_terminal_failed; //Log.Exception("Failed to open Windows Terminal", ex, GetType()); //_context.API.ShowMsg(name, message, string.Empty); + Logger.LogError($"Failed to open Windows Terminal: {ex.Message}"); + } + + try + { + _appSettingsManager.Current.AddRecentlyUsedProfile(id, profile); + _appSettingsManager.Save(); + } + catch (Exception ex) + { + // We don't want to fail the whole operation if we can't save the recently used profile + Logger.LogError($"Failed to save recently used profile: {ex.Message}"); } } #pragma warning restore IDE0059, CS0168, SA1005 private void Launch(string id, string profile) { - var appManager = new ApplicationActivationManager(); + IApplicationActivationManager appManager; + + try + { + appManager = ComHelper.CreateComInstance<IApplicationActivationManager>(ref Unsafe.AsRef(in CLSID.ApplicationActivationManager), CLSCTX.InProcServer); + } + catch (Exception e) + { + Logger.LogError($"Failed to create IApplicationActivationManager instance. ex: {e.Message}"); + throw; + } + const ActivateOptions noFlags = ActivateOptions.None; var queryArguments = TerminalHelper.GetArguments(profile, _openNewTab, _openQuake); try @@ -81,6 +103,7 @@ internal sealed partial class LaunchProfileAsAdminCommand : InvokableCommand // var message = Resources.run_terminal_failed; // Log.Exception("Failed to open Windows Terminal", ex, GetType()); // _context.API.ShowMsg(name, message, string.Empty); + Logger.LogError($"Failed to open Windows Terminal: {ex.Message}"); } } #pragma warning restore IDE0059, CS0168 @@ -94,6 +117,7 @@ internal sealed partial class LaunchProfileAsAdminCommand : InvokableCommand catch { // TODO GH #108 We need to figure out some logging + // No need to log here, as the exception is already logged in LaunchElevated } return CommandResult.Dismiss(); diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileCommand.cs similarity index 59% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileCommand.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileCommand.cs index 25124fb33c..0ea01b191d 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Commands/LaunchProfileCommand.cs @@ -3,17 +3,14 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Resources; -using System.Text; -using System.Threading.Tasks; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using ManagedCommon; +using ManagedCsWin32; using Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; using Microsoft.CmdPal.Ext.WindowsTerminal.Properties; -using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.UI; namespace Microsoft.CmdPal.Ext.WindowsTerminal.Commands; @@ -23,13 +20,15 @@ internal sealed partial class LaunchProfileCommand : InvokableCommand private readonly string _profile; private readonly bool _openNewTab; private readonly bool _openQuake; + private readonly AppSettingsManager _appSettingsManager; - internal LaunchProfileCommand(string id, string profile, string iconPath, bool openNewTab, bool openQuake) + internal LaunchProfileCommand(string id, string profile, string iconPath, bool openNewTab, bool openQuake, AppSettingsManager appSettingsManager) { this._id = id; this._profile = profile; this._openNewTab = openNewTab; this._openQuake = openQuake; + this._appSettingsManager = appSettingsManager; this.Name = Resources.launch_profile; this.Icon = new IconInfo(iconPath); @@ -37,7 +36,18 @@ internal sealed partial class LaunchProfileCommand : InvokableCommand private void Launch(string id, string profile) { - var appManager = new ApplicationActivationManager(); + IApplicationActivationManager appManager; + + try + { + appManager = ComHelper.CreateComInstance<IApplicationActivationManager>(ref Unsafe.AsRef(in CLSID.ApplicationActivationManager), CLSCTX.InProcServer); + } + catch (Exception e) + { + Logger.LogError($"Failed to create IApplicationActivationManager instance. ex: {e.Message}"); + throw; + } + const ActivateOptions noFlags = ActivateOptions.None; var queryArguments = TerminalHelper.GetArguments(profile, _openNewTab, _openQuake); try @@ -52,6 +62,18 @@ internal sealed partial class LaunchProfileCommand : InvokableCommand // var message = Resources.run_terminal_failed; // Log.Exception("Failed to open Windows Terminal", ex, GetType()); // _context.API.ShowMsg(name, message, string.Empty); + Logger.LogError($"Failed to open Windows Terminal: {ex.Message}"); + } + + try + { + _appSettingsManager.Current.AddRecentlyUsedProfile(id, profile); + _appSettingsManager.Save(); + } + catch (Exception ex) + { + // We don't want to fail the whole operation if we can't save the recently used profile + Logger.LogError($"Failed to save recently used profile: {ex.Message}"); } } #pragma warning restore IDE0059, CS0168 @@ -65,6 +87,7 @@ internal sealed partial class LaunchProfileCommand : InvokableCommand catch { // TODO GH #108 We need to figure out some logging + // No need to log here, as the exception is already logged in the Launch method } return CommandResult.Dismiss(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/AppSettings.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/AppSettings.cs new file mode 100644 index 0000000000..9bc8349e6c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/AppSettings.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; + +/// <summary> +/// Strongly typed application-level settings for the Windows Terminal extension. +/// These are distinct from the dynamic command palette <see cref="JsonSettingsManager"/> based settings +/// and are meant for simple persisted state (e.g. last selections). +/// </summary> +public sealed class AppSettings +{ + private const int MaxRecentProfilesCount = 64; + + /// <summary> + /// Gets or sets the last selected channel identifier for the Windows Terminal extension. + /// Empty string when no channel has been selected yet. + /// </summary> + [JsonPropertyName("lastSelectedChannel")] + public string LastSelectedChannel { get; set; } = string.Empty; + + /// <summary> + /// Gets the list of recently used profile identifiers. + /// </summary> + [JsonPropertyName("recentlyUsedProfiles")] + public List<TerminalProfileKey> RecentlyUsedProfiles { get; init; } = []; + + public void AddRecentlyUsedProfile(string appId, string profileName) + { + var key = new TerminalProfileKey(appId, profileName); + RecentlyUsedProfiles.Remove(key); + RecentlyUsedProfiles.Insert(0, key); + + if (RecentlyUsedProfiles.Count > MaxRecentProfilesCount) + { + RecentlyUsedProfiles.RemoveRange(MaxRecentProfilesCount, RecentlyUsedProfiles.Count - MaxRecentProfilesCount); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/AppSettingsJsonContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/AppSettingsJsonContext.cs new file mode 100644 index 0000000000..50fb9e8dc1 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/AppSettingsJsonContext.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. + +#nullable enable + +using System.Text.Json.Serialization; + +namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(AppSettings))] +internal sealed partial class AppSettingsJsonContext : JsonSerializerContext; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/AppSettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/AppSettingsManager.cs new file mode 100644 index 0000000000..6310f1123a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/AppSettingsManager.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.IO; +using System.Text.Json; +using ManagedCommon; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; + +public sealed class AppSettingsManager +{ + private const string FileName = "appsettings.json"; + + private static string SettingsPath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + return Path.Combine(directory, FileName); + } + + private readonly string _filePath; + + public AppSettings Current { get; private set; } = new(); + + public AppSettingsManager() + { + _filePath = SettingsPath(); + Load(); + } + + public void Load() + { + try + { + if (File.Exists(_filePath)) + { + var json = File.ReadAllText(_filePath); + var loaded = JsonSerializer.Deserialize(json, AppSettingsJsonContext.Default.AppSettings!); + if (loaded is not null) + { + Current = loaded; + } + } + } + catch (Exception ex) + { + ExtensionHost.LogMessage(new LogMessage { Message = ex.ToString() }); + Logger.LogError("Failed to load app settings", ex); + } + } + + public void Save() + { + try + { + var json = JsonSerializer.Serialize(Current, AppSettingsJsonContext.Default.AppSettings!); + File.WriteAllText(_filePath, json); + } + catch (Exception ex) + { + ExtensionHost.LogMessage(new LogMessage { Message = ex.ToString() }); + Logger.LogError("Failed to save app settings", ex); + } + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/IApplicationActivationManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/IApplicationActivationManager.cs similarity index 56% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/IApplicationActivationManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/IApplicationActivationManager.cs index e332eee6fd..c023323d7f 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/IApplicationActivationManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/IApplicationActivationManager.cs @@ -4,6 +4,7 @@ using System; using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; @@ -18,14 +19,13 @@ public enum ActivateOptions } // ApplicationActivationManager -[ComImport] +[GeneratedComInterface(StringMarshalling = StringMarshalling.Utf16)] [Guid("2e941141-7f97-4756-ba1d-9decde894a3d")] -[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] -public interface IApplicationActivationManager +public partial interface IApplicationActivationManager { - IntPtr ActivateApplication([In] string appUserModelId, [In] string arguments, [In] ActivateOptions options, [Out] out uint processId); + void ActivateApplication(string appUserModelId, string arguments, ActivateOptions options, out uint processId); - IntPtr ActivateForFile([In] string appUserModelId, [In] IntPtr /*IShellItemArray* */ itemArray, [In] string verb, [Out] out uint processId); + void ActivateForFile(string appUserModelId, IntPtr /*IShellItemArray* */ itemArray, string verb, out uint processId); - IntPtr ActivateForProtocol([In] string appUserModelId, [In] IntPtr /* IShellItemArray* */itemArray, [Out] out uint processId); + void ActivateForProtocol(string appUserModelId, IntPtr /* IShellItemArray* */itemArray, out uint processId); } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ITerminalQuery.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ITerminalQuery.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ITerminalQuery.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ITerminalQuery.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ProfileSortOrder.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ProfileSortOrder.cs new file mode 100644 index 0000000000..60be4a573e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/ProfileSortOrder.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. + +#nullable enable +namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; + +public enum ProfileSortOrder +{ + Default = 0, + Alphabetical = 1, + MostRecentlyUsed = 2, +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/SettingsManager.cs similarity index 60% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/SettingsManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/SettingsManager.cs index d1c8be21f4..ba4ee3f600 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/SettingsManager.cs @@ -34,13 +34,33 @@ public class SettingsManager : JsonSettingsManager Resources.open_quake_description, false); + private readonly ToggleSetting _saveLastSelectedChannel = new( + Namespaced(nameof(SaveLastSelectedChannel)), + Resources.save_last_selected_channel!, + Resources.save_last_selected_channel_description!, + false); + + private readonly ChoiceSetSetting _profileSortOrder = new( + Namespaced(nameof(ProfileSortOrder)), + Resources.profile_sort_order!, + Resources.profile_sort_order_description!, + [ + new ChoiceSetSetting.Choice(Resources.profile_sort_order_item_default!, ProfileSortOrder.Default.ToString("G")), + new ChoiceSetSetting.Choice(Resources.profile_sort_order_item_alphabetical!, ProfileSortOrder.Alphabetical.ToString("G")), + new ChoiceSetSetting.Choice(Resources.profile_sort_order_item_mru!, ProfileSortOrder.MostRecentlyUsed.ToString("G")), + ]); + public bool ShowHiddenProfiles => _showHiddenProfiles.Value; public bool OpenNewTab => _openNewTab.Value; public bool OpenQuake => _openQuake.Value; - internal static string SettingsJsonPath() + public bool SaveLastSelectedChannel => _saveLastSelectedChannel.Value; + + public ProfileSortOrder ProfileSortOrder => System.Enum.TryParse<ProfileSortOrder>(_profileSortOrder.Value, out var result) ? result : ProfileSortOrder.Default; + + private static string SettingsJsonPath() { var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); Directory.CreateDirectory(directory); @@ -56,6 +76,8 @@ public class SettingsManager : JsonSettingsManager Settings.Add(_showHiddenProfiles); Settings.Add(_openNewTab); Settings.Add(_openQuake); + Settings.Add(_saveLastSelectedChannel); + Settings.Add(_profileSortOrder); // Load settings from file upon initialization LoadSettings(); diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalHelper.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalHelper.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalHelper.cs diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalProfileKey.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalProfileKey.cs new file mode 100644 index 0000000000..ab0d520a32 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalProfileKey.cs @@ -0,0 +1,8 @@ +// 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 +namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; + +public sealed record TerminalProfileKey(string AppId, string ProfileName); diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalQuery.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalQuery.cs similarity index 87% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalQuery.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalQuery.cs index 088c488ad3..97bbae741f 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalQuery.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Helpers/TerminalQuery.cs @@ -8,7 +8,7 @@ using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Security.Principal; - +using ManagedCommon; using Windows.Management.Deployment; // using Wox.Plugin.Logger; @@ -41,6 +41,7 @@ public class TerminalQuery : ITerminalQuery { // TODO: what kind of logging should we do? // Log.Warn($"No Windows Terminal packages installed", typeof(TerminalQuery)); + Logger.LogWarning("No Windows Terminal packages installed"); } foreach (var terminal in Terminals) @@ -49,6 +50,7 @@ public class TerminalQuery : ITerminalQuery { // TODO: what kind of logging should we do? // Log.Warn($"Failed to find settings file {terminal.SettingsPath}", typeof(TerminalQuery)); + Logger.LogWarning($"Failed to find settings file {terminal.SettingsPath}"); continue; } @@ -56,13 +58,13 @@ public class TerminalQuery : ITerminalQuery profiles.AddRange(TerminalHelper.ParseSettings(terminal, settingsJson)); } - return profiles.OrderBy(p => p.Name); + return profiles; } - private IEnumerable<TerminalPackage> GetTerminals() + public IEnumerable<TerminalPackage> GetTerminals() { var user = WindowsIdentity.GetCurrent().User; - var localAppDataPath = Environment.GetEnvironmentVariable("LOCALAPPDATA"); + var localAppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); foreach (var p in _packageManager.FindPackagesForUser(user.Value).Where(p => Packages.Contains(p.Id.Name))) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Icons.cs new file mode 100644 index 0000000000..9d3072f25e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Icons.cs @@ -0,0 +1,16 @@ +// 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.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WindowsTerminal; + +internal static class Icons +{ + internal static IconInfo TerminalIcon { get; } = IconHelpers.FromRelativePath("Assets\\WindowsTerminal.svg"); + + internal static IconInfo AdminIcon { get; } = new IconInfo("\xE7EF"); // Admin icon + + internal static IconInfo FilterIcon { get; } = new IconInfo("\uE71C"); // Funnel icon +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Microsoft.CmdPal.Ext.WindowsTerminal.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Microsoft.CmdPal.Ext.WindowsTerminal.csproj similarity index 78% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Microsoft.CmdPal.Ext.WindowsTerminal.csproj rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Microsoft.CmdPal.Ext.WindowsTerminal.csproj index 7ea6f17148..df8f3e3487 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Microsoft.CmdPal.Ext.WindowsTerminal.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Microsoft.CmdPal.Ext.WindowsTerminal.csproj @@ -1,6 +1,9 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + <Import Project="..\Common.ExtDependencies.props" /> <Import Project="..\..\Microsoft.CmdPal.UI\CmdPal.pre.props" /> + <PropertyGroup> <RootNamespace>Microsoft.CmdPal.Ext.WindowsTerminal</RootNamespace> <!-- <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath> --> @@ -13,7 +16,9 @@ <None Remove="Assets\WindowsTerminal.svg" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> + <ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props --> + <ProjectReference Include="..\..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" /> </ItemGroup> <ItemGroup> <Compile Update="Properties\Resources.Designer.cs"> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs new file mode 100644 index 0000000000..e968b9848b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/ProfilesListPage.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using ManagedCommon; +using Microsoft.CmdPal.Ext.WindowsTerminal.Commands; +using Microsoft.CmdPal.Ext.WindowsTerminal.Helpers; +using Microsoft.CmdPal.Ext.WindowsTerminal.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace Microsoft.CmdPal.Ext.WindowsTerminal.Pages; + +internal sealed partial class ProfilesListPage : ListPage, INotifyItemsChanged +{ + event TypedEventHandler<object, IItemsChangedEventArgs> INotifyItemsChanged.ItemsChanged + { + add + { + ItemsChanged += value; + EnsureInitialized(); + SelectTerminalFilter(); + } + + remove + { + ItemsChanged -= value; + } + } + + private readonly TerminalQuery _terminalQuery = new(); + private readonly SettingsManager _terminalSettings; + private readonly AppSettingsManager _appSettingsManager; + + private bool showHiddenProfiles; + private bool openNewTab; + private bool openQuake; + + private bool initialized; + private TerminalChannelFilters? terminalFilters; + + public ProfilesListPage(SettingsManager terminalSettings, AppSettingsManager appSettingsManager) + { + Icon = Icons.TerminalIcon; + Name = Resources.profiles_list_page_name; + _terminalSettings = terminalSettings; + _terminalSettings.Settings.SettingsChanged += Settings_SettingsChanged; + _appSettingsManager = appSettingsManager; + } + + private void Settings_SettingsChanged(object sender, Settings args) + { + EnsureInitialized(); + RaiseItemsChanged(); + } + + private List<ListItem> Query() + { + EnsureInitialized(); + + showHiddenProfiles = _terminalSettings.ShowHiddenProfiles; + openNewTab = _terminalSettings.OpenNewTab; + openQuake = _terminalSettings.OpenQuake; + + var profiles = _terminalQuery.GetProfiles()!; + + switch (_terminalSettings.ProfileSortOrder) + { + case ProfileSortOrder.MostRecentlyUsed: + var mru = _appSettingsManager.Current.RecentlyUsedProfiles ?? []; + profiles = profiles.OrderBy(p => + { + var key = new TerminalProfileKey(p.Terminal?.AppUserModelId ?? string.Empty, p.Name ?? string.Empty); + var index = mru.IndexOf(key); + return index == -1 ? int.MaxValue : index; + }) + .ThenBy(static p => p.Name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + break; + case ProfileSortOrder.Default: + case ProfileSortOrder.Alphabetical: + default: + profiles = profiles.OrderBy(static p => p.Name, StringComparer.CurrentCultureIgnoreCase); + break; + } + + if (terminalFilters?.IsAllSelected == false) + { + profiles = profiles.Where(profile => profile.Terminal.AppUserModelId == terminalFilters.CurrentFilterId); + } + + var result = new List<ListItem>(); + + foreach (var profile in profiles) + { + if (profile.Hidden && !showHiddenProfiles) + { + continue; + } + + result.Add(new ListItem(new LaunchProfileCommand(profile.Terminal.AppUserModelId, profile.Name, profile.Terminal.LogoPath, openNewTab, openQuake, _appSettingsManager)) + { + Title = profile.Name, + Subtitle = profile.Terminal.DisplayName, + MoreCommands = [ + new CommandContextItem(new LaunchProfileAsAdminCommand(profile.Terminal.AppUserModelId, profile.Name, openNewTab, openQuake, _appSettingsManager)), + ], + }); + } + + return result; + } + + private void EnsureInitialized() + { + if (initialized) + { + return; + } + + var terminals = _terminalQuery.GetTerminals(); + terminalFilters = new TerminalChannelFilters(terminals); + terminalFilters.PropChanged += TerminalFiltersOnPropChanged; + SelectTerminalFilter(); + Filters = terminalFilters; + initialized = true; + } + + private void SelectTerminalFilter() + { + Trace.Assert(terminalFilters != null); + + // Select the preferred channel if it exists; we always select the preferred channel, + // but user have an option to save the preferred channel when he changes the filter + if (_terminalSettings.SaveLastSelectedChannel) + { + if (!string.IsNullOrWhiteSpace(_appSettingsManager.Current.LastSelectedChannel) && + terminalFilters.ContainsFilter(_appSettingsManager.Current.LastSelectedChannel)) + { + terminalFilters.CurrentFilterId = _appSettingsManager.Current.LastSelectedChannel; + } + } + else + { + terminalFilters.CurrentFilterId = TerminalChannelFilters.AllTerminalsFilterId; + } + } + + private void TerminalFiltersOnPropChanged(object sender, IPropChangedEventArgs args) + { + Trace.Assert(terminalFilters != null); + + RaiseItemsChanged(); + _appSettingsManager.Current.LastSelectedChannel = terminalFilters.CurrentFilterId; + _appSettingsManager.Save(); + } + + public override IListItem[] GetItems() + { + try + { + return [.. Query()]; + } + catch (Exception ex) + { + Logger.LogError("Failed to list Windows Terminal profiles", ex); + throw; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/TerminalChannelFilters.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/TerminalChannelFilters.cs new file mode 100644 index 0000000000..525edb3d41 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Pages/TerminalChannelFilters.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.WindowsTerminal.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WindowsTerminal.Pages; + +internal sealed partial class TerminalChannelFilters : Filters +{ + internal const string AllTerminalsFilterId = "all"; + + private readonly List<TerminalPackage> _terminals; + + public bool IsAllSelected => CurrentFilterId == AllTerminalsFilterId; + + public TerminalChannelFilters(IEnumerable<TerminalPackage> terminals, string preselectedFilterId = AllTerminalsFilterId) + { + CurrentFilterId = preselectedFilterId; + _terminals = [.. terminals]; + } + + public override IFilterItem[] GetFilters() + { + var items = new List<IFilterItem> + { + new Filter() + { + Id = AllTerminalsFilterId, + Name = Resources.all_channels, + Icon = Icons.FilterIcon, + }, + new Separator(), + }; + + foreach (var terminalPackage in _terminals) + { + items.Add(new Filter() + { + Id = terminalPackage.AppUserModelId, + Name = terminalPackage.DisplayName, + Icon = new IconInfo(terminalPackage.LogoPath), + }); + } + + return [.. items]; + } + + public bool ContainsFilter(string id) + { + return _terminals.FindIndex(terminal => terminal.AppUserModelId == id) > -1; + } +} diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs similarity index 65% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs index d6fa47029c..2845e40b5f 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal.Properties { // 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", "17.0.0.0")] + [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 { @@ -60,6 +60,15 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal.Properties { } } + /// <summary> + /// Looks up a localized string similar to All channels. + /// </summary> + internal static string all_channels { + get { + return ResourceManager.GetString("all_channels", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Windows Terminal Profiles. /// </summary> @@ -88,7 +97,7 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal.Properties { } /// <summary> - /// Looks up a localized string similar to Open Windows Terminal Profiles. + /// Looks up a localized string similar to Open Windows Terminal profiles. /// </summary> internal static string list_item_title { get { @@ -132,6 +141,78 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal.Properties { } } + /// <summary> + /// Looks up a localized string similar to Preferred channel. + /// </summary> + internal static string preferred_channel { + get { + return ResourceManager.GetString("preferred_channel", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Preferred channel. + /// </summary> + internal static string preferred_channel_description { + get { + return ResourceManager.GetString("preferred_channel_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Profiles order. + /// </summary> + internal static string profile_sort_order { + get { + return ResourceManager.GetString("profile_sort_order", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Profiles order. + /// </summary> + internal static string profile_sort_order_description { + get { + return ResourceManager.GetString("profile_sort_order_description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Alphabetical. + /// </summary> + internal static string profile_sort_order_item_alphabetical { + get { + return ResourceManager.GetString("profile_sort_order_item_alphabetical", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to As defined in Terminal. + /// </summary> + internal static string profile_sort_order_item_as_in_terminal { + get { + return ResourceManager.GetString("profile_sort_order_item_as_in_terminal", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Default (alphabetical). + /// </summary> + internal static string profile_sort_order_item_default { + get { + return ResourceManager.GetString("profile_sort_order_item_default", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Most recently used. + /// </summary> + internal static string profile_sort_order_item_mru { + get { + return ResourceManager.GetString("profile_sort_order_item_mru", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Windows Terminal Profiles. /// </summary> @@ -159,6 +240,24 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal.Properties { } } + /// <summary> + /// Looks up a localized string similar to Keep last channel filter. + /// </summary> + internal static string save_last_selected_channel { + get { + return ResourceManager.GetString("save_last_selected_channel", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Remember the last selected channel instead of resetting to All Channels.. + /// </summary> + internal static string save_last_selected_channel_description { + get { + return ResourceManager.GetString("save_last_selected_channel_description", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Settings. /// </summary> diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx similarity index 84% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx index 3b2ab8da6a..38fa22f751 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/Properties/Resources.resx @@ -156,6 +156,40 @@ <value>Settings</value> </data> <data name="list_item_title" xml:space="preserve"> - <value>Open Windows Terminal Profiles</value> + <value>Open Windows Terminal profiles</value> + <comment>Verb: leads to a list of Terminal profiles to open</comment> + </data> + <data name="preferred_channel" xml:space="preserve"> + <value>Preferred channel</value> + </data> + <data name="preferred_channel_description" xml:space="preserve"> + <value>Preferred channel</value> + </data> + <data name="all_channels" xml:space="preserve"> + <value>All channels</value> + </data> + <data name="save_last_selected_channel" xml:space="preserve"> + <value>Keep last channel filter</value> + </data> + <data name="save_last_selected_channel_description" xml:space="preserve"> + <value>Remember the last selected channel instead of resetting to All Channels.</value> + </data> + <data name="profile_sort_order" xml:space="preserve"> + <value>Profiles order</value> + </data> + <data name="profile_sort_order_description" xml:space="preserve"> + <value>Profiles order</value> + </data> + <data name="profile_sort_order_item_default" xml:space="preserve"> + <value>Default (alphabetical)</value> + </data> + <data name="profile_sort_order_item_mru" xml:space="preserve"> + <value>Most recently used</value> + </data> + <data name="profile_sort_order_item_as_in_terminal" xml:space="preserve"> + <value>As defined in Terminal</value> + </data> + <data name="profile_sort_order_item_alphabetical" xml:space="preserve"> + <value>Alphabetical</value> </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs similarity index 62% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs index 08f95c04fa..3301028da1 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalPackage.cs @@ -4,7 +4,7 @@ using System; using System.IO; -using Microsoft.UI.Xaml.Media.Imaging; +using ManagedCommon; // using Wox.Infrastructure.Image; namespace Microsoft.CmdPal.Ext.WindowsTerminal; @@ -29,22 +29,4 @@ public class TerminalPackage SettingsPath = settingsPath; LogoPath = logoPath; } - - public BitmapImage GetLogo() - { - var image = new BitmapImage(); - - if (File.Exists(LogoPath)) - { - using var fileStream = File.OpenRead(LogoPath); - image.SetSource(fileStream.AsRandomAccessStream()); - } - else - { - // Not using wox anymore, TODO: find the right new way to handle this - // image.UriSource = new Uri(ImageLoader.ErrorIconPath); - } - - return image; - } } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalProfile.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalProfile.cs similarity index 100% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalProfile.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalProfile.cs diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalTopLevelCommandItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalTopLevelCommandItem.cs similarity index 80% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalTopLevelCommandItem.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalTopLevelCommandItem.cs index 8f0f81c7c1..ce5a56e7a7 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalTopLevelCommandItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/TerminalTopLevelCommandItem.cs @@ -11,10 +11,10 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal; public partial class TerminalTopLevelCommandItem : CommandItem { - public TerminalTopLevelCommandItem(SettingsManager settingsManager) - : base(new ProfilesListPage(settingsManager)) + public TerminalTopLevelCommandItem(SettingsManager settingsManager, AppSettingsManager appSettingsManager) + : base(new ProfilesListPage(settingsManager, appSettingsManager)) { - Icon = WindowsTerminalCommandsProvider.TerminalIcon; + Icon = Icons.TerminalIcon; Title = Resources.list_item_title; } } diff --git a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/WindowsTerminalCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/WindowsTerminalCommandsProvider.cs similarity index 87% rename from src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/WindowsTerminalCommandsProvider.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/WindowsTerminalCommandsProvider.cs index cc311541ef..31dc9077bb 100644 --- a/src/modules/cmdpal/Exts/Microsoft.CmdPal.Ext.WindowsTerminal/WindowsTerminalCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsTerminal/WindowsTerminalCommandsProvider.cs @@ -13,17 +13,16 @@ public partial class WindowsTerminalCommandsProvider : CommandProvider { private readonly TerminalTopLevelCommandItem _terminalCommand; private readonly SettingsManager _settingsManager = new(); - - public static IconInfo TerminalIcon { get; } = IconHelpers.FromRelativePath("Assets\\WindowsTerminal.svg"); + private readonly AppSettingsManager _appSettingsManager = new(); public WindowsTerminalCommandsProvider() { Id = "WindowsTerminalProfiles"; DisplayName = Resources.extension_name; - Icon = TerminalIcon; + Icon = Icons.TerminalIcon; Settings = _settingsManager.Settings; - _terminalCommand = new TerminalTopLevelCommandItem(_settingsManager) + _terminalCommand = new TerminalTopLevelCommandItem(_settingsManager, _appSettingsManager) { MoreCommands = [ new CommandContextItem(Settings.SettingsPage), diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Assets/LockScreenLogo.scale-200.png b/src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/LockScreenLogo.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Assets/LockScreenLogo.scale-200.png rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/LockScreenLogo.scale-200.png diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Assets/SplashScreen.scale-200.png b/src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/SplashScreen.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Assets/SplashScreen.scale-200.png rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/SplashScreen.scale-200.png diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Assets/Square150x150Logo.scale-200.png b/src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/Square150x150Logo.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Assets/Square150x150Logo.scale-200.png rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/Square150x150Logo.scale-200.png diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Assets/Square44x44Logo.scale-200.png b/src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/Square44x44Logo.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Assets/Square44x44Logo.scale-200.png rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/Square44x44Logo.scale-200.png diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Assets/StoreLogo.png b/src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/StoreLogo.png similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Assets/StoreLogo.png rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/StoreLogo.png diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Assets/Wide310x150Logo.scale-200.png b/src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/Wide310x150Logo.scale-200.png similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Assets/Wide310x150Logo.scale-200.png rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Assets/Wide310x150Logo.scale-200.png diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Package.appxmanifest b/src/modules/cmdpal/ext/ProcessMonitorExtension/Package.appxmanifest similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Package.appxmanifest rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Package.appxmanifest diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/ProcessItem.cs b/src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessItem.cs similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/ProcessItem.cs rename to src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessItem.cs diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/ProcessListPage.cs b/src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessListPage.cs similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/ProcessListPage.cs rename to src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessListPage.cs diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/ProcessMonitorCommandProvider.cs b/src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorCommandProvider.cs similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/ProcessMonitorCommandProvider.cs rename to src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorCommandProvider.cs diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/ProcessMonitorExtension.csproj b/src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorExtension.csproj similarity index 92% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/ProcessMonitorExtension.csproj rename to src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorExtension.csproj index d36c277705..4ff1435154 100644 --- a/src/modules/cmdpal/Exts/ProcessMonitorExtension/ProcessMonitorExtension.csproj +++ b/src/modules/cmdpal/ext/ProcessMonitorExtension/ProcessMonitorExtension.csproj @@ -1,5 +1,5 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> <RootNamespace>ProcessMonitorExtension</RootNamespace> @@ -31,6 +31,10 @@ </ProjectReference> </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + </ItemGroup> + <!-- Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging Tools extension to be activated for this project even if the Windows App SDK Nuget diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Program.cs b/src/modules/cmdpal/ext/ProcessMonitorExtension/Program.cs similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Program.cs rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Program.cs diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/PublishProfiles/win-arm64.pubxml b/src/modules/cmdpal/ext/ProcessMonitorExtension/Properties/PublishProfiles/win-arm64.pubxml similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/PublishProfiles/win-arm64.pubxml rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Properties/PublishProfiles/win-arm64.pubxml diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/PublishProfiles/win-x64.pubxml b/src/modules/cmdpal/ext/ProcessMonitorExtension/Properties/PublishProfiles/win-x64.pubxml similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/PublishProfiles/win-x64.pubxml rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Properties/PublishProfiles/win-x64.pubxml diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/launchSettings.json b/src/modules/cmdpal/ext/ProcessMonitorExtension/Properties/launchSettings.json similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/launchSettings.json rename to src/modules/cmdpal/ext/ProcessMonitorExtension/Properties/launchSettings.json diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/SampleExtension.cs b/src/modules/cmdpal/ext/ProcessMonitorExtension/SampleExtension.cs similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/SampleExtension.cs rename to src/modules/cmdpal/ext/ProcessMonitorExtension/SampleExtension.cs diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/SwitchToProcess.cs b/src/modules/cmdpal/ext/ProcessMonitorExtension/SwitchToProcess.cs similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/SwitchToProcess.cs rename to src/modules/cmdpal/ext/ProcessMonitorExtension/SwitchToProcess.cs diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/TerminateProcess.cs b/src/modules/cmdpal/ext/ProcessMonitorExtension/TerminateProcess.cs similarity index 100% rename from src/modules/cmdpal/Exts/ProcessMonitorExtension/TerminateProcess.cs rename to src/modules/cmdpal/ext/ProcessMonitorExtension/TerminateProcess.cs diff --git a/src/modules/cmdpal/ext/ProcessMonitorExtension/app.manifest b/src/modules/cmdpal/ext/ProcessMonitorExtension/app.manifest new file mode 100644 index 0000000000..cf7490b1cb --- /dev/null +++ b/src/modules/cmdpal/ext/ProcessMonitorExtension/app.manifest @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> + <assemblyIdentity version="1.0.0.0" name="KillProcessExtension.app"/> + + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10. + It is necessary to support features in unpackaged applications, for example the custom titlebar implementation. + For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest --> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> + </application> + </compatibility> + + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> + </windowsSettings> + </application> +</assembly> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/FluentEmojiChipmunk.svg b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/FluentEmojiChipmunk.svg new file mode 100644 index 0000000000..60b2aba634 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/FluentEmojiChipmunk.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 32 32"><g fill="none"><g filter="url(#IconifyId199691df1f81eb73818217)"><path fill="url(#IconifyId199691df1f81eb73818194)" d="m28.046 9.04l-1.25-.62c-.2-.11-.13-.42.1-.42h.58a2.5 2.5 0 0 0 0-5h-2.03a4.468 4.468 0 0 0-2.3 8.3l1.88 1.13c.59.35.95.99.95 1.67V25a1 1 0 0 1-1 1h-3.48v4h4.48c2.21 0 4-1.79 4-4V12.16c0-1.33-.75-2.53-1.93-3.12"/><path fill="url(#IconifyId199691df1f81eb73818195)" d="m28.046 9.04l-1.25-.62c-.2-.11-.13-.42.1-.42h.58a2.5 2.5 0 0 0 0-5h-2.03a4.468 4.468 0 0 0-2.3 8.3l1.88 1.13c.59.35.95.99.95 1.67V25a1 1 0 0 1-1 1h-3.48v4h4.48c2.21 0 4-1.79 4-4V12.16c0-1.33-.75-2.53-1.93-3.12"/><path fill="url(#IconifyId199691df1f81eb73818196)" d="m28.046 9.04l-1.25-.62c-.2-.11-.13-.42.1-.42h.58a2.5 2.5 0 0 0 0-5h-2.03a4.468 4.468 0 0 0-2.3 8.3l1.88 1.13c.59.35.95.99.95 1.67V25a1 1 0 0 1-1 1h-3.48v4h4.48c2.21 0 4-1.79 4-4V12.16c0-1.33-.75-2.53-1.93-3.12"/><path fill="url(#IconifyId199691df1f81eb73818164)" d="m28.046 9.04l-1.25-.62c-.2-.11-.13-.42.1-.42h.58a2.5 2.5 0 0 0 0-5h-2.03a4.468 4.468 0 0 0-2.3 8.3l1.88 1.13c.59.35.95.99.95 1.67V25a1 1 0 0 1-1 1h-3.48v4h4.48c2.21 0 4-1.79 4-4V12.16c0-1.33-.75-2.53-1.93-3.12"/><path fill="url(#IconifyId199691df1f81eb73818165)" d="m28.046 9.04l-1.25-.62c-.2-.11-.13-.42.1-.42h.58a2.5 2.5 0 0 0 0-5h-2.03a4.468 4.468 0 0 0-2.3 8.3l1.88 1.13c.59.35.95.99.95 1.67V25a1 1 0 0 1-1 1h-3.48v4h4.48c2.21 0 4-1.79 4-4V12.16c0-1.33-.75-2.53-1.93-3.12"/><path fill="url(#IconifyId199691df1f81eb73818166)" d="m28.046 9.04l-1.25-.62c-.2-.11-.13-.42.1-.42h.58a2.5 2.5 0 0 0 0-5h-2.03a4.468 4.468 0 0 0-2.3 8.3l1.88 1.13c.59.35.95.99.95 1.67V25a1 1 0 0 1-1 1h-3.48v4h4.48c2.21 0 4-1.79 4-4V12.16c0-1.33-.75-2.53-1.93-3.12"/><path fill="url(#IconifyId199691df1f81eb73818167)" d="m28.046 9.04l-1.25-.62c-.2-.11-.13-.42.1-.42h.58a2.5 2.5 0 0 0 0-5h-2.03a4.468 4.468 0 0 0-2.3 8.3l1.88 1.13c.59.35.95.99.95 1.67V25a1 1 0 0 1-1 1h-3.48v4h4.48c2.21 0 4-1.79 4-4V12.16c0-1.33-.75-2.53-1.93-3.12"/></g><g filter="url(#IconifyId199691df1f81eb73818218)"><path stroke="url(#IconifyId199691df1f81eb73818197)" stroke-linecap="round" stroke-width=".75" d="m25.6 9.031l1.802.862a2 2 0 0 1 1.136 1.804V25.5a2 2 0 0 1-2 2h-2.187"/></g><path fill="url(#IconifyId199691df1f81eb73818198)" d="M3.836 16.99v1.99c0 .69.39 1.32 1.01 1.62l.69.33a.7.7 0 0 0 .6 0l.69-.33c.62-.3 1.01-.93 1.01-1.62v-1.99z"/><path fill="url(#IconifyId199691df1f81eb73818168)" d="M3.836 16.99v1.99c0 .69.39 1.32 1.01 1.62l.69.33a.7.7 0 0 0 .6 0l.69-.33c.62-.3 1.01-.93 1.01-1.62v-1.99z"/><path fill="url(#IconifyId199691df1f81eb73818199)" d="M16.526 11c-.95 0-1.82-.53-2.24-1.38l-.71-1.43a5.36 5.36 0 0 1-.59-2.48A2.687 2.687 0 0 0 10.306 3c-.18 0-.33.14-.33.33v2.076S9.374 5 8.288 5c-1.313 0-2.742.84-3.572 2.1c-.74 1.332-1.624 2.606-2.43 3.9c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h3.88v3h-1.46c-.85 0-1.56.69-1.54 1.53c.02.82.68 1.47 1.5 1.47l1.5.04v2.9c0 3.35 2.71 6.06 6.06 6.06h6.89a3.05 3.05 0 0 0 3.05-3.05v-7.49c0-4.67-3.78-8.46-8.45-8.46"/><path fill="url(#IconifyId199691df1f81eb73818169)" d="M16.526 11c-.95 0-1.82-.53-2.24-1.38l-.71-1.43a5.36 5.36 0 0 1-.59-2.48A2.687 2.687 0 0 0 10.306 3c-.18 0-.33.14-.33.33v2.076S9.374 5 8.288 5c-1.313 0-2.742.84-3.572 2.1c-.74 1.332-1.624 2.606-2.43 3.9c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h3.88v3h-1.46c-.85 0-1.56.69-1.54 1.53c.02.82.68 1.47 1.5 1.47l1.5.04v2.9c0 3.35 2.71 6.06 6.06 6.06h6.89a3.05 3.05 0 0 0 3.05-3.05v-7.49c0-4.67-3.78-8.46-8.45-8.46"/><path fill="url(#IconifyId199691df1f81eb73818170)" d="M16.526 11c-.95 0-1.82-.53-2.24-1.38l-.71-1.43a5.36 5.36 0 0 1-.59-2.48A2.687 2.687 0 0 0 10.306 3c-.18 0-.33.14-.33.33v2.076S9.374 5 8.288 5c-1.313 0-2.742.84-3.572 2.1c-.74 1.332-1.624 2.606-2.43 3.9c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h3.88v3h-1.46c-.85 0-1.56.69-1.54 1.53c.02.82.68 1.47 1.5 1.47l1.5.04v2.9c0 3.35 2.71 6.06 6.06 6.06h6.89a3.05 3.05 0 0 0 3.05-3.05v-7.49c0-4.67-3.78-8.46-8.45-8.46"/><path fill="url(#IconifyId199691df1f81eb73818171)" d="M16.526 11c-.95 0-1.82-.53-2.24-1.38l-.71-1.43a5.36 5.36 0 0 1-.59-2.48A2.687 2.687 0 0 0 10.306 3c-.18 0-.33.14-.33.33v2.076S9.374 5 8.288 5c-1.313 0-2.742.84-3.572 2.1c-.74 1.332-1.624 2.606-2.43 3.9c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h3.88v3h-1.46c-.85 0-1.56.69-1.54 1.53c.02.82.68 1.47 1.5 1.47l1.5.04v2.9c0 3.35 2.71 6.06 6.06 6.06h6.89a3.05 3.05 0 0 0 3.05-3.05v-7.49c0-4.67-3.78-8.46-8.45-8.46"/><path fill="url(#IconifyId199691df1f81eb73818172)" d="M16.526 11c-.95 0-1.82-.53-2.24-1.38l-.71-1.43a5.36 5.36 0 0 1-.59-2.48A2.687 2.687 0 0 0 10.306 3c-.18 0-.33.14-.33.33v2.076S9.374 5 8.288 5c-1.313 0-2.742.84-3.572 2.1c-.74 1.332-1.624 2.606-2.43 3.9c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h3.88v3h-1.46c-.85 0-1.56.69-1.54 1.53c.02.82.68 1.47 1.5 1.47l1.5.04v2.9c0 3.35 2.71 6.06 6.06 6.06h6.89a3.05 3.05 0 0 0 3.05-3.05v-7.49c0-4.67-3.78-8.46-8.45-8.46"/><path fill="url(#IconifyId199691df1f81eb73818173)" d="M16.526 11c-.95 0-1.82-.53-2.24-1.38l-.71-1.43a5.36 5.36 0 0 1-.59-2.48A2.687 2.687 0 0 0 10.306 3c-.18 0-.33.14-.33.33v2.076S9.374 5 8.288 5c-1.313 0-2.742.84-3.572 2.1c-.74 1.332-1.624 2.606-2.43 3.9c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h3.88v3h-1.46c-.85 0-1.56.69-1.54 1.53c.02.82.68 1.47 1.5 1.47l1.5.04v2.9c0 3.35 2.71 6.06 6.06 6.06h6.89a3.05 3.05 0 0 0 3.05-3.05v-7.49c0-4.67-3.78-8.46-8.45-8.46"/><path fill="url(#IconifyId199691df1f81eb73818174)" fill-opacity=".75" d="M16.526 11c-.95 0-1.82-.53-2.24-1.38l-.71-1.43a5.36 5.36 0 0 1-.59-2.48A2.687 2.687 0 0 0 10.306 3c-.18 0-.33.14-.33.33v2.076S9.374 5 8.288 5c-1.313 0-2.742.84-3.572 2.1c-.74 1.332-1.624 2.606-2.43 3.9c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h3.88v3h-1.46c-.85 0-1.56.69-1.54 1.53c.02.82.68 1.47 1.5 1.47l1.5.04v2.9c0 3.35 2.71 6.06 6.06 6.06h6.89a3.05 3.05 0 0 0 3.05-3.05v-7.49c0-4.67-3.78-8.46-8.45-8.46"/><path fill="url(#IconifyId199691df1f81eb73818200)" d="M16.526 11c-.95 0-1.82-.53-2.24-1.38l-.71-1.43a5.36 5.36 0 0 1-.59-2.48A2.687 2.687 0 0 0 10.306 3c-.18 0-.33.14-.33.33v2.076S9.374 5 8.288 5c-1.313 0-2.742.84-3.572 2.1c-.74 1.332-1.624 2.606-2.43 3.9c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h3.88v3h-1.46c-.85 0-1.56.69-1.54 1.53c.02.82.68 1.47 1.5 1.47l1.5.04v2.9c0 3.35 2.71 6.06 6.06 6.06h6.89a3.05 3.05 0 0 0 3.05-3.05v-7.49c0-4.67-3.78-8.46-8.45-8.46"/><path fill="url(#IconifyId199691df1f81eb73818201)" d="M7.256 8c-.74 0-1.39.46-1.63 1.15c-.08.21-.15.43-.23.65c-.25.72-.93 1.2-1.7 1.2h-1.41c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h5.88v-2.25c0-.97-.78-1.75-1.75-1.75c-.14 0-.25-.11-.25-.25V9.72c0-.95-.77-1.72-1.72-1.72"/><path fill="url(#IconifyId199691df1f81eb73818175)" d="M7.256 8c-.74 0-1.39.46-1.63 1.15c-.08.21-.15.43-.23.65c-.25.72-.93 1.2-1.7 1.2h-1.41c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h5.88v-2.25c0-.97-.78-1.75-1.75-1.75c-.14 0-.25-.11-.25-.25V9.72c0-.95-.77-1.72-1.72-1.72"/><path fill="url(#IconifyId199691df1f81eb73818176)" d="M7.256 8c-.74 0-1.39.46-1.63 1.15c-.08.21-.15.43-.23.65c-.25.72-.93 1.2-1.7 1.2h-1.41c-.2.25-.31.56-.31.88c0 1.73 1.39 3.12 3.12 3.12h5.88v-2.25c0-.97-.78-1.75-1.75-1.75c-.14 0-.25-.11-.25-.25V9.72c0-.95-.77-1.72-1.72-1.72"/><path fill="url(#IconifyId199691df1f81eb73818202)" d="M3.236 11.96c0-.53-.42-.96-.95-.96c-.2.25-.361.676-.361.988c0 .36.09.688.22.922c.05.01.09.01.13.01c.53 0 .96-.43.96-.96"/><path fill="url(#IconifyId199691df1f81eb73818203)" d="m9.626 17.74l1.35-1.36V15h-2v3c.24-.02.47-.09.65-.26"/><path fill="url(#IconifyId199691df1f81eb73818204)" d="M13.686 19.74c-.85.83-1.99 1.3-3.18 1.3h-1.53v2.9c0 3.31 1.71 6 5 6.06V19.4z"/><path fill="url(#IconifyId199691df1f81eb73818205)" d="M13.686 19.74c-.85.83-1.99 1.3-3.18 1.3h-1.53v2.9c0 3.31 1.71 6 5 6.06V19.4z"/><path fill="url(#IconifyId199691df1f81eb73818177)" d="M13.686 19.74c-.85.83-1.99 1.3-3.18 1.3h-1.53v2.9c0 3.31 1.71 6 5 6.06V19.4z"/><path fill="url(#IconifyId199691df1f81eb73818178)" d="M13.686 19.74c-.85.83-1.99 1.3-3.18 1.3h-1.53v2.9c0 3.31 1.71 6 5 6.06V19.4z"/><path fill="url(#IconifyId199691df1f81eb73818179)" d="M7.274 10.016a.539.539 0 1 0 0-1.078a.539.539 0 0 0 0 1.078"/><path fill="url(#IconifyId199691df1f81eb73818180)" d="M7.274 10.016a.539.539 0 1 0 0-1.078a.539.539 0 0 0 0 1.078"/><path fill="url(#IconifyId199691df1f81eb73818181)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><path fill="url(#IconifyId199691df1f81eb73818206)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><path fill="url(#IconifyId199691df1f81eb73818182)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><path fill="url(#IconifyId199691df1f81eb73818181)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><path fill="url(#IconifyId199691df1f81eb73818183)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><path fill="url(#IconifyId199691df1f81eb73818184)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><path fill="url(#IconifyId199691df1f81eb73818185)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><path fill="url(#IconifyId199691df1f81eb73818186)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><path fill="url(#IconifyId199691df1f81eb73818207)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><path fill="url(#IconifyId199691df1f81eb73818206)" d="M21.696 19c-4.27 0-7.72 3.45-7.72 7.72V28h-5c-.94 0-1.72.64-1.94 1.51c-.06.25.13.49.38.49h14.51a3.05 3.05 0 0 0 3.05-3.05V19z"/><g filter="url(#IconifyId199691df1f81eb73818219)"><path fill="#BD948F" d="M4.24 18.993v-1.237h.789v1.368a1 1 0 0 0 .048.306l.352 1.094l-.388-.204a1.5 1.5 0 0 1-.802-1.328"/></g><path fill="url(#IconifyId199691df1f81eb73818208)" d="M7.576 16.49h-3.48c-.41 0-.75.34-.75.75s.34.75.75.75h3.48a.749.749 0 1 0 0-1.5"/><path fill="url(#IconifyId199691df1f81eb73818187)" d="M7.576 16.49h-3.48c-.41 0-.75.34-.75.75s.34.75.75.75h3.48a.749.749 0 1 0 0-1.5"/><path fill="url(#IconifyId199691df1f81eb73818188)" d="M7.576 16.49h-3.48c-.41 0-.75.34-.75.75s.34.75.75.75h3.48a.749.749 0 1 0 0-1.5"/><path fill="url(#IconifyId199691df1f81eb73818189)" d="M21.976 19.45v5.8c0 .41.34.75.75.75s.75-.34.75-.75v-5.8c0-2.96-1.86-5.5-4.48-6.5a.76.76 0 0 0-1.02.71c0 .31.19.59.48.7c2.06.78 3.52 2.77 3.52 5.09"/><path fill="#F2A85C" d="m9.626 17.74l4.35-4.396l1.578 2.11l.953 1.796l-2.821 2.49c-.85.83-1.99 1.3-3.18 1.3h-1.53l-1.5-.04c-.82 0-1.48-.65-1.5-1.47c-.02-.84.69-1.53 1.54-1.53h1.46c.24-.02.47-.09.65-.26"/><path fill="url(#IconifyId199691df1f81eb73818209)" d="m9.626 17.74l4.35-4.396l1.578 2.11l.953 1.796l-2.821 2.49c-.85.83-1.99 1.3-3.18 1.3h-1.53l-1.5-.04c-.82 0-1.48-.65-1.5-1.47c-.02-.84.69-1.53 1.54-1.53h1.46c.24-.02.47-.09.65-.26"/><path fill="url(#IconifyId199691df1f81eb73818210)" d="m9.626 17.74l4.35-4.396l1.578 2.11l.953 1.796l-2.821 2.49c-.85.83-1.99 1.3-3.18 1.3h-1.53l-1.5-.04c-.82 0-1.48-.65-1.5-1.47c-.02-.84.69-1.53 1.54-1.53h1.46c.24-.02.47-.09.65-.26"/><path fill="url(#IconifyId199691df1f81eb73818211)" d="m9.626 17.74l4.35-4.396l1.578 2.11l.953 1.796l-2.821 2.49c-.85.83-1.99 1.3-3.18 1.3h-1.53l-1.5-.04c-.82 0-1.48-.65-1.5-1.47c-.02-.84.69-1.53 1.54-1.53h1.46c.24-.02.47-.09.65-.26"/><path fill="url(#IconifyId199691df1f81eb73818190)" d="m9.626 17.74l4.35-4.396l1.578 2.11l.953 1.796l-2.821 2.49c-.85.83-1.99 1.3-3.18 1.3h-1.53l-1.5-.04c-.82 0-1.48-.65-1.5-1.47c-.02-.84.69-1.53 1.54-1.53h1.46c.24-.02.47-.09.65-.26"/><path fill="url(#IconifyId199691df1f81eb73818191)" d="m9.626 17.74l4.35-4.396l1.578 2.11l.953 1.796l-2.821 2.49c-.85.83-1.99 1.3-3.18 1.3h-1.53l-1.5-.04c-.82 0-1.48-.65-1.5-1.47c-.02-.84.69-1.53 1.54-1.53h1.46c.24-.02.47-.09.65-.26"/><path fill="url(#IconifyId199691df1f81eb73818192)" d="m9.626 17.74l4.35-4.396l1.578 2.11l.953 1.796l-2.821 2.49c-.85.83-1.99 1.3-3.18 1.3h-1.53l-1.5-.04c-.82 0-1.48-.65-1.5-1.47c-.02-.84.69-1.53 1.54-1.53h1.46c.24-.02.47-.09.65-.26"/><g filter="url(#IconifyId199691df1f81eb73818220)"><path fill="url(#IconifyId199691df1f81eb73818212)" d="m15.304 17.844l-1.589 1.342a1.875 1.875 0 0 1-2.552-.123l2.906-2.532z"/></g><g filter="url(#IconifyId199691df1f81eb73818221)"><path fill="url(#IconifyId199691df1f81eb73818213)" d="m14.467 11.011l-2.088-4.527a.5.5 0 0 0-.678-.238L9.38 7.408a.5.5 0 0 0-.23.658l2.134 4.593a.5.5 0 0 0 .691.23l2.276-1.228a.5.5 0 0 0 .216-.65"/></g><g filter="url(#IconifyId199691df1f81eb73818222)"><path fill="url(#IconifyId199691df1f81eb73818214)" d="M12.278 5.688c-.091-.844-.409-1.382-.731-1.698c-.238-.232-.556-.019-.556.313v1.385a.5.5 0 0 0 .5.5h.313c.276 0 .503-.226.474-.5"/></g><g filter="url(#IconifyId199691df1f81eb73818223)"><ellipse cx="28.202" cy="4.963" fill="url(#IconifyId199691df1f81eb73818193)" rx="1.191" ry="1.561" transform="rotate(-38.224 28.202 4.963)"/></g><g filter="url(#IconifyId199691df1f81eb73818224)" opacity=".3"><path stroke="url(#IconifyId199691df1f81eb73818215)" stroke-linecap="round" stroke-width=".5" d="M23.038 4.75c-.927.896-2.194 3.287.156 5.688"/></g><g filter="url(#IconifyId199691df1f81eb73818225)"><path stroke="url(#IconifyId199691df1f81eb73818216)" stroke-linecap="round" stroke-width=".4" d="m25.96 8.453l-1.828.797"/></g><defs><radialGradient id="IconifyId199691df1f81eb73818164" cx="0" cy="0" r="1" gradientTransform="matrix(-4.37499 -1.81254 .67438 -1.62776 29.476 8.625)" gradientUnits="userSpaceOnUse"><stop stop-color="#843977"/><stop offset=".991" stop-color="#843977" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818165" cx="0" cy="0" r="1" gradientTransform="matrix(5.1875 -.0625 .06678 5.54297 22.288 7.5)" gradientUnits="userSpaceOnUse"><stop stop-color="#9D5F57"/><stop offset="1" stop-color="#9D5F57" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818166" cx="0" cy="0" r="1" gradientTransform="matrix(2.65624 -2.28126 2.77135 3.22689 24.382 14.063)" gradientUnits="userSpaceOnUse"><stop stop-color="#894144"/><stop offset="1" stop-color="#894144" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818167" cx="0" cy="0" r="1" gradientTransform="rotate(19.525 -63.307 77.673)scale(4.67509 9.51915)" gradientUnits="userSpaceOnUse"><stop stop-color="#602B2E"/><stop offset=".899" stop-color="#602B2E" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818168" cx="0" cy="0" r="1" gradientTransform="matrix(-.84586 .28195 -.31015 -.93044 6.388 20.088)" gradientUnits="userSpaceOnUse"><stop stop-color="#8B4267"/><stop offset="1" stop-color="#8B4267" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818169" cx="0" cy="0" r="1" gradientTransform="matrix(4.0625 -3.34375 3.01184 3.65924 12.226 23.188)" gradientUnits="userSpaceOnUse"><stop stop-color="#F4718B"/><stop offset=".854" stop-color="#F37890" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818170" cx="0" cy="0" r="1" gradientTransform="matrix(-2.875 -5.1875 4.90914 -2.72073 22.038 18.5)" gradientUnits="userSpaceOnUse"><stop stop-color="#FFC881"/><stop offset="1" stop-color="#FFC881" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818171" cx="0" cy="0" r="1" gradientTransform="matrix(-1.15624 -2.65625 3.25833 -1.41832 12.148 16.5)" gradientUnits="userSpaceOnUse"><stop stop-color="#D58653"/><stop offset="0" stop-color="#C56F38" stop-opacity=".974"/><stop offset="1" stop-color="#D58653" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818172" cx="0" cy="0" r="1" gradientTransform="rotate(92.272 -3.06 15.606)scale(3.9406 3.57085)" gradientUnits="userSpaceOnUse"><stop stop-color="#E58463"/><stop offset="0" stop-color="#C56F38" stop-opacity=".974"/><stop offset="1" stop-color="#E58463" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818173" cx="0" cy="0" r="1" gradientTransform="matrix(-5.28123 -3.31253 3.30204 -5.26451 16.132 25.063)" gradientUnits="userSpaceOnUse"><stop stop-color="#E58463"/><stop offset="0" stop-color="#C56F38" stop-opacity=".974"/><stop offset="1" stop-color="#E58463" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818174" cx="0" cy="0" r="1" gradientTransform="rotate(-38.839 17.067 -5.375)scale(3.20846 2.73313)" gradientUnits="userSpaceOnUse"><stop stop-color="#F16C42"/><stop offset="1" stop-color="#FFB179" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818175" cx="0" cy="0" r="1" gradientTransform="rotate(-40.426 27.218 2.252)scale(6.09611 3.09167)" gradientUnits="userSpaceOnUse"><stop stop-color="#F4718B"/><stop offset="1" stop-color="#F37890" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818176" cx="0" cy="0" r="1" gradientTransform="rotate(125.87 2.379 6.575)scale(2.50663 3.22281)" gradientUnits="userSpaceOnUse"><stop stop-color="#FFDAB6"/><stop offset="1" stop-color="#FFDAB6" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818177" cx="0" cy="0" r="1" gradientTransform="matrix(-3.125 -.93749 .99471 -3.31576 13.976 28.188)" gradientUnits="userSpaceOnUse"><stop stop-color="#DA9269"/><stop offset="1" stop-color="#DA9269" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818178" cx="0" cy="0" r="1" gradientTransform="matrix(-2.93748 2.66252 -1.19454 -1.3179 12.788 19.4)" gradientUnits="userSpaceOnUse"><stop stop-color="#DA9269"/><stop offset="1" stop-color="#DA9269" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818179" cx="0" cy="0" r="1" gradientTransform="matrix(-.438 .60644 -.54067 -.39049 7.434 9.303)" gradientUnits="userSpaceOnUse"><stop offset=".006" stop-color="#433437"/><stop offset="1" stop-color="#3B2838"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818180" cx="0" cy="0" r="1" gradientTransform="matrix(-.23584 .32007 -.2698 -.1988 7.451 9.362)" gradientUnits="userSpaceOnUse"><stop stop-color="#5C5051"/><stop offset="1" stop-color="#5C5051" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818181" cx="0" cy="0" r="1" gradientTransform="rotate(123.77 4.764 17.368)scale(12.4808)" gradientUnits="userSpaceOnUse"><stop stop-color="#FFBC7A"/><stop offset="1" stop-color="#DC872A"/><stop offset="1" stop-color="#F79755"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818182" cx="0" cy="0" r="1" gradientTransform="rotate(98.94 -2.79 17.791)scale(5.6309 3.83133)" gradientUnits="userSpaceOnUse"><stop stop-color="#EA8853"/><stop offset="1" stop-color="#EA8853" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818183" cx="0" cy="0" r="1" gradientTransform="matrix(0 .65625 -6.46875 0 14.976 28.938)" gradientUnits="userSpaceOnUse"><stop stop-color="#FAA44D"/><stop offset="1" stop-color="#FD9A6A" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818184" cx="0" cy="0" r="1" gradientTransform="matrix(-.9375 6.1875 -2.27772 -.34511 14.35 23.313)" gradientUnits="userSpaceOnUse"><stop stop-color="#E88750"/><stop offset="1" stop-color="#E88750" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818185" cx="0" cy="0" r="1" gradientTransform="matrix(-11 -7.12493 8.92994 -13.7867 24.163 30)" gradientUnits="userSpaceOnUse"><stop offset=".78" stop-color="#E9812E" stop-opacity="0"/><stop offset="1" stop-color="#E9812E"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818186" cx="0" cy="0" r="1" gradientTransform="rotate(100.46 3.514 19.587)scale(8.2623 3.78689)" gradientUnits="userSpaceOnUse"><stop stop-color="#FFC881"/><stop offset="1" stop-color="#FFC881" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818187" cx="0" cy="0" r="1" gradientTransform="matrix(2.40625 -.73437 .6019 1.97218 3.57 18.219)" gradientUnits="userSpaceOnUse"><stop stop-color="#FFCE9C"/><stop offset=".775" stop-color="#FFCE9C" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818188" cx="0" cy="0" r="1" gradientTransform="matrix(0 .57813 -1.91938 0 6.632 16.922)" gradientUnits="userSpaceOnUse"><stop stop-color="#F9AE7E"/><stop offset="1" stop-color="#F9AE7E" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818189" cx="0" cy="0" r="1" gradientTransform="rotate(153.759 9.574 12.017)scale(4.94738 12.4391)" gradientUnits="userSpaceOnUse"><stop stop-color="#C37E70"/><stop offset="1" stop-color="#9C5F60"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818190" cx="0" cy="0" r="1" gradientTransform="matrix(5.96875 0 0 1.18584 7.57 19.578)" gradientUnits="userSpaceOnUse"><stop stop-color="#F3A26C"/><stop offset="1" stop-color="#F3A26C" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818191" cx="0" cy="0" r="1" gradientTransform="matrix(1.09375 1.15625 -2.49436 2.35953 10.788 15.781)" gradientUnits="userSpaceOnUse"><stop stop-color="#D58653"/><stop offset="1" stop-color="#D58653" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818192" cx="0" cy="0" r="1" gradientTransform="matrix(-.71875 -.875 2.79292 -2.29421 14.1 19.875)" gradientUnits="userSpaceOnUse"><stop stop-color="#E98B57"/><stop offset="1" stop-color="#E98B57" stop-opacity="0"/></radialGradient><radialGradient id="IconifyId199691df1f81eb73818193" cx="0" cy="0" r="1" gradientTransform="matrix(-.83066 2.4724 -1.88722 -.63406 28.7 4.332)" gradientUnits="userSpaceOnUse"><stop stop-color="#C89185"/><stop offset="1" stop-color="#C89185" stop-opacity="0"/></radialGradient><linearGradient id="IconifyId199691df1f81eb73818194" x1="25.476" x2="25.476" y1="29.375" y2="3" gradientUnits="userSpaceOnUse"><stop stop-color="#732D22"/><stop offset="1" stop-color="#B67574"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818195" x1="30.913" x2="28.538" y1="18.688" y2="18.688" gradientUnits="userSpaceOnUse"><stop stop-color="#8B6D68"/><stop offset="1" stop-color="#8B6D68" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818196" x1="25.944" x2="25.944" y1="30.219" y2="25.969" gradientUnits="userSpaceOnUse"><stop stop-color="#843977"/><stop offset=".991" stop-color="#843977" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818197" x1="26.444" x2="26.444" y1="9.031" y2="27.5" gradientUnits="userSpaceOnUse"><stop stop-color="#C89492"/><stop offset="1" stop-color="#AA7472"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818198" x1="5.836" x2="6.619" y1="17.743" y2="20.127" gradientUnits="userSpaceOnUse"><stop stop-color="#9C5D65"/><stop offset="1" stop-color="#814376"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818199" x1="12.083" x2=".959" y1="3" y2="20.273" gradientUnits="userSpaceOnUse"><stop stop-color="#FFBC7A"/><stop offset="1" stop-color="#DC872A"/><stop offset="1" stop-color="#F79755"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818200" x1="3.594" x2="5.235" y1="6.875" y2="8.105" gradientUnits="userSpaceOnUse"><stop stop-color="#FFD490"/><stop offset="1" stop-color="#FFD490" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818201" x1="5.913" x2="6.029" y1="8.938" y2="15.082" gradientUnits="userSpaceOnUse"><stop stop-color="#FFD0A7"/><stop offset="1" stop-color="#EBB28F"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818202" x1="1.808" x2="3.24" y1="11.961" y2="11.961" gradientUnits="userSpaceOnUse"><stop offset=".006" stop-color="#432F41"/><stop offset="1" stop-color="#342230"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818203" x1="11.257" x2="10.585" y1="15.938" y2="14.688" gradientUnits="userSpaceOnUse"><stop stop-color="#DAA37B"/><stop offset="1" stop-color="#EBB28F"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818204" x1="13.976" x2="8.976" y1="23.669" y2="23.669" gradientUnits="userSpaceOnUse"><stop stop-color="#E19D85"/><stop offset="1" stop-color="#E8B997"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818205" x1="11.163" x2="11.163" y1="28.938" y2="25.875" gradientUnits="userSpaceOnUse"><stop stop-color="#E19D85"/><stop offset="1" stop-color="#E19D85" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818206" x1="16" x2="16" y1="30.625" y2="27.813" gradientUnits="userSpaceOnUse"><stop stop-color="#F4718B"/><stop offset=".77" stop-color="#F37890" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818207" x1="25.257" x2="24.476" y1="25" y2="25" gradientUnits="userSpaceOnUse"><stop stop-color="#EE9F58"/><stop offset="1" stop-color="#EE9F58" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818208" x1="7.288" x2="4.507" y1="18.438" y2="16.49" gradientUnits="userSpaceOnUse"><stop stop-color="#E68A48"/><stop offset="1" stop-color="#FAAF70"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818209" x1="15.632" x2="13.413" y1="17.192" y2="17.969" gradientUnits="userSpaceOnUse"><stop stop-color="#F0A456"/><stop offset="1" stop-color="#F1A75A" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818210" x1="9.975" x2="9.975" y1="21.719" y2="19.281" gradientUnits="userSpaceOnUse"><stop stop-color="#F4718B"/><stop offset=".713" stop-color="#F37890" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818211" x1="5.975" x2="7.288" y1="20.531" y2="20.531" gradientUnits="userSpaceOnUse"><stop stop-color="#FBA97E"/><stop offset="1" stop-color="#EB9D74" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818212" x1="14.694" x2="13.007" y1="19.281" y2="17.625" gradientUnits="userSpaceOnUse"><stop stop-color="#F5A57F"/><stop offset="1" stop-color="#F5A57F" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818213" x1="13.476" x2="10.351" y1="9.281" y2="10.969" gradientUnits="userSpaceOnUse"><stop stop-color="#FFD490"/><stop offset="1" stop-color="#FFD490" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818214" x1="12.031" x2="11.204" y1="4.808" y2="5.095" gradientUnits="userSpaceOnUse"><stop stop-color="#FFD490"/><stop offset="1" stop-color="#FFD490" stop-opacity="0"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818215" x1="22.524" x2="22.524" y1="4.75" y2="10.438" gradientUnits="userSpaceOnUse"><stop stop-color="#C89492"/><stop offset="1" stop-color="#AA7472"/></linearGradient><linearGradient id="IconifyId199691df1f81eb73818216" x1="24.929" x2="24.929" y1="8.594" y2="9.25" gradientUnits="userSpaceOnUse"><stop stop-color="#C89492"/><stop offset="1" stop-color="#AA7472"/></linearGradient><filter id="IconifyId199691df1f81eb73818217" width="9.2" height="27.4" x="20.976" y="3" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feColorMatrix in="SourceAlpha" result="hardAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx=".2" dy=".4"/><feGaussianBlur stdDeviation=".5"/><feComposite in2="hardAlpha" k2="-1" k3="1" operator="arithmetic"/><feColorMatrix values="0 0 0 0 0.431373 0 0 0 0 0.239216 0 0 0 0 0.231373 0 0 0 1 0"/><feBlend in2="shape" result="effect1_innerShadow_28327_3925"/></filter><filter id="IconifyId199691df1f81eb73818218" width="7.938" height="22.219" x="22.476" y="7.156" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_28327_3925" stdDeviation=".75"/></filter><filter id="IconifyId199691df1f81eb73818219" width="3.189" height="4.768" x="3.239" y="16.756" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_28327_3925" stdDeviation=".5"/></filter><filter id="IconifyId199691df1f81eb73818220" width="5.141" height="4.097" x="10.663" y="16.031" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_28327_3925" stdDeviation=".25"/></filter><filter id="IconifyId199691df1f81eb73818221" width="9.41" height="10.755" x="7.102" y="4.193" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_28327_3925" stdDeviation="1"/></filter><filter id="IconifyId199691df1f81eb73818222" width="3.289" height="4.295" x="9.991" y="2.893" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_28327_3925" stdDeviation=".5"/></filter><filter id="IconifyId199691df1f81eb73818223" width="4.689" height="4.861" x="25.858" y="2.532" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_28327_3925" stdDeviation=".5"/></filter><filter id="IconifyId199691df1f81eb73818224" width="3.34" height="7.688" x="20.854" y="3.75" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_28327_3925" stdDeviation=".375"/></filter><filter id="IconifyId199691df1f81eb73818225" width="3.828" height="2.797" x="23.132" y="7.453" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_28327_3925" stdDeviation=".4"/></filter></defs></g></svg> \ No newline at end of file diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/RedRectangle.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/RedRectangle.png new file mode 100644 index 0000000000..008473eb37 Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/RedRectangle.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/Space.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/Space.png new file mode 100644 index 0000000000..051dc6e2ae Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/Space.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/Swirls.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/Swirls.png new file mode 100644 index 0000000000..0c19fc3975 Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/Swirls.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/Win-Digital.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/Win-Digital.png new file mode 100644 index 0000000000..7aae180631 Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Images/Win-Digital.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/LockScreenLogo.scale-200.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/LockScreenLogo.scale-200.png new file mode 100644 index 0000000000..7440f0d4bf Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/LockScreenLogo.scale-200.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/SplashScreen.scale-200.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/SplashScreen.scale-200.png new file mode 100644 index 0000000000..32f486a867 Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/SplashScreen.scale-200.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Square150x150Logo.scale-200.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Square150x150Logo.scale-200.png new file mode 100644 index 0000000000..53ee3777ea Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Square150x150Logo.scale-200.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Square44x44Logo.scale-200.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Square44x44Logo.scale-200.png new file mode 100644 index 0000000000..f713bba67f Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Square44x44Logo.scale-200.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000000..dc9f5bea0c Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/StoreLogo.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/StoreLogo.png new file mode 100644 index 0000000000..a4586f26bd Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/StoreLogo.png differ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Wide310x150Logo.scale-200.png b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Wide310x150Logo.scale-200.png new file mode 100644 index 0000000000..8b4a5d0dd5 Binary files /dev/null and b/src/modules/cmdpal/ext/SamplePagesExtension/Assets/Wide310x150Logo.scale-200.png differ diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSampleListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/EvilSampleListPage.cs similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/EvilSampleListPage.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/EvilSampleListPage.cs diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs new file mode 100644 index 0000000000..1fc86bb54a --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; + +namespace SamplePagesExtension; + +public partial class EvilSamplesPage : ListPage +{ + private readonly IListItem[] _commands = [ + new ListItem(new EvilSampleListPage()) + { + Title = "List Page without items", + Subtitle = "Throws exception on GetItems", + }, + new ListItem(new ExplodeInFiveSeconds(false)) + { + Title = "Page that will throw an exception after loading it", + Subtitle = "Throws exception on GetItems _after_ a ItemsChanged", + }, + new ListItem(new ExplodeInFiveSeconds(true)) + { + Title = "Page that keeps throwing exceptions", + Subtitle = "Will throw every 5 seconds once you open it", + }, + new ListItem(new ExplodeOnPropChange()) + { + Title = "Throw in the middle of a PropChanged", + Subtitle = "Will throw every 5 seconds once you open it", + }, + new ListItem(new SelfImmolateCommand()) + { + Title = "Terminate this extension", + Subtitle = "Will exit this extension (while it's loaded!)", + }, + new ListItem(new EvilSlowDynamicPage()) + { + Title = "Slow loading Dynamic Page", + Subtitle = "Takes 5 seconds to load each time you type", + Tags = [new Tag("GH #38190")], + }, + new ListItem(new EvilFastUpdatesPage()) + { + Title = "Fast updating Dynamic Page", + Subtitle = "Updates in the middle of a GetItems call", + Tags = [new Tag("GH #41149")], + }, + new ListItem(new NoOpCommand()) + { + Title = "I have lots of nulls", + Subtitle = null, + MoreCommands = null, + Tags = null, + Details = new Details() + { + Title = null, + HeroImage = null, + Metadata = null, + }, + }, + new ListItem(new NoOpCommand()) + { + Title = "I also have nulls", + Subtitle = null, + MoreCommands = null, + Details = new Details() + { + Title = null, + HeroImage = null, + Metadata = [new DetailsElement() { Key = "Oops all nulls", Data = new DetailsTags() { Tags = null } }], + }, + }, + new ListItem(new AnonymousCommand(action: () => + { + ToastStatusMessage toast = new("I should appear immediately"); + toast.Show(); + Thread.Sleep(5000); + }) { Result = CommandResult.KeepOpen() }) + { + Title = "I take just forever to return something", + Subtitle = "The toast should appear immediately.", + MoreCommands = null, + Details = new Details() + { + Body = "This is a test for GH#512. If it doesn't appear immediately, it's likely InvokeCommand is happening on the UI thread.", + }, + }, + + // More edge cases than truly evil + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "anonymous command test", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Secondary command invoked", MessageState.Warning) { Name = "Secondary command", Icon = new IconInfo("\uF147") }) // dial 2 + { + Title = "I'm a second command", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), + }, + new CommandContextItem("nested...") + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "noop command test", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Secondary command invoked", MessageState.Warning) { Name = "Secondary command", Icon = new IconInfo("\uF147") }) // dial 2 + { + Title = "I'm a second command", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), + }, + new CommandContextItem(new NoOpCommand()) + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "noop secondary command test", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem(new NoOpCommand()) + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "H W\r\nE O\r\nL R\r\nL L\r\nO D", Icon = new IconInfo("\uF146") }) + { + Title = "noop third command test", + Icon = new IconInfo("\uE712"), // "More" dots + }, + new ListItem(new EvilDuplicateRequestedShortcut()) + { + Title = "Evil keyboard shortcuts", + Subtitle = "Two commands with the same shortcut and more...", + Icon = new IconInfo("\uE765"), + }, + ]; + + public EvilSamplesPage() + { + Name = "Evil Samples"; + Icon = new IconInfo("👿"); // Info + } + + public override IListItem[] GetItems() => _commands; +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class ExplodeOnPropChange : ListPage +{ + private bool _explode; + + public override string Title + { + get => _explode ? Commands[9001].Title : base.Title; + set => base.Title = value; + } + + private IListItem[] Commands => [ + new ListItem(new NoOpCommand()) + { + Title = "This page will explode in five seconds!", + Subtitle = "I'll change my Name, then explode", + }, + ]; + + public ExplodeOnPropChange() + { + Icon = new IconInfo(string.Empty); + Name = "Open"; + } + + public override IListItem[] GetItems() + { + _ = Task.Run(() => + { + Thread.Sleep(1000); + Title = "Ready? 3..."; + Thread.Sleep(1000); + Title = "Ready? 2..."; + Thread.Sleep(1000); + Title = "Ready? 1..."; + Thread.Sleep(1000); + _explode = true; + Title = "boom"; + }); + return Commands; + } +} + +/// <summary> +/// This sample simulates a long delay in handling UpdateSearchText. I've found +/// that if I type "124356781234", then somewhere around the second "1234", +/// we'll get into a state where the character is typed, but then CmdPal snaps +/// back to a previous query. +/// +/// We can use this to validate that we're always sticking with the last +/// SearchText. My guess is that it's a bug in +/// Toolkit.DynamicListPage.SearchText.set +/// +/// see GH #38190 +/// </summary> +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class EvilSlowDynamicPage : DynamicListPage +{ + private IListItem[] _items = []; + + public EvilSlowDynamicPage() + { + Icon = new IconInfo(string.Empty); + Name = "Open"; + Title = "Evil Slow Dynamic Page"; + PlaceholderText = "Type to see items appear after a delay"; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + DoQuery(newSearch); + RaiseItemsChanged(newSearch.Length); + } + + public override IListItem[] GetItems() + { + return _items.Length > 0 ? _items : DoQuery(SearchText); + } + + private IListItem[] DoQuery(string newSearch) + { + IsLoading = true; + + // Sleep for longer for shorter search terms + var delay = 10000 - (newSearch.Length * 2000); + delay = delay < 0 ? 0 : delay; + if (newSearch.Length == 0) + { + delay = 0; + } + + delay += 50; + + Thread.Sleep(delay); // Simulate a long load time + + var items = newSearch.ToCharArray().Select(ch => new ListItem(new NoOpCommand()) { Title = ch.ToString() }).ToArray(); + if (items.Length == 0) + { + items = [new ListItem(new NoOpCommand()) { Title = "Start typing in the search box" }]; + } + + if (items.Length > 0) + { + items[0].Subtitle = "Notice how the number of items changes for this page when you type in the filter box"; + } + + IsLoading = false; + + return items; + } +} + +/// <summary> +/// A sample for a page that updates its items in the middle of a GetItems call. +/// In this sample, we're returning 10000 items, which genuinely marshal slowly +/// (even before we start retrieving properties from them). +/// +/// While we're in the middle of the marshalling of that GetItems call, the +/// background thread we started will kick off another GetItems (via the +/// RaiseItemsChanged). +/// +/// That second GetItems will return a single item, which marshals quickly. +/// CmdPal _should_ only display that single green item. However, as of v0.4, +/// we'll display that green item, then "snap back" to the red items, when they +/// finish marshalling. +/// +/// See GH #41149 +/// </summary> +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class EvilFastUpdatesPage : DynamicListPage +{ + private static readonly IconInfo _red = new("🔴"); // "Red" icon + private static readonly IconInfo _green = new("🟢"); // "Green" icon + + private IListItem[] _redItems = []; + private IListItem[] _greenItems = []; + private bool _sentRed; + + public EvilFastUpdatesPage() + { + Icon = new IconInfo(string.Empty); + Name = "Open"; + Title = "Evil Fast Updates Page"; + PlaceholderText = "Type to trigger an update"; + + _redItems = Enumerable.Range(0, 10000).Select(i => new ListItem(new NoOpCommand()) + { + Icon = _red, + Title = $"Item {i + 1}", + Subtitle = "CmdPal is doing it wrong", + }).ToArray(); + _greenItems = [new ListItem(new NoOpCommand()) { Icon = _green, Title = "It works" }]; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) + { + _sentRed = false; + RaiseItemsChanged(); + } + + public override IListItem[] GetItems() + { + if (!_sentRed) + { + IsLoading = true; + _sentRed = true; + + // kick off a task to update the items after a delay + _ = Task.Run(() => + { + Thread.Sleep(5); + RaiseItemsChanged(); + }); + + return _redItems; + } + else + { + IsLoading = false; + return _greenItems; + } + } +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class EvilDuplicateRequestedShortcut : ListPage +{ + private readonly IListItem[] _items = + [ + new ListItem(new NoOpCommand()) + { + Title = "I'm evil!", + Subtitle = "I have multiple commands sharing the same keyboard shortcut", + MoreCommands = [ + new CommandContextItem(new AnonymousCommand(() => new ToastStatusMessage("Me too executed").Show()) + { + Result = CommandResult.KeepOpen(), + }) + { + Title = "Me too", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), + }, + new CommandContextItem(new AnonymousCommand(() => new ToastStatusMessage("Me three executed").Show()) + { + Result = CommandResult.KeepOpen(), + }) + { + Title = "Me three", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), + }, + ], + }, + ]; + + public override IListItem[] GetItems() => _items; + + public EvilDuplicateRequestedShortcut() + { + Icon = new IconInfo(string.Empty); + Name = "Open"; + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/ExplodeInFiveSeconds.cs b/src/modules/cmdpal/ext/SamplePagesExtension/ExplodeInFiveSeconds.cs similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/ExplodeInFiveSeconds.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/ExplodeInFiveSeconds.cs diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/NativeMethods.txt b/src/modules/cmdpal/ext/SamplePagesExtension/NativeMethods.txt new file mode 100644 index 0000000000..50eba02d20 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/NativeMethods.txt @@ -0,0 +1,3 @@ +GetForegroundWindow +GetWindowTextLength +GetWindowText \ No newline at end of file diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/OnLoadPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/OnLoadPage.cs new file mode 100644 index 0000000000..fb2911e99d --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/OnLoadPage.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; + +namespace SamplePagesExtension; + +internal sealed partial class OnLoadPage : IListPage +{ + private readonly List<ListItem> _items = new(); + + public IIconInfo Icon => new IconInfo("\uE8AB"); // switch + + public string Title => "Load/Unload sample"; + + public string PlaceholderText => "This page changes each time you load it"; + + public ICommandItem EmptyContent => new CommandItem() { Icon = new IconInfo("\uE8AB"), Title = "This page starts empty", Subtitle = "but go back and open it again" }; + + public IFilters Filters => null; + + public IGridProperties GridProperties => null; + + public bool HasMoreItems => false; + + public string SearchText => string.Empty; + + public bool ShowDetails => false; + + public OptionalColor AccentColor => default; + + public bool IsLoading => false; + + public string Id => string.Empty; + + public string Name => "Open"; + +#pragma warning disable CS0067 // The event is never used + public event TypedEventHandler<object, IPropChangedEventArgs> PropChanged; + + private event TypedEventHandler<object, IItemsChangedEventArgs> InternalItemsChanged; +#pragma warning restore CS0067 // The event is never used + + public event TypedEventHandler<object, IItemsChangedEventArgs> ItemsChanged + { + add + { + InternalItemsChanged += value; + var nowString = DateTime.Now.ToString("T", CultureInfo.CurrentCulture); + var item = new ListItem(new NoOpCommand()) + { + Title = $"Loaded {nowString}", + Icon = new IconInfo("\uECCB"), // Radio button on + }; + _items.Add(item); + } + + remove + { + InternalItemsChanged -= value; + var nowString = DateTime.Now.ToString("T", CultureInfo.CurrentCulture); + var item = new ListItem(new NoOpCommand()) + { + Title = $"Unloaded {nowString}", + Icon = new IconInfo("\uECCA"), // Radio button off + }; + _items.Add(item); + } + } + + public IListItem[] GetItems() => _items.ToArray(); + + public void LoadMore() + { + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Package.appxmanifest b/src/modules/cmdpal/ext/SamplePagesExtension/Package.appxmanifest similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Package.appxmanifest rename to src/modules/cmdpal/ext/SamplePagesExtension/Package.appxmanifest diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/IssueSpecificPages/AllIssueSamplesIndexPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/IssueSpecificPages/AllIssueSamplesIndexPage.cs new file mode 100644 index 0000000000..42cb326178 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/IssueSpecificPages/AllIssueSamplesIndexPage.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 Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension.Pages.IssueSpecificPages; + +internal sealed partial class AllIssueSamplesIndexPage : ListPage +{ + public AllIssueSamplesIndexPage() + { + Icon = new IconInfo("🐛"); + Name = "All Issue Samples Index Page"; + } + + public override IListItem[] GetItems() + { + return new IListItem[] + { + new ListItem(new SamplePageForIssue42827_FilterDropDownStaysVisibleAfterSwitchingFromListToContentPage()) + { + Title = "Issue 42827 - Filter Drop Down Stays Visible After Switching From List To Content Page", + Subtitle = "Repro steps: Open this page, open the filter dropdown, select a filter, navigate to a content page, navigate back to this page. The filter dropdown should be closed but it remains open.", + }, + }; + } +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/IssueSpecificPages/SamplePageForIssue42827_FilterDropDownStaysVisibleAfterSwitchingFromListToContentPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/IssueSpecificPages/SamplePageForIssue42827_FilterDropDownStaysVisibleAfterSwitchingFromListToContentPage.cs new file mode 100644 index 0000000000..4d29b56c83 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/IssueSpecificPages/SamplePageForIssue42827_FilterDropDownStaysVisibleAfterSwitchingFromListToContentPage.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension.Pages.IssueSpecificPages; + +internal sealed partial class SamplePageForIssue42827_FilterDropDownStaysVisibleAfterSwitchingFromListToContentPage : DynamicListPage +{ + public SamplePageForIssue42827_FilterDropDownStaysVisibleAfterSwitchingFromListToContentPage() + { + Icon = new IconInfo(string.Empty); + Name = "Issue 42827 - Filters not hiding when navigating between pages"; + IsLoading = true; + var filters = new SampleFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; + } + + private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged(); + + public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(); + + public override IListItem[] GetItems() + { + var items = SearchText.ToCharArray().Select(ch => new ListItem(new SampleContentPage()) { Title = ch.ToString() }).ToArray(); + if (items.Length == 0) + { + items = [ + new ListItem(new SampleContentPage()) { Title = "This List item will open a content page" }, + new ListItem(new SampleContentPage()) { Title = "This List item will open a content page too" }, + new ListItem(new SampleContentPage()) { Title = "Guess what this one will do?" }, + ]; + } + + if (!string.IsNullOrEmpty(Filters.CurrentFilterId)) + { + switch (Filters.CurrentFilterId) + { + case "mod2": + items = items.Where((item, index) => (index + 1) % 2 == 0).ToArray(); + break; + case "mod3": + items = items.Where((item, index) => (index + 1) % 3 == 0).ToArray(); + break; + case "all": + default: + // No filtering + break; + } + } + + foreach (var item in items) + { + item.Subtitle = "Filter drop-down should be hidden when navigating to a content page"; + } + + return items; + } + + internal sealed partial class SampleFilters : Filters + { + public override IFilterItem[] GetFilters() + { + return + [ + new Filter() { Id = "all", Name = "All" }, + new Filter() { Id = "mod2", Name = "Every 2nd", Icon = new IconInfo("2") }, + new Filter() { Id = "mod3", Name = "Every 3rd (and long name)", Icon = new IconInfo("3") }, + ]; + } + } +} diff --git a/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleCommentsPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleCommentsPage.cs similarity index 100% rename from src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleCommentsPage.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleCommentsPage.cs diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleContentPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs similarity index 99% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleContentPage.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs index a602f74a00..0584d96ee6 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleContentPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleContentPage.cs @@ -304,7 +304,7 @@ internal sealed partial class SampleContentForm : FormContent public override CommandResult SubmitForm(string payload) { var formInput = JsonNode.Parse(payload)?.AsObject(); - if (formInput == null) + if (formInput is null) { return CommandResult.GoHome(); } diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleDynamicListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleDynamicListPage.cs new file mode 100644 index 0000000000..ad2beb86b8 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleDynamicListPage.cs @@ -0,0 +1,76 @@ +// 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.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension; + +internal sealed partial class SampleDynamicListPage : DynamicListPage +{ + public SampleDynamicListPage() + { + Icon = new IconInfo(string.Empty); + Name = "Dynamic List"; + IsLoading = true; + var filters = new SampleFilters(); + filters.PropChanged += Filters_PropChanged; + Filters = filters; + } + + private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged(); + + public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(); + + public override IListItem[] GetItems() + { + var items = SearchText.ToCharArray().Select(ch => new ListItem(new NoOpCommand()) { Title = ch.ToString() }).ToArray(); + if (items.Length == 0) + { + items = [new ListItem(new NoOpCommand()) { Title = "Start typing in the search box" }]; + } + + if (!string.IsNullOrEmpty(Filters.CurrentFilterId)) + { + switch (Filters.CurrentFilterId) + { + case "mod2": + items = items.Where((item, index) => (index + 1) % 2 == 0).ToArray(); + break; + case "mod3": + items = items.Where((item, index) => (index + 1) % 3 == 0).ToArray(); + break; + case "all": + default: + // No filtering + break; + } + } + + if (items.Length > 0) + { + items[0].Subtitle = "Notice how the number of items changes for this page when you type in the filter box"; + } + + return items; + } +} + +#pragma warning disable SA1402 // File may only contain a single type +public partial class SampleFilters : Filters +#pragma warning restore SA1402 // File may only contain a single type +{ + public override IFilterItem[] GetFilters() + { + return + [ + new Filter() { Id = "all", Name = "All" }, + new Filter() { Id = "mod2", Name = "Every 2nd", Icon = new IconInfo("2") }, + new Filter() { Id = "mod3", Name = "Every 3rd (and long name)", Icon = new IconInfo("3") }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGalleryListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGalleryListPage.cs new file mode 100644 index 0000000000..2f6fba7089 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGalleryListPage.cs @@ -0,0 +1,59 @@ +// 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension; + +internal sealed partial class SampleGalleryListPage : ListPage +{ + public override IListItem[] GetItems() + { + return [ + new ListItem(new NoOpCommand()) + { + Title = "Sample Title", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/RedRectangle.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Another Title", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "More Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Stop With The Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/RedRectangle.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Another Title", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Space.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "More Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Stop With The Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGridsListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGridsListPage.cs new file mode 100644 index 0000000000..05b604c912 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGridsListPage.cs @@ -0,0 +1,59 @@ +// 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension; + +internal sealed partial class SampleGridsListPage : ListPage +{ + private readonly IListItem[] _items = + [ + new ListItem(new SampleGalleryListPage { GridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = true } }) + { + Title = "Gallery list page (title and subtitle)", + Subtitle = "A sample gallery list page with images", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false } }) + { + Title = "Gallery list page (title, no subtitle)", + Subtitle = "A sample gallery list page with images", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new GalleryGridLayout { ShowTitle = false, ShowSubtitle = false } }) + { + Title = "Gallery list page (no title, no subtitle)", + Subtitle = "A sample gallery list page with images", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new SmallGridLayout() }) + { + Title = "Small grid list page", + Subtitle = "A sample grid list page with text items", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new MediumGridLayout { ShowTitle = true } }) + { + Title = "Medium grid (with title)", + Subtitle = "A sample grid list page with text items", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new MediumGridLayout { ShowTitle = false } }) + { + Title = "Medium grid (hidden title)", + Subtitle = "A sample grid list page with text items", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + } + ]; + + public SampleGridsListPage() + { + Icon = new IconInfo("\uE7C5"); + Name = "Grid and gallery lists"; + } + + public override IListItem[] GetItems() => _items; +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleIconPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleIconPage.cs new file mode 100644 index 0000000000..08059dbc9e --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleIconPage.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension.Pages; + +internal sealed partial class SampleIconPage : ListPage +{ + private readonly IListItem[] _items = + [ + /* + * Quick intro to Unicode in source code: + * - Every character has a code point (e.g., U+0041 = 'A'). + * - Code points up to U+FFFF use \u1234 (4 hex digits and lowercase u). + * - Code points above that (up to U+10FFFF) use \U12345678 (8 hex digits and capital letter U). + * - If your source file is UTF-8, you can type the character directly, but it may not display properly in editors, + * and it's harder to see the actual code point. + * - Some symbols (like many emojis) are built from multiple code points + * joined together (e.g., 👋🏻 = U+1F44B + U+1F3FB). + * + * Examples: + * 😍 = "😍" or "\U0001F60D" + * 👋🏻 = "👋🏻" or "\U0001F44B\U0001F3FB" + * 🧙‍♂️ = "🧙‍♂️" or "\U0001F9D9\u200D\u2642\U0000FE0F" (male mage) + * 🧙🏿‍♀️ = "🧙🏿‍♀️" or "\U0001F9D9\U0001F3FF\u200D\u2640\U0000FE0F" (dark-skinned woman mage) + * + */ + + // Emoji Smiling Face with Heart-Eyes + // Unicode: \U0001F60D + BuildIconItem("😍", "Standard emoji icon", "Basic emoji character rendered as an icon"), + + // Emoji Smiling Face with Heart-Eyes + // Unicode: \U0001F60D\U0001F643\U0001F622 + BuildIconItem("😍🙃😢", "Multiple emojis", "Use of multiple emojis for icon is not allowed"), + + // Emoji Smiling Face with Sunglasses + // Unicode: \U0001F60E + BuildIconItem("\U0001F60E", "Unicode escape sequence emoji", "Emoji defined using Unicode escape sequence notation"), + + // Segoe Fluent Icons font icon + // Unicode: \uE8D4 + BuildIconItem("\uE8D4", "Segoe Fluent icon demonstration", "Segoe Fluent/MDL2 icon from system font\nWorks as an icon but won't display properly in button text"), + + // Extended pictographic symbol for keyboard + BuildIconItem("\u2328", "Extended pictographic symbol", "Pictographic symbol representing a keyboard"), + + // Capital letter A + BuildIconItem("A", "Simple text character as icon", "Basic letter character used as an icon demonstration"), + + // Letter 1 + // Unicode: \U00000031 + BuildIconItem("1", "Simple text character as icon", "Basic letter character used as an icon demonstration"), + + // Emoji Keycap Digit Two ... 2️⃣ + // Unicode: \U00000032\U000020E3 + // This is a sequence of three code points: the digit '2' (U+0032), and a combining enclosing keycap (U+20E3). No variation selector is used here. + BuildIconItem("\U00000032\U000020E3", "Emoji without variation selector", "Emoji character doesn't have VS16 variation selector to render as text"), + + // Emoji Keycap Digit Three ... 3️⃣ + // Unicode: \U00000033\U0000FE0F\U000020E3 + // This is a sequence of three code points: the digit '3' (U+0033), a variation selector (U+FE0F) to specify emoji presentation, and a combining enclosing keycap (U+20E3). + BuildIconItem("3️⃣", "Emoji with variation selector", "Emoji character using a variation selector to specify emoji presentation"), + + // Symbol # + // Unicode: \u0023 + BuildIconItem("#", "Simple text character as icon", "Basic letter character used as an icon demonstration"), + + // Symbol # keycap + // Unicode: \u0023\ufe0f\u20e3 + // Sequence of 3 code points: symbol #, a variation selector (U+FE0F) to specify emoji presentation, and a combining enclosing keycap (U+20E3). + BuildIconItem("\u0023\ufe0f\u20e3", "Simple text character as icon", "Basic letter character used as an icon demonstration"), + + // Capital letter WM + // This is two characters, which is not a valid icon representation. It will be replaced by a placeholder signalizing an invalid icon. + BuildIconItem("WM", "Invalid icon representation", "String with multiple characters that does not correspond to a valid single icon"), + + // Emoji Mage + // Unicode: \U0001F9D9 + BuildIconItem("🧙", "Single code-point emoji example", "Simple emoji character using a single Unicode code point"), + + // Emoji Male Mage (Mage with gender modifier) + // Unicode: \U0001F9D9\u200D\u2642\uFE0F + BuildIconItem("🧙‍♂️", "Complex emoji with gender modifier", "Composite emoji using Zero-Width Joiner (ZWJ) sequence for male variant"), + + // Emoji Woman Mage (Mage with gender modifier) + // Unicode: \U0001F9D9\u200D\u2640\uFE0F + BuildIconItem("\U0001F9D9\u200D\u2640\uFE0F", "Complex emoji with gender modifier", "Composite emoji using Zero-Width Joiner (ZWJ) sequence for female variant"), + + // Emoji Waving Hand + // Unicode: \U0001F44B + BuildIconItem("👋", "Basic hand gesture emoji", "Standard emoji character representing a waving hand"), + + // Emoji Waving Hand + Light Skin Tone + // Unicode: \U0001F44B\U0001F3FB + BuildIconItem("👋🏻", "Emoji with light skin tone modifier", "Emoji enhanced with Unicode skin tone modifier (light)"), + + // Emoji Waving Hand + Dark Skin Tone + // Unicode: \U0001F44B\U0001F3FF + BuildIconItem("\U0001F44B\U0001F3FF", "Emoji with dark skin tone modifier", "Emoji enhanced with Unicode skin tone modifier (dark)"), + + // Flag of Czechia (Czech Republic) + // Unicode: \U0001F1E8\U0001F1FF + BuildIconItem("\U0001F1E8\U0001F1FF", "Flag emoji using regional indicators", "Emoji flag constructed from regional indicator symbols for Czechia"), + + // Use of ZWJ without emojis + // KA (\u0995) + VIRAMA (\u09CD) + ZWJ (\u200D) - shows the half-form KA + // Unicode: \u0995\u09CD\u200D + BuildIconItem("\u0995\u09CD\u200D", "Use of ZWJ in non-emoji context", "Shows the half-form KA"), + + // Use of ZWJ without emojis + // KA (\u0995) + VIRAMA (\u09CD) + Shows full KA with an explicit virama mark (not half-form). + // Unicode: \u0995\u09CD + BuildIconItem("\u0995\u09CD", "Use of ZWJ in non-emoji context", "Shows full KA with an explicit virama mark"), + + // mahjong tile red dragon (using Unicode escape sequence) + // https://en.wikipedia.org/wiki/Mahjong_Tiles_(Unicode_block) + // Unicode: \U0001F004 + BuildIconItem("\U0001F004", "Mahjong tile emoji (red dragon)", "Mahjong tile red dragon emoji character using Unicode escape sequence"), + + // mahjong tile green dragon (non-emoji) + // https://en.wikipedia.org/wiki/Mahjong_Tiles_(Unicode_block) + // Unicode: \U0001F005 + BuildIconItem("\U0001F005", "Mahjong tile non-emoji (green dragon)", "Mahjong tile character that is not classified as an emoji"), + + // Play, PlayPause, Stop + BuildIconItem("\u25B6", "Play symbol (standalone)", "Play symbol"), + BuildIconItem("\u25B6\uFE0E", "Play symbol + VS15 (request text)", "Play symbol with variation specifier requesting rendering as text"), + BuildIconItem("\u25B6\uFE0F", "Play symbol + VS16 (request emoji)", "Play symbol with variation specifier requesting rendering as emoji "), + BuildIconItem("⏯️", "Play/Pause keycap emoji", "Play/Pause keycap emoji doesn't have plain text variant"), + BuildIconItem("⏸️", "Pause keycap emoji", "Pause keycap emoji doesn't have plain text variant"), + + // Copyright and emoji copyright: + BuildIconItem("\u00a9", "Copyright symbol (standalone)", "Copyright symbol that is not classified as an emoji"), + BuildIconItem("\u00a9\uFE0E", "Copyright symbol + VS15 (request text)", "Copyright symbol that is not classified as an emoji"), + BuildIconItem("\u00a9\uFE0F", "Copyright symbol + VS16 (request emoji)", "Copyright symbol that is not classified as an emoji"), + + // Tag flags + BuildIconItem("🏳️", "White Flag", "White Flag"), + BuildIconItem("\U0001F3F4\u200D\u2620\uFE0F", "Pirate Flag", "Pirate Flag"), + ]; + + public SampleIconPage() + { + Icon = new IconInfo("\uE8BA"); + Name = "Sample Icon Page"; + ShowDetails = true; + } + + public override IListItem[] GetItems() => _items; + + private static ListItem BuildIconItem(string icon, string title, string description) + { + var iconInfo = new IconInfo(icon); + + return new ListItem(new CopyTextCommand(icon) { Name = "Action with " + icon }) + { + Title = title, + Subtitle = description, + Icon = iconInfo, + Tags = [ + new Tag("Tag") { Icon = iconInfo }, + ], + Details = new Details + { + HeroImage = iconInfo, + Title = title, + Body = description, + Metadata = [ + new DetailsElement + { + Key = "Unicode Code Points", + Data = new DetailsTags + { + Tags = icon.EnumerateRunes() + .Select(rune => rune.Value <= 0xFFFF ? $"\\u{rune.Value:X4}" : $"\\U{rune.Value:X8}") + .Select(t => new Tag(t)) + .ToArray<ITag>(), + }, + } + ], + }, + }; + } +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs new file mode 100644 index 0000000000..2464724d50 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Foundation; +using Windows.System; +using Windows.Win32; + +namespace SamplePagesExtension; + +internal sealed partial class SampleListPage : ListPage +{ + public SampleListPage() + { + Icon = new IconInfo("\uEA37"); + Name = "Sample List Page"; + } + + public override IListItem[] GetItems() + { + var confirmOnceArgs = new ConfirmationArgs + { + PrimaryCommand = new AnonymousCommand( + () => + { + var t = new ToastStatusMessage("The dialog was confirmed"); + t.Show(); + }) + { + Name = "Confirm", + Result = CommandResult.KeepOpen(), + }, + Title = "You can set a title for the dialog", + Description = "Are you really sure you want to do the thing?", + }; + var confirmTwiceArgs = new ConfirmationArgs + { + PrimaryCommand = new AnonymousCommand(() => { }) + { + Name = "How sure are you?", + Result = CommandResult.Confirm(confirmOnceArgs), + }, + Title = "You can ask twice too", + Description = "You probably don't want to though, that'd be annoying.", + }; + + return [ + new ListItem(new NoOpCommand()) + { + Title = "This is a basic item in the list", + Subtitle = "I don't do anything though", + }, + new ListItem(new SampleListPageWithDetails()) + { + Title = "This item will take you to another page", + Subtitle = "This allows for nested lists of items", + }, + new ListItem(new OpenUrlCommand("https://github.com/microsoft/powertoys")) + { + Title = "Or you can go to links", + Subtitle = "This takes you to the PowerToys repo on GitHub", + }, + new ListItem(new SampleMarkdownPage()) + { + Title = "Items can have tags", + Subtitle = "and I'll take you to a page with markdown content", + Tags = [new Tag("Sample Tag")], + }, + + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "You can add context menu items too. Press Ctrl+k", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Secondary command invoked", MessageState.Warning) { Name = "Secondary command", Icon = new IconInfo("\uF147") }) // dial 2 + { + Title = "I'm a second command", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), + }, + new Separator(), + new CommandContextItem( + new ToastCommand("Third command invoked", MessageState.Error) { Name = "Do 3", Icon = new IconInfo("\uF148") }) // dial 3 + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B with a really, really long title that should be trimmed", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + + new ListItem(new SendMessageCommand()) + { + Title = "I send lots of messages", + Subtitle = "Status messages can be used to provide feedback to the user in-app", + }, + new SendSingleMessageItem(), + new ListItem(new IndeterminateProgressMessageCommand()) + { + Title = "Do a thing with a spinner", + Subtitle = "Messages can have progress spinners, to indicate something is happening in the background", + }, + new ListItem( + new AnonymousCommand(() => { }) + { + Result = CommandResult.Confirm(confirmOnceArgs), + }) + { + Title = "Confirm before doing something", + }, + new ListItem( + new AnonymousCommand(() => { }) + { + Result = CommandResult.Confirm(confirmTwiceArgs), + }) + { + Title = "Confirm twice before doing something", + }, + new ListItem( + new AnonymousCommand(() => + { + var fg = PInvoke.GetForegroundWindow(); + var bufferSize = PInvoke.GetWindowTextLength(fg) + 1; + unsafe + { + fixed (char* windowNameChars = new char[bufferSize]) + { + if (PInvoke.GetWindowText(fg, windowNameChars, bufferSize) == 0) + { + var emptyToast = new ToastStatusMessage(new StatusMessage() { Message = "FG Window didn't have a title", State = MessageState.Warning }); + emptyToast.Show(); + } + + var windowName = new string(windowNameChars); + var nameToast = new ToastStatusMessage(new StatusMessage() { Message = $"FG Window is {windowName}", State = MessageState.Success }); + nameToast.Show(); + } + } + }) + { + Result = CommandResult.KeepOpen(), + }) + { + Title = "Get the name of the Foreground window", + }, + + new ListItem(new CommandWithProperties()) + { + Title = "I have properties", + }, + new ListItem(new OtherCommandWithProperties()) + { + Title = "I also have properties", + }, + new ListItem(new EverChangingCommand("Cat", "🐈‍⬛", "🐈")) + { + Title = "And I have a commands with changing name and icon", + MoreCommands = [ + new CommandContextItem(new EverChangingCommand("Water", "🐬", "🐳", "🐟", "🦈")), + new CommandContextItem(new EverChangingCommand("Faces", "😁", "🥺", "😍")), + new CommandContextItem(new EverChangingCommand("Hearts", "♥️", "💚", "💜", "🧡", "💛", "💙")), + ], + }, + new ListItemChangingCommandInTime() + { + Title = "I'm a list item that changes entire command in time", + }, + ]; + } + + internal sealed partial class CommandWithProperties : InvokableCommand, IExtendedAttributesProvider + { + private FontIconData _icon = new("\u0026", "Wingdings"); + + public override IconInfo Icon => new(_icon, _icon); + + public override string Name => "Whatever"; + + // LOAD-BEARING: Use a Windows.Foundation.Collections.ValueSet as the + // backing store for Properties. A regular `Dictionary<string, object>` + // will not work across the ABI + public IDictionary<string, object> GetProperties() => new Windows.Foundation.Collections.ValueSet() + { + { "Foo", "bar" }, + { "Secret", 42 }, + { "hmm?", null }, + }; + } + + internal sealed partial class OtherCommandWithProperties : IExtendedAttributesProvider, IInvokableCommand + { + public string Name => "Whatever 2"; + + public IIconInfo Icon => new IconInfo("\uF146"); + + public string Id => string.Empty; + + public event TypedEventHandler<object, IPropChangedEventArgs> PropChanged; + + public ICommandResult Invoke(object sender) + { + PropChanged?.Invoke(this, new PropChangedEventArgs(nameof(Name))); + return CommandResult.ShowToast("whoop"); + } + + // LOAD-BEARING: Use a Windows.Foundation.Collections.ValueSet as the + // backing store for Properties. A regular `Dictionary<string, object>` + // will not work across the ABI + public IDictionary<string, object> GetProperties() => new Windows.Foundation.Collections.ValueSet() + { + { "yo", "dog" }, + { "Secret", 12345 }, + { "hmm?", null }, + }; + } + + internal sealed partial class EverChangingCommand : InvokableCommand, IDisposable + { + private readonly string[] _icons; + private readonly Timer _timer; + private readonly string _name; + private int _currentIndex; + + public EverChangingCommand(string name, params string[] icons) + : this(name, TimeSpan.FromSeconds(5), icons) + { + } + + public EverChangingCommand(string name, TimeSpan interval, params string[] icons) + { + _icons = icons ?? throw new ArgumentNullException(nameof(icons)); + if (_icons.Length == 0) + { + throw new ArgumentException("Icons array cannot be empty", nameof(icons)); + } + + _name = name; + Name = $"{_name} {DateTimeOffset.UtcNow:hh:mm:ss}"; + Icon = new IconInfo(_icons[_currentIndex]); + + // Start timer to change icon and name every 5 seconds + _timer = new Timer(OnTimerElapsed, null, interval, interval); + } + + private void OnTimerElapsed(object state) + { + var nextIndex = (_currentIndex + 1) % _icons.Length; + if (nextIndex == _currentIndex && _icons.Length > 1) + { + nextIndex = (_currentIndex + 1) % _icons.Length; + } + + _currentIndex = nextIndex; + + Name = $"{_name} {DateTimeOffset.UtcNow:hh:mm:ss}"; + Icon = new IconInfo(_icons[_currentIndex]); + } + + public void Dispose() + { + _timer?.Dispose(); + } + } + + internal sealed partial class ListItemChangingCommandInTime : ListItem + { + private readonly EverChangingCommand[] _commands = + [ + new("Water", TimeSpan.FromSeconds(2), "🐬", "🐳", "🐟", "🦈"), + new("Faces", TimeSpan.FromSeconds(2), "😁", "🥺", "😍"), + new("Hearts", TimeSpan.FromSeconds(2), "♥️", "💚", "💜", "🧡", "💛", "💙"), + ]; + + private int _state; + + public ListItemChangingCommandInTime() + { + Subtitle = "I change my command every 10 seconds, and the command changes it's icon every 2 seconds"; + var timer = new Timer(OnTimerElapsed, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); + this.Command = _commands[0]; + } + + private void OnTimerElapsed(object state) + { + _state = (_state + 1) % _commands.Length; + this.Command = _commands[_state]; + } + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPageWithDetails.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPageWithDetails.cs similarity index 76% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPageWithDetails.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPageWithDetails.cs index 9495a7f6bf..738a82c762 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPageWithDetails.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPageWithDetails.cs @@ -22,14 +22,34 @@ internal sealed partial class SampleListPageWithDetails : ListPage return [ new ListItem(new NoOpCommand()) { - Title = "This page demonstrates Details on ListItems", + Title = "Details on ListItems (Small)", Details = new Details() { - Title = "List Item 1", + Title = "This item has default details size", Body = "Each of these items can have a `Body` formatted with **Markdown**", }, }, new ListItem(new NoOpCommand()) + { + Title = "Details on ListItems (Medium)", + Details = new Details() + { + Title = "This item has medium details size", + Body = "Each of these items can have a `Body` formatted with **Markdown**", + Size = ContentSize.Medium, + }, + }, + new ListItem(new NoOpCommand()) + { + Title = "Details on ListItems (Large)", + Details = new Details() + { + Title = "This item has large details size", + Body = "Each of these items can have a `Body` formatted with **Markdown**", + Size = ContentSize.Large, + }, + }, + new ListItem(new NoOpCommand()) { Title = "This one has a subtitle too", Subtitle = "Example Subtitle", @@ -62,18 +82,20 @@ internal sealed partial class SampleListPageWithDetails : ListPage Details = new Details() { Title = "Hero Image Example", - HeroImage = new IconInfo("https://m.media-amazon.com/images/M/MV5BNDBkMzVmNGQtYTM2OC00OWRjLTk5OWMtNzNkMDI4NjFjNTZmXkEyXkFqcGdeQXZ3ZXNsZXk@._V1_QL75_UX500_CR0,0,500,281_.jpg"), + HeroImage = new IconInfo("https://m.media-amazon.com/images/M/MV5BNDBkMzVmNGQtYTM2OC00OWRjLTk5OWMtNzNkMDI4NjFjNTZmXkEyXkFqcGdeQXZ3ZXNsZXk@._V1_QL75_UX500_CR0,0,500,281_.jpg"), /* #no-spell-check-line */ Body = "It is literally an image of a hero", }, }, new ListItem(new NoOpCommand()) { Title = "This one has metadata", + Subtitle = "And Large Details panel", Tags = [], Details = new Details() { Title = "Metadata Example", Body = "Each of the sections below is some sample metadata", + Size = ContentSize.Large, Metadata = [ new DetailsElement() { @@ -129,6 +151,25 @@ internal sealed partial class SampleListPageWithDetails : ListPage ], }, }, + new DetailsElement() + { + Key = "Commands", + Data = new DetailsCommands() + { + Commands = [ + new ToastCommand("Hey! You clicked it!", MessageState.Success) + { + Name = "Do something amazing", + Icon = new("\uE945"), + }, + new ToastCommand("I warned you!", MessageState.Error) + { + Name = "Don't click me", + Icon = new("\uEA39"), + }, + ], + }, + }, ], }, } diff --git a/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleMarkdownDetails.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownDetails.cs similarity index 100% rename from src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleMarkdownDetails.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownDetails.cs diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs new file mode 100644 index 0000000000..a783b6cb91 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Threading.Tasks; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.Storage; + +namespace SamplePagesExtension.Pages; + +internal sealed partial class SampleMarkdownImagesPage : ContentPage +{ + private static readonly Task InitializationTask; + + private static string? _sampleMarkdownText; + + static SampleMarkdownImagesPage() + { + InitializationTask = Task.Run(static async () => + { + try + { + // prepare data files + // 1) prepare something in our AppData Temp Folder + var spaceFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///Assets/Images/Space.png")); + var tempFile = await spaceFile!.CopyAsync(ApplicationData.Current!.TemporaryFolder!, "Space.png", NameCollisionOption.ReplaceExisting); + + // 2) and also get an SVG directly from the package + var svgChipmunkFile = await StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///Assets/Images/FluentEmojiChipmunk.svg")); + + _sampleMarkdownText = GetContentMarkup( + new Uri(tempFile.Path!, UriKind.Absolute), + new Uri(svgChipmunkFile.Path!, UriKind.Absolute)); + } + catch (Exception ex) + { + ExtensionHost.LogMessage(ex.ToString()); + } + }); + return; + + static string GetContentMarkup(Uri path1, Uri path2) + { + return + $$""" + # Images in Markdown Content + + ## Available sources: + + - `![Alt Text](https://url)` + + - `![Alt Text](file://url)` + - ℹ️ Only absolute paths are supported. + + - `![Alt Text](data:<mime>;[base64,]<data>)` + - ⚠️ Only for small amount of data. Parsing large data blocks the UI. + + - `![Alt Text](ms-appx:///url)` + - ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions. + + - `![Alt Text](ms-appdata:///url)` + - ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions. + + ## Examples: + + ### Web URL + ```xml + ![painting](https://raw.githubusercontent.com/microsoft/PowerToys/refs/heads/main/doc/images/overview/Original/AdvancedPaste.png) + ``` + ![painting](https://raw.githubusercontent.com/microsoft/PowerToys/refs/heads/main/doc/images/overview/Original/AdvancedPaste.png) + + ```xml + ![painting](https://raw.githubusercontent.com/microsoft/PowerToys/refs/heads/main/doc/images/overview/Original/AdvancedPaste.png?--x-cmdpal-fit=fit) + ``` + ![painting](https://raw.githubusercontent.com/microsoft/PowerToys/refs/heads/main/doc/images/overview/Original/AdvancedPaste.png?--x-cmdpal-fit=fit) + + ### File URL (PNG): + ```xml + ![green rectangle]({{path1}}) + ``` + + ![green rectangle]({{path1}}) + + ### File URL (SVG): + ```xml + ![chipmunk]({{path2}}) + ``` + + ![chipmunk]({{path2}}) + + ```xml + ![chipmunk]({{path2}}?--x-cmdpal-maxwidth=400&--x-cmdpal-maxheight=400&--x-cmdpal-fit=fit) + ``` + + ![chipmunk]({{path2}}?--x-cmdpal-maxwidth=400&--x-cmdpal-maxheight=400&--x-cmdpal-fit=fit) + + ```xml + ![chipmunk]({{path2}}?--x-cmdpal-width=64) + ``` + ![chipmunk]({{path2}}?--x-cmdpal-width=64) + + ## Data URL (PNG): + ⚠️ Passing large data into Markdown Content is unadvisable, parsing large data URLs can be slow and cause hangs. + ```xml + ![QR](data:image/png;base64,iVBORw0KGgoA...RU5ErkJggg==) + ``` + + ![QR](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIQAAACECAYAAABRRIOnAAAGM0lEQVR4AeyYi04eSwyD/6/v/84cqfQIYsqEaWZ2s7tG4mJy8ziWQvvrzR9W4JMCv17+sAKfFLAhPonhH18vG8IuCArYEEEOAxvCHggK2BBBDgMbookHutCwIbpsogkPG6LJIrrQsCG6bKIJDxuiySK60LAhumyiCQ8boskiutCwIbpsogmPxxuiyR7a0CgbAnjBcZ+ZcrCWSzYvi8NaPjDul/HJ4mVDZAMcv5YCNsS19rWdrQ2xXeJrDVhuiLe3t9fKz1k5dfZsvebD2put/KpY+VbxckNUCbn+XAVsiHP1bzf9NEO0U8KEfiuw3RAwvsEQ479ZTXyBWr2Oym665kNtPsR6GGOdvxpvN8Rqwu63VwEbYq++l+tuQ1xuZXsJ384QEG+w/k2gcmocYj1ErPV3w7czxN0WdPR7bIijFe82T/jYECLI0+HtDJH9TZAtXOsznPW7Wvx2hrjaArrxtSG6beRkPjbEyQvoNn67IbIbrPGzBYLx/ztAjFf5a32Gd+uz3RC7H+D+axWwIdbq+fNuTTNtiKaLOYvWckNAvLFQw6uF0RsNkV81nvGFOA9qOJs3G19uiFkCzu+lgA3Rax+ns7EhTl9BLwJlQ+jN3Y2r8kG82dV+Wf1uPbR/xieLlw2RDXD8Wgo8zxDX2s/hbG2IwyXvPbBsCBjfZIhxqGGVU28oxP4aV6z9INZn8dl+EPvDHFY+q3HZEKsJud+5CtgQ5+rfbroN0W4l5xIqGyK7ofq8LF/jirUfxBuc5Wt9hrWfYojztZ/mZ3HNVwzjedp/FpcNMTvQ+b0VOM4QvXUwuz8K2BB/hPC3dwXKhoB40yDi7AZmcYj93ml/fM3qPzL/7SeI8yHif+v6fRXM9Ye5/O8nv0fKhnhv4693UcCGuMsmF73Dhlgk5F3aHG4IvfkqpMYVw9qbmc2fjUPkBxFrP4hxfa/mZ3HNn8WHG2KWoPNXKzDuZ0OM9Xlc1IZ43MrHDy4bIrtpEG8kjLHShZiv82Ac134Zhtgvy1c+q/Mh8oGIs3mz8bIhZgc6v7cCNkTv/RzOzoY4XPLeA8uGgHjT9KYqVjk0DuN+Wb3GM6zzNT+Lw5iv1sM4H8bxWX6an+GyIbIBjv+vwDW+2xDX2NNhLG2Iw6S+xqDDDVG9qVVZId5oiDjjBzE/4wPjfBjHq/2zeo0fbgglYNxLARui1z5OZ2NDnL6CXgS2GwLijYSIs5utcZUPYj+IWPMVZ/2zfK2HOF/j2k9xlp/Ftd8s3m6IWULr891xRgEbYkatB+TaEA9Y8swTtxsiu3kQb66ShxiHiDVf58E4X+sVZ/1g3B/GcZ2nGMb1yk/rZ/F2Q8wScv65CtgQ5+rfbroN0W4l5xIqG2L2hml+Fat8EG9u1h9iPkSs/bVfFtd8xVoPcX6Wr/VVXDbE9wQcuaICNsQVt7aRsw2xUdwrti4bAuLNg71YRdYbqxgin6xe4zCu13zFMFef8YfYDyLW+bO4bIjZgc7vrYAN0Xs/h7OzIQ6XvPfA5YbQG1jFmXxQu6Ewrlf+GR+Nz9ZD5DNbr/Nfr9fUr5YbYmq6k9spYEO0W8m5hGyIc/VvN327ISDeRBjj1QrN3mDNhzm+MM6HGNf3ZvM1fzXebojVhN1vrwI2xF59L9fdhrjcyvYSvp0hIN5oiFjl1JudxbP8T/V//TGrhzHfvzZd+MvbGWKhNo9sZUM8cu3fP9qG+F6bR0ZuZwi90Yoh3miIeLULdL72z+LVfK3P8O0MkT3Y8bECNsRYn8dFbYjHrXz84O2G0BuZ4THdr1HtB7zg4++CrxXxN1ofo6/QCz76wvvPL/nQfvCeBz/7Lu1e2k/jq/F2Q6wm7H57FbAh9up7ue42xOVWtpfwckPAz24l/Cwvez7EPln+bFxveIa1v+ZrPMMQ3wcRZ/Wz8eWGmCXg/F4K2BC99nE6Gxvi9BX0IlA2xKcb+eXfzDtiKl82I8vX+G6c8a3Gq/zLhqgScH0vBWyIXvs4nY0NcfoKehGwIXrt43Q2NsTpK+hFwIbotY/T2dgQp69gPYFKRxuiot4Na22IGy618iQboqLeDWttiBsutfIkG6Ki3g1rbYgbLrXyJBuiot4Na22IhUu9Q6v/AAAA//9XU3+9AAAABklEQVQDAEWCv4B2/D3YAAAAAElFTkSuQmCC) + + ### Data URL (SVG): + ⚠️ Passing large data into Markdown Content is unadvisable, parsing large data URLs can be slow and cause hangs. + ```xml + ![emoji](data:image/svg+xml;base64,PHN2ZyB4bWxucz0ia...NiAweiIvPjwvZz48L3N2Zz4=) + ``` + + ![emoji](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2IiB2aWV3Qm94PSIwIDAgMzIgMzIiPjxnIGZpbGw9Im5vbmUiPjxwYXRoIGZpbGw9IiNGRkIwMkUiIGQ9Ik0xNS45OTkgMjkuOTk4YzkuMzM0IDAgMTMuOTk5LTYuMjY4IDEzLjk5OS0xNGMwLTcuNzMtNC42NjUtMTMuOTk4LTE0LTEzLjk5OEM2LjY2NSAyIDIgOC4yNjggMiAxNS45OTlzNC42NjQgMTMuOTk5IDEzLjk5OSAxMy45OTkiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAuNSAxOWE0LjUgNC41IDAgMSAwIDAtOWE0LjUgNC41IDAgMCAwIDAgOW0xMSAwYTQuNSA0LjUgMCAxIDAgMC05YTQuNSA0LjUgMCAwIDAgMCA5Ii8+PHBhdGggZmlsbD0iIzQwMkEzMiIgZD0iTTguMDcgNy45ODhjLS41OTQuNTYyLS45NTIgMS4yNC0xLjA5NiAxLjY3YS41LjUgMCAxIDEtLjk0OC0uMzE2Yy4xOS0uNTcuNjMtMS4zOTIgMS4zNTUtMi4wOEM4LjExMyA2LjU2NyA5LjE0OCA2IDEwLjUgNmEuNS41IDAgMCAxIDAgMWMtMS4wNDggMC0xLjg0Ni40MzMtMi40My45ODhNMTIgMTdhMiAyIDAgMSAwIDAtNGEyIDIgMCAwIDAgMCA0bTggMGEyIDIgMCAxIDAgMC00YTIgMiAwIDAgMCAwIDRtNS4wMjYtNy4zNDJjLS4xNDQtLjQzLS41MDMtMS4xMDgtMS4wOTUtMS42N0MyMy4zNDYgNy40MzMgMjIuNTQ4IDcgMjEuNSA3YS41LjUgMCAxIDEgMC0xYzEuMzUyIDAgMi4zODcuNTY3IDMuMTIgMS4yNjJjLjcyMy42ODggMS4xNjQgMS41MSAxLjM1NCAyLjA4YS41LjUgMCAxIDEtLjk0OC4zMTYiLz48cGF0aCBmaWxsPSIjQkIxRDgwIiBkPSJNMTMuMTcgMjJjLS4xMS4zMTMtLjE3LjY1LS4xNyAxdjJhMyAzIDAgMSAwIDYgMHYtMmMwLS4zNS0uMDYtLjY4Ny0uMTctMUwxNiAyMXoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTMuMTcgMjJhMy4wMDEgMy4wMDEgMCAwIDEgNS42NiAweiIvPjwvZz48L3N2Zz4=) + + ### Data URL (SVG 2): + ⚠️ Passing large data into Markdown Content is unadvisable, parsing large data URLs can be slow and cause hangs. + ```xml + <img alt="emoji 2" + width="48" + height="48" + src="data:image/svg+xml;base64,PHN2ZyB....iIvPjwvZz48L3N2Zz4=" /> + ``` + + <img alt="emoji 2" + width="48" + height="48" + src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNTYiIGhlaWdodD0iMjU2IiB2aWV3Qm94PSIwIDAgMzIgMzIiPjxnIGZpbGw9Im5vbmUiPjxwYXRoIGZpbGw9IiNGRkIwMkUiIGQ9Ik0xNS45OTkgMjkuOTk4YzkuMzM0IDAgMTMuOTk5LTYuMjY4IDEzLjk5OS0xNGMwLTcuNzMtNC42NjUtMTMuOTk4LTE0LTEzLjk5OEM2LjY2NSAyIDIgOC4yNjggMiAxNS45OTlzNC42NjQgMTMuOTk5IDEzLjk5OSAxMy45OTkiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAuNSAxOWE0LjUgNC41IDAgMSAwIDAtOWE0LjUgNC41IDAgMCAwIDAgOW0xMSAwYTQuNSA0LjUgMCAxIDAgMC05YTQuNSA0LjUgMCAwIDAgMCA5Ii8+PHBhdGggZmlsbD0iIzQwMkEzMiIgZD0iTTguMDcgNy45ODhjLS41OTQuNTYyLS45NTIgMS4yNC0xLjA5NiAxLjY3YS41LjUgMCAxIDEtLjk0OC0uMzE2Yy4xOS0uNTcuNjMtMS4zOTIgMS4zNTUtMi4wOEM4LjExMyA2LjU2NyA5LjE0OCA2IDEwLjUgNmEuNS41IDAgMCAxIDAgMWMtMS4wNDggMC0xLjg0Ni40MzMtMi40My45ODhNMTIgMTdhMiAyIDAgMSAwIDAtNGEyIDIgMCAwIDAgMCA0bTggMGEyIDIgMCAxIDAgMC00YTIgMiAwIDAgMCAwIDRtNS4wMjYtNy4zNDJjLS4xNDQtLjQzLS41MDMtMS4xMDgtMS4wOTUtMS42N0MyMy4zNDYgNy40MzMgMjIuNTQ4IDcgMjEuNSA3YS41LjUgMCAxIDEgMC0xYzEuMzUyIDAgMi4zODcuNTY3IDMuMTIgMS4yNjJjLjcyMy42ODggMS4xNjQgMS41MSAxLjM1NCAyLjA4YS41LjUgMCAxIDEtLjk0OC4zMTYiLz48cGF0aCBmaWxsPSIjQkIxRDgwIiBkPSJNMTMuMTcgMjJjLS4xMS4zMTMtLjE3LjY1LS4xNyAxdjJhMyAzIDAgMSAwIDYgMHYtMmMwLS4zNS0uMDYtLjY4Ny0uMTctMUwxNiAyMXoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTMuMTcgMjJhMy4wMDEgMy4wMDEgMCAwIDEgNS42NiAweiIvPjwvZz48L3N2Zz4=" /> + + ### MS-APPX URL: + ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions. + ```xml + ![Swirl](ms-appx:///Assets/Square44x44Logo.png) + ``` + + ![Swirl](ms-appx:///Assets/Square44x44Logo.png) + + ### MS-APPDATA URL: + ⚠️ This loads from CmdPal's AppData folder, not Extension's, so it's not useful for extensions. + ```xml + ![Space](ms-appdata:///temp/Space.png) + ``` + + --- + + # Scaling + + For URIs that support query parameters (file, http, ms-appx, ms-appdata), you can provide hints to control scaling + + - `--x-cmdpal-fit` + - `none`: no automatic scaling, provides image as is (default) + - `fit`: scale to fit the available space + - `--x-cmdpal-upscale` + - `true`: allow upscaling + - `false`: downscale only (default) + - `--x-cmdpal-width`: desired width in pixels + - `--x-cmdpal-height`: desired height in pixels + - `--x-cmdpal-maxwidth`: max width in pixels + - `--x-cmdpal-maxheight`: max height in pixels + + Currently no support for data: scheme as it doesn't support query parameters at all. + + ## Examples + + ### No scaling + ```xml + ![green rectangle]({{path1}}) + ``` + + ![green rectangle]({{path1}}) + + ### Scale to fit (scaling down only by default) + ```xml + ![green rectangle]({{path1}}?--x-cmdpal-fit=fit) + ``` + + ![green rectangle]({{path1}}?--x-cmdpal-fit=fit) + + ### Scale to fit (in both direction) + ```xml + ![green rectangle]({{path1}}?--x-cmdpal-fit=fit&--x-cmdpal-upscale=true) + ``` + + ![green rectangle]({{path1}}?--x-cmdpal-fit=fit&--x-cmdpal-upscale=true) + + ### Scale to exact width + ```xml + ![green rectangle]({{path1}}?--x-cmdpal-width=320) + ``` + + ![green rectangle]({{path1}}?--x-cmdpal-width=320) + """; + } + } + + private string _currentContent; + + public SampleMarkdownImagesPage() + { + Name = "Sample Markdown with Images Page"; + _currentContent = "Initializing..."; + IsLoading = true; + + _ = InitializationTask!.ContinueWith(_ => + { + _currentContent = _sampleMarkdownText!; + RaiseItemsChanged(); + IsLoading = false; + }); + } + + public override IContent[] GetContent() => [new MarkdownContent(_currentContent ?? string.Empty)]; +} diff --git a/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleMarkdownManyBodies.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownManyBodies.cs similarity index 100% rename from src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleMarkdownManyBodies.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownManyBodies.cs diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleMarkdownPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownPage.cs similarity index 72% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleMarkdownPage.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownPage.cs index 3bfa786fb3..3ae7d09c85 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleMarkdownPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownPage.cs @@ -49,18 +49,18 @@ _You **can** combine them_ ### Lists -**Inordered:** +**Unordered:** * Milk * Bread - * Wholegrain + * Whole grain * Butter Result: * Milk * Bread - * Wholegrain + * Whole grain * Butter **Ordered:** @@ -81,7 +81,7 @@ Result: Result: -![painting](https://i.imgur.com/93XJSNh.png) +![painting](https://raw.githubusercontent.com/microsoft/PowerToys/refs/heads/main/doc/images/overview/Original/AdvancedPaste.png) ### Links @@ -147,8 +147,47 @@ Result: \*literally\* +## Tables + +### Pipe table + +[Markdig - Pipe Table specs](https://github.com/xoofx/markdig/blob/master/src/Markdig.Tests/Specs/PipeTableSpecs.md) + +| Right | Left | Default | Center | +|------:|:-----|---------|:------:| +| 12 | 12 | 12 | 12 | +| 123 | 123 | 123 | 123 | +| 1 | 1 | 1 | 1 | + +### HTML table + +<table> +<thead> +<tr> +<th style=""text-align: left;"">a</th> +<th style=""text-align: center;"">b</th> +<th style=""text-align: right;"">c</th> +</tr> +</thead> +<tbody> +<tr> +<td style=""text-align: left;"">0</td> +<td style=""text-align: center;"">1</td> +<td style=""text-align: right;"">2</td> +</tr> +<tr> +<td style=""text-align: left;"">3</td> +<td style=""text-align: center;"">4</td> +<td style=""text-align: right;"">5</td> +</tr> +</tbody> +</table> + + ## Advanced Markdown +[Markdig - emphasis extensions](https://github.com/xoofx/markdig/blob/master/src/Markdig.Tests/Specs/EmphasisExtraSpecs.md) + Note: Some syntax which is not standard to native Markdown. They're extensions of the language. ### Strike-throughs diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleSettingsPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleSettingsPage.cs similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleSettingsPage.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleSettingsPage.cs diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SectionsPages/SampleListPageWithSections.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SectionsPages/SampleListPageWithSections.cs new file mode 100644 index 0000000000..0fd3f039da --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SectionsPages/SampleListPageWithSections.cs @@ -0,0 +1,114 @@ +// 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension.Pages.SectionsPages; + +internal sealed partial class SampleListPageWithSections : ListPage +{ + public SampleListPageWithSections() + { + Icon = new IconInfo("\uE7C5"); + Name = "Sample Gallery List Page"; + } + + public SampleListPageWithSections(IGridProperties gridProperties) + { + Icon = new IconInfo("\uE7C5"); + Name = "Sample Gallery List Page"; + GridProperties = gridProperties; + } + + public override IListItem[] GetItems() + { + var sectionList = new Section("This is a section list", [ + new ListItem(new NoOpCommand()) + { + Title = "Sample Title", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/RedRectangle.png"), + }, + ]); + var anotherSectionList = new Section("This is another section list", [ + new ListItem(new NoOpCommand()) + { + Title = "Another Title", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Space.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "More Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Stop With The Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + ]); + + var yesTheresAnother = new Section("There's another", [ + new ListItem(new NoOpCommand()) + { + Title = "Sample Title", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/RedRectangle.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Another Title", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "More Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Stop With The Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/RedRectangle.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Another Title", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Space.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "More Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Stop With The Titles", + Subtitle = "I don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + ]); + + return [ + ..sectionList, + ..anotherSectionList, + new Separator(), + new ListItem(new NoOpCommand()) + { + Title = "Separators also work", + Subtitle = "But I still don't do anything", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + ..yesTheresAnother + ]; + } +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SectionsPages/SectionsIndexPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SectionsPages/SectionsIndexPage.cs new file mode 100644 index 0000000000..c1b71e00bd --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SectionsPages/SectionsIndexPage.cs @@ -0,0 +1,44 @@ +// 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using SamplePagesExtension.Pages.SectionsPages; + +namespace SamplePagesExtension.Pages; + +internal sealed partial class SectionsIndexPage : ListPage +{ + public SectionsIndexPage() + { + Name = "Sections Index Page"; + Icon = new IconInfo("\uF168"); + } + + public override IListItem[] GetItems() + { + return [ + new ListItem(new SampleListPageWithSections()) + { + Title = "A list page with sections", + }, + new ListItem(new SampleListPageWithSections(new SmallGridLayout())) + { + Title = "A small grid page with sections", + }, + new ListItem(new SampleListPageWithSections(new MediumGridLayout())) + { + Title = "A medium grid page with sections", + }, + new ListItem(new SampleListPageWithSections(new GalleryGridLayout())) + { + Title = "A Gallery grid page with sections", + }, + new ListItem(new SampleListPageWithSections(new GalleryGridLayout() { ShowTitle = false, ShowSubtitle = false })) + { + Title = "A Gallery grid page without labels with sections", + }, + ]; + } +} diff --git a/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SendMessageCommand.cs similarity index 100% rename from src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/Pages/SendMessageCommand.cs diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SlowListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SlowListPage.cs new file mode 100644 index 0000000000..ac3e175849 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SlowListPage.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension; + +internal sealed partial class SlowListPage : ListPage +{ + public SlowListPage() + { + Icon = new IconInfo("\uEA79"); + Name = "Slow List Page"; + Title = "This page simulates a slow load"; + } + + public override IListItem[] GetItems() + { + Thread.Sleep(5000); + + return [ + new ListItem(new NoOpCommand()) + { + Title = "This is a basic item in the list", + Subtitle = "I don't do anything though", + }, + new ListItem(new NoOpCommand()) + { + Title = "This is another item in the list", + Subtitle = "Still nothing", + }, + ]; + } +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/ToastCommand.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/ToastCommand.cs new file mode 100644 index 0000000000..dfbeb5225a --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/ToastCommand.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension; + +internal sealed partial class ToastCommand(string message, MessageState state = MessageState.Info) : InvokableCommand +{ + public override ICommandResult Invoke() + { + var t = new ToastStatusMessage(new StatusMessage() + { + Message = message, + State = state, + }); + t.Show(); + + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Program.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Program.cs similarity index 71% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Program.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/Program.cs index 24efd1a1c6..781f3b2897 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Program.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Program.cs @@ -1,10 +1,12 @@ -// 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; using System.Threading; using Microsoft.CommandPalette.Extensions; +using Shmuelie.WinRTServer; +using Shmuelie.WinRTServer.CsWinRT; namespace SamplePagesExtension; @@ -15,18 +17,22 @@ public class Program { if (args.Length > 0 && args[0] == "-RegisterProcessAsComServer") { - using ExtensionServer server = new(); - var extensionDisposedEvent = new ManualResetEvent(false); - var extensionInstance = new SampleExtension(extensionDisposedEvent); + global::Shmuelie.WinRTServer.ComServer server = new(); + + ManualResetEvent extensionDisposedEvent = new(false); // We are instantiating an extension instance once above, and returning it every time the callback in RegisterExtension below is called. // This makes sure that only one instance of SampleExtension is alive, which is returned every time the host asks for the IExtension object. // If you want to instantiate a new instance each time the host asks, create the new instance inside the delegate. - server.RegisterExtension(() => extensionInstance); + SampleExtension extensionInstance = new(extensionDisposedEvent); + server.RegisterClass<SampleExtension, IExtension>(() => extensionInstance); + server.Start(); // This will make the main thread wait until the event is signalled by the extension class. // Since we have single instance of the extension object, we exit as soon as it is disposed. extensionDisposedEvent.WaitOne(); + server.Stop(); + server.UnsafeDispose(); } else { diff --git a/src/modules/cmdpal/exts/SamplePagesExtension/Properties/PublishProfiles/win-arm64.pubxml b/src/modules/cmdpal/ext/SamplePagesExtension/Properties/PublishProfiles/win-arm64.pubxml similarity index 100% rename from src/modules/cmdpal/exts/SamplePagesExtension/Properties/PublishProfiles/win-arm64.pubxml rename to src/modules/cmdpal/ext/SamplePagesExtension/Properties/PublishProfiles/win-arm64.pubxml diff --git a/src/modules/cmdpal/exts/SamplePagesExtension/Properties/PublishProfiles/win-x64.pubxml b/src/modules/cmdpal/ext/SamplePagesExtension/Properties/PublishProfiles/win-x64.pubxml similarity index 100% rename from src/modules/cmdpal/exts/SamplePagesExtension/Properties/PublishProfiles/win-x64.pubxml rename to src/modules/cmdpal/ext/SamplePagesExtension/Properties/PublishProfiles/win-x64.pubxml diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Properties/launchSettings.json b/src/modules/cmdpal/ext/SamplePagesExtension/Properties/launchSettings.json similarity index 87% rename from src/modules/cmdpal/Exts/SamplePagesExtension/Properties/launchSettings.json rename to src/modules/cmdpal/ext/SamplePagesExtension/Properties/launchSettings.json index b62e4e316c..bb3120a64a 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Properties/launchSettings.json +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Properties/launchSettings.json @@ -3,7 +3,7 @@ "SamplePagesExtension (Package)": { "commandName": "MsixPackage", "doNotLaunchApp": true, - "nativeDebugging": true + "nativeDebugging": false }, "SamplePagesExtension (Unpackaged)": { "commandName": "Project" diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SampleDataTransferPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SampleDataTransferPage.cs new file mode 100644 index 0000000000..842f128842 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SampleDataTransferPage.cs @@ -0,0 +1,254 @@ +// 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.Globalization; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.ApplicationModel.DataTransfer; +using Windows.Storage.Streams; + +namespace SamplePagesExtension; + +internal sealed partial class SampleDataTransferPage : ListPage +{ + private readonly IListItem[] _items; + + public SampleDataTransferPage() + { + var dataPackageWithText = CreateDataPackageWithText(); + var dataPackageWithDelayedText = CreateDataPackageWithDelayedText(); + var dataPackageWithImage = CreateDataPackageWithImage(); + + _items = + [ + new ListItem(new NoOpCommand()) + { + Title = "Draggable item with a plain text", + Subtitle = "A sample page demonstrating how to drag and drop data", + DataPackage = dataPackageWithText, + }, + new ListItem(new NoOpCommand()) + { + Title = "Draggable item with a lazily rendered plain text", + Subtitle = "A sample page demonstrating how to drag and drop data with delayed rendering", + DataPackage = dataPackageWithDelayedText, + }, + new ListItem(new NoOpCommand()) + { + Title = "Draggable item with an image", + Subtitle = "This item has an image - package contains both file and a bitmap", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + DataPackage = dataPackageWithImage, + }, + new ListItem(new SampleDataTransferOnGridPage()) + { + Title = "Drag & drop grid", + Subtitle = "A sample page demonstrating a grid list of items", + Icon = new IconInfo("\uF0E2"), + } + ]; + } + + private static DataPackage CreateDataPackageWithText() + { + var dataPackageWithText = new DataPackage + { + Properties = + { + Title = "Item with data package with text", + Description = "This item has associated text with it", + }, + RequestedOperation = DataPackageOperation.Copy, + }; + dataPackageWithText.SetText("Text data in the Data Package"); + return dataPackageWithText; + } + + private static DataPackage CreateDataPackageWithDelayedText() + { + var dataPackageWithDelayedText = new DataPackage + { + Properties = + { + Title = "Item with delayed render data in the data package", + Description = "This items has an item associated with it that is evaluated when requested for the first time", + }, + RequestedOperation = DataPackageOperation.Copy, + }; + dataPackageWithDelayedText.SetDataProvider(StandardDataFormats.Text, request => + { + var d = request.GetDeferral(); + try + { + request.SetData(DateTime.Now.ToString("G", CultureInfo.CurrentCulture)); + } + finally + { + d.Complete(); + } + }); + return dataPackageWithDelayedText; + } + + private static DataPackage CreateDataPackageWithImage() + { + var dataPackageWithImage = new DataPackage + { + Properties = + { + Title = "Item with delayed render image in the data package", + Description = "This items has an image associated with it that is evaluated when requested for the first time", + }, + RequestedOperation = DataPackageOperation.Copy, + }; + dataPackageWithImage.SetDataProvider(StandardDataFormats.Bitmap, async void (request) => + { + var deferral = request.GetDeferral(); + try + { + var file = await Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///Assets/Images/Swirls.png")); + var stream = await file.OpenAsync(Windows.Storage.FileAccessMode.Read); + var streamRef = RandomAccessStreamReference.CreateFromStream(stream); + request.SetData(streamRef); + } + finally + { + deferral.Complete(); + } + }); + dataPackageWithImage.SetDataProvider(StandardDataFormats.StorageItems, async void (request) => + { + var deferral = request.GetDeferral(); + try + { + var file = await Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(new Uri("ms-appx:///Assets/Images/Swirls.png")); + var items = new[] { file }; + request.SetData(items); + } + finally + { + deferral.Complete(); + } + }); + return dataPackageWithImage; + } + + public override IListItem[] GetItems() => _items; +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Samples")] +internal sealed partial class SampleDataTransferOnGridPage : ListPage +{ + public SampleDataTransferOnGridPage() + { + GridProperties = new GalleryGridLayout + { + ShowTitle = true, + ShowSubtitle = true, + }; + } + + public override IListItem[] GetItems() + { + return [ + new ListItem(new NoOpCommand()) + { + Title = "Red Rectangle", + Subtitle = "Drag me", + Icon = IconHelpers.FromRelativePath("Assets/Images/RedRectangle.png"), + DataPackage = CreateDataPackageForImage("Assets/Images/RedRectangle.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Swirls", + Subtitle = "Drop me", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + DataPackage = CreateDataPackageForImage("Assets/Images/Swirls.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Windows Digital", + Subtitle = "Drag me", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + DataPackage = CreateDataPackageForImage("Assets/Images/Win-Digital.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Red Rectangle", + Subtitle = "Drop me", + Icon = IconHelpers.FromRelativePath("Assets/Images/RedRectangle.png"), + DataPackage = CreateDataPackageForImage("Assets/Images/RedRectangle.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Space", + Subtitle = "Drag me", + Icon = IconHelpers.FromRelativePath("Assets/Images/Space.png"), + DataPackage = CreateDataPackageForImage("Assets/Images/Space.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Swirls", + Subtitle = "Drop me", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + DataPackage = CreateDataPackageForImage("Assets/Images/Swirls.png"), + }, + new ListItem(new NoOpCommand()) + { + Title = "Windows Digital", + Subtitle = "Drag me", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + DataPackage = CreateDataPackageForImage("Assets/Images/Win-Digital.png"), + }, + ]; + } + + private static DataPackage CreateDataPackageForImage(string relativePath) + { + var dataPackageWithImage = new DataPackage + { + Properties = + { + Title = "Image", + Description = "This item has an image associated with it.", + }, + RequestedOperation = DataPackageOperation.Copy, + }; + + var imageUri = new Uri($"ms-appx:///{relativePath}"); + + dataPackageWithImage.SetDataProvider(StandardDataFormats.Bitmap, async (request) => + { + var deferral = request.GetDeferral(); + try + { + var file = await Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(imageUri); + var stream = await file.OpenAsync(Windows.Storage.FileAccessMode.Read); + var streamRef = RandomAccessStreamReference.CreateFromStream(stream); + request.SetData(streamRef); + } + finally + { + deferral.Complete(); + } + }); + + dataPackageWithImage.SetDataProvider(StandardDataFormats.StorageItems, async (request) => + { + var deferral = request.GetDeferral(); + try + { + var file = await Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(imageUri); + var items = new[] { file }; + request.SetData(items); + } + finally + { + deferral.Complete(); + } + }); + return dataPackageWithImage; + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SampleExtension.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SampleExtension.cs similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/SampleExtension.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/SampleExtension.cs diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplePagesCommandsProvider.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesCommandsProvider.cs similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/SamplePagesCommandsProvider.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesCommandsProvider.cs diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplePagesExtension.csproj b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj similarity index 72% rename from src/modules/cmdpal/Exts/SamplePagesExtension/SamplePagesExtension.csproj rename to src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj index c00df9b84e..59ae78737f 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplePagesExtension.csproj +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplePagesExtension.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\..\Common.Dotnet.AotCompatibility.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> <RootNamespace>SamplePagesExtension</RootNamespace> @@ -33,6 +33,14 @@ <ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" /> </ItemGroup> + <ItemGroup> + <PackageReference Include="Shmuelie.WinRTServer" /> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="Microsoft.Windows.CsWin32"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> + </ItemGroup> <!-- Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging Tools extension to be activated for this project even if the Windows App SDK Nuget @@ -55,10 +63,18 @@ <HasPackageAndPublishMenu>true</HasPackageAndPublishMenu> </PropertyGroup> - <PropertyGroup> + <!-- Only enable Native AOT for Release builds to avoid System.Private.CoreLib.dll version conflicts during development --> + <PropertyGroup Condition="'$(Configuration)' == 'Release'"> <PublishTrimmed>true</PublishTrimmed> <PublishSingleFile>true</PublishSingleFile> <PublishAot>true</PublishAot> </PropertyGroup> + <!-- For Debug builds, use standard JIT compilation --> + <PropertyGroup Condition="'$(Configuration)' == 'Debug'"> + <PublishTrimmed>false</PublishTrimmed> + <PublishSingleFile>false</PublishSingleFile> + <PublishAot>false</PublishAot> + </PropertyGroup> + </Project> diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SampleUpdatingItemsPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SampleUpdatingItemsPage.cs similarity index 98% rename from src/modules/cmdpal/Exts/SamplePagesExtension/SampleUpdatingItemsPage.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/SampleUpdatingItemsPage.cs index 4b94a22ead..63bf2a5a6f 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/SampleUpdatingItemsPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SampleUpdatingItemsPage.cs @@ -24,7 +24,7 @@ public partial class SampleUpdatingItemsPage : ListPage public override IListItem[] GetItems() { - if (timer == null) + if (timer is null) { timer = new Timer(500); timer.Elapsed += (object source, ElapsedEventArgs e) => diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs similarity index 64% rename from src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs index 3eeed6c2c2..3dd67086c8 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs @@ -4,6 +4,8 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using SamplePagesExtension.Pages; +using SamplePagesExtension.Pages.IssueSpecificPages; namespace SamplePagesExtension; @@ -22,6 +24,11 @@ public partial class SamplesListPage : ListPage Title = "List Page With Details", Subtitle = "A list of items, each with additional details to display", }, + new ListItem(new SectionsIndexPage()) + { + Title = "List Pages With Sections", + Subtitle = "A list of items, with sections header", + }, new ListItem(new SampleUpdatingItemsPage()) { Title = "List page with items that change", @@ -32,6 +39,26 @@ public partial class SamplesListPage : ListPage Title = "Dynamic List Page Command", Subtitle = "Changes the list of items in response to the typed query", }, + new ListItem(new SampleGridsListPage()) + { + Title = "Grid views and galleries", + Subtitle = "Displays items as a gallery", + }, + new ListItem(new OnLoadPage()) + { + Title = "Demo of OnLoad/OnUnload", + Subtitle = "Changes the list of items every time the page is opened / closed", + }, + new ListItem(new SampleIconPage()) + { + Title = "Sample Icon Page", + Subtitle = "A demo of using icons in various ways", + }, + new ListItem(new SlowListPage()) + { + Title = "Slow loading list page", + Subtitle = "A demo of a list page that takes a while to load", + }, // Content pages new ListItem(new SampleContentPage()) @@ -60,11 +87,17 @@ public partial class SamplesListPage : ListPage Title = "Markdown with multiple blocks", Subtitle = "A page with multiple blocks of rendered markdown", }, - new ListItem(new SampleMarkdownDetails()) + new ListItem(new SampleMarkdownDetails()) { Title = "Markdown with details", Subtitle = "A page with markdown and details", }, + new ListItem(new SampleMarkdownImagesPage()) + { + Title = "Markdown with images", + Subtitle = "A page with rendered markdown and images", + Icon = new IconInfo("\uee71"), + }, // Settings helpers new ListItem(new SampleSettingsPage()) @@ -73,12 +106,24 @@ public partial class SamplesListPage : ListPage Subtitle = "A demo of the settings helpers", }, + // Data package samples + new ListItem(new SampleDataTransferPage()) + { + Title = "Clipboard and Drag-and-Drop Demo", + Subtitle = "Demonstrates clipboard integration and drag-and-drop functionality", + }, + // Evil edge cases // Anything weird that might break the palette - put that in here. new ListItem(new EvilSamplesPage()) { Title = "Evil samples", Subtitle = "Samples designed to break the palette in many different evil ways", + }, + new ListItem(new AllIssueSamplesIndexPage()) + { + Title = "Issue-specific samples", + Subtitle = "Samples designed to reproduce specific issues", } ]; diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SelfImmolateCommand.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SelfImmolateCommand.cs similarity index 100% rename from src/modules/cmdpal/Exts/SamplePagesExtension/SelfImmolateCommand.cs rename to src/modules/cmdpal/ext/SamplePagesExtension/SelfImmolateCommand.cs diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/app.manifest b/src/modules/cmdpal/ext/SamplePagesExtension/app.manifest new file mode 100644 index 0000000000..13894ddf39 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/app.manifest @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> + <assemblyIdentity version="1.0.0.0" name="SamplePagesExtension.app"/> + + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10. + It is necessary to support features in unpackaged applications, for example the custom titlebar implementation. + For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest --> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> + </application> + </compatibility> + + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> + </windowsSettings> + </application> +</assembly> \ No newline at end of file diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/AnonymousCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/AnonymousCommand.cs index 7668a31fe3..4c6450706f 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/AnonymousCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/AnonymousCommand.cs @@ -12,13 +12,13 @@ public sealed partial class AnonymousCommand : InvokableCommand public AnonymousCommand(Action? action) { - Name = "Invoke"; + Name = Properties.Resources.AnonymousCommand_Invoke; _action = action; } public override ICommandResult Invoke() { - if (_action != null) + if (_action is not null) { _action(); } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/BaseObservable.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/BaseObservable.cs index 92303397dc..915abb89dc 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/BaseObservable.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/BaseObservable.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Runtime.CompilerServices; using Windows.Foundation; namespace Microsoft.CommandPalette.Extensions.Toolkit; @@ -14,7 +15,7 @@ public partial class BaseObservable : INotifyPropChanged { public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged; - protected void OnPropertyChanged(string propertyName) + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) { try { @@ -22,10 +23,37 @@ public partial class BaseObservable : INotifyPropChanged // this can crash as we try to invoke the handlers from that process. // However, just catching it seems to still raise the event on the // new host? - PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName)); + PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName!)); } catch { } } + + /// <summary> + /// Sets the backing field to the specified value and raises a property changed + /// notification if the value is different from the current one. + /// </summary> + /// <typeparam name="T">The type of the property.</typeparam> + /// <param name="field">A reference to the backing field for the property.</param> + /// <param name="value">The new value to assign to the property.</param> + /// <param name="propertyName"> + /// The name of the property. This is optional and is usually supplied + /// automatically by the <see cref="CallerMemberNameAttribute"/>. + /// </param> + /// <returns> + /// <see langword="true"/> if the field was updated and a property changed + /// notification was raised; otherwise, <see langword="false"/>. + /// </returns> + protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer<T>.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName!); + return true; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ChoiceSetSetting.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ChoiceSetSetting.cs index b38a1f305d..51beb0b5e7 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ChoiceSetSetting.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ChoiceSetSetting.cs @@ -65,7 +65,7 @@ public sealed class ChoiceSetSetting : Setting<string> public override void Update(JsonObject payload) { // If the key doesn't exist in the payload, don't do anything - if (payload[Key] != null) + if (payload[Key] is not null) { Value = payload[Key]?.GetValue<string>(); } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ClipboardHelper.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ClipboardHelper.cs index ca9e397f45..b2a8e65bc6 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ClipboardHelper.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ClipboardHelper.cs @@ -2,10 +2,7 @@ // 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; using System.Runtime.InteropServices; -using System.Threading; namespace Microsoft.CommandPalette.Extensions.Toolkit; @@ -293,7 +290,7 @@ public static partial class ClipboardHelper thread.Start(); thread.Join(); - if (exception != null) + if (exception is not null) { throw exception; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Command.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Command.cs index c776203acd..1cd96252c8 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Command.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Command.cs @@ -6,31 +6,11 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class Command : BaseObservable, ICommand { - public virtual string Name - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Name)); - } - } + public virtual string Name { get; set => SetProperty(ref field, value); } = string.Empty; -= string.Empty; + public virtual string Id { get; set; } = string.Empty; - public virtual string Id { get; protected set; } = string.Empty; - - public virtual IconInfo Icon - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Icon)); - } - } - -= new(); + public virtual IconInfo Icon { get; set => SetProperty(ref field, value); } = new(); IIconInfo ICommand.Icon => Icon; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandContextItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandContextItem.cs index 92dfc714bf..995ca4821d 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandContextItem.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandContextItem.cs @@ -6,9 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class CommandContextItem : CommandItem, ICommandContextItem { - public virtual bool IsCritical { get; set; } + public virtual bool IsCritical { get; set => SetProperty(ref field, value); } - public virtual KeyChord RequestedShortcut { get; set; } + public virtual KeyChord RequestedShortcut { get; set => SetProperty(ref field, value); } public CommandContextItem(ICommand command) : base(command) @@ -28,7 +28,7 @@ public partial class CommandContextItem : CommandItem, ICommandContextItem c.Name = name; } - if (result != null) + if (result is not null) { c.Result = result; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandItem.cs index f421622f94..fc2de06548 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandItem.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandItem.cs @@ -2,68 +2,117 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Windows.ApplicationModel.DataTransfer; +using Windows.Foundation.Collections; +using WinRT; + namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class CommandItem : BaseObservable, ICommandItem { - private ICommand? _command; + // NOTE TO MAINTAINERS: Do NOT implement `IExtendedAttributesProvider` here + // directly. Instead, implement it in derived classes like `ListItem` where + // appropriate. + // + // Putting it directly here will cause out-of-proc extensions to fail to + // load the context menu commands, for unknown CsWinRT reasons. + private readonly PropertySet _extendedAttributes = new(); - public virtual IIconInfo? Icon - { - get => field; - set - { - field = value; - OnPropertyChanged(nameof(Icon)); - } - } + private ICommand? _command; + private WeakEventListener<CommandItem, object, IPropChangedEventArgs>? _commandListener; + private string _title = string.Empty; + + private DataPackage? _dataPackage; + private DataPackageView? _dataPackageView; + + public virtual IIconInfo? Icon { get; set => SetProperty(ref field, value); } public virtual string Title { - get => !string.IsNullOrEmpty(field) ? field : _command?.Name ?? string.Empty; - + get => !string.IsNullOrEmpty(_title) ? _title : _command?.Name ?? string.Empty; set { - field = value; - OnPropertyChanged(nameof(Title)); + var oldTitle = Title; + _title = value; + if (Title != oldTitle) + { + OnPropertyChanged(); + } } } -= string.Empty; - - public virtual string Subtitle - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Subtitle)); - } - } - -= string.Empty; + public virtual string Subtitle { get; set => SetProperty(ref field, value); } = string.Empty; public virtual ICommand? Command { get => _command; set { + if (EqualityComparer<ICommand?>.Default.Equals(value, _command)) + { + return; + } + + var oldTitle = Title; + + if (_commandListener is not null) + { + _commandListener.Detach(); + _commandListener = null; + } + _command = value; - OnPropertyChanged(nameof(Command)); + + if (value is not null) + { + _commandListener = new(this, OnCommandPropertyChanged, listener => value.PropChanged -= listener.OnEvent); + value.PropChanged += _commandListener.OnEvent; + } + + OnPropertyChanged(); + if (string.IsNullOrEmpty(_title) && oldTitle != Title) + { + OnPropertyChanged(nameof(Title)); + } } } - public virtual IContextItem[] MoreCommands + private void OnCommandPropertyChanged(CommandItem instance, object source, IPropChangedEventArgs args) { - get; + // command's name affects Title only if Title wasn't explicitly set + if (args.PropertyName == nameof(ICommand.Name) && string.IsNullOrEmpty(_title)) + { + instance.OnPropertyChanged(nameof(Title)); + } + } + + public virtual IContextItem[] MoreCommands { get; set => SetProperty(ref field, value); } = []; + + public DataPackage? DataPackage + { + get => _dataPackage; set { - field = value; - OnPropertyChanged(nameof(MoreCommands)); + _dataPackage = value; + _dataPackageView = null; + _extendedAttributes[WellKnownExtensionAttributes.DataPackage] = value?.AsAgile().Get()?.GetView()!; + OnPropertyChanged(nameof(DataPackage)); + OnPropertyChanged(nameof(DataPackageView)); } } -= []; + public DataPackageView? DataPackageView + { + get => _dataPackageView; + set + { + _dataPackage = null; + _dataPackageView = value; + _extendedAttributes[WellKnownExtensionAttributes.DataPackage] = value?.AsAgile().Get()!; + OnPropertyChanged(nameof(DataPackage)); + OnPropertyChanged(nameof(DataPackageView)); + } + } public CommandItem() : this(new NoOpCommand()) @@ -73,13 +122,11 @@ public partial class CommandItem : BaseObservable, ICommandItem public CommandItem(ICommand command) { Command = command; - Title = command.Name; } public CommandItem(ICommandItem other) { Command = other.Command; - Title = other.Title; Subtitle = other.Subtitle; Icon = (IconInfo?)other.Icon; MoreCommands = other.MoreCommands; @@ -98,7 +145,7 @@ public partial class CommandItem : BaseObservable, ICommandItem c.Name = name; } - if (result != null) + if (result is not null) { c.Result = result; } @@ -108,4 +155,9 @@ public partial class CommandItem : BaseObservable, ICommandItem Title = title; Subtitle = subtitle; } + + public IDictionary<string, object> GetProperties() + { + return _extendedAttributes; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandProvider.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandProvider.cs index 1efc9475a7..ca64c87b23 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandProvider.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandProvider.cs @@ -6,7 +6,7 @@ using Windows.Foundation; namespace Microsoft.CommandPalette.Extensions.Toolkit; -public abstract partial class CommandProvider : ICommandProvider +public abstract partial class CommandProvider : ICommandProvider, ICommandProvider2 { public virtual string Id { get; protected set; } = string.Empty; @@ -31,7 +31,7 @@ public abstract partial class CommandProvider : ICommandProvider public virtual void InitializeWithHost(IExtensionHost host) => ExtensionHost.Initialize(host); #pragma warning disable CA1816 // Dispose methods should call SuppressFinalize - public void Dispose() + public virtual void Dispose() { } #pragma warning restore CA1816 // Dispose methods should call SuppressFinalize @@ -47,4 +47,26 @@ public abstract partial class CommandProvider : ICommandProvider { } } + + /// <summary> + /// This is used to manually populate the WinRT type cache in CmdPal with + /// any interfaces that might not follow a straight linear path of requires. + /// + /// You don't need to call this as an extension author. + /// </summary> + /// <returns>an array of objects that implement all the leaf interfaces we support</returns> + public object[] GetApiExtensionStubs() + { + return [new SupportCommandsWithProperties()]; + } + + /// <summary> + /// A stub class which implements IExtendedAttributesProvider. Just marshalling this + /// across the ABI will be enough for CmdPal to store IExtendedAttributesProvider in + /// its type cache. + /// </summary> + private sealed partial class SupportCommandsWithProperties : IExtendedAttributesProvider + { + public IDictionary<string, object>? GetProperties() => null; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandResult.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandResult.cs index 4be5f5092b..8be877806d 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandResult.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CommandResult.cs @@ -6,9 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class CommandResult : ICommandResult { - public ICommandResultArgs? Args { get; private set; } + public ICommandResultArgs? Args { get; private init; } - public CommandResultKind Kind { get; private set; } = CommandResultKind.Dismiss; + public CommandResultKind Kind { get; private init; } = CommandResultKind.Dismiss; public static CommandResult Dismiss() { diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/ConfirmableCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/ConfirmableCommand.cs new file mode 100644 index 0000000000..c2748b6f6a --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/ConfirmableCommand.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; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Common.Commands; + +public sealed partial class ConfirmableCommand : InvokableCommand +{ + private readonly IInvokableCommand? _command; + + public Func<bool>? IsConfirmationRequired { get; init; } + + public required string ConfirmationTitle { get; init; } + + public required string ConfirmationMessage { get; init; } + + public required IInvokableCommand Command + { + get => _command!; + init + { + if (_command is INotifyPropChanged oldNotifier) + { + oldNotifier.PropChanged -= InnerCommand_PropChanged; + } + + _command = value; + + if (_command is INotifyPropChanged notifier) + { + notifier.PropChanged += InnerCommand_PropChanged; + } + + OnPropertyChanged(nameof(Name)); + OnPropertyChanged(nameof(Id)); + OnPropertyChanged(nameof(Icon)); + } + } + + public override string Name + { + get => (_command as Command)?.Name ?? base.Name; + set + { + if (_command is Command cmd) + { + cmd.Name = value; + } + else + { + base.Name = value; + } + } + } + + public override string Id + { + get => (_command as Command)?.Id ?? base.Id; + set + { + var previous = Id; + if (_command is Command cmd) + { + cmd.Id = value; + } + else + { + base.Id = value; + } + + if (previous != Id) + { + OnPropertyChanged(nameof(Id)); + } + } + } + + public override IconInfo Icon + { + get => (_command as Command)?.Icon ?? base.Icon; + set + { + if (_command is Command cmd) + { + cmd.Icon = value; + } + else + { + base.Icon = value; + } + } + } + + public ConfirmableCommand() + { + // Allow init-only construction + } + + [SetsRequiredMembers] + public ConfirmableCommand(IInvokableCommand command, string confirmationTitle, string confirmationMessage, Func<bool>? isConfirmationRequired = null) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentException.ThrowIfNullOrWhiteSpace(confirmationMessage); + ArgumentNullException.ThrowIfNull(confirmationMessage); + + IsConfirmationRequired = isConfirmationRequired; + ConfirmationTitle = confirmationTitle; + ConfirmationMessage = confirmationMessage; + Command = command; + } + + private void InnerCommand_PropChanged(object sender, IPropChangedEventArgs args) + { + var property = args.PropertyName; + + if (string.IsNullOrEmpty(property) || property == nameof(Name)) + { + OnPropertyChanged(nameof(Name)); + } + + if (string.IsNullOrEmpty(property) || property == nameof(Id)) + { + OnPropertyChanged(nameof(Id)); + } + + if (string.IsNullOrEmpty(property) || property == nameof(Icon)) + { + OnPropertyChanged(nameof(Icon)); + } + } + + public override ICommandResult Invoke() + { + var showConfirmationDialog = IsConfirmationRequired?.Invoke() ?? true; + if (showConfirmationDialog) + { + return CommandResult.Confirm(new ConfirmationArgs + { + Title = ConfirmationTitle, + Description = ConfirmationMessage, + PrimaryCommand = Command, + IsPrimaryCommandCritical = true, + }); + } + else + { + return Command.Invoke(this) ?? CommandResult.Dismiss(); + } + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyPathCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyPathCommand.cs new file mode 100644 index 0000000000..9dcc8f36fb --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyPathCommand.cs @@ -0,0 +1,47 @@ +// 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.Text; +using Microsoft.CommandPalette.Extensions.Toolkit.Properties; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public partial class CopyPathCommand : InvokableCommand +{ + internal static IconInfo CopyPath { get; } = new("\uE8c8"); // Copy + + private static readonly CompositeFormat CopyFailedFormat = CompositeFormat.Parse(Resources.copy_failed); + + private readonly string _path; + + public CommandResult Result { get; set; } = CommandResult.ShowToast(Resources.CopyPathTextCommand_Result); + + public CopyPathCommand(string fullPath) + { + this._path = fullPath; + this.Name = Resources.CopyPathTextCommand_Name; + this.Icon = CopyPath; + } + + public override CommandResult Invoke() + { + try + { + ClipboardHelper.SetText(_path); + } + catch (Exception ex) + { + ExtensionHost.LogMessage(new LogMessage("Copy failed: " + ex.Message) { State = MessageState.Error }); + return CommandResult.ShowToast( + new ToastArgs + { + Message = string.Format(CultureInfo.CurrentCulture, CopyFailedFormat, ex.Message), + Result = CommandResult.KeepOpen(), + }); + } + + return Result; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CopyTextCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyTextCommand.cs similarity index 83% rename from src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CopyTextCommand.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyTextCommand.cs index ac4d618d0f..ed24bd01a7 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/CopyTextCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/CopyTextCommand.cs @@ -8,12 +8,12 @@ public partial class CopyTextCommand : InvokableCommand { public virtual string Text { get; set; } - public virtual CommandResult Result { get; set; } = CommandResult.ShowToast("Copied to clipboard"); + public virtual CommandResult Result { get; set; } = CommandResult.ShowToast(Properties.Resources.CopyTextCommand_CopiedToClipboard); public CopyTextCommand(string text) { Text = text; - Name = "Copy"; + Name = Properties.Resources.CopyTextCommand_Copy; Icon = new IconInfo("\uE8C8"); } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NoOpCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/NoOpCommand.cs similarity index 100% rename from src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NoOpCommand.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/NoOpCommand.cs diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenFileCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenFileCommand.cs new file mode 100644 index 0000000000..192d6313fc --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenFileCommand.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.ComponentModel; +using System.Diagnostics; +using Microsoft.CommandPalette.Extensions.Toolkit.Properties; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public partial class OpenFileCommand : InvokableCommand +{ + internal static IconInfo OpenFile { get; } = new("\uE8E5"); // OpenFile + + private readonly string _fullPath; + + public CommandResult Result { get; set; } = CommandResult.Dismiss(); + + public OpenFileCommand(string fullPath) + { + this._fullPath = fullPath; + this.Name = Resources.OpenFileCommand_Name; + this.Icon = OpenFile; + } + + public override CommandResult Invoke() + { + using (var process = new Process()) + { + process.StartInfo.FileName = _fullPath; + process.StartInfo.UseShellExecute = true; + + try + { + process.Start(); + } + catch (Win32Exception ex) + { + ExtensionHost.LogMessage($"Unable to open {_fullPath}\n{ex}"); + } + } + + return Result; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs new file mode 100644 index 0000000000..ff655387e0 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenInConsoleCommand.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using System.Diagnostics; +using Microsoft.CommandPalette.Extensions.Toolkit.Properties; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public partial class OpenInConsoleCommand : InvokableCommand +{ + internal static IconInfo OpenInConsoleIcon { get; } = new("\uE756"); // "CommandPrompt" + + private readonly string _path; + private bool _isDirectory; + + public OpenInConsoleCommand(string fullPath) + { + this._path = fullPath; + this.Name = Resources.OpenInConsoleCommand_Name; + this.Icon = OpenInConsoleIcon; + } + + public static OpenInConsoleCommand FromDirectory(string directory) => new(directory) { _isDirectory = true }; + + public static OpenInConsoleCommand FromFile(string file) => new(file); + + public override CommandResult Invoke() + { + using (var process = new Process()) + { + process.StartInfo.WorkingDirectory = _isDirectory ? _path : Path.GetDirectoryName(_path); + process.StartInfo.FileName = "cmd.exe"; + + try + { + process.Start(); + } + catch (Win32Exception ex) + { + ExtensionHost.LogMessage(new LogMessage($"Unable to open '{_path}'\n{ex.Message}\n{ex.StackTrace}") { State = MessageState.Error }); + } + } + + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenPropertiesCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenPropertiesCommand.cs new file mode 100644 index 0000000000..171a6c9910 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenPropertiesCommand.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using ManagedCsWin32; +using Microsoft.CommandPalette.Extensions.Toolkit.Properties; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public partial class OpenPropertiesCommand : InvokableCommand +{ + internal static IconInfo OpenPropertiesIcon { get; } = new("\uE90F"); + + private readonly string _path; + + private static unsafe bool ShowFileProperties(string filename) + { + var propertiesPtr = Marshal.StringToHGlobalUni("properties"); + var filenamePtr = Marshal.StringToHGlobalUni(filename); + + try + { + var info = new Shell32.SHELLEXECUTEINFOW + { + CbSize = (uint)sizeof(Shell32.SHELLEXECUTEINFOW), + LpVerb = propertiesPtr, + LpFile = filenamePtr, + Show = (int)SHOW_WINDOW_CMD.SW_SHOW, + FMask = global::Windows.Win32.PInvoke.SEE_MASK_INVOKEIDLIST, + }; + + return Shell32.ShellExecuteEx(ref info); + } + finally + { + Marshal.FreeHGlobal(filenamePtr); + Marshal.FreeHGlobal(propertiesPtr); + } + } + + public OpenPropertiesCommand(string fullPath) + { + this._path = fullPath; + this.Name = Resources.OpenPropertiesCommand_Name; + this.Icon = OpenPropertiesIcon; + } + + public override CommandResult Invoke() + { + try + { + ShowFileProperties(_path); + } + catch (Exception ex) + { + ExtensionHost.LogMessage(new LogMessage($"Error showing file properties '{_path}'\n{ex.Message}\n{ex.StackTrace}") { State = MessageState.Error }); + } + + return CommandResult.Dismiss(); + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/OpenUrlCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenUrlCommand.cs similarity index 84% rename from src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/OpenUrlCommand.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenUrlCommand.cs index 7189472c68..8078a258de 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/OpenUrlCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenUrlCommand.cs @@ -4,7 +4,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; -public sealed partial class OpenUrlCommand : InvokableCommand +public partial class OpenUrlCommand : InvokableCommand { private readonly string _target; @@ -13,7 +13,7 @@ public sealed partial class OpenUrlCommand : InvokableCommand public OpenUrlCommand(string target) { _target = target; - Name = "Open"; + Name = Properties.Resources.OpenUrlCommand_Open; Icon = new IconInfo("\uE8A7"); } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenWithCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenWithCommand.cs new file mode 100644 index 0000000000..5cd11f8635 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/OpenWithCommand.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using ManagedCsWin32; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CommandPalette.Extensions.Toolkit.Properties; +using Windows.Win32.UI.WindowsAndMessaging; + +namespace Microsoft.CmdPal.Core.Common.Commands; + +public partial class OpenWithCommand : InvokableCommand +{ + internal static IconInfo OpenWithIcon { get; } = new("\uE7AC"); + + private readonly string _path; + + private static unsafe bool OpenWith(string filename) + { + var filenamePtr = Marshal.StringToHGlobalUni(filename); + var verbPtr = Marshal.StringToHGlobalUni("openas"); + + try + { + var info = new Shell32.SHELLEXECUTEINFOW + { + CbSize = (uint)sizeof(Shell32.SHELLEXECUTEINFOW), + LpVerb = verbPtr, + LpFile = filenamePtr, + Show = (int)SHOW_WINDOW_CMD.SW_SHOWNORMAL, + FMask = global::Windows.Win32.PInvoke.SEE_MASK_INVOKEIDLIST, + }; + + return Shell32.ShellExecuteEx(ref info); + } + finally + { + Marshal.FreeHGlobal(filenamePtr); + Marshal.FreeHGlobal(verbPtr); + } + } + + public OpenWithCommand(string fullPath) + { + this._path = fullPath; + this.Name = Resources.OpenWithCommand_Name; + this.Icon = OpenWithIcon; + } + + public override CommandResult Invoke() + { + OpenWith(_path); + + return CommandResult.GoHome(); + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShowFileInFolderCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/ShowFileInFolderCommand.cs similarity index 92% rename from src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShowFileInFolderCommand.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/ShowFileInFolderCommand.cs index 4180b34cee..72f703ccb9 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShowFileInFolderCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Commands/ShowFileInFolderCommand.cs @@ -16,7 +16,7 @@ public partial class ShowFileInFolderCommand : InvokableCommand public ShowFileInFolderCommand(string path) { _path = path; - Name = "Show in folder"; + Name = Properties.Resources.ShowFileInFolderCommand_ShowInFolder; Icon = Ico; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ContentPage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ContentPage.cs index 1d2670d91d..9efe5940b4 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ContentPage.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ContentPage.cs @@ -10,17 +10,9 @@ public abstract partial class ContentPage : Page, IContentPage { public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged; - public virtual IDetails? Details - { - get => field; - set - { - field = value; - OnPropertyChanged(nameof(Details)); - } - } + public virtual IDetails? Details { get; set => SetProperty(ref field, value); } - public virtual IContextItem[] Commands { get; set; } = []; + public virtual IContextItem[] Commands { get; set => SetProperty(ref field, value); } = []; public abstract IContent[] GetContent(); diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Details.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Details.cs index f466f2fd71..c3af54fd17 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Details.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Details.cs @@ -1,56 +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 Windows.Foundation.Collections; namespace Microsoft.CommandPalette.Extensions.Toolkit; -public partial class Details : BaseObservable, IDetails +public partial class Details : BaseObservable, IDetails, IExtendedAttributesProvider { - public virtual IIconInfo HeroImage + public virtual IIconInfo HeroImage { get; set => SetProperty(ref field, value); } = new IconInfo(); + + public virtual string Title { get; set => SetProperty(ref field, value); } = string.Empty; + + public virtual string Body { get; set => SetProperty(ref field, value); } = string.Empty; + + public virtual IDetailsElement[] Metadata { get; set => SetProperty(ref field, value); } = []; + + public virtual ContentSize Size { get; set => SetProperty(ref field, value); } = ContentSize.Small; + + public IDictionary<string, object>? GetProperties() => new ValueSet() { - get => field; - set - { - field = value; - OnPropertyChanged(nameof(HeroImage)); - } - } - -= new IconInfo(); - - public virtual string Title - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Title)); - } - } - -= string.Empty; - - public virtual string Body - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Body)); - } - } - -= string.Empty; - - public virtual IDetailsElement[] Metadata - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Metadata)); - } - } - -= []; + { "Size", (int)Size }, + }; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DetailsCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DetailsCommands.cs similarity index 70% rename from src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DetailsCommand.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DetailsCommands.cs index 0d08458ebf..32b11faad4 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DetailsCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DetailsCommands.cs @@ -4,7 +4,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; -public partial class DetailsCommand : IDetailsCommand +public partial class DetailsCommands : IDetailsCommands { - public ICommand? Command { get; set; } + public ICommand[]? Commands { get; set; } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DynamicListPage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DynamicListPage.cs index 2bb5ffa576..ec2602fb56 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DynamicListPage.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/DynamicListPage.cs @@ -12,7 +12,7 @@ public abstract class DynamicListPage : ListPage, IDynamicListPage set { var oldSearch = base.SearchText; - base.SearchText = value; + SetSearchNoUpdate(value); UpdateSearchText(oldSearch, value); } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionHost.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionHost.cs index ed8ecb9566..cc9e2af15f 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionHost.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionHost.cs @@ -19,7 +19,7 @@ public partial class ExtensionHost /// <param name="message">The log message to send</param> public static void LogMessage(ILogMessage message) { - if (Host != null) + if (Host is not null) { _ = Task.Run(async () => { @@ -42,7 +42,7 @@ public partial class ExtensionHost public static void ShowStatus(IStatusMessage message, StatusContext context) { - if (Host != null) + if (Host is not null) { _ = Task.Run(async () => { @@ -59,7 +59,7 @@ public partial class ExtensionHost public static void HideStatus(IStatusMessage message) { - if (Host != null) + if (Host is not null) { _ = Task.Run(async () => { diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionInstanceManager`1.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionInstanceManager`1.cs index 019b4dc398..b80742f8f7 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionInstanceManager`1.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ExtensionInstanceManager`1.cs @@ -55,7 +55,7 @@ internal sealed partial class ExtensionInstanceManager : IClassFactory ppvObject = IntPtr.Zero; - if (pUnkOuter != null) + if (pUnkOuter is not null) { Marshal.ThrowExceptionForHR(CLASS_E_NOAGGREGATION); } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FallbackCommandItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FallbackCommandItem.cs index 13740eb1a1..db36e26992 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FallbackCommandItem.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FallbackCommandItem.cs @@ -1,16 +1,28 @@ -// 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. namespace Microsoft.CommandPalette.Extensions.Toolkit; -public partial class FallbackCommandItem : CommandItem, IFallbackCommandItem, IFallbackHandler +public partial class FallbackCommandItem : CommandItem, IFallbackCommandItem, IFallbackHandler, IFallbackCommandItem2, IExtendedAttributesProvider { - private IFallbackHandler? _fallbackHandler; + private readonly IFallbackHandler? _fallbackHandler; - public FallbackCommandItem(ICommand command, string displayTitle) + public FallbackCommandItem(string displayTitle, string id) + { + DisplayTitle = displayTitle; + Id = id; + } + + public FallbackCommandItem(ICommand command, string displayTitle, string id) : base(command) { + if (string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("A non-empty or whitespace Id must be provided.", nameof(id)); + } + + Id = id; DisplayTitle = displayTitle; if (command is IFallbackHandler f) { @@ -24,6 +36,8 @@ public partial class FallbackCommandItem : CommandItem, IFallbackCommandItem, IF init => _fallbackHandler = value; } + public virtual string Id { get; } + public virtual string DisplayTitle { get; } public virtual void UpdateQuery(string query) => _fallbackHandler?.UpdateQuery(query); diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filter.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filter.cs index 961556e572..67285c209a 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filter.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filter.cs @@ -6,39 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class Filter : BaseObservable, IFilter { - public virtual IIconInfo Icon - { - get => field; - set - { - field = value; - OnPropertyChanged(nameof(Icon)); - } - } + public virtual IIconInfo Icon { get; set => SetProperty(ref field, value); } = new IconInfo(); -= new IconInfo(); + public virtual string Id { get; set => SetProperty(ref field, value); } = string.Empty; - public virtual string Id - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Id)); - } - } - -= string.Empty; - - public virtual string Name - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Name)); - } - } - -= string.Empty; + public virtual string Name { get; set => SetProperty(ref field, value); } = string.Empty; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filters.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filters.cs new file mode 100644 index 0000000000..01882d62e0 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Filters.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. + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public abstract partial class Filters : BaseObservable, IFilters +{ + public string CurrentFilterId { get; set => SetProperty(ref field, value); } = string.Empty; + + // This method should be overridden in derived classes to provide the actual filters. + public abstract IFilterItem[] GetFilters(); +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FontIconData.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FontIconData.cs new file mode 100644 index 0000000000..3fcc01b96d --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FontIconData.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 Windows.Foundation.Collections; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +/// <summary> +/// Represents an icon that is a font glyph. +/// This is used for icons that are defined by a specific font face, +/// such as Wingdings. +/// +/// Note that Command Palette will default to using the Segoe Fluent Icons, +/// Segoe MDL2 Assets font for glyphs in the Segoe UI Symbol range, or Segoe +/// UI for any other glyphs. This class is only needed if you want a non-Segoe +/// font icon. +/// </summary> +public partial class FontIconData : IconData, IExtendedAttributesProvider +{ + public string FontFamily { get; set; } + + public FontIconData(string glyph, string fontFamily) + : base(glyph) + { + FontFamily = fontFamily; + } + + public IDictionary<string, object>? GetProperties() => new ValueSet() + { + { WellKnownExtensionAttributes.FontFamily, FontFamily }, + }; +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FormContent.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FormContent.cs index 7a8d400a89..dbfa5c2f03 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FormContent.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FormContent.cs @@ -6,41 +6,11 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class FormContent : BaseObservable, IFormContent { - public virtual string DataJson - { - get; - set - { - field = value; - OnPropertyChanged(nameof(DataJson)); - } - } + public virtual string DataJson { get; set => SetProperty(ref field, value); } = string.Empty; -= string.Empty; + public virtual string StateJson { get; set => SetProperty(ref field, value); } = string.Empty; - public virtual string StateJson - { - get; - set - { - field = value; - OnPropertyChanged(nameof(StateJson)); - } - } - -= string.Empty; - - public virtual string TemplateJson - { - get; - set - { - field = value; - OnPropertyChanged(nameof(TemplateJson)); - } - } - -= string.Empty; + public virtual string TemplateJson { get; set => SetProperty(ref field, value); } = string.Empty; public virtual ICommandResult SubmitForm(string inputs, string data) => SubmitForm(inputs); diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs new file mode 100644 index 0000000000..a4b7084555 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs @@ -0,0 +1,1094 @@ +// 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.Buffers; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text; +using ToolGood.Words.Pinyin; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +// Inspired by the fuzzy.rs from edit.exe +public static class FuzzyStringMatcher +{ + private const int NoMatchScore = 0; + private const int StackAllocThreshold = 512; + + /// <summary> + /// Gets a value indicating whether to support Chinese PinYin. + /// Automatically enabled when the system UI culture is Simplified Chinese. + /// </summary> + public static bool ChinesePinYinSupport { get; internal set; } = IsSimplifiedChinese(); + + private static bool IsSimplifiedChinese() + { + var culture = CultureInfo.CurrentUICulture; + return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase) + || culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static PreparedFuzzyQuery GetOrPrepareThreadCached(string needle, bool removeDiacritics) + { + return PreparedFuzzyQueryThreadCache.GetOrPrepare(needle, removeDiacritics); + } + + /// <summary> + /// Prepare a query for repeated scoring against many targets. + /// </summary> + private static PreparedFuzzyQuery PrepareQuery(string input, bool mayNeedDiacriticsRemoval = false) + => new(input, precomputeNoDiacritics: mayNeedDiacriticsRemoval); + + // ============================================================ + // Public API + // ============================================================ + public static int ScoreFuzzy(string needle, string haystack, bool allowNonContiguousMatches = true) + { + return ScoreFuzzy(needle, haystack, allowNonContiguousMatches, removeDiacritics: true); + } + + public static int ScoreFuzzy(string needle, string haystack, bool allowNonContiguousMatches, bool removeDiacritics) + { + var query = GetOrPrepareThreadCached(needle, removeDiacritics); + return ScoreBestVariant(in query, haystack, allowNonContiguousMatches, removeDiacritics); + } + + public static (int Score, List<int> Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches) + { + return ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches, removeDiacritics: true); + } + + public static (int Score, List<int> Positions) ScoreFuzzyWithPositions( + string needle, string haystack, bool allowNonContiguousMatches, bool removeDiacritics) + { + var query = GetOrPrepareThreadCached(needle, removeDiacritics); + return ScoreBestVariantWithPositions(in query, haystack, allowNonContiguousMatches, removeDiacritics); + } + + internal static void ClearCache() + { + PreparedFuzzyQueryThreadCache.Clear(); + } + + // ============================================================ + // Best-variant selection + // ============================================================ + [SkipLocalsInit] + private static int ScoreBestVariant( + in PreparedFuzzyQuery query, + string haystack, + bool allowNonContiguousMatches, + bool removeDiacritics) + { + if (string.IsNullOrEmpty(haystack)) + { + return NoMatchScore; + } + + var tLen = haystack.Length; + + // Fold haystack ONCE + using var tFoldBuffer = new RentedSpan<char>(tLen, stackalloc char[Math.Min(tLen, StackAllocThreshold)]); + Folding.FoldInto(haystack, removeDiacritics, tFoldBuffer.Span); + ReadOnlySpan<char> tFold = tFoldBuffer.Span; + + var qFold = query.GetPrimaryFolded(removeDiacritics); + var best = ScoreCore(query.PrimaryRaw, qFold, haystack, tFold, allowNonContiguousMatches); + + if (!ChinesePinYinSupport || !query.HasSecondary) + { + return best; + } + + var qRawSecondary = query.SecondaryRaw ?? string.Empty; + var qFoldSecondary = query.GetSecondaryFolded(removeDiacritics); + + best = Math.Max(best, ScoreCore(qRawSecondary, qFoldSecondary, haystack, tFold, allowNonContiguousMatches)); + + if (!WordsHelper.HasChinese(haystack)) + { + return best; + } + + // Fold PinYin target ONCE + var tPinYin = WordsHelper.GetPinyin(haystack) ?? string.Empty; + var tPinYinLen = tPinYin.Length; + + using var tPinYinFoldBuffer = new RentedSpan<char>(tPinYinLen, stackalloc char[Math.Min(tPinYinLen, StackAllocThreshold)]); + Folding.FoldInto(tPinYin, removeDiacritics, tPinYinFoldBuffer.Span); + ReadOnlySpan<char> tPinYinFold = tPinYinFoldBuffer.Span; + + best = Math.Max(best, ScoreCore(query.PrimaryRaw, qFold, tPinYin, tPinYinFold, allowNonContiguousMatches)); + best = Math.Max(best, ScoreCore(qRawSecondary, qFoldSecondary, tPinYin, tPinYinFold, allowNonContiguousMatches)); + + return best; + } + + private static (int Score, List<int> Positions) ScoreBestVariantWithPositions( + in PreparedFuzzyQuery query, + string haystack, + bool allowNonContiguousMatches, + bool removeDiacritics) + { + if (string.IsNullOrEmpty(haystack)) + { + return (NoMatchScore, []); + } + + var tLen = haystack.Length; + + // Fold haystack ONCE + using var tFoldBuffer = new RentedSpan<char>(tLen, stackalloc char[Math.Min(tLen, StackAllocThreshold)]); + Folding.FoldInto(haystack, removeDiacritics, tFoldBuffer.Span); + ReadOnlySpan<char> tFold = tFoldBuffer.Span; + + var needsPinYin = ChinesePinYinSupport && query.HasSecondary && WordsHelper.HasChinese(haystack); + var tPinYin = needsPinYin ? (WordsHelper.GetPinyin(haystack) ?? string.Empty) : string.Empty; + var tPinYinLen = tPinYin.Length; + + // Fold PinYin target if needed + using var tPinYinFoldBuffer = new RentedSpan<char>( + needsPinYin ? tPinYinLen : 0, + needsPinYin ? stackalloc char[Math.Min(tPinYinLen, StackAllocThreshold)] : Span<char>.Empty); + + if (needsPinYin) + { + Folding.FoldInto(tPinYin, removeDiacritics, tPinYinFoldBuffer.Span); + } + + ReadOnlySpan<char> tPinYinFold = tPinYinFoldBuffer.Span; + + var qFoldPrimary = query.GetPrimaryFoldedString(removeDiacritics); + + // (primary query, original haystack) - get score AND positions + var (bestScore, bestPositions) = ScoreWithPositionsCore(query.PrimaryRaw, qFoldPrimary, haystack, tFold, allowNonContiguousMatches); + + // (primary query, pinyin target) - score only. + // We only return positions for matches against the original haystack. + // For Pinyin variants, we typically don't show highlights in the UI since there's + // no 1:1 mapping back to the original characters' positions. + if (needsPinYin) + { + var score = ScoreCore(query.PrimaryRaw, qFoldPrimary, tPinYin, tPinYinFold, allowNonContiguousMatches); + if (score > bestScore) + { + bestScore = score; + } + } + + if (ChinesePinYinSupport && query.HasSecondary) + { + var qRawSecondary = query.SecondaryRaw ?? string.Empty; + var qFoldSecondary = query.GetSecondaryFoldedString(removeDiacritics) ?? string.Empty; + + // (secondary query, original haystack) - get score AND positions + var (scoreSecondary, positionsSecondary) = ScoreWithPositionsCore( + qRawSecondary, qFoldSecondary, haystack, tFold, allowNonContiguousMatches); + + if (scoreSecondary > bestScore) + { + bestScore = scoreSecondary; + bestPositions = positionsSecondary; + } + + // (secondary query, pinyin target) - score only. + // Highlight positions are not returned for Pinyin variants. + if (needsPinYin) + { + var score = ScoreCore(qRawSecondary, qFoldSecondary, tPinYin, tPinYinFold, allowNonContiguousMatches); + if (score > bestScore) + { + bestScore = score; + } + } + } + + return (bestScore, bestPositions); + } + + // ============================================================ + // Core scoring + // ============================================================ + private static int ScoreCore( + ReadOnlySpan<char> qRaw, + ReadOnlySpan<char> qFold, + ReadOnlySpan<char> tRaw, + ReadOnlySpan<char> tFold, + bool allowNonContiguousMatches) + { + var qLen = qRaw.Length; + var tLen = tRaw.Length; + + if (qLen == 0 || tLen < qLen || qFold.Length != qLen) + { + return NoMatchScore; + } + + return allowNonContiguousMatches + ? ScoreNonContiguous(qRaw, qFold, tRaw, tFold, qLen, tLen) + : ScoreContiguous(qRaw, qFold, tRaw, tFold).Score; + } + + private static (int Score, List<int> Positions) ScoreWithPositionsCore( + ReadOnlySpan<char> qRaw, + ReadOnlySpan<char> qFold, + ReadOnlySpan<char> tRaw, + ReadOnlySpan<char> tFold, + bool allowNonContiguousMatches) + { + var qLen = qRaw.Length; + var tLen = tRaw.Length; + + if (qLen == 0 || tLen < qLen || qFold.Length != qLen) + { + return (NoMatchScore, []); + } + + return allowNonContiguousMatches + ? ScoreNonContiguousWithPositions(qRaw, qFold, tRaw, tFold, qLen, tLen) + : ScoreContiguousWithPositions(qRaw, qFold, tRaw, tFold); + } + + // ============================================================ + // Non-contiguous matching (score only) + // ============================================================ + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + [SkipLocalsInit] + private static int ScoreNonContiguous( + ReadOnlySpan<char> qRaw, + ReadOnlySpan<char> qFold, + ReadOnlySpan<char> tRaw, + ReadOnlySpan<char> tFold, + int qLen, + int tLen) + { + if (!Scoring.CanMatchSubsequence(qFold, tFold)) + { + return NoMatchScore; + } + + using var dpBuffer = new RentedSpan<int>(tLen * 2, stackalloc int[Math.Min(tLen * 2, StackAllocThreshold)]); + var scores = dpBuffer.Span[..tLen]; + var seqLens = dpBuffer.Span.Slice(tLen, tLen); + scores.Clear(); + seqLens.Clear(); + + for (var qi = 0; qi < qLen; qi++) + { + var qChar = qRaw[qi]; + var qCharFold = qFold[qi]; + + var leftScore = 0; + var diagScore = 0; + var diagSeqLen = 0; + + var isFirstRow = qi == 0; + var tiMax = tLen - qLen + qi; + + for (var ti = 0; ti <= tiMax; ti++) + { + var upScore = scores[ti]; + var upSeqLen = seqLens[ti]; + + var charScore = 0; + if (diagScore != 0 || isFirstRow) + { + var tCharFold = tFold[ti]; + if (qCharFold == tCharFold) + { + charScore = Scoring.ComputeCharScore( + qRawChar: qChar, + tHasPrev: ti != 0, + tRawCharPrev: ti != 0 ? tRaw[ti - 1] : '\0', + tRawCharCurr: tRaw[ti], + matchSeqLen: diagSeqLen); + } + } + + var candidateScore = diagScore + charScore; + + if (charScore != 0 && candidateScore >= leftScore) + { + scores[ti] = candidateScore; + seqLens[ti] = diagSeqLen + 1; + } + else + { + scores[ti] = leftScore; + seqLens[ti] = 0; + } + + leftScore = scores[ti]; + diagScore = upScore; + diagSeqLen = upSeqLen; + } + + if (leftScore == 0) + { + return NoMatchScore; + } + + if (qi == qLen - 1) + { + return leftScore; + } + } + + return scores[tLen - 1]; + } + + // ============================================================ + // Non-contiguous matching (with positions) + // ============================================================ + private static (int Score, List<int> Positions) ScoreNonContiguousWithPositions( + ReadOnlySpan<char> qRaw, + ReadOnlySpan<char> qFold, + ReadOnlySpan<char> tRaw, + ReadOnlySpan<char> tFold, + int qLen, + int tLen) + { + if (!Scoring.CanMatchSubsequence(qFold, tFold)) + { + return (NoMatchScore, []); + } + + var areaLong = (long)qLen * tLen; + if (areaLong is <= 0 or > int.MaxValue) + { + return (NoMatchScore, []); + } + + var area = (int)areaLong; + var bitCount = (area + 63) >> 6; + + using var bitsBuffer = new RentedSpan<ulong>(bitCount, stackalloc ulong[Math.Min(bitCount, StackAllocThreshold / 8)]); + bitsBuffer.Span.Clear(); + + using var dpBuffer = new RentedSpan<int>(tLen * 2, stackalloc int[Math.Min(tLen * 2, StackAllocThreshold)]); + var scores = dpBuffer.Span[..tLen]; + var seqLens = dpBuffer.Span.Slice(tLen, tLen); + scores.Clear(); + seqLens.Clear(); + + for (var qi = 0; qi < qLen; qi++) + { + var qChar = qRaw[qi]; + var qCharFold = qFold[qi]; + + var leftScore = 0; + var diagScore = 0; + var diagSeqLen = 0; + + var isFirstRow = qi == 0; + var rowBase = qi * tLen; + + for (var ti = 0; ti < tLen; ti++) + { + var upScore = scores[ti]; + var upSeqLen = seqLens[ti]; + + var charScore = 0; + if (diagScore != 0 || isFirstRow) + { + var tCharFold = tFold[ti]; + if (qCharFold == tCharFold) + { + charScore = Scoring.ComputeCharScore( + qRawChar: qChar, + tHasPrev: ti != 0, + tRawCharPrev: ti != 0 ? tRaw[ti - 1] : '\0', + tRawCharCurr: tRaw[ti], + matchSeqLen: diagSeqLen); + } + } + + var candidateScore = diagScore + charScore; + + if (charScore != 0 && candidateScore >= leftScore) + { + scores[ti] = candidateScore; + seqLens[ti] = diagSeqLen + 1; + SetBit(bitsBuffer.Span, rowBase + ti); + } + else + { + scores[ti] = leftScore; + seqLens[ti] = 0; + } + + leftScore = scores[ti]; + diagScore = upScore; + diagSeqLen = upSeqLen; + } + + if (leftScore == 0) + { + return (NoMatchScore, []); + } + } + + var finalScore = scores[tLen - 1]; + if (finalScore == 0) + { + return (NoMatchScore, []); + } + + // Backtrack to find positions + var positions = new List<int>(qLen); + var q = qLen - 1; + var t = tLen - 1; + + while (true) + { + var bitIdx = (q * tLen) + t; + + if (!GetBit(bitsBuffer.Span, bitIdx)) + { + if (t == 0) + { + break; + } + + t--; + } + else + { + positions.Add(t); + if (q == 0 || t == 0) + { + break; + } + + q--; + t--; + } + } + + positions.Reverse(); + return (finalScore, positions); + } + + // ============================================================ + // Contiguous matching + // ============================================================ + private static (int Score, int Start) ScoreContiguous( + ReadOnlySpan<char> qRaw, + ReadOnlySpan<char> qFold, + ReadOnlySpan<char> tRaw, + ReadOnlySpan<char> tFold) + { + var qLen = qRaw.Length; + var tLen = tRaw.Length; + + if (qLen == 0 || tLen == 0 || tLen < qLen) + { + return (NoMatchScore, -1); + } + + var bestScore = NoMatchScore; + var bestStart = -1; + var searchStart = 0; + + while (searchStart <= tLen - qLen) + { + var relativeIdx = tFold.Slice(searchStart).IndexOf(qFold); + if (relativeIdx < 0) + { + break; + } + + var matchStart = searchStart + relativeIdx; + var score = 0; + + for (var i = 0; i < qLen; i++) + { + var ti = matchStart + i; + score += Scoring.ComputeCharScore( + qRawChar: qRaw[i], + tHasPrev: ti != 0, + tRawCharPrev: ti != 0 ? tRaw[ti - 1] : '\0', + tRawCharCurr: tRaw[ti], + matchSeqLen: i); + } + + if (score >= bestScore) + { + bestScore = score; + bestStart = matchStart; + } + + searchStart = matchStart + 1; + } + + return (bestScore, bestStart); + } + + private static (int Score, List<int> Positions) ScoreContiguousWithPositions( + ReadOnlySpan<char> qRaw, + ReadOnlySpan<char> qFold, + ReadOnlySpan<char> tRaw, + ReadOnlySpan<char> tFold) + { + var (score, bestStart) = ScoreContiguous(qRaw, qFold, tRaw, tFold); + + if (bestStart < 0 || score == NoMatchScore) + { + return (NoMatchScore, []); + } + + var qLen = qRaw.Length; + var positions = new List<int>(qLen); + for (var i = 0; i < qLen; i++) + { + positions.Add(bestStart + i); + } + + return (score, positions); + } + + // ============================================================ + // Bit manipulation helpers + // ============================================================ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SetBit(Span<ulong> words, int idx) + { + words[idx >> 6] |= 1UL << (idx & 63); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool GetBit(ReadOnlySpan<ulong> words, int idx) + { + return ((words[idx >> 6] >> (idx & 63)) & 1UL) != 0; + } + + // ============================================================ + // Scoring helpers + // ============================================================ + private static class Scoring + { + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + internal static bool CanMatchSubsequence(ReadOnlySpan<char> qFold, ReadOnlySpan<char> tFold) + { + var qi = 0; + var qLen = qFold.Length; + + foreach (var tChar in tFold) + { + if (qi < qLen && qFold[qi] == tChar) + { + qi++; + } + } + + return qi == qLen; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + internal static int ComputeCharScore( + char qRawChar, + bool tHasPrev, + char tRawCharPrev, + char tRawCharCurr, + int matchSeqLen) + { + var score = Bonus.CharacterMatch; + + if (matchSeqLen > 0) + { + score += matchSeqLen * Bonus.ConsecutiveMultiplier; + } + + var tCharCurrIsUpper = char.IsUpper(tRawCharCurr); + if (qRawChar == tRawCharCurr) + { + score += Bonus.ExactCase; + } + + if (!tHasPrev) + { + return score + Bonus.StringStart; + } + + var separatorBonus = GetSeparatorBonus(tRawCharPrev); + if (separatorBonus != 0) + { + return score + separatorBonus; + } + + if (matchSeqLen == 0 && tCharCurrIsUpper) + { + return score + Bonus.CamelCase; + } + + return score; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static int GetSeparatorBonus(char ch) + { + return ch switch + { + '/' or '\\' => Bonus.PathSeparator, + '_' or '-' or '.' or ' ' or '\'' or '"' or ':' => Bonus.WordSeparator, + _ => 0, + }; + } + } + + // ============================================================ + // Text folding + // ============================================================ + + // Folding: slash normalization + upper case + optional diacritics stripping + internal static class Folding + { + // Cache maps an upper case char to its diacritics-stripped upper case char. + // '\0' means "not cached yet". + private static readonly char[] StripCacheUpper = new char[char.MaxValue + 1]; + + /// <summary> + /// Folds <paramref name="input"/> into <paramref name="dest"/>: + /// - Normalizes slashes: '\' -> '/' + /// - Upper case with char.ToUpperInvariant (length-preserving) + /// - Optionally strips diacritics (length-preserving) + /// </summary> + public static void FoldInto(ReadOnlySpan<char> input, bool removeDiacritics, Span<char> dest) + { + // Assumes dest.Length >= input.Length. + if (!removeDiacritics) + { + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + dest[i] = c == '\\' ? '/' : char.ToUpperInvariant(c); + } + + return; + } + + // ASCII cannot have diacritics (and ToUpperInvariant is cheap), but we STILL normalize slashes. + if (Ascii.IsValid(input)) + { + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + dest[i] = c == '\\' ? '/' : char.ToUpperInvariant(c); + } + + return; + } + + // Non-ASCII + removeDiacritics + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + var upper = c == '\\' ? '/' : char.ToUpperInvariant(c); + dest[i] = StripDiacriticsFromUpper(upper); + } + } + + /// <summary> + /// Creates a folded string for fast equality comparisons: + /// - ALWAYS normalizes slashes: '\' -> '/' + /// - Upper case with char.ToUpperInvariant (length-preserving) + /// - Optionally strips diacritics (length-preserving) + /// + /// Returns the original <paramref name="input"/> when it is already in the desired form. + /// </summary> + public static string FoldForComparison(string input, bool removeDiacritics) + { + if (string.IsNullOrEmpty(input)) + { + return string.Empty; + } + + // If already fully normalized (slashes + casing), return input without allocating. + // Note: when removeDiacritics==true we still must run diacritics stripping on non-ASCII, + // so the "no-op" path is only safe if removeDiacritics==false OR input is ASCII. + if (!removeDiacritics) + { + if (IsAlreadyFoldedAndSlashNormalized(input)) + { + return input; + } + + return string.Create(input.Length, input, static (dst, src) => + { + for (var i = 0; i < src.Length; i++) + { + var c = src[i]; + dst[i] = c == '\\' ? '/' : char.ToUpperInvariant(c); + } + }); + } + + // removeDiacritics == true + if (Ascii.IsValid(input)) + { + // IMPORTANT: still normalize slashes for ASCII so caller can do simple equality checks. + if (IsAlreadyFoldedAndSlashNormalized(input)) + { + return input; + } + + return string.Create(input.Length, input, static (dst, src) => + { + for (var i = 0; i < src.Length; i++) + { + var c = src[i]; + dst[i] = c == '\\' ? '/' : char.ToUpperInvariant(c); + } + }); + } + + // Non-ASCII + removeDiacritics: must fold + strip (and still normalize slashes). + return string.Create(input.Length, input, static (dst, src) => + { + for (var i = 0; i < src.Length; i++) + { + var c = src[i]; + var upper = c == '\\' ? '/' : char.ToUpperInvariant(c); + dst[i] = StripDiacriticsFromUpper(upper); + } + }); + } + + // ============================================================ + // "No-op" detector (fast, avoids ToUpperInvariant per char for CJK) + // ============================================================ + private static bool IsAlreadyFoldedAndSlashNormalized(string input) + { + var sawNonAscii = false; + + // Tier 1: cheap ASCII checks. + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + + if (c == '\\') + { + return false; + } + + // ASCII lowercase present => would change. + if ((uint)(c - 'a') <= ('z' - 'a')) + { + return false; + } + + if (c > 0x7F) + { + sawNonAscii = true; + } + } + + // Tier 2: only when non-ASCII exists; avoid char.ToUpperInvariant for scripts without case. + if (sawNonAscii) + { + for (var i = 0; i < input.Length; i++) + { + var c = input[i]; + if (c <= 0x7F) + { + continue; + } + + var cat = CharUnicodeInfo.GetUnicodeCategory(c); + + // Lowercase/Titlecase letters will change under ToUpperInvariant. + if (cat is UnicodeCategory.LowercaseLetter or UnicodeCategory.TitlecaseLetter) + { + return false; + } + } + } + + return true; + } + + // ============================================================ + // Diacritics stripping (cached; input is expected to be uppercase already) + // ============================================================ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static char StripDiacriticsFromUpper(char upper) + { + if (upper <= 0x7F) + { + return upper; + } + + // Emoji and other astral symbols come through as surrogate pairs in UTF-16. + // We process char-by-char, so never try to normalize a lone surrogate. + if (char.IsSurrogate(upper)) + { + return upper; + } + + var cached = StripCacheUpper[upper]; + if (cached != '\0') + { + return cached; + } + + var mapped = StripDiacriticsSlow(upper); + StripCacheUpper[upper] = mapped; + return mapped; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static char StripDiacriticsSlow(char upper) + { + var baseChar = FirstNonMark(upper, NormalizationForm.FormD); + if (baseChar == '\0' || baseChar == upper) + { + var kd = FirstNonMark(upper, NormalizationForm.FormKD); + if (kd != '\0') + { + baseChar = kd; + } + } + + // Ensure result remains uppercase invariant. + return char.ToUpperInvariant(baseChar == '\0' ? upper : baseChar); + + static char FirstNonMark(char c, NormalizationForm form) + { + var normalized = c.ToString().Normalize(form); + + foreach (var ch in normalized) + { + var cat = CharUnicodeInfo.GetUnicodeCategory(ch); + if (cat is not (UnicodeCategory.NonSpacingMark + or UnicodeCategory.SpacingCombiningMark + or UnicodeCategory.EnclosingMark)) + { + return ch; + } + } + + return '\0'; + } + } + } + + // ============================================================ + // Text utilities + // ============================================================ + private static class Text + { + internal static string RemoveApostrophes(ReadOnlySpan<char> input) + { + var firstIdx = input.IndexOf('\''); + if (firstIdx < 0) + { + return input.ToString(); + } + + var count = 1; + for (var i = firstIdx + 1; i < input.Length; i++) + { + if (input[i] == '\'') + { + count++; + } + } + + return string.Create(input.Length - count, input.ToString(), static (dest, src) => + { + var destIdx = 0; + foreach (var c in src) + { + if (c != '\'') + { + dest[destIdx++] = c; + } + } + }); + } + } + + // ============================================================ + // Scoring bonuses + // ============================================================ + private static class Bonus + { + public const int CharacterMatch = 1; + public const int ConsecutiveMultiplier = 5; + public const int ExactCase = 1; + public const int StringStart = 8; + public const int PathSeparator = 5; + public const int WordSeparator = 4; + public const int CamelCase = 2; + } + + // ============================================================ + // Memory management + // ============================================================ + private ref struct RentedSpan<T> + { + private readonly Span<T> _span; + private T[]? _poolArray; + + public readonly Span<T> Span => _span; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public RentedSpan(int length, Span<T> stackBuffer) + { + if (length <= stackBuffer.Length) + { + _poolArray = null; + _span = stackBuffer[..length]; + } + else + { + _poolArray = ArrayPool<T>.Shared.Rent(length); + _span = new Span<T>(_poolArray, 0, length); + } + } + + public static implicit operator Span<T>(RentedSpan<T> rented) => rented._span; + + public static implicit operator ReadOnlySpan<T>(RentedSpan<T> rented) => rented._span; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + var toReturn = _poolArray; + if (toReturn != null) + { + _poolArray = null; + ArrayPool<T>.Shared.Return(toReturn, clearArray: RuntimeHelpers.IsReferenceOrContainsReferences<T>()); + } + } + } + + // ============================================================ + // Prepared query + // ============================================================ + private readonly struct PreparedFuzzyQuery + { + public readonly string PrimaryRaw; + internal readonly string? SecondaryRaw; + + internal readonly string PrimaryFolded; + internal readonly string? PrimaryFoldedNoDiacritics; + + internal readonly string? SecondaryFolded; + internal readonly string? SecondaryFoldedNoDiacritics; + + internal PreparedFuzzyQuery(string primaryRaw, bool precomputeNoDiacritics) + { + PrimaryRaw = primaryRaw ?? string.Empty; + + PrimaryFolded = Folding.FoldForComparison(PrimaryRaw, removeDiacritics: false); + PrimaryFoldedNoDiacritics = precomputeNoDiacritics + ? Folding.FoldForComparison(PrimaryRaw, removeDiacritics: true) + : null; + + if (ChinesePinYinSupport) + { + var input = Text.RemoveApostrophes(PrimaryRaw); + SecondaryRaw = WordsHelper.GetPinyin(input) ?? string.Empty; + + SecondaryFolded = Folding.FoldForComparison(SecondaryRaw, removeDiacritics: false); + SecondaryFoldedNoDiacritics = precomputeNoDiacritics + ? Folding.FoldForComparison(SecondaryRaw, removeDiacritics: true) + : null; + } + else + { + SecondaryRaw = null; + SecondaryFolded = null; + SecondaryFoldedNoDiacritics = null; + } + } + + internal bool HasSecondary => SecondaryFolded is not null; + + internal string GetPrimaryFoldedString(bool removeDiacritics) + { + return !removeDiacritics + ? PrimaryFolded + : (PrimaryFoldedNoDiacritics ?? Folding.FoldForComparison(PrimaryRaw, removeDiacritics: true)); + } + + internal string? GetSecondaryFoldedString(bool removeDiacritics) + { + if (SecondaryFolded is null) + { + return null; + } + + return !removeDiacritics + ? SecondaryFolded + : (SecondaryFoldedNoDiacritics ?? Folding.FoldForComparison(SecondaryRaw ?? string.Empty, removeDiacritics: true)); + } + + internal ReadOnlySpan<char> GetPrimaryFolded(bool removeDiacritics) + { + return GetPrimaryFoldedString(removeDiacritics).AsSpan(); + } + + internal ReadOnlySpan<char> GetSecondaryFolded(bool removeDiacritics) + { + return GetSecondaryFoldedString(removeDiacritics).AsSpan(); + } + } + + // ============================================================ + // Thread-local query cache + // ============================================================ + private static class PreparedFuzzyQueryThreadCache + { + [ThreadStatic] + private static Cache? _cache; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Cache GetCache() + { + return _cache ??= new Cache(); + } + + public static void Clear() + { + _cache = null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static PreparedFuzzyQuery GetOrPrepare(string? needle, bool removeDiacritics) + { + needle ??= string.Empty; + + var cache = GetCache(); + + if (string.Equals(cache.Needle, needle, StringComparison.Ordinal)) + { + if (!removeDiacritics || cache.HasDiacriticsVersion) + { + return cache.Query; + } + + cache.Query = PrepareQuery(needle, true); + cache.HasDiacriticsVersion = true; + return cache.Query; + } + + cache.Needle = needle; + cache.Query = PrepareQuery(needle, removeDiacritics); + cache.HasDiacriticsVersion = removeDiacritics; + return cache.Query; + } + + private sealed class Cache + { + public string? Needle { get; set; } + + public PreparedFuzzyQuery Query { get; set; } + + public bool HasDiacriticsVersion { get; set; } + } + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/GalleryGridLayout.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/GalleryGridLayout.cs new file mode 100644 index 0000000000..c339d563e7 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/GalleryGridLayout.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.CommandPalette.Extensions.Toolkit; + +public partial class GalleryGridLayout : BaseObservable, IGalleryGridLayout +{ + public virtual bool ShowTitle { get; set => SetProperty(ref field, value); } = true; + + public virtual bool ShowSubtitle { get; set => SetProperty(ref field, value); } = true; +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/IconInfo.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/IconInfo.cs index 674a96ec4b..731b529903 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/IconInfo.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/IconInfo.cs @@ -27,6 +27,12 @@ public partial class IconInfo : IIconInfo Dark = dark; } + public IconInfo(IconData icon) + { + Light = icon; + Dark = icon; + } + internal IconInfo() : this(string.Empty) { diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs index 6fbc734560..98e2fae688 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs @@ -15,8 +15,8 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; [JsonSerializable(typeof(List<Choice>))] [JsonSerializable(typeof(List<ChoiceSetSetting>))] [JsonSerializable(typeof(Dictionary<string, object>), TypeInfoPropertyName = "Dictionary")] +[JsonSerializable(typeof(List<Dictionary<string, object>>))] [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true)] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")] -internal partial class JsonSerializationContext : JsonSerializerContext +internal sealed partial class JsonSerializationContext : JsonSerializerContext { } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSettingsManager.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSettingsManager.cs index 8cf8d49db5..09c1eebdbe 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSettingsManager.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSettingsManager.cs @@ -76,7 +76,7 @@ public abstract class JsonSettingsManager { foreach (var item in newSettings) { - savedSettings[item.Key] = item.Value != null ? item.Value.DeepClone() : null; + savedSettings[item.Key] = item.Value is not null ? item.Value.DeepClone() : null; } var serialized = savedSettings.ToJsonString(_serializerOptions); diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/KeyChordHelpers.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/KeyChordHelpers.cs index 6c6a0ac5c5..0ffab0b7e4 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/KeyChordHelpers.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/KeyChordHelpers.cs @@ -2,14 +2,19 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Windows.Foundation; using Windows.System; namespace Microsoft.CommandPalette.Extensions.Toolkit; -public partial class KeyChordHelpers +public static partial class KeyChordHelpers { - public static KeyChord FromModifiers(bool ctrl, bool alt, bool shift, bool win, int vkey, int scanCode) + public static KeyChord FromModifiers( + bool ctrl = false, + bool alt = false, + bool shift = false, + bool win = false, + int vkey = 0, + int scanCode = 0) { var modifiers = (ctrl ? VirtualKeyModifiers.Control : VirtualKeyModifiers.None) | (alt ? VirtualKeyModifiers.Menu : VirtualKeyModifiers.None) @@ -18,4 +23,39 @@ public partial class KeyChordHelpers ; return new(modifiers, vkey, scanCode); } + + public static KeyChord FromModifiers( + bool ctrl = false, + bool alt = false, + bool shift = false, + bool win = false, + VirtualKey vkey = VirtualKey.None, + int scanCode = 0) + { + return FromModifiers(ctrl, alt, shift, win, (int)vkey, scanCode); + } + + public static string FormatForDebug(KeyChord value) + { + var result = string.Empty; + + if (value.Modifiers.HasFlag(VirtualKeyModifiers.Control)) + { + result += "Ctrl+"; + } + + if (value.Modifiers.HasFlag(VirtualKeyModifiers.Shift)) + { + result += "Shift+"; + } + + if (value.Modifiers.HasFlag(VirtualKeyModifiers.Menu)) + { + result += "Alt+"; + } + + result += (VirtualKey)value.Vkey; + + return result; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs index 5468449406..3847ab8e55 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs @@ -19,17 +19,17 @@ public partial class ListHelpers return 0; } - var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title); + var nameMatchScore = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title); // var locNameMatch = StringMatcher.FuzzySearch(query, NameLocalized); - var descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle); + var descriptionMatchScore = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle); // var executableNameMatch = StringMatcher.FuzzySearch(query, ExePath); // var locExecutableNameMatch = StringMatcher.FuzzySearch(query, ExecutableNameLocalized); // var lnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableName); // var locLnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableNameLocalized); // var score = new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, executableNameMatch.Score }.Max(); - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + return new[] { nameMatchScore, (descriptionMatchScore - 4) / 2, 0 }.Max(); } public static IEnumerable<IListItem> FilterList(IEnumerable<IListItem> items, string query) @@ -43,14 +43,18 @@ public partial class ListHelpers } public static IEnumerable<T> FilterList<T>(IEnumerable<T> items, string query, Func<string, T, int> scoreFunction) - where T : class + { + return FilterListWithScores<T>(items, query, scoreFunction) + .Select(score => score.Item); + } + + public static IEnumerable<Scored<T>> FilterListWithScores<T>(IEnumerable<T> items, string query, Func<string, T, int> scoreFunction) { var scores = items .Select(li => new Scored<T>() { Item = li, Score = scoreFunction(query, li) }) .Where(score => score.Score > 0) .OrderByDescending(score => score.Score); - return scores - .Select(score => score.Item); + return scores; } /// <summary> @@ -66,12 +70,32 @@ public partial class ListHelpers public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents) where T : class { + InPlaceUpdateList(original, newContents, out _); + } + + /// <summary> + /// Modifies the contents of `original` in-place, to match those of + /// `newContents`. The canonical use being: + /// ```cs + /// ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(ItemsToFilter, TextToFilterOn)); + /// ``` + /// </summary> + /// <typeparam name="T">Any type that can be compared for equality</typeparam> + /// <param name="original">Collection to modify</param> + /// <param name="newContents">The enumerable which `original` should match</param> + /// <param name="removedItems">List of items that were removed from the original collection</param> + public static void InPlaceUpdateList<T>(IList<T> original, IEnumerable<T> newContents, out List<T> removedItems) + where T : class + { + removedItems = []; + // we're not changing newContents - stash this so we don't re-evaluate it every time var numberOfNew = newContents.Count(); // Short circuit - new contents should just be empty if (numberOfNew == 0) { + removedItems.AddRange(original); original.Clear(); return; } @@ -93,6 +117,7 @@ public partial class ListHelpers for (var k = i; k < j; k++) { // This item from the original list was not in the new list. Remove it. + removedItems.Add(original[i]); original.RemoveAt(i); } @@ -121,6 +146,7 @@ public partial class ListHelpers while (original.Count > numberOfNew) { // RemoveAtEnd + removedItems.Add(original[original.Count - 1]); original.RemoveAt(original.Count - 1); } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs index 91d715b509..d9bafa67ad 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs @@ -1,56 +1,18 @@ -// 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. namespace Microsoft.CommandPalette.Extensions.Toolkit; -public partial class ListItem : CommandItem, IListItem +public partial class ListItem : CommandItem, IListItem, IExtendedAttributesProvider { - private ITag[] _tags = []; - private IDetails? _details; + public virtual ITag[] Tags { get; set => SetProperty(ref field, value); } = []; - private string _section = string.Empty; - private string _textToSuggest = string.Empty; + public virtual IDetails? Details { get; set => SetProperty(ref field, value); } - public virtual ITag[] Tags - { - get => _tags; - set - { - _tags = value; - OnPropertyChanged(nameof(Tags)); - } - } + public virtual string Section { get; set => SetProperty(ref field, value); } = string.Empty; - public virtual IDetails? Details - { - get => _details; - set - { - _details = value; - OnPropertyChanged(nameof(Details)); - } - } - - public virtual string Section - { - get => _section; - set - { - _section = value; - OnPropertyChanged(nameof(Section)); - } - } - - public virtual string TextToSuggest - { - get => _textToSuggest; - set - { - _textToSuggest = value; - OnPropertyChanged(nameof(TextToSuggest)); - } - } + public virtual string TextToSuggest { get; set => SetProperty(ref field, value); } = string.Empty; public ListItem(ICommand command) : base(command) @@ -61,4 +23,9 @@ public partial class ListItem : CommandItem, IListItem : base(command) { } + + public ListItem() + : base() + { + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListPage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListPage.cs index 0eef7b44ee..9edaf798e8 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListPage.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListPage.cs @@ -8,85 +8,23 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class ListPage : Page, IListPage { - private string _placeholderText = string.Empty; - private string _searchText = string.Empty; - private bool _showDetails; - private bool _hasMore; - private IFilters? _filters; - private IGridProperties? _gridProperties; - private ICommandItem? _emptyContent; - public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged; - public virtual string PlaceholderText - { - get => _placeholderText; - set - { - _placeholderText = value; - OnPropertyChanged(nameof(PlaceholderText)); - } - } + private string _searchText = string.Empty; - public virtual string SearchText - { - get => _searchText; - set - { - _searchText = value; - OnPropertyChanged(nameof(SearchText)); - } - } + public virtual string PlaceholderText { get; set => SetProperty(ref field, value); } = string.Empty; - public virtual bool ShowDetails - { - get => _showDetails; - set - { - _showDetails = value; - OnPropertyChanged(nameof(ShowDetails)); - } - } + public virtual string SearchText { get => _searchText; set => SetProperty(ref _searchText, value); } - public virtual bool HasMoreItems - { - get => _hasMore; - set - { - _hasMore = value; - OnPropertyChanged(nameof(HasMoreItems)); - } - } + public virtual bool ShowDetails { get; set => SetProperty(ref field, value); } - public virtual IFilters? Filters - { - get => _filters; - set - { - _filters = value; - OnPropertyChanged(nameof(Filters)); - } - } + public virtual bool HasMoreItems { get; set => SetProperty(ref field, value); } - public virtual IGridProperties? GridProperties - { - get => _gridProperties; - set - { - _gridProperties = value; - OnPropertyChanged(nameof(GridProperties)); - } - } + public virtual IFilters? Filters { get; set => SetProperty(ref field, value); } - public virtual ICommandItem? EmptyContent - { - get => _emptyContent; - set - { - _emptyContent = value; - OnPropertyChanged(nameof(EmptyContent)); - } - } + public virtual IGridProperties? GridProperties { get; set => SetProperty(ref field, value); } + + public virtual ICommandItem? EmptyContent { get; set => SetProperty(ref field, value); } public virtual IListItem[] GetItems() => []; @@ -105,4 +43,9 @@ public partial class ListPage : Page, IListPage { } } + + protected void SetSearchNoUpdate(string newSearchText) + { + _searchText = newSearchText; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ManagedCsWin32/Shell32.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ManagedCsWin32/Shell32.cs new file mode 100644 index 0000000000..de8c8e0bf3 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ManagedCsWin32/Shell32.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; +using System.Runtime.InteropServices; + +namespace ManagedCsWin32; + +internal static partial class Shell32 +{ + [LibraryImport("SHELL32.dll", EntryPoint = "ShellExecuteExW", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static partial bool ShellExecuteEx(ref SHELLEXECUTEINFOW lpExecInfo); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct SHELLEXECUTEINFOW + { + public uint CbSize; + public uint FMask; + public IntPtr Hwnd; + + public IntPtr LpVerb; + public IntPtr LpFile; + public IntPtr LpParameters; + public IntPtr LpDirectory; + public int Show; + public IntPtr HInstApp; + public IntPtr LpIDList; + public IntPtr LpClass; + public IntPtr HkeyClass; + public uint DwHotKey; + public IntPtr HIconOrMonitor; + public IntPtr Process; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MarkdownContent.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MarkdownContent.cs index b2de535d9a..73e503094a 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MarkdownContent.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MarkdownContent.cs @@ -6,17 +6,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class MarkdownContent : BaseObservable, IMarkdownContent { - public virtual string Body - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Body)); - } - } - -= string.Empty; + public virtual string Body { get; set => SetProperty(ref field, value); } = string.Empty; public MarkdownContent() { diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MatchOption.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MatchOption.cs index 5f56c5a5b0..9f740e4ade 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MatchOption.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MatchOption.cs @@ -5,8 +5,6 @@ using System; using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Microsoft.Plugin.Program.UnitTests")] - namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class MatchOption diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MatchResult.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MatchResult.cs index ad61733f9e..3848065f25 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MatchResult.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MatchResult.cs @@ -4,8 +4,6 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Microsoft.Plugin.Program.UnitTests")] - namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class MatchResult diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MediumGridLayout.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MediumGridLayout.cs new file mode 100644 index 0000000000..18bef9465a --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/MediumGridLayout.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.CommandPalette.Extensions.Toolkit; + +public partial class MediumGridLayout : BaseObservable, IMediumGridLayout +{ + public virtual bool ShowTitle { get; set => SetProperty(ref field, value); } = true; +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Microsoft.CommandPalette.Extensions.Toolkit.csproj b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Microsoft.CommandPalette.Extensions.Toolkit.csproj index 95dc53b444..c1e995056f 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Microsoft.CommandPalette.Extensions.Toolkit.csproj +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Microsoft.CommandPalette.Extensions.Toolkit.csproj @@ -1,9 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\..\Common.Dotnet.AotCompatibility.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> - <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\Microsoft.CommandPalette.Extensions.Toolkit</OutputPath> + <RepoRoot>$(MSBuildThisFileDirectory)..\..\..\..\..\</RepoRoot> + <WindowsSdkPackageVersion>10.0.26100.57</WindowsSdkPackageVersion> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\Microsoft.CommandPalette.Extensions.Toolkit</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <ImplicitUsings>enable</ImplicitUsings> @@ -15,6 +17,12 @@ <ResolveAssemblyWarnOrErrorOnTargetArchitectureMismatch>None</ResolveAssemblyWarnOrErrorOnTargetArchitectureMismatch> </PropertyGroup> + <PropertyGroup Condition="'$(CIBuild)'=='true'"> + <SignAssembly>true</SignAssembly> + <DelaySign>true</DelaySign> + <AssemblyOriginatorKeyFile>$(RepoRoot).pipelines\272MSSharedLibSN2048.snk</AssemblyOriginatorKeyFile> + </PropertyGroup> + <PropertyGroup> <CsWinRTIncludes>Microsoft.CommandPalette.Extensions</CsWinRTIncludes> <CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir> @@ -32,18 +40,40 @@ <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.WindowsAppSDK" /> - <PackageReference Include="Microsoft.Web.WebView2" /> <PackageReference Include="System.Drawing.Common" /> + <PackageReference Include="ToolGood.Words.Pinyin" /> <!-- This line forces the WebView2 version used by Windows App SDK to be the one we expect from Directory.Packages.props . --> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.vcxproj" /> + <ProjectReference Include="..\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.vcxproj"> + <ReferenceOutputAssembly>False</ReferenceOutputAssembly> + <BuildProject>True</BuildProject> + </ProjectReference> + <CsWinRTInputs Include="$(RepoRoot)$(Platform)\$(Configuration)\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.winmd" /> + <!-- Native implementation DLL --> + <None Include="$(RepoRoot)$(Platform)\$(Configuration)\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.dll"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + + <ItemGroup> + <Content Include="$(RepoRoot)$(Platform)\$(Configuration)\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.winmd" Link="Microsoft.CommandPalette.Extensions.winmd" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> <ItemGroup> - <Content Include="$(SolutionDir)$(Platform)\$(Configuration)\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.winmd" Link="Microsoft.CommandPalette.Extensions.winmd" CopyToOutputDirectory="PreserveNewest" /> + <Compile Update="Properties\Resources.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>Resources.resx</DependentUpon> + </Compile> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <Generator>ResXFileCodeGenerator</Generator> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + </EmbeddedResource> </ItemGroup> <PropertyGroup> @@ -51,6 +81,16 @@ <PublishAot>True</PublishAot> <!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection --> - <WarningsNotAsErrors>IL2081</WarningsNotAsErrors> + <WarningsNotAsErrors>IL2081;$(WarningsNotAsErrors)</WarningsNotAsErrors> </PropertyGroup> + + <!-- InternalsVisibleTo with public key for CI builds (signed assemblies) --> + <ItemGroup Condition="'$(CIBuild)'=='true'"> + <InternalsVisibleTo Include="Microsoft.CommandPalette.Extensions.Toolkit.UnitTests, PublicKey=002400000c80000014010000060200000024000052534131000800000100010085aad0bef0688d1b994a0d78e1fd29fc24ac34ed3d3ac3fb9b3d0c48386ba834aa880035060a8848b2d8adf58e670ed20914be3681a891c9c8c01eef2ab22872547c39be00af0e6c72485d7cfd1a51df8947d36ceba9989106b58abe79e6a3e71a01ed6bdc867012883e0b1a4d35b1b5eeed6df21e401bb0c22f2246ccb69979dc9e61eef262832ed0f2064853725a75485fa8a3efb7e027319c86dec03dc3b1bca2b5081bab52a627b9917450dfad534799e1c7af58683bdfa135f1518ff1ea60e90d7b993a6c87fd3dd93408e35d1296f9a7f9a97c5db56c0f3cc25ad11e9777f94d138b3cea53b9a8331c2e6dcb8d2ea94e18bf1163ff112a22dbd92d429a" /> + </ItemGroup> + + <!-- InternalsVisibleTo without public key for local builds (unsigned assemblies) --> + <ItemGroup Condition="'$(CIBuild)'!='true'"> + <InternalsVisibleTo Include="Microsoft.CommandPalette.Extensions.Toolkit.UnitTests" /> + </ItemGroup> </Project> diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs index 73e8b0cd1c..ccbe6ed885 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.cs @@ -6,11 +6,23 @@ using System.Runtime.InteropServices; namespace Microsoft.CommandPalette.Extensions.Toolkit; -internal sealed class NativeMethods +internal static partial class NativeMethods { [DllImport("shell32.dll", CharSet = CharSet.Unicode)] internal static extern IntPtr SHGetFileInfo(string pszPath, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbFileInfo, uint uFlags); + [DllImport("shell32.dll", CharSet = CharSet.Auto)] + internal static extern IntPtr SHGetFileInfo(IntPtr pidl, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbSizeFileInfo, uint uFlags); + + [DllImport("shell32.dll")] + internal static extern int SHParseDisplayName([MarshalAs(UnmanagedType.LPWStr)] string pszName, IntPtr pbc, out IntPtr ppidl, uint sfgaoIn, out uint psfgaoOut); + + [DllImport("ole32.dll")] + internal static extern void CoTaskMemFree(IntPtr pv); + + [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] + internal static extern int SHLoadIndirectString(string pszSource, System.Text.StringBuilder pszOutBuf, int cchOutBuf, IntPtr ppvReserved); + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal struct SHFILEINFO { @@ -27,4 +39,64 @@ internal sealed class NativeMethods [DllImport("user32.dll", CharSet = CharSet.Unicode)] internal static extern bool DestroyIcon(IntPtr hIcon); + + [DllImport("Shell32.dll", CharSet = CharSet.Unicode)] + internal static extern int SHGetImageList(int iImageList, ref Guid riid, out IntPtr ppv); + + [DllImport("comctl32.dll", SetLastError = true)] + internal static extern int ImageList_GetIcon(IntPtr himl, int i, int flags); + + [LibraryImport("shlwapi.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)] + internal static unsafe partial int AssocQueryStringW( + AssocF flags, + AssocStr str, + string pszAssoc, + string? pszExtra, + char* pszOut, + ref uint pcchOut); + + // SHDefExtractIconW lets us ask for specific sizes (incl. 256) + // nIconSize: HIWORD = large size, LOWORD = small size + [LibraryImport("shell32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)] + internal static partial int SHDefExtractIconW( + string pszIconFile, + int iIndex, + uint uFlags, + out nint phiconLarge, + out nint phiconSmall, + int nIconSize); + + [Flags] + public enum AssocF : uint + { + None = 0, + IsProtocol = 0x00001000, + } + + public enum AssocStr + { + Command = 1, + Executable, + FriendlyDocName, + FriendlyAppName, + NoOpen, + ShellNewValue, + DDECommand, + DDEIfExec, + DDEApplication, + DDETopic, + InfoTip, + QuickTip, + TileInfo, + ContentType, + DefaultIcon, + ShellExtension, + DropTarget, + DelegateExecute, + SupportedUriProtocols, + ProgId, + AppId, + AppPublisher, + AppIconReference, // sometimes present, but DefaultIcon is most common + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.txt b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.txt index 942650356e..21a724cd2d 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.txt +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/NativeMethods.txt @@ -6,3 +6,6 @@ CoRevertToSelf SHGetKnownFolderPath KNOWN_FOLDER_FLAG GetCurrentPackageId + +SHOW_WINDOW_CMD +SEE_MASK_INVOKEIDLIST \ No newline at end of file diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Page.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Page.cs index 0dfd1d0bdd..6ac33a0c9e 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Page.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Page.cs @@ -6,37 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class Page : Command, IPage { - private bool _loading; - private string _title = string.Empty; - private OptionalColor _accentColor; + public virtual bool IsLoading { get; set => SetProperty(ref field, value); } - public virtual bool IsLoading - { - get => _loading; - set - { - _loading = value; - OnPropertyChanged(nameof(IsLoading)); - } - } + public virtual string Title { get; set => SetProperty(ref field, value); } = string.Empty; - public virtual string Title - { - get => _title; - set - { - _title = value; - OnPropertyChanged(nameof(Title)); - } - } - - public virtual OptionalColor AccentColor - { - get => _accentColor; - set - { - _accentColor = value; - OnPropertyChanged(nameof(AccentColor)); - } - } + public virtual OptionalColor AccentColor { get; set => SetProperty(ref field, value); } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ProgressState.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ProgressState.cs index 4cc7e5921b..8ed4d3e046 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ProgressState.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ProgressState.cs @@ -6,27 +6,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class ProgressState : BaseObservable, IProgressState { - private bool _isIndeterminate; + public virtual bool IsIndeterminate { get; set => SetProperty(ref field, value); } - private uint _progressPercent; - - public virtual bool IsIndeterminate - { - get => _isIndeterminate; - set - { - _isIndeterminate = value; - OnPropertyChanged(nameof(IsIndeterminate)); - } - } - - public virtual uint ProgressPercent - { - get => _progressPercent; - set - { - _progressPercent = value; - OnPropertyChanged(nameof(ProgressPercent)); - } - } + public virtual uint ProgressPercent { get; set => SetProperty(ref field, value); } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/PropChangedEventArgs.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/PropChangedEventArgs.cs index aab02590f3..ae6a031354 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/PropChangedEventArgs.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/PropChangedEventArgs.cs @@ -2,8 +2,6 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Windows.Foundation; - namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class PropChangedEventArgs : IPropChangedEventArgs diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs similarity index 55% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs index f3926fb774..e2fd310a60 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.Designer.cs @@ -8,7 +8,7 @@ // </auto-generated> //------------------------------------------------------------------------------ -namespace Microsoft.CmdPal.Ext.Indexer.Properties { +namespace Microsoft.CommandPalette.Extensions.Toolkit.Properties { using System; @@ -39,7 +39,7 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { 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.Ext.Indexer.Properties.Resources", typeof(Resources).Assembly); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CommandPalette.Extensions.Toolkit.Properties.Resources", typeof(Resources).Assembly); resourceMan = temp; } return resourceMan; @@ -61,137 +61,119 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties { } /// <summary> - /// Looks up a localized string similar to Browse. + /// Looks up a localized string similar to Invoke. /// </summary> - internal static string Indexer_Command_Browse { + internal static string AnonymousCommand_Invoke { get { - return ResourceManager.GetString("Indexer_Command_Browse", resourceCulture); + return ResourceManager.GetString("AnonymousCommand_Invoke", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy failed ({0}). Please try again.. + /// </summary> + internal static string copy_failed { + get { + return ResourceManager.GetString("copy_failed", resourceCulture); } } /// <summary> /// Looks up a localized string similar to Copy path. /// </summary> - internal static string Indexer_Command_CopyPath { + internal static string CopyPathTextCommand_Name { get { - return ResourceManager.GetString("Indexer_Command_CopyPath", resourceCulture); + return ResourceManager.GetString("CopyPathTextCommand_Name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copied path to clipboard. + /// </summary> + internal static string CopyPathTextCommand_Result { + get { + return ResourceManager.GetString("CopyPathTextCommand_Result", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copied to clipboard. + /// </summary> + internal static string CopyTextCommand_CopiedToClipboard { + get { + return ResourceManager.GetString("CopyTextCommand_CopiedToClipboard", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Copy. + /// </summary> + internal static string CopyTextCommand_Copy { + get { + return ResourceManager.GetString("CopyTextCommand_Copy", resourceCulture); } } /// <summary> /// Looks up a localized string similar to Open. /// </summary> - internal static string Indexer_Command_OpenFile { + internal static string OpenFileCommand_Name { get { - return ResourceManager.GetString("Indexer_Command_OpenFile", resourceCulture); + return ResourceManager.GetString("OpenFileCommand_Name", resourceCulture); } } /// <summary> /// Looks up a localized string similar to Open path in console. /// </summary> - internal static string Indexer_Command_OpenPathInConsole { + internal static string OpenInConsoleCommand_Name { get { - return ResourceManager.GetString("Indexer_Command_OpenPathInConsole", resourceCulture); + return ResourceManager.GetString("OpenInConsoleCommand_Name", resourceCulture); } } /// <summary> /// Looks up a localized string similar to Properties. /// </summary> - internal static string Indexer_Command_OpenProperties { + internal static string OpenPropertiesCommand_Name { get { - return ResourceManager.GetString("Indexer_Command_OpenProperties", resourceCulture); + return ResourceManager.GetString("OpenPropertiesCommand_Name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Open. + /// </summary> + internal static string OpenUrlCommand_Open { + get { + return ResourceManager.GetString("OpenUrlCommand_Open", resourceCulture); } } /// <summary> /// Looks up a localized string similar to Open with. /// </summary> - internal static string Indexer_Command_OpenWith { + internal static string OpenWithCommand_Name { get { - return ResourceManager.GetString("Indexer_Command_OpenWith", resourceCulture); + return ResourceManager.GetString("OpenWithCommand_Name", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Settings. + /// </summary> + internal static string Settings { + get { + return ResourceManager.GetString("Settings", resourceCulture); } } /// <summary> /// Looks up a localized string similar to Show in folder. /// </summary> - internal static string Indexer_Command_ShowInFolder { + internal static string ShowFileInFolderCommand_ShowInFolder { get { - return ResourceManager.GetString("Indexer_Command_ShowInFolder", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to This file doesn't exist. - /// </summary> - internal static string Indexer_File_Does_Not_Exist { - get { - return ResourceManager.GetString("Indexer_File_Does_Not_Exist", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to This is a file, not a folder. - /// </summary> - internal static string Indexer_File_Is_File_Not_Folder { - get { - return ResourceManager.GetString("Indexer_File_Is_File_Not_Folder", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Find file from path. - /// </summary> - internal static string Indexer_Find_Path_fallback_display_title { - get { - return ResourceManager.GetString("Indexer_Find_Path_fallback_display_title", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to This folder is empty. - /// </summary> - internal static string Indexer_Folder_Is_Empty { - get { - return ResourceManager.GetString("Indexer_Folder_Is_Empty", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Search for files and folders.... - /// </summary> - internal static string Indexer_PlaceholderText { - get { - return ResourceManager.GetString("Indexer_PlaceholderText", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Search files on this device. - /// </summary> - internal static string Indexer_Subtitle { - get { - return ResourceManager.GetString("Indexer_Subtitle", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to Search files. - /// </summary> - internal static string Indexer_Title { - get { - return ResourceManager.GetString("Indexer_Title", resourceCulture); - } - } - - /// <summary> - /// Looks up a localized string similar to File search. - /// </summary> - internal static string IndexerCommandsProvider_DisplayName { - get { - return ResourceManager.GetString("IndexerCommandsProvider_DisplayName", resourceCulture); + return ResourceManager.GetString("ShowFileInFolderCommand_ShowInFolder", resourceCulture); } } } diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.resx similarity index 81% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx rename to src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.resx index 0f0f59a650..40fdb2c813 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Properties/Resources.resx +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Properties/Resources.resx @@ -117,49 +117,44 @@ <resheader name="writer"> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> </resheader> - <data name="IndexerCommandsProvider_DisplayName" xml:space="preserve"> - <value>File search</value> + <data name="AnonymousCommand_Invoke" xml:space="preserve"> + <value>Invoke</value> </data> - <data name="Indexer_Command_Browse" xml:space="preserve"> - <value>Browse</value> + <data name="CopyTextCommand_Copy" xml:space="preserve"> + <value>Copy</value> </data> - <data name="Indexer_Command_CopyPath" xml:space="preserve"> + <data name="CopyTextCommand_CopiedToClipboard" xml:space="preserve"> + <value>Copied to clipboard</value> + </data> + <data name="CopyPathTextCommand_Name" xml:space="preserve"> <value>Copy path</value> </data> - <data name="Indexer_Command_OpenFile" xml:space="preserve"> + <data name="CopyPathTextCommand_Result" xml:space="preserve"> + <value>Copied path to clipboard</value> + </data> + <data name="OpenUrlCommand_Open" xml:space="preserve"> <value>Open</value> </data> - <data name="Indexer_Command_OpenPathInConsole" xml:space="preserve"> - <value>Open path in console</value> + <data name="Settings" xml:space="preserve"> + <value>Settings</value> </data> - <data name="Indexer_Command_OpenProperties" xml:space="preserve"> - <value>Properties</value> - </data> - <data name="Indexer_Command_OpenWith" xml:space="preserve"> - <value>Open with</value> - </data> - <data name="Indexer_Command_ShowInFolder" xml:space="preserve"> + <data name="ShowFileInFolderCommand_ShowInFolder" xml:space="preserve"> <value>Show in folder</value> </data> - <data name="Indexer_File_Does_Not_Exist" xml:space="preserve"> - <value>This file doesn't exist</value> + <data name="OpenInConsoleCommand_Name" xml:space="preserve"> + <value>Open path in console</value> </data> - <data name="Indexer_File_Is_File_Not_Folder" xml:space="preserve"> - <value>This is a file, not a folder</value> + <data name="OpenPropertiesCommand_Name" xml:space="preserve"> + <value>Properties</value> </data> - <data name="Indexer_Find_Path_fallback_display_title" xml:space="preserve"> - <value>Find file from path</value> + <data name="OpenFileCommand_Name" xml:space="preserve"> + <value>Open</value> </data> - <data name="Indexer_Folder_Is_Empty" xml:space="preserve"> - <value>This folder is empty</value> + <data name="OpenWithCommand_Name" xml:space="preserve"> + <value>Open with</value> </data> - <data name="Indexer_PlaceholderText" xml:space="preserve"> - <value>Search for files and folders...</value> - </data> - <data name="Indexer_Subtitle" xml:space="preserve"> - <value>Search files on this device</value> - </data> - <data name="Indexer_Title" xml:space="preserve"> - <value>Search files</value> + <data name="copy_failed" xml:space="preserve"> + <value>Copy failed ({0}). Please try again.</value> + <comment>{0} is the error message</comment> </data> </root> \ No newline at end of file diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Section.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Section.cs new file mode 100644 index 0000000000..a077683172 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Section.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections; + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public sealed partial class Section : IEnumerable<IListItem> +{ + public IListItem[] Items { get; set; } = []; + + public string SectionTitle { get; set; } = string.Empty; + + public Section(string sectionName, IListItem[] items) + { + SectionTitle = sectionName; + var listItems = items.ToList(); + + if (listItems.Count > 0) + { + listItems.Insert(0, CreateSectionListItem()); + Items = [.. listItems]; + } + } + + public Section() + { + } + + private Separator CreateSectionListItem() + { + return new Separator(SectionTitle); + } + + public IEnumerator<IListItem> GetEnumerator() => Items.ToList().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Separator.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Separator.cs new file mode 100644 index 0000000000..259f17f34f --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Separator.cs @@ -0,0 +1,43 @@ +// 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.CommandPalette.Extensions.Toolkit; + +public partial class Separator : BaseObservable, IListItem, ISeparatorContextItem, ISeparatorFilterItem +{ + public IDetails? Details => null; + + public string? Section { get; private set; } + + public ITag[]? Tags => null; + + public string? TextToSuggest => null; + + public ICommand? Command => null; + + public IIconInfo? Icon => null; + + public IContextItem[]? MoreCommands => null; + + public string? Subtitle => null; + + public string? Title + { + get => Section; + set + { + if (Section != value) + { + Section = value; + OnPropertyChanged(); + OnPropertyChanged(Section); + } + } + } + + public Separator(string? title = "") + { + Section = title ?? string.Empty; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Settings.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Settings.cs index 39d3b1f855..fbd74ce694 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Settings.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Settings.cs @@ -41,7 +41,7 @@ public sealed partial class Settings : ICommandSettings .Values .Where(s => s is ISettingsForm) .Select(s => s as ISettingsForm) - .Where(s => s != null) + .Where(s => s is not null) .Select(s => s!); var bodies = string.Join(",", settings @@ -77,7 +77,7 @@ public sealed partial class Settings : ICommandSettings .Values .Where(s => s is ISettingsForm) .Select(s => s as ISettingsForm) - .Where(s => s != null) + .Where(s => s is not null) .Select(s => s!); var content = string.Join(",\n", settings.Select(s => s.ToState())); return $"{{\n{content}\n}}"; @@ -86,7 +86,7 @@ public sealed partial class Settings : ICommandSettings public void Update(string data) { var formInput = JsonNode.Parse(data)?.AsObject(); - if (formInput == null) + if (formInput is null) { return; } @@ -116,8 +116,12 @@ public sealed partial class Settings : ICommandSettings public SettingsContentPage(Settings settings) { _settings = settings; - Name = "Settings"; + Name = Properties.Resources.Settings; Icon = new IconInfo("\uE713"); // Settings icon + + // When our settings change, make sure to let CmdPal know to + // retrieve the new forms + _settings.SettingsChanged += (s, e) => RaiseItemsChanged(); } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SettingsForm.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SettingsForm.cs index fa23cd8f0d..2bab5e78dc 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SettingsForm.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SettingsForm.cs @@ -19,11 +19,17 @@ public partial class SettingsForm : FormContent public override ICommandResult SubmitForm(string inputs, string data) { var formInput = JsonNode.Parse(inputs)?.AsObject(); - if (formInput == null) + if (formInput is null) { return CommandResult.KeepOpen(); } + // Re-render the current value of the settings to a card. The + // SettingsContentPage will raise an ItemsChanged in its own + // SettingsChange handler, so we need to be prepared to return the + // current settings value. + TemplateJson = _settings.ToFormJson(); + _settings.Update(inputs); _settings.RaiseSettingsChanged(); diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs index c75c59ba68..3ddbcc5ad6 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ShellHelpers.cs @@ -4,11 +4,68 @@ using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Win32; namespace Microsoft.CommandPalette.Extensions.Toolkit; public static class ShellHelpers { + /// <summary> + /// These are the executable file extensions that Windows Shell recognizes. Unlike CMD/PowerShell, + /// Shell does not use PATHEXT, but has a magic fixed list. + /// </summary> + public static string[] ExecutableExtensions { get; } = [".PIF", ".COM", ".EXE", ".BAT", ".CMD"]; + + /// <summary> + /// Determines whether the specified file name represents an executable file + /// by examining its extension against the known list of Windows Shell + /// executable extensions (a fixed list that does not honor PATHEXT). + /// </summary> + /// <param name="fileName">The file name (with or without path) whose extension will be evaluated.</param> + /// <returns> + /// True if the file name has an extension that matches one of the recognized executable + /// extensions; otherwise, false. Returns false for null, empty, or whitespace input. + /// </returns> + public static bool IsExecutableFile(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return false; + } + + var fileExtension = Path.GetExtension(fileName); + return IsExecutableExtension(fileExtension); + } + + /// <summary> + /// Determines whether the provided file extension (including the leading dot) + /// is one of the Windows Shell recognized executable extensions. + /// </summary> + /// <param name="fileExtension">The file extension to test. Should include the leading dot (e.g. ".exe").</param> + /// <returns> + /// True if the extension matches (case-insensitive) one of the known executable + /// extensions; false if it does not match or if the input is null/whitespace. + /// </returns> + public static bool IsExecutableExtension(string fileExtension) + { + if (string.IsNullOrWhiteSpace(fileExtension)) + { + // Shell won't execute app with a filename without an extension + return false; + } + + foreach (var extension in ExecutableExtensions) + { + if (string.Equals(fileExtension, extension, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + public static bool OpenCommandInShell(string? path, string? pattern, string? arguments, string? workingDir = null, ShellRunAsType runAs = ShellRunAsType.None, bool runWithHiddenWindow = false) { if (string.IsNullOrEmpty(pattern)) @@ -59,4 +116,166 @@ public static class ShellHelpers Administrator, OtherUser, } + + /// <summary> + /// Parses the input string to extract the executable and its arguments. + /// </summary> + public static void ParseExecutableAndArgs(string input, out string executable, out string arguments) + { + input = input.Trim(); + executable = string.Empty; + arguments = string.Empty; + + if (string.IsNullOrEmpty(input)) + { + return; + } + + if (input.StartsWith("\"", System.StringComparison.InvariantCultureIgnoreCase)) + { + // Find the closing quote + var closingQuoteIndex = input.IndexOf('\"', 1); + if (closingQuoteIndex > 0) + { + executable = input.Substring(1, closingQuoteIndex - 1); + if (closingQuoteIndex + 1 < input.Length) + { + arguments = input.Substring(closingQuoteIndex + 1).TrimStart(); + } + } + } + else + { + // Executable ends at first space + var firstSpaceIndex = input.IndexOf(' '); + if (firstSpaceIndex > 0) + { + executable = input.Substring(0, firstSpaceIndex); + arguments = input[(firstSpaceIndex + 1)..].TrimStart(); + } + else + { + executable = input; + } + } + } + + /// <summary> + /// Checks if a file exists somewhere in the PATH. + /// If it exists, returns the full path to the file in the out parameter. + /// If it does not exist, returns false and the out parameter is set to an empty string. + /// <param name="filename">The name of the file to check.</param> + /// <param name="fullPath">The full path to the file if it exists; otherwise an empty string.</param> + /// <param name="token">An optional cancellation token to cancel the operation.</param> + /// <returns>True if the file exists in the PATH; otherwise false.</returns> + /// </summary> + public static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null) + { + fullPath = string.Empty; + + if (File.Exists(filename)) + { + token?.ThrowIfCancellationRequested(); + fullPath = Path.GetFullPath(filename); + return true; + } + else + { + var values = Environment.GetEnvironmentVariable("PATH"); + if (values is not null) + { + foreach (var path in values.Split(Path.PathSeparator)) + { + var path1 = Path.Combine(path, filename); + if (File.Exists(path1)) + { + fullPath = Path.GetFullPath(path1); + return true; + } + + token?.ThrowIfCancellationRequested(); + + var path2 = Path.Combine(path, filename + ".exe"); + if (File.Exists(path2)) + { + fullPath = Path.GetFullPath(path2); + return true; + } + + token?.ThrowIfCancellationRequested(); + } + } + + return false; + } + } + + private static bool TryResolveFromAppPaths(string name, [NotNullWhen(true)] out string? fullPath) + { + try + { + fullPath = TryHiveView(RegistryHive.CurrentUser, RegistryView.Registry64) ?? + TryHiveView(RegistryHive.CurrentUser, RegistryView.Registry32) ?? + TryHiveView(RegistryHive.LocalMachine, RegistryView.Registry64) ?? + TryHiveView(RegistryHive.LocalMachine, RegistryView.Registry32) ?? string.Empty; + + return !string.IsNullOrEmpty(fullPath); + + string? TryHiveView(RegistryHive hive, RegistryView view) + { + using var baseKey = RegistryKey.OpenBaseKey(hive, view); + using var k1 = baseKey.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{name}.exe"); + var val = (k1?.GetValue(null) as string)?.Trim('"'); + if (!string.IsNullOrEmpty(val)) + { + return val; + } + + // Some vendors create keys without .exe in the subkey name; check that too. + using var k2 = baseKey.OpenSubKey($@"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{name}"); + return (k2?.GetValue(null) as string)?.Trim('"'); + } + } + catch (Exception) + { + fullPath = null; + return false; + } + } + + /// <summary> + /// Mimics Windows Shell behavior to resolve an executable name to a full path. + /// </summary> + /// <param name="name"></param> + /// <param name="fullPath"></param> + /// <returns></returns> + public static bool TryResolveExecutableAsShell(string name, out string fullPath) + { + // First check if we can find the file in the registry + if (TryResolveFromAppPaths(name, out var path)) + { + fullPath = path; + return true; + } + + // If the name does not have an extension, try adding common executable extensions + // this order mimics Windows Shell behavior + // Note: HasExtension check follows Shell behavior, but differs from the + // Start Menu search results, which will offer file name with extensions + ".exe" + var nameHasExtension = Path.HasExtension(name); + if (!nameHasExtension) + { + foreach (var ext in ExecutableExtensions) + { + var nameWithExt = name + ext; + if (FileExistInPath(nameWithExt, out fullPath)) + { + return true; + } + } + } + + fullPath = string.Empty; + return false; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SmallGridLayout.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SmallGridLayout.cs new file mode 100644 index 0000000000..f8d7f63023 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/SmallGridLayout.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +public partial class SmallGridLayout : BaseObservable, ISmallGridLayout +{ +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StatusMessage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StatusMessage.cs index 360cf3db5f..6e6d1f5bf2 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StatusMessage.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StatusMessage.cs @@ -6,37 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class StatusMessage : BaseObservable, IStatusMessage { - public virtual string Message - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Message)); - } - } + public virtual string Message { get; set => SetProperty(ref field, value); } = string.Empty; -= string.Empty; + public virtual MessageState State { get; set => SetProperty(ref field, value); } = MessageState.Info; - public virtual MessageState State - { - get; - set - { - field = value; - OnPropertyChanged(nameof(State)); - } - } - -= MessageState.Info; - - public virtual IProgressState? Progress - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Progress)); - } - } + public virtual IProgressState? Progress { get; set => SetProperty(ref field, value); } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Tag.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Tag.cs index 3a2d797f55..8f724a6330 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Tag.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Tag.cs @@ -6,63 +6,15 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class Tag : BaseObservable, ITag { - private OptionalColor _foreground; - private OptionalColor _background; - private string _text = string.Empty; + public virtual OptionalColor Foreground { get; set => SetProperty(ref field, value); } - public virtual OptionalColor Foreground - { - get => _foreground; - set - { - _foreground = value; - OnPropertyChanged(nameof(Foreground)); - } - } + public virtual OptionalColor Background { get; set => SetProperty(ref field, value); } - public virtual OptionalColor Background - { - get => _background; - set - { - _background = value; - OnPropertyChanged(nameof(Background)); - } - } + public virtual IIconInfo Icon { get; set => SetProperty(ref field, value); } = new IconInfo(); - public virtual IIconInfo Icon - { - get; - set - { - field = value; - OnPropertyChanged(nameof(Icon)); - } - } + public virtual string Text { get; set => SetProperty(ref field, value); } = string.Empty; -= new IconInfo(); - - public virtual string Text - { - get => _text; - set - { - _text = value; - OnPropertyChanged(nameof(Text)); - } - } - - public virtual string ToolTip - { - get; - set - { - field = value; - OnPropertyChanged(nameof(ToolTip)); - } - } - -= string.Empty; + public virtual string ToolTip { get; set => SetProperty(ref field, value); } = string.Empty; public Tag() { @@ -70,6 +22,6 @@ public partial class Tag : BaseObservable, ITag public Tag(string text) { - _text = text; + Text = text; } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/TextSetting.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/TextSetting.cs index aaa8c2fbee..7cf9147159 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/TextSetting.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/TextSetting.cs @@ -50,7 +50,7 @@ public partial class TextSetting : Setting<string> public override void Update(JsonObject payload) { // If the key doesn't exist in the payload, don't do anything - if (payload[Key] != null) + if (payload[Key] is not null) { Value = payload[Key]?.GetValue<string>(); } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs index e71360bcd4..6231f3ad72 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ThumbnailHelper.cs @@ -2,16 +2,18 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Drawing; using System.Globalization; using System.Runtime.InteropServices; +using System.Text; using Windows.Storage; using Windows.Storage.FileProperties; using Windows.Storage.Streams; namespace Microsoft.CommandPalette.Extensions.Toolkit; -public class ThumbnailHelper +public static class ThumbnailHelper { private static readonly string[] ImageExtensions = [ @@ -24,30 +26,53 @@ public class ThumbnailHelper ".ico", ]; - public static Task<IRandomAccessStream?> GetThumbnail(string path) + public static async Task<IRandomAccessStream?> GetThumbnail(string path, bool jumbo = false) { var extension = Path.GetExtension(path).ToLower(CultureInfo.InvariantCulture); + var isImage = ImageExtensions.Contains(extension); + if (isImage) + { + try + { + var result = await GetImageThumbnailAsync(path, jumbo); + if (result is not null) + { + return result; + } + } + catch (Exception) + { + // ignore and fall back to icon + } + } + try { - if (ImageExtensions.Contains(extension)) - { - return GetImageThumbnailAsync(path); - } - else - { - return GetFileIconStream(path); - } + return await GetFileIconStream(path, jumbo); } catch (Exception) { + // ignore and return null } - return Task.FromResult<IRandomAccessStream?>(null); + return null; } - private const uint SHGFIICON = 0x000000100; - private const uint SHGFILARGEICON = 0x000000000; + // these are windows constants and mangling them is goofy +#pragma warning disable SA1310 // Field names should not contain underscore +#pragma warning disable SA1306 // Field names should begin with lower-case letter + private const uint SHGFI_ICON = 0x000000100; + private const uint SHGFI_LARGEICON = 0x000000000; + private const uint SHGFI_SHELLICONSIZE = 0x000000004; + private const uint SHGFI_SYSICONINDEX = 0x000004000; + private const uint SHGFI_PIDL = 0x000000008; + private const int SHIL_JUMBO = 4; + private const int ILD_TRANSPARENT = 1; +#pragma warning restore SA1306 // Field names should begin with lower-case letter +#pragma warning restore SA1310 // Field names should not contain underscore + // This will call DestroyIcon on the hIcon passed in. + // Duplicate it if you need it again after this. private static MemoryStream GetMemoryStreamFromIcon(IntPtr hIcon) { var memoryStream = new MemoryStream(); @@ -65,34 +90,346 @@ public class ThumbnailHelper return memoryStream; } - private static async Task<IRandomAccessStream?> GetFileIconStream(string filePath) + private static async Task<IRandomAccessStream?> GetFileIconStream(string filePath, bool jumbo) { - var shinfo = default(NativeMethods.SHFILEINFO); - var hr = NativeMethods.SHGetFileInfo(filePath, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFIICON | SHGFILARGEICON); + return await TryExtractUsingPIDL(filePath, jumbo) + ?? await GetFileIconStreamUsingFilePath(filePath, jumbo); + } - if (hr == 0 || shinfo.hIcon == 0) + private static async Task<IRandomAccessStream?> TryExtractUsingPIDL(string shellPath, bool jumbo) + { + IntPtr pidl = 0; + try + { + var hr = NativeMethods.SHParseDisplayName(shellPath, IntPtr.Zero, out pidl, 0, out _); + if (hr != 0 || pidl == IntPtr.Zero) + { + return null; + } + + nint hIcon = 0; + if (jumbo) + { + hIcon = GetLargestIcon(pidl); + } + + if (hIcon == 0) + { + var shinfo = default(NativeMethods.SHFILEINFO); + var fileInfoResult = NativeMethods.SHGetFileInfo(pidl, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_ICON | SHGFI_SHELLICONSIZE | SHGFI_LARGEICON | SHGFI_PIDL); + if (fileInfoResult != IntPtr.Zero && shinfo.hIcon != IntPtr.Zero) + { + hIcon = shinfo.hIcon; + } + } + + if (hIcon == 0) + { + return null; + } + + return await FromHIconToStream(hIcon); + } + catch (Exception) + { + return null; + } + finally + { + if (pidl != IntPtr.Zero) + { + NativeMethods.CoTaskMemFree(pidl); + } + } + } + + private static async Task<IRandomAccessStream?> GetFileIconStreamUsingFilePath(string filePath, bool jumbo) + { + nint hIcon = 0; + + // If requested, look up the Jumbo icon + if (jumbo) + { + hIcon = GetLargestIcon(filePath); + } + + // If we didn't want the JUMBO icon, or didn't find it, fall back to + // the normal icon lookup + if (hIcon == 0) + { + var shinfo = default(NativeMethods.SHFILEINFO); + + var hr = NativeMethods.SHGetFileInfo(filePath, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_ICON | SHGFI_SHELLICONSIZE); + + if (hr == 0 || shinfo.hIcon == 0) + { + return null; + } + + hIcon = shinfo.hIcon; + } + + if (hIcon == 0) { return null; } + return await FromHIconToStream(hIcon); + } + + private static async Task<IRandomAccessStream?> GetImageThumbnailAsync(string filePath, bool jumbo) + { + var file = await StorageFile.GetFileFromPathAsync(filePath); + var thumbnail = await file.GetThumbnailAsync( + jumbo ? ThumbnailMode.SingleItem : ThumbnailMode.ListView, + jumbo ? 64u : 20u); + return thumbnail; + } + + [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Win32 Naming/Private")] + private static readonly Guid IID_IImageList = new Guid("46EB5926-582E-4017-9FDF-E8998DAA0950"); + + private static nint GetLargestIcon(string path) + { + var shinfo = default(NativeMethods.SHFILEINFO); + NativeMethods.SHGetFileInfo(path, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_SYSICONINDEX); + + var hIcon = IntPtr.Zero; + var iID_IImageList = IID_IImageList; + + if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out var imageListPtr) == 0 && imageListPtr != IntPtr.Zero) + { + hIcon = NativeMethods.ImageList_GetIcon(imageListPtr, shinfo.iIcon, ILD_TRANSPARENT); + } + + return hIcon; + } + + private static nint GetLargestIcon(IntPtr pidl) + { + var shinfo = default(NativeMethods.SHFILEINFO); + NativeMethods.SHGetFileInfo(pidl, 0, ref shinfo, (uint)Marshal.SizeOf(shinfo), SHGFI_SYSICONINDEX | SHGFI_PIDL); + + var hIcon = IntPtr.Zero; + var iID_IImageList = IID_IImageList; + + if (NativeMethods.SHGetImageList(SHIL_JUMBO, ref iID_IImageList, out var imageListPtr) == 0 && imageListPtr != IntPtr.Zero) + { + hIcon = NativeMethods.ImageList_GetIcon(imageListPtr, shinfo.iIcon, ILD_TRANSPARENT); + } + + return hIcon; + } + + /// <summary> + /// Get an icon stream for a registered URI protocol (e.g. "mailto:", "http:", "steam:"). + /// </summary> + public static async Task<IRandomAccessStream?> GetProtocolIconStream(string protocol, bool jumbo) + { + // 1) Ask the shell for the protocol's default icon "path,index" + var iconRef = QueryProtocolIconReference(protocol); + if (string.IsNullOrWhiteSpace(iconRef)) + { + return null; + } + + // Indirect reference: + if (iconRef.StartsWith('@')) + { + if (TryLoadIndirectString(iconRef, out var expanded) && !string.IsNullOrWhiteSpace(expanded)) + { + iconRef = expanded; + } + } + + // 2) Handle image files from a store app + if (File.Exists(iconRef)) + { + try + { + var file = await StorageFile.GetFileFromPathAsync(iconRef); + var thumbnail = await file.GetThumbnailAsync( + jumbo ? ThumbnailMode.SingleItem : ThumbnailMode.ListView, + jumbo ? 64u : 20u); + return thumbnail; + } + catch (Exception) + { + return null; + } + } + + // 3) Parse "path,index" (index can be negative) + if (!TryParseIconReference(iconRef, out var path, out var index)) + { + return null; + } + + // if it's and .exe and without a path, let's find on path: + if (Path.GetExtension(path).Equals(".exe", StringComparison.OrdinalIgnoreCase) && !Path.IsPathRooted(path)) + { + var paths = Environment.GetEnvironmentVariable("PATH")?.Split(';') ?? []; + foreach (var p in paths) + { + var candidate = Path.Combine(p, path); + if (File.Exists(candidate)) + { + path = candidate; + break; + } + } + } + + // 3) Extract an HICON, preferably ~256px when jumbo==true + var hIcon = ExtractIconHandle(path, index, jumbo); + if (hIcon == 0) + { + return null; + } + + return await FromHIconToStream(hIcon); + } + + private static bool TryLoadIndirectString(string input, out string? output) + { + var outBuffer = new StringBuilder(1024); + var hr = NativeMethods.SHLoadIndirectString(input, outBuffer, outBuffer.Capacity, IntPtr.Zero); + if (hr == 0) + { + output = outBuffer.ToString(); + return !string.IsNullOrWhiteSpace(output); + } + + output = null; + return false; + } + + private static async Task<IRandomAccessStream?> FromHIconToStream(IntPtr hIcon) + { var stream = new InMemoryRandomAccessStream(); - using var memoryStream = GetMemoryStreamFromIcon(shinfo.hIcon); + using var memoryStream = GetMemoryStreamFromIcon(hIcon); // this will DestroyIcon hIcon using var outputStream = stream.GetOutputStreamAt(0); - using (var dataWriter = new DataWriter(outputStream)) - { - dataWriter.WriteBytes(memoryStream.ToArray()); - await dataWriter.StoreAsync(); - await dataWriter.FlushAsync(); - } + using var dataWriter = new DataWriter(outputStream); + + dataWriter.WriteBytes(memoryStream.ToArray()); + await dataWriter.StoreAsync(); + await dataWriter.FlushAsync(); return stream; } - private static async Task<IRandomAccessStream?> GetImageThumbnailAsync(string filePath) + private static string? QueryProtocolIconReference(string protocol) { - var file = await StorageFile.GetFileFromPathAsync(filePath); - var thumbnail = await file.GetThumbnailAsync(ThumbnailMode.PicturesView); - return thumbnail; + // First try DefaultIcon (most widely populated for protocols) + // If you want to try AppIconReference as a fallback, you can repeat with AssocStr.AppIconReference. + var iconReference = AssocQueryStringSafe(NativeMethods.AssocStr.DefaultIcon, protocol); + if (!string.IsNullOrWhiteSpace(iconReference)) + { + return iconReference; + } + + // Optional fallback – some registrations use AppIconReference: + iconReference = AssocQueryStringSafe(NativeMethods.AssocStr.AppIconReference, protocol); + return iconReference; + + static unsafe string? AssocQueryStringSafe(NativeMethods.AssocStr what, string protocol) + { + uint cch = 0; + + // First call: get required length (incl. null) + _ = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, null, ref cch); + if (cch == 0) + { + return null; + } + + // Small buffers on stack; large on heap + var span = cch <= 512 ? stackalloc char[(int)cch] : new char[(int)cch]; + + fixed (char* p = span) + { + var hr = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, p, ref cch); + if (hr != 0 || cch == 0) + { + return null; + } + + // cch includes the null terminator; slice it off + var len = (int)cch - 1; + if (len < 0) + { + len = 0; + } + + return new string(span.Slice(0, len)); + } + } + } + + private static bool TryParseIconReference(string iconRef, out string path, out int index) + { + // Typical shapes: + // "C:\Program Files\Outlook\OUTLOOK.EXE,-1" + // "shell32.dll,21" + // "\"C:\Some Path\app.dll\",-325" + + // If there's no comma, assume ",0" + index = 0; + path = iconRef.Trim(); + + // Split only on the last comma so paths with commas still work + var lastComma = path.LastIndexOf(','); + if (lastComma >= 0) + { + var idxPart = path[(lastComma + 1)..].Trim(); + path = path[..lastComma].Trim(); + _ = int.TryParse(idxPart, out index); + } + + // Trim quotes around path + path = path.Trim('"'); + if (path.Length > 1 && path[0] == '"' && path[^1] == '"') + { + path = path.Substring(1, path.Length - 2); + } + + // Basic sanity + return !string.IsNullOrWhiteSpace(path); + } + + private static nint ExtractIconHandle(string path, int index, bool jumbo) + { + // Request sizes: LOWORD=small, HIWORD=large. + // Ask for 256 when jumbo, else fall back to 32/16. + var small = jumbo ? 256 : 16; + var large = jumbo ? 256 : 32; + var sizeParam = (large << 16) | (small & 0xFFFF); + + var hr = NativeMethods.SHDefExtractIconW(path, index, 0, out var hLarge, out var hSmall, sizeParam); + if (hr == 0 && hLarge != 0) + { + return hLarge; + } + + if (hr == 0 && hSmall != 0) + { + return hSmall; + } + + // Final fallback: try 32/16 explicitly in case the resource can’t upscale + sizeParam = (32 << 16) | 16; + hr = NativeMethods.SHDefExtractIconW(path, index, 0, out hLarge, out hSmall, sizeParam); + if (hr == 0 && hLarge != 0) + { + return hLarge; + } + + if (hr == 0 && hSmall != 0) + { + return hSmall; + } + + return 0; } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs index c5e1838608..87beb49075 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ToggleSetting.cs @@ -26,15 +26,69 @@ public sealed class ToggleSetting : Setting<bool> public override Dictionary<string, object> ToDictionary() { - return new Dictionary<string, object> + var items = new List<Dictionary<string, object>>(); + + if (!string.IsNullOrEmpty(Label)) { - { "type", "Input.Toggle" }, - { "title", Label }, - { "id", Key }, - { "label", Description }, - { "value", JsonSerializer.Serialize(Value, JsonSerializationContext.Default.Boolean) }, - { "isRequired", IsRequired }, - { "errorMessage", ErrorMessage }, + items.Add( + new() + { + { "type", "TextBlock" }, + { "text", Label }, + { "wrap", true }, + }); + } + + if (!(string.IsNullOrEmpty(Description) || string.Equals(Description, Label, StringComparison.OrdinalIgnoreCase))) + { + items.Add( + new() + { + { "type", "TextBlock" }, + { "text", Description }, + { "isSubtle", true }, + { "size", "Small" }, + { "spacing", "Small" }, + { "wrap", true }, + }); + } + + return new() + { + { "type", "ColumnSet" }, + { + "columns", new List<Dictionary<string, object>> + { + new() + { + { "type", "Column" }, + { "width", "20px" }, + { + "items", new List<Dictionary<string, object>> + { + new() + { + { "type", "Input.Toggle" }, + { "title", " " }, + { "id", Key }, + { "value", JsonSerializer.Serialize(Value, JsonSerializationContext.Default.Boolean) }, + { "isRequired", IsRequired }, + { "errorMessage", ErrorMessage }, + }, + } + }, + { "verticalContentAlignment", "Center" }, + }, + new() + { + { "type", "Column" }, + { "width", "stretch" }, + { "items", items }, + { "verticalContentAlignment", "Center" }, + }, + } + }, + { "spacing", "Medium" }, }; } @@ -43,7 +97,7 @@ public sealed class ToggleSetting : Setting<bool> public override void Update(JsonObject payload) { // If the key doesn't exist in the payload, don't do anything - if (payload[Key] != null) + if (payload[Key] is not null) { // Adaptive cards returns boolean values as a string "true"/"false", cause of course. var strFromJson = payload[Key]?.GetValue<string>() ?? string.Empty; diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/TreeContent.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/TreeContent.cs index dfb2b9b447..8a658df068 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/TreeContent.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/TreeContent.cs @@ -8,19 +8,11 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; public partial class TreeContent : BaseObservable, ITreeContent { + public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged; + public IContent[] Children { get; set; } = []; - public virtual IContent? RootContent - { - get; - set - { - field = value; - OnPropertyChanged(nameof(RootContent)); - } - } - - public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged; + public virtual IContent? RootContent { get; set => SetProperty(ref field, value); } public virtual IContent[] GetChildren() => Children; diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Utilities.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Utilities.cs index ca88c11c0f..50e56bdefb 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Utilities.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Utilities.cs @@ -37,7 +37,7 @@ public class Utilities var FOLDERID_LocalAppData = new Guid("F1B32785-6FBA-4FCF-9D55-7B8E7F157091"); var hr = PInvoke.SHGetKnownFolderPath( FOLDERID_LocalAppData, - (uint)KNOWN_FOLDER_FLAG.KF_FLAG_FORCE_APP_DATA_REDIRECTION, + KNOWN_FOLDER_FLAG.KF_FLAG_FORCE_APP_DATA_REDIRECTION, null, out var localAppDataFolder); diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/WeakEventListener`3.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/WeakEventListener`3.cs new file mode 100644 index 0000000000..cd3a62a079 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/WeakEventListener`3.cs @@ -0,0 +1,80 @@ +// 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.CommandPalette.Extensions.Toolkit; + +/// <summary> +/// Implements a weak event listener that allows the owner to be garbage +/// collected if its only remaining link is an event handler. +/// </summary> +/// <typeparam name="TInstance">Type of instance listening for the event.</typeparam> +/// <typeparam name="TSource">Type of source for the event.</typeparam> +/// <typeparam name="TEventArgs">Type of event arguments for the event.</typeparam> +[EditorBrowsable(EditorBrowsableState.Never)] +internal sealed class WeakEventListener<TInstance, TSource, TEventArgs> + where TInstance : class +{ + /// <summary> + /// WeakReference to the instance listening for the event. + /// </summary> + private readonly WeakReference<TInstance> _weakInstance; + + /// <summary> + /// Initializes a new instance of the <see cref="WeakEventListener{TInstance, TSource, TEventArgs}"/> class. + /// </summary> + /// <param name="instance">Instance subscribing to the event.</param> + /// <param name="onEventAction">Event handler executed when event is raised.</param> + /// <param name="onDetachAction">Action to execute when instance was collected.</param> + public WeakEventListener( + TInstance instance, + Action<TInstance, TSource, TEventArgs>? onEventAction = null, + Action<WeakEventListener<TInstance, TSource, TEventArgs>>? onDetachAction = null) + { + ArgumentNullException.ThrowIfNull(instance); + + _weakInstance = new(instance); + OnEventAction = onEventAction; + OnDetachAction = onDetachAction; + } + + /// <summary> + /// Gets or sets the method to call when the event fires. + /// </summary> + public Action<TInstance, TSource, TEventArgs>? OnEventAction { get; set; } + + /// <summary> + /// Gets or sets the method to call when detaching from the event. + /// </summary> + public Action<WeakEventListener<TInstance, TSource, TEventArgs>>? OnDetachAction { get; set; } + + /// <summary> + /// Handler for the subscribed event calls OnEventAction to handle it. + /// </summary> + /// <param name="source">Event source.</param> + /// <param name="eventArgs">Event arguments.</param> + public void OnEvent(TSource source, TEventArgs eventArgs) + { + if (_weakInstance.TryGetTarget(out var target)) + { + // Call registered action + OnEventAction?.Invoke(target, source, eventArgs); + } + else + { + // Detach from event + Detach(); + } + } + + /// <summary> + /// Detaches from the subscribed event. + /// </summary> + public void Detach() + { + OnDetachAction?.Invoke(this); + OnDetachAction = null; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/WellKnownExtensionAttributes.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/WellKnownExtensionAttributes.cs new file mode 100644 index 0000000000..508cda72c1 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/WellKnownExtensionAttributes.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.CommandPalette.Extensions.Toolkit; + +public static class WellKnownExtensionAttributes +{ + public const string DataPackage = "Microsoft.CommandPalette.DataPackage"; + + public const string FontFamily = "FontFamily"; +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl index df4b442cdf..d7eed3bc15 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.idl @@ -122,7 +122,7 @@ namespace Microsoft.CommandPalette.Extensions interface ISeparatorFilterItem requires IFilterItem {} [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] - interface IFilter requires IFilterItem { + interface IFilter requires INotifyPropChanged, IFilterItem { String Id { get; }; String Name { get; }; IIconInfo Icon { get; }; @@ -131,7 +131,7 @@ namespace Microsoft.CommandPalette.Extensions [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] interface IFilters { String CurrentFilterId { get; set; }; - IFilterItem[] Filters(); + IFilterItem[] GetFilters(); } struct Color @@ -160,6 +160,15 @@ namespace Microsoft.CommandPalette.Extensions [uuid("6a6dd345-37a3-4a1e-914d-4f658a4d583d")] [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] interface IDetailsData {} + + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + enum ContentSize + { + Small = 0, + Medium = 1, + Large = 2, + }; + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] interface IDetailsElement { String Key { get; }; @@ -182,8 +191,8 @@ namespace Microsoft.CommandPalette.Extensions String Text { get; }; } [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] - interface IDetailsCommand requires IDetailsData { - ICommand Command { get; }; + interface IDetailsCommands requires IDetailsData { + ICommand[] Commands { get; }; } [uuid("58070392-02bb-4e89-9beb-47ceb8c3d741")] [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] @@ -273,10 +282,26 @@ namespace Microsoft.CommandPalette.Extensions String Section { get; }; String TextToSuggest { get; }; } + + [uuid("50C6F080-1CBE-4CE4-B92F-DA2F116ED524")] + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + interface IGridProperties requires INotifyPropChanged { } + + [uuid("05914D59-6ECB-4992-9CF2-5982B5120A26")] + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + interface ISmallGridLayout requires IGridProperties { } [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] - interface IGridProperties { - Windows.Foundation.Size TileSize { get; }; + interface IMediumGridLayout requires IGridProperties + { + Boolean ShowTitle { get; }; + } + + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + interface IGalleryGridLayout requires IGridProperties + { + Boolean ShowTitle { get; }; + Boolean ShowSubtitle { get; }; } [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] @@ -346,6 +371,11 @@ namespace Microsoft.CommandPalette.Extensions IFallbackHandler FallbackHandler{ get; }; String DisplayTitle { get; }; }; + + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + interface IFallbackCommandItem2 requires IFallbackCommandItem { + String Id { get; }; + }; [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] interface ICommandProvider requires Windows.Foundation.IClosable, INotifyItemsChanged @@ -364,5 +394,17 @@ namespace Microsoft.CommandPalette.Extensions void InitializeWithHost(IExtensionHost host); }; + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + interface IExtendedAttributesProvider + { + Windows.Foundation.Collections.IMap<String, Object> GetProperties(); + }; + + [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] + interface ICommandProvider2 requires ICommandProvider + { + Object[] GetApiExtensionStubs(); + }; + } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj index 1090e58f25..cc94852d0f 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/Microsoft.CommandPalette.Extensions.vcxproj @@ -1,15 +1,14 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <PathToRoot>..\..\..\..\..\</PathToRoot> - <WasdkNuget>$(PathToRoot)packages\Microsoft.WindowsAppSDK.1.6.250205002</WasdkNuget> - <CppWinRTNuget>$(PathToRoot)packages\Microsoft.Windows.CppWinRT.2.0.240111.5</CppWinRTNuget> - <WindowsSdkBuildToolsNuget>$(PathToRoot)packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428</WindowsSdkBuildToolsNuget> - <WebView2Nuget>$(PathToRoot)packages\Microsoft.Web.WebView2.1.0.2903.40</WebView2Nuget> + <PropertyGroup Label="NuGet"> + <!-- Tell NuGet this is PackageReference style --> + <RestoreProjectStyle>PackageReference</RestoreProjectStyle> + <!-- Tell NuGet we're a native project --> + <NuGetTargetMoniker>native,Version=v0.0</NuGetTargetMoniker> + <!-- Tell NuGet we target Windows (use your existing WindowsTargetPlatformVersion) --> + <NuGetTargetPlatformIdentifier>Windows</NuGetTargetPlatformIdentifier> + <NuGetTargetPlatformVersion>$(WindowsTargetPlatformVersion)</NuGetTargetPlatformVersion> </PropertyGroup> - <Import Project="$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.props" Condition="Exists('$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.props')" /> - <Import Project="$(CppWinRTNuget)\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(CppWinRTNuget)\build\native\Microsoft.Windows.CppWinRT.props')" /> - <Import Project="$(WindowsSdkBuildToolsNuget)\build\Microsoft.Windows.SDK.BuildTools.props" Condition="Exists('$(WindowsSdkBuildToolsNuget)\build\Microsoft.Windows.SDK.BuildTools.props')" /> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -24,8 +23,14 @@ <ApplicationType>Windows Store</ApplicationType> <ApplicationTypeRevision>10.0</ApplicationTypeRevision> <WindowsTargetPlatformMinVersion>10.0.19041.0</WindowsTargetPlatformMinVersion> - <WindowsTargetPlatformVersion>10.0.22621.0</WindowsTargetPlatformVersion> + <WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion> + <WindowsAppSDKVerifyTransitiveDependencies>false</WindowsAppSDKVerifyTransitiveDependencies> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.CppWinRT" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.ImplementationLibrary" GeneratePathProperty="true" /> + </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|ARM64"> @@ -45,16 +50,8 @@ <Platform>x64</Platform> </ProjectConfiguration> </ItemGroup> - <PropertyGroup> - <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\Microsoft.CommandPalette.Extensions\</OutDir> - <IntDir>obj\$(Platform)\$(Configuration)\</IntDir> - </PropertyGroup> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> - <PlatformToolset Condition="'$(VisualStudioVersion)' == '16.0'">v142</PlatformToolset> - <PlatformToolset Condition="'$(VisualStudioVersion)' == '15.0'">v141</PlatformToolset> - <PlatformToolset Condition="'$(VisualStudioVersion)' == '14.0'">v140</PlatformToolset> <CharacterSet>Unicode</CharacterSet> <GenerateManifest>false</GenerateManifest> <DesktopCompatible>true</DesktopCompatible> @@ -153,7 +150,6 @@ <Midl Include="Microsoft.CommandPalette.Extensions.idl" /> </ItemGroup> <ItemGroup> - <None Include="packages.config" /> <None Include="Microsoft.CommandPalette.Extensions.def" /> </ItemGroup> <ItemGroup> @@ -161,31 +157,9 @@ <DeploymentContent>false</DeploymentContent> </Text> </ItemGroup> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\Microsoft.CommandPalette.Extensions\</OutDir> + <IntDir>obj\$(Platform)\$(Configuration)\</IntDir> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <ImportGroup Label="ExtensionTargets"> - <Import Project="$(WindowsSdkBuildToolsNuget)\build\Microsoft.Windows.SDK.BuildTools.targets" Condition="Exists('$(WindowsSdkBuildToolsNuget)\build\Microsoft.Windows.SDK.BuildTools.targets')" /> - <Import Project="$(CppWinRTNuget)\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(CppWinRTNuget)\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.targets" Condition="Exists('$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.targets')" /> - <Import Project="$(WebView2Nuget)\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('$(WebView2Nuget)\build\native\Microsoft.Web.WebView2.targets')" /> - </ImportGroup> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>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}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('$(WindowsSdkBuildToolsNuget)\build\Microsoft.Windows.SDK.BuildTools.props')" Text="$([System.String]::Format('$(ErrorText)', '$(WindowsSdkBuildToolsNuget)\build\Microsoft.Windows.SDK.BuildTools.props'))" /> - <Error Condition="!Exists('$(WindowsSdkBuildToolsNuget)\build\Microsoft.Windows.SDK.BuildTools.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(WindowsSdkBuildToolsNuget)\build\Microsoft.Windows.SDK.BuildTools.targets'))" /> - <Error Condition="!Exists('$(CppWinRTNuget)\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(CppWinRTNuget)\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('$(CppWinRTNuget)\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(CppWinRTNuget)\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.props')" Text="$([System.String]::Format('$(ErrorText)', '$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.props'))" /> - <Error Condition="!Exists('$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(WasdkNuget)\build\native\Microsoft.WindowsAppSDK.targets'))" /> - <Error Condition="!Exists('$(WebView2Nuget)\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(WebView2Nuget)\build\native\Microsoft.Web.WebView2.targets'))" /> - </Target> - <ItemGroup> - <ResourceCompile Include="version.rc" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\..\..\..\common\version\version.vcxproj"> - <Project>{cc6e41ac-8174-4e8a-8d22-85dd7f4851df}</Project> - </ProjectReference> - </ItemGroup> -</Project> +</Project> \ No newline at end of file diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config deleted file mode 100644 index 93d095a2b1..0000000000 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/packages.config +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> - <package id="Microsoft.Windows.SDK.BuildTools" version="10.0.22621.2428" targetFramework="native" /> - <package id="Microsoft.WindowsAppSDK" version="1.6.250205002" targetFramework="native" /> -</packages> \ No newline at end of file diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/version.rc b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/version.rc deleted file mode 100644 index 67e50b2cbb..0000000000 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions/version.rc +++ /dev/null @@ -1,34 +0,0 @@ -#include "winres.h" - -#include "../../../../common/version/version.h" - -VS_VERSION_INFO VERSIONINFO - FILEVERSION FILE_VERSION - PRODUCTVERSION PRODUCT_VERSION - FILEFLAGSMASK 0x3fL -#ifdef _DEBUG - FILEFLAGS 0x1L -#else - FILEFLAGS 0x0L -#endif - FILEOS 0x40004L - FILETYPE 0x0L - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904b0" - BEGIN - VALUE "CompanyName", "Microsoft Corporation" - VALUE "FileDescription", "Microsoft.CommandPalette.Extensions.Toolkit" - VALUE "FileVersion", FILE_VERSION_STRING - VALUE "ProductName", "Microsoft.CommandPalette.Extensions.Toolkit" - VALUE "ProductVersion", PRODUCT_VERSION_STRING - VALUE "LegalCopyright", "Copyright (c) Microsoft Corporation" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1200 - END -END diff --git a/src/modules/cmdpal/extensionsdk/README.md b/src/modules/cmdpal/extensionsdk/README.md index 2b2732cc14..540bec74bd 100644 --- a/src/modules/cmdpal/extensionsdk/README.md +++ b/src/modules/cmdpal/extensionsdk/README.md @@ -7,9 +7,9 @@ Palette, and use the "Create a new extension" command. That will set up a project for you, with the packaging, dependencies, and basic program structure ready to go. -To view the full docs, you can head over to [our docs site](https://go.microsoft.com/fwlink/?linkid=2310639) +To view the full docs, you can head over to [our docs site](https://aka.ms/cmdpalextensions-devdocs) There are samples of just about everything you can do in [the samples project]. Head over there to see basic usage of the APIs. -[the samples project]: https://github.com/microsoft/PowerToys/tree/main/src/modules/cmdpal/Exts/SamplePagesExtension +[the samples project]: https://github.com/microsoft/PowerToys/tree/main/src/modules/cmdpal/ext/SamplePagesExtension diff --git a/src/modules/cmdpal/extensionsdk/nuget/BuildSDKHelper.ps1 b/src/modules/cmdpal/extensionsdk/nuget/BuildSDKHelper.ps1 index 179c4bfa3f..2afad38df5 100644 --- a/src/modules/cmdpal/extensionsdk/nuget/BuildSDKHelper.ps1 +++ b/src/modules/cmdpal/extensionsdk/nuget/BuildSDKHelper.ps1 @@ -1,11 +1,19 @@ Param( [string]$Configuration = "release", - [string]$VersionOfSDK = "0.0.0", + [string]$VersionOfSDK = "", [string]$BuildStep = "all", [switch]$IsAzurePipelineBuild = $false, [switch]$Help = $false ) +If ([String]::IsNullOrEmpty($VersionOfSDK)) { + $VersionOfSDK = $Env:XES_PACKAGEVERSIONNUMBER +} + +If ([String]::IsNullOrEmpty($VersionOfSDK)) { + $VersionOfSDK = "0.0.0" +} + $StartTime = Get-Date if ($Help) { @@ -46,9 +54,15 @@ if ($IsAzurePipelineBuild) { } else { $nugetPath = (Join-Path $PSScriptRoot "NugetWrapper.cmd") } +$solutionPath = (Join-Path $PSScriptRoot "..\..\..\..\..\PowerToys.slnx") if (($BuildStep -ieq "all") -Or ($BuildStep -ieq "build")) { - & $nugetPath restore (Join-Path $PSScriptRoot "..\..\..\..\..\PowerToys.sln") + $restoreArgs = @( + $solutionPath + "/t:Restore" + "/p:RestorePackagesConfig=true" + ) + & $msbuildPath $restoreArgs Try { foreach ($config in $Configuration.Split(",")) { @@ -61,6 +75,10 @@ if (($BuildStep -ieq "all") -Or ($BuildStep -ieq "build")) { ("/p:VersionNumber="+$VersionOfSDK) ) + if ($IsAzurePipelineBuild) { + $msbuildArgs += "/p:CIBuild=true" + } + & $msbuildPath $msbuildArgs } } diff --git a/src/modules/cmdpal/extensionsdk/nuget/Microsoft.CommandPalette.Extensions.SDK.nuspec b/src/modules/cmdpal/extensionsdk/nuget/Microsoft.CommandPalette.Extensions.SDK.nuspec index 89359d7412..4fa4297af9 100644 --- a/src/modules/cmdpal/extensionsdk/nuget/Microsoft.CommandPalette.Extensions.SDK.nuspec +++ b/src/modules/cmdpal/extensionsdk/nuget/Microsoft.CommandPalette.Extensions.SDK.nuspec @@ -25,16 +25,19 @@ <file src="Microsoft.CommandPalette.Extensions.props" target="build\"/> <file src="Microsoft.CommandPalette.Extensions.targets" target="build\"/> <!-- AnyCPU Managed dlls from SDK.Lib project --> - <file src="..\Microsoft.CommandPalette.Extensions.Toolkit\x64\release\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.dll" target="lib\net8.0-windows10.0.19041.0\"/> - <file src="..\Microsoft.CommandPalette.Extensions.Toolkit\x64\release\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.deps.json" target="lib\net8.0-windows10.0.19041.0\"/> + <file src="..\..\..\..\..\x64\release\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.dll" target="lib\net8.0-windows10.0.19041.0\"/> + <file src="..\..\..\..\..\x64\release\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.pdb" target="lib\net8.0-windows10.0.19041.0\"/> + <file src="..\..\..\..\..\x64\release\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.deps.json" target="lib\net8.0-windows10.0.19041.0\"/> <!-- Native dlls and winmd from SDK cpp project --> <!-- TODO: we may not need this, since there are no implementations in the Microsoft.CommandPalette.Extensions namespace --> - <file src="..\Microsoft.CommandPalette.Extensions\x64\release\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.dll" target="runtimes\win-x64\native\"/> - <file src="..\Microsoft.CommandPalette.Extensions\arm64\release\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.dll" target="runtimes\win-arm64\native\"/> + <file src="..\..\..\..\..\x64\release\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.dll" target="runtimes\win-x64\native\"/> + <file src="..\..\..\..\..\x64\release\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.pdb" target="runtimes\win-x64\native\"/> + <file src="..\..\..\..\..\arm64\release\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.dll" target="runtimes\win-arm64\native\"/> + <file src="..\..\..\..\..\arm64\release\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.pdb" target="runtimes\win-arm64\native\"/> - <!-- Not putting in the following the lib folder because we don't want plugin project to directly reference the winmd --> - <file src="..\Microsoft.CommandPalette.Extensions\x64\release\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.winmd" target="winmd\"/> + <!-- Not putting the following into the lib folder because we don't want plugin project to directly reference the winmd --> + <file src="..\..\..\..\..\x64\release\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.winmd" target="winmd\"/> <file src="..\README.md" target="docs\" /> </files> diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/OpenInConsoleCommand.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/OpenInConsoleCommand.cs deleted file mode 100644 index de68cafeb8..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/OpenInConsoleCommand.cs +++ /dev/null @@ -1,54 +0,0 @@ -// 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; -using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Apps.Commands; - -internal sealed partial class OpenInConsoleCommand : InvokableCommand -{ - private static readonly IconInfo TheIcon = new("\ue838"); - - private readonly string _target; - - public OpenInConsoleCommand(string target) - { - Name = Resources.open_path_in_console; - Icon = TheIcon; - - _target = target; - } - - internal static async Task LaunchTarget(string t) - { - await Task.Run(() => - { - try - { - var processStartInfo = new ProcessStartInfo - { - WorkingDirectory = t, - FileName = "cmd.exe", - }; - - Process.Start(processStartInfo); - } - catch (Exception) - { - // Log.Exception($"Failed to open {Name} in console, {e.Message}", e, GetType()); - } - }); - } - - public override CommandResult Invoke() - { - _ = LaunchTarget(_target).ConfigureAwait(false); - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/OpenPathCommand.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/OpenPathCommand.cs deleted file mode 100644 index 88c7df5d94..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Commands/OpenPathCommand.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Diagnostics; -using System.Threading.Tasks; -using Microsoft.CmdPal.Ext.Apps.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Apps.Commands; - -internal sealed partial class OpenPathCommand : InvokableCommand -{ - private static readonly IconInfo TheIcon = new("\ue838"); - - private readonly string _target; - - public OpenPathCommand(string target) - { - Name = Resources.open_location; - Icon = TheIcon; - - _target = target; - } - - internal static async Task LaunchTarget(string t) - { - await Task.Run(() => - { - Process.Start(new ProcessStartInfo(t) { UseShellExecute = true }); - }); - } - - public override CommandResult Invoke() - { - _ = LaunchTarget(_target).ConfigureAwait(false); - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/Native.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/Native.cs deleted file mode 100644 index 37918160fe..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/Native.cs +++ /dev/null @@ -1,157 +0,0 @@ -// 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.Runtime.InteropServices; -using System.Text; - -namespace Microsoft.CmdPal.Ext.Apps.Utils; - -[SuppressMessage("Interoperability", "CA1401:P/Invokes should not be visible", Justification = "We want plugins to share this NativeMethods class, instead of each one creating its own.")] -public sealed class Native -{ - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] - public static extern int SHLoadIndirectString(string pszSource, StringBuilder pszOutBuf, int cchOutBuf, nint ppvReserved); - - [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - public static extern int SHCreateItemFromParsingName([MarshalAs(UnmanagedType.LPWStr)] string path, nint pbc, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IShellItem shellItem); - - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] - public static extern HRESULT SHCreateStreamOnFileEx(string fileName, STGM grfMode, uint attributes, bool create, System.Runtime.InteropServices.ComTypes.IStream reserved, out System.Runtime.InteropServices.ComTypes.IStream stream); - - [DllImport("shlwapi.dll", CharSet = CharSet.Unicode)] - public static extern HRESULT SHLoadIndirectString(string pszSource, StringBuilder pszOutBuf, uint cchOutBuf, nint ppvReserved); - - public enum HRESULT : uint - { - /// <summary> - /// Operation successful. - /// </summary> - S_OK = 0x00000000, - - /// <summary> - /// Operation successful. (negative condition/no operation) - /// </summary> - S_FALSE = 0x00000001, - - /// <summary> - /// Not implemented. - /// </summary> - E_NOTIMPL = 0x80004001, - - /// <summary> - /// No such interface supported. - /// </summary> - E_NOINTERFACE = 0x80004002, - - /// <summary> - /// Pointer that is not valid. - /// </summary> - E_POINTER = 0x80004003, - - /// <summary> - /// Operation aborted. - /// </summary> - E_ABORT = 0x80004004, - - /// <summary> - /// Unspecified failure. - /// </summary> - E_FAIL = 0x80004005, - - /// <summary> - /// Unexpected failure. - /// </summary> - E_UNEXPECTED = 0x8000FFFF, - - /// <summary> - /// General access denied error. - /// </summary> - E_ACCESSDENIED = 0x80070005, - - /// <summary> - /// Handle that is not valid. - /// </summary> - E_HANDLE = 0x80070006, - - /// <summary> - /// Failed to allocate necessary memory. - /// </summary> - E_OUTOFMEMORY = 0x8007000E, - - /// <summary> - /// One or more arguments are not valid. - /// </summary> - E_INVALIDARG = 0x80070057, - - /// <summary> - /// The operation was canceled by the user. (Error source 7 means Win32.) - /// </summary> - /// <SeeAlso href="https://learn.microsoft.com/windows/win32/debug/system-error-codes--1000-1299-"/> - /// <SeeAlso href="https://en.wikipedia.org/wiki/HRESULT"/> - E_CANCELLED = 0x800704C7, - } - - public static class ShellItemTypeConstants - { - /// <summary> - /// Guid for type IShellItem. - /// </summary> - public static readonly Guid ShellItemGuid = new("43826d1e-e718-42ee-bc55-a1e261c37bfe"); - - /// <summary> - /// Guid for type IShellItem2. - /// </summary> - public static readonly Guid ShellItem2Guid = new("7E9FB0D3-919F-4307-AB2E-9B1860310C93"); - } - - /// <summary> - /// The following are ShellItem DisplayName types. - /// </summary> - [Flags] - public enum SIGDN : uint - { - NORMALDISPLAY = 0, - PARENTRELATIVEPARSING = 0x80018001, - PARENTRELATIVEFORADDRESSBAR = 0x8001c001, - DESKTOPABSOLUTEPARSING = 0x80028000, - PARENTRELATIVEEDITING = 0x80031001, - DESKTOPABSOLUTEEDITING = 0x8004c000, - FILESYSPATH = 0x80058000, - URL = 0x80068000, - } - - [ComImport] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - [Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")] - public interface IShellItem - { - void BindToHandler( - nint pbc, - [MarshalAs(UnmanagedType.LPStruct)] Guid bhid, - [MarshalAs(UnmanagedType.LPStruct)] Guid riid, - out nint ppv); - - void GetParent(out IShellItem ppsi); - - void GetDisplayName(SIGDN sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName); - - void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs); - - void Compare(IShellItem psi, uint hint, out int piOrder); - } - - /// <summary> - /// <see href="https://learn.microsoft.com/windows/win32/stg/stgm-constants">see all STGM values</see> - /// </summary> - [Flags] - public enum STGM : long - { - READ = 0x00000000L, - WRITE = 0x00000001L, - READWRITE = 0x00000002L, - CREATE = 0x00001000L, - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ShellLinkHelper.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ShellLinkHelper.cs deleted file mode 100644 index da07967987..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Apps/Utils/ShellLinkHelper.cs +++ /dev/null @@ -1,188 +0,0 @@ -// 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.Runtime.InteropServices.ComTypes; -using System.Text; - -namespace Microsoft.CmdPal.Ext.Apps.Utils; - -public class ShellLinkHelper : IShellLinkHelper -{ - [Flags] - private enum SLGP_FLAGS - { - SLGP_SHORTPATH = 0x1, - SLGP_UNCPRIORITY = 0x2, - SLGP_RAWPATH = 0x4, - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] - [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching COM")] - private struct WIN32_FIND_DATAW - { - public uint dwFileAttributes; - public long ftCreationTime; - public long ftLastAccessTime; - public long ftLastWriteTime; - public uint nFileSizeHigh; - public uint nFileSizeLow; - public uint dwReserved0; - public uint dwReserved1; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] - public string cFileName; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] - public string cAlternateFileName; - } - - [Flags] - [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Implements COM Interface")] - public enum SLR_FLAGS - { - SLR_NO_UI = 0x1, - SLR_ANY_MATCH = 0x2, - SLR_UPDATE = 0x4, - SLR_NOUPDATE = 0x8, - SLR_NOSEARCH = 0x10, - SLR_NOTRACK = 0x20, - SLR_NOLINKINFO = 0x40, - SLR_INVOKE_MSI = 0x80, - } - - // Reference : http://www.pinvoke.net/default.aspx/Interfaces.IShellLinkW - - // The IShellLink interface allows Shell links to be created, modified, and resolved - [ComImport] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - [Guid("000214F9-0000-0000-C000-000000000046")] - private interface IShellLinkW - { - /// <summary>Retrieves the path and file name of a Shell link object</summary> - void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, ref WIN32_FIND_DATAW pfd, SLGP_FLAGS fFlags); - - /// <summary>Retrieves the list of item identifiers for a Shell link object</summary> - void GetIDList(out nint ppidl); - - /// <summary>Sets the pointer to an item identifier list (PIDL) for a Shell link object.</summary> - void SetIDList(nint pidl); - - /// <summary>Retrieves the description string for a Shell link object</summary> - void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName); - - /// <summary>Sets the description for a Shell link object. The description can be any application-defined string</summary> - void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); - - /// <summary>Retrieves the name of the working directory for a Shell link object</summary> - void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath); - - /// <summary>Sets the name of the working directory for a Shell link object</summary> - void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); - - /// <summary>Retrieves the command-line arguments associated with a Shell link object</summary> - void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath); - - /// <summary>Sets the command-line arguments for a Shell link object</summary> - void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); - - /// <summary>Retrieves the hot key for a Shell link object</summary> - void GetHotkey(out short pwHotkey); - - /// <summary>Sets a hot key for a Shell link object</summary> - void SetHotkey(short wHotkey); - - /// <summary>Retrieves the show command for a Shell link object</summary> - void GetShowCmd(out int piShowCmd); - - /// <summary>Sets the show command for a Shell link object. The show command sets the initial show state of the window.</summary> - void SetShowCmd(int iShowCmd); - - /// <summary>Retrieves the location (path and index) of the icon for a Shell link object</summary> - void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon); - - /// <summary>Sets the location (path and index) of the icon for a Shell link object</summary> - void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); - - /// <summary>Sets the relative path to the Shell link object</summary> - void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved); - - /// <summary>Attempts to find the target of a Shell link, even if it has been moved or renamed</summary> - void Resolve(ref nint hwnd, SLR_FLAGS fFlags); - - /// <summary>Sets the path and file name of a Shell link object</summary> - void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); - } - - [ComImport] - [Guid("00021401-0000-0000-C000-000000000046")] - private class ShellLink - { - } - - // Contains the description of the app - public string Description { get; set; } = string.Empty; - - // Contains the arguments to the app - public string Arguments { get; set; } = string.Empty; - - public bool HasArguments { get; set; } - - // Retrieve the target path using Shell Link - public string RetrieveTargetPath(string path) - { - var link = new ShellLink(); - const int STGM_READ = 0; - - try - { - ((IPersistFile)link).Load(path, STGM_READ); - } - catch (System.IO.FileNotFoundException) - { - // Log.Exception("Path could not be retrieved", ex, GetType(), path); - return string.Empty; - } - - var hwnd = default(nint); - ((IShellLinkW)link).Resolve(ref hwnd, 0); - - const int MAX_PATH = 260; - var buffer = new StringBuilder(MAX_PATH); - - var data = default(WIN32_FIND_DATAW); - ((IShellLinkW)link).GetPath(buffer, buffer.Capacity, ref data, SLGP_FLAGS.SLGP_SHORTPATH); - var target = buffer.ToString(); - - // To set the app description - if (!string.IsNullOrEmpty(target)) - { - buffer = new StringBuilder(MAX_PATH); - try - { - ((IShellLinkW)link).GetDescription(buffer, MAX_PATH); - Description = buffer.ToString(); - } - catch (Exception) - { - // Log.Exception($"Failed to fetch description for {target}, {e.Message}", e, GetType()); - Description = string.Empty; - } - - var argumentBuffer = new StringBuilder(MAX_PATH); - ((IShellLinkW)link).GetArguments(argumentBuffer, argumentBuffer.Capacity); - Arguments = argumentBuffer.ToString(); - - // Set variable to true if the program takes in any arguments - if (argumentBuffer.Length != 0) - { - HasArguments = true; - } - } - - // To release unmanaged memory - Marshal.ReleaseComObject(link); - - return target; - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/OpenInTerminalCommand.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/OpenInTerminalCommand.cs deleted file mode 100644 index 32284d617b..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/OpenInTerminalCommand.cs +++ /dev/null @@ -1,42 +0,0 @@ -// 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.CmdPal.Ext.Bookmarks.Properties; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -internal sealed partial class OpenInTerminalCommand : InvokableCommand -{ - private readonly string _folder; - - public OpenInTerminalCommand(string folder) - { - Name = Resources.bookmarks_open_in_terminal_name; - _folder = folder; - } - - public override ICommandResult Invoke() - { - try - { - // Start Windows Terminal with the specified folder - var startInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "wt.exe", - Arguments = $"-d \"{_folder}\"", - UseShellExecute = true, - }; - System.Diagnostics.Process.Start(startInfo); - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error launching Windows Terminal: {ex.Message}"); - } - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/UrlCommand.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/UrlCommand.cs deleted file mode 100644 index 0b161a094e..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Bookmark/UrlCommand.cs +++ /dev/null @@ -1,98 +0,0 @@ -// 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.CommandPalette.Extensions.Toolkit; -using Windows.System; - -namespace Microsoft.CmdPal.Ext.Bookmarks; - -public partial class UrlCommand : InvokableCommand -{ - public string Type { get; } - - public string Url { get; } - - public UrlCommand(BookmarkData data) - : this(data.Name, data.Bookmark, data.Type) - { - } - - public UrlCommand(string name, string url, string type) - { - Name = name; - Type = type; - Url = url; - Icon = new IconInfo(IconFromUrl(Url, type)); - } - - public override CommandResult Invoke() - { - var target = Url; - try - { - var uri = GetUri(target); - if (uri != null) - { - _ = Launcher.LaunchUriAsync(uri); - } - else - { - // throw new UriFormatException("The provided URL is not valid."); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Error launching URL: {ex.Message}"); - } - - return CommandResult.Dismiss(); - } - - internal static Uri? GetUri(string url) - { - Uri? uri; - if (!Uri.TryCreate(url, UriKind.Absolute, out uri)) - { - if (!Uri.TryCreate("https://" + url, UriKind.Absolute, out uri)) - { - return null; - } - } - - return uri; - } - - internal static string IconFromUrl(string url, string type) - { - switch (type) - { - case "file": - return "📄"; - case "folder": - return "📁"; - case "web": - default: - // Get the base url up to the first placeholder - var placeholderIndex = url.IndexOf('{'); - var baseString = placeholderIndex > 0 ? url.Substring(0, placeholderIndex) : url; - try - { - var uri = GetUri(baseString); - if (uri != null) - { - var hostname = uri.Host; - var faviconUrl = $"{uri.Scheme}://{hostname}/favicon.ico"; - return faviconUrl; - } - } - catch (UriFormatException) - { - // return "🔗"; - } - - return "🔗"; - } - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs deleted file mode 100644 index b96cb68dac..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/FallbackOpenFileItem.cs +++ /dev/null @@ -1,54 +0,0 @@ -// 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.IO; -using Microsoft.CmdPal.Ext.Indexer.Data; -using Microsoft.CmdPal.Ext.Indexer.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Storage.Streams; - -namespace Microsoft.CmdPal.Ext.Indexer; - -internal sealed partial class FallbackOpenFileItem : FallbackCommandItem -{ - public FallbackOpenFileItem() - : base(new NoOpCommand(), Resources.Indexer_Find_Path_fallback_display_title) - { - Title = string.Empty; - Subtitle = string.Empty; - } - - public override void UpdateQuery(string query) - { - if (Path.Exists(query)) - { - var item = new IndexerItem() { FullPath = query, FileName = Path.GetFileName(query) }; - var listItemForUs = new IndexerListItem(item, IncludeBrowseCommand.AsDefault); - Command = listItemForUs.Command; - MoreCommands = listItemForUs.MoreCommands; - Subtitle = item.FileName; - Title = item.FullPath; - Icon = listItemForUs.Icon; - - try - { - var stream = ThumbnailHelper.GetThumbnail(item.FullPath).Result; - if (stream != null) - { - var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream)); - Icon = new IconInfo(data, data); - } - } - catch - { - } - } - else - { - Title = string.Empty; - Subtitle = string.Empty; - Command = new NoOpCommand(); - } - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs deleted file mode 100644 index abe12396ea..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SearchQuery.cs +++ /dev/null @@ -1,479 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Concurrent; -using System.Runtime.InteropServices; -using System.Threading; -using ManagedCommon; -using Microsoft.CmdPal.Ext.Indexer.Indexer.OleDB; -using Microsoft.CmdPal.Ext.Indexer.Indexer.Utils; -using Microsoft.CmdPal.Ext.Indexer.Native; -using Windows.Win32; -using Windows.Win32.System.Com; -using Windows.Win32.System.Search; -using Windows.Win32.UI.Shell.PropertiesSystem; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer; - -internal sealed partial class SearchQuery : IDisposable -{ - private readonly Lock _lockObject = new(); // Lock object for synchronization - private readonly DBPROPIDSET dbPropIdSet; - - private uint reuseWhereID; - private EventWaitHandle queryCompletedEvent; - private Timer queryTpTimer; - private IRowset currentRowset; - private IRowset reuseRowset; - - public uint Cookie { get; set; } - - public string SearchText { get; private set; } - - public ConcurrentQueue<SearchResult> SearchResults { get; private set; } = []; - - public SearchQuery() - { - dbPropIdSet = new DBPROPIDSET - { - rgPropertyIDs = Marshal.AllocCoTaskMem(sizeof(uint)), // Allocate memory for the property ID array - cPropertyIDs = 1, - guidPropertySet = new Guid("AA6EE6B0-E828-11D0-B23E-00AA0047FC01"), // DBPROPSET_MSIDXS_ROWSETEXT, - }; - - // Copy the property ID into the allocated memory - Marshal.WriteInt32(dbPropIdSet.rgPropertyIDs, 8); // MSIDXSPROP_WHEREID - - Init(); - } - - private void Init() - { - // Create all the objects we will want cached - try - { - queryTpTimer = new Timer(QueryTimerCallback, this, Timeout.Infinite, Timeout.Infinite); - if (queryTpTimer == null) - { - Logger.LogError("Failed to create query timer"); - return; - } - - queryCompletedEvent = new EventWaitHandle(false, EventResetMode.ManualReset); - if (queryCompletedEvent == null) - { - Logger.LogError("Failed to create query completed event"); - return; - } - - // Execute a synchronous query on file items to prime the index and keep that handle around - PrimeIndexAndCacheWhereId(); - } - catch (Exception ex) - { - Logger.LogError("Exception at SearchUXQueryHelper Init", ex); - } - } - - public void WaitForQueryCompletedEvent() => queryCompletedEvent.WaitOne(); - - public void CancelOutstandingQueries() - { - Logger.LogDebug("Cancel query " + SearchText); - - // Are we currently doing work? If so, let's cancel - lock (_lockObject) - { - if (queryTpTimer != null) - { - queryTpTimer.Change(Timeout.Infinite, Timeout.Infinite); - queryTpTimer.Dispose(); - queryTpTimer = null; - } - - Init(); - } - } - - public void Execute(string searchText, uint cookie) - { - SearchText = searchText; - Cookie = cookie; - ExecuteSyncInternal(); - } - - public static void QueryTimerCallback(object state) - { - var pQueryHelper = (SearchQuery)state; - pQueryHelper.ExecuteSyncInternal(); - } - - private void ExecuteSyncInternal() - { - lock (_lockObject) - { - var queryStr = QueryStringBuilder.GenerateQuery(SearchText, reuseWhereID); - try - { - // We need to generate a search query string with the search text the user entered above - if (currentRowset != null) - { - if (reuseRowset != null) - { - Marshal.ReleaseComObject(reuseRowset); - } - - // We have a previous rowset, this means the user is typing and we should store this - // recapture the where ID from this so the next ExecuteSync call will be faster - reuseRowset = currentRowset; - reuseWhereID = GetReuseWhereId(reuseRowset); - } - - currentRowset = ExecuteCommand(queryStr); - - SearchResults.Clear(); - } - catch (Exception ex) - { - Logger.LogError("Error executing query", ex); - } - finally - { - queryCompletedEvent.Set(); - } - } - } - - private bool HandleRow(IGetRow getRow, nuint rowHandle) - { - object propertyStorePtr = null; - - try - { - getRow.GetRowFromHROW(null, rowHandle, typeof(IPropertyStore).GUID, out propertyStorePtr); - - var propertyStore = (IPropertyStore)propertyStorePtr; - if (propertyStore == null) - { - Logger.LogError("Failed to get IPropertyStore interface"); - return false; - } - - var searchResult = SearchResult.Create(propertyStore); - if (searchResult == null) - { - Logger.LogError("Failed to create search result"); - return false; - } - - SearchResults.Enqueue(searchResult); - return true; - } - catch (Exception ex) - { - Logger.LogError("Error handling row", ex); - return false; - } - finally - { - // Ensure the COM object is released if not returned - if (propertyStorePtr != null) - { - Marshal.ReleaseComObject(propertyStorePtr); - } - } - } - - public bool FetchRows(int offset, int limit) - { - if (currentRowset == null) - { - Logger.LogError("No rowset to fetch rows from"); - return false; - } - - if (currentRowset is not IGetRow) - { - Logger.LogInfo("Reset the current rowset"); - ExecuteSyncInternal(); - } - - if (currentRowset is not IGetRow getRow) - { - Logger.LogError("Rowset does not support IGetRow interface"); - return false; - } - - uint rowCountReturned; - var prghRows = IntPtr.Zero; - - try - { - var res = currentRowset.GetNextRows(IntPtr.Zero, offset, limit, out rowCountReturned, out prghRows); - if (res < 0) - { - Logger.LogError($"Error fetching rows: {res}"); - return false; - } - - if (rowCountReturned == 0) - { - // No more rows to fetch - return false; - } - - // Marshal the row handles - var rowHandles = new IntPtr[rowCountReturned]; - Marshal.Copy(prghRows, rowHandles, 0, (int)rowCountReturned); - - for (var i = 0; i < rowCountReturned; i++) - { - var rowHandle = Marshal.ReadIntPtr(prghRows, i * IntPtr.Size); - if (!HandleRow(getRow, (nuint)rowHandle)) - { - break; - } - } - - res = currentRowset.ReleaseRows(rowCountReturned, rowHandles, IntPtr.Zero, null, null); - if (res != 0) - { - Logger.LogError($"Error releasing rows: {res}"); - } - - Marshal.FreeCoTaskMem(prghRows); - prghRows = IntPtr.Zero; - - return true; - } - catch (Exception ex) - { - Logger.LogError("Error fetching rows", ex); - return false; - } - finally - { - if (prghRows != IntPtr.Zero) - { - Marshal.FreeCoTaskMem(prghRows); - } - } - } - - private void PrimeIndexAndCacheWhereId() - { - var queryStr = QueryStringBuilder.GeneratePrimingQuery(); - var rowset = ExecuteCommand(queryStr); - if (rowset != null) - { - if (reuseRowset != null) - { - Marshal.ReleaseComObject(reuseRowset); - } - - reuseRowset = rowset; - reuseWhereID = GetReuseWhereId(reuseRowset); - } - } - - private unsafe IRowset ExecuteCommand(string queryStr) - { - object sessionPtr = null; - object commandPtr = null; - - try - { - var session = (IDBCreateSession)DataSourceManager.GetDataSource(); - session.CreateSession(null, typeof(IDBCreateCommand).GUID, out sessionPtr); - if (sessionPtr == null) - { - Logger.LogError("CreateSession failed"); - return null; - } - - var createCommand = (IDBCreateCommand)sessionPtr; - createCommand.CreateCommand(null, typeof(ICommandText).GUID, out commandPtr); - if (commandPtr == null) - { - Logger.LogError("CreateCommand failed"); - return null; - } - - var commandText = (ICommandText)commandPtr; - if (commandText == null) - { - Logger.LogError("Failed to get ICommandText interface"); - return null; - } - - commandText.SetCommandText(in NativeHelpers.OleDb.DbGuidDefault, queryStr); - commandText.Execute(null, typeof(IRowset).GUID, null, null, out var rowsetPointer); - - return rowsetPointer as IRowset; - } - catch (Exception ex) - { - Logger.LogError("Unexpected error.", ex); - return null; - } - finally - { - // Release the command pointer - if (commandPtr != null) - { - Marshal.ReleaseComObject(commandPtr); - } - - // Release the session pointer - if (sessionPtr != null) - { - Marshal.ReleaseComObject(sessionPtr); - } - } - } - - private IRowsetInfo GetRowsetInfo(IRowset rowset) - { - if (rowset == null) - { - return null; - } - - var rowsetPtr = IntPtr.Zero; - var rowsetInfoPtr = IntPtr.Zero; - - try - { - // Get the IUnknown pointer for the IRowset object - rowsetPtr = Marshal.GetIUnknownForObject(rowset); - - // Query for IRowsetInfo interface - var rowsetInfoGuid = typeof(IRowsetInfo).GUID; - var res = Marshal.QueryInterface(rowsetPtr, in rowsetInfoGuid, out rowsetInfoPtr); - if (res != 0) - { - Logger.LogError($"Error getting IRowsetInfo interface: {res}"); - return null; - } - - // Marshal the interface pointer to the actual IRowsetInfo object - var rowsetInfo = (IRowsetInfo)Marshal.GetObjectForIUnknown(rowsetInfoPtr); - return rowsetInfo; - } - catch (Exception ex) - { - Logger.LogError($"Exception occurred while getting IRowsetInfo. ", ex); - return null; - } - finally - { - // Release the IRowsetInfo pointer if it was obtained - if (rowsetInfoPtr != IntPtr.Zero) - { - Marshal.Release(rowsetInfoPtr); // Release the IRowsetInfo pointer - } - - // Release the IUnknown pointer for the IRowset object - if (rowsetPtr != IntPtr.Zero) - { - Marshal.Release(rowsetPtr); - } - } - } - - private DBPROP? GetPropset(IRowsetInfo rowsetInfo) - { - var prgPropSetsPtr = IntPtr.Zero; - - try - { - ulong cPropertySets; - var res = rowsetInfo.GetProperties(1, [dbPropIdSet], out cPropertySets, out prgPropSetsPtr); - if (res != 0) - { - Logger.LogError($"Error getting properties: {res}"); - return null; - } - - if (cPropertySets == 0 || prgPropSetsPtr == IntPtr.Zero) - { - Logger.LogError("No property sets returned"); - return null; - } - - var firstPropSetPtr = new IntPtr(prgPropSetsPtr.ToInt64()); - var propSet = Marshal.PtrToStructure<DBPROPSET>(firstPropSetPtr); - if (propSet.cProperties == 0 || propSet.rgProperties == IntPtr.Zero) - { - return null; - } - - var propPtr = new IntPtr(propSet.rgProperties.ToInt64()); - var prop = Marshal.PtrToStructure<DBPROP>(propPtr); - return prop; - } - catch (Exception ex) - { - Logger.LogError($"Exception occurred while getting properties,", ex); - return null; - } - finally - { - // Free the property sets pointer returned by GetProperties, if necessary - if (prgPropSetsPtr != IntPtr.Zero) - { - Marshal.FreeCoTaskMem(prgPropSetsPtr); - } - } - } - - private uint GetReuseWhereId(IRowset rowset) - { - var rowsetInfo = GetRowsetInfo(rowset); - if (rowsetInfo == null) - { - return 0; - } - - var prop = GetPropset(rowsetInfo); - if (prop == null) - { - return 0; - } - - if (prop?.vValue.Anonymous.Anonymous.vt == VARENUM.VT_UI4) - { - var value = prop?.vValue.Anonymous.Anonymous.Anonymous.ulVal; - return (uint)value; - } - - return 0; - } - - public void Dispose() - { - CancelOutstandingQueries(); - - // Free the allocated memory for rgPropertyIDs - if (dbPropIdSet.rgPropertyIDs != IntPtr.Zero) - { - Marshal.FreeCoTaskMem(dbPropIdSet.rgPropertyIDs); - } - - if (reuseRowset != null) - { - Marshal.ReleaseComObject(reuseRowset); - reuseRowset = null; - } - - if (currentRowset != null) - { - Marshal.ReleaseComObject(currentRowset); - currentRowset = null; - } - - queryCompletedEvent?.Dispose(); - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchCatalogManager.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchCatalogManager.cs deleted file mode 100644 index bc2b855844..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchCatalogManager.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -[CoClass(typeof(CSearchCatalogManagerClass))] -[Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF50")] -[ComImport] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1715:Identifiers should have correct prefix", Justification = "Using original name from type lib")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1302:Interface names should begin with I", Justification = "Using original name from type lib")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Using original name from type lib")] -public interface CSearchCatalogManager : ISearchCatalogManager -{ -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchCatalogManagerClass.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchCatalogManagerClass.cs deleted file mode 100644 index 6f64e518a0..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchCatalogManagerClass.cs +++ /dev/null @@ -1,131 +0,0 @@ -// 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.CompilerServices; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -[ComConversionLoss] -[Guid("AAB49DD5-AD0B-40AE-B654-AE8976BF6BD2")] -[ClassInterface((short)0)] -[ComImport] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1212:Property accessors should follow order", Justification = "The order of the property accessors must match the order in which the methods were defined in the vtable")] -public class CSearchCatalogManagerClass : ISearchCatalogManager, CSearchCatalogManager -{ - [DispId(1610678272)] - public virtual extern string Name - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern IntPtr GetParameter([MarshalAs(UnmanagedType.LPWStr), In] string pszName); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void SetParameter([MarshalAs(UnmanagedType.LPWStr), In] string pszName, [In] ref object pValue); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void GetCatalogStatus( - out object pStatus, - out object pPausedReason); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void Reset(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void Reindex(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void ReindexMatchingURLs([MarshalAs(UnmanagedType.LPWStr), In] string pszPattern); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void ReindexSearchRoot([MarshalAs(UnmanagedType.LPWStr), In] string pszRoot); - - [DispId(1610678280)] - public virtual extern uint ConnectTimeout - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678282)] - public virtual extern uint DataTimeout - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern int NumberOfItems(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void NumberOfItemsToIndex( - out int plIncrementalCount, - out int plNotificationQueue, - out int plHighPriorityQueue); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - public virtual extern string URLBeingIndexed(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern uint GetURLIndexingState([MarshalAs(UnmanagedType.LPWStr), In] string psz); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - public virtual extern object GetPersistentItemsChangedSink(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void RegisterViewForNotification( - [MarshalAs(UnmanagedType.LPWStr), In] string pszView, - [MarshalAs(UnmanagedType.Interface), In] object pViewChangedSink, - out uint pdwCookie); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void GetItemsChangedSink( - [MarshalAs(UnmanagedType.Interface), In] object pISearchNotifyInlineSite, - [In] ref Guid riid, - out IntPtr ppv, - out Guid pGUIDCatalogResetSignature, - out Guid pGUIDCheckPointSignature, - out uint pdwLastCheckPointNumber); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void UnregisterViewForNotification([In] uint dwCookie); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void SetExtensionClusion([MarshalAs(UnmanagedType.LPWStr), In] string pszExtension, [In] int fExclude); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - public virtual extern object EnumerateExcludedExtensions(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - public virtual extern CSearchQueryHelper GetQueryHelper(); - - [DispId(1610678295)] - public virtual extern int DiacriticSensitivity - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - public virtual extern object GetCrawlScopeManager(); -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchManager.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchManager.cs deleted file mode 100644 index 210bb3437e..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchManager.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -[CoClass(typeof(CSearchManagerClass))] -[Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF69")] -[ComImport] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1715:Identifiers should have correct prefix", Justification = "Using original name from type lib")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1302:Interface names should begin with I", Justification = "Using original name from type lib")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Using original name from type lib")] -public interface CSearchManager : ISearchManager -{ -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchManagerClass.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchManagerClass.cs deleted file mode 100644 index 19a8067bb7..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchManagerClass.cs +++ /dev/null @@ -1,90 +0,0 @@ -// 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.CompilerServices; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -[Guid("7D096C5F-AC08-4F1F-BEB7-5C22C517CE39")] -[TypeLibType(2)] -[ClassInterface((short)0)] -[ComConversionLoss] -[ComImport] -public class CSearchManagerClass : ISearchManager, CSearchManager -{ - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void GetIndexerVersionStr([MarshalAs(UnmanagedType.LPWStr)] out string ppszVersionString); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void GetIndexerVersion(out uint pdwMajor, out uint pdwMinor); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern IntPtr GetParameter([MarshalAs(UnmanagedType.LPWStr), In] string pszName); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void SetParameter([MarshalAs(UnmanagedType.LPWStr), In] string pszName, [In] ref object pValue); - - [DispId(1610678276)] - public virtual extern string ProxyName - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678277)] - public virtual extern string BypassList - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void SetProxy( - [In] object sUseProxy, - [In] int fLocalByPassProxy, - [In] uint dwPortNumber, - [MarshalAs(UnmanagedType.LPWStr), In] string pszProxyName, - [MarshalAs(UnmanagedType.LPWStr), In] string pszByPassList); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - public virtual extern CSearchCatalogManager GetCatalog([MarshalAs(UnmanagedType.LPWStr), In] string pszCatalog); - - [DispId(1610678280)] - public virtual extern string UserAgent - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - } - - [DispId(1610678282)] - public virtual extern object UseProxy - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678283)] - public virtual extern int LocalBypass - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678284)] - public virtual extern uint PortNumber - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchQueryHelper.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchQueryHelper.cs deleted file mode 100644 index c4a08859ff..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchQueryHelper.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -[Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF63")] -[CoClass(typeof(CSearchQueryHelperClass))] -[ComImport] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1715:Identifiers should have correct prefix", Justification = "Using original name from type lib")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1302:Interface names should begin with I", Justification = "Using original name from type lib")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Using original name from type lib")] -public interface CSearchQueryHelper : ISearchQueryHelper -{ -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchQueryHelperClass.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchQueryHelperClass.cs deleted file mode 100644 index f89de6050b..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/CSearchQueryHelperClass.cs +++ /dev/null @@ -1,134 +0,0 @@ -// 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.CompilerServices; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -[ClassInterface((short)0)] -[Guid("B271E955-09E1-42E1-9B95-5994A534B613")] -[ComImport] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1212:Property accessors should follow order", Justification = "The order of the property accessors must match the order in which the methods were defined in the vtable")] -public class CSearchQueryHelperClass : ISearchQueryHelper, CSearchQueryHelper -{ - [DispId(1610678272)] - public virtual extern string ConnectionString - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678273)] - public virtual extern uint QueryContentLocale - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678275)] - public virtual extern uint QueryKeywordLocale - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678277)] - public virtual extern object QueryTermExpansion - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678279)] - public virtual extern object QuerySyntax - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678281)] - public virtual extern string QueryContentProperties - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678283)] - public virtual extern string QuerySelectColumns - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678285)] - public virtual extern string QueryWhereRestrictions - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678287)] - public virtual extern string QuerySorting - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - public virtual extern string GenerateSQLFromUserQuery([MarshalAs(UnmanagedType.LPWStr), In] string pszQuery); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - public virtual extern void WriteProperties( - [In] int itemID, - [In] uint dwNumberOfColumns, - [In] ref object pColumns, - [In] ref object pValues, - [In] ref object pftGatherModifiedTime); - - [DispId(1610678291)] - public virtual extern int QueryMaxResults - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchCatalogManager.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchCatalogManager.cs deleted file mode 100644 index 04f6f76be0..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchCatalogManager.cs +++ /dev/null @@ -1,129 +0,0 @@ -// 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.CompilerServices; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -[Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF50")] -[ComConversionLoss] -[InterfaceType(1)] -[ComImport] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1212:Property accessors should follow order", Justification = "The order of the property accessors must match the order in which the methods were defined in the vtable")] -public interface ISearchCatalogManager -{ - [DispId(1610678272)] - string Name - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - IntPtr GetParameter([MarshalAs(UnmanagedType.LPWStr), In] string pszName); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void SetParameter([MarshalAs(UnmanagedType.LPWStr), In] string pszName, [In] ref object pValue); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void GetCatalogStatus(out object pStatus, out object pPausedReason); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void Reset(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void Reindex(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void ReindexMatchingURLs([MarshalAs(UnmanagedType.LPWStr), In] string pszPattern); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void ReindexSearchRoot([MarshalAs(UnmanagedType.LPWStr), In] string pszRoot); - - [DispId(1610678280)] - uint ConnectTimeout - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678282)] - uint DataTimeout - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - int NumberOfItems(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void NumberOfItemsToIndex( - out int plIncrementalCount, - out int plNotificationQueue, - out int plHighPriorityQueue); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - string URLBeingIndexed(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - uint GetURLIndexingState([MarshalAs(UnmanagedType.LPWStr), In] string psz); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - object GetPersistentItemsChangedSink(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void RegisterViewForNotification( - [MarshalAs(UnmanagedType.LPWStr), In] string pszView, - [MarshalAs(UnmanagedType.Interface), In] object pViewChangedSink, - out uint pdwCookie); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void GetItemsChangedSink( - [MarshalAs(UnmanagedType.Interface), In] object pISearchNotifyInlineSite, - [In] ref Guid riid, - out IntPtr ppv, - out Guid pGUIDCatalogResetSignature, - out Guid pGUIDCheckPointSignature, - out uint pdwLastCheckPointNumber); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void UnregisterViewForNotification([In] uint dwCookie); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void SetExtensionClusion([MarshalAs(UnmanagedType.LPWStr), In] string pszExtension, [In] int fExclude); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - object EnumerateExcludedExtensions(); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - CSearchQueryHelper GetQueryHelper(); - - [DispId(1610678295)] - int DiacriticSensitivity - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - object GetCrawlScopeManager(); -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchManager.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchManager.cs deleted file mode 100644 index 042e0f7660..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchManager.cs +++ /dev/null @@ -1,89 +0,0 @@ -// 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.CompilerServices; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -[ComConversionLoss] -[Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF69")] -[InterfaceType(1)] -[ComImport] -public interface ISearchManager -{ - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void GetIndexerVersionStr([MarshalAs(UnmanagedType.LPWStr)] out string ppszVersionString); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void GetIndexerVersion(out uint pdwMajor, out uint pdwMinor); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - IntPtr GetParameter([MarshalAs(UnmanagedType.LPWStr), In] string pszName); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void SetParameter([MarshalAs(UnmanagedType.LPWStr), In] string pszName, [In] ref object pValue); - - [DispId(1610678276)] - string ProxyName - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678277)] - string BypassList - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void SetProxy( - [In] object sUseProxy, - [In] int fLocalByPassProxy, - [In] uint dwPortNumber, - [MarshalAs(UnmanagedType.LPWStr), In] string pszProxyName, - [MarshalAs(UnmanagedType.LPWStr), In] string pszByPassList); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.Interface)] - CSearchCatalogManager GetCatalog([MarshalAs(UnmanagedType.LPWStr), In] string pszCatalog); - - [DispId(1610678280)] - string UserAgent - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - } - - [DispId(1610678282)] - object UseProxy - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678283)] - int LocalBypass - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678284)] - uint PortNumber - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchQueryHelper.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchQueryHelper.cs deleted file mode 100644 index e560935639..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Indexer/SystemSearch/ISearchQueryHelper.cs +++ /dev/null @@ -1,134 +0,0 @@ -// 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.CompilerServices; -using System.Runtime.InteropServices; - -namespace Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch; - -[Guid("AB310581-AC80-11D1-8DF3-00C04FB6EF63")] -[InterfaceType(1)] -[ComImport] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1212:Property accessors should follow order", Justification = "The order of the property accessors must match the order in which the methods were defined in the vtable")] -public interface ISearchQueryHelper -{ - [DispId(1610678272)] - string ConnectionString - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678273)] - uint QueryContentLocale - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678275)] - uint QueryKeywordLocale - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678277)] - object QueryTermExpansion - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678279)] - object QuerySyntax - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } - - [DispId(1610678281)] - string QueryContentProperties - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678283)] - string QuerySelectColumns - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678285)] - string QueryWhereRestrictions - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [DispId(1610678287)] - string QuerySorting - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: MarshalAs(UnmanagedType.LPWStr)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - get; - } - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [return: MarshalAs(UnmanagedType.LPWStr)] - string GenerateSQLFromUserQuery([MarshalAs(UnmanagedType.LPWStr), In] string pszQuery); - - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - void WriteProperties( - [In] int itemID, - [In] uint dwNumberOfColumns, - [In] ref object pColumns, - [In] ref object pValues, - [In] ref object pftGatherModifiedTime); - - [DispId(1610678291)] - int QueryMaxResults - { - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - [param: In] - set; - [MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] - get; - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/NativeMethods.txt b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/NativeMethods.txt deleted file mode 100644 index f0702ba24c..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/NativeMethods.txt +++ /dev/null @@ -1,11 +0,0 @@ -DBID -SHOW_WINDOW_CMD -CoCreateInstance -GetErrorInfo -ICommandText -IDBCreateCommand -IDBCreateSession -IDBInitialize -IGetRow -IPropertyStore -ShellExecuteEx \ No newline at end of file diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Pages/Icons.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Pages/Icons.cs deleted file mode 100644 index a55cbe506e..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Indexer/Pages/Icons.cs +++ /dev/null @@ -1,20 +0,0 @@ -// 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.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Indexer; - -internal sealed class Icons -{ - internal static IconInfo FileExplorerSegoe { get; } = new("\uEC50"); - - internal static IconInfo FileExplorer { get; } = IconHelpers.FromRelativePath("Assets\\FileExplorer.png"); - - internal static IconInfo OpenFile { get; } = new("\uE8E5"); // OpenFile - - internal static IconInfo Document { get; } = new("\uE8A5"); // Document - - internal static IconInfo FolderOpen { get; } = new("\uE838"); // FolderOpen -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs deleted file mode 100644 index d1bdb0e7be..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Shell/FallbackExecuteItem.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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.Ext.Shell.Commands; -using Microsoft.CmdPal.Ext.Shell.Helpers; -using Microsoft.CmdPal.Ext.Shell.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace Microsoft.CmdPal.Ext.Shell; - -internal sealed partial class FallbackExecuteItem : FallbackCommandItem -{ - private readonly ExecuteItem _executeItem; - - public FallbackExecuteItem(SettingsManager settings) - : base(new ExecuteItem(string.Empty, settings), Resources.shell_command_display_title) - { - _executeItem = (ExecuteItem)this.Command!; - Title = string.Empty; - _executeItem.Name = string.Empty; - Subtitle = Properties.Resources.generic_run_command; - Icon = Icons.RunV2; // Defined in Icons.cs and contains the execute command icon. - } - - public override void UpdateQuery(string query) - { - _executeItem.Cmd = query; - _executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Properties.Resources.generic_run_command; - Title = query; - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs deleted file mode 100644 index 0dc0265a7d..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs +++ /dev/null @@ -1,44 +0,0 @@ -// 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.CmdPal.Ext.WebSearch.Helpers; -using Microsoft.CmdPal.Ext.WebSearch.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; - -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; - -namespace Microsoft.CmdPal.Ext.WebSearch.Commands; - -internal sealed partial class SearchWebCommand : InvokableCommand -{ - private readonly SettingsManager _settingsManager; - - public string Arguments { get; internal set; } = string.Empty; - - internal SearchWebCommand(string arguments, SettingsManager settingsManager) - { - Arguments = arguments; - BrowserInfo.UpdateIfTimePassed(); - Icon = IconHelpers.FromRelativePath("Assets\\WebSearch.png"); - Name = Properties.Resources.open_in_default_browser; - _settingsManager = settingsManager; - } - - public override CommandResult Invoke() - { - if (!ShellHelpers.OpenCommandInShell(BrowserInfo.Path, BrowserInfo.ArgumentsPattern, $"? {Arguments}")) - { - // TODO GH# 138 --> actually display feedback from the extension somewhere. - return CommandResult.KeepOpen(); - } - - if (_settingsManager.ShowHistory != Resources.history_none) - { - _settingsManager.SaveHistory(new HistoryItem(Arguments, DateTime.Now)); - } - - return CommandResult.Dismiss(); - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs deleted file mode 100644 index e62f1a7c0a..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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.Text; -using Microsoft.CmdPal.Ext.WebSearch.Helpers; -using Microsoft.CmdPal.Ext.WebSearch.Properties; -using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; - -namespace Microsoft.CmdPal.Ext.WebSearch.Commands; - -internal sealed partial class FallbackExecuteSearchItem : FallbackCommandItem -{ - private readonly SearchWebCommand _executeItem; - private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); - - public FallbackExecuteSearchItem(SettingsManager settings) - : base(new SearchWebCommand(string.Empty, settings), Resources.command_item_title) - { - _executeItem = (SearchWebCommand)this.Command!; - Title = string.Empty; - _executeItem.Name = string.Empty; - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName); - Icon = IconHelpers.FromRelativePath("Assets\\WebSearch.png"); - } - - public override void UpdateQuery(string query) - { - _executeItem.Arguments = query; - _executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Properties.Resources.open_in_default_browser; - Title = query; - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs deleted file mode 100644 index 473675e32a..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Text; -using System.Threading; - -namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; - -/// <summary> -/// Contains information (e.g. path to executable, name...) about the default browser. -/// </summary> -public static class DefaultBrowserInfo -{ - private static readonly Lock _updateLock = new(); - - /// <summary>Gets the path to the MS Edge browser executable.</summary> - public static string MSEdgePath => System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), - @"Microsoft\Edge\Application\msedge.exe"); - - /// <summary>Gets the command line pattern of the MS Edge.</summary> - public const string MSEdgeArgumentsPattern = "--single-argument %1"; - - public const string MSEdgeName = "Microsoft Edge"; - - /// <summary>Gets the path to default browser's executable.</summary> - public static string? Path { get; private set; } - - /// <summary>Gets <see cref="Path"/> since the icon is embedded in the executable.</summary> - public static string? IconPath => Path; - - /// <summary>Gets the user-friendly name of the default browser.</summary> - public static string? Name { get; private set; } - - /// <summary>Gets the command line pattern of the default browser.</summary> - public static string? ArgumentsPattern { get; private set; } - - public static bool IsDefaultBrowserSet => !string.IsNullOrEmpty(Path); - - public const long UpdateTimeout = 300; - - private static long _lastUpdateTickCount = -UpdateTimeout; - - private static bool _updatedOnce; - private static bool _errorLogged; - - /// <summary> - /// Updates only if at least more than 300ms has passed since the last update, to avoid multiple calls to <see cref="Update"/>. - /// (because of multiple plugins calling update at the same time.) - /// </summary> - public static void UpdateIfTimePassed() - { - var curTickCount = Environment.TickCount64; - if (curTickCount - _lastUpdateTickCount >= UpdateTimeout) - { - _lastUpdateTickCount = curTickCount; - Update(); - } - } - - /// <summary> - /// Consider using <see cref="UpdateIfTimePassed"/> to avoid updating multiple times. - /// (because of multiple plugins calling update at the same time.) - /// </summary> - public static void Update() - { - lock (_updateLock) - { - if (!_updatedOnce) - { - // Log.Info("I've tried updating the chosen Web Browser info at least once.", typeof(DefaultBrowserInfo)); - _updatedOnce = true; - } - - try - { - var progId = GetRegistryValue( - @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", - "ProgId"); - var appName = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\Application", "ApplicationName") - ?? GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}", "FriendlyTypeName"); - - if (appName != null) - { - // Handle indirect strings: - if (appName.StartsWith('@')) - { - appName = GetIndirectString(appName); - } - - appName = appName - .Replace("URL", null, StringComparison.OrdinalIgnoreCase) - .Replace("HTML", null, StringComparison.OrdinalIgnoreCase) - .Replace("Document", null, StringComparison.OrdinalIgnoreCase) - .Replace("Web", null, StringComparison.OrdinalIgnoreCase) - .TrimEnd(); - } - - Name = appName; - - var commandPattern = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\shell\open\command", null); - - if (string.IsNullOrEmpty(commandPattern)) - { - throw new ArgumentOutOfRangeException( - nameof(commandPattern), - "Default browser program command is not specified."); - } - - if (commandPattern.StartsWith('@')) - { - commandPattern = GetIndirectString(commandPattern); - } - - // HACK: for firefox installed through Microsoft store - // When installed through Microsoft Firefox the commandPattern does not have - // quotes for the path. As the Program Files does have a space - // the extracted path would be invalid, here we add the quotes to fix it - const string FirefoxExecutableName = "firefox.exe"; - if (commandPattern.Contains(FirefoxExecutableName) && commandPattern.Contains(@"\WindowsApps\") && (!commandPattern.StartsWith('\"'))) - { - var pathEndIndex = commandPattern.IndexOf(FirefoxExecutableName, StringComparison.Ordinal) + FirefoxExecutableName.Length; - commandPattern = commandPattern.Insert(pathEndIndex, "\""); - commandPattern = commandPattern.Insert(0, "\""); - } - - if (commandPattern.StartsWith('\"')) - { - var endQuoteIndex = commandPattern.IndexOf('\"', 1); - if (endQuoteIndex != -1) - { - Path = commandPattern.Substring(1, endQuoteIndex - 1); - ArgumentsPattern = commandPattern.Substring(endQuoteIndex + 1).Trim(); - } - } - else - { - var spaceIndex = commandPattern.IndexOf(' '); - if (spaceIndex != -1) - { - Path = commandPattern.Substring(0, spaceIndex); - ArgumentsPattern = commandPattern.Substring(spaceIndex + 1).Trim(); - } - } - - // Packaged applications could be an URI. Example: shell:AppsFolder\Microsoft.MicrosoftEdge.Stable_8wekyb3d8bbwe!App - if (!System.IO.Path.Exists(Path) && !Uri.TryCreate(Path, UriKind.Absolute, out _)) - { - throw new ArgumentException( - $"Command validation failed: {commandPattern}", - nameof(commandPattern)); - } - - if (string.IsNullOrEmpty(Path)) - { - throw new ArgumentOutOfRangeException( - nameof(Path), - "Default browser program path could not be determined."); - } - } - catch (Exception) - { - // Fallback to MS Edge - Path = MSEdgePath; - Name = MSEdgeName; - ArgumentsPattern = MSEdgeArgumentsPattern; - - if (!_errorLogged) - { - // Log.Exception("Exception when retrieving browser path/name. Path and Name are set to use Microsoft Edge.", e, typeof(DefaultBrowserInfo)); - _errorLogged = true; - } - } - - string? GetRegistryValue(string registryLocation, string? valueName) - { - return Microsoft.Win32.Registry.GetValue(registryLocation, valueName, null) as string; - } - - string GetIndirectString(string str) - { - var stringBuilder = new StringBuilder(128); - unsafe - { - var buffer = stackalloc char[128]; - var capacity = 128; - void* reserved = null; - - // S_OK == 0 - if (global::Windows.Win32.PInvoke.SHLoadIndirectString( - str, - buffer, - (uint)capacity, - ref reserved) - == 0) - { - return new string(buffer); - } - } - - throw new ArgumentNullException(nameof(str), "Could not load indirect string."); - } - } - } -} diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs b/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs deleted file mode 100644 index 8b1a98d136..0000000000 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.WinGet/Pages/InstallPackageListItem.cs +++ /dev/null @@ -1,225 +0,0 @@ -// 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.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using ManagedCommon; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Microsoft.Management.Deployment; -using Windows.Foundation.Metadata; - -namespace Microsoft.CmdPal.Ext.WinGet.Pages; - -public partial class InstallPackageListItem : ListItem -{ - private readonly CatalogPackage _package; - - // Lazy-init the details - private readonly Lazy<Details?> _details; - - public override IDetails? Details { get => _details.Value; set => base.Details = value; } - - private InstallPackageCommand? _installCommand; - - public InstallPackageListItem(CatalogPackage package) - : base(new NoOpCommand()) - { - _package = package; - - var version = _package.DefaultInstallVersion; - var versionTagText = "Unknown"; - if (version != null) - { - versionTagText = version.Version == "Unknown" && version.PackageCatalog.Info.Id == "StoreEdgeFD" ? "msstore" : version.Version; - } - - Title = _package.Name; - Subtitle = _package.Id; - Tags = [new Tag() { Text = versionTagText }]; - - _details = new Lazy<Details?>(() => BuildDetails(version)); - - _ = Task.Run(UpdatedInstalledStatus); - } - - private Details? BuildDetails(PackageVersionInfo? version) - { - var metadata = version?.GetCatalogPackageMetadata(); - if (metadata != null) - { - if (metadata.Tags.Where(t => t.Equals(WinGetExtensionPage.ExtensionsTag, StringComparison.OrdinalIgnoreCase)).Any()) - { - if (_installCommand != null) - { - _installCommand.SkipDependencies = true; - } - } - - var description = string.IsNullOrEmpty(metadata.Description) ? metadata.ShortDescription : metadata.Description; - var detailsBody = $""" - -{description} -"""; - IconInfo heroIcon = new(string.Empty); - var icons = metadata.Icons; - if (icons.Count > 0) - { - // There's also a .Theme property we could probably use to - // switch between default or individual icons. - heroIcon = new IconInfo(icons[0].Url); - } - - return new Details() - { - Body = detailsBody, - Title = metadata.PackageName, - HeroImage = heroIcon, - Metadata = GetDetailsMetadata(metadata).ToArray(), - }; - } - - return null; - } - - private List<IDetailsElement> GetDetailsMetadata(CatalogPackageMetadata metadata) - { - List<IDetailsElement> detailsElements = []; - - // key -> {text, url} - Dictionary<string, (string, string)> simpleData = new() - { - { Properties.Resources.winget_author, (metadata.Author, string.Empty) }, - { Properties.Resources.winget_publisher, (metadata.Publisher, metadata.PublisherUrl) }, - { Properties.Resources.winget_copyright, (metadata.Copyright, metadata.CopyrightUrl) }, - { Properties.Resources.winget_license, (metadata.License, metadata.LicenseUrl) }, - { Properties.Resources.winget_publisher_support, (string.Empty, metadata.PublisherSupportUrl) }, - - // The link to the release notes will only show up if there is an - // actual URL for the release notes - { Properties.Resources.winget_view_release_notes, (string.IsNullOrEmpty(metadata.ReleaseNotesUrl) ? string.Empty : Properties.Resources.winget_view_online, metadata.ReleaseNotesUrl) }, - - // These can be l o n g - { Properties.Resources.winget_release_notes, (metadata.ReleaseNotes, string.Empty) }, - }; - var docs = metadata.Documentations.ToArray(); - foreach (var item in docs) - { - simpleData.Add(item.DocumentLabel, (string.Empty, item.DocumentUrl)); - } - - UriCreationOptions options = default; - foreach (var kv in simpleData) - { - var text = string.IsNullOrEmpty(kv.Value.Item1) ? kv.Value.Item2 : kv.Value.Item1; - var target = kv.Value.Item2; - if (!string.IsNullOrEmpty(text)) - { - Uri? uri = null; - Uri.TryCreate(target, options, out uri); - - DetailsElement pair = new() - { - Key = kv.Key, - Data = new DetailsLink() { Link = uri, Text = text }, - }; - detailsElements.Add(pair); - } - } - - if (metadata.Tags.Any()) - { - DetailsElement pair = new() - { - Key = "Tags", - Data = new DetailsTags() { Tags = metadata.Tags.Select(t => new Tag(t)).ToArray() }, - }; - detailsElements.Add(pair); - } - - return detailsElements; - } - - private async void UpdatedInstalledStatus() - { - var status = await _package.CheckInstalledStatusAsync(); - var isInstalled = _package.InstalledVersion != null; - - // might be an uninstall command - InstallPackageCommand installCommand = new(_package, isInstalled); - - if (isInstalled) - { - this.Icon = InstallPackageCommand.CompletedIcon; - this.Command = new NoOpCommand(); - List<IContextItem> contextMenu = []; - CommandContextItem uninstallContextItem = new(installCommand) - { - IsCritical = true, - Icon = InstallPackageCommand.DeleteIcon, - }; - - if (WinGetStatics.AppSearchCallback != null) - { - var callback = WinGetStatics.AppSearchCallback; - var installedApp = callback(_package.DefaultInstallVersion == null ? _package.Name : _package.DefaultInstallVersion.DisplayName); - if (installedApp != null) - { - this.Command = installedApp.Command; - contextMenu = [.. installedApp.MoreCommands]; - } - } - - contextMenu.Add(uninstallContextItem); - this.MoreCommands = contextMenu.ToArray(); - return; - } - - // didn't find the app - _installCommand = new InstallPackageCommand(_package, isInstalled); - this.Command = _installCommand; - - Icon = _installCommand.Icon; - _installCommand.InstallStateChanged += InstallStateChangedHandler; - } - - private void InstallStateChangedHandler(object? sender, InstallPackageCommand e) - { - if (!ApiInformation.IsApiContractPresent("Microsoft.Management.Deployment", 12)) - { - Logger.LogError($"RefreshPackageCatalogAsync isn't available"); - e.FakeChangeStatus(); - Command = e; - Icon = (IconInfo?)Command.Icon; - return; - } - - _ = Task.Run(() => - { - Stopwatch s = new(); - Logger.LogDebug($"Starting RefreshPackageCatalogAsync"); - s.Start(); - var refs = WinGetStatics.AvailableCatalogs.ToArray(); - - foreach (var catalog in refs) - { - var operation = catalog.RefreshPackageCatalogAsync(); - operation.Wait(); - } - - s.Stop(); - Logger.LogDebug($"RefreshPackageCatalogAsync took {s.ElapsedMilliseconds}ms"); - }).ContinueWith((previous) => - { - if (previous.IsCompletedSuccessfully) - { - Logger.LogDebug($"Updating InstalledStatus"); - UpdatedInstalledStatus(); - } - }); - } -} diff --git a/src/modules/colorPicker/ColorPicker.ModuleServices/ColorFormatValue.cs b/src/modules/colorPicker/ColorPicker.ModuleServices/ColorFormatValue.cs new file mode 100644 index 0000000000..90e71e6f18 --- /dev/null +++ b/src/modules/colorPicker/ColorPicker.ModuleServices/ColorFormatValue.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 ColorPicker.ModuleServices; + +public sealed record ColorFormatValue(string Format, string Value); diff --git a/src/modules/colorPicker/ColorPicker.ModuleServices/ColorPicker.ModuleServices.csproj b/src/modules/colorPicker/ColorPicker.ModuleServices/ColorPicker.ModuleServices.csproj new file mode 100644 index 0000000000..e3ab24a9bb --- /dev/null +++ b/src/modules/colorPicker/ColorPicker.ModuleServices/ColorPicker.ModuleServices.csproj @@ -0,0 +1,29 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <EnableDefaultCompileItems>false</EnableDefaultCompileItems> + </PropertyGroup> + + <ItemGroup> + <Compile Include="ColorFormatValue.cs" /> + <Compile Include="ColorPickerService.cs" /> + <Compile Include="IColorPickerService.cs" /> + <Compile Include="ColorPickerServiceJsonContext.cs" /> + <Compile Include="SavedColor.cs" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" /> + <ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" /> + <ProjectReference Include="..\..\..\common\PowerToys.ModuleContracts\PowerToys.ModuleContracts.csproj" /> + <ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/colorPicker/ColorPicker.ModuleServices/ColorPickerService.cs b/src/modules/colorPicker/ColorPicker.ModuleServices/ColorPickerService.cs new file mode 100644 index 0000000000..6407ae6ed9 --- /dev/null +++ b/src/modules/colorPicker/ColorPicker.ModuleServices/ColorPickerService.cs @@ -0,0 +1,157 @@ +// 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.Drawing; +using System.Text.Json; +using Common.UI; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerToys.Interop; +using PowerToys.ModuleContracts; + +namespace ColorPicker.ModuleServices; + +/// <summary> +/// Provides programmatic control for Color Picker actions. +/// </summary> +public sealed class ColorPickerService : ModuleServiceBase, IColorPickerService +{ + public static ColorPickerService Instance { get; } = new(); + + public override string Key => SettingsDeepLink.SettingsWindow.ColorPicker.ToString(); + + protected override SettingsDeepLink.SettingsWindow SettingsWindow => SettingsDeepLink.SettingsWindow.ColorPicker; + + public override Task<OperationResult> LaunchAsync(CancellationToken cancellationToken = default) + { + // Default launch -> open picker. + return OpenPickerAsync(cancellationToken); + } + + public Task<OperationResult> OpenPickerAsync(CancellationToken cancellationToken = default) + { + return SignalEventAsync(Constants.ShowColorPickerSharedEvent(), "Color Picker"); + } + + public Task<OperationResult<IReadOnlyList<SavedColor>>> GetSavedColorsAsync(CancellationToken cancellationToken = default) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var historyPath = Path.Combine(localAppData, "Microsoft", "PowerToys", "ColorPicker", "colorHistory.json"); + if (!File.Exists(historyPath)) + { + return Task.FromResult(OperationResults.Ok<IReadOnlyList<SavedColor>>(Array.Empty<SavedColor>())); + } + + using var stream = File.OpenRead(historyPath); + var colors = JsonSerializer.Deserialize(stream, ColorPickerServiceJsonContext.Default.ListString) ?? new List<string>(); + + var settingsUtils = SettingsUtils.Default; + var settings = settingsUtils.GetSettingsOrDefault<ColorPickerSettings>(ColorPickerSettings.ModuleName); + + var results = new List<SavedColor>(colors.Count); + foreach (var entry in colors) + { + if (!TryParseArgb(entry, out var color)) + { + continue; + } + + var formats = BuildFormats(color, settings); + var hex = $"#{color.R:X2}{color.G:X2}{color.B:X2}"; + + results.Add(new SavedColor( + hex, + color.A, + color.R, + color.G, + color.B, + formats)); + } + + return Task.FromResult(OperationResults.Ok<IReadOnlyList<SavedColor>>(results)); + } + catch (OperationCanceledException) + { + return Task.FromResult(OperationResults.Fail<IReadOnlyList<SavedColor>>("Reading saved colors was cancelled.")); + } + catch (Exception ex) + { + return Task.FromResult(OperationResults.Fail<IReadOnlyList<SavedColor>>($"Failed to read saved colors: {ex.Message}")); + } + } + + private static Task<OperationResult> SignalEventAsync(string eventName, string actionDescription) + { + try + { + using var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName); + if (!eventHandle.Set()) + { + return Task.FromResult(OperationResult.Fail($"Failed to signal {actionDescription}.")); + } + + return Task.FromResult(OperationResult.Ok()); + } + catch (Exception ex) + { + return Task.FromResult(OperationResult.Fail($"Failed to signal {actionDescription}: {ex.Message}")); + } + } + + private static bool TryParseArgb(string value, out Color color) + { + color = Color.Empty; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var parts = value.Split('|'); + if (parts.Length != 4) + { + return false; + } + + if (byte.TryParse(parts[0], out var a) && + byte.TryParse(parts[1], out var r) && + byte.TryParse(parts[2], out var g) && + byte.TryParse(parts[3], out var b)) + { + color = Color.FromArgb(a, r, g, b); + return true; + } + + return false; + } + + private static IReadOnlyList<ColorFormatValue> BuildFormats(Color color, ColorPickerSettings settings) + { + var formats = new List<ColorFormatValue>(); + foreach (var kvp in settings.Properties.VisibleColorFormats) + { + var formatName = kvp.Key; + var (isVisible, formatString) = kvp.Value; + if (!isVisible) + { + continue; + } + + var formatted = ColorFormatHelper.GetStringRepresentation(color, formatString); + if (formatName.Equals("HEX", StringComparison.OrdinalIgnoreCase) && !formatted.StartsWith('#')) + { + formatted = "#" + formatted; + } + + formats.Add(new ColorFormatValue(formatName, formatted)); + } + + return formats; + } +} diff --git a/src/modules/colorPicker/ColorPicker.ModuleServices/ColorPickerServiceJsonContext.cs b/src/modules/colorPicker/ColorPicker.ModuleServices/ColorPickerServiceJsonContext.cs new file mode 100644 index 0000000000..f26e9009d3 --- /dev/null +++ b/src/modules/colorPicker/ColorPicker.ModuleServices/ColorPickerServiceJsonContext.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.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace ColorPicker.ModuleServices; + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(List<string>))] +[JsonSerializable(typeof(List<SavedColor>))] +[JsonSerializable(typeof(SavedColor))] +[JsonSerializable(typeof(ColorFormatValue))] +[JsonSerializable(typeof(ColorPickerSettings))] +internal sealed partial class ColorPickerServiceJsonContext : JsonSerializerContext +{ +} diff --git a/src/modules/colorPicker/ColorPicker.ModuleServices/IColorPickerService.cs b/src/modules/colorPicker/ColorPicker.ModuleServices/IColorPickerService.cs new file mode 100644 index 0000000000..4ad2ca3da3 --- /dev/null +++ b/src/modules/colorPicker/ColorPicker.ModuleServices/IColorPickerService.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. + +using PowerToys.ModuleContracts; + +namespace ColorPicker.ModuleServices; + +public interface IColorPickerService : IModuleService +{ + Task<OperationResult> OpenPickerAsync(CancellationToken cancellationToken = default); + + Task<OperationResult<IReadOnlyList<SavedColor>>> GetSavedColorsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/modules/colorPicker/ColorPicker.ModuleServices/SavedColor.cs b/src/modules/colorPicker/ColorPicker.ModuleServices/SavedColor.cs new file mode 100644 index 0000000000..3697129aa0 --- /dev/null +++ b/src/modules/colorPicker/ColorPicker.ModuleServices/SavedColor.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 ColorPicker.ModuleServices; + +public sealed record SavedColor(string Hex, byte A, byte R, byte G, byte B, IReadOnlyList<ColorFormatValue> Formats); diff --git a/src/modules/colorPicker/ColorPicker/ColorPicker.vcxproj b/src/modules/colorPicker/ColorPicker/ColorPicker.vcxproj index 2a354198b8..9f89d5d2b4 100644 --- a/src/modules/colorPicker/ColorPicker/ColorPicker.vcxproj +++ b/src/modules/colorPicker/ColorPicker/ColorPicker.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ColorPicker.base.rc ColorPicker.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ColorPicker.base.rc ColorPicker.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> @@ -11,10 +12,9 @@ <RootNamespace>ColorPicker</RootNamespace> <ProjectName>ColorPicker</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -26,13 +26,13 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.ColorPicker</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -54,10 +54,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -71,15 +71,15 @@ </None> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/colorPicker/ColorPicker/packages.config b/src/modules/colorPicker/ColorPicker/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/colorPicker/ColorPicker/packages.config +++ b/src/modules/colorPicker/ColorPicker/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/colorPicker/UnitTest-ColorPickerUI/UnitTest-ColorPickerUI.csproj b/src/modules/colorPicker/ColorPickerUI.UnitTests/ColorPickerUI.UnitTests.csproj similarity index 75% rename from src/modules/colorPicker/UnitTest-ColorPickerUI/UnitTest-ColorPickerUI.csproj rename to src/modules/colorPicker/ColorPickerUI.UnitTests/ColorPickerUI.UnitTests.csproj index 4f9ce638fd..a713c6cd17 100644 --- a/src/modules/colorPicker/UnitTest-ColorPickerUI/UnitTest-ColorPickerUI.csproj +++ b/src/modules/colorPicker/ColorPickerUI.UnitTests/ColorPickerUI.UnitTests.csproj @@ -1,17 +1,20 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> - <ProjectGuid>{090CD7B7-3B0C-4D1D-BC98-83EB5D799BC1}</ProjectGuid> - <RootNamespace>Microsoft.ColorPicker.UnitTests</RootNamespace> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <ProjectGuid>{F93C2817-C846-4259-84D8-B39A6B57C8DE}</ProjectGuid> + <RootNamespace>ColorPicker.UnitTests</RootNamespace> <IsPackable>false</IsPackable> <Nullable>enable</Nullable> <OutputType>Exe</OutputType> </PropertyGroup> <PropertyGroup> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\UnitTest-ColorPickerUI\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\ColorPickerUI.UnitTest\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorConverterTest.cs b/src/modules/colorPicker/ColorPickerUI.UnitTests/Helpers/ColorConverterTest.cs similarity index 80% rename from src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorConverterTest.cs rename to src/modules/colorPicker/ColorPickerUI.UnitTests/Helpers/ColorConverterTest.cs index eaa5369dd6..4d28042543 100644 --- a/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorConverterTest.cs +++ b/src/modules/colorPicker/ColorPickerUI.UnitTests/Helpers/ColorConverterTest.cs @@ -9,7 +9,7 @@ using System.Globalization; using ManagedCommon; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Microsoft.ColorPicker.UnitTests +namespace ColorPicker.UnitTests.Helpers { /// <summary> /// Test class to test <see cref="ColorConverter"/> @@ -364,9 +364,6 @@ namespace Microsoft.ColorPicker.UnitTests [DataRow("8080FF", 59.20, 33.10, -63.46)] // blue [DataRow("BF40BF", 50.10, 65.50, -41.48)] // magenta [DataRow("BFBF00", 75.04, -17.35, 76.03)] // yellow - [DataRow("008000", 46.23, -51.70, 49.90)] // green - [DataRow("8080FF", 59.20, 33.10, -63.46)] // blue - [DataRow("BF40BF", 50.10, 65.50, -41.48)] // magenta [DataRow("0048BA", 34.35, 27.94, -64.80)] // absolute zero [DataRow("B0BF1A", 73.91, -23.39, 71.15)] // acid green [DataRow("D0FF14", 93.87, -40.20, 88.97)] // arctic lime @@ -401,13 +398,121 @@ namespace Microsoft.ColorPicker.UnitTests var result = ColorFormatHelper.ConvertToCIELABColor(color); // lightness[0..100] - Assert.AreEqual(Math.Round(result.Lightness, 2), lightness); + Assert.AreEqual(lightness, Math.Round(result.Lightness, 2)); // chromaticityA[-128..127] - Assert.AreEqual(Math.Round(result.ChromaticityA, 2), chromaticityA); + Assert.AreEqual(chromaticityA, Math.Round(result.ChromaticityA, 2)); // chromaticityB[-128..127] - Assert.AreEqual(Math.Round(result.ChromaticityB, 2), chromaticityB); + Assert.AreEqual(chromaticityB, Math.Round(result.ChromaticityB, 2)); + } + + // Test data calculated using https://oklch.com (which uses https://github.com/Evercoder/culori) + [TestMethod] + [DataRow("FFFFFF", 1.00, 0.00, 0.00)] // white + [DataRow("808080", 0.6, 0.00, 0.00)] // gray + [DataRow("000000", 0.00, 0.00, 0.00)] // black + [DataRow("FF0000", 0.628, 0.22, 0.13)] // red + [DataRow("008000", 0.52, -0.14, 0.11)] // green + [DataRow("80FFFF", 0.928, -0.11, -0.03)] // cyan + [DataRow("8080FF", 0.661, 0.03, -0.18)] // blue + [DataRow("BF40BF", 0.598, 0.18, -0.11)] // magenta + [DataRow("BFBF00", 0.779, -0.06, 0.16)] // yellow + [DataRow("0048BA", 0.444, -0.03, -0.19)] // absolute zero + [DataRow("B0BF1A", 0.767, -0.07, 0.15)] // acid green + [DataRow("D0FF14", 0.934, -0.12, 0.19)] // arctic lime + [DataRow("1B4D3E", 0.382, -0.06, 0.01)] // brunswick green + [DataRow("FFEF00", 0.935, -0.05, 0.19)] // canary yellow + [DataRow("FFA600", 0.794, 0.06, 0.16)] // cheese + [DataRow("1A2421", 0.25, -0.02, 0)] // dark jungle green + [DataRow("003399", 0.371, -0.02, -0.17)] // dark powder blue + [DataRow("D70A53", 0.563, 0.22, 0.04)] // debian red + [DataRow("80FFD5", 0.916, -0.13, 0.02)] // fathom secret green + [DataRow("EFDFBB", 0.907, 0, 0.05)] // dutch white + [DataRow("5218FA", 0.489, 0.05, -0.28)] // han purple + [DataRow("FF496C", 0.675, 0.21, 0.05)] // infra red + [DataRow("545AA7", 0.5, 0.02, -0.12)] // liberty + [DataRow("E6A8D7", 0.804, 0.09, -0.04)] // light orchid + [DataRow("ADDFAD", 0.856, -0.07, 0.05)] // light moss green + [DataRow("E3F988", 0.942, -0.07, 0.12)] // mindaro + public void ColorRGBtoOklabTest(string hexValue, double lightness, double chromaticityA, double chromaticityB) + { + if (string.IsNullOrWhiteSpace(hexValue)) + { + Assert.IsNotNull(hexValue); + } + + Assert.IsTrue(hexValue.Length >= 6); + + var red = int.Parse(hexValue.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var green = int.Parse(hexValue.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var blue = int.Parse(hexValue.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + var color = Color.FromArgb(255, red, green, blue); + var result = ColorFormatHelper.ConvertToOklabColor(color); + + // lightness[0..1] + Assert.AreEqual(lightness, Math.Round(result.Lightness, 3)); + + // chromaticityA[-0.5..0.5] + Assert.AreEqual(chromaticityA, Math.Round(result.ChromaticityA, 2)); + + // chromaticityB[-0.5..0.5] + Assert.AreEqual(chromaticityB, Math.Round(result.ChromaticityB, 2)); + } + + // Test data calculated using https://oklch.com (which uses https://github.com/Evercoder/culori) + [TestMethod] + [DataRow("FFFFFF", 1.00, 0.00, 0.00)] // white + [DataRow("808080", 0.6, 0.00, 0.00)] // gray + [DataRow("000000", 0.00, 0.00, 0.00)] // black + [DataRow("FF0000", 0.628, 0.258, 29.23)] // red + [DataRow("008000", 0.52, 0.177, 142.5)] // green + [DataRow("80FFFF", 0.928, 0.113, 195.38)] // cyan + [DataRow("8080FF", 0.661, 0.184, 280.13)] // blue + [DataRow("BF40BF", 0.598, 0.216, 327.86)] // magenta + [DataRow("BFBF00", 0.779, 0.17, 109.77)] // yellow + [DataRow("0048BA", 0.444, 0.19, 260.86)] // absolute zero + [DataRow("B0BF1A", 0.767, 0.169, 115.4)] // acid green + [DataRow("D0FF14", 0.934, 0.224, 122.28)] // arctic lime + [DataRow("1B4D3E", 0.382, 0.06, 170.28)] // brunswick green + [DataRow("FFEF00", 0.935, 0.198, 104.67)] // canary yellow + [DataRow("FFA600", 0.794, 0.171, 71.19)] // cheese + [DataRow("1A2421", 0.25, 0.015, 174.74)] // dark jungle green + [DataRow("003399", 0.371, 0.173, 262.12)] // dark powder blue + [DataRow("D70A53", 0.563, 0.222, 11.5)] // debian red + [DataRow("80FFD5", 0.916, 0.129, 169.38)] // fathom secret green + [DataRow("EFDFBB", 0.907, 0.05, 86.89)] // dutch white + [DataRow("5218FA", 0.489, 0.286, 279.13)] // han purple + [DataRow("FF496C", 0.675, 0.217, 14.37)] // infra red + [DataRow("545AA7", 0.5, 0.121, 277.7)] // liberty + [DataRow("E6A8D7", 0.804, 0.095, 335.4)] // light orchid + [DataRow("ADDFAD", 0.856, 0.086, 144.78)] // light moss green + [DataRow("E3F988", 0.942, 0.141, 118.24)] // mindaro + public void ColorRGBtoOklchTest(string hexValue, double lightness, double chroma, double hue) + { + if (string.IsNullOrWhiteSpace(hexValue)) + { + Assert.IsNotNull(hexValue); + } + + Assert.IsTrue(hexValue.Length >= 6); + + var red = int.Parse(hexValue.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var green = int.Parse(hexValue.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var blue = int.Parse(hexValue.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + var color = Color.FromArgb(255, red, green, blue); + var result = ColorFormatHelper.ConvertToOklchColor(color); + + // lightness[0..1] + Assert.AreEqual(lightness, Math.Round(result.Lightness, 3)); + + // chroma[0..0.5] + Assert.AreEqual(chroma, Math.Round(result.Chroma, 3)); + + // hue[0°..360°] + Assert.AreEqual(hue, Math.Round(result.Hue, 2)); } // The following results are computed using LittleCMS2, an open-source color management engine, @@ -415,7 +520,7 @@ namespace Microsoft.ColorPicker.UnitTests // echo 0xFF 0xFF 0xFF | transicc -i "*sRGB" -o "*XYZ" -t 3 -d 0 // where "0xFF 0xFF 0xFF" are filled in with the hexadecimal red/green/blue values; // "-t 3" means using absolute colorimetric intent, in other words, disabling white point scaling; - // "-d 0" means disabling chromatic adaptation, otherwise it will output CIEXYZ-D50 instead of D65. + // "-d 0" means disabling chromatic adaptation; otherwise, it will output CIEXYZ-D50 instead of D65. // // If we have the same results as the reference output listed below, it means our algorithm is accurate. [TestMethod] @@ -428,9 +533,6 @@ namespace Microsoft.ColorPicker.UnitTests [DataRow("8080FF", 34.6688, 27.2469, 98.0434)] // blue [DataRow("BF40BF", 32.7217, 18.5062, 51.1405)] // magenta [DataRow("BFBF00", 40.1154, 48.3384, 7.2171)] // yellow - [DataRow("008000", 7.7188, 15.4377, 2.5729)] // green - [DataRow("8080FF", 34.6688, 27.2469, 98.0434)] // blue - [DataRow("BF40BF", 32.7217, 18.5062, 51.1405)] // magenta [DataRow("0048BA", 11.1792, 8.1793, 47.4455)] // absolute zero [DataRow("B0BF1A", 36.7205, 46.5663, 8.0311)] // acid green [DataRow("D0FF14", 61.8965, 84.9797, 13.8037)] // arctic lime diff --git a/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorRepresentationHelperTest.cs b/src/modules/colorPicker/ColorPickerUI.UnitTests/Helpers/ColorRepresentationHelperTest.cs similarity index 91% rename from src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorRepresentationHelperTest.cs rename to src/modules/colorPicker/ColorPickerUI.UnitTests/Helpers/ColorRepresentationHelperTest.cs index a96310dc82..b29778aa4c 100644 --- a/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorRepresentationHelperTest.cs +++ b/src/modules/colorPicker/ColorPickerUI.UnitTests/Helpers/ColorRepresentationHelperTest.cs @@ -8,7 +8,7 @@ using ColorPicker.Helpers; using ManagedCommon; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Microsoft.ColorPicker.UnitTests +namespace ColorPicker.Helpers { [TestClass] public class ColorRepresentationHelperTest @@ -23,8 +23,10 @@ namespace Microsoft.ColorPicker.UnitTests [DataRow("HSV", "hsv(0, 0%, 0%)")] [DataRow("HWB", "hwb(0, 0%, 100%)")] [DataRow("RGB", "rgb(0, 0, 0)")] - [DataRow("CIELAB", "CIELab(0, 0, 0)")] [DataRow("CIEXYZ", "XYZ(0, 0, 0)")] + [DataRow("CIELAB", "CIELab(0, 0, 0)")] + [DataRow("Oklab", "oklab(0, 0, 0)")] + [DataRow("Oklch", "oklch(0, 0, 0)")] [DataRow("VEC4", "(0f, 0f, 0f, 1f)")] [DataRow("Decimal", "0")] [DataRow("HEX Int", "0xFF000000")] diff --git a/src/modules/colorPicker/ColorPickerUI/App.manifest b/src/modules/colorPicker/ColorPickerUI/App.manifest index 94b792d3fe..c99f120d43 100644 --- a/src/modules/colorPicker/ColorPickerUI/App.manifest +++ b/src/modules/colorPicker/ColorPickerUI/App.manifest @@ -52,10 +52,8 @@ <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> - <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings"> - PerMonitor - </dpiAwareness> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/colorPicker/ColorPickerUI/ColorPickerUI.csproj b/src/modules/colorPicker/ColorPickerUI/ColorPickerUI.csproj index d1c55e3db7..2a09975846 100644 --- a/src/modules/colorPicker/ColorPickerUI/ColorPickerUI.csproj +++ b/src/modules/colorPicker/ColorPickerUI/ColorPickerUI.csproj @@ -1,12 +1,12 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <AssemblyTitle>PowerToys.ColorPickerUI</AssemblyTitle> <AssemblyDescription>PowerToys ColorPicker</AssemblyDescription> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> diff --git a/src/modules/colorPicker/ColorPickerUI/Controls/ColorFormatControl.xaml b/src/modules/colorPicker/ColorPickerUI/Controls/ColorFormatControl.xaml index 723b2cfa7f..5c2e22fbee 100644 --- a/src/modules/colorPicker/ColorPickerUI/Controls/ColorFormatControl.xaml +++ b/src/modules/colorPicker/ColorPickerUI/Controls/ColorFormatControl.xaml @@ -140,7 +140,7 @@ <TextBlock x:Name="FormatNameTextBlock" VerticalAlignment="Center" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" Style="{StaticResource CaptionTextBlockStyle}" TextTrimming="CharacterEllipsis" /> diff --git a/src/modules/colorPicker/ColorPickerUI/Helpers/AppStateHandler.cs b/src/modules/colorPicker/ColorPickerUI/Helpers/AppStateHandler.cs index 4940bd75e3..72c874d839 100644 --- a/src/modules/colorPicker/ColorPickerUI/Helpers/AppStateHandler.cs +++ b/src/modules/colorPicker/ColorPickerUI/Helpers/AppStateHandler.cs @@ -107,21 +107,14 @@ namespace ColorPicker.Helpers } } - public void OnColorPickerMouseDown() + public void OpenColorEditor() { - if (_userSettings.ActivationAction.Value == ColorPickerActivationAction.OpenColorPickerAndThenEditor || _userSettings.ActivationAction.Value == ColorPickerActivationAction.OpenEditor) + lock (_colorPickerVisibilityLock) { - lock (_colorPickerVisibilityLock) - { - HideColorPicker(); - } + HideColorPicker(); + } - ShowColorPickerEditor(); - } - else - { - EndUserSession(); - } + ShowColorPickerEditor(); } public static void SetTopMost() @@ -212,7 +205,7 @@ namespace ColorPicker.Helpers private void ColorEditorViewModel_OpenSettingsRequested(object sender, EventArgs e) { - SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.ColorPicker, false); + SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.ColorPicker); } internal void RegisterWindowHandle(System.Windows.Interop.HwndSource hwndSource) @@ -222,7 +215,7 @@ namespace ColorPicker.Helpers public bool HandleEnterPressed() { - if (!IsColorPickerVisible()) + if (!_colorPickerShown) { return false; } @@ -233,14 +226,13 @@ namespace ColorPicker.Helpers public bool HandleEscPressed() { - if (!BlockEscapeKeyClosingColorPickerEditor) + if (!BlockEscapeKeyClosingColorPickerEditor + && (_colorPickerShown || (_colorEditorWindow != null && _colorEditorWindow.IsActive))) { return EndUserSession(); } - else - { - return false; - } + + return false; } internal void MoveCursor(int xOffset, int yOffset) diff --git a/src/modules/colorPicker/ColorPickerUI/Helpers/ColorRepresentationHelper.cs b/src/modules/colorPicker/ColorPickerUI/Helpers/ColorRepresentationHelper.cs index 82b238993d..85f5ab10e2 100644 --- a/src/modules/colorPicker/ColorPickerUI/Helpers/ColorRepresentationHelper.cs +++ b/src/modules/colorPicker/ColorPickerUI/Helpers/ColorRepresentationHelper.cs @@ -34,7 +34,7 @@ namespace ColorPicker.Helpers /// <param name="color">The <see cref="Color"/> for the presentation</param> /// <param name="colorRepresentationType">The type of the representation</param> /// <returns>A <see cref="string"/> representation of a color</returns> - internal static string GetStringRepresentation(Color color, string colorRepresentationType, string colorFormat) + public static string GetStringRepresentation(Color color, string colorRepresentationType, string colorFormat) { if (string.IsNullOrEmpty(colorFormat)) { @@ -243,6 +243,40 @@ namespace ColorPicker.Helpers $", {chromaticityB.ToString(CultureInfo.InvariantCulture)})"; } + /// <summary> + /// Returns a <see cref="string"/> representation of a Oklab color + /// </summary> + /// <param name="color">The <see cref="Color"/> for the Oklab color presentation</param> + /// <returns>A <see cref="string"/> representation of a Oklab color</returns> + private static string ColorToOklab(Color color) + { + var (lightness, chromaticityA, chromaticityB) = ColorFormatHelper.ConvertToOklabColor(color); + lightness = Math.Round(lightness, 2); + chromaticityA = Math.Round(chromaticityA, 2); + chromaticityB = Math.Round(chromaticityB, 2); + + return $"oklab({lightness.ToString(CultureInfo.InvariantCulture)}" + + $", {chromaticityA.ToString(CultureInfo.InvariantCulture)}" + + $", {chromaticityB.ToString(CultureInfo.InvariantCulture)})"; + } + + /// <summary> + /// Returns a <see cref="string"/> representation of a CIE LCh color + /// </summary> + /// <param name="color">The <see cref="Color"/> for the CIE LCh color presentation</param> + /// <returns>A <see cref="string"/> representation of a CIE LCh color</returns> + private static string ColorToOklch(Color color) + { + var (lightness, chroma, hue) = ColorFormatHelper.ConvertToOklchColor(color); + lightness = Math.Round(lightness, 2); + chroma = Math.Round(chroma, 2); + hue = Math.Round(hue, 2); + + return $"oklch({lightness.ToString(CultureInfo.InvariantCulture)}" + + $", {chroma.ToString(CultureInfo.InvariantCulture)}" + + $", {hue.ToString(CultureInfo.InvariantCulture)})"; + } + /// <summary> /// Returns a <see cref="string"/> representation of a CIE XYZ color /// </summary> diff --git a/src/modules/colorPicker/ColorPickerUI/Keyboard/KeyboardMonitor.cs b/src/modules/colorPicker/ColorPickerUI/Keyboard/KeyboardMonitor.cs index 208f139ae8..d6defaacb2 100644 --- a/src/modules/colorPicker/ColorPickerUI/Keyboard/KeyboardMonitor.cs +++ b/src/modules/colorPicker/ColorPickerUI/Keyboard/KeyboardMonitor.cs @@ -70,17 +70,10 @@ namespace ColorPicker.Keyboard var virtualCode = e.KeyboardData.VirtualCode; // ESC pressed - if (virtualCode == KeyInterop.VirtualKeyFromKey(Key.Escape) - && e.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyDown - ) + if (virtualCode == KeyInterop.VirtualKeyFromKey(Key.Escape) && e.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyDown) { - if (_appStateHandler.IsColorPickerVisible() - || !AppStateHandler.BlockEscapeKeyClosingColorPickerEditor - ) - { - e.Handled = _appStateHandler.EndUserSession(); - return; - } + e.Handled = _appStateHandler.HandleEscPressed(); + return; } if ((virtualCode == KeyInterop.VirtualKeyFromKey(Key.Space) || virtualCode == KeyInterop.VirtualKeyFromKey(Key.Enter)) && (e.KeyboardState == GlobalKeyboardHook.KeyboardState.KeyDown)) diff --git a/src/modules/colorPicker/ColorPickerUI/Mouse/IMouseInfoProvider.cs b/src/modules/colorPicker/ColorPickerUI/Mouse/IMouseInfoProvider.cs index 3361b95cb4..51bb35ac51 100644 --- a/src/modules/colorPicker/ColorPickerUI/Mouse/IMouseInfoProvider.cs +++ b/src/modules/colorPicker/ColorPickerUI/Mouse/IMouseInfoProvider.cs @@ -16,10 +16,12 @@ namespace ColorPicker.Mouse // position and bool indicating zoom in or zoom out event EventHandler<Tuple<System.Windows.Point, bool>> OnMouseWheel; - event MouseUpEventHandler OnMouseDown; + event PrimaryMouseDownEventHandler OnPrimaryMouseDown; event SecondaryMouseUpEventHandler OnSecondaryMouseUp; + event MiddleMouseDownEventHandler OnMiddleMouseDown; + System.Windows.Point CurrentPosition { get; } Color CurrentColor { get; } diff --git a/src/modules/colorPicker/ColorPickerUI/Mouse/MouseHook.cs b/src/modules/colorPicker/ColorPickerUI/Mouse/MouseHook.cs index 8476398c99..72fffd1445 100644 --- a/src/modules/colorPicker/ColorPickerUI/Mouse/MouseHook.cs +++ b/src/modules/colorPicker/ColorPickerUI/Mouse/MouseHook.cs @@ -7,17 +7,18 @@ using System.Diagnostics; using System.Runtime.InteropServices; using System.Windows.Input; -using ColorPicker.Helpers; using ManagedCommon; using static ColorPicker.NativeMethods; namespace ColorPicker.Mouse { - public delegate void MouseUpEventHandler(object sender, System.Drawing.Point p); + public delegate void PrimaryMouseDownEventHandler(object sender, IntPtr wParam); public delegate void SecondaryMouseUpEventHandler(object sender, IntPtr wParam); + public delegate void MiddleMouseDownEventHandler(object sender, IntPtr wParam); + internal class MouseHook { [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Interop object")] @@ -30,23 +31,25 @@ namespace ColorPicker.Mouse private const int WM_RBUTTONUP = 0x0205; [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Interop object")] private const int WM_RBUTTONDOWN = 0x0204; + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Interop object")] + private const int WM_MBUTTONDOWN = 0x0207; private IntPtr _mouseHookHandle; private HookProc _mouseDelegate; - private event MouseUpEventHandler MouseDown; + private event PrimaryMouseDownEventHandler PrimaryMouseDown; - public event MouseUpEventHandler OnMouseDown + public event PrimaryMouseDownEventHandler OnPrimaryMouseDown { add { Subscribe(); - MouseDown += value; + PrimaryMouseDown += value; } remove { - MouseDown -= value; + PrimaryMouseDown -= value; Unsubscribe(); } } @@ -68,6 +71,23 @@ namespace ColorPicker.Mouse } } + private event MiddleMouseDownEventHandler MiddleMouseDown; + + public event MiddleMouseDownEventHandler OnMiddleMouseDown + { + add + { + Subscribe(); + MiddleMouseDown += value; + } + + remove + { + MiddleMouseDown -= value; + Unsubscribe(); + } + } + private event MouseWheelEventHandler MouseWheel; public event MouseWheelEventHandler OnMouseWheel @@ -126,9 +146,9 @@ namespace ColorPicker.Mouse MSLLHOOKSTRUCT mouseHookStruct = (MSLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(MSLLHOOKSTRUCT)); if (wParam.ToInt32() == WM_LBUTTONDOWN) { - if (MouseDown != null) + if (PrimaryMouseDown != null) { - MouseDown.Invoke(null, new System.Drawing.Point(mouseHookStruct.pt.x, mouseHookStruct.pt.y)); + PrimaryMouseDown.Invoke(null, wParam); } return new IntPtr(-1); @@ -150,6 +170,16 @@ namespace ColorPicker.Mouse return new IntPtr(-1); } + if (wParam.ToInt32() == WM_MBUTTONDOWN) + { + if (MiddleMouseDown != null) + { + MiddleMouseDown.Invoke(null, wParam); + } + + return new IntPtr(-1); + } + if (wParam.ToInt32() == WM_MOUSEWHEEL) { if (MouseWheel != null) diff --git a/src/modules/colorPicker/ColorPickerUI/Mouse/MouseInfoProvider.cs b/src/modules/colorPicker/ColorPickerUI/Mouse/MouseInfoProvider.cs index 76c376b761..4d6596bc3f 100644 --- a/src/modules/colorPicker/ColorPickerUI/Mouse/MouseInfoProvider.cs +++ b/src/modules/colorPicker/ColorPickerUI/Mouse/MouseInfoProvider.cs @@ -56,10 +56,12 @@ namespace ColorPicker.Mouse public event EventHandler<Tuple<System.Windows.Point, bool>> OnMouseWheel; - public event MouseUpEventHandler OnMouseDown; + public event PrimaryMouseDownEventHandler OnPrimaryMouseDown; public event SecondaryMouseUpEventHandler OnSecondaryMouseUp; + public event MiddleMouseDownEventHandler OnMiddleMouseDown; + public System.Windows.Point CurrentPosition { get @@ -104,8 +106,10 @@ namespace ColorPicker.Mouse var rect = new Rectangle((int)mousePosition.X, (int)mousePosition.Y, 1, 1); using (var bmp = new Bitmap(rect.Width, rect.Height, PixelFormat.Format32bppArgb)) { - var g = Graphics.FromImage(bmp); - g.CopyFromScreen(rect.Left, rect.Top, 0, 0, bmp.Size, CopyPixelOperation.SourceCopy); + using (var g = Graphics.FromImage(bmp)) // Ensure Graphics object is disposed + { + g.CopyFromScreen(rect.Left, rect.Top, 0, 0, bmp.Size, CopyPixelOperation.SourceCopy); + } return bmp.GetPixel(0, 0); } @@ -146,9 +150,10 @@ namespace ColorPicker.Mouse _timer.Start(); } - _mouseHook.OnMouseDown += MouseHook_OnMouseDown; + _mouseHook.OnPrimaryMouseDown += MouseHook_OnPrimaryMouseDown; _mouseHook.OnMouseWheel += MouseHook_OnMouseWheel; _mouseHook.OnSecondaryMouseUp += MouseHook_OnSecondaryMouseUp; + _mouseHook.OnMiddleMouseDown += MouseHook_OnMiddleMouseDown; if (_userSettings.ChangeCursor.Value) { @@ -167,10 +172,10 @@ namespace ColorPicker.Mouse OnMouseWheel?.Invoke(this, new Tuple<System.Windows.Point, bool>(_previousMousePosition, zoomIn)); } - private void MouseHook_OnMouseDown(object sender, Point p) + private void MouseHook_OnPrimaryMouseDown(object sender, IntPtr wParam) { DisposeHook(); - OnMouseDown?.Invoke(this, p); + OnPrimaryMouseDown?.Invoke(this, wParam); } private void MouseHook_OnSecondaryMouseUp(object sender, IntPtr wParam) @@ -179,6 +184,12 @@ namespace ColorPicker.Mouse OnSecondaryMouseUp?.Invoke(this, wParam); } + private void MouseHook_OnMiddleMouseDown(object sender, IntPtr wParam) + { + DisposeHook(); + OnMiddleMouseDown?.Invoke(this, wParam); + } + private void CopiedColorRepresentation_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) { _colorFormatChanged = true; @@ -192,9 +203,10 @@ namespace ColorPicker.Mouse } _previousMousePosition = new System.Windows.Point(-1, 1); - _mouseHook.OnMouseDown -= MouseHook_OnMouseDown; + _mouseHook.OnPrimaryMouseDown -= MouseHook_OnPrimaryMouseDown; _mouseHook.OnMouseWheel -= MouseHook_OnMouseWheel; _mouseHook.OnSecondaryMouseUp -= MouseHook_OnSecondaryMouseUp; + _mouseHook.OnMiddleMouseDown -= MouseHook_OnMiddleMouseDown; if (_userSettings.ChangeCursor.Value) { diff --git a/src/modules/colorPicker/ColorPickerUI/Settings/IUserSettings.cs b/src/modules/colorPicker/ColorPickerUI/Settings/IUserSettings.cs index ab92e64081..170b58c944 100644 --- a/src/modules/colorPicker/ColorPickerUI/Settings/IUserSettings.cs +++ b/src/modules/colorPicker/ColorPickerUI/Settings/IUserSettings.cs @@ -21,6 +21,12 @@ namespace ColorPicker.Settings SettingItem<ColorPickerActivationAction> ActivationAction { get; } + SettingItem<ColorPickerClickAction> PrimaryClickAction { get; } + + SettingItem<ColorPickerClickAction> MiddleClickAction { get; } + + SettingItem<ColorPickerClickAction> SecondaryClickAction { get; } + RangeObservableCollection<string> ColorHistory { get; } SettingItem<int> ColorHistoryLimit { get; } diff --git a/src/modules/colorPicker/ColorPickerUI/Settings/UserSettings.cs b/src/modules/colorPicker/ColorPickerUI/Settings/UserSettings.cs index eb6a9db7aa..dfb5551a87 100644 --- a/src/modules/colorPicker/ColorPickerUI/Settings/UserSettings.cs +++ b/src/modules/colorPicker/ColorPickerUI/Settings/UserSettings.cs @@ -45,11 +45,14 @@ namespace ColorPicker.Settings [ImportingConstructor] public UserSettings(Helpers.IThrottledActionInvoker throttledActionInvoker) { - _settingsUtils = new SettingsUtils(); + _settingsUtils = SettingsUtils.Default; ChangeCursor = new SettingItem<bool>(true); ActivationShortcut = new SettingItem<string>(DefaultActivationShortcut); CopiedColorRepresentation = new SettingItem<string>(ColorRepresentationType.HEX.ToString()); - ActivationAction = new SettingItem<ColorPickerActivationAction>(ColorPickerActivationAction.OpenEditor); + ActivationAction = new SettingItem<ColorPickerActivationAction>(ColorPickerActivationAction.OpenColorPicker); + PrimaryClickAction = new SettingItem<ColorPickerClickAction>(ColorPickerClickAction.PickColorThenEditor); + MiddleClickAction = new SettingItem<ColorPickerClickAction>(ColorPickerClickAction.PickColorAndClose); + SecondaryClickAction = new SettingItem<ColorPickerClickAction>(ColorPickerClickAction.Close); ColorHistoryLimit = new SettingItem<int>(20); ColorHistory.CollectionChanged += ColorHistory_CollectionChanged; ShowColorName = new SettingItem<bool>(false); @@ -78,6 +81,12 @@ namespace ColorPicker.Settings public SettingItem<ColorPickerActivationAction> ActivationAction { get; private set; } + public SettingItem<ColorPickerClickAction> PrimaryClickAction { get; private set; } + + public SettingItem<ColorPickerClickAction> MiddleClickAction { get; private set; } + + public SettingItem<ColorPickerClickAction> SecondaryClickAction { get; private set; } + public RangeObservableCollection<string> ColorHistory { get; private set; } = new RangeObservableCollection<string>(); public SettingItem<int> ColorHistoryLimit { get; } @@ -121,6 +130,9 @@ namespace ColorPicker.Settings CopiedColorRepresentation.Value = settings.Properties.CopiedColorRepresentation; CopiedColorRepresentationFormat = new SettingItem<string>(string.Empty); ActivationAction.Value = settings.Properties.ActivationAction; + PrimaryClickAction.Value = settings.Properties.PrimaryClickAction; + MiddleClickAction.Value = settings.Properties.MiddleClickAction; + SecondaryClickAction.Value = settings.Properties.SecondaryClickAction; ColorHistoryLimit.Value = settings.Properties.ColorHistoryLimit; ShowColorName.Value = settings.Properties.ShowColorName; diff --git a/src/modules/colorPicker/ColorPickerUI/Telemetry/ColorPickerSession.cs b/src/modules/colorPicker/ColorPickerUI/Telemetry/ColorPickerSession.cs index a39bdbc8d9..377a1a5348 100644 --- a/src/modules/colorPicker/ColorPickerUI/Telemetry/ColorPickerSession.cs +++ b/src/modules/colorPicker/ColorPickerUI/Telemetry/ColorPickerSession.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace ColorPicker.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class ColorPickerSession : EventBase, IEvent { public ColorPickerSession() diff --git a/src/modules/colorPicker/ColorPickerUI/Telemetry/ColorPickerSettings.cs b/src/modules/colorPicker/ColorPickerUI/Telemetry/ColorPickerSettings.cs index 0bab133002..4fc265426c 100644 --- a/src/modules/colorPicker/ColorPickerUI/Telemetry/ColorPickerSettings.cs +++ b/src/modules/colorPicker/ColorPickerUI/Telemetry/ColorPickerSettings.cs @@ -3,14 +3,15 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace ColorPicker.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class ColorPickerSettings : EventBase, IEvent { public ColorPickerSettings(IDictionary<string, KeyValuePair<bool, string>> editorFormats) diff --git a/src/modules/colorPicker/ColorPickerUI/ViewModels/ColorEditorViewModel.cs b/src/modules/colorPicker/ColorPickerUI/ViewModels/ColorEditorViewModel.cs index 129f365e0d..2f8d5a2348 100644 --- a/src/modules/colorPicker/ColorPickerUI/ViewModels/ColorEditorViewModel.cs +++ b/src/modules/colorPicker/ColorPickerUI/ViewModels/ColorEditorViewModel.cs @@ -301,6 +301,12 @@ namespace ColorPicker.ViewModels FormatName = ColorRepresentationType.NCol.ToString(), Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.NCol.ToString()), }); + _allColorRepresentations.Add( + new ColorFormatModel() + { + FormatName = ColorRepresentationType.CIEXYZ.ToString(), + Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.CIEXYZ.ToString()), + }); _allColorRepresentations.Add( new ColorFormatModel() { @@ -310,8 +316,14 @@ namespace ColorPicker.ViewModels _allColorRepresentations.Add( new ColorFormatModel() { - FormatName = ColorRepresentationType.CIEXYZ.ToString(), - Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.CIEXYZ.ToString()), + FormatName = ColorRepresentationType.Oklab.ToString(), + Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.Oklab.ToString()), + }); + _allColorRepresentations.Add( + new ColorFormatModel() + { + FormatName = ColorRepresentationType.Oklch.ToString(), + Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.Oklch.ToString()), }); _allColorRepresentations.Add( new ColorFormatModel() diff --git a/src/modules/colorPicker/ColorPickerUI/ViewModels/MainViewModel.cs b/src/modules/colorPicker/ColorPickerUI/ViewModels/MainViewModel.cs index 518f253456..e4d0a54b44 100644 --- a/src/modules/colorPicker/ColorPickerUI/ViewModels/MainViewModel.cs +++ b/src/modules/colorPicker/ColorPickerUI/ViewModels/MainViewModel.cs @@ -16,6 +16,7 @@ using ColorPicker.Settings; using ColorPicker.ViewModelContracts; using Common.UI; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Enumerations; using PowerToys.Interop; namespace ColorPicker.ViewModels @@ -79,9 +80,10 @@ namespace ColorPicker.ViewModels { SetColorDetails(mouseInfoProvider.CurrentColor); mouseInfoProvider.MouseColorChanged += Mouse_ColorChanged; - mouseInfoProvider.OnMouseDown += MouseInfoProvider_OnMouseDown; + mouseInfoProvider.OnPrimaryMouseDown += MouseInfoProvider_OnPrimaryMouseDown; mouseInfoProvider.OnMouseWheel += MouseInfoProvider_OnMouseWheel; mouseInfoProvider.OnSecondaryMouseUp += MouseInfoProvider_OnSecondaryMouseUp; + mouseInfoProvider.OnMiddleMouseDown += MouseInfoProvider_OnMiddleMouseDown; } _userSettings.ShowColorName.PropertyChanged += (s, e) => { OnPropertyChanged(nameof(ShowColorName)); }; @@ -113,7 +115,7 @@ namespace ColorPicker.ViewModels private void AppStateHandler_EnterPressed(object sender, EventArgs e) { - MouseInfoProvider_OnMouseDown(null, default(System.Drawing.Point)); + MouseInfoProvider_OnPrimaryMouseDown(null, default); } /// <summary> @@ -167,18 +169,50 @@ namespace ColorPicker.ViewModels SetColorDetails(color); } - /// <summary> - /// Tell the color picker that the user have press a mouse button (after release the button) - /// </summary> - /// <param name="sender">The sender of this event</param> - /// <param name="p">The current <see cref="System.Drawing.Point"/> of the mouse cursor</param> - private void MouseInfoProvider_OnMouseDown(object sender, System.Drawing.Point p) + private void MouseInfoProvider_OnPrimaryMouseDown(object sender, IntPtr wParam) { - ClipboardHelper.CopyToClipboard(ColorText); + HandleMouseClickAction(_userSettings.PrimaryClickAction.Value); + } - var color = GetColorString(); + private void MouseInfoProvider_OnMiddleMouseDown(object sender, IntPtr wParam) + { + HandleMouseClickAction(_userSettings.MiddleClickAction.Value); + } - var oldIndex = _userSettings.ColorHistory.IndexOf(color); + private void MouseInfoProvider_OnSecondaryMouseUp(object sender, IntPtr wParam) + { + HandleMouseClickAction(_userSettings.SecondaryClickAction.Value); + } + + private void HandleMouseClickAction(ColorPickerClickAction action) + { + switch (action) + { + case ColorPickerClickAction.PickColorThenEditor: + ClipboardHelper.CopyToClipboard(ColorText); + UpdateColorHistory(GetColorString()); + + _appStateHandler.OpenColorEditor(); + + break; + + case ColorPickerClickAction.PickColorAndClose: + ClipboardHelper.CopyToClipboard(ColorText); + UpdateColorHistory(GetColorString()); + + _appStateHandler.EndUserSession(); + + break; + + case ColorPickerClickAction.Close: + _appStateHandler.EndUserSession(); + break; + } + } + + private void UpdateColorHistory(string color) + { + int oldIndex = _userSettings.ColorHistory.IndexOf(color); if (oldIndex != -1) { _userSettings.ColorHistory.Move(oldIndex, 0); @@ -192,13 +226,6 @@ namespace ColorPicker.ViewModels { _userSettings.ColorHistory.RemoveAt(_userSettings.ColorHistory.Count - 1); } - - _appStateHandler.OnColorPickerMouseDown(); - } - - private void MouseInfoProvider_OnSecondaryMouseUp(object sender, IntPtr wParam) - { - _appStateHandler.EndUserSession(); } private string GetColorString() diff --git a/src/modules/colorPicker/UITest-ColorPicker/ColorPickerUITest.cs b/src/modules/colorPicker/UITest-ColorPicker/ColorPickerUITest.cs new file mode 100644 index 0000000000..e5ca40ea78 --- /dev/null +++ b/src/modules/colorPicker/UITest-ColorPicker/ColorPickerUITest.cs @@ -0,0 +1,16 @@ +// 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.PowerToys.UITest; + +namespace Microsoft.ColorPicker.UITests +{ + public class ColorPickerUITest : UITestBase + { + public ColorPickerUITest() + : base(PowerToysModule.Runner) + { + } + } +} diff --git a/src/modules/colorPicker/UITest-ColorPicker/ColorPickerUITest.md b/src/modules/colorPicker/UITest-ColorPicker/ColorPickerUITest.md new file mode 100644 index 0000000000..89fb950964 --- /dev/null +++ b/src/modules/colorPicker/UITest-ColorPicker/ColorPickerUITest.md @@ -0,0 +1,16 @@ +## Color Picker +* Enable the Color Picker in settings and ensure that the hotkey brings up Color Picker + - [] when PowerToys is running unelevated on start-up + - [] when PowerToys is running as admin on start-up + - [] when PowerToys is restarted as admin, by clicking the restart as admin button in the settings +- [] Change `Activate Color Picker shortcut` and check the new shortcut is working +- [] Try all three `Activation behavior`s(`Color Picker with editor mode enabled`, `Editor`, `Color Picker only`) +- [] Change `Color format for clipboard` and check if the correct format is copied from the Color picker +- [] Try to copy color formats to the clipboard from the Editor +- [] Check `Show color name` and verify if color name is shown in the Color picker +- [] Enable one new format, disable one existing format, reorder enabled formats and check if settings are populated to the Editor +- [] Select a color from the history in the Editor +- [] Remove color from the history in the Editor +- [] Open the Color Picker from the Editor +- [] Open Adjust color from the Editor +- [] Check Color Picker logs for errors \ No newline at end of file diff --git a/src/modules/colorPicker/UITest-ColorPicker/UITest-ColorPicker.csproj b/src/modules/colorPicker/UITest-ColorPicker/UITest-ColorPicker.csproj new file mode 100644 index 0000000000..bc360c2c91 --- /dev/null +++ b/src/modules/colorPicker/UITest-ColorPicker/UITest-ColorPicker.csproj @@ -0,0 +1,32 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <ProjectGuid>{6880CE86-5B71-4440-9795-79A325F95747}</ProjectGuid> + <RootNamespace>Microsoft.ColorPicker.UITests</RootNamespace> + <IsPackable>false</IsPackable> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + + <!-- This is a UI test, so don't run as part of MSBuild --> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\UITests-ColorPicker\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Appium.WebDriver" /> + <PackageReference Include="MSTest" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> + +</Project> \ No newline at end of file diff --git a/src/modules/fancyzones/FancyZones.FuzzTests/FancyZones.FuzzTests.csproj b/src/modules/fancyzones/FancyZones.FuzzTests/FancyZones.FuzzTests.csproj new file mode 100644 index 0000000000..bf4aa9f551 --- /dev/null +++ b/src/modules/fancyzones/FancyZones.FuzzTests/FancyZones.FuzzTests.csproj @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.FuzzTest.props" /> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\FancyZones.FuzzTests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <Compile Include="..\editor\FancyZonesEditor\Utils\ParsingResult.cs" Link="ParsingResult.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\AppliedLayouts.cs" Link="AppliedLayouts.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\CustomLayouts.cs" Link="CustomLayouts.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\DefaultLayouts.cs" Link="DefaultLayouts.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\EditorData`1.cs" Link="EditorData`1.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\EditorParameters.cs" Link="EditorParameters.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\FancyZonesJsonContext.cs" Link="FancyZonesJsonContext.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\FancyZonesPaths.cs" Link="FancyZonesPaths.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\LayoutDefaultSettings.cs" Link="LayoutDefaultSettings.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\LayoutHotkeys.cs" Link="LayoutHotkeys.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Data\LayoutTemplates.cs" Link="LayoutTemplates.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Utils\DashCaseNamingPolicy.cs" Link="DashCaseNamingPolicy.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Utils\IOUtils.cs" Link="IOUtils.cs" /> + <Compile Include="..\FancyZonesEditorCommon\Utils\StringUtils.cs" Link="StringUtils.cs" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <PackageReference Include="System.IO.Abstractions" /> + </ItemGroup> + + <ItemGroup> + <Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" /> + </ItemGroup> + + <ItemGroup> + <Content Include="OneFuzzConfig.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + +</Project> diff --git a/src/modules/fancyzones/FancyZones.FuzzTests/FuzzTests.cs b/src/modules/fancyzones/FancyZones.FuzzTests/FuzzTests.cs new file mode 100644 index 0000000000..3bc846089f --- /dev/null +++ b/src/modules/fancyzones/FancyZones.FuzzTests/FuzzTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; +using static FancyZonesEditorCommon.Data.CustomLayouts; + +namespace FancyZones.FuzzTests +{ + public class FuzzTests + { + public static void FuzzGridFromJsonElement(ReadOnlySpan<byte> input) + { + if (input.Length < 4) + { + return; + } + + int inputData = BitConverter.ToInt32(input.Slice(0, 4)); + + // mock user input for custom-layouts.json + string mockCustomLayouts = $@"{{""custom-layouts"": [{{ + ""uuid"": ""{{B8C275E-A7BC-485F-A35C-67B69164F51F}}"", + ""name"": ""Custom layout 1"", + ""type"": ""grid"", + ""info"": {{ + ""rows"": {inputData}, + ""columns"": {inputData}, + ""rows-percentage"": [ {inputData} ], + ""columns-percentage"": [ {inputData}, {inputData}, {inputData} ], + ""cell-child-map"": [ [{inputData}, {inputData}, {inputData}] ], + ""show-spacing"": true, + ""spacing"": {inputData}, + ""sensitivity-radius"": {inputData} + }} + }}]}}"; + + CustomLayoutListWrapper wrapper; + try + { + wrapper = JsonSerializer.Deserialize<CustomLayoutListWrapper>(mockCustomLayouts, JsonOptions); + } + catch (JsonException) + { + return; + } + + List<CustomLayouts.CustomLayoutWrapper> customLayouts = wrapper.CustomLayouts; + + if (customLayouts == null) + { + return; + } + + // Get Layout Info from mockCustomLayouts + foreach (var zoneSet in customLayouts) + { + if (zoneSet.Uuid == null || zoneSet.Uuid.Length == 0) + { + return; + } + + CustomLayouts deserializer = new CustomLayouts(); + + // Fuzzing the deserializer + _ = deserializer.GridFromJsonElement(zoneSet.Info.GetRawText()); + } + } + + private static JsonSerializerOptions JsonOptions + { + get + { + return new JsonSerializerOptions + { + PropertyNamingPolicy = new DashCaseNamingPolicy(), + WriteIndented = true, + }; + } + } + } +} diff --git a/src/modules/fancyzones/FancyZones.FuzzTests/MSTestSettings.cs b/src/modules/fancyzones/FancyZones.FuzzTests/MSTestSettings.cs new file mode 100644 index 0000000000..5b05c0b86e --- /dev/null +++ b/src/modules/fancyzones/FancyZones.FuzzTests/MSTestSettings.cs @@ -0,0 +1,5 @@ +// 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. + +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/src/modules/fancyzones/FancyZones.FuzzTests/OneFuzzConfig.json b/src/modules/fancyzones/FancyZones.FuzzTests/OneFuzzConfig.json new file mode 100644 index 0000000000..031b02befa --- /dev/null +++ b/src/modules/fancyzones/FancyZones.FuzzTests/OneFuzzConfig.json @@ -0,0 +1,51 @@ +{ + "configVersion": 3, + "entries": [ + { + "fuzzer": { + "$type": "libfuzzerDotNet", + "dll": "FancyZones.FuzzTests.dll", + "class": "FancyZones.FuzzTests.FuzzTests", + "method": "FuzzGridFromJsonElement", + "FuzzingTargetBinaries": [ + "PowerToys.FancyZones.dll" + ] + }, + "adoTemplate": { + // supply the values appropriate to your + // project, where bugs will be filed + "org": "microsoft", + "project": "OS", + "AssignedTo": "leilzh@microsoft.com", + "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT", + "IterationPath": "OS\\Future" + }, + "jobNotificationEmail": "PowerToys@microsoft.com", + "skip": false, + "rebootAfterSetup": false, + "oneFuzzJobs": [ + // at least one job is required + { + "projectName": "FancyZones", + "targetName": "FancyZones-dotnet-fuzzer-FuzzGridFromJsonElement" + } + ], + "jobDependencies": [ + // this should contain, at minimum, + // the DLL and PDB files + // you will need to add any other files required + // (globs are supported) + "FancyZones.FuzzTests.dll", + "FancyZones.FuzzTests.pdb", + "Microsoft.Windows.SDK.NET.dll", + "Newtonsoft.Json.dll", + "System.IO.Abstractions.dll", + "Testably.Abstractions.FileSystem.Interface.dll", + "TestableIO.System.IO.Abstractions.dll", + "TestableIO.System.IO.Abstractions.Wrappers.dll", + "WinRT.Runtime.dll" + ], + "SdlWorkItemId": 49911822 + } + ] +} \ No newline at end of file diff --git a/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs b/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs new file mode 100644 index 0000000000..117e128734 --- /dev/null +++ b/src/modules/fancyzones/FancyZones.UITests/DragWindowTests.cs @@ -0,0 +1,680 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Forms; +using FancyZonesEditor.Models; +using FancyZonesEditorCommon.Data; +using Microsoft.FancyZones.UITests.Utils; +using Microsoft.FancyZonesEditor.UITests.Utils; +using Microsoft.FancyZonesEditor.UnitTests.Utils; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualBasic.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium.Appium; +using OpenQA.Selenium.Appium.Windows; +using static FancyZonesEditorCommon.Data.CustomLayouts; + +namespace UITests_FancyZones +{ + [TestClass] + public class DragWindowTests : UITestBase + { + private static readonly IOTestHelper AppZoneHistory = new FancyZonesEditorFiles().AppZoneHistoryIOHelper; + private static string nonPrimaryMouseButton = "Right"; + + private static string highlightColor = "#008CFF"; // set highlight color + private static string inactivateColor = "#AACDFF"; // set inactivate zone color + + // set screen margin + private static int screenMarginTop; + private static int screenMarginLeft; + private static int screenMarginRight; + private static int screenMarginBottom; + + // set 1/4 margin + private static int quarterX; + private static int quarterY; + + private static string powertoysWindowName = "PowerToys Settings"; // set powertoys settings window name + + public DragWindowTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.Medium) + { + } + + [TestInitialize] + public void TestInitialize() + { + Session.KillAllProcessesByName("PowerToys"); + ClearOpenWindows(); + + AppZoneHistory.DeleteFile(); + FancyZonesEditorHelper.Files.Restore(); + SetupCustomLayouts(); + + RestartScopeExe("Hosts"); + Thread.Sleep(2000); + + // Get the current mouse button setting + nonPrimaryMouseButton = SystemInformation.MouseButtonsSwapped ? "Left" : "Right"; + + // get PowerToys window Name + powertoysWindowName = ZoneSwitchHelper.GetActiveWindowTitle(); + + // Ensure FancyZones settings page is visible and enable FancyZones + LaunchFancyZones(); + } + + /// <summary> + /// Test toggling zones using a non-primary mouse click during window dragging. + /// <list type="bullet"> + /// <item> + /// <description>Verifies that clicking a non-primary mouse button deactivates zones while dragging a window.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("FancyZones.Settings.TestToggleZonesWithNonPrimaryMouseClick")] + [TestCategory("FancyZones_Dragging #3")] + public void TestToggleZonesWithNonPrimaryMouseClick() + { + string testCaseName = nameof(TestToggleZonesWithNonPrimaryMouseClick); + + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; + + var (initialColor, withMouseColor) = RunDragInteractions( + preAction: () => + { + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); + }, + postAction: () => + { + // press non-primary mouse button to toggle zones + Session.PerformMouseAction( + nonPrimaryMouseButton == "Right" ? MouseActionType.RightClick : MouseActionType.LeftClick); + }, + releaseAction: () => + { + Session.PerformMouseAction(MouseActionType.LeftUp); + }, + testCaseName: testCaseName); + + // check the zone color is deactivated + Assert.AreNotEqual(highlightColor, withMouseColor, $"[{testCaseName}] Zone deactivation failed."); + + // check the zone color is activated + Assert.AreEqual(highlightColor, initialColor, $"[{testCaseName}] Zone activation failed."); + } + + /// <summary> + /// Test both use Shift and non primary mouse off settings. + /// <list type="bullet"> + /// <item> + /// <description>Verifies that pressing the Shift key deactivates zones during a window drag-and-hold action.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("FancyZones.Settings.TestShowZonesWhenShiftAndMouseOff")] + [TestCategory("FancyZones_Dragging #4")] + public void TestShowZonesWhenShiftAndMouseOff() + { + string testCaseName = nameof(TestShowZonesWhenShiftAndMouseOff); + + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; + + var (initialColor, withShiftColor) = RunDragInteractions( + preAction: () => + { + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); + }, + postAction: () => + { + // press Shift Key to deactivate zones + Session.PressKey(Key.Shift); + Task.Delay(1000).Wait(); + }, + releaseAction: () => + { + Session.PerformMouseAction(MouseActionType.LeftUp); + Session.ReleaseKey(Key.Shift); + }, + testCaseName: testCaseName); + + Assert.AreEqual(highlightColor, initialColor, $"[{testCaseName}] Zone activation failed."); + Assert.AreNotEqual(highlightColor, withShiftColor, $"[{testCaseName}] Zone deactivation failed."); + } + + /// <summary> + /// Test zone visibility when both Shift key and mouse settings are involved. + /// <list type="bullet"> + /// <item> + /// <description>Verifies that zones are activated when Shift is pressed during drag, and deactivated by a non-primary mouse click.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("FancyZones.Settings.TestShowZonesWhenShiftAndMouseOn")] + [TestCategory("FancyZones_Dragging #5")] + public void TestShowZonesWhenShiftAndMouseOn() + { + string testCaseName = nameof(TestShowZonesWhenShiftAndMouseOn); + + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; + var (initialColor, withShiftColor) = RunDragInteractions( + preAction: () => + { + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); + }, + postAction: () => + { + Session.PressKey(Key.Shift); + }, + releaseAction: () => + { + }, + testCaseName: testCaseName); + + Assert.AreEqual(highlightColor, withShiftColor, $"[{testCaseName}] show zone failed."); + + Session.PerformMouseAction( + nonPrimaryMouseButton == "Right" ? MouseActionType.RightClick : MouseActionType.LeftClick); + + string zoneColorWithMouse = GetOutWindowPixelColor(30); + Assert.AreEqual(initialColor, zoneColorWithMouse, $"[{nameof(TestShowZonesWhenShiftAndMouseOff)}] Zone deactivate failed."); + + Session.ReleaseKey(Key.Shift); + Session.PerformMouseAction(MouseActionType.LeftUp); + } + + /// <summary> + /// Test that a window becomes transparent during dragging when the transparent window setting is enabled. + /// <list type="bullet"> + /// <item> + /// <description>Verifies that the window appears transparent while being dragged.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("FancyZones.Settings.TestMakeDraggedWindowTransparentOn")] + [TestCategory("FancyZones_Dragging #8")] + public void TestMakeDraggedWindowTransparentOn() + { + var pixel = GetPixelWhenMakeDraggedWindow(); + Assert.AreNotEqual(pixel.PixelInWindow, pixel.TransPixel, $"[{nameof(TestMakeDraggedWindowTransparentOn)}] Window transparency failed."); + } + + /// <summary> + /// Test that a window remains opaque during dragging when the transparent window setting is disabled. + /// <list type="bullet"> + /// <item> + /// <description>Verifies that the window is not transparent while being dragged.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("FancyZones.Settings.TestMakeDraggedWindowTransparentOff")] + [TestCategory("FancyZones_Dragging #8")] + public void TestMakeDraggedWindowTransparentOff() + { + var pixel = GetPixelWhenMakeDraggedWindow(); + Assert.AreEqual(pixel.PixelInWindow, pixel.TransPixel, $"[{nameof(TestMakeDraggedWindowTransparentOff)}] Window without transparency failed."); + } + + /// <summary> + /// Test Use Shift key to activate zones while dragging a window in FancyZones Zone Behaviour Settings + /// <list type="bullet"> + /// <item> + /// <description>Verifies that holding Shift while dragging shows all zones as expected.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("FancyZones.Settings.TestShowZonesOnShiftDuringDrag")] + [TestCategory("FancyZones_Dragging #1")] + public void TestShowZonesOnShiftDuringDrag() + { + string testCaseName = nameof(TestShowZonesOnShiftDuringDrag); + + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; + + var (initialColor, withShiftColor) = RunDragInteractions( + preAction: () => + { + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); + }, + postAction: () => + { + Session.PressKey(Key.Shift); + Task.Delay(500).Wait(); + }, + releaseAction: () => + { + Session.ReleaseKey(Key.Shift); + Task.Delay(1000).Wait(); // Optional: Wait for a moment to ensure window switch + }, + testCaseName: testCaseName); + + string zoneColorWithoutShift = GetOutWindowPixelColor(30); + + Assert.AreNotEqual(initialColor, withShiftColor, $"[{testCaseName}] Zone color did not change; zone activation failed."); + Assert.AreEqual(highlightColor, withShiftColor, $"[{testCaseName}] Zone color did not match the highlight color; activation failed."); + + Session.PerformMouseAction(MouseActionType.LeftUp); + } + + /// <summary> + /// Test dragging a window during Shift key press in FancyZones Zone Behaviour Settings + /// <list type="bullet"> + /// <item> + /// <description>Verifies that dragging activates zones as expected.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("FancyZones.Settings.TestShowZonesOnDragDuringShift")] + [TestCategory("FancyZones_Dragging #2")] + public void TestShowZonesOnDragDuringShift() + { + string testCaseName = nameof(TestShowZonesOnDragDuringShift); + + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 300; + int endY = startY + 300; + + var (initialColor, withDragColor) = RunDragInteractions( + preAction: () => + { + Session.PressKey(Key.Shift); + Task.Delay(100).Wait(); + }, + postAction: () => + { + Session.MoveMouseTo(startX, startY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); + Task.Delay(1000).Wait(); + }, + releaseAction: () => + { + Session.PerformMouseAction(MouseActionType.LeftUp); + Session.ReleaseKey(Key.Shift); + Task.Delay(100).Wait(); + }, + testCaseName: testCaseName); + + Assert.AreNotEqual(initialColor, withDragColor, $"[{testCaseName}] Zone color did not change; zone activation failed."); + Assert.AreEqual(highlightColor, withDragColor, $"[{testCaseName}] Zone color did not match the highlight color; activation failed."); + + // double check by app-zone-history.json + string appZoneHistoryJson = AppZoneHistory.GetData(); + string? zoneNumber = ZoneSwitchHelper.GetZoneIndexSetByAppName(powertoysWindowName, appZoneHistoryJson); + Assert.IsNull(zoneNumber, $"[{testCaseName}] AppZoneHistory layout was unexpectedly set."); + } + + // Helper method to ensure the desktop has no open windows by clicking the "Show Desktop" button + private void ClearOpenWindows() + { + string desktopButtonName; + + // Check for both possible button names (Win10/Win11) + if (this.FindAll<Microsoft.PowerToys.UITest.Button>("Show Desktop", 5000, true).Count == 0) + { + // win10 + desktopButtonName = "Show desktop"; + } + else + { + // win11 + desktopButtonName = "Show Desktop"; + } + + this.Find<Microsoft.PowerToys.UITest.Button>(By.Name(desktopButtonName), 5000, true).Click(false, 500, 1000); + } + + // Setup custom layout with 1 subzones + private void SetupCustomLayouts() + { + var customLayouts = new CustomLayouts(); + var customLayoutListWrapper = CustomLayoutsList; + + if (TestContext.TestName == "TestMakeDraggedWindowTransparentOff") + { + customLayoutListWrapper = CustomLayoutsListWithTwo; + } + + FancyZonesEditorHelper.Files.CustomLayoutsIOHelper.WriteData(customLayouts.Serialize(customLayoutListWrapper)); + } + + // launch FancyZones settings page + private void LaunchFancyZones() + { + this.Find<NavigationViewItem>(By.AccessibilityId("WindowingAndLayoutsNavItem")).Click(); + + this.Find<NavigationViewItem>(By.AccessibilityId("FancyZonesNavItem")).Click(); + this.Find<ToggleSwitch>(By.AccessibilityId("EnableFancyZonesToggleSwitch")).Toggle(true); + + this.Session.SetMainWindowSize(WindowSize.Large); + Find<Element>(By.AccessibilityId("HeaderPresenter")).Click(); + this.Scroll(6, "Down"); // Pull the settings page up to make sure the settings are visible + ZoneBehaviourSettings(TestContext.TestName); + + // Go back and forth to make sure settings applied + this.Find<NavigationViewItem>("Workspaces").Click(); + Task.Delay(200).Wait(); + this.Find<NavigationViewItem>("FancyZones").Click(); + + this.Find<Microsoft.PowerToys.UITest.Button>(By.AccessibilityId("LaunchLayoutEditorButton")).Click(false, 500, 10000); + this.Session.Attach(PowerToysModule.FancyZone); + + // pipeline machine may have an unstable delays, causing the custom layout to be unavailable as we set. then A retry is required. + // Console.WriteLine($"after launch, Custom layout data: {customLayoutData}"); + try + { + this.Find<Microsoft.PowerToys.UITest.Button>("Maximize").Click(); + + // Set the FancyZones layout to a custom layout + this.Find<Element>(By.Name("Custom Column")).Click(); + } + catch (Exception) + { + // Console.WriteLine($"[Exception] Failed to attach to FancyZones window. Retrying...{ex.Message}"); + this.Find<Microsoft.PowerToys.UITest.Button>("Close").Click(); + this.Session.Attach(PowerToysModule.PowerToysSettings); + SetupCustomLayouts(); + this.Find<Microsoft.PowerToys.UITest.Button>(By.AccessibilityId("LaunchLayoutEditorButton")).Click(false, 5000, 5000); + this.Session.Attach(PowerToysModule.FancyZone); + this.Find<Microsoft.PowerToys.UITest.Button>("Maximize").Click(); + + // customLayoutData = FancyZonesEditorHelper.Files.CustomLayoutsIOHelper.GetData(); + // Console.WriteLine($"after retry, Custom layout data: {customLayoutData}"); + + // Set the FancyZones layout to a custom layout + this.Find<Element>(By.Name("Custom Column")).Click(); + } + + // Get screen margins for positioning checks + GetScreenMargins(); + + // Close layout editor window + SendKeys(Key.Alt, Key.F4); + + // make window small to detect zone easily + Session.Attach(powertoysWindowName, WindowSize.Small); + } + + // Get the screen margins to calculate the dragged window position + private void GetScreenMargins() + { + var rect = Session.GetMainWindowRect(); + screenMarginTop = rect.Top; + screenMarginLeft = rect.Left; + screenMarginRight = rect.Right; + screenMarginBottom = rect.Bottom; + (quarterX, quarterY) = ZoneSwitchHelper.GetScreenMargins(rect, 4); + } + + // Get the mouse color of the pixel when make dragged window + private (string PixelInWindow, string TransPixel) GetPixelWhenMakeDraggedWindow() + { + var windowRect = Session.GetMainWindowRect(); + int startX = windowRect.Left + 70; + int startY = windowRect.Top + 25; + int endX = startX + 100; + int endY = startY + 100; + + Session.MoveMouseTo(startX, startY); + + // Session.PerformMouseAction(MouseActionType.LeftDoubleClick); + Session.PressKey(Key.Shift); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.MoveMouseTo(endX, endY); + + Tuple<int, int> pos = GetMousePosition(); + string pixelInWindow = this.GetPixelColorString(pos.Item1, pos.Item2); + Session.ReleaseKey(Key.Shift); + Task.Delay(1000).Wait(); + string transPixel = this.GetPixelColorString(pos.Item1, pos.Item2); + + Session.PerformMouseAction(MouseActionType.LeftUp); + return (pixelInWindow, transPixel); + } + + /// <summary> + /// Gets the color of a pixel located just outside the application's window. + /// </summary> + /// <param name="spacing"> + /// The minimum spacing (in pixels) required between the window edge and screen margin + /// to determine a safe pixel sampling area outside the window. + /// </param> + /// <returns> + /// A string representing the color of the pixel at the computed location outside the window, + /// </returns> + private string GetOutWindowPixelColor(int spacing) + { + var rect = Session.GetMainWindowRect(); + int checkX, checkY; + + if ((rect.Top - screenMarginTop) >= spacing) + { + checkX = rect.Left; + checkY = screenMarginTop + (spacing / 2); + } + else if ((screenMarginBottom - rect.Bottom) >= spacing) + { + checkX = rect.Left; + checkY = rect.Bottom + (spacing / 2); + } + else if ((rect.Left - screenMarginLeft) >= spacing) + { + checkX = rect.Left - (spacing / 2); + checkY = rect.Top; + } + else if ((screenMarginRight - rect.Right) >= spacing) + { + checkX = rect.Right + (spacing / 2); + checkY = rect.Top; + } + else + { + throw new ArgumentOutOfRangeException(nameof(spacing), "No sufficient margin to sample outside the window."); + } + + Task.Delay(1000).Wait(); // Optional: Wait for a moment to ensure the mouse is in position + string zoneColor = this.GetPixelColorString(checkX, checkY); + return zoneColor; + } + + /// <summary> + /// Runs drag interactions during a FancyZones test and returns the initial and final zone highlight colors. + /// </summary> + /// <param name="preAction">An optional action to execute before the drag starts (e.g., setup or key press).</param> + /// <param name="postAction">An optional action to execute after the drag is initiated but before it's released.</param> + /// <param name="releaseAction">An optional action to execute when releasing the dragged window (e.g., mouse up).</param> + /// <param name="testCaseName">The name of the test case for logging or diagnostics.</param> + /// <returns> + /// A tuple containing: + /// <list type="bullet"> + /// <item><description><c>InitialZoneColor</c>: The zone highlight color before interaction completes.</description></item> + /// <item><description><c>FinalZoneColor</c>: The zone highlight color after interaction completes.</description></item> + /// </list> + /// </returns> + private (string InitialZoneColor, string FinalZoneColor) RunDragInteractions( + Action? preAction, + Action? postAction, + Action? releaseAction, + string testCaseName) + { + // Invoke the pre-action + preAction?.Invoke(); + + // Capture initial window state and zone color + var initialWindowRect = Session.GetMainWindowRect(); + string initialZoneColor = GetOutWindowPixelColor(30); + + // Invoke the post-action + postAction?.Invoke(); + + // Capture final zone color after the interaction + string finalZoneColor = GetOutWindowPixelColor(30); + + releaseAction?.Invoke(); + + // Return initial and final zone colors + return (initialZoneColor, finalZoneColor); + } + + // set the custom layout + private static readonly CustomLayouts.CustomLayoutListWrapper CustomLayoutsList = new CustomLayouts.CustomLayoutListWrapper + { + CustomLayouts = new List<CustomLayouts.CustomLayoutWrapper> + { + new CustomLayouts.CustomLayoutWrapper + { + Uuid = "{63F09977-D327-4DAC-98F4-0C886CAE9517}", + Type = CustomLayout.Grid.TypeToString(), + Name = "Custom Column", + Info = new CustomLayouts().ToJsonElement(new CustomLayouts.GridInfoWrapper + { + Rows = 1, + Columns = 1, + RowsPercentage = new List<int> { 10000 }, + ColumnsPercentage = new List<int> { 10000 }, + CellChildMap = new int[][] { [0] }, + SensitivityRadius = 20, + ShowSpacing = true, + Spacing = 10, // set spacing to 0 make sure the zone is full of the screen + }), + }, + }, + }; + + // set the custom layout with 1 subzones + private static readonly CustomLayouts.CustomLayoutListWrapper CustomLayoutsListWithTwo = new CustomLayouts.CustomLayoutListWrapper + { + CustomLayouts = new List<CustomLayouts.CustomLayoutWrapper> + { + new CustomLayouts.CustomLayoutWrapper + { + Uuid = "{63F09977-D327-4DAC-98F4-0C886CAE9517}", + Type = CustomLayout.Grid.TypeToString(), + Name = "Custom Column", + Info = new CustomLayouts().ToJsonElement(new CustomLayouts.GridInfoWrapper + { + Rows = 1, + Columns = 2, + RowsPercentage = new List<int> { 10000 }, + ColumnsPercentage = new List<int> { 5000, 5000 }, + CellChildMap = new int[][] { [0, 1] }, + SensitivityRadius = 20, + ShowSpacing = true, + Spacing = 10, + }), + }, + }, + }; + + private string GetZoneColor(string color) + { + // Click on the "Highlight color" group + Find<Group>(color).Click(); + + // Optional: Ensure the hex textbox is found (to wait until the UI loads) + var hexBox = Find<Element>(By.AccessibilityId("HexTextBox")); + Task.Delay(500).Wait(); // Optional: Wait for the UI to update + + // Get and return the RGB hex value text + var hexColorElement = Find<Element>("RGB hex"); + + // return mouse to color set position + Find<Group>(color).Click(); + + return hexColorElement.Text; + } + + // set the zone behaviour settings + private void ZoneBehaviourSettings(string? testName) + { + // test settings + Microsoft.PowerToys.UITest.CheckBox useShiftCheckBox = this.Find<Microsoft.PowerToys.UITest.CheckBox>("Hold Shift key to activate zones while dragging a window"); + Microsoft.PowerToys.UITest.CheckBox useNonPrimaryMouseCheckBox = this.Find<Microsoft.PowerToys.UITest.CheckBox>("Use a non-primary mouse button to toggle zone activation"); + Microsoft.PowerToys.UITest.CheckBox makeDraggedWindowTransparent = this.Find<Microsoft.PowerToys.UITest.CheckBox>("Make the dragged window transparent"); + + Find<Microsoft.PowerToys.UITest.CheckBox>("Show zone number").SetCheck(false, 100); + Find<Slider>("Opacity (%)").QuickSetValue(100); // make highlight color visible with opacity 100 + + // Get the highlight and inactivate color from appearance settings + Find<Microsoft.PowerToys.UITest.ComboBox>("Zone appearance").Click(); + Find<Element>("Custom colors").Click(); + + // get the highlight (activated) and inactivate zone color + highlightColor = GetZoneColor("Highlight color"); + inactivateColor = GetZoneColor("Inactive color"); + + this.Scroll(2, "Down"); + makeDraggedWindowTransparent.SetCheck(false, 500); // set make dragged window transparent to false or will influence the color comparison + this.Scroll(6, "Up"); + + switch (testName) + { + case "TestShowZonesOnShiftDuringDrag": + useShiftCheckBox.SetCheck(true, 500); + useNonPrimaryMouseCheckBox.SetCheck(false, 500); + break; + case "TestShowZonesOnDragDuringShift": + useShiftCheckBox.SetCheck(true, 500); + useNonPrimaryMouseCheckBox.SetCheck(false, 500); + break; + case "TestToggleZonesWithNonPrimaryMouseClick": + useShiftCheckBox.SetCheck(false, 500); + useNonPrimaryMouseCheckBox.SetCheck(true, 500); + break; + case "TestShowZonesWhenShiftAndMouseOff": + useShiftCheckBox.SetCheck(false, 500); + useNonPrimaryMouseCheckBox.SetCheck(false, 500); + break; + case "TestShowZonesWhenShiftAndMouseOn": + useShiftCheckBox.SetCheck(true, 500); + useNonPrimaryMouseCheckBox.SetCheck(true, 500); + break; + case "TestMakeDraggedWindowTransparentOff": + useShiftCheckBox.SetCheck(true, 500); + useNonPrimaryMouseCheckBox.SetCheck(false, 500); + break; // Added break to prevent fall-through + case "TestMakeDraggedWindowTransparentOn": + useNonPrimaryMouseCheckBox.SetCheck(false, 500); + useShiftCheckBox.SetCheck(true, 500); + this.Scroll(5, "Down"); // Pull the settings page up to make sure the settings are visible + makeDraggedWindowTransparent.SetCheck(true, 500); + this.Scroll(5, "Up"); + break; // Added break to prevent fall-through + default: + throw new ArgumentException("Unsupported Test Case.", testName); + } + } + } +} diff --git a/src/modules/fancyzones/FancyZones.UITests/FancyZones.UITests.csproj b/src/modules/fancyzones/FancyZones.UITests/FancyZones.UITests.csproj new file mode 100644 index 0000000000..8554072add --- /dev/null +++ b/src/modules/fancyzones/FancyZones.UITests/FancyZones.UITests.csproj @@ -0,0 +1,44 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <ProjectGuid>{69D76A76-6EF6-4846-94CD-EAAF0CAC9F15}</ProjectGuid> + <RootNamespace>Microsoft.FancyZones.UITests</RootNamespace> + <IsPackable>false</IsPackable> + <Nullable>enable</Nullable> + <OutputType>Exe</OutputType> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + + <!-- This is a UI test, so don't run as part of the Test target --> + <TestingPlatformDisableCustomTestTarget>true</TestingPlatformDisableCustomTestTarget> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\FancyZones.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Appium.WebDriver" /> + <PackageReference Include="MSTest" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> + </ItemGroup> + + <ItemGroup> + <Folder Include="Properties\" /> + <ProjectReference Include="..\editor\FancyZonesEditor\FancyZonesEditor.csproj" /> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + <ProjectReference Include="..\FancyZonesEditor.UITests\FancyZonesEditor.UITests.csproj" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\editor\FancyZonesEditor\FancyZonesEditor.csproj" /> + <ProjectReference Include="..\FancyZonesEditor.UITests\FancyZonesEditor.UITests.csproj" /> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/modules/fancyzones/UITests-FancyZones/Init.cs b/src/modules/fancyzones/FancyZones.UITests/Init.cs similarity index 100% rename from src/modules/fancyzones/UITests-FancyZones/Init.cs rename to src/modules/fancyzones/FancyZones.UITests/Init.cs diff --git a/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs b/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs new file mode 100644 index 0000000000..bc0d31370f --- /dev/null +++ b/src/modules/fancyzones/FancyZones.UITests/LayoutApplyHotKeyTests.cs @@ -0,0 +1,674 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Automation; +using FancyZonesEditor.Models; +using FancyZonesEditorCommon.Data; +using Microsoft.FancyZonesEditor.UITests; +using Microsoft.FancyZonesEditor.UnitTests.Utils; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static FancyZonesEditorCommon.Data.CustomLayouts; +using static Microsoft.FancyZonesEditor.UnitTests.Utils.FancyZonesEditorHelper; + +namespace UITests_FancyZones +{ + [TestClass] + public class LayoutApplyHotKeyTests : UITestBase + { + public LayoutApplyHotKeyTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.Large) + { + } + + private static readonly EditorParameters.ParamsWrapper Parameters = new EditorParameters.ParamsWrapper + { + ProcessId = 1, + SpanZonesAcrossMonitors = false, + Monitors = new List<EditorParameters.NativeMonitorDataWrapper> + { + new EditorParameters.NativeMonitorDataWrapper + { + Monitor = "monitor-1", + MonitorInstanceId = "instance-id-1", + MonitorSerialNumber = "serial-number-1", + MonitorNumber = 1, + VirtualDesktop = "{FF34D993-73F3-4B8C-AA03-73730A01D6A8}", + Dpi = 96, + LeftCoordinate = 0, + TopCoordinate = 0, + WorkAreaHeight = 1040, + WorkAreaWidth = 1920, + MonitorHeight = 1080, + MonitorWidth = 1920, + IsSelected = true, + }, + new EditorParameters.NativeMonitorDataWrapper + { + Monitor = "monitor-2", + MonitorInstanceId = "instance-id-2", + MonitorSerialNumber = "serial-number-2", + MonitorNumber = 2, + VirtualDesktop = "{FF34D993-73F3-4B8C-AA03-73730A01D6A8}", + Dpi = 96, + LeftCoordinate = 1920, + TopCoordinate = 0, + WorkAreaHeight = 1040, + WorkAreaWidth = 1920, + MonitorHeight = 1080, + MonitorWidth = 1920, + IsSelected = false, + }, + }, + }; + + private static readonly CustomLayouts.CustomLayoutListWrapper CustomLayoutsList = new CustomLayouts.CustomLayoutListWrapper + { + CustomLayouts = new List<CustomLayouts.CustomLayoutWrapper> + { + new CustomLayoutWrapper + { + Uuid = "{0D6D2F58-9184-4804-81E4-4E4CC3476DC1}", + Type = CustomLayout.Grid.TypeToString(), + Name = "Grid custom layout", + Info = new CustomLayouts().ToJsonElement(new GridInfoWrapper + { + Rows = 2, + Columns = 2, + RowsPercentage = new List<int> { 5000, 5000 }, + ColumnsPercentage = new List<int> { 5000, 5000 }, + CellChildMap = new int[][] { [0, 1], [2, 3] }, + SensitivityRadius = 30, + Spacing = 26, + ShowSpacing = false, + }), + }, + new CustomLayoutWrapper + { + Uuid = "{0EB9BF3E-010E-46D7-8681-1879D1E111E1}", + Type = CustomLayout.Grid.TypeToString(), + Name = "Grid-9", + Info = new CustomLayouts().ToJsonElement(new GridInfoWrapper + { + Rows = 3, + Columns = 3, + RowsPercentage = new List<int> { 2333, 3333, 4334 }, + ColumnsPercentage = new List<int> { 2333, 3333, 4334 }, + CellChildMap = new int[][] { [0, 1, 2], [3, 4, 5], [6, 7, 8] }, + SensitivityRadius = 20, + Spacing = 3, + ShowSpacing = false, + }), + }, + new CustomLayoutWrapper + { + Uuid = "{E7807D0D-6223-4883-B15B-1F3883944C09}", + Type = CustomLayout.Canvas.TypeToString(), + Name = "Canvas custom layout", + Info = new CustomLayouts().ToJsonElement(new CanvasInfoWrapper + { + RefHeight = 1040, + RefWidth = 1920, + SensitivityRadius = 10, + Zones = new List<CanvasInfoWrapper.CanvasZoneWrapper> + { + new CanvasInfoWrapper.CanvasZoneWrapper + { + X = 0, + Y = 0, + Width = 500, + Height = 250, + }, + new CanvasInfoWrapper.CanvasZoneWrapper + { + X = 500, + Y = 0, + Width = 1420, + Height = 500, + }, + new CanvasInfoWrapper.CanvasZoneWrapper + { + X = 0, + Y = 250, + Width = 1920, + Height = 500, + }, + }, + }), + }, + }, + }; + + private static readonly LayoutHotkeys.LayoutHotkeysWrapper LayoutHotkeysList = new LayoutHotkeys.LayoutHotkeysWrapper + { + LayoutHotkeys = new List<LayoutHotkeys.LayoutHotkeyWrapper> + { + new LayoutHotkeys.LayoutHotkeyWrapper + { + Key = 0, + LayoutId = "{0D6D2F58-9184-4804-81E4-4E4CC3476DC1}", + }, + new LayoutHotkeys.LayoutHotkeyWrapper + { + Key = 1, + LayoutId = "{0EB9BF3E-010E-46D7-8681-1879D1E111E1}", + }, + new LayoutHotkeys.LayoutHotkeyWrapper + { + Key = 2, + LayoutId = "{E7807D0D-6223-4883-B15B-1F3883944C09}", + }, + }, + }; + + private static readonly LayoutTemplates.TemplateLayoutsListWrapper TemplateLayoutsList = new LayoutTemplates.TemplateLayoutsListWrapper + { + LayoutTemplates = new List<LayoutTemplates.TemplateLayoutWrapper> + { + new LayoutTemplates.TemplateLayoutWrapper + { + Type = LayoutType.Blank.TypeToString(), + }, + new LayoutTemplates.TemplateLayoutWrapper + { + Type = LayoutType.Focus.TypeToString(), + ZoneCount = 10, + }, + new LayoutTemplates.TemplateLayoutWrapper + { + Type = LayoutType.Rows.TypeToString(), + ZoneCount = 2, + ShowSpacing = true, + Spacing = 10, + SensitivityRadius = 10, + }, + new LayoutTemplates.TemplateLayoutWrapper + { + Type = LayoutType.Columns.TypeToString(), + ZoneCount = 2, + ShowSpacing = true, + Spacing = 20, + SensitivityRadius = 20, + }, + new LayoutTemplates.TemplateLayoutWrapper + { + Type = LayoutType.Grid.TypeToString(), + ZoneCount = 4, + ShowSpacing = false, + Spacing = 10, + SensitivityRadius = 30, + }, + new LayoutTemplates.TemplateLayoutWrapper + { + Type = LayoutType.PriorityGrid.TypeToString(), + ZoneCount = 3, + ShowSpacing = true, + Spacing = 1, + SensitivityRadius = 40, + }, + }, + }; + + [TestInitialize] + public void TestInitialize() + { + FancyZonesEditorHelper.Files.Restore(); + EditorParameters editorParameters = new EditorParameters(); + FancyZonesEditorHelper.Files.ParamsIOHelper.WriteData(editorParameters.Serialize(Parameters)); + + LayoutTemplates layoutTemplates = new LayoutTemplates(); + FancyZonesEditorHelper.Files.LayoutTemplatesIOHelper.WriteData(layoutTemplates.Serialize(TemplateLayoutsList)); + + CustomLayouts customLayouts = new CustomLayouts(); + FancyZonesEditorHelper.Files.CustomLayoutsIOHelper.WriteData(customLayouts.Serialize(CustomLayoutsList)); + + DefaultLayouts defaultLayouts = new DefaultLayouts(); + DefaultLayouts.DefaultLayoutsListWrapper defaultLayoutsListWrapper = new DefaultLayouts.DefaultLayoutsListWrapper + { + DefaultLayouts = new List<DefaultLayouts.DefaultLayoutWrapper> + { + new DefaultLayouts.DefaultLayoutWrapper + { + MonitorConfiguration = MonitorConfigurationType.Horizontal.TypeToString(), + Layout = new DefaultLayouts.DefaultLayoutWrapper.LayoutWrapper + { + Type = LayoutType.Focus.TypeToString(), + ZoneCount = 4, + ShowSpacing = true, + Spacing = 5, + SensitivityRadius = 20, + }, + }, + new DefaultLayouts.DefaultLayoutWrapper + { + MonitorConfiguration = MonitorConfigurationType.Vertical.TypeToString(), + Layout = new DefaultLayouts.DefaultLayoutWrapper.LayoutWrapper + { + Type = LayoutType.Custom.TypeToString(), + Uuid = "{0D6D2F58-9184-4804-81E4-4E4CC3476DC1}", + ZoneCount = 0, + ShowSpacing = false, + Spacing = 0, + SensitivityRadius = 0, + }, + }, + }, + }; + FancyZonesEditorHelper.Files.DefaultLayoutsIOHelper.WriteData(defaultLayouts.Serialize(defaultLayoutsListWrapper)); + + LayoutHotkeys layoutHotkeys = new LayoutHotkeys(); + FancyZonesEditorHelper.Files.LayoutHotkeysIOHelper.WriteData(layoutHotkeys.Serialize(LayoutHotkeysList)); + + AppliedLayouts appliedLayouts = new AppliedLayouts(); + AppliedLayouts.AppliedLayoutsListWrapper appliedLayoutsWrapper = new AppliedLayouts.AppliedLayoutsListWrapper + { + AppliedLayouts = new List<AppliedLayouts.AppliedLayoutWrapper> { }, + }; + FancyZonesEditorHelper.Files.AppliedLayoutsIOHelper.WriteData(appliedLayouts.Serialize(appliedLayoutsWrapper)); + + RestartScopeExe("Hosts"); + } + + [TestMethod("FancyZones.Settings.TestApplyHotKey")] + [TestCategory("FancyZones #1")] + public void TestApplyHotKey() + { + this.OpenFancyZonesPanel(); + this.ControlQuickLayoutSwitch(true); + + // Set Hotkey + this.AttachFancyZonesEditor(); + Session.Find<Element>(By.AccessibilityId(AccessibilityId.GridCustomLayoutCard)).Find<Button>(By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); + const string key = "0"; + var hotkeyComboBox = Session.Find<Element>(By.AccessibilityId(AccessibilityId.HotkeyComboBox)); + Assert.IsNotNull(hotkeyComboBox); + hotkeyComboBox.Click(); + var popup = Session.Find<Element>(By.ClassName(ClassName.Popup)); + Assert.IsNotNull(popup); + popup.Find<Element>($"{key}").Click(); // assign a free hotkey + + Task.Delay(3000).Wait(); + this.CloseFancyZonesEditor(); + this.AttachPowertoySetting(); + SendKeys(Key.Win, Key.Ctrl, Key.Alt, Key.Num0); + Task.Delay(3000).Wait(); + this.AttachFancyZonesEditor(); + var element = this.Find<Element>(By.AccessibilityId(AccessibilityId.GridCustomLayoutCard)); + Assert.IsTrue(element.Selected, $"{element.Selected} Grid custom layout is not visible"); + this.CloseFancyZonesEditor(); + this.AttachPowertoySetting(); + + SendKeys(Key.Win, Key.Ctrl, Key.Alt, Key.Num1); + Task.Delay(3000).Wait(); + this.AttachFancyZonesEditor(); + element = this.Find<Element>(By.AccessibilityId(AccessibilityId.Grid9LayoutCard)); + Assert.IsTrue(element.Selected, $"{element.Selected} Grid-9 is not visible"); + this.CloseFancyZonesEditor(); + this.AttachPowertoySetting(); + + SendKeys(Key.Win, Key.Ctrl, Key.Alt, Key.Num2); + Task.Delay(3000).Wait(); + this.AttachFancyZonesEditor(); + element = this.Find<Element>(By.AccessibilityId(AccessibilityId.CanvasCustomLayoutCard)); + Assert.IsTrue(element.Selected, $"{element.Selected} Canvas custom layout is not visible"); + this.CloseFancyZonesEditor(); + this.AttachPowertoySetting(); + } + + /* + [TestMethod] + [TestCategory("FancyZones #2")] + public void TestDragShiftHotKey() + { + this.OpenFancyZonesPanel(); + this.ControlQuickLayoutSwitch(true); + + int screenWidth = 1920; // default 1920 + + int targetX = screenWidth / 2 / 3; + int targetY = screenWidth / 2 / 2; + + LaunchHostFromSetting(); + this.Session.Attach(PowerToysModule.Hosts, WindowSize.Large_Vertical); + var hostsView = Find<Pane>(By.Name("Non Client Input Sink Window")); + hostsView.DoubleClick(); // maximize the window + + hostsView.HoldShiftToDrag(Key.Shift, targetX, targetY); + SendKeys(Key.Num0); + hostsView.ReleaseAction(); + hostsView.ReleaseKey(Key.Shift); + SendKeys(Key.Alt, Key.F4); + + // Attach FancyZones Editor + this.AttachPowertoySetting(); + this.Find<Pane>(By.ClassName("InputNonClientPointerSource")).Click(); + this.OpenFancyZonesPanel(isMax: false); + this.AttachFancyZonesEditor(); + var elements = this.FindAll<Element>("Grid custom layout"); + if (elements.Count == 0) + { + this.Session.Attach(PowerToysModule.Hosts, WindowSize.Large_Vertical); + hostsView = Find<Pane>(By.Name("Non Client Input Sink Window")); + hostsView.DoubleClick(); // maximize the window + + hostsView.HoldShiftToDrag(Key.Shift, targetX, targetY); + SendKeys(Key.Num0); + hostsView.ReleaseAction(); + hostsView.ReleaseKey(Key.Shift); + SendKeys(Key.Alt, Key.F4); + this.AttachPowertoySetting(); + this.Find<Pane>(By.ClassName("InputNonClientPointerSource")).Click(); + this.OpenFancyZonesPanel(isMax: false); + this.AttachFancyZonesEditor(); + elements = this.FindAll<Element>("Grid custom layout"); + } + + Assert.IsTrue(elements[0].Selected, "Grid custom layout is not visible"); + this.CloseFancyZonesEditor(); + + Clean(); + } + */ + + [TestMethod("FancyZones.Settings.HotKeyWindowFlashTest")] + [TestCategory("FancyZones #3")] + public void HotKeyWindowFlashTest() + { + this.OpenFancyZonesPanel(); + this.ControlQuickLayoutSwitch(true); + + this.TryReaction(); + int tries = 24; + Pull(tries, "down"); + var switchGroup = this.Find<Group>("Enable quick layout switch"); + switchGroup.Click(); + var checkbox1 = switchGroup.Find<Element>("Flash zones when switching layout"); + if (checkbox1.GetAttribute("Toggle.ToggleState") == "0") + { + checkbox1.Click(); + } + + this.Session.PressKey(Key.Win); + this.Session.PressKey(Key.Ctrl); + this.Session.PressKey(Key.Alt); + this.Session.PressKey(Key.Num0); + bool res = this.IsWindowOpen("FancyZones_ZonesOverlay"); + Assert.IsTrue(res, $" HotKeyWindowFlash Test error: FancyZones_ZonesOverlay is not open"); + this.Session.ReleaseKey(Key.Win); + this.Session.ReleaseKey(Key.Ctrl); + this.Session.ReleaseKey(Key.Alt); + this.Session.ReleaseKey(Key.Num0); + + var checkbox2 = this.Find<CheckBox>("Flash zones when switching layout"); + if (checkbox2.GetAttribute("Toggle.ToggleState") == "1") + { + checkbox2.Click(); + } + + // this.CloseFancyZonesEditor(); + Clean(); + } + + [TestMethod("FancyZones.Settings.TestDisableApplyHotKey")] + [TestCategory("FancyZones #4")] + public void TestDisableApplyHotKey() + { + this.OpenFancyZonesPanel(); + this.ControlQuickLayoutSwitch(false); + + SendKeys(Key.Win, Key.Ctrl, Key.Alt, Key.Num0); + this.AttachFancyZonesEditor(); + var element = this.Find<Element>(By.AccessibilityId(AccessibilityId.GridCustomLayoutCard)); + Assert.IsFalse(element.Selected, $"{element.Selected} Grid custom layout is not visible"); + this.CloseFancyZonesEditor(); + this.AttachPowertoySetting(); + + SendKeys(Key.Win, Key.Ctrl, Key.Alt, Key.Num1); + this.AttachFancyZonesEditor(); + element = this.Find<Element>(By.AccessibilityId(AccessibilityId.Grid9LayoutCard)); + Assert.IsFalse(element.Selected, $"{element.Selected} Grid-9 is not visible"); + this.CloseFancyZonesEditor(); + this.AttachPowertoySetting(); + + SendKeys(Key.Win, Key.Ctrl, Key.Alt, Key.Num2); + this.AttachFancyZonesEditor(); + element = this.Find<Element>(By.AccessibilityId(AccessibilityId.CanvasCustomLayoutCard)); + Assert.IsFalse(element.Selected, $"{element.Selected} Canvas custom layout is not visible"); + this.CloseFancyZonesEditor(); + this.AttachPowertoySetting(); + + Clean(); + } + + [TestMethod("FancyZones.Settings.TestVirtualDesktopLayout")] + [TestCategory("FancyZones #6")] + public void TestVirtualDesktopLayout() + { + this.OpenFancyZonesPanel(); + + this.AttachFancyZonesEditor(); + var element = this.Find<Element>(By.AccessibilityId(AccessibilityId.GridCustomLayoutCard)); + element.Click(); + this.CloseFancyZonesEditor(); + this.ExitScopeExe(); + + // Add virtual desktop + SendKeys(Key.Ctrl, Key.Win, Key.D); + this.RestartScopeExe(); + this.OpenFancyZonesPanel(); + this.AttachFancyZonesEditor(); + element = this.Find<Element>(By.AccessibilityId(AccessibilityId.GridCustomLayoutCard)); + Assert.IsTrue(element.Selected, $"{element.Selected} Grid custom layout is not visible"); + this.CloseFancyZonesEditor(); + + // close the virtual desktop + SendKeys(Key.Ctrl, Key.Win, Key.Right); + Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch + SendKeys(Key.Ctrl, Key.Win, Key.F4); + Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch + + Clean(); + } + + [TestMethod("FancyZones.Settings.TestVirtualDesktopLayoutExt")] + [TestCategory("FancyZones #7")] + public void TestVirtualDesktopLayoutExt() + { + this.OpenFancyZonesPanel(); + + this.AttachFancyZonesEditor(); + var element = this.Find<Element>(By.AccessibilityId(AccessibilityId.GridCustomLayoutCard)); + element.Click(); + this.CloseFancyZonesEditor(); + this.ExitScopeExe(); + + // Add virtual desktop + SendKeys(Key.Ctrl, Key.Win, Key.D); + this.RestartScopeExe(); + this.OpenFancyZonesPanel(); + this.AttachFancyZonesEditor(); + element = this.Find<Element>(By.AccessibilityId(AccessibilityId.Grid9LayoutCard)); + element.Click(); + this.CloseFancyZonesEditor(); + this.ExitScopeExe(); + + SendKeys(Key.Ctrl, Key.Win, Key.Left); + this.RestartScopeExe(); + this.OpenFancyZonesPanel(); + this.AttachFancyZonesEditor(); + element = this.Find<Element>(By.AccessibilityId(AccessibilityId.GridCustomLayoutCard)); + Assert.IsTrue(element.Selected, $"{element.Selected} Grid custom layout is not visible"); + this.CloseFancyZonesEditor(); + this.ExitScopeExe(); + + // close the virtual desktop + SendKeys(Key.Ctrl, Key.Win, Key.Right); + Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch + SendKeys(Key.Ctrl, Key.Win, Key.F4); + Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch + + Clean(); + } + + [TestMethod("FancyZones.Settings.TestDeleteCustomLayoutBehavior")] + [TestCategory("FancyZones #8")] + public void TestDeleteCustomLayoutBehavior() + { + this.OpenFancyZonesPanel(); + + this.AttachFancyZonesEditor(); + this.Find<Element>(By.AccessibilityId(AccessibilityId.GridCustomLayoutCard)).Click(); + this.Find<Element>(By.AccessibilityId(AccessibilityId.GridCustomLayoutCard)).Find<Button>(By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); + Session.Find<Button>(By.AccessibilityId(AccessibilityId.DeleteLayoutButton)).Click(); + Session.SendKeySequence(Key.Tab, Key.Enter); + + // verify the empty layout is selected + Assert.IsTrue(Session.Find<Element>(TestConstants.TemplateLayoutNames[LayoutType.Blank])!.Selected); + + Clean(); + } + + [TestMethod("FancyZones.Settings.TestCreateGridLayoutChangeMonitorSetting")] + [TestCategory("FancyZones #9")] + public void TestCreateGridLayoutChangeMonitorSetting() + { + this.OpenFancyZonesPanel(); + this.AttachFancyZonesEditor(); + + string name = "Custom layout 1"; + this.Session.Find<Element>(By.AccessibilityId(AccessibilityId.NewLayoutButton)).Click(); + this.Session.Find<Element>(By.AccessibilityId(AccessibilityId.PrimaryButton)).Click(); + this.Session.Find<Button>(ElementName.Save).Click(); + + // verify new layout presented + Assert.IsNotNull(Session.Find<Element>(name)); + this.CloseFancyZonesEditor(); + + int nowHeight = UITestBase.MonitorInfoData.Monitors[UITestBase.MonitorInfoData.Monitors.Count - 1].PelsHeight; + int nowWidth = UITestBase.MonitorInfoData.Monitors[UITestBase.MonitorInfoData.Monitors.Count - 1].PelsWidth; + int height = UITestBase.MonitorInfoData.Monitors[0].PelsHeight; + int width = UITestBase.MonitorInfoData.Monitors[0].PelsWidth; + UITestBase.NativeMethods.ChangeDisplayResolution(width, height); + this.AttachPowertoySetting(); + this.AttachFancyZonesEditor(); + var maxButton = this.Find<Button>("Maximize"); + maxButton.Click(); // maximize the window + var resolution = this.Session.Find<Element>(By.AccessibilityId("Monitors")).Find<Element>("Monitor 1").Find<Element>(By.AccessibilityId("ResolutionText")); + if (resolution.Text != "640 × 480") + { + this.CloseFancyZonesEditor(); + UITestBase.NativeMethods.ChangeDisplayResolution(nowWidth, nowHeight); + Assert.AreEqual("640 × 480", resolution.Text); + } + + this.CloseFancyZonesEditor(); + UITestBase.NativeMethods.ChangeDisplayResolution(nowWidth, nowHeight); + + Clean(); + } + + private void OpenFancyZonesPanel(bool launchAsAdmin = false, bool isMax = false) + { + var windowingElement = this.Find<NavigationViewItem>("Windowing & Layouts"); + + // Goto FancyZones Editor setting page + if (this.FindAll<NavigationViewItem>("FancyZones").Count == 0) + { + // Expand Advanced list-group if needed + windowingElement.Click(); + } + + windowingElement.Find<Element>("FancyZones").Click(); + Find<ToggleSwitch>(By.AccessibilityId("EnableFancyZonesToggleSwitch")).Toggle(true); + if (isMax == true) + { + this.Find<Button>("Maximize").Click(); // maximize the window + } + + this.Find<Custom>("Editor").Find<TextBlock>(By.AccessibilityId("HeaderPresenter")).Click(); + } + + private void ControlQuickLayoutSwitch(bool flag) + { + this.TryReaction(); + int tries = 24; + Pull(tries, "down"); // Pull the setting page up to make sure the setting is visible + this.Find<ToggleSwitch>("FancyZonesQuickLayoutSwitch").Toggle(flag); + + // Go back and forth to make sure settings applied + this.Find<NavigationViewItem>("Workspaces").Click(); + Task.Delay(200).Wait(); + this.Find<NavigationViewItem>("FancyZones").Click(); + } + + private void TryReaction() + { + this.Find<Custom>("Editor").Find<TextBlock>(By.AccessibilityId("HeaderPresenter")).Click(); + } + + private void AttachPowertoySetting() + { + Task.Delay(200).Wait(); + this.Session.Attach(PowerToysModule.PowerToysSettings); + } + + private void AttachFancyZonesEditor() + { + Task.Delay(4000).Wait(); + this.Find<Button>(By.AccessibilityId(AccessibilityId.LaunchLayoutEditorButton)).Click(); + + Task.Delay(3000).Wait(); + this.Session.Attach(PowerToysModule.FancyZone); + Task.Delay(3000).Wait(); + } + + private void CloseFancyZonesEditor() + { + this.Session.Find<Button>("Close").Click(); + } + + private void Clean() + { + // clean app zone history file + FancyZonesEditorHelper.Files.CustomLayoutsIOHelper.DeleteFile(); + FancyZonesEditorHelper.Files.LayoutHotkeysIOHelper.DeleteFile(); + FancyZonesEditorHelper.Files.LayoutTemplatesIOHelper.DeleteFile(); + } + + private void Pull(int tries = 5, string direction = "up") + { + Key keyToSend = direction == "up" ? Key.Up : Key.Down; + for (int i = 0; i < tries; i++) + { + SendKeys(keyToSend); + } + } + + private void LaunchHostFromSetting(bool showWarning = false, bool launchAsAdmin = false) + { + // Goto Hosts File Editor setting page + if (this.FindAll<NavigationViewItem>("Hosts File Editor").Count == 0) + { + // Expand Advanced list-group if needed + this.Find<NavigationViewItem>("Advanced").Click(); + } + + this.Find<NavigationViewItem>("Hosts File Editor").Click(); + Task.Delay(1000).Wait(); + + this.Find<ToggleSwitch>("Hosts File Editor").Toggle(true); + this.Find<ToggleSwitch>("Launch as administrator").Toggle(launchAsAdmin); + this.Find<ToggleSwitch>("Show a warning at startup").Toggle(showWarning); + + // launch Hosts File Editor + this.Find<Button>("Launch Hosts File Editor").Click(); + + Task.Delay(5000).Wait(); + } + } +} diff --git a/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs b/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs new file mode 100644 index 0000000000..adb5d0a8ee --- /dev/null +++ b/src/modules/fancyzones/FancyZones.UITests/OneZoneSwitchTests.cs @@ -0,0 +1,335 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FancyZonesEditor.Models; +using FancyZonesEditorCommon.Data; +using Microsoft.FancyZones.UITests.Utils; +using Microsoft.FancyZonesEditor.UITests.Utils; +using Microsoft.FancyZonesEditor.UnitTests.Utils; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace UITests_FancyZones +{ + [TestClass] + public class OneZoneSwitchTests : UITestBase + { + private static readonly int SubZones = 2; + private static readonly IOTestHelper AppZoneHistory = new FancyZonesEditorFiles().AppZoneHistoryIOHelper; + private static string powertoysWindowName = "PowerToys Settings"; // set powertoys settings window name + + public OneZoneSwitchTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.Medium) + { + } + + [TestInitialize] + public void TestInitialize() + { + // kill all processes related to FancyZones Editor to ensure a clean state + Session.KillAllProcessesByName("PowerToys.FancyZonesEditor"); + AppZoneHistory.DeleteFile(); + + RestartScopeExe("Hosts"); + FancyZonesEditorHelper.Files.Restore(); + + // Set a custom layout with 1 subzones and clear app zone history + SetupCustomLayouts(); + + // get PowerToys window Name + powertoysWindowName = ZoneSwitchHelper.GetActiveWindowTitle(); + + // Launch FancyZones + LaunchFancyZones(); + + // Launch the Hosts File Editor + LaunchFromSetting(); + } + + /// <summary> + /// Test switching between two snapped windows using keyboard shortcuts in FancyZones + /// <list type="bullet"> + /// <item> + /// <description>Verifies that after snapping two windows, the active window switches correctly using Win+PageDown.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod] + [TestCategory("FancyZones #Switch between windows in the current zone #1")] + public void TestSwitchWindow() + { + var (preWindow, postWindow) = SnapToOneZone(); + + string? activeWindowTitle = ZoneSwitchHelper.GetActiveWindowTitle(); + Assert.AreEqual(postWindow, activeWindowTitle); + + // switch to the previous window by shortcut win+page down + SendKeys(Key.Win, Key.PageDown); + + activeWindowTitle = ZoneSwitchHelper.GetActiveWindowTitle(); + Assert.AreEqual(preWindow, activeWindowTitle); + + Clean(); // close the windows + } + + /// <summary> + /// Test window switch behavior across virtual desktops in FancyZones + /// <list type="bullet"> + /// <item> + /// <description>Verifies that a window remains correctly snapped after switching desktops and can be switched using Win+PageDown.</description> + /// </item> + /// </summary> + [TestMethod("FancyZones.Settings.TestSwitchAfterDesktopChange")] + [TestCategory("FancyZones #Switch between windows in the current zone #2")] + public void TestSwitchAfterDesktopChange() + { + var (preWindow, postWindow) = SnapToOneZone(); + + string? windowTitle = ZoneSwitchHelper.GetActiveWindowTitle(); + Assert.AreEqual(postWindow, windowTitle); + + // Add virtual desktop + SendKeys(Key.Ctrl, Key.Win, Key.D); + + // return back + SendKeys(Key.Ctrl, Key.Win, Key.Left); + Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch + string? returnWindowTitle = ZoneSwitchHelper.GetActiveWindowTitle(); + Assert.AreEqual(postWindow, returnWindowTitle); + + // check shortcut + SendKeys(Key.Win, Key.PageDown); + string? activeWindowTitle = ZoneSwitchHelper.GetActiveWindowTitle(); + Assert.AreEqual(preWindow, activeWindowTitle); + + // close the virtual desktop + SendKeys(Key.Ctrl, Key.Win, Key.Right); + Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch + SendKeys(Key.Ctrl, Key.Win, Key.F4); + Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch + + Clean(); // close the windows + } + + /// <summary> + /// Test window switching shortcut behavior when the shortcut is disabled in FancyZones settings + /// <list type="bullet"> + /// <item> + /// <description>Verifies that pressing Win+PageDown does not switch to the previously snapped window when the shortcut is disabled.</description> + /// </item> + /// </list> + /// </summary> + [TestMethod("FancyZones.Settings.TestSwitchShortCutDisable")] + [TestCategory("FancyZones #Switch between windows in the current zone #3")] + public void TestSwitchShortCutDisable() + { + var (preWindow, postWindow) = SnapToOneZone(); + + string? activeWindowTitle = ZoneSwitchHelper.GetActiveWindowTitle(); + Assert.AreEqual(postWindow, activeWindowTitle); + + // switch to the previous window by shortcut win+page down + SendKeys(Key.Win, Key.PageDown); + Task.Delay(500).Wait(); // Optional: Wait for a moment to ensure window switch + + activeWindowTitle = ZoneSwitchHelper.GetActiveWindowTitle(); + Assert.AreEqual(postWindow, activeWindowTitle); + + Clean(); // close the windows + } + + private (string PreWindow, string PostWindow) SnapToOneZone() + { + this.Session.Attach(PowerToysModule.Hosts, WindowSize.Large_Vertical); + + var hostsView = Find<Pane>(By.Name("Non Client Input Sink Window")); + hostsView.DoubleClick(); // maximize the window + + var rect = Session.GetMainWindowRect(); + var (targetX, targetY) = ZoneSwitchHelper.GetScreenMargins(rect, 4); + + // Snap first window (Hosts) to left zone using shift+drag with direct mouse movement + var hostsRect = hostsView.Rect ?? throw new InvalidOperationException("Failed to get hosts window rect"); + int hostsStartX = hostsRect.Left + 70; + int hostsStartY = hostsRect.Top + 25; + + // For a 2-column layout, left zone is at approximately 1/4 of screen width + int hostsEndX = rect.Left + (3 * (rect.Right - rect.Left) / 4); + int hostsEndY = rect.Top + ((rect.Bottom - rect.Top) / 2); + + Session.MoveMouseTo(hostsStartX, hostsStartY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.PressKey(Key.Shift); + Session.MoveMouseTo(hostsEndX, hostsEndY); + Session.PerformMouseAction(MouseActionType.LeftUp); + Session.ReleaseKey(Key.Shift); + Task.Delay(500).Wait(); // Wait for snap to complete + + string preWindow = ZoneSwitchHelper.GetActiveWindowTitle(); + + // Attach the PowerToys settings window to the front + Session.Attach(powertoysWindowName, WindowSize.UnSpecified); + string windowNameFront = ZoneSwitchHelper.GetActiveWindowTitle(); + Pane settingsView = Find<Pane>(By.Name("Non Client Input Sink Window")); + settingsView.DoubleClick(); // maximize the window + + var windowRect = Session.GetMainWindowRect(); + var settingsRect = settingsView.Rect ?? throw new InvalidOperationException("Failed to get settings window rect"); + int settingsStartX = settingsRect.Left + 70; + int settingsStartY = settingsRect.Top + 25; + + // For a 2-column layout, right zone is at approximately 3/4 of screen width + int settingsEndX = windowRect.Left + (3 * (windowRect.Right - windowRect.Left) / 4); + int settingsEndY = windowRect.Top + ((windowRect.Bottom - windowRect.Top) / 2); + + Session.MoveMouseTo(settingsStartX, settingsStartY); + Session.PerformMouseAction(MouseActionType.LeftDown); + Session.PressKey(Key.Shift); + Session.MoveMouseTo(settingsEndX, settingsEndY); + Session.PerformMouseAction(MouseActionType.LeftUp); + Session.ReleaseKey(Key.Shift); + Task.Delay(500).Wait(); // Wait for snap to complete + + string appZoneHistoryJson = AppZoneHistory.GetData(); + + string? zoneIndexOfFileWindow = ZoneSwitchHelper.GetZoneIndexSetByAppName("PowerToys.Hosts.exe", appZoneHistoryJson); + string? zoneIndexOfPowertoys = ZoneSwitchHelper.GetZoneIndexSetByAppName("PowerToys.Settings.exe", appZoneHistoryJson); + + // check the AppZoneHistory layout is set and in the same zone + Assert.AreEqual(zoneIndexOfPowertoys, zoneIndexOfFileWindow); + + return (preWindow, powertoysWindowName); + } + + private static readonly CustomLayouts.CustomLayoutListWrapper CustomLayoutsList = new CustomLayouts.CustomLayoutListWrapper + { + CustomLayouts = new List<CustomLayouts.CustomLayoutWrapper> + { + new CustomLayouts.CustomLayoutWrapper + { + Uuid = "{63F09977-D327-4DAC-98F4-0C886CAE9517}", + Type = CustomLayout.Grid.TypeToString(), + Name = "Custom Column", + Info = new CustomLayouts().ToJsonElement(new CustomLayouts.GridInfoWrapper + { + Rows = 1, + Columns = SubZones, + RowsPercentage = new List<int> { 10000 }, + ColumnsPercentage = new List<int> { 5000, 5000 }, + CellChildMap = new int[][] { [0, 1] }, + SensitivityRadius = 20, + ShowSpacing = true, + Spacing = 10, + }), + }, + }, + }; + + // clean window + private void Clean() + { + // Close First window + SendKeys(Key.Alt, Key.F4); + + // Close Second window + SendKeys(Key.Alt, Key.F4); + + // clean app zone history file + AppZoneHistory.DeleteFile(); + } + + // Setup custom layout with 1 subzones + private void SetupCustomLayouts() + { + var customLayouts = new CustomLayouts(); + var customLayoutListWrapper = CustomLayoutsList; + FancyZonesEditorHelper.Files.CustomLayoutsIOHelper.WriteData(customLayouts.Serialize(customLayoutListWrapper)); + } + + // launch FancyZones settings page + private void LaunchFancyZones() + { + // Goto FancyZones setting page + if (this.FindAll<NavigationViewItem>("FancyZones").Count == 0) + { + // Expand Advanced list-group if needed + this.Find<NavigationViewItem>("Windowing & Layouts").Click(); + } + + this.Find<NavigationViewItem>("FancyZones").Click(); + Find<ToggleSwitch>(By.AccessibilityId("EnableFancyZonesToggleSwitch")).Toggle(true); + this.Session.SetMainWindowSize(WindowSize.Large); + + // fixed settings + this.Find<CheckBox>("Hold Shift key to activate zones while dragging a window").SetCheck(true, 500); + + // should bind mouse to suitable zone for scrolling + Find<Element>(By.AccessibilityId("HeaderPresenter")).Click(); + this.Scroll(9, "Down"); // Pull the setting page up to make sure the setting is visible + bool switchWindowEnable = TestContext.TestName == "TestSwitchShortCutDisable" ? false : true; + + this.Find<ToggleSwitch>("FancyZonesWindowSwitchingToggle").Toggle(switchWindowEnable); + + // Go back and forth to make sure settings applied + this.Find<NavigationViewItem>("Workspaces").Click(); + Task.Delay(200).Wait(); + this.Find<NavigationViewItem>("FancyZones").Click(); + + this.Find<Button>("Open layout editor").Click(false, 500, 5000); + this.Session.Attach(PowerToysModule.FancyZone); + + // pipeline machine may have an unstable delays, causing the custom layout to be unavailable as we set. then A retry is required. + // Console.WriteLine($"after launch, Custom layout data: {customLayoutData}"); + try + { + // Set the FancyZones layout to a custom layout + this.Find<Element>(By.Name("Custom Column")).Click(); + } + catch (Exception) + { + // Console.WriteLine($"[Exception] Failed to attach to FancyZones window. Retrying...{ex.Message}"); + this.Find<Microsoft.PowerToys.UITest.Button>("Close").Click(); + this.Session.Attach(PowerToysModule.PowerToysSettings); + SetupCustomLayouts(); + this.Find<Microsoft.PowerToys.UITest.Button>("Open layout editor").Click(false, 5000, 5000); + this.Session.Attach(PowerToysModule.FancyZone); + + // customLayoutData = FancyZonesEditorHelper.Files.CustomLayoutsIOHelper.GetData(); + // Console.WriteLine($"after retry, Custom layout data: {customLayoutData}"); + + // Set the FancyZones layout to a custom layout + this.Find<Element>(By.Name("Custom Column")).Click(); + } + + // Close layout editor window + SendKeys(Key.Alt, Key.F4); + this.Session.Attach(powertoysWindowName); + } + + private void LaunchFromSetting(bool showWarning = false, bool launchAsAdmin = false) + { + // Goto Hosts File Editor setting page + if (this.FindAll<NavigationViewItem>("Hosts File Editor").Count == 0) + { + // Expand Advanced list-group if needed + this.Find<NavigationViewItem>("Advanced").Click(); + } + + this.Find<NavigationViewItem>("Hosts File Editor").Click(); + Task.Delay(1000).Wait(); + + this.Find<ToggleSwitch>("Hosts File Editor").Toggle(true); + this.Find<ToggleSwitch>("Open as administrator").Toggle(launchAsAdmin); + this.Find<ToggleSwitch>("Show a warning at startup").Toggle(showWarning); + + // launch Hosts File Editor + this.Find<Button>("Open Hosts File Editor").Click(); + + Task.Delay(5000).Wait(); + } + } +} diff --git a/src/modules/fancyzones/UITests-FancyZones/RunFancyZonesTest.cs b/src/modules/fancyzones/FancyZones.UITests/RunFancyZonesTest.cs similarity index 100% rename from src/modules/fancyzones/UITests-FancyZones/RunFancyZonesTest.cs rename to src/modules/fancyzones/FancyZones.UITests/RunFancyZonesTest.cs diff --git a/src/modules/fancyzones/UITests-FancyZones/Utils/FancyZonesSession.cs b/src/modules/fancyzones/FancyZones.UITests/Utils/FancyZonesSession.cs similarity index 100% rename from src/modules/fancyzones/UITests-FancyZones/Utils/FancyZonesSession.cs rename to src/modules/fancyzones/FancyZones.UITests/Utils/FancyZonesSession.cs diff --git a/src/modules/fancyzones/FancyZones.UITests/Utils/ZoneSwitchHelper.cs b/src/modules/fancyzones/FancyZones.UITests/Utils/ZoneSwitchHelper.cs new file mode 100644 index 0000000000..1bf5fb5371 --- /dev/null +++ b/src/modules/fancyzones/FancyZones.UITests/Utils/ZoneSwitchHelper.cs @@ -0,0 +1,98 @@ +// 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.Diagnostics; +using System.Drawing; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using OpenQA.Selenium.Internal; + +namespace Microsoft.FancyZones.UITests.Utils +{ + public class ZoneSwitchHelper + { + public static string? GetZoneIndexSetByAppName(string exeName, string json) + { + if (string.IsNullOrEmpty(exeName) || string.IsNullOrEmpty(json)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(json); + var historyArray = doc.RootElement.GetProperty("app-zone-history"); + + foreach (var item in historyArray.EnumerateArray()) + { + if (item.TryGetProperty("app-path", out var appPathElement) && + appPathElement.GetString() is string path && + path.EndsWith(exeName, StringComparison.OrdinalIgnoreCase)) + { + var history = item.GetProperty("history"); + if (history.GetArrayLength() > 0) + { + return history[0].GetProperty("zone-index-set")[0].GetRawText(); + } + } + } + } + catch (JsonException ex) + { + throw new InvalidOperationException("JSON parse error: " + ex.Message, ex); + } + + return null; + } + + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + public static string GetActiveWindowTitle() + { + const int nChars = 256; + StringBuilder buff = new StringBuilder(nChars); + IntPtr handle = GetForegroundWindow(); + + if (GetWindowText(handle, buff, nChars) > 0) + { + return buff.ToString(); + } + else + { + // Handle the error if needed + throw new InvalidOperationException("Failed to get window title."); + } + } + + public static (int Dx, int Dy) GetOffset(Element element, int targetX, int targetY) + { + Assert.IsNotNull(element.Rect, "element is null"); + var rect = element.Rect.Value; + return (targetX - rect.X, targetY - rect.Y); + } + + public static (int X, int Y) GetScreenMargins((int Left, int Top, int Right, int Bottom) rect, int quantile = 4) + { + if (quantile == 0) + { + throw new ArgumentException("Quantile cannot be zero.", nameof(quantile)); + } + + int x = (rect.Left + rect.Right) / quantile; + int y = (rect.Top + rect.Bottom) / quantile; + return (x, y); + } + } +} diff --git a/src/modules/fancyzones/FancyZones/FancyZones.vcxproj b/src/modules/fancyzones/FancyZones/FancyZones.vcxproj index b54ee19e34..e1744b8422 100644 --- a/src/modules/fancyzones/FancyZones/FancyZones.vcxproj +++ b/src/modules/fancyzones/FancyZones/FancyZones.vcxproj @@ -2,7 +2,7 @@ <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <!-- Project configurations --> <!-- Props that should be disabled while building on CI server --> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <!-- C++ source compile-specific things for all configurations --> <ItemDefinitionGroup> <ClCompile> @@ -10,7 +10,6 @@ <ConformanceMode>false</ConformanceMode> <TreatWarningAsError>true</TreatWarningAsError> <LanguageStandard>stdcpplatest</LanguageStandard> - <AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions> <PreprocessorDefinitions>_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> @@ -57,7 +56,7 @@ <!-- Props that are constant for both Debug and Release configurations --> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> </PropertyGroup> @@ -156,15 +155,15 @@ <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <Import Project="..\..\..\..\deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/fancyzones/FancyZones/FancyZonesApp.cpp b/src/modules/fancyzones/FancyZones/FancyZonesApp.cpp index 97faec65d5..a20a6fc7f6 100644 --- a/src/modules/fancyzones/FancyZones/FancyZonesApp.cpp +++ b/src/modules/fancyzones/FancyZones/FancyZonesApp.cpp @@ -22,7 +22,7 @@ FancyZonesApp::FancyZonesApp(const std::wstring& appName, const std::wstring& ap m_app = MakeFancyZones(reinterpret_cast<HINSTANCE>(&__ImageBase), std::bind(&FancyZonesApp::DisableModule, this)); m_mainThreadId = GetCurrentThreadId(); - m_exitEventWaiter = EventWaiter(CommonSharedConstants::FZE_EXIT_EVENT, [&](int err) { + m_exitEventWaiter.start(CommonSharedConstants::FZE_EXIT_EVENT, [&](DWORD err) { if (err == ERROR_SUCCESS) { DisableModule(); diff --git a/src/modules/fancyzones/FancyZones/packages.config b/src/modules/fancyzones/FancyZones/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/fancyzones/FancyZones/packages.config +++ b/src/modules/fancyzones/FancyZones/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/FancyZonesBaseCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/FancyZonesBaseCommand.cs new file mode 100644 index 0000000000..57fd4c3dad --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/FancyZonesBaseCommand.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; +using System.CommandLine; +using System.CommandLine.Invocation; + +using FancyZonesCLI; +using FancyZonesCLI.CommandLine; +using FancyZonesCLI.Telemetry; +using Microsoft.PowerToys.Telemetry; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal abstract class FancyZonesBaseCommand : Command +{ + protected FancyZonesBaseCommand(string name, string description) + : base(name, description) + { + this.SetHandler(InvokeInternal); + } + + protected abstract string Execute(InvocationContext context); + + private void InvokeInternal(InvocationContext context) + { + Logger.LogInfo($"Executing command '{Name}'"); + bool successful = false; + + if (!FancyZonesCliGuards.IsFancyZonesRunning()) + { + Logger.LogWarning($"Command '{Name}' blocked: FancyZones is not running"); + context.Console.Error.Write($"{Properties.Resources.error_fancyzones_not_running}{Environment.NewLine}"); + context.ExitCode = 1; + LogTelemetry(successful: false); + return; + } + + try + { + string output = Execute(context); + context.ExitCode = 0; + successful = true; + + Logger.LogInfo($"Command '{Name}' completed successfully"); + Logger.LogDebug($"Command '{Name}' output length: {output?.Length ?? 0}"); + + if (!string.IsNullOrEmpty(output)) + { + context.Console.Out.Write(output); + context.Console.Out.Write(Environment.NewLine); + } + } + catch (Exception ex) + { + Logger.LogError($"Command '{Name}' failed", ex); + context.Console.Error.Write($"Error: {ex.Message}{Environment.NewLine}"); + context.ExitCode = 1; + successful = false; + } + finally + { + LogTelemetry(successful); + } + } + + private void LogTelemetry(bool successful) + { + try + { + PowerToysTelemetry.Log.WriteEvent(new FancyZonesCLICommandEvent + { + CommandName = Name, + Successful = successful, + }); + } + catch (Exception ex) + { + // Don't fail the command if telemetry logging fails + Logger.LogError($"Failed to log telemetry for command '{Name}'", ex); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetActiveLayoutCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetActiveLayoutCommand.cs new file mode 100644 index 0000000000..14e7fe71c5 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetActiveLayoutCommand.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 System; +using System.CommandLine.Invocation; +using System.Globalization; + +using FancyZonesCLI.Utils; +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class GetActiveLayoutCommand : FancyZonesBaseCommand +{ + public GetActiveLayoutCommand() + : base("get-active-layout", Properties.Resources.cmd_get_active_layout) + { + AddAlias("active"); + } + + protected override string Execute(InvocationContext context) + { + // Trigger FancyZones to save current monitor info and read it reliably. + var editorParams = EditorParametersRefresh.ReadEditorParametersWithRefresh( + () => NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_SAVE_EDITOR_PARAMETERS)); + + if (editorParams.Monitors == null || editorParams.Monitors.Count == 0) + { + throw new InvalidOperationException(Properties.Resources.get_active_layout_no_monitor_info); + } + + // Read applied layouts. + var appliedLayouts = FancyZonesDataIO.ReadAppliedLayouts(); + + if (appliedLayouts.AppliedLayouts == null) + { + return Properties.Resources.get_active_layout_no_layouts; + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"\n{Properties.Resources.get_active_layout_header}\n"); + + // Show only layouts for currently connected monitors. + for (int i = 0; i < editorParams.Monitors.Count; i++) + { + var monitor = editorParams.Monitors[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {i + 1}: {monitor.Monitor}"); + + var matchedLayout = AppliedLayoutsHelper.FindLayoutForMonitor( + appliedLayouts, + monitor.Monitor, + monitor.MonitorSerialNumber, + monitor.MonitorNumber, + monitor.VirtualDesktop); + + if (matchedLayout.HasValue) + { + var layout = matchedLayout.Value.AppliedLayout; + sb.AppendLine(CultureInfo.InvariantCulture, $" Layout UUID: {layout.Uuid}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Layout Type: {layout.Type}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Zone Count: {layout.ZoneCount}"); + + if (layout.ShowSpacing) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" Spacing: {layout.Spacing}px"); + } + + sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {layout.SensitivityRadius}px"); + } + else + { + sb.AppendLine(Properties.Resources.get_active_layout_no_layout); + } + + if (i < editorParams.Monitors.Count - 1) + { + sb.AppendLine(); + } + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetHotkeysCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetHotkeysCommand.cs new file mode 100644 index 0000000000..7f91849922 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetHotkeysCommand.cs @@ -0,0 +1,43 @@ +// 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.CommandLine.Invocation; +using System.Globalization; +using System.Linq; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class GetHotkeysCommand : FancyZonesBaseCommand +{ + public GetHotkeysCommand() + : base("get-hotkeys", Properties.Resources.cmd_get_hotkeys) + { + AddAlias("hk"); + } + + protected override string Execute(InvocationContext context) + { + var hotkeys = FancyZonesDataIO.ReadLayoutHotkeys(); + + if (hotkeys.LayoutHotkeys == null || hotkeys.LayoutHotkeys.Count == 0) + { + return Properties.Resources.get_hotkeys_no_hotkeys; + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"{Properties.Resources.get_hotkeys_header}\n"); + sb.AppendLine($"{Properties.Resources.get_hotkeys_instruction}\n"); + + foreach (var hotkey in hotkeys.LayoutHotkeys.OrderBy(h => h.Key)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" [{hotkey.Key}] => {hotkey.LayoutId}"); + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetLayoutsCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetLayoutsCommand.cs new file mode 100644 index 0000000000..f7fbd43eb7 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetLayoutsCommand.cs @@ -0,0 +1,110 @@ +// 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.CommandLine.Invocation; +using System.Globalization; +using System.Text.Json; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class GetLayoutsCommand : FancyZonesBaseCommand +{ + public GetLayoutsCommand() + : base("get-layouts", Properties.Resources.cmd_get_layouts) + { + AddAlias("ls"); + } + + protected override string Execute(InvocationContext context) + { + var sb = new System.Text.StringBuilder(); + + // Print template layouts. + var templatesJson = FancyZonesDataIO.ReadLayoutTemplates(); + + if (templatesJson.LayoutTemplates != null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Built-in Template Layouts ({templatesJson.LayoutTemplates.Count} total) ===\n"); + + for (int i = 0; i < templatesJson.LayoutTemplates.Count; i++) + { + var template = templatesJson.LayoutTemplates[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"[T{i + 1}] {template.Type}"); + sb.Append(CultureInfo.InvariantCulture, $" Zones: {template.ZoneCount}"); + if (template.ShowSpacing && template.Spacing > 0) + { + sb.Append(CultureInfo.InvariantCulture, $", Spacing: {template.Spacing}px"); + } + + sb.AppendLine(); + sb.AppendLine(); + + // Draw visual preview. + sb.Append(LayoutVisualizer.DrawTemplateLayout(template)); + + if (i < templatesJson.LayoutTemplates.Count - 1) + { + sb.AppendLine(); + } + } + + sb.AppendLine("\n"); + } + + // Print custom layouts. + var customLayouts = FancyZonesDataIO.ReadCustomLayouts(); + + if (customLayouts.CustomLayouts != null) + { + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.get_layouts_custom_header, customLayouts.CustomLayouts.Count)); + + for (int i = 0; i < customLayouts.CustomLayouts.Count; i++) + { + var layout = customLayouts.CustomLayouts[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"[{i + 1}] {layout.Name}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" UUID: {layout.Uuid}"); + sb.Append(CultureInfo.InvariantCulture, $" Type: {layout.Type}"); + + bool isCanvasLayout = false; + if (layout.Info.ValueKind != JsonValueKind.Undefined && layout.Info.ValueKind != JsonValueKind.Null) + { + if (layout.Type == "grid" && layout.Info.TryGetProperty("rows", out var rows) && layout.Info.TryGetProperty("columns", out var cols)) + { + sb.Append(CultureInfo.InvariantCulture, $" ({rows.GetInt32()}x{cols.GetInt32()} grid)"); + } + else if (layout.Type == "canvas" && layout.Info.TryGetProperty("zones", out var zones)) + { + sb.Append(CultureInfo.InvariantCulture, $" ({zones.GetArrayLength()} zones)"); + isCanvasLayout = true; + } + } + + sb.AppendLine("\n"); + + // Draw visual preview. + sb.Append(LayoutVisualizer.DrawCustomLayout(layout)); + + // Add note for canvas layouts. + if (isCanvasLayout) + { + sb.AppendLine($"\n {Properties.Resources.get_layouts_canvas_note}"); + sb.AppendLine($" {Properties.Resources.get_layouts_canvas_detail}"); + } + + if (i < customLayouts.CustomLayouts.Count - 1) + { + sb.AppendLine(); + } + } + + sb.AppendLine($"\n{Properties.Resources.get_layouts_usage}"); + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetMonitorsCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetMonitorsCommand.cs new file mode 100644 index 0000000000..700ee656ce --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetMonitorsCommand.cs @@ -0,0 +1,95 @@ +// 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.CommandLine.Invocation; +using System.Globalization; + +using FancyZonesCLI.Utils; +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class GetMonitorsCommand : FancyZonesBaseCommand +{ + public GetMonitorsCommand() + : base("get-monitors", Properties.Resources.cmd_get_monitors) + { + AddAlias("m"); + } + + protected override string Execute(InvocationContext context) + { + // Request FancyZones to save current monitor configuration and read it reliably. + EditorParameters.ParamsWrapper editorParams; + try + { + editorParams = EditorParametersRefresh.ReadEditorParametersWithRefresh( + () => NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_SAVE_EDITOR_PARAMETERS)); + } + catch (Exception ex) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.get_monitors_error, ex.Message), ex); + } + + if (editorParams.Monitors == null || editorParams.Monitors.Count == 0) + { + return Properties.Resources.get_monitors_no_monitors; + } + + // Also read applied layouts to show which layout is active on each monitor. + var appliedLayouts = FancyZonesDataIO.ReadAppliedLayouts(); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.get_monitors_header, editorParams.Monitors.Count)); + sb.AppendLine(); + + for (int i = 0; i < editorParams.Monitors.Count; i++) + { + var monitor = editorParams.Monitors[i]; + var monitorNum = i + 1; + + sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {monitorNum}:"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor: {monitor.Monitor}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Instance: {monitor.MonitorInstanceId}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Number: {monitor.MonitorNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Serial Number: {monitor.MonitorSerialNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Virtual Desktop: {monitor.VirtualDesktop}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" DPI: {monitor.Dpi}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Resolution: {monitor.MonitorWidth}x{monitor.MonitorHeight}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Work Area: {monitor.WorkAreaWidth}x{monitor.WorkAreaHeight}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Position: ({monitor.LeftCoordinate}, {monitor.TopCoordinate})"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Selected: {monitor.IsSelected}"); + + // Find matching applied layout for this monitor using EditorCommon's matching logic. + if (appliedLayouts.AppliedLayouts != null) + { + var matchedLayout = AppliedLayoutsHelper.FindLayoutForMonitor( + appliedLayouts, + monitor.Monitor, + monitor.MonitorSerialNumber, + monitor.MonitorNumber, + monitor.VirtualDesktop); + + if (matchedLayout != null && matchedLayout.Value.AppliedLayout.Type != null) + { + sb.AppendLine(); + sb.AppendLine(CultureInfo.InvariantCulture, $" Active Layout: {matchedLayout.Value.AppliedLayout.Type}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Zone Count: {matchedLayout.Value.AppliedLayout.ZoneCount}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {matchedLayout.Value.AppliedLayout.SensitivityRadius}px"); + if (!string.IsNullOrEmpty(matchedLayout.Value.AppliedLayout.Uuid) && + matchedLayout.Value.AppliedLayout.Uuid != "{00000000-0000-0000-0000-000000000000}") + { + sb.AppendLine(CultureInfo.InvariantCulture, $" Layout UUID: {matchedLayout.Value.AppliedLayout.Uuid}"); + } + } + } + + sb.AppendLine(); + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenEditorCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenEditorCommand.cs new file mode 100644 index 0000000000..5cdb6c1557 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenEditorCommand.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; +using System.CommandLine.Invocation; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class OpenEditorCommand : FancyZonesBaseCommand +{ + public OpenEditorCommand() + : base("open-editor", Properties.Resources.cmd_open_editor) + { + AddAlias("e"); + } + + protected override string Execute(InvocationContext context) + { + const string FancyZonesEditorToggleEventName = "Local\\FancyZones-ToggleEditorEvent-1e174338-06a3-472b-874d-073b21c62f14"; + + // Check if editor is already running + var existingProcess = Process.GetProcessesByName("PowerToys.FancyZonesEditor").FirstOrDefault(); + if (existingProcess != null) + { + NativeMethods.SetForegroundWindow(existingProcess.MainWindowHandle); + return string.Empty; + } + + try + { + using var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, FancyZonesEditorToggleEventName); + eventHandle.Set(); + return string.Empty; + } + catch (Exception ex) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.open_editor_error, ex.Message), ex); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenSettingsCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenSettingsCommand.cs new file mode 100644 index 0000000000..ce55d82d79 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenSettingsCommand.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.CommandLine.Invocation; +using System.Diagnostics; +using System.Globalization; +using System.IO; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class OpenSettingsCommand : FancyZonesBaseCommand +{ + public OpenSettingsCommand() + : base("open-settings", Properties.Resources.cmd_open_settings) + { + AddAlias("settings"); + } + + protected override string Execute(InvocationContext context) + { + // Check in the same directory as the CLI (typical for dev builds) + var powertoysExe = Path.Combine(AppContext.BaseDirectory, "PowerToys.exe"); + if (!File.Exists(powertoysExe)) + { + throw new FileNotFoundException("PowerToys.exe not found. Ensure PowerToys is installed, or run the CLI from the same folder as PowerToys.exe.", powertoysExe); + } + + try + { + var process = Process.Start(new ProcessStartInfo + { + FileName = powertoysExe, + Arguments = "--open-settings=FancyZones", + UseShellExecute = false, + }); + + if (process == null) + { + throw new InvalidOperationException(Properties.Resources.open_settings_error_not_started); + } + + return string.Empty; + } + catch (Exception ex) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.open_settings_error, ex.Message), ex); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/RemoveHotkeyCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/RemoveHotkeyCommand.cs new file mode 100644 index 0000000000..64de0bcc8a --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/RemoveHotkeyCommand.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Globalization; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class RemoveHotkeyCommand : FancyZonesBaseCommand +{ + private readonly Argument<int> _key; + + public RemoveHotkeyCommand() + : base("remove-hotkey", Properties.Resources.cmd_remove_hotkey) + { + AddAlias("rhk"); + + _key = new Argument<int>("key", Properties.Resources.remove_hotkey_arg_key); + AddArgument(_key); + } + + protected override string Execute(InvocationContext context) + { + // FancyZones running guard is handled by FancyZonesBaseCommand. + int key = context.ParseResult.GetValueForArgument(_key); + + var hotkeysWrapper = FancyZonesDataIO.ReadLayoutHotkeys(); + + if (hotkeysWrapper.LayoutHotkeys == null) + { + return Properties.Resources.remove_hotkey_no_hotkeys; + } + + var hotkeysList = hotkeysWrapper.LayoutHotkeys; + var removed = hotkeysList.RemoveAll(h => h.Key == key); + if (removed == 0) + { + return string.Format(CultureInfo.InvariantCulture, Properties.Resources.remove_hotkey_not_found, key); + } + + // Save. + var newWrapper = new LayoutHotkeys.LayoutHotkeysWrapper { LayoutHotkeys = hotkeysList }; + FancyZonesDataIO.WriteLayoutHotkeys(newWrapper); + + // Notify FancyZones. + NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE); + + return string.Empty; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetHotkeyCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetHotkeyCommand.cs new file mode 100644 index 0000000000..d65a6e1eee --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetHotkeyCommand.cs @@ -0,0 +1,97 @@ +// 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.CommandLine; +using System.CommandLine.Invocation; +using System.Globalization; +using System.Linq; + +using FancyZonesCLI.Utils; +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class SetHotkeyCommand : FancyZonesBaseCommand +{ + private readonly Argument<int> _key; + private readonly Argument<string> _layout; + + public SetHotkeyCommand() + : base("set-hotkey", Properties.Resources.cmd_set_hotkey) + { + AddAlias("shk"); + + _key = new Argument<int>("key", Properties.Resources.set_hotkey_arg_key); + _layout = new Argument<string>("layout", Properties.Resources.set_hotkey_arg_layout); + + AddArgument(_key); + AddArgument(_layout); + } + + protected override string Execute(InvocationContext context) + { + // FancyZones running guard is handled by FancyZonesBaseCommand. + int key = context.ParseResult.GetValueForArgument(_key); + string layoutInput = context.ParseResult.GetValueForArgument(_layout); + + if (key < 0 || key > 9) + { + throw new InvalidOperationException(Properties.Resources.set_hotkey_error_invalid_key); + } + + // Normalize GUID to Windows format with braces (supports input with or without braces) + if (!GuidHelper.TryNormalizeGuid(layoutInput, out string layout)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_hotkey_error_not_custom, layoutInput)); + } + + // Editor only allows assigning hotkeys to existing custom layouts. + var customLayouts = FancyZonesDataIO.ReadCustomLayouts(); + + CustomLayouts.CustomLayoutWrapper? matchedLayout = null; + if (customLayouts.CustomLayouts != null) + { + foreach (var candidate in customLayouts.CustomLayouts) + { + if (candidate.Uuid.Equals(layout, StringComparison.OrdinalIgnoreCase)) + { + matchedLayout = candidate; + break; + } + } + } + + if (!matchedLayout.HasValue) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_hotkey_error_not_custom, layoutInput)); + } + + string layoutName = matchedLayout.Value.Name; + + var hotkeysWrapper = FancyZonesDataIO.ReadLayoutHotkeys(); + + var hotkeysList = hotkeysWrapper.LayoutHotkeys ?? new List<LayoutHotkeys.LayoutHotkeyWrapper>(); + + // Match editor behavior: + // - One key maps to one layout + // - One layout maps to at most one key + hotkeysList.RemoveAll(h => h.Key == key); + hotkeysList.RemoveAll(h => string.Equals(h.LayoutId, layout, StringComparison.OrdinalIgnoreCase)); + + // Add new hotkey. + hotkeysList.Add(new LayoutHotkeys.LayoutHotkeyWrapper { Key = key, LayoutId = layout }); + + // Save. + var newWrapper = new LayoutHotkeys.LayoutHotkeysWrapper { LayoutHotkeys = hotkeysList }; + FancyZonesDataIO.WriteLayoutHotkeys(newWrapper); + + // Notify FancyZones. + NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE); + + return string.Empty; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetLayoutCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetLayoutCommand.cs new file mode 100644 index 0000000000..49d85ee06f --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetLayoutCommand.cs @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Globalization; + +using FancyZonesCLI.Utils; +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class SetLayoutCommand : FancyZonesBaseCommand +{ + private static readonly string[] AliasesMonitor = ["--monitor", "-m"]; + private static readonly string[] AliasesAll = ["--all", "-a"]; + + private const string DefaultLayoutUuid = "{00000000-0000-0000-0000-000000000000}"; + + private readonly Argument<string> _layoutId; + private readonly Option<int?> _monitor; + private readonly Option<bool> _all; + + public SetLayoutCommand() + : base("set-layout", Properties.Resources.cmd_set_layout) + { + AddAlias("s"); + + _layoutId = new Argument<string>("layout", Properties.Resources.set_layout_arg_layout); + AddArgument(_layoutId); + + _monitor = new Option<int?>(AliasesMonitor, Properties.Resources.set_layout_opt_monitor); + _monitor.AddValidator(result => + { + if (result.Tokens.Count == 0) + { + return; + } + + int? monitor = result.GetValueOrDefault<int?>(); + if (monitor.HasValue && monitor.Value < 1) + { + result.ErrorMessage = Properties.Resources.set_layout_error_monitor_index; + } + }); + + _all = new Option<bool>(AliasesAll, Properties.Resources.set_layout_opt_all); + + AddOption(_monitor); + AddOption(_all); + + AddValidator(commandResult => + { + int? monitor = commandResult.GetValueForOption(_monitor); + bool all = commandResult.GetValueForOption(_all); + + if (monitor.HasValue && all) + { + commandResult.ErrorMessage = Properties.Resources.set_layout_error_both_options; + } + }); + } + + protected override string Execute(InvocationContext context) + { + // FancyZones running guard is handled by FancyZonesBaseCommand. + string layout = context.ParseResult.GetValueForArgument(_layoutId); + int? monitor = context.ParseResult.GetValueForOption(_monitor); + bool all = context.ParseResult.GetValueForOption(_all); + Logger.LogInfo($"SetLayout called with layout: '{layout}', monitor: {(monitor.HasValue ? monitor.Value.ToString(CultureInfo.InvariantCulture) : "<default>")}, all: {all}"); + + var (targetCustomLayout, targetTemplate) = ResolveTargetLayout(layout); + + var editorParams = ReadEditorParametersWithRefresh(); + var appliedLayouts = FancyZonesDataIO.ReadAppliedLayouts(); + appliedLayouts.AppliedLayouts ??= new List<AppliedLayouts.AppliedLayoutWrapper>(); + + List<int> monitorsToUpdate = GetMonitorsToUpdate(editorParams, monitor, all); + List<AppliedLayouts.AppliedLayoutWrapper> newLayouts = BuildNewLayouts(editorParams, monitorsToUpdate, targetCustomLayout, targetTemplate); + var updatedLayouts = MergeWithHistoricalLayouts(appliedLayouts, newLayouts); + + Logger.LogInfo($"Writing {updatedLayouts.AppliedLayouts?.Count ?? 0} layouts to file"); + FancyZonesDataIO.WriteAppliedLayouts(updatedLayouts); + Logger.LogInfo($"Applied layouts file updated for {monitorsToUpdate.Count} monitor(s)"); + + NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE); + Logger.LogInfo("FancyZones notified of layout change"); + + return BuildSuccessMessage(layout, monitor, all); + } + + private static string BuildSuccessMessage(string layout, int? monitor, bool all) + { + if (all) + { + return string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_layout_success_all, layout); + } + + if (monitor.HasValue) + { + return string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_layout_success_monitor, layout, monitor.Value); + } + + return string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_layout_success_default, layout); + } + + private static (CustomLayouts.CustomLayoutWrapper? TargetCustomLayout, LayoutTemplates.TemplateLayoutWrapper? TargetTemplate) ResolveTargetLayout(string layout) + { + var customLayouts = FancyZonesDataIO.ReadCustomLayouts(); + CustomLayouts.CustomLayoutWrapper? targetCustomLayout = FindCustomLayout(customLayouts, layout); + + LayoutTemplates.TemplateLayoutWrapper? targetTemplate = null; + if (!targetCustomLayout.HasValue || string.IsNullOrEmpty(targetCustomLayout.Value.Uuid)) + { + var templates = FancyZonesDataIO.ReadLayoutTemplates(); + targetTemplate = FindTemplate(templates, layout); + + if (targetCustomLayout.HasValue && string.IsNullOrEmpty(targetCustomLayout.Value.Uuid)) + { + targetCustomLayout = null; + } + } + + if (!targetCustomLayout.HasValue && !targetTemplate.HasValue) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_layout_error_not_found, layout)); + } + + return (targetCustomLayout, targetTemplate); + } + + private static CustomLayouts.CustomLayoutWrapper? FindCustomLayout(CustomLayouts.CustomLayoutListWrapper customLayouts, string layout) + { + if (customLayouts.CustomLayouts == null) + { + return null; + } + + // Normalize GUID to Windows format with braces (supports input with or without braces) + string normalizedLayout = GuidHelper.NormalizeGuid(layout) ?? layout; + + foreach (var customLayout in customLayouts.CustomLayouts) + { + if (customLayout.Uuid.Equals(normalizedLayout, StringComparison.OrdinalIgnoreCase)) + { + return customLayout; + } + } + + return null; + } + + private static LayoutTemplates.TemplateLayoutWrapper? FindTemplate(LayoutTemplates.TemplateLayoutsListWrapper templates, string layout) + { + if (templates.LayoutTemplates == null) + { + return null; + } + + foreach (var template in templates.LayoutTemplates) + { + if (template.Type.Equals(layout, StringComparison.OrdinalIgnoreCase)) + { + return template; + } + } + + return null; + } + + private static EditorParameters.ParamsWrapper ReadEditorParametersWithRefresh() + { + return EditorParametersRefresh.ReadEditorParametersWithRefresh( + () => NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_SAVE_EDITOR_PARAMETERS)); + } + + private static List<int> GetMonitorsToUpdate(EditorParameters.ParamsWrapper editorParams, int? monitor, bool all) + { + var result = new List<int>(); + + if (all) + { + for (int i = 0; i < editorParams.Monitors.Count; i++) + { + result.Add(i); + } + + return result; + } + + if (monitor.HasValue) + { + int monitorIndex = monitor.Value - 1; // Convert to 0-based. + if (monitorIndex < 0 || monitorIndex >= editorParams.Monitors.Count) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_layout_error_monitor_not_found, monitor.Value, editorParams.Monitors.Count)); + } + + result.Add(monitorIndex); + return result; + } + + // Default: first monitor. + result.Add(0); + return result; + } + + private static List<AppliedLayouts.AppliedLayoutWrapper> BuildNewLayouts( + EditorParameters.ParamsWrapper editorParams, + List<int> monitorsToUpdate, + CustomLayouts.CustomLayoutWrapper? targetCustomLayout, + LayoutTemplates.TemplateLayoutWrapper? targetTemplate) + { + var newLayouts = new List<AppliedLayouts.AppliedLayoutWrapper>(); + + foreach (int monitorIndex in monitorsToUpdate) + { + var currentMonitor = editorParams.Monitors[monitorIndex]; + + var (layoutUuid, layoutType, showSpacing, spacing, zoneCount, sensitivityRadius) = + GetLayoutSettings(targetCustomLayout, targetTemplate); + + var deviceId = new AppliedLayouts.AppliedLayoutWrapper.DeviceIdWrapper + { + Monitor = currentMonitor.Monitor, + MonitorInstance = currentMonitor.MonitorInstanceId, + MonitorNumber = currentMonitor.MonitorNumber, + SerialNumber = currentMonitor.MonitorSerialNumber, + VirtualDesktop = currentMonitor.VirtualDesktop, + }; + + newLayouts.Add(new AppliedLayouts.AppliedLayoutWrapper + { + Device = deviceId, + AppliedLayout = new AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper + { + Uuid = layoutUuid, + Type = layoutType, + ShowSpacing = showSpacing, + Spacing = spacing, + ZoneCount = zoneCount, + SensitivityRadius = sensitivityRadius, + }, + }); + } + + if (newLayouts.Count == 0) + { + throw new InvalidOperationException(Properties.Resources.set_layout_error_no_monitors); + } + + return newLayouts; + } + + private static (string LayoutUuid, string LayoutType, bool ShowSpacing, int Spacing, int ZoneCount, int SensitivityRadius) GetLayoutSettings( + CustomLayouts.CustomLayoutWrapper? targetCustomLayout, + LayoutTemplates.TemplateLayoutWrapper? targetTemplate) + { + if (targetCustomLayout.HasValue) + { + var customLayoutsSerializer = new CustomLayouts(); + string type = targetCustomLayout.Value.Type?.ToLowerInvariant() ?? string.Empty; + + bool showSpacing = false; + int spacing = 0; + int zoneCount = 0; + int sensitivityRadius = 20; + + if (type == "canvas") + { + var info = customLayoutsSerializer.CanvasFromJsonElement(targetCustomLayout.Value.Info.GetRawText()); + zoneCount = info.Zones?.Count ?? 0; + sensitivityRadius = info.SensitivityRadius; + } + else if (type == "grid") + { + var info = customLayoutsSerializer.GridFromJsonElement(targetCustomLayout.Value.Info.GetRawText()); + showSpacing = info.ShowSpacing; + spacing = info.Spacing; + sensitivityRadius = info.SensitivityRadius; + + if (info.CellChildMap != null) + { + var uniqueZoneIds = new HashSet<int>(); + + for (int r = 0; r < info.CellChildMap.Length; r++) + { + int[] row = info.CellChildMap[r]; + if (row == null) + { + continue; + } + + for (int c = 0; c < row.Length; c++) + { + uniqueZoneIds.Add(row[c]); + } + } + + zoneCount = uniqueZoneIds.Count; + } + } + else + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_layout_error_unsupported_type, targetCustomLayout.Value.Type)); + } + + return ( + targetCustomLayout.Value.Uuid, + Constants.CustomLayoutJsonTag, + ShowSpacing: showSpacing, + Spacing: spacing, + ZoneCount: zoneCount, + SensitivityRadius: sensitivityRadius); + } + + if (targetTemplate.HasValue) + { + return ( + DefaultLayoutUuid, + targetTemplate.Value.Type, + targetTemplate.Value.ShowSpacing, + targetTemplate.Value.Spacing, + targetTemplate.Value.ZoneCount, + targetTemplate.Value.SensitivityRadius); + } + + throw new InvalidOperationException(Properties.Resources.set_layout_error_no_layout); + } + + private static AppliedLayouts.AppliedLayoutsListWrapper MergeWithHistoricalLayouts( + AppliedLayouts.AppliedLayoutsListWrapper existingLayouts, + List<AppliedLayouts.AppliedLayoutWrapper> newLayouts) + { + var mergedLayoutsList = new List<AppliedLayouts.AppliedLayoutWrapper>(); + mergedLayoutsList.AddRange(newLayouts); + + if (existingLayouts.AppliedLayouts != null) + { + foreach (var existingLayout in existingLayouts.AppliedLayouts) + { + bool isUpdated = false; + + foreach (var newLayout in newLayouts) + { + if (AppliedLayoutsHelper.MatchesDevice( + existingLayout.Device, + newLayout.Device.Monitor, + newLayout.Device.SerialNumber, + newLayout.Device.MonitorNumber, + newLayout.Device.VirtualDesktop)) + { + isUpdated = true; + break; + } + } + + if (!isUpdated) + { + mergedLayoutsList.Add(existingLayout); + } + } + } + + return new AppliedLayouts.AppliedLayoutsListWrapper + { + AppliedLayouts = mergedLayoutsList, + }; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliCommandFactory.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliCommandFactory.cs new file mode 100644 index 0000000000..864e221b34 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliCommandFactory.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; +using FancyZonesCLI.CommandLine.Commands; + +namespace FancyZonesCLI.CommandLine; + +internal static class FancyZonesCliCommandFactory +{ + public static RootCommand CreateRootCommand() + { + var root = new RootCommand("FancyZones CLI - Command line interface for FancyZones"); + + root.AddCommand(new OpenEditorCommand()); + root.AddCommand(new GetMonitorsCommand()); + root.AddCommand(new GetLayoutsCommand()); + root.AddCommand(new GetActiveLayoutCommand()); + root.AddCommand(new SetLayoutCommand()); + root.AddCommand(new OpenSettingsCommand()); + root.AddCommand(new GetHotkeysCommand()); + root.AddCommand(new SetHotkeyCommand()); + root.AddCommand(new RemoveHotkeyCommand()); + + return root; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliGuards.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliGuards.cs new file mode 100644 index 0000000000..e80af07c15 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliGuards.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; + +namespace FancyZonesCLI.CommandLine; + +internal static class FancyZonesCliGuards +{ + public static bool IsFancyZonesRunning() + { + try + { + return Process.GetProcessesByName("PowerToys.FancyZones").Length != 0; + } + catch + { + return false; + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliUsage.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliUsage.cs new file mode 100644 index 0000000000..63d43aabd0 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliUsage.cs @@ -0,0 +1,194 @@ +// 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.CommandLine; +using System.Globalization; +using System.Linq; + +namespace FancyZonesCLI.CommandLine; + +internal static class FancyZonesCliUsage +{ + public static void PrintUsage() + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.WriteLine(Properties.Resources.usage_title); + Console.WriteLine(); + + var cmd = FancyZonesCliCommandFactory.CreateRootCommand(); + + Console.WriteLine(Properties.Resources.usage_syntax); + Console.WriteLine(); + + Console.WriteLine(Properties.Resources.usage_options); + foreach (var option in cmd.Options) + { + var aliases = string.Join(", ", option.Aliases); + var description = option.Description ?? string.Empty; + Console.WriteLine($" {aliases,-30} {description}"); + } + + Console.WriteLine(); + Console.WriteLine(Properties.Resources.usage_commands); + foreach (var command in cmd.Subcommands) + { + if (command.IsHidden) + { + continue; + } + + // Format: "command-name <args>, alias" + string argsLabel = string.Join(" ", command.Arguments.Select(a => $"<{a.Name}>")); + string baseLabel = string.IsNullOrEmpty(argsLabel) ? command.Name : $"{command.Name} {argsLabel}"; + + // Find first alias (Aliases includes Name) + string alias = command.Aliases.FirstOrDefault(a => !string.Equals(a, command.Name, StringComparison.OrdinalIgnoreCase)); + string label = string.IsNullOrEmpty(alias) ? baseLabel : $"{baseLabel}, {alias}"; + + var description = command.Description ?? string.Empty; + Console.WriteLine($" {label,-30} {description}"); + } + + Console.WriteLine(); + Console.WriteLine(Properties.Resources.usage_examples); + Console.WriteLine(" FancyZonesCLI --help"); + Console.WriteLine(" FancyZonesCLI --version"); + Console.WriteLine(" FancyZonesCLI get-monitors"); + Console.WriteLine(" FancyZonesCLI set-layout focus"); + Console.WriteLine(" FancyZonesCLI set-layout <uuid> --monitor 1"); + Console.WriteLine(" FancyZonesCLI get-hotkeys"); + } + + public static void PrintCommandUsage(string commandName) + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + + var rootCmd = FancyZonesCliCommandFactory.CreateRootCommand(); + + // Find matching subcommand by name or alias + var subcommand = rootCmd.Subcommands.FirstOrDefault(c => + c.Aliases.Any(a => string.Equals(a, commandName, StringComparison.OrdinalIgnoreCase))); + + if (subcommand == null) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.usage_unknown_command, commandName)); + Console.WriteLine(); + Console.WriteLine(Properties.Resources.usage_run_help); + return; + } + + // Command name and description + Console.WriteLine($"{Properties.Resources.usage_command} {subcommand.Name}"); + if (!string.IsNullOrEmpty(subcommand.Description)) + { + Console.WriteLine($" {subcommand.Description}"); + } + + Console.WriteLine(); + + // Usage line + string argsLabel = string.Join(" ", subcommand.Arguments.Select(a => $"<{a.Name}>")); + string optionsLabel = subcommand.Options.Any() ? " [options]" : string.Empty; + Console.WriteLine($"Usage: FancyZonesCLI {subcommand.Name} {argsLabel}{optionsLabel}".TrimEnd()); + Console.WriteLine(); + + // Aliases + var aliases = subcommand.Aliases.Where(a => !string.Equals(a, subcommand.Name, StringComparison.OrdinalIgnoreCase)).ToList(); + if (aliases.Count > 0) + { + Console.WriteLine($"{Properties.Resources.usage_aliases} {string.Join(", ", aliases)}"); + Console.WriteLine(); + } + + // Arguments + if (subcommand.Arguments.Any()) + { + Console.WriteLine(Properties.Resources.usage_arguments); + foreach (var arg in subcommand.Arguments) + { + var argDescription = arg.Description ?? string.Empty; + Console.WriteLine($" <{arg.Name}>{(arg.Arity.MinimumNumberOfValues == 0 ? $" {Properties.Resources.usage_optional}" : string.Empty),-20} {argDescription}"); + } + + Console.WriteLine(); + } + + // Options + if (subcommand.Options.Any()) + { + Console.WriteLine(Properties.Resources.usage_options); + foreach (var option in subcommand.Options) + { + var optAliases = string.Join(", ", option.Aliases); + var optDescription = option.Description ?? string.Empty; + Console.WriteLine($" {optAliases,-25} {optDescription}"); + } + + Console.WriteLine(); + } + + // Command-specific examples + PrintCommandExamples(subcommand.Name); + } + + private static void PrintCommandExamples(string commandName) + { + Console.WriteLine(Properties.Resources.usage_examples); + + switch (commandName.ToLowerInvariant()) + { + case "get-monitors": + Console.WriteLine(" FancyZonesCLI get-monitors"); + Console.WriteLine(" FancyZonesCLI m"); + break; + + case "get-layouts": + Console.WriteLine(" FancyZonesCLI get-layouts"); + Console.WriteLine(" FancyZonesCLI ls"); + break; + + case "get-active-layout": + Console.WriteLine(" FancyZonesCLI get-active-layout"); + Console.WriteLine(" FancyZonesCLI active"); + break; + + case "set-layout": + Console.WriteLine(" FancyZonesCLI set-layout focus"); + Console.WriteLine(" FancyZonesCLI set-layout columns --monitor 1"); + Console.WriteLine(" FancyZonesCLI set-layout {uuid} --all"); + Console.WriteLine(" FancyZonesCLI s rows -m 2"); + break; + + case "open-editor": + Console.WriteLine(" FancyZonesCLI open-editor"); + Console.WriteLine(" FancyZonesCLI e"); + break; + + case "open-settings": + Console.WriteLine(" FancyZonesCLI open-settings"); + Console.WriteLine(" FancyZonesCLI settings"); + break; + + case "get-hotkeys": + Console.WriteLine(" FancyZonesCLI get-hotkeys"); + Console.WriteLine(" FancyZonesCLI hk"); + break; + + case "set-hotkey": + Console.WriteLine(" FancyZonesCLI set-hotkey 1 {layout-uuid}"); + Console.WriteLine(" FancyZonesCLI shk 2 0CEBCBA9-9C32-4395-B93E-DC77485AD6D0"); + break; + + case "remove-hotkey": + Console.WriteLine(" FancyZonesCLI remove-hotkey 1"); + Console.WriteLine(" FancyZonesCLI rhk 2"); + break; + + default: + Console.WriteLine($" FancyZonesCLI {commandName}"); + break; + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj b/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj new file mode 100644 index 0000000000..419c12cfd0 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj @@ -0,0 +1,50 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + + <PropertyGroup> + <AssemblyTitle>PowerToys.FancyZonesCLI</AssemblyTitle> + <AssemblyDescription>PowerToys FancyZones Command Line Interface</AssemblyDescription> + <Description>PowerToys FancyZones CLI</Description> + <OutputType>Exe</OutputType> + <Platforms>x64;ARM64</Platforms> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> + <AssemblyName>FancyZonesCLI</AssemblyName> + <NoWarn>$(NoWarn);SA1500;SA1402;CA1852;CA1863;CA1305</NoWarn> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="System.Text.Json" /> + <PackageReference Include="System.CommandLine" /> + <PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\FancyZonesEditorCommon\FancyZonesEditorCommon.csproj" /> + <ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> + </ItemGroup> + + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <DependentUpon>Resources.resx</DependentUpon> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + </Compile> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <Generator>ResXFileCodeGenerator</Generator> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + </EmbeddedResource> + </ItemGroup> + + <!-- Force using WindowsDesktop runtime to ensure consistent dll versions with other projects --> + <ItemGroup> + <FrameworkReference Include="Microsoft.WindowsDesktop.App" /> + </ItemGroup> + +</Project> diff --git a/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs new file mode 100644 index 0000000000..bf4c658119 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs @@ -0,0 +1,578 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; +using FancyZonesEditorCommon.Data; + +namespace FancyZonesCLI; + +public static class LayoutVisualizer +{ + public static string DrawTemplateLayout(LayoutTemplates.TemplateLayoutWrapper template) + { + var sb = new StringBuilder(); + sb.AppendLine(" Visual Preview:"); + + switch (template.Type.ToLowerInvariant()) + { + case "focus": + sb.Append(RenderFocusLayout(template.ZoneCount > 0 ? template.ZoneCount : 3)); + break; + case "columns": + sb.Append(RenderGridLayout(1, template.ZoneCount > 0 ? template.ZoneCount : 3)); + break; + case "rows": + sb.Append(RenderGridLayout(template.ZoneCount > 0 ? template.ZoneCount : 3, 1)); + break; + case "grid": + // Grid layout: calculate rows and columns from zone count + // Algorithm from GridLayoutModel.InitGrid() - tries to make it close to square + // with cols >= rows preference + int zoneCount = template.ZoneCount > 0 ? template.ZoneCount : 3; + int rows = 1; + while (zoneCount / rows >= rows) + { + rows++; + } + + rows--; + int cols = zoneCount / rows; + if (zoneCount % rows != 0) + { + cols++; + } + + sb.Append(RenderGridLayoutWithZoneCount(rows, cols, zoneCount)); + break; + case "priority-grid": + sb.Append(RenderPriorityGridLayout(template.ZoneCount > 0 ? template.ZoneCount : 3)); + break; + case "blank": + sb.AppendLine(" (No zones)"); + break; + default: + sb.AppendLine(CultureInfo.InvariantCulture, $" ({template.Type} layout)"); + break; + } + + return sb.ToString(); + } + + public static string DrawCustomLayout(CustomLayouts.CustomLayoutWrapper layout) + { + if (layout.Info.ValueKind == JsonValueKind.Undefined || layout.Info.ValueKind == JsonValueKind.Null) + { + return string.Empty; + } + + var sb = new StringBuilder(); + sb.AppendLine(" Visual Preview:"); + + if (layout.Type == "grid" && + layout.Info.TryGetProperty("rows", out var rows) && + layout.Info.TryGetProperty("columns", out var cols)) + { + int r = rows.GetInt32(); + int c = cols.GetInt32(); + + // Check if there's a cell-child-map (merged cells) + if (layout.Info.TryGetProperty("cell-child-map", out var cellMap)) + { + sb.Append(RenderGridLayoutWithMergedCells(r, c, cellMap)); + } + else + { + int height = r >= 4 ? 12 : 8; + sb.Append(RenderGridLayout(r, c, 30, height)); + } + } + else if (layout.Type == "canvas" && + layout.Info.TryGetProperty("zones", out var zones) && + layout.Info.TryGetProperty("ref-width", out var refWidth) && + layout.Info.TryGetProperty("ref-height", out var refHeight)) + { + sb.Append(RenderCanvasLayout(zones, refWidth.GetInt32(), refHeight.GetInt32())); + } + + return sb.ToString(); + } + + private static string RenderFocusLayout(int zoneCount = 3) + { + var sb = new StringBuilder(); + + // Focus layout: overlapping zones with cascading offset + if (zoneCount == 1) + { + sb.AppendLine(" +-------+"); + sb.AppendLine(" | |"); + sb.AppendLine(" | |"); + sb.AppendLine(" +-------+"); + } + else if (zoneCount == 2) + { + sb.AppendLine(" +-------+"); + sb.AppendLine(" | |"); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" | |"); + sb.AppendLine(" +-------+"); + } + else + { + sb.AppendLine(" +-------+"); + sb.AppendLine(" | |"); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" ..."); + sb.AppendLine(CultureInfo.InvariantCulture, $" (total: {zoneCount} zones)"); + sb.AppendLine(" ..."); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" | |"); + sb.AppendLine(" +-------+"); + } + + return sb.ToString(); + } + + private static string RenderPriorityGridLayout(int zoneCount = 3) + { + // Priority Grid has predefined layouts for zone counts 1-11 + // Data format from GridLayoutModel._priorityData + if (zoneCount >= 1 && zoneCount <= 11) + { + int[,] cellMap = GetPriorityGridCellMap(zoneCount); + return RenderGridLayoutWithCellMap(cellMap); + } + else + { + // > 11 zones: use grid layout + int rows = 1; + while (zoneCount / rows >= rows) + { + rows++; + } + + rows--; + int cols = zoneCount / rows; + if (zoneCount % rows != 0) + { + cols++; + } + + return RenderGridLayoutWithZoneCount(rows, cols, zoneCount); + } + } + + private static int[,] GetPriorityGridCellMap(int zoneCount) + { + // Parsed from Editor's _priorityData byte arrays + return zoneCount switch + { + 1 => new int[,] { { 0 } }, + 2 => new int[,] { { 0, 1 } }, + 3 => new int[,] { { 0, 1, 2 } }, + 4 => new int[,] { { 0, 1, 2 }, { 0, 1, 3 } }, + 5 => new int[,] { { 0, 1, 2 }, { 3, 1, 4 } }, + 6 => new int[,] { { 0, 1, 2 }, { 0, 1, 3 }, { 4, 1, 5 } }, + 7 => new int[,] { { 0, 1, 2 }, { 3, 1, 4 }, { 5, 1, 6 } }, + 8 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 2, 5 }, { 6, 1, 2, 7 } }, + 9 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 2, 5 }, { 6, 1, 7, 8 } }, + 10 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 5, 6 }, { 7, 1, 8, 9 } }, + 11 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 5, 6 }, { 7, 8, 9, 10 } }, + _ => new int[,] { { 0 } }, + }; + } + + private static string RenderGridLayoutWithCellMap(int[,] cellMap, int width = 30, int height = 8) + { + var sb = new StringBuilder(); + int rows = cellMap.GetLength(0); + int cols = cellMap.GetLength(1); + + int cellWidth = width / cols; + int cellHeight = height / rows; + + for (int r = 0; r < rows; r++) + { + // Top border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + bool mergeTop = r > 0 && cellMap[r, c] == cellMap[r - 1, c]; + bool mergeLeft = c > 0 && cellMap[r, c] == cellMap[r, c - 1]; + + if (mergeTop) + { + sb.Append(mergeLeft ? new string(' ', cellWidth) : new string(' ', cellWidth - 1) + "+"); + } + else + { + sb.Append(mergeLeft ? new string('-', cellWidth) : new string('-', cellWidth - 1) + "+"); + } + } + + sb.AppendLine(); + + // Cell content + for (int h = 0; h < cellHeight - 1; h++) + { + sb.Append(" "); + for (int c = 0; c < cols; c++) + { + bool mergeLeft = c > 0 && cellMap[r, c] == cellMap[r, c - 1]; + sb.Append(mergeLeft ? ' ' : '|'); + sb.Append(' ', cellWidth - 1); + } + + sb.AppendLine("|"); + } + } + + // Bottom border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + return sb.ToString(); + } + + private static string RenderGridLayoutWithMergedCells(int rows, int cols, JsonElement cellMap) + { + var sb = new StringBuilder(); + const int displayWidth = 39; + const int displayHeight = 12; + + // Build zone map from cell-child-map + int[,] zoneMap = new int[rows, cols]; + for (int r = 0; r < rows; r++) + { + var rowArray = cellMap[r]; + for (int c = 0; c < cols; c++) + { + zoneMap[r, c] = rowArray[c].GetInt32(); + } + } + + int cellHeight = displayHeight / rows; + int cellWidth = displayWidth / cols; + + // Draw top border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + // Draw rows + for (int r = 0; r < rows; r++) + { + for (int h = 0; h < cellHeight; h++) + { + sb.Append(" |"); + + for (int c = 0; c < cols; c++) + { + int currentZone = zoneMap[r, c]; + int leftZone = c > 0 ? zoneMap[r, c - 1] : -1; + bool needLeftBorder = c > 0 && currentZone != leftZone; + + bool zoneHasTopBorder = r > 0 && h == 0 && currentZone != zoneMap[r - 1, c]; + + if (needLeftBorder) + { + sb.Append('|'); + sb.Append(zoneHasTopBorder ? '-' : ' ', cellWidth - 1); + } + else + { + sb.Append(zoneHasTopBorder ? '-' : ' ', cellWidth); + } + } + + sb.AppendLine("|"); + } + } + + // Draw bottom border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + return sb.ToString(); + } + + public static string RenderGridLayoutWithZoneCount(int rows, int cols, int zoneCount, int width = 30, int height = 8) + { + var sb = new StringBuilder(); + + // Build zone map like Editor's InitGrid + int[,] zoneMap = new int[rows, cols]; + int index = 0; + for (int r = 0; r < rows; r++) + { + for (int c = 0; c < cols; c++) + { + zoneMap[r, c] = index++; + if (index == zoneCount) + { + index--; // Remaining cells use the last zone index + } + } + } + + int cellWidth = width / cols; + int cellHeight = height / rows; + + for (int r = 0; r < rows; r++) + { + // Top border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + bool mergeLeft = c > 0 && zoneMap[r, c] == zoneMap[r, c - 1]; + sb.Append('-', mergeLeft ? cellWidth : cellWidth - 1); + if (!mergeLeft) + { + sb.Append('+'); + } + } + + sb.AppendLine(); + + // Cell content + for (int h = 0; h < cellHeight - 1; h++) + { + sb.Append(" "); + for (int c = 0; c < cols; c++) + { + bool mergeLeft = c > 0 && zoneMap[r, c] == zoneMap[r, c - 1]; + sb.Append(mergeLeft ? ' ' : '|'); + sb.Append(' ', cellWidth - 1); + } + + sb.AppendLine("|"); + } + } + + // Bottom border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + return sb.ToString(); + } + + public static string RenderGridLayout(int rows, int cols, int width = 30, int height = 8) + { + var sb = new StringBuilder(); + int cellWidth = width / cols; + int cellHeight = height / rows; + + for (int r = 0; r < rows; r++) + { + // Top border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + + // Cell content + for (int h = 0; h < cellHeight - 1; h++) + { + sb.Append(" "); + for (int c = 0; c < cols; c++) + { + sb.Append('|'); + sb.Append(' ', cellWidth - 1); + } + + sb.AppendLine("|"); + } + } + + // Bottom border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + return sb.ToString(); + } + + private static string RenderCanvasLayout(JsonElement zones, int refWidth, int refHeight) + { + var sb = new StringBuilder(); + const int displayWidth = 49; + const int displayHeight = 15; + + if (refWidth <= 0 || refHeight <= 0) + { + return string.Empty; + } + + // Create a 2D array to track which zones occupy each position + var zoneGrid = new List<int>[displayHeight, displayWidth]; + for (int i = 0; i < displayHeight; i++) + { + for (int j = 0; j < displayWidth; j++) + { + zoneGrid[i, j] = new List<int>(); + } + } + + // Map each zone to the grid + int zoneId = 0; + var zoneList = new List<(int X, int Y, int Width, int Height, int Id)>(); + + foreach (var zone in zones.EnumerateArray()) + { + if (!TryGetInt32Property(zone, "x", "X", out int x) || + !TryGetInt32Property(zone, "y", "Y", out int y) || + !TryGetInt32Property(zone, "width", "Width", out int w) || + !TryGetInt32Property(zone, "height", "Height", out int h)) + { + continue; + } + + int dx = Math.Max(0, Math.Min(displayWidth - 1, x * displayWidth / refWidth)); + int dy = Math.Max(0, Math.Min(displayHeight - 1, y * displayHeight / refHeight)); + int dw = Math.Max(3, w * displayWidth / refWidth); + int dh = Math.Max(2, h * displayHeight / refHeight); + + if (dx + dw > displayWidth) + { + dw = displayWidth - dx; + } + + if (dy + dh > displayHeight) + { + dh = displayHeight - dy; + } + + zoneList.Add((dx, dy, dw, dh, zoneId)); + + for (int r = dy; r < dy + dh && r < displayHeight; r++) + { + for (int c = dx; c < dx + dw && c < displayWidth; c++) + { + zoneGrid[r, c].Add(zoneId); + } + } + + zoneId++; + } + + // Draw top border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + // Draw each row + char[] shades = { '.', ':', '░', '▒', '▓', '█', '◆', '●', '■', '▪' }; + + for (int r = 0; r < displayHeight; r++) + { + sb.Append(" |"); + for (int c = 0; c < displayWidth; c++) + { + var zonesHere = zoneGrid[r, c]; + + if (zonesHere.Count == 0) + { + sb.Append(' '); + } + else + { + int topZone = zonesHere[zonesHere.Count - 1]; + var rect = zoneList[topZone]; + + bool isTopEdge = r == rect.Y; + bool isBottomEdge = r == rect.Y + rect.Height - 1; + bool isLeftEdge = c == rect.X; + bool isRightEdge = c == rect.X + rect.Width - 1; + + if ((isTopEdge || isBottomEdge) && (isLeftEdge || isRightEdge)) + { + sb.Append('+'); + } + else if (isTopEdge || isBottomEdge) + { + sb.Append('-'); + } + else if (isLeftEdge || isRightEdge) + { + sb.Append('|'); + } + else + { + sb.Append(shades[topZone % shades.Length]); + } + } + } + + sb.AppendLine("|"); + } + + // Draw bottom border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + // Draw legend + sb.AppendLine(); + sb.Append(" Legend: "); + for (int i = 0; i < Math.Min(zoneId, shades.Length); i++) + { + if (i > 0) + { + sb.Append(", "); + } + + sb.Append(CultureInfo.InvariantCulture, $"Zone {i} = {shades[i]}"); + } + + sb.AppendLine(); + return sb.ToString(); + } + + private static bool TryGetInt32Property(JsonElement element, string primaryName, string fallbackName, out int value) + { + if (element.TryGetProperty(primaryName, out var property) || element.TryGetProperty(fallbackName, out property)) + { + if (property.ValueKind == JsonValueKind.Number) + { + return property.TryGetInt32(out value); + } + + if (property.ValueKind == JsonValueKind.String) + { + return int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + } + + value = default; + return false; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Logger.cs b/src/modules/fancyzones/FancyZonesCLI/Logger.cs new file mode 100644 index 0000000000..3f62abf7eb --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Logger.cs @@ -0,0 +1,126 @@ +// 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.Globalization; +using System.IO; +using System.Runtime.CompilerServices; + +namespace FancyZonesCLI; + +/// <summary> +/// Simple logger for FancyZones CLI. +/// Logs to %LOCALAPPDATA%\Microsoft\PowerToys\FancyZones\CLI\Logs +/// </summary> +internal static class Logger +{ + private static readonly object LockObj = new(); + private static string _logFilePath = string.Empty; + private static bool _isInitialized; + + /// <summary> + /// Gets the path to the current log file. + /// </summary> + public static string LogFilePath => _logFilePath; + + /// <summary> + /// Initializes the logger. + /// </summary> + public static void InitializeLogger() + { + if (_isInitialized) + { + return; + } + + try + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var logDirectory = Path.Combine(localAppData, "Microsoft", "PowerToys", "FancyZones", "CLI", "Logs"); + + if (!Directory.Exists(logDirectory)) + { + Directory.CreateDirectory(logDirectory); + } + + var logFileName = $"FancyZonesCLI_{DateTime.Now:yyyy-MM-dd}.log"; + _logFilePath = Path.Combine(logDirectory, logFileName); + _isInitialized = true; + + LogInfo("FancyZones CLI started"); + } + catch + { + // Silently fail if logging cannot be initialized + } + } + + /// <summary> + /// Logs an error message. + /// </summary> + public static void LogError(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("ERROR", message, memberName, sourceFilePath, sourceLineNumber); + } + + /// <summary> + /// Logs an error message with exception details. + /// </summary> + public static void LogError(string message, Exception ex, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + var fullMessage = ex == null + ? message + : $"{message} | Exception: {ex.GetType().Name}: {ex.Message}"; + Log("ERROR", fullMessage, memberName, sourceFilePath, sourceLineNumber); + } + + /// <summary> + /// Logs a warning message. + /// </summary> + public static void LogWarning(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("WARN", message, memberName, sourceFilePath, sourceLineNumber); + } + + /// <summary> + /// Logs an informational message. + /// </summary> + public static void LogInfo(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("INFO", message, memberName, sourceFilePath, sourceLineNumber); + } + + /// <summary> + /// Logs a debug message (only in DEBUG builds). + /// </summary> + [System.Diagnostics.Conditional("DEBUG")] + public static void LogDebug(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("DEBUG", message, memberName, sourceFilePath, sourceLineNumber); + } + + private static void Log(string level, string message, string memberName, string sourceFilePath, int sourceLineNumber) + { + if (!_isInitialized || string.IsNullOrEmpty(_logFilePath)) + { + return; + } + + try + { + var fileName = Path.GetFileName(sourceFilePath); + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + var logEntry = $"[{timestamp}] [{level}] [{fileName}:{sourceLineNumber}] [{memberName}] {message}{Environment.NewLine}"; + + lock (LockObj) + { + File.AppendAllText(_logFilePath, logEntry); + } + } + catch + { + // Silently fail if logging fails + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs new file mode 100644 index 0000000000..4d3f14db0c --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.Win32; +using Windows.Win32.Foundation; + +namespace FancyZonesCLI; + +/// <summary> +/// Native Windows API methods for FancyZones CLI. +/// </summary> +internal static class NativeMethods +{ + // Registered Windows messages for notifying FancyZones + private static uint wmPrivAppliedLayoutsFileUpdate; + private static uint wmPrivLayoutHotkeysFileUpdate; + private static uint wmPrivSaveEditorParameters; + + /// <summary> + /// Gets the Windows message ID for applied layouts file update notification. + /// </summary> + public static uint WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE => wmPrivAppliedLayoutsFileUpdate; + + /// <summary> + /// Gets the Windows message ID for layout hotkeys file update notification. + /// </summary> + public static uint WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE => wmPrivLayoutHotkeysFileUpdate; + + /// <summary> + /// Gets the Windows message ID used to request saving editor-parameters.json. + /// </summary> + public static uint WM_PRIV_SAVE_EDITOR_PARAMETERS => wmPrivSaveEditorParameters; + + /// <summary> + /// Initializes the Windows messages used for FancyZones notifications. + /// </summary> + public static void InitializeWindowMessages() + { + wmPrivAppliedLayoutsFileUpdate = PInvoke.RegisterWindowMessage("{2ef2c8a7-e0d5-4f31-9ede-52aade2d284d}"); + wmPrivLayoutHotkeysFileUpdate = PInvoke.RegisterWindowMessage("{07229b7e-4f22-4357-b136-33c289be2295}"); + wmPrivSaveEditorParameters = PInvoke.RegisterWindowMessage("{d8f9c0e3-5d77-4e83-8a4f-7c704c2bfb4a}"); + } + + /// <summary> + /// Broadcasts a notification message to FancyZones. + /// </summary> + /// <param name="message">The Windows message ID to broadcast.</param> + public static void NotifyFancyZones(uint message) + { + PInvoke.PostMessage(HWND.HWND_BROADCAST, message, 0, 0); + } + + /// <summary> + /// Brings the specified window to the foreground. + /// </summary> + /// <param name="hWnd">A handle to the window.</param> + /// <returns>True if the window was brought to the foreground.</returns> + public static bool SetForegroundWindow(nint hWnd) + { + return PInvoke.SetForegroundWindow(new HWND(hWnd)); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json new file mode 100644 index 0000000000..89cee38a92 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "emitSingleFile": true, + "allowMarshaling": false +} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt new file mode 100644 index 0000000000..e3555c2333 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt @@ -0,0 +1,4 @@ +PostMessage +SetForegroundWindow +RegisterWindowMessage +HWND_BROADCAST diff --git a/src/modules/fancyzones/FancyZonesCLI/Program.cs b/src/modules/fancyzones/FancyZonesCLI/Program.cs new file mode 100644 index 0000000000..680162b9c0 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Program.cs @@ -0,0 +1,116 @@ +// 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.CommandLine; +using System.Linq; +using System.Threading.Tasks; +using FancyZonesCLI.CommandLine; + +namespace FancyZonesCLI; + +internal sealed class Program +{ + private static readonly string[] HelpFlags = ["--help", "-h", "-?"]; + + private static async Task<int> Main(string[] args) + { + Logger.InitializeLogger(); + Logger.LogInfo($"CLI invoked with args: [{string.Join(", ", args)}]"); + + // Initialize Windows messages used to notify FancyZones. + NativeMethods.InitializeWindowMessages(); + + // Intercept help requests early and print custom usage. + if (TryHandleHelpRequest(args)) + { + return 0; + } + + // Detect PowerShell script block expansion (when {} is interpreted as script block) + if (DetectPowerShellScriptBlockArgs(args)) + { + return 1; + } + + RootCommand rootCommand = FancyZonesCliCommandFactory.CreateRootCommand(); + int exitCode = await rootCommand.InvokeAsync(args); + + if (exitCode == 0) + { + Logger.LogInfo("Command completed successfully"); + } + else + { + Logger.LogWarning($"Command failed with exit code {exitCode}"); + } + + return exitCode; + } + + /// <summary> + /// Handles help requests for root command and subcommands. + /// </summary> + /// <returns>True if help was printed, false otherwise.</returns> + private static bool TryHandleHelpRequest(string[] args) + { + bool hasHelpFlag = args.Any(a => HelpFlags.Any(h => string.Equals(a, h, StringComparison.OrdinalIgnoreCase))); + if (!hasHelpFlag) + { + return false; + } + + // Get non-help arguments to identify subcommand + var nonHelpArgs = args.Where(a => !HelpFlags.Any(h => string.Equals(a, h, StringComparison.OrdinalIgnoreCase))).ToArray(); + + if (nonHelpArgs.Length == 0) + { + // Root help: fancyzones cli --help + FancyZonesCliUsage.PrintUsage(); + } + else + { + // Subcommand help: fancyzones cli <command> --help + string subcommandName = nonHelpArgs[0]; + FancyZonesCliUsage.PrintCommandUsage(subcommandName); + } + + return true; + } + + /// <summary> + /// Detects when PowerShell interprets {GUID} as a script block and converts it to encoded command args. + /// This happens when users forget to quote GUIDs with braces in PowerShell. + /// </summary> + /// <returns>True if PowerShell script block args were detected, false otherwise.</returns> + private static bool DetectPowerShellScriptBlockArgs(string[] args) + { + // PowerShell converts {scriptblock} to: -encodedCommand <base64> -inputFormat xml -outputFormat text + bool hasEncodedCommand = args.Any(a => string.Equals(a, "-encodedCommand", StringComparison.OrdinalIgnoreCase)); + bool hasInputFormat = args.Any(a => string.Equals(a, "-inputFormat", StringComparison.OrdinalIgnoreCase)); + bool hasOutputFormat = args.Any(a => string.Equals(a, "-outputFormat", StringComparison.OrdinalIgnoreCase)); + + if (hasEncodedCommand || (hasInputFormat && hasOutputFormat)) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(Properties.Resources.error_powershell_scriptblock_title); + Console.ResetColor(); + Console.WriteLine(); + Console.WriteLine(Properties.Resources.error_powershell_scriptblock_explanation); + Console.WriteLine(Properties.Resources.error_powershell_scriptblock_hint); + Console.WriteLine(); + Console.WriteLine($" {Properties.Resources.error_powershell_scriptblock_option1}"); + Console.WriteLine($" {Properties.Resources.error_powershell_scriptblock_option1_example}"); + Console.WriteLine(); + Console.WriteLine($" {Properties.Resources.error_powershell_scriptblock_option2}"); + Console.WriteLine($" {Properties.Resources.error_powershell_scriptblock_option2_example}"); + Console.WriteLine(); + + Logger.LogWarning("PowerShell script block expansion detected - user needs to quote GUID or omit braces"); + return true; + } + + return false; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Properties/Resources.Designer.cs b/src/modules/fancyzones/FancyZonesCLI/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..256bb4f674 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Properties/Resources.Designer.cs @@ -0,0 +1,461 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// 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. +// </auto-generated> +//------------------------------------------------------------------------------ + +namespace FancyZonesCLI.Properties { + using System; + + + /// <summary> + /// A strongly-typed resource class, for looking up localized strings, etc. + /// </summary> + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.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() { + } + + /// <summary> + /// Returns the cached ResourceManager instance used by this class. + /// </summary> + [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("FancyZonesCLI.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// <summary> + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string error_fancyzones_not_running { + get { + return ResourceManager.GetString("error_fancyzones_not_running", resourceCulture); + } + } + + internal static string cmd_get_active_layout { + get { + return ResourceManager.GetString("cmd_get_active_layout", resourceCulture); + } + } + + internal static string get_active_layout_no_monitor_info { + get { + return ResourceManager.GetString("get_active_layout_no_monitor_info", resourceCulture); + } + } + + internal static string get_active_layout_no_layouts { + get { + return ResourceManager.GetString("get_active_layout_no_layouts", resourceCulture); + } + } + + internal static string get_active_layout_header { + get { + return ResourceManager.GetString("get_active_layout_header", resourceCulture); + } + } + + internal static string get_active_layout_no_layout { + get { + return ResourceManager.GetString("get_active_layout_no_layout", resourceCulture); + } + } + + internal static string cmd_get_layouts { + get { + return ResourceManager.GetString("cmd_get_layouts", resourceCulture); + } + } + + internal static string get_layouts_templates_header { + get { + return ResourceManager.GetString("get_layouts_templates_header", resourceCulture); + } + } + + internal static string get_layouts_custom_header { + get { + return ResourceManager.GetString("get_layouts_custom_header", resourceCulture); + } + } + + internal static string get_layouts_canvas_note { + get { + return ResourceManager.GetString("get_layouts_canvas_note", resourceCulture); + } + } + + internal static string get_layouts_canvas_detail { + get { + return ResourceManager.GetString("get_layouts_canvas_detail", resourceCulture); + } + } + + internal static string get_layouts_usage { + get { + return ResourceManager.GetString("get_layouts_usage", resourceCulture); + } + } + + internal static string cmd_get_monitors { + get { + return ResourceManager.GetString("cmd_get_monitors", resourceCulture); + } + } + + internal static string get_monitors_error { + get { + return ResourceManager.GetString("get_monitors_error", resourceCulture); + } + } + + internal static string get_monitors_no_monitors { + get { + return ResourceManager.GetString("get_monitors_no_monitors", resourceCulture); + } + } + + internal static string get_monitors_header { + get { + return ResourceManager.GetString("get_monitors_header", resourceCulture); + } + } + + internal static string cmd_set_layout { + get { + return ResourceManager.GetString("cmd_set_layout", resourceCulture); + } + } + + internal static string set_layout_arg_layout { + get { + return ResourceManager.GetString("set_layout_arg_layout", resourceCulture); + } + } + + internal static string set_layout_opt_monitor { + get { + return ResourceManager.GetString("set_layout_opt_monitor", resourceCulture); + } + } + + internal static string set_layout_opt_all { + get { + return ResourceManager.GetString("set_layout_opt_all", resourceCulture); + } + } + + internal static string set_layout_error_monitor_index { + get { + return ResourceManager.GetString("set_layout_error_monitor_index", resourceCulture); + } + } + + internal static string set_layout_error_both_options { + get { + return ResourceManager.GetString("set_layout_error_both_options", resourceCulture); + } + } + + internal static string set_layout_error_not_found { + get { + return ResourceManager.GetString("set_layout_error_not_found", resourceCulture); + } + } + + internal static string set_layout_error_monitor_not_found { + get { + return ResourceManager.GetString("set_layout_error_monitor_not_found", resourceCulture); + } + } + + internal static string set_layout_error_no_monitors { + get { + return ResourceManager.GetString("set_layout_error_no_monitors", resourceCulture); + } + } + + internal static string set_layout_error_unsupported_type { + get { + return ResourceManager.GetString("set_layout_error_unsupported_type", resourceCulture); + } + } + + internal static string set_layout_error_no_layout { + get { + return ResourceManager.GetString("set_layout_error_no_layout", resourceCulture); + } + } + + internal static string set_layout_success_all { + get { + return ResourceManager.GetString("set_layout_success_all", resourceCulture); + } + } + + internal static string set_layout_success_monitor { + get { + return ResourceManager.GetString("set_layout_success_monitor", resourceCulture); + } + } + + internal static string set_layout_success_default { + get { + return ResourceManager.GetString("set_layout_success_default", resourceCulture); + } + } + + internal static string cmd_open_editor { + get { + return ResourceManager.GetString("cmd_open_editor", resourceCulture); + } + } + + internal static string open_editor_error { + get { + return ResourceManager.GetString("open_editor_error", resourceCulture); + } + } + + internal static string cmd_open_settings { + get { + return ResourceManager.GetString("cmd_open_settings", resourceCulture); + } + } + + internal static string open_settings_error_not_started { + get { + return ResourceManager.GetString("open_settings_error_not_started", resourceCulture); + } + } + + internal static string open_settings_error { + get { + return ResourceManager.GetString("open_settings_error", resourceCulture); + } + } + + internal static string cmd_set_hotkey { + get { + return ResourceManager.GetString("cmd_set_hotkey", resourceCulture); + } + } + + internal static string set_hotkey_arg_key { + get { + return ResourceManager.GetString("set_hotkey_arg_key", resourceCulture); + } + } + + internal static string set_hotkey_arg_layout { + get { + return ResourceManager.GetString("set_hotkey_arg_layout", resourceCulture); + } + } + + internal static string set_hotkey_error_invalid_key { + get { + return ResourceManager.GetString("set_hotkey_error_invalid_key", resourceCulture); + } + } + + internal static string set_hotkey_error_not_custom { + get { + return ResourceManager.GetString("set_hotkey_error_not_custom", resourceCulture); + } + } + + internal static string cmd_remove_hotkey { + get { + return ResourceManager.GetString("cmd_remove_hotkey", resourceCulture); + } + } + + internal static string remove_hotkey_arg_key { + get { + return ResourceManager.GetString("remove_hotkey_arg_key", resourceCulture); + } + } + + internal static string remove_hotkey_no_hotkeys { + get { + return ResourceManager.GetString("remove_hotkey_no_hotkeys", resourceCulture); + } + } + + internal static string remove_hotkey_not_found { + get { + return ResourceManager.GetString("remove_hotkey_not_found", resourceCulture); + } + } + + internal static string cmd_get_hotkeys { + get { + return ResourceManager.GetString("cmd_get_hotkeys", resourceCulture); + } + } + + internal static string get_hotkeys_no_hotkeys { + get { + return ResourceManager.GetString("get_hotkeys_no_hotkeys", resourceCulture); + } + } + + internal static string get_hotkeys_header { + get { + return ResourceManager.GetString("get_hotkeys_header", resourceCulture); + } + } + + internal static string get_hotkeys_instruction { + get { + return ResourceManager.GetString("get_hotkeys_instruction", resourceCulture); + } + } + + internal static string editor_params_timeout { + get { + return ResourceManager.GetString("editor_params_timeout", resourceCulture); + } + } + + internal static string error_powershell_scriptblock_title { + get { + return ResourceManager.GetString("error_powershell_scriptblock_title", resourceCulture); + } + } + + internal static string error_powershell_scriptblock_explanation { + get { + return ResourceManager.GetString("error_powershell_scriptblock_explanation", resourceCulture); + } + } + + internal static string error_powershell_scriptblock_hint { + get { + return ResourceManager.GetString("error_powershell_scriptblock_hint", resourceCulture); + } + } + + internal static string error_powershell_scriptblock_option1 { + get { + return ResourceManager.GetString("error_powershell_scriptblock_option1", resourceCulture); + } + } + + internal static string error_powershell_scriptblock_option1_example { + get { + return ResourceManager.GetString("error_powershell_scriptblock_option1_example", resourceCulture); + } + } + + internal static string error_powershell_scriptblock_option2 { + get { + return ResourceManager.GetString("error_powershell_scriptblock_option2", resourceCulture); + } + } + + internal static string error_powershell_scriptblock_option2_example { + get { + return ResourceManager.GetString("error_powershell_scriptblock_option2_example", resourceCulture); + } + } + + internal static string usage_title { + get { + return ResourceManager.GetString("usage_title", resourceCulture); + } + } + + internal static string usage_syntax { + get { + return ResourceManager.GetString("usage_syntax", resourceCulture); + } + } + + internal static string usage_options { + get { + return ResourceManager.GetString("usage_options", resourceCulture); + } + } + + internal static string usage_commands { + get { + return ResourceManager.GetString("usage_commands", resourceCulture); + } + } + + internal static string usage_examples { + get { + return ResourceManager.GetString("usage_examples", resourceCulture); + } + } + + internal static string usage_arguments { + get { + return ResourceManager.GetString("usage_arguments", resourceCulture); + } + } + + internal static string usage_aliases { + get { + return ResourceManager.GetString("usage_aliases", resourceCulture); + } + } + + internal static string usage_command { + get { + return ResourceManager.GetString("usage_command", resourceCulture); + } + } + + internal static string usage_optional { + get { + return ResourceManager.GetString("usage_optional", resourceCulture); + } + } + + internal static string usage_unknown_command { + get { + return ResourceManager.GetString("usage_unknown_command", resourceCulture); + } + } + + internal static string usage_run_help { + get { + return ResourceManager.GetString("usage_run_help", resourceCulture); + } + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Properties/Resources.resx b/src/modules/fancyzones/FancyZonesCLI/Properties/Resources.resx new file mode 100644 index 0000000000..d4682908ea --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Properties/Resources.resx @@ -0,0 +1,291 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + + <!-- Base Command --> + <data name="error_fancyzones_not_running" xml:space="preserve"> + <value>Error: FancyZones is not running. Start PowerToys (FancyZones) and retry.</value> + </data> + + <!-- GetActiveLayoutCommand --> + <data name="cmd_get_active_layout" xml:space="preserve"> + <value>Show currently active layout</value> + </data> + <data name="get_active_layout_no_monitor_info" xml:space="preserve"> + <value>Could not get current monitor information.</value> + </data> + <data name="get_active_layout_no_layouts" xml:space="preserve"> + <value>No layouts configured.</value> + </data> + <data name="get_active_layout_header" xml:space="preserve"> + <value>=== Active FancyZones Layout(s) ===</value> + </data> + <data name="get_active_layout_no_layout" xml:space="preserve"> + <value> No layout applied</value> + </data> + + <!-- GetLayoutsCommand --> + <data name="cmd_get_layouts" xml:space="preserve"> + <value>List available layouts</value> + </data> + <data name="get_layouts_templates_header" xml:space="preserve"> + <value>=== Built-in Template Layouts ({0} total) ===</value> + </data> + <data name="get_layouts_custom_header" xml:space="preserve"> + <value>=== Custom Layouts ({0} total) ===</value> + </data> + <data name="get_layouts_canvas_note" xml:space="preserve"> + <value>Note: Canvas layout preview is approximate.</value> + </data> + <data name="get_layouts_canvas_detail" xml:space="preserve"> + <value>Open FancyZones Editor for precise zone boundaries.</value> + </data> + <data name="get_layouts_usage" xml:space="preserve"> + <value>Use 'FancyZonesCLI.exe set-layout <UUID>' to apply a layout.</value> + </data> + + <!-- GetMonitorsCommand --> + <data name="cmd_get_monitors" xml:space="preserve"> + <value>List monitors and FancyZones metadata</value> + </data> + <data name="get_monitors_error" xml:space="preserve"> + <value>Failed to read monitor information. {0} +Note: Ensure FancyZones is running to get current monitor information.</value> + </data> + <data name="get_monitors_no_monitors" xml:space="preserve"> + <value>No monitors found.</value> + </data> + <data name="get_monitors_header" xml:space="preserve"> + <value>=== Monitors ({0} total) ===</value> + </data> + + <!-- SetLayoutCommand --> + <data name="cmd_set_layout" xml:space="preserve"> + <value>Set layout by UUID or template name</value> + </data> + <data name="set_layout_arg_layout" xml:space="preserve"> + <value>Layout UUID or template type (e.g. focus, columns)</value> + </data> + <data name="set_layout_opt_monitor" xml:space="preserve"> + <value>Apply to monitor N (1-based)</value> + </data> + <data name="set_layout_opt_all" xml:space="preserve"> + <value>Apply to all monitors</value> + </data> + <data name="set_layout_error_monitor_index" xml:space="preserve"> + <value>Monitor index must be >= 1.</value> + </data> + <data name="set_layout_error_both_options" xml:space="preserve"> + <value>Cannot specify both --monitor and --all.</value> + </data> + <data name="set_layout_error_not_found" xml:space="preserve"> + <value>Layout '{0}' not found +Tip: For templates, use the type name (e.g., 'focus', 'columns', 'rows', 'grid', 'priority-grid') + For custom layouts, use the UUID from 'get-layouts'</value> + </data> + <data name="set_layout_error_monitor_not_found" xml:space="preserve"> + <value>Monitor {0} not found. Available monitors: 1-{1}</value> + </data> + <data name="set_layout_error_no_monitors" xml:space="preserve"> + <value>Internal error - no monitors to update.</value> + </data> + <data name="set_layout_error_unsupported_type" xml:space="preserve"> + <value>Unsupported custom layout type '{0}'.</value> + </data> + <data name="set_layout_error_no_layout" xml:space="preserve"> + <value>Internal error - no layout selected.</value> + </data> + <data name="set_layout_success_all" xml:space="preserve"> + <value>Layout '{0}' applied to all monitors.</value> + </data> + <data name="set_layout_success_monitor" xml:space="preserve"> + <value>Layout '{0}' applied to monitor {1}.</value> + </data> + <data name="set_layout_success_default" xml:space="preserve"> + <value>Layout '{0}' applied to monitor 1.</value> + </data> + + <!-- OpenEditorCommand --> + <data name="cmd_open_editor" xml:space="preserve"> + <value>Launch FancyZones layout editor</value> + </data> + <data name="open_editor_error" xml:space="preserve"> + <value>Failed to request FancyZones Editor launch. {0}</value> + </data> + + <!-- OpenSettingsCommand --> + <data name="cmd_open_settings" xml:space="preserve"> + <value>Open FancyZones settings page</value> + </data> + <data name="open_settings_error_not_started" xml:space="preserve"> + <value>PowerToys.exe failed to start.</value> + </data> + <data name="open_settings_error" xml:space="preserve"> + <value>Failed to open FancyZones Settings. {0}</value> + </data> + + <!-- SetHotkeyCommand --> + <data name="cmd_set_hotkey" xml:space="preserve"> + <value>Assign hotkey (0-9) to a custom layout</value> + </data> + <data name="set_hotkey_arg_key" xml:space="preserve"> + <value>Hotkey index (0-9)</value> + </data> + <data name="set_hotkey_arg_layout" xml:space="preserve"> + <value>Custom layout UUID</value> + </data> + <data name="set_hotkey_error_invalid_key" xml:space="preserve"> + <value>Key must be between 0 and 9.</value> + </data> + <data name="set_hotkey_error_not_custom" xml:space="preserve"> + <value>Layout '{0}' is not a custom layout UUID.</value> + </data> + + <!-- RemoveHotkeyCommand --> + <data name="cmd_remove_hotkey" xml:space="preserve"> + <value>Remove hotkey assignment</value> + </data> + <data name="remove_hotkey_arg_key" xml:space="preserve"> + <value>Hotkey index (0-9)</value> + </data> + <data name="remove_hotkey_no_hotkeys" xml:space="preserve"> + <value>No hotkeys configured.</value> + </data> + <data name="remove_hotkey_not_found" xml:space="preserve"> + <value>No hotkey assigned to key {0}</value> + </data> + + <!-- GetHotkeysCommand --> + <data name="cmd_get_hotkeys" xml:space="preserve"> + <value>List all layout hotkeys</value> + </data> + <data name="get_hotkeys_no_hotkeys" xml:space="preserve"> + <value>No hotkeys configured.</value> + </data> + <data name="get_hotkeys_header" xml:space="preserve"> + <value>=== Layout Hotkeys ===</value> + </data> + <data name="get_hotkeys_instruction" xml:space="preserve"> + <value>Press Win + Ctrl + Alt + <number> to switch layouts:</value> + </data> + + <!-- EditorParametersRefresh --> + <data name="editor_params_timeout" xml:space="preserve"> + <value>Could not get current monitor information (timed out after {0}ms waiting for '{1}').</value> + </data> + + <!-- PowerShell Script Block Detection --> + <data name="error_powershell_scriptblock_title" xml:space="preserve"> + <value>Error: Invalid GUID format detected.</value> + </data> + <data name="error_powershell_scriptblock_explanation" xml:space="preserve"> + <value>PowerShell interprets curly braces {} as script blocks.</value> + </data> + <data name="error_powershell_scriptblock_hint" xml:space="preserve"> + <value>Please quote your GUID or use it without braces:</value> + </data> + <data name="error_powershell_scriptblock_option1" xml:space="preserve"> + <value>Option 1 - Quote the GUID:</value> + </data> + <data name="error_powershell_scriptblock_option1_example" xml:space="preserve"> + <value>FancyZonesCLI shk 1 '{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}'</value> + </data> + <data name="error_powershell_scriptblock_option2" xml:space="preserve"> + <value>Option 2 - Omit the braces (recommended):</value> + </data> + <data name="error_powershell_scriptblock_option2_example" xml:space="preserve"> + <value>FancyZonesCLI shk 1 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</value> + </data> + + <!-- CLI Usage --> + <data name="usage_title" xml:space="preserve"> + <value>FancyZones CLI - Command line interface for FancyZones</value> + </data> + <data name="usage_syntax" xml:space="preserve"> + <value>Usage: FancyZonesCLI [command] [options]</value> + </data> + <data name="usage_options" xml:space="preserve"> + <value>Options:</value> + </data> + <data name="usage_commands" xml:space="preserve"> + <value>Commands:</value> + </data> + <data name="usage_examples" xml:space="preserve"> + <value>Examples:</value> + </data> + <data name="usage_arguments" xml:space="preserve"> + <value>Arguments:</value> + </data> + <data name="usage_aliases" xml:space="preserve"> + <value>Aliases:</value> + </data> + <data name="usage_command" xml:space="preserve"> + <value>Command:</value> + </data> + <data name="usage_optional" xml:space="preserve"> + <value>(optional)</value> + </data> + <data name="usage_unknown_command" xml:space="preserve"> + <value>Unknown command: {0}</value> + </data> + <data name="usage_run_help" xml:space="preserve"> + <value>Run 'FancyZonesCLI --help' to see available commands.</value> + </data> +</root> diff --git a/src/modules/fancyzones/FancyZonesCLI/Telemetry/FancyZonesCLICommandEvent.cs b/src/modules/fancyzones/FancyZonesCLI/Telemetry/FancyZonesCLICommandEvent.cs new file mode 100644 index 0000000000..05751b5f43 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Telemetry/FancyZonesCLICommandEvent.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.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace FancyZonesCLI.Telemetry +{ + /// <summary> + /// Telemetry event for FancyZones CLI command execution. + /// </summary> + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class FancyZonesCLICommandEvent : EventBase, IEvent + { + public FancyZonesCLICommandEvent() + { + EventName = "FancyZones_CLICommand"; + } + + /// <summary> + /// Gets or sets the name of the CLI command that was executed. + /// </summary> + public string CommandName { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the command executed successfully. + /// </summary> + public bool Successful { get; set; } + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Utils/AppliedLayoutsHelper.cs b/src/modules/fancyzones/FancyZonesCLI/Utils/AppliedLayoutsHelper.cs new file mode 100644 index 0000000000..f403ee0de5 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Utils/AppliedLayoutsHelper.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using FancyZonesEditorCommon.Data; + +namespace FancyZonesCLI.Utils; + +/// <summary> +/// Helper for managing applied layouts across monitors. +/// CLI-only business logic for matching, finding, and updating applied layouts. +/// </summary> +internal static class AppliedLayoutsHelper +{ + public const string DefaultVirtualDesktopGuid = "{00000000-0000-0000-0000-000000000000}"; + + public static bool MatchesDevice( + AppliedLayouts.AppliedLayoutWrapper.DeviceIdWrapper device, + string monitorName, + string serialNumber, + int monitorNumber, + string virtualDesktop) + { + // Must match monitor name + if (device.Monitor != monitorName) + { + return false; + } + + // Must match virtual desktop + if (device.VirtualDesktop != virtualDesktop) + { + return false; + } + + // If serial numbers are both available, they must match + if (!string.IsNullOrEmpty(device.SerialNumber) && !string.IsNullOrEmpty(serialNumber)) + { + if (device.SerialNumber != serialNumber) + { + return false; + } + } + + // If we reach here: Monitor name, VirtualDesktop, and SerialNumber (if available) all match + // MonitorInstance and MonitorNumber can vary, so we accept any value + return true; + } + + public static bool MatchesDeviceWithDefaultVirtualDesktop( + AppliedLayouts.AppliedLayoutWrapper.DeviceIdWrapper device, + string monitorName, + string serialNumber, + int monitorNumber, + string virtualDesktop) + { + if (device.VirtualDesktop == DefaultVirtualDesktopGuid) + { + // For this one layout record only, match any virtual desktop. + return device.Monitor == monitorName; + } + + return MatchesDevice(device, monitorName, serialNumber, monitorNumber, virtualDesktop); + } + + public static AppliedLayouts.AppliedLayoutWrapper? FindLayoutForMonitor( + AppliedLayouts.AppliedLayoutsListWrapper layouts, + string monitorName, + string serialNumber, + int monitorNumber, + string virtualDesktop) + { + if (layouts.AppliedLayouts == null) + { + return null; + } + + foreach (var layout in layouts.AppliedLayouts) + { + if (MatchesDevice(layout.Device, monitorName, serialNumber, monitorNumber, virtualDesktop)) + { + return layout; + } + } + + return null; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Utils/EditorParametersRefresh.cs b/src/modules/fancyzones/FancyZonesCLI/Utils/EditorParametersRefresh.cs new file mode 100644 index 0000000000..44303fb50a --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Utils/EditorParametersRefresh.cs @@ -0,0 +1,69 @@ +// 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.Globalization; +using System.IO; +using System.Text.Json; +using System.Threading; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.Utils; + +/// <summary> +/// Helper for requesting FancyZones to save editor-parameters.json and reading it reliably. +/// </summary> +internal static class EditorParametersRefresh +{ + public static EditorParameters.ParamsWrapper ReadEditorParametersWithRefresh(Action requestSave) + { + const int maxWaitMilliseconds = 500; + const int pollIntervalMilliseconds = 50; + + string filePath = FancyZonesPaths.EditorParameters; + DateTime lastWriteBefore = File.Exists(filePath) ? File.GetLastWriteTimeUtc(filePath) : DateTime.MinValue; + + requestSave(); + + int elapsedMilliseconds = 0; + while (elapsedMilliseconds < maxWaitMilliseconds) + { + try + { + if (File.Exists(filePath)) + { + DateTime lastWriteNow = File.GetLastWriteTimeUtc(filePath); + + // Prefer reading after the file is updated, but don't block forever if the + // timestamp resolution is coarse or FancyZones rewrites identical content. + if (lastWriteNow >= lastWriteBefore || elapsedMilliseconds > 100) + { + var editorParams = FancyZonesDataIO.ReadEditorParameters(); + if (editorParams.Monitors != null && editorParams.Monitors.Count > 0) + { + return editorParams; + } + } + } + } + catch (Exception ex) when (ex is FileNotFoundException || ex is IOException || ex is UnauthorizedAccessException || ex is JsonException) + { + // File may be mid-write/locked or temporarily invalid JSON; retry. + } + + Thread.Sleep(pollIntervalMilliseconds); + elapsedMilliseconds += pollIntervalMilliseconds; + } + + var finalParams = FancyZonesDataIO.ReadEditorParameters(); + if (finalParams.Monitors == null || finalParams.Monitors.Count == 0) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.editor_params_timeout, maxWaitMilliseconds, Path.GetFileName(filePath))); + } + + return finalParams; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Utils/GuidHelper.cs b/src/modules/fancyzones/FancyZonesCLI/Utils/GuidHelper.cs new file mode 100644 index 0000000000..e10377fafb --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Utils/GuidHelper.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics.CodeAnalysis; + +#nullable enable + +namespace FancyZonesCLI.Utils; + +/// <summary> +/// Helper class for normalizing GUID strings to Windows format with braces. +/// Supports input with or without braces: both "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" +/// and "{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}" are accepted. +/// </summary> +internal static class GuidHelper +{ + /// <summary> + /// Normalizes a GUID string to Windows format with braces. + /// Returns null if the input is not a valid GUID. + /// </summary> + /// <param name="input">GUID string with or without braces.</param> + /// <returns>GUID in "{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}" format, or null if invalid.</returns> + public static string? NormalizeGuid(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return null; + } + + if (Guid.TryParse(input, out Guid guid)) + { + // "B" format includes braces: {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} + return guid.ToString("B").ToUpperInvariant(); + } + + return null; + } + + /// <summary> + /// Tries to normalize a GUID string to Windows format with braces. + /// </summary> + /// <param name="input">GUID string with or without braces.</param> + /// <param name="normalizedGuid">The normalized GUID string, or the original input if normalization fails.</param> + /// <returns>True if the input was successfully normalized; otherwise, false.</returns> + public static bool TryNormalizeGuid(string? input, [NotNullWhen(true)] out string? normalizedGuid) + { + normalizedGuid = NormalizeGuid(input); + return normalizedGuid != null; + } +} diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/ApplyLayoutTests.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/ApplyLayoutTests.cs similarity index 94% rename from src/modules/fancyzones/UITests-FancyZonesEditor/ApplyLayoutTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/ApplyLayoutTests.cs index 2ae581ebe1..4991fb4546 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/ApplyLayoutTests.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/ApplyLayoutTests.cs @@ -19,7 +19,7 @@ namespace Microsoft.FancyZonesEditor.UITests public class ApplyLayoutTests : UITestBase { public ApplyLayoutTests() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } @@ -135,6 +135,7 @@ namespace Microsoft.FancyZonesEditor.UITests [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); EditorParameters editorParameters = new EditorParameters(); FancyZonesEditorHelper.Files.ParamsIOHelper.WriteData(editorParameters.Serialize(Parameters)); @@ -195,12 +196,6 @@ namespace Microsoft.FancyZonesEditor.UITests this.RestartScopeExe(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - [TestMethod] public void ApplyCustomLayout() { @@ -234,7 +229,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(Parameters.Monitors[0].MonitorNumber, data.AppliedLayouts[0].Device.MonitorNumber); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.ApplyLayoutsOnEachMonitor")] + [TestCategory("FancyZones Editor #10")] public void ApplyLayoutsOnEachMonitor() { // apply the layout on the first monitor @@ -261,7 +257,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(secondLayout.Uuid, data.AppliedLayouts.Find(x => x.Device.MonitorNumber == 2).AppliedLayout.Uuid); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.ApplyTemplateWithDifferentParametersOnEachMonitor")] + [TestCategory("FancyZones Editor #10")] public void ApplyTemplateWithDifferentParametersOnEachMonitor() { var layoutType = LayoutType.Columns; @@ -270,10 +267,10 @@ namespace Microsoft.FancyZonesEditor.UITests // apply the layout on the first monitor, set parameters Session.Find<Element>(layoutName).Click(); Session.Find<Element>(layoutName).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.TemplateZoneSlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.TemplateZoneSlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Right); - slider.SendKeys(Keys.Right); + slider.SendKeys(Key.Right); + slider.SendKeys(Key.Right); var expectedFirstLayoutZoneCount = int.Parse(slider.Text!, CultureInfo.InvariantCulture); Session.Find<Button>(ElementName.Save).Click(); @@ -281,16 +278,16 @@ namespace Microsoft.FancyZonesEditor.UITests Session.Find<Element>(PowerToys.UITest.By.AccessibilityId("Monitors")).Find<Element>("Monitor 2").Click(); Session.Find<Element>(layoutName).Click(); Session.Find<Element>(layoutName).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); - slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.TemplateZoneSlider)); + slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.TemplateZoneSlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Left); + slider.SendKeys(Key.Left); var expectedSecondLayoutZoneCount = int.Parse(slider.Text!, CultureInfo.InvariantCulture); Session.Find<Button>(ElementName.Save).Click(); // verify the layout on the first monitor wasn't changed Session.Find<Element>(PowerToys.UITest.By.AccessibilityId("Monitors")).Find<Element>("Monitor 1").Click(); Session.Find<Element>(layoutName).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); - slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.TemplateZoneSlider)); + slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.TemplateZoneSlider)); Assert.IsNotNull(slider); Assert.AreEqual(expectedFirstLayoutZoneCount, int.Parse(slider.Text!, CultureInfo.InvariantCulture)); Session.Find<Button>(ElementName.Cancel).Click(); diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/CopyLayoutTests.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/CopyLayoutTests.cs similarity index 94% rename from src/modules/fancyzones/UITests-FancyZonesEditor/CopyLayoutTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/CopyLayoutTests.cs index f39c735816..6ff9eb4895 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/CopyLayoutTests.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/CopyLayoutTests.cs @@ -17,7 +17,7 @@ namespace Microsoft.FancyZonesEditor.UITests public class CopyLayoutTests : UITestBase { public CopyLayoutTests() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } @@ -76,6 +76,7 @@ namespace Microsoft.FancyZonesEditor.UITests [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); EditorParameters editorParameters = new EditorParameters(); ParamsWrapper parameters = new ParamsWrapper { @@ -172,13 +173,8 @@ namespace Microsoft.FancyZonesEditor.UITests this.RestartScopeExe(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CopyTemplate_FromEditLayoutWindow")] + [TestCategory("FancyZones Editor #4")] public void CopyTemplate_FromEditLayoutWindow() { string copiedLayoutName = TestConstants.TemplateLayoutNames[LayoutType.Focus] + " (1)"; @@ -195,7 +191,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(data.CustomLayouts.Exists(x => x.Name == copiedLayoutName)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CopyTemplate_FromEditLayoutWindow")] + [TestCategory("FancyZones Editor #4")] public void CopyTemplate_FromContextMenu() { string copiedLayoutName = TestConstants.TemplateLayoutNames[LayoutType.Rows] + " (1)"; @@ -211,7 +208,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(data.CustomLayouts.Exists(x => x.Name == copiedLayoutName)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CopyTemplate_DefaultLayout")] + [TestCategory("FancyZones Editor #13")] public void CopyTemplate_DefaultLayout() { string copiedLayoutName = TestConstants.TemplateLayoutNames[LayoutType.PriorityGrid] + " (1)"; @@ -243,7 +241,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(defaultLayouts.Serialize(DefaultLayouts), defaultLayouts.Serialize(defaultLayoutData)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CopyCustomLayout_FromEditLayoutWindow")] + [TestCategory("FancyZones Editor #4")] public void CopyCustomLayout_FromEditLayoutWindow() { string copiedLayoutName = CustomLayouts.CustomLayouts[0].Name + " (1)"; @@ -260,7 +259,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(data.CustomLayouts.Exists(x => x.Name == copiedLayoutName)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CopyCustomLayout_FromContextMenu")] + [TestCategory("FancyZones Editor #4")] public void CopyCustomLayout_FromContextMenu() { string copiedLayoutName = CustomLayouts.CustomLayouts[0].Name + " (1)"; @@ -276,7 +276,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(data.CustomLayouts.Exists(x => x.Name == copiedLayoutName)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CopyCustomLayout_DefaultLayout")] + [TestCategory("FancyZones Editor #13")] public void CopyCustomLayout_DefaultLayout() { string copiedLayoutName = CustomLayouts.CustomLayouts[0].Name + " (1)"; @@ -308,7 +309,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(defaultLayouts.Serialize(DefaultLayouts), defaultLayouts.Serialize(defaultLayoutData)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CopyCustomLayout_Hotkey")] + [TestCategory("FancyZones Editor #4")] public void CopyCustomLayout_Hotkey() { string copiedLayoutName = CustomLayouts.CustomLayouts[0].Name + " (1)"; diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/CreateLayoutTests.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/CreateLayoutTests.cs similarity index 94% rename from src/modules/fancyzones/UITests-FancyZonesEditor/CreateLayoutTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/CreateLayoutTests.cs index 6907a7e8aa..a6e1adfb82 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/CreateLayoutTests.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/CreateLayoutTests.cs @@ -16,13 +16,15 @@ namespace Microsoft.FancyZonesEditor.UITests public class CreateLayoutTests : UITestBase { public CreateLayoutTests() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); + // prepare test editor parameters with 2 monitors before launching the editor EditorParameters editorParameters = new EditorParameters(); EditorParameters.ParamsWrapper parameters = new EditorParameters.ParamsWrapper @@ -132,12 +134,6 @@ namespace Microsoft.FancyZonesEditor.UITests this.RestartScopeExe(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - [TestMethod] public void CreateWithDefaultName() { @@ -156,7 +152,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(data.CustomLayouts.Exists(x => x.Name == name)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CreateWithCustomName")] + [TestCategory("FancyZones Editor #3")] public void CreateWithCustomName() { string name = "Layout Name"; @@ -177,7 +174,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(data.CustomLayouts.Exists(x => x.Name == name)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CreateGrid")] + [TestCategory("FancyZones Editor #3")] public void CreateGrid() { CustomLayout type = CustomLayout.Grid; @@ -193,7 +191,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(data.CustomLayouts.Exists(x => x.Type == type.TypeToString())); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CreateCanvas")] + [TestCategory("FancyZones Editor #3")] public void CreateCanvas() { CustomLayout type = CustomLayout.Canvas; @@ -209,7 +208,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(data.CustomLayouts.Exists(x => x.Type == type.TypeToString())); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CancelGridCreation")] + [TestCategory("FancyZones Editor #3")] public void CancelGridCreation() { Session.Find<Element>(By.AccessibilityId(AccessibilityId.NewLayoutButton)).Click(); @@ -223,7 +223,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(0, data.CustomLayouts.Count); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CancelCanvasCreation")] + [TestCategory("FancyZones Editor #3")] public void CancelCanvasCreation() { Session.Find<Element>(By.AccessibilityId(AccessibilityId.NewLayoutButton)).Click(); diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/CustomLayoutsTests.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/CustomLayoutsTests.cs similarity index 95% rename from src/modules/fancyzones/UITests-FancyZonesEditor/CustomLayoutsTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/CustomLayoutsTests.cs index f099bbfb42..59530f671e 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/CustomLayoutsTests.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/CustomLayoutsTests.cs @@ -20,7 +20,7 @@ namespace Microsoft.FancyZonesEditor.UITests public class CustomLayoutsTests : UITestBase { public CustomLayoutsTests() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } @@ -104,6 +104,7 @@ namespace Microsoft.FancyZonesEditor.UITests [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); CustomLayouts customLayouts = new CustomLayouts(); FancyZonesEditorHelper.Files.CustomLayoutsIOHelper.WriteData(customLayouts.Serialize(Layouts)); @@ -208,12 +209,6 @@ namespace Microsoft.FancyZonesEditor.UITests this.RestartScopeExe(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - [TestMethod] public void Name_Initialize() { @@ -292,14 +287,14 @@ namespace Microsoft.FancyZonesEditor.UITests var type = layout.Type; Session.Find<Element>(layout.Name).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SensitivitySlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SensitivitySlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Right); + slider.SendKeys(Key.Right); var value = type == CustomLayout.Canvas.TypeToString() ? new CustomLayouts().CanvasFromJsonElement(layout.Info.GetRawText()).SensitivityRadius : new CustomLayouts().GridFromJsonElement(layout.Info.GetRawText()).SensitivityRadius; - var expected = value + 1; // one step right + var expected = value; // if have one step right please + 1 Assert.AreEqual($"{expected}", slider.Text); @@ -321,9 +316,9 @@ namespace Microsoft.FancyZonesEditor.UITests var type = layout.Type; Session.Find<Element>(layout.Name).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SensitivitySlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SensitivitySlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Right); + slider.SendKeys(Key.Right); var expected = type == CustomLayout.Canvas.TypeToString() ? new CustomLayouts().CanvasFromJsonElement(layout.Info.GetRawText()).SensitivityRadius : @@ -373,15 +368,15 @@ namespace Microsoft.FancyZonesEditor.UITests public void SpaceAroundZones_Slider_Save() { var layout = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Grid.TypeToString() && new CustomLayouts().GridFromJsonElement(x.Info.GetRawText()).ShowSpacing); - var expected = new CustomLayouts().GridFromJsonElement(layout.Info.GetRawText()).Spacing + 1; // one step right + var expected = new CustomLayouts().GridFromJsonElement(layout.Info.GetRawText()).Spacing; // one step right Session.Find<Element>(layout.Name).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SpacingSlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SpacingSlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Right); + slider.SendKeys(Key.Right); Assert.AreEqual($"{expected}", slider.Text); - Session.Find<Button>(ElementName.Save).Click(); + Session.Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.PrimaryButton)).Click(); // verify the file var customLayouts = new CustomLayouts(); @@ -397,9 +392,9 @@ namespace Microsoft.FancyZonesEditor.UITests Session.Find<Element>(layout.Name).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); var expected = new CustomLayouts().GridFromJsonElement(layout.Info.GetRawText()).Spacing; - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SpacingSlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SpacingSlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Right); + slider.SendKeys(Key.Right); Session.Find<Button>(ElementName.Cancel).Click(); // verify the file diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/DefaultLayoutsTest.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/DefaultLayoutsTest.cs similarity index 97% rename from src/modules/fancyzones/UITests-FancyZonesEditor/DefaultLayoutsTest.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/DefaultLayoutsTest.cs index 398353b687..8cf285b022 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/DefaultLayoutsTest.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/DefaultLayoutsTest.cs @@ -20,7 +20,7 @@ namespace Microsoft.FancyZonesEditor.UITests public class DefaultLayoutsTest : UITestBase { public DefaultLayoutsTest() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } @@ -121,6 +121,7 @@ namespace Microsoft.FancyZonesEditor.UITests [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); var defaultLayouts = new DefaultLayouts(); FancyZonesEditorHelper.Files.DefaultLayoutsIOHelper.WriteData(defaultLayouts.Serialize(Layouts)); @@ -237,20 +238,16 @@ namespace Microsoft.FancyZonesEditor.UITests this.RestartScopeExe(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Default_Initialize")] + [TestCategory("FancyZones Editor #12")] public void Initialize() { CheckTemplateLayouts(LayoutType.Grid, null); CheckCustomLayouts(string.Empty, CustomLayouts.CustomLayouts[0].Uuid); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Default_Assign_Cancel")] + [TestCategory("FancyZones Editor #12")] public void Assign_Cancel() { // assign Focus as a default horizontal and vertical layout @@ -266,7 +263,8 @@ namespace Microsoft.FancyZonesEditor.UITests CheckCustomLayouts(string.Empty, CustomLayouts.CustomLayouts[0].Uuid); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Default_Assign_Save")] + [TestCategory("FancyZones Editor #12")] public void Assign_Save() { // assign Focus as a default horizontal and vertical layout diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/DeleteLayoutTests.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/DeleteLayoutTests.cs similarity index 93% rename from src/modules/fancyzones/UITests-FancyZonesEditor/DeleteLayoutTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/DeleteLayoutTests.cs index d0f3e5dd00..a8616e9a42 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/DeleteLayoutTests.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/DeleteLayoutTests.cs @@ -22,7 +22,7 @@ namespace Microsoft.FancyZonesEditor.UITests public class DeleteLayoutTests : UITestBase { public DeleteLayoutTests() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } @@ -142,6 +142,7 @@ namespace Microsoft.FancyZonesEditor.UITests [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); EditorParameters editorParameters = new EditorParameters(); FancyZonesEditorHelper.Files.ParamsIOHelper.WriteData(editorParameters.Serialize(Parameters)); @@ -215,19 +216,14 @@ namespace Microsoft.FancyZonesEditor.UITests Session.Find<Element>(CustomLayouts.CustomLayouts[0].Name).Click(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.DeleteNotAppliedLayout")] + [TestCategory("FancyZones Editor #5")] public void DeleteNotAppliedLayout() { var deletedLayout = CustomLayouts.CustomLayouts[1].Name; Session.Find<Element>(deletedLayout).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); Session.Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.DeleteLayoutButton)).Click(); - Session.KeyboardAction(Keys.Tab, Keys.Enter); + Session.SendKeySequence(Key.Tab, Key.Enter); // verify the layout is removed Assert.IsTrue(Session.FindAll<Element>(deletedLayout).Count == 0); @@ -239,13 +235,15 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsFalse(data.CustomLayouts.Exists(x => x.Name == deletedLayout)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.DeleteAppliedLayout")] + [TestCategory("FancyZones Editor #5")] public void DeleteAppliedLayout() { var deletedLayout = CustomLayouts.CustomLayouts[0].Name; Session.Find<Element>(deletedLayout).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); Session.Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.DeleteLayoutButton)).Click(); - Session.KeyboardAction(Keys.Tab, Keys.Enter); + + Session.SendKeySequence(Key.Tab, Key.Enter); // verify the layout is removed Assert.IsTrue(Session.FindAll<Element>(deletedLayout).Count == 0); @@ -264,13 +262,14 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(LayoutType.Blank.TypeToString(), appliedLayoutsData.AppliedLayouts.Find(x => x.Device.Monitor == Parameters.Monitors[0].Monitor).AppliedLayout.Type); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.CancelDeletion")] + [TestCategory("FancyZones Editor #5")] public void CancelDeletion() { var deletedLayout = CustomLayouts.CustomLayouts[1].Name; Session.Find<Element>(deletedLayout).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); Session.Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.DeleteLayoutButton)).Click(); - Session.KeyboardAction(Keys.Tab, Keys.Tab, Keys.Enter); + Session.SendKeySequence(Key.Tab, Key.Tab, Key.Enter); // verify the layout is not removed Assert.IsNotNull(Session.Find<Element>(deletedLayout)); @@ -282,12 +281,13 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(data.CustomLayouts.Exists(x => x.Name == deletedLayout)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.DeleteFromContextMenu")] + [TestCategory("FancyZones Editor #5")] public void DeleteFromContextMenu() { var deletedLayout = CustomLayouts.CustomLayouts[1].Name; FancyZonesEditorHelper.ClickContextMenuItem(Session, deletedLayout, FancyZonesEditorHelper.ElementName.Delete); - Session.KeyboardAction(Keys.Tab, Keys.Enter); + Session.SendKeySequence(Key.Tab, Key.Enter); // verify the layout is removed Assert.IsTrue(Session.FindAll<Element>(deletedLayout).Count == 0); @@ -299,12 +299,13 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsFalse(data.CustomLayouts.Exists(x => x.Name == deletedLayout)); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.DeleteDefaultLayout")] + [TestCategory("FancyZones Editor #5")] public void DeleteDefaultLayout() { var deletedLayout = CustomLayouts.CustomLayouts[1].Name; FancyZonesEditorHelper.ClickContextMenuItem(Session, deletedLayout, FancyZonesEditorHelper.ElementName.Delete); - Session.KeyboardAction(Keys.Tab, Keys.Enter); + Session.SendKeySequence(Key.Tab, Key.Enter); // verify the default layout is reset to the "default" default Session.Find<Element>(TestConstants.TemplateLayoutNames[LayoutType.PriorityGrid]).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); @@ -318,12 +319,13 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(LayoutType.PriorityGrid.TypeToString(), data.DefaultLayouts.Find(x => x.MonitorConfiguration == configuration).Layout.Type); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.DeleteLayoutWithHotkey")] + [TestCategory("FancyZones Editor #5")] public void DeleteLayoutWithHotkey() { var deletedLayout = CustomLayouts.CustomLayouts[1].Name; FancyZonesEditorHelper.ClickContextMenuItem(Session, deletedLayout, FancyZonesEditorHelper.ElementName.Delete); - Session.KeyboardAction(Keys.Tab, Keys.Enter); + Session.SendKeySequence(Key.Tab, Key.Enter); // verify the hotkey is available Session.Find<Element>(CustomLayouts.CustomLayouts[0].Name).Find<Button>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/EditLayoutTests.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/EditLayoutTests.cs similarity index 93% rename from src/modules/fancyzones/UITests-FancyZonesEditor/EditLayoutTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/EditLayoutTests.cs index 0519799912..ab28c6a260 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/EditLayoutTests.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/EditLayoutTests.cs @@ -21,7 +21,7 @@ namespace Microsoft.FancyZonesEditor.UITests public class EditLayoutTests : UITestBase { public EditLayoutTests() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } @@ -105,6 +105,7 @@ namespace Microsoft.FancyZonesEditor.UITests [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); EditorParameters editorParameters = new EditorParameters(); ParamsWrapper parameters = new ParamsWrapper { @@ -209,13 +210,8 @@ namespace Microsoft.FancyZonesEditor.UITests this.RestartScopeExe(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.OpenEditMode")] + [TestCategory("FancyZones Editor #7")] public void OpenEditMode() { Session.Find<Element>(Layouts.CustomLayouts[0].Name).Find<Button>(By.AccessibilityId(AccessibilityId.EditLayoutButton)).Click(); @@ -224,7 +220,8 @@ namespace Microsoft.FancyZonesEditor.UITests Session.Find<Button>(ElementName.Cancel).Click(); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.OpenEditModeFromContextMenu")] + [TestCategory("FancyZones Editor #7")] public void OpenEditModeFromContextMenu() { FancyZonesEditorHelper.ClickContextMenuItem(Session, Layouts.CustomLayouts[0].Name, FancyZonesEditorHelper.ElementName.EditZones); @@ -232,7 +229,8 @@ namespace Microsoft.FancyZonesEditor.UITests Session.Find<Button>(ElementName.Cancel).Click(); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Canvas_AddZone_Save")] + [TestCategory("FancyZones Editor #7")] public void Canvas_AddZone_Save() { var canvas = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Canvas.TypeToString()); @@ -248,7 +246,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected.Zones.Count + 1, actual.Zones.Count); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Canvas_AddZone_Cancel")] + [TestCategory("FancyZones Editor #7")] public void Canvas_AddZone_Cancel() { var canvas = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Canvas.TypeToString()); @@ -264,7 +263,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected.Zones.Count, actual.Zones.Count); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Canvas_DeleteZone_Save")] + [TestCategory("FancyZones Editor #7")] public void Canvas_DeleteZone_Save() { var canvas = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Canvas.TypeToString()); @@ -280,7 +280,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected.Zones.Count - 1, actual.Zones.Count); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Canvas_DeleteZone_Cancel")] + [TestCategory("FancyZones Editor #7")] public void Canvas_DeleteZone_Cancel() { var canvas = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Canvas.TypeToString()); @@ -296,7 +297,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected.Zones.Count, actual.Zones.Count); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Canvas_MoveZone_Save")] + [TestCategory("FancyZones Editor #7")] public void Canvas_MoveZone_Save() { int zoneNumber = 1; @@ -333,7 +335,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Canvas_MoveZone_Cancel")] + [TestCategory("FancyZones Editor #7")] public void Canvas_MoveZone_Cancel() { int zoneNumber = 1; @@ -357,7 +360,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Canvas_ResizeZone_Save")] + [TestCategory("FancyZones Editor #7")] public void Canvas_ResizeZone_Save() { int zoneNumber = 1; @@ -366,7 +370,7 @@ namespace Microsoft.FancyZonesEditor.UITests var canvas = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Canvas.TypeToString()); FancyZonesEditorHelper.ClickContextMenuItem(Session, canvas.Name, FancyZonesEditorHelper.ElementName.EditZones); - FancyZonesEditorHelper.GetZone(Session, zoneNumber, FancyZonesEditorHelper.ClassName.CanvasZone)?.Find<Element>(By.AccessibilityId(FancyZonesEditorHelper.AccessibilityId.TopRightCorner)).Drag(xOffset, yOffset); + FancyZonesEditorHelper.GetZone(Session, zoneNumber, FancyZonesEditorHelper.ClassName.CanvasZone)?.Find<Thumb>(By.AccessibilityId(FancyZonesEditorHelper.AccessibilityId.TopRightCorner)).Drag(xOffset, yOffset); Session.Find<Button>(ElementName.Save).Click(); // check the file @@ -394,7 +398,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Canvas_ResizeZone_Cancel")] + [TestCategory("FancyZones Editor #7")] public void Canvas_ResizeZone_Cancel() { int zoneNumber = 1; @@ -403,7 +408,7 @@ namespace Microsoft.FancyZonesEditor.UITests var canvas = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Canvas.TypeToString()); FancyZonesEditorHelper.ClickContextMenuItem(Session, canvas.Name, FancyZonesEditorHelper.ElementName.EditZones); - FancyZonesEditorHelper.GetZone(Session, zoneNumber, FancyZonesEditorHelper.ClassName.CanvasZone)?.Find<Element>(By.AccessibilityId(FancyZonesEditorHelper.AccessibilityId.TopRightCorner)).Drag(xOffset, yOffset); + FancyZonesEditorHelper.GetZone(Session, zoneNumber, FancyZonesEditorHelper.ClassName.CanvasZone)?.Find<Thumb>(By.AccessibilityId(FancyZonesEditorHelper.AccessibilityId.TopRightCorner)).Drag(xOffset, yOffset); Session.Find<Button>(ElementName.Cancel).Click(); // check the file @@ -421,7 +426,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Grid_SplitZone_Save")] + [TestCategory("FancyZones Editor #8")] public void Grid_SplitZone_Save() { int zoneNumber = 1; @@ -450,7 +456,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Grid_SplitZone_Cancel")] + [TestCategory("FancyZones Editor #8")] public void Grid_SplitZone_Cancel() { int zoneNumber = 1; @@ -481,7 +488,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Grid_MergeZones_Save")] + [TestCategory("FancyZones Editor #8")] public void Grid_MergeZones_Save() { var grid = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Grid.TypeToString()); @@ -515,7 +523,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.IsTrue(actual.CellChildMap[1].SequenceEqual([1, 2])); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Grid_MergeZones_Cancel")] + [TestCategory("FancyZones Editor #8")] public void Grid_MergeZones_Cancel() { var grid = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Grid.TypeToString()); @@ -551,7 +560,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Grid_MoveSplitter_Save")] + [TestCategory("FancyZones Editor #8")] public void Grid_MoveSplitter_Save() { EditorParameters editorParameters = new EditorParameters(); @@ -614,7 +624,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.Grid_MoveSplitter_Cancel")] + [TestCategory("FancyZones Editor #8")] public void Grid_MoveSplitter_Cancel() { var grid = Layouts.CustomLayouts.Find(x => x.Type == CustomLayout.Grid.TypeToString() && x.Name == "Grid-9"); diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/UITests-FancyZonesEditor.csproj b/src/modules/fancyzones/FancyZonesEditor.UITests/FancyZonesEditor.UITests.csproj similarity index 58% rename from src/modules/fancyzones/UITests-FancyZonesEditor/UITests-FancyZonesEditor.csproj rename to src/modules/fancyzones/FancyZonesEditor.UITests/FancyZonesEditor.UITests.csproj index 4e56ac5f8c..443c50059a 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/UITests-FancyZonesEditor.csproj +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/FancyZonesEditor.UITests.csproj @@ -1,26 +1,32 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> - <ProjectGuid>{3A9A791E-94A9-49F8-8401-C11CE288D5FB}</ProjectGuid> + <ProjectGuid>{9BAFFC28-E1EF-4C14-A101-EEBFC0A50D83}</ProjectGuid> <RootNamespace>Microsoft.FancyZonesEditor.UITests</RootNamespace> <IsPackable>false</IsPackable> <Nullable>enable</Nullable> <OutputType>Exe</OutputType> - + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <!-- This is a UI test, so don't run as part of the Test target --> <TestingPlatformDisableCustomTestTarget>true</TestingPlatformDisableCustomTestTarget> </PropertyGroup> <PropertyGroup> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\UITests-FancyZonesEditor\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\UITests-FancyZonesEditor\</OutputPath> </PropertyGroup> <ItemGroup> <PackageReference Include="Appium.WebDriver" /> <PackageReference Include="MSTest" /> <PackageReference Include="System.IO.Abstractions" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> </ItemGroup> <ItemGroup> @@ -31,4 +37,4 @@ <ProjectReference Include="..\editor\FancyZonesEditor\FancyZonesEditor.csproj" /> <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> </ItemGroup> -</Project> \ No newline at end of file +</Project> diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/FirstLunchTest.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/FirstLunchTest.cs similarity index 98% rename from src/modules/fancyzones/UITests-FancyZonesEditor/FirstLunchTest.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/FirstLunchTest.cs index 567c60b42f..7e4022bf68 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/FirstLunchTest.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/FirstLunchTest.cs @@ -25,13 +25,14 @@ namespace Microsoft.FancyZonesEditor.UITests public class FirstLunchTest : UITestBase { public FirstLunchTest() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); EditorParameters editorParameters = new EditorParameters(); ParamsWrapper parameters = new ParamsWrapper { @@ -141,12 +142,6 @@ namespace Microsoft.FancyZonesEditor.UITests this.RestartScopeExe(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - [TestMethod] public void FirstLaunch() { diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/Init.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/Init.cs similarity index 100% rename from src/modules/fancyzones/UITests-FancyZonesEditor/Init.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/Init.cs diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/LayoutHotkeysTests.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/LayoutHotkeysTests.cs similarity index 96% rename from src/modules/fancyzones/UITests-FancyZonesEditor/LayoutHotkeysTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/LayoutHotkeysTests.cs index d1e9fa1466..145a578bb9 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/LayoutHotkeysTests.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/LayoutHotkeysTests.cs @@ -23,7 +23,7 @@ namespace Microsoft.FancyZonesEditor.UITests public class LayoutHotkeysTests : UITestBase { public LayoutHotkeysTests() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } @@ -106,6 +106,7 @@ namespace Microsoft.FancyZonesEditor.UITests [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); EditorParameters editorParameters = new EditorParameters(); ParamsWrapper parameters = new ParamsWrapper { @@ -206,13 +207,8 @@ namespace Microsoft.FancyZonesEditor.UITests this.RestartScopeExe(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.HotKey_Initialize")] + [TestCategory("FancyZones Editor #11")] public void Initialize() { foreach (var layout in CustomLayouts.CustomLayouts) @@ -256,7 +252,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.HotKey_Assign_Save")] + [TestCategory("FancyZones Editor #11")] public void Assign_Save() { var layout = CustomLayouts.CustomLayouts[2]; // a layout without assigned hotkey @@ -299,7 +296,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.HotKey_Assign_Cancel")] + [TestCategory("FancyZones Editor #11")] public void Assign_Cancel() { var layout = CustomLayouts.CustomLayouts[2]; // a layout without assigned hotkey @@ -338,7 +336,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.HotKey_Assign_AllPossibleValues")] + [TestCategory("FancyZones Editor #11")] public void Assign_AllPossibleValues() { for (int i = 0; i < 4; i++) @@ -384,7 +383,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.HotKey_Reset_Save")] + [TestCategory("FancyZones Editor #11")] public void Reset_Save() { var layout = CustomLayouts.CustomLayouts[0]; // a layout with assigned hotkey @@ -424,7 +424,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.HotKey_Reset_Cancel")] + [TestCategory("FancyZones Editor #11")] public void Reset_Cancel() { var layout = CustomLayouts.CustomLayouts[0]; // a layout with assigned hotkey diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/NewFancyZonesEditorTest.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/NewFancyZonesEditorTest.cs similarity index 98% rename from src/modules/fancyzones/UITests-FancyZonesEditor/NewFancyZonesEditorTest.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/NewFancyZonesEditorTest.cs index b3bac9a574..14341b4abb 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/NewFancyZonesEditorTest.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/NewFancyZonesEditorTest.cs @@ -26,7 +26,7 @@ namespace Microsoft.FancyZonesEditor.UITests public class TestCaseFirstLaunch : UITestBase { public TestCaseFirstLaunch() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/RunFancyZonesEditorTest.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/RunFancyZonesEditorTest.cs similarity index 97% rename from src/modules/fancyzones/UITests-FancyZonesEditor/RunFancyZonesEditorTest.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/RunFancyZonesEditorTest.cs index a21fc27398..dc85518b11 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/RunFancyZonesEditorTest.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/RunFancyZonesEditorTest.cs @@ -18,12 +18,12 @@ namespace Microsoft.FancyZonesEditor.UITests public class RunFancyZonesEditorTest : UITestBase { public RunFancyZonesEditorTest() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } - [ClassInitialize] - public static void ClassInitialize(TestContext testContext) + [TestInitialize] + public void TestInitialize() { FancyZonesEditorHelper.Files.Restore(); @@ -163,12 +163,8 @@ namespace Microsoft.FancyZonesEditor.UITests AppliedLayouts = new List<AppliedLayouts.AppliedLayoutWrapper> { }, }; FancyZonesEditorHelper.Files.AppliedLayoutsIOHelper.WriteData(appliedLayouts.Serialize(appliedLayoutsWrapper)); - } - [ClassCleanup] - public static void ClassCleanup() - { - FancyZonesEditorHelper.Files.Restore(); + this.RestartScopeExe(); } [TestMethod] diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/TemplateLayoutsTests.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/TemplateLayoutsTests.cs similarity index 88% rename from src/modules/fancyzones/UITests-FancyZonesEditor/TemplateLayoutsTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/TemplateLayoutsTests.cs index ebb66abb5d..111e798ccf 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/TemplateLayoutsTests.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/TemplateLayoutsTests.cs @@ -73,13 +73,14 @@ namespace Microsoft.FancyZonesEditor.UITests }; public TemplateLayoutsTests() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } [TestInitialize] public void TestInitialize() { + FancyZonesEditorHelper.Files.Restore(); EditorParameters editorParameters = new EditorParameters(); ParamsWrapper parameters = new ParamsWrapper { @@ -191,13 +192,8 @@ namespace Microsoft.FancyZonesEditor.UITests this.RestartScopeExe(); } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.ZoneNumber_Cancel")] + [TestCategory("FancyZones Editor #6")] public void ZoneNumber_Cancel() { var type = LayoutType.Rows; @@ -205,9 +201,9 @@ namespace Microsoft.FancyZonesEditor.UITests var expected = layout.ZoneCount; Session.Find<Button>(TestConstants.TemplateLayoutNames[type]).Click(); - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.TemplateZoneSlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.TemplateZoneSlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Left); + slider.SendKeys(Key.Left); Session.Find<Button>(ElementName.Cancel).Click(); @@ -218,7 +214,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected, actual); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.HighlightDistance_Initialize")] + [TestCategory("FancyZones Editor #6")] public void HighlightDistance_Initialize() { foreach (var (type, name) in TestConstants.TemplateLayoutNames) @@ -239,7 +236,8 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.HighlightDistance_Save")] + [TestCategory("FancyZones Editor #6")] public void HighlightDistance_Save() { var type = LayoutType.Focus; @@ -247,11 +245,11 @@ namespace Microsoft.FancyZonesEditor.UITests var value = layout.SensitivityRadius; Session.Find<Button>(TestConstants.TemplateLayoutNames[type]).Click(); - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SensitivitySlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SensitivitySlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Right); + slider.SendKeys(Key.Right); - var expected = value + 1; // one step right + var expected = value; // one step right Assert.AreEqual($"{expected}", slider.Text); Session.Find<Button>(ElementName.Save).Click(); @@ -263,7 +261,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected, actual); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.HighlightDistance_Cancel")] + [TestCategory("FancyZones Editor #6")] public void HighlightDistance_Cancel() { var type = LayoutType.Focus; @@ -271,9 +270,9 @@ namespace Microsoft.FancyZonesEditor.UITests var expected = layout.SensitivityRadius; Session.Find<Button>(TestConstants.TemplateLayoutNames[type]).Click(); - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SensitivitySlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SensitivitySlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Right); + slider.SendKeys(Key.Right); Session.Find<Button>(ElementName.Cancel).Click(); // verify the file @@ -283,7 +282,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected, actual); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.SpaceAroundZones_Initialize")] + [TestCategory("FancyZones Editor #6")] public void SpaceAroundZones_Initialize() { foreach (var (type, name) in TestConstants.TemplateLayoutNames) @@ -309,17 +309,18 @@ namespace Microsoft.FancyZonesEditor.UITests } } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.SpaceAroundZones_Slider_Save")] + [TestCategory("FancyZones Editor #6")] public void SpaceAroundZones_Slider_Save() { var type = LayoutType.PriorityGrid; var layout = Layouts.LayoutTemplates.Find(x => x.Type == type.TypeToString()); - var expected = layout.Spacing + 1; + var expected = layout.Spacing; Session.Find<Button>(TestConstants.TemplateLayoutNames[type]).Click(); - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SpacingSlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SpacingSlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Right); + slider.SendKeys(Key.Right); Assert.AreEqual($"{expected}", slider.Text); Session.Find<Button>(ElementName.Save).Click(); @@ -331,7 +332,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected, actual); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.SpaceAroundZones_Slider_Cancel")] + [TestCategory("FancyZones Editor #6")] public void SpaceAroundZones_Slider_Cancel() { var type = LayoutType.PriorityGrid; @@ -339,10 +341,10 @@ namespace Microsoft.FancyZonesEditor.UITests var expected = layout.Spacing; Session.Find<Button>(TestConstants.TemplateLayoutNames[type]).Click(); - var slider = Session.Find<Element>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SpacingSlider)); + var slider = Session.Find<Custom>(PowerToys.UITest.By.AccessibilityId(AccessibilityId.SpacingSlider)); Assert.IsNotNull(slider); - slider.SendKeys(Keys.Right); - Assert.AreEqual($"{expected + 1}", slider.Text); + slider.SendKeys(Key.Right); + Assert.AreEqual($"{expected}", slider.Text); Session.Find<Button>(ElementName.Cancel).Click(); @@ -353,7 +355,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected, actual); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.SpaceAroundZones_Toggle_Save")] + [TestCategory("FancyZones Editor #6")] public void SpaceAroundZones_Toggle_Save() { var type = LayoutType.PriorityGrid; @@ -376,7 +379,8 @@ namespace Microsoft.FancyZonesEditor.UITests Assert.AreEqual(expected, actual); } - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.SpaceAroundZones_Toggle_Cancel")] + [TestCategory("FancyZones Editor #6")] public void SpaceAroundZones_Toggle_Cancel() { var type = LayoutType.PriorityGrid; diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/TestConstants.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/TestConstants.cs similarity index 100% rename from src/modules/fancyzones/UITests-FancyZonesEditor/TestConstants.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/TestConstants.cs diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/UIInitializeTest.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/UIInitializeTest.cs similarity index 99% rename from src/modules/fancyzones/UITests-FancyZonesEditor/UIInitializeTest.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/UIInitializeTest.cs index 88637df150..7c9e47f0d3 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/UIInitializeTest.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/UIInitializeTest.cs @@ -23,17 +23,12 @@ namespace Microsoft.FancyZonesEditor.UITests public class UIInitializeTest : UITestBase { public UIInitializeTest() - : base(PowerToysModule.FancyZone) + : base(PowerToysModule.FancyZone, WindowSize.UnSpecified) { } - [TestCleanup] - public void TestCleanup() - { - FancyZonesEditorHelper.Files.Restore(); - } - - [TestMethod] + [TestMethod("FancyZonesEditor.Basic.EditorParams_VerifySelectedMonitor")] + [TestCategory("FancyZones Editor #10")] public void EditorParams_VerifySelectedMonitor() { InitFileData(); @@ -737,6 +732,7 @@ namespace Microsoft.FancyZonesEditor.UITests private void InitFileData() { + FancyZonesEditorHelper.Files.Restore(); EditorParameters editorParameters = new EditorParameters(); ParamsWrapper parameters = new ParamsWrapper { diff --git a/src/modules/fancyzones/FancyZonesEditor.UITests/Utils/AppZoneHistory.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/Utils/AppZoneHistory.cs new file mode 100644 index 0000000000..f246665aea --- /dev/null +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/Utils/AppZoneHistory.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json; +using static FancyZonesEditorCommon.Data.AppZoneHistory.AppZoneHistoryWrapper; +using static FancyZonesEditorCommon.Data.CustomLayouts; + +namespace FancyZonesEditorCommon.Data +{ + public class AppZoneHistory : EditorData<AppZoneHistory.AppZoneHistoryListWrapper> + { + public string File + { + get + { + return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\app-zone-history.json"; + } + } + + public struct AppZoneHistoryWrapper + { + public struct ZoneHistoryWrapper + { + public int[][] ZoneIndexSet { get; set; } + + public DeviceIdWrapper Device { get; set; } + + public string ZonesetUuid { get; set; } + } + + public struct DeviceIdWrapper + { + public string Monitor { get; set; } + + public string MonitorInstance { get; set; } + + public int MonitorNumber { get; set; } + + public string SerialNumber { get; set; } + + public string VirtualDesktop { get; set; } + } + + public string AppPath { get; set; } + + public List<ZoneHistoryWrapper> History { get; set; } + } + + public struct AppZoneHistoryListWrapper + { + public List<AppZoneHistoryWrapper> AppZoneHistory { get; set; } + } + + public JsonElement ToJsonElement(ZoneHistoryWrapper info) + { + string json = JsonSerializer.Serialize(info, JsonOptions); + return JsonSerializer.Deserialize<JsonElement>(json); + } + + public JsonElement ToJsonElement(DeviceIdWrapper info) + { + string json = JsonSerializer.Serialize(info, JsonOptions); + return JsonSerializer.Deserialize<JsonElement>(json); + } + + public ZoneHistoryWrapper ZoneHistoryFromJsonElement(string json) + { + return JsonSerializer.Deserialize<ZoneHistoryWrapper>(json, JsonOptions); + } + + public DeviceIdWrapper GridFromJsonElement(string json) + { + return JsonSerializer.Deserialize<DeviceIdWrapper>(json, JsonOptions); + } + } +} diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/Utils/FancyZonesEditorFiles.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/Utils/FancyZonesEditorFiles.cs similarity index 89% rename from src/modules/fancyzones/UITests-FancyZonesEditor/Utils/FancyZonesEditorFiles.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/Utils/FancyZonesEditorFiles.cs index cc969f39b0..b6007d2845 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/Utils/FancyZonesEditorFiles.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/Utils/FancyZonesEditorFiles.cs @@ -20,6 +20,8 @@ namespace Microsoft.FancyZonesEditor.UITests.Utils public IOTestHelper LayoutTemplatesIOHelper { get; } + public IOTestHelper AppZoneHistoryIOHelper { get; } + public FancyZonesEditorFiles() { ParamsIOHelper = new IOTestHelper(new EditorParameters().File); @@ -28,6 +30,7 @@ namespace Microsoft.FancyZonesEditor.UITests.Utils DefaultLayoutsIOHelper = new IOTestHelper(new DefaultLayouts().File); LayoutHotkeysIOHelper = new IOTestHelper(new LayoutHotkeys().File); LayoutTemplatesIOHelper = new IOTestHelper(new LayoutTemplates().File); + AppZoneHistoryIOHelper = new IOTestHelper(new AppZoneHistory().File); } public void Restore() @@ -38,6 +41,7 @@ namespace Microsoft.FancyZonesEditor.UITests.Utils DefaultLayoutsIOHelper.RestoreData(); LayoutHotkeysIOHelper.RestoreData(); LayoutTemplatesIOHelper.RestoreData(); + AppZoneHistoryIOHelper.RestoreData(); } } } diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/Utils/FancyZonesEditorHelper.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/Utils/FancyZonesEditorHelper.cs similarity index 95% rename from src/modules/fancyzones/UITests-FancyZonesEditor/Utils/FancyZonesEditorHelper.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/Utils/FancyZonesEditorHelper.cs index 31afe73861..bf919949c7 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/Utils/FancyZonesEditorHelper.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/Utils/FancyZonesEditorHelper.cs @@ -41,9 +41,13 @@ namespace Microsoft.FancyZonesEditor.UnitTests.Utils public const string MainWindow = "MainWindow1"; public const string Monitors = "Monitors"; public const string NewLayoutButton = "NewLayoutButton"; + public const string LaunchLayoutEditorButton = "LaunchLayoutEditorButton"; // layout card public const string EditLayoutButton = "EditLayoutButton"; + public const string GridCustomLayoutCard = "GridcustomlayoutCard"; + public const string Grid9LayoutCard = "Grid-9Card"; + public const string CanvasCustomLayoutCard = "CanvascustomlayoutCard"; // edit layout window: common for template and custom layouts public const string DialogTitle = "EditLayoutDialogTitle"; @@ -117,9 +121,9 @@ namespace Microsoft.FancyZonesEditor.UnitTests.Utils session.Find<Element>(By.ClassName(ClassName.ContextMenu)).Find<Element>(menuItem).Click(); } - public static Element? GetZone(Session session, int zoneNumber, string zoneClassName) + public static Custom? GetZone(Session session, int zoneNumber, string zoneClassName) { - var zones = session.FindAll<Element>(By.ClassName(zoneClassName)); + var zones = session.FindAll<Custom>(By.ClassName(zoneClassName)); foreach (var zone in zones) { try @@ -157,7 +161,7 @@ namespace Microsoft.FancyZonesEditor.UnitTests.Utils public static void MoveSplitter(Session session, int index, int xOffset, int yOffset) { - var thumbs = session.FindAll<Element>(By.ClassName(ClassName.Thumb)); + var thumbs = session.FindAll<Thumb>(By.ClassName(ClassName.Thumb)); if (thumbs.Count == 0 || index >= thumbs.Count) { return; diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/Utils/IOTestHelper.cs b/src/modules/fancyzones/FancyZonesEditor.UITests/Utils/IOTestHelper.cs similarity index 95% rename from src/modules/fancyzones/UITests-FancyZonesEditor/Utils/IOTestHelper.cs rename to src/modules/fancyzones/FancyZonesEditor.UITests/Utils/IOTestHelper.cs index 7c52ca0c95..777d441cf2 100644 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/Utils/IOTestHelper.cs +++ b/src/modules/fancyzones/FancyZonesEditor.UITests/Utils/IOTestHelper.cs @@ -70,6 +70,12 @@ namespace Microsoft.FancyZonesEditor.UITests.Utils } } + // For get app zone history data + public string GetData() + { + return ReadFile(_file); + } + private string ReadFile(string fileName) { var attempts = 0; diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/release-test-checklist.md b/src/modules/fancyzones/FancyZonesEditor.UITests/release-test-checklist.md similarity index 100% rename from src/modules/fancyzones/UITests-FancyZonesEditor/release-test-checklist.md rename to src/modules/fancyzones/FancyZonesEditor.UITests/release-test-checklist.md diff --git a/src/modules/fancyzones/UnitTests-FancyZonesEditor/DefaultLayoutsModelTests.cs b/src/modules/fancyzones/FancyZonesEditor.UnitTests/DefaultLayoutsModelTests.cs similarity index 100% rename from src/modules/fancyzones/UnitTests-FancyZonesEditor/DefaultLayoutsModelTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UnitTests/DefaultLayoutsModelTests.cs diff --git a/src/modules/fancyzones/UnitTests-FancyZonesEditor/UnitTests-FancyZonesEditor.csproj b/src/modules/fancyzones/FancyZonesEditor.UnitTests/FancyZonesEditor.UnitTests.csproj similarity index 68% rename from src/modules/fancyzones/UnitTests-FancyZonesEditor/UnitTests-FancyZonesEditor.csproj rename to src/modules/fancyzones/FancyZonesEditor.UnitTests/FancyZonesEditor.UnitTests.csproj index 3884ad7ccf..d00e8b0917 100644 --- a/src/modules/fancyzones/UnitTests-FancyZonesEditor/UnitTests-FancyZonesEditor.csproj +++ b/src/modules/fancyzones/FancyZonesEditor.UnitTests/FancyZonesEditor.UnitTests.csproj @@ -1,14 +1,18 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> <IsPackable>false</IsPackable> <Nullable>enable</Nullable> - <OutputType>Exe</OutputType> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\UnitTest-FancyZonesEditor\</OutputPath> + <IsTestProject>true</IsTestProject> + <OutputType>Library</OutputType> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\UnitTest-FancyZonesEditor\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/fancyzones/UnitTests-FancyZonesEditor/GridLayoutModelTests.cs b/src/modules/fancyzones/FancyZonesEditor.UnitTests/GridLayoutModelTests.cs similarity index 100% rename from src/modules/fancyzones/UnitTests-FancyZonesEditor/GridLayoutModelTests.cs rename to src/modules/fancyzones/FancyZonesEditor.UnitTests/GridLayoutModelTests.cs diff --git a/src/modules/fancyzones/UnitTests-FancyZonesEditor/Usings.cs b/src/modules/fancyzones/FancyZonesEditor.UnitTests/Usings.cs similarity index 100% rename from src/modules/fancyzones/UnitTests-FancyZonesEditor/Usings.cs rename to src/modules/fancyzones/FancyZonesEditor.UnitTests/Usings.cs diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/AppliedLayouts.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/AppliedLayouts.cs index 188e834b37..2a5545d83b 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/AppliedLayouts.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/AppliedLayouts.cs @@ -12,7 +12,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\applied-layouts.json"; + return FancyZonesPaths.AppliedLayouts; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/CustomLayouts.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/CustomLayouts.cs index 110250ce02..4e3a2cc6bc 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/CustomLayouts.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/CustomLayouts.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Text.Json; +using System.Text.Json.Serialization; using static FancyZonesEditorCommon.Data.CustomLayouts; @@ -15,7 +16,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\custom-layouts.json"; + return FancyZonesPaths.CustomLayouts; } } @@ -23,8 +24,10 @@ namespace FancyZonesEditorCommon.Data { public struct CanvasZoneWrapper { + [JsonPropertyName("X")] public int X { get; set; } + [JsonPropertyName("Y")] public int Y { get; set; } public int Width { get; set; } @@ -78,24 +81,24 @@ namespace FancyZonesEditorCommon.Data public JsonElement ToJsonElement(CanvasInfoWrapper info) { - string json = JsonSerializer.Serialize(info, this.JsonOptions); + string json = JsonSerializer.Serialize(info, FancyZonesJsonContext.Default.CanvasInfoWrapper); return JsonSerializer.Deserialize<JsonElement>(json); } public JsonElement ToJsonElement(GridInfoWrapper info) { - string json = JsonSerializer.Serialize(info, this.JsonOptions); + string json = JsonSerializer.Serialize(info, FancyZonesJsonContext.Default.GridInfoWrapper); return JsonSerializer.Deserialize<JsonElement>(json); } public CanvasInfoWrapper CanvasFromJsonElement(string json) { - return JsonSerializer.Deserialize<CanvasInfoWrapper>(json, this.JsonOptions); + return JsonSerializer.Deserialize(json, FancyZonesJsonContext.Default.CanvasInfoWrapper); } public GridInfoWrapper GridFromJsonElement(string json) { - return JsonSerializer.Deserialize<GridInfoWrapper>(json, this.JsonOptions); + return JsonSerializer.Deserialize(json, FancyZonesJsonContext.Default.GridInfoWrapper); } } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/DefaultLayouts.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/DefaultLayouts.cs index 0a916559dc..03020c184f 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/DefaultLayouts.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/DefaultLayouts.cs @@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\default-layouts.json"; + return FancyZonesPaths.DefaultLayouts; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorData`1.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorData`1.cs index fdfa32cc28..83b35f4ac8 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorData`1.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorData`1.cs @@ -4,6 +4,7 @@ using System; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using FancyZonesEditorCommon.Utils; @@ -16,28 +17,20 @@ namespace FancyZonesEditorCommon.Data return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); } - protected JsonSerializerOptions JsonOptions - { - get - { - return new JsonSerializerOptions - { - PropertyNamingPolicy = new DashCaseNamingPolicy(), - WriteIndented = true, - }; - } - } + protected static JsonSerializerOptions JsonOptions => FancyZonesJsonContext.Default.Options; + + protected static JsonTypeInfo<T> TypeInfo => (JsonTypeInfo<T>)FancyZonesJsonContext.Default.GetTypeInfo(typeof(T)); public T Read(string file) { IOUtils ioUtils = new IOUtils(); string data = ioUtils.ReadFile(file); - return JsonSerializer.Deserialize<T>(data, JsonOptions); + return JsonSerializer.Deserialize(data, TypeInfo); } public string Serialize(T data) { - return JsonSerializer.Serialize(data, JsonOptions); + return JsonSerializer.Serialize(data, TypeInfo); } } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorParameters.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorParameters.cs index e973c6070f..fe1f023e0a 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorParameters.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorParameters.cs @@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\editor-parameters.json"; + return FancyZonesPaths.EditorParameters; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/FancyZonesJsonContext.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/FancyZonesJsonContext.cs new file mode 100644 index 0000000000..69fa8a97b7 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/FancyZonesJsonContext.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesEditorCommon.Data +{ + /// <summary> + /// JSON serialization context for AOT-compatible serialization of FancyZones data types. + /// </summary> + [JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.KebabCaseLower, + WriteIndented = true)] + [JsonSerializable(typeof(AppliedLayouts.AppliedLayoutsListWrapper))] + [JsonSerializable(typeof(AppliedLayouts.AppliedLayoutWrapper))] + [JsonSerializable(typeof(AppliedLayouts.AppliedLayoutWrapper.DeviceIdWrapper))] + [JsonSerializable(typeof(AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper), TypeInfoPropertyName = "AppliedLayoutLayoutWrapper")] + [JsonSerializable(typeof(CustomLayouts.CustomLayoutListWrapper))] + [JsonSerializable(typeof(CustomLayouts.CustomLayoutWrapper))] + [JsonSerializable(typeof(CustomLayouts.CanvasInfoWrapper))] + [JsonSerializable(typeof(CustomLayouts.CanvasInfoWrapper.CanvasZoneWrapper))] + [JsonSerializable(typeof(CustomLayouts.GridInfoWrapper))] + [JsonSerializable(typeof(LayoutTemplates.TemplateLayoutsListWrapper))] + [JsonSerializable(typeof(LayoutTemplates.TemplateLayoutWrapper))] + [JsonSerializable(typeof(LayoutHotkeys.LayoutHotkeysWrapper))] + [JsonSerializable(typeof(LayoutHotkeys.LayoutHotkeyWrapper))] + [JsonSerializable(typeof(EditorParameters.ParamsWrapper))] + [JsonSerializable(typeof(EditorParameters.NativeMonitorDataWrapper))] + [JsonSerializable(typeof(DefaultLayouts.DefaultLayoutsListWrapper))] + [JsonSerializable(typeof(DefaultLayouts.DefaultLayoutWrapper))] + [JsonSerializable(typeof(DefaultLayouts.DefaultLayoutWrapper.LayoutWrapper), TypeInfoPropertyName = "DefaultLayoutLayoutWrapper")] + public partial class FancyZonesJsonContext : JsonSerializerContext + { + } +} diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/FancyZonesPaths.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/FancyZonesPaths.cs new file mode 100644 index 0000000000..3d47f9e4c1 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/FancyZonesPaths.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; +using System.IO; + +namespace FancyZonesEditorCommon.Data; + +/// <summary> +/// Provides paths to FancyZones configuration files. +/// </summary> +public static class FancyZonesPaths +{ + private static readonly string DataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys", + "FancyZones"); + + public static string AppliedLayouts => Path.Combine(DataPath, "applied-layouts.json"); + + public static string CustomLayouts => Path.Combine(DataPath, "custom-layouts.json"); + + public static string LayoutTemplates => Path.Combine(DataPath, "layout-templates.json"); + + public static string LayoutHotkeys => Path.Combine(DataPath, "layout-hotkeys.json"); + + public static string EditorParameters => Path.Combine(DataPath, "editor-parameters.json"); + + public static string DefaultLayouts => Path.Combine(DataPath, "default-layouts.json"); +} diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutHotkeys.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutHotkeys.cs index 9b4fc2661f..1f4dfabf7f 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutHotkeys.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutHotkeys.cs @@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\layout-hotkeys.json"; + return FancyZonesPaths.LayoutHotkeys; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutTemplates.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutTemplates.cs index 4cb932571b..8dae1fbb3b 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutTemplates.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutTemplates.cs @@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\layout-templates.json"; + return FancyZonesPaths.LayoutTemplates; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/FancyZonesEditorCommon.csproj b/src/modules/fancyzones/FancyZonesEditorCommon/FancyZonesEditorCommon.csproj index 3b5d9d0d4f..ebed411a81 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/FancyZonesEditorCommon.csproj +++ b/src/modules/fancyzones/FancyZonesEditorCommon/FancyZonesEditorCommon.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <Description>PowerToys FancyZonesEditorCommon</Description> @@ -12,6 +12,6 @@ </ItemGroup> <PropertyGroup> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\FancyZonesEditorCommon\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\FancyZonesEditorCommon\</OutputPath> </PropertyGroup> </Project> diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Utils/FancyZonesDataIO.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Utils/FancyZonesDataIO.cs new file mode 100644 index 0000000000..e07fbde0f6 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Utils/FancyZonesDataIO.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using FancyZonesEditorCommon.Data; + +namespace FancyZonesEditorCommon.Utils +{ + /// <summary> + /// Unified helper for all FancyZones data file I/O operations. + /// Centralizes reading and writing of all JSON configuration files. + /// </summary> + public static class FancyZonesDataIO + { + private static TWrapper ReadData<TData, TWrapper>( + Func<TData> createInstance, + Func<TData, string> fileSelector, + Func<TData, string, TWrapper> readFunc) + { + var instance = createInstance(); + string filePath = fileSelector(instance); + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"File not found: {Path.GetFileName(filePath)}", filePath); + } + + return readFunc(instance, filePath); + } + + private static void WriteData<TData, TWrapper>( + Func<TData> createInstance, + Func<TData, string> fileSelector, + Func<TData, TWrapper, string> serializeFunc, + TWrapper data) + { + var instance = createInstance(); + var filePath = fileSelector(instance); + + IOUtils ioUtils = new IOUtils(); + ioUtils.WriteFile(filePath, serializeFunc(instance, data)); + } + + // AppliedLayouts operations + public static AppliedLayouts.AppliedLayoutsListWrapper ReadAppliedLayouts() + { + return ReadData( + () => new AppliedLayouts(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteAppliedLayouts(AppliedLayouts.AppliedLayoutsListWrapper data) + { + WriteData( + () => new AppliedLayouts(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // CustomLayouts operations + public static CustomLayouts.CustomLayoutListWrapper ReadCustomLayouts() + { + return ReadData( + () => new CustomLayouts(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteCustomLayouts(CustomLayouts.CustomLayoutListWrapper data) + { + WriteData( + () => new CustomLayouts(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // LayoutTemplates operations + public static LayoutTemplates.TemplateLayoutsListWrapper ReadLayoutTemplates() + { + return ReadData( + () => new LayoutTemplates(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteLayoutTemplates(LayoutTemplates.TemplateLayoutsListWrapper data) + { + WriteData( + () => new LayoutTemplates(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // LayoutHotkeys operations + public static LayoutHotkeys.LayoutHotkeysWrapper ReadLayoutHotkeys() + { + return ReadData( + () => new LayoutHotkeys(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteLayoutHotkeys(LayoutHotkeys.LayoutHotkeysWrapper data) + { + WriteData( + () => new LayoutHotkeys(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // EditorParameters operations + public static EditorParameters.ParamsWrapper ReadEditorParameters() + { + return ReadData( + () => new EditorParameters(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteEditorParameters(EditorParameters.ParamsWrapper data) + { + WriteData( + () => new EditorParameters(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // DefaultLayouts operations + public static DefaultLayouts.DefaultLayoutsListWrapper ReadDefaultLayouts() + { + return ReadData( + () => new DefaultLayouts(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteDefaultLayouts(DefaultLayouts.DefaultLayoutsListWrapper data) + { + WriteData( + () => new DefaultLayouts(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesLib/EditorParameters.cpp b/src/modules/fancyzones/FancyZonesLib/EditorParameters.cpp index b7a7ea4523..bd45c68950 100644 --- a/src/modules/fancyzones/FancyZonesLib/EditorParameters.cpp +++ b/src/modules/fancyzones/FancyZonesLib/EditorParameters.cpp @@ -191,27 +191,22 @@ bool EditorParameters::Save(const WorkAreaConfiguration& configuration, OnThread monitorJson.dpi = dpi; - MONITORINFOEX monitorInfo{}; + // Get DPI-unaware values for dimensions (virtual coordinates for WPF sizing) + MONITORINFOEX monitorInfoUnaware{}; dpiUnawareThread.submit(OnThreadExecutor::task_t{ [&] { - monitorInfo.cbSize = sizeof(monitorInfo); - if (!GetMonitorInfo(monitor, &monitorInfo)) - { - return; - } + monitorInfoUnaware.cbSize = sizeof(monitorInfoUnaware); + GetMonitorInfo(monitor, &monitorInfoUnaware); } }).wait(); - float width = static_cast<float>(monitorInfo.rcMonitor.right - monitorInfo.rcMonitor.left); - float height = static_cast<float>(monitorInfo.rcMonitor.bottom - monitorInfo.rcMonitor.top); - DPIAware::Convert(monitor, width, height); + // Dimensions in virtual coordinates (from DPI-unaware thread) + monitorJson.monitorWidth = monitorInfoUnaware.rcMonitor.right - monitorInfoUnaware.rcMonitor.left; + monitorJson.monitorHeight = monitorInfoUnaware.rcMonitor.bottom - monitorInfoUnaware.rcMonitor.top; + monitorJson.workAreaWidth = monitorInfoUnaware.rcWork.right - monitorInfoUnaware.rcWork.left; + monitorJson.workAreaHeight = monitorInfoUnaware.rcWork.bottom - monitorInfoUnaware.rcWork.top; - monitorJson.monitorWidth = static_cast<int>(std::roundf(width)); - monitorJson.monitorHeight = static_cast<int>(std::roundf(height)); - - // use dpi-unaware values - monitorJson.top = monitorInfo.rcWork.top; - monitorJson.left = monitorInfo.rcWork.left; - monitorJson.workAreaWidth = monitorInfo.rcWork.right - monitorInfo.rcWork.left; - monitorJson.workAreaHeight = monitorInfo.rcWork.bottom - monitorInfo.rcWork.top; + // Position in virtual coordinates (matched by DPI-unaware context in WPF editor) + monitorJson.left = monitorInfoUnaware.rcWork.left; + monitorJson.top = monitorInfoUnaware.rcWork.top; argsJson.monitors.emplace_back(std::move(monitorJson)); } diff --git a/src/modules/fancyzones/FancyZonesLib/FancyZones.cpp b/src/modules/fancyzones/FancyZonesLib/FancyZones.cpp index 1956c57a97..4e83a450f5 100644 --- a/src/modules/fancyzones/FancyZonesLib/FancyZones.cpp +++ b/src/modules/fancyzones/FancyZonesLib/FancyZones.cpp @@ -261,7 +261,7 @@ FancyZones::Run() noexcept } }) .wait(); - m_toggleEditorEventWaiter = EventWaiter(CommonSharedConstants::FANCY_ZONES_EDITOR_TOGGLE_EVENT, [&](int err) { + m_toggleEditorEventWaiter.start(CommonSharedConstants::FANCY_ZONES_EDITOR_TOGGLE_EVENT, [&](DWORD err) { if (err == ERROR_SUCCESS) { Logger::trace(L"{} event was signaled", CommonSharedConstants::FANCY_ZONES_EDITOR_TOGGLE_EVENT); @@ -728,6 +728,13 @@ LRESULT FancyZones::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lpa { FancyZonesSettings::instance().LoadSettings(); } + else if (message == WM_PRIV_SAVE_EDITOR_PARAMETERS) + { + if (!EditorParameters::Save(m_workAreaConfiguration, m_dpiUnawareThread)) + { + Logger::warn(L"Failed to save editor-parameters.json"); + } + } else { return DefWindowProc(window, message, wparam, lparam); diff --git a/src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj b/src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj index bff83a8d09..110c0ae69d 100644 --- a/src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj +++ b/src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h fancyzones.base.rc fancyzones.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h fancyzones.base.rc fancyzones.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> @@ -11,10 +12,9 @@ <RootNamespace>FancyZonesLib</RootNamespace> <ProjectName>FancyZonesLib</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -26,12 +26,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -140,31 +140,31 @@ </ItemGroup> <ItemGroup> <None Include="fancyzones.base.rc" /> - <ProjectReference Include="..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\notifications\notifications.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\notifications\notifications.vcxproj"> <Project>{1d5be09d-78c0-4fd7-af00-ae7c1af7c525}</Project> </ProjectReference> <ResourceCompile Include="Generated Files/fancyzones.rc" /> <None Include="packages.config" /> <None Include="Resources.resx" /> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.cpp b/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.cpp index 404486797a..94c07ebd24 100644 --- a/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.cpp +++ b/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.cpp @@ -18,6 +18,7 @@ UINT WM_PRIV_DEFAULT_LAYOUTS_FILE_UPDATE; UINT WM_PRIV_SNAP_HOTKEY; UINT WM_PRIV_QUICK_LAYOUT_KEY; UINT WM_PRIV_SETTINGS_CHANGED; +UINT WM_PRIV_SAVE_EDITOR_PARAMETERS; std::once_flag init_flag; @@ -40,5 +41,6 @@ void InitializeWinhookEventIds() WM_PRIV_SNAP_HOTKEY = RegisterWindowMessage(L"{72f4fd8e-23f1-43ab-bbbc-029363df9a84}"); WM_PRIV_QUICK_LAYOUT_KEY = RegisterWindowMessage(L"{15baab3d-c67b-4a15-aFF0-13610e05e947}"); WM_PRIV_SETTINGS_CHANGED = RegisterWindowMessage(L"{89ca3Daa-bf2d-4e73-9f3f-c60716364e27}"); + WM_PRIV_SAVE_EDITOR_PARAMETERS = RegisterWindowMessage(L"{d8f9c0e3-5d77-4e83-8a4f-7c704c2bfb4a}"); }); } diff --git a/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.h b/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.h index b00c8c1f8f..214a5b1f75 100644 --- a/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.h +++ b/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.h @@ -16,5 +16,6 @@ extern UINT WM_PRIV_DEFAULT_LAYOUTS_FILE_UPDATE; // Scheduled when the watched d extern UINT WM_PRIV_SNAP_HOTKEY; // Scheduled when we receive a snap hotkey key down press extern UINT WM_PRIV_QUICK_LAYOUT_KEY; // Scheduled when we receive a key down press to quickly apply a layout extern UINT WM_PRIV_SETTINGS_CHANGED; // Scheduled when a watched settings file is updated +extern UINT WM_PRIV_SAVE_EDITOR_PARAMETERS; // Scheduled to request saving editor-parameters.json void InitializeWinhookEventIds(); diff --git a/src/modules/fancyzones/FancyZonesLib/WindowKeyboardSnap.cpp b/src/modules/fancyzones/FancyZonesLib/WindowKeyboardSnap.cpp index 1ebab80f98..f982d5deea 100644 --- a/src/modules/fancyzones/FancyZonesLib/WindowKeyboardSnap.cpp +++ b/src/modules/fancyzones/FancyZonesLib/WindowKeyboardSnap.cpp @@ -474,7 +474,7 @@ bool WindowKeyboardSnap::Extend(HWND window, RECT windowRect, DWORD vkCode, Work } else { - auto deletethis = m_extendData.windowInitialIndexSet; + auto deleteThis = m_extendData.windowInitialIndexSet; m_extendData.windowFinalIndex = targetZone; resultIndexSet = layout->GetCombinedZoneRange(m_extendData.windowInitialIndexSet, { targetZone }); } diff --git a/src/modules/fancyzones/FancyZonesLib/WorkArea.cpp b/src/modules/fancyzones/FancyZonesLib/WorkArea.cpp index 638776c25a..a2cae59ca8 100644 --- a/src/modules/fancyzones/FancyZonesLib/WorkArea.cpp +++ b/src/modules/fancyzones/FancyZonesLib/WorkArea.cpp @@ -23,6 +23,7 @@ namespace NonLocalizable { const wchar_t ToolWindowClassName[] = L"FancyZones_ZonesOverlay"; + const wchar_t ToolWindowName[] = L"FancyZones_ZonesOverlay"; } using namespace FancyZonesUtils; @@ -59,7 +60,7 @@ namespace HWND windowFromPool = ExtractWindow(); if (windowFromPool == NULL) { - HWND window = CreateWindowExW(WS_EX_TOOLWINDOW, NonLocalizable::ToolWindowClassName, L"", WS_POPUP, position.left(), position.top(), position.width(), position.height(), nullptr, nullptr, hinstance, owner); + HWND window = CreateWindowExW(WS_EX_TOOLWINDOW, NonLocalizable::ToolWindowClassName, NonLocalizable::ToolWindowName, WS_POPUP, position.left(), position.top(), position.width(), position.height(), nullptr, nullptr, hinstance, owner); Logger::info("Creating new ZonesOverlay window, hWnd = {}", (void*)window); FancyZonesWindowUtils::MakeWindowTransparent(window); diff --git a/src/modules/fancyzones/FancyZonesLib/packages.config b/src/modules/fancyzones/FancyZonesLib/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/fancyzones/FancyZonesLib/packages.config +++ b/src/modules/fancyzones/FancyZonesLib/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj b/src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj index fcc2a70ef4..8b6bdecfa9 100644 --- a/src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj +++ b/src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{48804216-2A0E-4168-A6D8-9CD068D14227}</ProjectGuid> @@ -8,12 +9,11 @@ <RootNamespace>fancyzones</RootNamespace> <ProjectName>FancyZonesModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -23,13 +23,13 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.FancyZonesModuleInterface</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>FANCYZONES_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -48,13 +48,13 @@ </ClCompile> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> <ProjectReference Include="..\FancyZonesLib\FancyZonesLib.vcxproj"> @@ -69,16 +69,16 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/fancyzones/FancyZonesModuleInterface/packages.config b/src/modules/fancyzones/FancyZonesModuleInterface/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/fancyzones/FancyZonesModuleInterface/packages.config +++ b/src/modules/fancyzones/FancyZonesModuleInterface/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/fancyzones/FancyZonesTests/UnitTests/AppZoneHistoryTests.Spec.cpp b/src/modules/fancyzones/FancyZonesTests/UnitTests/AppZoneHistoryTests.Spec.cpp index c3eea23236..9b2b2b0503 100644 --- a/src/modules/fancyzones/FancyZonesTests/UnitTests/AppZoneHistoryTests.Spec.cpp +++ b/src/modules/fancyzones/FancyZonesTests/UnitTests/AppZoneHistoryTests.Spec.cpp @@ -233,7 +233,7 @@ namespace FancyZonesUnitTests Assert::IsFalse(AppZoneHistory::instance().SetAppLastZones(window, workAreaId, layoutId, { expectedZoneIndex })); } - TEST_METHOD (AppLastdeviceIdTest) + TEST_METHOD (AppLastDeviceIdTest) { const auto layoutId = FancyZonesUtils::GuidFromString(L"{2FEC41DA-3A0B-4E31-9CE1-9473C65D99F2}").value(); const FancyZonesDataTypes::WorkAreaId workAreaId1{ diff --git a/src/modules/fancyzones/FancyZonesTests/UnitTests/UnitTests.vcxproj b/src/modules/fancyzones/FancyZonesTests/UnitTests/UnitTests.vcxproj index 72f540c0dc..284f3397f5 100644 --- a/src/modules/fancyzones/FancyZonesTests/UnitTests/UnitTests.vcxproj +++ b/src/modules/fancyzones/FancyZonesTests/UnitTests/UnitTests.vcxproj @@ -1,22 +1,21 @@ <?xml version="1.0" encoding="utf-8"?> -<Project DefaultTargets="Build" - xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{9C6A7905-72D4-4BF5-B256-ABFDAEF68AE9}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>UnitTests</RootNamespace> <ProjectSubType>NativeUnitTestProject</ProjectSubType> - <ProjectName>UnitTests-FancyZones</ProjectName> + <ProjectName>FancyZones.UnitTests</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseOfMfc>false</UseOfMfc> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -32,7 +31,7 @@ </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\..\..\..\common\Telemetry;..\..\..\..\;..\..\;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;$(RepoRoot)src\;..\..\;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <PreprocessorDefinitions>UNIT_TESTS;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> @@ -68,10 +67,10 @@ <ClInclude Include="Util.h" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> <ProjectReference Include="..\..\FancyZonesLib\FancyZonesLib.vcxproj"> @@ -84,18 +83,18 @@ <ItemGroup> <ResourceCompile Include="UnitTests-FancyZones.rc" /> </ItemGroup> - <Import Project="..\..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> -</Project> \ No newline at end of file +</Project> diff --git a/src/modules/fancyzones/FancyZonesTests/UnitTests/packages.config b/src/modules/fancyzones/FancyZonesTests/UnitTests/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/fancyzones/FancyZonesTests/UnitTests/packages.config +++ b/src/modules/fancyzones/FancyZonesTests/UnitTests/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/fancyzones/UITests-FancyZones/UITests-FancyZones.csproj b/src/modules/fancyzones/UITests-FancyZones/UITests-FancyZones.csproj deleted file mode 100644 index 92ea952d3c..0000000000 --- a/src/modules/fancyzones/UITests-FancyZones/UITests-FancyZones.csproj +++ /dev/null @@ -1,28 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - - <PropertyGroup> - <ProjectGuid>{FE38FC07-1C05-4B57-ADA3-2FE2F53C6A52}</ProjectGuid> - <RootNamespace>Microsoft.FancyZones.UITests</RootNamespace> - <IsPackable>false</IsPackable> - <Nullable>enable</Nullable> - <OutputType>Exe</OutputType> - - <!-- This is a UI test, so don't run as part of the Test target --> - <TestingPlatformDisableCustomTestTarget>true</TestingPlatformDisableCustomTestTarget> - </PropertyGroup> - - <PropertyGroup> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\UITests-FancyZones\</OutputPath> - </PropertyGroup> - - <ItemGroup> - <PackageReference Include="Appium.WebDriver" /> - <PackageReference Include="MSTest" /> - </ItemGroup> - - <ItemGroup> - <Folder Include="Properties\" /> - </ItemGroup> -</Project> \ No newline at end of file diff --git a/src/modules/fancyzones/UITests-FancyZonesEditor/Utils/FancyZonesEditorSession.cs b/src/modules/fancyzones/UITests-FancyZonesEditor/Utils/FancyZonesEditorSession.cs deleted file mode 100644 index d8f5a84087..0000000000 --- a/src/modules/fancyzones/UITests-FancyZonesEditor/Utils/FancyZonesEditorSession.cs +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.IO; -using System.Reflection; - -using Microsoft.FancyZonesEditor.UITests.Utils; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using OpenQA.Selenium; -using OpenQA.Selenium.Appium; -using OpenQA.Selenium.Appium.Windows; -using OpenQA.Selenium.Interactions; - -namespace Microsoft.FancyZonesEditor.UnitTests.Utils -{ - public class FancyZonesEditorSession - { - protected const string WindowsApplicationDriverUrl = "http://127.0.0.1:4723"; - private const string FancyZonesEditorPath = @"\..\..\..\PowerToys.FancyZonesEditor.exe"; - - private static FancyZonesEditorFiles? _files; - - public static FancyZonesEditorFiles Files - { - get - { - if (_files == null) - { - _files = new FancyZonesEditorFiles(); - } - - return _files; - } - } - - public WindowsDriver<WindowsElement>? Session { get; } - - public WindowsElement? MainEditorWindow { get; } - - public FancyZonesEditorSession(TestContext testContext) - { - try - { - // Launch FancyZonesEditor - string? path = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - path += FancyZonesEditorPath; - - AppiumOptions opts = new AppiumOptions(); - opts.AddAdditionalCapability("app", path); - Session = new WindowsDriver<WindowsElement>(new Uri(WindowsApplicationDriverUrl), opts); - } - catch (Exception ex) - { - testContext.WriteLine(ex.Message); - } - - Assert.IsNotNull(Session, "Session not initialized"); - - testContext.WriteLine("Session: " + Session.SessionId.ToString()); - testContext.WriteLine("Title: " + Session.Title); - - // Set implicit timeout to make element search to retry every 500 ms - Session.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(3); - - // Find main editor window - try - { - MainEditorWindow = Session.FindElementByAccessibilityId("MainWindow1"); - } - catch - { - Assert.IsNotNull(MainEditorWindow, "Main editor window not found"); - } - } - - public void Close(TestContext testContext) - { - // Close the session - if (Session != null) - { - try - { - // FZEditor application can be closed by explicitly closing main editor window - MainEditorWindow?.SendKeys(Keys.Alt + Keys.F4); - } - catch (Exception ex) - { - testContext.WriteLine(ex.Message); - } - - Session.Quit(); - Session.Dispose(); - } - } - - private WindowsElement? GetLayout(string layoutName) - { - var listItem = Session?.FindElementByName(layoutName); - Assert.IsNotNull(listItem, "Layout " + layoutName + " not found"); - return listItem; - } - - public WindowsElement? OpenContextMenu(string layoutName) - { - RightClick_Layout(layoutName); - var menu = Session?.FindElementByClassName("ContextMenu"); - Assert.IsNotNull(menu, "Context menu not found"); - return menu; - } - - public void Click_CreateNewLayout() - { - var button = Session?.FindElementByAccessibilityId("NewLayoutButton"); - Assert.IsNotNull(button, "Create new layout button not found"); - button?.Click(); - } - - public void Click_EditLayout(string layoutName) - { - var layout = GetLayout(layoutName); - var editButton = layout?.FindElementByAccessibilityId("EditLayoutButton"); - Assert.IsNotNull(editButton, "Edit button not found"); - editButton.Click(); - } - - public void RightClick_Layout(string layoutName) - { - var layout = GetLayout(layoutName); - Actions actions = new Actions(Session); - actions.MoveToElement(layout); - actions.MoveByOffset(30, 30); - actions.ContextClick(); - actions.Build().Perform(); - } - } -} diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/EditorWindow.cs b/src/modules/fancyzones/editor/FancyZonesEditor/EditorWindow.cs index 2543568436..c6e5e5aec5 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/EditorWindow.cs +++ b/src/modules/fancyzones/editor/FancyZonesEditor/EditorWindow.cs @@ -4,7 +4,6 @@ using System; using System.Windows; - using FancyZonesEditor.Models; using ManagedCommon; diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj b/src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj index b7d7fcce1c..fe8a16b86f 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj +++ b/src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <AssemblyTitle>PowerToys.FancyZonesEditor</AssemblyTitle> @@ -14,7 +14,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <ProjectGuid>{5CCC8468-DEC8-4D36-99D4-5C891BEBD481}</ProjectGuid> <ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/MainWindow.xaml.cs b/src/modules/fancyzones/editor/FancyZonesEditor/MainWindow.xaml.cs index 3562175bca..eebd0bb54f 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/MainWindow.xaml.cs +++ b/src/modules/fancyzones/editor/FancyZonesEditor/MainWindow.xaml.cs @@ -563,7 +563,7 @@ namespace FancyZonesEditor private void SettingsBtn_Click(object sender, RoutedEventArgs e) { - SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.FancyZones, false); + SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.FancyZones); } private void EditLayoutDialogTitle_Loaded(object sender, RoutedEventArgs e) diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/Models/LayoutModel.cs b/src/modules/fancyzones/editor/FancyZonesEditor/Models/LayoutModel.cs index f176d4a4f0..97fcdbc400 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/Models/LayoutModel.cs +++ b/src/modules/fancyzones/editor/FancyZonesEditor/Models/LayoutModel.cs @@ -71,12 +71,22 @@ namespace FancyZonesEditor.Models { _name = value; FirePropertyChanged(nameof(Name)); + FirePropertyChanged(nameof(AutomationId)); } } } private string _name; + // AutomationId - used for UI automation testing + public virtual string AutomationId + { + get + { + return _name?.Replace(" ", string.Empty) + "Card"; + } + } + public LayoutType Type { get; set; } #pragma warning disable CA1720 // Identifier contains type name (Not worth the effort to change this now.) diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/Models/Monitor.cs b/src/modules/fancyzones/editor/FancyZonesEditor/Models/Monitor.cs index 301d12f783..72b7d03517 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/Models/Monitor.cs +++ b/src/modules/fancyzones/editor/FancyZonesEditor/Models/Monitor.cs @@ -67,10 +67,18 @@ namespace FancyZonesEditor.Models Window.KeyUp += ((App)Application.Current).App_KeyUp; Window.KeyDown += ((App)Application.Current).App_KeyDown; + // Store for DPI-unaware positioning + _virtualWorkArea = workArea; + + // Set initial WPF properties Window.Left = workArea.X; Window.Top = workArea.Y; Window.Width = workArea.Width; Window.Height = workArea.Height; + + // After HWND is created, reposition using DPI-unaware context + // This matches the C++ backend which uses a DPI-unaware thread + Window.SourceInitialized += OnWindowSourceInitialized; } public Monitor(string monitorName, string monitorInstanceId, string monitorSerialNumber, string virtualDesktop, int dpi, Rect workArea, Size monitorSize) @@ -80,16 +88,33 @@ namespace FancyZonesEditor.Models } private LayoutSettings _settings; + private Rect _virtualWorkArea; + + private void OnWindowSourceInitialized(object sender, EventArgs e) + { + // Reposition window using DPI-unaware context to match the virtual coordinates + // from the FancyZones C++ backend (which uses a DPI-unaware thread) + Utils.NativeMethods.SetWindowPositionDpiUnaware( + Window, + (int)_virtualWorkArea.X, + (int)_virtualWorkArea.Y, + (int)_virtualWorkArea.Width, + (int)_virtualWorkArea.Height); + } public void Scale(double scaleFactor) { Device.Scale(scaleFactor); - var workArea = Device.WorkAreaRect; - Window.Left = workArea.X; - Window.Top = workArea.Y; - Window.Width = workArea.Width; - Window.Height = workArea.Height; + _virtualWorkArea = Device.WorkAreaRect; + + // Use DPI-unaware positioning + Utils.NativeMethods.SetWindowPositionDpiUnaware( + Window, + (int)_virtualWorkArea.X, + (int)_virtualWorkArea.Y, + (int)_virtualWorkArea.Width, + (int)_virtualWorkArea.Height); } public void SetLayoutSettings(LayoutModel model) diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/Models/MonitorInfoModel.cs b/src/modules/fancyzones/editor/FancyZonesEditor/Models/MonitorInfoModel.cs index ec10dd500b..0356699b23 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/Models/MonitorInfoModel.cs +++ b/src/modules/fancyzones/editor/FancyZonesEditor/Models/MonitorInfoModel.cs @@ -69,7 +69,11 @@ namespace FancyZonesEditor.Utils } else { - return ScreenBoundsWidth + " × " + ScreenBoundsHeight; + // Convert virtual coordinates to physical resolution by applying DPI scale + double scale = DPI / 96.0; + int physicalWidth = (int)Math.Round(ScreenBoundsWidth * scale); + int physicalHeight = (int)Math.Round(ScreenBoundsHeight * scale); + return physicalWidth + " × " + physicalHeight; } } } diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/Properties/Resources.resx b/src/modules/fancyzones/editor/FancyZonesEditor/Properties/Resources.resx index c93e4ca459..079382baa9 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/Properties/Resources.resx +++ b/src/modules/fancyzones/editor/FancyZonesEditor/Properties/Resources.resx @@ -359,7 +359,7 @@ </data> <data name="MergeName" xml:space="preserve"> <value>Merge/Delete:</value> - <comment>Title for concept behind Merging two zones together or removing an zone</comment> + <comment>Title for concept behind Merging two zones together or removing a zone</comment> </data> <data name="KeyboardControlsName" xml:space="preserve"> <value>Keyboard navigation:</value> diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/Styles/GridViewStyles.xaml b/src/modules/fancyzones/editor/FancyZonesEditor/Styles/GridViewStyles.xaml index a76a649596..e9c37532ac 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/Styles/GridViewStyles.xaml +++ b/src/modules/fancyzones/editor/FancyZonesEditor/Styles/GridViewStyles.xaml @@ -145,6 +145,7 @@ <Setter Property="Background" Value="{DynamicResource LayoutItemBackgroundBrush}" /> <Setter Property="IsSelected" Value="{Binding IsApplied, Mode=OneWay}" /> <Setter Property="AutomationProperties.Name" Value="{Binding Name}" /> + <Setter Property="AutomationProperties.AutomationId" Value="{Binding AutomationId}" /> <Setter Property="KeyboardNavigation.TabNavigation" Value="Local" /> <!--<Setter Property="IsHoldingEnabled" Value="True" />--> <Setter Property="CornerRadius" Value="4" /> diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/Telemetry/FancyZonesEditorStartEvent.cs b/src/modules/fancyzones/editor/FancyZonesEditor/Telemetry/FancyZonesEditorStartEvent.cs index 8abf27f8e6..0a6f2529d8 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/Telemetry/FancyZonesEditorStartEvent.cs +++ b/src/modules/fancyzones/editor/FancyZonesEditor/Telemetry/FancyZonesEditorStartEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace FancyZoneEditor.Telemetry; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class FancyZonesEditorStartEvent() : EventBase, IEvent { public long TimeStamp { get; set; } diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/Telemetry/FancyZonesEditorStartFinishEvent.cs b/src/modules/fancyzones/editor/FancyZonesEditor/Telemetry/FancyZonesEditorStartFinishEvent.cs index f3d7ae19c2..630d59b23e 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/Telemetry/FancyZonesEditorStartFinishEvent.cs +++ b/src/modules/fancyzones/editor/FancyZonesEditor/Telemetry/FancyZonesEditorStartFinishEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace FancyZoneEditor.Telemetry; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class FancyZonesEditorStartFinishEvent() : EventBase, IEvent { public long TimeStamp { get; set; } diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/Utils/NativeMethods.cs b/src/modules/fancyzones/editor/FancyZonesEditor/Utils/NativeMethods.cs index b5d4db9952..5c42f3011d 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/Utils/NativeMethods.cs +++ b/src/modules/fancyzones/editor/FancyZonesEditor/Utils/NativeMethods.cs @@ -1,4 +1,4 @@ -// 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. @@ -17,14 +17,48 @@ namespace FancyZonesEditor.Utils [DllImport("user32.dll")] private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); + [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 int GWL_EX_STYLE = -20; private const int WS_EX_APPWINDOW = 0x00040000; private const int WS_EX_TOOLWINDOW = 0x00000080; + private const uint SWP_NOZORDER = 0x0004; + private const uint SWP_NOACTIVATE = 0x0010; + + private static readonly IntPtr DPI_AWARENESS_CONTEXT_UNAWARE = new IntPtr(-1); public static void SetWindowStyleToolWindow(Window hwnd) { var helper = new WindowInteropHelper(hwnd).Handle; _ = SetWindowLong(helper, GWL_EX_STYLE, (GetWindowLong(helper, GWL_EX_STYLE) | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW); } + + /// <summary> + /// Positions a WPF window using DPI-unaware context to match the virtual coordinates + /// from the FancyZones C++ backend (which uses a DPI-unaware thread). + /// This fixes overlay positioning on mixed-DPI multi-monitor setups. + /// </summary> + 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. + // This matches how the C++ backend gets coordinates via dpiUnawareThread. + IntPtr oldContext = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_UNAWARE); + try + { + SetWindowPos(helper, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE); + } + finally + { + SetThreadDpiAwarenessContext(oldContext); + } + } + } } } diff --git a/src/modules/fancyzones/editor/FancyZonesEditor/app.manifest b/src/modules/fancyzones/editor/FancyZonesEditor/app.manifest index 598c47dd41..98afec4cae 100644 --- a/src/modules/fancyzones/editor/FancyZonesEditor/app.manifest +++ b/src/modules/fancyzones/editor/FancyZonesEditor/app.manifest @@ -51,7 +51,7 @@ also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. --> <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">System</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj b/src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj new file mode 100644 index 0000000000..05efacafd8 --- /dev/null +++ b/src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj @@ -0,0 +1,28 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> + + <PropertyGroup> + <AssemblyTitle>PowerToys.ImageResizerCLI</AssemblyTitle> + <AssemblyDescription>PowerToys Image Resizer Command Line Interface</AssemblyDescription> + <Description>PowerToys Image Resizer CLI</Description> + <OutputType>Exe</OutputType> + <Platforms>x64;ARM64</Platforms> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutputPath> + <AssemblyName>PowerToys.ImageResizerCLI</AssemblyName> + <NoWarn>$(NoWarn);SA1500;SA1402;CA1852</NoWarn> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\ui\ImageResizerUI.csproj" /> + </ItemGroup> + + <!-- Force using WindowsDesktop runtime to ensure consistent dll versions with other projects --> + <ItemGroup> + <FrameworkReference Include="Microsoft.WindowsDesktop.App.WPF" /> + </ItemGroup> +</Project> diff --git a/src/modules/imageresizer/ImageResizerCLI/Program.cs b/src/modules/imageresizer/ImageResizerCLI/Program.cs new file mode 100644 index 0000000000..d24ab93bde --- /dev/null +++ b/src/modules/imageresizer/ImageResizerCLI/Program.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; +using System.Globalization; +using System.Text; + +using ImageResizer.Cli; +using ManagedCommon; + +namespace ImageResizerCLI; + +internal static class Program +{ + private static int Main(string[] args) + { + try + { + string appLanguage = LanguageHelper.LoadLanguage(); + if (!string.IsNullOrEmpty(appLanguage)) + { + System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(appLanguage); + } + } + catch (CultureNotFoundException) + { + // Ignore invalid culture and fall back to default. + } + + Console.InputEncoding = Encoding.Unicode; + + // Initialize logger to file (same as other modules) + CliLogger.Initialize("\\Image Resizer\\CLI"); + CliLogger.Info($"ImageResizerCLI started with {args.Length} argument(s)"); + + try + { + var executor = new ImageResizerCliExecutor(); + return executor.Run(args); + } + catch (Exception ex) + { + CliLogger.Error($"Unhandled exception: {ex.Message}"); + CliLogger.Error($"Stack trace: {ex.StackTrace}"); + Console.Error.WriteLine($"Fatal error: {ex.Message}"); + return 1; + } + } +} diff --git a/src/modules/imageresizer/ImageResizerContextMenu/ImageResizerContextMenu.vcxproj b/src/modules/imageresizer/ImageResizerContextMenu/ImageResizerContextMenu.vcxproj index e3303f759b..5e63e8f1d8 100644 --- a/src/modules/imageresizer/ImageResizerContextMenu/ImageResizerContextMenu.vcxproj +++ b/src/modules/imageresizer/ImageResizerContextMenu/ImageResizerContextMenu.vcxproj @@ -1,24 +1,24 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ImageResizerContextMenu.base.rc ImageResizerContextMenu.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ImageResizerContextMenu.base.rc ImageResizerContextMenu.rc" /> </Target> <PropertyGroup Label="Globals"> <Keyword>Win32Proj</Keyword> <ProjectGuid>{93b72a06-c8bd-484f-a6f7-c9f280b150bf}</ProjectGuid> <RootNamespace>ImageResizerContextMenu</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> @@ -34,7 +34,7 @@ <TargetName>PowerToys.ImageResizerContextMenu</TargetName> <!-- Needs a different int dir to avoid conflicts in msix creation. --> <IntDir>$(SolutionDir)$(Platform)\$(Configuration)\TemporaryBuild\obj\$(ProjectName)\</IntDir> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> <ClCompile> @@ -42,7 +42,7 @@ <SDLCheck>true</SDLCheck> <PreprocessorDefinitions>_DEBUG;IMAGERESIZERCONTEXTMENU_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>..\ImageResizerLib;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\ImageResizerLib;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -63,7 +63,7 @@ MakeAppx.exe pack /d . /p $(OutDir)ImageResizerContextMenuPackage.msix /nv</Comm <SDLCheck>true</SDLCheck> <PreprocessorDefinitions>NDEBUG;IMAGERESIZERCONTEXTMENU_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>..\ImageResizerLib;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\ImageResizerLib;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -125,13 +125,13 @@ MakeAppx.exe pack /d . /p $(OutDir)ImageResizerContextMenuPackage.msix /nv</Comm </None> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> <ProjectReference Include="..\ImageResizerLib\ImageResizerLib.vcxproj"> @@ -142,17 +142,17 @@ MakeAppx.exe pack /d . /p $(OutDir)ImageResizerContextMenuPackage.msix /nv</Comm <None Include="Resources.resx" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/imageresizer/ImageResizerContextMenu/packages.config b/src/modules/imageresizer/ImageResizerContextMenu/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/imageresizer/ImageResizerContextMenu/packages.config +++ b/src/modules/imageresizer/ImageResizerContextMenu/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/imageresizer/ImageResizerLib/ImageResizerLib.vcxproj b/src/modules/imageresizer/ImageResizerLib/ImageResizerLib.vcxproj index 78b5085908..7ab7611302 100644 --- a/src/modules/imageresizer/ImageResizerLib/ImageResizerLib.vcxproj +++ b/src/modules/imageresizer/ImageResizerLib/ImageResizerLib.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <Keyword>Win32Proj</Keyword> <ProjectGuid>{18b3db45-4ffe-4d01-97d6-5223feee1853}</ProjectGuid> @@ -8,10 +9,9 @@ </PropertyGroup> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <UseDebugLibraries>true</UseDebugLibraries> </PropertyGroup> @@ -29,7 +29,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -38,7 +38,7 @@ <ClCompile> <WarningLevel>Level3</WarningLevel> <PreprocessorDefinitions>WIN32;_DEBUG;_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\Telemetry;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem> @@ -52,7 +52,7 @@ <FunctionLevelLinking>true</FunctionLevelLinking> <IntrinsicFunctions>true</IntrinsicFunctions> <PreprocessorDefinitions>WIN32;NDEBUG;_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\Telemetry;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem> @@ -81,13 +81,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/imageresizer/ImageResizerLib/packages.config b/src/modules/imageresizer/ImageResizerLib/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/imageresizer/ImageResizerLib/packages.config +++ b/src/modules/imageresizer/ImageResizerLib/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/imageresizer/dll/ImageResizerExt.vcxproj b/src/modules/imageresizer/dll/ImageResizerExt.vcxproj index df038e2c43..ede7dea78d 100644 --- a/src/modules/imageresizer/dll/ImageResizerExt.vcxproj +++ b/src/modules/imageresizer/dll/ImageResizerExt.vcxproj @@ -1,21 +1,21 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ImageResizerExt.base.rc ImageResizerExt.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h ImageResizerExt.base.rc ImageResizerExt.rc" /> </Target> <PropertyGroup Label="Globals"> <ProjectGuid>{0B43679E-EDFA-4DA0-AD30-F4628B308B1B}</ProjectGuid> <Keyword>AtlProj</Keyword> <CppWinRTModernIDL>false</CppWinRTModernIDL> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseOfAtl>Static</UseOfAtl> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -26,13 +26,13 @@ <PropertyGroup Label="UserMacros" /> <PropertyGroup> <IgnoreImportLibrary>true</IgnoreImportLibrary> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.ImageResizerExt</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\ImageResizerLib;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\ImageResizerLib;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <ModuleDefinitionFile>.\ImageResizerExt.def</ModuleDefinitionFile> @@ -100,6 +100,7 @@ <None Include="resource.base.h" /> <ClInclude Include="Generated Files/resource.h" /> <ClInclude Include="ImageResizerExt_i.h" /> + <ClInclude Include="RuntimeRegistration.h" /> <ClInclude Include="pch.h" /> <ClInclude Include="targetver.h" /> </ItemGroup> @@ -116,16 +117,16 @@ <Midl Include="ImageResizerExt.idl" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> <ProjectReference Include="..\ImageResizerLib\ImageResizerLib.vcxproj"> @@ -139,15 +140,15 @@ <None Include="Resources.resx" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/imageresizer/dll/ImageResizerExt.vcxproj.filters b/src/modules/imageresizer/dll/ImageResizerExt.vcxproj.filters index 8ec4d09d40..5d58153793 100644 --- a/src/modules/imageresizer/dll/ImageResizerExt.vcxproj.filters +++ b/src/modules/imageresizer/dll/ImageResizerExt.vcxproj.filters @@ -54,6 +54,9 @@ <ClInclude Include="Generated Files/resource.h"> <Filter>Generated Files</Filter> </ClInclude> + <ClInclude Include="RuntimeRegistration.h"> + <Filter>Header Files</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <None Include="ImageResizerExt.rgs"> diff --git a/src/modules/imageresizer/dll/RuntimeRegistration.h b/src/modules/imageresizer/dll/RuntimeRegistration.h new file mode 100644 index 0000000000..f9369da1c4 --- /dev/null +++ b/src/modules/imageresizer/dll/RuntimeRegistration.h @@ -0,0 +1,38 @@ +// Header-only runtime registration for ImageResizer shell extension. +#pragma once + +#include <common/utils/shell_ext_registration.h> + +extern "C" IMAGE_DOS_HEADER __ImageBase; // provided by linker + +namespace ImageResizerRuntimeRegistration +{ + namespace + { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{51B4D7E5-7568-4234-B4BB-47FB3C016A69}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\ImageResizer"; + spec.sentinelValue = L"ContextMenuRegistered"; + spec.dllFileCandidates = { L"PowerToys.ImageResizerExt.dll" }; + spec.contextMenuHandlerKeyPaths = { }; + spec.systemFileAssocHandlerName = L"ImageResizer"; + spec.systemFileAssocExtensions = { L".bmp", L".dib", L".gif", L".jfif", L".jpe", L".jpeg", L".jpg", L".jxr", L".png", L".rle", L".tif", L".tiff", L".wdp" }; + spec.representativeSystemExt = L".png"; // probe for repair + spec.extraAssociationPaths = { L"Software\\Classes\\Directory\\ShellEx\\DragDropHandlers\\ImageResizer" }; + spec.friendlyName = L"ImageResizer Shell Extension"; + return spec; + } + } + + inline bool EnsureRegistered() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), reinterpret_cast<HMODULE>(&__ImageBase)); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/imageresizer/dll/dllmain.cpp b/src/modules/imageresizer/dll/dllmain.cpp index 4786388f06..c616e77e42 100644 --- a/src/modules/imageresizer/dll/dllmain.cpp +++ b/src/modules/imageresizer/dll/dllmain.cpp @@ -14,6 +14,7 @@ #include <common/utils/resources.h> #include <common/utils/logger_helper.h> #include <interface/powertoy_module_interface.h> +#include "RuntimeRegistration.h" CImageResizerExtModule _AtlModule; HINSTANCE g_hInst_imageResizer = 0; @@ -42,11 +43,32 @@ private: //contains the non localized key of the powertoy std::wstring app_key; + // Update registration based on enabled state + void UpdateRegistration(bool enabled) + { + if (enabled) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + ImageResizerRuntimeRegistration::EnsureRegistered(); + Logger::info(L"ImageResizer context menu registered"); +#endif + } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + ImageResizerRuntimeRegistration::Unregister(); + Logger::info(L"ImageResizer context menu unregistered"); +#endif + } + } + + public: // Constructor ImageResizerModule() { m_enabled = CSettingsInstance().GetEnabled(); + UpdateRegistration(m_enabled); app_name = GET_RESOURCE_STRING(IDS_IMAGERESIZER); app_key = ImageResizerConstants::ModuleKey; LoggerHelpers::init_logger(app_key, L"ModuleInterface", LogSettings::imageResizerLoggerName); @@ -106,13 +128,12 @@ public: { std::wstring path = get_module_folderpath(g_hInst_imageResizer); std::wstring packageUri = path + L"\\ImageResizerContextMenuPackage.msix"; - if (!package::IsPackageRegisteredWithPowerToysVersion(ImageResizerConstants::ModulePackageDisplayName)) { package::RegisterSparsePackage(path, packageUri); } } - + UpdateRegistration(m_enabled); Trace::EnableImageResizer(m_enabled); } @@ -120,6 +141,7 @@ public: virtual void disable() { m_enabled = false; + UpdateRegistration(m_enabled); Trace::EnableImageResizer(m_enabled); } diff --git a/src/modules/imageresizer/dll/packages.config b/src/modules/imageresizer/dll/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/imageresizer/dll/packages.config +++ b/src/modules/imageresizer/dll/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/imageresizer/tests/Cli/CliSettingsApplierTests.cs b/src/modules/imageresizer/tests/Cli/CliSettingsApplierTests.cs new file mode 100644 index 0000000000..20169bff7f --- /dev/null +++ b/src/modules/imageresizer/tests/Cli/CliSettingsApplierTests.cs @@ -0,0 +1,320 @@ +// 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 ImageResizer.Cli; +using ImageResizer.Models; +using ImageResizer.Properties; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ImageResizer.Tests.Cli +{ + [TestClass] + public class CliSettingsApplierTests + { + private Settings CreateDefaultSettings() + { + var settings = new Settings(); + settings.Sizes.Add(new ResizeSize(0, "Small", ResizeFit.Fit, 854, 480, ResizeUnit.Pixel)); + settings.Sizes.Add(new ResizeSize(1, "Medium", ResizeFit.Fit, 1366, 768, ResizeUnit.Pixel)); + settings.Sizes.Add(new ResizeSize(2, "Large", ResizeFit.Fit, 1920, 1080, ResizeUnit.Pixel)); + return settings; + } + + [TestMethod] + public void Apply_WithCustomWidth_SetsCustomSizeWidth() + { + var options = new CliOptions { Width = 800 }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(800.0, settings.CustomSize.Width); + } + + [TestMethod] + public void Apply_WithCustomHeight_SetsCustomSizeHeight() + { + var options = new CliOptions { Height = 600 }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(600.0, settings.CustomSize.Height); + } + + [TestMethod] + public void Apply_WithCustomSize_SelectsCustomSizeIndex() + { + var options = new CliOptions { Width = 800, Height = 600 }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + // Custom size index should be settings.Sizes.Count + Assert.AreEqual(settings.Sizes.Count, settings.SelectedSizeIndex); + } + + [TestMethod] + public void Apply_WithZeroWidth_SetsZeroForAutoCalculation() + { + var options = new CliOptions { Width = 0, Height = 600 }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(0.0, settings.CustomSize.Width); + Assert.AreEqual(600.0, settings.CustomSize.Height); + } + + [TestMethod] + public void Apply_WithZeroHeight_SetsZeroForAutoCalculation() + { + var options = new CliOptions { Width = 800, Height = 0 }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(800.0, settings.CustomSize.Width); + Assert.AreEqual(0.0, settings.CustomSize.Height); + } + + [TestMethod] + public void Apply_WithNullWidthAndHeight_DoesNotModifyCustomSize() + { + var options = new CliOptions { Width = null, Height = null }; + var settings = CreateDefaultSettings(); + var originalWidth = settings.CustomSize.Width; + var originalHeight = settings.CustomSize.Height; + + CliSettingsApplier.Apply(options, settings); + + // When both null, should not modify CustomSize (keeps default 1024x640) + Assert.AreEqual(originalWidth, settings.CustomSize.Width); + Assert.AreEqual(originalHeight, settings.CustomSize.Height); + } + + [TestMethod] + public void Apply_WithUnit_SetsCustomSizeUnit() + { + var options = new CliOptions { Width = 100, Unit = ResizeUnit.Percent }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(ResizeUnit.Percent, settings.CustomSize.Unit); + } + + [TestMethod] + public void Apply_WithFit_SetsCustomSizeFit() + { + var options = new CliOptions { Width = 800, Fit = ResizeFit.Fill }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(ResizeFit.Fill, settings.CustomSize.Fit); + } + + [TestMethod] + public void Apply_WithValidSizeIndex_SetsSelectedSizeIndex() + { + var options = new CliOptions { SizeIndex = 1 }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(1, settings.SelectedSizeIndex); + } + + [TestMethod] + public void Apply_WithInvalidSizeIndex_DoesNotChangeSelection() + { + var options = new CliOptions { SizeIndex = 99 }; + var settings = CreateDefaultSettings(); + var originalIndex = settings.SelectedSizeIndex; + + CliSettingsApplier.Apply(options, settings); + + // Should remain unchanged when invalid + Assert.AreEqual(originalIndex, settings.SelectedSizeIndex); + } + + [TestMethod] + public void Apply_WithNegativeSizeIndex_DoesNotChangeSelection() + { + var options = new CliOptions { SizeIndex = -1 }; + var settings = CreateDefaultSettings(); + var originalIndex = settings.SelectedSizeIndex; + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(originalIndex, settings.SelectedSizeIndex); + } + + [TestMethod] + public void Apply_WithShrinkOnly_SetsShrinkOnly() + { + var options = new CliOptions { ShrinkOnly = true }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.IsTrue(settings.ShrinkOnly); + } + + [TestMethod] + public void Apply_WithReplace_SetsReplace() + { + var options = new CliOptions { Replace = true }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.IsTrue(settings.Replace); + } + + [TestMethod] + public void Apply_WithIgnoreOrientation_SetsIgnoreOrientation() + { + var options = new CliOptions { IgnoreOrientation = true }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.IsTrue(settings.IgnoreOrientation); + } + + [TestMethod] + public void Apply_WithRemoveMetadata_SetsRemoveMetadata() + { + var options = new CliOptions { RemoveMetadata = true }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.IsTrue(settings.RemoveMetadata); + } + + [TestMethod] + public void Apply_WithJpegQualityLevel_SetsJpegQualityLevel() + { + var options = new CliOptions { JpegQualityLevel = 85 }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(85, settings.JpegQualityLevel); + } + + [TestMethod] + public void Apply_WithKeepDateModified_SetsKeepDateModified() + { + var options = new CliOptions { KeepDateModified = true }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.IsTrue(settings.KeepDateModified); + } + + [TestMethod] + public void Apply_WithFileName_SetsFileName() + { + var options = new CliOptions { FileName = "%1 (%2)" }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual("%1 (%2)", settings.FileName); + } + + [TestMethod] + public void Apply_WithEmptyFileName_DoesNotChangeFileName() + { + var options = new CliOptions { FileName = string.Empty }; + var settings = CreateDefaultSettings(); + var originalFileName = settings.FileName; + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(originalFileName, settings.FileName); + } + + [TestMethod] + public void Apply_WithMultipleOptions_AppliesAllOptions() + { + var options = new CliOptions + { + Width = 800, + Height = 600, + Unit = ResizeUnit.Percent, + Fit = ResizeFit.Fill, + ShrinkOnly = true, + Replace = true, + IgnoreOrientation = true, + RemoveMetadata = true, + JpegQualityLevel = 90, + KeepDateModified = true, + FileName = "test_%2", + }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(800.0, settings.CustomSize.Width); + Assert.AreEqual(600.0, settings.CustomSize.Height); + Assert.AreEqual(ResizeUnit.Percent, settings.CustomSize.Unit); + Assert.AreEqual(ResizeFit.Fill, settings.CustomSize.Fit); + Assert.IsTrue(settings.ShrinkOnly); + Assert.IsTrue(settings.Replace); + Assert.IsTrue(settings.IgnoreOrientation); + Assert.IsTrue(settings.RemoveMetadata); + Assert.AreEqual(90, settings.JpegQualityLevel); + Assert.IsTrue(settings.KeepDateModified); + Assert.AreEqual("test_%2", settings.FileName); + } + + [TestMethod] + public void Apply_CustomSizeTakesPrecedence_OverSizeIndex() + { + var options = new CliOptions + { + Width = 800, + Height = 600, + SizeIndex = 1, // Should be ignored when Width/Height specified + }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + // Custom size should be selected, not preset + Assert.AreEqual(settings.Sizes.Count, settings.SelectedSizeIndex); + Assert.AreEqual(800.0, settings.CustomSize.Width); + } + + [TestMethod] + public void Apply_WithOnlyWidth_StillSelectsCustomSize() + { + var options = new CliOptions { Width = 800 }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(settings.Sizes.Count, settings.SelectedSizeIndex); + Assert.AreEqual(800.0, settings.CustomSize.Width); + } + + [TestMethod] + public void Apply_WithOnlyHeight_StillSelectsCustomSize() + { + var options = new CliOptions { Height = 600 }; + var settings = CreateDefaultSettings(); + + CliSettingsApplier.Apply(options, settings); + + Assert.AreEqual(settings.Sizes.Count, settings.SelectedSizeIndex); + Assert.AreEqual(600.0, settings.CustomSize.Height); + } + } +} diff --git a/src/modules/imageresizer/tests/ImageResizerUITest.csproj b/src/modules/imageresizer/tests/ImageResizer.UnitTests.csproj similarity index 86% rename from src/modules/imageresizer/tests/ImageResizerUITest.csproj rename to src/modules/imageresizer/tests/ImageResizer.UnitTests.csproj index 2f9f285ac7..4588574c62 100644 --- a/src/modules/imageresizer/tests/ImageResizerUITest.csproj +++ b/src/modules/imageresizer/tests/ImageResizer.UnitTests.csproj @@ -1,11 +1,14 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{E0CC7526-D85E-43AC-844F-D5DF0D2F5AB8}</ProjectGuid> - <OutputType>Exe</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> + <OutputType>Exe</OutputType> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <AppDesignerFolder>Properties</AppDesignerFolder> <RootNamespace>ImageResizer</RootNamespace> <AssemblyName>ImageResizer.Test</AssemblyName> diff --git a/src/modules/imageresizer/tests/Models/CliOptionsTests.cs b/src/modules/imageresizer/tests/Models/CliOptionsTests.cs new file mode 100644 index 0000000000..3c88a100ba --- /dev/null +++ b/src/modules/imageresizer/tests/Models/CliOptionsTests.cs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using ImageResizer.Cli.Commands; +using ImageResizer.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ImageResizer.Tests.Models +{ + [TestClass] + public class CliOptionsTests + { + private static readonly string[] _multiFileArgs = new[] { "test1.jpg", "test2.jpg", "test3.jpg" }; + private static readonly string[] _mixedOptionsArgs = new[] { "--width", "800", "test1.jpg", "--height", "600", "test2.jpg" }; + + [TestMethod] + public void Parse_WithValidWidth_SetsWidth() + { + var args = new[] { "--width", "800", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(800.0, options.Width); + } + + [TestMethod] + public void Parse_WithValidHeight_SetsHeight() + { + var args = new[] { "--height", "600", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(600.0, options.Height); + } + + [TestMethod] + public void Parse_WithShortWidthAlias_WorksIdentically() + { + var longFormArgs = new[] { "--width", "800", "test.jpg" }; + var shortFormArgs = new[] { "-w", "800", "test.jpg" }; + var longForm = CliOptions.Parse(longFormArgs); + var shortForm = CliOptions.Parse(shortFormArgs); + + Assert.AreEqual(longForm.Width, shortForm.Width); + } + + [TestMethod] + public void Parse_WithShortHeightAlias_WorksIdentically() + { + var longFormArgs = new[] { "--height", "600", "test.jpg" }; + var shortFormArgs = new[] { "-h", "600", "test.jpg" }; + var longForm = CliOptions.Parse(longFormArgs); + var shortForm = CliOptions.Parse(shortFormArgs); + + Assert.AreEqual(longForm.Height, shortForm.Height); + } + + [TestMethod] + public void Parse_WithValidUnit_SetsUnit() + { + var args = new[] { "--unit", "Percent", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(ResizeUnit.Percent, options.Unit); + } + + [TestMethod] + public void Parse_WithValidFit_SetsFit() + { + var args = new[] { "--fit", "Fill", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(ResizeFit.Fill, options.Fit); + } + + [TestMethod] + public void Parse_WithSizeIndex_SetsSizeIndex() + { + var args = new[] { "--size", "2", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(2, options.SizeIndex); + } + + [TestMethod] + public void Parse_WithShrinkOnly_SetsShrinkOnly() + { + var args = new[] { "--shrink-only", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(true, options.ShrinkOnly); + } + + [TestMethod] + public void Parse_WithReplace_SetsReplace() + { + var args = new[] { "--replace", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(true, options.Replace); + } + + [TestMethod] + public void Parse_WithIgnoreOrientation_SetsIgnoreOrientation() + { + var args = new[] { "--ignore-orientation", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(true, options.IgnoreOrientation); + } + + [TestMethod] + public void Parse_WithRemoveMetadata_SetsRemoveMetadata() + { + var args = new[] { "--remove-metadata", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(true, options.RemoveMetadata); + } + + [TestMethod] + public void Parse_WithValidQuality_SetsQuality() + { + var args = new[] { "--quality", "85", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(85, options.JpegQualityLevel); + } + + [TestMethod] + public void Parse_WithKeepDateModified_SetsKeepDateModified() + { + var args = new[] { "--keep-date-modified", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(true, options.KeepDateModified); + } + + [TestMethod] + public void Parse_WithFileName_SetsFileName() + { + var args = new[] { "--filename", "%1 (%2)", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual("%1 (%2)", options.FileName); + } + + [TestMethod] + public void Parse_WithDestination_SetsDestinationDirectory() + { + var args = new[] { "--destination", "C:\\Output", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual("C:\\Output", options.DestinationDirectory); + } + + [TestMethod] + public void Parse_WithShortDestinationAlias_WorksIdentically() + { + var longFormArgs = new[] { "--destination", "C:\\Output", "test.jpg" }; + var shortFormArgs = new[] { "-d", "C:\\Output", "test.jpg" }; + var longForm = CliOptions.Parse(longFormArgs); + var shortForm = CliOptions.Parse(shortFormArgs); + + Assert.AreEqual(longForm.DestinationDirectory, shortForm.DestinationDirectory); + } + + [TestMethod] + public void Parse_WithProgressLines_SetsProgressLines() + { + var args = new[] { "--progress-lines", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(true, options.ProgressLines); + } + + [TestMethod] + public void Parse_WithAccessibleAlias_SetsProgressLines() + { + var args = new[] { "--accessible", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(true, options.ProgressLines); + } + + [TestMethod] + public void Parse_WithMultipleFiles_AddsAllFiles() + { + var args = _multiFileArgs; + var options = CliOptions.Parse(args); + + Assert.AreEqual(3, options.Files.Count); + CollectionAssert.Contains(options.Files.ToList(), "test1.jpg"); + CollectionAssert.Contains(options.Files.ToList(), "test2.jpg"); + CollectionAssert.Contains(options.Files.ToList(), "test3.jpg"); + } + + [TestMethod] + public void Parse_WithMixedOptionsAndFiles_ParsesCorrectly() + { + var args = _mixedOptionsArgs; + var options = CliOptions.Parse(args); + + Assert.AreEqual(800.0, options.Width); + Assert.AreEqual(600.0, options.Height); + Assert.AreEqual(2, options.Files.Count); + } + + [TestMethod] + public void Parse_WithHelp_SetsShowHelp() + { + var args = new[] { "--help" }; + var options = CliOptions.Parse(args); + + Assert.IsTrue(options.ShowHelp); + } + + [TestMethod] + public void Parse_WithShowConfig_SetsShowConfig() + { + var args = new[] { "--show-config" }; + var options = CliOptions.Parse(args); + + Assert.IsTrue(options.ShowConfig); + } + + [TestMethod] + public void Parse_WithNoArguments_ReturnsEmptyOptions() + { + var args = Array.Empty<string>(); + var options = CliOptions.Parse(args); + + Assert.IsNotNull(options); + Assert.AreEqual(0, options.Files.Count); + } + + [TestMethod] + public void Parse_WithZeroWidth_AllowsZeroValue() + { + var args = new[] { "--width", "0", "--height", "600", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(0.0, options.Width); + Assert.AreEqual(600.0, options.Height); + } + + [TestMethod] + public void Parse_WithZeroHeight_AllowsZeroValue() + { + var args = new[] { "--width", "800", "--height", "0", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(800.0, options.Width); + Assert.AreEqual(0.0, options.Height); + } + + [TestMethod] + public void Parse_CaseInsensitiveEnums_ParsesCorrectly() + { + var args = new[] { "--unit", "pixel", "--fit", "fit", "test.jpg" }; + var options = CliOptions.Parse(args); + + Assert.AreEqual(ResizeUnit.Pixel, options.Unit); + Assert.AreEqual(ResizeFit.Fit, options.Fit); + } + } +} diff --git a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs index 36a17ceb19..bd6031cad4 100644 --- a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs +++ b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs @@ -10,7 +10,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; - +using ImageResizer.Properties; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; using Moq.Protected; @@ -25,20 +25,27 @@ namespace ImageResizer.Models [TestMethod] public void FromCommandLineWorks() { + // Use actual test files that exist in the test directory + var testDir = Path.GetDirectoryName(typeof(ResizeBatchTests).Assembly.Location); + var file1 = Path.Combine(testDir, "Test.jpg"); + var file2 = Path.Combine(testDir, "Test.png"); + var file3 = Path.Combine(testDir, "Test.gif"); + var standardInput = - "Image1.jpg" + EOL + - "Image2.jpg"; + file1 + EOL + + file2; var args = new[] { "/d", "OutputDir", - "Image3.jpg", + file3, }; var result = ResizeBatch.FromCommandLine( new StringReader(standardInput), args); - CollectionAssert.AreEquivalent(new List<string> { "Image1.jpg", "Image2.jpg", "Image3.jpg" }, result.Files.ToArray()); + var files = result.Files.Select(Path.GetFileName).ToArray(); + CollectionAssert.AreEquivalent(new List<string> { "Test.jpg", "Test.png", "Test.gif" }, files); Assert.AreEqual("OutputDir", result.DestinationDirectory); } @@ -101,7 +108,9 @@ namespace ImageResizer.Models private static ResizeBatch CreateBatch(Action<string> executeAction) { var mock = new Mock<ResizeBatch> { CallBase = true }; - mock.Protected().Setup("Execute", ItExpr.IsAny<string>()).Callback(executeAction); + mock.Protected() + .Setup("Execute", ItExpr.IsAny<string>(), ItExpr.IsAny<Settings>()) + .Callback((string file, Settings settings) => executeAction(file)); return mock.Object; } diff --git a/src/modules/imageresizer/tests/Models/ResizeOperationTests.cs b/src/modules/imageresizer/tests/Models/ResizeOperationTests.cs index d1eeae664e..d91a4e4879 100644 --- a/src/modules/imageresizer/tests/Models/ResizeOperationTests.cs +++ b/src/modules/imageresizer/tests/Models/ResizeOperationTests.cs @@ -394,6 +394,93 @@ namespace ImageResizer.Models }); } + [TestMethod] + public void TransformHonorsFillWithShrinkOnlyWhenCropRequired() + { + // Testing original 96x96 pixel Test.jpg cropped to 48x96 (Fill mode). + // + // ScaleX = 48/96 = 0.5 + // ScaleY = 96/96 = 1.0 + // Fill mode takes the max of these = 1.0. + // + // Previously, the transform logic saw the scale of 1.0 and returned the + // original dimensions. The corrected logic recognizes that a crop is + // required on one dimension and proceeds with the operation. + var operation = new ResizeOperation( + "Test.jpg", + _directory, + Settings(x => + { + x.ShrinkOnly = true; + x.SelectedSize.Fit = ResizeFit.Fill; + x.SelectedSize.Width = 48; + x.SelectedSize.Height = 96; + })); + + operation.Execute(); + + AssertEx.Image( + _directory.File(), + image => + { + Assert.AreEqual(48, image.Frames[0].PixelWidth); + Assert.AreEqual(96, image.Frames[0].PixelHeight); + }); + } + + [TestMethod] + public void TransformHonorsFillWithShrinkOnlyWhenUpscaleAttempted() + { + // Confirm that attempting to upscale the original image will return the + // original dimensions when Shrink Only is enabled. + var operation = new ResizeOperation( + "Test.jpg", + _directory, + Settings(x => + { + x.ShrinkOnly = true; + x.SelectedSize.Fit = ResizeFit.Fill; + x.SelectedSize.Width = 192; + x.SelectedSize.Height = 192; + })); + + operation.Execute(); + + AssertEx.Image( + _directory.File(), + image => + { + Assert.AreEqual(96, image.Frames[0].PixelWidth); + Assert.AreEqual(96, image.Frames[0].PixelHeight); + }); + } + + [TestMethod] + public void TransformHonorsFillWithShrinkOnlyWhenNoChangeRequired() + { + // With a scale of 1.0 on both axes, the original should be returned. + var operation = new ResizeOperation( + "Test.jpg", + _directory, + Settings(x => + { + x.ShrinkOnly = true; + x.SelectedSize.Fit = ResizeFit.Fill; + x.SelectedSize.Width = 96; + x.SelectedSize.Height = 96; + })); + + operation.Execute(); + + AssertEx.Image( + _directory.File(), + image => + { + Assert.AreEqual(96, image.Frames[0].PixelWidth); + Assert.AreEqual(96, image.Frames[0].PixelHeight); + }); + } + [TestMethod] public void GetDestinationPathUniquifiesOutputFilename() { diff --git a/src/modules/imageresizer/tests/Properties/SettingsTests.cs b/src/modules/imageresizer/tests/Properties/SettingsTests.cs index ed182556a3..16cbceae6e 100644 --- a/src/modules/imageresizer/tests/Properties/SettingsTests.cs +++ b/src/modules/imageresizer/tests/Properties/SettingsTests.cs @@ -126,13 +126,10 @@ namespace ImageResizer.Properties h => ncc.CollectionChanged -= h, () => settings.CustomSize = new CustomSize()); - Assert.AreEqual(NotifyCollectionChangedAction.Replace, result.Arguments.Action); - Assert.AreEqual(1, result.Arguments.NewItems.Count); - Assert.AreEqual(settings.CustomSize, result.Arguments.NewItems[0]); - Assert.AreEqual(0, result.Arguments.NewStartingIndex); - Assert.AreEqual(1, result.Arguments.OldItems.Count); - Assert.AreEqual(originalCustomSize, result.Arguments.OldItems[0]); - Assert.AreEqual(0, result.Arguments.OldStartingIndex); + // Reset is used instead of Replace to avoid ArgumentOutOfRangeException + // when notifying changes for virtual items (CustomSize/AiSize) that exist + // outside the bounds of the underlying _sizes collection. + Assert.AreEqual(NotifyCollectionChangedAction.Reset, result.Arguments.Action); } [TestMethod] diff --git a/src/modules/imageresizer/ui/App.xaml.cs b/src/modules/imageresizer/ui/App.xaml.cs index e2b87d7746..9977a8a474 100644 --- a/src/modules/imageresizer/ui/App.xaml.cs +++ b/src/modules/imageresizer/ui/App.xaml.cs @@ -6,9 +6,9 @@ using System; using System.Globalization; +using System.Runtime.InteropServices; using System.Text; using System.Windows; - using ImageResizer.Models; using ImageResizer.Properties; using ImageResizer.Utilities; @@ -20,8 +20,32 @@ namespace ImageResizer { public partial class App : Application, IDisposable { + private const string LogSubFolder = "\\Image Resizer\\Logs"; + + /// <summary> + /// Gets cached AI availability state, checked at app startup. + /// Can be updated after model download completes or background initialization. + /// </summary> + public static AiAvailabilityState AiAvailabilityState { get; internal set; } + + /// <summary> + /// Event fired when AI initialization completes in background. + /// Allows UI to refresh state when initialization finishes. + /// </summary> + public static event EventHandler<AiAvailabilityState> AiInitializationCompleted; + static App() { + try + { + // Initialize logger early (mirroring PowerOCR pattern) + Logger.InitializeLogger(LogSubFolder); + } + catch + { + /* swallow logger init issues silently */ + } + try { string appLanguage = LanguageHelper.LoadLanguage(); @@ -30,9 +54,9 @@ namespace ImageResizer System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(appLanguage); } } - catch (CultureNotFoundException) + catch (CultureNotFoundException ex) { - // error + Logger.LogError("CultureNotFoundException: " + ex.Message); } Console.InputEncoding = Encoding.Unicode; @@ -43,15 +67,74 @@ namespace ImageResizer // Fix for .net 3.1.19 making Image Resizer not adapt to DPI changes. NativeMethods.SetProcessDPIAware(); + // TODO: Re-enable AI Super Resolution in next release by removing this #if block + // Temporarily disable AI Super Resolution feature (hide from UI but keep code) +#if true // Set to false to re-enable AI Super Resolution + AiAvailabilityState = AiAvailabilityState.NotSupported; + ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance); + + // Skip AI detection mode as well + if (e?.Args?.Length > 0 && e.Args[0] == "--detect-ai") + { + Services.AiAvailabilityCacheService.SaveCache(AiAvailabilityState.NotSupported); + Environment.Exit(0); + return; + } +#else + // Check for AI detection mode (called by Runner in background) + if (e?.Args?.Length > 0 && e.Args[0] == "--detect-ai") + { + RunAiDetectionMode(); + return; + } +#endif + if (PowerToys.GPOWrapperProjection.GPOWrapper.GetConfiguredImageResizerEnabledValue() == PowerToys.GPOWrapperProjection.GpoRuleConfigured.Disabled) { /* TODO: Add logs to ImageResizer. * Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator."); */ + Logger.LogWarning("GPO policy disables ImageResizer. Exiting."); Environment.Exit(0); // Current.Exit won't work until there's a window opened. return; } + // AI Super Resolution is not supported on Windows 10 - skip cache check entirely + if (OSVersionHelper.IsWindows10()) + { + AiAvailabilityState = AiAvailabilityState.NotSupported; + ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance); + Logger.LogInfo("AI Super Resolution not supported on Windows 10"); + } + else + { + // Load AI availability from cache (written by Runner's background detection) + var cachedState = Services.AiAvailabilityCacheService.LoadCache(); + + if (cachedState.HasValue) + { + AiAvailabilityState = cachedState.Value; + Logger.LogInfo($"AI state loaded from cache: {AiAvailabilityState}"); + } + else + { + // No valid cache - default to NotSupported (Runner will detect and cache for next startup) + AiAvailabilityState = AiAvailabilityState.NotSupported; + Logger.LogInfo("No AI cache found, defaulting to NotSupported"); + } + + // If AI is potentially available, start background initialization (non-blocking) + if (AiAvailabilityState == AiAvailabilityState.Ready) + { + _ = InitializeAiServiceAsync(); // Fire and forget - don't block UI + } + else + { + // AI not available - set NoOp service immediately + ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance); + } + } + var batch = ResizeBatch.FromCommandLine(Console.In, e?.Args); // TODO: Add command-line parameters that can be used in lieu of the input page (issue #14) @@ -62,9 +145,98 @@ namespace ImageResizer WindowHelpers.BringToForeground(new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle); } + /// <summary> + /// AI detection mode: perform detection, write to cache, and exit. + /// Called by Runner in background to avoid blocking ImageResizer UI startup. + /// </summary> + private void RunAiDetectionMode() + { + try + { + Logger.LogInfo("Running AI detection mode..."); + + // AI Super Resolution is not supported on Windows 10 + if (OSVersionHelper.IsWindows10()) + { + Logger.LogInfo("AI detection skipped: Windows 10 does not support AI Super Resolution"); + Services.AiAvailabilityCacheService.SaveCache(AiAvailabilityState.NotSupported); + Environment.Exit(0); + return; + } + + // Perform detection (reuse existing logic) + var state = CheckAiAvailability(); + + // Write result to cache file + Services.AiAvailabilityCacheService.SaveCache(state); + + Logger.LogInfo($"AI detection complete: {state}"); + } + catch (Exception ex) + { + Logger.LogError($"AI detection failed: {ex.Message}"); + Services.AiAvailabilityCacheService.SaveCache(AiAvailabilityState.NotSupported); + } + + // Exit silently without showing UI + Environment.Exit(0); + } + + /// <summary> + /// Check AI Super Resolution availability on this system. + /// Performs architecture check and model availability check. + /// </summary> + private static AiAvailabilityState CheckAiAvailability() + { + // AI feature disabled - always return NotSupported + return AiAvailabilityState.NotSupported; + } + + /// <summary> + /// Initialize AI Super Resolution service asynchronously in background. + /// Runs without blocking UI startup - state change event notifies completion. + /// </summary> + private static async System.Threading.Tasks.Task InitializeAiServiceAsync() + { + AiAvailabilityState finalState; + + try + { + // Create and initialize AI service using async factory + var aiService = await Services.WinAiSuperResolutionService.CreateAsync(); + + if (aiService != null) + { + ResizeBatch.SetAiSuperResolutionService(aiService); + Logger.LogInfo("AI Super Resolution service initialized successfully."); + finalState = AiAvailabilityState.Ready; + } + else + { + // Initialization failed - use default NoOp service + ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance); + Logger.LogWarning("AI Super Resolution service initialization failed. Using default service."); + finalState = AiAvailabilityState.NotSupported; + } + } + catch (Exception ex) + { + // Log error and use default NoOp service + ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance); + Logger.LogError($"Exception during AI service initialization: {ex.Message}"); + finalState = AiAvailabilityState.NotSupported; + } + + // Update cached state and notify listeners + AiAvailabilityState = finalState; + AiInitializationCompleted?.Invoke(null, finalState); + } + public void Dispose() { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + // Dispose AI Super Resolution service + ResizeBatch.DisposeAiSuperResolutionService(); + GC.SuppressFinalize(this); } } diff --git a/src/modules/imageresizer/ui/Cli/CliLogger.cs b/src/modules/imageresizer/ui/Cli/CliLogger.cs new file mode 100644 index 0000000000..d497eee383 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/CliLogger.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using ManagedCommon; + +namespace ImageResizer.Cli +{ + public static class CliLogger + { + private static bool _initialized; + + public static void Initialize(string logSubFolder) + { + if (!_initialized) + { + Logger.InitializeLogger(logSubFolder); + _initialized = true; + } + } + + public static void Info(string message) => Logger.LogInfo(message); + + public static void Warn(string message) => Logger.LogWarning(message); + + public static void Error(string message) => Logger.LogError(message); + } +} diff --git a/src/modules/imageresizer/ui/Cli/CliSettingsApplier.cs b/src/modules/imageresizer/ui/Cli/CliSettingsApplier.cs new file mode 100644 index 0000000000..3cd15f8006 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/CliSettingsApplier.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; + +using ImageResizer.Models; +using ImageResizer.Properties; + +namespace ImageResizer.Cli +{ + /// <summary> + /// Applies CLI options to Settings object. + /// Separated from executor logic for Single Responsibility Principle. + /// </summary> + public static class CliSettingsApplier + { + /// <summary> + /// Applies CLI options to the settings, overriding default values. + /// </summary> + /// <param name="cliOptions">The CLI options to apply.</param> + /// <param name="settings">The settings to modify.</param> + public static void Apply(CliOptions cliOptions, Settings settings) + { + // Handle complex size options first + ApplySizeOptions(cliOptions, settings); + + // Apply simple property mappings + ApplySimpleOptions(cliOptions, settings); + } + + private static void ApplySizeOptions(CliOptions cliOptions, Settings settings) + { + if (cliOptions.Width.HasValue || cliOptions.Height.HasValue) + { + ApplyCustomSizeOptions(cliOptions, settings); + } + else if (cliOptions.SizeIndex.HasValue) + { + ApplyPresetSizeOption(cliOptions, settings); + } + } + + private static void ApplyCustomSizeOptions(CliOptions cliOptions, Settings settings) + { + // Set dimensions (0 = auto-calculate for aspect ratio preservation) + // Implementation: ResizeSize.ConvertToPixels() returns double.PositiveInfinity for 0 in Fit mode, + // causing Math.Min(scaleX, scaleY) to preserve aspect ratio by selecting the non-zero scale. + // For Fill/Stretch modes, 0 uses the original dimension instead. + settings.CustomSize.Width = cliOptions.Width ?? 0; + settings.CustomSize.Height = cliOptions.Height ?? 0; + + // Apply optional properties + if (cliOptions.Unit.HasValue) + { + settings.CustomSize.Unit = cliOptions.Unit.Value; + } + + if (cliOptions.Fit.HasValue) + { + settings.CustomSize.Fit = cliOptions.Fit.Value; + } + + // Select custom size (index = Sizes.Count) + settings.SelectedSizeIndex = settings.Sizes.Count; + } + + private static void ApplyPresetSizeOption(CliOptions cliOptions, Settings settings) + { + var index = cliOptions.SizeIndex.Value; + + if (index >= 0 && index < settings.Sizes.Count) + { + settings.SelectedSizeIndex = index; + } + else + { + Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, Resources.CLI_WarningInvalidSizeIndex, index)); + CliLogger.Warn($"Invalid size index: {index}"); + } + } + + private static void ApplySimpleOptions(CliOptions cliOptions, Settings settings) + { + if (cliOptions.ShrinkOnly.HasValue) + { + settings.ShrinkOnly = cliOptions.ShrinkOnly.Value; + } + + if (cliOptions.Replace.HasValue) + { + settings.Replace = cliOptions.Replace.Value; + } + + if (cliOptions.IgnoreOrientation.HasValue) + { + settings.IgnoreOrientation = cliOptions.IgnoreOrientation.Value; + } + + if (cliOptions.RemoveMetadata.HasValue) + { + settings.RemoveMetadata = cliOptions.RemoveMetadata.Value; + } + + if (cliOptions.JpegQualityLevel.HasValue) + { + settings.JpegQualityLevel = cliOptions.JpegQualityLevel.Value; + } + + if (cliOptions.KeepDateModified.HasValue) + { + settings.KeepDateModified = cliOptions.KeepDateModified.Value; + } + + if (!string.IsNullOrEmpty(cliOptions.FileName)) + { + settings.FileName = cliOptions.FileName; + } + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs b/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs new file mode 100644 index 0000000000..c63fb9cfdf --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs @@ -0,0 +1,90 @@ +// 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.CommandLine; + +using ImageResizer.Cli.Options; + +namespace ImageResizer.Cli.Commands +{ + /// <summary> + /// Root command for the ImageResizer CLI. + /// </summary> + public sealed class ImageResizerRootCommand : RootCommand + { + public ImageResizerRootCommand() + : base("PowerToys Image Resizer - Resize images from command line") + { + HelpOption = new HelpOption(); + ShowConfigOption = new ShowConfigOption(); + DestinationOption = new DestinationOption(); + WidthOption = new WidthOption(); + HeightOption = new HeightOption(); + UnitOption = new UnitOption(); + FitOption = new FitOption(); + SizeOption = new SizeOption(); + ShrinkOnlyOption = new ShrinkOnlyOption(); + ReplaceOption = new ReplaceOption(); + IgnoreOrientationOption = new IgnoreOrientationOption(); + RemoveMetadataOption = new RemoveMetadataOption(); + QualityOption = new QualityOption(); + KeepDateModifiedOption = new KeepDateModifiedOption(); + FileNameOption = new FileNameOption(); + ProgressLinesOption = new ProgressLinesOption(); + FilesArgument = new FilesArgument(); + + AddOption(HelpOption); + AddOption(ShowConfigOption); + AddOption(DestinationOption); + AddOption(WidthOption); + AddOption(HeightOption); + AddOption(UnitOption); + AddOption(FitOption); + AddOption(SizeOption); + AddOption(ShrinkOnlyOption); + AddOption(ReplaceOption); + AddOption(IgnoreOrientationOption); + AddOption(RemoveMetadataOption); + AddOption(QualityOption); + AddOption(KeepDateModifiedOption); + AddOption(FileNameOption); + AddOption(ProgressLinesOption); + AddArgument(FilesArgument); + } + + public HelpOption HelpOption { get; } + + public ShowConfigOption ShowConfigOption { get; } + + public DestinationOption DestinationOption { get; } + + public WidthOption WidthOption { get; } + + public HeightOption HeightOption { get; } + + public UnitOption UnitOption { get; } + + public FitOption FitOption { get; } + + public SizeOption SizeOption { get; } + + public ShrinkOnlyOption ShrinkOnlyOption { get; } + + public ReplaceOption ReplaceOption { get; } + + public IgnoreOrientationOption IgnoreOrientationOption { get; } + + public RemoveMetadataOption RemoveMetadataOption { get; } + + public QualityOption QualityOption { get; } + + public KeepDateModifiedOption KeepDateModifiedOption { get; } + + public FileNameOption FileNameOption { get; } + + public ProgressLinesOption ProgressLinesOption { get; } + + public FilesArgument FilesArgument { get; } + } +} diff --git a/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs b/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs new file mode 100644 index 0000000000..bd22da62da --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs @@ -0,0 +1,124 @@ +// 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.Globalization; +using System.Linq; +using System.Threading; + +using ImageResizer.Models; +using ImageResizer.Properties; + +namespace ImageResizer.Cli +{ + /// <summary> + /// Executes Image Resizer CLI operations. + /// Instance-based design for better testability and Single Responsibility Principle. + /// </summary> + public class ImageResizerCliExecutor + { + /// <summary> + /// Runs the CLI executor with the provided command-line arguments. + /// </summary> + /// <param name="args">Command-line arguments.</param> + /// <returns>Exit code.</returns> + public int Run(string[] args) + { + var cliOptions = CliOptions.Parse(args); + + if (cliOptions.ParseErrors.Count > 0) + { + foreach (var error in cliOptions.ParseErrors) + { + Console.Error.WriteLine(error); + CliLogger.Error($"Parse error: {error}"); + } + + CliOptions.PrintUsage(); + return 1; + } + + if (cliOptions.ShowHelp) + { + CliOptions.PrintUsage(); + return 0; + } + + if (cliOptions.ShowConfig) + { + CliOptions.PrintConfig(Settings.Default); + return 0; + } + + if (cliOptions.Files.Count == 0 && string.IsNullOrEmpty(cliOptions.PipeName)) + { + Console.WriteLine(Resources.CLI_NoInputFiles); + CliOptions.PrintUsage(); + return 1; + } + + return RunSilentMode(cliOptions); + } + + private int RunSilentMode(CliOptions cliOptions) + { + var batch = ResizeBatch.FromCliOptions(Console.In, cliOptions); + var settings = Settings.Default; + CliSettingsApplier.Apply(cliOptions, settings); + + CliLogger.Info($"CLI mode: processing {batch.Files.Count} files"); + + // Use accessible line-based progress if requested or detected + bool useLineBasedProgress = cliOptions.ProgressLines ?? false; + int lastReportedMilestone = -1; + + var errors = batch.Process( + (completed, total) => + { + var progress = (int)((completed / total) * 100); + + if (useLineBasedProgress) + { + // Milestone-based progress (0%, 25%, 50%, 75%, 100%) + int milestone = (progress / 25) * 25; + if (milestone > lastReportedMilestone || completed == (int)total) + { + lastReportedMilestone = milestone; + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Resources.CLI_ProgressFormat, progress, completed, (int)total)); + } + } + else + { + // Traditional carriage return mode + Console.Write(string.Format(CultureInfo.InvariantCulture, "\r{0}", string.Format(CultureInfo.InvariantCulture, Resources.CLI_ProgressFormat, progress, completed, (int)total))); + } + }, + settings, + CancellationToken.None); + + if (!useLineBasedProgress) + { + Console.WriteLine(); + } + + var errorList = errors.ToList(); + if (errorList.Count > 0) + { + Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, Resources.CLI_CompletedWithErrors, errorList.Count)); + CliLogger.Error($"Processing completed with {errorList.Count} error(s)"); + foreach (var error in errorList) + { + Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, " {0}: {1}", error.File, error.Error)); + CliLogger.Error($" {error.File}: {error.Error}"); + } + + return 1; + } + + CliLogger.Info("CLI batch completed successfully"); + Console.WriteLine(Resources.CLI_AllFilesProcessed); + return 0; + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs b/src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs new file mode 100644 index 0000000000..50a9e9bc10 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/DestinationOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class DestinationOption : Option<string> + { + private static readonly string[] _aliases = ["--destination", "-d", "/d"]; + + public DestinationOption() + : base(_aliases, Properties.Resources.CLI_Option_Destination) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs b/src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs new file mode 100644 index 0000000000..fc1d7879db --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/FileNameOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class FileNameOption : Option<string> + { + private static readonly string[] _aliases = ["--filename", "-n"]; + + public FileNameOption() + : base(_aliases, Properties.Resources.CLI_Option_FileName) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs b/src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs new file mode 100644 index 0000000000..e8d3c27872 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class FilesArgument : Argument<string[]> + { + public FilesArgument() + : base("files", Properties.Resources.CLI_Option_Files) + { + Arity = ArgumentArity.ZeroOrMore; + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/FitOption.cs b/src/modules/imageresizer/ui/Cli/Options/FitOption.cs new file mode 100644 index 0000000000..65417f4fd0 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/FitOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class FitOption : Option<ImageResizer.Models.ResizeFit?> + { + private static readonly string[] _aliases = ["--fit", "-f"]; + + public FitOption() + : base(_aliases, Properties.Resources.CLI_Option_Fit) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs b/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs new file mode 100644 index 0000000000..7abbff7cf6 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/HeightOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class HeightOption : Option<double?> + { + private static readonly string[] _aliases = ["--height", "-h"]; + + public HeightOption() + : base(_aliases, Properties.Resources.CLI_Option_Height) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/HelpOption.cs b/src/modules/imageresizer/ui/Cli/Options/HelpOption.cs new file mode 100644 index 0000000000..ff42a22061 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/HelpOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class HelpOption : Option<bool> + { + private static readonly string[] _aliases = ["--help", "-?", "/?"]; + + public HelpOption() + : base(_aliases, Properties.Resources.CLI_Option_Help) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs b/src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs new file mode 100644 index 0000000000..35e7437d90 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class IgnoreOrientationOption : Option<bool> + { + private static readonly string[] _aliases = ["--ignore-orientation"]; + + public IgnoreOrientationOption() + : base(_aliases, Properties.Resources.CLI_Option_IgnoreOrientation) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs b/src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs new file mode 100644 index 0000000000..43b0977b82 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class KeepDateModifiedOption : Option<bool> + { + private static readonly string[] _aliases = ["--keep-date-modified"]; + + public KeepDateModifiedOption() + : base(_aliases, Properties.Resources.CLI_Option_KeepDateModified) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/ProgressLinesOption.cs b/src/modules/imageresizer/ui/Cli/Options/ProgressLinesOption.cs new file mode 100644 index 0000000000..95935fa0f1 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/ProgressLinesOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class ProgressLinesOption : Option<bool> + { + private static readonly string[] _aliases = ["--progress-lines", "--accessible"]; + + public ProgressLinesOption() + : base(_aliases, "Use line-based progress output for screen reader accessibility (milestones: 0%, 25%, 50%, 75%, 100%)") + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/QualityOption.cs b/src/modules/imageresizer/ui/Cli/Options/QualityOption.cs new file mode 100644 index 0000000000..d87573ebfc --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/QualityOption.cs @@ -0,0 +1,26 @@ +// 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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class QualityOption : Option<int?> + { + private static readonly string[] _aliases = ["--quality", "-q"]; + + public QualityOption() + : base(_aliases, Properties.Resources.CLI_Option_Quality) + { + AddValidator(result => + { + var value = result.GetValueOrDefault<int?>(); + if (value.HasValue && (value.Value < 1 || value.Value > 100)) + { + result.ErrorMessage = "JPEG quality must be between 1 and 100."; + } + }); + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs b/src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs new file mode 100644 index 0000000000..3db3ad2089 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class RemoveMetadataOption : Option<bool> + { + private static readonly string[] _aliases = ["--remove-metadata"]; + + public RemoveMetadataOption() + : base(_aliases, Properties.Resources.CLI_Option_RemoveMetadata) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs b/src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs new file mode 100644 index 0000000000..c9a8073261 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/ReplaceOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class ReplaceOption : Option<bool> + { + private static readonly string[] _aliases = ["--replace", "-r"]; + + public ReplaceOption() + : base(_aliases, Properties.Resources.CLI_Option_Replace) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs b/src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs new file mode 100644 index 0000000000..c530662649 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class ShowConfigOption : Option<bool> + { + private static readonly string[] _aliases = ["--show-config", "--config"]; + + public ShowConfigOption() + : base(_aliases, Properties.Resources.CLI_Option_ShowConfig) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs b/src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs new file mode 100644 index 0000000000..0f51adf642 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class ShrinkOnlyOption : Option<bool> + { + private static readonly string[] _aliases = ["--shrink-only"]; + + public ShrinkOnlyOption() + : base(_aliases, Properties.Resources.CLI_Option_ShrinkOnly) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/SizeOption.cs b/src/modules/imageresizer/ui/Cli/Options/SizeOption.cs new file mode 100644 index 0000000000..af3c978d2b --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/SizeOption.cs @@ -0,0 +1,26 @@ +// 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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class SizeOption : Option<int?> + { + private static readonly string[] _aliases = ["--size"]; + + public SizeOption() + : base(_aliases, Properties.Resources.CLI_Option_Size) + { + AddValidator(result => + { + var value = result.GetValueOrDefault<int?>(); + if (value.HasValue && value.Value < 0) + { + result.ErrorMessage = "Size index must be a non-negative integer."; + } + }); + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/UnitOption.cs b/src/modules/imageresizer/ui/Cli/Options/UnitOption.cs new file mode 100644 index 0000000000..dc9edde180 --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/UnitOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class UnitOption : Option<ImageResizer.Models.ResizeUnit?> + { + private static readonly string[] _aliases = ["--unit", "-u"]; + + public UnitOption() + : base(_aliases, Properties.Resources.CLI_Option_Unit) + { + } + } +} diff --git a/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs b/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs new file mode 100644 index 0000000000..64b8a2091d --- /dev/null +++ b/src/modules/imageresizer/ui/Cli/Options/WidthOption.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.CommandLine; + +namespace ImageResizer.Cli.Options +{ + public sealed class WidthOption : Option<double?> + { + private static readonly string[] _aliases = ["--width", "-w"]; + + public WidthOption() + : base(_aliases, Properties.Resources.CLI_Option_Width) + { + } + } +} diff --git a/src/modules/imageresizer/ui/ImageResizerUI.csproj b/src/modules/imageresizer/ui/ImageResizerUI.csproj index 3a7701607e..f2bec0c3b6 100644 --- a/src/modules/imageresizer/ui/ImageResizerUI.csproj +++ b/src/modules/imageresizer/ui/ImageResizerUI.csproj @@ -1,15 +1,16 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <AssemblyTitle>PowerToys.ImageResizer</AssemblyTitle> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> <UseWPF>true</UseWPF> + <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> </PropertyGroup> <PropertyGroup> @@ -18,12 +19,22 @@ <RootNamespace>ImageResizer</RootNamespace> <AssemblyName>PowerToys.ImageResizer</AssemblyName> <ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + <NoWarn>CA1863</NoWarn> </PropertyGroup> <PropertyGroup> <ApplicationIcon>Resources\ImageResizer.ico</ApplicationIcon> </PropertyGroup> + <!-- <PropertyGroup> + <ApplicationManifest>ImageResizerUI.dev.manifest</ApplicationManifest> + </PropertyGroup> + + <PropertyGroup Condition="'$(CIBuild)'=='true'"> + <ApplicationManifest>ImageResizerUI.prod.manifest</ApplicationManifest> + </PropertyGroup> --> + <ItemGroup> <EmbeddedResource Update="Properties\Resources.resx"> <Generator>PublicResXFileCodeGenerator</Generator> @@ -38,7 +49,10 @@ <Resource Include="Resources\ImageResizer.png" /> </ItemGroup> <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="Microsoft.WindowsAppSDK.AI" /> <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" /> + <PackageReference Include="System.CommandLine" /> <PackageReference Include="System.IO.Abstractions" /> <PackageReference Include="WPF-UI" /> </ItemGroup> @@ -55,4 +69,14 @@ <DependentUpon>Resources.resx</DependentUpon> </Compile> </ItemGroup> + + <!-- Ensure Resources directory and ImageResizer.png are available for dependent projects --> + <Target Name="CopyResourcesToSharedLocation" AfterTargets="Build"> + <ItemGroup> + <ResourceFiles Include="$(MSBuildProjectDirectory)\Resources\ImageResizer.png" /> + </ItemGroup> + <MakeDir Directories="$(OutputPath)Resources" Condition="!Exists('$(OutputPath)Resources')" /> + <Copy SourceFiles="@(ResourceFiles)" DestinationFolder="$(OutputPath)Resources" SkipUnchangedFiles="true" /> + </Target> + </Project> \ No newline at end of file diff --git a/src/modules/imageresizer/ui/ImageResizerUI.dev.manifest b/src/modules/imageresizer/ui/ImageResizerUI.dev.manifest new file mode 100644 index 0000000000..cb91bc2b66 --- /dev/null +++ b/src/modules/imageresizer/ui/ImageResizerUI.dev.manifest @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> + <assemblyIdentity version="1.0.0.0" name="PowerToys.ImageResizer.app" /> + <msix xmlns="urn:schemas-microsoft-com:msix.v1" + publisher="CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US" + packageName="Microsoft.PowerToys.SparseApp" + applicationId="PowerToys.ImageResizerUI" /> +</assembly> diff --git a/src/modules/imageresizer/ui/ImageResizerUI.prod.manifest b/src/modules/imageresizer/ui/ImageResizerUI.prod.manifest new file mode 100644 index 0000000000..bbb50a9ec5 --- /dev/null +++ b/src/modules/imageresizer/ui/ImageResizerUI.prod.manifest @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> + <assemblyIdentity version="1.0.0.0" name="PowerToys.ImageResizer.app" /> + <msix xmlns="urn:schemas-microsoft-com:msix.v1" + publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" + packageName="Microsoft.PowerToys.SparseApp" + applicationId="PowerToys.ImageResizerUI" /> +</assembly> diff --git a/src/modules/imageresizer/ui/Models/AiSize.cs b/src/modules/imageresizer/ui/Models/AiSize.cs new file mode 100644 index 0000000000..dcb9d521d8 --- /dev/null +++ b/src/modules/imageresizer/ui/Models/AiSize.cs @@ -0,0 +1,41 @@ +// 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.Text; +using System.Text.Json.Serialization; + +using ImageResizer.Properties; + +namespace ImageResizer.Models +{ + public class AiSize : ResizeSize + { + private static readonly CompositeFormat ScaleFormat = CompositeFormat.Parse(Resources.Input_AiScaleFormat); + private int _scale = 2; + + /// <summary> + /// Gets the formatted scale display string (e.g., "2×"). + /// </summary> + [JsonIgnore] + public string ScaleDisplay => string.Format(CultureInfo.CurrentCulture, ScaleFormat, _scale); + + [JsonPropertyName("scale")] + public int Scale + { + get => _scale; + set => Set(ref _scale, value); + } + + [JsonConstructor] + public AiSize(int scale) + { + Scale = scale; + } + + public AiSize() + { + } + } +} diff --git a/src/modules/imageresizer/ui/Models/CliOptions.cs b/src/modules/imageresizer/ui/Models/CliOptions.cs new file mode 100644 index 0000000000..2df23f532b --- /dev/null +++ b/src/modules/imageresizer/ui/Models/CliOptions.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.CommandLine.Parsing; +using System.Globalization; +using ImageResizer.Cli.Commands; + +#pragma warning disable SA1649 // File name should match first type name +#pragma warning disable SA1402 // File may only contain a single type + +namespace ImageResizer.Models +{ + /// <summary> + /// Represents the command-line options for ImageResizer CLI mode. + /// </summary> + public class CliOptions + { + /// <summary> + /// Gets or sets a value indicating whether to show help information. + /// </summary> + public bool ShowHelp { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to show current configuration. + /// </summary> + public bool ShowConfig { get; set; } + + /// <summary> + /// Gets or sets the destination directory for resized images. + /// </summary> + public string DestinationDirectory { get; set; } + + /// <summary> + /// Gets or sets the width of the resized image. + /// </summary> + public double? Width { get; set; } + + /// <summary> + /// Gets or sets the height of the resized image. + /// </summary> + public double? Height { get; set; } + + /// <summary> + /// Gets or sets the resize unit (Pixel, Percent, Inch, Centimeter). + /// </summary> + public ResizeUnit? Unit { get; set; } + + /// <summary> + /// Gets or sets the resize fit mode (Fill, Fit, Stretch). + /// </summary> + public ResizeFit? Fit { get; set; } + + /// <summary> + /// Gets or sets the index of the preset size to use. + /// </summary> + public int? SizeIndex { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to only shrink images (not enlarge). + /// </summary> + public bool? ShrinkOnly { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to replace the original file. + /// </summary> + public bool? Replace { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to ignore orientation when resizing. + /// </summary> + public bool? IgnoreOrientation { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to remove metadata from the resized image. + /// </summary> + public bool? RemoveMetadata { get; set; } + + /// <summary> + /// Gets or sets the JPEG quality level (1-100). + /// </summary> + public int? JpegQualityLevel { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to keep the date modified. + /// </summary> + public bool? KeepDateModified { get; set; } + + /// <summary> + /// Gets or sets the output filename format. + /// </summary> + public string FileName { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether to use line-based progress output for screen reader accessibility. + /// </summary> + public bool? ProgressLines { get; set; } + + /// <summary> + /// Gets the list of files to process. + /// </summary> + public ICollection<string> Files { get; } = new List<string>(); + + /// <summary> + /// Gets or sets the pipe name for receiving file list. + /// </summary> + public string PipeName { get; set; } + + /// <summary> + /// Gets parse/validation errors produced by System.CommandLine. + /// </summary> + public IReadOnlyList<string> ParseErrors { get; private set; } = Array.Empty<string>(); + + /// <summary> + /// Converts a boolean value to nullable bool (true -> true, false -> null). + /// </summary> + private static bool? ToBoolOrNull(bool value) => value ? true : null; + + /// <summary> + /// Parses command-line arguments into CliOptions using System.CommandLine. + /// </summary> + /// <param name="args">The command-line arguments.</param> + /// <returns>A CliOptions instance with parsed values.</returns> + public static CliOptions Parse(string[] args) + { + var options = new CliOptions(); + var cmd = new ImageResizerRootCommand(); + + // Parse using System.CommandLine + var parseResult = new Parser(cmd).Parse(args); + + if (parseResult.Errors.Count > 0) + { + var errors = new List<string>(parseResult.Errors.Count); + foreach (var error in parseResult.Errors) + { + errors.Add(error.Message); + } + + options.ParseErrors = new ReadOnlyCollection<string>(errors); + } + + // Extract values from parse result using strongly typed options + options.ShowHelp = parseResult.GetValueForOption(cmd.HelpOption); + options.ShowConfig = parseResult.GetValueForOption(cmd.ShowConfigOption); + options.DestinationDirectory = parseResult.GetValueForOption(cmd.DestinationOption); + options.Width = parseResult.GetValueForOption(cmd.WidthOption); + options.Height = parseResult.GetValueForOption(cmd.HeightOption); + options.Unit = parseResult.GetValueForOption(cmd.UnitOption); + options.Fit = parseResult.GetValueForOption(cmd.FitOption); + options.SizeIndex = parseResult.GetValueForOption(cmd.SizeOption); + + // Convert bool to nullable bool (true -> true, false -> null) + options.ShrinkOnly = ToBoolOrNull(parseResult.GetValueForOption(cmd.ShrinkOnlyOption)); + options.Replace = ToBoolOrNull(parseResult.GetValueForOption(cmd.ReplaceOption)); + options.IgnoreOrientation = ToBoolOrNull(parseResult.GetValueForOption(cmd.IgnoreOrientationOption)); + options.RemoveMetadata = ToBoolOrNull(parseResult.GetValueForOption(cmd.RemoveMetadataOption)); + options.KeepDateModified = ToBoolOrNull(parseResult.GetValueForOption(cmd.KeepDateModifiedOption)); + options.ProgressLines = ToBoolOrNull(parseResult.GetValueForOption(cmd.ProgressLinesOption)); + + options.JpegQualityLevel = parseResult.GetValueForOption(cmd.QualityOption); + + options.FileName = parseResult.GetValueForOption(cmd.FileNameOption); + + // Get files from arguments + var files = parseResult.GetValueForArgument(cmd.FilesArgument); + if (files != null) + { + const string pipeNamePrefix = "\\\\.\\pipe\\"; + foreach (var file in files) + { + // Check for pipe name (must be at the start of the path) + if (file.StartsWith(pipeNamePrefix, StringComparison.OrdinalIgnoreCase)) + { + options.PipeName = file.Substring(pipeNamePrefix.Length); + } + else + { + options.Files.Add(file); + } + } + } + + return options; + } + + /// <summary> + /// Prints current configuration to the console. + /// </summary> + /// <param name="settings">The settings to display.</param> + public static void PrintConfig(ImageResizer.Properties.Settings settings) + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.WriteLine(Properties.Resources.CLI_ConfigTitle); + Console.WriteLine(); + Console.WriteLine(Properties.Resources.CLI_ConfigGeneralSettings); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigShrinkOnly, settings.ShrinkOnly)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigReplaceOriginal, settings.Replace)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigIgnoreOrientation, settings.IgnoreOrientation)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigRemoveMetadata, settings.RemoveMetadata)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigKeepDateModified, settings.KeepDateModified)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigJpegQuality, settings.JpegQualityLevel)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigPngInterlace, settings.PngInterlaceOption)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigTiffCompress, settings.TiffCompressOption)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigFilenameFormat, settings.FileName)); + Console.WriteLine(); + Console.WriteLine(Properties.Resources.CLI_ConfigCustomSize); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigWidth, settings.CustomSize.Width, settings.CustomSize.Unit)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigHeight, settings.CustomSize.Height, settings.CustomSize.Unit)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigFitMode, settings.CustomSize.Fit)); + Console.WriteLine(); + Console.WriteLine(Properties.Resources.CLI_ConfigPresetSizes); + for (int i = 0; i < settings.Sizes.Count; i++) + { + var size = settings.Sizes[i]; + var selected = i == settings.SelectedSizeIndex ? "*" : " "; + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigPresetSizeFormat, i, selected, size.Name, size.Width, size.Height, size.Unit, size.Fit)); + } + + if (settings.SelectedSizeIndex >= settings.Sizes.Count) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigCustomSelected, settings.CustomSize.Width, settings.CustomSize.Height, settings.CustomSize.Unit, settings.CustomSize.Fit)); + } + } + + /// <summary> + /// Prints usage information to the console. + /// </summary> + public static void PrintUsage() + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.WriteLine(Properties.Resources.CLI_UsageTitle); + Console.WriteLine(); + + var cmd = new ImageResizerRootCommand(); + + // Print usage line + Console.WriteLine(Properties.Resources.CLI_UsageLine); + Console.WriteLine(); + + // Print options from the command definition + Console.WriteLine(Properties.Resources.CLI_UsageOptions); + foreach (var option in cmd.Options) + { + var aliases = string.Join(", ", option.Aliases); + var description = option.Description ?? string.Empty; + Console.WriteLine($" {aliases,-30} {description}"); + } + + Console.WriteLine(); + Console.WriteLine(Properties.Resources.CLI_UsageExamples); + Console.WriteLine(Properties.Resources.CLI_UsageExampleHelp); + Console.WriteLine(Properties.Resources.CLI_UsageExampleDimensions); + Console.WriteLine(Properties.Resources.CLI_UsageExamplePercent); + Console.WriteLine(Properties.Resources.CLI_UsageExamplePreset); + } + } +} diff --git a/src/modules/imageresizer/ui/Models/ResizeBatch.cs b/src/modules/imageresizer/ui/Models/ResizeBatch.cs index 1181395c09..07df9cea75 100644 --- a/src/modules/imageresizer/ui/Models/ResizeBatch.cs +++ b/src/modules/imageresizer/ui/Models/ResizeBatch.cs @@ -10,60 +10,108 @@ using System.Collections.Generic; using System.IO; using System.IO.Abstractions; using System.IO.Pipes; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using ImageResizer.Properties; +using ImageResizer.Services; namespace ImageResizer.Models { public class ResizeBatch { private readonly IFileSystem _fileSystem = new FileSystem(); + private static IAISuperResolutionService _aiSuperResolutionService; public string DestinationDirectory { get; set; } public ICollection<string> Files { get; } = new List<string>(); - public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args) + public static void SetAiSuperResolutionService(IAISuperResolutionService service) { - var batch = new ResizeBatch(); - const string pipeNamePrefix = "\\\\.\\pipe\\"; - string pipeName = null; + _aiSuperResolutionService = service; + } - for (var i = 0; i < args?.Length; i++) + public static void DisposeAiSuperResolutionService() + { + _aiSuperResolutionService?.Dispose(); + _aiSuperResolutionService = null; + } + + /// <summary> + /// Validates if a file path is a supported image format. + /// </summary> + /// <param name="path">The file path to validate.</param> + /// <returns>True if the path is valid and points to a supported image file.</returns> + private static bool IsValidImagePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) { - if (args[i] == "/d") - { - batch.DestinationDirectory = args[++i]; - continue; - } - else if (args[i].Contains(pipeNamePrefix)) - { - pipeName = args[i].Substring(pipeNamePrefix.Length); - continue; - } - - batch.Files.Add(args[i]); + return false; } - if (string.IsNullOrEmpty(pipeName)) + if (!File.Exists(path)) + { + return false; + } + + var ext = Path.GetExtension(path)?.ToLowerInvariant(); + var validExtensions = new[] + { + ".bmp", ".dib", ".gif", ".jfif", ".jpe", ".jpeg", ".jpg", + ".jxr", ".png", ".rle", ".tif", ".tiff", ".wdp", + }; + + return validExtensions.Contains(ext); + } + + /// <summary> + /// Creates a ResizeBatch from CliOptions. + /// </summary> + /// <param name="standardInput">Standard input stream for reading additional file paths.</param> + /// <param name="options">The parsed CLI options.</param> + /// <returns>A ResizeBatch instance.</returns> + public static ResizeBatch FromCliOptions(TextReader standardInput, CliOptions options) + { + var batch = new ResizeBatch + { + DestinationDirectory = options.DestinationDirectory, + }; + + foreach (var file in options.Files) + { + // Convert relative paths to absolute paths + var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file); + if (IsValidImagePath(absolutePath)) + { + batch.Files.Add(absolutePath); + } + } + + if (string.IsNullOrEmpty(options.PipeName)) { // NB: We read these from stdin since there are limits on the number of args you can have + // Only read from stdin if it's redirected (piped input), not from interactive terminal string file; - if (standardInput != null) + if (standardInput != null && (Console.IsInputRedirected || !ReferenceEquals(standardInput, Console.In))) { while ((file = standardInput.ReadLine()) != null) { - batch.Files.Add(file); + // Convert relative paths to absolute paths + var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file); + if (IsValidImagePath(absolutePath)) + { + batch.Files.Add(absolutePath); + } } } } else { using (NamedPipeClientStream pipeClient = - new NamedPipeClientStream(".", pipeName, PipeDirection.In)) + new NamedPipeClientStream(".", options.PipeName, PipeDirection.In)) { // Connect to the pipe or wait until the pipe is available. pipeClient.Connect(); @@ -75,7 +123,10 @@ namespace ImageResizer.Models // Display the read text to the console while ((file = sr.ReadLine()) != null) { - batch.Files.Add(file); + if (IsValidImagePath(file)) + { + batch.Files.Add(file); + } } } } @@ -84,10 +135,24 @@ namespace ImageResizer.Models return batch; } + public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args) + { + var options = CliOptions.Parse(args); + return FromCliOptions(standardInput, options); + } + public IEnumerable<ResizeError> Process(Action<int, double> reportProgress, CancellationToken cancellationToken) + { + // NOTE: Settings.Default is captured once before parallel processing. + // Any changes to settings on disk during this batch will NOT be reflected until the next batch. + // This improves performance and predictability by avoiding repeated mutex acquisition and behaviour change results in a batch. + return Process(reportProgress, Settings.Default, cancellationToken); + } + + public IEnumerable<ResizeError> Process(Action<int, double> reportProgress, Settings settings, CancellationToken cancellationToken) { double total = Files.Count; - var completed = 0; + int completed = 0; var errors = new ConcurrentBag<ResizeError>(); // TODO: If we ever switch to Windows.Graphics.Imaging, we can get a lot more throughput by using the async @@ -97,13 +162,12 @@ namespace ImageResizer.Models new ParallelOptions { CancellationToken = cancellationToken, - MaxDegreeOfParallelism = Environment.ProcessorCount, }, (file, state, i) => { try { - Execute(file); + Execute(file, settings); } catch (Exception ex) { @@ -111,14 +175,16 @@ namespace ImageResizer.Models } Interlocked.Increment(ref completed); - reportProgress(completed, total); }); return errors; } - protected virtual void Execute(string file) - => new ResizeOperation(file, DestinationDirectory, Settings.Default).Execute(); + protected virtual void Execute(string file, Settings settings) + { + var aiService = _aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance; + new ResizeOperation(file, DestinationDirectory, settings, aiService).Execute(); + } } } diff --git a/src/modules/imageresizer/ui/Models/ResizeOperation.cs b/src/modules/imageresizer/ui/Models/ResizeOperation.cs index 2c81076012..4c3cb837a1 100644 --- a/src/modules/imageresizer/ui/Models/ResizeOperation.cs +++ b/src/modules/imageresizer/ui/Models/ResizeOperation.cs @@ -10,12 +10,14 @@ using System.Globalization; using System.IO; using System.IO.Abstractions; using System.Linq; +using System.Text; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using ImageResizer.Extensions; using ImageResizer.Properties; +using ImageResizer.Services; using ImageResizer.Utilities; using Microsoft.VisualBasic.FileIO; @@ -30,6 +32,10 @@ namespace ImageResizer.Models private readonly string _file; private readonly string _destinationDirectory; private readonly Settings _settings; + private readonly IAISuperResolutionService _aiSuperResolutionService; + + // Cache CompositeFormat for AI error message formatting (CA1863) + private static readonly CompositeFormat _aiErrorFormat = CompositeFormat.Parse(Resources.Error_AiProcessingFailed); // Filenames to avoid according to https://learn.microsoft.com/windows/win32/fileio/naming-a-file#file-and-directory-names private static readonly string[] _avoidFilenames = @@ -39,11 +45,12 @@ namespace ImageResizer.Models "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", }; - public ResizeOperation(string file, string destinationDirectory, Settings settings) + public ResizeOperation(string file, string destinationDirectory, Settings settings, IAISuperResolutionService aiSuperResolutionService = null) { _file = file; _destinationDirectory = destinationDirectory; _settings = settings; + _aiSuperResolutionService = aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance; } public void Execute() @@ -167,49 +174,94 @@ namespace ImageResizer.Models private BitmapSource Transform(BitmapSource source) { - var originalWidth = source.PixelWidth; - var originalHeight = source.PixelHeight; - var width = _settings.SelectedSize.GetPixelWidth(originalWidth, source.DpiX); - var height = _settings.SelectedSize.GetPixelHeight(originalHeight, source.DpiY); - - if (_settings.IgnoreOrientation - && !_settings.SelectedSize.HasAuto - && _settings.SelectedSize.Unit != ResizeUnit.Percent - && originalWidth < originalHeight != (width < height)) + if (_settings.SelectedSize is AiSize) { - var temp = width; - width = height; - height = temp; + return TransformWithAi(source); } - var scaleX = width / originalWidth; - var scaleY = height / originalHeight; + int originalWidth = source.PixelWidth; + int originalHeight = source.PixelHeight; + // Convert from the chosen size unit to pixels, if necessary. + double width = _settings.SelectedSize.GetPixelWidth(originalWidth, source.DpiX); + double height = _settings.SelectedSize.GetPixelHeight(originalHeight, source.DpiY); + + // Swap target width/height dimensions if orientation correction is required. + // Ensures that we don't try to fit a landscape image into a portrait box by + // distorting it, unless specific Auto/Percent rules are applied. + bool canSwapDimensions = _settings.IgnoreOrientation && + !_settings.SelectedSize.HasAuto && + _settings.SelectedSize.Unit != ResizeUnit.Percent; + + if (canSwapDimensions) + { + bool isInputLandscape = originalWidth > originalHeight; + bool isInputPortrait = originalHeight > originalWidth; + bool isTargetLandscape = width > height; + bool isTargetPortrait = height > width; + + // Swap dimensions if there is a mismatch between input and target. + if ((isInputLandscape && isTargetPortrait) || + (isInputPortrait && isTargetLandscape)) + { + (width, height) = (height, width); + } + } + + double scaleX = width / originalWidth; + double scaleY = height / originalHeight; + + // Normalize scales based on the chosen Fit/Fill mode. if (_settings.SelectedSize.Fit == ResizeFit.Fit) { + // Fit: use the smaller scale to ensure the image fits within the target. scaleX = Math.Min(scaleX, scaleY); scaleY = scaleX; } else if (_settings.SelectedSize.Fit == ResizeFit.Fill) { + // Fill: use the larger scale to ensure the target area is fully covered. + // This often results in one dimension overflowing, which is handled by + // cropping later. scaleX = Math.Max(scaleX, scaleY); scaleY = scaleX; } - if (_settings.ShrinkOnly - && _settings.SelectedSize.Unit != ResizeUnit.Percent - && (scaleX >= 1 || scaleY >= 1)) + // Handle Shrink Only mode. + if (_settings.ShrinkOnly && _settings.SelectedSize.Unit != ResizeUnit.Percent) { - return source; + // Shrink Only mode should never return an image larger than the original. + if (scaleX > 1 || scaleY > 1) + { + return source; + } + + // Allow for crop-only when in Fill mode. + // At this point, the scale is <= 1.0. In Fill mode, it is possible for + // the scale to be 1.0 (no resize needed) while the target dimensions are + // smaller than the originals, requiring a crop. + bool isFillCropRequired = _settings.SelectedSize.Fit == ResizeFit.Fill && + (originalWidth > width || originalHeight > height); + + // If the scale is exactly 1.0 and a crop isn't required, we return the + // original image to prevent a re-encode. + if (scaleX == 1 && scaleY == 1 && !isFillCropRequired) + { + return source; + } } + // Apply the scaling. var scaledBitmap = new TransformedBitmap(source, new ScaleTransform(scaleX, scaleY)); + + // Apply the centered crop for Fill mode, if necessary. Applies when Fill + // mode caused the scaled image to exceed the target dimensions. if (_settings.SelectedSize.Fit == ResizeFit.Fill && (scaledBitmap.PixelWidth > width || scaledBitmap.PixelHeight > height)) { - var x = (int)(((originalWidth * scaleX) - width) / 2); - var y = (int)(((originalHeight * scaleY) - height) / 2); + int x = (int)(((originalWidth * scaleX) - width) / 2); + int y = (int)(((originalHeight * scaleY) - height) / 2); return new CroppedBitmap(scaledBitmap, new Int32Rect(x, y, (int)width, (int)height)); } @@ -217,6 +269,31 @@ namespace ImageResizer.Models return scaledBitmap; } + private BitmapSource TransformWithAi(BitmapSource source) + { + try + { + var result = _aiSuperResolutionService.ApplySuperResolution( + source, + _settings.AiSize.Scale, + _file); + + if (result == null) + { + throw new InvalidOperationException(Properties.Resources.Error_AiConversionFailed); + } + + return result; + } + catch (Exception ex) + { + // Wrap the exception with a localized message + // This will be caught by ResizeBatch.Process() and displayed to the user + var errorMessage = string.Format(CultureInfo.CurrentCulture, _aiErrorFormat, ex.Message); + throw new InvalidOperationException(errorMessage, ex); + } + } + /// <summary> /// Checks original metadata by writing an image containing the given metadata into a memory stream. /// In case of errors, we try to rebuild the metadata object and check again. @@ -323,19 +400,24 @@ namespace ImageResizer.Models } // Remove directory characters from the size's name. - string sizeNameSanitized = _settings.SelectedSize.Name; - sizeNameSanitized = sizeNameSanitized + // For AI Size, use the scale display (e.g., "2×") instead of the full name + string sizeName = _settings.SelectedSize is AiSize aiSize + ? aiSize.ScaleDisplay + : _settings.SelectedSize.Name; + string sizeNameSanitized = sizeName .Replace('\\', '_') .Replace('/', '_'); // Using CurrentCulture since this is user facing + var selectedWidth = _settings.SelectedSize is AiSize ? encoder.Frames[0].PixelWidth : _settings.SelectedSize.Width; + var selectedHeight = _settings.SelectedSize is AiSize ? encoder.Frames[0].PixelHeight : _settings.SelectedSize.Height; var fileName = string.Format( CultureInfo.CurrentCulture, _settings.FileNameFormat, originalFileName, sizeNameSanitized, - _settings.SelectedSize.Width, - _settings.SelectedSize.Height, + selectedWidth, + selectedHeight, encoder.Frames[0].PixelWidth, encoder.Frames[0].PixelHeight); diff --git a/src/modules/imageresizer/ui/Models/ResizeSize.cs b/src/modules/imageresizer/ui/Models/ResizeSize.cs index ebfca83963..49869f4bd2 100644 --- a/src/modules/imageresizer/ui/Models/ResizeSize.cs +++ b/src/modules/imageresizer/ui/Models/ResizeSize.cs @@ -11,10 +11,11 @@ using System.Text.Json.Serialization; using ImageResizer.Helpers; using ImageResizer.Properties; +using ManagedCommon; namespace ImageResizer.Models { - public class ResizeSize : Observable + public class ResizeSize : Observable, IHasId { private static readonly Dictionary<string, string> _tokens = new Dictionary<string, string> { @@ -24,6 +25,7 @@ namespace ImageResizer.Models ["$phone$"] = Resources.Phone, }; + private int _id; private string _name; private ResizeFit _fit = ResizeFit.Fit; private double _width; @@ -31,8 +33,9 @@ namespace ImageResizer.Models private bool _showHeight = true; private ResizeUnit _unit = ResizeUnit.Pixel; - public ResizeSize(string name, ResizeFit fit, double width, double height, ResizeUnit unit) + public ResizeSize(int id, string name, ResizeFit fit, double width, double height, ResizeUnit unit) { + Id = id; Name = name; Fit = fit; Width = width; @@ -44,6 +47,13 @@ namespace ImageResizer.Models { } + [JsonPropertyName("Id")] + public int Id + { + get => _id; + set => Set(ref _id, value); + } + [JsonPropertyName("name")] public virtual string Name { diff --git a/src/modules/imageresizer/ui/Properties/Resources.Designer.cs b/src/modules/imageresizer/ui/Properties/Resources.Designer.cs index 4229ca31df..916f38e890 100644 --- a/src/modules/imageresizer/ui/Properties/Resources.Designer.cs +++ b/src/modules/imageresizer/ui/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace ImageResizer.Properties { // 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", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -78,6 +78,33 @@ namespace ImageResizer.Properties { } } + /// <summary> + /// Looks up a localized string similar to Failed to convert image format for AI processing.. + /// </summary> + public static string Error_AiConversionFailed { + get { + return ResourceManager.GetString("Error_AiConversionFailed", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to AI super resolution processing failed: {0}. + /// </summary> + public static string Error_AiProcessingFailed { + get { + return ResourceManager.GetString("Error_AiProcessingFailed", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to AI scaling operation failed.. + /// </summary> + public static string Error_AiScalingFailed { + get { + return ResourceManager.GetString("Error_AiScalingFailed", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Height. /// </summary> @@ -105,6 +132,132 @@ namespace ImageResizer.Properties { } } + /// <summary> + /// Looks up a localized string similar to Current:. + /// </summary> + public static string Input_AiCurrentLabel { + get { + return ResourceManager.GetString("Input_AiCurrentLabel", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Checking AI model availability.... + /// </summary> + public static string Input_AiModelChecking { + get { + return ResourceManager.GetString("Input_AiModelChecking", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to AI feature is disabled by system settings.. + /// </summary> + public static string Input_AiModelDisabledByUser { + get { + return ResourceManager.GetString("Input_AiModelDisabledByUser", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Download. + /// </summary> + public static string Input_AiModelDownloadButton { + get { + return ResourceManager.GetString("Input_AiModelDownloadButton", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Failed to download AI model. Please try again.. + /// </summary> + public static string Input_AiModelDownloadFailed { + get { + return ResourceManager.GetString("Input_AiModelDownloadFailed", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Downloading AI model.... + /// </summary> + public static string Input_AiModelDownloading { + get { + return ResourceManager.GetString("Input_AiModelDownloading", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to AI model not downloaded. Click Download to get started.. + /// </summary> + public static string Input_AiModelNotAvailable { + get { + return ResourceManager.GetString("Input_AiModelNotAvailable", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to AI feature is not supported on this system.. + /// </summary> + public static string Input_AiModelNotSupported { + get { + return ResourceManager.GetString("Input_AiModelNotSupported", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to New:. + /// </summary> + public static string Input_AiNewLabel { + get { + return ResourceManager.GetString("Input_AiNewLabel", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0}×. + /// </summary> + public static string Input_AiScaleFormat { + get { + return ResourceManager.GetString("Input_AiScaleFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Scale. + /// </summary> + public static string Input_AiScaleLabel { + get { + return ResourceManager.GetString("Input_AiScaleLabel", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Super resolution. + /// </summary> + public static string Input_AiSuperResolution { + get { + return ResourceManager.GetString("Input_AiSuperResolution", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Upscale images using on-device AI. + /// </summary> + public static string Input_AiSuperResolutionDescription { + get { + return ResourceManager.GetString("Input_AiSuperResolutionDescription", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Unavailable. + /// </summary> + public static string Input_AiUnknownSize { + get { + return ResourceManager.GetString("Input_AiUnknownSize", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to (auto). /// </summary> @@ -563,5 +716,437 @@ namespace ImageResizer.Properties { return ResourceManager.GetString("Width", resourceCulture); } } + + /// <summary> + /// Looks up a localized string similar to Processing {0} files.... + /// </summary> + public static string CLI_ProcessingFiles { + get { + return ResourceManager.GetString("CLI_ProcessingFiles", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to [{0}%] {1}/{2} completed. + /// </summary> + public static string CLI_ProgressFormat { + get { + return ResourceManager.GetString("CLI_ProgressFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Completed with {0} error(s).. + /// </summary> + public static string CLI_CompletedWithErrors { + get { + return ResourceManager.GetString("CLI_CompletedWithErrors", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to All files processed successfully!. + /// </summary> + public static string CLI_AllFilesProcessed { + get { + return ResourceManager.GetString("CLI_AllFilesProcessed", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to No input files or pipe specified. Showing usage.. + /// </summary> + public static string CLI_NoInputFiles { + get { + return ResourceManager.GetString("CLI_NoInputFiles", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Warning: Size index {0} is invalid. Using custom size.. + /// </summary> + public static string CLI_WarningInvalidSizeIndex { + get { + return ResourceManager.GetString("CLI_WarningInvalidSizeIndex", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Current Configuration:. + /// </summary> + public static string CLI_ConfigTitle { + get { + return ResourceManager.GetString("CLI_ConfigTitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to General Settings:. + /// </summary> + public static string CLI_ConfigGeneralSettings { + get { + return ResourceManager.GetString("CLI_ConfigGeneralSettings", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Shrink only: {0}. + /// </summary> + public static string CLI_ConfigShrinkOnly { + get { + return ResourceManager.GetString("CLI_ConfigShrinkOnly", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Replace original: {0}. + /// </summary> + public static string CLI_ConfigReplaceOriginal { + get { + return ResourceManager.GetString("CLI_ConfigReplaceOriginal", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Ignore orientation: {0}. + /// </summary> + public static string CLI_ConfigIgnoreOrientation { + get { + return ResourceManager.GetString("CLI_ConfigIgnoreOrientation", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Remove metadata: {0}. + /// </summary> + public static string CLI_ConfigRemoveMetadata { + get { + return ResourceManager.GetString("CLI_ConfigRemoveMetadata", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Keep date modified: {0}. + /// </summary> + public static string CLI_ConfigKeepDateModified { + get { + return ResourceManager.GetString("CLI_ConfigKeepDateModified", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to JPEG quality: {0}. + /// </summary> + public static string CLI_ConfigJpegQuality { + get { + return ResourceManager.GetString("CLI_ConfigJpegQuality", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PNG interlace: {0}. + /// </summary> + public static string CLI_ConfigPngInterlace { + get { + return ResourceManager.GetString("CLI_ConfigPngInterlace", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to TIFF compress: {0}. + /// </summary> + public static string CLI_ConfigTiffCompress { + get { + return ResourceManager.GetString("CLI_ConfigTiffCompress", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Filename format: {0}. + /// </summary> + public static string CLI_ConfigFilenameFormat { + get { + return ResourceManager.GetString("CLI_ConfigFilenameFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Custom Size:. + /// </summary> + public static string CLI_ConfigCustomSize { + get { + return ResourceManager.GetString("CLI_ConfigCustomSize", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Width: {0}. + /// </summary> + public static string CLI_ConfigWidth { + get { + return ResourceManager.GetString("CLI_ConfigWidth", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Height: {0}. + /// </summary> + public static string CLI_ConfigHeight { + get { + return ResourceManager.GetString("CLI_ConfigHeight", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Fit mode: {0}. + /// </summary> + public static string CLI_ConfigFitMode { + get { + return ResourceManager.GetString("CLI_ConfigFitMode", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Preset Sizes: (* = currently selected). + /// </summary> + public static string CLI_ConfigPresetSizes { + get { + return ResourceManager.GetString("CLI_ConfigPresetSizes", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to {0}: {1} x {2} ({3}). + /// </summary> + public static string CLI_ConfigPresetSizeFormat { + get { + return ResourceManager.GetString("CLI_ConfigPresetSizeFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to → Custom size selected. + /// </summary> + public static string CLI_ConfigCustomSelected { + get { + return ResourceManager.GetString("CLI_ConfigCustomSelected", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Image Resizer CLI. + /// </summary> + public static string CLI_UsageTitle { + get { + return ResourceManager.GetString("CLI_UsageTitle", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Usage: PowerToys.ImageResizer.exe [options] <files>. + /// </summary> + public static string CLI_UsageLine { + get { + return ResourceManager.GetString("CLI_UsageLine", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Options:. + /// </summary> + public static string CLI_UsageOptions { + get { + return ResourceManager.GetString("CLI_UsageOptions", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Examples:. + /// </summary> + public static string CLI_UsageExamples { + get { + return ResourceManager.GetString("CLI_UsageExamples", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PowerToys.ImageResizer.exe --help. + /// </summary> + public static string CLI_UsageExampleHelp { + get { + return ResourceManager.GetString("CLI_UsageExampleHelp", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PowerToys.ImageResizer.exe --width 800 --height 600 image.jpg. + /// </summary> + public static string CLI_UsageExampleDimensions { + get { + return ResourceManager.GetString("CLI_UsageExampleDimensions", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PowerToys.ImageResizer.exe --size 50 --unit percent *.jpg. + /// </summary> + public static string CLI_UsageExamplePercent { + get { + return ResourceManager.GetString("CLI_UsageExamplePercent", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to PowerToys.ImageResizer.exe --size 2 image1.png image2.png. + /// </summary> + public static string CLI_UsageExamplePreset { + get { + return ResourceManager.GetString("CLI_UsageExamplePreset", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Destination directory for resized images. + /// </summary> + public static string CLI_Option_Destination { + get { + return ResourceManager.GetString("CLI_Option_Destination", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Output filename format (e.g., %1 (%2)). + /// </summary> + public static string CLI_Option_FileName { + get { + return ResourceManager.GetString("CLI_Option_FileName", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Image files to resize. + /// </summary> + public static string CLI_Option_Files { + get { + return ResourceManager.GetString("CLI_Option_Files", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to How to fit image: fill, fit, stretch. + /// </summary> + public static string CLI_Option_Fit { + get { + return ResourceManager.GetString("CLI_Option_Fit", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Height of the resized image in pixels. + /// </summary> + public static string CLI_Option_Height { + get { + return ResourceManager.GetString("CLI_Option_Height", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Display this help message. + /// </summary> + public static string CLI_Option_Help { + get { + return ResourceManager.GetString("CLI_Option_Help", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Ignore image orientation metadata. + /// </summary> + public static string CLI_Option_IgnoreOrientation { + get { + return ResourceManager.GetString("CLI_Option_IgnoreOrientation", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Preserve the original file modification date. + /// </summary> + public static string CLI_Option_KeepDateModified { + get { + return ResourceManager.GetString("CLI_Option_KeepDateModified", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Set JPEG quality level (1-100). + /// </summary> + public static string CLI_Option_Quality { + get { + return ResourceManager.GetString("CLI_Option_Quality", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Remove image metadata during resizing. + /// </summary> + public static string CLI_Option_RemoveMetadata { + get { + return ResourceManager.GetString("CLI_Option_RemoveMetadata", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Replace the original image file. + /// </summary> + public static string CLI_Option_Replace { + get { + return ResourceManager.GetString("CLI_Option_Replace", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Display current configuration. + /// </summary> + public static string CLI_Option_ShowConfig { + get { + return ResourceManager.GetString("CLI_Option_ShowConfig", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Only shrink images, do not enlarge. + /// </summary> + public static string CLI_Option_ShrinkOnly { + get { + return ResourceManager.GetString("CLI_Option_ShrinkOnly", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Use preset size by index (0-based). + /// </summary> + public static string CLI_Option_Size { + get { + return ResourceManager.GetString("CLI_Option_Size", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Unit of measurement: pixel, percent, cm, inch. + /// </summary> + public static string CLI_Option_Unit { + get { + return ResourceManager.GetString("CLI_Option_Unit", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Width of the resized image in pixels. + /// </summary> + public static string CLI_Option_Width { + get { + return ResourceManager.GetString("CLI_Option_Width", resourceCulture); + } + } } } diff --git a/src/modules/imageresizer/ui/Properties/Resources.resx b/src/modules/imageresizer/ui/Properties/Resources.resx index 0d6e81d311..a939ab1808 100644 --- a/src/modules/imageresizer/ui/Properties/Resources.resx +++ b/src/modules/imageresizer/ui/Properties/Resources.resx @@ -296,4 +296,207 @@ <data name="Input_ShrinkOnly.Content" xml:space="preserve"> <value>_Make pictures smaller but not larger</value> </data> + <data name="Input_AiSuperResolution" xml:space="preserve"> + <value>Super resolution</value> + </data> + <data name="Input_AiUnknownSize" xml:space="preserve"> + <value>Unavailable</value> + </data> + <data name="Input_AiScaleFormat" xml:space="preserve"> + <value>{0}×</value> + </data> + <data name="Input_AiScaleLabel" xml:space="preserve"> + <value>Scale</value> + </data> + <data name="Input_AiCurrentLabel" xml:space="preserve"> + <value>Current:</value> + </data> + <data name="Input_AiNewLabel" xml:space="preserve"> + <value>New:</value> + </data> + <data name="Input_AiModelChecking" xml:space="preserve"> + <value>Checking AI model availability...</value> + </data> + <data name="Input_AiModelNotAvailable" xml:space="preserve"> + <value>AI model not downloaded. Click Download to get started.</value> + </data> + <data name="Input_AiModelDisabledByUser" xml:space="preserve"> + <value>AI feature is disabled by system settings.</value> + </data> + <data name="Input_AiModelNotSupported" xml:space="preserve"> + <value>AI feature is not supported on this system.</value> + </data> + <data name="Input_AiModelDownloading" xml:space="preserve"> + <value>Downloading AI model...</value> + </data> + <data name="Input_AiModelDownloadFailed" xml:space="preserve"> + <value>Failed to download AI model. Please try again.</value> + </data> + <data name="Input_AiModelDownloadButton" xml:space="preserve"> + <value>Download</value> + </data> + <data name="Error_AiProcessingFailed" xml:space="preserve"> + <value>AI super resolution processing failed: {0}</value> + </data> + <data name="Error_AiConversionFailed" xml:space="preserve"> + <value>Failed to convert image format for AI processing.</value> + </data> + <data name="Error_AiScalingFailed" xml:space="preserve"> + <value>AI scaling operation failed.</value> + </data> + <data name="Input_AiSuperResolutionDescription" xml:space="preserve"> + <value>Upscale images using on-device AI</value> + </data> + + <!-- CLI Processing messages --> + <data name="CLI_ProcessingFiles" xml:space="preserve"> + <value>Processing {0} file(s)...</value> + </data> + <data name="CLI_ProgressFormat" xml:space="preserve"> + <value>Progress: {0}% ({1}/{2})</value> + </data> + <data name="CLI_CompletedWithErrors" xml:space="preserve"> + <value>Completed with {0} error(s):</value> + </data> + <data name="CLI_AllFilesProcessed" xml:space="preserve"> + <value>All files processed successfully.</value> + </data> + <data name="CLI_WarningInvalidSizeIndex" xml:space="preserve"> + <value>Warning: Invalid size index {0}. Using default.</value> + </data> + <data name="CLI_NoInputFiles" xml:space="preserve"> + <value>No input files or pipe specified. Showing usage.</value> + </data> + + <!-- CLI Config display --> + <data name="CLI_ConfigTitle" xml:space="preserve"> + <value>ImageResizer - Current Configuration</value> + </data> + <data name="CLI_ConfigGeneralSettings" xml:space="preserve"> + <value>General Settings:</value> + </data> + <data name="CLI_ConfigShrinkOnly" xml:space="preserve"> + <value> Shrink Only: {0}</value> + </data> + <data name="CLI_ConfigReplaceOriginal" xml:space="preserve"> + <value> Replace Original: {0}</value> + </data> + <data name="CLI_ConfigIgnoreOrientation" xml:space="preserve"> + <value> Ignore Orientation: {0}</value> + </data> + <data name="CLI_ConfigRemoveMetadata" xml:space="preserve"> + <value> Remove Metadata: {0}</value> + </data> + <data name="CLI_ConfigKeepDateModified" xml:space="preserve"> + <value> Keep Date Modified: {0}</value> + </data> + <data name="CLI_ConfigJpegQuality" xml:space="preserve"> + <value> JPEG Quality: {0}</value> + </data> + <data name="CLI_ConfigPngInterlace" xml:space="preserve"> + <value> PNG Interlace: {0}</value> + </data> + <data name="CLI_ConfigTiffCompress" xml:space="preserve"> + <value> TIFF Compress: {0}</value> + </data> + <data name="CLI_ConfigFilenameFormat" xml:space="preserve"> + <value> Filename Format: {0}</value> + </data> + <data name="CLI_ConfigCustomSize" xml:space="preserve"> + <value>Custom Size:</value> + </data> + <data name="CLI_ConfigWidth" xml:space="preserve"> + <value> Width: {0} {1}</value> + </data> + <data name="CLI_ConfigHeight" xml:space="preserve"> + <value> Height: {0} {1}</value> + </data> + <data name="CLI_ConfigFitMode" xml:space="preserve"> + <value> Fit Mode: {0}</value> + </data> + <data name="CLI_ConfigPresetSizes" xml:space="preserve"> + <value>Preset Sizes: (* = currently selected)</value> + </data> + <data name="CLI_ConfigPresetSizeFormat" xml:space="preserve"> + <value> [{0}]{1} {2}: {3}x{4} {5} ({6})</value> + </data> + <data name="CLI_ConfigCustomSelected" xml:space="preserve"> + <value> [Custom]* {0}x{1} {2} ({3})</value> + </data> + + <!-- CLI Usage help --> + <data name="CLI_UsageTitle" xml:space="preserve"> + <value>ImageResizer - PowerToys Image Resizer CLI</value> + </data> + <data name="CLI_UsageLine" xml:space="preserve"> + <value>Usage: PowerToys.ImageResizerCLI.exe [options] [files...]</value> + </data> + <data name="CLI_UsageOptions" xml:space="preserve"> + <value>Options:</value> + </data> + <data name="CLI_UsageExamples" xml:space="preserve"> + <value>Examples:</value> + </data> + <data name="CLI_UsageExampleHelp" xml:space="preserve"> + <value> PowerToys.ImageResizerCLI.exe --help</value> + </data> + <data name="CLI_UsageExampleDimensions" xml:space="preserve"> + <value> PowerToys.ImageResizerCLI.exe --width 800 --height 600 image.jpg</value> + </data> + <data name="CLI_UsageExamplePercent" xml:space="preserve"> + <value> PowerToys.ImageResizerCLI.exe -w 50 -h 50 -u Percent *.jpg</value> + </data> + <data name="CLI_UsageExamplePreset" xml:space="preserve"> + <value> PowerToys.ImageResizerCLI.exe --size 0 -d "C:\Output" photo.png</value> + </data> + + <!-- CLI Option Descriptions --> + <data name="CLI_Option_Destination" xml:space="preserve"> + <value>Set destination directory</value> + </data> + <data name="CLI_Option_FileName" xml:space="preserve"> + <value>Set output filename format (%1=original name, %2=size name)</value> + </data> + <data name="CLI_Option_Files" xml:space="preserve"> + <value>Image files to resize</value> + </data> + <data name="CLI_Option_Fit" xml:space="preserve"> + <value>Set fit mode (Fill, Fit, Stretch)</value> + </data> + <data name="CLI_Option_Height" xml:space="preserve"> + <value>Set height</value> + </data> + <data name="CLI_Option_Help" xml:space="preserve"> + <value>Show help information</value> + </data> + <data name="CLI_Option_IgnoreOrientation" xml:space="preserve"> + <value>Ignore image orientation</value> + </data> + <data name="CLI_Option_KeepDateModified" xml:space="preserve"> + <value>Keep original date modified</value> + </data> + <data name="CLI_Option_Quality" xml:space="preserve"> + <value>Set JPEG quality level (1-100)</value> + </data> + <data name="CLI_Option_Replace" xml:space="preserve"> + <value>Replace original files</value> + </data> + <data name="CLI_Option_ShowConfig" xml:space="preserve"> + <value>Show current configuration</value> + </data> + <data name="CLI_Option_ShrinkOnly" xml:space="preserve"> + <value>Only shrink images, don't enlarge</value> + </data> + <data name="CLI_Option_RemoveMetadata" xml:space="preserve"> + <value>Remove metadata from resized images</value> + </data> + <data name="CLI_Option_Size" xml:space="preserve"> + <value>Use preset size by index (0-based)</value> + </data> + <data name="CLI_Option_Unit" xml:space="preserve"> + <value>Set unit (Pixel, Percent, Inch, Centimeter)</value> + </data> + <data name="CLI_Option_Width" xml:space="preserve"> + <value>Set width</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/imageresizer/ui/Properties/Settings.cs b/src/modules/imageresizer/ui/Properties/Settings.cs index 4d7ebb3848..a6b535a782 100644 --- a/src/modules/imageresizer/ui/Properties/Settings.cs +++ b/src/modules/imageresizer/ui/Properties/Settings.cs @@ -15,13 +15,27 @@ using System.IO.Abstractions; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Windows.Media.Imaging; using ImageResizer.Models; +using ImageResizer.Services; +using ImageResizer.ViewModels; +using ManagedCommon; namespace ImageResizer.Properties { + /// <summary> + /// Represents the availability state of AI Super Resolution feature. + /// </summary> + public enum AiAvailabilityState + { + NotSupported, // System doesn't support AI (architecture issue or policy disabled) + ModelNotReady, // AI supported but model not downloaded + Ready, // AI fully ready to use + } + public sealed partial class Settings : IDataErrorInfo, INotifyPropertyChanged { private static readonly IFileSystem _fileSystem = new FileSystem(); @@ -29,6 +43,7 @@ namespace ImageResizer.Properties { NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, WriteIndented = true, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), }; private static readonly CompositeFormat ValueMustBeBetween = System.Text.CompositeFormat.Parse(Properties.Resources.ValueMustBeBetween); @@ -49,6 +64,7 @@ namespace ImageResizer.Properties private bool _keepDateModified; private System.Guid _fallbackEncoder; private CustomSize _customSize; + private AiSize _aiSize; public Settings() { @@ -63,17 +79,36 @@ namespace ImageResizer.Properties FileName = "%1 (%2)"; Sizes = new ObservableCollection<ResizeSize> { - new ResizeSize("$small$", ResizeFit.Fit, 854, 480, ResizeUnit.Pixel), - new ResizeSize("$medium$", ResizeFit.Fit, 1366, 768, ResizeUnit.Pixel), - new ResizeSize("$large$", ResizeFit.Fit, 1920, 1080, ResizeUnit.Pixel), - new ResizeSize("$phone$", ResizeFit.Fit, 320, 568, ResizeUnit.Pixel), + new ResizeSize(0, "$small$", ResizeFit.Fit, 854, 480, ResizeUnit.Pixel), + new ResizeSize(1, "$medium$", ResizeFit.Fit, 1366, 768, ResizeUnit.Pixel), + new ResizeSize(2, "$large$", ResizeFit.Fit, 1920, 1080, ResizeUnit.Pixel), + new ResizeSize(3, "$phone$", ResizeFit.Fit, 320, 568, ResizeUnit.Pixel), }; KeepDateModified = false; FallbackEncoder = new System.Guid("19e4a5aa-5662-4fc5-a0c0-1758028e1057"); CustomSize = new CustomSize(ResizeFit.Fit, 1024, 640, ResizeUnit.Pixel); + AiSize = new AiSize(2); // Initialize with default scale of 2 AllSizes = new AllSizesCollection(this); } + /// <summary> + /// Validates the SelectedSizeIndex to ensure it's within the valid range. + /// This handles cross-device migration where settings saved on ARM64 with AI selected + /// are loaded on non-ARM64 devices. + /// </summary> + private void ValidateSelectedSizeIndex() + { + // Index structure: 0 to Sizes.Count-1 (regular), Sizes.Count (CustomSize), Sizes.Count+1 (AiSize) + var maxIndex = ImageResizer.App.AiAvailabilityState == AiAvailabilityState.NotSupported + ? Sizes.Count // CustomSize only + : Sizes.Count + 1; // CustomSize + AiSize + + if (_selectedSizeIndex > maxIndex) + { + _selectedSizeIndex = 0; // Reset to first size + } + } + [JsonIgnore] public IEnumerable<ResizeSize> AllSizes { get; set; } @@ -93,15 +128,40 @@ namespace ImageResizer.Properties [JsonIgnore] public ResizeSize SelectedSize { - get => SelectedSizeIndex >= 0 && SelectedSizeIndex < Sizes.Count - ? Sizes[SelectedSizeIndex] - : CustomSize; + get + { + if (SelectedSizeIndex >= 0 && SelectedSizeIndex < Sizes.Count) + { + return Sizes[SelectedSizeIndex]; + } + else if (SelectedSizeIndex == Sizes.Count) + { + return CustomSize; + } + else if (ImageResizer.App.AiAvailabilityState != AiAvailabilityState.NotSupported && SelectedSizeIndex == Sizes.Count + 1) + { + return AiSize; + } + else + { + // Fallback to CustomSize when index is out of range or AI is not available + return CustomSize; + } + } + set { var index = Sizes.IndexOf(value); if (index == -1) { - index = Sizes.Count; + if (value is AiSize) + { + index = Sizes.Count + 1; + } + else + { + index = Sizes.Count; + } } SelectedSizeIndex = index; @@ -137,13 +197,17 @@ namespace ImageResizer.Properties private class AllSizesCollection : IEnumerable<ResizeSize>, INotifyCollectionChanged, INotifyPropertyChanged { + private readonly Settings _settings; private ObservableCollection<ResizeSize> _sizes; private CustomSize _customSize; + private AiSize _aiSize; public AllSizesCollection(Settings settings) { + _settings = settings; _sizes = settings.Sizes; _customSize = settings.CustomSize; + _aiSize = settings.AiSize; _sizes.CollectionChanged += HandleCollectionChanged; ((INotifyPropertyChanged)_sizes).PropertyChanged += HandlePropertyChanged; @@ -152,15 +216,15 @@ namespace ImageResizer.Properties { if (e.PropertyName == nameof(Models.CustomSize)) { - var oldCustomSize = _customSize; _customSize = settings.CustomSize; - OnCollectionChanged( - new NotifyCollectionChangedEventArgs( - NotifyCollectionChangedAction.Replace, - _customSize, - oldCustomSize, - _sizes.Count)); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + else if (e.PropertyName == nameof(Models.AiSize)) + { + _aiSize = settings.AiSize; + + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } else if (e.PropertyName == nameof(Sizes)) { @@ -184,12 +248,30 @@ namespace ImageResizer.Properties public event PropertyChangedEventHandler PropertyChanged; public int Count - => _sizes.Count + 1; + => _sizes.Count + 1 + (ImageResizer.App.AiAvailabilityState != AiAvailabilityState.NotSupported ? 1 : 0); public ResizeSize this[int index] - => index == _sizes.Count - ? _customSize - : _sizes[index]; + { + get + { + if (index < _sizes.Count) + { + return _sizes[index]; + } + else if (index == _sizes.Count) + { + return _customSize; + } + else if (ImageResizer.App.AiAvailabilityState != AiAvailabilityState.NotSupported && index == _sizes.Count + 1) + { + return _aiSize; + } + else + { + throw new ArgumentOutOfRangeException(nameof(index), index, $"Index {index} is out of range for AllSizesCollection."); + } + } + } public IEnumerator<ResizeSize> GetEnumerator() => new AllSizesEnumerator(this); @@ -409,6 +491,18 @@ namespace ImageResizer.Properties } } + [JsonConverter(typeof(WrappedJsonValueConverter))] + [JsonPropertyName("imageresizer_aiSize")] + public AiSize AiSize + { + get => _aiSize; + set + { + _aiSize = value; + NotifyPropertyChanged(); + } + } + public static string SettingsPath { get => _settingsPath; set => _settingsPath = value; } public event PropertyChangedEventHandler PropertyChanged; @@ -460,30 +554,47 @@ namespace ImageResizer.Properties { } - // Needs to be called on the App UI thread as the properties are bound to the UI. - App.Current.Dispatcher.Invoke(() => + if (App.Current?.Dispatcher != null) { - ShrinkOnly = jsonSettings.ShrinkOnly; - Replace = jsonSettings.Replace; - IgnoreOrientation = jsonSettings.IgnoreOrientation; - RemoveMetadata = jsonSettings.RemoveMetadata; - JpegQualityLevel = jsonSettings.JpegQualityLevel; - PngInterlaceOption = jsonSettings.PngInterlaceOption; - TiffCompressOption = jsonSettings.TiffCompressOption; - FileName = jsonSettings.FileName; - KeepDateModified = jsonSettings.KeepDateModified; - FallbackEncoder = jsonSettings.FallbackEncoder; - CustomSize = jsonSettings.CustomSize; - SelectedSizeIndex = jsonSettings.SelectedSizeIndex; - - if (jsonSettings.Sizes.Count > 0) - { - Sizes.Clear(); - Sizes.AddRange(jsonSettings.Sizes); - } - }); + // Needs to be called on the App UI thread as the properties are bound to the UI. + App.Current.Dispatcher.Invoke(() => ReloadCore(jsonSettings)); + } + else + { + ReloadCore(jsonSettings); + } _jsonMutex.ReleaseMutex(); } + + private void ReloadCore(Settings jsonSettings) + { + ShrinkOnly = jsonSettings.ShrinkOnly; + Replace = jsonSettings.Replace; + IgnoreOrientation = jsonSettings.IgnoreOrientation; + RemoveMetadata = jsonSettings.RemoveMetadata; + JpegQualityLevel = jsonSettings.JpegQualityLevel; + PngInterlaceOption = jsonSettings.PngInterlaceOption; + TiffCompressOption = jsonSettings.TiffCompressOption; + FileName = jsonSettings.FileName; + KeepDateModified = jsonSettings.KeepDateModified; + FallbackEncoder = jsonSettings.FallbackEncoder; + CustomSize = jsonSettings.CustomSize; + AiSize = jsonSettings.AiSize ?? new AiSize(InputViewModel.DefaultAiScale); + SelectedSizeIndex = jsonSettings.SelectedSizeIndex; + + if (jsonSettings.Sizes.Count > 0) + { + Sizes.Clear(); + Sizes.AddRange(jsonSettings.Sizes); + + // Ensure Ids are unique and handle missing Ids + IdRecoveryHelper.RecoverInvalidIds(Sizes); + } + + // Validate SelectedSizeIndex after Sizes collection has been updated + // This handles cross-device migration (e.g., ARM64 -> non-ARM64) + ValidateSelectedSizeIndex(); + } } } diff --git a/src/modules/imageresizer/ui/Services/AiAvailabilityCacheService.cs b/src/modules/imageresizer/ui/Services/AiAvailabilityCacheService.cs new file mode 100644 index 0000000000..474d1c398e --- /dev/null +++ b/src/modules/imageresizer/ui/Services/AiAvailabilityCacheService.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.Json; +using ImageResizer.Properties; +using ManagedCommon; + +namespace ImageResizer.Services +{ + /// <summary> + /// Service for caching AI availability detection results. + /// Persists results to avoid slow API calls on every startup. + /// Runner calls ImageResizer --detect-ai to perform detection, + /// and ImageResizer reads the cached result on normal startup. + /// </summary> + public static class AiAvailabilityCacheService + { + private const string CacheFileName = "ai_capabilities.json"; + private const int CacheVersion = 1; + + private static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions + { + WriteIndented = true, + }; + + private static string CachePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys", + CacheFileName); + + /// <summary> + /// Load AI availability state from cache. + /// Returns null if cache doesn't exist, is invalid, or read fails. + /// </summary> + public static AiAvailabilityState? LoadCache() + { + // Cache disabled - always return null to use default value + return null; + } + + /// <summary> + /// Save AI availability state to cache. + /// Called by --detect-ai mode after performing detection. + /// </summary> + public static void SaveCache(AiAvailabilityState state) + { + // Cache disabled - do not save anything + return; + } + + /// <summary> + /// Validate cache against current system environment. + /// Cache is invalid if version, architecture, or Windows build changed. + /// </summary> + private static bool IsCacheValid(AiCapabilityCache cache) + { + if (cache == null || cache.Version != CacheVersion) + { + return false; + } + + if (cache.Architecture != RuntimeInformation.ProcessArchitecture.ToString()) + { + return false; + } + + if (cache.WindowsBuild != Environment.OSVersion.Version.ToString()) + { + return false; + } + + return true; + } + } +} diff --git a/src/modules/imageresizer/ui/Services/AiCapabilityCache.cs b/src/modules/imageresizer/ui/Services/AiCapabilityCache.cs new file mode 100644 index 0000000000..f787f13569 --- /dev/null +++ b/src/modules/imageresizer/ui/Services/AiCapabilityCache.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace ImageResizer.Services +{ + /// <summary> + /// Data model for AI capability cache file. + /// </summary> + internal sealed class AiCapabilityCache + { + public int Version { get; set; } + + public int State { get; set; } + + public string WindowsBuild { get; set; } + + public string Architecture { get; set; } + + public string Timestamp { get; set; } + } +} diff --git a/src/modules/imageresizer/ui/Services/IAISuperResolutionService.cs b/src/modules/imageresizer/ui/Services/IAISuperResolutionService.cs new file mode 100644 index 0000000000..3db073c5e5 --- /dev/null +++ b/src/modules/imageresizer/ui/Services/IAISuperResolutionService.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. + +using System; +using System.Windows.Media.Imaging; + +namespace ImageResizer.Services +{ + public interface IAISuperResolutionService : IDisposable + { + BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath); + } +} diff --git a/src/modules/imageresizer/ui/Services/NoOpAiSuperResolutionService.cs b/src/modules/imageresizer/ui/Services/NoOpAiSuperResolutionService.cs new file mode 100644 index 0000000000..e59b5033ac --- /dev/null +++ b/src/modules/imageresizer/ui/Services/NoOpAiSuperResolutionService.cs @@ -0,0 +1,27 @@ +// 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.Windows.Media.Imaging; + +namespace ImageResizer.Services +{ + public sealed class NoOpAiSuperResolutionService : IAISuperResolutionService + { + public static NoOpAiSuperResolutionService Instance { get; } = new NoOpAiSuperResolutionService(); + + private NoOpAiSuperResolutionService() + { + } + + public BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath) + { + return source; + } + + public void Dispose() + { + // No resources to dispose in no-op implementation + } + } +} diff --git a/src/modules/imageresizer/ui/Services/WinAiSuperResolutionService.cs b/src/modules/imageresizer/ui/Services/WinAiSuperResolutionService.cs new file mode 100644 index 0000000000..4cd752184a --- /dev/null +++ b/src/modules/imageresizer/ui/Services/WinAiSuperResolutionService.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.WindowsRuntime; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Microsoft.Windows.AI; +using Microsoft.Windows.AI.Imaging; +using Windows.Graphics.Imaging; + +namespace ImageResizer.Services +{ + public sealed class WinAiSuperResolutionService : IAISuperResolutionService + { + private readonly ImageScaler _imageScaler; + private readonly object _usageLock = new object(); + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="WinAiSuperResolutionService"/> class. + /// Private constructor. Use CreateAsync() factory method to create instances. + /// </summary> + private WinAiSuperResolutionService(ImageScaler imageScaler) + { + _imageScaler = imageScaler ?? throw new ArgumentNullException(nameof(imageScaler)); + } + + /// <summary> + /// Async factory method to create and initialize WinAiSuperResolutionService. + /// Returns null if initialization fails. + /// </summary> + public static async Task<WinAiSuperResolutionService> CreateAsync() + { + try + { + var imageScaler = await ImageScaler.CreateAsync(); + if (imageScaler == null) + { + return null; + } + + return new WinAiSuperResolutionService(imageScaler); + } + catch + { + return null; + } + } + + public static AIFeatureReadyState GetModelReadyState() + { + try + { + return ImageScaler.GetReadyState(); + } + catch (Exception) + { + // If we can't get the state, treat it as disabled by user + // The caller should check if it's Ready or NotReady + return AIFeatureReadyState.DisabledByUser; + } + } + + public static async Task<AIFeatureReadyResult> EnsureModelReadyAsync(IProgress<double> progress = null) + { + try + { + var operation = ImageScaler.EnsureReadyAsync(); + + // Register progress handler if provided + if (progress != null) + { + operation.Progress = (asyncInfo, progressValue) => + { + // progressValue is a double representing completion percentage (0.0 to 1.0 or 0 to 100) + progress.Report(progressValue); + }; + } + + return await operation; + } + catch (Exception) + { + return null; + } + } + + public BitmapSource ApplySuperResolution(BitmapSource source, int scale, string filePath) + { + if (source == null || _disposed) + { + return source; + } + + // Note: filePath parameter reserved for future use (e.g., logging, caching) + // Currently not used by the ImageScaler API + try + { + // Convert WPF BitmapSource to WinRT SoftwareBitmap + var softwareBitmap = ConvertBitmapSourceToSoftwareBitmap(source); + if (softwareBitmap == null) + { + return source; + } + + // Calculate target dimensions + var newWidth = softwareBitmap.PixelWidth * scale; + var newHeight = softwareBitmap.PixelHeight * scale; + + // Apply super resolution with thread-safe access + // _usageLock protects concurrent access from Parallel.ForEach threads + SoftwareBitmap scaledBitmap; + lock (_usageLock) + { + if (_disposed) + { + return source; + } + + scaledBitmap = _imageScaler.ScaleSoftwareBitmap(softwareBitmap, newWidth, newHeight); + } + + if (scaledBitmap == null) + { + return source; + } + + // Convert back to WPF BitmapSource + return ConvertSoftwareBitmapToBitmapSource(scaledBitmap); + } + catch (Exception) + { + // Any error, return original image gracefully + return source; + } + } + + private static SoftwareBitmap ConvertBitmapSourceToSoftwareBitmap(BitmapSource bitmapSource) + { + try + { + // Ensure the bitmap is in a compatible format + var convertedBitmap = new FormatConvertedBitmap(); + convertedBitmap.BeginInit(); + convertedBitmap.Source = bitmapSource; + convertedBitmap.DestinationFormat = PixelFormats.Bgra32; + convertedBitmap.EndInit(); + + int width = convertedBitmap.PixelWidth; + int height = convertedBitmap.PixelHeight; + int stride = width * 4; // 4 bytes per pixel for Bgra32 + byte[] pixels = new byte[height * stride]; + + convertedBitmap.CopyPixels(pixels, stride, 0); + + // Create SoftwareBitmap from pixel data + var softwareBitmap = new SoftwareBitmap( + BitmapPixelFormat.Bgra8, + width, + height, + BitmapAlphaMode.Premultiplied); + + using (var buffer = softwareBitmap.LockBuffer(BitmapBufferAccessMode.Write)) + using (var reference = buffer.CreateReference()) + { + unsafe + { + ((IMemoryBufferByteAccess)reference).GetBuffer(out byte* dataInBytes, out uint capacity); + System.Runtime.InteropServices.Marshal.Copy(pixels, 0, (IntPtr)dataInBytes, pixels.Length); + } + } + + return softwareBitmap; + } + catch (Exception) + { + return null; + } + } + + private static BitmapSource ConvertSoftwareBitmapToBitmapSource(SoftwareBitmap softwareBitmap) + { + try + { + // Convert to Bgra8 format if needed + var convertedBitmap = SoftwareBitmap.Convert( + softwareBitmap, + BitmapPixelFormat.Bgra8, + BitmapAlphaMode.Premultiplied); + + int width = convertedBitmap.PixelWidth; + int height = convertedBitmap.PixelHeight; + int stride = width * 4; // 4 bytes per pixel for Bgra8 + byte[] pixels = new byte[height * stride]; + + using (var buffer = convertedBitmap.LockBuffer(BitmapBufferAccessMode.Read)) + using (var reference = buffer.CreateReference()) + { + unsafe + { + ((IMemoryBufferByteAccess)reference).GetBuffer(out byte* dataInBytes, out uint capacity); + System.Runtime.InteropServices.Marshal.Copy((IntPtr)dataInBytes, pixels, 0, pixels.Length); + } + } + + // Create WPF BitmapSource from pixel data + var wpfBitmap = BitmapSource.Create( + width, + height, + 96, // DPI X + 96, // DPI Y + PixelFormats.Bgra32, + null, + pixels, + stride); + + wpfBitmap.Freeze(); // Make it thread-safe + return wpfBitmap; + } + catch (Exception) + { + return null; + } + } + + [ComImport] + [Guid("5B0D3235-4DBA-4D44-865E-8F1D0E4FD04D")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface IMemoryBufferByteAccess + { + unsafe void GetBuffer(out byte* buffer, out uint capacity); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + lock (_usageLock) + { + if (_disposed) + { + return; + } + + // ImageScaler implements IDisposable + (_imageScaler as IDisposable)?.Dispose(); + + _disposed = true; + } + } + } +} diff --git a/src/modules/imageresizer/ui/ViewModels/InputViewModel.cs b/src/modules/imageresizer/ui/ViewModels/InputViewModel.cs index 54728defd4..c241728276 100644 --- a/src/modules/imageresizer/ui/ViewModels/InputViewModel.cs +++ b/src/modules/imageresizer/ui/ViewModels/InputViewModel.cs @@ -6,22 +6,41 @@ using System; using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.IO; using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; using System.Windows.Input; - +using System.Windows.Media.Imaging; using Common.UI; using ImageResizer.Helpers; using ImageResizer.Models; using ImageResizer.Properties; +using ImageResizer.Services; using ImageResizer.Views; namespace ImageResizer.ViewModels { public class InputViewModel : Observable { + public const int DefaultAiScale = 2; + private const int MinAiScale = 1; + private const int MaxAiScale = 8; + private readonly ResizeBatch _batch; private readonly MainViewModel _mainViewModel; private readonly IMainView _mainView; + private readonly bool _hasMultipleFiles; + private bool _originalDimensionsLoaded; + private int? _originalWidth; + private int? _originalHeight; + private string _currentResolutionDescription; + private string _newResolutionDescription; + private bool _isDownloadingModel; + private string _modelStatusMessage; + private double _modelDownloadProgress; public enum Dimension { @@ -45,24 +64,114 @@ namespace ImageResizer.ViewModels _batch = batch; _mainViewModel = mainViewModel; _mainView = mainView; + _hasMultipleFiles = _batch?.Files.Count > 1; Settings = settings; if (settings != null) { settings.CustomSize.PropertyChanged += (sender, e) => settings.SelectedSize = (CustomSize)sender; + settings.AiSize.PropertyChanged += (sender, e) => + { + if (e.PropertyName == nameof(AiSize.Scale)) + { + NotifyAiScaleChanged(); + } + }; + settings.PropertyChanged += HandleSettingsPropertyChanged; } - ResizeCommand = new RelayCommand(Resize); + ResizeCommand = new RelayCommand(Resize, () => CanResize); CancelCommand = new RelayCommand(Cancel); OpenSettingsCommand = new RelayCommand(OpenSettings); EnterKeyPressedCommand = new RelayCommand<KeyPressParams>(HandleEnterKeyPress); + DownloadModelCommand = new RelayCommand(async () => await DownloadModelAsync()); + + // Initialize AI UI state based on Settings availability + InitializeAiState(); } public Settings Settings { get; } - public IEnumerable<ResizeFit> ResizeFitValues => Enum.GetValues(typeof(ResizeFit)).Cast<ResizeFit>(); + public IEnumerable<ResizeFit> ResizeFitValues => Enum.GetValues<ResizeFit>(); - public IEnumerable<ResizeUnit> ResizeUnitValues => Enum.GetValues(typeof(ResizeUnit)).Cast<ResizeUnit>(); + public IEnumerable<ResizeUnit> ResizeUnitValues => Enum.GetValues<ResizeUnit>(); + + public int AiSuperResolutionScale + { + get => Settings?.AiSize?.Scale ?? DefaultAiScale; + set + { + if (Settings?.AiSize != null && Settings.AiSize.Scale != value) + { + Settings.AiSize.Scale = value; + NotifyAiScaleChanged(); + } + } + } + + public string AiScaleDisplay => Settings?.AiSize?.ScaleDisplay ?? string.Empty; + + public string CurrentResolutionDescription + { + get => _currentResolutionDescription; + private set => Set(ref _currentResolutionDescription, value); + } + + public string NewResolutionDescription + { + get => _newResolutionDescription; + private set => Set(ref _newResolutionDescription, value); + } + + // ==================== UI State Properties ==================== + + // Show AI size descriptions only when AI size is selected and not multiple files + public bool ShowAiSizeDescriptions => Settings?.SelectedSize is AiSize && !_hasMultipleFiles; + + // Helper property: Is model currently being downloaded? + public bool IsModelDownloading => _isDownloadingModel; + + public string ModelStatusMessage + { + get => _modelStatusMessage; + private set => Set(ref _modelStatusMessage, value); + } + + public double ModelDownloadProgress + { + get => _modelDownloadProgress; + private set => Set(ref _modelDownloadProgress, value); + } + + // Show download prompt when: AI size is selected and model is not ready (including downloading) + public bool ShowModelDownloadPrompt => + Settings?.SelectedSize is AiSize && + (App.AiAvailabilityState == Properties.AiAvailabilityState.ModelNotReady || _isDownloadingModel); + + // Show AI controls when: AI size is selected and AI is ready + public bool ShowAiControls => + Settings?.SelectedSize is AiSize && + App.AiAvailabilityState == Properties.AiAvailabilityState.Ready; + + /// <summary> + /// Gets a value indicating whether the resize operation can proceed. + /// For AI resize: only enabled when AI is fully ready. + /// For non-AI resize: always enabled. + /// </summary> + public bool CanResize + { + get + { + // If AI size is selected, only allow resize when AI is fully ready + if (Settings?.SelectedSize is AiSize) + { + return App.AiAvailabilityState == Properties.AiAvailabilityState.Ready; + } + + // Non-AI resize can always proceed + return true; + } + } public ICommand ResizeCommand { get; } @@ -72,9 +181,11 @@ namespace ImageResizer.ViewModels public ICommand EnterKeyPressedCommand { get; private set; } + public ICommand DownloadModelCommand { get; private set; } + // Any of the files is a gif public bool TryingToResizeGifFiles => - _batch.Files.Any(filename => filename.EndsWith(".gif", System.StringComparison.InvariantCultureIgnoreCase)); + _batch?.Files.Any(filename => filename.EndsWith(".gif", System.StringComparison.InvariantCultureIgnoreCase)) == true; public void Resize() { @@ -84,7 +195,7 @@ namespace ImageResizer.ViewModels public static void OpenSettings() { - SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.ImageResizer, false); + SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.ImageResizer); } private void HandleEnterKeyPress(KeyPressParams parameters) @@ -102,5 +213,234 @@ namespace ImageResizer.ViewModels public void Cancel() => _mainView.Close(); + + private void HandleSettingsPropertyChanged(object sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(Settings.SelectedSizeIndex): + case nameof(Settings.SelectedSize): + // Notify UI state properties that depend on SelectedSize + NotifyAiStateChanged(); + UpdateAiDetails(); + + // Trigger CanExecuteChanged for ResizeCommand + if (ResizeCommand is RelayCommand cmd) + { + cmd.OnCanExecuteChanged(); + } + + break; + } + } + + private void EnsureAiScaleWithinRange() + { + if (Settings?.AiSize != null) + { + Settings.AiSize.Scale = Math.Clamp( + Settings.AiSize.Scale, + MinAiScale, + MaxAiScale); + } + } + + private void UpdateAiDetails() + { + // Clear AI details if AI size not selected + if (Settings == null || Settings.SelectedSize is not AiSize) + { + CurrentResolutionDescription = string.Empty; + NewResolutionDescription = string.Empty; + return; + } + + EnsureAiScaleWithinRange(); + + if (_hasMultipleFiles) + { + CurrentResolutionDescription = string.Empty; + NewResolutionDescription = string.Empty; + return; + } + + EnsureOriginalDimensionsLoaded(); + + var hasConcreteSize = _originalWidth.HasValue && _originalHeight.HasValue; + CurrentResolutionDescription = hasConcreteSize + ? FormatDimensions(_originalWidth!.Value, _originalHeight!.Value) + : Resources.Input_AiUnknownSize; + + var scale = Settings.AiSize.Scale; + NewResolutionDescription = hasConcreteSize + ? FormatDimensions((long)_originalWidth!.Value * scale, (long)_originalHeight!.Value * scale) + : Resources.Input_AiUnknownSize; + } + + private static string FormatDimensions(long width, long height) + { + return string.Format(CultureInfo.CurrentCulture, "{0} × {1}", width, height); + } + + private void EnsureOriginalDimensionsLoaded() + { + if (_originalDimensionsLoaded) + { + return; + } + + var file = _batch?.Files.FirstOrDefault(); + if (string.IsNullOrEmpty(file)) + { + _originalDimensionsLoaded = true; + return; + } + + try + { + using var stream = File.OpenRead(file); + var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.None); + var frame = decoder.Frames.FirstOrDefault(); + if (frame != null) + { + _originalWidth = frame.PixelWidth; + _originalHeight = frame.PixelHeight; + } + } + catch (Exception) + { + // Failed to load image dimensions - clear values + _originalWidth = null; + _originalHeight = null; + } + finally + { + _originalDimensionsLoaded = true; + } + } + + /// <summary> + /// Initializes AI UI state based on App's cached availability state. + /// Subscribe to state change event to update UI when background initialization completes. + /// </summary> + private void InitializeAiState() + { + // Subscribe to initialization completion event to refresh UI + App.AiInitializationCompleted += OnAiInitializationCompleted; + + // Set initial status message based on current state + UpdateStatusMessage(); + } + + /// <summary> + /// Handles AI initialization completion event from App. + /// Refreshes UI when background initialization finishes. + /// </summary> + private void OnAiInitializationCompleted(object sender, Properties.AiAvailabilityState finalState) + { + UpdateStatusMessage(); + NotifyAiStateChanged(); + } + + /// <summary> + /// Updates status message based on current App availability state. + /// </summary> + private void UpdateStatusMessage() + { + ModelStatusMessage = App.AiAvailabilityState switch + { + Properties.AiAvailabilityState.Ready => string.Empty, + Properties.AiAvailabilityState.ModelNotReady => Resources.Input_AiModelNotAvailable, + Properties.AiAvailabilityState.NotSupported => Resources.Input_AiModelNotSupported, + _ => string.Empty, + }; + } + + /// <summary> + /// Notifies UI when AI state changes (model availability, download status). + /// </summary> + private void NotifyAiStateChanged() + { + OnPropertyChanged(nameof(IsModelDownloading)); + OnPropertyChanged(nameof(ShowModelDownloadPrompt)); + OnPropertyChanged(nameof(ShowAiControls)); + OnPropertyChanged(nameof(ShowAiSizeDescriptions)); + OnPropertyChanged(nameof(CanResize)); + + // Trigger CanExecuteChanged for ResizeCommand + if (ResizeCommand is RelayCommand resizeCommand) + { + resizeCommand.OnCanExecuteChanged(); + } + } + + /// <summary> + /// Notifies UI when AI scale changes (slider value). + /// </summary> + private void NotifyAiScaleChanged() + { + OnPropertyChanged(nameof(AiSuperResolutionScale)); + OnPropertyChanged(nameof(AiScaleDisplay)); + UpdateAiDetails(); + } + + private async Task DownloadModelAsync() + { + try + { + // Set downloading flag and show progress + _isDownloadingModel = true; + ModelStatusMessage = Resources.Input_AiModelDownloading; + ModelDownloadProgress = 0; + NotifyAiStateChanged(); + + // Create progress reporter to update UI + var progress = new Progress<double>(value => + { + // progressValue could be 0-1 or 0-100, normalize to 0-100 + ModelDownloadProgress = value > 1 ? value : value * 100; + }); + + // Call EnsureReadyAsync to download and prepare the AI model + var result = await WinAiSuperResolutionService.EnsureModelReadyAsync(progress); + + if (result?.Status == Microsoft.Windows.AI.AIFeatureReadyResultState.Success) + { + // Model successfully downloaded and ready + ModelDownloadProgress = 100; + + // Update App's cached state + App.AiAvailabilityState = Properties.AiAvailabilityState.Ready; + UpdateStatusMessage(); + + // Initialize the AI service now that model is ready + var aiService = await WinAiSuperResolutionService.CreateAsync(); + ResizeBatch.SetAiSuperResolutionService(aiService ?? (Services.IAISuperResolutionService)NoOpAiSuperResolutionService.Instance); + } + else + { + // Download failed + ModelStatusMessage = Resources.Input_AiModelDownloadFailed; + } + } + catch (Exception) + { + // Exception during download + ModelStatusMessage = Resources.Input_AiModelDownloadFailed; + } + finally + { + // Clear downloading flag + _isDownloadingModel = false; + + // Reset progress if not successful + if (App.AiAvailabilityState != Properties.AiAvailabilityState.Ready) + { + ModelDownloadProgress = 0; + } + + NotifyAiStateChanged(); + } + } } } diff --git a/src/modules/imageresizer/ui/Views/BoolValueConverter.cs b/src/modules/imageresizer/ui/Views/BoolValueConverter.cs index ac59068a28..f4e83fab0d 100644 --- a/src/modules/imageresizer/ui/Views/BoolValueConverter.cs +++ b/src/modules/imageresizer/ui/Views/BoolValueConverter.cs @@ -11,11 +11,21 @@ using System.Windows.Data; namespace ImageResizer.Views { - [ValueConversion(typeof(Enum), typeof(string))] + [ValueConversion(typeof(bool), typeof(Visibility))] internal class BoolValueConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => (bool)value ? Visibility.Visible : Visibility.Collapsed; + { + bool boolValue = (bool)value; + bool invert = parameter is string param && param.Equals("Inverted", StringComparison.OrdinalIgnoreCase); + + if (invert) + { + boolValue = !boolValue; + } + + return boolValue ? Visibility.Visible : Visibility.Collapsed; + } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => (Visibility)value == Visibility.Visible; diff --git a/src/modules/imageresizer/ui/Views/InputPage.xaml b/src/modules/imageresizer/ui/Views/InputPage.xaml index ca42f6d793..b45b2a66bd 100644 --- a/src/modules/imageresizer/ui/Views/InputPage.xaml +++ b/src/modules/imageresizer/ui/Views/InputPage.xaml @@ -7,6 +7,23 @@ xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:v="clr-namespace:ImageResizer.Views"> + <UserControl.Resources> + <Style + x:Key="ReadableDisabledButtonStyle" + BasedOn="{StaticResource {x:Type ui:Button}}" + TargetType="ui:Button"> + <Style.Triggers> + <Trigger Property="IsEnabled" Value="False"> + <!-- Improved disabled state: keep readable but clearly disabled --> + <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" /> + <Setter Property="Background" Value="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" /> + <Setter Property="BorderBrush" Value="{DynamicResource {x:Static SystemColors.ControlDarkBrushKey}}" /> + <Setter Property="Opacity" Value="0.75" /> + </Trigger> + </Style.Triggers> + </Style> + </UserControl.Resources> + <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> @@ -15,61 +32,67 @@ <!-- other controls --> </Grid.RowDefinitions> - <ComboBox - Name="SizeComboBox" - Grid.Row="0" - Height="64" - Margin="16" - HorizontalAlignment="Stretch" - VerticalAlignment="Stretch" - VerticalContentAlignment="Stretch" - AutomationProperties.HelpText="{Binding Settings.SelectedSize, Converter={StaticResource SizeTypeToHelpTextConverter}}" - AutomationProperties.Name="{x:Static p:Resources.Image_Sizes}" - ItemsSource="{Binding Settings.AllSizes}" - SelectedIndex="{Binding Settings.SelectedSizeIndex}"> - <ComboBox.ItemContainerStyle> - <Style BasedOn="{StaticResource {x:Type ComboBoxItem}}" TargetType="ComboBoxItem"> - <Setter Property="AutomationProperties.Name" Value="{Binding Name}" /> - <Setter Property="AutomationProperties.HelpText" Value="{Binding ., Converter={StaticResource SizeTypeToHelpTextConverter}}" /> - </Style> - </ComboBox.ItemContainerStyle> - <ComboBox.Resources> - <DataTemplate DataType="{x:Type m:ResizeSize}"> - <Grid VerticalAlignment="Center"> - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - <TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{Binding Name}" /> - <StackPanel Grid.Row="1" Orientation="Horizontal"> - <TextBlock Text="{Binding Fit, Converter={StaticResource EnumValueConverter}, ConverterParameter=ThirdPersonSingular}" /> - <TextBlock - Margin="4,0,0,0" - Style="{StaticResource BodyStrongTextBlockStyle}" - Text="{Binding Width, Converter={StaticResource AutoDoubleConverter}, ConverterParameter=Auto}" /> - <TextBlock - Margin="4,0,0,0" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - Text="×" - Visibility="{Binding ShowHeight, Converter={StaticResource BoolValueConverter}}" /> - <TextBlock - Margin="4,0,0,0" - Style="{StaticResource BodyStrongTextBlockStyle}" - Text="{Binding Height, Converter={StaticResource AutoDoubleConverter}, ConverterParameter=Auto}" - Visibility="{Binding ShowHeight, Converter={StaticResource BoolValueConverter}}" /> - <TextBlock Margin="4,0,0,0" Text="{Binding Unit, Converter={StaticResource EnumValueConverter}, ConverterParameter=ToLower}" /> + <StackPanel Grid.Row="0" Margin="16"> + <ComboBox + Name="SizeComboBox" + Height="64" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + VerticalContentAlignment="Stretch" + AutomationProperties.HelpText="{Binding Settings.SelectedSize, Converter={StaticResource SizeTypeToHelpTextConverter}}" + AutomationProperties.Name="{x:Static p:Resources.Image_Sizes}" + ItemsSource="{Binding Settings.AllSizes}" + SelectedItem="{Binding Settings.SelectedSize, Mode=TwoWay}"> + <ComboBox.ItemContainerStyle> + <Style BasedOn="{StaticResource {x:Type ComboBoxItem}}" TargetType="ComboBoxItem"> + <Setter Property="AutomationProperties.Name" Value="{Binding Name}" /> + <Setter Property="AutomationProperties.HelpText" Value="{Binding ., Converter={StaticResource SizeTypeToHelpTextConverter}}" /> + </Style> + </ComboBox.ItemContainerStyle> + <ComboBox.Resources> + <DataTemplate DataType="{x:Type m:ResizeSize}"> + <Grid VerticalAlignment="Center"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{Binding Name}" /> + <StackPanel Grid.Row="1" Orientation="Horizontal"> + <TextBlock Text="{Binding Fit, Converter={StaticResource EnumValueConverter}, ConverterParameter=ThirdPersonSingular}" /> + <TextBlock + Margin="4,0,0,0" + Style="{StaticResource BodyStrongTextBlockStyle}" + Text="{Binding Width, Converter={StaticResource AutoDoubleConverter}, ConverterParameter=Auto}" /> + <TextBlock + Margin="4,0,0,0" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + Text="×" + Visibility="{Binding ShowHeight, Converter={StaticResource BoolValueConverter}}" /> + <TextBlock + Margin="4,0,0,0" + Style="{StaticResource BodyStrongTextBlockStyle}" + Text="{Binding Height, Converter={StaticResource AutoDoubleConverter}, ConverterParameter=Auto}" + Visibility="{Binding ShowHeight, Converter={StaticResource BoolValueConverter}}" /> + <TextBlock Margin="4,0,0,0" Text="{Binding Unit, Converter={StaticResource EnumValueConverter}, ConverterParameter=ToLower}" /> + </StackPanel> + </Grid> + </DataTemplate> + + <DataTemplate DataType="{x:Type m:CustomSize}"> + <Grid VerticalAlignment="Center"> + <TextBlock FontWeight="SemiBold" Text="{Binding Name}" /> + </Grid> + </DataTemplate> + + <DataTemplate DataType="{x:Type m:AiSize}"> + <StackPanel VerticalAlignment="Center" Orientation="Vertical"> + <TextBlock FontWeight="SemiBold" Text="{x:Static p:Resources.Input_AiSuperResolution}" /> + <TextBlock Text="{x:Static p:Resources.Input_AiSuperResolutionDescription}" /> </StackPanel> - </Grid> - </DataTemplate> - - <DataTemplate DataType="{x:Type m:CustomSize}"> - <Grid VerticalAlignment="Center"> - <TextBlock FontWeight="SemiBold" Text="{Binding Name}" /> - </Grid> - </DataTemplate> - </ComboBox.Resources> - </ComboBox> - + </DataTemplate> + </ComboBox.Resources> + </ComboBox> + </StackPanel> <Grid Grid.Row="1"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> @@ -84,6 +107,90 @@ BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}" BorderThickness="0,1,0,0" /> + <!-- AI Configuration Panel --> + <Grid Margin="16"> + <!-- AI Model Download Prompt --> + <StackPanel> + <StackPanel.Style> + <Style TargetType="StackPanel"> + <Setter Property="Visibility" Value="Collapsed" /> + <Style.Triggers> + <DataTrigger Binding="{Binding ShowModelDownloadPrompt}" Value="True"> + <Setter Property="Visibility" Value="Visible" /> + </DataTrigger> + </Style.Triggers> + </Style> + </StackPanel.Style> + + <ui:InfoBar + IsClosable="False" + IsOpen="True" + Message="{Binding ModelStatusMessage}" + Severity="Informational" /> + + <ui:Button + Margin="0,8,0,0" + HorizontalAlignment="Stretch" + Appearance="Primary" + Command="{Binding DownloadModelCommand}" + Content="{x:Static p:Resources.Input_AiModelDownloadButton}" + Visibility="{Binding IsModelDownloading, Converter={StaticResource BoolValueConverter}, ConverterParameter=Inverted}" /> + + <StackPanel Margin="0,8,0,0" Visibility="{Binding IsModelDownloading, Converter={StaticResource BoolValueConverter}}"> + <ui:ProgressRing IsIndeterminate="True" /> + <TextBlock + Margin="0,8,0,0" + HorizontalAlignment="Center" + Text="{Binding ModelStatusMessage}" /> + </StackPanel> + </StackPanel> + + <!-- AI Scale Controls --> + <StackPanel> + <StackPanel.Style> + <Style TargetType="StackPanel"> + <Setter Property="Visibility" Value="Collapsed" /> + <Style.Triggers> + <DataTrigger Binding="{Binding ShowAiControls}" Value="True"> + <Setter Property="Visibility" Value="Visible" /> + </DataTrigger> + </Style.Triggers> + </Style> + </StackPanel.Style> + + <Grid> + <TextBlock Text="{x:Static p:Resources.Input_AiCurrentLabel}" /> + <TextBlock HorizontalAlignment="Right" Text="{Binding AiScaleDisplay}" /> + </Grid> + + <Slider + Margin="0,8,0,0" + AutomationProperties.Name="{x:Static p:Resources.Input_AiScaleLabel}" + IsSelectionRangeEnabled="False" + IsSnapToTickEnabled="True" + Maximum="8" + Minimum="1" + TickFrequency="1" + TickPlacement="BottomRight" + Ticks="1,2,3,4,5,6,7,8" + Value="{Binding AiSuperResolutionScale, Mode=TwoWay}" /> + + <StackPanel Margin="0,16,0,0" Visibility="{Binding ShowAiSizeDescriptions, Converter={StaticResource BoolValueConverter}}"> + <Grid> + <TextBlock Foreground="{DynamicResource TextFillColorSecondaryBrush}" Text="{x:Static p:Resources.Input_AiCurrentLabel}" /> + <TextBlock + HorizontalAlignment="Right" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + Text="{Binding CurrentResolutionDescription}" /> + </Grid> + <Grid Margin="0,8,0,0"> + <TextBlock Text="{x:Static p:Resources.Input_AiNewLabel}" /> + <TextBlock HorizontalAlignment="Right" Text="{Binding NewResolutionDescription}" /> + </Grid> + </StackPanel> + </StackPanel> + </Grid> + <!-- "Custom" input matrix --> <Grid Margin="16" Visibility="{Binding ElementName=SizeComboBox, Path=SelectedValue, Converter={StaticResource SizeTypeToVisibilityConverter}}"> <Grid.ColumnDefinitions> @@ -280,7 +387,8 @@ Appearance="Primary" AutomationProperties.Name="{x:Static p:Resources.Resize_Tooltip}" Command="{Binding ResizeCommand}" - IsDefault="True"> + IsDefault="True" + Style="{StaticResource ReadableDisabledButtonStyle}"> <StackPanel Orientation="Horizontal"> <ui:SymbolIcon FontSize="16" Symbol="ResizeLarge16" /> <TextBlock Margin="8,0,0,0" Text="{x:Static p:Resources.Input_Resize}" /> diff --git a/src/modules/interface/powertoy_module_interface.h b/src/modules/interface/powertoy_module_interface.h index aa7560b144..b88763d1a3 100644 --- a/src/modules/interface/powertoy_module_interface.h +++ b/src/modules/interface/powertoy_module_interface.h @@ -16,7 +16,7 @@ On the received object, the runner will call: - get_key() to get the non localized ID of the PowerToy, - enable() to initialize the PowerToy. - - get_hotkeys() to register the hotkeys the PowerToy uses. + - get_hotkeys() to register the hotkeys that the PowerToy uses. While running, the runner might call the following methods between create_powertoy() and destroy(): @@ -45,14 +45,44 @@ public: bool shift = false; bool alt = false; unsigned char key = 0; + // The id is used to identify the hotkey in the module. The order in module interface should be the same as in the settings. + int id = 0; + // Currently, this is only used by AdvancedPaste to determine if the hotkey is shown in the settings. + bool isShown = true; - std::strong_ordering operator<=>(const Hotkey&) const = default; + std::strong_ordering operator<=>(const Hotkey& other) const + { + // Compare bool fields first + if (auto cmp = (win <=> other.win); cmp != 0) + return cmp; + if (auto cmp = (ctrl <=> other.ctrl); cmp != 0) + return cmp; + if (auto cmp = (shift <=> other.shift); cmp != 0) + return cmp; + if (auto cmp = (alt <=> other.alt); cmp != 0) + return cmp; + + // Compare key value only + return key <=> other.key; + + // Note: Deliberately NOT comparing 'name' field + } + + bool operator==(const Hotkey& other) const + { + return win == other.win && + ctrl == other.ctrl && + shift == other.shift && + alt == other.alt && + key == other.key; + } }; struct HotkeyEx { WORD modifiersMask = 0; WORD vkCode = 0; + int id = 0; }; /* Returns the localized name of the PowerToy*/ diff --git a/src/modules/keyboardmanager/Directory.Build.targets b/src/modules/keyboardmanager/Directory.Build.targets index dfcf63e016..d25c78388a 100644 --- a/src/modules/keyboardmanager/Directory.Build.targets +++ b/src/modules/keyboardmanager/Directory.Build.targets @@ -2,6 +2,6 @@ <Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)../'))" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 ..\dll resource.base.h resource.h KeyboardManager.base.rc KeyboardManager.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 ..\dll resource.base.h resource.h KeyboardManager.base.rc KeyboardManager.rc" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj index 3c124d63e4..f59c3603e8 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.vcxproj @@ -3,7 +3,7 @@ <!-- Project configurations --> <Import Project="..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props" Condition="Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props')" /> <Import Project="..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.props" Condition="Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.props')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup> <NoWarn>81010002</NoWarn> </PropertyGroup> @@ -14,7 +14,6 @@ <ConformanceMode>false</ConformanceMode> <TreatWarningAsError>true</TreatWarningAsError> <LanguageStandard>stdcpplatest</LanguageStandard> - <AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions> <PreprocessorDefinitions>_UNICODE;UNICODE;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> @@ -60,7 +59,7 @@ </PropertyGroup> <!-- Props that are constant for both Debug and Release configurations --> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + <OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir> <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> @@ -161,7 +160,7 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets" Condition="Exists('..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets" Condition="Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" /> @@ -175,8 +174,8 @@ <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.props'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.VCRTForwarders.140.1.0.7\build\native\Microsoft.VCRTForwarders.140.targets'))" /> diff --git a/src/modules/keyboardmanager/KeyboardManagerEditor/packages.config b/src/modules/keyboardmanager/KeyboardManagerEditor/packages.config index 9f61973b47..9017d0289b 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditor/packages.config +++ b/src/modules/keyboardmanager/KeyboardManagerEditor/packages.config @@ -4,5 +4,5 @@ <package id="Microsoft.UI.Xaml" version="2.8.2-prerelease.220830001" targetFramework="native" /> <package id="Microsoft.VCRTForwarders.140" version="1.0.7" targetFramework="native" /> <package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditorHelpers.cpp b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditorHelpers.cpp index 059d8931cc..a3ddf5c022 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditorHelpers.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditorHelpers.cpp @@ -49,7 +49,7 @@ namespace EditorHelpers return numberOfSameType > 1; } - // Function to return true if the shortcut is valid. A valid shortcut has atleast one modifier, as well as an action key + // Function to return true if the shortcut is valid. A valid shortcut has at least one modifier, as well as an action key bool IsValidShortcut(Shortcut shortcut) { if (shortcut.operationType == Shortcut::OperationType::RunProgram && shortcut.runProgramFilePath.length() > 0) @@ -117,15 +117,15 @@ namespace EditorHelpers } if (shortcut.ctrlKey != ModifierKey::Disabled) { - keys.push_back(winrt::to_hstring(keyboardMap.GetKeyName(shortcut.GetCtrlKey()).c_str())); + keys.push_back(winrt::to_hstring(keyboardMap.GetKeyName(shortcut.GetCtrlKey(ModifierKey::Both)).c_str())); } if (shortcut.altKey != ModifierKey::Disabled) { - keys.push_back(winrt::to_hstring(keyboardMap.GetKeyName(shortcut.GetAltKey()).c_str())); + keys.push_back(winrt::to_hstring(keyboardMap.GetKeyName(shortcut.GetAltKey(ModifierKey::Both)).c_str())); } if (shortcut.shiftKey != ModifierKey::Disabled) { - keys.push_back(winrt::to_hstring(keyboardMap.GetKeyName(shortcut.GetShiftKey()).c_str())); + keys.push_back(winrt::to_hstring(keyboardMap.GetKeyName(shortcut.GetShiftKey(ModifierKey::Both)).c_str())); } if (shortcut.actionKey != NULL) { diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditorHelpers.h b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditorHelpers.h index 5d6647e88c..f2731d1b37 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditorHelpers.h +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditorHelpers.h @@ -11,7 +11,7 @@ namespace EditorHelpers // Function to check if a modifier has been repeated in the previous drop downs bool CheckRepeatedModifier(const std::vector<int32_t>& currentKeys, int selectedKeyCodes); - // Function to return true if the shortcut is valid. A valid shortcut has atleast one modifier, as well as an action key + // Function to return true if the shortcut is valid. A valid shortcut has at least one modifier, as well as an action key bool IsValidShortcut(Shortcut shortcut); // Function to check if the two shortcuts are equal or cover the same set of keys. Return value depends on type of overlap diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/KeyDropDownControl.cpp b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/KeyDropDownControl.cpp index 5eb65e3266..13dbc7999c 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/KeyDropDownControl.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/KeyDropDownControl.cpp @@ -314,7 +314,7 @@ void KeyDropDownControl::SetSelectionHandler(StackPanel& table, StackPanel row, } } - // If the user searches for a key the selection handler gets invoked however if they click away it reverts back to the previous state. This can result in dangling references to added drop downs which were then reset. + // If the user searches for a key, the selection handler gets invoked; however if they click away it reverts back to the previous state. This can result in dangling references to added drop downs which were then reset. // We handle this by removing the drop down if it no longer a child of the parent for (long long i = keyDropDownControlObjects.size() - 1; i >= 0; i--) { diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/KeyboardManagerEditorLibrary.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/KeyboardManagerEditorLibrary.vcxproj index 7d997ceb24..51ea2103b2 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/KeyboardManagerEditorLibrary.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/KeyboardManagerEditorLibrary.vcxproj @@ -2,7 +2,7 @@ <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Import Project="..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props" Condition="Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props')" /> <Import Project="..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.props" Condition="Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.props')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> @@ -15,7 +15,7 @@ <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -98,7 +98,7 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets" Condition="Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.targets')" /> <Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" /> @@ -108,8 +108,8 @@ <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.props'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Toolkit.Win32.UI.XamlApplication.6.1.3\build\native\Microsoft.Toolkit.Win32.UI.XamlApplication.targets'))" /> <Error Condition="!Exists('..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.UI.Xaml.2.8.2-prerelease.220830001\build\native\Microsoft.UI.Xaml.props'))" /> diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/ShortcutControl.cpp b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/ShortcutControl.cpp index 41e23f36d8..58960a52ed 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/ShortcutControl.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/ShortcutControl.cpp @@ -472,9 +472,9 @@ ShortcutControl& ShortcutControl::AddNewShortcutControlRow(StackPanel& parent, s deleteShortcut.SetValue(Automation::AutomationProperties::NameProperty(), box_value(GET_RESOURCE_STRING(IDS_DELETE_REMAPPING_BUTTON))); // Add tooltip for delete button which would appear on hover - ToolTip deleteShortcuttoolTip; - deleteShortcuttoolTip.Content(box_value(GET_RESOURCE_STRING(IDS_DELETE_REMAPPING_BUTTON))); - ToolTipService::SetToolTip(deleteShortcut, deleteShortcuttoolTip); + ToolTip deleteShortcutToolTip; + deleteShortcutToolTip.Content(box_value(GET_RESOURCE_STRING(IDS_DELETE_REMAPPING_BUTTON))); + ToolTipService::SetToolTip(deleteShortcut, deleteShortcutToolTip); StackPanel deleteShortcutContainer = StackPanel(); deleteShortcutContainer.Name(L"deleteShortcutContainer"); diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/packages.config b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/packages.config index 73abacfa44..513520804c 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/packages.config +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibrary/packages.config @@ -3,5 +3,5 @@ <package id="Microsoft.Toolkit.Win32.UI.XamlApplication" version="6.1.3" targetFramework="native" /> <package id="Microsoft.UI.Xaml" version="2.8.2-prerelease.220830001" targetFramework="native" /> <package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.vcxproj index e82a21cb06..f96a66750f 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/KeyboardManagerEditorLibraryWrapper.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|ARM64"> <Configuration>Debug</Configuration> @@ -37,43 +38,42 @@ <PropertyGroup> <TargetName>PowerToys.KeyboardManagerEditorLibraryWrapper</TargetName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>NotSet</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>NotSet</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -149,14 +149,14 @@ <PreprocessorDefinitions>_DEBUG;KEYBOARDMANAGEREDITORLIBRARYWRAPPER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>false</ConformanceMode> <PrecompiledHeader>Use</PrecompiledHeader> - <AdditionalIncludeDirectories>./;$(SolutionDir)src\modules\;$(SolutionDir)src\modules\KeyboardManager\KeyboardManagerEditorLibrary\;$(SolutionDir)src\common\Display;$(SolutionDir)src\common\inc;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>./;$(RepoRoot)src\modules\;$(RepoRoot)src\modules\KeyboardManager\KeyboardManagerEditorLibrary\;$(RepoRoot)src\common\Display;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <CompileAsWinRT>false</CompileAsWinRT> </ClCompile> <Link> <SubSystem>Windows</SubSystem> <GenerateDebugInformation>true</GenerateDebugInformation> <EnableUAC>false</EnableUAC> - <AdditionalLibraryDirectories>$(SolutionDir)$(Platform)\$(ConfigurationName);%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> + <AdditionalLibraryDirectories>$(RepoRoot)$(Platform)\$(ConfigurationName);%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> </Link> </ItemDefinitionGroup> <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'"> @@ -166,14 +166,14 @@ <PreprocessorDefinitions>_DEBUG;KEYBOARDMANAGEREDITORLIBRARYWRAPPER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>false</ConformanceMode> <PrecompiledHeader>Use</PrecompiledHeader> - <AdditionalIncludeDirectories>./;$(SolutionDir)src\modules\;$(SolutionDir)src\modules\KeyboardManager\KeyboardManagerEditorLibrary\;$(SolutionDir)src\common\Display;$(SolutionDir)src\common\inc;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>./;$(RepoRoot)src\modules\;$(RepoRoot)src\modules\KeyboardManager\KeyboardManagerEditorLibrary\;$(RepoRoot)src\common\Display;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <CompileAsWinRT>false</CompileAsWinRT> </ClCompile> <Link> <SubSystem>Windows</SubSystem> <GenerateDebugInformation>true</GenerateDebugInformation> <EnableUAC>false</EnableUAC> - <AdditionalLibraryDirectories>$(SolutionDir)$(Platform)\$(ConfigurationName);%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> + <AdditionalLibraryDirectories>$(RepoRoot)$(Platform)\$(ConfigurationName);%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> </Link> </ItemDefinitionGroup> <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> @@ -185,7 +185,7 @@ <PreprocessorDefinitions>NDEBUG;KEYBOARDMANAGEREDITORLIBRARYWRAPPER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>false</ConformanceMode> <PrecompiledHeader>Use</PrecompiledHeader> - <AdditionalIncludeDirectories>./;$(SolutionDir)src\modules\;$(SolutionDir)src\modules\KeyboardManager\KeyboardManagerEditorLibrary\;$(SolutionDir)src\common\Display;$(SolutionDir)src\common\inc;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>./;$(RepoRoot)src\modules\;$(RepoRoot)src\modules\KeyboardManager\KeyboardManagerEditorLibrary\;$(RepoRoot)src\common\Display;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <SupportJustMyCode>true</SupportJustMyCode> <DebugInformationFormat>ProgramDatabase</DebugInformationFormat> </ClCompile> @@ -195,7 +195,7 @@ <OptimizeReferences>true</OptimizeReferences> <GenerateDebugInformation>true</GenerateDebugInformation> <EnableUAC>false</EnableUAC> - <AdditionalLibraryDirectories>$(SolutionDir)$(Platform)\$(ConfigurationName);%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> + <AdditionalLibraryDirectories>$(RepoRoot)$(Platform)\$(ConfigurationName);%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> </Link> </ItemDefinitionGroup> <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'"> @@ -207,7 +207,7 @@ <PreprocessorDefinitions>NDEBUG;KEYBOARDMANAGEREDITORLIBRARYWRAPPER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>false</ConformanceMode> <PrecompiledHeader>Use</PrecompiledHeader> - <AdditionalIncludeDirectories>./;$(SolutionDir)src\modules\;$(SolutionDir)src\modules\KeyboardManager\KeyboardManagerEditorLibrary\;$(SolutionDir)src\common\Display;$(SolutionDir)src\common\inc;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>./;$(RepoRoot)src\modules\;$(RepoRoot)src\modules\KeyboardManager\KeyboardManagerEditorLibrary\;$(RepoRoot)src\common\Display;$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -239,7 +239,7 @@ <ResourceCompile Include="KeyboardManagerEditorLibraryWrapper.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> <ProjectReference Include="..\common\KeyboardManagerCommon.vcxproj"> @@ -253,15 +253,15 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/packages.config b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/packages.config index 32b2b5ff13..066228eeb6 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/packages.config +++ b/src/modules/keyboardmanager/KeyboardManagerEditorLibraryWrapper/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorTest/BufferValidationTests.cpp b/src/modules/keyboardmanager/KeyboardManagerEditorTest/BufferValidationTests.cpp index fb7265e32b..9a2d36a5ac 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorTest/BufferValidationTests.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEditorTest/BufferValidationTests.cpp @@ -392,7 +392,7 @@ namespace RemappingUITests }); } - // Test if the ValidateShortcutBufferElement method returns no error and no drop down action is required on setting last drop down to an action key on a column with atleast two drop downs + // Test if the ValidateShortcutBufferElement method returns no error and no drop down action is required on setting last drop down to an action key on a column with at least two drop downs TEST_METHOD (ValidateShortcutBufferElement_ShouldReturnNoErrorAndNoAction_OnSettingLastDropDownToActionKeyOnAColumnWithAtleastTwoDropDowns) { std::vector<ValidateShortcutBufferElementArgs> testCases; diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorTest/KeyboardManagerEditorTest.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEditorTest/KeyboardManagerEditorTest.vcxproj index 26b64786da..7eb4713b2d 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorTest/KeyboardManagerEditorTest.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEditorTest/KeyboardManagerEditorTest.vcxproj @@ -1,19 +1,20 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{62173D9A-6724-4C00-A1C8-FB646480A9EC}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>KeyboardManagerEditorTest</RootNamespace> <ProjectSubType>NativeUnitTestProject</ProjectSubType> + <ProjectName>KeyboardManager.Editor.UnitTests</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -29,7 +30,7 @@ </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(VCInstallDir)UnitTest\include;$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(VCInstallDir)UnitTest\include;$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <UseFullPaths>true</UseFullPaths> </ClCompile> <Link> @@ -49,7 +50,7 @@ <ClInclude Include="resource.h" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> <ProjectReference Include="..\common\KeyboardManagerCommon.vcxproj"> @@ -67,13 +68,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorTest/packages.config b/src/modules/keyboardmanager/KeyboardManagerEditorTest/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorTest/packages.config +++ b/src/modules/keyboardmanager/KeyboardManagerEditorTest/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/KeyboardManagerEditorUI.csproj b/src/modules/keyboardmanager/KeyboardManagerEditorUI/KeyboardManagerEditorUI.csproj index 4d278fa0c1..b71aa65515 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/KeyboardManagerEditorUI.csproj +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/KeyboardManagerEditorUI.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> @@ -15,7 +15,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <AssemblyName>PowerToys.KeyboardManagerEditorUI</AssemblyName> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\$(MSBuildProjectName)</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/keyboardmanager/KeyboardManagerEditorUI/MainWindow.xaml b/src/modules/keyboardmanager/KeyboardManagerEditorUI/MainWindow.xaml index 44de8cc962..43311b4618 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEditorUI/MainWindow.xaml +++ b/src/modules/keyboardmanager/KeyboardManagerEditorUI/MainWindow.xaml @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8" ?> +<?xml version="1.0" encoding="utf-8" ?> <Window x:Class="KeyboardManagerEditorUI.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" diff --git a/src/modules/keyboardmanager/KeyboardManagerEngine/KeyboardManagerEngine.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEngine/KeyboardManagerEngine.vcxproj index fd7b4a97c3..b956555234 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngine/KeyboardManagerEngine.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEngine/KeyboardManagerEngine.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -11,12 +12,11 @@ <Keyword>Win32Proj</Keyword> <RootNamespace>KeyboardManagerEngine</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -34,11 +34,11 @@ <TargetName>PowerToys.$(MSBuildProjectName)</TargetName> </PropertyGroup> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(ProjectName)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\$(ProjectName)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> @@ -61,13 +61,13 @@ <None Include="PropertySheet.props" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> <ProjectReference Include="..\KeyboardManagerEngineLibrary\KeyboardManagerEngineLibrary.vcxproj"> @@ -81,15 +81,15 @@ <Image Include="Keyboard.ico" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEngine/main.cpp b/src/modules/keyboardmanager/KeyboardManagerEngine/main.cpp index 5969ed6cfd..df48555df5 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngine/main.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEngine/main.cpp @@ -51,7 +51,8 @@ int WINAPI wWinMain(_In_ HINSTANCE /*hInstance*/, auto mainThreadId = GetCurrentThreadId(); - EventWaiter ev = EventWaiter(CommonSharedConstants::TERMINATE_KBM_SHARED_EVENT, [&](int) { + EventWaiter ev; + ev.start(CommonSharedConstants::TERMINATE_KBM_SHARED_EVENT, [&](DWORD) { PostThreadMessage(mainThreadId, WM_QUIT, 0, 0); }); diff --git a/src/modules/keyboardmanager/KeyboardManagerEngine/packages.config b/src/modules/keyboardmanager/KeyboardManagerEngine/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngine/packages.config +++ b/src/modules/keyboardmanager/KeyboardManagerEngine/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp index 537e9016ac..179103d41b 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.cpp @@ -88,7 +88,7 @@ namespace namespace KeyboardEventHandlers { - // Function to a handle a single key remap + // Function to handle a single key remap intptr_t HandleSingleKeyRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept { // Check if the key event was generated by KeyboardManager to avoid remapping events generated by us. @@ -158,13 +158,13 @@ namespace KeyboardEventHandlers if (data->wParam == WM_KEYUP || data->wParam == WM_SYSKEYUP) { Helpers::SetKeyEvent(keyEventList, INPUT_KEYBOARD, static_cast<WORD>(targetShortcut.GetActionKey()), KEYEVENTF_KEYUP, KeyboardManagerConstants::KEYBOARDMANAGER_SINGLEKEY_FLAG); - Helpers::SetModifierKeyEvents(targetShortcut, ModifierKey::Disabled, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SINGLEKEY_FLAG); + Helpers::SetModifierKeyEvents(targetShortcut, Modifiers(), keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SINGLEKEY_FLAG); // Dummy key is not required here since SetModifierKeyEvents will only add key-up events for the modifiers here, and the action key key-up is already sent before it } else { // Dummy key is not required here since SetModifierKeyEvents will only add key-down events for the modifiers here, and the action key key-down is already sent after it - Helpers::SetModifierKeyEvents(targetShortcut, ModifierKey::Disabled, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SINGLEKEY_FLAG); + Helpers::SetModifierKeyEvents(targetShortcut, Modifiers(), keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SINGLEKEY_FLAG); Helpers::SetKeyEvent(keyEventList, INPUT_KEYBOARD, static_cast<WORD>(targetShortcut.GetActionKey()), 0, KeyboardManagerConstants::KEYBOARDMANAGER_SINGLEKEY_FLAG); } } @@ -219,7 +219,7 @@ namespace KeyboardEventHandlers /* This feature has not been enabled (code from proof of concept stage) * - // Function to a change a key's behavior from toggle to modifier + // Function to change a key's behavior from toggle to modifier __declspec(dllexport) intptr_t HandleSingleKeyToggleToModEvent(InputInterface& ii, LowlevelKeyboardEvent* data, State& State) noexcept { // Check if the key event was generated by KeyboardManager to avoid remapping events generated by us. @@ -266,7 +266,7 @@ namespace KeyboardEventHandlers } */ - // Function to a handle a shortcut remap + // Function to handle a shortcut remap intptr_t HandleShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, const std::optional<std::wstring>& activatedApp) noexcept { auto resetChordsResults = ResetChordsIfNeeded(data, state, activatedApp); @@ -371,11 +371,35 @@ namespace KeyboardEventHandlers // Remember which win key was pressed initially if (ii.GetVirtualKeyState(VK_RWIN)) { - it->second.winKeyInvoked = ModifierKey::Right; + it->second.modifierKeysInvoked.winKey = ModifierKey::Right; } else if (ii.GetVirtualKeyState(VK_LWIN)) { - it->second.winKeyInvoked = ModifierKey::Left; + it->second.modifierKeysInvoked.winKey = ModifierKey::Left; + } + if (ii.GetVirtualKeyState(VK_RCONTROL)) + { + it->second.modifierKeysInvoked.ctrlKey = ModifierKey::Right; + } + else if (ii.GetVirtualKeyState(VK_LCONTROL)) + { + it->second.modifierKeysInvoked.ctrlKey = ModifierKey::Left; + } + if (ii.GetVirtualKeyState(VK_RSHIFT)) + { + it->second.modifierKeysInvoked.shiftKey = ModifierKey::Right; + } + else if (ii.GetVirtualKeyState(VK_LSHIFT)) + { + it->second.modifierKeysInvoked.shiftKey = ModifierKey::Left; + } + if (ii.GetVirtualKeyState(VK_RMENU)) + { + it->second.modifierKeysInvoked.altKey = ModifierKey::Right; + } + else if (ii.GetVirtualKeyState(VK_LMENU)) + { + it->second.modifierKeysInvoked.altKey = ModifierKey::Left; } if (isRunProgram) @@ -450,7 +474,7 @@ namespace KeyboardEventHandlers { // key down for all new shortcut keys except the common modifiers keyEventList = std::vector<INPUT>{}; - Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.winKeyInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first); + Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.modifierKeysInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first); Helpers::SetKeyEvent(keyEventList, INPUT_KEYBOARD, static_cast<WORD>(std::get<Shortcut>(it->second.targetShortcut).GetActionKey()), 0, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); } else @@ -460,15 +484,15 @@ namespace KeyboardEventHandlers Helpers::SetDummyKeyEvent(keyEventList, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); // Release original shortcut state (release in reverse order of shortcut to be accurate) - Helpers::SetModifierKeyEvents(it->first, it->second.winKeyInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, std::get<Shortcut>(it->second.targetShortcut)); + Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, std::get<Shortcut>(it->second.targetShortcut)); // Set new shortcut key down state - Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.winKeyInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first); + Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.modifierKeysInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first); Helpers::SetKeyEvent(keyEventList, INPUT_KEYBOARD, static_cast<WORD>(std::get<Shortcut>(it->second.targetShortcut).GetActionKey()), 0, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); } // Modifier state reset might be required for this key depending on the shortcut's action and target modifiers - ex: Win+Caps -> Ctrl+A - if (it->first.GetCtrlKey() == NULL && it->first.GetAltKey() == NULL && it->first.GetShiftKey() == NULL) + if (it->first.GetCtrlKey(it->second.modifierKeysInvoked.ctrlKey) == NULL && it->first.GetAltKey(it->second.modifierKeysInvoked.altKey) == NULL && it->first.GetShiftKey(it->second.modifierKeysInvoked.shiftKey) == NULL) { Shortcut temp = std::get<Shortcut>(it->second.targetShortcut); for (auto keys : temp.GetKeyCodes()) @@ -490,7 +514,7 @@ namespace KeyboardEventHandlers Helpers::SetDummyKeyEvent(keyEventList, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); // Release original shortcut state (release in reverse order of shortcut to be accurate) - Helpers::SetModifierKeyEvents(it->first, it->second.winKeyInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); + Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); // Set target key down state if (std::get<DWORD>(it->second.targetShortcut) != CommonSharedConstants::VK_DISABLED) @@ -499,7 +523,7 @@ namespace KeyboardEventHandlers } // Modifier state reset might be required for this key depending on the shortcut's action and target modifier - ex: Win+Caps -> Ctrl - if (it->first.GetCtrlKey() == NULL && it->first.GetAltKey() == NULL && it->first.GetShiftKey() == NULL) + if (it->first.GetCtrlKey(it->second.modifierKeysInvoked.ctrlKey) == NULL && it->first.GetAltKey(it->second.modifierKeysInvoked.altKey) == NULL && it->first.GetShiftKey(it->second.modifierKeysInvoked.shiftKey) == NULL) { ResetIfModifierKeyForLowerLevelKeyHandlers(ii, static_cast<WORD>(Helpers::FilterArtificialKeys(std::get<DWORD>(it->second.targetShortcut))), data->lParam->vkCode); } @@ -512,7 +536,7 @@ namespace KeyboardEventHandlers Helpers::SetDummyKeyEvent(keyEventList, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); // Release original shortcut state (release in reverse order of shortcut to be accurate) - Helpers::SetModifierKeyEvents(it->first, it->second.winKeyInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); + Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); Helpers::SetTextKeyEvents(keyEventList, remapping); } @@ -614,12 +638,12 @@ namespace KeyboardEventHandlers Helpers::SetKeyEvent(keyEventList, INPUT_KEYBOARD, static_cast<WORD>(std::get<Shortcut>(it->second.targetShortcut).GetActionKey()), KEYEVENTF_KEYUP, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); } - Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.winKeyInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first, data->lParam->vkCode); + Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first, data->lParam->vkCode); if (!isAltRightKeyInvoked) { // Set original shortcut key down state except the action key and the released modifier since the original action key may or may not be held down. If it is held down it will generate its own key message - Helpers::SetModifierKeyEvents(it->first, it->second.winKeyInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, std::get<Shortcut>(it->second.targetShortcut), data->lParam->vkCode); + Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, std::get<Shortcut>(it->second.targetShortcut), data->lParam->vkCode); } else { @@ -643,7 +667,7 @@ namespace KeyboardEventHandlers if (!isAltRightKeyInvoked) { // Set original shortcut key down state except the action key and the released modifier since the original action key may or may not be held down. If it is held down it will generate its own key message - Helpers::SetModifierKeyEvents(it->first, it->second.winKeyInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, Shortcut(), data->lParam->vkCode); + Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, Shortcut(), data->lParam->vkCode); } else { @@ -656,7 +680,7 @@ namespace KeyboardEventHandlers // Reset the remap state it->second.isShortcutInvoked = false; - it->second.winKeyInvoked = ModifierKey::Disabled; + it->second.modifierKeysInvoked.Reset(); it->second.isOriginalActionKeyPressed = false; // If app specific shortcut has finished invoking, reset the target application @@ -719,14 +743,14 @@ namespace KeyboardEventHandlers Helpers::SetKeyEvent(keyEventList, INPUT_KEYBOARD, static_cast<WORD>(std::get<Shortcut>(it->second.targetShortcut).GetActionKey()), KEYEVENTF_KEYUP, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); // Release new shortcut state (release in reverse order of shortcut to be accurate) - Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.winKeyInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first); + Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first); // Set old shortcut key down state - Helpers::SetModifierKeyEvents(it->first, it->second.winKeyInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, std::get<Shortcut>(it->second.targetShortcut)); + Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, std::get<Shortcut>(it->second.targetShortcut)); // Reset the remap state it->second.isShortcutInvoked = false; - it->second.winKeyInvoked = ModifierKey::Disabled; + it->second.modifierKeysInvoked.Reset(); it->second.isOriginalActionKeyPressed = false; // If app specific shortcut has finished invoking, reset the target application @@ -763,7 +787,7 @@ namespace KeyboardEventHandlers if (!isAltRightKeyInvoked) { // Set original shortcut key down state except the action key - Helpers::SetModifierKeyEvents(it->first, it->second.winKeyInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); + Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); } // Send a dummy key event to prevent modifier press+release from being triggered. Example: Win+A->V, press Shift+Win+A and release A, since Win will be pressed here we need to send a dummy event after it @@ -773,7 +797,7 @@ namespace KeyboardEventHandlers { // Reset the remap state it->second.isShortcutInvoked = false; - it->second.winKeyInvoked = ModifierKey::Disabled; + it->second.modifierKeysInvoked.Reset(); it->second.isOriginalActionKeyPressed = false; } @@ -795,7 +819,7 @@ namespace KeyboardEventHandlers if (remapToShortcut) { // Modifier state reset might be required for this key depending on the target shortcut action key - ex: Ctrl+A -> Win+Caps - if (std::get<Shortcut>(it->second.targetShortcut).GetCtrlKey() == NULL && std::get<Shortcut>(it->second.targetShortcut).GetAltKey() == NULL && std::get<Shortcut>(it->second.targetShortcut).GetShiftKey() == NULL) + if (std::get<Shortcut>(it->second.targetShortcut).GetCtrlKey(it->second.modifierKeysInvoked.ctrlKey) == NULL && std::get<Shortcut>(it->second.targetShortcut).GetAltKey(it->second.modifierKeysInvoked.altKey) == NULL && std::get<Shortcut>(it->second.targetShortcut).GetShiftKey(it->second.modifierKeysInvoked.shiftKey) == NULL) { ResetIfModifierKeyForLowerLevelKeyHandlers(ii, data->lParam->vkCode, std::get<Shortcut>(it->second.targetShortcut).GetActionKey()); } @@ -817,7 +841,7 @@ namespace KeyboardEventHandlers if (remapToShortcut) { // Modifier state reset might be required for this key depending on the target shortcut action key - ex: Ctrl+A -> Win+Caps, Shift is pressed. System should not see Shift and Caps pressed together - if (std::get<Shortcut>(it->second.targetShortcut).GetCtrlKey() == NULL && std::get<Shortcut>(it->second.targetShortcut).GetAltKey() == NULL && std::get<Shortcut>(it->second.targetShortcut).GetShiftKey() == NULL) + if (std::get<Shortcut>(it->second.targetShortcut).GetCtrlKey(it->second.modifierKeysInvoked.ctrlKey) == NULL && std::get<Shortcut>(it->second.targetShortcut).GetAltKey(it->second.modifierKeysInvoked.altKey) == NULL && std::get<Shortcut>(it->second.targetShortcut).GetShiftKey(it->second.modifierKeysInvoked.shiftKey) == NULL) { ResetIfModifierKeyForLowerLevelKeyHandlers(ii, data->lParam->vkCode, std::get<Shortcut>(it->second.targetShortcut).GetActionKey()); } @@ -837,7 +861,7 @@ namespace KeyboardEventHandlers DWORD to = std::get<0>(newRemapping.targetShortcut); if (!isAltRightKeyInvoked) { - Helpers::SetModifierKeyEvents(from, it->second.winKeyInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); + Helpers::SetModifierKeyEvents(from, it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); } if (ii.GetVirtualKeyState(static_cast<WORD>(from.actionKey))) { @@ -851,7 +875,7 @@ namespace KeyboardEventHandlers Shortcut to = std::get<Shortcut>(newRemapping.targetShortcut); if (!isAltRightKeyInvoked) { - Helpers::SetModifierKeyEvents(from, it->second.winKeyInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, to); + Helpers::SetModifierKeyEvents(from, it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, to); } if (ii.GetVirtualKeyState(static_cast<WORD>(from.actionKey))) { @@ -860,21 +884,11 @@ namespace KeyboardEventHandlers } if (!isAltRightKeyInvoked) { - Helpers::SetModifierKeyEvents(to, it->second.winKeyInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, from); + Helpers::SetModifierKeyEvents(to, it->second.modifierKeysInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, from); } Helpers::SetKeyEvent(keyEventList, INPUT_KEYBOARD, static_cast<WORD>(to.actionKey), 0, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); newRemapping.isShortcutInvoked = true; } - - // Remember which win key was pressed initially - if (ii.GetVirtualKeyState(VK_RWIN)) - { - newRemapping.winKeyInvoked = ModifierKey::Right; - } - else if (ii.GetVirtualKeyState(VK_LWIN)) - { - newRemapping.winKeyInvoked = ModifierKey::Left; - } } else { @@ -888,10 +902,10 @@ namespace KeyboardEventHandlers } if (!isAltRightKeyInvoked) { - Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.winKeyInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first); + Helpers::SetModifierKeyEvents(std::get<Shortcut>(it->second.targetShortcut), it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, it->first); // Set old shortcut key down state - Helpers::SetModifierKeyEvents(it->first, it->second.winKeyInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, std::get<Shortcut>(it->second.targetShortcut)); + Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, std::get<Shortcut>(it->second.targetShortcut)); } // key down for original shortcut action key with shortcut flag so that we don't invoke the same shortcut remap again @@ -910,7 +924,7 @@ namespace KeyboardEventHandlers { // Reset the remap state it->second.isShortcutInvoked = false; - it->second.winKeyInvoked = ModifierKey::Disabled; + it->second.modifierKeysInvoked.Reset(); it->second.isOriginalActionKeyPressed = false; } @@ -960,7 +974,7 @@ namespace KeyboardEventHandlers if (!isAltRightKeyInvoked) { // Set original shortcut key down state - Helpers::SetModifierKeyEvents(it->first, it->second.winKeyInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); + Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, true, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG); } // Send the original action key only if it is physically pressed. For remappings to keys other than disabled we already check earlier that it is not pressed in this scenario. For remap to disable @@ -979,7 +993,7 @@ namespace KeyboardEventHandlers { // Reset the remap state it->second.isShortcutInvoked = false; - it->second.winKeyInvoked = ModifierKey::Disabled; + it->second.modifierKeysInvoked.Reset(); it->second.isOriginalActionKeyPressed = false; } @@ -1670,7 +1684,7 @@ namespace KeyboardEventHandlers return false; } - // Function to a handle an os-level shortcut remap + // Function to handle an os-level shortcut remap intptr_t HandleOSLevelShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept { // Check if the key event was generated by KeyboardManager to avoid remapping events generated by us. @@ -1683,7 +1697,7 @@ namespace KeyboardEventHandlers return 0; } - // Function to a handle an app-specific shortcut remap + // Function to handle an app-specific shortcut remap intptr_t HandleAppSpecificShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept { // Check if the key event was generated by KeyboardManager to avoid remapping events generated by us. diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.h b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.h index 67eeed6977..8797aac311 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.h +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardEventHandlers.h @@ -17,15 +17,15 @@ namespace KeyboardEventHandlers bool AnyChordStarted; }; - // Function to a handle a single key remap + // Function to handle a single key remap intptr_t HandleSingleKeyRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept; /* This feature has not been enabled (code from proof of concept stage) - // Function to a change a key's behavior from toggle to modifier + // Function to change a key's behavior from toggle to modifier __declspec(dllexport) intptr_t HandleSingleKeyToggleToModEvent(InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept; */ - // Function to a handle a shortcut remap + // Function to handle a shortcut remap intptr_t HandleShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state, const std::optional<std::wstring>& activatedApp = std::nullopt) noexcept; // Function to reset chord matching @@ -68,15 +68,15 @@ namespace KeyboardEventHandlers // Function to get just the file name from a fill path std::wstring GetFileNameFromPath(const std::wstring& fullPath); - // Function to a find and show a running program + // Function to find and show a running program bool ShowProgram(DWORD pid, std::wstring programName, bool isNewProcess, bool minimizeIfVisible, int retryCount); bool HideProgram(DWORD pid, std::wstring programName, int retryCount); - // Function to a handle an os-level shortcut remap + // Function to handle an os-level shortcut remap intptr_t HandleOSLevelShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept; - // Function to a handle an app-specific shortcut remap + // Function to handle an app-specific shortcut remap intptr_t HandleAppSpecificShortcutRemapEvent(KeyboardManagerInput::InputInterface& ii, LowlevelKeyboardEvent* data, State& state) noexcept; // Function to generate a unicode string in response to a single keypress diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManager.cpp b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManager.cpp index 8d9ec63698..3eb3261524 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManager.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManager.cpp @@ -70,7 +70,7 @@ KeyboardManager::KeyboardManager() }; editorIsRunningEvent = CreateEvent(nullptr, true, false, KeyboardManagerConstants::EditorWindowEventName.c_str()); - settingsEventWaiter = EventWaiter(KeyboardManagerConstants::SettingsEventName, changeSettingsCallback); + settingsEventWaiter.start(KeyboardManagerConstants::SettingsEventName, changeSettingsCallback); } void KeyboardManager::LoadSettings() diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj index ede1eb2eef..f169c213cc 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj @@ -1,19 +1,19 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{e496b7fc-1e99-4bab-849b-0e8367040b02}</ProjectGuid> <RootNamespace>KeyboardManagerEngineLibrary</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -27,7 +27,7 @@ <ItemDefinitionGroup> <ClCompile> <WarningLevel>Level3</WarningLevel> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <SDLCheck>true</SDLCheck> <PreprocessorDefinitions>_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> @@ -57,17 +57,17 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/packages.config b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/packages.config index 9cdcd1b1c8..d3882436a5 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/packages.config +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/trace.cpp b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/trace.cpp index ab3e49cb87..76a7355bff 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/trace.cpp +++ b/src/modules/keyboardmanager/KeyboardManagerEngineLibrary/trace.cpp @@ -145,15 +145,15 @@ std::wstring GetShortcutHumanReadableString(Shortcut const& shortcut, LayoutMap& } if (shortcut.ctrlKey != ModifierKey::Disabled) { - humanReadableShortcut += keyboardMap.GetKeyName(shortcut.GetCtrlKey()) + L" + "; + humanReadableShortcut += keyboardMap.GetKeyName(shortcut.GetCtrlKey(ModifierKey::Both)) + L" + "; } if (shortcut.altKey != ModifierKey::Disabled) { - humanReadableShortcut += keyboardMap.GetKeyName(shortcut.GetAltKey()) + L" + "; + humanReadableShortcut += keyboardMap.GetKeyName(shortcut.GetAltKey(ModifierKey::Both)) + L" + "; } if (shortcut.shiftKey != ModifierKey::Disabled) { - humanReadableShortcut += keyboardMap.GetKeyName(shortcut.GetShiftKey()) + L" + "; + humanReadableShortcut += keyboardMap.GetKeyName(shortcut.GetShiftKey(ModifierKey::Both)) + L" + "; } if (shortcut.actionKey != NULL) { diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineTest/KeyboardManagerEngineTest.vcxproj b/src/modules/keyboardmanager/KeyboardManagerEngineTest/KeyboardManagerEngineTest.vcxproj index 6120ba8646..f47178e5fb 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineTest/KeyboardManagerEngineTest.vcxproj +++ b/src/modules/keyboardmanager/KeyboardManagerEngineTest/KeyboardManagerEngineTest.vcxproj @@ -1,20 +1,20 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{7f4b3a60-bc27-45a7-8000-68b0b6ea7466}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>KeyboardManagerEngineTest</RootNamespace> <ProjectSubType>NativeUnitTestProject</ProjectSubType> - <ProjectName>KeyboardManagerEngineTest</ProjectName> + <ProjectName>KeyboardManager.Engine.UnitTests</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -30,7 +30,7 @@ </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(VCInstallDir)UnitTest\include;$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(VCInstallDir)UnitTest\include;$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <UseFullPaths>true</UseFullPaths> </ClCompile> <Link> @@ -57,7 +57,7 @@ <ClInclude Include="TestHelpers.h" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> <ProjectReference Include="..\common\KeyboardManagerCommon.vcxproj"> @@ -72,13 +72,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/keyboardmanager/KeyboardManagerEngineTest/packages.config b/src/modules/keyboardmanager/KeyboardManagerEngineTest/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/keyboardmanager/KeyboardManagerEngineTest/packages.config +++ b/src/modules/keyboardmanager/KeyboardManagerEngineTest/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/keyboardmanager/common/Helpers.cpp b/src/modules/keyboardmanager/common/Helpers.cpp index fb2cefd98a..b619ae73d3 100644 --- a/src/modules/keyboardmanager/common/Helpers.cpp +++ b/src/modules/keyboardmanager/common/Helpers.cpp @@ -262,27 +262,27 @@ namespace Helpers } // Function to set key events for modifier keys: When shortcutToCompare is passed (non-empty shortcut), then the key event is sent only if both shortcut's don't have the same modifier key. When keyToBeReleased is passed (non-NULL), then the key event is sent if either the shortcuts don't have the same modifier or if the shortcutToBeSent's modifier matches the keyToBeReleased - void SetModifierKeyEvents(const Shortcut& shortcutToBeSent, const ModifierKey& winKeyInvoked, std::vector<INPUT>& keyEventArray, bool isKeyDown, ULONG_PTR extraInfoFlag, const Shortcut& shortcutToCompare, const DWORD& keyToBeReleased) + void SetModifierKeyEvents(const Shortcut& shortcutToBeSent, const Modifiers& modifiersKeys, std::vector<INPUT>& keyEventArray, bool isKeyDown, ULONG_PTR extraInfoFlag, const Shortcut& shortcutToCompare, const DWORD& keyToBeReleased) { // If key down is to be sent, send in the order Win, Ctrl, Alt, Shift if (isKeyDown) { // If shortcutToCompare is non-empty, then the key event is sent only if both shortcut's don't have the same modifier key. If keyToBeReleased is non-NULL, then the key event is sent if either the shortcuts don't have the same modifier or if the shortcutToBeSent's modifier matches the keyToBeReleased - if (shortcutToBeSent.GetWinKey(winKeyInvoked) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetWinKey(winKeyInvoked) != shortcutToCompare.GetWinKey(winKeyInvoked)) && (keyToBeReleased == NULL || !shortcutToBeSent.CheckWinKey(keyToBeReleased))) + if (shortcutToBeSent.GetWinKey(modifiersKeys.winKey) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetWinKey(modifiersKeys.winKey) != shortcutToCompare.GetWinKey(modifiersKeys.winKey)) && (keyToBeReleased == NULL || !shortcutToBeSent.CheckWinKey(keyToBeReleased))) { - Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetWinKey(winKeyInvoked)), 0, extraInfoFlag); + Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetWinKey(modifiersKeys.winKey)), 0, extraInfoFlag); } - if (shortcutToBeSent.GetCtrlKey() != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetCtrlKey() != shortcutToCompare.GetCtrlKey()) && (keyToBeReleased == NULL || !shortcutToBeSent.CheckCtrlKey(keyToBeReleased))) + if (shortcutToBeSent.GetCtrlKey(modifiersKeys.ctrlKey) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetCtrlKey(modifiersKeys.ctrlKey) != shortcutToCompare.GetCtrlKey(modifiersKeys.ctrlKey)) && (keyToBeReleased == NULL || !shortcutToBeSent.CheckCtrlKey(keyToBeReleased))) { - Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetCtrlKey()), 0, extraInfoFlag); + Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetCtrlKey(modifiersKeys.ctrlKey)), 0, extraInfoFlag); } - if (shortcutToBeSent.GetAltKey() != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetAltKey() != shortcutToCompare.GetAltKey()) && (keyToBeReleased == NULL || !shortcutToBeSent.CheckAltKey(keyToBeReleased))) + if (shortcutToBeSent.GetAltKey(modifiersKeys.altKey) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetAltKey(modifiersKeys.altKey) != shortcutToCompare.GetAltKey(modifiersKeys.altKey)) && (keyToBeReleased == NULL || !shortcutToBeSent.CheckAltKey(keyToBeReleased))) { - Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetAltKey()), 0, extraInfoFlag); + Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetAltKey(modifiersKeys.altKey)), 0, extraInfoFlag); } - if (shortcutToBeSent.GetShiftKey() != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetShiftKey() != shortcutToCompare.GetShiftKey()) && (keyToBeReleased == NULL || !shortcutToBeSent.CheckShiftKey(keyToBeReleased))) + if (shortcutToBeSent.GetShiftKey(modifiersKeys.shiftKey) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetShiftKey(modifiersKeys.shiftKey) != shortcutToCompare.GetShiftKey(modifiersKeys.shiftKey)) && (keyToBeReleased == NULL || !shortcutToBeSent.CheckShiftKey(keyToBeReleased))) { - Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetShiftKey()), 0, extraInfoFlag); + Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetShiftKey(modifiersKeys.shiftKey)), 0, extraInfoFlag); } } @@ -290,21 +290,21 @@ namespace Helpers else { // If shortcutToCompare is non-empty, then the key event is sent only if both shortcut's don't have the same modifier key. If keyToBeReleased is non-NULL, then the key event is sent if either the shortcuts don't have the same modifier or if the shortcutToBeSent's modifier matches the keyToBeReleased - if (shortcutToBeSent.GetShiftKey() != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetShiftKey() != shortcutToCompare.GetShiftKey() || shortcutToBeSent.CheckShiftKey(keyToBeReleased))) + if (shortcutToBeSent.GetShiftKey(modifiersKeys.shiftKey) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetShiftKey(modifiersKeys.shiftKey) != shortcutToCompare.GetShiftKey(modifiersKeys.shiftKey) || shortcutToBeSent.CheckShiftKey(keyToBeReleased))) { - Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetShiftKey()), KEYEVENTF_KEYUP, extraInfoFlag); + Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetShiftKey(modifiersKeys.shiftKey)), KEYEVENTF_KEYUP, extraInfoFlag); } - if (shortcutToBeSent.GetAltKey() != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetAltKey() != shortcutToCompare.GetAltKey() || shortcutToBeSent.CheckAltKey(keyToBeReleased))) + if (shortcutToBeSent.GetAltKey(modifiersKeys.altKey) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetAltKey(modifiersKeys.altKey) != shortcutToCompare.GetAltKey(modifiersKeys.altKey) || shortcutToBeSent.CheckAltKey(keyToBeReleased))) { - Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetAltKey()), KEYEVENTF_KEYUP, extraInfoFlag); + Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetAltKey(modifiersKeys.altKey)), KEYEVENTF_KEYUP, extraInfoFlag); } - if (shortcutToBeSent.GetCtrlKey() != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetCtrlKey() != shortcutToCompare.GetCtrlKey() || shortcutToBeSent.CheckCtrlKey(keyToBeReleased))) + if (shortcutToBeSent.GetCtrlKey(modifiersKeys.ctrlKey) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetCtrlKey(modifiersKeys.ctrlKey) != shortcutToCompare.GetCtrlKey(modifiersKeys.ctrlKey) || shortcutToBeSent.CheckCtrlKey(keyToBeReleased))) { - Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetCtrlKey()), KEYEVENTF_KEYUP, extraInfoFlag); + Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetCtrlKey(modifiersKeys.ctrlKey)), KEYEVENTF_KEYUP, extraInfoFlag); } - if (shortcutToBeSent.GetWinKey(winKeyInvoked) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetWinKey(winKeyInvoked) != shortcutToCompare.GetWinKey(winKeyInvoked) || shortcutToBeSent.CheckWinKey(keyToBeReleased))) + if (shortcutToBeSent.GetWinKey(modifiersKeys.winKey) != NULL && (shortcutToCompare.IsEmpty() || shortcutToBeSent.GetWinKey(modifiersKeys.winKey) != shortcutToCompare.GetWinKey(modifiersKeys.winKey) || shortcutToBeSent.CheckWinKey(keyToBeReleased))) { - Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetWinKey(winKeyInvoked)), KEYEVENTF_KEYUP, extraInfoFlag); + Helpers::SetKeyEvent(keyEventArray, INPUT_KEYBOARD, static_cast<WORD>(shortcutToBeSent.GetWinKey(modifiersKeys.winKey)), KEYEVENTF_KEYUP, extraInfoFlag); } } } diff --git a/src/modules/keyboardmanager/common/Helpers.h b/src/modules/keyboardmanager/common/Helpers.h index bd878a3942..8f38bbbbe4 100644 --- a/src/modules/keyboardmanager/common/Helpers.h +++ b/src/modules/keyboardmanager/common/Helpers.h @@ -1,5 +1,6 @@ #pragma once #include "Shortcut.h" +#include "RemapShortcut.h" class LayoutMap; @@ -47,7 +48,8 @@ namespace Helpers std::wstring GetCurrentApplication(bool keepPath); // Function to set key events for modifier keys: When shortcutToCompare is passed (non-empty shortcut), then the key event is sent only if both shortcut's don't have the same modifier key. When keyToBeReleased is passed (non-NULL), then the key event is sent if either the shortcuts don't have the same modifier or if the shortcutToBeSent's modifier matches the keyToBeReleased - void SetModifierKeyEvents(const Shortcut& shortcutToBeSent, const ModifierKey& winKeyInvoked, std::vector<INPUT>& keyEventArray, bool isKeyDown, ULONG_PTR extraInfoFlag, const Shortcut& shortcutToCompare = Shortcut(), const DWORD& keyToBeReleased = NULL); + void SetModifierKeyEvents(const Shortcut& shortcutToBeSent, const Modifiers& modifiersKeys, std::vector<INPUT>& keyEventArray, bool isKeyDown, ULONG_PTR extraInfoFlag, const Shortcut& shortcutToCompare = Shortcut(), const DWORD& keyToBeReleased = NULL); + // Function to filter the key codes for artificial key codes int32_t FilterArtificialKeys(const int32_t& key); diff --git a/src/modules/keyboardmanager/common/KeyboardManagerCommon.vcxproj b/src/modules/keyboardmanager/common/KeyboardManagerCommon.vcxproj index 36e76f971c..0366b20c05 100644 --- a/src/modules/keyboardmanager/common/KeyboardManagerCommon.vcxproj +++ b/src/modules/keyboardmanager/common/KeyboardManagerCommon.vcxproj @@ -1,15 +1,15 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <ProjectGuid>{8AFFA899-0B73-49EC-8C50-0FADDA57B2FC}</ProjectGuid> <RootNamespace>KeyboardManagerCommon</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -21,12 +21,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\;..\..\..\;..\..\..\common\telemetry;..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;$(RepoRoot)src\;$(RepoRoot)src\common\telemetry;..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <AdditionalOptions>%(AdditionalOptions)</AdditionalOptions> </ClCompile> <Lib> @@ -37,7 +37,7 @@ </Link> </ItemDefinitionGroup> <ItemGroup> - <ClCompile Include="..\..\..\common\interop\keyboard_layout.cpp" /> + <ClCompile Include="$(RepoRoot)src\common\interop\keyboard_layout.cpp" /> <ClCompile Include="Helpers.cpp" /> <ClCompile Include="KeyboardEventHandlers.cpp" /> <ClCompile Include="MappingConfiguration.cpp" /> @@ -54,18 +54,19 @@ <ClInclude Include="InputInterface.h" /> <ClInclude Include="Helpers.h" /> <ClInclude Include="KeyboardManagerConstants.h" /> + <ClInclude Include="Modifiers.h" /> <ClInclude Include="pch.h" /> <ClInclude Include="RemapShortcut.h" /> <ClInclude Include="Shortcut.h" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\COMUtils\COMUtils.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\COMUtils\COMUtils.vcxproj"> <Project>{7319089e-46d6-4400-bc65-e39bdf1416ee}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -73,15 +74,15 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/keyboardmanager/common/KeyboardManagerCommon.vcxproj.filters b/src/modules/keyboardmanager/common/KeyboardManagerCommon.vcxproj.filters index d1df33fa5e..287017e312 100644 --- a/src/modules/keyboardmanager/common/KeyboardManagerCommon.vcxproj.filters +++ b/src/modules/keyboardmanager/common/KeyboardManagerCommon.vcxproj.filters @@ -65,6 +65,9 @@ <ClInclude Include="MappingConfiguration.h"> <Filter>Header Files</Filter> </ClInclude> + <ClInclude Include="Modifiers.h"> + <Filter>Header Files</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <None Include="packages.config" /> diff --git a/src/modules/keyboardmanager/common/Modifiers.h b/src/modules/keyboardmanager/common/Modifiers.h new file mode 100644 index 0000000000..17dc5612e8 --- /dev/null +++ b/src/modules/keyboardmanager/common/Modifiers.h @@ -0,0 +1,26 @@ +#pragma once +#include "ModifierKey.h" + +#include <vector> + +class Modifiers +{ +public: + ModifierKey winKey = ModifierKey::Disabled; + ModifierKey ctrlKey = ModifierKey::Disabled; + ModifierKey altKey = ModifierKey::Disabled; + ModifierKey shiftKey = ModifierKey::Disabled; + + void Reset() + { + winKey = ModifierKey::Disabled; + ctrlKey = ModifierKey::Disabled; + altKey = ModifierKey::Disabled; + shiftKey = ModifierKey::Disabled; + } + + inline bool operator==(const Modifiers& other) const + { + return winKey == other.winKey && ctrlKey == other.ctrlKey && altKey == other.altKey && shiftKey == other.shiftKey; + } +}; diff --git a/src/modules/keyboardmanager/common/RemapShortcut.h b/src/modules/keyboardmanager/common/RemapShortcut.h index 8690630b4c..de12867903 100644 --- a/src/modules/keyboardmanager/common/RemapShortcut.h +++ b/src/modules/keyboardmanager/common/RemapShortcut.h @@ -1,6 +1,8 @@ #pragma once #include "Shortcut.h" +#include "Modifiers.h" #include <variant> +#include <vector> // This class stores all the variables associated with each shortcut remapping class RemapShortcut @@ -8,23 +10,24 @@ class RemapShortcut public: KeyShortcutTextUnion targetShortcut; bool isShortcutInvoked; - ModifierKey winKeyInvoked; + + Modifiers modifierKeysInvoked; // This bool value is only required for remapping shortcuts to Disable bool isOriginalActionKeyPressed; RemapShortcut(const KeyShortcutTextUnion& sc) : - targetShortcut(sc), isShortcutInvoked(false), winKeyInvoked(ModifierKey::Disabled), isOriginalActionKeyPressed(false) + targetShortcut(sc), isShortcutInvoked(false), isOriginalActionKeyPressed(false) { } RemapShortcut() : - targetShortcut(Shortcut()), isShortcutInvoked(false), winKeyInvoked(ModifierKey::Disabled), isOriginalActionKeyPressed(false) + targetShortcut(Shortcut()), isShortcutInvoked(false), isOriginalActionKeyPressed(false) { } inline bool operator==(const RemapShortcut& sc) const { - return targetShortcut == sc.targetShortcut && isShortcutInvoked == sc.isShortcutInvoked && winKeyInvoked == sc.winKeyInvoked; + return targetShortcut == sc.targetShortcut && isShortcutInvoked == sc.isShortcutInvoked && modifierKeysInvoked == sc.modifierKeysInvoked; } bool RemapToKey() diff --git a/src/modules/keyboardmanager/common/Shortcut.cpp b/src/modules/keyboardmanager/common/Shortcut.cpp index 38550199f4..08f161e08c 100644 --- a/src/modules/keyboardmanager/common/Shortcut.cpp +++ b/src/modules/keyboardmanager/common/Shortcut.cpp @@ -185,7 +185,7 @@ DWORD Shortcut::GetWinKey(const ModifierKey& input) const } // Function to return the virtual key code of the ctrl key state expected in the shortcut. Return NULL if it is not a part of the shortcut -DWORD Shortcut::GetCtrlKey() const +DWORD Shortcut::GetCtrlKey(const ModifierKey& input) const { if (ctrlKey == ModifierKey::Disabled) { @@ -201,12 +201,20 @@ DWORD Shortcut::GetCtrlKey() const } else { + if (input == ModifierKey::Right) + { + return VK_RCONTROL; + } + if (input == ModifierKey::Left) + { + return VK_LCONTROL; + } return VK_CONTROL; } } // Function to return the virtual key code of the alt key state expected in the shortcut. Return NULL if it is not a part of the shortcut -DWORD Shortcut::GetAltKey() const +DWORD Shortcut::GetAltKey(const ModifierKey& input) const { if (altKey == ModifierKey::Disabled) { @@ -220,6 +228,14 @@ DWORD Shortcut::GetAltKey() const { return VK_RMENU; } + if (input == ModifierKey::Right) + { + return VK_RMENU; + } + else if (input == ModifierKey::Left || input == ModifierKey::Disabled) + { + return VK_LMENU; + } else { return VK_MENU; @@ -227,7 +243,7 @@ DWORD Shortcut::GetAltKey() const } // Function to return the virtual key code of the shift key state expected in the shortcut. Return NULL if it is not a part of the shortcut -DWORD Shortcut::GetShiftKey() const +DWORD Shortcut::GetShiftKey(const ModifierKey& input) const { if (shiftKey == ModifierKey::Disabled) { @@ -243,6 +259,14 @@ DWORD Shortcut::GetShiftKey() const } else { + if (input == ModifierKey::Right) + { + return VK_RSHIFT; + } + if (input == ModifierKey::Left) + { + return VK_LSHIFT; + } return VK_SHIFT; } } @@ -493,15 +517,15 @@ winrt::hstring Shortcut::ToHstringVK() const } if (ctrlKey != ModifierKey::Disabled) { - output = output + winrt::to_hstring(static_cast<unsigned int>(GetCtrlKey())) + winrt::to_hstring(L";"); + output = output + winrt::to_hstring(static_cast<unsigned int>(GetCtrlKey(ModifierKey::Both))) + winrt::to_hstring(L";"); } if (altKey != ModifierKey::Disabled) { - output = output + winrt::to_hstring(static_cast<unsigned int>(GetAltKey())) + winrt::to_hstring(L";"); + output = output + winrt::to_hstring(static_cast<unsigned int>(GetAltKey(ModifierKey::Both))) + winrt::to_hstring(L";"); } if (shiftKey != ModifierKey::Disabled) { - output = output + winrt::to_hstring(static_cast<unsigned int>(GetShiftKey())) + winrt::to_hstring(L";"); + output = output + winrt::to_hstring(static_cast<unsigned int>(GetShiftKey(ModifierKey::Both))) + winrt::to_hstring(L";"); } if (actionKey != NULL) { @@ -531,15 +555,15 @@ std::vector<DWORD> Shortcut::GetKeyCodes() } if (ctrlKey != ModifierKey::Disabled) { - keys.push_back(GetCtrlKey()); + keys.push_back(GetCtrlKey(ModifierKey::Both)); } if (altKey != ModifierKey::Disabled) { - keys.push_back(GetAltKey()); + keys.push_back(GetAltKey(ModifierKey::Both)); } if (shiftKey != ModifierKey::Disabled) { - keys.push_back(GetShiftKey()); + keys.push_back(GetShiftKey(ModifierKey::Both)); } if (actionKey != NULL) { diff --git a/src/modules/keyboardmanager/common/Shortcut.h b/src/modules/keyboardmanager/common/Shortcut.h index d9a61ff433..439559c9d0 100644 --- a/src/modules/keyboardmanager/common/Shortcut.h +++ b/src/modules/keyboardmanager/common/Shortcut.h @@ -4,7 +4,6 @@ #include <compare> #include <tuple> #include <variant> - namespace KeyboardManagerInput { class InputInterface; @@ -142,13 +141,13 @@ public: DWORD GetWinKey(const ModifierKey& input) const; // Function to return the virtual key code of the ctrl key state expected in the shortcut. Return NULL if it is not a part of the shortcut - DWORD GetCtrlKey() const; + DWORD GetCtrlKey(const ModifierKey& input) const; // Function to return the virtual key code of the alt key state expected in the shortcut. Return NULL if it is not a part of the shortcut - DWORD GetAltKey() const; + DWORD GetAltKey(const ModifierKey& input) const; // Function to return the virtual key code of the shift key state expected in the shortcut. Return NULL if it is not a part of the shortcut - DWORD GetShiftKey() const; + DWORD GetShiftKey(const ModifierKey& input) const; // Function to check if the input key matches the win key expected in the shortcut bool CheckWinKey(const DWORD input) const; diff --git a/src/modules/keyboardmanager/common/packages.config b/src/modules/keyboardmanager/common/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/keyboardmanager/common/packages.config +++ b/src/modules/keyboardmanager/common/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/keyboardmanager/dll/KeyboardManager.vcxproj b/src/modules/keyboardmanager/dll/KeyboardManager.vcxproj index 255ded7abd..86039b95e0 100644 --- a/src/modules/keyboardmanager/dll/KeyboardManager.vcxproj +++ b/src/modules/keyboardmanager/dll/KeyboardManager.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{89f34af7-1c34-4a72-aa6e-534bcf972bd9}</ProjectGuid> @@ -8,12 +9,11 @@ <RootNamespace>KeyboardManager</RootNamespace> <ProjectName>KeyboardManager</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -25,12 +25,12 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <TargetName>PowerToys.KeyboardManager</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> @@ -48,16 +48,15 @@ <ItemGroup> <ClCompile Include="dllmain.cpp" /> <ClCompile Include="trace.cpp" /> - <None Include="KeyboardManager.base.rc" /> <ClCompile Include="pch.cpp"> <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> </ClCompile> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> <ProjectReference Include="..\common\KeyboardManagerCommon.vcxproj"> @@ -66,20 +65,24 @@ </ItemGroup> <ItemGroup> <ResourceCompile Include="Generated Files\KeyboardManager.rc" /> + <None Include="KeyboardManager.base.rc" /> </ItemGroup> <ItemGroup> <None Include="Resources.resx" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> + <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h KeyboardManager.base.rc KeyboardManager.rc" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/keyboardmanager/dll/packages.config b/src/modules/keyboardmanager/dll/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/keyboardmanager/dll/packages.config +++ b/src/modules/keyboardmanager/dll/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj b/src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj index 8dc2df71bf..e953143013 100644 --- a/src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj +++ b/src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj @@ -1,9 +1,10 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h Microsoft.Launcher.base.rc Microsoft.Launcher.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h Microsoft.Launcher.base.rc Microsoft.Launcher.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> @@ -12,13 +13,12 @@ <RootNamespace>Wox_Launcher</RootNamespace> <ProjectName>Microsoft.Launcher</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -35,7 +35,7 @@ <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <AdditionalDependencies>Shlwapi.lib;%(AdditionalDependencies)</AdditionalDependencies> @@ -55,10 +55,10 @@ </ClCompile> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -73,17 +73,17 @@ <None Include="Resources.resx" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/launcher/Microsoft.Launcher/packages.config b/src/modules/launcher/Microsoft.Launcher/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/launcher/Microsoft.Launcher/packages.config +++ b/src/modules/launcher/Microsoft.Launcher/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest.csproj b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest.csproj index eb50f533b1..c14e9e4ead 100644 --- a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest.csproj +++ b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest/Community.PowerToys.Run.Plugin.UnitConverter.UnitTest.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/Community.PowerToys.Run.Plugin.UnitConverter.csproj b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/Community.PowerToys.Run.Plugin.UnitConverter.csproj index 8bfd37e7a8..cf2328e442 100644 --- a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/Community.PowerToys.Run.Plugin.UnitConverter.csproj +++ b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/Community.PowerToys.Run.Plugin.UnitConverter.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{BB23A474-5058-4F75-8FA3-5FE3DE53CDF4}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\UnitConverter\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\UnitConverter\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/InputInterpreter.cs b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/InputInterpreter.cs index dbe718e6e7..cbd1d36f4a 100644 --- a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/InputInterpreter.cs +++ b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.UnitConverter/InputInterpreter.cs @@ -44,7 +44,7 @@ namespace Community.PowerToys.Run.Plugin.UnitConverter } /// <summary> - /// Replaces a split input array with shorthand feet/inch notation (1', 1'2" etc) to 'x foot in cm'. + /// Replaces a split input array with shorthand feet/inch notation (1', 1'2", etc.) to 'x foot in cm'. /// </summary> public static void ShorthandFeetInchHandler(ref string[] split, CultureInfo culture) { diff --git a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.VSCodeWorkspaces/Community.PowerToys.Run.Plugin.VSCodeWorkspaces.csproj b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.VSCodeWorkspaces/Community.PowerToys.Run.Plugin.VSCodeWorkspaces.csproj index f482308c42..7a86ae52f9 100644 --- a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.VSCodeWorkspaces/Community.PowerToys.Run.Plugin.VSCodeWorkspaces.csproj +++ b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.VSCodeWorkspaces/Community.PowerToys.Run.Plugin.VSCodeWorkspaces.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{4D971245-7A70-41D5-BAA0-DDB5684CAF51}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\VSCodeWorkspaces\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\VSCodeWorkspaces\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests.csproj b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests.csproj index 68b6e33c8b..b15a47c9b4 100644 --- a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests.csproj +++ b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests/Community.PowerToys.Run.Plugin.ValueGenerator.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <Nullable>enable</Nullable> diff --git a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator/Community.PowerToys.Run.Plugin.ValueGenerator.csproj b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator/Community.PowerToys.Run.Plugin.ValueGenerator.csproj index 31ba1ce1e8..e771f4e6ca 100644 --- a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator/Community.PowerToys.Run.Plugin.ValueGenerator.csproj +++ b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.ValueGenerator/Community.PowerToys.Run.Plugin.ValueGenerator.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{D095BE44-1F2E-463E-A494-121892A75EA2}</ProjectGuid> @@ -11,7 +11,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\ValueGenerator\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\ValueGenerator\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.WebSearch/Community.PowerToys.Run.Plugin.WebSearch.csproj b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.WebSearch/Community.PowerToys.Run.Plugin.WebSearch.csproj index 623ea68075..b43f91f5e8 100644 --- a/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.WebSearch/Community.PowerToys.Run.Plugin.WebSearch.csproj +++ b/src/modules/launcher/Plugins/Community.PowerToys.Run.Plugin.WebSearch/Community.PowerToys.Run.Plugin.WebSearch.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{9F94B303-5E21-4364-9362-64426F8DB932}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\WebSearch\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\WebSearch\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj index f21e5093cd..5292ee9009 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder.UnitTests/Microsoft.Plugin.Folder.UnitTests.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Microsoft.Plugin.Folder.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Microsoft.Plugin.Folder.csproj index 9207e9dd56..4648722df9 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Microsoft.Plugin.Folder.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Microsoft.Plugin.Folder.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{787B8AA6-CA93-4C84-96FE-DF31110AD1C4}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\Folder\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\Folder\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryInternalDirectory.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryInternalDirectory.cs index f1dc64340f..844c3521dd 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryInternalDirectory.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Folder/Sources/QueryInternalDirectory.cs @@ -101,7 +101,7 @@ namespace Microsoft.Plugin.Folder.Sources if (isRecursive) { - // match everything before and after search term using supported wildcard '*', ie. *searchterm* + // match everything before and after search term using supported wildcard '*', i.e. *searchterm* if (string.IsNullOrEmpty(incompleteName)) { incompleteName = "*"; diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/DriveDetection/RegistryWrapper.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/DriveDetection/RegistryWrapper.cs index a4f4fbcc32..ca194f8a06 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/DriveDetection/RegistryWrapper.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/DriveDetection/RegistryWrapper.cs @@ -8,7 +8,7 @@ namespace Microsoft.Plugin.Indexer.DriveDetection { public class RegistryWrapper : IRegistryWrapper { - // Given the registrypath and the name of the value, to retrieve the data corresponding to that registry key + // Given the registry path and the name of the value, to retrieve the data corresponding to that registry key public int GetHKLMRegistryValue(string registryLocation, string valueName) { using (RegistryKey regKey = Registry.LocalMachine.OpenSubKey(registryLocation)) diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/Microsoft.Plugin.Indexer.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/Microsoft.Plugin.Indexer.csproj index 89191ad854..dcfbb3dfb1 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/Microsoft.Plugin.Indexer.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Indexer/Microsoft.Plugin.Indexer.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{F8B870EB-D5F5-45BA-9CF7-A5C459818820}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\Indexer\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\Indexer\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Program.UnitTests/Microsoft.Plugin.Program.UnitTests.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Program.UnitTests/Microsoft.Plugin.Program.UnitTests.csproj index b25c1a8494..d55171e92b 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Program.UnitTests/Microsoft.Plugin.Program.UnitTests.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Program.UnitTests/Microsoft.Plugin.Program.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Program.UnitTests/Storage/Win32ProgramRepositoryTest.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Program.UnitTests/Storage/Win32ProgramRepositoryTest.cs index 48e86e9850..554ad46882 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Program.UnitTests/Storage/Win32ProgramRepositoryTest.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Program.UnitTests/Storage/Win32ProgramRepositoryTest.cs @@ -363,7 +363,7 @@ namespace Microsoft.Plugin.Program.UnitTests.Storage RenamedEventArgs e = new RenamedEventArgs(WatcherChangeTypes.Renamed, directory, path, oldpath); string oldFullPath = directory + "\\" + oldpath; - string fullPath = directory + "\\" + path; + string newFullPath = directory + "\\" + path; string linkingTo = Directory.GetCurrentDirectory(); // ShellLinkHelper must be mocked for lnk applications @@ -372,19 +372,8 @@ namespace Microsoft.Plugin.Program.UnitTests.Storage Win32Program.ShellLinkHelper = mockShellLink.Object; // old item and new item are the actual items when they are in existence - Win32Program olditem = new Win32Program - { - Name = "oldpath", - ExecutableName = oldpath, - FullPath = linkingTo, - }; - - Win32Program newitem = new Win32Program - { - Name = "path", - ExecutableName = path, - FullPath = linkingTo, - }; + Win32Program olditem = Win32Program.GetAppFromPath(oldFullPath); + Win32Program newitem = Win32Program.GetAppFromPath(newFullPath); win32ProgramRepository.Add(olditem); diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Logger/ProgramLogger.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Logger/ProgramLogger.cs index 75b2ad8941..e3dd484c3a 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Logger/ProgramLogger.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Logger/ProgramLogger.cs @@ -19,7 +19,7 @@ namespace Microsoft.Plugin.Program.Logger internal static class ProgramLogger { /// <summary> - /// Logs an warning + /// Logs a warning /// </summary> [MethodImpl(MethodImplOptions.Synchronized)] internal static void Warn(string message, Exception ex, Type fullClassName, string loadingProgramPath, [CallerMemberName] string methodName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Microsoft.Plugin.Program.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Microsoft.Plugin.Program.csproj index bf0034286f..853a23d20b 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Microsoft.Plugin.Program.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Microsoft.Plugin.Program.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{FDB3555B-58EF-4AE6-B5F1-904719637AB4}</ProjectGuid> @@ -13,7 +13,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\Program\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\Program\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Programs/UWPApplication.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Programs/UWPApplication.cs index b5611b793b..b8721d3677 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Programs/UWPApplication.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Programs/UWPApplication.cs @@ -115,7 +115,7 @@ namespace Microsoft.Plugin.Program.Programs }, }; - // To set the title to always be the displayname of the packaged application + // To set the title to always be the display name of the packaged application result.Title = DisplayName; result.TitleHighlightData = StringMatcher.FuzzySearch(query, Name).MatchData; @@ -596,7 +596,7 @@ namespace Microsoft.Plugin.Program.Programs } else { - // for C:\Windows\MiracastView etc + // for C:\Windows\MiracastView, etc. path = Path.Combine(Package.Location, "Assets", uri); } diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Programs/Win32Program.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Programs/Win32Program.cs index 56fd4d894d..d16d0e32ca 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Programs/Win32Program.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Programs/Win32Program.cs @@ -82,7 +82,7 @@ namespace Microsoft.Plugin.Program.Programs public ApplicationType AppType { get; set; } // Wrappers for File Operations - public static IFileVersionInfoWrapper FileVersionInfoWrapper { get; set; } = new FileVersionInfoWrapper(); + public static IFileVersionInfoWrapper FileVersionInfoWrapper { get; set; } = new Wox.Infrastructure.FileSystemHelper.FileVersionInfoWrapper(); public static IFile FileWrapper { get; set; } = new FileSystem().File; @@ -991,7 +991,7 @@ namespace Microsoft.Plugin.Program.Programs var paths = new HashSet<string>(defaultHashsetSize); var runCommandPaths = new HashSet<string>(defaultHashsetSize); - // Parallelize multiple sources, and priority based on paths which most likely contain .lnks which are formatted + // Parallelize multiple sources, and priority based on paths which most likely contain .lnk files which are formatted var sources = new (bool IsEnabled, Func<IEnumerable<string>> GetPaths)[] { (true, () => CustomProgramPaths(settings.ProgramSources, settings.ProgramSuffixes)), diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Storage/PackageRepository.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Storage/PackageRepository.cs index d42e4b991f..5aa3a78e3d 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Storage/PackageRepository.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Storage/PackageRepository.cs @@ -83,7 +83,7 @@ namespace Microsoft.Plugin.Program.Storage // InitializeAppInfo will throw if there is no AppxManifest.xml for the package. // Note there are sometimes multiple packages per product and this doesn't necessarily mean that we haven't found the app. - // eg. "Could not find file 'C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminalPreview_2020.616.45.0_neutral_~_8wekyb3d8bbwe\\AppxManifest.xml'." + // e.g. "Could not find file 'C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminalPreview_2020.616.45.0_neutral_~_8wekyb3d8bbwe\\AppxManifest.xml'." catch (System.IO.FileNotFoundException e) { ProgramLogger.Exception(e.Message, e, GetType(), package.InstalledLocation.ToString()); diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Storage/Win32ProgramRepository.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Storage/Win32ProgramRepository.cs index e43f85bcfd..7fcae9bc95 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Storage/Win32ProgramRepository.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Program/Storage/Win32ProgramRepository.cs @@ -100,7 +100,7 @@ namespace Microsoft.Plugin.Program.Storage // fix for https://github.com/microsoft/PowerToys/issues/34391 // the msi installer creates a shortcut, which is detected by the PT Run and ends up in calling this OnAppRenamed method - // the thread needs to be halted for a short time to avoid locking the new shortcut file as we read it, otherwise the lock causes + // the thread needs to be halted for a short time to avoid locking the new shortcut file as we read it; otherwise, the lock causes // in the issue scenario that a warning is popping up during the msi install process. await Task.Delay(OnRenamedEventWaitTime).ConfigureAwait(false); @@ -203,12 +203,12 @@ namespace Microsoft.Plugin.Program.Storage } // When a URL application is deleted, we can no longer get the HashCode directly from the path because the FullPath a Url app is the URL obtained from reading the file - [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1309:Use ordinal string comparison", Justification = "Using CurrentCultureIgnoreCase since application names could be dependent on currentculture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1309:Use ordinal string comparison", Justification = "Using CurrentCultureIgnoreCase since application names could be dependent on current culture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190")] private Win32Program GetAppWithSameNameAndExecutable(string name, string executableName) { foreach (Win32Program app in Items) { - // Using CurrentCultureIgnoreCase since application names could be dependent on currentculture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190 + // Using CurrentCultureIgnoreCase since application names could be dependent on current culture See: https://github.com/microsoft/PowerToys/pull/5847/files#r468245190 if (name.Equals(app.Name, StringComparison.CurrentCultureIgnoreCase) && executableName.Equals(app.ExecutableName, StringComparison.CurrentCultureIgnoreCase)) { return app; diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Main.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Main.cs index 20c05858eb..ba8c245528 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Main.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Main.cs @@ -97,7 +97,7 @@ namespace Microsoft.Plugin.Shell string cmd = query.Search; if (string.IsNullOrEmpty(cmd)) { - return ResultsFromlHistory(); + return ResultsFromHistory(); } else { @@ -169,7 +169,7 @@ namespace Microsoft.Plugin.Shell return result; } - private List<Result> ResultsFromlHistory() + private List<Result> ResultsFromHistory() { IEnumerable<Result> history = _settings.Count.OrderByDescending(o => o.Value) .Select(m => new Result @@ -446,7 +446,7 @@ namespace Microsoft.Plugin.Shell public List<ContextMenuResult> LoadContextMenus(Result selectedResult) { - var resultlist = new List<ContextMenuResult> + var resultList = new List<ContextMenuResult> { new ContextMenuResult { @@ -478,7 +478,7 @@ namespace Microsoft.Plugin.Shell }, }; - return resultlist; + return resultList; } public void UpdateSettings(PowerLauncherPluginSettings settings) diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Microsoft.Plugin.Shell.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Microsoft.Plugin.Shell.csproj index 8ac68f7c14..45ffa6d33a 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Microsoft.Plugin.Shell.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Shell/Microsoft.Plugin.Shell.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{C21BFF9C-2C99-4B5F-B7C9-A5E6DDDB37B0}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\Shell\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\Shell\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Uri.UnitTests/Microsoft.Plugin.Uri.UnitTests.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Uri.UnitTests/Microsoft.Plugin.Uri.UnitTests.csproj index 82840b532f..b72d7190bf 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Uri.UnitTests/Microsoft.Plugin.Uri.UnitTests.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Uri.UnitTests/Microsoft.Plugin.Uri.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.Uri/Microsoft.Plugin.Uri.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.Uri/Microsoft.Plugin.Uri.csproj index 876096794e..e69dfc09cf 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.Uri/Microsoft.Plugin.Uri.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.Uri/Microsoft.Plugin.Uri.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{03276a39-d4e9-417c-8ffd-200b0ee5e871}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\Uri\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\Uri\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker.UnitTests/Microsoft.Plugin.WindowWalker.UnitTests.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker.UnitTests/Microsoft.Plugin.WindowWalker.UnitTests.csproj index dbdc0db48b..9fb7d3af3e 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker.UnitTests/Microsoft.Plugin.WindowWalker.UnitTests.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker.UnitTests/Microsoft.Plugin.WindowWalker.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/ContextMenuHelper.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/ContextMenuHelper.cs index ecbe4bfb07..8fc7513da8 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/ContextMenuHelper.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/ContextMenuHelper.cs @@ -73,7 +73,7 @@ namespace Microsoft.Plugin.WindowWalker.Components /// Method to initiate killing the process of a window /// </summary> /// <param name="window">Window data</param> - /// <returns>True if the PT Run window should close, otherwise false.</returns> + /// <returns>True if the PT Run window should close; otherwise, false.</returns> private static bool KillProcessCommand(Window window) { // Validate process diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/FuzzyMatching.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/FuzzyMatching.cs index 35d8981262..c761f4986e 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/FuzzyMatching.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/FuzzyMatching.cs @@ -35,7 +35,7 @@ namespace Microsoft.Plugin.WindowWalker.Components text = text.ToLower(CultureInfo.CurrentCulture); // Create a grid to march matches like - // eg. + // e.g. // a b c a d e c f g // a x x // c x x diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/SearchString.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/SearchString.cs index fb54278ff0..2f971a3333 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/SearchString.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/SearchString.cs @@ -8,7 +8,7 @@ namespace Microsoft.Plugin.WindowWalker.Components /// <summary> /// A class to represent a search string /// </summary> - /// <remarks>Class was added inorder to be able to attach various context data to + /// <remarks>Class was added in order to be able to attach various context data to /// a search string</remarks> internal class SearchString { diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/Window.cs b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/Window.cs index f660cc3057..cb01d9f59c 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/Window.cs +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Components/Window.cs @@ -228,7 +228,7 @@ namespace Microsoft.Plugin.WindowWalker.Components { if (!NativeMethods.ShowWindow(Hwnd, ShowWindowCommand.Restore)) { - // ShowWindow doesn't work if the process is running elevated: fallback to SendMessage + // ShowWindow doesn't work if the process is running elevated: fall back to SendMessage _ = NativeMethods.SendMessage(Hwnd, Win32Constants.WM_SYSCOMMAND, Win32Constants.SC_RESTORE); } } diff --git a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Microsoft.Plugin.WindowWalker.csproj b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Microsoft.Plugin.WindowWalker.csproj index c814348273..908de41f15 100644 --- a/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Microsoft.Plugin.WindowWalker.csproj +++ b/src/modules/launcher/Plugins/Microsoft.Plugin.WindowWalker/Microsoft.Plugin.WindowWalker.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{74F1B9ED-F59C-4FE7-B473-7B453E30837E}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\WindowWalker\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\WindowWalker\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest.csproj index 88d88b2c2f..4127232e74 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest/Microsoft.PowerToys.Run.Plugin.Calculator.UnitTest.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/CalculateHelper.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/CalculateHelper.cs index e35d706a26..73a1435eed 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/CalculateHelper.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/CalculateHelper.cs @@ -76,7 +76,7 @@ namespace Microsoft.PowerToys.Run.Plugin.Calculator private static string CheckScientificNotation(string input) { /** - * NOTE: By the time the expression gets to us, it's already in English format. + * NOTE: By the time that the expression gets to us, it's already in English format. * * Regex explanation: * (-?(\d+({0}\d*)?)|-?({0}\d+)): Used to capture one of two types: diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/Microsoft.PowerToys.Run.Plugin.Calculator.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/Microsoft.PowerToys.Run.Plugin.Calculator.csproj index 9f4123d32a..1565933913 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/Microsoft.PowerToys.Run.Plugin.Calculator.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/Microsoft.PowerToys.Run.Plugin.Calculator.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{59BD9891-3837-438A-958D-ADC7F91F6F7E}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\Calculator\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\Calculator\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.History/Microsoft.PowerToys.Run.Plugin.History.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.History/Microsoft.PowerToys.Run.Plugin.History.csproj index 1197139d30..c4fcf151b8 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.History/Microsoft.PowerToys.Run.Plugin.History.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.History/Microsoft.PowerToys.Run.Plugin.History.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{212AD910-8488-4036-BE20-326931B75FB2}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\History\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\History\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png index 4228da1e88..7a96d92df1 100644 Binary files a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png and b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.dark.png differ diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png index 6c4dcc5ae5..580cf3f609 100644 Binary files a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png and b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Images/oneNote.light.png differ diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Main.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Main.cs index 129350f51c..1237f08cc3 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Main.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Main.cs @@ -103,7 +103,7 @@ namespace Microsoft.PowerToys.Run.Plugin.OneNote return new List<Result>(0); } - // If there's cached results for this query, return immediately, otherwise wait for delayedExecution. + // If there's cached results for this query, return immediately; otherwise, wait for delayedExecution. var results = _cache.Get<List<Result>>(query.Search); return results ?? Query(query, false); } @@ -121,7 +121,7 @@ namespace Microsoft.PowerToys.Run.Plugin.OneNote return new List<Result>(0); } - // Get results from cache if they already exist for this query, otherwise query OneNote. Results will be cached for 1 day. + // Get results from cache if they already exist for this query; otherwise, query OneNote. Results will be cached for 1 day. var results = _cache.GetOrAdd(query.Search, () => { var pages = OneNoteProvider.FindPages(query.Search); diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Microsoft.PowerToys.Run.Plugin.OneNote.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Microsoft.PowerToys.Run.Plugin.OneNote.csproj index 4cc88d06e6..97f5389395 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Microsoft.PowerToys.Run.Plugin.OneNote.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Microsoft.PowerToys.Run.Plugin.OneNote.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <RootNamespace>Microsoft.PowerToys.Run.Plugin.OneNote</RootNamespace> @@ -11,7 +11,7 @@ <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> <Nullable>enable</Nullable> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\OneNote\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\OneNote\</OutputPath> </PropertyGroup> <PropertyGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Components/Utility.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Components/Utility.cs index 03f0090c80..0b154f8e9f 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Components/Utility.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Components/Utility.cs @@ -98,7 +98,7 @@ namespace Microsoft.PowerToys.Run.Plugin.PowerToys.Components AcceleratorModifiers = ModifierKeys.Control | ModifierKeys.Shift, Action = _ => { - SettingsDeepLink.OpenSettings(settingsWindow.Value, false); + SettingsDeepLink.OpenSettings(settingsWindow.Value); return true; }, }); diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Components/UtilityProvider.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Components/UtilityProvider.cs index a953496500..74d35a5b18 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Components/UtilityProvider.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Components/UtilityProvider.cs @@ -27,7 +27,7 @@ namespace Microsoft.PowerToys.Run.Plugin.PowerToys public UtilityProvider() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; var generalSettings = settingsUtils.GetSettings<GeneralSettings>(); _utilities = new List<Utility>(); @@ -228,7 +228,7 @@ namespace Microsoft.PowerToys.Run.Plugin.PowerToys { retryCount++; - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; var generalSettings = settingsUtils.GetSettings<GeneralSettings>(); foreach (var u in _utilities) diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Microsoft.PowerToys.Run.Plugin.PowerToys.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Microsoft.PowerToys.Run.Plugin.PowerToys.csproj index d31dab3b80..2327bee14f 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Microsoft.PowerToys.Run.Plugin.PowerToys.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.PowerToys/Microsoft.PowerToys.Run.Plugin.PowerToys.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <RootNamespace>Microsoft.PowerToys.Run.Plugin.PowerToys</RootNamespace> @@ -9,7 +9,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\PowerToys\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\PowerToys\</OutputPath> </PropertyGroup> <!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info --> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry.UnitTest/Helper/RegistryHelperTest.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry.UnitTest/Helper/RegistryHelperTest.cs index 14a8abd37b..359bff8807 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry.UnitTest/Helper/RegistryHelperTest.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry.UnitTest/Helper/RegistryHelperTest.cs @@ -31,7 +31,7 @@ namespace Microsoft.PowerToys.Run.Plugin.Registry.UnitTest.Helper [TestMethod] public void GetRegistryBaseKeyTestMoreThanOneBaseKey() { - var (baseKeyList, _) = RegistryHelper.GetRegistryBaseKey("HKC\\Control Panel\\Accessibility"); /* #no-spell-check-line */ + var (baseKeyList, _) = RegistryHelper.GetRegistryBaseKey("HKC\\Control Panel\\Accessibility"); Assert.IsNotNull(baseKeyList); Assert.IsTrue(baseKeyList.Count() > 1); diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry.UnitTest/Microsoft.PowerToys.Run.Plugin.Registry.UnitTests.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry.UnitTest/Microsoft.PowerToys.Run.Plugin.Registry.UnitTests.csproj index eeeb6bf3bb..f90d11b779 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry.UnitTest/Microsoft.PowerToys.Run.Plugin.Registry.UnitTests.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry.UnitTest/Microsoft.PowerToys.Run.Plugin.Registry.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <Nullable>enable</Nullable> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Helper/ContextMenuHelper.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Helper/ContextMenuHelper.cs index aec718d4fd..f6997f26b2 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Helper/ContextMenuHelper.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Helper/ContextMenuHelper.cs @@ -91,7 +91,7 @@ namespace Microsoft.PowerToys.Run.Plugin.Registry.Helper /// Open the Windows registry editor and jump to registry key inside the given key (inside the <see cref="RegistryEntry"/> /// </summary> /// <param name="entry">The <see cref="RegistryEntry"/> to jump in</param> - /// <returns><see langword="true"/> if the registry editor was successful open, otherwise <see langword="false"/></returns> + /// <returns><see langword="true"/> if the registry editor was successful open; otherwise, <see langword="false"/></returns> internal static bool TryToOpenInRegistryEditor(in RegistryEntry entry) { try @@ -119,7 +119,7 @@ namespace Microsoft.PowerToys.Run.Plugin.Registry.Helper /// Copy the given text to the clipboard /// </summary> /// <param name="text">The text to copy to the clipboard</param> - /// <returns><see langword="true"/>The text successful copy to the clipboard, otherwise <see langword="false"/></returns> + /// <returns><see langword="true"/>The text successful copy to the clipboard; otherwise, <see langword="false"/></returns> private static bool TryToCopyToClipBoard(in string text) { try diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Helper/QueryHelper.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Helper/QueryHelper.cs index 1af57a4112..ee59f96fde 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Helper/QueryHelper.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Helper/QueryHelper.cs @@ -52,7 +52,7 @@ namespace Microsoft.PowerToys.Run.Plugin.Registry.Helper /// <param name="query">The query that could contain parts</param> /// <param name="queryKey">The key part of the query</param> /// <param name="queryValueName">The value name part of the query</param> - /// <returns><see langword="true"/> when the query search for a key and a value name, otherwise <see langword="false"/></returns> + /// <returns><see langword="true"/> when the query search for a key and a value name; otherwise, <see langword="false"/></returns> internal static bool GetQueryParts(in string query, out string queryKey, out string queryValueName) { var sanitizedQuery = SanitizeQuery(query); diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Microsoft.PowerToys.Run.Plugin.Registry.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Microsoft.PowerToys.Run.Plugin.Registry.csproj index fba2adb1c8..bd93210b90 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Microsoft.PowerToys.Run.Plugin.Registry.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Registry/Microsoft.PowerToys.Run.Plugin.Registry.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <RootNamespace>Microsoft.PowerToys.Run.Plugin.Registry</RootNamespace> @@ -10,7 +10,7 @@ <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <Nullable>enable</Nullable> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\Registry\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\Registry\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Service/Microsoft.PowerToys.Run.Plugin.Service.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Service/Microsoft.PowerToys.Run.Plugin.Service.csproj index 1f9bb66178..f473775c84 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Service/Microsoft.PowerToys.Run.Plugin.Service.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Service/Microsoft.PowerToys.Run.Plugin.Service.csproj @@ -1,6 +1,6 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <RootNamespace>Microsoft.PowerToys.Run.Plugin.Service</RootNamespace> @@ -9,7 +9,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\Service\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\Service\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/Microsoft.PowerToys.Run.Plugin.System.UnitTests.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/Microsoft.PowerToys.Run.Plugin.System.UnitTests.csproj index ec377c4258..34c20c3fd8 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/Microsoft.PowerToys.Run.Plugin.System.UnitTests.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/Microsoft.PowerToys.Run.Plugin.System.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs index d08ec588d3..a6cec9880d 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System.UnitTests/QueryTests.cs @@ -74,7 +74,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.UnitTests var result = main.Object.Query(expectedQuery).FirstOrDefault().SubTitle; // Assert - Assert.AreEqual("Reboot computer into UEFI Firmware Settings (Requires administrative permissions.)", result); + Assert.AreEqual("Reboot computer into UEFI firmware settings (Requires administrative permissions.)", result); } [TestMethod] diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Microsoft.PowerToys.Run.Plugin.System.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Microsoft.PowerToys.Run.Plugin.System.csproj index e868337841..cb8683c826 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Microsoft.PowerToys.Run.Plugin.System.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Microsoft.PowerToys.Run.Plugin.System.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <OutputType>Library</OutputType> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\System\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\System\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs index 5d07afd41f..a530fbc580 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.Properties { // 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", "17.0.0.0")] + [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 { @@ -682,7 +682,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.Properties { } /// <summary> - /// Looks up a localized string similar to You are about to reboot this computer into UEFI Firmware Settings menu, are you sure?. + /// Looks up a localized string similar to You are about to reboot this computer into UEFI firmware settings menu, are you sure?. /// </summary> internal static string Microsoft_plugin_sys_uefi_confirmation { get { @@ -691,7 +691,7 @@ namespace Microsoft.PowerToys.Run.Plugin.System.Properties { } /// <summary> - /// Looks up a localized string similar to Reboot computer into UEFI Firmware Settings (Requires administrative permissions.). + /// Looks up a localized string similar to Reboot computer into UEFI firmware settings (Requires administrative permissions.). /// </summary> internal static string Microsoft_plugin_sys_uefi_description { get { diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx index eeaa8a423b..f9d1deaab2 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.System/Properties/Resources.resx @@ -362,15 +362,15 @@ <comment>Means type like category. Here it means network interface type (ethernet, wifi, ...).</comment> </data> <data name="Microsoft_plugin_sys_uefi" xml:space="preserve"> - <value>UEFI Firmware Settings</value> + <value>UEFI firmware settings</value> <comment>This should align to the action in Windows Recovery Environment that restart into uefi settings.</comment> </data> <data name="Microsoft_plugin_sys_uefi_confirmation" xml:space="preserve"> - <value>You are about to reboot this computer into UEFI Firmware Settings menu, are you sure?</value> + <value>You are about to reboot this computer into UEFI firmware settings menu, are you sure?</value> <comment>This should align to the action in Windows Recovery Environment that restart into uefi settings.</comment> </data> <data name="Microsoft_plugin_sys_uefi_description" xml:space="preserve"> - <value>Reboot computer into UEFI Firmware Settings (Requires administrative permissions.)</value> + <value>Reboot computer into UEFI firmware settings (Requires administrative permissions.)</value> <comment>This should align to the action in Windows Recovery Environment that restart into uefi settings.</comment> </data> <data name="Microsoft_plugin_sys_Unknown" xml:space="preserve"> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests.csproj index e5a1b71814..09bb6c0597 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <IsPackable>false</IsPackable> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/PluginSettingsTests.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/PluginSettingsTests.cs index 2897bbe692..74ea13bb0f 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/PluginSettingsTests.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/PluginSettingsTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Reflection; using Microsoft.PowerToys.Run.Plugin.TimeDate.Components; @@ -23,7 +24,7 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests var result = settings?.Length; // Assert - Assert.AreEqual(6, result); + Assert.AreEqual(7, result); } [DataTestMethod] @@ -33,6 +34,7 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests [DataRow("TimeWithSeconds")] [DataRow("DateWithWeekday")] [DataRow("HideNumberMessageOnGlobalQuery")] + [DataRow("CustomFormats")] public void DoesSettingExist(string name) { // Setup @@ -78,5 +80,20 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests // Assert Assert.AreEqual(valueExpected, result); } + + [DataTestMethod] + [DataRow("CustomFormats")] + public void DefaultEmptyMultilineTextValues(string name) + { + // Setup + TimeDateSettings setting = TimeDateSettings.Instance; + + // Act + PropertyInfo propertyInfo = setting?.GetType()?.GetProperty(name, BindingFlags.NonPublic | BindingFlags.Instance); + List<string> result = (List<string>)propertyInfo?.GetValue(setting); + + // Assert + Assert.AreEqual(0, result.Count); + } } } diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/QueryTests.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/QueryTests.cs index cab3e92c04..2eddc0f1c8 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/QueryTests.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/QueryTests.cs @@ -54,11 +54,11 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests [DataTestMethod] [DataRow("(time", 18)] - [DataRow("(date", 26)] - [DataRow("(year", 7)] - [DataRow("(now", 32)] - [DataRow("(current", 32)] - [DataRow("(", 32)] + [DataRow("(date", 28)] + [DataRow("(year", 8)] + [DataRow("(now", 34)] + [DataRow("(current", 34)] + [DataRow("(", 34)] [DataRow("(now::10:10:10", 1)] // Windows file time [DataRow("(current::10:10:10", 0)] public void CountWithPluginKeyword(string typedString, int expectedResultCount) @@ -140,6 +140,8 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests [DataRow("(week day", "Day of the week (Week day) -")] [DataRow("(cal week", "Week of the year (Calendar week, Week number) -")] [DataRow("(week num", "Week of the year (Calendar week, Week number) -")] + [DataRow("(days in mo", "Days in month -")] + [DataRow("(Leap y", "Leap year -")] public void CanFindFormatResult(string typedString, string expectedResult) { // Setup diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/StringParserTests.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/StringParserTests.cs index e9079307cf..e38cb2660a 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/StringParserTests.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/StringParserTests.cs @@ -41,10 +41,29 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests [DataRow("ums-10456", true, "", "")] // Value is UTC and can be different based on system [DataRow("ums+10456", true, "", "")] // Value is UTC and can be different based on system [DataRow("ft10456", true, "", "")] // Value is UTC and can be different based on system + [DataRow("oa-657434.99999999", true, "G", "1/1/0100 11:59:59 PM")] + [DataRow("oa2958465.99999999", true, "G", "12/31/9999 11:59:59 PM")] + [DataRow("oa-657435", false, "", "")] // Value to low + [DataRow("oa2958466", false, "", "")] // Value to large + [DataRow("exc1.99998843", true, "G", "1/1/1900 11:59:59 PM")] + [DataRow("exc59.99998843", true, "G", "2/28/1900 11:59:59 PM")] + [DataRow("exc61", true, "G", "3/1/1900 12:00:00 AM")] + [DataRow("exc62.99998843", true, "G", "3/2/1900 11:59:59 PM")] + [DataRow("exc2958465.99998843", true, "G", "12/31/9999 11:59:59 PM")] + [DataRow("exc0", false, "", "")] // Day 0 means in Excel 0/1/1900 and this is a fake date. + [DataRow("exc0.99998843", false, "", "")] // Day 0 means in Excel 0/1/1900 and this is a fake date. + [DataRow("exc60.99998843", false, "", "")] // Day 60 means in Excel 2/29/1900 and this is a fake date in Excel which we cannot support. + [DataRow("exc60", false, "", "")] // Day 60 means in Excel 2/29/1900 and this is a fake date in Excel which we cannot support. + [DataRow("exc-1", false, "", "")] // Value to low + [DataRow("exc2958466", false, "", "")] // Value to large + [DataRow("exf0.99998843", true, "G", "1/1/1904 11:59:59 PM")] + [DataRow("exf2957003.99998843", true, "G", "12/31/9999 11:59:59 PM")] + [DataRow("exf-0.5", false, "", "")] // Value to low + [DataRow("exf2957004", false, "", "")] // Value to large public void ConvertStringToDateTime(string typedString, bool expectedBool, string stringType, string expectedString) { // Act - bool boolResult = TimeAndDateHelper.ParseStringAsDateTime(in typedString, out DateTime result); + bool boolResult = TimeAndDateHelper.ParseStringAsDateTime(in typedString, out DateTime result, out string _); // Assert Assert.AreEqual(expectedBool, boolResult); diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/TimeAndDateHelperTests.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/TimeAndDateHelperTests.cs index 39d5a94043..6ae41d2b22 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/TimeAndDateHelperTests.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests/TimeAndDateHelperTests.cs @@ -13,6 +13,19 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests [TestClass] public class TimeAndDateHelperTests { + private CultureInfo originalCulture; + private CultureInfo originalUiCulture; + + [TestInitialize] + public void Setup() + { + // Set culture to 'en-us' + originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("en-us", false); + originalUiCulture = CultureInfo.CurrentUICulture; + CultureInfo.CurrentUICulture = new CultureInfo("en-us", false); + } + [DataTestMethod] [DataRow(-1, null)] // default setting [DataRow(0, CalendarWeekRule.FirstDay)] @@ -62,5 +75,103 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests Assert.AreEqual(valueExpected, result); } } + + [DataTestMethod] + [DataRow(0, "12/30/1899 12:00 PM", 0.5)] // OLE Automation date + [DataRow(1, "12/31/1898 12:00 PM", null)] // Excel based 1900: Date to low + [DataRow(1, "1/1/1900, 00:00 AM", 1.0)] // Excel based 1900 + [DataRow(2, "12/31/1898 12:00 PM", null)] // Excel based 1904: Date to low + [DataRow(2, "1/1/1904, 00:00 AM", 0.0)] // Excel based 1904 + public void ConvertToOADateFormat(int type, string date, double? valueExpected) + { + // Act + DateTime dt = DateTime.Parse(date, DateTimeFormatInfo.CurrentInfo); + + // Assert + if (valueExpected == null) + { + Assert.ThrowsException<ArgumentOutOfRangeException>(() => TimeAndDateHelper.ConvertToOleAutomationFormat(dt, (OADateFormats)type)); + } + else + { + var result = TimeAndDateHelper.ConvertToOleAutomationFormat(dt, (OADateFormats)type); + Assert.AreEqual(valueExpected, result); + } + } + + [DataTestMethod] + [DataRow("dow")] + [DataRow("\\DOW")] + [DataRow("wom")] + [DataRow("\\WOM")] + [DataRow("woy")] + [DataRow("\\WOY")] + [DataRow("eab")] + [DataRow("\\EAB")] + [DataRow("wft")] + [DataRow("\\WFT")] + [DataRow("uxt")] + [DataRow("\\UXT")] + [DataRow("ums")] + [DataRow("\\UMS")] + [DataRow("oad")] + [DataRow("\\OAD")] + [DataRow("exc")] + [DataRow("\\EXC")] + [DataRow("exf")] + [DataRow("\\EXF")] + [DataRow("My super Test String with \\EXC pattern.")] + public void CustomFormatIgnoreInvalidPattern(string format) + { + // Act + string result = TimeAndDateHelper.ConvertToCustomFormat(DateTime.Now, 0, 0, 1, "AD", format, CalendarWeekRule.FirstDay, DayOfWeek.Sunday); + + // Assert + Assert.AreEqual(format, result); + } + + [DataTestMethod] + [DataRow("DOW")] + [DataRow("DIM")] + [DataRow("WOM")] + [DataRow("WOY")] + [DataRow("EAB")] + [DataRow("WFT")] + [DataRow("UXT")] + [DataRow("UMS")] + [DataRow("OAD")] + [DataRow("EXC")] + [DataRow("EXF")] + public void CustomFormatReplacesValidPattern(string format) + { + // Act + string result = TimeAndDateHelper.ConvertToCustomFormat(DateTime.Now, 0, 0, 1, "AD", format, CalendarWeekRule.FirstDay, DayOfWeek.Sunday); + + // Assert + Assert.IsFalse(result.Contains(format, StringComparison.CurrentCulture)); + } + + [DataTestMethod] + [DataRow("01/01/0001", 1)] // First possible date + [DataRow("12/31/9999", 5)] // Last possible date + [DataRow("03/20/2025", 4)] + [DataRow("09/01/2025", 1)] // First day in month is first day of week + [DataRow("03/03/2025", 2)] // First monday is in second week + public void GetWeekOfMonth(string date, int week) + { + // Act + int result = TimeAndDateHelper.GetWeekOfMonth(DateTime.Parse(date, CultureInfo.GetCultureInfo("en-us")), DayOfWeek.Monday); + + // Assert + Assert.AreEqual(result, week); + } + + [TestCleanup] + public void CleanUp() + { + // Set culture to original value + CultureInfo.CurrentCulture = originalCulture; + CultureInfo.CurrentUICulture = originalUiCulture; + } } } diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/AvailableResult.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/AvailableResult.cs index b545c28e48..93d265df62 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/AvailableResult.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/AvailableResult.cs @@ -7,7 +7,7 @@ using System.Runtime.CompilerServices; namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components { - internal class AvailableResult + internal sealed class AvailableResult { /// <summary> /// Gets or sets the time/date value @@ -41,6 +41,7 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components ResultIconType.Time => $"Images\\time.{theme}.png", ResultIconType.Date => $"Images\\calendar.{theme}.png", ResultIconType.DateTime => $"Images\\timeDate.{theme}.png", + ResultIconType.Error => $"Images\\Warning.{theme}.png", _ => string.Empty, }; } @@ -51,5 +52,6 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components Time, Date, DateTime, + Error, } } diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/AvailableResultsList.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/AvailableResultsList.cs index f011d3b8c8..46dcd127df 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/AvailableResultsList.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/AvailableResultsList.cs @@ -6,7 +6,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; - +using System.Text.RegularExpressions; using Microsoft.PowerToys.Run.Plugin.TimeDate.Properties; namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components @@ -72,6 +72,86 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components string era = DateTimeFormatInfo.CurrentInfo.GetEraName(calendar.GetEra(dateTimeNow)); string eraShort = DateTimeFormatInfo.CurrentInfo.GetAbbreviatedEraName(calendar.GetEra(dateTimeNow)); + // Custom formats + foreach (string f in TimeDateSettings.Instance.CustomFormats) + { + string[] formatParts = f.Split("=", 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + string formatSyntax = formatParts.Length == 2 ? formatParts[1] : string.Empty; + string searchTags = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagCustom"); + DateTime dtObject = dateTimeNow; + + // If Length = 0 then empty string. + if (formatParts.Length >= 1) + { + try + { + // Verify and check input and update search tags + if (formatParts.Length == 1) + { + throw new FormatException("Format syntax part after equal sign is missing."); + } + + bool containsCustomSyntax = TimeAndDateHelper.StringContainsCustomFormatSyntax(formatSyntax); + if (formatSyntax.StartsWith("UTC:", StringComparison.InvariantCulture)) + { + searchTags = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagCustomUtc"); + dtObject = dateTimeNowUtc; + } + + // Get formated date + var value = TimeAndDateHelper.ConvertToCustomFormat(dtObject, unixTimestamp, unixTimestampMilliseconds, weekOfYear, eraShort, Regex.Replace(formatSyntax, "^UTC:", string.Empty), firstWeekRule, firstDayOfTheWeek); + try + { + value = dtObject.ToString(value, CultureInfo.CurrentCulture); + } + catch + { + if (!containsCustomSyntax) + { + throw; + } + else + { + // Do not fail as we have custom format syntax. Instead fix backslashes. + value = Regex.Replace(value, @"(?<!\\)\\", string.Empty).Replace("\\\\", "\\"); + } + } + + // Add result + results.Add(new AvailableResult() + { + Value = value, + Label = formatParts[0], + AlternativeSearchTag = searchTags, + IconType = ResultIconType.DateTime, + }); + } + catch (ArgumentOutOfRangeException e) + { + Wox.Plugin.Logger.Log.Exception($"Failed to convert into custom format {formatParts[0]}: {formatSyntax}", e, typeof(AvailableResultsList)); + results.Add(new AvailableResult() + { + Value = Resources.Microsoft_plugin_timedate_ErrorConvertCustomFormat + " " + e.Message, + Label = formatParts[0], + AlternativeSearchTag = searchTags, + IconType = ResultIconType.Error, + }); + } + catch (Exception e) + { + Wox.Plugin.Logger.Log.Exception($"Failed to convert into custom format {formatParts[0]}: {formatSyntax}", e, typeof(AvailableResultsList)); + results.Add(new AvailableResult() + { + Value = Resources.Microsoft_plugin_timedate_InvalidCustomFormat + " " + formatSyntax, + Label = formatParts[0], + AlternativeSearchTag = searchTags, + IconType = ResultIconType.Error, + }); + } + } + } + + // Predefined formats results.AddRange(new[] { new AvailableResult() @@ -152,6 +232,13 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components IconType = ResultIconType.Date, }, new AvailableResult() + { + Value = DateTime.DaysInMonth(dateTimeNow.Year, dateTimeNow.Month).ToString(CultureInfo.CurrentCulture), + Label = Resources.Microsoft_plugin_timedate_DaysInMonth, + AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagDate"), + IconType = ResultIconType.Date, + }, + new AvailableResult() { Value = dateTimeNow.DayOfYear.ToString(CultureInfo.CurrentCulture), Label = Resources.Microsoft_plugin_timedate_DayOfYear, @@ -201,6 +288,13 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components IconType = ResultIconType.Date, }, new AvailableResult() + { + Value = DateTime.IsLeapYear(dateTimeNow.Year) ? Resources.Microsoft_plugin_timedate_LeapYear : Resources.Microsoft_plugin_timedate_NoLeapYear, + Label = Resources.Microsoft_plugin_timedate_LeapYear, + AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagDate"), + IconType = ResultIconType.Date, + }, + new AvailableResult() { Value = era, Label = Resources.Microsoft_plugin_timedate_Era, @@ -221,13 +315,31 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagDate"), IconType = ResultIconType.Date, }, - new AvailableResult() + }); + + try + { + results.Add(new AvailableResult() { Value = dateTimeNow.ToFileTime().ToString(CultureInfo.CurrentCulture), Label = Resources.Microsoft_plugin_timedate_WindowsFileTime, AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagFormat"), IconType = ResultIconType.DateTime, - }, + }); + } + catch + { + results.Add(new AvailableResult() + { + Value = Resources.Microsoft_plugin_timedate_ErrorConvertWft, + Label = Resources.Microsoft_plugin_timedate_WindowsFileTime, + AlternativeSearchTag = ResultHelper.SelectStringFromResources(isSystemDateTime, "Microsoft_plugin_timedate_SearchTagFormat"), + IconType = ResultIconType.Error, + }); + } + + results.AddRange(new[] + { new AvailableResult() { Value = dateTimeNowUtc.ToString("u"), diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/ResultHelper.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/ResultHelper.cs index 7ea0ed798c..055131e2a8 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/ResultHelper.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/ResultHelper.cs @@ -44,7 +44,7 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components /// Copy the given text to the clipboard /// </summary> /// <param name="text">The text to copy to the clipboard</param> - /// <returns><see langword="true"/>The text successful copy to the clipboard, otherwise <see langword="false"/></returns> + /// <returns><see langword="true"/>The text successful copy to the clipboard; otherwise, <see langword="false"/></returns> /// <remarks>Code copied from TimeZone plugin</remarks> internal static bool CopyToClipBoard(in string text) { @@ -83,10 +83,10 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components /// Gets a result with an error message that only numbers can't be parsed /// </summary> /// <returns>Element of type <see cref="Result"/>.</returns> - internal static Result CreateNumberErrorResult(string theme) => new Result() + internal static Result CreateNumberErrorResult(string theme, string title, string subtitle) => new Result() { - Title = Resources.Microsoft_plugin_timedate_ErrorResultTitle, - SubTitle = Resources.Microsoft_plugin_timedate_ErrorResultSubTitle, + Title = title, + SubTitle = subtitle, ToolTipData = new ToolTipData(Resources.Microsoft_plugin_timedate_ErrorResultTitle, Resources.Microsoft_plugin_timedate_ErrorResultSubTitle), IcoPath = $"Images\\Warning.{theme}.png", }; diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/SearchController.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/SearchController.cs index 1c9a2a7f6a..20113456a6 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/SearchController.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/SearchController.cs @@ -40,9 +40,12 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components List<AvailableResult> availableFormats = new List<AvailableResult>(); List<Result> results = new List<Result>(); bool isKeywordSearch = !string.IsNullOrEmpty(query.ActionKeyword); - bool isEmptySearchInput = string.IsNullOrEmpty(query.Search); + bool isEmptySearchInput = string.IsNullOrWhiteSpace(query.Search); string searchTerm = query.Search; + // Last input parsing error + string lastInputParsingErrorReason = string.Empty; + // Conjunction search without keyword => return no results // (This improves the results on global queries.) if (!isKeywordSearch && _conjunctionList.Any(x => x.Equals(searchTerm, StringComparison.CurrentCultureIgnoreCase))) @@ -61,13 +64,13 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components { // Search for specified format with specified time/date value var userInput = searchTerm.Split(InputDelimiter); - if (TimeAndDateHelper.ParseStringAsDateTime(userInput[1], out DateTime timestamp)) + if (TimeAndDateHelper.ParseStringAsDateTime(userInput[1], out DateTime timestamp, out lastInputParsingErrorReason)) { availableFormats.AddRange(AvailableResultsList.GetList(isKeywordSearch, null, null, timestamp)); searchTerm = userInput[0]; } } - else if (TimeAndDateHelper.ParseStringAsDateTime(searchTerm, out DateTime timestamp)) + else if (TimeAndDateHelper.ParseStringAsDateTime(searchTerm, out DateTime timestamp, out lastInputParsingErrorReason)) { // Return all formats for specified time/date value availableFormats.AddRange(AvailableResultsList.GetList(isKeywordSearch, null, null, timestamp)); @@ -122,12 +125,15 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components } // If search term is only a number that can't be parsed return an error message - if (!isEmptySearchInput && results.Count == 0 && Regex.IsMatch(searchTerm, @"\w+\d+.*$") && !searchTerm.Any(char.IsWhiteSpace) && (TimeAndDateHelper.IsSpecialInputParsing(searchTerm) || !Regex.IsMatch(searchTerm, @"\d+[\.:/]\d+"))) + if (!isEmptySearchInput && results.Count == 0 && Regex.IsMatch(searchTerm, @"\w+[+-]?\d+.*$") && !searchTerm.Any(char.IsWhiteSpace) && (TimeAndDateHelper.IsSpecialInputParsing(searchTerm) || !Regex.IsMatch(searchTerm, @"\d+[\.:/]\d+"))) { - // Without plugin key word show only if message is not hidden by setting + string title = !string.IsNullOrEmpty(lastInputParsingErrorReason) ? Resources.Microsoft_plugin_timedate_ErrorResultValue : Resources.Microsoft_plugin_timedate_ErrorResultTitle; + string message = !string.IsNullOrEmpty(lastInputParsingErrorReason) ? lastInputParsingErrorReason : Resources.Microsoft_plugin_timedate_ErrorResultSubTitle; + + // Without plugin key word show only if not hidden by setting if (isKeywordSearch || !TimeDateSettings.Instance.HideNumberMessageOnGlobalQuery) { - results.Add(ResultHelper.CreateNumberErrorResult(iconTheme)); + results.Add(ResultHelper.CreateNumberErrorResult(iconTheme, title, message)); } } diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/TimeAndDateHelper.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/TimeAndDateHelper.cs index 72379c14cb..75ffed1958 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/TimeAndDateHelper.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/TimeAndDateHelper.cs @@ -5,7 +5,9 @@ using System; using System.Globalization; using System.Runtime.CompilerServices; +using System.Text; using System.Text.RegularExpressions; +using Microsoft.PowerToys.Run.Plugin.TimeDate.Properties; [assembly: InternalsVisibleTo("Microsoft.PowerToys.Run.Plugin.TimeDate.UnitTests")] @@ -13,6 +15,33 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components { internal static class TimeAndDateHelper { + private static readonly Regex _regexSpecialInputFormats = new Regex(@"^.*(::)?(u|ums|ft|oa|exc|exf)[+-]\d"); + private static readonly Regex _regexCustomDateTimeFormats = new Regex(@"(?<!\\)(DOW|DIM|WOM|WOY|EAB|WFT|UXT|UMS|OAD|EXC|EXF)"); + private static readonly Regex _regexCustomDateTimeDow = new Regex(@"(?<!\\)DOW"); + private static readonly Regex _regexCustomDateTimeDim = new Regex(@"(?<!\\)DIM"); + private static readonly Regex _regexCustomDateTimeWom = new Regex(@"(?<!\\)WOM"); + private static readonly Regex _regexCustomDateTimeWoy = new Regex(@"(?<!\\)WOY"); + private static readonly Regex _regexCustomDateTimeEab = new Regex(@"(?<!\\)EAB"); + private static readonly Regex _regexCustomDateTimeWft = new Regex(@"(?<!\\)WFT"); + private static readonly Regex _regexCustomDateTimeUxt = new Regex(@"(?<!\\)UXT"); + private static readonly Regex _regexCustomDateTimeUms = new Regex(@"(?<!\\)UMS"); + private static readonly Regex _regexCustomDateTimeOad = new Regex(@"(?<!\\)OAD"); + private static readonly Regex _regexCustomDateTimeExc = new Regex(@"(?<!\\)EXC"); + private static readonly Regex _regexCustomDateTimeExf = new Regex(@"(?<!\\)EXF"); + + private const long UnixTimeSecondsMin = -62135596800; + private const long UnixTimeSecondsMax = 253402300799; + private const long UnixTimeMillisecondsMin = -62135596800000; + private const long UnixTimeMillisecondsMax = 253402300799999; + private const long WindowsFileTimeMin = 0; + private const long WindowsFileTimeMax = 2650467707991000000; + private const double OADateMin = -657434.99999999; + private const double OADateMax = 2958465.99999999; + private const double Excel1900DateMin = 1; + private const double Excel1900DateMax = 2958465.99998843; + private const double Excel1904DateMin = 0; + private const double Excel1904DateMax = 2957003.99998843; + /// <summary> /// Get the format for the time string /// </summary> @@ -56,18 +85,25 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components /// Returns the number week in the month (Used code from 'David Morton' from <see href="https://social.msdn.microsoft.com/Forums/vstudio/bf504bba-85cb-492d-a8f7-4ccabdf882cb/get-week-number-for-month"/>) /// </summary> /// <param name="date">date</param> + /// <param name="formatSettingFirstDayOfWeek">Setting for the first day in the week.</param> /// <returns>Number of week in the month</returns> internal static int GetWeekOfMonth(DateTime date, DayOfWeek formatSettingFirstDayOfWeek) { - DateTime beginningOfMonth = new DateTime(date.Year, date.Month, 1); - int adjustment = 1; // We count from 1 to 7 and not from 0 to 6 + int weekCount = 1; - while (date.Date.AddDays(1).DayOfWeek != formatSettingFirstDayOfWeek) + for (int i = 1; i <= date.Day; i++) { - date = date.AddDays(1); + DateTime d = new(date.Year, date.Month, i); + + // Count week number +1 if day is the first day of a week and not day 1 of the month. + // (If we count on day one of a month we would start the month with week number 2.) + if (i > 1 && d.DayOfWeek == formatSettingFirstDayOfWeek) + { + weekCount += 1; + } } - return (int)Math.Truncate((double)date.Subtract(beginningOfMonth).TotalDays / 7f) + adjustment; + return weekCount; } /// <summary> @@ -83,40 +119,170 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components return ((date.DayOfWeek + daysInWeek - formatSettingFirstDayOfWeek) % daysInWeek) + adjustment; } + internal static double ConvertToOleAutomationFormat(DateTime date, OADateFormats type) + { + double v = date.ToOADate(); + + switch (type) + { + case OADateFormats.Excel1904: + // Excel with base 1904: Adjust by -1462 + v -= 1462; + + // Date starts at 1/1/1904 = 0 + if (Math.Truncate(v) < 0) + { + throw new ArgumentOutOfRangeException("Not a valid Excel date.", innerException: null); + } + + return v; + case OADateFormats.Excel1900: + // Excel with base 1900: Adjust by -1 if v < 61 + v = v < 61 ? v - 1 : v; + + // Date starts at 1/1/1900 = 1 + if (Math.Truncate(v) < 1) + { + throw new ArgumentOutOfRangeException("Not a valid Excel date.", innerException: null); + } + + return v; + default: + // OLE Automation date: Return as is. + return v; + } + } + /// <summary> /// Convert input string to a <see cref="DateTime"/> object in local time /// </summary> /// <param name="input">String with date/time</param> /// <param name="timestamp">The new <see cref="DateTime"/> object</param> - /// <returns>True on success, otherwise false</returns> - internal static bool ParseStringAsDateTime(in string input, out DateTime timestamp) + /// <param name="inputParsingErrorMsg">Error message shown to the user</param> + /// <returns>True on success; otherwise, false</returns> + internal static bool ParseStringAsDateTime(in string input, out DateTime timestamp, out string inputParsingErrorMsg) { + inputParsingErrorMsg = string.Empty; + CompositeFormat errorMessage = CompositeFormat.Parse(Resources.Microsoft_plugin_timedate_InvalidInput_SupportedRange); + if (DateTime.TryParse(input, out timestamp)) { // Known date/time format return true; } - else if (Regex.IsMatch(input, @"^u[\+-]?\d{1,10}$") && long.TryParse(input.TrimStart('u'), out long secondsU)) + else if (Regex.IsMatch(input, @"^u[\+-]?\d+$")) { // Unix time stamp // We use long instead of int, because int is too small after 03:14:07 UTC 2038-01-19 + var canParse = long.TryParse(input.TrimStart('u'), out var secondsU); + + // Value has to be in the range from -62135596800 to 253402300799 + if (!canParse || secondsU < UnixTimeSecondsMin || secondsU > UnixTimeSecondsMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_Unix, UnixTimeSecondsMin, UnixTimeSecondsMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + return false; + } + timestamp = DateTimeOffset.FromUnixTimeSeconds(secondsU).LocalDateTime; return true; } - else if (Regex.IsMatch(input, @"^ums[\+-]?\d{1,13}$") && long.TryParse(input.TrimStart("ums".ToCharArray()), out long millisecondsUms)) + else if (Regex.IsMatch(input, @"^ums[\+-]?\d+$")) { // Unix time stamp in milliseconds // We use long instead of int because int is too small after 03:14:07 UTC 2038-01-19 + var canParse = long.TryParse(input.TrimStart("ums".ToCharArray()), out var millisecondsUms); + + // Value has to be in the range from -62135596800000 to 253402300799999 + if (!canParse || millisecondsUms < UnixTimeMillisecondsMin || millisecondsUms > UnixTimeMillisecondsMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_Unix_Milliseconds, UnixTimeMillisecondsMin, UnixTimeMillisecondsMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + return false; + } + timestamp = DateTimeOffset.FromUnixTimeMilliseconds(millisecondsUms).LocalDateTime; return true; } - else if (Regex.IsMatch(input, @"^ft\d+$") && long.TryParse(input.TrimStart("ft".ToCharArray()), out long secondsFt)) + else if (Regex.IsMatch(input, @"^ft\d+$")) { + var canParse = long.TryParse(input.TrimStart("ft".ToCharArray()), out var secondsFt); + // Windows file time + // Value has to be in the range from 0 to 2650467707991000000 + if (!canParse || secondsFt < WindowsFileTimeMin || secondsFt > WindowsFileTimeMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_WindowsFileTime, WindowsFileTimeMin, WindowsFileTimeMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + return false; + } + // DateTime.FromFileTime returns as local time. timestamp = DateTime.FromFileTime(secondsFt); return true; } + else if (Regex.IsMatch(input, @"^oa[+-]?\d+[,.0-9]*$")) + { + var canParse = double.TryParse(input.TrimStart("oa".ToCharArray()), out var oADate); + + // OLE Automation date + // Input has to be in the range from -657434.99999999 to 2958465.99999999 + // DateTime.FromOADate returns as local time. + if (!canParse || oADate < OADateMin || oADate > OADateMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_OADate, OADateMin, OADateMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + return false; + } + + timestamp = DateTime.FromOADate(oADate); + return true; + } + else if (Regex.IsMatch(input, @"^exc[+-]?\d+[,.0-9]*$")) + { + var canParse = double.TryParse(input.TrimStart("exc".ToCharArray()), out var excDate); + + // Excel's 1900 date value + // Input has to be in the range from 1 (0 = Fake date) to 2958465.99998843 and not 60 whole number + // Because of a bug in Excel and the way it behaves before 3/1/1900 we have to adjust all inputs lower than 61 for +1 + // DateTime.FromOADate returns as local time. + if (!canParse || excDate < 0 || excDate > Excel1900DateMax) + { + // For the if itself we use 0 as min value that we can show a special message if input is 0. + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_Excel1900, Excel1900DateMin, Excel1900DateMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + return false; + } + + if (Math.Truncate(excDate) == 0 || Math.Truncate(excDate) == 60) + { + inputParsingErrorMsg = Resources.Microsoft_plugin_timedate_InvalidInput_FakeExcel1900; + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + return false; + } + + excDate = excDate <= 60 ? excDate + 1 : excDate; + timestamp = DateTime.FromOADate(excDate); + return true; + } + else if (Regex.IsMatch(input, @"^exf[+-]?\d+[,.0-9]*$")) + { + var canParse = double.TryParse(input.TrimStart("exf".ToCharArray()), out var exfDate); + + // Excel's 1904 date value + // Input has to be in the range from 0 to 2957003.99998843 + // Because Excel uses 01/01/1904 as base we need to adjust for +1462 + // DateTime.FromOADate returns as local time. + if (!canParse || exfDate < Excel1904DateMin || exfDate > Excel1904DateMax) + { + inputParsingErrorMsg = string.Format(CultureInfo.CurrentCulture, errorMessage, Resources.Microsoft_plugin_timedate_Excel1904, Excel1904DateMin, Excel1904DateMax); + timestamp = new DateTime(1, 1, 1, 1, 1, 1); + return false; + } + + timestamp = DateTime.FromOADate(exfDate + 1462); + return true; + } else { timestamp = new DateTime(1, 1, 1, 1, 1, 1); @@ -125,13 +291,85 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components } /// <summary> - /// Test if input is special parsing for Unix time, Unix time in milliseconds or File time. + /// Test if input is special parsing for Unix time, Unix time in milliseconds, file time, ... /// </summary> /// <param name="input">String with date/time</param> - /// <returns>True if yes, otherwise false</returns> + /// <returns>True if yes; otherwise, false</returns> internal static bool IsSpecialInputParsing(string input) { - return Regex.IsMatch(input, @"^.*(u|ums|ft)\d"); + return _regexSpecialInputFormats.IsMatch(input); + } + + /// <summary> + /// Converts a DateTime object based on the format string + /// </summary> + /// <param name="date">Date/time object.</param> + /// <param name="unix">Value for replacing "Unix Time Stamp".</param> + /// <param name="unixMilliseconds">Value for replacing "Unix Time Stamp in milliseconds".</param> + /// <param name="calWeek">Value for relacing calendar week.</param> + /// <param name="eraShortFormat">Era abbreviation.</param> + /// <param name="format">Format definition.</param> + /// <returns>Formated date/time string.</returns> + internal static string ConvertToCustomFormat(DateTime date, long unix, long unixMilliseconds, int calWeek, string eraShortFormat, string format, CalendarWeekRule firstWeekRule, DayOfWeek firstDayOfTheWeek) + { + string result = format; + + // DOW: Number of day in week + result = _regexCustomDateTimeDow.Replace(result, GetNumberOfDayInWeek(date, firstDayOfTheWeek).ToString(CultureInfo.CurrentCulture)); + + // DIM: Days in Month + result = _regexCustomDateTimeDim.Replace(result, DateTime.DaysInMonth(date.Year, date.Month).ToString(CultureInfo.CurrentCulture)); + + // WOM: Week of Month + result = _regexCustomDateTimeWom.Replace(result, GetWeekOfMonth(date, firstDayOfTheWeek).ToString(CultureInfo.CurrentCulture)); + + // WOY: Week of Year + result = _regexCustomDateTimeWoy.Replace(result, calWeek.ToString(CultureInfo.CurrentCulture)); + + // EAB: Era abbreviation + result = _regexCustomDateTimeEab.Replace(result, eraShortFormat); + + // WFT: Week of Month + if (_regexCustomDateTimeWft.IsMatch(result)) + { + // Special handling as very early dates can't convert. + result = _regexCustomDateTimeWft.Replace(result, date.ToFileTime().ToString(CultureInfo.CurrentCulture)); + } + + // UXT: Unix time stamp + result = _regexCustomDateTimeUxt.Replace(result, unix.ToString(CultureInfo.CurrentCulture)); + + // UMS: Unix time stamp milli seconds + result = _regexCustomDateTimeUms.Replace(result, unixMilliseconds.ToString(CultureInfo.CurrentCulture)); + + // OAD: OLE Automation date + result = _regexCustomDateTimeOad.Replace(result, ConvertToOleAutomationFormat(date, OADateFormats.OLEAutomation).ToString(CultureInfo.CurrentCulture)); + + // EXC: Excel date value with base 1900 + if (_regexCustomDateTimeExc.IsMatch(result)) + { + // Special handling as very early dates can't convert. + result = _regexCustomDateTimeExc.Replace(result, ConvertToOleAutomationFormat(date, OADateFormats.Excel1900).ToString(CultureInfo.CurrentCulture)); + } + + // EXF: Excel date value with base 1904 + if (_regexCustomDateTimeExf.IsMatch(result)) + { + // Special handling as very early dates can't convert. + result = _regexCustomDateTimeExf.Replace(result, ConvertToOleAutomationFormat(date, OADateFormats.Excel1904).ToString(CultureInfo.CurrentCulture)); + } + + return result; + } + + /// <summary> + /// Test a string for our custom date and time format syntax + /// </summary> + /// <param name="str">String to test.</param> + /// <returns>True if yes and otherwise false</returns> + internal static bool StringContainsCustomFormatSyntax(string str) + { + return _regexCustomDateTimeFormats.IsMatch(str); } /// <summary> @@ -190,4 +428,14 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components Date, DateTime, } + + /// <summary> + /// Different versions of Date formats based on OLE Automation date + /// </summary> + internal enum OADateFormats + { + OLEAutomation, + Excel1900, + Excel1904, + } } diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/TimeDateSettings.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/TimeDateSettings.cs index 23053885cb..2dada3c974 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/TimeDateSettings.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Components/TimeDateSettings.cs @@ -61,6 +61,11 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components /// </summary> internal bool HideNumberMessageOnGlobalQuery { get; private set; } + /// <summary> + /// Gets a value containing the custom format definitions + /// </summary> + internal List<string> CustomFormats { get; private set; } + /// <summary> /// Initializes a new instance of the <see cref="TimeDateSettings"/> class. /// Private constructor to make sure there is never more than one instance of this class @@ -100,29 +105,6 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components { var optionList = new List<PluginAdditionalOption> { - new PluginAdditionalOption() - { - Key = nameof(CalendarFirstWeekRule), - DisplayLabel = Resources.Microsoft_plugin_timedate_SettingFirstWeekRule, - DisplayDescription = Resources.Microsoft_plugin_timedate_SettingFirstWeekRule_Description, - PluginOptionType = PluginAdditionalOption.AdditionalOptionType.Combobox, - ComboBoxItems = new List<KeyValuePair<string, string>> - { - new KeyValuePair<string, string>(Resources.Microsoft_plugin_timedate_Setting_UseSystemSetting, "-1"), - new KeyValuePair<string, string>(Resources.Microsoft_plugin_timedate_SettingFirstWeekRule_FirstDay, "0"), - new KeyValuePair<string, string>(Resources.Microsoft_plugin_timedate_SettingFirstWeekRule_FirstFullWeek, "1"), - new KeyValuePair<string, string>(Resources.Microsoft_plugin_timedate_SettingFirstWeekRule_FirstFourDayWeek, "2"), - }, - ComboBoxValue = -1, - }, - new PluginAdditionalOption() - { - Key = nameof(FirstDayOfWeek), - DisplayLabel = Resources.Microsoft_plugin_timedate_SettingFirstDayOfWeek, - PluginOptionType = PluginAdditionalOption.AdditionalOptionType.Combobox, - ComboBoxItems = GetSortedListForWeekDaySetting(), - ComboBoxValue = -1, - }, new PluginAdditionalOption() { Key = nameof(OnlyDateTimeNowGlobal), @@ -150,6 +132,38 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components DisplayLabel = Resources.Microsoft_plugin_timedate_SettingHideNumberMessageOnGlobalQuery, Value = false, }, + new PluginAdditionalOption() + { + Key = nameof(CalendarFirstWeekRule), + DisplayLabel = Resources.Microsoft_plugin_timedate_SettingFirstWeekRule, + DisplayDescription = Resources.Microsoft_plugin_timedate_SettingFirstWeekRule_Description, + PluginOptionType = PluginAdditionalOption.AdditionalOptionType.Combobox, + ComboBoxItems = new List<KeyValuePair<string, string>> + { + new KeyValuePair<string, string>(Resources.Microsoft_plugin_timedate_Setting_UseSystemSetting, "-1"), + new KeyValuePair<string, string>(Resources.Microsoft_plugin_timedate_SettingFirstWeekRule_FirstDay, "0"), + new KeyValuePair<string, string>(Resources.Microsoft_plugin_timedate_SettingFirstWeekRule_FirstFullWeek, "1"), + new KeyValuePair<string, string>(Resources.Microsoft_plugin_timedate_SettingFirstWeekRule_FirstFourDayWeek, "2"), + }, + ComboBoxValue = -1, + }, + new PluginAdditionalOption() + { + Key = nameof(FirstDayOfWeek), + DisplayLabel = Resources.Microsoft_plugin_timedate_SettingFirstDayOfWeek, + PluginOptionType = PluginAdditionalOption.AdditionalOptionType.Combobox, + ComboBoxItems = GetSortedListForWeekDaySetting(), + ComboBoxValue = -1, + }, + new PluginAdditionalOption() + { + Key = nameof(CustomFormats), + PluginOptionType = PluginAdditionalOption.AdditionalOptionType.MultilineTextbox, + DisplayLabel = Resources.Microsoft_plugin_timedate_Setting_CustomFormats, + DisplayDescription = string.Format(CultureInfo.CurrentCulture, Resources.Microsoft_plugin_timedate_Setting_CustomFormatsDescription.ToString(), "DOW", "DIM", "WOM", "WOY", "EAB", "WFT", "UXT", "UMS", "OAD", "EXC", "EXF", "UTC:"), + PlaceholderText = "MyFormat=dd-MMM-yyyy\rMySecondFormat=dddd (Da\\y nu\\mber: DOW)\rMyUtcFormat=UTC:hh:mm:ss", + TextValue = string.Empty, + }, }; return optionList; @@ -172,6 +186,7 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components TimeWithSeconds = GetSettingOrDefault(settings, nameof(TimeWithSeconds)); DateWithWeekday = GetSettingOrDefault(settings, nameof(DateWithWeekday)); HideNumberMessageOnGlobalQuery = GetSettingOrDefault(settings, nameof(HideNumberMessageOnGlobalQuery)); + CustomFormats = GetMultilineTextSettingOrDefault(settings, nameof(CustomFormats)); } /// <summary> @@ -204,6 +219,21 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Components return option?.ComboBoxValue ?? GetAdditionalOptions().First(x => x.Key == name).ComboBoxValue; } + /// <summary> + /// Return the combobox value of the given settings list with the given name. + /// </summary> + /// <param name="settings">The object that contain all settings.</param> + /// <param name="name">The name of the setting.</param> + /// <returns>A settings value.</returns> + private static List<string> GetMultilineTextSettingOrDefault(PowerLauncherPluginSettings settings, string name) + { + var option = settings?.AdditionalOptions?.FirstOrDefault(x => x.Key == name); + + // If a setting isn't available, we use the value defined in the method GetAdditionalOptions() as fallback. + // We can use First() instead of FirstOrDefault() because the values must exist. Otherwise, we made a mistake when defining the settings. + return option?.TextValueAsMultilineList ?? GetAdditionalOptions().First(x => x.Key == name).TextValueAsMultilineList; + } + /// <summary> /// Returns a sorted list of values for the combo box of 'first day of week' setting. /// The list is sorted based on the current system culture setting. diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Microsoft.PowerToys.Run.Plugin.TimeDate.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Microsoft.PowerToys.Run.Plugin.TimeDate.csproj index 4236156764..42fb8dc114 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Microsoft.PowerToys.Run.Plugin.TimeDate.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Microsoft.PowerToys.Run.Plugin.TimeDate.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <OutputType>Library</OutputType> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\TimeDate\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\TimeDate\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.Designer.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.Designer.cs index 1ecd40721d..b3016d9821 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.Designer.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.Designer.cs @@ -150,6 +150,15 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } } + /// <summary> + /// Looks up a localized string similar to Days in month. + /// </summary> + internal static string Microsoft_plugin_timedate_DaysInMonth { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_DaysInMonth", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Era. /// </summary> @@ -169,7 +178,25 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } /// <summary> - /// Looks up a localized string similar to Valid prefixes: 'u' for Unix Timestamp, 'ums' for Unix Timestamp in milliseconds, 'ft' for Windows file time. + /// Looks up a localized string similar to Failed to convert into custom format:. + /// </summary> + internal static string Microsoft_plugin_timedate_ErrorConvertCustomFormat { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_ErrorConvertCustomFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Not a valid Windows file time. + /// </summary> + internal static string Microsoft_plugin_timedate_ErrorConvertWft { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_ErrorConvertWft", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Valid prefixes: 'u' for Unix Timestamp, 'ums' for Unix Timestamp in milliseconds, 'ft' for Windows file time, 'oa' for OLE Automation date, 'exc' for Excel's 1900 date value, 'exf' for Excel's 1904 date value. /// </summary> internal static string Microsoft_plugin_timedate_ErrorResultSubTitle { get { @@ -178,7 +205,7 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } /// <summary> - /// Looks up a localized string similar to Error: Invalid number input. + /// Looks up a localized string similar to Error: Invalid input. /// </summary> internal static string Microsoft_plugin_timedate_ErrorResultTitle { get { @@ -186,6 +213,33 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } } + /// <summary> + /// Looks up a localized string similar to Error: Invalid number. + /// </summary> + internal static string Microsoft_plugin_timedate_ErrorResultValue { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_ErrorResultValue", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Excel's 1900 date value. + /// </summary> + internal static string Microsoft_plugin_timedate_Excel1900 { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_Excel1900", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Excel's 1904 date value. + /// </summary> + internal static string Microsoft_plugin_timedate_Excel1904 { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_Excel1904", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Date and time in filename-compatible format. /// </summary> @@ -204,6 +258,33 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } } + /// <summary> + /// Looks up a localized string similar to Invalid custom format:. + /// </summary> + internal static string Microsoft_plugin_timedate_InvalidCustomFormat { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_InvalidCustomFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Cannot parse the input as Excel's 1900 date value because it is a fake date. (In Excel 0 stands for 0/1/1900 and this date doesn't exist. And 60 stands for 2/29/1900 and this date only exists in Excel for compatibility with Lotus 123.). + /// </summary> + internal static string Microsoft_plugin_timedate_InvalidInput_FakeExcel1900 { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_InvalidInput_FakeExcel1900", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Your input for {0} is outside the range from {1} to {2}.. + /// </summary> + internal static string Microsoft_plugin_timedate_InvalidInput_SupportedRange { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_InvalidInput_SupportedRange", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to ISO 8601. /// </summary> @@ -240,6 +321,15 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } } + /// <summary> + /// Looks up a localized string similar to Leap year. + /// </summary> + internal static string Microsoft_plugin_timedate_LeapYear { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_LeapYear", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Millisecond. /// </summary> @@ -285,6 +375,15 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } } + /// <summary> + /// Looks up a localized string similar to Not a leap year. + /// </summary> + internal static string Microsoft_plugin_timedate_NoLeapYear { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_NoLeapYear", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Now. /// </summary> @@ -303,6 +402,15 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } } + /// <summary> + /// Looks up a localized string similar to OLE Automation Date. + /// </summary> + internal static string Microsoft_plugin_timedate_OADate { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_OADate", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Provides time and date values for the system time or a custom time stamp (e.g.'{0}', '{1}', '{2}', '{3}'). /// </summary> @@ -358,7 +466,7 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } /// <summary> - /// Looks up a localized string similar to for; and; nor; but; or; so. + /// Looks up a localized string similar to "for; and; nor; but; or; so". /// </summary> internal static string Microsoft_plugin_timedate_Search_ConjunctionList { get { @@ -366,6 +474,42 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } } + /// <summary> + /// Looks up a localized string similar to Date and time; Time and Date; Custom format. + /// </summary> + internal static string Microsoft_plugin_timedate_SearchTagCustom { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagCustom", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Current date and time; Current time and date; Now; Custom format. + /// </summary> + internal static string Microsoft_plugin_timedate_SearchTagCustomNow { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagCustomNow", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Date and time UTC; Time UTC and Date; Custom UTC format. + /// </summary> + internal static string Microsoft_plugin_timedate_SearchTagCustomUtc { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagCustomUtc", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Current date and time UTC; Current time UTC and date; Now UTC; Custom UTC format. + /// </summary> + internal static string Microsoft_plugin_timedate_SearchTagCustomUtcNow { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagCustomUtcNow", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Date. /// </summary> @@ -447,6 +591,24 @@ namespace Microsoft.PowerToys.Run.Plugin.TimeDate.Properties { } } + /// <summary> + /// Looks up a localized string similar to Custom formats. + /// </summary> + internal static string Microsoft_plugin_timedate_Setting_CustomFormats { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_Setting_CustomFormats", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Use date and time string format syntax and {0} (Day of Week), {1} (Days in Month), {2} (Week of Month), {3} (Week of the year), {4} (Era abbreviation), {5} (Windows File Time), {6} (Unix Time), {7} (Unix Time in milliseconds), {8} (OLE Automation date), {9} (Excel's 1900 based date value), {10} (Excel's 1904 based date value). If the format starts with {11}, then Universal Time (UTC) is used. (Use a backslash to escape format sequences and the backslash character as text.). + /// </summary> + internal static string Microsoft_plugin_timedate_Setting_CustomFormatsDescription { + get { + return ResourceManager.GetString("Microsoft_plugin_timedate_Setting_CustomFormatsDescription", resourceCulture); + } + } + /// <summary> /// Looks up a localized string similar to Use system setting. /// </summary> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx index de7f2a0be5..84d81c1b96 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.TimeDate/Properties/Resources.resx @@ -156,10 +156,13 @@ <value>Era abbreviation</value> </data> <data name="Microsoft_plugin_timedate_ErrorResultSubTitle" xml:space="preserve"> - <value>Valid prefixes: 'u' for Unix Timestamp, 'ums' for Unix Timestamp in milliseconds, 'ft' for Windows file time</value> + <value>Valid prefixes: 'u' for Unix Timestamp, 'ums' for Unix Timestamp in milliseconds, 'ft' for Windows file time, 'oa' for OLE Automation date, 'exc' for Excel's 1900 date value, 'exf' for Excel's 1904 date value</value> </data> <data name="Microsoft_plugin_timedate_ErrorResultTitle" xml:space="preserve"> - <value>Error: Invalid number input</value> + <value>Error: Invalid input</value> + </data> + <data name="Microsoft_plugin_timedate_ErrorResultValue" xml:space="preserve"> + <value>Error: Invalid number</value> </data> <data name="Microsoft_plugin_timedate_Hour" xml:space="preserve"> <value>Hour</value> @@ -205,7 +208,7 @@ <comment>'UTC' means here 'Universal Time Convention'</comment> </data> <data name="Microsoft_plugin_timedate_plugin_description" xml:space="preserve"> - <value>Provides time and date values for the system time or a custom time stamp (e.g.'{0}', '{1}', '{2}', '{3}')</value> + <value>Shows time and date values for the system time or a custom time stamp (e.g.'{0}', '{1}', '{2}', '{3}')</value> <comment>Do not translate the placeholders like '{0}' because it will be replaced in code.</comment> </data> <data name="Microsoft_plugin_timedate_plugin_description_example_calendarWeek" xml:space="preserve"> @@ -243,10 +246,26 @@ <value>Date and time; Time and Date</value> <comment>Don't change order</comment> </data> + <data name="Microsoft_plugin_timedate_SearchTagCustom" xml:space="preserve"> + <value>Date and time; Time and Date; Custom format</value> + <comment>Don't change order</comment> + </data> + <data name="Microsoft_plugin_timedate_SearchTagCustomUtc" xml:space="preserve"> + <value>Date and time UTC; Time UTC and Date; Custom UTC format</value> + <comment>Don't change order</comment> + </data> <data name="Microsoft_plugin_timedate_SearchTagFormatNow" xml:space="preserve"> <value>Current date and time; Current time and date; Now</value> <comment>Don't change order</comment> </data> + <data name="Microsoft_plugin_timedate_SearchTagCustomNow" xml:space="preserve"> + <value>Current date and time; Current time and date; Now; Custom format</value> + <comment>Don't change order</comment> + </data> + <data name="Microsoft_plugin_timedate_SearchTagCustomUtcNow" xml:space="preserve"> + <value>Current date and time UTC; Current time UTC and date; Now UTC; Custom UTC format</value> + <comment>Don't change order</comment> + </data> <data name="Microsoft_plugin_timedate_SearchTagTime" xml:space="preserve"> <value>Time</value> <comment>Don't change order</comment> @@ -262,6 +281,9 @@ <data name="Microsoft_plugin_timedate_Second" xml:space="preserve"> <value>Second</value> </data> + <data name="Microsoft_plugin_timedate_InvalidCustomFormat" xml:space="preserve"> + <value>Invalid custom format:</value> + </data> <data name="Microsoft_plugin_timedate_SettingDateWithWeekday" xml:space="preserve"> <value>Show date with weekday and name of month</value> </data> @@ -360,4 +382,42 @@ <data name="Microsoft_plugin_timedate_Setting_UseSystemSetting" xml:space="preserve"> <value>Use system setting</value> </data> + <data name="Microsoft_plugin_timedate_Setting_CustomFormats" xml:space="preserve"> + <value>Custom formats</value> + </data> + <data name="Microsoft_plugin_timedate_Setting_CustomFormatsDescription" xml:space="preserve"> + <value>Use date and time string format syntax and {0} (Day of Week), {1} (Days in Month), {2} (Week of Month), {3} (Week of the year), {4} (Era abbreviation), {5} (Windows File Time), {6} (Unix Time), {7} (Unix Time in milliseconds), {8} (OLE Automation date), {9} (Excel's 1900 based date value), {10} (Excel's 1904 based date value). If the format starts with {11}, then Universal Time (UTC) is used. (Use a backslash to escape format sequences and the backslash character as text.)</value> + <comment>The {n} parts are place holders and get replaced in the code.</comment> + </data> + <data name="Microsoft_plugin_timedate_ErrorConvertCustomFormat" xml:space="preserve"> + <value>Failed to convert into custom format:</value> + </data> + <data name="Microsoft_plugin_timedate_ErrorConvertWft" xml:space="preserve"> + <value>Not a valid Windows file time</value> + </data> + <data name="Microsoft_plugin_timedate_InvalidInput_SupportedRange" xml:space="preserve"> + <value>Your input for {0} is outside the range from {1} to {2}.</value> + <comment>The placeholder will be replace in code.</comment> + </data> + <data name="Microsoft_plugin_timedate_OADate" xml:space="preserve"> + <value>OLE Automation Date</value> + </data> + <data name="Microsoft_plugin_timedate_Excel1900" xml:space="preserve"> + <value>Excel's 1900 date value</value> + </data> + <data name="Microsoft_plugin_timedate_InvalidInput_FakeExcel1900" xml:space="preserve"> + <value>Cannot parse the input as Excel's 1900 date value because it is a fake date. (In Excel 0 stands for 0/1/1900 and this date doesn't exist. And 60 stands for 2/29/1900 and this date only exists in Excel for compatibility with Lotus 123.)</value> + </data> + <data name="Microsoft_plugin_timedate_Excel1904" xml:space="preserve"> + <value>Excel's 1904 date value</value> + </data> + <data name="Microsoft_plugin_timedate_LeapYear" xml:space="preserve"> + <value>Leap year</value> + </data> + <data name="Microsoft_plugin_timedate_NoLeapYear" xml:space="preserve"> + <value>Not a leap year</value> + </data> + <data name="Microsoft_plugin_timedate_DaysInMonth" xml:space="preserve"> + <value>Days in month</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Helper/ContextMenuHelper.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Helper/ContextMenuHelper.cs index 447f78d7fa..b9ee4b9cc3 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Helper/ContextMenuHelper.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Helper/ContextMenuHelper.cs @@ -53,7 +53,7 @@ namespace Microsoft.PowerToys.Run.Plugin.WindowsSettings.Helper /// Copy the given text to the clipboard /// </summary> /// <param name="text">The text to copy to the clipboard</param> - /// <returns><see langword="true"/>The text successful copy to the clipboard, otherwise <see langword="false"/></returns> + /// <returns><see langword="true"/>The text successful copy to the clipboard; otherwise, <see langword="false"/></returns> private static bool TryToCopyToClipBoard(in string text) { try diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Helper/ResultHelper.cs b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Helper/ResultHelper.cs index d4774fbd9a..1852a7f1b8 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Helper/ResultHelper.cs +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Helper/ResultHelper.cs @@ -104,7 +104,7 @@ namespace Microsoft.PowerToys.Run.Plugin.WindowsSettings.Helper /// Open the settings page of the given <see cref="IWindowsSetting"/>. /// </summary> /// <param name="entry">The <see cref="WindowsSetting"/> that contain the information to open the setting on command level.</param> - /// <returns><see langword="true"/> if the settings could be opened, otherwise <see langword="false"/>.</returns> + /// <returns><see langword="true"/> if the settings could be opened; otherwise, <see langword="false"/>.</returns> private static bool DoOpenSettingsAction(WindowsSetting entry) { ProcessStartInfo processStartInfo; diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Microsoft.PowerToys.Run.Plugin.WindowsSettings.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Microsoft.PowerToys.Run.Plugin.WindowsSettings.csproj index 9e7d5d6d2a..bb540576cf 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Microsoft.PowerToys.Run.Plugin.WindowsSettings.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/Microsoft.PowerToys.Run.Plugin.WindowsSettings.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{5043CECE-E6A7-4867-9CBE-02D27D83747A}</ProjectGuid> @@ -13,7 +13,7 @@ <UseWindowsForms>true</UseWindowsForms> <Nullable>enable</Nullable> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\WindowsSettings\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\WindowsSettings\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/WindowsSettings.json b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/WindowsSettings.json index 2695b7c1d2..97c4d3f65c 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/WindowsSettings.json +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsSettings/WindowsSettings.json @@ -295,7 +295,7 @@ "Areas": [ "AreaEaseOfAccess" ], "Type": "AppSettingsApp", "AltNames": [ "TouchFeedback" ], - "Command": "ms-settings:easeofaccess-MousePointer" + "Command": "ms-settings:easeofaccess-mousepointer" }, { "Name": "Display", diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.UnitTests/Microsoft.Plugin.WindowsTerminal.UnitTests.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.UnitTests/Microsoft.Plugin.WindowsTerminal.UnitTests.csproj index b582893d31..b10b194563 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.UnitTests/Microsoft.Plugin.WindowsTerminal.UnitTests.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.UnitTests/Microsoft.Plugin.WindowsTerminal.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> diff --git a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.csproj b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.csproj index 25c35fceaa..395055db42 100644 --- a/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.csproj +++ b/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.WindowsTerminal/Microsoft.PowerToys.Run.Plugin.WindowsTerminal.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <RootNamespace>Microsoft.PowerToys.Run.Plugin.WindowsTerminal</RootNamespace> @@ -9,7 +9,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\..\$(Platform)\$(Configuration)\RunPlugins\WindowsTerminal\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\RunPlugins\WindowsTerminal\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherBootEvent.cs b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherBootEvent.cs index 1bf8819577..0068ece1dc 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherBootEvent.cs +++ b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherBootEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerLauncher.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class LauncherBootEvent : EventBase, IEvent { public double BootTimeMs { get; set; } diff --git a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherColdStateHotkeyEvent.cs b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherColdStateHotkeyEvent.cs index 8997137c8f..844490af87 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherColdStateHotkeyEvent.cs +++ b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherColdStateHotkeyEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerLauncher.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class LauncherColdStateHotkeyEvent : EventBase, IEvent { public double HotkeyToVisibleTimeMs { get; set; } diff --git a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherFirstDeleteEvent.cs b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherFirstDeleteEvent.cs index cfcc87e6a5..c413e96fe4 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherFirstDeleteEvent.cs +++ b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherFirstDeleteEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerLauncher.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class LauncherFirstDeleteEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherHideEvent.cs b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherHideEvent.cs index 0c8d010a68..eb544f472a 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherHideEvent.cs +++ b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherHideEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerLauncher.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class LauncherHideEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherQueryEvent.cs b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherQueryEvent.cs index 6914bac5df..86c51d9a58 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherQueryEvent.cs +++ b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherQueryEvent.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerLauncher.Telemetry /// ETW Event for when the user initiates a query /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class LauncherQueryEvent : EventBase, IEvent { public double QueryTimeMs { get; set; } diff --git a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherResultActionEvent.cs b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherResultActionEvent.cs index e4cc9b20ce..fb5ee7caa1 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherResultActionEvent.cs +++ b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherResultActionEvent.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerLauncher.Telemetry /// ETW event for when a result is actioned. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class LauncherResultActionEvent : EventBase, IEvent { public enum TriggerType diff --git a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherShowEvent.cs b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherShowEvent.cs index 8882c5f0ec..f56ff92b44 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherShowEvent.cs +++ b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherShowEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerLauncher.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class LauncherShowEvent : EventBase, IEvent { public LauncherShowEvent(string hotkey) diff --git a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherWarmStateHotkeyEvent.cs b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherWarmStateHotkeyEvent.cs index 5dbeba4865..c828b37c88 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherWarmStateHotkeyEvent.cs +++ b/src/modules/launcher/PowerLauncher.Telemetry/Events/LauncherWarmStateHotkeyEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerLauncher.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class LauncherWarmStateHotkeyEvent : EventBase, IEvent { public double HotkeyToVisibleTimeMs { get; set; } diff --git a/src/modules/launcher/PowerLauncher.Telemetry/Events/RunPluginsSettingsEvent.cs b/src/modules/launcher/PowerLauncher.Telemetry/Events/RunPluginsSettingsEvent.cs index 94eaa90089..f92219671a 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/Events/RunPluginsSettingsEvent.cs +++ b/src/modules/launcher/PowerLauncher.Telemetry/Events/RunPluginsSettingsEvent.cs @@ -3,14 +3,15 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace PowerLauncher.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class RunPluginsSettingsEvent : EventBase, IEvent { public RunPluginsSettingsEvent(IDictionary<string, PluginModel> pluginManager) diff --git a/src/modules/launcher/PowerLauncher.Telemetry/PowerLauncher.Telemetry.csproj b/src/modules/launcher/PowerLauncher.Telemetry/PowerLauncher.Telemetry.csproj index 9b161cd95e..f7d094abc3 100644 --- a/src/modules/launcher/PowerLauncher.Telemetry/PowerLauncher.Telemetry.csproj +++ b/src/modules/launcher/PowerLauncher.Telemetry/PowerLauncher.Telemetry.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <Description>PowerToys PowerLauncher Telemetry</Description> diff --git a/src/modules/launcher/PowerLauncher/Helper/EnvironmentHelper.cs b/src/modules/launcher/PowerLauncher/Helper/EnvironmentHelper.cs index 378027040c..1d8f983283 100644 --- a/src/modules/launcher/PowerLauncher/Helper/EnvironmentHelper.cs +++ b/src/modules/launcher/PowerLauncher/Helper/EnvironmentHelper.cs @@ -15,7 +15,7 @@ using Stopwatch = Wox.Infrastructure.Stopwatch; namespace PowerLauncher.Helper { /// <Note> - /// On Windows operating system the name of environment variables is case-insensitive. This means if we have a user and machine variable with differences in their name casing (eg. test vs Test), the name casing from machine level is used and won't be overwritten by the user var. + /// On Windows operating system the name of environment variables is case-insensitive. This means if we have a user and machine variable with differences in their name casing (e.g. test vs Test), the name casing from machine level is used and won't be overwritten by the user var. /// Example for Window's behavior: test=ValueMachine (Machine level) + TEST=ValueUser (User level) => test=ValueUser (merged) /// To get the same behavior we use "StringComparer.OrdinalIgnoreCase" as compare property for the HashSet and Dictionaries where we merge machine and user variable names. /// </Note> diff --git a/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs b/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs index 3eb6252a24..4ff1a08697 100644 --- a/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs +++ b/src/modules/launcher/PowerLauncher/Helper/ErrorReporting.cs @@ -14,25 +14,7 @@ namespace PowerLauncher.Helper { public static class ErrorReporting { - private static void Report(Exception e, bool waitForClose) - { - if (e != null) - { - var logger = LogManager.GetLogger("UnHandledException"); - logger.Fatal(ExceptionFormatter.FormatException(e)); - - var reportWindow = new ReportWindow(e); - - if (waitForClose) - { - reportWindow.ShowDialog(); - } - else - { - reportWindow.Show(); - } - } - } + private const string LoggerName = "UnHandledException"; public static void ShowMessageBox(string title, string message) { @@ -47,17 +29,20 @@ namespace PowerLauncher.Helper // handle non-ui thread exceptions System.Windows.Application.Current.Dispatcher.Invoke(() => { - Report((Exception)e?.ExceptionObject, true); + HandleException(e?.ExceptionObject as Exception, true); }); } public static void DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) { - // handle ui thread exceptions - Report(e?.Exception, false); + if (e != null) + { + // handle ui thread exceptions + HandleException(e.Exception, false); - // prevent application exist, so the user can copy prompted error info - e.Handled = true; + // prevent application exist, so the user can copy prompted error info + e.Handled = true; + } } public static string RuntimeInfo() @@ -68,5 +53,43 @@ namespace PowerLauncher.Helper $"\nx64: {Environment.Is64BitOperatingSystem}"; return info; } + + private static void HandleException(Exception e, bool isNotUIThread) + { + // The crash occurs in PresentationFramework.dll, not necessarily when the Runner UI is visible, originating from this line: + // https://github.com/dotnet/wpf/blob/3439f20fb8c685af6d9247e8fd2978cac42e74ac/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Shell/WindowChromeWorker.cs#L1005 + // Many bug reports because users see the "Report problem UI" after "the" crash with System.Runtime.InteropServices.COMException 0xD0000701 or 0x80263001. + // However, displaying this "Report problem UI" during WPF crashes, especially when DWM composition is changing, is not ideal; some users reported it hangs for up to a minute before the "Report problem UI" appears. + // This change modifies the behavior to log the exception instead of showing the "Report problem UI". + if (ExceptionHelper.IsRecoverableDwmCompositionException(e as System.Runtime.InteropServices.COMException)) + { + var logger = LogManager.GetLogger(LoggerName); + logger.Error($"From {(isNotUIThread ? "non" : string.Empty)} UI thread's exception: {ExceptionFormatter.FormatException(e)}"); + } + else + { + Report(e, isNotUIThread); + } + } + + private static void Report(Exception e, bool waitForClose) + { + if (e != null) + { + var logger = LogManager.GetLogger(LoggerName); + logger.Fatal($"From {(waitForClose ? "non" : string.Empty)} UI thread's exception: {ExceptionFormatter.FormatException(e)}"); + + var reportWindow = new ReportWindow(e); + + if (waitForClose) + { + reportWindow.ShowDialog(); + } + else + { + reportWindow.Show(); + } + } + } } } diff --git a/src/modules/launcher/PowerLauncher/Helper/ExceptionHelper.cs b/src/modules/launcher/PowerLauncher/Helper/ExceptionHelper.cs new file mode 100644 index 0000000000..15e7de4eac --- /dev/null +++ b/src/modules/launcher/PowerLauncher/Helper/ExceptionHelper.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace PowerLauncher.Helper +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Win32 naming conventions")] + internal static class ExceptionHelper + { + private const string PresentationFrameworkExceptionSource = "PresentationFramework"; + + private const int DWM_E_COMPOSITIONDISABLED = unchecked((int)0x80263001); + + // HRESULT for NT STATUS STATUS_MESSAGE_LOST (0xC0000701 | 0x10000000 == 0xD0000701) + private const int STATUS_MESSAGE_LOST_HR = unchecked((int)0xD0000701); + + /// <summary> + /// Returns true if the exception is a recoverable DWM composition exception. + /// </summary> + internal static bool IsRecoverableDwmCompositionException(Exception exception) + { + if (exception is not COMException comException) + { + return false; + } + + if (comException.HResult is DWM_E_COMPOSITIONDISABLED) + { + return true; + } + + if (comException.HResult is STATUS_MESSAGE_LOST_HR && comException.Source == PresentationFrameworkExceptionSource) + { + return true; + } + + // Check for common DWM composition changed patterns in the stack trace + var stackTrace = comException.StackTrace; + return !string.IsNullOrEmpty(stackTrace) && + stackTrace.Contains("DwmCompositionChanged"); + } + } +} diff --git a/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs b/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs index 2a27494b30..53cc841b30 100644 --- a/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs +++ b/src/modules/launcher/PowerLauncher/Helper/ThemeManager.cs @@ -3,13 +3,16 @@ // See the LICENSE file in the project root for more information. using System; -using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Media; using ManagedCommon; using Microsoft.Win32; using Wox.Infrastructure.Image; using Wox.Infrastructure.UserSettings; +using Wox.Plugin.Logger; namespace PowerLauncher.Helper { @@ -20,6 +23,9 @@ namespace PowerLauncher.Helper private readonly ThemeHelper _themeHelper = new(); private bool _disposed; + private CancellationTokenSource _themeUpdateTokenSource; + private const int MaxRetries = 5; + private const int InitialDelayMs = 2000; public Theme CurrentTheme { get; private set; } @@ -108,10 +114,80 @@ namespace PowerLauncher.Helper { Theme newTheme = _themeHelper.DetermineTheme(_settings.Theme); - _mainWindow.Dispatcher.Invoke(() => + // Cancel any existing theme update operation + _themeUpdateTokenSource?.Cancel(); + _themeUpdateTokenSource?.Dispose(); + _themeUpdateTokenSource = new CancellationTokenSource(); + + // Start theme update with retry logic in the background + _ = UpdateThemeWithRetryAsync(newTheme, _themeUpdateTokenSource.Token); + } + + /// <summary> + /// Applies the theme with retry logic for desktop composition errors. + /// </summary> + /// <param name="theme">The theme to apply.</param> + /// <param name="cancellationToken">Token to cancel the operation.</param> + private async Task UpdateThemeWithRetryAsync(Theme theme, CancellationToken cancellationToken) + { + var delayMs = 0; + const int maxAttempts = MaxRetries + 1; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { - SetSystemTheme(newTheme); - }); + try + { + if (delayMs > 0) + { + await Task.Delay(delayMs, cancellationToken); + } + + if (cancellationToken.IsCancellationRequested) + { + Log.Debug("Theme update operation was cancelled.", typeof(ThemeManager)); + return; + } + + await _mainWindow.Dispatcher.InvokeAsync(() => + { + SetSystemTheme(theme); + }); + + if (attempt > 1) + { + Log.Info($"Successfully applied theme after {attempt - 1} retry attempt(s).", typeof(ThemeManager)); + } + + return; + } + catch (COMException ex) when (ExceptionHelper.IsRecoverableDwmCompositionException(ex)) + { + switch (attempt) + { + case 1: + Log.Warn($"Desktop composition is disabled (HRESULT: 0x{ex.HResult:X}). Scheduling retries for theme update.", typeof(ThemeManager)); + delayMs = InitialDelayMs; + break; + case < maxAttempts: + Log.Warn($"Retry {attempt - 1}/{MaxRetries} failed: Desktop composition still disabled. Retrying in {delayMs * 2}ms...", typeof(ThemeManager)); + delayMs *= 2; + break; + default: + Log.Exception($"Failed to set theme after {MaxRetries} retry attempts. Desktop composition remains disabled.", ex, typeof(ThemeManager)); + break; + } + } + catch (OperationCanceledException) + { + Log.Debug("Theme update operation was cancelled.", typeof(ThemeManager)); + return; + } + catch (Exception ex) + { + Log.Exception($"Unexpected error during theme update (attempt {attempt}/{maxAttempts}): {ex.Message}", ex, typeof(ThemeManager)); + throw; + } + } } public void Dispose() @@ -130,6 +206,8 @@ namespace PowerLauncher.Helper if (disposing) { SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; + _themeUpdateTokenSource?.Cancel(); + _themeUpdateTokenSource?.Dispose(); } _disposed = true; diff --git a/src/modules/launcher/PowerLauncher/MainWindow.xaml.cs b/src/modules/launcher/PowerLauncher/MainWindow.xaml.cs index eaade8972f..b0a125a949 100644 --- a/src/modules/launcher/PowerLauncher/MainWindow.xaml.cs +++ b/src/modules/launcher/PowerLauncher/MainWindow.xaml.cs @@ -329,7 +329,7 @@ namespace PowerLauncher var result = ((FrameworkElement)e.OriginalSource).DataContext; if (result != null) { - // This may be null if the tapped item was one of the context buttons (run as admin etc). + // This may be null if the tapped item was one of the context buttons (run as admin, etc.). if (result is ResultViewModel resultVM) { _viewModel.Results.SelectedItem = resultVM; diff --git a/src/modules/launcher/PowerLauncher/Plugin/PluginManager.cs b/src/modules/launcher/PowerLauncher/Plugin/PluginManager.cs index ad01b7eba4..f12359c528 100644 --- a/src/modules/launcher/PowerLauncher/Plugin/PluginManager.cs +++ b/src/modules/launcher/PowerLauncher/Plugin/PluginManager.cs @@ -185,9 +185,10 @@ namespace PowerLauncher.Plugin if (!failedPlugins.IsEmpty) { - var failed = string.Join(", ", failedPlugins.Select(x => x.Metadata.Name)); + string title = Resources.FailedToInitializePluginsTitle.ToString().Replace("{0}", Constant.Version); + var failed = string.Join(", ", failedPlugins.Select(x => $"{x.Metadata.Name} ({x.Metadata.ExecuteFileVersion})")); var description = $"{string.Format(CultureInfo.CurrentCulture, FailedToInitializePluginsDescription, failed)}\n\n{Resources.FailedToInitializePluginsDescriptionPartTwo}"; - Application.Current.Dispatcher.InvokeAsync(() => API.ShowMsg(Resources.FailedToInitializePluginsTitle, description, string.Empty, false)); + Application.Current.Dispatcher.InvokeAsync(() => API.ShowMsg(title, description, string.Empty, false)); } } diff --git a/src/modules/launcher/PowerLauncher/PowerLauncher.csproj b/src/modules/launcher/PowerLauncher/PowerLauncher.csproj index 0953b4d5a1..d22f82282f 100644 --- a/src/modules/launcher/PowerLauncher/PowerLauncher.csproj +++ b/src/modules/launcher/PowerLauncher/PowerLauncher.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <AssemblyTitle>PowerToys.Run</AssemblyTitle> @@ -18,7 +18,7 @@ <AssetTargetFallback>uap10.0.19041</AssetTargetFallback> <Description>PowerToys PowerLauncher</Description> <AssemblyName>PowerToys.PowerLauncher</AssemblyName> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> </PropertyGroup> diff --git a/src/modules/launcher/PowerLauncher/Properties/Resources.Designer.cs b/src/modules/launcher/PowerLauncher/Properties/Resources.Designer.cs index d4c435a900..b67d172e2f 100644 --- a/src/modules/launcher/PowerLauncher/Properties/Resources.Designer.cs +++ b/src/modules/launcher/PowerLauncher/Properties/Resources.Designer.cs @@ -160,7 +160,7 @@ namespace PowerLauncher.Properties { } /// <summary> - /// Looks up a localized string similar to PowerToys Run - Plugin Initialization Error. + /// Looks up a localized string similar to PowerToys Run {0} - Plugin Initialization Error. /// </summary> public static string FailedToInitializePluginsTitle { get { diff --git a/src/modules/launcher/PowerLauncher/Properties/Resources.resx b/src/modules/launcher/PowerLauncher/Properties/Resources.resx index 421919bb13..e3a64a6fdd 100644 --- a/src/modules/launcher/PowerLauncher/Properties/Resources.resx +++ b/src/modules/launcher/PowerLauncher/Properties/Resources.resx @@ -189,7 +189,7 @@ <value>Fail to initialize plugins: {0}</value> </data> <data name="FailedToInitializePluginsTitle" xml:space="preserve"> - <value>PowerToys Run - Plugin Initialization Error</value> + <value>PowerToys Run {0} - Plugin Initialization Error</value> <comment>Don't translate "PowerToys Run". This is a product name.</comment> </data> <data name="ContextMenuItemsAvailable" xml:space="preserve"> diff --git a/src/modules/launcher/PowerLauncher/ResultList.xaml b/src/modules/launcher/PowerLauncher/ResultList.xaml index 91523185d9..f2629bb2d7 100644 --- a/src/modules/launcher/PowerLauncher/ResultList.xaml +++ b/src/modules/launcher/PowerLauncher/ResultList.xaml @@ -8,7 +8,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:p="clr-namespace:PowerLauncher.Properties" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" - xmlns:viewmodel="clr-namespace:PowerLauncher.ViewModel" + xmlns:viewModel="clr-namespace:PowerLauncher.ViewModel" d:DesignHeight="300" d:DesignWidth="720" mc:Ignorable="d"> @@ -119,13 +119,13 @@ FontSize="{DynamicResource TitleFontSize}" IsHitTestVisible="False" TextTrimming="CharacterEllipsis"> - <viewmodel:ResultsViewModel.FormattedText> + <viewModel:ResultsViewModel.FormattedText> <MultiBinding Converter="{StaticResource highlightTextConverter}"> <Binding Path="Result.Title" /> <Binding Path="Result.TitleHighlightData" /> <Binding Path="IsSelected" RelativeSource="{RelativeSource Mode=FindAncestor, AncestorType={x:Type ListViewItem}}" /> </MultiBinding> - </viewmodel:ResultsViewModel.FormattedText> + </viewModel:ResultsViewModel.FormattedText> </TextBlock> <TextBlock x:Name="Path" diff --git a/src/modules/launcher/PowerLauncher/SettingsReader.cs b/src/modules/launcher/PowerLauncher/SettingsReader.cs index 7d03e4cb27..dbba7e7906 100644 --- a/src/modules/launcher/PowerLauncher/SettingsReader.cs +++ b/src/modules/launcher/PowerLauncher/SettingsReader.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.IO.Abstractions; using System.Linq; @@ -39,7 +38,7 @@ namespace PowerLauncher public SettingsReader(PowerToysRunSettings settings, ThemeManager themeManager) { - _settingsUtils = new SettingsUtils(); + _settingsUtils = SettingsUtils.Default; _settings = settings; _themeManager = themeManager; @@ -252,7 +251,7 @@ namespace PowerLauncher Id = x.Metadata.ID, Name = x.Plugin == null ? x.Metadata.Name : x.Plugin.Name, Description = x.Plugin?.Description, - Version = FileVersionInfo.GetVersionInfo(x.Metadata.ExecuteFilePath).FileVersion, + Version = x.Metadata.ExecuteFileVersion, Author = x.Metadata.Author, Website = x.Metadata.Website, Disabled = x.Metadata.Disabled, diff --git a/src/modules/launcher/PowerLauncher/ViewModel/ResultViewModel.cs b/src/modules/launcher/PowerLauncher/ViewModel/ResultViewModel.cs index fdb6ef8daa..bf32ba9492 100644 --- a/src/modules/launcher/PowerLauncher/ViewModel/ResultViewModel.cs +++ b/src/modules/launcher/PowerLauncher/ViewModel/ResultViewModel.cs @@ -280,7 +280,7 @@ namespace PowerLauncher.ViewModel /// <summary> /// Triggers the action on the selected context button /// </summary> - /// <returns>False if there is nothing selected, otherwise true</returns> + /// <returns>False if there is nothing selected; otherwise, true</returns> public bool ExecuteSelectedContextButton() { if (HasSelectedContextButton()) diff --git a/src/modules/launcher/PowerLauncher/ViewModel/ResultsViewModel.cs b/src/modules/launcher/PowerLauncher/ViewModel/ResultsViewModel.cs index 02e6138b30..64a52e8385 100644 --- a/src/modules/launcher/PowerLauncher/ViewModel/ResultsViewModel.cs +++ b/src/modules/launcher/PowerLauncher/ViewModel/ResultsViewModel.cs @@ -282,11 +282,11 @@ namespace PowerLauncher.ViewModel if (options.SearchQueryTuningEnabled) { - sorted = Results.OrderByDescending(x => (x.Result.Metadata.WeightBoost + x.Result.Score + (x.Result.SelectedCount * options.SearchClickedItemWeight))).ToList(); + sorted = Results.OrderByDescending(x => x.Result.GetSortOrderScore(options.SearchClickedItemWeight)).ToList(); } else { - sorted = Results.OrderByDescending(x => (x.Result.Metadata.WeightBoost + x.Result.Score + (x.Result.SelectedCount * 5))).ToList(); + sorted = Results.OrderByDescending(x => x.Result.GetSortOrderScore(5)).ToList(); } // remove history items in they are in the list as non-history items diff --git a/src/modules/launcher/PowerLauncher/app.manifest b/src/modules/launcher/PowerLauncher/app.manifest index fb9b15e291..88e30f9f32 100644 --- a/src/modules/launcher/PowerLauncher/app.manifest +++ b/src/modules/launcher/PowerLauncher/app.manifest @@ -52,8 +52,8 @@ <application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> - <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> diff --git a/src/modules/launcher/Wox.Infrastructure/Image/WindowsThumbnailProvider.cs b/src/modules/launcher/Wox.Infrastructure/Image/WindowsThumbnailProvider.cs index e5cc42cc7c..ef36af04bc 100644 --- a/src/modules/launcher/Wox.Infrastructure/Image/WindowsThumbnailProvider.cs +++ b/src/modules/launcher/Wox.Infrastructure/Image/WindowsThumbnailProvider.cs @@ -255,7 +255,7 @@ namespace Wox.Infrastructure.Image Log.Exception("Got an exception while trying to detect Adobe Reader / Adobe Acrobat Pro as PDF thumbnail provider. To prevent PT Run from a Dispatcher crash, we report that Adobe Reader / Adobe Acrobat Pro is used and show only the PDF icon in the results.", ex, MethodBase.GetCurrentMethod().DeclaringType); } - // If we fail to detect it, we return that Adobe is used. Otherwise we could run into the Dispatcher crash. + // If we fail to detect it, we return that Adobe is used. Otherwise, we could run into the Dispatcher crash. // (This only results in showing the icon instead of a thumbnail. It has no other functional impact.) return true; } diff --git a/src/modules/launcher/Wox.Infrastructure/Storage/ListRepository`1.cs b/src/modules/launcher/Wox.Infrastructure/Storage/ListRepository`1.cs index 5003fd8b63..1867f3b01e 100644 --- a/src/modules/launcher/Wox.Infrastructure/Storage/ListRepository`1.cs +++ b/src/modules/launcher/Wox.Infrastructure/Storage/ListRepository`1.cs @@ -14,7 +14,7 @@ namespace Wox.Infrastructure.Storage { /// <summary> /// The intent of this class is to provide a basic subset of 'list' like operations, without exposing callers to the internal representation - /// of the data structure. Currently this is implemented as a list for it's simplicity. + /// of the data structure. Currently this is implemented as a list for its simplicity. /// </summary> /// <typeparam name="T">typeof</typeparam> public class ListRepository<T> : IRepository<T>, IEnumerable<T> diff --git a/src/modules/launcher/Wox.Infrastructure/StringMatcher.cs b/src/modules/launcher/Wox.Infrastructure/StringMatcher.cs index 986ce8528f..04608be23a 100644 --- a/src/modules/launcher/Wox.Infrastructure/StringMatcher.cs +++ b/src/modules/launcher/Wox.Infrastructure/StringMatcher.cs @@ -272,7 +272,7 @@ namespace Wox.Infrastructure // while the score is lower if they are more spread out // The length of the match is assigned a larger weight factor. - // I.e. the length is more important than where in the string a match is found. + // I.e. the length is more important than the location where a match is found. const int matchLenWeightFactor = 2; var score = 100 * (query.Length + 1) * matchLenWeightFactor / ((1 + firstIndex) + (matchLenWeightFactor * (matchLen + 1))); diff --git a/src/modules/launcher/Wox.Infrastructure/Wox.Infrastructure.csproj b/src/modules/launcher/Wox.Infrastructure/Wox.Infrastructure.csproj index 6490b7cee5..ffdc41aad9 100644 --- a/src/modules/launcher/Wox.Infrastructure/Wox.Infrastructure.csproj +++ b/src/modules/launcher/Wox.Infrastructure/Wox.Infrastructure.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{4FD29318-A8AB-4D8F-AA47-60BC241B8DA3}</ProjectGuid> @@ -12,7 +12,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WoxInfrastructure</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WoxInfrastructure</OutputPath> </PropertyGroup> <!-- FOLLOW UP: Should this be self-contained? --> diff --git a/src/modules/launcher/Wox.Plugin/Common/DefaultBrowserInfo.cs b/src/modules/launcher/Wox.Plugin/Common/DefaultBrowserInfo.cs index 64493e23e2..a5796efeb1 100644 --- a/src/modules/launcher/Wox.Plugin/Common/DefaultBrowserInfo.cs +++ b/src/modules/launcher/Wox.Plugin/Common/DefaultBrowserInfo.cs @@ -80,8 +80,11 @@ namespace Wox.Plugin.Common try { string progId = GetRegistryValue( - @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", - "ProgId"); + @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoiceLatest\ProgId", + "ProgId") + ?? GetRegistryValue( + @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", + "ProgId"); string appName = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\Application", "ApplicationName") ?? GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}", "FriendlyTypeName"); diff --git a/src/modules/launcher/Wox.Plugin/Common/VirtualDesktop/VirtualDesktopHelper.cs b/src/modules/launcher/Wox.Plugin/Common/VirtualDesktop/VirtualDesktopHelper.cs index 7770488551..50add849da 100644 --- a/src/modules/launcher/Wox.Plugin/Common/VirtualDesktop/VirtualDesktopHelper.cs +++ b/src/modules/launcher/Wox.Plugin/Common/VirtualDesktop/VirtualDesktopHelper.cs @@ -104,7 +104,7 @@ namespace Wox.Plugin.Common.VirtualDesktop.Helper byte[] allDeskValue = (byte[])virtualDesktopKey.GetValue("VirtualDesktopIDs", null); if (allDeskValue != null) { - // We clear only, if we can read from registry. Otherwise we keep the existing values. + // We clear only, if we can read from registry. Otherwise, we keep the existing values. _availableDesktops.Clear(); // Each guid has a length of 16 elements @@ -135,7 +135,7 @@ namespace Wox.Plugin.Common.VirtualDesktop.Helper else { // The registry value is missing when the user hasn't switched the desktop at least one time before reading the registry. In this case we can set it to desktop one. - // We can only set it to desktop one, if we have at least one desktop in the desktops list. Otherwise we keep the existing value. + // We can only set it to desktop one, if we have at least one desktop in the desktops list. Otherwise, we keep the existing value. Log.Debug("VirtualDesktopHelper.UpdateDesktopList() failed to read the id for the current desktop form registry.", typeof(VirtualDesktopHelper)); _currentDesktop = _availableDesktops.Count >= 1 ? _availableDesktops[0] : _currentDesktop; } @@ -237,7 +237,7 @@ namespace Wox.Plugin.Common.VirtualDesktop.Helper /// Returns the number (position) of a desktop. /// </summary> /// <param name="desktop">The guid of the desktop.</param> - /// <returns>Number of the desktop, if found. Otherwise a value of zero.</returns> + /// <returns>Number of the desktop, if found. Otherwise, a value of zero.</returns> public int GetDesktopNumber(Guid desktop) { if (_desktopListAutoUpdate) diff --git a/src/modules/launcher/Wox.Plugin/Common/Win32/NativeMethods.cs b/src/modules/launcher/Wox.Plugin/Common/Win32/NativeMethods.cs index e3442ae025..28873d67f0 100644 --- a/src/modules/launcher/Wox.Plugin/Common/Win32/NativeMethods.cs +++ b/src/modules/launcher/Wox.Plugin/Common/Win32/NativeMethods.cs @@ -1048,7 +1048,7 @@ namespace Wox.Plugin.Common.Win32 /// <summary> /// The window has generic "right-aligned" properties. This depends on the window class. This style has - /// an effect only if the shell language supports reading-order alignment, otherwise is ignored. + /// an effect only if the shell language supports reading-order alignment; otherwise, is ignored. /// </summary> WS_EX_RIGHT = 0x1000, diff --git a/src/modules/launcher/Wox.Plugin/PluginMetadata.cs b/src/modules/launcher/Wox.Plugin/PluginMetadata.cs index 9a22fa485a..4596e7da4a 100644 --- a/src/modules/launcher/Wox.Plugin/PluginMetadata.cs +++ b/src/modules/launcher/Wox.Plugin/PluginMetadata.cs @@ -2,6 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics; +using System.IO; using System.IO.Abstractions; using System.Text.Json.Serialization; @@ -42,6 +44,9 @@ namespace Wox.Plugin public string ExecuteFileName { get; set; } + [JsonIgnore] + public string ExecuteFileVersion { get; private set; } + public string PluginDirectory { get @@ -53,6 +58,7 @@ namespace Wox.Plugin { _pluginDirectory = value; ExecuteFilePath = Path.Combine(value, ExecuteFileName); + SetExecutableVersion(); } } @@ -84,5 +90,19 @@ namespace Wox.Plugin [JsonIgnore] public int QueryCount { get; set; } + + private void SetExecutableVersion() + { + // Using version from plugin metadata json as fallback + try + { + var v = FileVersionInfo.GetVersionInfo(ExecuteFilePath).FileVersion; + ExecuteFileVersion = (v is null or "0.0.0.0") ? Version : v; + } + catch (FileNotFoundException) + { + ExecuteFileVersion = Version; + } + } } } diff --git a/src/modules/launcher/Wox.Plugin/PluginPair.cs b/src/modules/launcher/Wox.Plugin/PluginPair.cs index d31970d16d..01daf9fb22 100644 --- a/src/modules/launcher/Wox.Plugin/PluginPair.cs +++ b/src/modules/launcher/Wox.Plugin/PluginPair.cs @@ -82,8 +82,9 @@ namespace Wox.Plugin if (!IsPluginInitialized) { - string description = $"{Resources.FailedToLoadPluginDescription} {Metadata.Name}\n\n{Resources.FailedToLoadPluginDescriptionPartTwo}"; - Application.Current.Dispatcher.InvokeAsync(() => api.ShowMsg(Resources.FailedToLoadPluginTitle, description, string.Empty, false)); + string title = Resources.FailedToLoadPluginTitle.ToString().Replace("{0}", Constant.Version); + string description = $"{Resources.FailedToLoadPluginDescription} {Metadata.Name} ({Metadata.ExecuteFileVersion})\n\n{Resources.FailedToLoadPluginDescriptionPartTwo}"; + Application.Current.Dispatcher.InvokeAsync(() => api.ShowMsg(title, description, string.Empty, false)); } } else diff --git a/src/modules/launcher/Wox.Plugin/Properties/Resources.Designer.cs b/src/modules/launcher/Wox.Plugin/Properties/Resources.Designer.cs index b90d429f40..6e08434dfc 100644 --- a/src/modules/launcher/Wox.Plugin/Properties/Resources.Designer.cs +++ b/src/modules/launcher/Wox.Plugin/Properties/Resources.Designer.cs @@ -79,7 +79,7 @@ namespace Wox.Plugin.Properties { } /// <summary> - /// Looks up a localized string similar to PowerToys Run - Plugin Loading Error. + /// Looks up a localized string similar to PowerToys Run {0} - Plugin Loading Error. /// </summary> public static string FailedToLoadPluginTitle { get { diff --git a/src/modules/launcher/Wox.Plugin/Properties/Resources.resx b/src/modules/launcher/Wox.Plugin/Properties/Resources.resx index c8b8b2e9a7..1286bea532 100644 --- a/src/modules/launcher/Wox.Plugin/Properties/Resources.resx +++ b/src/modules/launcher/Wox.Plugin/Properties/Resources.resx @@ -125,7 +125,7 @@ <comment>"https://aka.ms/powerToysReportBug" is a web uri.</comment> </data> <data name="FailedToLoadPluginTitle" xml:space="preserve"> - <value>PowerToys Run - Plugin Loading Error</value> + <value>PowerToys Run {0} - Plugin Loading Error</value> <comment>Don't translate "PowerToys Run". This is a product name.</comment> </data> <data name="VirtualDesktopHelper_AllDesktops" xml:space="preserve"> diff --git a/src/modules/launcher/Wox.Plugin/Query.cs b/src/modules/launcher/Wox.Plugin/Query.cs index c8bc934508..450e83faa0 100644 --- a/src/modules/launcher/Wox.Plugin/Query.cs +++ b/src/modules/launcher/Wox.Plugin/Query.cs @@ -60,7 +60,7 @@ namespace Wox.Plugin /// <summary> /// Gets search part of a query. - /// This will not include action keyword if exclusive plugin gets it, otherwise it should be same as RawQuery. + /// This will not include action keyword if exclusive plugin gets it; otherwise, it should be same as RawQuery. /// Since we allow user to switch a exclusive plugin to generic plugin, /// so this property will always give you the "real" query part of the query /// </summary> diff --git a/src/modules/launcher/Wox.Plugin/Result.cs b/src/modules/launcher/Wox.Plugin/Result.cs index 91f026bbb2..3bbb6dbf5e 100644 --- a/src/modules/launcher/Wox.Plugin/Result.cs +++ b/src/modules/launcher/Wox.Plugin/Result.cs @@ -187,5 +187,20 @@ namespace Wox.Plugin /// Gets plugin ID that generated this result /// </summary> public string PluginID { get; internal set; } + + /// <summary> + /// Gets or sets a value indicating whether usage based sorting should be applied to this result. + /// </summary> + public bool DisableUsageBasedScoring { get; set; } + + public int GetSortOrderScore(int selectedItemMultiplier) + { + if (DisableUsageBasedScoring) + { + return Metadata.WeightBoost + Score; + } + + return Metadata.WeightBoost + Score + (SelectedCount * selectedItemMultiplier); + } } } diff --git a/src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj b/src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj index e9179e6ddf..0c7d6889a0 100644 --- a/src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj +++ b/src/modules/launcher/Wox.Plugin/Wox.Plugin.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <ProjectGuid>{8451ECDD-2EA4-4966-BB0A-7BBC40138E80}</ProjectGuid> @@ -13,7 +13,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WoxPlugin</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WoxPlugin</OutputPath> </PropertyGroup> <PropertyGroup> @@ -30,6 +30,7 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="NLog" /> <PackageReference Include="NLog.Extensions.Logging" /> <!-- HACK: Microsoft.Extensions.Hosting is referenced, even if it is not used, to force dll versions to be the same as in other projects. Really only needed since OneNote uses the LazyCache and NLog dependencies, which references older assemblies. --> <PackageReference Include="Microsoft.Extensions.Hosting" /> diff --git a/src/modules/launcher/Wox.Test/Wox.Test.csproj b/src/modules/launcher/Wox.Test/Wox.Test.csproj index eaae10a2c4..232f29d72e 100644 --- a/src/modules/launcher/Wox.Test/Wox.Test.csproj +++ b/src/modules/launcher/Wox.Test/Wox.Test.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <ProjectGuid>{FF742965-9A80-41A5-B042-D6C7D3A21708}</ProjectGuid> <OutputType>Exe</OutputType> <AppDesignerFolder>Properties</AppDesignerFolder> @@ -11,7 +14,7 @@ <ApplicationIcon /> <StartupObject /> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\WoxTest</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\WoxTest</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/peek/Peek.Common/Peek.Common.csproj b/src/modules/peek/Peek.Common/Peek.Common.csproj index b5707ed23a..8f51923493 100644 --- a/src/modules/peek/Peek.Common/Peek.Common.csproj +++ b/src/modules/peek/Peek.Common/Peek.Common.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <RootNamespace>Peek.Common</RootNamespace> diff --git a/src/modules/peek/Peek.Common/Telemetry/Events/ClosedEvent.cs b/src/modules/peek/Peek.Common/Telemetry/Events/ClosedEvent.cs index d50f49ac0d..d71629ad46 100644 --- a/src/modules/peek/Peek.Common/Telemetry/Events/ClosedEvent.cs +++ b/src/modules/peek/Peek.Common/Telemetry/Events/ClosedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Peek.UI.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class ClosedEvent : EventBase, IEvent { public ClosedEvent() diff --git a/src/modules/peek/Peek.Common/Telemetry/Events/ErrorEvent.cs b/src/modules/peek/Peek.Common/Telemetry/Events/ErrorEvent.cs index d308308291..2ec0d2fa93 100644 --- a/src/modules/peek/Peek.Common/Telemetry/Events/ErrorEvent.cs +++ b/src/modules/peek/Peek.Common/Telemetry/Events/ErrorEvent.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; using Peek.Common.Models; @@ -11,6 +11,7 @@ using Peek.Common.Models; namespace Peek.UI.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class ErrorEvent : EventBase, IEvent { public class FailureType diff --git a/src/modules/peek/Peek.Common/Telemetry/Events/OpenWithEvent.cs b/src/modules/peek/Peek.Common/Telemetry/Events/OpenWithEvent.cs index 0abb563588..46f9fc2fb5 100644 --- a/src/modules/peek/Peek.Common/Telemetry/Events/OpenWithEvent.cs +++ b/src/modules/peek/Peek.Common/Telemetry/Events/OpenWithEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Peek.UI.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class OpenWithEvent : EventBase, IEvent { public OpenWithEvent() diff --git a/src/modules/peek/Peek.Common/Telemetry/Events/OpenedEvent.cs b/src/modules/peek/Peek.Common/Telemetry/Events/OpenedEvent.cs index 13990ae88f..b83c8c88aa 100644 --- a/src/modules/peek/Peek.Common/Telemetry/Events/OpenedEvent.cs +++ b/src/modules/peek/Peek.Common/Telemetry/Events/OpenedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Peek.UI.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class OpenedEvent : EventBase, IEvent { public OpenedEvent() diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/BrowserControl.xaml b/src/modules/peek/Peek.FilePreviewer/Controls/BrowserControl.xaml index bed3409c99..87439e2d36 100644 --- a/src/modules/peek/Peek.FilePreviewer/Controls/BrowserControl.xaml +++ b/src/modules/peek/Peek.FilePreviewer/Controls/BrowserControl.xaml @@ -21,6 +21,19 @@ x:Name="OpenUriDialog" x:Uid="OpenUriDialog" PrimaryButtonStyle="{ThemeResource AccentButtonStyle}" - SecondaryButtonClick="OpenUriDialog_SecondaryButtonClick" /> + SecondaryButtonClick="OpenUriDialog_SecondaryButtonClick"> + <StackPanel Spacing="16"> + <controls:InfoBar + x:Name="OpenUriWarningBanner" + x:Uid="OpenUriWarningBanner" + IsClosable="False" + IsOpen="True" + Severity="Warning" /> + <TextBlock + x:Name="OpenUriDialogContent" + IsTextSelectionEnabled="True" + TextWrapping="Wrap" /> + </StackPanel> + </ContentDialog> </Grid> </UserControl> diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/BrowserControl.xaml.cs b/src/modules/peek/Peek.FilePreviewer/Controls/BrowserControl.xaml.cs index e6d313d791..5a2e9900d0 100644 --- a/src/modules/peek/Peek.FilePreviewer/Controls/BrowserControl.xaml.cs +++ b/src/modules/peek/Peek.FilePreviewer/Controls/BrowserControl.xaml.cs @@ -34,6 +34,11 @@ namespace Peek.FilePreviewer.Controls private Color? _originalBackgroundColor; + /// <summary> + /// URI of the current source being previewed (for resource filtering) + /// </summary> + private Uri? _currentSourceUri; + public delegate void NavigationCompletedHandler(WebView2? sender, CoreWebView2NavigationCompletedEventArgs? args); public delegate void DOMContentLoadedHandler(CoreWebView2? sender, CoreWebView2DOMContentLoadedEventArgs? args); @@ -97,6 +102,7 @@ namespace Peek.FilePreviewer.Controls { this.InitializeComponent(); Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", TempFolderPath.Path, EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "--block-new-web-contents", EnvironmentVariableTarget.Process); } public void Dispose() @@ -105,6 +111,7 @@ namespace Peek.FilePreviewer.Controls { PreviewBrowser.CoreWebView2.DOMContentLoaded -= CoreWebView2_DOMContentLoaded; PreviewBrowser.CoreWebView2.ContextMenuRequested -= CoreWebView2_ContextMenuRequested; + RemoveResourceFilter(); } } @@ -123,6 +130,14 @@ namespace Peek.FilePreviewer.Controls { /* CoreWebView2.Navigate() will always trigger a navigation even if the content/URI is the same. * Use WebView2.Source to avoid re-navigating to the same content. */ + _currentSourceUri = Source; + + // Only apply resource filter for non-dev files + if (!IsDevFilePreview) + { + ApplyResourceFilter(); + } + PreviewBrowser.CoreWebView2.Navigate(Source.ToString()); } } @@ -146,10 +161,14 @@ namespace Peek.FilePreviewer.Controls if (IsDevFilePreview) { PreviewBrowser.CoreWebView2.SetVirtualHostNameToFolderMapping(Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.VirtualHostName, Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.MonacoDirectory, CoreWebView2HostResourceAccessKind.Allow); + + // Remove resource filter for dev files (Monaco needs to load resources) + RemoveResourceFilter(); } else { PreviewBrowser.CoreWebView2.ClearVirtualHostNameToFolderMapping(Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.VirtualHostName); + ApplyResourceFilter(); } } } @@ -283,6 +302,66 @@ namespace Peek.FilePreviewer.Controls } } + /// <summary> + /// Applies strict resource filtering for non-dev files to block external resources. + /// This prevents XSS attacks and unwanted external content loading. + /// </summary> + private void ApplyResourceFilter() + { + // Remove existing handler to prevent duplicate subscriptions + RemoveResourceFilter(); + + // Add filter and subscribe to resource requests + PreviewBrowser.CoreWebView2.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All); + PreviewBrowser.CoreWebView2.WebResourceRequested += CoreWebView2_WebResourceRequested; + } + + private void RemoveResourceFilter() + { + PreviewBrowser.CoreWebView2.WebResourceRequested -= CoreWebView2_WebResourceRequested; + } + + private void CoreWebView2_WebResourceRequested(CoreWebView2 sender, CoreWebView2WebResourceRequestedEventArgs args) + { + if (_currentSourceUri == null) + { + return; + } + + var requestUri = new Uri(args.Request.Uri); + + // Allow loading the source file itself + if (requestUri == _currentSourceUri) + { + return; + } + + // For local file:// resources, allow same directory and subdirectories + if (requestUri.Scheme == "file" && _currentSourceUri.Scheme == "file") + { + try + { + var sourceDirectory = System.IO.Path.GetDirectoryName(_currentSourceUri.LocalPath); + var requestPath = requestUri.LocalPath; + + // Allow resources in the same directory or subdirectories + if (!string.IsNullOrEmpty(sourceDirectory) && + requestPath.StartsWith(sourceDirectory, StringComparison.OrdinalIgnoreCase)) + { + return; + } + } + catch + { + // If path processing fails, block for security + } + } + + // Block all other resources including http(s) requests to prevent external tracking, + // data exfiltration, and XSS attacks + args.Response = PreviewBrowser.CoreWebView2.Environment.CreateWebResourceResponse(null, 403, "Forbidden", null); + } + private void CoreWebView2_DOMContentLoaded(CoreWebView2 sender, CoreWebView2DOMContentLoadedEventArgs args) { // If the file being previewed is HTML or HTM, reset the background color to its original state. @@ -344,7 +423,7 @@ namespace Peek.FilePreviewer.Controls private async Task ShowOpenUriDialogAsync(Uri uri) { - OpenUriDialog.Content = uri.ToString(); + OpenUriDialogContent.Text = uri.ToString(); var result = await OpenUriDialog.ShowAsync(); if (result == ContentDialogResult.Primary) @@ -356,7 +435,7 @@ namespace Peek.FilePreviewer.Controls private void OpenUriDialog_SecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) { var dataPackage = new DataPackage(); - dataPackage.SetText(sender.Content.ToString()); + dataPackage.SetText(OpenUriDialogContent.Text); Clipboard.SetContent(dataPackage); } } diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/DriveControl.xaml b/src/modules/peek/Peek.FilePreviewer/Controls/DriveControl.xaml index 979e4780e7..ee53887f7c 100644 --- a/src/modules/peek/Peek.FilePreviewer/Controls/DriveControl.xaml +++ b/src/modules/peek/Peek.FilePreviewer/Controls/DriveControl.xaml @@ -1,4 +1,4 @@ -<!-- Copyright (c) Microsoft Corporation. All rights reserved. --> +<!-- Copyright (c) Microsoft Corporation. All rights reserved. --> <!-- Licensed under the MIT License. See LICENSE in the project root for license information. --> <UserControl diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml.cs b/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml.cs index c02582db5d..b7e2652335 100644 --- a/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml.cs +++ b/src/modules/peek/Peek.FilePreviewer/Controls/ShellPreviewHandlerControl.xaml.cs @@ -4,7 +4,6 @@ using System; using System.Runtime.CompilerServices; - using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.UI; using Microsoft.UI.Xaml; @@ -24,8 +23,8 @@ namespace Peek.FilePreviewer.Controls private static readonly COLORREF LightThemeBgColor = new(0x00f3f3f3); private static readonly COLORREF DarkThemeBgColor = new(0x00202020); - private static readonly HBRUSH LightThemeBgBrush = PInvoke.CreateSolidBrush(LightThemeBgColor); - private static readonly HBRUSH DarkThemeBgBrush = PInvoke.CreateSolidBrush(DarkThemeBgColor); + private static readonly HBRUSH LightThemeBgBrush = PInvoke_FilePreviewer.CreateSolidBrush(LightThemeBgColor); + private static readonly HBRUSH DarkThemeBgBrush = PInvoke_FilePreviewer.CreateSolidBrush(DarkThemeBgColor); [ObservableProperty] private IPreviewHandler? source; @@ -88,19 +87,19 @@ namespace Peek.FilePreviewer.Controls if (HandlerVisibility == Visibility.Visible) { - PInvoke.ShowWindow(containerHwnd, SHOW_WINDOW_CMD.SW_SHOW); + PInvoke_FilePreviewer.ShowWindow(containerHwnd, SHOW_WINDOW_CMD.SW_SHOW); IsEnabled = true; // Clears the background from the last previewer // The brush can only be drawn here because flashes will occur during resize - PInvoke.SetClassLongPtr(containerHwnd, GET_CLASS_LONG_INDEX.GCLP_HBRBACKGROUND, containerBgBrush); - PInvoke.UpdateWindow(containerHwnd); - PInvoke.SetClassLongPtr(containerHwnd, GET_CLASS_LONG_INDEX.GCLP_HBRBACKGROUND, IntPtr.Zero); - PInvoke.InvalidateRect(containerHwnd, (RECT*)null, true); + PInvoke_FilePreviewer.SetClassLongPtr(containerHwnd, GET_CLASS_LONG_INDEX.GCLP_HBRBACKGROUND, containerBgBrush); + PInvoke_FilePreviewer.UpdateWindow(containerHwnd); + PInvoke_FilePreviewer.SetClassLongPtr(containerHwnd, GET_CLASS_LONG_INDEX.GCLP_HBRBACKGROUND, IntPtr.Zero); + PInvoke_FilePreviewer.InvalidateRect(containerHwnd, (RECT*)null, true); } else { - PInvoke.ShowWindow(containerHwnd, SHOW_WINDOW_CMD.SW_HIDE); + PInvoke_FilePreviewer.ShowWindow(containerHwnd, SHOW_WINDOW_CMD.SW_HIDE); IsEnabled = false; } } @@ -132,14 +131,14 @@ namespace Peek.FilePreviewer.Controls visuals.SetTextColor(fgColor); // Changing the previewer colors might not always redraw itself - PInvoke.InvalidateRect(containerHwnd, (RECT*)null, true); + PInvoke_FilePreviewer.InvalidateRect(containerHwnd, (RECT*)null, true); } } private LRESULT ContainerWndProc(HWND hWnd, uint msg, WPARAM wParam, LPARAM lParam) { // Here for future use :) - return PInvoke.DefWindowProc(hWnd, msg, wParam, lParam); + return PInvoke_FilePreviewer.DefWindowProc(hWnd, msg, wParam, lParam); } private void EnsureContainerHwndCreated() @@ -157,14 +156,14 @@ namespace Peek.FilePreviewer.Controls fixed (char* pContainerClassName = "PeekShellPreviewHandlerContainer") { - PInvoke.RegisterClass(new WNDCLASSW() + PInvoke_FilePreviewer.RegisterClass(new WNDCLASSW() { lpfnWndProc = containerWndProc, lpszClassName = pContainerClassName, }); // Create the container window to host the preview handler - containerHwnd = PInvoke.CreateWindowEx( + containerHwnd = PInvoke_FilePreviewer.CreateWindowEx( WINDOW_EX_STYLE.WS_EX_LAYERED, pContainerClassName, null, @@ -178,7 +177,7 @@ namespace Peek.FilePreviewer.Controls HINSTANCE.Null); // Allows the preview handlers to display properly - PInvoke.SetLayeredWindowAttributes(containerHwnd, default, byte.MaxValue, LAYERED_WINDOW_ATTRIBUTES_FLAGS.LWA_ALPHA); + PInvoke_FilePreviewer.SetLayeredWindowAttributes(containerHwnd, default, byte.MaxValue, LAYERED_WINDOW_ATTRIBUTES_FLAGS.LWA_ALPHA); } } @@ -186,12 +185,12 @@ namespace Peek.FilePreviewer.Controls { EnsureContainerHwndCreated(); - var dpi = (float)PInvoke.GetDpiForWindow(containerHwnd) / 96; + var dpi = (float)PInvoke_FilePreviewer.GetDpiForWindow(containerHwnd) / 96; // Resize the container window - PInvoke.SetWindowPos( + PInvoke_FilePreviewer.SetWindowPos( containerHwnd, - (HWND)0, // HWND_TOP + (HWND)(nint)0, // HWND_TOP (int)(Math.Abs(args.EffectiveViewport.X) * dpi), (int)(Math.Abs(args.EffectiveViewport.Y) * dpi), (int)(ActualWidth * dpi), @@ -210,7 +209,7 @@ namespace Peek.FilePreviewer.Controls } // Resizing the previewer might not always redraw itself - PInvoke.InvalidateRect(containerHwnd, (RECT*)null, false); + PInvoke_FilePreviewer.InvalidateRect(containerHwnd, (RECT*)null, false); } private void UserControl_GotFocus(object sender, RoutedEventArgs e) diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml b/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml index ec08d0396d..7d518156d4 100644 --- a/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/Controls/SpecialFolderPreview/SpecialFolderPreview.xaml @@ -1,4 +1,4 @@ -<!-- Copyright (c) Microsoft Corporation and Contributors. --> +<!-- Copyright (c) Microsoft Corporation and Contributors. --> <!-- Licensed under the MIT License. --> <UserControl diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/UnsupportedFilePreview/FailedFallbackPreviewControl.xaml b/src/modules/peek/Peek.FilePreviewer/Controls/UnsupportedFilePreview/FailedFallbackPreviewControl.xaml index 61b1765e48..dc0b90dc17 100644 --- a/src/modules/peek/Peek.FilePreviewer/Controls/UnsupportedFilePreview/FailedFallbackPreviewControl.xaml +++ b/src/modules/peek/Peek.FilePreviewer/Controls/UnsupportedFilePreview/FailedFallbackPreviewControl.xaml @@ -1,4 +1,4 @@ -<!-- Copyright (c) Microsoft Corporation and Contributors. --> +<!-- Copyright (c) Microsoft Corporation and Contributors. --> <!-- Licensed under the MIT License. --> <UserControl diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/UnsupportedFilePreview/UnsupportedFilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/Controls/UnsupportedFilePreview/UnsupportedFilePreview.xaml index 0d231839da..aad83e70f6 100644 --- a/src/modules/peek/Peek.FilePreviewer/Controls/UnsupportedFilePreview/UnsupportedFilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/Controls/UnsupportedFilePreview/UnsupportedFilePreview.xaml @@ -1,4 +1,4 @@ -<!-- Copyright (c) Microsoft Corporation and Contributors. --> +<!-- Copyright (c) Microsoft Corporation and Contributors. --> <!-- Licensed under the MIT License. --> <UserControl diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index c3bbe50c61..e7f6db1652 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -60,6 +60,21 @@ </MediaPlayerElement.TransportControls> </MediaPlayerElement> + <RichTextBlock + x:Name="VideoWarningMessage" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Foreground="{StaticResource TextFillColorSecondaryBrush}" + Visibility="{x:Bind IsWarningMessageVisible(VideoPreviewer, Previewer.State), Mode=OneWay}"> + <Paragraph> + <Run Text="{x:Bind GetWarningMessage(VideoPreviewer.MissingCodecName), Mode=OneWay}" /> + <Hyperlink Click="CodecSearchHyperlink_Click"> + <Run x:Uid="VideoCodecStoreLink" /> + </Hyperlink> + </Paragraph> + </RichTextBlock> + + <controls:AudioControl x:Name="AudioPreview" Source="{x:Bind AudioPreviewer.Preview, Mode=OneWay}" diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs index c60d062d70..7166de69a5 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml.cs @@ -14,6 +14,7 @@ using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Documents; using Microsoft.UI.Xaml.Input; using Microsoft.Web.WebView2.Core; using Peek.Common.Extensions; @@ -149,6 +150,28 @@ namespace Peek.FilePreviewer return isValidPreview ? Visibility.Visible : Visibility.Collapsed; } + public Visibility IsWarningMessageVisible(IPreviewer? previewer, PreviewState? state) + { + var shouldShow = previewer is IVideoPreviewer videoPreviewer && MatchPreviewState(state, PreviewState.Loaded) && !string.IsNullOrEmpty(videoPreviewer.MissingCodecName); + + return shouldShow ? Visibility.Visible : Visibility.Collapsed; + } + + public string GetWarningMessage(string missingCodecName) + { + return ReadableStringHelper.FormatResourceString("VideoMissingCodec_WarningMessage", missingCodecName); + } + + private async void CodecSearchHyperlink_Click(Hyperlink sender, HyperlinkClickEventArgs args) + { + string codecName = VideoPreviewer?.MissingCodecName ?? string.Empty; + + string searchQuery = Uri.EscapeDataString(codecName); + Uri storeSearchUri = new Uri($"ms-windows-store://search/?query=codec {codecName}"); + + await Windows.System.Launcher.LaunchUriAsync(storeSearchUri); + } + public Visibility IsUnsupportedPreviewVisible(IUnsupportedFilePreviewer? previewer, PreviewState state) { var isValidPreview = previewer != null && (MatchPreviewState(state, PreviewState.Loaded) || MatchPreviewState(state, PreviewState.Error)); diff --git a/src/modules/peek/Peek.FilePreviewer/Models/PreviewSettings.cs b/src/modules/peek/Peek.FilePreviewer/Models/PreviewSettings.cs index 3df7fc23a1..1ba5e676a5 100644 --- a/src/modules/peek/Peek.FilePreviewer/Models/PreviewSettings.cs +++ b/src/modules/peek/Peek.FilePreviewer/Models/PreviewSettings.cs @@ -34,7 +34,7 @@ namespace Peek.FilePreviewer.Models public PreviewSettings() { - _settingsUtils = new SettingsUtils(); + _settingsUtils = SettingsUtils.Default; SourceCodeWrapText = false; SourceCodeTryFormat = false; SourceCodeFontSize = 14; diff --git a/src/modules/peek/Peek.FilePreviewer/NativeMethods.json b/src/modules/peek/Peek.FilePreviewer/NativeMethods.json index dc43b58861..b5347f1336 100644 --- a/src/modules/peek/Peek.FilePreviewer/NativeMethods.json +++ b/src/modules/peek/Peek.FilePreviewer/NativeMethods.json @@ -1,4 +1,5 @@ { - "$schema": "https://aka.ms/CsWin32.schema.json", - "public": false + "$schema": "https://aka.ms/CsWin32.schema.json", + "public": false, + "className": "PInvoke_FilePreviewer" } \ No newline at end of file diff --git a/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj b/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj index 83b75f8ed9..6d58478be3 100644 --- a/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj +++ b/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <RootNamespace>Peek.FilePreviewer</RootNamespace> diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/IVideoPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/IVideoPreviewer.cs index 4d2b4f4f8a..2ac3c6773e 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/IVideoPreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/IVideoPreviewer.cs @@ -9,5 +9,7 @@ namespace Peek.FilePreviewer.Previewers.Interfaces public interface IVideoPreviewer : IPreviewer, IPreviewTarget { public MediaSource? Preview { get; } + + public string? MissingCodecName { get; } } } diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs index 586b92ed75..a3721a04ec 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/AudioPreviewer.cs @@ -24,13 +24,15 @@ using Windows.Storage; namespace Peek.FilePreviewer.Previewers.MediaPreviewer { - public partial class AudioPreviewer : ObservableObject, IAudioPreviewer + public partial class AudioPreviewer : ObservableObject, IDisposable, IAudioPreviewer { + private MediaSource? _mediaSource; + [ObservableProperty] private PreviewState _state; [ObservableProperty] - private AudioPreviewData _preview; + private AudioPreviewData? _preview; private IFileSystemItem Item { get; } @@ -40,7 +42,6 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { Item = file; Dispatcher = DispatcherQueue.GetForCurrentThread(); - Preview = new AudioPreviewData(); } public async Task CopyAsync() @@ -63,19 +64,23 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { State = PreviewState.Loading; + Preview = new AudioPreviewData(); + var thumbnailTask = LoadThumbnailAsync(cancellationToken); var sourceTask = LoadSourceAsync(cancellationToken); var metadataTask = LoadMetadataAsync(cancellationToken); await Task.WhenAll(thumbnailTask, sourceTask, metadataTask); - if (!thumbnailTask.Result || !sourceTask.Result || !metadataTask.Result) + if (sourceTask.Result && metadataTask.Result) { - State = PreviewState.Error; + State = PreviewState.Loaded; } else { - State = PreviewState.Loaded; + // Release all resources on error. + Unload(); + State = PreviewState.Error; } } @@ -88,12 +93,15 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { cancellationToken.ThrowIfCancellationRequested(); - var thumbnail = await ThumbnailHelper.GetThumbnailAsync(Item.Path, cancellationToken) - ?? await ThumbnailHelper.GetIconAsync(Item.Path, cancellationToken); + if (Preview != null) + { + var thumbnail = await ThumbnailHelper.GetThumbnailAsync(Item.Path, cancellationToken) + ?? await ThumbnailHelper.GetIconAsync(Item.Path, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - Preview.Thumbnail = thumbnail ?? new SvgImageSource(new Uri("ms-appx:///Assets/Peek/DefaultFileIcon.svg")); + Preview.Thumbnail = thumbnail ?? new SvgImageSource(new Uri("ms-appx:///Assets/Peek/DefaultFileIcon.svg")); + } }); }); } @@ -110,7 +118,11 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer { cancellationToken.ThrowIfCancellationRequested(); - Preview.MediaSource = MediaSource.CreateFromStorageFile(storageFile); + if (Preview != null) + { + _mediaSource = MediaSource.CreateFromStorageFile(storageFile); + Preview.MediaSource = _mediaSource; + } }); }); } @@ -123,6 +135,11 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer await Dispatcher.RunOnUiThread(() => { + if (Preview == null) + { + return; + } + cancellationToken.ThrowIfCancellationRequested(); Preview.Title = PropertyStoreHelper.TryGetStringProperty(Item.Path, PropertyKey.MusicTitle) ?? Item.Name[..^Item.Extension.Length]; @@ -160,6 +177,22 @@ namespace Peek.FilePreviewer.Previewers.MediaPreviewer return _supportedFileTypes.Contains(item.Extension); } + public void Dispose() + { + Unload(); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Explicitly unloads the preview and releases file resources. + /// </summary> + public void Unload() + { + _mediaSource?.Dispose(); + _mediaSource = null; + Preview = null; + } + private static readonly HashSet<string> _supportedFileTypes = new() { ".aac", diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/ImagePreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/ImagePreviewer.cs index 47e488c4d3..fd190bf72a 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/ImagePreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/ImagePreviewer.cs @@ -54,8 +54,6 @@ namespace Peek.FilePreviewer.Previewers private bool IsPng() => Item.Extension == ".png"; - private bool IsSvg() => Item.Extension == ".svg"; - private bool IsQoi() => Item.Extension == ".qoi"; private DispatcherQueue Dispatcher { get; } @@ -63,7 +61,7 @@ namespace Peek.FilePreviewer.Previewers private static readonly HashSet<string> _supportedFileTypes = BitmapDecoder.GetDecoderInformationEnumerator() .SelectMany(di => di.FileExtensions) - .Union([".svg", ".qoi"]) + .Union([".qoi"]) .ToHashSet(StringComparer.OrdinalIgnoreCase); public static bool IsItemSupported(IFileSystemItem item) @@ -75,15 +73,7 @@ namespace Peek.FilePreviewer.Previewers { cancellationToken.ThrowIfCancellationRequested(); - if (IsSvg()) - { - var size = await Task.Run(Item.GetSvgSize); - if (size != null) - { - ImageSize = size.Value; - } - } - else if (IsQoi()) + if (IsQoi()) { var size = await Task.Run(Item.GetQoiSize); if (size != null) @@ -176,31 +166,16 @@ namespace Peek.FilePreviewer.Previewers { cancellationToken.ThrowIfCancellationRequested(); - using FileStream stream = ReadHelper.OpenReadOnly(Item.Path); - - if (IsSvg()) - { - var source = new SvgImageSource(); - source.RasterizePixelHeight = ImageSize?.Height ?? 0; - source.RasterizePixelWidth = ImageSize?.Width ?? 0; - - var loadStatus = await source.SetSourceAsync(stream.AsRandomAccessStream()); - if (loadStatus != SvgImageSourceLoadStatus.Success) - { - Logger.LogError("Error loading SVG: " + loadStatus.ToString()); - throw new ImageLoadingException(nameof(source)); - } - - Preview = source; - } - else if (IsQoi()) + if (IsQoi()) { + using FileStream stream = ReadHelper.OpenReadOnly(Item.Path); using var bitmap = QoiImage.FromStream(stream); Preview = await BitmapHelper.BitmapToImageSource(bitmap, true, cancellationToken); } else { + using FileStream stream = ReadHelper.OpenReadOnly(Item.Path); Preview = new BitmapImage(); await ((BitmapImage)Preview).SetSourceAsync(stream.AsRandomAccessStream()); } diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs index 0a80661bfa..061d3eca47 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/MediaPreviewer/VideoPreviewer.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; +using ManagedCommon; using Microsoft.UI.Dispatching; using Peek.Common.Extensions; using Peek.Common.Helpers; @@ -16,12 +17,16 @@ using Peek.FilePreviewer.Models; using Peek.FilePreviewer.Previewers.Interfaces; using Windows.Foundation; using Windows.Media.Core; +using Windows.Media.MediaProperties; +using Windows.Media.Transcoding; using Windows.Storage; namespace Peek.FilePreviewer.Previewers { public partial class VideoPreviewer : ObservableObject, IVideoPreviewer, IDisposable { + private MediaSource? _mediaSource; + [ObservableProperty] private MediaSource? preview; @@ -31,6 +36,9 @@ namespace Peek.FilePreviewer.Previewers [ObservableProperty] private Size videoSize; + [ObservableProperty] + private string? missingCodecName; + public VideoPreviewer(IFileSystemItem file) { Item = file; @@ -50,12 +58,14 @@ namespace Peek.FilePreviewer.Previewers public void Dispose() { + Unload(); GC.SuppressFinalize(this); } public async Task LoadPreviewAsync(CancellationToken cancellationToken) { State = PreviewState.Loading; + MissingCodecName = null; VideoTask = LoadVideoAsync(cancellationToken); cancellationToken.ThrowIfCancellationRequested(); await VideoTask; @@ -90,6 +100,35 @@ namespace Peek.FilePreviewer.Previewers }); } + private async Task<string> GetMissingCodecAsync(StorageFile? file) + { + try + { + var profile = await MediaEncodingProfile.CreateFromFileAsync(file); + + if (profile.Video != null) + { + var codecQuery = new CodecQuery(); + var decoders = await codecQuery.FindAllAsync( + CodecKind.Video, + CodecCategory.Decoder, + profile.Video.Subtype); + + return decoders.Count > 0 ? string.Empty : profile.Video.Subtype; + } + else + { + Logger.LogWarning($"No video profile found for file {file?.Path}. Cannot determine codec support."); + } + } + catch (Exception ex) + { + Logger.LogError($"Error checking codec support for file {file?.Path}: {ex.Message}"); + } + + return string.Empty; + } + private Task<bool> LoadVideoAsync(CancellationToken cancellationToken) { return TaskExtension.RunSafe(async () => @@ -98,11 +137,19 @@ namespace Peek.FilePreviewer.Previewers var storageFile = await Item.GetStorageItemAsync() as StorageFile; + var missingCodecName = await GetMissingCodecAsync(storageFile); + await Dispatcher.RunOnUiThread(() => { cancellationToken.ThrowIfCancellationRequested(); - Preview = MediaSource.CreateFromStorageFile(storageFile); + if (!string.IsNullOrEmpty(missingCodecName)) + { + MissingCodecName = missingCodecName; + } + + _mediaSource = MediaSource.CreateFromStorageFile(storageFile); + Preview = _mediaSource; }); }); } @@ -112,6 +159,16 @@ namespace Peek.FilePreviewer.Previewers return !(VideoTask?.Result ?? true); } + /// <summary> + /// Explicitly unloads the preview and releases file resources. + /// </summary> + public void Unload() + { + _mediaSource?.Dispose(); + _mediaSource = null; + Preview = null; + } + private static readonly HashSet<string> _supportedFileTypes = new() { ".mp4", ".3g2", ".3gp", ".3gp2", ".3gpp", ".asf", ".avi", ".m2t", ".m2ts", diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/Helpers/IStreamWrapper.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/Helpers/IStreamWrapper.cs index 91783781e3..acf54e9f91 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/Helpers/IStreamWrapper.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/Helpers/IStreamWrapper.cs @@ -53,9 +53,9 @@ namespace Peek.FilePreviewer.Previewers.Helpers } } - public void Seek(long dlibMove, STREAM_SEEK dwOrigin, [Optional] ulong* plibNewPosition) + public void Seek(long dlibMove, SeekOrigin dwOrigin, [Optional] ulong* plibNewPosition) { - long position = Stream.Seek(dlibMove, (SeekOrigin)dwOrigin); + long position = Stream.Seek(dlibMove, dwOrigin); if (plibNewPosition != null) { *plibNewPosition = (ulong)position; @@ -82,7 +82,7 @@ namespace Peek.FilePreviewer.Previewers.Helpers throw new NotSupportedException(); } - public void LockRegion(ulong libOffset, ulong cb, uint dwLockType) + public void LockRegion(ulong libOffset, ulong cb, LOCKTYPE dwLockType) { throw new NotSupportedException(); } @@ -92,7 +92,7 @@ namespace Peek.FilePreviewer.Previewers.Helpers throw new NotSupportedException(); } - public void Stat(STATSTG* pstatstg, uint grfStatFlag) + public void Stat(STATSTG* pstatstg, STATFLAG grfStatFlag) { throw new NotSupportedException(); } diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/ShellPreviewHandlerPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/ShellPreviewHandlerPreviewer.cs index 248a18ec2c..7dbcada42d 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/ShellPreviewHandlerPreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/ShellPreviewHandlerPreviewer/ShellPreviewHandlerPreviewer.cs @@ -90,15 +90,13 @@ namespace Peek.FilePreviewer.Previewers unsafe { // This runs the preview handler in a separate process (prevhost.exe) - // TODO: Figure out how to get it to run in a low integrity level if (!HandlerFactories.TryGetValue(clsid, out var factory)) { - var hr = PInvoke.CoGetClassObject(clsid, CLSCTX.CLSCTX_LOCAL_SERVER, null, typeof(IClassFactory).GUID, out var pFactory); + var hr = PInvoke_FilePreviewer.CoGetClassObject(clsid, CLSCTX.CLSCTX_LOCAL_SERVER, null, typeof(IClassFactory).GUID, out object pFactory); Marshal.ThrowExceptionForHR(hr); // Storing the factory in memory helps makes the handlers load faster - // TODO: Maybe free them after some inactivity or when Peek quits? - factory = (IClassFactory)Marshal.GetObjectForIUnknown((IntPtr)pFactory); + factory = (IClassFactory)pFactory; factory.LockServer(true); HandlerFactories.AddOrUpdate(clsid, factory, (_, _) => factory); } @@ -149,7 +147,7 @@ namespace Peek.FilePreviewer.Previewers } else if (previewHandler is IInitializeWithItem initWithItem) { - var hr = PInvoke.SHCreateItemFromParsingName(FileItem.Path, null, typeof(IShellItem).GUID, out var item); + var hr = PInvoke_FilePreviewer.SHCreateItemFromParsingName(FileItem.Path, null, typeof(IShellItem).GUID, out var item); Marshal.ThrowExceptionForHR(hr); initWithItem.Initialize((IShellItem)item, STGM_READ); @@ -213,6 +211,20 @@ namespace Peek.FilePreviewer.Previewers return !string.IsNullOrEmpty(GetPreviewHandlerGuid(item.Extension)); } + public static void ReleaseHandlerFactories() + { + foreach (var factory in HandlerFactories.Values) + { + try + { + Marshal.FinalReleaseComObject(factory); + } + catch + { + } + } + } + private static string? GetPreviewHandlerGuid(string fileExt) { const string PreviewHandlerKeyPath = "shellex\\{8895b1c6-b41f-4c1c-a562-0d564250836f}"; diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/WebBrowserPreviewer/Helpers/ReadHelper.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/WebBrowserPreviewer/Helpers/ReadHelper.cs index e7bf79ecb1..52319dc1b6 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/WebBrowserPreviewer/Helpers/ReadHelper.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/WebBrowserPreviewer/Helpers/ReadHelper.cs @@ -17,7 +17,7 @@ namespace Peek.FilePreviewer.Previewers DetectionResult result = CharsetDetector.DetectFromFile(path); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - // Check if the detected encoding is not null, otherwise default to UTF-8 + // Check if the detected encoding is not null; otherwise, default to UTF-8 Encoding encodingToUse = result.Detected?.Encoding ?? Encoding.UTF8; using var fs = OpenReadOnly(path); diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/WebBrowserPreviewer/WebBrowserPreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/WebBrowserPreviewer/WebBrowserPreviewer.cs index 03f4461e50..4516b7fc29 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/WebBrowserPreviewer/WebBrowserPreviewer.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/WebBrowserPreviewer/WebBrowserPreviewer.cs @@ -33,6 +33,10 @@ namespace Peek.FilePreviewer.Previewers // Markdown ".md", + + // SVG - using WebView2 for better compatibility with complex SVGs + // (e.g., from Adobe Illustrator, Inkscape) + ".svg", }; [ObservableProperty] @@ -109,29 +113,41 @@ namespace Peek.FilePreviewer.Previewers await Dispatcher.RunOnUiThread(async () => { - bool isHtml = File.Extension == ".html" || File.Extension == ".htm"; - bool isMarkdown = File.Extension == ".md"; + string extension = File.Extension; - bool supportedByMonaco = MonacoHelper.SupportedMonacoFileTypes.Contains(File.Extension); - bool useMonaco = supportedByMonaco && !isHtml && !isMarkdown; + // Default: non-dev file preview with standard context menu + IsDevFilePreview = false; + CustomContextMenu = false; - IsDevFilePreview = supportedByMonaco; - CustomContextMenu = useMonaco; - - if (useMonaco) - { - var raw = await ReadHelper.Read(File.Path.ToString()); - Preview = new Uri(MonacoHelper.PreviewTempFile(raw, File.Extension, TempFolderPath.Path, _previewSettings.SourceCodeTryFormat, _previewSettings.SourceCodeWrapText, _previewSettings.SourceCodeStickyScroll, _previewSettings.SourceCodeFontSize, _previewSettings.SourceCodeMinimap)); - } - else if (isMarkdown) + // Determine preview strategy based on file type priority + if (extension == ".md") { + // Markdown files use custom renderer var raw = await ReadHelper.Read(File.Path.ToString()); Preview = new Uri(MarkdownHelper.PreviewTempFile(raw, File.Path, TempFolderPath.Path)); } - else + else if (extension == ".svg") + { + // SVG files are rendered directly by WebView2 for better compatibility + // with complex SVGs from Adobe Illustrator, Inkscape, etc. + Preview = new Uri(File.Path); + } + else if (extension == ".html" || extension == ".htm") { // Simple html file to preview. Shouldn't do things like enabling scripts or using a virtual mapped directory. - IsDevFilePreview = false; + Preview = new Uri(File.Path); + } + else if (MonacoHelper.SupportedMonacoFileTypes.Contains(extension)) + { + // Source code files use Monaco editor + IsDevFilePreview = true; + CustomContextMenu = true; + var raw = await ReadHelper.Read(File.Path.ToString()); + Preview = new Uri(MonacoHelper.PreviewTempFile(raw, extension, TempFolderPath.Path, _previewSettings.SourceCodeTryFormat, _previewSettings.SourceCodeWrapText, _previewSettings.SourceCodeStickyScroll, _previewSettings.SourceCodeFontSize, _previewSettings.SourceCodeMinimap)); + } + else + { + // Fallback for other supported file types (e.g., PDF) Preview = new Uri(File.Path); } }); diff --git a/src/modules/peek/Peek.UI/Extensions/HWNDExtensions.cs b/src/modules/peek/Peek.UI/Extensions/HWNDExtensions.cs index 2a29aadb70..54df9dab21 100644 --- a/src/modules/peek/Peek.UI/Extensions/HWNDExtensions.cs +++ b/src/modules/peek/Peek.UI/Extensions/HWNDExtensions.cs @@ -48,21 +48,21 @@ namespace Peek.UI.Extensions internal static HWND FindChildWindow(this HWND windowHandle, string className) { - return PInvoke.FindWindowEx(windowHandle, HWND.Null, className, null); + return PInvoke_PeekUI.FindWindowEx(windowHandle, HWND.Null, className, null); } internal static Size GetMonitorSize(this HWND hwnd) { - var monitor = PInvoke.MonitorFromWindow(hwnd, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); + var monitor = PInvoke_PeekUI.MonitorFromWindow(hwnd, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); MONITORINFO info = default(MONITORINFO); info.cbSize = 40; - PInvoke.GetMonitorInfo(monitor, ref info); + PInvoke_PeekUI.GetMonitorInfo(monitor, ref info); return new Size(info.rcMonitor.Size.Width, info.rcMonitor.Size.Height); } internal static double GetMonitorScale(this HWND hwnd) { - var dpi = PInvoke.GetDpiForWindow(hwnd); + var dpi = PInvoke_PeekUI.GetDpiForWindow(hwnd); var scalingFactor = dpi / 96d; return scalingFactor; } diff --git a/src/modules/peek/Peek.UI/Extensions/WindowExtensions.cs b/src/modules/peek/Peek.UI/Extensions/WindowExtensions.cs index 0112a3877c..393f07c20b 100644 --- a/src/modules/peek/Peek.UI/Extensions/WindowExtensions.cs +++ b/src/modules/peek/Peek.UI/Extensions/WindowExtensions.cs @@ -28,12 +28,12 @@ namespace Peek.UI.Extensions // If the window is maximized, restore to normal state before change its size var placement = default(WINDOWPLACEMENT); - if (PInvoke.GetWindowPlacement(hwndToCenter, ref placement)) + if (PInvoke_PeekUI.GetWindowPlacement(hwndToCenter, ref placement)) { if (placement.showCmd == SHOW_WINDOW_CMD.SW_MAXIMIZE) { placement.showCmd = SHOW_WINDOW_CMD.SW_SHOWNORMAL; - if (!PInvoke.SetWindowPlacement(hwndToCenter, in placement)) + if (!PInvoke_PeekUI.SetWindowPlacement(hwndToCenter, in placement)) { Logger.LogError($"SetWindowPlacement failed with error {Marshal.GetLastWin32Error()}"); } @@ -44,12 +44,12 @@ namespace Peek.UI.Extensions Logger.LogError($"GetWindowPlacement failed with error {Marshal.GetLastWin32Error()}"); } - var monitor = PInvoke.MonitorFromWindow(hwndDesktop, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); + var monitor = PInvoke_PeekUI.MonitorFromWindow(hwndDesktop, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST); MONITORINFO info = default(MONITORINFO); info.cbSize = 40; - PInvoke.GetMonitorInfo(monitor, ref info); - var dpi = PInvoke.GetDpiForWindow(new HWND(hwndDesktop)); - PInvoke.GetWindowRect(hwndToCenter, out RECT windowRect); + PInvoke_PeekUI.GetMonitorInfo(monitor, ref info); + var dpi = PInvoke_PeekUI.GetDpiForWindow(new HWND((nint)hwndDesktop)); + PInvoke_PeekUI.GetWindowRect(hwndToCenter, out RECT windowRect); var scalingFactor = dpi / 96d; var w = width.HasValue ? (int)(width * scalingFactor) : windowRect.right - windowRect.left; var h = height.HasValue ? (int)(height * scalingFactor) : windowRect.bottom - windowRect.top; @@ -63,7 +63,7 @@ namespace Peek.UI.Extensions private static void SetWindowPosOrThrow(HWND hWnd, HWND hWndInsertAfter, int x, int y, int cx, int cy, SET_WINDOW_POS_FLAGS uFlags) { - bool result = PInvoke.SetWindowPos(hWnd, hWndInsertAfter, x, y, cx, cy, uFlags); + bool result = PInvoke_PeekUI.SetWindowPos(hWnd, hWndInsertAfter, x, y, cx, cy, uFlags); if (!result) { Marshal.ThrowExceptionForHR(Marshal.GetLastWin32Error()); diff --git a/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs b/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs index 466ae85028..89e814b6f4 100644 --- a/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs +++ b/src/modules/peek/Peek.UI/Helpers/FileExplorerHelper.cs @@ -58,7 +58,7 @@ namespace Peek.UI.Helpers object? oNull2 = null; var serviceProvider = (IServiceProvider)shellWindows.FindWindowSW(ref oNull1, ref oNull2, SWC_DESKTOP, out int pHWND, SWFO_NEEDDISPATCH); - var shellBrowser = (IShellBrowser)serviceProvider.QueryService(PInvoke.SID_STopLevelBrowser, typeof(IShellBrowser).GUID); + var shellBrowser = (IShellBrowser)serviceProvider.QueryService(PInvoke_PeekUI.SID_STopLevelBrowser, typeof(IShellBrowser).GUID); IShellItemArray? shellItemArray = GetShellItemArray(shellBrowser, onlySelectedFiles); return shellItemArray; @@ -81,7 +81,7 @@ namespace Peek.UI.Helpers if (webBrowserApp.HWND == foregroundWindowHandle) { var serviceProvider = (IServiceProvider)webBrowserApp; - var shellBrowser = (IShellBrowser)serviceProvider.QueryService(PInvoke.SID_STopLevelBrowser, typeof(IShellBrowser).GUID); + var shellBrowser = (IShellBrowser)serviceProvider.QueryService(PInvoke_PeekUI.SID_STopLevelBrowser, typeof(IShellBrowser).GUID); shellBrowser.GetWindow(out IntPtr shellBrowserHandle); if (activeTab == shellBrowserHandle) @@ -115,19 +115,55 @@ namespace Peek.UI.Helpers } /// <summary> - /// Returns whether the caret is visible in the specified window. + /// Heuristic to decide whether the user is actively typing so we should suppress Peek activation. + /// Current logic: + /// - If the focused control class name contains "Edit" or "Input" (e.g. Explorer search box or in-place rename), return true. + /// - Otherwise fall back to the legacy GUI_CARETBLINKING flag (covers other text contexts where class name differs but caret blinks). + /// - If we fail to retrieve GUI thread info, we default to false (do not suppress) to avoid blocking activation due to transient failures. + /// NOTE: This intentionally no longer walks ancestor chains; any Edit/Input focus inside the same top-level Explorer/Desktop window is treated as typing. /// </summary> - private static bool CaretVisible(HWND hwnd) + private static unsafe bool CaretVisible(HWND hwnd) { - GUITHREADINFO guiThreadInfo = new() { cbSize = (uint)Marshal.SizeOf<GUITHREADINFO>() }; - - // Get information for the foreground thread - if (PInvoke.GetGUIThreadInfo(0, ref guiThreadInfo)) + GUITHREADINFO gi = new() { cbSize = (uint)Marshal.SizeOf<GUITHREADINFO>() }; + if (!PInvoke_PeekUI.GetGUIThreadInfo(0, ref gi)) { - return guiThreadInfo.hwndActive == hwnd && (guiThreadInfo.flags & GUITHREADINFO_FLAGS.GUI_CARETBLINKING) != 0; + return false; // fail open (allow activation) } - return false; + // Quick sanity: restrict to same top-level window (match prior behavior) + if (gi.hwndActive != hwnd) + { + return false; + } + + HWND focus = gi.hwndFocus; + if (focus == HWND.Null) + { + return false; + } + + // Get focused window class (96 chars buffer; GetClassNameW bounds writes). Treat any class containing + // "Edit" or "Input" as a text field (search / titlebar) and suppress Peek. + Span<char> buf = stackalloc char[96]; + fixed (char* p = buf) + { + int len = PInvoke_PeekUI.GetClassName(focus, p, buf.Length); + if (len > 0) + { + var focusClass = new string(p, 0, len); + if (focusClass.Contains("Edit", StringComparison.OrdinalIgnoreCase) || focusClass.Contains("Input", StringComparison.OrdinalIgnoreCase)) + { + return true; // treat any Edit/Input focus as typing. + } + else + { + ManagedCommon.Logger.LogDebug($"Peek suppression: focus class{focusClass}"); + } + } + } + + // Fallback: original caret blinking heuristic for other text-entry contexts + return (gi.flags & GUITHREADINFO_FLAGS.GUI_CARETBLINKING) != 0; } } } diff --git a/src/modules/peek/Peek.UI/MainWindowViewModel.cs b/src/modules/peek/Peek.UI/MainWindowViewModel.cs index db54e346cc..fb2b326c17 100644 --- a/src/modules/peek/Peek.UI/MainWindowViewModel.cs +++ b/src/modules/peek/Peek.UI/MainWindowViewModel.cs @@ -62,6 +62,12 @@ namespace Peek.UI [ObservableProperty] private IFileSystemItem? _currentItem; + /// <summary> + /// Work around missing navigation when peeking from CLI. + /// TODO: Implement navigation when peeking from CLI. + /// </summary> + private bool _isFromCli; + partial void OnCurrentItemChanged(IFileSystemItem? value) { WindowTitle = value != null @@ -129,7 +135,24 @@ namespace Peek.UI NavigationThrottleTimer.Interval = TimeSpan.FromMilliseconds(NavigationThrottleDelayMs); } - public void Initialize(HWND foregroundWindowHandle) + public void Initialize(SelectedItem selectedItem) + { + switch (selectedItem) + { + case SelectedItemByPath selectedItemByPath: + InitializeFromCli(selectedItemByPath.Path); + break; + + case SelectedItemByWindowHandle selectedItemByWindowHandle: + InitializeFromExplorer(selectedItemByWindowHandle.WindowHandle); + break; + + default: + throw new NotImplementedException($"Invalid type of selected item: '{selectedItem.GetType().FullName}'"); + } + } + + private void InitializeFromExplorer(HWND foregroundWindowHandle) { try { @@ -141,10 +164,20 @@ namespace Peek.UI } _currentIndex = DisplayIndex = 0; + _isFromCli = false; CurrentItem = (Items != null && Items.Count > 0) ? Items[0] : null; } + private void InitializeFromCli(string path) + { + // TODO: implement navigation + _isFromCli = true; + Items = null; + _currentIndex = DisplayIndex = 0; + CurrentItem = new FileItem(path, Path.GetFileName(path)); + } + public void Uninitialize() { _currentIndex = DisplayIndex = 0; @@ -153,6 +186,7 @@ namespace Peek.UI Items = null; _navigationDirection = NavigationDirection.Forwards; IsErrorVisible = false; + _isFromCli = false; } public void AttemptPreviousNavigation() => Navigate(NavigationDirection.Backwards); @@ -166,6 +200,12 @@ namespace Peek.UI return; } + // TODO: implement navigation. + if (_isFromCli) + { + return; + } + if (Items == null || Items.Count == _deletedItemIndexes.Count) { _currentIndex = DisplayIndex = 0; @@ -348,6 +388,13 @@ namespace Peek.UI IsErrorVisible = true; } + public void ShowError(string message) + { + IsErrorVisible = false; + ErrorMessage = message; + IsErrorVisible = true; + } + private void NavigationThrottleTimer_Tick(object? sender, object e) { if (sender == null) diff --git a/src/modules/peek/Peek.UI/Models/SelectedItem.cs b/src/modules/peek/Peek.UI/Models/SelectedItem.cs new file mode 100644 index 0000000000..9d0cc6568a --- /dev/null +++ b/src/modules/peek/Peek.UI/Models/SelectedItem.cs @@ -0,0 +1,11 @@ +// 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 Peek.UI.Models +{ + public abstract class SelectedItem + { + public abstract bool Matches(string? path); + } +} diff --git a/src/modules/peek/Peek.UI/Models/SelectedItemByPath.cs b/src/modules/peek/Peek.UI/Models/SelectedItemByPath.cs new file mode 100644 index 0000000000..5f53865bfd --- /dev/null +++ b/src/modules/peek/Peek.UI/Models/SelectedItemByPath.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Peek.UI.Models +{ + public class SelectedItemByPath : SelectedItem + { + public string Path { get; } + + public SelectedItemByPath(string path) + { + Path = path; + } + + public override bool Matches(string? path) + { + return string.Equals(Path, path, StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/modules/peek/Peek.UI/Models/SelectedItemByWindowHandle.cs b/src/modules/peek/Peek.UI/Models/SelectedItemByWindowHandle.cs new file mode 100644 index 0000000000..e93b2f94ca --- /dev/null +++ b/src/modules/peek/Peek.UI/Models/SelectedItemByWindowHandle.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Peek.UI.Extensions; +using Peek.UI.Helpers; +using Windows.Win32.Foundation; + +namespace Peek.UI.Models +{ + public class SelectedItemByWindowHandle : SelectedItem + { + public HWND WindowHandle { get; } + + public SelectedItemByWindowHandle(HWND windowHandle) + { + WindowHandle = windowHandle; + } + + public override bool Matches(string? path) + { + var selectedItems = FileExplorerHelper.GetSelectedItems(WindowHandle); + var selectedItemsCount = selectedItems?.GetCount() ?? 0; + if (selectedItems == null || selectedItemsCount == 0 || selectedItemsCount > 1) + { + return false; + } + + var fileExplorerSelectedItemPath = selectedItems.GetItemAt(0).ToIFileSystemItem().Path; + var currentItemPath = path; + return fileExplorerSelectedItemPath != null && currentItemPath != null && fileExplorerSelectedItemPath != currentItemPath; + } + } +} diff --git a/src/modules/peek/Peek.UI/NativeMethods.json b/src/modules/peek/Peek.UI/NativeMethods.json new file mode 100644 index 0000000000..ff8e941bec --- /dev/null +++ b/src/modules/peek/Peek.UI/NativeMethods.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "public": false, + "className": "PInvoke_PeekUI" +} \ No newline at end of file diff --git a/src/modules/peek/Peek.UI/Peek.UI.csproj b/src/modules/peek/Peek.UI/Peek.UI.csproj index 673ff5c9e7..6448ec7322 100644 --- a/src/modules/peek/Peek.UI/Peek.UI.csproj +++ b/src/modules/peek/Peek.UI/Peek.UI.csproj @@ -1,8 +1,8 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> - <Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <AssemblyName>PowerToys.Peek.UI</AssemblyName> @@ -10,7 +10,7 @@ <AssemblyDescription>PowerToys Peek UI</AssemblyDescription> <RootNamespace>Peek.UI</RootNamespace> <OutputType>WinExe</OutputType> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> <ApplicationManifest>app.manifest</ApplicationManifest> <UseWinUI>true</UseWinUI> <Platforms>x64;ARM64</Platforms> diff --git a/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs index a433b00e43..b89e871a4d 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/App.xaml.cs @@ -3,15 +3,19 @@ // See the LICENSE file in the project root for more information. using System; - +using System.IO; +using System.Threading; using ManagedCommon; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls.Primitives; using Peek.Common; using Peek.FilePreviewer; using Peek.FilePreviewer.Models; +using Peek.FilePreviewer.Previewers; +using Peek.UI.Models; using Peek.UI.Native; using Peek.UI.Telemetry.Events; using Peek.UI.Views; @@ -22,7 +26,7 @@ namespace Peek.UI /// <summary> /// Provides application-specific behavior to supplement the default Application class. /// </summary> - public partial class App : Application, IApp + public partial class App : Application, IApp, IDisposable { public static int PowerToysPID { get; set; } @@ -35,6 +39,10 @@ namespace Peek.UI private MainWindow? Window { get; set; } + private bool _disposed; + private SelectedItem? _selectedItem; + private bool _launchedFromCli; + /// <summary> /// Initializes a new instance of the <see cref="App"/> class. /// Initializes the singleton application object. This is the first line of authored code @@ -51,22 +59,22 @@ namespace Peek.UI InitializeComponent(); Logger.InitializeLogger("\\Peek\\Logs"); - Host = Microsoft.Extensions.Hosting.Host. - CreateDefaultBuilder(). - UseContentRoot(AppContext.BaseDirectory). - ConfigureServices((context, services) => - { - // Core Services - services.AddTransient<NeighboringItemsQuery>(); - services.AddSingleton<IUserSettings, UserSettings>(); - services.AddSingleton<IPreviewSettings, PreviewSettings>(); + Host = Microsoft.Extensions.Hosting.Host + .CreateDefaultBuilder() + .UseContentRoot(AppContext.BaseDirectory) + .ConfigureServices((context, services) => + { + // Core Services + services.AddTransient<NeighboringItemsQuery>(); + services.AddSingleton<IUserSettings, UserSettings>(); + services.AddSingleton<IPreviewSettings, PreviewSettings>(); - // Views and ViewModels - services.AddTransient<TitleBar>(); - services.AddTransient<FilePreview>(); - services.AddTransient<MainWindowViewModel>(); - }). - Build(); + // Views and ViewModels + services.AddTransient<TitleBar>(); + services.AddTransient<FilePreview>(); + services.AddTransient<MainWindowViewModel>(); + }) + .Build(); UnhandledException += App_UnhandledException; } @@ -98,6 +106,7 @@ namespace Peek.UI var cmdArgs = Environment.GetCommandLineArgs(); if (cmdArgs?.Length > 1) { + // Check if the last argument is a PowerToys Runner PID if (int.TryParse(cmdArgs[^1], out int powerToysRunnerPid)) { RunnerHelper.WaitForPowerToysRunner(powerToysRunnerPid, () => @@ -106,11 +115,28 @@ namespace Peek.UI Environment.Exit(0); }); } + else + { + // Command line argument is a file path - activate Peek with that file + string filePath = cmdArgs[^1]; + if (File.Exists(filePath) || Directory.Exists(filePath)) + { + _selectedItem = new SelectedItemByPath(filePath); + _launchedFromCli = true; + OnShowPeek(); + return; + } + else + { + Logger.LogError($"Command line argument is not a valid file or directory: {filePath}"); + } + } } - NativeEventWaiter.WaitForEventLoop(Constants.ShowPeekEvent(), OnPeekHotkey); + NativeEventWaiter.WaitForEventLoop(Constants.ShowPeekEvent(), OnShowPeek); NativeEventWaiter.WaitForEventLoop(Constants.TerminatePeekEvent(), () => { + ShellPreviewHandlerPreviewer.ReleaseHandlerFactories(); EtwTrace?.Dispose(); Environment.Exit(0); }); @@ -124,11 +150,16 @@ namespace Peek.UI /// <summary> /// Handle Peek hotkey /// </summary> - private void OnPeekHotkey() + private void OnShowPeek() { - // Need to read the foreground HWND before activating Peek to avoid focus stealing - // Foreground HWND must always be Explorer or Desktop - var foregroundWindowHandle = Windows.Win32.PInvoke.GetForegroundWindow(); + // null means explorer, not null means CLI + if (_selectedItem == null) + { + // Need to read the foreground HWND before activating Peek to avoid focus stealing + // Foreground HWND must always be Explorer or Desktop + var foregroundWindowHandle = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow(); + _selectedItem = new SelectedItemByWindowHandle(foregroundWindowHandle); + } bool firstActivation = false; @@ -138,7 +169,38 @@ namespace Peek.UI Window = new MainWindow(); } - Window.Toggle(firstActivation, foregroundWindowHandle); + Window.Toggle(firstActivation, _selectedItem, _launchedFromCli); + _launchedFromCli = false; + _selectedItem = null; + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // dispose managed state (managed objects) + } + + // free unmanaged resources (unmanaged objects) and override finalizer + // set large fields to null + _disposed = true; + } + } + + /* // override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources + // ~App() + // { + // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + // Dispose(disposing: false); + // } */ + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); } } } diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml index f8e3166b0a..26dbb19b24 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml @@ -50,7 +50,8 @@ Item="{x:Bind ViewModel.CurrentItem, Mode=OneWay}" NumberOfFiles="{x:Bind ViewModel.DisplayItemCount, Mode=OneWay}" PreviewSizeChanged="FilePreviewer_PreviewSizeChanged" - ScalingFactor="{x:Bind ViewModel.ScalingFactor, Mode=OneWay}" /> + ScalingFactor="{x:Bind ViewModel.ScalingFactor, Mode=OneWay}" + Visibility="{x:Bind ContentVisibility(ViewModel.IsErrorVisible), Mode=OneWay}" /> <InfoBar x:Name="ErrorInfoBar" @@ -59,6 +60,7 @@ Grid.RowSpan="2" Margin="4,0,4,6" VerticalAlignment="Bottom" + Closed="ErrorInfoBar_Closed" IsOpen="{x:Bind ViewModel.IsErrorVisible, Mode=TwoWay}" Message="{x:Bind ViewModel.ErrorMessage, Mode=OneWay}" Severity="Error" /> diff --git a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs index 03e48c5ceb..15c7812148 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs +++ b/src/modules/peek/Peek.UI/PeekXAML/MainWindow.xaml.cs @@ -14,9 +14,12 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; using Peek.Common.Constants; using Peek.Common.Extensions; +using Peek.Common.Helpers; using Peek.FilePreviewer.Models; +using Peek.FilePreviewer.Previewers; using Peek.UI.Extensions; using Peek.UI.Helpers; +using Peek.UI.Models; using Peek.UI.Telemetry.Events; using Windows.Foundation; using WinUIEx; @@ -37,6 +40,7 @@ namespace Peek.UI /// dialog is open at a time. /// </summary> private bool _isDeleteInProgress; + private bool _exitAfterClose; public MainWindow() { @@ -115,12 +119,17 @@ namespace Peek.UI /// <summary> /// Toggling the window visibility and querying files when necessary. /// </summary> - public void Toggle(bool firstActivation, Windows.Win32.Foundation.HWND foregroundWindowHandle) + public void Toggle(bool firstActivation, SelectedItem selectedItem, bool exitAfterClose) { + if (exitAfterClose) + { + _exitAfterClose = true; + } + if (firstActivation) { Activate(); - Initialize(foregroundWindowHandle); + Initialize(selectedItem); return; } @@ -131,9 +140,9 @@ namespace Peek.UI if (AppWindow.IsVisible) { - if (IsNewSingleSelectedItem(foregroundWindowHandle)) + if (IsNewSingleSelectedItem(selectedItem)) { - Initialize(foregroundWindowHandle); + Initialize(selectedItem); Activate(); // Brings existing window into focus in case it was previously minimized } else @@ -143,7 +152,7 @@ namespace Peek.UI } else { - Initialize(foregroundWindowHandle); + Initialize(selectedItem); } } @@ -181,12 +190,20 @@ namespace Peek.UI Uninitialize(); } - private void Initialize(Windows.Win32.Foundation.HWND foregroundWindowHandle) + private void Initialize(SelectedItem selectedItem) { var bootTime = new System.Diagnostics.Stopwatch(); bootTime.Start(); - ViewModel.Initialize(foregroundWindowHandle); + ViewModel.Initialize(selectedItem); + + // If no files were found (e.g., user is typing in rename/search box, or in virtual folders), + // don't show anything - just return silently to avoid stealing focus + if (ViewModel.CurrentItem == null) + { + return; + } + ViewModel.ScalingFactor = this.GetMonitorScale(); this.Content.KeyUp += Content_KeyUp; @@ -204,6 +221,13 @@ namespace Peek.UI ViewModel.ScalingFactor = 1; this.Content.KeyUp -= Content_KeyUp; + + ShellPreviewHandlerPreviewer.ReleaseHandlerFactories(); + + if (_exitAfterClose) + { + Environment.Exit(0); + } } /// <summary> @@ -213,7 +237,7 @@ namespace Peek.UI /// <param name="e">PreviewSizeChangedArgs</param> private void FilePreviewer_PreviewSizeChanged(object sender, PreviewSizeChangedArgs e) { - var foregroundWindowHandle = Windows.Win32.PInvoke.GetForegroundWindow(); + var foregroundWindowHandle = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow(); var monitorSize = foregroundWindowHandle.GetMonitorSize(); var monitorScale = foregroundWindowHandle.GetMonitorScale(); @@ -269,20 +293,11 @@ namespace Peek.UI Uninitialize(); } - private bool IsNewSingleSelectedItem(Windows.Win32.Foundation.HWND foregroundWindowHandle) + private bool IsNewSingleSelectedItem(SelectedItem selectedItem) { try { - var selectedItems = FileExplorerHelper.GetSelectedItems(foregroundWindowHandle); - var selectedItemsCount = selectedItems?.GetCount() ?? 0; - if (selectedItems == null || selectedItemsCount == 0 || selectedItemsCount > 1) - { - return false; - } - - var fileExplorerSelectedItemPath = selectedItems.GetItemAt(0).ToIFileSystemItem().Path; - var currentItemPath = ViewModel.CurrentItem?.Path; - return fileExplorerSelectedItemPath != null && currentItemPath != null && fileExplorerSelectedItemPath != currentItemPath; + return selectedItem.Matches(ViewModel.CurrentItem?.Path); } catch (Exception ex) { @@ -296,5 +311,24 @@ namespace Peek.UI { themeListener?.Dispose(); } + + /// <summary> + /// Returns Visibility.Collapsed when error is showing, Visibility.Visible when not. + /// </summary> + public Visibility ContentVisibility(bool isErrorVisible) + { + return isErrorVisible ? Visibility.Collapsed : Visibility.Visible; + } + + /// <summary> + /// Handle InfoBar closed - if there's no current item, close the window. + /// </summary> + private void ErrorInfoBar_Closed(InfoBar sender, InfoBarClosedEventArgs args) + { + if (ViewModel.CurrentItem == null) + { + Uninitialize(); + } + } } } diff --git a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml index 57e073d8a5..c33a9d9d7d 100644 --- a/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml +++ b/src/modules/peek/Peek.UI/PeekXAML/Views/TitleBar.xaml @@ -93,6 +93,7 @@ Grid.Column="4" VerticalAlignment="Center" Command="{x:Bind PinCommand, Mode=OneWay}" + Style="{StaticResource SubtleButtonStyle}" ToolTipService.ToolTip="{x:Bind PinToolTip(Pinned), Mode=OneWay}"> <Button.Content> <FontIcon diff --git a/src/modules/peek/Peek.UI/Services/UserSettings.cs b/src/modules/peek/Peek.UI/Services/UserSettings.cs index 77257eaf80..e3750fe8e1 100644 --- a/src/modules/peek/Peek.UI/Services/UserSettings.cs +++ b/src/modules/peek/Peek.UI/Services/UserSettings.cs @@ -77,7 +77,7 @@ namespace Peek.UI public UserSettings() { - _settingsUtils = new SettingsUtils(); + _settingsUtils = SettingsUtils.Default; LoadSettingsFromJson(); diff --git a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw index 4ffec5f685..f3dbc0f54d 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -250,9 +250,17 @@ <comment>Dialog showed when an URI is clicked. Button to copy the URI.</comment> </data> <data name="OpenUriDialog.Title" xml:space="preserve"> - <value>Do you want Peek to open the external application?</value> + <value>Do you want Peek to open this link?</value> <comment>Title of the dialog showed when an URI is clicked,"Peek" is the name of the utility. </comment> </data> + <data name="OpenUriWarningBanner.Title" xml:space="preserve"> + <value>Security Warning</value> + <comment>Title of the warning banner in the open URI dialog.</comment> + </data> + <data name="OpenUriWarningBanner.Message" xml:space="preserve"> + <value>This link will open externally. Make sure you trust the source before proceeding.</value> + <comment>Warning message displayed in the banner when opening an external URI.</comment> + </data> <data name="ReadableString_BytesString" xml:space="preserve"> <value> ({1:N0} bytes)</value> <comment>Displays total number of bytes. Don't localize the "{1:N0}" part.</comment> @@ -333,6 +341,10 @@ <value>No more files to preview.</value> <comment>The message to show when there are no files remaining to preview.</comment> </data> + <data name="NoFilesSelected" xml:space="preserve"> + <value>No files selected or this folder is not supported for preview.</value> + <comment>Displayed when Peek is activated in a virtual folder (like Home or Recent) where file selection cannot be retrieved.</comment> + </data> <data name="DeleteFileError_NotFound" xml:space="preserve"> <value>The file cannot be found. Please check if the file has been moved, renamed, or deleted.</value> <comment>Displayed if the file or path was not found</comment> @@ -372,4 +384,10 @@ <data name="DeleteConfirmationDialog_DontWarnCheckbox.Content" xml:space="preserve"> <value>Don't show this warning again</value> </data> + <data name="VideoMissingCodec_WarningMessage" xml:space="preserve"> + <value>Your device doesn't support the {0} format. To play this file, install a codec that supports this format.</value> + </data> + <data name="VideoCodecStoreLink.Text" xml:space="preserve"> + <value>Search in Microsoft Store</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/peek/Peek.UI/app.manifest b/src/modules/peek/Peek.UI/app.manifest index 122bedce24..e45d3c1982 100644 --- a/src/modules/peek/Peek.UI/app.manifest +++ b/src/modules/peek/Peek.UI/app.manifest @@ -19,7 +19,7 @@ 2) System < Windows 10 Anniversary Update --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> <longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware> </windowsSettings> </application> diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_arm64.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_arm64.png new file mode 100644 index 0000000000..e5a9a6bc56 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_arm64.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win10.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win10.png new file mode 100644 index 0000000000..f6a97a8c35 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win11.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win11.png new file mode 100644 index 0000000000..a59986a075 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_2_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_arm64.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_arm64.png new file mode 100644 index 0000000000..4bc50db232 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_arm64.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win10.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win10.png new file mode 100644 index 0000000000..19f470e400 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win11.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win11.png new file mode 100644 index 0000000000..83f2d6ae33 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_4_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_arm64.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_arm64.png new file mode 100644 index 0000000000..9fa92f1ba6 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_arm64.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win10.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win10.png new file mode 100644 index 0000000000..3343f2195d Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win11.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win11.png new file mode 100644 index 0000000000..0878eb24cb Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_5_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_arm64.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_arm64.png new file mode 100644 index 0000000000..edd054d206 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_arm64.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win10.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win10.png new file mode 100644 index 0000000000..c5154374c9 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win11.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win11.png new file mode 100644 index 0000000000..9d6b102cce Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_6_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_arm64.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_arm64.png new file mode 100644 index 0000000000..977a4036af Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_arm64.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win10.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win10.png new file mode 100644 index 0000000000..0cd49bf36e Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win11.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win11.png new file mode 100644 index 0000000000..cf8c972382 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_7_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_arm64.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_arm64.png new file mode 100644 index 0000000000..9f308b7e26 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_arm64.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win10.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win10.png new file mode 100644 index 0000000000..e5fff0dcf6 Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win10.png differ diff --git a/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win11.png b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win11.png new file mode 100644 index 0000000000..b5892351ef Binary files /dev/null and b/src/modules/peek/Peek.UITests/Baseline/PeekFilePreviewTests_TestSingleFilePreview_8_x64Win11.png differ diff --git a/src/modules/peek/Peek.UITests/Peek.UITests.csproj b/src/modules/peek/Peek.UITests/Peek.UITests.csproj new file mode 100644 index 0000000000..0b73d4913a --- /dev/null +++ b/src/modules/peek/Peek.UITests/Peek.UITests.csproj @@ -0,0 +1,44 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <PropertyGroup> + <RootNamespace>PowerToys.Peek.UITests</RootNamespace> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\Peek.UITests\</OutputPath> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="MSTest" /> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_2_arm64.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_4_arm64.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_5_arm64.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_6_arm64.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_7_arm64.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_8_arm64.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_2_x64Win11.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_4_x64Win11.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_5_x64Win11.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_6_x64Win11.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_7_x64Win11.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_8_x64Win11.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_2_x64Win10.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_4_x64Win10.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_5_x64Win10.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_6_x64Win10.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_7_x64Win10.png" /> + <EmbeddedResource Include="Baseline\PeekFilePreviewTests_TestSingleFilePreview_8_x64Win10.png" /> + </ItemGroup> + <ItemGroup> + <Content Include="TestAssets\**\*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> +</Project> diff --git a/src/modules/peek/Peek.UITests/PeekFilePreviewTests.cs b/src/modules/peek/Peek.UITests/PeekFilePreviewTests.cs new file mode 100644 index 0000000000..f2ed1a9c9e --- /dev/null +++ b/src/modules/peek/Peek.UITests/PeekFilePreviewTests.cs @@ -0,0 +1,963 @@ +// 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.Diagnostics; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Peek.UITests; + +[TestClass] +public class PeekFilePreviewTests : UITestBase +{ + // Timeout constants for better maintainability + private const int ExplorerOpenTimeoutSeconds = 15; + private const int PeekWindowTimeoutSeconds = 15; + private const int ExplorerLoadDelayMs = 3000; + private const int ExplorerCheckIntervalMs = 1000; + private const int PeekCheckIntervalMs = 1000; + private const int PeekInitializeDelayMs = 3000; + private const int MaxRetryAttempts = 3; + private const int RetryDelayMs = 3000; + private const int PinActionDelayMs = 500; + + public PeekFilePreviewTests() + : base(PowerToysModule.PowerToysSettings, WindowSize.Small_Vertical) + { + } + + static PeekFilePreviewTests() + { + FixSettingsFileBeforeTests(); + } + + private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; + + private static void FixSettingsFileBeforeTests() + { + try + { + // Default Peek settings + string peekSettingsContent = @"{ + ""name"": ""Peek"", + ""version"": ""1.0"", + ""properties"": { + ""ActivationShortcut"": { + ""win"": false, + ""ctrl"": true, + ""alt"": false, + ""shift"": false, + ""code"": 32, + ""key"": ""Space"" + }, + ""AlwaysRunNotElevated"": { + ""value"": true + }, + ""CloseAfterLosingFocus"": { + ""value"": false + }, + ""ConfirmFileDelete"": { + ""value"": true + }, + ""EnableSpaceToActivate"": { + ""value"": false + } + } + }"; + + // Update Peek module settings + SettingsConfigHelper.UpdateModuleSettings( + "Peek", + peekSettingsContent, + (settings) => + { + // Get or ensure properties section exists + Dictionary<string, object> properties; + + if (settings.TryGetValue("properties", out var propertiesObj)) + { + if (propertiesObj is Dictionary<string, object> dict) + { + properties = dict; + } + else if (propertiesObj is JsonElement jsonElem) + { + properties = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonElem.GetRawText()) + ?? throw new InvalidOperationException("Failed to deserialize properties"); + } + else + { + properties = new Dictionary<string, object>(); + } + } + else + { + properties = new Dictionary<string, object>(); + } + + // Update the required properties + properties["ActivationShortcut"] = new Dictionary<string, object> + { + { "win", false }, + { "ctrl", true }, + { "alt", false }, + { "shift", false }, + { "code", 32 }, + { "key", "Space" }, + }; + + properties["EnableSpaceToActivate"] = new Dictionary<string, object> + { + { "value", false }, + }; + + settings["properties"] = properties; + }); + + Debug.WriteLine("Successfully updated all settings - Peek shortcut configured and all modules except Peek disabled"); + } + catch (Exception ex) + { + Assert.Fail($"ERROR in FixSettingsFileBeforeTests: {ex.Message}"); + } + } + + [TestInitialize] + public void TestInitialize() + { + RestartScopeExe("Peek"); + Session.CloseMainWindow(); + SendKeys(Key.Win, Key.M); + } + + [TestMethod("Peek.FilePreview.Folder")] + [TestCategory("Preview files")] + public void PeekFolderFilePreview() + { + string folderFullPath = Path.GetFullPath(@".\TestAssets"); + + var peekWindow = OpenPeekWindow(folderFullPath); + + Assert.IsNotNull(peekWindow); + + Assert.IsNotNull(peekWindow.Find<TextBlock>("File Type: File folder", 500), "Folder preview should be loaded successfully"); + + ClosePeekAndExplorer(); + } + + /// <summary> + /// Test JPEG image preview + /// </summary> + [TestMethod("Peek.FilePreview.JPEGImage")] + [TestCategory("Preview files")] + public void PeekJPEGImagePreview() + { + string imagePath = Path.GetFullPath(@".\TestAssets\2.jpg"); + TestSingleFilePreview(imagePath, "2"); + } + + /// <summary> + /// Test PDF document preview + /// ToDo: need to open settings to enable PDF preview in Peek + /// </summary> + // [TestMethod("Peek.FilePreview.PDFDocument")] + // [TestCategory("Preview files")] + // public void PeekPDFDocumentPreview() + // { + // string pdfPath = Path.GetFullPath(@".\TestAssets\3.pdf"); + // TestSingleFilePreview(pdfPath, "3", 10000); + // } + + /// <summary> + /// Test QOI image preview + /// </summary> + [TestMethod("Peek.FilePreview.QOIImage")] + [TestCategory("Preview files")] + public void PeekQOIImagePreview() + { + string qoiPath = Path.GetFullPath(@".\TestAssets\4.qoi"); + TestSingleFilePreview(qoiPath, "4"); + } + + /// <summary> + /// Test C++ source code preview + /// </summary> + [TestMethod("Peek.FilePreview.CPPSourceCode")] + [TestCategory("Preview files")] + public void PeekCPPSourceCodePreview() + { + string cppPath = Path.GetFullPath(@".\TestAssets\5.cpp"); + TestSingleFilePreview(cppPath, "5"); + } + + /// <summary> + /// Test Markdown document preview + /// </summary> + [TestMethod("Peek.FilePreview.MarkdownDocument")] + [TestCategory("Preview files")] + public void PeekMarkdownDocumentPreview() + { + string markdownPath = Path.GetFullPath(@".\TestAssets\6.md"); + TestSingleFilePreview(markdownPath, "6"); + } + + /// <summary> + /// Test ZIP archive preview + /// </summary> + [TestMethod("Peek.FilePreview.ZIPArchive")] + [TestCategory("Preview files")] + public void PeekZIPArchivePreview() + { + string zipPath = Path.GetFullPath(@".\TestAssets\7.zip"); + TestSingleFilePreview(zipPath, "7"); + } + + /// <summary> + /// Test PNG image preview + /// </summary> + [TestMethod("Peek.FilePreview.PNGImage")] + [TestCategory("Preview files")] + public void PeekPNGImagePreview() + { + string pngPath = Path.GetFullPath(@".\TestAssets\8.png"); + TestSingleFilePreview(pngPath, "8"); + } + + /// <summary> + /// Test window pinning functionality - pin window and switch between different sized images + /// Verify the window stays at the same place and the same size + /// </summary> + [TestMethod("Peek.WindowPinning.PinAndSwitchImages")] + [TestCategory("Window Pinning")] + public void TestPinWindowAndSwitchImages() + { + // Use two different image files with different size + string firstImagePath = Path.GetFullPath(@".\TestAssets\8.png"); + string secondImagePath = Path.GetFullPath(@".\TestAssets\2.jpg"); // Different format/size + + // Open first image + var initialWindow = OpenPeekWindow(firstImagePath); + + var originalBounds = GetWindowBounds(initialWindow); + + // Move window to a custom position to test pin functionality + NativeMethods.MoveWindow(initialWindow, originalBounds.X + 100, originalBounds.Y + 50); + var movedBounds = GetWindowBounds(initialWindow); + + // Pin the window + PinWindow(); + + // Close current peek + ClosePeekAndExplorer(); + + // Open second image with different size + var secondWindow = OpenPeekWindow(secondImagePath); + var finalBounds = GetWindowBounds(secondWindow); + + // Verify window position and size remained the same as the moved position + Assert.AreEqual(movedBounds.X, finalBounds.X, 5, "Window X position should remain the same when pinned"); + Assert.AreEqual(movedBounds.Y, finalBounds.Y, 5, "Window Y position should remain the same when pinned"); + Assert.AreEqual(movedBounds.Width, finalBounds.Width, 10, "Window width should remain the same when pinned"); + Assert.AreEqual(movedBounds.Height, finalBounds.Height, 10, "Window height should remain the same when pinned"); + + ClosePeekAndExplorer(); + } + + /// <summary> + /// Test window pinning persistence - pin window, close and reopen Peek + /// Verify the new window is opened at the same place and the same size as before + /// </summary> + [TestMethod("Peek.WindowPinning.PinAndReopen")] + [TestCategory("Window Pinning")] + public void TestPinWindowAndReopen() + { + string imagePath = Path.GetFullPath(@".\TestAssets\8.png"); + + // Open image and pin window + var initialWindow = OpenPeekWindow(imagePath); + var originalBounds = GetWindowBounds(initialWindow); + + // Move window to a custom position to test pin persistence + NativeMethods.MoveWindow(initialWindow, originalBounds.X + 150, originalBounds.Y + 75); + var movedBounds = GetWindowBounds(initialWindow); + + // Pin the window + PinWindow(); + + // Close peek + ClosePeekAndExplorer(); + Thread.Sleep(1000); // Wait for window to close completely + + // Reopen the same image + var reopenedWindow = OpenPeekWindow(imagePath); + var finalBounds = GetWindowBounds(reopenedWindow); + + // Verify window position and size are restored to the moved position + Assert.AreEqual(movedBounds.X, finalBounds.X, 5, "Window X position should be restored when pinned"); + Assert.AreEqual(movedBounds.Y, finalBounds.Y, 5, "Window Y position should be restored when pinned"); + Assert.AreEqual(movedBounds.Width, finalBounds.Width, 10, "Window width should be restored when pinned"); + Assert.AreEqual(movedBounds.Height, finalBounds.Height, 10, "Window height should be restored when pinned"); + + ClosePeekAndExplorer(); + } + + /// <summary> + /// Test window unpinning - unpin window and switch to different file + /// Verify the window is moved to the default place + /// </summary> + [TestMethod("Peek.WindowPinning.UnpinAndSwitchFiles")] + [TestCategory("Window Pinning")] + public void TestUnpinWindowAndSwitchFiles() + { + string firstFilePath = Path.GetFullPath(@".\TestAssets\8.png"); + string secondFilePath = Path.GetFullPath(@".\TestAssets\2.jpg"); + + // Open first file and pin window + var pinnedWindow = OpenPeekWindow(firstFilePath); + var originalBounds = GetWindowBounds(pinnedWindow); + + // Move window to a custom position + NativeMethods.MoveWindow(pinnedWindow, originalBounds.X + 200, originalBounds.Y + 100); + var movedBounds = GetWindowBounds(pinnedWindow); + + // Calculate the center point of the moved window + var movedCenter = Session.GetMainWindowCenter(); + + // Pin the window first + PinWindow(); + + // Unpin the window + UnpinWindow(); + + // Close current peek + ClosePeekAndExplorer(); + + // Open different file (different size) + var unpinnedWindow = OpenPeekWindow(secondFilePath); + var unpinnedBounds = GetWindowBounds(unpinnedWindow); + + // Calculate the center point of the unpinned window + var unpinnedCenter = Session.GetMainWindowCenter(); + + // Verify window size is different (since it's a different file type) + bool sizeChanged = Math.Abs(movedBounds.Width - unpinnedBounds.Width) > 10 || + Math.Abs(movedBounds.Height - unpinnedBounds.Height) > 10; + + // Verify window center moved to default position (should be different from moved center) + bool centerChanged = Math.Abs(movedCenter.CenterX - unpinnedCenter.CenterX) > 50 || + Math.Abs(movedCenter.CenterY - unpinnedCenter.CenterY) > 50; + + Assert.IsTrue(sizeChanged, "Window size should be different for different file types"); + Assert.IsTrue(centerChanged, "Window center should move to default position when unpinned"); + + ClosePeekAndExplorer(); + } + + /// <summary> + /// Test unpinned window behavior - unpin window, close and reopen Peek + /// Verify the new window is opened on the default place + /// </summary> + [TestMethod("Peek.WindowPinning.UnpinAndReopen")] + [TestCategory("Window Pinning")] + public void TestUnpinWindowAndReopen() + { + string imagePath = Path.GetFullPath(@".\TestAssets\8.png"); + + // Open image, pin it first, then unpin + var initialWindow = OpenPeekWindow(imagePath); + var originalBounds = GetWindowBounds(initialWindow); + + // Move window to a custom position + NativeMethods.MoveWindow(initialWindow, originalBounds.X + 250, originalBounds.Y + 125); + var movedBounds = GetWindowBounds(initialWindow); + + // Pin then unpin to ensure we test the unpinned state + PinWindow(); + UnpinWindow(); + + // Close peek + ClosePeekAndExplorer(); + + // Reopen the same image + var reopenedWindow = OpenPeekWindow(imagePath); + var reopenedBounds = GetWindowBounds(reopenedWindow); + + // Verify window opened at default position (not the previous moved position) + bool openedAtDefault = Math.Abs(movedBounds.X - reopenedBounds.X) > 50 || + Math.Abs(movedBounds.Y - reopenedBounds.Y) > 50; + + Assert.IsTrue(openedAtDefault, "Unpinned window should open at default position, not previous moved position"); + + ClosePeekAndExplorer(); + } + + /// <summary> + /// Test opening file with default program by clicking a button + /// </summary> + [TestMethod("Peek.OpenWithDefaultProgram.ClickButton")] + [TestCategory("Open with default program")] + public void TestOpenWithDefaultProgramByButton() + { + string zipPath = Path.GetFullPath(@".\TestAssets\7.zip"); + + // Open zip file with Peek + var peekWindow = OpenPeekWindow(zipPath); + + // Find and click the "Open with default program" button + var openButton = FindLaunchButton(); + Assert.IsNotNull(openButton, "Open with default program button should be found"); + + // Click the button to open with default program + openButton.Click(); + + // Wait a moment for the default program to launch + Thread.Sleep(2000); + + // Verify that the default program process has started (check for Explorer opening 7-zip) + bool defaultProgramLaunched = CheckIfExplorerLaunched(); + Assert.IsTrue(defaultProgramLaunched, "Default program (Explorer/7-zip) should be launched after clicking the button"); + + ClosePeekAndExplorer(); + } + + /// <summary> + /// Test opening file with default program by pressing Enter key + /// </summary> + [TestMethod("Peek.OpenWithDefaultProgram.PressEnter")] + [TestCategory("Open with default program")] + public void TestOpenWithDefaultProgramByEnter() + { + string zipPath = Path.GetFullPath(@".\TestAssets\7.zip"); + + // Open zip file with Peek + var peekWindow = OpenPeekWindow(zipPath); + + // Press Enter key to open with default program + SendKeys(Key.Enter); + + // Wait a moment for the default program to launch + Thread.Sleep(2000); + + // Verify that the default program process has started (check for Explorer opening 7-zip) + bool defaultProgramLaunched = CheckIfExplorerLaunched(); + Assert.IsTrue(defaultProgramLaunched, "Default program (Explorer/7-zip) should be launched after pressing Enter"); + + ClosePeekAndExplorer(); + } + + /// <summary> + /// Test switching between files in a folder using Left and Right arrow keys + /// </summary> + [TestMethod("Peek.FileNavigation.SwitchFilesWithArrowKeys")] + [TestCategory("File Navigation")] + public void TestSwitchFilesWithArrowKeys() + { + // Get all files in TestAssets folder, ordered alphabetically + var testFiles = GetTestAssetFiles(); + + // Start with the first file in the TestAssets folder + string firstFilePath = testFiles[0]; + var peekWindow = OpenPeekWindow(firstFilePath); + + // Keep track of visited files to ensure we can navigate through all + var visitedFiles = new List<string> { Path.GetFileNameWithoutExtension(firstFilePath) }; + + // Navigate forward through files using Right arrow + for (int i = 1; i < testFiles.Count; i++) + { + // Press Right arrow to go to next file + SendKeys(Key.Right); + + // Wait for file to load + Thread.Sleep(2000); + + // Try to determine current file from window title + var currentWindow = peekWindow.Name; + string expectedFileName = Path.GetFileNameWithoutExtension(testFiles[i]); + if (!string.IsNullOrEmpty(currentWindow) && currentWindow.StartsWith(expectedFileName, StringComparison.Ordinal)) + { + visitedFiles.Add(expectedFileName); + } + } + + // Verify we navigated through the expected number of files + Assert.AreEqual(testFiles.Count, visitedFiles.Count, $"Should have navigated through all {testFiles.Count} files, but only visited {visitedFiles.Count} files: {string.Join(", ", visitedFiles)}"); + + // Navigate backward using Left arrow to verify reverse navigation + for (int i = testFiles.Count - 2; i >= 0; i--) + { + SendKeys(Key.Left); + + // Wait for file to load + Thread.Sleep(2000); + + // Try to determine current file from window title during backward navigation + var currentWindow = peekWindow.Name; + string expectedFileName = Path.GetFileNameWithoutExtension(testFiles[i]); + if (!string.IsNullOrEmpty(currentWindow) && currentWindow.StartsWith(expectedFileName, StringComparison.Ordinal)) + { + // Remove the last visited file (going backward) + if (visitedFiles.Count > 1) + { + visitedFiles.RemoveAt(visitedFiles.Count - 1); + } + } + } + + // Verify backward navigation worked - should be back to the first file + Assert.AreEqual(1, visitedFiles.Count, $"After backward navigation, should be back to first file only. Remaining files: {string.Join(", ", visitedFiles)}"); + + ClosePeekAndExplorer(); + } + + /// <summary> + /// Test switching between multiple selected files + /// Select first 3 files in Explorer, open with Peek, verify you can switch only between selected files using arrow keys + /// </summary> + [TestMethod("Peek.FileNavigation.SwitchBetweenSelectedFiles")] + [TestCategory("File Navigation")] + public void TestSwitchBetweenSelectedFiles() + { + // Get first 3 files in TestAssets folder, ordered alphabetically + var allFiles = GetTestAssetFiles(); + var selectedFiles = allFiles.Take(3).ToList(); + + // Open Explorer and select the first file + Session.StartExe("explorer.exe", $"/select,\"{selectedFiles[0]}\""); + + // Wait for Explorer to open and select the first file + WaitForExplorerWindow(selectedFiles[0]); + + // Give Explorer time to fully load + Thread.Sleep(2000); + + // Use Shift+Down to extend selection to include the next 2 files + SendKeys(Key.Shift, Key.Down); // Extend to second file + Thread.Sleep(300); + SendKeys(Key.Shift, Key.Down); // Extend to third file + Thread.Sleep(300); + + // Now we should have the first 3 files selected, open Peek + SendPeekHotkeyWithRetry(); + + // Find the peek window (should open with last selected file when multiple files are selected) + var peekWindow = FindPeekWindow(selectedFiles[2]); // Third file (last selected) + string lastFileName = Path.GetFileNameWithoutExtension(selectedFiles[2]); + + // Keep track of visited files during navigation (starting from the last file) + var visitedFiles = new List<string> { lastFileName }; + var expectedFileNames = selectedFiles.Select(f => Path.GetFileNameWithoutExtension(f)).ToList(); + + // Test navigation by pressing Left arrow multiple times to verify we only cycle through 3 selected files + var windowTitles = new List<string> { peekWindow.Name }; + + // Press Left arrow 5 times (more than the 3 selected files) to see if we cycle through only the selected files + for (int i = 0; i < 5; i++) + { + SendKeys(Key.Left); + Thread.Sleep(2000); // Wait for file to load + + var currentWindowTitle = peekWindow.Name; + windowTitles.Add(currentWindowTitle); + } + + // Analyze the navigation pattern - we should see repetition indicating we're only cycling through 3 files + var uniqueWindowsVisited = windowTitles.Distinct().Count(); + + // We should see at most 3 unique windows (the 3 selected files), even after 6 navigation steps + Assert.IsTrue(uniqueWindowsVisited <= 3, $"Should only navigate through the 3 selected files, but found {uniqueWindowsVisited} unique windows. " + $"Window titles: {string.Join(" -> ", windowTitles)}"); + + ClosePeekAndExplorer(); + } + + private bool CheckIfExplorerLaunched() + { + var possibleTitles = new[] + { + "7.zip - File Explorer", + "7 - File Explorer", + "7", + "7.zip", + }; + + foreach (var title in possibleTitles) + { + try + { + var explorerWindow = Find(title, 5000, true); + if (explorerWindow != null) + { + return true; + } + } + catch + { + // Continue to next title + } + } + + return false; + } + + private void OpenAndPeekFile(string fullPath) + { + Session.StartExe("explorer.exe", $"/select,\"{fullPath}\""); + + // Wait for Explorer to open and become ready + WaitForExplorerWindow(fullPath); + + // Send Peek hotkey with retry mechanism + SendPeekHotkeyWithRetry(); + } + + private void WaitForExplorerWindow(string filePath) + { + WaitForCondition( + condition: () => + { + try + { + // Check if Explorer window is open and responsive + var explorerProcesses = Process.GetProcessesByName("explorer") + .Where(p => p.MainWindowHandle != IntPtr.Zero) + .ToList(); + + if (explorerProcesses.Count != 0) + { + // Give Explorer a moment to fully load the file selection + Thread.Sleep(ExplorerLoadDelayMs); + + // Verify the file is accessible + return File.Exists(filePath) || Directory.Exists(filePath); + } + + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"WaitForExplorerWindow exception: {ex.Message}"); + return false; + } + }, + timeoutSeconds: ExplorerOpenTimeoutSeconds, + checkIntervalMs: ExplorerCheckIntervalMs, + timeoutMessage: $"Explorer window did not open for file: {filePath}"); + } + + private void SendPeekHotkeyWithRetry() + { + for (int attempt = 1; attempt <= MaxRetryAttempts; attempt++) + { + try + { + // Send the Peek hotkey + SendKeys(Key.LCtrl, Key.Space); + + // Wait for Peek window to appear + if (WaitForPeekWindow()) + { + return; // Success + } + } + catch (Exception ex) + { + Debug.WriteLine($"SendPeekHotkeyWithRetry attempt {attempt} failed: {ex.Message}"); + + if (attempt == MaxRetryAttempts) + { + throw new InvalidOperationException($"Failed to open Peek after {MaxRetryAttempts} attempts. Last error: {ex.Message}", ex); + } + } + + // Wait before retry using Thread.Sleep + Thread.Sleep(RetryDelayMs); + } + + throw new InvalidOperationException($"Failed to open Peek after {MaxRetryAttempts} attempts"); + } + + private bool WaitForPeekWindow() + { + try + { + WaitForCondition( + condition: () => + { + if (TryFindPeekWindow()) + { + // Give Peek a moment to fully initialize using Thread.Sleep + Thread.Sleep(PeekInitializeDelayMs); + return true; + } + + return false; + }, + timeoutSeconds: PeekWindowTimeoutSeconds, + checkIntervalMs: PeekCheckIntervalMs, + timeoutMessage: "Peek window did not appear"); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"WaitForPeekWindow failed: {ex.Message}"); + return false; + } + } + + private bool WaitForCondition(Func<bool> condition, int timeoutSeconds, int checkIntervalMs, string timeoutMessage) + { + var timeout = TimeSpan.FromSeconds(timeoutSeconds); + var startTime = DateTime.Now; + + while (DateTime.Now - startTime < timeout) + { + try + { + if (condition()) + { + return true; + } + } + catch (Exception ex) + { + // Log exception but continue waiting + Debug.WriteLine($"WaitForCondition exception: {ex.Message}"); + } + + // Use async delay to prevent blocking the thread + Thread.Sleep(checkIntervalMs); + } + + throw new TimeoutException($"{timeoutMessage} (timeout: {timeoutSeconds}s)"); + } + + private bool TryFindPeekWindow() + { + try + { + // Check for Peek process with timeout + var peekProcesses = Process.GetProcessesByName("PowerToys.Peek.UI") + .Where(p => p.MainWindowHandle != IntPtr.Zero); + + var foundProcess = peekProcesses.Any(); + + if (foundProcess) + { + // Additional validation - check if window is responsive + Thread.Sleep(100); // Small delay to ensure window is ready + return true; + } + + return false; + } + catch (Exception ex) + { + Debug.WriteLine($"TryFindPeekWindow exception: {ex.Message}"); + return false; + } + } + + private Element OpenPeekWindow(string filePath) + { + try + { + SendKeys(Key.Enter); + + // Open file with Peek + OpenAndPeekFile(filePath); + + // Find the Peek window using the common method with timeout + var peekWindow = FindPeekWindow(filePath); + + // Attach to the found window with error handling + try + { + Session.Attach(peekWindow.Name); + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to attach to window: {ex.Message}"); + } + + return peekWindow; + } + catch (Exception ex) + { + Debug.WriteLine($"OpenPeekWindow failed for {filePath}: {ex.Message}"); + throw; + } + } + + /// <summary> + /// Test a single file preview with visual comparison + /// </summary> + /// <param name="filePath">Full path to the file to test</param> + /// <param name="expectedFileName">Expected file name for visual comparison</param> + private void TestSingleFilePreview(string filePath, string expectedFileName, int? delayMs = 5000) + { + Element? previewWindow = null; + + try + { + Debug.WriteLine($"Testing file preview: {Path.GetFileName(filePath)}"); + + previewWindow = OpenPeekWindow(filePath); + + if (delayMs.HasValue) + { + Thread.Sleep(delayMs.Value); // Allow time for the preview to load + } + + Assert.IsNotNull(previewWindow, $"Should open Peek window for {Path.GetFileName(filePath)}"); + + // Perform visual comparison + VisualAssert.AreEqual(TestContext, previewWindow, expectedFileName); + + Debug.WriteLine($"Successfully tested: {Path.GetFileName(filePath)}"); + } + finally + { + // Always cleanup in finally block + ClosePeekAndExplorer(); + } + } + + private Rectangle GetWindowBounds(Element window) + { + if (window.Rect == null) + { + return Rectangle.Empty; + } + else + { + return window.Rect.Value; + } + } + + private void PinWindow() + { + // Find pin button using AutomationId + var pinButton = Find(By.AccessibilityId("PinButton"), 2000); + Assert.IsNotNull(pinButton, "Pin button should be found"); + + pinButton.Click(); + Thread.Sleep(PinActionDelayMs); // Wait for pin action to complete + } + + private void UnpinWindow() + { + // Find pin button using AutomationId (same button, just toggle the state) + var pinButton = Find(By.AccessibilityId("PinButton"), 2000); + Assert.IsNotNull(pinButton, "Pin button should be found"); + + pinButton.Click(); + Thread.Sleep(PinActionDelayMs); // Wait for unpin action to complete + } + + private void ClosePeekAndExplorer() + { + try + { + // Close Peek window + Session.CloseMainWindow(); + Thread.Sleep(500); + SendKeys(Key.Win, Key.M); + } + catch (Exception ex) + { + Debug.WriteLine($"Error closing Peek window: {ex.Message}"); + } + } + + /// <summary> + /// Get all files in TestAssets folder, ordered alphabetically, excluding hidden files + /// </summary> + /// <returns>List of file paths in alphabetical order</returns> + private List<string> GetTestAssetFiles() + { + string testAssetsPath = Path.GetFullPath(@".\TestAssets"); + return Directory.GetFiles(testAssetsPath, "*.*", SearchOption.TopDirectoryOnly) + .Where(file => !Path.GetFileName(file).StartsWith('.')) + .OrderBy(file => file) + .ToList(); + } + + /// <summary> + /// Find Peek window by trying both filename with and without extension + /// </summary> + /// <param name="filePath">Full path to the file</param> + /// <param name="timeout">Timeout in milliseconds</param> + /// <returns>The found Peek window element</returns> + private Element FindPeekWindow(string filePath, int timeout = 5000) + { + string fileName = Path.GetFileName(filePath); + string fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath); + + // Try both window title formats since Windows may show or hide file extensions + string peekWindowTitleWithExt = $"{fileName} - Peek"; + string peekWindowTitleWithoutExt = $"{fileNameWithoutExt} - Peek"; + + Element? peekWindow = null; + + try + { + // First try to find the window with extension + peekWindow = Find(peekWindowTitleWithoutExt, timeout, true); + } + catch + { + try + { + // Then try without extension + peekWindow = Find(peekWindowTitleWithExt, timeout, true); + } + catch + { + // If neither works, let it fail with a clear message + Assert.Fail($"Could not find Peek window with title '{peekWindowTitleWithExt}' or '{peekWindowTitleWithoutExt}'"); + } + } + + Assert.IsNotNull(peekWindow, $"Should find Peek window for file: {Path.GetFileName(filePath)}"); + + return peekWindow; + } + + /// <summary> + /// Helper method to find the launch button with different AccessibilityIds depending on window size + /// </summary> + /// <returns>The launch button element</returns> + private Element? FindLaunchButton() + { + try + { + // Try to find button with ID for larger window first + var button = Find(By.AccessibilityId("LaunchAppButton_Text"), 1000); + if (button != null) + { + return button; + } + } + catch + { + // Try to find button with ID for smaller window + var button = Find(By.AccessibilityId("LaunchAppButton"), 1000); + if (button != null) + { + return button; + } + } + + return null; + } +} diff --git a/src/modules/peek/Peek.UITests/TestAssets/2.jpg b/src/modules/peek/Peek.UITests/TestAssets/2.jpg new file mode 100644 index 0000000000..808462ae77 Binary files /dev/null and b/src/modules/peek/Peek.UITests/TestAssets/2.jpg differ diff --git a/src/modules/peek/Peek.UITests/TestAssets/4.qoi b/src/modules/peek/Peek.UITests/TestAssets/4.qoi new file mode 100644 index 0000000000..90eef44feb Binary files /dev/null and b/src/modules/peek/Peek.UITests/TestAssets/4.qoi differ diff --git a/src/modules/peek/Peek.UITests/TestAssets/5.cpp b/src/modules/peek/Peek.UITests/TestAssets/5.cpp new file mode 100644 index 0000000000..54e47ecd1e --- /dev/null +++ b/src/modules/peek/Peek.UITests/TestAssets/5.cpp @@ -0,0 +1,6 @@ +#include <iostream> + +int main() { + std::cout << "Hello, world!" << std::endl; + return 0; +} diff --git a/src/modules/peek/Peek.UITests/TestAssets/6.md b/src/modules/peek/Peek.UITests/TestAssets/6.md new file mode 100644 index 0000000000..339bae7a48 --- /dev/null +++ b/src/modules/peek/Peek.UITests/TestAssets/6.md @@ -0,0 +1,11 @@ +## 简单的 C++ 示例 + +这是一个最基础的 C++ 程序,它会输出 "Hello, world!": + +```cpp +#include <iostream> + +int main() { + std::cout << "Hello, world!" << std::endl; + return 0; +} \ No newline at end of file diff --git a/src/modules/peek/Peek.UITests/TestAssets/7.zip b/src/modules/peek/Peek.UITests/TestAssets/7.zip new file mode 100644 index 0000000000..769fc18253 Binary files /dev/null and b/src/modules/peek/Peek.UITests/TestAssets/7.zip differ diff --git a/src/modules/peek/Peek.UITests/TestAssets/8.png b/src/modules/peek/Peek.UITests/TestAssets/8.png new file mode 100644 index 0000000000..b6b87e89c4 Binary files /dev/null and b/src/modules/peek/Peek.UITests/TestAssets/8.png differ diff --git a/src/modules/peek/Peek.UITests/tests-checklist-template-peek.md b/src/modules/peek/Peek.UITests/tests-checklist-template-peek.md new file mode 100644 index 0000000000..9a15fed3af --- /dev/null +++ b/src/modules/peek/Peek.UITests/tests-checklist-template-peek.md @@ -0,0 +1,21 @@ +## Peek + * Open different files to check that they're shown properly + - [x] Image + - [x] Text or dev file + - [x] Markdown file + - [x] PDF + - [x] Archive files (.zip, .tar, .rar) + - [x] Any other not mentioned file (.exe for example) to verify the unsupported file view is shown + + * Pinning/unpinning + - [x] Pin the window, switch between images of different size, verify the window stays at the same place and the same size. + - [x] Pin the window, close and reopen Peek, verify the new window is opened at the same place and the same size as before. + - [x] Unpin the window, switch to a different file, verify the window is moved to the default place. + - [x] Unpin the window, close and reopen Peek, verify the new window is opened on the default place. + +* Open with a default program + - [x] By clicking a button. + - [x] By pressing enter. + + - [x] Switch between files in the folder using `LeftArrow` and `RightArrow`, verify you can switch between all files in the folder. + - [x] Open multiple files, verify you can switch only between selected files. diff --git a/src/modules/peek/peek/dllmain.cpp b/src/modules/peek/peek/dllmain.cpp index 5fd4da8039..1127df38bd 100644 --- a/src/modules/peek/peek/dllmain.cpp +++ b/src/modules/peek/peek/dllmain.cpp @@ -1,15 +1,17 @@ #include "pch.h" -#include <interface/powertoy_module_interface.h> +#include "trace.h" +#include <atlbase.h> +#include <atomic> +#include <comdef.h> +#include <common/interop/shared_constants.h> #include <common/logger/logger.h> #include <common/SettingsAPI/settings_objects.h> -#include "trace.h" -#include <common/utils/winapi_error.h> -#include <filesystem> -#include <common/interop/shared_constants.h> -#include <atlbase.h> -#include <exdisp.h> -#include <comdef.h> #include <common/utils/elevation.h> +#include <common/utils/logger_helper.h> +#include <common/utils/winapi_error.h> +#include <exdisp.h> +#include <filesystem> +#include <interface/powertoy_module_interface.h> extern "C" IMAGE_DOS_HEADER __ImageBase; @@ -32,6 +34,9 @@ BOOL APIENTRY DllMain(HMODULE /*hModule*/, return TRUE; } +// Forward declare global Peek so anonymous namespace uses same type +class Peek; + namespace { const wchar_t JSON_KEY_PROPERTIES[] = L"properties"; @@ -42,6 +47,17 @@ namespace const wchar_t JSON_KEY_CODE[] = L"code"; const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"ActivationShortcut"; const wchar_t JSON_KEY_ALWAYS_RUN_NOT_ELEVATED[] = L"AlwaysRunNotElevated"; + const wchar_t JSON_KEY_ENABLE_SPACE_TO_ACTIVATE[] = L"EnableSpaceToActivate"; + + // Space activation (single-space mode) state + std::atomic_bool g_foregroundHookActive{ false }; // Foreground hook installed + std::atomic_bool g_foregroundEligible{ false }; // Cached eligibility (Explorer/Desktop/Peek focused) + HWINEVENTHOOK g_foregroundHook = nullptr; // Foreground change hook handle + constexpr DWORD FOREGROUND_DEBOUNCE_MS = 40; // Delay before eligibility recompute (ms) + HANDLE g_foregroundDebounceTimer = nullptr; // One-shot scheduled timer + std::atomic<DWORD> g_foregroundLastScheduleTick{ 0 }; // Tick count when timer last scheduled + + Peek* g_instance = nullptr; // pointer to active instance (global Peek) } // The PowerToy name that will be shown in the settings. @@ -60,6 +76,7 @@ private: // If we should always try to run Peek non-elevated. bool m_alwaysRunNotElevated = true; + bool m_enableSpaceToActivate = false; // toggle from settings HANDLE m_hProcess = 0; DWORD m_processPid = 0; @@ -111,11 +128,55 @@ private: m_alwaysRunNotElevated = true; } + try + { + auto jsonEnableSpaceObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ENABLE_SPACE_TO_ACTIVATE); + m_enableSpaceToActivate = jsonEnableSpaceObject.GetNamedBoolean(L"value"); + } + catch (...) + { + m_enableSpaceToActivate = false; + } + + // Enforce design: if space toggle ON, force single-space hotkey and store previous combination once. + if (m_enableSpaceToActivate) + { + if (!(m_hotkey.win || m_hotkey.alt || m_hotkey.shift || m_hotkey.ctrl) && m_hotkey.key == ' ') + { + // already single space + } + else + { + m_hotkey.win = false; + m_hotkey.alt = false; + m_hotkey.shift = false; + m_hotkey.ctrl = false; + m_hotkey.key = ' '; + } + } + else + { + // If toggle off and current hotkey is bare space, revert to default (simplified policy) + if (!(m_hotkey.win || m_hotkey.alt || m_hotkey.shift || m_hotkey.ctrl) && m_hotkey.key == ' ') + { + set_default_key_settings(); + } + } + + manage_space_mode_hook(); + Trace::SpaceModeEnabled(m_enableSpaceToActivate); } else { - Logger::info("Peek settings are empty"); - set_default_key_settings(); + // First-run (no existing settings file or empty JSON): default to Space-only activation + Logger::info("Peek settings are empty - initializing first-run defaults (Space activation)"); + m_enableSpaceToActivate = true; + m_hotkey.win = false; + m_hotkey.alt = false; + m_hotkey.shift = false; + m_hotkey.ctrl = false; + m_hotkey.key = ' '; + Trace::SpaceModeEnabled(true); } } @@ -129,6 +190,111 @@ private: m_hotkey.key = ' '; } + // Eligibility recompute (debounced via timer) +public: // callable from anonymous namespace helper + void recompute_space_mode_eligibility() + { + if (!m_enableSpaceToActivate) + { + g_foregroundEligible.store(false, std::memory_order_relaxed); + return; + } + const bool eligible = is_peek_or_explorer_or_desktop_window_focused(); + g_foregroundEligible.store(eligible, std::memory_order_relaxed); + Logger::debug(L"Peek space-mode eligibility recomputed: {}", eligible); + } + +private: + static void CALLBACK ForegroundDebounceTimerProc(PVOID /*param*/, BOOLEAN /*fired*/) + { + if (!g_instance || !g_foregroundHookActive.load(std::memory_order_relaxed)) + { + return; + } + g_instance->recompute_space_mode_eligibility(); + } + + static void CALLBACK ForegroundWinEventProc(HWINEVENTHOOK /*hook*/, DWORD /*event*/, HWND /*hwnd*/, LONG /*idObject*/, LONG /*idChild*/, DWORD /*thread*/, DWORD /*time*/) + { + if (!g_foregroundHookActive.load(std::memory_order_relaxed) || !g_instance) + { + return; + } + const DWORD now = GetTickCount(); + const DWORD last = g_foregroundLastScheduleTick.load(std::memory_order_relaxed); + // If no timer or sufficient time since last schedule, create a new one. + if (!g_foregroundDebounceTimer || (now - last) >= FOREGROUND_DEBOUNCE_MS || now < last) + { + if (g_foregroundDebounceTimer) + { + // Best effort: cancel previous pending timer; ignore failure. + DeleteTimerQueueTimer(nullptr, g_foregroundDebounceTimer, INVALID_HANDLE_VALUE); + g_foregroundDebounceTimer = nullptr; + } + if (CreateTimerQueueTimer(&g_foregroundDebounceTimer, nullptr, ForegroundDebounceTimerProc, nullptr, FOREGROUND_DEBOUNCE_MS, 0, WT_EXECUTEDEFAULT)) + { + g_foregroundLastScheduleTick.store(now, std::memory_order_relaxed); + } + else + { + Logger::warn(L"Peek failed to create foreground debounce timer"); + // Fallback: compute immediately if timer creation failed. + g_instance->recompute_space_mode_eligibility(); + } + } + } + + void install_foreground_hook() + { + if (g_foregroundHook || !m_enableSpaceToActivate) + { + return; + } + + g_instance = this; + g_foregroundHook = SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, nullptr, ForegroundWinEventProc, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + if (g_foregroundHook) + { + g_foregroundHookActive.store(true, std::memory_order_relaxed); + recompute_space_mode_eligibility(); + } + else + { + g_foregroundHookActive.store(false, std::memory_order_relaxed); + Logger::warn(L"Peek failed to install foreground hook. Falling back to polling."); + } + } + + void uninstall_foreground_hook() + { + if (g_foregroundHook) + { + UnhookWinEvent(g_foregroundHook); + g_foregroundHook = nullptr; + } + if (g_foregroundDebounceTimer) + { + DeleteTimerQueueTimer(nullptr, g_foregroundDebounceTimer, INVALID_HANDLE_VALUE); + g_foregroundDebounceTimer = nullptr; + } + g_foregroundLastScheduleTick.store(0, std::memory_order_relaxed); + g_foregroundHookActive.store(false, std::memory_order_relaxed); + g_foregroundEligible.store(false, std::memory_order_relaxed); + g_instance = nullptr; + } + + void manage_space_mode_hook() + { + if (m_enableSpaceToActivate && m_enabled) + { + install_foreground_hook(); + } + else + { + uninstall_foreground_hook(); + } + } + void parse_hotkey(winrt::Windows::Data::Json::JsonObject& jsonHotkeyObject) { try @@ -319,6 +485,7 @@ private: public: Peek() { + LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", "Peek"); init_settings(); m_hInvokeEvent = CreateDefaultEvent(CommonSharedConstants::SHOW_PEEK_SHARED_EVENT); @@ -331,6 +498,7 @@ public: { } m_enabled = false; + uninstall_foreground_hook(); }; // Destroy the powertoy and free memory @@ -364,6 +532,7 @@ public: // Create a Settings object. PowerToysSettings::Settings settings(hinstance, get_name()); settings.set_description(MODULE_DESC); + settings.add_bool_toggle(JSON_KEY_ENABLE_SPACE_TO_ACTIVATE, L"Enable single Space key activation", m_enableSpaceToActivate); return settings.serialize_to_buffer(buffer, buffer_size); } @@ -395,6 +564,7 @@ public: launch_process(); m_enabled = true; Trace::EnablePeek(true); + manage_space_mode_hook(); } // Disable the powertoy @@ -405,13 +575,19 @@ public: { ResetEvent(m_hInvokeEvent); SetEvent(m_hTerminateEvent); - WaitForSingleObject(m_hProcess, 1500); - auto result = TerminateProcess(m_hProcess, 1); - if (result == 0) + + HANDLE hProcess = OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, FALSE, m_processPid); + if (WaitForSingleObject(hProcess, 1500) == WAIT_TIMEOUT) { - int error = GetLastError(); - Logger::trace("Couldn't terminate the process. Last error: {}", error); + auto result = TerminateProcess(hProcess, 1); + if (result == 0) + { + int error = GetLastError(); + Logger::trace("Couldn't terminate the process. Last error: {}", error); + } } + + CloseHandle(hProcess); CloseHandle(m_hProcess); m_hProcess = 0; m_processPid = 0; @@ -419,6 +595,7 @@ public: m_enabled = false; Trace::EnablePeek(false); + uninstall_foreground_hook(); } // Returns if the powertoys is enabled @@ -448,11 +625,21 @@ public: { if (m_enabled) { - Logger::trace(L"Peek hotkey pressed"); - - // Only activate and consume the shortcut if a Peek, explorer or desktop window is the foreground application. - if (is_peek_or_explorer_or_desktop_window_focused()) + bool spaceMode = m_enableSpaceToActivate && !(m_hotkey.win || m_hotkey.alt || m_hotkey.shift || m_hotkey.ctrl) && m_hotkey.key == ' '; + bool eligible = false; + if (spaceMode && g_foregroundHookActive.load(std::memory_order_relaxed)) { + eligible = g_foregroundEligible.load(std::memory_order_relaxed); + } + else + { + eligible = is_peek_or_explorer_or_desktop_window_focused(); + } + + if (eligible) + { + Logger::trace(L"Peek hotkey pressed and eligible for launching"); + // TODO: fix VK_SPACE DestroyWindow in viewer app if (!is_viewer_running()) { @@ -462,7 +649,16 @@ public: SetEvent(m_hInvokeEvent); Trace::PeekInvoked(); - return true; + + + if (spaceMode) + { + return false; + } + else + { + return true; + } } } diff --git a/src/modules/peek/peek/peek.vcxproj b/src/modules/peek/peek/peek.vcxproj index 3da3b2e7de..428889c442 100644 --- a/src/modules/peek/peek/peek.vcxproj +++ b/src/modules/peek/peek/peek.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{a1425b53-3d61-4679-8623-e64a0d3d0a48}</ProjectGuid> @@ -8,10 +9,9 @@ <RootNamespace>peek</RootNamespace> <ProjectName>Peek</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -23,13 +23,13 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.Peek</TargetName> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -48,10 +48,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -60,18 +60,18 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/peek/peek/trace.cpp b/src/modules/peek/peek/trace.cpp index 529abb94f3..a1dd6355a2 100644 --- a/src/modules/peek/peek/trace.cpp +++ b/src/modules/peek/peek/trace.cpp @@ -48,3 +48,13 @@ void Trace::SettingsTelemetry(PowertoyModuleIface::Hotkey& hotkey) noexcept TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), TraceLoggingWideString(hotKeyStr.c_str(), "HotKey")); } + +void Trace::SpaceModeEnabled(bool enabled) noexcept +{ + TraceLoggingWriteWrapper( + g_hProvider, + "Peek_SpaceModeEnabled", + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, "Enabled")); +} diff --git a/src/modules/peek/peek/trace.h b/src/modules/peek/peek/trace.h index c250fc6b45..b5c22e7645 100644 --- a/src/modules/peek/peek/trace.h +++ b/src/modules/peek/peek/trace.h @@ -15,4 +15,7 @@ public: // Event to send settings telemetry. static void Trace::SettingsTelemetry(PowertoyModuleIface::Hotkey& hotkey) noexcept; + // Space mode telemetry (single-key activation toggle) + static void SpaceModeEnabled(bool enabled) noexcept; + }; diff --git a/src/modules/poweraccent/PowerAccent.Core/Languages.cs b/src/modules/poweraccent/PowerAccent.Core/Languages.cs index 542af1b599..73dca3ee72 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Languages.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Languages.cs @@ -35,6 +35,7 @@ namespace PowerAccent.Core KU, LT, MK, + MT, MI, NL, NO, @@ -51,6 +52,7 @@ namespace PowerAccent.Core SR_CYRL, SV, TK, + VI, } internal sealed class Languages @@ -97,6 +99,7 @@ namespace PowerAccent.Core Language.KU => GetDefaultLetterKeyKU(letter), // Kurdish Language.LT => GetDefaultLetterKeyLT(letter), // Lithuanian Language.MK => GetDefaultLetterKeyMK(letter), // Macedonian + Language.MT => GetDefaultLetterKeyMT(letter), // Maltese Language.MI => GetDefaultLetterKeyMI(letter), // Maori Language.NL => GetDefaultLetterKeyNL(letter), // Dutch Language.NO => GetDefaultLetterKeyNO(letter), // Norwegian @@ -113,6 +116,7 @@ namespace PowerAccent.Core Language.SR_CYRL => GetDefaultLetterKeySRCyrillic(letter), // Serbian Cyrillic Language.SV => GetDefaultLetterKeySV(letter), // Swedish Language.TK => GetDefaultLetterKeyTK(letter), // Turkish + Language.VI => GetDefaultLetterKeyVI(letter), // Vietnamese _ => throw new ArgumentException("The language {0} is not known in this context", lang.ToString()), }); } @@ -153,6 +157,7 @@ namespace PowerAccent.Core .Union(GetDefaultLetterKeyLT(letter)) .Union(GetDefaultLetterKeyROM(letter)) .Union(GetDefaultLetterKeyMK(letter)) + .Union(GetDefaultLetterKeyMT(letter)) .Union(GetDefaultLetterKeyMI(letter)) .Union(GetDefaultLetterKeyNL(letter)) .Union(GetDefaultLetterKeyNO(letter)) @@ -168,6 +173,7 @@ namespace PowerAccent.Core .Union(GetDefaultLetterKeySRCyrillic(letter)) .Union(GetDefaultLetterKeySV(letter)) .Union(GetDefaultLetterKeyTK(letter)) + .Union(GetDefaultLetterKeyVI(letter)) .Union(GetDefaultLetterKeySPECIAL(letter)) .ToArray(); @@ -206,7 +212,7 @@ namespace PowerAccent.Core LetterKey.VK_L => new[] { "ļ", "₺" }, // ₺ is in VK_T for other languages, but not VK_L, so we add it here. LetterKey.VK_M => new[] { "ṁ" }, LetterKey.VK_N => new[] { "ņ", "ṅ", "ⁿ", "ℕ", "№" }, - LetterKey.VK_O => new[] { "ȯ", "∅" }, + LetterKey.VK_O => new[] { "ȯ", "∅", "⌀" }, LetterKey.VK_P => new[] { "ṗ", "℗", "∏", "¶" }, LetterKey.VK_Q => new[] { "ℚ" }, LetterKey.VK_R => new[] { "ṙ", "®", "ℝ" }, @@ -218,13 +224,13 @@ namespace PowerAccent.Core LetterKey.VK_X => new[] { "ẋ", "×" }, LetterKey.VK_Y => new[] { "ẏ", "ꝡ" }, LetterKey.VK_Z => new[] { "ʒ", "ǯ", "ℤ" }, - LetterKey.VK_COMMA => new[] { "∙", "₋", "⁻", "–", "√" }, // – is in VK_MINUS for other languages, but not VK_COMMA, so we add it here. + LetterKey.VK_COMMA => new[] { "∙", "₋", "⁻", "–", "√", "‟", "《", "》", "‛", "〈", "〉", "″", "‴", "⁗" }, // – is in VK_MINUS for other languages, but not VK_COMMA, so we add it here. LetterKey.VK_PERIOD => new[] { "…", "⁝", "\u0300", "\u0301", "\u0302", "\u0303", "\u0304", "\u0308", "\u030B", "\u030C" }, LetterKey.VK_MINUS => new[] { "~", "‐", "‑", "‒", "—", "―", "⁓", "−", "⸺", "⸻", "∓" }, LetterKey.VK_SLASH_ => new[] { "÷", "√" }, LetterKey.VK_DIVIDE_ => new[] { "÷", "√" }, LetterKey.VK_MULTIPLY_ => new[] { "×", "⋅" }, - LetterKey.VK_PLUS => new[] { "≤", "≥", "≠", "≈", "≙", "⊕", "⊗", "∓", "≅", "≡" }, + LetterKey.VK_PLUS => new[] { "≤", "≥", "≠", "≈", "≙", "⊕", "⊗", "±", "≅", "≡", "₊", "⁺" }, LetterKey.VK_BACKSLASH => new[] { "`", "~" }, _ => Array.Empty<string>(), }; @@ -296,6 +302,7 @@ namespace PowerAccent.Core LetterKey.VK_E => new[] { "€" }, LetterKey.VK_S => new[] { "š" }, LetterKey.VK_Z => new[] { "ž" }, + LetterKey.VK_COMMA => new[] { "„", "“", "»", "«" }, _ => Array.Empty<string>(), }; } @@ -311,6 +318,7 @@ namespace PowerAccent.Core LetterKey.VK_U => new[] { "ü" }, LetterKey.VK_Z => new[] { "ž" }, LetterKey.VK_S => new[] { "š" }, + LetterKey.VK_COMMA => new[] { "„", "“", "«", "»" }, _ => Array.Empty<string>(), }; } @@ -325,7 +333,7 @@ namespace PowerAccent.Core LetterKey.VK_H => new[] { "ĥ" }, LetterKey.VK_J => new[] { "ĵ" }, LetterKey.VK_S => new[] { "ŝ" }, - LetterKey.VK_U => new[] { "ǔ" }, + LetterKey.VK_U => new[] { "ŭ" }, _ => Array.Empty<string>(), }; } @@ -338,6 +346,7 @@ namespace PowerAccent.Core LetterKey.VK_A => new[] { "ä", "å" }, LetterKey.VK_E => new[] { "€" }, LetterKey.VK_O => new[] { "ö" }, + LetterKey.VK_COMMA => new[] { "”", "’", "»" }, _ => Array.Empty<string>(), }; } @@ -354,6 +363,7 @@ namespace PowerAccent.Core LetterKey.VK_O => new[] { "ô", "ö", "ó", "ò", "õ", "œ" }, LetterKey.VK_U => new[] { "û", "ù", "ü", "ú" }, LetterKey.VK_Y => new[] { "ÿ", "ý" }, + LetterKey.VK_COMMA => new[] { "«", "»", "‹", "›", "“", "”", "‘", "’" }, _ => Array.Empty<string>(), }; } @@ -370,6 +380,7 @@ namespace PowerAccent.Core LetterKey.VK_U => new[] { "ú" }, LetterKey.VK_Y => new[] { "ý" }, LetterKey.VK_T => new[] { "þ" }, + LetterKey.VK_COMMA => new[] { "„", "“", "‚", "‘" }, _ => Array.Empty<string>(), }; } @@ -387,7 +398,7 @@ namespace PowerAccent.Core LetterKey.VK_N => new[] { "ñ" }, LetterKey.VK_O => new[] { "ó" }, LetterKey.VK_U => new[] { "ú", "ü" }, - LetterKey.VK_COMMA => new[] { "¿", "?", "¡", "!" }, + LetterKey.VK_COMMA => new[] { "¿", "?", "¡", "!", "«", "»", "“", "”", "‘", "’" }, _ => Array.Empty<string>(), }; } @@ -405,7 +416,7 @@ namespace PowerAccent.Core LetterKey.VK_O => new[] { "ò", "ó" }, LetterKey.VK_U => new[] { "ù", "ú", "ü" }, LetterKey.VK_L => new[] { "·" }, - LetterKey.VK_COMMA => new[] { "¿", "?", "¡", "!" }, + LetterKey.VK_COMMA => new[] { "¿", "?", "¡", "!", "«", "»", "“", "”", "‘", "’" }, _ => Array.Empty<string>(), }; } @@ -421,6 +432,7 @@ namespace PowerAccent.Core LetterKey.VK_O => new[] { "ō" }, LetterKey.VK_S => new[] { "$" }, LetterKey.VK_U => new[] { "ū" }, + LetterKey.VK_COMMA => new[] { "“", "”", "‘", "’" }, _ => Array.Empty<string>(), }; } @@ -437,6 +449,7 @@ namespace PowerAccent.Core LetterKey.VK_N => new[] { "ñ" }, LetterKey.VK_O => new[] { "ó", "ö", "ô" }, LetterKey.VK_U => new[] { "ú", "ü", "û" }, + LetterKey.VK_COMMA => new[] { "“", "„", "”", "‘", ",", "’" }, _ => Array.Empty<string>(), }; } @@ -463,6 +476,7 @@ namespace PowerAccent.Core LetterKey.VK_V => new[] { "ü", "ǖ", "ǘ", "ǚ", "ǜ" }, LetterKey.VK_Y => new[] { "¥" }, LetterKey.VK_Z => new[] { "ẑ" }, + LetterKey.VK_COMMA => new[] { "“", "”", "‘", "’", "「", "」", "『", "』" }, _ => Array.Empty<string>(), }; } @@ -499,6 +513,7 @@ namespace PowerAccent.Core LetterKey.VK_S => new[] { "ş" }, LetterKey.VK_T => new[] { "₺" }, LetterKey.VK_U => new[] { "ü", "û" }, + LetterKey.VK_COMMA => new[] { "“", "”", "‘", "’", "«", "»", "‹", "›" }, _ => Array.Empty<string>(), }; } @@ -516,6 +531,7 @@ namespace PowerAccent.Core LetterKey.VK_O => new[] { "ó" }, LetterKey.VK_S => new[] { "ś" }, LetterKey.VK_Z => new[] { "ż", "ź" }, + LetterKey.VK_COMMA => new[] { "„", "”", "‘", "’", "»", "«" }, _ => Array.Empty<string>(), }; } @@ -530,10 +546,9 @@ namespace PowerAccent.Core LetterKey.VK_E => new[] { "é", "ê", "€" }, LetterKey.VK_I => new[] { "í" }, LetterKey.VK_O => new[] { "ô", "ó", "õ", "º" }, - LetterKey.VK_P => new[] { "π" }, LetterKey.VK_S => new[] { "$" }, LetterKey.VK_U => new[] { "ú" }, - LetterKey.VK_COMMA => new[] { "≤", "≥", "≠", "≈", "≙", "±", "₊", "⁺" }, + LetterKey.VK_COMMA => new[] { "“", "”", "‘", "’", "«", "»" }, _ => Array.Empty<string>(), }; } @@ -588,6 +603,7 @@ namespace PowerAccent.Core LetterKey.VK_U => new[] { "ú" }, LetterKey.VK_Y => new[] { "ý" }, LetterKey.VK_Z => new[] { "ž" }, + LetterKey.VK_COMMA => new[] { "„", "“", "‚", "‘", "»", "«", "›", "‹" }, _ => Array.Empty<string>(), }; } @@ -602,6 +618,7 @@ namespace PowerAccent.Core LetterKey.VK_I => new[] { "í" }, LetterKey.VK_O => new[] { "ó" }, LetterKey.VK_U => new[] { "ú" }, + LetterKey.VK_COMMA => new[] { "“", "”", "‘", "’" }, _ => Array.Empty<string>(), }; } @@ -617,6 +634,7 @@ namespace PowerAccent.Core LetterKey.VK_O => new[] { "ò" }, LetterKey.VK_P => new[] { "£" }, LetterKey.VK_U => new[] { "ù" }, + LetterKey.VK_COMMA => new[] { "“", "”", "‘", "’" }, _ => Array.Empty<string>(), }; } @@ -639,6 +657,7 @@ namespace PowerAccent.Core LetterKey.VK_U => new[] { "ů", "ú" }, LetterKey.VK_Y => new[] { "ý" }, LetterKey.VK_Z => new[] { "ž" }, + LetterKey.VK_COMMA => new[] { "„", "“", "‚", "‘", "»", "«", "›", "‹" }, _ => Array.Empty<string>(), }; } @@ -653,6 +672,7 @@ namespace PowerAccent.Core LetterKey.VK_O => new[] { "ö" }, LetterKey.VK_S => new[] { "ß" }, LetterKey.VK_U => new[] { "ü" }, + LetterKey.VK_COMMA => new[] { "„", "“", "‚", "‘", "»", "«", "›", "‹" }, _ => Array.Empty<string>(), }; } @@ -683,6 +703,7 @@ namespace PowerAccent.Core LetterKey.VK_X => new string[] { "ξ" }, LetterKey.VK_Y => new string[] { "υ" }, LetterKey.VK_Z => new string[] { "ζ" }, + LetterKey.VK_COMMA => new[] { "“", "”", "«", "»", }, _ => Array.Empty<string>(), }; } @@ -704,9 +725,9 @@ namespace PowerAccent.Core LetterKey.VK_U => new[] { "וֹ", "וּ", "װ", "\u05b9" }, LetterKey.VK_X => new[] { "\u05b6", "\u05b1" }, LetterKey.VK_Y => new[] { "ױ" }, - LetterKey.VK_COMMA => new[] { "”", "’", "״", "׳" }, + LetterKey.VK_COMMA => new[] { "”", "’", "'", "״", "׳" }, LetterKey.VK_PERIOD => new[] { "\u05ab", "\u05bd", "\u05bf" }, - LetterKey.VK_MINUS => new[] { "–", "־" }, + LetterKey.VK_MINUS => new[] { "־" }, _ => Array.Empty<string>(), }; } @@ -721,6 +742,7 @@ namespace PowerAccent.Core LetterKey.VK_I => new[] { "í" }, LetterKey.VK_O => new[] { "ó", "ő", "ö" }, LetterKey.VK_U => new[] { "ú", "ű", "ü" }, + LetterKey.VK_COMMA => new[] { "„", "”", "»", "«" }, _ => Array.Empty<string>(), }; } @@ -734,6 +756,7 @@ namespace PowerAccent.Core LetterKey.VK_I => new[] { "î" }, LetterKey.VK_S => new[] { "ș" }, LetterKey.VK_T => new[] { "ț" }, + LetterKey.VK_COMMA => new[] { "„", "”", "«", "»" }, _ => Array.Empty<string>(), }; } @@ -748,6 +771,7 @@ namespace PowerAccent.Core LetterKey.VK_I => new[] { "ì", "í" }, LetterKey.VK_O => new[] { "ò", "ó" }, LetterKey.VK_U => new[] { "ù", "ú" }, + LetterKey.VK_COMMA => new[] { "«", "»", "“", "”", "‘", "’" }, _ => Array.Empty<string>(), }; } @@ -766,6 +790,7 @@ namespace PowerAccent.Core LetterKey.VK_R => new[] { "ř" }, LetterKey.VK_S => new[] { "ş" }, LetterKey.VK_U => new[] { "û", "ü" }, + LetterKey.VK_COMMA => new[] { "«", "»", "“", "”" }, _ => Array.Empty<string>(), }; } @@ -775,14 +800,15 @@ namespace PowerAccent.Core { return letter switch { - LetterKey.VK_A => new[] { "â" }, - LetterKey.VK_E => new[] { "ê" }, - LetterKey.VK_I => new[] { "î" }, - LetterKey.VK_O => new[] { "ô" }, + LetterKey.VK_A => new[] { "â", "ä", "à", "á" }, + LetterKey.VK_E => new[] { "ê", "ë", "è", "é" }, + LetterKey.VK_I => new[] { "î", "ï", "ì", "í" }, + LetterKey.VK_O => new[] { "ô", "ö", "ò", "ó" }, LetterKey.VK_P => new[] { "£" }, - LetterKey.VK_U => new[] { "û" }, - LetterKey.VK_Y => new[] { "ŷ" }, - LetterKey.VK_W => new[] { "ŵ" }, + LetterKey.VK_U => new[] { "û", "ü", "ù", "ú" }, + LetterKey.VK_Y => new[] { "ŷ", "ÿ", "ỳ", "ý" }, + LetterKey.VK_W => new[] { "ŵ", "ẅ", "ẁ", "ẃ" }, + LetterKey.VK_COMMA => new[] { "‘", "’", "“", "“" }, _ => Array.Empty<string>(), }; } @@ -795,6 +821,7 @@ namespace PowerAccent.Core LetterKey.VK_A => new[] { "å", "ä" }, LetterKey.VK_E => new[] { "é" }, LetterKey.VK_O => new[] { "ö" }, + LetterKey.VK_COMMA => new[] { "”", "’", "»", "«" }, _ => Array.Empty<string>(), }; } @@ -808,6 +835,7 @@ namespace PowerAccent.Core LetterKey.VK_D => new[] { "đ" }, LetterKey.VK_S => new[] { "š" }, LetterKey.VK_Z => new[] { "ž" }, + LetterKey.VK_COMMA => new[] { "„", "“", "‚", "’", "»", "«", "›", "‹" }, _ => Array.Empty<string>(), }; } @@ -832,6 +860,25 @@ namespace PowerAccent.Core { LetterKey.VK_E => new[] { "ѐ" }, LetterKey.VK_I => new[] { "ѝ" }, + LetterKey.VK_COMMA => new[] { "„", "“", "’", "‘" }, + _ => Array.Empty<string>(), + }; + } + + // Maltese + private static string[] GetDefaultLetterKeyMT(LetterKey letter) + { + return letter switch + { + LetterKey.VK_A => new[] { "à" }, + LetterKey.VK_C => new[] { "ċ" }, + LetterKey.VK_E => new[] { "è", "€" }, + LetterKey.VK_G => new[] { "ġ" }, + LetterKey.VK_H => new[] { "ħ" }, + LetterKey.VK_I => new[] { "ì" }, + LetterKey.VK_O => new[] { "ò" }, + LetterKey.VK_U => new[] { "ù" }, + LetterKey.VK_Z => new[] { "ż" }, _ => Array.Empty<string>(), }; } @@ -845,6 +892,7 @@ namespace PowerAccent.Core LetterKey.VK_E => new[] { "€", "é" }, LetterKey.VK_O => new[] { "ø" }, LetterKey.VK_S => new[] { "$" }, + LetterKey.VK_COMMA => new[] { "«", "»", ",", "‘", "’", "„", "“" }, _ => Array.Empty<string>(), }; } @@ -857,6 +905,7 @@ namespace PowerAccent.Core LetterKey.VK_A => new[] { "å", "æ" }, LetterKey.VK_E => new[] { "€" }, LetterKey.VK_O => new[] { "ø" }, + LetterKey.VK_COMMA => new[] { "»", "«", "“", "”", "›", "‹", "‘", "’" }, _ => Array.Empty<string>(), }; } @@ -873,6 +922,7 @@ namespace PowerAccent.Core LetterKey.VK_S => new[] { "š" }, LetterKey.VK_U => new[] { "ų", "ū" }, LetterKey.VK_Z => new[] { "ž" }, + LetterKey.VK_COMMA => new[] { "„", "“", "‚", "‘" }, _ => Array.Empty<string>(), }; } @@ -886,6 +936,23 @@ namespace PowerAccent.Core LetterKey.VK_E => new[] { "€" }, LetterKey.VK_S => new[] { "š" }, LetterKey.VK_Z => new[] { "ž" }, + LetterKey.VK_COMMA => new[] { "„", "“", "»", "«" }, + _ => Array.Empty<string>(), + }; + } + + // Vietnamese + private static string[] GetDefaultLetterKeyVI(LetterKey letter) + { + return letter switch + { + LetterKey.VK_A => new[] { "à", "ả", "ã", "á", "ạ", "ă", "ằ", "ẳ", "ẵ", "ắ", "ặ", "â", "ầ", "ẩ", "ẫ", "ấ", "ậ" }, + LetterKey.VK_D => new[] { "đ" }, + LetterKey.VK_E => new[] { "è", "ẻ", "ẽ", "é", "ẹ", "ê", "ề", "ể", "ễ", "ế", "ệ" }, + LetterKey.VK_I => new[] { "ì", "ỉ", "ĩ", "í", "ị" }, + LetterKey.VK_O => new[] { "ò", "ỏ", "õ", "ó", "ọ", "ô", "ồ", "ổ", "ỗ", "ố", "ộ", "ơ", "ờ", "ở", "ỡ", "ớ", "ợ" }, + LetterKey.VK_U => new[] { "ù", "ủ", "ũ", "ú", "ụ", "ư", "ừ", "ử", "ữ", "ứ", "ự" }, + LetterKey.VK_Y => new[] { "ỳ", "ỷ", "ỹ", "ý", "ỵ" }, _ => Array.Empty<string>(), }; } diff --git a/src/modules/poweraccent/PowerAccent.Core/Models/UsageInfoData.cs b/src/modules/poweraccent/PowerAccent.Core/Models/UsageInfoData.cs new file mode 100644 index 0000000000..322e4eae79 --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.Core/Models/UsageInfoData.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. + +namespace PowerAccent.Core.Models +{ + public class UsageInfoData + { + public Dictionary<string, uint> CharacterUsageCounters { get; set; } = []; + + public Dictionary<string, long> CharacterUsageTimestamp { get; set; } = []; + } +} diff --git a/src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj b/src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj index 1f653884cf..49ae53eb23 100644 --- a/src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj +++ b/src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj @@ -1,8 +1,8 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> - <Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> diff --git a/src/modules/poweraccent/PowerAccent.Core/PowerAccent.cs b/src/modules/poweraccent/PowerAccent.Core/PowerAccent.cs index c2f0698f25..4811118adb 100644 --- a/src/modules/poweraccent/PowerAccent.Core/PowerAccent.cs +++ b/src/modules/poweraccent/PowerAccent.Core/PowerAccent.cs @@ -20,6 +20,7 @@ public partial class PowerAccent : IDisposable // Keys that show a description (like dashes) when ShowCharacterInfoSetting is 1 private readonly LetterKey[] _letterKeysShowingDescription = new LetterKey[] { LetterKey.VK_O }; + private const double ScreenMinPadding = 150; private bool _visible; private string[] _characters = Array.Empty<string>(); @@ -117,8 +118,8 @@ public partial class PowerAccent : IDisposable if (_settingService.SortByUsageFrequency) { characters = characters.OrderByDescending(character => _usageInfo.GetUsageFrequency(character)) - .ThenByDescending(character => _usageInfo.GetLastUsageTimestamp(character)). - ToArray<string>(); + .ThenByDescending(character => _usageInfo.GetLastUsageTimestamp(character)) + .ToArray<string>(); } else if (!_usageInfo.Empty()) { @@ -323,13 +324,17 @@ public partial class PowerAccent : IDisposable public Point GetDisplayCoordinates(Size window) { (Point Location, Size Size, double Dpi) activeDisplay = WindowsFunctions.GetActiveDisplay(); - double primaryDPI = Screen.PrimaryScreen.Bounds.Width / SystemParameters.PrimaryScreenWidth; - Rect screen = new Rect(activeDisplay.Location, activeDisplay.Size) / primaryDPI; + Rect screen = new(activeDisplay.Location, activeDisplay.Size); Position position = _settingService.Position; /* Debug.WriteLine("Dpi: " + activeDisplay.Dpi); */ - return Calculation.GetRawCoordinatesFromPosition(position, screen, window); + return Calculation.GetRawCoordinatesFromPosition(position, screen, window, activeDisplay.Dpi) / activeDisplay.Dpi; + } + + public double GetDisplayMaxWidth() + { + return WindowsFunctions.GetActiveDisplay().Size.Width - ScreenMinPadding; } public Position GetToolbarPosition() @@ -337,6 +342,14 @@ public partial class PowerAccent : IDisposable return _settingService.Position; } + public void SaveUsageInfo() + { + if (_settingService.SortByUsageFrequency) + { + _usageInfo.Save(); + } + } + public void Dispose() { _keyboardListener.UnInitHook(); @@ -345,20 +358,21 @@ public partial class PowerAccent : IDisposable public static string[] ToUpper(string[] array) { - string[] result = new string[array.Length]; + List<string> result = new(array.Length); for (int i = 0; i < array.Length; i++) { switch (array[i]) { - case "ß": result[i] = "ẞ"; break; - case "ǰ": result[i] = "J\u030c"; break; - case "ı\u0307\u0304": result[i] = "İ\u0304"; break; - case "ı": result[i] = "İ"; break; - case "ᵛ": result[i] = "ⱽ"; break; - default: result[i] = array[i].ToUpper(System.Globalization.CultureInfo.InvariantCulture); break; + case "ß": result.Add("ẞ"); break; + case "ǰ": result.Add("J\u030c"); break; + case "ı\u0307\u0304": result.Add("İ\u0304"); break; + case "ı": result.Add("İ"); break; + case "ᵛ": result.Add("ⱽ"); break; + case "ϑ": break; + default: result.Add(array[i].ToUpper(CultureInfo.InvariantCulture)); break; } } - return result; + return [..result]; } } diff --git a/src/modules/poweraccent/PowerAccent.Core/SerializationContext/SourceGenerationContext.cs b/src/modules/poweraccent/PowerAccent.Core/SerializationContext/SourceGenerationContext.cs index e682aa7b63..55fdd144a1 100644 --- a/src/modules/poweraccent/PowerAccent.Core/SerializationContext/SourceGenerationContext.cs +++ b/src/modules/poweraccent/PowerAccent.Core/SerializationContext/SourceGenerationContext.cs @@ -3,12 +3,14 @@ // See the LICENSE file in the project root for more information. using System.Text.Json.Serialization; +using PowerAccent.Core.Models; using PowerAccent.Core.Services; namespace PowerAccent.Core.SerializationContext; [JsonSourceGenerationOptions(WriteIndented = true)] [JsonSerializable(typeof(SettingsService))] +[JsonSerializable(typeof(UsageInfoData))] public partial class SourceGenerationContext : JsonSerializerContext { } diff --git a/src/modules/poweraccent/PowerAccent.Core/Services/SettingsService.cs b/src/modules/poweraccent/PowerAccent.Core/Services/SettingsService.cs index 1a1657ecaf..81e3d5c56f 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Services/SettingsService.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Services/SettingsService.cs @@ -24,7 +24,7 @@ public class SettingsService public SettingsService(KeyboardListener keyboardListener) { - _settingsUtils = new SettingsUtils(); + _settingsUtils = SettingsUtils.Default; _keyboardListener = keyboardListener; ReadSettings(); _watcher = Helper.GetFileWatcher(PowerAccentModuleName, "settings.json", () => { ReadSettings(); }); diff --git a/src/modules/poweraccent/PowerAccent.Core/Telemetry/PowerAccentShowAccentMenuEvent.cs b/src/modules/poweraccent/PowerAccent.Core/Telemetry/PowerAccentShowAccentMenuEvent.cs index 99c6fcdb8b..2fe32e293c 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Telemetry/PowerAccentShowAccentMenuEvent.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Telemetry/PowerAccentShowAccentMenuEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace PowerAccent.Core.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class PowerAccentShowAccentMenuEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/modules/poweraccent/PowerAccent.Core/Tools/Calculation.cs b/src/modules/poweraccent/PowerAccent.Core/Tools/Calculation.cs index 4929af535b..0945bfc99f 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Tools/Calculation.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Tools/Calculation.cs @@ -18,18 +18,18 @@ namespace PowerAccent.Core.Tools top < screen.Y ? caret.Y + 20 : top); } - public static Point GetRawCoordinatesFromPosition(Position position, Rect screen, Size window) + public static Point GetRawCoordinatesFromPosition(Position position, Rect screen, Size window, double dpi) { int offset = 24; double pointX = position switch { Position.Top or Position.Bottom or Position.Center - => screen.X + (screen.Width / 2) - (window.Width / 2), + => screen.X + (screen.Width / 2) - (window.Width * dpi / 2), Position.TopLeft or Position.Left or Position.BottomLeft => screen.X + offset, Position.TopRight or Position.Right or Position.BottomRight - => screen.X + screen.Width - (window.Width + offset), + => screen.X + screen.Width - ((window.Width * dpi) + offset), _ => throw new NotImplementedException(), }; @@ -38,9 +38,9 @@ namespace PowerAccent.Core.Tools Position.TopLeft or Position.Top or Position.TopRight => screen.Y + offset, Position.Left or Position.Center or Position.Right - => screen.Y + (screen.Height / 2) - (window.Height / 2), + => screen.Y + (screen.Height / 2) - (window.Height * dpi / 2), Position.BottomLeft or Position.Bottom or Position.BottomRight - => screen.Y + screen.Height - (window.Height + offset), + => screen.Y + screen.Height - ((window.Height * dpi) + offset), _ => throw new NotImplementedException(), }; diff --git a/src/modules/poweraccent/PowerAccent.Core/Tools/CharactersUsageInfo.cs b/src/modules/poweraccent/PowerAccent.Core/Tools/CharactersUsageInfo.cs index 07a448bbe0..ef2fa1db7e 100644 --- a/src/modules/poweraccent/PowerAccent.Core/Tools/CharactersUsageInfo.cs +++ b/src/modules/poweraccent/PowerAccent.Core/Tools/CharactersUsageInfo.cs @@ -2,12 +2,28 @@ // 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.IO; +using System.Text.Json; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerAccent.Core.Models; +using PowerAccent.Core.SerializationContext; + namespace PowerAccent.Core.Tools { public class CharactersUsageInfo { - private Dictionary<string, uint> _characterUsageCounters = new Dictionary<string, uint>(); - private Dictionary<string, long> _characterUsageTimestamp = new Dictionary<string, long>(); + private readonly string _filePath; + private readonly Dictionary<string, uint> _characterUsageCounters; + private readonly Dictionary<string, long> _characterUsageTimestamp; + + public CharactersUsageInfo() + { + _filePath = SettingsUtils.Default.GetSettingsFilePath(PowerAccentSettings.ModuleName, "UsageInfo.json"); + var data = GetUsageInfoData(); + _characterUsageCounters = data.CharacterUsageCounters; + _characterUsageTimestamp = data.CharacterUsageTimestamp; + } public bool Empty() { @@ -18,19 +34,18 @@ namespace PowerAccent.Core.Tools { _characterUsageCounters.Clear(); _characterUsageTimestamp.Clear(); + Delete(); } public uint GetUsageFrequency(string character) { _characterUsageCounters.TryGetValue(character, out uint frequency); - return frequency; } public long GetLastUsageTimestamp(string character) { _characterUsageTimestamp.TryGetValue(character, out long timestamp); - return timestamp; } @@ -47,5 +62,58 @@ namespace PowerAccent.Core.Tools _characterUsageTimestamp[character] = DateTimeOffset.Now.ToUnixTimeSeconds(); } + + public void Save() + { + var data = new UsageInfoData + { + CharacterUsageCounters = _characterUsageCounters, + CharacterUsageTimestamp = _characterUsageTimestamp, + }; + + try + { + var json = JsonSerializer.Serialize(data, SourceGenerationContext.Default.UsageInfoData); + File.WriteAllText(_filePath, json); + } + catch (Exception ex) + { + Logger.LogError("Failed to save usage file", ex); + } + } + + public void Delete() + { + try + { + if (File.Exists(_filePath)) + { + File.Delete(_filePath); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to delete usage file", ex); + } + } + + private UsageInfoData GetUsageInfoData() + { + if (!File.Exists(_filePath)) + { + return new UsageInfoData(); + } + + try + { + var json = File.ReadAllText(_filePath); + return JsonSerializer.Deserialize(json, SourceGenerationContext.Default.UsageInfoData) ?? new UsageInfoData(); + } + catch (Exception ex) + { + Logger.LogError("Failed to read usage file", ex); + return new UsageInfoData(); + } + } } } diff --git a/src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj b/src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj index 291a2a8c17..30111fbdf7 100644 --- a/src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj +++ b/src/modules/poweraccent/PowerAccent.UI/PowerAccent.UI.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> @@ -9,10 +9,11 @@ <UseWPF>true</UseWPF> <AllowUnsafeBlocks>True</AllowUnsafeBlocks> <ApplicationIcon>icon.ico</ApplicationIcon> + <ApplicationManifest>app.manifest</ApplicationManifest> <AssemblyName>PowerToys.PowerAccent</AssemblyName> <XamlDebuggingInformation>True</XamlDebuggingInformation> <StartupObject>PowerAccent.UI.Program</StartupObject> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> </PropertyGroup> diff --git a/src/modules/poweraccent/PowerAccent.UI/Selector.xaml.cs b/src/modules/poweraccent/PowerAccent.UI/Selector.xaml.cs index 529d4552e1..7eed6a9a1b 100644 --- a/src/modules/poweraccent/PowerAccent.UI/Selector.xaml.cs +++ b/src/modules/poweraccent/PowerAccent.UI/Selector.xaml.cs @@ -44,7 +44,6 @@ public partial class Selector : FluentWindow, IDisposable, INotifyPropertyChange Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this); Application.Current.MainWindow.ShowActivated = false; - Application.Current.MainWindow.Topmost = true; } protected override void OnSourceInitialized(EventArgs e) @@ -60,10 +59,14 @@ public partial class Selector : FluentWindow, IDisposable, INotifyPropertyChange _selectedIndex = index; characters.SelectedIndex = _selectedIndex; characterName.Text = _powerAccent.CharacterDescriptions[_selectedIndex]; + characters.ScrollIntoView(character); } private void PowerAccent_OnChangeDisplay(bool isActive, string[] chars) { + // Topmost is conditionally set here to address hybrid graphics issues on laptops. + this.Topmost = isActive; + CharacterNameVisibility = _powerAccent.ShowUnicodeDescription ? Visibility.Visible : Visibility.Collapsed; if (isActive) @@ -71,6 +74,7 @@ public partial class Selector : FluentWindow, IDisposable, INotifyPropertyChange characters.ItemsSource = chars; characters.SelectedIndex = _selectedIndex; this.UpdateLayout(); // Required for filling the actual width/height before positioning. + SetWindowsSize(); SetWindowPosition(); Show(); Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new PowerAccent.Core.Telemetry.PowerAccentShowAccentMenuEvent()); @@ -94,8 +98,14 @@ public partial class Selector : FluentWindow, IDisposable, INotifyPropertyChange this.Top = position.Y; } + private void SetWindowsSize() + { + this.characters.MaxWidth = _powerAccent.GetDisplayMaxWidth(); + } + protected override void OnClosed(EventArgs e) { + _powerAccent.SaveUsageInfo(); _powerAccent.Dispose(); base.OnClosed(e); } diff --git a/src/modules/poweraccent/PowerAccent.UI/app.manifest b/src/modules/poweraccent/PowerAccent.UI/app.manifest new file mode 100644 index 0000000000..4747d3bd23 --- /dev/null +++ b/src/modules/poweraccent/PowerAccent.UI/app.manifest @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8" standalone="yes"?> +<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> + </windowsSettings> + </application> +</assembly> \ No newline at end of file diff --git a/src/modules/poweraccent/PowerAccentKeyboardService/KeyboardListener.cpp b/src/modules/poweraccent/PowerAccentKeyboardService/KeyboardListener.cpp index 6969d7dd58..93fb5c0230 100644 --- a/src/modules/poweraccent/PowerAccentKeyboardService/KeyboardListener.cpp +++ b/src/modules/poweraccent/PowerAccentKeyboardService/KeyboardListener.cpp @@ -169,6 +169,14 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation if (std::find(letters.begin(), letters.end(), letterKey) != cend(letters) && m_isLanguageLetterCb(letterKey)) { + if (m_toolbarVisible && letterPressed == letterKey) + { + // On-screen keyboard continuously sends WM_KEYDOWN when a key is held down + // If Quick Accent is visible, prevent the letter key from being processed + // https://github.com/microsoft/PowerToys/issues/36853 + return true; + } + m_stopwatch.reset(); letterPressed = letterKey; } @@ -282,12 +290,11 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation LRESULT KeyboardListener::LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) { + if (nCode == HC_ACTION && s_instance != nullptr) { - if (nCode == HC_ACTION && s_instance != nullptr) + KBDLLHOOKSTRUCT* key = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam); + switch (wParam) { - KBDLLHOOKSTRUCT* key = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam); - switch (wParam) - { case WM_KEYDOWN: { if (s_instance->OnKeyDown(*key)) @@ -304,10 +311,9 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation } } break; - } } - - return CallNextHookEx(NULL, nCode, wParam, lParam); } + + return CallNextHookEx(NULL, nCode, wParam, lParam); } } diff --git a/src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj b/src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj index 4f96f21e66..6685359d5b 100644 --- a/src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj +++ b/src/modules/poweraccent/PowerAccentKeyboardService/PowerAccentKeyboardService.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -16,10 +17,9 @@ <ApplicationType>Windows Store</ApplicationType> <ApplicationTypeRevision>10.0</ApplicationTypeRevision> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <GenerateManifest>false</GenerateManifest> </PropertyGroup> @@ -46,7 +46,7 @@ <PropertyGroup Label="UserMacros" /> <PropertyGroup> <TargetName>PowerToys.PowerAccentKeyboardService</TargetName> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> @@ -105,10 +105,10 @@ <None Include="PropertySheet.props" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -116,15 +116,15 @@ <ResourceCompile Include="PowerAccentKeyboardService.rc" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/poweraccent/PowerAccentKeyboardService/packages.config b/src/modules/poweraccent/PowerAccentKeyboardService/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/poweraccent/PowerAccentKeyboardService/packages.config +++ b/src/modules/poweraccent/PowerAccentKeyboardService/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/poweraccent/PowerAccentModuleInterface/PowerAccentModuleInterface.vcxproj b/src/modules/poweraccent/PowerAccentModuleInterface/PowerAccentModuleInterface.vcxproj index 06f2722733..75aa524883 100644 --- a/src/modules/poweraccent/PowerAccentModuleInterface/PowerAccentModuleInterface.vcxproj +++ b/src/modules/poweraccent/PowerAccentModuleInterface/PowerAccentModuleInterface.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{34A354C5-23C7-4343-916C-C52DAF4FC39D}</ProjectGuid> @@ -8,7 +9,6 @@ <RootNamespace>PowerAccent</RootNamespace> <ProjectName>PowerAccentModuleInterface</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> </PropertyGroup> @@ -22,7 +22,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -30,7 +30,7 @@ <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -50,10 +50,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -64,17 +64,17 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/poweraccent/PowerAccentModuleInterface/packages.config b/src/modules/poweraccent/PowerAccentModuleInterface/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/poweraccent/PowerAccentModuleInterface/packages.config +++ b/src/modules/poweraccent/PowerAccentModuleInterface/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/MccsCapabilitiesParserTests.cs b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/MccsCapabilitiesParserTests.cs new file mode 100644 index 0000000000..55f5e574ff --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/MccsCapabilitiesParserTests.cs @@ -0,0 +1,737 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PowerDisplay.Common.Utils; + +namespace PowerDisplay.UnitTests; + +/// <summary> +/// Unit tests for MccsCapabilitiesParser class. +/// </summary> +[TestClass] +public class MccsCapabilitiesParserTests +{ + private const string DellU3011Capabilities = + "(prot(monitor)type(lcd)model(U3011)cmds(01 02 03 07 0C E3 F3)vcp(02 04 05 06 08 10 12 14(01 05 08 0B 0C) 16 18 1A 52 60(01 03 04 0C 0F 11 12) AC AE B2 B6 C6 C8 C9 D6(01 04 05) DC(00 02 03 04 05) DF FD)mccs_ver(2.1)mswhql(1))"; + + // Real capabilities string from Dell P2416D monitor + private const string DellP2416DCapabilities = + "(prot(monitor)type(LCD)model(P2416D)cmds(01 02 03 07 0C E3 F3) vcp(02 04 05 08 10 12 14(05 08 0B 0C) 16 18 1A 52 60(01 11 0F) AA(01 02) AC AE B2 B6 C6 C8 C9 D6(01 04 05) DC(00 02 03 05) DF E0 E1 E2(00 01 02 04 0E 12 14 19) F0(00 08) F1(01 02) F2 FD) mswhql(1)asset_eep(40)mccs_ver(2.1))"; + + // Simple test string + private const string SimpleCapabilities = + "(prot(monitor)type(lcd)model(TestMonitor)vcp(10 12)mccs_ver(2.2))"; + + // Capabilities without outer parentheses (some monitors like Apple Cinema Display) + private const string NoOuterParensCapabilities = + "prot(monitor)type(lcd)model(TestMonitor)vcp(10 12)mccs_ver(2.0)"; + + // Concatenated hex format (no spaces between hex bytes) + private const string ConcatenatedHexCapabilities = + "(prot(monitor)cmds(01020307)vcp(101214)mccs_ver(2.1))"; + + [TestMethod] + public void Parse_NullInput_ReturnsEmptyCapabilities() + { + // Act + var result = MccsCapabilitiesParser.Parse(null); + + // Assert + Assert.IsNotNull(result); + Assert.IsNotNull(result.Capabilities); + Assert.AreEqual(0, result.Capabilities.SupportedVcpCodes.Count); + Assert.IsFalse(result.HasErrors); + } + + [TestMethod] + public void Parse_EmptyString_ReturnsEmptyCapabilities() + { + // Act + var result = MccsCapabilitiesParser.Parse(string.Empty); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_WhitespaceOnly_ReturnsEmptyCapabilities() + { + // Act + var result = MccsCapabilitiesParser.Parse(" \t\n "); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_DellU3011_ParsesProtocol() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.AreEqual("monitor", result.Capabilities.Protocol); + } + + [TestMethod] + public void Parse_DellU3011_ParsesType() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.AreEqual("lcd", result.Capabilities.Type); + } + + [TestMethod] + public void Parse_DellU3011_ParsesModel() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.AreEqual("U3011", result.Capabilities.Model); + } + + [TestMethod] + public void Parse_DellU3011_ParsesMccsVersion() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.AreEqual("2.1", result.Capabilities.MccsVersion); + } + + [TestMethod] + public void Parse_DellU3011_ParsesCommands() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + var cmds = result.Capabilities.SupportedCommands; + Assert.IsNotNull(cmds); + Assert.AreEqual(7, cmds.Count); + CollectionAssert.Contains(cmds, (byte)0x01); + CollectionAssert.Contains(cmds, (byte)0x02); + CollectionAssert.Contains(cmds, (byte)0x03); + CollectionAssert.Contains(cmds, (byte)0x07); + CollectionAssert.Contains(cmds, (byte)0x0C); + CollectionAssert.Contains(cmds, (byte)0xE3); + CollectionAssert.Contains(cmds, (byte)0xF3); + } + + [TestMethod] + public void Parse_DellU3011_ParsesBrightnessVcpCode() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0x10 is Brightness + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + var brightnessInfo = result.Capabilities.GetVcpCodeInfo(0x10); + Assert.IsNotNull(brightnessInfo); + Assert.AreEqual(0x10, brightnessInfo.Value.Code); + Assert.IsTrue(brightnessInfo.Value.IsContinuous); + } + + [TestMethod] + public void Parse_DellU3011_ParsesContrastVcpCode() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0x12 is Contrast + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + } + + [TestMethod] + public void Parse_DellU3011_ParsesInputSourceWithDiscreteValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0x60 is Input Source with discrete values + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x60)); + var inputSourceInfo = result.Capabilities.GetVcpCodeInfo(0x60); + Assert.IsNotNull(inputSourceInfo); + Assert.IsTrue(inputSourceInfo.Value.HasDiscreteValues); + + // Should have values: 01 03 04 0C 0F 11 12 + var values = inputSourceInfo.Value.SupportedValues; + Assert.AreEqual(7, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x03)); + Assert.IsTrue(values.Contains(0x04)); + Assert.IsTrue(values.Contains(0x0C)); + Assert.IsTrue(values.Contains(0x0F)); + Assert.IsTrue(values.Contains(0x11)); + Assert.IsTrue(values.Contains(0x12)); + } + + [TestMethod] + public void Parse_DellU3011_ParsesColorPresetWithDiscreteValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0x14 is Color Preset + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x14)); + var colorPresetInfo = result.Capabilities.GetVcpCodeInfo(0x14); + Assert.IsNotNull(colorPresetInfo); + Assert.IsTrue(colorPresetInfo.Value.HasDiscreteValues); + + // Should have values: 01 05 08 0B 0C + var values = colorPresetInfo.Value.SupportedValues; + Assert.AreEqual(5, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x05)); + Assert.IsTrue(values.Contains(0x08)); + Assert.IsTrue(values.Contains(0x0B)); + Assert.IsTrue(values.Contains(0x0C)); + } + + [TestMethod] + public void Parse_DellU3011_ParsesPowerModeWithDiscreteValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP 0xD6 is Power Mode + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xD6)); + var powerModeInfo = result.Capabilities.GetVcpCodeInfo(0xD6); + Assert.IsNotNull(powerModeInfo); + Assert.IsTrue(powerModeInfo.Value.HasDiscreteValues); + + // Should have values: 01 04 05 + var values = powerModeInfo.Value.SupportedValues; + Assert.AreEqual(3, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x04)); + Assert.IsTrue(values.Contains(0x05)); + } + + [TestMethod] + public void Parse_DellU3011_TotalVcpCodeCount() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert - VCP codes: 02 04 05 06 08 10 12 14 16 18 1A 52 60 AC AE B2 B6 C6 C8 C9 D6 DC DF FD + Assert.AreEqual(24, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesModel() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert + Assert.AreEqual("P2416D", result.Capabilities.Model); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesTypeWithDifferentCase() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert - Type is "LCD" (uppercase) in this monitor + Assert.AreEqual("LCD", result.Capabilities.Type); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesMccsVersion() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert + Assert.AreEqual("2.1", result.Capabilities.MccsVersion); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesInputSourceWithThreeValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert - VCP 0x60 Input Source has values: 01 11 0F + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x60)); + var inputSourceInfo = result.Capabilities.GetVcpCodeInfo(0x60); + Assert.IsNotNull(inputSourceInfo); + + var values = inputSourceInfo.Value.SupportedValues; + Assert.AreEqual(3, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x11)); + Assert.IsTrue(values.Contains(0x0F)); + } + + [TestMethod] + public void Parse_DellP2416D_ParsesE2WithManyValues() + { + // Act + var result = MccsCapabilitiesParser.Parse(DellP2416DCapabilities); + + // Assert - VCP 0xE2 has values: 00 01 02 04 0E 12 14 19 + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xE2)); + var e2Info = result.Capabilities.GetVcpCodeInfo(0xE2); + Assert.IsNotNull(e2Info); + + var values = e2Info.Value.SupportedValues; + Assert.AreEqual(8, values.Count); + } + + [TestMethod] + public void Parse_NoOuterParentheses_StillParses() + { + // Act - Some monitors like Apple Cinema Display omit outer parens + var result = MccsCapabilitiesParser.Parse(NoOuterParensCapabilities); + + // Assert + Assert.AreEqual("monitor", result.Capabilities.Protocol); + Assert.AreEqual("lcd", result.Capabilities.Type); + Assert.AreEqual("TestMonitor", result.Capabilities.Model); + Assert.AreEqual("2.0", result.Capabilities.MccsVersion); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + } + + [TestMethod] + public void Parse_ConcatenatedHexFormat_ParsesCorrectly() + { + // Act - Some monitors output hex without spaces: cmds(01020307) + var result = MccsCapabilitiesParser.Parse(ConcatenatedHexCapabilities); + + // Assert + var cmds = result.Capabilities.SupportedCommands; + Assert.AreEqual(4, cmds.Count); + CollectionAssert.Contains(cmds, (byte)0x01); + CollectionAssert.Contains(cmds, (byte)0x02); + CollectionAssert.Contains(cmds, (byte)0x03); + CollectionAssert.Contains(cmds, (byte)0x07); + + // VCP codes without spaces + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x14)); + } + + [TestMethod] + public void Parse_NestedParenthesesInVcp_HandlesCorrectly() + { + // Arrange - VCP code 0x14 with nested discrete values + var input = "(vcp(14(01 05 08)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x14)); + var vcpInfo = result.Capabilities.GetVcpCodeInfo(0x14); + Assert.IsNotNull(vcpInfo); + Assert.AreEqual(3, vcpInfo.Value.SupportedValues.Count); + } + + [TestMethod] + public void Parse_MultipleVcpCodesWithMixedFormats_ParsesAll() + { + // Arrange - Mixed: some with values, some without + var input = "(vcp(10 12 14(01 05) 16 60(0F 11)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.AreEqual(5, result.Capabilities.SupportedVcpCodes.Count); + + // Continuous codes (no discrete values) + var brightness = result.Capabilities.GetVcpCodeInfo(0x10); + Assert.IsTrue(brightness?.IsContinuous ?? false); + + var contrast = result.Capabilities.GetVcpCodeInfo(0x12); + Assert.IsTrue(contrast?.IsContinuous ?? false); + + // Discrete codes (with values) + var colorPreset = result.Capabilities.GetVcpCodeInfo(0x14); + Assert.IsTrue(colorPreset?.HasDiscreteValues ?? false); + Assert.AreEqual(2, colorPreset?.SupportedValues.Count); + + var inputSource = result.Capabilities.GetVcpCodeInfo(0x60); + Assert.IsTrue(inputSource?.HasDiscreteValues ?? false); + Assert.AreEqual(2, inputSource?.SupportedValues.Count); + } + + [TestMethod] + public void Parse_UnknownSegments_DoesNotFail() + { + // Arrange - Contains unknown segments like mswhql and asset_eep + var input = "(prot(monitor)mswhql(1)asset_eep(40)vcp(10))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsFalse(result.HasErrors); + Assert.AreEqual("monitor", result.Capabilities.Protocol); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + } + + [TestMethod] + public void Parse_ExtraWhitespace_HandlesCorrectly() + { + // Arrange - Extra spaces everywhere + var input = "( prot( monitor ) type( lcd ) vcp( 10 12 14( 01 05 ) ) )"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.AreEqual("monitor", result.Capabilities.Protocol); + Assert.AreEqual("lcd", result.Capabilities.Type); + Assert.AreEqual(3, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_LowercaseHex_ParsesCorrectly() + { + // Arrange - All lowercase hex + var input = "(cmds(01 0c e3 f3)vcp(10 ac ae))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + CollectionAssert.Contains(result.Capabilities.SupportedCommands, (byte)0xE3); + CollectionAssert.Contains(result.Capabilities.SupportedCommands, (byte)0xF3); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xAC)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xAE)); + } + + [TestMethod] + public void Parse_MixedCaseHex_ParsesCorrectly() + { + // Arrange - Mixed case hex + var input = "(vcp(Aa Bb cC Dd))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xAA)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xBB)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xCC)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0xDD)); + } + + [TestMethod] + public void Parse_MalformedInput_ReturnsPartialResults() + { + // Arrange - Missing closing paren for vcp section + var input = "(prot(monitor)vcp(10 12"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert - Should still parse what it can + Assert.AreEqual("monitor", result.Capabilities.Protocol); + + // VCP codes should still be parsed + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + } + + [TestMethod] + public void Parse_InvalidHexInVcp_SkipsAndContinues() + { + // Arrange - Contains invalid hex "GG" + var input = "(vcp(10 GG 12 14))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert - Should skip invalid and parse valid codes + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x14)); + Assert.AreEqual(3, result.Capabilities.SupportedVcpCodes.Count); + } + + [TestMethod] + public void Parse_SingleCharacterHex_Skipped() + { + // Arrange - Single char "A" is not valid (need 2 chars) + var input = "(vcp(10 A 12))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert - Should only have 10 and 12 + Assert.AreEqual(2, result.Capabilities.SupportedVcpCodes.Count); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x10)); + Assert.IsTrue(result.Capabilities.SupportsVcpCode(0x12)); + } + + [TestMethod] + public void GetVcpCodesAsHexStrings_ReturnsSortedList() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(60 10 14 12))"); + + // Act + var hexStrings = result.Capabilities.GetVcpCodesAsHexStrings(); + + // Assert - Should be sorted + Assert.AreEqual(4, hexStrings.Count); + Assert.AreEqual("0x10", hexStrings[0]); + Assert.AreEqual("0x12", hexStrings[1]); + Assert.AreEqual("0x14", hexStrings[2]); + Assert.AreEqual("0x60", hexStrings[3]); + } + + [TestMethod] + public void GetSortedVcpCodes_ReturnsSortedEnumerable() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(60 10 14 12))"); + + // Act + var sortedCodes = result.Capabilities.GetSortedVcpCodes().ToList(); + + // Assert + Assert.AreEqual(0x10, sortedCodes[0].Code); + Assert.AreEqual(0x12, sortedCodes[1].Code); + Assert.AreEqual(0x14, sortedCodes[2].Code); + Assert.AreEqual(0x60, sortedCodes[3].Code); + } + + [TestMethod] + public void HasDiscreteValues_ContinuousCode_ReturnsFalse() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(10))"); + + // Act & Assert + Assert.IsFalse(result.Capabilities.HasDiscreteValues(0x10)); + } + + [TestMethod] + public void HasDiscreteValues_DiscreteCode_ReturnsTrue() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(60(01 11)))"); + + // Act & Assert + Assert.IsTrue(result.Capabilities.HasDiscreteValues(0x60)); + } + + [TestMethod] + public void GetSupportedValues_DiscreteCode_ReturnsValues() + { + // Arrange + var result = MccsCapabilitiesParser.Parse("(vcp(60(01 11 0F)))"); + + // Act + var values = result.Capabilities.GetSupportedValues(0x60); + + // Assert + Assert.IsNotNull(values); + Assert.AreEqual(3, values.Count); + Assert.IsTrue(values.Contains(0x01)); + Assert.IsTrue(values.Contains(0x11)); + Assert.IsTrue(values.Contains(0x0F)); + } + + [TestMethod] + public void IsValid_ValidCapabilities_ReturnsTrue() + { + // Arrange & Act + var result = MccsCapabilitiesParser.Parse(DellU3011Capabilities); + + // Assert + Assert.IsTrue(result.IsValid); + Assert.IsFalse(result.HasErrors); + } + + [TestMethod] + public void IsValid_EmptyVcpCodes_ReturnsFalse() + { + // Arrange & Act + var result = MccsCapabilitiesParser.Parse("(prot(monitor)type(lcd))"); + + // Assert - No VCP codes = not valid + Assert.IsFalse(result.IsValid); + } + + [TestMethod] + public void Capabilities_RawProperty_ContainsOriginalString() + { + // Arrange & Act + var result = MccsCapabilitiesParser.Parse(SimpleCapabilities); + + // Assert + Assert.AreEqual(SimpleCapabilities, result.Capabilities.Raw); + } + + [TestMethod] + public void Parse_Window1Segment_ParsesCorrectly() + { + // Arrange - Full window segment with all fields + var input = "(vcp(10)window1(type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual(1, result.Capabilities.Windows.Count); + + var window = result.Capabilities.Windows[0]; + Assert.AreEqual(1, window.WindowNumber); + Assert.AreEqual("PIP", window.Type); + Assert.AreEqual(25, window.Area.X1); + Assert.AreEqual(25, window.Area.Y1); + Assert.AreEqual(1895, window.Area.X2); + Assert.AreEqual(1175, window.Area.Y2); + Assert.AreEqual(640, window.MaxSize.Width); + Assert.AreEqual(480, window.MaxSize.Height); + Assert.AreEqual(10, window.MinSize.Width); + Assert.AreEqual(10, window.MinSize.Height); + Assert.AreEqual(10, window.WindowId); + } + + [TestMethod] + public void Parse_MultipleWindows_ParsesAll() + { + // Arrange - Two windows (PIP and PBP) + var input = "(window1(type(PIP) area(0 0 640 480))window2(type(PBP) area(640 0 1280 480)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual(2, result.Capabilities.Windows.Count); + + var window1 = result.Capabilities.Windows[0]; + Assert.AreEqual(1, window1.WindowNumber); + Assert.AreEqual("PIP", window1.Type); + Assert.AreEqual(0, window1.Area.X1); + Assert.AreEqual(640, window1.Area.X2); + + var window2 = result.Capabilities.Windows[1]; + Assert.AreEqual(2, window2.WindowNumber); + Assert.AreEqual("PBP", window2.Type); + Assert.AreEqual(640, window2.Area.X1); + Assert.AreEqual(1280, window2.Area.X2); + } + + [TestMethod] + public void Parse_WindowWithMissingFields_HandlesGracefully() + { + // Arrange - Window with only type and area (missing max, min, window) + var input = "(window1(type(PIP) area(0 0 640 480)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual(1, result.Capabilities.Windows.Count); + + var window = result.Capabilities.Windows[0]; + Assert.AreEqual(1, window.WindowNumber); + Assert.AreEqual("PIP", window.Type); + Assert.AreEqual(640, window.Area.X2); + Assert.AreEqual(480, window.Area.Y2); + + // Default values for missing fields + Assert.AreEqual(0, window.MaxSize.Width); + Assert.AreEqual(0, window.MinSize.Width); + Assert.AreEqual(0, window.WindowId); + } + + [TestMethod] + public void Parse_WindowWithOnlyType_ParsesType() + { + // Arrange + var input = "(window1(type(PBP)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual(1, result.Capabilities.Windows.Count); + Assert.AreEqual("PBP", result.Capabilities.Windows[0].Type); + } + + [TestMethod] + public void Parse_NoWindowSegment_HasWindowSupportFalse() + { + // Arrange + var input = "(prot(monitor)vcp(10 12))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsFalse(result.Capabilities.HasWindowSupport); + Assert.AreEqual(0, result.Capabilities.Windows.Count); + } + + [TestMethod] + public void Parse_WindowAreaDimensions_CalculatesCorrectly() + { + // Arrange + var input = "(window1(area(100 200 500 600)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + var area = result.Capabilities.Windows[0].Area; + Assert.AreEqual(400, area.Width); // 500 - 100 + Assert.AreEqual(400, area.Height); // 600 - 200 + } + + [TestMethod] + public void Parse_RealWorldMccsWindowExample_ParsesCorrectly() + { + // Arrange - Example from MCCS 2.2a specification + var input = "(prot(display)type(lcd)model(PD3220U)cmds(01 02 03)vcp(10 12)mccs_ver(2.2)window1(type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10)))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.AreEqual("lcd", result.Capabilities.Type); + Assert.AreEqual("PD3220U", result.Capabilities.Model); + Assert.AreEqual("2.2", result.Capabilities.MccsVersion); + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual("PIP", result.Capabilities.Windows[0].Type); + } + + [TestMethod] + public void Parse_WindowWithExtraSpaces_HandlesCorrectly() + { + // Arrange - Extra spaces in content + var input = "(window1( type( PIP ) area( 0 0 640 480 ) ))"; + + // Act + var result = MccsCapabilitiesParser.Parse(input); + + // Assert + Assert.IsTrue(result.Capabilities.HasWindowSupport); + Assert.AreEqual("PIP", result.Capabilities.Windows[0].Type); + Assert.AreEqual(640, result.Capabilities.Windows[0].Area.X2); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj new file mode 100644 index 0000000000..fb7e2474db --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj @@ -0,0 +1,41 @@ +<!-- Copyright (c) Microsoft Corporation. All rights reserved. --> +<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. --> +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <RootNamespace>PowerDisplay.UnitTests</RootNamespace> + <Platforms>x64;ARM64</Platforms> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerDisplay.Lib.UnitTests\</OutputPath> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <!-- Hide build log files from Solution Explorer --> + <None Remove="*.log" /> + <None Remove="*.binlog" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <PackageReference Include="Moq" /> + <PackageReference Include="System.CodeDom"> + <!-- This package is a transitive dependency, but we need to set it here so we can exclude the assets, + so it doesn't conflict with the dll coming from .NET SDK. --> + <ExcludeAssets>runtime</ExcludeAssets> + </PackageReference> + <PackageReference Include="System.Diagnostics.EventLog"> + <!-- This package is a transitive dependency, but we need to set it here so we can exclude the assets, + so it doesn't conflict with the dll coming from .NET SDK. --> + <ExcludeAssets>runtime</ExcludeAssets> + </PackageReference> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\PowerDisplay.Lib\PowerDisplay.Lib.csproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiController.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiController.cs new file mode 100644 index 0000000000..e268045624 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiController.cs @@ -0,0 +1,725 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Polly; +using Polly.Retry; +using PowerDisplay.Common.Interfaces; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; +using static PowerDisplay.Common.Drivers.NativeConstants; +using static PowerDisplay.Common.Drivers.NativeDelegates; +using static PowerDisplay.Common.Drivers.PInvoke; +using Monitor = PowerDisplay.Common.Models.Monitor; + +// Type aliases matching Windows API naming conventions for better readability when working with native structures. +// These uppercase aliases are used consistently throughout this file to match Win32 API documentation. +using MONITORINFOEX = PowerDisplay.Common.Drivers.MonitorInfoEx; +using PHYSICAL_MONITOR = PowerDisplay.Common.Drivers.PhysicalMonitor; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// <summary> + /// DDC/CI monitor controller for controlling external monitors + /// </summary> + public partial class DdcCiController : IMonitorController, IDisposable + { + /// <summary> + /// Represents a candidate monitor discovered during Phase 1 of monitor enumeration. + /// </summary> + /// <param name="Handle">Physical monitor handle for DDC/CI communication</param> + /// <param name="PhysicalMonitor">Native physical monitor structure with description</param> + /// <param name="MonitorInfo">Display info from QueryDisplayConfig (EdidId, FriendlyName, MonitorNumber)</param> + private readonly record struct CandidateMonitor( + IntPtr Handle, + PHYSICAL_MONITOR PhysicalMonitor, + MonitorDisplayInfo MonitorInfo); + + /// <summary> + /// Delay between retry attempts for DDC/CI operations (in milliseconds) + /// </summary> + private const int RetryDelayMs = 100; + + /// <summary> + /// Retry pipeline for getting capabilities string length (3 retries). + /// </summary> + private static readonly ResiliencePipeline<uint> CapabilitiesLengthRetryPipeline = + new ResiliencePipelineBuilder<uint>() + .AddRetry(new RetryStrategyOptions<uint> + { + MaxRetryAttempts = 2, // 2 retries = 3 total attempts + Delay = TimeSpan.FromMilliseconds(RetryDelayMs), + ShouldHandle = new PredicateBuilder<uint>().HandleResult(len => len == 0), + OnRetry = static args => + { + Logger.LogWarning($"[Retry] GetCapabilitiesStringLength returned invalid result on attempt {args.AttemptNumber + 1}, retrying..."); + return default; + }, + }) + .Build(); + + /// <summary> + /// Retry pipeline for getting capabilities string (5 retries). + /// </summary> + private static readonly ResiliencePipeline<string?> CapabilitiesStringRetryPipeline = + new ResiliencePipelineBuilder<string?>() + .AddRetry(new RetryStrategyOptions<string?> + { + MaxRetryAttempts = 4, // 4 retries = 5 total attempts + Delay = TimeSpan.FromMilliseconds(RetryDelayMs), + ShouldHandle = new PredicateBuilder<string?>().HandleResult(static str => string.IsNullOrEmpty(str)), + OnRetry = static args => + { + Logger.LogWarning($"[Retry] GetCapabilitiesString returned invalid result on attempt {args.AttemptNumber + 1}, retrying..."); + return default; + }, + }) + .Build(); + + private readonly PhysicalMonitorHandleManager _handleManager = new(); + private readonly MonitorDiscoveryHelper _discoveryHelper; + + private bool _disposed; + + public DdcCiController() + { + _discoveryHelper = new MonitorDiscoveryHelper(); + } + + public string Name => "DDC/CI Monitor Controller"; + + /// <summary> + /// Get monitor brightness using VCP code 0x10 + /// </summary> + public async Task<VcpFeatureValue> GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + return await GetVcpFeatureAsync(monitor, VcpCodeBrightness, cancellationToken); + } + + /// <summary> + /// Set monitor brightness using VCP code 0x10 + /// </summary> + public Task<MonitorOperationResult> SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeBrightness, brightness, cancellationToken); + + /// <summary> + /// Set monitor contrast + /// </summary> + public Task<MonitorOperationResult> SetContrastAsync(Monitor monitor, int contrast, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeContrast, contrast, cancellationToken); + + /// <summary> + /// Set monitor volume + /// </summary> + public Task<MonitorOperationResult> SetVolumeAsync(Monitor monitor, int volume, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, NativeConstants.VcpCodeVolume, volume, cancellationToken); + + /// <summary> + /// Get monitor color temperature using VCP code 0x14 (Select Color Preset) + /// Returns the raw VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature + /// </summary> + public async Task<VcpFeatureValue> GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + return await GetVcpFeatureAsync(monitor, VcpCodeSelectColorPreset, cancellationToken); + } + + /// <summary> + /// Set monitor color temperature using VCP code 0x14 (Select Color Preset) + /// </summary> + public Task<MonitorOperationResult> SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, VcpCodeSelectColorPreset, colorTemperature, cancellationToken); + + /// <summary> + /// Get current input source using VCP code 0x60 + /// Returns the raw VCP value (e.g., 0x11 for HDMI-1) + /// </summary> + public async Task<VcpFeatureValue> GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + return await GetVcpFeatureAsync(monitor, VcpCodeInputSource, cancellationToken); + } + + /// <summary> + /// Set input source using VCP code 0x60 + /// </summary> + public Task<MonitorOperationResult> SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, VcpCodeInputSource, inputSource, cancellationToken); + + /// <summary> + /// Set power state using VCP code 0xD6 (Power Mode). + /// Values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard). + /// Note: Setting any value other than 0x01 (On) will turn off the display. + /// </summary> + public Task<MonitorOperationResult> SetPowerStateAsync(Monitor monitor, int powerState, CancellationToken cancellationToken = default) + => SetVcpFeatureAsync(monitor, VcpCodePowerMode, powerState, cancellationToken); + + /// <summary> + /// Get current power state using VCP code 0xD6 (Power Mode). + /// Returns the raw VCP value (0x01=On, 0x02=Standby, etc.) + /// </summary> + public async Task<VcpFeatureValue> GetPowerStateAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + return await GetVcpFeatureAsync(monitor, VcpCodePowerMode, cancellationToken); + } + + /// <summary> + /// Get monitor capabilities string with retry logic. + /// Uses cached CapabilitiesRaw if available to avoid slow I2C operations. + /// </summary> + public async Task<string> GetCapabilitiesStringAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + + // Check if capabilities are already cached + if (!string.IsNullOrEmpty(monitor.CapabilitiesRaw)) + { + return monitor.CapabilitiesRaw; + } + + return await Task.Run( + () => + { + if (monitor.Handle == IntPtr.Zero) + { + return string.Empty; + } + + try + { + // Step 1: Get capabilities string length with retry + var length = CapabilitiesLengthRetryPipeline.Execute(() => + { + if (GetCapabilitiesStringLength(monitor.Handle, out uint len) && len > 0) + { + return len; + } + + return 0u; + }); + + if (length == 0) + { + Logger.LogWarning("[Retry] GetCapabilitiesStringLength failed after 3 attempts"); + return string.Empty; + } + + // Step 2: Get actual capabilities string with retry + var capsString = CapabilitiesStringRetryPipeline.Execute( + () => TryGetCapabilitiesString(monitor.Handle, length)); + + if (!string.IsNullOrEmpty(capsString)) + { + return capsString; + } + + Logger.LogWarning("[Retry] GetCapabilitiesString failed after 5 attempts"); + } + catch (Exception ex) + { + Logger.LogError($"Exception getting capabilities string: {ex.Message}"); + } + + return string.Empty; + }, + cancellationToken); + } + + /// <summary> + /// Try to get capabilities string from monitor handle. + /// </summary> + private string? TryGetCapabilitiesString(IntPtr handle, uint length) + { + var buffer = System.Runtime.InteropServices.Marshal.AllocHGlobal((int)length); + try + { + if (CapabilitiesRequestAndCapabilitiesReply(handle, buffer, length)) + { + return System.Runtime.InteropServices.Marshal.PtrToStringAnsi(buffer); + } + + return null; + } + finally + { + System.Runtime.InteropServices.Marshal.FreeHGlobal(buffer); + } + } + + /// <summary> + /// Discover supported monitors using a three-phase approach: + /// Phase 1: Enumerate and collect candidate monitors with their handles + /// Phase 2: Fetch DDC/CI capabilities in parallel (slow I2C operations) + /// Phase 3: Create Monitor objects for valid DDC/CI monitors + /// </summary> + public async Task<IEnumerable<Monitor>> DiscoverMonitorsAsync(CancellationToken cancellationToken = default) + { + try + { + // Get monitor display info from QueryDisplayConfig, keyed by device path (unique per target) + var allMonitorDisplayInfo = DdcCiNative.GetAllMonitorDisplayInfo(); + + // Phase 1: Collect candidate monitors + var monitorHandles = EnumerateMonitorHandles(); + if (monitorHandles.Count == 0) + { + return Enumerable.Empty<Monitor>(); + } + + var candidateMonitors = await CollectCandidateMonitorsAsync( + monitorHandles, allMonitorDisplayInfo, cancellationToken); + + if (candidateMonitors.Count == 0) + { + return Enumerable.Empty<Monitor>(); + } + + // Phase 2: Fetch capabilities in parallel + var fetchResults = await FetchCapabilitiesInParallelAsync( + candidateMonitors, cancellationToken); + + // Phase 3: Create monitor objects + return CreateValidMonitors(fetchResults); + } + catch (Exception ex) + { + Logger.LogError($"DDC: DiscoverMonitorsAsync exception: {ex.Message}\nStack: {ex.StackTrace}"); + return Enumerable.Empty<Monitor>(); + } + } + + /// <summary> + /// Enumerate all logical monitor handles using Win32 API. + /// </summary> + private List<IntPtr> EnumerateMonitorHandles() + { + var handles = new List<IntPtr>(); + + bool EnumProc(IntPtr hMonitor, IntPtr hdcMonitor, IntPtr lprcMonitor, IntPtr dwData) + { + handles.Add(hMonitor); + return true; + } + + if (!EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, EnumProc, IntPtr.Zero)) + { + Logger.LogWarning("DDC: EnumDisplayMonitors failed"); + } + + return handles; + } + + /// <summary> + /// Get GDI device name for a monitor handle (e.g., "\\.\DISPLAY1"). + /// </summary> + private unsafe string? GetGdiDeviceName(IntPtr hMonitor) + { + var monitorInfo = new MONITORINFOEX { CbSize = (uint)sizeof(MONITORINFOEX) }; + if (GetMonitorInfo(hMonitor, &monitorInfo)) + { + return monitorInfo.GetDeviceName(); + } + + return null; + } + + /// <summary> + /// Phase 1: Collect all candidate monitors with their physical handles. + /// Matches physical monitors with MonitorDisplayInfo using GDI device name and friendly name. + /// Supports mirror mode where multiple physical monitors share the same GDI name. + /// </summary> + private async Task<List<CandidateMonitor>> CollectCandidateMonitorsAsync( + List<IntPtr> monitorHandles, + Dictionary<string, MonitorDisplayInfo> allMonitorDisplayInfo, + CancellationToken cancellationToken) + { + var candidates = new List<CandidateMonitor>(); + + foreach (var hMonitor in monitorHandles) + { + // Get GDI device name for this monitor (e.g., "\\.\DISPLAY1") + var gdiDeviceName = GetGdiDeviceName(hMonitor); + if (string.IsNullOrEmpty(gdiDeviceName)) + { + Logger.LogWarning($"DDC: Failed to get GDI device name for hMonitor 0x{hMonitor:X}"); + continue; + } + + var physicalMonitors = await GetPhysicalMonitorsWithRetryAsync(hMonitor, cancellationToken); + if (physicalMonitors == null || physicalMonitors.Length == 0) + { + Logger.LogWarning($"DDC: Failed to get physical monitors for {gdiDeviceName} after retries"); + continue; + } + + // Find all MonitorDisplayInfo entries that match this GDI device name + // In mirror mode, multiple targets share the same GDI name + var matchingInfos = allMonitorDisplayInfo.Values + .Where(info => string.Equals(info.GdiDeviceName, gdiDeviceName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (matchingInfos.Count == 0) + { + Logger.LogWarning($"DDC: No QueryDisplayConfig info for {gdiDeviceName}, skipping"); + continue; + } + + for (int i = 0; i < physicalMonitors.Length; i++) + { + var physicalMonitor = physicalMonitors[i]; + + if (i >= matchingInfos.Count) + { + Logger.LogWarning($"DDC: Physical monitor index {i} exceeds available QueryDisplayConfig entries ({matchingInfos.Count}) for {gdiDeviceName}"); + break; + } + + var monitorInfo = matchingInfos[i]; + + candidates.Add(new CandidateMonitor(physicalMonitor.HPhysicalMonitor, physicalMonitor, monitorInfo)); + } + } + + return candidates; + } + + /// <summary> + /// Phase 2: Fetch DDC/CI capabilities in parallel for all candidate monitors. + /// This is the slow I2C operation (~4s per monitor), but parallelization + /// significantly reduces total time when multiple monitors are connected. + /// </summary> + private async Task<(CandidateMonitor Candidate, DdcCiValidationResult Result)[]> FetchCapabilitiesInParallelAsync( + List<CandidateMonitor> candidates, + CancellationToken cancellationToken) + { + var tasks = candidates.Select(candidate => + Task.Run( + () => (Candidate: candidate, Result: DdcCiNative.FetchCapabilities(candidate.Handle)), + cancellationToken)); + + var results = await Task.WhenAll(tasks); + + return results; + } + + /// <summary> + /// Phase 3: Create Monitor objects for valid DDC/CI monitors. + /// A monitor is valid if it has capabilities with brightness support. + /// </summary> + private List<Monitor> CreateValidMonitors( + (CandidateMonitor Candidate, DdcCiValidationResult Result)[] fetchResults) + { + var monitors = new List<Monitor>(); + var newHandleMap = new Dictionary<string, IntPtr>(); + + foreach (var (candidate, capResult) in fetchResults) + { + if (!capResult.IsValid) + { + continue; + } + + var monitor = _discoveryHelper.CreateMonitorFromPhysical( + candidate.PhysicalMonitor, + candidate.MonitorInfo); + + if (monitor == null) + { + continue; + } + + // Set capabilities data + if (!string.IsNullOrEmpty(capResult.CapabilitiesString)) + { + monitor.CapabilitiesRaw = capResult.CapabilitiesString; + } + + if (capResult.VcpCapabilitiesInfo != null) + { + monitor.VcpCapabilitiesInfo = capResult.VcpCapabilitiesInfo; + UpdateMonitorCapabilitiesFromVcp(monitor, capResult.VcpCapabilitiesInfo); + + // Initialize input source if supported + if (monitor.SupportsInputSource) + { + InitializeInputSource(monitor, candidate.Handle); + } + + // Initialize color temperature if supported + if (monitor.SupportsColorTemperature) + { + InitializeColorTemperature(monitor, candidate.Handle); + } + + // Initialize power state if supported + if (monitor.SupportsPowerState) + { + InitializePowerState(monitor, candidate.Handle); + } + + // Initialize contrast if supported + if (monitor.SupportsContrast) + { + InitializeContrast(monitor, candidate.Handle); + } + } + + // Initialize brightness (always supported for DDC/CI monitors) + InitializeBrightness(monitor, candidate.Handle); + + monitors.Add(monitor); + newHandleMap[monitor.Id] = candidate.Handle; + } + + _handleManager.UpdateHandleMap(newHandleMap); + return monitors; + } + + /// <summary> + /// Initialize input source value for a monitor using VCP 0x60. + /// </summary> + private static void InitializeInputSource(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodeInputSource, monitor.Id, out uint current, out uint _)) + { + monitor.CurrentInputSource = (int)current; + } + } + + /// <summary> + /// Initialize color temperature value for a monitor using VCP 0x14. + /// </summary> + private static void InitializeColorTemperature(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodeSelectColorPreset, monitor.Id, out uint current, out uint _)) + { + monitor.CurrentColorTemperature = (int)current; + } + } + + /// <summary> + /// Initialize power state value for a monitor using VCP 0xD6. + /// </summary> + private static void InitializePowerState(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodePowerMode, monitor.Id, out uint current, out uint _)) + { + monitor.CurrentPowerState = (int)current; + } + } + + /// <summary> + /// Initialize brightness value for a monitor using VCP 0x10. + /// </summary> + private static void InitializeBrightness(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodeBrightness, monitor.Id, out uint current, out uint max)) + { + var brightnessInfo = new VcpFeatureValue((int)current, 0, (int)max); + monitor.CurrentBrightness = brightnessInfo.ToPercentage(); + } + } + + /// <summary> + /// Initialize contrast value for a monitor using VCP 0x12. + /// </summary> + private static void InitializeContrast(Monitor monitor, IntPtr handle) + { + if (TryGetVcpFeature(handle, VcpCodeContrast, monitor.Id, out uint current, out uint max)) + { + var contrastInfo = new VcpFeatureValue((int)current, 0, (int)max); + monitor.CurrentContrast = contrastInfo.ToPercentage(); + } + } + + /// <summary> + /// Wrapper for GetVCPFeatureAndVCPFeatureReply that logs errors on failure. + /// </summary> + /// <param name="handle">Physical monitor handle</param> + /// <param name="vcpCode">VCP code to read</param> + /// <param name="monitorId">Monitor ID for logging (optional)</param> + /// <param name="currentValue">Output: current value</param> + /// <param name="maxValue">Output: maximum value</param> + /// <returns>True if successful, false otherwise</returns> + private static bool TryGetVcpFeature(IntPtr handle, byte vcpCode, string? monitorId, out uint currentValue, out uint maxValue) + { + if (GetVCPFeatureAndVCPFeatureReply(handle, vcpCode, IntPtr.Zero, out currentValue, out maxValue)) + { + return true; + } + + var lastError = GetLastError(); + var monitorPrefix = string.IsNullOrEmpty(monitorId) ? string.Empty : $"[{monitorId}] "; + Logger.LogError($"{monitorPrefix}Failed to read VCP 0x{vcpCode:X2}, error code: {lastError}"); + return false; + } + + /// <summary> + /// Update monitor capability flags based on parsed VCP capabilities. + /// </summary> + private static void UpdateMonitorCapabilitiesFromVcp(Monitor monitor, VcpCapabilities vcpCaps) + { + // Check for Contrast support (VCP 0x12) + if (vcpCaps.SupportsVcpCode(VcpCodeContrast)) + { + monitor.Capabilities |= MonitorCapabilities.Contrast; + } + + // Check for Volume support (VCP 0x62) + if (vcpCaps.SupportsVcpCode(VcpCodeVolume)) + { + monitor.Capabilities |= MonitorCapabilities.Volume; + } + + // Check for Color Temperature support (VCP 0x14) + if (vcpCaps.SupportsVcpCode(VcpCodeSelectColorPreset)) + { + monitor.SupportsColorTemperature = true; + } + } + + /// <summary> + /// Get physical monitors with retry logic to handle Windows API occasionally returning NULL handles. + /// NULL handles are automatically filtered out by GetPhysicalMonitors; retry if any were filtered. + /// </summary> + /// <param name="hMonitor">Handle to the monitor</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>Array of valid physical monitors, or null if failed after retries</returns> + private async Task<PHYSICAL_MONITOR[]?> GetPhysicalMonitorsWithRetryAsync( + IntPtr hMonitor, + CancellationToken cancellationToken) + { + const int maxRetries = 3; + const int retryDelayMs = 200; + + for (int attempt = 0; attempt < maxRetries; attempt++) + { + if (attempt > 0) + { + await Task.Delay(retryDelayMs, cancellationToken); + } + + var monitors = _discoveryHelper.GetPhysicalMonitors(hMonitor, out bool hasNullHandles); + + // Success: got valid monitors with no NULL handles filtered out + if (monitors != null && !hasNullHandles) + { + return monitors; + } + + // Got monitors but some had NULL handles - retry to see if API stabilizes + if (monitors != null && hasNullHandles && attempt < maxRetries - 1) + { + Logger.LogWarning($"DDC: Some monitors had NULL handles on attempt {attempt + 1}, will retry"); + continue; + } + + // No monitors returned - retry + if (monitors == null && attempt < maxRetries - 1) + { + Logger.LogWarning($"DDC: GetPhysicalMonitors returned null on attempt {attempt + 1}, will retry"); + continue; + } + + // Last attempt - return whatever we have (may have NULL handles filtered) + if (monitors != null && hasNullHandles) + { + Logger.LogWarning($"DDC: NULL handles still present after {maxRetries} attempts, using filtered result"); + } + + return monitors; + } + + return null; + } + + /// <summary> + /// Generic method to get VCP feature value. + /// </summary> + /// <param name="monitor">Monitor to query</param> + /// <param name="vcpCode">VCP code to read</param> + /// <param name="cancellationToken">Cancellation token</param> + private async Task<VcpFeatureValue> GetVcpFeatureAsync( + Monitor monitor, + byte vcpCode, + CancellationToken cancellationToken = default) + { + return await Task.Run( + () => + { + if (monitor.Handle == IntPtr.Zero) + { + return VcpFeatureValue.Invalid; + } + + if (TryGetVcpFeature(monitor.Handle, vcpCode, monitor.Id, out uint current, out uint max)) + { + return new VcpFeatureValue((int)current, 0, (int)max); + } + + return VcpFeatureValue.Invalid; + }, + cancellationToken); + } + + /// <summary> + /// Generic method to set VCP feature value directly. + /// </summary> + private Task<MonitorOperationResult> SetVcpFeatureAsync( + Monitor monitor, + byte vcpCode, + int value, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + + return Task.Run( + () => + { + if (monitor.Handle == IntPtr.Zero) + { + return MonitorOperationResult.Failure("Invalid monitor handle"); + } + + try + { + if (SetVCPFeature(monitor.Handle, vcpCode, (uint)value)) + { + return MonitorOperationResult.Success(); + } + + var lastError = GetLastError(); + return MonitorOperationResult.Failure($"Failed to set VCP 0x{vcpCode:X2}", (int)lastError); + } + catch (Exception ex) + { + return MonitorOperationResult.Failure($"Exception setting VCP 0x{vcpCode:X2}: {ex.Message}"); + } + }, + cancellationToken); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _handleManager?.Dispose(); + _disposed = true; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiNative.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiNative.cs new file mode 100644 index 0000000000..8b5dbf49fb --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiNative.cs @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Windows.Win32.Foundation; +using static PowerDisplay.Common.Drivers.NativeConstants; +using static PowerDisplay.Common.Drivers.PInvoke; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// <summary> + /// DDC/CI native API wrapper + /// </summary> + public static class DdcCiNative + { + /// <summary> + /// Fetches VCP capabilities string from a monitor and returns a validation result. + /// This is the slow I2C operation (~4 seconds per monitor) that should only be done once. + /// The result is cached regardless of success or failure. + /// </summary> + /// <param name="hPhysicalMonitor">Physical monitor handle</param> + /// <returns>Validation result with capabilities data (or failure status)</returns> + public static DdcCiValidationResult FetchCapabilities(IntPtr hPhysicalMonitor) + { + if (hPhysicalMonitor == IntPtr.Zero) + { + return DdcCiValidationResult.Invalid; + } + + try + { + // Try to get capabilities string (slow I2C operation) + var capsString = TryGetCapabilitiesString(hPhysicalMonitor); + if (string.IsNullOrEmpty(capsString)) + { + return DdcCiValidationResult.Invalid; + } + + // Parse the capabilities string + var parseResult = Utils.MccsCapabilitiesParser.Parse(capsString); + var capabilities = parseResult.Capabilities; + if (capabilities == null || capabilities.SupportedVcpCodes.Count == 0) + { + return DdcCiValidationResult.Invalid; + } + + // Check if brightness (VCP 0x10) is supported - determines DDC/CI validity + bool supportsBrightness = capabilities.SupportsVcpCode(NativeConstants.VcpCodeBrightness); + return new DdcCiValidationResult(supportsBrightness, capsString, capabilities); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return DdcCiValidationResult.Invalid; + } + } + + /// <summary> + /// Try to get capabilities string from a physical monitor handle. + /// </summary> + /// <param name="hPhysicalMonitor">Physical monitor handle</param> + /// <returns>Capabilities string, or null if failed</returns> + private static string? TryGetCapabilitiesString(IntPtr hPhysicalMonitor) + { + if (hPhysicalMonitor == IntPtr.Zero) + { + return null; + } + + try + { + // Get capabilities string length + if (!GetCapabilitiesStringLength(hPhysicalMonitor, out uint length) || length == 0) + { + return null; + } + + // Allocate buffer and get capabilities string + var buffer = Marshal.AllocHGlobal((int)length); + try + { + if (!CapabilitiesRequestAndCapabilitiesReply(hPhysicalMonitor, buffer, length)) + { + return null; + } + + return Marshal.PtrToStringAnsi(buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return null; + } + } + + /// <summary> + /// Gets GDI device name for a source (e.g., "\\.\DISPLAY1"). + /// </summary> + /// <param name="adapterId">Adapter ID</param> + /// <param name="sourceId">Source ID</param> + /// <returns>GDI device name, or null if retrieval fails</returns> + private static unsafe string? GetSourceGdiDeviceName(LUID adapterId, uint sourceId) + { + try + { + var sourceName = new DisplayConfigSourceDeviceName + { + Header = new DisplayConfigDeviceInfoHeader + { + Type = DisplayconfigDeviceInfoGetSourceName, + Size = (uint)sizeof(DisplayConfigSourceDeviceName), + AdapterId = adapterId, + Id = sourceId, + }, + }; + + var result = DisplayConfigGetDeviceInfo(&sourceName); + if (result == 0) + { + return sourceName.GetViewGdiDeviceName(); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + } + + return null; + } + + /// <summary> + /// Gets friendly name, EDID ID, and device path for a monitor target. + /// </summary> + /// <param name="adapterId">Adapter ID</param> + /// <param name="targetId">Target ID</param> + /// <returns>Tuple of (friendlyName, edidId, devicePath), any may be null if retrieval fails</returns> + private static unsafe (string? FriendlyName, string? EdidId, string? DevicePath) GetTargetDeviceInfo(LUID adapterId, uint targetId) + { + try + { + var deviceName = new DisplayConfigTargetDeviceName + { + Header = new DisplayConfigDeviceInfoHeader + { + Type = DisplayconfigDeviceInfoGetTargetName, + Size = (uint)sizeof(DisplayConfigTargetDeviceName), + AdapterId = adapterId, + Id = targetId, + }, + }; + + var result = DisplayConfigGetDeviceInfo(&deviceName); + if (result == 0) + { + // Extract friendly name + var friendlyName = deviceName.GetMonitorFriendlyDeviceName(); + + // Extract device path (unique per target, used as key) + var devicePath = deviceName.GetMonitorDevicePath(); + + // Extract EDID ID from EDID data + var manufacturerId = deviceName.EdidManufactureId; + var manufactureCode = ConvertManufactureIdToString(manufacturerId); + var productCode = deviceName.EdidProductCodeId.ToString("X4", System.Globalization.CultureInfo.InvariantCulture); + var edidId = $"{manufactureCode}{productCode}"; + + return (friendlyName, edidId, devicePath); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + } + + return (null, null, null); + } + + /// <summary> + /// Converts manufacturer ID to 3-character manufacturer code + /// </summary> + /// <param name="manufacturerId">Manufacturer ID</param> + /// <returns>3-character manufacturer code</returns> + private static string ConvertManufactureIdToString(ushort manufacturerId) + { + // EDID manufacturer ID requires byte order swap first + manufacturerId = (ushort)(((manufacturerId & 0xff00) >> 8) | ((manufacturerId & 0x00ff) << 8)); + + // Extract 3 5-bit characters (each character is A-Z, where A=1, B=2, ..., Z=26) + var char1 = (char)('A' - 1 + ((manufacturerId >> 0) & 0x1f)); + var char2 = (char)('A' - 1 + ((manufacturerId >> 5) & 0x1f)); + var char3 = (char)('A' - 1 + ((manufacturerId >> 10) & 0x1f)); + + // Combine characters in correct order + return $"{char3}{char2}{char1}"; + } + + /// <summary> + /// Gets complete information for all monitors, keyed by GDI device name (e.g., "\\.\DISPLAY1"). + /// This allows reliable matching with GetMonitorInfo results. + /// </summary> + /// <returns>Dictionary keyed by GDI device name containing monitor information</returns> + public static unsafe Dictionary<string, MonitorDisplayInfo> GetAllMonitorDisplayInfo() + { + var monitorInfo = new Dictionary<string, MonitorDisplayInfo>(StringComparer.OrdinalIgnoreCase); + + try + { + // Get buffer sizes + var result = GetDisplayConfigBufferSizes(QdcOnlyActivePaths, out uint pathCount, out uint modeCount); + if (result != 0) + { + return monitorInfo; + } + + // Allocate buffers + var paths = new DisplayConfigPathInfo[pathCount]; + var modes = new DisplayConfigModeInfo[modeCount]; + + // Query display configuration using fixed pointer + fixed (DisplayConfigPathInfo* pathsPtr = paths) + { + fixed (DisplayConfigModeInfo* modesPtr = modes) + { + result = QueryDisplayConfig(QdcOnlyActivePaths, ref pathCount, pathsPtr, ref modeCount, modesPtr, IntPtr.Zero); + if (result != 0) + { + return monitorInfo; + } + } + } + + // Get information for each path + // The path index corresponds to Windows Display Settings "Identify" number + for (int i = 0; i < pathCount; i++) + { + var path = paths[i]; + + // Get GDI device name from source info (e.g., "\\.\DISPLAY1") + var gdiDeviceName = GetSourceGdiDeviceName(path.SourceInfo.AdapterId, path.SourceInfo.Id); + if (string.IsNullOrEmpty(gdiDeviceName)) + { + continue; + } + + // Get target info (friendly name, EDID ID, device path) + var (friendlyName, edidId, devicePath) = GetTargetDeviceInfo(path.TargetInfo.AdapterId, path.TargetInfo.Id); + + // Use device path as key - unique per target, supports mirror mode + if (string.IsNullOrEmpty(devicePath)) + { + continue; + } + + monitorInfo[devicePath] = new MonitorDisplayInfo + { + DevicePath = devicePath, + GdiDeviceName = gdiDeviceName, + FriendlyName = friendlyName ?? string.Empty, + EdidId = edidId ?? string.Empty, + AdapterId = path.TargetInfo.AdapterId, + TargetId = path.TargetInfo.Id, + MonitorNumber = i + 1, // 1-based, matches Windows Display Settings + }; + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + } + + return monitorInfo; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiValidationResult.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiValidationResult.cs new file mode 100644 index 0000000000..33cb3b7b5e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiValidationResult.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// <summary> + /// DDC/CI validation result containing both validation status and cached capabilities data. + /// This allows reusing capabilities data retrieved during validation, avoiding duplicate I2C calls. + /// </summary> + public struct DdcCiValidationResult + { + /// <summary> + /// Gets a value indicating whether the monitor has a valid DDC/CI connection with brightness support. + /// </summary> + public bool IsValid { get; } + + /// <summary> + /// Gets the raw capabilities string retrieved during validation. + /// Null if retrieval failed. + /// </summary> + public string? CapabilitiesString { get; } + + /// <summary> + /// Gets the parsed VCP capabilities info retrieved during validation. + /// Null if parsing failed. + /// </summary> + public Models.VcpCapabilities? VcpCapabilitiesInfo { get; } + + /// <summary> + /// Gets a value indicating whether capabilities retrieval was attempted. + /// True means the result is from an actual attempt (success or failure). + /// </summary> + public bool WasAttempted { get; } + + /// <summary> + /// Initializes a new instance of the <see cref="DdcCiValidationResult"/> struct. + /// </summary> + public DdcCiValidationResult(bool isValid, string? capabilitiesString = null, Models.VcpCapabilities? vcpCapabilitiesInfo = null, bool wasAttempted = true) + { + IsValid = isValid; + CapabilitiesString = capabilitiesString; + VcpCapabilitiesInfo = vcpCapabilitiesInfo; + WasAttempted = wasAttempted; + } + + /// <summary> + /// Gets an invalid validation result with no cached data. + /// </summary> + public static DdcCiValidationResult Invalid => new(false, null, null, true); + + /// <summary> + /// Gets a result indicating validation was not attempted yet. + /// </summary> + public static DdcCiValidationResult NotAttempted => new(false, null, null, false); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDiscoveryHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDiscoveryHelper.cs new file mode 100644 index 0000000000..82d0240e80 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDiscoveryHelper.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using ManagedCommon; +using PowerDisplay.Common.Models; +using static PowerDisplay.Common.Drivers.NativeConstants; +using static PowerDisplay.Common.Drivers.PInvoke; +using PHYSICAL_MONITOR = PowerDisplay.Common.Drivers.PhysicalMonitor; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// <summary> + /// Helper class for discovering and creating monitor objects + /// </summary> + public class MonitorDiscoveryHelper + { + /// <summary> + /// Get physical monitors for a logical monitor. + /// Filters out any monitors with NULL handles (Windows API bug workaround). + /// </summary> + /// <param name="hMonitor">Handle to the logical monitor</param> + /// <param name="hasNullHandles">Output: true if any NULL handles were filtered out</param> + /// <returns>Array of valid physical monitors, or null if API call failed</returns> + internal PHYSICAL_MONITOR[]? GetPhysicalMonitors(IntPtr hMonitor, out bool hasNullHandles) + { + hasNullHandles = false; + + try + { + if (!GetNumberOfPhysicalMonitorsFromHMONITOR(hMonitor, out uint numMonitors)) + { + Logger.LogWarning($"GetPhysicalMonitors: GetNumberOfPhysicalMonitorsFromHMONITOR failed for 0x{hMonitor:X}"); + return null; + } + + if (numMonitors == 0) + { + Logger.LogWarning($"GetPhysicalMonitors: numMonitors is 0"); + return null; + } + + var physicalMonitors = new PHYSICAL_MONITOR[numMonitors]; + bool apiResult; + unsafe + { + fixed (PHYSICAL_MONITOR* ptr = physicalMonitors) + { + apiResult = GetPhysicalMonitorsFromHMONITOR(hMonitor, numMonitors, ptr); + } + } + + if (!apiResult) + { + Logger.LogWarning($"GetPhysicalMonitors: GetPhysicalMonitorsFromHMONITOR failed"); + return null; + } + + // Filter out NULL handles and log each physical monitor + var validMonitors = new List<PHYSICAL_MONITOR>(); + for (int i = 0; i < numMonitors; i++) + { + IntPtr handle = physicalMonitors[i].HPhysicalMonitor; + + if (handle == IntPtr.Zero) + { + Logger.LogWarning($"GetPhysicalMonitors: Monitor [{i}] has NULL handle, filtering out"); + hasNullHandles = true; + continue; + } + + validMonitors.Add(physicalMonitors[i]); + } + + return validMonitors.Count > 0 ? validMonitors.ToArray() : null; + } + catch (Exception ex) + { + Logger.LogError($"GetPhysicalMonitors: Exception: {ex.Message}"); + return null; + } + } + + /// <summary> + /// Create Monitor object from physical monitor and display info. + /// Uses MonitorDisplayInfo directly from QueryDisplayConfig for stable identification. + /// Note: Brightness is not initialized here - MonitorManager handles brightness initialization + /// after discovery to avoid slow I2C operations during the discovery phase. + /// </summary> + /// <param name="physicalMonitor">Physical monitor structure with handle and description</param> + /// <param name="monitorInfo">Display info from QueryDisplayConfig (EdidId, FriendlyName, MonitorNumber)</param> + internal Monitor? CreateMonitorFromPhysical( + PHYSICAL_MONITOR physicalMonitor, + MonitorDisplayInfo monitorInfo) + { + try + { + // Get EDID ID and friendly name directly from MonitorDisplayInfo + string edidId = monitorInfo.EdidId ?? string.Empty; + string name = physicalMonitor.GetDescription() ?? string.Empty; + + // Use FriendlyName from QueryDisplayConfig if available and not generic + if (!string.IsNullOrEmpty(monitorInfo.FriendlyName) && + !monitorInfo.FriendlyName.Contains("Generic")) + { + name = monitorInfo.FriendlyName; + } + + // Generate unique monitor Id: "DDC_{EdidId}_{MonitorNumber}" + string monitorId = !string.IsNullOrEmpty(edidId) + ? $"DDC_{edidId}_{monitorInfo.MonitorNumber}" + : $"DDC_Unknown_{monitorInfo.MonitorNumber}"; + + // If still no good name, use default value + if (string.IsNullOrEmpty(name) || name.Contains("Generic") || name.Contains("PnP")) + { + name = "External Display"; + } + + var monitor = new Monitor + { + Id = monitorId, + Name = name.Trim(), + CurrentBrightness = 50, // Default value, will be updated by MonitorManager after discovery + MinBrightness = 0, + MaxBrightness = 100, + IsAvailable = true, + Handle = physicalMonitor.HPhysicalMonitor, + Capabilities = MonitorCapabilities.DdcCi, + CommunicationMethod = "DDC/CI", + MonitorNumber = monitorInfo.MonitorNumber, + GdiDeviceName = monitorInfo.GdiDeviceName ?? string.Empty, + Orientation = DmdoDefault, // Orientation will be set separately if needed + }; + + // Note: Feature detection (brightness, contrast, color temp, volume) is now done + // in MonitorManager after capabilities string is retrieved and parsed. + // This ensures we rely on capabilities data rather than trial-and-error probing. + return monitor; + } + catch (Exception ex) + { + Logger.LogError($"DDC: CreateMonitorFromPhysical exception: {ex.Message}"); + return null; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDisplayInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDisplayInfo.cs new file mode 100644 index 0000000000..9faad9f18a --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/MonitorDisplayInfo.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// <summary> + /// Monitor display information structure + /// </summary> + public struct MonitorDisplayInfo + { + /// <summary> + /// Gets or sets the monitor device path (e.g., "\\?\DISPLAY#DELA1D8#..."). + /// This is unique per target and used as the primary key. + /// </summary> + public string DevicePath { get; set; } + + /// <summary> + /// Gets or sets the GDI device name (e.g., "\\.\DISPLAY1"). + /// This is used to match with GetMonitorInfo results from HMONITOR. + /// In mirror mode, multiple targets may share the same GDI name. + /// </summary> + public string GdiDeviceName { get; set; } + + /// <summary> + /// Gets or sets the friendly display name from EDID. + /// </summary> + public string FriendlyName { get; set; } + + /// <summary> + /// Gets or sets the EDID ID derived from manufacturer and product code. + /// Format: "{ManufacturerCode}{ProductCode}" (e.g., "GSM5C6D", "LEN4038"). + /// Note: This is NOT unique - same model monitors have the same EdidId. + /// </summary> + public string EdidId { get; set; } + + public LUID AdapterId { get; set; } + + public uint TargetId { get; set; } + + /// <summary> + /// Gets or sets the monitor number based on QueryDisplayConfig path index. + /// This matches the number shown in Windows Display Settings "Identify" feature. + /// 1-based index (paths[0] = 1, paths[1] = 2, etc.) + /// </summary> + public int MonitorNumber { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/PhysicalMonitorHandleManager.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/PhysicalMonitorHandleManager.cs new file mode 100644 index 0000000000..c9482673d2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/PhysicalMonitorHandleManager.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using ManagedCommon; +using static PowerDisplay.Common.Drivers.PInvoke; + +namespace PowerDisplay.Common.Drivers.DDC +{ + /// <summary> + /// Manages physical monitor handles - reuse, cleanup, and validation + /// </summary> + public partial class PhysicalMonitorHandleManager : IDisposable + { + // Mapping: monitorId -> physical handle (thread-safe) + private readonly ConcurrentDictionary<string, IntPtr> _monitorIdToHandleMap = new(); + private readonly object _handleLock = new(); + private bool _disposed; + + /// <summary> + /// Update the handle mapping with new handles + /// </summary> + public void UpdateHandleMap(Dictionary<string, IntPtr> newHandleMap) + { + // Lock to ensure atomic update (cleanup + replace) + lock (_handleLock) + { + // Clean up unused handles before updating + CleanupUnusedHandles(newHandleMap); + + // Update the device key map + _monitorIdToHandleMap.Clear(); + foreach (var kvp in newHandleMap) + { + _monitorIdToHandleMap[kvp.Key] = kvp.Value; + } + } + } + + /// <summary> + /// Clean up handles that are no longer in use. + /// Called within lock context. Optimized to O(n) using HashSet lookup. + /// </summary> + private void CleanupUnusedHandles(Dictionary<string, IntPtr> newHandles) + { + if (_monitorIdToHandleMap.IsEmpty) + { + return; + } + + // Build HashSet of handles that will be reused (O(m)) + var reusedHandles = new HashSet<IntPtr>(newHandles.Values); + + // Find handles to destroy: in old map but not reused (O(n) with O(1) lookup) + var handlesToDestroy = _monitorIdToHandleMap.Values + .Where(h => h != IntPtr.Zero && !reusedHandles.Contains(h)) + .ToList(); + + // Destroy unused handles + foreach (var handle in handlesToDestroy) + { + try + { + DestroyPhysicalMonitor(handle); + } + catch (Exception ex) + { + Logger.LogTrace($"Failed to destroy physical monitor handle 0x{handle:X}: {ex.Message}"); + } + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + // Release all physical monitor handles - get snapshot to avoid holding lock during cleanup + var handles = _monitorIdToHandleMap.Values.ToList(); + foreach (var handle in handles) + { + if (handle != IntPtr.Zero) + { + try + { + DestroyPhysicalMonitor(handle); + } + catch (Exception ex) + { + Logger.LogTrace($"Failed to destroy physical monitor handle 0x{handle:X} during dispose: {ex.Message}"); + } + } + } + + _monitorIdToHandleMap.Clear(); + _disposed = true; + GC.SuppressFinalize(this); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeConstants.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeConstants.cs new file mode 100644 index 0000000000..7a3983cc4f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeConstants.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Windows API constant definitions + /// </summary> + public static class NativeConstants + { + /// <summary> + /// VCP code: Brightness (0x10) + /// Standard VESA MCCS brightness control. + /// This is the ONLY brightness code used by PowerDisplay. + /// </summary> + public const byte VcpCodeBrightness = 0x10; + + /// <summary> + /// VCP code: Contrast (0x12) + /// Standard VESA MCCS contrast control. + /// </summary> + public const byte VcpCodeContrast = 0x12; + + /// <summary> + /// VCP code: Audio Speaker Volume (0x62) + /// Standard VESA MCCS volume control for monitors with built-in speakers. + /// </summary> + public const byte VcpCodeVolume = 0x62; + + /// <summary> + /// VCP code: Select Color Preset (0x14) + /// Standard VESA MCCS color temperature preset selection. + /// Supports discrete values like: 0x01=sRGB, 0x04=5000K, 0x05=6500K, 0x08=9300K. + /// This is the standard method for color temperature control. + /// </summary> + public const byte VcpCodeSelectColorPreset = 0x14; + + /// <summary> + /// VCP code: Input Source (0x60) + /// Standard VESA MCCS input source selection. + /// Supports values like: 0x0F=DisplayPort-1, 0x10=DisplayPort-2, 0x11=HDMI-1, 0x12=HDMI-2, 0x1B=USB-C. + /// Note: Actual supported values depend on monitor capabilities. + /// </summary> + public const byte VcpCodeInputSource = 0x60; + + /// <summary> + /// VCP code: Power Mode (0xD6) + /// Controls monitor power state via DPMS. + /// Values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard). + /// Note: Switching to any non-On state will turn off the display. + /// </summary> + public const byte VcpCodePowerMode = 0xD6; + + /// <summary> + /// Query display config: only active paths + /// </summary> + public const uint QdcOnlyActivePaths = 0x00000002; + + /// <summary> + /// Get source name (GDI device name like "\\.\DISPLAY1") + /// </summary> + public const uint DisplayconfigDeviceInfoGetSourceName = 1; + + /// <summary> + /// Get target name (monitor friendly name and hardware ID) + /// </summary> + public const uint DisplayconfigDeviceInfoGetTargetName = 2; + + /// <summary> + /// Retrieve the current settings for the display device. + /// </summary> + public const int EnumCurrentSettings = -1; + + /// <summary> + /// The display is in the natural orientation of the device. + /// </summary> + public const int DmdoDefault = 0; + + /// <summary> + /// The display is rotated 180 degrees (measured clockwise) from its natural orientation. + /// </summary> + public const int Dmdo180 = 2; + + // ==================== DEVMODE field flags ==================== + + /// <summary> + /// DmDisplayOrientation field is valid. + /// </summary> + public const int DmDisplayOrientation = 0x00000080; + + /// <summary> + /// DmPelsWidth field is valid. + /// </summary> + public const int DmPelsWidth = 0x00080000; + + /// <summary> + /// DmPelsHeight field is valid. + /// </summary> + public const int DmPelsHeight = 0x00100000; + + // ==================== ChangeDisplaySettings flags ==================== + + /// <summary> + /// Test the graphics mode but don't actually set it. + /// </summary> + public const uint CdsTest = 0x00000002; + + // ==================== ChangeDisplaySettings result codes ==================== + + /// <summary> + /// The settings change was successful. + /// </summary> + public const int DispChangeSuccessful = 0; + + /// <summary> + /// The computer must be restarted for the graphics mode to work. + /// </summary> + public const int DispChangeRestart = 1; + + /// <summary> + /// The display driver failed the specified graphics mode. + /// </summary> + public const int DispChangeFailed = -1; + + /// <summary> + /// The graphics mode is not supported. + /// </summary> + public const int DispChangeBadmode = -2; + + /// <summary> + /// Unable to write settings to the registry. + /// </summary> + public const int DispChangeNotupdated = -3; + + /// <summary> + /// An invalid set of flags was passed in. + /// </summary> + public const int DispChangeBadflags = -4; + + /// <summary> + /// An invalid parameter was passed in. + /// </summary> + public const int DispChangeBadparam = -5; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeDelegates.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeDelegates.cs new file mode 100644 index 0000000000..03f77535d0 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeDelegates.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers; + +/// <summary> +/// Native delegate type definitions +/// </summary> +public static class NativeDelegates +{ + /// <summary> + /// Monitor enumeration procedure delegate + /// </summary> + /// <param name="hMonitor">Monitor handle</param> + /// <param name="hdcMonitor">Monitor device context</param> + /// <param name="lprcMonitor">Pointer to monitor rectangle</param> + /// <param name="dwData">User data</param> + /// <returns>True to continue enumeration</returns> + [UnmanagedFunctionPointer(CallingConvention.StdCall)] + public delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, IntPtr lprcMonitor, IntPtr dwData); +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DevMode.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DevMode.cs new file mode 100644 index 0000000000..7a600d5b7e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DevMode.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// The DEVMODE structure contains information about the initialization and environment of a printer or a display device. + /// </summary> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct DevMode + { + /// <summary> + /// Device name - fixed buffer for LibraryImport compatibility + /// </summary> + public fixed ushort DmDeviceName[32]; + + public short DmSpecVersion; + public short DmDriverVersion; + public short DmSize; + public short DmDriverExtra; + public int DmFields; + public int DmPositionX; + public int DmPositionY; + public int DmDisplayOrientation; + public int DmDisplayFixedOutput; + public short DmColor; + public short DmDuplex; + public short DmYResolution; + public short DmTTOption; + public short DmCollate; + + /// <summary> + /// Form name - fixed buffer for LibraryImport compatibility + /// </summary> + public fixed ushort DmFormName[32]; + + public short DmLogPixels; + public int DmBitsPerPel; + public int DmPelsWidth; + public int DmPelsHeight; + public int DmDisplayFlags; + public int DmDisplayFrequency; + public int DmICMMethod; + public int DmICMIntent; + public int DmMediaType; + public int DmDitherType; + public int DmReserved1; + public int DmReserved2; + public int DmPanningWidth; + public int DmPanningHeight; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfig2DRegion.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfig2DRegion.cs new file mode 100644 index 0000000000..27c7ea1c7f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfig2DRegion.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration 2D region + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfig2DRegion + { + public uint Cx; + public uint Cy; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigDeviceInfoHeader.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigDeviceInfoHeader.cs new file mode 100644 index 0000000000..48b2bbcde5 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigDeviceInfoHeader.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +using Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration device information header + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigDeviceInfoHeader + { + public uint Type; + public uint Size; + public LUID AdapterId; + public uint Id; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfo.cs new file mode 100644 index 0000000000..9c63467659 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfo.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +using Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration mode information + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigModeInfo + { + public uint InfoType; + public uint Id; + public LUID AdapterId; + public DisplayConfigModeInfoUnion ModeInfo; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfoUnion.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfoUnion.cs new file mode 100644 index 0000000000..aabe635a41 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigModeInfoUnion.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration mode information union + /// </summary> + [StructLayout(LayoutKind.Explicit)] + public struct DisplayConfigModeInfoUnion + { + [FieldOffset(0)] + public DisplayConfigTargetMode TargetMode; + + [FieldOffset(0)] + public DisplayConfigSourceMode SourceMode; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathInfo.cs new file mode 100644 index 0000000000..06880ec425 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathInfo.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration path information + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigPathInfo + { + public DisplayConfigPathSourceInfo SourceInfo; + public DisplayConfigPathTargetInfo TargetInfo; + public uint Flags; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathSourceInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathSourceInfo.cs new file mode 100644 index 0000000000..ea38f3fade --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathSourceInfo.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +using Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration path source information + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigPathSourceInfo + { + public LUID AdapterId; + public uint Id; + public uint ModeInfoIdx; + public uint StatusFlags; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathTargetInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathTargetInfo.cs new file mode 100644 index 0000000000..739aef3357 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPathTargetInfo.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +using Windows.Win32.Foundation; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration path target information + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigPathTargetInfo + { + public LUID AdapterId; + public uint Id; + public uint ModeInfoIdx; + public uint OutputTechnology; + public uint Rotation; + public uint Scaling; + public DisplayConfigRational RefreshRate; + public uint ScanLineOrdering; + public bool TargetAvailable; + public uint StatusFlags; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPoint.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPoint.cs new file mode 100644 index 0000000000..d2ad0a76f8 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigPoint.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration point + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigPoint + { + public int X; + public int Y; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigRational.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigRational.cs new file mode 100644 index 0000000000..dde4497d73 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigRational.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration rational number + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigRational + { + public uint Numerator; + public uint Denominator; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceDeviceName.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceDeviceName.cs new file mode 100644 index 0000000000..7af54f0609 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceDeviceName.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration source device name - contains GDI device name (e.g., "\\.\DISPLAY1") + /// </summary> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct DisplayConfigSourceDeviceName + { + public DisplayConfigDeviceInfoHeader Header; + + /// <summary> + /// GDI device name - fixed buffer for 32 wide characters (CCHDEVICENAME) + /// </summary> + public fixed ushort ViewGdiDeviceName[32]; + + /// <summary> + /// Helper method to get GDI device name as string + /// </summary> + public readonly string GetViewGdiDeviceName() + { + fixed (ushort* ptr = ViewGdiDeviceName) + { + return new string((char*)ptr); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceMode.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceMode.cs new file mode 100644 index 0000000000..a39b7a298d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigSourceMode.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration source mode + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigSourceMode + { + public uint Width; + public uint Height; + public uint PixelFormat; + public DisplayConfigPoint Position; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetDeviceName.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetDeviceName.cs new file mode 100644 index 0000000000..9a38f82c30 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetDeviceName.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration target device name + /// </summary> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct DisplayConfigTargetDeviceName + { + public DisplayConfigDeviceInfoHeader Header; + public uint Flags; + public uint OutputTechnology; + public ushort EdidManufactureId; + public ushort EdidProductCodeId; + public uint ConnectorInstance; + + /// <summary> + /// Monitor friendly name - fixed buffer for LibraryImport compatibility + /// </summary> + public fixed ushort MonitorFriendlyDeviceName[64]; + + /// <summary> + /// Monitor device path - fixed buffer for LibraryImport compatibility + /// </summary> + public fixed ushort MonitorDevicePath[128]; + + /// <summary> + /// Helper method to get monitor friendly name as string + /// </summary> + public readonly string GetMonitorFriendlyDeviceName() + { + fixed (ushort* ptr = MonitorFriendlyDeviceName) + { + return new string((char*)ptr); + } + } + + /// <summary> + /// Helper method to get monitor device path as string + /// </summary> + public readonly string GetMonitorDevicePath() + { + fixed (ushort* ptr = MonitorDevicePath) + { + return new string((char*)ptr); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetMode.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetMode.cs new file mode 100644 index 0000000000..9ea0f15867 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigTargetMode.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration target mode + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigTargetMode + { + public DisplayConfigVideoSignalInfo TargetVideoSignalInfo; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigVideoSignalInfo.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigVideoSignalInfo.cs new file mode 100644 index 0000000000..36c4907e5c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/DisplayConfigVideoSignalInfo.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Display configuration video signal information + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct DisplayConfigVideoSignalInfo + { + public ulong PixelRate; + public DisplayConfigRational HSyncFreq; + public DisplayConfigRational VSyncFreq; + public DisplayConfig2DRegion ActiveSize; + public DisplayConfig2DRegion TotalSize; + public uint VideoStandard; + public uint ScanLineOrdering; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/MonitorInfoEx.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/MonitorInfoEx.cs new file mode 100644 index 0000000000..1af97ed764 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/MonitorInfoEx.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Monitor information extended structure + /// </summary> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct MonitorInfoEx + { + /// <summary> + /// Structure size + /// </summary> + public uint CbSize; + + /// <summary> + /// Monitor rectangle area + /// </summary> + public Rect RcMonitor; + + /// <summary> + /// Work area rectangle + /// </summary> + public Rect RcWork; + + /// <summary> + /// Flags + /// </summary> + public uint DwFlags; + + /// <summary> + /// Device name (e.g., "\\.\DISPLAY1") - fixed buffer for LibraryImport compatibility + /// </summary> + public fixed ushort SzDevice[32]; + + /// <summary> + /// Helper property to get device name as string + /// </summary> + public readonly string GetDeviceName() + { + fixed (ushort* ptr = SzDevice) + { + return new string((char*)ptr); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/PhysicalMonitor.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/PhysicalMonitor.cs new file mode 100644 index 0000000000..a418dcc168 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/PhysicalMonitor.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Physical monitor structure for DDC/CI + /// </summary> + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public unsafe struct PhysicalMonitor + { + /// <summary> + /// Physical monitor handle + /// </summary> + public IntPtr HPhysicalMonitor; + + /// <summary> + /// Physical monitor description string - fixed buffer for LibraryImport compatibility + /// </summary> + public fixed ushort SzPhysicalMonitorDescription[128]; + + /// <summary> + /// Helper method to get description as string + /// </summary> + public readonly string GetDescription() + { + fixed (ushort* ptr = SzPhysicalMonitorDescription) + { + return new string((char*)ptr); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/Rect.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/Rect.cs new file mode 100644 index 0000000000..0af4d13dc5 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/NativeStructures/Rect.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// Rectangle structure + /// </summary> + [StructLayout(LayoutKind.Sequential)] + public struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + + public int Width => Right - Left; + + public int Height => Bottom - Top; + + public Rect(int left, int top, int right, int bottom) + { + Left = left; + Top = top; + Right = right; + Bottom = bottom; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/PInvoke.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/PInvoke.cs new file mode 100644 index 0000000000..1e1ab5185e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/PInvoke.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; + +namespace PowerDisplay.Common.Drivers +{ + /// <summary> + /// P/Invoke declarations using LibraryImport source generator + /// </summary> + internal static partial class PInvoke + { + // ==================== User32.dll - Display Configuration ==================== + [LibraryImport("user32.dll")] + internal static partial int GetDisplayConfigBufferSizes( + uint flags, + out uint numPathArrayElements, + out uint numModeInfoArrayElements); + + // Use unsafe pointer to avoid runtime marshalling + [LibraryImport("user32.dll")] + internal static unsafe partial int QueryDisplayConfig( + uint flags, + ref uint numPathArrayElements, + DisplayConfigPathInfo* pathArray, + ref uint numModeInfoArrayElements, + DisplayConfigModeInfo* modeInfoArray, + IntPtr currentTopologyId); + + [LibraryImport("user32.dll")] + internal static unsafe partial int DisplayConfigGetDeviceInfo( + DisplayConfigTargetDeviceName* deviceName); + + [LibraryImport("user32.dll")] + internal static unsafe partial int DisplayConfigGetDeviceInfo( + DisplayConfigSourceDeviceName* sourceName); + + // ==================== User32.dll - Monitor Enumeration ==================== + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool EnumDisplayMonitors( + IntPtr hdc, + IntPtr lprcClip, + NativeDelegates.MonitorEnumProc lpfnEnum, + IntPtr dwData); + + [LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static unsafe partial bool GetMonitorInfo( + IntPtr hMonitor, + MonitorInfoEx* lpmi); + + [LibraryImport("user32.dll", EntryPoint = "EnumDisplaySettingsW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static unsafe partial bool EnumDisplaySettings( + [MarshalAs(UnmanagedType.LPWStr)] string? lpszDeviceName, + int iModeNum, + DevMode* lpDevMode); + + [LibraryImport("user32.dll", EntryPoint = "ChangeDisplaySettingsExW", StringMarshalling = StringMarshalling.Utf16)] + internal static unsafe partial int ChangeDisplaySettingsEx( + [MarshalAs(UnmanagedType.LPWStr)] string? lpszDeviceName, + DevMode* lpDevMode, + IntPtr hwnd, + uint dwflags, + IntPtr lParam); + + // ==================== Dxva2.dll - DDC/CI Monitor Control ==================== + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetNumberOfPhysicalMonitorsFromHMONITOR( + IntPtr hMonitor, + out uint pdwNumberOfPhysicalMonitors); + + // Use unsafe pointer to avoid ArraySubType limitation + [LibraryImport("Dxva2.dll", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static unsafe partial bool GetPhysicalMonitorsFromHMONITOR( + IntPtr hMonitor, + uint dwPhysicalMonitorArraySize, + PhysicalMonitor* pPhysicalMonitorArray); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool DestroyPhysicalMonitor(IntPtr hMonitor); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetVCPFeatureAndVCPFeatureReply( + IntPtr hPhysicalMonitor, + byte bVCPCode, + IntPtr pvct, + out uint pdwCurrentValue, + out uint pdwMaximumValue); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetVCPFeature( + IntPtr hPhysicalMonitor, + byte bVCPCode, + uint dwNewValue); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetCapabilitiesStringLength( + IntPtr hPhysicalMonitor, + out uint pdwCapabilitiesStringLengthInCharacters); + + [LibraryImport("Dxva2.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CapabilitiesRequestAndCapabilitiesReply( + IntPtr hPhysicalMonitor, + IntPtr pszASCIICapabilitiesString, + uint dwCapabilitiesStringLengthInCharacters); + + // ==================== Kernel32.dll ==================== + [LibraryImport("kernel32.dll")] + internal static partial uint GetLastError(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/WMI/WmiController.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/WMI/WmiController.cs new file mode 100644 index 0000000000..4b464500da --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Drivers/WMI/WmiController.cs @@ -0,0 +1,387 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using PowerDisplay.Common.Interfaces; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; +using WmiLight; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.Common.Drivers.WMI +{ + /// <summary> + /// WMI monitor controller for controlling internal laptop displays. + /// </summary> + public partial class WmiController : IMonitorController, IDisposable + { + private const string WmiNamespace = @"root\WMI"; + private const string BrightnessQueryClass = "WmiMonitorBrightness"; + private const string BrightnessMethodClass = "WmiMonitorBrightnessMethods"; + + // Common WMI error codes for classification + private const int WbemENotFound = unchecked((int)0x80041002); + private const int WbemEAccessDenied = unchecked((int)0x80041003); + private const int WbemEProviderFailure = unchecked((int)0x80041004); + private const int WbemEInvalidQuery = unchecked((int)0x80041017); + private const int WmiFeatureNotSupported = 0x1068; + + /// <summary> + /// Classifies WMI exceptions into user-friendly error messages. + /// </summary> + private static MonitorOperationResult ClassifyWmiError(WmiException ex, string operation) + { + var hresult = ex.HResult; + + return hresult switch + { + WbemENotFound => MonitorOperationResult.Failure($"WMI class not found during {operation}. This feature may not be supported on your system.", hresult), + WbemEAccessDenied => MonitorOperationResult.Failure($"Access denied during {operation}. Administrator privileges may be required.", hresult), + WbemEProviderFailure => MonitorOperationResult.Failure($"WMI provider failure during {operation}. The display driver may not support this feature.", hresult), + WbemEInvalidQuery => MonitorOperationResult.Failure($"Invalid WMI query during {operation}. This is likely a bug.", hresult), + WmiFeatureNotSupported => MonitorOperationResult.Failure($"WMI brightness control not supported on this system during {operation}.", hresult), + _ => MonitorOperationResult.Failure($"WMI error during {operation}: {ex.Message}", hresult), + }; + } + + /// <summary> + /// Escape special characters in WMI query strings. + /// WMI requires backslashes and single quotes to be escaped in WHERE clauses. + /// See: https://learn.microsoft.com/en-us/windows/win32/wmisdk/wql-sql-for-wmi + /// </summary> + /// <param name="value">The string value to escape.</param> + /// <returns>The escaped string safe for use in WMI queries.</returns> + private static string EscapeWmiString(string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + // WMI requires backslashes and single quotes to be escaped in WHERE clauses + // Backslash must be escaped first to avoid double-escaping the quote's backslash + return value.Replace("\\", "\\\\").Replace("'", "\\'"); + } + + /// <summary> + /// Extract hardware ID from WMI InstanceName. + /// InstanceName format: "DISPLAY\BOE0900\4&10fd3ab1&0&UID265988_0" + /// Returns the second segment (e.g., "BOE0900") which is the manufacturer+product code. + /// </summary> + /// <param name="instanceName">The WMI InstanceName.</param> + /// <returns>The EDID ID extracted from the InstanceName, or empty string if extraction fails.</returns> + private static string ExtractEdidIdFromInstanceName(string instanceName) + { + if (string.IsNullOrEmpty(instanceName)) + { + return string.Empty; + } + + // Split by backslash: ["DISPLAY", "BOE0900", "4&10fd3ab1&0&UID265988_0"] + var parts = instanceName.Split('\\'); + if (parts.Length >= 2 && !string.IsNullOrEmpty(parts[1])) + { + // Return the second part (e.g., "BOE0900") + return parts[1]; + } + + return string.Empty; + } + + /// <summary> + /// Build a WMI query filtered by monitor instance name. + /// </summary> + /// <param name="wmiClass">The WMI class to query.</param> + /// <param name="instanceName">The monitor instance name to filter by.</param> + /// <param name="selectClause">Optional SELECT clause fields (defaults to "*").</param> + /// <returns>The formatted WMI query string.</returns> + private static string BuildInstanceNameQuery(string wmiClass, string instanceName, string selectClause = "*") + { + var escapedInstanceName = EscapeWmiString(instanceName); + return $"SELECT {selectClause} FROM {wmiClass} WHERE InstanceName = '{escapedInstanceName}'"; + } + + /// <summary> + /// Get MonitorDisplayInfo from dictionary by matching EdidId. + /// Uses QueryDisplayConfig path index which matches Windows Display Settings "Identify" feature. + /// </summary> + /// <param name="edidId">The EDID ID to match (e.g., "LEN4038", "BOE0900").</param> + /// <param name="monitorDisplayInfos">Dictionary of monitor display info from QueryDisplayConfig.</param> + /// <returns>MonitorDisplayInfo if found, or null if not found.</returns> + private static Drivers.DDC.MonitorDisplayInfo? GetMonitorDisplayInfoByEdidId(string edidId, Dictionary<string, Drivers.DDC.MonitorDisplayInfo> monitorDisplayInfos) + { + if (string.IsNullOrEmpty(edidId) || monitorDisplayInfos == null || monitorDisplayInfos.Count == 0) + { + return null; + } + + var match = monitorDisplayInfos.Values.FirstOrDefault( + v => edidId.Equals(v.EdidId, StringComparison.OrdinalIgnoreCase)); + + // Check if match was found (struct default has null/empty EdidId) + if (!string.IsNullOrEmpty(match.EdidId)) + { + return match; + } + + Logger.LogWarning($"WMI: Could not find MonitorDisplayInfo for EdidId '{edidId}'"); + return null; + } + + public string Name => "WMI Monitor Controller"; + + /// <summary> + /// Get monitor brightness + /// </summary> + public async Task<VcpFeatureValue> GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + + return await Task.Run( + () => + { + try + { + using var connection = new WmiConnection(WmiNamespace); + var query = BuildInstanceNameQuery(BrightnessQueryClass, monitor.InstanceName, "CurrentBrightness"); + var results = connection.CreateQuery(query); + + foreach (var obj in results) + { + var currentBrightness = obj.GetPropertyValue<byte>("CurrentBrightness"); + return new VcpFeatureValue(currentBrightness, 0, 100); + } + + // No match found - monitor may have been disconnected + } + catch (WmiException ex) + { + Logger.LogWarning($"WMI GetBrightness failed: {ex.Message} (HResult: 0x{ex.HResult:X})"); + } + catch (Exception ex) + { + Logger.LogWarning($"WMI GetBrightness failed: {ex.Message}"); + } + + return VcpFeatureValue.Invalid; + }, + cancellationToken); + } + + /// <summary> + /// Set monitor brightness + /// </summary> + public async Task<MonitorOperationResult> SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(monitor); + + // Validate brightness range + brightness = Math.Clamp(brightness, 0, 100); + + return await Task.Run( + () => + { + try + { + using var connection = new WmiConnection(WmiNamespace); + var query = BuildInstanceNameQuery(BrightnessMethodClass, monitor.InstanceName); + var results = connection.CreateQuery(query); + + foreach (var obj in results) + { + // Call WmiSetBrightness method + // Parameters: Timeout (uint32), Brightness (uint8) + // Note: WmiLight requires string values for method parameters + using (WmiMethod method = obj.GetMethod("WmiSetBrightness")) + using (WmiMethodParameters inParams = method.CreateInParameters()) + { + inParams.SetPropertyValue("Timeout", "0"); + inParams.SetPropertyValue("Brightness", brightness.ToString(CultureInfo.InvariantCulture)); + + uint result = obj.ExecuteMethod<uint>( + method, + inParams, + out WmiMethodParameters outParams); + + // Check return value (0 indicates success) + if (result == 0) + { + return MonitorOperationResult.Success(); + } + + return MonitorOperationResult.Failure($"WMI method returned error code: {result}", (int)result); + } + } + + // No match found - monitor may have been disconnected + Logger.LogWarning($"WMI SetBrightness: No monitor found with InstanceName '{monitor.InstanceName}'"); + return MonitorOperationResult.Failure($"No WMI brightness method found for monitor '{monitor.InstanceName}'"); + } + catch (UnauthorizedAccessException) + { + return MonitorOperationResult.Failure("Access denied. Administrator privileges may be required.", 5); + } + catch (WmiException ex) + { + return ClassifyWmiError(ex, "SetBrightness"); + } + catch (Exception ex) + { + return MonitorOperationResult.Failure($"Unexpected error during SetBrightness: {ex.Message}"); + } + }, + cancellationToken); + } + + /// <summary> + /// Discover supported monitors. + /// WMI brightness control is typically only available on internal laptop displays, + /// which don't have meaningful UserFriendlyName in WmiMonitorID, so we use "Built-in Display". + /// </summary> + public async Task<IEnumerable<Monitor>> DiscoverMonitorsAsync(CancellationToken cancellationToken = default) + { + return await Task.Run( + () => + { + var monitors = new List<Monitor>(); + + try + { + using var connection = new WmiConnection(WmiNamespace); + + // Query WMI brightness support - only internal displays typically support this + var brightnessQuery = $"SELECT InstanceName, CurrentBrightness FROM {BrightnessQueryClass}"; + var brightnessResults = connection.CreateQuery(brightnessQuery).ToList(); + + if (brightnessResults.Count == 0) + { + return monitors; + } + + // Get MonitorDisplayInfo from QueryDisplayConfig - this provides the correct monitor numbers + var monitorDisplayInfos = Drivers.DDC.DdcCiNative.GetAllMonitorDisplayInfo(); + + // Create monitor objects for each supported brightness instance + foreach (var obj in brightnessResults) + { + try + { + var instanceName = obj.GetPropertyValue<string>("InstanceName") ?? string.Empty; + var currentBrightness = obj.GetPropertyValue<byte>("CurrentBrightness"); + + // Extract EDID ID from InstanceName + // e.g., "DISPLAY\LEN4038\4&40f4dee&0&UID8388688_0" -> "LEN4038" + var edidId = ExtractEdidIdFromInstanceName(instanceName); + + // Get MonitorDisplayInfo from QueryDisplayConfig by matching EDID ID + // This provides MonitorNumber and GdiDeviceName for display settings APIs + var displayInfo = GetMonitorDisplayInfoByEdidId(edidId, monitorDisplayInfos); + int monitorNumber = displayInfo?.MonitorNumber ?? 0; + string gdiDeviceName = displayInfo?.GdiDeviceName ?? string.Empty; + + // Generate unique ID: "WMI_{EdidId}_{MonitorNumber}" + string uniqueId = !string.IsNullOrEmpty(edidId) + ? $"WMI_{edidId}_{monitorNumber}" + : $"WMI_Unknown_{monitorNumber}"; + + // Get display name from PnP manufacturer ID (e.g., "Lenovo Built-in Display") + var displayName = PnpIdHelper.GetBuiltInDisplayName(edidId); + + var monitor = new Monitor + { + Id = uniqueId, + Name = displayName, + CurrentBrightness = currentBrightness, + MinBrightness = 0, + MaxBrightness = 100, + IsAvailable = true, + InstanceName = instanceName, + Capabilities = MonitorCapabilities.Brightness | MonitorCapabilities.Wmi, + CommunicationMethod = "WMI", + SupportsColorTemperature = false, + MonitorNumber = monitorNumber, + GdiDeviceName = gdiDeviceName, + }; + + monitors.Add(monitor); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to create monitor from WMI data: {ex.Message}"); + } + } + } + catch (WmiException ex) + { + Logger.LogError($"WMI DiscoverMonitors failed: {ex.Message} (HResult: 0x{ex.HResult:X})"); + } + catch (Exception ex) + { + Logger.LogError($"WMI DiscoverMonitors failed: {ex.Message}"); + } + + return monitors; + }, + cancellationToken); + } + + // Extended features not supported by WMI + public Task<MonitorOperationResult> SetContrastAsync(Monitor monitor, int contrast, CancellationToken cancellationToken = default) + { + return Task.FromResult(MonitorOperationResult.Failure("Contrast control not supported via WMI")); + } + + public Task<MonitorOperationResult> SetVolumeAsync(Monitor monitor, int volume, CancellationToken cancellationToken = default) + { + return Task.FromResult(MonitorOperationResult.Failure("Volume control not supported via WMI")); + } + + public Task<VcpFeatureValue> GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + return Task.FromResult(VcpFeatureValue.Invalid); + } + + public Task<MonitorOperationResult> SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default) + { + return Task.FromResult(MonitorOperationResult.Failure("Color temperature control not supported via WMI")); + } + + public Task<VcpFeatureValue> GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + // Input source switching not supported for internal displays + return Task.FromResult(VcpFeatureValue.Invalid); + } + + public Task<MonitorOperationResult> SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default) + { + // Input source switching not supported for internal displays + return Task.FromResult(MonitorOperationResult.Failure("Input source switching not supported via WMI")); + } + + public Task<MonitorOperationResult> SetPowerStateAsync(Monitor monitor, int powerState, CancellationToken cancellationToken = default) + { + // Power state control not supported for internal displays via WMI + return Task.FromResult(MonitorOperationResult.Failure("Power state control not supported via WMI")); + } + + public Task<VcpFeatureValue> GetPowerStateAsync(Monitor monitor, CancellationToken cancellationToken = default) + { + // Power state control not supported for internal displays via WMI + return Task.FromResult(VcpFeatureValue.Invalid); + } + + public void Dispose() + { + // WmiLight objects are created per-operation and disposed immediately via using statements. + // No instance-level resources require cleanup. + GC.SuppressFinalize(this); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorController.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorController.cs new file mode 100644 index 0000000000..cd3a6fe15d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorController.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PowerDisplay.Common.Models; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.Common.Interfaces +{ + /// <summary> + /// Monitor controller interface + /// </summary> + public interface IMonitorController + { + /// <summary> + /// Gets controller name + /// </summary> + string Name { get; } + + /// <summary> + /// Gets monitor brightness + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>Brightness information</returns> + Task<VcpFeatureValue> GetBrightnessAsync(Monitor monitor, CancellationToken cancellationToken = default); + + /// <summary> + /// Sets monitor brightness + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="brightness">Brightness value (0-100)</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>Operation result</returns> + Task<MonitorOperationResult> SetBrightnessAsync(Monitor monitor, int brightness, CancellationToken cancellationToken = default); + + /// <summary> + /// Discovers supported monitors + /// </summary> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>List of monitors</returns> + Task<IEnumerable<Monitor>> DiscoverMonitorsAsync(CancellationToken cancellationToken = default); + + /// <summary> + /// Sets monitor contrast + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="contrast">Contrast value (0-100)</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>Operation result</returns> + Task<MonitorOperationResult> SetContrastAsync(Monitor monitor, int contrast, CancellationToken cancellationToken = default); + + /// <summary> + /// Sets monitor volume + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="volume">Volume value (0-100)</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>Operation result</returns> + Task<MonitorOperationResult> SetVolumeAsync(Monitor monitor, int volume, CancellationToken cancellationToken = default); + + /// <summary> + /// Gets monitor color temperature using VCP 0x14 (Select Color Preset) + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature</returns> + Task<VcpFeatureValue> GetColorTemperatureAsync(Monitor monitor, CancellationToken cancellationToken = default); + + /// <summary> + /// Sets monitor color temperature using VCP 0x14 preset value + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="colorTemperature">VCP preset value (e.g., 0x05 for 6500K), not Kelvin temperature</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>Operation result</returns> + Task<MonitorOperationResult> SetColorTemperatureAsync(Monitor monitor, int colorTemperature, CancellationToken cancellationToken = default); + + /// <summary> + /// Gets current input source using VCP 0x60 + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>VCP input source value (e.g., 0x11 for HDMI-1)</returns> + Task<VcpFeatureValue> GetInputSourceAsync(Monitor monitor, CancellationToken cancellationToken = default); + + /// <summary> + /// Sets input source using VCP 0x60 + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="inputSource">VCP input source value (e.g., 0x11 for HDMI-1)</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>Operation result</returns> + Task<MonitorOperationResult> SetInputSourceAsync(Monitor monitor, int inputSource, CancellationToken cancellationToken = default); + + /// <summary> + /// Sets power state using VCP 0xD6 (Power Mode) + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="powerState">VCP power state value: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard)</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>Operation result</returns> + Task<MonitorOperationResult> SetPowerStateAsync(Monitor monitor, int powerState, CancellationToken cancellationToken = default); + + /// <summary> + /// Gets current power state using VCP 0xD6 (Power Mode) + /// </summary> + /// <param name="monitor">Monitor object</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>VCP power state value: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard)</returns> + Task<VcpFeatureValue> GetPowerStateAsync(Monitor monitor, CancellationToken cancellationToken = default); + + /// <summary> + /// Releases resources + /// </summary> + void Dispose(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorData.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorData.cs new file mode 100644 index 0000000000..26f156b97c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IMonitorData.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerDisplay.Common.Interfaces +{ + /// <summary> + /// Core interface representing monitor hardware data. + /// This interface defines the actual hardware values for a monitor. + /// Implementations can add UI-specific properties and use converters for display formatting. + /// </summary> + public interface IMonitorData + { + /// <summary> + /// Gets or sets the unique identifier for the monitor. + /// </summary> + string Id { get; set; } + + /// <summary> + /// Gets or sets the display name of the monitor. + /// </summary> + string Name { get; set; } + + /// <summary> + /// Gets or sets the current brightness value (0-100). + /// </summary> + int Brightness { get; set; } + + /// <summary> + /// Gets or sets the current contrast value (0-100). + /// </summary> + int Contrast { get; set; } + + /// <summary> + /// Gets or sets the current volume value (0-100). + /// </summary> + int Volume { get; set; } + + /// <summary> + /// Gets or sets the color temperature VCP preset value (raw DDC/CI value from VCP code 0x14). + /// This stores the raw VCP value (e.g., 0x05 for 6500K preset), not the Kelvin temperature. + /// Use ColorTemperatureHelper to convert to/from human-readable display names. + /// </summary> + int ColorTemperatureVcp { get; set; } + + /// <summary> + /// Gets or sets the monitor number (1, 2, 3...) as assigned by the OS. + /// </summary> + int MonitorNumber { get; set; } + + /// <summary> + /// Gets or sets the monitor orientation (0=0, 1=90, 2=180, 3=270). + /// </summary> + int Orientation { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IProfileService.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IProfileService.cs new file mode 100644 index 0000000000..3562346b94 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Interfaces/IProfileService.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using PowerDisplay.Common.Models; + +namespace PowerDisplay.Common.Interfaces +{ + /// <summary> + /// Interface for profile management service. + /// Provides abstraction for loading, saving, and managing PowerDisplay profiles. + /// Enables dependency injection and unit testing. + /// </summary> + public interface IProfileService + { + /// <summary> + /// Loads PowerDisplay profiles from disk. + /// </summary> + /// <returns>PowerDisplayProfiles object, or a new empty instance if file doesn't exist or load fails.</returns> + PowerDisplayProfiles LoadProfiles(); + + /// <summary> + /// Saves PowerDisplay profiles to disk. + /// </summary> + /// <param name="profiles">The profiles collection to save.</param> + /// <returns>True if save was successful, false otherwise.</returns> + bool SaveProfiles(PowerDisplayProfiles profiles); + + /// <summary> + /// Adds or updates a profile in the collection and persists to disk. + /// </summary> + /// <param name="profile">The profile to add or update.</param> + /// <returns>True if operation was successful, false otherwise.</returns> + bool AddOrUpdateProfile(PowerDisplayProfile profile); + + /// <summary> + /// Removes a profile by name and persists to disk. + /// </summary> + /// <param name="profileName">The name of the profile to remove.</param> + /// <returns>True if profile was found and removed, false otherwise.</returns> + bool RemoveProfile(string profileName); + + /// <summary> + /// Gets a profile by name. + /// </summary> + /// <param name="profileName">The name of the profile to retrieve.</param> + /// <returns>The profile if found, null otherwise.</returns> + PowerDisplayProfile? GetProfile(string profileName); + + /// <summary> + /// Checks if the profiles file exists. + /// </summary> + /// <returns>True if profiles file exists, false otherwise.</returns> + bool ProfilesFileExists(); + + /// <summary> + /// Gets the path to the profiles file. + /// </summary> + /// <returns>The full path to the profiles file.</returns> + string GetProfilesFilePath(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/ColorPresetItem.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/ColorPresetItem.cs new file mode 100644 index 0000000000..13baa45ed2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/ColorPresetItem.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Represents a color temperature preset item for VCP code 0x14. + /// Used to display available color temperature presets in UI components. + /// </summary> + public partial class ColorPresetItem : INotifyPropertyChanged + { + private int _vcpValue; + private string _displayName = string.Empty; + + /// <summary> + /// Initializes a new instance of the <see cref="ColorPresetItem"/> class. + /// </summary> + public ColorPresetItem() + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="ColorPresetItem"/> class. + /// </summary> + /// <param name="vcpValue">The VCP value for the color temperature preset.</param> + /// <param name="displayName">The display name for UI.</param> + public ColorPresetItem(int vcpValue, string displayName) + { + _vcpValue = vcpValue; + _displayName = displayName; + } + + /// <summary> + /// Occurs when a property value changes. + /// </summary> + public event PropertyChangedEventHandler? PropertyChanged; + + /// <summary> + /// Gets or sets the VCP value for this color temperature preset. + /// </summary> + [JsonPropertyName("vcpValue")] + public int VcpValue + { + get => _vcpValue; + set + { + if (_vcpValue != value) + { + _vcpValue = value; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets or sets the display name for UI. + /// </summary> + [JsonPropertyName("displayName")] + public string DisplayName + { + get => _displayName; + set + { + if (_displayName != value) + { + _displayName = value; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Raises the PropertyChanged event. + /// </summary> + /// <param name="propertyName">The name of the property that changed.</param> + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/CustomVcpValueMapping.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/CustomVcpValueMapping.cs new file mode 100644 index 0000000000..65131af103 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/CustomVcpValueMapping.cs @@ -0,0 +1,88 @@ +// 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; +using PowerDisplay.Common.Utils; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Represents a custom name mapping for a VCP code value. + /// Used to override the default VCP value names with user-defined names. + /// This class is shared between PowerDisplay app and Settings UI. + /// </summary> + public class CustomVcpValueMapping + { + /// <summary> + /// Gets or sets the VCP code (e.g., 0x14 for color temperature, 0x60 for input source). + /// </summary> + [JsonPropertyName("vcpCode")] + public byte VcpCode { get; set; } + + /// <summary> + /// Gets or sets the VCP value to map (e.g., 0x11 for HDMI-1). + /// </summary> + [JsonPropertyName("value")] + public int Value { get; set; } + + /// <summary> + /// Gets or sets the custom name to display instead of the default name. + /// </summary> + [JsonPropertyName("customName")] + public string CustomName { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets a value indicating whether this mapping applies to all monitors. + /// When true, the mapping is applied globally. When false, only applies to TargetMonitorId. + /// </summary> + [JsonPropertyName("applyToAll")] + public bool ApplyToAll { get; set; } = true; + + /// <summary> + /// Gets or sets the target monitor ID when ApplyToAll is false. + /// This is the monitor's unique identifier. + /// </summary> + [JsonPropertyName("targetMonitorId")] + public string TargetMonitorId { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the target monitor display name (for UI display only, not serialized). + /// </summary> + [JsonIgnore] + public string TargetMonitorName { get; set; } = string.Empty; + + /// <summary> + /// Gets the display name for the VCP code (for UI display). + /// Uses VcpNames.GetCodeName() to get the standard MCCS VCP code name. + /// Note: For localized display in Settings UI, use VcpCodeToDisplayNameConverter instead. + /// </summary> + [JsonIgnore] + public string VcpCodeDisplayName => VcpNames.GetCodeName(VcpCode); + + /// <summary> + /// Gets the display name for the VCP value (using built-in mapping). + /// </summary> + [JsonIgnore] + public string ValueDisplayName => VcpNames.GetFormattedValueName(VcpCode, Value); + + /// <summary> + /// Gets a summary string for display in the UI list. + /// Format: "OriginalValue → CustomName" or "OriginalValue → CustomName (MonitorName)" + /// </summary> + [JsonIgnore] + public string DisplaySummary + { + get + { + var baseSummary = $"{VcpNames.GetValueName(VcpCode, Value) ?? $"0x{Value:X2}"} → {CustomName}"; + if (!ApplyToAll && !string.IsNullOrEmpty(TargetMonitorName)) + { + return $"{baseSummary} ({TargetMonitorName})"; + } + + return baseSummary; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/Monitor.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/Monitor.cs new file mode 100644 index 0000000000..2b72890803 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/Monitor.cs @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using PowerDisplay.Common.Interfaces; +using PowerDisplay.Common.Utils; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Monitor model that implements property change notification. + /// Implements IMonitorData to provide a common interface for monitor hardware values. + /// </summary> + /// <remarks> + /// <para><see cref="Id"/> is the unique identifier used for all purposes: UI lookups, IPC, persistent storage, and handle management.</para> + /// <para>Format: "{Source}_{EdidId}_{MonitorNumber}" (e.g., "DDC_GSM5C6D_1", "WMI_BOE0900_2").</para> + /// </remarks> + public partial class Monitor : INotifyPropertyChanged, IMonitorData + { + private int _currentBrightness; + private int _currentColorTemperature = 0x05; // Default to 6500K preset (VCP 0x14 value) + private int _currentInputSource; // VCP 0x60 value + private int _currentPowerState = 0x01; // Default to On (VCP 0xD6 value) + private bool _isAvailable = true; + private int _orientation; + + /// <summary> + /// Gets or sets unique identifier for all purposes: UI lookups, IPC, persistent storage, and handle management. + /// </summary> + /// <remarks> + /// Format: "{Source}_{EdidId}_{MonitorNumber}" where Source is "DDC" or "WMI". + /// Examples: "DDC_GSM5C6D_1", "WMI_BOE0900_2". + /// Stable across reboots and unique even for multiple identical monitors. + /// </remarks> + public string Id { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets display name + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets current brightness (0-100) + /// </summary> + public int CurrentBrightness + { + get => _currentBrightness; + set + { + var clamped = Math.Clamp(value, MinBrightness, MaxBrightness); + if (_currentBrightness != clamped) + { + _currentBrightness = clamped; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets or sets minimum brightness value + /// </summary> + public int MinBrightness { get; set; } + + /// <summary> + /// Gets or sets maximum brightness value + /// </summary> + public int MaxBrightness { get; set; } = 100; + + /// <summary> + /// Gets or sets current color temperature VCP preset value (from VCP code 0x14). + /// This stores the raw VCP value (e.g., 0x05 for 6500K), not Kelvin temperature. + /// Use ColorTemperaturePresetName to get human-readable name. + /// </summary> + public int CurrentColorTemperature + { + get => _currentColorTemperature; + set + { + if (_currentColorTemperature != value) + { + _currentColorTemperature = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ColorTemperaturePresetName)); + } + } + } + + /// <summary> + /// Gets human-readable color temperature preset name (e.g., "6500K (0x05)", "sRGB (0x01)") + /// </summary> + public string ColorTemperaturePresetName => + VcpNames.GetFormattedValueName(0x14, CurrentColorTemperature); + + /// <summary> + /// Gets or sets a value indicating whether the monitor supports color temperature adjustment via VCP 0x14 + /// </summary> + public bool SupportsColorTemperature { get; set; } + + /// <summary> + /// Gets or sets current input source VCP value (from VCP code 0x60). + /// This stores the raw VCP value (e.g., 0x11 for HDMI-1). + /// Use InputSourceName to get human-readable name. + /// </summary> + public int CurrentInputSource + { + get => _currentInputSource; + set + { + if (_currentInputSource != value) + { + _currentInputSource = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(InputSourceName)); + } + } + } + + /// <summary> + /// Gets human-readable input source name (e.g., "HDMI-1", "DisplayPort-1") + /// Returns just the name without hex value for cleaner UI display. + /// </summary> + public string InputSourceName => + VcpNames.GetValueName(0x60, CurrentInputSource) ?? $"Source 0x{CurrentInputSource:X2}"; + + /// <summary> + /// Gets a value indicating whether the monitor supports input source switching via VCP 0x60 + /// </summary> + public bool SupportsInputSource => VcpCapabilitiesInfo?.SupportsVcpCode(0x60) ?? false; + + /// <summary> + /// Gets get supported input sources from capabilities (as list of VCP values) + /// </summary> + public System.Collections.Generic.IReadOnlyList<int>? SupportedInputSources => + VcpCapabilitiesInfo?.GetSupportedValues(0x60); + + /// <summary> + /// Gets a value indicating whether the monitor supports power state control via VCP 0xD6 + /// </summary> + public bool SupportsPowerState => VcpCapabilitiesInfo?.SupportsVcpCode(0xD6) ?? false; + + /// <summary> + /// Gets supported power states from capabilities (as list of VCP values) + /// Values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard) + /// </summary> + public System.Collections.Generic.IReadOnlyList<int>? SupportedPowerStates => + VcpCapabilitiesInfo?.GetSupportedValues(0xD6); + + /// <summary> + /// Gets or sets current power state VCP value (from VCP code 0xD6). + /// Values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard). + /// </summary> + public int CurrentPowerState + { + get => _currentPowerState; + set + { + if (_currentPowerState != value) + { + _currentPowerState = value; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets a value indicating whether the monitor supports contrast adjustment + /// </summary> + public bool SupportsContrast => Capabilities.HasFlag(MonitorCapabilities.Contrast); + + /// <summary> + /// Gets a value indicating whether the monitor supports volume adjustment (for audio-capable monitors) + /// </summary> + public bool SupportsVolume => Capabilities.HasFlag(MonitorCapabilities.Volume); + + private int _currentContrast = 50; + private int _currentVolume = 50; + + /// <summary> + /// Gets or sets current contrast (0-100) + /// </summary> + public int CurrentContrast + { + get => _currentContrast; + set + { + var clamped = Math.Clamp(value, MinContrast, MaxContrast); + if (_currentContrast != clamped) + { + _currentContrast = clamped; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets or sets minimum contrast value + /// </summary> + public int MinContrast { get; set; } + + /// <summary> + /// Gets or sets maximum contrast value + /// </summary> + public int MaxContrast { get; set; } = 100; + + /// <summary> + /// Gets or sets current volume (0-100) + /// </summary> + public int CurrentVolume + { + get => _currentVolume; + set + { + var clamped = Math.Clamp(value, MinVolume, MaxVolume); + if (_currentVolume != clamped) + { + _currentVolume = clamped; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets or sets minimum volume value + /// </summary> + public int MinVolume { get; set; } + + /// <summary> + /// Gets or sets maximum volume value + /// </summary> + public int MaxVolume { get; set; } = 100; + + /// <summary> + /// Gets or sets a value indicating whether the monitor is available/online + /// </summary> + public bool IsAvailable + { + get => _isAvailable; + set + { + if (_isAvailable != value) + { + _isAvailable = value; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets or sets physical monitor handle (for DDC/CI) + /// </summary> + public IntPtr Handle { get; set; } = IntPtr.Zero; + + /// <summary> + /// Gets or sets instance name (used by WMI) + /// </summary> + public string InstanceName { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets communication method (DDC/CI, WMI, HDR API, etc.) + /// </summary> + public string CommunicationMethod { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets supported control methods + /// </summary> + public MonitorCapabilities Capabilities { get; set; } = MonitorCapabilities.None; + + /// <summary> + /// Gets or sets raw DDC/CI capabilities string (MCCS format) + /// </summary> + public string? CapabilitiesRaw { get; set; } + + /// <summary> + /// Gets or sets parsed VCP capabilities information + /// </summary> + public VcpCapabilities? VcpCapabilitiesInfo { get; set; } + + /// <summary> + /// Gets or sets last update time + /// </summary> + public DateTime LastUpdate { get; set; } = DateTime.Now; + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public override string ToString() + { + return $"{Name} ({CommunicationMethod}) - {CurrentBrightness}%"; + } + + /// <summary> + /// Update monitor status + /// </summary> + public void UpdateStatus(int brightness, bool isAvailable = true) + { + IsAvailable = isAvailable; + if (isAvailable) + { + CurrentBrightness = brightness; + LastUpdate = DateTime.Now; + } + } + + /// <inheritdoc /> + int IMonitorData.Brightness + { + get => CurrentBrightness; + set => CurrentBrightness = value; + } + + /// <inheritdoc /> + int IMonitorData.Contrast + { + get => CurrentContrast; + set => CurrentContrast = value; + } + + /// <inheritdoc /> + int IMonitorData.Volume + { + get => CurrentVolume; + set => CurrentVolume = value; + } + + /// <inheritdoc /> + int IMonitorData.ColorTemperatureVcp + { + get => CurrentColorTemperature; + set => CurrentColorTemperature = value; + } + + /// <summary> + /// Gets or sets monitor number (1, 2, 3...) + /// </summary> + public int MonitorNumber { get; set; } + + /// <summary> + /// Gets or sets the GDI device name (e.g., "\\.\DISPLAY1"). + /// This is obtained from QueryDisplayConfig during discovery and should be used + /// for display settings APIs (EnumDisplaySettings, ChangeDisplaySettingsEx). + /// </summary> + public string GdiDeviceName { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets monitor orientation (0=0, 1=90, 2=180, 3=270). + /// Fires PropertyChanged when value changes. + /// </summary> + public int Orientation + { + get => _orientation; + set + { + if (_orientation != value) + { + _orientation = value; + OnPropertyChanged(); + } + } + } + + /// <inheritdoc /> + int IMonitorData.MonitorNumber + { + get => MonitorNumber; + set => MonitorNumber = value; + } + + /// <inheritdoc /> + int IMonitorData.Orientation + { + get => Orientation; + set => Orientation = value; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorCapabilities.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorCapabilities.cs new file mode 100644 index 0000000000..e961f32038 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorCapabilities.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Monitor control capabilities flags + /// </summary> + [Flags] + public enum MonitorCapabilities + { + None = 0, + + /// <summary> + /// Supports brightness control + /// </summary> + Brightness = 1 << 0, + + /// <summary> + /// Supports contrast control + /// </summary> + Contrast = 1 << 1, + + /// <summary> + /// Supports DDC/CI protocol + /// </summary> + DdcCi = 1 << 2, + + /// <summary> + /// Supports WMI control + /// </summary> + Wmi = 1 << 3, + + /// <summary> + /// Supports HDR + /// </summary> + Hdr = 1 << 4, + + /// <summary> + /// Supports high-level monitor API + /// </summary> + HighLevel = 1 << 5, + + /// <summary> + /// Supports volume control + /// </summary> + Volume = 1 << 6, + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorOperationResult.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorOperationResult.cs new file mode 100644 index 0000000000..6905d7be44 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorOperationResult.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Monitor operation result + /// </summary> + public readonly struct MonitorOperationResult + { + /// <summary> + /// Gets a value indicating whether the operation was successful + /// </summary> + public bool IsSuccess { get; } + + /// <summary> + /// Gets error message + /// </summary> + public string? ErrorMessage { get; } + + /// <summary> + /// Gets system error code + /// </summary> + public int? ErrorCode { get; } + + /// <summary> + /// Gets operation timestamp + /// </summary> + public DateTime Timestamp { get; } + + private MonitorOperationResult(bool isSuccess, string? errorMessage = null, int? errorCode = null) + { + IsSuccess = isSuccess; + ErrorMessage = errorMessage; + ErrorCode = errorCode; + Timestamp = DateTime.Now; + } + + /// <summary> + /// Creates a successful result + /// </summary> + public static MonitorOperationResult Success() => new(true); + + /// <summary> + /// Creates a failed result + /// </summary> + public static MonitorOperationResult Failure(string errorMessage, int? errorCode = null) + => new(false, errorMessage, errorCode); + + public override string ToString() + { + return IsSuccess ? "Success" : $"Failed: {ErrorMessage}"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateEntry.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateEntry.cs new file mode 100644 index 0000000000..8b1ade54a2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateEntry.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Individual monitor state entry for JSON persistence. + /// Stores the current state of a monitor's adjustable parameters. + /// </summary> + public sealed class MonitorStateEntry + { + /// <summary> + /// Gets or sets the brightness level (0-100). + /// </summary> + [JsonPropertyName("brightness")] + public int Brightness { get; set; } + + /// <summary> + /// Gets or sets the color temperature VCP value. + /// </summary> + [JsonPropertyName("colorTemperature")] + public int ColorTemperatureVcp { get; set; } + + /// <summary> + /// Gets or sets the contrast level (0-100). + /// </summary> + [JsonPropertyName("contrast")] + public int Contrast { get; set; } + + /// <summary> + /// Gets or sets the volume level (0-100). + /// </summary> + [JsonPropertyName("volume")] + public int Volume { get; set; } + + /// <summary> + /// Gets or sets the raw capabilities string from DDC/CI. + /// </summary> + [JsonPropertyName("capabilitiesRaw")] + public string? CapabilitiesRaw { get; set; } + + /// <summary> + /// Gets or sets when this entry was last updated. + /// </summary> + [JsonPropertyName("lastUpdated")] + public DateTime LastUpdated { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateFile.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateFile.cs new file mode 100644 index 0000000000..e761503649 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/MonitorStateFile.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Monitor state file structure for JSON persistence. + /// Contains all monitor states indexed by Monitor.Id. + /// </summary> + public sealed class MonitorStateFile + { + /// <summary> + /// Gets or sets the monitor states dictionary. + /// Key is the monitor's unique Id (e.g., "DDC_GSM5C6D_1", "WMI_BOE0900_2"). + /// </summary> + [JsonPropertyName("monitors")] + public Dictionary<string, MonitorStateEntry> Monitors { get; set; } = new(); + + /// <summary> + /// Gets or sets when the file was last updated. + /// </summary> + [JsonPropertyName("lastUpdated")] + public DateTime LastUpdated { get; set; } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfile.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfile.cs new file mode 100644 index 0000000000..8944569201 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfile.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Represents a PowerDisplay profile containing monitor settings + /// </summary> + public class PowerDisplayProfile + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("monitorSettings")] + public List<ProfileMonitorSetting> MonitorSettings { get; set; } + + [JsonPropertyName("createdDate")] + public DateTime CreatedDate { get; set; } + + [JsonPropertyName("lastModified")] + public DateTime LastModified { get; set; } + + public PowerDisplayProfile() + { + Name = string.Empty; + MonitorSettings = new List<ProfileMonitorSetting>(); + CreatedDate = DateTime.UtcNow; + LastModified = DateTime.UtcNow; + } + + public PowerDisplayProfile(string name, List<ProfileMonitorSetting> monitorSettings) + { + Name = name; + MonitorSettings = monitorSettings ?? new List<ProfileMonitorSetting>(); + CreatedDate = DateTime.UtcNow; + LastModified = DateTime.UtcNow; + } + + /// <summary> + /// Validates that the profile has at least one monitor configured + /// </summary> + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(Name) && MonitorSettings != null && MonitorSettings.Count > 0; + } + + /// <summary> + /// Updates the last modified timestamp + /// </summary> + public void Touch() + { + LastModified = DateTime.UtcNow; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfiles.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfiles.cs new file mode 100644 index 0000000000..6813089943 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/PowerDisplayProfiles.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Container for all PowerDisplay profiles + /// </summary> + public class PowerDisplayProfiles + { + // NOTE: Custom profile concept has been removed. Profiles are now templates, not states. + // This constant is kept for backward compatibility (cleaning up legacy Custom profiles). + public const string CustomProfileName = "Custom"; + + [JsonPropertyName("profiles")] + public List<PowerDisplayProfile> Profiles { get; set; } + + [JsonPropertyName("lastUpdated")] + public DateTime LastUpdated { get; set; } + + public PowerDisplayProfiles() + { + Profiles = new List<PowerDisplayProfile>(); + LastUpdated = DateTime.UtcNow; + } + + /// <summary> + /// Gets the profile by name + /// </summary> + public PowerDisplayProfile? GetProfile(string name) + { + return Profiles.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + } + + /// <summary> + /// Adds or updates a profile + /// </summary> + public void SetProfile(PowerDisplayProfile profile) + { + if (profile == null || !profile.IsValid()) + { + throw new ArgumentException("Profile is invalid"); + } + + var existing = GetProfile(profile.Name); + if (existing != null) + { + Profiles.Remove(existing); + } + + profile.Touch(); + Profiles.Add(profile); + LastUpdated = DateTime.UtcNow; + } + + /// <summary> + /// Removes a profile by name + /// </summary> + public bool RemoveProfile(string name) + { + var profile = GetProfile(name); + if (profile != null) + { + Profiles.Remove(profile); + LastUpdated = DateTime.UtcNow; + return true; + } + + return false; + } + + /// <summary> + /// Checks if a profile name is valid and available + /// </summary> + public bool IsNameAvailable(string name, string? excludeName = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + // Check if name is already used (excluding the profile being renamed) + var existing = GetProfile(name); + if (existing != null && (excludeName == null || !existing.Name.Equals(excludeName, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return true; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/ProfileMonitorSetting.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/ProfileMonitorSetting.cs new file mode 100644 index 0000000000..d346657d7c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/ProfileMonitorSetting.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// Monitor settings for a specific profile + /// </summary> + public class ProfileMonitorSetting + { + /// <summary> + /// Gets or sets the monitor's unique identifier. + /// Format: "{Source}_{EdidId}_{MonitorNumber}" (e.g., "DDC_GSM5C6D_1"). + /// </summary> + [JsonPropertyName("monitorId")] + public string MonitorId { get; set; } + + [JsonPropertyName("brightness")] + public int? Brightness { get; set; } + + [JsonPropertyName("contrast")] + public int? Contrast { get; set; } + + [JsonPropertyName("volume")] + public int? Volume { get; set; } + + /// <summary> + /// Gets or sets the color temperature VCP preset value. + /// JSON property name kept as "colorTemperature" for backward compatibility. + /// </summary> + [JsonPropertyName("colorTemperature")] + public int? ColorTemperatureVcp { get; set; } + + public ProfileMonitorSetting() + { + MonitorId = string.Empty; + } + + public ProfileMonitorSetting(string monitorId, int? brightness = null, int? colorTemperatureVcp = null, int? contrast = null, int? volume = null) + { + MonitorId = monitorId; + Brightness = brightness; + ColorTemperatureVcp = colorTemperatureVcp; + Contrast = contrast; + Volume = volume; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpCapabilities.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpCapabilities.cs new file mode 100644 index 0000000000..c30eccefe2 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpCapabilities.cs @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// DDC/CI VCP capabilities information + /// </summary> + public class VcpCapabilities + { + /// <summary> + /// Gets or sets raw capabilities string (MCCS format) + /// </summary> + public string Raw { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets monitor model name from capabilities + /// </summary> + public string? Model { get; set; } + + /// <summary> + /// Gets or sets monitor type from capabilities (e.g., "LCD") + /// </summary> + public string? Type { get; set; } + + /// <summary> + /// Gets or sets mCCS protocol version + /// </summary> + public string? Protocol { get; set; } + + /// <summary> + /// Gets or sets mCCS version (e.g., "2.2", "2.1") + /// </summary> + public string? MccsVersion { get; set; } + + /// <summary> + /// Gets or sets supported command codes + /// </summary> + public List<byte> SupportedCommands { get; set; } = new(); + + /// <summary> + /// Gets or sets supported VCP codes with their information + /// </summary> + public Dictionary<byte, VcpCodeInfo> SupportedVcpCodes { get; set; } = new(); + + /// <summary> + /// Gets or sets window capabilities for PIP/PBP support + /// </summary> + public List<WindowCapability> Windows { get; set; } = new(); + + /// <summary> + /// Gets a value indicating whether check if display supports PIP/PBP windows + /// </summary> + public bool HasWindowSupport => Windows.Count > 0; + + /// <summary> + /// Check if a specific VCP code is supported + /// </summary> + public bool SupportsVcpCode(byte code) => SupportedVcpCodes.ContainsKey(code); + + /// <summary> + /// Get VCP code information + /// </summary> + public VcpCodeInfo? GetVcpCodeInfo(byte code) + { + return SupportedVcpCodes.TryGetValue(code, out var info) ? info : null; + } + + /// <summary> + /// Check if a VCP code supports discrete values + /// </summary> + public bool HasDiscreteValues(byte code) + { + var info = GetVcpCodeInfo(code); + return info?.HasDiscreteValues ?? false; + } + + /// <summary> + /// Get supported values for a VCP code + /// </summary> + public IReadOnlyList<int>? GetSupportedValues(byte code) + { + return GetVcpCodeInfo(code)?.SupportedValues; + } + + /// <summary> + /// Get all VCP codes as hex strings, sorted by code value. + /// </summary> + /// <returns>List of hex strings like ["0x10", "0x12", "0x14"]</returns> + public List<string> GetVcpCodesAsHexStrings() + { + var result = new List<string>(SupportedVcpCodes.Count); + foreach (var kvp in SupportedVcpCodes) + { + result.Add($"0x{kvp.Key:X2}"); + } + + result.Sort(StringComparer.Ordinal); + return result; + } + + /// <summary> + /// Get all VCP codes sorted by code value. + /// </summary> + /// <returns>Sorted list of VcpCodeInfo</returns> + public IEnumerable<VcpCodeInfo> GetSortedVcpCodes() + { + var sortedKeys = new List<byte>(SupportedVcpCodes.Keys); + sortedKeys.Sort(); + + foreach (var key in sortedKeys) + { + yield return SupportedVcpCodes[key]; + } + } + + /// <summary> + /// Gets creates an empty capabilities object + /// </summary> + public static VcpCapabilities Empty => new(); + + public override string ToString() + { + return $"Model: {Model}, VCP Codes: {SupportedVcpCodes.Count}"; + } + } + + /// <summary> + /// Information about a single VCP code + /// </summary> + public readonly struct VcpCodeInfo + { + /// <summary> + /// Gets vCP code (e.g., 0x10 for brightness) + /// </summary> + public byte Code { get; } + + /// <summary> + /// Gets human-readable name of the VCP code + /// </summary> + public string Name { get; } + + /// <summary> + /// Gets supported discrete values (empty if continuous range) + /// </summary> + public IReadOnlyList<int> SupportedValues { get; } + + /// <summary> + /// Gets a value indicating whether this VCP code has discrete values + /// </summary> + public bool HasDiscreteValues => SupportedValues.Count > 0; + + /// <summary> + /// Gets a value indicating whether this VCP code supports a continuous range + /// </summary> + public bool IsContinuous => SupportedValues.Count == 0; + + /// <summary> + /// Gets the VCP code formatted as a hex string (e.g., "0x10"). + /// </summary> + public string FormattedCode => $"0x{Code:X2}"; + + /// <summary> + /// Gets the VCP code formatted with its name (e.g., "Brightness (0x10)"). + /// </summary> + public string FormattedTitle => $"{Name} ({FormattedCode})"; + + public VcpCodeInfo(byte code, string name, IReadOnlyList<int>? supportedValues = null) + { + Code = code; + Name = name; + SupportedValues = supportedValues ?? Array.Empty<int>(); + } + + public override string ToString() + { + if (HasDiscreteValues) + { + return $"0x{Code:X2} ({Name}): {string.Join(", ", SupportedValues)}"; + } + + return $"0x{Code:X2} ({Name}): Continuous"; + } + } + + /// <summary> + /// Window size (width and height) + /// </summary> + public readonly struct WindowSize + { + /// <summary> + /// Gets width in pixels + /// </summary> + public int Width { get; } + + /// <summary> + /// Gets height in pixels + /// </summary> + public int Height { get; } + + public WindowSize(int width, int height) + { + Width = width; + Height = height; + } + + public override string ToString() => $"{Width}x{Height}"; + } + + /// <summary> + /// Window area coordinates (top-left and bottom-right) + /// </summary> + public readonly struct WindowArea + { + /// <summary> + /// Gets top-left X coordinate + /// </summary> + public int X1 { get; } + + /// <summary> + /// Gets top-left Y coordinate + /// </summary> + public int Y1 { get; } + + /// <summary> + /// Gets bottom-right X coordinate + /// </summary> + public int X2 { get; } + + /// <summary> + /// Gets bottom-right Y coordinate + /// </summary> + public int Y2 { get; } + + /// <summary> + /// Gets width of the area + /// </summary> + public int Width => X2 - X1; + + /// <summary> + /// Gets height of the area + /// </summary> + public int Height => Y2 - Y1; + + public WindowArea(int x1, int y1, int x2, int y2) + { + X1 = x1; + Y1 = y1; + X2 = x2; + Y2 = y2; + } + + public override string ToString() => $"({X1},{Y1})-({X2},{Y2})"; + } + + /// <summary> + /// Window capability information for PIP/PBP displays + /// </summary> + public readonly struct WindowCapability + { + /// <summary> + /// Gets window number (1, 2, 3, etc.) + /// </summary> + public int WindowNumber { get; } + + /// <summary> + /// Gets window type (e.g., "PIP", "PBP") + /// </summary> + public string Type { get; } + + /// <summary> + /// Gets window area coordinates + /// </summary> + public WindowArea Area { get; } + + /// <summary> + /// Gets maximum window size + /// </summary> + public WindowSize MaxSize { get; } + + /// <summary> + /// Gets minimum window size + /// </summary> + public WindowSize MinSize { get; } + + /// <summary> + /// Gets window identifier + /// </summary> + public int WindowId { get; } + + public WindowCapability( + int windowNumber, + string type, + WindowArea area, + WindowSize maxSize, + WindowSize minSize, + int windowId) + { + WindowNumber = windowNumber; + Type = type ?? string.Empty; + Area = area; + MaxSize = maxSize; + MinSize = minSize; + WindowId = windowId; + } + + public override string ToString() => + $"Window{WindowNumber}: Type={Type}, Area={Area}, Max={MaxSize}, Min={MinSize}"; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpFeatureValue.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpFeatureValue.cs new file mode 100644 index 0000000000..64c4b2d801 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Models/VcpFeatureValue.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace PowerDisplay.Common.Models +{ + /// <summary> + /// VCP feature value information structure. + /// Represents the current, minimum, and maximum values for a VCP (Virtual Control Panel) feature. + /// </summary> + public readonly struct VcpFeatureValue + { + /// <summary> + /// Gets current value + /// </summary> + public int Current { get; } + + /// <summary> + /// Gets minimum value + /// </summary> + public int Minimum { get; } + + /// <summary> + /// Gets maximum value + /// </summary> + public int Maximum { get; } + + /// <summary> + /// Gets a value indicating whether the value information is valid + /// </summary> + public bool IsValid { get; } + + /// <summary> + /// Gets timestamp when the value information was obtained + /// </summary> + public DateTime Timestamp { get; } + + public VcpFeatureValue(int current, int minimum, int maximum) + { + Current = current; + Minimum = minimum; + Maximum = maximum; + IsValid = current >= minimum && current <= maximum && maximum > minimum; + Timestamp = DateTime.Now; + } + + public VcpFeatureValue(int current, int maximum) + : this(current, 0, maximum) + { + } + + /// <summary> + /// Gets creates invalid value information + /// </summary> + public static VcpFeatureValue Invalid => new(-1, -1, -1); + + /// <summary> + /// Converts value to percentage (0-100) + /// </summary> + public int ToPercentage() + { + if (!IsValid || Maximum == Minimum) + { + return 0; + } + + return (int)Math.Round((double)(Current - Minimum) * 100 / (Maximum - Minimum)); + } + + public override string ToString() + { + return IsValid ? $"{Current}/{Maximum} ({ToPercentage()}%)" : "Invalid"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.json b/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.json new file mode 100644 index 0000000000..450ecacafd --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "public": true, + "allowMarshaling": false +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.txt b/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.txt new file mode 100644 index 0000000000..769c001fed --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/NativeMethods.txt @@ -0,0 +1,9 @@ +// Structs and constants only - functions use LibraryImport for AOT compatibility +// CsWin32 generates blittable types when allowMarshaling: false + +// Note: All structs are manually defined in NativeStructures.cs with proper blittable layouts +// This file is intentionally left with only LUID as a minimal test +// Full DISPLAYCONFIG_* types need helper methods like GetViewGdiDeviceName() which CsWin32 doesn't provide + +// Only request LUID from CsWin32 (other types manually defined in NativeStructures.cs) +LUID diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/PathConstants.cs b/src/modules/powerdisplay/PowerDisplay.Lib/PathConstants.cs new file mode 100644 index 0000000000..4dd92d9666 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/PathConstants.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; + +namespace PowerDisplay.Common +{ + /// <summary> + /// Centralized path constants for PowerDisplay module. + /// Provides unified access to all file and folder paths used by PowerDisplay and related integrations. + /// </summary> + public static class PathConstants + { + private static readonly Lazy<string> _localAppDataPath = new Lazy<string>( + () => Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + + private static readonly Lazy<string> _powerToysBasePath = new Lazy<string>( + () => Path.Combine(_localAppDataPath.Value, "Microsoft", "PowerToys")); + + /// <summary> + /// Gets the base PowerToys settings folder path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys + /// </summary> + public static string PowerToysBasePath => _powerToysBasePath.Value; + + /// <summary> + /// Gets the PowerDisplay module folder path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay + /// </summary> + public static string PowerDisplayFolderPath => Path.Combine(PowerToysBasePath, "PowerDisplay"); + + /// <summary> + /// Gets the PowerDisplay profiles file path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay\profiles.json + /// </summary> + public static string ProfilesFilePath => Path.Combine(PowerDisplayFolderPath, ProfilesFileName); + + /// <summary> + /// Gets the PowerDisplay settings file path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay\settings.json + /// </summary> + public static string SettingsFilePath => Path.Combine(PowerDisplayFolderPath, SettingsFileName); + + /// <summary> + /// Gets the LightSwitch module folder path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\LightSwitch + /// </summary> + public static string LightSwitchFolderPath => Path.Combine(PowerToysBasePath, "LightSwitch"); + + /// <summary> + /// Gets the LightSwitch settings file path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\LightSwitch\settings.json + /// </summary> + public static string LightSwitchSettingsFilePath => Path.Combine(LightSwitchFolderPath, SettingsFileName); + + /// <summary> + /// The name of the profiles file. + /// </summary> + public const string ProfilesFileName = "profiles.json"; + + /// <summary> + /// The name of the settings file. + /// </summary> + public const string SettingsFileName = "settings.json"; + + /// <summary> + /// The name of the monitor state file. + /// </summary> + public const string MonitorStateFileName = "monitor_state.json"; + + /// <summary> + /// Gets the monitor state file path. + /// Example: C:\Users\{User}\AppData\Local\Microsoft\PowerToys\PowerDisplay\monitor_state.json + /// </summary> + public static string MonitorStateFilePath => Path.Combine(PowerDisplayFolderPath, MonitorStateFileName); + + /// <summary> + /// Event name for LightSwitch light theme change notifications. + /// Signaled when LightSwitch switches to light mode. + /// Must match CommonSharedConstants::LIGHT_SWITCH_LIGHT_THEME_EVENT in shared_constants.h. + /// </summary> + public const string LightSwitchLightThemeEventName = "Local\\PowerToysLightSwitch-LightThemeEvent-50077121-2ffc-4841-9c86-ab1bd3f9baca"; + + /// <summary> + /// Event name for LightSwitch dark theme change notifications. + /// Signaled when LightSwitch switches to dark mode. + /// Must match CommonSharedConstants::LIGHT_SWITCH_DARK_THEME_EVENT in shared_constants.h. + /// </summary> + public const string LightSwitchDarkThemeEventName = "Local\\PowerToysLightSwitch-DarkThemeEvent-b3a835c0-eaa2-49b0-b8eb-f793e3df3368"; + + /// <summary> + /// Ensures the PowerDisplay folder exists. Creates it if necessary. + /// </summary> + /// <returns>The PowerDisplay folder path</returns> + public static string EnsurePowerDisplayFolderExists() + => EnsureFolderExists(PowerDisplayFolderPath); + + /// <summary> + /// Ensures the LightSwitch folder exists. Creates it if necessary. + /// </summary> + /// <returns>The LightSwitch folder path</returns> + public static string EnsureLightSwitchFolderExists() + => EnsureFolderExists(LightSwitchFolderPath); + + /// <summary> + /// Ensures the specified folder exists. Creates it if necessary. + /// </summary> + /// <param name="folderPath">The folder path to ensure exists</param> + /// <returns>The folder path</returns> + private static string EnsureFolderExists(string folderPath) + { + if (!Directory.Exists(folderPath)) + { + Directory.CreateDirectory(folderPath); + } + + return folderPath; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj b/src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj new file mode 100644 index 0000000000..e9f8cd3f05 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj @@ -0,0 +1,43 @@ +<!-- Copyright (c) Microsoft Corporation. All rights reserved. --> +<!-- Licensed under the MIT License. See LICENSE file in the project root for license information. --> +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" /> + <PropertyGroup> + <OutputType>Library</OutputType> + <RootNamespace>PowerDisplay.Common</RootNamespace> + <Platforms>x64;ARM64</Platforms> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + <Nullable>enable</Nullable> + <AssemblyName>PowerDisplay.Lib</AssemblyName> + </PropertyGroup> + <!-- Native AOT Configuration --> + <PropertyGroup> + <PublishSingleFile>false</PublishSingleFile> + <DisableRuntimeMarshalling>false</DisableRuntimeMarshalling> + <IsAotCompatible>true</IsAotCompatible> + <TrimmerSingleWarn>false</TrimmerSingleWarn> + <SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Polly.Core" /> + <PackageReference Include="WmiLight" /> + <PackageReference Include="System.Collections.Immutable" /> + <PackageReference Include="Microsoft.Windows.CsWin32"> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <!-- Hide build log files from Solution Explorer --> + <None Remove="*.log" /> + <None Remove="*.binlog" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" /> + <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" /> + </ItemGroup> +</Project> diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Serialization/ProfileSerializationContext.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Serialization/ProfileSerializationContext.cs new file mode 100644 index 0000000000..198829f93e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Serialization/ProfileSerializationContext.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using PowerDisplay.Common.Models; + +namespace PowerDisplay.Common.Serialization +{ + /// <summary> + /// JSON serialization context for PowerDisplay Profile types. + /// Provides source-generated serialization for Native AOT compatibility. + /// </summary> + [JsonSourceGenerationOptions( + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + IncludeFields = true)] + + // Profile Types + [JsonSerializable(typeof(ProfileMonitorSetting))] + [JsonSerializable(typeof(List<ProfileMonitorSetting>))] + [JsonSerializable(typeof(PowerDisplayProfile))] + [JsonSerializable(typeof(List<PowerDisplayProfile>))] + [JsonSerializable(typeof(PowerDisplayProfiles))] + + // Monitor State Types + [JsonSerializable(typeof(MonitorStateEntry))] + [JsonSerializable(typeof(MonitorStateFile))] + [JsonSerializable(typeof(Dictionary<string, MonitorStateEntry>))] + public partial class ProfileSerializationContext : JsonSerializerContext + { + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Services/DisplayRotationService.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Services/DisplayRotationService.cs new file mode 100644 index 0000000000..2b3c6bc31d --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Services/DisplayRotationService.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using ManagedCommon; +using PowerDisplay.Common.Models; +using static PowerDisplay.Common.Drivers.NativeConstants; +using static PowerDisplay.Common.Drivers.PInvoke; + +using DevMode = PowerDisplay.Common.Drivers.DevMode; + +namespace PowerDisplay.Common.Services +{ + /// <summary> + /// Service for controlling display rotation/orientation. + /// Uses ChangeDisplaySettingsEx API to change display orientation. + /// </summary> + public class DisplayRotationService + { + /// <summary> + /// Set display rotation for a specific monitor. + /// Uses GdiDeviceName from the Monitor object for accurate adapter targeting. + /// </summary> + /// <param name="monitor">Monitor object with GdiDeviceName</param> + /// <param name="newOrientation">New orientation: 0=normal, 1=90°, 2=180°, 3=270°</param> + /// <returns>Operation result</returns> + public MonitorOperationResult SetRotation(Monitor monitor, int newOrientation) + { + ArgumentNullException.ThrowIfNull(monitor); + + if (newOrientation < 0 || newOrientation > 3) + { + return MonitorOperationResult.Failure($"Invalid orientation value: {newOrientation}. Must be 0-3."); + } + + if (string.IsNullOrEmpty(monitor.GdiDeviceName)) + { + return MonitorOperationResult.Failure("Monitor has no GdiDeviceName"); + } + + return SetRotationByGdiDeviceName(monitor.GdiDeviceName, newOrientation); + } + + /// <summary> + /// Set display rotation by GDI device name. + /// </summary> + /// <param name="gdiDeviceName">GDI device name (e.g., "\\.\DISPLAY1")</param> + /// <param name="newOrientation">New orientation: 0=normal, 1=90°, 2=180°, 3=270°</param> + /// <returns>Operation result</returns> + public unsafe MonitorOperationResult SetRotationByGdiDeviceName(string gdiDeviceName, int newOrientation) + { + if (string.IsNullOrEmpty(gdiDeviceName)) + { + return MonitorOperationResult.Failure("GDI device name is required"); + } + + try + { + // 1. Get current display settings + DevMode devMode = default; + devMode.DmSize = (short)sizeof(DevMode); + + if (!EnumDisplaySettings(gdiDeviceName, EnumCurrentSettings, &devMode)) + { + var error = GetLastError(); + Logger.LogError($"SetRotation: EnumDisplaySettings failed for {gdiDeviceName}, error: {error}"); + return MonitorOperationResult.Failure($"Failed to get current display settings for {gdiDeviceName}", (int)error); + } + + int currentOrientation = devMode.DmDisplayOrientation; + + // If already at target orientation, return success + if (currentOrientation == newOrientation) + { + return MonitorOperationResult.Success(); + } + + // 2. Determine if we need to swap width and height + // When switching between landscape (0°/180°) and portrait (90°/270°), swap dimensions + bool currentIsLandscape = currentOrientation == DmdoDefault || currentOrientation == Dmdo180; + bool newIsLandscape = newOrientation == DmdoDefault || newOrientation == Dmdo180; + + if (currentIsLandscape != newIsLandscape) + { + // Swap width and height + int temp = devMode.DmPelsWidth; + devMode.DmPelsWidth = devMode.DmPelsHeight; + devMode.DmPelsHeight = temp; + } + + // 3. Set new orientation + devMode.DmDisplayOrientation = newOrientation; + devMode.DmFields = DmDisplayOrientation | DmPelsWidth | DmPelsHeight; + + // 4. Test the settings first using CDS_TEST flag + int testResult = ChangeDisplaySettingsEx(gdiDeviceName, &devMode, IntPtr.Zero, CdsTest, IntPtr.Zero); + if (testResult != DispChangeSuccessful) + { + string errorMsg = GetChangeDisplaySettingsErrorMessage(testResult); + Logger.LogError($"SetRotation: Test failed for {gdiDeviceName}: {errorMsg}"); + return MonitorOperationResult.Failure($"Display settings test failed: {errorMsg}", testResult); + } + + // 5. Apply the settings (without CDS_UPDATEREGISTRY to make it temporary) + int result = ChangeDisplaySettingsEx(gdiDeviceName, &devMode, IntPtr.Zero, 0, IntPtr.Zero); + if (result != DispChangeSuccessful) + { + string errorMsg = GetChangeDisplaySettingsErrorMessage(result); + Logger.LogError($"SetRotation: Apply failed for {gdiDeviceName}: {errorMsg}"); + return MonitorOperationResult.Failure($"Failed to apply display settings: {errorMsg}", result); + } + + return MonitorOperationResult.Success(); + } + catch (Exception ex) + { + Logger.LogError($"SetRotation: Exception for {gdiDeviceName}: {ex.Message}"); + return MonitorOperationResult.Failure($"Exception while setting rotation: {ex.Message}"); + } + } + + /// <summary> + /// Get current orientation for a GDI device name. + /// </summary> + /// <param name="gdiDeviceName">GDI device name (e.g., "\\.\DISPLAY1")</param> + /// <returns>Current orientation (0-3), or -1 if query failed</returns> + public unsafe int GetCurrentOrientation(string gdiDeviceName) + { + if (string.IsNullOrEmpty(gdiDeviceName)) + { + return -1; + } + + try + { + DevMode devMode = default; + devMode.DmSize = (short)sizeof(DevMode); + + if (!EnumDisplaySettings(gdiDeviceName, EnumCurrentSettings, &devMode)) + { + return -1; + } + + return devMode.DmDisplayOrientation; + } + catch + { + return -1; + } + } + + /// <summary> + /// Get human-readable error message for ChangeDisplaySettings result code. + /// </summary> + private static string GetChangeDisplaySettingsErrorMessage(int resultCode) + { + return resultCode switch + { + DispChangeSuccessful => "Success", + DispChangeRestart => "Computer must be restarted", + DispChangeFailed => "Display driver failed the specified graphics mode", + DispChangeBadmode => "Graphics mode is not supported", + DispChangeNotupdated => "Unable to write settings to registry", + DispChangeBadflags => "Invalid flags", + DispChangeBadparam => "Invalid parameter", + _ => $"Unknown error code: {resultCode}", + }; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Services/MonitorStateManager.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Services/MonitorStateManager.cs new file mode 100644 index 0000000000..807102cffb --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Services/MonitorStateManager.cs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using ManagedCommon; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Serialization; +using PowerDisplay.Common.Utils; + +namespace PowerDisplay.Common.Services +{ + /// <summary> + /// Manages monitor parameter state in a separate file from main settings. + /// This avoids FileSystemWatcher feedback loops by separating read-only config (settings.json) + /// from frequently updated state (monitor_state.json). + /// Simplified to use direct save strategy for reliability and simplicity (KISS principle). + /// </summary> + public partial class MonitorStateManager : IDisposable + { + private readonly string _stateFilePath; + private readonly ConcurrentDictionary<string, MonitorState> _states = new(); + private readonly SimpleDebouncer _saveDebouncer; + + private bool _disposed; + private bool _isDirty; // Track pending changes for flush on dispose + private const int SaveDebounceMs = 2000; // Save 2 seconds after last update + + /// <summary> + /// Monitor state data (internal tracking, not serialized) + /// </summary> + private sealed class MonitorState + { + public int Brightness { get; set; } + + public int ColorTemperatureVcp { get; set; } + + public int Contrast { get; set; } + + public int Volume { get; set; } + + public string? CapabilitiesRaw { get; set; } + } + + /// <summary> + /// Initializes a new instance of the <see cref="MonitorStateManager"/> class. + /// Uses PathConstants for consistent path management. + /// </summary> + public MonitorStateManager() + { + // Use PathConstants for consistent path management + PathConstants.EnsurePowerDisplayFolderExists(); + _stateFilePath = PathConstants.MonitorStateFilePath; + + // Initialize debouncer for batching rapid updates (e.g., slider drag) + _saveDebouncer = new SimpleDebouncer(SaveDebounceMs); + + // Load existing state if available + LoadStateFromDisk(); + } + + /// <summary> + /// Update monitor parameter and schedule debounced save to disk. + /// Uses Monitor.Id as the stable key (e.g., "DDC_GSM5C6D_1", "WMI_BOE0900_2"). + /// Debounced-save strategy reduces disk I/O by batching rapid updates (e.g., during slider drag). + /// </summary> + /// <param name="monitorId">The monitor's unique Id (e.g., "DDC_GSM5C6D_1").</param> + /// <param name="property">The property name to update (Brightness, ColorTemperature, Contrast, or Volume).</param> + /// <param name="value">The new value.</param> + public void UpdateMonitorParameter(string monitorId, string property, int value) + { + try + { + if (string.IsNullOrEmpty(monitorId)) + { + Logger.LogWarning($"Cannot update monitor parameter: monitorId is empty"); + return; + } + + var state = _states.GetOrAdd(monitorId, _ => new MonitorState()); + + // Update the specific property + bool shouldSave = true; + switch (property) + { + case "Brightness": + state.Brightness = value; + break; + case "ColorTemperature": + state.ColorTemperatureVcp = value; + break; + case "Contrast": + state.Contrast = value; + break; + case "Volume": + state.Volume = value; + break; + default: + Logger.LogWarning($"Unknown property: {property}"); + shouldSave = false; + break; + } + + if (shouldSave) + { + // Mark dirty for flush on dispose + _isDirty = true; + } + + // Schedule debounced save (SimpleDebouncer handles cancellation of previous calls) + if (shouldSave) + { + _saveDebouncer.Debounce(SaveStateToDiskAsync); + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to update monitor parameter: {ex.Message}"); + } + } + + /// <summary> + /// Get saved parameters for a monitor using Monitor.Id. + /// </summary> + /// <param name="monitorId">The monitor's unique Id (e.g., "DDC_GSM5C6D_1").</param> + /// <returns>A tuple of (Brightness, ColorTemperatureVcp, Contrast, Volume) or null if not found.</returns> + public (int Brightness, int ColorTemperatureVcp, int Contrast, int Volume)? GetMonitorParameters(string monitorId) + { + if (string.IsNullOrEmpty(monitorId)) + { + return null; + } + + if (_states.TryGetValue(monitorId, out var state)) + { + return (state.Brightness, state.ColorTemperatureVcp, state.Contrast, state.Volume); + } + + return null; + } + + /// <summary> + /// Load state from disk. + /// </summary> + private void LoadStateFromDisk() + { + try + { + if (!File.Exists(_stateFilePath)) + { + return; + } + + var json = File.ReadAllText(_stateFilePath); + var stateFile = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.MonitorStateFile); + + if (stateFile?.Monitors != null) + { + foreach (var kvp in stateFile.Monitors) + { + var monitorKey = kvp.Key; // Should be MonitorId (e.g., "GSM5C6D") + var entry = kvp.Value; + + _states[monitorKey] = new MonitorState + { + Brightness = entry.Brightness, + ColorTemperatureVcp = entry.ColorTemperatureVcp, + Contrast = entry.Contrast, + Volume = entry.Volume, + CapabilitiesRaw = entry.CapabilitiesRaw, + }; + } + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to load monitor state: {ex.Message}"); + } + } + + /// <summary> + /// Save current state to disk immediately (async). + /// Called by timer after debounce period. + /// </summary> + private async Task SaveStateToDiskAsync() + { + try + { + if (_disposed) + { + return; + } + + var json = BuildStateJson(); + + // Write to disk asynchronously + await File.WriteAllTextAsync(_stateFilePath, json); + + // Clear dirty flag after successful save + _isDirty = false; + } + catch (Exception ex) + { + Logger.LogError($"Failed to save monitor state: {ex.Message}"); + } + } + + /// <summary> + /// Save current state to disk synchronously. + /// Called during Dispose to flush pending changes without risk of deadlock. + /// </summary> + private void SaveStateToDiskSync() + { + try + { + var json = BuildStateJson(); + + // Write to disk synchronously - safe for Dispose + File.WriteAllText(_stateFilePath, json); + } + catch (Exception ex) + { + Logger.LogError($"Failed to save monitor state (sync): {ex.Message}"); + } + } + + /// <summary> + /// Build the JSON string for state file. + /// Shared logic between async and sync save methods. + /// </summary> + /// <returns>JSON string for state file</returns> + private string BuildStateJson() + { + var now = DateTime.Now; + var stateFile = new MonitorStateFile + { + LastUpdated = now, + }; + + foreach (var kvp in _states) + { + var monitorId = kvp.Key; + var state = kvp.Value; + + stateFile.Monitors[monitorId] = new MonitorStateEntry + { + Brightness = state.Brightness, + ColorTemperatureVcp = state.ColorTemperatureVcp, + Contrast = state.Contrast, + Volume = state.Volume, + CapabilitiesRaw = state.CapabilitiesRaw, + LastUpdated = now, + }; + } + + return JsonSerializer.Serialize(stateFile, ProfileSerializationContext.Default.MonitorStateFile); + } + + /// <summary> + /// Disposes the MonitorStateManager, flushing any pending state changes. + /// </summary> + public void Dispose() + { + if (_disposed) + { + return; + } + + bool wasDirty = _isDirty; + _disposed = true; + _isDirty = false; + + // Dispose debouncer first to cancel any pending saves + _saveDebouncer?.Dispose(); + + // Flush any pending changes before disposing using sync method to avoid deadlock + if (wasDirty) + { + SaveStateToDiskSync(); + } + + GC.SuppressFinalize(this); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Services/ProfileService.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Services/ProfileService.cs new file mode 100644 index 0000000000..094cb554fb --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Services/ProfileService.cs @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Text.Json; +using ManagedCommon; +using PowerDisplay.Common.Interfaces; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Serialization; + +namespace PowerDisplay.Common.Services +{ + /// <summary> + /// Centralized service for managing PowerDisplay profiles storage and retrieval. + /// Provides unified access to profile data for PowerDisplay, Settings UI, and LightSwitch modules. + /// Thread-safe and AOT-compatible. + /// </summary> + public class ProfileService : IProfileService + { + private const string LogPrefix = "[ProfileService]"; + private static readonly object _lock = new object(); + + /// <summary> + /// Gets the singleton instance of the ProfileService. + /// Use this for dependency injection or when interface-based access is needed. + /// </summary> + public static IProfileService Instance { get; } = new ProfileService(); + + /// <summary> + /// Initializes a new instance of the <see cref="ProfileService"/> class. + /// Private constructor to enforce singleton pattern for instance-based access. + /// Static methods remain available for backward compatibility. + /// </summary> + private ProfileService() + { + } + + /// <summary> + /// Loads PowerDisplay profiles from disk. + /// Thread-safe operation with automatic legacy profile cleanup. + /// </summary> + /// <returns>PowerDisplayProfiles object, or a new empty instance if file doesn't exist or load fails</returns> + public static PowerDisplayProfiles LoadProfiles() + { + lock (_lock) + { + var (profiles, _) = LoadProfilesInternal(); + return profiles; + } + } + + /// <summary> + /// Saves PowerDisplay profiles to disk. + /// Thread-safe operation with automatic timestamp update and legacy profile cleanup. + /// </summary> + /// <param name="profiles">The profiles collection to save</param> + /// <returns>True if save was successful, false otherwise</returns> + public static bool SaveProfiles(PowerDisplayProfiles profiles) + { + lock (_lock) + { + if (profiles == null) + { + Logger.LogWarning($"{LogPrefix} Cannot save null profiles"); + return false; + } + + var (success, _) = SaveProfilesInternal(profiles); + return success; + } + } + + /// <summary> + /// Adds or updates a profile in the collection and persists to disk. + /// Thread-safe operation. + /// </summary> + /// <param name="profile">The profile to add or update</param> + /// <returns>True if operation was successful, false otherwise</returns> + public static bool AddOrUpdateProfile(PowerDisplayProfile profile) + { + lock (_lock) + { + if (profile == null || !profile.IsValid()) + { + Logger.LogWarning($"{LogPrefix} Cannot add invalid profile"); + return false; + } + + var (profiles, _) = LoadProfilesInternal(); + profiles.SetProfile(profile); + + var (success, _) = SaveProfilesInternal(profiles); + return success; + } + } + + /// <summary> + /// Removes a profile by name and persists to disk. + /// Thread-safe operation. + /// </summary> + /// <param name="profileName">The name of the profile to remove</param> + /// <returns>True if profile was found and removed, false otherwise</returns> + public static bool RemoveProfile(string profileName) + { + lock (_lock) + { + var (profiles, _) = LoadProfilesInternal(); + bool removed = profiles.RemoveProfile(profileName); + + if (removed) + { + SaveProfilesInternal(profiles); + } + + return removed; + } + } + + /// <summary> + /// Gets a profile by name. + /// Thread-safe operation. + /// </summary> + /// <param name="profileName">The name of the profile to retrieve</param> + /// <returns>The profile if found, null otherwise</returns> + public static PowerDisplayProfile? GetProfile(string profileName) + { + lock (_lock) + { + var (profiles, _) = LoadProfilesInternal(); + return profiles.GetProfile(profileName); + } + } + + /// <summary> + /// Checks if the profiles file exists. + /// </summary> + /// <returns>True if profiles file exists, false otherwise</returns> + public static bool ProfilesFileExists() + { + try + { + return File.Exists(PathConstants.ProfilesFilePath); + } + catch (Exception ex) + { + Logger.LogError($"{LogPrefix} Error checking if profiles file exists: {ex.Message}"); + return false; + } + } + + /// <summary> + /// Gets the path to the profiles file. + /// </summary> + /// <returns>The full path to the profiles file</returns> + public static string GetProfilesFilePath() + { + return PathConstants.ProfilesFilePath; + } + + // Internal methods without lock for use within already-locked contexts + // Returns tuple with result and optional log message + private static (PowerDisplayProfiles Profiles, string? Message) LoadProfilesInternal() + { + try + { + var filePath = PathConstants.ProfilesFilePath; + + PathConstants.EnsurePowerDisplayFolderExists(); + + if (File.Exists(filePath)) + { + var json = File.ReadAllText(filePath); + var profiles = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.PowerDisplayProfiles); + + if (profiles != null) + { + profiles.Profiles.RemoveAll(p => p.Name.Equals(PowerDisplayProfiles.CustomProfileName, StringComparison.OrdinalIgnoreCase)); + return (profiles, $"Loaded {profiles.Profiles.Count} profiles from {filePath}"); + } + } + else + { + return (new PowerDisplayProfiles(), $"No profiles file found at {filePath}, returning empty collection"); + } + + return (new PowerDisplayProfiles(), null); + } + catch (Exception ex) + { + Logger.LogError($"{LogPrefix} Failed to load profiles: {ex.Message}"); + return (new PowerDisplayProfiles(), null); + } + } + + // Returns tuple with success status and optional log message + private static (bool Success, string? Message) SaveProfilesInternal(PowerDisplayProfiles profiles) + { + try + { + if (profiles == null) + { + return (false, null); + } + + PathConstants.EnsurePowerDisplayFolderExists(); + + profiles.Profiles.RemoveAll(p => p.Name.Equals(PowerDisplayProfiles.CustomProfileName, StringComparison.OrdinalIgnoreCase)); + profiles.LastUpdated = DateTime.UtcNow; + + var json = JsonSerializer.Serialize(profiles, ProfileSerializationContext.Default.PowerDisplayProfiles); + var filePath = PathConstants.ProfilesFilePath; + File.WriteAllText(filePath, json); + + return (true, $"Saved {profiles.Profiles.Count} profiles to {filePath}"); + } + catch (Exception ex) + { + Logger.LogError($"{LogPrefix} Failed to save profiles: {ex.Message}"); + return (false, null); + } + } + + // IProfileService Implementation + // Explicit interface implementation to satisfy IProfileService + // These methods delegate to the static methods for backward compatibility + + /// <inheritdoc/> + PowerDisplayProfiles IProfileService.LoadProfiles() => LoadProfiles(); + + /// <inheritdoc/> + bool IProfileService.SaveProfiles(PowerDisplayProfiles profiles) => SaveProfiles(profiles); + + /// <inheritdoc/> + bool IProfileService.AddOrUpdateProfile(PowerDisplayProfile profile) => AddOrUpdateProfile(profile); + + /// <inheritdoc/> + bool IProfileService.RemoveProfile(string profileName) => RemoveProfile(profileName); + + /// <inheritdoc/> + PowerDisplayProfile? IProfileService.GetProfile(string profileName) => GetProfile(profileName); + + /// <inheritdoc/> + bool IProfileService.ProfilesFileExists() => ProfilesFileExists(); + + /// <inheritdoc/> + string IProfileService.GetProfilesFilePath() => GetProfilesFilePath(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ColorTemperatureHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ColorTemperatureHelper.cs new file mode 100644 index 0000000000..43dc8c044f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ColorTemperatureHelper.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Linq; +using PowerDisplay.Common.Drivers; +using PowerDisplay.Common.Models; + +namespace PowerDisplay.Common.Utils +{ + /// <summary> + /// Helper class for color temperature preset computation. + /// Provides shared logic for computing available color presets from VCP capabilities. + /// </summary> + public static class ColorTemperatureHelper + { + /// <summary> + /// Computes available color temperature presets from VCP value data. + /// </summary> + /// <param name="colorTemperatureValues"> + /// Collection of tuples containing (VcpValue, Name) for each color temperature preset. + /// The VcpValue is the VCP value, Name is the name from capabilities string if available. + /// </param> + /// <returns>Sorted list of ColorPresetItem objects.</returns> + public static List<ColorPresetItem> ComputeColorPresets(IEnumerable<(int VcpValue, string? Name)> colorTemperatureValues) + { + if (colorTemperatureValues == null) + { + return new List<ColorPresetItem>(); + } + + var presetList = new List<ColorPresetItem>(); + + foreach (var item in colorTemperatureValues) + { + var displayName = FormatColorTemperatureDisplayName(item.VcpValue, item.Name); + presetList.Add(new ColorPresetItem(item.VcpValue, displayName)); + } + + // Sort by VCP value for consistent ordering + return presetList.OrderBy(p => p.VcpValue).ToList(); + } + + /// <summary> + /// Formats a color temperature display name. + /// Uses VcpNames for standard VCP value mappings if no custom name is provided. + /// </summary> + /// <param name="vcpValue">The VCP value.</param> + /// <param name="customName">Optional custom name from capabilities string.</param> + /// <returns>Formatted display name.</returns> + public static string FormatColorTemperatureDisplayName(int vcpValue, string? customName = null) + { + // Priority: use name from VCP capabilities if available + if (!string.IsNullOrEmpty(customName)) + { + return customName; + } + + // Fall back to standard VCP value name from shared library + return VcpNames.GetValueName(NativeConstants.VcpCodeSelectColorPreset, vcpValue) + ?? "Manufacturer Defined"; + } + + /// <summary> + /// Formats a display name for a custom (non-preset) color temperature value. + /// Used when the current value is not in the available preset list. + /// </summary> + /// <param name="vcpValue">The VCP value.</param> + /// <returns>Formatted display name with "Custom" indicator.</returns> + public static string FormatCustomColorTemperatureDisplayName(int vcpValue) + { + var standardName = VcpNames.GetValueName(NativeConstants.VcpCodeSelectColorPreset, vcpValue); + return string.IsNullOrEmpty(standardName) + ? "Custom" + : $"{standardName} (Custom)"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/EventHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/EventHelper.cs new file mode 100644 index 0000000000..0b3b82b8f7 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/EventHelper.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using ManagedCommon; + +namespace PowerDisplay.Common.Utils +{ + /// <summary> + /// Helper class for Windows named event operations. + /// Provides unified event signaling with consistent error handling and logging. + /// </summary> + public static class EventHelper + { + /// <summary> + /// Signals a named event. Creates the event if it doesn't exist. + /// </summary> + /// <param name="eventName">The name of the event to signal.</param> + /// <returns>True if the event was signaled successfully, false otherwise.</returns> + public static bool SignalEvent(string eventName) + { + if (string.IsNullOrEmpty(eventName)) + { + Logger.LogWarning("[EventHelper] SignalEvent called with null or empty event name"); + return false; + } + + try + { + using var eventHandle = new EventWaitHandle( + false, + EventResetMode.AutoReset, + eventName); + eventHandle.Set(); + return true; + } + catch (Exception ex) + { + Logger.LogError($"[EventHelper] Failed to signal event '{eventName}': {ex.Message}"); + return false; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/MccsCapabilitiesParser.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/MccsCapabilitiesParser.cs new file mode 100644 index 0000000000..201ead3965 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/MccsCapabilitiesParser.cs @@ -0,0 +1,860 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using ManagedCommon; +using PowerDisplay.Common.Models; + +namespace PowerDisplay.Common.Utils +{ + /// <summary> + /// Recursive descent parser for DDC/CI MCCS capabilities strings. + /// + /// MCCS Capabilities String Grammar (BNF): + /// <code> + /// capabilities ::= '(' segment* ')' + /// segment ::= identifier '(' segment_content ')' + /// segment_content ::= text | vcp_entries | hex_list + /// vcp_entries ::= vcp_entry* + /// vcp_entry ::= hex_byte [ '(' hex_list ')' ] + /// hex_list ::= hex_byte* + /// hex_byte ::= [0-9A-Fa-f]{2} + /// identifier ::= [a-z_]+ + /// text ::= [^()]+ + /// </code> + /// + /// Example input: + /// (prot(monitor)type(lcd)model(PD3220U)cmds(01 02 03)vcp(10 12 14(04 05) 60(11 12))mccs_ver(2.2)) + /// </summary> + public ref struct MccsCapabilitiesParser + { + private readonly List<ParseError> _errors; + private ReadOnlySpan<char> _input; + private int _position; + + /// <summary> + /// Parse a capabilities string into structured VcpCapabilities. + /// </summary> + /// <param name="capabilitiesString">Raw MCCS capabilities string</param> + /// <returns>Parsed capabilities object with any parse errors</returns> + public static MccsParseResult Parse(string? capabilitiesString) + { + if (string.IsNullOrWhiteSpace(capabilitiesString)) + { + return new MccsParseResult(VcpCapabilities.Empty, new List<ParseError>()); + } + + var parser = new MccsCapabilitiesParser(capabilitiesString); + return parser.ParseCapabilities(); + } + + private MccsCapabilitiesParser(string input) + { + _input = input.AsSpan(); + _position = 0; + _errors = new List<ParseError>(); + } + + /// <summary> + /// Main entry point: parse the entire capabilities string. + /// capabilities ::= '(' segment* ')' | segment* + /// </summary> + private MccsParseResult ParseCapabilities() + { + var capabilities = new VcpCapabilities + { + Raw = _input.ToString(), + }; + + SkipWhitespace(); + + // Handle optional outer parentheses (some monitors omit them) + bool hasOuterParens = Peek() == '('; + if (hasOuterParens) + { + Advance(); // consume '(' + } + + // Parse segments until end or closing paren + while (!IsAtEnd()) + { + SkipWhitespace(); + + if (IsAtEnd()) + { + break; + } + + if (Peek() == ')') + { + if (hasOuterParens) + { + Advance(); // consume closing ')' + } + + break; + } + + // Parse a segment: identifier(content) + var segment = ParseSegment(); + if (segment.HasValue) + { + ApplySegment(capabilities, segment.Value); + } + } + + return new MccsParseResult(capabilities, _errors); + } + + /// <summary> + /// Parse a single segment: identifier '(' content ')' + /// </summary> + private ParsedSegment? ParseSegment() + { + SkipWhitespace(); + + int startPos = _position; + + // Parse identifier + var identifier = ParseIdentifier(); + if (identifier.IsEmpty) + { + // Not a valid segment start - skip this character and continue + if (!IsAtEnd()) + { + Advance(); + } + + return null; + } + + SkipWhitespace(); + + // Expect '(' + if (Peek() != '(') + { + AddError($"Expected '(' after identifier '{identifier.ToString()}' at position {_position}"); + return null; + } + + Advance(); // consume '(' + + // Parse content until matching ')' + var content = ParseBalancedContent(); + + // Expect ')' + if (Peek() != ')') + { + AddError($"Expected ')' to close segment '{identifier.ToString()}' at position {_position}"); + } + else + { + Advance(); // consume ')' + } + + return new ParsedSegment(identifier.ToString(), content); + } + + /// <summary> + /// Parse content between balanced parentheses. + /// Handles nested parentheses correctly. + /// </summary> + private string ParseBalancedContent() + { + int start = _position; + int depth = 1; + + while (!IsAtEnd() && depth > 0) + { + char c = Peek(); + if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + if (depth == 0) + { + break; // Don't consume the closing paren + } + } + + Advance(); + } + + return _input.Slice(start, _position - start).ToString(); + } + + /// <summary> + /// Parse an identifier (letters, digits, and underscores). + /// identifier ::= [a-zA-Z0-9_]+ + /// Note: MCCS uses identifiers like window1, window2, etc. + /// </summary> + private ReadOnlySpan<char> ParseIdentifier() + { + int start = _position; + + while (!IsAtEnd() && IsIdentifierChar(Peek())) + { + Advance(); + } + + return _input.Slice(start, _position - start); + } + + /// <summary> + /// Apply a parsed segment to the capabilities object. + /// </summary> + private void ApplySegment(VcpCapabilities capabilities, ParsedSegment segment) + { + switch (segment.Name.ToLowerInvariant()) + { + case "prot": + capabilities.Protocol = segment.Content.Trim(); + break; + + case "type": + capabilities.Type = segment.Content.Trim(); + break; + + case "model": + capabilities.Model = segment.Content.Trim(); + break; + + case "mccs_ver": + capabilities.MccsVersion = segment.Content.Trim(); + break; + + case "cmds": + capabilities.SupportedCommands = ParseHexList(segment.Content); + break; + + case "vcp": + capabilities.SupportedVcpCodes = ParseVcpEntries(segment.Content); + break; + + case "vcpname": + ParseVcpNames(segment.Content, capabilities); + break; + + default: + // Check for windowN pattern (window1, window2, etc.) + if (segment.Name.Length > 6 && + segment.Name.StartsWith("window", StringComparison.OrdinalIgnoreCase) && + int.TryParse(segment.Name.AsSpan(6), out int windowNum)) + { + var windowParser = new WindowParser(segment.Content); + var windowCap = windowParser.Parse(windowNum); + capabilities.Windows.Add(windowCap); + } + else + { + // Unknown segments are silently ignored + } + + break; + } + } + + /// <summary> + /// Parse VCP entries: vcp_entry* + /// vcp_entry ::= hex_byte [ '(' hex_list ')' ] + /// </summary> + private Dictionary<byte, VcpCodeInfo> ParseVcpEntries(string content) + { + var vcpCodes = new Dictionary<byte, VcpCodeInfo>(); + var parser = new VcpEntryParser(content); + + while (parser.TryParseEntry(out var entry)) + { + var name = VcpNames.GetCodeName(entry.Code); + vcpCodes[entry.Code] = new VcpCodeInfo(entry.Code, name, entry.Values); + } + + return vcpCodes; + } + + /// <summary> + /// Parse a hex byte list: hex_byte* + /// Handles both space-separated (01 02 03) and concatenated (010203) formats. + /// </summary> + private static List<byte> ParseHexList(string content) + { + var result = new List<byte>(); + var span = content.AsSpan(); + int i = 0; + + while (i < span.Length) + { + // Skip whitespace + while (i < span.Length && char.IsWhiteSpace(span[i])) + { + i++; + } + + if (i >= span.Length) + { + break; + } + + // Try to read two hex digits + if (i + 1 < span.Length && IsHexDigit(span[i]) && IsHexDigit(span[i + 1])) + { + if (byte.TryParse(span.Slice(i, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)) + { + result.Add(value); + } + + i += 2; + } + else + { + i++; // Skip invalid character + } + } + + return result; + } + + /// <summary> + /// Parse vcpname entries: hex_byte '(' name ')' + /// </summary> + private void ParseVcpNames(string content, VcpCapabilities capabilities) + { + // vcpname format: F0(Custom Name 1) F1(Custom Name 2) + var parser = new VcpNameParser(content); + + while (parser.TryParseEntry(out var code, out var name)) + { + if (capabilities.SupportedVcpCodes.TryGetValue(code, out var existingInfo)) + { + // Update existing entry with custom name + capabilities.SupportedVcpCodes[code] = new VcpCodeInfo(code, name, existingInfo.SupportedValues); + } + else + { + // Add new entry with custom name + capabilities.SupportedVcpCodes[code] = new VcpCodeInfo(code, name, Array.Empty<int>()); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private char Peek() => IsAtEnd() ? '\0' : _input[_position]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Advance() => _position++; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsAtEnd() => _position >= _input.Length; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SkipWhitespace() + { + while (!IsAtEnd() && char.IsWhiteSpace(Peek())) + { + Advance(); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsIdentifierChar(char c) => + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHexDigit(char c) => + (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + + private void AddError(string message) + { + _errors.Add(new ParseError(_position, message)); + Logger.LogWarning($"[MccsParser] {message}"); + } + } + + /// <summary> + /// Sub-parser for VCP entries within the vcp() segment. + /// </summary> + internal ref struct VcpEntryParser + { + private ReadOnlySpan<char> _content; + private int _position; + + public VcpEntryParser(string content) + { + _content = content.AsSpan(); + _position = 0; + } + + /// <summary> + /// Try to parse the next VCP entry. + /// vcp_entry ::= hex_byte [ '(' hex_list ')' ] + /// </summary> + public bool TryParseEntry(out VcpEntry entry) + { + entry = default; + SkipWhitespace(); + + if (IsAtEnd()) + { + return false; + } + + // Parse hex byte (VCP code) + if (!TryParseHexByte(out var code)) + { + // Skip invalid character and try again + _position++; + return TryParseEntry(out entry); + } + + var values = new List<int>(); + + SkipWhitespace(); + + // Check for optional value list + if (!IsAtEnd() && Peek() == '(') + { + _position++; // consume '(' + + // Parse values until ')' + while (!IsAtEnd() && Peek() != ')') + { + SkipWhitespace(); + + if (Peek() == ')') + { + break; + } + + if (TryParseHexByte(out var value)) + { + values.Add(value); + } + else + { + _position++; // Skip invalid character + } + } + + if (!IsAtEnd() && Peek() == ')') + { + _position++; // consume ')' + } + } + + entry = new VcpEntry(code, values); + return true; + } + + private bool TryParseHexByte(out byte value) + { + value = 0; + + if (_position + 1 >= _content.Length) + { + return false; + } + + if (!IsHexDigit(_content[_position]) || !IsHexDigit(_content[_position + 1])) + { + return false; + } + + if (byte.TryParse(_content.Slice(_position, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value)) + { + _position += 2; + return true; + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private char Peek() => IsAtEnd() ? '\0' : _content[_position]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsAtEnd() => _position >= _content.Length; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SkipWhitespace() + { + while (!IsAtEnd() && char.IsWhiteSpace(Peek())) + { + _position++; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHexDigit(char c) => + (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + } + + /// <summary> + /// Sub-parser for vcpname entries. + /// </summary> + internal ref struct VcpNameParser + { + private ReadOnlySpan<char> _content; + private int _position; + + public VcpNameParser(string content) + { + _content = content.AsSpan(); + _position = 0; + } + + /// <summary> + /// Try to parse the next vcpname entry. + /// vcpname_entry ::= hex_byte '(' name ')' + /// </summary> + public bool TryParseEntry(out byte code, out string name) + { + code = 0; + name = string.Empty; + + SkipWhitespace(); + + if (IsAtEnd()) + { + return false; + } + + // Parse hex byte + if (!TryParseHexByte(out code)) + { + _position++; + return TryParseEntry(out code, out name); + } + + SkipWhitespace(); + + // Expect '(' + if (IsAtEnd() || Peek() != '(') + { + return false; + } + + _position++; // consume '(' + + // Parse name until ')' + int start = _position; + while (!IsAtEnd() && Peek() != ')') + { + _position++; + } + + name = _content.Slice(start, _position - start).ToString().Trim(); + + if (!IsAtEnd() && Peek() == ')') + { + _position++; // consume ')' + } + + return true; + } + + private bool TryParseHexByte(out byte value) + { + value = 0; + + if (_position + 1 >= _content.Length) + { + return false; + } + + if (!IsHexDigit(_content[_position]) || !IsHexDigit(_content[_position + 1])) + { + return false; + } + + if (byte.TryParse(_content.Slice(_position, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value)) + { + _position += 2; + return true; + } + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private char Peek() => IsAtEnd() ? '\0' : _content[_position]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsAtEnd() => _position >= _content.Length; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SkipWhitespace() + { + while (!IsAtEnd() && char.IsWhiteSpace(Peek())) + { + _position++; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsHexDigit(char c) => + (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); + } + + /// <summary> + /// Sub-parser for window segment content. + /// Parses: type(PIP) area(25 25 1895 1175) max(640 480) min(10 10) window(10) + /// </summary> + internal ref struct WindowParser + { + private ReadOnlySpan<char> _content; + private int _position; + + public WindowParser(string content) + { + _content = content.AsSpan(); + _position = 0; + } + + /// <summary> + /// Parse window segment content into a WindowCapability. + /// </summary> + public WindowCapability Parse(int windowNumber) + { + string type = string.Empty; + var area = default(WindowArea); + var maxSize = default(WindowSize); + var minSize = default(WindowSize); + int windowId = 0; + + // Parse sub-segments: type(...) area(...) max(...) min(...) window(...) + while (!IsAtEnd()) + { + SkipWhitespace(); + if (IsAtEnd()) + { + break; + } + + var subSegment = ParseSubSegment(); + if (subSegment.HasValue) + { + switch (subSegment.Value.Name.ToLowerInvariant()) + { + case "type": + type = subSegment.Value.Content.Trim(); + break; + case "area": + area = ParseArea(subSegment.Value.Content); + break; + case "max": + maxSize = ParseSize(subSegment.Value.Content); + break; + case "min": + minSize = ParseSize(subSegment.Value.Content); + break; + case "window": + _ = int.TryParse(subSegment.Value.Content.Trim(), out windowId); + break; + } + } + } + + return new WindowCapability(windowNumber, type, area, maxSize, minSize, windowId); + } + + private (string Name, string Content)? ParseSubSegment() + { + int start = _position; + + // Parse identifier + while (!IsAtEnd() && IsIdentifierChar(Peek())) + { + _position++; + } + + if (_position == start) + { + // No identifier found, skip character + if (!IsAtEnd()) + { + _position++; + } + + return null; + } + + var name = _content.Slice(start, _position - start).ToString(); + + SkipWhitespace(); + + // Expect '(' + if (IsAtEnd() || Peek() != '(') + { + return null; + } + + _position++; // consume '(' + + // Parse content with balanced parentheses + int contentStart = _position; + int depth = 1; + + while (!IsAtEnd() && depth > 0) + { + char c = Peek(); + if (c == '(') + { + depth++; + } + else if (c == ')') + { + depth--; + if (depth == 0) + { + break; + } + } + + _position++; + } + + var content = _content.Slice(contentStart, _position - contentStart).ToString(); + + if (!IsAtEnd() && Peek() == ')') + { + _position++; // consume ')' + } + + return (name, content); + } + + private static WindowArea ParseArea(string content) + { + var values = ParseIntList(content); + if (values.Length >= 4) + { + return new WindowArea(values[0], values[1], values[2], values[3]); + } + + return default; + } + + private static WindowSize ParseSize(string content) + { + var values = ParseIntList(content); + if (values.Length >= 2) + { + return new WindowSize(values[0], values[1]); + } + + return default; + } + + private static int[] ParseIntList(string content) + { + var parts = content.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var result = new List<int>(parts.Length); + + foreach (var part in parts) + { + if (int.TryParse(part.Trim(), out int value)) + { + result.Add(value); + } + } + + return result.ToArray(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private char Peek() => IsAtEnd() ? '\0' : _content[_position]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsAtEnd() => _position >= _content.Length; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void SkipWhitespace() + { + while (!IsAtEnd() && char.IsWhiteSpace(Peek())) + { + _position++; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsIdentifierChar(char c) => + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; + } + + /// <summary> + /// Represents a parsed segment from the capabilities string. + /// </summary> + internal readonly struct ParsedSegment + { + public string Name { get; } + + public string Content { get; } + + public ParsedSegment(string name, string content) + { + Name = name; + Content = content; + } + } + + /// <summary> + /// Represents a parsed VCP entry. + /// </summary> + internal readonly struct VcpEntry + { + public byte Code { get; } + + public IReadOnlyList<int> Values { get; } + + public VcpEntry(byte code, IReadOnlyList<int> values) + { + Code = code; + Values = values; + } + } + + /// <summary> + /// Represents a parse error with position information. + /// </summary> + public readonly struct ParseError + { + public int Position { get; } + + public string Message { get; } + + public ParseError(int position, string message) + { + Position = position; + Message = message; + } + + public override string ToString() => $"[{Position}] {Message}"; + } + + /// <summary> + /// Result of parsing MCCS capabilities string. + /// </summary> + public sealed class MccsParseResult + { + public VcpCapabilities Capabilities { get; } + + public IReadOnlyList<ParseError> Errors { get; } + + public bool HasErrors => Errors.Count > 0; + + public bool IsValid => !HasErrors && Capabilities.SupportedVcpCodes.Count > 0; + + public MccsParseResult(VcpCapabilities capabilities, IReadOnlyList<ParseError> errors) + { + Capabilities = capabilities; + Errors = errors; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/PnpIdHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/PnpIdHelper.cs new file mode 100644 index 0000000000..dd1665d0f5 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/PnpIdHelper.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Frozen; +using System.Collections.Generic; + +namespace PowerDisplay.Common.Utils; + +/// <summary> +/// Helper class for mapping PnP (Plug and Play) manufacturer IDs to display names. +/// PnP IDs are 3-character codes assigned by Microsoft to hardware manufacturers. +/// See: https://uefi.org/pnp_id_list +/// </summary> +public static class PnpIdHelper +{ + /// <summary> + /// Map of common laptop/monitor manufacturer PnP IDs to display names. + /// Only includes manufacturers known to produce laptops with internal displays. + /// </summary> + private static readonly FrozenDictionary<string, string> ManufacturerNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + // Major laptop manufacturers + { "ACR", "Acer" }, + { "AUO", "AU Optronics" }, + { "BOE", "BOE" }, + { "CMN", "Chi Mei Innolux" }, + { "DEL", "Dell" }, + { "HWP", "HP" }, + { "IVO", "InfoVision" }, + { "LEN", "Lenovo" }, + { "LGD", "LG Display" }, + { "NCP", "Nanjing CEC Panda" }, + { "SAM", "Samsung" }, + { "SDC", "Samsung Display" }, + { "SEC", "Samsung Electronics" }, + { "SHP", "Sharp" }, + { "AUS", "ASUS" }, + { "MSI", "MSI" }, + { "APP", "Apple" }, + { "SNY", "Sony" }, + { "PHL", "Philips" }, + { "HSD", "HannStar" }, + { "CPT", "Chunghwa Picture Tubes" }, + { "QDS", "Quanta Display" }, + { "TMX", "Tianma Microelectronics" }, + { "CSO", "CSOT" }, + + // Microsoft Surface + { "MSF", "Microsoft" }, + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// Extract the 3-character PnP manufacturer ID from an EDID ID. + /// </summary> + /// <param name="edidId">EDID ID like "LEN4038" or "BOE0900".</param> + /// <returns>The 3-character PnP ID (e.g., "LEN"), or null if invalid.</returns> + public static string? ExtractPnpId(string? edidId) + { + if (string.IsNullOrEmpty(edidId) || edidId.Length < 3) + { + return null; + } + + // PnP ID is the first 3 characters + return edidId.Substring(0, 3).ToUpperInvariant(); + } + + /// <summary> + /// Get a user-friendly display name for an internal display based on its EDID ID. + /// </summary> + /// <param name="edidId">EDID ID like "LEN4038" or "BOE0900".</param> + /// <returns>Display name like "Lenovo Built-in Display" or "Built-in Display" as fallback.</returns> + public static string GetBuiltInDisplayName(string? edidId) + { + var pnpId = ExtractPnpId(edidId); + + if (pnpId != null && ManufacturerNames.TryGetValue(pnpId, out var manufacturer)) + { + return $"{manufacturer} Built-in Display"; + } + + return "Built-in Display"; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ProfileHelper.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ProfileHelper.cs new file mode 100644 index 0000000000..d7274824d7 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/ProfileHelper.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace PowerDisplay.Common.Utils +{ + /// <summary> + /// Helper class for profile management. + /// Provides shared logic for generating unique profile names and other profile-related operations. + /// </summary> + public static class ProfileHelper + { + /// <summary> + /// Default base name for new profiles. + /// </summary> + public const string DefaultProfileBaseName = "Profile"; + + /// <summary> + /// Maximum counter value when generating unique profile names. + /// </summary> + private const int MaxProfileCounter = 1000; + + /// <summary> + /// Generates a unique profile name that doesn't conflict with existing names. + /// Uses the format "Profile N" where N is an incrementing number. + /// </summary> + /// <param name="existingNames">Set of existing profile names to avoid conflicts.</param> + /// <param name="baseName">Optional base name to use (defaults to "Profile").</param> + /// <returns>A unique profile name.</returns> + public static string GenerateUniqueProfileName(ISet<string> existingNames, string? baseName = null) + { + if (existingNames == null) + { + existingNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + } + + var nameBase = string.IsNullOrEmpty(baseName) ? DefaultProfileBaseName : baseName; + + // Start with base name without number + if (!existingNames.Contains(nameBase)) + { + return nameBase; + } + + // Try "Profile 2", "Profile 3", etc. + int counter = 2; + while (counter < MaxProfileCounter) + { + var candidateName = string.Format(CultureInfo.InvariantCulture, "{0} {1}", nameBase, counter); + if (!existingNames.Contains(candidateName)) + { + return candidateName; + } + + counter++; + } + + // Fallback with timestamp if somehow we hit the limit + return string.Format(CultureInfo.InvariantCulture, "{0} {1}", nameBase, DateTime.Now.Ticks); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/SimpleDebouncer.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/SimpleDebouncer.cs new file mode 100644 index 0000000000..3c55a5d992 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/SimpleDebouncer.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; + +namespace PowerDisplay.Common.Utils +{ + /// <summary> + /// Simple debouncer that delays execution of an action until a quiet period. + /// Replaces the complex PropertyUpdateQueue with a much simpler approach (KISS principle). + /// </summary> + public partial class SimpleDebouncer : IDisposable + { + private readonly int _delayMs; + private readonly object _lock = new object(); + private CancellationTokenSource? _cts; + private bool _disposed; + + /// <summary> + /// Initializes a new instance of the <see cref="SimpleDebouncer"/> class. + /// Create a debouncer with specified delay + /// </summary> + /// <param name="delayMs">Delay in milliseconds before executing action</param> + public SimpleDebouncer(int delayMs = 300) + { + _delayMs = delayMs; + } + + /// <summary> + /// Debounce an async action. Cancels previous invocation if still pending. + /// </summary> + /// <param name="action">Async action to execute after delay</param> + public void Debounce(Func<Task> action) + { + _ = DebounceAsync(action); + } + + /// <summary> + /// Debounce a synchronous action + /// </summary> + public void Debounce(Action action) + { + _ = DebounceAsync(() => + { + action(); + return Task.CompletedTask; + }); + } + + private async Task DebounceAsync(Func<Task> action) + { + if (_disposed) + { + return; + } + + CancellationTokenSource cts; + CancellationTokenSource? oldCts = null; + + lock (_lock) + { + // Store old CTS to dispose later + oldCts = _cts; + + // Create new CTS + _cts = new CancellationTokenSource(); + cts = _cts; + } + + // Dispose old CTS outside the lock to avoid blocking + if (oldCts != null) + { + try + { + oldCts.Cancel(); + oldCts.Dispose(); + } + catch (ObjectDisposedException) + { + // Expected if CTS was already disposed + } + } + + try + { + // Wait for quiet period + await Task.Delay(_delayMs, cts.Token).ConfigureAwait(false); + + // Execute action if not cancelled + if (!cts.Token.IsCancellationRequested) + { + await action().ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Expected when debouncing - a newer call cancelled this one + } + catch (Exception ex) + { + Logger.LogError($"Debounced action failed: {ex.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + lock (_lock) + { + _disposed = true; + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + GC.SuppressFinalize(this); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs new file mode 100644 index 0000000000..1ecd5f150e --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay.Lib/Utils/VcpNames.cs @@ -0,0 +1,512 @@ +// 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 PowerDisplay.Common.Models; + +namespace PowerDisplay.Common.Utils +{ + /// <summary> + /// Provides human-readable names for VCP codes and their values based on MCCS v2.2a specification. + /// Combines VCP code names (e.g., 0x10 = "Brightness") and VCP value names (e.g., 0x14:0x05 = "6500K"). + /// Supports localization through the LocalizedCodeNameProvider delegate. + /// </summary> + public static class VcpNames + { + /// <summary> + /// Optional delegate to provide localized VCP code names. + /// Set this at application startup to enable localization. + /// The delegate receives a VCP code and should return the localized name, or null to use the default. + /// </summary> + public static Func<byte, string?>? LocalizedCodeNameProvider { get; set; } + + /// <summary> + /// VCP code to name mapping + /// </summary> + private static readonly Dictionary<byte, string> CodeNames = new() + { + // Control codes (0x00-0x0F) + { 0x00, "Code Page" }, + { 0x01, "Degauss" }, + { 0x02, "New Control Value" }, + { 0x03, "Soft Controls" }, + + // Preset operations (0x04-0x0A) + { 0x04, "Restore Factory Defaults" }, + { 0x05, "Restore Brightness and Contrast" }, + { 0x06, "Restore Factory Geometry" }, + { 0x08, "Restore Color Defaults" }, + { 0x0A, "Restore Factory TV Defaults" }, + + // Color temperature codes + { 0x0B, "Color Temperature Increment" }, + { 0x0C, "Color Temperature Request" }, + { 0x0E, "Clock" }, + { 0x0F, "Color Saturation" }, + + // Image adjustment codes + { 0x10, "Brightness" }, + { 0x11, "Flesh Tone Enhancement" }, + { 0x12, "Contrast" }, + { 0x13, "Backlight Control" }, + { 0x14, "Select Color Preset" }, + { 0x16, "Video Gain: Red" }, + { 0x17, "User Color Vision Compensation" }, + { 0x18, "Video Gain: Green" }, + { 0x1A, "Video Gain: Blue" }, + { 0x1C, "Focus" }, + { 0x1E, "Auto Setup" }, + { 0x1F, "Auto Color Setup" }, + + // Geometry controls (0x20-0x4C) + { 0x20, "Horizontal Position" }, + { 0x22, "Horizontal Size" }, + { 0x24, "Horizontal Pincushion" }, + { 0x26, "Horizontal Pincushion Balance" }, + { 0x28, "Horizontal Convergence R/B" }, + { 0x29, "Horizontal Convergence M/G" }, + { 0x2A, "Horizontal Linearity" }, + { 0x2C, "Horizontal Linearity Balance" }, + { 0x2E, "Gray Scale Expansion" }, + { 0x30, "Vertical Position" }, + { 0x32, "Vertical Size" }, + { 0x34, "Vertical Pincushion" }, + { 0x36, "Vertical Pincushion Balance" }, + { 0x38, "Vertical Convergence R/B" }, + { 0x39, "Vertical Convergence M/G" }, + { 0x3A, "Vertical Linearity" }, + { 0x3C, "Vertical Linearity Balance" }, + { 0x3E, "Clock Phase" }, + + // Miscellaneous codes + { 0x40, "Horizontal Parallelogram" }, + { 0x41, "Vertical Parallelogram" }, + { 0x42, "Horizontal Keystone" }, + { 0x43, "Vertical Keystone" }, + { 0x44, "Rotation" }, + { 0x46, "Top Corner Flare" }, + { 0x48, "Top Corner Hook" }, + { 0x4A, "Bottom Corner Flare" }, + { 0x4C, "Bottom Corner Hook" }, + + // Advanced codes + { 0x52, "Active Control" }, + { 0x54, "Performance Preservation" }, + { 0x56, "Horizontal Moire" }, + { 0x58, "Vertical Moire" }, + { 0x59, "6 Axis Saturation: Red" }, + { 0x5A, "6 Axis Saturation: Yellow" }, + { 0x5B, "6 Axis Saturation: Green" }, + { 0x5C, "6 Axis Saturation: Cyan" }, + { 0x5D, "6 Axis Saturation: Blue" }, + { 0x5E, "6 Axis Saturation: Magenta" }, + + // Input source codes + { 0x60, "Input Source" }, + { 0x62, "Audio Speaker Volume" }, + { 0x63, "Speaker Select" }, + { 0x64, "Audio: Microphone Volume" }, + { 0x66, "Ambient Light Sensor" }, + { 0x6B, "Backlight Level: White" }, + { 0x6C, "Video Black Level: Red" }, + { 0x6D, "Backlight Level: Red" }, + { 0x6E, "Video Black Level: Green" }, + { 0x6F, "Backlight Level: Green" }, + { 0x70, "Video Black Level: Blue" }, + { 0x71, "Backlight Level: Blue" }, + { 0x72, "Gamma" }, + { 0x73, "LUT Size" }, + { 0x74, "Single Point LUT Operation" }, + { 0x75, "Block LUT Operation" }, + { 0x76, "Remote Procedure Call" }, + { 0x78, "Display Identification Data Operation" }, + { 0x7A, "Adjust Focal Plane" }, + { 0x7C, "Adjust Zoom" }, + { 0x7E, "Trapezoid" }, + { 0x80, "Keystone" }, + { 0x82, "Horizontal Mirror (Flip)" }, + { 0x84, "Vertical Mirror (Flip)" }, + + // Image adjustment codes (0x86-0x9F) + { 0x86, "Display Scaling" }, + { 0x87, "Sharpness" }, + { 0x88, "Velocity Scan Modulation" }, + { 0x8A, "Color Saturation" }, + { 0x8B, "TV Channel Up/Down" }, + { 0x8C, "TV Sharpness" }, + { 0x8D, "Audio Mute/Screen Blank" }, + { 0x8E, "TV Contrast" }, + { 0x8F, "Audio Treble" }, + { 0x90, "Hue" }, + { 0x91, "Audio Bass" }, + { 0x92, "TV Black Level/Luminance" }, + { 0x93, "Audio Balance L/R" }, + { 0x94, "Audio Processor Mode" }, + { 0x95, "Window Position(TL_X)" }, + { 0x96, "Window Position(TL_Y)" }, + { 0x97, "Window Position(BR_X)" }, + { 0x98, "Window Position(BR_Y)" }, + { 0x99, "Window Background" }, + { 0x9A, "6 Axis Hue Control: Red" }, + { 0x9B, "6 Axis Hue Control: Yellow" }, + { 0x9C, "6 Axis Hue Control: Green" }, + { 0x9D, "6 Axis Hue Control: Cyan" }, + { 0x9E, "6 Axis Hue Control: Blue" }, + { 0x9F, "6 Axis Hue Control: Magenta" }, + + // Window control codes + { 0xA0, "Auto Setup On/Off" }, + { 0xA2, "Auto Color Setup On/Off" }, + { 0xA4, "Window Mask Control" }, + { 0xA5, "Window Select" }, + { 0xA6, "Window Size" }, + { 0xA7, "Window Transparency" }, + { 0xA8, "Window Control" }, + { 0xAA, "Screen Orientation" }, + { 0xAC, "Horizontal Frequency" }, + { 0xAE, "Vertical Frequency" }, + + // Misc advanced codes + { 0xB0, "Settings" }, + { 0xB2, "Flat Panel Sub-Pixel Layout" }, + { 0xB4, "Source Timing Mode" }, + { 0xB6, "Display Technology Type" }, + { 0xB7, "Monitor Status" }, + { 0xB8, "Packet Count" }, + { 0xB9, "Monitor X Origin" }, + { 0xBA, "Monitor Y Origin" }, + { 0xBB, "Header Error Count" }, + { 0xBC, "Body CRC Error Count" }, + { 0xBD, "Client ID" }, + { 0xBE, "Link Control" }, + + // Display controller codes + { 0xC0, "Display Usage Time" }, + { 0xC2, "Display Firmware Level" }, + { 0xC4, "Display Descriptor Length" }, + { 0xC5, "Transmit Display Descriptor" }, + { 0xC6, "Enable Display of 'Display Descriptor'" }, + { 0xC8, "Display Controller Type" }, + { 0xC9, "Display Firmware Level" }, + { 0xCA, "OSD" }, + { 0xCC, "OSD Language" }, + { 0xCD, "Status Indicators" }, + { 0xCE, "Auxiliary Display Size" }, + { 0xCF, "Auxiliary Display Data" }, + { 0xD0, "Output Select" }, + { 0xD2, "Asset Tag" }, + { 0xD4, "Stereo Video Mode" }, + { 0xD6, "Power Mode" }, + { 0xD7, "Auxiliary Power Output" }, + { 0xD8, "Scan Mode" }, + { 0xD9, "Image Mode" }, + { 0xDA, "On Screen Display" }, + { 0xDB, "Backlight Level: White" }, + { 0xDC, "Display Application" }, + { 0xDD, "Application Enable Key" }, + { 0xDE, "Scratch Pad" }, + { 0xDF, "VCP Version" }, + + // Manufacturer specific codes (0xE0-0xFF) + // Per MCCS 2.2a: "The 32 control codes E0h through FFh have been + // allocated to allow manufacturers to issue their own specific controls." + { 0xE0, "Manufacturer Specific" }, + { 0xE1, "Manufacturer Specific" }, + { 0xE2, "Manufacturer Specific" }, + { 0xE3, "Manufacturer Specific" }, + { 0xE4, "Manufacturer Specific" }, + { 0xE5, "Manufacturer Specific" }, + { 0xE6, "Manufacturer Specific" }, + { 0xE7, "Manufacturer Specific" }, + { 0xE8, "Manufacturer Specific" }, + { 0xE9, "Manufacturer Specific" }, + { 0xEA, "Manufacturer Specific" }, + { 0xEB, "Manufacturer Specific" }, + { 0xEC, "Manufacturer Specific" }, + { 0xED, "Manufacturer Specific" }, + { 0xEE, "Manufacturer Specific" }, + { 0xEF, "Manufacturer Specific" }, + { 0xF0, "Manufacturer Specific" }, + { 0xF1, "Manufacturer Specific" }, + { 0xF2, "Manufacturer Specific" }, + { 0xF3, "Manufacturer Specific" }, + { 0xF4, "Manufacturer Specific" }, + { 0xF5, "Manufacturer Specific" }, + { 0xF6, "Manufacturer Specific" }, + { 0xF7, "Manufacturer Specific" }, + { 0xF8, "Manufacturer Specific" }, + { 0xF9, "Manufacturer Specific" }, + { 0xFA, "Manufacturer Specific" }, + { 0xFB, "Manufacturer Specific" }, + { 0xFC, "Manufacturer Specific" }, + { 0xFD, "Manufacturer Specific" }, + { 0xFE, "Manufacturer Specific" }, + { 0xFF, "Manufacturer Specific" }, + }; + + /// <summary> + /// Get the friendly name for a VCP code. + /// Uses LocalizedCodeNameProvider if set; falls back to built-in MCCS names if not. + /// </summary> + /// <param name="code">VCP code (e.g., 0x10)</param> + /// <returns>Friendly name, or hex representation if unknown</returns> + public static string GetCodeName(byte code) + { + // Try localized name first + var localizedName = LocalizedCodeNameProvider?.Invoke(code); + if (!string.IsNullOrEmpty(localizedName)) + { + return localizedName; + } + + // Fallback to built-in MCCS names + return CodeNames.TryGetValue(code, out var name) ? name : $"Unknown (0x{code:X2})"; + } + + // Dictionary<VcpCode, Dictionary<Value, Name>> + private static readonly Dictionary<byte, Dictionary<int, string>> ValueNames = new() + { + // 0x14: Select Color Preset + [0x14] = new Dictionary<int, string> + { + [0x01] = "sRGB", + [0x02] = "Display Native", + [0x03] = "4000K", + [0x04] = "5000K", + [0x05] = "6500K", + [0x06] = "7500K", + [0x07] = "8200K", + [0x08] = "9300K", + [0x09] = "10000K", + [0x0A] = "11500K", + [0x0B] = "User 1", + [0x0C] = "User 2", + [0x0D] = "User 3", + }, + + // 0x60: Input Source + [0x60] = new Dictionary<int, string> + { + [0x01] = "VGA-1", + [0x02] = "VGA-2", + [0x03] = "DVI-1", + [0x04] = "DVI-2", + [0x05] = "Composite Video 1", + [0x06] = "Composite Video 2", + [0x07] = "S-Video-1", + [0x08] = "S-Video-2", + [0x09] = "Tuner-1", + [0x0A] = "Tuner-2", + [0x0B] = "Tuner-3", + [0x0C] = "Component Video 1", + [0x0D] = "Component Video 2", + [0x0E] = "Component Video 3", + [0x0F] = "DisplayPort-1", + [0x10] = "DisplayPort-2", + [0x11] = "HDMI-1", + [0x12] = "HDMI-2", + [0x1B] = "USB-C", + }, + + // 0xD6: Power Mode + [0xD6] = new Dictionary<int, string> + { + [0x01] = "On", + [0x02] = "Standby", + [0x03] = "Suspend", + [0x04] = "Off (DPM)", + [0x05] = "Off (Hard)", + }, + + // 0x8D: Audio Mute + [0x8D] = new Dictionary<int, string> + { + [0x01] = "Muted", + [0x02] = "Unmuted", + }, + + // 0xDC: Display Application + [0xDC] = new Dictionary<int, string> + { + [0x00] = "Standard/Default", + [0x01] = "Productivity", + [0x02] = "Mixed", + [0x03] = "Movie", + [0x04] = "User Defined", + [0x05] = "Games", + [0x06] = "Sports", + [0x07] = "Professional (calibration)", + [0x08] = "Standard/Default with intermediate power consumption", + [0x09] = "Standard/Default with low power consumption", + [0x0A] = "Demonstration", + [0xF0] = "Dynamic Contrast", + }, + + // 0xCC: OSD Language + [0xCC] = new Dictionary<int, string> + { + [0x01] = "Chinese (traditional, Hantai)", + [0x02] = "English", + [0x03] = "French", + [0x04] = "German", + [0x05] = "Italian", + [0x06] = "Japanese", + [0x07] = "Korean", + [0x08] = "Portuguese (Portugal)", + [0x09] = "Russian", + [0x0A] = "Spanish", + [0x0B] = "Swedish", + [0x0C] = "Turkish", + [0x0D] = "Chinese (simplified, Kantai)", + [0x0E] = "Portuguese (Brazil)", + [0x0F] = "Arabic", + [0x10] = "Bulgarian", + [0x11] = "Croatian", + [0x12] = "Czech", + [0x13] = "Danish", + [0x14] = "Dutch", + [0x15] = "Estonian", + [0x16] = "Finnish", + [0x17] = "Greek", + [0x18] = "Hebrew", + [0x19] = "Hindi", + [0x1A] = "Hungarian", + [0x1B] = "Latvian", + [0x1C] = "Lithuanian", + [0x1D] = "Norwegian", + [0x1E] = "Polish", + [0x1F] = "Romanian", + [0x20] = "Serbian", + [0x21] = "Slovak", + [0x22] = "Slovenian", + [0x23] = "Thai", + [0x24] = "Ukrainian", + [0x25] = "Vietnamese", + }, + + // 0x62: Audio Speaker Volume + [0x62] = new Dictionary<int, string> + { + [0x00] = "Mute", + + // Other values are continuous + }, + + // 0xDB: Image Mode (Dell monitors) + [0xDB] = new Dictionary<int, string> + { + [0x00] = "Standard", + [0x01] = "Multimedia", + [0x02] = "Movie", + [0x03] = "Game", + [0x04] = "Sports", + [0x05] = "Color Temperature", + [0x06] = "Custom Color", + [0x07] = "ComfortView", + }, + }; + + /// <summary> + /// Get all known values for a VCP code + /// </summary> + /// <param name="vcpCode">VCP code (e.g., 0x14)</param> + /// <returns>Dictionary of value to name mappings, or null if no mappings exist</returns> + public static IReadOnlyDictionary<int, string>? GetValueMappings(byte vcpCode) + { + return ValueNames.TryGetValue(vcpCode, out var values) ? values : null; + } + + /// <summary> + /// Get human-readable name for a VCP value + /// </summary> + /// <param name="vcpCode">VCP code (e.g., 0x14)</param> + /// <param name="value">Value to translate</param> + /// <returns>Name string like "sRGB" or null if unknown</returns> + public static string? GetValueName(byte vcpCode, int value) + { + if (ValueNames.TryGetValue(vcpCode, out var codeValues)) + { + if (codeValues.TryGetValue(value, out var name)) + { + return name; + } + } + + return null; + } + + /// <summary> + /// Get formatted display name for a VCP value (with hex value in parentheses) + /// </summary> + /// <param name="vcpCode">VCP code (e.g., 0x14)</param> + /// <param name="value">Value to translate</param> + /// <returns>Formatted string like "sRGB (0x01)" or "0x01" if unknown</returns> + public static string GetFormattedValueName(byte vcpCode, int value) + { + var name = GetValueName(vcpCode, value); + if (name != null) + { + return $"{name} (0x{value:X2})"; + } + + return $"0x{value:X2}"; + } + + /// <summary> + /// Get human-readable name for a VCP value with custom mapping support. + /// Custom mappings take priority over built-in mappings. + /// Monitor ID is required to properly filter monitor-specific mappings. + /// </summary> + /// <param name="vcpCode">VCP code (e.g., 0x14)</param> + /// <param name="value">Value to translate</param> + /// <param name="customMappings">Optional custom mappings that take priority</param> + /// <param name="monitorId">Monitor ID to filter mappings</param> + /// <returns>Name string like "sRGB" or null if unknown</returns> + public static string? GetValueName(byte vcpCode, int value, IEnumerable<CustomVcpValueMapping>? customMappings, string monitorId) + { + // 1. Priority: Check custom mappings first + if (customMappings != null) + { + // Find a matching custom mapping: + // - ApplyToAll = true (global), OR + // - ApplyToAll = false AND TargetMonitorId matches the given monitorId + var custom = customMappings.FirstOrDefault(m => + m.VcpCode == vcpCode && + m.Value == value && + (m.ApplyToAll || (!m.ApplyToAll && m.TargetMonitorId == monitorId))); + + if (custom != null && !string.IsNullOrEmpty(custom.CustomName)) + { + return custom.CustomName; + } + } + + // 2. Fallback to built-in mappings + return GetValueName(vcpCode, value); + } + + /// <summary> + /// Get formatted display name for a VCP value with custom mapping support. + /// Custom mappings take priority over built-in mappings. + /// Monitor ID is required to properly filter monitor-specific mappings. + /// </summary> + /// <param name="vcpCode">VCP code (e.g., 0x14)</param> + /// <param name="value">Value to translate</param> + /// <param name="customMappings">Optional custom mappings that take priority</param> + /// <param name="monitorId">Monitor ID to filter mappings</param> + /// <returns>Formatted string like "sRGB (0x01)" or "0x01" if unknown</returns> + public static string GetFormattedValueName(byte vcpCode, int value, IEnumerable<CustomVcpValueMapping>? customMappings, string monitorId) + { + var name = GetValueName(vcpCode, value, customMappings, monitorId); + if (name != null) + { + return $"{name} (0x{value:X2})"; + } + + return $"0x{value:X2}"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Assets/PowerDisplay/PowerDisplay.ico b/src/modules/powerdisplay/PowerDisplay/Assets/PowerDisplay/PowerDisplay.ico new file mode 100644 index 0000000000..a9f170a8bd Binary files /dev/null and b/src/modules/powerdisplay/PowerDisplay/Assets/PowerDisplay/PowerDisplay.ico differ diff --git a/src/modules/powerdisplay/PowerDisplay/Configuration/AppConstants.cs b/src/modules/powerdisplay/PowerDisplay/Configuration/AppConstants.cs new file mode 100644 index 0000000000..94ab75444a --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Configuration/AppConstants.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace PowerDisplay.Configuration +{ + /// <summary> + /// Application-wide constants and configuration values + /// </summary> + public static class AppConstants + { + /// <summary> + /// UI layout and timing constants + /// </summary> + public static class UI + { + // Window dimensions + public const int WindowWidth = 362; + public const int MinWindowHeight = 100; + public const int MaxWindowHeight = 650; + public const int WindowRightMargin = 12; + + /// <summary> + /// Icon glyph for internal/laptop displays (WMI) + /// </summary> + public const string InternalMonitorGlyph = "\uE7F8"; + + /// <summary> + /// Icon glyph for external monitors (DDC/CI) + /// </summary> + public const string ExternalMonitorGlyph = "\uE7F4"; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/GlobalUsings.cs b/src/modules/powerdisplay/PowerDisplay/GlobalUsings.cs new file mode 100644 index 0000000000..d6c14983d5 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/GlobalUsings.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; + +// Enable compile-time marshalling for all P/Invoke declarations +// This allows LibraryImport to handle array marshalling and achieve 100% coverage +[assembly: DisableRuntimeMarshalling] diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/DisplayChangeWatcher.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/DisplayChangeWatcher.cs new file mode 100644 index 0000000000..74650a4373 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/DisplayChangeWatcher.cs @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.UI.Dispatching; +using Microsoft.Win32; +using Windows.Devices.Display; +using Windows.Devices.Enumeration; + +namespace PowerDisplay.Helpers; + +/// <summary> +/// Watches for display/monitor connection changes using WinRT DeviceWatcher. +/// Triggers DisplayChanged event when monitors are added, removed, or updated. +/// </summary> +public sealed partial class DisplayChangeWatcher : IDisposable +{ + private readonly DispatcherQueue _dispatcherQueue; + private readonly TimeSpan _debounceDelay; + + private DeviceWatcher? _deviceWatcher; + private CancellationTokenSource? _debounceCts; + private bool _isRunning; + private bool _disposed; + private bool _initialEnumerationComplete; + + /// <summary> + /// Event triggered when display configuration changes (after debounce period). + /// </summary> + public event EventHandler? DisplayChanged; + + /// <summary> + /// Initializes a new instance of the <see cref="DisplayChangeWatcher"/> class. + /// </summary> + /// <param name="dispatcherQueue">The dispatcher queue for UI thread marshalling.</param> + /// <param name="debounceDelay">Delay before triggering DisplayChanged event. This allows hardware to stabilize after monitor plug/unplug.</param> + public DisplayChangeWatcher(DispatcherQueue dispatcherQueue, TimeSpan debounceDelay) + { + _dispatcherQueue = dispatcherQueue ?? throw new ArgumentNullException(nameof(dispatcherQueue)); + _debounceDelay = debounceDelay; + SystemEvents.PowerModeChanged += OnPowerModeChanged; + } + + /// <summary> + /// Gets a value indicating whether the watcher is currently running. + /// </summary> + public bool IsRunning => _isRunning; + + /// <summary> + /// Starts watching for display changes. + /// </summary> + public void Start() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_isRunning) + { + return; + } + + try + { + // Get the device selector for display monitors + string selector = DisplayMonitor.GetDeviceSelector(); + + // Create the device watcher + _deviceWatcher = DeviceInformation.CreateWatcher(selector); + + // Subscribe to events + _deviceWatcher.Added += OnDeviceAdded; + _deviceWatcher.Removed += OnDeviceRemoved; + _deviceWatcher.Updated += OnDeviceUpdated; + _deviceWatcher.EnumerationCompleted += OnEnumerationCompleted; + _deviceWatcher.Stopped += OnWatcherStopped; + + // Reset state before starting (must be before Start() to avoid race) + _initialEnumerationComplete = false; + _isRunning = true; + + // Start watching + _deviceWatcher.Start(); + } + catch (Exception ex) + { + Logger.LogError($"[DisplayChangeWatcher] Failed to start: {ex.Message}"); + _isRunning = false; + } + } + + /// <summary> + /// Stops watching for display changes. + /// </summary> + public void Stop() + { + if (!_isRunning || _deviceWatcher == null) + { + return; + } + + try + { + // Cancel any pending debounce + CancelDebounce(); + + // Stop the watcher + _deviceWatcher.Stop(); + } + catch (Exception ex) + { + Logger.LogError($"[DisplayChangeWatcher] Error stopping watcher: {ex.Message}"); + } + } + + private void OnDeviceAdded(DeviceWatcher sender, DeviceInformation args) + { + // Dispatch to UI thread to ensure thread-safe state access + _dispatcherQueue.TryEnqueue(() => + { + // Ignore events during initial enumeration or after disposal + if (_disposed || !_initialEnumerationComplete) + { + return; + } + + ScheduleDisplayChanged(); + }); + } + + private void OnDeviceRemoved(DeviceWatcher sender, DeviceInformationUpdate args) + { + // Dispatch to UI thread to ensure thread-safe state access + _dispatcherQueue.TryEnqueue(() => + { + // Ignore events during initial enumeration or after disposal + if (_disposed || !_initialEnumerationComplete) + { + return; + } + + ScheduleDisplayChanged(); + }); + } + + private void OnDeviceUpdated(DeviceWatcher sender, DeviceInformationUpdate args) + { + // Only trigger refresh for significant updates, not every property change. + // For now, we'll skip updates to avoid excessive refreshes. + // The Added and Removed events are the primary triggers for monitor changes. + } + + private void OnEnumerationCompleted(DeviceWatcher sender, object args) + { + // Dispatch to UI thread to ensure thread-safe state access + _dispatcherQueue.TryEnqueue(() => + { + _initialEnumerationComplete = true; + }); + } + + private void OnWatcherStopped(DeviceWatcher sender, object args) + { + // Dispatch to UI thread to ensure thread-safe state access + _dispatcherQueue.TryEnqueue(() => + { + _isRunning = false; + + // If not disposed, this is an unexpected stop (e.g., during sleep/wake) + // Try to auto-restart the watcher + if (!_disposed) + { + Logger.LogInfo("[DisplayChangeWatcher] Watcher stopped unexpectedly, attempting restart"); + + // Clean up the old watcher + CleanupDeviceWatcher(); + + // Restart after a short delay to allow system to stabilize + Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(1)); + _dispatcherQueue.TryEnqueue(() => + { + if (!_disposed && !_isRunning) + { + Start(); + } + }); + }); + } + else + { + _initialEnumerationComplete = false; + } + }); + } + + /// <summary> + /// Handles system power mode changes (suspend/resume). + /// </summary> + private void OnPowerModeChanged(object? sender, PowerModeChangedEventArgs e) + { + if (_disposed) + { + return; + } + + if (e.Mode == PowerModes.Resume) + { + Logger.LogInfo("[DisplayChangeWatcher] System resumed from sleep, scheduling display refresh"); + + // Schedule a display refresh after system resumes + // Use a longer delay to allow hardware to fully reinitialize + _dispatcherQueue.TryEnqueue(() => + { + if (!_disposed) + { + // Trigger a display changed event after wake-up + // The debounce mechanism will handle rapid successive events + ScheduleDisplayChanged(); + } + }); + } + } + + /// <summary> + /// Cleans up the current device watcher and unsubscribes from events. + /// </summary> + private void CleanupDeviceWatcher() + { + if (_deviceWatcher != null) + { + try + { + _deviceWatcher.Added -= OnDeviceAdded; + _deviceWatcher.Removed -= OnDeviceRemoved; + _deviceWatcher.Updated -= OnDeviceUpdated; + _deviceWatcher.EnumerationCompleted -= OnEnumerationCompleted; + _deviceWatcher.Stopped -= OnWatcherStopped; + } + catch + { + // Ignore errors during cleanup + } + + _deviceWatcher = null; + } + } + + /// <summary> + /// Schedules a DisplayChanged event with debouncing. + /// Multiple rapid changes will only trigger one event after the debounce period. + /// </summary> + private void ScheduleDisplayChanged() + { + // Cancel any pending debounce + CancelDebounce(); + + // Create new cancellation token + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + + // Schedule the event after debounce delay + Task.Run(async () => + { + try + { + await Task.Delay(_debounceDelay, token); + + if (!token.IsCancellationRequested) + { + // Dispatch to UI thread + _dispatcherQueue.TryEnqueue(() => + { + if (!_disposed) + { + DisplayChanged?.Invoke(this, EventArgs.Empty); + } + }); + } + } + catch (OperationCanceledException) + { + // Debounce was cancelled by a newer event, this is expected + } + catch (Exception ex) + { + Logger.LogError($"[DisplayChangeWatcher] Error in debounce task: {ex.Message}"); + } + }); + } + + private void CancelDebounce() + { + try + { + _debounceCts?.Cancel(); + _debounceCts?.Dispose(); + _debounceCts = null; + } + catch (ObjectDisposedException) + { + // Already disposed, ignore + } + } + + /// <summary> + /// Disposes resources used by the watcher. + /// </summary> + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + // Unsubscribe from power mode changes + SystemEvents.PowerModeChanged -= OnPowerModeChanged; + + // Stop watching + Stop(); + + // Unsubscribe from device watcher events + CleanupDeviceWatcher(); + + // Cancel debounce + CancelDebounce(); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/HotkeyService.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/HotkeyService.cs new file mode 100644 index 0000000000..63ef27c054 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/HotkeyService.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.InteropServices; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using WinRT.Interop; + +namespace PowerDisplay.Helpers +{ + /// <summary> + /// Service for handling hotkey registration in-process. + /// Uses RegisterHotKey Win32 API instead of Runner's centralized mechanism + /// to avoid IPC timing issues (CmdPal pattern). + /// </summary> + internal sealed partial class HotkeyService : IDisposable + { + private const int HotkeyId = 9001; + + private readonly SettingsUtils _settingsUtils; + private readonly Action _hotkeyAction; + + private nint _hwnd; + private nint _originalWndProc; + + // Must keep delegate reference to prevent GC collection + private WndProcDelegate? _hotkeyWndProc; + private bool _isRegistered; + private bool _disposed; + + public HotkeyService(SettingsUtils settingsUtils, Action hotkeyAction) + { + _settingsUtils = settingsUtils; + _hotkeyAction = hotkeyAction; + } + + /// <summary> + /// Initialize the hotkey service with a window handle. + /// Must be called after window is created. + /// </summary> + /// <param name="window">The WinUI window to attach to.</param> + public void Initialize(Microsoft.UI.Xaml.Window window) + { + _hwnd = WindowNative.GetWindowHandle(window); + + // LOAD BEARING: If you don't stick the pointer to the WndProc into a + // member (and instead use a local), then the pointer we marshal + // into the WindowLongPtr will be useless after we leave this function, + // and our WndProc will explode. + _hotkeyWndProc = HotkeyWndProc; + var wndProcPointer = Marshal.GetFunctionPointerForDelegate(_hotkeyWndProc); + _originalWndProc = SetWindowLongPtrNative(_hwnd, GwlWndProc, wndProcPointer); + + // Register hotkey based on current settings + ReloadSettings(); + } + + /// <summary> + /// Reload settings and re-register hotkey. + /// Call this when settings change. + /// </summary> + public void ReloadSettings() + { + UnregisterHotkey(); + + var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName); + var hotkey = settings?.Properties?.ActivationShortcut; + + if (hotkey == null || !hotkey.IsValid()) + { + return; + } + + RegisterHotkey(hotkey); + } + + private void RegisterHotkey(HotkeySettings hotkey) + { + if (_hwnd == 0) + { + Logger.LogWarning("[HotkeyService] Cannot register hotkey: window handle not set"); + return; + } + + // Build modifiers using bit flags + uint modifiers = ModNoRepeat + | (hotkey.Win ? ModWin : 0) + | (hotkey.Ctrl ? ModControl : 0) + | (hotkey.Alt ? ModAlt : 0) + | (hotkey.Shift ? ModShift : 0); + + if (RegisterHotKeyNative(_hwnd, HotkeyId, modifiers, (uint)hotkey.Code)) + { + _isRegistered = true; + } + else + { + Logger.LogError($"[HotkeyService] Failed to register hotkey: {hotkey}, error={Marshal.GetLastWin32Error()}"); + } + } + + private void UnregisterHotkey() + { + if (!_isRegistered || _hwnd == 0) + { + return; + } + + bool success = UnregisterHotKeyNative(_hwnd, HotkeyId); + + if (!success) + { + var error = Marshal.GetLastWin32Error(); + Logger.LogWarning($"[HotkeyService] Failed to unregister hotkey, error={error}"); + } + + _isRegistered = false; + } + + private nint HotkeyWndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam) + { + if (uMsg == WmHotkey && (int)wParam == HotkeyId) + { + try + { + _hotkeyAction?.Invoke(); + } + catch (Exception ex) + { + Logger.LogError($"[HotkeyService] Hotkey action failed: {ex.Message}"); + } + + return 0; + } + + return CallWindowProcNative(_originalWndProc, hwnd, uMsg, wParam, lParam); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + UnregisterHotkey(); + _disposed = true; + } + + // P/Invoke constants + private const int GwlWndProc = -4; + private const uint WmHotkey = 0x0312; + + // HOT_KEY_MODIFIERS flags + private const uint ModAlt = 0x0001; + private const uint ModControl = 0x0002; + private const uint ModShift = 0x0004; + private const uint ModWin = 0x0008; + private const uint ModNoRepeat = 0x4000; + + [LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static partial nint SetWindowLongPtrNative(nint hWnd, int nIndex, nint dwNewLong); + + [LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")] + private static partial nint CallWindowProcNative(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam); + + [LibraryImport("user32.dll", EntryPoint = "RegisterHotKey", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool RegisterHotKeyNative(nint hWnd, int id, uint fsModifiers, uint vk); + + [LibraryImport("user32.dll", EntryPoint = "UnregisterHotKey", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool UnregisterHotKeyNative(nint hWnd, int id); + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/MonitorManager.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/MonitorManager.cs new file mode 100644 index 0000000000..1f9a353303 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/MonitorManager.cs @@ -0,0 +1,460 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using PowerDisplay.Common.Drivers; +using PowerDisplay.Common.Drivers.DDC; +using PowerDisplay.Common.Drivers.WMI; +using PowerDisplay.Common.Interfaces; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Services; +using PowerDisplay.Common.Utils; +using Monitor = PowerDisplay.Common.Models.Monitor; + +namespace PowerDisplay.Helpers +{ + /// <summary> + /// Monitor manager for unified control of all monitors + /// No interface abstraction - KISS principle (only one implementation needed) + /// </summary> + public partial class MonitorManager : IDisposable + { + private readonly List<Monitor> _monitors = new(); + private readonly Dictionary<string, Monitor> _monitorLookup = new(); + private readonly SemaphoreSlim _discoveryLock = new(1, 1); + private readonly DisplayRotationService _rotationService = new(); + + // Controllers stored by type for O(1) lookup based on CommunicationMethod + private DdcCiController? _ddcController; + private WmiController? _wmiController; + private bool _disposed; + + public IReadOnlyList<Monitor> Monitors => _monitors.AsReadOnly(); + + public MonitorManager() + { + // Initialize controllers + InitializeControllers(); + } + + /// <summary> + /// Initialize controllers + /// </summary> + private void InitializeControllers() + { + try + { + // DDC/CI controller (external monitors) + _ddcController = new DdcCiController(); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to initialize DDC/CI controller: {ex.Message}"); + } + + try + { + // WMI controller (internal monitors) + // Always create - DiscoverMonitorsAsync returns empty list if WMI is unavailable + _wmiController = new WmiController(); + } + catch (Exception ex) + { + Logger.LogWarning($"Failed to initialize WMI controller: {ex.Message}"); + } + } + + /// <summary> + /// Discover all monitors from all controllers. + /// Each controller is responsible for fully initializing its monitors + /// (including brightness, capabilities, input source, color temperature, etc.) + /// </summary> + public async Task<IReadOnlyList<Monitor>> DiscoverMonitorsAsync(CancellationToken cancellationToken = default) + { + await _discoveryLock.WaitAsync(cancellationToken); + + try + { + var discoveredMonitors = await DiscoverFromAllControllersAsync(cancellationToken); + + // Update collections + _monitors.Clear(); + _monitorLookup.Clear(); + + var sortedMonitors = discoveredMonitors + .OrderBy(m => m.MonitorNumber) + .ToList(); + + _monitors.AddRange(sortedMonitors); + foreach (var monitor in sortedMonitors) + { + _monitorLookup[monitor.Id] = monitor; + } + + return _monitors.AsReadOnly(); + } + finally + { + _discoveryLock.Release(); + } + } + + /// <summary> + /// Discover monitors from all registered controllers in parallel. + /// </summary> + private async Task<List<Monitor>> DiscoverFromAllControllersAsync(CancellationToken cancellationToken) + { + var tasks = new List<Task<IEnumerable<Monitor>>>(); + + if (_ddcController != null) + { + tasks.Add(SafeDiscoverAsync(_ddcController, cancellationToken)); + } + + if (_wmiController != null) + { + tasks.Add(SafeDiscoverAsync(_wmiController, cancellationToken)); + } + + var results = await Task.WhenAll(tasks); + return results.SelectMany(m => m).ToList(); + } + + /// <summary> + /// Safely discover monitors from a controller, returning empty list on failure. + /// </summary> + private static async Task<IEnumerable<Monitor>> SafeDiscoverAsync( + IMonitorController controller, + CancellationToken cancellationToken) + { + try + { + return await controller.DiscoverMonitorsAsync(cancellationToken); + } + catch (Exception ex) + { + Logger.LogWarning($"Controller {controller.Name} discovery failed: {ex.Message}"); + return Enumerable.Empty<Monitor>(); + } + } + + /// <summary> + /// Get brightness of the specified monitor + /// </summary> + public async Task<VcpFeatureValue> GetBrightnessAsync(string monitorId, CancellationToken cancellationToken = default) + { + var monitor = GetMonitor(monitorId); + if (monitor == null) + { + return VcpFeatureValue.Invalid; + } + + var controller = GetControllerForMonitor(monitor); + if (controller == null) + { + return VcpFeatureValue.Invalid; + } + + try + { + var brightnessInfo = await controller.GetBrightnessAsync(monitor, cancellationToken); + + // Update cached brightness value + if (brightnessInfo.IsValid) + { + monitor.UpdateStatus(brightnessInfo.ToPercentage(), true); + } + + return brightnessInfo; + } + catch (Exception ex) + { + // Mark monitor as unavailable + Logger.LogError($"Failed to get brightness for monitor {monitorId}: {ex.Message}"); + monitor.IsAvailable = false; + return VcpFeatureValue.Invalid; + } + } + + /// <summary> + /// Set brightness of the specified monitor + /// </summary> + public Task<MonitorOperationResult> SetBrightnessAsync(string monitorId, int brightness, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + brightness, + (ctrl, mon, val, ct) => ctrl.SetBrightnessAsync(mon, val, ct), + (mon, val) => mon.UpdateStatus(val, true), + cancellationToken); + + /// <summary> + /// Set contrast of the specified monitor + /// </summary> + public Task<MonitorOperationResult> SetContrastAsync(string monitorId, int contrast, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + contrast, + (ctrl, mon, val, ct) => ctrl.SetContrastAsync(mon, val, ct), + (mon, val) => mon.CurrentContrast = val, + cancellationToken); + + /// <summary> + /// Set volume of the specified monitor + /// </summary> + public Task<MonitorOperationResult> SetVolumeAsync(string monitorId, int volume, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + volume, + (ctrl, mon, val, ct) => ctrl.SetVolumeAsync(mon, val, ct), + (mon, val) => mon.CurrentVolume = val, + cancellationToken); + + /// <summary> + /// Get monitor color temperature + /// </summary> + public async Task<VcpFeatureValue> GetColorTemperatureAsync(string monitorId, CancellationToken cancellationToken = default) + { + var monitor = GetMonitor(monitorId); + if (monitor == null) + { + return VcpFeatureValue.Invalid; + } + + var controller = GetControllerForMonitor(monitor); + if (controller == null) + { + return VcpFeatureValue.Invalid; + } + + try + { + return await controller.GetColorTemperatureAsync(monitor, cancellationToken); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return VcpFeatureValue.Invalid; + } + } + + /// <summary> + /// Set monitor color temperature + /// </summary> + public Task<MonitorOperationResult> SetColorTemperatureAsync(string monitorId, int colorTemperature, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + colorTemperature, + (ctrl, mon, val, ct) => ctrl.SetColorTemperatureAsync(mon, val, ct), + (mon, val) => mon.CurrentColorTemperature = val, + cancellationToken); + + /// <summary> + /// Get current input source for a monitor + /// </summary> + public async Task<VcpFeatureValue> GetInputSourceAsync(string monitorId, CancellationToken cancellationToken = default) + { + var monitor = GetMonitor(monitorId); + if (monitor == null) + { + return VcpFeatureValue.Invalid; + } + + var controller = GetControllerForMonitor(monitor); + if (controller == null) + { + return VcpFeatureValue.Invalid; + } + + try + { + return await controller.GetInputSourceAsync(monitor, cancellationToken); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return VcpFeatureValue.Invalid; + } + } + + /// <summary> + /// Set input source for a monitor + /// </summary> + public Task<MonitorOperationResult> SetInputSourceAsync(string monitorId, int inputSource, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + inputSource, + (ctrl, mon, val, ct) => ctrl.SetInputSourceAsync(mon, val, ct), + (mon, val) => mon.CurrentInputSource = val, + cancellationToken); + + /// <summary> + /// Set power state for a monitor using VCP 0xD6. + /// Note: Setting any state other than On (0x01) will turn off the display. + /// We don't update monitor state since the display will be off. + /// </summary> + public Task<MonitorOperationResult> SetPowerStateAsync(string monitorId, int powerState, CancellationToken cancellationToken = default) + => ExecuteMonitorOperationAsync( + monitorId, + powerState, + (ctrl, mon, val, ct) => ctrl.SetPowerStateAsync(mon, val, ct), + (mon, val) => { }, // No state update - display will be off for non-On values + cancellationToken); + + /// <summary> + /// Set rotation/orientation for a monitor. + /// Uses Windows ChangeDisplaySettingsEx API (not DDC/CI). + /// After successful rotation, refreshes orientation for all monitors sharing the same GdiDeviceName + /// (important for mirror/clone mode where multiple monitors share one display source). + /// </summary> + /// <param name="monitorId">Monitor ID</param> + /// <param name="orientation">Orientation: 0=normal, 1=90°, 2=180°, 3=270°</param> + /// <param name="cancellationToken">Cancellation token</param> + /// <returns>Operation result</returns> + public Task<MonitorOperationResult> SetRotationAsync(string monitorId, int orientation, CancellationToken cancellationToken = default) + { + var monitor = GetMonitor(monitorId); + if (monitor == null) + { + Logger.LogError($"[MonitorManager] SetRotation: Monitor not found: {monitorId}"); + return Task.FromResult(MonitorOperationResult.Failure("Monitor not found")); + } + + // Rotation uses Windows display settings API, not DDC/CI controller + // Prefer using Monitor object which contains GdiDeviceName for accurate adapter targeting + var result = _rotationService.SetRotation(monitor, orientation); + + if (result.IsSuccess) + { + // Refresh orientation for all monitors - rotation affects the GdiDeviceName (display source), + // and in mirror mode multiple monitors may share the same GdiDeviceName + RefreshAllOrientations(); + } + else + { + Logger.LogError($"[MonitorManager] SetRotation: Failed for {monitorId}: {result.ErrorMessage}"); + } + + return Task.FromResult(result); + } + + /// <summary> + /// Refresh orientation values for all monitors by querying current display settings. + /// This ensures all monitors reflect the actual system state, which is important + /// in mirror mode where multiple monitors share the same GdiDeviceName. + /// </summary> + public void RefreshAllOrientations() + { + foreach (var monitor in _monitors) + { + if (string.IsNullOrEmpty(monitor.GdiDeviceName)) + { + continue; + } + + var currentOrientation = _rotationService.GetCurrentOrientation(monitor.GdiDeviceName); + if (currentOrientation >= 0 && currentOrientation != monitor.Orientation) + { + monitor.Orientation = currentOrientation; + monitor.LastUpdate = DateTime.Now; + } + } + } + + /// <summary> + /// Get monitor by ID. Uses dictionary lookup for O(1) performance. + /// </summary> + public Monitor? GetMonitor(string monitorId) + { + return _monitorLookup.TryGetValue(monitorId, out var monitor) ? monitor : null; + } + + /// <summary> + /// Get controller for the monitor based on CommunicationMethod. + /// O(1) lookup - no async validation needed since controller type is determined at discovery. + /// </summary> + private IMonitorController? GetControllerForMonitor(Monitor monitor) + { + return monitor.CommunicationMethod switch + { + "WMI" => _wmiController, + "DDC/CI" => _ddcController, + _ => null, + }; + } + + /// <summary> + /// Generic helper to execute monitor operations with common error handling. + /// Eliminates code duplication across Set* methods. + /// </summary> + private async Task<MonitorOperationResult> ExecuteMonitorOperationAsync<T>( + string monitorId, + T value, + Func<IMonitorController, Monitor, T, CancellationToken, Task<MonitorOperationResult>> operation, + Action<Monitor, T> onSuccess, + CancellationToken cancellationToken = default) + { + var monitor = GetMonitor(monitorId); + if (monitor == null) + { + Logger.LogError($"[MonitorManager] Monitor not found: {monitorId}"); + return MonitorOperationResult.Failure("Monitor not found"); + } + + var controller = GetControllerForMonitor(monitor); + if (controller == null) + { + Logger.LogError($"[MonitorManager] No controller available for monitor {monitorId}"); + return MonitorOperationResult.Failure("No controller available for this monitor"); + } + + try + { + var result = await operation(controller, monitor, value, cancellationToken); + + if (result.IsSuccess) + { + onSuccess(monitor, value); + monitor.LastUpdate = DateTime.Now; + } + else + { + monitor.IsAvailable = false; + } + + return result; + } + catch (Exception ex) + { + monitor.IsAvailable = false; + Logger.LogError($"[MonitorManager] Operation failed for {monitorId}: {ex.Message}"); + return MonitorOperationResult.Failure($"Exception: {ex.Message}"); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _discoveryLock?.Dispose(); + + // Release controllers + _ddcController?.Dispose(); + _wmiController?.Dispose(); + + _monitors.Clear(); + _monitorLookup.Clear(); + _disposed = true; + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/NamedPipeProcessor.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/NamedPipeProcessor.cs new file mode 100644 index 0000000000..9945ad9da9 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/NamedPipeProcessor.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.IO.Pipes; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; + +namespace PowerDisplay.Helpers; + +/// <summary> +/// Processes messages from the Module DLL via Named Pipe. +/// Based on AdvancedPaste NamedPipeProcessor pattern. +/// </summary> +public static class NamedPipeProcessor +{ + /// <summary> + /// Connects to a named pipe and processes incoming messages. + /// This method runs continuously until cancelled or the pipe is disconnected. + /// </summary> + /// <param name="pipeName">The name of the pipe to connect to.</param> + /// <param name="connectTimeout">Timeout for initial connection.</param> + /// <param name="messageHandler">Handler for each received message.</param> + /// <param name="cancellationToken">Token to cancel the operation.</param> + public static async Task ProcessNamedPipeAsync( + string pipeName, + TimeSpan connectTimeout, + Action<string> messageHandler, + CancellationToken cancellationToken) + { + try + { + using NamedPipeClientStream pipeClient = new(".", pipeName, PipeDirection.In); + + Logger.LogInfo($"[NamedPipe] Connecting to pipe: {pipeName}"); + await pipeClient.ConnectAsync(connectTimeout, cancellationToken); + Logger.LogInfo($"[NamedPipe] Connected to pipe: {pipeName}"); + + using StreamReader streamReader = new(pipeClient, Encoding.Unicode); + + while (!cancellationToken.IsCancellationRequested) + { + var message = await streamReader.ReadLineAsync(cancellationToken); + + if (message != null) + { + Logger.LogInfo($"[NamedPipe] Received message: {message}"); + messageHandler(message); + } + + // Small delay to prevent tight loop + var intraMessageDelay = TimeSpan.FromMilliseconds(10); + await Task.Delay(intraMessageDelay, cancellationToken); + } + } + catch (OperationCanceledException) + { + Logger.LogInfo("[NamedPipe] Processing cancelled"); + } + catch (IOException ex) + { + // Pipe disconnected, this is expected when the module DLL terminates + Logger.LogInfo($"[NamedPipe] Pipe disconnected: {ex.Message}"); + } + catch (Exception ex) + { + Logger.LogError($"[NamedPipe] Error processing pipe: {ex.Message}"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/NativeEventWaiter.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/NativeEventWaiter.cs new file mode 100644 index 0000000000..33ef13664c --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/NativeEventWaiter.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using ManagedCommon; +using Microsoft.UI.Dispatching; + +namespace PowerDisplay.Helpers +{ + /// <summary> + /// Helper class for waiting on Windows Named Events (Awake pattern) + /// Based on Peek.UI implementation + /// </summary> + public static class NativeEventWaiter + { + /// <summary> + /// Wait for a Windows Event in a background thread and invoke callback on UI thread when signaled + /// </summary> + /// <param name="eventName">Name of the Windows Event to wait for</param> + /// <param name="callback">Callback to invoke when event is signaled</param> + /// <param name="cancellationToken">Token to cancel the wait loop</param> + public static void WaitForEventLoop(string eventName, Action callback, CancellationToken cancellationToken) + { + Logger.LogTrace($"[NativeEventWaiter] Setting up event loop for event: {eventName}"); + var dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + + if (dispatcherQueue == null) + { + Logger.LogError($"[NativeEventWaiter] DispatcherQueue is null for event: {eventName}"); + return; + } + + Logger.LogTrace($"[NativeEventWaiter] DispatcherQueue obtained for event: {eventName}"); + + var t = new Thread(() => + { + Logger.LogInfo($"[NativeEventWaiter] Background thread started for event: {eventName}"); + try + { + Logger.LogTrace($"[NativeEventWaiter] Creating EventWaitHandle for event: {eventName}"); + using var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName); + Logger.LogInfo($"[NativeEventWaiter] EventWaitHandle created successfully for event: {eventName}"); + + int waitCount = 0; + while (!cancellationToken.IsCancellationRequested) + { + // Use 500ms timeout for polling + if (eventHandle.WaitOne(500)) + { + waitCount++; + Logger.LogInfo($"[NativeEventWaiter] Event SIGNALED: {eventName} (signal count: {waitCount})"); + bool enqueued = dispatcherQueue.TryEnqueue(() => + { + Logger.LogTrace($"[NativeEventWaiter] Executing callback on UI thread for event: {eventName}"); + try + { + callback(); + Logger.LogTrace($"[NativeEventWaiter] Callback completed for event: {eventName}"); + } + catch (Exception callbackEx) + { + Logger.LogError($"[NativeEventWaiter] Callback exception for event {eventName}: {callbackEx.Message}"); + } + }); + + if (!enqueued) + { + Logger.LogError($"[NativeEventWaiter] Failed to enqueue callback to UI thread for event: {eventName}"); + } + } + } + + Logger.LogInfo($"[NativeEventWaiter] Event loop ending for event: {eventName} (cancellation requested)"); + } + catch (Exception ex) + { + Logger.LogError($"[NativeEventWaiter] Exception in event loop for {eventName}: {ex.Message}\n{ex.StackTrace}"); + } + }); + + t.IsBackground = true; + t.Name = $"NativeEventWaiter_{eventName}"; + t.Start(); + Logger.LogTrace($"[NativeEventWaiter] Background thread started with name: {t.Name}"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/ResourceLoaderInstance.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/ResourceLoaderInstance.cs new file mode 100644 index 0000000000..cb549336fd --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/ResourceLoaderInstance.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Windows.ApplicationModel.Resources; + +namespace PowerDisplay.Helpers +{ + public static class ResourceLoaderInstance + { + public static ResourceLoader ResourceLoader { get; private set; } + + static ResourceLoaderInstance() + { + ResourceLoader = new ResourceLoader("PowerToys.PowerDisplay.pri"); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/SettingsDeepLink.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/SettingsDeepLink.cs new file mode 100644 index 0000000000..d721fa5811 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/SettingsDeepLink.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.IO; + +namespace PowerDisplay.Helpers +{ + public static class SettingsDeepLink + { + public static void OpenSettings(bool mainExecutableIsOnTheParentFolder) + { + try + { + var directoryPath = System.AppContext.BaseDirectory; + if (mainExecutableIsOnTheParentFolder) + { + // Need to go into parent folder for PowerToys.exe. Likely a WinUI3 App SDK application. + directoryPath = Path.Combine(directoryPath, ".."); + directoryPath = Path.Combine(directoryPath, "PowerToys.exe"); + } + else + { + // PowerToys.exe is in the same path as the application. + directoryPath = Path.Combine(directoryPath, "PowerToys.exe"); + } + + Process.Start(new ProcessStartInfo(directoryPath) { Arguments = "--open-settings=PowerDisplay" }); + } + catch + { + // Silently ignore errors opening settings + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs new file mode 100644 index 0000000000..ab8dca6b80 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/TrayIconService.cs @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Runtime.InteropServices; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.UI.Shell; +using Windows.Win32.UI.WindowsAndMessaging; +using WinRT.Interop; + +namespace PowerDisplay.Helpers +{ + /// <summary> + /// Window procedure delegate for handling window messages. + /// Uses primitive types to avoid accessibility issues with CsWin32-generated types. + /// </summary> + /// <param name="hwnd">Handle to the window.</param> + /// <param name="msg">The message.</param> + /// <param name="wParam">Additional message information.</param> + /// <param name="lParam">Additional message.</param> + /// <returns>The result of the message processing.</returns> + internal delegate nint WndProcDelegate(nint hwnd, uint msg, nuint wParam, nint lParam); + + internal sealed partial class TrayIconService + { + private const uint MyNotifyId = 1001; + private const uint WmTrayIcon = PInvoke.WM_USER + 1; + + private readonly SettingsUtils _settingsUtils; + private readonly Action _toggleWindowAction; + private readonly Action _exitAction; + private readonly Action _openSettingsAction; + private readonly uint _wmTaskbarRestart; + + private Window? _window; + private nint _hwnd; + private nint _originalWndProc; + private WndProcDelegate? _trayWndProc; + private NOTIFYICONDATAW? _trayIconData; + private nint _largeIcon; + private nint _popupMenu; + + public TrayIconService( + SettingsUtils settingsUtils, + Action toggleWindowAction, + Action exitAction, + Action openSettingsAction) + { + _settingsUtils = settingsUtils; + _toggleWindowAction = toggleWindowAction; + _exitAction = exitAction; + _openSettingsAction = openSettingsAction; + + // TaskbarCreated is the message that's broadcast when explorer.exe + // restarts. We need to know when that happens to be able to bring our + // notification area icon back + _wmTaskbarRestart = RegisterWindowMessageNative("TaskbarCreated"); + } + + public void SetupTrayIcon(bool? showSystemTrayIcon = null) + { + var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName); + bool shouldShow = showSystemTrayIcon ?? settings.Properties.ShowSystemTrayIcon; + + if (shouldShow) + { + if (_window is null) + { + _window = new Window(); + _hwnd = WindowNative.GetWindowHandle(_window); + + // LOAD BEARING: If you don't stick the pointer to HotKeyPrc into a + // member (and instead like, use a local), then the pointer we marshal + // into the WindowLongPtr will be useless after we leave this function, + // and our **WindProc will explode**. + _trayWndProc = WindowProc; + var hotKeyPrcPointer = Marshal.GetFunctionPointerForDelegate(_trayWndProc); + _originalWndProc = SetWindowLongPtrNative(_hwnd, GwlWndproc, hotKeyPrcPointer); + } + + if (_trayIconData is null) + { + // We need to stash this handle, so it doesn't clean itself up. If + // explorer restarts, we'll come back through here, and we don't + // really need to re-load the icon in that case. We can just use + // the handle from the first time. + _largeIcon = GetAppIconHandle(); + unsafe + { + _trayIconData = new NOTIFYICONDATAW() + { + cbSize = (uint)sizeof(NOTIFYICONDATAW), + hWnd = new HWND(_hwnd), + uID = MyNotifyId, + uFlags = NOTIFY_ICON_DATA_FLAGS.NIF_MESSAGE | NOTIFY_ICON_DATA_FLAGS.NIF_ICON | NOTIFY_ICON_DATA_FLAGS.NIF_TIP, + uCallbackMessage = WmTrayIcon, + hIcon = new HICON(_largeIcon), + szTip = GetString("AppName"), + }; + } + } + + var d = (NOTIFYICONDATAW)_trayIconData; + + // Add the notification icon + unsafe + { + bool success = Shell_NotifyIconNative((uint)NOTIFY_ICON_MESSAGE.NIM_ADD, &d); + if (!success) + { + // Shell_NotifyIcon can fail if explorer.exe isn't ready yet (e.g., during system startup) + // Reset _trayIconData to allow retry via WM_WINDOWPOSCHANGING or WM_TASKBAR_RESTART + Logger.LogWarning("[TrayIcon] Shell_NotifyIcon(NIM_ADD) failed, will retry later"); + _trayIconData = null; + return; + } + } + + if (_popupMenu == 0) + { + _popupMenu = CreatePopupMenu(); + InsertMenuNative(_popupMenu, 0, (uint)(MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING), PInvoke.WM_USER + 1, GetString("TrayMenu_Settings")); + InsertMenuNative(_popupMenu, 1, (uint)(MENU_ITEM_FLAGS.MF_BYPOSITION | MENU_ITEM_FLAGS.MF_STRING), PInvoke.WM_USER + 2, GetString("TrayMenu_Exit")); + } + } + else + { + Destroy(); + } + } + + public void Destroy() + { + if (_trayIconData is not null) + { + var d = (NOTIFYICONDATAW)_trayIconData; + unsafe + { + if (Shell_NotifyIconNative((uint)NOTIFY_ICON_MESSAGE.NIM_DELETE, &d)) + { + _trayIconData = null; + } + } + } + + if (_popupMenu != 0) + { + DestroyMenu(_popupMenu); + _popupMenu = 0; + } + + if (_largeIcon != 0) + { + DestroyIcon(_largeIcon); + _largeIcon = 0; + } + + if (_window is not null) + { + _window.Close(); + _window = null; + _hwnd = 0; + } + } + + private static string GetString(string key) + { + try + { + return ResourceLoaderInstance.ResourceLoader.GetString(key); + } + catch + { + return "unknown"; + } + } + + private nint GetAppIconHandle() + { + var exePath = Path.Combine(AppContext.BaseDirectory, "PowerToys.PowerDisplay.exe"); + ExtractIconExNative(exePath, 0, out var largeIcon, out _, 1); + return largeIcon; + } + + private nint WindowProc( + nint hwnd, + uint uMsg, + nuint wParam, + nint lParam) + { + switch (uMsg) + { + case PInvoke.WM_COMMAND: + { + if (wParam == PInvoke.WM_USER + 1) + { + // Settings menu item + _openSettingsAction?.Invoke(); + } + else if (wParam == PInvoke.WM_USER + 2) + { + // Exit menu item + _exitAction?.Invoke(); + } + } + + break; + + // Shell_NotifyIcon can fail when we invoke it during the time explorer.exe isn't present/ready to handle it. + // We'll also never receive _wmTaskbarRestart message if the first call to Shell_NotifyIcon failed, so we use + // WM_WINDOWPOSCHANGING which is always received on explorer startup sequence. + case PInvoke.WM_WINDOWPOSCHANGING: + { + if (_trayIconData is null) + { + SetupTrayIcon(); + } + } + + break; + default: + // _wmTaskbarRestart isn't a compile-time constant, so we can't + // use it in a case label + if (uMsg == _wmTaskbarRestart) + { + // Handle the case where explorer.exe restarts. + // Even if we created it before, do it again + SetupTrayIcon(); + } + else if (uMsg == WmTrayIcon) + { + switch ((uint)lParam) + { + case PInvoke.WM_RBUTTONUP: + { + if (_popupMenu != 0) + { + GetCursorPos(out var cursorPos); + SetForegroundWindow(_hwnd); + TrackPopupMenuExNative(_popupMenu, (uint)TRACK_POPUP_MENU_FLAGS.TPM_LEFTALIGN | (uint)TRACK_POPUP_MENU_FLAGS.TPM_BOTTOMALIGN, cursorPos.X, cursorPos.Y, _hwnd, 0); + } + } + + break; + case PInvoke.WM_LBUTTONUP: + case PInvoke.WM_LBUTTONDBLCLK: + _toggleWindowAction?.Invoke(); + break; + } + } + + break; + } + + return CallWindowProcIntPtr(_originalWndProc, hwnd, uMsg, wParam, lParam); + } + + [LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")] + private static partial nint CallWindowProcIntPtr(IntPtr lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam); + + [LibraryImport("user32.dll", EntryPoint = "RegisterWindowMessageW", StringMarshalling = StringMarshalling.Utf16)] + private static partial uint RegisterWindowMessageNative(string lpString); + + [LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static partial nint SetWindowLongPtrNative(nint hWnd, int nIndex, nint dwNewLong); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetCursorPos(out POINT lpPoint); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetForegroundWindow(nint hWnd); + + // Shell APIs - use uint for enums and unsafe pointer for struct + [LibraryImport("shell32.dll", EntryPoint = "Shell_NotifyIconW")] + [return: MarshalAs(UnmanagedType.Bool)] + private static unsafe partial bool Shell_NotifyIconNative(uint dwMessage, NOTIFYICONDATAW* lpData); + + [LibraryImport("shell32.dll", EntryPoint = "ExtractIconExW", StringMarshalling = StringMarshalling.Utf16)] + private static partial uint ExtractIconExNative(string lpszFile, int nIconIndex, out nint phiconLarge, out nint phiconSmall, uint nIcons); + + // Menu APIs + [LibraryImport("user32.dll")] + private static partial nint CreatePopupMenu(); + + [LibraryImport("user32.dll", EntryPoint = "InsertMenuW", StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool InsertMenuNative(nint hMenu, uint uPosition, uint uFlags, nuint uIDNewItem, string? lpNewItem); + + [LibraryImport("user32.dll", EntryPoint = "TrackPopupMenuEx")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool TrackPopupMenuExNative(nint hMenu, uint uFlags, int x, int y, nint hwnd, nint lptpm); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool DestroyMenu(nint hMenu); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool DestroyIcon(nint hIcon); + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + private const int GwlWndproc = -4; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/TypePreservation.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/TypePreservation.cs new file mode 100644 index 0000000000..6ae3be94dc --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/TypePreservation.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; + +namespace PowerDisplay.Helpers +{ + /// <summary> + /// This class ensures types used in XAML are preserved during AOT compilation. + /// Framework types cannot have attributes added directly to their definitions since they're external types. + /// Use DynamicDependency to preserve all members of these WinUI3 framework types. + /// </summary> + internal static class TypePreservation + { + // Core WinUI3 Controls used in XAML + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Window))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Application))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.Grid))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.Border))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ScrollViewer))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.StackPanel))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ItemsControl))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.Slider))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.TextBlock))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.Button))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.FontIcon))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ProgressRing))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.InfoBar))] + + // Animation and Transform types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.Storyboard))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.DoubleAnimation))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.CubicEase))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.TranslateTransform))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.TransitionCollection))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.EntranceThemeTransition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.RepositionThemeTransition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Animation.EasingFunctionBase))] + + // Template and Resource types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.DataTemplate))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ItemsPanelTemplate))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Style))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.FontIconSource))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.ResourceDictionary))] + + // Text and Document types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Documents.Run))] + + // Layout types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.RowDefinition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ColumnDefinition))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.RowDefinitionCollection))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ColumnDefinitionCollection))] + + // Media types for brushes and visuals + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.SolidColorBrush))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Brush))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Media.Transform))] + + // Core UI element types + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.UIElement))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.FrameworkElement))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.DependencyObject))] + + // Thickness and other value types used in XAML (structs, not enums) + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Thickness))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.CornerRadius))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.GridLength))] + + // ToolTip service used in buttons + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Microsoft.UI.Xaml.Controls.ToolTipService))] + + public static void PreserveTypes() + { + // This method exists only to hold the DynamicDependency attributes above. + // It must be called to ensure the types are not trimmed during AOT compilation. + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/VisibilityConverter.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/VisibilityConverter.cs new file mode 100644 index 0000000000..ef3314f440 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/VisibilityConverter.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; + +namespace PowerDisplay.Helpers +{ + /// <summary> + /// Provides conversion utilities for Visibility binding in x:Bind scenarios. + /// AOT-compatible alternative to IValueConverter implementations. + /// </summary> + public static class VisibilityConverter + { + /// <summary> + /// Converts a boolean value to a Visibility value. + /// </summary> + /// <param name="value">The boolean value to convert.</param> + /// <returns>Visibility.Visible if true, Visibility.Collapsed if false.</returns> + public static Visibility BoolToVisibility(bool value) => value ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/Helpers/WindowHelper.cs b/src/modules/powerdisplay/PowerDisplay/Helpers/WindowHelper.cs new file mode 100644 index 0000000000..b5ae7a391f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/Helpers/WindowHelper.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using WinUIEx; + +namespace PowerDisplay.Helpers +{ + internal static partial class WindowHelper + { + // Cursor position structure for GetCursorPos + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + // Cursor position for detecting the monitor with the mouse + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetCursorPos(out POINT lpPoint); + + // Window Styles + private const int GwlStyle = -16; + private const int WsCaption = 0x00C00000; + private const int WsThickframe = 0x00040000; + private const int WsMinimizebox = 0x00020000; + private const int WsMaximizebox = 0x00010000; + private const int WsSysmenu = 0x00080000; + + // Extended Window Styles + private const int GwlExstyle = -20; + private const int WsExDlgmodalframe = 0x00000001; + private const int WsExWindowedge = 0x00000100; + private const int WsExClientedge = 0x00000200; + private const int WsExStaticedge = 0x00020000; + private const int WsExToolwindow = 0x00000080; + + private const uint SwpNosize = 0x0001; + private const uint SwpNomove = 0x0002; + private const uint SwpFramechanged = 0x0020; + private const nint HwndTopmost = -1; + private const nint HwndNotopmost = -2; + + // ShowWindow commands + private const int SwHide = 0; + private const int SwShow = 5; + + // P/Invoke declarations (64-bit only - PowerToys only builds for x64/ARM64) + [LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static partial nint GetWindowLongPtr(nint hWnd, int nIndex); + + [LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static partial nint SetWindowLong(nint hWnd, int nIndex, nint dwNewLong); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetWindowPos( + nint hWnd, + nint hWndInsertAfter, + int x, + int y, + int cx, + int cy, + uint uFlags); + + [LibraryImport("user32.dll", EntryPoint = "ShowWindow")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ShowWindowNative(nint hWnd, int nCmdShow); + + [LibraryImport("user32.dll", EntryPoint = "IsWindowVisible")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool IsWindowVisibleNative(nint hWnd); + + /// <summary> + /// Check if window is visible + /// </summary> + public static bool IsWindowVisible(nint hWnd) + { + return IsWindowVisibleNative(hWnd); + } + + /// <summary> + /// Disable window moving and resizing functionality + /// </summary> + public static void DisableWindowMovingAndResizing(nint hWnd) + { + // Get current window style + nint style = GetWindowLongPtr(hWnd, GwlStyle); + + // Remove resizable borders, title bar, and system menu + style &= ~WsThickframe; + style &= ~WsMaximizebox; + style &= ~WsMinimizebox; + style &= ~WsCaption; // Remove entire title bar + style &= ~WsSysmenu; // Remove system menu + + // Set new window style + _ = SetWindowLong(hWnd, GwlStyle, style); + + // Get extended style and remove related borders + nint exStyle = GetWindowLongPtr(hWnd, GwlExstyle); + exStyle &= ~WsExDlgmodalframe; + exStyle &= ~WsExWindowedge; + exStyle &= ~WsExClientedge; + exStyle &= ~WsExStaticedge; + _ = SetWindowLong(hWnd, GwlExstyle, exStyle); + + // Refresh window frame + SetWindowPos( + hWnd, + 0, + 0, + 0, + 0, + 0, + SwpNomove | SwpNosize | SwpFramechanged); + } + + /// <summary> + /// Set whether window is topmost + /// </summary> + public static void SetWindowTopmost(nint hWnd, bool topmost) + { + SetWindowPos( + hWnd, + topmost ? HwndTopmost : HwndNotopmost, + 0, + 0, + 0, + 0, + SwpNomove | SwpNosize); + } + + /// <summary> + /// Show or hide window + /// </summary> + public static void ShowWindow(nint hWnd, bool show) + { + ShowWindowNative(hWnd, show ? SwShow : SwHide); + } + + /// <summary> + /// Hide window from taskbar + /// </summary> + public static void HideFromTaskbar(nint hWnd) + { + // Get current extended style + nint exStyle = GetWindowLongPtr(hWnd, GwlExstyle); + + // Add WS_EX_TOOLWINDOW style to hide window from taskbar + exStyle |= WsExToolwindow; + + // Set new extended style + _ = SetWindowLong(hWnd, GwlExstyle, exStyle); + + // Refresh window frame + SetWindowPos( + hWnd, + 0, + 0, + 0, + 0, + 0, + SwpNomove | SwpNosize | SwpFramechanged); + } + + /// <summary> + /// Get the DPI scale factor for a window (relative to standard 96 DPI) + /// </summary> + /// <param name="window">WinUIEx window</param> + /// <returns>DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%)</returns> + public static double GetDpiScale(WindowEx window) + { + return (float)window.GetDpiForWindow() / 96.0; + } + + /// <summary> + /// Convert device-independent units (DIU) to physical pixels + /// </summary> + /// <param name="diu">Device-independent unit value</param> + /// <param name="dpiScale">DPI scale factor</param> + /// <returns>Physical pixel value</returns> + public static int ScaleToPhysicalPixels(int diu, double dpiScale) + { + return (int)Math.Ceiling(diu * dpiScale); + } + + /// <summary> + /// Position a window at the bottom-right corner of the monitor where the mouse cursor is located. + /// Correctly handles all edge cases: + /// - Multi-monitor setups + /// - Taskbar at any position (top/bottom/left/right) + /// - Different DPI settings + /// </summary> + /// <param name="window">WinUIEx window to position</param> + /// <param name="width">Window width in device-independent units (DIU)</param> + /// <param name="height">Window height in device-independent units (DIU)</param> + /// <param name="rightMargin">Right margin in device-independent units (DIU)</param> + public static void PositionWindowBottomRight( + WindowEx window, + int width, + int height, + int rightMargin = 0) + { + // RectWork already includes correct offsets for taskbar position + var monitors = MonitorInfo.GetDisplayMonitors(); + if (monitors == null || monitors.Count == 0) + { + ManagedCommon.Logger.LogWarning("PositionWindowBottomRight: No monitors found, skipping positioning"); + return; + } + + // Find the monitor where the mouse cursor is located + var targetMonitor = GetMonitorAtCursor(monitors); + var workArea = targetMonitor.RectWork; + double dpiScale = GetDpiScale(window); + + // Calculate bottom-right position + // RectWork.Right/Bottom already account for taskbar position + double x = workArea.Right - (dpiScale * (width + rightMargin)); + double y = workArea.Bottom - (dpiScale * height); + + window.MoveAndResize(x, y, width, height); + } + + /// <summary> + /// Get the monitor where the mouse cursor is currently located. + /// Falls back to primary monitor if cursor position cannot be determined. + /// </summary> + /// <param name="monitors">List of available monitors</param> + /// <returns>MonitorInfo of the monitor containing the cursor</returns> + private static MonitorInfo GetMonitorAtCursor(IList<MonitorInfo> monitors) + { + // Try to get cursor position using Win32 API + if (GetCursorPos(out var cursorPos)) + { + // Find the monitor that contains the cursor point + foreach (var monitor in monitors) + { + if (cursorPos.X >= monitor.RectMonitor.Left && + cursorPos.X < monitor.RectMonitor.Right && + cursorPos.Y >= monitor.RectMonitor.Top && + cursorPos.Y < monitor.RectMonitor.Bottom) + { + return monitor; + } + } + } + + // Fallback to first monitor (typically primary) + return monitors[0]; + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/NativeMethods.json b/src/modules/powerdisplay/PowerDisplay/NativeMethods.json new file mode 100644 index 0000000000..450ecacafd --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/NativeMethods.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "public": true, + "allowMarshaling": false +} diff --git a/src/modules/powerdisplay/PowerDisplay/NativeMethods.txt b/src/modules/powerdisplay/PowerDisplay/NativeMethods.txt new file mode 100644 index 0000000000..754b73be27 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/NativeMethods.txt @@ -0,0 +1,17 @@ +// Structs and types only - functions use LibraryImport for AOT compatibility +NOTIFYICONDATAW +NOTIFY_ICON_MESSAGE +NOTIFY_ICON_DATA_FLAGS +MENU_ITEM_FLAGS +TRACK_POPUP_MENU_FLAGS + +// Window message constants (used by TrayIconService) +WM_USER +WM_COMMAND +WM_RBUTTONUP +WM_LBUTTONUP +WM_LBUTTONDBLCLK +WM_WINDOWPOSCHANGING + +// COM wait flags for single instance redirection (constants only) +CWMO_FLAGS diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj b/src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj new file mode 100644 index 0000000000..78107c261b --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj @@ -0,0 +1,104 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" /> + <PropertyGroup> + <OutputType>WinExe</OutputType> + <RootNamespace>PowerDisplay</RootNamespace> + <ApplicationManifest>app.manifest</ApplicationManifest> + <ApplicationIcon>Assets\PowerDisplay\PowerDisplay.ico</ApplicationIcon> + <Platforms>x64;ARM64</Platforms> + <UseWinUI>true</UseWinUI> + <EnableMsixTooling>true</EnableMsixTooling> + <EnablePreviewMsixTooling>true</EnablePreviewMsixTooling> + <WindowsPackageType>None</WindowsPackageType> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> + <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <AssemblyName>PowerToys.PowerDisplay</AssemblyName> + <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> + <ProjectPriFileName>PowerToys.PowerDisplay.pri</ProjectPriFileName> + <AllowUnsafeBlocks>true</AllowUnsafeBlocks> + <Nullable>enable</Nullable> + <!-- Disable XAML-generated Main method, use custom Program.cs --> + <DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants> + </PropertyGroup> + <!-- Native AOT Configuration --> + <PropertyGroup> + <PublishSingleFile>false</PublishSingleFile> + <DisableRuntimeMarshalling>false</DisableRuntimeMarshalling> + <PublishAot>true</PublishAot> + <TrimmerSingleWarn>false</TrimmerSingleWarn> + <SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings> + </PropertyGroup> + <ItemGroup> + <!-- Hide build log files from Solution Explorer --> + <None Remove="*.log" /> + <None Remove="*.binlog" /> + </ItemGroup> + <ItemGroup> + <!-- Add WindowsDesktop.App framework reference to align Microsoft.VisualBasic.dll version + with other projects that use UseWPF/UseWindowsForms. This does NOT enable WPF/WinForms, + it only ensures consistent runtime DLL versions across all WinUI3Apps. --> + <FrameworkReference Include="Microsoft.WindowsDesktop.App" /> + </ItemGroup> + <ItemGroup> + <Page Remove="PowerDisplayXAML\App.xaml" /> + </ItemGroup> + <ItemGroup> + <ApplicationDefinition Include="PowerDisplayXAML\App.xaml" /> + </ItemGroup> + <ItemGroup> + <Folder Include="PowerDisplayXAML\" /> + </ItemGroup> + <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="Microsoft.Windows.SDK.BuildTools" /> + <PackageReference Include="WmiLight" /> + <PackageReference Include="WinUIEx" /> + <PackageReference Include="System.Collections.Immutable" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" /> + <PackageReference Include="CommunityToolkit.WinUI.Animations" /> + <PackageReference Include="CommunityToolkit.WinUI.Extensions" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.Segmented" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.Sizers" /> + <PackageReference Include="CommunityToolkit.WinUI.Converters" /> + <PackageReference Include="CommunityToolkit.Mvvm" /> + <PackageReference Include="Microsoft.Windows.CsWin32"> + <PrivateAssets>all</PrivateAssets> + </PackageReference> + <Manifest Include="$(ApplicationManifest)" /> + </ItemGroup> + <!-- + Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging + Tools extension to be activated for this project even if the Windows App SDK Nuget + package has not yet been restored. + --> + <ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'"> + <ProjectCapability Include="Msix" /> + </ItemGroup> + <ItemGroup> + <!-- Removed Common.UI dependency - SettingsDeepLink is now implemented locally --> + <ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <!-- Removed ManagedCsWin32 - using CsWin32 directly for TrayIcon APIs --> + <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" /> + <ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" /> + <ProjectReference Include="..\PowerDisplay.Lib\PowerDisplay.Lib.csproj" /> + </ItemGroup> + <!-- Copy Assets folder to output directory --> + <ItemGroup> + <Content Include="Assets\PowerDisplay\*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + <!-- + Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution + Explorer "Package and Publish" context menu entry to be enabled for this project even if + the Windows App SDK Nuget package has not yet been restored. + --> + <PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'"> + <HasPackageAndPublishMenu>true</HasPackageAndPublishMenu> + </PropertyGroup> +</Project> \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml new file mode 100644 index 0000000000..9822ec0c29 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8" ?> +<Application + x:Class="PowerDisplay.App" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:toolkit="using:CommunityToolkit.WinUI"> + <Application.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <!-- WinUI 3 System Resources --> + <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> + </ResourceDictionary.MergedDictionaries> + </ResourceDictionary> + </Application.Resources> +</Application> \ No newline at end of file diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs new file mode 100644 index 0000000000..6e6d91df76 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/App.xaml.cs @@ -0,0 +1,379 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.Windows.AppLifecycle; +using PowerDisplay.Common; +using PowerDisplay.Helpers; +using PowerDisplay.Serialization; +using PowerToys.Interop; + +namespace PowerDisplay +{ + /// <summary> + /// PowerDisplay application main class + /// </summary> + public partial class App : Application + { + private readonly SettingsUtils _settingsUtils = SettingsUtils.Default; + private Window? _mainWindow; + private int _powerToysRunnerPid; + private string? _pipeName; + private TrayIconService? _trayIconService; + + public App(int runnerPid, string? pipeName) + { + Logger.LogInfo($"App constructor: Starting with runnerPid={runnerPid}, pipeName={pipeName ?? "null"}"); + _powerToysRunnerPid = runnerPid; + _pipeName = pipeName; + + Logger.LogTrace("App constructor: Calling InitializeComponent"); + this.InitializeComponent(); + + // Ensure types used in XAML are preserved for AOT compilation + TypePreservation.PreserveTypes(); + + // Note: Logger is already initialized in Program.cs before App constructor + Logger.LogTrace("App constructor: InitializeComponent completed"); + + // Initialize PowerToys telemetry + try + { + PowerToysTelemetry.Log.WriteEvent(new Telemetry.Events.PowerDisplayStartEvent()); + Logger.LogTrace("App constructor: Telemetry event sent"); + } + catch (Exception ex) + { + Logger.LogWarning($"App constructor: Telemetry failed: {ex.Message}"); + } + + // Initialize language settings + string appLanguage = LanguageHelper.LoadLanguage(); + if (!string.IsNullOrEmpty(appLanguage)) + { + Microsoft.Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = appLanguage; + Logger.LogTrace($"App constructor: Language set to {appLanguage}"); + } + + // Handle unhandled exceptions + this.UnhandledException += OnUnhandledException; + Logger.LogInfo("App constructor: Completed"); + } + + /// <summary> + /// Handle unhandled exceptions + /// </summary> + private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e) + { + Logger.LogError("Unhandled exception", e.Exception); + } + + /// <summary> + /// Called when the application is launched + /// </summary> + /// <param name="args">Launch arguments</param> + protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + { + Logger.LogInfo("OnLaunched: Application launching"); + try + { + // Single instance is already ensured by AppInstance.FindOrRegisterForKey() in Program.cs + // PID is already parsed in Program.cs and passed to constructor + + // Set up Windows Events monitoring (Awake pattern) + // Note: PowerDisplay.exe should NOT listen to RefreshMonitorsEvent + // That event is sent BY PowerDisplay TO Settings UI for one-way notification + Logger.LogInfo("OnLaunched: Registering Windows Events for IPC..."); + RegisterWindowEvent(Constants.TogglePowerDisplayEvent(), mw => mw.ToggleWindow(), "Toggle"); + Logger.LogTrace($"OnLaunched: Registered Toggle event: {Constants.TogglePowerDisplayEvent()}"); + RegisterEvent(Constants.TerminatePowerDisplayEvent(), () => Environment.Exit(0), "Terminate"); + Logger.LogTrace($"OnLaunched: Registered Terminate event: {Constants.TerminatePowerDisplayEvent()}"); + RegisterWindowEvent( + Constants.SettingsUpdatedPowerDisplayEvent(), + mw => + { + mw.ViewModel.ApplySettingsFromUI(); + + // Refresh tray icon based on updated settings + _trayIconService?.SetupTrayIcon(); + }, + "SettingsUpdated"); + RegisterWindowEvent( + Constants.HotkeyUpdatedPowerDisplayEvent(), + mw => mw.ReloadHotkeySettings(), + "HotkeyUpdated"); + RegisterViewModelEvent(Constants.PowerDisplaySendSettingsTelemetryEvent(), vm => vm.SendSettingsTelemetry(), "SendSettingsTelemetry"); + + // LightSwitch integration - apply profiles when theme changes + RegisterViewModelEvent(PathConstants.LightSwitchLightThemeEventName, vm => vm.ApplyLightSwitchProfile(isLightMode: true), "LightSwitch-Light"); + RegisterViewModelEvent(PathConstants.LightSwitchDarkThemeEventName, vm => vm.ApplyLightSwitchProfile(isLightMode: false), "LightSwitch-Dark"); + Logger.LogInfo("OnLaunched: All Windows Events registered"); + + // Connect to Named Pipe for IPC with module DLL (if pipe name provided) + if (!string.IsNullOrEmpty(_pipeName)) + { + Logger.LogInfo($"OnLaunched: Starting Named Pipe processing for pipe: {_pipeName}"); + ProcessNamedPipe(_pipeName); + } + else + { + Logger.LogInfo("OnLaunched: No pipe name provided, skipping Named Pipe setup"); + } + + // Monitor Runner process (backup exit mechanism) + if (_powerToysRunnerPid > 0) + { + Logger.LogInfo($"OnLaunched: PowerDisplay started from PowerToys Runner. Runner pid={_powerToysRunnerPid}"); + + RunnerHelper.WaitForPowerToysRunner(_powerToysRunnerPid, () => + { + Logger.LogInfo("OnLaunched: PowerToys Runner exited. Exiting PowerDisplay"); + Environment.Exit(0); + }); + } + else + { + Logger.LogInfo("OnLaunched: PowerDisplay started in standalone mode (no runner PID)"); + } + + // Create main window + Logger.LogInfo("OnLaunched: Creating MainWindow"); + _mainWindow = new MainWindow(); + Logger.LogInfo("OnLaunched: MainWindow created"); + + // Initialize tray icon service + Logger.LogTrace("OnLaunched: Initializing TrayIconService"); + _trayIconService = new TrayIconService( + _settingsUtils, + ToggleMainWindow, + () => Environment.Exit(0), + OpenSettings); + _trayIconService.SetupTrayIcon(); + Logger.LogTrace("OnLaunched: TrayIconService initialized"); + + // Window visibility depends on launch mode + bool isStandaloneMode = _powerToysRunnerPid <= 0; + Logger.LogInfo($"OnLaunched: isStandaloneMode={isStandaloneMode}"); + + if (isStandaloneMode) + { + // Standalone mode - activate and show window immediately + Logger.LogInfo("OnLaunched: Activating window (standalone mode)"); + _mainWindow.Activate(); + Logger.LogInfo("OnLaunched: Window activated (standalone mode)"); + } + else + { + // PowerToys mode - window remains hidden until show event received + // Background initialization runs automatically via MainWindow constructor + Logger.LogInfo("OnLaunched: Window created but hidden, waiting for show/toggle event (PowerToys mode)"); + } + + Logger.LogInfo("OnLaunched: Application launch completed"); + } + catch (Exception ex) + { + Logger.LogError($"OnLaunched: PowerDisplay startup failed: {ex.Message}\n{ex.StackTrace}"); + } + } + + /// <summary> + /// Register a simple event handler (no window access needed) + /// </summary> + private void RegisterEvent(string eventName, Action action, string logName) + { + Logger.LogTrace($"RegisterEvent: Setting up event listener for '{logName}' on event '{eventName}'"); + NativeEventWaiter.WaitForEventLoop( + eventName, + () => + { + Logger.LogInfo($"[EVENT] {logName} event received from event '{eventName}'"); + try + { + action(); + Logger.LogTrace($"[EVENT] {logName} action completed"); + } + catch (Exception ex) + { + Logger.LogError($"[EVENT] {logName} action failed: {ex.Message}"); + } + }, + CancellationToken.None); + } + + /// <summary> + /// Register an event handler that operates on MainWindow directly + /// NativeEventWaiter already marshals to UI thread + /// </summary> + private void RegisterWindowEvent(string eventName, Action<MainWindow> action, string logName) + { + Logger.LogTrace($"RegisterWindowEvent: Setting up window event listener for '{logName}' on event '{eventName}'"); + NativeEventWaiter.WaitForEventLoop( + eventName, + () => + { + Logger.LogInfo($"[EVENT] {logName} window event received from event '{eventName}'"); + if (_mainWindow is MainWindow mainWindow) + { + Logger.LogTrace($"[EVENT] {logName}: MainWindow is valid, invoking action"); + try + { + action(mainWindow); + Logger.LogTrace($"[EVENT] {logName}: Window action completed"); + } + catch (Exception ex) + { + Logger.LogError($"[EVENT] {logName}: Window action failed: {ex.Message}"); + } + } + else + { + Logger.LogError($"[EVENT] {logName}: _mainWindow is null or not MainWindow type"); + } + }, + CancellationToken.None); + } + + /// <summary> + /// Register an event handler that operates on ViewModel via DispatcherQueue + /// Used for Settings UI IPC events that need ViewModel access + /// </summary> + private void RegisterViewModelEvent(string eventName, Action<ViewModels.MainViewModel> action, string logName) + { + NativeEventWaiter.WaitForEventLoop( + eventName, + () => + { + Logger.LogInfo($"[EVENT] {logName} event received"); + _mainWindow?.DispatcherQueue.TryEnqueue(() => + { + if (_mainWindow is MainWindow mainWindow && mainWindow.ViewModel != null) + { + action(mainWindow.ViewModel); + } + }); + }, + CancellationToken.None); + } + + /// <summary> + /// Gets the main window instance + /// </summary> + public Window? MainWindow => _mainWindow; + + /// <summary> + /// Toggle the main window visibility + /// </summary> + private void ToggleMainWindow() + { + Logger.LogInfo("ToggleMainWindow: Called"); + if (_mainWindow is MainWindow mainWindow) + { + Logger.LogTrace($"ToggleMainWindow: MainWindow is valid, current visibility={mainWindow.IsWindowVisible()}"); + mainWindow.ToggleWindow(); + } + else + { + Logger.LogError("ToggleMainWindow: _mainWindow is null or not MainWindow type"); + } + } + + /// <summary> + /// Open PowerDisplay settings in PowerToys Settings UI + /// </summary> + private void OpenSettings() + { + // mainExecutableIsOnTheParentFolder = true because PowerDisplay is a WinUI 3 app + // deployed in a subfolder (PowerDisplay\) while PowerToys.exe is in the parent folder + SettingsDeepLink.OpenSettings(true); + } + + /// <summary> + /// Refresh tray icon based on current settings + /// </summary> + public void RefreshTrayIcon() + { + _trayIconService?.SetupTrayIcon(); + } + + /// <summary> + /// Check if running standalone (not launched from PowerToys Runner) + /// </summary> + public bool IsRunningDetachedFromPowerToys() + { + return _powerToysRunnerPid == -1; + } + + /// <summary> + /// Shutdown application (Awake pattern - simple and clean) + /// </summary> + public void Shutdown() + { + Logger.LogInfo("PowerDisplay shutting down"); + _trayIconService?.Destroy(); + Environment.Exit(0); + } + + /// <summary> + /// Connect to Named Pipe and process messages from module DLL + /// </summary> + private void ProcessNamedPipe(string pipeName) + { + void OnMessage(string message) => _mainWindow?.DispatcherQueue.TryEnqueue(async () => await OnNamedPipeMessage(message)); + + Task.Run(async () => await NamedPipeProcessor.ProcessNamedPipeAsync( + pipeName, + connectTimeout: TimeSpan.FromSeconds(10), + OnMessage, + CancellationToken.None)); + } + + /// <summary> + /// Handle messages received from the module DLL via Named Pipe + /// </summary> + private async Task OnNamedPipeMessage(string message) + { + var messageParts = message.Split(' ', 2); + var messageType = messageParts[0]; + + Logger.LogInfo($"[NamedPipe] Processing message type: {messageType}"); + + if (messageType == Constants.PowerDisplayToggleMessage()) + { + // Toggle window visibility + if (_mainWindow is MainWindow mainWindow) + { + mainWindow.ToggleWindow(); + } + } + else if (messageType == Constants.PowerDisplayApplyProfileMessage()) + { + // Apply profile by name + if (messageParts.Length > 1 && _mainWindow is MainWindow mainWindow && mainWindow.ViewModel != null) + { + var profileName = messageParts[1].Trim(); + Logger.LogInfo($"[NamedPipe] Applying profile: {profileName}"); + await mainWindow.ViewModel.ApplyProfileByNameAsync(profileName); + } + } + else if (messageType == Constants.PowerDisplayTerminateAppMessage()) + { + // Terminate the application + Logger.LogInfo("[NamedPipe] Received terminate message"); + Shutdown(); + } + else + { + Logger.LogWarning($"[NamedPipe] Unknown message type: {messageType}"); + } + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml new file mode 100644 index 0000000000..fccc9ce0f6 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml @@ -0,0 +1,24 @@ +<winuiex:WindowEx + x:Class="PowerDisplay.PowerDisplayXAML.IdentifyWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:winuiex="using:WinUIEx" + IsMaximizable="False" + IsMinimizable="False" + IsResizable="False" + IsTitleBarVisible="False" + mc:Ignorable="d"> + <Grid Background="#1A000000"> + <TextBlock + x:Name="NumberText" + HorizontalAlignment="Center" + VerticalAlignment="Center" + FontFamily="Segoe UI" + FontSize="200" + FontWeight="Bold" + Foreground="White" + Text="1" /> + </Grid> +</winuiex:WindowEx> diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml.cs b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml.cs new file mode 100644 index 0000000000..63ec38f150 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/IdentifyWindow.xaml.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; +using System.Threading.Tasks; +using Microsoft.UI.Windowing; +using Windows.Graphics; +using WinUIEx; + +namespace PowerDisplay.PowerDisplayXAML +{ + /// <summary> + /// Interaction logic for IdentifyWindow.xaml + /// </summary> + public sealed partial class IdentifyWindow : WindowEx + { + // Window size in device-independent units (DIU) + private const int WindowWidthDiu = 300; + private const int WindowHeightDiu = 280; + + private double _dpiScale = 1.0; + + public IdentifyWindow(string displayText) + { + InitializeComponent(); + NumberText.Text = displayText; + try + { + this.SetIsShownInSwitchers(false); + } + catch (NotImplementedException) + { + // WinUI will throw if explorer is not running, safely ignore + } + catch (Exception) + { + } + + // Configure window style + ConfigureWindow(); + + // Auto close after 3 seconds + Task.Delay(3000).ContinueWith(_ => + { + DispatcherQueue.TryEnqueue(() => + { + Close(); + }); + }); + } + + private void ConfigureWindow() + { + _dpiScale = this.GetDpiForWindow() / 96.0; + + // Set window size scaled for DPI + // AppWindow.Resize expects physical pixels + int physicalWidth = (int)(WindowWidthDiu * _dpiScale); + int physicalHeight = (int)(WindowHeightDiu * _dpiScale); + this.AppWindow.Resize(new SizeInt32 { Width = physicalWidth, Height = physicalHeight }); + this.IsAlwaysOnTop = true; + } + + /// <summary> + /// Position the window at the center of the specified display area + /// </summary> + public void PositionOnDisplay(DisplayArea displayArea) + { + var workArea = displayArea.WorkArea; + + // Window size in physical pixels (already scaled for DPI) + int physicalWidth = (int)(WindowWidthDiu * _dpiScale); + int physicalHeight = (int)(WindowHeightDiu * _dpiScale); + + // Calculate center position (WorkArea coordinates are in physical pixels) + int x = workArea.X + ((workArea.Width - physicalWidth) / 2); + int y = workArea.Y + ((workArea.Height - physicalHeight) / 2); + + // Use WindowEx's AppWindow property + this.AppWindow.Move(new PointInt32(x, y)); + } + } +} diff --git a/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml new file mode 100644 index 0000000000..137894fd3f --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml @@ -0,0 +1,551 @@ +<winuiex:WindowEx + x:Class="PowerDisplay.MainWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:animatedVisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals" + xmlns:helpers="using:PowerDisplay.Helpers" + xmlns:local="using:PowerDisplay" + xmlns:models="using:PowerDisplay.Common.Models" + xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" + xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:vm="using:PowerDisplay.ViewModels" + xmlns:winuiex="using:WinUIEx" + MinWidth="0" + MinHeight="0" + IsMaximizable="False" + IsMinimizable="False" + IsResizable="False" + IsTitleBarVisible="False"> + <winuiex:WindowEx.SystemBackdrop> + <DesktopAcrylicBackdrop /> + </winuiex:WindowEx.SystemBackdrop> + + <Grid + x:Name="RootGrid" + IsTabStop="True" + TabFocusNavigation="Local"> + <Grid.Resources> + <Style + x:Key="FlyoutButtonStyle" + BasedOn="{StaticResource SubtleButtonStyle}" + TargetType="Button"> + <Setter Property="Padding" Value="6" /> + <Setter Property="Width" Value="32" /> + <Setter Property="Height" Value="32" /> + <Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" /> + </Style> + </Grid.Resources> + <Border x:Name="MainContainer"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="48" /> + </Grid.RowDefinitions> + + <!-- Main Content Area with modern design --> + <Border + x:Name="ContentArea" + Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="0,0,0,1"> + <Grid> + <StackPanel + Margin="0,16,0,16" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Orientation="Vertical" + Spacing="16" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.IsScanning), Mode=OneWay}"> + <ProgressRing + Width="24" + Height="24" + Foreground="{ThemeResource AccentFillColorDefaultBrush}" + IsActive="True" /> + <TextBlock + x:Name="ScanningMonitorsTextBlock" + x:Uid="ScanningMonitorsText" + HorizontalAlignment="Center" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextAlignment="Center" /> + </StackPanel> + + <!-- No Monitors State with InfoBar --> + <InfoBar + x:Name="NoMonitorsInfoBar" + x:Uid="NoMonitorsText" + IconSource="{ui:FontIconSource Glyph=}" + IsClosable="False" + IsOpen="{x:Bind ViewModel.ShowNoMonitorsMessage, Mode=OneWay}" + Severity="Informational" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowNoMonitorsMessage), Mode=OneWay}" /> + + <!-- Content Area --> + <ScrollViewer + x:Name="MainScrollViewer" + Padding="16,16,16,16" + VerticalAlignment="Stretch" + HorizontalScrollBarVisibility="Disabled" + HorizontalScrollMode="Disabled" + IsTabStop="False" + VerticalScrollBarVisibility="Auto" + ZoomMode="Disabled"> + <!-- Monitors List with modern card design --> + <ItemsRepeater + x:Name="MonitorsRepeater" + HorizontalAlignment="Stretch" + ItemsSource="{x:Bind ViewModel.Monitors, Mode=OneWay}" + TabFocusNavigation="Local" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.HasMonitors), Mode=OneWay}"> + <ItemsRepeater.Layout> + <StackLayout Orientation="Vertical" Spacing="32" /> + </ItemsRepeater.Layout> + <ItemsRepeater.ItemTemplate> + <DataTemplate x:DataType="vm:MonitorViewModel"> + <StackPanel + HorizontalAlignment="Stretch" + Spacing="4" + TabFocusNavigation="Local"> + <!-- Monitor Name with Icon --> + <Grid Margin="2,0,0,0" HorizontalAlignment="Stretch"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="22" /> + <ColumnDefinition Width="16" /> + <!-- Spacing --> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <local:MonitorIcon + VerticalAlignment="Center" + IsBuiltIn="{x:Bind IsInternal, Mode=OneWay}" + MonitorNumber="{x:Bind MonitorNumber, Mode=OneWay}" /> + <TextBlock + Grid.Column="2" + Margin="0,0,0,2" + VerticalAlignment="Center" + Text="{x:Bind DisplayName, Mode=OneWay}" /> + <!-- Icon buttons for InputSource and ColorTemperature --> + <StackPanel + Grid.Column="2" + HorizontalAlignment="Right" + Orientation="Horizontal" + Spacing="4"> + <!-- Color Temperature Button --> + <Button + x:Uid="ColorTemperatureTooltip" + Content="{ui:FontIcon Glyph=, + FontSize=16}" + IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}" + Style="{StaticResource SubtleButtonStyle}" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowColorTemperature), Mode=OneWay}"> + <Button.Flyout> + <Flyout Opened="Flyout_Opened" ShouldConstrainToRootBounds="False"> + <StackPanel Orientation="Vertical"> + <ListView + ItemsSource="{x:Bind AvailableColorPresets, Mode=OneWay}" + SelectionChanged="ColorTemperatureListView_SelectionChanged" + SelectionMode="Single"> + <ListView.Header> + <TextBlock + x:Uid="ColorTemperatureHeader" + Margin="16,0,8,0" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </ListView.Header> + <ListView.ItemTemplate> + <DataTemplate x:DataType="vm:ColorTemperatureItem"> + <Grid Padding="0,4"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <TextBlock VerticalAlignment="Center" Text="{x:Bind DisplayName}" /> + <FontIcon + Grid.Column="1" + Margin="8,0,0,0" + FontSize="12" + Glyph="" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsSelected)}" /> + </Grid> + </DataTemplate> + </ListView.ItemTemplate> + </ListView> + </StackPanel> + </Flyout> + </Button.Flyout> + </Button> + <!-- More Button (Input Source + Power State) --> + <Button + x:Uid="MoreOptionsTooltip" + Content="{ui:FontIcon Glyph=, + FontSize=16}" + Style="{StaticResource SubtleButtonStyle}" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowMoreButton), Mode=OneWay}"> + <Button.Flyout> + <Flyout + Opened="Flyout_Opened" + Placement="BottomEdgeAlignedRight" + ShouldConstrainToRootBounds="False"> + <StackPanel + MinWidth="240" + MaxWidth="320" + Orientation="Vertical"> + <!-- Input Source Section --> + <StackPanel Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowInputSource), Mode=OneWay}"> + <ListView + ItemsSource="{x:Bind AvailableInputSources, Mode=OneWay}" + SelectionChanged="InputSourceListView_SelectionChanged" + SelectionMode="Single"> + <ListView.Header> + <TextBlock + x:Uid="InputSourceHeader" + Margin="16,0,8,0" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </ListView.Header> + <ListView.ItemTemplate> + <DataTemplate x:DataType="vm:InputSourceItem"> + <Grid Padding="0,4"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <TextBlock VerticalAlignment="Center" Text="{x:Bind Name}" /> + <FontIcon + Grid.Column="1" + Margin="8,0,0,0" + FontSize="12" + Glyph="" + Visibility="{x:Bind SelectionVisibility}" /> + </Grid> + </DataTemplate> + </ListView.ItemTemplate> + </ListView> + </StackPanel> + <!-- Separator between Input Source and Power State --> + <Border + Height="1" + Margin="8,8" + Background="{ThemeResource DividerStrokeColorDefaultBrush}" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowSeparatorAfterInputSource), Mode=OneWay}" /> + <!-- Power State Section --> + <StackPanel Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowPowerState), Mode=OneWay}"> + <ListView + ItemsSource="{x:Bind AvailablePowerStates, Mode=OneWay}" + SelectionChanged="PowerStateListView_SelectionChanged" + SelectionMode="Single"> + <ListView.Header> + <TextBlock + x:Uid="PowerStateHeader" + Margin="16,0,8,0" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </ListView.Header> + <ListView.ItemTemplate> + <DataTemplate x:DataType="vm:PowerStateItem"> + <Grid Padding="0,4"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <TextBlock VerticalAlignment="Center" Text="{x:Bind Name}" /> + <FontIcon + Grid.Column="1" + Margin="8,0,0,0" + FontSize="12" + Glyph="" + Visibility="{x:Bind SelectionVisibility}" /> + </Grid> + </DataTemplate> + </ListView.ItemTemplate> + </ListView> + </StackPanel> + </StackPanel> + </Flyout> + </Button.Flyout> + </Button> + </StackPanel> + </Grid> + <StackPanel + Margin="0,8,0,0" + Padding="8,0,16,8" + Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="{StaticResource OverlayCornerRadius}" + TabFocusNavigation="Local" + XYFocusKeyboardNavigation="Enabled"> + <!-- Brightness Control --> + <Grid Margin="0,8,0,0" HorizontalAlignment="Stretch"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="16" /> + <ColumnDefinition Width="16" /> + <!-- Spacing --> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <FontIcon + x:Uid="BrightnessTooltip" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + FontSize="18" + Glyph="" /> + <Slider + x:Uid="BrightnessAutomation" + Grid.Column="2" + HorizontalAlignment="Stretch" + VerticalAlignment="Center" + DataContext="{x:Bind}" + IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}" + IsTabStop="True" + KeyUp="Slider_KeyUp" + Maximum="{x:Bind MaxBrightness, Mode=OneWay}" + Minimum="{x:Bind MinBrightness, Mode=OneWay}" + PointerCaptureLost="Slider_PointerCaptureLost" + Tag="Brightness" + Value="{x:Bind Brightness, Mode=OneWay}" /> + </Grid> + + <!-- Contrast Control --> + <Grid + Margin="0,8,0,0" + HorizontalAlignment="Stretch" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowContrast), Mode=OneWay}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="16" /> + <ColumnDefinition Width="16" /> + <!-- Spacing --> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + + <FontIcon + x:Uid="ContrastTooltip" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + FontSize="12" + Glyph="" /> + + <Slider + x:Uid="ContrastAutomation" + Grid.Column="2" + VerticalAlignment="Center" + DataContext="{x:Bind}" + IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}" + IsTabStop="True" + KeyUp="Slider_KeyUp" + Maximum="100" + Minimum="0" + PointerCaptureLost="Slider_PointerCaptureLost" + Tag="Contrast" + Value="{x:Bind ContrastPercent, Mode=OneWay}" /> + </Grid> + + <!-- Volume Control --> + <Grid + Margin="0,8,0,0" + HorizontalAlignment="Stretch" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowVolume), Mode=OneWay}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="16" /> + <ColumnDefinition Width="16" /> + <!-- Spacing --> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + + <FontIcon + x:Uid="VolumeTooltip" + Margin="2,0,0,0" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + FontSize="16" + Glyph="" /> + <Slider + x:Uid="VolumeAutomation" + Grid.Column="2" + HorizontalAlignment="Stretch" + VerticalAlignment="Center" + DataContext="{x:Bind}" + IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}" + IsTabStop="True" + KeyUp="Slider_KeyUp" + Maximum="{x:Bind MaxVolume, Mode=OneWay}" + Minimum="{x:Bind MinVolume, Mode=OneWay}" + PointerCaptureLost="Slider_PointerCaptureLost" + Tag="Volume" + Value="{x:Bind Volume, Mode=OneWay}" /> + </Grid> + + <!-- Rotation Controls --> + <Grid + Margin="0,8,0,0" + HorizontalAlignment="Stretch" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowRotation), Mode=OneWay}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="16" /> + <ColumnDefinition Width="16" /> + <!-- Spacing --> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + + <FontIcon + x:Uid="RotationTooltip" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + FontSize="16" + Glyph="" /> + + <Grid + Grid.Column="2" + HorizontalAlignment="Stretch" + TabFocusNavigation="Local" + XYFocusKeyboardNavigation="Enabled"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="4" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="4" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="4" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <!-- Normal (0°) --> + <ToggleButton + x:Uid="RotateNormalTooltip" + Grid.Column="0" + HorizontalAlignment="Stretch" + Click="RotationButton_Click" + DataContext="{x:Bind}" + IsChecked="{x:Bind IsRotation0, Mode=OneWay}" + IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}" + IsTabStop="True" + Tag="0"> + <FontIcon FontSize="14" Glyph="" /> + </ToggleButton> + <!-- Left (270°) --> + <ToggleButton + x:Uid="RotateLeftTooltip" + Grid.Column="2" + HorizontalAlignment="Stretch" + Click="RotationButton_Click" + DataContext="{x:Bind}" + IsChecked="{x:Bind IsRotation3, Mode=OneWay}" + IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}" + IsTabStop="True" + Tag="3"> + <FontIcon FontSize="14" Glyph="" /> + </ToggleButton> + <!-- Right (90°) --> + <ToggleButton + x:Uid="RotateRightTooltip" + Grid.Column="4" + HorizontalAlignment="Stretch" + Click="RotationButton_Click" + DataContext="{x:Bind}" + IsChecked="{x:Bind IsRotation1, Mode=OneWay}" + IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}" + IsTabStop="True" + Tag="1"> + <FontIcon FontSize="14" Glyph="" /> + </ToggleButton> + <!-- Inverted (180°) --> + <ToggleButton + x:Uid="RotateInvertedTooltip" + Grid.Column="6" + HorizontalAlignment="Stretch" + Click="RotationButton_Click" + DataContext="{x:Bind}" + IsChecked="{x:Bind IsRotation2, Mode=OneWay}" + IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}" + IsTabStop="True" + Tag="2"> + <FontIcon FontSize="14" Glyph="" /> + </ToggleButton> + </Grid> + </Grid> + </StackPanel> + </StackPanel> + </DataTemplate> + </ItemsRepeater.ItemTemplate> + </ItemsRepeater> + </ScrollViewer> + </Grid> + </Border> + + <Grid x:Name="StatusBar" Grid.Row="1"> + <!-- Action Buttons --> + <StackPanel + Margin="0,0,8,8" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Orientation="Horizontal" + Spacing="8"> + <Button + x:Name="ProfilesButton" + x:Uid="ProfilesTooltip" + Content="{ui:FontIcon Glyph=, + FontSize=16}" + Style="{StaticResource SubtleButtonStyle}" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowProfileSwitcherButton), Mode=OneWay}"> + <Button.Flyout> + <Flyout + x:Name="ProfilesFlyout" + Opened="Flyout_Opened" + ShouldConstrainToRootBounds="False"> + <ListView + x:Name="ProfilesListView" + ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}" + SelectionChanged="ProfileListView_SelectionChanged" + SelectionMode="Single"> + <ListView.Header> + <TextBlock + x:Uid="ProfilesHeader" + Margin="16,0,8,0" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </ListView.Header> + <ListView.ItemTemplate> + <DataTemplate x:DataType="models:PowerDisplayProfile"> + <TextBlock Padding="0,4" Text="{x:Bind Name}" /> + </DataTemplate> + </ListView.ItemTemplate> + </ListView> + </Flyout> + </Button.Flyout> + </Button> + <Button + x:Name="RefreshButton" + x:Uid="RefreshTooltip" + Click="OnRefreshClick" + Content="{ui:FontIcon Glyph=, + FontSize=16}" + Style="{StaticResource SubtleButtonStyle}" /> + <Button + x:Name="IdentifyButton" + x:Uid="IdentifyTooltip" + Command="{x:Bind ViewModel.IdentifyMonitorsCommand}" + Content="{ui:FontIcon Glyph=, + FontSize=16}" + Style="{StaticResource SubtleButtonStyle}" + Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowIdentifyMonitorsButton), Mode=OneWay}" /> + <Button + x:Name="SettingsBtn" + x:Uid="SettingsTooltip" + Padding="6" + Click="OnSettingsClick" + Style="{StaticResource FlyoutButtonStyle}"> + <ToolTipService.ToolTip> + <TextBlock x:Uid="SettingsTooltip" /> + </ToolTipService.ToolTip> + <AnimatedIcon x:Name="SearchAnimatedIcon"> + <AnimatedIcon.Source> + <animatedVisuals:AnimatedSettingsVisualSource /> + </AnimatedIcon.Source> + <AnimatedIcon.FallbackIconSource> + <SymbolIconSource Symbol="Setting" /> + </AnimatedIcon.FallbackIconSource> + </AnimatedIcon> + </Button> + </StackPanel> + </Grid> + </Grid> + </Border> + </Grid> +</winuiex:WindowEx> \ 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..67b8605442 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplay/PowerDisplayXAML/MainWindow.xaml.cs @@ -0,0 +1,719 @@ +// 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 +{ + /// <summary> + /// PowerDisplay main window + /// </summary> + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] + public sealed partial class MainWindow : WindowEx, IDisposable + { + private readonly SettingsUtils _settingsUtils = SettingsUtils.Default; + private MainViewModel? _viewModel; + private HotkeyService? _hotkeyService; + + // Expose ViewModel as property for x:Bind + public MainViewModel ViewModel => _viewModel ?? throw new InvalidOperationException("ViewModel not initialized"); + + public MainWindow() + { + Logger.LogInfo("MainWindow constructor: Starting"); + try + { + // 1. Create ViewModel BEFORE InitializeComponent to avoid x:Bind failures + // x:Bind evaluates during InitializeComponent, so ViewModel must exist first + Logger.LogTrace("MainWindow constructor: Creating MainViewModel"); + _viewModel = new MainViewModel(); + Logger.LogTrace("MainWindow constructor: MainViewModel created"); + + Logger.LogTrace("MainWindow constructor: Calling InitializeComponent"); + this.InitializeComponent(); + Logger.LogTrace("MainWindow constructor: InitializeComponent completed"); + + // 2. Configure window immediately (synchronous, no data dependency) + Logger.LogTrace("MainWindow constructor: Configuring window"); + ConfigureWindow(); + + // 3. Set up data context and update bindings + RootGrid.DataContext = _viewModel; + Bindings.Update(); + Logger.LogTrace("MainWindow constructor: Data context set and bindings updated"); + + // 4. Register event handlers + RegisterEventHandlers(); + Logger.LogTrace("MainWindow constructor: Event handlers registered"); + + // 5. Initialize HotkeyService for in-process hotkey handling (CmdPal pattern) + // This avoids IPC timing issues with Runner's centralized hotkey mechanism + Logger.LogTrace("MainWindow constructor: Initializing HotkeyService"); + _hotkeyService = new HotkeyService(_settingsUtils, ToggleWindow); + _hotkeyService.Initialize(this); + Logger.LogTrace("MainWindow constructor: HotkeyService initialized"); + + Logger.LogTrace("MainWindow constructor: Setting IsShownInSwitchers property"); + this.SetIsShownInSwitchers(false); + Logger.LogTrace("MainWindow constructor: Set IsShownInSwitchers property successfully"); + + // Note: ViewModel handles all async initialization internally. + // We listen to InitializationCompleted event to know when data is ready. + // No duplicate initialization here - single responsibility in ViewModel. + Logger.LogInfo("MainWindow constructor: Completed"); + } + catch (Exception ex) + { + Logger.LogError($"MainWindow constructor: Initialization failed: {ex.Message}\n{ex.StackTrace}"); + ShowError($"Unable to start main window: {ex.Message}"); + } + } + + /// <summary> + /// Register all event handlers for window and ViewModel + /// </summary> + private void RegisterEventHandlers() + { + // Window events + this.Closed += OnWindowClosed; + this.Activated += OnWindowActivated; + + // ViewModel events - _viewModel is guaranteed non-null here as this is called after initialization + if (_viewModel != null) + { + _viewModel.InitializationCompleted += OnViewModelInitializationCompleted; + _viewModel.UIRefreshRequested += OnUIRefreshRequested; + _viewModel.Monitors.CollectionChanged += OnMonitorsCollectionChanged; + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + } + } + + /// <summary> + /// Called when ViewModel completes initial monitor discovery. + /// This is the single source of truth for initialization state. + /// </summary> + private void OnViewModelInitializationCompleted(object? sender, EventArgs e) + { + _hasInitialized = true; + Logger.LogInfo("MainWindow: Initialization completed via ViewModel event, _hasInitialized=true"); + AdjustWindowSizeToContent(); + } + + private bool _hasInitialized; + + private void ShowError(string message) + { + Logger.LogError($"Error: {message}"); + } + + private void OnWindowActivated(object sender, WindowActivatedEventArgs args) + { + Logger.LogTrace($"OnWindowActivated: WindowActivationState={args.WindowActivationState}"); + + // Auto-hide window when it loses focus (deactivated) + if (args.WindowActivationState == WindowActivationState.Deactivated) + { + Logger.LogInfo("OnWindowActivated: Window deactivated, hiding window"); + HideWindow(); + } + } + + private void OnWindowClosed(object sender, WindowEventArgs args) + { + // If only user operation (although we hide close button), just hide window + args.Handled = true; // Prevent window closing + HideWindow(); + } + + public void ShowWindow() + { + Logger.LogInfo($"ShowWindow: Called, _hasInitialized={_hasInitialized}"); + try + { + // If not initialized, log warning but continue showing + if (!_hasInitialized) + { + Logger.LogWarning("ShowWindow: Window not fully initialized yet, showing anyway"); + } + + // Adjust size BEFORE showing to prevent flicker + // This measures content and positions window at correct size + Logger.LogTrace("ShowWindow: Adjusting window size to content"); + AdjustWindowSizeToContent(); + + // CRITICAL: WinUI3 windows must be Activated at least once to display properly. + // In PowerToys mode, window is created but never activated until first show. + // Without Activate(), Show() may not actually render the window on screen. + Logger.LogTrace("ShowWindow: Calling this.Activate()"); + this.Activate(); + + // Now show the window - it should appear at the correct size + 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()}"); + } + + /// <summary> + /// Check if window is currently visible + /// </summary> + /// <returns>True if window is visible, false otherwise</returns> + public bool IsWindowVisible() + { + bool visible = this.Visible; + Logger.LogTrace($"IsWindowVisible: Returning {visible}"); + return visible; + } + + /// <summary> + /// Toggle window visibility (show if hidden, hide if visible) + /// </summary> + public void ToggleWindow() + { + bool currentlyVisible = IsWindowVisible(); + Logger.LogInfo($"ToggleWindow: Called, current visibility={currentlyVisible}"); + try + { + if (currentlyVisible) + { + Logger.LogInfo("ToggleWindow: Window is visible, hiding"); + HideWindow(); + } + else + { + Logger.LogInfo("ToggleWindow: Window is hidden, showing"); + ShowWindow(); + } + + Logger.LogInfo($"ToggleWindow: Completed, new visibility={IsWindowVisible()}"); + } + catch (Exception ex) + { + Logger.LogError($"ToggleWindow: Failed to toggle window: {ex.Message}\n{ex.StackTrace}"); + throw; + } + } + + private void OnUIRefreshRequested(object? sender, EventArgs e) + { + // Adjust window size when UI configuration changes (feature visibility toggles) + DispatcherQueue.TryEnqueue(() => AdjustWindowSizeToContent()); + } + + private void OnMonitorsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + // Adjust window size when monitors collection changes (event-driven!) + // The UI binding will update first, then we adjust size + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + AdjustWindowSizeToContent(); + }); + } + + private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + // Adjust window size when relevant properties change (event-driven!) + if (e.PropertyName == nameof(_viewModel.IsScanning) || + e.PropertyName == nameof(_viewModel.HasMonitors) || + e.PropertyName == nameof(_viewModel.ShowNoMonitorsMessage)) + { + // Use Low priority to ensure UI bindings update first + DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => + { + AdjustWindowSizeToContent(); + }); + } + } + + private void OnRefreshClick(object sender, RoutedEventArgs e) + { + try + { + // Refresh monitor list + if (_viewModel?.RefreshCommand?.CanExecute(null) == true) + { + _viewModel.RefreshCommand.Execute(null); + + // Window size will be adjusted automatically by OnMonitorsCollectionChanged event! + // No delay needed - event-driven design + } + } + catch (Exception ex) + { + Logger.LogError($"OnRefreshClick failed: {ex}"); + } + } + + private void OnSettingsClick(object sender, RoutedEventArgs e) + { + // Open PowerDisplay settings in PowerToys Settings UI + // mainExecutableIsOnTheParentFolder = true because PowerDisplay is a WinUI 3 app + // deployed in a subfolder (PowerDisplay\) while PowerToys.exe is in the parent folder + SettingsDeepLink.OpenSettings(true); + } + + /// <summary> + /// Configure window properties (synchronous, no data dependency) + /// </summary> + private void ConfigureWindow() + { + try + { + // Window properties (IsResizable, IsMaximizable, IsMinimizable, + // IsTitleBarVisible, IsShownInSwitchers) are set in XAML + + // Set minimal initial window size - will be adjusted before showing + // Using minimal height to prevent "large window shrinking" flicker + this.AppWindow.Resize(new SizeInt32 { Width = AppConstants.UI.WindowWidth, Height = 100 }); + + // Position window at bottom right corner + PositionWindowAtBottomRight(); + + // Set window title + this.AppWindow.Title = "PowerDisplay"; + + // Custom title bar - completely remove all buttons + var titleBar = this.AppWindow.TitleBar; + if (titleBar != null) + { + // Extend content into title bar area + titleBar.ExtendsContentIntoTitleBar = true; + + // Completely remove title bar height + titleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed; + + // Set all button colors to transparent + titleBar.ButtonBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonInactiveBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonHoverBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonHoverForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonPressedBackgroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonPressedForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + titleBar.ButtonInactiveForegroundColor = Windows.UI.Color.FromArgb(0, 0, 0, 0); + + // Disable title bar interaction area + titleBar.SetDragRectangles(Array.Empty<Windows.Graphics.RectInt32>()); + } + + // Use Win32 API to further disable window moving (removes WS_CAPTION, WS_SYSMENU, etc.) + var hWnd = this.GetWindowHandle(); + WindowHelper.DisableWindowMovingAndResizing(hWnd); + } + catch (Exception ex) + { + // Ignore window setup errors + Logger.LogWarning($"Window configuration error: {ex.Message}"); + } + } + + private void AdjustWindowSizeToContent() + { + try + { + if (RootGrid == null) + { + return; + } + + // Force layout update and measure content height + RootGrid.UpdateLayout(); + MainContainer?.Measure(new Windows.Foundation.Size(AppConstants.UI.WindowWidth, double.PositiveInfinity)); + var contentHeight = (int)Math.Ceiling(MainContainer?.DesiredSize.Height ?? 0); + + // Apply min/max height limits and reposition (WindowEx handles DPI automatically) + // Min height ensures window is visible even if content hasn't loaded yet + var finalHeight = Math.Max(AppConstants.UI.MinWindowHeight, Math.Min(contentHeight, AppConstants.UI.MaxWindowHeight)); + Logger.LogTrace($"AdjustWindowSizeToContent: contentHeight={contentHeight}, finalHeight={finalHeight}"); + WindowHelper.PositionWindowBottomRight(this, AppConstants.UI.WindowWidth, finalHeight, AppConstants.UI.WindowRightMargin); + } + catch (Exception ex) + { + Logger.LogError($"Error adjusting window size: {ex.Message}"); + } + } + + private void PositionWindowAtBottomRight() + { + try + { + var windowSize = this.AppWindow.Size; + WindowHelper.PositionWindowBottomRight( + this, // MainWindow inherits from WindowEx + AppConstants.UI.WindowWidth, + windowSize.Height, + AppConstants.UI.WindowRightMargin); + } + catch (Exception) + { + // Window positioning failures are non-critical, silently ignore + } + } + + /// <summary> + /// Slider PointerCaptureLost event handler - updates ViewModel when drag completes + /// This is the WinUI3 recommended way to detect drag completion + /// </summary> + private void Slider_PointerCaptureLost(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) + { + var slider = sender as Slider; + if (slider == null) + { + return; + } + + var propertyName = slider.Tag as string; + var monitorVm = slider.DataContext as MonitorViewModel; + + if (monitorVm == null || propertyName == null) + { + return; + } + + // Get final value after drag completes + int finalValue = (int)slider.Value; + + // Now update the ViewModel, which will trigger hardware operation + switch (propertyName) + { + case "Brightness": + monitorVm.Brightness = finalValue; + break; + case "Contrast": + monitorVm.ContrastPercent = finalValue; + break; + case "Volume": + monitorVm.Volume = finalValue; + break; + } + } + + /// <summary> + /// Slider KeyUp event handler - updates ViewModel when arrow keys are released + /// This handles keyboard navigation for accessibility + /// </summary> + 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; + } + } + + /// <summary> + /// Input source ListView selection changed handler - switches the monitor input source + /// </summary> + private async void InputSourceListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ListView listView) + { + return; + } + + // Get the selected input source item + var selectedItem = listView.SelectedItem as InputSourceItem; + if (selectedItem == null) + { + return; + } + + Logger.LogInfo($"[UI] InputSourceListView_SelectionChanged: Selected {selectedItem.Name} (0x{selectedItem.Value:X2}) for monitor {selectedItem.MonitorId}"); + + // Find the monitor by ID + MonitorViewModel? monitorVm = null; + if (!string.IsNullOrEmpty(selectedItem.MonitorId) && _viewModel != null) + { + monitorVm = _viewModel.Monitors.FirstOrDefault(m => m.Id == selectedItem.MonitorId); + } + + if (monitorVm == null) + { + Logger.LogWarning("[UI] InputSourceListView_SelectionChanged: Could not find MonitorViewModel"); + return; + } + + // Set the input source + await monitorVm.SetInputSourceAsync(selectedItem.Value); + } + + /// <summary> + /// Power state ListView selection changed handler - switches the monitor power state. + /// Note: Selecting any state other than "On" will turn off the display. + /// </summary> + 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); + } + + /// <summary> + /// Rotation button click handler - changes monitor orientation + /// </summary> + private async void RotationButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not Microsoft.UI.Xaml.Controls.Primitives.ToggleButton toggleButton) + { + return; + } + + // Get the orientation from the Tag + if (toggleButton.Tag is not string tagStr || !int.TryParse(tagStr, out int orientation)) + { + Logger.LogWarning("[UI] RotationButton_Click: Invalid Tag"); + return; + } + + var monitorVm = toggleButton.DataContext as MonitorViewModel; + if (monitorVm == null) + { + Logger.LogWarning("[UI] RotationButton_Click: Could not find MonitorViewModel"); + return; + } + + // If clicking the current orientation, restore the checked state and do nothing + if (monitorVm.CurrentRotation == orientation) + { + toggleButton.IsChecked = true; + return; + } + + Logger.LogInfo($"[UI] RotationButton_Click: Setting rotation for {monitorVm.Name} to {orientation}"); + + // Set the rotation + await monitorVm.SetRotationAsync(orientation); + } + + /// <summary> + /// Profile selection changed handler - applies the selected profile + /// </summary> + private void ProfileListView_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (sender is not ListView listView) + { + return; + } + + var selectedProfile = listView.SelectedItem as PowerDisplayProfile; + if (selectedProfile == null || !selectedProfile.IsValid()) + { + return; + } + + Logger.LogInfo($"[UI] ProfileListView_SelectionChanged: Applying profile '{selectedProfile.Name}'"); + + // Apply profile via ViewModel command + if (_viewModel?.ApplyProfileCommand?.CanExecute(selectedProfile) == true) + { + _viewModel.ApplyProfileCommand.Execute(selectedProfile); + } + + // Close the flyout after selection + ProfilesFlyout?.Hide(); + + // Clear selection to allow reselecting the same profile + listView.SelectedItem = null; + } + + /// <summary> + /// Color temperature selection changed handler - applies the selected color temperature preset + /// </summary> + 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; + } + + /// <summary> + /// Flyout opened event handler - sets focus to the first focusable element inside the flyout. + /// This enables keyboard navigation when the flyout opens. + /// </summary> + 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); + } + + /// <summary> + /// Reload hotkey settings. Call this when settings change. + /// </summary> + 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 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="PowerDisplay.MonitorIcon" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:PowerDisplay" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d"> + + <Grid> + <Viewbox> + <Grid> + <Grid x:Name="MonitorGrid"> + <FontIcon + x:Uid="MonitorTooltip" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + FontSize="22" + Glyph="" /> + <TextBlock + Margin="0,0,0,4" + HorizontalAlignment="Center" + VerticalAlignment="Center" + FontSize="10" + FontWeight="SemiBold" + Text="{x:Bind MonitorNumber, Mode=OneWay}" /> + </Grid> + + <Grid + x:Name="BuiltInDisplayGrid" + Padding="0,0,0,-4" + Visibility="Collapsed"> + <FontIcon + x:Uid="MonitorTooltip" + Margin="0,0,0,0" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + FontSize="22" + Glyph="" /> + <TextBlock + Margin="0,0,0,6" + HorizontalAlignment="Center" + VerticalAlignment="Center" + FontSize="10" + FontWeight="SemiBold" + Text="{x:Bind MonitorNumber, Mode=OneWay}" /> + </Grid> + </Grid> + </Viewbox> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Monitor" /> + <VisualState x:Name="BuiltIn"> + <VisualState.Setters> + <Setter Target="BuiltInDisplayGrid.Visibility" Value="Visible" /> + <Setter Target="MonitorGrid.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> +</UserControl> 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; + } + + /// <summary> + /// Redirect activation to existing instance (Command Palette pattern) + /// Called BEFORE logger is initialized, so no logging here + /// </summary> + private static void RedirectActivationTo(AppActivationArguments args, AppInstance keyInstance) + { + // Do the redirection on another thread, and use a non-blocking + // wait method to wait for the redirection to complete. + using var redirectSemaphore = new Semaphore(0, 1); + var redirectTimeout = TimeSpan.FromSeconds(10); + + _ = Task.Run(() => + { + using var cts = new CancellationTokenSource(redirectTimeout); + try + { + keyInstance.RedirectActivationToAsync(args) + .AsTask(cts.Token) + .GetAwaiter() + .GetResult(); + } + catch + { + // Silently ignore errors - logger not initialized yet + } + finally + { + redirectSemaphore.Release(); + } + }); + + // Use CoWaitForMultipleObjects to pump COM messages while waiting + nint[] handles = [redirectSemaphore.SafeWaitHandle.DangerousGetHandle()]; + _ = CoWaitForMultipleObjects( + CowaitDefault, + InfiniteTimeout, + 1, + handles, + out _); + } + + /// <summary> + /// Called when an existing instance is activated by another process. + /// 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. + /// </summary> + 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 +{ + /// <summary> + /// IPC message wrapper for parsing action-based messages. + /// Used in App.xaml.cs for dynamic IPC command handling. + /// </summary> + internal sealed class IpcMessageAction + { + [JsonPropertyName("action")] + public string? Action { get; set; } + } +} 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 +{ + /// <summary> + /// JSON source generation context for AOT compatibility. + /// Eliminates reflection-based JSON serialization. + /// Note: MonitorStateFile and MonitorStateEntry are now in PowerDisplay.Lib + /// and should be serialized using ProfileSerializationContext from the Lib. + /// </summary> + [JsonSerializable(typeof(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<string>))] + [JsonSerializable(typeof(List<MonitorInfo>))] + [JsonSerializable(typeof(List<VcpCodeDisplayInfo>))] + [JsonSerializable(typeof(List<VcpValueInfo>))] + [JsonSerializable(typeof(List<PowerDisplayProfile>))] + [JsonSerializable(typeof(List<ProfileMonitorSetting>))] + + [JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never)] + internal sealed partial class AppJsonContext : JsonSerializerContext + { + } +} 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 +{ + /// <summary> + /// Service for handling LightSwitch theme change events. + /// Reads LightSwitch settings using the standard PowerToys settings pattern. + /// </summary> + public static class LightSwitchService + { + private const string LogPrefix = "[LightSwitch]"; + + /// <summary> + /// Get the profile name to apply for the given theme. + /// </summary> + /// <param name="isLightMode">Whether the theme changed to light mode.</param> + /// <returns>The profile name to apply, or null if no profile is configured.</returns> + public static string? GetProfileForTheme(bool isLightMode) + { + try + { + Logger.LogInfo($"{LogPrefix} Processing theme change to {(isLightMode ? "light" : "dark")} mode"); + + var settings = SettingsUtils.Default.GetSettingsOrDefault<LightSwitchSettings>(LightSwitchSettings.ModuleName); + + if (settings?.Properties == null) + { + Logger.LogWarning($"{LogPrefix} LightSwitch settings not found"); + return null; + } + + string? profileName; + if (isLightMode) + { + if (!settings.Properties.EnableLightModeProfile.Value) + { + Logger.LogInfo($"{LogPrefix} Light mode profile is disabled"); + return null; + } + + profileName = settings.Properties.LightModeProfile.Value; + } + else + { + if (!settings.Properties.EnableDarkModeProfile.Value) + { + Logger.LogInfo($"{LogPrefix} Dark mode profile is disabled"); + return null; + } + + profileName = settings.Properties.DarkModeProfile.Value; + } + + if (string.IsNullOrEmpty(profileName) || profileName == "(None)") + { + Logger.LogInfo($"{LogPrefix} No profile configured for {(isLightMode ? "light" : "dark")} mode"); + return null; + } + + Logger.LogInfo($"{LogPrefix} Profile to apply: {profileName}"); + return profileName; + } + catch (Exception ex) + { + Logger.LogError($"{LogPrefix} Failed to get profile for theme: {ex.Message}"); + return null; + } + } + } +} 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="ScanningMonitorsText.Text" xml:space="preserve"> + <value>Scanning monitors..</value> + </data> + <data name="NoMonitorsText.Message" xml:space="preserve"> + <value>No monitors detected</value> + </data> + <data name="RefreshTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Rescan connected monitors</value> + </data> + <data name="SettingsTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Settings</value> + </data> + <data name="MonitorTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Monitor</value> + </data> + <data name="BrightnessTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Brightness</value> + </data> + <data name="ContrastTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Contrast</value> + </data> + <data name="VolumeTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Volume</value> + </data> + <data name="RotationTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Rotation</value> + </data> + <data name="RotateNormalTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Normal (0°)</value> + </data> + <data name="RotateLeftTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Rotate left (270°)</value> + </data> + <data name="RotateRightTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Rotate right (90°)</value> + </data> + <data name="RotateInvertedTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Inverted (180°)</value> + </data> + <data name="VolumeAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Volume</value> + </data> + <data name="ContrastAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Contrast</value> + </data> + <data name="BrightnessAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Brightness</value> + </data> + <data name="AppName" xml:space="preserve"> + <value>PowerDisplay</value> + </data> + <data name="TrayMenu_Settings" xml:space="preserve"> + <value>Settings</value> + </data> + <data name="TrayMenu_Exit" xml:space="preserve"> + <value>Exit</value> + </data> + <data name="ProfilesTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Quick apply profiles</value> + </data> + <data name="IdentifyTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Identify monitors</value> + </data> + <data name="InputSourceHeader.Text" xml:space="preserve"> + <value>Input source</value> + </data> + <data name="PowerStateHeader.Text" xml:space="preserve"> + <value>Power state</value> + </data> + <data name="MoreOptionsTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>More options</value> + </data> + <data name="ProfilesHeader.Text" xml:space="preserve"> + <value>Profiles</value> + </data> + <data name="ColorTemperatureTooltip.ToolTipService.ToolTip" xml:space="preserve"> + <value>Color temperature</value> + </data> + <data name="ColorTemperatureHeader.Text" xml:space="preserve"> + <value>Color temperature</value> + </data> +</root> 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 +{ + /// <summary> + /// Telemetry event for PowerDisplay settings + /// Sent when Runner requests settings telemetry via send_settings_telemetry() + /// </summary> + [EventData] + public class PowerDisplaySettingsTelemetryEvent : EventBase, IEvent + { + public new string EventName => "PowerDisplay_Settings"; + + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + /// <summary> + /// Whether the hotkey is enabled + /// </summary> + public bool HotkeyEnabled { get; set; } + + /// <summary> + /// Whether the tray icon is enabled + /// </summary> + public bool TrayIconEnabled { get; set; } + + /// <summary> + /// Number of monitors currently detected + /// </summary> + public int MonitorCount { get; set; } + + /// <summary> + /// Number of profiles saved + /// </summary> + 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; + +/// <summary> +/// Represents a color temperature preset option for display in UI +/// </summary> +public class ColorTemperatureItem +{ + /// <summary> + /// VCP value for this color temperature preset (e.g., 0x05 for 6500K) + /// </summary> + public int VcpValue { get; set; } + + /// <summary> + /// Human-readable name (e.g., "6500K", "sRGB", "User 1") + /// </summary> + public string DisplayName { get; set; } = string.Empty; + + /// <summary> + /// Whether this preset is currently selected + /// </summary> + public bool IsSelected { get; set; } + + /// <summary> + /// Monitor ID for direct lookup (Flyout popup is not in visual tree) + /// </summary> + 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; + +/// <summary> +/// Represents an input source option for display in UI +/// </summary> +public class InputSourceItem +{ + /// <summary> + /// VCP value for this input source (e.g., 0x11 for HDMI-1) + /// </summary> + public int Value { get; set; } + + /// <summary> + /// Human-readable name (e.g., "HDMI-1", "DisplayPort-1") + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// Visibility of selection indicator (Visible when selected) + /// </summary> + public Visibility SelectionVisibility { get; set; } = Visibility.Collapsed; + + /// <summary> + /// Monitor ID for direct lookup (Flyout popup is not in visual tree) + /// </summary> + 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; + +/// <summary> +/// MainViewModel - Monitor discovery and management methods +/// </summary> +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; + }); + } + } + + /// <summary> + /// 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. + /// </summary> + private async Task CompleteInitializationAsync() + { + try + { + // Check if we should restore settings on startup + var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(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); + } + } + + /// <summary> + /// Refresh monitors list asynchronously. + /// </summary> + /// <param name="skipScanningCheck">If true, skip the IsScanning check (used by OnDisplayChanged which sets IsScanning before calling).</param> + 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<Monitor> monitors, bool isInitialLoad) + { + Monitors.Clear(); + + // Load settings to check for hidden monitors + var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(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 + } + + /// <summary> + /// Get set of hidden monitor IDs from settings + /// </summary> + private HashSet<string> GetHiddenMonitorIds(PowerDisplaySettings settings) + => new HashSet<string>( + 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; + +/// <summary> +/// MainViewModel - Settings UI synchronization and Profile management methods +/// </summary> +public partial class MainViewModel +{ + /// <summary> + /// Check if a value is within the valid range (inclusive). + /// </summary> + private static bool IsValueInRange(int value, int min, int max) => value >= min && value <= max; + + /// <summary> + /// 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. + /// </summary> + 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<PowerDisplaySettings>("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}"); + } + } + + /// <summary> + /// Apply UI-only configuration changes (feature visibility toggles) + /// Synchronous, lightweight operation + /// </summary> + 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}"); + } + } + + /// <summary> + /// Apply profile by name (called via Named Pipe from Settings UI) + /// This is the new direct method that receives the profile name via IPC. + /// </summary> + /// <param name="profileName">The name of the profile to apply.</param> + 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}"); + } + } + + /// <summary> + /// Handle theme change from LightSwitch by applying the appropriate profile. + /// Called from App.xaml.cs when LightSwitch theme events are received. + /// </summary> + /// <param name="isLightMode">Whether the theme changed to light mode.</param> + 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<bool>(); + 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}"); + } + } + }); + } + + /// <summary> + /// Helper method to apply profile and signal completion. + /// </summary> + private async Task ApplyProfileAndCompleteAsync(List<ProfileMonitorSetting> monitorSettings, TaskCompletionSource<bool> tcs) + { + try + { + await ApplyProfileAsync(monitorSettings); + tcs.SetResult(true); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + } + + /// <summary> + /// Apply profile settings to monitors + /// </summary> + private async Task ApplyProfileAsync(List<ProfileMonitorSetting> monitorSettings) + { + var updateTasks = new List<Task>(); + + 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); + } + } + + /// <summary> + /// 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. + /// </summary> + public async Task RestoreMonitorSettingsAsync() + { + try + { + IsLoading = true; + var updateTasks = new List<Task>(); + + 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; + } + } + + /// <summary> + /// Apply feature visibility settings to a monitor ViewModel. + /// Only shows features that are both enabled by user AND supported by hardware. + /// </summary> + 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; + } + } + + /// <summary> + /// Thread-safe save method that can be called from background threads. + /// Does not access UI collections or update UI properties. + /// </summary> + 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}"); + } + } + + /// <summary> + /// Save monitor information to settings.json for Settings UI to read + /// </summary> + private void SaveMonitorsToSettings() + { + try + { + // Load current settings to preserve user preferences (including IsHidden) + var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(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<Microsoft.PowerToys.Settings.UI.Library.MonitorInfo>(); + + 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}"); + } + } + + /// <summary> + /// Create MonitorInfo object from MonitorViewModel + /// </summary> + 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<Microsoft.PowerToys.Settings.UI.Library.VcpCodeDisplayInfo>(), + + // 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; + } + + /// <summary> + /// Apply preserved user settings from existing monitor settings + /// </summary> + private void ApplyPreservedUserSettings( + Microsoft.PowerToys.Settings.UI.Library.MonitorInfo monitorInfo, + Dictionary<string, Microsoft.PowerToys.Settings.UI.Library.MonitorInfo> 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; + } + } + + /// <summary> + /// Signal Settings UI that the monitor list has been refreshed + /// </summary> + private void SignalMonitorsRefreshEvent() + { + EventHelper.SignalEvent(Constants.RefreshPowerDisplayMonitorsEvent()); + } + + /// <summary> + /// Format VCP code information for display in Settings UI + /// </summary> + 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; + } + + /// <summary> + /// Send settings telemetry event (triggered by Runner via send_settings_telemetry()) + /// </summary> + public void SendSettingsTelemetry() + { + try + { + // Load current settings to get hotkey and tray icon status + var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(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; + +/// <summary> +/// 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 +/// </summary> +[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<MonitorViewModel> _monitors; + private ObservableCollection<PowerDisplayProfile> _profiles; + private bool _isScanning; + private bool _isInitialized; + private bool _isLoading; + + /// <summary> + /// Event triggered when UI refresh is requested due to settings changes + /// </summary> + public event EventHandler? UIRefreshRequested; + + /// <summary> + /// Event triggered when initial monitor discovery is completed. + /// Used by MainWindow to know when data is ready for display. + /// </summary> + public event EventHandler? InitializationCompleted; + + public MainViewModel() + { + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _cancellationTokenSource = new CancellationTokenSource(); + _monitors = new ObservableCollection<MonitorViewModel>(); + _profiles = new ObservableCollection<PowerDisplayProfile>(); + _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>(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<MonitorViewModel> Monitors + { + get => _monitors; + set + { + _monitors = value; + OnPropertyChanged(); + } + } + + public ObservableCollection<PowerDisplayProfile> 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; + + /// <summary> + /// Gets a value indicating whether to show the profile switcher button. + /// Combines settings value with HasProfiles check. + /// </summary> + public bool ShowProfileSwitcherButton => _showProfileSwitcher && HasProfiles; + + /// <summary> + /// Gets or sets a value indicating whether to show the profile switcher (from settings). + /// </summary> + public bool ShowProfileSwitcher + { + get => _showProfileSwitcher; + set + { + if (_showProfileSwitcher != value) + { + _showProfileSwitcher = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ShowProfileSwitcherButton)); + } + } + } + + /// <summary> + /// Gets or sets a value indicating whether to show the identify monitors button. + /// </summary> + public bool ShowIdentifyMonitorsButton + { + get => _showIdentifyMonitorsButton; + set + { + if (_showIdentifyMonitorsButton != value) + { + _showIdentifyMonitorsButton = value; + OnPropertyChanged(); + } + } + } + + // Custom VCP mappings - loaded from settings + private List<CustomVcpValueMapping> _customVcpMappings = new(); + + /// <summary> + /// Gets or sets the custom VCP value name mappings. + /// These mappings override the default VCP value names for color temperature and input source. + /// </summary> + public List<CustomVcpValueMapping> CustomVcpMappings + { + get => _customVcpMappings; + set + { + _customVcpMappings = value ?? new List<CustomVcpValueMapping>(); + 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)); + } + } + + /// <summary> + /// Gets a value indicating whether user interaction is enabled (not loading or scanning). + /// </summary> + 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); + } + + /// <summary> + /// Load profiles from disk for quick apply feature + /// </summary> + private void LoadProfiles() + { + try + { + var profilesData = ProfileService.LoadProfiles(); + _profiles.Clear(); + foreach (var profile in profilesData.Profiles) + { + _profiles.Add(profile); + } + + OnPropertyChanged(nameof(HasProfiles)); + OnPropertyChanged(nameof(ShowProfileSwitcherButton)); + } + catch (Exception ex) + { + Logger.LogError($"[Profile] Failed to load profiles: {ex.Message}"); + } + } + + /// <summary> + /// Load UI display settings from settings file + /// </summary> + private void LoadUIDisplaySettings() + { + try + { + var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(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<CustomVcpValueMapping>(); + Logger.LogInfo($"[Settings] Loaded {CustomVcpMappings.Count} custom VCP mappings"); + } + catch (Exception ex) + { + Logger.LogError($"[Settings] Failed to load UI display settings: {ex.Message}"); + } + } + + /// <summary> + /// 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. + /// </summary> + 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); + } + + /// <summary> + /// Starts watching for display changes. Call after initialization is complete. + /// </summary> + public void StartDisplayWatching() + { + _displayChangeWatcher.Start(); + } + + /// <summary> + /// Stops watching for display changes. + /// </summary> + 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; + +/// <summary> +/// ViewModel for individual monitor +/// </summary> +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; + + /// <summary> + /// Updates a property value directly without triggering hardware updates. + /// Used during initialization to update UI from saved state. + /// </summary> + 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; + } + } + + /// <summary> + /// Apply brightness with hardware update and state persistence. + /// </summary> + /// <param name="brightness">Brightness value (0-100)</param> + 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); + } + + /// <summary> + /// Apply contrast with hardware update and state persistence. + /// </summary> + 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); + } + + /// <summary> + /// Apply volume with hardware update and state persistence. + /// </summary> + 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); + } + + /// <summary> + /// Unified method to apply color temperature with hardware update and state persistence. + /// Always immediate (no debouncing for discrete preset values). + /// </summary> + 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}"); + } + } + + /// <summary> + /// Generic method to apply a monitor property to hardware and persist state. + /// Consolidates common logic for brightness, contrast, and volume operations. + /// </summary> + /// <param name="propertyName">Name of the property being set (for logging and state persistence)</param> + /// <param name="value">Value to apply</param> + /// <param name="setAsyncFunc">Async function to call on MonitorManager</param> + private async Task ApplyPropertyToHardwareAsync( + string propertyName, + int value, + Func<string, int, CancellationToken, Task<MonitorOperationResult>> 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; + + /// <summary> + /// Gets the monitor number from the underlying monitor model (Windows DISPLAY number) + /// </summary> + public int MonitorNumber => _monitor.MonitorNumber; + + /// <summary> + /// Gets the display name - includes monitor number when multiple monitors exist. + /// Follows the same logic as Settings UI's MonitorInfo.DisplayName for consistency. + /// </summary> + 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; + + /// <summary> + /// Gets the icon glyph based on communication method + /// WMI monitors (laptop internal displays) use laptop icon, others use external monitor icon + /// </summary> + 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; + + /// <summary> + /// Gets a value indicating whether this monitor supports contrast control via VCP 0x12 + /// </summary> + public bool SupportsContrast => _monitor.SupportsContrast; + + /// <summary> + /// Gets a value indicating whether this monitor supports volume control via VCP 0x62 + /// </summary> + 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(); + } + } + } + + /// <summary> + /// Gets or sets a value indicating whether to show power state control in the More Button flyout. + /// </summary> + public bool ShowPowerState + { + get => _showPowerState && SupportsPowerState; + set + { + if (_showPowerState != value) + { + _showPowerState = value; + OnPropertyChanged(); + OnMoreButtonPropertiesChanged(); + } + } + } + + /// <summary> + /// Gets a value indicating whether the More Button should be visible. + /// Visible when at least one feature (InputSource or PowerState) is enabled. + /// </summary> + public bool ShowMoreButton => ShowInputSource || ShowPowerState; + + /// <summary> + /// Gets a value indicating whether to show separator after Input Source section. + /// Only shown when both InputSource and PowerState are visible. + /// </summary> + public bool ShowSeparatorAfterInputSource => ShowInputSource && ShowPowerState; + + /// <summary> + /// Notifies property changes for More Button related properties. + /// </summary> + private void OnMoreButtonPropertiesChanged() + { + OnPropertyChanged(nameof(ShowMoreButton)); + OnPropertyChanged(nameof(ShowSeparatorAfterInputSource)); + } + + /// <summary> + /// Gets or sets a value indicating whether to show rotation controls (controlled by Settings UI, default false). + /// </summary> + public bool ShowRotation + { + get => _showRotation; + set + { + if (_showRotation != value) + { + _showRotation = value; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets the current rotation/orientation of the monitor (0=normal, 1=90°, 2=180°, 3=270°) + /// </summary> + public int CurrentRotation => _monitor.Orientation; + + /// <summary> + /// Gets a value indicating whether the current rotation is 0° (normal/default). + /// </summary> + public bool IsRotation0 => CurrentRotation == 0; + + /// <summary> + /// Gets a value indicating whether the current rotation is 90° (rotated right). + /// </summary> + public bool IsRotation1 => CurrentRotation == 1; + + /// <summary> + /// Gets a value indicating whether the current rotation is 180° (inverted). + /// </summary> + public bool IsRotation2 => CurrentRotation == 2; + + /// <summary> + /// Gets a value indicating whether the current rotation is 270° (rotated left). + /// </summary> + public bool IsRotation3 => CurrentRotation == 3; + + /// <summary> + /// 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. + /// </summary> + /// <param name="orientation">Orientation: 0=normal, 1=90°, 2=180°, 3=270°</param> + 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); + } + } + } + + /// <summary> + /// 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). + /// </summary> + public int ColorTemperature => _monitor.CurrentColorTemperature; + + /// <summary> + /// Gets human-readable color temperature preset name (e.g., "6500K", "sRGB") + /// Uses custom mappings if available; falls back to built-in names if not. + /// </summary> + public string ColorTemperaturePresetName => + Common.Utils.VcpNames.GetFormattedValueName(0x14, _monitor.CurrentColorTemperature, _mainViewModel?.CustomVcpMappings, _monitor.Id); + + /// <summary> + /// Gets a value indicating whether this monitor supports color temperature via VCP 0x14 + /// </summary> + public bool SupportsColorTemperature => _monitor.SupportsColorTemperature; + + private List<ColorTemperatureItem>? _availableColorPresets; + private bool _showColorTemperature; + + /// <summary> + /// Gets or sets a value indicating whether to show color temperature switcher (controlled by Settings UI, default false). + /// </summary> + public bool ShowColorTemperature + { + get => _showColorTemperature && SupportsColorTemperature; + set + { + if (_showColorTemperature != value) + { + _showColorTemperature = value; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets available color temperature presets for this monitor + /// </summary> + public List<ColorTemperatureItem>? AvailableColorPresets + { + get + { + if (_availableColorPresets == null && SupportsColorTemperature) + { + RefreshAvailableColorPresets(); + } + + return _availableColorPresets; + } + } + + /// <summary> + /// Standard MCCS color temperature presets (VCP 0x14 values) to use as fallback + /// when the monitor doesn't report discrete values in its capabilities string. + /// </summary> + private static readonly int[] StandardColorTemperaturePresets = { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x08, 0x09, 0x0A, 0x0B }; + + /// <summary> + /// Refresh the list of available color temperature presets based on monitor capabilities + /// </summary> + private void RefreshAvailableColorPresets() + { + if (!SupportsColorTemperature) + { + _availableColorPresets = null; + return; + } + + IEnumerable<int> 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)); + } + + /// <summary> + /// Gets a value indicating whether this monitor supports input source switching via VCP 0x60 + /// </summary> + public bool SupportsInputSource => _monitor.SupportsInputSource; + + /// <summary> + /// Gets current input source VCP value (from VCP code 0x60) + /// </summary> + public int CurrentInputSource => _monitor.CurrentInputSource; + + /// <summary> + /// 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. + /// </summary> + public string CurrentInputSourceName => + Common.Utils.VcpNames.GetValueName(0x60, _monitor.CurrentInputSource, _mainViewModel?.CustomVcpMappings, _monitor.Id) + ?? $"Source 0x{_monitor.CurrentInputSource:X2}"; + + private List<InputSourceItem>? _availableInputSources; + + /// <summary> + /// Gets available input sources for this monitor + /// </summary> + public List<InputSourceItem>? AvailableInputSources + { + get + { + if (_availableInputSources == null && SupportsInputSource) + { + RefreshAvailableInputSources(); + } + + return _availableInputSources; + } + } + + /// <summary> + /// Refresh the list of available input sources based on monitor capabilities + /// </summary> + 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)); + } + + /// <summary> + /// Refresh custom VCP name displays after settings change. + /// Called when CustomVcpMappings is updated from Settings UI. + /// </summary> + 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)); + } + + /// <summary> + /// Set input source for this monitor + /// </summary> + 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}"); + } + } + + /// <summary> + /// Command to set input source + /// </summary> + [RelayCommand] + private async Task SetInputSource(int? source) + { + if (source.HasValue) + { + await SetInputSourceAsync(source.Value); + } + } + + /// <summary> + /// Gets a value indicating whether this monitor supports power state control via VCP 0xD6 + /// </summary> + public bool SupportsPowerState => _monitor.SupportsPowerState; + + private List<PowerStateItem>? _availablePowerStates; + + /// <summary> + /// Gets available power states for this monitor. + /// The current power state is shown as selected based on the monitor's actual state. + /// </summary> + public List<PowerStateItem>? AvailablePowerStates + { + get + { + if (_availablePowerStates == null && SupportsPowerState) + { + RefreshAvailablePowerStates(); + } + + return _availablePowerStates; + } + } + + /// <summary> + /// Refresh the list of available power states based on monitor capabilities + /// </summary> + 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)); + } + + /// <summary> + /// Set power state for this monitor. + /// Note: Setting any state other than "On" will turn off the display. + /// </summary> + 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}"); + } + } + + /// <summary> + /// Command to set power state + /// </summary> + [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; + +/// <summary> +/// Represents a power state option for display in UI. +/// VCP 0xD6 values: 0x01=On, 0x02=Standby, 0x03=Suspend, 0x04=Off(DPM), 0x05=Off(Hard) +/// </summary> +public class PowerStateItem +{ + /// <summary> + /// VCP power mode value representing On state + /// </summary> + public const int PowerStateOn = 0x01; + + /// <summary> + /// VCP value for this power state + /// </summary> + public int Value { get; set; } + + /// <summary> + /// Human-readable name (e.g., "On", "Standby", "Off (DPM)") + /// </summary> + public string Name { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets whether this power state is currently selected. + /// Set based on monitor's actual power state during list creation. + /// </summary> + public bool IsSelected { get; set; } + + /// <summary> + /// Visibility of selection indicator (Visible when IsSelected is true) + /// </summary> + public Visibility SelectionVisibility => IsSelected ? Visibility.Visible : Visibility.Collapsed; + + /// <summary> + /// Monitor ID for direct lookup (Flyout popup is not in visual tree) + /// </summary> + 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> + <assemblyIdentity version="1.0.0.0" name="PowerDisplay.app"/> + + <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> + <security> + <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> + <requestedExecutionLevel level="asInvoker" uiAccess="false" /> + </requestedPrivileges> + </security> + </trustInfo> + + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <!-- Windows 10 --> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> + <!-- Windows 11 --> + <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" /> + </application> + </compatibility> + + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <!-- The combination of below two tags have the following effect: + 1) Per-Monitor for >= Windows 10 Anniversary Update + 2) System < Windows 10 Anniversary Update + --> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + </windowsSettings> + </application> +</assembly> \ 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 <windows.h> +#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..de837dbb4a --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj @@ -0,0 +1,133 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <ItemGroup Label="ProjectConfigurations"> + <ProjectConfiguration Include="Debug|ARM64"> + <Configuration>Debug</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|ARM64"> + <Configuration>Release</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Debug|x64"> + <Configuration>Debug</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|x64"> + <Configuration>Release</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + </ItemGroup> + <PropertyGroup Label="Globals"> + <VCProjectVersion>16.0</VCProjectVersion> + <ProjectGuid>{D1234567-8901-2345-6789-ABCDEF012345}</ProjectGuid> + <Keyword>Win32Proj</Keyword> + <RootNamespace>PowerDisplayModuleInterface</RootNamespace> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <IntDir>$(Platform)\$(Configuration)\PowerDisplayModuleInterface\</IntDir> + <TargetName>PowerToys.PowerDisplayModuleInterface</TargetName> + </PropertyGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_DEBUG;POWERDISPLAYMODULEINTERFACE_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <EnableUAC>false</EnableUAC> + <AdditionalDependencies>Shlwapi.lib;Rpcrt4.lib;$(CoreLibraryDependencies);%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>NDEBUG;POWERDISPLAYMODULEINTERFACE_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <EnableUAC>false</EnableUAC> + <AdditionalDependencies>Shlwapi.lib;Rpcrt4.lib;$(CoreLibraryDependencies);%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClInclude Include="Constants.h" /> + <ClInclude Include="pch.h" /> + <ClInclude Include="PowerDisplayProcessManager.h" /> + <ClInclude Include="resource.h" /> + <ClInclude Include="trace.h" /> + </ItemGroup> + <ItemGroup> + <ClCompile Include="dllmain.cpp" /> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> + </ClCompile> + <ClCompile Include="PowerDisplayProcessManager.cpp" /> + <ClCompile Include="trace.cpp" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> + </ProjectReference> + <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="PowerDisplayModuleInterface.rc" /> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <Import Project="..\..\..\..\deps\spdlog.props" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + </Target> +</Project> \ 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="Constants.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="Trace.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="resource.h"> + <Filter>Header Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <ClCompile Include="dllmain.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="pch.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="Trace.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="PowerDisplayModuleInterface.rc"> + <Filter>Resource Files</Filter> + </ResourceCompile> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" /> + </ItemGroup> +</Project> \ 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 <common/logger/logger.h> +#include <common/utils/winapi_error.h> +#include <common/interop/shared_constants.h> +#include <atlstr.h> + +namespace +{ + std::optional<std::wstring> 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<RPC_WSTR*>(&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<RPC_WSTR*>(&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<HANDLE>(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<LPARAM>(m_hProcess)); + }); +} + +bool PowerDisplayProcessManager::is_running() const +{ + return is_process_running(); +} + +void PowerDisplayProcessManager::submit_task(std::function<void()> 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<CAtlFile>(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 <common/utils/OnThreadExecutor.h> +#include <atlfile.h> +#include <string> +#include <atomic> +#include <memory> +#include <functional> + +/// <summary> +/// Manages the PowerDisplay.exe process and Named Pipe communication. +/// Based on AdvancedPasteProcessManager pattern. +/// </summary> +class PowerDisplayProcessManager +{ +public: + PowerDisplayProcessManager() = default; + PowerDisplayProcessManager(const PowerDisplayProcessManager&) = delete; + PowerDisplayProcessManager& operator=(const PowerDisplayProcessManager&) = delete; + + /// <summary> + /// Enable the module - starts the PowerDisplay.exe process. + /// </summary> + void start(); + + /// <summary> + /// Disable the module - terminates the PowerDisplay.exe process. + /// </summary> + void stop(); + + /// <summary> + /// Send a message to PowerDisplay.exe via Named Pipe. + /// </summary> + /// <param name="message_type">The message type (e.g., "Toggle", "ApplyProfile")</param> + /// <param name="message_arg">Optional message argument</param> + void send_message(const std::wstring& message_type, const std::wstring& message_arg = L""); + + /// <summary> + /// Bring the PowerDisplay window to the foreground. + /// </summary> + void bring_to_front(); + + /// <summary> + /// Check if PowerDisplay.exe process is running. + /// </summary> + bool is_running() const; + +private: + void submit_task(std::function<void()> 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<bool> m_enabled = false; // written on main thread, read on background thread + HANDLE m_hProcess = 0; + std::unique_ptr<CAtlFile> 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 <common/Telemetry/TraceBase.h> + +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 <common/Telemetry/TraceBase.h> + +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 <interface/powertoy_module_interface.h> +#include <common/SettingsAPI/settings_objects.h> +#include "trace.h" +#include "PowerDisplayProcessManager.h" +#include <common/interop/shared_constants.h> +#include <common/utils/string_utils.h> +#include <common/utils/winapi_error.h> +#include <common/utils/logger_helper.h> +#include <common/utils/resources.h> +#include <thread> +#include <atomic> + +#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<void*>(m_hRefreshEvent)); + m_hSendSettingsTelemetryEvent = CreateDefaultEvent(CommonSharedConstants::POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT); + Logger::trace(L"Created SEND_SETTINGS_TELEMETRY_EVENT: handle={}", reinterpret_cast<void*>(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<void*>(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<void*>(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<void*>(m_hRefreshEvent), + reinterpret_cast<void*>(m_hSendSettingsTelemetryEvent), + reinterpret_cast<void*>(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(); + } + } + + /// <summary> + /// 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. + /// </summary> + 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<HINSTANCE>(&__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..d3882436a5 --- /dev/null +++ b/src/modules/powerdisplay/PowerDisplayModuleInterface/packages.config @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> + <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> +</packages> \ 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 <windows.h> +#include <strsafe.h> +#include <hIdUsage.h> +#include <shellapi.h> + +#include <thread> + +#include <winrt/Windows.Foundation.Collections.h> +#include <common/SettingsAPI/settings_helpers.h> +#include <common/logger/logger.h> 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/OneFuzzConfig.json b/src/modules/powerrename/PowerRename.FuzzingTest/OneFuzzConfig.json new file mode 100644 index 0000000000..5f7fcb8c7a --- /dev/null +++ b/src/modules/powerrename/PowerRename.FuzzingTest/OneFuzzConfig.json @@ -0,0 +1,40 @@ +{ + "configVersion": 3, + "entries": [ + { + "Fuzzer": { + "$type": "libfuzzer", + "FuzzingHarnessExecutableName": "PowerRename.FuzzTests.exe" + }, + "adoTemplate": { + // supply the values appropriate to your + // project, where bugs will be filed + "org": "microsoft", + "project": "OS", + "AssignedTo": "leilzh@microsoft.com", + "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT", + "IterationPath": "OS\\Future" + }, + "jobNotificationEmail": "PowerToys@microsoft.com", + "skip": false, + "rebootAfterSetup": false, + "oneFuzzJobs": [ + // at least one job is required + { + "projectName": "PowerToys.PowerRename", + "targetName": "PowerRename_Fuzzer" + } + ], + "jobDependencies": [ + // this should contain, at minimum, + // the DLL and PDB files + // you will need to add any other files required + // (globs are supported) + "PowerRename.FuzzTests.exe", + "PowerRename.FuzzTests.pdb", + "PowerRename.FuzzTests.lib", + "clang_rt.asan_dynamic-x86_64.dll" + ] + } + ] +} diff --git a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.cpp b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.cpp new file mode 100644 index 0000000000..5a060ad3fb --- /dev/null +++ b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.cpp @@ -0,0 +1,67 @@ +// Test.cpp : This file contains the 'main' function. Program execution begins and ends there. +// + +#include <iostream> +#include <fstream> +#include <PowerRenameRegEx.h> + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) +{ + if (size < 6) + return 0; + + size_t offset = 0; + + size_t input_len = size / 3; + size_t find_len = size / 3; + size_t replace_len = size - input_len - find_len; + + auto read_wstring = [&](size_t len) -> std::wstring { + std::wstring result; + if (offset + len > size) + len = size - offset; + + result.assign(reinterpret_cast<const wchar_t*>(data + offset), len / sizeof(wchar_t)); + offset += len; + return result; + }; + + std::wstring input = read_wstring(input_len); + std::wstring find = read_wstring(find_len); + std::wstring replace = read_wstring(replace_len); + + if (find.empty() || replace.empty()) + return 0; + + CComPtr<IPowerRenameRegEx> renamer; + CPowerRenameRegEx::s_CreateInstance(&renamer); + + renamer->PutFlags(UseRegularExpressions | CaseSensitive); + + renamer->PutSearchTerm(find.c_str()); + renamer->PutReplaceTerm(replace.c_str()); + + PWSTR result = nullptr; + unsigned long index = 0; + HRESULT hr = renamer->Replace(input.c_str(), &result, index); + if (SUCCEEDED(hr) && result != nullptr) + { + CoTaskMemFree(result); + } + + return 0; +} + +#ifndef DISABLE_FOR_FUZZING + +int main(int argc, char** argv) +{ + const char8_t raw[] = u8"test_string"; + + std::vector<uint8_t> data(reinterpret_cast<const uint8_t*>(raw), reinterpret_cast<const uint8_t*>(raw) + sizeof(raw) - 1); + + LLVMFuzzerTestOneInput(data.data(), data.size()); + return 0; +} + +#endif diff --git a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.filters b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.filters new file mode 100644 index 0000000000..21d23a2ee7 --- /dev/null +++ b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.filters @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <ClCompile Include="PowerRename.FuzzingTest.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <CopyFileToFolders Include="OneFuzzConfig.json" /> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj new file mode 100644 index 0000000000..6526b9cce8 --- /dev/null +++ b/src/modules/powerrename/PowerRename.FuzzingTest/PowerRename.FuzzingTest.vcxproj @@ -0,0 +1,110 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <VCProjectVersion>17.0</VCProjectVersion> + <Keyword>Win32Proj</Keyword> + <ProjectGuid>{2694e2fb-dcd5-4bff-a418-b6c3c7ce3b8e}</ProjectGuid> + <RootNamespace>Test</RootNamespace> + <WindowsTargetPlatformVersion>10.0.26100.0</WindowsTargetPlatformVersion> + <ProjectName>PowerRename.FuzzTests</ProjectName> + </PropertyGroup> + <PropertyGroup Label="Configuration"> + <ConfigurationType>Application</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Label="Configuration" Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <WholeProgramOptimization>true</WholeProgramOptimization> + <EnableASAN>true</EnableASAN> + <EnableFuzzer>true</EnableFuzzer> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <LibraryPath>$(VC_LibraryPath_x64);$(WindowsSDK_LibraryPath_x64);$(VCToolsInstallDir)\lib\$(Platform)</LibraryPath> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\tests\PowerRename.FuzzTests\</OutDir> + </PropertyGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>NDEBUG;_CONSOLE;DISABLE_FOR_FUZZING;%(PreprocessorDefinitions);_DISABLE_VECTOR_ANNOTATION;_DISABLE_STRING_ANNOTATION</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + <AdditionalOptions>/fsanitize=address /fsanitize-coverage=inline-8bit-counters /fsanitize-coverage=edge /fsanitize-coverage=trace-cmp /fsanitize-coverage=trace-div %(AdditionalOptions)</AdditionalOptions> + <RuntimeLibrary>MultiThreaded</RuntimeLibrary> + <LanguageStandard>stdcpplatest</LanguageStandard> + <AdditionalIncludeDirectories>..\;..\lib\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <AdditionalDependencies>legacy_stdio_definitions.lib;windowscodecs.lib;$(VCToolsInstallDir)lib\$(Platform)\libsancov.lib;$(CoreLibraryDependencies);%(AdditionalDependencies)</AdditionalDependencies> + </Link> + <PostBuildEvent> + <Command>xcopy /y "$(VCToolsInstallDir)bin\Hostx64\x64\clang_rt.asan_dynamic-x86_64.dll" "$(OutDir)"</Command> + <Message>Copy the required ASan runtime DLL to the output directory.</Message> + </PostBuildEvent> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + <AdditionalIncludeDirectories>..\;..\lib\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <AdditionalDependencies>windowscodecs.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClCompile Include="PowerRename.FuzzingTest.cpp" /> + </ItemGroup> + <ItemGroup> + <CopyFileToFolders Include="OneFuzzConfig.json" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\lib\PowerRenameLib.vcxproj" Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <Project>{51920f1f-c28c-4adf-8660-4238766796c2}</Project> + </ProjectReference> + <ProjectReference Include="..\lib\PowerRenameLib.vcxproj" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <Project>{51920f1f-c28c-4adf-8660-4238766796c2}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\boost.1.87.0\build\boost.targets" Condition="Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" /> + <Import Project="$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets" Condition="Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost.1.87.0\build\boost.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets'))" /> + </Target> +</Project> \ No newline at end of file diff --git a/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj b/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj index af3c71ad8e..c4cddc1a7b 100644 --- a/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj +++ b/src/modules/powerrename/PowerRenameContextMenu/PowerRenameContextMenu.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h PowerRenameContextMenu.base.rc PowerRenameContextMenu.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h PowerRenameContextMenu.base.rc PowerRenameContextMenu.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> @@ -10,17 +11,16 @@ <ProjectGuid>{1dbbb112-4bb1-444b-8ebb-e66555c76ba6}</ProjectGuid> <RootNamespace>PowerRenameContextMenu</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -37,7 +37,7 @@ <TargetName>PowerToys.PowerRenameContextMenu</TargetName> <!-- Needs a different int dir to avoid conflicts in msix creation. --> <IntDir>$(SolutionDir)$(Platform)\$(Configuration)\TemporaryBuild\obj\$(ProjectName)\</IntDir> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> <ClCompile> @@ -45,13 +45,13 @@ <SDLCheck>true</SDLCheck> <PreprocessorDefinitions>_DEBUG;POWERRENAMECONTEXTMENU_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>..\..\..\;..\lib\;..\..\;..\..\..\common\telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;..\lib\;..\..\;$(RepoRoot)src\common\telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> <GenerateDebugInformation>true</GenerateDebugInformation> <EnableUAC>false</EnableUAC> - <AdditionalDependencies>runtimeobject.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies>runtimeobject.lib;windowscodecs.lib;%(AdditionalDependencies)</AdditionalDependencies> <ModuleDefinitionFile>Source.def</ModuleDefinitionFile> </Link> <PreBuildEvent> @@ -67,7 +67,7 @@ MakeAppx.exe pack /d . /p $(OutDir)PowerRenameContextMenuPackage.msix /nv</Comma <SDLCheck>true</SDLCheck> <PreprocessorDefinitions>NDEBUG;POWERRENAMECONTEXTMENU_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>..\..\..\;..\lib\;..\..\;..\..\..\common\telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;..\lib\;..\..\;$(RepoRoot)src\common\telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -75,7 +75,7 @@ MakeAppx.exe pack /d . /p $(OutDir)PowerRenameContextMenuPackage.msix /nv</Comma <OptimizeReferences>true</OptimizeReferences> <GenerateDebugInformation>true</GenerateDebugInformation> <EnableUAC>false</EnableUAC> - <AdditionalDependencies>runtimeobject.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies>runtimeobject.lib;windowscodecs.lib;%(AdditionalDependencies)</AdditionalDependencies> <ModuleDefinitionFile>Source.def</ModuleDefinitionFile> </Link> <PreBuildEvent> @@ -129,10 +129,10 @@ MakeAppx.exe pack /d . /p $(OutDir)PowerRenameContextMenuPackage.msix /nv</Comma </None> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> <ProjectReference Include="..\lib\PowerRenameLib.vcxproj"> @@ -143,17 +143,17 @@ MakeAppx.exe pack /d . /p $(OutDir)PowerRenameContextMenuPackage.msix /nv</Comma <None Include="Resources.resx" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/powerrename/PowerRenameContextMenu/packages.config b/src/modules/powerrename/PowerRenameContextMenu/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/powerrename/PowerRenameContextMenu/packages.config +++ b/src/modules/powerrename/PowerRenameContextMenu/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj index 1421778986..bd3086e1ab 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameUI.vcxproj @@ -1,8 +1,16 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.props" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.props')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.props')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="NuGet"> + <!-- Tell NuGet this is PackageReference style --> + <RestoreProjectStyle>PackageReference</RestoreProjectStyle> + + <!-- Tell NuGet we're a native project --> + <NuGetTargetMoniker>native,Version=v0.0</NuGetTargetMoniker> + + <!-- Tell NuGet we target Windows (use your existing WindowsTargetPlatformVersion) --> + <NuGetTargetPlatformIdentifier>Windows</NuGetTargetPlatformIdentifier> + <NuGetTargetPlatformVersion>$(WindowsTargetPlatformVersion)</NuGetTargetPlatformVersion> + </PropertyGroup> <PropertyGroup Label="Globals"> <CppWinRTOptimized>true</CppWinRTOptimized> <CppWinRTRootNamespaceAutoMerge>true</CppWinRTRootNamespaceAutoMerge> @@ -27,12 +35,20 @@ <EnablePreviewMsixTooling>true</EnablePreviewMsixTooling> <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> <ProjectPriFileName>PowerToys.PowerRename.pri</ProjectPriFileName> + <RuntimeIdentifier>win10-x64;win10-arm64</RuntimeIdentifier> + <WindowsAppSDKVerifyTransitiveDependencies>false</WindowsAppSDKVerifyTransitiveDependencies> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.CppWinRT" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.ImplementationLibrary" GeneratePathProperty="true" /> + <PackageReference Include="boost" GeneratePathProperty="true" /> + <PackageReference Include="boost_regex-vc143" GeneratePathProperty="true" /> + </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <DesktopCompatible>true</DesktopCompatible> </PropertyGroup> @@ -53,7 +69,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> @@ -61,7 +77,7 @@ <WarningLevel>Level4</WarningLevel> <AdditionalOptions>%(AdditionalOptions) /bigobj</AdditionalOptions> <!-- Need to add $(ProjectDir)\Generated Files\PowerRenameXAML files directly because of https://github.com/microsoft/microsoft-ui-xaml/issues/7652 --> - <AdditionalIncludeDirectories>$(ProjectDir)\Generated Files\PowerRenameXAML;$(ProjectDir)..\..\..\common\Telemetry;$(ProjectDir)..\..\..\;$(ProjectDir)..\lib;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(ProjectDir)\Generated Files\PowerRenameXAML;$(RepoRoot)src\common\Telemetry;$(RepoRoot)src\;$(ProjectDir)..\lib;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> @@ -69,7 +85,7 @@ <PreprocessorDefinitions>_DEBUG;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> - <AdditionalDependencies>kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies>kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;windowscodecs.lib;propsys.lib;ole32.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> </ItemDefinitionGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> @@ -79,7 +95,7 @@ <Link> <EnableCOMDATFolding>true</EnableCOMDATFolding> <OptimizeReferences>true</OptimizeReferences> - <AdditionalDependencies>kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies>kernel32.lib;user32.lib;dwmapi.lib;Shcore.lib;windowscodecs.lib;propsys.lib;ole32.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> </ItemDefinitionGroup> <ItemGroup> @@ -185,13 +201,13 @@ <_WildCardPRIResource Include="Strings\*\Resources.resw" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> <ProjectReference Include="..\lib\PowerRenameLib.vcxproj"> @@ -202,33 +218,10 @@ <ResourceCompile Include="PowerRenameUI.rc" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\boost.1.84.0\build\boost.targets" Condition="Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" /> - <Import Project="..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets" Condition="Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.targets" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.targets')" /> - </ImportGroup> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>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}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost.1.84.0\build\boost.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.22621.2428\build\Microsoft.Windows.SDK.BuildTools.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.6.250205002\build\native\Microsoft.WindowsAppSDK.targets'))" /> - </Target> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Target Name="AddWildCardItems" AfterTargets="BuildGenerateSources"> <ItemGroup> <PRIResource Include="@(_WildCardPRIResource)" /> </ItemGroup> </Target> -</Project> \ No newline at end of file +</Project> diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp index 71c510e357..b46c5e548d 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/App.xaml.cpp @@ -1,4 +1,4 @@ -#include "pch.h" +#include "pch.h" #include "App.xaml.h" #include "MainWindow.xaml.h" @@ -6,6 +6,8 @@ #include <vector> #include <string> #include <filesystem> +#include <algorithm> +#include <cctype> #include <common/logger/logger.h> #include <common/logger/logger_settings.h> @@ -28,6 +30,61 @@ std::vector<std::wstring> g_files; const std::wstring moduleName = L"PowerRename"; +// Helper function to parse command line arguments for file paths +std::vector<std::wstring> ParseCommandLineArgs(const std::wstring& commandLine) +{ + std::vector<std::wstring> filePaths; + + // Skip executable name + size_t argsStart = 0; + if (!commandLine.empty() && commandLine[0] == L'"') + { + argsStart = commandLine.find(L'"', 1); + if (argsStart != std::wstring::npos) argsStart++; + } + else + { + argsStart = commandLine.find_first_of(L" \t"); + } + + if (argsStart == std::wstring::npos) return filePaths; + + // Get the arguments part + std::wstring args = commandLine.substr(argsStart); + + // Simple split with quote handling + std::wstring current; + bool inQuotes = false; + + for (wchar_t ch : args) + { + if (ch == L'"') + { + inQuotes = !inQuotes; + } + else if ((ch == L' ' || ch == L'\t') && !inQuotes) + { + if (!current.empty()) + { + filePaths.push_back(current); + current.clear(); + } + } + else + { + current += ch; + } + } + + // Add the last argument if any + if (!current.empty()) + { + filePaths.push_back(current); + } + + return filePaths; +} + /// <summary> /// Initializes the singleton application object. This is the first line of authored code /// executed, and as such is the logical equivalent of main() or WinMain(). @@ -60,6 +117,9 @@ App::App() /// <param name="e">Details about the launch request and process.</param> void App::OnLaunched(LaunchActivatedEventArgs const&) { + // WinUI3 framework automatically initializes COM as STA on the main thread + // No manual initialization needed for WIC operations + LoggerHelpers::init_logger(moduleName, L"", LogSettings::powerRenameLoggerName); if (powertoys_gpo::getConfiguredPowerRenameEnabledValue() == powertoys_gpo::gpo_rule_configured_disabled) @@ -69,132 +129,146 @@ void App::OnLaunched(LaunchActivatedEventArgs const&) } auto args = std::wstring{ GetCommandLine() }; - size_t pos{ args.rfind(L"\\\\.\\pipe\\") }; + size_t pipePos{ args.rfind(L"\\\\.\\pipe\\") }; - std::wstring pipe_name; - if (pos != std::wstring::npos) + // Try to parse command line arguments first + std::vector<std::wstring> cmdLineFiles = ParseCommandLineArgs(args); + + if (pipePos == std::wstring::npos && !cmdLineFiles.empty()) { - pipe_name = args.substr(pos); - } - - HANDLE hStdin; - - if (pipe_name.size() > 0) - { - while (1) + // Use command line arguments for UI testing + for (const auto& filePath : cmdLineFiles) { - hStdin = CreateFile( - pipe_name.c_str(), // pipe name - GENERIC_READ | GENERIC_WRITE, // read and write - 0, // no sharing - NULL, // default security attributes - OPEN_EXISTING, // opens existing pipe - 0, // default attributes - NULL); // no template file - - // Break if the pipe handle is valid. - if (hStdin != INVALID_HANDLE_VALUE) - break; - - // Exit if an error other than ERROR_PIPE_BUSY occurs. - auto error = GetLastError(); - if (error != ERROR_PIPE_BUSY) - { - break; - } - - if (!WaitNamedPipe(pipe_name.c_str(), 3)) - { - printf("Could not open pipe: 20 second wait timed out."); - } + g_files.push_back(filePath); } + Logger::debug(L"Starting PowerRename with {} files from command line", g_files.size()); } else { - hStdin = GetStdHandle(STD_INPUT_HANDLE); - } + // Use original pipe/stdin logic for normal operation + std::wstring pipe_name; + if (pipePos != std::wstring::npos) + { + pipe_name = args.substr(pipePos); + } - if (hStdin == INVALID_HANDLE_VALUE) - { - Logger::error(L"Invalid input handle."); - ExitProcess(1); - } + HANDLE hStdin; + + if (pipe_name.size() > 0) + { + while (1) + { + hStdin = CreateFile( + pipe_name.c_str(), // pipe name + GENERIC_READ | GENERIC_WRITE, // read and write + 0, // no sharing + NULL, // default security attributes + OPEN_EXISTING, // opens existing pipe + 0, // default attributes + NULL); // no template file + + // Break if the pipe handle is valid. + if (hStdin != INVALID_HANDLE_VALUE) + break; + + // Exit if an error other than ERROR_PIPE_BUSY occurs. + auto error = GetLastError(); + if (error != ERROR_PIPE_BUSY) + { + break; + } + + if (!WaitNamedPipe(pipe_name.c_str(), 3)) + { + printf("Could not open pipe: 20 second wait timed out."); + } + } + } + else + { + hStdin = GetStdHandle(STD_INPUT_HANDLE); + } + + if (hStdin == INVALID_HANDLE_VALUE) + { + Logger::error(L"Invalid input handle."); + ExitProcess(1); + } #ifdef DEBUG_BENCHMARK_100K_ENTRIES - const std::wstring_view ROOT_PATH = L"R:\\PowerRenameBenchmark"; + const std::wstring_view ROOT_PATH = L"R:\\PowerRenameBenchmark"; - std::wstring subdirectory_name = L"0"; - std::error_code _; + std::wstring subdirectory_name = L"0"; + std::error_code _; #if 1 - constexpr bool recreate_files = true; + constexpr bool recreate_files = true; #else - constexpr bool recreate_files = false; + constexpr bool recreate_files = false; #endif - if constexpr (recreate_files) - fs::remove_all(ROOT_PATH, _); - - g_files.push_back(fs::path{ ROOT_PATH }); - constexpr int pow2_threshold = 10; - constexpr int num_files = 100'000; - for (int i = 0; i < num_files; ++i) - { - fs::path file_path{ ROOT_PATH }; - // Create a subdirectory for each subsequent 2^pow2_threshold files, o/w filesystem becomes too slow to create them in a reasonable time. - if ((i & ((1 << pow2_threshold) - 1)) == 0) - { - subdirectory_name = std::to_wstring(i >> pow2_threshold); - } - - file_path /= subdirectory_name; - if constexpr (recreate_files) + fs::remove_all(ROOT_PATH, _); + + g_files.push_back(fs::path{ ROOT_PATH }); + constexpr int pow2_threshold = 10; + constexpr int num_files = 100'000; + for (int i = 0; i < num_files; ++i) { - fs::create_directories(file_path, _); - file_path /= std::to_wstring(i) + L".txt"; - HANDLE hFile = CreateFileW( - file_path.c_str(), - GENERIC_WRITE, - 0, - nullptr, - CREATE_NEW, - FILE_ATTRIBUTE_NORMAL, - nullptr); - CloseHandle(hFile); + fs::path file_path{ ROOT_PATH }; + // Create a subdirectory for each subsequent 2^pow2_threshold files, o/w filesystem becomes too slow to create them in a reasonable time. + if ((i & ((1 << pow2_threshold) - 1)) == 0) + { + subdirectory_name = std::to_wstring(i >> pow2_threshold); + } + + file_path /= subdirectory_name; + + if constexpr (recreate_files) + { + fs::create_directories(file_path, _); + file_path /= std::to_wstring(i) + L".txt"; + HANDLE hFile = CreateFileW( + file_path.c_str(), + GENERIC_WRITE, + 0, + nullptr, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + nullptr); + CloseHandle(hFile); + } } - } #else #define BUFSIZE 4096 * 4 - - BOOL bSuccess; - WCHAR chBuf[BUFSIZE]; - DWORD dwRead; - for (;;) - { - // Read from standard input and stop on error or no data. - bSuccess = ReadFile(hStdin, chBuf, BUFSIZE * sizeof(wchar_t), &dwRead, NULL); - - if (!bSuccess || dwRead == 0) - break; - - std::wstring inputBatch{ chBuf, dwRead / sizeof(wchar_t) }; - - std::wstringstream ss(inputBatch); - std::wstring item; - wchar_t delimiter = '?'; - while (std::getline(ss, item, delimiter)) + BOOL bSuccess; + WCHAR chBuf[BUFSIZE]; + DWORD dwRead; + for (;;) { - g_files.push_back(item); + // Read from standard input and stop on error or no data. + bSuccess = ReadFile(hStdin, chBuf, BUFSIZE * sizeof(wchar_t), &dwRead, NULL); + + if (!bSuccess || dwRead == 0) + break; + + std::wstring inputBatch{ chBuf, dwRead / sizeof(wchar_t) }; + + std::wstringstream ss(inputBatch); + std::wstring item; + wchar_t delimiter = '?'; + while (std::getline(ss, item, delimiter)) + { + g_files.push_back(item); + } + + if (!bSuccess) + break; } - - if (!bSuccess) - break; - } - CloseHandle(hStdin); + CloseHandle(hStdin); #endif - - Logger::debug(L"Starting PowerRename with {} files selected", g_files.size()); + Logger::debug(L"Starting PowerRename with {} files from pipe/stdin", g_files.size()); + } window = make<MainWindow>(); window.Activate(); -} \ No newline at end of file +} diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl index 041e3d1921..bb02ec2e14 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.idl @@ -16,6 +16,7 @@ namespace PowerRenameUI Windows.Foundation.Collections.IObservableVector<PatternSnippet> DateTimeShortcuts { get; }; Windows.Foundation.Collections.IObservableVector<PatternSnippet> CounterShortcuts { get; }; Windows.Foundation.Collections.IObservableVector<PatternSnippet> RandomizerShortcuts { get; }; + Windows.Foundation.Collections.IObservableVector<PatternSnippet> MetadataShortcuts { get; }; String OriginalCount; String RenamedCount; diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml index d049a0d067..1c67d9a73b 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml @@ -184,6 +184,7 @@ <Grid.RowDefinitions> <RowDefinition Height="*" /> <RowDefinition Height="48" /> + <RowDefinition Height="48" /> </Grid.RowDefinitions> <StackPanel @@ -329,6 +330,8 @@ <RowDefinition Height="*" /> <RowDefinition Height="28" /> <RowDefinition Height="*" /> + <RowDefinition Height="28" /> + <RowDefinition Height="*" /> </Grid.RowDefinitions> <TextBlock x:Uid="DateTimeCheatSheet_Title" FontWeight="SemiBold" /> <ListView @@ -450,6 +453,48 @@ </DataTemplate> </ListView.ItemTemplate> </ListView> + <!-- Media Metadata --> + <TextBlock + x:Uid="MetadataCheatSheet_Title" + Grid.Row="6" + Margin="0,10,0,0" + FontWeight="SemiBold" /> + <ListView + Grid.Row="7" + Margin="-4,12,0,0" + IsItemClickEnabled="True" + ItemClick="MetadataItemClick" + ItemsSource="{x:Bind MetadataShortcuts}" + SelectionMode="None"> + <ListView.ItemTemplate> + <DataTemplate x:DataType="local:PatternSnippet"> + <Grid Margin="-10,0,0,0" ColumnSpacing="8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Border + Padding="8" + HorizontalAlignment="Left" + Background="{ThemeResource ButtonBackground}" + BorderBrush="{ThemeResource ButtonBorderBrush}" + BorderThickness="1" + CornerRadius="4"> + <TextBlock + FontFamily="Consolas" + Foreground="{ThemeResource ButtonForeground}" + Text="{x:Bind Code}" /> + </Border> + <TextBlock + Grid.Column="1" + VerticalAlignment="Center" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind Description}" /> + </Grid> + </DataTemplate> + </ListView.ItemTemplate> + </ListView> </Grid> </Flyout> </Button.Flyout> @@ -558,6 +603,63 @@ Content="" FontFamily="{ThemeResource SymbolThemeFontFamily}" /> </StackPanel> + + <Grid Margin="0,16,0,0"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="12" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + + <!-- File Time Section --> + <TextBlock + x:Name="FileTimeLabel" + x:Uid="TextBlock_FileTime" + Grid.Row="0" + Grid.Column="0" + Margin="0,0,0,8" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + + <ComboBox + x:Name="comboBox_fileTimeParts" + Grid.Row="1" + Grid.Column="0" + HorizontalAlignment="Stretch" + AutomationProperties.LabeledBy="{Binding ElementName=FileTimeLabel}" + SelectedIndex="0"> + <ComboBoxItem x:Uid="FileTimeParts_CreationTime" /> + <ComboBoxItem x:Uid="FileTimeParts_ModificationTime" /> + <ComboBoxItem x:Uid="FileTimeParts_AccessTime" /> + </ComboBox> + + <!-- Metadata Source Section --> + <TextBlock + x:Name="MetadataSourceLabel" + x:Uid="TextBlock_MetadataSource" + Grid.Row="0" + Grid.Column="2" + Margin="0,0,0,8" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + + <ComboBox + x:Name="comboBox_metadataSource" + Grid.Row="1" + Grid.Column="2" + HorizontalAlignment="Stretch" + AutomationProperties.LabeledBy="{Binding ElementName=MetadataSourceLabel}" + SelectedIndex="0" + SelectionChanged="MetadataSourceComboBox_SelectionChanged"> + <ComboBoxItem x:Uid="MetadataSource_EXIF" /> + <ComboBoxItem x:Uid="MetadataSource_XMP" /> + </ComboBox> + </Grid> + </StackPanel> <Rectangle diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp index 9d5d8497d3..01c7c517c2 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.cpp @@ -1,4 +1,4 @@ -#include "pch.h" +#include "pch.h" #include "MainWindow.xaml.h" #if __has_include("MainWindow.g.cpp") #include "MainWindow.g.cpp" @@ -6,6 +6,7 @@ #include <settings.h> #include <trace.h> +#include <Helpers.h> #include <common/logger/call_tracer.h> #include <common/logger/logger.h> @@ -195,8 +196,15 @@ namespace winrt::PowerRenameUI::implementation m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$DDD", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_DayNameAbbr").ValueAsString())); m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$DD", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_DayDigitLZero").ValueAsString())); m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$D", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_DayDigit").ValueAsString())); - m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$hh", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_HoursLZero").ValueAsString())); - m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$h", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_Hours").ValueAsString())); + + m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$HH", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_Hours12LZero").ValueAsString())); + m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$H", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_Hours12").ValueAsString())); + m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$TT", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_AMPMUpperCase").ValueAsString())); + m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$tt", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_AMPMLowerCase").ValueAsString())); + + m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$hh", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_Hours24LZero").ValueAsString())); + m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$h", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_Hours24").ValueAsString())); + m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$mm", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_MinutesLZero").ValueAsString())); m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$m", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_Minutes").ValueAsString())); m_dateTimeShortcuts.Append(winrt::make<PatternSnippet>(L"$ss", manager.MainResourceMap().GetValue(L"Resources/DateTimeCheatSheet_SecondsLZero").ValueAsString())); @@ -218,6 +226,11 @@ namespace winrt::PowerRenameUI::implementation m_RandomizerShortcuts.Append(winrt::make<PatternSnippet>(L"${rstringdigit=36}", manager.MainResourceMap().GetValue(L"Resources/RandomizerCheatSheet_Digit").ValueAsString())); m_RandomizerShortcuts.Append(winrt::make<PatternSnippet>(L"${ruuidv4}", manager.MainResourceMap().GetValue(L"Resources/RandomizerCheatSheet_Uuid").ValueAsString())); + // Initialize metadata shortcuts - will be populated based on selected metadata type + m_metadataShortcuts = winrt::single_threaded_observable_vector<PowerRenameUI::PatternSnippet>(); + // Initialize with EXIF patterns (default) + UpdateMetadataShortcuts(PowerRenameLib::MetadataType::EXIF); + InitializeComponent(); m_etwTrace.UpdateState(true); @@ -349,7 +362,10 @@ namespace winrt::PowerRenameUI::implementation hstring MainWindow::OriginalCount() { UINT count = 0; - m_prManager->GetItemCount(&count); + if (m_prManager) + { + m_prManager->GetItemCount(&count); + } return hstring{ std::to_wstring(count) }; } @@ -387,13 +403,16 @@ namespace winrt::PowerRenameUI::implementation button_showAll().IsChecked(true); button_showRenamed().IsChecked(false); - DWORD filter = 0; - m_prManager->GetFilter(&filter); - if (filter != PowerRenameFilters::None) + if (m_prManager) { - m_prManager->SwitchFilter(0); - get_self<ExplorerItemsSource>(m_explorerItems)->SetIsFiltered(false); - InvalidateItemListViewState(); + DWORD filter = 0; + m_prManager->GetFilter(&filter); + if (filter != PowerRenameFilters::None) + { + m_prManager->SwitchFilter(0); + get_self<ExplorerItemsSource>(m_explorerItems)->SetIsFiltered(false); + InvalidateItemListViewState(); + } } } @@ -402,14 +421,17 @@ namespace winrt::PowerRenameUI::implementation button_showRenamed().IsChecked(true); button_showAll().IsChecked(false); - DWORD filter = 0; - m_prManager->GetFilter(&filter); - if (filter != PowerRenameFilters::ShouldRename) + if (m_prManager) { - m_prManager->SwitchFilter(0); - UpdateCounts(); - get_self<ExplorerItemsSource>(m_explorerItems)->SetIsFiltered(true); - InvalidateItemListViewState(); + DWORD filter = 0; + m_prManager->GetFilter(&filter); + if (filter != PowerRenameFilters::ShouldRename) + { + m_prManager->SwitchFilter(0); + UpdateCounts(); + get_self<ExplorerItemsSource>(m_explorerItems)->SetIsFiltered(true); + InvalidateItemListViewState(); + } } } @@ -427,6 +449,27 @@ namespace winrt::PowerRenameUI::implementation textBox_replace().Text(textBox_replace().Text() + s->Code()); } + void MainWindow::MetadataItemClick(winrt::Windows::Foundation::IInspectable const&, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e) + { + auto s = e.ClickedItem().try_as<PatternSnippet>(); + DateTimeFlyout().Hide(); + textBox_replace().Text(textBox_replace().Text() + s->Code()); + } + + void MainWindow::MetadataSourceComboBox_SelectionChanged(winrt::Windows::Foundation::IInspectable const&, winrt::Microsoft::UI::Xaml::Controls::SelectionChangedEventArgs const&) + { + int selectedIndex = comboBox_metadataSource().SelectedIndex(); + + // Get the selected metadata type based on ComboBox selection + PowerRenameLib::MetadataType metadataType = static_cast<PowerRenameLib::MetadataType>(selectedIndex); + + // Update the metadata shortcuts list + UpdateMetadataShortcuts(metadataType); + + // Update the metadata source flags + UpdateMetadataSourceFlags(selectedIndex); + } + void MainWindow::button_rename_Click(winrt::Microsoft::UI::Xaml::Controls::SplitButton const&, winrt::Microsoft::UI::Xaml::Controls::SplitButtonClickEventArgs const&) { Rename(false); @@ -614,6 +657,12 @@ namespace winrt::PowerRenameUI::implementation { _TRACER_; + if (!m_prManager) + { + // Manager not initialized yet, ignore flag updates + return; + } + DWORD flags{}; m_prManager->GetFlags(&flags); @@ -785,6 +834,33 @@ namespace winrt::PowerRenameUI::implementation button_settings().Click([&](auto const&, auto const&) { OpenSettingsApp(); }); + + // ComboBox RenameParts + comboBox_fileTimeParts().SelectionChanged([&](auto const&, auto const&) { + int selectedIndex = comboBox_fileTimeParts().SelectedIndex(); + if (selectedIndex == 0) + { + // default behaviour. Date Created + UpdateFlag(CreationTime, UpdateFlagCommand::Set); + UpdateFlag(ModificationTime, UpdateFlagCommand::Reset); + UpdateFlag(AccessTime, UpdateFlagCommand::Reset); + } + else if (selectedIndex == 1) + { + // Date Modified + UpdateFlag(ModificationTime, UpdateFlagCommand::Set); + UpdateFlag(CreationTime, UpdateFlagCommand::Reset); + UpdateFlag(AccessTime, UpdateFlagCommand::Reset); + } + else if (selectedIndex == 2) + { + // Accessed + UpdateFlag(AccessTime, UpdateFlagCommand::Set); + UpdateFlag(CreationTime, UpdateFlagCommand::Reset); + UpdateFlag(ModificationTime, UpdateFlagCommand::Reset); + } + }); + } void MainWindow::ToggleItem(int32_t id, bool checked) @@ -1016,6 +1092,15 @@ namespace winrt::PowerRenameUI::implementation { toggleButton_capitalize().IsChecked(true); } + + int metadataIndex = (flags & MetadataSourceXMP) ? 1 : 0; + if (comboBox_metadataSource().SelectedIndex() != metadataIndex) + { + comboBox_metadataSource().SelectedIndex(metadataIndex); + } + + auto metadataType = metadataIndex == 1 ? PowerRenameLib::MetadataType::XMP : PowerRenameLib::MetadataType::EXIF; + UpdateMetadataShortcuts(metadataType); } void MainWindow::UpdateCounts() @@ -1048,6 +1133,220 @@ namespace winrt::PowerRenameUI::implementation RenamedCount(hstring{ std::to_wstring(m_renamingCount) }); } + void MainWindow::UpdateMetadataShortcuts(PowerRenameLib::MetadataType metadataType) + { + // Clear existing list + m_metadataShortcuts.Clear(); + + // Get supported patterns for the selected metadata type + auto supportedPatterns = PowerRenameLib::MetadataPatternExtractor::GetSupportedPatterns(metadataType); + + auto factory = winrt::get_activation_factory<ResourceManager, IResourceManagerFactory>(); + ResourceManager manager = factory.CreateInstance(L"PowerToys.PowerRename.pri"); + + // Add each supported pattern to the list + for (const auto& pattern : supportedPatterns) + { + std::wstring resourceKey = L"Resources/MetadataCheatSheet_" + ConvertPatternToResourceKey(pattern); + winrt::hstring patternWithDollar = winrt::hstring(L"$" + pattern); + + try { + auto description = manager.MainResourceMap().GetValue(resourceKey).ValueAsString(); + m_metadataShortcuts.Append(winrt::make<PatternSnippet>(patternWithDollar, description)); + } + catch (...) { + // If resource doesn't exist, use the pattern name as description + m_metadataShortcuts.Append(winrt::make<PatternSnippet>(patternWithDollar, winrt::hstring(pattern))); + } + } + } + + std::wstring MainWindow::ConvertPatternToResourceKey(const std::wstring& pattern) + { + // Special cases for patterns that don't follow the standard naming convention + if (pattern == L"TITLE") + { + return L"DocTitle"; + } + else if (pattern == L"DATE_TAKEN_YYYY") + { + return L"DateTakenYear4"; + } + else if (pattern == L"DATE_TAKEN_YY") + { + return L"DateTakenYear2"; + } + else if (pattern == L"DATE_TAKEN_MM") + { + return L"DateTakenMonth"; + } + else if (pattern == L"DATE_TAKEN_DD") + { + return L"DateTakenDay"; + } + else if (pattern == L"DATE_TAKEN_HH") + { + return L"DateTakenHour"; + } + else if (pattern == L"DATE_TAKEN_mm") + { + return L"DateTakenMinute"; + } + else if (pattern == L"DATE_TAKEN_SS") + { + return L"DateTakenSecond"; + } + else if (pattern == L"CREATE_DATE_YYYY") + { + return L"CreateDateYear4"; + } + else if (pattern == L"CREATE_DATE_YY") + { + return L"CreateDateYear2"; + } + else if (pattern == L"CREATE_DATE_MM") + { + return L"CreateDateMonth"; + } + else if (pattern == L"CREATE_DATE_DD") + { + return L"CreateDateDay"; + } + else if (pattern == L"CREATE_DATE_HH") + { + return L"CreateDateHour"; + } + else if (pattern == L"CREATE_DATE_mm") + { + return L"CreateDateMinute"; + } + else if (pattern == L"CREATE_DATE_SS") + { + return L"CreateDateSecond"; + } + else if (pattern == L"MODIFY_DATE_YYYY") + { + return L"ModifyDateYear4"; + } + else if (pattern == L"MODIFY_DATE_YY") + { + return L"ModifyDateYear2"; + } + else if (pattern == L"MODIFY_DATE_MM") + { + return L"ModifyDateMonth"; + } + else if (pattern == L"MODIFY_DATE_DD") + { + return L"ModifyDateDay"; + } + else if (pattern == L"MODIFY_DATE_HH") + { + return L"ModifyDateHour"; + } + else if (pattern == L"MODIFY_DATE_mm") + { + return L"ModifyDateMinute"; + } + else if (pattern == L"MODIFY_DATE_SS") + { + return L"ModifyDateSecond"; + } + else if (pattern == L"METADATA_DATE_YYYY") + { + return L"MetadataDateYear4"; + } + else if (pattern == L"METADATA_DATE_YY") + { + return L"MetadataDateYear2"; + } + else if (pattern == L"METADATA_DATE_MM") + { + return L"MetadataDateMonth"; + } + else if (pattern == L"METADATA_DATE_DD") + { + return L"MetadataDateDay"; + } + else if (pattern == L"METADATA_DATE_HH") + { + return L"MetadataDateHour"; + } + else if (pattern == L"METADATA_DATE_mm") + { + return L"MetadataDateMinute"; + } + else if (pattern == L"METADATA_DATE_SS") + { + return L"MetadataDateSecond"; + } + else if (pattern == L"ISO") + { + return L"ISO"; + } + else if (pattern == L"TITLE") + { + return L"DocTitle"; + } + else if (pattern == L"DESCRIPTION") + { + return L"DocDescription"; + } + else if (pattern == L"CREATOR") + { + return L"DocCreator"; + } + else if (pattern == L"SUBJECT") + { + return L"DocSubject"; + } + else if (pattern == L"RIGHTS") + { + return L"Rights"; + } + + // Convert pattern name to resource key format + // e.g., "CAMERA_MAKE" -> "CameraMake" + std::wstring result; + bool capitalizeNext = true; + + for (wchar_t ch : pattern) + { + if (ch == L'_') + { + capitalizeNext = true; + } + else + { + if (capitalizeNext) + { + result += static_cast<wchar_t>(std::toupper(ch)); + capitalizeNext = false; + } + else + { + result += static_cast<wchar_t>(std::tolower(ch)); + } + } + } + + return result; + } + + void MainWindow::UpdateMetadataSourceFlags(int selectedIndex) + { + // Clear all metadata source flags first + UpdateFlag(MetadataSourceEXIF, UpdateFlagCommand::Reset); + UpdateFlag(MetadataSourceXMP, UpdateFlagCommand::Reset); + + // Set the appropriate metadata source flag based on selection + switch(selectedIndex) { + case 0: UpdateFlag(MetadataSourceEXIF, UpdateFlagCommand::Set); break; + case 1: UpdateFlag(MetadataSourceXMP, UpdateFlagCommand::Set); break; + default: UpdateFlag(MetadataSourceEXIF, UpdateFlagCommand::Set); break; // Default to EXIF + } + } + HRESULT MainWindow::OnRename(_In_ IPowerRenameItem* /*renameItem*/) { UpdateCounts(); @@ -1089,3 +1388,6 @@ namespace winrt::PowerRenameUI::implementation return S_OK; } } + + + diff --git a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h index 8c70194f1b..cff802f582 100644 --- a/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h +++ b/src/modules/powerrename/PowerRenameUILib/PowerRenameXAML/MainWindow.xaml.h @@ -1,4 +1,4 @@ -#pragma once +#pragma once #include "winrt/Windows.UI.Xaml.h" #include "winrt/Windows.UI.Xaml.Markup.h" @@ -20,6 +20,8 @@ #include <PowerRenameManager.h> #include <PowerRenameInterfaces.h> #include <PowerRenameMRU.h> +#include <MetadataTypes.h> +#include <MetadataPatternExtractor.h> namespace winrt::PowerRenameUI::implementation { @@ -88,6 +90,7 @@ namespace winrt::PowerRenameUI::implementation winrt::Windows::Foundation::Collections::IObservableVector<PowerRenameUI::PatternSnippet> DateTimeShortcuts() { return m_dateTimeShortcuts; } winrt::Windows::Foundation::Collections::IObservableVector<PowerRenameUI::PatternSnippet> CounterShortcuts() { return m_CounterShortcuts; } winrt::Windows::Foundation::Collections::IObservableVector<PowerRenameUI::PatternSnippet> RandomizerShortcuts() { return m_RandomizerShortcuts; } + winrt::Windows::Foundation::Collections::IObservableVector<PowerRenameUI::PatternSnippet> MetadataShortcuts() { return m_metadataShortcuts; } hstring OriginalCount(); void OriginalCount(hstring value); @@ -111,6 +114,7 @@ namespace winrt::PowerRenameUI::implementation winrt::Windows::Foundation::Collections::IObservableVector<PowerRenameUI::PatternSnippet> m_dateTimeShortcuts; winrt::Windows::Foundation::Collections::IObservableVector<PowerRenameUI::PatternSnippet> m_CounterShortcuts; winrt::Windows::Foundation::Collections::IObservableVector<PowerRenameUI::PatternSnippet> m_RandomizerShortcuts; + winrt::Windows::Foundation::Collections::IObservableVector<PowerRenameUI::PatternSnippet> m_metadataShortcuts; // Used by PowerRenameManagerEvents HRESULT OnRename(_In_ IPowerRenameItem* renameItem); @@ -144,6 +148,9 @@ namespace winrt::PowerRenameUI::implementation HRESULT OpenSettingsApp(); void SetCheckboxesFromFlags(DWORD flags); void UpdateCounts(); + void UpdateMetadataShortcuts(PowerRenameLib::MetadataType metadataType); + std::wstring ConvertPatternToResourceKey(const std::wstring& pattern); + void UpdateMetadataSourceFlags(int selectedIndex); Shared::Trace::ETWTrace m_etwTrace{}; @@ -167,6 +174,8 @@ namespace winrt::PowerRenameUI::implementation public: void RegExItemClick(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e); void DateTimeItemClick(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e); + void MetadataItemClick(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::ItemClickEventArgs const& e); + void MetadataSourceComboBox_SelectionChanged(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::Controls::SelectionChangedEventArgs const& e); void button_rename_Click(winrt::Microsoft::UI::Xaml::Controls::SplitButton const& sender, winrt::Microsoft::UI::Xaml::Controls::SplitButtonClickEventArgs const& args); void MenuFlyoutItem_Click(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::RoutedEventArgs const& e); void OpenDocs(winrt::Windows::Foundation::IInspectable const& sender, winrt::Microsoft::UI::Xaml::RoutedEventArgs const& e); @@ -179,3 +188,4 @@ namespace winrt::PowerRenameUI::factory_implementation { }; } + diff --git a/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw b/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw index c1d55917f7..178106908d 100644 --- a/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw +++ b/src/modules/powerrename/PowerRenameUILib/Strings/en-us/Resources.resw @@ -219,11 +219,23 @@ <data name="DateTimeCheatSheet_DayDigit" xml:space="preserve"> <value>Day of the month as digits without leading zeros for single-digit days.</value> </data> - <data name="DateTimeCheatSheet_HoursLZero" xml:space="preserve"> - <value>Hours with leading zeros for single-digit hours.</value> + <data name="DateTimeCheatSheet_Hours12LZero" xml:space="preserve"> + <value>Hours in 12-hour format (01-12) with leading zero.</value> </data> - <data name="DateTimeCheatSheet_Hours" xml:space="preserve"> - <value>Hours without leading zeros for single-digit hours.</value> + <data name="DateTimeCheatSheet_Hours12" xml:space="preserve"> + <value>Hours in 12-hour format (1-12) without leading zero.</value> + </data> + <data name="DateTimeCheatSheet_AMPMUpperCase" xml:space="preserve"> + <value>AM/PM indicator in uppercase (AM or PM).</value> + </data> + <data name="DateTimeCheatSheet_AMPMLowerCase" xml:space="preserve"> + <value>AM/PM indicator in lowercase (am or pm).</value> + </data> + <data name="DateTimeCheatSheet_Hours24LZero" xml:space="preserve"> + <value>Hours in 24-hour format (00-23) with leading zero.</value> + </data> + <data name="DateTimeCheatSheet_Hours24" xml:space="preserve"> + <value>Hours in 24-hour format (0-23) without leading zero.</value> </data> <data name="DateTimeCheatSheet_MinutesLZero" xml:space="preserve"> <value>Minutes with leading zeros for single-digit minutes.</value> @@ -399,4 +411,164 @@ <data name="ToggleButton_RandItems.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve"> <value>Random string features</value> </data> + <data name="TextBlock_FileTime.Text" xml:space="preserve"> + <value>Time used for replacement</value> + </data> + <data name="TextBlock_MetadataSource.Text" xml:space="preserve"> + <value>Metadata source for replacement</value> + </data> + <data name="FileTimeParts_CreationTime.Content" xml:space="preserve"> + <value>Creation Time</value> + </data> + <data name="FileTimeParts_ModificationTime.Content" xml:space="preserve"> + <value>Modification Time</value> + </data> + <data name="FileTimeParts_AccessTime.Content" xml:space="preserve"> + <value>Access Time</value> + </data> + + <data name="MetadataSource_EXIF.Content" xml:space="preserve"> + <value>EXIF Metadata</value> + </data> + <data name="MetadataSource_XMP.Content" xml:space="preserve"> + <value>XMP Metadata</value> + </data> + + <data name="MetadataCheatSheet_Title.Text" xml:space="preserve"> + <value>Replace with media metadata</value> + </data> + <data name="MetadataCheatSheet_CameraMake" xml:space="preserve"> + <value>Camera manufacturer name</value> + </data> + <data name="MetadataCheatSheet_CameraModel" xml:space="preserve"> + <value>Camera model name</value> + </data> + <data name="MetadataCheatSheet_Lens" xml:space="preserve"> + <value>Lens model name</value> + </data> + <data name="MetadataCheatSheet_ISO" xml:space="preserve"> + <value>ISO sensitivity value</value> + </data> + <data name="MetadataCheatSheet_Aperture" xml:space="preserve"> + <value>F-number aperture value</value> + </data> + <data name="MetadataCheatSheet_Shutter" xml:space="preserve"> + <value>Shutter speed value</value> + </data> + <data name="MetadataCheatSheet_Focal" xml:space="preserve"> + <value>Focal length in millimeters</value> + </data> + <data name="MetadataCheatSheet_Flash" xml:space="preserve"> + <value>Flash status (On/Off)</value> + </data> + <data name="MetadataCheatSheet_Width" xml:space="preserve"> + <value>Image width in pixels</value> + </data> + <data name="MetadataCheatSheet_Height" xml:space="preserve"> + <value>Image height in pixels</value> + </data> + <data name="MetadataCheatSheet_Author" xml:space="preserve"> + <value>Image author/artist</value> + </data> + <data name="MetadataCheatSheet_Copyright" xml:space="preserve"> + <value>Copyright information</value> + </data> + <data name="MetadataCheatSheet_Latitude" xml:space="preserve"> + <value>GPS latitude coordinate</value> + </data> + <data name="MetadataCheatSheet_Longitude" xml:space="preserve"> + <value>GPS longitude coordinate</value> + </data> + <data name="MetadataCheatSheet_Altitude" xml:space="preserve"> + <value>GPS altitude in meters</value> + </data> + <data name="MetadataCheatSheet_ExposureBias" xml:space="preserve"> + <value>Exposure compensation value</value> + </data> + <data name="MetadataCheatSheet_Orientation" xml:space="preserve"> + <value>Image orientation</value> + </data> + <data name="MetadataCheatSheet_ColorSpace" xml:space="preserve"> + <value>Color space information</value> + </data> + <data name="MetadataCheatSheet_DateTakenYear4" xml:space="preserve"> + <value>Year photo was taken (4 digits)</value> + </data> + <data name="MetadataCheatSheet_DateTakenYear2" xml:space="preserve"> + <value>Year photo was taken (2 digits)</value> + </data> + <data name="MetadataCheatSheet_DateTakenMonth" xml:space="preserve"> + <value>Month photo was taken (01-12)</value> + </data> + <data name="MetadataCheatSheet_DateTakenDay" xml:space="preserve"> + <value>Day photo was taken (01-31)</value> + </data> + <data name="MetadataCheatSheet_DateTakenHour" xml:space="preserve"> + <value>Hour photo was taken (00-23)</value> + </data> + <data name="MetadataCheatSheet_DateTakenMinute" xml:space="preserve"> + <value>Minute photo was taken (00-59)</value> + </data> + <data name="MetadataCheatSheet_DateTakenSecond" xml:space="preserve"> + <value>Second photo was taken (00-59)</value> + </data> + <data name="MetadataCheatSheet_CreateDateYear4" xml:space="preserve"> + <value>Year from XMP create date (4 digits)</value> + </data> + <data name="MetadataCheatSheet_CreateDateYear2" xml:space="preserve"> + <value>Year from XMP create date (2 digits)</value> + </data> + <data name="MetadataCheatSheet_CreateDateMonth" xml:space="preserve"> + <value>Month from XMP create date (01-12)</value> + </data> + <data name="MetadataCheatSheet_CreateDateDay" xml:space="preserve"> + <value>Day from XMP create date (01-31)</value> + </data> + <data name="MetadataCheatSheet_CreateDateHour" xml:space="preserve"> + <value>Hour from XMP create date (00-23)</value> + </data> + <data name="MetadataCheatSheet_CreateDateMinute" xml:space="preserve"> + <value>Minute from XMP create date (00-59)</value> + </data> + <data name="MetadataCheatSheet_CreateDateSecond" xml:space="preserve"> + <value>Second from XMP create date (00-59)</value> + </data> + + <!-- XMP patterns --> + <data name="MetadataCheatSheet_CreatorTool" xml:space="preserve"> + <value>Software used to create/edit</value> + </data> + + <!-- Dublin Core patterns --> + <data name="MetadataCheatSheet_DocTitle" xml:space="preserve"> + <value>Document title</value> + </data> + <data name="MetadataCheatSheet_DocDescription" xml:space="preserve"> + <value>Document description</value> + </data> + <data name="MetadataCheatSheet_DocCreator" xml:space="preserve"> + <value>Document creator/author</value> + </data> + <data name="MetadataCheatSheet_DocSubject" xml:space="preserve"> + <value>Keywords/tags</value> + </data> + + <!-- XMP Rights pattern --> + <data name="MetadataCheatSheet_Rights" xml:space="preserve"> + <value>Copyright/rights information</value> + </data> + + <!-- XMP Media Management schema patterns --> + <data name="MetadataCheatSheet_DocumentId" xml:space="preserve"> + <value>Document unique identifier</value> + </data> + <data name="MetadataCheatSheet_InstanceId" xml:space="preserve"> + <value>Instance unique identifier</value> + </data> + <data name="MetadataCheatSheet_OriginalDocumentId" xml:space="preserve"> + <value>Original document identifier</value> + </data> + <data name="MetadataCheatSheet_VersionId" xml:space="preserve"> + <value>Version identifier</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/powerrename/PowerRenameUILib/app.manifest b/src/modules/powerrename/PowerRenameUILib/app.manifest index 8554f867e0..a5c3bbd209 100644 --- a/src/modules/powerrename/PowerRenameUILib/app.manifest +++ b/src/modules/powerrename/PowerRenameUILib/app.manifest @@ -9,7 +9,7 @@ 2) System < Windows 10 Anniversary Update --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> diff --git a/src/modules/powerrename/PowerRenameUILib/packages.config b/src/modules/powerrename/PowerRenameUILib/packages.config deleted file mode 100644 index 893bd804ad..0000000000 --- a/src/modules/powerrename/PowerRenameUILib/packages.config +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="boost" version="1.84.0" targetFramework="native" /> - <package id="boost_regex-vc143" version="1.84.0" targetFramework="native" /> - <package id="Microsoft.Web.WebView2" version="1.0.2903.40" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> - <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> - <package id="Microsoft.Windows.SDK.BuildTools" version="10.0.22621.2428" targetFramework="native" /> - <package id="Microsoft.WindowsAppSDK" version="1.6.250205002" targetFramework="native" /> -</packages> \ No newline at end of file diff --git a/src/modules/powerrename/PowerRenameUITest/BasicRenameTests.cs b/src/modules/powerrename/PowerRenameUITest/BasicRenameTests.cs new file mode 100644 index 0000000000..6f3f259734 --- /dev/null +++ b/src/modules/powerrename/PowerRenameUITest/BasicRenameTests.cs @@ -0,0 +1,87 @@ +// 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.Drawing.Text; +using System.IO; +using System.Reflection; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerRename.UITests; + +/// <summary> +/// Initializes a new instance of the <see cref="BasicRenameTests"/> class. +/// Initialize PowerRename UITest with custom test file paths +/// </summary> +/// <param name="testFilePaths">Array of file/folder paths to test with</param> +[TestClass] +public class BasicRenameTests : PowerRenameUITestBase +{ + /// <summary> + /// Initializes a new instance of the <see cref="BasicRenameTests"/> class. + /// Initialize PowerRename UITest with default test files + /// </summary> + public BasicRenameTests() + : base() + { + } + + [TestMethod] + public void BasicInput() + { + this.SetSearchBoxText("search"); + this.SetReplaceBoxText("replace"); + } + + [TestMethod] + public void BasicMatchFileName() + { + this.SetSearchBoxText("testCase1"); + this.SetReplaceBoxText("replaced"); + + Assert.IsTrue(this.Find<TextBlock>("replaced.txt").Text == "replaced.txt"); + } + + [TestMethod] + public void BasicRegularMatch() + { + this.SetSearchBoxText("^test.*\\.txt$"); + this.SetReplaceBoxText("matched.txt"); + + CheckOriginalOrRenamedCount(0); + + this.SetRegularExpressionCheckbox(true); + + CheckOriginalOrRenamedCount(2); + + Assert.IsTrue(this.Find<TextBlock>("matched.txt").Text == "matched.txt"); + } + + [TestMethod] + public void BasicMatchAllOccurrences() + { + this.SetSearchBoxText("t"); + this.SetReplaceBoxText("f"); + + this.SetMatchAllOccurrencesCheckbox(true); + + Assert.IsTrue(this.Find<TextBlock>("fesfCase2.fxf").Text == "fesfCase2.fxf"); + Assert.IsTrue(this.Find<TextBlock>("fesfCase1.fxf").Text == "fesfCase1.fxf"); + } + + [TestMethod] + public void BasicCaseSensitive() + { + this.SetSearchBoxText("testcase1"); + this.SetReplaceBoxText("match1"); + + CheckOriginalOrRenamedCount(1); + Assert.IsTrue(this.Find<TextBlock>("match1.txt").Text == "match1.txt"); + + this.SetCaseSensitiveCheckbox(true); + CheckOriginalOrRenamedCount(0); + } +} diff --git a/src/modules/powerrename/PowerRenameUITest/PowerRename.UITests.csproj b/src/modules/powerrename/PowerRenameUITest/PowerRename.UITests.csproj new file mode 100644 index 0000000000..7cdde8ae3b --- /dev/null +++ b/src/modules/powerrename/PowerRenameUITest/PowerRename.UITests.csproj @@ -0,0 +1,41 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <RootNamespace>PowerRename.UITests</RootNamespace> + <AssemblyName>PowerRename.UITests</AssemblyName> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + <!-- This is a UI test, so don't run as part of MSBuild --> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerRename.UITests\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <Compile Include="..\..\..\codeAnalysis\GlobalSuppressions.cs" Link="GlobalSuppressions.cs" /> + </ItemGroup> + + <ItemGroup> + <None Include="BasicRenameTests.cs" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="MSTest" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> + <ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> + + <ItemGroup> + <!-- Copy testItems folder and all contents to output directory --> + <Content Include="testItems\**\*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/powerrename/PowerRenameUITest/PowerRenameUITestBase.cs b/src/modules/powerrename/PowerRenameUITest/PowerRenameUITestBase.cs new file mode 100644 index 0000000000..9d1eda8668 --- /dev/null +++ b/src/modules/powerrename/PowerRenameUITest/PowerRenameUITestBase.cs @@ -0,0 +1,200 @@ +// 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.IO; +using System.Reflection; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace PowerRename.UITests; + +[TestClass] +public class PowerRenameUITestBase : UITestBase +{ + private static readonly string[] OriginalTestFilePaths = new string[] + { + Path.Combine("testItems", "folder1"), // Test folder + Path.Combine("testItems", "folder2"), // Test folder + Path.Combine("testItems", "testCase1.txt"), // Test file + }; + + private static readonly string BaseTestFileFolderPath = Path.Combine(Assembly.GetExecutingAssembly().Location, "..", "test", typeof(BasicRenameTests).Name); + + private static List<string> TestFilesAndFoldersArray { get; } = InitCleanTestEnvironment(); + + private static List<string> InitCleanTestEnvironment() + { + var testFilesAndFolders = new List<string> + { + }; + + foreach (var files in OriginalTestFilePaths) + { + var targetFolder = Path.Combine(BaseTestFileFolderPath, files); + testFilesAndFolders.Add(targetFolder); + } + + return testFilesAndFolders; + } + + [TestInitialize] + public void InitTestCase() + { + // Clean up any existing test directories for this test class + CleanupTestDirectories(); + + // copy files and folders from OriginalTestFilePaths to testFilesAndFoldersArray + CopyTestFilesToDestination(); + + RestartScopeExe(); + } + + /// <summary> + /// Initializes a new instance of the <see cref="PowerRenameUITestBase"/> class. + /// Initialize PowerRename UITest with default test files + /// </summary> + public PowerRenameUITestBase() + : base(PowerToysModule.PowerRename, WindowSize.UnSpecified, TestFilesAndFoldersArray.ToArray()) + { + } + + /// <summary> + /// Clean up any existing test directories for the specified test class + /// </summary> + private static void CleanupTestDirectories() + { + try + { + if (Directory.Exists(BaseTestFileFolderPath)) + { + Directory.Delete(BaseTestFileFolderPath, true); + Console.WriteLine($"Cleaned up old test directory: {BaseTestFileFolderPath}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Error during cleanup: {ex.Message}"); + } + + try + { + Directory.CreateDirectory(BaseTestFileFolderPath); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Error during cleanup create folder: {ex.Message}"); + } + } + + /// <summary> + /// Copy test files and folders from source paths to destination paths + /// </summary> + private static void CopyTestFilesToDestination() + { + try + { + for (int i = 0; i < OriginalTestFilePaths.Length && i < TestFilesAndFoldersArray.Count; i++) + { + var sourcePath = Path.GetFullPath(OriginalTestFilePaths[i]); + var destinationPath = TestFilesAndFoldersArray[i]; + + var destinationDir = Path.GetDirectoryName(destinationPath); + if (destinationDir != null && !Directory.Exists(destinationDir)) + { + Directory.CreateDirectory(destinationDir); + } + + if (Directory.Exists(sourcePath)) + { + CopyDirectory(sourcePath, destinationPath); + Console.WriteLine($"Copied directory from {sourcePath} to {destinationPath}"); + } + else if (File.Exists(sourcePath)) + { + File.Copy(sourcePath, destinationPath, true); + Console.WriteLine($"Copied file from {sourcePath} to {destinationPath}"); + } + else + { + Console.WriteLine($"Warning: Source path does not exist: {sourcePath}"); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error during file copy operation: {ex.Message}"); + throw; + } + } + + /// <summary> + /// Recursively copy a directory and its contents + /// </summary> + /// <param name="sourceDir">Source directory path</param> + /// <param name="destDir">Destination directory path</param> + private static void CopyDirectory(string sourceDir, string destDir) + { + try + { + // Create target directory + if (!Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + // Copy all files + foreach (var file in Directory.GetFiles(sourceDir)) + { + var fileName = Path.GetFileName(file); + var destFile = Path.Combine(destDir, fileName); + File.Copy(file, destFile, true); + } + + // Recursively copy all subdirectories + foreach (var dir in Directory.GetDirectories(sourceDir)) + { + var dirName = Path.GetFileName(dir); + var destSubDir = Path.Combine(destDir, dirName); + CopyDirectory(dir, destSubDir); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error copying directory from {sourceDir} to {destDir}: {ex.Message}"); + throw; + } + } + + protected void SetSearchBoxText(string text) + { + Assert.IsTrue(this.Find<TextBox>("Search for").SetText(text, true).Text == text); + } + + protected void SetReplaceBoxText(string text) + { + Assert.IsTrue(this.Find<TextBox>("Replace with").SetText(text, true).Text == text); + } + + protected void SetRegularExpressionCheckbox(bool flag) + { + Assert.IsTrue(this.Find<CheckBox>("Use regular expressions").SetCheck(flag).IsChecked == flag); + } + + protected void SetMatchAllOccurrencesCheckbox(bool flag) + { + Assert.IsTrue(this.Find<CheckBox>("Match all occurrences").SetCheck(flag).IsChecked == flag); + } + + protected void SetCaseSensitiveCheckbox(bool flag) + { + Assert.IsTrue(this.Find<CheckBox>("Case sensitive").SetCheck(flag).IsChecked == flag); + } + + protected void CheckOriginalOrRenamedCount(int count) + { + Assert.IsTrue(this.Find<TextBlock>($"({count})").Text == $"({count})"); + } +} diff --git a/src/modules/powerrename/PowerRenameUITest/testItems/folder1/testCase2.txt b/src/modules/powerrename/PowerRenameUITest/testItems/folder1/testCase2.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/modules/powerrename/PowerRenameUITest/testItems/folder2/SpecialCase.txt b/src/modules/powerrename/PowerRenameUITest/testItems/folder2/SpecialCase.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/modules/powerrename/PowerRenameUITest/testItems/testCase1.txt b/src/modules/powerrename/PowerRenameUITest/testItems/testCase1.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/modules/powerrename/dll/PowerRenameExt.vcxproj b/src/modules/powerrename/dll/PowerRenameExt.vcxproj index ead9518f35..1f852ef2ef 100644 --- a/src/modules/powerrename/dll/PowerRenameExt.vcxproj +++ b/src/modules/powerrename/dll/PowerRenameExt.vcxproj @@ -1,30 +1,30 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h PowerRenameExt.base.rc PowerRenameExt.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h PowerRenameExt.base.rc PowerRenameExt.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{B25AC7A5-FB9F-4789-B392-D5C85E948670}</ProjectGuid> <RootNamespace>PowerRenameExt</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <TargetName>PowerToys.PowerRenameExt</TargetName> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\lib\;..\PowerRenameUILib\;..\;..\..\..\;..\..\..\common\telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\lib\;..\PowerRenameUILib\;..\;$(RepoRoot)src\;$(RepoRoot)src\common\telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> - <AdditionalDependencies>Pathcch.lib;comctl32.lib;shcore.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies>Pathcch.lib;comctl32.lib;shcore.lib;windowscodecs.lib;%(AdditionalDependencies)</AdditionalDependencies> <ModuleDefinitionFile>PowerRenameExt.def</ModuleDefinitionFile> <DelayLoadDLLs>gdi32.dll;shell32.dll;ole32.dll;shlwapi.dll;oleaut32.dll;%(DelayLoadDLLs)</DelayLoadDLLs> </Link> @@ -34,6 +34,7 @@ <ClInclude Include="Generated Files/resource.h" /> <ClInclude Include="PowerRenameConstants.h" /> <ClInclude Include="PowerRenameExt.h" /> + <ClInclude Include="RuntimeRegistration.h" /> <ClInclude Include="pch.h" /> <None Include="resource.base.h" /> <ClInclude Include="targetver.h" /> @@ -54,16 +55,16 @@ <None Include="PowerRenameExt.def" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Telemetry\EtwTrace\EtwTrace.vcxproj"> <Project>{8f021b46-362b-485c-bfba-ccf83e820cbd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> <ProjectReference Include="..\lib\PowerRenameLib.vcxproj"> @@ -74,19 +75,19 @@ <None Include="Resources.resx" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\boost.1.84.0\build\boost.targets" Condition="Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" /> - <Import Project="..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets" Condition="Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\boost.1.87.0\build\boost.targets" Condition="Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" /> + <Import Project="$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets" Condition="Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost.1.84.0\build\boost.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost.1.87.0\build\boost.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters b/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters index ffd2177829..f4f0ffcbe5 100644 --- a/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters +++ b/src/modules/powerrename/dll/PowerRenameExt.vcxproj.filters @@ -36,6 +36,9 @@ <ClInclude Include="PowerRenameConstants.h"> <Filter>Header Files</Filter> </ClInclude> + <ClInclude Include="RuntimeRegistration.h"> + <Filter>Header Files</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <ClCompile Include="PowerRenameExt.cpp"> diff --git a/src/modules/powerrename/dll/Resources.resx b/src/modules/powerrename/dll/Resources.resx index 0cb356f56d..b62836f6ca 100644 --- a/src/modules/powerrename/dll/Resources.resx +++ b/src/modules/powerrename/dll/Resources.resx @@ -126,8 +126,8 @@ <comment>do not loc, product name</comment> </data> <data name="PowerRename_Context_Menu_Entry" xml:space="preserve"> - <value>Rename with Po&werRename</value> - <comment>PowerRename is a product name. do not loc it. The & allows to use W as a keyboard accelerator.</comment> + <value>Rename with Pow&erRename</value> + <comment>PowerRename is a product name. do not loc it. The & allows to use E as a keyboard accelerator.</comment> </data> <data name="Settings_Description" xml:space="preserve"> <value>A Windows Shell extension for more advanced bulk renaming using search and replace or regular expressions.</value> diff --git a/src/modules/powerrename/dll/RuntimeRegistration.h b/src/modules/powerrename/dll/RuntimeRegistration.h new file mode 100644 index 0000000000..3cb06d5876 --- /dev/null +++ b/src/modules/powerrename/dll/RuntimeRegistration.h @@ -0,0 +1,37 @@ +// Header-only runtime registration for PowerRename context menu extension. +#pragma once + +#include <common/utils/shell_ext_registration.h> + +// Provided by dllmain.cpp +extern HINSTANCE g_hInst; + +namespace PowerRenameRuntimeRegistration +{ + namespace + { + inline runtime_shell_ext::Spec BuildSpec() + { + runtime_shell_ext::Spec spec; + spec.clsid = L"{0440049F-D1DC-4E46-B27B-98393D79486B}"; + spec.sentinelKey = L"Software\\Microsoft\\PowerToys\\PowerRename"; + spec.sentinelValue = L"ContextMenuRegistered"; + spec.dllFileCandidates = { L"PowerToys.PowerRenameExt.dll" }; + spec.contextMenuHandlerKeyPaths = { + L"Software\\Classes\\AllFileSystemObjects\\ShellEx\\ContextMenuHandlers\\PowerRenameExt", + L"Software\\Classes\\Directory\\background\\ShellEx\\ContextMenuHandlers\\PowerRenameExt" }; + spec.friendlyName = L"PowerRename Shell Extension"; + return spec; + } + } + + inline bool EnsureRegistered() + { + return runtime_shell_ext::EnsureRegistered(BuildSpec(), g_hInst); + } + + inline void Unregister() + { + runtime_shell_ext::Unregister(BuildSpec()); + } +} diff --git a/src/modules/powerrename/dll/dllmain.cpp b/src/modules/powerrename/dll/dllmain.cpp index 4f9c918fb6..698797f932 100644 --- a/src/modules/powerrename/dll/dllmain.cpp +++ b/src/modules/powerrename/dll/dllmain.cpp @@ -15,6 +15,7 @@ #include <common/utils/package.h> #include <common/utils/process_path.h> #include <common/utils/resources.h> +#include "RuntimeRegistration.h" #include <atomic> @@ -167,6 +168,25 @@ private: //contains the non localized key of the powertoy std::wstring app_key; + // Update registration based on enabled state + void UpdateRegistration(bool enabled) + { + if (enabled) + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + PowerRenameRuntimeRegistration::EnsureRegistered(); + Logger::info(L"PowerRename context menu registered"); +#endif + } + else + { +#if defined(ENABLE_REGISTRATION) || defined(NDEBUG) + PowerRenameRuntimeRegistration::Unregister(); + Logger::info(L"PowerRename context menu unregistered"); +#endif + } + } + public: // Return the localized display name of the powertoy virtual PCWSTR get_name() override @@ -196,12 +216,12 @@ public: { std::wstring path = get_module_folderpath(g_hInst); std::wstring packageUri = path + L"\\PowerRenameContextMenuPackage.msix"; - if (!package::IsPackageRegisteredWithPowerToysVersion(PowerRenameConstants::ModulePackageDisplayName)) { package::RegisterSparsePackage(path, packageUri); } } + UpdateRegistration(m_enabled); } // Disable the powertoy @@ -209,6 +229,7 @@ public: { m_enabled = false; Logger::info(L"PowerRename disabled"); + UpdateRegistration(m_enabled); } // Returns if the powertoy is enabled @@ -308,6 +329,7 @@ public: void init_settings() { m_enabled = CSettingsInstance().GetEnabled(); + UpdateRegistration(m_enabled); Trace::EnablePowerRename(m_enabled); } diff --git a/src/modules/powerrename/dll/packages.config b/src/modules/powerrename/dll/packages.config index ecc3202cd3..d33ff8fb41 100644 --- a/src/modules/powerrename/dll/packages.config +++ b/src/modules/powerrename/dll/packages.config @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="boost" version="1.84.0" targetFramework="native" /> - <package id="boost_regex-vc143" version="1.84.0" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="boost" version="1.87.0" targetFramework="native" /> + <package id="boost_regex-vc143" version="1.87.0" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/powerrename/lib/Helpers.cpp b/src/modules/powerrename/lib/Helpers.cpp index aeadd8b683..a1ae8c9073 100644 --- a/src/modules/powerrename/lib/Helpers.cpp +++ b/src/modules/powerrename/lib/Helpers.cpp @@ -1,9 +1,13 @@ #include "pch.h" #include "Helpers.h" +#include "MetadataTypes.h" #include <regex> #include <ShlGuid.h> #include <cstring> #include <filesystem> +#include <unordered_map> +#include <unordered_set> +#include <algorithm> namespace fs = std::filesystem; @@ -12,6 +16,50 @@ namespace const int MAX_INPUT_STRING_LEN = 1024; const wchar_t c_rootRegPath[] = L"Software\\Microsoft\\PowerRename"; + + // Helper function: Find the longest matching pattern starting at the given position + // Returns the matched pattern name, or empty string if no match found + std::wstring FindLongestPattern( + const std::wstring& input, + size_t startPos, + size_t maxPatternLength, + const std::unordered_set<std::wstring>& validPatterns) + { + const size_t remaining = input.length() - startPos; + const size_t searchLength = std::min(maxPatternLength, remaining); + + // Try to match from longest to shortest to ensure greedy matching + // e.g., DATE_TAKEN_YYYY should be matched before DATE_TAKEN_YY + for (size_t len = searchLength; len > 0; --len) + { + std::wstring candidate = input.substr(startPos, len); + if (validPatterns.find(candidate) != validPatterns.end()) + { + return candidate; + } + } + + return L""; + } + + // Helper function: Get the replacement value for a pattern + // Returns the actual metadata value if available; if not, returns the pattern name with $ prefix + std::wstring GetPatternValue( + const std::wstring& patternName, + const PowerRenameLib::MetadataPatternMap& patterns) + { + auto it = patterns.find(patternName); + + // Return actual value if found and valid (non-empty) + if (it != patterns.end() && !it->second.empty()) + { + return it->second; + } + + // Return pattern name with $ prefix if value is unavailable + // This provides visual feedback that the field exists but has no data + return L"$" + patternName; + } } HRESULT GetTrimmedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source) @@ -248,7 +296,19 @@ HRESULT GetTransformedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR sour bool isFileTimeUsed(_In_ PCWSTR source) { bool used = false; - static const std::array patterns = { std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$Y" }, std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$M" }, std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$D" }, std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$h" }, std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$m" }, std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$s" }, std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$f" } }; + static const std::array patterns = { + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$Y" }, + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$M" }, + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$D" }, + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$h" }, + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$m" }, + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$s" }, + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$f" }, + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$H" }, + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$T" }, + std::wregex{ L"(([^\\$]|^)(\\$\\$)*)\\$t" } + }; + for (size_t i = 0; !used && i < patterns.size(); i++) { if (std::regex_search(source, patterns[i])) @@ -259,6 +319,77 @@ bool isFileTimeUsed(_In_ PCWSTR source) return used; } +bool isMetadataUsed(_In_ PCWSTR source, PowerRenameLib::MetadataType metadataType, _In_opt_ PCWSTR filePath, bool isFolder) +{ + if (!source) return false; + + // Early exit: If file path is provided, check file type first (fastest checks) + // This avoids expensive pattern matching for files that don't support metadata + if (filePath != nullptr) + { + // Folders don't support metadata extraction + if (isFolder) + { + return false; + } + + // Check if file path is valid + if (wcslen(filePath) == 0) + { + return false; + } + + // Get file extension + std::wstring extension = fs::path(filePath).extension().wstring(); + + // Convert to lowercase for case-insensitive comparison + std::transform(extension.begin(), extension.end(), extension.begin(), ::towlower); + + // According to the metadata support table, only these formats support metadata extraction: + // - JPEG (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding + // - TIFF (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding + // - PNG (text chunks) + // - HEIF/HEIC (IFD, Exif, XMP, GPS) - requires HEIF Image Extensions from Microsoft Store + // - AVIF (IFD, Exif, XMP, GPS) - requires AV1 Video Extension from Microsoft Store + static const std::unordered_set<std::wstring> supportedExtensions = { + L".jpg", + L".jpeg", + L".png", + L".tif", + L".tiff", + L".heic", + L".heif", + L".avif" + }; + + // If file type doesn't support metadata, no need to check patterns + if (supportedExtensions.find(extension) == supportedExtensions.end()) + { + return false; + } + } + + // Now check if any metadata pattern exists in the source string + // This is the most expensive check, so we do it last + std::wstring str(source); + + // Get supported patterns for the specified metadata type + auto supportedPatterns = PowerRenameLib::MetadataPatternExtractor::GetSupportedPatterns(metadataType); + + // Check if any metadata pattern exists in the source string + for (const auto& pattern : supportedPatterns) + { + std::wstring searchPattern = L"$" + pattern; + if (str.find(searchPattern) != std::wstring::npos) + { + return true; + } + } + + // No metadata pattern found + return false; +} + HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime) { std::locale::global(std::locale("")); @@ -275,6 +406,14 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY StringCchCopy(localeName, LOCALE_NAME_MAX_LENGTH, L"en_US"); } + int hour12 = (fileTime.wHour % 12); + if (hour12 == 0) + { + hour12 = 12; + } + + // Order matters. Longer patterns are processed before any prefixes. + // Years. StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%04d"), L"$01", fileTime.wYear); res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$YYYY"), replaceTerm); @@ -284,6 +423,7 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", (fileTime.wYear % 10)); res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$Y"), replaceTerm); + // Months. GetDateFormatEx(localeName, NULL, &fileTime, L"MMMM", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); @@ -300,6 +440,7 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wMonth); res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$M"), replaceTerm); + // Days. GetDateFormatEx(localeName, NULL, &fileTime, L"dddd", formattedDate, MAX_PATH, NULL); formattedDate[0] = towupper(formattedDate[0]); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", formattedDate); @@ -314,7 +455,27 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$DD"), replaceTerm); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", fileTime.wDay); - res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$D"), replaceTerm); + // $D overlaps with metadata patterns like $DATE_TAKEN_YYYY, so we use negative + // lookahead to prevent matching those. + res = regex_replace( + res, + std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$D(?!(ATE_TAKEN_|ESCRIPTION|OCUMENT_ID))"), /* #no-spell-check-line */ + replaceTerm); + + // Time. + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", hour12); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$HH"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%d"), L"$01", hour12); + // $H overlaps with metadata's $HEIGHT, so we use negative lookahead to prevent + // matching that. + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$H(?!(EIGHT))"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", (fileTime.wHour < 12) ? L"AM" : L"PM"); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$TT"), replaceTerm); + + StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%s"), L"$01", (fileTime.wHour < 12) ? L"am" : L"pm"); + res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$tt"), replaceTerm); StringCchPrintf(replaceTerm, MAX_PATH, TEXT("%s%02d"), L"$01", fileTime.wHour); res = regex_replace(res, std::wregex(L"(([^\\$]|^)(\\$\\$)*)\\$hh"), replaceTerm); @@ -349,6 +510,91 @@ HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SY return hr; } +HRESULT GetMetadataFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, const PowerRenameLib::MetadataPatternMap& patterns) +{ + if (!source || wcslen(source) == 0) + { + return E_INVALIDARG; + } + + std::wstring input(source); + std::wstring output; + output.reserve(input.length() * 2); // Reserve space to avoid frequent reallocations + + // Build pattern lookup table for fast validation + // Using all possible patterns to recognize valid pattern names even when metadata is unavailable + auto allPatterns = PowerRenameLib::MetadataPatternExtractor::GetAllPossiblePatterns(); + std::unordered_set<std::wstring> validPatterns; + validPatterns.reserve(allPatterns.size()); + size_t maxPatternLength = 0; + for (const auto& pattern : allPatterns) + { + validPatterns.insert(pattern); + maxPatternLength = std::max(maxPatternLength, pattern.length()); + } + + size_t pos = 0; + while (pos < input.length()) + { + // Handle regular characters + if (input[pos] != L'$') + { + output += input[pos]; + pos++; + continue; + } + + // Count consecutive dollar signs + size_t dollarCount = 0; + while (pos < input.length() && input[pos] == L'$') + { + dollarCount++; + pos++; + } + + // Even number of dollars: all are escaped (e.g., $$ -> $, $$$$ -> $$) + if (dollarCount % 2 == 0) + { + output.append(dollarCount / 2, L'$'); + continue; + } + + // Odd number of dollars: pairs are escaped, last one might be a pattern prefix + // e.g., $ -> might be pattern, $$$ -> $ + might be pattern + size_t escapedDollars = dollarCount / 2; + + // If no more characters, output all dollar signs + if (pos >= input.length()) + { + output.append(dollarCount, L'$'); + continue; + } + + // Try to match a pattern (greedy matching for longest pattern) + std::wstring matchedPattern = FindLongestPattern(input, pos, maxPatternLength, validPatterns); + + if (matchedPattern.empty()) + { + // No pattern matched, output all dollar signs + output.append(dollarCount, L'$'); + } + else + { + // Pattern matched + output.append(escapedDollars, L'$'); // Output escaped dollars first + + // Replace pattern with its value or keep pattern name if value unavailable + std::wstring replacementValue = GetPatternValue(matchedPattern, patterns); + output += replacementValue; + + pos += matchedPattern.length(); + } + } + + return StringCchCopy(result, cchMax, output.c_str()); +} + + HRESULT GetShellItemArrayFromDataObject(_In_ IUnknown* dataSource, _COM_Outptr_ IShellItemArray** items) { *items = nullptr; @@ -530,7 +776,7 @@ BOOL GetEnumeratedFileName(__out_ecount(cchMax) PWSTR pszUniqueName, UINT cchMax } // Iterate through the shell items array and checks if at least 1 item has SFGAO_CANRENAME. -// We do not enumerate child items - only the items the user selected. +// We do not enumerate child items - only items the user selected. bool ShellItemArrayContainsRenamableItem(_In_ IShellItemArray* shellItemArray) { bool hasRenamable = false; @@ -555,7 +801,7 @@ bool ShellItemArrayContainsRenamableItem(_In_ IShellItemArray* shellItemArray) } // Iterate through the data source and checks if at least 1 item has SFGAO_CANRENAME. -// We do not enumerate child items - only the items the user selected. +// We do not enumerate child items - only items the user selected. bool DataObjectContainsRenamableItem(_In_ IUnknown* dataSource) { bool hasRenamable = false; @@ -677,4 +923,4 @@ std::wstring CreateGuidStringWithoutBrackets() } return L""; -} \ No newline at end of file +} diff --git a/src/modules/powerrename/lib/Helpers.h b/src/modules/powerrename/lib/Helpers.h index 05b8eab19d..83659637c9 100644 --- a/src/modules/powerrename/lib/Helpers.h +++ b/src/modules/powerrename/lib/Helpers.h @@ -1,13 +1,17 @@ #pragma once #include "PowerRenameInterfaces.h" +#include "MetadataTypes.h" +#include "MetadataPatternExtractor.h" #include <string> HRESULT GetTrimmedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source); HRESULT GetTransformedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, DWORD flags, bool isFolder); HRESULT GetDatedFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, SYSTEMTIME fileTime); +HRESULT GetMetadataFileName(_Out_ PWSTR result, UINT cchMax, _In_ PCWSTR source, const PowerRenameLib::MetadataPatternMap& patterns); bool isFileTimeUsed(_In_ PCWSTR source); +bool isMetadataUsed(_In_ PCWSTR source, PowerRenameLib::MetadataType metadataType, _In_opt_ PCWSTR filePath = nullptr, bool isFolder = false); bool ShellItemArrayContainsRenamableItem(_In_ IShellItemArray* shellItemArray); bool DataObjectContainsRenamableItem(_In_ IUnknown* dataSource); HRESULT GetShellItemArrayFromDataObject(_In_ IUnknown* dataSource, _COM_Outptr_ IShellItemArray** items); diff --git a/src/modules/powerrename/lib/MetadataFormatHelper.cpp b/src/modules/powerrename/lib/MetadataFormatHelper.cpp new file mode 100644 index 0000000000..e6b88e913a --- /dev/null +++ b/src/modules/powerrename/lib/MetadataFormatHelper.cpp @@ -0,0 +1,237 @@ +// 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 "MetadataFormatHelper.h" +#include <format> +#include <cmath> +#include <cstring> + +using namespace PowerRenameLib; + +// Formatting functions + +std::wstring MetadataFormatHelper::FormatAperture(double aperture) +{ + return std::format(L"f/{:.1f}", aperture); +} + +std::wstring MetadataFormatHelper::FormatShutterSpeed(double speed) +{ + if (speed <= 0.0) + { + return L"0"; + } + + if (speed >= 1.0) + { + return std::format(L"{:.1f}s", speed); + } + + const double reciprocal = std::round(1.0 / speed); + if (reciprocal <= 1.0) + { + return std::format(L"{:.3f}s", speed); + } + + return std::format(L"1/{:.0f}s", reciprocal); +} + +std::wstring MetadataFormatHelper::FormatISO(int64_t iso) +{ + if (iso <= 0) + { + return L"ISO"; + } + + return std::format(L"ISO {}", iso); +} + +std::wstring MetadataFormatHelper::FormatFlash(int64_t flashValue) +{ + switch (flashValue & 0x1) + { + case 0: + return L"Flash Off"; + case 1: + return L"Flash On"; + default: + break; + } + + return std::format(L"Flash 0x{:X}", static_cast<unsigned int>(flashValue)); +} + +std::wstring MetadataFormatHelper::FormatCoordinate(double coord, bool isLatitude) +{ + wchar_t direction = isLatitude ? (coord >= 0.0 ? L'N' : L'S') : (coord >= 0.0 ? L'E' : L'W'); + double absolute = std::abs(coord); + int degrees = static_cast<int>(absolute); + double minutes = (absolute - static_cast<double>(degrees)) * 60.0; + + return std::format(L"{:d}°{:.2f}'{}", degrees, minutes, direction); +} + +std::wstring MetadataFormatHelper::FormatSystemTime(const SYSTEMTIME& st) +{ + return std::format(L"{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}", + st.wYear, + st.wMonth, + st.wDay, + st.wHour, + st.wMinute, + st.wSecond); +} + +// Parsing functions + +double MetadataFormatHelper::ParseGPSRational(const PROPVARIANT& pv) +{ + if ((pv.vt & VT_VECTOR) && pv.caub.cElems >= 8) + { + return ParseSingleRational(pv.caub.pElems, 0); + } + return 0.0; +} + +double MetadataFormatHelper::ParseSingleRational(const uint8_t* bytes, size_t offset) +{ + // Parse a single rational number (8 bytes: numerator + denominator) + if (!bytes) + return 0.0; + + // Note: Callers are responsible for ensuring the buffer is large enough. + // This function assumes offset points to at least 8 bytes of valid data. + // All current callers perform cElems >= required_size checks before calling. + const uint8_t* rationalBytes = bytes + offset; + + // Parse as little-endian uint32_t values + uint32_t numerator = static_cast<uint32_t>(rationalBytes[0]) | + (static_cast<uint32_t>(rationalBytes[1]) << 8) | + (static_cast<uint32_t>(rationalBytes[2]) << 16) | + (static_cast<uint32_t>(rationalBytes[3]) << 24); + + uint32_t denominator = static_cast<uint32_t>(rationalBytes[4]) | + (static_cast<uint32_t>(rationalBytes[5]) << 8) | + (static_cast<uint32_t>(rationalBytes[6]) << 16) | + (static_cast<uint32_t>(rationalBytes[7]) << 24); + + if (denominator != 0) + { + return static_cast<double>(numerator) / static_cast<double>(denominator); + } + + return 0.0; +} + +double MetadataFormatHelper::ParseSingleSRational(const uint8_t* bytes, size_t offset) +{ + // Parse a single signed rational number (8 bytes: signed numerator + signed denominator) + if (!bytes) + return 0.0; + + // Note: Callers are responsible for ensuring the buffer is large enough. + // This function assumes offset points to at least 8 bytes of valid data. + // All current callers perform cElems >= required_size checks before calling. + const uint8_t* rationalBytes = bytes + offset; + + // Parse as little-endian int32_t values (signed) + // First construct as unsigned, then reinterpret as signed + uint32_t numerator_uint = static_cast<uint32_t>(rationalBytes[0]) | + (static_cast<uint32_t>(rationalBytes[1]) << 8) | + (static_cast<uint32_t>(rationalBytes[2]) << 16) | + (static_cast<uint32_t>(rationalBytes[3]) << 24); + + uint32_t denominator_uint = static_cast<uint32_t>(rationalBytes[4]) | + (static_cast<uint32_t>(rationalBytes[5]) << 8) | + (static_cast<uint32_t>(rationalBytes[6]) << 16) | + (static_cast<uint32_t>(rationalBytes[7]) << 24); + + // Reinterpret as signed + int32_t numerator = static_cast<int32_t>(numerator_uint); + int32_t denominator = static_cast<int32_t>(denominator_uint); + + if (denominator != 0) + { + return static_cast<double>(numerator) / static_cast<double>(denominator); + } + + return 0.0; +} + +std::pair<double, double> MetadataFormatHelper::ParseGPSCoordinates( + const PROPVARIANT& latitude, + const PROPVARIANT& longitude, + const PROPVARIANT& latRef, + const PROPVARIANT& lonRef) +{ + double lat = 0.0, lon = 0.0; + + // Parse latitude - typically stored as 3 rationals (degrees, minutes, seconds) + if ((latitude.vt & VT_VECTOR) && latitude.caub.cElems >= 24) // 3 rationals * 8 bytes each + { + const uint8_t* bytes = latitude.caub.pElems; + + // degrees, minutes, seconds (each rational is 8 bytes) + double degrees = ParseSingleRational(bytes, 0); + double minutes = ParseSingleRational(bytes, 8); + double seconds = ParseSingleRational(bytes, 16); + + lat = degrees + minutes / 60.0 + seconds / 3600.0; + } + + // Parse longitude + if ((longitude.vt & VT_VECTOR) && longitude.caub.cElems >= 24) + { + const uint8_t* bytes = longitude.caub.pElems; + + double degrees = ParseSingleRational(bytes, 0); + double minutes = ParseSingleRational(bytes, 8); + double seconds = ParseSingleRational(bytes, 16); + + lon = degrees + minutes / 60.0 + seconds / 3600.0; + } + + // Apply direction references (N/S for latitude, E/W for longitude) + if (latRef.vt == VT_LPSTR && latRef.pszVal) + { + if (strcmp(latRef.pszVal, "S") == 0) + lat = -lat; + } + + if (lonRef.vt == VT_LPSTR && lonRef.pszVal) + { + if (strcmp(lonRef.pszVal, "W") == 0) + lon = -lon; + } + + return { lat, lon }; +} + +std::wstring MetadataFormatHelper::SanitizeForFileName(const std::wstring& str) +{ + // Windows illegal filename characters: < > : " / \ | ? * + // Also control characters (0-31) and some others + std::wstring sanitized = str; + + // Replace illegal characters with underscore + for (auto& ch : sanitized) + { + // Check for illegal characters + if (ch == L'<' || ch == L'>' || ch == L':' || ch == L'"' || + ch == L'/' || ch == L'\\' || ch == L'|' || ch == L'?' || ch == L'*' || + ch < 32) // Control characters + { + ch = L'_'; + } + } + + // Also remove trailing dots and spaces (Windows doesn't like them at end of filename) + while (!sanitized.empty() && (sanitized.back() == L'.' || sanitized.back() == L' ')) + { + sanitized.pop_back(); + } + + return sanitized; +} diff --git a/src/modules/powerrename/lib/MetadataFormatHelper.h b/src/modules/powerrename/lib/MetadataFormatHelper.h new file mode 100644 index 0000000000..86208225cf --- /dev/null +++ b/src/modules/powerrename/lib/MetadataFormatHelper.h @@ -0,0 +1,117 @@ +// 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 <string> +#include <utility> +#include <windows.h> +#include <propvarutil.h> + +namespace PowerRenameLib +{ + /// <summary> + /// Helper class for formatting and parsing metadata values + /// Provides static utility functions for converting metadata to human-readable strings + /// and parsing raw metadata values + /// </summary> + class MetadataFormatHelper + { + public: + // Formatting functions - Convert metadata values to display strings + + /// <summary> + /// Format aperture value (f-number) + /// </summary> + /// <param name="aperture">Aperture value (e.g., 2.8)</param> + /// <returns>Formatted string (e.g., "f/2.8")</returns> + static std::wstring FormatAperture(double aperture); + + /// <summary> + /// Format shutter speed + /// </summary> + /// <param name="speed">Shutter speed in seconds</param> + /// <returns>Formatted string (e.g., "1/100s" or "2.5s")</returns> + static std::wstring FormatShutterSpeed(double speed); + + /// <summary> + /// Format ISO value + /// </summary> + /// <param name="iso">ISO speed value</param> + /// <returns>Formatted string (e.g., "ISO 400")</returns> + static std::wstring FormatISO(int64_t iso); + + /// <summary> + /// Format flash status + /// </summary> + /// <param name="flashValue">Flash value from EXIF</param> + /// <returns>Formatted string (e.g., "Flash On" or "Flash Off")</returns> + static std::wstring FormatFlash(int64_t flashValue); + + /// <summary> + /// Format GPS coordinate + /// </summary> + /// <param name="coord">Coordinate value in decimal degrees</param> + /// <param name="isLatitude">true for latitude, false for longitude</param> + /// <returns>Formatted string (e.g., "40°26.76'N")</returns> + static std::wstring FormatCoordinate(double coord, bool isLatitude); + + /// <summary> + /// Format SYSTEMTIME to string + /// </summary> + /// <param name="st">SYSTEMTIME structure</param> + /// <returns>Formatted string (e.g., "2024-03-15 14:30:45")</returns> + static std::wstring FormatSystemTime(const SYSTEMTIME& st); + + // Parsing functions - Convert raw metadata to usable values + + /// <summary> + /// Parse GPS rational value from PROPVARIANT + /// </summary> + /// <param name="pv">PROPVARIANT containing GPS rational data</param> + /// <returns>Parsed double value</returns> + static double ParseGPSRational(const PROPVARIANT& pv); + + /// <summary> + /// Parse single rational value from byte array + /// </summary> + /// <param name="bytes">Byte array containing rational data</param> + /// <param name="offset">Offset in the byte array</param> + /// <returns>Parsed double value (numerator / denominator)</returns> + static double ParseSingleRational(const uint8_t* bytes, size_t offset); + + /// <summary> + /// Parse single signed rational value from byte array + /// </summary> + /// <param name="bytes">Byte array containing signed rational data</param> + /// <param name="offset">Offset in the byte array</param> + /// <returns>Parsed double value (signed numerator / signed denominator)</returns> + static double ParseSingleSRational(const uint8_t* bytes, size_t offset); + + /// <summary> + /// Parse GPS coordinates from PROPVARIANT values + /// </summary> + /// <param name="latitude">PROPVARIANT containing latitude</param> + /// <param name="longitude">PROPVARIANT containing longitude</param> + /// <param name="latRef">PROPVARIANT containing latitude reference (N/S)</param> + /// <param name="lonRef">PROPVARIANT containing longitude reference (E/W)</param> + /// <returns>Pair of (latitude, longitude) in decimal degrees</returns> + static std::pair<double, double> ParseGPSCoordinates( + const PROPVARIANT& latitude, + const PROPVARIANT& longitude, + const PROPVARIANT& latRef, + const PROPVARIANT& lonRef); + + /// <summary> + /// Sanitize a string to make it safe for use in filenames + /// Replaces illegal filename characters (< > : " / \ | ? * and control chars) with underscore + /// Also removes trailing dots and spaces which Windows doesn't allow at end of filename + /// + /// IMPORTANT: This should ONLY be called in ExtractPatterns to avoid performance waste. + /// Do NOT call this function when reading raw metadata values. + /// </summary> + /// <param name="str">String to sanitize</param> + /// <returns>Sanitized string safe for use in filename</returns> + static std::wstring SanitizeForFileName(const std::wstring& str); + }; +} diff --git a/src/modules/powerrename/lib/MetadataPatternExtractor.cpp b/src/modules/powerrename/lib/MetadataPatternExtractor.cpp new file mode 100644 index 0000000000..cfbc40837d --- /dev/null +++ b/src/modules/powerrename/lib/MetadataPatternExtractor.cpp @@ -0,0 +1,353 @@ +// 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 "MetadataPatternExtractor.h" +#include "MetadataFormatHelper.h" +#include "WICMetadataExtractor.h" +#include <algorithm> +#include <format> +#include <sstream> +#include <iomanip> +#include <cmath> +#include <utility> + +using namespace PowerRenameLib; + +MetadataPatternExtractor::MetadataPatternExtractor() + : extractor(std::make_unique<WICMetadataExtractor>()) +{ +} + +MetadataPatternExtractor::~MetadataPatternExtractor() = default; + +MetadataPatternMap MetadataPatternExtractor::ExtractPatterns( + const std::wstring& filePath, + MetadataType type) +{ + MetadataPatternMap patterns; + + switch (type) + { + case MetadataType::EXIF: + patterns = ExtractEXIFPatterns(filePath); + break; + case MetadataType::XMP: + patterns = ExtractXMPPatterns(filePath); + break; + default: + return MetadataPatternMap(); + } + + // Sanitize all pattern values for filename safety before returning + // This ensures all metadata values are safe to use in filenames (removes illegal chars like <>:"/\|?*) + // IMPORTANT: Only call SanitizeForFileName here to avoid performance waste + for (auto& [key, value] : patterns) + { + value = MetadataFormatHelper::SanitizeForFileName(value); + } + + return patterns; +} + +void MetadataPatternExtractor::ClearCache() +{ + if (extractor) + { + extractor->ClearCache(); + } +} + +MetadataPatternMap MetadataPatternExtractor::ExtractEXIFPatterns(const std::wstring& filePath) +{ + MetadataPatternMap patterns; + + EXIFMetadata exif; + if (!extractor->ExtractEXIFMetadata(filePath, exif)) + { + return patterns; + } + + if (exif.cameraMake.has_value()) + { + patterns[MetadataPatterns::CAMERA_MAKE] = exif.cameraMake.value(); + } + + if (exif.cameraModel.has_value()) + { + patterns[MetadataPatterns::CAMERA_MODEL] = exif.cameraModel.value(); + } + + if (exif.lensModel.has_value()) + { + patterns[MetadataPatterns::LENS] = exif.lensModel.value(); + } + + if (exif.iso.has_value()) + { + patterns[MetadataPatterns::ISO] = MetadataFormatHelper::FormatISO(exif.iso.value()); + } + + if (exif.aperture.has_value()) + { + patterns[MetadataPatterns::APERTURE] = MetadataFormatHelper::FormatAperture(exif.aperture.value()); + } + + if (exif.shutterSpeed.has_value()) + { + patterns[MetadataPatterns::SHUTTER] = MetadataFormatHelper::FormatShutterSpeed(exif.shutterSpeed.value()); + } + + if (exif.focalLength.has_value()) + { + patterns[MetadataPatterns::FOCAL] = std::to_wstring(static_cast<int>(exif.focalLength.value())) + L"mm"; + } + + if (exif.flash.has_value()) + { + patterns[MetadataPatterns::FLASH] = MetadataFormatHelper::FormatFlash(exif.flash.value()); + } + + if (exif.width.has_value()) + { + patterns[MetadataPatterns::WIDTH] = std::to_wstring(exif.width.value()); + } + + if (exif.height.has_value()) + { + patterns[MetadataPatterns::HEIGHT] = std::to_wstring(exif.height.value()); + } + + if (exif.author.has_value()) + { + patterns[MetadataPatterns::AUTHOR] = exif.author.value(); + } + + if (exif.copyright.has_value()) + { + patterns[MetadataPatterns::COPYRIGHT] = exif.copyright.value(); + } + + if (exif.latitude.has_value()) + { + patterns[MetadataPatterns::LATITUDE] = MetadataFormatHelper::FormatCoordinate(exif.latitude.value(), true); + } + + if (exif.longitude.has_value()) + { + patterns[MetadataPatterns::LONGITUDE] = MetadataFormatHelper::FormatCoordinate(exif.longitude.value(), false); + } + + // Only extract DATE_TAKEN patterns (most commonly used) + if (exif.dateTaken.has_value()) + { + const SYSTEMTIME& date = exif.dateTaken.value(); + patterns[MetadataPatterns::DATE_TAKEN_YYYY] = std::format(L"{:04d}", date.wYear); + patterns[MetadataPatterns::DATE_TAKEN_YY] = std::format(L"{:02d}", date.wYear % 100); + patterns[MetadataPatterns::DATE_TAKEN_MM] = std::format(L"{:02d}", date.wMonth); + patterns[MetadataPatterns::DATE_TAKEN_DD] = std::format(L"{:02d}", date.wDay); + patterns[MetadataPatterns::DATE_TAKEN_HH] = std::format(L"{:02d}", date.wHour); + patterns[MetadataPatterns::DATE_TAKEN_mm] = std::format(L"{:02d}", date.wMinute); + patterns[MetadataPatterns::DATE_TAKEN_SS] = std::format(L"{:02d}", date.wSecond); + } + // Note: dateDigitized and dateModified are still extracted but not exposed as patterns + + if (exif.exposureBias.has_value()) + { + patterns[MetadataPatterns::EXPOSURE_BIAS] = std::format(L"{:.2f}", exif.exposureBias.value()); + } + + if (exif.orientation.has_value()) + { + patterns[MetadataPatterns::ORIENTATION] = std::to_wstring(exif.orientation.value()); + } + + if (exif.colorSpace.has_value()) + { + patterns[MetadataPatterns::COLOR_SPACE] = std::to_wstring(exif.colorSpace.value()); + } + + if (exif.altitude.has_value()) + { + patterns[MetadataPatterns::ALTITUDE] = std::format(L"{:.2f} m", exif.altitude.value()); + } + + return patterns; +} + +MetadataPatternMap MetadataPatternExtractor::ExtractXMPPatterns(const std::wstring& filePath) +{ + MetadataPatternMap patterns; + + XMPMetadata xmp; + if (!extractor->ExtractXMPMetadata(filePath, xmp)) + { + return patterns; + } + + if (xmp.creator.has_value()) + { + const auto& creator = xmp.creator.value(); + patterns[MetadataPatterns::AUTHOR] = creator; + patterns[MetadataPatterns::CREATOR] = creator; + } + + if (xmp.rights.has_value()) + { + const auto& rights = xmp.rights.value(); + patterns[MetadataPatterns::RIGHTS] = rights; + patterns[MetadataPatterns::COPYRIGHT] = rights; + } + + if (xmp.title.has_value()) + { + patterns[MetadataPatterns::TITLE] = xmp.title.value(); + } + + if (xmp.description.has_value()) + { + patterns[MetadataPatterns::DESCRIPTION] = xmp.description.value(); + } + + if (xmp.subject.has_value()) + { + std::wstring joined; + for (const auto& entry : xmp.subject.value()) + { + if (!joined.empty()) + { + joined.append(L"; "); + } + joined.append(entry); + } + if (!joined.empty()) + { + patterns[MetadataPatterns::SUBJECT] = joined; + } + } + + if (xmp.creatorTool.has_value()) + { + patterns[MetadataPatterns::CREATOR_TOOL] = xmp.creatorTool.value(); + } + + if (xmp.documentID.has_value()) + { + patterns[MetadataPatterns::DOCUMENT_ID] = xmp.documentID.value(); + } + + if (xmp.instanceID.has_value()) + { + patterns[MetadataPatterns::INSTANCE_ID] = xmp.instanceID.value(); + } + + if (xmp.originalDocumentID.has_value()) + { + patterns[MetadataPatterns::ORIGINAL_DOCUMENT_ID] = xmp.originalDocumentID.value(); + } + + if (xmp.versionID.has_value()) + { + patterns[MetadataPatterns::VERSION_ID] = xmp.versionID.value(); + } + + // Only extract CREATE_DATE patterns (primary creation time) + if (xmp.createDate.has_value()) + { + const SYSTEMTIME& date = xmp.createDate.value(); + patterns[MetadataPatterns::CREATE_DATE_YYYY] = std::format(L"{:04d}", date.wYear); + patterns[MetadataPatterns::CREATE_DATE_YY] = std::format(L"{:02d}", date.wYear % 100); + patterns[MetadataPatterns::CREATE_DATE_MM] = std::format(L"{:02d}", date.wMonth); + patterns[MetadataPatterns::CREATE_DATE_DD] = std::format(L"{:02d}", date.wDay); + patterns[MetadataPatterns::CREATE_DATE_HH] = std::format(L"{:02d}", date.wHour); + patterns[MetadataPatterns::CREATE_DATE_mm] = std::format(L"{:02d}", date.wMinute); + patterns[MetadataPatterns::CREATE_DATE_SS] = std::format(L"{:02d}", date.wSecond); + } + // Note: modifyDate and metadataDate are still extracted but not exposed as patterns + + return patterns; +} + +// AddDatePatterns function has been removed as dynamic patterns are no longer supported. +// Date patterns are now directly added inline for DATE_TAKEN and CREATE_DATE only. +// Formatting functions have been moved to MetadataFormatHelper for better testability. + +std::vector<std::wstring> MetadataPatternExtractor::GetSupportedPatterns(MetadataType type) +{ + switch (type) + { + case MetadataType::EXIF: + return { + MetadataPatterns::CAMERA_MAKE, + MetadataPatterns::CAMERA_MODEL, + MetadataPatterns::LENS, + MetadataPatterns::ISO, + MetadataPatterns::APERTURE, + MetadataPatterns::SHUTTER, + MetadataPatterns::FOCAL, + MetadataPatterns::FLASH, + MetadataPatterns::WIDTH, + MetadataPatterns::HEIGHT, + MetadataPatterns::AUTHOR, + MetadataPatterns::COPYRIGHT, + MetadataPatterns::LATITUDE, + MetadataPatterns::LONGITUDE, + MetadataPatterns::DATE_TAKEN_YYYY, + MetadataPatterns::DATE_TAKEN_YY, + MetadataPatterns::DATE_TAKEN_MM, + MetadataPatterns::DATE_TAKEN_DD, + MetadataPatterns::DATE_TAKEN_HH, + MetadataPatterns::DATE_TAKEN_mm, + MetadataPatterns::DATE_TAKEN_SS, + MetadataPatterns::EXPOSURE_BIAS, + MetadataPatterns::ORIENTATION, + MetadataPatterns::COLOR_SPACE, + MetadataPatterns::ALTITUDE + }; + + case MetadataType::XMP: + return { + MetadataPatterns::AUTHOR, + MetadataPatterns::COPYRIGHT, + MetadataPatterns::RIGHTS, + MetadataPatterns::TITLE, + MetadataPatterns::DESCRIPTION, + MetadataPatterns::SUBJECT, + MetadataPatterns::CREATOR, + MetadataPatterns::CREATOR_TOOL, + MetadataPatterns::DOCUMENT_ID, + MetadataPatterns::INSTANCE_ID, + MetadataPatterns::ORIGINAL_DOCUMENT_ID, + MetadataPatterns::VERSION_ID, + MetadataPatterns::CREATE_DATE_YYYY, + MetadataPatterns::CREATE_DATE_YY, + MetadataPatterns::CREATE_DATE_MM, + MetadataPatterns::CREATE_DATE_DD, + MetadataPatterns::CREATE_DATE_HH, + MetadataPatterns::CREATE_DATE_mm, + MetadataPatterns::CREATE_DATE_SS + }; + + default: + return {}; + } +} + +std::vector<std::wstring> MetadataPatternExtractor::GetAllPossiblePatterns() +{ + auto exifPatterns = GetSupportedPatterns(MetadataType::EXIF); + auto xmpPatterns = GetSupportedPatterns(MetadataType::XMP); + + std::vector<std::wstring> allPatterns; + allPatterns.reserve(exifPatterns.size() + xmpPatterns.size()); + + allPatterns.insert(allPatterns.end(), exifPatterns.begin(), exifPatterns.end()); + allPatterns.insert(allPatterns.end(), xmpPatterns.begin(), xmpPatterns.end()); + + std::sort(allPatterns.begin(), allPatterns.end()); + allPatterns.erase(std::unique(allPatterns.begin(), allPatterns.end()), allPatterns.end()); + + return allPatterns; +} + diff --git a/src/modules/powerrename/lib/MetadataPatternExtractor.h b/src/modules/powerrename/lib/MetadataPatternExtractor.h new file mode 100644 index 0000000000..787e5c437d --- /dev/null +++ b/src/modules/powerrename/lib/MetadataPatternExtractor.h @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include <string> +#include <unordered_map> +#include <vector> +#include <memory> +#include "MetadataTypes.h" + +namespace PowerRenameLib +{ + // Pattern-Value mapping for metadata replacement + using MetadataPatternMap = std::unordered_map<std::wstring, std::wstring>; + + /// <summary> + /// Metadata pattern extractor that converts metadata into replaceable patterns + /// </summary> + class MetadataPatternExtractor + { + public: + MetadataPatternExtractor(); + ~MetadataPatternExtractor(); + + MetadataPatternMap ExtractPatterns(const std::wstring& filePath, MetadataType type); + + void ClearCache(); + + static std::vector<std::wstring> GetSupportedPatterns(MetadataType type); + static std::vector<std::wstring> GetAllPossiblePatterns(); + + private: + std::unique_ptr<class WICMetadataExtractor> extractor; + + MetadataPatternMap ExtractEXIFPatterns(const std::wstring& filePath); + MetadataPatternMap ExtractXMPPatterns(const std::wstring& filePath); + }; +} diff --git a/src/modules/powerrename/lib/MetadataResultCache.cpp b/src/modules/powerrename/lib/MetadataResultCache.cpp new file mode 100644 index 0000000000..5f30b24abe --- /dev/null +++ b/src/modules/powerrename/lib/MetadataResultCache.cpp @@ -0,0 +1,87 @@ +// 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 "MetadataResultCache.h" + +using namespace PowerRenameLib; + +namespace +{ + template <typename Metadata, typename CacheEntry, typename Cache, typename Mutex, typename Loader> + bool GetOrLoadInternal(const std::wstring& filePath, + Metadata& outMetadata, + Cache& cache, + Mutex& mutex, + const Loader& loader) + { + { + std::shared_lock sharedLock(mutex); + auto it = cache.find(filePath); + if (it != cache.end()) + { + // Return cached result (success or failure) + outMetadata = it->second.data; + return it->second.wasSuccessful; + } + } + + if (!loader) + { + // No loader provided + return false; + } + + Metadata loaded{}; + const bool result = loader(loaded); + + // Cache the result (success or failure) + { + std::unique_lock uniqueLock(mutex); + // Check if another thread cached it while we were loading + auto it = cache.find(filePath); + if (it == cache.end()) + { + // Not cached yet, insert our result + cache.emplace(filePath, CacheEntry{ result, loaded }); + } + else + { + // Another thread cached it, use their result + outMetadata = it->second.data; + return it->second.wasSuccessful; + } + } + + outMetadata = loaded; + return result; + } +} + +bool MetadataResultCache::GetOrLoadEXIF(const std::wstring& filePath, + EXIFMetadata& outMetadata, + const EXIFLoader& loader) +{ + return GetOrLoadInternal<EXIFMetadata, CacheEntry<EXIFMetadata>>(filePath, outMetadata, exifCache, exifMutex, loader); +} + +bool MetadataResultCache::GetOrLoadXMP(const std::wstring& filePath, + XMPMetadata& outMetadata, + const XMPLoader& loader) +{ + return GetOrLoadInternal<XMPMetadata, CacheEntry<XMPMetadata>>(filePath, outMetadata, xmpCache, xmpMutex, loader); +} + +void MetadataResultCache::ClearAll() +{ + { + std::unique_lock lock(exifMutex); + exifCache.clear(); + } + + { + std::unique_lock lock(xmpMutex); + xmpCache.clear(); + } +} diff --git a/src/modules/powerrename/lib/MetadataResultCache.h b/src/modules/powerrename/lib/MetadataResultCache.h new file mode 100644 index 0000000000..ad3b9782c4 --- /dev/null +++ b/src/modules/powerrename/lib/MetadataResultCache.h @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once +#include "MetadataTypes.h" +#include <shared_mutex> +#include <unordered_map> +#include <string> +#include <functional> + +namespace PowerRenameLib +{ + class MetadataResultCache + { + public: + using EXIFLoader = std::function<bool(EXIFMetadata&)>; + using XMPLoader = std::function<bool(XMPMetadata&)>; + + bool GetOrLoadEXIF(const std::wstring& filePath, EXIFMetadata& outMetadata, const EXIFLoader& loader); + bool GetOrLoadXMP(const std::wstring& filePath, XMPMetadata& outMetadata, const XMPLoader& loader); + + void ClearAll(); + + private: + // Wrapper to cache both success and failure states + template<typename T> + struct CacheEntry + { + bool wasSuccessful; + T data; + }; + + mutable std::shared_mutex exifMutex; + mutable std::shared_mutex xmpMutex; + std::unordered_map<std::wstring, CacheEntry<EXIFMetadata>> exifCache; + std::unordered_map<std::wstring, CacheEntry<XMPMetadata>> xmpCache; + }; +} diff --git a/src/modules/powerrename/lib/MetadataTypes.h b/src/modules/powerrename/lib/MetadataTypes.h new file mode 100644 index 0000000000..aa6a721e4c --- /dev/null +++ b/src/modules/powerrename/lib/MetadataTypes.h @@ -0,0 +1,156 @@ +// 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 <string> +#include <optional> +#include <vector> +#include <windows.h> + +namespace PowerRenameLib +{ + /// <summary> + /// Supported metadata format types + /// </summary> + enum class MetadataType + { + EXIF, // EXIF metadata (camera settings, date taken, etc.) + XMP // XMP metadata (Dublin Core, Photoshop, etc.) + }; + + /// <summary> + /// Complete EXIF metadata structure + /// Contains all commonly used EXIF fields with optional values + /// </summary> + struct EXIFMetadata + { + // Date and time information + std::optional<SYSTEMTIME> dateTaken; // DateTimeOriginal + std::optional<SYSTEMTIME> dateDigitized; // DateTimeDigitized + std::optional<SYSTEMTIME> dateModified; // DateTime + + // Camera information + std::optional<std::wstring> cameraMake; // Make + std::optional<std::wstring> cameraModel; // Model + std::optional<std::wstring> lensModel; // LensModel + + // Shooting parameters + std::optional<int64_t> iso; // ISO speed + std::optional<double> aperture; // F-number + std::optional<double> shutterSpeed; // Exposure time + std::optional<double> focalLength; // Focal length in mm + std::optional<double> exposureBias; // Exposure bias value + std::optional<int64_t> flash; // Flash status + + // Image properties + std::optional<int64_t> width; // Image width in pixels + std::optional<int64_t> height; // Image height in pixels + std::optional<int64_t> orientation; // Image orientation + std::optional<int64_t> colorSpace; // Color space + + // Author and copyright + std::optional<std::wstring> author; // Artist + std::optional<std::wstring> copyright; // Copyright notice + + // GPS information + std::optional<double> latitude; // GPS latitude in decimal degrees + std::optional<double> longitude; // GPS longitude in decimal degrees + std::optional<double> altitude; // GPS altitude in meters + }; + + /// <summary> + /// XMP (Extensible Metadata Platform) metadata structure + /// Contains XMP Basic, Dublin Core, Rights and Media Management schema fields + /// </summary> + struct XMPMetadata + { + // XMP Basic schema - https://ns.adobe.com/xap/1.0/ + std::optional<SYSTEMTIME> createDate; // xmp:CreateDate + std::optional<SYSTEMTIME> modifyDate; // xmp:ModifyDate + std::optional<SYSTEMTIME> metadataDate; // xmp:MetadataDate + std::optional<std::wstring> creatorTool; // xmp:CreatorTool + + // Dublin Core schema - http://purl.org/dc/elements/1.1/ + std::optional<std::wstring> title; // dc:title + std::optional<std::wstring> description; // dc:description + std::optional<std::wstring> creator; // dc:creator (author) + std::optional<std::vector<std::wstring>> subject; // dc:subject (keywords) + + // XMP Rights Management schema - http://ns.adobe.com/xap/1.0/rights/ + std::optional<std::wstring> rights; // xmpRights:WebStatement (copyright) + + // XMP Media Management schema - http://ns.adobe.com/xap/1.0/mm/ + std::optional<std::wstring> documentID; // xmpMM:DocumentID + std::optional<std::wstring> instanceID; // xmpMM:InstanceID + std::optional<std::wstring> originalDocumentID; // xmpMM:OriginalDocumentID + std::optional<std::wstring> versionID; // xmpMM:VersionID + }; + + + + + /// <summary> + /// Constants for metadata pattern names + /// </summary> + namespace MetadataPatterns + { + // EXIF patterns + constexpr wchar_t CAMERA_MAKE[] = L"CAMERA_MAKE"; + constexpr wchar_t CAMERA_MODEL[] = L"CAMERA_MODEL"; + constexpr wchar_t LENS[] = L"LENS"; + constexpr wchar_t ISO[] = L"ISO"; + constexpr wchar_t APERTURE[] = L"APERTURE"; + constexpr wchar_t SHUTTER[] = L"SHUTTER"; + constexpr wchar_t FOCAL[] = L"FOCAL"; + constexpr wchar_t FLASH[] = L"FLASH"; + constexpr wchar_t WIDTH[] = L"WIDTH"; + constexpr wchar_t HEIGHT[] = L"HEIGHT"; + constexpr wchar_t AUTHOR[] = L"AUTHOR"; + constexpr wchar_t COPYRIGHT[] = L"COPYRIGHT"; + constexpr wchar_t LATITUDE[] = L"LATITUDE"; + constexpr wchar_t LONGITUDE[] = L"LONGITUDE"; + + // Date components from EXIF DateTimeOriginal (when photo was taken) + constexpr wchar_t DATE_TAKEN_YYYY[] = L"DATE_TAKEN_YYYY"; + constexpr wchar_t DATE_TAKEN_YY[] = L"DATE_TAKEN_YY"; + constexpr wchar_t DATE_TAKEN_MM[] = L"DATE_TAKEN_MM"; + constexpr wchar_t DATE_TAKEN_DD[] = L"DATE_TAKEN_DD"; + constexpr wchar_t DATE_TAKEN_HH[] = L"DATE_TAKEN_HH"; + constexpr wchar_t DATE_TAKEN_mm[] = L"DATE_TAKEN_mm"; + constexpr wchar_t DATE_TAKEN_SS[] = L"DATE_TAKEN_SS"; + + // Additional EXIF patterns + constexpr wchar_t EXPOSURE_BIAS[] = L"EXPOSURE_BIAS"; + constexpr wchar_t ORIENTATION[] = L"ORIENTATION"; + constexpr wchar_t COLOR_SPACE[] = L"COLOR_SPACE"; + constexpr wchar_t ALTITUDE[] = L"ALTITUDE"; + + // XMP patterns + constexpr wchar_t CREATOR_TOOL[] = L"CREATOR_TOOL"; + + // Date components from XMP CreateDate + constexpr wchar_t CREATE_DATE_YYYY[] = L"CREATE_DATE_YYYY"; + constexpr wchar_t CREATE_DATE_YY[] = L"CREATE_DATE_YY"; + constexpr wchar_t CREATE_DATE_MM[] = L"CREATE_DATE_MM"; + constexpr wchar_t CREATE_DATE_DD[] = L"CREATE_DATE_DD"; + constexpr wchar_t CREATE_DATE_HH[] = L"CREATE_DATE_HH"; + constexpr wchar_t CREATE_DATE_mm[] = L"CREATE_DATE_mm"; + constexpr wchar_t CREATE_DATE_SS[] = L"CREATE_DATE_SS"; + + // Dublin Core patterns + constexpr wchar_t TITLE[] = L"TITLE"; + constexpr wchar_t DESCRIPTION[] = L"DESCRIPTION"; + constexpr wchar_t CREATOR[] = L"CREATOR"; + constexpr wchar_t SUBJECT[] = L"SUBJECT"; // Keywords + + // XMP Rights pattern + constexpr wchar_t RIGHTS[] = L"RIGHTS"; // Copyright + + // XMP Media Management patterns + constexpr wchar_t DOCUMENT_ID[] = L"DOCUMENT_ID"; + constexpr wchar_t INSTANCE_ID[] = L"INSTANCE_ID"; + constexpr wchar_t ORIGINAL_DOCUMENT_ID[] = L"ORIGINAL_DOCUMENT_ID"; + constexpr wchar_t VERSION_ID[] = L"VERSION_ID"; + } +} \ No newline at end of file diff --git a/src/modules/powerrename/lib/PowerRenameInterfaces.h b/src/modules/powerrename/lib/PowerRenameInterfaces.h index c545e9dc00..7e3402433b 100644 --- a/src/modules/powerrename/lib/PowerRenameInterfaces.h +++ b/src/modules/powerrename/lib/PowerRenameInterfaces.h @@ -1,7 +1,10 @@ #pragma once #include "pch.h" +#include "MetadataTypes.h" +#include "MetadataPatternExtractor.h" #include <string> #include <vector> +#include <unordered_map> enum PowerRenameFlags { @@ -18,7 +21,13 @@ enum PowerRenameFlags Lowercase = 0x400, Titlecase = 0x800, Capitalized = 0x1000, - RandomizeItems = 0x2000 + RandomizeItems = 0x2000, + CreationTime = 0x4000, + ModificationTime = 0x8000, + AccessTime = 0x10000, + // Metadata source flags + MetadataSourceEXIF = 0x20000, // Default + MetadataSourceXMP = 0x40000, }; enum PowerRenameFilters @@ -44,6 +53,7 @@ public: IFACEMETHOD(OnReplaceTermChanged)(_In_ PCWSTR replaceTerm) = 0; IFACEMETHOD(OnFlagsChanged)(_In_ DWORD flags) = 0; IFACEMETHOD(OnFileTimeChanged)(_In_ SYSTEMTIME fileTime) = 0; + IFACEMETHOD(OnMetadataChanged)() = 0; }; interface __declspec(uuid("E3ED45B5-9CE0-47E2-A595-67EB950B9B72")) IPowerRenameRegEx : public IUnknown @@ -59,6 +69,9 @@ public: IFACEMETHOD(PutFlags)(_In_ DWORD flags) = 0; IFACEMETHOD(PutFileTime)(_In_ SYSTEMTIME fileTime) = 0; IFACEMETHOD(ResetFileTime)() = 0; + IFACEMETHOD(PutMetadataPatterns)(_In_ const PowerRenameLib::MetadataPatternMap& patterns) = 0; + IFACEMETHOD(ResetMetadata)() = 0; + IFACEMETHOD(GetMetadataType)(_Out_ PowerRenameLib::MetadataType* metadataType) = 0; IFACEMETHOD(Replace)(_In_ PCWSTR source, _Outptr_ PWSTR* result, unsigned long& enumIndex) = 0; }; @@ -67,7 +80,7 @@ interface __declspec(uuid("C7F59201-4DE1-4855-A3A2-26FC3279C8A5")) IPowerRenameI public: IFACEMETHOD(PutPath)(_In_opt_ PCWSTR newPath) = 0; IFACEMETHOD(GetPath)(_Outptr_ PWSTR * path) = 0; - IFACEMETHOD(GetTime)(_Outptr_ SYSTEMTIME* time) = 0; + IFACEMETHOD(GetTime)(_In_ DWORD flags, _Outptr_ SYSTEMTIME * time) = 0; IFACEMETHOD(GetShellItem)(_Outptr_ IShellItem** ppsi) = 0; IFACEMETHOD(GetOriginalName)(_Outptr_ PWSTR * originalName) = 0; IFACEMETHOD(PutOriginalName)(_In_opt_ PCWSTR originalName) = 0; diff --git a/src/modules/powerrename/lib/PowerRenameItem.cpp b/src/modules/powerrename/lib/PowerRenameItem.cpp index bd8fa48285..61e07a93fc 100644 --- a/src/modules/powerrename/lib/PowerRenameItem.cpp +++ b/src/modules/powerrename/lib/PowerRenameItem.cpp @@ -56,12 +56,28 @@ IFACEMETHODIMP CPowerRenameItem::GetPath(_Outptr_ PWSTR* path) return hr; } -IFACEMETHODIMP CPowerRenameItem::GetTime(_Outptr_ SYSTEMTIME* time) +IFACEMETHODIMP CPowerRenameItem::GetTime(_In_ DWORD flags, _Outptr_ SYSTEMTIME* time) { CSRWSharedAutoLock lock(&m_lock); HRESULT hr = E_FAIL; + PowerRenameFlags parsedTimeType; - if (m_isTimeParsed) + // Get Time by PowerRenameFlags + if (flags & PowerRenameFlags::ModificationTime) + { + parsedTimeType = PowerRenameFlags::ModificationTime; + } + else if (flags & PowerRenameFlags::AccessTime) + { + parsedTimeType = PowerRenameFlags::AccessTime; + } + else + { + // Default to modification time if no specific flag is set + parsedTimeType = PowerRenameFlags::CreationTime; + } + + if (m_isTimeParsed && parsedTimeType == m_parsedTimeType) { hr = S_OK; } @@ -70,22 +86,49 @@ IFACEMETHODIMP CPowerRenameItem::GetTime(_Outptr_ SYSTEMTIME* time) HANDLE hFile = CreateFileW(m_path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); if (hFile != INVALID_HANDLE_VALUE) { - FILETIME CreationTime; - if (GetFileTime(hFile, &CreationTime, NULL, NULL)) + // Use RAII-style scope guard to ensure handle is always closed + struct FileHandleCloser + { + HANDLE handle; + ~FileHandleCloser() { if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); } + } scopedHandle{ hFile }; + + FILETIME FileTime; + bool success = false; + + // Get Time by PowerRenameFlags + switch (parsedTimeType) + { + case PowerRenameFlags::CreationTime: + success = GetFileTime(hFile, &FileTime, NULL, NULL); + break; + case PowerRenameFlags::ModificationTime: + success = GetFileTime(hFile, NULL, NULL, &FileTime); + break; + case PowerRenameFlags::AccessTime: + success = GetFileTime(hFile, NULL, &FileTime, NULL); + break; + default: + // Default to modification time if no specific flag is set + success = GetFileTime(hFile, NULL, NULL, &FileTime); + break; + } + + if (success) { SYSTEMTIME SystemTime, LocalTime; - if (FileTimeToSystemTime(&CreationTime, &SystemTime)) + if (FileTimeToSystemTime(&FileTime, &SystemTime)) { if (SystemTimeToTzSpecificLocalTime(NULL, &SystemTime, &LocalTime)) { m_time = LocalTime; m_isTimeParsed = true; + m_parsedTimeType = parsedTimeType; hr = S_OK; } } } } - CloseHandle(hFile); } *time = m_time; return hr; @@ -301,7 +344,7 @@ HRESULT CPowerRenameItem::_Init(_In_ IShellItem* psi) // Some items can be both folders and streams (ex: zip folders). m_isFolder = (att & SFGAO_FOLDER) && !(att & SFGAO_STREAM); // The shell lets us know if an item should not be renamed - // (ex: user profile director, windows dir, etc). + // (ex: user profile director, windows dir, etc.). m_canRename = (att & SFGAO_CANRENAME); } } diff --git a/src/modules/powerrename/lib/PowerRenameItem.h b/src/modules/powerrename/lib/PowerRenameItem.h index 83817b0699..6ced0a3ada 100644 --- a/src/modules/powerrename/lib/PowerRenameItem.h +++ b/src/modules/powerrename/lib/PowerRenameItem.h @@ -16,7 +16,7 @@ public: // IPowerRenameItem IFACEMETHODIMP PutPath(_In_opt_ PCWSTR newPath); IFACEMETHODIMP GetPath(_Outptr_ PWSTR* path); - IFACEMETHODIMP GetTime(_Outptr_ SYSTEMTIME* time); + IFACEMETHODIMP GetTime(_In_ DWORD flags, _Outptr_ SYSTEMTIME* time); IFACEMETHODIMP GetShellItem(_Outptr_ IShellItem** ppsi); IFACEMETHODIMP PutOriginalName(_In_opt_ PCWSTR originalName); IFACEMETHODIMP GetOriginalName(_Outptr_ PWSTR* originalName); @@ -54,6 +54,7 @@ protected: bool m_selected = true; bool m_isFolder = false; bool m_isTimeParsed = false; + PowerRenameFlags m_parsedTimeType = PowerRenameFlags::CreationTime; bool m_canRename = true; int m_id = -1; int m_iconIndex = -1; diff --git a/src/modules/powerrename/lib/PowerRenameLib.vcxproj b/src/modules/powerrename/lib/PowerRenameLib.vcxproj index d55c7b4b77..533b50ccdf 100644 --- a/src/modules/powerrename/lib/PowerRenameLib.vcxproj +++ b/src/modules/powerrename/lib/PowerRenameLib.vcxproj @@ -1,34 +1,39 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <ProjectGuid>{51920F1F-C28C-4ADF-8660-4238766796C2}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>PowerRenameLib</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>StaticLibrary</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> </ImportGroup> <ImportGroup Label="Shared"> </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <DepsPath>$(RepoRoot)deps</DepsPath> + </PropertyGroup> <ImportGroup Label="PropertySheets"> <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> </ImportGroup> - <PropertyGroup Label="UserMacros" /> - <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> - </PropertyGroup> <ItemDefinitionGroup> <ClCompile> <WarningLevel>Level3</WarningLevel> <PreprocessorDefinitions>WIN32;_LIB;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>$(ProjectDir)..\;$(ProjectDir)..\ui;$(ProjectDir)..\dll;$(ProjectDir)..\lib;$(ProjectDir)..\..\..\;$(ProjectDir)..\..\..\common\Telemetry;%(AdditionalIncludeDirectories);$(GeneratedFilesDir)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(ProjectDir)..\;$(ProjectDir)..\ui;$(ProjectDir)..\dll;$(ProjectDir)..\lib;$(RepoRoot)src\;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories);$(GeneratedFilesDir)</AdditionalIncludeDirectories> + <AdditionalOptions>/FS %(AdditionalOptions)</AdditionalOptions> </ClCompile> + <Link> + <AdditionalDependencies>windowscodecs.lib;propsys.lib;ole32.lib;%(AdditionalDependencies)</AdditionalDependencies> + </Link> </ItemDefinitionGroup> <ItemGroup> <ClInclude Include="Enumerating.h" /> @@ -47,6 +52,12 @@ <ClInclude Include="pch.h" /> <ClInclude Include="targetver.h" /> <ClInclude Include="trace.h" /> + <ClInclude Include="MetadataTypes.h" /> + <ClInclude Include="PropVariantValue.h" /> + <ClInclude Include="WICMetadataExtractor.h" /> + <ClInclude Include="MetadataPatternExtractor.h" /> + <ClInclude Include="MetadataFormatHelper.h" /> + <ClInclude Include="MetadataResultCache.h" /> </ItemGroup> <ItemGroup> <ClCompile Include="Enumerating.cpp" /> @@ -64,31 +75,35 @@ <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> </ClCompile> <ClCompile Include="trace.cpp" /> + <ClCompile Include="WICMetadataExtractor.cpp" /> + <ClCompile Include="MetadataPatternExtractor.cpp" /> + <ClCompile Include="MetadataFormatHelper.cpp" /> + <ClCompile Include="MetadataResultCache.cpp" /> </ItemGroup> <ItemGroup> <None Include="packages.config" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\boost.1.84.0\build\boost.targets" Condition="Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" /> - <Import Project="..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets" Condition="Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\boost.1.87.0\build\boost.targets" Condition="Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" /> + <Import Project="$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets" Condition="Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost.1.84.0\build\boost.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost.1.87.0\build\boost.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/powerrename/lib/PowerRenameManager.cpp b/src/modules/powerrename/lib/PowerRenameManager.cpp index b6641374ba..b650113b25 100644 --- a/src/modules/powerrename/lib/PowerRenameManager.cpp +++ b/src/modules/powerrename/lib/PowerRenameManager.cpp @@ -462,6 +462,12 @@ IFACEMETHODIMP CPowerRenameManager::OnFileTimeChanged(_In_ SYSTEMTIME /*fileTime return S_OK; } +IFACEMETHODIMP CPowerRenameManager::OnMetadataChanged() +{ + _PerformRegExRename(); + return S_OK; +} + HRESULT CPowerRenameManager::s_CreateInstance(_Outptr_ IPowerRenameManager** ppsrm) { *ppsrm = nullptr; @@ -748,8 +754,26 @@ DWORD WINAPI CPowerRenameManager::s_fileOpWorkerThread(_In_ void* pv) // We add the items to the operation in depth-first order. This allows child items to be // renamed before parent items. + // First pass: find the maximum depth to properly size the matrix + UINT maxDepth = 0; + for (UINT u = 0; u < itemCount; u++) + { + CComPtr<IPowerRenameItem> spItem; + if (SUCCEEDED(pwtd->spsrm->GetItemByIndex(u, &spItem))) + { + UINT depth = 0; + spItem->GetDepth(&depth); + if (depth > maxDepth) + { + maxDepth = depth; + } + } + } + // Creating a vector of vectors of items of the same depth - std::vector<std::vector<UINT>> matrix(itemCount); + // Size by maxDepth+1 (not itemCount) to avoid excessive memory allocation + // Cast to size_t before arithmetic to avoid overflow on 32-bit UINT + std::vector<std::vector<UINT>> matrix(static_cast<size_t>(maxDepth) + 1); for (UINT u = 0; u < itemCount; u++) { @@ -763,7 +787,7 @@ DWORD WINAPI CPowerRenameManager::s_fileOpWorkerThread(_In_ void* pv) } // From the greatest depth first, add all items of that depth to the operation - for (LONG v = itemCount - 1; v >= 0; v--) + for (LONG v = static_cast<LONG>(maxDepth); v >= 0; v--) { for (auto it : matrix[v]) { diff --git a/src/modules/powerrename/lib/PowerRenameManager.h b/src/modules/powerrename/lib/PowerRenameManager.h index f339f5c9d4..a9fa44d144 100644 --- a/src/modules/powerrename/lib/PowerRenameManager.h +++ b/src/modules/powerrename/lib/PowerRenameManager.h @@ -50,6 +50,7 @@ public: IFACEMETHODIMP OnReplaceTermChanged(_In_ PCWSTR replaceTerm); IFACEMETHODIMP OnFlagsChanged(_In_ DWORD flags); IFACEMETHODIMP OnFileTimeChanged(_In_ SYSTEMTIME fileTime); + IFACEMETHODIMP OnMetadataChanged(); static HRESULT s_CreateInstance(_Outptr_ IPowerRenameManager** ppsrm); diff --git a/src/modules/powerrename/lib/PowerRenameRegEx.cpp b/src/modules/powerrename/lib/PowerRenameRegEx.cpp index 76136ff39c..266fe5af9d 100644 --- a/src/modules/powerrename/lib/PowerRenameRegEx.cpp +++ b/src/modules/powerrename/lib/PowerRenameRegEx.cpp @@ -11,6 +11,52 @@ using std::conditional_t; using std::regex_error; +/// <summary> +/// Sanitizes the input string by replacing non-breaking spaces with regular spaces and +/// normalizes it to Unicode NFC (precomposed) form. +/// </summary> +/// <param name="input">The input wide string to sanitize and normalize. If empty, it is +/// returned unchanged.</param> +/// <returns>A new std::wstring containing the sanitized and NFC-normalized form of the +/// input. If normalization fails, the function returns the sanitized string (with non- +/// breaking spaces replaced) as-is.</returns> +static std::wstring SanitizeAndNormalize(const std::wstring& input) +{ + if (input.empty()) + { + return input; + } + + std::wstring sanitized = input; + // Replace non-breaking spaces (0xA0) with regular spaces (0x20). + std::replace(sanitized.begin(), sanitized.end(), L'\u00A0', L' '); + + // Normalize to NFC (Precomposed). + // Get the size needed for the normalized string, including null terminator. + 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(sizeEstimate); + int actualSize = NormalizeString(NormalizationC, sanitized.c_str(), -1, &normalized[0], sizeEstimate); + + if (actualSize <= 0) + { + // 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<size_t>(actualSize) - 1); + + return normalized; +} + IFACEMETHODIMP_(ULONG) CPowerRenameRegEx::AddRef() { @@ -94,18 +140,20 @@ IFACEMETHODIMP CPowerRenameRegEx::PutSearchTerm(_In_ PCWSTR searchTerm, bool for HRESULT hr = S_OK; if (searchTerm) { + std::wstring normalizedSearchTerm = SanitizeAndNormalize(searchTerm); + CSRWExclusiveAutoLock lock(&m_lock); - if (m_searchTerm == nullptr || lstrcmp(searchTerm, m_searchTerm) != 0) + if (m_searchTerm == nullptr || lstrcmp(normalizedSearchTerm.c_str(), m_searchTerm) != 0) { changed = true; CoTaskMemFree(m_searchTerm); - if (lstrcmp(searchTerm, L"") == 0) + if (normalizedSearchTerm.empty()) { m_searchTerm = NULL; } else { - hr = SHStrDup(searchTerm, &m_searchTerm); + hr = SHStrDup(normalizedSearchTerm.c_str(), &m_searchTerm); } } } @@ -154,8 +202,7 @@ HRESULT CPowerRenameRegEx::_OnEnumerateOrRandomizeItemsChanged() std::find_if( m_randomizer.begin(), m_randomizer.end(), - [option](const Randomizer& r) -> bool { return r.options.replaceStrSpan.offset == option.replaceStrSpan.offset; } - )) + [option](const Randomizer& r) -> bool { return r.options.replaceStrSpan.offset == option.replaceStrSpan.offset; })) { // Only add as enumerator if we didn't find a randomizer already at this offset. // Every randomizer will also be a valid enumerator according to the definition of enumerators, which allows any string to mean the default enumerator, so it should be interpreted that the user wanted a randomizer if both were found at the same offset of the replace string. @@ -239,17 +286,19 @@ IFACEMETHODIMP CPowerRenameRegEx::PutReplaceTerm(_In_ PCWSTR replaceTerm, bool f HRESULT hr = S_OK; if (replaceTerm) { + std::wstring normalizedReplaceTerm = SanitizeAndNormalize(replaceTerm); + CSRWExclusiveAutoLock lock(&m_lock); - if (m_replaceTerm == nullptr || lstrcmp(replaceTerm, m_RawReplaceTerm.c_str()) != 0) + if (m_replaceTerm == nullptr || lstrcmp(normalizedReplaceTerm.c_str(), m_RawReplaceTerm.c_str()) != 0) { changed = true; CoTaskMemFree(m_replaceTerm); - m_RawReplaceTerm = replaceTerm; + m_RawReplaceTerm = normalizedReplaceTerm; if ((m_flags & RandomizeItems) || (m_flags & EnumerateItems)) hr = _OnEnumerateOrRandomizeItemsChanged(); else - hr = SHStrDup(replaceTerm, &m_replaceTerm); + hr = SHStrDup(normalizedReplaceTerm.c_str(), &m_replaceTerm); } } @@ -299,19 +348,13 @@ IFACEMETHODIMP CPowerRenameRegEx::PutFlags(_In_ DWORD flags) IFACEMETHODIMP CPowerRenameRegEx::PutFileTime(_In_ SYSTEMTIME fileTime) { - union timeunion - { - FILETIME fileTime; - ULARGE_INTEGER ul; - }; + FILETIME ft1; + FILETIME ft2; - timeunion ft1; - timeunion ft2; + SystemTimeToFileTime(&m_fileTime, &ft1); + SystemTimeToFileTime(&fileTime, &ft2); - SystemTimeToFileTime(&m_fileTime, &ft1.fileTime); - SystemTimeToFileTime(&fileTime, &ft2.fileTime); - - if (ft2.ul.QuadPart != ft1.ul.QuadPart) + if (ft2.dwLowDateTime != ft1.dwLowDateTime || ft2.dwHighDateTime != ft1.dwHighDateTime) { m_fileTime = fileTime; m_useFileTime = true; @@ -329,6 +372,22 @@ IFACEMETHODIMP CPowerRenameRegEx::ResetFileTime() return S_OK; } +IFACEMETHODIMP CPowerRenameRegEx::PutMetadataPatterns(_In_ const PowerRenameLib::MetadataPatternMap& patterns) +{ + m_metadataPatterns = patterns; + m_useMetadata = true; + _OnMetadataChanged(); + return S_OK; +} + +IFACEMETHODIMP CPowerRenameRegEx::ResetMetadata() +{ + m_metadataPatterns.clear(); + m_useMetadata = false; + _OnMetadataChanged(); + return S_OK; +} + HRESULT CPowerRenameRegEx::s_CreateInstance(_Outptr_ IPowerRenameRegEx** renameRegEx) { *renameRegEx = nullptr; @@ -382,30 +441,58 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u { return hr; } - std::wstring res = source; + + std::wstring normalizedSource = SanitizeAndNormalize(source); + + std::wstring res = normalizedSource; try { // TODO: creating the regex could be costly. May want to cache this. wchar_t newReplaceTerm[MAX_PATH] = { 0 }; bool fileTimeErrorOccurred = false; - if (m_useFileTime) + bool metadataErrorOccurred = false; + bool appliedTemplateTransform = false; + + std::wstring replaceTemplate; + if (m_replaceTerm) { - if (FAILED(GetDatedFileName(newReplaceTerm, ARRAYSIZE(newReplaceTerm), m_replaceTerm, m_fileTime))) - fileTimeErrorOccurred = true; + replaceTemplate = m_replaceTerm; } - std::wstring sourceToUse; - std::wstring originalSource; + if (m_useFileTime) + { + if (FAILED(GetDatedFileName(newReplaceTerm, ARRAYSIZE(newReplaceTerm), replaceTemplate.c_str(), m_fileTime))) + { + fileTimeErrorOccurred = true; + } + else + { + replaceTemplate.assign(newReplaceTerm); + appliedTemplateTransform = true; + } + } + + if (m_useMetadata) + { + if (FAILED(GetMetadataFileName(newReplaceTerm, ARRAYSIZE(newReplaceTerm), replaceTemplate.c_str(), m_metadataPatterns))) + { + metadataErrorOccurred = true; + } + else + { + replaceTemplate.assign(newReplaceTerm); + appliedTemplateTransform = true; + } + } + + std::wstring sourceToUse = normalizedSource; sourceToUse.reserve(MAX_PATH); - originalSource.reserve(MAX_PATH); - sourceToUse = source; - originalSource = sourceToUse; std::wstring searchTerm(m_searchTerm); std::wstring replaceTerm; - if (m_useFileTime && !fileTimeErrorOccurred) + if (appliedTemplateTransform) { - replaceTerm = newReplaceTerm; + replaceTerm = replaceTemplate; } else if (m_replaceTerm) { @@ -487,27 +574,46 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u } } - bool replacedSomething = false; + bool shouldIncrementCounter = false; + const bool isCaseInsensitive = !(m_flags & CaseSensitive); + if (m_flags & UseRegularExpressions) { replaceTerm = regex_replace(replaceTerm, zeroGroupRegex, L"$1$$$0"); replaceTerm = regex_replace(replaceTerm, otherGroupsRegex, L"$1$0$4"); - res = RegexReplaceDispatch[_useBoostLib](source, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, !(m_flags & CaseSensitive)); - replacedSomething = originalSource != res; + res = RegexReplaceDispatch[_useBoostLib](sourceToUse, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, isCaseInsensitive); + + // Use regex search to determine if a match exists. This is the basis for incrementing + // the counter. + if (_useBoostLib) + { + boost::wregex pattern(m_searchTerm, boost::wregex::ECMAScript | (isCaseInsensitive ? boost::wregex::icase : boost::wregex::normal)); + shouldIncrementCounter = boost::regex_search(sourceToUse, pattern); + } + else + { + auto regexFlags = std::wregex::ECMAScript; + if (isCaseInsensitive) + { + regexFlags |= std::wregex::icase; + } + std::wregex pattern(m_searchTerm, regexFlags); + shouldIncrementCounter = std::regex_search(sourceToUse, pattern); + } } else { - // Simple search and replace + // Simple search and replace. size_t pos = 0; do { - pos = _Find(sourceToUse, searchTerm, (!(m_flags & CaseSensitive)), pos); + pos = _Find(sourceToUse, searchTerm, isCaseInsensitive, pos); if (pos != std::string::npos) { res = sourceToUse.replace(pos, searchTerm.length(), replaceTerm); pos += replaceTerm.length(); - replacedSomething = true; + shouldIncrementCounter = true; } if (!(m_flags & MatchAllOccurrences)) { @@ -516,7 +622,8 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u } while (pos != std::string::npos); } hr = SHStrDup(res.c_str(), result); - if (replacedSomething) + + if (shouldIncrementCounter) enumIndex++; } catch (regex_error e) @@ -590,3 +697,41 @@ void CPowerRenameRegEx::_OnFileTimeChanged() } } } + +void CPowerRenameRegEx::_OnMetadataChanged() +{ + CSRWSharedAutoLock lock(&m_lockEvents); + + for (auto it : m_renameRegExEvents) + { + if (it.pEvents) + { + it.pEvents->OnMetadataChanged(); + } + } +} + +PowerRenameLib::MetadataType CPowerRenameRegEx::_GetMetadataTypeFromFlags() const +{ + if (m_flags & MetadataSourceXMP) + return PowerRenameLib::MetadataType::XMP; + + // Default to EXIF + return PowerRenameLib::MetadataType::EXIF; +} + +// Interface method implementation +IFACEMETHODIMP CPowerRenameRegEx::GetMetadataType(_Out_ PowerRenameLib::MetadataType* metadataType) +{ + if (metadataType == nullptr) + return E_POINTER; + + *metadataType = _GetMetadataTypeFromFlags(); + return S_OK; +} + +// Convenience method for internal use +PowerRenameLib::MetadataType CPowerRenameRegEx::GetMetadataType() const +{ + return _GetMetadataTypeFromFlags(); +} diff --git a/src/modules/powerrename/lib/PowerRenameRegEx.h b/src/modules/powerrename/lib/PowerRenameRegEx.h index 55c6c14c17..9e43107efa 100644 --- a/src/modules/powerrename/lib/PowerRenameRegEx.h +++ b/src/modules/powerrename/lib/PowerRenameRegEx.h @@ -5,6 +5,8 @@ #include "Enumerating.h" #include "Randomizer.h" +#include "MetadataTypes.h" +#include "MetadataPatternExtractor.h" #include "PowerRenameInterfaces.h" @@ -29,7 +31,13 @@ public: IFACEMETHODIMP PutFlags(_In_ DWORD flags); IFACEMETHODIMP PutFileTime(_In_ SYSTEMTIME fileTime); IFACEMETHODIMP ResetFileTime(); + IFACEMETHODIMP PutMetadataPatterns(_In_ const PowerRenameLib::MetadataPatternMap& patterns); + IFACEMETHODIMP ResetMetadata(); + IFACEMETHODIMP GetMetadataType(_Out_ PowerRenameLib::MetadataType* metadataType); IFACEMETHODIMP Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, unsigned long& enumIndex); + + // Get current metadata type based on flags + PowerRenameLib::MetadataType GetMetadataType() const; static HRESULT s_CreateInstance(_Outptr_ IPowerRenameRegEx** renameRegEx); @@ -41,7 +49,9 @@ protected: void _OnReplaceTermChanged(); void _OnFlagsChanged(); void _OnFileTimeChanged(); + void _OnMetadataChanged(); HRESULT _OnEnumerateOrRandomizeItemsChanged(); + PowerRenameLib::MetadataType _GetMetadataTypeFromFlags() const; size_t _Find(std::wstring data, std::wstring toSearch, bool caseInsensitive, size_t pos); @@ -54,6 +64,9 @@ protected: SYSTEMTIME m_fileTime = { 0 }; bool m_useFileTime = false; + PowerRenameLib::MetadataPatternMap m_metadataPatterns; + bool m_useMetadata = false; + CSRWLock m_lock; CSRWLock m_lockEvents; diff --git a/src/modules/powerrename/lib/PropVariantValue.h b/src/modules/powerrename/lib/PropVariantValue.h new file mode 100644 index 0000000000..23e5973d25 --- /dev/null +++ b/src/modules/powerrename/lib/PropVariantValue.h @@ -0,0 +1,62 @@ +#pragma once + +#include <propvarutil.h> +#include <propidl.h> + +namespace PowerRenameLib +{ + /// <summary> + /// RAII wrapper around PROPVARIANT to ensure proper initialization and cleanup. + /// Move-only semantics keep ownership simple while still allowing use in optionals. + /// </summary> + struct PropVariantValue + { + PropVariantValue() noexcept + { + PropVariantInit(&value); + } + + ~PropVariantValue() + { + PropVariantClear(&value); + } + + PropVariantValue(const PropVariantValue&) = delete; + PropVariantValue& operator=(const PropVariantValue&) = delete; + + PropVariantValue(PropVariantValue&& other) noexcept + { + value = other.value; + PropVariantInit(&other.value); // Properly clear the moved-from object + } + + PropVariantValue& operator=(PropVariantValue&& other) noexcept + { + if (this != &other) + { + PropVariantClear(&value); + value = other.value; + PropVariantInit(&other.value); // Properly clear the moved-from object + } + return *this; + } + + PROPVARIANT* GetAddressOf() noexcept + { + return &value; + } + + PROPVARIANT& Get() noexcept + { + return value; + } + + const PROPVARIANT& Get() const noexcept + { + return value; + } + + private: + PROPVARIANT value; + }; +} diff --git a/src/modules/powerrename/lib/Renaming.cpp b/src/modules/powerrename/lib/Renaming.cpp index bf27500529..028621eef4 100644 --- a/src/modules/powerrename/lib/Renaming.cpp +++ b/src/modules/powerrename/lib/Renaming.cpp @@ -1,9 +1,13 @@ #include "pch.h" #include <winrt/base.h> +#include <memory> +#include <mutex> +#include <optional> #include "Renaming.h" #include <Helpers.h> - +#include "MetadataPatternExtractor.h" +#include "PowerRenameRegEx.h" namespace fs = std::filesystem; bool DoRename(CComPtr<IPowerRenameRegEx>& spRenameRegEx, unsigned long& itemEnumIndex, CComPtr<IPowerRenameItem>& spItem) @@ -14,6 +18,7 @@ bool DoRename(CComPtr<IPowerRenameRegEx>& spRenameRegEx, unsigned long& itemEnum PWSTR replaceTerm = nullptr; bool useFileTime = false; + bool useMetadata = false; winrt::check_hresult(spRenameRegEx->GetReplaceTerm(&replaceTerm)); @@ -21,7 +26,6 @@ bool DoRename(CComPtr<IPowerRenameRegEx>& spRenameRegEx, unsigned long& itemEnum { useFileTime = true; } - CoTaskMemFree(replaceTerm); int id = -1; winrt::check_hresult(spItem->GetId(&id)); @@ -30,6 +34,29 @@ bool DoRename(CComPtr<IPowerRenameRegEx>& spRenameRegEx, unsigned long& itemEnum bool isSubFolderContent = false; winrt::check_hresult(spItem->GetIsFolder(&isFolder)); winrt::check_hresult(spItem->GetIsSubFolderContent(&isSubFolderContent)); + + // Get metadata type to check if metadata patterns are used + PowerRenameLib::MetadataType metadataType; + HRESULT hr = spRenameRegEx->GetMetadataType(&metadataType); + if (FAILED(hr)) + { + // Fallback to default metadata type if call fails + metadataType = PowerRenameLib::MetadataType::EXIF; + } + + // Check if metadata is used AND if this file type supports metadata + // Get file path early for metadata type checking and reuse later + PWSTR filePath = nullptr; + winrt::check_hresult(spItem->GetPath(&filePath)); + std::wstring filePathStr(filePath); // Copy once for reuse + CoTaskMemFree(filePath); // Free immediately after copying + + if (isMetadataUsed(replaceTerm, metadataType, filePathStr.c_str(), isFolder)) + { + useMetadata = true; + } + + CoTaskMemFree(replaceTerm); if ((isFolder && (flags & PowerRenameFlags::ExcludeFolders)) || (!isFolder && (flags & PowerRenameFlags::ExcludeFiles)) || (isSubFolderContent && (flags & PowerRenameFlags::ExcludeSubfolders)) || @@ -78,10 +105,57 @@ bool DoRename(CComPtr<IPowerRenameRegEx>& spRenameRegEx, unsigned long& itemEnum if (useFileTime) { - winrt::check_hresult(spItem->GetTime(&fileTime)); + winrt::check_hresult(spItem->GetTime(flags, &fileTime)); winrt::check_hresult(spRenameRegEx->PutFileTime(fileTime)); } + if (useMetadata) + { + // Extract metadata patterns from the file + // Note: filePathStr was already obtained and saved earlier for reuse + + // Get metadata type using the interface method + PowerRenameLib::MetadataType metadataType; + HRESULT hr = spRenameRegEx->GetMetadataType(&metadataType); + if (FAILED(hr)) + { + // Fallback to default metadata type if call fails + metadataType = PowerRenameLib::MetadataType::EXIF; + } + // Extract all patterns for the selected metadata type + // At this point we know the file is a supported image format (jpg/jpeg/png/tif/tiff) + static std::mutex s_metadataMutex; // Mutex to protect static variables + static std::once_flag s_metadataExtractorInitFlag; + static std::shared_ptr<PowerRenameLib::MetadataPatternExtractor> s_metadataExtractor; + static std::optional<PowerRenameLib::MetadataType> s_activeMetadataType; + + // Initialize the extractor only once + std::call_once(s_metadataExtractorInitFlag, []() { + s_metadataExtractor = std::make_shared<PowerRenameLib::MetadataPatternExtractor>(); + }); + + // Protect access to shared state + { + std::lock_guard<std::mutex> lock(s_metadataMutex); + + // Clear cache if metadata type has changed + if (s_activeMetadataType.has_value() && s_activeMetadataType.value() != metadataType) + { + s_metadataExtractor->ClearCache(); + } + + // Update the active metadata type + s_activeMetadataType = metadataType; + } + + // Extract patterns (this can be done outside the lock if ExtractPatterns is thread-safe) + PowerRenameLib::MetadataPatternMap patterns = s_metadataExtractor->ExtractPatterns(filePathStr, metadataType); + + // Always call PutMetadataPatterns to ensure all patterns get replaced + // Even if empty, this keeps metadata placeholders consistent when no values are extracted + winrt::check_hresult(spRenameRegEx->PutMetadataPatterns(patterns)); + } + PWSTR newName = nullptr; // Failure here means we didn't match anything or had nothing to match @@ -93,6 +167,10 @@ bool DoRename(CComPtr<IPowerRenameRegEx>& spRenameRegEx, unsigned long& itemEnum winrt::check_hresult(spRenameRegEx->ResetFileTime()); } + if (useMetadata) + { + winrt::check_hresult(spRenameRegEx->ResetMetadata()); + } wchar_t resultName[MAX_PATH] = { 0 }; PWSTR newNameToUse = nullptr; @@ -206,4 +284,4 @@ bool DoRename(CComPtr<IPowerRenameRegEx>& spRenameRegEx, unsigned long& itemEnum CoTaskMemFree(originalName); return wouldRename; -} \ No newline at end of file +} diff --git a/src/modules/powerrename/lib/WICMetadataExtractor.cpp b/src/modules/powerrename/lib/WICMetadataExtractor.cpp new file mode 100644 index 0000000000..eb66679aad --- /dev/null +++ b/src/modules/powerrename/lib/WICMetadataExtractor.cpp @@ -0,0 +1,1115 @@ +// 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 "WICMetadataExtractor.h" +#include "MetadataFormatHelper.h" +#include <algorithm> +#include <sstream> +#include <iomanip> +#include <cwctype> +#include <comdef.h> +#include <shlwapi.h> + +using namespace PowerRenameLib; + +namespace +{ + // Documentation: https://learn.microsoft.com/en-us/windows/win32/wic/-wic-native-image-format-metadata-queries + + // WIC metadata property paths + const std::wstring EXIF_DATE_TAKEN = L"/app1/ifd/exif/{ushort=36867}"; // DateTimeOriginal + const std::wstring EXIF_DATE_DIGITIZED = L"/app1/ifd/exif/{ushort=36868}"; // DateTimeDigitized + const std::wstring EXIF_DATE_MODIFIED = L"/app1/ifd/{ushort=306}"; // DateTime + const std::wstring EXIF_CAMERA_MAKE = L"/app1/ifd/{ushort=271}"; // Make + const std::wstring EXIF_CAMERA_MODEL = L"/app1/ifd/{ushort=272}"; // Model + const std::wstring EXIF_LENS_MODEL = L"/app1/ifd/exif/{ushort=42036}"; // LensModel + const std::wstring EXIF_ISO = L"/app1/ifd/exif/{ushort=34855}"; // ISOSpeedRatings + const std::wstring EXIF_APERTURE = L"/app1/ifd/exif/{ushort=33437}"; // FNumber + const std::wstring EXIF_SHUTTER_SPEED = L"/app1/ifd/exif/{ushort=33434}"; // ExposureTime + const std::wstring EXIF_FOCAL_LENGTH = L"/app1/ifd/exif/{ushort=37386}"; // FocalLength + const std::wstring EXIF_EXPOSURE_BIAS = L"/app1/ifd/exif/{ushort=37380}"; // ExposureBiasValue + const std::wstring EXIF_FLASH = L"/app1/ifd/exif/{ushort=37385}"; // Flash + const std::wstring EXIF_ORIENTATION = L"/app1/ifd/{ushort=274}"; // Orientation + const std::wstring EXIF_COLOR_SPACE = L"/app1/ifd/exif/{ushort=40961}"; // ColorSpace + const std::wstring EXIF_WIDTH = L"/app1/ifd/exif/{ushort=40962}"; // PixelXDimension - actual image width + const std::wstring EXIF_HEIGHT = L"/app1/ifd/exif/{ushort=40963}"; // PixelYDimension - actual image height + const std::wstring EXIF_ARTIST = L"/app1/ifd/{ushort=315}"; // Artist + const std::wstring EXIF_COPYRIGHT = L"/app1/ifd/{ushort=33432}"; // Copyright + + // GPS paths for JPEG format + const std::wstring GPS_LATITUDE = L"/app1/ifd/gps/{ushort=2}"; // GPSLatitude + const std::wstring GPS_LATITUDE_REF = L"/app1/ifd/gps/{ushort=1}"; // GPSLatitudeRef + const std::wstring GPS_LONGITUDE = L"/app1/ifd/gps/{ushort=4}"; // GPSLongitude + const std::wstring GPS_LONGITUDE_REF = L"/app1/ifd/gps/{ushort=3}"; // GPSLongitudeRef + const std::wstring GPS_ALTITUDE = L"/app1/ifd/gps/{ushort=6}"; // GPSAltitude + const std::wstring GPS_ALTITUDE_REF = L"/app1/ifd/gps/{ushort=5}"; // GPSAltitudeRef + + // WIC metadata property paths for TIFF/HEIF format (uses /ifd prefix directly) + // HEIF/HEIC images use TIFF-style metadata paths + const std::wstring HEIF_DATE_TAKEN = L"/ifd/exif/{ushort=36867}"; // DateTimeOriginal + const std::wstring HEIF_DATE_DIGITIZED = L"/ifd/exif/{ushort=36868}"; // DateTimeDigitized + const std::wstring HEIF_DATE_MODIFIED = L"/ifd/{ushort=306}"; // DateTime + const std::wstring HEIF_CAMERA_MAKE = L"/ifd/{ushort=271}"; // Make + const std::wstring HEIF_CAMERA_MODEL = L"/ifd/{ushort=272}"; // Model + const std::wstring HEIF_LENS_MODEL = L"/ifd/exif/{ushort=42036}"; // LensModel + const std::wstring HEIF_ISO = L"/ifd/exif/{ushort=34855}"; // ISOSpeedRatings + const std::wstring HEIF_APERTURE = L"/ifd/exif/{ushort=33437}"; // FNumber + const std::wstring HEIF_SHUTTER_SPEED = L"/ifd/exif/{ushort=33434}"; // ExposureTime + const std::wstring HEIF_FOCAL_LENGTH = L"/ifd/exif/{ushort=37386}"; // FocalLength + const std::wstring HEIF_EXPOSURE_BIAS = L"/ifd/exif/{ushort=37380}"; // ExposureBiasValue + const std::wstring HEIF_FLASH = L"/ifd/exif/{ushort=37385}"; // Flash + const std::wstring HEIF_ORIENTATION = L"/ifd/{ushort=274}"; // Orientation + const std::wstring HEIF_COLOR_SPACE = L"/ifd/exif/{ushort=40961}"; // ColorSpace + const std::wstring HEIF_WIDTH = L"/ifd/exif/{ushort=40962}"; // PixelXDimension + const std::wstring HEIF_HEIGHT = L"/ifd/exif/{ushort=40963}"; // PixelYDimension + const std::wstring HEIF_ARTIST = L"/ifd/{ushort=315}"; // Artist + const std::wstring HEIF_COPYRIGHT = L"/ifd/{ushort=33432}"; // Copyright + + // GPS paths for TIFF/HEIF format + const std::wstring HEIF_GPS_LATITUDE = L"/ifd/gps/{ushort=2}"; // GPSLatitude + const std::wstring HEIF_GPS_LATITUDE_REF = L"/ifd/gps/{ushort=1}"; // GPSLatitudeRef + const std::wstring HEIF_GPS_LONGITUDE = L"/ifd/gps/{ushort=4}"; // GPSLongitude + const std::wstring HEIF_GPS_LONGITUDE_REF = L"/ifd/gps/{ushort=3}"; // GPSLongitudeRef + const std::wstring HEIF_GPS_ALTITUDE = L"/ifd/gps/{ushort=6}"; // GPSAltitude + const std::wstring HEIF_GPS_ALTITUDE_REF = L"/ifd/gps/{ushort=5}"; // GPSAltitudeRef + + + // Documentation: https://developer.adobe.com/xmp/docs/XMPNamespaces/xmp/ + // Based on actual WIC path format discovered through enumeration + // XMP Basic schema - xmp: namespace + const std::wstring XMP_CREATE_DATE = L"/xmp/xmp:CreateDate"; // XMP Create Date + const std::wstring XMP_MODIFY_DATE = L"/xmp/xmp:ModifyDate"; // XMP Modify Date + const std::wstring XMP_METADATA_DATE = L"/xmp/xmp:MetadataDate"; // XMP Metadata Date + const std::wstring XMP_CREATOR_TOOL = L"/xmp/xmp:CreatorTool"; // XMP Creator Tool + + // Dublin Core schema - dc: namespace + // Note: For language alternatives like title/description, we need to append /x-default + const std::wstring XMP_DC_TITLE = L"/xmp/dc:title/x-default"; // Title (default language) + const std::wstring XMP_DC_DESCRIPTION = L"/xmp/dc:description/x-default"; // Description (default language) + const std::wstring XMP_DC_CREATOR = L"/xmp/dc:creator"; // Creator/Author + const std::wstring XMP_DC_SUBJECT = L"/xmp/dc:subject"; // Subject/Keywords (array) + + // XMP Rights Management schema - xmpRights: namespace + const std::wstring XMP_RIGHTS = L"/xmp/xmpRights:WebStatement"; // Copyright/Rights + + // XMP Media Management schema - xmpMM: namespace + const std::wstring XMP_MM_DOCUMENT_ID = L"/xmp/xmpMM:DocumentID"; // Document ID + const std::wstring XMP_MM_INSTANCE_ID = L"/xmp/xmpMM:InstanceID"; // Instance ID + const std::wstring XMP_MM_ORIGINAL_DOCUMENT_ID = L"/xmp/xmpMM:OriginalDocumentID"; // Original Document ID + const std::wstring XMP_MM_VERSION_ID = L"/xmp/xmpMM:VersionID"; // Version ID + + + std::wstring TrimWhitespace(const std::wstring& value) + { + const auto first = value.find_first_not_of(L" \t\r\n"); + if (first == std::wstring::npos) + { + return {}; + } + + const auto last = value.find_last_not_of(L" \t\r\n"); + return value.substr(first, last - first + 1); + } + + bool TryParseFixedWidthInt(const std::wstring& source, size_t start, size_t length, int& value) + { + if (start + length > source.size()) + { + return false; + } + + int result = 0; + for (size_t i = 0; i < length; ++i) + { + const wchar_t ch = source[start + i]; + if (ch < L'0' || ch > L'9') + { + return false; + } + + result = result * 10 + static_cast<int>(ch - L'0'); + } + + value = result; + return true; + } + + bool ValidateAndBuildSystemTime(int year, int month, int day, int hour, int minute, int second, int milliseconds, SYSTEMTIME& outTime) + { + if (year < 1601 || year > 9999 || + month < 1 || month > 12 || + day < 1 || day > 31 || + hour < 0 || hour > 23 || + minute < 0 || minute > 59 || + second < 0 || second > 59 || + milliseconds < 0 || milliseconds > 999) + { + return false; + } + + SYSTEMTIME candidate{}; + candidate.wYear = static_cast<WORD>(year); + candidate.wMonth = static_cast<WORD>(month); + candidate.wDay = static_cast<WORD>(day); + candidate.wHour = static_cast<WORD>(hour); + candidate.wMinute = static_cast<WORD>(minute); + candidate.wSecond = static_cast<WORD>(second); + candidate.wMilliseconds = static_cast<WORD>(milliseconds); + + FILETIME fileTime{}; + if (!SystemTimeToFileTime(&candidate, &fileTime)) + { + return false; + } + + outTime = candidate; + return true; + } + + std::optional<SYSTEMTIME> ParseExifDateTime(const std::wstring& date) + { + if (date.size() < 19) + { + return std::nullopt; + } + + if (date[4] != L':' || date[7] != L':' || + (date[10] != L' ' && date[10] != L'T') || + date[13] != L':' || date[16] != L':') + { + return std::nullopt; + } + + int year = 0; + int month = 0; + int day = 0; + int hour = 0; + int minute = 0; + int second = 0; + + if (!TryParseFixedWidthInt(date, 0, 4, year) || + !TryParseFixedWidthInt(date, 5, 2, month) || + !TryParseFixedWidthInt(date, 8, 2, day) || + !TryParseFixedWidthInt(date, 11, 2, hour) || + !TryParseFixedWidthInt(date, 14, 2, minute) || + !TryParseFixedWidthInt(date, 17, 2, second)) + { + return std::nullopt; + } + + int milliseconds = 0; + size_t pos = 19; + if (pos < date.size() && (date[pos] == L'.' || date[pos] == L',')) + { + ++pos; + int digits = 0; + while (pos < date.size() && std::iswdigit(date[pos]) && digits < 3) + { + milliseconds = milliseconds * 10 + static_cast<int>(date[pos] - L'0'); + ++pos; + ++digits; + } + + while (digits > 0 && digits < 3) + { + milliseconds *= 10; + ++digits; + } + } + + SYSTEMTIME result{}; + if (!ValidateAndBuildSystemTime(year, month, day, hour, minute, second, milliseconds, result)) + { + return std::nullopt; + } + + return result; + } + + std::optional<SYSTEMTIME> ParseIso8601DateTime(const std::wstring& date) + { + if (date.size() < 19) + { + return std::nullopt; + } + + size_t separator = date.find(L'T'); + if (separator == std::wstring::npos) + { + separator = date.find(L' '); + } + + if (separator == std::wstring::npos) + { + return std::nullopt; + } + + int year = 0; + int month = 0; + int day = 0; + if (!TryParseFixedWidthInt(date, 0, 4, year) || + date[4] != L'-' || + !TryParseFixedWidthInt(date, 5, 2, month) || + date[7] != L'-' || + !TryParseFixedWidthInt(date, 8, 2, day)) + { + return std::nullopt; + } + + size_t timePos = separator + 1; + if (timePos + 7 >= date.size()) + { + return std::nullopt; + } + + int hour = 0; + int minute = 0; + int second = 0; + if (!TryParseFixedWidthInt(date, timePos, 2, hour) || + date[timePos + 2] != L':' || + !TryParseFixedWidthInt(date, timePos + 3, 2, minute) || + date[timePos + 5] != L':' || + !TryParseFixedWidthInt(date, timePos + 6, 2, second)) + { + return std::nullopt; + } + + size_t pos = timePos + 8; + int milliseconds = 0; + if (pos < date.size() && (date[pos] == L'.' || date[pos] == L',')) + { + ++pos; + int digits = 0; + while (pos < date.size() && std::iswdigit(date[pos]) && digits < 3) + { + milliseconds = milliseconds * 10 + static_cast<int>(date[pos] - L'0'); + ++pos; + ++digits; + } + + while (pos < date.size() && std::iswdigit(date[pos])) + { + ++pos; + } + + while (digits > 0 && digits < 3) + { + milliseconds *= 10; + ++digits; + } + } + + bool hasOffset = false; + int offsetMinutes = 0; + if (pos < date.size()) + { + const wchar_t tzIndicator = date[pos]; + if (tzIndicator == L'Z' || tzIndicator == L'z') + { + hasOffset = true; + offsetMinutes = 0; + ++pos; + } + else if (tzIndicator == L'+' || tzIndicator == L'-') + { + hasOffset = true; + const int sign = (tzIndicator == L'-') ? -1 : 1; + ++pos; + + int offsetHours = 0; + int offsetMins = 0; + if (!TryParseFixedWidthInt(date, pos, 2, offsetHours)) + { + return std::nullopt; + } + pos += 2; + + if (pos < date.size() && date[pos] == L':') + { + ++pos; + } + + if (pos + 1 < date.size() && std::iswdigit(date[pos]) && std::iswdigit(date[pos + 1])) + { + if (!TryParseFixedWidthInt(date, pos, 2, offsetMins)) + { + return std::nullopt; + } + pos += 2; + } + + if (offsetHours < 0 || offsetHours > 23 || offsetMins < 0 || offsetMins > 59) + { + return std::nullopt; + } + + offsetMinutes = sign * (offsetHours * 60 + offsetMins); + } + + while (pos < date.size() && std::iswspace(date[pos])) + { + ++pos; + } + + if (pos != date.size()) + { + return std::nullopt; + } + } + + SYSTEMTIME baseTime{}; + if (!ValidateAndBuildSystemTime(year, month, day, hour, minute, second, milliseconds, baseTime)) + { + return std::nullopt; + } + + if (!hasOffset) + { + return baseTime; + } + + FILETIME utcFileTime{}; + if (!SystemTimeToFileTime(&baseTime, &utcFileTime)) + { + return std::nullopt; + } + + ULARGE_INTEGER timeValue{}; + timeValue.LowPart = utcFileTime.dwLowDateTime; + timeValue.HighPart = utcFileTime.dwHighDateTime; + + constexpr long long TicksPerMinute = 60LL * 10000000LL; + timeValue.QuadPart -= static_cast<long long>(offsetMinutes) * TicksPerMinute; + + FILETIME adjustedUtc{}; + adjustedUtc.dwLowDateTime = timeValue.LowPart; + adjustedUtc.dwHighDateTime = timeValue.HighPart; + + FILETIME localFileTime{}; + if (!FileTimeToLocalFileTime(&adjustedUtc, &localFileTime)) + { + return std::nullopt; + } + + SYSTEMTIME localTime{}; + if (!FileTimeToSystemTime(&localFileTime, &localTime)) + { + return std::nullopt; + } + + return localTime; + } +// Global WIC factory management with thread-safe access + CComPtr<IWICImagingFactory> g_wicFactory; + std::once_flag g_wicInitFlag; + std::mutex g_wicFactoryMutex; // Protect access to g_wicFactory +} + +WICMetadataExtractor::WICMetadataExtractor() +{ + InitializeWIC(); +} + +WICMetadataExtractor::~WICMetadataExtractor() +{ + // WIC cleanup handled statically +} + +void WICMetadataExtractor::InitializeWIC() +{ + std::call_once(g_wicInitFlag, []() { + // Don't initialize COM in library code - assume caller has done it + // Just create the WIC factory + HRESULT hr = CoCreateInstance( + CLSID_WICImagingFactory, + nullptr, + CLSCTX_INPROC_SERVER, + IID_IWICImagingFactory, + reinterpret_cast<LPVOID*>(&g_wicFactory) + ); + + if (FAILED(hr)) + { + g_wicFactory = nullptr; + } + }); +} + +CComPtr<IWICImagingFactory> WICMetadataExtractor::GetWICFactory() +{ + std::lock_guard<std::mutex> lock(g_wicFactoryMutex); + return g_wicFactory; +} + +bool WICMetadataExtractor::ExtractEXIFMetadata( + const std::wstring& filePath, + EXIFMetadata& outMetadata) +{ + return cache.GetOrLoadEXIF(filePath, outMetadata, [this, &filePath](EXIFMetadata& metadata) { + return LoadEXIFMetadata(filePath, metadata); + }); +} + +bool WICMetadataExtractor::LoadEXIFMetadata( + const std::wstring& filePath, + EXIFMetadata& outMetadata) +{ + CComPtr<IWICMetadataQueryReader> reader; + + if (!PathFileExistsW(filePath.c_str())) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] EXIF metadata extraction failed: File not found - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + auto decoder = CreateDecoder(filePath); + if (!decoder) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] EXIF metadata extraction: Unsupported format or unable to create decoder - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + CComPtr<IWICBitmapFrameDecode> frame; + if (FAILED(decoder->GetFrame(0, &frame))) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] EXIF metadata extraction failed: WIC decoder error - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + reader = GetMetadataReader(decoder); + if (!reader) + { + // No metadata is not necessarily an error - just means the file has no EXIF data + return false; + } + + // Detect container format to determine correct metadata paths + MetadataPathFormat pathFormat = GetMetadataPathFormatFromDecoder(decoder); + + ExtractAllEXIFFields(reader, outMetadata, pathFormat); + ExtractGPSData(reader, outMetadata, pathFormat); + + return true; +} + +void WICMetadataExtractor::ClearCache() +{ + cache.ClearAll(); +} + +CComPtr<IWICBitmapDecoder> WICMetadataExtractor::CreateDecoder(const std::wstring& filePath) +{ + auto factory = GetWICFactory(); + if (!factory) + { + return nullptr; + } + + CComPtr<IWICBitmapDecoder> decoder; + HRESULT hr = factory->CreateDecoderFromFilename( + filePath.c_str(), + nullptr, + GENERIC_READ, + WICDecodeMetadataCacheOnLoad, + &decoder + ); + + if (FAILED(hr)) + { + return nullptr; + } + + return decoder; +} + +CComPtr<IWICMetadataQueryReader> WICMetadataExtractor::GetMetadataReader(IWICBitmapDecoder* decoder) +{ + if (!decoder) + { + return nullptr; + } + + CComPtr<IWICBitmapFrameDecode> frame; + if (FAILED(decoder->GetFrame(0, &frame))) + { + return nullptr; + } + + CComPtr<IWICMetadataQueryReader> reader; + frame->GetMetadataQueryReader(&reader); + + return reader; +} + +MetadataPathFormat WICMetadataExtractor::GetMetadataPathFormatFromDecoder(IWICBitmapDecoder* decoder) +{ + if (!decoder) + { + return MetadataPathFormat::JPEG; + } + + GUID containerFormat; + if (SUCCEEDED(decoder->GetContainerFormat(&containerFormat))) + { + // HEIF and TIFF use /ifd/... paths directly + if (containerFormat == GUID_ContainerFormatHeif || + containerFormat == GUID_ContainerFormatTiff) + { + return MetadataPathFormat::IFD; + } + } + + // JPEG and other formats use /app1/ifd/... paths + return MetadataPathFormat::JPEG; +} + +void WICMetadataExtractor::ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat) +{ + if (!reader) + return; + + // Select the correct paths based on container format + const bool useIfdPaths = (pathFormat == MetadataPathFormat::IFD); + + // Date/time paths + const auto& dateTakenPath = useIfdPaths ? HEIF_DATE_TAKEN : EXIF_DATE_TAKEN; + const auto& dateDigitizedPath = useIfdPaths ? HEIF_DATE_DIGITIZED : EXIF_DATE_DIGITIZED; + const auto& dateModifiedPath = useIfdPaths ? HEIF_DATE_MODIFIED : EXIF_DATE_MODIFIED; + + // Camera info paths + const auto& cameraMakePath = useIfdPaths ? HEIF_CAMERA_MAKE : EXIF_CAMERA_MAKE; + const auto& cameraModelPath = useIfdPaths ? HEIF_CAMERA_MODEL : EXIF_CAMERA_MODEL; + const auto& lensModelPath = useIfdPaths ? HEIF_LENS_MODEL : EXIF_LENS_MODEL; + + // Shooting parameter paths + const auto& isoPath = useIfdPaths ? HEIF_ISO : EXIF_ISO; + const auto& aperturePath = useIfdPaths ? HEIF_APERTURE : EXIF_APERTURE; + const auto& shutterSpeedPath = useIfdPaths ? HEIF_SHUTTER_SPEED : EXIF_SHUTTER_SPEED; + const auto& focalLengthPath = useIfdPaths ? HEIF_FOCAL_LENGTH : EXIF_FOCAL_LENGTH; + const auto& exposureBiasPath = useIfdPaths ? HEIF_EXPOSURE_BIAS : EXIF_EXPOSURE_BIAS; + const auto& flashPath = useIfdPaths ? HEIF_FLASH : EXIF_FLASH; + + // Image property paths + const auto& widthPath = useIfdPaths ? HEIF_WIDTH : EXIF_WIDTH; + const auto& heightPath = useIfdPaths ? HEIF_HEIGHT : EXIF_HEIGHT; + const auto& orientationPath = useIfdPaths ? HEIF_ORIENTATION : EXIF_ORIENTATION; + const auto& colorSpacePath = useIfdPaths ? HEIF_COLOR_SPACE : EXIF_COLOR_SPACE; + + // Author info paths + const auto& artistPath = useIfdPaths ? HEIF_ARTIST : EXIF_ARTIST; + const auto& copyrightPath = useIfdPaths ? HEIF_COPYRIGHT : EXIF_COPYRIGHT; + + // Extract date/time fields + metadata.dateTaken = ReadDateTime(reader, dateTakenPath); + metadata.dateDigitized = ReadDateTime(reader, dateDigitizedPath); + metadata.dateModified = ReadDateTime(reader, dateModifiedPath); + + // Extract camera information + metadata.cameraMake = ReadString(reader, cameraMakePath); + metadata.cameraModel = ReadString(reader, cameraModelPath); + metadata.lensModel = ReadString(reader, lensModelPath); + + // Extract shooting parameters + metadata.iso = ReadInteger(reader, isoPath); + metadata.aperture = ReadDouble(reader, aperturePath); + metadata.shutterSpeed = ReadDouble(reader, shutterSpeedPath); + metadata.focalLength = ReadDouble(reader, focalLengthPath); + metadata.exposureBias = ReadDouble(reader, exposureBiasPath); + metadata.flash = ReadInteger(reader, flashPath); + + // Extract image properties + metadata.width = ReadInteger(reader, widthPath); + metadata.height = ReadInteger(reader, heightPath); + metadata.orientation = ReadInteger(reader, orientationPath); + metadata.colorSpace = ReadInteger(reader, colorSpacePath); + + // Extract author information + metadata.author = ReadString(reader, artistPath); + metadata.copyright = ReadString(reader, copyrightPath); +} + +void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat) +{ + if (!reader) + { + return; + } + + // Select the correct paths based on container format + const bool useIfdPaths = (pathFormat == MetadataPathFormat::IFD); + + const auto& latitudePath = useIfdPaths ? HEIF_GPS_LATITUDE : GPS_LATITUDE; + const auto& longitudePath = useIfdPaths ? HEIF_GPS_LONGITUDE : GPS_LONGITUDE; + const auto& latitudeRefPath = useIfdPaths ? HEIF_GPS_LATITUDE_REF : GPS_LATITUDE_REF; + const auto& longitudeRefPath = useIfdPaths ? HEIF_GPS_LONGITUDE_REF : GPS_LONGITUDE_REF; + const auto& altitudePath = useIfdPaths ? HEIF_GPS_ALTITUDE : GPS_ALTITUDE; + + auto lat = ReadMetadata(reader, latitudePath); + auto lon = ReadMetadata(reader, longitudePath); + auto latRef = ReadMetadata(reader, latitudeRefPath); + auto lonRef = ReadMetadata(reader, longitudeRefPath); + + if (lat && lon) + { + PropVariantValue emptyLatRef; + PropVariantValue emptyLonRef; + + const PROPVARIANT& latRefVar = latRef ? latRef->Get() : emptyLatRef.Get(); + const PROPVARIANT& lonRefVar = lonRef ? lonRef->Get() : emptyLonRef.Get(); + + auto coords = MetadataFormatHelper::ParseGPSCoordinates( + lat->Get(), + lon->Get(), + latRefVar, + lonRefVar); + + metadata.latitude = coords.first; + metadata.longitude = coords.second; + } + + auto alt = ReadMetadata(reader, altitudePath); + if (alt) + { + metadata.altitude = MetadataFormatHelper::ParseGPSRational(alt->Get()); + } +} + + +std::optional<SYSTEMTIME> WICMetadataExtractor::ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar) + { + return std::nullopt; + } + + std::wstring rawValue; + const PROPVARIANT& variant = propVar->Get(); + + switch (variant.vt) + { + case VT_LPWSTR: + if (variant.pwszVal) + { + rawValue = variant.pwszVal; + } + break; + case VT_BSTR: + if (variant.bstrVal) + { + rawValue = variant.bstrVal; + } + break; + case VT_LPSTR: + if (variant.pszVal) + { + const int size = MultiByteToWideChar(CP_UTF8, 0, variant.pszVal, -1, nullptr, 0); + if (size > 1) + { + rawValue.resize(static_cast<size_t>(size) - 1); + MultiByteToWideChar(CP_UTF8, 0, variant.pszVal, -1, &rawValue[0], size); + } + } + break; + default: + break; + } + + if (rawValue.empty()) + { + return std::nullopt; + } + + const std::wstring normalized = TrimWhitespace(rawValue); + if (normalized.empty()) + { + return std::nullopt; + } + + if (auto exifDate = ParseExifDateTime(normalized)) + { + return exifDate; + } + + if (auto isoDate = ParseIso8601DateTime(normalized)) + { + return isoDate; + } + + return std::nullopt; +} + +std::optional<std::wstring> WICMetadataExtractor::ReadString(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar.has_value()) + return std::nullopt; + + std::wstring result; + switch (propVar->Get().vt) + { + case VT_LPWSTR: + if (propVar->Get().pwszVal) + result = propVar->Get().pwszVal; + break; + case VT_BSTR: + if (propVar->Get().bstrVal) + result = propVar->Get().bstrVal; + break; + case VT_LPSTR: + if (propVar->Get().pszVal) + { + int size = MultiByteToWideChar(CP_UTF8, 0, propVar->Get().pszVal, -1, nullptr, 0); + if (size > 1) + { + result.resize(static_cast<size_t>(size) - 1); + MultiByteToWideChar(CP_UTF8, 0, propVar->Get().pszVal, -1, &result[0], size); + } + } + break; + } + + + // Trim whitespace from both ends + if (!result.empty()) + { + size_t start = result.find_first_not_of(L" \t\r\n"); + size_t end = result.find_last_not_of(L" \t\r\n"); + if (start != std::wstring::npos && end != std::wstring::npos) + { + result = result.substr(start, end - start + 1); + } + else if (start == std::wstring::npos) + { + result.clear(); + } + } + + return result.empty() ? std::nullopt : std::make_optional(result); +} + +std::optional<int64_t> WICMetadataExtractor::ReadInteger(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar.has_value()) + return std::nullopt; + + int64_t result = 0; + switch (propVar->Get().vt) + { + case VT_I1: result = propVar->Get().cVal; break; + case VT_I2: result = propVar->Get().iVal; break; + case VT_I4: result = propVar->Get().lVal; break; + case VT_I8: result = propVar->Get().hVal.QuadPart; break; + case VT_UI1: result = propVar->Get().bVal; break; + case VT_UI2: result = propVar->Get().uiVal; break; + case VT_UI4: result = propVar->Get().ulVal; break; + case VT_UI8: result = static_cast<int64_t>(propVar->Get().uhVal.QuadPart); break; + default: + return std::nullopt; + } + + return result; +} + +std::optional<double> WICMetadataExtractor::ReadDouble(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + auto propVar = ReadMetadata(reader, path); + if (!propVar.has_value()) + return std::nullopt; + + double result = 0.0; + switch (propVar->Get().vt) + { + case VT_R4: + result = static_cast<double>(propVar->Get().fltVal); + break; + case VT_R8: + result = propVar->Get().dblVal; + break; + case VT_UI1 | VT_VECTOR: + case VT_UI4 | VT_VECTOR: + // Handle rational number (common for EXIF values) + // Rational data is stored as 8 bytes: 4-byte numerator + 4-byte denominator + if (propVar->Get().caub.cElems >= 8) + { + // ExposureBias (EXIF tag 37380) uses SRATIONAL type (signed rational) + // which can represent negative values like -0.33 EV for exposure compensation. + // Most other EXIF fields use RATIONAL type (unsigned) for values like aperture, shutter speed. + if (path == EXIF_EXPOSURE_BIAS) + { + // Parse as signed rational: int32_t / int32_t + result = MetadataFormatHelper::ParseSingleSRational(propVar->Get().caub.pElems, 0); + break; + } + else + { + // Parse as unsigned rational: uint32_t / uint32_t + // First check if denominator is valid (non-zero) to avoid division by zero + const uint8_t* bytes = propVar->Get().caub.pElems; + uint32_t denominator = static_cast<uint32_t>(bytes[4]) | + (static_cast<uint32_t>(bytes[5]) << 8) | + (static_cast<uint32_t>(bytes[6]) << 16) | + (static_cast<uint32_t>(bytes[7]) << 24); + + if (denominator != 0) + { + result = MetadataFormatHelper::ParseSingleRational(propVar->Get().caub.pElems, 0); + break; + } + } + } + return std::nullopt; + default: + // Try integer conversion + switch (propVar->Get().vt) + { + case VT_I1: result = static_cast<double>(propVar->Get().cVal); break; + case VT_I2: result = static_cast<double>(propVar->Get().iVal); break; + case VT_I4: result = static_cast<double>(propVar->Get().lVal); break; + case VT_I8: + { + // ExposureBias (EXIF tag 37380) may be stored as VT_I8 in some WIC implementations + // It represents a signed rational (SRATIONAL) packed into a 64-bit integer + if (path == EXIF_EXPOSURE_BIAS) + { + // Parse signed rational from int64: low 32 bits = numerator, high 32 bits = denominator + // Some implementations may reverse the order, so we try both + int32_t numerator = static_cast<int32_t>(propVar->Get().hVal.QuadPart & 0xFFFFFFFF); + int32_t denominator = static_cast<int32_t>(propVar->Get().hVal.QuadPart >> 32); + if (denominator != 0) + { + result = static_cast<double>(numerator) / static_cast<double>(denominator); + } + else + { + // Try reversed order: high 32 bits = numerator, low 32 bits = denominator + numerator = static_cast<int32_t>(propVar->Get().hVal.QuadPart >> 32); + denominator = static_cast<int32_t>(propVar->Get().hVal.QuadPart & 0xFFFFFFFF); + if (denominator != 0) + { + result = static_cast<double>(numerator) / static_cast<double>(denominator); + } + else + { + result = 0.0; // Default to 0 if both attempts fail + } + } + } + else + { + // For other fields, treat VT_I8 as a simple 64-bit integer + result = static_cast<double>(propVar->Get().hVal.QuadPart); + } + } + break; + case VT_UI1: result = static_cast<double>(propVar->Get().bVal); break; + case VT_UI2: result = static_cast<double>(propVar->Get().uiVal); break; + case VT_UI4: result = static_cast<double>(propVar->Get().ulVal); break; + case VT_UI8: + { + // ExposureBias (EXIF tag 37380) may be stored as VT_UI8 in some WIC implementations + // Even though it's unsigned, we need to reinterpret it as signed for SRATIONAL + if (path == EXIF_EXPOSURE_BIAS) + { + // Parse signed rational from uint64 (reinterpret as signed) + // Low 32 bits = numerator, high 32 bits = denominator + int32_t numerator = static_cast<int32_t>(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + int32_t denominator = static_cast<int32_t>(propVar->Get().uhVal.QuadPart >> 32); + if (denominator != 0) + { + result = static_cast<double>(numerator) / static_cast<double>(denominator); + } + else + { + // Try reversed order: high 32 bits = numerator, low 32 bits = denominator + numerator = static_cast<int32_t>(propVar->Get().uhVal.QuadPart >> 32); + denominator = static_cast<int32_t>(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + if (denominator != 0) + { + result = static_cast<double>(numerator) / static_cast<double>(denominator); + } + else + { + result = 0.0; // Default to 0 if both attempts fail + } + } + } + else + { + // For other EXIF rational fields (unsigned), try both byte orders to handle different encodings + // First try: low 32 bits = numerator, high 32 bits = denominator + uint32_t numerator = static_cast<uint32_t>(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + uint32_t denominator = static_cast<uint32_t>(propVar->Get().uhVal.QuadPart >> 32); + + if (denominator != 0) + { + result = static_cast<double>(numerator) / static_cast<double>(denominator); + } + else + { + // Second try: high 32 bits = numerator, low 32 bits = denominator + numerator = static_cast<uint32_t>(propVar->Get().uhVal.QuadPart >> 32); + denominator = static_cast<uint32_t>(propVar->Get().uhVal.QuadPart & 0xFFFFFFFF); + if (denominator != 0) + { + result = static_cast<double>(numerator) / static_cast<double>(denominator); + } + else + { + // Fall back to treating as regular integer if denominator is 0 + result = static_cast<double>(propVar->Get().uhVal.QuadPart); + } + } + } + } + break; + default: + return std::nullopt; + } + } + + return result; +} + +std::optional<PropVariantValue> WICMetadataExtractor::ReadMetadata(IWICMetadataQueryReader* reader, const std::wstring& path) +{ + if (!reader) + return std::nullopt; + + PropVariantValue value; + + HRESULT hr = reader->GetMetadataByName(path.c_str(), value.GetAddressOf()); + if (SUCCEEDED(hr)) + { + return std::optional<PropVariantValue>(std::move(value)); + } + + return std::nullopt; +} + +// GPS parsing functions have been moved to MetadataFormatHelper for better testability + +bool WICMetadataExtractor::ExtractXMPMetadata( + const std::wstring& filePath, + XMPMetadata& outMetadata) +{ + return cache.GetOrLoadXMP(filePath, outMetadata, [this, &filePath](XMPMetadata& metadata) { + return LoadXMPMetadata(filePath, metadata); + }); +} + +bool WICMetadataExtractor::LoadXMPMetadata( + const std::wstring& filePath, + XMPMetadata& outMetadata) +{ + if (!PathFileExistsW(filePath.c_str())) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] XMP metadata extraction failed: File not found - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + auto decoder = CreateDecoder(filePath); + if (!decoder) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] XMP metadata extraction: Unsupported format or unable to create decoder - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + CComPtr<IWICBitmapFrameDecode> frame; + if (FAILED(decoder->GetFrame(0, &frame))) + { +#ifdef _DEBUG + std::wstring msg = L"[PowerRename] XMP metadata extraction failed: WIC decoder error - " + filePath + L"\n"; + OutputDebugStringW(msg.c_str()); +#endif + return false; + } + + CComPtr<IWICMetadataQueryReader> rootReader; + if (FAILED(frame->GetMetadataQueryReader(&rootReader))) + { + // No metadata is not necessarily an error - just means the file has no XMP data + return false; + } + + ExtractAllXMPFields(rootReader, outMetadata); + + return true; +} + +// Batch extraction method implementations +void WICMetadataExtractor::ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata) +{ + if (!reader) + return; + + // XMP Basic schema - xmp: namespace + metadata.creatorTool = ReadString(reader, XMP_CREATOR_TOOL); + metadata.createDate = ReadDateTime(reader, XMP_CREATE_DATE); + metadata.modifyDate = ReadDateTime(reader, XMP_MODIFY_DATE); + metadata.metadataDate = ReadDateTime(reader, XMP_METADATA_DATE); + + // Dublin Core schema - dc: namespace + metadata.title = ReadString(reader, XMP_DC_TITLE); + metadata.description = ReadString(reader, XMP_DC_DESCRIPTION); + metadata.creator = ReadString(reader, XMP_DC_CREATOR); + + // For dc:subject, we need to handle the array structure + // Try to read individual elements + // XMP allows for large arrays, but we limit to a reasonable number to avoid performance issues + constexpr int MAX_XMP_SUBJECTS = 50; + std::vector<std::wstring> subjects; + for (int i = 0; i < MAX_XMP_SUBJECTS; ++i) + { + std::wstring subjectPath = L"/xmp/dc:subject/{ulong=" + std::to_wstring(i) + L"}"; + auto subject = ReadString(reader, subjectPath); + if (subject.has_value()) + { + subjects.push_back(subject.value()); + } + else + { + break; // No more subjects + } + } + if (!subjects.empty()) + { + metadata.subject = subjects; + } + + // XMP Rights Management schema + metadata.rights = ReadString(reader, XMP_RIGHTS); + + // XMP Media Management schema - xmpMM: namespace + metadata.documentID = ReadString(reader, XMP_MM_DOCUMENT_ID); + metadata.instanceID = ReadString(reader, XMP_MM_INSTANCE_ID); + metadata.originalDocumentID = ReadString(reader, XMP_MM_ORIGINAL_DOCUMENT_ID); + metadata.versionID = ReadString(reader, XMP_MM_VERSION_ID); +} + + + + + + + + + diff --git a/src/modules/powerrename/lib/WICMetadataExtractor.h b/src/modules/powerrename/lib/WICMetadataExtractor.h new file mode 100644 index 0000000000..f2149a40f1 --- /dev/null +++ b/src/modules/powerrename/lib/WICMetadataExtractor.h @@ -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. + +#pragma once +#include "MetadataTypes.h" +#include "MetadataResultCache.h" +#include "PropVariantValue.h" +#include <wincodec.h> +#include <atlbase.h> + +// Forward declarations for unit test friend classes +namespace WICMetadataExtractorTests +{ + class ExtractAVIFMetadataTests; +} + +namespace PowerRenameLib +{ + /// <summary> + /// Metadata path format based on container type + /// </summary> + enum class MetadataPathFormat + { + JPEG, // Uses /app1/ifd/... paths (JPEG) + IFD // Uses /ifd/... paths (HEIF, TIFF, etc.) + }; + + /// <summary> + /// Windows Imaging Component (WIC) implementation for metadata extraction + /// Provides efficient batch extraction of all metadata types with built-in caching + /// </summary> + class WICMetadataExtractor + { + // Friend declarations for unit testing + friend class WICMetadataExtractorTests::ExtractAVIFMetadataTests; + + public: + WICMetadataExtractor(); + ~WICMetadataExtractor(); + + // Public metadata extraction methods + bool ExtractEXIFMetadata( + const std::wstring& filePath, + EXIFMetadata& outMetadata); + + bool ExtractXMPMetadata( + const std::wstring& filePath, + XMPMetadata& outMetadata); + + void ClearCache(); + + private: + // WIC factory management + static CComPtr<IWICImagingFactory> GetWICFactory(); + static void InitializeWIC(); + + // WIC operations + CComPtr<IWICBitmapDecoder> CreateDecoder(const std::wstring& filePath); + CComPtr<IWICMetadataQueryReader> GetMetadataReader(IWICBitmapDecoder* decoder); + + bool LoadEXIFMetadata(const std::wstring& filePath, EXIFMetadata& outMetadata); + bool LoadXMPMetadata(const std::wstring& filePath, XMPMetadata& outMetadata); + + // Batch extraction methods + void ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat); + void ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat); + void ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata); + + // Internal container format detection + MetadataPathFormat GetMetadataPathFormatFromDecoder(IWICBitmapDecoder* decoder); + + // Field reading helpers + std::optional<SYSTEMTIME> ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path); + std::optional<std::wstring> ReadString(IWICMetadataQueryReader* reader, const std::wstring& path); + std::optional<int64_t> ReadInteger(IWICMetadataQueryReader* reader, const std::wstring& path); + std::optional<double> ReadDouble(IWICMetadataQueryReader* reader, const std::wstring& path); + + // Helper methods + std::optional<PropVariantValue> ReadMetadata(IWICMetadataQueryReader* reader, const std::wstring& path); + + private: + MetadataResultCache cache; + }; +} diff --git a/src/modules/powerrename/lib/packages.config b/src/modules/powerrename/lib/packages.config index ecc3202cd3..d33ff8fb41 100644 --- a/src/modules/powerrename/lib/packages.config +++ b/src/modules/powerrename/lib/packages.config @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="boost" version="1.84.0" targetFramework="native" /> - <package id="boost_regex-vc143" version="1.84.0" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="boost" version="1.87.0" targetFramework="native" /> + <package id="boost_regex-vc143" version="1.87.0" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/powerrename/lib/pch.h b/src/modules/powerrename/lib/pch.h index c5a4711a03..2ee372ae61 100644 --- a/src/modules/powerrename/lib/pch.h +++ b/src/modules/powerrename/lib/pch.h @@ -28,5 +28,17 @@ #include <charconv> #include <string> #include <random> +#include <map> +#include <memory> +#include <fstream> +#include <chrono> +#include <mutex> +#include <unordered_map> #include <winrt/base.h> + +// Windows Imaging Component (WIC) headers +#include <wincodec.h> +#include <wincodecsdk.h> +#include <propkey.h> +#include <propvarutil.h> diff --git a/src/modules/powerrename/testapp/PowerRenameTest.vcxproj b/src/modules/powerrename/testapp/PowerRenameTest.vcxproj index 616f8e70a4..4c004c59a9 100644 --- a/src/modules/powerrename/testapp/PowerRenameTest.vcxproj +++ b/src/modules/powerrename/testapp/PowerRenameTest.vcxproj @@ -1,18 +1,18 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{A3935CF4-46C5-4A88-84D3-6B12E16E6BA2}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>PowerRenameTest</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -24,15 +24,15 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\tests\PowerRename\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\tests\PowerRename\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(ProjectDir)..\;$(ProjectDir)..\ui;$(ProjectDir)..\dll;$(ProjectDir)..\lib;$(ProjectDir)..\..\..\;$(ProjectDir)..\..\..\common\Telemetry;%(AdditionalIncludeDirectories);$(GeneratedFilesDir)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(ProjectDir)..\;$(ProjectDir)..\ui;$(ProjectDir)..\dll;$(ProjectDir)..\lib;$(RepoRoot)src\;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories);$(GeneratedFilesDir)</AdditionalIncludeDirectories> <PreprocessorDefinitions>WIN32;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions> </ClCompile> <Link> - <AdditionalDependencies>$(OutDir)\..\..\WinUI3Apps\PowerRenameLib.lib;Pathcch.lib;comctl32.lib;shlwapi.lib;shcore.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies>Pathcch.lib;comctl32.lib;shlwapi.lib;shcore.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> </ItemDefinitionGroup> <ItemGroup> @@ -51,32 +51,35 @@ <ResourceCompile Include="PowerRenameTest.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> + <ProjectReference Include="..\lib\PowerRenameLib.vcxproj"> + <Project>{51920f1f-c28c-4adf-8660-4238766796c2}</Project> + </ProjectReference> </ItemGroup> <ItemGroup> <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\boost.1.84.0\build\boost.targets" Condition="Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" /> - <Import Project="..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets" Condition="Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\boost.1.87.0\build\boost.targets" Condition="Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" /> + <Import Project="$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets" Condition="Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost.1.84.0\build\boost.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost.1.87.0\build\boost.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/powerrename/testapp/packages.config b/src/modules/powerrename/testapp/packages.config index ecc3202cd3..d33ff8fb41 100644 --- a/src/modules/powerrename/testapp/packages.config +++ b/src/modules/powerrename/testapp/packages.config @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="boost" version="1.84.0" targetFramework="native" /> - <package id="boost_regex-vc143" version="1.84.0" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="boost" version="1.87.0" targetFramework="native" /> + <package id="boost_regex-vc143" version="1.87.0" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/powerrename/unittests/CommonRegExTests.h b/src/modules/powerrename/unittests/CommonRegExTests.h index b1b2b8d731..392252655d 100644 --- a/src/modules/powerrename/unittests/CommonRegExTests.h +++ b/src/modules/powerrename/unittests/CommonRegExTests.h @@ -611,6 +611,122 @@ TEST_METHOD (VerifyRandomizerRegExAllBackToBack) CoTaskMemFree(result); } +TEST_METHOD(VerifyCounterIncrementsWhenResultIsUnchanged) +{ + CComPtr<IPowerRenameRegEx> renameRegEx; + Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK); + DWORD flags = EnumerateItems | UseRegularExpressions; + Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK); + + renameRegEx->PutSearchTerm(L"(.*)"); + renameRegEx->PutReplaceTerm(L"NewFile-${start=1}"); + + PWSTR result = nullptr; + unsigned long index = 0; + + renameRegEx->Replace(L"DocA", &result, index); + Assert::AreEqual(1ul, index, L"Counter should advance to 1 on first match."); + Assert::AreEqual(L"NewFile-1", result, L"First file should be renamed correctly."); + CoTaskMemFree(result); + + renameRegEx->Replace(L"DocB", &result, index); + Assert::AreEqual(2ul, index, L"Counter should advance to 2 on second match."); + Assert::AreEqual(L"NewFile-2", result, L"Second file should be renamed correctly."); + CoTaskMemFree(result); + + // The original term and the replacement are identical. + renameRegEx->Replace(L"NewFile-3", &result, index); + Assert::AreEqual(3ul, index, L"Counter must advance on a match, even if the new name is identical to the old one."); + Assert::AreEqual(L"NewFile-3", result, L"Filename should be unchanged on a coincidental match."); + CoTaskMemFree(result); + + // Test that there wasn't a "stall" in the numbering. + renameRegEx->Replace(L"DocC", &result, index); + Assert::AreEqual(4ul, index, L"Counter should continue sequentially after the coincidental match."); + Assert::AreEqual(L"NewFile-4", result, L"The subsequent file should receive the correct next number."); + CoTaskMemFree(result); +} + +// Helper function to verify normalization behavior. +void VerifyNormalizationHelper(DWORD flags) +{ + CComPtr<IPowerRenameRegEx> renameRegEx; + Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK); + Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK); + + // 1. Unicode Normalization: NFD source with NFC search term. + PWSTR result = nullptr; + unsigned long index = 0; + + // Source: "Test" + U+0438 (Cyrillic small letter i) + U+0306 (combining breve). + std::wstring sourceNFD = L"Test\u0438\u0306"; + // Search: "Test" + U+0438 (Cyrillic small letter i with breve). + std::wstring searchNFC = L"Test\u0439"; + + // A match should occur despite different normalization forms. + Assert::IsTrue(renameRegEx->PutSearchTerm(searchNFC.c_str()) == S_OK); + Assert::IsTrue(renameRegEx->PutReplaceTerm(L"Match") == S_OK); + Assert::IsTrue(renameRegEx->Replace(sourceNFD.c_str(), &result, index) == S_OK); + Assert::AreEqual(L"Match", result, L"Failed to match NFD source with NFC search term."); + CoTaskMemFree(result); + + // 2. Whitespace Normalization: test non-breaking space versus regular space. + result = nullptr; + index = 0; + + // Source: "Hello" + non-breaking space + "World". + std::wstring sourceNBSP = L"Hello\u00A0World"; + // Search: "Hello" + regular space + "World". + std::wstring searchSpace = L"Hello World"; + + Assert::IsTrue(renameRegEx->PutSearchTerm(searchSpace.c_str()) == S_OK); + Assert::IsTrue(renameRegEx->Replace(sourceNBSP.c_str(), &result, index) == S_OK); + Assert::AreEqual(L"Match", result, L"Failed to match non-breaking space source with regular space search term."); + CoTaskMemFree(result); +} + +TEST_METHOD(VerifyUnicodeAndWhitespaceNormalizationSimpleSearch) +{ + VerifyNormalizationHelper(0); +} + +TEST_METHOD(VerifyUnicodeAndWhitespaceNormalizationRegex) +{ + VerifyNormalizationHelper(UseRegularExpressions); +} + +TEST_METHOD(VerifyRegexMetacharacterDollarSign) +{ + CComPtr<IPowerRenameRegEx> 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<IPowerRenameRegEx> 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/HelpersTests.cpp b/src/modules/powerrename/unittests/HelpersTests.cpp new file mode 100644 index 0000000000..426bebab02 --- /dev/null +++ b/src/modules/powerrename/unittests/HelpersTests.cpp @@ -0,0 +1,833 @@ +#include "pch.h" +#include "Helpers.h" +#include "MetadataPatternExtractor.h" +#include "MetadataTypes.h" + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; + +namespace HelpersTests +{ + TEST_CLASS(GetMetadataFileNameTests) + { + public: + TEST_METHOD(BasicPatternReplacement) + { + // Test basic pattern replacement with available metadata + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"ISO"] = L"ISO 400"; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_ISO 400", result); + } + + TEST_METHOD(PatternWithoutValueShowsPatternName) + { + // Test that patterns without values show the pattern name with $ prefix + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + // ISO is not in the map + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_$ISO", result); + } + + TEST_METHOD(EmptyPatternShowsPatternName) + { + // Test that patterns with empty value show the pattern name with $ prefix + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"ISO"] = L""; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_$ISO", result); + } + + TEST_METHOD(EscapedDollarSigns) + { + // Test that $$ is converted to single $ + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$_$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_$_ISO 400", result); + } + + TEST_METHOD(MultipleEscapedDollarSigns) + { + // Test that $$$$ is converted to $$ + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$$$price", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_$$price", result); + } + + TEST_METHOD(OddDollarSignsWithPattern) + { + // Test that $$$ becomes $ followed by pattern + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$$$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_$ISO 400", result); + } + + TEST_METHOD(LongestPatternMatchPriority) + { + // Test that longer patterns are matched first (DATE_TAKEN_YYYY vs DATE_TAKEN_YY) + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + patterns[L"DATE_TAKEN_YY"] = L"24"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$DATE_TAKEN_YYYY", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_2024", result); + } + + TEST_METHOD(MultiplePatterns) + { + // Test multiple patterns in one string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"CAMERA_MODEL"] = L"EOS R5"; + patterns[L"ISO"] = L"ISO 800"; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$DATE_TAKEN_YYYY-$CAMERA_MAKE-$CAMERA_MODEL-$ISO", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024-Canon-EOS R5-ISO 800", result); + } + + TEST_METHOD(UnrecognizedPatternIgnored) + { + // Test that unrecognized patterns are not replaced + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE_$INVALID_PATTERN", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon_$INVALID_PATTERN", result); + } + + TEST_METHOD(NoPatterns) + { + // Test string with no patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_name_without_patterns", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_name_without_patterns", result); + } + + TEST_METHOD(EmptyInput) + { + // Test with empty input string + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"", patterns); + + Assert::IsTrue(FAILED(hr)); + } + + TEST_METHOD(NullInput) + { + // Test with null input + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, nullptr, patterns); + + Assert::IsTrue(FAILED(hr)); + } + + TEST_METHOD(DollarAtEnd) + { + // Test dollar sign at the end of string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"ISO"] = L"ISO 400"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$ISO$", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_ISO 400$", result); + } + + TEST_METHOD(ThreeDollarsAtEnd) + { + // Test three dollar signs at the end + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo$$$", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo$$$", result); + } + + TEST_METHOD(ComplexMixedScenario) + { + // Test complex scenario with mixed patterns, escapes, and regular text + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"ISO"] = L"ISO 400"; + patterns[L"APERTURE"] = L"f/2.8"; + patterns[L"LENS"] = L""; // Empty value + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$$price_$CAMERA_MAKE_$$$ISO_$APERTURE_$LENS_$$end", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"$price_Canon_$ISO 400_f/2.8_$LENS_$end", result); + } + + TEST_METHOD(AllEXIFPatterns) + { + // Test with various EXIF patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"WIDTH"] = L"4000"; + patterns[L"HEIGHT"] = L"3000"; + patterns[L"FOCAL"] = L"50mm"; + patterns[L"SHUTTER"] = L"1/100s"; + patterns[L"FLASH"] = L"Flash Off"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"photo_$WIDTH x $HEIGHT_$FOCAL_$SHUTTER_$FLASH", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_4000 x 3000_50mm_1/100s_Flash Off", result); + } + + TEST_METHOD(AllXMPPatterns) + { + // Test with various XMP patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"TITLE"] = L"Sunset"; + patterns[L"CREATOR"] = L"John Doe"; + patterns[L"DESCRIPTION"] = L"Beautiful sunset"; + patterns[L"CREATE_DATE_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$CREATE_DATE_YYYY-$TITLE-by-$CREATOR", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024-Sunset-by-John Doe", result); + } + + TEST_METHOD(DateComponentPatterns) + { + // Test date component patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + patterns[L"DATE_TAKEN_MM"] = L"03"; + patterns[L"DATE_TAKEN_DD"] = L"15"; + patterns[L"DATE_TAKEN_HH"] = L"14"; + patterns[L"DATE_TAKEN_mm"] = L"30"; + patterns[L"DATE_TAKEN_SS"] = L"45"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"photo_$DATE_TAKEN_YYYY-$DATE_TAKEN_MM-$DATE_TAKEN_DD_$DATE_TAKEN_HH-$DATE_TAKEN_mm-$DATE_TAKEN_SS", + patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_2024-03-15_14-30-45", result); + } + + TEST_METHOD(SpecialCharactersInValues) + { + // Test that special characters in metadata values are preserved + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"TITLE"] = L"Photo (with) [brackets] & symbols!"; + patterns[L"DESCRIPTION"] = L"Test: value; with, punctuation."; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, + L"$TITLE - $DESCRIPTION", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Photo (with) [brackets] & symbols! - Test: value; with, punctuation.", result); + } + + TEST_METHOD(ConsecutivePatternsWithoutSeparator) + { + // Test consecutive patterns without separator + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + patterns[L"CAMERA_MODEL"] = L"R5"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE$CAMERA_MODEL", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"CanonR5", result); + } + + TEST_METHOD(PatternAtStart) + { + // Test pattern at the beginning of string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE_photo", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Canon_photo", result); + } + + TEST_METHOD(PatternAtEnd) + { + // Test pattern at the end of string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$CAMERA_MAKE", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo_Canon", result); + } + + TEST_METHOD(OnlyPattern) + { + // Test string with only a pattern + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MAKE", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Canon", result); + } + }; + + TEST_CLASS(PatternMatchingTests) + { + public: + TEST_METHOD(VerifyLongestPatternMatching) + { + // This test verifies the greedy matching behavior + // When we have overlapping pattern names, the longest should be matched first + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"DATE_TAKEN_Y"] = L"4"; + patterns[L"DATE_TAKEN_YY"] = L"24"; + patterns[L"DATE_TAKEN_YYYY"] = L"2024"; + + wchar_t result[MAX_PATH] = { 0 }; + + // Should match YYYY (longest) + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$DATE_TAKEN_YYYY", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024", result); + + // Should match YY (available pattern) + hr = GetMetadataFileName(result, MAX_PATH, L"$DATE_TAKEN_YY", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"24", result); + } + + TEST_METHOD(PartialPatternNames) + { + // Test that partial pattern names don't match longer patterns + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MODEL"] = L"EOS R5"; + + wchar_t result[MAX_PATH] = { 0 }; + // CAMERA is not a valid pattern, should not match + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$CAMERA_MODEL", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"EOS R5", result); + } + + TEST_METHOD(CaseSensitivePatterns) + { + // Test that pattern names are case-sensitive + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + wchar_t result[MAX_PATH] = { 0 }; + // lowercase should not match + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$camera_make", patterns); + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"$camera_make", result); // Not replaced + } + + TEST_METHOD(EmptyPatternMap) + { + // Test with empty pattern map + PowerRenameLib::MetadataPatternMap patterns; // Empty + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo_$ISO_$CAMERA_MAKE", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + // Patterns should show with $ prefix since they're valid but have no values + Assert::AreEqual(L"photo_$ISO_$CAMERA_MAKE", result); + } + }; + + TEST_CLASS(EdgeCaseTests) + { + public: + TEST_METHOD(VeryLongString) + { + // Test with a very long input string + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"CAMERA_MAKE"] = L"Canon"; + + std::wstring longInput = L"prefix_"; + for (int i = 0; i < 100; i++) + { + longInput += L"$CAMERA_MAKE_"; + } + + wchar_t result[4096] = { 0 }; + HRESULT hr = GetMetadataFileName(result, 4096, longInput.c_str(), patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + // Verify it starts correctly + Assert::IsTrue(wcsstr(result, L"prefix_Canon_") == result); + } + + TEST_METHOD(ManyConsecutiveDollars) + { + // Test with many consecutive dollar signs + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + // 8 dollars should become 4 dollars + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"photo$$$$$$$$name", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"photo$$$$name", result); + } + + TEST_METHOD(OnlyDollars) + { + // Test string with only dollar signs + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$$$$", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"$$", result); + } + + TEST_METHOD(UnicodeCharacters) + { + // Test with unicode characters in pattern values + PowerRenameLib::MetadataPatternMap patterns; + patterns[L"TITLE"] = L"照片_фото_φωτογραφία"; + patterns[L"CREATOR"] = L"张三_Иван_Γιάννης"; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"$TITLE-$CREATOR", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"照片_фото_φωτογραφία-张三_Иван_Γιάννης", result); + } + + TEST_METHOD(SingleDollar) + { + // Test with single dollar not followed by pattern + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"price$100", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"price$100", result); + } + + TEST_METHOD(DollarFollowedByNumber) + { + // Test dollar followed by numbers (not a pattern) + PowerRenameLib::MetadataPatternMap patterns; + + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetMetadataFileName(result, MAX_PATH, L"cost_$123.45", patterns); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"cost_$123.45", result); + } + }; + + TEST_CLASS(GetDatedFileNameTests) + { + public: + // Helper to get a fixed test time for consistent testing + SYSTEMTIME GetTestTime() + { + SYSTEMTIME testTime = { 0 }; + testTime.wYear = 2024; + testTime.wMonth = 3; // March + testTime.wDay = 15; // 15th + testTime.wHour = 14; // 2 PM (24-hour format) + testTime.wMinute = 30; + testTime.wSecond = 45; + testTime.wMilliseconds = 123; + testTime.wDayOfWeek = 5; // Friday (0=Sunday, 5=Friday) + return testTime; + } + + // Category 1: Tests for patterns with extra characters. Verifies negative + // lookahead doesn't cause issues with partially matched patterns and the + // ordering of pattern matches is correct, i.e. longer patterns are matched + // first. + + TEST_METHOD(ValidPattern_YYY_PartiallyMatched) + { + // Test $YYY (3 Y's) is recognized as a valid pattern $YY plus a verbatim 'Y' + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYY", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_24Y", result); + } + + TEST_METHOD(ValidPattern_DDD_Matched) + { + // Test that $DDD (short weekday) is not confused with $DD (2-digit day) + // Verifies that the matching of $DDD before $DD works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Fri", result); // Should be "Fri", not "15D" + } + + TEST_METHOD(ValidPattern_MMM_Matched) + { + // Test that $MMM (short month name) is not confused with $MM (2-digit month) + // Verifies that the matching of $MMM before $MM works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Mar", result); // Should be "Mar", not "03M" + } + + TEST_METHOD(ValidPattern_HHH_PartiallyMatched) + { + // Test $HHH (3 H's) should match $HH and leave extra H unchanged + // Also confirms that $HH is matched before $H + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$HHH", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_02H", result); + } + + TEST_METHOD(SeparatedPatterns_SingleY) + { + // Test multiple $Y with separators works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$Y-$Y-$Y", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_4-4-4", result); // Each $Y outputs "4" (from 2024) + } + + TEST_METHOD(SeparatedPatterns_SingleD) + { + // Test multiple $D with separators works correctly + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$D.$D.$D", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_15.15.15", result); // Each $D outputs "15" + } + + // Category 2: Tests for mixed length patterns (verify longer patterns don't get matched incorrectly) + + TEST_METHOD(MixedLengthYear_QuadFollowedBySingle) + { + // Test $YYYY$Y - should be 2024 + 4 + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYYY$Y", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_20244", result); + } + + TEST_METHOD(MixedLengthDay_TripleFollowedBySingle) + { + // Test $DDD$D - should be "Fri" + "15" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDD$D", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Fri15", result); + } + + TEST_METHOD(MixedLengthDay_QuadFollowedByDouble) + { + // Test $DDDD$DD - should be "Friday" + "15" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDDD$DD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Friday15", result); + } + + TEST_METHOD(MixedLengthMonth_TripleFollowedBySingle) + { + // Test $MMM$M - should be "Mar" + "3" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM$M", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Mar3", result); + } + + // Category 3: Tests for boundary conditions (patterns at start, end, with special chars) + + TEST_METHOD(PatternAtStart) + { + // Test pattern at the very start of filename + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$YYYY$M$D", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024315", result); + } + + TEST_METHOD(PatternAtEnd) + { + // Test pattern at the very end of filename + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$Y", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_4", result); + } + + TEST_METHOD(PatternWithSpecialChars) + { + // Test patterns surrounded by special characters + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file-$Y.$Y-$M", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file-4.4-3", result); + } + + TEST_METHOD(EmptyFileName) + { + // Test with empty input string - should return E_INVALIDARG + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"", testTime); + + Assert::IsTrue(FAILED(hr)); // Empty string should fail + Assert::AreEqual(E_INVALIDARG, hr); + } + + // Category 4: Tests to explicitly verify execution order + + TEST_METHOD(ExecutionOrder_YearNotMatchedInYYYY) + { + // Verify $Y doesn't match when part of $YYYY + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYYY", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_2024", result); // Should be "2024", not "202Y" + } + + TEST_METHOD(ExecutionOrder_MonthNotMatchedInMMM) + { + // Verify $M or $MM don't match when $MMM is given + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$MMM", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Mar", result); // Should be "Mar", not "3ar" + } + + TEST_METHOD(ExecutionOrder_DayNotMatchedInDDDD) + { + // Verify $D or $DD don't match when $DDDD is given + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDDD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_Friday", result); // Should be "Friday", not "15riday" + } + + TEST_METHOD(ExecutionOrder_HourNotMatchedInHH) + { + // Verify $H doesn't match when part of $HH + // Note: $HH is 12-hour format, so 14:00 (2 PM) displays as "02" + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$HH", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_02", result); // 14:00 in 12-hour format is "02 PM" + } + + TEST_METHOD(ExecutionOrder_MillisecondNotMatchedInFFF) + { + // Verify $f or $ff don't match when $fff is given + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$fff", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_123", result); // Should be "123", not "1ff" + } + + // Category 5: Complex mixed scenarios + + TEST_METHOD(ComplexMixedPattern_AllFormats) + { + // Test a complex realistic filename with mixed pattern lengths + // Note: Using $hh for 24-hour format instead of $HH (which is 12-hour) + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"Photo_$YYYY-$MM-$DD_$hh-$mm-$ss_$fff", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"Photo_2024-03-15_14-30-45_123", result); + } + + TEST_METHOD(ComplexMixedPattern_WithSeparators) + { + // Test multiple patterns of different lengths with separators + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$YYYY_$Y-$Y_$MM_$M", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"2024_4-4_03_3", result); + } + + TEST_METHOD(ComplexMixedPattern_DayFormats) + { + // Test all day format variations in one string + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"$D-$DD-$DDD-$DDDD", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"15-15-Fri-Friday", result); + } + + // Category 6: Specific bug fixes and collision avoidance + + TEST_METHOD(BugFix_DDT_AllowsSuffixT) + { + // #44202 - $DDT should be allowed and matched as $DD plus verbatim 'T'. It + // was previously blocked due to the negative lookahead for any capital + // letter after $DD. + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DDT", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_15T", result); + } + + TEST_METHOD(RelaxedConstraint_VerbatimCapitalAfterPatterns) + { + // Verify that patterns can be followed by capital letters that are not part + // of longer patterns, e.g., $DDC should match $DD + 'C'. + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$YYYYA_$MMB_$DDC", testTime); /* #no-spell-check-line */ + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_2024A_03B_15C", result); + } + + TEST_METHOD(Collision_DateTaken_Protected) + { + // Verify that date patterns do not collide with metadata patterns like + // DATE_TAKEN_YYYY. + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DATE_TAKEN_YYYY", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_$DATE_TAKEN_YYYY", result); // Not replaced + } + + TEST_METHOD(Collision_Height_Protected) + { + // Verify that HEIGHT metadata pattern does not collide with date pattern $H. + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$HEIGHT", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_$HEIGHT", result); // Not replaced + } + + TEST_METHOD(Collision_SafeSuffix_Deer) + { + // Verifies that patterns can be safely followed by certain suffix letters as + // long as they don't match a longer pattern. $DEER should be matched as + // $D + 'EER' + SYSTEMTIME testTime = GetTestTime(); + wchar_t result[MAX_PATH] = { 0 }; + HRESULT hr = GetDatedFileName(result, MAX_PATH, L"file_$DEER", testTime); + + Assert::IsTrue(SUCCEEDED(hr)); + Assert::AreEqual(L"file_15EER", result); + } + }; +} diff --git a/src/modules/powerrename/unittests/MetadataFormatHelperTests.cpp b/src/modules/powerrename/unittests/MetadataFormatHelperTests.cpp new file mode 100644 index 0000000000..6fd5badca8 --- /dev/null +++ b/src/modules/powerrename/unittests/MetadataFormatHelperTests.cpp @@ -0,0 +1,487 @@ +#include "pch.h" +#include "MetadataFormatHelper.h" +#include <cmath> + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace PowerRenameLib; + +namespace MetadataFormatHelperTests +{ + TEST_CLASS(FormatApertureTests) + { + public: + TEST_METHOD(FormatAperture_ValidValue) + { + // Test formatting a typical aperture value + std::wstring result = MetadataFormatHelper::FormatAperture(2.8); + Assert::AreEqual(L"f/2.8", result.c_str()); + } + + TEST_METHOD(FormatAperture_SmallValue) + { + // Test small aperture (large f-number) + std::wstring result = MetadataFormatHelper::FormatAperture(1.4); + Assert::AreEqual(L"f/1.4", result.c_str()); + } + + TEST_METHOD(FormatAperture_LargeValue) + { + // Test large aperture (small f-number) + std::wstring result = MetadataFormatHelper::FormatAperture(22.0); + Assert::AreEqual(L"f/22.0", result.c_str()); + } + + TEST_METHOD(FormatAperture_RoundedValue) + { + // Test rounding to one decimal place + std::wstring result = MetadataFormatHelper::FormatAperture(5.66666); + Assert::AreEqual(L"f/5.7", result.c_str()); + } + + TEST_METHOD(FormatAperture_Zero) + { + // Test zero value + std::wstring result = MetadataFormatHelper::FormatAperture(0.0); + Assert::AreEqual(L"f/0.0", result.c_str()); + } + }; + + TEST_CLASS(FormatShutterSpeedTests) + { + public: + TEST_METHOD(FormatShutterSpeed_FastSpeed) + { + // Test fast shutter speed (fraction of second) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.002); + Assert::AreEqual(L"1/500s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_VeryFastSpeed) + { + // Test very fast shutter speed + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.0001); + Assert::AreEqual(L"1/10000s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_SlowSpeed) + { + // Test slow shutter speed (more than 1 second) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(2.5); + Assert::AreEqual(L"2.5s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_OneSecond) + { + // Test exactly 1 second + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(1.0); + Assert::AreEqual(L"1.0s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_VerySlowSpeed) + { + // Test very slow shutter speed (< 1 second but close) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.5); + Assert::AreEqual(L"1/2s", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_Zero) + { + // Test zero value + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(0.0); + Assert::AreEqual(L"0", result.c_str()); + } + + TEST_METHOD(FormatShutterSpeed_Negative) + { + // Test negative value (invalid but should handle gracefully) + std::wstring result = MetadataFormatHelper::FormatShutterSpeed(-1.0); + Assert::AreEqual(L"0", result.c_str()); + } + }; + + TEST_CLASS(FormatISOTests) + { + public: + TEST_METHOD(FormatISO_TypicalValue) + { + // Test typical ISO value + std::wstring result = MetadataFormatHelper::FormatISO(400); + Assert::AreEqual(L"ISO 400", result.c_str()); + } + + TEST_METHOD(FormatISO_LowValue) + { + // Test low ISO value + std::wstring result = MetadataFormatHelper::FormatISO(100); + Assert::AreEqual(L"ISO 100", result.c_str()); + } + + TEST_METHOD(FormatISO_HighValue) + { + // Test high ISO value + std::wstring result = MetadataFormatHelper::FormatISO(12800); + Assert::AreEqual(L"ISO 12800", result.c_str()); + } + + TEST_METHOD(FormatISO_Zero) + { + // Test zero value + std::wstring result = MetadataFormatHelper::FormatISO(0); + Assert::AreEqual(L"ISO", result.c_str()); + } + + TEST_METHOD(FormatISO_Negative) + { + // Test negative value (invalid but should handle gracefully) + std::wstring result = MetadataFormatHelper::FormatISO(-100); + Assert::AreEqual(L"ISO", result.c_str()); + } + }; + + TEST_CLASS(FormatFlashTests) + { + public: + TEST_METHOD(FormatFlash_Off) + { + // Test flash off (bit 0 = 0) + std::wstring result = MetadataFormatHelper::FormatFlash(0x0); + Assert::AreEqual(L"Flash Off", result.c_str()); + } + + TEST_METHOD(FormatFlash_On) + { + // Test flash on (bit 0 = 1) + std::wstring result = MetadataFormatHelper::FormatFlash(0x1); + Assert::AreEqual(L"Flash On", result.c_str()); + } + + TEST_METHOD(FormatFlash_OnWithAdditionalFlags) + { + // Test flash on with additional flags + std::wstring result = MetadataFormatHelper::FormatFlash(0x5); // 0b0101 = fired, return detected + Assert::AreEqual(L"Flash On", result.c_str()); + } + + TEST_METHOD(FormatFlash_OffWithAdditionalFlags) + { + // Test flash off with additional flags + std::wstring result = MetadataFormatHelper::FormatFlash(0x10); // Bit 0 is 0 + Assert::AreEqual(L"Flash Off", result.c_str()); + } + }; + + TEST_CLASS(FormatCoordinateTests) + { + public: + TEST_METHOD(FormatCoordinate_NorthLatitude) + { + // Test north latitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(40.7128, true); + Assert::AreEqual(L"40°42.77'N", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_SouthLatitude) + { + // Test south latitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(-33.8688, true); + Assert::AreEqual(L"33°52.13'S", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_EastLongitude) + { + // Test east longitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(151.2093, false); + Assert::AreEqual(L"151°12.56'E", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_WestLongitude) + { + // Test west longitude + std::wstring result = MetadataFormatHelper::FormatCoordinate(-74.0060, false); + Assert::AreEqual(L"74°0.36'W", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_ZeroLatitude) + { + // Test equator (0 degrees latitude) + std::wstring result = MetadataFormatHelper::FormatCoordinate(0.0, true); + Assert::AreEqual(L"0°0.00'N", result.c_str()); + } + + TEST_METHOD(FormatCoordinate_ZeroLongitude) + { + // Test prime meridian (0 degrees longitude) + std::wstring result = MetadataFormatHelper::FormatCoordinate(0.0, false); + Assert::AreEqual(L"0°0.00'E", result.c_str()); + } + }; + + TEST_CLASS(FormatSystemTimeTests) + { + public: + TEST_METHOD(FormatSystemTime_ValidDateTime) + { + // Test formatting a valid date and time + SYSTEMTIME st = { 0 }; + st.wYear = 2024; + st.wMonth = 3; + st.wDay = 15; + st.wHour = 14; + st.wMinute = 30; + st.wSecond = 45; + + std::wstring result = MetadataFormatHelper::FormatSystemTime(st); + Assert::AreEqual(L"2024-03-15 14:30:45", result.c_str()); + } + + TEST_METHOD(FormatSystemTime_Midnight) + { + // Test midnight time + SYSTEMTIME st = { 0 }; + st.wYear = 2024; + st.wMonth = 1; + st.wDay = 1; + st.wHour = 0; + st.wMinute = 0; + st.wSecond = 0; + + std::wstring result = MetadataFormatHelper::FormatSystemTime(st); + Assert::AreEqual(L"2024-01-01 00:00:00", result.c_str()); + } + + TEST_METHOD(FormatSystemTime_EndOfDay) + { + // Test end of day time + SYSTEMTIME st = { 0 }; + st.wYear = 2024; + st.wMonth = 12; + st.wDay = 31; + st.wHour = 23; + st.wMinute = 59; + st.wSecond = 59; + + std::wstring result = MetadataFormatHelper::FormatSystemTime(st); + Assert::AreEqual(L"2024-12-31 23:59:59", result.c_str()); + } + }; + + TEST_CLASS(ParseSingleRationalTests) + { + public: + TEST_METHOD(ParseSingleRational_ValidValue) + { + // Test parsing a valid rational: 5/2 = 2.5 + uint8_t bytes[] = { 5, 0, 0, 0, 2, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_IntegerResult) + { + // Test parsing rational that results in integer: 10/5 = 2.0 + uint8_t bytes[] = { 10, 0, 0, 0, 5, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(2.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_LargeNumerator) + { + // Test parsing with large numerator: 1000/100 = 10.0 + uint8_t bytes[] = { 0xE8, 0x03, 0, 0, 100, 0, 0, 0 }; // 1000 in little-endian + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(10.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_ZeroDenominator) + { + // Test parsing with zero denominator (should return 0.0) + uint8_t bytes[] = { 5, 0, 0, 0, 0, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(0.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_ZeroNumerator) + { + // Test parsing with zero numerator: 0/5 = 0.0 + uint8_t bytes[] = { 0, 0, 0, 0, 5, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleRational(bytes, 0); + Assert::AreEqual(0.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_WithOffset) + { + // Test parsing with offset + uint8_t bytes[] = { 0xFF, 0xFF, 0xFF, 0xFF, 10, 0, 0, 0, 5, 0, 0, 0 }; // Offset = 4 + double result = MetadataFormatHelper::ParseSingleRational(bytes, 4); + Assert::AreEqual(2.0, result, 0.001); + } + + TEST_METHOD(ParseSingleRational_NullPointer) + { + // Test with null pointer (should return 0.0) + double result = MetadataFormatHelper::ParseSingleRational(nullptr, 0); + Assert::AreEqual(0.0, result, 0.001); + } + }; + + TEST_CLASS(ParseSingleSRationalTests) + { + public: + TEST_METHOD(ParseSingleSRational_PositiveValue) + { + // Test parsing positive signed rational: 5/2 = 2.5 + uint8_t bytes[] = { 5, 0, 0, 0, 2, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_NegativeNumerator) + { + // Test parsing negative numerator: -5/2 = -2.5 + uint8_t bytes[] = { 0xFB, 0xFF, 0xFF, 0xFF, 2, 0, 0, 0 }; // -5 in two's complement + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(-2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_NegativeDenominator) + { + // Test parsing negative denominator: 5/-2 = -2.5 + uint8_t bytes[] = { 5, 0, 0, 0, 0xFE, 0xFF, 0xFF, 0xFF }; // -2 in two's complement + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(-2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_BothNegative) + { + // Test parsing both negative: -5/-2 = 2.5 + uint8_t bytes[] = { 0xFB, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF }; + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(2.5, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_ExposureBias) + { + // Test typical exposure bias value: -1/3 ≈ -0.333 + uint8_t bytes[] = { 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0 }; // -1/3 + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(-0.333, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_ZeroDenominator) + { + // Test with zero denominator (should return 0.0) + uint8_t bytes[] = { 5, 0, 0, 0, 0, 0, 0, 0 }; + double result = MetadataFormatHelper::ParseSingleSRational(bytes, 0); + Assert::AreEqual(0.0, result, 0.001); + } + + TEST_METHOD(ParseSingleSRational_NullPointer) + { + // Test with null pointer (should return 0.0) + double result = MetadataFormatHelper::ParseSingleSRational(nullptr, 0); + Assert::AreEqual(0.0, result, 0.001); + } + }; + + TEST_CLASS(SanitizeForFileNameTests) + { + public: + TEST_METHOD(SanitizeForFileName_ValidString) + { + // Test string without illegal characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Canon EOS 5D"); + Assert::AreEqual(L"Canon EOS 5D", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithColon) + { + // Test string with colon (illegal character) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photo:001"); + Assert::AreEqual(L"Photo_001", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithSlashes) + { + // Test string with forward and backward slashes + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photos/2024\\January"); + Assert::AreEqual(L"Photos_2024_January", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithMultipleIllegalChars) + { + // Test string with multiple illegal characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"<Test>:File|Name*?.txt"); + Assert::AreEqual(L"_Test__File_Name__.txt", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithQuotes) + { + // Test string with quotes + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Photo \"Best Shot\""); + Assert::AreEqual(L"Photo _Best Shot_", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithTrailingDot) + { + // Test string with trailing dot (should be removed) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename."); + Assert::AreEqual(L"filename", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithTrailingSpace) + { + // Test string with trailing space (should be removed) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename "); + Assert::AreEqual(L"filename", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithMultipleTrailingDotsAndSpaces) + { + // Test string with multiple trailing dots and spaces + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"filename. . "); + Assert::AreEqual(L"filename", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_WithControlCharacters) + { + // Test string with control characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"File\x01Name\x1F"); + Assert::AreEqual(L"File_Name_", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_EmptyString) + { + // Test empty string + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L""); + Assert::AreEqual(L"", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_OnlyIllegalCharacters) + { + // Test string with only illegal characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"<>:\"/\\|?*"); + Assert::AreEqual(L"_________", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_OnlyTrailingCharacters) + { + // Test string with only dots and spaces (should return empty) + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L". . "); + Assert::AreEqual(L"", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_UnicodeCharacters) + { + // Test string with valid Unicode characters + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"照片_2024年"); + Assert::AreEqual(L"照片_2024年", result.c_str()); + } + + TEST_METHOD(SanitizeForFileName_MixedContent) + { + // Test realistic metadata string with multiple issues + std::wstring result = MetadataFormatHelper::SanitizeForFileName(L"Copyright © 2024: John/Jane Doe. "); + Assert::AreEqual(L"Copyright © 2024_ John_Jane Doe", result.c_str()); + } + }; +} diff --git a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp index a882802499..97c3f2fa2d 100644 --- a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp +++ b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.cpp @@ -62,6 +62,12 @@ IFACEMETHODIMP CMockPowerRenameRegExEvents::OnFileTimeChanged(_In_ SYSTEMTIME fi return S_OK; } +IFACEMETHODIMP CMockPowerRenameRegExEvents::OnMetadataChanged() +{ + return S_OK; +} + + HRESULT CMockPowerRenameRegExEvents::s_CreateInstance(_Outptr_ IPowerRenameRegExEvents** ppsrree) { *ppsrree = nullptr; @@ -74,3 +80,4 @@ HRESULT CMockPowerRenameRegExEvents::s_CreateInstance(_Outptr_ IPowerRenameRegEx } return hr; } + diff --git a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h index f65108b123..b68f3775e8 100644 --- a/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h +++ b/src/modules/powerrename/unittests/MockPowerRenameRegExEvents.h @@ -19,6 +19,7 @@ public: IFACEMETHODIMP OnReplaceTermChanged(_In_ PCWSTR replaceTerm); IFACEMETHODIMP OnFlagsChanged(_In_ DWORD flags); IFACEMETHODIMP OnFileTimeChanged(_In_ SYSTEMTIME fileTime); + IFACEMETHODIMP OnMetadataChanged(); static HRESULT s_CreateInstance(_Outptr_ IPowerRenameRegExEvents** ppsrree); @@ -39,3 +40,4 @@ public: SYSTEMTIME m_fileTime = { 0 }; long m_refCount; }; + diff --git a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj index 56976c1877..c653ffc943 100644 --- a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj +++ b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj @@ -1,15 +1,15 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <ProjectGuid>{2151F984-E006-4A9F-92EF-C6DDE3DC8413}</ProjectGuid> <Keyword>Win32Proj</Keyword> <RootNamespace>PowerRenameLibUnitTests</RootNamespace> - <ProjectName>PowerRenameUnitTests</ProjectName> + <ProjectName>PowerRename.UnitTests</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <PropertyGroup> <ConfigurationType>DynamicLibrary</ConfigurationType> @@ -24,17 +24,17 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\tests\PowerRename\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\tests\PowerRename\</OutDir> </PropertyGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>..\;..\lib\;..\..\..\;..\..\..\common\telemetry;..\..\;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;..\lib\;$(RepoRoot)src\;$(RepoRoot)src\common\telemetry;..\..\;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> <PreprocessorDefinitions>WIN32;%(PreprocessorDefinitions)</PreprocessorDefinitions> <UseFullPaths>true</UseFullPaths> </ClCompile> <Link> <AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories> - <AdditionalDependencies>$(OutDir)\..\..\WinUI3Apps\PowerRenameLib.lib;comctl32.lib;pathcch.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;Pathcch.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalDependencies>$(OutDir)\..\..\WinUI3Apps\PowerRenameLib.lib;comctl32.lib;pathcch.lib;windowscodecs.lib;kernel32.lib;user32.lib;gdi32.lib;winspool.lib;comdlg32.lib;advapi32.lib;shell32.lib;ole32.lib;oleaut32.lib;uuid.lib;odbc32.lib;odbccp32.lib;Pathcch.lib;%(AdditionalDependencies)</AdditionalDependencies> </Link> </ItemDefinitionGroup> <ItemGroup> @@ -49,11 +49,14 @@ <ClInclude Include="CommonRegExTests.h" /> </ItemGroup> <ItemGroup> + <ClCompile Include="HelpersTests.cpp" /> <ClCompile Include="MockPowerRenameItem.cpp" /> <ClCompile Include="MockPowerRenameManagerEvents.cpp" /> <ClCompile Include="MockPowerRenameRegExEvents.cpp" /> <ClCompile Include="PowerRenameRegExBoostTests.cpp" /> <ClCompile Include="PowerRenameManagerTests.cpp" /> + <ClCompile Include="MetadataFormatHelperTests.cpp" /> + <ClCompile Include="WICMetadataExtractorTests.cpp" /> <ClCompile Include="pch.cpp"> <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> </ClCompile> @@ -61,10 +64,10 @@ <ClCompile Include="TestFileHelper.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> @@ -73,20 +76,48 @@ </ItemGroup> <ItemGroup> <None Include="packages.config" /> + <!-- Include all test data files for deployment --> + <None Include="testdata\exif_test.jpg"> + <DeploymentContent>true</DeploymentContent> + </None> + <None Include="testdata\exif_test_2.jpg"> + <DeploymentContent>true</DeploymentContent> + </None> + <None Include="testdata\xmp_test.jpg"> + <DeploymentContent>true</DeploymentContent> + </None> + <None Include="testdata\xmp_test_2.jpg"> + <DeploymentContent>true</DeploymentContent> + </None> + <None Include="testdata\heif_test.heic"> + <DeploymentContent>true</DeploymentContent> + </None> + <None Include="testdata\avif_test.avif"> + <DeploymentContent>true</DeploymentContent> + </None> + <None Include="testdata\ATTRIBUTION.md"> + <DeploymentContent>true</DeploymentContent> + </None> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <Target Name="CopyTestData" AfterTargets="Build"> + <ItemGroup> + <TestDataFiles Include="$(ProjectDir)testdata\**\*.*" /> + </ItemGroup> + <Copy SourceFiles="@(TestDataFiles)" DestinationFolder="$(OutDir)testdata\%(RecursiveDir)" SkipUnchangedFiles="true" /> + </Target> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\boost.1.84.0\build\boost.targets" Condition="Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" /> - <Import Project="..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets" Condition="Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\boost.1.87.0\build\boost.targets" Condition="Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" /> + <Import Project="$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets" Condition="Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost.1.84.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost.1.84.0\build\boost.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\boost_regex-vc143.1.84.0\build\boost_regex-vc143.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost.1.87.0\build\boost.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost.1.87.0\build\boost.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\boost_regex-vc143.1.87.0\build\boost_regex-vc143.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters index db5bc09af4..42b6d1d17b 100644 --- a/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters +++ b/src/modules/powerrename/unittests/PowerRenameLibUnitTests.vcxproj.filters @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <ItemGroup> + <ClCompile Include="HelpersTests.cpp" /> <ClCompile Include="MockPowerRenameItem.cpp" /> <ClCompile Include="MockPowerRenameManagerEvents.cpp" /> <ClCompile Include="MockPowerRenameRegExEvents.cpp" /> @@ -30,6 +31,9 @@ <Filter Include="Header Files"> <UniqueIdentifier>{d34a343a-52ef-4296-83c9-a94fa62062ff}</UniqueIdentifier> </Filter> + <Filter Include="testdata"> + <UniqueIdentifier>{8c9f3e2d-1a4b-4e5f-9c7d-2b8a6f3e1d4c}</UniqueIdentifier> + </Filter> </ItemGroup> <ItemGroup> <ResourceCompile Include="PowerRenameUnitTests.rc"> @@ -38,5 +42,20 @@ </ItemGroup> <ItemGroup> <None Include="packages.config" /> + <None Include="testdata\exif_test.jpg"> + <Filter>testdata</Filter> + </None> + <None Include="testdata\exif_test_2.jpg"> + <Filter>testdata</Filter> + </None> + <None Include="testdata\xmp_test.jpg"> + <Filter>testdata</Filter> + </None> + <None Include="testdata\xmp_test_2.jpg"> + <Filter>testdata</Filter> + </None> + <None Include="testdata\ATTRIBUTION.md"> + <Filter>testdata</Filter> + </None> </ItemGroup> </Project> \ No newline at end of file diff --git a/src/modules/powerrename/unittests/PowerRenameRegExBoostTests.cpp b/src/modules/powerrename/unittests/PowerRenameRegExBoostTests.cpp index 2bff1a4b9c..491852ff88 100644 --- a/src/modules/powerrename/unittests/PowerRenameRegExBoostTests.cpp +++ b/src/modules/powerrename/unittests/PowerRenameRegExBoostTests.cpp @@ -127,5 +127,53 @@ TEST_METHOD(VerifyLookbehind) CoTaskMemFree(result); } } + +TEST_METHOD (Verify12and24HourTimeFormats) +{ + CComPtr<IPowerRenameRegEx> renameRegEx; + Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK); + DWORD flags = MatchAllOccurrences | UseRegularExpressions; + Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK); + + struct TimeTestCase { + SYSTEMTIME time; // Input time + PCWSTR formatString; // Format pattern + PCWSTR expectedResult; // Expected output + PCWSTR description; // Description of what we're testing + }; + + struct TimeTestCase testCases[] = { + // Midnight (00:00 / 12:00 AM) + { { 2025, 4, 4, 10, 0, 0, 0, 0 }, L"[$hh:$mm] [$H:$mm $tt]", L"[00:00] [12:00 am]", L"Midnight formatting" }, + + // Noon (12:00 / 12:00 PM) + { { 2025, 4, 4, 10, 12, 0, 0, 0 }, L"[$hh:$mm] [$H:$mm $tt]", L"[12:00] [12:00 pm]", L"Noon formatting" }, + + // 1:05 AM + { { 2025, 4, 4, 10, 1, 5, 0, 0 }, L"[$h:$m] [$H:$m $tt] [$hh:$mm] [$HH:$mm $TT]", + L"[1:5] [1:5 am] [01:05] [01:05 AM]", L"1 AM with various formats" }, + + // 11 PM + { { 2025, 4, 4, 10, 23, 45, 0, 0 }, L"[$h:$m] [$H:$m $tt] [$hh:$mm] [$HH:$mm $TT]", + L"[23:45] [11:45 pm] [23:45] [11:45 PM]", L"11 PM with various formats" }, + + // Mixed formats in complex pattern + { { 2025, 4, 4, 10, 14, 30, 0, 0 }, L"Date: $YYYY-$MM-$DD Time: $hh:$mm (24h) / $H:$mm $tt (12h)", + L"Date: 2025-04-10 Time: 14:30 (24h) / 2:30 pm (12h)", L"Complex combined format" }, + }; + + for (int i = 0; i < ARRAYSIZE(testCases); i++) + { + PWSTR result = nullptr; + Assert::IsTrue(renameRegEx->PutSearchTerm(L"test") == S_OK); + Assert::IsTrue(renameRegEx->PutReplaceTerm(testCases[i].formatString) == S_OK); + Assert::IsTrue(renameRegEx->PutFileTime(testCases[i].time) == S_OK); + unsigned long index = {}; + Assert::IsTrue(renameRegEx->Replace(L"test", &result, index) == S_OK); + Assert::IsTrue(wcscmp(result, testCases[i].expectedResult) == 0, + (std::wstring(L"Failed test case: ") + testCases[i].description).c_str()); + CoTaskMemFree(result); + } +} }; } diff --git a/src/modules/powerrename/unittests/PowerRenameRegExTests.cpp b/src/modules/powerrename/unittests/PowerRenameRegExTests.cpp index 5270f193e2..18a679995a 100644 --- a/src/modules/powerrename/unittests/PowerRenameRegExTests.cpp +++ b/src/modules/powerrename/unittests/PowerRenameRegExTests.cpp @@ -207,5 +207,53 @@ TEST_METHOD(VerifyLookbehindFails) } } +TEST_METHOD (Verify12and24HourTimeFormats) +{ + CComPtr<IPowerRenameRegEx> renameRegEx; + Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK); + DWORD flags = MatchAllOccurrences | UseRegularExpressions; + Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK); + + struct TimeTestCase { + SYSTEMTIME time; // Input time + PCWSTR formatString; // Format pattern + PCWSTR expectedResult; // Expected output + PCWSTR description; // Description of what we're testing + }; + + struct TimeTestCase testCases[] = { + // Midnight (00:00 / 12:00 AM) + { { 2025, 4, 4, 10, 0, 0, 0, 0 }, L"[$hh:$mm] [$H:$mm $tt]", L"[00:00] [12:00 am]", L"Midnight formatting" }, + + // Noon (12:00 / 12:00 PM) + { { 2025, 4, 4, 10, 12, 0, 0, 0 }, L"[$hh:$mm] [$H:$mm $tt]", L"[12:00] [12:00 pm]", L"Noon formatting" }, + + // 1:05 AM + { { 2025, 4, 4, 10, 1, 5, 0, 0 }, L"[$h:$m] [$H:$m $tt] [$hh:$mm] [$HH:$mm $TT]", + L"[1:5] [1:5 am] [01:05] [01:05 AM]", L"1 AM with various formats" }, + + // 11 PM + { { 2025, 4, 4, 10, 23, 45, 0, 0 }, L"[$h:$m] [$H:$m $tt] [$hh:$mm] [$HH:$mm $TT]", + L"[23:45] [11:45 pm] [23:45] [11:45 PM]", L"11 PM with various formats" }, + + // Mixed formats in complex pattern + { { 2025, 4, 4, 10, 14, 30, 0, 0 }, L"Date: $YYYY-$MM-$DD Time: $hh:$mm (24h) / $H:$mm $tt (12h)", + L"Date: 2025-04-10 Time: 14:30 (24h) / 2:30 pm (12h)", L"Complex combined format" }, + }; + + for (int i = 0; i < ARRAYSIZE(testCases); i++) + { + PWSTR result = nullptr; + Assert::IsTrue(renameRegEx->PutSearchTerm(L"test") == S_OK); + Assert::IsTrue(renameRegEx->PutReplaceTerm(testCases[i].formatString) == S_OK); + Assert::IsTrue(renameRegEx->PutFileTime(testCases[i].time) == S_OK); + unsigned long index = {}; + Assert::IsTrue(renameRegEx->Replace(L"test", &result, index) == S_OK); + Assert::IsTrue(wcscmp(result, testCases[i].expectedResult) == 0, + (std::wstring(L"Failed test case: ") + testCases[i].description).c_str()); + CoTaskMemFree(result); + } +} + }; } diff --git a/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp b/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp new file mode 100644 index 0000000000..265abfa7b7 --- /dev/null +++ b/src/modules/powerrename/unittests/WICMetadataExtractorTests.cpp @@ -0,0 +1,576 @@ +#include "pch.h" +#include "WICMetadataExtractor.h" +#include <filesystem> +#include <sstream> + +using namespace Microsoft::VisualStudio::CppUnitTestFramework; +using namespace PowerRenameLib; + +namespace WICMetadataExtractorTests +{ + // Helper function to get the test data directory path + std::wstring GetTestDataPath() + { + // Get the directory where the test DLL is located + // When running with vstest, we need to get the DLL module handle + HMODULE hModule = nullptr; + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast<LPCWSTR>(&GetTestDataPath), + &hModule); + + wchar_t modulePath[MAX_PATH]; + GetModuleFileNameW(hModule, modulePath, MAX_PATH); + std::filesystem::path dllPath(modulePath); + + // Navigate to the test data directory + // The test data is in the output directory alongside the DLL + std::filesystem::path testDataPath = dllPath.parent_path() / L"testdata"; + + return testDataPath.wstring(); + } + + TEST_CLASS(ExtractEXIFMetadataTests) + { + public: + TEST_METHOD(ExtractEXIF_InvalidFile_ReturnsFalse) + { + // Test that EXIF extraction fails for nonexistent file + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\nonexistent.jpg"; + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + Assert::IsFalse(result, L"EXIF extraction should fail for nonexistent file"); + } + + TEST_METHOD(ExtractEXIF_ExifTest_AllFields) + { + // Test exif_test.jpg which contains comprehensive EXIF data + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\exif_test.jpg"; + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + Assert::IsTrue(result, L"EXIF extraction should succeed"); + + // Verify all the fields that are in exif_test.jpg + Assert::IsTrue(metadata.cameraMake.has_value(), L"Camera make should be present"); + Assert::AreEqual(L"samsung", metadata.cameraMake.value().c_str(), L"Camera make should be samsung"); + + Assert::IsTrue(metadata.cameraModel.has_value(), L"Camera model should be present"); + Assert::AreEqual(L"SM-G930P", metadata.cameraModel.value().c_str(), L"Camera model should be SM-G930P"); + + Assert::IsTrue(metadata.lensModel.has_value(), L"Lens model should be present"); + Assert::AreEqual(L"Samsung Galaxy S7 Rear Camera", metadata.lensModel.value().c_str(), L"Lens model should match"); + + Assert::IsTrue(metadata.iso.has_value(), L"ISO should be present"); + Assert::AreEqual(40, static_cast<int>(metadata.iso.value()), L"ISO should be 40"); + + Assert::IsTrue(metadata.aperture.has_value(), L"Aperture should be present"); + Assert::AreEqual(1.7, metadata.aperture.value(), 0.01, L"Aperture should be f/1.7"); + + Assert::IsTrue(metadata.shutterSpeed.has_value(), L"Shutter speed should be present"); + Assert::AreEqual(0.000625, metadata.shutterSpeed.value(), 0.000001, L"Shutter speed should be 0.000625s"); + + Assert::IsTrue(metadata.focalLength.has_value(), L"Focal length should be present"); + Assert::AreEqual(4.2, metadata.focalLength.value(), 0.1, L"Focal length should be 4.2mm"); + + Assert::IsTrue(metadata.flash.has_value(), L"Flash should be present"); + Assert::AreEqual(0u, static_cast<unsigned int>(metadata.flash.value()), L"Flash should be 0x0"); + + Assert::IsTrue(metadata.exposureBias.has_value(), L"Exposure bias should be present"); + Assert::AreEqual(0.0, metadata.exposureBias.value(), 0.01, L"Exposure bias should be 0 EV"); + + Assert::IsTrue(metadata.author.has_value(), L"Author should be present"); + Assert::AreEqual(L"Carl Seibert (Exif)", metadata.author.value().c_str(), L"Author should match"); + + Assert::IsTrue(metadata.copyright.has_value(), L"Copyright should be present"); + Assert::IsTrue(metadata.copyright.value().find(L"Carl Seibert") != std::wstring::npos, L"Copyright should contain Carl Seibert"); + } + + TEST_METHOD(ExtractEXIF_ExifTest2_WidthHeight) + { + // Test exif_test_2.jpg which only contains width and height + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\exif_test_2.jpg"; + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + Assert::IsTrue(result, L"EXIF extraction should succeed"); + + // exif_test_2.jpg only has width and height + Assert::IsTrue(metadata.width.has_value(), L"Width should be present"); + Assert::AreEqual(1080u, static_cast<unsigned int>(metadata.width.value()), L"Width should be 1080px"); + + Assert::IsTrue(metadata.height.has_value(), L"Height should be present"); + Assert::AreEqual(810u, static_cast<unsigned int>(metadata.height.value()), L"Height should be 810px"); + + // Other fields should not be present + Assert::IsFalse(metadata.cameraMake.has_value(), L"Camera make should not be present in exif_test_2.jpg"); + Assert::IsFalse(metadata.cameraModel.has_value(), L"Camera model should not be present in exif_test_2.jpg"); + Assert::IsFalse(metadata.iso.has_value(), L"ISO should not be present in exif_test_2.jpg"); + } + + TEST_METHOD(ExtractEXIF_ClearCache) + { + // Test cache clearing works + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\exif_test.jpg"; + + bool result1 = extractor.ExtractEXIFMetadata(testFile, metadata); + Assert::IsTrue(result1); + + extractor.ClearCache(); + + EXIFMetadata metadata2; + bool result2 = extractor.ExtractEXIFMetadata(testFile, metadata2); + Assert::IsTrue(result2); + + // Both calls should succeed + Assert::AreEqual(metadata.cameraMake.value().c_str(), metadata2.cameraMake.value().c_str()); + } + }; + + TEST_CLASS(ExtractXMPMetadataTests) + { + public: + TEST_METHOD(ExtractXMP_InvalidFile_ReturnsFalse) + { + // Test that XMP extraction fails for nonexistent file + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\nonexistent.jpg"; + bool result = extractor.ExtractXMPMetadata(testFile, metadata); + + Assert::IsFalse(result, L"XMP extraction should fail for nonexistent file"); + } + + TEST_METHOD(ExtractXMP_XmpTest_AllFields) + { + // Test xmp_test.jpg which contains comprehensive XMP data + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg"; + bool result = extractor.ExtractXMPMetadata(testFile, metadata); + + Assert::IsTrue(result, L"XMP extraction should succeed"); + + // Verify all the fields that are in xmp_test.jpg + Assert::IsTrue(metadata.title.has_value(), L"Title should be present"); + Assert::AreEqual(L"object name here", metadata.title.value().c_str(), L"Title should match"); + + Assert::IsTrue(metadata.description.has_value(), L"Description should be present"); + Assert::IsTrue(metadata.description.value().find(L"This is a metadata test file") != std::wstring::npos, + L"Description should contain expected text"); + + Assert::IsTrue(metadata.rights.has_value(), L"Rights should be present"); + Assert::AreEqual(L"metadatamatters.blog", metadata.rights.value().c_str(), L"Rights should match"); + + Assert::IsTrue(metadata.creatorTool.has_value(), L"Creator tool should be present"); + Assert::IsTrue(metadata.creatorTool.value().find(L"Adobe Photoshop Lightroom") != std::wstring::npos, + L"Creator tool should contain Lightroom"); + + Assert::IsTrue(metadata.documentID.has_value(), L"Document ID should be present"); + Assert::IsTrue(metadata.documentID.value().find(L"xmp.did:") != std::wstring::npos, + L"Document ID should start with xmp.did:"); + + Assert::IsTrue(metadata.instanceID.has_value(), L"Instance ID should be present"); + Assert::IsTrue(metadata.instanceID.value().find(L"xmp.iid:") != std::wstring::npos, + L"Instance ID should start with xmp.iid:"); + + Assert::IsTrue(metadata.subject.has_value(), L"Subject keywords should be present"); + Assert::IsTrue(metadata.subject.value().size() > 0, L"Should have at least one keyword"); + } + + TEST_METHOD(ExtractXMP_XmpTest2_BasicFields) + { + // Test xmp_test_2.jpg which only contains basic XMP fields + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\xmp_test_2.jpg"; + bool result = extractor.ExtractXMPMetadata(testFile, metadata); + + Assert::IsTrue(result, L"XMP extraction should succeed"); + + // xmp_test_2.jpg only has CreatorTool, DocumentID, and InstanceID + Assert::IsTrue(metadata.creatorTool.has_value(), L"Creator tool should be present"); + Assert::IsTrue(metadata.creatorTool.value().find(L"Adobe Photoshop CS6") != std::wstring::npos, + L"Creator tool should be Photoshop CS6"); + + Assert::IsTrue(metadata.documentID.has_value(), L"Document ID should be present"); + Assert::IsTrue(metadata.documentID.value().find(L"xmp.did:") != std::wstring::npos, + L"Document ID should start with xmp.did:"); + + Assert::IsTrue(metadata.instanceID.has_value(), L"Instance ID should be present"); + Assert::IsTrue(metadata.instanceID.value().find(L"xmp.iid:") != std::wstring::npos, + L"Instance ID should start with xmp.iid:"); + + // Other fields should not be present + Assert::IsFalse(metadata.title.has_value(), L"Title should not be present in xmp_test_2.jpg"); + Assert::IsFalse(metadata.description.has_value(), L"Description should not be present in xmp_test_2.jpg"); + Assert::IsFalse(metadata.rights.has_value(), L"Rights should not be present in xmp_test_2.jpg"); + Assert::IsFalse(metadata.creator.has_value(), L"Creator should not be present in xmp_test_2.jpg"); + } + + TEST_METHOD(ExtractXMP_ClearCache) + { + // Test cache clearing works + WICMetadataExtractor extractor; + XMPMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg"; + + bool result1 = extractor.ExtractXMPMetadata(testFile, metadata); + Assert::IsTrue(result1); + + extractor.ClearCache(); + + XMPMetadata metadata2; + bool result2 = extractor.ExtractXMPMetadata(testFile, metadata2); + Assert::IsTrue(result2); + + // Both calls should succeed + Assert::AreEqual(metadata.title.value().c_str(), metadata2.title.value().c_str()); + } + }; + + TEST_CLASS(ExtractHEIFMetadataTests) + { + public: + TEST_METHOD(ExtractHEIF_EXIF_CameraInfo) + { + // Test HEIF EXIF extraction - camera information + // This test requires HEIF Image Extensions to be installed from Microsoft Store + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + // Check if file exists first + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + // If HEIF extension is not installed, extraction may fail + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify camera information from iPhone + Assert::IsTrue(metadata.cameraMake.has_value(), L"Camera make should be present"); + Assert::AreEqual(L"Apple", metadata.cameraMake.value().c_str(), L"Camera make should be Apple"); + + Assert::IsTrue(metadata.cameraModel.has_value(), L"Camera model should be present"); + // Model should contain "iPhone" + Assert::IsTrue(metadata.cameraModel.value().find(L"iPhone") != std::wstring::npos, + L"Camera model should contain iPhone"); + } + + TEST_METHOD(ExtractHEIF_EXIF_DateTaken) + { + // Test HEIF EXIF extraction - date taken + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify date taken is present + Assert::IsTrue(metadata.dateTaken.has_value(), L"Date taken should be present"); + + // Verify the date is a reasonable year (2020-2030 range) + SYSTEMTIME dt = metadata.dateTaken.value(); + Assert::IsTrue(dt.wYear >= 2020 && dt.wYear <= 2030, L"Date taken year should be reasonable"); + } + + TEST_METHOD(ExtractHEIF_EXIF_ShootingParameters) + { + // Test HEIF EXIF extraction - shooting parameters + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify shooting parameters are present + Assert::IsTrue(metadata.iso.has_value(), L"ISO should be present"); + Assert::IsTrue(metadata.iso.value() > 0, L"ISO should be positive"); + + Assert::IsTrue(metadata.aperture.has_value(), L"Aperture should be present"); + Assert::IsTrue(metadata.aperture.value() > 0, L"Aperture should be positive"); + + Assert::IsTrue(metadata.shutterSpeed.has_value(), L"Shutter speed should be present"); + Assert::IsTrue(metadata.shutterSpeed.value() > 0, L"Shutter speed should be positive"); + + Assert::IsTrue(metadata.focalLength.has_value(), L"Focal length should be present"); + Assert::IsTrue(metadata.focalLength.value() > 0, L"Focal length should be positive"); + } + + TEST_METHOD(ExtractHEIF_EXIF_GPS) + { + // Test HEIF EXIF extraction - GPS coordinates + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify GPS coordinates are present (if the test file has GPS data) + if (metadata.latitude.has_value() && metadata.longitude.has_value()) + { + // Latitude should be between -90 and 90 + Assert::IsTrue(metadata.latitude.value() >= -90.0 && metadata.latitude.value() <= 90.0, + L"Latitude should be valid"); + + // Longitude should be between -180 and 180 + Assert::IsTrue(metadata.longitude.value() >= -180.0 && metadata.longitude.value() <= 180.0, + L"Longitude should be valid"); + } + else + { + Logger::WriteMessage(L"GPS data not present in test file"); + } + } + + TEST_METHOD(ExtractHEIF_EXIF_ImageDimensions) + { + // Test HEIF EXIF extraction - image dimensions + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify image dimensions are present + Assert::IsTrue(metadata.width.has_value(), L"Width should be present"); + Assert::IsTrue(metadata.width.value() > 0, L"Width should be positive"); + + Assert::IsTrue(metadata.height.has_value(), L"Height should be present"); + Assert::IsTrue(metadata.height.value() > 0, L"Height should be positive"); + } + + TEST_METHOD(ExtractHEIF_EXIF_LensModel) + { + // Test HEIF EXIF extraction - lens model + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"HEIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed"); + return; + } + + Assert::IsTrue(result, L"HEIF EXIF extraction should succeed"); + + // Verify lens model is present (iPhone photos typically have this) + if (metadata.lensModel.has_value()) + { + Assert::IsFalse(metadata.lensModel.value().empty(), L"Lens model should not be empty"); + } + else + { + Logger::WriteMessage(L"Lens model not present in test file"); + } + } + }; + + TEST_CLASS(ExtractAVIFMetadataTests) + { + public: + TEST_METHOD(ExtractAVIF_EXIF_CameraInfo) + { + // Test AVIF EXIF extraction - camera information + // This test requires AV1 Video Extension to be installed from Microsoft Store + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"AVIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed"); + return; + } + + Assert::IsTrue(result, L"AVIF EXIF extraction should succeed"); + + // Verify camera information + if (metadata.cameraMake.has_value()) + { + Assert::IsFalse(metadata.cameraMake.value().empty(), L"Camera make should not be empty"); + } + + if (metadata.cameraModel.has_value()) + { + Assert::IsFalse(metadata.cameraModel.value().empty(), L"Camera model should not be empty"); + } + } + + TEST_METHOD(ExtractAVIF_EXIF_DateTaken) + { + // Test AVIF EXIF extraction - date taken + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"AVIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed"); + return; + } + + Assert::IsTrue(result, L"AVIF EXIF extraction should succeed"); + + // Verify date taken is present + if (metadata.dateTaken.has_value()) + { + SYSTEMTIME dt = metadata.dateTaken.value(); + Assert::IsTrue(dt.wYear >= 2000 && dt.wYear <= 2100, L"Date taken year should be reasonable"); + } + else + { + Logger::WriteMessage(L"Date taken not present in AVIF test file"); + } + } + + TEST_METHOD(ExtractAVIF_EXIF_ImageDimensions) + { + // Test AVIF EXIF extraction - image dimensions + WICMetadataExtractor extractor; + EXIFMetadata metadata; + + std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif"; + + if (!std::filesystem::exists(testFile)) + { + Logger::WriteMessage(L"AVIF test file not found, skipping test"); + return; + } + + bool result = extractor.ExtractEXIFMetadata(testFile, metadata); + + if (!result) + { + Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed"); + return; + } + + Assert::IsTrue(result, L"AVIF EXIF extraction should succeed"); + + // Verify image dimensions are present + if (metadata.width.has_value()) + { + Assert::IsTrue(metadata.width.value() > 0, L"Width should be positive"); + } + + if (metadata.height.has_value()) + { + Assert::IsTrue(metadata.height.value() > 0, L"Height should be positive"); + } + } + }; +} diff --git a/src/modules/powerrename/unittests/packages.config b/src/modules/powerrename/unittests/packages.config index ecc3202cd3..d33ff8fb41 100644 --- a/src/modules/powerrename/unittests/packages.config +++ b/src/modules/powerrename/unittests/packages.config @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="boost" version="1.84.0" targetFramework="native" /> - <package id="boost_regex-vc143" version="1.84.0" targetFramework="native" /> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="boost" version="1.87.0" targetFramework="native" /> + <package id="boost_regex-vc143" version="1.87.0" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/powerrename/unittests/testdata/ATTRIBUTION.md b/src/modules/powerrename/unittests/testdata/ATTRIBUTION.md new file mode 100644 index 0000000000..88844e57b8 --- /dev/null +++ b/src/modules/powerrename/unittests/testdata/ATTRIBUTION.md @@ -0,0 +1,45 @@ +# Test Data Attribution + +This directory contains test image files used for PowerRename metadata extraction unit tests. These images are sourced from Wikimedia Commons and are used under the Creative Commons licenses specified below. + +## Test Files and Licenses + +### Files from Carlseibert + +**License:** [Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/) + +- `exif_test.jpg` - Uploaded by [Carlseibert](https://commons.wikimedia.org/wiki/File%3AMetadata_test_file_-_includes_data_in_IIM%2C_XMP%2C_and_Exif.jpg) on Wikimedia Commons +- `xmp_test.jpg` - Uploaded by [Carlseibert](https://commons.wikimedia.org/wiki/File%3AMetadata_test_file_-_includes_data_in_IIM%2C_XMP%2C_and_Exif.jpg) on Wikimedia Commons + +### Files from Edward Steven + +**License:** [Creative Commons Attribution-ShareAlike 2.0 Generic (CC BY-SA 2.0)](https://creativecommons.org/licenses/by-sa/2.0/) + +- `exif_test_2.jpg` - Uploaded by [Edward Steven](https://commons.wikimedia.org/wiki/File%3AAreca_hutchinsoniana.jpg) on Wikimedia Commons +- `xmp_test_2.jpg` - Uploaded by [Edward Steven](https://commons.wikimedia.org/wiki/File%3AAreca_hutchinsoniana.jpg) on Wikimedia Commons + +## Acknowledgments + +We gratefully acknowledge the contributions of Carlseibert and Edward Steven for making these images available under Creative Commons licenses. Their work enables us to test metadata extraction functionality with real-world EXIF and XMP data. + +## Usage + +These test images are used in PowerRename's unit tests to verify correct extraction of: +- EXIF metadata (camera make, model, ISO, aperture, shutter speed, etc.) +- XMP metadata (creator, title, description, copyright, etc.) +- GPS coordinates +- Date/time information + +## License Compliance + +These test images are distributed as part of the PowerToys source code repository under their original Creative Commons licenses: +- Files from Carlseibert: CC BY-SA 4.0 +- Files from Edward Steven: CC BY-SA 2.0 + +**Modifications:** These images have not been modified from their original versions downloaded from Wikimedia Commons. They are used in their original form for metadata extraction testing purposes. + +**Distribution:** These test images are included in the PowerToys source repository and comply with the terms of their respective Creative Commons licenses through proper attribution in this file. While included in the source code, these images are not distributed in end-user installation packages or releases. + +**Derivatives:** Any modifications or derivative works of these images must comply with the respective CC BY-SA license terms, including proper attribution and applying the same license to the modified versions. + +For more information about Creative Commons licenses, visit: https://creativecommons.org/licenses/ diff --git a/src/modules/powerrename/unittests/testdata/avif_test.avif b/src/modules/powerrename/unittests/testdata/avif_test.avif new file mode 100644 index 0000000000..49fad74288 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/avif_test.avif differ diff --git a/src/modules/powerrename/unittests/testdata/exif_test.jpg b/src/modules/powerrename/unittests/testdata/exif_test.jpg new file mode 100644 index 0000000000..5b40a5a688 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/exif_test.jpg differ diff --git a/src/modules/powerrename/unittests/testdata/exif_test_2.jpg b/src/modules/powerrename/unittests/testdata/exif_test_2.jpg new file mode 100644 index 0000000000..ec2a3ad703 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/exif_test_2.jpg differ diff --git a/src/modules/powerrename/unittests/testdata/heif_test.heic b/src/modules/powerrename/unittests/testdata/heif_test.heic new file mode 100644 index 0000000000..824e011b9e Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/heif_test.heic differ diff --git a/src/modules/powerrename/unittests/testdata/xmp_test.jpg b/src/modules/powerrename/unittests/testdata/xmp_test.jpg new file mode 100644 index 0000000000..5b40a5a688 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/xmp_test.jpg differ diff --git a/src/modules/powerrename/unittests/testdata/xmp_test_2.jpg b/src/modules/powerrename/unittests/testdata/xmp_test_2.jpg new file mode 100644 index 0000000000..ec2a3ad703 Binary files /dev/null and b/src/modules/powerrename/unittests/testdata/xmp_test_2.jpg differ diff --git a/src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandler.csproj b/src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandler.csproj new file mode 100644 index 0000000000..3c73225bb8 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandler.csproj @@ -0,0 +1,65 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <UseWindowsForms>true</UseWindowsForms> + <AssemblyTitle>PowerToys.BgcodePreviewHandler</AssemblyTitle> + <AssemblyDescription>PowerToys BgcodePreviewHandler</AssemblyDescription> + <Description>PowerToys BgcodePreviewHandler</Description> + <DocumentationFile>..\..\..\..\$(Platform)\$(Configuration)\BgcodePreviewPaneDocumentation.xml</DocumentationFile> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> + <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore> + <AssemblyName>PowerToys.BgcodePreviewHandler</AssemblyName> + <ProjectGuid>{9E0CBC06-F29A-4810-B93C-97D53863B95E}</ProjectGuid> + <RootNamespace>Microsoft.PowerToys.PreviewHandler.Bgcode</RootNamespace> + <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode> + </PropertyGroup> + + <ItemGroup> + <Compile Update="BgcodePreviewHandlerControl.cs" /> + <Compile Update="Properties\Resource.Designer.cs"> + <DesignTime>True</DesignTime> + <AutoGen>True</AutoGen> + <DependentUpon>Resource.resx</DependentUpon> + </Compile> + </ItemGroup> + + <!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info --> + <PropertyGroup> + <CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes> + <CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir> + <ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles> + </PropertyGroup> + <PropertyGroup> + <!-- Disable missing comment warning. WinRT/C++ libraries added won't have comments on their reflections. --> + <NoWarn>$(NoWarn);1591</NoWarn> + <OutputType>WinExe</OutputType> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="System.IO.Abstractions" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" /> + <ProjectReference Include="..\..\..\common\FilePreviewCommon\FilePreviewCommon.csproj" /> + <ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" /> + <ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" /> + <ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> + <ProjectReference Include="..\common\PreviewHandlerCommon.csproj" /> + </ItemGroup> + + <ItemGroup> + <EmbeddedResource Update="Properties\Resource.resx"> + <Generator>ResXFileCodeGenerator</Generator> + <LastGenOutput>Resource.Designer.cs</LastGenOutput> + </EmbeddedResource> + </ItemGroup> + +</Project> diff --git a/src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandlerControl.cs b/src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandlerControl.cs new file mode 100644 index 0000000000..7dc205bed3 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandlerControl.cs @@ -0,0 +1,179 @@ +// 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 Common; +using Microsoft.PowerToys.FilePreviewCommon; +using Microsoft.PowerToys.PreviewHandler.Bgcode.Telemetry.Events; +using Microsoft.PowerToys.Telemetry; + +namespace Microsoft.PowerToys.PreviewHandler.Bgcode +{ + /// <summary> + /// Implementation of Control for Bgcode Preview Handler. + /// </summary> + public class BgcodePreviewHandlerControl : FormHandlerControl + { + /// <summary> + /// Picture box control to display the Binary G-code thumbnail. + /// </summary> + private PictureBox _pictureBox; + + /// <summary> + /// Text box to display the information about blocked elements from Svg. + /// </summary> + private RichTextBox _textBox; + + /// <summary> + /// Represent if a text box info bar is added for showing message. + /// </summary> + private bool _infoBarAdded; + + /// <summary> + /// Initializes a new instance of the <see cref="BgcodePreviewHandlerControl"/> class. + /// </summary> + public BgcodePreviewHandlerControl() + { + SetBackgroundColor(Settings.BackgroundColor); + } + + /// <summary> + /// Start the preview on the Control. + /// </summary> + /// <param name="dataSource">Stream reference to access source file.</param> + public override void DoPreview<T>(T dataSource) + { + if (global::PowerToys.GPOWrapper.GPOWrapper.GetConfiguredBgcodePreviewEnabledValue() == global::PowerToys.GPOWrapper.GpoRuleConfigured.Disabled) + { + // GPO is disabling this utility. Show an error message instead. + _infoBarAdded = true; + AddTextBoxControl(Properties.Resource.GpoDisabledErrorText); + Resize += FormResized; + base.DoPreview(dataSource); + + return; + } + + try + { + Bitmap thumbnail = null; + + if (!(dataSource is string filePath)) + { + throw new ArgumentException($"{nameof(dataSource)} for {nameof(BgcodePreviewHandlerControl)} must be a string but was a '{typeof(T)}'"); + } + + FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read); + + using (var reader = new BinaryReader(fs)) + { + var bgcodeThumbnail = BgcodeHelper.GetBestThumbnail(reader); + + thumbnail = bgcodeThumbnail?.GetBitmap(); + } + + _infoBarAdded = false; + + if (thumbnail == null) + { + _infoBarAdded = true; + AddTextBoxControl(Properties.Resource.BgcodeWithoutEmbeddedThumbnails); + } + else + { + AddPictureBoxControl(thumbnail); + } + + Resize += FormResized; + base.DoPreview(fs); + try + { + PowerToysTelemetry.Log.WriteEvent(new BgcodeFilePreviewed()); + } + catch + { // Should not crash if sending telemetry is failing. Ignore the exception. + } + } + catch (Exception ex) + { + PreviewError(ex, dataSource); + } + } + + /// <summary> + /// Occurs when RichtextBox is resized. + /// </summary> + /// <param name="sender">Reference to resized control.</param> + /// <param name="e">Provides data for the ContentsResized event.</param> + private void RTBContentsResized(object sender, ContentsResizedEventArgs e) + { + var richTextBox = sender as RichTextBox; + richTextBox.Height = e.NewRectangle.Height + 5; + } + + /// <summary> + /// Occurs when form is resized. + /// </summary> + /// <param name="sender">Reference to resized control.</param> + /// <param name="e">Provides data for the resize event.</param> + private void FormResized(object sender, EventArgs e) + { + if (_infoBarAdded) + { + _textBox.Width = Width; + } + } + + /// <summary> + /// Adds a PictureBox Control to Control Collection. + /// </summary> + /// <param name="image">Image to display on PictureBox Control.</param> + private void AddPictureBoxControl(Image image) + { + _pictureBox = new PictureBox(); + _pictureBox.BackgroundImage = image; + _pictureBox.BackgroundImageLayout = Width >= image.Width && Height >= image.Height ? ImageLayout.Center : ImageLayout.Zoom; + _pictureBox.Dock = DockStyle.Fill; + Controls.Add(_pictureBox); + } + + /// <summary> + /// Adds a Text Box in Controls for showing information about blocked elements. + /// </summary> + /// <param name="message">Message to be displayed in textbox.</param> + private void AddTextBoxControl(string message) + { + _textBox = new RichTextBox(); + _textBox.Text = message; + _textBox.BackColor = Color.LightYellow; + _textBox.Multiline = true; + _textBox.Dock = DockStyle.Top; + _textBox.ReadOnly = true; + _textBox.ContentsResized += RTBContentsResized; + _textBox.ScrollBars = RichTextBoxScrollBars.None; + _textBox.BorderStyle = BorderStyle.None; + Controls.Add(_textBox); + } + + /// <summary> + /// Called when an error occurs during preview. + /// </summary> + /// <param name="exception">The exception which occurred.</param> + /// <param name="dataSource">Stream reference to access source file.</param> + private void PreviewError<T>(Exception exception, T dataSource) + { + try + { + PowerToysTelemetry.Log.WriteEvent(new BgcodeFilePreviewError { Message = exception.Message }); + } + catch + { // Should not crash if sending telemetry is failing. Ignore the exception. + } + + Controls.Clear(); + _infoBarAdded = true; + AddTextBoxControl(Properties.Resource.BgcodeNotPreviewedError); + base.DoPreview(dataSource); + } + } +} diff --git a/src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandlerControl.resx b/src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandlerControl.resx new file mode 100644 index 0000000000..1af7de150c --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/BgcodePreviewHandlerControl.resx @@ -0,0 +1,120 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> +</root> \ No newline at end of file diff --git a/src/modules/previewpane/BgcodePreviewHandler/Program.cs b/src/modules/previewpane/BgcodePreviewHandler/Program.cs new file mode 100644 index 0000000000..c513ac1e38 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/Program.cs @@ -0,0 +1,80 @@ +// 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.Windows.Threading; + +using Common.UI; +using Microsoft.PowerToys.Telemetry; +using PowerToys.Interop; + +namespace Microsoft.PowerToys.PreviewHandler.Bgcode +{ + internal static class Program + { + private static CancellationTokenSource _tokenSource = new CancellationTokenSource(); + + private static BgcodePreviewHandlerControl _previewHandlerControl; + + /// <summary> + /// The main entry point for the application. + /// </summary> + [STAThread] + public static void Main(string[] args) + { + ApplicationConfiguration.Initialize(); + if (args != null) + { + if (args.Length == 6) + { + ETWTrace etwTrace = new ETWTrace(Path.Combine(Environment.GetEnvironmentVariable("USERPROFILE"), "AppData", "LocalLow", "Microsoft", "PowerToys", "etw")); + + string filePath = args[0]; + IntPtr hwnd = IntPtr.Parse(args[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + int left = Convert.ToInt32(args[2], 10); + int right = Convert.ToInt32(args[3], 10); + int top = Convert.ToInt32(args[4], 10); + int bottom = Convert.ToInt32(args[5], 10); + Rectangle s = new Rectangle(left, top, right - left, bottom - top); + + _previewHandlerControl = new BgcodePreviewHandlerControl(); + + if (!_previewHandlerControl.SetWindow(hwnd, s)) + { + return; + } + + _previewHandlerControl.DoPreview(filePath); + + NativeEventWaiter.WaitForEventLoop( + Constants.BgcodePreviewResizeEvent(), + () => + { + Rectangle s = default; + if (!_previewHandlerControl.SetRect(s)) + { + etwTrace?.Dispose(); + + // When the parent HWND became invalid, the application won't respond to Application.Exit(). + Environment.Exit(0); + } + }, + Dispatcher.CurrentDispatcher, + _tokenSource.Token); + + etwTrace?.Dispose(); + } + else + { + MessageBox.Show("Wrong number of args: " + args.Length.ToString(CultureInfo.InvariantCulture)); + } + } + + // To customize application configuration such as set high DPI settings or default font, + // see https://aka.ms/applicationconfiguration. + Application.Run(); + } + } +} diff --git a/src/modules/previewpane/BgcodePreviewHandler/Properties/Resource.Designer.cs b/src/modules/previewpane/BgcodePreviewHandler/Properties/Resource.Designer.cs new file mode 100644 index 0000000000..ecb8017305 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/Properties/Resource.Designer.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// <auto-generated> +// 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. +// </auto-generated> +//------------------------------------------------------------------------------ + +namespace Microsoft.PowerToys.PreviewHandler.Bgcode.Properties { + using System; + + + /// <summary> + /// A strongly-typed resource class, for looking up localized strings, etc. + /// </summary> + // 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", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resource { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resource() { + } + + /// <summary> + /// Returns the cached ResourceManager instance used by this class. + /// </summary> + [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.PowerToys.PreviewHandler.Bgcode.Properties.Resource", typeof(Resource).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// <summary> + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// </summary> + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// <summary> + /// Looks up a localized string similar to This Binary G-code could not be previewed due to an internal error.. + /// </summary> + internal static string BgcodeNotPreviewedError { + get { + return ResourceManager.GetString("BgcodeNotPreviewedError", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This Binary G-code does not contain any embedded thumbnails.. + /// </summary> + internal static string BgcodeWithoutEmbeddedThumbnails { + get { + return ResourceManager.GetString("BgcodeWithoutEmbeddedThumbnails", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.. + /// </summary> + internal static string GpoDisabledErrorText { + get { + return ResourceManager.GetString("GpoDisabledErrorText", resourceCulture); + } + } + } +} diff --git a/src/modules/previewpane/BgcodePreviewHandler/Properties/Resource.resx b/src/modules/previewpane/BgcodePreviewHandler/Properties/Resource.resx new file mode 100644 index 0000000000..62a1cdc0dd --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/Properties/Resource.resx @@ -0,0 +1,131 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="BgcodeNotPreviewedError" xml:space="preserve"> + <value>This Binary G-code could not be previewed due to an internal error.</value> + <comment>This text is displayed if Binary G-code fails to preview</comment> + </data> + <data name="BgcodeWithoutEmbeddedThumbnails" xml:space="preserve"> + <value>This Binary G-code does not contain any embedded thumbnails.</value> + </data> + <data name="GpoDisabledErrorText" xml:space="preserve"> + <value>Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.</value> + <comment>GPO stands for the Windows Group Policy Object feature.</comment> + </data> +</root> \ No newline at end of file diff --git a/src/modules/previewpane/BgcodePreviewHandler/Settings.cs b/src/modules/previewpane/BgcodePreviewHandler/Settings.cs new file mode 100644 index 0000000000..20815d160b --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/Settings.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.PreviewHandler.Bgcode +{ + internal sealed class Settings + { + /// <summary> + /// Gets the color of the window background. + /// Even though this is not a setting yet, it's retrieved from a "Settings" class to be aligned with other preview handlers that contain this setting. + /// It's possible it can be converted into a setting in the future. + /// </summary> + public static Color BackgroundColor + { + get + { + if (GetTheme() == "dark") + { + return Color.FromArgb(30, 30, 30); // #1e1e1e + } + else + { + return Color.White; + } + } + } + + /// <summary> + /// Returns the theme. + /// </summary> + /// <returns>Theme that should be used.</returns> + public static string GetTheme() + { + return Common.UI.ThemeManager.GetWindowsBaseColor().ToLowerInvariant(); + } + } +} diff --git a/src/modules/previewpane/BgcodePreviewHandler/Telemetry/Events/BgcodeFileHandlerLoaded.cs b/src/modules/previewpane/BgcodePreviewHandler/Telemetry/Events/BgcodeFileHandlerLoaded.cs new file mode 100644 index 0000000000..f28f358953 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/Telemetry/Events/BgcodeFileHandlerLoaded.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.PreviewHandler.Bgcode.Telemetry.Events +{ + /// <summary> + /// A telemetry event to be raised when a bgcode file has been viewed in the preview pane. + /// </summary> + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class BgcodeFileHandlerLoaded : EventBase, IEvent + { + /// <inheritdoc/> + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + } +} diff --git a/src/modules/previewpane/BgcodePreviewHandler/Telemetry/Events/BgcodeFilePreviewError.cs b/src/modules/previewpane/BgcodePreviewHandler/Telemetry/Events/BgcodeFilePreviewError.cs new file mode 100644 index 0000000000..e71f2c3f3f --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/Telemetry/Events/BgcodeFilePreviewError.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.PreviewHandler.Bgcode.Telemetry.Events +{ + /// <summary> + /// A telemetry event to be raised when an error has occurred in the preview pane. + /// </summary> + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class BgcodeFilePreviewError : EventBase, IEvent + { + /// <summary> + /// Gets or sets the error message to log as part of the telemetry event. + /// </summary> + public string Message { get; set; } + + /// <inheritdoc/> + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServicePerformance; + } +} diff --git a/src/modules/previewpane/BgcodePreviewHandler/Telemetry/Events/BgcodeFilePreviewed.cs b/src/modules/previewpane/BgcodePreviewHandler/Telemetry/Events/BgcodeFilePreviewed.cs new file mode 100644 index 0000000000..8083b5d7e5 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandler/Telemetry/Events/BgcodeFilePreviewed.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.PreviewHandler.Bgcode.Telemetry.Events +{ + /// <summary> + /// A telemetry event to be raised when a bgcode file has been viewed in the preview pane. + /// </summary> + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class BgcodeFilePreviewed : EventBase, IEvent + { + /// <inheritdoc/> + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + } +} diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandler.cpp b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandler.cpp new file mode 100644 index 0000000000..2091db6c34 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandler.cpp @@ -0,0 +1,284 @@ +#include "pch.h" +#include "BgcodePreviewHandler.h" +#include "../powerpreview/powerpreviewConstants.h" + +#include <shellapi.h> +#include <Shlwapi.h> +#include <string> + +#include <common/interop/shared_constants.h> +#include <common/logger/logger.h> +#include <common/SettingsAPI/settings_helpers.h> +#include <common/utils/process_path.h> +#include <common/Themes/windows_colors.h> + +extern HINSTANCE g_hInst; +extern long g_cDllRef; + +BgcodePreviewHandler::BgcodePreviewHandler() : + m_cRef(1), m_hwndParent(NULL), m_rcParent(), m_punkSite(NULL), m_process(NULL) +{ + m_resizeEvent = CreateEvent(nullptr, false, false, CommonSharedConstants::BGCODE_PREVIEW_RESIZE_EVENT); + + std::filesystem::path logFilePath(PTSettingsHelper::get_local_low_folder_location()); + logFilePath.append(LogSettings::bgcodePrevLogPath); + Logger::init(LogSettings::bgcodePrevLoggerName, logFilePath.wstring(), PTSettingsHelper::get_log_settings_file_location()); + + InterlockedIncrement(&g_cDllRef); +} + +BgcodePreviewHandler::~BgcodePreviewHandler() +{ + InterlockedDecrement(&g_cDllRef); +} + +#pragma region IUnknown + +IFACEMETHODIMP BgcodePreviewHandler::QueryInterface(REFIID riid, void** ppv) +{ + static const QITAB qit[] = { + QITABENT(BgcodePreviewHandler, IPreviewHandler), + QITABENT(BgcodePreviewHandler, IInitializeWithFile), + QITABENT(BgcodePreviewHandler, IPreviewHandlerVisuals), + QITABENT(BgcodePreviewHandler, IOleWindow), + QITABENT(BgcodePreviewHandler, IObjectWithSite), + { 0 }, + }; + return QISearch(this, qit, riid, ppv); +} + +IFACEMETHODIMP_(ULONG) +BgcodePreviewHandler::AddRef() +{ + return InterlockedIncrement(&m_cRef); +} + +IFACEMETHODIMP_(ULONG) +BgcodePreviewHandler::Release() +{ + ULONG cRef = InterlockedDecrement(&m_cRef); + if (0 == cRef) + { + delete this; + } + return cRef; +} + +#pragma endregion + +#pragma region IInitializationWithFile + +IFACEMETHODIMP BgcodePreviewHandler::Initialize(LPCWSTR pszFilePath, DWORD grfMode) +{ + m_filePath = pszFilePath; + return S_OK; +} + +#pragma endregion + +#pragma region IPreviewHandler + +IFACEMETHODIMP BgcodePreviewHandler::SetWindow(HWND hwnd, const RECT* prc) +{ + if (hwnd && prc) + { + m_hwndParent = hwnd; + m_rcParent = *prc; + } + return S_OK; +} + +IFACEMETHODIMP BgcodePreviewHandler::SetFocus() +{ + return S_OK; +} + +IFACEMETHODIMP BgcodePreviewHandler::QueryFocus(HWND* phwnd) +{ + HRESULT hr = E_INVALIDARG; + if (phwnd) + { + *phwnd = ::GetFocus(); + if (*phwnd) + { + hr = S_OK; + } + else + { + hr = HRESULT_FROM_WIN32(GetLastError()); + } + } + return hr; +} + +IFACEMETHODIMP BgcodePreviewHandler::TranslateAccelerator(MSG* pmsg) +{ + HRESULT hr = S_FALSE; + IPreviewHandlerFrame* pFrame = NULL; + if (m_punkSite && SUCCEEDED(m_punkSite->QueryInterface(&pFrame))) + { + hr = pFrame->TranslateAccelerator(pmsg); + + pFrame->Release(); + } + return hr; +} + +IFACEMETHODIMP BgcodePreviewHandler::SetRect(const RECT* prc) +{ + HRESULT hr = E_INVALIDARG; + if (prc != NULL) + { + if (m_rcParent.left == 0 && m_rcParent.top == 0 && m_rcParent.right == 0 && m_rcParent.bottom == 0 && (prc->left != 0 || prc->top != 0 || prc->right != 0 || prc->bottom != 0)) + { + // BgcodePreviewHandler position first initialisation, do the first preview + m_rcParent = *prc; + DoPreview(); + } + if (!m_resizeEvent) + { + Logger::error(L"Failed to create resize event for BgcodePreviewHandler"); + } + else + { + if (m_rcParent.right != prc->right || m_rcParent.left != prc->left || m_rcParent.top != prc->top || m_rcParent.bottom != prc->bottom) + { + if (!SetEvent(m_resizeEvent)) + { + Logger::error(L"Failed to signal resize event for BgcodePreviewHandler"); + } + } + } + m_rcParent = *prc; + hr = S_OK; + } + return hr; +} + +IFACEMETHODIMP BgcodePreviewHandler::DoPreview() +{ + try + { + if (m_hwndParent == NULL || (m_rcParent.left == 0 && m_rcParent.top == 0 && m_rcParent.right == 0 && m_rcParent.bottom == 0)) + { + // Postponing Start BgcodePreviewHandler.exe, parent and position not yet initialized. Preview will be done after initialisation. + return S_OK; + } + Logger::info(L"Starting BgcodePreviewHandler.exe"); + + STARTUPINFO info = { sizeof(info) }; + std::wstring cmdLine{ L"\"" + m_filePath + L"\"" }; + cmdLine += L" "; + std::wostringstream ss; + ss << std::hex << m_hwndParent; + + cmdLine += ss.str(); + cmdLine += L" "; + cmdLine += std::to_wstring(m_rcParent.left); + cmdLine += L" "; + cmdLine += std::to_wstring(m_rcParent.right); + cmdLine += L" "; + cmdLine += std::to_wstring(m_rcParent.top); + cmdLine += L" "; + cmdLine += std::to_wstring(m_rcParent.bottom); + std::wstring appPath = get_module_folderpath(g_hInst) + L"\\PowerToys.BgcodePreviewHandler.exe"; + + SHELLEXECUTEINFO sei{ sizeof(sei) }; + sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI }; + sei.lpFile = appPath.c_str(); + sei.lpParameters = cmdLine.c_str(); + sei.nShow = SW_SHOWDEFAULT; + ShellExecuteEx(&sei); + + // Prevent to leak processes: preview is called multiple times when minimizing and restoring Explorer window + if (m_process) + { + TerminateProcess(m_process, 0); + } + + m_process = sei.hProcess; + } + catch (std::exception& e) + { + std::wstring errorMessage = std::wstring{ winrt::to_hstring(e.what()) }; + Logger::error(L"Failed to start BgcodePreviewHandler.exe. Error: {}", errorMessage); + } + + return S_OK; +} + +IFACEMETHODIMP BgcodePreviewHandler::Unload() +{ + Logger::info(L"Unload and terminate .exe"); + + m_hwndParent = NULL; + TerminateProcess(m_process, 0); + return S_OK; +} + +#pragma endregion + +#pragma region IPreviewHandlerVisuals + +IFACEMETHODIMP BgcodePreviewHandler::SetBackgroundColor(COLORREF color) +{ + HBRUSH brush = CreateSolidBrush(WindowsColors::is_dark_mode() ? powerpreviewConstants::DARK_THEME_COLOR : powerpreviewConstants::LIGHT_THEME_COLOR); + SetClassLongPtr(m_hwndParent, GCLP_HBRBACKGROUND, reinterpret_cast<LONG_PTR>(brush)); + return S_OK; +} + +IFACEMETHODIMP BgcodePreviewHandler::SetFont(const LOGFONTW* plf) +{ + return S_OK; +} + +IFACEMETHODIMP BgcodePreviewHandler::SetTextColor(COLORREF color) +{ + return S_OK; +} + +#pragma endregion + +#pragma region IOleWindow + +IFACEMETHODIMP BgcodePreviewHandler::GetWindow(HWND* phwnd) +{ + HRESULT hr = E_INVALIDARG; + if (phwnd) + { + *phwnd = m_hwndParent; + hr = S_OK; + } + return hr; +} + +IFACEMETHODIMP BgcodePreviewHandler::ContextSensitiveHelp(BOOL fEnterMode) +{ + return E_NOTIMPL; +} + +#pragma endregion + +#pragma region IObjectWithSite + +IFACEMETHODIMP BgcodePreviewHandler::SetSite(IUnknown* punkSite) +{ + if (m_punkSite) + { + m_punkSite->Release(); + m_punkSite = NULL; + } + return punkSite ? punkSite->QueryInterface(&m_punkSite) : S_OK; +} + +IFACEMETHODIMP BgcodePreviewHandler::GetSite(REFIID riid, void** ppv) +{ + *ppv = NULL; + return m_punkSite ? m_punkSite->QueryInterface(riid, ppv) : E_FAIL; +} + +#pragma endregion + +#pragma region Helper Functions + +#pragma endregion diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandler.h b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandler.h new file mode 100644 index 0000000000..481eedec09 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandler.h @@ -0,0 +1,69 @@ +#pragma once + +#include "pch.h" + +#include <filesystem> +#include <ShlObj.h> +#include <string> + +class BgcodePreviewHandler : + public IInitializeWithFile, + public IPreviewHandler, + public IPreviewHandlerVisuals, + public IOleWindow, + public IObjectWithSite +{ +public: + // IUnknown + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv); + IFACEMETHODIMP_(ULONG) AddRef(); + IFACEMETHODIMP_(ULONG) Release(); + + // IInitializeWithFile + IFACEMETHODIMP Initialize(LPCWSTR pszFilePath, DWORD grfMode); + + // IPreviewHandler + IFACEMETHODIMP SetWindow(HWND hwnd, const RECT* prc); + IFACEMETHODIMP SetFocus(); + IFACEMETHODIMP QueryFocus(HWND* phwnd); + IFACEMETHODIMP TranslateAccelerator(MSG* pmsg); + IFACEMETHODIMP SetRect(const RECT* prc); + IFACEMETHODIMP DoPreview(); + IFACEMETHODIMP Unload(); + + // IPreviewHandlerVisuals + IFACEMETHODIMP SetBackgroundColor(COLORREF color); + IFACEMETHODIMP SetFont(const LOGFONTW* plf); + IFACEMETHODIMP SetTextColor(COLORREF color); + + // IOleWindow + IFACEMETHODIMP GetWindow(HWND* phwnd); + IFACEMETHODIMP ContextSensitiveHelp(BOOL fEnterMode); + + // IObjectWithSite + IFACEMETHODIMP SetSite(IUnknown* punkSite); + IFACEMETHODIMP GetSite(REFIID riid, void** ppv); + + BgcodePreviewHandler(); +protected: + ~BgcodePreviewHandler(); + +private: + // Reference count of component. + long m_cRef; + + // Provided during initialization. + std::wstring m_filePath; + + // Parent window that hosts the previewer window. + // Note: do NOT DestroyWindow this. + HWND m_hwndParent; + // Bounding rect of the parent window. + RECT m_rcParent; + + // Site pointer from host, used to get IPreviewHandlerFrame. + IUnknown* m_punkSite; + + HANDLE m_process; + HANDLE m_resizeEvent; +}; \ No newline at end of file diff --git a/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.rc b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.rc similarity index 100% rename from src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.rc rename to src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.rc diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj new file mode 100644 index 0000000000..f0284d5a2d --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj @@ -0,0 +1,119 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <VCProjectVersion>16.0</VCProjectVersion> + <Keyword>Win32Proj</Keyword> + <ProjectGuid>{F6088A11-1C9E-4420-AA90-CF7E78DD7F1C}</ProjectGuid> + <RootNamespace>BgcodePreviewHandlerCpp</RootNamespace> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> + </PropertyGroup> + <PropertyGroup> + <TargetName>PowerToys.$(ProjectName)</TargetName> + </PropertyGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_DEBUG;MARKDOWNPREVIEWHANDLERCPP_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <AdditionalIncludeDirectories>../../..</AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <EnableUAC>false</EnableUAC> + <ModuleDefinitionFile>GlobalExportFunctions.def</ModuleDefinitionFile> + <AdditionalDependencies>Shlwapi.lib;$(CoreLibraryDependencies);%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>NDEBUG;MARKDOWNPREVIEWHANDLERCPP_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <AdditionalIncludeDirectories>../../..</AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <EnableUAC>false</EnableUAC> + <ModuleDefinitionFile>GlobalExportFunctions.def</ModuleDefinitionFile> + <AdditionalDependencies>Shlwapi.lib;$(CoreLibraryDependencies);%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClInclude Include="ClassFactory.h" /> + <ClInclude Include="BgcodePreviewHandler.h" /> + <ClInclude Include="pch.h" /> + <ClInclude Include="resource.h" /> + </ItemGroup> + <ItemGroup> + <ClCompile Include="ClassFactory.cpp" /> + <ClCompile Include="dllmain.cpp" /> + <ClCompile Include="BgcodePreviewHandler.cpp" /> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> + </ClCompile> + </ItemGroup> + <ItemGroup> + <None Include="GlobalExportFunctions.def" /> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="BgcodePreviewHandlerCpp.rc" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> + <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> + <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> + </ProjectReference> + </ItemGroup> + <Import Project="$(RepoRoot)deps\spdlog.props" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + </Target> +</Project> \ No newline at end of file diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj.filters b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj.filters new file mode 100644 index 0000000000..9d6a4997b2 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/BgcodePreviewHandlerCpp.vcxproj.filters @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="ClassFactory.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="BgcodePreviewHandler.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="resource.h"> + <Filter>Resource Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <ClCompile Include="dllmain.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="pch.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="ClassFactory.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="BgcodePreviewHandler.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <None Include="GlobalExportFunctions.def"> + <Filter>Source Files</Filter> + </None> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="BgcodePreviewHandlerCpp.rc"> + <Filter>Resource Files</Filter> + </ResourceCompile> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/ClassFactory.cpp b/src/modules/previewpane/BgcodePreviewHandlerCpp/ClassFactory.cpp new file mode 100644 index 0000000000..13b07c0c71 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/ClassFactory.cpp @@ -0,0 +1,84 @@ +#include "pch.h" +#include "ClassFactory.h" +#include "BgcodePreviewHandler.h" + +#include <new> +#include <Shlwapi.h> + +extern long g_cDllRef; + +ClassFactory::ClassFactory() : + m_cRef(1) +{ + InterlockedIncrement(&g_cDllRef); +} + +ClassFactory::~ClassFactory() +{ + InterlockedDecrement(&g_cDllRef); +} + +// +// IUnknown +// + +IFACEMETHODIMP ClassFactory::QueryInterface(REFIID riid, void **ppv) +{ + static const QITAB qit[] = { + QITABENT(ClassFactory, IClassFactory), + { 0 }, + }; + return QISearch(this, qit, riid, ppv); +} + +IFACEMETHODIMP_(ULONG) ClassFactory::AddRef() +{ + return InterlockedIncrement(&m_cRef); +} + +IFACEMETHODIMP_(ULONG) ClassFactory::Release() +{ + ULONG cRef = InterlockedDecrement(&m_cRef); + if (0 == cRef) + { + delete this; + } + return cRef; +} + +// +// IClassFactory +// + +IFACEMETHODIMP ClassFactory::CreateInstance(IUnknown *pUnkOuter, REFIID riid, void **ppv) +{ + HRESULT hr = CLASS_E_NOAGGREGATION; + + if (pUnkOuter == NULL) + { + hr = E_OUTOFMEMORY; + + BgcodePreviewHandler* pExt = new (std::nothrow) BgcodePreviewHandler(); + if (pExt) + { + hr = pExt->QueryInterface(riid, ppv); + pExt->Release(); + } + } + + return hr; +} + +IFACEMETHODIMP ClassFactory::LockServer(BOOL fLock) +{ + if (fLock) + { + InterlockedIncrement(&g_cDllRef); + } + else + { + InterlockedDecrement(&g_cDllRef); + } + + return S_OK; +} \ No newline at end of file diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/ClassFactory.h b/src/modules/previewpane/BgcodePreviewHandlerCpp/ClassFactory.h new file mode 100644 index 0000000000..b393c3916e --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/ClassFactory.h @@ -0,0 +1,24 @@ +#pragma once + +#include <Unknwn.h> + +class ClassFactory : public IClassFactory +{ +public: + // IUnknown + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv); + IFACEMETHODIMP_(ULONG) AddRef(); + IFACEMETHODIMP_(ULONG) Release(); + + // IClassFactory + IFACEMETHODIMP CreateInstance(IUnknown* pUnkOuter, REFIID riid, void** ppv); + IFACEMETHODIMP LockServer(BOOL fLock); + + ClassFactory(); + +protected: + ~ClassFactory(); + +private: + long m_cRef; +}; diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/GlobalExportFunctions.def b/src/modules/previewpane/BgcodePreviewHandlerCpp/GlobalExportFunctions.def new file mode 100644 index 0000000000..76fc66cac3 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/GlobalExportFunctions.def @@ -0,0 +1,3 @@ +EXPORTS + DllGetClassObject PRIVATE + DllCanUnloadNow PRIVATE diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/dllmain.cpp b/src/modules/previewpane/BgcodePreviewHandlerCpp/dllmain.cpp new file mode 100644 index 0000000000..49ca61cdf9 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/dllmain.cpp @@ -0,0 +1,73 @@ +// dllmain.cpp : Defines the entry point for the DLL application. +#include "pch.h" +#include "ClassFactory.h" + +HINSTANCE g_hInst = NULL; +long g_cDllRef = 0; + +// {0e6d5bdd-d5f8-4692-a089-8bb88cdd37f4} +static const GUID CLSID_BgcodePreviewHandler = { 0x0e6d5bdd, 0xd5f8, 0x4692, { 0xa0, 0x89, 0x8b, 0xb8, 0x8c, 0xdd, 0x37, 0xf4 } }; + +BOOL APIENTRY DllMain(HMODULE hModule, + DWORD ul_reason_for_call, + LPVOID lpReserved) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + g_hInst = hModule; + DisableThreadLibraryCalls(hModule); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + case DLL_PROCESS_DETACH: + break; + } + return TRUE; +} + +// +// FUNCTION: DllGetClassObject +// +// PURPOSE: Create the class factory and query to the specific interface. +// +// PARAMETERS: +// * rclsid - The CLSID that will associate the correct data and code. +// * riid - A reference to the identifier of the interface that the caller +// is to use to communicate with the class object. +// * ppv - The address of a pointer variable that receives the interface +// pointer requested in riid. Upon successful return, *ppv contains the +// requested interface pointer. If an error occurs, the interface pointer +// is NULL. +// +STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, void** ppv) +{ + HRESULT hr = CLASS_E_CLASSNOTAVAILABLE; + + if (IsEqualCLSID(CLSID_BgcodePreviewHandler, rclsid)) + { + hr = E_OUTOFMEMORY; + + ClassFactory* pClassFactory = new ClassFactory(); + if (pClassFactory) + { + hr = pClassFactory->QueryInterface(riid, ppv); + pClassFactory->Release(); + } + } + + return hr; +} + +// +// FUNCTION: DllCanUnloadNow +// +// PURPOSE: Check if we can unload the component from the memory. +// +// NOTE: The component can be unloaded from the memory when its reference +// count is zero (i.e. nobody is still using the component). +// +STDAPI DllCanUnloadNow(void) +{ + return g_cDllRef > 0 ? S_FALSE : S_OK; +} diff --git a/src/modules/MouseUtils/FindMyMouse/packages.config b/src/modules/previewpane/BgcodePreviewHandlerCpp/packages.config similarity index 59% rename from src/modules/MouseUtils/FindMyMouse/packages.config rename to src/modules/previewpane/BgcodePreviewHandlerCpp/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/MouseUtils/FindMyMouse/packages.config +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/BgcodePreviewHandlerCpp/pch.cpp b/src/modules/previewpane/BgcodePreviewHandlerCpp/pch.cpp new file mode 100644 index 0000000000..64b7eef6d6 --- /dev/null +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/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/modules/FileLocksmith/FileLocksmithLib/pch.h b/src/modules/previewpane/BgcodePreviewHandlerCpp/pch.h similarity index 79% rename from src/modules/FileLocksmith/FileLocksmithLib/pch.h rename to src/modules/previewpane/BgcodePreviewHandlerCpp/pch.h index 885d5d62e4..125ddcdf24 100644 --- a/src/modules/FileLocksmith/FileLocksmithLib/pch.h +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/pch.h @@ -7,7 +7,8 @@ #ifndef PCH_H #define PCH_H -// add headers that you want to pre-compile here -#include "framework.h" +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +// Windows Header Files +#include <windows.h> #endif //PCH_H diff --git a/src/modules/cmdpal/CmdPalModuleInterface/resource.h b/src/modules/previewpane/BgcodePreviewHandlerCpp/resource.h similarity index 52% rename from src/modules/cmdpal/CmdPalModuleInterface/resource.h rename to src/modules/previewpane/BgcodePreviewHandlerCpp/resource.h index 483f62d2bc..446d0b8185 100644 --- a/src/modules/cmdpal/CmdPalModuleInterface/resource.h +++ b/src/modules/previewpane/BgcodePreviewHandlerCpp/resource.h @@ -5,9 +5,9 @@ ////////////////////////////// // Non-localizable -#define FILE_DESCRIPTION "PowerToys Command Palette Module" -#define INTERNAL_NAME "PowerToys.CmdPalModuleInterface" -#define ORIGINAL_FILENAME "PowerToys.CmdPalModuleInterface.dll" +#define FILE_DESCRIPTION "PowerToys Bgcode Preview Handler Module" +#define INTERNAL_NAME "PowerToys.BgcodePreviewHandlerCpp" +#define ORIGINAL_FILENAME "PowerToys.BgcodePreviewHandlerCpp.dll" // Non-localizable ////////////////////////////// diff --git a/src/modules/previewpane/BgcodeThumbnailProvider/BgcodeThumbnailProvider.cs b/src/modules/previewpane/BgcodeThumbnailProvider/BgcodeThumbnailProvider.cs new file mode 100644 index 0000000000..8ec7b6e631 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProvider/BgcodeThumbnailProvider.cs @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; + +using Microsoft.PowerToys.FilePreviewCommon; + +namespace Microsoft.PowerToys.ThumbnailHandler.Bgcode +{ + /// <summary> + /// Binary G-code Thumbnail Provider. + /// </summary> + public class BgcodeThumbnailProvider + { + public BgcodeThumbnailProvider(string filePath) + { + FilePath = filePath; + Stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + } + + /// <summary> + /// Gets the file path to the file creating thumbnail for. + /// </summary> + public string FilePath { get; private set; } + + /// <summary> + /// Gets the stream object to access file. + /// </summary> + public Stream Stream { get; private set; } + + /// <summary> + /// The maximum dimension (width or height) thumbnail we will generate. + /// </summary> + private const uint MaxThumbnailSize = 10000; + + /// <summary> + /// Reads the Binary G-code content searching for thumbnails and returns the largest. + /// </summary> + /// <param name="reader">The BinaryReader instance for the Binary G-code content.</param> + /// <param name="cx">The maximum thumbnail size, in pixels.</param> + /// <returns>A thumbnail extracted from the Binary G-code content.</returns> + public static Bitmap GetThumbnail(BinaryReader reader, uint cx) + { + if (cx > MaxThumbnailSize || reader == null || reader.BaseStream.Length == 0) + { + return null; + } + + Bitmap thumbnail = null; + + try + { + var bgcodeThumbnail = BgcodeHelper.GetBestThumbnail(reader); + + thumbnail = bgcodeThumbnail?.GetBitmap(); + } + catch (Exception) + { + // TODO: add logger + } + + if (thumbnail != null && ( + ((thumbnail.Width != cx || thumbnail.Height > cx) && (thumbnail.Height != cx || thumbnail.Width > cx)) || + thumbnail.PixelFormat != PixelFormat.Format32bppArgb)) + { + // We are not the appropriate size for caller. Resize now while + // respecting the aspect ratio. + float scale = Math.Min((float)cx / thumbnail.Width, (float)cx / thumbnail.Height); + int scaleWidth = (int)(thumbnail.Width * scale); + int scaleHeight = (int)(thumbnail.Height * scale); + thumbnail = ResizeImage(thumbnail, scaleWidth, scaleHeight); + } + + return thumbnail; + } + + /// <summary> + /// Resize the image with high quality to the specified width and height. + /// </summary> + /// <param name="image">The image to resize.</param> + /// <param name="width">The width to resize to.</param> + /// <param name="height">The height to resize to.</param> + /// <returns>The resized image.</returns> + public static Bitmap ResizeImage(Image image, int width, int height) + { + if (width <= 0 || + height <= 0 || + width > MaxThumbnailSize || + height > MaxThumbnailSize || + image == null) + { + return null; + } + + Bitmap destImage = new Bitmap(width, height, PixelFormat.Format32bppArgb); + + destImage.SetResolution(image.HorizontalResolution, image.VerticalResolution); + + using (var graphics = Graphics.FromImage(destImage)) + { + graphics.CompositingMode = CompositingMode.SourceCopy; + graphics.CompositingQuality = CompositingQuality.HighQuality; + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.SmoothingMode = SmoothingMode.HighQuality; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + + graphics.DrawImage(image, 0, 0, width, height); + } + + image.Dispose(); + + return destImage; + } + + /// <summary> + /// Generate thumbnail bitmap for provided Bgcode file/stream. + /// </summary> + /// <param name="cx">Maximum thumbnail size, in pixels.</param> + /// <returns>Generated bitmap</returns> + public Bitmap GetThumbnail(uint cx) + { + if (cx == 0 || cx > MaxThumbnailSize) + { + return null; + } + + if (global::PowerToys.GPOWrapper.GPOWrapper.GetConfiguredBgcodeThumbnailsEnabledValue() == global::PowerToys.GPOWrapper.GpoRuleConfigured.Disabled) + { + // GPO is disabling this utility. + return null; + } + + using (var reader = new BinaryReader(this.Stream)) + { + Bitmap thumbnail = GetThumbnail(reader, cx); + if (thumbnail != null && thumbnail.Size.Width > 0 && thumbnail.Size.Height > 0) + { + return thumbnail; + } + } + + return null; + } + } +} diff --git a/src/modules/previewpane/BgcodeThumbnailProvider/BgcodeThumbnailProvider.csproj b/src/modules/previewpane/BgcodeThumbnailProvider/BgcodeThumbnailProvider.csproj new file mode 100644 index 0000000000..1899ba6981 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProvider/BgcodeThumbnailProvider.csproj @@ -0,0 +1,43 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <UseWindowsForms>true</UseWindowsForms> + <ProjectGuid>{9BC1C986-1E97-4D07-A7B1-CE226C239EFA}</ProjectGuid> + <RootNamespace>Microsoft.PowerToys.ThumbnailHandler.Bgcode</RootNamespace> + <AssemblyName>PowerToys.BgcodeThumbnailProvider</AssemblyName> + <AssemblyTitle>PowerToys.BgcodeThumbnailProvider</AssemblyTitle> + <AssemblyDescription>PowerToys BgcodePreviewHandler</AssemblyDescription> + <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> + <Description>PowerToys BgcodePreviewHandler</Description> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> + </PropertyGroup> + + <!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info --> + <PropertyGroup> + <CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes> + <CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir> + <ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles> + </PropertyGroup> + + <PropertyGroup> + <!-- Disable missing comment warning. WinRT/C++ libraries added won't have comments on their reflections. --> + <NoWarn>$(NoWarn);1591</NoWarn> + <OutputType>WinExe</OutputType> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\..\common\FilePreviewCommon\FilePreviewCommon.csproj" /> + <ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" /> + <ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> + <ProjectReference Include="..\Common\PreviewHandlerCommon.csproj" /> + <ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" /> + </ItemGroup> + +</Project> \ No newline at end of file diff --git a/src/modules/previewpane/BgcodeThumbnailProvider/Program.cs b/src/modules/previewpane/BgcodeThumbnailProvider/Program.cs new file mode 100644 index 0000000000..debc9c298b --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProvider/Program.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.Globalization; + +namespace Microsoft.PowerToys.ThumbnailHandler.Bgcode +{ + internal static class Program + { + private static BgcodeThumbnailProvider _thumbnailProvider; + + /// <summary> + /// The main entry point for the application. + /// </summary> + [STAThread] + public static void Main(string[] args) + { + ApplicationConfiguration.Initialize(); + if (args != null) + { + if (args.Length == 2) + { + string filePath = args[0]; + uint cx = Convert.ToUInt32(args[1], 10); + + _thumbnailProvider = new BgcodeThumbnailProvider(filePath); + Bitmap thumbnail = _thumbnailProvider.GetThumbnail(cx); + if (thumbnail != null) + { + filePath = filePath.Replace(".bgcode", ".bmp"); + thumbnail.Save(filePath, System.Drawing.Imaging.ImageFormat.Bmp); + } + } + else + { + MessageBox.Show("Bgcode thumbnail - wrong number of args: " + args.Length.ToString(CultureInfo.InvariantCulture)); + } + } + } + } +} diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProvider.cpp b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProvider.cpp new file mode 100644 index 0000000000..b0f2992fe7 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProvider.cpp @@ -0,0 +1,201 @@ +#include "pch.h" +#include "BgcodeThumbnailProvider.h" + +#include <filesystem> +#include <fstream> +#include <shellapi.h> +#include <Shlwapi.h> +#include <string> + +#include <wil/com.h> + +#include <common/utils/process_path.h> +#include <common/interop/shared_constants.h> +#include <common/logger/logger.h> +#include <common/SettingsAPI/settings_helpers.h> +#include <common/utils/process_path.h> + +extern HINSTANCE g_hInst; +extern long g_cDllRef; + +BgcodeThumbnailProvider::BgcodeThumbnailProvider() : + m_cRef(1), m_pStream(NULL), m_process(NULL) +{ + std::filesystem::path logFilePath(PTSettingsHelper::get_local_low_folder_location()); + logFilePath.append(LogSettings::bgcodeThumbLogPath); + Logger::init(LogSettings::bgcodeThumbLoggerName, logFilePath.wstring(), PTSettingsHelper::get_log_settings_file_location()); + + InterlockedIncrement(&g_cDllRef); +} + +BgcodeThumbnailProvider::~BgcodeThumbnailProvider() +{ + InterlockedDecrement(&g_cDllRef); +} + +#pragma region IUnknown + +IFACEMETHODIMP BgcodeThumbnailProvider::QueryInterface(REFIID riid, void** ppv) +{ + static const QITAB qit[] = { + QITABENT(BgcodeThumbnailProvider, IThumbnailProvider), + QITABENT(BgcodeThumbnailProvider, IInitializeWithStream), + { 0 }, + }; + return QISearch(this, qit, riid, ppv); +} + +IFACEMETHODIMP_(ULONG) +BgcodeThumbnailProvider::AddRef() +{ + return InterlockedIncrement(&m_cRef); +} + +IFACEMETHODIMP_(ULONG) +BgcodeThumbnailProvider::Release() +{ + ULONG cRef = InterlockedDecrement(&m_cRef); + if (0 == cRef) + { + delete this; + } + return cRef; +} + +#pragma endregion + +#pragma region IInitializationWithStream + +IFACEMETHODIMP BgcodeThumbnailProvider::Initialize(IStream* pStream, DWORD grfMode) +{ + HRESULT hr = E_INVALIDARG; + if (pStream) + { + // Initialize can be called more than once, so release existing valid + // m_pStream. + if (m_pStream) + { + m_pStream->Release(); + m_pStream = NULL; + } + + m_pStream = pStream; + m_pStream->AddRef(); + hr = S_OK; + } + return hr; +} + +#pragma endregion + +#pragma region IThumbnailProvider + +IFACEMETHODIMP BgcodeThumbnailProvider::GetThumbnail(UINT cx, HBITMAP* phbmp, WTS_ALPHATYPE* pdwAlpha) +{ + // Read stream into the buffer + char buffer[4096]; + ULONG cbRead; + + Logger::trace(L"Begin"); + + GUID guid; + if (CoCreateGuid(&guid) == S_OK) + { + wil::unique_cotaskmem_string guidString; + if (SUCCEEDED(StringFromCLSID(guid, &guidString))) + { + Logger::info(L"Read stream and save to tmp file."); + + // {CLSID} -> CLSID + std::wstring guid = std::wstring(guidString.get()).substr(1, std::wstring(guidString.get()).size() - 2); + std::wstring filePath = PTSettingsHelper::get_local_low_folder_location() + L"\\BgcodeThumbnail-Temp\\"; + if (!std::filesystem::exists(filePath)) + { + std::filesystem::create_directories(filePath); + } + + std::wstring fileName = filePath + guid + L".bgcode"; + + // Write data to tmp file + std::fstream file; + file.open(fileName, std::ios_base::out | std::ios_base::binary); + + if (!file.is_open()) + { + return 0; + } + + while (true) + { + auto result = m_pStream->Read(buffer, 4096, &cbRead); + + file.write(buffer, cbRead); + if (result == S_FALSE) + { + break; + } + } + file.close(); + + m_pStream->Release(); + m_pStream = NULL; + + try + { + Logger::info(L"Start BgcodeThumbnailProvider.exe"); + + STARTUPINFO info = { sizeof(info) }; + std::wstring cmdLine{ L"\"" + fileName + L"\"" }; + cmdLine += L" "; + cmdLine += std::to_wstring(cx); + + std::wstring appPath = get_module_folderpath(g_hInst) + L"\\PowerToys.BgcodeThumbnailProvider.exe"; + + SHELLEXECUTEINFO sei{ sizeof(sei) }; + sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI }; + sei.lpFile = appPath.c_str(); + sei.lpParameters = cmdLine.c_str(); + sei.nShow = SW_SHOWDEFAULT; + ShellExecuteEx(&sei); + m_process = sei.hProcess; + WaitForSingleObject(m_process, INFINITE); + std::filesystem::remove(fileName); + + + std::wstring fileNameBmp = filePath + guid + L".bmp"; + if (std::filesystem::exists(fileNameBmp)) + { + *phbmp = static_cast<HBITMAP>(LoadImage(NULL, fileNameBmp.c_str(), IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE)); + *pdwAlpha = WTS_ALPHATYPE::WTSAT_ARGB; + std::filesystem::remove(fileNameBmp); + } + else + { + Logger::info(L"Bmp file not generated."); + return E_FAIL; + } + } + catch (std::exception& e) + { + std::wstring errorMessage = std::wstring{ winrt::to_hstring(e.what()) }; + Logger::error(L"Failed to start BgcodeThumbnailProvider.exe. Error: {}", errorMessage); + } + } + } + + // ensure releasing the stream (not all if branches contain it) + if (m_pStream) + { + m_pStream->Release(); + m_pStream = NULL; + } + + return S_OK; +} + + +#pragma endregion + +#pragma region Helper Functions + +#pragma endregion diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProvider.h b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProvider.h new file mode 100644 index 0000000000..56bac9b1fc --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProvider.h @@ -0,0 +1,37 @@ +#pragma once + +#include "pch.h" + +#include <ShlObj.h> +#include <string> +#include <thumbcache.h> + +class BgcodeThumbnailProvider : + public IInitializeWithStream, + public IThumbnailProvider +{ +public: + // IUnknown + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv); + IFACEMETHODIMP_(ULONG) AddRef(); + IFACEMETHODIMP_(ULONG) Release(); + + // IInitializeWithStream + IFACEMETHODIMP Initialize(IStream* pstream, DWORD grfMode); + + // IThumbnailProvider + IFACEMETHODIMP GetThumbnail(UINT cx, HBITMAP* phbmp, WTS_ALPHATYPE* pdwAlpha); + + BgcodeThumbnailProvider(); +protected: + ~BgcodeThumbnailProvider(); + +private: + // Reference count of component. + long m_cRef; + + // Provided during initialization. + IStream* m_pStream; + + HANDLE m_process; +}; \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/version.rc b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.rc similarity index 95% rename from src/modules/cmdpal/Microsoft.Terminal.UI/version.rc rename to src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.rc index 0bcdeca2ef..5fa3c8b90d 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/version.rc +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.rc @@ -1,6 +1,6 @@ #include <windows.h> #include "resource.h" -#include "../../../../common/version/version.h" +#include "../../../common/version/version.h" #define APSTUDIO_READONLY_SYMBOLS #include "winres.h" diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj new file mode 100644 index 0000000000..389c1ccf92 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj @@ -0,0 +1,118 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> + <PropertyGroup Label="Globals"> + <VCProjectVersion>16.0</VCProjectVersion> + <Keyword>Win32Proj</Keyword> + <ProjectGuid>{47B0678C-806B-4FE1-9F50-46BA88989532}</ProjectGuid> + <RootNamespace>BgcodeThumbnailProviderCpp</RootNamespace> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> + <ConfigurationType>DynamicLibrary</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> + </PropertyGroup> + <PropertyGroup> + <TargetName>PowerToys.$(ProjectName)</TargetName> + </PropertyGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_DEBUG;MARKDOWNPREVIEWHANDLERCPP_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <AdditionalIncludeDirectories>../../..</AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <EnableUAC>false</EnableUAC> + <ModuleDefinitionFile>GlobalExportFunctions.def</ModuleDefinitionFile> + <AdditionalDependencies>Shlwapi.lib;$(CoreLibraryDependencies);%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)'=='Release'"> + <ClCompile> + <WarningLevel>Level3</WarningLevel> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>NDEBUG;MARKDOWNPREVIEWHANDLERCPP_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <AdditionalIncludeDirectories>../../..</AdditionalIncludeDirectories> + </ClCompile> + <Link> + <SubSystem>Windows</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <EnableUAC>false</EnableUAC> + <ModuleDefinitionFile>GlobalExportFunctions.def</ModuleDefinitionFile> + <AdditionalDependencies>Shlwapi.lib;$(CoreLibraryDependencies);%(AdditionalDependencies)</AdditionalDependencies> + </Link> + </ItemDefinitionGroup> + <ItemGroup> + <ClInclude Include="ClassFactory.h" /> + <ClInclude Include="BgcodeThumbnailProvider.h" /> + <ClInclude Include="pch.h" /> + <ClInclude Include="resource.h" /> + </ItemGroup> + <ItemGroup> + <ClCompile Include="ClassFactory.cpp" /> + <ClCompile Include="dllmain.cpp" /> + <ClCompile Include="BgcodeThumbnailProvider.cpp" /> + <ClCompile Include="pch.cpp"> + <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> + </ClCompile> + </ItemGroup> + <ItemGroup> + <None Include="GlobalExportFunctions.def" /> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> + <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> + </ProjectReference> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> + <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="BgcodeThumbnailProviderCpp.rc" /> + </ItemGroup> + <Import Project="$(RepoRoot)deps\spdlog.props" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + </ImportGroup> + <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> + <PropertyGroup> + <ErrorText>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}.</ErrorText> + </PropertyGroup> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + </Target> +</Project> \ No newline at end of file diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj.filters b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj.filters new file mode 100644 index 0000000000..3ce6ff0db6 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/BgcodeThumbnailProviderCpp.vcxproj.filters @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <ClInclude Include="pch.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="ClassFactory.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="BgcodeThumbnailProvider.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="resource.h"> + <Filter>Resource Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <ClCompile Include="dllmain.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="pch.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="ClassFactory.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="BgcodeThumbnailProvider.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <None Include="GlobalExportFunctions.def"> + <Filter>Source Files</Filter> + </None> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" /> + </ItemGroup> + <ItemGroup> + <ResourceCompile Include="BgcodeThumbnailProviderCpp.rc"> + <Filter>Resource Files</Filter> + </ResourceCompile> + </ItemGroup> +</Project> \ No newline at end of file diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/ClassFactory.cpp b/src/modules/previewpane/BgcodeThumbnailProviderCpp/ClassFactory.cpp new file mode 100644 index 0000000000..9196db6c19 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/ClassFactory.cpp @@ -0,0 +1,84 @@ +#include "pch.h" +#include "ClassFactory.h" +#include "BgcodeThumbnailProvider.h" + +#include <new> +#include <Shlwapi.h> + +extern long g_cDllRef; + +ClassFactory::ClassFactory() : + m_cRef(1) +{ + InterlockedIncrement(&g_cDllRef); +} + +ClassFactory::~ClassFactory() +{ + InterlockedDecrement(&g_cDllRef); +} + +// +// IUnknown +// + +IFACEMETHODIMP ClassFactory::QueryInterface(REFIID riid, void **ppv) +{ + static const QITAB qit[] = { + QITABENT(ClassFactory, IClassFactory), + { 0 }, + }; + return QISearch(this, qit, riid, ppv); +} + +IFACEMETHODIMP_(ULONG) ClassFactory::AddRef() +{ + return InterlockedIncrement(&m_cRef); +} + +IFACEMETHODIMP_(ULONG) ClassFactory::Release() +{ + ULONG cRef = InterlockedDecrement(&m_cRef); + if (0 == cRef) + { + delete this; + } + return cRef; +} + +// +// IClassFactory +// + +IFACEMETHODIMP ClassFactory::CreateInstance(IUnknown *pUnkOuter, REFIID riid, void **ppv) +{ + HRESULT hr = CLASS_E_NOAGGREGATION; + + if (pUnkOuter == NULL) + { + hr = E_OUTOFMEMORY; + + BgcodeThumbnailProvider* pExt = new (std::nothrow) BgcodeThumbnailProvider(); + if (pExt) + { + hr = pExt->QueryInterface(riid, ppv); + pExt->Release(); + } + } + + return hr; +} + +IFACEMETHODIMP ClassFactory::LockServer(BOOL fLock) +{ + if (fLock) + { + InterlockedIncrement(&g_cDllRef); + } + else + { + InterlockedDecrement(&g_cDllRef); + } + + return S_OK; +} \ No newline at end of file diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/ClassFactory.h b/src/modules/previewpane/BgcodeThumbnailProviderCpp/ClassFactory.h new file mode 100644 index 0000000000..b393c3916e --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/ClassFactory.h @@ -0,0 +1,24 @@ +#pragma once + +#include <Unknwn.h> + +class ClassFactory : public IClassFactory +{ +public: + // IUnknown + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv); + IFACEMETHODIMP_(ULONG) AddRef(); + IFACEMETHODIMP_(ULONG) Release(); + + // IClassFactory + IFACEMETHODIMP CreateInstance(IUnknown* pUnkOuter, REFIID riid, void** ppv); + IFACEMETHODIMP LockServer(BOOL fLock); + + ClassFactory(); + +protected: + ~ClassFactory(); + +private: + long m_cRef; +}; diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/GlobalExportFunctions.def b/src/modules/previewpane/BgcodeThumbnailProviderCpp/GlobalExportFunctions.def new file mode 100644 index 0000000000..76fc66cac3 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/GlobalExportFunctions.def @@ -0,0 +1,3 @@ +EXPORTS + DllGetClassObject PRIVATE + DllCanUnloadNow PRIVATE diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/dllmain.cpp b/src/modules/previewpane/BgcodeThumbnailProviderCpp/dllmain.cpp new file mode 100644 index 0000000000..4c28da5c6d --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/dllmain.cpp @@ -0,0 +1,73 @@ +// dllmain.cpp : Defines the entry point for the DLL application. +#include "pch.h" +#include "ClassFactory.h" + +HINSTANCE g_hInst = NULL; +long g_cDllRef = 0; + +// {5c93a1e4-99d0-4fb3-991c-6c296a27be21} +static const GUID CLSID_BgcodeThumbnailProvider = { 0x5c93a1e4, 0x99d0, 0x4fb3, { 0x99, 0x1c, 0x6c, 0x29, 0x6a, 0x27, 0xbe, 0x21 } }; + +BOOL APIENTRY DllMain(HMODULE hModule, + DWORD ul_reason_for_call, + LPVOID lpReserved) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + g_hInst = hModule; + DisableThreadLibraryCalls(hModule); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + case DLL_PROCESS_DETACH: + break; + } + return TRUE; +} + +// +// FUNCTION: DllGetClassObject +// +// PURPOSE: Create the class factory and query to the specific interface. +// +// PARAMETERS: +// * rclsid - The CLSID that will associate the correct data and code. +// * riid - A reference to the identifier of the interface that the caller +// is to use to communicate with the class object. +// * ppv - The address of a pointer variable that receives the interface +// pointer requested in riid. Upon successful return, *ppv contains the +// requested interface pointer. If an error occurs, the interface pointer +// is NULL. +// +STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, void** ppv) +{ + HRESULT hr = CLASS_E_CLASSNOTAVAILABLE; + + if (IsEqualCLSID(CLSID_BgcodeThumbnailProvider, rclsid)) + { + hr = E_OUTOFMEMORY; + + ClassFactory* pClassFactory = new ClassFactory(); + if (pClassFactory) + { + hr = pClassFactory->QueryInterface(riid, ppv); + pClassFactory->Release(); + } + } + + return hr; +} + +// +// FUNCTION: DllCanUnloadNow +// +// PURPOSE: Check if we can unload the component from the memory. +// +// NOTE: The component can be unloaded from the memory when its reference +// count is zero (i.e. nobody is still using the component). +// +STDAPI DllCanUnloadNow(void) +{ + return g_cDllRef > 0 ? S_FALSE : S_OK; +} diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/packages.config b/src/modules/previewpane/BgcodeThumbnailProviderCpp/packages.config new file mode 100644 index 0000000000..d3882436a5 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/packages.config @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> + <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> +</packages> \ No newline at end of file diff --git a/src/modules/previewpane/BgcodeThumbnailProviderCpp/pch.cpp b/src/modules/previewpane/BgcodeThumbnailProviderCpp/pch.cpp new file mode 100644 index 0000000000..64b7eef6d6 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/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/modules/previewpane/BgcodeThumbnailProviderCpp/pch.h b/src/modules/previewpane/BgcodeThumbnailProviderCpp/pch.h new file mode 100644 index 0000000000..8a0d004247 --- /dev/null +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/pch.h @@ -0,0 +1,15 @@ +// 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 + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +// Windows Header Files +#include <windows.h> +#include <winrt/base.h> + +#endif //PCH_H diff --git a/src/modules/cmdpal/Microsoft.Terminal.UI/resource.h b/src/modules/previewpane/BgcodeThumbnailProviderCpp/resource.h similarity index 51% rename from src/modules/cmdpal/Microsoft.Terminal.UI/resource.h rename to src/modules/previewpane/BgcodeThumbnailProviderCpp/resource.h index 85ae1a0e9b..911c12832c 100644 --- a/src/modules/cmdpal/Microsoft.Terminal.UI/resource.h +++ b/src/modules/previewpane/BgcodeThumbnailProviderCpp/resource.h @@ -5,9 +5,9 @@ ////////////////////////////// // Non-localizable -#define FILE_DESCRIPTION "PowerToys Command Palette Terminal UI" -#define INTERNAL_NAME "Microsoft.Terminal.UI" -#define ORIGINAL_FILENAME "Microsoft.Terminal.UI.dll" +#define FILE_DESCRIPTION "PowerToys Bgcode Thumbnail Provider Module" +#define INTERNAL_NAME "PowerToys.BgcodeThumbnailProviderCpp" +#define ORIGINAL_FILENAME "PowerToys.BgcodeThumbnailProviderCpp.dll" // Non-localizable ////////////////////////////// diff --git a/src/modules/previewpane/GcodePreviewHandler/GcodePreviewHandler.csproj b/src/modules/previewpane/GcodePreviewHandler/GcodePreviewHandler.csproj index 51bf6f1f7e..2a0947dc81 100644 --- a/src/modules/previewpane/GcodePreviewHandler/GcodePreviewHandler.csproj +++ b/src/modules/previewpane/GcodePreviewHandler/GcodePreviewHandler.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -10,7 +10,7 @@ <AssemblyDescription>PowerToys GcodePreviewHandler</AssemblyDescription> <Description>PowerToys GcodePreviewHandler</Description> <DocumentationFile>..\..\..\..\$(Platform)\$(Configuration)\GcodePreviewPaneDocumentation.xml</DocumentationFile> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> diff --git a/src/modules/previewpane/GcodePreviewHandler/GcodePreviewHandlerControl.cs b/src/modules/previewpane/GcodePreviewHandler/GcodePreviewHandlerControl.cs index 67c6d9f42e..b210612b4e 100644 --- a/src/modules/previewpane/GcodePreviewHandler/GcodePreviewHandlerControl.cs +++ b/src/modules/previewpane/GcodePreviewHandler/GcodePreviewHandlerControl.cs @@ -25,7 +25,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Gcode private RichTextBox _textBox; /// <summary> - /// Represent if an text box info bar is added for showing message. + /// Represent if a text box info bar is added for showing message. /// </summary> private bool _infoBarAdded; diff --git a/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFileHandlerLoaded.cs b/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFileHandlerLoaded.cs index c1d1fba40a..81d0ab352e 100644 --- a/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFileHandlerLoaded.cs +++ b/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFileHandlerLoaded.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Gcode.Telemetry.Events /// A telemetry event to be raised when a svg file has been viewed in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class GcodeFileHandlerLoaded : EventBase, IEvent { /// <inheritdoc/> diff --git a/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFilePreviewError.cs b/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFilePreviewError.cs index 9e9a232e04..64e81ebcbd 100644 --- a/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFilePreviewError.cs +++ b/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFilePreviewError.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Gcode.Telemetry.Events /// A telemetry event to be raised when an error has occurred in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class GcodeFilePreviewError : EventBase, IEvent { /// <summary> diff --git a/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFilePreviewed.cs b/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFilePreviewed.cs index 693e314e40..69bb66e5b4 100644 --- a/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFilePreviewed.cs +++ b/src/modules/previewpane/GcodePreviewHandler/Telemetry/Events/GcodeFilePreviewed.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Gcode.Telemetry.Events /// A telemetry event to be raised when a svg file has been viewed in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class GcodeFilePreviewed : EventBase, IEvent { /// <inheritdoc/> diff --git a/src/modules/previewpane/GcodePreviewHandlerCpp/GcodePreviewHandlerCpp.vcxproj b/src/modules/previewpane/GcodePreviewHandlerCpp/GcodePreviewHandlerCpp.vcxproj index 6123215de5..9318a52652 100644 --- a/src/modules/previewpane/GcodePreviewHandlerCpp/GcodePreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/GcodePreviewHandlerCpp/GcodePreviewHandlerCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{5a5dd09d-723a-44d3-8f2b-293584c3d731}</ProjectGuid> <RootNamespace>GcodePreviewHandlerCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -94,26 +94,26 @@ <ResourceCompile Include="GcodePreviewHandlerCpp.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/GcodePreviewHandlerCpp/packages.config b/src/modules/previewpane/GcodePreviewHandlerCpp/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/previewpane/GcodePreviewHandlerCpp/packages.config +++ b/src/modules/previewpane/GcodePreviewHandlerCpp/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/GcodeThumbnailProvider/GcodeThumbnailProvider.csproj b/src/modules/previewpane/GcodeThumbnailProvider/GcodeThumbnailProvider.csproj index 9073a94931..dad5b10b44 100644 --- a/src/modules/previewpane/GcodeThumbnailProvider/GcodeThumbnailProvider.csproj +++ b/src/modules/previewpane/GcodeThumbnailProvider/GcodeThumbnailProvider.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -13,7 +13,7 @@ <AssemblyDescription>PowerToys GcodePreviewHandler</AssemblyDescription> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <Description>PowerToys GcodePreviewHandler</Description> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> diff --git a/src/modules/previewpane/GcodeThumbnailProviderCpp/GcodeThumbnailProviderCpp.vcxproj b/src/modules/previewpane/GcodeThumbnailProviderCpp/GcodeThumbnailProviderCpp.vcxproj index 6bb6a12661..0ae4f692fb 100644 --- a/src/modules/previewpane/GcodeThumbnailProviderCpp/GcodeThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/GcodeThumbnailProviderCpp/GcodeThumbnailProviderCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{56cc2f10-6e41-453d-be16-c593a5e58482}</ProjectGuid> <RootNamespace>GcodeThumbnailProviderCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -91,28 +91,28 @@ <None Include="packages.config" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> <ItemGroup> <ResourceCompile Include="GcodeThumbnailProviderCpp.rc" /> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/GcodeThumbnailProviderCpp/packages.config b/src/modules/previewpane/GcodeThumbnailProviderCpp/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/previewpane/GcodeThumbnailProviderCpp/packages.config +++ b/src/modules/previewpane/GcodeThumbnailProviderCpp/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj b/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj index 6d0fe10677..6dc4006e53 100644 --- a/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj +++ b/src/modules/previewpane/MarkdownPreviewHandler/MarkdownPreviewHandler.csproj @@ -1,8 +1,8 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> - <Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -11,7 +11,7 @@ <AssemblyDescription>PowerToys MarkdownPreviewHandler</AssemblyDescription> <Description>PowerToys MarkdownPreviewHandler</Description> <DocumentationFile>..\..\..\..\$(Platform)\$(Configuration)\MarkdownPreviewPaneDocumentation.xml</DocumentationFile> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> diff --git a/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFileHandlerLoaded.cs b/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFileHandlerLoaded.cs index dec9852dec..36139dbaea 100644 --- a/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFileHandlerLoaded.cs +++ b/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFileHandlerLoaded.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown.Telemetry.Events /// A telemetry event that is triggered when a markdown file is viewed in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MarkdownFileHandlerLoaded : EventBase, IEvent { /// <inheritdoc/> diff --git a/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFilePreviewError.cs b/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFilePreviewError.cs index 95268b1619..d665c1b0f6 100644 --- a/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFilePreviewError.cs +++ b/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFilePreviewError.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -10,6 +11,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown.Telemetry.Events /// <summary> /// A telemetry event that is triggered when an error occurs while attempting to view a markdown file in the preview pane. /// </summary> + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MarkdownFilePreviewError : EventBase, IEvent { /// <summary> diff --git a/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFilePreviewed.cs b/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFilePreviewed.cs index e38d2d38ca..f8b869a300 100644 --- a/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFilePreviewed.cs +++ b/src/modules/previewpane/MarkdownPreviewHandler/Telemetry/Events/MarkdownFilePreviewed.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Markdown.Telemetry.Events /// A telemetry event that is triggered when a markdown file is viewed in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class MarkdownFilePreviewed : EventBase, IEvent { /// <inheritdoc/> diff --git a/src/modules/previewpane/MarkdownPreviewHandlerCpp/MarkdownPreviewHandlerCpp.vcxproj b/src/modules/previewpane/MarkdownPreviewHandlerCpp/MarkdownPreviewHandlerCpp.vcxproj index 3c42d80bc0..82fbf6d2cb 100644 --- a/src/modules/previewpane/MarkdownPreviewHandlerCpp/MarkdownPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/MarkdownPreviewHandlerCpp/MarkdownPreviewHandlerCpp.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h markdownpreviewhandler.base.rc markdownpreviewhandler.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h markdownpreviewhandler.base.rc markdownpreviewhandler.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> @@ -10,17 +11,16 @@ <ProjectGuid>{ed9a1ac6-aeb0-4569-a6e9-e1696182b545}</ProjectGuid> <RootNamespace>MarkdownPreviewHandlerCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -34,7 +34,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> <CopyCppRuntimeToOutputDir>true</CopyCppRuntimeToOutputDir> </PropertyGroup> <PropertyGroup> @@ -100,29 +100,29 @@ <None Include="markdownpreviewhandler.base.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> <ItemGroup> <None Include="Resources.resx" /> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/MarkdownPreviewHandlerCpp/packages.config b/src/modules/previewpane/MarkdownPreviewHandlerCpp/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/previewpane/MarkdownPreviewHandlerCpp/packages.config +++ b/src/modules/previewpane/MarkdownPreviewHandlerCpp/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/MonacoPreviewHandler/MonacoPreviewHandler.csproj b/src/modules/previewpane/MonacoPreviewHandler/MonacoPreviewHandler.csproj index 5753ad910d..adc89ae1f6 100644 --- a/src/modules/previewpane/MonacoPreviewHandler/MonacoPreviewHandler.csproj +++ b/src/modules/previewpane/MonacoPreviewHandler/MonacoPreviewHandler.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -9,7 +9,7 @@ <AssemblyTitle>PowerToys.MonacoPreviewHandler</AssemblyTitle> <AssemblyDescription>PowerToys MonacoPreviewHandler</AssemblyDescription> <Description>PowerToys MonacoPreviewHandler</Description> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> diff --git a/src/modules/previewpane/MonacoPreviewHandler/MonacoPreviewHandlerControl.cs b/src/modules/previewpane/MonacoPreviewHandler/MonacoPreviewHandlerControl.cs index 68dde66e3c..1cb007c98a 100644 --- a/src/modules/previewpane/MonacoPreviewHandler/MonacoPreviewHandlerControl.cs +++ b/src/modules/previewpane/MonacoPreviewHandler/MonacoPreviewHandlerControl.cs @@ -29,7 +29,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Monaco private RichTextBox _textBox; /// <summary> - /// Represent if an text box info bar is added for showing message. + /// Represent if a text box info bar is added for showing message. /// </summary> private bool _infoBarAdded; @@ -364,7 +364,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Monaco DetectionResult result = CharsetDetector.DetectFromFile(filePath); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - // Check if the detected encoding is not null, otherwise default to UTF-8 + // Check if the detected encoding is not null; otherwise, default to UTF-8 Encoding encodingToUse = result.Detected?.Encoding ?? Encoding.UTF8; using (StreamReader fileReader = new StreamReader(new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite), encodingToUse)) @@ -389,7 +389,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Monaco } fileReader.Close(); - _base64FileCode = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(fileContent)); + _base64FileCode = Convert.ToBase64String(encodingToUse.GetBytes(fileContent)); Logger.LogInfo("Reading requested file ended"); } diff --git a/src/modules/previewpane/MonacoPreviewHandler/Settings.cs b/src/modules/previewpane/MonacoPreviewHandler/Settings.cs index 94eeab308f..80ee876104 100644 --- a/src/modules/previewpane/MonacoPreviewHandler/Settings.cs +++ b/src/modules/previewpane/MonacoPreviewHandler/Settings.cs @@ -16,7 +16,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Monaco /// </summary> public class Settings { - private static SettingsUtils moduleSettings = new SettingsUtils(); + private static SettingsUtils moduleSettings = SettingsUtils.Default; /// <summary> /// Gets a value indicating whether word wrapping should be applied. Set by PT settings. diff --git a/src/modules/previewpane/MonacoPreviewHandlerCpp/MonacoPreviewHandlerCpp.vcxproj b/src/modules/previewpane/MonacoPreviewHandlerCpp/MonacoPreviewHandlerCpp.vcxproj index dbe3cfced4..1debe06f0b 100644 --- a/src/modules/previewpane/MonacoPreviewHandlerCpp/MonacoPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/MonacoPreviewHandlerCpp/MonacoPreviewHandlerCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{b3e869c4-8210-4ebd-a621-ff4c4afcbfa9}</ProjectGuid> <RootNamespace>MonacoPreviewHandlerCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -94,26 +94,26 @@ <ResourceCompile Include="MonacoPreviewHandlerCpp.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/MonacoPreviewHandlerCpp/packages.config b/src/modules/previewpane/MonacoPreviewHandlerCpp/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/previewpane/MonacoPreviewHandlerCpp/packages.config +++ b/src/modules/previewpane/MonacoPreviewHandlerCpp/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandler.csproj b/src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandler.csproj index 069ce76d8f..9997442f39 100644 --- a/src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandler.csproj +++ b/src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandler.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -10,7 +10,7 @@ <AssemblyDescription>PowerToys PdfPreviewHandler</AssemblyDescription> <Description>PowerToys PdfPreviewHandler</Description> <DocumentationFile>..\..\..\..\$(Platform)\$(Configuration)\PdfPreviewPaneDocumentation.xml</DocumentationFile> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> diff --git a/src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandlerControl.cs b/src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandlerControl.cs index 4068567985..9de84327ef 100644 --- a/src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandlerControl.cs +++ b/src/modules/previewpane/PdfPreviewHandler/PdfPreviewHandlerControl.cs @@ -228,6 +228,8 @@ namespace Microsoft.PowerToys.PreviewHandler.Pdf DestinationWidth = (uint)this.ClientSize.Width, }).GetAwaiter().GetResult(); + stream.Seek(0); // Reset the stream position to the beginning before reading. + imageOfPage = Image.FromStream(stream.AsStream()); } diff --git a/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFileHandlerLoaded.cs b/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFileHandlerLoaded.cs index fc2ac2881b..0748b2cbb6 100644 --- a/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFileHandlerLoaded.cs +++ b/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFileHandlerLoaded.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Pdf.Telemetry.Events /// A telemetry event that is triggered when a pdf file is viewed in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class PdfFileHandlerLoaded : EventBase, IEvent { /// <inheritdoc/> diff --git a/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFilePreviewError.cs b/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFilePreviewError.cs index 73dec91265..77917c8cff 100644 --- a/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFilePreviewError.cs +++ b/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFilePreviewError.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -10,6 +11,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Pdf.Telemetry.Events /// <summary> /// A telemetry event that is triggered when an error occurs while attempting to view a markdown file in the preview pane. /// </summary> + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class PdfFilePreviewError : EventBase, IEvent { /// <summary> diff --git a/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFilePreviewed.cs b/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFilePreviewed.cs index 0a223a24b2..a5f4f1bd35 100644 --- a/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFilePreviewed.cs +++ b/src/modules/previewpane/PdfPreviewHandler/Telemetry/Events/PdfFilePreviewed.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Pdf.Telemetry.Events /// A telemetry event that is triggered when a markdown file is viewed in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class PdfFilePreviewed : EventBase, IEvent { /// <inheritdoc/> diff --git a/src/modules/previewpane/PdfPreviewHandlerCpp/PdfPreviewHandlerCpp.vcxproj b/src/modules/previewpane/PdfPreviewHandlerCpp/PdfPreviewHandlerCpp.vcxproj index 2a683cc14b..4f1b9dd271 100644 --- a/src/modules/previewpane/PdfPreviewHandlerCpp/PdfPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/PdfPreviewHandlerCpp/PdfPreviewHandlerCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{54f7c616-fd41-4e62-bff9-015686914f4d}</ProjectGuid> <RootNamespace>PdfPreviewHandlerCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -94,23 +94,23 @@ <ResourceCompile Include="PdfPreviewHandlerCpp.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/PdfPreviewHandlerCpp/packages.config b/src/modules/previewpane/PdfPreviewHandlerCpp/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/previewpane/PdfPreviewHandlerCpp/packages.config +++ b/src/modules/previewpane/PdfPreviewHandlerCpp/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/PdfThumbnailProvider/PdfThumbnailProvider.cs b/src/modules/previewpane/PdfThumbnailProvider/PdfThumbnailProvider.cs index e82607b299..e6b40f0ea8 100644 --- a/src/modules/previewpane/PdfThumbnailProvider/PdfThumbnailProvider.cs +++ b/src/modules/previewpane/PdfThumbnailProvider/PdfThumbnailProvider.cs @@ -1,6 +1,7 @@ -// 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.Drawing; using Windows.Data.Pdf; using Windows.Storage; using Windows.Storage.Streams; @@ -95,6 +96,8 @@ namespace Microsoft.PowerToys.ThumbnailHandler.Pdf DestinationHeight = height, }).GetAwaiter().GetResult(); + stream.Seek(0); + imageOfPage = Image.FromStream(stream.AsStream()); return imageOfPage; diff --git a/src/modules/previewpane/PdfThumbnailProvider/PdfThumbnailProvider.csproj b/src/modules/previewpane/PdfThumbnailProvider/PdfThumbnailProvider.csproj index b3702300c3..a5837836e3 100644 --- a/src/modules/previewpane/PdfThumbnailProvider/PdfThumbnailProvider.csproj +++ b/src/modules/previewpane/PdfThumbnailProvider/PdfThumbnailProvider.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -13,7 +13,7 @@ <AssemblyDescription>PowerToys PdfPreviewHandler</AssemblyDescription> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <Description>PowerToys PdfPreviewHandler</Description> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> diff --git a/src/modules/previewpane/PdfThumbnailProviderCpp/PdfThumbnailProviderCpp.vcxproj b/src/modules/previewpane/PdfThumbnailProviderCpp/PdfThumbnailProviderCpp.vcxproj index 1daebb6612..fbb27c63df 100644 --- a/src/modules/previewpane/PdfThumbnailProviderCpp/PdfThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/PdfThumbnailProviderCpp/PdfThumbnailProviderCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{ca5518ed-0458-4b09-8f53-4122b9888655}</ProjectGuid> <RootNamespace>PdfThumbnailProviderCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -91,28 +91,28 @@ <None Include="packages.config" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> <ItemGroup> <ResourceCompile Include="PdfThumbnailProviderCpp.rc" /> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/PdfThumbnailProviderCpp/packages.config b/src/modules/previewpane/PdfThumbnailProviderCpp/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/previewpane/PdfThumbnailProviderCpp/packages.config +++ b/src/modules/previewpane/PdfThumbnailProviderCpp/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/QoiPreviewHandler/QoiPreviewHandler.csproj b/src/modules/previewpane/QoiPreviewHandler/QoiPreviewHandler.csproj index eaa6744203..d757f2ab57 100644 --- a/src/modules/previewpane/QoiPreviewHandler/QoiPreviewHandler.csproj +++ b/src/modules/previewpane/QoiPreviewHandler/QoiPreviewHandler.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -10,7 +10,7 @@ <AssemblyDescription>PowerToys QoiPreviewHandler</AssemblyDescription> <Description>PowerToys QoiPreviewHandler</Description> <DocumentationFile>..\..\..\..\$(Platform)\$(Configuration)\QoiPreviewPaneDocumentation.xml</DocumentationFile> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> diff --git a/src/modules/previewpane/QoiPreviewHandler/QoiPreviewHandlerControl.cs b/src/modules/previewpane/QoiPreviewHandler/QoiPreviewHandlerControl.cs index 047a3e614f..2482b501bb 100644 --- a/src/modules/previewpane/QoiPreviewHandler/QoiPreviewHandlerControl.cs +++ b/src/modules/previewpane/QoiPreviewHandler/QoiPreviewHandlerControl.cs @@ -25,7 +25,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Qoi private RichTextBox _textBox; /// <summary> - /// Represent if an text box info bar is added for showing message. + /// Represent if a text box info bar is added for showing message. /// </summary> private bool _infoBarAdded; diff --git a/src/modules/previewpane/QoiPreviewHandler/Telemetry/Events/QoiFilePreviewError.cs b/src/modules/previewpane/QoiPreviewHandler/Telemetry/Events/QoiFilePreviewError.cs index cdc4516fd9..6a3f4629e5 100644 --- a/src/modules/previewpane/QoiPreviewHandler/Telemetry/Events/QoiFilePreviewError.cs +++ b/src/modules/previewpane/QoiPreviewHandler/Telemetry/Events/QoiFilePreviewError.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Qoi.Telemetry.Events /// A telemetry event to be raised when an error has occurred in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class QoiFilePreviewError : EventBase, IEvent { /// <summary> diff --git a/src/modules/previewpane/QoiPreviewHandler/Telemetry/Events/QoiFilePreviewed.cs b/src/modules/previewpane/QoiPreviewHandler/Telemetry/Events/QoiFilePreviewed.cs index 273ec8caf0..bba4b7972c 100644 --- a/src/modules/previewpane/QoiPreviewHandler/Telemetry/Events/QoiFilePreviewed.cs +++ b/src/modules/previewpane/QoiPreviewHandler/Telemetry/Events/QoiFilePreviewed.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Qoi.Telemetry.Events /// A telemetry event to be raised when a Qoi file has been viewed in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class QoiFilePreviewed : EventBase, IEvent { /// <inheritdoc/> diff --git a/src/modules/previewpane/QoiPreviewHandlerCpp/QoiPreviewHandlerCpp.vcxproj b/src/modules/previewpane/QoiPreviewHandlerCpp/QoiPreviewHandlerCpp.vcxproj index 6bd7147154..bbd3d2a622 100644 --- a/src/modules/previewpane/QoiPreviewHandlerCpp/QoiPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/QoiPreviewHandlerCpp/QoiPreviewHandlerCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{3BAF9C81-A194-4925-A035-5E24A5D1E542}</ProjectGuid> <RootNamespace>QoiPreviewHandlerCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -94,26 +94,26 @@ <ResourceCompile Include="QoiPreviewHandlerCpp.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/QoiPreviewHandlerCpp/packages.config b/src/modules/previewpane/QoiPreviewHandlerCpp/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/previewpane/QoiPreviewHandlerCpp/packages.config +++ b/src/modules/previewpane/QoiPreviewHandlerCpp/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/QoiThumbnailProvider/QoiThumbnailProvider.csproj b/src/modules/previewpane/QoiThumbnailProvider/QoiThumbnailProvider.csproj index b12679fad7..19d8a0f12d 100644 --- a/src/modules/previewpane/QoiThumbnailProvider/QoiThumbnailProvider.csproj +++ b/src/modules/previewpane/QoiThumbnailProvider/QoiThumbnailProvider.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -13,7 +13,7 @@ <AssemblyDescription>PowerToys QoiPreviewHandler</AssemblyDescription> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <Description>PowerToys QoiPreviewHandler</Description> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> diff --git a/src/modules/previewpane/QoiThumbnailProviderCpp/QoiThumbnailProviderCpp.vcxproj b/src/modules/previewpane/QoiThumbnailProviderCpp/QoiThumbnailProviderCpp.vcxproj index 8bdc0b826f..bffcfa6db8 100644 --- a/src/modules/previewpane/QoiThumbnailProviderCpp/QoiThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/QoiThumbnailProviderCpp/QoiThumbnailProviderCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{CCB5E44F-84D9-4203-83C6-1C9EC9302BC7}</ProjectGuid> <RootNamespace>QoiThumbnailProviderCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -91,28 +91,28 @@ <None Include="packages.config" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> <ItemGroup> <ResourceCompile Include="QoiThumbnailProviderCpp.rc" /> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/QoiThumbnailProviderCpp/packages.config b/src/modules/previewpane/QoiThumbnailProviderCpp/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/previewpane/QoiThumbnailProviderCpp/packages.config +++ b/src/modules/previewpane/QoiThumbnailProviderCpp/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/StlThumbnailProvider/StlThumbnailProvider.cs b/src/modules/previewpane/StlThumbnailProvider/StlThumbnailProvider.cs index 2301c7beb0..d6dbd74251 100644 --- a/src/modules/previewpane/StlThumbnailProvider/StlThumbnailProvider.cs +++ b/src/modules/previewpane/StlThumbnailProvider/StlThumbnailProvider.cs @@ -155,7 +155,7 @@ namespace Microsoft.PowerToys.ThumbnailHandler.Stl { try { - var moduleSettings = new SettingsUtils(); + var moduleSettings = SettingsUtils.Default; var colorString = moduleSettings.GetSettings<PowerPreviewSettings>(PowerPreviewSettings.ModuleName).Properties.StlThumbnailColor.Value; diff --git a/src/modules/previewpane/StlThumbnailProvider/StlThumbnailProvider.csproj b/src/modules/previewpane/StlThumbnailProvider/StlThumbnailProvider.csproj index 951d1ec1d3..31da5d4a83 100644 --- a/src/modules/previewpane/StlThumbnailProvider/StlThumbnailProvider.csproj +++ b/src/modules/previewpane/StlThumbnailProvider/StlThumbnailProvider.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -13,7 +13,7 @@ <AssemblyDescription>PowerToys StlPreviewHandler</AssemblyDescription> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <Description>PowerToys StlPreviewHandler</Description> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <UseWPF>true</UseWPF> diff --git a/src/modules/previewpane/StlThumbnailProviderCpp/StlThumbnailProviderCpp.vcxproj b/src/modules/previewpane/StlThumbnailProviderCpp/StlThumbnailProviderCpp.vcxproj index 202c2290d0..4b2fe549b9 100644 --- a/src/modules/previewpane/StlThumbnailProviderCpp/StlThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/StlThumbnailProviderCpp/StlThumbnailProviderCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{d6dcc3ae-18c0-488a-b978-baa9e3cff09d}</ProjectGuid> <RootNamespace>StlThumbnailProviderCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -91,28 +91,28 @@ <None Include="packages.config" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> <ItemGroup> <ResourceCompile Include="StlThumbnailProviderCpp.rc" /> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/StlThumbnailProviderCpp/packages.config b/src/modules/previewpane/StlThumbnailProviderCpp/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/previewpane/StlThumbnailProviderCpp/packages.config +++ b/src/modules/previewpane/StlThumbnailProviderCpp/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/SvgPreviewHandler/Settings.cs b/src/modules/previewpane/SvgPreviewHandler/Settings.cs index 925da8307a..dfde5bed03 100644 --- a/src/modules/previewpane/SvgPreviewHandler/Settings.cs +++ b/src/modules/previewpane/SvgPreviewHandler/Settings.cs @@ -8,7 +8,7 @@ namespace SvgPreviewHandler { internal sealed class Settings { - private static readonly SettingsUtils ModuleSettings = new SettingsUtils(); + private static readonly SettingsUtils ModuleSettings = SettingsUtils.Default; public int ColorMode { diff --git a/src/modules/previewpane/SvgPreviewHandler/SvgPreviewControl.cs b/src/modules/previewpane/SvgPreviewHandler/SvgPreviewControl.cs index 095c985896..26810a19dc 100644 --- a/src/modules/previewpane/SvgPreviewHandler/SvgPreviewControl.cs +++ b/src/modules/previewpane/SvgPreviewHandler/SvgPreviewControl.cs @@ -74,7 +74,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Svg private RichTextBox _textBox; /// <summary> - /// Represent if an text box info bar is added for showing message. + /// Represent if a text box info bar is added for showing message. /// </summary> private bool _infoBarAdded; diff --git a/src/modules/previewpane/SvgPreviewHandler/SvgPreviewHandler.csproj b/src/modules/previewpane/SvgPreviewHandler/SvgPreviewHandler.csproj index 32f8bc0e30..40ff418d99 100644 --- a/src/modules/previewpane/SvgPreviewHandler/SvgPreviewHandler.csproj +++ b/src/modules/previewpane/SvgPreviewHandler/SvgPreviewHandler.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -10,7 +10,7 @@ <AssemblyTitle>PowerToys.SvgPreviewHandler</AssemblyTitle> <AssemblyDescription>PowerToys SvgPreviewHandler</AssemblyDescription> <Description>PowerToys SvgPreviewHandler</Description> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <DocumentationFile>..\..\..\..\$(Platform)\$(Configuration)\SvgPreviewHandler.xml</DocumentationFile> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> diff --git a/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFileHandlerLoaded.cs b/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFileHandlerLoaded.cs index aaea992ba9..bda66bceb5 100644 --- a/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFileHandlerLoaded.cs +++ b/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFileHandlerLoaded.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Svg.Telemetry.Events /// A telemetry event to be raised when a svg file has been viewed in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class SvgFileHandlerLoaded : EventBase, IEvent { /// <inheritdoc/> diff --git a/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFilePreviewError.cs b/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFilePreviewError.cs index daed435502..526a75af3e 100644 --- a/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFilePreviewError.cs +++ b/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFilePreviewError.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Svg.Telemetry.Events /// A telemetry event to be raised when an error has occurred in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class SvgFilePreviewError : EventBase, IEvent { /// <summary> diff --git a/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFilePreviewed.cs b/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFilePreviewed.cs index 5348bcd466..6e1e16a328 100644 --- a/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFilePreviewed.cs +++ b/src/modules/previewpane/SvgPreviewHandler/Telemetry/Events/SvgFilePreviewed.cs @@ -2,8 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; @@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.PreviewHandler.Svg.Telemetry.Events /// A telemetry event to be raised when a svg file has been viewed in the preview pane. /// </summary> [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class SvgFilePreviewed : EventBase, IEvent { /// <inheritdoc/> diff --git a/src/modules/previewpane/SvgPreviewHandlerCpp/SvgPreviewHandlerCpp.vcxproj b/src/modules/previewpane/SvgPreviewHandlerCpp/SvgPreviewHandlerCpp.vcxproj index fc55a391d5..70f9433744 100644 --- a/src/modules/previewpane/SvgPreviewHandlerCpp/SvgPreviewHandlerCpp.vcxproj +++ b/src/modules/previewpane/SvgPreviewHandlerCpp/SvgPreviewHandlerCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{143f13e3-d2e3-4d83-b035-356612d99956}</ProjectGuid> <RootNamespace>SvgPreviewHandlerCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -94,26 +94,26 @@ <ResourceCompile Include="SvgPreviewHandlerCpp.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Themes\Themes.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Themes\Themes.vcxproj"> <Project>{98537082-0fdb-40de-abd8-0dc5a4269bab}</Project> </ProjectReference> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/SvgPreviewHandlerCpp/packages.config b/src/modules/previewpane/SvgPreviewHandlerCpp/packages.config index 09bfc449e2..f32f48b009 100644 --- a/src/modules/previewpane/SvgPreviewHandlerCpp/packages.config +++ b/src/modules/previewpane/SvgPreviewHandlerCpp/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/SvgThumbnailProvider/SvgThumbnailProvider.csproj b/src/modules/previewpane/SvgThumbnailProvider/SvgThumbnailProvider.csproj index 847c22bd6b..1d6382b039 100644 --- a/src/modules/previewpane/SvgThumbnailProvider/SvgThumbnailProvider.csproj +++ b/src/modules/previewpane/SvgThumbnailProvider/SvgThumbnailProvider.csproj @@ -1,7 +1,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> @@ -13,7 +13,7 @@ <AssemblyDescription>PowerToys SvgPreviewHandler</AssemblyDescription> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <Description>PowerToys SvgPreviewHandler</Description> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> diff --git a/src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj b/src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj index 8712c6c298..1ce28c3cb0 100644 --- a/src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj +++ b/src/modules/previewpane/SvgThumbnailProviderCpp/SvgThumbnailProviderCpp.vcxproj @@ -1,23 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> <ProjectGuid>{2bbc9e33-21ec-401c-84da-bb6590a9b2aa}</ProjectGuid> <RootNamespace>SvgThumbnailProviderCpp</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -31,7 +31,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.$(ProjectName)</TargetName> @@ -91,28 +91,28 @@ <None Include="packages.config" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> <ItemGroup> <ResourceCompile Include="SvgThumbnailProviderCpp.rc" /> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/previewpane/SvgThumbnailProviderCpp/packages.config b/src/modules/previewpane/SvgThumbnailProviderCpp/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/previewpane/SvgThumbnailProviderCpp/packages.config +++ b/src/modules/previewpane/SvgThumbnailProviderCpp/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/UnitTests-BgcodePreviewHandler/BgcodePreviewHandlerTest.cs b/src/modules/previewpane/UnitTests-BgcodePreviewHandler/BgcodePreviewHandlerTest.cs new file mode 100644 index 0000000000..fcc15c134d --- /dev/null +++ b/src/modules/previewpane/UnitTests-BgcodePreviewHandler/BgcodePreviewHandlerTest.cs @@ -0,0 +1,93 @@ +// 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.Drawing; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Windows.Forms; + +using Microsoft.PowerToys.PreviewHandler.Bgcode; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace BgcodePreviewHandlerUnitTests +{ + [STATestClass] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2201:Do not raise reserved exception types", Justification = "new Exception() is fine in test projects.")] + public class BgcodePreviewHandlerTest + { + [DataTestMethod] + [DataRow("HelperFiles/sample.bgcode")] + public void BgcodePreviewHandlerControlAddsControlsToFormWhenDoPreviewIsCalled(string filePath) + { + // Arrange + using (var bgcodePreviewHandlerControl = new BgcodePreviewHandlerControl()) + { + // Act + var file = File.ReadAllBytes(filePath); + + bgcodePreviewHandlerControl.DoPreview<IStream>(GetMockStream(file)); + + var flowLayoutPanel = bgcodePreviewHandlerControl.Controls[0] as FlowLayoutPanel; + + // Assert + Assert.AreEqual(1, bgcodePreviewHandlerControl.Controls.Count); + } + } + + [TestMethod] + public void BgcodePreviewHandlerControlShouldAddValidInfoBarIfBgcodePreviewThrows() + { + // Arrange + using (var bgcodePreviewHandlerControl = new BgcodePreviewHandlerControl()) + { + var mockStream = new Mock<IStream>(); + mockStream + .Setup(x => x.Read(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<IntPtr>())) + .Throws(new Exception()); + + // Act + bgcodePreviewHandlerControl.DoPreview(mockStream.Object); + var textBox = bgcodePreviewHandlerControl.Controls[0] as RichTextBox; + + // Assert + Assert.IsFalse(string.IsNullOrWhiteSpace(textBox.Text)); + Assert.AreEqual(1, bgcodePreviewHandlerControl.Controls.Count); + Assert.AreEqual(DockStyle.Top, textBox.Dock); + Assert.AreEqual(Color.LightYellow, textBox.BackColor); + Assert.IsTrue(textBox.Multiline); + Assert.IsTrue(textBox.ReadOnly); + Assert.AreEqual(RichTextBoxScrollBars.None, textBox.ScrollBars); + Assert.AreEqual(BorderStyle.None, textBox.BorderStyle); + } + } + + private static IStream GetMockStream(byte[] sourceArray) + { + var streamMock = new Mock<IStream>(); + int bytesRead = 0; + + streamMock + .Setup(x => x.Read(It.IsAny<byte[]>(), It.IsAny<int>(), It.IsAny<IntPtr>())) + .Callback<byte[], int, IntPtr>((buffer, countToRead, bytesReadPtr) => + { + int actualCountToRead = Math.Min(sourceArray.Length - bytesRead, countToRead); + if (actualCountToRead > 0) + { + Array.Copy(sourceArray, bytesRead, buffer, 0, actualCountToRead); + Marshal.WriteInt32(bytesReadPtr, actualCountToRead); + bytesRead += actualCountToRead; + } + else + { + Marshal.WriteInt32(bytesReadPtr, 0); + } + }); + + return streamMock.Object; + } + } +} diff --git a/src/modules/previewpane/UnitTests-BgcodePreviewHandler/HelperFiles/sample.bgcode b/src/modules/previewpane/UnitTests-BgcodePreviewHandler/HelperFiles/sample.bgcode new file mode 100644 index 0000000000..64ec331f7f Binary files /dev/null and b/src/modules/previewpane/UnitTests-BgcodePreviewHandler/HelperFiles/sample.bgcode differ diff --git a/src/modules/previewpane/UnitTests-BgcodePreviewHandler/Preview.BgcodePreviewHandler.UnitTests.csproj b/src/modules/previewpane/UnitTests-BgcodePreviewHandler/Preview.BgcodePreviewHandler.UnitTests.csproj new file mode 100644 index 0000000000..9eb28d3329 --- /dev/null +++ b/src/modules/previewpane/UnitTests-BgcodePreviewHandler/Preview.BgcodePreviewHandler.UnitTests.csproj @@ -0,0 +1,40 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <OutputType>Exe</OutputType> + <AssemblyTitle>UnitTests-BgcodePreviewHandler</AssemblyTitle> + <AssemblyDescription>PowerToys UnitTests-BgcodePreviewHandler</AssemblyDescription> + <Description>PowerToys UnitTests-BgcodePreviewHandler</Description> + <ProjectGuid>{99CA1509-FB73-456E-AFAF-AB89C017BD72}</ProjectGuid> + <RootNamespace>BgcodePreviewHandlerUnitTests</RootNamespace> + <ProjectTypeGuids>{3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids> + <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath> + <ReferencePath>$(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages</ReferencePath> + <IsCodedUITest>False</IsCodedUITest> + </PropertyGroup> + + <ItemGroup> + <None Remove="HelperFiles\sample.bgcode" /> + <None Remove="HelperFiles\sample_JPG.bgcode" /> + <None Remove="HelperFiles\sample_QOI.bgcode" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\common\PreviewHandlerCommon.csproj" /> + <ProjectReference Include="..\BgcodePreviewHandler\BgcodePreviewHandler.csproj" /> + </ItemGroup> + <ItemGroup> + <Content Include="HelperFiles\sample.bgcode"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + </ItemGroup> +</Project> diff --git a/src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/BgcodeThumbnailProviderTests.cs b/src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/BgcodeThumbnailProviderTests.cs new file mode 100644 index 0000000000..6c15a3fc3c --- /dev/null +++ b/src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/BgcodeThumbnailProviderTests.cs @@ -0,0 +1,71 @@ +// 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.Drawing; +using System.IO; + +using Microsoft.PowerToys.ThumbnailHandler.Bgcode; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace BgcodeThumbnailProviderUnitTests +{ + [STATestClass] + public class BgcodeThumbnailProviderTests + { + [DataTestMethod] + [DataRow("HelperFiles/sample.bgcode")] + public void GetThumbnailValidStreamBgcode(string filePath) + { + // Act + BgcodeThumbnailProvider provider = new BgcodeThumbnailProvider(filePath); + + Bitmap bitmap = provider.GetThumbnail(256); + + Assert.IsTrue(bitmap != null); + } + + [TestMethod] + public void GetThumbnailInValidSizeBgcode() + { + // Act + var filePath = "HelperFiles/sample.bgcode"; + + BgcodeThumbnailProvider provider = new BgcodeThumbnailProvider(filePath); + + Bitmap bitmap = provider.GetThumbnail(0); + + Assert.IsTrue(bitmap == null); + } + + [TestMethod] + public void GetThumbnailToBigBgcode() + { + // Act + var filePath = "HelperFiles/sample.bgcode"; + + BgcodeThumbnailProvider provider = new BgcodeThumbnailProvider(filePath); + + Bitmap bitmap = provider.GetThumbnail(10001); + + Assert.IsTrue(bitmap == null); + } + + [TestMethod] + public void CheckNoBgcodeEmptyDataShouldReturnNullBitmap() + { + using (var reader = new BinaryReader(new MemoryStream())) + { + Bitmap thumbnail = BgcodeThumbnailProvider.GetThumbnail(reader, 256); + Assert.IsTrue(thumbnail == null); + } + } + + [TestMethod] + public void CheckNoBgcodeNullStringShouldReturnNullBitmap() + { + Bitmap thumbnail = BgcodeThumbnailProvider.GetThumbnail(null, 256); + Assert.IsTrue(thumbnail == null); + } + } +} diff --git a/src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/HelperFiles/sample.bgcode b/src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/HelperFiles/sample.bgcode new file mode 100644 index 0000000000..64ec331f7f Binary files /dev/null and b/src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/HelperFiles/sample.bgcode differ diff --git a/src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/Preview.BgcodeThumbnailProvider.UnitTests.csproj b/src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/Preview.BgcodeThumbnailProvider.UnitTests.csproj new file mode 100644 index 0000000000..86d28a144c --- /dev/null +++ b/src/modules/previewpane/UnitTests-BgcodeThumbnailProvider/Preview.BgcodeThumbnailProvider.UnitTests.csproj @@ -0,0 +1,42 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <OutputType>Exe</OutputType> + <AssemblyTitle>UnitTests-BgcodeThumbnailProvider</AssemblyTitle> + <AssemblyDescription>PowerToys UnitTests-BgcodeThumbnailProvider</AssemblyDescription> + <AssemblyTitle>UnitTests-BgcodeThumbnailProvider</AssemblyTitle> + <Description>PowerToys UnitTests-BgcodeThumbnailProvider</Description> + <ProjectGuid>{61CBF221-9452-4934-B685-146285E080D7}</ProjectGuid> + <RootNamespace>BgcodeThumbnailProviderUnitTests</RootNamespace> + <ProjectTypeGuids>{3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids> + <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath> + <ReferencePath>$(ProgramFiles)\Common Files\microsoft shared\VSTT\$(VisualStudioVersion)\UITestExtensionPackages</ReferencePath> + <IsCodedUITest>False</IsCodedUITest> + <TestProjectType>UnitTest</TestProjectType> + </PropertyGroup> + + <ItemGroup> + <None Remove="HelperFiles\sample.bgcode" /> + <None Remove="HelperFiles\sample_JPG.bgcode" /> + <None Remove="HelperFiles\sample_QOI.bgcode" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Moq" /> + <PackageReference Include="MSTest" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\Common\PreviewHandlerCommon.csproj" /> + <ProjectReference Include="..\BgcodeThumbnailProvider\BgcodeThumbnailProvider.csproj" /> + </ItemGroup> + <ItemGroup> + <Content Include="HelperFiles\sample.bgcode"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + </ItemGroup> +</Project> diff --git a/src/modules/previewpane/UnitTests-GcodePreviewHandler/UnitTests-GcodePreviewHandler.csproj b/src/modules/previewpane/UnitTests-GcodePreviewHandler/Preview.GcodePreviewHandler.UnitTests.csproj similarity index 85% rename from src/modules/previewpane/UnitTests-GcodePreviewHandler/UnitTests-GcodePreviewHandler.csproj rename to src/modules/previewpane/UnitTests-GcodePreviewHandler/Preview.GcodePreviewHandler.UnitTests.csproj index c45fe21d7f..3770b3b179 100644 --- a/src/modules/previewpane/UnitTests-GcodePreviewHandler/UnitTests-GcodePreviewHandler.csproj +++ b/src/modules/previewpane/UnitTests-GcodePreviewHandler/Preview.GcodePreviewHandler.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-GcodePreviewHandler</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-GcodePreviewHandler</AssemblyDescription> <AssemblyTitle>UnitTests-GcodePreviewHandler</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-GcodeThumbnailProvider/UnitTests-GcodeThumbnailProvider.csproj b/src/modules/previewpane/UnitTests-GcodeThumbnailProvider/Preview.GcodeThumbnailProvider.UnitTests.csproj similarity index 86% rename from src/modules/previewpane/UnitTests-GcodeThumbnailProvider/UnitTests-GcodeThumbnailProvider.csproj rename to src/modules/previewpane/UnitTests-GcodeThumbnailProvider/Preview.GcodeThumbnailProvider.UnitTests.csproj index c4bb7d11f2..0ca84e94fb 100644 --- a/src/modules/previewpane/UnitTests-GcodeThumbnailProvider/UnitTests-GcodeThumbnailProvider.csproj +++ b/src/modules/previewpane/UnitTests-GcodeThumbnailProvider/Preview.GcodeThumbnailProvider.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-GcodeThumbnailProvider</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-GcodeThumbnailProvider</AssemblyDescription> <AssemblyTitle>UnitTests-GcodeThumbnailProvider</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-MarkdownPreviewHandler/UnitTests-MarkdownPreviewHandler.csproj b/src/modules/previewpane/UnitTests-MarkdownPreviewHandler/Preview.MarkdownPreviewHandler.UnitTests.csproj similarity index 85% rename from src/modules/previewpane/UnitTests-MarkdownPreviewHandler/UnitTests-MarkdownPreviewHandler.csproj rename to src/modules/previewpane/UnitTests-MarkdownPreviewHandler/Preview.MarkdownPreviewHandler.UnitTests.csproj index 8e138c81d9..29572732cb 100644 --- a/src/modules/previewpane/UnitTests-MarkdownPreviewHandler/UnitTests-MarkdownPreviewHandler.csproj +++ b/src/modules/previewpane/UnitTests-MarkdownPreviewHandler/Preview.MarkdownPreviewHandler.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-MarkdownPreviewHandler</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-MarkdownPreviewHandler</AssemblyDescription> <AssemblyTitle>UnitTests-MarkdownPreviewHandler</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-PdfPreviewHandler/UnitTests-PdfPreviewHandler.csproj b/src/modules/previewpane/UnitTests-PdfPreviewHandler/Preview.PdfPreviewHandler.UnitTests.csproj similarity index 83% rename from src/modules/previewpane/UnitTests-PdfPreviewHandler/UnitTests-PdfPreviewHandler.csproj rename to src/modules/previewpane/UnitTests-PdfPreviewHandler/Preview.PdfPreviewHandler.UnitTests.csproj index 45f24ca0df..17a0687657 100644 --- a/src/modules/previewpane/UnitTests-PdfPreviewHandler/UnitTests-PdfPreviewHandler.csproj +++ b/src/modules/previewpane/UnitTests-PdfPreviewHandler/Preview.PdfPreviewHandler.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-PdfPreviewHandler</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-PdfPreviewHandler</AssemblyDescription> <AssemblyTitle>UnitTests-PdfPreviewHandler</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-PdfThumbnailProvider/UnitTests-PdfThumbnailProvider.csproj b/src/modules/previewpane/UnitTests-PdfThumbnailProvider/Preview.PdfThumbnailProvider.UnitTests.csproj similarity index 83% rename from src/modules/previewpane/UnitTests-PdfThumbnailProvider/UnitTests-PdfThumbnailProvider.csproj rename to src/modules/previewpane/UnitTests-PdfThumbnailProvider/Preview.PdfThumbnailProvider.UnitTests.csproj index ad81ad4c41..254903dcb3 100644 --- a/src/modules/previewpane/UnitTests-PdfThumbnailProvider/UnitTests-PdfThumbnailProvider.csproj +++ b/src/modules/previewpane/UnitTests-PdfThumbnailProvider/Preview.PdfThumbnailProvider.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-PdfThumbnailProvider</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-PdfThumbnailProvider</AssemblyDescription> <AssemblyTitle>UnitTests-PdfThumbnailProvider</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-PreviewHandlerCommon/FormHandlerControlTests.cs b/src/modules/previewpane/UnitTests-PreviewHandlerCommon/FormHandlerControlTests.cs index adcf12034f..8bf6e67063 100644 --- a/src/modules/previewpane/UnitTests-PreviewHandlerCommon/FormHandlerControlTests.cs +++ b/src/modules/previewpane/UnitTests-PreviewHandlerCommon/FormHandlerControlTests.cs @@ -146,7 +146,7 @@ namespace PreviewHandlerCommonUnitTests } [TestMethod] - public void FormHandlerControlShouldSetVisibletrueWhenDoPreviewCalled() + public void FormHandlerControlShouldSetVisibleTrueWhenDoPreviewCalled() { // Arrange using (var testFormHandlerControl = new TestFormControl()) diff --git a/src/modules/previewpane/UnitTests-PreviewHandlerCommon/UnitTests-PreviewHandlerCommon.csproj b/src/modules/previewpane/UnitTests-PreviewHandlerCommon/Preview.PreviewHandlerCommon.UnitTests.csproj similarity index 92% rename from src/modules/previewpane/UnitTests-PreviewHandlerCommon/UnitTests-PreviewHandlerCommon.csproj rename to src/modules/previewpane/UnitTests-PreviewHandlerCommon/Preview.PreviewHandlerCommon.UnitTests.csproj index fe1da98da1..424ac914ec 100644 --- a/src/modules/previewpane/UnitTests-PreviewHandlerCommon/UnitTests-PreviewHandlerCommon.csproj +++ b/src/modules/previewpane/UnitTests-PreviewHandlerCommon/Preview.PreviewHandlerCommon.UnitTests.csproj @@ -1,6 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> <AssemblyTitle>UnitTests-PreviewHandlerCommon</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-QoiPreviewHandler/UnitTests-QoiPreviewHandler.csproj b/src/modules/previewpane/UnitTests-QoiPreviewHandler/Preview.QoiPreviewHandler.UnitTests.csproj similarity index 82% rename from src/modules/previewpane/UnitTests-QoiPreviewHandler/UnitTests-QoiPreviewHandler.csproj rename to src/modules/previewpane/UnitTests-QoiPreviewHandler/Preview.QoiPreviewHandler.UnitTests.csproj index 06a5df196a..fd3a1682e3 100644 --- a/src/modules/previewpane/UnitTests-QoiPreviewHandler/UnitTests-QoiPreviewHandler.csproj +++ b/src/modules/previewpane/UnitTests-QoiPreviewHandler/Preview.QoiPreviewHandler.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-QoiPreviewHandler</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-QoiPreviewHandler</AssemblyDescription> <AssemblyTitle>UnitTests-QoiPreviewHandler</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-QoiThumbnailProvider/UnitTests-QoiThumbnailProvider.csproj b/src/modules/previewpane/UnitTests-QoiThumbnailProvider/Preview.QoiThumbnailProvider.UnitTests.csproj similarity index 83% rename from src/modules/previewpane/UnitTests-QoiThumbnailProvider/UnitTests-QoiThumbnailProvider.csproj rename to src/modules/previewpane/UnitTests-QoiThumbnailProvider/Preview.QoiThumbnailProvider.UnitTests.csproj index 5a95b26d26..6fc7c10b7e 100644 --- a/src/modules/previewpane/UnitTests-QoiThumbnailProvider/UnitTests-QoiThumbnailProvider.csproj +++ b/src/modules/previewpane/UnitTests-QoiThumbnailProvider/Preview.QoiThumbnailProvider.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-QoiThumbnailProvider</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-QoiThumbnailProvider</AssemblyDescription> <AssemblyTitle>UnitTests-QoiThumbnailProvider</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-StlThumbnailProvider/UnitTests-StlThumbnailProvider.csproj b/src/modules/previewpane/UnitTests-StlThumbnailProvider/Preview.StlThumbnailProvider.UnitTests.csproj similarity index 83% rename from src/modules/previewpane/UnitTests-StlThumbnailProvider/UnitTests-StlThumbnailProvider.csproj rename to src/modules/previewpane/UnitTests-StlThumbnailProvider/Preview.StlThumbnailProvider.UnitTests.csproj index 64ff9772ca..c7f37453fd 100644 --- a/src/modules/previewpane/UnitTests-StlThumbnailProvider/UnitTests-StlThumbnailProvider.csproj +++ b/src/modules/previewpane/UnitTests-StlThumbnailProvider/Preview.StlThumbnailProvider.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-StlThumbnailProvider</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-StlThumbnailProvider</AssemblyDescription> <AssemblyTitle>UnitTests-StlThumbnailProvider</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-SvgPreviewHandler/UnitTests-SvgPreviewHandler.csproj b/src/modules/previewpane/UnitTests-SvgPreviewHandler/Preview.SvgPreviewHandler.UnitTests.csproj similarity index 84% rename from src/modules/previewpane/UnitTests-SvgPreviewHandler/UnitTests-SvgPreviewHandler.csproj rename to src/modules/previewpane/UnitTests-SvgPreviewHandler/Preview.SvgPreviewHandler.UnitTests.csproj index 63cf7a6c2d..a49070efa4 100644 --- a/src/modules/previewpane/UnitTests-SvgPreviewHandler/UnitTests-SvgPreviewHandler.csproj +++ b/src/modules/previewpane/UnitTests-SvgPreviewHandler/Preview.SvgPreviewHandler.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-SvgPreviewHandler</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-SvgPreviewHandler</AssemblyDescription> <AssemblyTitle>UnitTests-SvgPreviewHandler</AssemblyTitle> diff --git a/src/modules/previewpane/UnitTests-SvgThumbnailProvider/UnitTests-SvgThumbnailProvider.csproj b/src/modules/previewpane/UnitTests-SvgThumbnailProvider/Preview.SvgThumbnailProvider.UnitTests.csproj similarity index 84% rename from src/modules/previewpane/UnitTests-SvgThumbnailProvider/UnitTests-SvgThumbnailProvider.csproj rename to src/modules/previewpane/UnitTests-SvgThumbnailProvider/Preview.SvgThumbnailProvider.UnitTests.csproj index 3356fcb79f..09f63b0c5e 100644 --- a/src/modules/previewpane/UnitTests-SvgThumbnailProvider/UnitTests-SvgThumbnailProvider.csproj +++ b/src/modules/previewpane/UnitTests-SvgThumbnailProvider/Preview.SvgThumbnailProvider.UnitTests.csproj @@ -1,8 +1,11 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> <AssemblyTitle>UnitTests-SvgThumbnailProvider</AssemblyTitle> <AssemblyDescription>PowerToys UnitTests-SvgThumbnailProvider</AssemblyDescription> <AssemblyTitle>UnitTests-SvgThumbnailProvider</AssemblyTitle> @@ -44,4 +47,4 @@ </Content> </ItemGroup> -</Project> \ No newline at end of file +</Project> diff --git a/src/modules/previewpane/common/PreviewHandlerCommon.csproj b/src/modules/previewpane/common/PreviewHandlerCommon.csproj index 0c3654e7ca..7e535eaa2a 100644 --- a/src/modules/previewpane/common/PreviewHandlerCommon.csproj +++ b/src/modules/previewpane/common/PreviewHandlerCommon.csproj @@ -15,6 +15,8 @@ <Nullable>enable</Nullable> <ProjectGuid>{AF2349B8-E5B6-4004-9502-687C1C7730B1}</ProjectGuid> <AssemblyName>PowerToys.PreviewHandlerCommon</AssemblyName> + <!-- AutoUnify resolves WindowsBase version conflict (WebView2.Wpf refs .NET 5 version, we target .NET 9) --> + <AutoUnifyAssemblyReferences>true</AutoUnifyAssemblyReferences> </PropertyGroup> <ItemGroup> diff --git a/src/modules/previewpane/common/Utilities/SvgPreviewHandlerHelper.cs b/src/modules/previewpane/common/Utilities/SvgPreviewHandlerHelper.cs index 180158152c..44fd940b7c 100644 --- a/src/modules/previewpane/common/Utilities/SvgPreviewHandlerHelper.cs +++ b/src/modules/previewpane/common/Utilities/SvgPreviewHandlerHelper.cs @@ -58,7 +58,7 @@ namespace Common.Utilities { foundBlockedElement = true; - // No need to iterate further since we are displaying info bar with condition of atleast one occurrence of blocked element is present. + // No need to iterate further since we are displaying info bar with condition of at least one occurrence of blocked element is present. break; } } diff --git a/src/modules/previewpane/common/cominterop/IPreviewHandlerFrame.cs b/src/modules/previewpane/common/cominterop/IPreviewHandlerFrame.cs index dd80a4edf5..f01f11dd21 100644 --- a/src/modules/previewpane/common/cominterop/IPreviewHandlerFrame.cs +++ b/src/modules/previewpane/common/cominterop/IPreviewHandlerFrame.cs @@ -23,7 +23,7 @@ namespace Common.ComInterlop void GetWindowContext(IntPtr pinfo); /// <summary> - /// Directs the host to handle an keyboard shortcut passed from the preview handler. + /// Directs the host to handle a keyboard shortcut passed from the preview handler. /// </summary> /// <param name="pmsg">A reference to <see cref="MSG"/> that corresponds to a keyboard shortcut.</param> /// <returns>If the keyboard shortcut is one that the host intends to handle, the host will process it and return S_OK(0); otherwise, it returns S_FALSE(1).</returns> diff --git a/src/modules/previewpane/common/controls/FormHandlerControl.cs b/src/modules/previewpane/common/controls/FormHandlerControl.cs index 82779e59a2..ce16d8f1fd 100644 --- a/src/modules/previewpane/common/controls/FormHandlerControl.cs +++ b/src/modules/previewpane/common/controls/FormHandlerControl.cs @@ -17,7 +17,7 @@ namespace Common public abstract class FormHandlerControl : Form, IPreviewHandlerControl { /// <summary> - /// Needed to make the form a child window. + /// Needed to make the form into a child window. /// </summary> private static int gwlStyle = -16; private static int wsChild = 0x40000000; diff --git a/src/modules/previewpane/powerpreview/CLSID.h b/src/modules/previewpane/powerpreview/CLSID.h index 0c75ed23f4..0c9aeee0df 100644 --- a/src/modules/previewpane/powerpreview/CLSID.h +++ b/src/modules/previewpane/powerpreview/CLSID.h @@ -37,6 +37,12 @@ const CLSID CLSID_SHIMActivateGcodePreviewHandler = { 0x516cb24f, 0x562f, 0x422f // ec52dea8-7c9f-4130-a77b-1737d0418507 const CLSID CLSID_GcodePreviewHandler = { 0xec52dea8, 0x7c9f, 0x4130, { 0xa7, 0x7b, 0x17, 0x37, 0xd0, 0x41, 0x85, 0x07 } }; +// 8fae8d5d-6bd1-46b4-a74f-cbebba4c7b62 +const CLSID CLSID_SHIMActivateBgcodePreviewHandler = { 0x8fae8d5d, 0x6bd1, 0x46b4, { 0xa7, 0x4f, 0xcb, 0xeb, 0xba, 0x4c, 0x7b, 0x62 } }; + +// dd8de316-7b01-48e7-ba21-e92c646704af +const GUID CLSID_BgcodePreviewHandler = { 0xdd8de316, 0x7b01, 0x48e7, { 0xba, 0x21, 0xe9, 0x2c, 0x64, 0x67, 0x04, 0xaf } }; + // F498BE36-5C94-4EC9-A65A-AD1CF4C38271 const GUID CLSID_SHIMActivateQoiPreviewHandler = { 0xf498be36, 0x5c94, 0x4ec9, { 0xa6, 0x5a, 0xad, 0x1c, 0xf4, 0xc3, 0x82, 0x71 } }; @@ -46,6 +52,9 @@ const GUID CLSID_QoiPreviewHandler = { 0x8aa07897, 0xc30b, 0x4543, { 0x86, 0x5b, // BFEE99B4-B74D-4348-BCA5-E757029647FF const GUID CLSID_GcodeThumbnailProvider = { 0xbfee99b4, 0xb74d, 0x4348, { 0xbc, 0xa5, 0xe7, 0x57, 0x02, 0x96, 0x47, 0xff } }; +// c28761a0-8420-43ad-bff3-40400543e2d4 +const GUID CLSID_BgcodeThumbnailProvider = {0xc28761a0, 0x8420, 0x43ad, {0xbf, 0xf3, 0x40, 0x40, 0x05, 0x43, 0xe2, 0xd4}}; + // 8BC8AFC2-4E7C-4695-818E-8C1FFDCEA2AF const GUID CLSID_StlThumbnailProvider = { 0x8bc8afc2, 0x4e7c, 0x4695, { 0x81, 0x8e, 0x8c, 0x1f, 0xfd, 0xce, 0xa2, 0xaf } }; @@ -57,6 +66,7 @@ const std::vector<std::pair<CLSID, CLSID>> NativeToManagedClsid({ { CLSID_SHIMActivateMdPreviewHandler, CLSID_MdPreviewHandler }, { CLSID_SHIMActivatePdfPreviewHandler, CLSID_PdfPreviewHandler }, { CLSID_SHIMActivateGcodePreviewHandler, CLSID_GcodePreviewHandler }, + { CLSID_SHIMActivateBgcodePreviewHandler, CLSID_BgcodePreviewHandler }, { CLSID_SHIMActivateQoiPreviewHandler, CLSID_QoiPreviewHandler }, { CLSID_SHIMActivateSvgPreviewHandler, CLSID_SvgPreviewHandler }, { CLSID_SHIMActivateSvgThumbnailProvider, CLSID_SvgThumbnailProvider } diff --git a/src/modules/previewpane/powerpreview/Resources.resx b/src/modules/previewpane/powerpreview/Resources.resx index 6703e476ef..38a95c38ce 100644 --- a/src/modules/previewpane/powerpreview/Resources.resx +++ b/src/modules/previewpane/powerpreview/Resources.resx @@ -143,7 +143,7 @@ </data> <data name="Prevpane_Monaco_Settings_Description" xml:space="preserve"> <value>Developer files Previewer</value> - </data> + </data> <data name="Prevpane_Md_Settings_Displayname" xml:space="preserve"> <value>Markdown Previewer</value> </data> @@ -198,4 +198,13 @@ <data name="Qoi_Thumbnail_Provider_Settings_Description" xml:space="preserve"> <value>Qoi Thumbnail Provider</value> </data> + <data name="Prevpane_Bgcode_Settings_Displayname" xml:space="preserve"> + <value>Binary G-code Previewer</value> + </data> + <data name="Prevpane_Bgcode_Settings_Description" xml:space="preserve"> + <value>Binary G-code Previewer</value> + </data> + <data name="Bgcode_Thumbnail_Provider_Settings_Description" xml:space="preserve"> + <value>Binary G-code Thumbnail Provider</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/previewpane/powerpreview/packages.config b/src/modules/previewpane/powerpreview/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/previewpane/powerpreview/packages.config +++ b/src/modules/previewpane/powerpreview/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/previewpane/powerpreview/powerpreview.base.rc b/src/modules/previewpane/powerpreview/powerpreview.base.rc index 0de3cc578b..e061322154 100644 --- a/src/modules/previewpane/powerpreview/powerpreview.base.rc +++ b/src/modules/previewpane/powerpreview/powerpreview.base.rc @@ -52,9 +52,9 @@ STRINGTABLE BEGIN IDS_EXPLR_ICONS_PREV_STTNGS_GROUP_HEADER_ID L"EXPLR_ICONS_PREV_STTNGS_GROUP_HEADER_ID" IDS_PRVPANE_FILE_PREV_STTNGS_GROUP_HEADER_ID L"PRVPANE_FILE_PREV_STTNGS_GROUP_HEADER_ID" - IDS_PREVPANE_MD_BOOL_TOGGLE_CONTROL L"PREVPANE_MD_BOOL_TOGGLE_CONTROLL_ID" + IDS_PREVPANE_MD_BOOL_TOGGLE_CONTROL L"PREVPANE_MD_BOOL_TOGGLE_CONTROL_ID" IDS_PREVPANE_SVG_BOOL_TOGGLE_CONTROL L"IDS_PREVPANE_SVG_BOOL_TOGGLE_CONTROL" - IDS_EXPLR_SVG_BOOL_TOGGLE_CONTROL L"EXPLR_SVG_BOOL_TOGGLE_CONTROLL" + IDS_EXPLR_SVG_BOOL_TOGGLE_CONTROL L"EXPLR_SVG_BOOL_TOGGLE_CONTROL" END // Non-localizable diff --git a/src/modules/previewpane/powerpreview/powerpreview.cpp b/src/modules/previewpane/powerpreview/powerpreview.cpp index 84d2a590dc..9df07ced0a 100644 --- a/src/modules/previewpane/powerpreview/powerpreview.cpp +++ b/src/modules/previewpane/powerpreview/powerpreview.cpp @@ -50,6 +50,11 @@ PowerPreviewModule::PowerPreviewModule() : .checkModuleGPOEnabledRuleFunction = powertoys_gpo::getConfiguredGcodePreviewEnabledValue, .registryChanges = getGcodePreviewHandlerChangeSet(installationDir, installPerUser) }); + m_fileExplorerModules.push_back({ .settingName = L"bgcode-previewer-toggle-setting", + .settingDescription = GET_RESOURCE_STRING(IDS_PREVPANE_BGCODE_SETTINGS_DESCRIPTION), + .checkModuleGPOEnabledRuleFunction = powertoys_gpo::getConfiguredBgcodePreviewEnabledValue, + .registryChanges = getBgcodePreviewHandlerChangeSet(installationDir, installPerUser) }); + m_fileExplorerModules.push_back({ .settingName = L"svg-thumbnail-toggle-setting", .settingDescription = GET_RESOURCE_STRING(IDS_SVG_THUMBNAIL_PROVIDER_SETTINGS_DESCRIPTION), .checkModuleGPOEnabledRuleFunction = powertoys_gpo::getConfiguredSvgThumbnailsEnabledValue, @@ -65,6 +70,11 @@ PowerPreviewModule::PowerPreviewModule() : .checkModuleGPOEnabledRuleFunction = powertoys_gpo::getConfiguredGcodeThumbnailsEnabledValue, .registryChanges = getGcodeThumbnailHandlerChangeSet(installationDir, installPerUser) }); + m_fileExplorerModules.push_back({ .settingName = L"bgcode-thumbnail-toggle-setting", + .settingDescription = GET_RESOURCE_STRING(IDS_BGCODE_THUMBNAIL_PROVIDER_SETTINGS_DESCRIPTION), + .checkModuleGPOEnabledRuleFunction = powertoys_gpo::getConfiguredBgcodeThumbnailsEnabledValue, + .registryChanges = getBgcodeThumbnailHandlerChangeSet(installationDir, installPerUser) }); + m_fileExplorerModules.push_back({ .settingName = L"stl-thumbnail-toggle-setting", .settingDescription = GET_RESOURCE_STRING(IDS_STL_THUMBNAIL_PROVIDER_SETTINGS_DESCRIPTION), .checkModuleGPOEnabledRuleFunction = powertoys_gpo::getConfiguredStlThumbnailsEnabledValue, diff --git a/src/modules/previewpane/powerpreview/powerpreview.vcxproj b/src/modules/previewpane/powerpreview/powerpreview.vcxproj index 7ea86fc440..45a9d83fd4 100644 --- a/src/modules/previewpane/powerpreview/powerpreview.vcxproj +++ b/src/modules/previewpane/powerpreview/powerpreview.vcxproj @@ -1,8 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h powerpreview.base.rc powerpreview.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(RepoRoot)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h powerpreview.base.rc powerpreview.rc" /> </Target> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> @@ -11,12 +12,11 @@ <RootNamespace>examplepowertoy</RootNamespace> <ProjectName>powerpreview</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> </PropertyGroup> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <ImportGroup Label="ExtensionSettings"> @@ -28,7 +28,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir> </PropertyGroup> <PropertyGroup> <TargetName>PowerToys.powerpreview</TargetName> @@ -36,7 +36,7 @@ <ItemDefinitionGroup> <ClCompile> <PreprocessorDefinitions>_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> - <AdditionalIncludeDirectories>..\;..\..\..\common;..\..\..\common\telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>..\;..\..\..\common;$(RepoRoot)src\common\telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile> @@ -65,19 +65,19 @@ <None Include="powerpreview.base.rc" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\COMUtils\COMUtils.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\COMUtils\COMUtils.vcxproj"> <Project>{7319089e-46d6-4400-bc65-e39bdf1416ee}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\Display\Display.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\Display\Display.vcxproj"> <Project>{caba8dfb-823b-4bf2-93ac-3f31984150d9}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\notifications\notifications.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\notifications\notifications.vcxproj"> <Project>{1d5be09d-78c0-4fd7-af00-ae7c1af7c525}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -86,20 +86,22 @@ <None Include="powerpreview.def" /> </ItemGroup> <ItemGroup> - <None Include="Resources.resx" /> + <None Include="Resources.resx"> + <SubType>Designer</SubType> + </None> </ItemGroup> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/registrypreview/RegistryPreview.FuzzTests/OneFuzzConfig.json b/src/modules/registrypreview/RegistryPreview.FuzzTests/OneFuzzConfig.json index a074f41123..d5909bef71 100644 --- a/src/modules/registrypreview/RegistryPreview.FuzzTests/OneFuzzConfig.json +++ b/src/modules/registrypreview/RegistryPreview.FuzzTests/OneFuzzConfig.json @@ -19,11 +19,11 @@ // project, where bugs will be filed "org": "microsoft", "project": "OS", - "AssignedTo": "mengyuanchen@microsoft.com", - "AreaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\SHINE\\PowerToys", + "AssignedTo": "leilzh@microsoft.com", + "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT", "IterationPath": "OS\\Future" }, - "jobNotificationEmail": "mengyuanchen@microsoft.com", + "jobNotificationEmail": "PowerToys@microsoft.com", "skip": false, "rebootAfterSetup": false, "oneFuzzJobs": [ @@ -60,11 +60,11 @@ // project, where bugs will be filed "org": "microsoft", "project": "OS", - "AssignedTo": "mengyuanchen@microsoft.com", - "AreaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\SHINE\\PowerToys", + "AssignedTo": "leilzh@microsoft.com", + "AreaPath": "OS\\Windows Client and Services\\WinPD\\DFX-Developer Fundamentals and Experiences\\DEFT\\SALT", "IterationPath": "OS\\Future" }, - "jobNotificationEmail": "mengyuanchen@microsoft.com", + "jobNotificationEmail": "PowerToys@microsoft.com", "skip": false, "rebootAfterSetup": false, "oneFuzzJobs": [ diff --git a/src/modules/registrypreview/RegistryPreview.FuzzTests/RegistryPreview.FuzzTests.csproj b/src/modules/registrypreview/RegistryPreview.FuzzTests/RegistryPreview.FuzzTests.csproj index d59656463e..9fff08025f 100644 --- a/src/modules/registrypreview/RegistryPreview.FuzzTests/RegistryPreview.FuzzTests.csproj +++ b/src/modules/registrypreview/RegistryPreview.FuzzTests/RegistryPreview.FuzzTests.csproj @@ -1,15 +1,17 @@ <Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.FuzzTest.props" /> <PropertyGroup> - <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework> - <Platforms>x64;ARM64</Platforms> - <LangVersion>latest</LangVersion> + <Platforms>x64;ARM64</Platforms> + <LangVersion>latest</LangVersion> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> <PropertyGroup> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\RegistryPreview.FuzzTests\</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\RegistryPreview.FuzzTests\</OutputPath> </PropertyGroup> <ItemGroup> diff --git a/src/modules/registrypreview/RegistryPreview/MainWindow.Events.cs b/src/modules/registrypreview/RegistryPreview/MainWindow.Events.cs index abcbbfd09b..fa283a153d 100644 --- a/src/modules/registrypreview/RegistryPreview/MainWindow.Events.cs +++ b/src/modules/registrypreview/RegistryPreview/MainWindow.Events.cs @@ -15,10 +15,10 @@ namespace RegistryPreview /// </summary> private void AppWindow_Closing(Microsoft.UI.Windowing.AppWindow sender, Microsoft.UI.Windowing.AppWindowClosingEventArgs args) { - jsonWindowPlacement.SetNamedValue("appWindow.Position.X", JsonValue.CreateNumberValue(appWindow.Position.X)); - jsonWindowPlacement.SetNamedValue("appWindow.Position.Y", JsonValue.CreateNumberValue(appWindow.Position.Y)); - jsonWindowPlacement.SetNamedValue("appWindow.Size.Width", JsonValue.CreateNumberValue(appWindow.Size.Width)); - jsonWindowPlacement.SetNamedValue("appWindow.Size.Height", JsonValue.CreateNumberValue(appWindow.Size.Height)); + jsonWindowPlacement.SetNamedValue("appWindow.Position.X", JsonValue.CreateNumberValue(AppWindow.Position.X)); + jsonWindowPlacement.SetNamedValue("appWindow.Position.Y", JsonValue.CreateNumberValue(AppWindow.Position.Y)); + jsonWindowPlacement.SetNamedValue("appWindow.Size.Width", JsonValue.CreateNumberValue(AppWindow.Size.Width)); + jsonWindowPlacement.SetNamedValue("appWindow.Size.Height", JsonValue.CreateNumberValue(AppWindow.Size.Height)); } /// <summary> diff --git a/src/modules/registrypreview/RegistryPreview/RegistryPreview.csproj b/src/modules/registrypreview/RegistryPreview/RegistryPreview.csproj index 36bd1f4f4d..a7376b7fe4 100644 --- a/src/modules/registrypreview/RegistryPreview/RegistryPreview.csproj +++ b/src/modules/registrypreview/RegistryPreview/RegistryPreview.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <OutputType>WinExe</OutputType> @@ -15,7 +15,7 @@ <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> - <OutputPath>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> <AssemblyName>PowerToys.RegistryPreview</AssemblyName> <ApplicationIcon>Assets\RegistryPreview\RegistryPreview.ico</ApplicationIcon> <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> @@ -31,7 +31,7 @@ <ItemGroup> <Folder Include="RegistryPreviewXAML\" /> </ItemGroup> - + <ItemGroup> <PackageReference Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" /> <PackageReference Include="CommunityToolkit.WinUI.Controls.Sizers" /> diff --git a/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/App.xaml b/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/App.xaml index 7c2836890c..400d0c71fd 100644 --- a/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/App.xaml +++ b/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/App.xaml @@ -10,7 +10,31 @@ <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> <!-- Other merged dictionaries here --> </ResourceDictionary.MergedDictionaries> - <!-- Other app resources here --> + + <ResourceDictionary.ThemeDictionaries> + <ResourceDictionary x:Key="Default"> + <SolidColorBrush x:Key="HexBox_SelectionTextBrush" Color="{ThemeResource TextOnAccentFillColorSelectedText}" /> + <SolidColorBrush x:Key="HexBox_SelectionBackgroundBrush" Color="{ThemeResource SystemAccentColor}" /> + <SolidColorBrush x:Key="HexBox_VerticalLineBrush" Color="{ThemeResource DividerStrokeColorDefault}" /> + <StaticResource x:Key="HexBox_ControlBorderBrush" ResourceKey="TextControlElevationBorderBrush" /> + <StaticResource x:Key="HexBox_ControlBorderFocusedBrush" ResourceKey="TextControlElevationBorderFocusedBrush" /> + <Thickness x:Key="HexBox_ControlBorderThickness">1</Thickness> + <Thickness x:Key="HexBox_ControlBorderFocusedThickness">1,1,1,2</Thickness> + </ResourceDictionary> + <ResourceDictionary x:Key="HighContrast"> + <SolidColorBrush x:Key="HexBox_SelectionTextBrush" Color="{ThemeResource SystemColorHighlightTextColor}" /> + <SolidColorBrush x:Key="HexBox_SelectionBackgroundBrush" Color="{ThemeResource SystemColorHighlightColor}" /> + <SolidColorBrush x:Key="HexBox_VerticalLineBrush" Color="{ThemeResource SystemColorWindowTextColor}" /> + <LinearGradientBrush x:Key="HexBox_ControlBorderBrush" StartPoint="0,0" EndPoint="1,1"> + <GradientStop Offset="0" Color="{ThemeResource SystemColorButtonTextColor}" /> + </LinearGradientBrush> + <LinearGradientBrush x:Key="HexBox_ControlBorderFocusedBrush" StartPoint="0,0" EndPoint="1,1"> + <GradientStop Offset="0" Color="{ThemeResource SystemColorHighlightColor}" /> + </LinearGradientBrush> + <Thickness x:Key="HexBox_ControlBorderThickness">1</Thickness> + <Thickness x:Key="HexBox_ControlBorderFocusedThickness">2</Thickness> + </ResourceDictionary> + </ResourceDictionary.ThemeDictionaries> </ResourceDictionary> </Application.Resources> </Application> diff --git a/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml b/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml index 95ab0e4331..c9cb36d746 100644 --- a/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml +++ b/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml @@ -1,12 +1,9 @@ -<winuiex:WindowEx +<winuiex:WindowEx x:Class="RegistryPreview.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" - xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" - xmlns:ui="using:CommunityToolkit.WinUI" xmlns:winuiex="using:WinUIEx" MinWidth="480" MinHeight="320" @@ -15,38 +12,19 @@ <Window.SystemBackdrop> <MicaBackdrop /> </Window.SystemBackdrop> - <Grid x:Name="MainGrid" Loaded="Grid_Loaded"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - <Grid - x:Name="titleBar" - Grid.Row="0" - Height="32" - Margin="16,0" - ColumnSpacing="16" - IsHitTestVisible="True"> - <Grid.ColumnDefinitions> - <!--<ColumnDefinition x:Name="LeftPaddingColumn" Width="0"/>--> - <ColumnDefinition x:Name="IconColumn" Width="Auto" /> - <ColumnDefinition x:Name="TitleColumn" Width="Auto" /> - <!--<ColumnDefinition x:Name="RightPaddingColumn" Width="0"/>--> - </Grid.ColumnDefinitions> - <Image - Grid.Column="0" - Width="16" - Height="16" - VerticalAlignment="Center" - Source="../Assets/RegistryPreview/RegistryPreview.ico" /> - <TextBlock - x:Name="titleBarText" - Grid.Column="1" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}" - Text="{Binding ApplicationTitle}" /> - </Grid> - + <TitleBar x:Name="titleBar" IsTabStop="False"> + <!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource --> + <TitleBar.LeftHeader> + <ImageIcon + Height="16" + Margin="16,0,0,0" + Source="/Assets/RegistryPreview/RegistryPreview.ico" /> + </TitleBar.LeftHeader> + </TitleBar> </Grid> -</winuiex:WindowEx> +</winuiex:WindowEx> \ No newline at end of file diff --git a/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml.cs b/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml.cs index 0a1083d00d..9874a150b0 100644 --- a/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml.cs +++ b/src/modules/registrypreview/RegistryPreview/RegistryPreviewXAML/MainWindow.xaml.cs @@ -6,6 +6,7 @@ using System; using ManagedCommon; using Microsoft.PowerToys.Telemetry; using Microsoft.UI; +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; @@ -23,7 +24,6 @@ namespace RegistryPreview private const string APPNAME = "RegistryPreview"; // private members - private Microsoft.UI.Windowing.AppWindow appWindow; private JsonObject jsonWindowPlacement; private string settingsFolder = string.Empty; private string windowPlacementFile = "app-placement.json"; @@ -38,20 +38,15 @@ namespace RegistryPreview settingsFolder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) + @"\Microsoft\PowerToys\" + APPNAME; OpenWindowPlacementFile(settingsFolder, windowPlacementFile); - // Update the Win32 looking window with the correct icon (and grab the appWindow handle for later) - IntPtr windowHandle = this.GetWindowHandle(); - WindowId windowId = Win32Interop.GetWindowIdFromWindow(windowHandle); - appWindow = Microsoft.UI.Windowing.AppWindow.GetFromWindowId(windowId); - appWindow.SetIcon("Assets\\RegistryPreview\\RegistryPreview.ico"); - // TODO(stefan) - appWindow.Closing += AppWindow_Closing; - Activated += MainWindow_Activated; + AppWindow.Closing += AppWindow_Closing; // Extend the canvas to include the title bar so the app can support theming ExtendsContentIntoTitleBar = true; + IntPtr windowHandle = this.GetWindowHandle(); WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(windowHandle); SetTitleBar(titleBar); + AppWindow.SetIcon("Assets\\RegistryPreview\\RegistryPreview.ico"); // if have settings, update the location of the window if (jsonWindowPlacement != null) @@ -66,7 +61,7 @@ namespace RegistryPreview // check to make sure the size values are reasonable before attempting to restore the last saved size if (size.Width >= 320 && size.Height >= 240) { - appWindow.Resize(size); + AppWindow.Resize(size); } } @@ -80,7 +75,7 @@ namespace RegistryPreview // check to make sure the move values are reasonable before attempting to restore the last saved location if (point.X >= 0 && point.Y >= 0) { - appWindow.Move(point); + AppWindow.Move(point); } } } @@ -92,20 +87,6 @@ namespace RegistryPreview PowerToysTelemetry.Log.WriteEvent(new RegistryPreviewEditorStartFinishEvent() { TimeStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); } - private void MainWindow_Activated(object sender, WindowActivatedEventArgs args) - { - if (args.WindowActivationState == WindowActivationState.Deactivated) - { - titleBarText.Foreground = - (SolidColorBrush)Application.Current.Resources["WindowCaptionForegroundDisabled"]; - } - else - { - titleBarText.Foreground = - (SolidColorBrush)Application.Current.Resources["WindowCaptionForeground"]; - } - } - private void Grid_Loaded(object sender, RoutedEventArgs e) { MainGrid.Children.Add(MainPage); @@ -118,23 +99,23 @@ namespace RegistryPreview if (string.IsNullOrEmpty(filename)) { - titleBarText.Text = APPNAME; - appWindow.Title = APPNAME; + titleBar.Title = APPNAME; + AppWindow.Title = APPNAME; } else { string[] file = filename.Split('\\'); if (file.Length > 0) { - titleBarText.Text = file[file.Length - 1] + " - " + APPNAME; + titleBar.Title = file[file.Length - 1] + " - " + APPNAME; } else { - titleBarText.Text = filename + " - " + APPNAME; + titleBar.Title = filename + " - " + APPNAME; } // Continue to update the window's title, after updating the custom title bar - appWindow.Title = titleBarText.Text; + AppWindow.Title = titleBar.Title; } } } diff --git a/src/modules/registrypreview/RegistryPreview/Telemetry/RegistryPreviewEditorStartEvent.cs b/src/modules/registrypreview/RegistryPreview/Telemetry/RegistryPreviewEditorStartEvent.cs index 6ac5fb51a8..aa511bcd12 100644 --- a/src/modules/registrypreview/RegistryPreview/Telemetry/RegistryPreviewEditorStartEvent.cs +++ b/src/modules/registrypreview/RegistryPreview/Telemetry/RegistryPreviewEditorStartEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace RegistryPreview.Telemetry; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class RegistryPreviewEditorStartEvent() : EventBase, IEvent { public long TimeStamp { get; set; } diff --git a/src/modules/registrypreview/RegistryPreview/Telemetry/RegistryPreviewEditorStartFinishEvent.cs b/src/modules/registrypreview/RegistryPreview/Telemetry/RegistryPreviewEditorStartFinishEvent.cs index 98067f26ce..6a5edc5343 100644 --- a/src/modules/registrypreview/RegistryPreview/Telemetry/RegistryPreviewEditorStartFinishEvent.cs +++ b/src/modules/registrypreview/RegistryPreview/Telemetry/RegistryPreviewEditorStartFinishEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace RegistryPreview.Telemetry; [EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class RegistryPreviewEditorStartFinishEvent() : EventBase, IEvent { public long TimeStamp { get; set; } diff --git a/src/modules/registrypreview/RegistryPreviewExt/RegistryPreviewExt.vcxproj b/src/modules/registrypreview/RegistryPreviewExt/RegistryPreviewExt.vcxproj index e1b5064da4..121f7d5488 100644 --- a/src/modules/registrypreview/RegistryPreviewExt/RegistryPreviewExt.vcxproj +++ b/src/modules/registrypreview/RegistryPreviewExt/RegistryPreviewExt.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <ItemGroup Label="ProjectConfigurations"> <ProjectConfiguration Include="Debug|ARM64"> <Configuration>Debug</Configuration> @@ -25,17 +26,16 @@ <Keyword>Win32Proj</Keyword> <RootNamespace>RegistryPreviewExt</RootNamespace> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -49,7 +49,7 @@ </ImportGroup> <PropertyGroup Label="UserMacros" /> <PropertyGroup> - <OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir> <TargetName>PowerToys.RegistryPreviewExt</TargetName> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> @@ -58,7 +58,7 @@ <SDLCheck>true</SDLCheck> <PreprocessorDefinitions>_DEBUG;REGISTRYPREVIEWEXT_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -75,7 +75,7 @@ <SDLCheck>true</SDLCheck> <PreprocessorDefinitions>NDEBUG;REGISTRYPREVIEWEXT_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions> <ConformanceMode>true</ConformanceMode> - <AdditionalIncludeDirectories>..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\common\inc;$(RepoRoot)src\common\Telemetry;..\..\;$(RepoRoot)src\;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> <Link> <SubSystem>Windows</SubSystem> @@ -100,10 +100,10 @@ <ClCompile Include="trace.cpp" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\..\common\logger\logger.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj"> <Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project> </ProjectReference> - <ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj"> + <ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj"> <Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project> </ProjectReference> </ItemGroup> @@ -114,17 +114,17 @@ <None Include="packages.config" /> </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\..\..\deps\spdlog.props" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> </Target> </Project> \ No newline at end of file diff --git a/src/modules/registrypreview/RegistryPreviewExt/dllmain.cpp b/src/modules/registrypreview/RegistryPreviewExt/dllmain.cpp index 6d6039cd98..70e63d9bb0 100644 --- a/src/modules/registrypreview/RegistryPreviewExt/dllmain.cpp +++ b/src/modules/registrypreview/RegistryPreviewExt/dllmain.cpp @@ -161,7 +161,7 @@ public: init_settings(); triggerEvent = CreateEvent(nullptr, false, false, CommonSharedConstants::REGISTRY_PREVIEW_TRIGGER_EVENT); - triggerEventWaiter = EventWaiter(CommonSharedConstants::REGISTRY_PREVIEW_TRIGGER_EVENT, [this](int) { + triggerEventWaiter.start(CommonSharedConstants::REGISTRY_PREVIEW_TRIGGER_EVENT, [this](DWORD) { on_hotkey(0); }); } @@ -274,7 +274,7 @@ public: Trace::EnableRegistryPreview(false); Logger::trace(L"Disabling Registry Preview..."); - // Yeet the Registry setting so preview doesn't work anymore + // Remove the Registry setting so preview doesn't work anymore const std::wstring installationDir = get_module_folderpath(); if (!getRegistryPreviewChangeSet(installationDir, true).unApply()) diff --git a/src/modules/registrypreview/RegistryPreviewExt/packages.config b/src/modules/registrypreview/RegistryPreviewExt/packages.config index ff4b059648..d3882436a5 100644 --- a/src/modules/registrypreview/RegistryPreviewExt/packages.config +++ b/src/modules/registrypreview/RegistryPreviewExt/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/AddressFormat.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/AddressFormat.cs new file mode 100644 index 0000000000..8219ed8b51 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/AddressFormat.cs @@ -0,0 +1,43 @@ +// 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. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox +{ + /// <summary> + /// Enumerates the address column formatting options. + /// </summary> + public enum AddressFormat + { + /// <summary> + /// 16 bit HEX address "0000". + /// </summary> + Address16, + + /// <summary> + /// 24 bit HEX address "00:0000". + /// </summary> + Address24, + + /// <summary> + /// 32 bit HEX address "0000:0000". + /// </summary> + Address32, + + /// <summary> + /// 48 bit HEX address "0000:00000000". + /// </summary> + Address48, + + /// <summary> + /// 64 bit HEX address "00000000:00000000". + /// </summary> + Address64, + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/CanvasCommands.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/CanvasCommands.cs new file mode 100644 index 0000000000..dca1f95725 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/CanvasCommands.cs @@ -0,0 +1,43 @@ +// 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. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> +using System; +using System.Windows.Input; + +namespace RegistryPreviewUILib.HexBox +{ + public class RelayCommand : ICommand + { + private readonly Action<object> _execute; + private readonly Func<object, bool> _canExecute; + + public RelayCommand(Action<object> execute, Func<object, bool> canExecute = null) + { + _execute = execute; + _canExecute = canExecute; + } + + public event EventHandler CanExecuteChanged; + + public bool CanExecute(object parameter) + { + return _canExecute == null || _canExecute(parameter); + } + + public void Execute(object parameter) + { + _execute(parameter); + } + + public void RaiseCanExecuteChanged() + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/DataFormat.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/DataFormat.cs new file mode 100644 index 0000000000..31929f8b88 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/DataFormat.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox +{ + /// <summary> + /// Enumerates the format to display integral data in. + /// </summary> + public enum DataFormat + { + /// <summary> + /// Display the data in decimal format. + /// </summary> + Decimal, + + /// <summary> + /// Display the data in hexadecimal format. + /// </summary> + Hexadecimal, + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/DataSignedness.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/DataSignedness.cs new file mode 100644 index 0000000000..4f5d95bc93 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/DataSignedness.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox +{ + /// <summary> + /// Enumerates the signedness of the data to display. + /// </summary> + public enum DataSignedness + { + /// <summary> + /// Display the data as signed values. + /// </summary> + Signed, + + /// <summary> + /// Display the data as unsigned values. + /// </summary> + Unsigned, + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/DataType.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/DataType.cs new file mode 100644 index 0000000000..c9619bfc6b --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/DataType.cs @@ -0,0 +1,43 @@ +// 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. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox +{ + /// <summary> + /// Enumerates how the data (bytes read from the buffer) is to be interpreted when displayed. + /// </summary> + public enum DataType + { + /// <summary> + /// Display the data as integral (integer) values. + /// </summary> + Int_1 = 1, + /// <summary> + /// Display the data as integral (integer) values. + /// </summary> + Int_2 = 2, + /// <summary> + /// Display the data as integral (integer) values. + /// </summary> + Int_4 = 4, + /// <summary> + /// Display the data as integral (integer) values. + /// </summary> + Int_8 = 8, + /// <summary> + /// Display the data as floating point values. + /// </summary> + Float_32 = 32, + /// <summary> + /// Display the data as floating point values. + /// </summary> + Float_64 = 64, + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/HexBox.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/HexBox.cs new file mode 100644 index 0000000000..46df5a60ba --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/HexBox.cs @@ -0,0 +1,2913 @@ +// 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. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> +#pragma warning disable SA1210 // Using directives should be ordered alphabetically by namespace +#pragma warning disable SA1208 // System using directives should be placed before other using directives +using RegistryPreviewUILib.HexBox.Library.EndianConvert; +using Microsoft.UI; +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using SkiaSharp; +using SkiaSharp.Views.Windows; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using System.Windows.Input; +using Windows.ApplicationModel.DataTransfer; +using Windows.Foundation; +using Windows.System; +using Windows.UI.Core; +#pragma warning restore SA1208 // System using directives should be placed before other using directives +#pragma warning restore SA1210 // Using directives should be ordered alphabetically by namespace + +// To learn more about WinUI, the WinUI project structure, +// and more about our project templates, see: http://aka.ms/winui-project-info. + +namespace RegistryPreviewUILib.HexBox +{ + [TemplatePart(Name = "ElementCanvas", Type = typeof(SKXamlCanvas))] + [TemplatePart(Name = "ElementScrollBar", Type = typeof(ScrollBar))] + public sealed class HexBox : Control, INotifyPropertyChanged + { + /// <summary> + /// Defines the address at which the data in the <see cref="DataSourceProperty"/> begins. + /// </summary> + public static readonly DependencyProperty AddressProperty = + DependencyProperty.Register(nameof(Address), typeof(ulong), typeof(HexBox), + new PropertyMetadata(0UL, OnAddressChanged)); + + /// <summary> + /// Defines the brush used to display the addresses in the address section of the control. + /// </summary> + public static readonly DependencyProperty AddressBrushProperty = + DependencyProperty.Register(nameof(AddressBrush), typeof(SolidColorBrush), typeof(HexBox), + new PropertyMetadata(new SolidColorBrush(Colors.CornflowerBlue), OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the width of the addresses displayed in the address section of the control. + /// </summary> + public static readonly DependencyProperty AddressFormatProperty = + DependencyProperty.Register(nameof(AddressFormat), typeof(AddressFormat), typeof(HexBox), + new PropertyMetadata(AddressFormat.Address32, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the brush used for alternating for text in alternating (odd numbered) columns in the data section of the control. + /// </summary> + public static readonly DependencyProperty AlternatingDataColumnTextBrushProperty = + DependencyProperty.Register(nameof(AlternatingDataColumnTextBrush), typeof(SolidColorBrush), typeof(HexBox), + new PropertyMetadata(new SolidColorBrush(Colors.Gray), OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the number of columns to display. + /// </summary> + public static readonly DependencyProperty ColumnsProperty = + DependencyProperty.Register(nameof(Columns), typeof(int), typeof(HexBox), + new PropertyMetadata(16, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the endianness used to interpret the data. + /// </summary> + public static readonly DependencyProperty EndiannessProperty = + DependencyProperty.Register(nameof(Endianness), typeof(Endianness), typeof(HexBox), + new PropertyMetadata(Endianness.BigEndian, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the format of the data to display. + /// </summary> + public static readonly DependencyProperty DataFormatProperty = + DependencyProperty.Register(nameof(DataFormat), typeof(DataFormat), typeof(HexBox), + new PropertyMetadata(DataFormat.Hexadecimal, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the signedness of the data to display. + /// </summary> + public static readonly DependencyProperty DataSignednessProperty = + DependencyProperty.Register(nameof(DataSignedness), typeof(DataSignedness), typeof(HexBox), + new PropertyMetadata(DataSignedness.Unsigned, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the data source which is used to read the data to display within this control. + /// </summary> + public static readonly DependencyProperty DataSourceProperty = + DependencyProperty.Register(nameof(DataSource), typeof(BinaryReader), typeof(HexBox), + new PropertyMetadata(null, OnDataSourceChanged)); + + /// <summary> + /// Defines the offset from the <see cref="DataSourceProperty"/> of the first visible data element being displayed. + /// </summary> + public static readonly DependencyProperty OffsetProperty = + DependencyProperty.Register(nameof(Offset), typeof(long), typeof(HexBox), + new PropertyMetadata(0L, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the maximum number of columns, based on the size of the control, which can be displayed. + /// </summary> + public static readonly DependencyProperty MaxVisibleColumnsProperty = + DependencyProperty.Register(nameof(MaxVisibleColumns), typeof(int), typeof(HexBox), + new PropertyMetadata(int.MaxValue, OnPropertyChangedInvalidateVisual)); + + + /// <summary> + /// Defines the maximum number of rows, based on the size of the control, which can be displayed. + /// </summary> + public static readonly DependencyProperty MaxVisibleRowsProperty = + DependencyProperty.Register(nameof(MaxVisibleRows), typeof(int), typeof(HexBox), + new PropertyMetadata(int.MaxValue, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the brush used for selection fill. + /// </summary> + public static readonly DependencyProperty SelectionBrushProperty = + DependencyProperty.Register(nameof(SelectionBrush), typeof(SolidColorBrush), typeof(HexBox), + new PropertyMetadata(new SolidColorBrush(Colors.LightPink), OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the brush used for selected text. + /// </summary> + public static readonly DependencyProperty SelectionTextBrushProperty = + DependencyProperty.Register(nameof(SelectionTextBrush), typeof(SolidColorBrush), typeof(HexBox), + new PropertyMetadata(new SolidColorBrush(Colors.Black), OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the offset from <see cref="DataSourceProperty"/> of where the user selection has ended. + /// </summary> + public static readonly DependencyProperty SelectionEndProperty = + DependencyProperty.Register(nameof(SelectionEnd), typeof(long), typeof(HexBox), + new PropertyMetadata(0L, OnSelectionEndChanged)); + + /// <summary> + /// Defines the offset from <see cref="DataSourceProperty"/> of where the user selection has started. + /// </summary> + public static readonly DependencyProperty SelectionStartProperty = + DependencyProperty.Register(nameof(SelectionStart), typeof(long), typeof(HexBox), + new PropertyMetadata(0L, OnSelectionStartChanged)); + + /// <summary> + /// Determines whether the user can change the layout and data format. + /// </summary> + public static readonly DependencyProperty EnforcePropertiesProperty = + DependencyProperty.Register(nameof(EnforceProperties), typeof(bool), typeof(HexBox), + new PropertyMetadata(false, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Determines whether to show the address section of the control. + /// </summary> + public static readonly DependencyProperty ShowAddressProperty = + DependencyProperty.Register(nameof(ShowAddress), typeof(bool), typeof(HexBox), + new PropertyMetadata(true, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Determines whether to show the data section of the control. + /// </summary> + public static readonly DependencyProperty ShowDataProperty = + DependencyProperty.Register(nameof(ShowData), typeof(bool), typeof(HexBox), + new PropertyMetadata(true, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Determines whether to show the text section of the control. + /// </summary> + public static readonly DependencyProperty ShowTextProperty = + DependencyProperty.Register(nameof(ShowText), typeof(bool), typeof(HexBox), + new PropertyMetadata(true, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Defines the brush used for the fill of the vertical separator line between the areas. + /// </summary> + public static readonly DependencyProperty VerticalSeparatorLineBrushProperty = + DependencyProperty.Register(nameof(VerticalSeparatorLineBrush), typeof(SolidColorBrush), typeof(HexBox), + new PropertyMetadata(new SolidColorBrush(Colors.Black), OnPropertyChangedInvalidateVisual)); + + + /// <summary> + /// Defines the format of the text to display in the text section. + /// </summary> + public static readonly DependencyProperty TextFormatProperty = + DependencyProperty.Register(nameof(TextFormat), typeof(TextFormat), typeof(HexBox), + new PropertyMetadata(TextFormat.Ascii, OnPropertyChangedInvalidateVisual)); + + /// <summary> + /// Gets the <see cref="SelectAll"/> command. + /// </summary> + public ICommand SelectAllCommand + { + get { return (ICommand)GetValue(SelectAllCommandProperty); } + set { SetValue(SelectAllCommandProperty, value); } + } + + // Using a DependencyProperty as the backing store for CopyCommand. This enables animation, styling, binding, etc... + public static readonly DependencyProperty SelectAllCommandProperty = + DependencyProperty.Register("SelectAllCommand", typeof(ICommand), typeof(HexBox), new PropertyMetadata(null)); + + /// <summary> + /// Gets the <see cref="Copy"/> command. + /// </summary> + public ICommand CopyCommand + { + get { return (ICommand)GetValue(CopyCommandProperty); } + set { SetValue(CopyCommandProperty, value); } + } + + // Using a DependencyProperty as the backing store for CopyCommand. This enables animation, styling, binding, etc... + public static readonly DependencyProperty CopyCommandProperty = + DependencyProperty.Register("CopyCommand", typeof(ICommand), typeof(HexBox), new PropertyMetadata(null)); + + /// <summary> + /// Gets the <see cref="CopyText"/> for text command. + /// </summary> + public ICommand CopyTextCommand + { + get { return (ICommand)GetValue(CopyTextCommandProperty); } + set { SetValue(CopyTextCommandProperty, value); } + } + + // Using a DependencyProperty as the backing store for CopyTextCommand. This enables animation, styling, binding, etc... + public static readonly DependencyProperty CopyTextCommandProperty = + DependencyProperty.Register("CopyTextCommand", typeof(ICommand), typeof(HexBox), new PropertyMetadata(null)); + + + private const int _MaxColumns = 128; + private const int _MaxRows = 128; + + private const int _CharsBetweenSections = 2; + private const int _CharsBetweenDataColumns = 1; + private const int _ScrollWheelScrollRows = 3; + + private Rect _AddressRect; + private Rect _DataRect; + private Rect _TextRect; + + private SKPaint _TextPaint; + private SKPaint _LinePaint; + private SKRect _TextMeasure; + private SKTypeface _TextTypeFace; + + private SKXamlCanvas _Canvas; + private string _CanvasName = "ElementCanvas"; + + private SelectionArea _HighlightBegin = SelectionArea.None; + private SelectionArea _HighlightState = SelectionArea.None; + + private double _LastVerticalScrollValue = 0; + + private ScrollBar _ScrollBar; + private string _ScrollBarName = "ElementScrollBar"; + + private SelectionAdjustment _pointerMoveSelectionAdjustment = SelectionAdjustment.None; + + /// <inheritdoc/> + public event PropertyChangedEventHandler PropertyChanged; + + private enum SelectionArea + { + None, + Address, + Data, + Text, + } + + private enum SelectionAdjustment + { + None, + Up, + Down + } + + /// <summary> + /// Gets or sets the address at which the data in the <see cref="DataSource"/> begins. + /// </summary> + public ulong Address + { + get => (ulong)GetValue(AddressProperty); + + set => SetValue(AddressProperty, value); + } + + /// <summary> + /// Gets or sets the brush used to display the addresses in the address section of the control. + /// </summary> + public SolidColorBrush AddressBrush + { + get => (SolidColorBrush)GetValue(AddressBrushProperty); + + set => SetValue(AddressBrushProperty, value); + } + + /// <summary> + /// Gets or sets the brush used for alternating for text in alternating (odd numbered) columns in the data section of the control. + /// </summary> + public SolidColorBrush AlternatingDataColumnTextBrush + { + get => (SolidColorBrush)GetValue(AlternatingDataColumnTextBrushProperty); + + set => SetValue(AlternatingDataColumnTextBrushProperty, value); + } + + /// <summary> + /// Gets or sets the number of columns to display. + /// </summary> + public int Columns + { + get => (int)GetValue(ColumnsProperty); + + set => SetValue(ColumnsProperty, CoerceColumns(this, value)); + } + + /// <summary> + /// Gets or sets the endianness used to interpret the data. + /// </summary> + public Endianness Endianness + { + get => (Endianness)GetValue(EndiannessProperty); + + set => SetValue(EndiannessProperty, value); + } + + /// <summary> + /// Gets or sets the format of the data to display. + /// </summary> + public DataFormat DataFormat + { + get => (DataFormat)GetValue(DataFormatProperty); + + set => SetValue(DataFormatProperty, value); + } + + /// <summary> + /// Gets or sets the signedness of the data to display. + /// </summary> + public DataSignedness DataSignedness + { + get => (DataSignedness)GetValue(DataSignednessProperty); + + set => SetValue(DataSignednessProperty, value); + } + + /// <summary> + /// Gets or sets the data source which is used to read the data to display within this control. + /// </summary> + public BinaryReader DataSource + { + get => (BinaryReader)GetValue(DataSourceProperty); + + set => SetValue(DataSourceProperty, value); + } + + /// <summary> + /// Gets or sets the data type which is used to display within this control. + /// </summary> + public DataType DataType + { + get { return (DataType)GetValue(DataTypeProperty); } + set => SetValue(DataTypeProperty, value); + } + + // Using a DependencyProperty as the backing store for DataType. This enables animation, styling, binding, etc... + public static readonly DependencyProperty DataTypeProperty = + DependencyProperty.Register("DataType", typeof(DataType), typeof(HexBox), new PropertyMetadata(DataType.Int_1, OnDataTypeChanged)); + + /// <summary> + /// Gets or sets the width of the data to display. + /// </summary> + private int DataWidth = 1; + + /// <summary> + /// Gets a value indicating whether the user has made any selection within the control. + /// </summary> + public bool IsSelectionActive => SelectionLength != 0; + + /// <summary> + /// Gets the maximum number of columns, based on the size of the control, which can be displayed. + /// </summary> + public int MaxVisibleColumns + { + get => (int)GetValue(MaxVisibleColumnsProperty); + + private set => SetValue(MaxVisibleColumnsProperty, CoerceMaxVisibleColumns(this, value)); + } + + /// <summary> + /// Gets the maximum number of rows, based on the size of the control, which can be displayed. + /// </summary> + public int MaxVisibleRows + { + get => (int)GetValue(MaxVisibleRowsProperty); + + private set => SetValue(MaxVisibleRowsProperty, CoerceMaxVisibleRows(this, value)); + } + + /// <summary> + /// Gets or sets the offset from the <see cref="DataSource"/> of the first visible data element being displayed. + /// </summary> + public long Offset + { + get => (long)GetValue(OffsetProperty); + + set => SetValue(OffsetProperty, CoerceOffset(this, value)); + } + + /// <summary> + /// Gets lowest order address currently being selected. + /// </summary> + public ulong SelectedAddress => Address + (ulong)SelectedOffset; + + /// <summary> + /// Gets the offset from <see cref="DataSource"/> of the <see cref="SelectedAddress"/>. + /// </summary> + public long SelectedOffset => Math.Min(SelectionStart, SelectionEnd); + + /// <summary> + /// Gets or sets the brush used for selection fill. + /// </summary> + public SolidColorBrush SelectionBrush + { + get => (SolidColorBrush)GetValue(SelectionBrushProperty); + + set => SetValue(SelectionBrushProperty, value); + } + + /// <summary> + /// Gets the offset from <see cref="DataSource"/> of where the user selection has ended. + /// </summary> + public long SelectionEnd + { + get => (long)GetValue(SelectionEndProperty); + + private set => SetValue(SelectionEndProperty, CoerceSelectionEnd(this, value)); + } + + /// <summary> + /// Gets the number of bytes selected. + /// </summary> + public long SelectionLength + { + get + { + if (SelectionStart <= SelectionEnd) + { + return SelectionEnd - SelectionStart; + } + else + { + return SelectionStart - SelectionEnd + _BytesPerColumn; + } + } + } + + /// <summary> + /// Gets the offset from <see cref="DataSource"/> of where the user selection has started. + /// </summary> + public long SelectionStart + { + get => (long)GetValue(SelectionStartProperty); + + private set + { + SetValue(SelectionStartProperty, CoerceSelectionStart(this, value)); + + // Reset SelectionStart adjustment state + _pointerMoveSelectionAdjustment = SelectionAdjustment.None; + } + } + + /// <summary> + /// Gets or sets the brush used for selected text. + /// </summary> + public SolidColorBrush SelectionTextBrush + { + get => (SolidColorBrush)GetValue(SelectionTextBrushProperty); + + set => SetValue(SelectionTextBrushProperty, value); + } + + /// <summary> + /// Gets or sets a value indicating whether the user can change the layout and data format or not. + /// </summary> + public bool EnforceProperties + { + get => (bool)GetValue(EnforcePropertiesProperty); + set => SetValue(EnforcePropertiesProperty, value); + } + + /// <summary> + /// Gets or sets a value indicating whether to show the address section of the control. + /// </summary> + public bool ShowAddress + { + get => (bool)GetValue(ShowAddressProperty); + + set => SetValue(ShowAddressProperty, value); + } + + /// <summary> + /// Gets or sets a value indicating whether to show the data section of the control. + /// </summary> + public bool ShowData + { + get => (bool)GetValue(ShowDataProperty); + + set => SetValue(ShowDataProperty, value); + } + + /// <summary> + /// Gets or sets a value indicating whether to show the text section of the control. + /// </summary> + public bool ShowText + { + get => (bool)GetValue(ShowTextProperty); + + set => SetValue(ShowTextProperty, value); + } + + /// <summary> + /// Gets or sets the brush used to display the vertical separator line between the control areas. + /// </summary> + public SolidColorBrush VerticalSeparatorLineBrush + { + get => (SolidColorBrush)GetValue(VerticalSeparatorLineBrushProperty); + + set => SetValue(VerticalSeparatorLineBrushProperty, value); + } + + /// <summary> + /// Gets or sets the width of the addresses displayed in the address section of the control. + /// </summary> + public AddressFormat AddressFormat + { + get => (AddressFormat)GetValue(AddressFormatProperty); + + set => SetValue(AddressFormatProperty, value); + } + + /// <summary> + /// Gets or sets the format of the text to display in the text section. + /// </summary> + public TextFormat TextFormat + { + get => (TextFormat)GetValue(TextFormatProperty); + + set => SetValue(TextFormatProperty, value); + } + + private double _SelectionBoxDataXPadding => _TextMeasure.Width / 4; + + private double _SelectionBoxDataYPadding => 0; + + private double _SelectionBoxTextXPadding => 0; + + private double _SelectionBoxTextYPadding => 0; + + private int _BytesPerColumn => DataWidth; + + private int _BytesPerRow => DataWidth * Columns; + + public class HighlightedRegion + { + public long Start; + public long Length; + public long End { get { return Start + Length; } } + public Brush Color; + + public HighlightedRegion() + { + + } + + public HighlightedRegion(int Start, int Length, Brush Color) + { + this.Start = Start; + this.Length = Length; + this.Color = Color; + } + + public bool IsByteSelected(long BytePos) + { + return BytePos >= Start && BytePos <= End; + } + } + + public List<HighlightedRegion> HighlightedRegions + { + get { return (List<HighlightedRegion>)GetValue(HighlightedRegionsProperty); } + set { SetValue(HighlightedRegionsProperty, value); } + } + + // Using a DependencyProperty as the backing store for HighlightedRegions. This enables animation, styling, binding, etc... + public static readonly DependencyProperty HighlightedRegionsProperty = + DependencyProperty.Register("HighlightedRegions", typeof(List<HighlightedRegion>), typeof(HexBox), new PropertyMetadata(new List<HighlightedRegion>(), OnPropertyChangedInvalidateVisual)); + + + /// <summary> + /// Clears the current selection + /// </summary> + public void ClearSelection() + { + SelectionStart = SelectionEnd = 0; + } + + + /// <summary> + /// Select all data. + /// </summary> + public void SelectAll() + { + SelectionStart = 0; + SelectionEnd = DataSource.BaseStream.Length; + } + + + /// <summary> + /// Copies the current selection of the control to the <see cref="Clipboard"/>. + /// </summary> + /// <param name="copyText">Copy the text and not the data.</param> + public void Copy(bool copyText) + { + if (IsSelectionActive) + { + StringBuilder builder = new(); + + long savedDataSourcePositionBeforeReadingData = DataSource.BaseStream.Position; + + // Adjust wrong SelectionEnd after selecting down or left to right + long selectionEnd = SelectionStart < SelectionEnd ? SelectionEnd - _BytesPerColumn : SelectionEnd; + + DataSource.BaseStream.Position = Math.Min(SelectionStart, selectionEnd); + + while (DataSource.BaseStream.Position <= Math.Max(SelectionStart, selectionEnd)) + { + if (copyText) + { + var formattedData = ReadFormattedText(); + builder.Append(formattedData); + } + else + { + var formattedData = ReadFormattedData(); + builder.Append(formattedData); + } + } + + DataSource.BaseStream.Position = savedDataSourcePositionBeforeReadingData; + + var dataPackage = new DataPackage(); + dataPackage.SetText(builder.ToString()); + Clipboard.SetContent(dataPackage); + } + } + + protected override void OnApplyTemplate() + { + base.OnApplyTemplate(); + + _Canvas = GetTemplateChild(_CanvasName) as SKXamlCanvas; + + if (_Canvas != null) + { + CopyCommand = new RelayCommand(CopyExecuted, CopyCanExecute); + CopyTextCommand = new RelayCommand(CopyTextExecuted, CopyCanExecute); + SelectAllCommand = new RelayCommand(SelectAllExecuted, SelectAllCanExecute); + _Canvas.PaintSurface += Canvas_PaintSurface; + } + else + { + throw new InvalidOperationException($"Could not find {_CanvasName} template child."); + } + + if (_ScrollBar != null) + { + _ScrollBar.Scroll -= OnVerticalScrollBarScroll; + } + + _ScrollBar = GetTemplateChild(_ScrollBarName) as ScrollBar; + + if (_ScrollBar != null) + { + _ScrollBar.Scroll += OnVerticalScrollBarScroll; + _ScrollBar.ValueChanged += OnVerticalScrollBarValueChanged; + + _ScrollBar.Minimum = 0; + _ScrollBar.SmallChange = 1; + _ScrollBar.LargeChange = MaxVisibleRows; + _TextTypeFace = SKTypeface.FromFamilyName(_ScrollBar.FontFamily.Source, SKFontStyle.Normal); + } + else + { + throw new InvalidOperationException($"Could not find {_ScrollBarName} template child."); + } + } + + private void DrawSelectionGeometry(SKCanvas Canvas, + Brush brush, + SKPaint pen, + Point point0, + Point point1, + SelectionArea relativeTo) + { + if ((long)point0.Y > (long)point1.Y) + { + throw new ArgumentException($"{point0.ToString()} > {point1.ToString()}", nameof(point0)); + } + + Point lhsVerticalLinePoint0; + Point rhsVerticalLinePoint0; + + double selectionBoxXPadding; + double selectionBoxYPadding; + + switch (relativeTo) + { + case SelectionArea.Data: + { + lhsVerticalLinePoint0 = new Point(_AddressRect.Left, _AddressRect.Top); + rhsVerticalLinePoint0 = new Point(_DataRect.Left, _DataRect.Top); + + selectionBoxXPadding = _SelectionBoxDataXPadding; + selectionBoxYPadding = _SelectionBoxDataYPadding; + } + + break; + + case SelectionArea.Text: + { + lhsVerticalLinePoint0 = new Point(_DataRect.Left, _DataRect.Top); + rhsVerticalLinePoint0 = new Point(_TextRect.Left, _TextRect.Top); + + selectionBoxXPadding = _SelectionBoxTextXPadding; + selectionBoxYPadding = _SelectionBoxTextYPadding; + } + + break; + + default: + { + throw new ArgumentException($"Invalid relative area {relativeTo}", nameof(relativeTo)); + } + } + + point0.X -= selectionBoxXPadding; + point1.X += selectionBoxXPadding; + point0.Y -= selectionBoxYPadding; + point1.Y += selectionBoxYPadding; + + var ps_CharsBetweenSections = _CharsBetweenSections * _TextMeasure.Width; + + SKPath path = new(); + SKPoint[] points; + + if ((long)point0.X < (long)point1.X) + { + if ((long)point0.Y < (long)point1.Y) + { + // +---------------------------+ + // | | + // | 0-------------2 + // | | | + // 6-------------7 1-------3 + // | | | + // 5-------------------4 | + // | | + // | | + // | | + // +---------------------------+ + Point point2 = new(rhsVerticalLinePoint0.X - ps_CharsBetweenSections + selectionBoxXPadding, point0.Y); + Point point3 = new(rhsVerticalLinePoint0.X - ps_CharsBetweenSections + selectionBoxXPadding, point1.Y); + Point point4 = new(point1.X, point1.Y + _TextMeasure.Height); + Point point5 = new(lhsVerticalLinePoint0.X + ps_CharsBetweenSections - selectionBoxXPadding, point1.Y + _TextMeasure.Height); + Point point6 = new(lhsVerticalLinePoint0.X + ps_CharsBetweenSections - selectionBoxXPadding, point0.Y + _TextMeasure.Height); + Point point7 = new(point0.X, point0.Y + _TextMeasure.Height); + + points = [point0.ToSKPoint(), point2.ToSKPoint(), point3.ToSKPoint(), point1.ToSKPoint(), point4.ToSKPoint(), point5.ToSKPoint(), point6.ToSKPoint(), point7.ToSKPoint()]; + } + else + { + // +---------------------------+ + // | | + // | 0-------------1 | + // | | | | + // | 3-------------2 | + // | | + // | | + // | | + // | | + // | | + // +---------------------------+ + Point point2 = new(point1.X, point1.Y + _TextMeasure.Height); + Point point3 = new(point0.X, point0.Y + _TextMeasure.Height); + + points = [point0.ToSKPoint(), point1.ToSKPoint(), point2.ToSKPoint(), point3.ToSKPoint()]; + } + } + else + { + if ((long)(point0.Y + _TextMeasure.Height) == (long)point1.Y) + { + // +---------------------------+ + // | | + // | 0-------------2 + // | | | + // 7--------1 4-------------3 + // | | | + // 6--------5 | + // | | + // | | + // | | + // +---------------------------+ + { + Point point2 = new(rhsVerticalLinePoint0.X - ps_CharsBetweenSections + selectionBoxXPadding, point0.Y); + Point point3 = new(rhsVerticalLinePoint0.X - ps_CharsBetweenSections + selectionBoxXPadding, point1.Y); + Point point4 = new(point0.X, point1.Y); + + points = [point0.ToSKPoint(), point2.ToSKPoint(), point3.ToSKPoint(), point4.ToSKPoint()]; + } + + path.AddPoly(points); + + { + Point point5 = new(point1.X, point1.Y + _TextMeasure.Height); + Point point6 = new(lhsVerticalLinePoint0.X + ps_CharsBetweenSections - selectionBoxXPadding, point1.Y + _TextMeasure.Height); + Point point7 = new(lhsVerticalLinePoint0.X + ps_CharsBetweenSections - selectionBoxXPadding, point1.Y); + points = [point1.ToSKPoint(), point5.ToSKPoint(), point6.ToSKPoint(), point7.ToSKPoint()]; + } + } + else + { + // +---------------------------+ + // | | + // | 0-------------2 + // | | | + // 6-------------7 | + // | | + // | 1------------------3 + // | | | + // 5--------4 | + // | | + // +---------------------------+ + Point point2 = new(rhsVerticalLinePoint0.X - ps_CharsBetweenSections + selectionBoxXPadding, point0.Y); + Point point3 = new(rhsVerticalLinePoint0.X - ps_CharsBetweenSections + selectionBoxXPadding, point1.Y); + Point point4 = new(point1.X, point1.Y + _TextMeasure.Height); + Point point5 = new(lhsVerticalLinePoint0.X + ps_CharsBetweenSections - selectionBoxXPadding, point1.Y + _TextMeasure.Height); + Point point6 = new(lhsVerticalLinePoint0.X + ps_CharsBetweenSections - selectionBoxXPadding, point0.Y + _TextMeasure.Height); + Point point7 = new(point0.X, point0.Y + _TextMeasure.Height); + + points = [point0.ToSKPoint(), point2.ToSKPoint(), point3.ToSKPoint(), point1.ToSKPoint(), point4.ToSKPoint(), point5.ToSKPoint(), point6.ToSKPoint(), point7.ToSKPoint()]; + } + } + + path.AddPoly(points); + if (brush is SolidColorBrush s) + pen.Color = s.Color.ToSKColor(); + Canvas.DrawPath(path, pen); + } + + private void DrawTextAccuracy(SKCanvas Canvas, SKPaint paint, SKPoint pt, string text) + { + //Canvas.DrawText(text, pt, paint); + int index = 0; + foreach (var c in text) + { + Canvas.DrawText(c.ToString(), pt.X + index * _TextMeasure.Width, pt.Y, paint); + index++; + } + } + + private void Canvas_PaintSurface(object sender, SKPaintSurfaceEventArgs e) + { + var view = sender as SKXamlCanvas; + var canvas = e.Surface.Canvas; + + if (_LinePaint == null) + { + _LinePaint = new() + { + IsStroke = true, + IsAntialias = true, + StrokeWidth = 1, + TextSize = (float)FontSize, + Typeface = _TextTypeFace, + TextAlign = SKTextAlign.Left, + }; + } + _LinePaint.Color = VerticalSeparatorLineBrush.Color.ToSKColor(); + + if (_TextPaint == null) + { + _TextPaint = new() + { + TextSize = (float)FontSize, + Typeface = _TextTypeFace, + TextScaleX = 1f, + IsAntialias = true, + TextAlign = SKTextAlign.Left, + HintingLevel = SKPaintHinting.Normal, + }; + } + + UpdateState(); + + if (DataSource != null) + { + canvas.Clear(); + long savedDataSourcePosition = DataSource.BaseStream.Position; + + DataSource.BaseStream.Position = Offset; + + if (ShowAddress) + { + var p0 = new Point(_AddressRect.Left, _AddressRect.Top).ToSKPoint(); + var p1 = new Point(_AddressRect.Right, _AddressRect.Bottom).ToSKPoint(); + + canvas.DrawLine(p0, p1, _LinePaint); + } + + if (ShowData) + { + var p0 = new Point(_DataRect.Left, _DataRect.Top).ToSKPoint(); + var p1 = new Point(_DataRect.Right, _DataRect.Bottom).ToSKPoint(); + + canvas.DrawLine(p0, p1, _LinePaint); + + if (HighlightedRegions.Count != 0 && MaxVisibleRows > 0 && Columns > 0) + { + var viewLimited = Offset + _BytesPerRow * MaxVisibleRows; + + foreach (var hlSection in HighlightedRegions) + { + if (hlSection.End <= Offset || (hlSection.Start >= viewLimited) || hlSection.Start >= hlSection.End) continue; + + var max_visible = Math.Min(hlSection.End, viewLimited); + + Point hlsP0 = ConvertOffsetToPosition(hlSection.Start, SelectionArea.Data); + Point hlsP1 = ConvertOffsetToPosition(max_visible, SelectionArea.Data); + + if (max_visible % _BytesPerRow == 0) + { + hlsP1.X = p1.X - _CharsBetweenSections * _TextMeasure.Width; + hlsP1.Y = Math.Max(hlsP0.Y, hlsP1.Y - _TextMeasure.Height); + } + else + { + hlsP1.X -= _TextMeasure.Width; + } + + DrawSelectionGeometry(canvas, hlSection.Color, _TextPaint, hlsP0, hlsP1, SelectionArea.Data); + } + } + } + + if (ShowText) + { + var p0 = new Point(_TextRect.Left, _TextRect.Top); + var p1 = new Point(_TextRect.Right, _TextRect.Bottom); + + canvas.DrawLine(p0.ToSKPoint(), p1.ToSKPoint(), _LinePaint); + + if (HighlightedRegions.Count != 0 && MaxVisibleRows > 0 && Columns > 0) + { + var viewLimited = Offset + MaxVisibleColumns * MaxVisibleRows; + + foreach (var hlSection in HighlightedRegions) + { + if (hlSection.End <= Offset || (hlSection.Start >= viewLimited) || hlSection.Start >= hlSection.End) continue; + + var max_visible = Math.Min(hlSection.End, viewLimited); + + Point hlsP0 = ConvertOffsetToPosition(hlSection.Start, SelectionArea.Text); + Point hlsP1 = ConvertOffsetToPosition(max_visible, SelectionArea.Text); + + if (max_visible % _BytesPerRow == 0) + { + hlsP1.X = p1.X - _CharsBetweenSections * _TextMeasure.Width; + hlsP1.Y = Math.Max(hlsP0.Y, hlsP1.Y - _TextMeasure.Height); + } + + DrawSelectionGeometry(canvas, hlSection.Color, _TextPaint, hlsP0, hlsP1, SelectionArea.Text); + } + } + } + + if (ShowData) + { + if (SelectionLength != 0 && MaxVisibleRows > 0 && Columns > 0) + { + Point sp0 = ConvertOffsetToPosition(SelectedOffset, SelectionArea.Data); + Point sp1 = ConvertOffsetToPosition(SelectedOffset + SelectionLength, SelectionArea.Data); + + if ((SelectedOffset + SelectionLength) % _BytesPerRow == 0) + { + sp1.X = _DataRect.Left - _CharsBetweenSections * _TextMeasure.Width; + sp1.Y = Math.Max(sp0.Y, sp1.Y - _TextMeasure.Height); + } + else + { + sp1.X -= _TextMeasure.Width; + } + + DrawSelectionGeometry(canvas, SelectionBrush, _TextPaint, sp0, sp1, SelectionArea.Data); + } + } + + if (ShowText) + { + if (SelectionLength != 0 && MaxVisibleRows > 0 && Columns > 0) + { + Point sp0 = ConvertOffsetToPosition(SelectedOffset, SelectionArea.Text); + Point sp1 = ConvertOffsetToPosition(SelectedOffset + SelectionLength, SelectionArea.Text); + + if ((SelectedOffset + SelectionLength) % _BytesPerRow == 0) + { + sp1.X = _TextRect.Left - _CharsBetweenSections * _TextMeasure.Width; + sp1.Y -= _TextMeasure.Height; + } + + DrawSelectionGeometry(canvas, SelectionBrush, _TextPaint, sp0, sp1, SelectionArea.Text); + } + } + + SKPoint origin = default; + origin.Y = _TextMeasure.Height * 3 / 4; /* left bottom to right top */ + + for (var row = 0; row < MaxVisibleRows; ++row) + { + if (ShowAddress) + { + if (DataSource.BaseStream.Position + _BytesPerColumn <= DataSource.BaseStream.Length) + { + var textToFormat = GetFormattedAddressText(Address + (ulong)DataSource.BaseStream.Position); + + if (AddressBrush is SolidColorBrush s) + { + _TextPaint.Color = s.Color.ToSKColor(); + } + canvas.DrawText(textToFormat, origin.X, origin.Y, _TextPaint); + + origin.X += (float)((CalculateAddressColumnCharWidth() + _CharsBetweenSections) * _TextMeasure.Width); + } + } + + long savedDataSourcePositionBeforeReadingData = DataSource.BaseStream.Position; + + if (ShowData) + { + origin.X += (float)(_CharsBetweenSections * _TextMeasure.Width); + + var cachedDataColumnCharWidth = CalculateDataColumnCharWidth(); + + // Needed to track text in alternating columns so we can use a different brush when drawing + var evenColumnBuilder = new StringBuilder(Columns * DataWidth); + var oddColumnBuilder = new StringBuilder(Columns * DataWidth); + + var column = 0; + + // Draw text up until selection start point + while (column < Columns) + { + if (DataSource.BaseStream.Position + _BytesPerColumn <= DataSource.BaseStream.Length) + { + if (DataSource.BaseStream.Position >= SelectedOffset) + { + break; + } + + var textToFormat = ReadFormattedData(); + + if (column % 2 == 0) + { + evenColumnBuilder.Append(textToFormat); + evenColumnBuilder.Append(' ', _CharsBetweenDataColumns); + + oddColumnBuilder.Append(' ', textToFormat.Length + _CharsBetweenDataColumns); + } + else + { + oddColumnBuilder.Append(textToFormat); + oddColumnBuilder.Append(' ', _CharsBetweenDataColumns); + + evenColumnBuilder.Append(' ', textToFormat.Length + _CharsBetweenDataColumns); + } + } + else + { + evenColumnBuilder.Append(' ', cachedDataColumnCharWidth + _CharsBetweenDataColumns); + oddColumnBuilder.Append(' ', cachedDataColumnCharWidth + _CharsBetweenDataColumns); + } + + ++column; + } + + { + if (Foreground is SolidColorBrush s) + { + _TextPaint.Color = s.Color.ToSKColor(); + } + DrawTextAccuracy(canvas, _TextPaint, origin, evenColumnBuilder.ToString()); + } + + { + if (AlternatingDataColumnTextBrush is SolidColorBrush s) + { + _TextPaint.Color = s.Color.ToSKColor(); + } + DrawTextAccuracy(canvas, _TextPaint, origin, oddColumnBuilder.ToString()); + } + origin.X += evenColumnBuilder.Length * _TextMeasure.Width; + + if (column < Columns) + { + // We'll reuse this builder for drawing selection text + evenColumnBuilder.Clear(); + + // Draw text starting from selection start point + while (column < Columns) + { + if (DataSource.BaseStream.Position + _BytesPerColumn <= DataSource.BaseStream.Length) + { + if (DataSource.BaseStream.Position >= SelectedOffset + SelectionLength) + { + break; + } + + var textToFormat = ReadFormattedData(); + + evenColumnBuilder.Append(textToFormat); + evenColumnBuilder.Append(' ', _CharsBetweenDataColumns); + } + else + { + evenColumnBuilder.Append(' ', cachedDataColumnCharWidth + _CharsBetweenDataColumns); + } + + ++column; + } + + { + if (SelectionTextBrush is SolidColorBrush s) + { + _TextPaint.Color = s.Color.ToSKColor(); + } + DrawTextAccuracy(canvas, _TextPaint, origin, evenColumnBuilder.ToString()); + } + + origin.X += evenColumnBuilder.Length * _TextMeasure.Width; + + if (column < Columns) + { + evenColumnBuilder.Clear(); + oddColumnBuilder.Clear(); + + // Draw text after end of selection + while (column < Columns) + { + if (DataSource.BaseStream.Position + _BytesPerColumn <= DataSource.BaseStream.Length) + { + var textToFormat = ReadFormattedData(); + if (column % 2 == 0) + { + evenColumnBuilder.Append(textToFormat); + evenColumnBuilder.Append(' ', _CharsBetweenDataColumns); + + oddColumnBuilder.Append(' ', textToFormat.Length + _CharsBetweenDataColumns); + } + else + { + oddColumnBuilder.Append(textToFormat); + oddColumnBuilder.Append(' ', _CharsBetweenDataColumns); + + evenColumnBuilder.Append(' ', textToFormat.Length + _CharsBetweenDataColumns); + } + } + else + { + evenColumnBuilder.Append(' ', cachedDataColumnCharWidth + _CharsBetweenDataColumns); + oddColumnBuilder.Append(' ', cachedDataColumnCharWidth + _CharsBetweenDataColumns); + } + + ++column; + } + + { + if (Foreground is SolidColorBrush s) + { + _TextPaint.Color = s.Color.ToSKColor(); + } + DrawTextAccuracy(canvas, _TextPaint, origin, evenColumnBuilder.ToString()); + } + + { + if (AlternatingDataColumnTextBrush is SolidColorBrush s) + { + _TextPaint.Color = s.Color.ToSKColor(); + } + DrawTextAccuracy(canvas, _TextPaint, origin, oddColumnBuilder.ToString()); + } + + origin.X += oddColumnBuilder.Length * _TextMeasure.Width; + } + } + + // Compensate for the extra space added at the end of the builder + origin.X += (float)((_CharsBetweenSections - _CharsBetweenDataColumns) * _TextMeasure.Width); + } + + if (ShowText) + { + origin.X += (float)(_CharsBetweenSections * _TextMeasure.Width); + + if (ShowData) + { + // Reset the stream to read one byte at a time + DataSource.BaseStream.Position = savedDataSourcePositionBeforeReadingData; + } + + var builder = new StringBuilder(Columns * DataWidth); + + var column = 0; + + // Draw text up until selection start point + while (column < Columns) + { + if (DataSource.BaseStream.Position + _BytesPerColumn <= DataSource.BaseStream.Length) + { + if (DataSource.BaseStream.Position >= SelectedOffset) + { + break; + } + + var textToFormat = ReadFormattedText(); + builder.Append(textToFormat); + } + + ++column; + } + + { + if (Foreground is SolidColorBrush s) + { + _TextPaint.Color = s.Color.ToSKColor(); + } + DrawTextAccuracy(canvas, _TextPaint, origin, builder.ToString()); + } + + if (column < Columns) + { + origin.X += builder.Length * _TextMeasure.Width; + + builder.Clear(); + + // Draw text starting from selection start point + while (column < Columns) + { + if (DataSource.BaseStream.Position + _BytesPerColumn <= DataSource.BaseStream.Length) + { + if (DataSource.BaseStream.Position >= SelectedOffset + SelectionLength) + { + break; + } + + var textToFormat = ReadFormattedText(); + builder.Append(textToFormat); + } + + ++column; + } + + { + if (SelectionTextBrush is SolidColorBrush s) + { + _TextPaint.Color = s.Color.ToSKColor(); + } + + DrawTextAccuracy(canvas, _TextPaint, origin, builder.ToString()); + } + + if (column < Columns) + { + origin.X += builder.Length * _TextMeasure.Width; + + builder.Clear(); + + // Draw text after end of selection + while (column < Columns) + { + if (DataSource.BaseStream.Position + _BytesPerColumn <= DataSource.BaseStream.Length) + { + var textToFormat = ReadFormattedText(); + builder.Append(textToFormat); + } + + ++column; + } + + { + if (Foreground is SolidColorBrush s) + { + _TextPaint.Color = s.Color.ToSKColor(); + } + + DrawTextAccuracy(canvas, _TextPaint, origin, builder.ToString()); + } + } + } + } + + origin.X = 0; + origin.Y += _TextMeasure.Height; + } + + DataSource.BaseStream.Position = savedDataSourcePosition; + } + } + + /// <summary> + /// Scrolls the contents of the control to the specified offset. + /// </summary> + /// + /// <param name="offset"> + /// The offset to scroll to. + /// </param> + public void ScrollToOffset(long offset) + { + long maxBytesDisplayed = _BytesPerRow * MaxVisibleRows; + long lastByteOffset = (DataSource?.BaseStream?.Length ?? 1) - 1; + + // Adjust requested offset if not existing + if (offset < 0) + { + offset = 0; + } + else if (offset > lastByteOffset) + { + offset = lastByteOffset; + } + + if (Offset > offset) + { + // We need to scroll up + Offset -= ((Offset - offset - 1) / _BytesPerRow + 1) * _BytesPerRow; + } + + if (Offset + maxBytesDisplayed <= offset) + { + // We need to scroll down + Offset += ((offset - (Offset + maxBytesDisplayed)) / _BytesPerRow + 1) * _BytesPerRow; + } + } + + // Using .HasFlag(x) to correctly detect state of modifier keys (CTRL, SHIFT, ...) + private static bool IsKeyDown(VirtualKey key) => InputKeyboardSource.GetKeyStateForCurrentThread(key).HasFlag(CoreVirtualKeyStates.Down); + + /// <inheritdoc/> + protected override void OnKeyDown(KeyRoutedEventArgs e) + { + base.OnKeyDown(e); + + // Context Menu + switch (e.Key) + { + case VirtualKey.Application: + { + ShowContextMenu(); + e.Handled = true; + return; + } + + case VirtualKey.F10: + { + if (IsKeyDown(VirtualKey.LeftShift) || IsKeyDown(VirtualKey.RightShift)) + { + ShowContextMenu(); + } + + e.Handled = true; + return; + } + } + + // Other keys + if (Columns > 0 && MaxVisibleRows > 0) + { + switch (e.Key) + { + case VirtualKey.A: + { + if (IsKeyDown(VirtualKey.LeftControl) || IsKeyDown(VirtualKey.RightControl)) + { + if (SelectAllCanExecute(null)) + { + SelectionStart = 0; + SelectionEnd = DataSource.BaseStream.Length; + } + } + + e.Handled = true; + break; + } + + case VirtualKey.C: + { + if (IsKeyDown(VirtualKey.LeftControl) || IsKeyDown(VirtualKey.RightControl)) + { + if (CopyCanExecute(null)) + { + if (IsKeyDown(VirtualKey.LeftShift) || IsKeyDown(VirtualKey.RightShift)) + { + // Copy text + Copy(true); + } + else + { + // Copy data + Copy(false); + } + } + } + + e.Handled = true; + break; + } + + case VirtualKey.Down: + { + if (IsKeyDown(VirtualKey.LeftShift) || IsKeyDown(VirtualKey.RightShift)) + { + SelectionEnd += _BytesPerRow; + } + else + { + SelectionStart += _BytesPerRow; + SelectionEnd = SelectionStart + _BytesPerColumn; + } + + ScrollToOffset(SelectionEnd - _BytesPerColumn); + + e.Handled = true; + + break; + } + + case VirtualKey.End: + { + if (IsKeyDown(VirtualKey.LeftControl) || IsKeyDown(VirtualKey.RightControl)) + { + SelectionEnd = DataSource.BaseStream.Length; + + if (!IsKeyDown(VirtualKey.LeftShift) && !IsKeyDown(VirtualKey.RightShift)) + { + SelectionStart = SelectionEnd - _BytesPerColumn; + } + + ScrollToOffset(SelectionEnd - _BytesPerColumn); + } + else + { + SelectionEnd += (Offset - SelectionEnd).Mod(_BytesPerRow); + + if (!IsKeyDown(VirtualKey.LeftShift) && !IsKeyDown(VirtualKey.RightShift)) + { + SelectionStart = SelectionEnd - _BytesPerColumn; + } + + ScrollToOffset(SelectionEnd - _BytesPerColumn); + } + + e.Handled = true; + + break; + } + + case VirtualKey.Home: + { + if (IsKeyDown(VirtualKey.LeftControl) || IsKeyDown(VirtualKey.RightControl)) + { + SelectionEnd = 0; + + if (!IsKeyDown(VirtualKey.LeftShift) && !IsKeyDown(VirtualKey.RightShift)) + { + SelectionStart = SelectionEnd; + SelectionEnd = SelectionStart + _BytesPerColumn; + } + + ScrollToOffset(SelectionEnd - _BytesPerColumn); + } + else + { + // TODO: Because of the way we represent selection there is no way to distinguish at the + // moment whether the selection ends at the start of the current line or the end of the + // previous line. As such, when the Shift+End hotkey is used twice consecutively a whole + // new line above the current selection will be selected. This is undesirable behavior + // that deviates from the canonical semantics of Shift+End. + SelectionEnd -= (SelectionEnd - 1 - Offset).Mod(_BytesPerRow) + 1; + + if (!IsKeyDown(VirtualKey.LeftShift) && !IsKeyDown(VirtualKey.RightShift)) + { + SelectionStart = SelectionEnd; + SelectionEnd = SelectionStart + _BytesPerColumn; + } + + ScrollToOffset(SelectionEnd - _BytesPerColumn); + } + + e.Handled = true; + + break; + } + + case VirtualKey.Left: + { + if (IsKeyDown(VirtualKey.LeftShift) || IsKeyDown(VirtualKey.RightShift)) + { + SelectionEnd -= _BytesPerColumn; + } + else + { + SelectionStart -= _BytesPerColumn; + SelectionEnd = SelectionStart + _BytesPerColumn; + } + + ScrollToOffset(SelectionEnd - _BytesPerColumn); + + e.Handled = true; + + break; + } + + case VirtualKey.PageDown: + { + bool isOffsetVisibleBeforeSelectionChange = IsOffsetVisible(SelectionEnd); + + SelectionEnd += _BytesPerRow * MaxVisibleRows; + + if (!IsKeyDown(VirtualKey.LeftShift) && !IsKeyDown(VirtualKey.RightShift)) + { + SelectionStart = SelectionEnd - _BytesPerColumn; + } + + _ScrollBar.Value += MaxVisibleRows; + + OnVerticalScrollBarScroll(_ScrollBar, ScrollEventType.SmallIncrement, _ScrollBar.Value); + + e.Handled = true; + break; + } + + case VirtualKey.PageUp: + { + bool isOffsetVisibleBeforeSelectionChange = IsOffsetVisible(SelectionEnd); + + SelectionEnd -= _BytesPerRow * MaxVisibleRows; + + if (!IsKeyDown(VirtualKey.LeftShift) && !IsKeyDown(VirtualKey.RightShift)) + { + SelectionStart = SelectionEnd - _BytesPerColumn; + SelectionEnd = SelectionStart + _BytesPerColumn; + } + + _ScrollBar.Value -= MaxVisibleRows; + + OnVerticalScrollBarScroll(_ScrollBar, ScrollEventType.SmallIncrement, _ScrollBar.Value); + + e.Handled = true; + break; + } + + case VirtualKey.Right: + { + if (IsKeyDown(VirtualKey.LeftShift) || IsKeyDown(VirtualKey.RightShift)) + { + SelectionEnd += _BytesPerColumn; + } + else + { + SelectionStart += _BytesPerColumn; + SelectionEnd = SelectionStart + _BytesPerColumn; + } + + ScrollToOffset(SelectionEnd - _BytesPerColumn); + + e.Handled = true; + break; + } + + case VirtualKey.Up: + { + if (IsKeyDown(VirtualKey.LeftShift) || IsKeyDown(VirtualKey.RightShift)) + { + SelectionEnd -= _BytesPerRow; + } + else + { + SelectionStart -= _BytesPerRow; + SelectionEnd = SelectionStart + _BytesPerColumn; + } + + ScrollToOffset(SelectionEnd - _BytesPerColumn); + + e.Handled = true; + break; + } + } + } + } + + protected override void OnDoubleTapped(DoubleTappedRoutedEventArgs e) + { + Focus(FocusState.Programmatic); + e.Handled = true; + + if (e.PointerDeviceType == PointerDeviceType.Mouse) + { + OnMouseDoubleClick(e.GetPosition(_Canvas)); + } + } + + protected override void OnPointerPressed(PointerRoutedEventArgs e) + { + Focus(FocusState.Programmatic); + e.Handled = true; + + var pps = e.GetCurrentPoint(this).Properties; + if (pps != null) + { + if (pps.PointerUpdateKind == PointerUpdateKind.LeftButtonPressed) + { + OnMouseLeftButtonDown(e); + } + } + } + + protected override void OnPointerReleased(PointerRoutedEventArgs e) + { + base.OnPointerReleased(e); + var pps = e.GetCurrentPoint(this).Properties; + if (pps != null) + { + if (pps.PointerUpdateKind == PointerUpdateKind.LeftButtonReleased) + { + OnMouseLeftButtonUp(e); + } + } + } + + protected override void OnPointerCanceled(PointerRoutedEventArgs e) + { + base.OnPointerCanceled(e); + var pps = e.GetCurrentPoint(this).Properties; + if (pps != null) + { + if (pps.PointerUpdateKind == PointerUpdateKind.LeftButtonReleased) + { + OnMouseLeftButtonUp(e); + } + } + } + + protected override void OnPointerCaptureLost(PointerRoutedEventArgs e) + { + base.OnPointerCaptureLost(e); + } + + protected override void OnPointerExited(PointerRoutedEventArgs e) + { + base.OnPointerExited(e); + } + + protected override void OnPointerEntered(PointerRoutedEventArgs e) + { + base.OnPointerEntered(e); + } + + /// <inheritdoc/> + protected override void OnPointerMoved(PointerRoutedEventArgs e) + { + base.OnPointerMoved(e); + + if (_HighlightState != SelectionArea.None) + { + var position = e.GetCurrentPoint(_Canvas).Position; + var currentMouseOverOffset = ConvertPositionToOffset(position); + + switch (_HighlightState) + { + case SelectionArea.Address: + { + if (currentMouseOverOffset >= SelectionStart) + { + SelectionEnd = currentMouseOverOffset + _BytesPerRow; + } + else + { + SelectionEnd = currentMouseOverOffset; + } + + // Adjust start point + if (SelectionStart > SelectionEnd && _pointerMoveSelectionAdjustment != SelectionAdjustment.Up) + { + // If moving up and SelectionStart was previously adjusted down or not adjusted, then set SelectionStart to end of row. + SelectionStart = SelectionStart + (_BytesPerRow - _BytesPerColumn); + _pointerMoveSelectionAdjustment = SelectionAdjustment.Up; + } + else if (SelectionStart < SelectionEnd && _pointerMoveSelectionAdjustment == SelectionAdjustment.Up) + { + // If moving down and SelectionStart was previously adjusted up, then set SelectionStart to start of row. + SelectionStart = SelectionStart - (_BytesPerRow - _BytesPerColumn); + _pointerMoveSelectionAdjustment = SelectionAdjustment.Down; + } + break; + } + case SelectionArea.Data: + case SelectionArea.Text: + { + if (currentMouseOverOffset >= SelectionStart) + { + SelectionEnd = currentMouseOverOffset + _BytesPerColumn; + } + else + { + SelectionEnd = currentMouseOverOffset; + } + break; + } + } + + // Move next row into view if selection goes out of view + if (position.Y > _AddressRect.Y + _AddressRect.Height) + { + ScrollToOffset(currentMouseOverOffset + _BytesPerRow); + } + else if (position.Y < _AddressRect.Y) + { + ScrollToOffset(currentMouseOverOffset - _BytesPerRow); + } + } + } + + /// <inheritdoc/> + protected override void OnPointerWheelChanged(PointerRoutedEventArgs e) + { + base.OnPointerWheelChanged(e); + var Delta = e.GetCurrentPoint(this).Properties.MouseWheelDelta; + + var value = _ScrollBar.Value; + if (Delta < 0) + { + _ScrollBar.Value += _ScrollWheelScrollRows; + + OnVerticalScrollBarScroll(_ScrollBar, ScrollEventType.SmallIncrement, _ScrollBar.Value); + } + else + { + _ScrollBar.Value -= _ScrollWheelScrollRows; + + OnVerticalScrollBarScroll(_ScrollBar, ScrollEventType.SmallDecrement, _ScrollBar.Value); + } + } + + /// <inheritdoc/> + private void OnMouseDoubleClick(Point position) + { + Point addressVerticalLinePoint0 = CalculateAddressVerticalLinePoint0(); + + if (position.X < addressVerticalLinePoint0.X) + { + _HighlightBegin = SelectionArea.Address; + _HighlightState = SelectionArea.Address; + + SelectionStart = ConvertPositionToOffset(position); + SelectionEnd = SelectionStart + _BytesPerRow; + } + } + + /// <inheritdoc/> + private void OnMouseLeftButtonDown(PointerRoutedEventArgs e) + { + if (_HighlightState == SelectionArea.None && CapturePointer(e.Pointer)) + { + Point position = e.GetCurrentPoint(_Canvas).Position; + + Point addressVerticalLinePoint0 = CalculateAddressVerticalLinePoint0(); + Point dataVerticalLinePoint0 = CalculateDataVerticalLinePoint0(); + Point textVerticalLinePoint0 = CalculateTextVerticalLinePoint0(); + + if (position.X < addressVerticalLinePoint0.X) + { + _HighlightBegin = SelectionArea.Address; + _HighlightState = SelectionArea.Address; + } + else if (position.X < dataVerticalLinePoint0.X) + { + _HighlightBegin = SelectionArea.Data; + _HighlightState = SelectionArea.Data; + } + else if (position.X < textVerticalLinePoint0.X) + { + _HighlightBegin = SelectionArea.Text; + _HighlightState = SelectionArea.Text; + } + + if (_HighlightState != SelectionArea.None) + { + SelectionStart = ConvertPositionToOffset(position); + + SelectionEnd = SelectionStart + _BytesPerColumn; + } + } + } + + /// <inheritdoc/> + private void OnMouseLeftButtonUp(PointerRoutedEventArgs e) + { + _HighlightState = SelectionArea.None; + + ReleasePointerCapture(e.Pointer); + } + + private static void OnPropertyChangedInvalidateVisual(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var HexBox = (HexBox)d; + + HexBox.Reflush(); + } + + private static void OnSelectionEndChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var HexBox = (HexBox)d; + + HexBox.Reflush(); + + HexBox.OnPropertyChanged(nameof(SelectionEnd)); + HexBox.OnPropertyChanged(nameof(SelectionLength)); + HexBox.OnPropertyChanged(nameof(SelectedOffset)); + HexBox.OnPropertyChanged(nameof(SelectedAddress)); + HexBox.OnPropertyChanged(nameof(IsSelectionActive)); + } + + private static void OnSelectionStartChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var HexBox = (HexBox)d; + + HexBox.Reflush(); + + HexBox.OnPropertyChanged(nameof(SelectionStart)); + HexBox.OnPropertyChanged(nameof(SelectionLength)); + HexBox.OnPropertyChanged(nameof(SelectedOffset)); + HexBox.OnPropertyChanged(nameof(SelectedAddress)); + HexBox.OnPropertyChanged(nameof(IsSelectionActive)); + } + + private static object CoerceColumns(DependencyObject d, object value) + { + var HexBox = (HexBox)d; + + if (HexBox.MaxVisibleColumns == 0) + { + return (int)value; + } + else + { + return Math.Min((int)value, HexBox.MaxVisibleColumns); + } + } + + private static object CoerceMaxVisibleColumns(DependencyObject d, object value) + { + return Math.Min((int)value, _MaxColumns); + } + + private static object CoerceMaxVisibleRows(DependencyObject d, object value) + { + return Math.Min((int)value, _MaxRows); + } + + private static object CoerceSelectionStart(DependencyObject d, object value) + { + var HexBox = (HexBox)d; + + if (HexBox.DataSource != null) + { + long selectionStart = (long)value; + + // Selection offset cannot start in the middle of the data width + selectionStart -= selectionStart % HexBox._BytesPerColumn; + + // Selection start cannot be at the end of the stream so adjust by data width number of bytes + value = selectionStart.Clamp(0, HexBox.DataSource.BaseStream.Length / HexBox._BytesPerColumn * HexBox._BytesPerColumn - HexBox._BytesPerColumn); + } + else + { + value = 0L; + } + + return value; + } + + private static object CoerceSelectionEnd(DependencyObject d, object value) + { + var HexBox = (HexBox)d; + + if (HexBox.DataSource != null) + { + long selectionEnd = (long)value; + + // Selection offset cannot start in the middle of the data width + selectionEnd -= selectionEnd % HexBox._BytesPerColumn; + + // Unlike selection start the selection end can be at the end of the stream + value = selectionEnd.Clamp(0, HexBox.DataSource.BaseStream.Length / HexBox._BytesPerColumn * HexBox._BytesPerColumn); + } + else + { + value = 0L; + } + + return value; + } + + private static object CoerceOffset(DependencyObject d, object value) + { + var HexBox = (HexBox)d; + + if (HexBox.DataSource != null) + { + long offset = (long)value; + + value = offset.Clamp(0, HexBox.DataSource.BaseStream.Length); + } + else + { + value = 0L; + } + + return value; + } + + private static void OnAddressChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var HexBox = (HexBox)d; + + HexBox.SelectionStart = 0; + HexBox.SelectionEnd = 0; + + HexBox.Reflush(); + + HexBox.OnPropertyChanged(nameof(Address)); + HexBox.OnPropertyChanged(nameof(SelectedAddress)); + } + + private static void OnDataTypeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var HexBox = (HexBox)d; + + switch (HexBox.DataType) + { + case DataType.Int_1: + HexBox.DataWidth = 1; + break; + case DataType.Int_2: + HexBox.DataWidth = 2; + break; + case DataType.Int_4: + HexBox.DataWidth = 4; + break; + case DataType.Int_8: + HexBox.DataWidth = 8; + break; + case DataType.Float_32: + HexBox.DataWidth = 4; + break; + case DataType.Float_64: + HexBox.DataWidth = 8; + break; + } + + HexBox.Reflush(); + } + + private static void OnDataSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var HexBox = (HexBox)d; + + HexBox.Offset = 0; + HexBox.SelectionStart = 0; + HexBox.SelectionEnd = 0; + + HexBox.Reflush(); + } + + private void Reflush() + { + if (_Canvas != null) + { + _Canvas.Invalidate(); + } + } + + private void OnPropertyChanged([CallerMemberName] string name = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + } + + private string ReadFormattedText() + { + StringBuilder builder = new(DataWidth); + + switch (TextFormat) + { + case TextFormat.Ascii: + { + for (var k = 0; k < DataWidth; ++k) + { + byte value = DataSource.ReadByte(); + + if (value > 31 && value < 127) + { + builder.Append(Convert.ToChar(value)); + } + else + { + builder.Append('.'); + } + } + + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(TextFormat)} value."); + } + } + + return builder.ToString(); + } + + private string ReadFormattedData() + { + string result; + + if (DataType < DataType.Float_32) + { + switch (DataFormat) + { + case DataFormat.Decimal: + { + if (DataSignedness == DataSignedness.Signed) + { + switch (DataType) + { + case DataType.Int_1: + { + result = $"{DataSource.ReadSByte():+#;-#;0}".PadLeft(4); + break; + } + + case DataType.Int_2: + { + result = $"{EndianBitConverter.Convert(DataSource.ReadInt16(), Endianness):+#;-#;0}".PadLeft(6); + break; + } + + case DataType.Int_4: + { + result = $"{EndianBitConverter.Convert(DataSource.ReadInt32(), Endianness):+#;-#;0}".PadLeft(11); + break; + } + + case DataType.Int_8: + { + result = $"{EndianBitConverter.Convert(DataSource.ReadInt64(), Endianness):+#;-#;0}".PadLeft(21); + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataWidth)} value."); + } + } + } + else if (DataSignedness == DataSignedness.Unsigned) + { + switch (DataType) + { + case DataType.Int_1: + { + result = $"{DataSource.ReadByte()}".PadLeft(3); + break; + } + + case DataType.Int_2: + { + result = $"{EndianBitConverter.Convert(DataSource.ReadUInt16(), Endianness)}".PadLeft(5); + break; + } + + case DataType.Int_4: + { + result = $"{EndianBitConverter.Convert(DataSource.ReadUInt32(), Endianness)}".PadLeft(10); + break; + } + + case DataType.Int_8: + { + result = $"{EndianBitConverter.Convert(DataSource.ReadUInt64(), Endianness)}".PadLeft(20); + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataWidth)} value."); + } + } + } + else + { + throw new InvalidOperationException($"Invalid {nameof(DataType)} value."); + } + } + break; + + case DataFormat.Hexadecimal: + { + switch (DataType) + { + case DataType.Int_1: + { + result = $"{DataSource.ReadByte(),0:X2}"; + break; + } + + case DataType.Int_2: + { + result = $"{EndianBitConverter.Convert(DataSource.ReadUInt16(), Endianness),0:X4}"; + break; + } + + case DataType.Int_4: + { + result = $"{EndianBitConverter.Convert(DataSource.ReadUInt32(), Endianness),0:X8}"; + break; + } + + case DataType.Int_8: + { + result = $"{EndianBitConverter.Convert(DataSource.ReadUInt64(), Endianness),0:X16}"; + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataWidth)} value."); + } + } + + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataFormat)} value."); + } + } + } + else + { + switch (DataType) + { + case DataType.Float_32: + { + var bytes = BitConverter.GetBytes(EndianBitConverter.Convert(DataSource.ReadUInt32(), Endianness)); + var value = BitConverter.ToSingle(bytes, 0); + result = $"{value:E08}".PadLeft(16); + break; + } + + case DataType.Float_64: + { + var bytes = BitConverter.GetBytes(EndianBitConverter.Convert(DataSource.ReadUInt64(), Endianness)); + var value = BitConverter.ToSingle(bytes, 0); + result = $"{value:E16}".PadLeft(24); + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataWidth)} value."); + } + } + } + + return result; + } + + private void SelectAllExecuted(object sender) + { + SelectAll(); + } + + private void CopyExecuted(object sender) + { + Copy(false); + } + + private void CopyTextExecuted(object sender) + { + Copy(true); + } + + private bool SelectAllCanExecute(object sender) + { + return DataSource != null && (ShowData || ShowText); + } + + private bool CopyCanExecute(object sender) + { + return IsSelectionActive && (ShowData || ShowText); + } + + private void OnVerticalScrollBarValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + _LastVerticalScrollValue = e.OldValue; + } + + private void OnVerticalScrollBarScroll(object sender, ScrollEventArgs e) + { + long newOffset = (long)e.NewValue * _BytesPerRow; + + Offset = newOffset; + } + + private void OnVerticalScrollBarScroll(object sender, ScrollEventType type, double NewValue) + { + long newOffset = (long)NewValue * _BytesPerRow; + + Offset = newOffset; + } + + private string GetFormattedAddressText(ulong address) + { + string formattedAddressText; + + switch (AddressFormat) + { + case AddressFormat.Address16: + { + formattedAddressText = $"{address & 0xFFFF,0:X4}"; + break; + } + + case AddressFormat.Address24: + { + formattedAddressText = $"{address >> 16 & 0xFF,0:X2}:{address & 0xFFFF,0:X4}"; + break; + } + + case AddressFormat.Address32: + { + formattedAddressText = $"{address >> 16 & 0xFFFF,0:X4}:{address & 0xFFFF,0:X4}"; + break; + } + + case AddressFormat.Address48: + { + formattedAddressText = $"{address >> 32 & 0xFF,0:X4}:{address & 0xFFFFFFFF,0:X8}"; + break; + } + + case AddressFormat.Address64: + { + formattedAddressText = $"{address >> 32,0:X8}:{address & 0xFFFFFFFF,0:X8}"; + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(AddressFormat)} value."); + } + } + + return formattedAddressText; + } + + private int CalculateAddressColumnCharWidth() + { + int addressColumnCharWidth; + + switch (AddressFormat) + { + case AddressFormat.Address16: + { + addressColumnCharWidth = 4; + break; + } + + case AddressFormat.Address24: + { + addressColumnCharWidth = 7; + break; + } + + case AddressFormat.Address32: + { + addressColumnCharWidth = 9; + break; + } + + case AddressFormat.Address48: + { + addressColumnCharWidth = 13; + break; + } + + case AddressFormat.Address64: + { + addressColumnCharWidth = 17; + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(AddressFormat)} value."); + } + } + + return addressColumnCharWidth; + } + + private int CalculateDataColumnCharWidth() + { + int dataColumnCharWidth; + + if (DataType < DataType.Float_32) + { + switch (DataFormat) + { + case DataFormat.Decimal: + { + switch (DataSignedness) + { + case DataSignedness.Signed: + { + switch (DataType) + { + case DataType.Int_1: + { + dataColumnCharWidth = 4; + break; + } + + case DataType.Int_2: + { + dataColumnCharWidth = 6; + break; + } + + case DataType.Int_4: + { + dataColumnCharWidth = 11; + break; + } + + case DataType.Int_8: + { + dataColumnCharWidth = 21; + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataWidth)} value."); + } + } + } + + break; + + case DataSignedness.Unsigned: + { + switch (DataType) + { + case DataType.Int_1: + { + dataColumnCharWidth = 3; + break; + } + + case DataType.Int_2: + { + dataColumnCharWidth = 5; + break; + } + + case DataType.Int_4: + { + dataColumnCharWidth = 10; + break; + } + + case DataType.Int_8: + { + dataColumnCharWidth = 20; + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataWidth)} value."); + } + } + } + + break; + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataType)} value."); + } + } + } + + break; + + case DataFormat.Hexadecimal: + { + switch (DataWidth) + { + case 1: + case 2: + case 4: + case 8: + { + dataColumnCharWidth = 2 * DataWidth; + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataWidth)} value."); + } + } + + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataFormat)} value."); + } + } + } + else + { + switch (DataType) + { + case DataType.Float_32: + { + dataColumnCharWidth = 16; + break; + } + + case DataType.Float_64: + { + dataColumnCharWidth = 24; + break; + } + + default: + { + throw new InvalidOperationException($"Invalid {nameof(DataWidth)} value."); + } + } + } + return dataColumnCharWidth; + } + + private Point CalculateAddressVerticalLinePoint0() + { + Point point1 = default; + + if (ShowAddress) + { + point1.X = (CalculateAddressColumnCharWidth() + _CharsBetweenSections) * _TextMeasure.Width; + } + + return point1; + } + + private Point CalculateAddressVerticalLinePoint1() + { + Point point2 = default; + + if (ShowAddress) + { + point2.X = (CalculateAddressColumnCharWidth() + _CharsBetweenSections) * _TextMeasure.Width; + } + + point2.Y = Math.Min(_TextMeasure.Height * (MaxVisibleRows + 1), _Canvas.ActualHeight); + + return point2; + } + + private Point CalculateDataVerticalLinePoint0() + { + Point point1 = CalculateAddressVerticalLinePoint0(); + + if (ShowData) + { + point1.X += (_CharsBetweenSections + (CalculateDataColumnCharWidth() + _CharsBetweenDataColumns) * Columns - _CharsBetweenDataColumns + _CharsBetweenSections) * _TextMeasure.Width; + } + + return point1; + } + + private Point CalculateDataVerticalLinePoint1() + { + Point point2 = CalculateAddressVerticalLinePoint1(); + + if (ShowData) + { + point2.X += (_CharsBetweenSections + (CalculateDataColumnCharWidth() + _CharsBetweenDataColumns) * Columns - _CharsBetweenDataColumns + _CharsBetweenSections) * _TextMeasure.Width; + } + + return point2; + } + + private int CalculateTextColumnCharWidth() + { + return _BytesPerColumn; + } + + private Point CalculateTextVerticalLinePoint0() + { + Point point1 = CalculateDataVerticalLinePoint0(); + + if (ShowText) + { + point1.X += (_CharsBetweenSections + CalculateTextColumnCharWidth() * Columns + _CharsBetweenSections) * _TextMeasure.Width; + } + + return point1; + } + + private Point CalculateTextVerticalLinePoint1() + { + Point point2 = CalculateDataVerticalLinePoint1(); + + if (ShowText) + { + point2.X += (_CharsBetweenSections + CalculateTextColumnCharWidth() * Columns + _CharsBetweenSections) * _TextMeasure.Width; + } + + return point2; + } + + private void UpdateState() + { + UpdateMaxVisibleRowsAndColumns(); + UpdateScrollBar(); + UpdateColumnsLayout(); + } + + private void UpdateColumnsLayout() + { + var p0 = CalculateAddressVerticalLinePoint0(); + var p1 = CalculateAddressVerticalLinePoint1(); + _AddressRect = new(p0, p1); + + p0 = CalculateDataVerticalLinePoint0(); + p1 = CalculateDataVerticalLinePoint1(); + _DataRect = new(p0, p1); + + p0 = CalculateTextVerticalLinePoint0(); + p1 = CalculateTextVerticalLinePoint1(); + _TextRect = new(p0, p1); + } + + private void UpdateMaxVisibleRowsAndColumns() + { + int maxVisibleRows = 0; + int maxVisibleColumns = 0; + + if ((ShowAddress || ShowData || ShowText) && _Canvas != null) + { + { + SKRect cellSize = new(); + string bigChars = "0123456789abcdef ABCDEF"; + for (int i = 0; i < bigChars.Length; i++) + { + var s = bigChars.Substring(i, 1); + if (_TextPaint.ContainsGlyphs(s)) // if the font does not contain the glyph, then skip it + { + var rect = new SKRect(); + _TextPaint.MeasureText(s, ref rect); + cellSize.Union(rect); + } + } + _TextMeasure = cellSize; + } + + _TextMeasure.Bottom = _TextMeasure.Height; /* 2 * line font height */ + + maxVisibleRows = Math.Max(0, (int)(_Canvas.ActualHeight / _TextMeasure.Height)); + + if (ShowData || ShowText) + { + int charsPerRow = (int)(_Canvas.ActualWidth / _TextMeasure.Width); + + if (ShowAddress) + { + charsPerRow -= CalculateAddressColumnCharWidth() + 2 * _CharsBetweenSections; + } + + if (ShowData && ShowText) + { + charsPerRow -= 3 * _CharsBetweenSections; + } + + int charsPerColumn = 0; + + if (ShowData) + { + charsPerColumn += CalculateDataColumnCharWidth() + _CharsBetweenDataColumns; + } + + if (ShowText) + { + charsPerColumn += CalculateTextColumnCharWidth(); + } + + if (charsPerColumn != 0) + { + maxVisibleColumns = Math.Max(0, charsPerRow / charsPerColumn); + } + } + else + { + maxVisibleColumns = 0; + } + } + + MaxVisibleRows = maxVisibleRows; + MaxVisibleColumns = maxVisibleColumns; + + // Maximum visible rows has now changed and so we must update the maximum amount we should scroll by + _ScrollBar.LargeChange = maxVisibleRows; + } + + private void UpdateScrollBar() + { + if ((ShowAddress || ShowData || ShowText) && DataSource != null && Columns > 0 && MaxVisibleRows > 0) + { + long q = DataSource.BaseStream.Length / _BytesPerRow; + long r = DataSource.BaseStream.Length % _BytesPerRow; + + // Each scroll value represents a single drawn row + _ScrollBar.Maximum = q + (r > 0 ? 1 : 0) - MaxVisibleRows; + + // Adjust the scroll value based on the current offset + _ScrollBar.Value = Offset / _BytesPerRow; + + // Adjust again to compensate for residual bytes if the number of bytes between the start of the stream + // and the current offset is less than the number of bytes we can display per row + if (_ScrollBar.Value == 0 && Offset > 0) + { + ++_ScrollBar.Value; + } + } + else + { + _ScrollBar.Maximum = 0; + } + } + + private long ConvertPositionToOffset(Point position) + { + long offset = Offset; + + switch (_HighlightBegin) + { + case SelectionArea.Address: + { + // Clamp the Y coordinate to within the address region + position.Y = position.Y.Clamp(_AddressRect.Top, _AddressRect.Bottom); + + // Convert the Y coordinate to the row number + position.Y /= _TextMeasure.Height; + + if (position.Y >= MaxVisibleRows) + { + // Due to floating point rounding we may end up with exactly the maximum number of rows, so adjust to compensate + --position.Y; + } + + offset += _BytesPerRow * (long)position.Y; + } + + break; + + case SelectionArea.Data: + { + var pix_CharsBetweenSections = _CharsBetweenSections * _TextMeasure.Width; + + // Clamp the X coordinate to within the data region + position.X = position.X.Clamp(_AddressRect.Left + pix_CharsBetweenSections, _DataRect.Left - pix_CharsBetweenSections); + + // Normalize with respect to the data region + position.X -= _AddressRect.Left + pix_CharsBetweenSections; + + // Convert the X coordinate to the column number + position.X /= (CalculateDataColumnCharWidth() + _CharsBetweenDataColumns) * _TextMeasure.Width; + + if (position.X >= Columns) + { + // Due to floating point rounding we may end up with exactly the maximum number of columns, so adjust to compensate + --position.X; + } + + // Clamp the Y coordinate to within the data region + position.Y = position.Y.Clamp(_DataRect.Top, _DataRect.Bottom); + + // Convert the Y coordinate to the row number + position.Y /= _TextMeasure.Height; + + if (position.Y >= MaxVisibleRows) + { + // Due to floating point rounding we may end up with exactly the maximum number of rows, so adjust to compensate + --position.Y; + } + + offset += ((long)position.Y * Columns + (long)position.X) * _BytesPerColumn; + } + + break; + + case SelectionArea.Text: + { + var pix_CharsBetweenSections = _CharsBetweenSections * _TextMeasure.Width; + + // Clamp the X coordinate to within the text region + position.X = position.X.Clamp(_DataRect.Left + pix_CharsBetweenSections, _TextRect.Left - pix_CharsBetweenSections); + + // Normalize with respect to the text region + position.X -= _DataRect.Left + pix_CharsBetweenSections; + + // Convert the X coordinate to the column number + position.X /= CalculateTextColumnCharWidth() * _TextMeasure.Width; + + if (position.X >= Columns) + { + // Due to floating point rounding we may end up with exactly the maximum number of columns, so + // adjust to compensate + --position.X; + } + + // Clamp the Y coordinate to within the text region + position.Y = position.Y.Clamp(_TextRect.Top, _TextRect.Bottom); + + // Convert the Y coordinate to the row number + position.Y /= _TextMeasure.Height; + + if (position.Y >= MaxVisibleRows) + { + // Due to floating point rounding we may end up with exactly the maximum number of rows, so adjust to compensate + --position.Y; + } + + offset += ((long)position.Y * Columns + (long)position.X) * _BytesPerColumn; + } + + break; + + default: + { + throw new InvalidOperationException($"Invalid highlight state ${_HighlightState}"); + } + } + + return offset; + } + + private Point ConvertOffsetToPosition(long offset, SelectionArea relativeTo) + { + Point position = default; + + switch (relativeTo) + { + case SelectionArea.Data: + { + position.X = _AddressRect.Left + _CharsBetweenSections * _TextMeasure.Width; + position.Y = _AddressRect.Top; + + // Normalize requested offset to a zero based column + long normalizedColumn = (offset - Offset) / _BytesPerColumn; + + position.X += (normalizedColumn % Columns + Columns) % Columns * (CalculateDataColumnCharWidth() + _CharsBetweenDataColumns) * _TextMeasure.Width; + + if (normalizedColumn < 0) + { + // Negative normalized offset means the Y position is above the current offset. Because division + // rounds toward zero we need to compensate here. + position.Y += ((normalizedColumn + 1) / Columns - 1) * _TextMeasure.Height; + } + else + { + position.Y += normalizedColumn / Columns * _TextMeasure.Height; + } + } + + break; + + case SelectionArea.Text: + { + position.X = _DataRect.Left + _CharsBetweenSections * _TextMeasure.Width; + position.Y = _DataRect.Top; + + // Normalize requested offset to a zero based column + long normalizedColumn = (offset - Offset) / _BytesPerColumn; + + position.X += (normalizedColumn % Columns + Columns) % Columns * CalculateTextColumnCharWidth() * _TextMeasure.Width; + + if (normalizedColumn < 0) + { + // Negative normalized offset means the Y position is above the current offset. Because division + // rounds toward zero we need to compensate here. + position.Y += ((normalizedColumn + 1) / Columns - 1) * _TextMeasure.Height; + } + else + { + position.Y += normalizedColumn / Columns * _TextMeasure.Height; + } + } + + break; + + default: + { + throw new ArgumentException($"Invalid relative area {relativeTo}", nameof(relativeTo)); + } + } + + return position; + } + + private bool IsOffsetVisible(long offset) + { + long maxBytesDisplayed = _BytesPerRow * MaxVisibleRows; + + return Offset <= offset && Offset + maxBytesDisplayed >= offset; + } + + /// <summary> + /// Show the context menu programatical. + /// Invoked if Application key or SCHIFT+F10 is pressed. + /// </summary> + private void ShowContextMenu() + { + // Get offset for context menu + var lastVisibleOffset = Offset + (_BytesPerRow * MaxVisibleRows) - 1; + var offset = Math.Max(Math.Max(SelectionStart, SelectionEnd), Offset); + var palcementOffset = Math.Min(offset, lastVisibleOffset); + + // Show menu + if (ShowData) + { + _Canvas.ContextFlyout.ShowAt(_Canvas, new FlyoutShowOptions + { + Position = ConvertOffsetToPosition(palcementOffset, SelectionArea.Data), + }); + } + else if (ShowText) + { + _Canvas.ContextFlyout.ShowAt(_Canvas, new FlyoutShowOptions + { + Position = ConvertOffsetToPosition(palcementOffset, SelectionArea.Text), + }); + } + else + { + _Canvas.ContextFlyout.ShowAt(_Canvas, new FlyoutShowOptions + { + Position = new Point(0, 0), + }); + } + } + + /// <summary> + /// Initializes static members of the <see cref="HexBox"/> class. + /// </summary> + public HexBox() + { + DefaultStyleKey = typeof(HexBox); + } + } + +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/EndianBinaryReader.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/EndianBinaryReader.cs new file mode 100644 index 0000000000..1ba0658bd7 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/EndianBinaryReader.cs @@ -0,0 +1,231 @@ +// 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. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox.Library.EndianConvert +{ + using System; + using System.IO; + using System.Text; + + /// <summary> + /// Reads primitive data types as binary values in a specific encoding and endianness. + /// </summary> + public class EndianBinaryReader : BinaryReader + { + /// <summary> + /// Initializes a new instance of the <see cref="EndianBinaryReader"/> class based on the specified stream, endianness, and using UTF-8 + /// encoding. + /// </summary> + /// + /// <param name="input"> + /// The input stream. + /// </param> + /// + /// <param name="endianness"> + /// The endianness of the data in the input stream. + /// </param> + /// + /// <exception cref="System.ArgumentException"> + /// The stream does not support reading, is null, or is already closed. + /// </exception> + public EndianBinaryReader(Stream input, Endianness endianness) + : this(input, endianness, Encoding.UTF8) + { + // Void + } + + /// <summary> + /// Initializes a new instance of the <see cref="EndianBinaryReader"/> class based on the specified stream, endianness, and character + /// encoding. + /// </summary> + /// + /// <param name="input"> + /// The input stream. + /// </param> + /// + /// <param name="endianness"> + /// The endianness of the data in the input stream. + /// </param> + /// + /// <param name="encoding"> + /// The character encoding to use. + /// </param> + /// + /// <exception cref="System.ArgumentException"> + /// The stream does not support reading, is null, or is already closed. + /// </exception> + public EndianBinaryReader(Stream input, Endianness endianness, Encoding encoding) + : this(input, endianness, encoding, false) + { + // Void + } + + /// <summary> + /// Initializes a new instance of the <see cref="EndianBinaryReader"/> class based on the specified stream, endianness, and character + /// encoding, and optionally leaves the stream open. + /// </summary> + /// + /// <param name="input"> + /// The input stream. + /// </param> + /// + /// <param name="endianness"> + /// The endianness of the data in the input stream. + /// </param> + /// + /// <param name="encoding"> + /// The character encoding to use. + /// </param> + /// + /// <param name="leaveOpen"> + /// <c>true</c> to leave the stream open after the <see cref="EndianBinaryReader"/> object is disposed; <c>false</c> otherwise. + /// </param> + /// + /// <exception cref="System.ArgumentException"> + /// The stream does not support reading, is null, or is already closed. + /// </exception> + public EndianBinaryReader(Stream input, Endianness endianness, Encoding encoding, bool leaveOpen) + : base(input, encoding, leaveOpen) + { + Endianness = endianness; + } + + /// <summary> + /// Gets the endianness of the data in the input stream. + /// </summary> + public Endianness Endianness + { + get; + } + + /// <summary> + /// Reads a decimal value from the current stream and advances the current position of the stream by sixteen bytes. + /// </summary> + /// + /// <returns> + /// A decimal value read from the current stream. + /// </returns> + public override decimal ReadDecimal() + { + throw new NotImplementedException(); + } + + /// <summary> + /// Reads an 8-byte floating point value from the current stream and advances the current position of the stream by eight bytes. + /// </summary> + /// + /// <returns> + /// An 8-byte floating point value read from the current stream. + /// </returns> + public override double ReadDouble() + { + throw new NotImplementedException(); + } + + /// <summary> + /// Reads a 2-byte signed integer from the current stream and advances the current position of the stream by two bytes. + /// </summary> + /// + /// <returns> + /// A 2-byte signed integer read from the current stream. + /// </returns> + public override short ReadInt16() + { + return EndianBitConverter.Convert(base.ReadInt16(), Endianness); + } + + /// <summary> + /// Reads a 4-byte signed integer from the current stream and advances the current position of the stream by four bytes. + /// </summary> + /// + /// <returns> + /// A 4-byte signed integer read from the current stream. + /// </returns> + public override int ReadInt32() + { + return EndianBitConverter.Convert(base.ReadInt32(), Endianness); + } + + /// <summary> + /// Reads an 8-byte signed integer from the current stream and advances the current position of the stream by eight bytes. + /// </summary> + /// + /// <returns> + /// An 8-byte signed integer read from the current stream. + /// </returns> + public override long ReadInt64() + { + return EndianBitConverter.Convert(base.ReadInt64(), Endianness); + } + + /// <summary> + /// Reads a 4-byte floating point value from the current stream and advances the current position of the stream by four bytes. + /// </summary> + /// + /// <returns> + /// A 4-byte floating point value read from the current stream. + /// </returns> + public override float ReadSingle() + { + throw new NotImplementedException(); + } + + /// <summary> + /// Reads a string from the current stream. The string is prefixed with the length, encoded as an integer seven bits at a time. + /// </summary> + /// + /// <returns> + /// The string being read. + /// </returns> + public override string ReadString() + { + throw new NotImplementedException(); + } + + /// <summary> + /// Reads a 2-byte unsigned integer from the current stream using little-endian encoding and advances the position of the stream by + /// two bytes. + /// </summary> + /// + /// <returns> + /// A 2-byte unsigned integer read from this stream. + /// </returns> + public override ushort ReadUInt16() + { + return EndianBitConverter.Convert(base.ReadUInt16(), Endianness); + } + + /// <summary> + /// Reads a 4-byte unsigned integer from the current stream using little-endian encoding and advances the position of the stream by + /// four bytes. + /// </summary> + /// + /// <returns> + /// A 4-byte unsigned integer read from this stream. + /// </returns> + public override uint ReadUInt32() + { + return EndianBitConverter.Convert(base.ReadUInt32(), Endianness); + } + + /// <summary> + /// Reads an 8-byte unsigned integer from the current stream using little-endian encoding and advances the position of the stream by + /// eight bytes. + /// </summary> + /// + /// <returns> + /// An 8-byte unsigned integer read from this stream. + /// </returns> + public override ulong ReadUInt64() + { + return EndianBitConverter.Convert(base.ReadUInt64(), Endianness); + } + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/EndianBitConverter.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/EndianBitConverter.cs new file mode 100644 index 0000000000..dde0287c2d --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/EndianBitConverter.cs @@ -0,0 +1,186 @@ +// 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. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox.Library.EndianConvert +{ + using System; + + /// <summary> + /// Converts integral values to the native endianness of this computer architecture. + /// </summary> + public static class EndianBitConverter + { + /// <summary> + /// Gets the native endianness of this computer architecture. + /// </summary> + public static readonly Endianness NativeEndianness = BitConverter.IsLittleEndian ? Endianness.LittleEndian : Endianness.BigEndian; + + /// <summary> + /// Converts a value from the specified endianness to the native endianness. + /// </summary> + /// + /// <param name="value"> + /// The value to convert. + /// </param> + /// + /// <param name="endianness"> + /// The endianness of <paramref name="value"/>. + /// </param> + /// + /// <returns> + /// The value converted from the specified endianness to the native endianness (<see cref="NativeEndianness"/>). + /// </returns> + public static ushort Convert(ushort value, Endianness endianness) + { + if (endianness == NativeEndianness) + { + return value; + } + else + { + unchecked + { + return (ushort)((value & 0x00FFU) << 8 | + (value & 0xFF00U) >> 8); + } + } + } + + /// <summary> + /// Converts a value from the specified endianness to the native endianness. + /// </summary> + /// + /// <param name="value"> + /// The value to convert. + /// </param> + /// + /// <param name="endianness"> + /// The endianness of <paramref name="value"/>. + /// </param> + /// + /// <returns> + /// The value converted from the specified endianness to the native endianness (<see cref="NativeEndianness"/>). + /// </returns> + public static uint Convert(uint value, Endianness endianness) + { + if (endianness == NativeEndianness) + { + return value; + } + else + { + unchecked + { + return (value & 0x000000FFU) << 24 | + (value & 0xFF000000U) >> 24 | + (value & 0x0000FF00U) << 8 | + (value & 0x00FF0000U) >> 8; + } + } + } + + /// <summary> + /// Converts a value from the specified endianness to the native endianness. + /// </summary> + /// + /// <param name="value"> + /// The value to convert. + /// </param> + /// + /// <param name="endianness"> + /// The endianness of <paramref name="value"/>. + /// </param> + /// + /// <returns> + /// The value converted from the specified endianness to the native endianness (<see cref="NativeEndianness"/>). + /// </returns> + public static ulong Convert(ulong value, Endianness endianness) + { + if (endianness == NativeEndianness) + { + return value; + } + else + { + unchecked + { + return (value & 0x00000000000000FFUL) << 56 | + (value & 0xFF00000000000000UL) >> 56 | + (value & 0x000000000000FF00UL) << 40 | + (value & 0x00FF000000000000UL) >> 40 | + (value & 0x0000000000FF0000UL) << 24 | + (value & 0x0000FF0000000000UL) >> 24 | + (value & 0x00000000FF000000UL) << 8 | + (value & 0x000000FF00000000UL) >> 8; + } + } + } + + /// <summary> + /// Converts a value from the specified endianness to the native endianness. + /// </summary> + /// + /// <param name="value"> + /// The value to convert. + /// </param> + /// + /// <param name="endianness"> + /// The endianness of <paramref name="value"/>. + /// </param> + /// + /// <returns> + /// The value converted from the specified endianness to the native endianness (<see cref="NativeEndianness"/>). + /// </returns> + public static short Convert(short value, Endianness endianness) + { + return (short)Convert((ushort)value, endianness); + } + + /// <summary> + /// Converts a value from the specified endianness to the native endianness. + /// </summary> + /// + /// <param name="value"> + /// The value to convert. + /// </param> + /// + /// <param name="endianness"> + /// The endianness of <paramref name="value"/>. + /// </param> + /// + /// <returns> + /// The value converted from the specified endianness to the native endianness (<see cref="NativeEndianness"/>). + /// </returns> + public static int Convert(int value, Endianness endianness) + { + return (int)Convert((uint)value, endianness); + } + + /// <summary> + /// Converts a value from the specified endianness to the native endianness. + /// </summary> + /// + /// <param name="value"> + /// The value to convert. + /// </param> + /// + /// <param name="endianness"> + /// The endianness of <paramref name="value"/>. + /// </param> + /// + /// <returns> + /// The value converted from the specified endianness to the native endianness (<see cref="NativeEndianness"/>). + /// </returns> + public static long Convert(long value, Endianness endianness) + { + return (long)Convert((ulong)value, endianness); + } + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/Endianness.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/Endianness.cs new file mode 100644 index 0000000000..7293e8b9d1 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/Endianness.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox.Library.EndianConvert +{ + /// <summary> + /// Represents the endianness of a value in a computer architecture. + /// </summary> + public enum Endianness + { + /// <summary> + /// Most significant byte first. + /// </summary> + BigEndian, + + /// <summary> + /// Least significant byte first. + /// </summary> + LittleEndian, + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/FileFormatException.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/FileFormatException.cs new file mode 100644 index 0000000000..81b96c3858 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Library/EndianConvert/FileFormatException.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox.Library.EndianConvert +{ + using System; + + /// <summary> + /// The exception that is thrown when an input file or a data stream is malformed. + /// </summary> + [Serializable] + public sealed class FileFormatException : Exception + { + /// <summary> + /// Initializes a new instance of the <see cref="FileFormatException"/> class. + /// </summary> + public FileFormatException() + { + // Void + } + + /// <summary> + /// Initializes a new instance of the <see cref="FileFormatException"/> class with a specified error message. + /// </summary> + /// + /// <param name="message"> + /// The message that describes the error. + /// </param> + public FileFormatException(string message) + : base(message) + { + // Void + } + + /// <summary> + /// Initializes a new instance of the <see cref="FileFormatException"/> class with a specified error message and a reference to the inner + /// exception that is the cause of this exception. + /// </summary> + /// + /// <param name="message"> + /// The message that describes the error. + /// </param> + /// + /// <param name="innerException"> + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + /// </param> + public FileFormatException(string message, Exception innerException) + : base(message, innerException) + { + // Void + } + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/TextFormat.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/TextFormat.cs new file mode 100644 index 0000000000..ca5cc99683 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/TextFormat.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox +{ + /// <summary> + /// Enumerates the text section encodings/formats that the control is able to display. + /// </summary> + public enum TextFormat + { + /// <summary> + /// Display data in ASCII (ISO-8859-1) encoding. + /// </summary> + Ascii, + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Themes/Generic.xaml b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Themes/Generic.xaml new file mode 100644 index 0000000000..8b2208128d --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Themes/Generic.xaml @@ -0,0 +1,170 @@ +<ResourceDictionary + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:convert="using:CommunityToolkit.WinUI.Converters" + xmlns:local="using:RegistryPreviewUILib.HexBox" + xmlns:skia="using:SkiaSharp.Views.Windows"> + + <Style TargetType="local:HexBox"> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:HexBox"> + <Grid + Margin="{TemplateBinding Margin}" + Padding="4" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{TemplateBinding CornerRadius}" + IsTabStop="False"> + <Grid.Resources> + <convert:BoolNegationConverter x:Key="BoolNegationConverter" /> + <local:BigEndianConverter x:Key="BigEndianConverter" /> + <local:HexboxDataFormatConverter x:Key="HexboxDataFormatConverter" /> + <local:HexboxDataSignednessConverter x:Key="HexboxDataSignednessConverter" /> + <local:HexboxDataFormatBoolConverter x:Key="HexboxDataFormatBoolConverter" /> + <local:HexboxDataTypeConverter x:Key="HexboxDataTypeConverter" /> + <MenuFlyout x:Key="DataMenuFlyout"> + <!-- New code for PowerToys implementation --> + <MenuFlyoutItem + x:Uid="/PowerToys.RegistryPreviewUILib/Resources/HexBox_CopyCommand" + Command="{Binding CopyCommand, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Icon="Copy"> + <MenuFlyoutItem.KeyboardAccelerators> + <KeyboardAccelerator Key="C" Modifiers="Control" /> + </MenuFlyoutItem.KeyboardAccelerators> + </MenuFlyoutItem> + <MenuFlyoutItem + x:Uid="/PowerToys.RegistryPreviewUILib/Resources/HexBox_CopyTextCommand" + Command="{Binding CopyTextCommand, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Icon="Font"> + <MenuFlyoutItem.KeyboardAccelerators> + <KeyboardAccelerator Key="C" Modifiers="Control,Shift" /> + </MenuFlyoutItem.KeyboardAccelerators> + </MenuFlyoutItem> + <MenuFlyoutItem + x:Uid="/PowerToys.RegistryPreviewUILib/Resources/HexBox_SelectAllCommand" + Command="{Binding SelectAllCommand, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Icon="SelectAll"> + <MenuFlyoutItem.KeyboardAccelerators> + <KeyboardAccelerator Key="A" Modifiers="Control" /> + </MenuFlyoutItem.KeyboardAccelerators> + </MenuFlyoutItem> + <!-- Original source code from HexBox.WinUI.HexBox + <MenuFlyoutItem + Command="{Binding CopyCommand, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Icon="Copy" + Text="Copy"> + <MenuFlyoutItem.KeyboardAccelerators> + <KeyboardAccelerator Key="C" Modifiers="Control" /> + </MenuFlyoutItem.KeyboardAccelerators> + </MenuFlyoutItem> + <MenuFlyoutItem + Command="{Binding CopyTextCommand, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Icon="Font" + Text="Copy text"> + <MenuFlyoutItem.KeyboardAccelerators> + <KeyboardAccelerator Key="C" Modifiers="Control,Shift"/> + </MenuFlyoutItem.KeyboardAccelerators> + </MenuFlyoutItem> + <MenuFlyoutItem + Command="{Binding SelectAllCommand, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Icon="SelectAll" + Text="Select all"> + <MenuFlyoutItem.KeyboardAccelerators> + <KeyboardAccelerator Key="A" Modifiers="Control" /> + </MenuFlyoutItem.KeyboardAccelerators> + </MenuFlyoutItem> + <MenuFlyoutSeparator /> + <MenuFlyoutSubItem IsEnabled="{Binding EnforceProperties, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" Text="Address properties"> + <ToggleMenuFlyoutItem IsChecked="{Binding ShowAddress, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource BoolNegationConverter}}" Text="No Address" /> + </MenuFlyoutSubItem> + <MenuFlyoutSubItem IsEnabled="{Binding EnforceProperties, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" Text="Data properties"> + <ToggleMenuFlyoutItem IsChecked="{Binding ShowData, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource BoolNegationConverter}}" Text="No Data" /> + <RadioMenuFlyoutItem + GroupName="DataType" + IsChecked="{Binding DataType, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataTypeConverter}, ConverterParameter=Int_1}" + IsEnabled="{Binding ShowData, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Text="1 byte-integer" /> + <RadioMenuFlyoutItem + GroupName="DataType" + IsChecked="{Binding DataType, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataTypeConverter}, ConverterParameter=Int_2}" + IsEnabled="{Binding ShowData, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Text="2 byte-integer" /> + <RadioMenuFlyoutItem + GroupName="DataType" + IsChecked="{Binding DataType, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataTypeConverter}, ConverterParameter=Int_4}" + IsEnabled="{Binding ShowData, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Text="4 byte-integer" /> + <RadioMenuFlyoutItem + GroupName="DataType" + IsChecked="{Binding DataType, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataTypeConverter}, ConverterParameter=Int_8}" + IsEnabled="{Binding ShowData, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Text="8 byte-integer" /> + <RadioMenuFlyoutItem + GroupName="DataType" + IsChecked="{Binding DataType, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataTypeConverter}, ConverterParameter=Float_32}" + IsEnabled="{Binding ShowData, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Text="32-bit Floating Point" /> + <RadioMenuFlyoutItem + GroupName="DataType" + IsChecked="{Binding DataType, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataTypeConverter}, ConverterParameter=Float_64}" + IsEnabled="{Binding ShowData, RelativeSource={RelativeSource Mode=TemplatedParent}}" + Text="64-bit Floating Point" /> + <MenuFlyoutSeparator /> + <ToggleMenuFlyoutItem + IsChecked="{Binding DataSignedness, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataSignednessConverter}, ConverterParameter=Signed}" + IsEnabled="{Binding DataFormat, RelativeSource={RelativeSource Mode=TemplatedParent}, Converter={StaticResource HexboxDataFormatBoolConverter}, ConverterParameter={Binding DataFormat}}" + Text="Signed" /> + <ToggleMenuFlyoutItem + IsChecked="{Binding DataSignedness, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataSignednessConverter}, ConverterParameter=Unsigned}" + IsEnabled="{Binding DataFormat, RelativeSource={RelativeSource Mode=TemplatedParent}, Converter={StaticResource HexboxDataFormatBoolConverter}, ConverterParameter={Binding DataFormat}}" + Text="Unsigned" /> + <MenuFlyoutSeparator /> + <ToggleMenuFlyoutItem IsChecked="{Binding DataFormat, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataFormatConverter}, ConverterParameter=Decimal}" Text="Decimal" /> + <ToggleMenuFlyoutItem IsChecked="{Binding DataFormat, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource HexboxDataFormatConverter}, ConverterParameter=Hexadecimal}" Text="Hexadecimal" /> + <MenuFlyoutSeparator /> + <ToggleMenuFlyoutItem IsChecked="{Binding Endianness, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource BigEndianConverter}, ConverterParameter=BigEndian}" Text="Big-Endian" /> + <ToggleMenuFlyoutItem IsChecked="{Binding Endianness, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource BigEndianConverter}, ConverterParameter=LittleEndian}" Text="Little-Endian" /> + </MenuFlyoutSubItem> + <MenuFlyoutSubItem IsEnabled="{Binding EnforceProperties, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" Text="Text properties"> + <ToggleMenuFlyoutItem IsChecked="{Binding ShowText, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay, Converter={StaticResource BoolNegationConverter}}" Text="No Text" /> + <ToggleMenuFlyoutItem + IsChecked="{Binding ShowText, RelativeSource={RelativeSource Mode=TemplatedParent}, Mode=TwoWay}" + IsEnabled="False" + Text="ASCII" /> + </MenuFlyoutSubItem>--> + </MenuFlyout> + </Grid.Resources> + + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="24" /> + </Grid.ColumnDefinitions> + + <skia:SKXamlCanvas + Name="ElementCanvas" + Grid.Column="0" + Margin="2,0,2,0" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + ContextFlyout="{StaticResource DataMenuFlyout}" /> + + <ScrollBar + Name="ElementScrollBar" + Grid.Column="1" + Margin="0,0,2,0" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + FontFamily="{TemplateBinding FontFamily}" + FontSize="{TemplateBinding FontSize}" + IndicatorMode="MouseIndicator" + Orientation="Vertical" /> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> +</ResourceDictionary> diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/TypeConverters.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/TypeConverters.cs new file mode 100644 index 0000000000..bed7841cab --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/TypeConverters.cs @@ -0,0 +1,264 @@ +// 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. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> +#pragma warning disable SA1210 // Using directives should be ordered alphabetically by namespace +#pragma warning disable SA1208 // System using directives should be placed before other using directives +using RegistryPreviewUILib.HexBox.Library.EndianConvert; +using Microsoft.UI.Xaml.Data; +using System; +#pragma warning restore SA1208 // System using directives should be placed before other using directives +#pragma warning restore SA1210 // Using directives should be ordered alphabetically by namespace + +namespace RegistryPreviewUILib.HexBox +{ + public partial class HexboxDataTypeConverter : IValueConverter + { + /// <summary> + /// Convert a DataType value to its negation. + /// </summary> + /// <param name="value">The <see cref="DataType"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is DataType b) + { + if (parameter is string c) + { + return c == b.ToString(); + } + } + throw new NotImplementedException(); + } + + /// <summary> + /// Convert back a DataType value to its negation. + /// </summary> + /// <param name="value">The <see cref="DataType"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is bool b && parameter is string c) + { + if(c == "Int_1") + { + return DataType.Int_1; + } + else if (c == "Int_2") + { + return DataType.Int_2; + } + else if (c == "Int_4") + { + return DataType.Int_4; + } + else if (c == "Int_8") + { + return DataType.Int_8; + } + else if (c == "Float_32") + { + return DataType.Float_32; + } + else /*if (c == "Float_64")*/ + { + return DataType.Float_64; + } + } + throw new NotImplementedException(); + } + } + + public class HexboxDataSignednessConverter : IValueConverter + { + /// <summary> + /// Convert a DataSignedness value to its negation. + /// </summary> + /// <param name="value">The <see cref="DataSignedness"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is DataSignedness b) + { + if (parameter is string c) + { + var end = c == "Signed" ? DataSignedness.Signed : DataSignedness.Unsigned; + return (b == end); + } + } + throw new NotImplementedException(); + } + + /// <summary> + /// Convert back a DataSignedness value to its negation. + /// </summary> + /// <param name="value">The <see cref="DataSignedness"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is bool b && parameter is string c) + { + var end = c == "Signed" ? DataSignedness.Signed : DataSignedness.Unsigned; + if (b) + { + return end; + } + else + { + return c == "Signed" ? DataSignedness.Unsigned : DataSignedness.Signed; + } + } + throw new NotImplementedException(); + } + } + + public class HexboxDataFormatBoolConverter : IValueConverter + { + /// <summary> + /// Convert a DataFormat value to its negation. + /// </summary> + /// <param name="value">The <see cref="DataFormat"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object Convert(object value, Type targetType, object parameter, string language) + { + if(value is DataFormat f) + { + return f != DataFormat.Hexadecimal; + } + throw new NotImplementedException(); + } + + /// <summary> + /// Convert back a DataFormat value to its negation. + /// </summary> + /// <param name="value">The <see cref="DataFormat"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } + + + public class HexboxDataFormatConverter : IValueConverter + { + /// <summary> + /// Convert a DataFormat value to its negation. + /// </summary> + /// <param name="value">The <see cref="DataFormat"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is DataFormat b) + { + if (parameter is string c) + { + var end = c == "Decimal" ? DataFormat.Decimal: DataFormat.Hexadecimal; + return (b == end); + } + } + throw new NotImplementedException(); + } + + /// <summary> + /// Convert back a DataFormat value to its negation. + /// </summary> + /// <param name="value">The <see cref="DataFormat"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is bool b && parameter is string c) + { + var end = c == "Decimal" ? DataFormat.Decimal : DataFormat.Hexadecimal; + if (b) + { + return end; + } + else + { + return end == DataFormat.Decimal ? DataFormat.Hexadecimal : DataFormat.Decimal; + } + } + throw new NotImplementedException(); + } + } + + + public class BigEndianConverter : IValueConverter + { + /// <summary> + /// Convert a Endian value to its negation. + /// </summary> + /// <param name="value">The <see cref="Endian"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is Endianness b) + { + if (parameter is string c) + { + var end = c == "BigEndian" ? Endianness.BigEndian : Endianness.LittleEndian; + return (b == end); + } + } + throw new NotImplementedException(); + } + + /// <summary> + /// Convert back a Endian value to its negation. + /// </summary> + /// <param name="value">The <see cref="Endian"/> value to negate.</param> + /// <param name="targetType">The type of the target property, as a type reference.</param> + /// <param name="parameter">Optional parameter. Not used.</param> + /// <param name="language">The language of the conversion. Not used</param> + /// <returns>The value to be passed to the target dependency property.</returns> + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is bool b && parameter is string c) + { + var end = c == "BigEndian" ? Endianness.BigEndian : Endianness.LittleEndian; + if (b) + { + return end; + } + else + { + return end == Endianness.BigEndian ? Endianness.LittleEndian : Endianness.BigEndian; + } + } + throw new NotImplementedException(); + } + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Utilities.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Utilities.cs new file mode 100644 index 0000000000..d2e4795038 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/Utilities.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// <history> +// 2020-... created by Filip Jeremic (fjeremic) as "HexView.Wpf". +// 2024-... republished by @hotkidfamily as "HexBox.WinUI". +// 2025 Included in PowerToys. (Branch master; commit 72dcf64dc858c693a7a16887004c8ddbab61fce7.) +// </history> + +namespace RegistryPreviewUILib.HexBox +{ + using System; + + /// <summary> + /// A utility class with miscellaneous methods. + /// </summary> + internal static class Utilities + { + /// <summary> + /// Clamps the <paramref name="value"/> to the range [<paramref name="min"/>, <paramref name="max"/>]. + /// </summary> + /// + /// <typeparam name="T"> + /// The type of the value to clamp. + /// </typeparam> + /// + /// <param name="value"> + /// The value to clamp. + /// </param> + /// + /// <param name="min"> + /// The upper bound on the clamped value. + /// </param> + /// + /// <param name="max"> + /// The lower bound on the clmaped value. + /// </param> + /// + /// <returns> + /// The nearest value of <paramref name="value"/> in the range [<paramref name="min"/>, + /// <paramref name="max"/>]. + /// </returns> + public static T Clamp<T>(this T value, T min, T max) + where T : IComparable<T> + { + return value.CompareTo(min) < 0 ? min : value.CompareTo(max) > 0 ? max : value; + } + + /// <summary> + /// Calculates the arithmetic modulus of <paramref name="n"/> modulo <paramref name="m"/>. + /// </summary> + /// + /// <typeparam name="T"> + /// The type of the values. + /// </typeparam> + /// + /// <param name="n"> + /// The value to compute the modulus of. + /// </param> + /// + /// <param name="m"> + /// The modulus. + /// </param> + /// + /// <returns> + /// The non-negative value <c>r</c> such that for some integral value <c>q</c>: + /// <c><paramref name="n"/> = q*m + r</c>. + /// </returns> + public static T Mod<T>(this T n, T m) + where T : IComparable<T> + { + dynamic dn = n; + dynamic dm = m; + + dynamic dr = dn % dm; + + return dr.CompareTo(0) < 0 ? dr + dm : dr; + } + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/MonacoEditorControl.xaml b/src/modules/registrypreview/RegistryPreviewUILib/Controls/MonacoEditor/MonacoEditorControl.xaml similarity index 99% rename from src/modules/registrypreview/RegistryPreviewUILib/MonacoEditorControl.xaml rename to src/modules/registrypreview/RegistryPreviewUILib/Controls/MonacoEditor/MonacoEditorControl.xaml index e3d65c72c8..3541c50f9b 100644 --- a/src/modules/registrypreview/RegistryPreviewUILib/MonacoEditorControl.xaml +++ b/src/modules/registrypreview/RegistryPreviewUILib/Controls/MonacoEditor/MonacoEditorControl.xaml @@ -1,4 +1,4 @@ -<UserControl +<UserControl x:Class="RegistryPreviewUILib.MonacoEditorControl" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" diff --git a/src/modules/registrypreview/RegistryPreviewUILib/MonacoEditorControl.xaml.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/MonacoEditor/MonacoEditorControl.xaml.cs similarity index 100% rename from src/modules/registrypreview/RegistryPreviewUILib/MonacoEditorControl.xaml.cs rename to src/modules/registrypreview/RegistryPreviewUILib/Controls/MonacoEditor/MonacoEditorControl.xaml.cs diff --git a/src/modules/registrypreview/RegistryPreviewUILib/MonacoHelper.cs b/src/modules/registrypreview/RegistryPreviewUILib/Controls/MonacoEditor/MonacoHelper.cs similarity index 100% rename from src/modules/registrypreview/RegistryPreviewUILib/MonacoHelper.cs rename to src/modules/registrypreview/RegistryPreviewUILib/Controls/MonacoEditor/MonacoHelper.cs diff --git a/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.DataPreview.cs b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.DataPreview.cs new file mode 100644 index 0000000000..5cdffda4dd --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.DataPreview.cs @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.Windows.ApplicationModel.Resources; +using Windows.Foundation.Metadata; +using HB = RegistryPreviewUILib.HexBox; + +namespace RegistryPreviewUILib +{ + public sealed partial class RegistryPreviewMainPage : Page + { + private static bool _isDataPreviewHexBoxLoaded; + + internal async Task ShowExtendedDataPreview(string name, string type, string value) + { + // Create dialog + _isDataPreviewHexBoxLoaded = false; + var panel = new StackPanel() + { + Spacing = 16, + Padding = new Thickness(0), + }; + ContentDialog contentDialog = new ContentDialog() + { + Title = resourceLoader.GetString("DataPreviewTitle") + " - " + name, + Content = panel, + CloseButtonText = resourceLoader.GetString("DataPreviewClose"), + DefaultButton = ContentDialogButton.Primary, + Padding = new Thickness(0), + }; + contentDialog.Opened += ExtendedDataPreview_Opened; + + // Add content based on value type + switch (type) + { + case "REG_DWORD": + case "REG_QWORD": + AddHexView(ref panel, ref resourceLoader, value); + break; + case "REG_NONE": + case "REG_BINARY": + // Convert value to BinaryReader + byte[] byteArray = Convert.FromHexString(value.Replace(" ", string.Empty)); + MemoryStream memoryStream = new MemoryStream(byteArray); + BinaryReader binaryData = new BinaryReader(memoryStream); + binaryData.ReadBytes(byteArray.Length); + + // Convert value to text + // For more printable asci characters the following code lines are required: + // var cpW1252 = CodePagesEncodingProvider.Instance.GetEncoding(1252); + // || b == 128 || (b >= 130 && b <= 140) || b == 142 || (b >= 145 & b <= 156) || b >= 158 + // cpW1252.GetString([b]); + string binaryDataText = string.Empty; + foreach (byte b in byteArray) + { + // ASCII codes: + // 9, 10, 13: Space, Line Feed, Carriage Return + // 32-126: Printable characters + // 128, 130-140, 142, 145-156, 158-255: Extended printable characters + if (b == 9 || b == 10 || b == 13 || (b >= 32 && b <= 126)) + { + binaryDataText += Convert.ToChar(b); + } + } + + // Add controls + AddBinaryView(ref panel, ref resourceLoader, ref binaryData, binaryDataText); + break; + case "REG_MULTI_SZ": + var multiLineBox = new TextBox() + { + IsReadOnly = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.NoWrap, + MaxHeight = 200, + FontSize = 14, + RequestedTheme = panel.ActualTheme, + Text = value, + }; + ScrollViewer.SetVerticalScrollBarVisibility(multiLineBox, ScrollBarVisibility.Auto); + ScrollViewer.SetHorizontalScrollBarVisibility(multiLineBox, ScrollBarVisibility.Auto); + AutomationProperties.SetName(multiLineBox, resourceLoader.GetString("DataPreview_AutomationPropertiesName_MultilineTextValue")); + panel.Children.Add(multiLineBox); + break; + case "REG_EXPAND_SZ": + AddExpandStringView(ref panel, ref resourceLoader, value); + break; + default: // REG_SZ + var stringBox = new TextBox() + { + IsReadOnly = true, + FontSize = 14, + RequestedTheme = panel.ActualTheme, + Text = value, + }; + AutomationProperties.SetName(stringBox, resourceLoader.GetString("DataPreview_AutomationPropertiesName_TextValue")); + panel.Children.Add(stringBox); + break; + } + + // Use this code to associate the dialog to the appropriate AppWindow by setting + // the dialog's XamlRoot to the same XamlRoot as an element that is already present in the AppWindow. + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + { + contentDialog.XamlRoot = this.Content.XamlRoot; + } + + // Show dialog and wait. + ChangeCursor(gridPreview, false); + _ = await contentDialog.ShowAsync(); + } + + private static void AddHexView(ref StackPanel panel, ref ResourceLoader resourceLoader, string value) + { + var hexBox = new TextBox() + { + Header = resourceLoader.GetString("DataPreviewHex"), + IsReadOnly = true, + FontSize = 14, + RequestedTheme = panel.ActualTheme, + Text = value.Split(" ")[0], + }; + var decimalBox = new TextBox() + { + Header = resourceLoader.GetString("DataPreviewDec"), + IsReadOnly = true, + FontSize = 14, + RequestedTheme = panel.ActualTheme, + Text = value.Split(" ")[1].TrimStart('(').TrimEnd(')'), + }; + panel.Children.Add(hexBox); + panel.Children.Add(decimalBox); + } + + private static void AddBinaryView(ref StackPanel panel, ref ResourceLoader resourceLoader, ref BinaryReader data, string dataText) + { + // Create SelectorBar + var navBar = new SelectorBar() + { + RequestedTheme = panel.ActualTheme, + }; + navBar.SelectionChanged += BinaryPreview_SelectorChanged; + navBar.Items.Add(new SelectorBarItem() + { + Text = resourceLoader.GetString("DataPreviewDataView"), + Tag = "DataView", + FontSize = 14, + RequestedTheme = panel.ActualTheme, + IsSelected = true, + }); + navBar.Items.Add(new SelectorBarItem() + { + Text = resourceLoader.GetString("DataPreviewVisibleText"), + Tag = "TextView", + FontSize = 14, + RequestedTheme = panel.ActualTheme, + IsSelected = false, + IsEnabled = !string.IsNullOrWhiteSpace(dataText), + }); + + // Create HexBox + var binaryPreviewBox = new HB.HexBox() + { + Height = 300, + Width = 495, + ShowAddress = true, + ShowData = true, + ShowText = true, + Columns = 8, + FontSize = 13, + RequestedTheme = panel.ActualTheme, + AddressBrush = (SolidColorBrush)Application.Current.Resources["AccentTextFillColorPrimaryBrush"], + AlternatingDataColumnTextBrush = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"], + SelectionTextBrush = (SolidColorBrush)Application.Current.Resources["HexBox_SelectionTextBrush"], + SelectionBrush = (SolidColorBrush)Application.Current.Resources["HexBox_SelectionBackgroundBrush"], + VerticalSeparatorLineBrush = (SolidColorBrush)Application.Current.Resources["HexBox_VerticalLineBrush"], + BorderBrush = (LinearGradientBrush)Application.Current.Resources["HexBox_ControlBorderBrush"], + BorderThickness = (Thickness)Application.Current.Resources["HexBox_ControlBorderThickness"], + CornerRadius = (CornerRadius)Application.Current.Resources["ControlCornerRadius"], + DataFormat = HB.DataFormat.Hexadecimal, + DataSignedness = HB.DataSignedness.Unsigned, + DataType = HB.DataType.Int_1, + EnforceProperties = true, + Visibility = Visibility.Collapsed, + DataSource = data, + }; + AutomationProperties.SetName(binaryPreviewBox, resourceLoader.GetString("DataPreview_AutomationPropertiesName_BinaryDataPreview")); + binaryPreviewBox.Loaded += BinaryPreview_HexBoxLoaded; + binaryPreviewBox.GotFocus += BinaryPreview_HexBoxFocused; + binaryPreviewBox.LostFocus += BinaryPreview_HexBoxFocusLost; + + // Create TextBox + var visibleText = new TextBox() + { + IsReadOnly = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + Height = 300, + Width = 495, + FontSize = 13, + Text = dataText, + RequestedTheme = panel.ActualTheme, + Visibility = Visibility.Collapsed, + }; + AutomationProperties.SetName(visibleText, resourceLoader.GetString("DataPreview_AutomationPropertiesName_VisibleTextPreview")); + + // Add controls: 0 = SelectorBar, 1 = ProgressRing, 2 = HexBox, 3 = TextBox + panel.Children.Add(navBar); + panel.Children.Add(new ProgressRing()); + panel.Children.Add(binaryPreviewBox); + panel.Children.Add(visibleText); + } + + private static void AddExpandStringView(ref StackPanel panel, ref ResourceLoader resourceLoader, string value) + { + var stringBoxRaw = new TextBox() + { + Header = resourceLoader.GetString("DataPreviewRawValue"), + IsReadOnly = true, + FontSize = 14, + RequestedTheme = panel.ActualTheme, + Text = value, + }; + var stringBoxExp = new TextBox() + { + Header = resourceLoader.GetString("DataPreviewExpandedValue"), + IsReadOnly = true, + FontSize = 14, + RequestedTheme = panel.ActualTheme, + Text = Environment.ExpandEnvironmentVariables(value), + }; + panel.Children.Add(stringBoxRaw); + panel.Children.Add(stringBoxExp); + } + + private static void BinaryPreview_SelectorChanged(SelectorBar sender, SelectorBarSelectionChangedEventArgs args) + { + // Child controls: 0 = SelectorBar, 1 = ProgressRing, 2 = HexBox, 3 = TextBox + var stackPanel = sender.Parent as StackPanel; + var progressRing = (ProgressRing)stackPanel.Children[1]; + var hexBox = (HB.HexBox)stackPanel.Children[2]; + var textBox = (TextBox)stackPanel.Children[3]; + + if (sender.SelectedItem.Tag.ToString() == "DataView") + { + textBox.Visibility = Visibility.Collapsed; + if (_isDataPreviewHexBoxLoaded) + { + progressRing.Visibility = Visibility.Collapsed; + hexBox.Visibility = Visibility.Visible; + + // Clear selection aligned to TextBox + hexBox.ClearSelection(); + hexBox.Focus(FocusState.Programmatic); + } + else + { + hexBox.Visibility = Visibility.Collapsed; + progressRing.Visibility = Visibility.Visible; + progressRing.Focus(FocusState.Programmatic); + } + } + else + { + progressRing.Visibility = Visibility.Collapsed; + + hexBox.Visibility = Visibility.Collapsed; + textBox.Visibility = Visibility.Visible; + + // Workaround for wrong text selection (color) after switching back to "Visible text" + textBox.Focus(FocusState.Programmatic); + textBox.Select(0, 0); + } + } + + private static void BinaryPreview_HexBoxLoaded(object sender, RoutedEventArgs e) + { + _isDataPreviewHexBoxLoaded = true; + + // Child controls: 0 = SelectorBar, 1 = ProgressRing, 2 = HexBox, 3 = TextBox + var hexBox = (HB.HexBox)sender; + var stackPanel = hexBox.Parent as StackPanel; + var selectorBar = stackPanel.Children[0] as SelectorBar; + var progressRing = stackPanel.Children[1] as ProgressRing; + + if (selectorBar.SelectedItem.Tag.ToString() == "DataView") + { + progressRing.Visibility = Visibility.Collapsed; + hexBox.Visibility = Visibility.Visible; + } + } + + /// <summary> + /// Event handler to set correct control border if focused. + /// </summary> + private static void BinaryPreview_HexBoxFocused(object sender, RoutedEventArgs e) + { + var hexBox = (HB.HexBox)sender; + + hexBox.BorderThickness = (Thickness)Application.Current.Resources["HexBox_ControlBorderFocusedThickness"]; + hexBox.BorderBrush = (LinearGradientBrush)Application.Current.Resources["HexBox_ControlBorderFocusedBrush"]; + } + + /// <summary> + /// Event handler to set correct control border if not focused. + /// </summary> + private static void BinaryPreview_HexBoxFocusLost(object sender, RoutedEventArgs e) + { + var hexBox = (HB.HexBox)sender; + + // Workaround: Verify that the newly focused control isn't the context menu of the HexBox control + if (FocusManager.GetFocusedElement(hexBox.XamlRoot).GetType() != typeof(MenuFlyoutPresenter)) + { + hexBox.BorderThickness = (Thickness)Application.Current.Resources["HexBox_ControlBorderThickness"]; + hexBox.BorderBrush = (LinearGradientBrush)Application.Current.Resources["HexBox_ControlBorderBrush"]; + } + } + + /// <summary> + /// Make sure that for REG_Binary preview the HexBox control is focused after opening. + /// </summary> + private static void ExtendedDataPreview_Opened(ContentDialog sender, ContentDialogOpenedEventArgs e) + { + // If <_isDataPreviewHexBoxLoaded == true> then we have the right content on the dialog. + if (_isDataPreviewHexBoxLoaded) + { + // Child controls: 0 = SelectorBar, 1 = ProgressRing, 2 = HexBox, 3 = TextBox + (sender.Content as StackPanel).Children[2].Focus(FocusState.Programmatic); + } + } + } +} diff --git a/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.Events.cs b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.Events.cs index 0df3a5c0f5..b2bf8ea277 100644 --- a/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.Events.cs +++ b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.Events.cs @@ -8,7 +8,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; - using CommunityToolkit.WinUI.UI.Controls; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; @@ -23,6 +22,10 @@ namespace RegistryPreviewUILib { private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + // Indicator if we loaded/reloaded/saved a file and need to skip TextChanged event one time. + // (Solves the problem that enabling the event handler fires it one time.) + private static bool editorContentChangedScripted; + /// <summary> /// Event that is will prevent the app from closing if the "save file" flag is active /// </summary> @@ -77,6 +80,67 @@ namespace RegistryPreviewUILib MonacoEditor.Focus(FocusState.Programmatic); } + /// <summary> + /// New button action: Ask to save last changes and reset editor content to reg header only + /// </summary> + private async void NewButton_Click(object sender, RoutedEventArgs e) + { + // Check to see if the current file has been saved + if (saveButton.IsEnabled) + { + ContentDialog contentDialog = new ContentDialog() + { + Title = resourceLoader.GetString("YesNoCancelDialogTitle"), + Content = resourceLoader.GetString("YesNoCancelDialogContent"), + PrimaryButtonText = resourceLoader.GetString("YesNoCancelDialogPrimaryButtonText"), + SecondaryButtonText = resourceLoader.GetString("YesNoCancelDialogSecondaryButtonText"), + CloseButtonText = resourceLoader.GetString("YesNoCancelDialogCloseButtonText"), + DefaultButton = ContentDialogButton.Primary, + }; + + // Use this code to associate the dialog to the appropriate AppWindow by setting + // the dialog's XamlRoot to the same XamlRoot as an element that is already present in the AppWindow. + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + { + contentDialog.XamlRoot = this.Content.XamlRoot; + } + + ContentDialogResult contentDialogResult = await contentDialog.ShowAsync(); + switch (contentDialogResult) + { + case ContentDialogResult.Primary: + // Save, then continue the new action + if (!AskFileName(string.Empty) || + !SaveFile()) + { + return; + } + + break; + case ContentDialogResult.Secondary: + // Don't save and continue the new action! + break; + default: + // Don't open the new action! + return; + } + } + + // mute the TextChanged handler to make for clean UI + MonacoEditor.TextChanged -= MonacoEditor_TextChanged; + + // reset editor, file info and ui. + _appFileName = string.Empty; + ResetEditorAndFile(); + + // disable buttons that do not make sense + UpdateUnsavedFileState(false); + refreshButton.IsEnabled = false; + + // restore the TextChanged handler + ButtonAction_RestoreTextChangedEvent(); + } + /// <summary> /// Uses a picker to select a new file to open /// </summary> @@ -107,11 +171,15 @@ namespace RegistryPreviewUILib { case ContentDialogResult.Primary: // Save, then continue the file open - SaveFile(); + if (!AskFileName(string.Empty) || + !SaveFile()) + { + return; + } + break; case ContentDialogResult.Secondary: // Don't save and continue the file open! - saveButton.IsEnabled = false; break; default: // Don't open the new file! @@ -138,14 +206,16 @@ namespace RegistryPreviewUILib { // mute the TextChanged handler to make for clean UI MonacoEditor.TextChanged -= MonacoEditor_TextChanged; + + // update file name _appFileName = storageFile.Path; UpdateToolBarAndUI(await OpenRegistryFile(_appFileName)); // disable the Save button as it's a new file - saveButton.IsEnabled = false; + UpdateUnsavedFileState(false); // Restore the event handler as we're loaded - MonacoEditor.TextChanged += MonacoEditor_TextChanged; + ButtonAction_RestoreTextChangedEvent(); } } @@ -154,7 +224,14 @@ namespace RegistryPreviewUILib /// </summary> private void SaveButton_Click(object sender, RoutedEventArgs e) { - SaveFile(); + if (!AskFileName(string.Empty)) + { + return; + } + + // save and update window title + // error handling and ui update happens in SaveFile() method + _ = SaveFile(); } /// <summary> @@ -162,23 +239,18 @@ namespace RegistryPreviewUILib /// </summary> private async void SaveAsButton_Click(object sender, RoutedEventArgs e) { - // Save out a new REG file and then open it - we have to use the direct Win32 method because FileOpenPicker crashes when it's - // called while running as admin - IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(_mainWindow); - string filename = SaveFilePicker.ShowDialog( - windowHandle, - resourceLoader.GetString("SuggestFileName"), - resourceLoader.GetString("FilterRegistryName") + '\0' + "*.reg" + '\0' + resourceLoader.GetString("FilterAllFiles") + '\0' + "*.*" + '\0' + '\0', - resourceLoader.GetString("SaveDialogTitle")); + // mute the TextChanged handler to make for clean UI + MonacoEditor.TextChanged -= MonacoEditor_TextChanged; - if (filename == string.Empty) + if (!AskFileName(_appFileName) || !SaveFile()) { return; } - _appFileName = filename; - SaveFile(); UpdateToolBarAndUI(await OpenRegistryFile(_appFileName)); + + // restore the TextChanged handler + ButtonAction_RestoreTextChangedEvent(); } /// <summary> @@ -186,16 +258,48 @@ namespace RegistryPreviewUILib /// </summary> private async void RefreshButton_Click(object sender, RoutedEventArgs e) { + // Check to see if the current file has been saved + if (saveButton.IsEnabled) + { + ContentDialog contentDialog = new ContentDialog() + { + Title = resourceLoader.GetString("YesNoCancelDialogTitle"), + Content = resourceLoader.GetString("ReloadDialogContent"), + PrimaryButtonText = resourceLoader.GetString("ReloadDialogPrimaryButtonText"), + CloseButtonText = resourceLoader.GetString("ReloadDialogCloseButtonText"), + DefaultButton = ContentDialogButton.Primary, + }; + + // Use this code to associate the dialog to the appropriate AppWindow by setting + // the dialog's XamlRoot to the same XamlRoot as an element that is already present in the AppWindow. + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + { + contentDialog.XamlRoot = this.Content.XamlRoot; + } + + ContentDialogResult contentDialogResult = await contentDialog.ShowAsync(); + switch (contentDialogResult) + { + case ContentDialogResult.Primary: + // Don't save and continue the reload action! + break; + default: + // Don't continue the reload action! + return; + } + } + // mute the TextChanged handler to make for clean UI MonacoEditor.TextChanged -= MonacoEditor_TextChanged; // reload the current Registry file and update the toolbar accordingly. UpdateToolBarAndUI(await OpenRegistryFile(_appFileName), true, true); - saveButton.IsEnabled = false; + // disable the Save button as it's a new file + UpdateUnsavedFileState(false); // restore the TextChanged handler - MonacoEditor.TextChanged += MonacoEditor_TextChanged; + ButtonAction_RestoreTextChangedEvent(); } /// <summary> @@ -256,15 +360,20 @@ namespace RegistryPreviewUILib switch (contentDialogResult) { case ContentDialogResult.Primary: - // Save, then continue the file open - SaveFile(); + // Save, then continue the merge action + if (!AskFileName(string.Empty) || + !SaveFile()) + { + return; + } + break; case ContentDialogResult.Secondary: - // Don't save and continue the file open! - saveButton.IsEnabled = false; + // Don't save and continue the merge action! + UpdateUnsavedFileState(false); break; default: - // Don't open the new file! + // Don't merge the file! return; } } @@ -354,8 +463,70 @@ namespace RegistryPreviewUILib _dispatcherQueue.TryEnqueue(() => { RefreshRegistryFile(); - saveButton.IsEnabled = true; + if (!editorContentChangedScripted) + { + UpdateUnsavedFileState(true); + } + + editorContentChangedScripted = false; }); } + + /// <summary> + /// Sets indicator for programatic text change and adds text changed handler + /// </summary> + /// <remarks> + /// Use this always, if button actions temporary disable the text changed event + /// </remarks> + private void ButtonAction_RestoreTextChangedEvent() + { + // Solves the problem that enabling the event handler fires it one time. + // These one time fired event would causes wrong unsaved changes state. + editorContentChangedScripted = true; + MonacoEditor.TextChanged += MonacoEditor_TextChanged; + } + + // Commands to show data preview + public void ButtonExtendedPreview_Click(object sender, RoutedEventArgs e) + { + var data = ((Button)sender).DataContext as RegistryValue; + InvokeExtendedDataPreview(data); + } + + public void MenuExtendedPreview_Click(object sender, RoutedEventArgs e) + { + var data = ((MenuFlyoutItem)sender).DataContext as RegistryValue; + InvokeExtendedDataPreview(data); + } + + private async void InvokeExtendedDataPreview(RegistryValue valueData) + { + // Only one content dialog can be open at the same time and multiple instances of data preview can crash the app. + if (_dialogSemaphore.CurrentCount == 0) + { + return; + } + + try + { + // Lock ui and request dialog lock + _dialogSemaphore.Wait(); + ChangeCursor(gridPreview, true); + + await ShowExtendedDataPreview(valueData.Name, valueData.Type, valueData.Value); + } + catch + { +#if DEBUG + throw; +#endif + } + finally + { + // Unblock ui and release dialog lock + ChangeCursor(gridPreview, false); + _dialogSemaphore.Release(); + } + } } } diff --git a/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.Utilities.cs b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.Utilities.cs index 819fee949a..2513a7adb0 100644 --- a/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.Utilities.cs +++ b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.Utilities.cs @@ -11,6 +11,7 @@ using System.IO; using System.Linq; using System.Reflection; using System.Text; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.UI.Input; @@ -22,7 +23,12 @@ namespace RegistryPreviewUILib { public sealed partial class RegistryPreviewMainPage : Page { + private const string NEWFILEHEADER = "Windows Registry Editor Version 5.00\r\n\r\n"; + + private static readonly string _unsavedFileIndicator = "* "; + private static readonly char[] _unsavedFileIndicatorChars = [' ', '*']; private static SemaphoreSlim _dialogSemaphore = new(1); + private string lastKeyPath; public delegate void UpdateWindowTitleFunction(string title); @@ -77,6 +83,9 @@ namespace RegistryPreviewUILib } catch { + // Set default value for empty opening + await MonacoEditor.SetTextAsync(NEWFILEHEADER); + // restore TextChanged handler to make for clean UI MonacoEditor.TextChanged += MonacoEditor_TextChanged; @@ -110,7 +119,7 @@ namespace RegistryPreviewUILib } /// <summary> - /// Method that re-opens and processes the filename the app already knows about; expected to not be a first time open + /// Method that re-opens and processes the filename that the app already knows about; expected to not be a first time open /// </summary> private void RefreshRegistryFile() { @@ -167,6 +176,25 @@ namespace RegistryPreviewUILib ChangeCursor(gridPreview, false); } + private async void ResetEditorAndFile() + { + // Disable parts of the UI that can cause trouble when loading + ChangeCursor(gridPreview, true); + + // clear the treeView and dataGrid no matter what + treeView.RootNodes.Clear(); + ClearTable(); + + // update the current window's title with the current filename + _updateWindowTitleFunction(string.Empty); + + // Set default value for empty opening + await MonacoEditor.SetTextAsync(NEWFILEHEADER); + + // Reset the cursor but leave editor disabled as no content got loaded + ChangeCursor(gridPreview, false); + } + /// <summary> /// Parses the text that is passed in, which should be the same text that's in editor /// </summary> @@ -479,6 +507,7 @@ namespace RegistryPreviewUILib case "REG_NONE": if (value.Length <= 0) { + registryValue.IsEmptyBinary = true; value = resourceLoader.GetString("ZeroLength"); } else @@ -807,42 +836,66 @@ namespace RegistryPreviewUILib /// </summary> private async void HandleDirtyClosing(string title, string content, string primaryButtonText, string secondaryButtonText, string closeButtonText) { - ContentDialog contentDialog = new ContentDialog() + if (_dialogSemaphore.CurrentCount == 0) { - Title = title, - Content = content, - PrimaryButtonText = primaryButtonText, - SecondaryButtonText = secondaryButtonText, - CloseButtonText = closeButtonText, - DefaultButton = ContentDialogButton.Primary, - }; - - // Use this code to associate the dialog to the appropriate AppWindow by setting - // the dialog's XamlRoot to the same XamlRoot as an element that is already present in the AppWindow. - if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) - { - contentDialog.XamlRoot = this.Content.XamlRoot; + return; } - ContentDialogResult contentDialogResult = await contentDialog.ShowAsync(); - - switch (contentDialogResult) + try { - case ContentDialogResult.Primary: - // Save, then close - SaveFile(); - break; - case ContentDialogResult.Secondary: - // Don't save, and then close! - saveButton.IsEnabled = false; - break; - default: - // Cancel closing! - return; - } + await _dialogSemaphore.WaitAsync(); - // if we got here, we should try to close again - Application.Current.Exit(); + ContentDialog contentDialog = new ContentDialog() + { + Title = title, + Content = content, + PrimaryButtonText = primaryButtonText, + SecondaryButtonText = secondaryButtonText, + CloseButtonText = closeButtonText, + DefaultButton = ContentDialogButton.Primary, + }; + + // Use this code to associate the dialog to the appropriate AppWindow by setting + // the dialog's XamlRoot to the same XamlRoot as an element that is already present in the AppWindow. + if (ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 8)) + { + contentDialog.XamlRoot = this.Content.XamlRoot; + } + + ContentDialogResult contentDialogResult = await contentDialog.ShowAsync(); + + switch (contentDialogResult) + { + case ContentDialogResult.Primary: + // Save, then close + if (!AskFileName(string.Empty) || + !SaveFile()) + { + return; + } + + break; + case ContentDialogResult.Secondary: + // Don't save, and then close! + UpdateUnsavedFileState(false); + break; + default: + // Cancel closing! + return; + } + + // if we got here, we should try to close again + Application.Current.Exit(); + } + catch + { + // Normally nothing to catch here. + // But for safety the try-catch ensures that we always release the content dialog lock and exit correctly. + } + finally + { + _dialogSemaphore.Release(); + } } /// <summary> @@ -902,11 +955,71 @@ namespace RegistryPreviewUILib type.InvokeMember("ProtectedCursor", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.SetProperty | BindingFlags.Instance, null, uiElement, new object[] { cursor }, CultureInfo.InvariantCulture); } + public void UpdateUnsavedFileState(bool unsavedChanges) + { + // get, cut and analyze the current title + string currentTitle = Regex.Replace(_mainWindow.Title, APPNAME + @"$|\s-\s" + APPNAME + @"$", string.Empty); + bool titleContainsIndicator = currentTitle.StartsWith(_unsavedFileIndicator, StringComparison.CurrentCultureIgnoreCase); + + // update window title and save button state + if (unsavedChanges) + { + saveButton.IsEnabled = true; + + if (!titleContainsIndicator) + { + _updateWindowTitleFunction(_unsavedFileIndicator + currentTitle); + } + } + else + { + saveButton.IsEnabled = false; + + if (titleContainsIndicator) + { + _updateWindowTitleFunction(currentTitle.TrimStart(_unsavedFileIndicatorChars)); + } + } + } + + /// <summary> + /// Ask the user for the file path if it is unknown because of an unsaved file + /// </summary> + /// <param name="fileName">If not empty always ask for a file path and use the value as name.</param> + /// <returns>Returns true if user selected a path; otherwise, false</returns> + public bool AskFileName(string fileName) + { + if (string.IsNullOrEmpty(_appFileName) || !string.IsNullOrEmpty(fileName) ) + { + string fName = string.IsNullOrEmpty(fileName) ? resourceLoader.GetString("SuggestFileName") : fileName; + + // Save out a new REG file and then open it - we have to use the direct Win32 method because FileOpenPicker crashes when it's + // called while running as admin + IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(_mainWindow); + string filename = SaveFilePicker.ShowDialog( + windowHandle, + fName, + resourceLoader.GetString("FilterRegistryName") + '\0' + "*.reg" + '\0' + resourceLoader.GetString("FilterAllFiles") + '\0' + "*.*" + '\0' + '\0', + resourceLoader.GetString("SaveDialogTitle")); + + if (filename == string.Empty) + { + return false; + } + + _appFileName = filename; + } + + return true; + } + /// <summary> /// Wrapper method that saves the current file in place, using the current text in editor. /// </summary> - private void SaveFile() + private bool SaveFile() { + bool saveSuccess = true; + ChangeCursor(gridPreview, true); // set up the FileStream for all writing @@ -930,10 +1043,13 @@ namespace RegistryPreviewUILib streamWriter.Close(); // only change when the save is successful - saveButton.IsEnabled = false; + UpdateUnsavedFileState(false); + _updateWindowTitleFunction(_appFileName); } catch (UnauthorizedAccessException ex) { + saveSuccess = false; + // this exception is thrown if the file is there but marked as read only ShowMessageBox( resourceLoader.GetString("ErrorDialogTitle"), @@ -942,6 +1058,8 @@ namespace RegistryPreviewUILib } catch { + saveSuccess = false; + // this catch handles all other exceptions thrown when trying to write the file out ShowMessageBox( resourceLoader.GetString("ErrorDialogTitle"), @@ -959,6 +1077,8 @@ namespace RegistryPreviewUILib // restore the cursor ChangeCursor(gridPreview, false); + + return saveSuccess; } /// <summary> diff --git a/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.xaml b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.xaml index a48222e9aa..50a89fc223 100644 --- a/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.xaml +++ b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewMainPage.xaml @@ -2,6 +2,7 @@ x:Class="RegistryPreviewUILib.RegistryPreviewMainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:converter="using:CommunityToolkit.WinUI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:RegistryPreviewUILib" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" @@ -10,6 +11,12 @@ xmlns:ui="using:CommunityToolkit.WinUI" mc:Ignorable="d"> + <Page.Resources> + <ResourceDictionary> + <converter:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> + </ResourceDictionary> + </Page.Resources> + <Grid x:Name="gridPreview" Grid.Row="1" @@ -55,6 +62,15 @@ HorizontalAlignment="Left" DefaultLabelPosition="Right"> + <AppBarButton + x:Name="newButton" + x:Uid="NewButton" + Click="NewButton_Click" + Icon="{ui:FontIcon Glyph=}"> + <AppBarButton.KeyboardAccelerators> + <KeyboardAccelerator Key="N" Modifiers="Control" /> + </AppBarButton.KeyboardAccelerators> + </AppBarButton> <AppBarButton x:Name="openButton" x:Uid="OpenButton" @@ -169,7 +185,7 @@ Spacing="8"> <StackPanel.ContextFlyout> <MenuFlyout> - <MenuFlyoutItem x:Uid="ContextMenu_CopyName" Command="{Binding Content.CopyToClipboardName_Click}" /> + <MenuFlyoutItem x:Uid="ContextMenu_CopyKeyName" Command="{Binding Content.CopyToClipboardName_Click}" /> <MenuFlyoutItem x:Uid="ContextMenu_CopyKeyPath" Command="{Binding Content.CopyToClipboardKeyPath_Click}" /> </MenuFlyout> </StackPanel.ContextFlyout> @@ -216,10 +232,18 @@ Spacing="8"> <StackPanel.ContextFlyout> <MenuFlyout> - <MenuFlyoutItem x:Uid="ContextMenu_CopyName" Command="{Binding CopyToClipboardName_Click}" /> - <MenuFlyoutSeparator /> + <MenuFlyoutItem + x:Uid="ContextMenu_CopyName" + Command="{Binding CopyToClipboardName_Click}" + Icon="Copy" /> <MenuFlyoutItem x:Uid="ContextMenu_CopyValue" Command="{Binding CopyToClipboardEntry_Click}" /> <MenuFlyoutItem x:Uid="ContextMenu_CopyValueWithPath" Command="{Binding CopyToClipboardWithPath_Click}" /> + <MenuFlyoutSeparator /> + <MenuFlyoutItem + x:Uid="ContextMenu_DataPreview" + Click="MenuExtendedPreview_Click" + Icon="Zoom" + IsEnabled="{Binding ShowPreviewButton}" /> </MenuFlyout> </StackPanel.ContextFlyout> <Image @@ -246,10 +270,18 @@ <Setter Property="ContextFlyout"> <Setter.Value> <MenuFlyout> - <MenuFlyoutItem x:Uid="ContextMenu_CopyType" Command="{Binding CopyToClipboardType_Click}" /> - <MenuFlyoutSeparator /> + <MenuFlyoutItem + x:Uid="ContextMenu_CopyType" + Command="{Binding CopyToClipboardType_Click}" + Icon="Copy" /> <MenuFlyoutItem x:Uid="ContextMenu_CopyValue" Command="{Binding CopyToClipboardEntry_Click}" /> <MenuFlyoutItem x:Uid="ContextMenu_CopyValueWithPath" Command="{Binding CopyToClipboardWithPath_Click}" /> + <MenuFlyoutSeparator /> + <MenuFlyoutItem + x:Uid="ContextMenu_DataPreview" + Click="MenuExtendedPreview_Click" + Icon="Zoom" + IsEnabled="{Binding ShowPreviewButton}" /> </MenuFlyout> </Setter.Value> </Setter> @@ -265,20 +297,36 @@ <StackPanel Margin="4" VerticalAlignment="Center" - Orientation="Horizontal"> + Orientation="Horizontal" + Spacing="6"> <StackPanel.ContextFlyout> <MenuFlyout> - <MenuFlyoutItem x:Uid="ContextMenu_CopyData" Command="{Binding CopyToClipboardData_Click}" /> - <MenuFlyoutSeparator /> + <MenuFlyoutItem + x:Uid="ContextMenu_CopyData" + Command="{Binding CopyToClipboardData_Click}" + Icon="Copy" /> <MenuFlyoutItem x:Uid="ContextMenu_CopyValue" Command="{Binding CopyToClipboardEntry_Click}" /> <MenuFlyoutItem x:Uid="ContextMenu_CopyValueWithPath" Command="{Binding CopyToClipboardWithPath_Click}" /> + <MenuFlyoutSeparator /> + <MenuFlyoutItem + x:Uid="ContextMenu_DataPreview" + Click="MenuExtendedPreview_Click" + Icon="Zoom" + IsEnabled="{Binding ShowPreviewButton}" /> </MenuFlyout> </StackPanel.ContextFlyout> + <Button + x:Uid="ShowDataPreviewButton" + Padding="2" + Click="ButtonExtendedPreview_Click" + Content="" + FontFamily="{StaticResource SymbolThemeFontFamily}" + Visibility="{Binding ShowPreviewButton, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> <TextBlock + VerticalAlignment="Center" IsTabStop="False" Style="{StaticResource CaptionTextBlockStyle}" - Text="{Binding ValueOneLine}" - ToolTipService.ToolTip="{Binding Value}" /> + Text="{Binding ValueOneLine}" /> </StackPanel> </DataTemplate> </tk7controls:DataGridTemplateColumn.CellTemplate> diff --git a/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewUILib.csproj b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewUILib.csproj index 4b84ed53ef..7b91f8e0d6 100644 --- a/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewUILib.csproj +++ b/src/modules/registrypreview/RegistryPreviewUILib/RegistryPreviewUILib.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\..\Monaco.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Monaco.props" /> <PropertyGroup> <OutputType>Library</OutputType> @@ -40,7 +40,9 @@ </ItemGroup> <ItemGroup> - <None Remove="MonacoEditorControl.xaml" /> + <None Remove="Controls\MonacoEditor\MonacoEditorControl.xaml" /> + <None Remove="Controls\HexBox\Themes\Generic.xaml" /> + <None Remove="Themes\Generic.xaml" /> </ItemGroup> <ItemGroup> @@ -51,6 +53,7 @@ <PackageReference Include="CommunityToolkit.WinUI.Extensions" /> <PackageReference Include="Microsoft.Windows.SDK.BuildTools" /> <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="SkiaSharp.Views.WinUI" /> <Manifest Include="$(ApplicationManifest)" /> </ItemGroup> @@ -59,10 +62,21 @@ <CopyToOutputDirectory>Always</CopyToOutputDirectory> </None> </ItemGroup> - + <ItemGroup> - <Page Update="MonacoEditorControl.xaml"> + <Page Update="Controls\MonacoEditor\MonacoEditorControl.xaml"> <Generator>MSBuild:Compile</Generator> </Page> + <Page Update="Controls\HexBox\Themes\Generic.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + <Page Update="Themes\Generic.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + </ItemGroup> + + <ItemGroup> + <Folder Include="Controls\MonacoEditor\" /> + <Folder Include="Controls\HexBox\" /> </ItemGroup> </Project> diff --git a/src/modules/registrypreview/RegistryPreviewUILib/RegistryValue.xaml.cs b/src/modules/registrypreview/RegistryPreviewUILib/RegistryValue.xaml.cs index 1bf403c3fa..4bddb8d9a3 100644 --- a/src/modules/registrypreview/RegistryPreviewUILib/RegistryValue.xaml.cs +++ b/src/modules/registrypreview/RegistryPreviewUILib/RegistryValue.xaml.cs @@ -6,7 +6,6 @@ using System; using System.Windows.Input; using CommunityToolkit.Mvvm.Input; using Microsoft.UI.Xaml; -using Windows.ApplicationModel.DataTransfer; namespace RegistryPreviewUILib { @@ -29,6 +28,8 @@ namespace RegistryPreviewUILib public string Value { get; set; } + public bool IsEmptyBinary { private get; set; } + public string ValueOneLine => Value.Replace('\r', ' '); public string ToolTipText { get; set; } @@ -54,6 +55,10 @@ namespace RegistryPreviewUILib } } + public bool ShowPreviewButton => + Type != "ERROR" && Type != string.Empty && + Value != string.Empty && IsEmptyBinary != true; + public RegistryValue(string name, string type, string value, string key) { this.Name = name; diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Strings/en-US/Resources.resw b/src/modules/registrypreview/RegistryPreviewUILib/Strings/en-US/Resources.resw index 73469a3b50..b52dd4b511 100644 --- a/src/modules/registrypreview/RegistryPreviewUILib/Strings/en-US/Resources.resw +++ b/src/modules/registrypreview/RegistryPreviewUILib/Strings/en-US/Resources.resw @@ -258,12 +258,18 @@ <data name="YesNoCancelDialogCloseButtonText" xml:space="preserve"> <value>Cancel</value> </data> + <data name="ReloadDialogCloseButtonText" xml:space="preserve"> + <value>No</value> + </data> <data name="YesNoCancelDialogContent" xml:space="preserve"> <value>Save changes?</value> </data> <data name="YesNoCancelDialogPrimaryButtonText" xml:space="preserve"> <value>Save</value> </data> + <data name="ReloadDialogPrimaryButtonText" xml:space="preserve"> + <value>Yes</value> + </data> <data name="YesNoCancelDialogSecondaryButtonText" xml:space="preserve"> <value>Don't save</value> </data> @@ -277,16 +283,20 @@ <value>Copy path</value> <comment>Like "Copy item"</comment> </data> - <data name="ContextMenu_CopyName.Text" xml:space="preserve"> + <data name="ContextMenu_CopyKeyName.Text" xml:space="preserve"> <value>Copy name</value> <comment>Like "Copy item"</comment> </data> + <data name="ContextMenu_CopyName.Text" xml:space="preserve"> + <value>Copy</value> + <comment>Like "Copy item"</comment> + </data> <data name="ContextMenu_CopyType.Text" xml:space="preserve"> - <value>Copy type</value> + <value>Copy</value> <comment>Like "Copy item"</comment> </data> <data name="ContextMenu_CopyData.Text" xml:space="preserve"> - <value>Copy data</value> + <value>Copy</value> <comment>Like "Copy item"</comment> </data> <data name="ContextMenu_CopyValue.Text" xml:space="preserve"> @@ -297,4 +307,69 @@ <value>Copy value with key path</value> <comment>Like "Copy item"</comment> </data> + <data name="ShowDataPreviewButton.ToolTipService.ToolTip" xml:space="preserve"> + <value>Show extended preview</value> + </data> + <data name="ShowDataPreviewButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Show extended preview</value> + </data> + <data name="DataPreviewTitle" xml:space="preserve"> + <value>View data</value> + </data> + <data name="DataPreviewClose" xml:space="preserve"> + <value>Close</value> + <comment>like "Close window"</comment> + </data> + <data name="DataPreviewHex" xml:space="preserve"> + <value>Hexadecimal</value> + </data> + <data name="DataPreviewDec" xml:space="preserve"> + <value>Decimal</value> + </data> + <data name="DataPreviewRawValue" xml:space="preserve"> + <value>Raw value</value> + </data> + <data name="DataPreviewExpandedValue" xml:space="preserve"> + <value>Expanded value</value> + </data> + <data name="DataPreviewVisibleText" xml:space="preserve"> + <value>Readable text</value> + <comment>Means text that is human readable and not only control characters.</comment> + </data> + <data name="DataPreviewDataView" xml:space="preserve"> + <value>Data</value> + <comment>Like "binary data"</comment> + </data> + <data name="DataPreview_AutomationPropertiesName_MultilineTextValue" xml:space="preserve"> + <value>Multiline text value</value> + </data> + <data name="DataPreview_AutomationPropertiesName_TextValue" xml:space="preserve"> + <value>Text value</value> + </data> + <data name="DataPreview_AutomationPropertiesName_BinaryDataPreview" xml:space="preserve"> + <value>Binary data preview</value> + </data> + <data name="DataPreview_AutomationPropertiesName_VisibleTextPreview" xml:space="preserve"> + <value>Preview of readable text</value> + </data> + <data name="HexBox_CopyCommand.Text" xml:space="preserve"> + <value>Copy</value> + <comment>Like "copy the value"</comment> + </data> + <data name="HexBox_CopyTextCommand.Text" xml:space="preserve"> + <value>Copy text</value> + <comment>Like "Copy the text"</comment> + </data> + <data name="HexBox_SelectAllCommand.Text" xml:space="preserve"> + <value>Select all</value> + </data> + <data name="ContextMenu_DataPreview.Text" xml:space="preserve"> + <value>Extended data preview</value> + </data> + <data name="NewButton.Label" xml:space="preserve"> + <value>New</value> + </data> + <data name="ReloadDialogContent" xml:space="preserve"> + <value>You lose any unsaved changes. Reload anyway?</value> + </data> </root> \ No newline at end of file diff --git a/src/modules/registrypreview/RegistryPreviewUILib/Themes/Generic.xaml b/src/modules/registrypreview/RegistryPreviewUILib/Themes/Generic.xaml new file mode 100644 index 0000000000..879cdc9bd6 --- /dev/null +++ b/src/modules/registrypreview/RegistryPreviewUILib/Themes/Generic.xaml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="using:RegistryPreviewUILib"> + + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="/PowerToys.RegistryPreviewUILib/Controls/HexBox/Themes/Generic.xaml" /> + </ResourceDictionary.MergedDictionaries> +</ResourceDictionary> diff --git a/src/modules/registrypreview/RegistryPreviewUILib/app.manifest b/src/modules/registrypreview/RegistryPreviewUILib/app.manifest index 0871bb63b2..d4e6b85826 100644 --- a/src/modules/registrypreview/RegistryPreviewUILib/app.manifest +++ b/src/modules/registrypreview/RegistryPreviewUILib/app.manifest @@ -9,7 +9,7 @@ 2) System < Windows 10 Anniversary Update --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> diff --git a/src/runner/PowerToys.exe.manifest b/src/runner/PowerToys.exe.manifest index e8c022cf25..ce82aa752f 100644 --- a/src/runner/PowerToys.exe.manifest +++ b/src/runner/PowerToys.exe.manifest @@ -6,4 +6,11 @@ <maxversiontested Id="10.0.19041.0"/> </application> </compatibility> + + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> + </windowsSettings> + </application> </assembly> diff --git a/src/runner/Resources.resx b/src/runner/Resources.resx index 902e3a0874..b94f84714e 100644 --- a/src/runner/Resources.resx +++ b/src/runner/Resources.resx @@ -1,5 +1,64 @@ <?xml version="1.0" encoding="utf-8"?> <root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> <xsd:element name="root" msdata:IsDataSet="true"> @@ -89,7 +148,7 @@ <value>This setting has been disabled by your administrator.</value> </data> <data name="STARTUP_DISABLED_BY_USER" xml:space="preserve"> - <value>This setting has been disabled manually via <a href="https://ms_settings_startupapps" target="_blank">Startup Settings</a>.</value> + <value>This setting has been disabled manually via <a href="https://ms_settings_startupapps" target="_blank">Startup Settings</a>.</value> </data> <data name="GITHUB_NEW_VERSION_AVAILABLE" xml:space="preserve"> <value>An update to PowerToys is available.</value> @@ -114,13 +173,13 @@ <value>Settings\tDouble-click</value> <comment>Don't localize "\t" as that is what separates the click portion to be right aligned in the menu.</comment> </data> + <data name="SETTINGS_MENU_TEXT_LEFTCLICK" xml:space="preserve"> + <value>Settings\tLeft-click</value> + <comment>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.</comment> + </data> <data name="DOCUMENTATION_MENU_TEXT" xml:space="preserve"> <value>Documentation</value> </data> - <data name="EXIT_MENU_TEXT" xml:space="preserve"> - <value>Exit</value> - <comment>Exit as a verb, as in Exit the application</comment> - </data> <data name="SUBMIT_BUG_TEXT" xml:space="preserve"> <value>Report bug</value> </data> @@ -134,4 +193,8 @@ <data name="TRAY_ICON_ADMIN_TOOLTIP" xml:space="preserve"> <value>Administrator</value> </data> -</root> + <data name="CLOSE_MENU_TEXT" xml:space="preserve"> + <value>Close</value> + <comment>Close as a verb, as in Close the application</comment> + </data> +</root> \ No newline at end of file diff --git a/src/runner/UpdateUtils.cpp b/src/runner/UpdateUtils.cpp index 23743d2001..8c33d781a1 100644 --- a/src/runner/UpdateUtils.cpp +++ b/src/runner/UpdateUtils.cpp @@ -4,6 +4,7 @@ #include "ActionRunnerUtils.h" #include "general_settings.h" +#include "trace.h" #include "UpdateUtils.h" #include <common/utils/gpo.h> @@ -172,10 +173,12 @@ void ProcessNewVersionInfo(const github_version_info& version_info, // Cleanup old updates before downloading the latest updating::cleanup_updates(); - if (download_new_version(new_version_info).get()) + auto downloaded_installer = std::move(download_new_version_async(new_version_info)).get(); + if (downloaded_installer) { state.state = UpdateState::readyToInstall; state.downloadedInstallerFilename = new_version_info.installer_filename; + Trace::UpdateDownloadCompleted(true, new_version_info.version.toWstring()); if (show_notifications) { ShowNewVersionAvailable(new_version_info); @@ -185,6 +188,7 @@ void ProcessNewVersionInfo(const github_version_info& version_info, { state.state = UpdateState::errorDownloading; state.downloadedInstallerFilename = {}; + Trace::UpdateDownloadCompleted(false, new_version_info.version.toWstring()); Logger::error("Couldn't download new installer"); } } @@ -229,14 +233,19 @@ void PeriodicUpdateWorker() bool version_info_obtained = false; try { - const auto new_version_info = get_github_version_info_async().get(); + const auto new_version_info = std::move(get_github_version_info_async()).get(); if (new_version_info.has_value()) { version_info_obtained = true; + bool updateAvailable = std::holds_alternative<new_version_download_info>(*new_version_info); + std::wstring fromVersion = get_product_version(); + std::wstring toVersion = updateAvailable ? std::get<new_version_download_info>(*new_version_info).version.toWstring() : L""; + Trace::UpdateCheckCompleted(true, updateAvailable, fromVersion, toVersion); ProcessNewVersionInfo(*new_version_info, state, download_update, true); } else { + Trace::UpdateCheckCompleted(false, false, get_product_version(), L""); Logger::error(L"Couldn't obtain version info from github: {}", new_version_info.error()); } } @@ -264,11 +273,12 @@ void CheckForUpdatesCallback() auto state = UpdateState::read(); try { - auto new_version_info = get_github_version_info_async().get(); + auto new_version_info = std::move(get_github_version_info_async()).get(); if (!new_version_info) { // We couldn't get a new version from github for some reason, log error state.state = UpdateState::networkError; + Trace::UpdateCheckCompleted(false, false, get_product_version(), L""); Logger::error(L"Couldn't obtain version info from github: {}", new_version_info.error()); } else @@ -281,6 +291,10 @@ void CheckForUpdatesCallback() download_update = false; } + bool updateAvailable = std::holds_alternative<new_version_download_info>(*new_version_info); + std::wstring fromVersion = get_product_version(); + std::wstring toVersion = updateAvailable ? std::get<new_version_download_info>(*new_version_info).version.toWstring() : L""; + Trace::UpdateCheckCompleted(true, updateAvailable, fromVersion, toVersion); ProcessNewVersionInfo(*new_version_info, state, download_update, false); } diff --git a/src/runner/ai_detection.h b/src/runner/ai_detection.h new file mode 100644 index 0000000000..41aa1dc328 --- /dev/null +++ b/src/runner/ai_detection.h @@ -0,0 +1,11 @@ +#pragma once + +// Detect AI capabilities by calling ImageResizer in detection mode. +// This runs in a background thread to avoid blocking. +// ImageResizer writes the result to a cache file that it reads on normal startup. +// +// Parameters: +// skipSettingsCheck - If true, skip checking if ImageResizer is enabled in settings. +// Use this when called from apply_general_settings where we know +// ImageResizer is being enabled but settings file may not be saved yet. +void DetectAiCapabilitiesAsync(bool skipSettingsCheck = false); diff --git a/src/runner/auto_start_helper.cpp b/src/runner/auto_start_helper.cpp index 56e11f1c6e..ac1ae45afe 100644 --- a/src/runner/auto_start_helper.cpp +++ b/src/runner/auto_start_helper.cpp @@ -5,22 +5,24 @@ #include <comdef.h> #include <taskschd.h> +#include <common/logger/logger.h> // Helper macros from wix. -// TODO: use "s" and "..." parameters to report errors from these functions. #define ExitOnFailure(x, s, ...) \ if (FAILED(x)) \ { \ + Logger::error(s, ##__VA_ARGS__); \ goto LExit; \ } #define ExitWithLastError(x, s, ...) \ { \ - DWORD Dutil_er = ::GetLastError(); \ - x = HRESULT_FROM_WIN32(Dutil_er); \ + DWORD util_err = ::GetLastError(); \ + x = HRESULT_FROM_WIN32(util_err); \ if (!FAILED(x)) \ { \ x = E_FAIL; \ } \ + Logger::error(s, ##__VA_ARGS__); \ goto LExit; \ } #define ExitFunction() \ @@ -52,11 +54,11 @@ bool create_auto_start_task_for_this_user(bool runElevated) // Get the Domain/Username for the trigger. if (!GetEnvironmentVariable(L"USERNAME", username, USERNAME_LEN)) { - ExitWithLastError(hr, "Getting username failed: %x", hr); + ExitWithLastError(hr, "Getting username failed: {:x}", hr); } if (!GetEnvironmentVariable(L"USERDOMAIN", username_domain, USERNAME_DOMAIN_LEN)) { - ExitWithLastError(hr, "Getting the user's domain failed: %x", hr); + ExitWithLastError(hr, "Getting the user's domain failed: {:x}", hr); } wcscat_s(username_domain, L"\\"); wcscat_s(username_domain, username); @@ -76,11 +78,11 @@ bool create_auto_start_task_for_this_user(bool runElevated) CLSCTX_INPROC_SERVER, IID_ITaskService, reinterpret_cast<void**>(&pService)); - ExitOnFailure(hr, "Failed to create an instance of ITaskService: %x", hr); + ExitOnFailure(hr, "Failed to create an instance of ITaskService: {:x}", hr); // Connect to the task service. hr = pService->Connect(_variant_t(), _variant_t(), _variant_t(), _variant_t()); - ExitOnFailure(hr, "ITaskService::Connect failed: %x", hr); + ExitOnFailure(hr, "ITaskService::Connect failed: {:x}", hr); // ------------------------------------------------------ // Get the PowerToys task folder. Creates it if it doesn't exist. @@ -90,12 +92,12 @@ bool create_auto_start_task_for_this_user(bool runElevated) // Folder doesn't exist. Get the Root folder and create the PowerToys subfolder. ITaskFolder* pRootFolder = NULL; hr = pService->GetFolder(_bstr_t(L"\\"), &pRootFolder); - ExitOnFailure(hr, "Cannot get Root Folder pointer: %x", hr); + ExitOnFailure(hr, "Cannot get Root Folder pointer: {:x}", hr); hr = pRootFolder->CreateFolder(_bstr_t(L"\\PowerToys"), _variant_t(L""), &pTaskFolder); if (FAILED(hr)) { pRootFolder->Release(); - ExitOnFailure(hr, "Cannot create PowerToys task folder: %x", hr); + ExitOnFailure(hr, "Cannot create PowerToys task folder: {:x}", hr); } } @@ -118,47 +120,47 @@ bool create_auto_start_task_for_this_user(bool runElevated) // Create the task builder object to create the task. hr = pService->NewTask(0, &pTask); - ExitOnFailure(hr, "Failed to create a task definition: %x", hr); + ExitOnFailure(hr, "Failed to create a task definition: {:x}", hr); // ------------------------------------------------------ // Get the registration info for setting the identification. hr = pTask->get_RegistrationInfo(&pRegInfo); - ExitOnFailure(hr, "Cannot get identification pointer: %x", hr); + ExitOnFailure(hr, "Cannot get identification pointer: {:x}", hr); hr = pRegInfo->put_Author(_bstr_t(username_domain)); - ExitOnFailure(hr, "Cannot put identification info: %x", hr); + ExitOnFailure(hr, "Cannot put identification info: {:x}", hr); // ------------------------------------------------------ // Create the settings for the task hr = pTask->get_Settings(&pSettings); - ExitOnFailure(hr, "Cannot get settings pointer: %x", hr); + ExitOnFailure(hr, "Cannot get settings pointer: {:x}", hr); hr = pSettings->put_StartWhenAvailable(VARIANT_FALSE); - ExitOnFailure(hr, "Cannot put_StartWhenAvailable setting info: %x", hr); + ExitOnFailure(hr, "Cannot put_StartWhenAvailable setting info: {:x}", hr); hr = pSettings->put_StopIfGoingOnBatteries(VARIANT_FALSE); - ExitOnFailure(hr, "Cannot put_StopIfGoingOnBatteries setting info: %x", hr); + ExitOnFailure(hr, "Cannot put_StopIfGoingOnBatteries setting info: {:x}", hr); hr = pSettings->put_ExecutionTimeLimit(_bstr_t(L"PT0S")); //Unlimited - ExitOnFailure(hr, "Cannot put_ExecutionTimeLimit setting info: %x", hr); + ExitOnFailure(hr, "Cannot put_ExecutionTimeLimit setting info: {:x}", hr); hr = pSettings->put_DisallowStartIfOnBatteries(VARIANT_FALSE); - ExitOnFailure(hr, "Cannot put_DisallowStartIfOnBatteries setting info: %x", hr); + ExitOnFailure(hr, "Cannot put_DisallowStartIfOnBatteries setting info: {:x}", hr); hr = pSettings->put_Priority(4); - ExitOnFailure(hr, "Cannot put_Priority setting info : %x", hr); + ExitOnFailure(hr, "Cannot put_Priority setting info : {:x}", hr); // ------------------------------------------------------ // Get the trigger collection to insert the logon trigger. hr = pTask->get_Triggers(&pTriggerCollection); - ExitOnFailure(hr, "Cannot get trigger collection: %x", hr); + ExitOnFailure(hr, "Cannot get trigger collection: {:x}", hr); // Add the logon trigger to the task. { ITrigger* pTrigger = NULL; ILogonTrigger* pLogonTrigger = NULL; hr = pTriggerCollection->Create(TASK_TRIGGER_LOGON, &pTrigger); - ExitOnFailure(hr, "Cannot create the trigger: %x", hr); + ExitOnFailure(hr, "Cannot create the trigger: {:x}", hr); hr = pTrigger->QueryInterface( IID_ILogonTrigger, reinterpret_cast<void**>(&pLogonTrigger)); pTrigger->Release(); - ExitOnFailure(hr, "QueryInterface call failed for ILogonTrigger: %x", hr); + ExitOnFailure(hr, "QueryInterface call failed for ILogonTrigger: {:x}", hr); hr = pLogonTrigger->put_Id(_bstr_t(L"Trigger1")); @@ -170,7 +172,7 @@ bool create_auto_start_task_for_this_user(bool runElevated) // The specified user must be a user on this computer. hr = pLogonTrigger->put_UserId(_bstr_t(username_domain)); pLogonTrigger->Release(); - ExitOnFailure(hr, "Cannot add user ID to logon trigger: %x", hr); + ExitOnFailure(hr, "Cannot add user ID to logon trigger: {:x}", hr); } // ------------------------------------------------------ @@ -182,23 +184,23 @@ bool create_auto_start_task_for_this_user(bool runElevated) // Get the task action collection pointer. hr = pTask->get_Actions(&pActionCollection); - ExitOnFailure(hr, "Cannot get Task collection pointer: %x", hr); + ExitOnFailure(hr, "Cannot get Task collection pointer: {:x}", hr); // Create the action, specifying that it is an executable action. hr = pActionCollection->Create(TASK_ACTION_EXEC, &pAction); pActionCollection->Release(); - ExitOnFailure(hr, "Cannot create the action: %x", hr); + ExitOnFailure(hr, "Cannot create the action: {:x}", hr); // QI for the executable task pointer. hr = pAction->QueryInterface( IID_IExecAction, reinterpret_cast<void**>(&pExecAction)); pAction->Release(); - ExitOnFailure(hr, "QueryInterface call failed for IExecAction: %x", hr); + ExitOnFailure(hr, "QueryInterface call failed for IExecAction: {:x}", hr); // Set the path of the executable to PowerToys (passed as CustomActionData). hr = pExecAction->put_Path(_bstr_t(wszExecutablePath)); pExecAction->Release(); - ExitOnFailure(hr, "Cannot set path of executable: %x", hr); + ExitOnFailure(hr, "Cannot set path of executable: {:x}", hr); } // ------------------------------------------------------ @@ -206,7 +208,7 @@ bool create_auto_start_task_for_this_user(bool runElevated) { IPrincipal* pPrincipal = NULL; hr = pTask->get_Principal(&pPrincipal); - ExitOnFailure(hr, "Cannot get principal pointer: %x", hr); + ExitOnFailure(hr, "Cannot get principal pointer: {:x}", hr); // Set up principal information: hr = pPrincipal->put_Id(_bstr_t(L"Principal1")); @@ -224,7 +226,7 @@ bool create_auto_start_task_for_this_user(bool runElevated) hr = pPrincipal->put_RunLevel(_TASK_RUNLEVEL::TASK_RUNLEVEL_LUA); } pPrincipal->Release(); - ExitOnFailure(hr, "Cannot put principal run level: %x", hr); + ExitOnFailure(hr, "Cannot put principal run level: {:x}", hr); } // ------------------------------------------------------ // Save the task in the PowerToys folder. @@ -239,7 +241,7 @@ bool create_auto_start_task_for_this_user(bool runElevated) TASK_LOGON_INTERACTIVE_TOKEN, SDDL_FULL_ACCESS_FOR_EVERYONE, &pRegisteredTask); - ExitOnFailure(hr, "Error saving the Task : %x", hr); + ExitOnFailure(hr, "Error saving the Task : {:x}", hr); } LExit: @@ -275,7 +277,7 @@ bool delete_auto_start_task_for_this_user() // Get the Username for the task. if (!GetEnvironmentVariable(L"USERNAME", username, USERNAME_LEN)) { - ExitWithLastError(hr, "Getting username failed: %x", hr); + ExitWithLastError(hr, "Getting username failed: {:x}", hr); } // Task Name. @@ -289,11 +291,11 @@ bool delete_auto_start_task_for_this_user() CLSCTX_INPROC_SERVER, IID_ITaskService, reinterpret_cast<void**>(&pService)); - ExitOnFailure(hr, "Failed to create an instance of ITaskService: %x", hr); + ExitOnFailure(hr, "Failed to create an instance of ITaskService: {:x}", hr); // Connect to the task service. hr = pService->Connect(_variant_t(), _variant_t(), _variant_t(), _variant_t()); - ExitOnFailure(hr, "ITaskService::Connect failed: %x", hr); + ExitOnFailure(hr, "ITaskService::Connect failed: {:x}", hr); // ------------------------------------------------------ // Get the PowerToys task folder. @@ -340,7 +342,7 @@ bool is_auto_start_task_active_for_this_user() // Get the Username for the task. if (!GetEnvironmentVariable(L"USERNAME", username, USERNAME_LEN)) { - ExitWithLastError(hr, "Getting username failed: %x", hr); + ExitWithLastError(hr, "Getting username failed: {:x}", hr); } // Task Name. @@ -354,16 +356,16 @@ bool is_auto_start_task_active_for_this_user() CLSCTX_INPROC_SERVER, IID_ITaskService, reinterpret_cast<void**>(&pService)); - ExitOnFailure(hr, "Failed to create an instance of ITaskService: %x", hr); + ExitOnFailure(hr, "Failed to create an instance of ITaskService: {:x}", hr); // Connect to the task service. hr = pService->Connect(_variant_t(), _variant_t(), _variant_t(), _variant_t()); - ExitOnFailure(hr, "ITaskService::Connect failed: %x", hr); + ExitOnFailure(hr, "ITaskService::Connect failed: {:x}", hr); // ------------------------------------------------------ // Get the PowerToys task folder. hr = pService->GetFolder(_bstr_t(L"\\PowerToys"), &pTaskFolder); - ExitOnFailure(hr, "ITaskFolder doesn't exist: %x", hr); + ExitOnFailure(hr, "ITaskFolder doesn't exist: {:x}", hr); // ------------------------------------------------------ // If the task exists, disable. diff --git a/src/runner/bug_report.cpp b/src/runner/bug_report.cpp index 9abfe6fa18..697bf518f7 100644 --- a/src/runner/bug_report.cpp +++ b/src/runner/bug_report.cpp @@ -4,17 +4,52 @@ #include <common/utils/process_path.h> #include <common/utils/resources.h> -std::atomic_bool isBugReportThreadRunning = false; +BugReportManager& BugReportManager::instance() +{ + static BugReportManager instance; + return instance; +} -void launch_bug_report() noexcept +void BugReportManager::register_callback(const BugReportCallback& callback) +{ + std::lock_guard<std::mutex> lock(m_callbacksMutex); + m_callbacks.push_back(callback); +} + +void BugReportManager::clear_callbacks() +{ + std::lock_guard<std::mutex> lock(m_callbacksMutex); + m_callbacks.clear(); +} + +void BugReportManager::notify_observers(bool isRunning) +{ + std::lock_guard<std::mutex> lock(m_callbacksMutex); + for (const auto& callback : m_callbacks) + { + try + { + callback(isRunning); + } + catch (...) + { + // Ignore callback exceptions to prevent one bad callback from affecting others + } + } +} + +void BugReportManager::launch_bug_report() noexcept { std::wstring bug_report_path = get_module_folderpath(); bug_report_path += L"\\Tools\\PowerToys.BugReportTool.exe"; - bool expected_isBugReportThreadRunning = false; - if (isBugReportThreadRunning.compare_exchange_strong(expected_isBugReportThreadRunning, true)) + bool expected_isBugReportRunning = false; + if (m_isBugReportRunning.compare_exchange_strong(expected_isBugReportRunning, true)) { - std::thread([bug_report_path]() { + // Notify observers that bug report is starting + notify_observers(true); + + std::thread([this, bug_report_path]() { SHELLEXECUTEINFOW sei{ sizeof(sei) }; sei.fMask = { SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC | SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE }; sei.lpFile = bug_report_path.c_str(); @@ -27,7 +62,29 @@ void launch_bug_report() noexcept MessageBoxW(nullptr, bugreport_success.c_str(), L"PowerToys", MB_OK); } - isBugReportThreadRunning.store(false); + m_isBugReportRunning.store(false); + // Notify observers that bug report has finished + notify_observers(false); }).detach(); } + else + { + notify_observers(false); + } +} + +bool BugReportManager::is_bug_report_running() const noexcept +{ + return m_isBugReportRunning.load(); +} + +// Legacy functions for backward compatibility +void launch_bug_report() noexcept +{ + BugReportManager::instance().launch_bug_report(); +} + +bool is_bug_report_running() noexcept +{ + return BugReportManager::instance().is_bug_report_running(); } diff --git a/src/runner/bug_report.h b/src/runner/bug_report.h index 2d7084ea21..6edb1c6ba3 100644 --- a/src/runner/bug_report.h +++ b/src/runner/bug_report.h @@ -1,3 +1,43 @@ #pragma once -void launch_bug_report() noexcept; \ No newline at end of file +#include <functional> +#include <vector> +#include <mutex> + +// Observer pattern for bug report status changes +using BugReportCallback = std::function<void(bool isRunning)>; + +class BugReportManager +{ +public: + static BugReportManager& instance(); + + // Register a callback to be notified when bug report status changes + void register_callback(const BugReportCallback& callback); + + // Remove all callbacks (useful for cleanup) + void clear_callbacks(); + + // Launch bug report and notify observers + void launch_bug_report() noexcept; + + // Check if bug report is currently running + bool is_bug_report_running() const noexcept; + +private: + BugReportManager() = default; + ~BugReportManager() = default; + BugReportManager(const BugReportManager&) = delete; + BugReportManager& operator=(const BugReportManager&) = delete; + + // Notify all registered callbacks + void notify_observers(bool isRunning); + + std::atomic_bool m_isBugReportRunning = false; + std::vector<BugReportCallback> m_callbacks; + mutable std::mutex m_callbacksMutex; +}; + +// Legacy functions for backward compatibility +void launch_bug_report() noexcept; +bool is_bug_report_running() noexcept; \ No newline at end of file diff --git a/src/runner/centralized_hotkeys.h b/src/runner/centralized_hotkeys.h index 29ef079f9e..bb503d332d 100644 --- a/src/runner/centralized_hotkeys.h +++ b/src/runner/centralized_hotkeys.h @@ -20,11 +20,13 @@ namespace CentralizedHotkeys { WORD modifiersMask; WORD vkCode; + int hotkeyID; - Shortcut(WORD modifiersMask = 0, WORD vkCode = 0) + Shortcut(WORD modifiersMask = 0, WORD vkCode = 0, const int hotkeyID = 0) { this->modifiersMask = modifiersMask; this->vkCode = vkCode; + this->hotkeyID = hotkeyID; } bool operator<(const Shortcut& key) const diff --git a/src/runner/general_settings.cpp b/src/runner/general_settings.cpp index 83128b05e9..6225ed8f2a 100644 --- a/src/runner/general_settings.cpp +++ b/src/runner/general_settings.cpp @@ -1,30 +1,91 @@ #include "pch.h" #include "general_settings.h" #include "auto_start_helper.h" +#include "tray_icon.h" +#include "quick_access_host.h" #include "Generated files/resource.h" +#include "hotkey_conflict_detector.h" #include <common/SettingsAPI/settings_helpers.h> #include "powertoy_module.h" #include <common/themes/windows_colors.h> #include "trace.h" +#include "ai_detection.h" #include <common/utils/elevation.h> #include <common/version/version.h> #include <common/utils/resources.h> +namespace +{ + json::JsonValue create_empty_shortcut_array_value() + { + return json::JsonValue::Parse(L"[]"); + } + + void ensure_ignored_conflict_properties_shape(json::JsonObject& obj) + { + if (!json::has(obj, L"ignored_shortcuts", json::JsonValueType::Array)) + { + obj.SetNamedValue(L"ignored_shortcuts", create_empty_shortcut_array_value()); + } + } + + json::JsonObject create_default_ignored_conflict_properties() + { + json::JsonObject obj; + ensure_ignored_conflict_properties_shape(obj); + return obj; + } + + DashboardSortOrder parse_dashboard_sort_order(const json::JsonObject& obj, DashboardSortOrder fallback) + { + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::Number)) + { + const auto raw_value = static_cast<int>(obj.GetNamedNumber(L"dashboard_sort_order", static_cast<double>(static_cast<int>(fallback)))); + return raw_value == static_cast<int>(DashboardSortOrder::ByStatus) ? DashboardSortOrder::ByStatus : DashboardSortOrder::Alphabetical; + } + + if (json::has(obj, L"dashboard_sort_order", json::JsonValueType::String)) + { + const auto raw = obj.GetNamedString(L"dashboard_sort_order"); + if (raw == L"ByStatus") + { + return DashboardSortOrder::ByStatus; + } + + if (raw == L"Alphabetical") + { + return DashboardSortOrder::Alphabetical; + } + } + + return fallback; + } +} + // TODO: would be nice to get rid of these globals, since they're basically cached json settings static std::wstring settings_theme = L"system"; +static bool show_tray_icon = true; +static bool show_theme_adaptive_tray_icon = false; static bool run_as_elevated = false; static bool show_new_updates_toast_notification = true; static bool download_updates_automatically = true; static bool show_whats_new_after_updates = true; static bool enable_experimentation = true; static bool enable_warnings_elevated_apps = true; +static bool enable_quick_access = true; +static PowerToysSettings::HotkeyObject quick_access_shortcut; +static DashboardSortOrder dashboard_sort_order = DashboardSortOrder::Alphabetical; +static json::JsonObject ignored_conflict_properties = create_default_ignored_conflict_properties(); json::JsonObject GeneralSettings::to_json() { json::JsonObject result; + auto ignoredProps = ignoredConflictProperties; + ensure_ignored_conflict_properties_shape(ignoredProps); + result.SetNamedValue(L"startup", json::value(isStartupEnabled)); if (!startupDisabledReason.empty()) { @@ -38,17 +99,23 @@ json::JsonObject GeneralSettings::to_json() } result.SetNamedValue(L"enabled", std::move(enabled)); + result.SetNamedValue(L"show_tray_icon", json::value(showSystemTrayIcon)); + result.SetNamedValue(L"show_theme_adaptive_tray_icon", json::value(showThemeAdaptiveTrayIcon)); result.SetNamedValue(L"is_elevated", json::value(isElevated)); result.SetNamedValue(L"run_elevated", json::value(isRunElevated)); result.SetNamedValue(L"show_new_updates_toast_notification", json::value(showNewUpdatesToastNotification)); result.SetNamedValue(L"download_updates_automatically", json::value(downloadUpdatesAutomatically)); result.SetNamedValue(L"show_whats_new_after_updates", json::value(showWhatsNewAfterUpdates)); result.SetNamedValue(L"enable_experimentation", json::value(enableExperimentation)); + result.SetNamedValue(L"dashboard_sort_order", json::value(static_cast<int>(dashboardSortOrder))); result.SetNamedValue(L"is_admin", json::value(isAdmin)); result.SetNamedValue(L"enable_warnings_elevated_apps", json::value(enableWarningsElevatedApps)); + result.SetNamedValue(L"enable_quick_access", json::value(enableQuickAccess)); + result.SetNamedValue(L"quick_access_shortcut", quickAccessShortcut.get_json()); result.SetNamedValue(L"theme", json::value(theme)); result.SetNamedValue(L"system_theme", json::value(systemTheme)); result.SetNamedValue(L"powertoys_version", json::value(powerToysVersion)); + result.SetNamedValue(L"ignored_conflict_properties", json::value(ignoredProps)); return result; } @@ -61,12 +128,31 @@ json::JsonObject load_general_settings() { settings_theme = L"system"; } + show_tray_icon = loaded.GetNamedBoolean(L"show_tray_icon", true); + show_theme_adaptive_tray_icon = loaded.GetNamedBoolean(L"show_theme_adaptive_tray_icon", false); run_as_elevated = loaded.GetNamedBoolean(L"run_elevated", false); show_new_updates_toast_notification = loaded.GetNamedBoolean(L"show_new_updates_toast_notification", true); download_updates_automatically = loaded.GetNamedBoolean(L"download_updates_automatically", true) && check_user_is_admin(); show_whats_new_after_updates = loaded.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = loaded.GetNamedBoolean(L"enable_experimentation", true); enable_warnings_elevated_apps = loaded.GetNamedBoolean(L"enable_warnings_elevated_apps", true); + enable_quick_access = loaded.GetNamedBoolean(L"enable_quick_access", true); + if (json::has(loaded, L"quick_access_shortcut", json::JsonValueType::Object)) + { + quick_access_shortcut = PowerToysSettings::HotkeyObject::from_json(loaded.GetNamedObject(L"quick_access_shortcut")); + } + dashboard_sort_order = parse_dashboard_sort_order(loaded, dashboard_sort_order); + + if (json::has(loaded, L"ignored_conflict_properties", json::JsonValueType::Object)) + { + ignored_conflict_properties = loaded.GetNamedObject(L"ignored_conflict_properties"); + } + else + { + ignored_conflict_properties = create_default_ignored_conflict_properties(); + } + + ensure_ignored_conflict_properties_shape(ignored_conflict_properties); return loaded; } @@ -74,20 +160,29 @@ json::JsonObject load_general_settings() GeneralSettings get_general_settings() { const bool is_user_admin = check_user_is_admin(); - GeneralSettings settings{ + GeneralSettings settings + { + .showSystemTrayIcon = show_tray_icon, + .showThemeAdaptiveTrayIcon = show_theme_adaptive_tray_icon, .isElevated = is_process_elevated(), .isRunElevated = run_as_elevated, .isAdmin = is_user_admin, .enableWarningsElevatedApps = enable_warnings_elevated_apps, + .enableQuickAccess = enable_quick_access, + .quickAccessShortcut = quick_access_shortcut, .showNewUpdatesToastNotification = show_new_updates_toast_notification, .downloadUpdatesAutomatically = download_updates_automatically && is_user_admin, .showWhatsNewAfterUpdates = show_whats_new_after_updates, .enableExperimentation = enable_experimentation, + .dashboardSortOrder = dashboard_sort_order, .theme = settings_theme, .systemTheme = WindowsColors::is_dark_mode() ? L"dark" : L"light", - .powerToysVersion = get_product_version() + .powerToysVersion = get_product_version(), + .ignoredConflictProperties = ignored_conflict_properties }; + ensure_ignored_conflict_properties_shape(settings.ignoredConflictProperties); + settings.isStartupEnabled = is_auto_start_task_active_for_this_user(); for (auto& [name, powertoy] : modules()) @@ -98,19 +193,152 @@ GeneralSettings get_general_settings() return settings; } +void apply_module_status_update(const json::JsonObject& module_config, bool save) +{ + Logger::info(L"apply_module_status_update: {}", std::wstring{ module_config.ToString() }); + + // Expected format: {"ModuleName": true/false} - only one module per update + auto iter = module_config.First(); + if (!iter.HasCurrent()) + { + Logger::warn(L"apply_module_status_update: Empty module config"); + return; + } + + const auto& element = iter.Current(); + const auto value = element.Value(); + if (value.ValueType() != json::JsonValueType::Boolean) + { + Logger::warn(L"apply_module_status_update: Invalid value type for module status"); + return; + } + + const std::wstring name{ element.Key().c_str() }; + if (modules().find(name) == modules().end()) + { + Logger::warn(L"apply_module_status_update: Module {} not found", name); + return; + } + + PowertoyModule& powertoy = modules().at(name); + const bool module_inst_enabled = powertoy->is_enabled(); + bool target_enabled = value.GetBoolean(); + + auto gpo_rule = powertoy->gpo_policy_enabled_configuration(); + if (gpo_rule == powertoys_gpo::gpo_rule_configured_enabled || gpo_rule == powertoys_gpo::gpo_rule_configured_disabled) + { + // Apply the GPO Rule. + target_enabled = gpo_rule == powertoys_gpo::gpo_rule_configured_enabled; + } + + if (module_inst_enabled == target_enabled) + { + Logger::info(L"apply_module_status_update: Module {} already in target state {}", name, target_enabled); + return; + } + + if (target_enabled) + { + Logger::info(L"apply_module_status_update: Enabling powertoy {}", name); + powertoy->enable(); + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + hkmng.EnableHotkeyByModule(name); + + // Trigger AI capability detection when ImageResizer is enabled + if (name == L"Image Resizer") + { + Logger::info(L"ImageResizer enabled, triggering AI capability detection"); + DetectAiCapabilitiesAsync(true); // Skip settings check since we know it's being enabled + } + } + else + { + Logger::info(L"apply_module_status_update: Disabling powertoy {}", name); + powertoy->disable(); + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + hkmng.DisableHotkeyByModule(name); + } + // Sync the hotkey state with the module state, so it can be removed for disabled modules. + powertoy.UpdateHotkeyEx(); + + if (save) + { + // Load existing settings and only update the specific module's enabled state + json::JsonObject current_settings = PTSettingsHelper::load_general_settings(); + + json::JsonObject enabled; + if (current_settings.HasKey(L"enabled")) + { + enabled = current_settings.GetNamedObject(L"enabled"); + } + + // Check if the saved state is different from the requested state + bool current_saved = enabled.HasKey(name) ? enabled.GetNamedBoolean(name, true) : true; + + if (current_saved != target_enabled) + { + // Update only this module's enabled state + enabled.SetNamedValue(name, json::value(target_enabled)); + current_settings.SetNamedValue(L"enabled", enabled); + + PTSettingsHelper::save_general_settings(current_settings); + + GeneralSettings settings_for_trace = get_general_settings(); + Trace::SettingsChanged(settings_for_trace); + } + } +} + void apply_general_settings(const json::JsonObject& general_configs, bool save) { + std::wstring old_settings_json_string; + if (save) + { + old_settings_json_string = get_general_settings().to_json().Stringify().c_str(); + } + Logger::info(L"apply_general_settings: {}", std::wstring{ general_configs.ToString() }); run_as_elevated = general_configs.GetNamedBoolean(L"run_elevated", false); enable_warnings_elevated_apps = general_configs.GetNamedBoolean(L"enable_warnings_elevated_apps", true); + bool new_enable_quick_access = general_configs.GetNamedBoolean(L"enable_quick_access", true); + Logger::info(L"apply_general_settings: enable_quick_access={}, new_enable_quick_access={}", enable_quick_access, new_enable_quick_access); + + PowerToysSettings::HotkeyObject new_quick_access_shortcut; + if (json::has(general_configs, L"quick_access_shortcut", json::JsonValueType::Object)) + { + new_quick_access_shortcut = PowerToysSettings::HotkeyObject::from_json(general_configs.GetNamedObject(L"quick_access_shortcut")); + } + + auto hotkey_equals = [](const PowerToysSettings::HotkeyObject& a, const PowerToysSettings::HotkeyObject& b) { + return a.get_code() == b.get_code() && + a.get_modifiers() == b.get_modifiers(); + }; + + if (enable_quick_access != new_enable_quick_access || !hotkey_equals(quick_access_shortcut, new_quick_access_shortcut)) + { + enable_quick_access = new_enable_quick_access; + quick_access_shortcut = new_quick_access_shortcut; + + if (enable_quick_access) + { + QuickAccessHost::start(); + } + else + { + QuickAccessHost::stop(); + } + update_quick_access_hotkey(enable_quick_access, quick_access_shortcut); + } + show_new_updates_toast_notification = general_configs.GetNamedBoolean(L"show_new_updates_toast_notification", true); download_updates_automatically = general_configs.GetNamedBoolean(L"download_updates_automatically", true); show_whats_new_after_updates = general_configs.GetNamedBoolean(L"show_whats_new_after_updates", true); enable_experimentation = general_configs.GetNamedBoolean(L"enable_experimentation", true); + dashboard_sort_order = parse_dashboard_sort_order(general_configs, dashboard_sort_order); // apply_general_settings is called by the runner's WinMain, so we can just force the run at startup gpo rule here. auto gpo_run_as_startup = powertoys_gpo::getConfiguredRunAtStartupValue(); @@ -159,7 +387,8 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) else { delete_auto_start_task_for_this_user(); - if (gpo_run_as_startup == powertoys_gpo::gpo_rule_configured_enabled || gpo_run_as_startup == powertoys_gpo::gpo_rule_configured_not_configured) { + if (gpo_run_as_startup == powertoys_gpo::gpo_rule_configured_enabled || gpo_run_as_startup == powertoys_gpo::gpo_rule_configured_not_configured) + { create_auto_start_task_for_this_user(run_as_elevated); } } @@ -198,11 +427,22 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) { Logger::info(L"apply_general_settings: Enabling powertoy {}", name); powertoy->enable(); + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + hkmng.EnableHotkeyByModule(name); + + // Trigger AI capability detection when ImageResizer is enabled + if (name == L"Image Resizer") + { + Logger::info(L"ImageResizer enabled, triggering AI capability detection"); + DetectAiCapabilitiesAsync(true); // Skip settings check since we know it's being enabled + } } else { Logger::info(L"apply_general_settings: Disabling powertoy {}", name); powertoy->disable(); + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + hkmng.DisableHotkeyByModule(name); } // Sync the hotkey state with the module state, so it can be removed for disabled modules. powertoy.UpdateHotkeyEx(); @@ -214,11 +454,47 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save) settings_theme = general_configs.GetNamedString(L"theme"); } + if (json::has(general_configs, L"show_tray_icon", json::JsonValueType::Boolean)) + { + show_tray_icon = general_configs.GetNamedBoolean(L"show_tray_icon"); + set_tray_icon_visible(show_tray_icon); + } + + if (json::has(general_configs, L"show_theme_adaptive_tray_icon", json::JsonValueType::Boolean)) + { + bool new_theme_adaptive = general_configs.GetNamedBoolean(L"show_theme_adaptive_tray_icon"); + Logger::info(L"apply_general_settings: show_theme_adaptive_tray_icon current={}, new={}", + show_theme_adaptive_tray_icon, new_theme_adaptive); + if (show_theme_adaptive_tray_icon != new_theme_adaptive) + { + show_theme_adaptive_tray_icon = new_theme_adaptive; + set_tray_icon_theme_adaptive(show_theme_adaptive_tray_icon); + } + else + { + Logger::info(L"apply_general_settings: show_theme_adaptive_tray_icon unchanged, skipping update"); + } + } + else + { + Logger::warn(L"apply_general_settings: show_theme_adaptive_tray_icon not found in config"); + } + + if (json::has(general_configs, L"ignored_conflict_properties", json::JsonValueType::Object)) + { + ignored_conflict_properties = general_configs.GetNamedObject(L"ignored_conflict_properties"); + ensure_ignored_conflict_properties_shape(ignored_conflict_properties); + } + if (save) { GeneralSettings save_settings = get_general_settings(); - PTSettingsHelper::save_general_settings(save_settings.to_json()); - Trace::SettingsChanged(save_settings); + std::wstring new_settings_json_string = save_settings.to_json().Stringify().c_str(); + if (old_settings_json_string != new_settings_json_string) + { + PTSettingsHelper::save_general_settings(save_settings.to_json()); + Trace::SettingsChanged(save_settings); + } } } @@ -302,7 +578,11 @@ void start_enabled_powertoys() { Logger::info(L"start_enabled_powertoys: Enabling powertoy {}", name); powertoy->enable(); + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + hkmng.EnableHotkeyByModule(name); powertoy.UpdateHotkeyEx(); } } } + + diff --git a/src/runner/general_settings.h b/src/runner/general_settings.h index f56271cf07..487e3216da 100644 --- a/src/runner/general_settings.h +++ b/src/runner/general_settings.h @@ -1,23 +1,36 @@ #pragma once #include <common/utils/json.h> +#include <common/SettingsAPI/settings_objects.h> + +enum class DashboardSortOrder +{ + Alphabetical = 0, + ByStatus = 1, +}; struct GeneralSettings { bool isStartupEnabled; + bool showSystemTrayIcon; + bool showThemeAdaptiveTrayIcon; std::wstring startupDisabledReason; std::map<std::wstring, bool> isModulesEnabledMap; bool isElevated; bool isRunElevated; bool isAdmin; bool enableWarningsElevatedApps; + bool enableQuickAccess; + PowerToysSettings::HotkeyObject quickAccessShortcut; bool showNewUpdatesToastNotification; bool downloadUpdatesAutomatically; bool showWhatsNewAfterUpdates; bool enableExperimentation; + DashboardSortOrder dashboardSortOrder; std::wstring theme; std::wstring systemTheme; std::wstring powerToysVersion; + json::JsonObject ignoredConflictProperties; json::JsonObject to_json(); }; @@ -25,4 +38,5 @@ struct GeneralSettings json::JsonObject load_general_settings(); GeneralSettings get_general_settings(); void apply_general_settings(const json::JsonObject& general_configs, bool save = true); +void apply_module_status_update(const json::JsonObject& module_config, bool save = true); void start_enabled_powertoys(); \ No newline at end of file diff --git a/src/runner/hotkey_conflict_detector.cpp b/src/runner/hotkey_conflict_detector.cpp new file mode 100644 index 0000000000..14c8a1ecd9 --- /dev/null +++ b/src/runner/hotkey_conflict_detector.cpp @@ -0,0 +1,471 @@ +#include "pch.h" +#include "hotkey_conflict_detector.h" +#include <common/SettingsAPI/settings_helpers.h> +#include <windows.h> +#include <unordered_map> +#include <cwchar> + +namespace HotkeyConflictDetector +{ + Hotkey ShortcutToHotkey(const CentralizedHotkeys::Shortcut& shortcut) + { + Hotkey hotkey; + + hotkey.win = (shortcut.modifiersMask & MOD_WIN) != 0; + hotkey.ctrl = (shortcut.modifiersMask & MOD_CONTROL) != 0; + hotkey.shift = (shortcut.modifiersMask & MOD_SHIFT) != 0; + hotkey.alt = (shortcut.modifiersMask & MOD_ALT) != 0; + + hotkey.key = shortcut.vkCode > 255 ? 0 : static_cast<unsigned char>(shortcut.vkCode); + + return hotkey; + } + + HotkeyConflictManager* HotkeyConflictManager::instance = nullptr; + std::mutex HotkeyConflictManager::instanceMutex; + + HotkeyConflictManager& HotkeyConflictManager::GetInstance() + { + std::lock_guard<std::mutex> lock(instanceMutex); + if (instance == nullptr) + { + instance = new HotkeyConflictManager(); + } + return *instance; + } + + HotkeyConflictType HotkeyConflictManager::HasConflict(Hotkey const& _hotkey, const wchar_t* _moduleName, const int _hotkeyID) + { + if (disabledHotkeys.find(_moduleName) != disabledHotkeys.end()) + { + return HotkeyConflictType::NoConflict; + } + + uint16_t handle = GetHotkeyHandle(_hotkey); + + if (handle == 0) + { + return HotkeyConflictType::NoConflict; + } + + // The order is important, first to check sys conflict and then inapp conflict + if (sysConflictHotkeyMap.find(handle) != sysConflictHotkeyMap.end()) + { + return HotkeyConflictType::SystemConflict; + } + + if (inAppConflictHotkeyMap.find(handle) != inAppConflictHotkeyMap.end()) + { + return HotkeyConflictType::InAppConflict; + } + + auto it = hotkeyMap.find(handle); + + if (it == hotkeyMap.end()) + { + return HasConflictWithSystemHotkey(_hotkey) ? + HotkeyConflictType::SystemConflict : + HotkeyConflictType::NoConflict; + } + + if (wcscmp(it->second.moduleName.c_str(), _moduleName) == 0 && it->second.hotkeyID == _hotkeyID) + { + // A shortcut matching its own assignment is not considered a conflict. + return HotkeyConflictType::NoConflict; + } + + return HotkeyConflictType::InAppConflict; + } + + HotkeyConflictType HotkeyConflictManager::HasConflict(Hotkey const& _hotkey) + { + uint16_t handle = GetHotkeyHandle(_hotkey); + + if (handle == 0) + { + return HotkeyConflictType::NoConflict; + } + + // The order is important, first to check sys conflict and then inapp conflict + if (sysConflictHotkeyMap.find(handle) != sysConflictHotkeyMap.end()) + { + return HotkeyConflictType::SystemConflict; + } + + if (inAppConflictHotkeyMap.find(handle) != inAppConflictHotkeyMap.end()) + { + return HotkeyConflictType::InAppConflict; + } + + auto it = hotkeyMap.find(handle); + + if (it == hotkeyMap.end()) + { + return HasConflictWithSystemHotkey(_hotkey) ? + HotkeyConflictType::SystemConflict : + HotkeyConflictType::NoConflict; + } + + return HotkeyConflictType::InAppConflict; + } + + // This function should only be called when a conflict has already been identified. + // It returns a list of all conflicting shortcuts. + std::vector<HotkeyConflictInfo> HotkeyConflictManager::GetAllConflicts(Hotkey const& _hotkey) + { + std::vector<HotkeyConflictInfo> conflicts; + uint16_t handle = GetHotkeyHandle(_hotkey); + + // Check in-app conflicts first + auto inAppIt = inAppConflictHotkeyMap.find(handle); + if (inAppIt != inAppConflictHotkeyMap.end()) + { + // Add all in-app conflicts + for (const auto& conflict : inAppIt->second) + { + conflicts.push_back(conflict); + } + + return conflicts; + } + + // Check system conflicts + auto sysIt = sysConflictHotkeyMap.find(handle); + if (sysIt != sysConflictHotkeyMap.end()) + { + HotkeyConflictInfo systemConflict; + systemConflict.hotkey = _hotkey; + systemConflict.moduleName = L"System"; + systemConflict.hotkeyID = 0; + + conflicts.push_back(systemConflict); + + return conflicts; + } + + // Check if there's a successfully registered hotkey that would conflict + auto registeredIt = hotkeyMap.find(handle); + if (registeredIt != hotkeyMap.end()) + { + conflicts.push_back(registeredIt->second); + + return conflicts; + } + + // If all the above conditions are ruled out, a system-level conflict is the only remaining explanation. + HotkeyConflictInfo systemConflict; + systemConflict.hotkey = _hotkey; + systemConflict.moduleName = L"System"; + systemConflict.hotkeyID = 0; + conflicts.push_back(systemConflict); + + return conflicts; + } + + bool HotkeyConflictManager::AddHotkey(Hotkey const& _hotkey, const wchar_t* _moduleName, const int _hotkeyID, bool isEnabled) + { + if (!isEnabled) + { + disabledHotkeys[_moduleName].push_back({ _hotkey, _moduleName, _hotkeyID }); + return true; + } + + uint16_t handle = GetHotkeyHandle(_hotkey); + + if (handle == 0) + { + return false; + } + + HotkeyConflictType conflictType = HasConflict(_hotkey, _moduleName, _hotkeyID); + if (conflictType != HotkeyConflictType::NoConflict) + { + if (conflictType == HotkeyConflictType::InAppConflict) + { + auto hotkeyFound = hotkeyMap.find(handle); + inAppConflictHotkeyMap[handle].insert({ _hotkey, _moduleName, _hotkeyID }); + + if (hotkeyFound != hotkeyMap.end()) + { + inAppConflictHotkeyMap[handle].insert(hotkeyFound->second); + hotkeyMap.erase(hotkeyFound); + } + } + else + { + sysConflictHotkeyMap[handle].insert({ _hotkey, _moduleName, _hotkeyID }); + } + return false; + } + + HotkeyConflictInfo hotkeyInfo; + hotkeyInfo.moduleName = _moduleName; + hotkeyInfo.hotkeyID = _hotkeyID; + hotkeyInfo.hotkey = _hotkey; + hotkeyMap[handle] = hotkeyInfo; + + return true; + } + + std::vector<HotkeyConflictInfo> HotkeyConflictManager::RemoveHotkeyByModule(const std::wstring& moduleName) + { + std::vector<HotkeyConflictInfo> removedHotkeys; + + if (disabledHotkeys.find(moduleName) != disabledHotkeys.end()) + { + disabledHotkeys.erase(moduleName); + } + + std::lock_guard<std::mutex> lock(hotkeyMutex); + bool foundRecord = false; + + for (auto it = sysConflictHotkeyMap.begin(); it != sysConflictHotkeyMap.end();) + { + auto& conflictSet = it->second; + for (auto setIt = conflictSet.begin(); setIt != conflictSet.end();) + { + if (setIt->moduleName == moduleName) + { + removedHotkeys.push_back(*setIt); + setIt = conflictSet.erase(setIt); + foundRecord = true; + } + else + { + ++setIt; + } + } + if (conflictSet.empty()) + { + it = sysConflictHotkeyMap.erase(it); + } + else + { + ++it; + } + } + + for (auto it = inAppConflictHotkeyMap.begin(); it != inAppConflictHotkeyMap.end();) + { + auto& conflictSet = it->second; + uint16_t handle = it->first; + + for (auto setIt = conflictSet.begin(); setIt != conflictSet.end();) + { + if (setIt->moduleName == moduleName) + { + removedHotkeys.push_back(*setIt); + setIt = conflictSet.erase(setIt); + foundRecord = true; + } + else + { + ++setIt; + } + } + + if (conflictSet.empty()) + { + it = inAppConflictHotkeyMap.erase(it); + } + else if (conflictSet.size() == 1) + { + // Move the only remaining conflict to main map + const auto& onlyConflict = *conflictSet.begin(); + hotkeyMap[handle] = onlyConflict; + it = inAppConflictHotkeyMap.erase(it); + } + else + { + ++it; + } + } + + for (auto it = hotkeyMap.begin(); it != hotkeyMap.end();) + { + if (it->second.moduleName == moduleName) + { + uint16_t handle = it->first; + removedHotkeys.push_back(it->second); + it = hotkeyMap.erase(it); + foundRecord = true; + + auto inAppIt = inAppConflictHotkeyMap.find(handle); + if (inAppIt != inAppConflictHotkeyMap.end() && inAppIt->second.size() == 1) + { + // Move the only in-app conflict to main map + const auto& onlyConflict = *inAppIt->second.begin(); + hotkeyMap[handle] = onlyConflict; + inAppConflictHotkeyMap.erase(inAppIt); + } + } + else + { + ++it; + } + } + + return removedHotkeys; + } + + void HotkeyConflictManager::EnableHotkeyByModule(const std::wstring& moduleName) + { + if (disabledHotkeys.find(moduleName) == disabledHotkeys.end()) + { + return; // No disabled hotkeys for this module + } + + auto hotkeys = disabledHotkeys[moduleName]; + disabledHotkeys.erase(moduleName); + + for (const auto& hotkeyInfo : hotkeys) + { + // Re-add the hotkey as enabled + AddHotkey(hotkeyInfo.hotkey, moduleName.c_str(), hotkeyInfo.hotkeyID, true); + } + } + + void HotkeyConflictManager::DisableHotkeyByModule(const std::wstring& moduleName) + { + auto hotkeys = RemoveHotkeyByModule(moduleName); + disabledHotkeys[moduleName] = hotkeys; + } + + bool HotkeyConflictManager::HasConflictWithSystemHotkey(const Hotkey& hotkey) + { + // Convert PowerToys Hotkey format to Win32 RegisterHotKey format + UINT modifiers = 0; + if (hotkey.win) + { + modifiers |= MOD_WIN; + } + if (hotkey.ctrl) + { + modifiers |= MOD_CONTROL; + } + if (hotkey.alt) + { + modifiers |= MOD_ALT; + } + if (hotkey.shift) + { + modifiers |= MOD_SHIFT; + } + + // No modifiers or no key is not a valid hotkey + if (modifiers == 0 || hotkey.key == 0) + { + return false; + } + + // Use a unique ID for this test registration + const int hotkeyId = 0x0FFF; // Arbitrary ID for temporary registration + + // Try to register the hotkey with Windows, using nullptr instead of a window handle + if (!RegisterHotKey(nullptr, hotkeyId, modifiers, hotkey.key)) + { + // If registration fails with ERROR_HOTKEY_ALREADY_REGISTERED, it means the hotkey + // is already in use by the system or another application + if (GetLastError() == ERROR_HOTKEY_ALREADY_REGISTERED) + { + return true; + } + } + else + { + // If registration succeeds, unregister it immediately + UnregisterHotKey(nullptr, hotkeyId); + } + + return false; + } + + json::JsonObject HotkeyConflictManager::GetHotkeyConflictsAsJson() + { + std::lock_guard<std::mutex> lock(hotkeyMutex); + + using namespace json; + JsonObject root; + + // Serialize hotkey to a unique string format for grouping + auto serializeHotkey = [](const Hotkey& hotkey) -> JsonObject { + JsonObject obj; + obj.Insert(L"win", value(hotkey.win)); + obj.Insert(L"ctrl", value(hotkey.ctrl)); + obj.Insert(L"shift", value(hotkey.shift)); + obj.Insert(L"alt", value(hotkey.alt)); + obj.Insert(L"key", value(static_cast<int>(hotkey.key))); + return obj; + }; + + // New format: Group conflicts by hotkey + JsonArray inAppConflictsArray; + JsonArray sysConflictsArray; + + // Process in-app conflicts - only include hotkeys that are actually in conflict + for (const auto& [handle, conflicts] : inAppConflictHotkeyMap) + { + if (!conflicts.empty()) + { + JsonObject conflictGroup; + + // All entries have the same hotkey, so use the first one for the key + conflictGroup.Insert(L"hotkey", serializeHotkey(conflicts.begin()->hotkey)); + + // Create an array of module info without repeating the hotkey + JsonArray modules; + for (const auto& info : conflicts) + { + JsonObject moduleInfo; + moduleInfo.Insert(L"moduleName", value(info.moduleName)); + moduleInfo.Insert(L"hotkeyID", value(info.hotkeyID)); + modules.Append(moduleInfo); + } + + conflictGroup.Insert(L"modules", modules); + inAppConflictsArray.Append(conflictGroup); + } + } + + // Process system conflicts - only include hotkeys that are actually in conflict + for (const auto& [handle, conflicts] : sysConflictHotkeyMap) + { + if (!conflicts.empty()) + { + JsonObject conflictGroup; + + // All entries have the same hotkey, so use the first one for the key + conflictGroup.Insert(L"hotkey", serializeHotkey(conflicts.begin()->hotkey)); + + // Create an array of module info without repeating the hotkey + JsonArray modules; + for (const auto& info : conflicts) + { + JsonObject moduleInfo; + moduleInfo.Insert(L"moduleName", value(info.moduleName)); + moduleInfo.Insert(L"hotkeyID", value(info.hotkeyID)); + modules.Append(moduleInfo); + } + + conflictGroup.Insert(L"modules", modules); + sysConflictsArray.Append(conflictGroup); + } + } + + // Add the grouped conflicts to the root object + root.Insert(L"inAppConflicts", inAppConflictsArray); + root.Insert(L"sysConflicts", sysConflictsArray); + + return root; + } + + uint16_t HotkeyConflictManager::GetHotkeyHandle(const Hotkey& hotkey) + { + uint16_t handle = hotkey.key; + handle |= hotkey.win << 8; + handle |= hotkey.ctrl << 9; + handle |= hotkey.shift << 10; + handle |= hotkey.alt << 11; + return handle; + } +} \ No newline at end of file diff --git a/src/runner/hotkey_conflict_detector.h b/src/runner/hotkey_conflict_detector.h new file mode 100644 index 0000000000..c32954e3e4 --- /dev/null +++ b/src/runner/hotkey_conflict_detector.h @@ -0,0 +1,100 @@ +#pragma once +#include "pch.h" +#include <unordered_map> +#include <unordered_set> +#include <string> + +#include "../modules/interface/powertoy_module_interface.h" +#include "centralized_hotkeys.h" +#include "common/utils/json.h" + +namespace HotkeyConflictDetector +{ + using Hotkey = PowertoyModuleIface::Hotkey; + using HotkeyEx = PowertoyModuleIface::HotkeyEx; + using Shortcut = CentralizedHotkeys::Shortcut; + + struct HotkeyConflictInfo + { + Hotkey hotkey; + std::wstring moduleName; + int hotkeyID = 0; + + inline bool operator==(const HotkeyConflictInfo& other) const + { + return hotkey == other.hotkey && + moduleName == other.moduleName && + hotkeyID == other.hotkeyID; + } + }; + + Hotkey ShortcutToHotkey(const CentralizedHotkeys::Shortcut& shortcut); + + enum HotkeyConflictType + { + NoConflict = 0, + SystemConflict = 1, + InAppConflict = 2, + }; + + class HotkeyConflictManager + { + public: + static HotkeyConflictManager& GetInstance(); + + HotkeyConflictType HasConflict(const Hotkey& hotkey, const wchar_t* moduleName, const int hotkeyID); + HotkeyConflictType HotkeyConflictManager::HasConflict(Hotkey const& _hotkey); + std::vector<HotkeyConflictInfo> HotkeyConflictManager::GetAllConflicts(Hotkey const& hotkey); + bool AddHotkey(const Hotkey& hotkey, const wchar_t* moduleName, const int hotkeyID, bool isEnabled); + std::vector<HotkeyConflictInfo> RemoveHotkeyByModule(const std::wstring& moduleName); + + void EnableHotkeyByModule(const std::wstring& moduleName); + void DisableHotkeyByModule(const std::wstring& moduleName); + + json::JsonObject GetHotkeyConflictsAsJson(); + + private: + static std::mutex instanceMutex; + static HotkeyConflictManager* instance; + + std::mutex hotkeyMutex; + // Hotkey in hotkeyMap means the hotkey has been registered successfully + std::unordered_map<uint16_t, HotkeyConflictInfo> hotkeyMap; + // Hotkey in sysConflictHotkeyMap means the hotkey has conflict with system defined hotkeys + std::unordered_map<uint16_t, std::unordered_set<HotkeyConflictInfo>> sysConflictHotkeyMap; + // Hotkey in inAppConflictHotkeyMap means the hotkey has conflict with other modules + std::unordered_map<uint16_t, std::unordered_set<HotkeyConflictInfo>> inAppConflictHotkeyMap; + + std::unordered_map<std::wstring, std::vector<HotkeyConflictInfo>> disabledHotkeys; + + uint16_t GetHotkeyHandle(const Hotkey&); + bool HasConflictWithSystemHotkey(const Hotkey&); + + HotkeyConflictManager() = default; + }; +}; + +namespace std +{ + template<> + struct hash<HotkeyConflictDetector::HotkeyConflictInfo> + { + size_t operator()(const HotkeyConflictDetector::HotkeyConflictInfo& info) const + { + + size_t hotkeyHash = + (info.hotkey.win ? 1ULL : 0ULL) | + ((info.hotkey.ctrl ? 1ULL : 0ULL) << 1) | + ((info.hotkey.shift ? 1ULL : 0ULL) << 2) | + ((info.hotkey.alt ? 1ULL : 0ULL) << 3) | + (static_cast<size_t>(info.hotkey.key) << 4); + + size_t moduleHash = std::hash<std::wstring>{}(info.moduleName); + size_t idHash = std::hash<int>{}(info.hotkeyID); + + return hotkeyHash ^ + ((moduleHash << 1) | (moduleHash >> (sizeof(size_t) * 8 - 1))) ^ // rotate left 1 bit + ((idHash << 2) | (idHash >> (sizeof(size_t) * 8 - 2))); // rotate left 2 bits + } + }; +} diff --git a/src/runner/main.cpp b/src/runner/main.cpp index e9ea4e59d9..973cee4ba5 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -34,8 +34,12 @@ #include <Psapi.h> #include <RestartManager.h> +#include <shellapi.h> #include "centralized_kb_hook.h" #include "centralized_hotkeys.h" +#include "quick_access_host.h" +#include "ai_detection.h" +#include <common/utils/package.h> #if _DEBUG && _WIN64 #include "unhandled_exception_handler.h" @@ -76,6 +80,87 @@ void chdir_current_executable() } } +// Detect AI capabilities by calling ImageResizer in detection mode. +// This runs in a background thread to avoid blocking the main startup. +// ImageResizer writes the result to a cache file that it reads on normal startup. +void DetectAiCapabilitiesAsync(bool skipSettingsCheck) +{ + std::thread([skipSettingsCheck]() { + try + { + // Check if ImageResizer module is enabled (skip if called from apply_general_settings) + if (!skipSettingsCheck) + { + auto settings = PTSettingsHelper::load_general_settings(); + if (json::has(settings, L"enabled", json::JsonValueType::Object)) + { + auto enabledModules = settings.GetNamedObject(L"enabled"); + if (json::has(enabledModules, L"Image Resizer", json::JsonValueType::Boolean)) + { + bool isEnabled = enabledModules.GetNamedBoolean(L"Image Resizer", false); + if (!isEnabled) + { + Logger::info(L"ImageResizer module is disabled, skipping AI detection"); + return; + } + } + } + } + + // Get ImageResizer.exe path (located in WinUI3Apps folder) + std::wstring imageResizerPath = get_module_folderpath(); + imageResizerPath += L"\\WinUI3Apps\\PowerToys.ImageResizer.exe"; + + if (!std::filesystem::exists(imageResizerPath)) + { + Logger::warn(L"ImageResizer.exe not found at {}, skipping AI detection", imageResizerPath); + return; + } + + Logger::info(L"Starting AI capability detection via ImageResizer"); + + // Call ImageResizer --detect-ai + SHELLEXECUTEINFO sei = { sizeof(sei) }; + sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI; + sei.lpFile = imageResizerPath.c_str(); + sei.lpParameters = L"--detect-ai"; + sei.nShow = SW_HIDE; + + if (ShellExecuteExW(&sei)) + { + // Wait for detection to complete (with timeout) + DWORD waitResult = WaitForSingleObject(sei.hProcess, 30000); // 30 second timeout + CloseHandle(sei.hProcess); + + if (waitResult == WAIT_OBJECT_0) + { + Logger::info(L"AI capability detection completed successfully"); + } + else if (waitResult == WAIT_TIMEOUT) + { + Logger::warn(L"AI capability detection timed out"); + } + else + { + Logger::warn(L"AI capability detection wait failed"); + } + } + else + { + Logger::warn(L"Failed to launch ImageResizer for AI detection, error: {}", GetLastError()); + } + } + catch (const std::exception& e) + { + Logger::error("Exception during AI capability detection: {}", e.what()); + } + catch (...) + { + Logger::error("Unknown exception during AI capability detection"); + } + }).detach(); +} + inline wil::unique_mutex_nothrow create_msi_mutex() { return createAppMutex(POWERTOYS_MSI_MUTEX_NAME); @@ -90,6 +175,7 @@ void open_menu_from_another_instance(std::optional<std::string> settings_window) msg = static_cast<LPARAM>(ESettingsWindowNames_from_string(settings_window.value())); } PostMessageW(hwnd_main, WM_COMMAND, ID_SETTINGS_MENU_COMMAND, msg); + SetForegroundWindow(hwnd_main); // Bring the settings window to the front } int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow, bool openOobe, bool openScoobe, bool showRestartNotificationAfterUpdate) @@ -103,7 +189,18 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow //init_global_error_handlers(); #endif Trace::RegisterProvider(); - start_tray_icon(isProcessElevated); + + // Load settings from file before reading them + load_general_settings(); + auto const settings = get_general_settings(); + start_tray_icon(isProcessElevated, settings.showThemeAdaptiveTrayIcon); + + if (settings.enableQuickAccess) + { + QuickAccessHost::start(); + } + update_quick_access_hotkey(settings.enableQuickAccess, settings.quickAccessShortcut); + set_tray_icon_visible(settings.showSystemTrayIcon); CentralizedKeyboardHook::Start(); int result = -1; @@ -125,6 +222,18 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow PeriodicUpdateWorker(); } }.detach(); + // Start AI capability detection in background (Windows 11+ only) + // AI Super Resolution is not supported on Windows 10 + // This calls ImageResizer --detect-ai which writes result to cache file + if (package::IsWin11OrGreater()) + { + DetectAiCapabilitiesAsync(); + } + else + { + Logger::info(L"AI capability detection skipped: Windows 10 does not support AI Super Resolution"); + } + std::thread{ [] { if (updating::uninstall_previous_msix_version_async().get()) { @@ -147,7 +256,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow std::vector<std::wstring_view> knownModules = { L"PowerToys.FancyZonesModuleInterface.dll", L"PowerToys.powerpreview.dll", - L"PowerToys.ImageResizerExt.dll", + L"WinUI3Apps/PowerToys.ImageResizerExt.dll", L"PowerToys.KeyboardManager.dll", L"PowerToys.Launcher.dll", L"WinUI3Apps/PowerToys.PowerRenameExt.dll", @@ -159,6 +268,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.MouseJump.dll", L"PowerToys.AlwaysOnTopModuleInterface.dll", L"PowerToys.MousePointerCrosshairs.dll", + L"PowerToys.CursorWrap.dll", L"PowerToys.PowerAccentModuleInterface.dll", L"PowerToys.PowerOCRModuleInterface.dll", L"PowerToys.AdvancedPasteModuleInterface.dll", @@ -175,6 +285,8 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"PowerToys.WorkspacesModuleInterface.dll", L"PowerToys.CmdPalModuleInterface.dll", L"PowerToys.ZoomItModuleInterface.dll", + L"PowerToys.LightSwitchModuleInterface.dll", + L"PowerToys.PowerDisplayModuleInterface.dll", }; for (auto moduleSubdir : knownModules) @@ -188,10 +300,19 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow { std::wstring errorMessage = POWER_TOYS_MODULE_LOAD_FAIL; errorMessage += moduleSubdir; + +#ifdef _DEBUG + // In debug mode, simply log the warning and continue execution. + // This contrasts with the past approach where developers had to build all modules + // without errors before debugging—slowing down quick clone-and-fix iterations. + Logger::warn(L"Debug mode: {}", errorMessage); +#else + // In release mode, show error dialog as before MessageBoxW(NULL, errorMessage.c_str(), L"PowerToys", MB_OK | MB_ICONERROR); +#endif } } // Start initial powertoys @@ -207,7 +328,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow { window = winrt::to_hstring(settingsWindow); } - open_settings_window(window, false); + open_settings_window(window); } if (openOobe) @@ -230,6 +351,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow result = -1; } Trace::UnregisterProvider(); + QuickAccessHost::stop(); return result; } @@ -324,6 +446,7 @@ int WINAPI WinMain(HINSTANCE /*hInstance*/, HINSTANCE /*hPrevInstance*/, LPSTR l GdiplusStartup(&gpToken, &gpStartupInput, NULL); winrt::init_apartment(); + const wchar_t* securityDescriptor = L"O:BA" // Owner: Builtin (local) administrator L"G:BA" // Group: Builtin (local) administrator @@ -515,5 +638,6 @@ int WINAPI WinMain(HINSTANCE /*hInstance*/, HINSTANCE /*hPrevInstance*/, LPSTR l } } stop_tray_icon(); + return result; } diff --git a/src/runner/pch.h b/src/runner/pch.h index 537bef12d6..2c713c7099 100644 --- a/src/runner/pch.h +++ b/src/runner/pch.h @@ -32,3 +32,4 @@ #include <winrt/Windows.Storage.h> #include <wil/resource.h> +#include <wil/coroutine.h> diff --git a/src/runner/powertoy_module.cpp b/src/runner/powertoy_module.cpp index 32f856f465..eb1f7c4fd7 100644 --- a/src/runner/powertoy_module.cpp +++ b/src/runner/powertoy_module.cpp @@ -40,13 +40,14 @@ json::JsonObject PowertoyModule::json_config() const } PowertoyModule::PowertoyModule(PowertoyModuleIface* pt_module, HMODULE handle) : - handle(handle), pt_module(pt_module) + handle(handle), pt_module(pt_module), hkmng(HotkeyConflictDetector::HotkeyConflictManager::GetInstance()) { if (!pt_module) { throw std::runtime_error("Module not initialized"); } + remove_hotkey_records(); update_hotkeys(); UpdateHotkeyEx(); } @@ -63,19 +64,27 @@ void PowertoyModule::update_hotkeys() for (size_t i = 0; i < hotkeyCount; i++) { - CentralizedKeyboardHook::SetHotkeyAction(pt_module->get_key(), hotkeys[i], [modulePtr, i] { - Logger::trace(L"{} hotkey is invoked from Centralized keyboard hook", modulePtr->get_key()); - return modulePtr->on_hotkey(i); - }); + if (hotkeys[i].isShown) + { + hkmng.AddHotkey(hotkeys[i], pt_module->get_key(), static_cast<int>(i), pt_module->is_enabled()); + + CentralizedKeyboardHook::SetHotkeyAction(pt_module->get_key(), hotkeys[i], [modulePtr, i] { + Logger::trace(L"{} hotkey is invoked from Centralized keyboard hook", modulePtr->get_key()); + return modulePtr->on_hotkey(i); + }); + } } } void PowertoyModule::UpdateHotkeyEx() { CentralizedHotkeys::UnregisterHotkeysForModule(pt_module->get_key()); + auto container = pt_module->GetHotkeyEx(); if (container.has_value() && pt_module->is_enabled()) { + hkmng.RemoveHotkeyByModule(pt_module->get_key()); + auto hotkey = container.value(); auto modulePtr = pt_module.get(); auto action = [modulePtr](WORD /*modifiersMask*/, WORD /*vkCode*/) { @@ -83,6 +92,9 @@ void PowertoyModule::UpdateHotkeyEx() modulePtr->OnHotkeyEx(); }; + HotkeyConflictDetector::Hotkey _hotkey = HotkeyConflictDetector::ShortcutToHotkey({ hotkey.modifiersMask, hotkey.vkCode }); + hkmng.AddHotkey(_hotkey, pt_module->get_key(), 0, pt_module->is_enabled()); // This is the only one activation hotkey, so we use "0" as the name. + CentralizedHotkeys::AddHotkeyAction({ hotkey.modifiersMask, hotkey.vkCode }, { pt_module->get_key(), action }); } diff --git a/src/runner/powertoy_module.h b/src/runner/powertoy_module.h index 9332e5f025..9b7a9a59bd 100644 --- a/src/runner/powertoy_module.h +++ b/src/runner/powertoy_module.h @@ -5,6 +5,7 @@ #include <mutex> #include <vector> #include <functional> +#include "hotkey_conflict_detector.h" #include <common/utils/json.h> @@ -44,9 +45,17 @@ public: void UpdateHotkeyEx(); + inline void remove_hotkey_records() + { + hkmng.RemoveHotkeyByModule(pt_module->get_key()); + } + private: + HotkeyConflictDetector::HotkeyConflictManager& hkmng; std::unique_ptr<HMODULE, PowertoyModuleDLLDeleter> handle; std::unique_ptr<PowertoyModuleIface, PowertoyModuleDeleter> pt_module; + + }; PowertoyModule load_powertoy(const std::wstring_view filename); diff --git a/src/runner/quick_access_host.cpp b/src/runner/quick_access_host.cpp new file mode 100644 index 0000000000..b546ee244e --- /dev/null +++ b/src/runner/quick_access_host.cpp @@ -0,0 +1,296 @@ +#include "pch.h" +#include "quick_access_host.h" + +#include <mutex> +#include <string> +#include <vector> +#include <rpc.h> +#include <new> +#include <memory> + +#include <common/logger/logger.h> +#include <common/utils/process_path.h> +#include <common/interop/two_way_pipe_message_ipc.h> +#include <wil/resource.h> + +extern void receive_json_send_to_main_thread(const std::wstring& msg); + +namespace +{ + wil::unique_handle quick_access_process; + wil::unique_handle quick_access_job; + wil::unique_handle show_event; + wil::unique_handle exit_event; + std::wstring show_event_name; + std::wstring exit_event_name; + std::wstring runner_pipe_name; + std::wstring app_pipe_name; + std::unique_ptr<TwoWayPipeMessageIPC> quick_access_ipc; + std::mutex quick_access_mutex; + + bool is_process_active_locked() + { + if (!quick_access_process) + { + return false; + } + + DWORD exit_code = 0; + if (!GetExitCodeProcess(quick_access_process.get(), &exit_code)) + { + Logger::warn(L"QuickAccessHost: failed to read Quick Access process exit code. error={}.", GetLastError()); + return false; + } + + return exit_code == STILL_ACTIVE; + } + + void reset_state_locked() + { + if (quick_access_ipc) + { + quick_access_ipc->end(); + quick_access_ipc.reset(); + } + + quick_access_process.reset(); + quick_access_job.reset(); + show_event.reset(); + exit_event.reset(); + show_event_name.clear(); + exit_event_name.clear(); + runner_pipe_name.clear(); + app_pipe_name.clear(); + } + + std::wstring build_event_name(const wchar_t* suffix) + { + std::wstring name = L"Local\\PowerToysQuickAccess_"; + name += std::to_wstring(GetCurrentProcessId()); + if (suffix) + { + name += suffix; + } + return name; + } + + std::wstring build_command_line(const std::wstring& exe_path) + { + std::wstring command_line = L"\""; + command_line += exe_path; + command_line += L"\" --show-event=\""; + command_line += show_event_name; + command_line += L"\" --exit-event=\""; + command_line += exit_event_name; + command_line += L"\""; + if (!runner_pipe_name.empty()) + { + command_line.append(L" --runner-pipe=\""); + command_line += runner_pipe_name; + command_line += L"\""; + } + if (!app_pipe_name.empty()) + { + command_line.append(L" --app-pipe=\""); + command_line += app_pipe_name; + command_line += L"\""; + } + return command_line; + } +} + +namespace QuickAccessHost +{ + bool is_running() + { + std::scoped_lock lock(quick_access_mutex); + return is_process_active_locked(); + } + + void start() + { + Logger::info(L"QuickAccessHost::start() called"); + std::scoped_lock lock(quick_access_mutex); + if (is_process_active_locked()) + { + Logger::info(L"QuickAccessHost::start: process already active"); + return; + } + + reset_state_locked(); + + show_event_name = build_event_name(L"_Show"); + exit_event_name = build_event_name(L"_Exit"); + + show_event.reset(CreateEventW(nullptr, FALSE, FALSE, show_event_name.c_str())); + if (!show_event) + { + Logger::error(L"QuickAccessHost: failed to create show event. error={}.", GetLastError()); + reset_state_locked(); + return; + } + + exit_event.reset(CreateEventW(nullptr, FALSE, FALSE, exit_event_name.c_str())); + if (!exit_event) + { + Logger::error(L"QuickAccessHost: failed to create exit event. error={}.", GetLastError()); + reset_state_locked(); + return; + } + + runner_pipe_name = L"\\\\.\\pipe\\powertoys_quick_access_runner_"; + app_pipe_name = L"\\\\.\\pipe\\powertoys_quick_access_ui_"; + UUID temp_uuid; + wchar_t* uuid_chars = nullptr; + if (UuidCreate(&temp_uuid) == RPC_S_UUID_NO_ADDRESS) + { + Logger::warn(L"QuickAccessHost: failed to create UUID for pipe names. error={}.", GetLastError()); + } + else if (UuidToString(&temp_uuid, reinterpret_cast<RPC_WSTR*>(&uuid_chars)) != RPC_S_OK) + { + Logger::warn(L"QuickAccessHost: failed to convert UUID to string. error={}.", GetLastError()); + } + + if (uuid_chars != nullptr) + { + runner_pipe_name += std::wstring(uuid_chars); + app_pipe_name += std::wstring(uuid_chars); + RpcStringFree(reinterpret_cast<RPC_WSTR*>(&uuid_chars)); + uuid_chars = nullptr; + } + else + { + const std::wstring fallback_suffix = std::to_wstring(GetTickCount64()); + runner_pipe_name += fallback_suffix; + app_pipe_name += fallback_suffix; + } + + HANDLE token_handle = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token_handle)) + { + Logger::error(L"QuickAccessHost: failed to open process token. error={}.", GetLastError()); + reset_state_locked(); + return; + } + + wil::unique_handle token(token_handle); + quick_access_ipc.reset(new (std::nothrow) TwoWayPipeMessageIPC(runner_pipe_name, app_pipe_name, receive_json_send_to_main_thread)); + if (!quick_access_ipc) + { + Logger::error(L"QuickAccessHost: failed to allocate IPC instance."); + reset_state_locked(); + return; + } + + try + { + quick_access_ipc->start(token.get()); + } + catch (...) + { + Logger::error(L"QuickAccessHost: failed to start IPC server for Quick Access."); + reset_state_locked(); + return; + } + + const std::wstring exe_path = get_module_folderpath() + L"\\WinUI3Apps\\PowerToys.QuickAccess.exe"; + if (GetFileAttributesW(exe_path.c_str()) == INVALID_FILE_ATTRIBUTES) + { + Logger::warn(L"QuickAccessHost: missing Quick Access executable at {}", exe_path); + reset_state_locked(); + return; + } + + const std::wstring command_line = build_command_line(exe_path); + std::vector<wchar_t> command_line_buffer(command_line.begin(), command_line.end()); + command_line_buffer.push_back(L'\0'); + STARTUPINFOW startup_info{}; + startup_info.cb = sizeof(startup_info); + PROCESS_INFORMATION process_info{}; + + BOOL created = CreateProcessW(exe_path.c_str(), command_line_buffer.data(), nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr, nullptr, &startup_info, &process_info); + if (!created) + { + Logger::error(L"QuickAccessHost: failed to launch Quick Access host. error={}.", GetLastError()); + reset_state_locked(); + return; + } + + quick_access_process.reset(process_info.hProcess); + + // Assign to job object to ensure the process is killed if the runner exits unexpectedly (e.g. debugging stop) + quick_access_job.reset(CreateJobObjectW(nullptr, nullptr)); + if (quick_access_job) + { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli = { 0 }; + jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(quick_access_job.get(), JobObjectExtendedLimitInformation, &jeli, sizeof(jeli))) + { + Logger::warn(L"QuickAccessHost: failed to set job object information. error={}", GetLastError()); + } + else + { + if (!AssignProcessToJobObject(quick_access_job.get(), quick_access_process.get())) + { + Logger::warn(L"QuickAccessHost: failed to assign process to job object. error={}", GetLastError()); + } + } + } + else + { + Logger::warn(L"QuickAccessHost: failed to create job object. error={}", GetLastError()); + } + + ResumeThread(process_info.hThread); + CloseHandle(process_info.hThread); + } + + void show() + { + start(); + std::scoped_lock lock(quick_access_mutex); + + if (show_event) + { + if (!SetEvent(show_event.get())) + { + Logger::warn(L"QuickAccessHost: failed to signal show event. error={}.", GetLastError()); + } + } + } + + void stop() + { + Logger::info(L"QuickAccessHost::stop() called"); + std::unique_lock lock(quick_access_mutex); + if (exit_event) + { + SetEvent(exit_event.get()); + } + + if (quick_access_process) + { + const DWORD wait_result = WaitForSingleObject(quick_access_process.get(), 2000); + Logger::info(L"QuickAccessHost::stop: WaitForSingleObject result={}", wait_result); + if (wait_result == WAIT_TIMEOUT) + { + Logger::warn(L"QuickAccessHost: Quick Access process did not exit in time, terminating."); + if (!TerminateProcess(quick_access_process.get(), 0)) + { + Logger::error(L"QuickAccessHost: failed to terminate Quick Access process. error={}.", GetLastError()); + } + else + { + Logger::info(L"QuickAccessHost: TerminateProcess succeeded."); + WaitForSingleObject(quick_access_process.get(), 5000); + } + } + else if (wait_result == WAIT_FAILED) + { + Logger::error(L"QuickAccessHost: failed while waiting for Quick Access process. error={}.", GetLastError()); + } + } + + reset_state_locked(); + } +} diff --git a/src/runner/quick_access_host.h b/src/runner/quick_access_host.h new file mode 100644 index 0000000000..22a65a9c26 --- /dev/null +++ b/src/runner/quick_access_host.h @@ -0,0 +1,12 @@ +#pragma once + +#include <Windows.h> +#include <optional> + +namespace QuickAccessHost +{ + void start(); + void show(); + void stop(); + bool is_running(); +} diff --git a/src/runner/resource.base.h b/src/runner/resource.base.h index 22d040cc82..7037f4342d 100644 --- a/src/runner/resource.base.h +++ b/src/runner/resource.base.h @@ -15,9 +15,9 @@ #define APPICON 101 #define ID_TRAY_MENU 102 -#define ID_EXIT_MENU_COMMAND 40001 +#define ID_CLOSE_MENU_COMMAND 40001 #define ID_SETTINGS_MENU_COMMAND 40002 #define ID_ABOUT_MENU_COMMAND 40003 #define ID_REPORT_BUG_COMMAND 40004 #define ID_DOCUMENTATION_MENU_COMMAND 40005 -#define ID_QUICK_ACCESS_MENU_COMMAND 40006 \ No newline at end of file +#define ID_QUICK_ACCESS_MENU_COMMAND 40006 diff --git a/src/runner/runner.base.rc b/src/runner/runner.base.rc index 5737649c37..55b4e13fdd 100644 Binary files a/src/runner/runner.base.rc and b/src/runner/runner.base.rc differ diff --git a/src/runner/runner.vcxproj b/src/runner/runner.vcxproj index a55396a71a..16587c73ac 100644 --- a/src/runner/runner.vcxproj +++ b/src/runner/runner.vcxproj @@ -1,30 +1,43 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> <Target Name="GenerateResourceFiles" BeforeTargets="PrepareForBuild"> - <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted $(SolutionDir)tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h runner.base.rc runner.rc" /> + <Exec Command="powershell -NonInteractive -executionpolicy Unrestricted ..\..\tools\build\convert-resx-to-rc.ps1 $(MSBuildThisFileDirectory) resource.base.h resource.h runner.base.rc runner.rc" /> </Target> <PropertyGroup> <NoWarn>81010002</NoWarn> </PropertyGroup> + <PropertyGroup Label="NuGet"> + <!-- Tell NuGet this is PackageReference style --> + <RestoreProjectStyle>PackageReference</RestoreProjectStyle> + <!-- Tell NuGet we're a native project --> + <NuGetTargetMoniker>native,Version=v0.0</NuGetTargetMoniker> + <!-- Tell NuGet we target Windows (use your existing WindowsTargetPlatformVersion) --> + <NuGetTargetPlatformIdentifier>Windows</NuGetTargetPlatformIdentifier> + <NuGetTargetPlatformVersion>$(WindowsTargetPlatformVersion)</NuGetTargetPlatformVersion> + </PropertyGroup> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{9412D5C6-2CF2-4FC2-A601-B55508EA9B27}</ProjectGuid> <RootNamespace>powertoys</RootNamespace> <ProjectName>runner</ProjectName> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.WindowsAppSDK" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.WindowsAppSDK.Foundation" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.CppWinRT" GeneratePathProperty="true" /> + <PackageReference Include="Microsoft.Windows.ImplementationLibrary" GeneratePathProperty="true" /> + </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> - <Import Project="..\..\deps\expected.props" /> + <Import Project="$(RepoRoot)deps\expected.props" /> <ImportGroup Label="Shared" /> <PropertyGroup Label="Configuration"> <ConfigurationType>Application</ConfigurationType> - <PlatformToolset>v143</PlatformToolset> + + <WindowsPackageType>None</WindowsPackageType> + <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> + <WindowsAppSdkUndockedRegFreeWinRTInitialize>true</WindowsAppSdkUndockedRegFreeWinRTInitialize> </PropertyGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> - <ImportGroup Label="ExtensionSettings"> - </ImportGroup> - <ImportGroup Label="Shared"> - </ImportGroup> <ImportGroup Label="PropertySheets"> <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> </ImportGroup> @@ -51,11 +64,13 @@ <ClCompile Include="bug_report.cpp" /> <ClCompile Include="centralized_hotkeys.cpp" /> <ClCompile Include="general_settings.cpp" /> + <ClCompile Include="hotkey_conflict_detector.cpp" /> <ClCompile Include="pch.cpp"> <PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader> </ClCompile> <ClCompile Include="powertoy_module.cpp" /> <ClCompile Include="main.cpp" /> + <ClCompile Include="quick_access_host.cpp" /> <ClCompile Include="restart_elevated.cpp" /> <ClCompile Include="centralized_kb_hook.cpp" /> <ClCompile Include="settings_telemetry.cpp" /> @@ -67,10 +82,13 @@ </ItemGroup> <ItemGroup> <ClInclude Include="ActionRunnerUtils.h" /> + <ClInclude Include="ai_detection.h" /> <ClInclude Include="auto_start_helper.h" /> <ClInclude Include="bug_report.h" /> <ClInclude Include="centralized_hotkeys.h" /> + <ClInclude Include="quick_access_host.h" /> <ClInclude Include="general_settings.h" /> + <ClInclude Include="hotkey_conflict_detector.h" /> <ClInclude Include="pch.h" /> <ClInclude Include="centralized_kb_hook.h" /> <ClInclude Include="settings_telemetry.h" /> @@ -117,27 +135,45 @@ <Project>{17da04df-e393-4397-9cf0-84dabe11032e}</Project> </ProjectReference> </ItemGroup> - <ItemGroup> - <None Include="packages.config" /> - </ItemGroup> <ItemGroup> <Manifest Include="PowerToys.exe.manifest" /> </ItemGroup> <ItemGroup> <EmbeddedResource Include="Resources.resx" /> </ItemGroup> + <ItemGroup> + <CopyFileToFolders Include="svgs\PowerToysDark.ico"> + <DeploymentContent Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">true</DeploymentContent> + <DeploymentContent Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">true</DeploymentContent> + <DeploymentContent Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">true</DeploymentContent> + <DeploymentContent Condition="'$(Configuration)|$(Platform)'=='Release|x64'">true</DeploymentContent> + <DestinationFolders Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">$(OutDir)\svgs</DestinationFolders> + <DestinationFolders Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">$(OutDir)\svgs</DestinationFolders> + <DestinationFolders Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">$(OutDir)\svgs</DestinationFolders> + <DestinationFolders Condition="'$(Configuration)|$(Platform)'=='Release|x64'">$(OutDir)\svgs</DestinationFolders> + </CopyFileToFolders> + <CopyFileToFolders Include="svgs\PowerToysWhite.ico"> + <DeploymentContent Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">true</DeploymentContent> + <DeploymentContent Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">true</DeploymentContent> + <DeploymentContent Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">true</DeploymentContent> + <DeploymentContent Condition="'$(Configuration)|$(Platform)'=='Release|x64'">true</DeploymentContent> + <DestinationFolders Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">$(OutDir)\svgs</DestinationFolders> + <DestinationFolders Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">$(OutDir)\svgs</DestinationFolders> + <DestinationFolders Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">$(OutDir)\svgs</DestinationFolders> + <DestinationFolders Condition="'$(Configuration)|$(Platform)'=='Release|x64'">$(OutDir)\svgs</DestinationFolders> + </CopyFileToFolders> + </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> - <Import Project="..\..\deps\spdlog.props" /> - <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> - <Import Project="..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - </ImportGroup> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>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}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> - <Error Condition="!Exists('..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> + <Import Project="$(RepoRoot)deps\spdlog.props" /> + <!-- Deduplicate WindowsAppRuntimeAutoInitializer.cpp (added twice via transitive imports causing LNK4042). Remove all then add exactly once. --> + <Target Name="FixWinAppSDKAutoInitializer" BeforeTargets="ClCompile" AfterTargets="WindowsAppRuntimeAutoInitializer"> + <ItemGroup> + <!-- Remove ALL injected versions of the file --> + <ClCompile Remove="@(ClCompile)" Condition="'%(Filename)' == 'WindowsAppRuntimeAutoInitializer'" /> + <!-- Add ONE copy back manually --> + <ClCompile Include="$(PkgMicrosoft_WindowsAppSDK_Foundation)\include\WindowsAppRuntimeAutoInitializer.cpp"> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + </ClCompile> + </ItemGroup> </Target> </Project> \ No newline at end of file diff --git a/src/runner/runner.vcxproj.filters b/src/runner/runner.vcxproj.filters index a91782fd24..4d1e82ff0d 100644 --- a/src/runner/runner.vcxproj.filters +++ b/src/runner/runner.vcxproj.filters @@ -45,6 +45,12 @@ <ClCompile Include="bug_report.cpp"> <Filter>Utils</Filter> </ClCompile> + <ClCompile Include="hotkey_conflict_detector.cpp"> + <Filter>Utils</Filter> + </ClCompile> + <ClCompile Include="quick_access_host.cpp"> + <Filter>Utils</Filter> + </ClCompile> </ItemGroup> <ItemGroup> <ClInclude Include="pch.h" /> @@ -78,6 +84,9 @@ <ClInclude Include="ActionRunnerUtils.h"> <Filter>Utils</Filter> </ClInclude> + <ClInclude Include="ai_detection.h"> + <Filter>Utils</Filter> + </ClInclude> <ClInclude Include="resource.h"> <Filter>Utils</Filter> </ClInclude> @@ -93,6 +102,12 @@ <ClInclude Include="bug_report.h"> <Filter>Utils</Filter> </ClInclude> + <ClInclude Include="hotkey_conflict_detector.h"> + <Filter>Utils</Filter> + </ClInclude> + <ClInclude Include="quick_access_host.h"> + <Filter>Utils</Filter> + </ClInclude> </ItemGroup> <ItemGroup> <Filter Include="Utils"> @@ -107,9 +122,10 @@ </ItemGroup> <ItemGroup> <CopyFileToFolders Include="svgs\icon.ico" /> + <CopyFileToFolders Include="svgs\PowerToysDark.ico" /> + <CopyFileToFolders Include="svgs\PowerToysWhite.ico" /> </ItemGroup> <ItemGroup> - <None Include="packages.config" /> <None Include="runner.base.rc" /> </ItemGroup> <ItemGroup> diff --git a/src/runner/settings_window.cpp b/src/runner/settings_window.cpp index 265d3e3dc0..022ad9d76c 100644 --- a/src/runner/settings_window.cpp +++ b/src/runner/settings_window.cpp @@ -13,6 +13,7 @@ #include "UpdateUtils.h" #include "centralized_kb_hook.h" #include "Generated files/resource.h" +#include "hotkey_conflict_detector.h" #include <common/utils/json.h> #include <common/SettingsAPI/settings_helpers.cpp> @@ -147,14 +148,19 @@ std::optional<std::wstring> dispatch_json_action_to_module(const json::JsonObjec return result; } -void send_json_config_to_module(const std::wstring& module_key, const std::wstring& settings) +void send_json_config_to_module(const std::wstring& module_key, const std::wstring& settings, bool hotkeyUpdated) { auto moduleIt = modules().find(module_key); if (moduleIt != modules().end()) { moduleIt->second->set_config(settings.c_str()); - moduleIt->second.update_hotkeys(); - moduleIt->second.UpdateHotkeyEx(); + + if (hotkeyUpdated) + { + moduleIt->second.remove_hotkey_records(); + moduleIt->second.update_hotkeys(); + moduleIt->second.UpdateHotkeyEx(); + } } } @@ -163,7 +169,25 @@ void dispatch_json_config_to_modules(const json::JsonObject& powertoys_configs) for (const auto& powertoy_element : powertoys_configs) { const auto element = powertoy_element.Value().Stringify(); - send_json_config_to_module(powertoy_element.Key().c_str(), element.c_str()); + + /* As PowerToys Run hotkeys are not registered by the runner, hotkey updates are + * triggered only when hotkey properties change to avoid incorrect conflict detection; + * otherwise, the existing logic remains. + */ + auto settings = powertoy_element.Value().GetObjectW(); + bool hotkeyUpdated = true; + if (settings.HasKey(L"properties")) + { + const auto properties = settings.GetNamedObject(L"properties"); + + // Currently, only PowerToys Run settings use the 'hotkey_changed' property. + if (properties.HasKey(L"hotkey_changed")) + { + json::get(properties, L"hotkey_changed", hotkeyUpdated, true); + } + } + + send_json_config_to_module(powertoy_element.Key().c_str(), element.c_str(), hotkeyUpdated); } }; @@ -177,6 +201,8 @@ void dispatch_received_json(const std::wstring& json_to_parse) return; } + Logger::info(L"dispatch_received_json: {}", json_to_parse); + for (const auto& base_element : j) { const auto name = base_element.Key(); @@ -185,12 +211,18 @@ void dispatch_received_json(const std::wstring& json_to_parse) if (name == L"general") { apply_general_settings(value.GetObjectW()); - const std::wstring settings_string{ get_all_settings().Stringify().c_str() }; - { - std::unique_lock lock{ ipc_mutex }; - if (current_settings_ipc) - current_settings_ipc->send(settings_string); - } + // const std::wstring settings_string{ get_all_settings().Stringify().c_str() }; + // { + // std::unique_lock lock{ ipc_mutex }; + // if (current_settings_ipc) + // current_settings_ipc->send(settings_string); + // } + } + else if (name == L"module_status") + { + // Handle single module enable/disable update + // Expected format: {"module_status": {"ModuleName": true/false}} + apply_module_status_update(value.GetObjectW()); } else if (name == L"powertoys") { @@ -227,6 +259,14 @@ void dispatch_received_json(const std::wstring& json_to_parse) { launch_bug_report(); } + else if (name == L"bug_report_status") + { + json::JsonObject result; + result.SetNamedValue(L"bug_report_running", winrt::Windows::Data::Json::JsonValue::CreateBooleanValue(is_bug_report_running())); + std::unique_lock lock{ ipc_mutex }; + if (current_settings_ipc) + current_settings_ipc->send(result.Stringify().c_str()); + } else if (name == L"killrunner") { const auto pt_main_window = FindWindowW(pt_tray_icon_window_class, nullptr); @@ -241,6 +281,77 @@ void dispatch_received_json(const std::wstring& json_to_parse) const std::wstring save_file_location = PTSettingsHelper::get_root_save_folder_location() + language_filename; json::to_file(save_file_location, j); } + else if (name == L"check_hotkey_conflict") + { + try + { + PowertoyModuleIface::Hotkey hotkey; + hotkey.win = value.GetObjectW().GetNamedBoolean(L"win", false); + hotkey.ctrl = value.GetObjectW().GetNamedBoolean(L"ctrl", false); + hotkey.shift = value.GetObjectW().GetNamedBoolean(L"shift", false); + hotkey.alt = value.GetObjectW().GetNamedBoolean(L"alt", false); + hotkey.key = static_cast<unsigned char>(value.GetObjectW().GetNamedNumber(L"key", 0)); + + std::wstring requestId = value.GetObjectW().GetNamedString(L"request_id", L"").c_str(); + + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + bool hasConflict = hkmng.HasConflict(hotkey); + + json::JsonObject response; + response.SetNamedValue(L"response_type", json::JsonValue::CreateStringValue(L"hotkey_conflict_result")); + response.SetNamedValue(L"request_id", json::JsonValue::CreateStringValue(requestId)); + response.SetNamedValue(L"has_conflict", json::JsonValue::CreateBooleanValue(hasConflict)); + + if (hasConflict) + { + auto conflicts = hkmng.GetAllConflicts(hotkey); + if (!conflicts.empty()) + { + // Include all conflicts in the response + json::JsonArray allConflicts; + for (const auto& conflict : conflicts) + { + json::JsonObject conflictObj; + conflictObj.SetNamedValue(L"module", json::JsonValue::CreateStringValue(conflict.moduleName)); + conflictObj.SetNamedValue(L"hotkeyID", json::JsonValue::CreateNumberValue(conflict.hotkeyID)); + allConflicts.Append(conflictObj); + } + response.SetNamedValue(L"all_conflicts", allConflicts); + } + } + + std::unique_lock lock{ ipc_mutex }; + if (current_settings_ipc) + { + current_settings_ipc->send(response.Stringify().c_str()); + } + } + catch (...) + { + Logger::error(L"Failed to process hotkey conflict check request"); + } + } + else if (name == L"get_all_hotkey_conflicts") + { + try + { + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + auto conflictsJson = hkmng.GetHotkeyConflictsAsJson(); + + // Add response type identifier + conflictsJson.SetNamedValue(L"response_type", json::JsonValue::CreateStringValue(L"all_hotkey_conflicts")); + + std::unique_lock lock{ ipc_mutex }; + if (current_settings_ipc) + { + current_settings_ipc->send(conflictsJson.Stringify().c_str()); + } + } + catch (...) + { + Logger::error(L"Failed to process get all hotkey conflicts request"); + } + } } return; } @@ -321,7 +432,7 @@ BOOL run_settings_non_elevated(LPCWSTR executable_path, LPWSTR executable_args, DWORD g_settings_process_id = 0; -void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::optional<std::wstring> settings_window, bool show_flyout = false, const std::optional<POINT>& flyout_position = std::nullopt) +void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::optional<std::wstring> settings_window) { g_isLaunchInProgress = true; @@ -391,22 +502,16 @@ void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::op // Arg 9: should scoobe window be shown std::wstring settings_showScoobe = show_scoobe_window ? L"true" : L"false"; - // Arg 10: should flyout be shown - std::wstring settings_showFlyout = show_flyout ? L"true" : L"false"; - - // Arg 11: contains if there's a settings window argument. If true, will add one extra argument with the value to the call. + // Arg 10: contains if there's a settings window argument. If true, will add one extra argument with the value to the call. std::wstring settings_containsSettingsWindow = settings_window.has_value() ? L"true" : L"false"; - // Arg 12: contains if there's flyout coordinates. If true, will add two extra arguments to the call containing the x and y coordinates. - std::wstring settings_containsFlyoutPosition = flyout_position.has_value() ? L"true" : L"false"; - - // Args 13, .... : Optional arguments depending on the options presented before. All by the same value. + // Args 11, .... : Optional arguments depending on the options presented before. All by the same value. // create general settings file to initialize the settings file with installation configurations like : // 1. Run on start up. PTSettingsHelper::save_general_settings(save_settings.to_json()); - std::wstring executable_args = fmt::format(L"\"{}\" {} {} {} {} {} {} {} {} {} {} {}", + std::wstring executable_args = fmt::format(L"\"{}\" {} {} {} {} {} {} {} {} {}", executable_path, powertoys_pipe_name, settings_pipe_name, @@ -416,9 +521,7 @@ void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::op settings_isUserAnAdmin, settings_showOobe, settings_showScoobe, - settings_showFlyout, - settings_containsSettingsWindow, - settings_containsFlyoutPosition); + settings_containsSettingsWindow); if (settings_window.has_value()) { @@ -426,14 +529,6 @@ void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::op executable_args.append(settings_window.value()); } - if (flyout_position) - { - executable_args.append(L" "); - executable_args.append(std::to_wstring(flyout_position.value().x)); - executable_args.append(L" "); - executable_args.append(std::to_wstring(flyout_position.value().y)); - } - BOOL process_created = false; // Commented out to fix #22659 @@ -488,7 +583,18 @@ void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::op std::unique_lock lock{ ipc_mutex }; current_settings_ipc = new TwoWayPipeMessageIPC(powertoys_pipe_name, settings_pipe_name, receive_json_send_to_main_thread); current_settings_ipc->start(hToken); + + // Register callback for bug report status changes + BugReportManager::instance().register_callback([](bool isRunning) { + json::JsonObject result; + result.SetNamedValue(L"bug_report_running", winrt::Windows::Data::Json::JsonValue::CreateBooleanValue(isRunning)); + + std::unique_lock lock{ ipc_mutex }; + if (current_settings_ipc) + current_settings_ipc->send(result.Stringify().c_str()); + }); } + g_settings_process_id = process_info.dwProcessId; if (process_info.hProcess) @@ -573,39 +679,22 @@ void bring_settings_to_front() EnumWindows(callback, 0); } -void open_settings_window(std::optional<std::wstring> settings_window, bool show_flyout = false, const std::optional<POINT>& flyout_position) +void open_settings_window(std::optional<std::wstring> settings_window) { if (g_settings_process_id != 0) { - if (show_flyout) + // nl instead of showing the window, send message to it (flyout might need to be hidden, main setting window activated) + // bring_settings_to_front(); + if (current_settings_ipc) { - if (current_settings_ipc) + if (settings_window.has_value()) { - if (!flyout_position.has_value()) - { - current_settings_ipc->send(L"{\"ShowYourself\":\"flyout\"}"); - } - else - { - current_settings_ipc->send(fmt::format(L"{{\"ShowYourself\":\"flyout\", \"x_position\":{}, \"y_position\":{} }}", std::to_wstring(flyout_position.value().x), std::to_wstring(flyout_position.value().y))); - } + std::wstring msg = L"{\"ShowYourself\":\"" + settings_window.value() + L"\"}"; + current_settings_ipc->send(msg); } - } - else - { - // nl instead of showing the window, send message to it (flyout might need to be hidden, main setting window activated) - // bring_settings_to_front(); - if (current_settings_ipc) + else { - if (settings_window.has_value()) - { - std::wstring msg = L"{\"ShowYourself\":\"" + settings_window.value() + L"\"}"; - current_settings_ipc->send(msg); - } - else - { - current_settings_ipc->send(L"{\"ShowYourself\":\"Dashboard\"}"); - } + current_settings_ipc->send(L"{\"ShowYourself\":\"Dashboard\"}"); } } } @@ -613,8 +702,8 @@ void open_settings_window(std::optional<std::wstring> settings_window, bool show { if (!g_isLaunchInProgress) { - std::thread([settings_window, show_flyout, flyout_position]() { - run_settings_window(false, false, settings_window, show_flyout, flyout_position); + std::thread([settings_window]() { + run_settings_window(false, false, settings_window); }).detach(); } } @@ -652,14 +741,24 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) { switch (value) { + case ESettingsWindowNames::Dashboard: + return "Dashboard"; case ESettingsWindowNames::Overview: return "Overview"; + case ESettingsWindowNames::AlwaysOnTop: + return "AlwaysOnTop"; case ESettingsWindowNames::Awake: return "Awake"; case ESettingsWindowNames::ColorPicker: return "ColorPicker"; + case ESettingsWindowNames::CmdNotFound: + return "CmdNotFound"; + case ESettingsWindowNames::LightSwitch: + return "LightSwitch"; case ESettingsWindowNames::FancyZones: return "FancyZones"; + case ESettingsWindowNames::FileLocksmith: + return "FileLocksmith"; case ESettingsWindowNames::Run: return "Run"; case ESettingsWindowNames::ImageResizer: @@ -668,6 +767,16 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) return "KBM"; case ESettingsWindowNames::MouseUtils: return "MouseUtils"; + case ESettingsWindowNames::MouseWithoutBorders: + return "MouseWithoutBorders"; + case ESettingsWindowNames::Peek: + return "Peek"; + case ESettingsWindowNames::PowerAccent: + return "PowerAccent"; + case ESettingsWindowNames::PowerLauncher: + return "PowerLauncher"; + case ESettingsWindowNames::PowerPreview: + return "PowerPreview"; case ESettingsWindowNames::PowerRename: return "PowerRename"; case ESettingsWindowNames::FileExplorer: @@ -688,8 +797,6 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) return "CropAndLock"; case ESettingsWindowNames::EnvironmentVariables: return "EnvironmentVariables"; - case ESettingsWindowNames::Dashboard: - return "Dashboard"; case ESettingsWindowNames::AdvancedPaste: return "AdvancedPaste"; case ESettingsWindowNames::NewPlus: @@ -698,6 +805,8 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) return "CmdPal"; case ESettingsWindowNames::ZoomIt: return "ZoomIt"; + case ESettingsWindowNames::PowerDisplay: + return "PowerDisplay"; default: { Logger::error(L"Can't convert ESettingsWindowNames value={} to string", static_cast<int>(value)); @@ -709,10 +818,18 @@ std::string ESettingsWindowNames_to_string(ESettingsWindowNames value) ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { - if (value == "Overview") + if (value == "Dashboard") + { + return ESettingsWindowNames::Dashboard; + } + else if (value == "Overview") { return ESettingsWindowNames::Overview; } + else if (value == "AlwaysOnTop") + { + return ESettingsWindowNames::AlwaysOnTop; + } else if (value == "Awake") { return ESettingsWindowNames::Awake; @@ -721,10 +838,22 @@ ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { return ESettingsWindowNames::ColorPicker; } + else if (value == "CmdNotFound") + { + return ESettingsWindowNames::CmdNotFound; + } + else if (value == "LightSwitch") + { + return ESettingsWindowNames::LightSwitch; + } else if (value == "FancyZones") { return ESettingsWindowNames::FancyZones; } + else if (value == "FileLocksmith") + { + return ESettingsWindowNames::FileLocksmith; + } else if (value == "Run") { return ESettingsWindowNames::Run; @@ -741,6 +870,26 @@ ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { return ESettingsWindowNames::MouseUtils; } + else if (value == "MouseWithoutBorders") + { + return ESettingsWindowNames::MouseWithoutBorders; + } + else if (value == "Peek") + { + return ESettingsWindowNames::Peek; + } + else if (value == "PowerAccent") + { + return ESettingsWindowNames::PowerAccent; + } + else if (value == "PowerLauncher") + { + return ESettingsWindowNames::PowerLauncher; + } + else if (value == "PowerPreview") + { + return ESettingsWindowNames::PowerPreview; + } else if (value == "PowerRename") { return ESettingsWindowNames::PowerRename; @@ -781,10 +930,6 @@ ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { return ESettingsWindowNames::EnvironmentVariables; } - else if (value == "Dashboard") - { - return ESettingsWindowNames::Dashboard; - } else if (value == "AdvancedPaste") { return ESettingsWindowNames::AdvancedPaste; @@ -801,6 +946,10 @@ ESettingsWindowNames ESettingsWindowNames_from_string(std::string value) { return ESettingsWindowNames::ZoomIt; } + else if (value == "PowerDisplay") + { + return ESettingsWindowNames::PowerDisplay; + } else { Logger::error(L"Can't convert string value={} to ESettingsWindowNames", winrt::to_hstring(value)); diff --git a/src/runner/settings_window.h b/src/runner/settings_window.h index e6e6e07e4c..4da4d70a7a 100644 --- a/src/runner/settings_window.h +++ b/src/runner/settings_window.h @@ -6,13 +6,22 @@ enum class ESettingsWindowNames { Dashboard = 0, Overview, + AlwaysOnTop, Awake, ColorPicker, + CmdNotFound, + LightSwitch, FancyZones, + FileLocksmith, Run, ImageResizer, KBM, MouseUtils, + MouseWithoutBorders, + Peek, + PowerAccent, + PowerLauncher, + PowerPreview, PowerRename, FileExplorer, ShortcutGuide, @@ -27,14 +36,14 @@ enum class ESettingsWindowNames NewPlus, CmdPal, ZoomIt, + PowerDisplay, }; std::string ESettingsWindowNames_to_string(ESettingsWindowNames value); ESettingsWindowNames ESettingsWindowNames_from_string(std::string value); -void open_settings_window(std::optional<std::wstring> settings_window, bool show_flyout, const std::optional<POINT>& flyout_position); +void open_settings_window(std::optional<std::wstring> settings_window); void close_settings_window(); void open_oobe_window(); void open_scoobe_window(); -void open_flyout(); diff --git a/src/runner/svgs/PowerToysDark.ico b/src/runner/svgs/PowerToysDark.ico new file mode 100644 index 0000000000..313a3d01ec Binary files /dev/null and b/src/runner/svgs/PowerToysDark.ico differ diff --git a/src/runner/svgs/PowerToysWhite.ico b/src/runner/svgs/PowerToysWhite.ico new file mode 100644 index 0000000000..9e55ac8794 Binary files /dev/null and b/src/runner/svgs/PowerToysWhite.ico differ diff --git a/src/runner/trace.cpp b/src/runner/trace.cpp index 1c15092679..6fb2f89ba8 100644 --- a/src/runner/trace.cpp +++ b/src/runner/trace.cpp @@ -55,3 +55,62 @@ void Trace::SettingsChanged(const GeneralSettings& settings) TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); } + +void Trace::UpdateCheckCompleted(bool success, bool updateAvailable, const std::wstring& fromVersion, const std::wstring& toVersion) +{ + TraceLoggingWriteWrapper( + g_hProvider, + "UpdateCheck_Completed", + TraceLoggingBoolean(success, "Success"), + TraceLoggingBoolean(updateAvailable, "UpdateAvailable"), + TraceLoggingWideString(fromVersion.c_str(), "FromVersion"), + TraceLoggingWideString(toVersion.c_str(), "ToVersion"), + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingBoolean(TRUE, "UTCReplace_AppSessionGuid"), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE)); +} + +void Trace::UpdateDownloadCompleted(bool success, const std::wstring& version) +{ + TraceLoggingWriteWrapper( + g_hProvider, + "UpdateDownload_Completed", + TraceLoggingBoolean(success, "Success"), + TraceLoggingWideString(version.c_str(), "Version"), + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + 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 3170fa665a..fb22ce3301 100644 --- a/src/runner/trace.h +++ b/src/runner/trace.h @@ -9,4 +9,13 @@ class Trace : public telemetry::TraceBase public: static void EventLaunch(const std::wstring& versionNumber, bool isProcessElevated); static void SettingsChanged(const GeneralSettings& settings); + + // 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 015fb158b8..307129d63b 100644 --- a/src/runner/tray_icon.cpp +++ b/src/runner/tray_icon.cpp @@ -2,14 +2,20 @@ #include "Generated files/resource.h" #include "settings_window.h" #include "tray_icon.h" +#include "general_settings.h" #include "centralized_hotkeys.h" #include "centralized_kb_hook.h" +#include "quick_access_host.h" +#include "hotkey_conflict_detector.h" +#include "trace.h" #include <Windows.h> #include <common/utils/resources.h> #include <common/version/version.h> #include <common/logger/logger.h> #include <common/utils/elevation.h> +#include <common/Themes/theme_listener.h> +#include <common/Themes/theme_helpers.h> #include "bug_report.h" namespace @@ -35,7 +41,10 @@ namespace bool double_click_timer_running = false; bool double_clicked = false; POINT tray_icon_click_point; + std::optional<bool> last_quick_access_state; // Track the last known Quick Access state + static ThemeListener theme_listener; + static bool theme_adaptive_enabled = false; } // Struct to fill with callback and the data. The window_proc is responsible for cleaning it. @@ -68,9 +77,9 @@ void change_menu_item_text(const UINT item_id, wchar_t* new_text) SetMenuItemInfoW(h_menu, item_id, false, &menuitem); } -void open_quick_access_flyout_window(const POINT flyout_position) +void open_quick_access_flyout_window() { - open_settings_window(std::nullopt, true, flyout_position); + QuickAccessHost::show(); } void handle_tray_command(HWND window, const WPARAM command_id, LPARAM lparam) @@ -80,10 +89,10 @@ void handle_tray_command(HWND window, const WPARAM command_id, LPARAM lparam) case ID_SETTINGS_MENU_COMMAND: { std::wstring settings_window{ winrt::to_hstring(ESettingsWindowNames_to_string(static_cast<ESettingsWindowNames>(lparam))) }; - open_settings_window(settings_window, false); + open_settings_window(settings_window); } break; - case ID_EXIT_MENU_COMMAND: + case ID_CLOSE_MENU_COMMAND: if (h_menu) { DestroyMenu(h_menu); @@ -112,9 +121,7 @@ void handle_tray_command(HWND window, const WPARAM command_id, LPARAM lparam) } case ID_QUICK_ACCESS_MENU_COMMAND: { - POINT mouse_pointer; - GetCursorPos(&mouse_pointer); - open_quick_access_flyout_window(mouse_pointer); + open_quick_access_flyout_window(); break; } } @@ -125,7 +132,17 @@ void click_timer_elapsed() double_click_timer_running = false; if (!double_clicked) { - open_quick_access_flyout_window(tray_icon_click_point); + // 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(); + } + else + { + open_settings_window(std::nullopt); + } } } @@ -183,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<HINSTANCE>(&__ImageBase), MAKEINTRESOURCE(ID_TRAY_MENU)); @@ -190,15 +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 exit_menuitem_label = GET_RESOURCE_STRING(IDS_EXIT_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()); - change_menu_item_text(ID_EXIT_MENU_COMMAND, exit_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) { @@ -215,9 +271,6 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam // ignore event if this is the second click of a double click if (!double_click_timer_running) { - // save the cursor position for sending where to show the popup. - GetCursorPos(&tray_icon_click_point); - // start timer for detecting single or double click double_click_timer_running = true; double_clicked = false; @@ -232,8 +285,11 @@ 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, false); + open_settings_window(std::nullopt); break; } break; @@ -259,10 +315,48 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam return DefWindowProc(window, message, wparam, lparam); } -void start_tray_icon(bool isProcessElevated) +static HICON get_icon(Theme theme) { + std::wstring icon_path = get_module_folderpath(); + icon_path += theme == Theme::Dark ? L"\\svgs\\PowerToysWhite.ico" : L"\\svgs\\PowerToysDark.ico"; + Logger::trace(L"get_icon: Loading icon from path: {}", icon_path); + + HICON icon = static_cast<HICON>(LoadImage(NULL, + icon_path.c_str(), + IMAGE_ICON, + 0, + 0, + LR_LOADFROMFILE | LR_DEFAULTSIZE | LR_SHARED)); + if (!icon) + { + Logger::warn(L"get_icon: Failed to load icon from {}, error: {}", icon_path, GetLastError()); + } + return icon; +} + + +static void handle_theme_change() +{ + if (theme_adaptive_enabled) + { + tray_icon_data.hIcon = get_icon(ThemeHelpers::GetSystemTheme()); + Shell_NotifyIcon(NIM_MODIFY, &tray_icon_data); + } +} + +void update_bug_report_menu_status(bool isRunning) +{ + if (h_sub_menu != nullptr) + { + EnableMenuItem(h_sub_menu, ID_REPORT_BUG_COMMAND, MF_BYCOMMAND | (isRunning ? MF_GRAYED : MF_ENABLED)); + } +} + +void start_tray_icon(bool isProcessElevated, bool theme_adaptive) +{ + theme_adaptive_enabled = theme_adaptive; auto h_instance = reinterpret_cast<HINSTANCE>(&__ImageBase); - auto icon = LoadIcon(h_instance, MAKEINTRESOURCE(APPICON)); + HICON const icon = theme_adaptive ? get_icon(ThemeHelpers::GetSystemTheme()) : LoadIcon(h_instance, MAKEINTRESOURCE(APPICON)); if (icon) { UINT id_tray_icon = 1; @@ -309,6 +403,69 @@ void start_tray_icon(bool isProcessElevated) ChangeWindowMessageFilterEx(hwnd, WM_COMMAND, MSGFLT_ALLOW, nullptr); tray_icon_created = Shell_NotifyIcon(NIM_ADD, &tray_icon_data) == TRUE; + theme_listener.AddSystemThemeChangedHandler(&handle_theme_change); + + // Register callback to update bug report menu item status + BugReportManager::instance().register_callback([](bool isRunning) { + dispatch_run_on_main_ui_thread([](PVOID data) { + bool* running = static_cast<bool*>(data); + update_bug_report_menu_status(*running); + delete running; + }, + new bool(isRunning)); + }); + } +} + +void set_tray_icon_visible(bool shouldIconBeVisible) +{ + tray_icon_data.uFlags |= NIF_STATE; + tray_icon_data.dwStateMask = NIS_HIDDEN; + tray_icon_data.dwState = shouldIconBeVisible ? 0 : NIS_HIDDEN; + Shell_NotifyIcon(NIM_MODIFY, &tray_icon_data); +} + +void set_tray_icon_theme_adaptive(bool theme_adaptive) +{ + Logger::info(L"set_tray_icon_theme_adaptive: Called with theme_adaptive={}, current theme_adaptive_enabled={}", + theme_adaptive, theme_adaptive_enabled); + + auto h_instance = reinterpret_cast<HINSTANCE>(&__ImageBase); + HICON icon = nullptr; + + if (theme_adaptive) + { + icon = get_icon(ThemeHelpers::GetSystemTheme()); + if (!icon) + { + Logger::warn(L"set_tray_icon_theme_adaptive: Failed to load theme adaptive icon, falling back to default"); + } + } + + // If not requesting adaptive icon, or if adaptive icon failed to load, use default icon + if (!icon) + { + icon = LoadIcon(h_instance, MAKEINTRESOURCE(APPICON)); + if (theme_adaptive && icon) + { + // We requested adaptive but had to fall back, so update the flag + theme_adaptive = false; + Logger::info(L"set_tray_icon_theme_adaptive: Using default icon as fallback"); + } + } + + theme_adaptive_enabled = theme_adaptive; + + if (icon) + { + tray_icon_data.hIcon = icon; + BOOL result = Shell_NotifyIcon(NIM_MODIFY, &tray_icon_data); + Logger::info(L"set_tray_icon_theme_adaptive: Icon updated, theme_adaptive_enabled={}, Shell_NotifyIcon result={}", + theme_adaptive_enabled, result); + } + else + { + Logger::error(L"set_tray_icon_theme_adaptive: Failed to load any icon"); } } @@ -316,6 +473,41 @@ void stop_tray_icon() { if (tray_icon_created) { + // Clear bug report callbacks + BugReportManager::instance().clear_callbacks(); SendMessage(tray_icon_hwnd, WM_CLOSE, 0, 0); } } +void update_quick_access_hotkey(bool enabled, PowerToysSettings::HotkeyObject hotkey) +{ + static PowerToysSettings::HotkeyObject current_hotkey; + static bool is_registered = false; + auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance(); + + if (is_registered) + { + CentralizedKeyboardHook::ClearModuleHotkeys(L"QuickAccess"); + hkmng.RemoveHotkeyByModule(L"GeneralSettings"); + is_registered = false; + } + + if (enabled && hotkey.get_code() != 0) + { + HotkeyConflictDetector::Hotkey hk = { + hotkey.win_pressed(), + hotkey.ctrl_pressed(), + hotkey.shift_pressed(), + hotkey.alt_pressed(), + static_cast<unsigned char>(hotkey.get_code()) + }; + + hkmng.AddHotkey(hk, L"GeneralSettings", 0, true); + CentralizedKeyboardHook::SetHotkeyAction(L"QuickAccess", hk, []() { + open_quick_access_flyout_window(); + return true; + }); + + current_hotkey = hotkey; + is_registered = true; + } +} diff --git a/src/runner/tray_icon.h b/src/runner/tray_icon.h index 3c323053ca..5ef4c3a75b 100644 --- a/src/runner/tray_icon.h +++ b/src/runner/tray_icon.h @@ -1,16 +1,24 @@ #pragma once #include <optional> #include <string> +#include <common/SettingsAPI/settings_objects.h> // Start the Tray Icon -void start_tray_icon(bool isProcessElevated); +void start_tray_icon(bool isProcessElevated, bool theme_adaptive); +// Change the Tray Icon visibility +void set_tray_icon_visible(bool shouldIconBeVisible); +// Enable or disable theme adaptive tray icon at runtime +void set_tray_icon_theme_adaptive(bool theme_adaptive); // Stop the Tray Icon void stop_tray_icon(); // Open the Settings Window -void open_settings_window(std::optional<std::wstring> settings_window, bool show_flyout, const std::optional<POINT>& flyout_position = std::nullopt); +void open_settings_window(std::optional<std::wstring> settings_window); +// Update Quick Access Hotkey +void update_quick_access_hotkey(bool enabled, PowerToysSettings::HotkeyObject hotkey); // Callback type to be called by the tray icon loop typedef void (*main_loop_callback_function)(PVOID); // Calls a callback in _callback bool dispatch_run_on_main_ui_thread(main_loop_callback_function _callback, PVOID data); +// Must be the same as: settings-ui/Settings.UI/Views/ShellPage.xaml.cs -> ExitPTItem_Tapped() -> const string ptTrayIconWindowClass const inline wchar_t* pt_tray_icon_window_class = L"PToyTrayIconWindow"; \ No newline at end of file diff --git a/src/settings-ui/QuickAccess.UI/Helpers/ModuleGpoHelper.cs b/src/settings-ui/QuickAccess.UI/Helpers/ModuleGpoHelper.cs new file mode 100644 index 0000000000..25f32e191b --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/Helpers/ModuleGpoHelper.cs @@ -0,0 +1,49 @@ +// 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 global::PowerToys.GPOWrapper; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace Microsoft.PowerToys.QuickAccess.Helpers; + +internal static class ModuleGpoHelper +{ + public static GpoRuleConfigured GetModuleGpoConfiguration(ModuleType moduleType) + { + return moduleType switch + { + ModuleType.AdvancedPaste => GPOWrapper.GetConfiguredAdvancedPasteEnabledValue(), + ModuleType.AlwaysOnTop => GPOWrapper.GetConfiguredAlwaysOnTopEnabledValue(), + ModuleType.Awake => GPOWrapper.GetConfiguredAwakeEnabledValue(), + ModuleType.CmdPal => GPOWrapper.GetConfiguredCmdPalEnabledValue(), + ModuleType.ColorPicker => GPOWrapper.GetConfiguredColorPickerEnabledValue(), + ModuleType.CropAndLock => GPOWrapper.GetConfiguredCropAndLockEnabledValue(), + ModuleType.CursorWrap => GPOWrapper.GetConfiguredCursorWrapEnabledValue(), + ModuleType.EnvironmentVariables => GPOWrapper.GetConfiguredEnvironmentVariablesEnabledValue(), + ModuleType.FancyZones => GPOWrapper.GetConfiguredFancyZonesEnabledValue(), + ModuleType.FileLocksmith => GPOWrapper.GetConfiguredFileLocksmithEnabledValue(), + ModuleType.FindMyMouse => GPOWrapper.GetConfiguredFindMyMouseEnabledValue(), + ModuleType.Hosts => GPOWrapper.GetConfiguredHostsFileEditorEnabledValue(), + ModuleType.ImageResizer => GPOWrapper.GetConfiguredImageResizerEnabledValue(), + ModuleType.KeyboardManager => GPOWrapper.GetConfiguredKeyboardManagerEnabledValue(), + ModuleType.MouseHighlighter => GPOWrapper.GetConfiguredMouseHighlighterEnabledValue(), + ModuleType.MouseJump => GPOWrapper.GetConfiguredMouseJumpEnabledValue(), + ModuleType.MousePointerCrosshairs => GPOWrapper.GetConfiguredMousePointerCrosshairsEnabledValue(), + ModuleType.MouseWithoutBorders => GPOWrapper.GetConfiguredMouseWithoutBordersEnabledValue(), + ModuleType.NewPlus => GPOWrapper.GetConfiguredNewPlusEnabledValue(), + ModuleType.Peek => GPOWrapper.GetConfiguredPeekEnabledValue(), + ModuleType.PowerRename => GPOWrapper.GetConfiguredPowerRenameEnabledValue(), + ModuleType.PowerLauncher => GPOWrapper.GetConfiguredPowerLauncherEnabledValue(), + ModuleType.PowerAccent => GPOWrapper.GetConfiguredQuickAccentEnabledValue(), + ModuleType.Workspaces => GPOWrapper.GetConfiguredWorkspacesEnabledValue(), + ModuleType.RegistryPreview => GPOWrapper.GetConfiguredRegistryPreviewEnabledValue(), + ModuleType.MeasureTool => GPOWrapper.GetConfiguredScreenRulerEnabledValue(), + ModuleType.ShortcutGuide => GPOWrapper.GetConfiguredShortcutGuideEnabledValue(), + ModuleType.PowerOCR => GPOWrapper.GetConfiguredTextExtractorEnabledValue(), + ModuleType.ZoomIt => GPOWrapper.GetConfiguredZoomItEnabledValue(), + _ => GpoRuleConfigured.Unavailable, + }; + } +} diff --git a/src/settings-ui/QuickAccess.UI/Helpers/ResourceLoaderInstance.cs b/src/settings-ui/QuickAccess.UI/Helpers/ResourceLoaderInstance.cs new file mode 100644 index 0000000000..b57d73015b --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/Helpers/ResourceLoaderInstance.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.Windows.ApplicationModel.Resources; + +namespace Microsoft.PowerToys.QuickAccess.Helpers; + +internal static class ResourceLoaderInstance +{ + internal static ResourceLoader ResourceLoader { get; } = new("PowerToys.QuickAccess.pri"); +} diff --git a/src/settings-ui/QuickAccess.UI/PowerToys.QuickAccess.csproj b/src/settings-ui/QuickAccess.UI/PowerToys.QuickAccess.csproj new file mode 100644 index 0000000000..c3c48d37b3 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/PowerToys.QuickAccess.csproj @@ -0,0 +1,90 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + + <PropertyGroup> + <OutputType>WinExe</OutputType> + <TargetFramework>net9.0-windows10.0.26100.0</TargetFramework> + <RootNamespace>Microsoft.PowerToys.QuickAccess</RootNamespace> + <AssemblyName>PowerToys.QuickAccess</AssemblyName> + <ApplicationIcon>..\..\runner\svgs\icon.ico</ApplicationIcon> + <UseWinUI>true</UseWinUI> + <WindowsPackageType>None</WindowsPackageType> + <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> + <EnablePreviewMsixTooling>true</EnablePreviewMsixTooling> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <ApplicationManifest>app.manifest</ApplicationManifest> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <EnableDefaultPageItems>false</EnableDefaultPageItems> + <EnableDefaultApplicationDefinition>false</EnableDefaultApplicationDefinition> + <Nullable>enable</Nullable> + <ProjectPriFileName>PowerToys.QuickAccess.pri</ProjectPriFileName> + </PropertyGroup> + + <PropertyGroup> + <CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes> + <CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir> + </PropertyGroup> + + <ItemGroup> + <ApplicationDefinition Include="QuickAccessXaml\App.xaml" /> + <Page Include="QuickAccessXaml\MainWindow.xaml" /> + <Page Include="QuickAccessXaml\Flyout\ShellPage.xaml" /> + <Page Include="QuickAccessXaml\Flyout\LaunchPage.xaml" /> + <Page Include="QuickAccessXaml\Flyout\AppsListPage.xaml" /> + </ItemGroup> + + <ItemGroup> + <Page Include="..\Settings.UI\SettingsXAML\Styles\Button.xaml"> + <Link>Resources\Styles\Button.xaml</Link> + </Page> + <Page Include="..\Settings.UI\SettingsXAML\Styles\TextBlock.xaml"> + <Link>Resources\Styles\TextBlock.xaml</Link> + </Page> + <Page Include="..\Settings.UI\SettingsXAML\Themes\Colors.xaml"> + <Link>Resources\Themes\Colors.xaml</Link> + </Page> + <Page Include="..\Settings.UI\SettingsXAML\Themes\Generic.xaml"> + <Link>Resources\Themes\Generic.xaml</Link> + </Page> + </ItemGroup> + + <ItemGroup> + <PRIResource Include="..\Settings.UI\Strings\**\Resources.resw"> + <Link>Strings\%(RecursiveDir)Resources.resw</Link> + </PRIResource> + </ItemGroup> + + <ItemGroup> + <Content Include="..\Settings.UI\Assets\Settings\Icons\**\*"> + <Link>Assets\Settings\Icons\%(RecursiveDir)%(Filename)%(Extension)</Link> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="CommunityToolkit.WinUI.Animations" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.Segmented" /> + <PackageReference Include="CommunityToolkit.WinUI.Converters" /> + <PackageReference Include="CommunityToolkit.WinUI.Extensions" /> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="WinUIEx" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\common\GPOWrapper\GPOWrapper.vcxproj" /> + <ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> + <ProjectReference Include="..\..\common\interop\PowerToys.Interop.vcxproj" /> + <ProjectReference Include="..\..\common\Common.UI\Common.UI.csproj" /> + <ProjectReference Include="..\Settings.UI.Library\Settings.UI.Library.csproj" /> + <ProjectReference Include="..\Settings.UI.Controls\Settings.UI.Controls.csproj" /> + </ItemGroup> + + <ItemGroup> + <Manifest Include="$(ApplicationManifest)" /> + </ItemGroup> +</Project> diff --git a/src/settings-ui/QuickAccess.UI/QuickAccessLaunchContext.cs b/src/settings-ui/QuickAccess.UI/QuickAccessLaunchContext.cs new file mode 100644 index 0000000000..2b01947728 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/QuickAccessLaunchContext.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.PowerToys.QuickAccess; + +public sealed record QuickAccessLaunchContext(string? ShowEventName, string? ExitEventName, string? RunnerPipeName, string? AppPipeName) +{ + public static QuickAccessLaunchContext Parse(string[] args) + { + string? showEvent = null; + string? exitEvent = null; + string? runnerPipe = null; + string? appPipe = null; + + foreach (var arg in args) + { + if (TryReadValue(arg, "--show-event", out var value)) + { + showEvent = value; + } + else if (TryReadValue(arg, "--exit-event", out value)) + { + exitEvent = value; + } + else if (TryReadValue(arg, "--runner-pipe", out value)) + { + runnerPipe = value; + } + else if (TryReadValue(arg, "--app-pipe", out value)) + { + appPipe = value; + } + } + + return new QuickAccessLaunchContext(showEvent, exitEvent, runnerPipe, appPipe); + } + + private static bool TryReadValue(string candidate, string key, [NotNullWhen(true)] out string? value) + { + if (candidate.StartsWith(key, StringComparison.OrdinalIgnoreCase)) + { + if (candidate.Length == key.Length) + { + value = null; + return false; + } + + if (candidate[key.Length] == '=') + { + value = candidate[(key.Length + 1)..].Trim('"'); + return true; + } + } + + value = null; + return false; + } +} diff --git a/src/settings-ui/QuickAccess.UI/QuickAccessXAML/App.xaml b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/App.xaml new file mode 100644 index 0000000000..ab7b3c250a --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/App.xaml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="utf-8" ?> +<Application + x:Class="Microsoft.PowerToys.QuickAccess.App" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"> + <Application.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> + <ResourceDictionary Source="/Resources/Styles/Button.xaml" /> + <ResourceDictionary Source="/Resources/Styles/TextBlock.xaml" /> + <ResourceDictionary Source="/Resources/Themes/Colors.xaml" /> + </ResourceDictionary.MergedDictionaries> + + <ResourceDictionary.ThemeDictionaries> + <ResourceDictionary x:Key="Default"> + <SolidColorBrush + x:Key="LayerOnAcrylicFillColorDefaultBrush" + Opacity="0.7" + Color="#FFFFFFFF" /> + <SolidColorBrush x:Key="CardStrokeColorDefaultBrush" Color="#0F000000" /> + <SolidColorBrush x:Key="CardBackgroundFillColorDefaultBrush" Color="#B3FFFFFF" /> + </ResourceDictionary> + <ResourceDictionary x:Key="Light"> + <SolidColorBrush + x:Key="LayerOnAcrylicFillColorDefaultBrush" + Opacity="0.7" + Color="#FFFFFFFF" /> + <SolidColorBrush x:Key="CardStrokeColorDefaultBrush" Color="#0F000000" /> + <SolidColorBrush x:Key="CardBackgroundFillColorDefaultBrush" Color="#B3FFFFFF" /> + </ResourceDictionary> + <ResourceDictionary x:Key="Dark"> + <SolidColorBrush + x:Key="LayerOnAcrylicFillColorDefaultBrush" + Opacity="0.6" + Color="#FF000000" /> + <SolidColorBrush x:Key="CardStrokeColorDefaultBrush" Color="#0FFFFFFF" /> + <SolidColorBrush x:Key="CardBackgroundFillColorDefaultBrush" Color="#0DFFFFFF" /> + </ResourceDictionary> + <ResourceDictionary x:Key="HighContrast"> + <SolidColorBrush x:Key="LayerOnAcrylicFillColorDefaultBrush" Color="{ThemeResource SystemColorWindowColor}" /> + <SolidColorBrush x:Key="CardStrokeColorDefaultBrush" Color="{ThemeResource SystemColorWindowTextColor}" /> + <SolidColorBrush x:Key="CardBackgroundFillColorDefaultBrush" Color="{ThemeResource SystemColorWindowColor}" /> + </ResourceDictionary> + </ResourceDictionary.ThemeDictionaries> + + <tkconverters:BoolToVisibilityConverter + x:Key="ReverseBoolToVisibilityConverter" + FalseValue="Visible" + TrueValue="Collapsed" /> + <tkconverters:BoolToVisibilityConverter + x:Key="BoolToVisibilityConverter" + FalseValue="Collapsed" + TrueValue="Visible" /> + <tkconverters:BoolNegationConverter x:Key="BoolNegationConverter" /> + <tkconverters:StringVisibilityConverter x:Key="StringVisibilityConverter" /> + </ResourceDictionary> + </Application.Resources> +</Application> diff --git a/src/settings-ui/QuickAccess.UI/QuickAccessXAML/App.xaml.cs b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/App.xaml.cs new file mode 100644 index 0000000000..b21fb04b24 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/App.xaml.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; +using Microsoft.UI.Xaml; + +namespace Microsoft.PowerToys.QuickAccess; + +public partial class App : Application +{ + private static MainWindow? _window; + + public App() + { + InitializeComponent(); + } + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + var launchContext = QuickAccessLaunchContext.Parse(Environment.GetCommandLineArgs()); + _window = new MainWindow(launchContext); + _window.Closed += OnWindowClosed; + _window.Activate(); + } + + private static void OnWindowClosed(object sender, WindowEventArgs args) + { + if (sender is MainWindow window) + { + window.Closed -= OnWindowClosed; + } + + _window = null; + } +} diff --git a/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/AppsListPage.xaml b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/AppsListPage.xaml new file mode 100644 index 0000000000..c4d010560d --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/AppsListPage.xaml @@ -0,0 +1,83 @@ +<Page + x:Class="Microsoft.PowerToys.QuickAccess.Flyout.AppsListPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Controls.Converters" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.QuickAccess.Flyout" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:viewModels="using:Microsoft.PowerToys.QuickAccess.ViewModels" + mc:Ignorable="d"> + <Page.Resources> + <converters:EnumToBooleanConverter x:Key="EnumToBooleanConverter" /> + </Page.Resources> + <Grid Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <Grid Padding="24,32,24,0"> + <TextBlock + x:Uid="AllAppsTxt" + VerticalAlignment="Center" + Style="{StaticResource BodyStrongTextBlockStyle}" /> + <StackPanel + HorizontalAlignment="Right" + Orientation="Horizontal" + Spacing="8"> + <Button + x:Uid="Dashboard_SortBy" + VerticalAlignment="Center" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource SubtleButtonStyle}"> + <ToolTipService.ToolTip> + <TextBlock x:Uid="Dashboard_SortBy_ToolTip" /> + </ToolTipService.ToolTip> + <Button.Content> + <FontIcon FontSize="14" Glyph="" /> + </Button.Content> + <Button.Flyout> + <MenuFlyout Placement="BottomEdgeAlignedRight"> + <ToggleMenuFlyoutItem + x:Uid="Dashboard_SortAlphabetical" + Click="SortAlphabetical_Click" + IsChecked="{x:Bind ViewModel.DashboardSortOrder, Mode=OneWay, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Alphabetical}" /> + <ToggleMenuFlyoutItem + x:Uid="Dashboard_SortByStatus" + Click="SortByStatus_Click" + IsChecked="{x:Bind ViewModel.DashboardSortOrder, Mode=OneWay, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=ByStatus}" /> + </MenuFlyout> + </Button.Flyout> + </Button> + <Button + x:Uid="BackBtn" + Padding="8,4,8,4" + VerticalAlignment="Center" + Click="BackButton_Click"> + <Button.Content> + <StackPanel + VerticalAlignment="Center" + Orientation="Horizontal" + Spacing="12"> + <FontIcon + Margin="0,2,0,0" + FontSize="12" + Glyph="" /> + <TextBlock x:Uid="BackLabel" Style="{StaticResource CaptionTextBlockStyle}" /> + </StackPanel> + </Button.Content> + </Button> + </StackPanel> + </Grid> + <Grid Grid.Row="1"> + <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> + <controls:ModuleList + Margin="8,12,12,12" + DividerThickness="0,0,0,0" + IsItemClickable="False" + ItemsSource="{x:Bind ViewModel.FlyoutMenuItems, Mode=OneWay}" /> + </ScrollViewer> + </Grid> + </Grid> +</Page> diff --git a/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/AppsListPage.xaml.cs b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/AppsListPage.xaml.cs new file mode 100644 index 0000000000..9c1422d726 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/AppsListPage.xaml.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.QuickAccess.ViewModels; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Animation; +using Microsoft.UI.Xaml.Navigation; + +namespace Microsoft.PowerToys.QuickAccess.Flyout; + +public sealed partial class AppsListPage : Page +{ + private FlyoutNavigationContext? _context; + + public AppsListPage() + { + InitializeComponent(); + } + + public AllAppsViewModel ViewModel { get; private set; } = default!; + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + + if (e.Parameter is FlyoutNavigationContext context) + { + _context = context; + ViewModel = context.AllAppsViewModel; + DataContext = ViewModel; + ViewModel.RefreshSettings(); + } + } + + private void BackButton_Click(object sender, RoutedEventArgs e) + { + if (_context == null || Frame == null) + { + return; + } + + Frame.Navigate(typeof(LaunchPage), _context, new SlideNavigationTransitionInfo { Effect = SlideNavigationTransitionEffect.FromLeft }); + } + + private void SortAlphabetical_Click(object sender, RoutedEventArgs e) + { + if (ViewModel != null) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.Alphabetical; + ((ToggleMenuFlyoutItem)sender).IsChecked = true; + } + } + + private void SortByStatus_Click(object sender, RoutedEventArgs e) + { + if (ViewModel != null) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.ByStatus; + ((ToggleMenuFlyoutItem)sender).IsChecked = true; + } + } +} diff --git a/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/FlyoutNavigationContext.cs b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/FlyoutNavigationContext.cs new file mode 100644 index 0000000000..3aab3ca334 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/FlyoutNavigationContext.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 Microsoft.PowerToys.QuickAccess.Services; +using Microsoft.PowerToys.QuickAccess.ViewModels; + +namespace Microsoft.PowerToys.QuickAccess.Flyout; + +internal sealed record FlyoutNavigationContext( + LauncherViewModel LauncherViewModel, + AllAppsViewModel AllAppsViewModel, + IQuickAccessCoordinator Coordinator); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/LaunchPage.xaml similarity index 62% rename from src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml rename to src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/LaunchPage.xaml index 67d8030b16..f2f53b57d4 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/LaunchPage.xaml @@ -1,5 +1,5 @@ -<Page - x:Class="Microsoft.PowerToys.Settings.UI.Flyout.LaunchPage" +<Page + x:Class="Microsoft.PowerToys.QuickAccess.Flyout.LaunchPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:animatedVisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals" @@ -9,7 +9,7 @@ xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewModels="using:Microsoft.PowerToys.Settings.UI.ViewModels" + xmlns:viewModels="using:Microsoft.PowerToys.QuickAccess.ViewModels" mc:Ignorable="d"> <Page.Resources> <Style @@ -21,8 +21,6 @@ <Setter Property="Height" Value="32" /> <Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" /> </Style> - - <tkconverters:StringVisibilityConverter x:Key="StringVisibilityConverter" /> </Page.Resources> <Grid> <Grid.RowDefinitions> @@ -54,7 +52,6 @@ VerticalAlignment="Center" Orientation="Horizontal" Spacing="12"> - <TextBlock x:Uid="MoreLabel" Style="{StaticResource CaptionTextBlockStyle}" /> <FontIcon Margin="0,2,0,0" @@ -66,54 +63,22 @@ </Grid> <Grid Grid.Row="1"> <ScrollViewer> - <ItemsControl + <controls:QuickAccessList Margin="12,26,12,24" HorizontalAlignment="Stretch" VerticalAlignment="Top" ItemsSource="{x:Bind ViewModel.FlyoutMenuItems}" - TabNavigation="Local"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <tkcontrols:WrapPanel HorizontalAlignment="Stretch" VerticalSpacing="12" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate x:DataType="viewModels:FlyoutMenuItem"> - <controls:FlyoutMenuButton - AutomationProperties.Name="{x:Bind Label}" - Click="ModuleButton_Click" - Tag="{x:Bind Tag}" - Visibility="{x:Bind Visible, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, Converter={StaticResource BoolToVisibilityConverter}}"> - <controls:FlyoutMenuButton.Content> - <TextBlock - Style="{StaticResource CaptionTextBlockStyle}" - Text="{x:Bind Label}" - TextAlignment="Center" - TextWrapping="Wrap" /> - </controls:FlyoutMenuButton.Content> - <controls:FlyoutMenuButton.Icon> - <Image> - <Image.Source> - <BitmapImage UriSource="{x:Bind Icon, Mode=OneWay}" /> - </Image.Source> - </Image> - </controls:FlyoutMenuButton.Icon> - <ToolTipService.ToolTip> - <ToolTip Content="{x:Bind ToolTip}" Visibility="{x:Bind ToolTip, Converter={StaticResource StringVisibilityConverter}}" /> - </ToolTipService.ToolTip> - </controls:FlyoutMenuButton> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> + TabNavigation="Local" /> </ScrollViewer> </Grid> </Grid> <Grid Grid.Row="2"> <InfoBar - x:Uid="UpdateAvailable" + x:Uid="UpdateAvailableInfoBar" IsClosable="False" IsOpen="{x:Bind ViewModel.IsUpdateAvailable, Mode=OneWay}" - Severity="Success" /> + Severity="Success" + Tapped="UpdateInfoBar_Tapped" /> <StackPanel Grid.Row="1" @@ -146,13 +111,12 @@ <Button x:Name="SettingsBtn" x:Uid="SettingsBtn" - Padding="8" Click="SettingsBtn_Click" Style="{StaticResource FlyoutButtonStyle}"> <ToolTipService.ToolTip> <TextBlock x:Uid="SettingsTooltip" /> </ToolTipService.ToolTip> - <AnimatedIcon x:Name="SearchAnimatedIcon"> + <AnimatedIcon x:Name="SettingsAnimatedIcon"> <AnimatedIcon.Source> <animatedVisuals:AnimatedSettingsVisualSource /> </AnimatedIcon.Source> @@ -161,14 +125,6 @@ </AnimatedIcon.FallbackIconSource> </AnimatedIcon> </Button> - <!--<AppBarSeparator /> - <Button - x:Name="QuitBtn" - Style="{StaticResource FlyoutButtonStyle}" - ToolTipService.ToolTip="Quit" - Click="QuitButton_Click"> - <FontIcon FontSize="16" Glyph="" /> - </Button>--> </StackPanel> </Grid> </Grid> diff --git a/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/LaunchPage.xaml.cs b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/LaunchPage.xaml.cs new file mode 100644 index 0000000000..fc0e5c211a --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/LaunchPage.xaml.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using ManagedCommon; +using Microsoft.PowerToys.QuickAccess.Services; +using Microsoft.PowerToys.QuickAccess.ViewModels; +using Microsoft.PowerToys.Settings.UI.Controls; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media.Animation; +using Microsoft.UI.Xaml.Navigation; +using PowerToys.Interop; +using Windows.System; + +namespace Microsoft.PowerToys.QuickAccess.Flyout; + +public sealed partial class LaunchPage : Page +{ + private AllAppsViewModel? _allAppsViewModel; + private IQuickAccessCoordinator? _coordinator; + + public LaunchPage() + { + InitializeComponent(); + } + + public LauncherViewModel ViewModel { get; private set; } = default!; + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + + if (e.Parameter is FlyoutNavigationContext context) + { + ViewModel = context.LauncherViewModel; + _allAppsViewModel = context.AllAppsViewModel; + _coordinator = context.Coordinator; + DataContext = ViewModel; + } + } + + private void SettingsBtn_Click(object sender, RoutedEventArgs e) + { + _coordinator?.OpenSettings(); + } + + private async void DocsBtn_Click(object sender, RoutedEventArgs e) + { + if (_coordinator == null || !await _coordinator.ShowDocumentationAsync()) + { + await Launcher.LaunchUriAsync(new Uri("https://aka.ms/PowerToysOverview")); + } + } + + private void AllAppButton_Click(object sender, RoutedEventArgs e) + { + if (Frame == null || _allAppsViewModel == null || ViewModel == null || _coordinator == null) + { + return; + } + + var context = new FlyoutNavigationContext(ViewModel, _allAppsViewModel, _coordinator); + Frame.Navigate(typeof(AppsListPage), context, new SlideNavigationTransitionInfo { Effect = SlideNavigationTransitionEffect.FromRight }); + } + + public void ReportBugBtn_Click(object sender, RoutedEventArgs e) + { + _coordinator?.ReportBug(); + } + + private void UpdateInfoBar_Tapped(object sender, TappedRoutedEventArgs e) + { + _coordinator?.OpenGeneralSettingsForUpdates(); + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/ShellPage.xaml b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/ShellPage.xaml similarity index 84% rename from src/settings-ui/Settings.UI/SettingsXAML/Flyout/ShellPage.xaml rename to src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/ShellPage.xaml index 26ef5c1d0e..5f19844d69 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/ShellPage.xaml +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/ShellPage.xaml @@ -1,5 +1,5 @@ -<Page - x:Class="Microsoft.PowerToys.Settings.UI.Flyout.ShellPage" +<Page + x:Class="Microsoft.PowerToys.QuickAccess.Flyout.ShellPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" diff --git a/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/ShellPage.xaml.cs b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/ShellPage.xaml.cs new file mode 100644 index 0000000000..0964f00053 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/Flyout/ShellPage.xaml.cs @@ -0,0 +1,68 @@ +// 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.PowerToys.QuickAccess.Services; +using Microsoft.PowerToys.QuickAccess.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Animation; + +namespace Microsoft.PowerToys.QuickAccess.Flyout; + +/// <summary> +/// Hosts the flyout navigation frame. +/// </summary> +public sealed partial class ShellPage : Page +{ + private LauncherViewModel? _launcherViewModel; + private AllAppsViewModel? _allAppsViewModel; + private IQuickAccessCoordinator? _coordinator; + + public ShellPage() + { + InitializeComponent(); + } + + public void Initialize(IQuickAccessCoordinator coordinator, LauncherViewModel launcherViewModel, AllAppsViewModel allAppsViewModel) + { + _coordinator = coordinator; + _launcherViewModel = launcherViewModel; + _allAppsViewModel = allAppsViewModel; + } + + private void Page_Loaded(object sender, RoutedEventArgs e) + { + if (_launcherViewModel == null || _allAppsViewModel == null || _coordinator == null) + { + return; + } + + if (ContentFrame.Content is LaunchPage) + { + return; + } + + var context = new FlyoutNavigationContext(_launcherViewModel, _allAppsViewModel, _coordinator); + ContentFrame.Navigate(typeof(LaunchPage), context, new SuppressNavigationTransitionInfo()); + } + + internal void NavigateToLaunch() + { + if (_launcherViewModel == null || _allAppsViewModel == null || _coordinator == null) + { + return; + } + + var context = new FlyoutNavigationContext(_launcherViewModel, _allAppsViewModel, _coordinator); + ContentFrame.Navigate(typeof(LaunchPage), context, new SlideNavigationTransitionInfo { Effect = SlideNavigationTransitionEffect.FromLeft }); + } + + internal void RefreshIfAppsList() + { + if (ContentFrame.Content is AppsListPage appsListPage) + { + appsListPage.ViewModel?.RefreshSettings(); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/FlyoutWindow.xaml b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/MainWindow.xaml similarity index 61% rename from src/settings-ui/Settings.UI/SettingsXAML/FlyoutWindow.xaml rename to src/settings-ui/QuickAccess.UI/QuickAccessXAML/MainWindow.xaml index d6a6f6f2f8..90db2afca7 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/FlyoutWindow.xaml +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/MainWindow.xaml @@ -1,22 +1,24 @@ -<winuiex:WindowEx - x:Class="Microsoft.PowerToys.Settings.UI.FlyoutWindow" +<winuiEx:WindowEx + x:Class="Microsoft.PowerToys.QuickAccess.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:flyout="using:Microsoft.PowerToys.Settings.UI.Flyout" - xmlns:local="using:Microsoft.PowerToys.Settings.UI" + xmlns:flyout="using:Microsoft.PowerToys.QuickAccess.Flyout" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:winuiex="using:WinUIEx" - Title="PowerToys Settings" + xmlns:winuiEx="using:WinUIEx" + Title="PowerToys Quick Access (Preview)" + Width="400" + Height="516" + MinWidth="400" + MinHeight="516" IsAlwaysOnTop="True" IsMaximizable="False" IsMinimizable="False" IsResizable="False" - IsShownInSwitchers="False" IsTitleBarVisible="False" mc:Ignorable="d"> - <winuiex:WindowEx.Backdrop> - <winuiex:AcrylicSystemBackdrop + <winuiEx:WindowEx.Backdrop> + <winuiEx:AcrylicSystemBackdrop DarkFallbackColor="#1c1c1c" DarkLuminosityOpacity="0.96" DarkTintColor="#202020" @@ -25,8 +27,9 @@ LightLuminosityOpacity="0.90" LightTintColor="#F3F3F3" LightTintOpacity="0" /> - </winuiex:WindowEx.Backdrop> + </winuiEx:WindowEx.Backdrop> + <Grid> - <flyout:ShellPage x:Name="FlyoutShellPage" /> + <flyout:ShellPage x:Name="ShellHost" /> </Grid> -</winuiex:WindowEx> +</winuiEx:WindowEx> diff --git a/src/settings-ui/QuickAccess.UI/QuickAccessXAML/MainWindow.xaml.cs b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/MainWindow.xaml.cs new file mode 100644 index 0000000000..00db257bae --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/QuickAccessXAML/MainWindow.xaml.cs @@ -0,0 +1,742 @@ +// 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.PowerToys.QuickAccess.Services; +using Microsoft.PowerToys.QuickAccess.ViewModels; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Windows.Graphics; +using WinRT.Interop; +using WinUIEx; + +namespace Microsoft.PowerToys.QuickAccess; + +public sealed partial class MainWindow : WindowEx, IDisposable +{ + private readonly QuickAccessLaunchContext _launchContext; + private readonly DispatcherQueue _dispatcherQueue; + private readonly IntPtr _hwnd; + private readonly AppWindow? _appWindow; + private readonly LauncherViewModel _launcherViewModel; + private readonly AllAppsViewModel _allAppsViewModel; + private readonly QuickAccessCoordinator _coordinator; + private bool _disposed; + private EventWaitHandle? _showEvent; + private EventWaitHandle? _exitEvent; + private ManualResetEventSlim? _listenerShutdownEvent; + private Thread? _showListenerThread; + private Thread? _exitListenerThread; + private bool _isWindowCloaked; + private bool _initialActivationHandled; + private bool _isPrimed; + + // Prevent auto-hide until the window actually gained focus once. + private bool _hasSeenInteractiveActivation; + private bool _isVisible; + private IntPtr _mouseHook; + private LowLevelMouseProc? _mouseHookDelegate; + private CancellationTokenSource? _trimCts; + + private const int DefaultWidth = 320; + private const int DefaultHeight = 480; + private const int DwmWaCloak = 13; + private const int GwlStyle = -16; + private const int GwlExStyle = -20; + private const int SwHide = 0; + private const int SwShow = 5; + private const int SwShowNoActivate = 8; + private const uint SwpShowWindow = 0x0040; + private const uint SwpNoZorder = 0x0004; + private const uint SwpNoSize = 0x0001; + private const uint SwpNoMove = 0x0002; + private const uint SwpNoActivate = 0x0010; + private const uint SwpFrameChanged = 0x0020; + private const long WsSysmenu = 0x00080000L; + private const long WsMinimizeBox = 0x00020000L; + private const long WsMaximizeBox = 0x00010000L; + private const long WsExToolWindow = 0x00000080L; + private const uint MonitorDefaulttonearest = 0x00000002; + private static readonly IntPtr HwndTopmost = new(-1); + private static readonly IntPtr HwndBottom = new(1); + + public MainWindow(QuickAccessLaunchContext launchContext) + { + InitializeComponent(); + _launchContext = launchContext; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _hwnd = WindowNative.GetWindowHandle(this); + _appWindow = InitializeAppWindow(_hwnd); + Title = "PowerToys Quick Access (Preview)"; + + _coordinator = new QuickAccessCoordinator(this, _launchContext); + _launcherViewModel = new LauncherViewModel(_coordinator); + _allAppsViewModel = new AllAppsViewModel(_coordinator); + ShellHost.Initialize(_coordinator, _launcherViewModel, _allAppsViewModel); + + CustomizeWindowChrome(); + HideFromTaskbar(); + HideWindow(); + InitializeEventListeners(); + Closed += OnClosed; + Activated += OnActivated; + } + + private AppWindow? InitializeAppWindow(IntPtr hwnd) + { + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(hwnd); + return AppWindow.GetFromWindowId(windowId); + } + + private void HideWindow() + { + if (_hwnd != IntPtr.Zero) + { + var cloaked = CloakWindow(); + + if (!ShowWindowNative(_hwnd, SwHide) && _appWindow != null) + { + _appWindow.Hide(); + } + + if (cloaked) + { + ShowWindowNative(_hwnd, SwShowNoActivate); + } + else + { + SetWindowPosNative(_hwnd, HwndBottom, 0, 0, 0, 0, SwpNoMove | SwpNoSize | SwpNoActivate); + } + } + else if (_appWindow != null) + { + _appWindow.Hide(); + } + + _isVisible = false; + RemoveGlobalMouseHook(); + + ScheduleMemoryTrim(); + } + + internal void RequestHide() + { + if (_dispatcherQueue.HasThreadAccess) + { + HideWindow(); + } + else + { + _dispatcherQueue.TryEnqueue(HideWindow); + } + } + + private void ScheduleMemoryTrim() + { + CancelMemoryTrim(); + _trimCts = new CancellationTokenSource(); + var token = _trimCts.Token; + + // Delay the trim to avoid aggressive GC during quick toggles + Task.Delay(2000, token).ContinueWith( + _ => + { + if (token.IsCancellationRequested) + { + return; + } + + TrimMemory(); + }, + token, + TaskContinuationOptions.None, + TaskScheduler.Default); + } + + private void CancelMemoryTrim() + { + _trimCts?.Cancel(); + _trimCts?.Dispose(); + _trimCts = null; + } + + private void TrimMemory() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + SetProcessWorkingSetSize(System.Diagnostics.Process.GetCurrentProcess().Handle, -1, -1); + } + + private void InitializeEventListeners() + { + if (!string.IsNullOrEmpty(_launchContext.ShowEventName)) + { + try + { + _showEvent = EventWaitHandle.OpenExisting(_launchContext.ShowEventName!); + EnsureListenerInfrastructure(); + StartShowListenerThread(); + } + catch (WaitHandleCannotBeOpenedException) + { + } + } + + if (!string.IsNullOrEmpty(_launchContext.ExitEventName)) + { + try + { + _exitEvent = EventWaitHandle.OpenExisting(_launchContext.ExitEventName!); + EnsureListenerInfrastructure(); + StartExitListenerThread(); + } + catch (WaitHandleCannotBeOpenedException) + { + } + } + } + + private void ShowWindow() + { + CancelMemoryTrim(); + + if (_hwnd != IntPtr.Zero) + { + UncloakWindow(); + + ShowWindowNative(_hwnd, SwShow); + + var flags = SwpNoSize | SwpShowWindow; + var targetX = 0; + var targetY = 0; + + var windowSize = _appWindow?.Size; + var windowWidth = windowSize?.Width ?? DefaultWidth; + var windowHeight = windowSize?.Height ?? DefaultHeight; + + GetCursorPos(out var cursorPosition); + var monitorHandle = MonitorFromPointNative(cursorPosition, MonitorDefaulttonearest); + if (monitorHandle != IntPtr.Zero) + { + var monitorInfo = new MonitorInfo { CbSize = Marshal.SizeOf<MonitorInfo>() }; + if (GetMonitorInfoNative(monitorHandle, ref monitorInfo)) + { + targetX = monitorInfo.RcWork.Right - windowWidth; + targetY = monitorInfo.RcWork.Bottom - windowHeight; + } + } + + SetWindowPosNative(_hwnd, HwndTopmost, targetX, targetY, 0, 0, flags); + WindowHelpers.BringToForeground(_hwnd); + } + + _hasSeenInteractiveActivation = true; + _initialActivationHandled = true; + Activate(); + _isVisible = true; + EnsureGlobalMouseHook(); + ShellHost.RefreshIfAppsList(); + } + + private void OnActivated(object sender, WindowActivatedEventArgs args) + { + if (args.WindowActivationState == WindowActivationState.Deactivated) + { + if (!_hasSeenInteractiveActivation) + { + return; + } + + HideWindow(); + return; + } + + _hasSeenInteractiveActivation = true; + + if (_initialActivationHandled) + { + return; + } + + _initialActivationHandled = true; + PrimeWindow(); + HideWindow(); + } + + private void OnClosed(object sender, WindowEventArgs e) + { + Dispose(); + } + + private void PrimeWindow() + { + if (_isPrimed || _hwnd == IntPtr.Zero) + { + return; + } + + _isPrimed = true; + + if (_appWindow != null) + { + var currentPosition = _appWindow.Position; + _appWindow.MoveAndResize(new RectInt32(currentPosition.X, currentPosition.Y, DefaultWidth, DefaultHeight)); + } + + // Warm up the window while cloaked so the first summon does not pay XAML initialization cost. + var cloaked = CloakWindow(); + if (cloaked) + { + ShowWindowNative(_hwnd, SwShowNoActivate); + } + } + + private void HideFromTaskbar() + { + if (_appWindow == null) + { + return; + } + + try + { + _appWindow.IsShownInSwitchers = false; + } + catch (NotImplementedException) + { + // WinUI Will throw if explorer is not running, safely ignore + } + catch (Exception) + { + } + } + + private bool CloakWindow() + { + if (_hwnd == IntPtr.Zero) + { + return false; + } + + if (_isWindowCloaked) + { + return true; + } + + int cloak = 1; + var result = DwmSetWindowAttribute(_hwnd, DwmWaCloak, ref cloak, sizeof(int)); + if (result == 0) + { + _isWindowCloaked = true; + SetWindowPosNative(_hwnd, HwndBottom, 0, 0, 0, 0, SwpNoMove | SwpNoSize | SwpNoActivate); + return true; + } + + return false; + } + + private void UncloakWindow() + { + if (_hwnd == IntPtr.Zero || !_isWindowCloaked) + { + return; + } + + int cloak = 0; + var result = DwmSetWindowAttribute(_hwnd, DwmWaCloak, ref cloak, sizeof(int)); + if (result == 0) + { + _isWindowCloaked = false; + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + if (disposing) + { + StopEventListeners(); + + _showEvent?.Dispose(); + _showEvent = null; + + _exitEvent?.Dispose(); + _exitEvent = null; + + if (_hwnd != IntPtr.Zero && IsWindow(_hwnd)) + { + UncloakWindow(); + } + + RemoveGlobalMouseHook(); + + _coordinator.Dispose(); + } + + _disposed = true; + } + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindow(IntPtr hWnd); + + [DllImport("user32.dll", EntryPoint = "ShowWindow", SetLastError = true)] + private static extern bool ShowWindowNative(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtr", SetLastError = true)] + private static extern nint GetWindowLongPtrNative(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr", SetLastError = true)] + private static extern nint SetWindowLongPtrNative(IntPtr hWnd, int nIndex, nint dwNewLong); + + [DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)] + private static extern bool SetWindowPosNative(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll", EntryPoint = "SetForegroundWindow", SetLastError = true)] + private static extern bool SetForegroundWindowNative(IntPtr hWnd); + + [DllImport("user32.dll", EntryPoint = "GetForegroundWindow", SetLastError = true)] + private static extern IntPtr GetForegroundWindowNative(); + + [DllImport("user32.dll", EntryPoint = "GetWindowThreadProcessId", SetLastError = true)] + private static extern uint GetWindowThreadProcessIdNative(IntPtr hWnd, IntPtr lpdwProcessId); + + [DllImport("user32.dll", EntryPoint = "AttachThreadInput", SetLastError = true)] + private static extern bool AttachThreadInputNative(uint idAttach, uint idAttachTo, bool fAttach); + + [DllImport("dwmapi.dll", EntryPoint = "DwmSetWindowAttribute", SetLastError = true)] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize); + + [DllImport("user32.dll", EntryPoint = "MonitorFromPoint", SetLastError = true)] + private static extern IntPtr MonitorFromPointNative(NativePoint pt, uint dwFlags); + + [DllImport("user32.dll", EntryPoint = "GetMonitorInfoW", SetLastError = true)] + private static extern bool GetMonitorInfoNative(IntPtr hMonitor, ref MonitorInfo lpmi); + + [DllImport("user32.dll", EntryPoint = "SetWindowsHookExW", SetLastError = true)] + private static extern IntPtr SetWindowsHookExNative(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId); + + [DllImport("user32.dll", EntryPoint = "UnhookWindowsHookEx", SetLastError = true)] + private static extern bool UnhookWindowsHookExNative(IntPtr hhk); + + [DllImport("user32.dll", EntryPoint = "CallNextHookEx", SetLastError = true)] + private static extern IntPtr CallNextHookExNative(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); + + [DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandleNative([MarshalAs(UnmanagedType.LPWStr)] string? lpModuleName); + + [DllImport("user32.dll", EntryPoint = "GetWindowRect", SetLastError = true)] + private static extern bool GetWindowRectNative(IntPtr hWnd, out Rect rect); + + private void EnsureGlobalMouseHook() + { + if (_mouseHook != IntPtr.Zero) + { + return; + } + + _mouseHookDelegate ??= LowLevelMouseHookCallback; + var moduleHandle = GetModuleHandleNative(null); + _mouseHook = SetWindowsHookExNative(WhMouseLl, _mouseHookDelegate, moduleHandle, 0); + } + + private void RemoveGlobalMouseHook() + { + if (_mouseHook == IntPtr.Zero) + { + return; + } + + UnhookWindowsHookExNative(_mouseHook); + _mouseHook = IntPtr.Zero; + } + + private IntPtr LowLevelMouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam) + { + if (nCode >= 0 && _isVisible && lParam != IntPtr.Zero && IsMouseButtonDownMessage(wParam)) + { + var data = Marshal.PtrToStructure<LowLevelMouseInput>(lParam); + if (!IsPointInsideWindow(data.Point)) + { + _dispatcherQueue.TryEnqueue(() => + { + if (_isVisible) + { + HideWindow(); + } + }); + } + } + + return CallNextHookExNative(_mouseHook, nCode, wParam, lParam); + } + + private static bool IsMouseButtonDownMessage(IntPtr wParam) + { + var message = wParam.ToInt32(); + return message == WmLbuttondown || message == WmRbuttondown || message == WmMbuttondown || message == WmXbuttondown; + } + + private bool IsPointInsideWindow(NativePoint point) + { + if (_hwnd == IntPtr.Zero) + { + return false; + } + + if (!GetWindowRectNative(_hwnd, out var rect)) + { + return false; + } + + return point.X >= rect.Left && point.X <= rect.Right && point.Y >= rect.Top && point.Y <= rect.Bottom; + } + + private void EnsureListenerInfrastructure() + { + _listenerShutdownEvent ??= new ManualResetEventSlim(false); + } + + private void StartShowListenerThread() + { + if (_showEvent == null || _listenerShutdownEvent == null || _showListenerThread != null) + { + return; + } + + _showListenerThread = new Thread(ListenForShowEvents) + { + IsBackground = true, + Name = "QuickAccess-ShowEventListener", + }; + _showListenerThread.Start(); + } + + private void StartExitListenerThread() + { + if (_exitEvent == null || _listenerShutdownEvent == null || _exitListenerThread != null) + { + return; + } + + _exitListenerThread = new Thread(ListenForExitEvents) + { + IsBackground = true, + Name = "QuickAccess-ExitEventListener", + }; + _exitListenerThread.Start(); + } + + private void ListenForShowEvents() + { + if (_showEvent == null || _listenerShutdownEvent == null) + { + return; + } + + var handles = new WaitHandle[] { _showEvent, _listenerShutdownEvent.WaitHandle }; + try + { + while (true) + { + var index = WaitHandle.WaitAny(handles); + if (index == 0) + { + _dispatcherQueue.TryEnqueue(ShowWindow); + } + else + { + break; + } + } + } + catch (ObjectDisposedException) + { + } + catch (ThreadInterruptedException) + { + } + } + + private void ListenForExitEvents() + { + if (_exitEvent == null || _listenerShutdownEvent == null) + { + return; + } + + var handles = new WaitHandle[] { _exitEvent, _listenerShutdownEvent.WaitHandle }; + try + { + while (true) + { + var index = WaitHandle.WaitAny(handles); + if (index == 0) + { + _dispatcherQueue.TryEnqueue(Close); + break; + } + + if (index == 1) + { + break; + } + } + } + catch (ObjectDisposedException) + { + } + catch (ThreadInterruptedException) + { + } + } + + private void StopEventListeners() + { + if (_listenerShutdownEvent == null) + { + return; + } + + _listenerShutdownEvent.Set(); + + JoinListenerThread(ref _showListenerThread); + JoinListenerThread(ref _exitListenerThread); + + _listenerShutdownEvent.Dispose(); + _listenerShutdownEvent = null; + } + + private static void JoinListenerThread(ref Thread? thread) + { + if (thread == null) + { + return; + } + + try + { + if (!thread.Join(TimeSpan.FromMilliseconds(250))) + { + thread.Interrupt(); + thread.Join(TimeSpan.FromMilliseconds(250)); + } + } + catch (ThreadInterruptedException) + { + } + catch (ThreadStateException) + { + } + + thread = null; + } + + private void CustomizeWindowChrome() + { + if (_hwnd == IntPtr.Zero) + { + return; + } + + var windowAttributesChanged = false; + + var stylePtr = GetWindowLongPtrNative(_hwnd, GwlStyle); + var styleError = Marshal.GetLastWin32Error(); + if (!(stylePtr == nint.Zero && styleError != 0)) + { + var styleValue = (long)stylePtr; + var newStyleValue = styleValue & ~(WsSysmenu | WsMinimizeBox | WsMaximizeBox); + + if (newStyleValue != styleValue) + { + SetWindowLongPtrNative(_hwnd, GwlStyle, (nint)newStyleValue); + windowAttributesChanged = true; + } + } + + var exStylePtr = GetWindowLongPtrNative(_hwnd, GwlExStyle); + var exStyleError = Marshal.GetLastWin32Error(); + if (!(exStylePtr == nint.Zero && exStyleError != 0)) + { + var exStyleValue = (long)exStylePtr; + var newExStyleValue = exStyleValue | WsExToolWindow; + if (newExStyleValue != exStyleValue) + { + SetWindowLongPtrNative(_hwnd, GwlExStyle, (nint)newExStyleValue); + windowAttributesChanged = true; + } + } + + if (windowAttributesChanged) + { + // Apply the new chrome immediately so caption buttons disappear right away and the tool-window flag takes effect. + SetWindowPosNative(_hwnd, IntPtr.Zero, 0, 0, 0, 0, SwpNoMove | SwpNoSize | SwpNoZorder | SwpNoActivate | SwpFrameChanged); + } + } + + private const int WhMouseLl = 14; + private const int WmLbuttondown = 0x0201; + private const int WmRbuttondown = 0x0204; + private const int WmMbuttondown = 0x0207; + + [DllImport("user32.dll")] + private static extern bool GetCursorPos(out NativePoint lpPoint); + + [DllImport("kernel32.dll")] + private static extern bool SetProcessWorkingSetSize(IntPtr hProcess, int dwMinimumWorkingSetSize, int dwMaximumWorkingSetSize); + + private const int WmXbuttondown = 0x020B; + + private delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam); + + private struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct LowLevelMouseInput + { + public NativePoint Point; + public int MouseData; + public int Flags; + public int Time; + public IntPtr DwExtraInfo; + } + + private struct NativePoint + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MonitorInfo + { + public int CbSize; + public Rect RcMonitor; + public Rect RcWork; + public uint DwFlags; + } +} diff --git a/src/settings-ui/QuickAccess.UI/Services/IQuickAccessCoordinator.cs b/src/settings-ui/QuickAccess.UI/Services/IQuickAccessCoordinator.cs new file mode 100644 index 0000000000..c9a4c5d77f --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/Services/IQuickAccessCoordinator.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.Threading.Tasks; + +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace Microsoft.PowerToys.QuickAccess.Services; + +public interface IQuickAccessCoordinator +{ + bool IsRunnerElevated { get; } + + void HideFlyout(); + + void OpenSettings(); + + void OpenGeneralSettingsForUpdates(); + + Task<bool> ShowDocumentationAsync(); + + bool UpdateModuleEnabled(ModuleType moduleType, bool isEnabled); + + void SendSortOrderUpdate(GeneralSettings generalSettings); + + void ReportBug(); + + void OnModuleLaunched(ModuleType moduleType); +} diff --git a/src/settings-ui/QuickAccess.UI/Services/QuickAccessCoordinator.cs b/src/settings-ui/QuickAccess.UI/Services/QuickAccessCoordinator.cs new file mode 100644 index 0000000000..0e25630d54 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/Services/QuickAccessCoordinator.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Common.UI; +using ManagedCommon; +using Microsoft.PowerToys.QuickAccess.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using PowerToys.Interop; + +namespace Microsoft.PowerToys.QuickAccess.Services; + +internal sealed class QuickAccessCoordinator : IQuickAccessCoordinator, IDisposable +{ + private readonly MainWindow _window; + private readonly QuickAccessLaunchContext _launchContext; + private readonly SettingsUtils _settingsUtils = SettingsUtils.Default; + private readonly object _generalSettingsLock = new(); + private readonly object _ipcLock = new(); + private TwoWayPipeMessageIPCManaged? _ipcManager; + private bool _ipcUnavailableLogged; + + public QuickAccessCoordinator(MainWindow window, QuickAccessLaunchContext launchContext) + { + _window = window; + _launchContext = launchContext; + InitializeIpc(); + } + + public bool IsRunnerElevated => false; // TODO: wire up real elevation state. + + public void HideFlyout() + { + _window.RequestHide(); + } + + public void OpenSettings() + { + Common.UI.SettingsDeepLink.OpenSettings(Common.UI.SettingsDeepLink.SettingsWindow.Dashboard); + _window.RequestHide(); + } + + public void OpenGeneralSettingsForUpdates() + { + Common.UI.SettingsDeepLink.OpenSettings(Common.UI.SettingsDeepLink.SettingsWindow.Overview); + _window.RequestHide(); + } + + public Task<bool> ShowDocumentationAsync() + { + Logger.LogInfo("QuickAccessCoordinator.ShowDocumentationAsync is not yet connected."); + return Task.FromResult(false); + } + + public bool UpdateModuleEnabled(ModuleType moduleType, bool isEnabled) + => TrySendIpcMessage($"{{\"module_status\": {{\"{ModuleHelper.GetModuleKey(moduleType)}\": {isEnabled.ToString().ToLowerInvariant()}}}}}", "module status update"); + + public void ReportBug() + { + if (!TrySendIpcMessage("{\"bugreport\": 0 }", "bug report request")) + { + Logger.LogWarning("QuickAccessCoordinator: failed to dispatch bug report request; IPC unavailable."); + } + } + + public void OnModuleLaunched(ModuleType moduleType) + { + Logger.LogInfo($"QuickAccessLauncher invoked module {moduleType}."); + } + + public void Dispose() + { + DisposeIpc(); + } + + private void InitializeIpc() + { + if (string.IsNullOrEmpty(_launchContext.RunnerPipeName) || string.IsNullOrEmpty(_launchContext.AppPipeName)) + { + Logger.LogWarning("QuickAccessCoordinator: IPC pipe names not provided. Runner will not receive updates."); + return; + } + + try + { + _ipcManager = new TwoWayPipeMessageIPCManaged(_launchContext.AppPipeName, _launchContext.RunnerPipeName, OnIpcMessageReceived); + _ipcManager.Start(); + _ipcUnavailableLogged = false; + } + catch (Exception ex) + { + Logger.LogError("QuickAccessCoordinator: failed to start IPC channel to runner.", ex); + DisposeIpc(); + } + } + + private void OnIpcMessageReceived(string message) + { + Logger.LogDebug($"QuickAccessCoordinator received IPC payload: {message}"); + } + + public void SendSortOrderUpdate(GeneralSettings generalSettings) + { + var outgoing = new OutGoingGeneralSettings(generalSettings); + TrySendIpcMessage(outgoing.ToString(), "sort order update"); + } + + private bool TrySendIpcMessage(string payload, string operationDescription) + { + lock (_ipcLock) + { + if (_ipcManager == null) + { + if (!_ipcUnavailableLogged) + { + _ipcUnavailableLogged = true; + Logger.LogWarning($"QuickAccessCoordinator: unable to send {operationDescription} because IPC is not available."); + } + + return false; + } + + try + { + _ipcManager.Send(payload); + return true; + } + catch (Exception ex) + { + Logger.LogError($"QuickAccessCoordinator: failed to send {operationDescription}.", ex); + return false; + } + } + } + + private void DisposeIpc() + { + lock (_ipcLock) + { + if (_ipcManager == null) + { + return; + } + + try + { + _ipcManager.End(); + } + catch (Exception ex) + { + Logger.LogWarning($"QuickAccessCoordinator: exception while shutting down IPC. {ex.Message}"); + } + + _ipcManager.Dispose(); + _ipcManager = null; + _ipcUnavailableLogged = false; + } + } +} diff --git a/src/settings-ui/QuickAccess.UI/Services/QuickAccessLauncher.cs b/src/settings-ui/QuickAccess.UI/Services/QuickAccessLauncher.cs new file mode 100644 index 0000000000..fcf88fd26f --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/Services/QuickAccessLauncher.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Controls; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerToys.Interop; + +namespace Microsoft.PowerToys.QuickAccess.Services +{ + public class QuickAccessLauncher : Microsoft.PowerToys.Settings.UI.Controls.QuickAccessLauncher + { + private readonly IQuickAccessCoordinator? _coordinator; + + public QuickAccessLauncher(IQuickAccessCoordinator? coordinator) + : base(coordinator?.IsRunnerElevated ?? false) + { + _coordinator = coordinator; + } + + public override bool Launch(ModuleType moduleType) + { + bool moduleRun = base.Launch(moduleType); + + if (moduleRun) + { + _coordinator?.OnModuleLaunched(moduleType); + } + + _coordinator?.HideFlyout(); + + return moduleRun; + } + } +} diff --git a/src/settings-ui/QuickAccess.UI/ViewModels/AllAppsViewModel.cs b/src/settings-ui/QuickAccess.UI/ViewModels/AllAppsViewModel.cs new file mode 100644 index 0000000000..eb00296263 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/ViewModels/AllAppsViewModel.cs @@ -0,0 +1,194 @@ +// 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 global::PowerToys.GPOWrapper; +using ManagedCommon; +using Microsoft.PowerToys.QuickAccess.Helpers; +using Microsoft.PowerToys.QuickAccess.Services; +using Microsoft.PowerToys.Settings.UI.Controls; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands; +using Microsoft.UI.Dispatching; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace Microsoft.PowerToys.QuickAccess.ViewModels; + +public sealed class AllAppsViewModel : Observable +{ + private readonly object _sortLock = new object(); + private readonly IQuickAccessCoordinator _coordinator; + private readonly ISettingsRepository<GeneralSettings> _settingsRepository; + private readonly SettingsUtils _settingsUtils; + private readonly ResourceLoader _resourceLoader; + private readonly DispatcherQueue _dispatcherQueue; + private readonly List<FlyoutMenuItem> _allFlyoutMenuItems = new(); + private GeneralSettings _generalSettings; + + // Flag to prevent toggle operations during sorting to avoid race conditions. + private bool _isSorting; + + public ObservableCollection<FlyoutMenuItem> FlyoutMenuItems { get; } + + public DashboardSortOrder DashboardSortOrder + { + get => _generalSettings.DashboardSortOrder; + set + { + if (_generalSettings.DashboardSortOrder != value) + { + _generalSettings.DashboardSortOrder = value; + _coordinator.SendSortOrderUpdate(_generalSettings); + OnPropertyChanged(); + SortFlyoutMenuItems(); + } + } + } + + public AllAppsViewModel(IQuickAccessCoordinator coordinator) + { + _coordinator = coordinator; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _settingsUtils = SettingsUtils.Default; + _settingsRepository = SettingsRepository<GeneralSettings>.GetInstance(_settingsUtils); + _generalSettings = _settingsRepository.SettingsConfig; + _settingsRepository.SettingsChanged += OnSettingsChanged; + + _resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; + FlyoutMenuItems = new ObservableCollection<FlyoutMenuItem>(); + + BuildFlyoutMenuItems(); + RefreshFlyoutMenuItems(); + } + + private void BuildFlyoutMenuItems() + { + _allFlyoutMenuItems.Clear(); + foreach (ModuleType moduleType in Enum.GetValues<ModuleType>()) + { + if (moduleType == ModuleType.GeneralSettings) + { + continue; + } + + _allFlyoutMenuItems.Add(new FlyoutMenuItem + { + Tag = moduleType, + EnabledChangedCallback = EnabledChangedOnUI, + }); + } + } + + private void OnSettingsChanged(GeneralSettings newSettings) + { + _dispatcherQueue.TryEnqueue(() => + { + _generalSettings = newSettings; + OnPropertyChanged(nameof(DashboardSortOrder)); + RefreshFlyoutMenuItems(); + }); + } + + public void RefreshSettings() + { + if (_settingsRepository.ReloadSettings()) + { + OnSettingsChanged(_settingsRepository.SettingsConfig); + } + } + + private void RefreshFlyoutMenuItems() + { + foreach (var item in _allFlyoutMenuItems) + { + var moduleType = item.Tag; + var gpo = Helpers.ModuleGpoHelper.GetModuleGpoConfiguration(moduleType); + var isLocked = gpo is GpoRuleConfigured.Enabled or GpoRuleConfigured.Disabled; + var isEnabled = gpo == GpoRuleConfigured.Enabled || (!isLocked && Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetIsModuleEnabled(_generalSettings, moduleType)); + + item.Label = _resourceLoader.GetString(Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetModuleLabelResourceName(moduleType)); + item.IsLocked = isLocked; + item.Icon = Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetModuleTypeFluentIconName(moduleType); + + if (item.IsEnabled != isEnabled) + { + item.UpdateStatus(isEnabled); + } + } + + SortFlyoutMenuItems(); + } + + private void SortFlyoutMenuItems() + { + if (_isSorting) + { + return; + } + + lock (_sortLock) + { + _isSorting = true; + try + { + var sortedItems = DashboardSortOrder switch + { + DashboardSortOrder.ByStatus => _allFlyoutMenuItems.OrderByDescending(x => x.IsEnabled).ThenBy(x => x.Label).ToList(), + _ => _allFlyoutMenuItems.OrderBy(x => x.Label).ToList(), + }; + + if (FlyoutMenuItems.Count == 0) + { + foreach (var item in sortedItems) + { + FlyoutMenuItems.Add(item); + } + + return; + } + + for (int i = 0; i < sortedItems.Count; i++) + { + var item = sortedItems[i]; + var oldIndex = FlyoutMenuItems.IndexOf(item); + + if (oldIndex != -1 && oldIndex != i) + { + FlyoutMenuItems.Move(oldIndex, i); + } + } + } + finally + { + // Use dispatcher to reset flag after UI updates complete + _dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => + { + _isSorting = false; + }); + } + } + } + + private void EnabledChangedOnUI(ModuleListItem item) + { + var flyoutItem = (FlyoutMenuItem)item; + var isEnabled = flyoutItem.IsEnabled; + + // Ignore toggle operations during sorting to prevent race conditions. + // Revert the toggle state since UI already changed due to TwoWay binding. + if (_isSorting) + { + flyoutItem.UpdateStatus(!isEnabled); + return; + } + + _coordinator.UpdateModuleEnabled(flyoutItem.Tag, flyoutItem.IsEnabled); + SortFlyoutMenuItems(); + } +} diff --git a/src/settings-ui/QuickAccess.UI/ViewModels/FlyoutMenuItem.cs b/src/settings-ui/QuickAccess.UI/ViewModels/FlyoutMenuItem.cs new file mode 100644 index 0000000000..b0ce5a9512 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/ViewModels/FlyoutMenuItem.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Controls; + +namespace Microsoft.PowerToys.QuickAccess.ViewModels; + +public sealed class FlyoutMenuItem : ModuleListItem +{ + private bool _visible; + + public string ToolTip { get; set; } = string.Empty; + + public new ModuleType Tag + { + get => (ModuleType)(base.Tag ?? ModuleType.PowerLauncher); + set => base.Tag = value; + } + + public bool Visible + { + get => _visible; + set + { + if (_visible != value) + { + _visible = value; + OnPropertyChanged(); + } + } + } +} diff --git a/src/settings-ui/QuickAccess.UI/ViewModels/LauncherViewModel.cs b/src/settings-ui/QuickAccess.UI/ViewModels/LauncherViewModel.cs new file mode 100644 index 0000000000..afc6522332 --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/ViewModels/LauncherViewModel.cs @@ -0,0 +1,47 @@ +// 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.ObjectModel; +using global::PowerToys.GPOWrapper; +using ManagedCommon; +using Microsoft.PowerToys.QuickAccess.Services; +using Microsoft.PowerToys.Settings.UI.Controls; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.UI.Dispatching; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace Microsoft.PowerToys.QuickAccess.ViewModels; + +public sealed class LauncherViewModel : Observable +{ + private readonly IQuickAccessCoordinator _coordinator; + private readonly ISettingsRepository<GeneralSettings> _settingsRepository; + private readonly ResourceLoader _resourceLoader; + private readonly DispatcherQueue _dispatcherQueue; + private readonly QuickAccessViewModel _quickAccessViewModel; + + public ObservableCollection<QuickAccessItem> FlyoutMenuItems => _quickAccessViewModel.Items; + + public bool IsUpdateAvailable { get; private set; } + + public LauncherViewModel(IQuickAccessCoordinator coordinator) + { + _coordinator = coordinator; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + var settingsUtils = SettingsUtils.Default; + _settingsRepository = SettingsRepository<GeneralSettings>.GetInstance(settingsUtils); + + _resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; + + _quickAccessViewModel = new QuickAccessViewModel( + _settingsRepository, + new Microsoft.PowerToys.QuickAccess.Services.QuickAccessLauncher(_coordinator), + moduleType => Helpers.ModuleGpoHelper.GetModuleGpoConfiguration(moduleType) == GpoRuleConfigured.Disabled, + _resourceLoader); + var updatingSettings = UpdatingSettings.LoadSettings() ?? new UpdatingSettings(); + IsUpdateAvailable = updatingSettings.State is UpdatingSettings.UpdatingState.ReadyToInstall or UpdatingSettings.UpdatingState.ReadyToDownload; + } +} diff --git a/src/settings-ui/QuickAccess.UI/app.manifest b/src/settings-ui/QuickAccess.UI/app.manifest new file mode 100644 index 0000000000..52dd9cc7fe --- /dev/null +++ b/src/settings-ui/QuickAccess.UI/app.manifest @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"> + <assemblyIdentity version="1.0.0.0" name="PowerToys.QuickAccess.app" /> + + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> + </windowsSettings> + </application> + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> + </application> + </compatibility> +</assembly> diff --git a/src/settings-ui/Settings.UI.Controls/Converters/EnumToBooleanConverter.cs b/src/settings-ui/Settings.UI.Controls/Converters/EnumToBooleanConverter.cs new file mode 100644 index 0000000000..0bf1eb260f --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/Converters/EnumToBooleanConverter.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 Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Controls.Converters +{ + public partial class EnumToBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value == null || parameter == null) + { + return false; + } + + // Get the enum value as string + var enumString = value.ToString(); + var parameterString = parameter.ToString(); + + return enumString!.Equals(parameterString, StringComparison.OrdinalIgnoreCase); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI.Controls/Converters/ModuleListSortOptionToBooleanConverter.cs b/src/settings-ui/Settings.UI.Controls/Converters/ModuleListSortOptionToBooleanConverter.cs new file mode 100644 index 0000000000..2c015f0b92 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/Converters/ModuleListSortOptionToBooleanConverter.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Controls.Converters +{ + public partial class ModuleListSortOptionToBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is ModuleListSortOption sortOption && parameter is string paramString) + { + if (Enum.TryParse(typeof(ModuleListSortOption), paramString, out object? result) && result != null) + { + return sortOption == (ModuleListSortOption)result; + } + } + + return false; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is bool isChecked && isChecked && parameter is string paramString) + { + if (Enum.TryParse(typeof(ModuleListSortOption), paramString, out object? result) && result != null) + { + return (ModuleListSortOption)result; + } + } + + return ModuleListSortOption.Alphabetical; + } + } +} diff --git a/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleList.xaml b/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleList.xaml new file mode 100644 index 0000000000..c465378ce8 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleList.xaml @@ -0,0 +1,107 @@ +<UserControl + x:Class="Microsoft.PowerToys.Settings.UI.Controls.ModuleList" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Controls.Converters" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tk="using:CommunityToolkit.WinUI" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" + mc:Ignorable="d"> + + <UserControl.Resources> + <converters:ModuleListSortOptionToBooleanConverter x:Key="ModuleListSortOptionToBooleanConverter" /> + <x:Double x:Key="SettingsCardWrapThreshold">0</x:Double> + <tkconverters:BoolToVisibilityConverter + x:Key="BoolToVisibilityConverter" + FalseValue="Collapsed" + TrueValue="Visible" /> + <tkconverters:BoolToVisibilityConverter + x:Key="ReverseBoolToVisibilityConverter" + FalseValue="Visible" + TrueValue="Collapsed" /> + <tkconverters:BoolNegationConverter x:Key="BoolNegationConverter" /> + + <Style x:Key="NewInfoBadgeStyle" TargetType="InfoBadge"> + <Setter Property="Padding" Value="5,1,5,2" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="InfoBadge"> + <Border + x:Name="RootGrid" + Padding="{TemplateBinding Padding}" + Background="{TemplateBinding Background}" + CornerRadius="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.InfoBadgeCornerRadius}"> + <TextBlock + x:Uid="NewInfoBadge" + HorizontalAlignment="Center" + VerticalAlignment="Center" + FontSize="10" /> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + </UserControl.Resources> + <ItemsRepeater x:Name="DashboardView" ItemsSource="{x:Bind ItemsSource, Mode=OneWay}"> + <ItemsRepeater.Layout> + <StackLayout Orientation="Vertical" Spacing="0" /> + </ItemsRepeater.Layout> + <ItemsRepeater.ItemTemplate> + <DataTemplate x:DataType="controls:ModuleListItem"> + <tkcontrols:SettingsCard + MinWidth="0" + MinHeight="0" + Padding="12,4,12,4" + tk:FrameworkElementExtensions.AncestorType="controls:ModuleList" + Background="Transparent" + BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" + BorderThickness="{Binding (tk:FrameworkElementExtensions.Ancestor).DividerThickness, RelativeSource={RelativeSource Self}}" + Click="OnSettingsCardClick" + CornerRadius="0" + IsClickEnabled="{Binding (tk:FrameworkElementExtensions.Ancestor).IsItemClickable, RelativeSource={RelativeSource Self}}" + Tag="{x:Bind}"> + <tkcontrols:SettingsCard.Header> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="{x:Bind Label, Mode=OneWay}" /> + <!-- InfoBadge --> + <InfoBadge + x:Name="NewInfoBadge" + Margin="4,0,0,0" + Style="{StaticResource NewInfoBadgeStyle}" + Visibility="{x:Bind IsNew, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> + <FontIcon + Width="20" + Margin="4,0,0,0" + FontSize="16" + Glyph="" + Visibility="{x:Bind IsLocked, Converter={StaticResource ReverseBoolToVisibilityConverter}, ConverterParameter=True}"> + <ToolTipService.ToolTip> + <TextBlock x:Uid="GPOWarning" TextWrapping="WrapWholeWords" /> + </ToolTipService.ToolTip> + </FontIcon> + </StackPanel> + </tkcontrols:SettingsCard.Header> + + <tkcontrols:SettingsCard.HeaderIcon> + <ImageIcon> + <ImageIcon.Source> + <BitmapImage UriSource="{x:Bind Icon, Mode=OneWay}" /> + </ImageIcon.Source> + </ImageIcon> + </tkcontrols:SettingsCard.HeaderIcon> + + <ToggleSwitch + HorizontalAlignment="Right" + AutomationProperties.Name="{x:Bind Label, Mode=OneWay}" + IsEnabled="{x:Bind IsLocked, Converter={StaticResource BoolNegationConverter}, ConverterParameter=True, Mode=OneWay}" + IsOn="{x:Bind IsEnabled, Mode=TwoWay}" + OffContent="" + OnContent="" /> + </tkcontrols:SettingsCard> + </DataTemplate> + </ItemsRepeater.ItemTemplate> + </ItemsRepeater> +</UserControl> diff --git a/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleList.xaml.cs b/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleList.xaml.cs new file mode 100644 index 0000000000..f9d36a7e69 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleList.xaml.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class ModuleList : UserControl + { + public ModuleList() + { + this.InitializeComponent(); + } + + public Thickness DividerThickness + { + get => (Thickness)GetValue(DividerThicknessProperty); + set => SetValue(DividerThicknessProperty, value); + } + + public static readonly DependencyProperty DividerThicknessProperty = DependencyProperty.Register(nameof(DividerThickness), typeof(Thickness), typeof(ModuleList), new PropertyMetadata(new Thickness(0, 1, 0, 0))); + + public bool IsItemClickable + { + get => (bool)GetValue(IsItemClickableProperty); + set => SetValue(IsItemClickableProperty, value); + } + + public static readonly DependencyProperty IsItemClickableProperty = DependencyProperty.Register(nameof(IsItemClickable), typeof(bool), typeof(ModuleList), new PropertyMetadata(true)); + + public object ItemsSource + { + get => (object)GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register(nameof(ItemsSource), typeof(object), typeof(ModuleList), new PropertyMetadata(null)); + + public ModuleListSortOption SortOption + { + get => (ModuleListSortOption)GetValue(SortOptionProperty); + set => SetValue(SortOptionProperty, value); + } + + public static readonly DependencyProperty SortOptionProperty = DependencyProperty.Register(nameof(SortOption), typeof(ModuleListSortOption), typeof(ModuleList), new PropertyMetadata(ModuleListSortOption.Alphabetical)); + + private void OnSettingsCardClick(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement element && element.Tag is ModuleListItem item) + { + item.ClickCommand?.Execute(item.Tag); + } + } + } +} diff --git a/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleListItem.cs b/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleListItem.cs new file mode 100644 index 0000000000..3b6bf6a0bb --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleListItem.cs @@ -0,0 +1,141 @@ +// 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; // For Action +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public class ModuleListItem : INotifyPropertyChanged + { + private bool _isEnabled; + private string _label = string.Empty; + private string _icon = string.Empty; + private bool _isNew; + private bool _isLocked; + private object? _tag; + private ICommand? _clickCommand; + private bool _isUpdating; + + public Action<ModuleListItem>? EnabledChangedCallback { get; set; } + + public void UpdateStatus(bool isEnabled) + { + _isUpdating = true; + try + { + IsEnabled = isEnabled; + } + finally + { + _isUpdating = false; + } + } + + public virtual string Label + { + get => _label; + set + { + if (_label != value) + { + _label = value; + OnPropertyChanged(); + } + } + } + + public virtual string Icon + { + get => _icon; + set + { + if (_icon != value) + { + _icon = value; + OnPropertyChanged(); + } + } + } + + public virtual bool IsNew + { + get => _isNew; + set + { + if (_isNew != value) + { + _isNew = value; + OnPropertyChanged(); + } + } + } + + public virtual bool IsLocked + { + get => _isLocked; + set + { + if (_isLocked != value) + { + _isLocked = value; + OnPropertyChanged(); + } + } + } + + public virtual bool IsEnabled + { + get => _isEnabled; + set + { + if (_isEnabled != value) + { + _isEnabled = value; + OnPropertyChanged(); + + if (!_isUpdating) + { + EnabledChangedCallback?.Invoke(this); + } + } + } + } + + public virtual object? Tag + { + get => _tag; + set + { + if (_tag != value) + { + _tag = value; + OnPropertyChanged(); + } + } + } + + public virtual ICommand? ClickCommand + { + get => _clickCommand; + set + { + if (_clickCommand != value) + { + _clickCommand = value; + OnPropertyChanged(); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleListSortOption.cs b/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleListSortOption.cs new file mode 100644 index 0000000000..233c891d6b --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/ModuleList/ModuleListSortOption.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.PowerToys.Settings.UI.Controls +{ + public enum ModuleListSortOption + { + Alphabetical, + ByStatus, + } +} diff --git a/src/settings-ui/Settings.UI.Controls/Primitives/Card.xaml b/src/settings-ui/Settings.UI.Controls/Primitives/Card.xaml new file mode 100644 index 0000000000..508d94b7ca --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/Primitives/Card.xaml @@ -0,0 +1,71 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.PowerToys.Settings.UI.Controls.Card" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + Padding="8" + HorizontalContentAlignment="Stretch" + VerticalContentAlignment="Stretch" + Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="{StaticResource OverlayCornerRadius}" + mc:Ignorable="d"> + <Grid + VerticalAlignment="{x:Bind VerticalContentAlignment, Mode=OneWay}" + Background="{x:Bind Background, Mode=OneWay}" + BorderBrush="{x:Bind BorderBrush, Mode=OneWay}" + BorderThickness="{x:Bind BorderThickness, Mode=OneWay}" + CornerRadius="{x:Bind CornerRadius, Mode=OneWay}"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <Grid x:Name="TitleGrid" MinHeight="44"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <TextBlock + Margin="16,0,0,0" + VerticalAlignment="Center" + AutomationProperties.HeadingLevel="Level2" + FontSize="16" + FontWeight="SemiBold" + Text="{x:Bind Title, Mode=OneWay}" /> + <ContentPresenter + Grid.Column="2" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Content="{x:Bind TitleContent, Mode=OneWay}" /> + </Grid> + <Rectangle + x:Name="Divider" + Grid.Row="1" + Height="1" + HorizontalAlignment="Stretch" + Fill="{ThemeResource DividerStrokeColorDefaultBrush}" + Visibility="{x:Bind DividerVisibility, Mode=OneWay}" /> + + <ContentPresenter + Grid.Row="2" + Margin="{x:Bind Padding, Mode=OneWay}" + HorizontalAlignment="{x:Bind HorizontalContentAlignment, Mode=OneWay}" + VerticalAlignment="{x:Bind VerticalContentAlignment, Mode=OneWay}" + Content="{x:Bind Content, Mode=OneWay}" /> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="TitleGridVisibilityStates"> + <VisualState x:Name="TitleGridVisible" /> + <VisualState x:Name="TitleGridCollapsed"> + <VisualState.Setters> + <Setter Target="TitleGrid.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> +</UserControl> diff --git a/src/settings-ui/Settings.UI.Controls/Primitives/Card.xaml.cs b/src/settings-ui/Settings.UI.Controls/Primitives/Card.xaml.cs new file mode 100644 index 0000000000..ebf04c4e89 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/Primitives/Card.xaml.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class Card : UserControl + { + public static readonly DependencyProperty TitleContentProperty = DependencyProperty.Register(nameof(TitleContent), typeof(object), typeof(Card), new PropertyMetadata(defaultValue: null, OnVisualPropertyChanged)); + + public object? TitleContent + { + get => (object?)GetValue(TitleContentProperty); + set => SetValue(TitleContentProperty, value); + } + + public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(nameof(Title), typeof(string), typeof(Card), new PropertyMetadata(defaultValue: null, OnVisualPropertyChanged)); + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public static new readonly DependencyProperty ContentProperty = DependencyProperty.Register(nameof(Content), typeof(object), typeof(Card), new PropertyMetadata(defaultValue: null)); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1061:Do not hide base class methods", Justification = "We need to hide the base class method")] + public new object Content + { + get => (object)GetValue(ContentProperty); + set => SetValue(ContentProperty, value); + } + + public static readonly DependencyProperty DividerVisibilityProperty = DependencyProperty.Register(nameof(DividerVisibility), typeof(Visibility), typeof(Card), new PropertyMetadata(defaultValue: Visibility.Visible)); + + public Visibility DividerVisibility + { + get => (Visibility)GetValue(DividerVisibilityProperty); + set => SetValue(DividerVisibilityProperty, value); + } + + public Card() + { + InitializeComponent(); + SetVisualStates(); + } + + private static void OnVisualPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is Card card) + { + card.SetVisualStates(); + } + } + + private void SetVisualStates() + { + if (string.IsNullOrEmpty(Title) && TitleContent == null) + { + VisualStateManager.GoToState(this, "TitleGridCollapsed", true); + DividerVisibility = Visibility.Collapsed; + } + else + { + VisualStateManager.GoToState(this, "TitleGridVisible", true); + } + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/FlyoutMenuButton/FlyoutMenuButton.cs b/src/settings-ui/Settings.UI.Controls/Primitives/FlyoutMenuButton.cs similarity index 96% rename from src/settings-ui/Settings.UI/SettingsXAML/Controls/FlyoutMenuButton/FlyoutMenuButton.cs rename to src/settings-ui/Settings.UI.Controls/Primitives/FlyoutMenuButton.cs index bb1767e01e..f313506f23 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/FlyoutMenuButton/FlyoutMenuButton.cs +++ b/src/settings-ui/Settings.UI.Controls/Primitives/FlyoutMenuButton.cs @@ -1,4 +1,4 @@ -// 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. diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/IQuickAccessLauncher.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/IQuickAccessLauncher.cs new file mode 100644 index 0000000000..8c35889c94 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/IQuickAccessLauncher.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 ManagedCommon; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public interface IQuickAccessLauncher + { + bool Launch(ModuleType moduleType); + } +} diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessItem.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessItem.cs new file mode 100644 index 0000000000..b639d87802 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessItem.cs @@ -0,0 +1,69 @@ +// 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.Windows.Input; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.UI.Xaml; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed class QuickAccessItem : Observable + { + private string _title = string.Empty; + + public string Title + { + get => _title; + set => Set(ref _title, value); + } + + private string _description = string.Empty; + + public string Description + { + get => _description; + set => Set(ref _description, value); + } + + private string _icon = string.Empty; + + public string Icon + { + get => _icon; + set => Set(ref _icon, value); + } + + private ICommand? _command; + + public ICommand? Command + { + get => _command; + set => Set(ref _command, value); + } + + private object? _commandParameter; + + public object? CommandParameter + { + get => _commandParameter; + set => Set(ref _commandParameter, value); + } + + private bool _visible = true; + + public bool Visible + { + get => _visible; + set => Set(ref _visible, value); + } + + private object? _tag; + + public object? Tag + { + get => _tag; + set => Set(ref _tag, value); + } + } +} diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.cs new file mode 100644 index 0000000000..1347ce86c1 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessLauncher.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.Threading; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerToys.Interop; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public class QuickAccessLauncher : IQuickAccessLauncher + { + private readonly bool _isElevated; + + public QuickAccessLauncher(bool isElevated) + { + _isElevated = isElevated; + } + + public virtual bool Launch(ModuleType moduleType) + { + switch (moduleType) + { + case ModuleType.ColorPicker: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowColorPickerSharedEvent())) + { + eventHandle.Set(); + } + + return true; + case ModuleType.EnvironmentVariables: + { + bool launchAdmin = SettingsRepository<EnvironmentVariablesSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.LaunchAdministrator; + string eventName = !_isElevated && launchAdmin + ? Constants.ShowEnvironmentVariablesAdminSharedEvent() + : Constants.ShowEnvironmentVariablesSharedEvent(); + + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName)) + { + eventHandle.Set(); + } + } + + return true; + case ModuleType.FancyZones: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.FZEToggleEvent())) + { + eventHandle.Set(); + } + + return true; + case ModuleType.Hosts: + { + bool launchAdmin = SettingsRepository<HostsSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.LaunchAdministrator; + string eventName = !_isElevated && launchAdmin + ? Constants.ShowHostsAdminSharedEvent() + : Constants.ShowHostsSharedEvent(); + + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName)) + { + eventHandle.Set(); + } + } + + return true; + case ModuleType.PowerLauncher: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.PowerLauncherSharedEvent())) + { + eventHandle.Set(); + } + + return true; + case ModuleType.PowerOCR: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowPowerOCRSharedEvent())) + { + eventHandle.Set(); + } + + return true; + case ModuleType.RegistryPreview: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.RegistryPreviewTriggerEvent())) + { + eventHandle.Set(); + } + + return true; + case ModuleType.MeasureTool: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.MeasureToolTriggerEvent())) + { + eventHandle.Set(); + } + + return true; + case ModuleType.ShortcutGuide: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShortcutGuideTriggerEvent())) + { + eventHandle.Set(); + } + + return true; + case ModuleType.CmdPal: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowCmdPalEvent())) + { + eventHandle.Set(); + } + + return true; + case ModuleType.Workspaces: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.WorkspacesLaunchEditorEvent())) + { + eventHandle.Set(); + } + + return true; + case ModuleType.LightSwitch: + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.LightSwitchToggleEvent())) + { + 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/QuickAccessList.xaml b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessList.xaml new file mode 100644 index 0000000000..415ae22684 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessList.xaml @@ -0,0 +1,51 @@ +<UserControl + x:Class="Microsoft.PowerToys.Settings.UI.Controls.QuickAccessList" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"> + + <UserControl.Resources> + <tkconverters:BoolToVisibilityConverter + x:Key="BoolToVisibilityConverter" + FalseValue="Collapsed" + TrueValue="Visible" /> + <tkconverters:StringVisibilityConverter x:Key="StringVisibilityConverter" /> + </UserControl.Resources> + + <ItemsControl ItemsSource="{x:Bind ItemsSource, Mode=OneWay}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <tkcontrols:WrapPanel HorizontalAlignment="Stretch" VerticalSpacing="12" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate x:DataType="local:QuickAccessItem"> + <local:FlyoutMenuButton + AutomationProperties.Name="{x:Bind Title}" + Command="{x:Bind Command}" + CommandParameter="{x:Bind CommandParameter}" + Visibility="{x:Bind Visible, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <local:FlyoutMenuButton.Content> + <TextBlock + Style="{StaticResource CaptionTextBlockStyle}" + Text="{x:Bind Title}" + TextAlignment="Center" + TextWrapping="Wrap" /> + </local:FlyoutMenuButton.Content> + <local:FlyoutMenuButton.Icon> + <Image> + <Image.Source> + <BitmapImage UriSource="{x:Bind Icon}" /> + </Image.Source> + </Image> + </local:FlyoutMenuButton.Icon> + <ToolTipService.ToolTip> + <ToolTip Content="{x:Bind Description}" Visibility="{x:Bind Description, Converter={StaticResource StringVisibilityConverter}}" /> + </ToolTipService.ToolTip> + </local:FlyoutMenuButton> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> +</UserControl> diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessList.xaml.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessList.xaml.cs new file mode 100644 index 0000000000..34c4bad013 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessList.xaml.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class QuickAccessList : UserControl + { + public QuickAccessList() + { + this.InitializeComponent(); + } + + public object ItemsSource + { + get => (object)GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register(nameof(ItemsSource), typeof(object), typeof(QuickAccessList), new PropertyMetadata(null)); + } +} diff --git a/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs new file mode 100644 index 0000000000..2fb626869d --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/QuickAccess/QuickAccessViewModel.cs @@ -0,0 +1,144 @@ +// 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.ObjectModel; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands; +using Microsoft.UI.Dispatching; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public partial class QuickAccessViewModel : Observable + { + private readonly ISettingsRepository<GeneralSettings> _settingsRepository; + private readonly IQuickAccessLauncher _launcher; + private readonly Func<ModuleType, bool> _isModuleGpoDisabled; + private readonly ResourceLoader _resourceLoader; + private readonly DispatcherQueue _dispatcherQueue; + private GeneralSettings _generalSettings; + + public ObservableCollection<QuickAccessItem> Items { get; } = new(); + + public QuickAccessViewModel( + ISettingsRepository<GeneralSettings> settingsRepository, + IQuickAccessLauncher launcher, + Func<ModuleType, bool> isModuleGpoDisabled, + ResourceLoader resourceLoader) + { + _settingsRepository = settingsRepository; + _launcher = launcher; + _isModuleGpoDisabled = isModuleGpoDisabled; + _resourceLoader = resourceLoader; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + + _generalSettings = _settingsRepository.SettingsConfig; + _generalSettings.AddEnabledModuleChangeNotification(ModuleEnabledChanged); + _settingsRepository.SettingsChanged += OnSettingsChanged; + + InitializeItems(); + } + + private void OnSettingsChanged(GeneralSettings newSettings) + { + if (_dispatcherQueue != null) + { + _dispatcherQueue.TryEnqueue(() => + { + _generalSettings = newSettings; + _generalSettings.AddEnabledModuleChangeNotification(ModuleEnabledChanged); + RefreshItemsVisibility(); + }); + } + } + + private void InitializeItems() + { + AddFlyoutMenuItem(ModuleType.ColorPicker); + AddFlyoutMenuItem(ModuleType.CmdPal); + AddFlyoutMenuItem(ModuleType.EnvironmentVariables); + AddFlyoutMenuItem(ModuleType.FancyZones); + AddFlyoutMenuItem(ModuleType.Hosts); + AddFlyoutMenuItem(ModuleType.LightSwitch); + AddFlyoutMenuItem(ModuleType.PowerDisplay); + AddFlyoutMenuItem(ModuleType.PowerLauncher); + AddFlyoutMenuItem(ModuleType.PowerOCR); + AddFlyoutMenuItem(ModuleType.RegistryPreview); + AddFlyoutMenuItem(ModuleType.MeasureTool); + AddFlyoutMenuItem(ModuleType.ShortcutGuide); + AddFlyoutMenuItem(ModuleType.Workspaces); + } + + private void AddFlyoutMenuItem(ModuleType moduleType) + { + if (_isModuleGpoDisabled(moduleType)) + { + return; + } + + Items.Add(new QuickAccessItem + { + Title = _resourceLoader.GetString(Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetModuleLabelResourceName(moduleType)), + Tag = moduleType, + Visible = Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetIsModuleEnabled(_generalSettings, moduleType), + Description = GetModuleToolTip(moduleType), + Icon = Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetModuleTypeFluentIconName(moduleType), + Command = new RelayCommand(() => _launcher.Launch(moduleType)), + }); + } + + private void ModuleEnabledChanged() + { + if (_dispatcherQueue != null) + { + _dispatcherQueue.TryEnqueue(() => + { + _generalSettings = _settingsRepository.SettingsConfig; + _generalSettings.AddEnabledModuleChangeNotification(ModuleEnabledChanged); + RefreshItemsVisibility(); + }); + } + } + + private void RefreshItemsVisibility() + { + foreach (var item in Items) + { + if (item.Tag is ModuleType moduleType) + { + item.Visible = Microsoft.PowerToys.Settings.UI.Library.Helpers.ModuleHelper.GetIsModuleEnabled(_generalSettings, moduleType); + } + } + } + + private string GetModuleToolTip(ModuleType moduleType) + { + return moduleType switch + { + ModuleType.ColorPicker => SettingsRepository<ColorPickerSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), + ModuleType.FancyZones => SettingsRepository<FancyZonesSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.FancyzonesEditorHotkey.Value.ToString(), + ModuleType.PowerDisplay => SettingsRepository<PowerDisplaySettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), + ModuleType.LightSwitch => SettingsRepository<LightSwitchSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ToggleThemeHotkey.Value.ToString(), + ModuleType.PowerLauncher => SettingsRepository<PowerLauncherSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.OpenPowerLauncher.ToString(), + ModuleType.PowerOCR => SettingsRepository<PowerOcrSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), + ModuleType.Workspaces => SettingsRepository<WorkspacesSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.Hotkey.Value.ToString(), + ModuleType.MeasureTool => SettingsRepository<MeasureToolSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.ToString(), + ModuleType.ShortcutGuide => GetShortcutGuideToolTip(), + _ => string.Empty, + }; + } + + private string GetShortcutGuideToolTip() + { + var shortcutGuideSettings = SettingsRepository<ShortcutGuideSettings>.GetInstance(SettingsUtils.Default).SettingsConfig; + return shortcutGuideSettings.Properties.UseLegacyPressWinKeyBehavior.Value + ? "Win" + : shortcutGuideSettings.Properties.OpenShortcutGuide.ToString(); + } + } +} diff --git a/src/settings-ui/Settings.UI.Controls/Settings.UI.Controls.csproj b/src/settings-ui/Settings.UI.Controls/Settings.UI.Controls.csproj new file mode 100644 index 0000000000..6cdd3d0867 --- /dev/null +++ b/src/settings-ui/Settings.UI.Controls/Settings.UI.Controls.csproj @@ -0,0 +1,32 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> + + <PropertyGroup> + <OutputType>Library</OutputType> + <TargetFramework>net9.0-windows10.0.26100.0</TargetFramework> + <RootNamespace>Microsoft.PowerToys.Settings.UI.Controls</RootNamespace> + <AssemblyName>PowerToys.Settings.UI.Controls</AssemblyName> + <UseWinUI>true</UseWinUI> + <EnablePreviewMsixTooling>true</EnablePreviewMsixTooling> + <GenerateLibraryLayout>true</GenerateLibraryLayout> + <ProjectPriFileName>PowerToys.Settings.UI.Controls.pri</ProjectPriFileName> + <Nullable>enable</Nullable> + <Platforms>x64;ARM64</Platforms> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" /> + <PackageReference Include="CommunityToolkit.WinUI.Extensions" /> + <PackageReference Include="CommunityToolkit.WinUI.Converters" /> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="WinUIEx" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\Settings.UI.Library\Settings.UI.Library.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Calc/Properties/Resources.resx b/src/settings-ui/Settings.UI.Controls/Strings/en-us/Resources.resw similarity index 87% rename from src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Calc/Properties/Resources.resx rename to src/settings-ui/Settings.UI.Controls/Strings/en-us/Resources.resw index 580f704433..6664b14936 100644 --- a/src/modules/cmdpal/exts/Microsoft.CmdPal.Ext.Calc/Properties/Resources.resx +++ b/src/settings-ui/Settings.UI.Controls/Strings/en-us/Resources.resw @@ -117,27 +117,22 @@ <resheader name="writer"> <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> </resheader> - <data name="calculator_display_name" xml:space="preserve"> - <value>Calculator</value> + <data name="Dashboard_SortBy_ToolTip.Text" xml:space="preserve"> + <value>Sort utilities</value> </data> - <data name="calculator_title" xml:space="preserve"> - <value>Calculator</value> + <data name="Dashboard_SortAlphabetical.Text" xml:space="preserve"> + <value>Alphabetically</value> </data> - <data name="calculator_top_level_subtitle" xml:space="preserve"> - <value>Press = to type an equation</value> - <comment>{Locked="="}</comment> + <data name="Dashboard_SortByStatus.Text" xml:space="preserve"> + <value>By status</value> </data> - <data name="calculator_placeholder_text" xml:space="preserve"> - <value>Type an equation...</value> + <data name="Dashboard_SortBy.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Sort utilities</value> </data> - <data name="calculator_error" xml:space="preserve"> - <value>Error: {0}</value> - <comment>{0} will be replaced by an error message from an invalid equation</comment> + <data name="NewInfoBadge.Text" xml:space="preserve"> + <value>NEW</value> </data> - <data name="calculator_save_command_name" xml:space="preserve"> - <value>Save</value> - </data> - <data name="calculator_copy_command_name" xml:space="preserve"> - <value>Copy</value> + <data name="GPOWarning.Text" xml:space="preserve"> + <value>This setting is managed by your organization</value> </data> </root> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/FlyoutMenuButton/FlyoutMenuButton.xaml b/src/settings-ui/Settings.UI.Controls/Themes/Generic.xaml similarity index 95% rename from src/settings-ui/Settings.UI/SettingsXAML/Controls/FlyoutMenuButton/FlyoutMenuButton.xaml rename to src/settings-ui/Settings.UI.Controls/Themes/Generic.xaml index 7cf88ac0f6..466ad4475b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/FlyoutMenuButton/FlyoutMenuButton.xaml +++ b/src/settings-ui/Settings.UI.Controls/Themes/Generic.xaml @@ -1,14 +1,9 @@ -<!-- Copyright (c) Microsoft Corporation and Contributors. --> -<!-- Licensed under the MIT License. --> - <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"> - <Style BasedOn="{StaticResource DefaultFlyoutMenuButtonStyle}" TargetType="controls:FlyoutMenuButton" /> - - <Style x:Key="DefaultFlyoutMenuButtonStyle" TargetType="controls:FlyoutMenuButton"> + <Style TargetType="controls:FlyoutMenuButton"> <Setter Property="HorizontalAlignment" Value="Stretch" /> <Setter Property="VerticalAlignment" Value="Stretch" /> <Setter Property="Width" Value="116" /> diff --git a/src/settings-ui/Settings.UI.Library/AIServiceType.cs b/src/settings-ui/Settings.UI.Library/AIServiceType.cs new file mode 100644 index 0000000000..27eccff1cf --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceType.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// <summary> + /// Supported AI service types for PowerToys AI experiences. + /// </summary> + public enum AIServiceType + { + Unknown = 0, + OpenAI, + AzureOpenAI, + Onnx, + ML, + FoundryLocal, + Mistral, + Google, + AzureAIInference, + Ollama, + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs new file mode 100644 index 0000000000..5b19212eba --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeExtensions.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public static class AIServiceTypeExtensions + { + /// <summary> + /// Convert a persisted string value into an <see cref="AIServiceType"/>. + /// Supports historical casing and aliases. + /// </summary> + public static AIServiceType ToAIServiceType(this string serviceType) + { + if (string.IsNullOrWhiteSpace(serviceType)) + { + return AIServiceType.OpenAI; + } + + var normalized = serviceType.Trim().ToLowerInvariant(); + return normalized switch + { + "openai" => AIServiceType.OpenAI, + "azureopenai" or "azure" => AIServiceType.AzureOpenAI, + "onnx" => AIServiceType.Onnx, + "foundrylocal" or "foundry" or "fl" => AIServiceType.FoundryLocal, + "ml" or "windowsml" or "winml" => AIServiceType.ML, + "mistral" => AIServiceType.Mistral, + "google" or "googleai" or "googlegemini" => AIServiceType.Google, + "azureaiinference" or "azureinference" => AIServiceType.AzureAIInference, + "ollama" => AIServiceType.Ollama, + _ => AIServiceType.Unknown, + }; + } + + /// <summary> + /// Convert an <see cref="AIServiceType"/> to the canonical string used for persistence. + /// </summary> + public static string ToConfigurationString(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "OpenAI", + AIServiceType.AzureOpenAI => "AzureOpenAI", + AIServiceType.Onnx => "Onnx", + AIServiceType.FoundryLocal => "FoundryLocal", + AIServiceType.ML => "ML", + AIServiceType.Mistral => "Mistral", + AIServiceType.Google => "Google", + AIServiceType.AzureAIInference => "AzureAIInference", + AIServiceType.Ollama => "Ollama", + AIServiceType.Unknown => string.Empty, + _ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, "Unsupported AI service type."), + }; + } + + /// <summary> + /// Convert an <see cref="AIServiceType"/> into the normalized key used internally. + /// </summary> + public static string ToNormalizedKey(this AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "openai", + AIServiceType.AzureOpenAI => "azureopenai", + AIServiceType.Onnx => "onnx", + AIServiceType.FoundryLocal => "foundrylocal", + AIServiceType.ML => "ml", + AIServiceType.Mistral => "mistral", + AIServiceType.Google => "google", + AIServiceType.AzureAIInference => "azureaiinference", + AIServiceType.Ollama => "ollama", + _ => string.Empty, + }; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs new file mode 100644 index 0000000000..df01b1816a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeMetadata.cs @@ -0,0 +1,44 @@ +// 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; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// <summary> + /// Metadata information for an AI service type. + /// </summary> + public class AIServiceTypeMetadata + { + public AIServiceType ServiceType { get; init; } + + public string DisplayName { get; init; } + + public string IconPath { get; init; } + + public bool IsOnlineService { get; init; } + + public bool IsAvailableInUI { get; init; } = true; + + public bool IsLocalModel { get; init; } + + public string LegalDescription { get; init; } + + public string TermsLabel { get; init; } + + public Uri TermsUri { get; init; } + + public string PrivacyLabel { get; init; } + + public Uri PrivacyUri { get; init; } + + public bool HasLegalInfo => !string.IsNullOrWhiteSpace(LegalDescription); + + public bool HasTermsLink => TermsUri is not null && !string.IsNullOrEmpty(TermsLabel); + + public bool HasPrivacyLink => PrivacyUri is not null && !string.IsNullOrEmpty(PrivacyLabel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs new file mode 100644 index 0000000000..653b85553e --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AIServiceTypeRegistry.cs @@ -0,0 +1,189 @@ +// 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; + +namespace Microsoft.PowerToys.Settings.UI.Library; + +/// <summary> +/// Centralized registry for AI service type metadata. +/// </summary> +public static class AIServiceTypeRegistry +{ + private static readonly Dictionary<AIServiceType, AIServiceTypeMetadata> MetadataMap = new() + { + [AIServiceType.AzureAIInference] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureAIInference, + DisplayName = "Azure AI Inference", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Azure.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureAIInference_LegalDescription", + TermsLabel = "AdvancedPaste_AzureAIInference_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureAIInference_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.AzureOpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.AzureOpenAI, + DisplayName = "Azure OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/AzureAI.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_AzureOpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_AzureOpenAI_TermsLabel", + TermsUri = new Uri("https://azure.microsoft.com/support/legal/"), + PrivacyLabel = "AdvancedPaste_AzureOpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://privacy.microsoft.com/privacystatement"), + }, + [AIServiceType.FoundryLocal] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.FoundryLocal, + DisplayName = "Foundry Local", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/FoundryLocal.svg", + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_FoundryLocal_LegalDescription", // Resource key for localized description + }, + [AIServiceType.Google] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Google, + DisplayName = "Google", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Gemini.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Google_LegalDescription", + TermsLabel = "AdvancedPaste_Google_TermsLabel", + TermsUri = new Uri("https://ai.google.dev/gemini-api/terms"), + PrivacyLabel = "AdvancedPaste_Google_PrivacyLabel", + PrivacyUri = new Uri("https://support.google.com/gemini/answer/13594961"), + }, + [AIServiceType.Mistral] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Mistral, + DisplayName = "Mistral", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Mistral.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_Mistral_LegalDescription", + TermsLabel = "AdvancedPaste_Mistral_TermsLabel", + TermsUri = new Uri("https://mistral.ai/terms-of-service/"), + PrivacyLabel = "AdvancedPaste_Mistral_PrivacyLabel", + PrivacyUri = new Uri("https://mistral.ai/privacy-policy/"), + }, + [AIServiceType.ML] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.ML, + DisplayName = "Windows ML", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/WindowsML.svg", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IsAvailableInUI = false, + IsOnlineService = false, + IsLocalModel = true, + }, + [AIServiceType.Ollama] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Ollama, + DisplayName = "Ollama", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Ollama.svg", + + // Ollama provide online service, but we treat it as local model at first version since it can is known for local model. + IsOnlineService = false, + IsLocalModel = true, + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + TermsLabel = "AdvancedPaste_Ollama_TermsLabel", + TermsUri = new Uri("https://ollama.org/terms"), + PrivacyLabel = "AdvancedPaste_Ollama_PrivacyLabel", + PrivacyUri = new Uri("https://ollama.org/privacy"), + }, + [AIServiceType.Onnx] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Onnx, + DisplayName = "ONNX", + LegalDescription = "AdvancedPaste_LocalModel_LegalDescription", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/Onnx.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + [AIServiceType.OpenAI] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.OpenAI, + DisplayName = "OpenAI", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = true, + LegalDescription = "AdvancedPaste_OpenAI_LegalDescription", + TermsLabel = "AdvancedPaste_OpenAI_TermsLabel", + TermsUri = new Uri("https://openai.com/terms"), + PrivacyLabel = "AdvancedPaste_OpenAI_PrivacyLabel", + PrivacyUri = new Uri("https://openai.com/privacy"), + }, + [AIServiceType.Unknown] = new AIServiceTypeMetadata + { + ServiceType = AIServiceType.Unknown, + DisplayName = "Unknown", + IconPath = "ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg", + IsOnlineService = false, + IsAvailableInUI = false, + }, + }; + + /// <summary> + /// Get metadata for a specific service type. + /// </summary> + public static AIServiceTypeMetadata GetMetadata(AIServiceType serviceType) + { + return MetadataMap.TryGetValue(serviceType, out var metadata) + ? metadata + : MetadataMap[AIServiceType.Unknown]; + } + + /// <summary> + /// Get metadata for a service type from its string representation. + /// </summary> + public static AIServiceTypeMetadata GetMetadata(string serviceType) + { + var type = serviceType.ToAIServiceType(); + return GetMetadata(type); + } + + /// <summary> + /// Get icon path for a service type. + /// </summary> + public static string GetIconPath(AIServiceType serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// <summary> + /// Get icon path for a service type from its string representation. + /// </summary> + public static string GetIconPath(string serviceType) + { + return GetMetadata(serviceType).IconPath; + } + + /// <summary> + /// Get all service types available in the UI. + /// </summary> + public static IEnumerable<AIServiceTypeMetadata> GetAvailableServiceTypes() + { + return MetadataMap.Values.Where(m => m.IsAvailableInUI); + } + + /// <summary> + /// Get all online service types available in the UI. + /// </summary> + public static IEnumerable<AIServiceTypeMetadata> GetOnlineServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsOnlineService); + } + + /// <summary> + /// Get all local service types available in the UI. + /// </summary> + public static IEnumerable<AIServiceTypeMetadata> GetLocalServiceTypes() + { + return GetAvailableServiceTypes().Where(m => m.IsLocalModel); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs index 28bed92012..1642ecf9c4 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs @@ -12,7 +12,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library; public sealed partial class AdvancedPasteAdditionalAction : Observable, IAdvancedPasteAction { private HotkeySettings _shortcut = new(); - private bool _isShown = true; + private bool _isShown; + private bool _hasConflict; + private string _tooltip; [JsonPropertyName("shortcut")] public HotkeySettings Shortcut @@ -38,6 +40,20 @@ public sealed partial class AdvancedPasteAdditionalAction : Observable, IAdvance set => Set(ref _isShown, value); } + [JsonIgnore] + public bool HasConflict + { + get => _hasConflict; + set => Set(ref _hasConflict, value); + } + + [JsonIgnore] + public string Tooltip + { + get => _tooltip; + set => Set(ref _tooltip, value); + } + [JsonIgnore] public IEnumerable<IAdvancedPasteAction> SubActions => []; } diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs index 3b1a859364..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,26 +22,45 @@ 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<IAdvancedPasteAction> GetAllActions() { - Queue<IAdvancedPasteAction> queue = new([ImageToText, PasteAsFile, Transcode]); + return GetAllActionsRecursive([ImageToText, PasteAsFile, Transcode]); + } - while (queue.Count != 0) + /// <summary> + /// Changed to depth-first traversal to ensure ordered output + /// </summary> + /// <param name="actions">The collection of actions to traverse</param> + /// <returns>All actions returned in depth-first order</returns> + private static IEnumerable<IAdvancedPasteAction> GetAllActionsRecursive(IEnumerable<IAdvancedPasteAction> actions) + { + foreach (var action in actions) { - var action = queue.Dequeue(); yield return action; - foreach (var subAction in action.SubActions) + foreach (var subAction in GetAllActionsRecursive(action.SubActions)) { - queue.Enqueue(subAction); + yield return subAction; } } } diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs index 971d24c93b..c981295906 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs @@ -5,8 +5,8 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; - using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; namespace Microsoft.PowerToys.Settings.UI.Library; @@ -14,12 +14,15 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { private int _id; private string _name = string.Empty; + private string _description = string.Empty; private string _prompt = string.Empty; private HotkeySettings _shortcut = new(); private bool _isShown; private bool _canMoveUp; private bool _canMoveDown; private bool _isValid; + private bool _hasConflict; + private string _tooltip; [JsonPropertyName("id")] public int Id @@ -41,6 +44,13 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction } } + [JsonPropertyName("description")] + public string Description + { + get => _description; + set => Set(ref _description, value ?? string.Empty); + } + [JsonPropertyName("prompt")] public string Prompt { @@ -65,7 +75,6 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction // We null-coalesce here rather than outside this branch as we want to raise PropertyChanged when the setter is called // with null; the ShortcutControl depends on this. _shortcut = value ?? new(); - OnPropertyChanged(); } } @@ -99,6 +108,20 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction private set => Set(ref _isValid, value); } + [JsonIgnore] + public bool HasConflict + { + get => _hasConflict; + set => Set(ref _hasConflict, value); + } + + [JsonIgnore] + public string Tooltip + { + get => _tooltip; + set => Set(ref _tooltip, value); + } + [JsonIgnore] public IEnumerable<IAdvancedPasteAction> SubActions => []; @@ -113,11 +136,14 @@ public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction { Id = other.Id; Name = other.Name; + Description = other.Description; Prompt = other.Prompt; Shortcut = other.GetShortcutClone(); IsShown = other.IsShown; CanMoveUp = other.CanMoveUp; CanMoveDown = other.CanMoveDown; + HasConflict = other.HasConflict; + Tooltip = other.Tooltip; } private HotkeySettings GetShortcutClone() diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomActions.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomActions.cs index 5ab4331393..87d74bae5f 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomActions.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomActions.cs @@ -10,7 +10,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library; public sealed class AdvancedPasteCustomActions { - private static readonly JsonSerializerOptions _serializerOptions = new() + private static readonly JsonSerializerOptions _serializerOptions = new(SettingsSerializationContext.Default.Options) { WriteIndented = true, }; diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs new file mode 100644 index 0000000000..b61f83dd3d --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteMigrationHelper.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// <summary> + /// Helper methods for migrating legacy Advanced Paste settings to the updated schema. + /// </summary> + public static class AdvancedPasteMigrationHelper + { + /// <summary> + /// Ensures an OpenAI provider exists in the configuration, creating one if necessary. + /// </summary> + /// <param name="configuration">The configuration instance.</param> + /// <returns>The ensured provider and a flag indicating whether changes were made.</returns> + public static (PasteAIProviderDefinition Provider, bool Updated) EnsureOpenAIProvider(PasteAIConfiguration configuration) + { + if (configuration is null) + { + return (null, false); + } + + configuration.Providers ??= new ObservableCollection<PasteAIProviderDefinition>(); + + const string serviceTypeKey = "OpenAI"; + var existingProvider = configuration.Providers.FirstOrDefault(provider => string.Equals(provider.ServiceType, serviceTypeKey, StringComparison.OrdinalIgnoreCase)); + bool updated = false; + + if (existingProvider is null) + { + existingProvider = CreateProvider(serviceTypeKey); + configuration.Providers.Add(existingProvider); + updated = true; + } + + updated |= EnsureActiveProviderIsValid(configuration, existingProvider); + + return (existingProvider, updated); + } + + /// <summary> + /// Creates a provider with default values for the requested service type. + /// </summary> + private static PasteAIProviderDefinition CreateProvider(string serviceTypeKey) + { + var serviceType = serviceTypeKey.ToAIServiceType(); + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + var provider = new PasteAIProviderDefinition + { + ServiceType = serviceTypeKey, + ModelName = PasteAIProviderDefaults.GetDefaultModelName(serviceType), + EndpointUrl = string.Empty, + ApiVersion = string.Empty, + DeploymentName = string.Empty, + ModelPath = string.Empty, + SystemPrompt = string.Empty, + ModerationEnabled = serviceType == AIServiceType.OpenAI, + IsLocalModel = metadata.IsLocalModel, + }; + + return provider; + } + + private static bool EnsureActiveProviderIsValid(PasteAIConfiguration configuration, PasteAIProviderDefinition preferredProvider = null) + { + if (configuration?.Providers is null || configuration.Providers.Count == 0) + { + if (!string.IsNullOrWhiteSpace(configuration?.ActiveProviderId)) + { + configuration.ActiveProviderId = string.Empty; + return true; + } + + return false; + } + + bool updated = false; + + var activeProvider = configuration.Providers.FirstOrDefault(provider => string.Equals(provider.Id, configuration.ActiveProviderId, StringComparison.OrdinalIgnoreCase)); + if (activeProvider is null) + { + activeProvider = preferredProvider ?? configuration.Providers.First(); + configuration.ActiveProviderId = activeProvider.Id; + updated = true; + } + + foreach (var provider in configuration.Providers) + { + bool shouldBeActive = string.Equals(provider.Id, configuration.ActiveProviderId, StringComparison.OrdinalIgnoreCase); + if (provider.IsActive != shouldBeActive) + { + provider.IsActive = shouldBeActive; + updated = true; + } + } + + return updated; + } + } +} 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 d40bd686d3..683ef06bf9 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs @@ -2,6 +2,7 @@ // 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; @@ -23,13 +24,52 @@ namespace Microsoft.PowerToys.Settings.UI.Library PasteAsJsonShortcut = new(); CustomActions = new(); AdditionalActions = new(); - IsAdvancedAIEnabled = false; + IsAIEnabled = false; ShowCustomPreview = true; CloseAfterLosingFocus = false; + EnableClipboardPreview = true; + AutoCopySelectionForCustomActionHotkey = false; + PasteAIConfiguration = new(); } [JsonConverter(typeof(BoolPropertyJsonConverter))] - public bool IsAdvancedAIEnabled { get; set; } + public bool IsAIEnabled { get; set; } + + private bool? _legacyAdvancedAIEnabled; + + [JsonPropertyName("IsAdvancedAIEnabled")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public BoolProperty LegacyAdvancedAIEnabledProperty + { + get => null; + set + { + if (value is not null) + { + LegacyAdvancedAIEnabled = value.Value; + } + } + } + + [JsonIgnore] + public bool? LegacyAdvancedAIEnabled + { + get => _legacyAdvancedAIEnabled; + private set => _legacyAdvancedAIEnabled = value; + } + + public bool TryConsumeLegacyAdvancedAIEnabled(out bool value) + { + if (_legacyAdvancedAIEnabled is bool flag) + { + value = flag; + _legacyAdvancedAIEnabled = null; + return true; + } + + value = default; + return false; + } [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool ShowCustomPreview { get; set; } @@ -37,6 +77,12 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonConverter(typeof(BoolPropertyJsonConverter))] public bool CloseAfterLosingFocus { get; set; } + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool EnableClipboardPreview { get; set; } + + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool AutoCopySelectionForCustomActionHotkey { get; set; } + [JsonPropertyName("advanced-paste-ui-hotkey")] public HotkeySettings AdvancedPasteUIShortcut { get; set; } @@ -51,13 +97,17 @@ 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] + public PasteAIConfiguration PasteAIConfiguration { get; set; } public override string ToString() - => JsonSerializer.Serialize(this); + => JsonSerializer.Serialize(this, SettingsSerializationContext.Default.AdvancedPasteProperties); } } diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs index e3ba7d4122..be001fd9d6 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteSettings.cs @@ -3,14 +3,16 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; 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 AdvancedPasteSettings : BasePTModuleSettings, ISettingsConfig + public class AdvancedPasteSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "AdvancedPaste"; @@ -29,7 +31,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library Name = ModuleName; } - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; @@ -39,6 +41,64 @@ namespace Microsoft.PowerToys.Settings.UI.Library settingsUtils.SaveSettings(JsonSerializer.Serialize(this, options), ModuleName); } + public ModuleType GetModuleType() => ModuleType.AdvancedPaste; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.PasteAsPlainTextShortcut, + value => Properties.PasteAsPlainTextShortcut = value ?? AdvancedPasteProperties.DefaultPasteAsPlainTextShortcut, + "PasteAsPlainText_Shortcut"), + new HotkeyAccessor( + () => Properties.AdvancedPasteUIShortcut, + value => Properties.AdvancedPasteUIShortcut = value ?? AdvancedPasteProperties.DefaultAdvancedPasteUIShortcut, + "AdvancedPasteUI_Shortcut"), + new HotkeyAccessor( + () => Properties.PasteAsMarkdownShortcut, + value => Properties.PasteAsMarkdownShortcut = value ?? new HotkeySettings(), + "PasteAsMarkdown_Shortcut"), + new HotkeyAccessor( + () => Properties.PasteAsJsonShortcut, + value => Properties.PasteAsJsonShortcut = value ?? new HotkeySettings(), + "PasteAsJson_Shortcut"), + }; + + string[] additionalActionHeaderKeys = + [ + "ImageToText", + "PasteAsTxtFile", + "PasteAsPngFile", + "PasteAsHtmlFile", + "TranscodeToMp3", + "TranscodeToMp4", + ]; + int index = 0; + foreach (var action in Properties.AdditionalActions.GetAllActions()) + { + if (action is AdvancedPasteAdditionalAction additionalAction) + { + hotkeyAccessors.Add(new HotkeyAccessor( + () => additionalAction.Shortcut, + value => additionalAction.Shortcut = value ?? new HotkeySettings(), + additionalActionHeaderKeys[index])); + index++; + } + } + + // Custom actions do not have localization header, just use the action name. + foreach (var customAction in Properties.CustomActions.Value) + { + hotkeyAccessors.Add(new HotkeyAccessor( + () => customAction.Shortcut, + value => customAction.Shortcut = value ?? new HotkeySettings(), + customAction.Name)); + } + + return hotkeyAccessors.ToArray(); + } + public string GetModuleName() => Name; 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/AlwaysOnTopSettings.cs b/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs index 449c1c0a76..cb7e138596 100644 --- a/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs +++ b/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs @@ -2,13 +2,15 @@ // 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 AlwaysOnTopSettings : BasePTModuleSettings, ISettingsConfig + public class AlwaysOnTopSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "AlwaysOnTop"; public const string ModuleVersion = "0.0.1"; @@ -28,6 +30,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.AlwaysOnTop; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.Hotkey.Value, + value => Properties.Hotkey.Value = value ?? AlwaysOnTopProperties.DefaultHotkeyValue, + "AlwaysOnTop_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public bool UpgradeSettingsConfiguration() { return false; diff --git a/src/settings-ui/Settings.UI.Library/BasePTModuleSettings.cs b/src/settings-ui/Settings.UI.Library/BasePTModuleSettings.cs index ef99b37216..82869fd9aa 100644 --- a/src/settings-ui/Settings.UI.Library/BasePTModuleSettings.cs +++ b/src/settings-ui/Settings.UI.Library/BasePTModuleSettings.cs @@ -2,13 +2,32 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; using System.Text.Json; using System.Text.Json.Serialization; namespace Microsoft.PowerToys.Settings.UI.Library { + /// <summary> + /// Base class for all PowerToys module settings. + /// </summary> + /// <remarks> + /// <para><strong>IMPORTANT for Native AOT compatibility:</strong></para> + /// <para>When creating a new class that inherits from <see cref="BasePTModuleSettings"/>, + /// you MUST register it in <see cref="SettingsSerializationContext"/> by adding a + /// <c>[JsonSerializable(typeof(YourNewSettingsClass))]</c> attribute.</para> + /// <para>Failure to register the type will cause <see cref="ToJsonString"/> to throw + /// <see cref="InvalidOperationException"/> at runtime.</para> + /// <para>See <see cref="SettingsSerializationContext"/> for registration instructions.</para> + /// </remarks> public abstract class BasePTModuleSettings { + // Cached JsonSerializerOptions for Native AOT compatibility + private static readonly JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions + { + TypeInfoResolver = SettingsSerializationContext.Default, + }; + // Gets or sets name of the powertoy module. [JsonPropertyName("name")] public string Name { get; set; } @@ -17,11 +36,33 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("version")] public string Version { get; set; } - // converts the current to a json string. + /// <summary> + /// Converts the current settings object to a JSON string. + /// </summary> + /// <returns>JSON string representation of this settings object.</returns> + /// <exception cref="InvalidOperationException"> + /// Thrown when the runtime type is not registered in <see cref="SettingsSerializationContext"/>. + /// All derived types must be registered with <c>[JsonSerializable(typeof(YourType))]</c> attribute. + /// </exception> + /// <remarks> + /// This method uses Native AOT-compatible JSON serialization. The runtime type must be + /// registered in <see cref="SettingsSerializationContext"/> for serialization to work. + /// </remarks> public virtual string ToJsonString() { // By default JsonSerializer will only serialize the properties in the base class. This can be avoided by passing the object type (more details at https://stackoverflow.com/a/62498888) - return JsonSerializer.Serialize(this, GetType()); + var runtimeType = GetType(); + + // For Native AOT compatibility, get JsonTypeInfo from the TypeInfoResolver + var typeInfo = _jsonSerializerOptions.TypeInfoResolver?.GetTypeInfo(runtimeType, _jsonSerializerOptions); + + if (typeInfo == null) + { + throw new InvalidOperationException($"Type {runtimeType.FullName} is not registered in SettingsSerializationContext. Please add it to the [JsonSerializable] attributes."); + } + + // Use AOT-friendly serialization + return JsonSerializer.Serialize(this, typeInfo); } public override int GetHashCode() diff --git a/src/settings-ui/Settings.UI.Library/BoolProperty.cs b/src/settings-ui/Settings.UI.Library/BoolProperty.cs index 6842770a79..96b0807dd1 100644 --- a/src/settings-ui/Settings.UI.Library/BoolProperty.cs +++ b/src/settings-ui/Settings.UI.Library/BoolProperty.cs @@ -37,7 +37,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public override string ToString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.BoolProperty); } public bool TryToCmdRepresentable(out string result) diff --git a/src/settings-ui/Settings.UI.Library/BoolPropertyJsonConverter.cs b/src/settings-ui/Settings.UI.Library/BoolPropertyJsonConverter.cs index 3cbc0b72b5..140bbffec1 100644 --- a/src/settings-ui/Settings.UI.Library/BoolPropertyJsonConverter.cs +++ b/src/settings-ui/Settings.UI.Library/BoolPropertyJsonConverter.cs @@ -12,14 +12,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library { public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var boolProperty = JsonSerializer.Deserialize<BoolProperty>(ref reader, options); + var boolProperty = JsonSerializer.Deserialize(ref reader, SettingsSerializationContext.Default.BoolProperty); return boolProperty.Value; } public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) { var boolProperty = new BoolProperty(value); - JsonSerializer.Serialize(writer, boolProperty, options); + JsonSerializer.Serialize(writer, boolProperty, SettingsSerializationContext.Default.BoolProperty); } } } diff --git a/src/settings-ui/Settings.UI.Library/CmdNotFoundSettings.cs b/src/settings-ui/Settings.UI.Library/CmdNotFoundSettings.cs index 1f48b218c9..e7e1bf8281 100644 --- a/src/settings-ui/Settings.UI.Library/CmdNotFoundSettings.cs +++ b/src/settings-ui/Settings.UI.Library/CmdNotFoundSettings.cs @@ -25,7 +25,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library Name = ModuleName; } - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file ArgumentNullException.ThrowIfNull(settingsUtils); diff --git a/src/settings-ui/Settings.UI.Library/CmdPalProperties.cs b/src/settings-ui/Settings.UI.Library/CmdPalProperties.cs index 406f67c2a4..c738335827 100644 --- a/src/settings-ui/Settings.UI.Library/CmdPalProperties.cs +++ b/src/settings-ui/Settings.UI.Library/CmdPalProperties.cs @@ -4,9 +4,7 @@ using System; using System.IO; -using System.IO.Abstractions; using System.Text.Json; -using System.Text.Json.Serialization; namespace Microsoft.PowerToys.Settings.UI.Library { @@ -45,18 +43,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library if (doc.RootElement.TryGetProperty(nameof(Hotkey), out JsonElement hotkeyElement)) { - Hotkey = JsonSerializer.Deserialize<HotkeySettings>(hotkeyElement.GetRawText()); - - if (Hotkey == null) - { - Hotkey = DefaultHotkeyValue; - } + Hotkey = JsonSerializer.Deserialize(hotkeyElement.GetRawText(), SettingsSerializationContext.Default.HotkeySettings); } } catch (Exception) { - Hotkey = DefaultHotkeyValue; } + + Hotkey ??= DefaultHotkeyValue; } } } diff --git a/src/settings-ui/Settings.UI.Library/ColorPickerProperties.cs b/src/settings-ui/Settings.UI.Library/ColorPickerProperties.cs index 0d3fc918d6..45548791b6 100644 --- a/src/settings-ui/Settings.UI.Library/ColorPickerProperties.cs +++ b/src/settings-ui/Settings.UI.Library/ColorPickerProperties.cs @@ -32,13 +32,18 @@ namespace Microsoft.PowerToys.Settings.UI.Library VisibleColorFormats.Add("HSI", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("HSI"))); VisibleColorFormats.Add("HWB", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("HWB"))); VisibleColorFormats.Add("NCol", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("NCol"))); - VisibleColorFormats.Add("CIELAB", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("CIELAB"))); VisibleColorFormats.Add("CIEXYZ", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("CIEXYZ"))); + VisibleColorFormats.Add("CIELAB", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("CIELAB"))); + VisibleColorFormats.Add("Oklab", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("Oklab"))); + VisibleColorFormats.Add("Oklch", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("Oklch"))); VisibleColorFormats.Add("VEC4", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("VEC4"))); VisibleColorFormats.Add("Decimal", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("Decimal"))); VisibleColorFormats.Add("HEX Int", new KeyValuePair<bool, string>(false, ColorFormatHelper.GetDefaultFormat("HEX Int"))); ShowColorName = false; - ActivationAction = ColorPickerActivationAction.OpenColorPickerAndThenEditor; + ActivationAction = ColorPickerActivationAction.OpenColorPicker; + PrimaryClickAction = ColorPickerClickAction.PickColorThenEditor; + MiddleClickAction = ColorPickerClickAction.PickColorAndClose; + SecondaryClickAction = ColorPickerClickAction.Close; CopiedColorRepresentation = "HEX"; } @@ -55,6 +60,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("activationaction")] public ColorPickerActivationAction ActivationAction { get; set; } + [JsonPropertyName("primaryclickaction")] + public ColorPickerClickAction PrimaryClickAction { get; set; } + + [JsonPropertyName("middleclickaction")] + public ColorPickerClickAction MiddleClickAction { get; set; } + + [JsonPropertyName("secondaryclickaction")] + public ColorPickerClickAction SecondaryClickAction { get; set; } + // Property ColorHistory is not used, the color history is saved separately in the colorHistory.json file [JsonPropertyName("colorhistory")] [CmdConfigureIgnoreAttribute] @@ -73,6 +87,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library public bool ShowColorName { get; set; } public override string ToString() - => JsonSerializer.Serialize(this); + => JsonSerializer.Serialize(this, SettingsSerializationContext.Default.ColorPickerProperties); } } diff --git a/src/settings-ui/Settings.UI.Library/ColorPickerPropertiesVersion1.cs b/src/settings-ui/Settings.UI.Library/ColorPickerPropertiesVersion1.cs index d0e60d0eec..bc9f0d9f6f 100644 --- a/src/settings-ui/Settings.UI.Library/ColorPickerPropertiesVersion1.cs +++ b/src/settings-ui/Settings.UI.Library/ColorPickerPropertiesVersion1.cs @@ -25,7 +25,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library VisibleColorFormats.Add("RGB", true); VisibleColorFormats.Add("HSL", true); ShowColorName = false; - ActivationAction = ColorPickerActivationAction.OpenColorPickerAndThenEditor; + ActivationAction = ColorPickerActivationAction.OpenColorPicker; } public HotkeySettings ActivationShortcut { get; set; } @@ -54,6 +54,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library public bool ShowColorName { get; set; } public override string ToString() - => JsonSerializer.Serialize(this); + => JsonSerializer.Serialize(this, SettingsSerializationContext.Default.ColorPickerPropertiesVersion1); } } diff --git a/src/settings-ui/Settings.UI.Library/ColorPickerSettings.cs b/src/settings-ui/Settings.UI.Library/ColorPickerSettings.cs index 58a2b3f69d..6935e0afbc 100644 --- a/src/settings-ui/Settings.UI.Library/ColorPickerSettings.cs +++ b/src/settings-ui/Settings.UI.Library/ColorPickerSettings.cs @@ -7,13 +7,14 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; - using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Enumerations; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class ColorPickerSettings : BasePTModuleSettings, ISettingsConfig + public class ColorPickerSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "ColorPicker"; @@ -23,7 +24,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public ColorPickerSettings() { Properties = new ColorPickerProperties(); - Version = "2"; + Version = "2.1"; Name = ModuleName; } @@ -32,7 +33,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library WriteIndented = true, }; - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; @@ -47,7 +48,36 @@ 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() - => false; + { + // Upgrading V1 to V2 doesn't set the version to 2.0, therefore V2 settings still report Version == 1.0 + if (Version == "1.0") + { + if (!Enum.IsDefined(Properties.ActivationAction)) + { + Properties.ActivationAction = ColorPickerActivationAction.OpenColorPicker; + } + + Version = "2.1"; + return true; + } + + return false; + } + + public ModuleType GetModuleType() => ModuleType.ColorPicker; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } public static object UpgradeSettings(object oldSettingsObject) { diff --git a/src/settings-ui/Settings.UI.Library/ColorPickerSettingsVersion1.cs b/src/settings-ui/Settings.UI.Library/ColorPickerSettingsVersion1.cs index 840788992d..409a25f578 100644 --- a/src/settings-ui/Settings.UI.Library/ColorPickerSettingsVersion1.cs +++ b/src/settings-ui/Settings.UI.Library/ColorPickerSettingsVersion1.cs @@ -5,7 +5,6 @@ using System; using System.Text.Json; using System.Text.Json.Serialization; - using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library @@ -29,7 +28,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library Name = ModuleName; } - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; diff --git a/src/settings-ui/Settings.UI.Library/CropAndLockProperties.cs b/src/settings-ui/Settings.UI.Library/CropAndLockProperties.cs index 7a850eebf5..df00c4c6d5 100644 --- a/src/settings-ui/Settings.UI.Library/CropAndLockProperties.cs +++ b/src/settings-ui/Settings.UI.Library/CropAndLockProperties.cs @@ -11,11 +11,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library { public static readonly HotkeySettings DefaultReparentHotkeyValue = new HotkeySettings(true, true, false, true, 0x52); // Ctrl+Win+Shift+R public static readonly HotkeySettings DefaultThumbnailHotkeyValue = new HotkeySettings(true, true, false, true, 0x54); // Ctrl+Win+Shift+T + public static readonly HotkeySettings DefaultScreenshotHotkeyValue = new HotkeySettings(true, true, false, true, 0x53); // Ctrl+Win+Shift+S public CropAndLockProperties() { ReparentHotkey = new KeyboardKeysProperty(DefaultReparentHotkeyValue); ThumbnailHotkey = new KeyboardKeysProperty(DefaultThumbnailHotkeyValue); + ScreenshotHotkey = new KeyboardKeysProperty(DefaultScreenshotHotkeyValue); } [JsonPropertyName("reparent-hotkey")] @@ -23,5 +25,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("thumbnail-hotkey")] public KeyboardKeysProperty ThumbnailHotkey { get; set; } + + [JsonPropertyName("screenshot-hotkey")] + public KeyboardKeysProperty ScreenshotHotkey { get; set; } } } diff --git a/src/settings-ui/Settings.UI.Library/CropAndLockSettings.cs b/src/settings-ui/Settings.UI.Library/CropAndLockSettings.cs index ed6600f287..bb979d8ecf 100644 --- a/src/settings-ui/Settings.UI.Library/CropAndLockSettings.cs +++ b/src/settings-ui/Settings.UI.Library/CropAndLockSettings.cs @@ -2,13 +2,15 @@ // 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 CropAndLockSettings : BasePTModuleSettings, ISettingsConfig + public class CropAndLockSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "CropAndLock"; public const string ModuleVersion = "0.0.1"; @@ -28,6 +30,29 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.CropAndLock; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ReparentHotkey.Value, + value => Properties.ReparentHotkey.Value = value ?? CropAndLockProperties.DefaultReparentHotkeyValue, + "CropAndLock_ReparentActivation_Shortcut"), + new HotkeyAccessor( + () => Properties.ThumbnailHotkey.Value, + value => Properties.ThumbnailHotkey.Value = value ?? CropAndLockProperties.DefaultThumbnailHotkeyValue, + "CropAndLock_ThumbnailActivation_Shortcut"), + new HotkeyAccessor( + () => Properties.ScreenshotHotkey.Value, + value => Properties.ScreenshotHotkey.Value = value ?? CropAndLockProperties.DefaultScreenshotHotkeyValue, + "CropAndLock_ScreenshotActivation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public bool UpgradeSettingsConfiguration() { return false; diff --git a/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs new file mode 100644 index 0000000000..228cf74998 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapProperties.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +using Settings.UI.Library.Attributes; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class CursorWrapProperties + { + [CmdConfigureIgnore] + public HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, false, true, false, 0x55); // Win + Alt + U + + [JsonPropertyName("activation_shortcut")] + public HotkeySettings ActivationShortcut { get; set; } + + [JsonPropertyName("auto_activate")] + public BoolProperty AutoActivate { get; set; } + + [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 new file mode 100644 index 0000000000..fc918c37db --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/CursorWrapSettings.cs @@ -0,0 +1,69 @@ +// 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 CursorWrapSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig + { + public const string ModuleName = "CursorWrap"; + + [JsonPropertyName("properties")] + public CursorWrapProperties Properties { get; set; } + + public CursorWrapSettings() + { + Name = ModuleName; + Properties = new CursorWrapProperties(); + Version = "1.0"; + } + + public string GetModuleName() + { + return Name; + } + + public ModuleType GetModuleType() => ModuleType.CursorWrap; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_CursorWrap_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + + // This can be utilized in the future if the settings.json file is to be modified/deleted. + public bool UpgradeSettingsConfiguration() + { + 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/DoubleProperty.cs b/src/settings-ui/Settings.UI.Library/DoubleProperty.cs index 7854f70529..701268b3be 100644 --- a/src/settings-ui/Settings.UI.Library/DoubleProperty.cs +++ b/src/settings-ui/Settings.UI.Library/DoubleProperty.cs @@ -27,7 +27,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library // Returns a JSON version of the class settings configuration class. public override string ToString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.DoubleProperty); } } } diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index 0e0556d442..f56176a1f0 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -513,6 +513,56 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool cursorWrap; // defaulting to off + + [JsonPropertyName("CursorWrap")] + public bool CursorWrap + { + get => cursorWrap; + set + { + if (cursorWrap != value) + { + LogTelemetryEvent(value); + cursorWrap = value; + } + } + } + + private bool lightSwitch; + + [JsonPropertyName("LightSwitch")] + public bool LightSwitch + { + get => lightSwitch; + set + { + if (lightSwitch != value) + { + LogTelemetryEvent(value); + lightSwitch = value; + NotifyChange(); + } + } + } + + 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/Enumerations/ColorPickerActivationAction.cs b/src/settings-ui/Settings.UI.Library/Enumerations/ColorPickerActivationAction.cs index 459f6ddd2b..b3a9f55812 100644 --- a/src/settings-ui/Settings.UI.Library/Enumerations/ColorPickerActivationAction.cs +++ b/src/settings-ui/Settings.UI.Library/Enumerations/ColorPickerActivationAction.cs @@ -9,10 +9,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Enumerations // Activation shortcut opens editor OpenEditor, - // Activation shortcut opens color picker and after picking a color is copied into clipboard and opens editor - OpenColorPickerAndThenEditor, - - // Activation shortcut opens color picker only and picking color copies color into clipboard - OpenOnlyColorPicker, + // Activation shortcut opens color picker and after picking a color is copied into clipboard and editor optionally opens depending on which mouse button was pressed + OpenColorPicker, } } diff --git a/src/settings-ui/Settings.UI.Library/Enumerations/ColorPickerClickAction.cs b/src/settings-ui/Settings.UI.Library/Enumerations/ColorPickerClickAction.cs new file mode 100644 index 0000000000..9cb159f10f --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Enumerations/ColorPickerClickAction.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. + +namespace Microsoft.PowerToys.Settings.UI.Library.Enumerations +{ + public enum ColorPickerClickAction + { + // Clicking copies the picked color and opens the editor + PickColorThenEditor, + + // Clicking only copies the picked color and then exits color picker + PickColorAndClose, + + // Clicking exits color picker, without copying anything + Close, + } +} diff --git a/src/settings-ui/Settings.UI.Library/Enumerations/ColorRepresentationType.cs b/src/settings-ui/Settings.UI.Library/Enumerations/ColorRepresentationType.cs index 09ef003e88..7e57f5a730 100644 --- a/src/settings-ui/Settings.UI.Library/Enumerations/ColorRepresentationType.cs +++ b/src/settings-ui/Settings.UI.Library/Enumerations/ColorRepresentationType.cs @@ -80,5 +80,20 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Enumerations /// Color presentation as an 8-digit hexadecimal integer (0xFFFFFFFF) /// </summary> HexInteger = 13, + + /// <summary> + /// Color representation as CIELCh color space (L[0..100], C[0..230], h[0°..360°]) + /// </summary> + CIELCh = 14, + + /// <summary> + /// Color representation as Oklab color space (L[0..1], a[-0.5..0.5], b[-0.5..0.5]) + /// </summary> + Oklab = 15, + + /// <summary> + /// Color representation as Oklch color space (L[0..1], C[0..0.5], h[0°..360°]) + /// </summary> + Oklch = 16, } } diff --git a/src/settings-ui/Settings.UI.Library/Enumerations/HostsDeleteBackupMode.cs b/src/settings-ui/Settings.UI.Library/Enumerations/HostsDeleteBackupMode.cs new file mode 100644 index 0000000000..782bcccf48 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Enumerations/HostsDeleteBackupMode.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. + +namespace Settings.UI.Library.Enumerations +{ + public enum HostsDeleteBackupMode + { + Never = 0, + Count = 1, + Age = 2, + } +} diff --git a/src/settings-ui/Settings.UI.Library/EnvironmentVariablesSettings.cs b/src/settings-ui/Settings.UI.Library/EnvironmentVariablesSettings.cs index d54641e977..ccb8f1747a 100644 --- a/src/settings-ui/Settings.UI.Library/EnvironmentVariablesSettings.cs +++ b/src/settings-ui/Settings.UI.Library/EnvironmentVariablesSettings.cs @@ -29,7 +29,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library Name = ModuleName; } - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; diff --git a/src/settings-ui/Settings.UI.Library/FileLocksmithLocalProperties.cs b/src/settings-ui/Settings.UI.Library/FileLocksmithLocalProperties.cs index 747baf6dff..57e3d842c4 100644 --- a/src/settings-ui/Settings.UI.Library/FileLocksmithLocalProperties.cs +++ b/src/settings-ui/Settings.UI.Library/FileLocksmithLocalProperties.cs @@ -21,7 +21,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public string ToJsonString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.FileLocksmithLocalProperties); } // This function is required to implement the ISettingsConfig interface and obtain the settings configurations. diff --git a/src/settings-ui/Settings.UI.Library/FileLocksmithProperties.cs b/src/settings-ui/Settings.UI.Library/FileLocksmithProperties.cs index 3ec6e6d492..a6e37e9ba3 100644 --- a/src/settings-ui/Settings.UI.Library/FileLocksmithProperties.cs +++ b/src/settings-ui/Settings.UI.Library/FileLocksmithProperties.cs @@ -17,6 +17,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("bool_show_extended_menu")] public BoolProperty ExtendedContextMenuOnly { get; set; } - public override string ToString() => JsonSerializer.Serialize(this); + public override string ToString() => JsonSerializer.Serialize(this, SettingsSerializationContext.Default.FileLocksmithProperties); } } diff --git a/src/settings-ui/Settings.UI.Library/FindMyMouseProperties.cs b/src/settings-ui/Settings.UI.Library/FindMyMouseProperties.cs index a028eb9e43..b0d1b347ab 100644 --- a/src/settings-ui/Settings.UI.Library/FindMyMouseProperties.cs +++ b/src/settings-ui/Settings.UI.Library/FindMyMouseProperties.cs @@ -31,9 +31,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("spotlight_color")] public StringProperty SpotlightColor { get; set; } - [JsonPropertyName("overlay_opacity")] - public IntProperty OverlayOpacity { get; set; } - [JsonPropertyName("spotlight_radius")] public IntProperty SpotlightRadius { get; set; } @@ -61,9 +58,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library IncludeWinKey = new BoolProperty(false); ActivationShortcut = DefaultActivationShortcut; DoNotActivateOnGameMode = new BoolProperty(true); - BackgroundColor = new StringProperty("#000000"); - SpotlightColor = new StringProperty("#FFFFFF"); - OverlayOpacity = new IntProperty(50); + BackgroundColor = new StringProperty("#80000000"); // ARGB (#AARRGGBB) + SpotlightColor = new StringProperty("#80FFFFFF"); SpotlightRadius = new IntProperty(100); AnimationDurationMs = new IntProperty(500); SpotlightInitialZoom = new IntProperty(9); diff --git a/src/settings-ui/Settings.UI.Library/FindMyMouseSettings.cs b/src/settings-ui/Settings.UI.Library/FindMyMouseSettings.cs index aca45d0b01..fb00351ee2 100644 --- a/src/settings-ui/Settings.UI.Library/FindMyMouseSettings.cs +++ b/src/settings-ui/Settings.UI.Library/FindMyMouseSettings.cs @@ -2,13 +2,15 @@ // 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 FindMyMouseSettings : BasePTModuleSettings, ISettingsConfig + public class FindMyMouseSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "FindMyMouse"; @@ -27,6 +29,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.FindMyMouse; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_FindMyMouse_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs index ed7b503150..415eb60040 100644 --- a/src/settings-ui/Settings.UI.Library/GeneralSettings.cs +++ b/src/settings-ui/Settings.UI.Library/GeneralSettings.cs @@ -7,18 +7,33 @@ using System.Text.Json; using System.Text.Json.Serialization; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.Library.Utilities; using Settings.UI.Library.Attributes; namespace Microsoft.PowerToys.Settings.UI.Library { - public class GeneralSettings : ISettingsConfig + public enum DashboardSortOrder + { + Alphabetical, + ByStatus, + } + + public class GeneralSettings : ISettingsConfig, IHotkeyConfig { // Gets or sets a value indicating whether run powertoys on start-up. [JsonPropertyName("startup")] public bool Startup { get; set; } + // Gets or sets a value indicating whether the powertoys system tray icon should be hidden. + [JsonPropertyName("show_tray_icon")] + public bool ShowSysTrayIcon { get; set; } + + // Gets or sets a value indicating whether the powertoys system tray icon should show a theme adaptive icon + [JsonPropertyName("show_theme_adaptive_tray_icon")] + public bool ShowThemeAdaptiveTrayIcon { get; set; } + // Gets or sets a value indicating whether the powertoy elevated. [CmdConfigureIgnoreAttribute] [JsonPropertyName("is_elevated")] @@ -38,6 +53,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("enable_warnings_elevated_apps")] public bool EnableWarningsElevatedApps { get; set; } + // Gets or sets a value indicating whether Quick Access is enabled. + [JsonPropertyName("enable_quick_access")] + public bool EnableQuickAccess { get; set; } + + // Gets or sets Quick Access shortcut. + [JsonPropertyName("quick_access_shortcut")] + public HotkeySettings QuickAccessShortcut { get; set; } + // Gets or sets theme Name. [JsonPropertyName("theme")] public string Theme { get; set; } @@ -72,15 +95,25 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("enable_experimentation")] public bool EnableExperimentation { get; set; } + [JsonPropertyName("dashboard_sort_order")] + public DashboardSortOrder DashboardSortOrder { get; set; } + + [JsonPropertyName("ignored_conflict_properties")] + public ShortcutConflictProperties IgnoredConflictProperties { get; set; } + public GeneralSettings() { Startup = false; + ShowSysTrayIcon = true; IsAdmin = false; EnableWarningsElevatedApps = true; + EnableQuickAccess = true; + QuickAccessShortcut = new HotkeySettings(); IsElevated = false; ShowNewUpdatesToastNotification = true; AutoDownloadUpdates = false; EnableExperimentation = true; + DashboardSortOrder = DashboardSortOrder.Alphabetical; Theme = "system"; SystemTheme = "light"; try @@ -95,12 +128,29 @@ namespace Microsoft.PowerToys.Settings.UI.Library Enabled = new EnabledModules(); CustomActionName = string.Empty; + IgnoredConflictProperties = new ShortcutConflictProperties(); + } + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + return new HotkeyAccessor[] + { + new HotkeyAccessor( + () => QuickAccessShortcut, + (hotkey) => { QuickAccessShortcut = hotkey; }, + "GeneralPage_QuickAccessShortcut"), + }; + } + + public ModuleType GetModuleType() + { + return ModuleType.GeneralSettings; } // converts the current to a json string. public string ToJsonString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.GeneralSettings); } private static string DefaultPowertoysVersion() @@ -132,6 +182,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library // If there is an issue with the version number format, don't migrate settings. } + // Ensure IgnoredConflictProperties is initialized (for backward compatibility) + if (IgnoredConflictProperties == null) + { + IgnoredConflictProperties = new ShortcutConflictProperties(); + return true; // Indicate that settings were upgraded + } + return false; } diff --git a/src/settings-ui/Settings.UI.Library/GeneralSettingsCustomAction.cs b/src/settings-ui/Settings.UI.Library/GeneralSettingsCustomAction.cs index f180ca3186..d9cc2727e8 100644 --- a/src/settings-ui/Settings.UI.Library/GeneralSettingsCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/GeneralSettingsCustomAction.cs @@ -23,7 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public override string ToString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.GeneralSettingsCustomAction); } } } diff --git a/src/settings-ui/Settings.UI.Library/Helpers/HotkeyAccessor.cs b/src/settings-ui/Settings.UI.Library/Helpers/HotkeyAccessor.cs new file mode 100644 index 0000000000..41c4d4af61 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/HotkeyAccessor.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.Helpers +{ + public class HotkeyAccessor + { + public Func<HotkeySettings> Getter { get; } + + public Action<HotkeySettings> Setter { get; } + + public HotkeyAccessor(Func<HotkeySettings> getter, Action<HotkeySettings> setter, string localizationHeaderKey = "") + { + Getter = getter ?? throw new ArgumentNullException(nameof(getter)); + Setter = setter ?? throw new ArgumentNullException(nameof(setter)); + LocalizationHeaderKey = localizationHeaderKey; + } + + public HotkeySettings Value + { + get => Getter(); + set => Setter(value); + } + + public string LocalizationHeaderKey { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.cs new file mode 100644 index 0000000000..9b4581957c --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/ModuleHelper.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 ManagedCommon; + +namespace Microsoft.PowerToys.Settings.UI.Library.Helpers +{ + public static class ModuleHelper + { + public static string GetModuleLabelResourceName(ModuleType moduleType) + { + return moduleType switch + { + ModuleType.Workspaces => "Workspaces/ModuleTitle", + ModuleType.PowerAccent => "QuickAccent/ModuleTitle", + ModuleType.PowerOCR => "TextExtractor/ModuleTitle", + ModuleType.FindMyMouse => "MouseUtils_FindMyMouse/Header", + ModuleType.MouseHighlighter => "MouseUtils_MouseHighlighter/Header", + ModuleType.MouseJump => "MouseUtils_MouseJump/Header", + ModuleType.MousePointerCrosshairs => "MouseUtils_MousePointerCrosshairs/Header", + ModuleType.CursorWrap => "MouseUtils_CursorWrap/Header", + ModuleType.GeneralSettings => "QuickAccessTitle/Title", + _ => $"{moduleType}/ModuleTitle", + }; + } + + public static string GetModuleTypeFluentIconName(ModuleType moduleType) + { + return moduleType switch + { + ModuleType.AdvancedPaste => "ms-appx:///Assets/Settings/Icons/AdvancedPaste.png", + ModuleType.Workspaces => "ms-appx:///Assets/Settings/Icons/Workspaces.png", + ModuleType.PowerOCR => "ms-appx:///Assets/Settings/Icons/TextExtractor.png", + ModuleType.PowerAccent => "ms-appx:///Assets/Settings/Icons/QuickAccent.png", + ModuleType.MousePointerCrosshairs => "ms-appx:///Assets/Settings/Icons/MouseCrosshairs.png", + ModuleType.MeasureTool => "ms-appx:///Assets/Settings/Icons/ScreenRuler.png", + ModuleType.PowerLauncher => "ms-appx:///Assets/Settings/Icons/PowerToysRun.png", + ModuleType.GeneralSettings => "ms-appx:///Assets/Settings/Icons/PowerToys.png", + _ => $"ms-appx:///Assets/Settings/Icons/{moduleType}.png", + }; + } + + public static bool GetIsModuleEnabled(GeneralSettings generalSettingsConfig, ModuleType moduleType) + { + return moduleType switch + { + ModuleType.AdvancedPaste => generalSettingsConfig.Enabled.AdvancedPaste, + ModuleType.AlwaysOnTop => generalSettingsConfig.Enabled.AlwaysOnTop, + ModuleType.Awake => generalSettingsConfig.Enabled.Awake, + ModuleType.CmdPal => generalSettingsConfig.Enabled.CmdPal, + ModuleType.ColorPicker => generalSettingsConfig.Enabled.ColorPicker, + ModuleType.CropAndLock => generalSettingsConfig.Enabled.CropAndLock, + ModuleType.CursorWrap => generalSettingsConfig.Enabled.CursorWrap, + ModuleType.EnvironmentVariables => generalSettingsConfig.Enabled.EnvironmentVariables, + ModuleType.FancyZones => generalSettingsConfig.Enabled.FancyZones, + ModuleType.FileLocksmith => generalSettingsConfig.Enabled.FileLocksmith, + ModuleType.FindMyMouse => generalSettingsConfig.Enabled.FindMyMouse, + ModuleType.Hosts => generalSettingsConfig.Enabled.Hosts, + ModuleType.ImageResizer => generalSettingsConfig.Enabled.ImageResizer, + ModuleType.KeyboardManager => generalSettingsConfig.Enabled.KeyboardManager, + ModuleType.LightSwitch => generalSettingsConfig.Enabled.LightSwitch, + ModuleType.MouseHighlighter => generalSettingsConfig.Enabled.MouseHighlighter, + ModuleType.MouseJump => generalSettingsConfig.Enabled.MouseJump, + ModuleType.MousePointerCrosshairs => generalSettingsConfig.Enabled.MousePointerCrosshairs, + ModuleType.MouseWithoutBorders => generalSettingsConfig.Enabled.MouseWithoutBorders, + ModuleType.NewPlus => generalSettingsConfig.Enabled.NewPlus, + ModuleType.Peek => generalSettingsConfig.Enabled.Peek, + ModuleType.PowerRename => generalSettingsConfig.Enabled.PowerRename, + ModuleType.PowerLauncher => generalSettingsConfig.Enabled.PowerLauncher, + ModuleType.PowerAccent => generalSettingsConfig.Enabled.PowerAccent, + ModuleType.RegistryPreview => generalSettingsConfig.Enabled.RegistryPreview, + 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, + _ => false, + }; + } + + public static void SetIsModuleEnabled(GeneralSettings generalSettingsConfig, ModuleType moduleType, bool isEnabled) + { + switch (moduleType) + { + case ModuleType.AdvancedPaste: generalSettingsConfig.Enabled.AdvancedPaste = isEnabled; break; + case ModuleType.AlwaysOnTop: generalSettingsConfig.Enabled.AlwaysOnTop = isEnabled; break; + case ModuleType.Awake: generalSettingsConfig.Enabled.Awake = isEnabled; break; + case ModuleType.CmdPal: generalSettingsConfig.Enabled.CmdPal = isEnabled; break; + case ModuleType.ColorPicker: generalSettingsConfig.Enabled.ColorPicker = isEnabled; break; + case ModuleType.CropAndLock: generalSettingsConfig.Enabled.CropAndLock = isEnabled; break; + case ModuleType.CursorWrap: generalSettingsConfig.Enabled.CursorWrap = isEnabled; break; + case ModuleType.EnvironmentVariables: generalSettingsConfig.Enabled.EnvironmentVariables = isEnabled; break; + case ModuleType.FancyZones: generalSettingsConfig.Enabled.FancyZones = isEnabled; break; + case ModuleType.FileLocksmith: generalSettingsConfig.Enabled.FileLocksmith = isEnabled; break; + case ModuleType.FindMyMouse: generalSettingsConfig.Enabled.FindMyMouse = isEnabled; break; + case ModuleType.Hosts: generalSettingsConfig.Enabled.Hosts = isEnabled; break; + case ModuleType.ImageResizer: generalSettingsConfig.Enabled.ImageResizer = isEnabled; break; + case ModuleType.KeyboardManager: generalSettingsConfig.Enabled.KeyboardManager = isEnabled; break; + case ModuleType.LightSwitch: generalSettingsConfig.Enabled.LightSwitch = isEnabled; break; + case ModuleType.MouseHighlighter: generalSettingsConfig.Enabled.MouseHighlighter = isEnabled; break; + case ModuleType.MouseJump: generalSettingsConfig.Enabled.MouseJump = isEnabled; break; + case ModuleType.MousePointerCrosshairs: generalSettingsConfig.Enabled.MousePointerCrosshairs = isEnabled; break; + case ModuleType.MouseWithoutBorders: generalSettingsConfig.Enabled.MouseWithoutBorders = isEnabled; break; + case ModuleType.NewPlus: generalSettingsConfig.Enabled.NewPlus = isEnabled; break; + case ModuleType.Peek: generalSettingsConfig.Enabled.Peek = isEnabled; break; + case ModuleType.PowerRename: generalSettingsConfig.Enabled.PowerRename = isEnabled; break; + case ModuleType.PowerLauncher: generalSettingsConfig.Enabled.PowerLauncher = isEnabled; break; + case ModuleType.PowerAccent: generalSettingsConfig.Enabled.PowerAccent = isEnabled; break; + case ModuleType.RegistryPreview: generalSettingsConfig.Enabled.RegistryPreview = isEnabled; break; + 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; + } + } + + /// <summary> + /// Gets the module key name used in IPC messages and settings JSON. + /// These names match the JsonPropertyName attributes in EnabledModules class. + /// </summary> + public static string GetModuleKey(ModuleType moduleType) + { + return moduleType switch + { + ModuleType.AdvancedPaste => AdvancedPasteSettings.ModuleName, + ModuleType.AlwaysOnTop => AlwaysOnTopSettings.ModuleName, + ModuleType.Awake => AwakeSettings.ModuleName, + ModuleType.CmdPal => "CmdPal", // No dedicated settings class + ModuleType.ColorPicker => ColorPickerSettings.ModuleName, + ModuleType.CropAndLock => CropAndLockSettings.ModuleName, + ModuleType.CursorWrap => CursorWrapSettings.ModuleName, + ModuleType.EnvironmentVariables => EnvironmentVariablesSettings.ModuleName, + ModuleType.FancyZones => FancyZonesSettings.ModuleName, + ModuleType.FileLocksmith => FileLocksmithSettings.ModuleName, + ModuleType.FindMyMouse => FindMyMouseSettings.ModuleName, + ModuleType.Hosts => HostsSettings.ModuleName, + ModuleType.ImageResizer => ImageResizerSettings.ModuleName, + ModuleType.KeyboardManager => KeyboardManagerSettings.ModuleName, + ModuleType.LightSwitch => LightSwitchSettings.ModuleName, + ModuleType.MouseHighlighter => MouseHighlighterSettings.ModuleName, + ModuleType.MouseJump => MouseJumpSettings.ModuleName, + ModuleType.MousePointerCrosshairs => MousePointerCrosshairsSettings.ModuleName, + ModuleType.MouseWithoutBorders => MouseWithoutBordersSettings.ModuleName, + ModuleType.NewPlus => NewPlusSettings.ModuleName, + ModuleType.Peek => PeekSettings.ModuleName, + ModuleType.PowerRename => PowerRenameSettings.ModuleName, + ModuleType.PowerLauncher => PowerLauncherSettings.ModuleName, + ModuleType.PowerAccent => PowerAccentSettings.ModuleName, + ModuleType.RegistryPreview => RegistryPreviewSettings.ModuleName, + ModuleType.MeasureTool => MeasureToolSettings.ModuleName, + ModuleType.ShortcutGuide => ShortcutGuideSettings.ModuleName, + ModuleType.PowerOCR => PowerOcrSettings.ModuleName, + ModuleType.Workspaces => WorkspacesSettings.ModuleName, + ModuleType.ZoomIt => ZoomItSettings.ModuleName, + _ => moduleType.ToString(), + }; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SearchLocation.cs b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocation.cs new file mode 100644 index 0000000000..8e357534af --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocation.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.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Settings.UI.Library.Helpers +{ + public class SearchLocation + { + public string City { get; set; } + + public string Country { get; set; } + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public SearchLocation(string city, string country, double latitude, double longitude) + { + City = city; + Country = country; + Latitude = latitude; + Longitude = longitude; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SearchLocationLoader.cs b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocationLoader.cs new file mode 100644 index 0000000000..24f0846a02 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SearchLocationLoader.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + public static class SearchLocationLoader + { + private static readonly List<SearchLocation> LocationDataList = new List<SearchLocation>(); + + public static IEnumerable<SearchLocation> GetAll() + { + return LocationDataList + .GroupBy(l => $"{l.Country}|{l.City}|{l.Latitude.ToString(CultureInfo.InvariantCulture)}|{l.Longitude.ToString(CultureInfo.InvariantCulture)}") + .Select(g => g.First()) + .OrderBy(l => l.Country, StringComparer.OrdinalIgnoreCase) + .ThenBy(l => l.City, StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SunCalc.cs b/src/settings-ui/Settings.UI.Library/Helpers/SunCalc.cs new file mode 100644 index 0000000000..6b69fee755 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SunCalc.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.PowerToys.Settings.UI.Library.Helpers +{ + public static class SunCalc + { + public static SunTimes CalculateSunriseSunset(double latitude, double longitude, int year, int month, int day) + { + double zenith = 90.833; // official sunrise/sunset + + int n1 = (int)Math.Floor(275.0 * month / 9.0); + int n2 = (int)Math.Floor((month + 9.0) / 12.0); + int n3 = (int)Math.Floor(1.0 + Math.Floor((year - (4.0 * Math.Floor(year / 4.0)) + 2.0) / 3.0)); + int n = n1 - (n2 * n3) + day - 30; + + double? riseUT = CalcTime(isSunrise: true); + double? setUT = CalcTime(isSunrise: false); + + var riseLocal = ToLocal(riseUT, year, month, day); + var setLocal = ToLocal(setUT, year, month, day); + + var result = new SunTimes + { + HasSunrise = riseLocal.HasValue, + HasSunset = setLocal.HasValue, + SunriseHour = riseLocal?.Hour ?? -1, + SunriseMinute = riseLocal?.Minute ?? -1, + SunsetHour = setLocal?.Hour ?? -1, + SunsetMinute = setLocal?.Minute ?? -1, + }; + + return result; + + // Local functions + double? CalcTime(bool isSunrise) + { + double lngHour = longitude / 15.0; + double t = isSunrise ? n + ((6 - lngHour) / 24.0) : n + ((18 - lngHour) / 24.0); + + double m1 = (0.9856 * t) - 3.289; + double l = m1 + (1.916 * Math.Sin(Deg2Rad(m1))) + (0.020 * Math.Sin(2 * Deg2Rad(m1))) + 282.634; + l = NormalizeDegrees(l); + + double rA = Rad2Deg(Math.Atan(0.91764 * Math.Tan(Deg2Rad(l)))); + rA = NormalizeDegrees(rA); + + double lquadrant = Math.Floor(l / 90.0) * 90.0; + double rAquadrant = Math.Floor(rA / 90.0) * 90.0; + rA = rA + (lquadrant - rAquadrant); + rA /= 15.0; + + double sinDec = 0.39782 * Math.Sin(Deg2Rad(l)); + double cosDec = Math.Cos(Math.Asin(sinDec)); + + double cosH = (Math.Cos(Deg2Rad(zenith)) - (sinDec * Math.Sin(Deg2Rad(latitude)))) + / (cosDec * Math.Cos(Deg2Rad(latitude))); + + if (cosH > 1.0 || cosH < -1.0) + { + // Sun never rises or never sets on this date at this location + return null; + } + + double h = isSunrise ? 360.0 - Rad2Deg(Math.Acos(cosH)) : Rad2Deg(Math.Acos(cosH)); + h /= 15.0; + + double t1 = h + rA - (0.06571 * t) - 6.622; + double uT = t1 - lngHour; + uT = NormalizeHours(uT); + + return uT; + } + + static (int Hour, int Minute)? ToLocal(double? ut, int y, int m, int d) + { + if (!ut.HasValue) + { + return null; + } + + // Convert fractional hours to hh:mm with proper rounding + int hours = (int)Math.Floor(ut.Value); + int minutes = (int)((ut.Value - hours) * 60.0); + + // Normalize minute overflow + if (minutes == 60) + { + minutes = 0; + hours = (hours + 1) % 24; + } + + // Build a UTC DateTime on the given date + var utc = new DateTime(y, m, d, hours, minutes, 0, DateTimeKind.Utc); + + // Convert to local time using system time zone rules for that date + var local = TimeZoneInfo.ConvertTimeFromUtc(utc, TimeZoneInfo.Local); + + return (local.Hour, local.Minute); + } + + static double Deg2Rad(double deg) => deg * Math.PI / 180.0; + static double Rad2Deg(double rad) => rad * 180.0 / Math.PI; + + static double NormalizeDegrees(double angle) + { + angle %= 360.0; + if (angle < 0) + { + angle += 360.0; + } + + return angle; + } + + static double NormalizeHours(double hours) + { + hours %= 24.0; + if (hours < 0) + { + hours += 24.0; + } + + return hours; + } + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Helpers/SunTimes.cs b/src/settings-ui/Settings.UI.Library/Helpers/SunTimes.cs new file mode 100644 index 0000000000..6c24d1c557 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Helpers/SunTimes.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.Helpers +{ + public struct SunTimes + { + public int SunriseHour { get; set; } + + public int SunriseMinute { get; set; } + + public int SunsetHour { get; set; } + + public int SunsetMinute { get; set; } + + public string Text { get; set; } + + public bool HasSunrise { get; set; } + + public bool HasSunset { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HostsProperties.cs b/src/settings-ui/Settings.UI.Library/HostsProperties.cs index 90a576601d..b4feedce45 100644 --- a/src/settings-ui/Settings.UI.Library/HostsProperties.cs +++ b/src/settings-ui/Settings.UI.Library/HostsProperties.cs @@ -1,10 +1,10 @@ -// 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; +using System.IO; using System.Text.Json.Serialization; - -using Settings.UI.Library.Attributes; using Settings.UI.Library.Enumerations; namespace Microsoft.PowerToys.Settings.UI.Library @@ -24,6 +24,20 @@ namespace Microsoft.PowerToys.Settings.UI.Library public HostsEncoding Encoding { get; set; } + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool NoLeadingSpaces { get; set; } + + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool BackupHosts { get; set; } + + public string BackupPath { get; set; } + + public HostsDeleteBackupMode DeleteBackupsMode { get; set; } + + public int DeleteBackupsDays { get; set; } + + public int DeleteBackupsCount { get; set; } + public HostsProperties() { ShowStartupWarning = true; @@ -31,6 +45,12 @@ namespace Microsoft.PowerToys.Settings.UI.Library LoopbackDuplicates = false; AdditionalLinesPosition = HostsAdditionalLinesPosition.Top; Encoding = HostsEncoding.Utf8; + NoLeadingSpaces = false; + BackupHosts = true; + BackupPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"System32\drivers\etc"); + DeleteBackupsMode = HostsDeleteBackupMode.Age; + DeleteBackupsDays = 15; + DeleteBackupsCount = 5; } } } diff --git a/src/settings-ui/Settings.UI.Library/HostsSettings.cs b/src/settings-ui/Settings.UI.Library/HostsSettings.cs index bb339f178c..8559c94f4f 100644 --- a/src/settings-ui/Settings.UI.Library/HostsSettings.cs +++ b/src/settings-ui/Settings.UI.Library/HostsSettings.cs @@ -29,7 +29,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library Name = ModuleName; } - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsData.cs new file mode 100644 index 0000000000..00d2145f29 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsData.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class AllHotkeyConflictsData + { + public List<HotkeyConflictGroupData> InAppConflicts { get; set; } = new List<HotkeyConflictGroupData>(); + + public List<HotkeyConflictGroupData> SystemConflicts { get; set; } = new List<HotkeyConflictGroupData>(); + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsEventArgs.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsEventArgs.cs new file mode 100644 index 0000000000..28f034d81b --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/AllHotkeyConflictsEventArgs.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class AllHotkeyConflictsEventArgs : EventArgs + { + public AllHotkeyConflictsData Conflicts { get; } + + public AllHotkeyConflictsEventArgs(AllHotkeyConflictsData conflicts) + { + Conflicts = conflicts; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs new file mode 100644 index 0000000000..0c76ddf8ea --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictGroupData.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class HotkeyConflictGroupData + { + public HotkeyData Hotkey { get; set; } + + public bool IsSystemConflict { get; set; } + + public bool ConflictIgnored { get; set; } + + public bool ConflictVisible => !ConflictIgnored; + + public bool ShouldShowSysConflict => !ConflictIgnored && IsSystemConflict; + + public List<ModuleHotkeyData> Modules { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictInfo.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictInfo.cs new file mode 100644 index 0000000000..193eb39d89 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyConflictInfo.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class HotkeyConflictInfo + { + public bool IsSystemConflict { get; set; } + + public string ConflictingModuleName { get; set; } + + public int ConflictingHotkeyID { get; set; } + + public List<string> AllConflictingModules { get; set; } = new List<string>(); + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyData.cs new file mode 100644 index 0000000000..9e416db7d9 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/HotkeyData.cs @@ -0,0 +1,71 @@ +// 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.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class HotkeyData + { + public bool Win { get; set; } + + public bool Ctrl { get; set; } + + public bool Shift { get; set; } + + public bool Alt { get; set; } + + public int Key { get; set; } + + public List<object> GetKeysList() + { + List<object> shortcutList = new List<object>(); + + if (Win) + { + shortcutList.Add(92); // The Windows key or button. + } + + if (Ctrl) + { + shortcutList.Add("Ctrl"); + } + + if (Alt) + { + shortcutList.Add("Alt"); + } + + if (Shift) + { + shortcutList.Add(16); // The Shift key or button. + } + + if (Key > 0) + { + switch (Key) + { + // https://learn.microsoft.com/uwp/api/windows.system.virtualkey?view=winrt-20348 + case 38: // The Up Arrow key or button. + case 40: // The Down Arrow key or button. + case 37: // The Left Arrow key or button. + case 39: // The Right Arrow key or button. + shortcutList.Add(Key); + break; + default: + var localKey = Helper.GetKeyName((uint)Key); + shortcutList.Add(localKey); + break; + } + } + + return shortcutList; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleConflictsData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleConflictsData.cs new file mode 100644 index 0000000000..2b343693bd --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleConflictsData.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class ModuleConflictsData + { + public List<HotkeyConflictGroupData> InAppConflicts { get; set; } = new List<HotkeyConflictGroupData>(); + + public List<HotkeyConflictGroupData> SystemConflicts { get; set; } = new List<HotkeyConflictGroupData>(); + + public bool HasConflicts => InAppConflicts.Count > 0 || SystemConflicts.Count > 0; + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleHotkeyData.cs b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleHotkeyData.cs new file mode 100644 index 0000000000..f24e02e650 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/HotkeyConflicts/ModuleHotkeyData.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.ComponentModel; +using System.Runtime.CompilerServices; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Windows.Web.AtomPub; + +namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts +{ + public class ModuleHotkeyData : INotifyPropertyChanged + { + private string _moduleName; + private int _hotkeyID; + private HotkeySettings _hotkeySettings; + private bool _isSystemConflict; + + public event PropertyChangedEventHandler PropertyChanged; + + public string IconPath { get; set; } + + public string DisplayName { get; set; } + + public string Header { get; set; } + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public string ModuleName + { + get => _moduleName; + set + { + if (_moduleName != value) + { + _moduleName = value; + } + } + } + + public int HotkeyID + { + get => _hotkeyID; + set + { + if (_hotkeyID != value) + { + _hotkeyID = value; + } + } + } + + public HotkeySettings HotkeySettings + { + get => _hotkeySettings; + set + { + if (_hotkeySettings != value) + { + _hotkeySettings = value; + OnPropertyChanged(); + } + } + } + + public bool IsSystemConflict + { + get => _isSystemConflict; + set + { + if (_isSystemConflict != value) + { + _isSystemConflict = value; + } + } + } + + public ModuleType ModuleType { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/HotkeySettings.cs b/src/settings-ui/Settings.UI.Library/HotkeySettings.cs index ff588eafbd..b5fa41fcf6 100644 --- a/src/settings-ui/Settings.UI.Library/HotkeySettings.cs +++ b/src/settings-ui/Settings.UI.Library/HotkeySettings.cs @@ -4,17 +4,30 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Globalization; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json.Serialization; - +using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library.Utilities; namespace Microsoft.PowerToys.Settings.UI.Library { - public record HotkeySettings : ICmdLineRepresentable + public record HotkeySettings : ICmdLineRepresentable, INotifyPropertyChanged { private const int VKTAB = 0x09; + private bool _hasConflict; + private string _conflictDescription; + private bool _isSystemConflict; + private bool _ignoreConflict; + + public event PropertyChangedEventHandler PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } public HotkeySettings() { @@ -23,6 +36,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library Alt = false; Shift = false; Code = 0; + + HasConflict = false; } /// <summary> @@ -40,6 +55,68 @@ namespace Microsoft.PowerToys.Settings.UI.Library Alt = alt; Shift = shift; Code = code; + HasConflict = false; + } + + [JsonIgnore] + public bool IgnoreConflict + { + get => _ignoreConflict; + set + { + if (_ignoreConflict != value) + { + _ignoreConflict = value; + OnPropertyChanged(); + } + } + } + + [JsonIgnore] + public bool HasConflict + { + get => _hasConflict; + set + { + if (_hasConflict != value) + { + _hasConflict = value; + OnPropertyChanged(); + } + } + } + + [JsonIgnore] + public string ConflictDescription + { + get => _ignoreConflict ? null : _conflictDescription; + set + { + if (_conflictDescription != value) + { + _conflictDescription = value; + OnPropertyChanged(); + } + } + } + + [JsonIgnore] + public bool IsSystemConflict + { + get => _isSystemConflict; + set + { + if (_isSystemConflict != value) + { + _isSystemConflict = value; + OnPropertyChanged(); + } + } + } + + public virtual void UpdateConflictStatus() + { + Logger.LogInfo($"{this.ToString()}"); } [JsonPropertyName("win")] @@ -120,9 +197,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library if (Shift) { - shortcutList.Add("Shift"); - - // shortcutList.Add(16); // The Shift key or button. + shortcutList.Add(16); // The Shift key or button. } if (Code > 0) diff --git a/src/settings-ui/Settings.UI.Library/ISettingsPath.cs b/src/settings-ui/Settings.UI.Library/ISettingsPath.cs deleted file mode 100644 index 072058e4bc..0000000000 --- a/src/settings-ui/Settings.UI.Library/ISettingsPath.cs +++ /dev/null @@ -1,17 +0,0 @@ -// 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.PowerToys.Settings.UI.Library -{ - public interface ISettingsPath - { - bool SettingsFolderExists(string powertoy); - - void CreateSettingsFolder(string powertoy); - - void DeleteSettings(string powertoy = ""); - - string GetSettingsPath(string powertoy, string fileName); - } -} diff --git a/src/settings-ui/Settings.UI.Library/ISettingsUtils.cs b/src/settings-ui/Settings.UI.Library/ISettingsUtils.cs deleted file mode 100644 index 3d3ef95f06..0000000000 --- a/src/settings-ui/Settings.UI.Library/ISettingsUtils.cs +++ /dev/null @@ -1,33 +0,0 @@ -// 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.PowerToys.Settings.UI.Library.Interfaces; - -namespace Microsoft.PowerToys.Settings.UI.Library -{ - public interface ISettingsUtils - { - public const string DefaultFileName = "settings.json"; - - T GetSettings<T>(string powertoy = "", string fileName = DefaultFileName) - where T : ISettingsConfig, new(); - - T GetSettingsOrDefault<T>(string powertoy = "", string fileName = DefaultFileName) - where T : ISettingsConfig, new(); - - void SaveSettings(string jsonSettings, string powertoy = "", string fileName = DefaultFileName); - - bool SettingsExists(string powertoy = "", string fileName = DefaultFileName); - - void DeleteSettings(string powertoy = ""); - - string GetSettingsFilePath(string powertoy = "", string fileName = DefaultFileName); - - T GetSettingsOrDefault<T, T2>(string powertoy = "", string fileName = DefaultFileName, Func<object, object> settingsUpgrader = null) - where T : ISettingsConfig, new() - where T2 : ISettingsConfig, new(); - } -} diff --git a/src/settings-ui/Settings.UI.Library/ImageResizerProperties.cs b/src/settings-ui/Settings.UI.Library/ImageResizerProperties.cs index acef5a2f4d..90080d1c5a 100644 --- a/src/settings-ui/Settings.UI.Library/ImageResizerProperties.cs +++ b/src/settings-ui/Settings.UI.Library/ImageResizerProperties.cs @@ -86,7 +86,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public string ToJsonString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.ImageResizerProperties); } } diff --git a/src/settings-ui/Settings.UI.Library/ImageResizerSettings.cs b/src/settings-ui/Settings.UI.Library/ImageResizerSettings.cs index 97f785d6c2..f3dffca9a0 100644 --- a/src/settings-ui/Settings.UI.Library/ImageResizerSettings.cs +++ b/src/settings-ui/Settings.UI.Library/ImageResizerSettings.cs @@ -37,8 +37,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public override string ToJsonString() { - var options = _serializerOptions; - return JsonSerializer.Serialize(this, options); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.ImageResizerSettings); } public string GetModuleName() diff --git a/src/settings-ui/Settings.UI.Library/ImageSize.cs b/src/settings-ui/Settings.UI.Library/ImageSize.cs index 017f665820..39b712d67f 100644 --- a/src/settings-ui/Settings.UI.Library/ImageSize.cs +++ b/src/settings-ui/Settings.UI.Library/ImageSize.cs @@ -8,11 +8,12 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; +using ManagedCommon; using Settings.UI.Library.Resources; namespace Microsoft.PowerToys.Settings.UI.Library; -public partial class ImageSize : INotifyPropertyChanged +public partial class ImageSize : INotifyPropertyChanged, IHasId { public event PropertyChangedEventHandler PropertyChanged; diff --git a/src/settings-ui/Settings.UI.Library/IntProperty.cs b/src/settings-ui/Settings.UI.Library/IntProperty.cs index ac6a87ad4a..63fae3e22b 100644 --- a/src/settings-ui/Settings.UI.Library/IntProperty.cs +++ b/src/settings-ui/Settings.UI.Library/IntProperty.cs @@ -42,7 +42,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library // Returns a JSON version of the class settings configuration class. public override string ToString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.IntProperty); } public static implicit operator IntProperty(int v) diff --git a/src/settings-ui/Settings.UI.Library/Interfaces/IHotkeyConfig.cs b/src/settings-ui/Settings.UI.Library/Interfaces/IHotkeyConfig.cs new file mode 100644 index 0000000000..ee38f51cad --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Interfaces/IHotkeyConfig.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.Library.Interfaces +{ + public interface IHotkeyConfig + { + HotkeyAccessor[] GetAllHotkeyAccessors(); + + ModuleType GetModuleType(); + } +} diff --git a/src/settings-ui/Settings.UI.Library/Interfaces/ISettingsRepository`1.cs b/src/settings-ui/Settings.UI.Library/Interfaces/ISettingsRepository`1.cs index a9cd92899a..32cb5a40f8 100644 --- a/src/settings-ui/Settings.UI.Library/Interfaces/ISettingsRepository`1.cs +++ b/src/settings-ui/Settings.UI.Library/Interfaces/ISettingsRepository`1.cs @@ -2,6 +2,8 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System; + namespace Microsoft.PowerToys.Settings.UI.Library.Interfaces { public interface ISettingsRepository<T> @@ -9,5 +11,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Interfaces T SettingsConfig { get; set; } bool ReloadSettings(); + + event Action<T> SettingsChanged; } } diff --git a/src/settings-ui/Settings.UI.Library/KeyboardManagerProfile.cs b/src/settings-ui/Settings.UI.Library/KeyboardManagerProfile.cs index 983f9a1f6a..091c70eb39 100644 --- a/src/settings-ui/Settings.UI.Library/KeyboardManagerProfile.cs +++ b/src/settings-ui/Settings.UI.Library/KeyboardManagerProfile.cs @@ -34,7 +34,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public string ToJsonString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.KeyboardManagerProfile); } public string GetModuleName() diff --git a/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs new file mode 100644 index 0000000000..4c56051ce9 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/LightSwitchProperties.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class LightSwitchProperties + { + public const bool DefaultChangeSystem = true; + public const bool DefaultChangeApps = true; + public const int DefaultLightTime = 480; + public const int DefaultDarkTime = 1200; + public const int DefaultSunriseOffset = 0; + public const int DefaultSunsetOffset = 0; + 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() + { + ChangeSystem = new BoolProperty(DefaultChangeSystem); + ChangeApps = new BoolProperty(DefaultChangeApps); + LightTime = new IntProperty(DefaultLightTime); + DarkTime = new IntProperty(DefaultDarkTime); + Latitude = new StringProperty(DefaultLatitude); + Longitude = new StringProperty(DefaultLongitude); + SunriseOffset = new IntProperty(DefaultSunriseOffset); + 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")] + public BoolProperty ChangeSystem { get; set; } + + [JsonPropertyName("changeApps")] + public BoolProperty ChangeApps { get; set; } + + [JsonPropertyName("lightTime")] + public IntProperty LightTime { get; set; } + + [JsonPropertyName("darkTime")] + public IntProperty DarkTime { get; set; } + + [JsonPropertyName("sunrise_offset")] + public IntProperty SunriseOffset { get; set; } + + [JsonPropertyName("sunset_offset")] + public IntProperty SunsetOffset { get; set; } + + [JsonPropertyName("latitude")] + public StringProperty Latitude { get; set; } + + [JsonPropertyName("longitude")] + public StringProperty Longitude { get; set; } + + [JsonPropertyName("scheduleMode")] + public StringProperty ScheduleMode { get; set; } + + [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 new file mode 100644 index 0000000000..4aa5647102 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/LightSwitchSettings.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json.Serialization; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class LightSwitchSettings : BasePTModuleSettings, ISettingsConfig, ICloneable, IHotkeyConfig + { + public const string ModuleName = "LightSwitch"; + + public LightSwitchSettings() + { + Name = ModuleName; + Version = Assembly.GetExecutingAssembly().GetName().Version.ToString(); + Properties = new LightSwitchProperties(); + } + + [JsonPropertyName("properties")] + public LightSwitchProperties Properties { get; set; } + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ToggleThemeHotkey.Value, + value => Properties.ToggleThemeHotkey.Value = value ?? LightSwitchProperties.DefaultToggleThemeHotkey, + "LightSwitch_ThemeToggle_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + + public ModuleType GetModuleType() => ModuleType.LightSwitch; + + public object Clone() + { + return new LightSwitchSettings() + { + Name = Name, + Version = Version, + Properties = new LightSwitchProperties() + { + ChangeSystem = new BoolProperty(Properties.ChangeSystem.Value), + ChangeApps = new BoolProperty(Properties.ChangeApps.Value), + ScheduleMode = new StringProperty(Properties.ScheduleMode.Value), + LightTime = new IntProperty((int)Properties.LightTime.Value), + DarkTime = new IntProperty((int)Properties.DarkTime.Value), + SunriseOffset = new IntProperty((int)Properties.SunriseOffset.Value), + SunsetOffset = new IntProperty((int)Properties.SunsetOffset.Value), + 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), + }, + }; + } + + public string GetModuleName() + { + return Name; + } + + public bool UpgradeSettingsConfiguration() + { + return false; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/MeasureToolProperties.cs b/src/settings-ui/Settings.UI.Library/MeasureToolProperties.cs index 5edc9f9175..48fc544ed4 100644 --- a/src/settings-ui/Settings.UI.Library/MeasureToolProperties.cs +++ b/src/settings-ui/Settings.UI.Library/MeasureToolProperties.cs @@ -46,6 +46,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library public IntProperty DefaultMeasureStyle { get; set; } - public override string ToString() => JsonSerializer.Serialize(this); + public override string ToString() => JsonSerializer.Serialize(this, SettingsSerializationContext.Default.MeasureToolProperties); } } diff --git a/src/settings-ui/Settings.UI.Library/MeasureToolSettings.cs b/src/settings-ui/Settings.UI.Library/MeasureToolSettings.cs index 5720c70ca5..e2d034eb21 100644 --- a/src/settings-ui/Settings.UI.Library/MeasureToolSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MeasureToolSettings.cs @@ -2,13 +2,15 @@ // 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 MeasureToolSettings : BasePTModuleSettings, ISettingsConfig + public class MeasureToolSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Measure Tool"; @@ -25,6 +27,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library public string GetModuleName() => Name; + public ModuleType GetModuleType() => ModuleType.MeasureTool; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MeasureTool_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() => false; 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<VcpCodeDisplayInfo> _vcpCodesFormatted = new List<VcpCodeDisplayInfo>(); + 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<ColorPresetItem> _availableColorPresetsCache; + private ObservableCollection<ColorPresetItem> _colorPresetsForDisplayCache; + private int _lastColorTemperatureVcpForCache = -1; + + /// <summary> + /// Invalidates the color preset cache and notifies property changes. + /// Call this when VcpCodesFormatted or SupportsColorTemperature changes. + /// </summary> + 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)); + } + } + } + + /// <summary> + /// Gets or sets the monitor number (Windows DISPLAY number, e.g., 1, 2, 3...). + /// </summary> + [JsonPropertyName("monitorNumber")] + public int MonitorNumber + { + get => _monitorNumber; + set + { + if (_monitorNumber != value) + { + _monitorNumber = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + /// <summary> + /// Gets or sets the total number of monitors (used for dynamic display name). + /// This is not serialized; it's set by the ViewModel. + /// </summary> + [JsonIgnore] + public int TotalMonitorCount + { + get => _totalMonitorCount; + set + { + if (_totalMonitorCount != value) + { + _totalMonitorCount = value; + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + /// <summary> + /// Gets the display name - includes monitor number when multiple monitors exist. + /// Follows the same logic as PowerDisplay UI's MonitorViewModel.DisplayName. + /// </summary> + [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(); + } + } + } + + /// <summary> + /// Gets or sets the color temperature VCP preset value (raw DDC/CI value from VCP code 0x14). + /// This stores the raw VCP value (e.g., 0x05 for 6500K preset), not the Kelvin temperature. + /// </summary> + [JsonPropertyName("colorTemperatureVcp")] + public int ColorTemperatureVcp + { + get => _colorTemperatureVcp; + set + { + if (_colorTemperatureVcp != value) + { + _colorTemperatureVcp = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(ColorPresetsForDisplay)); // Update display list when current value changes + } + } + } + + /// <summary> + /// Gets or sets the current contrast value (0-100). + /// </summary> + [JsonPropertyName("contrast")] + public int Contrast + { + get => _contrast; + set + { + if (_contrast != value) + { + _contrast = value; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets or sets the current volume value (0-100). + /// </summary> + [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<VcpCodeDisplayInfo> VcpCodesFormatted + { + get => _vcpCodesFormatted; + set + { + var newValue = value ?? new List<VcpCodeDisplayInfo>(); + + // Only update if content actually changed (compare by VCP code list content) + if (AreVcpCodesEqual(_vcpCodesFormatted, newValue)) + { + return; + } + + _vcpCodesFormatted = newValue; + OnPropertyChanged(); + InvalidateColorPresetCache(); + } + } + + /// <summary> + /// Compare two VcpCodesFormatted lists for equality by content. + /// Returns true if both lists have the same VCP codes (by code value). + /// </summary> + private static bool AreVcpCodesEqual(List<VcpCodeDisplayInfo> list1, List<VcpCodeDisplayInfo> 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(); + } + } + } + + /// <summary> + /// Gets available color temperature presets computed from VcpCodesFormatted (VCP code 0x14). + /// This is a computed property that parses the VCP capabilities data on-demand. + /// </summary> + private ObservableCollection<ColorPresetItem> AvailableColorPresets + { + get + { + // Return cached value if available + if (_availableColorPresetsCache != null) + { + return _availableColorPresetsCache; + } + + // Compute from VcpCodesFormatted + _availableColorPresetsCache = ComputeAvailableColorPresets(); + return _availableColorPresetsCache; + } + } + + /// <summary> + /// Compute available color presets from VcpCodesFormatted (VCP code 0x14). + /// Uses ColorTemperatureHelper from PowerDisplay.Lib for shared computation logic. + /// </summary> + private ObservableCollection<ColorPresetItem> ComputeAvailableColorPresets() + { + // Check if color temperature is supported + if (!_supportsColorTemperature || _vcpCodesFormatted == null) + { + return new ObservableCollection<ColorPresetItem>(); + } + + // 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<ColorPresetItem>(); + } + + // 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<ColorPresetItem>(presetList); + } + + /// <summary> + /// Gets color presets for display in ComboBox, includes current value if not in preset list. + /// Uses caching to avoid recreating collections on every access. + /// </summary> + [JsonIgnore] + public ObservableCollection<ColorPresetItem> 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<ColorPresetItem>(); + _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<ColorPresetItem>(); + + // 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<ColorPresetItem>(displayList); + } + + _lastColorTemperatureVcpForCache = _colorTemperatureVcp; + return _colorPresetsForDisplayCache; + } + } + + [JsonIgnore] + public bool HasCapabilities => !string.IsNullOrEmpty(_capabilitiesRaw); + + [JsonIgnore] + public bool ShowCapabilitiesWarning => _communicationMethod.Contains("WMI", StringComparison.OrdinalIgnoreCase); + + /// <summary> + /// Generate formatted text of all VCP codes for clipboard + /// </summary> + public string GetVcpCodesAsText() + { + if (_vcpCodesFormatted == null || _vcpCodesFormatted.Count == 0) + { + return "No VCP codes detected"; + } + + var lines = new List<string>(); + 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); + } + + /// <summary> + /// Update this monitor's properties from another MonitorInfo instance. + /// This preserves the object reference while updating all properties. + /// </summary> + /// <param name="other">The source MonitorInfo to copy properties from</param> + 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/MouseHighlighterProperties.cs b/src/settings-ui/Settings.UI.Library/MouseHighlighterProperties.cs index 298b0d9230..0fdb084b94 100644 --- a/src/settings-ui/Settings.UI.Library/MouseHighlighterProperties.cs +++ b/src/settings-ui/Settings.UI.Library/MouseHighlighterProperties.cs @@ -41,6 +41,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("auto_activate")] public BoolProperty AutoActivate { get; set; } + [JsonPropertyName("spotlight_mode")] + public BoolProperty SpotlightMode { get; set; } + public MouseHighlighterProperties() { ActivationShortcut = DefaultActivationShortcut; @@ -52,6 +55,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library HighlightFadeDelayMs = new IntProperty(500); HighlightFadeDurationMs = new IntProperty(250); AutoActivate = new BoolProperty(false); + SpotlightMode = new BoolProperty(false); } } } diff --git a/src/settings-ui/Settings.UI.Library/MouseHighlighterSettings.cs b/src/settings-ui/Settings.UI.Library/MouseHighlighterSettings.cs index e23a7fe288..54f28c026b 100644 --- a/src/settings-ui/Settings.UI.Library/MouseHighlighterSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MouseHighlighterSettings.cs @@ -2,15 +2,17 @@ // 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.Globalization; using System.Runtime.InteropServices; 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 MouseHighlighterSettings : BasePTModuleSettings, ISettingsConfig + public class MouseHighlighterSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "MouseHighlighter"; @@ -29,6 +31,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.MouseHighlighter; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_MouseHighlighter_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/MouseJumpSettings.cs b/src/settings-ui/Settings.UI.Library/MouseJumpSettings.cs index 450e6aec93..91944368a1 100644 --- a/src/settings-ui/Settings.UI.Library/MouseJumpSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MouseJumpSettings.cs @@ -3,16 +3,18 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; - +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using MouseJump.Common.Helpers; using MouseJump.Common.Models.Settings; namespace Microsoft.PowerToys.Settings.UI.Library { - public class MouseJumpSettings : BasePTModuleSettings, ISettingsConfig + public class MouseJumpSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "MouseJump"; @@ -31,7 +33,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library Version = "1.1"; } - public void Save(ISettingsUtils settingsUtils) + public void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; @@ -46,6 +48,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.MouseJump; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_MouseJump_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs index 9b0e530a2a..83427a9f30 100644 --- a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs +++ b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsProperties.cs @@ -13,9 +13,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library [CmdConfigureIgnore] public HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, false, true, false, 0x50); // Win + Alt + P + [CmdConfigureIgnore] + public HotkeySettings DefaultGlidingCursorActivationShortcut => new HotkeySettings(true, false, true, false, 0xBE); // Win + Alt + . + [JsonPropertyName("activation_shortcut")] public HotkeySettings ActivationShortcut { get; set; } + [JsonPropertyName("gliding_cursor_activation_shortcut")] + public HotkeySettings GlidingCursorActivationShortcut { get; set; } + [JsonPropertyName("crosshairs_color")] public StringProperty CrosshairsColor { get; set; } @@ -34,6 +40,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("crosshairs_border_size")] public IntProperty CrosshairsBorderSize { get; set; } + [JsonPropertyName("crosshairs_orientation")] + public IntProperty CrosshairsOrientation { get; set; } + [JsonPropertyName("crosshairs_auto_hide")] public BoolProperty CrosshairsAutoHide { get; set; } @@ -46,19 +55,29 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("auto_activate")] public BoolProperty AutoActivate { get; set; } + [JsonPropertyName("gliding_travel_speed")] + public IntProperty GlidingTravelSpeed { get; set; } + + [JsonPropertyName("gliding_delay_speed")] + public IntProperty GlidingDelaySpeed { get; set; } + public MousePointerCrosshairsProperties() { ActivationShortcut = DefaultActivationShortcut; + GlidingCursorActivationShortcut = DefaultGlidingCursorActivationShortcut; CrosshairsColor = new StringProperty("#FF0000"); CrosshairsOpacity = new IntProperty(75); CrosshairsRadius = new IntProperty(20); CrosshairsThickness = new IntProperty(5); CrosshairsBorderColor = new StringProperty("#FFFFFF"); CrosshairsBorderSize = new IntProperty(1); + CrosshairsOrientation = new IntProperty(0); // Default to both (0=Both, 1=Vertical, 2=Horizontal) CrosshairsAutoHide = new BoolProperty(false); CrosshairsIsFixedLengthEnabled = new BoolProperty(false); CrosshairsFixedLength = new IntProperty(1); AutoActivate = new BoolProperty(false); + GlidingTravelSpeed = new IntProperty(25); + GlidingDelaySpeed = new IntProperty(5); } } } diff --git a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsSettings.cs b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsSettings.cs index 2658a2adec..d814f115a1 100644 --- a/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MousePointerCrosshairsSettings.cs @@ -2,13 +2,15 @@ // 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 MousePointerCrosshairsSettings : BasePTModuleSettings, ISettingsConfig + public class MousePointerCrosshairsSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "MousePointerCrosshairs"; @@ -27,6 +29,25 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.MousePointerCrosshairs; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "MouseUtils_MousePointerCrosshairs_ActivationShortcut"), + new HotkeyAccessor( + () => Properties.GlidingCursorActivationShortcut, + value => Properties.GlidingCursorActivationShortcut = value ?? Properties.DefaultGlidingCursorActivationShortcut, + "MouseUtils_GlidingCursor"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/MouseWithoutBordersProperties.cs b/src/settings-ui/Settings.UI.Library/MouseWithoutBordersProperties.cs index 265b8a1e2d..2970cdb654 100644 --- a/src/settings-ui/Settings.UI.Library/MouseWithoutBordersProperties.cs +++ b/src/settings-ui/Settings.UI.Library/MouseWithoutBordersProperties.cs @@ -15,8 +15,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library public struct ConnectionRequest #pragma warning restore SA1649 // File name should match first type name { - public string PCName; - public string SecurityKey; + public string PCName { get; set; } + + public string SecurityKey { get; set; } } public struct NewKeyGenerationRequest @@ -92,6 +93,14 @@ namespace Microsoft.PowerToys.Settings.UI.Library public IntProperty EasyMouse { get; set; } + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool DisableEasyMouseWhenForegroundWindowIsFullscreen { get; set; } + + // Apps that are to be excluded when using DisableEasyMouseWhenForegroundWindowIsFullscreen + // meaning that it is possible to switch screen when these apps are running in fullscreen. + [CmdConfigureIgnore] + public GenericProperty<HashSet<string>> EasyMouseFullscreenSwitchBlockExcludedApps { get; set; } + [CmdConfigureIgnore] public IntProperty MachineID { get; set; } @@ -173,6 +182,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library ShowOriginalUI = false; UseService = false; + DisableEasyMouseWhenForegroundWindowIsFullscreen = true; + EasyMouseFullscreenSwitchBlockExcludedApps = new GenericProperty<HashSet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase)); + HotKeySwitchMachine = new IntProperty(0x70); // VK.F1 ToggleEasyMouseShortcut = DefaultHotKeyToggleEasyMouse; LockMachineShortcut = DefaultHotKeyLockMachine; diff --git a/src/settings-ui/Settings.UI.Library/MouseWithoutBordersSettings.cs b/src/settings-ui/Settings.UI.Library/MouseWithoutBordersSettings.cs index 6a51a150e5..5074bf56f7 100644 --- a/src/settings-ui/Settings.UI.Library/MouseWithoutBordersSettings.cs +++ b/src/settings-ui/Settings.UI.Library/MouseWithoutBordersSettings.cs @@ -3,15 +3,17 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Diagnostics; using System.Text.Json; 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 MouseWithoutBordersSettings : BasePTModuleSettings, ISettingsConfig + public class MouseWithoutBordersSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "MouseWithoutBorders"; @@ -37,6 +39,33 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.MouseWithoutBorders; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ToggleEasyMouseShortcut, + value => Properties.ToggleEasyMouseShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyToggleEasyMouse, + "MouseWithoutBorders_ToggleEasyMouseShortcut"), + new HotkeyAccessor( + () => Properties.LockMachineShortcut, + value => Properties.LockMachineShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyLockMachine, + "MouseWithoutBorders_LockMachinesShortcut"), + new HotkeyAccessor( + () => Properties.Switch2AllPCShortcut, + value => Properties.Switch2AllPCShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeySwitch2AllPC, + "MouseWithoutBorders_Switch2AllPcShortcut"), + new HotkeyAccessor( + () => Properties.ReconnectShortcut, + value => Properties.ReconnectShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyReconnect, + "MouseWithoutBorders_ReconnectShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public HotkeySettings ConvertMouseWithoutBordersHotKeyToPowerToys(int value) { // VK_A <= value <= VK_Z @@ -96,7 +125,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library #pragma warning restore CS0618 } - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; diff --git a/src/settings-ui/Settings.UI.Library/NewPlusProperties.cs b/src/settings-ui/Settings.UI.Library/NewPlusProperties.cs index ccdbeb9601..c2ad8cc328 100644 --- a/src/settings-ui/Settings.UI.Library/NewPlusProperties.cs +++ b/src/settings-ui/Settings.UI.Library/NewPlusProperties.cs @@ -33,6 +33,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("ReplaceVariables")] public BoolProperty ReplaceVariables { get; set; } - public override string ToString() => JsonSerializer.Serialize(this); + public override string ToString() => JsonSerializer.Serialize(this, SettingsSerializationContext.Default.NewPlusProperties); } } diff --git a/src/settings-ui/Settings.UI.Library/OutGoingGeneralSettings.cs b/src/settings-ui/Settings.UI.Library/OutGoingGeneralSettings.cs index d040016660..dad5f02b25 100644 --- a/src/settings-ui/Settings.UI.Library/OutGoingGeneralSettings.cs +++ b/src/settings-ui/Settings.UI.Library/OutGoingGeneralSettings.cs @@ -23,7 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public override string ToString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.OutGoingGeneralSettings); } } } diff --git a/src/settings-ui/Settings.UI.Library/OutGoingLanguageSettings.cs b/src/settings-ui/Settings.UI.Library/OutGoingLanguageSettings.cs index 61a16eb778..7f2d55faf6 100644 --- a/src/settings-ui/Settings.UI.Library/OutGoingLanguageSettings.cs +++ b/src/settings-ui/Settings.UI.Library/OutGoingLanguageSettings.cs @@ -23,7 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public override string ToString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.OutGoingLanguageSettings); } } } diff --git a/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs new file mode 100644 index 0000000000..2b743c4670 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIConfiguration.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// <summary> + /// Configuration for Paste AI features (custom action transformations like custom prompt processing) + /// </summary> + public class PasteAIConfiguration : INotifyPropertyChanged + { + private string _activeProviderId = string.Empty; + private ObservableCollection<PasteAIProviderDefinition> _providers = new(); + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("active-provider-id")] + public string ActiveProviderId + { + get => _activeProviderId; + set => SetProperty(ref _activeProviderId, value ?? string.Empty); + } + + [JsonPropertyName("providers")] + public ObservableCollection<PasteAIProviderDefinition> Providers + { + get => _providers; + set => SetProperty(ref _providers, value ?? new ObservableCollection<PasteAIProviderDefinition>()); + } + + [JsonIgnore] + public PasteAIProviderDefinition ActiveProvider + { + get + { + if (_providers is null || _providers.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(_activeProviderId)) + { + var match = _providers.FirstOrDefault(provider => string.Equals(provider.Id, _activeProviderId, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + return match; + } + } + + return _providers[0]; + } + } + + [JsonIgnore] + public AIServiceType ActiveServiceTypeKind => ActiveProvider?.ServiceTypeKind ?? AIServiceType.OpenAI; + + public override string ToString() + => JsonSerializer.Serialize(this, SettingsSerializationContext.Default.PasteAIConfiguration); + + protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer<T>.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.cs new file mode 100644 index 0000000000..1ccfa753fa --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefaults.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. + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// <summary> + /// Provides default values for Paste AI provider definitions. + /// </summary> + public static class PasteAIProviderDefaults + { + /// <summary> + /// Gets the default model name for a given AI service type. + /// </summary> + public static string GetDefaultModelName(AIServiceType serviceType) + { + return serviceType switch + { + AIServiceType.OpenAI => "gpt-4o", + AIServiceType.AzureOpenAI => "gpt-4o", + AIServiceType.Mistral => "mistral-large-latest", + AIServiceType.Google => "gemini-1.5-pro", + AIServiceType.AzureAIInference => "gpt-4o-mini", + AIServiceType.Ollama => "llama3", + _ => string.Empty, + }; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs new file mode 100644 index 0000000000..0fbb3328e7 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/PasteAIProviderDefinition.cs @@ -0,0 +1,175 @@ +// 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.Runtime.CompilerServices; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// <summary> + /// Represents a single Paste AI provider configuration entry. + /// </summary> + public class PasteAIProviderDefinition : INotifyPropertyChanged + { + private string _id = Guid.NewGuid().ToString("N"); + private string _serviceType = "OpenAI"; + private string _modelName = string.Empty; + private string _endpointUrl = string.Empty; + private string _apiVersion = string.Empty; + private string _deploymentName = string.Empty; + private string _modelPath = string.Empty; + private string _systemPrompt = string.Empty; + private bool _moderationEnabled = true; + private bool _isActive; + private bool _enableAdvancedAI; + private bool _isLocalModel; + + public event PropertyChangedEventHandler PropertyChanged; + + [JsonPropertyName("id")] + public string Id + { + get => _id; + set => SetProperty(ref _id, value); + } + + [JsonPropertyName("service-type")] + public string ServiceType + { + get => _serviceType; + set + { + if (SetProperty(ref _serviceType, string.IsNullOrWhiteSpace(value) ? "OpenAI" : value)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonIgnore] + public AIServiceType ServiceTypeKind + { + get => ServiceType.ToAIServiceType(); + set => ServiceType = value.ToConfigurationString(); + } + + [JsonPropertyName("model-name")] + public string ModelName + { + get => _modelName; + set + { + if (SetProperty(ref _modelName, value ?? string.Empty)) + { + OnPropertyChanged(nameof(DisplayName)); + } + } + } + + [JsonPropertyName("endpoint-url")] + public string EndpointUrl + { + get => _endpointUrl; + set => SetProperty(ref _endpointUrl, value ?? string.Empty); + } + + [JsonPropertyName("api-version")] + public string ApiVersion + { + get => _apiVersion; + set => SetProperty(ref _apiVersion, value ?? string.Empty); + } + + [JsonPropertyName("deployment-name")] + public string DeploymentName + { + get => _deploymentName; + set => SetProperty(ref _deploymentName, value ?? string.Empty); + } + + [JsonPropertyName("model-path")] + public string ModelPath + { + get => _modelPath; + set => SetProperty(ref _modelPath, value ?? string.Empty); + } + + [JsonPropertyName("system-prompt")] + public string SystemPrompt + { + get => _systemPrompt; + set => SetProperty(ref _systemPrompt, value?.Trim() ?? string.Empty); + } + + [JsonPropertyName("moderation-enabled")] + public bool ModerationEnabled + { + get => _moderationEnabled; + set => SetProperty(ref _moderationEnabled, value); + } + + [JsonPropertyName("enable-advanced-ai")] + public bool EnableAdvancedAI + { + get => _enableAdvancedAI; + set => SetProperty(ref _enableAdvancedAI, value); + } + + [JsonPropertyName("is-local-model")] + public bool IsLocalModel + { + get => _isLocalModel; + set => SetProperty(ref _isLocalModel, value); + } + + [JsonIgnore] + public bool IsActive + { + get => _isActive; + set => SetProperty(ref _isActive, value); + } + + [JsonIgnore] + public string DisplayName => string.IsNullOrWhiteSpace(ModelName) ? ServiceType : ModelName; + + public PasteAIProviderDefinition Clone() + { + return new PasteAIProviderDefinition + { + Id = Id, + ServiceType = ServiceType, + ModelName = ModelName, + EndpointUrl = EndpointUrl, + ApiVersion = ApiVersion, + DeploymentName = DeploymentName, + ModelPath = ModelPath, + SystemPrompt = SystemPrompt, + ModerationEnabled = ModerationEnabled, + EnableAdvancedAI = EnableAdvancedAI, + IsLocalModel = IsLocalModel, + IsActive = IsActive, + }; + } + + protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer<T>.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PeekPreviewSettings.cs b/src/settings-ui/Settings.UI.Library/PeekPreviewSettings.cs index fa2fb3bf6c..b139076fe8 100644 --- a/src/settings-ui/Settings.UI.Library/PeekPreviewSettings.cs +++ b/src/settings-ui/Settings.UI.Library/PeekPreviewSettings.cs @@ -34,7 +34,7 @@ namespace Settings.UI.Library public string ToJsonString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, Microsoft.PowerToys.Settings.UI.Library.SettingsSerializationContext.Default.PeekPreviewSettings); } public string GetModuleName() diff --git a/src/settings-ui/Settings.UI.Library/PeekProperties.cs b/src/settings-ui/Settings.UI.Library/PeekProperties.cs index f81a3bc9a6..e8b8692888 100644 --- a/src/settings-ui/Settings.UI.Library/PeekProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PeekProperties.cs @@ -19,6 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library AlwaysRunNotElevated = new BoolProperty(true); CloseAfterLosingFocus = new BoolProperty(false); ConfirmFileDelete = new BoolProperty(true); + EnableSpaceToActivate = new BoolProperty(true); // Toggle is ON by default for new users. No impact on existing users. } public HotkeySettings ActivationShortcut { get; set; } @@ -29,6 +30,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library public BoolProperty ConfirmFileDelete { get; set; } - public override string ToString() => JsonSerializer.Serialize(this); + public BoolProperty EnableSpaceToActivate { get; set; } + + public override string ToString() => JsonSerializer.Serialize(this, SettingsSerializationContext.Default.PeekProperties); } } diff --git a/src/settings-ui/Settings.UI.Library/PeekSettings.cs b/src/settings-ui/Settings.UI.Library/PeekSettings.cs index f5ad2a0e26..a62d6e60e9 100644 --- a/src/settings-ui/Settings.UI.Library/PeekSettings.cs +++ b/src/settings-ui/Settings.UI.Library/PeekSettings.cs @@ -3,17 +3,21 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; 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 PeekSettings : BasePTModuleSettings, ISettingsConfig + public class PeekSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Peek"; - public const string ModuleVersion = "0.0.1"; + public const string InitialModuleVersion = "0.0.1"; + public const string SpaceActivationIntroducedVersion = "0.0.2"; + public const string CurrentModuleVersion = SpaceActivationIntroducedVersion; private static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions { @@ -26,7 +30,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public PeekSettings() { Name = ModuleName; - Version = ModuleVersion; + Version = CurrentModuleVersion; Properties = new PeekProperties(); } @@ -35,12 +39,35 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.Peek; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + public bool UpgradeSettingsConfiguration() { + if (string.IsNullOrEmpty(Version) || + Version.Equals(InitialModuleVersion, StringComparison.OrdinalIgnoreCase)) + { + Version = CurrentModuleVersion; + Properties.EnableSpaceToActivate.Value = false; + return true; + } + return false; } - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; 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 +{ + /// <summary> + /// Message for PowerDisplay module actions + /// </summary> + 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<MonitorInfo>(); + RestoreSettingsOnStartup = false; + ShowSystemTrayIcon = true; + ShowProfileSwitcher = true; + ShowIdentifyMonitorsButton = true; + CustomVcpMappings = new List<CustomVcpValueMapping>(); + + // 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; } + + /// <summary> + /// Gets or sets delay in seconds before refreshing monitors after display changes (hot-plug). + /// This allows hardware to stabilize before querying DDC/CI. + /// </summary> + [JsonPropertyName("monitor_refresh_delay")] + public int MonitorRefreshDelay { get; set; } + + [JsonPropertyName("monitors")] + public List<MonitorInfo> Monitors { get; set; } + + [JsonPropertyName("restore_settings_on_startup")] + public bool RestoreSettingsOnStartup { get; set; } + + [JsonPropertyName("show_system_tray_icon")] + public bool ShowSystemTrayIcon { get; set; } + + /// <summary> + /// 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. + /// </summary> + [JsonPropertyName("show_profile_switcher")] + public bool ShowProfileSwitcher { get; set; } + + /// <summary> + /// Gets or sets whether to show the identify monitors button in the flyout UI. + /// Default is true. + /// </summary> + [JsonPropertyName("show_identify_monitors_button")] + public bool ShowIdentifyMonitorsButton { get; set; } + + /// <summary> + /// 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. + /// </summary> + [JsonPropertyName("custom_vcp_mappings")] + public List<CustomVcpValueMapping> 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<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/PowerLauncherProperties.cs b/src/settings-ui/Settings.UI.Library/PowerLauncherProperties.cs index 590fb2e290..08eb16a389 100644 --- a/src/settings-ui/Settings.UI.Library/PowerLauncherProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PowerLauncherProperties.cs @@ -94,6 +94,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library [JsonPropertyName("generate_thumbnails_from_files")] public bool GenerateThumbnailsFromFiles { get; set; } + [JsonPropertyName("hotkey_changed")] + public bool HotkeyChanged { get; set; } = false; + [CmdConfigureIgnoreAttribute] public HotkeySettings DefaultOpenPowerLauncher => new HotkeySettings(false, false, true, false, 32); diff --git a/src/settings-ui/Settings.UI.Library/PowerLauncherSettings.cs b/src/settings-ui/Settings.UI.Library/PowerLauncherSettings.cs index c21ce67df5..b9a438f472 100644 --- a/src/settings-ui/Settings.UI.Library/PowerLauncherSettings.cs +++ b/src/settings-ui/Settings.UI.Library/PowerLauncherSettings.cs @@ -6,12 +6,13 @@ using System; using System.Collections.Generic; using System.Text.Json; 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 PowerLauncherSettings : BasePTModuleSettings, ISettingsConfig + public class PowerLauncherSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "PowerToys Run"; @@ -34,7 +35,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library Name = ModuleName; } - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; @@ -49,6 +50,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.PowerLauncher; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.OpenPowerLauncher, + value => Properties.OpenPowerLauncher = value ?? Properties.DefaultOpenPowerLauncher, + "PowerLauncher_OpenPowerLauncher"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/PowerOcrProperties.cs b/src/settings-ui/Settings.UI.Library/PowerOcrProperties.cs index 5cba3cfb3e..1570038f22 100644 --- a/src/settings-ui/Settings.UI.Library/PowerOcrProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PowerOcrProperties.cs @@ -24,6 +24,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library public string PreferredLanguage { get; set; } public override string ToString() - => JsonSerializer.Serialize(this); + => JsonSerializer.Serialize(this, SettingsSerializationContext.Default.PowerOcrProperties); } } diff --git a/src/settings-ui/Settings.UI.Library/PowerOcrSettings.cs b/src/settings-ui/Settings.UI.Library/PowerOcrSettings.cs index 46d176d2b0..f0300922d0 100644 --- a/src/settings-ui/Settings.UI.Library/PowerOcrSettings.cs +++ b/src/settings-ui/Settings.UI.Library/PowerOcrSettings.cs @@ -3,14 +3,16 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; 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 PowerOcrSettings : BasePTModuleSettings, ISettingsConfig + public class PowerOcrSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "TextExtractor"; @@ -29,7 +31,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library Name = ModuleName; } - public virtual void Save(ISettingsUtils settingsUtils) + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; @@ -42,6 +44,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library public string GetModuleName() => Name; + public ModuleType GetModuleType() => ModuleType.PowerOCR; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.ActivationShortcut, + value => Properties.ActivationShortcut = value ?? Properties.DefaultActivationShortcut, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() => false; diff --git a/src/settings-ui/Settings.UI.Library/PowerPreviewProperties.cs b/src/settings-ui/Settings.UI.Library/PowerPreviewProperties.cs index f6dd527015..b99b19aae7 100644 --- a/src/settings-ui/Settings.UI.Library/PowerPreviewProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PowerPreviewProperties.cs @@ -223,6 +223,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool enableBgcodePreview = true; + + [JsonPropertyName("bgcode-previewer-toggle-setting")] + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool EnableBgcodePreview + { + get => enableBgcodePreview; + set + { + if (value != enableBgcodePreview) + { + LogTelemetryEvent(value); + enableBgcodePreview = value; + } + } + } + private bool enableGcodeThumbnail = true; [JsonPropertyName("gcode-thumbnail-toggle-setting")] @@ -240,6 +257,23 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool enableBgcodeThumbnail = true; + + [JsonPropertyName("bgcode-thumbnail-toggle-setting")] + [JsonConverter(typeof(BoolPropertyJsonConverter))] + public bool EnableBgcodeThumbnail + { + get => enableBgcodeThumbnail; + set + { + if (value != enableBgcodeThumbnail) + { + LogTelemetryEvent(value); + enableBgcodeThumbnail = value; + } + } + } + private bool enableStlThumbnail = true; [JsonPropertyName("stl-thumbnail-toggle-setting")] @@ -306,7 +340,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public override string ToString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.PowerPreviewProperties); } private static void LogTelemetryEvent(bool value, [CallerMemberName] string propertyName = null) diff --git a/src/settings-ui/Settings.UI.Library/PowerRenameLocalProperties.cs b/src/settings-ui/Settings.UI.Library/PowerRenameLocalProperties.cs index 726faf2bc0..cec4b472c7 100644 --- a/src/settings-ui/Settings.UI.Library/PowerRenameLocalProperties.cs +++ b/src/settings-ui/Settings.UI.Library/PowerRenameLocalProperties.cs @@ -54,7 +54,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public string ToJsonString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.PowerRenameLocalProperties); } // This function is required to implement the ISettingsConfig interface and obtain the settings configurations. diff --git a/src/settings-ui/Settings.UI.Library/SettingEntry.cs b/src/settings-ui/Settings.UI.Library/SettingEntry.cs new file mode 100644 index 0000000000..8f5f6ed0da --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SettingEntry.cs @@ -0,0 +1,44 @@ +// 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 Settings.UI.Library +{ + public enum EntryType + { + SettingsPage, + SettingsCard, + SettingsExpander, + } + + public struct SettingEntry + { + public EntryType Type { get; set; } + + public string Header { get; set; } + + public string PageTypeName { get; set; } + + public string ElementName { get; set; } + + public string ElementUid { get; set; } + + public string ParentElementName { get; set; } + + public string Description { get; set; } + + public string Icon { get; set; } + + public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null) + { + Type = type; + Header = header; + PageTypeName = pageTypeName; + ElementName = elementName; + ElementUid = elementUid; + ParentElementName = parentElementName; + Description = description; + Icon = icon; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/SettingPath.cs b/src/settings-ui/Settings.UI.Library/SettingPath.cs index 94c5d83ca1..b97779008c 100644 --- a/src/settings-ui/Settings.UI.Library/SettingPath.cs +++ b/src/settings-ui/Settings.UI.Library/SettingPath.cs @@ -9,7 +9,7 @@ using Microsoft.PowerToys.Settings.UI.Library.Utilities; namespace Microsoft.PowerToys.Settings.UI.Library { - public class SettingPath : ISettingsPath + public class SettingPath { private const string DefaultFileName = "settings.json"; @@ -23,6 +23,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library _path = path ?? throw new ArgumentNullException(nameof(path)); } + public SettingPath() + : this(new FileSystem().Directory, new FileSystem().Path) + { + } + public bool SettingsFolderExists(string powertoy) { return _directory.Exists(System.IO.Path.Combine(Helper.LocalApplicationDataFolder(), $"Microsoft\\PowerToys\\{powertoy}")); diff --git a/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj b/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj index c3832b11c5..744e42e38c 100644 --- a/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj +++ b/src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj @@ -1,7 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\Common.SelfContained.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> <PropertyGroup> <Description>PowerToys Settings UI Library</Description> <AssemblyName>PowerToys.Settings.UI.Lib</AssemblyName> @@ -23,6 +23,7 @@ <ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" /> <ProjectReference Include="..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> <ProjectReference Include="..\..\modules\MouseUtils\MouseJump.Common\MouseJump.Common.csproj" /> + <ProjectReference Include="..\..\modules\powerdisplay\PowerDisplay.Lib\PowerDisplay.Lib.csproj" /> </ItemGroup> <ItemGroup> diff --git a/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs b/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs index 4b79045483..08708009d0 100644 --- a/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs +++ b/src/settings-ui/Settings.UI.Library/SettingsBackupAndRestoreUtils.cs @@ -384,7 +384,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library } /// <summary> - /// Method <c>GetSettingsBackupAndRestoreDir</c> returns the path the backup and restore location. + /// Method <c>GetSettingsBackupAndRestoreDir</c> returns the path of the backup and restore location. /// </summary> /// <remarks> /// This will return a default location based on user documents if non is set. @@ -592,7 +592,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library /// </summary> public (bool Success, string Message, string Severity, bool LastBackupExists, string OptionalMessage) DryRunBackup() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; var appBasePath = Path.GetDirectoryName(settingsUtils.GetSettingsFilePath()); string settingsBackupAndRestoreDir = GetSettingsBackupAndRestoreDir(); var results = BackupSettings(appBasePath, settingsBackupAndRestoreDir, true); @@ -653,11 +653,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library return (false, "General_SettingsBackupAndRestore_InvalidBackupLocation", "Error", lastBackupExists, "\n" + appBasePath); } - var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir); - if (!dirExists) + // Only create the backup directory if this is not a dry run + if (!dryRun) { - Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}"); - return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir); + var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir); + if (!dirExists) + { + Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}"); + return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir); + } } // get data needed for process @@ -717,12 +721,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library var relativePath = currentFile.Value.Substring(appBasePath.Length + 1); var backupFullPath = Path.Combine(fullBackupDir, relativePath); - TryCreateDirectory(fullBackupDir); - TryCreateDirectory(Path.GetDirectoryName(backupFullPath)); - Logger.LogInfo($"BackupSettings writing, {backupFullPath}, dryRun:{dryRun}."); if (!dryRun) { + TryCreateDirectory(fullBackupDir); + TryCreateDirectory(Path.GetDirectoryName(backupFullPath)); File.WriteAllText(backupFullPath, currentSettingsFileToBackup); } } @@ -959,7 +962,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library if (item.Value.Contains("PowerToys_settings_", StringComparison.OrdinalIgnoreCase)) { - // this is a temp backup and we want to clean based on the time it was created in the temp place, not the time the backup was made. + // this is a temp backup and we want to clean based on the time it was created in the temp place, not the time that the backup was made. var folderCreatedTime = new DirectoryInfo(item.Value).CreationTimeUtc; if (folderCreatedTime > backupTime) diff --git a/src/settings-ui/Settings.UI.Library/SettingsFactory.cs b/src/settings-ui/Settings.UI.Library/SettingsFactory.cs new file mode 100644 index 0000000000..6e53204e33 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SettingsFactory.cs @@ -0,0 +1,208 @@ +// 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.Reflection; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Services +{ + /// <summary> + /// Factory service for getting PowerToys module Settings that implement IHotkeyConfig + /// </summary> + public class SettingsFactory + { + private readonly SettingsUtils _settingsUtils; + private readonly Dictionary<string, Type> _settingsTypes; + + public SettingsFactory(SettingsUtils settingsUtils) + { + _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); + _settingsTypes = DiscoverSettingsTypes(); + } + + /// <summary> + /// Dynamically discovers all Settings types that implement IHotkeyConfig + /// </summary> + private Dictionary<string, Type> DiscoverSettingsTypes() + { + var settingsTypes = new Dictionary<string, Type>(); + + // Get the Settings.UI.Library assembly + var assembly = Assembly.GetAssembly(typeof(IHotkeyConfig)); + if (assembly == null) + { + return settingsTypes; + } + + try + { + // Find all types that implement IHotkeyConfig and ISettingsConfig + var hotkeyConfigTypes = assembly.GetTypes() + .Where(type => + type.IsClass && + !type.IsAbstract && + typeof(IHotkeyConfig).IsAssignableFrom(type) && + typeof(ISettingsConfig).IsAssignableFrom(type)) + .ToList(); + + foreach (var type in hotkeyConfigTypes) + { + // Try to get the ModuleName using SettingsRepository + try + { + var repositoryType = typeof(SettingsRepository<>).MakeGenericType(type); + var getInstanceMethod = repositoryType.GetMethod("GetInstance", BindingFlags.Public | BindingFlags.Static); + var repository = getInstanceMethod?.Invoke(null, new object[] { _settingsUtils }); + + if (repository != null) + { + var settingsConfigProperty = repository.GetType().GetProperty("SettingsConfig"); + var settingsInstance = settingsConfigProperty?.GetValue(repository) as ISettingsConfig; + + if (settingsInstance != null) + { + var moduleName = settingsInstance.GetModuleName(); + if (string.IsNullOrEmpty(moduleName) && type == typeof(GeneralSettings)) + { + moduleName = "GeneralSettings"; + } + + if (!string.IsNullOrEmpty(moduleName)) + { + settingsTypes[moduleName] = type; + System.Diagnostics.Debug.WriteLine($"Discovered settings type: {type.Name} for module: {moduleName}"); + } + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting module name for {type.Name}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error scanning assembly {assembly.FullName}: {ex.Message}"); + } + + return settingsTypes; + } + + public IHotkeyConfig GetFreshSettings(string moduleKey) + { + if (!_settingsTypes.TryGetValue(moduleKey, out var settingsType)) + { + return null; + } + + try + { + // Create a generic method call to _settingsUtils.GetSettingsOrDefault<T>(moduleKey) + var getSettingsMethod = typeof(SettingsUtils).GetMethod("GetSettingsOrDefault", new[] { typeof(string), typeof(string) }); + var genericMethod = getSettingsMethod?.MakeGenericMethod(settingsType); + + // Call GetSettingsOrDefault<T>(moduleKey) to get fresh settings from file + string actualModuleKey = moduleKey; + if (moduleKey == "GeneralSettings") + { + actualModuleKey = string.Empty; + } + + var freshSettings = genericMethod?.Invoke(_settingsUtils, new object[] { actualModuleKey, "settings.json" }); + + return freshSettings as IHotkeyConfig; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting fresh settings for {moduleKey}: {ex.Message}"); + return null; + } + } + + /// <summary> + /// Gets a settings instance for the specified module using SettingsRepository + /// </summary> + /// <param name="moduleKey">The module key/name</param> + /// <returns>The settings instance implementing IHotkeyConfig, or null if not found</returns> + public IHotkeyConfig GetSettings(string moduleKey) + { + if (!_settingsTypes.TryGetValue(moduleKey, out var settingsType)) + { + return null; + } + + try + { + var repositoryType = typeof(SettingsRepository<>).MakeGenericType(settingsType); + var getInstanceMethod = repositoryType.GetMethod("GetInstance", BindingFlags.Public | BindingFlags.Static); + var repository = getInstanceMethod?.Invoke(null, new object[] { _settingsUtils }); + + if (repository != null) + { + var settingsConfigProperty = repository.GetType().GetProperty("SettingsConfig"); + return settingsConfigProperty?.GetValue(repository) as IHotkeyConfig; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting Settings for {moduleKey}: {ex.Message}"); + } + + return null; + } + + /// <summary> + /// Gets all available module names that have settings implementing IHotkeyConfig + /// </summary> + /// <returns>List of module names</returns> + public List<string> GetAvailableModuleNames() + { + return _settingsTypes.Keys.ToList(); + } + + /// <summary> + /// Gets all available settings that implement IHotkeyConfig + /// </summary> + /// <returns>Dictionary of module name to settings instance</returns> + public Dictionary<string, IHotkeyConfig> GetAllHotkeySettings() + { + var result = new Dictionary<string, IHotkeyConfig>(); + + foreach (var moduleKey in _settingsTypes.Keys) + { + try + { + var settings = GetSettings(moduleKey); + if (settings != null) + { + result[moduleKey] = settings; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting settings for {moduleKey}: {ex.Message}"); + } + } + + return result; + } + + /// <summary> + /// Gets a specific settings repository instance + /// </summary> + /// <typeparam name="T">The settings type</typeparam> + /// <returns>The settings repository instance</returns> + public ISettingsRepository<T> GetRepository<T>() + where T : class, ISettingsConfig, new() + { + return SettingsRepository<T>.GetInstance(_settingsUtils); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/SettingsRepository`1.cs b/src/settings-ui/Settings.UI.Library/SettingsRepository`1.cs index 136b92b63a..de3b3005d9 100644 --- a/src/settings-ui/Settings.UI.Library/SettingsRepository`1.cs +++ b/src/settings-ui/Settings.UI.Library/SettingsRepository`1.cs @@ -3,29 +3,34 @@ // See the LICENSE file in the project root for more information. using System; +using System.IO; using System.Threading; - +using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { // This Singleton class is a wrapper around the settings configurations that are accessed by viewmodels. // This class can have only one instance and therefore the settings configurations are common to all. - public class SettingsRepository<T> : ISettingsRepository<T> + public sealed class SettingsRepository<T> : ISettingsRepository<T>, IDisposable where T : class, ISettingsConfig, new() { private static readonly Lock _SettingsRepoLock = new Lock(); - private static ISettingsUtils _settingsUtils; + private static SettingsUtils _settingsUtils; private static SettingsRepository<T> settingsRepository; private T settingsConfig; + private FileSystemWatcher _watcher; + + public event Action<T> SettingsChanged; + // Suppressing the warning as this is a singleton class and this method is // necessarily static #pragma warning disable CA1000 // Do not declare static members on generic types - public static SettingsRepository<T> GetInstance(ISettingsUtils settingsUtils) + public static SettingsRepository<T> GetInstance(SettingsUtils settingsUtils) #pragma warning restore CA1000 // Do not declare static members on generic types { // To ensure that only one instance of Settings Repository is created in a multi-threaded environment. @@ -35,6 +40,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library { settingsRepository = new SettingsRepository<T>(); _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); + settingsRepository.InitializeWatcher(); } return settingsRepository; @@ -46,12 +52,51 @@ namespace Microsoft.PowerToys.Settings.UI.Library { } + private void InitializeWatcher() + { + try + { + var settingsItem = new T(); + var filePath = _settingsUtils.GetSettingsFilePath(settingsItem.GetModuleName()); + var directory = Path.GetDirectoryName(filePath); + var fileName = Path.GetFileName(filePath); + + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + _watcher = new FileSystemWatcher(directory, fileName); + _watcher.NotifyFilter = NotifyFilters.LastWrite; + _watcher.Changed += Watcher_Changed; + _watcher.EnableRaisingEvents = true; + } + catch (Exception ex) + { + Logger.LogError($"Failed to initialize settings watcher for {typeof(T).Name}", ex); + } + } + + private void Watcher_Changed(object sender, FileSystemEventArgs e) + { + // Wait a bit for the file write to complete and retry if needed + for (int i = 0; i < 5; i++) + { + Thread.Sleep(100); + if (ReloadSettings()) + { + SettingsChanged?.Invoke(SettingsConfig); + return; + } + } + } + public bool ReloadSettings() { try { T settingsItem = new T(); - settingsConfig = _settingsUtils.GetSettingsOrDefault<T>(settingsItem.GetModuleName()); + settingsConfig = _settingsUtils.GetSettings<T>(settingsItem.GetModuleName()); SettingsConfig = settingsConfig; @@ -85,5 +130,26 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } } + + public void StopWatching() + { + if (_watcher != null) + { + _watcher.EnableRaisingEvents = false; + } + } + + public void StartWatching() + { + if (_watcher != null) + { + _watcher.EnableRaisingEvents = true; + } + } + + public void Dispose() + { + _watcher?.Dispose(); + } } } diff --git a/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs b/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs new file mode 100644 index 0000000000..366f98cd8b --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SettingsSerializationContext.cs @@ -0,0 +1,197 @@ +// 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 SettingsUILibrary = Settings.UI.Library; +using SettingsUILibraryHelpers = Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + /// <summary> + /// JSON serialization context for Native AOT compatibility. + /// This context provides source-generated serialization for all PowerToys settings types. + /// </summary> + /// <remarks> + /// <para><strong>⚠️ CRITICAL REQUIREMENT FOR ALL NEW SETTINGS CLASSES ⚠️</strong></para> + /// <para> + /// When adding a new PowerToys module or any class that inherits from <see cref="BasePTModuleSettings"/>, + /// you <strong>MUST</strong> add a <c>[JsonSerializable(typeof(YourNewSettingsClass))]</c> attribute + /// to this class. This is a MANDATORY step for Native AOT compatibility. + /// </para> + /// <para><strong>Steps to add a new settings class:</strong></para> + /// <list type="number"> + /// <item><description>Create your new settings class (e.g., <c>MyNewModuleSettings</c>) that inherits from <see cref="BasePTModuleSettings"/></description></item> + /// <item><description>Add <c>[JsonSerializable(typeof(MyNewModuleSettings))]</c> attribute to this <see cref="SettingsSerializationContext"/> class</description></item> + /// <item><description>If you have a corresponding Properties class, also add <c>[JsonSerializable(typeof(MyNewModuleProperties))]</c></description></item> + /// <item><description>Rebuild the project - source generator will create serialization code at compile time</description></item> + /// </list> + /// <para><strong>⚠️ Failure to register types will cause runtime errors:</strong></para> + /// <para> + /// If you forget to add the <c>[JsonSerializable]</c> attribute, calling <c>ToJsonString()</c> or + /// deserialization methods will throw <see cref="InvalidOperationException"/> at runtime with a clear + /// error message indicating which type is missing registration. + /// </para> + /// </remarks> + [JsonSourceGenerationOptions( + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + IncludeFields = true)] + + // Main Settings Classes + [JsonSerializable(typeof(GeneralSettings))] + [JsonSerializable(typeof(OutGoingGeneralSettings))] + [JsonSerializable(typeof(AdvancedPasteSettings))] + [JsonSerializable(typeof(AlwaysOnTopSettings))] + [JsonSerializable(typeof(AwakeSettings))] + [JsonSerializable(typeof(CmdNotFoundSettings))] + [JsonSerializable(typeof(ColorPickerSettings))] + [JsonSerializable(typeof(ColorPickerSettingsVersion1))] + [JsonSerializable(typeof(CropAndLockSettings))] + [JsonSerializable(typeof(CursorWrapSettings))] + [JsonSerializable(typeof(EnvironmentVariablesSettings))] + [JsonSerializable(typeof(FancyZonesSettings))] + [JsonSerializable(typeof(FileLocksmithSettings))] + [JsonSerializable(typeof(FindMyMouseSettings))] + [JsonSerializable(typeof(HostsSettings))] + [JsonSerializable(typeof(ImageResizerSettings))] + [JsonSerializable(typeof(KeyboardManagerSettings))] + [JsonSerializable(typeof(LightSwitchSettings))] + [JsonSerializable(typeof(MeasureToolSettings))] + [JsonSerializable(typeof(MouseHighlighterSettings))] + [JsonSerializable(typeof(MouseJumpSettings))] + [JsonSerializable(typeof(MousePointerCrosshairsSettings))] + [JsonSerializable(typeof(MouseWithoutBordersSettings))] + [JsonSerializable(typeof(NewPlusSettings))] + [JsonSerializable(typeof(PeekSettings))] + [JsonSerializable(typeof(PowerAccentSettings))] + [JsonSerializable(typeof(PowerDisplaySettings))] + [JsonSerializable(typeof(PowerLauncherSettings))] + [JsonSerializable(typeof(PowerOcrSettings))] + [JsonSerializable(typeof(PowerPreviewSettings))] + [JsonSerializable(typeof(PowerRenameSettings))] + [JsonSerializable(typeof(RegistryPreviewSettings))] + [JsonSerializable(typeof(ShortcutGuideSettings))] + [JsonSerializable(typeof(WorkspacesSettings))] + [JsonSerializable(typeof(ZoomItSettings))] + + // Properties Classes + [JsonSerializable(typeof(AdvancedPasteProperties))] + [JsonSerializable(typeof(AlwaysOnTopProperties))] + [JsonSerializable(typeof(AwakeProperties))] + [JsonSerializable(typeof(CmdPalProperties))] + [JsonSerializable(typeof(ColorPickerProperties))] + [JsonSerializable(typeof(ColorPickerPropertiesVersion1))] + [JsonSerializable(typeof(CropAndLockProperties))] + [JsonSerializable(typeof(CursorWrapProperties))] + [JsonSerializable(typeof(EnvironmentVariablesProperties))] + [JsonSerializable(typeof(FileLocksmithProperties))] + [JsonSerializable(typeof(FileLocksmithLocalProperties))] + [JsonSerializable(typeof(FindMyMouseProperties))] + [JsonSerializable(typeof(FZConfigProperties))] + [JsonSerializable(typeof(HostsProperties))] + [JsonSerializable(typeof(ImageResizerProperties))] + [JsonSerializable(typeof(KeyboardManagerProperties))] + [JsonSerializable(typeof(KeyboardManagerProfile))] + [JsonSerializable(typeof(LightSwitchProperties))] + [JsonSerializable(typeof(MeasureToolProperties))] + [JsonSerializable(typeof(MouseHighlighterProperties))] + [JsonSerializable(typeof(MouseJumpProperties))] + [JsonSerializable(typeof(MousePointerCrosshairsProperties))] + [JsonSerializable(typeof(MouseWithoutBordersProperties))] + [JsonSerializable(typeof(NewPlusProperties))] + [JsonSerializable(typeof(PeekProperties))] + [JsonSerializable(typeof(SettingsUILibrary.PeekPreviewSettings))] + [JsonSerializable(typeof(PowerAccentProperties))] + [JsonSerializable(typeof(PowerDisplayProperties))] + [JsonSerializable(typeof(PowerLauncherProperties))] + [JsonSerializable(typeof(PowerOcrProperties))] + [JsonSerializable(typeof(PowerPreviewProperties))] + [JsonSerializable(typeof(PowerRenameProperties))] + [JsonSerializable(typeof(PowerRenameLocalProperties))] + [JsonSerializable(typeof(RegistryPreviewProperties))] + [JsonSerializable(typeof(ShortcutConflictProperties))] + [JsonSerializable(typeof(ShortcutGuideProperties))] + [JsonSerializable(typeof(WorkspacesProperties))] + [JsonSerializable(typeof(ZoomItProperties))] + + // Base Property Types (used throughout settings) + [JsonSerializable(typeof(BoolProperty))] + [JsonSerializable(typeof(StringProperty))] + [JsonSerializable(typeof(IntProperty))] + [JsonSerializable(typeof(DoubleProperty))] + + // Helper and Utility Types + [JsonSerializable(typeof(HotkeySettings))] + [JsonSerializable(typeof(ColorFormatModel))] + [JsonSerializable(typeof(ImageSize))] + [JsonSerializable(typeof(KeysDataModel))] + [JsonSerializable(typeof(EnabledModules))] + [JsonSerializable(typeof(GeneralSettingsCustomAction))] + [JsonSerializable(typeof(OutGoingGeneralSettings))] + [JsonSerializable(typeof(OutGoingLanguageSettings))] + [JsonSerializable(typeof(AdvancedPasteCustomActions))] + [JsonSerializable(typeof(AdvancedPasteAdditionalActions))] + [JsonSerializable(typeof(AdvancedPasteCustomAction))] + [JsonSerializable(typeof(AdvancedPasteAdditionalAction))] + [JsonSerializable(typeof(AdvancedPastePasteAsFileAction))] + [JsonSerializable(typeof(AdvancedPasteTranscodeAction))] + [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<string>))] + [JsonSerializable(typeof(List<MonitorInfo>))] + [JsonSerializable(typeof(List<VcpCodeDisplayInfo>))] + [JsonSerializable(typeof(List<VcpValueInfo>))] + [JsonSerializable(typeof(SettingsUILibraryHelpers.SearchLocation))] + + // AdvancedPaste AI Provider Types (for AOT compatibility) + [JsonSerializable(typeof(PasteAIConfiguration))] + [JsonSerializable(typeof(PasteAIProviderDefinition))] + [JsonSerializable(typeof(System.Collections.ObjectModel.ObservableCollection<PasteAIProviderDefinition>))] + + // PowerDisplay Profile Types (for AOT compatibility) + [JsonSerializable(typeof(PowerDisplayProfile))] + [JsonSerializable(typeof(List<PowerDisplayProfile>))] + [JsonSerializable(typeof(PowerDisplayProfiles))] + [JsonSerializable(typeof(ProfileMonitorSetting))] + [JsonSerializable(typeof(List<ProfileMonitorSetting>))] + + // IPC Send Message Wrapper Classes (Snd*) + [JsonSerializable(typeof(SndAwakeSettings))] + [JsonSerializable(typeof(SndCursorWrapSettings))] + [JsonSerializable(typeof(SndFindMyMouseSettings))] + [JsonSerializable(typeof(SndLightSwitchSettings))] + [JsonSerializable(typeof(SndMouseHighlighterSettings))] + [JsonSerializable(typeof(SndMouseJumpSettings))] + [JsonSerializable(typeof(SndMousePointerCrosshairsSettings))] + [JsonSerializable(typeof(SndPowerAccentSettings))] + [JsonSerializable(typeof(SndPowerPreviewSettings))] + [JsonSerializable(typeof(SndPowerRenameSettings))] + [JsonSerializable(typeof(SndShortcutGuideSettings))] + + // IPC Message Generic Wrapper Types (SndModuleSettings<T>) + [JsonSerializable(typeof(SndModuleSettings<SndAwakeSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndCursorWrapSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndFindMyMouseSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndLightSwitchSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndMouseHighlighterSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndMouseJumpSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndMousePointerCrosshairsSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndPowerAccentSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndPowerPreviewSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndPowerRenameSettings>))] + [JsonSerializable(typeof(SndModuleSettings<SndShortcutGuideSettings>))] + + public partial class SettingsSerializationContext : JsonSerializerContext + { + } +} diff --git a/src/settings-ui/Settings.UI.Library/SettingsUtils.cs b/src/settings-ui/Settings.UI.Library/SettingsUtils.cs index 2c41850201..7266bd28cf 100644 --- a/src/settings-ui/Settings.UI.Library/SettingsUtils.cs +++ b/src/settings-ui/Settings.UI.Library/SettingsUtils.cs @@ -2,9 +2,12 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +#nullable enable + using System; using System.IO; using System.IO.Abstractions; +using System.Runtime.CompilerServices; using System.Text.Json; using ManagedCommon; @@ -12,33 +15,42 @@ using Microsoft.PowerToys.Settings.UI.Library.Interfaces; namespace Microsoft.PowerToys.Settings.UI.Library { - public class SettingsUtils : ISettingsUtils + // Some functions are marked as virtual to allow mocking in unit tests. + public class SettingsUtils { public const string DefaultFileName = "settings.json"; private const string DefaultModuleName = ""; private readonly IFile _file; - private readonly ISettingsPath _settingsPath; + private readonly SettingPath _settingsPath; + private readonly JsonSerializerOptions _serializerOptions; - private static readonly JsonSerializerOptions _serializerOptions = new JsonSerializerOptions - { - MaxDepth = 0, - IncludeFields = true, - }; + /// <summary> + /// Gets the default instance of the <see cref="SettingsUtils"/> class for general use. + /// Same as instantiating a new instance with the <see cref="SettingsUtils(IFileSystem?, JsonSerializerOptions?)"/> constructor with a new <see cref="FileSystem"/> object as the first argument and <c>null</c> as the second argument. + /// </summary> + /// <remarks>For using in tests, you should use one of the public constructors.</remarks> + public static SettingsUtils Default { get; } = new SettingsUtils(); - public SettingsUtils() + private SettingsUtils() : this(new FileSystem()) { } - public SettingsUtils(IFileSystem fileSystem) - : this(fileSystem?.File, new SettingPath(fileSystem?.Directory, fileSystem?.Path)) + public SettingsUtils(IFileSystem? fileSystem, JsonSerializerOptions? serializerOptions = null) + : this(fileSystem?.File!, new SettingPath(fileSystem?.Directory, fileSystem?.Path), serializerOptions) { } - public SettingsUtils(IFile file, ISettingsPath settingPath) + public SettingsUtils(IFile file, SettingPath settingPath, JsonSerializerOptions? serializerOptions = null) { _file = file ?? throw new ArgumentNullException(nameof(file)); _settingsPath = settingPath; + _serializerOptions = serializerOptions ?? new JsonSerializerOptions + { + MaxDepth = 0, + IncludeFields = true, + TypeInfoResolver = SettingsSerializationContext.Default, + }; } public bool SettingsExists(string powertoy = DefaultModuleName, string fileName = DefaultFileName) @@ -52,7 +64,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library _settingsPath.DeleteSettings(powertoy); } - public T GetSettings<T>(string powertoy = DefaultModuleName, string fileName = DefaultFileName) + public virtual T GetSettings<T>(string powertoy = DefaultModuleName, string fileName = DefaultFileName) where T : ISettingsConfig, new() { if (!SettingsExists(powertoy, fileName)) @@ -77,7 +89,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library /// This function creates a file in the powertoy folder if it does not exist and returns an object with default properties. /// </summary> /// <returns>Deserialized json settings object.</returns> - public T GetSettingsOrDefault<T>(string powertoy = DefaultModuleName, string fileName = DefaultFileName) + public virtual T GetSettingsOrDefault<T>(string powertoy = DefaultModuleName, string fileName = DefaultFileName) where T : ISettingsConfig, new() { try @@ -108,7 +120,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library /// This function creates a file in the powertoy folder if it does not exist and returns an object with default properties. /// </summary> /// <returns>Deserialized json settings object.</returns> - public T GetSettingsOrDefault<T, T2>(string powertoy = DefaultModuleName, string fileName = DefaultFileName, Func<object, object> settingsUpgrader = null) + public virtual T GetSettingsOrDefault<T, T2>(string powertoy = DefaultModuleName, string fileName = DefaultFileName, Func<object, object>? settingsUpgrader = null) where T : ISettingsConfig, new() where T2 : ISettingsConfig, new() { @@ -128,8 +140,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library try { T2 oldSettings = GetSettings<T2>(powertoy, fileName); - T newSettings = (T)settingsUpgrader(oldSettings); + T newSettings = (T)settingsUpgrader!(oldSettings); Logger.LogInfo($"Settings file {fileName} for {powertoy} was read successfully in the old format."); + + // If the file needs to be modified, to save the new configurations accordingly. + if (newSettings.UpgradeSettingsConfiguration()) + { + SaveSettings(newSettings.ToJsonString(), powertoy, fileName); + } + return newSettings; } catch (Exception) @@ -149,7 +168,22 @@ namespace Microsoft.PowerToys.Settings.UI.Library return newSettingsItem; } - // Given the powerToy folder name and filename to be accessed, this function deserializes and returns the file. + /// <summary> + /// Deserializes settings from a JSON file. + /// </summary> + /// <typeparam name="T">The settings type to deserialize. Must be registered in <see cref="SettingsSerializationContext"/>.</typeparam> + /// <param name="powertoyFolderName">The PowerToy module folder name.</param> + /// <param name="fileName">The settings file name.</param> + /// <returns>Deserialized settings object of type T.</returns> + /// <exception cref="InvalidOperationException"> + /// Thrown when type T is not registered in <see cref="SettingsSerializationContext"/>. + /// All settings types must be registered with <c>[JsonSerializable(typeof(T))]</c> attribute + /// for Native AOT compatibility. + /// </exception> + /// <remarks> + /// This method uses Native AOT-compatible JSON deserialization. Type T must be registered + /// in <see cref="SettingsSerializationContext"/> before calling this method. + /// </remarks> private T GetFile<T>(string powertoyFolderName = DefaultModuleName, string fileName = DefaultFileName) { // Adding Trim('\0') to overcome possible NTFS file corruption. @@ -158,12 +192,20 @@ namespace Microsoft.PowerToys.Settings.UI.Library // The file itself did write the content correctly but something is off with the actual end of the file, hence the 0x00 bug var jsonSettingsString = _file.ReadAllText(_settingsPath.GetSettingsPath(powertoyFolderName, fileName)).Trim('\0'); - var options = _serializerOptions; - return JsonSerializer.Deserialize<T>(jsonSettingsString, options); + // For Native AOT compatibility, get JsonTypeInfo from the TypeInfoResolver + var typeInfo = _serializerOptions.TypeInfoResolver?.GetTypeInfo(typeof(T), _serializerOptions); + + if (typeInfo == null) + { + throw new InvalidOperationException($"Type {typeof(T).FullName} is not registered in SettingsSerializationContext. Please add it to the [JsonSerializable] attributes."); + } + + // Use AOT-friendly deserialization + return (T)JsonSerializer.Deserialize(jsonSettingsString, typeInfo)!; } // Save settings to a json file. - public void SaveSettings(string jsonSettings, string powertoy = DefaultModuleName, string fileName = DefaultFileName) + public virtual void SaveSettings(string jsonSettings, string powertoy = DefaultModuleName, string fileName = DefaultFileName) { try { @@ -201,7 +243,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public static (bool Success, string Message, string Severity, bool LastBackupExists, string OptionalMessage) BackupSettings() { var settingsBackupAndRestoreUtilsX = SettingsBackupAndRestoreUtils.Instance; - var settingsUtils = new SettingsUtils(); + var settingsUtils = Default; var appBasePath = Path.GetDirectoryName(settingsUtils._settingsPath.GetSettingsPath(string.Empty, string.Empty)); string settingsBackupAndRestoreDir = settingsBackupAndRestoreUtilsX.GetSettingsBackupAndRestoreDir(); @@ -214,7 +256,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library public static (bool Success, string Message, string Severity) RestoreSettings() { var settingsBackupAndRestoreUtilsX = SettingsBackupAndRestoreUtils.Instance; - var settingsUtils = new SettingsUtils(); + var settingsUtils = Default; var appBasePath = Path.GetDirectoryName(settingsUtils._settingsPath.GetSettingsPath(string.Empty, string.Empty)); string settingsBackupAndRestoreDir = settingsBackupAndRestoreUtilsX.GetSettingsBackupAndRestoreDir(); return settingsBackupAndRestoreUtilsX.RestoreSettings(appBasePath, settingsBackupAndRestoreDir); diff --git a/src/settings-ui/Settings.UI.Library/ShortcutConflictProperties.cs b/src/settings-ui/Settings.UI.Library/ShortcutConflictProperties.cs new file mode 100644 index 0000000000..7ce5fb5b1f --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/ShortcutConflictProperties.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.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class ShortcutConflictProperties + { + [JsonPropertyName("ignored_shortcuts")] + public List<HotkeySettings> IgnoredShortcuts { get; set; } + + public ShortcutConflictProperties() + { + IgnoredShortcuts = new List<HotkeySettings>(); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/ShortcutGuideSettings.cs b/src/settings-ui/Settings.UI.Library/ShortcutGuideSettings.cs index c39e757fe3..40174aeb81 100644 --- a/src/settings-ui/Settings.UI.Library/ShortcutGuideSettings.cs +++ b/src/settings-ui/Settings.UI.Library/ShortcutGuideSettings.cs @@ -2,13 +2,15 @@ // 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 ShortcutGuideSettings : BasePTModuleSettings, ISettingsConfig + public class ShortcutGuideSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Shortcut Guide"; @@ -27,6 +29,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library return Name; } + public ModuleType GetModuleType() => ModuleType.ShortcutGuide; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.OpenShortcutGuide, + value => Properties.OpenShortcutGuide = value ?? Properties.DefaultOpenShortcutGuide, + "Activation_Shortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + // This can be utilized in the future if the settings.json file is to be modified/deleted. public bool UpgradeSettingsConfiguration() { diff --git a/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.cs new file mode 100644 index 0000000000..3d6d781d03 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SndCursorWrapSettings.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; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class SndCursorWrapSettings + { + [JsonPropertyName("CursorWrap")] + public CursorWrapSettings CursorWrap { get; set; } + + public SndCursorWrapSettings() + { + } + + public SndCursorWrapSettings(CursorWrapSettings settings) + { + CursorWrap = settings; + } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/SndLightSwitchSettings.cs b/src/settings-ui/Settings.UI.Library/SndLightSwitchSettings.cs new file mode 100644 index 0000000000..814ab5a6b1 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/SndLightSwitchSettings.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class SndLightSwitchSettings + { + [JsonPropertyName("LightSwitch")] + public LightSwitchSettings Settings { get; set; } + + public SndLightSwitchSettings() + { + } + + public SndLightSwitchSettings(LightSwitchSettings settings) + { + Settings = settings; + } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/StringProperty.cs b/src/settings-ui/Settings.UI.Library/StringProperty.cs index 8b0d86b177..5d521189dc 100644 --- a/src/settings-ui/Settings.UI.Library/StringProperty.cs +++ b/src/settings-ui/Settings.UI.Library/StringProperty.cs @@ -27,7 +27,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library // Returns a JSON version of the class settings configuration class. public override string ToString() { - return JsonSerializer.Serialize(this); + return JsonSerializer.Serialize(this, SettingsSerializationContext.Default.StringProperty); } public static StringProperty ToStringProperty(string v) diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/CmdNotFoundInstallEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/CmdNotFoundInstallEvent.cs index 6af5444174..213c6a8dc7 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/CmdNotFoundInstallEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/CmdNotFoundInstallEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class CmdNotFoundInstallEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/CmdNotFoundUninstallEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/CmdNotFoundUninstallEvent.cs index 36f90bcfd7..50a43560fe 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/CmdNotFoundUninstallEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/CmdNotFoundUninstallEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class CmdNotFoundUninstallEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeModuleRunEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeModuleRunEvent.cs index b6316cd2db..2f09bee06b 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeModuleRunEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeModuleRunEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class OobeModuleRunEvent : EventBase, IEvent { public string ModuleName { get; set; } diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeSectionEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeSectionEvent.cs index 4d762220c3..e39e798761 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeSectionEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeSectionEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class OobeSectionEvent : EventBase, IEvent { public string Section { get; set; } diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeSettingsEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeSettingsEvent.cs index 499a1b98b3..f02ed6658a 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeSettingsEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeSettingsEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class OobeSettingsEvent : EventBase, IEvent { public string ModuleName { get; set; } diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeStartedEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeStartedEvent.cs index 8912ea3156..9edc989e49 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeStartedEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeStartedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class OobeStartedEvent : EventBase, IEvent { public bool OobeStarted { get; set; } = true; diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeVariantAssignmentEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeVariantAssignmentEvent.cs index d4a30550eb..91bf17f345 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeVariantAssignmentEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/OobeVariantAssignmentEvent.cs @@ -3,14 +3,15 @@ // See the LICENSE file in the project root for more information. using System; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class OobeVariantAssignmentEvent : EventBase, IEvent { public string AssignmentContext { get; set; } diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/ScoobeStartedEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ScoobeStartedEvent.cs index f8f6c10c41..f4cccee1f2 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/ScoobeStartedEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ScoobeStartedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class ScoobeStartedEvent : EventBase, IEvent { public bool ScoobeStarted { get; set; } = true; diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/SettingsBootEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/SettingsBootEvent.cs index edb989199f..d324b0210c 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/SettingsBootEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/SettingsBootEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerLauncher.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class SettingsBootEvent : EventBase, IEvent { public double BootTimeMs { get; set; } diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/SettingsEnabledEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/SettingsEnabledEvent.cs index 7abfc8e2c2..81eda09d22 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/SettingsEnabledEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/SettingsEnabledEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.Telemetry { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class SettingsEnabledEvent : EventBase, IEvent { public string Name { get; set; } diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictControlClickedEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictControlClickedEvent.cs new file mode 100644 index 0000000000..3f5b8e9964 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictControlClickedEvent.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.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events +{ + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class ShortcutConflictControlClickedEvent : EventBase, IEvent + { + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public int ConflictCount { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictDetectedEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictDetectedEvent.cs new file mode 100644 index 0000000000..b8d7c13497 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictDetectedEvent.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.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events +{ + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class ShortcutConflictDetectedEvent : EventBase, IEvent + { + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public int ConflictCount { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictResolvedEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictResolvedEvent.cs new file mode 100644 index 0000000000..7f5bf56e82 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/ShortcutConflictResolvedEvent.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.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using Microsoft.PowerToys.Telemetry; +using Microsoft.PowerToys.Telemetry.Events; + +namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events +{ + [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public class ShortcutConflictResolvedEvent : EventBase, IEvent + { + public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; + + public string Source { get; set; } + } +} diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/TrayFlyoutActivatedEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/TrayFlyoutActivatedEvent.cs index 91600660e4..a98a70f850 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/TrayFlyoutActivatedEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/TrayFlyoutActivatedEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class TrayFlyoutActivatedEvent : EventBase, IEvent { public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage; diff --git a/src/settings-ui/Settings.UI.Library/Telemetry/Events/TrayFlyoutModuleRunEvent.cs b/src/settings-ui/Settings.UI.Library/Telemetry/Events/TrayFlyoutModuleRunEvent.cs index 167cf48dfb..de302413ff 100644 --- a/src/settings-ui/Settings.UI.Library/Telemetry/Events/TrayFlyoutModuleRunEvent.cs +++ b/src/settings-ui/Settings.UI.Library/Telemetry/Events/TrayFlyoutModuleRunEvent.cs @@ -2,14 +2,15 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Tracing; - using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry.Events; namespace Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events { [EventData] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public class TrayFlyoutModuleRunEvent : EventBase, IEvent { public string ModuleName { get; set; } diff --git a/src/settings-ui/Settings.UI.Library/Utilities/CommandLineUtils.cs b/src/settings-ui/Settings.UI.Library/Utilities/CommandLineUtils.cs index a7702e17a2..3b10d0d63f 100644 --- a/src/settings-ui/Settings.UI.Library/Utilities/CommandLineUtils.cs +++ b/src/settings-ui/Settings.UI.Library/Utilities/CommandLineUtils.cs @@ -18,13 +18,13 @@ public class CommandLineUtils return settingsLibraryAssembly.GetType(typeof(CommandLineUtils).Namespace + "." + settingsClassName); } - public static ISettingsConfig GetSettingsConfigFor(string moduleName, ISettingsUtils settingsUtils, Assembly settingsLibraryAssembly) + public static ISettingsConfig GetSettingsConfigFor(string moduleName, SettingsUtils settingsUtils, Assembly settingsLibraryAssembly) { return GetSettingsConfigFor(GetSettingsConfigType(moduleName, settingsLibraryAssembly), settingsUtils); } /// Executes SettingsRepository<moduleSettingsType>.GetInstance(settingsUtils).SettingsConfig - public static ISettingsConfig GetSettingsConfigFor(Type moduleSettingsType, ISettingsUtils settingsUtils) + public static ISettingsConfig GetSettingsConfigFor(Type moduleSettingsType, SettingsUtils settingsUtils) { var genericSettingsRepositoryType = typeof(SettingsRepository<>); var moduleSettingsRepositoryType = genericSettingsRepositoryType.MakeGenericType(moduleSettingsType); diff --git a/src/settings-ui/Settings.UI.Library/Utilities/GetSettingCommandLineCommand.cs b/src/settings-ui/Settings.UI.Library/Utilities/GetSettingCommandLineCommand.cs index a4ad1d1862..5780b95392 100644 --- a/src/settings-ui/Settings.UI.Library/Utilities/GetSettingCommandLineCommand.cs +++ b/src/settings-ui/Settings.UI.Library/Utilities/GetSettingCommandLineCommand.cs @@ -48,7 +48,7 @@ public sealed class GetSettingCommandLineCommand var modulesSettings = new Dictionary<string, Dictionary<string, object>>(); var settingsAssembly = CommandLineUtils.GetSettingsAssembly(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; var enabledModules = SettingsRepository<GeneralSettings>.GetInstance(settingsUtils).SettingsConfig.Enabled; diff --git a/src/settings-ui/Settings.UI.Library/Utilities/SetAdditionalSettingsCommandLineCommand.cs b/src/settings-ui/Settings.UI.Library/Utilities/SetAdditionalSettingsCommandLineCommand.cs index 29f47a4347..b6ba04dec8 100644 --- a/src/settings-ui/Settings.UI.Library/Utilities/SetAdditionalSettingsCommandLineCommand.cs +++ b/src/settings-ui/Settings.UI.Library/Utilities/SetAdditionalSettingsCommandLineCommand.cs @@ -244,7 +244,7 @@ public sealed class SetAdditionalSettingsCommandLineCommand } } - public static void Execute(string moduleName, JsonDocument settings, ISettingsUtils settingsUtils) + public static void Execute(string moduleName, JsonDocument settings, SettingsUtils settingsUtils) { Assembly settingsLibraryAssembly = CommandLineUtils.GetSettingsAssembly(); diff --git a/src/settings-ui/Settings.UI.Library/Utilities/SetSettingCommandLineCommand.cs b/src/settings-ui/Settings.UI.Library/Utilities/SetSettingCommandLineCommand.cs index ab5a88c5d8..3cd70856a8 100644 --- a/src/settings-ui/Settings.UI.Library/Utilities/SetSettingCommandLineCommand.cs +++ b/src/settings-ui/Settings.UI.Library/Utilities/SetSettingCommandLineCommand.cs @@ -25,7 +25,7 @@ public sealed class SetSettingCommandLineCommand return (parts[0], parts[1]); } - public static void Execute(string settingName, string settingValue, ISettingsUtils settingsUtils) + public static void Execute(string settingName, string settingValue, SettingsUtils settingsUtils) { Assembly settingsLibraryAssembly = CommandLineUtils.GetSettingsAssembly(); 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 +{ + /// <summary> + /// Formatted VCP code display information + /// </summary> + 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<VcpValueInfo> ValueList { get; set; } = new System.Collections.Generic.List<VcpValueInfo>(); + } +} 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 +{ + /// <summary> + /// Individual VCP value information + /// </summary> + 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/WorkspacesSettings.cs b/src/settings-ui/Settings.UI.Library/WorkspacesSettings.cs index 1e3ce2261e..18a364e2b3 100644 --- a/src/settings-ui/Settings.UI.Library/WorkspacesSettings.cs +++ b/src/settings-ui/Settings.UI.Library/WorkspacesSettings.cs @@ -3,14 +3,16 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Text.Json; 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 WorkspacesSettings : BasePTModuleSettings, ISettingsConfig + public class WorkspacesSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig { public const string ModuleName = "Workspaces"; public const string ModuleVersion = "0.0.1"; @@ -39,7 +41,22 @@ namespace Microsoft.PowerToys.Settings.UI.Library return false; } - public virtual void Save(ISettingsUtils settingsUtils) + public ModuleType GetModuleType() => ModuleType.Workspaces; + + public HotkeyAccessor[] GetAllHotkeyAccessors() + { + var hotkeyAccessors = new List<HotkeyAccessor> + { + new HotkeyAccessor( + () => Properties.Hotkey.Value, + value => Properties.Hotkey.Value = value ?? WorkspacesProperties.DefaultHotkeyValue, + "Workspaces_ActivationShortcut"), + }; + + return hotkeyAccessors.ToArray(); + } + + public virtual void Save(SettingsUtils settingsUtils) { // Save settings to file var options = _serializerOptions; diff --git a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs index 936c08689e..6c6535801a 100644 --- a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs +++ b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs @@ -78,12 +78,19 @@ namespace Microsoft.PowerToys.Settings.UI.Library public BoolProperty ShowTrayIcon { get; set; } - public BoolProperty AnimnateZoom { get; set; } + [JsonPropertyName("AnimnateZoom")] + public BoolProperty AnimateZoom { get; set; } + + public BoolProperty SmoothImage { get; set; } public IntProperty ZoominSliderLevel { get; set; } public IntProperty RecordScaling { get; set; } + 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.UnitTests/BackwardsCompatibility/BackCompatTestProperties.cs b/src/settings-ui/Settings.UI.UnitTests/BackwardsCompatibility/BackCompatTestProperties.cs index b2048fa573..921dd63f79 100644 --- a/src/settings-ui/Settings.UI.UnitTests/BackwardsCompatibility/BackCompatTestProperties.cs +++ b/src/settings-ui/Settings.UI.UnitTests/BackwardsCompatibility/BackCompatTestProperties.cs @@ -27,10 +27,15 @@ namespace Microsoft.PowerToys.Settings.UI.UnitTests.BackwardsCompatibility internal sealed class MockSettingsRepository<T> : ISettingsRepository<T> where T : ISettingsConfig, new() { - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private T _settingsConfig; - public MockSettingsRepository(ISettingsUtils settingsUtils) + // Implements ISettingsRepository<T>.SettingsChanged +#pragma warning disable CS0067 + public event System.Action<T> SettingsChanged; +#pragma warning restore CS0067 + + public MockSettingsRepository(SettingsUtils settingsUtils) { _settingsUtils = settingsUtils; } diff --git a/src/settings-ui/Settings.UI.UnitTests/Mocks/IIOProviderMocks.cs b/src/settings-ui/Settings.UI.UnitTests/Mocks/IIOProviderMocks.cs index b5e531d742..f259d3a7f3 100644 --- a/src/settings-ui/Settings.UI.UnitTests/Mocks/IIOProviderMocks.cs +++ b/src/settings-ui/Settings.UI.UnitTests/Mocks/IIOProviderMocks.cs @@ -52,7 +52,7 @@ namespace Microsoft.PowerToys.Settings.UI.UnitTests.Mocks /// This mock is specific to a given module, and is verifiable that the stub file was read. /// </summary> /// <param name="savePath">The path to the stub settings file</param> - /// <param name="filterExpression">The substring in the path that identifies the module eg. Microsoft\\PowerToys\\ColorPicker</param> + /// <param name="filterExpression">The substring in the path that identifies the module e.g. Microsoft\\PowerToys\\ColorPicker</param> /// <returns>Mocked IFile</returns> internal static Mock<IFile> GetMockIOReadWithStubFile(string savePath, Expression<Func<string, bool>> filterExpression) { diff --git a/src/settings-ui/Settings.UI.UnitTests/Mocks/ISettingsUtilsMocks.cs b/src/settings-ui/Settings.UI.UnitTests/Mocks/ISettingsUtilsMocks.cs index c51902f13f..3f19e0b8d4 100644 --- a/src/settings-ui/Settings.UI.UnitTests/Mocks/ISettingsUtilsMocks.cs +++ b/src/settings-ui/Settings.UI.UnitTests/Mocks/ISettingsUtilsMocks.cs @@ -2,6 +2,7 @@ // 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.IO.Abstractions; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Moq; @@ -10,11 +11,11 @@ namespace Microsoft.PowerToys.Settings.UI.UnitTests.Mocks { internal static class ISettingsUtilsMocks { - // Stubs out empty values for imageresizersettings and general settings as needed by the imageresizer viewmodel - internal static Mock<ISettingsUtils> GetStubSettingsUtils<T>() + // Stubs out empty values for imageresizersettings and general settings as needed by the imageresizer view model + internal static Mock<SettingsUtils> GetStubSettingsUtils<T>() where T : ISettingsConfig, new() { - var settingsUtils = new Mock<ISettingsUtils>(); + var settingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); settingsUtils .Setup(x => x.GetSettingsOrDefault<T>(It.IsAny<string>(), It.IsAny<string>())) .Returns(new T()); diff --git a/src/settings-ui/Settings.UI.UnitTests/ModelsTests/BasePTModuleSettingsSerializationTests.cs b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/BasePTModuleSettingsSerializationTests.cs new file mode 100644 index 0000000000..29fe1d0508 --- /dev/null +++ b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/BasePTModuleSettingsSerializationTests.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace CommonLibTest +{ + [TestClass] + public class BasePTModuleSettingsSerializationTests + { + /// <summary> + /// Test to verify that all classes derived from BasePTModuleSettings are registered + /// in the SettingsSerializationContext for Native AOT compatibility. + /// </summary> + [TestMethod] + public void AllBasePTModuleSettingsClasses_ShouldBeRegisteredInSerializationContext() + { + // Arrange + var assembly = typeof(BasePTModuleSettings).Assembly; + var settingsClasses = assembly.GetTypes() + .Where(t => typeof(BasePTModuleSettings).IsAssignableFrom(t) && !t.IsAbstract && t != typeof(BasePTModuleSettings)) + .OrderBy(t => t.Name) + .ToList(); + + Assert.IsTrue(settingsClasses.Count > 0, "No BasePTModuleSettings derived classes found. This test may be broken."); + + var jsonSerializerOptions = new JsonSerializerOptions + { + TypeInfoResolver = SettingsSerializationContext.Default, + }; + + var unregisteredTypes = new System.Collections.Generic.List<string>(); + + // Act & Assert + foreach (var settingsType in settingsClasses) + { + var typeInfo = jsonSerializerOptions.TypeInfoResolver?.GetTypeInfo(settingsType, jsonSerializerOptions); + + if (typeInfo == null) + { + unregisteredTypes.Add(settingsType.FullName ?? settingsType.Name); + } + } + + // Assert + if (unregisteredTypes.Count > 0) + { + var errorMessage = $"The following {unregisteredTypes.Count} settings class(es) are NOT registered in SettingsSerializationContext:\n" + + $"{string.Join("\n", unregisteredTypes.Select(t => $" - {t}"))}\n\n" + + $"Please add [JsonSerializable(typeof(ClassName))] attribute to SettingsSerializationContext.cs for each missing type."; + Assert.Fail(errorMessage); + } + + // Print success message with count + Console.WriteLine($"✓ All {settingsClasses.Count} BasePTModuleSettings derived classes are properly registered in SettingsSerializationContext."); + } + + /// <summary> + /// Test to verify that calling ToJsonString() on an unregistered type throws InvalidOperationException + /// with a helpful error message. + /// </summary> + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void ToJsonString_UnregisteredType_ShouldThrowInvalidOperationException() + { + // Arrange + var unregisteredSettings = new UnregisteredTestSettings + { + Name = "UnregisteredModule", + Version = "1.0.0", + }; + + // Act - This should throw InvalidOperationException + var jsonString = unregisteredSettings.ToJsonString(); + + // Assert - Exception should be thrown, so this line should never be reached + Assert.Fail("Expected InvalidOperationException was not thrown."); + } + + /// <summary> + /// Test to verify that the error message for unregistered types is helpful and contains + /// necessary information for developers. + /// </summary> + [TestMethod] + public void ToJsonString_UnregisteredType_ShouldHaveHelpfulErrorMessage() + { + // Arrange + var unregisteredSettings = new UnregisteredTestSettings + { + Name = "UnregisteredModule", + Version = "1.0.0", + }; + + // Act & Assert + try + { + var jsonString = unregisteredSettings.ToJsonString(); + Assert.Fail("Expected InvalidOperationException was not thrown."); + } + catch (InvalidOperationException ex) + { + // Verify the error message contains helpful information + Assert.IsTrue(ex.Message.Contains("UnregisteredTestSettings"), "Error message should contain the type name."); + Assert.IsTrue(ex.Message.Contains("SettingsSerializationContext"), "Error message should mention SettingsSerializationContext."); + Assert.IsTrue(ex.Message.Contains("JsonSerializable"), "Error message should mention JsonSerializable attribute."); + + Console.WriteLine($"✓ Error message is helpful: {ex.Message}"); + } + } + + /// <summary> + /// Test class that is intentionally NOT registered in SettingsSerializationContext + /// to verify error handling for unregistered types. + /// </summary> + private sealed class UnregisteredTestSettings : BasePTModuleSettings + { + // Intentionally empty - this class should NOT be registered in SettingsSerializationContext + } + } +} diff --git a/src/settings-ui/Settings.UI.UnitTests/ModelsTests/BasePTSettingsTest.cs b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/BasePTSettingsTest.cs index 8d73d2a6ee..c4e92fb322 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ModelsTests/BasePTSettingsTest.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/BasePTSettingsTest.cs @@ -2,8 +2,10 @@ // 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 Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.UnitTests; namespace Microsoft.PowerToys.Settings.UnitTest { @@ -24,5 +26,11 @@ namespace Microsoft.PowerToys.Settings.UnitTest { return false; } + + // Override ToJsonString to use test-specific serialization context + public override string ToJsonString() + { + return JsonSerializer.Serialize(this, TestSettingsSerializationContext.Default.BasePTSettingsTest); + } } } diff --git a/src/settings-ui/Settings.UI.UnitTests/ModelsTests/SettingsRepositoryTest.cs b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/SettingsRepositoryTest.cs index 87d8c2d9d1..8db0cc09f9 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ModelsTests/SettingsRepositoryTest.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/SettingsRepositoryTest.cs @@ -14,7 +14,7 @@ namespace CommonLibTest [TestClass] public class SettingsRepositoryTest { - private static Task<SettingsRepository<GeneralSettings>> GetSettingsRepository(ISettingsUtils settingsUtils) + private static Task<SettingsRepository<GeneralSettings>> GetSettingsRepository(SettingsUtils settingsUtils) { return Task.Run(() => { diff --git a/src/settings-ui/Settings.UI.UnitTests/ModelsTests/SettingsUtilsTests.cs b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/SettingsUtilsTests.cs index 9b75925cb8..37dfcee4d8 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ModelsTests/SettingsUtilsTests.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ModelsTests/SettingsUtilsTests.cs @@ -9,6 +9,7 @@ using System.Text.Json; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.UnitTests; using Microsoft.PowerToys.Settings.UnitTest; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -22,7 +23,13 @@ namespace CommonLibTest { // Arrange var mockFileSystem = new MockFileSystem(); - var settingsUtils = new SettingsUtils(mockFileSystem); + var testSerializerOptions = new JsonSerializerOptions + { + MaxDepth = 0, + IncludeFields = true, + TypeInfoResolver = TestSettingsSerializationContext.Default, + }; + var settingsUtils = new SettingsUtils(mockFileSystem, testSerializerOptions); string file_name = "\\test"; string file_contents_correct_json_content = "{\"name\":\"powertoy module name\",\"version\":\"powertoy version\"}"; @@ -42,7 +49,13 @@ namespace CommonLibTest { // Arrange var mockFileSystem = new MockFileSystem(); - var settingsUtils = new SettingsUtils(mockFileSystem); + var testSerializerOptions = new JsonSerializerOptions + { + MaxDepth = 0, + IncludeFields = true, + TypeInfoResolver = TestSettingsSerializationContext.Default, + }; + var settingsUtils = new SettingsUtils(mockFileSystem, testSerializerOptions); string file_name = "test\\Test Folder"; string file_contents_correct_json_content = "{\"name\":\"powertoy module name\",\"version\":\"powertoy version\"}"; diff --git a/src/settings-ui/Settings.UI.UnitTests/Settings.UI.UnitTests.csproj b/src/settings-ui/Settings.UI.UnitTests/Settings.UI.UnitTests.csproj index 228af72058..dbe092c0ad 100644 --- a/src/settings-ui/Settings.UI.UnitTests/Settings.UI.UnitTests.csproj +++ b/src/settings-ui/Settings.UI.UnitTests/Settings.UI.UnitTests.csproj @@ -1,10 +1,14 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> <PropertyGroup> + <SelfContained>true</SelfContained> + <RuntimeIdentifier Condition="'$(Platform)' == 'x64'">win-x64</RuntimeIdentifier> + <RuntimeIdentifier Condition="'$(Platform)' == 'ARM64'">win-arm64</RuntimeIdentifier> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <IsPackable>false</IsPackable> - <OutputPath>..\..\..\$(Configuration)\$(Platform)\tests\SettingsTests\</OutputPath> + <OutputPath>$(RepoRoot)$(Configuration)\$(Platform)\tests\SettingsTests\</OutputPath> <!-- TODO: fix issues and reenable --> <!-- These are caused by streamjsonrpc dependency on Microsoft.VisualStudio.Threading.Analyzers --> @@ -17,6 +21,9 @@ <PackageReference Include="Moq" /> <PackageReference Include="MSTest" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> </ItemGroup> <ItemGroup> diff --git a/src/settings-ui/Settings.UI.UnitTests/TestSettingsSerializationContext.cs b/src/settings-ui/Settings.UI.UnitTests/TestSettingsSerializationContext.cs new file mode 100644 index 0000000000..bf38245105 --- /dev/null +++ b/src/settings-ui/Settings.UI.UnitTests/TestSettingsSerializationContext.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UnitTest; + +namespace Microsoft.PowerToys.Settings.UI.UnitTests +{ + /// <summary> + /// JSON serialization context for unit tests. + /// This context provides source-generated serialization for test-specific types. + /// </summary> + [JsonSourceGenerationOptions( + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + IncludeFields = true)] + [JsonSerializable(typeof(BasePTSettingsTest))] + public partial class TestSettingsSerializationContext : JsonSerializerContext + { + } +} diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ColorPicker.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ColorPicker.cs index baf46827c4..ec90204eb5 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ColorPicker.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ColorPicker.cs @@ -27,7 +27,7 @@ namespace ViewModelTests { // Arrange var mockIOProvider = BackCompatTestProperties.GetModuleIOProvider(version, ColorPickerSettings.ModuleName, fileName); - var settingPathMock = new Mock<ISettingsPath>(); + var settingPathMock = new Mock<SettingPath>(); var mockSettingsUtils = new SettingsUtils(mockIOProvider.Object, settingPathMock.Object); ColorPickerSettings originalSettings = mockSettingsUtils.GetSettingsOrDefault<ColorPickerSettings>(ColorPickerSettings.ModuleName); @@ -67,7 +67,7 @@ namespace ViewModelTests using (var viewModel = new ColorPickerViewModel( ISettingsUtilsMocks.GetStubSettingsUtils<ColorPickerSettings>().Object, SettingsRepository<GeneralSettings>.GetInstance(ISettingsUtilsMocks.GetStubSettingsUtils<GeneralSettings>().Object), - SettingsRepository<ColorPickerSettings>.GetInstance(new SettingsUtils()), + SettingsRepository<ColorPickerSettings>.GetInstance(SettingsUtils.Default), ColorPickerIsEnabledByDefaultIPC)) { Assert.IsTrue(viewModel.IsEnabled); diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/FancyZones.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/FancyZones.cs index ef230bde0a..e557f08bdf 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/FancyZones.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/FancyZones.cs @@ -3,9 +3,11 @@ // See the LICENSE file in the project root for more information. using System; +using System.IO.Abstractions; using System.Text.Json; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.UnitTests.BackwardsCompatibility; using Microsoft.PowerToys.Settings.UI.UnitTests.Mocks; using Microsoft.PowerToys.Settings.UI.ViewModels; @@ -30,7 +32,7 @@ namespace ViewModelTests [DataRow("v0.22.0", "settings.json")] public void OriginalFilesModificationTest(string version, string fileName) { - var settingPathMock = new Mock<ISettingsPath>(); + var settingPathMock = new Mock<SettingPath>(); var fileMock = BackCompatTestProperties.GetModuleIOProvider(version, FancyZonesSettings.ModuleName, fileName); var mockSettingsUtils = new SettingsUtils(fileMock.Object, settingPathMock.Object); @@ -87,9 +89,9 @@ namespace ViewModelTests return 0; } - private Mock<ISettingsUtils> mockGeneralSettingsUtils; + private Mock<SettingsUtils> mockGeneralSettingsUtils; - private Mock<ISettingsUtils> mockFancyZonesSettingsUtils; + private Mock<SettingsUtils> mockFancyZonesSettingsUtils; private Func<string, int> sendMockIPCConfigMSG = msg => { return 0; }; @@ -100,10 +102,26 @@ namespace ViewModelTests mockFancyZonesSettingsUtils = ISettingsUtilsMocks.GetStubSettingsUtils<FancyZonesSettings>(); } + [TestCleanup] + public void CleanUp() + { + // Reset singleton instances to prevent state pollution between tests + ResetSettingsRepository<GeneralSettings>(); + ResetSettingsRepository<FancyZonesSettings>(); + } + + private void ResetSettingsRepository<T>() + where T : class, ISettingsConfig, new() + { + var repositoryType = typeof(SettingsRepository<T>); + var field = repositoryType.GetField("settingsRepository", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + field?.SetValue(null, null); + } + [TestMethod] public void IsEnabledShouldDisableModuleWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); Func<string, int> sendMockIPCConfigMSG = msg => { @@ -123,7 +141,7 @@ namespace ViewModelTests [TestMethod] public void ShiftDragShouldSetValue2FalseWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -141,7 +159,7 @@ namespace ViewModelTests [TestMethod] public void OverrideSnapHotkeysShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -159,7 +177,7 @@ namespace ViewModelTests [TestMethod] public void MoveWindowsAcrossMonitorsShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -177,7 +195,7 @@ namespace ViewModelTests [TestMethod] public void MoveWindowsBasedOnPositionShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -200,7 +218,7 @@ namespace ViewModelTests [TestMethod] public void QuickLayoutSwitchShouldSetValue2FalseWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -218,7 +236,7 @@ namespace ViewModelTests [TestMethod] public void FlashZonesOnQuickSwitchShouldSetValue2FalseWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -236,7 +254,7 @@ namespace ViewModelTests [TestMethod] public void MakeDraggedWindowsTransparentShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -254,7 +272,7 @@ namespace ViewModelTests [TestMethod] public void MouseSwitchShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -272,7 +290,7 @@ namespace ViewModelTests [TestMethod] public void DisplayOrWorkAreaChangeMoveWindowsShouldSetValue2FalseWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -290,7 +308,7 @@ namespace ViewModelTests [TestMethod] public void ZoneSetChangeMoveWindowsShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -308,7 +326,7 @@ namespace ViewModelTests [TestMethod] public void AppLastZoneMoveWindowsShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -326,7 +344,7 @@ namespace ViewModelTests [TestMethod] public void OpenWindowOnActiveMonitorShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -344,7 +362,7 @@ namespace ViewModelTests [TestMethod] public void RestoreSizeShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -362,7 +380,7 @@ namespace ViewModelTests [TestMethod] public void UseCursorPosEditorStartupScreenShouldSetValue2FalseWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -380,7 +398,7 @@ namespace ViewModelTests [TestMethod] public void ShowOnAllMonitorsShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -398,7 +416,7 @@ namespace ViewModelTests [TestMethod] public void SpanZonesAcrossMonitorsShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -416,7 +434,7 @@ namespace ViewModelTests [TestMethod] public void OverlappingZonesAlgorithmIndexShouldSetValue2AnotherWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -434,7 +452,7 @@ namespace ViewModelTests [TestMethod] public void AllowChildWindowsToSnapShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -452,7 +470,7 @@ namespace ViewModelTests [TestMethod] public void DisableRoundCornersOnSnapShouldSetValue2TrueWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -470,7 +488,7 @@ namespace ViewModelTests [TestMethod] public void ZoneHighlightColorShouldSetColorValue2WhiteWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -488,7 +506,7 @@ namespace ViewModelTests [TestMethod] public void ZoneBorderColorShouldSetColorValue2WhiteWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -506,7 +524,7 @@ namespace ViewModelTests [TestMethod] public void ZoneInActiveColorShouldSetColorValue2WhiteWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -524,7 +542,7 @@ namespace ViewModelTests [TestMethod] public void ExcludedAppsShouldSetColorValue2WhiteWhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); @@ -542,7 +560,7 @@ namespace ViewModelTests [TestMethod] public void HighlightOpacityShouldSetOpacityValueTo60WhenSuccessful() { - Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(); + Mock<SettingsUtils> mockSettingsUtils = new Mock<SettingsUtils>(new FileSystem(), null); // arrange FancyZonesViewModel viewModel = new FancyZonesViewModel(mockSettingsUtils.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<FancyZonesSettings>.GetInstance(mockFancyZonesSettingsUtils.Object), sendMockIPCConfigMSG, FancyZonesTestFolderName); diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs index 70bfbe4fba..56119c790b 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs @@ -20,7 +20,7 @@ namespace ViewModelTests { public const string GeneralSettingsFileName = "Test\\GeneralSettings"; - private Mock<ISettingsUtils> mockGeneralSettingsUtils; + private Mock<SettingsUtils> mockGeneralSettingsUtils; [TestInitialize] public void SetUpStubSettingUtils() @@ -28,6 +28,28 @@ namespace ViewModelTests mockGeneralSettingsUtils = ISettingsUtilsMocks.GetStubSettingsUtils<GeneralSettings>(); } + private sealed class TestGeneralViewModel : GeneralViewModel + { + public TestGeneralViewModel( + Microsoft.PowerToys.Settings.UI.Library.Interfaces.ISettingsRepository<GeneralSettings> settingsRepository, + string runAsAdminText, + string runAsUserText, + bool isElevated, + bool isAdmin, + Func<string, int> ipcMSGCallBackFunc, + Func<string, int> ipcMSGRestartAsAdminMSGCallBackFunc, + Func<string, int> ipcMSGCheckForUpdatesCallBackFunc, + string configFileSubfolder = "") + : base(settingsRepository, runAsAdminText, runAsUserText, isElevated, isAdmin, ipcMSGCallBackFunc, ipcMSGRestartAsAdminMSGCallBackFunc, ipcMSGCheckForUpdatesCallBackFunc, configFileSubfolder) + { + } + + protected override Microsoft.UI.Dispatching.DispatcherQueue GetDispatcherQueue() + { + return null; + } + } + [TestMethod] [DataRow("v0.18.2")] [DataRow("v0.19.2")] @@ -36,7 +58,7 @@ namespace ViewModelTests [DataRow("v0.22.0")] public void OriginalFilesModificationTest(string version) { - var settingPathMock = new Mock<ISettingsPath>(); + var settingPathMock = new Mock<SettingPath>(); var fileMock = BackCompatTestProperties.GetGeneralSettingsIOProvider(version); var mockGeneralSettingsUtils = new SettingsUtils(fileMock.Object, settingPathMock.Object); @@ -49,7 +71,7 @@ namespace ViewModelTests Func<string, int> sendMockIPCConfigMSG = msg => 0; Func<string, int> sendRestartAdminIPCMessage = msg => 0; Func<string, int> sendCheckForUpdatesIPCMessage = msg => 0; - var viewModel = new GeneralViewModel( + var viewModel = new TestGeneralViewModel( settingsRepository: generalSettingsRepository, runAsAdminText: "GeneralSettings_RunningAsAdminText", runAsUserText: "GeneralSettings_RunningAsUserText", @@ -78,7 +100,7 @@ namespace ViewModelTests Func<string, int> sendMockIPCConfigMSG = msg => { return 0; }; Func<string, int> sendRestartAdminIPCMessage = msg => { return 0; }; Func<string, int> sendCheckForUpdatesIPCMessage = msg => { return 0; }; - GeneralViewModel viewModel = new GeneralViewModel( + GeneralViewModel viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", "GeneralSettings_RunningAsUserText", @@ -104,17 +126,29 @@ namespace ViewModelTests public void StartupShouldEnableRunOnStartUpWhenSuccessful() { // Assert + bool sawExpectedIpcPayload = false; Func<string, int> sendMockIPCConfigMSG = msg => { + if (string.IsNullOrWhiteSpace(msg)) + { + return 0; + } + OutGoingGeneralSettings snd = JsonSerializer.Deserialize<OutGoingGeneralSettings>(msg); + if (snd?.GeneralSettings is null) + { + return 0; + } + Assert.IsTrue(snd.GeneralSettings.Startup); + sawExpectedIpcPayload = true; return 0; }; // Arrange Func<string, int> sendRestartAdminIPCMessage = msg => { return 0; }; Func<string, int> sendCheckForUpdatesIPCMessage = msg => { return 0; }; - GeneralViewModel viewModel = new GeneralViewModel( + GeneralViewModel viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", "GeneralSettings_RunningAsUserText", @@ -128,16 +162,29 @@ namespace ViewModelTests // act viewModel.Startup = true; + Assert.IsTrue(sawExpectedIpcPayload); } [TestMethod] public void RunElevatedShouldEnableAlwaysRunElevatedWhenSuccessful() { // Assert + bool sawExpectedIpcPayload = false; Func<string, int> sendMockIPCConfigMSG = msg => { + if (string.IsNullOrWhiteSpace(msg)) + { + return 0; + } + OutGoingGeneralSettings snd = JsonSerializer.Deserialize<OutGoingGeneralSettings>(msg); + if (snd?.GeneralSettings is null) + { + return 0; + } + Assert.IsTrue(snd.GeneralSettings.RunElevated); + sawExpectedIpcPayload = true; return 0; }; @@ -145,7 +192,7 @@ namespace ViewModelTests Func<string, int> sendCheckForUpdatesIPCMessage = msg => { return 0; }; // Arrange - GeneralViewModel viewModel = new GeneralViewModel( + GeneralViewModel viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", "GeneralSettings_RunningAsUserText", @@ -160,6 +207,7 @@ namespace ViewModelTests // act viewModel.RunElevated = true; + Assert.IsTrue(sawExpectedIpcPayload); } [TestMethod] @@ -167,18 +215,30 @@ namespace ViewModelTests { // Arrange GeneralViewModel viewModel = null; + bool sawExpectedIpcPayload = false; // Assert Func<string, int> sendMockIPCConfigMSG = msg => { + if (string.IsNullOrWhiteSpace(msg)) + { + return 0; + } + OutGoingGeneralSettings snd = JsonSerializer.Deserialize<OutGoingGeneralSettings>(msg); + if (snd?.GeneralSettings is null) + { + return 0; + } + Assert.AreEqual("light", snd.GeneralSettings.Theme); + sawExpectedIpcPayload = true; return 0; }; Func<string, int> sendRestartAdminIPCMessage = msg => { return 0; }; Func<string, int> sendCheckForUpdatesIPCMessage = msg => { return 0; }; - viewModel = new GeneralViewModel( + viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", "GeneralSettings_RunningAsUserText", @@ -192,23 +252,35 @@ namespace ViewModelTests // act viewModel.ThemeIndex = 1; + Assert.IsTrue(sawExpectedIpcPayload); } [TestMethod] public void IsDarkThemeRadioButtonCheckedShouldThemeToDarkWhenSuccessful() { // Arrange - // Assert + bool sawExpectedIpcPayload = false; Func<string, int> sendMockIPCConfigMSG = msg => { + if (string.IsNullOrWhiteSpace(msg)) + { + return 0; + } + OutGoingGeneralSettings snd = JsonSerializer.Deserialize<OutGoingGeneralSettings>(msg); + if (snd?.GeneralSettings is null) + { + return 0; + } + Assert.AreEqual("dark", snd.GeneralSettings.Theme); + sawExpectedIpcPayload = true; return 0; }; Func<string, int> sendRestartAdminIPCMessage = msg => { return 0; }; Func<string, int> sendCheckForUpdatesIPCMessage = msg => { return 0; }; - GeneralViewModel viewModel = new GeneralViewModel( + GeneralViewModel viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", "GeneralSettings_RunningAsUserText", @@ -222,6 +294,49 @@ namespace ViewModelTests // act viewModel.ThemeIndex = 0; + Assert.IsTrue(sawExpectedIpcPayload); + } + + [TestMethod] + public void IsShowSysTrayIconEnabledByDefaultShouldDisableWhenSuccessful() + { + // Arrange + bool sawExpectedIpcPayload = false; + Func<string, int> sendMockIPCConfigMSG = msg => + { + if (string.IsNullOrWhiteSpace(msg)) + { + return 0; + } + + OutGoingGeneralSettings snd = JsonSerializer.Deserialize<OutGoingGeneralSettings>(msg); + if (snd?.GeneralSettings is null) + { + return 0; + } + + Assert.IsFalse(snd.GeneralSettings.ShowSysTrayIcon); + sawExpectedIpcPayload = true; + return 0; + }; + + Func<string, int> sendRestartAdminIPCMessage = msg => { return 0; }; + Func<string, int> sendCheckForUpdatesIPCMessage = msg => { return 0; }; + GeneralViewModel viewModel = new TestGeneralViewModel( + settingsRepository: SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), + "GeneralSettings_RunningAsAdminText", + "GeneralSettings_RunningAsUserText", + false, + false, + sendMockIPCConfigMSG, + sendRestartAdminIPCMessage, + sendCheckForUpdatesIPCMessage, + GeneralSettingsFileName); + Assert.IsTrue(viewModel.ShowSysTrayIcon); + + // Act + viewModel.ShowSysTrayIcon = false; + Assert.IsTrue(sawExpectedIpcPayload); } [TestMethod] diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ImageResizer.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ImageResizer.cs index c5175bc180..15eac53645 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ImageResizer.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ImageResizer.cs @@ -19,9 +19,9 @@ namespace ViewModelTests [TestClass] public class ImageResizer { - private Mock<ISettingsUtils> _mockGeneralSettingsUtils; + private Mock<SettingsUtils> _mockGeneralSettingsUtils; - private Mock<ISettingsUtils> _mockImgResizerSettingsUtils; + private Mock<SettingsUtils> _mockImgResizerSettingsUtils; [TestInitialize] public void SetUpStubSettingUtils() @@ -41,7 +41,7 @@ namespace ViewModelTests [DataRow("v0.22.0", "settings.json")] public void OriginalFilesModificationTest(string version, string fileName) { - var settingPathMock = new Mock<ISettingsPath>(); + var settingPathMock = new Mock<SettingPath>(); var fileMock = BackCompatTestProperties.GetModuleIOProvider(version, ImageResizerSettings.ModuleName, fileName); var mockSettingsUtils = new SettingsUtils(fileMock.Object, settingPathMock.Object); diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerLauncherViewModelTest.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerLauncherViewModelTest.cs index f1084d498a..a21fd51bc6 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerLauncherViewModelTest.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerLauncherViewModelTest.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for more information. using System; - using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.UnitTests.BackwardsCompatibility; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -13,7 +13,7 @@ using Moq; namespace ViewModelTests { [TestClass] - public class PowerLauncherViewModelTest + public class PowerLauncherViewModelTest : IDisposable { private sealed class SendCallbackMock { @@ -26,20 +26,48 @@ namespace ViewModelTests { TimesSent++; } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1313:Parameter names should begin with lower-case letter", Justification = "We actually don't validate setting, just calculate it was sent")] + public int OnSendIPC(string _) + { + TimesSent++; + return 0; + } } private PowerLauncherViewModel viewModel; private PowerLauncherSettings mockSettings; private SendCallbackMock sendCallbackMock; + private BackCompatTestProperties.MockSettingsRepository<GeneralSettings> mockGeneralSettingsRepository; [TestInitialize] public void Initialize() { mockSettings = new PowerLauncherSettings(); sendCallbackMock = new SendCallbackMock(); + + var settingPathMock = new Mock<SettingPath>(); + var mockGeneralIOProvider = BackCompatTestProperties.GetGeneralSettingsIOProvider("v0.22.0"); + var mockGeneralSettingsUtils = new SettingsUtils(mockGeneralIOProvider.Object, settingPathMock.Object); + mockGeneralSettingsRepository = new BackCompatTestProperties.MockSettingsRepository<GeneralSettings>(mockGeneralSettingsUtils); + viewModel = new PowerLauncherViewModel( mockSettings, - new PowerLauncherViewModel.SendCallback(sendCallbackMock.OnSend)); + mockGeneralSettingsRepository, + sendCallbackMock.OnSendIPC, + () => false); + } + + [TestCleanup] + public void Cleanup() + { + viewModel?.Dispose(); + } + + public void Dispose() + { + viewModel?.Dispose(); + GC.SuppressFinalize(this); } /// <summary> @@ -53,7 +81,7 @@ namespace ViewModelTests [DataRow("v0.22.0", "settings.json")] public void OriginalFilesModificationTest(string version, string fileName) { - var settingPathMock = new Mock<ISettingsPath>(); + var settingPathMock = new Mock<SettingPath>(); var mockIOProvider = BackCompatTestProperties.GetModuleIOProvider(version, PowerLauncherSettings.ModuleName, fileName); var mockSettingsUtils = new SettingsUtils(mockIOProvider.Object, settingPathMock.Object); @@ -67,7 +95,7 @@ namespace ViewModelTests // Initialise View Model with test Config files Func<string, int> sendMockIPCConfigMSG = msg => { return 0; }; - PowerLauncherViewModel viewModel = new PowerLauncherViewModel(originalSettings, generalSettingsRepository, sendMockIPCConfigMSG, () => true); + using PowerLauncherViewModel viewModel = new PowerLauncherViewModel(originalSettings, generalSettingsRepository, sendMockIPCConfigMSG, () => true); // Verify that the old settings persisted Assert.AreEqual(originalGeneralSettings.Enabled.PowerLauncher, viewModel.EnablePowerLauncher); diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerPreview.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerPreview.cs index 373b9a3580..c0a3ad8514 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerPreview.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerPreview.cs @@ -17,9 +17,9 @@ namespace ViewModelTests [TestClass] public class PowerPreview { - private Mock<ISettingsUtils> mockPowerPreviewSettingsUtils; + private Mock<SettingsUtils> mockPowerPreviewSettingsUtils; - private Mock<ISettingsUtils> mockGeneralSettingsUtils; + private Mock<SettingsUtils> mockGeneralSettingsUtils; [TestInitialize] public void SetUpStubSettingUtils() @@ -39,7 +39,7 @@ namespace ViewModelTests [DataRow("v0.22.0", "settings.json")] public void OriginalFilesModificationTest(string version, string fileName) { - var settingPathMock = new Mock<ISettingsPath>(); + var settingPathMock = new Mock<SettingPath>(); var fileMock = BackCompatTestProperties.GetModuleIOProvider(version, PowerPreviewSettings.ModuleName, fileName); var mockSettingsUtils = new SettingsUtils(fileMock.Object, settingPathMock.Object); @@ -61,11 +61,13 @@ namespace ViewModelTests Assert.AreEqual(originalSettings.Properties.EnableMonacoPreview, viewModel.MonacoRenderIsEnabled); Assert.AreEqual(originalSettings.Properties.EnablePdfPreview, viewModel.PDFRenderIsEnabled); Assert.AreEqual(originalSettings.Properties.EnableGcodePreview, viewModel.GCODERenderIsEnabled); + Assert.AreEqual(originalSettings.Properties.EnableBgcodePreview, viewModel.BGCODERenderIsEnabled); Assert.AreEqual(originalSettings.Properties.EnableQoiPreview, viewModel.QOIRenderIsEnabled); Assert.AreEqual(originalSettings.Properties.EnableSvgPreview, viewModel.SVGRenderIsEnabled); Assert.AreEqual(originalSettings.Properties.EnableSvgThumbnail, viewModel.SVGThumbnailIsEnabled); Assert.AreEqual(originalSettings.Properties.EnablePdfThumbnail, viewModel.PDFThumbnailIsEnabled); Assert.AreEqual(originalSettings.Properties.EnableGcodeThumbnail, viewModel.GCODEThumbnailIsEnabled); + Assert.AreEqual(originalSettings.Properties.EnableBgcodeThumbnail, viewModel.BGCODEThumbnailIsEnabled); Assert.AreEqual(originalSettings.Properties.EnableStlThumbnail, viewModel.STLThumbnailIsEnabled); Assert.AreEqual(originalSettings.Properties.EnableQoiThumbnail, viewModel.QOIThumbnailIsEnabled); @@ -146,6 +148,24 @@ namespace ViewModelTests viewModel.GCODEThumbnailIsEnabled = true; } + [TestMethod] + public void BGCODEThumbnailIsEnabledShouldPrevHandlerWhenSuccessful() + { + // Assert + Func<string, int> sendMockIPCConfigMSG = msg => + { + SndModuleSettings<SndPowerPreviewSettings> snd = JsonSerializer.Deserialize<SndModuleSettings<SndPowerPreviewSettings>>(msg); + Assert.IsTrue(snd.PowertoysSetting.FileExplorerPreviewSettings.Properties.EnableBgcodeThumbnail); + return 0; + }; + + // arrange + PowerPreviewViewModel viewModel = new PowerPreviewViewModel(SettingsRepository<PowerPreviewSettings>.GetInstance(mockPowerPreviewSettingsUtils.Object), SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), sendMockIPCConfigMSG, PowerPreviewSettings.ModuleName); + + // act + viewModel.BGCODEThumbnailIsEnabled = true; + } + [TestMethod] public void STLThumbnailIsEnabledShouldPrevHandlerWhenSuccessful() { @@ -254,6 +274,24 @@ namespace ViewModelTests viewModel.GCODERenderIsEnabled = true; } + [TestMethod] + public void BGCODERenderIsEnabledShouldPrevHandlerWhenSuccessful() + { + // Assert + Func<string, int> sendMockIPCConfigMSG = msg => + { + SndModuleSettings<SndPowerPreviewSettings> snd = JsonSerializer.Deserialize<SndModuleSettings<SndPowerPreviewSettings>>(msg); + Assert.IsTrue(snd.PowertoysSetting.FileExplorerPreviewSettings.Properties.EnableBgcodePreview); + return 0; + }; + + // arrange + PowerPreviewViewModel viewModel = new PowerPreviewViewModel(SettingsRepository<PowerPreviewSettings>.GetInstance(mockPowerPreviewSettingsUtils.Object), SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), sendMockIPCConfigMSG, PowerPreviewSettings.ModuleName); + + // act + viewModel.BGCODERenderIsEnabled = true; + } + [TestMethod] public void QOIRenderIsEnabledShouldPrevHandlerWhenSuccessful() { diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerRename.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerRename.cs index 278975183b..77fd9328d6 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerRename.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/PowerRename.cs @@ -19,9 +19,9 @@ namespace ViewModelTests { public const string GeneralSettingsFileName = "Test\\PowerRename"; - private Mock<ISettingsUtils> mockGeneralSettingsUtils; + private Mock<SettingsUtils> mockGeneralSettingsUtils; - private Mock<ISettingsUtils> mockPowerRenamePropertiesUtils; + private Mock<SettingsUtils> mockPowerRenamePropertiesUtils; [TestInitialize] public void SetUpStubSettingUtils() @@ -40,7 +40,7 @@ namespace ViewModelTests [DataRow("v0.22.0", "power-rename-settings.json")] public void OriginalFilesModificationTest(string version, string fileName) { - var settingPathMock = new Mock<ISettingsPath>(); + var settingPathMock = new Mock<SettingPath>(); var mockIOProvider = BackCompatTestProperties.GetModuleIOProvider(version, PowerRenameSettings.ModuleName, fileName); var mockSettingsUtils = new SettingsUtils(mockIOProvider.Object, settingPathMock.Object); diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ShortcutGuide.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ShortcutGuide.cs index 3613d0cfa3..6ebab902e7 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ShortcutGuide.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/ShortcutGuide.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.IO.Abstractions; using System.Text.Json; using Microsoft.PowerToys.Settings.UI.Library; @@ -30,7 +31,7 @@ namespace ViewModelTests [DataRow("v0.22.0", "settings.json")] public void OriginalFilesModificationTest(string version, string fileName) { - var settingPathMock = new Mock<ISettingsPath>(); + var settingPathMock = new Mock<SettingPath>(); var mockIOProvider = BackCompatTestProperties.GetModuleIOProvider(version, ShortcutGuideSettings.ModuleName, fileName); var mockSettingsUtils = new SettingsUtils(mockIOProvider.Object, settingPathMock.Object); ShortcutGuideSettings originalSettings = mockSettingsUtils.GetSettingsOrDefault<ShortcutGuideSettings>(ShortcutGuideSettings.ModuleName); @@ -55,9 +56,9 @@ namespace ViewModelTests BackCompatTestProperties.VerifyGeneralSettingsIOProviderWasRead(mockGeneralIOProvider, expectedCallCount); } - private Mock<ISettingsUtils> mockGeneralSettingsUtils; + private Mock<SettingsUtils> mockGeneralSettingsUtils; - private Mock<ISettingsUtils> mockShortcutGuideSettingsUtils; + private Mock<SettingsUtils> mockShortcutGuideSettingsUtils; [TestInitialize] public void SetUpStubSettingUtils() @@ -69,7 +70,7 @@ namespace ViewModelTests [TestMethod] public void IsEnabledShouldEnableModuleWhenSuccessful() { - var settingsUtilsMock = new Mock<SettingsUtils>(); + var settingsUtilsMock = new Mock<SettingsUtils>(new FileSystem(), null); // Assert // Initialize mock function of sending IPC message. @@ -91,7 +92,7 @@ namespace ViewModelTests public void ThemeIndexShouldSetThemeToDarkWhenSuccessful() { // Arrange - var settingsUtilsMock = new Mock<ISettingsUtils>(); + var settingsUtilsMock = new Mock<SettingsUtils>(new FileSystem(), null); ShortcutGuideViewModel viewModel = new ShortcutGuideViewModel(settingsUtilsMock.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<ShortcutGuideSettings>.GetInstance(mockShortcutGuideSettingsUtils.Object), msg => { return 0; }, ShortCutGuideTestFolderName); // Initialize shortcut guide settings theme to 'system' to be in sync with shortcut_guide.h. @@ -109,7 +110,7 @@ namespace ViewModelTests public void OverlayOpacityShouldSeOverlayOpacityToOneHundredWhenSuccessful() { // Arrange - var settingsUtilsMock = new Mock<ISettingsUtils>(); + var settingsUtilsMock = new Mock<SettingsUtils>(new FileSystem(), null); ShortcutGuideViewModel viewModel = new ShortcutGuideViewModel(settingsUtilsMock.Object, SettingsRepository<GeneralSettings>.GetInstance(mockGeneralSettingsUtils.Object), SettingsRepository<ShortcutGuideSettings>.GetInstance(mockShortcutGuideSettingsUtils.Object), msg => { return 0; }, ShortCutGuideTestFolderName); Assert.AreEqual(90, viewModel.OverlayOpacity); diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/ModuleIconResolver.cs b/src/settings-ui/Settings.UI.XamlIndexBuilder/ModuleIconResolver.cs new file mode 100644 index 0000000000..43c3ed2b05 --- /dev/null +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/ModuleIconResolver.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 System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace Microsoft.PowerToys.Tools.XamlIndexBuilder +{ + public static class ModuleIconResolver + { + // Hardcoded page-level overrides for module -> icon path + private static readonly System.Collections.Generic.Dictionary<string, string> FileNameOverrides = new System.Collections.Generic.Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase) + { + // Example overrides; expand as needed + { "FancyZonesPage.xaml", "/Assets/Settings/Icons/FancyZones.png" }, + { "FileLocksmithPage.xaml", "/Assets/Settings/Icons/FileLocksmith.png" }, + { "CmdNotFoundPage.xaml", "/Assets/Settings/Icons/CommandNotFound.png" }, + { "PowerLauncherPage.xaml", "/Assets/Settings/Icons/PowerToysRun.png" }, + }; + + // Contract: + // - Input: absolute path to the module XAML file (e.g., FancyZonesPage.xaml) + // - Output: app-relative icon path (e.g., "/Assets/Settings/Icons/FancyZones.png"), or null if not found + // - Strategy: take the first SettingsCard under the page and read its HeaderIcon value + public static string ResolveIconFromFirstSettingsCard(string xamlFilePath) + { + if (string.IsNullOrWhiteSpace(xamlFilePath)) + { + return null; + } + + try + { + var doc = XDocument.Load(xamlFilePath); + + // Prefer looking inside SettingsPageControl.ModuleContent to avoid picking cards in Resources/DataTemplates + var pageControl = doc.Descendants().FirstOrDefault(e => e.Name.LocalName == "SettingsPageControl"); + + if (pageControl != null) + { + // Locate the property element <SettingsPageControl.ModuleContent> + var moduleContent = pageControl + .Elements() + .FirstOrDefault(e => e.Name.LocalName.EndsWith(".ModuleContent", System.StringComparison.OrdinalIgnoreCase)) + ?? pageControl + .Descendants() + .FirstOrDefault(e => e.Name.LocalName.EndsWith(".ModuleContent", System.StringComparison.OrdinalIgnoreCase)); + + if (moduleContent != null) + { + // Find the first SettingsCard under ModuleContent and try to read its HeaderIcon + var firstCardUnderModule = moduleContent + .Descendants() + .FirstOrDefault(e => e.Name.LocalName == "SettingsCard"); + + if (firstCardUnderModule != null) + { + var icon = Program.ExtractIconValue(firstCardUnderModule); + if (!string.IsNullOrWhiteSpace(icon)) + { + return icon; + } + } + } + } + + // Fallback to hardcoded overrides by file name + var fileName = Path.GetFileName(xamlFilePath); + if (!string.IsNullOrEmpty(fileName) && FileNameOverrides.TryGetValue(fileName, out var overrideIcon)) + { + return overrideIcon; + } + + return null; + } + catch + { + // Non-fatal: let caller decide fallback + return null; + } + } + } +} diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs b/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs new file mode 100644 index 0000000000..b270ec0178 --- /dev/null +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/Program.cs @@ -0,0 +1,434 @@ +// 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.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Xml.Linq; + +namespace Microsoft.PowerToys.Tools.XamlIndexBuilder +{ + public class Program + { + private static readonly HashSet<string> ExcludedXamlFiles = new(StringComparer.OrdinalIgnoreCase) + { + "ShellPage.xaml", + }; + + // Hardcoded panel-to-page mapping (temporary until generic panel host mapping is needed) + // Key: panel file base name (without .xaml), Value: owning page base name + private static readonly Dictionary<string, string> PanelPageMapping = new(StringComparer.OrdinalIgnoreCase) + { + { "MouseJumpPanel", "MouseUtilsPage" }, + }; + + private static JsonSerializerOptions serializeOption = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public static void Main(string[] args) + { + if (args.Length < 2) + { + Debug.WriteLine("Usage: XamlIndexBuilder <xaml-directory> <output-json-file>"); + Environment.Exit(1); + } + + string xamlRootDirectory = args[0]; + string outputFile = args[1]; + + if (!Directory.Exists(xamlRootDirectory)) + { + Debug.WriteLine($"Error: Directory '{xamlRootDirectory}' does not exist."); + Environment.Exit(1); + } + + try + { + var searchableElements = new List<SettingEntry>(); + var processedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + void ScanDirectory(string root) + { + if (!Directory.Exists(root)) + { + return; + } + + Debug.WriteLine($"[XamlIndexBuilder] Scanning root: {root}"); + var xamlFilesLocal = Directory.GetFiles(root, "*.xaml", SearchOption.AllDirectories); + foreach (var xamlFile in xamlFilesLocal) + { + var fullPath = Path.GetFullPath(xamlFile); + if (processedFiles.Contains(fullPath)) + { + continue; // already handled (can happen if overlapping directories) + } + + var fileName = Path.GetFileName(xamlFile); + if (ExcludedXamlFiles.Contains(fileName)) + { + continue; // explicitly excluded + } + + Debug.WriteLine($"Processing: {fileName}"); + var elements = ExtractSearchableElements(xamlFile); + + // Apply hardcoded panel mapping override + var baseName = Path.GetFileNameWithoutExtension(xamlFile); + if (PanelPageMapping.TryGetValue(baseName, out var hostPage)) + { + for (int i = 0; i < elements.Count; i++) + { + var entry = elements[i]; + entry.PageTypeName = hostPage; + elements[i] = entry; + } + } + + searchableElements.AddRange(elements); + processedFiles.Add(fullPath); + } + } + + // Scan well-known subdirectories under the provided root + var subDirs = new[] { "Views", "Panels" }; + foreach (var sub in subDirs) + { + ScanDirectory(Path.Combine(xamlRootDirectory, sub)); + } + + // Fallback: also scan root directly (in case some XAML lives at root level) + ScanDirectory(xamlRootDirectory); + + // ----------------------------------------------------------------------------- + // Explicit include section: add specific XAML files that we always want indexed + // even if future logic excludes them or they live outside typical scan patterns. + // Add future files to the ExplicitExtraXamlFiles array below. + // ----------------------------------------------------------------------------- + string[] explicitExtraXamlFiles = new[] + { + "MouseJumpPanel.xaml", // Mouse Jump settings panel + }; + + foreach (var extraFileName in explicitExtraXamlFiles) + { + try + { + var matches = Directory.GetFiles(xamlRootDirectory, extraFileName, SearchOption.AllDirectories); + foreach (var match in matches) + { + var full = Path.GetFullPath(match); + if (processedFiles.Contains(full)) + { + continue; // already processed in general scan + } + + Debug.WriteLine($"Processing (explicit include): {extraFileName}"); + var elements = ExtractSearchableElements(full); + var baseName = Path.GetFileNameWithoutExtension(full); + if (PanelPageMapping.TryGetValue(baseName, out var hostPage)) + { + for (int i = 0; i < elements.Count; i++) + { + var entry = elements[i]; + entry.PageTypeName = hostPage; + elements[i] = entry; + } + } + + searchableElements.AddRange(elements); + processedFiles.Add(full); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Explicit include failed for {extraFileName}: {ex.Message}"); + } + } + + searchableElements = searchableElements.OrderBy(e => e.PageTypeName).ThenBy(e => e.ElementName).ToList(); + + string json = JsonSerializer.Serialize(searchableElements, serializeOption); + File.WriteAllText(outputFile, json); + + Debug.WriteLine($"Successfully generated index with {searchableElements.Count} elements."); + Debug.WriteLine($"Output written to: {outputFile}"); + } + catch (Exception ex) + { + Debug.WriteLine($"Error: {ex.Message}"); + Environment.Exit(1); + } + } + + public static List<SettingEntry> ExtractSearchableElements(string xamlFile) + { + var elements = new List<SettingEntry>(); + string pageName = Path.GetFileNameWithoutExtension(xamlFile); + + try + { + // Load XAML as XML + var doc = XDocument.Load(xamlFile); + + // Define namespaces + XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml"; + XNamespace controls = "http://schemas.microsoft.com/winfx/2006/xaml/presentation"; + + // Extract SettingsPageControl elements + var settingsPageElements = doc.Descendants() + .Where(e => e.Name.LocalName == "SettingsPageControl") + .Where(e => e.Attribute(x + "Uid") != null); + + // Extract SettingsCard elements (support both Name and x:Name) + var settingsElements = doc.Descendants() + .Where(e => e.Name.LocalName == "SettingsCard") + .Where(e => e.Attribute("Name") != null || e.Attribute(x + "Name") != null || e.Attribute(x + "Uid") != null); + + // Extract SettingsExpander elements (support both Name and x:Name) + var settingsExpanderElements = doc.Descendants() + .Where(e => e.Name.LocalName == "SettingsExpander") + .Where(e => e.Attribute("Name") != null || e.Attribute(x + "Name") != null || e.Attribute(x + "Uid") != null); + + // Process SettingsPageControl elements + foreach (var element in settingsPageElements) + { + var elementUid = GetElementUid(element, x); + + // Prefer the first SettingsCard.HeaderIcon as the module icon + var moduleImageSource = ModuleIconResolver.ResolveIconFromFirstSettingsCard(xamlFile); + + if (!string.IsNullOrEmpty(elementUid)) + { + elements.Add(new SettingEntry + { + PageTypeName = pageName, + Type = EntryType.SettingsPage, + ParentElementName = string.Empty, + ElementName = string.Empty, + ElementUid = elementUid, + Icon = moduleImageSource, + }); + } + } + + // Process SettingsCard elements + foreach (var element in settingsElements) + { + var elementName = GetElementName(element, x); + var elementUid = GetElementUid(element, x); + var headerIcon = ExtractIconValue(element); + + if (!string.IsNullOrEmpty(elementName) || !string.IsNullOrEmpty(elementUid)) + { + var parentElementName = GetParentElementName(element, x); + + elements.Add(new SettingEntry + { + PageTypeName = pageName, + Type = EntryType.SettingsCard, + ParentElementName = parentElementName, + ElementName = elementName, + ElementUid = elementUid, + Icon = headerIcon, + }); + } + } + + // Process SettingsExpander elements + foreach (var element in settingsExpanderElements) + { + var elementName = GetElementName(element, x); + var elementUid = GetElementUid(element, x); + var headerIcon = ExtractIconValue(element); + + if (!string.IsNullOrEmpty(elementName) || !string.IsNullOrEmpty(elementUid)) + { + var parentElementName = GetParentElementName(element, x); + + elements.Add(new SettingEntry + { + PageTypeName = pageName, + Type = EntryType.SettingsExpander, + ParentElementName = parentElementName, + ElementName = elementName, + ElementUid = elementUid, + Icon = headerIcon, + }); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error processing {xamlFile}: {ex.Message}"); + } + + return elements; + } + + public static string GetElementName(XElement element, XNamespace x) + { + var name = element.Attribute("Name")?.Value; + if (string.IsNullOrEmpty(name)) + { + name = element.Attribute(x + "Name")?.Value; + } + + return name; + } + + public static string GetElementUid(XElement element, XNamespace x) + { + // Try x:Uid on the element itself + var uid = element.Attribute(x + "Uid")?.Value; + if (!string.IsNullOrWhiteSpace(uid)) + { + return uid; + } + + // Fallback: check the first direct child element's x:Uid + var firstChild = element.Elements().FirstOrDefault(); + if (firstChild != null) + { + var childUid = firstChild.Attribute(x + "Uid")?.Value; + if (!string.IsNullOrWhiteSpace(childUid)) + { + return childUid; + } + } + + return null; + } + + public static string GetParentElementName(XElement element, XNamespace x) + { + // Look for parent SettingsExpander + var current = element.Parent; + while (current != null) + { + // Check if we're inside a SettingsExpander.Items or just directly inside SettingsExpander + if (current.Name.LocalName == "Items") + { + // Check if the parent of Items is SettingsExpander + var expanderParent = current.Parent; + if (expanderParent?.Name.LocalName == "SettingsExpander") + { + var expanderName = expanderParent.Attribute("Name")?.Value; + if (string.IsNullOrEmpty(expanderName)) + { + expanderName = expanderParent.Attribute(x + "Name")?.Value; + } + + if (!string.IsNullOrEmpty(expanderName)) + { + return expanderName; + } + } + } + else if (current.Name.LocalName == "SettingsExpander") + { + // Direct child of SettingsExpander + var expanderName = current.Attribute("Name")?.Value; + if (string.IsNullOrEmpty(expanderName)) + { + expanderName = current.Attribute(x + "Name")?.Value; + } + + if (!string.IsNullOrEmpty(expanderName)) + { + return expanderName; + } + } + + current = current.Parent; + } + + return string.Empty; + } + + public static string ExtractIconValue(XElement element) + { + var headerIconAttribute = element.Attribute("HeaderIcon")?.Value; + + if (string.IsNullOrEmpty(headerIconAttribute)) + { + // Try nested property element: <SettingsCard.HeaderIcon> ... </SettingsCard.HeaderIcon> + var headerIconProperty = element.Elements() + .FirstOrDefault(e => e.Name.LocalName.EndsWith(".HeaderIcon", StringComparison.OrdinalIgnoreCase)); + + if (headerIconProperty != null) + { + // Prefer explicit icon elements within the HeaderIcon property + var pathIcon = headerIconProperty.Descendants().FirstOrDefault(d => d.Name.LocalName == "PathIcon"); + if (pathIcon != null) + { + var dataAttr = pathIcon.Attribute("Data")?.Value; + if (!string.IsNullOrWhiteSpace(dataAttr)) + { + return dataAttr.Trim(); + } + } + + var fontIcon = headerIconProperty.Descendants().FirstOrDefault(d => d.Name.LocalName == "FontIcon"); + if (fontIcon != null) + { + var glyphAttr = fontIcon.Attribute("Glyph")?.Value; + if (!string.IsNullOrWhiteSpace(glyphAttr)) + { + return glyphAttr.Trim(); + } + } + + var bitmapIcon = headerIconProperty.Descendants().FirstOrDefault(d => d.Name.LocalName == "BitmapIcon"); + if (bitmapIcon != null) + { + var sourceAttr = bitmapIcon.Attribute("Source")?.Value; + if (!string.IsNullOrWhiteSpace(sourceAttr)) + { + return sourceAttr.Trim(); + } + } + } + + return null; + } + + // Parse different icon markup extensions + // Example: {ui:BitmapIcon Source=/Assets/Settings/Icons/AlwaysOnTop.png} + if (headerIconAttribute.Contains("BitmapIcon") && headerIconAttribute.Contains("Source=")) + { + var sourceStart = headerIconAttribute.IndexOf("Source=", StringComparison.OrdinalIgnoreCase) + "Source=".Length; + var sourceEnd = headerIconAttribute.IndexOf('}', sourceStart); + if (sourceEnd == -1) + { + sourceEnd = headerIconAttribute.Length; + } + + return headerIconAttribute.Substring(sourceStart, sourceEnd - sourceStart).Trim(); + } + + // Example: {ui:FontIcon Glyph=} + if (headerIconAttribute.Contains("FontIcon") && headerIconAttribute.Contains("Glyph=")) + { + var glyphStart = headerIconAttribute.IndexOf("Glyph=", StringComparison.OrdinalIgnoreCase) + "Glyph=".Length; + var glyphEnd = headerIconAttribute.IndexOf('}', glyphStart); + if (glyphEnd == -1) + { + glyphEnd = headerIconAttribute.Length; + } + + return headerIconAttribute.Substring(glyphStart, glyphEnd - glyphStart).Trim(); + } + + // If it doesn't match known patterns, return the original value + return headerIconAttribute; + } + } +} diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/SettingEntry.cs b/src/settings-ui/Settings.UI.XamlIndexBuilder/SettingEntry.cs new file mode 100644 index 0000000000..0a95f8e816 --- /dev/null +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/SettingEntry.cs @@ -0,0 +1,44 @@ +// 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.PowerToys.Tools.XamlIndexBuilder +{ + public enum EntryType + { + SettingsPage, + SettingsCard, + SettingsExpander, + } + + public struct SettingEntry + { + public EntryType Type { get; set; } + + public string Header { get; set; } + + public string PageTypeName { get; set; } + + public string ElementName { get; set; } + + public string ElementUid { get; set; } + + public string ParentElementName { get; set; } + + public string Description { get; set; } + + public string Icon { get; set; } + + public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null) + { + Type = type; + Header = header; + PageTypeName = pageTypeName; + ElementName = elementName; + ElementUid = elementUid; + ParentElementName = parentElementName; + Description = description; + Icon = icon; + } + } +} diff --git a/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj b/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj new file mode 100644 index 0000000000..91416fc676 --- /dev/null +++ b/src/settings-ui/Settings.UI.XamlIndexBuilder/Settings.UI.XamlIndexBuilder.csproj @@ -0,0 +1,43 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Import common props to satisfy repo audit; override problematic bits below for this console tool. --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <TargetFramework>net9.0</TargetFramework> + <OutputType>Exe</OutputType> + <RootNamespace>Microsoft.PowerToys.Tools.XamlIndexBuilder</RootNamespace> + <AssemblyName>XamlIndexBuilder</AssemblyName> + <!-- Platform-agnostic: framework-dependent DLL executed via dotnet --> + <SelfContained>false</SelfContained> + <UseAppHost>false</UseAppHost> + <PlatformTarget>AnyCPU</PlatformTarget> + <RuntimeIdentifier></RuntimeIdentifier> + <!-- Keep tool output out of product scan paths to avoid deps.json audit conflicts --> + <OutputPath>$(MSBuildProjectDirectory)\obj\XamlIndexBuilder\$(Configuration)\</OutputPath> + </PropertyGroup> + + <!-- Remove CsWinRT package introduced by common props; not needed for this tool and causes Windows metadata errors --> + <ItemGroup> + <PackageReference Remove="Microsoft.Windows.CsWinRT" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="System.Text.Json" /> + <PackageReference Include="System.CommandLine" /> + </ItemGroup> + + <!-- Remove UI library reference to avoid pulling WindowsDesktop runtime (WindowsBase) --> + + <PropertyGroup> + <DotNetExe Condition="'$(DotNetExe)' == ''">dotnet</DotNetExe> + <XamlRootDir Condition="'$(XamlRootDir)' == ''">$(MSBuildProjectDirectory)\..\Settings.UI\SettingsXAML</XamlRootDir> + <XamlRootDir Condition="'$(XamlViewsDir)' != ''">$([System.IO.Path]::GetDirectoryName('$(XamlViewsDir)'))</XamlRootDir> + <GeneratedJsonFile Condition="'$(GeneratedJsonFile)' == ''">$(MSBuildProjectDirectory)\..\Settings.UI\Assets\Settings\search.index.json</GeneratedJsonFile> + </PropertyGroup> + <Target Name="GenerateSearchIndexSelf" AfterTargets="Build"> + <RemoveDir Directories="$(MSBuildProjectDirectory)\obj\ARM64;$(MSBuildProjectDirectory)\obj\x64;$(MSBuildProjectDirectory)\bin" /> + <MakeDir Directories="$([System.IO.Path]::GetDirectoryName('$(GeneratedJsonFile)'))" /> + <Message Importance="high" Text="[XamlIndexBuilder] Generating search index. Root='$(XamlRootDir)'; Out='$(GeneratedJsonFile)'; Tool='$(TargetPath)'; DotNet='$(DotNetExe)'." /> + <Exec Command=""$(DotNetExe)" "$(TargetPath)" "$(XamlRootDir)" "$(GeneratedJsonFile)"" /> + </Target> +</Project> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Activation/ActivationHandler.cs b/src/settings-ui/Settings.UI/Activation/ActivationHandler.cs deleted file mode 100644 index aabe2aff53..0000000000 --- a/src/settings-ui/Settings.UI/Activation/ActivationHandler.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; - -namespace Microsoft.PowerToys.Settings.UI.Activation -{ - // For more information on understanding and extending activation flow see - // https://github.com/Microsoft/WindowsTemplateStudio/blob/master/docs/activation.md - internal abstract class ActivationHandler - { - public abstract bool CanHandle(object args); - - public abstract Task HandleAsync(object args); - } - - [SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "abstract T and abstract")] - internal abstract class ActivationHandler<T> : ActivationHandler - where T : class - { - public override async Task HandleAsync(object args) - { - await HandleInternalAsync(args as T).ConfigureAwait(false); - } - - public override bool CanHandle(object args) - { - // CanHandle checks the args is of type you have configured - return args is T && CanHandleInternal(args as T); - } - - // Override this method to add the activation logic in your activation handler - protected abstract Task HandleInternalAsync(T args); - - // You can override this method to add extra validation on activation args - // to determine if your ActivationHandler should handle this activation args - protected virtual bool CanHandleInternal(T args) - { - return true; - } - } -} diff --git a/src/settings-ui/Settings.UI/Activation/DefaultActivationHandler.cs b/src/settings-ui/Settings.UI/Activation/DefaultActivationHandler.cs deleted file mode 100644 index 946fab205c..0000000000 --- a/src/settings-ui/Settings.UI/Activation/DefaultActivationHandler.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Threading.Tasks; - -using Microsoft.PowerToys.Settings.UI.Services; -using Windows.ApplicationModel.Activation; - -namespace Microsoft.PowerToys.Settings.UI.Activation -{ - internal sealed class DefaultActivationHandler : ActivationHandler<IActivatedEventArgs> - { - private readonly Type navElement; - - public DefaultActivationHandler(Type navElement) - { - this.navElement = navElement; - } - - protected override async Task HandleInternalAsync(IActivatedEventArgs args) - { - // When the navigation stack isn't restored, navigate to the first page and configure - // the new page by passing required information in the navigation parameter - object arguments = null; - if (args is LaunchActivatedEventArgs launchArgs) - { - arguments = launchArgs.Arguments; - } - - NavigationService.Navigate(navElement, arguments); - await Task.CompletedTask.ConfigureAwait(false); - } - - protected override bool CanHandleInternal(IActivatedEventArgs args) - { - // None of the ActivationHandlers has handled the app activation - return NavigationService.Frame.Content == null && navElement != null; - } - } -} diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png new file mode 100644 index 0000000000..c32f1e309a Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/CursorWrap.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png index 6cdb55cb66..581c317518 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/FindMyMouse.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/LightSwitch.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/LightSwitch.png new file mode 100644 index 0000000000..d4ce00c74a Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/LightSwitch.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg new file mode 100644 index 0000000000..7497187ad7 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Azure.svg @@ -0,0 +1,23 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6.05607 1.09062H10.3957L5.89074 14.4385C5.84444 14.5756 5.75629 14.6948 5.6387 14.7792C5.52111 14.8637 5.38 14.9091 5.23524 14.9091H1.85791C1.74822 14.9091 1.64011 14.883 1.54252 14.833C1.44493 14.7829 1.36066 14.7103 1.29669 14.6212C1.23271 14.5322 1.19087 14.4291 1.17462 14.3206C1.15837 14.2122 1.16818 14.1014 1.20324 13.9975L5.40041 1.56129C5.44669 1.42407 5.53485 1.30483 5.65248 1.22037C5.7701 1.1359 5.91126 1.09063 6.05607 1.09062Z" fill="url(#paint0_linear_2092_1811)"/> +<path d="M12.3626 10.0435H5.48096C5.41698 10.0434 5.35447 10.0626 5.30156 10.0986C5.24864 10.1345 5.20779 10.1856 5.18432 10.2451C5.16085 10.3046 5.15584 10.3698 5.16996 10.4322C5.18408 10.4946 5.21666 10.5513 5.26346 10.595L9.68546 14.7223C9.81421 14.8424 9.98373 14.9092 10.1598 14.9091H14.0565L12.3626 10.0435Z" fill="#0078D4"/> +<path d="M6.05617 1.0907C5.90978 1.09014 5.76704 1.1364 5.64881 1.22273C5.53058 1.30906 5.44305 1.43093 5.399 1.57054L1.2085 13.9862C1.17108 14.0905 1.15933 14.2023 1.17425 14.3121C1.18917 14.4219 1.23031 14.5265 1.2942 14.617C1.3581 14.7076 1.44285 14.7814 1.54131 14.8323C1.63976 14.8831 1.74902 14.9095 1.85983 14.9092H5.32433C5.45337 14.8861 5.57397 14.8293 5.67382 14.7443C5.77367 14.6594 5.84919 14.5495 5.89267 14.4259L6.72833 11.963L9.71333 14.7472C9.83842 14.8507 9.99534 14.9079 10.1577 14.9092H14.0398L12.3372 10.0435L7.37367 10.0447L10.4115 1.0907H6.05617Z" fill="url(#paint1_linear_2092_1811)"/> +<path d="M11.5996 1.5607C11.5533 1.4237 11.4653 1.30466 11.3479 1.22034C11.2304 1.13603 11.0895 1.09068 10.9449 1.0907H6.1084C6.25297 1.09071 6.3939 1.13606 6.51135 1.22038C6.62879 1.30469 6.71683 1.42372 6.76307 1.5607L10.9604 13.9974C10.9955 14.1013 11.0053 14.2121 10.9891 14.3206C10.9729 14.4291 10.931 14.5322 10.867 14.6213C10.8031 14.7104 10.7188 14.7831 10.6212 14.8331C10.5236 14.8832 10.4154 14.9094 10.3057 14.9094H15.1424C15.2521 14.9093 15.3602 14.8832 15.4578 14.8331C15.5554 14.783 15.6396 14.7104 15.7036 14.6213C15.7675 14.5321 15.8094 14.4291 15.8256 14.3206C15.8418 14.2121 15.832 14.1013 15.7969 13.9974L11.5996 1.5607Z" fill="url(#paint2_linear_2092_1811)"/> +<defs> +<linearGradient id="paint0_linear_2092_1811" x1="7.63774" y1="2.11462" x2="3.1309" y2="15.429" gradientUnits="userSpaceOnUse"> +<stop stop-color="#114A8B"/> +<stop offset="1" stop-color="#0669BC"/> +</linearGradient> +<linearGradient id="paint1_linear_2092_1811" x1="9.04567" y1="8.31954" x2="8.00317" y2="8.67204" gradientUnits="userSpaceOnUse"> +<stop stop-opacity="0.3"/> +<stop offset="0.071" stop-opacity="0.2"/> +<stop offset="0.321" stop-opacity="0.1"/> +<stop offset="0.623" stop-opacity="0.05"/> +<stop offset="1" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint2_linear_2092_1811" x1="8.4729" y1="1.72636" x2="13.4201" y2="14.9065" gradientUnits="userSpaceOnUse"> +<stop stop-color="#3CCBF4"/> +<stop offset="1" stop-color="#2892DF"/> +</linearGradient> +</defs> +</svg> diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg new file mode 100644 index 0000000000..e6fd7121b2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/AzureAI.svg @@ -0,0 +1,9 @@ +<svg id="uuid-adbdae8e-5a41-46d1-8c18-aa73cdbfee32" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"> + <defs> + <radialGradient id="uuid-2a7407aa-b787-48dd-a96a-0d81ab6e93bb" cx="-67.981" cy="793.199" r=".45" gradientTransform="translate(-17939.03 20368.029) rotate(45) scale(25.091 -34.149)" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#83b9f9" /> + <stop offset="1" stop-color="#0078d4" /> + </radialGradient> + </defs> + <path d="m0,2.7v12.6c0,1.491,1.209,2.7,2.7,2.7h12.6c1.491,0,2.7-1.209,2.7-2.7V2.7c0-1.491-1.209-2.7-2.7-2.7H2.7C1.209,0,0,1.209,0,2.7ZM10.8,0v3.6c0,3.976,3.224,7.2,7.2,7.2h-3.6c-3.976,0-7.199,3.222-7.2,7.198v-3.598c0-3.976-3.224-7.2-7.2-7.2h3.6c3.976,0,7.2-3.224,7.2-7.2Z" fill="url(#uuid-2a7407aa-b787-48dd-a96a-0d81ab6e93bb)" stroke-width="0" /> +</svg> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg new file mode 100644 index 0000000000..53747d557d --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/FoundryLocal.svg @@ -0,0 +1,34 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M10.2663 0H0.231885C0.103572 0 0 0.10358 0 0.231885V2.55072C0 2.67904 0.103572 2.78261 0.231885 2.78261H12.5217C12.9059 2.78261 13.2174 3.09411 13.2174 3.47826V3.18995C13.2174 1.53971 11.9861 0 10.2663 0Z" fill="url(#paint0_linear_178_3940)"/> +<path d="M12.2334 0.81543C12.8633 1.44693 13.2174 2.29872 13.2174 3.19069V15.7689C13.2174 15.8972 13.3209 16.0007 13.4492 16.0007H15.7681C15.8964 16.0007 16 15.8972 16 15.7689V5.73524C16 4.99707 15.707 4.28983 15.1853 3.76732L12.2334 0.81543Z" fill="url(#paint1_linear_178_3940)"/> +<path d="M6.78804 3.47852H0.231885C0.103572 3.47852 0 3.58209 0 3.71039V6.02921C0 6.1575 0.103572 6.26112 0.231885 6.26112H9.04346C9.42759 6.26112 9.7391 6.57263 9.7391 6.95676V6.6685C9.7391 5.01822 8.50778 3.47852 6.78804 3.47852Z" fill="url(#paint2_linear_178_3940)"/> +<path d="M8.75537 4.29297C9.38531 4.92446 9.73928 5.77628 9.73928 6.6682V15.7681C9.73928 15.8964 9.8429 16 9.97119 16H12.29C12.4183 16 12.5219 15.8964 12.5219 15.7681V9.21281C12.5219 8.47462 12.229 7.76735 11.7072 7.24482L8.75537 4.29297Z" fill="url(#paint3_linear_178_3940)"/> +<path d="M3.30975 6.95703H0.231885C0.103572 6.95703 0 7.06056 0 7.18886V9.50771C0 9.63609 0.103572 9.73962 0.231885 9.73962H5.56521C5.94936 9.73962 6.26087 10.0511 6.26087 10.4353V10.147C6.26087 8.49675 5.02956 6.95703 3.30975 6.95703Z" fill="url(#paint4_linear_178_3940)"/> +<path d="M5.27686 7.77148C5.90677 8.40302 6.26083 9.25477 6.26083 10.1468V15.7684C6.26083 15.8967 6.36436 16.0003 6.49274 16.0003H8.8115C8.93988 16.0003 9.04341 15.8967 9.04341 15.7684V12.6913C9.04341 11.9531 8.75051 11.2459 8.22874 10.7234L5.27686 7.77148Z" fill="url(#paint5_linear_178_3940)"/> +<defs> +<linearGradient id="paint0_linear_178_3940" x1="13.2174" y1="3.15349" x2="0" y2="3.15349" gradientUnits="userSpaceOnUse"> +<stop stop-color="#2C08AC"/> +<stop offset="0.8" stop-color="#4F42FD"/> +</linearGradient> +<linearGradient id="paint1_linear_178_3940" x1="14.2303" y1="0.81543" x2="23.44" y2="11.9747" gradientUnits="userSpaceOnUse"> +<stop offset="0.3" stop-color="#7274FF"/> +<stop offset="1" stop-color="#4F42FD"/> +</linearGradient> +<linearGradient id="paint2_linear_178_3940" x1="9.7391" y1="6.63202" x2="0" y2="6.63202" gradientUnits="userSpaceOnUse"> +<stop stop-color="#2C08AC"/> +<stop offset="0.8" stop-color="#4F42FD"/> +</linearGradient> +<linearGradient id="paint3_linear_178_3940" x1="10.7523" y1="4.29297" x2="17.3026" y2="14.5881" gradientUnits="userSpaceOnUse"> +<stop offset="0.3" stop-color="#7274FF"/> +<stop offset="1" stop-color="#4F42FD"/> +</linearGradient> +<linearGradient id="paint4_linear_178_3940" x1="6.26087" y1="9.91172" x2="0" y2="9.91172" gradientUnits="userSpaceOnUse"> +<stop stop-color="#2C08AC"/> +<stop offset="0.8" stop-color="#4F42FD"/> +</linearGradient> +<linearGradient id="paint5_linear_178_3940" x1="7.2738" y1="7.77148" x2="11.0624" y2="16.243" gradientUnits="userSpaceOnUse"> +<stop offset="0.3" stop-color="#7274FF"/> +<stop offset="1" stop-color="#4F42FD"/> +</linearGradient> +</defs> +</svg> diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg new file mode 100644 index 0000000000..56a5fe461b --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Gemini.svg @@ -0,0 +1,20 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M14.2445 7.22331C13.137 6.75184 12.13 6.07273 11.2778 5.22264C10.0911 4.03353 9.2444 2.54831 8.8258 0.921308C8.80744 0.849046 8.76551 0.784967 8.70665 0.739197C8.6478 0.693428 8.57536 0.668579 8.5008 0.668579C8.42624 0.668579 8.35381 0.693428 8.29495 0.739197C8.2361 0.784967 8.19417 0.849046 8.1758 0.921308C7.75632 2.5481 6.90952 4.03315 5.72314 5.22264C4.87089 6.07263 3.8639 6.75172 2.75647 7.22331C2.32314 7.40998 1.8778 7.55998 1.4218 7.67531C1.3491 7.69317 1.28448 7.7349 1.23829 7.79382C1.1921 7.85274 1.16699 7.92544 1.16699 8.00031C1.16699 8.07518 1.1921 8.14788 1.23829 8.2068C1.28448 8.26572 1.3491 8.30744 1.4218 8.32531C1.8778 8.43998 2.3218 8.58998 2.75647 8.77664C3.86397 9.24811 4.87098 9.92722 5.72314 10.7773C6.9102 11.9666 7.75709 13.452 8.1758 15.0793C8.19367 15.152 8.2354 15.2166 8.29431 15.2628C8.35323 15.309 8.42594 15.3341 8.5008 15.3341C8.57567 15.3341 8.64838 15.309 8.7073 15.2628C8.76621 15.2166 8.80794 15.152 8.8258 15.0793C8.94047 14.6226 9.09047 14.1786 9.27714 13.744C9.74858 12.6365 10.4277 11.6294 11.2778 10.7773C12.4671 9.59052 13.9526 8.74386 15.5798 8.32531C15.6521 8.30694 15.7161 8.26502 15.7619 8.20616C15.8077 8.1473 15.8325 8.07487 15.8325 8.00031C15.8325 7.92575 15.8077 7.85332 15.7619 7.79446C15.7161 7.7356 15.6521 7.69367 15.5798 7.67531C15.1234 7.56047 14.6768 7.40932 14.2445 7.22331Z" fill="#3186FF"/> +<path d="M14.2445 7.22331C13.137 6.75184 12.13 6.07273 11.2778 5.22264C10.0911 4.03353 9.2444 2.54831 8.8258 0.921308C8.80744 0.849046 8.76551 0.784967 8.70665 0.739197C8.6478 0.693428 8.57536 0.668579 8.5008 0.668579C8.42624 0.668579 8.35381 0.693428 8.29495 0.739197C8.2361 0.784967 8.19417 0.849046 8.1758 0.921308C7.75632 2.5481 6.90952 4.03315 5.72314 5.22264C4.87089 6.07263 3.8639 6.75172 2.75647 7.22331C2.32314 7.40998 1.8778 7.55998 1.4218 7.67531C1.3491 7.69317 1.28448 7.7349 1.23829 7.79382C1.1921 7.85274 1.16699 7.92544 1.16699 8.00031C1.16699 8.07518 1.1921 8.14788 1.23829 8.2068C1.28448 8.26572 1.3491 8.30744 1.4218 8.32531C1.8778 8.43998 2.3218 8.58998 2.75647 8.77664C3.86397 9.24811 4.87098 9.92722 5.72314 10.7773C6.9102 11.9666 7.75709 13.452 8.1758 15.0793C8.19367 15.152 8.2354 15.2166 8.29431 15.2628C8.35323 15.309 8.42594 15.3341 8.5008 15.3341C8.57567 15.3341 8.64838 15.309 8.7073 15.2628C8.76621 15.2166 8.80794 15.152 8.8258 15.0793C8.94047 14.6226 9.09047 14.1786 9.27714 13.744C9.74858 12.6365 10.4277 11.6294 11.2778 10.7773C12.4671 9.59052 13.9526 8.74386 15.5798 8.32531C15.6521 8.30694 15.7161 8.26502 15.7619 8.20616C15.8077 8.1473 15.8325 8.07487 15.8325 8.00031C15.8325 7.92575 15.8077 7.85332 15.7619 7.79446C15.7161 7.7356 15.6521 7.69367 15.5798 7.67531C15.1234 7.56047 14.6768 7.40932 14.2445 7.22331Z" fill="url(#paint0_linear_2092_1806)"/> +<path d="M14.2445 7.22331C13.137 6.75184 12.13 6.07273 11.2778 5.22264C10.0911 4.03353 9.2444 2.54831 8.8258 0.921308C8.80744 0.849046 8.76551 0.784967 8.70665 0.739197C8.6478 0.693428 8.57536 0.668579 8.5008 0.668579C8.42624 0.668579 8.35381 0.693428 8.29495 0.739197C8.2361 0.784967 8.19417 0.849046 8.1758 0.921308C7.75632 2.5481 6.90952 4.03315 5.72314 5.22264C4.87089 6.07263 3.8639 6.75172 2.75647 7.22331C2.32314 7.40998 1.8778 7.55998 1.4218 7.67531C1.3491 7.69317 1.28448 7.7349 1.23829 7.79382C1.1921 7.85274 1.16699 7.92544 1.16699 8.00031C1.16699 8.07518 1.1921 8.14788 1.23829 8.2068C1.28448 8.26572 1.3491 8.30744 1.4218 8.32531C1.8778 8.43998 2.3218 8.58998 2.75647 8.77664C3.86397 9.24811 4.87098 9.92722 5.72314 10.7773C6.9102 11.9666 7.75709 13.452 8.1758 15.0793C8.19367 15.152 8.2354 15.2166 8.29431 15.2628C8.35323 15.309 8.42594 15.3341 8.5008 15.3341C8.57567 15.3341 8.64838 15.309 8.7073 15.2628C8.76621 15.2166 8.80794 15.152 8.8258 15.0793C8.94047 14.6226 9.09047 14.1786 9.27714 13.744C9.74858 12.6365 10.4277 11.6294 11.2778 10.7773C12.4671 9.59052 13.9526 8.74386 15.5798 8.32531C15.6521 8.30694 15.7161 8.26502 15.7619 8.20616C15.8077 8.1473 15.8325 8.07487 15.8325 8.00031C15.8325 7.92575 15.8077 7.85332 15.7619 7.79446C15.7161 7.7356 15.6521 7.69367 15.5798 7.67531C15.1234 7.56047 14.6768 7.40932 14.2445 7.22331Z" fill="url(#paint1_linear_2092_1806)"/> +<path d="M14.2445 7.22331C13.137 6.75184 12.13 6.07273 11.2778 5.22264C10.0911 4.03353 9.2444 2.54831 8.8258 0.921308C8.80744 0.849046 8.76551 0.784967 8.70665 0.739197C8.6478 0.693428 8.57536 0.668579 8.5008 0.668579C8.42624 0.668579 8.35381 0.693428 8.29495 0.739197C8.2361 0.784967 8.19417 0.849046 8.1758 0.921308C7.75632 2.5481 6.90952 4.03315 5.72314 5.22264C4.87089 6.07263 3.8639 6.75172 2.75647 7.22331C2.32314 7.40998 1.8778 7.55998 1.4218 7.67531C1.3491 7.69317 1.28448 7.7349 1.23829 7.79382C1.1921 7.85274 1.16699 7.92544 1.16699 8.00031C1.16699 8.07518 1.1921 8.14788 1.23829 8.2068C1.28448 8.26572 1.3491 8.30744 1.4218 8.32531C1.8778 8.43998 2.3218 8.58998 2.75647 8.77664C3.86397 9.24811 4.87098 9.92722 5.72314 10.7773C6.9102 11.9666 7.75709 13.452 8.1758 15.0793C8.19367 15.152 8.2354 15.2166 8.29431 15.2628C8.35323 15.309 8.42594 15.3341 8.5008 15.3341C8.57567 15.3341 8.64838 15.309 8.7073 15.2628C8.76621 15.2166 8.80794 15.152 8.8258 15.0793C8.94047 14.6226 9.09047 14.1786 9.27714 13.744C9.74858 12.6365 10.4277 11.6294 11.2778 10.7773C12.4671 9.59052 13.9526 8.74386 15.5798 8.32531C15.6521 8.30694 15.7161 8.26502 15.7619 8.20616C15.8077 8.1473 15.8325 8.07487 15.8325 8.00031C15.8325 7.92575 15.8077 7.85332 15.7619 7.79446C15.7161 7.7356 15.6521 7.69367 15.5798 7.67531C15.1234 7.56047 14.6768 7.40932 14.2445 7.22331Z" fill="url(#paint2_linear_2092_1806)"/> +<defs> +<linearGradient id="paint0_linear_2092_1806" x1="5.16714" y1="10.3333" x2="7.8338" y2="7.99997" gradientUnits="userSpaceOnUse"> +<stop stop-color="#08B962"/> +<stop offset="1" stop-color="#08B962" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint1_linear_2092_1806" x1="5.8338" y1="3.66664" x2="8.16714" y2="7.33331" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F94543"/> +<stop offset="1" stop-color="#F94543" stop-opacity="0"/> +</linearGradient> +<linearGradient id="paint2_linear_2092_1806" x1="2.8338" y1="8.99998" x2="12.1671" y2="7.99998" gradientUnits="userSpaceOnUse"> +<stop stop-color="#FABC12"/> +<stop offset="0.46" stop-color="#FABC12" stop-opacity="0"/> +</linearGradient> +</defs> +</svg> diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg new file mode 100644 index 0000000000..ce2471552e --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Mistral.svg @@ -0,0 +1,24 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2092_1755)"> +<mask id="mask0_2092_1755" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="17" height="16"> +<path d="M16.428 0H0.5V16H16.428V0Z" fill="white"/> +</mask> +<g mask="url(#mask0_2092_1755)"> +<path d="M5.05035 0H2.77441V3.21057H5.05035V0Z" fill="#FFD800"/> +<path d="M14.1529 0H11.877V3.21057H14.1529V0Z" fill="#FFD800"/> +<path d="M7.32555 3.21082H2.77441V6.42139H7.32555V3.21082Z" fill="#FFAF00"/> +<path d="M14.1537 3.21082H9.60254V6.42139H14.1537V3.21082Z" fill="#FFAF00"/> +<path d="M14.1519 6.41992H2.77441V9.63049H14.1519V6.41992Z" fill="#FF8205"/> +<path d="M5.05035 9.63074H2.77441V12.8414H5.05035V9.63074Z" fill="#FA500F"/> +<path d="M9.60213 9.63074H7.32617V12.8414H9.60213V9.63074Z" fill="#FA500F"/> +<path d="M14.1529 9.63074H11.877V12.8414H14.1529V9.63074Z" fill="#FA500F"/> +<path d="M7.32633 12.8402H0.5V16.0509H7.32633V12.8402Z" fill="#E10500"/> +<path d="M16.4296 12.8402H9.60254V16.0509H16.4296V12.8402Z" fill="#E10500"/> +</g> +</g> +<defs> +<clipPath id="clip0_2092_1755"> +<rect width="16" height="16" fill="white" transform="translate(0.5)"/> +</clipPath> +</defs> +</svg> diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg new file mode 100644 index 0000000000..e44dda654d --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Ollama.svg @@ -0,0 +1,3 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M5.76978 0.726646C5.91378 0.783313 6.04378 0.876646 6.16178 0.99998C6.35845 1.20398 6.52445 1.49598 6.65111 1.84198C6.77845 2.18998 6.86111 2.57531 6.89245 2.96198C7.31219 2.72452 7.77801 2.57993 8.25844 2.53798L8.29245 2.53531C8.87245 2.48865 9.44578 2.59331 9.94578 2.85131C10.0131 2.88665 10.0791 2.92465 10.1438 2.96465C10.1771 2.58531 10.2584 2.20865 10.3838 1.86865C10.5104 1.52198 10.6764 1.23065 10.8724 1.02598C10.982 0.907489 11.116 0.814225 11.2651 0.752646C11.4364 0.68598 11.6184 0.67398 11.7958 0.724646C12.0631 0.800646 12.2924 0.96998 12.4731 1.21598C12.6384 1.44065 12.7624 1.72865 12.8471 2.07398C13.0004 2.69665 13.0271 3.51598 12.9238 4.50398L12.9591 4.53065L12.9764 4.54331C13.4811 4.92731 13.8324 5.47465 14.0184 6.10998C14.3084 7.10131 14.1624 8.21331 13.6624 8.83531L13.6504 8.84931L13.6518 8.85131C13.9298 9.35931 14.0984 9.89598 14.1344 10.4513L14.1358 10.4713C14.1784 11.1813 14.0024 11.896 13.5931 12.598L13.5884 12.6046L13.5951 12.6206C13.9098 13.392 14.0084 14.1686 13.8871 14.9446L13.8831 14.9706C13.8643 15.084 13.8013 15.1853 13.708 15.2523C13.6146 15.3192 13.4985 15.3465 13.3851 15.328C13.329 15.3192 13.2751 15.2995 13.2266 15.2698C13.1781 15.2402 13.1359 15.2013 13.1025 15.1554C13.069 15.1094 13.045 15.0573 13.0317 15.002C13.0184 14.9468 13.0162 14.8894 13.0251 14.8333C13.1364 14.1446 13.0318 13.454 12.7051 12.7513C12.6746 12.686 12.6611 12.6141 12.6658 12.5422C12.6704 12.4703 12.6931 12.4008 12.7318 12.34L12.7344 12.336C13.1371 11.72 13.3038 11.116 13.2678 10.5226C13.2371 10.0033 13.0511 9.49331 12.7344 9.00731C12.6729 8.91283 12.6509 8.79791 12.6734 8.68738C12.6958 8.57686 12.7609 8.47961 12.8544 8.41665L12.8604 8.41265C13.0224 8.30665 13.1718 8.03598 13.2471 7.66598C13.3302 7.22847 13.3085 6.77749 13.1838 6.34998C13.0471 5.88331 12.7971 5.49398 12.4471 5.22798C12.0504 4.92531 11.5251 4.77931 10.8604 4.82131C10.7735 4.82696 10.6869 4.80642 10.6118 4.76232C10.5367 4.71823 10.4765 4.65262 10.4391 4.57398C10.2298 4.13065 9.92444 3.81331 9.54378 3.61665C9.17831 3.43425 8.76915 3.35759 8.36244 3.39531C7.53244 3.46131 6.80044 3.92931 6.58244 4.51931C6.55161 4.60235 6.49613 4.67398 6.42345 4.72462C6.35077 4.77526 6.26436 4.80248 6.17578 4.80265C5.46445 4.80398 4.91378 4.97065 4.51111 5.27131C4.16311 5.53131 3.92578 5.89465 3.80045 6.32998C3.68703 6.73975 3.6715 7.17044 3.75511 7.58731C3.82978 7.95931 3.97578 8.26731 4.14311 8.43331L4.14845 8.43798C4.28978 8.57598 4.31978 8.79131 4.22111 8.96131C3.98111 9.37598 3.80178 9.99398 3.77245 10.588C3.73911 11.2666 3.89645 11.856 4.25178 12.2786L4.26245 12.2913C4.31607 12.3538 4.35056 12.4304 4.36179 12.512C4.37302 12.5936 4.36052 12.6767 4.32578 12.7513C3.94178 13.5753 3.82378 14.2526 3.95111 14.786C3.974 14.8969 3.95271 15.0123 3.89176 15.1078C3.83081 15.2032 3.73503 15.2711 3.62479 15.297C3.51455 15.3229 3.39856 15.3047 3.3015 15.2464C3.20444 15.1881 3.13398 15.0941 3.10511 14.9846C2.94311 14.306 3.05311 13.5286 3.42045 12.6526L3.42978 12.6293L3.42445 12.6213C3.24391 12.3546 3.10916 12.0597 3.02578 11.7486L3.02245 11.736C2.92125 11.3479 2.88144 10.9464 2.90445 10.546C2.93378 9.93931 3.08978 9.31798 3.31911 8.81931L3.32711 8.80198L3.32578 8.80065C3.13045 8.52198 2.98578 8.16531 2.90578 7.77065L2.90245 7.75465C2.79222 7.20414 2.81346 6.6354 2.96445 6.09465C3.13911 5.48465 3.48245 4.96065 3.98845 4.58198C4.02845 4.55198 4.07045 4.52198 4.11245 4.49398C4.00645 3.49865 4.03311 2.67398 4.18711 2.04731C4.27178 1.70198 4.39645 1.41398 4.56178 1.18931C4.74178 0.94398 4.97111 0.774646 5.23845 0.69798C5.41578 0.647313 5.59845 0.658646 5.76978 0.72598V0.726646ZM8.51378 6.78665C9.13778 6.78665 9.71378 6.99531 10.1444 7.35665C10.5644 7.70798 10.8144 8.17998 10.8144 8.64998C10.8144 9.24198 10.5438 9.70331 10.0591 9.99798C9.64578 10.248 9.09178 10.3693 8.45711 10.3693C7.78445 10.3693 7.20978 10.1966 6.79511 9.87998C6.38378 9.56665 6.15311 9.12665 6.15311 8.64998C6.15311 8.17865 6.41845 7.70531 6.85711 7.35265C7.30245 6.99465 7.89045 6.78665 8.51378 6.78665ZM8.51378 7.38398C8.05127 7.37994 7.60103 7.53269 7.23644 7.81731C6.92911 8.06398 6.75511 8.37398 6.75511 8.65065C6.75511 8.93598 6.89511 9.20331 7.16178 9.40665C7.46511 9.63798 7.91111 9.77198 8.45711 9.77198C8.98978 9.77198 9.43911 9.67398 9.74511 9.48798C10.0538 9.30131 10.2118 9.03065 10.2118 8.64998C10.2118 8.36798 10.0478 8.05665 9.75645 7.81265C9.43378 7.54265 8.99644 7.38398 8.51378 7.38398ZM8.95511 8.19065L8.95778 8.19331C9.03778 8.29398 9.02111 8.43998 8.92044 8.51998L8.72578 8.67331V8.97065C8.72543 9.03684 8.69884 9.10018 8.65185 9.1468C8.60486 9.19341 8.5413 9.21949 8.47511 9.21931C8.40892 9.21949 8.34536 9.19341 8.29837 9.1468C8.25138 9.10018 8.2248 9.03684 8.22445 8.97065V8.66398L8.04378 8.51865C8.01995 8.49955 8.00013 8.47592 7.98548 8.44913C7.97082 8.42233 7.96162 8.3929 7.9584 8.36254C7.95517 8.33217 7.95799 8.30146 7.9667 8.27219C7.9754 8.24291 7.98982 8.21565 8.00911 8.19198C8.04846 8.14408 8.10512 8.11364 8.16679 8.10727C8.22845 8.10091 8.29014 8.11913 8.33845 8.15798L8.48178 8.27265L8.62844 8.15665C8.67658 8.11862 8.73767 8.10089 8.79869 8.10724C8.85971 8.11359 8.91584 8.14352 8.95511 8.19065ZM5.59511 6.91131C5.91378 6.91131 6.17311 7.17131 6.17311 7.49198C6.17329 7.64569 6.11244 7.79318 6.00394 7.90206C5.89544 8.01094 5.74816 8.07229 5.59445 8.07265C5.44097 8.07212 5.29395 8.01078 5.18562 7.90206C5.07728 7.79335 5.01644 7.64613 5.01645 7.49265C5.01609 7.33894 5.07677 7.19137 5.18514 7.08237C5.29352 6.97337 5.4414 6.91184 5.59511 6.91131ZM11.3991 6.91131C11.7191 6.91131 11.9778 7.17131 11.9778 7.49198C11.978 7.64569 11.9171 7.79318 11.8086 7.90206C11.7001 8.01094 11.5528 8.07229 11.3991 8.07265C11.2456 8.07212 11.0986 8.01078 10.9903 7.90206C10.8819 7.79335 10.8211 7.64613 10.8211 7.49265C10.8208 7.33894 10.8814 7.19137 10.9898 7.08237C11.0982 6.97337 11.2454 6.91184 11.3991 6.91131ZM5.45978 1.53331L5.45778 1.53465C5.38055 1.56823 5.31459 1.62331 5.26778 1.69331L5.26445 1.69731C5.17245 1.82331 5.09245 2.00865 5.03245 2.25198C4.91911 2.71331 4.88845 3.33931 4.94978 4.10665C5.23645 4.02131 5.54911 3.96798 5.88578 3.94865L5.89245 3.94798L5.90511 3.92531C5.93578 3.87065 5.96844 3.81798 6.00378 3.76598C6.08578 3.25198 6.01845 2.63798 5.83511 2.13665C5.74578 1.89398 5.63711 1.70331 5.53311 1.59465C5.51164 1.57205 5.48772 1.55193 5.46178 1.53465L5.45978 1.53331ZM11.5758 1.55998L11.5744 1.56065C11.5485 1.57793 11.5246 1.59805 11.5031 1.62065C11.3991 1.72931 11.2898 1.92065 11.2011 2.16331C11.0078 2.69265 10.9431 3.34731 11.0478 3.87798L11.0864 3.94265L11.0918 3.95198H11.1118C11.4426 3.95206 11.7718 3.99966 12.0891 4.09331C12.1464 3.34398 12.1144 2.73131 12.0038 2.27865C11.9438 2.03531 11.8638 1.84998 11.7711 1.72398L11.7684 1.71998C11.7217 1.64973 11.6558 1.59441 11.5784 1.56065H11.5758V1.55998Z" fill="black"/> +</svg> diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg new file mode 100644 index 0000000000..301a40fd55 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/Onnx.svg @@ -0,0 +1,18 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2092_1790)"> +<path d="M15.85 7.50005H15.75L13.08 2.54505C13.1287 2.45432 13.1544 2.35303 13.155 2.25005C13.155 2.16427 13.138 2.07933 13.105 2.00014C13.072 1.92095 13.0237 1.84907 12.9628 1.78865C12.9019 1.72823 12.8297 1.68045 12.7502 1.64808C12.6708 1.61571 12.5857 1.59939 12.5 1.60005C12.4129 1.59972 12.3267 1.6173 12.2467 1.65171C12.1667 1.68612 12.0946 1.73661 12.035 1.80005L6.71496 0.740047C6.70184 0.64745 6.66874 0.558814 6.61795 0.480283C6.56716 0.401752 6.4999 0.335204 6.42084 0.285253C6.34177 0.235302 6.25279 0.203143 6.16006 0.191003C6.06733 0.178864 5.97307 0.187036 5.88381 0.214952C5.79455 0.242869 5.71243 0.289862 5.64314 0.352674C5.57385 0.415486 5.51905 0.492615 5.48253 0.578715C5.44602 0.664814 5.42867 0.757825 5.43167 0.851299C5.43468 0.944773 5.45798 1.03647 5.49996 1.12005L1.34996 7.06505C1.29477 7.04867 1.23753 7.04025 1.17996 7.04005C1.01805 7.05429 0.867358 7.12867 0.757581 7.24853C0.647805 7.36838 0.586914 7.52502 0.586914 7.68755C0.586914 7.85008 0.647805 8.00671 0.757581 8.12657C0.867358 8.24643 1.01805 8.32081 1.17996 8.33505L3.42996 13.87C3.39194 13.9551 3.37153 14.0469 3.36996 14.14C3.37128 14.3116 3.44034 14.4756 3.5621 14.5964C3.68386 14.7173 3.84843 14.7851 4.01996 14.785C4.10788 14.7861 4.19505 14.7688 4.27596 14.7344C4.35686 14.7 4.42973 14.6491 4.48996 14.585L11.21 15.24C11.2312 15.4111 11.3195 15.5667 11.4554 15.6727C11.5914 15.7787 11.7639 15.8263 11.935 15.805C12.106 15.7838 12.2617 15.6955 12.3676 15.5596C12.4736 15.4236 12.5212 15.2511 12.5 15.08C12.498 14.9264 12.4433 14.7781 12.345 14.66L15.73 8.76005H15.84C15.9253 8.76137 16.0101 8.74587 16.0895 8.71442C16.1688 8.68297 16.2412 8.63619 16.3025 8.57676C16.3638 8.51733 16.4128 8.44641 16.4467 8.36804C16.4805 8.28968 16.4987 8.20541 16.5 8.12005C16.4947 7.95205 16.4236 7.79286 16.302 7.67686C16.1804 7.56085 16.018 7.49734 15.85 7.50005ZM12 2.64505C12.0769 2.74772 12.1833 2.82448 12.305 2.86505L11.305 10.55C11.2418 10.5642 11.1811 10.5878 11.125 10.62L5.49996 5.76005C5.51878 5.70543 5.52726 5.64778 5.52496 5.59005C5.52496 5.55005 5.52496 5.50505 5.52496 5.46505L12 2.64505ZM15.235 8.30505L11.79 10.66C11.763 10.6412 11.7345 10.6245 11.705 10.61L12.725 2.84505L15.355 7.69505C15.2499 7.81343 15.1928 7.96678 15.195 8.12505L15.235 8.30505ZM4.78496 4.95005C4.63217 4.97297 4.49283 5.0504 4.39266 5.16803C4.29249 5.28566 4.23825 5.43555 4.23996 5.59005V5.63505L1.92996 7.00005L5.49996 1.87005L4.78496 4.95005ZM4.99996 6.22505C5.08363 6.20348 5.16307 6.16798 5.23496 6.12005L10.79 10.955C10.7638 11.0306 10.7502 11.11 10.75 11.19V11.225L4.54996 13.775C4.46194 13.6393 4.32644 13.5412 4.16996 13.5L4.99996 6.22505ZM10.935 11.62C11.0198 11.7179 11.1337 11.7862 11.26 11.815L11.565 14.5C11.4362 14.5671 11.3327 14.6741 11.27 14.805L4.76996 14.165L10.935 11.62ZM11.7 11.765C11.8068 11.7099 11.8967 11.6269 11.9601 11.5248C12.0235 11.4226 12.058 11.3052 12.06 11.185C12.0622 11.1306 12.0537 11.0762 12.035 11.025L15.185 8.86005L12 14.4L11.7 11.765ZM11.86 2.22505L5.28996 5.08505L5.21496 5.03505L6.04996 1.45505H6.07496C6.18268 1.45615 6.28889 1.42961 6.38342 1.37796C6.47796 1.32631 6.55768 1.25129 6.61496 1.16005L11.86 2.20505V2.22505ZM1.82996 7.69005C1.82996 7.64505 1.82996 7.60505 1.82996 7.57005L4.42496 6.04005C4.47735 6.09439 4.53812 6.13997 4.60496 6.17505L3.74996 13.43L1.60996 8.17005C1.67867 8.11029 1.73383 8.03657 1.77177 7.95379C1.80971 7.87102 1.82955 7.7811 1.82996 7.69005Z" fill="#333333"/> +<path d="M12.7446 2.84497L15.3696 7.69497C15.2665 7.81456 15.2098 7.96712 15.2096 8.12497C15.2023 8.18475 15.2023 8.24519 15.2096 8.30497L11.7696 10.66L11.6846 10.61L12.6846 2.84497H12.7446Z" fill="#DEDEDD"/> +<path d="M11.7002 11.765C11.807 11.7099 11.8969 11.6268 11.9603 11.5247C12.0237 11.4226 12.0582 11.3052 12.0602 11.185C12.0625 11.1305 12.054 11.0762 12.0352 11.025L15.1852 8.85999L12.0002 14.4L11.7002 11.765Z" fill="#B2B2B2"/> +<path d="M10.9345 11.62C11.0194 11.7179 11.1332 11.7862 11.2595 11.815L11.5645 14.5C11.4358 14.567 11.3322 14.6741 11.2695 14.805L4.76953 14.165L10.9345 11.62Z" fill="#D1D1D1"/> +<path d="M4.99992 6.225C5.08359 6.20343 5.16303 6.16793 5.23492 6.12L10.7899 10.955C10.7637 11.0306 10.7502 11.11 10.7499 11.19V11.225L4.54992 13.775C4.4619 13.6392 4.3264 13.5412 4.16992 13.5L4.99992 6.225Z" fill="#F2F2F2"/> +<path d="M1.83035 7.69004C1.83035 7.64504 1.83035 7.60504 1.83035 7.57004L4.42535 6.04004C4.47774 6.09439 4.53851 6.13997 4.60535 6.17504L3.75035 13.43L1.61035 8.17004C1.67906 8.11029 1.73422 8.03656 1.77216 7.95378C1.8101 7.87101 1.82994 7.78109 1.83035 7.69004Z" fill="#D8D8D7"/> +<path d="M4.78469 4.95C4.6319 4.97292 4.49255 5.05034 4.39238 5.16797C4.29221 5.28561 4.23798 5.4355 4.23969 5.59V5.635L1.92969 7L5.49969 1.87L4.78469 4.95Z" fill="#B2B2B2"/> +<path d="M11.8598 2.22503L5.28984 5.08503L5.21484 5.03503L6.04984 1.45503H6.07484C6.18256 1.45613 6.28877 1.42959 6.38331 1.37795C6.47785 1.3263 6.55757 1.25127 6.61484 1.16003L11.8598 2.20503V2.22503Z" fill="#D1D1D1"/> +<path d="M12 2.64502C12.0769 2.74769 12.1833 2.82445 12.305 2.86502L11.305 10.55C11.2418 10.5642 11.1811 10.5878 11.125 10.62L5.5 5.76002C5.51882 5.7054 5.5273 5.64775 5.525 5.59002C5.525 5.55002 5.525 5.50502 5.525 5.46502L12 2.64502Z" fill="white"/> +</g> +<defs> +<clipPath id="clip0_2092_1790"> +<rect width="16" height="16" fill="white" transform="translate(0.5)"/> +</clipPath> +</defs> +</svg> diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg new file mode 100644 index 0000000000..87aacb3a4f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.dark.svg @@ -0,0 +1,15 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2092_1800)"> +<mask id="mask0_2092_1800" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="17" height="16"> +<path d="M16.5 0H0.5V16H16.5V0Z" fill="white"/> +</mask> +<g mask="url(#mask0_2092_1800)"> +<path d="M6.63673 5.772V4.26557C6.63673 4.13867 6.68433 4.0435 6.79527 3.98013L9.8241 2.23585C10.2364 1.998 10.728 1.88706 11.2353 1.88706C13.1382 1.88706 14.3434 3.3618 14.3434 4.9316C14.3434 5.0426 14.3434 5.16947 14.3275 5.29633L11.1877 3.45687C10.9975 3.3459 10.8071 3.3459 10.6169 3.45687L6.63673 5.772ZM13.709 11.6392V8.03953C13.709 7.8175 13.6138 7.65893 13.4236 7.54793L9.44343 5.2328L10.7437 4.48747C10.8547 4.42413 10.9499 4.42413 11.0609 4.48747L14.0897 6.23177C14.9619 6.73927 15.5485 7.8175 15.5485 8.864C15.5485 10.0691 14.835 11.1793 13.709 11.6392ZM5.70117 8.46777L4.40087 7.70667C4.28993 7.64333 4.2423 7.5481 4.2423 7.42123V3.9327C4.2423 2.23602 5.5426 0.951497 7.30277 0.951497C7.96887 0.951497 8.58717 1.17355 9.1106 1.56996L5.98673 3.37773C5.7965 3.4887 5.7013 3.64723 5.7013 3.86933L5.70117 8.46777ZM8.5 10.0852L6.63673 9.03863V6.8187L8.5 5.77217L10.3631 6.8187V9.03863L8.5 10.0852ZM9.6972 14.9058C9.03113 14.9058 8.41283 14.6838 7.8894 14.2874L11.0132 12.4796C11.2035 12.3686 11.2987 12.2101 11.2987 11.988V7.3894L12.6149 8.15053C12.7258 8.21387 12.7735 8.30907 12.7735 8.43597V11.9245C12.7735 13.6212 11.4572 14.9058 9.6972 14.9058ZM5.939 11.3697L2.91018 9.62543C2.03797 9.1179 1.45133 8.0397 1.45133 6.99317C1.45133 5.77217 2.18077 4.67803 3.30657 4.21813V7.83357C3.30657 8.0556 3.40178 8.21417 3.592 8.32513L7.5564 10.6244L6.2561 11.3697C6.14517 11.433 6.04993 11.433 5.939 11.3697ZM5.76467 13.9703C3.9728 13.9703 2.6566 12.6224 2.6566 10.9574C2.6566 10.8305 2.6725 10.7036 2.68826 10.5768L5.81213 12.3845C6.00237 12.4955 6.19273 12.4955 6.38297 12.3845L10.3631 10.0853V11.5918C10.3631 11.7186 10.3155 11.8138 10.2046 11.8772L7.17577 13.6215C6.76347 13.8593 6.27203 13.9703 5.76467 13.9703ZM9.6972 15.8572C11.6159 15.8572 13.2174 14.4935 13.5823 12.6857C15.3583 12.2258 16.5 10.5608 16.5 8.86417C16.5 7.7541 16.0243 6.6759 15.168 5.89887C15.2473 5.56583 15.2949 5.2328 15.2949 4.89993C15.2949 2.6324 13.4554 0.935567 11.3305 0.935567C10.9025 0.935567 10.4902 0.99892 10.0778 1.14172C9.36417 0.443973 8.381 0 7.30277 0C5.38407 0 3.78256 1.36364 3.41771 3.17142C1.64172 3.63133 0.5 5.29633 0.5 6.993C0.5 8.10307 0.975663 9.18127 1.83198 9.9583C1.7527 10.2913 1.70511 10.6244 1.70511 10.9572C1.70511 13.2248 3.54458 14.9216 5.66947 14.9216C6.09753 14.9216 6.50983 14.8582 6.92217 14.7154C7.63567 15.4132 8.61883 15.8572 9.6972 15.8572Z" fill="white"/> +</g> +</g> +<defs> +<clipPath id="clip0_2092_1800"> +<rect width="16" height="16" fill="white" transform="translate(0.5)"/> +</clipPath> +</defs> +</svg> diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg new file mode 100644 index 0000000000..f72a3c64d1 --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/OpenAI.light.svg @@ -0,0 +1,10 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2092_1732)"> +<path d="M6.63673 5.772V4.26557C6.63673 4.13867 6.68433 4.0435 6.79527 3.98013L9.8241 2.23585C10.2364 1.998 10.728 1.88706 11.2353 1.88706C13.1382 1.88706 14.3434 3.3618 14.3434 4.9316C14.3434 5.0426 14.3434 5.16947 14.3275 5.29633L11.1877 3.45687C10.9975 3.3459 10.8071 3.3459 10.6169 3.45687L6.63673 5.772ZM13.709 11.6392V8.03953C13.709 7.8175 13.6138 7.65893 13.4236 7.54793L9.44343 5.2328L10.7437 4.48747C10.8547 4.42413 10.9499 4.42413 11.0609 4.48747L14.0897 6.23177C14.9619 6.73927 15.5485 7.8175 15.5485 8.864C15.5485 10.0691 14.835 11.1793 13.709 11.6392ZM5.70117 8.46777L4.40087 7.70667C4.28993 7.64333 4.2423 7.5481 4.2423 7.42123V3.9327C4.2423 2.23602 5.5426 0.951497 7.30277 0.951497C7.96887 0.951497 8.58717 1.17355 9.1106 1.56996L5.98673 3.37773C5.7965 3.4887 5.7013 3.64723 5.7013 3.86933L5.70117 8.46777ZM8.5 10.0852L6.63673 9.03863V6.8187L8.5 5.77217L10.3631 6.8187V9.03863L8.5 10.0852ZM9.6972 14.9058C9.03113 14.9058 8.41283 14.6838 7.8894 14.2874L11.0132 12.4796C11.2035 12.3686 11.2987 12.2101 11.2987 11.988V7.3894L12.6149 8.15053C12.7258 8.21387 12.7735 8.30907 12.7735 8.43597V11.9245C12.7735 13.6212 11.4572 14.9058 9.6972 14.9058ZM5.939 11.3697L2.91018 9.62543C2.03797 9.1179 1.45133 8.0397 1.45133 6.99317C1.45133 5.77217 2.18077 4.67803 3.30657 4.21813V7.83357C3.30657 8.0556 3.40178 8.21417 3.592 8.32513L7.5564 10.6244L6.2561 11.3697C6.14517 11.433 6.04993 11.433 5.939 11.3697ZM5.76467 13.9703C3.9728 13.9703 2.6566 12.6224 2.6566 10.9574C2.6566 10.8305 2.6725 10.7036 2.68826 10.5768L5.81213 12.3845C6.00237 12.4955 6.19273 12.4955 6.38297 12.3845L10.3631 10.0853V11.5918C10.3631 11.7186 10.3155 11.8138 10.2046 11.8772L7.17577 13.6215C6.76347 13.8593 6.27203 13.9703 5.76467 13.9703ZM9.6972 15.8572C11.6159 15.8572 13.2174 14.4935 13.5823 12.6857C15.3583 12.2258 16.5 10.5608 16.5 8.86417C16.5 7.7541 16.0243 6.6759 15.168 5.89887C15.2473 5.56583 15.2949 5.2328 15.2949 4.89993C15.2949 2.6324 13.4554 0.935567 11.3305 0.935567C10.9025 0.935567 10.4902 0.99892 10.0778 1.14172C9.36417 0.443973 8.381 0 7.30277 0C5.38407 0 3.78256 1.36364 3.41771 3.17142C1.64172 3.63133 0.5 5.29633 0.5 6.993C0.5 8.10307 0.975663 9.18127 1.83198 9.9583C1.7527 10.2913 1.70511 10.6244 1.70511 10.9572C1.70511 13.2248 3.54458 14.9216 5.66947 14.9216C6.09753 14.9216 6.50983 14.8582 6.92217 14.7154C7.63567 15.4132 8.61883 15.8572 9.6972 15.8572Z" fill="black"/> +</g> +<defs> +<clipPath id="clip0_2092_1732"> +<rect width="16" height="16" fill="white" transform="translate(0.5)"/> +</clipPath> +</defs> +</svg> diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg new file mode 100644 index 0000000000..fafc16b59f --- /dev/null +++ b/src/settings-ui/Settings.UI/Assets/Settings/Icons/Models/WindowsML.svg @@ -0,0 +1,74 @@ +<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_2092_1770)"> +<mask id="mask0_2092_1770" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="17" height="16"> +<path d="M16.5 0H0.5V16H16.5V0Z" fill="white"/> +</mask> +<g mask="url(#mask0_2092_1770)"> +<mask id="mask1_2092_1770" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="17" height="16"> +<path d="M16.5 0H0.5V16H16.5V0Z" fill="white"/> +</mask> +<g mask="url(#mask1_2092_1770)"> +<path d="M16.131 10.9838L9.24712 15.124C9.02152 15.2597 8.76328 15.3313 8.5 15.3313C8.23675 15.3313 7.97846 15.2597 7.75286 15.124L0.869003 10.9838C0.640038 10.8461 0.5 10.5985 0.5 10.3313C0.5 10.0641 0.640038 9.81645 0.869003 9.67877L7.75286 5.53867C7.97846 5.40299 8.23675 5.3313 8.5 5.3313C8.76328 5.3313 9.02152 5.40299 9.24712 5.53867L16.131 9.67877C16.36 9.81645 16.5 10.0641 16.5 10.3313C16.5 10.5985 16.36 10.8461 16.131 10.9838Z" fill="url(#paint0_linear_2092_1770)"/> +<path d="M16.131 8.65256L9.24712 12.7926C9.02152 12.9283 8.76328 13 8.5 13C8.23675 13 7.97846 12.9283 7.75286 12.7926L0.869003 8.65256C0.640038 8.5148 0.5 8.2672 0.5 8C0.5 7.73282 0.640038 7.48518 0.869003 7.34746L7.75286 3.20737C7.97846 3.07168 8.23675 3 8.5 3C8.76328 3 9.02152 3.07168 9.24712 3.20737L16.131 7.34746C16.36 7.48518 16.5 7.73282 16.5 8C16.5 8.2672 16.36 8.5148 16.131 8.65256Z" fill="url(#paint1_linear_2092_1770)"/> +<path d="M16.131 6.31818L9.24712 10.4583C9.02152 10.5939 8.76328 10.6656 8.5 10.6656C8.23675 10.6656 7.97846 10.5939 7.75286 10.4583L0.869003 6.31818C0.640038 6.18046 0.5 5.93283 0.5 5.66565C0.5 5.39846 0.640038 5.15083 0.869003 5.01311L7.75286 0.873017C7.97846 0.737337 8.23675 0.665649 8.5 0.665649C8.76328 0.665649 9.02152 0.737337 9.24712 0.873017L16.131 5.01311C16.36 5.15083 16.5 5.39846 16.5 5.66565C16.5 5.93283 16.36 6.18046 16.131 6.31818Z" fill="url(#paint2_linear_2092_1770)"/> +<path d="M11.8334 7.55765C11.8334 8.29403 11.2364 8.89099 10.5 8.89099H5.83334C5.09695 8.89099 4.5 8.29403 4.5 7.55765V2.66565H11.8334V7.55765Z" fill="url(#paint3_radial_2092_1770)" fill-opacity="0.4"/> +<path d="M11.8334 7.55765C11.8334 8.29403 11.2364 8.89099 10.5 8.89099H5.83334C5.09695 8.89099 4.5 8.29403 4.5 7.55765V2.66565H11.8334V7.55765Z" fill="url(#paint4_radial_2092_1770)" fill-opacity="0.4"/> +<path d="M11.8334 7.55765C11.8334 8.29403 11.2364 8.89099 10.5 8.89099H5.83334C5.09695 8.89099 4.5 8.29403 4.5 7.55765V2.66565H11.8334V7.55765Z" fill="url(#paint5_radial_2092_1770)" fill-opacity="0.4"/> +<path d="M11.8334 7.55765C11.8334 8.29403 11.2364 8.89099 10.5 8.89099H5.83334C5.09695 8.89099 4.5 8.29403 4.5 7.55765V2.66565H11.8334V7.55765Z" fill="url(#paint6_radial_2092_1770)" fill-opacity="0.4"/> +<path d="M11.8334 7.55765C11.8334 8.29403 11.2364 8.89099 10.5 8.89099H5.83334C5.09695 8.89099 4.5 8.29403 4.5 7.55765V2.66565H11.8334V7.55765Z" fill="url(#paint7_radial_2092_1770)" fill-opacity="0.4"/> +<path d="M11.8334 7.55765C11.8334 8.29403 11.2364 8.89099 10.5 8.89099H5.83334C5.09695 8.89099 4.5 8.29403 4.5 7.55765V2.66565H11.8334V7.55765Z" fill="url(#paint8_radial_2092_1770)" fill-opacity="0.4"/> +<path d="M8.01858 3.31028L7.76982 2.80011C7.749 2.7608 7.7104 2.72675 7.65934 2.70267C7.60826 2.67859 7.54725 2.66565 7.48468 2.66565C7.42214 2.66565 7.36112 2.67859 7.31005 2.70267C7.25898 2.72675 7.22038 2.7608 7.19957 2.80011L6.9508 3.31028C6.87498 3.46428 6.74675 3.60454 6.57612 3.72002C6.40548 3.83551 6.19708 3.92314 5.9672 3.97603L5.20177 4.14185C5.14277 4.15571 5.09168 4.18143 5.05555 4.21548C5.0194 4.24951 5 4.29019 5 4.33188C5 4.37357 5.0194 4.41423 5.05555 4.44828C5.09168 4.48231 5.14277 4.50803 5.20177 4.52191L5.9672 4.68771C6.16372 4.73139 6.34502 4.80035 6.501 4.89045C6.53005 4.90723 6.55823 4.92475 6.58548 4.94297C6.72874 5.03882 6.84258 5.152 6.92118 5.27615C6.93774 5.30231 6.95274 5.32895 6.9661 5.35602L7.21486 5.86619C7.23363 5.90162 7.26685 5.93279 7.31062 5.95622C7.3154 5.95879 7.32032 5.96127 7.32535 5.96363C7.37642 5.98771 7.43743 6.00065 7.5 6.00065C7.56257 6.00065 7.62358 5.98771 7.67465 5.96363C7.72572 5.93955 7.76432 5.9055 7.78514 5.86619L8.0339 5.35602C8.11125 5.20091 8.24182 5.05999 8.41522 4.94442C8.58864 4.82885 8.80008 4.74182 9.0328 4.69027L9.79824 4.52447C9.8572 4.51059 9.90832 4.48487 9.94448 4.45083C9.98056 4.41679 10 4.37611 10 4.33443C10 4.29274 9.98056 4.25207 9.94448 4.21803C9.90832 4.18399 9.8572 4.15827 9.79824 4.1444L9.78296 4.14185L9.01752 3.97603C8.7848 3.92448 8.57328 3.83747 8.39992 3.72188C8.22652 3.60631 8.09595 3.46539 8.01858 3.31028Z" fill="url(#paint9_linear_2092_1770)"/> +<path d="M11.0775 6.78623L11.5368 6.88572L11.546 6.88725C11.5813 6.89557 11.612 6.911 11.6336 6.93143C11.6553 6.95185 11.667 6.97625 11.667 7.00126C11.667 7.02628 11.6553 7.05068 11.6336 7.0711C11.612 7.09154 11.5813 7.10697 11.546 7.11528L11.0867 7.21477C10.947 7.2457 10.8202 7.29792 10.7161 7.36726C10.6121 7.4366 10.5337 7.52117 10.4873 7.61422L10.338 7.92032C10.3256 7.94392 10.3024 7.96434 10.2718 7.97878C10.2412 7.99323 10.2045 8.00104 10.167 8.00104C10.1295 8.00104 10.0928 7.99323 10.0622 7.97878C10.0316 7.96434 10.0084 7.94392 9.99587 7.92032L9.99539 7.91937L9.84667 7.61422C9.80051 7.52088 9.72235 7.43602 9.61827 7.3664C9.51419 7.29677 9.38715 7.24434 9.24731 7.21323L8.78803 7.11375C8.75267 7.10543 8.72195 7.09 8.70035 7.06958C8.67859 7.04915 8.66699 7.02475 8.66699 6.99974C8.66699 6.97472 8.67859 6.95032 8.70035 6.9299C8.72195 6.90946 8.75267 6.89403 8.78803 6.88572L9.24731 6.78623C9.38523 6.7545 9.51027 6.70192 9.61267 6.63262C9.71499 6.56334 9.79195 6.47918 9.83747 6.38678L9.98675 6.08068C9.99923 6.05708 10.0224 6.03666 10.053 6.02222C10.0836 6.00777 10.1203 6 10.1578 6C10.1953 6 10.232 6.00777 10.2626 6.02222C10.2932 6.03666 10.3164 6.05708 10.3288 6.08068L10.4781 6.38678C10.5245 6.47983 10.6029 6.5644 10.7069 6.63375C10.811 6.70308 10.9379 6.7553 11.0775 6.78623Z" fill="url(#paint10_linear_2092_1770)"/> +</g> +</g> +</g> +<defs> +<linearGradient id="paint0_linear_2092_1770" x1="0.5" y1="5.3313" x2="9.4888" y2="19.7133" gradientUnits="userSpaceOnUse"> +<stop stop-color="#004695"/> +<stop offset="1" stop-color="#0078D4"/> +</linearGradient> +<linearGradient id="paint1_linear_2092_1770" x1="0.5" y1="3" x2="9.4888" y2="17.382" gradientUnits="userSpaceOnUse"> +<stop stop-color="#0078D4"/> +<stop offset="1" stop-color="#0FAFFF"/> +</linearGradient> +<linearGradient id="paint2_linear_2092_1770" x1="0.9" y1="0.66565" x2="9.8888" y2="15.0477" gradientUnits="userSpaceOnUse"> +<stop stop-color="#3BD5FF"/> +<stop offset="1" stop-color="#0FAFFF"/> +</linearGradient> +<radialGradient id="paint3_radial_2092_1770" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(7.5 5.33231) rotate(90) scale(1 0.97124)"> +<stop stop-color="#00204D"/> +<stop offset="1" stop-color="#00204D" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint4_radial_2092_1770" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(8.5 4.99899) rotate(-14.0362) scale(1.37437 0.380635)"> +<stop stop-color="#00204D"/> +<stop offset="1" stop-color="#00204D" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint5_radial_2092_1770" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(6.5 4.99899) rotate(-165.964) scale(1.37437 0.384775)"> +<stop stop-color="#00204D"/> +<stop offset="1" stop-color="#00204D" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint6_radial_2092_1770" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(9.5 7.33231) rotate(-153.435) scale(0.745357 0.359002)"> +<stop stop-color="#00204D"/> +<stop offset="1" stop-color="#00204D" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint7_radial_2092_1770" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(10.8334 7.33231) rotate(-26.565) scale(0.745357 0.290223)"> +<stop stop-color="#00204D"/> +<stop offset="1" stop-color="#00204D" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint8_radial_2092_1770" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(10.1666 7.66565) rotate(90) scale(0.666666 0.627526)"> +<stop offset="0.0638343" stop-color="#00204D"/> +<stop offset="1" stop-color="#00204D" stop-opacity="0"/> +</radialGradient> +<linearGradient id="paint9_linear_2092_1770" x1="6.43217" y1="3.09882" x2="8.32475" y2="8.55931" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="#DFFAFF"/> +</linearGradient> +<linearGradient id="paint10_linear_2092_1770" x1="6.43248" y1="3.09983" x2="8.32506" y2="8.56032" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="#DFFAFF"/> +</linearGradient> +<clipPath id="clip0_2092_1770"> +<rect width="16" height="16" fill="white" transform="translate(0.5)"/> +</clipPath> +</defs> +</svg> diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseCrosshairs.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseCrosshairs.png index ae940629b0..dea5a249b5 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseCrosshairs.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseCrosshairs.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png index 7e9a2da1a8..69ed506e99 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseHighlighter.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png index 15568110cc..14d5e71d53 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseJump.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png index 83d1fbc553..99a8f64ed4 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png and b/src/settings-ui/Settings.UI/Assets/Settings/Icons/MouseWithoutBorders.png differ 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/APDialog.dark.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/APDialog.dark.png deleted file mode 100644 index fc4d7a0292..0000000000 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Modules/APDialog.dark.png and /dev/null differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/APDialog.light.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/APDialog.light.png deleted file mode 100644 index 8bc91ca89a..0000000000 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Modules/APDialog.light.png and /dev/null differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal.png index 70512fdbe1..834de00437 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal.png and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/CmdPal.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/LightSwitch.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/LightSwitch.png new file mode 100644 index 0000000000..3a98b7f3e2 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/LightSwitch.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/CmdPal.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/CmdPal.png index 17d7a300a8..5bae6296af 100644 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/CmdPal.png and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/CmdPal.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/LightSwitch.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/LightSwitch.png new file mode 100644 index 0000000000..1532531a86 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/LightSwitch.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/PTHeroShort.png b/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/PTHeroShort.png deleted file mode 100644 index 085b113599..0000000000 Binary files a/src/settings-ui/Settings.UI/Assets/Settings/Modules/OOBE/PTHeroShort.png and /dev/null 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/Assets/Settings/Scripts/EnableModule.ps1 b/src/settings-ui/Settings.UI/Assets/Settings/Scripts/EnableModule.ps1 index a2943e4d4e..af6632f98e 100644 --- a/src/settings-ui/Settings.UI/Assets/Settings/Scripts/EnableModule.ps1 +++ b/src/settings-ui/Settings.UI/Assets/Settings/Scripts/EnableModule.ps1 @@ -4,10 +4,17 @@ Param( [string]$scriptPath ) -Write-Host "Enabling experimental feature: PSFeedbackProvider" -Enable-ExperimentalFeature PSFeedbackProvider -Write-Host "Enabling experimental feature: PSCommandNotFoundSuggestion" -Enable-ExperimentalFeature PSCommandNotFoundSuggestion +$experimentalFeatures = Get-ExperimentalFeature; +if ($experimentalFeatures.Name -contains "PSFeedbackProvider") +{ + Write-Host "Enabling experimental feature: PSFeedbackProvider" + Enable-ExperimentalFeature PSFeedbackProvider +} +if ($experimentalFeatures.Name -contains "PSCommandNotFoundSuggestion") +{ + Write-Host "Enabling experimental feature: PSCommandNotFoundSuggestion" + Enable-ExperimentalFeature PSCommandNotFoundSuggestion +} $wingetModules = Get-Module -ListAvailable -Name Microsoft.WinGet.Client if ($wingetModules) { diff --git a/src/settings-ui/Settings.UI/Converters/BoolToConflictTypeConverter.cs b/src/settings-ui/Settings.UI/Converters/BoolToConflictTypeConverter.cs new file mode 100644 index 0000000000..826bfa19dd --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/BoolToConflictTypeConverter.cs @@ -0,0 +1,27 @@ +// 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 partial class BoolToConflictTypeConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is bool isSystemConflict) + { + return isSystemConflict ? "System Conflict" : "In-App Conflict"; + } + + return "Unknown Conflict"; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/BoolToKeyVisualStateConverter.cs b/src/settings-ui/Settings.UI/Converters/BoolToKeyVisualStateConverter.cs new file mode 100644 index 0000000000..04a62b02c7 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/BoolToKeyVisualStateConverter.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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Controls; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class BoolToKeyVisualStateConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is bool b && parameter is string param) + { + if (b && param == "Warning") + { + return State.Warning; + } + else if (b && param == "Error") + { + return State.Error; + } + else + { + return State.Normal; + } + } + else + { + return State.Normal; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/EnumToModuleListSortOptionConverter.cs b/src/settings-ui/Settings.UI/Converters/EnumToModuleListSortOptionConverter.cs new file mode 100644 index 0000000000..ab6eff7668 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/EnumToModuleListSortOptionConverter.cs @@ -0,0 +1,44 @@ +// 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.PowerToys.Settings.UI.Controls; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class EnumToModuleListSortOptionConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is DashboardSortOrder sortOrder) + { + return sortOrder switch + { + DashboardSortOrder.Alphabetical => ModuleListSortOption.Alphabetical, + DashboardSortOrder.ByStatus => ModuleListSortOption.ByStatus, + _ => ModuleListSortOption.Alphabetical, + }; + } + + return ModuleListSortOption.Alphabetical; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is ModuleListSortOption sortOption) + { + return sortOption switch + { + ModuleListSortOption.Alphabetical => DashboardSortOrder.Alphabetical, + ModuleListSortOption.ByStatus => DashboardSortOrder.ByStatus, + _ => DashboardSortOrder.Alphabetical, + }; + } + + return DashboardSortOrder.Alphabetical; + } + } +} 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/IconConverter.cs b/src/settings-ui/Settings.UI/Converters/IconConverter.cs new file mode 100644 index 0000000000..45ba530e02 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/IconConverter.cs @@ -0,0 +1,133 @@ +// 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.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Markup; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class IconConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string iconValue || string.IsNullOrEmpty(iconValue)) + { + // Return a default icon based on the parameter + var defaultGlyph = parameter?.ToString() ?? "\uE8B7"; // Default gear icon + return new FontIcon { Glyph = defaultGlyph }; + } + + // Check if it's a single Unicode character (most common case after JSON deserialization) + if (iconValue.Length == 1) + { + return new FontIcon { Glyph = iconValue }; + } + + // Handle HTML numeric character references, e.g. "" or "" + if (iconValue.StartsWith("&#", StringComparison.Ordinal) && iconValue.EndsWith(';')) + { + var inner = iconValue.Substring(2, iconValue.Length - 3); // strip &# and ; + try + { + string glyph; + if (inner.StartsWith("x", StringComparison.OrdinalIgnoreCase)) + { + var hex = inner.Substring(1); + if (int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out int codePointHex)) + { + glyph = char.ConvertFromUtf32(codePointHex); + return new FontIcon { Glyph = glyph }; + } + } + else if (int.TryParse(inner, out int codePointDec)) + { + glyph = char.ConvertFromUtf32(codePointDec); + return new FontIcon { Glyph = glyph }; + } + } + catch + { + // fall through to other handlers + } + } + + if (iconValue.StartsWith("\\u", StringComparison.OrdinalIgnoreCase) && iconValue.Length == 6) + { + var hexPart = iconValue.Substring(2); // Remove \u + if (int.TryParse(hexPart, System.Globalization.NumberStyles.HexNumber, null, out int codePoint)) + { + var unicodeChar = char.ConvertFromUtf32(codePoint); + return new FontIcon { Glyph = unicodeChar }; + } + } + + // Check if it's an image path + if (iconValue.Contains('/') || iconValue.Contains('\\') || iconValue.Contains(".png", StringComparison.OrdinalIgnoreCase) || iconValue.Contains(".jpg", StringComparison.OrdinalIgnoreCase) || iconValue.Contains(".ico", StringComparison.OrdinalIgnoreCase) || iconValue.Contains(".svg", StringComparison.OrdinalIgnoreCase)) + { + // Handle different path formats + var imagePath = iconValue; + + // Convert ms-appx:/// paths to local paths + if (imagePath.StartsWith("ms-appx:///", StringComparison.OrdinalIgnoreCase)) + { + imagePath = imagePath.Substring("ms-appx:///".Length); + } + + // Ensure path starts with / + if (!imagePath.StartsWith('/')) + { + imagePath = "/" + imagePath; + } + + var uri = new Uri($"ms-appx://{imagePath}"); + + if (imagePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase)) + { + // Render SVG using ImageIcon + SvgImageSource + return new ImageIcon + { + Source = new SvgImageSource(uri), + }; + } + else + { + return new BitmapIcon + { + UriSource = uri, + ShowAsMonochrome = false, + }; + } + } + + // Try to interpret as raw SVG path data (PathIcon.Data) + // Many of our XAML PathIcon usages (e.g., AdvancedPastePage) provide a Data string like "M128 766q0-42 ...". + // If parsing succeeds, render it as a PathIcon. + try + { + var geometryObj = XamlBindingHelper.ConvertValue(typeof(Geometry), iconValue); + if (geometryObj is Geometry geometry) + { + return new PathIcon { Data = geometry }; + } + } + catch + { + // Ignore parse errors and fall back below. + } + + // If all else fails, return default icon + var fallbackGlyph = parameter?.ToString() ?? "\uE8B7"; + return new FontIcon { Glyph = fallbackGlyph }; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/KeyVisualTemplateSelector.cs b/src/settings-ui/Settings.UI/Converters/KeyVisualTemplateSelector.cs deleted file mode 100644 index 43e993912a..0000000000 --- a/src/settings-ui/Settings.UI/Converters/KeyVisualTemplateSelector.cs +++ /dev/null @@ -1,23 +0,0 @@ -// 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.PowerToys.Settings.UI.Library; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; - -namespace Microsoft.PowerToys.Settings.UI.Converters -{ - internal sealed partial class KeyVisualTemplateSelector : DataTemplateSelector - { - public DataTemplate KeyVisualTemplate { get; set; } - - public DataTemplate CommaTemplate { get; set; } - - protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) - { - var stringValue = item as string; - return stringValue == KeysDataModel.CommaSeparator ? CommaTemplate : KeyVisualTemplate; - } - } -} diff --git a/src/settings-ui/Settings.UI/Converters/ModuleItemTemplateSelector.cs b/src/settings-ui/Settings.UI/Converters/ModuleItemTemplateSelector.cs index 1374c16482..bfc05b5deb 100644 --- a/src/settings-ui/Settings.UI/Converters/ModuleItemTemplateSelector.cs +++ b/src/settings-ui/Settings.UI/Converters/ModuleItemTemplateSelector.cs @@ -10,23 +10,17 @@ namespace Microsoft.PowerToys.Settings.UI.Converters { public partial class ModuleItemTemplateSelector : DataTemplateSelector { - public DataTemplate TextTemplate { get; set; } - - public DataTemplate ButtonTemplate { get; set; } - public DataTemplate ShortcutTemplate { get; set; } - public DataTemplate KBMTemplate { get; set; } + public DataTemplate ActivationTemplate { get; set; } protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) { switch (item) { - case DashboardModuleButtonItem: return ButtonTemplate; case DashboardModuleShortcutItem: return ShortcutTemplate; - case DashboardModuleTextItem: return TextTemplate; - case DashboardModuleKBMItem: return KBMTemplate; - default: return TextTemplate; + case DashboardModuleActivationItem: return ActivationTemplate; + default: return ActivationTemplate; } } } diff --git a/src/settings-ui/Settings.UI/Converters/SearchSuggestionTemplateSelector.cs b/src/settings-ui/Settings.UI/Converters/SearchSuggestionTemplateSelector.cs new file mode 100644 index 0000000000..11320baaea --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/SearchSuggestionTemplateSelector.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public sealed partial class SearchSuggestionTemplateSelector : DataTemplateSelector +{ + public DataTemplate DefaultSuggestionTemplate { get; set; } + + public DataTemplate NoResultsSuggestionTemplate { get; set; } + + public DataTemplate ShowAllSuggestionTemplate { get; set; } + + protected override DataTemplate SelectTemplateCore(object item, DependencyObject container) + { + if (item is SuggestionItem suggestionItem) + { + if (suggestionItem.IsNoResults) + { + return NoResultsSuggestionTemplate; + } + + if (suggestionItem.IsShowAll) + { + return ShowAllSuggestionTemplate ?? NoResultsSuggestionTemplate ?? DefaultSuggestionTemplate; + } + + return DefaultSuggestionTemplate; + } + + return DefaultSuggestionTemplate; + } +} diff --git a/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs new file mode 100644 index 0000000000..7d632906c2 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/ServiceTypeToIconConverter.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public partial class ServiceTypeToIconConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is not string serviceType || string.IsNullOrWhiteSpace(serviceType)) + { + return new ImageIcon { Source = new SvgImageSource(new Uri(AIServiceTypeRegistry.GetIconPath(AIServiceType.OpenAI))) }; + } + + var iconPath = AIServiceTypeRegistry.GetIconPath(serviceType); + return new ImageIcon { Source = new SvgImageSource(new Uri(iconPath)) }; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotImplementedException(); + } +} diff --git a/src/settings-ui/Settings.UI/Converters/StringToDouble.cs b/src/settings-ui/Settings.UI/Converters/StringToDouble.cs new file mode 100644 index 0000000000..fae0618467 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/StringToDouble.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public partial class StringToDoubleConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is string s && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double result)) + { + return result; + } + + return 0.0; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is double d) + { + return d.ToString(CultureInfo.InvariantCulture); + } + + return "0"; + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/TimeSpanToFriendlyTimeConverter.cs b/src/settings-ui/Settings.UI/Converters/TimeSpanToFriendlyTimeConverter.cs new file mode 100644 index 0000000000..496c96959b --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/TimeSpanToFriendlyTimeConverter.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml.Data; + +namespace Microsoft.PowerToys.Settings.UI.Converters; + +public sealed partial class TimeSpanToFriendlyTimeConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is TimeSpan time) + { + return TimeSpanHelper.Convert(time); + } + + return string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) => new NotImplementedException(); +} diff --git a/src/settings-ui/Settings.UI/Converters/UriToImageConverter.cs b/src/settings-ui/Settings.UI/Converters/UriToImageConverter.cs new file mode 100644 index 0000000000..dc079f5d01 --- /dev/null +++ b/src/settings-ui/Settings.UI/Converters/UriToImageConverter.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Converters +{ + public sealed partial class UriToImageSourceConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + return value is Uri uri ? new BitmapImage(uri) : null; + } + + 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/HotkeyConflictHelper.cs b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictHelper.cs new file mode 100644 index 0000000000..d7f56fceea --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictHelper.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; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + public class HotkeyConflictHelper + { + public delegate void HotkeyConflictCheckCallback(bool hasConflict, HotkeyConflictResponse conflicts); + + private static readonly Dictionary<string, HotkeyConflictCheckCallback> PendingHotkeyConflictChecks = new Dictionary<string, HotkeyConflictCheckCallback>(); + private static readonly object LockObject = new object(); + + public static void CheckHotkeyConflict(HotkeySettings hotkeySettings, Func<string, int> ipcMSGCallBackFunc, HotkeyConflictCheckCallback callback) + { + if (hotkeySettings == null || ipcMSGCallBackFunc == null) + { + return; + } + + string requestId = GenerateRequestId(); + + lock (LockObject) + { + PendingHotkeyConflictChecks[requestId] = callback; + } + + var hotkeyObj = new JsonObject + { + ["request_id"] = requestId, + ["win"] = hotkeySettings.Win, + ["ctrl"] = hotkeySettings.Ctrl, + ["shift"] = hotkeySettings.Shift, + ["alt"] = hotkeySettings.Alt, + ["key"] = hotkeySettings.Code, + }; + + var requestObject = new JsonObject + { + ["check_hotkey_conflict"] = hotkeyObj, + }; + + ipcMSGCallBackFunc(requestObject.ToString()); + } + + public static void HandleHotkeyConflictResponse(HotkeyConflictResponse response) + { + if (response.AllConflicts.Count == 0) + { + return; + } + + HotkeyConflictCheckCallback callback = null; + + lock (LockObject) + { + if (PendingHotkeyConflictChecks.TryGetValue(response.RequestId, out callback)) + { + PendingHotkeyConflictChecks.Remove(response.RequestId); + } + } + + callback?.Invoke(response.HasConflict, response); + } + + private static string GenerateRequestId() => Guid.NewGuid().ToString(); + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/HotkeyConflictIgnoreHelper.cs b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictIgnoreHelper.cs new file mode 100644 index 0000000000..59018bf74b --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictIgnoreHelper.cs @@ -0,0 +1,229 @@ +// 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 ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Views; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + /// <summary> + /// Static helper class to manage and check hotkey conflict ignore settings + /// </summary> + public static class HotkeyConflictIgnoreHelper + { + private static readonly ISettingsRepository<GeneralSettings> _generalSettingsRepository; + private static readonly SettingsUtils _settingsUtils; + + static HotkeyConflictIgnoreHelper() + { + _settingsUtils = SettingsUtils.Default; + _generalSettingsRepository = SettingsRepository<GeneralSettings>.GetInstance(_settingsUtils); + } + + /// <summary> + /// Ensures ignored conflict properties are initialized + /// </summary> + private static void EnsureInitialized() + { + var settings = _generalSettingsRepository.SettingsConfig; + if (settings.IgnoredConflictProperties == null) + { + settings.IgnoredConflictProperties = new ShortcutConflictProperties(); + SaveSettings(); + } + } + + /// <summary> + /// Checks if a specific hotkey setting is configured to ignore conflicts + /// </summary> + /// <param name="hotkeySettings">The hotkey settings to check</param> + /// <returns>True if the hotkey is set to ignore conflicts, false otherwise</returns> + public static bool IsIgnoringConflicts(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return false; + } + + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + return settings.IgnoredConflictProperties.IgnoredShortcuts + .Any(h => AreHotkeySettingsEqual(h, hotkeySettings)); + } + catch (Exception ex) + { + Logger.LogError($"Error checking if hotkey is ignoring conflicts: {ex.Message}"); + return false; + } + } + + /// <summary> + /// Adds a hotkey setting to the ignored shortcuts list + /// </summary> + /// <param name="hotkeySettings">The hotkey settings to add to the ignored list</param> + /// <returns>True if successfully added, false if it was already ignored or on error</returns> + public static bool AddToIgnoredList(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return false; + } + + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + + // Check if already ignored (avoid duplicates) + if (IsIgnoringConflicts(hotkeySettings)) + { + Logger.LogInfo($"Hotkey already in ignored list: {hotkeySettings}"); + return false; + } + + // Add to ignored list + settings.IgnoredConflictProperties.IgnoredShortcuts.Add(hotkeySettings); + SaveSettings(); + + Logger.LogInfo($"Added hotkey to ignored list: {hotkeySettings}"); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Error adding hotkey to ignored list: {ex.Message}"); + return false; + } + } + + /// <summary> + /// Removes a hotkey setting from the ignored shortcuts list + /// </summary> + /// <param name="hotkeySettings">The hotkey settings to remove from the ignored list</param> + /// <returns>True if successfully removed, false if it wasn't in the list or on error</returns> + public static bool RemoveFromIgnoredList(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return false; + } + + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + var ignoredShortcut = settings.IgnoredConflictProperties.IgnoredShortcuts + .FirstOrDefault(h => AreHotkeySettingsEqual(h, hotkeySettings)); + + if (ignoredShortcut != null) + { + settings.IgnoredConflictProperties.IgnoredShortcuts.Remove(ignoredShortcut); + SaveSettings(); + + Logger.LogInfo($"Removed hotkey from ignored list: {ignoredShortcut}"); + return true; + } + + Logger.LogInfo($"Hotkey not found in ignored list: {hotkeySettings}"); + return false; + } + catch (Exception ex) + { + Logger.LogError($"Error removing hotkey from ignored list: {ex.Message}"); + return false; + } + } + + /// <summary> + /// Gets all hotkey settings that are currently being ignored + /// </summary> + /// <returns>List of ignored hotkey settings</returns> + public static List<HotkeySettings> GetAllIgnoredShortcuts() + { + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + return new List<HotkeySettings>(settings.IgnoredConflictProperties.IgnoredShortcuts); + } + catch (Exception ex) + { + Logger.LogError($"Error getting ignored shortcuts: {ex.Message}"); + return new List<HotkeySettings>(); + } + } + + /// <summary> + /// Clears all ignored shortcuts from the list + /// </summary> + /// <returns>True if successfully cleared, false on error</returns> + public static bool ClearAllIgnoredShortcuts() + { + try + { + EnsureInitialized(); + var settings = _generalSettingsRepository.SettingsConfig; + var count = settings.IgnoredConflictProperties.IgnoredShortcuts.Count; + settings.IgnoredConflictProperties.IgnoredShortcuts.Clear(); + SaveSettings(); + + Logger.LogInfo($"Cleared all {count} ignored shortcuts"); + return true; + } + catch (Exception ex) + { + Logger.LogError($"Error clearing ignored shortcuts: {ex.Message}"); + return false; + } + } + + /// <summary> + /// Compares two HotkeySettings for equality + /// </summary> + /// <param name="hotkey1">First hotkey settings</param> + /// <param name="hotkey2">Second hotkey settings</param> + /// <returns>True if they represent the same shortcut, false otherwise</returns> + private static bool AreHotkeySettingsEqual(HotkeySettings hotkey1, HotkeySettings hotkey2) + { + if (hotkey1 == null || hotkey2 == null) + { + return false; + } + + return hotkey1.Win == hotkey2.Win && + hotkey1.Ctrl == hotkey2.Ctrl && + hotkey1.Alt == hotkey2.Alt && + hotkey1.Shift == hotkey2.Shift && + hotkey1.Code == hotkey2.Code; + } + + /// <summary> + /// Saves the general settings using PowerToys standard settings persistence + /// </summary> + private static void SaveSettings() + { + try + { + var settings = _generalSettingsRepository.SettingsConfig; + + // Send IPC message to notify runner of changes (this is thread-safe) + var outgoing = new OutGoingGeneralSettings(settings); + ShellPage.SendDefaultIPCMessage(outgoing.ToString()); + ShellPage.ShellHandler?.SignalGeneralDataUpdate(); + } + catch (Exception ex) + { + Logger.LogError($"Error saving shortcut conflict settings: {ex.Message}"); + Logger.LogError($"Stack trace: {ex.StackTrace}"); + throw; + } + } + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/HotkeyConflictResponse.cs b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictResponse.cs new file mode 100644 index 0000000000..90803df64c --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/HotkeyConflictResponse.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + public class HotkeyConflictResponse + { + public string RequestId { get; set; } + + public bool HasConflict { get; set; } + + public List<ModuleHotkeyData> AllConflicts { get; set; } = new List<ModuleHotkeyData>(); + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs new file mode 100644 index 0000000000..18a17937dc --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/ModuleGpoHelper.cs @@ -0,0 +1,93 @@ +// 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 global::PowerToys.GPOWrapper; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Views; +using Windows.UI; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + internal sealed class ModuleGpoHelper + { + public static GpoRuleConfigured GetModuleGpoConfiguration(ModuleType moduleType) + { + switch (moduleType) + { + case ModuleType.AdvancedPaste: return GPOWrapper.GetConfiguredAdvancedPasteEnabledValue(); + case ModuleType.AlwaysOnTop: return GPOWrapper.GetConfiguredAlwaysOnTopEnabledValue(); + case ModuleType.Awake: return GPOWrapper.GetConfiguredAwakeEnabledValue(); + case ModuleType.CmdPal: return GPOWrapper.GetConfiguredCmdPalEnabledValue(); + case ModuleType.ColorPicker: return GPOWrapper.GetConfiguredColorPickerEnabledValue(); + case ModuleType.CropAndLock: return GPOWrapper.GetConfiguredCropAndLockEnabledValue(); + case ModuleType.CursorWrap: return GPOWrapper.GetConfiguredCursorWrapEnabledValue(); + case ModuleType.EnvironmentVariables: return GPOWrapper.GetConfiguredEnvironmentVariablesEnabledValue(); + case ModuleType.FancyZones: return GPOWrapper.GetConfiguredFancyZonesEnabledValue(); + case ModuleType.FileLocksmith: return GPOWrapper.GetConfiguredFileLocksmithEnabledValue(); + case ModuleType.FindMyMouse: return GPOWrapper.GetConfiguredFindMyMouseEnabledValue(); + case ModuleType.Hosts: return GPOWrapper.GetConfiguredHostsFileEditorEnabledValue(); + case ModuleType.ImageResizer: return GPOWrapper.GetConfiguredImageResizerEnabledValue(); + case ModuleType.KeyboardManager: return GPOWrapper.GetConfiguredKeyboardManagerEnabledValue(); + case ModuleType.MouseHighlighter: return GPOWrapper.GetConfiguredMouseHighlighterEnabledValue(); + case ModuleType.MouseJump: return GPOWrapper.GetConfiguredMouseJumpEnabledValue(); + case ModuleType.MousePointerCrosshairs: return GPOWrapper.GetConfiguredMousePointerCrosshairsEnabledValue(); + case ModuleType.MouseWithoutBorders: return GPOWrapper.GetConfiguredMouseWithoutBordersEnabledValue(); + case ModuleType.NewPlus: return GPOWrapper.GetConfiguredNewPlusEnabledValue(); + case ModuleType.Peek: return GPOWrapper.GetConfiguredPeekEnabledValue(); + case ModuleType.PowerRename: return GPOWrapper.GetConfiguredPowerRenameEnabledValue(); + case ModuleType.PowerLauncher: return GPOWrapper.GetConfiguredPowerLauncherEnabledValue(); + case ModuleType.PowerAccent: return GPOWrapper.GetConfiguredQuickAccentEnabledValue(); + case ModuleType.Workspaces: return GPOWrapper.GetConfiguredWorkspacesEnabledValue(); + case ModuleType.RegistryPreview: return GPOWrapper.GetConfiguredRegistryPreviewEnabledValue(); + 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; + } + } + + public static System.Type GetModulePageType(ModuleType moduleType) + { + return moduleType switch + { + ModuleType.AdvancedPaste => typeof(AdvancedPastePage), + ModuleType.AlwaysOnTop => typeof(AlwaysOnTopPage), + ModuleType.Awake => typeof(AwakePage), + ModuleType.CmdPal => typeof(CmdPalPage), + ModuleType.ColorPicker => typeof(ColorPickerPage), + ModuleType.CropAndLock => typeof(CropAndLockPage), + ModuleType.CursorWrap => typeof(MouseUtilsPage), + ModuleType.LightSwitch => typeof(LightSwitchPage), + ModuleType.EnvironmentVariables => typeof(EnvironmentVariablesPage), + ModuleType.FancyZones => typeof(FancyZonesPage), + ModuleType.FileLocksmith => typeof(FileLocksmithPage), + ModuleType.FindMyMouse => typeof(MouseUtilsPage), + ModuleType.GeneralSettings => typeof(GeneralPage), + ModuleType.Hosts => typeof(HostsPage), + ModuleType.ImageResizer => typeof(ImageResizerPage), + ModuleType.KeyboardManager => typeof(KeyboardManagerPage), + ModuleType.MouseHighlighter => typeof(MouseUtilsPage), + ModuleType.MouseJump => typeof(MouseUtilsPage), + ModuleType.MousePointerCrosshairs => typeof(MouseUtilsPage), + ModuleType.MouseWithoutBorders => typeof(MouseWithoutBordersPage), + ModuleType.NewPlus => typeof(NewPlusPage), + ModuleType.Peek => typeof(PeekPage), + ModuleType.PowerRename => typeof(PowerRenamePage), + ModuleType.PowerLauncher => typeof(PowerLauncherPage), + ModuleType.PowerAccent => typeof(PowerAccentPage), + ModuleType.Workspaces => typeof(WorkspacesPage), + ModuleType.RegistryPreview => typeof(RegistryPreviewPage), + 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/Helpers/ModuleHelper.cs b/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs deleted file mode 100644 index f21ca2bfac..0000000000 --- a/src/settings-ui/Settings.UI/Helpers/ModuleHelper.cs +++ /dev/null @@ -1,188 +0,0 @@ -// 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 global::PowerToys.GPOWrapper; -using ManagedCommon; -using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.Views; -using Windows.UI; - -namespace Microsoft.PowerToys.Settings.UI.Helpers -{ - internal sealed class ModuleHelper - { - public static string GetModuleLabelResourceName(ModuleType moduleType) - { - switch (moduleType) - { - case ModuleType.Workspaces: return "Workspaces/ModuleTitle"; - case ModuleType.PowerAccent: return "QuickAccent/ModuleTitle"; - case ModuleType.PowerOCR: return "TextExtractor/ModuleTitle"; - case ModuleType.FindMyMouse: - case ModuleType.MouseHighlighter: - case ModuleType.MouseJump: - case ModuleType.MousePointerCrosshairs: return $"MouseUtils_{moduleType}/Header"; - default: return $"{moduleType}/ModuleTitle"; - } - } - - public static string GetModuleTypeFluentIconName(ModuleType moduleType) - { - switch (moduleType) - { - case ModuleType.AdvancedPaste: return "ms-appx:///Assets/Settings/Icons/AdvancedPaste.png"; - case ModuleType.Workspaces: return "ms-appx:///Assets/Settings/Icons/Workspaces.png"; - case ModuleType.PowerOCR: return "ms-appx:///Assets/Settings/Icons/TextExtractor.png"; - case ModuleType.PowerAccent: return "ms-appx:///Assets/Settings/Icons/QuickAccent.png"; - case ModuleType.MousePointerCrosshairs: return "ms-appx:///Assets/Settings/Icons/MouseCrosshairs.png"; - case ModuleType.MeasureTool: return "ms-appx:///Assets/Settings/Icons/ScreenRuler.png"; - case ModuleType.PowerLauncher: return $"ms-appx:///Assets/Settings/Icons/PowerToysRun.png"; - default: return $"ms-appx:///Assets/Settings/Icons/{moduleType}.png"; - } - } - - public static bool GetIsModuleEnabled(Library.GeneralSettings generalSettingsConfig, ModuleType moduleType) - { - switch (moduleType) - { - case ModuleType.AdvancedPaste: return generalSettingsConfig.Enabled.AdvancedPaste; - case ModuleType.AlwaysOnTop: return generalSettingsConfig.Enabled.AlwaysOnTop; - case ModuleType.Awake: return generalSettingsConfig.Enabled.Awake; - case ModuleType.CmdPal: return generalSettingsConfig.Enabled.CmdPal; - case ModuleType.ColorPicker: return generalSettingsConfig.Enabled.ColorPicker; - case ModuleType.CropAndLock: return generalSettingsConfig.Enabled.CropAndLock; - case ModuleType.EnvironmentVariables: return generalSettingsConfig.Enabled.EnvironmentVariables; - case ModuleType.FancyZones: return generalSettingsConfig.Enabled.FancyZones; - case ModuleType.FileLocksmith: return generalSettingsConfig.Enabled.FileLocksmith; - case ModuleType.FindMyMouse: return generalSettingsConfig.Enabled.FindMyMouse; - case ModuleType.Hosts: return generalSettingsConfig.Enabled.Hosts; - case ModuleType.ImageResizer: return generalSettingsConfig.Enabled.ImageResizer; - case ModuleType.KeyboardManager: return generalSettingsConfig.Enabled.KeyboardManager; - case ModuleType.MouseHighlighter: return generalSettingsConfig.Enabled.MouseHighlighter; - case ModuleType.MouseJump: return generalSettingsConfig.Enabled.MouseJump; - case ModuleType.MousePointerCrosshairs: return generalSettingsConfig.Enabled.MousePointerCrosshairs; - case ModuleType.MouseWithoutBorders: return generalSettingsConfig.Enabled.MouseWithoutBorders; - case ModuleType.NewPlus: return generalSettingsConfig.Enabled.NewPlus; - case ModuleType.Peek: return generalSettingsConfig.Enabled.Peek; - case ModuleType.PowerRename: return generalSettingsConfig.Enabled.PowerRename; - case ModuleType.PowerLauncher: return generalSettingsConfig.Enabled.PowerLauncher; - case ModuleType.PowerAccent: return generalSettingsConfig.Enabled.PowerAccent; - case ModuleType.Workspaces: return generalSettingsConfig.Enabled.Workspaces; - case ModuleType.RegistryPreview: return generalSettingsConfig.Enabled.RegistryPreview; - case ModuleType.MeasureTool: return generalSettingsConfig.Enabled.MeasureTool; - case ModuleType.ShortcutGuide: return generalSettingsConfig.Enabled.ShortcutGuide; - case ModuleType.PowerOCR: return generalSettingsConfig.Enabled.PowerOcr; - case ModuleType.ZoomIt: return generalSettingsConfig.Enabled.ZoomIt; - default: return false; - } - } - - internal static void SetIsModuleEnabled(GeneralSettings generalSettingsConfig, ModuleType moduleType, bool isEnabled) - { - switch (moduleType) - { - case ModuleType.AdvancedPaste: generalSettingsConfig.Enabled.AdvancedPaste = isEnabled; break; - case ModuleType.AlwaysOnTop: generalSettingsConfig.Enabled.AlwaysOnTop = isEnabled; break; - case ModuleType.Awake: generalSettingsConfig.Enabled.Awake = isEnabled; break; - case ModuleType.CmdPal: generalSettingsConfig.Enabled.CmdPal = isEnabled; break; - case ModuleType.ColorPicker: generalSettingsConfig.Enabled.ColorPicker = isEnabled; break; - case ModuleType.CropAndLock: generalSettingsConfig.Enabled.CropAndLock = isEnabled; break; - case ModuleType.EnvironmentVariables: generalSettingsConfig.Enabled.EnvironmentVariables = isEnabled; break; - case ModuleType.FancyZones: generalSettingsConfig.Enabled.FancyZones = isEnabled; break; - case ModuleType.FileLocksmith: generalSettingsConfig.Enabled.FileLocksmith = isEnabled; break; - case ModuleType.FindMyMouse: generalSettingsConfig.Enabled.FindMyMouse = isEnabled; break; - case ModuleType.Hosts: generalSettingsConfig.Enabled.Hosts = isEnabled; break; - case ModuleType.ImageResizer: generalSettingsConfig.Enabled.ImageResizer = isEnabled; break; - case ModuleType.KeyboardManager: generalSettingsConfig.Enabled.KeyboardManager = isEnabled; break; - case ModuleType.MouseHighlighter: generalSettingsConfig.Enabled.MouseHighlighter = isEnabled; break; - case ModuleType.MouseJump: generalSettingsConfig.Enabled.MouseJump = isEnabled; break; - case ModuleType.MousePointerCrosshairs: generalSettingsConfig.Enabled.MousePointerCrosshairs = isEnabled; break; - case ModuleType.MouseWithoutBorders: generalSettingsConfig.Enabled.MouseWithoutBorders = isEnabled; break; - case ModuleType.NewPlus: generalSettingsConfig.Enabled.NewPlus = isEnabled; break; - case ModuleType.Peek: generalSettingsConfig.Enabled.Peek = isEnabled; break; - case ModuleType.PowerRename: generalSettingsConfig.Enabled.PowerRename = isEnabled; break; - case ModuleType.PowerLauncher: generalSettingsConfig.Enabled.PowerLauncher = isEnabled; break; - case ModuleType.PowerAccent: generalSettingsConfig.Enabled.PowerAccent = isEnabled; break; - case ModuleType.Workspaces: generalSettingsConfig.Enabled.Workspaces = isEnabled; break; - case ModuleType.RegistryPreview: generalSettingsConfig.Enabled.RegistryPreview = isEnabled; break; - 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.ZoomIt: generalSettingsConfig.Enabled.ZoomIt = isEnabled; break; - } - } - - public static GpoRuleConfigured GetModuleGpoConfiguration(ModuleType moduleType) - { - switch (moduleType) - { - case ModuleType.AdvancedPaste: return GPOWrapper.GetConfiguredAdvancedPasteEnabledValue(); - case ModuleType.AlwaysOnTop: return GPOWrapper.GetConfiguredAlwaysOnTopEnabledValue(); - case ModuleType.Awake: return GPOWrapper.GetConfiguredAwakeEnabledValue(); - case ModuleType.CmdPal: return GPOWrapper.GetConfiguredCmdPalEnabledValue(); - case ModuleType.ColorPicker: return GPOWrapper.GetConfiguredColorPickerEnabledValue(); - case ModuleType.CropAndLock: return GPOWrapper.GetConfiguredCropAndLockEnabledValue(); - case ModuleType.EnvironmentVariables: return GPOWrapper.GetConfiguredEnvironmentVariablesEnabledValue(); - case ModuleType.FancyZones: return GPOWrapper.GetConfiguredFancyZonesEnabledValue(); - case ModuleType.FileLocksmith: return GPOWrapper.GetConfiguredFileLocksmithEnabledValue(); - case ModuleType.FindMyMouse: return GPOWrapper.GetConfiguredFindMyMouseEnabledValue(); - case ModuleType.Hosts: return GPOWrapper.GetConfiguredHostsFileEditorEnabledValue(); - case ModuleType.ImageResizer: return GPOWrapper.GetConfiguredImageResizerEnabledValue(); - case ModuleType.KeyboardManager: return GPOWrapper.GetConfiguredKeyboardManagerEnabledValue(); - case ModuleType.MouseHighlighter: return GPOWrapper.GetConfiguredMouseHighlighterEnabledValue(); - case ModuleType.MouseJump: return GPOWrapper.GetConfiguredMouseJumpEnabledValue(); - case ModuleType.MousePointerCrosshairs: return GPOWrapper.GetConfiguredMousePointerCrosshairsEnabledValue(); - case ModuleType.MouseWithoutBorders: return GPOWrapper.GetConfiguredMouseWithoutBordersEnabledValue(); - case ModuleType.NewPlus: return GPOWrapper.GetConfiguredNewPlusEnabledValue(); - case ModuleType.Peek: return GPOWrapper.GetConfiguredPeekEnabledValue(); - case ModuleType.PowerRename: return GPOWrapper.GetConfiguredPowerRenameEnabledValue(); - case ModuleType.PowerLauncher: return GPOWrapper.GetConfiguredPowerLauncherEnabledValue(); - case ModuleType.PowerAccent: return GPOWrapper.GetConfiguredQuickAccentEnabledValue(); - case ModuleType.Workspaces: return GPOWrapper.GetConfiguredWorkspacesEnabledValue(); - case ModuleType.RegistryPreview: return GPOWrapper.GetConfiguredRegistryPreviewEnabledValue(); - case ModuleType.MeasureTool: return GPOWrapper.GetConfiguredScreenRulerEnabledValue(); - case ModuleType.ShortcutGuide: return GPOWrapper.GetConfiguredShortcutGuideEnabledValue(); - case ModuleType.PowerOCR: return GPOWrapper.GetConfiguredTextExtractorEnabledValue(); - case ModuleType.ZoomIt: return GPOWrapper.GetConfiguredZoomItEnabledValue(); - default: return GpoRuleConfigured.Unavailable; - } - } - - public static System.Type GetModulePageType(ModuleType moduleType) - { - return moduleType switch - { - ModuleType.AdvancedPaste => typeof(AdvancedPastePage), - ModuleType.AlwaysOnTop => typeof(AlwaysOnTopPage), - ModuleType.Awake => typeof(AwakePage), - ModuleType.CmdPal => typeof(CmdPalPage), - ModuleType.ColorPicker => typeof(ColorPickerPage), - ModuleType.CropAndLock => typeof(CropAndLockPage), - ModuleType.EnvironmentVariables => typeof(EnvironmentVariablesPage), - ModuleType.FancyZones => typeof(FancyZonesPage), - ModuleType.FileLocksmith => typeof(FileLocksmithPage), - ModuleType.FindMyMouse => typeof(MouseUtilsPage), - ModuleType.Hosts => typeof(HostsPage), - ModuleType.ImageResizer => typeof(ImageResizerPage), - ModuleType.KeyboardManager => typeof(KeyboardManagerPage), - ModuleType.MouseHighlighter => typeof(MouseUtilsPage), - ModuleType.MouseJump => typeof(MouseUtilsPage), - ModuleType.MousePointerCrosshairs => typeof(MouseUtilsPage), - ModuleType.MouseWithoutBorders => typeof(MouseWithoutBordersPage), - ModuleType.NewPlus => typeof(NewPlusPage), - ModuleType.Peek => typeof(PeekPage), - ModuleType.PowerRename => typeof(PowerRenamePage), - ModuleType.PowerLauncher => typeof(PowerLauncherPage), - ModuleType.PowerAccent => typeof(PowerAccentPage), - ModuleType.Workspaces => typeof(WorkspacesPage), - ModuleType.RegistryPreview => typeof(RegistryPreviewPage), - ModuleType.MeasureTool => typeof(MeasureToolPage), - ModuleType.ShortcutGuide => typeof(ShortcutGuidePage), - ModuleType.PowerOCR => typeof(PowerOcrPage), - ModuleType.ZoomIt => typeof(ZoomItPage), - _ => typeof(DashboardPage), // never called, all values listed above - }; - } - } -} diff --git a/src/settings-ui/Settings.UI/Helpers/NativeMethods.cs b/src/settings-ui/Settings.UI/Helpers/NativeMethods.cs index 072f12b00c..35132b2049 100644 --- a/src/settings-ui/Settings.UI/Helpers/NativeMethods.cs +++ b/src/settings-ui/Settings.UI/Helpers/NativeMethods.cs @@ -17,6 +17,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers internal const int SW_SHOWNORMAL = 1; internal const int SW_SHOWMAXIMIZED = 3; internal const int SW_HIDE = 0; + internal const int WM_COMMAND = 0x0111; // https://learn.microsoft.com/en-us/windows/win32/menurc/wm-command [DllImport("user32.dll")] internal static extern IntPtr GetActiveWindow(); @@ -48,6 +49,12 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers [DllImport("Comdlg32.dll", CharSet = CharSet.Unicode)] internal static extern bool GetOpenFileName([In, Out] OpenFileName openFileName); + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + internal static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + + [DllImport("user32.dll")] + internal static extern IntPtr SendMessage(IntPtr hWnd, IntPtr msg, UIntPtr wParam, UIntPtr lParam); + [DllImport("comdlg32.dll", CharSet = CharSet.Auto, EntryPoint = "ChooseFont", SetLastError = true)] internal static extern bool ChooseFont(IntPtr lpChooseFont); diff --git a/src/settings-ui/Settings.UI/Helpers/NavigablePage.cs b/src/settings-ui/Settings.UI/Helpers/NavigablePage.cs new file mode 100644 index 0000000000..51c89875ab --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/NavigablePage.cs @@ -0,0 +1,244 @@ +// 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.Numerics; +using System.Threading.Tasks; +using CommunityToolkit.WinUI.Controls; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace Microsoft.PowerToys.Settings.UI.Helpers; + +public abstract partial class NavigablePage : Page +{ + private const int ExpandWaitDuration = 500; + private const int AnimationDuration = 1850; + + private NavigationParams _pendingNavigationParams; + + public NavigablePage() + { + Loaded += OnPageLoaded; + } + + protected override void OnNavigatedTo(Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) + { + base.OnNavigatedTo(e); + + // Handle both old string parameter and new NavigationParams + if (e.Parameter is NavigationParams navParams) + { + _pendingNavigationParams = navParams; + } + else if (e.Parameter is string elementKey) + { + _pendingNavigationParams = new NavigationParams(elementKey); + } + } + + private async void OnPageLoaded(object sender, RoutedEventArgs e) + { + if (_pendingNavigationParams != null && !string.IsNullOrEmpty(_pendingNavigationParams.ElementName)) + { + // First, expand parent if specified + if (!string.IsNullOrEmpty(_pendingNavigationParams.ParentElementName)) + { + var parentElement = FindElementByName(_pendingNavigationParams.ParentElementName); + if (parentElement is SettingsExpander expander) + { + expander.IsExpanded = true; + + // Give time for the expander to animate + await Task.Delay(ExpandWaitDuration); + } + } + + // Then find and navigate to the target element + var target = FindElementByName(_pendingNavigationParams.ElementName); + + target?.StartBringIntoView(new BringIntoViewOptions + { + VerticalOffset = -20, + AnimationDesired = true, + }); + + await OnTargetElementNavigatedAsync(target, _pendingNavigationParams.ElementName); + + _pendingNavigationParams = null; + } + } + + protected virtual async Task OnTargetElementNavigatedAsync(FrameworkElement target, string elementKey) + { + if (target == null) + { + return; + } + + // Attempt to set keyboard focus so that screen readers announce the element and keyboard users land directly on it. + TrySetFocus(target); + + // Get the visual and compositor + var visual = ElementCompositionPreview.GetElementVisual(target); + var compositor = visual.Compositor; + + // Create a subtle glow effect using drop shadow + var dropShadow = compositor.CreateDropShadow(); + dropShadow.Color = (Color)Application.Current.Resources["SystemAccentColorLight2"]; + dropShadow.BlurRadius = 16f; + dropShadow.Opacity = 0f; + dropShadow.Offset = new Vector3(0, 0, 0); + + var spriteVisual = compositor.CreateSpriteVisual(); + spriteVisual.Size = new Vector2((float)target.ActualWidth + 8, (float)target.ActualHeight + 8); + spriteVisual.Shadow = dropShadow; + spriteVisual.Offset = new Vector3(-4, -4, 0); + + // Insert the shadow visual behind the target element + ElementCompositionPreview.SetElementChildVisual(target, spriteVisual); + + // Create a simple fade in/out animation + var fadeAnimation = compositor.CreateScalarKeyFrameAnimation(); + fadeAnimation.InsertKeyFrame(0f, 0f); + fadeAnimation.InsertKeyFrame(0.5f, 0.3f); + fadeAnimation.InsertKeyFrame(1f, 0f); + fadeAnimation.Duration = TimeSpan.FromMilliseconds(AnimationDuration); + + dropShadow.StartAnimation("Opacity", fadeAnimation); + await Task.Delay(AnimationDuration); + + // Clean up the shadow visual + ElementCompositionPreview.SetElementChildVisual(target, null); + } + + private static void TrySetFocus(FrameworkElement target) + { + try + { + // Prefer Control.Focus when available. + if (target is Control ctrl) + { + // Ensure it can receive focus. + if (!ctrl.IsTabStop) + { + ctrl.IsTabStop = true; + } + + ctrl.Focus(FocusState.Programmatic); + } + + // Target is not a Control. Find first focusable descendant Control. + var focusCandidate = FindFirstFocusableDescendant(target); + if (focusCandidate != null) + { + if (!focusCandidate.IsTabStop) + { + focusCandidate.IsTabStop = true; + } + + focusCandidate.Focus(FocusState.Programmatic); + return; + } + + // Fallback: attempt to focus parent control if no descendant found. + if (target.Parent is Control parent) + { + if (!parent.IsTabStop) + { + parent.IsTabStop = true; + } + + parent.Focus(FocusState.Programmatic); + } + } + catch + { + // Swallow focus exceptions; not critical. Could log if logging enabled. + // Leave the default focus as it is. + } + } + + private static Control FindFirstFocusableDescendant(FrameworkElement root) + { + if (root == null) + { + return null; + } + + var queue = new System.Collections.Generic.Queue<DependencyObject>(); + queue.Enqueue(root); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current is Control c && c.IsEnabled && c.Visibility == Visibility.Visible) + { + return c; + } + + int count = VisualTreeHelper.GetChildrenCount(current); + for (int i = 0; i < count; i++) + { + queue.Enqueue(VisualTreeHelper.GetChild(current, i)); + } + } + + return null; + } + + protected FrameworkElement FindElementByName(string name) + { + var element = this.FindName(name) as FrameworkElement; + if (element != null) + { + return element; + } + + if (this.Content is DependencyObject root) + { + var found = FindInDescendants(root, name); + if (found != null) + { + return found; + } + } + + return null; + } + + private static FrameworkElement FindInDescendants(DependencyObject root, string name) + { + if (root == null || string.IsNullOrEmpty(name)) + { + return null; + } + + var queue = new System.Collections.Generic.Queue<DependencyObject>(); + queue.Enqueue(root); + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current is FrameworkElement fe) + { + var local = fe.FindName(name) as FrameworkElement; + if (local != null) + { + return local; + } + } + + int count = VisualTreeHelper.GetChildrenCount(current); + for (int i = 0; i < count; i++) + { + var child = VisualTreeHelper.GetChild(current, i); + queue.Enqueue(child); + } + } + + return null; + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/NavigationParams.cs b/src/settings-ui/Settings.UI/Helpers/NavigationParams.cs new file mode 100644 index 0000000000..30c27343ce --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/NavigationParams.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. + +namespace Microsoft.PowerToys.Settings.UI.Helpers; + +public class NavigationParams +{ + public string ElementName { get; set; } + + public string ParentElementName { get; set; } + + public NavigationParams(string elementName, string parentElementName = null) + { + ElementName = elementName; + ParentElementName = parentElementName; + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs b/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs index ce172b2aa2..05c5d7f66c 100644 --- a/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs +++ b/src/settings-ui/Settings.UI/Helpers/StartProcessHelper.cs @@ -11,6 +11,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers { public const string ColorsSettings = "ms-settings:colors"; public const string DiagnosticsAndFeedback = "ms-settings:privacy-feedback"; + public const string NightLightSettings = "ms-settings:nightlight"; public static string AnimationsSettings => OSVersionHelper.IsWindows11() ? "ms-settings:easeofaccess-visualeffects" diff --git a/src/settings-ui/Settings.UI/Helpers/StoreExtensionHelper.cs b/src/settings-ui/Settings.UI/Helpers/StoreExtensionHelper.cs new file mode 100644 index 0000000000..5dd82c2ce1 --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/StoreExtensionHelper.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using ManagedCommon; +using Windows.Management.Deployment; +using Windows.System; + +namespace Microsoft.PowerToys.Settings.UI.Helpers +{ + /// <summary> + /// Helper class to manage installation status and installation command for a Microsoft Store extension. + /// </summary> + public class StoreExtensionHelper : INotifyPropertyChanged + { + private readonly string _packageFamilyName; + private readonly string _storeUri; + private readonly string _extensionName; + private bool? _isInstalled; + + public event PropertyChangedEventHandler PropertyChanged; + + public StoreExtensionHelper(string packageFamilyName, string storeUri, string extensionName) + { + _packageFamilyName = packageFamilyName ?? throw new ArgumentNullException(nameof(packageFamilyName)); + _storeUri = storeUri ?? throw new ArgumentNullException(nameof(storeUri)); + _extensionName = extensionName ?? throw new ArgumentNullException(nameof(extensionName)); + InstallCommand = new AsyncCommand(InstallExtensionAsync); + } + + /// <summary> + /// Gets a value indicating whether the extension is installed. + /// </summary> + public bool IsInstalled + { + get + { + if (!_isInstalled.HasValue) + { + _isInstalled = CheckExtensionInstalled(); + } + + return _isInstalled.Value; + } + } + + /// <summary> + /// Gets the command to install the extension. + /// </summary> + public ICommand InstallCommand { get; } + + /// <summary> + /// Refreshes the installation status of the extension. + /// </summary> + public void RefreshStatus() + { + _isInstalled = null; + OnPropertyChanged(nameof(IsInstalled)); + } + + private bool CheckExtensionInstalled() + { + try + { + var packageManager = new PackageManager(); + var packages = packageManager.FindPackagesForUser(string.Empty, _packageFamilyName); + return packages.Any(); + } + catch (Exception ex) + { + Logger.LogError($"Failed to check extension installation status: {_packageFamilyName}", ex); + return false; + } + } + + private async Task InstallExtensionAsync() + { + try + { + await Launcher.LaunchUriAsync(new Uri(_storeUri)); + } + catch (Exception ex) + { + Logger.LogError($"Failed to open {_extensionName} extension store page", ex); + } + } + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI/Helpers/TimeSpanHelper.cs b/src/settings-ui/Settings.UI/Helpers/TimeSpanHelper.cs new file mode 100644 index 0000000000..95308ec67e --- /dev/null +++ b/src/settings-ui/Settings.UI/Helpers/TimeSpanHelper.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Microsoft.PowerToys.Settings.UI.Helpers; + +public static class TimeSpanHelper +{ + public static string Convert(TimeSpan? time) + { + if (time is not TimeSpan ts) + { + return string.Empty; + } + + // If user passed in a negative TimeSpan, normalize + if (ts < TimeSpan.Zero) + { + ts = ts.Duration(); + } + + // Map the TimeSpan to a DateTime on today's date + var dt = DateTime.Today.Add(ts); + + // This pattern automatically respects system 12/24-hour setting + string pattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern; + + return dt.ToString(pattern, CultureInfo.CurrentCulture); + } +} diff --git a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs index fbf689b9de..93c1cc3e07 100644 --- a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs +++ b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs @@ -20,6 +20,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Enums FileExplorer, ImageResizer, KBM, + LightSwitch, MouseUtils, MouseWithoutBorders, Peek, @@ -31,8 +32,8 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Enums MeasureTool, Hosts, Workspaces, - WhatsNew, RegistryPreview, + PowerDisplay, NewPlus, ZoomIt, } diff --git a/src/settings-ui/Settings.UI/OOBE/ViewModel/OobeShellViewModel.cs b/src/settings-ui/Settings.UI/OOBE/ViewModel/OobeShellViewModel.cs index 6159b58690..a9aa7f9d15 100644 --- a/src/settings-ui/Settings.UI/OOBE/ViewModel/OobeShellViewModel.cs +++ b/src/settings-ui/Settings.UI/OOBE/ViewModel/OobeShellViewModel.cs @@ -2,12 +2,73 @@ // 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.ObjectModel; +using System.Linq; +using Microsoft.PowerToys.Settings.UI.OOBE.Enums; + namespace Microsoft.PowerToys.Settings.UI.OOBE.ViewModel { public class OobeShellViewModel { + public ObservableCollection<OobePowerToysModule> Modules { get; } = new(); + public OobeShellViewModel() { + Modules = new ObservableCollection<OobePowerToysModule>( + new (PowerToysModules Module, bool IsNew)[] + { + (PowerToysModules.Overview, false), + (PowerToysModules.AdvancedPaste, false), + (PowerToysModules.AlwaysOnTop, false), + (PowerToysModules.Awake, false), + (PowerToysModules.CmdNotFound, false), + (PowerToysModules.CmdPal, false), + (PowerToysModules.ColorPicker, false), + (PowerToysModules.CropAndLock, false), + (PowerToysModules.EnvironmentVariables, false), + (PowerToysModules.FancyZones, false), + (PowerToysModules.FileLocksmith, false), + (PowerToysModules.FileExplorer, false), + (PowerToysModules.ImageResizer, false), + (PowerToysModules.KBM, false), + (PowerToysModules.LightSwitch, false), + (PowerToysModules.MouseUtils, false), + (PowerToysModules.MouseWithoutBorders, false), + (PowerToysModules.Peek, false), + (PowerToysModules.PowerDisplay, true), + (PowerToysModules.PowerRename, false), + (PowerToysModules.Run, false), + (PowerToysModules.QuickAccent, false), + (PowerToysModules.ShortcutGuide, false), + (PowerToysModules.TextExtractor, false), + (PowerToysModules.MeasureTool, false), + (PowerToysModules.Hosts, false), + (PowerToysModules.Workspaces, false), + (PowerToysModules.RegistryPreview, false), + (PowerToysModules.NewPlus, false), + (PowerToysModules.ZoomIt, false), + } + .Select(x => new OobePowerToysModule + { + ModuleName = x.Module.ToString(), + IsNew = x.IsNew, + })); + } + + public OobePowerToysModule GetModule(PowerToysModules module) + { + return Modules.First(m => m.ModuleName == module.ToString()); + } + + public OobePowerToysModule GetModuleFromTag(string tag) + { + if (!Enum.TryParse<PowerToysModules>(tag, ignoreCase: true, out var module)) + { + throw new ArgumentException($"Invalid module tag: {tag}", nameof(tag)); + } + + return GetModule(module); } } } diff --git a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj index 9d71b62de7..c1f15281c7 100644 --- a/src/settings-ui/Settings.UI/PowerToys.Settings.csproj +++ b/src/settings-ui/Settings.UI/PowerToys.Settings.csproj @@ -1,144 +1,219 @@ -<Project Sdk="Microsoft.NET.Sdk"> - <!-- Look at Directory.Build.props in root for common stuff as well --> - <Import Project="..\..\Common.Dotnet.CsWinRT.props" /> - <Import Project="..\..\Common.SelfContained.props" /> +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + <Import Project="$(RepoRoot)src\Common.SelfContained.props" /> - <PropertyGroup> - <OutputType>WinExe</OutputType> - <RootNamespace>Microsoft.PowerToys.Settings.UI</RootNamespace> - <ApplicationManifest>app.manifest</ApplicationManifest> - <UseWinUI>true</UseWinUI> - <EnablePreviewMsixTooling>true</EnablePreviewMsixTooling> - <WindowsPackageType>None</WindowsPackageType> - <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> - <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> - <ApplicationIcon>Assets\Settings\icon.ico</ApplicationIcon> - <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> - <!-- OutputPath looks like this because it has to be called both by settings and publish.cmd --> - <OutputPath>..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> - <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> - <ProjectPriFileName>PowerToys.Settings.pri</ProjectPriFileName> - </PropertyGroup> - <ItemGroup> - <None Remove="Assets\Settings\Modules\APDialog.dark.png" /> - <None Remove="Assets\Settings\Modules\APDialog.light.png" /> - </ItemGroup> - <ItemGroup> - <Page Remove="SettingsXAML\App.xaml" /> - </ItemGroup> - <ItemGroup> - <ApplicationDefinition Include="SettingsXAML\App.xaml" /> - </ItemGroup> + <PropertyGroup> + <OutputType>WinExe</OutputType> + <RootNamespace>Microsoft.PowerToys.Settings.UI</RootNamespace> + <ApplicationManifest>app.manifest</ApplicationManifest> + <UseWinUI>true</UseWinUI> + <EnablePreviewMsixTooling>true</EnablePreviewMsixTooling> + <WindowsPackageType>None</WindowsPackageType> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> + <ApplicationIcon>Assets\Settings\icon.ico</ApplicationIcon> + <WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained> + <!-- OutputPath looks like this because it has to be called both by settings and publish.cmd --> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath> + <!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri --> + <ProjectPriFileName>PowerToys.Settings.pri</ProjectPriFileName> + </PropertyGroup> + <ItemGroup> + <None Remove="Assets\Settings\Icons\Models\Azure.svg" /> + <None Remove="Assets\Settings\Icons\Models\FoundryLocal.svg" /> + <None Remove="Assets\Settings\Icons\Models\Onnx.svg" /> + <None Remove="Assets\Settings\Icons\Models\OpenAI.dark.svg" /> + <None Remove="Assets\Settings\Icons\Models\OpenAI.light.svg" /> + <None Remove="Assets\Settings\Icons\Models\WindowsML.svg" /> + <None Remove="Assets\Settings\Modules\APDialog.dark.png" /> + <None Remove="Assets\Settings\Modules\APDialog.light.png" /> + <None Remove="Assets\Settings\Modules\LightSwitch.png" /> + <None Remove="SettingsXAML\Controls\Dashboard\CheckUpdateControl.xaml" /> + <None Remove="SettingsXAML\Controls\Dashboard\ShortcutConflictControl.xaml" /> + <None Remove="SettingsXAML\Controls\KeyVisual\KeyCharPresenter.xaml" /> + <None Remove="SettingsXAML\Controls\TitleBar\TitleBar.xaml" /> + </ItemGroup> + <ItemGroup> + <Page Remove="SettingsXAML\App.xaml" /> + </ItemGroup> + <ItemGroup> + <ApplicationDefinition Include="SettingsXAML\App.xaml" /> + </ItemGroup> - <!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info --> - <PropertyGroup> - <CsWinRTIncludes>PowerToys.GPOWrapper;PowerToys.ZoomItSettingsInterop</CsWinRTIncludes> - <CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir> - <ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles> - </PropertyGroup> + <!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info --> + <PropertyGroup> + <CsWinRTIncludes>PowerToys.GPOWrapper;PowerToys.ZoomItSettingsInterop</CsWinRTIncludes> + <CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir> + <ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles> + </PropertyGroup> - <ItemGroup> - <Content Include="Assets\Settings\SplashScreen.scale-200.png" /> - <Content Include="Assets\Settings\LockScreenLogo.scale-200.png" /> - <Content Include="Assets\Settings\Square150x150Logo.scale-200.png" /> - <Content Include="Assets\Settings\Square44x44Logo.scale-200.png" /> - <Content Include="Assets\Settings\Square44x44Logo.targetsize-24_altform-unplated.png" /> - <Content Include="Assets\Settings\StoreLogo.png" /> - <Content Include="Assets\Settings\Wide310x150Logo.scale-200.png" /> - </ItemGroup> + <ItemGroup> + <Content Include="Assets\Settings\SplashScreen.scale-200.png" /> + <Content Include="Assets\Settings\LockScreenLogo.scale-200.png" /> + <Content Include="Assets\Settings\Square150x150Logo.scale-200.png" /> + <Content Include="Assets\Settings\Square44x44Logo.scale-200.png" /> + <Content Include="Assets\Settings\Square44x44Logo.targetsize-24_altform-unplated.png" /> + <Content Include="Assets\Settings\StoreLogo.png" /> + <Content Include="Assets\Settings\Wide310x150Logo.scale-200.png" /> + </ItemGroup> - <ItemGroup> - <EmbeddedResource Include="Images\MouseJump-Desktop.png" /> - </ItemGroup> + <ItemGroup> + <!-- AI Model Provider Icons - SVG files --> + <Content Include="Assets\Settings\Icons\Models\*.svg"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> - <ItemGroup> - <PackageReference Include="CommunityToolkit.WinUI.Controls.Segmented" /> - <PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" /> - <PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" /> - <PackageReference Include="CommunityToolkit.WinUI.Animations" /> - <PackageReference Include="CommunityToolkit.WinUI.Extensions" /> - <PackageReference Include="CommunityToolkit.WinUI.Converters" /> - <PackageReference Include="CommunityToolkit.WinUI.UI.Controls.Markdown" /> - <PackageReference Include="WinUIEx" /> - <!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. --> - <PackageReference Include="MessagePack" /> - <PackageReference Include="Microsoft.WindowsAppSDK" /> - <PackageReference Include="Microsoft.Windows.SDK.BuildTools" /> - <PackageReference Include="Microsoft.Xaml.Behaviors.WinUI.Managed" /> - <PackageReference Include="StreamJsonRpc" /> - <!-- HACK: Microsoft.Extensions.Hosting is referenced, even if it is not used, to force dll versions to be the same as in other projects. Really only needed since the Experimentation APIs that are added in CI reference some net standard 2.0 assemblies. --> - <PackageReference Include="Microsoft.Extensions.Hosting" /> - <!-- HACK: To make sure the version pulled in by Microsoft.Extensions.Hosting is current. --> - <PackageReference Include="System.Text.Json" /> - <!-- This line forces the WebView2 version used by Windows App SDK to be the one we expect from Directory.Packages.props . --> - <PackageReference Include="Microsoft.Web.WebView2" /> - <!-- HACK: CmdPal uses CommunityToolkit.Common directly. Align the version. --> - <PackageReference Include="CommunityToolkit.Common" /> - <Manifest Include="$(ApplicationManifest)" /> - </ItemGroup> - <!-- Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging - Tools extension to be activated for this project even if the Windows App SDK Nuget - package has not yet been restored --> + <ItemGroup> + <EmbeddedResource Include="Images\MouseJump-Desktop.png" /> + </ItemGroup> - <ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnablePreviewMsixTooling)'=='true'"> - <ProjectCapability Include="Msix" /> - </ItemGroup> + <ItemGroup> + <PackageReference Include="CommunityToolkit.Labs.WinUI.Controls.OpacityMaskView" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.Segmented" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.SettingsControls" /> + <PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" /> + <PackageReference Include="CommunityToolkit.WinUI.Animations" /> + <PackageReference Include="CommunityToolkit.WinUI.Extensions" /> + <PackageReference Include="CommunityToolkit.WinUI.Converters" /> + <PackageReference Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> + <PackageReference Include="WinUIEx" /> + <!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. --> + <PackageReference Include="MessagePack" /> + <PackageReference Include="Microsoft.WindowsAppSDK" /> + <PackageReference Include="Microsoft.Windows.SDK.BuildTools" /> + <PackageReference Include="Microsoft.Xaml.Behaviors.WinUI.Managed" /> + <PackageReference Include="StreamJsonRpc" /> + <!-- HACK: Microsoft.Extensions.Hosting is referenced, even if it is not used, to force dll versions to be the same as in other projects. Really only needed since the Experimentation APIs that are added in CI reference some net standard 2.0 assemblies. --> + <PackageReference Include="Microsoft.Extensions.Hosting" /> + <!-- HACK: To make sure the version pulled in by Microsoft.Extensions.Hosting is current. --> + <PackageReference Include="System.Text.Json" /> + <!-- This line forces the WebView2 version used by Windows App SDK to be the one we expect from Directory.Packages.props . --> + <PackageReference Include="Microsoft.Web.WebView2" /> + <!-- HACK: CmdPal uses CommunityToolkit.Common directly. Align the version. --> + <PackageReference Include="CommunityToolkit.Common" /> + <!-- HACK: MWB and Advanced Paste. Align the version. got flagged when https://github.com/microsoft/PowerToys/pull/38779 was done --> + <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" /> + <Manifest Include="$(ApplicationManifest)" /> + </ItemGroup> + <!-- Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging + Tools extension to be activated for this project even if the Windows App SDK Nuget + package has not yet been restored --> - <ItemGroup> - <!-- HACK: Common.UI is referenced, even if it is not used, to force dll versions to be the same as in other projects that use it. It's still unclear why this is the case, but this is need for flattening the install directory. --> - <ProjectReference Include="..\..\common\Common.UI\Common.UI.csproj" /> - <ProjectReference Include="..\..\common\AllExperiments\AllExperiments.csproj" /> - <ProjectReference Include="..\..\common\GPOWrapper\GPOWrapper.vcxproj" /> - <ProjectReference Include="..\..\common\interop\PowerToys.Interop.vcxproj" /> - <ProjectReference Include="..\..\modules\ZoomIt\ZoomItSettingsInterop\ZoomItSettingsInterop.vcxproj" /> - <ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" /> - <ProjectReference Include="..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> - <ProjectReference Include="..\..\modules\MouseUtils\MouseJump.Common\MouseJump.Common.csproj" /> - <ProjectReference Include="..\Settings.UI.Library\Settings.UI.Library.csproj" /> - </ItemGroup> + <ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnablePreviewMsixTooling)'=='true'"> + <ProjectCapability Include="Msix" /> + </ItemGroup> - <PropertyGroup> - <!-- TODO: fix issues and reenable --> - <!-- These are caused by streamjsonrpc dependency on Microsoft.VisualStudio.Threading.Analyzers --> - <!-- We might want to add that to the project and fix the issues as well --> - <NoWarn>VSTHRD002;VSTHRD110;VSTHRD100;VSTHRD200;VSTHRD101</NoWarn> - </PropertyGroup> + <ItemGroup> + <!-- HACK: Common.UI is referenced, even if it is not used, to force dll versions to be the same as in other projects that use it. It's still unclear why this is the case, but this is need for flattening the install directory. --> + <ProjectReference Include="..\..\common\Common.Search\Common.Search.csproj" /> + <ProjectReference Include="..\..\common\Common.UI\Common.UI.csproj" /> + <ProjectReference Include="..\..\common\GPOWrapper\GPOWrapper.vcxproj" /> + <ProjectReference Include="..\..\common\interop\PowerToys.Interop.vcxproj" /> + <ProjectReference Include="..\..\modules\ZoomIt\ZoomItSettingsInterop\ZoomItSettingsInterop.vcxproj" /> + <ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" /> + <ProjectReference Include="..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" /> + <ProjectReference Include="..\..\common\LanguageModelProvider\LanguageModelProvider.csproj" /> + <ProjectReference Include="..\..\modules\MouseUtils\MouseJump.Common\MouseJump.Common.csproj" /> + <ProjectReference Include="..\..\modules\powerdisplay\PowerDisplay.Lib\PowerDisplay.Lib.csproj" /> + <ProjectReference Include="..\Settings.UI.Library\Settings.UI.Library.csproj" /> + <ProjectReference Include="..\Settings.UI.Controls\Settings.UI.Controls.csproj" /> + </ItemGroup> - <ItemGroup> - <None Update="Assets\Settings\icon.ico"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - </ItemGroup> + <!-- XamlIndexBuilder now outputs directly to Assets\Settings --> + <PropertyGroup> + <GeneratedJsonFile>$(MSBuildProjectDirectory)\Assets\Settings\search.index.json</GeneratedJsonFile> + </PropertyGroup> - <ItemGroup> - <None Update="Assets\Settings\Scripts\CheckCmdNotFoundRequirements.ps1"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - <None Update="Assets\Settings\Scripts\InstallWinGetClientModule.ps1"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - <None Update="Assets\Settings\Scripts\InstallPowerShell7.ps1"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - <None Update="Assets\Settings\Scripts\EnableModule.ps1"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - <None Update="Assets\Settings\Scripts\DisableModule.ps1"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - </ItemGroup> + <!-- No RID/Platform plumbing needed here. XamlIndexBuilder handles generation after its own Build. --> - <ItemGroup> - <Page Update="SettingsXAML\OOBE\Views\OobeWorkspaces.xaml"> - <XamlRuntime>$(DefaultXamlRuntime)</XamlRuntime> + <PropertyGroup> + <!-- TODO: fix issues and reenable --> + <!-- These are caused by streamjsonrpc dependency on Microsoft.VisualStudio.Threading.Analyzers --> + <!-- We might want to add that to the project and fix the issues as well --> + <NoWarn>VSTHRD002;VSTHRD110;VSTHRD100;VSTHRD200;VSTHRD101</NoWarn> + </PropertyGroup> + + <!-- Removed hard-coded resource exclusion. --> + + <ItemGroup> + <!-- Ensure the generated search index is present in the project; only if it exists to prevent CS1566 in clean CI --> + <Content Include="$(GeneratedJsonFile)" Condition="Exists('$(GeneratedJsonFile)')"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + <!-- Embed the generated search index; logical name must match PrebuiltIndexResourceName --> + <EmbeddedResource Include="$(GeneratedJsonFile)" Condition="Exists('$(GeneratedJsonFile)')"> + <LogicalName>Microsoft.PowerToys.Settings.UI.Assets.search.index.json</LogicalName> + </EmbeddedResource> + </ItemGroup> + + <ItemGroup> + <None Update="Assets\Settings\icon.ico"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + </ItemGroup> + + <ItemGroup> + <None Update="Assets\Settings\Scripts\CheckCmdNotFoundRequirements.ps1"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + <None Update="Assets\Settings\Scripts\InstallWinGetClientModule.ps1"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + <None Update="Assets\Settings\Scripts\InstallPowerShell7.ps1"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + <None Update="Assets\Settings\Scripts\EnableModule.ps1"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + <None Update="Assets\Settings\Scripts\DisableModule.ps1"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + <Page Update="SettingsXAML\Controls\ShortcutControl\ShortcutWithTextLabelControl.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + <Page Update="SettingsXAML\Controls\TitleBar\TitleBar.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + <Page Update="SettingsXAML\Controls\KeyVisual\KeyCharPresenter.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + <Page Update="SettingsXAML\Controls\GPOInfoControl.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + <Page Update="SettingsXAML\Controls\Dashboard\ShortcutConflictControl.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + <Page Update="SettingsXAML\Controls\Dashboard\CheckUpdateControl.xaml"> + <Generator>MSBuild:Compile</Generator> + </Page> + <Page Update="SettingsXAML\Controls\Timeline\TimelineStyles.xaml"> + <Generator>MSBuild:Compile</Generator> </Page> - <Page Update="SettingsXAML\Panels\MouseJumpPanel.xaml"> - <XamlRuntime>$(DefaultXamlRuntime)</XamlRuntime> + <Page Update="SettingsXAML\Controls\Timeline\Timeline.xaml"> + <Generator>MSBuild:Compile</Generator> </Page> - <Page Update="SettingsXAML\Views\WorkspacesPage.xaml"> - <XamlRuntime>$(DefaultXamlRuntime)</XamlRuntime> - </Page> - </ItemGroup> + </ItemGroup> -</Project> + <ItemGroup> + <Page Update="SettingsXAML\OOBE\Views\OobeWorkspaces.xaml"> + <XamlRuntime>$(DefaultXamlRuntime)</XamlRuntime> + </Page> + <Page Update="SettingsXAML\Panels\MouseJumpPanel.xaml"> + <XamlRuntime>$(DefaultXamlRuntime)</XamlRuntime> + </Page> + <Page Update="SettingsXAML\Views\WorkspacesPage.xaml"> + <XamlRuntime>$(DefaultXamlRuntime)</XamlRuntime> + </Page> + </ItemGroup> + + <Target Name="BuildXamlIndexBeforeSettings" BeforeTargets="CoreCompile" Condition="'$(DesignTimeBuild)' != 'true'"> + <Message Importance="high" Text="[Settings] Building XamlIndexBuilder prior to compile. Views='$(MSBuildProjectDirectory)\SettingsXAML\Views' Out='$(GeneratedJsonFile)'" /> + <MSBuild Projects="..\Settings.UI.XamlIndexBuilder\Settings.UI.XamlIndexBuilder.csproj" Targets="Build" Properties="Configuration=$(Configuration);Platform=Any CPU;TargetFramework=net9.0;XamlViewsDir=$(MSBuildProjectDirectory)\SettingsXAML\Views;GeneratedJsonFile=$(GeneratedJsonFile)" /> + </Target> +</Project> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs index 8fd948fd86..36fd08ecd2 100644 --- a/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs +++ b/src/settings-ui/Settings.UI/SerializationContext/SourceGenerationContextContext.cs @@ -10,26 +10,41 @@ using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; +using SettingsUILibrary = Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.SerializationContext; -[JsonSerializable(typeof(WINDOWPLACEMENT))] +[JsonSerializable(typeof(ActionMessage))] [JsonSerializable(typeof(AdvancedPasteSettings))] -[JsonSerializable(typeof(Dictionary<string, List<string>>))] [JsonSerializable(typeof(AlwaysOnTopSettings))] [JsonSerializable(typeof(ColorPickerSettings))] [JsonSerializable(typeof(CropAndLockSettings))] +[JsonSerializable(typeof(CursorWrapSettings))] +[JsonSerializable(typeof(Dictionary<string, List<string>>))] [JsonSerializable(typeof(FileLocksmithSettings))] +[JsonSerializable(typeof(FindMyMouseSettings))] +[JsonSerializable(typeof(IList<PowerToysReleaseInfo>))] +[JsonSerializable(typeof(LightSwitchSettings))] [JsonSerializable(typeof(MeasureToolSettings))] +[JsonSerializable(typeof(MouseHighlighterSettings))] +[JsonSerializable(typeof(MouseJumpSettings))] +[JsonSerializable(typeof(MousePointerCrosshairsSettings))] [JsonSerializable(typeof(MouseWithoutBordersSettings))] [JsonSerializable(typeof(NewPlusSettings))] [JsonSerializable(typeof(PeekSettings))] [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(IList<PowerToysReleaseInfo>))] -[JsonSerializable(typeof(ActionMessage))] +[JsonSerializable(typeof(ZoomItSettings))] +[JsonSerializable(typeof(PasteAIConfiguration))] +[JsonSerializable(typeof(PasteAIProviderDefinition))] +[JsonSerializable(typeof(System.Collections.ObjectModel.ObservableCollection<PasteAIProviderDefinition>))] public sealed partial class SourceGenerationContextContext : JsonSerializerContext { } diff --git a/src/settings-ui/Settings.UI/Services/ActivationService.cs b/src/settings-ui/Settings.UI/Services/ActivationService.cs deleted file mode 100644 index 86ad2e4d7c..0000000000 --- a/src/settings-ui/Settings.UI/Services/ActivationService.cs +++ /dev/null @@ -1,106 +0,0 @@ -// 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 Microsoft.PowerToys.Settings.UI.Activation; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Windows.ApplicationModel.Activation; - -namespace Microsoft.PowerToys.Settings.UI.Services -{ - // For more information on understanding and extending activation flow see - // https://github.com/Microsoft/WindowsTemplateStudio/blob/master/docs/activation.md - internal sealed class ActivationService - { - private readonly App app; - private readonly Type defaultNavItem; - private Lazy<UIElement> shell; - - private object lastActivationArgs; - - public ActivationService(App app, Type defaultNavItem, Lazy<UIElement> shell = null) - { - this.app = app; - this.shell = shell; - this.defaultNavItem = defaultNavItem; - } - - public async Task ActivateAsync(object activationArgs) - { - if (IsInteractive(activationArgs)) - { - // Initialize services that you need before app activation - // take into account that the splash screen is shown while this code runs. - await InitializeAsync().ConfigureAwait(false); - - // Do not repeat app initialization when the Window already has content, - // just ensure that the window is active - if (Window.Current.Content == null) - { - // Create a Shell or Frame to act as the navigation context - Window.Current.Content = shell?.Value ?? new Frame(); - } - } - - // Depending on activationArgs one of ActivationHandlers or DefaultActivationHandler - // will navigate to the first page - await HandleActivationAsync(activationArgs).ConfigureAwait(false); - lastActivationArgs = activationArgs; - - if (IsInteractive(activationArgs)) - { - // Ensure the current window is active - Window.Current.Activate(); - - // Tasks after activation - await StartupAsync().ConfigureAwait(false); - } - } - - private static async Task InitializeAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - } - - private async Task HandleActivationAsync(object activationArgs) - { - var activationHandler = GetActivationHandlers() - .FirstOrDefault(h => h.CanHandle(activationArgs)); - - if (activationHandler != null) - { - await activationHandler.HandleAsync(activationArgs).ConfigureAwait(false); - } - - if (IsInteractive(activationArgs)) - { - var defaultHandler = new DefaultActivationHandler(defaultNavItem); - if (defaultHandler.CanHandle(activationArgs)) - { - await defaultHandler.HandleAsync(activationArgs).ConfigureAwait(false); - } - } - } - - private static async Task StartupAsync() - { - await Task.CompletedTask.ConfigureAwait(false); - } - - private static IEnumerable<ActivationHandler> GetActivationHandlers() - { - yield break; - } - - private static bool IsInteractive(object args) - { - return args is IActivatedEventArgs; - } - } -} diff --git a/src/settings-ui/Settings.UI/Services/GlobalHotkeyConflictManager.cs b/src/settings-ui/Settings.UI/Services/GlobalHotkeyConflictManager.cs new file mode 100644 index 0000000000..3971c0589e --- /dev/null +++ b/src/settings-ui/Settings.UI/Services/GlobalHotkeyConflictManager.cs @@ -0,0 +1,121 @@ +// 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 Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; + +namespace Microsoft.PowerToys.Settings.UI.Services +{ + public class GlobalHotkeyConflictManager + { + private readonly Func<string, int> _sendIPCMessage; + + private static GlobalHotkeyConflictManager _instance; + private AllHotkeyConflictsData _currentConflicts = new AllHotkeyConflictsData(); + + public static GlobalHotkeyConflictManager Instance => _instance; + + public static void Initialize(Func<string, int> sendIPCMessage) + { + _instance = new GlobalHotkeyConflictManager(sendIPCMessage); + } + + private GlobalHotkeyConflictManager(Func<string, int> sendIPCMessage) + { + _sendIPCMessage = sendIPCMessage; + + IPCResponseService.AllHotkeyConflictsReceived += OnAllHotkeyConflictsReceived; + } + + public event EventHandler<AllHotkeyConflictsEventArgs> ConflictsUpdated; + + public void RequestAllConflicts() + { + var requestMessage = "{\"get_all_hotkey_conflicts\":{}}"; + _sendIPCMessage?.Invoke(requestMessage); + } + + private void OnAllHotkeyConflictsReceived(object sender, AllHotkeyConflictsEventArgs e) + { + _currentConflicts = e.Conflicts; + ConflictsUpdated?.Invoke(this, e); + } + + public bool HasConflictForHotkey(HotkeySettings hotkey, string moduleName, int hotkeyID) + { + if (hotkey == null) + { + return false; + } + + var allConflictGroups = _currentConflicts.InAppConflicts.Concat(_currentConflicts.SystemConflicts); + + foreach (var group in allConflictGroups) + { + if (IsHotkeyMatch(hotkey, group.Hotkey)) + { + if (!string.IsNullOrEmpty(moduleName) && hotkeyID >= 0) + { + var selfModule = group.Modules.FirstOrDefault(m => + m.ModuleName.Equals(moduleName, StringComparison.OrdinalIgnoreCase) && + m.HotkeyID == hotkeyID); + + if (selfModule != null && group.Modules.Count == 1) + { + return false; + } + } + + return true; + } + } + + return false; + } + + public HotkeyConflictInfo GetConflictInfo(HotkeySettings hotkey) + { + if (hotkey == null) + { + return null; + } + + var allConflictGroups = _currentConflicts.InAppConflicts.Concat(_currentConflicts.SystemConflicts); + + foreach (var group in allConflictGroups) + { + if (IsHotkeyMatch(hotkey, group.Hotkey)) + { + var conflictModules = group.Modules.Where(m => m != null).ToList(); + if (conflictModules.Count != 0) + { + var firstModule = conflictModules.First(); + return new HotkeyConflictInfo + { + IsSystemConflict = group.IsSystemConflict, + ConflictingModuleName = firstModule.ModuleName, + ConflictingHotkeyID = firstModule.HotkeyID, + AllConflictingModules = conflictModules.Select(m => $"{m.ModuleName}:{m.HotkeyID}").ToList(), + }; + } + } + } + + return null; + } + + private bool IsHotkeyMatch(HotkeySettings settings, HotkeyData data) + { + return settings.Win == data.Win && + settings.Ctrl == data.Ctrl && + settings.Shift == data.Shift && + settings.Alt == data.Alt && + settings.Code == data.Key; + } + } +} diff --git a/src/settings-ui/Settings.UI/Services/IPCResponseService.cs b/src/settings-ui/Settings.UI/Services/IPCResponseService.cs new file mode 100644 index 0000000000..ed16b43603 --- /dev/null +++ b/src/settings-ui/Settings.UI/Services/IPCResponseService.cs @@ -0,0 +1,199 @@ +// 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 Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.Views; +using Windows.Data.Json; + +namespace Microsoft.PowerToys.Settings.UI.Services +{ + public class IPCResponseService + { + private static IPCResponseService _instance; + + public static IPCResponseService Instance => _instance ??= new IPCResponseService(); + + public static event EventHandler<AllHotkeyConflictsEventArgs> AllHotkeyConflictsReceived; + + public void RegisterForIPC() + { + ShellPage.ShellHandler?.IPCResponseHandleList.Add(ProcessIPCMessage); + } + + public void UnregisterFromIPC() + { + ShellPage.ShellHandler?.IPCResponseHandleList.Remove(ProcessIPCMessage); + } + + private void ProcessIPCMessage(JsonObject json) + { + try + { + if (json.TryGetValue("response_type", out IJsonValue responseTypeValue) && + responseTypeValue.ValueType == JsonValueType.String) + { + string responseType = responseTypeValue.GetString(); + + if (responseType.Equals("hotkey_conflict_result", StringComparison.Ordinal)) + { + ProcessHotkeyConflictResult(json); + } + else if (responseType.Equals("all_hotkey_conflicts", StringComparison.Ordinal)) + { + ProcessAllHotkeyConflicts(json); + } + } + } + catch (Exception) + { + } + } + + private void ProcessHotkeyConflictResult(JsonObject json) + { + string requestId = string.Empty; + if (json.TryGetValue("request_id", out IJsonValue requestIdValue) && + requestIdValue.ValueType == JsonValueType.String) + { + requestId = requestIdValue.GetString(); + } + + bool hasConflict = false; + if (json.TryGetValue("has_conflict", out IJsonValue hasConflictValue) && + hasConflictValue.ValueType == JsonValueType.Boolean) + { + hasConflict = hasConflictValue.GetBoolean(); + } + + var allConflicts = new List<ModuleHotkeyData>(); + + if (hasConflict) + { + // Parse the all_conflicts array + if (json.TryGetValue("all_conflicts", out IJsonValue allConflictsValue) && + allConflictsValue.ValueType == JsonValueType.Array) + { + var conflictsArray = allConflictsValue.GetArray(); + foreach (var conflictItem in conflictsArray) + { + if (conflictItem.ValueType == JsonValueType.Object) + { + var conflictObj = conflictItem.GetObject(); + + string moduleName = string.Empty; + int hotkeyID = -1; + + if (conflictObj.TryGetValue("module", out IJsonValue moduleValue) && + moduleValue.ValueType == JsonValueType.String) + { + moduleName = moduleValue.GetString(); + } + + if (conflictObj.TryGetValue("hotkeyID", out IJsonValue hotkeyValue) && + hotkeyValue.ValueType == JsonValueType.Number) + { + hotkeyID = (int)hotkeyValue.GetNumber(); + } + + allConflicts.Add(new ModuleHotkeyData + { + ModuleName = moduleName, + HotkeyID = hotkeyID, + }); + } + } + } + } + + var response = new HotkeyConflictResponse + { + RequestId = requestId, + HasConflict = hasConflict, + AllConflicts = allConflicts, + }; + + HotkeyConflictHelper.HandleHotkeyConflictResponse(response); + } + + private void ProcessAllHotkeyConflicts(JsonObject json) + { + var allConflicts = new AllHotkeyConflictsData(); + + if (json.TryGetValue("inAppConflicts", out IJsonValue inAppValue) && + inAppValue.ValueType == JsonValueType.Array) + { + var inAppArray = inAppValue.GetArray(); + foreach (var conflictGroup in inAppArray) + { + var conflictObj = conflictGroup.GetObject(); + var conflictData = ParseConflictGroup(conflictObj, false); + if (conflictData != null) + { + allConflicts.InAppConflicts.Add(conflictData); + } + } + } + + if (json.TryGetValue("sysConflicts", out IJsonValue sysValue) && + sysValue.ValueType == JsonValueType.Array) + { + var sysArray = sysValue.GetArray(); + foreach (var conflictGroup in sysArray) + { + var conflictObj = conflictGroup.GetObject(); + var conflictData = ParseConflictGroup(conflictObj, true); + if (conflictData != null) + { + allConflicts.SystemConflicts.Add(conflictData); + } + } + } + + AllHotkeyConflictsReceived?.Invoke(this, new AllHotkeyConflictsEventArgs(allConflicts)); + } + + private HotkeyConflictGroupData ParseConflictGroup(JsonObject conflictObj, bool isSystemConflict) + { + if (!conflictObj.TryGetValue("hotkey", out var hotkeyValue) || + !conflictObj.TryGetValue("modules", out var modulesValue)) + { + return null; + } + + var hotkeyObj = hotkeyValue.GetObject(); + bool win = hotkeyObj.TryGetValue("win", out var winVal) && winVal.GetBoolean(); + bool ctrl = hotkeyObj.TryGetValue("ctrl", out var ctrlVal) && ctrlVal.GetBoolean(); + bool shift = hotkeyObj.TryGetValue("shift", out var shiftVal) && shiftVal.GetBoolean(); + bool alt = hotkeyObj.TryGetValue("alt", out var altVal) && altVal.GetBoolean(); + int key = hotkeyObj.TryGetValue("key", out var keyVal) ? (int)keyVal.GetNumber() : 0; + + var conflictGroup = new HotkeyConflictGroupData + { + Hotkey = new HotkeyData { Win = win, Ctrl = ctrl, Shift = shift, Alt = alt, Key = key }, + IsSystemConflict = isSystemConflict, + Modules = new List<ModuleHotkeyData>(), + }; + + var modulesArray = modulesValue.GetArray(); + foreach (var module in modulesArray) + { + var moduleObj = module.GetObject(); + string moduleName = moduleObj.TryGetValue("moduleName", out var modNameVal) ? modNameVal.GetString() : string.Empty; + int hotkeyID = moduleObj.TryGetValue("hotkeyID", out var hotkeyIDVal) ? (int)hotkeyIDVal.GetNumber() : -1; + + conflictGroup.Modules.Add(new ModuleHotkeyData + { + ModuleName = moduleName, + HotkeyID = hotkeyID, + }); + } + + return conflictGroup; + } + } +} diff --git a/src/settings-ui/Settings.UI/Services/NavigationService.cs b/src/settings-ui/Settings.UI/Services/NavigationService.cs index b70976bd01..d7c408208b 100644 --- a/src/settings-ui/Settings.UI/Services/NavigationService.cs +++ b/src/settings-ui/Settings.UI/Services/NavigationService.cs @@ -24,12 +24,6 @@ namespace Microsoft.PowerToys.Settings.UI.Services { get { - if (frame == null) - { - frame = Window.Current.Content as Frame; - RegisterFrameEvents(); - } - return frame; } diff --git a/src/settings-ui/Settings.UI/Services/SearchIndexService.cs b/src/settings-ui/Settings.UI/Services/SearchIndexService.cs new file mode 100644 index 0000000000..d3d19020e4 --- /dev/null +++ b/src/settings-ui/Settings.UI/Services/SearchIndexService.cs @@ -0,0 +1,338 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Common.Search.FuzzSearch; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.Windows.ApplicationModel.Resources; +using Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.Services +{ + public static class SearchIndexService + { + private static readonly object _lockObject = new(); + private static readonly Dictionary<string, string> _pageNameCache = []; + private static readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new(); + private static readonly Dictionary<string, Type> _pageTypeCache = new(); + private static ImmutableArray<SettingEntry> _index = []; + private static bool _isIndexBuilt; + private static bool _isIndexBuilding; + private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json"; + private static JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true }; + + public static ImmutableArray<SettingEntry> Index + { + get + { + lock (_lockObject) + { + return _index; + } + } + } + + public static bool IsIndexReady + { + get + { + lock (_lockObject) + { + return _isIndexBuilt; + } + } + } + + public static void BuildIndex() + { + lock (_lockObject) + { + if (_isIndexBuilt || _isIndexBuilding) + { + return; + } + + _isIndexBuilding = true; + + // Clear caches on rebuild + _normalizedTextCache.Clear(); + _pageTypeCache.Clear(); + } + + try + { + var builder = ImmutableArray.CreateBuilder<SettingEntry>(); + LoadIndexFromPrebuiltData(builder); + + lock (_lockObject) + { + _index = builder.ToImmutable(); + _isIndexBuilt = true; + _isIndexBuilding = false; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[SearchIndexService] CRITICAL ERROR building search index: {ex.Message}\n{ex.StackTrace}"); + lock (_lockObject) + { + _isIndexBuilding = false; + _isIndexBuilt = false; + } + } + } + + private static void LoadIndexFromPrebuiltData(ImmutableArray<SettingEntry>.Builder builder) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + SettingEntry[] metadataList; + + Debug.WriteLine($"[SearchIndexService] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}"); + + try + { + using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName); + if (stream == null) + { + Debug.WriteLine($"[SearchIndexService] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches."); + return; + } + + using StreamReader reader = new(stream); + string json = reader.ReadToEnd(); + if (string.IsNullOrWhiteSpace(json)) + { + Debug.WriteLine("[SearchIndexService] ERROR: Embedded resource was empty."); + return; + } + + metadataList = JsonSerializer.Deserialize<SettingEntry[]>(json, _serializerOptions); + } + catch (Exception ex) + { + Debug.WriteLine($"[SearchIndexService] ERROR: Failed to load or deserialize prebuilt index: {ex.Message}"); + return; + } + + if (metadataList == null || metadataList.Length == 0) + { + Debug.WriteLine("[SearchIndexService] Prebuilt index is empty or deserialization failed."); + return; + } + + foreach (ref var metadata in metadataList.AsSpan()) + { + if (metadata.Type == EntryType.SettingsPage) + { + (metadata.Header, metadata.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, metadata.ElementUid); + } + else + { + (metadata.Header, metadata.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, metadata.ElementUid); + } + + if (string.IsNullOrEmpty(metadata.Header)) + { + continue; + } + + builder.Add(metadata); + + // Cache the page name mapping for SettingsPage entries + if (metadata.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(metadata.Header)) + { + _pageNameCache[metadata.PageTypeName] = metadata.Header; + } + } + + Debug.WriteLine($"[SearchIndexService] Finished loading index. Total entries: {builder.Count}"); + } + + private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid) + { + string header = GetString(resourceLoader, $"{elementUid}/Header"); + string description = GetString(resourceLoader, $"{elementUid}/Description"); + + if (string.IsNullOrEmpty(header)) + { + header = GetString(resourceLoader, $"{elementUid}/Content"); + } + + return (header, description); + } + + private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid) + { + string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle"); + string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription"); + + return (title, description); + } + + private static string GetString(ResourceLoader rl, string key) + { + try + { + string value = rl.GetString(key); + return string.IsNullOrWhiteSpace(value) ? string.Empty : value; + } + catch (Exception) + { + return string.Empty; + } + } + + public static List<SettingEntry> Search(string query) + { + return Search(query, CancellationToken.None); + } + + public static List<SettingEntry> Search(string query, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(query)) + { + return []; + } + + var currentIndex = Index; + if (currentIndex.IsEmpty) + { + Debug.WriteLine("[SearchIndexService] Search called but index is empty."); + return []; + } + + var normalizedQuery = NormalizeString(query); + var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>(); + var po = new ParallelOptions + { + CancellationToken = token, + MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1), + }; + + try + { + Parallel.ForEach(currentIndex, po, entry => + { + var (headerNorm, descNorm) = GetNormalizedTexts(entry); + var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm); + double score = captionScoreResult.Score; + + if (!string.IsNullOrEmpty(descNorm)) + { + var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm); + if (descriptionScoreResult.Success) + { + score = Math.Max(score, descriptionScoreResult.Score * 0.8); + } + } + + if (score > 0) + { + var pageType = GetPageTypeFromName(entry.PageTypeName); + if (pageType != null) + { + bag.Add((entry, score)); + } + } + }); + } + catch (OperationCanceledException) + { + return []; + } + + return bag + .OrderByDescending(r => r.Score) + .Select(r => r.Hit) + .ToList(); + } + + private static Type GetPageTypeFromName(string pageTypeName) + { + if (string.IsNullOrEmpty(pageTypeName)) + { + return null; + } + + lock (_lockObject) + { + if (_pageTypeCache.TryGetValue(pageTypeName, out var cached)) + { + return cached; + } + + var assembly = typeof(GeneralPage).Assembly; + var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}"); + _pageTypeCache[pageTypeName] = type; + return type; + } + } + + private static (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry) + { + if (entry.ElementUid == null && entry.Header == null) + { + return (NormalizeString(entry.Header), NormalizeString(entry.Description)); + } + + var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}"; + lock (_lockObject) + { + if (_normalizedTextCache.TryGetValue(key, out var cached)) + { + return cached; + } + } + + var headerNorm = NormalizeString(entry.Header); + var descNorm = NormalizeString(entry.Description); + lock (_lockObject) + { + _normalizedTextCache[key] = (headerNorm, descNorm); + } + + return (headerNorm, descNorm); + } + + private static string NormalizeString(string input) + { + if (string.IsNullOrEmpty(input)) + { + return string.Empty; + } + + var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD); + var stringBuilder = new StringBuilder(); + foreach (var c in normalized) + { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != UnicodeCategory.NonSpacingMark) + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString(); + } + + public static string GetLocalizedPageName(string pageTypeName) + { + return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml index 365c592d27..57cc0fb1ec 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml @@ -3,17 +3,23 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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"> <Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" /> <ResourceDictionary Source="/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml" /> + <ResourceDictionary Source="/SettingsXAML/Controls/KeyVisual/KeyCharPresenter.xaml" /> + <ResourceDictionary Source="/SettingsXAML/Controls/TitleBar/TitleBar.xaml" /> <ResourceDictionary Source="/SettingsXAML/Styles/TextBlock.xaml" /> <ResourceDictionary Source="/SettingsXAML/Styles/Button.xaml" /> <ResourceDictionary Source="/SettingsXAML/Styles/InfoBadge.xaml" /> <ResourceDictionary Source="/SettingsXAML/Themes/Colors.xaml" /> <ResourceDictionary Source="/SettingsXAML/Themes/Generic.xaml" /> + <ResourceDictionary Source="/SettingsXAML/Controls/Timeline/TimelineStyles.xaml" /> + <ResourceDictionary Source="/SettingsXAML/Controls/ShortcutControl/ShortcutWithTextLabelControl.xaml" /> <!-- Other merged dictionaries here --> </ResourceDictionary.MergedDictionaries> @@ -26,25 +32,40 @@ x:Key="BoolToVisibilityConverter" FalseValue="Collapsed" TrueValue="Visible" /> - <tkconverters:BoolToObjectConverter x:Key="BoolToComboBoxIndexConverter" FalseValue="0" TrueValue="1" /> - <tkconverters:BoolToObjectConverter x:Key="ReverseBoolToComboBoxIndexConverter" FalseValue="1" TrueValue="0" /> - + <tkconverters:DoubleToVisibilityConverter + x:Name="DoubleToVisibilityConverter" + FalseValue="Collapsed" + GreaterThan="0" + TrueValue="Visible" /> + <tkconverters:DoubleToVisibilityConverter + x:Name="DoubleToInvertedVisibilityConverter" + FalseValue="Visible" + GreaterThan="0" + TrueValue="Collapsed" /> <tkconverters:StringFormatConverter x:Key="StringFormatConverter" /> <tkconverters:BoolNegationConverter x:Key="BoolNegationConverter" /> + <tkconverters:EmptyObjectToObjectConverter + x:Key="EmptyObjectToObjectConverter" + EmptyValue="Collapsed" + NotEmptyValue="Visible" /> + <converters:UpdateStateToBoolConverter x:Key="UpdateStateToBoolConverter" /> + <tkconverters:StringVisibilityConverter x:Key="StringVisibilityConverter" /> + <x:Double x:Key="SettingsCardSpacing">2</x:Double> <!-- Overrides --> <Thickness x:Key="InfoBarIconMargin">6,16,16,16</Thickness> <Thickness x:Key="InfoBarContentRootPadding">16,0,0,0</Thickness> <x:Double x:Key="SettingActionControlMinWidth">240</x:Double> + <x:Double x:Key="PageMaxWidth">1000</x:Double> <Style TargetType="ListViewItem"> <Setter Property="Margin" Value="0,0,0,2" /> @@ -54,7 +75,17 @@ </Style> <Style BasedOn="{StaticResource DefaultCheckBoxStyle}" TargetType="controls:CheckBoxWithDescriptionControl" /> - <!-- Other app resources here --> + + <tkcontrols:MarkdownThemes + x:Key="DescriptionTextMarkdownThemeConfig" + InlineCodeBackground="{StaticResource ControlFillColorDefaultBrush}" + InlineCodeBorderBrush="{StaticResource ControlElevationBorderBrush}" + InlineCodeCornerRadius="2" + InlineCodeFontSize="12" + InlineCodeForeground="{StaticResource TextFillColorSecondaryBrush}" + InlineCodePadding="2,0,2,1" /> + + <tkcontrols:MarkdownConfig x:Key="DescriptionTextMarkdownConfig" Themes="{StaticResource DescriptionTextMarkdownThemeConfig}" /> <TransitionCollection x:Key="SettingsCardsAnimations"> <EntranceThemeTransition FromVerticalOffset="50" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs index 40abe6e1e8..071c782901 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs @@ -9,13 +9,15 @@ using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; - using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; +using Microsoft.PowerToys.Settings.UI.OOBE.Enums; +using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.SerializationContext; using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard; using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; @@ -26,11 +28,16 @@ using WinUIEx; namespace Microsoft.PowerToys.Settings.UI { - /// <summary> - /// Provides application-specific behavior to supplement the default Application class. - /// </summary> public partial class App : Application { + public static OobeShellViewModel OobeShellViewModel { get; } = new(); + + private OobeWindow oobeWindow; + + private ScoobeWindow scoobeWindow; + + private ShortcutConflictWindow shortcutConflictWindow; + private enum Arguments { PTPipeName = 1, @@ -41,16 +48,14 @@ namespace Microsoft.PowerToys.Settings.UI IsUserAdmin, ShowOobeWindow, ShowScoobeWindow, - ShowFlyout, ContainsSettingsWindow, - ContainsFlyoutPosition, } private const int RequiredArgumentsSetSettingQty = 4; private const int RequiredArgumentsSetAdditionalSettingsQty = 4; private const int RequiredArgumentsGetSettingQty = 3; - private const int RequiredArgumentsLaunchedFromRunnerQty = 12; + private const int RequiredArgumentsLaunchedFromRunnerQty = 10; // Create an instance of the IPC wrapper. private static TwoWayPipeMessageIPCManaged ipcmanager; @@ -63,8 +68,6 @@ namespace Microsoft.PowerToys.Settings.UI public bool ShowOobe { get; set; } - public bool ShowFlyout { get; set; } - public bool ShowScoobe { get; set; } public Type StartupPage { get; set; } = typeof(Views.DashboardPage); @@ -133,7 +136,7 @@ namespace Microsoft.PowerToys.Settings.UI var settingValue = cmdArgs[3]; try { - SetSettingCommandLineCommand.Execute(settingName, settingValue, new SettingsUtils()); + SetSettingCommandLineCommand.Execute(settingName, settingValue, SettingsUtils.Default); } catch (Exception ex) { @@ -151,7 +154,7 @@ namespace Microsoft.PowerToys.Settings.UI { using (var settings = JsonDocument.Parse(File.ReadAllText(ipcFileName))) { - SetAdditionalSettingsCommandLineCommand.Execute(moduleName, settings, new SettingsUtils()); + SetAdditionalSettingsCommandLineCommand.Execute(moduleName, settings, SettingsUtils.Default); } } catch (Exception ex) @@ -194,9 +197,7 @@ namespace Microsoft.PowerToys.Settings.UI IsUserAnAdmin = cmdArgs[(int)Arguments.IsUserAdmin] == "true"; ShowOobe = cmdArgs[(int)Arguments.ShowOobeWindow] == "true"; ShowScoobe = cmdArgs[(int)Arguments.ShowScoobeWindow] == "true"; - ShowFlyout = cmdArgs[(int)Arguments.ShowFlyout] == "true"; bool containsSettingsWindow = cmdArgs[(int)Arguments.ContainsSettingsWindow] == "true"; - bool containsFlyoutPosition = cmdArgs[(int)Arguments.ContainsFlyoutPosition] == "true"; // To keep track of variable arguments int currentArgumentIndex = RequiredArgumentsLaunchedFromRunnerQty; @@ -209,15 +210,6 @@ namespace Microsoft.PowerToys.Settings.UI currentArgumentIndex++; } - int flyout_x = 0; - int flyout_y = 0; - if (containsFlyoutPosition) - { - // get the flyout position arguments - _ = int.TryParse(cmdArgs[currentArgumentIndex++], out flyout_x); - _ = int.TryParse(cmdArgs[currentArgumentIndex++], out flyout_y); - } - RunnerHelper.WaitForPowerToysRunner(PowerToysPID, () => { Environment.Exit(0); @@ -232,20 +224,21 @@ namespace Microsoft.PowerToys.Settings.UI }); ipcmanager.Start(); - if (!ShowOobe && !ShowScoobe && !ShowFlyout) + GlobalHotkeyConflictManager.Initialize(message => + { + ipcmanager.Send(message); + return 0; + }); + + if (!ShowOobe && !ShowScoobe) { settingsWindow = new MainWindow(); settingsWindow.Activate(); - settingsWindow.ExtendsContentIntoTitleBar = true; settingsWindow.NavigateToSection(StartupPage); // https://github.com/microsoft/microsoft-ui-xaml/issues/7595 - Activate doesn't bring window to the foreground // Need to call SetForegroundWindow to actually gain focus. WindowHelpers.BringToForeground(settingsWindow.GetWindowHandle()); - - // https://github.com/microsoft/microsoft-ui-xaml/issues/8948 - A window's top border incorrectly - // renders as black on Windows 10. - WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(settingsWindow)); } else { @@ -256,31 +249,11 @@ namespace Microsoft.PowerToys.Settings.UI if (ShowOobe) { - PowerToysTelemetry.Log.WriteEvent(new OobeStartedEvent()); - OobeWindow oobeWindow = new OobeWindow(OOBE.Enums.PowerToysModules.Overview); - oobeWindow.Activate(); - oobeWindow.ExtendsContentIntoTitleBar = true; - WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(settingsWindow)); - SetOobeWindow(oobeWindow); + OpenOobe(); } else if (ShowScoobe) { - PowerToysTelemetry.Log.WriteEvent(new ScoobeStartedEvent()); - OobeWindow scoobeWindow = new OobeWindow(OOBE.Enums.PowerToysModules.WhatsNew); - scoobeWindow.Activate(); - scoobeWindow.ExtendsContentIntoTitleBar = true; - WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(settingsWindow)); - SetOobeWindow(scoobeWindow); - } - else if (ShowFlyout) - { - POINT? p = null; - if (containsFlyoutPosition) - { - p = new POINT(flyout_x, flyout_y); - } - - ShellPage.OpenFlyoutCallback(p); + OpenScoobe(); } } } @@ -290,7 +263,7 @@ namespace Microsoft.PowerToys.Settings.UI /// will be used such as when the application is launched to open a specific file. /// </summary> /// <param name="args">Details about the launch request and process.</param> - protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) + protected override void OnLaunched(LaunchActivatedEventArgs args) { var cmdArgs = Environment.GetCommandLineArgs(); @@ -316,44 +289,24 @@ namespace Microsoft.PowerToys.Settings.UI // For debugging purposes // Window is also needed to show MessageDialog settingsWindow = new MainWindow(); - settingsWindow.ExtendsContentIntoTitleBar = true; - WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(settingsWindow)); settingsWindow.Activate(); settingsWindow.NavigateToSection(StartupPage); - ShowMessageDialog("The application is running in Debug mode.", "DEBUG"); + + // In DEBUG mode, we might not have IPC set up, so provide a dummy implementation + GlobalHotkeyConflictManager.Initialize(message => + { + // In debug mode, just log or do nothing + Debug.WriteLine($"IPC Message: {message}"); + return 0; + }); #else - /* If we try to run Settings as a standalone app, it will start PowerToys.exe if not running and open Settings again through it in the Dashboard page. */ - Common.UI.SettingsDeepLink.OpenSettings(Common.UI.SettingsDeepLink.SettingsWindow.Dashboard, true); - Exit(); + /* If we try to run Settings as a standalone app, it will start PowerToys.exe if not running and open Settings again through it in the Dashboard page. */ + Common.UI.SettingsDeepLink.OpenSettings(Common.UI.SettingsDeepLink.SettingsWindow.Dashboard); + Exit(); #endif } } -#if !DEBUG - private async void ShowMessageDialogAndExit(string content, string title = null) -#else - private async void ShowMessageDialog(string content, string title = null) -#endif - { - await ShowDialogAsync(content, title); -#if !DEBUG - this.Exit(); -#endif - } - - public static Task<IUICommand> ShowDialogAsync(string content, string title = null) - { - var dialog = new MessageDialog(content, title ?? string.Empty); - var handle = NativeMethods.GetActiveWindow(); - if (handle == IntPtr.Zero) - { - throw new InvalidOperationException(); - } - - InitializeWithWindow.Initialize(dialog, handle); - return dialog.ShowAsync().AsTask<IUICommand>(); - } - public static TwoWayPipeMessageIPCManaged GetTwoWayIPCManager() { return ipcmanager; @@ -369,14 +322,12 @@ namespace Microsoft.PowerToys.Settings.UI return 0; } - private static ISettingsUtils settingsUtils = new SettingsUtils(); + private static SettingsUtils settingsUtils = SettingsUtils.Default; private static ThemeService themeService = new ThemeService(SettingsRepository<GeneralSettings>.GetInstance(settingsUtils)); public static ThemeService ThemeService => themeService; private static MainWindow settingsWindow; - private static OobeWindow oobeWindow; - private static FlyoutWindow flyoutWindow; public static void ClearSettingsWindow() { @@ -388,34 +339,71 @@ namespace Microsoft.PowerToys.Settings.UI return settingsWindow; } - public static OobeWindow GetOobeWindow() + public static bool IsSecondaryWindowOpen() { - return oobeWindow; + var app = (App)Current; + return app.oobeWindow != null || app.scoobeWindow != null || app.shortcutConflictWindow != null; } - public static FlyoutWindow GetFlyoutWindow() + public void OpenScoobe() { - return flyoutWindow; + PowerToysTelemetry.Log.WriteEvent(new ScoobeStartedEvent()); + + if (scoobeWindow == null) + { + scoobeWindow = new ScoobeWindow(); + + scoobeWindow.Closed += (_, _) => + { + scoobeWindow = null; + }; + + scoobeWindow.Activate(); + } + else + { + WindowHelpers.BringToForeground(scoobeWindow.GetWindowHandle()); + } } - public static void SetOobeWindow(OobeWindow window) + public void OpenOobe() { - oobeWindow = window; + PowerToysTelemetry.Log.WriteEvent(new OobeStartedEvent()); + + if (oobeWindow == null) + { + oobeWindow = new OobeWindow(); + + oobeWindow.Closed += (_, _) => + { + oobeWindow = null; + }; + + oobeWindow.Activate(); + } + else + { + WindowHelpers.BringToForeground(oobeWindow.GetWindowHandle()); + } } - public static void SetFlyoutWindow(FlyoutWindow window) + public void OpenShortcutConflictWindow() { - flyoutWindow = window; - } + if (shortcutConflictWindow == null) + { + shortcutConflictWindow = new ShortcutConflictWindow(); - public static void ClearOobeWindow() - { - oobeWindow = null; - } + shortcutConflictWindow.Closed += (_, _) => + { + shortcutConflictWindow = null; + }; - public static void ClearFlyoutWindow() - { - flyoutWindow = null; + shortcutConflictWindow.Activate(); + } + else + { + WindowHelpers.BringToForeground(shortcutConflictWindow.GetWindowHandle()); + } } public static Type GetPage(string settingWindow) @@ -429,6 +417,7 @@ namespace Microsoft.PowerToys.Settings.UI case "Awake": return typeof(AwakePage); case "CmdNotFound": return typeof(CmdNotFoundPage); case "ColorPicker": return typeof(ColorPickerPage); + case "LightSwitch": return typeof(LightSwitchPage); case "FancyZones": return typeof(FancyZonesPage); case "FileLocksmith": return typeof(FileLocksmithPage); case "Run": return typeof(PowerLauncherPage); @@ -436,6 +425,11 @@ namespace Microsoft.PowerToys.Settings.UI case "KBM": return typeof(KeyboardManagerPage); case "MouseUtils": return typeof(MouseUtilsPage); case "MouseWithoutBorders": return typeof(MouseWithoutBordersPage); + case "Peek": return typeof(PeekPage); + case "PowerAccent": return typeof(PowerAccentPage); + case "PowerDisplay": return typeof(PowerDisplayPage); + case "PowerLauncher": return typeof(PowerLauncherPage); + case "PowerPreview": return typeof(PowerPreviewPage); case "PowerRename": return typeof(PowerRenamePage); case "QuickAccent": return typeof(PowerAccentPage); case "FileExplorer": return typeof(PowerPreviewPage); @@ -444,7 +438,6 @@ namespace Microsoft.PowerToys.Settings.UI case "MeasureTool": return typeof(MeasureToolPage); case "Hosts": return typeof(HostsPage); case "RegistryPreview": return typeof(RegistryPreviewPage); - case "Peek": return typeof(PeekPage); case "CropAndLock": return typeof(CropAndLockPage); case "EnvironmentVariables": return typeof(EnvironmentVariablesPage); case "NewPlus": return typeof(NewPlusPage); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml deleted file mode 100644 index c077042d96..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml +++ /dev/null @@ -1,41 +0,0 @@ -<UserControl - x:Class="Microsoft.PowerToys.Settings.UI.Controls.AlphaColorPickerButton" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - d:DesignHeight="300" - d:DesignWidth="400" - mc:Ignorable="d"> - - <Grid> - <!--TODO(stefan): ToDisplayName is no longer available in ColorHelper - <DropDownButton Padding="4,4,8,4" AutomationProperties.FullDescription="{x:Bind clr:ColorHelper.ToDisplayName(SelectedColor), Mode=OneWay }"> - --> - <DropDownButton Padding="4,4,8,4"> - <Border - x:Name="ColorPreviewBorder" - Width="48" - Height="24" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="{ThemeResource ControlCornerRadius}"> - <Border.Background> - <SolidColorBrush Color="{x:Bind SelectedColor, Mode=OneWay}" /> - </Border.Background> - </Border> - <DropDownButton.Flyout> - <Flyout ShouldConstrainToRootBounds="False"> - <ColorPicker - IsAlphaEnabled="True" - IsAlphaSliderVisible="True" - IsAlphaTextInputVisible="True" - IsColorChannelTextInputVisible="True" - IsColorSliderVisible="True" - IsHexInputVisible="True" - Color="{x:Bind SelectedColor, Mode=TwoWay}" /> - </Flyout> - </DropDownButton.Flyout> - </DropDownButton> - </Grid> -</UserControl> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml.cs deleted file mode 100644 index 24a0d0e448..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/AlphaColorPickerButton.xaml.cs +++ /dev/null @@ -1,59 +0,0 @@ -// 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; -using Windows.UI; - -namespace Microsoft.PowerToys.Settings.UI.Controls -{ - public sealed partial class AlphaColorPickerButton : UserControl - { - private Color _selectedColor; - - public Color SelectedColor - { - get - { - return _selectedColor; - } - - set - { - if (_selectedColor != value) - { - _selectedColor = value; - SetValue(SelectedColorProperty, value); - } - } - } - - public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register("SelectedColor", typeof(Color), typeof(AlphaColorPickerButton), new PropertyMetadata(null)); - - public AlphaColorPickerButton() - { - this.InitializeComponent(); - IsEnabledChanged -= AlphaColorPickerButton_IsEnabledChanged; - SetEnabledState(); - IsEnabledChanged += AlphaColorPickerButton_IsEnabledChanged; - } - - private void AlphaColorPickerButton_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) - { - SetEnabledState(); - } - - private void SetEnabledState() - { - if (this.IsEnabled) - { - ColorPreviewBorder.Opacity = 1; - } - else - { - ColorPreviewBorder.Opacity = 0.2; - } - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/CheckBoxWithDescriptionControl.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/CheckBoxWithDescriptionControl.cs index d8b5b6e31a..854bf8cd72 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/CheckBoxWithDescriptionControl.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/CheckBoxWithDescriptionControl.cs @@ -12,11 +12,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { public partial class CheckBoxWithDescriptionControl : CheckBox { - private CheckBoxWithDescriptionControl _checkBoxSubTextControl; - public CheckBoxWithDescriptionControl() { - _checkBoxSubTextControl = (CheckBoxWithDescriptionControl)this; this.Loaded += CheckBoxSubTextControl_Loaded; } @@ -45,17 +42,17 @@ namespace Microsoft.PowerToys.Settings.UI.Controls panel.Children.Add(new IsEnabledTextBlock() { Style = (Style)App.Current.Resources["SecondaryIsEnabledTextBlockStyle"], Text = Description }); } - _checkBoxSubTextControl.Content = panel; + this.Content = panel; } public static readonly DependencyProperty HeaderProperty = DependencyProperty.Register( - "Header", + nameof(Header), typeof(string), typeof(CheckBoxWithDescriptionControl), new PropertyMetadata(default(string))); public static readonly DependencyProperty DescriptionProperty = DependencyProperty.Register( - "Description", + nameof(Description), typeof(string), typeof(CheckBoxWithDescriptionControl), new PropertyMetadata(default(string))); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml index 32f0ee488e..743ced3204 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml @@ -9,7 +9,7 @@ xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <UserControl.Resources> <converters:ColorFormatConverter x:Key="ColorFormatConverter" /> @@ -32,7 +32,8 @@ Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind Description}" TextTrimming="CharacterEllipsis" - TextWrapping="NoWrap" /> + TextWrapping="NoWrap" + ToolTipService.ToolTip="{x:Bind Description}" /> </Grid> </DataTemplate> @@ -60,7 +61,7 @@ </DataTemplate> <ItemsPanelTemplate x:Key="ItemPanelTemplate"> - <tk7controls:WrapPanel + <tkcontrols:WrapPanel HorizontalSpacing="20" Orientation="Horizontal" VerticalSpacing="4" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml.cs index 76681d8b46..475d399674 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml.cs @@ -47,12 +47,17 @@ namespace Microsoft.PowerToys.Settings.UI.Controls new ColorFormatParameter() { Parameter = "%In", Description = resourceLoader.GetString("Help_intensity") }, new ColorFormatParameter() { Parameter = "%Hn", Description = resourceLoader.GetString("Help_hueNat") }, new ColorFormatParameter() { Parameter = "%Ll", Description = resourceLoader.GetString("Help_lightnessNat") }, - new ColorFormatParameter() { Parameter = "%Lc", Description = resourceLoader.GetString("Help_lightnessCIE") }, new ColorFormatParameter() { Parameter = "%Va", Description = resourceLoader.GetString("Help_value") }, new ColorFormatParameter() { Parameter = "%Wh", Description = resourceLoader.GetString("Help_whiteness") }, new ColorFormatParameter() { Parameter = "%Bn", Description = resourceLoader.GetString("Help_blackness") }, - new ColorFormatParameter() { Parameter = "%Ca", Description = resourceLoader.GetString("Help_chromaticityA") }, - new ColorFormatParameter() { Parameter = "%Cb", Description = resourceLoader.GetString("Help_chromaticityB") }, + new ColorFormatParameter() { Parameter = "%Lc", Description = resourceLoader.GetString("Help_lightnessCIE") }, + new ColorFormatParameter() { Parameter = "%Ca", Description = resourceLoader.GetString("Help_chromaticityACIE") }, + new ColorFormatParameter() { Parameter = "%Cb", Description = resourceLoader.GetString("Help_chromaticityBCIE") }, + new ColorFormatParameter() { Parameter = "%Lo", Description = resourceLoader.GetString("Help_lightnessOklab") }, + new ColorFormatParameter() { Parameter = "%Oa", Description = resourceLoader.GetString("Help_chromaticityAOklab") }, + new ColorFormatParameter() { Parameter = "%Ob", Description = resourceLoader.GetString("Help_chromaticityBOklab") }, + new ColorFormatParameter() { Parameter = "%Oc", Description = resourceLoader.GetString("Help_chromaOklch") }, + new ColorFormatParameter() { Parameter = "%Oh", Description = resourceLoader.GetString("Help_hueOklch") }, new ColorFormatParameter() { Parameter = "%Xv", Description = resourceLoader.GetString("Help_X_value") }, new ColorFormatParameter() { Parameter = "%Yv", Description = resourceLoader.GetString("Help_Y_value") }, new ColorFormatParameter() { Parameter = "%Zv", Description = resourceLoader.GetString("Help_Z_value") }, diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml index 10a1e01236..4a46b7dc29 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml @@ -27,9 +27,9 @@ <DropDownButton.Flyout> <Flyout ShouldConstrainToRootBounds="False"> <ColorPicker - IsAlphaEnabled="False" - IsAlphaSliderVisible="False" - IsAlphaTextInputVisible="False" + IsAlphaEnabled="{x:Bind IsAlphaEnabled, Mode=OneWay}" + IsAlphaSliderVisible="{x:Bind IsAlphaEnabled, Mode=OneWay}" + IsAlphaTextInputVisible="{x:Bind IsAlphaEnabled, Mode=OneWay}" IsColorChannelTextInputVisible="True" IsColorSliderVisible="True" IsHexInputVisible="True" diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml.cs index 06a76cc72e..c915513aed 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorPickerButton.xaml.cs @@ -2,6 +2,7 @@ // 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; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Windows.UI; @@ -29,7 +30,15 @@ namespace Microsoft.PowerToys.Settings.UI.Controls } } - public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register("SelectedColor", typeof(Color), typeof(ColorPickerButton), new PropertyMetadata(null)); + public static readonly DependencyProperty SelectedColorProperty = DependencyProperty.Register(nameof(SelectedColor), typeof(Color), typeof(ColorPickerButton), new PropertyMetadata(null)); + + public static readonly DependencyProperty IsAlphaEnabledProperty = DependencyProperty.Register(nameof(IsAlphaEnabled), typeof(bool), typeof(ColorPickerButton), new PropertyMetadata(defaultValue: false)); + + public bool IsAlphaEnabled + { + get => (bool)GetValue(IsAlphaEnabledProperty); + set => SetValue(IsAlphaEnabledProperty, value); + } public ColorPickerButton() { diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml new file mode 100644 index 0000000000..38b79c31e6 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.PowerToys.Settings.UI.Controls.CheckUpdateControl" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d"> + + <StackPanel Orientation="Horizontal"> + <Button + Click="SWVersionButtonClicked" + Style="{StaticResource SubtleButtonStyle}" + Visibility="{x:Bind UpdateAvailable, Mode=OneWay}"> + <Grid ColumnSpacing="16"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Border + Width="20" + Height="20" + CornerRadius="10"> + <Border.Background> + <LinearGradientBrush StartPoint="0,0" EndPoint="0.5,1"> + <GradientStop Offset="0.0" Color="#239DE0" /> + <GradientStop Offset="1.0" Color="#037CD6" /> + </LinearGradientBrush> + </Border.Background> + <FontIcon + AutomationProperties.AccessibilityView="Raw" + FontSize="11" + Foreground="White" + Glyph="" /> + </Border> + <StackPanel Grid.Column="1" Orientation="Vertical"> + <TextBlock x:Uid="UpdateAvailableTextBlock" FontWeight="SemiBold" /> + <TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Style="{StaticResource CaptionTextBlockStyle}"> + <Run x:Uid="GeneralVersion" /> + <Run Text="{x:Bind UpdateSettingsConfig.NewVersion, Mode=OneWay}" /> + </TextBlock> + </StackPanel> + </Grid> + </Button> + <Grid + Padding="0,0,4,0" + VerticalAlignment="Center" + ColumnSpacing="16" + Visibility="{x:Bind UpdateAvailable, Converter={StaticResource ReverseBoolToVisibilityConverter}, Mode=OneWay}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Border + Width="20" + Height="20" + CornerRadius="10"> + <Border.Background> + <LinearGradientBrush StartPoint="0,0" EndPoint="0.5,1"> + <GradientStop Offset="0.0" Color="#6FB538" /> + <GradientStop Offset="1.0" Color="#397A24" /> + </LinearGradientBrush> + </Border.Background> + <FontIcon + AutomationProperties.AccessibilityView="Raw" + FontSize="11" + Foreground="White" + Glyph="" /> + </Border> + <StackPanel Grid.Column="1" Orientation="Vertical"> + <TextBlock x:Uid="YoureUpToDate" FontWeight="SemiBold" /> + <TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Style="{StaticResource CaptionTextBlockStyle}"> + <Run x:Uid="General_VersionLastChecked" /> + <Run Text="{x:Bind UpdateSettingsConfig.LastCheckedDateLocalized, Mode=OneWay}" /> + </TextBlock> + </StackPanel> + </Grid> + </StackPanel> +</UserControl> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml.cs new file mode 100644 index 0000000000..36a20fd340 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class CheckUpdateControl : UserControl + { + public bool UpdateAvailable { get; set; } + + public UpdatingSettings UpdateSettingsConfig { get; set; } + + public CheckUpdateControl() + { + InitializeComponent(); + UpdateSettingsConfig = UpdatingSettings.LoadSettings(); + UpdateAvailable = UpdateSettingsConfig != null && (UpdateSettingsConfig.State == UpdatingSettings.UpdatingState.ReadyToInstall || UpdateSettingsConfig.State == UpdatingSettings.UpdatingState.ReadyToDownload); + } + + private void SWVersionButtonClicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + NavigationService.Navigate(typeof(GeneralPage)); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml new file mode 100644 index 0000000000..44470ebbc1 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.PowerToys.Settings.UI.Controls.ShortcutConflictControl" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d"> + + <Grid> + <Button + x:Uid="ShortcutConflictControl_Automation" + Click="ShortcutConflictBtn_Click" + Style="{StaticResource SubtleButtonStyle}"> + <Grid ColumnSpacing="16"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <FontIcon + x:Name="Icon" + AutomationProperties.AccessibilityView="Raw" + FontSize="20" + Glyph="" /> + <StackPanel Grid.Column="1" Orientation="Vertical"> + <TextBlock x:Uid="ShortcutConflictControl_Title" FontWeight="SemiBold" /> + <TextBlock + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{x:Bind ConflictText, Mode=OneWay}" /> + </StackPanel> + </Grid> + </Button> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="ConflictsStateGroup"> + <VisualState x:Name="NoConflictState" /> + <VisualState x:Name="ConflictState"> + <VisualState.Setters> + <Setter Target="Icon.Glyph" Value="" /> + <Setter Target="Icon.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> +</UserControl> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs new file mode 100644 index 0000000000..1bb42d8f15 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictControl.xaml.cs @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; +using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard; +using Microsoft.PowerToys.Telemetry; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class ShortcutConflictControl : UserControl, INotifyPropertyChanged + { + private static readonly ResourceLoader ResourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; + + private static bool _telemetryEventSent; + + public static readonly DependencyProperty AllHotkeyConflictsDataProperty = + DependencyProperty.Register( + nameof(AllHotkeyConflictsData), + typeof(AllHotkeyConflictsData), + typeof(ShortcutConflictControl), + new PropertyMetadata(null, OnAllHotkeyConflictsDataChanged)); + + public AllHotkeyConflictsData AllHotkeyConflictsData + { + get => (AllHotkeyConflictsData)GetValue(AllHotkeyConflictsDataProperty); + set => SetValue(AllHotkeyConflictsDataProperty, value); + } + + public int ConflictCount + { + get + { + if (AllHotkeyConflictsData == null) + { + return 0; + } + + int count = 0; + if (AllHotkeyConflictsData.InAppConflicts != null) + { + foreach (var inAppConflict in AllHotkeyConflictsData.InAppConflicts) + { + if (!inAppConflict.ConflictIgnored) + { + count++; + } + } + } + + if (AllHotkeyConflictsData.SystemConflicts != null) + { + foreach (var systemConflict in AllHotkeyConflictsData.SystemConflicts) + { + if (!systemConflict.ConflictIgnored) + { + count++; + } + } + } + + return count; + } + } + + public string ConflictText + { + get + { + var count = ConflictCount; + return count switch + { + 0 => ResourceLoader.GetString("ShortcutConflictControl_NoConflictsFound"), + 1 => ResourceLoader.GetString("ShortcutConflictControl_SingleConflictFound"), + _ => string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ResourceLoader.GetString("ShortcutConflictControl_MultipleConflictsFound"), + count), + }; + } + } + + public bool HasConflicts => ConflictCount > 0; + + private static void OnAllHotkeyConflictsDataChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is ShortcutConflictControl control) + { + control.UpdateProperties(); + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + private void UpdateProperties() + { + OnPropertyChanged(nameof(ConflictCount)); + OnPropertyChanged(nameof(ConflictText)); + OnPropertyChanged(nameof(HasConflicts)); + + // Update visibility based on conflict count + if (HasConflicts) + { + VisualStateManager.GoToState(this, "ConflictState", true); + } + else + { + VisualStateManager.GoToState(this, "NoConflictState", true); + } + + if (!_telemetryEventSent && HasConflicts) + { + // Log telemetry event when conflicts are detected + PowerToysTelemetry.Log.WriteEvent(new ShortcutConflictDetectedEvent() + { + ConflictCount = ConflictCount, + }); + + _telemetryEventSent = true; + } + } + + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + public ShortcutConflictControl() + { + InitializeComponent(); + DataContext = this; + + UpdateProperties(); + } + + private void ShortcutConflictBtn_Click(object sender, RoutedEventArgs e) + { + if (AllHotkeyConflictsData == null) + { + return; + } + + // Log telemetry event when user clicks the shortcut conflict button + PowerToysTelemetry.Log.WriteEvent(new ShortcutConflictControlClickedEvent() + { + ConflictCount = this.ConflictCount, + }); + + ((App)App.Current)!.OpenShortcutConflictWindow(); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml new file mode 100644 index 0000000000..5673345cff --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml @@ -0,0 +1,182 @@ +<winuiex:WindowEx + x:Class="Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard.ShortcutConflictWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + 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:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:hotkeyConflicts="using:Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:winuiex="using:WinUIEx" + MinWidth="480" + MinHeight="600" + MaxWidth="900" + MaxHeight="1000" + Closed="WindowEx_Closed" + IsMaximizable="False" + IsMinimizable="False" + mc:Ignorable="d"> + + <Window.SystemBackdrop> + <MicaBackdrop /> + </Window.SystemBackdrop> + + <Grid x:Name="RootGrid"> + <Grid.Resources> + <ResourceDictionary> + <ResourceDictionary.ThemeDictionaries> + <ResourceDictionary x:Key="Default"> + <LinearGradientBrush x:Key="WindowsLogoGradient" StartPoint="0,0" EndPoint="1,1"> + <GradientStop Offset="0.0" Color="#FF80F9FF" /> + <GradientStop Offset="1" Color="#FF0B9CFF" /> + </LinearGradientBrush> + </ResourceDictionary> + <ResourceDictionary x:Key="Light"> + <LinearGradientBrush x:Key="WindowsLogoGradient" StartPoint="0,0" EndPoint="1,1"> + <GradientStop Offset="0.0" Color="#FF4DD2FF" /> + <GradientStop Offset="0.75" Color="#FF0078D4" /> + </LinearGradientBrush> + </ResourceDictionary> + <ResourceDictionary x:Key="HighContrast"> + <SolidColorBrush x:Key="WindowsLogoGradient" Color="{StaticResource SystemColorHighlightTextColor}" /> + </ResourceDictionary> + </ResourceDictionary.ThemeDictionaries> + </ResourceDictionary> + </Grid.Resources> + + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + + <!-- Title Bar Area --> + <TitleBar x:Name="titleBar" x:Uid="ShortcutConflictWindow_TitleTxt"> + <!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource --> + <TitleBar.LeftHeader> + <ImageIcon + Height="16" + Margin="16,0,0,0" + Source="/Assets/Settings/icon.ico" /> + </TitleBar.LeftHeader> + </TitleBar> + + <!-- Description text --> + + <TextBlock + x:Uid="ShortcutConflictWindow_Description" + Grid.Row="1" + Margin="16,8,16,24" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource BodyTextBlockStyle}" + TextWrapping="Wrap" /> + + <!-- Main Content Area --> + <ScrollViewer Grid.Row="2"> + <Grid Margin="16,0,16,16"> + <!-- Conflicts List --> + <ItemsControl x:Name="ConflictItemsControl" ItemsSource="{x:Bind ViewModel.ConflictItems, Mode=OneWay}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <StackPanel Orientation="Vertical" Spacing="32" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate x:DataType="hotkeyConflicts:HotkeyConflictGroupData"> + <StackPanel + Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="{StaticResource OverlayCornerRadius}" + Orientation="Vertical"> + <!-- Hotkey Header --> + <Grid Margin="16,12,16,12"> + <controls:ShortcutWithTextLabelControl + x:Uid="ShortcutConflictWindow_ModulesUsingShortcut" + VerticalAlignment="Center" + FontWeight="SemiBold" + Keys="{x:Bind Hotkey.GetKeysList()}" + LabelPlacement="Before" /> + <CheckBox + x:Uid="ShortcutConflictWindow_IgnoreShortcut" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Click="OnIgnoreConflictClicked" + IsChecked="{x:Bind ConflictIgnored, Mode=OneWay}" /> + </Grid> + + <!-- PowerToys Module Cards --> + <ItemsControl + Grid.Row="1" + IsEnabled="{x:Bind ConflictVisible, Mode=OneWay}" + ItemsSource="{x:Bind Modules, Mode=OneWay}"> + <ItemsControl.ItemTemplate> + <DataTemplate x:DataType="hotkeyConflicts:ModuleHotkeyData"> + <tkcontrols:SettingsCard + Background="Transparent" + BorderThickness="0,1,0,0" + Click="SettingsCard_Click" + CornerRadius="0" + Description="{x:Bind DisplayName}" + Header="{x:Bind Header}" + IsClickEnabled="True"> + <tkcontrols:SettingsCard.HeaderIcon> + <BitmapIcon ShowAsMonochrome="False" UriSource="{x:Bind IconPath}" /> + </tkcontrols:SettingsCard.HeaderIcon> + <!-- ShortcutControl with TwoWay binding and enabled for editing --> + <controls:ShortcutControl + x:Name="ShortcutControl" + MinWidth="140" + Margin="2" + VerticalAlignment="Center" + HasConflict="True" + HotkeySettings="{x:Bind HotkeySettings, Mode=TwoWay}" + IsEnabled="True" /> + </tkcontrols:SettingsCard> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + + <!-- System Conflict Card (only show if it's a system conflict) --> + <tkcontrols:SettingsCard + x:Name="SystemConflictCard" + x:Uid="ShortcutConflictWindow_SystemCard" + Background="Transparent" + BorderThickness="0,1,0,0" + CornerRadius="0" + IsEnabled="{x:Bind ShouldShowSysConflict, Mode=OneWay}"> + <tkcontrols:SettingsCard.HeaderIcon> + <PathIcon Data="M9 20H0V11H9V20ZM20 20H11V11H20V20ZM9 9H0V0H9V9ZM20 9H11V0H20V9Z" Foreground="{ThemeResource WindowsLogoGradient}" /> + </tkcontrols:SettingsCard.HeaderIcon> + <!-- System shortcut message --> + <HyperlinkButton x:Uid="ShortcutConflictWindow_SystemShortcutLink" NavigateUri="https://support.microsoft.com/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec" /> + </tkcontrols:SettingsCard> + </StackPanel> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </Grid> + </ScrollViewer> + <!-- Empty State (when no conflicts) --> + <StackPanel + x:Name="EmptyStatePanel" + Grid.Row="2" + Margin="24" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Visibility="Collapsed"> + <FontIcon + HorizontalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + FontSize="48" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Glyph="" /> + <TextBlock Margin="0,16,0,4" HorizontalAlignment="Center"> + <Run x:Uid="ShortcutConflictWindow_NoConflictsTitle" /> + <Run x:Uid="ShortcutConflictWindow_NoConflictsDescription" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </TextBlock> + </StackPanel> + </Grid> +</winuiex:WindowEx> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs new file mode 100644 index 0000000000..ec6aac76d1 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/ShortcutConflictWindow.xaml.cs @@ -0,0 +1,152 @@ +// 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 CommunityToolkit.WinUI.Controls; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Windows.Graphics; +using WinUIEx; + +namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard +{ + public sealed partial class ShortcutConflictWindow : WindowEx + { + public ShortcutConflictViewModel ViewModel { get; private set; } + + public ShortcutConflictWindow() + { + App.ThemeService.ThemeChanged += OnThemeChanged; + App.ThemeService.ApplyTheme(); + + var settingsUtils = SettingsUtils.Default; + + ViewModel = new ShortcutConflictViewModel( + settingsUtils, + SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), + ShellPage.SendDefaultIPCMessage); + + InitializeComponent(); + + // Set DataContext on the root Grid instead of the Window + RootGrid.DataContext = ViewModel; + + this.Activated += Window_Activated_SetIcon; + + // Set localized window title + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + ExtendsContentIntoTitleBar = true; + SetTitleBar(titleBar); + + this.Title = resourceLoader.GetString("ShortcutConflictWindow_Title"); + this.CenterOnScreen(); + + ViewModel.OnPageLoaded(); + } + + private void OnThemeChanged(object sender, ElementTheme theme) + { + WindowHelper.SetTheme(this, theme); + } + + private void CenterOnScreen() + { + var displayArea = DisplayArea.GetFromWindowId(this.AppWindow.Id, DisplayAreaFallback.Nearest); + if (displayArea != null) + { + var windowSize = this.AppWindow.Size; + var centeredPosition = new PointInt32 + { + X = (displayArea.WorkArea.Width - windowSize.Width) / 2, + Y = (displayArea.WorkArea.Height - windowSize.Height) / 2, + }; + this.AppWindow.Move(centeredPosition); + } + } + + private void SettingsCard_Click(object sender, RoutedEventArgs e) + { + if (sender is SettingsCard settingsCard && + settingsCard.DataContext is ModuleHotkeyData moduleData) + { + var moduleType = moduleData.ModuleType; + NavigationService.Navigate(ModuleGpoHelper.GetModulePageType(moduleType)); + this.Close(); + } + } + + private void OnIgnoreConflictClicked(object sender, RoutedEventArgs e) + { + if (sender is CheckBox checkBox && checkBox.DataContext is HotkeyConflictGroupData conflictGroup) + { + // The Click event only fires from user interaction, not programmatic changes + if (checkBox.IsChecked == true) + { + IgnoreConflictGroup(conflictGroup); + } + else + { + UnignoreConflictGroup(conflictGroup); + } + } + } + + private void IgnoreConflictGroup(HotkeyConflictGroupData conflictGroup) + { + try + { + // Ignore all hotkey settings in this conflict group + if (conflictGroup.Modules != null) + { + HotkeySettings hotkey = new(conflictGroup.Hotkey.Win, conflictGroup.Hotkey.Ctrl, conflictGroup.Hotkey.Alt, conflictGroup.Hotkey.Shift, conflictGroup.Hotkey.Key); + ViewModel.IgnoreShortcut(hotkey); + } + } + catch + { + } + } + + private void UnignoreConflictGroup(HotkeyConflictGroupData conflictGroup) + { + try + { + // Unignore all hotkey settings in this conflict group + if (conflictGroup.Modules != null) + { + HotkeySettings hotkey = new(conflictGroup.Hotkey.Win, conflictGroup.Hotkey.Ctrl, conflictGroup.Hotkey.Alt, conflictGroup.Hotkey.Shift, conflictGroup.Hotkey.Key); + ViewModel.UnignoreShortcut(hotkey); + } + } + catch + { + } + } + + private void WindowEx_Closed(object sender, WindowEventArgs args) + { + ViewModel?.Dispose(); + + var mainWindow = App.GetSettingsWindow(); + if (mainWindow != null) + { + mainWindow.CloseHiddenWindow(); + } + + App.ThemeService.ThemeChanged -= OnThemeChanged; + } + + private void Window_Activated_SetIcon(object sender, WindowActivatedEventArgs args) + { + // Set window icon + AppWindow.SetIcon("Assets\\Settings\\icon.ico"); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml new file mode 100644 index 0000000000..73a862f00a --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls"> + <Style TargetType="local:GPOInfoControl"> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:GPOInfoControl"> + <StackPanel Orientation="Vertical"> + <ContentPresenter Content="{TemplateBinding Content}" ContentTemplate="{TemplateBinding ContentTemplate}" /> + <InfoBar + x:Uid="GPO_SettingIsManaged" + Margin="0,4,0,0" + IsClosable="False" + IsOpen="{TemplateBinding ShowWarning}" + IsTabStop="{TemplateBinding ShowWarning}" + Severity="Informational"> + <InfoBar.IconSource> + <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> + </InfoBar.IconSource> + </InfoBar> + </StackPanel> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> +</ResourceDictionary> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml.cs new file mode 100644 index 0000000000..8942d5c4db --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/GPOInfoControl.xaml.cs @@ -0,0 +1,49 @@ +// 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.Runtime.CompilerServices; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +public sealed partial class GPOInfoControl : ContentControl +{ + public static readonly DependencyProperty ShowWarningProperty = + DependencyProperty.Register( + nameof(ShowWarning), + typeof(bool), + typeof(GPOInfoControl), + new PropertyMetadata(false, OnShowWarningPropertyChanged)); + + public bool ShowWarning + { + get => (bool)GetValue(ShowWarningProperty); + set => SetValue(ShowWarningProperty, value); + } + + private static void OnShowWarningPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is GPOInfoControl gpoInfoControl) + { + if (gpoInfoControl.ShowWarning) + { + gpoInfoControl.IsEnabled = false; + } + } + } + + public GPOInfoControl() + { + DefaultStyleKey = typeof(GPOInfoControl); + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock.xaml similarity index 91% rename from src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml rename to src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock.xaml index 1c2366ffbe..a1f446917b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock.xaml @@ -3,6 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"> + <Style BasedOn="{StaticResource DefaultIsEnabledTextBlockStyle}" TargetType="controls:IsEnabledTextBlock" /> + <Style x:Key="DefaultIsEnabledTextBlockStyle" TargetType="controls:IsEnabledTextBlock"> <Setter Property="Foreground" Value="{ThemeResource DefaultTextForegroundThemeBrush}" /> <Setter Property="IsTabStop" Value="False" /> @@ -16,6 +18,7 @@ FontSize="{TemplateBinding FontSize}" FontWeight="{TemplateBinding FontWeight}" Foreground="{TemplateBinding Foreground}" + IsTextSelectionEnabled="{TemplateBinding IsTextSelectionEnabled}" Text="{TemplateBinding Text}" TextWrapping="WrapWholeWords" /> <VisualStateManager.VisualStateGroups> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock/IsEnabledTextBlock.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock.xaml.cs similarity index 70% rename from src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock/IsEnabledTextBlock.cs rename to src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock.xaml.cs index 82c6b3a986..e7f8a52f25 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock/IsEnabledTextBlock.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/IsEnabledTextBlock.xaml.cs @@ -15,7 +15,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { public IsEnabledTextBlock() { - this.Style = (Style)App.Current.Resources["DefaultIsEnabledTextBlockStyle"]; + this.DefaultStyleKey = typeof(KeyVisual); } protected override void OnApplyTemplate() @@ -26,11 +26,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls base.OnApplyTemplate(); } - public static readonly DependencyProperty TextProperty = DependencyProperty.Register( - "Text", - typeof(string), - typeof(IsEnabledTextBlock), - null); + public static readonly DependencyProperty TextProperty = DependencyProperty.Register(nameof(Text), typeof(string), typeof(IsEnabledTextBlock), new PropertyMetadata(null)); [Localizable(true)] public string Text @@ -39,6 +35,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set => SetValue(TextProperty, value); } + public static readonly DependencyProperty IsTextSelectionEnabledProperty = DependencyProperty.Register(nameof(IsTextSelectionEnabled), typeof(bool), typeof(IsEnabledTextBlock), new PropertyMetadata(false)); + + public bool IsTextSelectionEnabled + { + get => (bool)GetValue(IsTextSelectionEnabledProperty); + set => SetValue(IsTextSelectionEnabledProperty, value); + } + private void IsEnabledTextBlock_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) { SetEnabledState(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyCharPresenter.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyCharPresenter.xaml new file mode 100644 index 0000000000..a28874b4ee --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyCharPresenter.xaml @@ -0,0 +1,107 @@ +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:ui="using:CommunityToolkit.WinUI"> + + <Style BasedOn="{StaticResource DefaultKeyCharPresenterStyle}" TargetType="local:KeyCharPresenter" /> + + <Style x:Key="DefaultKeyCharPresenterStyle" TargetType="local:KeyCharPresenter"> + <Setter Property="FontWeight" Value="Normal" /> + <Setter Property="HorizontalAlignment" Value="Left" /> + <Setter Property="IsTabStop" Value="False" /> + <Setter Property="AutomationProperties.AccessibilityView" Value="Raw" /> + <Setter Property="HorizontalContentAlignment" Value="Center" /> + <Setter Property="VerticalContentAlignment" Value="Center" /> + <Setter Property="VerticalAlignment" Value="Center" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:KeyCharPresenter"> + <Grid Height="{TemplateBinding FontSize}"> + <TextBlock + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}" + FontFamily="{TemplateBinding FontFamily}" + FontSize="{TemplateBinding FontSize}" + FontWeight="{TemplateBinding FontWeight}" + Text="{TemplateBinding Content}" + TextLineBounds="Tight" /> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + <Style + x:Key="WindowsKeyCharPresenterStyle" + BasedOn="{StaticResource DefaultKeyCharPresenterStyle}" + TargetType="local:KeyCharPresenter"> + <!-- Scale to visually align the height of the Windows logo and text --> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:KeyCharPresenter"> + <Grid Height="{TemplateBinding FontSize}"> + <Viewbox> + <PathIcon Data="M9 20H0V11H9V20ZM20 20H11V11H20V20ZM9 9H0V0H9V9ZM20 9H11V0H20V9Z" /> + </Viewbox> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + <Style + x:Key="OfficeKeyCharPresenterStyle" + BasedOn="{StaticResource DefaultKeyCharPresenterStyle}" + TargetType="local:KeyCharPresenter"> + <!-- Scale to visually align the height of the Office logo and text --> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:KeyCharPresenter"> + <Grid Height="{TemplateBinding FontSize}"> + <Viewbox> + <PathIcon Data="M1792 405v1238q0 33-10 62t-28 54-44 41-57 27l-555 159q-23 6-47 6-31 0-58-8t-53-24l-363-205q-20-11-31-29t-12-42q0-35 24-59t60-25h470V458L735 584q-43 15-69 53t-26 83v651q0 41-20 73t-55 53l-167 91q-23 12-46 12-40 0-68-28t-28-68V587q0-51 26-96t71-71L949 81q41-23 89-23 17 0 30 2t30 8l555 153q31 9 56 27t44 42 29 54 10 61zm-128 1238V405q0-22-13-38t-34-23l-273-75-64-18-64-18v1586l401-115q21-6 34-22t13-39z" /> + </Viewbox> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + <Style + x:Key="CopilotKeyCharPresenterStyle" + BasedOn="{StaticResource DefaultKeyCharPresenterStyle}" + TargetType="local:KeyCharPresenter"> + <!-- Scale to visually align the height of the Copilot logo and text --> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:KeyCharPresenter"> + <Grid Height="{TemplateBinding FontSize}"> + <Viewbox> + <PathIcon Data="M0 1213q0-60 10-124t27-130 35-129 38-121q18-55 41-119t54-129 70-125 87-106 106-74 129-28h661q59 0 114 17t96 64q30 34 46 72t33 81l22 58q11 29 34 52 23 25 56 31t65 9h4q157 0 238 83t82 240q0 60-10 125t-27 130-35 128-38 121q-18 55-41 119t-54 129-70 125-87 106-106 74-129 28H790q-61 0-107-15t-82-44-61-72-46-98q-11-29-24-60t-35-55q-23-25-51-31t-60-9h-4q-157 0-238-83T0 1213zm598-957q-50 0-93 25t-79 68-67 94-54 108-42 106-31 91q-17 51-35 110t-33 119-26 121-10 114q0 102 43 149t147 47h163q39 0 74-12t64-35 50-53 34-67q19-58 35-115t35-117q35-117 70-232t72-233q23-73 47-147t63-141H598zm452 285q69-29 143-29h281q-18-29-29-59t-21-58-21-54-30-44-46-30-69-11q-32 0-60 9t-48 35q-17 23-31 53t-27 63-23 65-19 60zm-296 867h101q39 0 74-12t66-34 52-52 33-68l58-191 42-140q21-70 43-140 11-36 28-69t43-62h-101q-39 0-74 12t-66 34-52 52-33 68q-15 48-29 96t-29 96q-21 70-41 140t-44 140q-11 36-28 68t-43 62zm814-768q-39 0-74 12t-64 35-50 53-34 68q-56 174-107 347t-106 349q-23 74-47 147t-63 141h427q50 0 93-25t79-68 67-94 54-108 42-106 31-91q16-51 34-110t34-119 26-121 10-114q0-102-43-149t-147-47h-162zm-570 867q-69 29-143 29H564q17 28 29 58t22 58 24 54 32 45 48 30 71 11q31 0 60-8t49-35q15-19 29-50t28-65 24-69 18-58z" /> + </Viewbox> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + <Style + x:Key="GlyphKeyCharPresenterStyle" + BasedOn="{StaticResource DefaultKeyCharPresenterStyle}" + TargetType="local:KeyCharPresenter"> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:KeyCharPresenter"> + <Grid> + <Viewbox> + <FontIcon + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}" + FontSize="{TemplateBinding FontSize}" + FontWeight="{TemplateBinding FontWeight}" + Glyph="{TemplateBinding Content}" /> + </Viewbox> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> +</ResourceDictionary> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyCharPresenter.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyCharPresenter.xaml.cs new file mode 100644 index 0000000000..43ba496712 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyCharPresenter.xaml.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; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices.WindowsRuntime; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +public sealed partial class KeyCharPresenter : Control +{ + public KeyCharPresenter() + { + DefaultStyleKey = typeof(KeyCharPresenter); + } + + public object Content + { + get => (object)GetValue(ContentProperty); + set => SetValue(ContentProperty, value); + } + + public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(nameof(Content), typeof(object), typeof(KeyCharPresenter), new PropertyMetadata(default(string))); +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.cs deleted file mode 100644 index 9d323c636d..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.cs +++ /dev/null @@ -1,191 +0,0 @@ -// 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; -using Microsoft.UI.Xaml.Markup; -using Windows.System; - -namespace Microsoft.PowerToys.Settings.UI.Controls -{ - [TemplatePart(Name = KeyPresenter, Type = typeof(ContentPresenter))] - [TemplateVisualState(Name = "Normal", GroupName = "CommonStates")] - [TemplateVisualState(Name = "Disabled", GroupName = "CommonStates")] - [TemplateVisualState(Name = "Default", GroupName = "StateStates")] - [TemplateVisualState(Name = "Error", GroupName = "StateStates")] - public sealed partial class KeyVisual : Control - { - private const string KeyPresenter = "KeyPresenter"; - private KeyVisual _keyVisual; - private ContentPresenter _keyPresenter; - - public object Content - { - get => (object)GetValue(ContentProperty); - set => SetValue(ContentProperty, value); - } - - public static readonly DependencyProperty ContentProperty = DependencyProperty.Register("Content", typeof(object), typeof(KeyVisual), new PropertyMetadata(default(string), OnContentChanged)); - - public VisualType VisualType - { - get => (VisualType)GetValue(VisualTypeProperty); - set => SetValue(VisualTypeProperty, value); - } - - public static readonly DependencyProperty VisualTypeProperty = DependencyProperty.Register("VisualType", typeof(VisualType), typeof(KeyVisual), new PropertyMetadata(default(VisualType), OnSizeChanged)); - - public bool IsError - { - get => (bool)GetValue(IsErrorProperty); - set => SetValue(IsErrorProperty, value); - } - - public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(KeyVisual), new PropertyMetadata(false, OnIsErrorChanged)); - - public KeyVisual() - { - this.DefaultStyleKey = typeof(KeyVisual); - this.Style = GetStyleSize("TextKeyVisualStyle"); - } - - protected override void OnApplyTemplate() - { - IsEnabledChanged -= KeyVisual_IsEnabledChanged; - _keyVisual = (KeyVisual)this; - _keyPresenter = (ContentPresenter)_keyVisual.GetTemplateChild(KeyPresenter); - Update(); - SetEnabledState(); - SetErrorState(); - IsEnabledChanged += KeyVisual_IsEnabledChanged; - base.OnApplyTemplate(); - } - - private static void OnContentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - ((KeyVisual)d).Update(); - } - - private static void OnSizeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - ((KeyVisual)d).Update(); - } - - private static void OnIsErrorChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) - { - ((KeyVisual)d).SetErrorState(); - } - - private void Update() - { - if (_keyVisual == null) - { - return; - } - - if (_keyVisual.Content != null) - { - if (_keyVisual.Content.GetType() == typeof(string)) - { - _keyVisual.Style = GetStyleSize("TextKeyVisualStyle"); - _keyVisual._keyPresenter.Content = _keyVisual.Content; - } - else - { - _keyVisual.Style = GetStyleSize("IconKeyVisualStyle"); - - switch ((int)_keyVisual.Content) - { - /* We can enable other glyphs in the future - case 13: // The Enter key or button. - _keyVisual._keyPresenter.Content = "\uE751"; break; - - case 8: // The Back key or button. - _keyVisual._keyPresenter.Content = "\uE750"; break; - - case 16: // The right Shift key or button. - case 160: // The left Shift key or button. - case 161: // The Shift key or button. - _keyVisual._keyPresenter.Content = "\uE752"; break; */ - - case 38: _keyVisual._keyPresenter.Content = "\uE0E4"; break; // The Up Arrow key or button. - case 40: _keyVisual._keyPresenter.Content = "\uE0E5"; break; // The Down Arrow key or button. - case 37: _keyVisual._keyPresenter.Content = "\uE0E2"; break; // The Left Arrow key or button. - case 39: _keyVisual._keyPresenter.Content = "\uE0E3"; break; // The Right Arrow key or button. - - case 91: // The left Windows key - case 92: // The right Windows key - PathIcon winIcon = XamlReader.Load(@"<PathIcon xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" Data=""M683 1229H0V546h683v683zm819 0H819V546h683v683zm-819 819H0v-683h683v683zm819 0H819v-683h683v683z"" />") as PathIcon; - Viewbox winIconContainer = new Viewbox(); - winIconContainer.Child = winIcon; - winIconContainer.HorizontalAlignment = HorizontalAlignment.Center; - winIconContainer.VerticalAlignment = VerticalAlignment.Center; - - double iconDimensions = GetIconSize(); - winIconContainer.Height = iconDimensions; - winIconContainer.Width = iconDimensions; - _keyVisual._keyPresenter.Content = winIconContainer; - break; - default: _keyVisual._keyPresenter.Content = ((VirtualKey)_keyVisual.Content).ToString(); break; - } - } - } - } - - public Style GetStyleSize(string styleName) - { - if (VisualType == VisualType.Small) - { - return (Style)App.Current.Resources["Small" + styleName]; - } - else if (VisualType == VisualType.SmallOutline) - { - return (Style)App.Current.Resources["SmallOutline" + styleName]; - } - else if (VisualType == VisualType.TextOnly) - { - return (Style)App.Current.Resources["Only" + styleName]; - } - else - { - return (Style)App.Current.Resources["Default" + styleName]; - } - } - - public double GetIconSize() - { - if (VisualType == VisualType.Small || VisualType == VisualType.SmallOutline) - { - return (double)App.Current.Resources["SmallIconSize"]; - } - else - { - return (double)App.Current.Resources["DefaultIconSize"]; - } - } - - private void KeyVisual_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) - { - SetEnabledState(); - } - - private void SetErrorState() - { - VisualStateManager.GoToState(this, IsError ? "Error" : "Default", true); - } - - private void SetEnabledState() - { - VisualStateManager.GoToState(this, IsEnabled ? "Normal" : "Disabled", true); - } - } - - public enum VisualType - { - Small, - SmallOutline, - TextOnly, - Large, - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml index 00192a215a..9c820ba12c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml @@ -1,66 +1,79 @@ <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"> + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls"> - <x:Double x:Key="DefaultIconSize">16</x:Double> - <x:Double x:Key="SmallIconSize">12</x:Double> - <Style x:Key="DefaultTextKeyVisualStyle" TargetType="controls:KeyVisual"> - <Setter Property="MinWidth" Value="56" /> - <Setter Property="MinHeight" Value="48" /> - <Setter Property="Background" Value="{ThemeResource AccentButtonBackground}" /> - <Setter Property="Foreground" Value="{ThemeResource AccentButtonForeground}" /> - <Setter Property="BorderBrush" Value="{ThemeResource AccentButtonBorderBrush}" /> + <Style BasedOn="{StaticResource DefaultKeyVisualStyle}" TargetType="local:KeyVisual" /> + + <Style x:Key="DefaultKeyVisualStyle" TargetType="local:KeyVisual"> + <Setter Property="MinWidth" Value="16" /> + <Setter Property="AutomationProperties.AccessibilityView" Value="Raw" /> + <Setter Property="IsTabStop" Value="False" /> + <Setter Property="MinHeight" Value="16" /> + <Setter Property="Background" Value="{ThemeResource CardBackgroundFillColorSecondaryBrush}" /> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" /> + <Setter Property="BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" /> <Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" /> - <Setter Property="Padding" Value="16,8,16,8" /> - <Setter Property="FontWeight" Value="SemiBold" /> - <Setter Property="HorizontalAlignment" Value="Center" /> + <Setter Property="Padding" Value="4,4,4,4" /> + <Setter Property="FontWeight" Value="Normal" /> + <Setter Property="FontSize" Value="14" /> + <Setter Property="CornerRadius" Value="2" /> + <Setter Property="BackgroundSizing" Value="InnerBorderEdge" /> + <Setter Property="HorizontalAlignment" Value="Left" /> <Setter Property="HorizontalContentAlignment" Value="Center" /> - <Setter Property="FontSize" Value="18" /> + <Setter Property="VerticalContentAlignment" Value="Center" /> + <Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="Template"> <Setter.Value> - <ControlTemplate TargetType="controls:KeyVisual"> - <Grid> - <Grid> - <Rectangle - x:Name="ContentHolder" - Height="{TemplateBinding Height}" - MinWidth="{TemplateBinding MinWidth}" - Fill="{TemplateBinding Background}" - RadiusX="4" - RadiusY="4" - Stroke="{TemplateBinding BorderBrush}" - StrokeThickness="{TemplateBinding BorderThickness}" /> - <ContentPresenter - x:Name="KeyPresenter" - Margin="{TemplateBinding Padding}" - HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" - VerticalAlignment="Center" - Content="{TemplateBinding Content}" - FontSize="{TemplateBinding FontSize}" - FontWeight="{TemplateBinding FontWeight}" - Foreground="{TemplateBinding Foreground}" /> - </Grid> + <ControlTemplate TargetType="local:KeyVisual"> + <Grid + x:Name="KeyHolder" + MinWidth="{TemplateBinding MinWidth}" + MinHeight="{TemplateBinding MinHeight}" + HorizontalAlignment="{TemplateBinding HorizontalAlignment}" + VerticalAlignment="{TemplateBinding VerticalAlignment}" + Background="{TemplateBinding Background}" + BackgroundSizing="{TemplateBinding BackgroundSizing}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{TemplateBinding CornerRadius}"> + <Grid.BackgroundTransition> + <BrushTransition Duration="0:0:0.083" /> + </Grid.BackgroundTransition> + <local:KeyCharPresenter + x:Name="KeyPresenter" + Margin="{TemplateBinding Padding}" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}" + AutomationProperties.AccessibilityView="Raw" + Content="{TemplateBinding Content}" + FontSize="{TemplateBinding FontSize}" + FontWeight="{TemplateBinding FontWeight}" + Foreground="{TemplateBinding Foreground}" /> <VisualStateManager.VisualStateGroups> <VisualStateGroup x:Name="CommonStates"> <VisualState x:Name="Normal" /> <VisualState x:Name="Disabled"> <VisualState.Setters> - <Setter Target="ContentHolder.Fill" Value="{ThemeResource AccentButtonBackgroundDisabled}" /> - <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource AccentButtonForegroundDisabled}" /> - <Setter Target="ContentHolder.Stroke" Value="{ThemeResource AccentButtonBorderBrushDisabled}" /> - <!--<Setter Target="ContentHolder.StrokeThickness" Value="{TemplateBinding BorderThickness}" />--> + <Setter Target="KeyHolder.Background" Value="{ThemeResource SubtleFillColorTransparentBrush}" /> + <Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource CardStrokeColorDefaultSolidBrush}" /> + <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource ControlStrokeColorDefaultBrush}" /> </VisualState.Setters> </VisualState> - </VisualStateGroup> - <VisualStateGroup x:Name="StateStates"> - <VisualState x:Name="Default" /> - <VisualState x:Name="Error"> + <VisualState x:Name="Invalid"> <VisualState.Setters> - <Setter Target="ContentHolder.Fill" Value="{ThemeResource InfoBarErrorSeverityBackgroundBrush}" /> - <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource InfoBarErrorSeverityIconBackground}" /> - <Setter Target="ContentHolder.Stroke" Value="{ThemeResource InfoBarErrorSeverityIconBackground}" /> - <Setter Target="ContentHolder.StrokeThickness" Value="2" /> + <Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCriticalBackgroundBrush}" /> + <Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" /> + <Setter Target="KeyHolder.BorderThickness" Value="1" /> + <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="Warning"> + <VisualState.Setters> + <Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCautionBackgroundBrush}" /> + <Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCautionBrush}" /> + <Setter Target="KeyHolder.BorderThickness" Value="1" /> + <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" /> </VisualState.Setters> </VisualState> </VisualStateGroup> @@ -72,103 +85,129 @@ </Style> <Style - x:Key="SmallTextKeyVisualStyle" - BasedOn="{StaticResource DefaultTextKeyVisualStyle}" - TargetType="controls:KeyVisual"> - <Setter Property="MinWidth" Value="40" /> - <Setter Property="Height" Value="36" /> - <Setter Property="FontWeight" Value="SemiBold" /> - <Setter Property="Padding" Value="12,0,12,2" /> - <Setter Property="FontSize" Value="14" /> - <Setter Property="HorizontalContentAlignment" Value="Center" /> + x:Key="SubtleKeyVisualStyle" + BasedOn="{StaticResource DefaultKeyVisualStyle}" + TargetType="local:KeyVisual"> + <Setter Property="Background" Value="{ThemeResource SubtleFillColorTransparentBrush}" /> + <Setter Property="BorderBrush" Value="{ThemeResource SubtleFillColorTransparentBrush}" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:KeyVisual"> + <Grid + x:Name="KeyHolder" + MinWidth="{TemplateBinding MinWidth}" + MinHeight="{TemplateBinding MinHeight}" + HorizontalAlignment="{TemplateBinding HorizontalAlignment}" + VerticalAlignment="{TemplateBinding VerticalAlignment}" + Background="{TemplateBinding Background}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{TemplateBinding CornerRadius}"> + <Grid.BackgroundTransition> + <BrushTransition Duration="0:0:0.083" /> + </Grid.BackgroundTransition> + <local:KeyCharPresenter + x:Name="KeyPresenter" + Margin="{TemplateBinding Padding}" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}" + AutomationProperties.AccessibilityView="Raw" + Content="{TemplateBinding Content}" + FontSize="{TemplateBinding FontSize}" + FontWeight="{TemplateBinding FontWeight}" + Foreground="{TemplateBinding Foreground}" /> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Normal" /> + <VisualState x:Name="Disabled"> + <VisualState.Setters> + <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource TextFillColorDisabledBrush}" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="Invalid"> + <VisualState.Setters> + <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="Warning"> + <VisualState.Setters> + <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> </Style> <Style - x:Key="SmallOutlineTextKeyVisualStyle" - BasedOn="{StaticResource DefaultTextKeyVisualStyle}" - TargetType="controls:KeyVisual"> - <Setter Property="MinWidth" Value="40" /> - <Setter Property="Background" Value="{ThemeResource ButtonBackground}" /> - <Setter Property="Foreground" Value="{ThemeResource ButtonForeground}" /> - <Setter Property="BorderBrush" Value="{ThemeResource ButtonBorderBrush}" /> - <Setter Property="Height" Value="36" /> - <Setter Property="FontWeight" Value="SemiBold" /> - <Setter Property="Padding" Value="8,0,8,2" /> - <Setter Property="FontSize" Value="13" /> - <Setter Property="HorizontalContentAlignment" Value="Center" /> - </Style> - - - - <Style - x:Key="DefaultIconKeyVisualStyle" - BasedOn="{StaticResource DefaultTextKeyVisualStyle}" - TargetType="controls:KeyVisual"> - <Setter Property="MinWidth" Value="56" /> - <Setter Property="MinHeight" Value="48" /> - <Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" /> - <Setter Property="Padding" Value="16,8,16,8" /> - <Setter Property="FontSize" Value="14" /> - <Setter Property="HorizontalContentAlignment" Value="Center" /> - </Style> - - <Style - x:Key="SmallIconKeyVisualStyle" - BasedOn="{StaticResource DefaultTextKeyVisualStyle}" - TargetType="controls:KeyVisual"> - <Setter Property="MinWidth" Value="40" /> - <Setter Property="Height" Value="36" /> - <Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" /> - <Setter Property="FontWeight" Value="Normal" /> - <Setter Property="Padding" Value="0" /> - <Setter Property="FontSize" Value="10" /> - <Setter Property="HorizontalContentAlignment" Value="Center" /> - </Style> - - <Style - x:Key="SmallOutlineIconKeyVisualStyle" - BasedOn="{StaticResource DefaultTextKeyVisualStyle}" - TargetType="controls:KeyVisual"> - <Setter Property="MinWidth" Value="40" /> - <Setter Property="Background" Value="{ThemeResource ButtonBackground}" /> - <Setter Property="Foreground" Value="{ThemeResource ButtonForeground}" /> - <Setter Property="BorderBrush" Value="{ThemeResource ButtonBorderBrush}" /> - <Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" /> - <Setter Property="Height" Value="36" /> - <Setter Property="FontWeight" Value="SemiBold" /> - <Setter Property="Padding" Value="0" /> - <Setter Property="FontSize" Value="9" /> - <Setter Property="HorizontalContentAlignment" Value="Center" /> - </Style> - - <Style - x:Key="OnlyTextKeyVisualStyle" - BasedOn="{StaticResource DefaultTextKeyVisualStyle}" - TargetType="controls:KeyVisual"> - <Setter Property="MinHeight" Value="12" /> - <Setter Property="MinWidth" Value="12" /> - <Setter Property="Background" Value="Transparent" /> - <Setter Property="Foreground" Value="{ThemeResource ButtonForeground}" /> - <Setter Property="BorderBrush" Value="Transparent" /> - <Setter Property="FontWeight" Value="Normal" /> - <Setter Property="Padding" Value="0" /> - <Setter Property="FontSize" Value="12" /> - <Setter Property="HorizontalContentAlignment" Value="Center" /> - </Style> - - <Style - x:Key="OnlyIconKeyVisualStyle" - BasedOn="{StaticResource DefaultTextKeyVisualStyle}" - TargetType="controls:KeyVisual"> - <Setter Property="MinHeight" Value="10" /> - <Setter Property="MinWidth" Value="10" /> - <Setter Property="Background" Value="Transparent" /> - <Setter Property="Foreground" Value="{ThemeResource ButtonForeground}" /> - <Setter Property="BorderBrush" Value="Transparent" /> - <Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" /> - <Setter Property="FontWeight" Value="Normal" /> - <Setter Property="Padding" Value="0,0,0,3" /> - <!--<Setter Property="FontSize" Value="9" />--> - <Setter Property="HorizontalContentAlignment" Value="Center" /> + x:Key="AccentKeyVisualStyle" + BasedOn="{StaticResource DefaultKeyVisualStyle}" + TargetType="local:KeyVisual"> + <Setter Property="Background" Value="{ThemeResource AccentFillColorDefaultBrush}" /> + <Setter Property="Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" /> + <Setter Property="BorderBrush" Value="{ThemeResource AccentControlElevationBorderBrush}" /> + <Setter Property="BackgroundSizing" Value="OuterBorderEdge" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:KeyVisual"> + <Grid + x:Name="KeyHolder" + MinWidth="{TemplateBinding MinWidth}" + MinHeight="{TemplateBinding MinHeight}" + HorizontalAlignment="{TemplateBinding HorizontalAlignment}" + VerticalAlignment="{TemplateBinding VerticalAlignment}" + AutomationProperties.AccessibilityView="Raw" + Background="{TemplateBinding Background}" + BackgroundSizing="{TemplateBinding BackgroundSizing}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{TemplateBinding CornerRadius}"> + <Grid.BackgroundTransition> + <BrushTransition Duration="0:0:0.083" /> + </Grid.BackgroundTransition> + <local:KeyCharPresenter + x:Name="KeyPresenter" + Margin="{TemplateBinding Padding}" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}" + Content="{TemplateBinding Content}" + FontSize="{TemplateBinding FontSize}" + FontWeight="{TemplateBinding FontWeight}" + Foreground="{TemplateBinding Foreground}" /> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Normal" /> + <VisualState x:Name="Disabled"> + <VisualState.Setters> + <Setter Target="KeyHolder.Background" Value="{ThemeResource AccentButtonBackgroundDisabled}" /> + <Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource AccentButtonBorderBrushDisabled}" /> + <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource AccentButtonForegroundDisabled}" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="Invalid"> + <VisualState.Setters> + <Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCriticalBackgroundBrush}" /> + <Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" /> + <Setter Target="KeyHolder.BorderThickness" Value="1" /> + <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="Warning"> + <VisualState.Setters> + <Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCautionBackgroundBrush}" /> + <Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCautionBrush}" /> + <Setter Target="KeyHolder.BorderThickness" Value="1" /> + <Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> </Style> </ResourceDictionary> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml.cs new file mode 100644 index 0000000000..87dc9a4c21 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/KeyVisual/KeyVisual.xaml.cs @@ -0,0 +1,193 @@ +// 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; +using Windows.System; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + [TemplatePart(Name = KeyPresenter, Type = typeof(KeyCharPresenter))] + [TemplateVisualState(Name = NormalState, GroupName = "CommonStates")] + [TemplateVisualState(Name = DisabledState, GroupName = "CommonStates")] + [TemplateVisualState(Name = InvalidState, GroupName = "CommonStates")] + [TemplateVisualState(Name = WarningState, GroupName = "CommonStates")] + public sealed partial class KeyVisual : Control + { + private const string KeyPresenter = "KeyPresenter"; + private const string NormalState = "Normal"; + private const string DisabledState = "Disabled"; + private const string InvalidState = "Invalid"; + private const string WarningState = "Warning"; + private KeyCharPresenter _keyPresenter; + + public object Content + { + get => (object)GetValue(ContentProperty); + set => SetValue(ContentProperty, value); + } + + public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(nameof(Content), typeof(object), typeof(KeyVisual), new PropertyMetadata(default(string), OnContentChanged)); + + public State State + { + get => (State)GetValue(StateProperty); + set => SetValue(StateProperty, value); + } + + public static readonly DependencyProperty StateProperty = DependencyProperty.Register(nameof(State), typeof(State), typeof(KeyVisual), new PropertyMetadata(State.Normal, OnStateChanged)); + + public bool RenderKeyAsGlyph + { + get => (bool)GetValue(RenderKeyAsGlyphProperty); + set => SetValue(RenderKeyAsGlyphProperty, value); + } + + public static readonly DependencyProperty RenderKeyAsGlyphProperty = DependencyProperty.Register(nameof(RenderKeyAsGlyph), typeof(bool), typeof(KeyVisual), new PropertyMetadata(false, OnContentChanged)); + + public KeyVisual() + { + this.DefaultStyleKey = typeof(KeyVisual); + } + + protected override void OnApplyTemplate() + { + IsEnabledChanged -= KeyVisual_IsEnabledChanged; + _keyPresenter = (KeyCharPresenter)this.GetTemplateChild(KeyPresenter); + Update(); + SetVisualStates(); + IsEnabledChanged += KeyVisual_IsEnabledChanged; + base.OnApplyTemplate(); + } + + private static void OnContentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((KeyVisual)d).SetVisualStates(); + } + + private static void OnStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((KeyVisual)d).SetVisualStates(); + } + + private void SetVisualStates() + { + if (this != null) + { + if (State == State.Error) + { + VisualStateManager.GoToState(this, InvalidState, true); + } + else if (State == State.Warning) + { + VisualStateManager.GoToState(this, WarningState, true); + } + else if (!IsEnabled) + { + VisualStateManager.GoToState(this, DisabledState, true); + } + else + { + VisualStateManager.GoToState(this, NormalState, true); + } + } + } + + private void Update() + { + if (Content == null) + { + return; + } + + if (Content is string key) + { + switch (key) + { + case "Copilot": + _keyPresenter.Style = (Style)Application.Current.Resources["CopilotKeyCharPresenterStyle"]; + break; + + case "Office": + _keyPresenter.Style = (Style)Application.Current.Resources["OfficeKeyCharPresenterStyle"]; + break; + + default: + _keyPresenter.Style = (Style)Application.Current.Resources["DefaultKeyCharPresenterStyle"]; + break; + } + + return; + } + + if (Content is int keyCode) + { + VirtualKey virtualKey = (VirtualKey)keyCode; + switch (virtualKey) + { + case VirtualKey.Enter: + SetGlyphOrText("\uE751", virtualKey); + break; + + case VirtualKey.Back: + SetGlyphOrText("\uE750", virtualKey); + break; + + case VirtualKey.Shift: + case (VirtualKey)160: // Left Shift + case (VirtualKey)161: // Right Shift + SetGlyphOrText("\uE752", virtualKey); + break; + + case VirtualKey.Up: + _keyPresenter.Content = "\uE0E4"; + break; + + case VirtualKey.Down: + _keyPresenter.Content = "\uE0E5"; + break; + + case VirtualKey.Left: + _keyPresenter.Content = "\uE0E2"; + break; + + case VirtualKey.Right: + _keyPresenter.Content = "\uE0E3"; + break; + + case VirtualKey.LeftWindows: + case VirtualKey.RightWindows: + _keyPresenter.Style = (Style)Application.Current.Resources["WindowsKeyCharPresenterStyle"]; + break; + } + } + } + + private void SetGlyphOrText(string glyph, VirtualKey key) + { + if (RenderKeyAsGlyph) + { + _keyPresenter.Content = glyph; + _keyPresenter.Style = (Style)Application.Current.Resources["GlyphKeyCharPresenterStyle"]; + } + else + { + _keyPresenter.Content = key.ToString(); + _keyPresenter.Style = (Style)Application.Current.Resources["DefaultKeyCharPresenterStyle"]; + } + } + + private void KeyVisual_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + SetVisualStates(); + } + } + + public enum State + { + Normal, + Error, + Warning, + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml new file mode 100644 index 0000000000..0177295546 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml @@ -0,0 +1,216 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.PowerToys.Settings.UI.Controls.FoundryLocalModelPicker" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:models="using:LanguageModelProvider" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" + xmlns:toolkit="using:CommunityToolkit.WinUI.Controls" + x:Name="Root" + mc:Ignorable="d"> + + <UserControl.Resources> + <tkconverters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> + <Style x:Key="TagBorderStyle" TargetType="Border"> + <Setter Property="Background" Value="{ThemeResource LayerFillColorDefaultBrush}" /> + <Setter Property="BorderBrush" Value="{ThemeResource ControlStrongStrokeColorDefaultBrush}" /> + <Setter Property="BorderThickness" Value="1" /> + <Setter Property="CornerRadius" Value="8" /> + <Setter Property="Padding" Value="8,2" /> + <Setter Property="VerticalAlignment" Value="Center" /> + </Style> + <Style x:Key="TagTextStyle" TargetType="TextBlock"> + <Setter Property="FontSize" Value="12" /> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" /> + <Setter Property="TextWrapping" Value="NoWrap" /> + </Style> + </UserControl.Resources> + + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <InfoBar + x:Uid="AdvancedPaste_FL_PreviewMessage" + Grid.Row="1" + Padding="4" + IsClosable="False" + IsOpen="True"> + <InfoBar.ActionButton> + <HyperlinkButton x:Uid="AdvancedPaste_FL_LearnMoreFoundryLocal" NavigateUri="https://learn.microsoft.com/azure/ai-foundry/foundry-local/what-is-foundry-local" /> + </InfoBar.ActionButton> + </InfoBar> + <StackPanel + x:Name="LoadingPanel" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Spacing="12"> + <ProgressRing + x:Name="LoadingIndicator" + Width="36" + Height="36" + HorizontalAlignment="Center" /> + <TextBlock + x:Name="LoadingStatusTextBlock" + x:Uid="AdvancedPaste_FL_LoadingStatus" + HorizontalAlignment="Center" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextAlignment="Center" + TextWrapping="Wrap" /> + </StackPanel> + + <ScrollViewer x:Name="ModelsView" Visibility="Collapsed"> + <Grid Padding="0,12,0,16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <StackPanel + x:Name="NoModelsPanel" + Grid.Row="0" + Margin="0,0,0,16" + HorizontalAlignment="Center" + Orientation="Vertical" + Spacing="4"> + <FontIcon + AutomationProperties.AccessibilityView="Raw" + FontSize="24" + Glyph="" /> + <TextBlock + x:Uid="AdvancedPaste_FL_NoModelsDownloaded" + HorizontalAlignment="Center" + Style="{StaticResource BodyStrongTextBlockStyle}" + TextAlignment="Center" /> + <TextBlock + x:Uid="AdvancedPaste_FL_RunFoundryLocalText" + HorizontalAlignment="Center" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextAlignment="Center" + TextWrapping="Wrap" /> + <Button + x:Name="LaunchFoundryModelListButton" + x:Uid="AdvancedPaste_FL_OpenFoundryModelList" + Margin="0,8,0,0" + HorizontalAlignment="Center" + Click="LaunchFoundryModelListButton_Click" + Style="{StaticResource AccentButtonStyle}" /> + </StackPanel> + + <StackPanel Grid.Row="1" Spacing="12"> + <Grid ColumnSpacing="4"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <ComboBox + x:Name="CachedModelsComboBox" + Grid.Column="0" + HorizontalAlignment="Stretch" + DisplayMemberPath="Name" + ItemsSource="{x:Bind CachedModels, Mode=OneWay}" + SelectedItem="{x:Bind SelectedModel, Mode=TwoWay}" + SelectionChanged="CachedModelsComboBox_SelectionChanged"> + <ComboBox.Header> + <TextBlock> + <Run x:Uid="AdvancedPaste_FL_LocalModel" /><LineBreak /><Run + x:Uid="AdvancedPaste_FL_UseCliToDownloadModels" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </TextBlock> + </ComboBox.Header> + </ComboBox> + <Button + x:Name="RefreshModelsButton" + Grid.Column="1" + MinHeight="32" + VerticalAlignment="Bottom" + Click="RefreshModelsButton_Click" + Style="{StaticResource SubtleButtonStyle}"> + <FontIcon FontSize="16" Glyph="" /> + <ToolTipService.ToolTip> + <TextBlock x:Uid="AdvancedPaste_FL_RefreshModelList" /> + </ToolTipService.ToolTip> + </Button> + </Grid> + <StackPanel + x:Name="SelectedModelDetailsPanel" + Spacing="8" + Visibility="Collapsed"> + <TextBlock + x:Name="SelectedModelDescriptionText" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + <toolkit:WrapPanel + x:Name="SelectedModelTagsPanel" + HorizontalSpacing="4" + VerticalSpacing="4" + Visibility="Collapsed" /> + </StackPanel> + </StackPanel> + </Grid> + </ScrollViewer> + + <Grid x:Name="NotAvailableGrid" Visibility="Collapsed"> + <StackPanel + Margin="48,0,48,48" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Orientation="Vertical" + Spacing="8"> + <Image Width="36" Source="ms-appx:///Assets/Settings/Icons/Models/FoundryLocal.svg" /> + <TextBlock + x:Uid="AdvancedPaste_FL_FLNotAvailableYet" + HorizontalAlignment="Center" + FontWeight="SemiBold" + TextAlignment="Center" + TextWrapping="Wrap" /> + <TextBlock + x:Uid="AdvancedPaste_FL_StartService" + HorizontalAlignment="Center" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + IsTextSelectionEnabled="True" + TextAlignment="Center" + TextWrapping="Wrap" /> + <HyperlinkButton + x:Uid="AdvancedPaste_FL_CLIGuide" + HorizontalAlignment="Center" + NavigateUri="https://learn.microsoft.com/azure/ai-foundry/foundry-local/get-started" /> + <TextBlock + x:Uid="FoundryLocal_RestartRequiredNote" + HorizontalAlignment="Center" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextAlignment="Center" + TextWrapping="Wrap" /> + </StackPanel> + </Grid> + + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="StateGroup"> + <VisualState x:Name="ShowLoading" /> + <VisualState x:Name="ShowModels"> + <VisualState.Setters> + <Setter Target="LoadingPanel.Visibility" Value="Collapsed" /> + <Setter Target="NotAvailableGrid.Visibility" Value="Collapsed" /> + <Setter Target="ModelsView.Visibility" Value="Visible" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="ShowNotAvailable"> + <VisualState.Setters> + <Setter Target="LoadingPanel.Visibility" Value="Collapsed" /> + <Setter Target="NotAvailableGrid.Visibility" Value="Visible" /> + <Setter Target="ModelsView.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> +</UserControl> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs new file mode 100644 index 0000000000..400074f9d3 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ModelPicker/FoundryLocalModelPicker.xaml.cs @@ -0,0 +1,447 @@ +// 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; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using LanguageModelProvider; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +public sealed partial class FoundryLocalModelPicker : UserControl +{ + private INotifyCollectionChanged _cachedModelsSubscription; + private INotifyCollectionChanged _downloadableModelsSubscription; + private bool _suppressSelection; + + public FoundryLocalModelPicker() + { + InitializeComponent(); + Loaded += (_, _) => UpdateVisualStates(); + } + + public delegate void ModelSelectionChangedEventHandler(object sender, ModelDetails model); + + public delegate void DownloadRequestedEventHandler(object sender, object payload); + + public delegate void LoadRequestedEventHandler(object sender); + + public event ModelSelectionChangedEventHandler SelectionChanged; + + public event LoadRequestedEventHandler LoadRequested; + + public IEnumerable<ModelDetails> CachedModels + { + get => (IEnumerable<ModelDetails>)GetValue(CachedModelsProperty); + set => SetValue(CachedModelsProperty, value); + } + + public static readonly DependencyProperty CachedModelsProperty = + DependencyProperty.Register(nameof(CachedModels), typeof(IEnumerable<ModelDetails>), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnCachedModelsChanged)); + + public IEnumerable DownloadableModels + { + get => (IEnumerable)GetValue(DownloadableModelsProperty); + set => SetValue(DownloadableModelsProperty, value); + } + + public static readonly DependencyProperty DownloadableModelsProperty = + DependencyProperty.Register(nameof(DownloadableModels), typeof(IEnumerable), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnDownloadableModelsChanged)); + + public ModelDetails SelectedModel + { + get => (ModelDetails)GetValue(SelectedModelProperty); + set => SetValue(SelectedModelProperty, value); + } + + public static readonly DependencyProperty SelectedModelProperty = + DependencyProperty.Register(nameof(SelectedModel), typeof(ModelDetails), typeof(FoundryLocalModelPicker), new PropertyMetadata(null, OnSelectedModelChanged)); + + public bool IsLoading + { + get => (bool)GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public static readonly DependencyProperty IsLoadingProperty = + DependencyProperty.Register(nameof(IsLoading), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public bool IsAvailable + { + get => (bool)GetValue(IsAvailableProperty); + set => SetValue(IsAvailableProperty, value); + } + + public static readonly DependencyProperty IsAvailableProperty = + DependencyProperty.Register(nameof(IsAvailable), typeof(bool), typeof(FoundryLocalModelPicker), new PropertyMetadata(false, OnStatePropertyChanged)); + + public string StatusText + { + get => (string)GetValue(StatusTextProperty); + set => SetValue(StatusTextProperty, value); + } + + public static readonly DependencyProperty StatusTextProperty = + DependencyProperty.Register(nameof(StatusText), typeof(string), typeof(FoundryLocalModelPicker), new PropertyMetadata(string.Empty, OnStatePropertyChanged)); + + public bool HasCachedModels => CachedModels?.Any() ?? false; + + public bool HasDownloadableModels => DownloadableModels?.Cast<object>().Any() ?? false; + + public void RequestLoad() + { + if (IsLoading) + { + // Allow refresh requests to continue even if already loading by cancelling via host. + } + else + { + IsLoading = true; + } + + IsAvailable = false; + StatusText = "Loading Foundry Local status..."; + LoadRequested?.Invoke(this); + } + + private static void OnCachedModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToCachedModels(e.OldValue as IEnumerable<ModelDetails>, e.NewValue as IEnumerable<ModelDetails>); + control.UpdateVisualStates(); + } + + private static void OnDownloadableModelsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.SubscribeToDownloadableModels(e.OldValue as IEnumerable, e.NewValue as IEnumerable); + control.UpdateVisualStates(); + } + + private static void OnSelectedModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + if (control._suppressSelection) + { + return; + } + + try + { + control._suppressSelection = true; + if (control.CachedModelsComboBox is not null) + { + control.CachedModelsComboBox.SelectedItem = e.NewValue; + } + } + finally + { + control._suppressSelection = false; + } + + control.UpdateSelectedModelDetails(); + } + + private static void OnStatePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (FoundryLocalModelPicker)d; + control.UpdateVisualStates(); + } + + private void SubscribeToCachedModels(IEnumerable<ModelDetails> oldValue, IEnumerable<ModelDetails> newValue) + { + if (_cachedModelsSubscription is not null) + { + _cachedModelsSubscription.CollectionChanged -= CachedModels_CollectionChanged; + _cachedModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += CachedModels_CollectionChanged; + _cachedModelsSubscription = observable; + } + } + + private void SubscribeToDownloadableModels(IEnumerable oldValue, IEnumerable newValue) + { + if (_downloadableModelsSubscription is not null) + { + _downloadableModelsSubscription.CollectionChanged -= DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = null; + } + + if (newValue is INotifyCollectionChanged observable) + { + observable.CollectionChanged += DownloadableModels_CollectionChanged; + _downloadableModelsSubscription = observable; + } + } + + private void CachedModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void DownloadableModels_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + UpdateVisualStates(); + } + + private void CachedModelsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_suppressSelection) + { + return; + } + + try + { + _suppressSelection = true; + var selected = CachedModelsComboBox.SelectedItem as ModelDetails; + SetValue(SelectedModelProperty, selected); + SelectionChanged?.Invoke(this, selected); + } + finally + { + _suppressSelection = false; + } + + UpdateSelectedModelDetails(); + } + + private void UpdateSelectedModelDetails() + { + if (SelectedModelDetailsPanel is null || SelectedModelDescriptionText is null || SelectedModelTagsPanel is null) + { + return; + } + + if (!HasCachedModels || SelectedModel is not ModelDetails model) + { + SelectedModelDetailsPanel.Visibility = Visibility.Collapsed; + SelectedModelDescriptionText.Text = string.Empty; + SelectedModelTagsPanel.Children.Clear(); + SelectedModelTagsPanel.Visibility = Visibility.Collapsed; + return; + } + + SelectedModelDetailsPanel.Visibility = Visibility.Visible; + SelectedModelDescriptionText.Text = string.IsNullOrWhiteSpace(model.Description) + ? "No description provided." + : model.Description; + + SelectedModelTagsPanel.Children.Clear(); + + AddTag(GetModelSizeText(model.Size)); + AddTag(GetLicenseShortText(model.License), model.License); + + foreach (var deviceTag in GetDeviceTags(model.HardwareAccelerators)) + { + AddTag(deviceTag); + } + + SelectedModelTagsPanel.Visibility = SelectedModelTagsPanel.Children.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + + void AddTag(string text, string tooltip = null) + { + if (string.IsNullOrWhiteSpace(text) || SelectedModelTagsPanel is null) + { + return; + } + + Border tag = new(); + if (Resources.TryGetValue("TagBorderStyle", out var borderStyleObj) && borderStyleObj is Style borderStyle) + { + tag.Style = borderStyle; + } + + TextBlock label = new() + { + Text = text, + }; + + if (Resources.TryGetValue("TagTextStyle", out var textStyleObj) && textStyleObj is Style textStyle) + { + label.Style = textStyle; + } + + tag.Child = label; + + if (!string.IsNullOrWhiteSpace(tooltip)) + { + ToolTipService.SetToolTip(tag, new TextBlock + { + Text = tooltip, + TextWrapping = TextWrapping.Wrap, + }); + } + + SelectedModelTagsPanel.Children.Add(tag); + } + } + + private void LaunchFoundryModelListButton_Click(object sender, RoutedEventArgs e) + { + try + { + ProcessStartInfo processInfo = new() + { + FileName = "powershell.exe", + Arguments = "-NoExit -Command \"foundry model list\"", + UseShellExecute = true, + }; + + Process.Start(processInfo); + StatusText = "Opening PowerShell and running 'foundry model list'..."; + } + catch (Exception ex) + { + StatusText = $"Unable to start PowerShell. {ex.Message}"; + Debug.WriteLine($"[FoundryLocalModelPicker] Failed to run 'foundry model list': {ex}"); + } + } + + private void RefreshModelsButton_Click(object sender, RoutedEventArgs e) + { + RequestLoad(); + } + + private void UpdateVisualStates() + { + LoadingIndicator.IsActive = IsLoading; + + if (IsLoading) + { + VisualStateManager.GoToState(this, "ShowLoading", true); + } + else if (!IsAvailable) + { + VisualStateManager.GoToState(this, "ShowNotAvailable", true); + } + else + { + VisualStateManager.GoToState(this, "ShowModels", true); + } + + if (LoadingStatusTextBlock is not null) + { + LoadingStatusTextBlock.Text = string.IsNullOrWhiteSpace(StatusText) + ? "Loading Foundry Local status..." + : StatusText; + } + + NoModelsPanel.Visibility = HasCachedModels ? Visibility.Collapsed : Visibility.Visible; + if (CachedModelsComboBox is not null) + { + CachedModelsComboBox.Visibility = HasCachedModels ? Visibility.Visible : Visibility.Collapsed; + CachedModelsComboBox.IsEnabled = HasCachedModels; + } + + UpdateSelectedModelDetails(); + + Bindings.Update(); + } + + public static string GetModelSizeText(long size) + { + if (size <= 0) + { + return string.Empty; + } + + const long kiloByte = 1024; + const long megaByte = kiloByte * 1024; + const long gigaByte = megaByte * 1024; + + if (size >= gigaByte) + { + return $"{size / (double)gigaByte:0.##} GB"; + } + + if (size >= megaByte) + { + return $"{size / (double)megaByte:0.##} MB"; + } + + if (size >= kiloByte) + { + return $"{size / (double)kiloByte:0.##} KB"; + } + + return $"{size} B"; + } + + public static Visibility GetModelSizeVisibility(long size) + { + return size > 0 ? Visibility.Visible : Visibility.Collapsed; + } + + public static IEnumerable<string> GetDeviceTags(IReadOnlyCollection<HardwareAccelerator> accelerators) + { + if (accelerators is null || accelerators.Count == 0) + { + return Array.Empty<string>(); + } + + HashSet<string> tags = new(StringComparer.OrdinalIgnoreCase); + + foreach (var accelerator in accelerators) + { + switch (accelerator) + { + case HardwareAccelerator.CPU: + tags.Add("CPU"); + break; + case HardwareAccelerator.GPU: + case HardwareAccelerator.DML: + tags.Add("GPU"); + break; + case HardwareAccelerator.NPU: + case HardwareAccelerator.QNN: + tags.Add("NPU"); + break; + } + } + + return tags.Count > 0 ? tags.ToArray() : Array.Empty<string>(); + } + + public static Visibility GetDeviceVisibility(IReadOnlyCollection<HardwareAccelerator> accelerators) + { + return GetDeviceTags(accelerators).Any() ? Visibility.Visible : Visibility.Collapsed; + } + + public static string GetLicenseShortText(string license) + { + if (string.IsNullOrWhiteSpace(license)) + { + return string.Empty; + } + + var trimmed = license.Trim(); + int separatorIndex = trimmed.IndexOfAny(['(', '[', ':']); + if (separatorIndex > 0) + { + trimmed = trimmed[..separatorIndex].Trim(); + } + + if (trimmed.Length > 24) + { + trimmed = $"{trimmed[..24].TrimEnd()}…"; + } + + return trimmed; + } + + public static Visibility GetLicenseVisibility(string license) + { + return string.IsNullOrWhiteSpace(license) ? Visibility.Collapsed : Visibility.Visible; + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/OOBEPageControl/OOBEPageControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/OOBEPageControl.xaml similarity index 100% rename from src/settings-ui/Settings.UI/SettingsXAML/Controls/OOBEPageControl/OOBEPageControl.xaml rename to src/settings-ui/Settings.UI/SettingsXAML/Controls/OOBEPageControl.xaml diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/OOBEPageControl/OOBEPageControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/OOBEPageControl.xaml.cs similarity index 100% rename from src/settings-ui/Settings.UI/SettingsXAML/Controls/OOBEPageControl/OOBEPageControl.xaml.cs rename to src/settings-ui/Settings.UI/SettingsXAML/Controls/OOBEPageControl.xaml.cs diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/PowerAccentShortcutControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/PowerAccentShortcutControl.xaml index c115d1febe..14de03d176 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/PowerAccentShortcutControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/PowerAccentShortcutControl.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" d:DesignHeight="300" d:DesignWidth="400" mc:Ignorable="d"> @@ -31,12 +31,11 @@ VerticalAlignment="Center" AutomationProperties.AccessibilityView="Raw" Content="{Binding}" - IsTabStop="False" - VisualType="SmallOutline" /> + IsTabStop="False" /> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> - <tk7controls:MarkdownTextBlock + <tkcontrols:MarkdownTextBlock Grid.Column="1" Margin="8,0,0,0" VerticalAlignment="Center" diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsGroup/SettingsGroup.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsGroup/SettingsGroup.xaml index d3462c6655..912211d9c4 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsGroup/SettingsGroup.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsGroup/SettingsGroup.xaml @@ -10,7 +10,7 @@ <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical" - Spacing="2" /> + Spacing="{StaticResource SettingsCardSpacing}" /> </ItemsPanelTemplate> </Setter.Value> </Setter> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsGroup/SettingsGroup.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsGroup/SettingsGroup.xaml.cs similarity index 100% rename from src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsGroup/SettingsGroup.cs rename to src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsGroup/SettingsGroup.xaml.cs diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml index 2026f37e3a..e60c8ad400 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml @@ -3,22 +3,17 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" - xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" Loaded="UserControl_Loaded" mc:Ignorable="d"> - <UserControl.Resources> <x:Double x:Key="PageMaxWidth">1000</x:Double> - <tkconverters:DoubleToVisibilityConverter - x:Name="doubleToVisibilityConverter" - FalseValue="Collapsed" - GreaterThan="0" - TrueValue="Visible" /> + <x:Double x:Key="PageHeaderMaxWidth">1020</x:Double> + <converters:UriToImageSourceConverter x:Key="UriToImageSourceConverter" /> </UserControl.Resources> - <Grid Padding="20,0,0,0" RowSpacing="24"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> @@ -26,14 +21,14 @@ </Grid.RowDefinitions> <TextBlock x:Name="Header" - MaxWidth="{StaticResource PageMaxWidth}" + MaxWidth="{StaticResource PageHeaderMaxWidth}" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" AutomationProperties.HeadingLevel="1" Style="{StaticResource TitleTextBlockStyle}" Text="{x:Bind ModuleTitle}" /> - <ScrollViewer Grid.Row="1"> + <ScrollViewer Grid.Row="1" AutomationProperties.AutomationId="PageScrollViewer"> <Grid Padding="0,0,20,48" ChildrenTransitions="{StaticResource SettingsCardsAnimations}" @@ -45,10 +40,7 @@ </Grid.RowDefinitions> <!-- Top panel --> - <Grid - MaxWidth="{StaticResource PageMaxWidth}" - ColumnSpacing="16" - RowSpacing="16"> + <Grid MaxWidth="{StaticResource PageMaxWidth}" RowSpacing="16"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="*" /> @@ -59,14 +51,12 @@ </Grid.RowDefinitions> <Border MaxWidth="160" + Margin="0,0,16,0" HorizontalAlignment="Left" VerticalAlignment="Top" - CornerRadius="4"> - <Image AutomationProperties.AccessibilityView="Raw"> - <Image.Source> - <BitmapImage UriSource="{x:Bind ModuleImageSource}" /> - </Image.Source> - </Image> + CornerRadius="{StaticResource OverlayCornerRadius}" + Visibility="{x:Bind ModuleImageSource, Converter={StaticResource EmptyObjectToObjectConverter}}"> + <Image AutomationProperties.AccessibilityView="Raw" Source="{x:Bind ModuleImageSource, Converter={StaticResource UriToImageSourceConverter}, Mode=OneWay}" /> </Border> <StackPanel x:Name="DescriptionPanel" Grid.Column="1"> @@ -80,7 +70,8 @@ x:Name="PrimaryLinksControl" Margin="0,8,0,0" IsTabStop="False" - ItemsSource="{x:Bind PrimaryLinks}"> + ItemsSource="{x:Bind PrimaryLinks}" + Visibility="{x:Bind PrimaryLinks.Count, Converter={StaticResource DoubleToVisibilityConverter}}"> <ItemsControl.ItemTemplate> <DataTemplate x:DataType="controls:PageLink"> <HyperlinkButton NavigateUri="{x:Bind Link}" Style="{StaticResource TextButtonStyle}"> @@ -90,7 +81,7 @@ </ItemsControl.ItemTemplate> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> - <tk7controls:WrapPanel HorizontalSpacing="24" Orientation="Horizontal" /> + <tkcontrols:WrapPanel HorizontalSpacing="24" Orientation="Horizontal" /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> </ItemsControl> @@ -112,7 +103,7 @@ MaxWidth="{StaticResource PageMaxWidth}" AutomationProperties.Name="{x:Bind SecondaryLinksHeader}" Orientation="Vertical" - Visibility="{x:Bind SecondaryLinks.Count, Converter={StaticResource doubleToVisibilityConverter}}"> + Visibility="{x:Bind SecondaryLinks.Count, Converter={StaticResource DoubleToVisibilityConverter}}"> <TextBlock Margin="2,8,0,0" AutomationProperties.HeadingLevel="Level2" @@ -132,7 +123,7 @@ </ItemsControl.ItemTemplate> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> - <tk7controls:WrapPanel HorizontalSpacing="24" Orientation="Horizontal" /> + <tkcontrols:WrapPanel HorizontalSpacing="24" Orientation="Horizontal" /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> </ItemsControl> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml.cs index dca1fbae8e..6e5fef091f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/SettingsPageControl/SettingsPageControl.xaml.cs @@ -2,6 +2,7 @@ // 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.ObjectModel; using Microsoft.UI.Xaml; @@ -30,9 +31,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set => SetValue(ModuleDescriptionProperty, value); } - public string ModuleImageSource + public Uri ModuleImageSource { - get => (string)GetValue(ModuleImageSourceProperty); + get => (Uri)GetValue(ModuleImageSourceProperty); set => SetValue(ModuleImageSourceProperty, value); } @@ -60,13 +61,13 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set { SetValue(ModuleContentProperty, value); } } - public static readonly DependencyProperty ModuleTitleProperty = DependencyProperty.Register("ModuleTitle", typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public static readonly DependencyProperty ModuleDescriptionProperty = DependencyProperty.Register("ModuleDescription", typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public static readonly DependencyProperty ModuleImageSourceProperty = DependencyProperty.Register("ModuleImageSource", typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public static readonly DependencyProperty PrimaryLinksProperty = DependencyProperty.Register("PrimaryLinks", typeof(ObservableCollection<PageLink>), typeof(SettingsPageControl), new PropertyMetadata(new ObservableCollection<PageLink>())); - public static readonly DependencyProperty SecondaryLinksHeaderProperty = DependencyProperty.Register("SecondaryLinksHeader", typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public static readonly DependencyProperty SecondaryLinksProperty = DependencyProperty.Register("SecondaryLinks", typeof(ObservableCollection<PageLink>), typeof(SettingsPageControl), new PropertyMetadata(new ObservableCollection<PageLink>())); - public static readonly DependencyProperty ModuleContentProperty = DependencyProperty.Register("ModuleContent", typeof(object), typeof(SettingsPageControl), new PropertyMetadata(new Grid())); + public static readonly DependencyProperty ModuleTitleProperty = DependencyProperty.Register(nameof(ModuleTitle), typeof(string), typeof(SettingsPageControl), new PropertyMetadata(defaultValue: null)); + public static readonly DependencyProperty ModuleDescriptionProperty = DependencyProperty.Register(nameof(ModuleDescription), typeof(string), typeof(SettingsPageControl), new PropertyMetadata(defaultValue: null)); + public static readonly DependencyProperty ModuleImageSourceProperty = DependencyProperty.Register(nameof(ModuleImageSource), typeof(Uri), typeof(SettingsPageControl), new PropertyMetadata(null)); + public static readonly DependencyProperty PrimaryLinksProperty = DependencyProperty.Register(nameof(PrimaryLinks), typeof(ObservableCollection<PageLink>), typeof(SettingsPageControl), new PropertyMetadata(new ObservableCollection<PageLink>())); + public static readonly DependencyProperty SecondaryLinksHeaderProperty = DependencyProperty.Register(nameof(SecondaryLinksHeader), typeof(string), typeof(SettingsPageControl), new PropertyMetadata(default(string))); + public static readonly DependencyProperty SecondaryLinksProperty = DependencyProperty.Register(nameof(SecondaryLinks), typeof(ObservableCollection<PageLink>), typeof(SettingsPageControl), new PropertyMetadata(new ObservableCollection<PageLink>())); + public static readonly DependencyProperty ModuleContentProperty = DependencyProperty.Register(nameof(ModuleContent), typeof(object), typeof(SettingsPageControl), new PropertyMetadata(new Grid())); private void UserControl_Loaded(object sender, RoutedEventArgs e) { diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml index cac7e3ed09..a747d71ef0 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml @@ -3,51 +3,95 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" x:Name="LayoutRoot" d:DesignHeight="300" d:DesignWidth="400" mc:Ignorable="d"> - + <UserControl.Resources> + <converters:BoolToKeyVisualStateConverter x:Key="BoolToKeyVisualStateConverter" /> + </UserControl.Resources> <Grid HorizontalAlignment="Right"> - <StackPanel Orientation="Horizontal"> - <Button - x:Name="EditButton" - Padding="0" - Click="OpenDialogButton_Click" - CornerRadius="8"> + <Button + x:Name="EditButton" + Padding="0" + HorizontalAlignment="Right" + Click="OpenDialogButton_Click" + Style="{StaticResource SubtleButtonStyle}"> + <StackPanel Orientation="Horizontal" Spacing="4"> + <ItemsControl + x:Name="PreviewKeysControl" + Margin="2" + VerticalAlignment="Center" + IsEnabled="{Binding ElementName=EditButton, Path=IsEnabled}" + IsTabStop="False" + Visibility="Collapsed"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <StackPanel Orientation="Horizontal" Spacing="4" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate> + <controls:KeyVisual + MinWidth="36" + Padding="8,8,8,8" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + Content="{Binding}" + CornerRadius="{StaticResource ControlCornerRadius}" + IsTabStop="False" + State="{Binding ElementName=LayoutRoot, Path=KeyVisualShouldShowConflict, Mode=OneWay, Converter={StaticResource BoolToKeyVisualStateConverter}, ConverterParameter=Warning}" + Style="{StaticResource AccentKeyVisualStyle}" /> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> <StackPanel - Margin="12,6,12,6" + x:Name="PlaceholderPanel" + Padding="8,4" + BorderBrush="{ThemeResource ControlStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="{StaticResource ControlCornerRadius}" Orientation="Horizontal" - Spacing="16"> - <ItemsControl - x:Name="PreviewKeysControl" + Spacing="8"> + <controls:IsEnabledTextBlock VerticalAlignment="Center" - IsEnabled="{Binding ElementName=EditButton, Path=IsEnabled}" - IsTabStop="False"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Orientation="Horizontal" Spacing="4" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate> - <controls:KeyVisual - VerticalAlignment="Center" - AutomationProperties.AccessibilityView="Raw" - Content="{Binding}" - IsTabStop="False" - VisualType="Small" /> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - <FontIcon - FontFamily="{ThemeResource SymbolThemeFontFamily}" - FontSize="16" - Glyph="" /> + FontFamily="Segoe Fluent Icons" + FontSize="12" + Text="" /> + <controls:IsEnabledTextBlock + x:Uid="ConfigureShortcutText" + Margin="0,-1,0,0" + VerticalAlignment="Center" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> </StackPanel> - </Button> - </StackPanel> + <controls:IsEnabledTextBlock + x:Name="EditIcon" + Margin="0,0,4,0" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + AutomationProperties.Name="" + FontFamily="{ThemeResource SymbolThemeFontFamily}" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="" + Visibility="Collapsed" /> + </StackPanel> + </Button> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Normal" /> + <VisualState x:Name="Configured"> + <VisualState.Setters> + <Setter Target="PlaceholderPanel.Visibility" Value="Collapsed" /> + <Setter Target="PreviewKeysControl.Visibility" Value="Visible" /> + <Setter Target="EditIcon.Visibility" Value="Visible" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> </Grid> </UserControl> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs index e33127572d..488c68a3ae 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutControl.xaml.cs @@ -3,18 +3,33 @@ // See the LICENSE file in the project root for more information. using System; - +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; using CommunityToolkit.WinUI; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.PowerToys.Telemetry; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.Windows.ApplicationModel.Resources; using Windows.System; namespace Microsoft.PowerToys.Settings.UI.Controls { + public enum ShortcutControlSource + { + SettingsPage, + ConflictWindow, + } + public sealed partial class ShortcutControl : UserControl, IDisposable { private readonly UIntPtr ignoreKeyEventFlag = (UIntPtr)0x5555; @@ -33,8 +48,16 @@ namespace Microsoft.PowerToys.Settings.UI.Controls public static readonly DependencyProperty IsActiveProperty = DependencyProperty.Register("Enabled", typeof(bool), typeof(ShortcutControl), null); public static readonly DependencyProperty HotkeySettingsProperty = DependencyProperty.Register("HotkeySettings", typeof(HotkeySettings), typeof(ShortcutControl), null); - public static readonly DependencyProperty AllowDisableProperty = DependencyProperty.Register("AllowDisable", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnAllowDisableChanged)); + public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnHasConflictChanged)); + public static readonly DependencyProperty TooltipProperty = DependencyProperty.Register("Tooltip", typeof(string), typeof(ShortcutControl), new PropertyMetadata(null, OnTooltipChanged)); + public static readonly DependencyProperty KeyVisualShouldShowConflictProperty = DependencyProperty.Register("KeyVisualShouldShowConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false)); + public static readonly DependencyProperty IgnoreConflictProperty = DependencyProperty.Register("IgnoreConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false)); + + // Dependency property to track the source/context of the ShortcutControl + public static readonly DependencyProperty SourceProperty = DependencyProperty.Register("Source", typeof(ShortcutControlSource), typeof(ShortcutControl), new PropertyMetadata(ShortcutControlSource.SettingsPage)); + + private static ResourceLoader resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; private static void OnAllowDisableChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { @@ -50,14 +73,75 @@ namespace Microsoft.PowerToys.Settings.UI.Controls return; } - var resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; - var newValue = (bool)(e?.NewValue ?? false); var text = newValue ? resourceLoader.GetString("Activation_Shortcut_With_Disable_Description") : resourceLoader.GetString("Activation_Shortcut_Description"); description.Text = text; } + private static void OnHasConflictChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as ShortcutControl; + if (control == null) + { + return; + } + + control.UpdateKeyVisualStyles(); + + // Check if conflict was resolved (had conflict before, no conflict now) + var oldValue = (bool)(e.OldValue ?? false); + var newValue = (bool)(e.NewValue ?? false); + + // General conflict resolution telemetry (for all sources) + if (oldValue && !newValue) + { + // Determine the actual source based on the control's context + var actualSource = DetermineControlSource(control); + + // Conflict was resolved - send general telemetry + PowerToysTelemetry.Log.WriteEvent(new ShortcutConflictResolvedEvent() + { + Source = actualSource.ToString(), + }); + } + } + + private static ShortcutControlSource DetermineControlSource(ShortcutControl control) + { + // Walk up the visual tree to find the parent window/container + DependencyObject parent = control; + while (parent != null) + { + parent = VisualTreeHelper.GetParent(parent); + + // Check if we're in a ShortcutConflictWindow + if (parent != null && parent.GetType().Name == "ShortcutConflictWindow") + { + return ShortcutControlSource.ConflictWindow; + } + + if (parent != null && (parent.GetType().Name == "MainWindow" || parent.GetType().Name == "ShellPage")) + { + return ShortcutControlSource.SettingsPage; + } + } + + // Fallback to the explicitly set value or default + return ShortcutControlSource.ConflictWindow; + } + + private static void OnTooltipChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as ShortcutControl; + if (control == null) + { + return; + } + + control.UpdateTooltip(); + } + private ShortcutDialogContentControl c = new ShortcutDialogContentControl(); private ContentDialog shortcutDialog; @@ -67,6 +151,36 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set => SetValue(AllowDisableProperty, value); } + public bool HasConflict + { + get => (bool)GetValue(HasConflictProperty); + set => SetValue(HasConflictProperty, value); + } + + public string Tooltip + { + get => (string)GetValue(TooltipProperty); + set => SetValue(TooltipProperty, value); + } + + public bool KeyVisualShouldShowConflict + { + get => (bool)GetValue(KeyVisualShouldShowConflictProperty); + set => SetValue(KeyVisualShouldShowConflictProperty, value); + } + + public bool IgnoreConflict + { + get => (bool)GetValue(IgnoreConflictProperty); + set => SetValue(IgnoreConflictProperty, value); + } + + public ShortcutControlSource Source + { + get => (ShortcutControlSource)GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + public bool Enabled { get @@ -101,15 +215,56 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { if (hotkeySettings != value) { + // Unsubscribe from old settings + if (hotkeySettings != null) + { + hotkeySettings.PropertyChanged -= OnHotkeySettingsPropertyChanged; + } + hotkeySettings = value; SetValue(HotkeySettingsProperty, value); - PreviewKeysControl.ItemsSource = HotkeySettings.GetKeysList(); - AutomationProperties.SetHelpText(EditButton, HotkeySettings.ToString()); - c.Keys = HotkeySettings.GetKeysList(); + + // Subscribe to new settings + if (hotkeySettings != null) + { + hotkeySettings.PropertyChanged += OnHotkeySettingsPropertyChanged; + + // Update UI based on conflict properties + UpdateConflictStatusFromHotkeySettings(); + } + + SetKeys(); + c.Keys = HotkeySettings?.GetKeysList(); } } } + private void OnHotkeySettingsPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(HotkeySettings.HasConflict) || + e.PropertyName == nameof(HotkeySettings.ConflictDescription)) + { + UpdateConflictStatusFromHotkeySettings(); + } + } + + private void UpdateConflictStatusFromHotkeySettings() + { + if (hotkeySettings != null) + { + // Update the ShortcutControl's conflict properties from HotkeySettings + HasConflict = hotkeySettings.HasConflict; + Tooltip = hotkeySettings.HasConflict ? hotkeySettings.ConflictDescription : null; + IgnoreConflict = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySettings); + KeyVisualShouldShowConflict = !IgnoreConflict && HasConflict; + } + else + { + HasConflict = false; + Tooltip = null; + } + } + public ShortcutControl() { InitializeComponent(); @@ -118,7 +273,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls this.Unloaded += ShortcutControl_Unloaded; this.Loaded += ShortcutControl_Loaded; - var resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; + c.ResetClick += C_ResetClick; + c.ClearClick += C_ClearClick; + c.LearnMoreClick += C_LearnMoreClick; // We create the Dialog in C# because doing it in XAML is giving WinUI/XAML Island bugs when using dark theme. shortcutDialog = new ContentDialog @@ -127,11 +284,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls Title = resourceLoader.GetString("Activation_Shortcut_Title"), Content = c, PrimaryButtonText = resourceLoader.GetString("Activation_Shortcut_Save"), - SecondaryButtonText = resourceLoader.GetString("Activation_Shortcut_Reset"), CloseButtonText = resourceLoader.GetString("Activation_Shortcut_Cancel"), DefaultButton = ContentDialogButton.Primary, }; - shortcutDialog.SecondaryButtonClick += ShortcutDialog_Reset; shortcutDialog.RightTapped += ShortcutDialog_Disable; AutomationProperties.SetName(EditButton, resourceLoader.GetString("Activation_Shortcut_Title")); @@ -139,17 +294,56 @@ namespace Microsoft.PowerToys.Settings.UI.Controls OnAllowDisableChanged(this, null); } + private void C_LearnMoreClick(object sender, RoutedEventArgs e) + { + // Close the current shortcut dialog + shortcutDialog.Hide(); + + ((App)App.Current)!.OpenShortcutConflictWindow(); + } + + private void UpdateKeyVisualStyles() + { + if (PreviewKeysControl?.ItemsSource != null) + { + // Force refresh of the ItemsControl to update KeyVisual styles + var items = PreviewKeysControl.ItemsSource; + PreviewKeysControl.ItemsSource = null; + PreviewKeysControl.ItemsSource = items; + } + } + + private void UpdateTooltip() + { + if (!string.IsNullOrEmpty(Tooltip)) + { + ToolTipService.SetToolTip(EditButton, Tooltip); + } + else + { + ToolTipService.SetToolTip(EditButton, null); + } + } + private void ShortcutControl_Unloaded(object sender, RoutedEventArgs e) { shortcutDialog.PrimaryButtonClick -= ShortcutDialog_PrimaryButtonClick; shortcutDialog.Opened -= ShortcutDialog_Opened; shortcutDialog.Closing -= ShortcutDialog_Closing; + c.LearnMoreClick -= C_LearnMoreClick; + if (App.GetSettingsWindow() != null) { App.GetSettingsWindow().Activated -= ShortcutDialog_SettingsWindow_Activated; } + // Unsubscribe from HotkeySettings property changes + if (hotkeySettings != null) + { + hotkeySettings.PropertyChanged -= OnHotkeySettingsPropertyChanged; + } + // Dispose the HotkeySettingsControlHook object to terminate the hook threads when the textbox is unloaded hook?.Dispose(); @@ -171,6 +365,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { App.GetSettingsWindow().Activated += ShortcutDialog_SettingsWindow_Activated; } + + // Initialize tooltip when loaded + UpdateTooltip(); } private void KeyEventHandler(int key, bool matchValue, int matchValueCode) @@ -305,6 +502,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls KeyEventHandler(key, true, key); c.Keys = internalSettings.GetKeysList(); + c.ConflictMessage = string.Empty; + c.HasConflict = false; if (internalSettings.GetKeysList().Count == 0) { @@ -339,26 +538,81 @@ namespace Microsoft.PowerToys.Settings.UI.Controls else { EnableKeys(); + + if (lastValidSettings.IsValid()) + { + if (string.Equals(lastValidSettings.ToString(), hotkeySettings.ToString(), StringComparison.OrdinalIgnoreCase)) + { + c.HasConflict = hotkeySettings.HasConflict; + c.ConflictMessage = hotkeySettings.ConflictDescription; + } + else + { + // Check for conflicts with the new hotkey settings + CheckForConflicts(lastValidSettings); + } + } } } c.IsWarningAltGr = internalSettings.Ctrl && internalSettings.Alt && !internalSettings.Win && (internalSettings.Code > 0); } + private void CheckForConflicts(HotkeySettings settings) + { + void UpdateUIForConflict(bool hasConflict, HotkeyConflictResponse hotkeyConflictResponse) + { + DispatcherQueue.TryEnqueue(() => + { + if (hasConflict) + { + // Build conflict message from all conflicts - only show module names + var conflictingModules = new HashSet<string>(); + + foreach (var conflict in hotkeyConflictResponse.AllConflicts) + { + if (!string.IsNullOrEmpty(conflict.ModuleName)) + { + conflictingModules.Add(conflict.ModuleName); + } + } + + var moduleNames = conflictingModules.ToArray(); + if (string.Equals(moduleNames[0], "System", StringComparison.OrdinalIgnoreCase)) + { + c.ConflictMessage = ResourceLoaderInstance.ResourceLoader.GetString("SysHotkeyConflictTooltipText"); + } + else + { + c.ConflictMessage = ResourceLoaderInstance.ResourceLoader.GetString("InAppHotkeyConflictTooltipText"); + } + + c.HasConflict = true; + } + else + { + c.ConflictMessage = string.Empty; + c.HasConflict = false; + } + }); + } + + HotkeyConflictHelper.CheckHotkeyConflict( + settings, + ShellPage.SendDefaultIPCMessage, + UpdateUIForConflict); + } + private void EnableKeys() { shortcutDialog.IsPrimaryButtonEnabled = true; c.IsError = false; - - // WarningLabel.Style = (Style)App.Current.Resources["SecondaryTextStyle"]; } private void DisableKeys() { shortcutDialog.IsPrimaryButtonEnabled = false; c.IsError = true; - - // WarningLabel.Style = (Style)App.Current.Resources["SecondaryWarningTextStyle"]; } private void Hotkey_KeyUp(int key) @@ -419,6 +673,10 @@ namespace Microsoft.PowerToys.Settings.UI.Controls c.Keys = null; c.Keys = HotkeySettings.GetKeysList(); + c.IgnoreConflict = IgnoreConflict; + c.HasConflict = hotkeySettings.HasConflict; + c.ConflictMessage = hotkeySettings.ConflictDescription; + // 92 means the Win key. The logic is: warning should be visible if the shortcut contains Alt AND contains Ctrl AND NOT contains Win. // Additional key must be present, as this is a valid, previously used shortcut shown at dialog open. Check for presence of non-modifier-key is not necessary therefore c.IsWarningAltGr = c.Keys.Contains("Ctrl") && c.Keys.Contains("Alt") && !c.Keys.Contains(92); @@ -428,28 +686,55 @@ namespace Microsoft.PowerToys.Settings.UI.Controls await shortcutDialog.ShowAsync(); } - private void ShortcutDialog_Reset(ContentDialog sender, ContentDialogButtonClickEventArgs args) + private void C_ResetClick(object sender, RoutedEventArgs e) { hotkeySettings = null; SetValue(HotkeySettingsProperty, hotkeySettings); - PreviewKeysControl.ItemsSource = HotkeySettings.GetKeysList(); + SetKeys(); lastValidSettings = hotkeySettings; - - AutomationProperties.SetHelpText(EditButton, HotkeySettings.ToString()); shortcutDialog.Hide(); + + // Send RequestAllConflicts IPC to update the UI after changed hotkey settings. + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + + private void C_ClearClick(object sender, RoutedEventArgs e) + { + hotkeySettings = new HotkeySettings(); + + SetValue(HotkeySettingsProperty, hotkeySettings); + SetKeys(); + + lastValidSettings = hotkeySettings; + shortcutDialog.Hide(); + + // Send RequestAllConflicts IPC to update the UI after changed hotkey settings. + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); } private void ShortcutDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) { if (ComboIsValid(lastValidSettings)) { - HotkeySettings = lastValidSettings with { }; + if (c.HasConflict) + { + lastValidSettings = lastValidSettings with { HasConflict = true }; + } + else + { + lastValidSettings = lastValidSettings with { HasConflict = false }; + } + + HotkeySettings = lastValidSettings; } - PreviewKeysControl.ItemsSource = hotkeySettings.GetKeysList(); - AutomationProperties.SetHelpText(EditButton, HotkeySettings.ToString()); + SetKeys(); + + // Send RequestAllConflicts IPC to update the UI after changed hotkey settings. + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + shortcutDialog.Hide(); } @@ -462,9 +747,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls var empty = new HotkeySettings(); HotkeySettings = empty; - - PreviewKeysControl.ItemsSource = HotkeySettings.GetKeysList(); - AutomationProperties.SetHelpText(EditButton, HotkeySettings.ToString()); + SetKeys(); shortcutDialog.Hide(); } @@ -485,7 +768,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls args.Handled = true; if (args.WindowActivationState != WindowActivationState.Deactivated && (hook == null || hook.GetDisposedState() == true)) { - // If the PT settings window gets focussed/activated again, we enable the keyboard hook to catch the keyboard input. + // If the PT settings window gets focused/activated again, we enable the keyboard hook to catch the keyboard input. hook = new HotkeySettingsControlHook(Hotkey_KeyDown, Hotkey_KeyUp, Hotkey_IsActive, FilterAccessibleKeyboardEvents); } else if (args.WindowActivationState == WindowActivationState.Deactivated && hook != null && hook.GetDisposedState() == false) @@ -499,6 +782,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls private void ShortcutDialog_Closing(ContentDialog sender, ContentDialogClosingEventArgs args) { _isActive = false; + lastValidSettings = hotkeySettings; } private void Dispose(bool disposing) @@ -525,5 +809,22 @@ namespace Microsoft.PowerToys.Settings.UI.Controls Dispose(disposing: true); GC.SuppressFinalize(this); } + + private void SetKeys() + { + var keys = HotkeySettings?.GetKeysList(); + + if (keys != null && keys.Count > 0) + { + VisualStateManager.GoToState(this, "Configured", true); + PreviewKeysControl.ItemsSource = keys; + AutomationProperties.SetHelpText(EditButton, HotkeySettings.ToString()); + } + else + { + VisualStateManager.GoToState(this, "Normal", true); + AutomationProperties.SetHelpText(EditButton, resourceLoader.GetString("ConfigureShortcut")); + } + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml index 8765a3d4b3..68ce9ffbff 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml @@ -3,74 +3,332 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tk7controls="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" x:Name="ShortcutContentControl" mc:Ignorable="d"> - <Grid MinWidth="498" MinHeight="220"> + <UserControl.Resources> + <converters:BoolToKeyVisualStateConverter x:Key="BoolToKeyVisualStateConverter" /> + <Style x:Key="CondensedInfoBarStyle" TargetType="InfoBar"> + <Setter Property="IsTabStop" Value="False" /> + <Setter Property="Background" Value="Transparent" /> + <Setter Property="BorderBrush" Value="{ThemeResource InfoBarBorderBrush}" /> + <Setter Property="BorderThickness" Value="{ThemeResource InfoBarBorderThickness}" /> + <Setter Property="AutomationProperties.LandmarkType" Value="Custom" /> + <Setter Property="AutomationProperties.IsDialog" Value="True" /> + <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="InfoBar"> + <Border + x:Name="ContentRoot" + VerticalAlignment="Top" + Background="{ThemeResource InfoBarInformationalSeverityBackgroundBrush}" + BorderBrush="{TemplateBinding BorderBrush}" + BorderThickness="{TemplateBinding BorderThickness}" + CornerRadius="{TemplateBinding CornerRadius}"> + <!-- Background is used here so that it overrides the severity status color if set. --> + <Grid + MinHeight="0" + Padding="8" + HorizontalAlignment="Stretch" + Background="{TemplateBinding Background}" + CornerRadius="{TemplateBinding CornerRadius}"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <!-- Icon --> + <ColumnDefinition Width="*" /> + <!-- Title, message, and action --> + <ColumnDefinition Width="Auto" /> + <!-- Close button --> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <Grid + x:Name="StandardIconArea" + Margin="0,0,8,0" + Visibility="Collapsed"> + <TextBlock + x:Name="IconBackground" + Grid.Column="0" + VerticalAlignment="Top" + AutomationProperties.AccessibilityView="Raw" + FontFamily="{ThemeResource SymbolThemeFontFamily}" + FontSize="{StaticResource InfoBarIconFontSize}" + Foreground="{ThemeResource InfoBarInformationalSeverityIconBackground}" + Text="{StaticResource InfoBarIconBackgroundGlyph}" /> + <TextBlock + x:Name="StandardIcon" + Grid.Column="0" + VerticalAlignment="Top" + FontFamily="{ThemeResource SymbolThemeFontFamily}" + FontSize="{StaticResource InfoBarIconFontSize}" + Foreground="{ThemeResource InfoBarInformationalSeverityIconForeground}" + Text="{StaticResource InfoBarInformationalIconGlyph}" /> + </Grid> + <Viewbox + x:Name="UserIconBox" + Grid.Column="0" + MaxWidth="{ThemeResource InfoBarIconFontSize}" + MaxHeight="{ThemeResource InfoBarIconFontSize}" + Margin="0" + VerticalAlignment="Top" + Child="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.IconElement}" + Visibility="Collapsed" /> + <InfoBarPanel + Grid.Column="1" + Margin="0,0,0,0" + HorizontalOrientationPadding="0" + VerticalOrientationPadding="0"> + <TextBlock + x:Name="Title" + Margin="0,-1,0,0" + FontSize="{StaticResource InfoBarTitleFontSize}" + FontWeight="{StaticResource InfoBarTitleFontWeight}" + Foreground="{ThemeResource InfoBarTitleForeground}" + InfoBarPanel.HorizontalOrientationMargin="0,0,8,0" + InfoBarPanel.VerticalOrientationMargin="0,8,0,0" + Text="{TemplateBinding Title}" + TextWrapping="WrapWholeWords" /> + <TextBlock + x:Name="Message" + Margin="0,-1,0,0" + FontSize="{StaticResource InfoBarMessageFontSize}" + FontWeight="{StaticResource InfoBarMessageFontWeight}" + Foreground="{ThemeResource InfoBarMessageForeground}" + InfoBarPanel.HorizontalOrientationMargin="0" + InfoBarPanel.VerticalOrientationMargin="0" + Text="{TemplateBinding Message}" + TextWrapping="WrapWholeWords" /> + <ContentPresenter + Content="{TemplateBinding ActionButton}" + InfoBarPanel.HorizontalOrientationMargin="16,-2,0,0" + InfoBarPanel.VerticalOrientationMargin="0,8,0,0" /> + </InfoBarPanel> + <ContentPresenter + x:Name="ContentArea" + Grid.Row="1" + Grid.Column="1" + VerticalAlignment="Center" + Content="{TemplateBinding Content}" + ContentTemplate="{TemplateBinding ContentTemplate}" /> + </Grid> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="SeverityLevels"> + <VisualState x:Name="Informational" /> + <VisualState x:Name="Error"> + <VisualState.Setters> + <Setter Target="ContentRoot.Background" Value="{ThemeResource InfoBarErrorSeverityBackgroundBrush}" /> + <Setter Target="IconBackground.Foreground" Value="{ThemeResource InfoBarErrorSeverityIconBackground}" /> + <Setter Target="StandardIcon.Text" Value="{StaticResource InfoBarErrorIconGlyph}" /> + <Setter Target="StandardIcon.Foreground" Value="{ThemeResource InfoBarErrorSeverityIconForeground}" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="Warning"> + <VisualState.Setters> + <Setter Target="ContentRoot.Background" Value="{ThemeResource InfoBarWarningSeverityBackgroundBrush}" /> + <Setter Target="IconBackground.Foreground" Value="{ThemeResource InfoBarWarningSeverityIconBackground}" /> + <Setter Target="StandardIcon.Text" Value="{StaticResource InfoBarWarningIconGlyph}" /> + <Setter Target="StandardIcon.Foreground" Value="{ThemeResource InfoBarWarningSeverityIconForeground}" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="Success"> + <VisualState.Setters> + <Setter Target="ContentRoot.Background" Value="{ThemeResource InfoBarSuccessSeverityBackgroundBrush}" /> + <Setter Target="IconBackground.Foreground" Value="{ThemeResource InfoBarSuccessSeverityIconBackground}" /> + <Setter Target="StandardIcon.Text" Value="{StaticResource InfoBarSuccessIconGlyph}" /> + <Setter Target="StandardIcon.Foreground" Value="{ThemeResource InfoBarSuccessSeverityIconForeground}" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup x:Name="IconStates"> + <VisualState x:Name="StandardIconVisible"> + <VisualState.Setters> + <Setter Target="UserIconBox.Visibility" Value="Collapsed" /> + <Setter Target="StandardIconArea.Visibility" Value="Visible" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="UserIconVisible"> + <VisualState.Setters> + <Setter Target="UserIconBox.Visibility" Value="Visible" /> + <Setter Target="StandardIconArea.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + <VisualState x:Name="NoIconVisible" /> + </VisualStateGroup> + <VisualStateGroup> + <VisualState x:Name="CloseButtonVisible" /> + <VisualState x:Name="CloseButtonCollapsed" /> + </VisualStateGroup> + <VisualStateGroup x:Name="InfoBarVisibility"> + <VisualState x:Name="InfoBarVisible" /> + <VisualState x:Name="InfoBarCollapsed"> + <VisualState.Setters> + <Setter Target="ContentRoot.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup> + <VisualState x:Name="ForegroundNotSet" /> + <VisualState x:Name="ForegroundSet"> + <VisualState.Setters> + <Setter Target="Title.Foreground" Value="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Foreground}" /> + <Setter Target="Message.Foreground" Value="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Foreground}" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup> + <VisualState x:Name="BannerContent" /> + <VisualState x:Name="NoBannerContent"> + <VisualState.Setters> + <Setter Target="ContentArea.(Grid.Row)" Value="0" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Border> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + </UserControl.Resources> + <Grid + MinWidth="498" + MinHeight="220" + RowSpacing="8"> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> - <RowDefinition MinHeight="110" /> + <RowDefinition Height="Auto" MinHeight="104" /> + <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> - - <TextBlock Grid.Row="0" /> - - <ItemsControl - x:Name="KeysControl" + <tk7controls:MarkdownTextBlock x:Uid="InvalidShortcutWarningLabel" Background="Transparent" /> + <Grid Grid.Row="1" - Height="56" - Margin="0,64,0,0" - HorizontalAlignment="Center" - VerticalAlignment="Top" - HorizontalContentAlignment="Center" - ItemsSource="{x:Bind Keys, Mode=OneWay}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Orientation="Horizontal" Spacing="8" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate> - <controls:KeyVisual - Height="56" - AutomationProperties.AccessibilityView="Raw" - Content="{Binding}" - IsError="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}" - IsTabStop="False" - VisualType="Large" /> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - + Margin="0,16,0,0" + Background="{ThemeResource SolidBackgroundFillColorTertiaryBrush}" + CornerRadius="{StaticResource OverlayCornerRadius}"> + <ItemsControl + x:Name="KeysControl" + Height="56" + HorizontalAlignment="Center" + VerticalAlignment="Center" + HorizontalContentAlignment="Center" + ItemsSource="{x:Bind Keys, Mode=OneWay}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <StackPanel Orientation="Horizontal" Spacing="8" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate> + <controls:KeyVisual + Padding="20,16" + AutomationProperties.AccessibilityView="Raw" + Content="{Binding}" + CornerRadius="{StaticResource OverlayCornerRadius}" + FontSize="16" + FontWeight="SemiBold" + IsTabStop="False" + State="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay, Converter={StaticResource BoolToKeyVisualStateConverter}, ConverterParameter=Error}" + Style="{StaticResource AccentKeyVisualStyle}" /> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + <TextBlock + HorizontalAlignment="Center" + VerticalAlignment="Center" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextAlignment="Center" + Visibility="{x:Bind Keys.Count, Mode=OneWay, Converter={StaticResource DoubleToInvertedVisibilityConverter}}" /> + </Grid> <StackPanel Grid.Row="2" - Margin="0,24,0,0" - VerticalAlignment="Top" - Orientation="Vertical" - Spacing="8"> - <Grid Height="62"> - - <InfoBar - x:Uid="InvalidShortcut" - IsClosable="False" - IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}" - IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}" - Severity="Error" /> - - <InfoBar - x:Uid="WarningShortcutAltGr" - IsClosable="False" - IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}" - IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}" - Severity="Warning" /> - </Grid> - <tk7controls:MarkdownTextBlock - x:Uid="InvalidShortcutWarningLabel" - Background="Transparent" - FontSize="12" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + HorizontalAlignment="Center" + Orientation="Horizontal" + Spacing="12"> + <HyperlinkButton + x:Name="ResetBtn" + x:Uid="Shortcut_ResetBtn" + Click="ResetBtn_Click"> + <ToolTipService.ToolTip> + <TextBlock x:Uid="Shortcut_ResetToolTip" TextWrapping="Wrap" /> + </ToolTipService.ToolTip> + <StackPanel Orientation="Horizontal" Spacing="4"> + <FontIcon FontSize="10" Glyph="" /> + <TextBlock + x:Uid="Shortcut_Reset" + Margin="0,-1,0,0" + VerticalAlignment="Center" + FontSize="12" /> + </StackPanel> + </HyperlinkButton> + <HyperlinkButton + x:Name="ClearBtn" + x:Uid="Shortcut_ClearBtn" + Click="ClearBtn_Click" + Visibility="{x:Bind Keys.Count, Mode=OneWay, Converter={StaticResource DoubleToVisibilityConverter}}"> + <ToolTipService.ToolTip> + <TextBlock x:Uid="Shortcut_ClearToolTip" TextWrapping="Wrap" /> + </ToolTipService.ToolTip> + <StackPanel Orientation="Horizontal" Spacing="4"> + <FontIcon FontSize="12" Glyph="" /> + <TextBlock + x:Uid="Shortcut_Clear" + Margin="0,-1,0,0" + VerticalAlignment="Center" + FontSize="12" /> + </StackPanel> + </HyperlinkButton> </StackPanel> + + <Grid Grid.Row="3" Margin="0,12,0,0"> + <InfoBar + x:Uid="InvalidShortcut" + BorderThickness="0" + IsClosable="False" + IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}" + IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}" + Severity="Error" + Style="{StaticResource CondensedInfoBarStyle}" /> + <InfoBar + x:Uid="WarningShortcutAltGr" + BorderThickness="0" + IsClosable="False" + IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}" + IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}" + Severity="Warning" + Style="{StaticResource CondensedInfoBarStyle}" /> + <InfoBar + BorderThickness="0" + IsClosable="False" + IsOpen="{Binding ElementName=ShortcutContentControl, Path=ShouldShowConflict, Mode=OneWay}" + IsTabStop="{Binding ElementName=ShortcutContentControl, Path=ShouldShowConflict, Mode=OneWay}" + Message="{Binding ElementName=ShortcutContentControl, Path=ConflictMessage, Mode=OneWay}" + Severity="Warning" + Style="{StaticResource CondensedInfoBarStyle}" /> + <InfoBar + x:Uid="WarningPotentialShortcutConflict" + BorderThickness="0" + IsClosable="False" + IsOpen="{Binding ElementName=ShortcutContentControl, Path=ShouldShowPotentialConflict, Mode=OneWay}" + IsTabStop="{Binding ElementName=ShortcutContentControl, Path=ShouldShowPotentialConflict, Mode=OneWay}" + Message="{Binding ElementName=ShortcutContentControl, Path=ConflictMessage, Mode=OneWay}" + Severity="Warning" + Style="{StaticResource CondensedInfoBarStyle}"> + <InfoBar.ActionButton> + <HyperlinkButton + x:Uid="Shortcut_Conflict_LearnMore" + Padding="0" + Click="LearnMoreBtn_Click" /> + </InfoBar.ActionButton> + </InfoBar> + </Grid> </Grid> -</UserControl> +</UserControl> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs index 5d44f7c451..9a369f0ebc 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ShortcutControl/ShortcutDialogContentControl.xaml.cs @@ -2,8 +2,10 @@ // 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.Diagnostics.Eventing.Reader; +using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -11,9 +13,54 @@ namespace Microsoft.PowerToys.Settings.UI.Controls { public sealed partial class ShortcutDialogContentControl : UserControl { + public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List<object>), typeof(ShortcutDialogContentControl), new PropertyMetadata(default(string))); + public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + public static readonly DependencyProperty IsWarningAltGrProperty = DependencyProperty.Register("IsWarningAltGr", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false, OnConflictPropertyChanged)); + public static readonly DependencyProperty ConflictMessageProperty = DependencyProperty.Register("ConflictMessage", typeof(string), typeof(ShortcutDialogContentControl), new PropertyMetadata(string.Empty)); + public static readonly DependencyProperty IgnoreConflictProperty = DependencyProperty.Register("IgnoreConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false, OnIgnoreConflictChanged)); + + public static readonly DependencyProperty ShouldShowConflictProperty = DependencyProperty.Register("ShouldShowConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + public static readonly DependencyProperty ShouldShowPotentialConflictProperty = DependencyProperty.Register("ShouldShowPotentialConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + + public event EventHandler<bool> IgnoreConflictChanged; + + public event RoutedEventHandler LearnMoreClick; + + public bool IgnoreConflict + { + get => (bool)GetValue(IgnoreConflictProperty); + set => SetValue(IgnoreConflictProperty, value); + } + + public bool HasConflict + { + get => (bool)GetValue(HasConflictProperty); + set => SetValue(HasConflictProperty, value); + } + + public string ConflictMessage + { + get => (string)GetValue(ConflictMessageProperty); + set => SetValue(ConflictMessageProperty, value); + } + + public bool ShouldShowConflict + { + get => (bool)GetValue(ShouldShowConflictProperty); + private set => SetValue(ShouldShowConflictProperty, value); + } + + public bool ShouldShowPotentialConflict + { + get => (bool)GetValue(ShouldShowPotentialConflictProperty); + private set => SetValue(ShouldShowPotentialConflictProperty, value); + } + public ShortcutDialogContentControl() { this.InitializeComponent(); + UpdateShouldShowConflict(); } public List<object> Keys @@ -22,22 +69,65 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set { SetValue(KeysProperty, value); } } - public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List<object>), typeof(SettingsPageControl), new PropertyMetadata(default(string))); - public bool IsError { get => (bool)GetValue(IsErrorProperty); set => SetValue(IsErrorProperty, value); } - public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); - public bool IsWarningAltGr { get => (bool)GetValue(IsWarningAltGrProperty); set => SetValue(IsWarningAltGrProperty, value); } - public static readonly DependencyProperty IsWarningAltGrProperty = DependencyProperty.Register("IsWarningAltGr", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false)); + public event RoutedEventHandler ResetClick; + + public event RoutedEventHandler ClearClick; + + private static void OnIgnoreConflictChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as ShortcutDialogContentControl; + if (control == null) + { + return; + } + + control.UpdateShouldShowConflict(); + + control.IgnoreConflictChanged?.Invoke(control, (bool)e.NewValue); + } + + private static void OnConflictPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = d as ShortcutDialogContentControl; + if (control == null) + { + return; + } + + control.UpdateShouldShowConflict(); + } + + private void UpdateShouldShowConflict() + { + ShouldShowConflict = !IgnoreConflict && HasConflict; + ShouldShowPotentialConflict = IgnoreConflict && HasConflict; + } + + private void ResetBtn_Click(object sender, RoutedEventArgs e) + { + ResetClick?.Invoke(this, new RoutedEventArgs()); + } + + private void ClearBtn_Click(object sender, RoutedEventArgs e) + { + ClearClick?.Invoke(this, new RoutedEventArgs()); + } + + private void LearnMoreBtn_Click(object sender, RoutedEventArgs e) + { + LearnMoreClick?.Invoke(this, new RoutedEventArgs()); + } } } 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 23f2d3cc2b..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,45 +1,67 @@ -<UserControl - x:Class="Microsoft.PowerToys.Settings.UI.Controls.ShortcutWithTextLabelControl" +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" - d:DesignHeight="300" - d:DesignWidth="400" - mc:Ignorable="d"> + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:tk="using:CommunityToolkit.WinUI" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"> - <Grid ColumnSpacing="8"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="*" /> - </Grid.ColumnDefinitions> - <ItemsControl - VerticalAlignment="Center" - AutomationProperties.AccessibilityView="Raw" - IsTabStop="False" - ItemsSource="{x:Bind Keys}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Orientation="Horizontal" Spacing="4" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate> - <controls:KeyVisual - VerticalAlignment="Center" - AutomationProperties.AccessibilityView="Raw" - Content="{Binding}" - IsTabStop="False" - VisualType="SmallOutline" /> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - <tk7controls:MarkdownTextBlock - Grid.Column="1" - VerticalAlignment="Center" - Background="Transparent" - Text="{x:Bind Text}" /> - </Grid> -</UserControl> + <Style BasedOn="{StaticResource DefaultShortcutWithTextLabelControlStyle}" TargetType="local:ShortcutWithTextLabelControl" /> + + <Style x:Key="DefaultShortcutWithTextLabelControlStyle" TargetType="local:ShortcutWithTextLabelControl"> + <Setter Property="KeyVisualStyle" Value="{StaticResource DefaultKeyVisualStyle}" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:ShortcutWithTextLabelControl"> + <Grid ColumnSpacing="8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <ItemsControl + x:Name="ShortcutsControl" + VerticalAlignment="Bottom" + AutomationProperties.AccessibilityView="Raw" + IsTabStop="False" + ItemsSource="{TemplateBinding Keys}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <StackPanel Orientation="Horizontal" Spacing="4" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate> + <StackPanel Orientation="Vertical"> + <local:KeyVisual + tk:FrameworkElementExtensions.AncestorType="local:ShortcutWithTextLabelControl" + AutomationProperties.AccessibilityView="Raw" + Content="{Binding}" + IsTabStop="False" + Style="{Binding (tk:FrameworkElementExtensions.Ancestor).KeyVisualStyle, RelativeSource={RelativeSource Self}}" /> + </StackPanel> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + <tkcontrols:MarkdownTextBlock + x:Name="LabelControl" + Grid.Column="1" + VerticalAlignment="Center" + Config="{TemplateBinding MarkdownConfig}" + Text="{TemplateBinding Text}" /> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="LabelPlacementStates"> + <VisualState x:Name="LabelAfter" /> + <VisualState x:Name="LabelBefore"> + <VisualState.Setters> + <Setter Target="LabelControl.(Grid.Column)" Value="0" /> + <Setter Target="ShortcutsControl.(Grid.Column)" Value="1" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> +</ResourceDictionary> 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 ed18669eba..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 { @@ -17,7 +17,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set { SetValue(TextProperty, value); } } - public static readonly DependencyProperty TextProperty = DependencyProperty.Register("Text", typeof(string), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); + public static readonly DependencyProperty TextProperty = DependencyProperty.Register(nameof(Text), typeof(string), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); public List<object> Keys { @@ -25,11 +25,61 @@ namespace Microsoft.PowerToys.Settings.UI.Controls set { SetValue(KeysProperty, value); } } - public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List<object>), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); + public static readonly DependencyProperty KeysProperty = DependencyProperty.Register(nameof(Keys), typeof(List<object>), typeof(ShortcutWithTextLabelControl), new PropertyMetadata(default(string))); + + public Placement LabelPlacement + { + get { return (Placement)GetValue(LabelPlacementProperty); } + set { SetValue(LabelPlacementProperty, value); } + } + + 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 == Placement.Before) + { + VisualStateManager.GoToState(labelControl, "LabelBefore", true); + } + else + { + VisualStateManager.GoToState(labelControl, "LabelAfter", true); + } + } + } + + public enum Placement + { + Before, + After, } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml new file mode 100644 index 0000000000..df058fe220 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8" ?> +<UserControl + x:Class="Microsoft.PowerToys.Settings.UI.Controls.Timeline" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d"> + + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + + <Canvas + x:Name="HeaderCanvas" + Grid.Row="0" + Height="24" /> + + <!-- Timeline (bands + ticks + labels) --> + <Border + Grid.Row="1" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="{ThemeResource OverlayCornerRadius}"> + <Canvas + x:Name="TimelineCanvas" + Height="36" + Loaded="TimelineCanvas_Loaded" /> + </Border> + + <!-- Below-chart annotations (sunrise/sunset panels + major labels) --> + <Canvas + x:Name="AnnotationCanvas" + Grid.Row="2" + Height="32" /> + </Grid> +</UserControl> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml.cs new file mode 100644 index 0000000000..307d499fac --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/Timeline.xaml.cs @@ -0,0 +1,664 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Shapes; +using Windows.Foundation; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class Timeline : UserControl + { + public TimeSpan StartTime + { + get => (TimeSpan)GetValue(StartTimeProperty); + set => SetValue(StartTimeProperty, value); + } + + public static readonly DependencyProperty StartTimeProperty = DependencyProperty.Register(nameof(StartTime), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: new TimeSpan(22, 0, 0), OnTimeChanged)); + + public TimeSpan EndTime + { + get => (TimeSpan)GetValue(EndTimeProperty); + set => SetValue(EndTimeProperty, value); + } + + public static readonly DependencyProperty EndTimeProperty = DependencyProperty.Register(nameof(EndTime), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: new TimeSpan(7, 0, 0), OnTimeChanged)); + + public TimeSpan? Sunrise + { + get => (TimeSpan?)GetValue(SunriseProperty); + set => SetValue(SunriseProperty, value); + } + + public static readonly DependencyProperty SunriseProperty = DependencyProperty.Register(nameof(Sunrise), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: null, OnTimeChanged)); + + public TimeSpan? Sunset + { + get => (TimeSpan?)GetValue(SunsetProperty); + set => SetValue(SunsetProperty, value); + } + + public static readonly DependencyProperty SunsetProperty = DependencyProperty.Register(nameof(Sunset), typeof(TimeSpan), typeof(Timeline), new PropertyMetadata(defaultValue: null, OnTimeChanged)); + + private readonly List<int> _tickHours = new(); + + // Locale 24h/12h + private readonly bool _is24h = CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern.Contains('H'); + + // Visuals + private readonly List<Line> _ticks = new(); + private readonly List<TextBlock> _majorTickBottomLabels = new(); // 00,06,12,18,24 (below) + + private readonly List<Border> _darkRects = new(); // up to 2 (wrap) + private readonly List<Border> _lightRects = new(); // up to 2 (complement) + + private TextBlock _startEdgeLabel; // top-of-chart + private TextBlock _endEdgeLabel; + + private Line _sunriseTick; + private Line _sunsetTick; + + // Add/replace these constants (top of your class) + private const int TickHourStep = 2; // <-- every 2 hours + + private StackPanel _sunrisePanel; // icon + time (below chart) + private StackPanel _sunsetPanel; + + public Timeline() + { + this.InitializeComponent(); + this.Loaded += Timeline_Loaded; + this.IsEnabledChanged += Timeline_IsEnabledChanged; + } + + protected override AutomationPeer OnCreateAutomationPeer() + { + return new TimelineAutomationPeer(this); + } + + private void Timeline_Loaded(object sender, RoutedEventArgs e) + { + CheckEnabledState(); + } + + private void Timeline_IsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + CheckEnabledState(); + } + + private void CheckEnabledState() + { + if (IsEnabled) + { + this.Opacity = 1.0; + } + else + { + this.Opacity = 0.4; + } + } + + private static void OnTimeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((Timeline)d).Setup(); + } + + private void Setup() + { + EnsureBands(); + EnsureTicks(); + EnsureStartEndEdgeLabels(); + EnsureSunriseSunsetTicks(); + EnsureSunPanels(); + EnsureMajorTickLabels(); + UpdateAll(); + } + + private void TimelineCanvas_Loaded(object sender, RoutedEventArgs e) + { + // SizeChanged wiring here (as requested) + HeaderCanvas.SizeChanged += (_, __) => UpdateAll(); + TimelineCanvas.SizeChanged += (_, __) => UpdateAll(); + AnnotationCanvas.SizeChanged += (_, __) => UpdateAll(); + Setup(); + } + + private void UpdateAll() + { + UpdateBandsLayout(); + UpdateTicksLayout(); + UpdateStartEndEdgeLabelsLayout(); + UpdateSunriseSunsetTicksLayout(); + UpdateSunPanelsLayout(); + UpdateMajorTickLabelsLayout(); + AutomationProperties.SetHelpText( + this, + $"Start={StartTime};End={EndTime};Sunrise={Sunrise};Sunset={Sunset}"); + } + + // ===== Ticks ===== + private void EnsureTicks() + { + if (_ticks.Count > 0) + { + return; + } + + _tickHours.Clear(); + + // Build ticks at 0,2,4,...,24 but skip the first/last MAJOR ticks (0 and 24) + for (int hour = 0; hour <= 24; hour += TickHourStep) + { + bool isMajor = hour % 6 == 0; + if (isMajor && (hour == 0 || hour == 24)) + { + continue; // skip first/last major ticks + } + + var line = new Line + { + Style = (Style)Application.Current.Resources[isMajor ? "MajorHourTickStyle" : "HourTickStyle"], + }; + + Canvas.SetZIndex(line, 0); // above bands (adjust if needed) + + _ticks.Add(line); + _tickHours.Add(hour); + + // If you actually want these IN the chart, use TimelineCanvas instead: + AnnotationCanvas.Children.Add(line); // or TimelineCanvas.Children.Add(line); + } + } + + private void UpdateTicksLayout() + { + double w = TimelineCanvas.ActualWidth; + double h = TimelineCanvas.ActualHeight; // keeping your offset + if (w <= 0 || h <= 0) + { + return; + } + + double minorLen = h * 0.1; + double majorLen = h * 0.2; + + for (int i = 0; i < _ticks.Count; i++) + { + int hour = _tickHours[i]; + double x = Math.Round((hour / 24.0) * w); + + var line = _ticks[i]; + double len = (hour % 6 == 0) ? majorLen : minorLen; + + line.X1 = x; + line.Y1 = 0; + line.X2 = x; + line.Y2 = len; + } + } + + // ===== Bands (Dark + Light) ===== + private void EnsureBands() + { + if (_darkRects.Count == 0) + { + _darkRects.Add(MakeBandRect(isDark: false)); + _darkRects.Add(MakeBandRect(isDark: false)); + } + + if (_lightRects.Count == 0) + { + _lightRects.Add(MakeBandRect(isDark: true)); + _lightRects.Add(MakeBandRect(isDark: true)); + } + } + + private Border MakeBandRect(bool isDark) + { + var r = new Border(); + if (isDark) + { + r.Style = (Style)Application.Current.Resources["DarkBandStyle"]; + FontIcon icon = new FontIcon(); + icon.Style = (Style)Application.Current.Resources["DarkBandIconStyle"]; + r.Child = icon; + } + else + { + r.Style = (Style)Application.Current.Resources["LightBandStyle"]; + } + + Canvas.SetZIndex(r, 5); // below ticks/labels + TimelineCanvas.Children.Add(r); + return r; + } + + private void UpdateBandsLayout() + { + double w = TimelineCanvas.ActualWidth; + double h = TimelineCanvas.ActualHeight; + if (w <= 0 || h <= 0) + { + return; + } + + foreach (var r in _darkRects) + { + r.Height = h; + Canvas.SetTop(r, 0); + } + + foreach (var r in _lightRects) + { + r.Height = h; + Canvas.SetTop(r, 0); + } + + var darkRanges = ToRanges(StartTime, EndTime); // 1 or 2 segments + var lightRanges = ComplementRanges(darkRanges); // 0..2 + + LayoutRangeRects(_darkRects, darkRanges, w); + LayoutRangeRects(_lightRects, lightRanges, w); + } + + private static void LayoutRangeRects(List<Border> rects, List<(TimeSpan Start, TimeSpan End)> ranges, double width) + { + for (int i = 0; i < rects.Count; i++) + { + if (i < ranges.Count) + { + var (start, end) = ranges[i]; + double x = Math.Round((start.TotalHours / 24.0) * width); + double x2 = Math.Round((end.TotalHours / 24.0) * width); + + var r = rects[i]; + Canvas.SetLeft(r, x); + r.Width = Math.Max(0, x2 - x); + r.Visibility = Visibility.Visible; + } + else + { + rects[i].Visibility = Visibility.Collapsed; + } + } + } + + private static List<(TimeSpan Start, TimeSpan End)> ToRanges(TimeSpan start, TimeSpan end) + { + // Full day + if (start == end) + { + return new() { (TimeSpan.Zero, TimeSpan.FromHours(24)) }; + } + + if (start < end) + { + return new() { (start, end) }; + } + + // Wraps midnight + return new() + { + (start, TimeSpan.FromHours(24)), + (TimeSpan.Zero, end), + }; + } + + private static List<(TimeSpan Start, TimeSpan End)> ComplementRanges(List<(TimeSpan Start, TimeSpan End)> dark) + { + var res = new List<(TimeSpan, TimeSpan)>(); + + // If dark covers the full day, there is no light + if (dark.Count == 1 && dark[0].Start == TimeSpan.Zero && dark[0].End == TimeSpan.FromHours(24)) + { + return res; + } + + if (dark.Count == 1) + { + var (ds, de) = dark[0]; + if (ds > TimeSpan.Zero) + { + res.Add((TimeSpan.Zero, ds)); + } + + if (de < TimeSpan.FromHours(24)) + { + res.Add((de, TimeSpan.FromHours(24))); + } + } + else + { + // dark[0] = [a,24), dark[1] = [0,b) => single light [b,a) + var a = dark[0].Start; + var b = dark[1].End; + res.Add((b, a)); + } + + return res; + } + + // ===== Start & End labels (TOP of chart, ABOVE rectangles) ===== + private void EnsureStartEndEdgeLabels() + { + if (_startEdgeLabel == null) + { + _startEdgeLabel = new TextBlock { Style = (Style)Application.Current.Resources["EdgeLabelStyle"] }; + HeaderCanvas.Children.Add(_startEdgeLabel); + Canvas.SetZIndex(_startEdgeLabel, 25); + } + + if (_endEdgeLabel == null) + { + _endEdgeLabel = new TextBlock { Style = (Style)Application.Current.Resources["EdgeLabelStyle"] }; + HeaderCanvas.Children.Add(_endEdgeLabel); + Canvas.SetZIndex(_endEdgeLabel, 25); + } + } + + private void UpdateStartEndEdgeLabelsLayout() + { + double w = TimelineCanvas.ActualWidth; + if (w <= 0) + { + return; + } + + _startEdgeLabel.Text = TimeSpanHelper.Convert(StartTime); + _endEdgeLabel.Text = TimeSpanHelper.Convert(EndTime); + + PlaceTopLabelAtTime(_startEdgeLabel, StartTime, w); + PlaceTopLabelAtTime(_endEdgeLabel, EndTime, w); + } + + private void PlaceTopLabelAtTime(TextBlock tb, TimeSpan t, double timelineWidth) + { + double x = Math.Round((t.TotalHours / 24.0) * timelineWidth); + double textW = MeasureTextWidth(tb); + double desiredLeft = x - (textW / 2.0); + + Canvas.SetLeft(tb, Clamp(desiredLeft, 0, timelineWidth - textW)); + Canvas.SetTop(tb, 0); + tb.Visibility = Visibility.Visible; + } + + // ===== Sunrise/Sunset ticks on chart ===== + private void EnsureSunriseSunsetTicks() + { + if (_sunriseTick == null) + { + _sunriseTick = new Line { Style = (Style)Application.Current.Resources["SunRiseMarkerTickStyle"] }; + TimelineCanvas.Children.Add(_sunriseTick); + Canvas.SetZIndex(_sunriseTick, 12); + } + + if (_sunsetTick == null) + { + _sunsetTick = new Line { Style = (Style)Application.Current.Resources["SunSetMarkerTickStyle"] }; + TimelineCanvas.Children.Add(_sunsetTick); + Canvas.SetZIndex(_sunsetTick, 12); + } + } + + private void UpdateSunriseSunsetTicksLayout() + { + double w = TimelineCanvas.ActualWidth; + double h = TimelineCanvas.ActualHeight + 24; + if (w <= 0 || h <= 0) + { + return; + } + + void Place(Line tick, TimeSpan t) + { + double x = Math.Round((t.TotalHours / 24.0) * w); + tick.X1 = x; + tick.X2 = x; + tick.Y1 = 0; + tick.Y2 = h; + } + + if (_sunriseTick != null) + { + if (Sunrise.HasValue) + { + Place(_sunriseTick, Sunrise.Value); + _sunriseTick.Visibility = Visibility.Visible; + } + else + { + _sunriseTick.Visibility = Visibility.Collapsed; + } + } + + if (_sunsetTick != null) + { + if (Sunset.HasValue) + { + Place(_sunsetTick, Sunset.Value); + _sunsetTick.Visibility = Visibility.Visible; + } + else + { + _sunsetTick.Visibility = Visibility.Collapsed; + } + } + } + + // ===== Sunrise/Sunset panels (below chart) ===== + private void EnsureSunPanels() + { + if (_sunrisePanel == null) + { + _sunrisePanel = MakeSunPanel("\uEC8A"); + AnnotationCanvas.Children.Add(_sunrisePanel); + } + + if (_sunsetPanel == null) + { + _sunsetPanel = MakeSunPanel("\uED3A"); + AnnotationCanvas.Children.Add(_sunsetPanel); + } + } + + private StackPanel MakeSunPanel(string iconEmoji) + { + var icon = new FontIcon { Glyph = iconEmoji, Style = (Style)Application.Current.Resources["SunIconStyle"] }; + var sp = new StackPanel { Orientation = Orientation.Vertical, Spacing = 2 }; + sp.Children.Add(icon); + return sp; + } + + private void UpdateSunPanelsLayout() + { + double timelineW = TimelineCanvas.ActualWidth; + double annotationW = AnnotationCanvas.ActualWidth; + if (annotationW <= 0) + { + annotationW = timelineW; + } + + if (timelineW <= 0 || annotationW <= 0) + { + return; + } + + void Place(StackPanel sp, TimeSpan t) + { + double panelW = MeasureElementWidth(sp); + double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW); + double left = Clamp(xTimeline - (panelW / 2.0), 0, annotationW - panelW); + Canvas.SetLeft(sp, left); + Canvas.SetTop(sp, 8); + } + + if (_sunrisePanel != null) + { + if (Sunrise.HasValue) + { + ToolTipService.SetToolTip(_sunrisePanel, $"Sunrise: {TimeSpanHelper.Convert(Sunrise.Value)}"); + _sunrisePanel.Visibility = Visibility.Visible; + Place(_sunrisePanel, Sunrise.Value); + } + else + { + ToolTipService.SetToolTip(_sunrisePanel, null); + _sunrisePanel.Visibility = Visibility.Collapsed; + } + } + + if (_sunsetPanel != null) + { + if (Sunset.HasValue) + { + ToolTipService.SetToolTip(_sunsetPanel, $"Sunset: {TimeSpanHelper.Convert(Sunset.Value)}"); + _sunsetPanel.Visibility = Visibility.Visible; + Place(_sunsetPanel, Sunset.Value); + } + else + { + ToolTipService.SetToolTip(_sunsetPanel, null); + _sunsetPanel.Visibility = Visibility.Collapsed; + } + } + } + + // ===== Major labels BELOW chart (00,06,12,18,24) ===== + private void EnsureMajorTickLabels() + { + if (_majorTickBottomLabels.Count > 0) + { + return; + } + + // Includes 24:00 at end + for (int i = 0; i < 5; i++) + { + var tb = new TextBlock { Style = (Style)Application.Current.Resources["MajorTickLabelStyle"] }; + Canvas.SetZIndex(tb, 5); // on annotation canvas + _majorTickBottomLabels.Add(tb); + AnnotationCanvas.Children.Add(tb); + } + } + + private void UpdateMajorTickLabelsLayout() + { + double timelineW = TimelineCanvas.ActualWidth; + double annotationW = AnnotationCanvas.ActualWidth; + if (annotationW <= 0) + { + annotationW = timelineW; + } + + if (timelineW <= 0 || annotationW <= 0) + { + return; + } + + int[] hours = { 0, 6, 12, 18, 24 }; + + // 1) Place labels first + for (int i = 0; i < hours.Length; i++) + { + var tb = _majorTickBottomLabels[i]; + var t = TimeSpan.FromHours(hours[i]); + tb.Text = TimeSpanHelper.Convert(t); + + double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW); + double textW = MeasureTextWidth(tb); + double left = xTimeline - (textW / 2.0); + + // Middle ones (06, 12) exact center; edges clamp inside canvas + if (i == 1 || i == 2) + { + Canvas.SetLeft(tb, left); + } + else + { + Canvas.SetLeft(tb, Clamp(left, 0, annotationW - textW)); + } + + Canvas.SetTop(tb, 8); // your existing baseline below chart + tb.Visibility = Visibility.Visible; + } + + // 2) Compute sunrise/sunset occupied horizontal ranges (if present) + (double Left, double Right)? sunriseBounds = null; + (double Left, double Right)? sunsetBounds = null; + + if (Sunrise.HasValue && _sunrisePanel != null) + { + sunriseBounds = GetAnnotationBoundsForTime(Sunrise.Value, timelineW, annotationW, _sunrisePanel); + } + + if (Sunset.HasValue && _sunsetPanel != null) + { + sunsetBounds = GetAnnotationBoundsForTime(Sunset.Value, timelineW, annotationW, _sunsetPanel); + } + + // 3) Hide any label that intersects the sunrise/sunset panel bounds + for (int i = 0; i < hours.Length; i++) + { + var tb = _majorTickBottomLabels[i]; + if (tb.Visibility != Visibility.Visible) + { + continue; + } + + var lbl = GetLabelBounds(tb); + + bool hide = + (sunriseBounds.HasValue && Intersects(lbl, sunriseBounds.Value)) || + (sunsetBounds.HasValue && Intersects(lbl, sunsetBounds.Value)); // include sunset too; remove if you only want sunrise + + tb.Visibility = hide ? Visibility.Collapsed : Visibility.Visible; + } + } + + // ===== Utilities ===== + private static double Clamp(double v, double min, double max) => Math.Max(min, Math.Min(max, v)); + + private static double MeasureElementWidth(FrameworkElement el) + { + el.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + return el.DesiredSize.Width; + } + + private static double MeasureTextWidth(TextBlock tb) + { + tb.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + return tb.DesiredSize.Width; + } + + private static bool Intersects((double Left, double Right) a, (double Left, double Right) b, double pad = 4) + { + // Horizontal overlap with padding + return !(a.Right + pad <= b.Left || b.Right + pad <= a.Left); + } + + private (double Left, double Right) GetAnnotationBoundsForTime(TimeSpan t, double timelineW, double annotationW, FrameworkElement element) + { + // Compute the *actual* left/right the panel will occupy in AnnotationCanvas + double panelW = MeasureElementWidth(element); + double xTimeline = Math.Round((t.TotalHours / 24.0) * timelineW); + double left = Clamp(xTimeline - (panelW / 2.0), 0, annotationW - panelW); + return (left, left + panelW); + } + + private (double Left, double Right) GetLabelBounds(TextBlock tb) + { + double w = MeasureTextWidth(tb); + double left = Canvas.GetLeft(tb); + return (left, left + w); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineAutomationPeer.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineAutomationPeer.cs new file mode 100644 index 0000000000..a32f99059a --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineAutomationPeer.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public partial class TimelineAutomationPeer : FrameworkElementAutomationPeer + { + public TimelineAutomationPeer(Timeline owner) + : base(owner) + { + } + + protected override string GetClassNameCore() => "Timeline"; + + protected override AutomationControlType GetAutomationControlTypeCore() + => AutomationControlType.Custom; + + protected override string GetAutomationIdCore() + { + var owner = (Timeline)Owner; + var id = AutomationProperties.GetAutomationId(owner); + return string.IsNullOrEmpty(id) ? base.GetAutomationIdCore() : id; + } + + protected override string GetNameCore() + { + var owner = (Timeline)Owner; + var name = AutomationProperties.GetName(owner); + return !string.IsNullOrEmpty(name) + ? name + : $"Timeline from {owner.StartTime} to {owner.EndTime}"; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineStyles.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineStyles.xaml new file mode 100644 index 0000000000..82acf66ef5 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Timeline/TimelineStyles.xaml @@ -0,0 +1,84 @@ +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + <Style x:Key="HourTickStyle" TargetType="Line"> + <Setter Property="Stroke" Value="{ThemeResource TextFillColorTertiaryBrush}" /> + <Setter Property="StrokeThickness" Value="1" /> + </Style> + + <Style + x:Key="MajorHourTickStyle" + BasedOn="{StaticResource HourTickStyle}" + TargetType="Line"> + <Setter Property="StrokeThickness" Value="2" /> + <Setter Property="Stroke" Value="{ThemeResource TextFillColorTertiaryBrush}" /> + </Style> + + <Style x:Key="SunRiseMarkerTickStyle" TargetType="Line"> + <Setter Property="Stroke" Value="{ThemeResource TextFillColorTertiaryBrush}" /> + <Setter Property="StrokeThickness" Value="1" /> + <Setter Property="StrokeDashArray" Value="2,2" /> + </Style> + + + <Style x:Key="SunSetMarkerTickStyle" TargetType="Line"> + <Setter Property="Stroke" Value="{ThemeResource TextFillColorTertiaryBrush}" /> + <Setter Property="StrokeThickness" Value="1" /> + <Setter Property="StrokeDashArray" Value="2,2" /> + </Style> + + <!-- ===== Text / Labels ===== --> + <Style x:Key="EdgeLabelStyle" TargetType="TextBlock"> + <Setter Property="FontSize" Value="14" /> + <Setter Property="FontWeight" Value="SemiBold" /> + <Setter Property="Foreground" Value="{ThemeResource AccentTextFillColorPrimaryBrush}" /> + <Setter Property="IsHitTestVisible" Value="False" /> + </Style> + + <!-- Below-chart labels for 00/06/12/18/24 --> + <Style x:Key="MajorTickLabelStyle" TargetType="TextBlock"> + <Setter Property="FontSize" Value="12" /> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorTertiaryBrush}" /> + <Setter Property="IsHitTestVisible" Value="False" /> + </Style> + + <!-- Sunrise/Sunset panel styles --> + <Style x:Key="SunIconStyle" TargetType="FontIcon"> + <Setter Property="FontSize" Value="18" /> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorTertiaryBrush}" /> + <Setter Property="HorizontalAlignment" Value="Center" /> + </Style> + + <!-- ===== Bands ===== --> + <Style x:Key="DarkBandStyle" TargetType="Border"> + <!--<Setter Property="Background"> + <Setter.Value> + <SolidColorBrush Opacity="0.6" Color="{ThemeResource SystemAccentColorDark1}"/> + </Setter.Value> + </Setter>--> + <Setter Property="Background" Value="{ThemeResource AccentFillColorTertiaryBrush}" /> + <Setter Property="CornerRadius" Value="8" /> + <Setter Property="Padding" Value="4" /> + <Setter Property="ToolTipService.ToolTip" Value="Dark mode" /> + </Style> + + <Style x:Key="LightBandStyle" TargetType="Border"> + <Setter Property="Background" Value="{ThemeResource CardBackgroundFillColorDefaultBrush}" /> + <Setter Property="ToolTipService.ToolTip" Value="Light mode" /> + </Style> + + <Style x:Key="DarkBandIconStyle" TargetType="FontIcon"> + <Setter Property="FontSize" Value="14" /> + <Setter Property="Glyph" Value="" /> + <Setter Property="Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" /> + <Setter Property="HorizontalAlignment" Value="Center" /> + </Style> + <Style + x:Key="LightBandIconStyle" + BasedOn="{StaticResource DarkBandIconStyle}" + TargetType="FontIcon"> + <Setter Property="Glyph" Value="" /> + <Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" /> + </Style> + +</ResourceDictionary> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.Properties.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.Properties.cs new file mode 100644 index 0000000000..db4e5244cf --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.Properties.cs @@ -0,0 +1,229 @@ +// 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; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +public partial class TitleBar : Control +{ + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="Icon"/> property. + /// </summary> + public static readonly DependencyProperty IconProperty = DependencyProperty.Register(nameof(Icon), typeof(IconElement), typeof(TitleBar), new PropertyMetadata(null, IconChanged)); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="Title"/> property. + /// </summary> + public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(nameof(Title), typeof(string), typeof(TitleBar), new PropertyMetadata(default(string))); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="Subtitle"/> property. + /// </summary> + public static readonly DependencyProperty SubtitleProperty = DependencyProperty.Register(nameof(Subtitle), typeof(string), typeof(TitleBar), new PropertyMetadata(default(string))); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="Content"/> property. + /// </summary> + public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(nameof(Content), typeof(object), typeof(TitleBar), new PropertyMetadata(null, ContentChanged)); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="Footer"/> property. + /// </summary> + public static readonly DependencyProperty FooterProperty = DependencyProperty.Register(nameof(Footer), typeof(object), typeof(TitleBar), new PropertyMetadata(null, FooterChanged)); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="IsBackButtonVisible"/> property. + /// </summary> + public static readonly DependencyProperty IsBackButtonVisibleProperty = DependencyProperty.Register(nameof(IsBackButtonVisible), typeof(bool), typeof(TitleBar), new PropertyMetadata(false, IsBackButtonVisibleChanged)); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="IsPaneButtonVisible"/> property. + /// </summary> + public static readonly DependencyProperty IsPaneButtonVisibleProperty = DependencyProperty.Register(nameof(IsPaneButtonVisible), typeof(bool), typeof(TitleBar), new PropertyMetadata(false, IsPaneButtonVisibleChanged)); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="DisplayMode"/> property. + /// </summary> + public static readonly DependencyProperty DisplayModeProperty = DependencyProperty.Register(nameof(DisplayMode), typeof(DisplayMode), typeof(TitleBar), new PropertyMetadata(DisplayMode.Standard, DisplayModeChanged)); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="CompactStateBreakpoint + /// "/> property. + /// </summary> + public static readonly DependencyProperty CompactStateBreakpointProperty = DependencyProperty.Register(nameof(CompactStateBreakpoint), typeof(int), typeof(TitleBar), new PropertyMetadata(850)); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="AutoConfigureCustomTitleBar"/> property. + /// </summary> + public static readonly DependencyProperty AutoConfigureCustomTitleBarProperty = DependencyProperty.Register(nameof(AutoConfigureCustomTitleBar), typeof(bool), typeof(TitleBar), new PropertyMetadata(true, AutoConfigureCustomTitleBarChanged)); + + /// <summary> + /// The backing <see cref="DependencyProperty"/> for the <see cref="Window"/> property. + /// </summary> + public static readonly DependencyProperty WindowProperty = DependencyProperty.Register(nameof(Window), typeof(Window), typeof(TitleBar), new PropertyMetadata(null)); + + /// <summary> + /// The event that gets fired when the back button is clicked + /// </summary> +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + public event EventHandler<RoutedEventArgs>? BackButtonClick; +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + + /// <summary> + /// The event that gets fired when the pane toggle button is clicked + /// </summary> +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + public event EventHandler<RoutedEventArgs>? PaneButtonClick; +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + + /// <summary> + /// Gets or sets the Icon + /// </summary> + public IconElement Icon + { + get => (IconElement)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + /// <summary> + /// Gets or sets the Title + /// </summary> + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + /// <summary> + /// Gets or sets the Subtitle + /// </summary> + public string Subtitle + { + get => (string)GetValue(SubtitleProperty); + set => SetValue(SubtitleProperty, value); + } + + /// <summary> + /// Gets or sets the content shown at the center of the TitleBar. When setting this, using DisplayMode=Tall is recommended. + /// </summary> + public object Content + { + get => (object)GetValue(ContentProperty); + set => SetValue(ContentProperty, value); + } + + /// <summary> + /// Gets or sets the content shown at the right of the TitleBar, next to the caption buttons. When setting this, using DisplayMode=Tall is recommended. + /// </summary> + public object Footer + { + get => (object)GetValue(FooterProperty); + set => SetValue(FooterProperty, value); + } + + /// <summary> + /// Gets or sets DisplayMode. Compact is default (32px), Tall is recommended when setting the Content or Footer. + /// </summary> + public DisplayMode DisplayMode + { + get => (DisplayMode)GetValue(DisplayModeProperty); + set => SetValue(DisplayModeProperty, value); + } + + /// <summary> + /// Gets or sets a value indicating whether gets or sets the visibility of the back button. + /// </summary> + public bool IsBackButtonVisible + { + get => (bool)GetValue(IsBackButtonVisibleProperty); + set => SetValue(IsBackButtonVisibleProperty, value); + } + + /// <summary> + /// Gets or sets a value indicating whether gets or sets the visibility of the pane toggle button. + /// </summary> + public bool IsPaneButtonVisible + { + get => (bool)GetValue(IsPaneButtonVisibleProperty); + set => SetValue(IsPaneButtonVisibleProperty, value); + } + + /// <summary> + /// Gets or sets the breakpoint of when the compact state is triggered. + /// </summary> + public int CompactStateBreakpoint + { + get => (int)GetValue(CompactStateBreakpointProperty); + set => SetValue(CompactStateBreakpointProperty, value); + } + + /// <summary> + /// Gets or sets a value indicating whether gets or sets if the TitleBar should auto configure ExtendContentIntoTitleBar and CaptionButton background colors. + /// </summary> + public bool AutoConfigureCustomTitleBar + { + get => (bool)GetValue(AutoConfigureCustomTitleBarProperty); + set => SetValue(AutoConfigureCustomTitleBarProperty, value); + } + + /// <summary> + /// Gets or sets the window the TitleBar should configure. + /// </summary> + public Window Window + { + get => (Window)GetValue(WindowProperty); + set => SetValue(WindowProperty, value); + } + + private static void IsBackButtonVisibleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((TitleBar)d).Update(); + } + + private static void IsPaneButtonVisibleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((TitleBar)d).Update(); + } + + private static void DisplayModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((TitleBar)d).Update(); + } + + private static void ContentChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((TitleBar)d).Update(); + } + + private static void FooterChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((TitleBar)d).Update(); + } + + private static void IconChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((TitleBar)d).Update(); + } + + private static void AutoConfigureCustomTitleBarChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (((TitleBar)d).AutoConfigureCustomTitleBar) + { + ((TitleBar)d).Configure(); + } + else + { + ((TitleBar)d).Reset(); + } + } +} + +public enum DisplayMode +{ + Standard, + Tall, +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.WASDK.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.WASDK.cs new file mode 100644 index 0000000000..261044d98e --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.WASDK.cs @@ -0,0 +1,195 @@ +// 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 Microsoft.UI; +using Microsoft.UI.Input; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.Foundation; +using Windows.Graphics; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +[TemplatePart(Name = nameof(PART_FooterPresenter), Type = typeof(ContentPresenter))] +[TemplatePart(Name = nameof(PART_ContentPresenter), Type = typeof(ContentPresenter))] + +public partial class TitleBar : Control +{ +#pragma warning disable SA1306 // Field names should begin with lower-case letter +#pragma warning disable SA1310 // Field names should not contain underscore +#pragma warning disable SA1400 // Access modifier should be declared +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + ContentPresenter? PART_ContentPresenter; + ContentPresenter? PART_FooterPresenter; + #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. +#pragma warning restore SA1400 // Access modifier should be declared +#pragma warning restore SA1306 // Field names should begin with lower-case letter +#pragma warning restore SA1310 // Field names should not contain underscore + + private void SetWASDKTitleBar() + { + if (this.Window == null) + { + return; + } + + if (AutoConfigureCustomTitleBar) + { + Window.AppWindow.TitleBar.ExtendsContentIntoTitleBar = true; + + this.Window.SizeChanged -= Window_SizeChanged; + this.Window.SizeChanged += Window_SizeChanged; + this.Window.Activated -= Window_Activated; + this.Window.Activated += Window_Activated; + + if (Window.Content is FrameworkElement rootElement) + { + UpdateCaptionButtons(rootElement); + rootElement.ActualThemeChanged += (s, e) => + { + UpdateCaptionButtons(rootElement); + }; + } + + PART_ContentPresenter = GetTemplateChild(nameof(PART_ContentPresenter)) as ContentPresenter; + PART_FooterPresenter = GetTemplateChild(nameof(PART_FooterPresenter)) as ContentPresenter; + + // Get caption button occlusion information. + int captionButtonOcclusionWidthRight = Window.AppWindow.TitleBar.RightInset; + int captionButtonOcclusionWidthLeft = Window.AppWindow.TitleBar.LeftInset; + PART_LeftPaddingColumn!.Width = new GridLength(captionButtonOcclusionWidthLeft); + PART_RightPaddingColumn!.Width = new GridLength(captionButtonOcclusionWidthRight); + + if (DisplayMode == DisplayMode.Tall) + { + // Choose a tall title bar to provide more room for interactive elements + // like search box or person picture controls. + Window.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall; + } + else + { + Window.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Standard; + } + + // Recalculate the drag region for the custom title bar + // if you explicitly defined new draggable areas. + SetDragRegionForCustomTitleBar(); + + _isAutoConfigCompleted = true; + } + } + + private void Window_SizeChanged(object sender, WindowSizeChangedEventArgs args) + { + UpdateVisualStateAndDragRegion(args.Size); + } + + private void UpdateCaptionButtons(FrameworkElement rootElement) + { + Window.AppWindow.TitleBar.ButtonBackgroundColor = Colors.Transparent; + Window.AppWindow.TitleBar.ButtonInactiveBackgroundColor = Colors.Transparent; + if (rootElement.ActualTheme == ElementTheme.Dark) + { + Window.AppWindow.TitleBar.ButtonForegroundColor = Colors.White; + Window.AppWindow.TitleBar.ButtonInactiveForegroundColor = Colors.DarkGray; + } + else + { + Window.AppWindow.TitleBar.ButtonForegroundColor = Colors.Black; + Window.AppWindow.TitleBar.ButtonInactiveForegroundColor = Colors.DarkGray; + } + } + + private void ResetWASDKTitleBar() + { + if (this.Window == null) + { + return; + } + + // Only reset if we were the ones who configured + if (_isAutoConfigCompleted) + { + Window.AppWindow.TitleBar.ExtendsContentIntoTitleBar = false; + this.Window.SizeChanged -= Window_SizeChanged; + this.Window.Activated -= Window_Activated; + SizeChanged -= this.TitleBar_SizeChanged; + Window.AppWindow.TitleBar.ResetToDefault(); + } + } + + private void Window_Activated(object sender, WindowActivatedEventArgs args) + { + if (args.WindowActivationState == WindowActivationState.Deactivated) + { + VisualStateManager.GoToState(this, WindowDeactivatedState, true); + } + else + { + VisualStateManager.GoToState(this, WindowActivatedState, true); + } + } + + public void SetDragRegionForCustomTitleBar() + { + if (AutoConfigureCustomTitleBar && Window is not null) + { + ClearDragRegions(NonClientRegionKind.Passthrough); +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + var items = new FrameworkElement?[] { PART_ContentPresenter, PART_FooterPresenter, PART_ButtonHolder }; +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + var validItems = items.Where(x => x is not null).Select(x => x!).ToArray(); // Prune null items + + SetDragRegion(NonClientRegionKind.Passthrough, validItems); + } + } + + public double GetRasterizationScaleForElement(UIElement element) + { + if (element.XamlRoot != null) + { + return element.XamlRoot.RasterizationScale; + } + + return 0.0; + } + + public void SetDragRegion(NonClientRegionKind nonClientRegionKind, params FrameworkElement[] frameworkElements) + { + List<RectInt32> rects = new List<RectInt32>(); + var scale = GetRasterizationScaleForElement(this); + + foreach (var frameworkElement in frameworkElements) + { + if (frameworkElement == null) + { + continue; + } + + GeneralTransform transformElement = frameworkElement.TransformToVisual(null); + Rect bounds = transformElement.TransformBounds(new Rect(0, 0, frameworkElement.ActualWidth, frameworkElement.ActualHeight)); + var transparentRect = new RectInt32( + _X: (int)Math.Round(bounds.X * scale), + _Y: (int)Math.Round(bounds.Y * scale), + _Width: (int)Math.Round(bounds.Width * scale), + _Height: (int)Math.Round(bounds.Height * scale)); + rects.Add(transparentRect); + } + + if (rects.Count > 0) + { + InputNonClientPointerSource.GetForWindowId(Window.AppWindow.Id).SetRegionRects(nonClientRegionKind, rects.ToArray()); + } + } + + public void ClearDragRegions(NonClientRegionKind nonClientRegionKind) + { + InputNonClientPointerSource.GetForWindowId(Window.AppWindow.Id).ClearRegionRects(nonClientRegionKind); + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.cs new file mode 100644 index 0000000000..f49bcc3bec --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.cs @@ -0,0 +1,220 @@ +// 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; +using Windows.Foundation; + +namespace Microsoft.PowerToys.Settings.UI.Controls; + +[TemplateVisualState(Name = BackButtonVisibleState, GroupName = BackButtonStates)] +[TemplateVisualState(Name = BackButtonCollapsedState, GroupName = BackButtonStates)] +[TemplateVisualState(Name = PaneButtonVisibleState, GroupName = PaneButtonStates)] +[TemplateVisualState(Name = PaneButtonCollapsedState, GroupName = PaneButtonStates)] +[TemplateVisualState(Name = WindowActivatedState, GroupName = ActivationStates)] +[TemplateVisualState(Name = WindowDeactivatedState, GroupName = ActivationStates)] +[TemplateVisualState(Name = StandardState, GroupName = DisplayModeStates)] +[TemplateVisualState(Name = TallState, GroupName = DisplayModeStates)] +[TemplateVisualState(Name = IconVisibleState, GroupName = IconStates)] +[TemplateVisualState(Name = IconCollapsedState, GroupName = IconStates)] +[TemplateVisualState(Name = ContentVisibleState, GroupName = ContentStates)] +[TemplateVisualState(Name = ContentCollapsedState, GroupName = ContentStates)] +[TemplateVisualState(Name = FooterVisibleState, GroupName = FooterStates)] +[TemplateVisualState(Name = FooterCollapsedState, GroupName = FooterStates)] +[TemplateVisualState(Name = WideState, GroupName = ReflowStates)] +[TemplateVisualState(Name = NarrowState, GroupName = ReflowStates)] +[TemplatePart(Name = PartBackButton, Type = typeof(Button))] +[TemplatePart(Name = PartPaneButton, Type = typeof(Button))] +[TemplatePart(Name = nameof(PART_LeftPaddingColumn), Type = typeof(ColumnDefinition))] +[TemplatePart(Name = nameof(PART_RightPaddingColumn), Type = typeof(ColumnDefinition))] +[TemplatePart(Name = nameof(PART_ButtonHolder), Type = typeof(StackPanel))] + +public partial class TitleBar : Control +{ + private const string PartBackButton = "PART_BackButton"; + private const string PartPaneButton = "PART_PaneButton"; + + private const string BackButtonVisibleState = "BackButtonVisible"; + private const string BackButtonCollapsedState = "BackButtonCollapsed"; + private const string BackButtonStates = "BackButtonStates"; + + private const string PaneButtonVisibleState = "PaneButtonVisible"; + private const string PaneButtonCollapsedState = "PaneButtonCollapsed"; + private const string PaneButtonStates = "PaneButtonStates"; + + private const string WindowActivatedState = "Activated"; + private const string WindowDeactivatedState = "Deactivated"; + private const string ActivationStates = "WindowActivationStates"; + + private const string IconVisibleState = "IconVisible"; + private const string IconCollapsedState = "IconCollapsed"; + private const string IconStates = "IconStates"; + + private const string StandardState = "Standard"; + private const string TallState = "Tall"; + private const string DisplayModeStates = "DisplayModeStates"; + + private const string ContentVisibleState = "ContentVisible"; + private const string ContentCollapsedState = "ContentCollapsed"; + private const string ContentStates = "ContentStates"; + + private const string FooterVisibleState = "FooterVisible"; + private const string FooterCollapsedState = "FooterCollapsed"; + private const string FooterStates = "FooterStates"; + + private const string WideState = "Wide"; + private const string NarrowState = "Narrow"; + private const string ReflowStates = "ReflowStates"; + +#pragma warning disable SA1306 // Field names should begin with lower-case letter +#pragma warning disable SA1310 // Field names should not contain underscore +#pragma warning disable SA1400 // Access modifier should be declared +#pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. + ColumnDefinition? PART_RightPaddingColumn; + ColumnDefinition? PART_LeftPaddingColumn; + StackPanel? PART_ButtonHolder; +#pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. +#pragma warning restore SA1400 // Access modifier should be declared +#pragma warning restore SA1306 // Field names should begin with lower-case letter +#pragma warning restore SA1310 // Field names should not contain underscore + + // We only want to reset TitleBar configuration in app, if we're the TitleBar instance that's managing that state. + private bool _isAutoConfigCompleted; + + public TitleBar() + { + this.DefaultStyleKey = typeof(TitleBar); + } + + protected override void OnApplyTemplate() + { + PART_LeftPaddingColumn = GetTemplateChild(nameof(PART_LeftPaddingColumn)) as ColumnDefinition; + PART_RightPaddingColumn = GetTemplateChild(nameof(PART_RightPaddingColumn)) as ColumnDefinition; + ConfigureButtonHolder(); + Configure(); + if (GetTemplateChild(PartBackButton) is Button backButton) + { + backButton.Click -= BackButton_Click; + backButton.Click += BackButton_Click; + } + + if (GetTemplateChild(PartPaneButton) is Button paneButton) + { + paneButton.Click -= PaneButton_Click; + paneButton.Click += PaneButton_Click; + } + + SizeChanged -= this.TitleBar_SizeChanged; + SizeChanged += this.TitleBar_SizeChanged; + + Update(); + base.OnApplyTemplate(); + } + + private void TitleBar_SizeChanged(object sender, SizeChangedEventArgs e) + { + UpdateVisualStateAndDragRegion(e.NewSize); + } + + private void UpdateVisualStateAndDragRegion(Size size) + { + if (size.Width <= CompactStateBreakpoint) + { + if (Content != null || Footer != null) + { + VisualStateManager.GoToState(this, NarrowState, true); + } + } + else + { + VisualStateManager.GoToState(this, WideState, true); + } + + SetDragRegionForCustomTitleBar(); + } + + private void BackButton_Click(object sender, RoutedEventArgs e) + { + BackButtonClick?.Invoke(this, new RoutedEventArgs()); + } + + private void PaneButton_Click(object sender, RoutedEventArgs e) + { + PaneButtonClick?.Invoke(this, new RoutedEventArgs()); + } + + private void ConfigureButtonHolder() + { + if (PART_ButtonHolder != null) + { + PART_ButtonHolder.SizeChanged -= PART_ButtonHolder_SizeChanged; + } + + PART_ButtonHolder = GetTemplateChild(nameof(PART_ButtonHolder)) as StackPanel; + + if (PART_ButtonHolder != null) + { + PART_ButtonHolder.SizeChanged += PART_ButtonHolder_SizeChanged; + } + } + + private void PART_ButtonHolder_SizeChanged(object sender, SizeChangedEventArgs e) + { + SetDragRegionForCustomTitleBar(); + } + + private void Configure() + { + SetWASDKTitleBar(); + } + + public void Reset() + { + ResetWASDKTitleBar(); + } + + private void Update() + { + if (Icon != null) + { + VisualStateManager.GoToState(this, IconVisibleState, true); + } + else + { + VisualStateManager.GoToState(this, IconCollapsedState, true); + } + + VisualStateManager.GoToState(this, IsBackButtonVisible ? BackButtonVisibleState : BackButtonCollapsedState, true); + VisualStateManager.GoToState(this, IsPaneButtonVisible ? PaneButtonVisibleState : PaneButtonCollapsedState, true); + + if (DisplayMode == DisplayMode.Tall) + { + VisualStateManager.GoToState(this, TallState, true); + } + else + { + VisualStateManager.GoToState(this, StandardState, true); + } + + if (Content != null) + { + VisualStateManager.GoToState(this, ContentVisibleState, true); + } + else + { + VisualStateManager.GoToState(this, ContentCollapsedState, true); + } + + if (Footer != null) + { + VisualStateManager.GoToState(this, FooterVisibleState, true); + } + else + { + VisualStateManager.GoToState(this, FooterCollapsedState, true); + } + + SetDragRegionForCustomTitleBar(); + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.xaml new file mode 100644 index 0000000000..09c9344ad7 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/TitleBar/TitleBar.xaml @@ -0,0 +1,371 @@ +<ResourceDictionary + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:animatedvisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Controls"> + + <x:Double x:Key="TitleBarCompactHeight">32</x:Double> + <x:Double x:Key="TitleBarTallHeight">48</x:Double> + <x:Double x:Key="TitleBarContentMinWidth">360</x:Double> + <Style BasedOn="{StaticResource DefaultTitleBarStyle}" TargetType="local:TitleBar" /> + + <Style x:Key="DefaultTitleBarStyle" TargetType="local:TitleBar"> + <Setter Property="MinHeight" Value="{ThemeResource TitleBarCompactHeight}" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="local:TitleBar"> + <Grid + x:Name="PART_RootGrid" + Height="{TemplateBinding MinHeight}" + Padding="4,0,0,0" + VerticalAlignment="Stretch" + Background="{TemplateBinding Background}"> + <Grid.ColumnDefinitions> + <ColumnDefinition x:Name="PART_LeftPaddingColumn" Width="0" /> + <ColumnDefinition x:Name="PART_ButtonsHolderColumn" Width="Auto" /> + <ColumnDefinition x:Name="PART_IconColumn" Width="Auto" /> + <ColumnDefinition x:Name="PART_TitleColumn" Width="Auto" /> + <ColumnDefinition + x:Name="PART_LeftDragColumn" + Width="*" + MinWidth="4" /> + <ColumnDefinition x:Name="PART_ContentColumn" Width="Auto" /> + <ColumnDefinition + x:Name="PART_RightDragColumn" + Width="*" + MinWidth="4" /> + <ColumnDefinition x:Name="PART_FooterColumn" Width="Auto" /> + <ColumnDefinition x:Name="PART_RightPaddingColumn" Width="0" /> + </Grid.ColumnDefinitions> + <Border + x:Name="PART_IconHolder" + Grid.Column="2" + Margin="12,0,0,0" + VerticalAlignment="Center"> + <Viewbox + x:Name="PART_Icon" + MaxWidth="16" + MaxHeight="16"> + <ContentPresenter + x:Name="PART_IconPresenter" + AutomationProperties.AccessibilityView="Raw" + Content="{TemplateBinding Icon}" + HighContrastAdjustment="None" /> + </Viewbox> + </Border> + + <StackPanel + x:Name="PART_TitleHolder" + Grid.Column="3" + Margin="16,0,0,0" + HorizontalAlignment="Left" + VerticalAlignment="Center" + Orientation="Horizontal" + Spacing="4"> + <TextBlock + x:Name="PART_TitleText" + MinWidth="48" + Margin="0,0,0,1" + Foreground="{ThemeResource TextFillColorPrimaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{TemplateBinding Title}" + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap" /> + <TextBlock + x:Name="PART_SubtitleText" + MinWidth="48" + Margin="0,0,0,1" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{TemplateBinding Subtitle}" + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap" /> + </StackPanel> + <Grid + x:Name="PART_DragRegion" + Grid.Column="2" + Grid.ColumnSpan="6" + Background="Transparent" /> + <StackPanel + x:Name="PART_ButtonHolder" + Grid.Column="1" + Orientation="Horizontal"> + <Button + x:Name="PART_BackButton" + Style="{ThemeResource TitleBarBackButtonStyle}" + ToolTipService.ToolTip="Back" /> + <Button + x:Name="PART_PaneButton" + Style="{StaticResource TitleBarPaneToggleButtonStyle}" + ToolTipService.ToolTip="Toggle menu" /> + </StackPanel> + + <ContentPresenter + x:Name="PART_ContentPresenter" + Grid.Column="5" + MinWidth="{ThemeResource TitleBarContentMinWidth}" + HorizontalAlignment="Stretch" + VerticalAlignment="Center" + HorizontalContentAlignment="Stretch" + Content="{TemplateBinding Content}" /> + <ContentPresenter + x:Name="PART_FooterPresenter" + Grid.Column="7" + Margin="4,0,8,0" + HorizontalContentAlignment="Right" + Content="{TemplateBinding Footer}" /> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="BackButtonStates"> + <VisualState x:Name="BackButtonVisible" /> + <VisualState x:Name="BackButtonCollapsed"> + <VisualState.Setters> + <Setter Target="PART_BackButton.Visibility" Value="Collapsed" /> + <Setter Target="PART_BackButton.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup x:Name="PaneButtonStates"> + <VisualState x:Name="PaneButtonVisible" /> + <VisualState x:Name="PaneButtonCollapsed"> + <VisualState.Setters> + <Setter Target="PART_PaneButton.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup x:Name="IconStates"> + <VisualState x:Name="IconVisible" /> + <VisualState x:Name="IconCollapsed"> + <VisualState.Setters> + <Setter Target="PART_IconHolder.Visibility" Value="Collapsed" /> + <Setter Target="PART_TitleHolder.Margin" Value="4,0,0,0" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup x:Name="ContentStates"> + <VisualState x:Name="ContentVisible" /> + <VisualState x:Name="ContentCollapsed"> + <VisualState.Setters> + <Setter Target="PART_ContentPresenter.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup x:Name="FooterStates"> + <VisualState x:Name="FooterVisible" /> + <VisualState x:Name="FooterCollapsed"> + <VisualState.Setters> + <Setter Target="PART_FooterPresenter.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup x:Name="ReflowStates"> + <VisualState x:Name="Wide" /> + <VisualState x:Name="Narrow"> + <VisualState.Setters> + <Setter Target="PART_TitleHolder.Visibility" Value="Collapsed" /> + <Setter Target="PART_LeftDragColumn.Width" Value="Auto" /> + <Setter Target="PART_RightDragColumn.Width" Value="Auto" /> + <Setter Target="PART_RightDragColumn.MinWidth" Value="16" /> + <Setter Target="PART_LeftDragColumn.MinWidth" Value="16" /> + <Setter Target="PART_ContentColumn.Width" Value="*" /> + <!-- Content can stretch now --> + <Setter Target="PART_ContentPresenter.MinWidth" Value="0" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup x:Name="WindowActivationStates"> + <VisualState x:Name="Activated" /> + <VisualState x:Name="Deactivated"> + <VisualState.Setters> + <Setter Target="PART_TitleText.Foreground" Value="{ThemeResource TextFillColorDisabledBrush}" /> + <Setter Target="PART_SubtitleText.Foreground" Value="{ThemeResource TextFillColorDisabledBrush}" /> + <Setter Target="PART_BackButton.IsEnabled" Value="False" /> + <Setter Target="PART_PaneButton.IsEnabled" Value="False" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + <VisualStateGroup x:Name="DisplayModeStates"> + <VisualState x:Name="Standard" /> + <VisualState x:Name="Tall"> + <VisualState.Setters> + <Setter Target="PART_RootGrid.MinHeight" Value="{ThemeResource TitleBarTallHeight}" /> + <Setter Target="PART_RootGrid.Padding" Value="4" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Copy of WinUI NavigationBackButtonNormalStyle - cannot use it as it picks up the generic.xaml version, not the WinUI version --> + <Style x:Key="TitleBarBackButtonStyle" TargetType="Button"> + <Setter Property="Background" Value="{ThemeResource NavigationViewBackButtonBackground}" /> + <Setter Property="Foreground" Value="{ThemeResource NavigationViewItemForegroundChecked}" /> + <Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" /> + <Setter Property="FontSize" Value="16" /> + <Setter Property="MaxHeight" Value="40" /> + <Setter Property="VerticalAlignment" Value="Stretch" /> + <Setter Property="HorizontalContentAlignment" Value="Center" /> + <Setter Property="VerticalContentAlignment" Value="Center" /> + <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> + <Setter Property="Content" Value="" /> + <Setter Property="Padding" Value="12,4,12,4" /> + <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Grid + x:Name="RootGrid" + Padding="{TemplateBinding Padding}" + Background="{TemplateBinding Background}" + CornerRadius="{TemplateBinding CornerRadius}"> + <AnimatedIcon + x:Name="Content" + Width="16" + Height="16" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}" + AnimatedIcon.State="Normal" + AutomationProperties.AccessibilityView="Raw"> + <animatedvisuals:AnimatedBackVisualSource /> + <AnimatedIcon.FallbackIconSource> + <FontIconSource + FontFamily="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=FontFamily}" + FontSize="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=FontSize}" + Glyph="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Content}" + MirroredWhenRightToLeft="True" /> + </AnimatedIcon.FallbackIconSource> + </AnimatedIcon> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Normal" /> + <VisualState x:Name="PointerOver"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonBackgroundPointerOver}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Content" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonForegroundPointerOver}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + <VisualState.Setters> + <Setter Target="Content.(AnimatedIcon.State)" Value="PointerOver" /> + </VisualState.Setters> + </VisualState> + + <VisualState x:Name="Pressed"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonBackgroundPressed}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Content" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonForegroundPressed}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + <VisualState.Setters> + <Setter Target="Content.(AnimatedIcon.State)" Value="Pressed" /> + </VisualState.Setters> + </VisualState> + + <VisualState x:Name="Disabled"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Content" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonForegroundDisabled}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> + + <!-- Copy of WinUI PaneToggleButtonStyle - cannot use it as it picks up the generic.xaml version, not the WinUI version --> + <Style x:Key="TitleBarPaneToggleButtonStyle" TargetType="Button"> + <Setter Property="Background" Value="{ThemeResource NavigationViewBackButtonBackground}" /> + <Setter Property="Foreground" Value="{ThemeResource NavigationViewItemForegroundChecked}" /> + <Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" /> + <Setter Property="FontSize" Value="16" /> + <Setter Property="MaxHeight" Value="40" /> + <Setter Property="VerticalAlignment" Value="Stretch" /> + <Setter Property="HorizontalContentAlignment" Value="Center" /> + <Setter Property="VerticalContentAlignment" Value="Center" /> + <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> + <Setter Property="Content" Value="" /> + <Setter Property="Padding" Value="12,4,12,4" /> + <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="Button"> + <Grid + x:Name="RootGrid" + Padding="{TemplateBinding Padding}" + Background="{TemplateBinding Background}" + CornerRadius="{TemplateBinding CornerRadius}"> + <AnimatedIcon + x:Name="Content" + Width="16" + Height="16" + HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" + VerticalAlignment="{TemplateBinding VerticalContentAlignment}" + AnimatedIcon.State="Normal" + AutomationProperties.AccessibilityView="Raw"> + <animatedvisuals:AnimatedGlobalNavigationButtonVisualSource /> + <AnimatedIcon.FallbackIconSource> + <FontIconSource + FontFamily="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=FontFamily}" + FontSize="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=FontSize}" + Glyph="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Content}" + MirroredWhenRightToLeft="True" /> + </AnimatedIcon.FallbackIconSource> + </AnimatedIcon> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="CommonStates"> + <VisualState x:Name="Normal" /> + <VisualState x:Name="PointerOver"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonBackgroundPointerOver}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Content" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonForegroundPointerOver}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + <VisualState.Setters> + <Setter Target="Content.(AnimatedIcon.State)" Value="PointerOver" /> + </VisualState.Setters> + </VisualState> + + <VisualState x:Name="Pressed"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonBackgroundPressed}" /> + </ObjectAnimationUsingKeyFrames> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Content" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonForegroundPressed}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + <VisualState.Setters> + <Setter Target="Content.(AnimatedIcon.State)" Value="Pressed" /> + </VisualState.Setters> + </VisualState> + + <VisualState x:Name="Disabled"> + <Storyboard> + <ObjectAnimationUsingKeyFrames Storyboard.TargetName="Content" Storyboard.TargetProperty="Foreground"> + <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource NavigationViewButtonForegroundDisabled}" /> + </ObjectAnimationUsingKeyFrames> + </Storyboard> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> +</ResourceDictionary> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/AppsListPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/AppsListPage.xaml deleted file mode 100644 index 54a2c096be..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/AppsListPage.xaml +++ /dev/null @@ -1,112 +0,0 @@ -<!-- Copyright (c) Microsoft Corporation and Contributors. --> -<!-- Licensed under the MIT License. --> - -<Page - x:Class="Microsoft.PowerToys.Settings.UI.Flyout.AppsListPage" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" - xmlns:viewmodels="using:Microsoft.PowerToys.Settings.UI.ViewModels" - mc:Ignorable="d"> - - <Page.Resources> - <tkconverters:BoolNegationConverter x:Key="BoolNegationConverter" /> - <tkconverters:BoolToVisibilityConverter - x:Key="BoolToInvertedVisibilityConverter" - FalseValue="Visible" - TrueValue="Collapsed" /> - </Page.Resources> - <Grid Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"> - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="*" /> - </Grid.RowDefinitions> - <Grid Padding="24,32,24,0"> - <TextBlock - x:Uid="AllAppsTxt" - VerticalAlignment="Center" - Style="{StaticResource BodyStrongTextBlockStyle}" /> - <Button - x:Uid="BackBtn" - Padding="8,4,8,4" - HorizontalAlignment="Right" - VerticalAlignment="Center" - Click="BackButton_Click"> - <Button.Content> - <StackPanel - VerticalAlignment="Center" - Orientation="Horizontal" - Spacing="12"> - <FontIcon - Margin="0,2,0,0" - FontSize="12" - Glyph="" /> - <TextBlock x:Uid="BackLabel" Style="{StaticResource CaptionTextBlockStyle}" /> - </StackPanel> - </Button.Content> - </Button> - </Grid> - <ListView - Grid.Row="1" - Margin="0,16,0,0" - ItemsSource="{x:Bind ViewModel.FlyoutMenuItems}" - SelectionMode="None"> - <ListView.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Padding="0,0,0,16" Orientation="Vertical" /> - </ItemsPanelTemplate> - </ListView.ItemsPanel> - - <ListView.ItemTemplate> - <DataTemplate x:DataType="viewmodels:FlyoutMenuItem"> - <Grid Height="40" Padding="24,0,24,0"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - <!--<ViewBox VerticalAlignment="Center">--> - <Image - Width="20" - Margin="0,0,16,0" - VerticalAlignment="Center"> - <Image.Source> - <BitmapImage UriSource="{x:Bind Icon, Mode=OneWay}" /> - </Image.Source> - </Image> - <!--</ViewBox>--> - <TextBlock - Grid.Column="1" - VerticalAlignment="Center" - Text="{x:Bind Label, Mode=OneWay}" - TextTrimming="CharacterEllipsis" /> - <FontIcon - Grid.Column="2" - Width="20" - VerticalAlignment="Center" - FontSize="16" - Glyph="" - Visibility="{x:Bind IsLocked, Converter={StaticResource BoolToInvertedVisibilityConverter}, ConverterParameter=True, Mode=OneWay}"> - <ToolTipService.ToolTip> - <TextBlock x:Uid="GPO_SettingIsManaged_ToolTip" TextWrapping="WrapWholeWords" /> - </ToolTipService.ToolTip> - </FontIcon> - <ToggleSwitch - Grid.Column="3" - HorizontalAlignment="Right" - VerticalAlignment="Center" - AutomationProperties.Name="{x:Bind Label, Mode=OneWay}" - IsEnabled="{x:Bind IsLocked, Converter={StaticResource BoolNegationConverter}, ConverterParameter=True, Mode=OneWay}" - IsOn="{x:Bind IsEnabled, Mode=TwoWay}" - OffContent="" - OnContent="" - Style="{StaticResource RightAlignedCompactToggleSwitchStyle}" /> - </Grid> - </DataTemplate> - </ListView.ItemTemplate> - </ListView> - </Grid> -</Page> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/AppsListPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/AppsListPage.xaml.cs deleted file mode 100644 index b58636f41b..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/AppsListPage.xaml.cs +++ /dev/null @@ -1,37 +0,0 @@ -// 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.ObjectModel; -using System.Threading; - -using global::Windows.System; -using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.ViewModels; -using Microsoft.PowerToys.Settings.UI.Views; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media.Animation; -using PowerToys.Interop; - -namespace Microsoft.PowerToys.Settings.UI.Flyout -{ - public sealed partial class AppsListPage : Page - { - private AllAppsViewModel ViewModel { get; set; } - - public AppsListPage() - { - this.InitializeComponent(); - - var settingsUtils = new SettingsUtils(); - ViewModel = new AllAppsViewModel(SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), Views.ShellPage.SendDefaultIPCMessage); - DataContext = ViewModel; - } - - private void BackButton_Click(object sender, RoutedEventArgs e) - { - Frame.Navigate(typeof(LaunchPage), null, new SlideNavigationTransitionInfo() { Effect = SlideNavigationTransitionEffect.FromLeft }); - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs deleted file mode 100644 index dadcd78c68..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/LaunchPage.xaml.cs +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. -using System; -using System.Threading; - -using global::Windows.System; -using ManagedCommon; -using Microsoft.PowerToys.Settings.UI.Controls; -using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; -using Microsoft.PowerToys.Settings.UI.ViewModels; -using Microsoft.PowerToys.Telemetry; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media.Animation; -using PowerToys.Interop; -using WinUIEx; - -namespace Microsoft.PowerToys.Settings.UI.Flyout -{ - public sealed partial class LaunchPage : Page - { - private LauncherViewModel ViewModel { get; set; } - - public LaunchPage() - { - this.InitializeComponent(); - var settingsUtils = new SettingsUtils(); - ViewModel = new LauncherViewModel(SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), Views.ShellPage.SendDefaultIPCMessage); - DataContext = ViewModel; - } - - private void ModuleButton_Click(object sender, RoutedEventArgs e) - { - FlyoutMenuButton selectedModuleBtn = sender as FlyoutMenuButton; - bool moduleRun = true; - - // Closing manually the flyout to workaround focus gain problems - App.GetFlyoutWindow()?.Hide(); - - switch ((ModuleType)selectedModuleBtn.Tag) - { - case ModuleType.ColorPicker: // Launch ColorPicker - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowColorPickerSharedEvent())) - { - eventHandle.Set(); - } - - break; - case ModuleType.EnvironmentVariables: // Launch Environment Variables - { - bool launchAdmin = SettingsRepository<EnvironmentVariablesSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.LaunchAdministrator; - string eventName = !App.IsElevated && launchAdmin - ? Constants.ShowEnvironmentVariablesAdminSharedEvent() - : Constants.ShowEnvironmentVariablesSharedEvent(); - - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName)) - { - eventHandle.Set(); - } - } - - break; - - case ModuleType.FancyZones: // Launch FancyZones Editor - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.FZEToggleEvent())) - { - eventHandle.Set(); - } - - break; - - case ModuleType.Hosts: // Launch Hosts - { - bool launchAdmin = SettingsRepository<HostsSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.LaunchAdministrator; - string eventName = !App.IsElevated && launchAdmin - ? Constants.ShowHostsAdminSharedEvent() - : Constants.ShowHostsSharedEvent(); - - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName)) - { - eventHandle.Set(); - } - } - - break; - - case ModuleType.RegistryPreview: // Launch Registry Preview - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.RegistryPreviewTriggerEvent())) - { - eventHandle.Set(); - } - - break; - case ModuleType.MeasureTool: // Launch Screen Ruler - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.MeasureToolTriggerEvent())) - { - eventHandle.Set(); - } - - break; - - case ModuleType.PowerLauncher: // Launch Run - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.PowerLauncherSharedEvent())) - { - eventHandle.Set(); - } - - break; - - case ModuleType.PowerOCR: // Launch Text Extractor - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowPowerOCRSharedEvent())) - { - eventHandle.Set(); - } - - break; - - case ModuleType.Workspaces: // Launch Workspaces Editor - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.WorkspacesLaunchEditorEvent())) - { - eventHandle.Set(); - } - - break; - - case ModuleType.ShortcutGuide: // Launch Shortcut Guide - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShortcutGuideTriggerEvent())) - { - eventHandle.Set(); - } - - break; - - case ModuleType.CmdPal: // Show CmdPal - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.ShowCmdPalEvent())) - { - eventHandle.Set(); - } - - break; - - default: - moduleRun = false; - break; - } - - if (moduleRun) - { - PowerToysTelemetry.Log.WriteEvent(new TrayFlyoutModuleRunEvent() { ModuleName = ((ModuleType)selectedModuleBtn.Tag).ToString() }); - } - } - - private void SettingsBtn_Click(object sender, RoutedEventArgs e) - { - App.OpenSettingsWindow(null, true); - } - - private async void DocsBtn_Click(object sender, RoutedEventArgs e) - { - await Launcher.LaunchUriAsync(new Uri("https://aka.ms/PowerToysOverview")); - } - - private void AllAppButton_Click(object sender, RoutedEventArgs e) - { - Frame.Navigate(typeof(AppsListPage), null, new SlideNavigationTransitionInfo() { Effect = SlideNavigationTransitionEffect.FromRight }); - } - - private void QuitButton_Click(object sender, RoutedEventArgs e) - { - ViewModel.KillRunner(); - this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => - { - Application.Current.Exit(); - }); - } - - private void ReportBugBtn_Click(object sender, RoutedEventArgs e) - { - ViewModel.StartBugReport(); - - // Closing manually the flyout since no window will steal the focus - App.GetFlyoutWindow()?.Hide(); - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/ShellPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Flyout/ShellPage.xaml.cs deleted file mode 100644 index fe0aa75f69..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Flyout/ShellPage.xaml.cs +++ /dev/null @@ -1,30 +0,0 @@ -// 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; -using Microsoft.UI.Xaml.Media.Animation; - -namespace Microsoft.PowerToys.Settings.UI.Flyout -{ - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> - public sealed partial class ShellPage : Page - { - public ShellPage() - { - this.InitializeComponent(); - } - - internal void SwitchToLaunchPage() - { - ContentFrame.Navigate(typeof(LaunchPage), null, new SuppressNavigationTransitionInfo()); - } - - private void Page_Loaded(object sender, RoutedEventArgs e) - { - SwitchToLaunchPage(); - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/FlyoutWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/FlyoutWindow.xaml.cs deleted file mode 100644 index 27a20540c9..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/FlyoutWindow.xaml.cs +++ /dev/null @@ -1,103 +0,0 @@ -// 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.PowerToys.Settings.UI.Helpers; -using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events; -using Microsoft.PowerToys.Settings.UI.ViewModels.Flyout; -using Microsoft.PowerToys.Telemetry; -using Microsoft.UI; -using Microsoft.UI.Windowing; -using Windows.Graphics; -using WinUIEx; - -namespace Microsoft.PowerToys.Settings.UI -{ - /// <summary> - /// An empty window that can be used on its own or navigated to within a Frame. - /// </summary> - public sealed partial class FlyoutWindow : WindowEx - { - private const int WindowWidth = 386; - private const int WindowHeight = 486; - private const int WindowMargin = 12; - - public FlyoutViewModel ViewModel { get; set; } - - public POINT? FlyoutAppearPosition { get; set; } - - public FlyoutWindow(POINT? initialPosition) - { - this.InitializeComponent(); - - // Remove the caption style from the window style. Windows App SDK 1.6 added it, which made the title bar and borders appear for the Flyout. This code removes it. - var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - var windowStyle = NativeMethods.GetWindowLong(hwnd, NativeMethods.GWL_STYLE); - windowStyle &= ~NativeMethods.WS_CAPTION; - _ = NativeMethods.SetWindowLong(hwnd, NativeMethods.GWL_STYLE, windowStyle); - - this.Activated += FlyoutWindow_Activated; - FlyoutAppearPosition = initialPosition; - ViewModel = new FlyoutViewModel(); - } - - private void FlyoutWindow_Activated(object sender, Microsoft.UI.Xaml.WindowActivatedEventArgs args) - { - PowerToysTelemetry.Log.WriteEvent(new TrayFlyoutActivatedEvent()); - if (args.WindowActivationState == Microsoft.UI.Xaml.WindowActivationState.CodeActivated) - { - var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - WindowId windowId = Win32Interop.GetWindowIdFromWindow(hwnd); - if (!FlyoutAppearPosition.HasValue) - { - DisplayArea displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Nearest); - double dpiScale = (float)this.GetDpiForWindow() / 96; - double x = displayArea.WorkArea.Width - (dpiScale * (WindowWidth + WindowMargin)); - double y = displayArea.WorkArea.Height - (dpiScale * (WindowHeight + WindowMargin)); - this.MoveAndResize(x, y, WindowWidth, WindowHeight); - } - else - { - DisplayArea displayArea = DisplayArea.GetFromPoint(new PointInt32(FlyoutAppearPosition.Value.X, FlyoutAppearPosition.Value.Y), DisplayAreaFallback.Nearest); - - // Move the window to the correct screen as a little blob, so we can get the accurate dpi for the screen to calculate the best position to show it. - this.MoveAndResize(FlyoutAppearPosition.Value.X, FlyoutAppearPosition.Value.Y, 1, 1); - double dpiScale = (float)this.GetDpiForWindow() / 96; - - // Position the window so that it's inside the display are closest to the point. - POINT newPosition = new POINT(FlyoutAppearPosition.Value.X - (int)(dpiScale * WindowWidth / 2), FlyoutAppearPosition.Value.Y - (int)(dpiScale * WindowHeight / 2)); - if (newPosition.X < displayArea.WorkArea.X) - { - newPosition.X = displayArea.WorkArea.X; - } - - if (newPosition.Y < displayArea.WorkArea.Y) - { - newPosition.Y = displayArea.WorkArea.Y; - } - - if (newPosition.X + (dpiScale * WindowWidth) > displayArea.WorkArea.X + displayArea.WorkArea.Width) - { - newPosition.X = (int)(displayArea.WorkArea.X + displayArea.WorkArea.Width - (dpiScale * WindowWidth)); - } - - if (newPosition.Y + (dpiScale * WindowHeight) > displayArea.WorkArea.Y + displayArea.WorkArea.Height) - { - newPosition.Y = (int)(displayArea.WorkArea.Y + displayArea.WorkArea.Height - (dpiScale * WindowHeight)); - } - - this.MoveAndResize(newPosition.X, newPosition.Y, WindowWidth, WindowHeight); - } - - FlyoutShellPage.SwitchToLaunchPage(); - } - - if (args.WindowActivationState == Microsoft.UI.Xaml.WindowActivationState.Deactivated) - { - if (ViewModel.CanHide) - { - this.Hide(); - } - } - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs index e85d377da2..ba417ad066 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs @@ -3,24 +3,24 @@ // See the LICENSE file in the project root for more information. using System; - +using System.Threading.Tasks; using ManagedCommon; using Microsoft.PowerLauncher.Telemetry; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.PowerToys.Telemetry; using Microsoft.UI; +using Microsoft.UI.Dispatching; using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Windows.Data.Json; +using WinRT.Interop; using WinUIEx; namespace Microsoft.PowerToys.Settings.UI { - /// <summary> - /// An empty window that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class MainWindow : WindowEx { public MainWindow(bool createHidden = false) @@ -28,18 +28,17 @@ namespace Microsoft.PowerToys.Settings.UI var bootTime = new System.Diagnostics.Stopwatch(); bootTime.Start(); + this.Activated += Window_Activated_SetIcon; + App.ThemeService.ThemeChanged += OnThemeChanged; App.ThemeService.ApplyTheme(); + this.ExtendsContentIntoTitleBar = true; + ShellPage.SetElevationStatus(App.IsElevated); ShellPage.SetIsUserAnAdmin(App.IsUserAnAdmin); - // Set window icon - var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - WindowId windowId = Win32Interop.GetWindowIdFromWindow(hWnd); - AppWindow appWindow = AppWindow.GetFromWindowId(windowId); - appWindow.SetIcon("Assets\\Settings\\icon.ico"); - + var hWnd = WindowNative.GetWindowHandle(this); var placement = WindowHelper.DeserializePlacementOrDefault(hWnd); if (createHidden) { @@ -77,14 +76,14 @@ namespace Microsoft.PowerToys.Settings.UI // open main window ShellPage.SetOpenMainWindowCallback(type => { - DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => + DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal, () => App.OpenSettingsWindow(type)); }); // open main window ShellPage.SetUpdatingGeneralSettingsCallback((ModuleType moduleType, bool isEnabled) => { - SettingsRepository<GeneralSettings> repository = SettingsRepository<GeneralSettings>.GetInstance(new SettingsUtils()); + SettingsRepository<GeneralSettings> repository = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default); GeneralSettings generalSettingsConfig = repository.SettingsConfig; bool needToUpdate = ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType) != isEnabled; @@ -92,77 +91,24 @@ namespace Microsoft.PowerToys.Settings.UI { ModuleHelper.SetIsModuleEnabled(generalSettingsConfig, moduleType, isEnabled); var outgoing = new OutGoingGeneralSettings(generalSettingsConfig); - this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => + + // Save settings to file + SettingsUtils.Default.SaveSettings(generalSettingsConfig.ToJsonString()); + + // Send IPC message asynchronously to avoid blocking UI and potential recursive calls + Task.Run(() => { ShellPage.SendDefaultIPCMessage(outgoing.ToString()); - ShellPage.ShellHandler?.SignalGeneralDataUpdate(); }); + + ShellPage.ShellHandler?.SignalGeneralDataUpdate(); } return needToUpdate; }); - // open oobe - ShellPage.SetOpenOobeCallback(() => - { - if (App.GetOobeWindow() == null) - { - App.SetOobeWindow(new OobeWindow(Microsoft.PowerToys.Settings.UI.OOBE.Enums.PowerToysModules.Overview)); - } - - App.GetOobeWindow().Activate(); - }); - - // open whats new window - ShellPage.SetOpenWhatIsNewCallback(() => - { - if (App.GetOobeWindow() == null) - { - App.SetOobeWindow(new OobeWindow(Microsoft.PowerToys.Settings.UI.OOBE.Enums.PowerToysModules.WhatsNew)); - } - else - { - App.GetOobeWindow().SetAppWindow(OOBE.Enums.PowerToysModules.WhatsNew); - } - - App.GetOobeWindow().Activate(); - }); - - // open flyout - ShellPage.SetOpenFlyoutCallback((POINT? p) => - { - this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => - { - if (App.GetFlyoutWindow() == null) - { - App.SetFlyoutWindow(new FlyoutWindow(p)); - } - - FlyoutWindow flyout = App.GetFlyoutWindow(); - flyout.FlyoutAppearPosition = p; - flyout.Activate(); - - // https://github.com/microsoft/microsoft-ui-xaml/issues/7595 - Activate doesn't bring window to the foreground - // Need to call SetForegroundWindow to actually gain focus. - WindowHelpers.BringToForeground(flyout.GetWindowHandle()); - }); - }); - - // disable flyout hiding - ShellPage.SetDisableFlyoutHidingCallback(() => - { - this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => - { - if (App.GetFlyoutWindow() == null) - { - App.SetFlyoutWindow(new FlyoutWindow(null)); - } - - App.GetFlyoutWindow().ViewModel.DisableHiding(); - }); - }); - this.InitializeComponent(); + SetTitleBar(); // receive IPC Message App.IPCMessageReceivedCallback = (string msg) => @@ -189,14 +135,22 @@ namespace Microsoft.PowerToys.Settings.UI PowerToysTelemetry.Log.WriteEvent(new SettingsBootEvent() { BootTimeMs = bootTime.ElapsedMilliseconds }); } - public void NavigateToSection(System.Type type) + private void SetTitleBar() + { + // We need to assign the window here so it can configure the custom title bar area correctly. + shellPage.TitleBar.Window = this; + this.ExtendsContentIntoTitleBar = true; + WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(this)); + } + + public void NavigateToSection(Type type) { ShellPage.Navigate(type); } public void CloseHiddenWindow() { - var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + var hWnd = WindowNative.GetWindowHandle(this); if (!NativeMethods.IsWindowVisible(hWnd)) { Close(); @@ -205,10 +159,10 @@ namespace Microsoft.PowerToys.Settings.UI private void Window_Closed(object sender, WindowEventArgs args) { - var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + var hWnd = WindowNative.GetWindowHandle(this); WindowHelper.SerializePlacement(hWnd); - if (App.GetOobeWindow() == null) + if (!App.IsSecondaryWindowOpen()) { App.ClearSettingsWindow(); } @@ -221,12 +175,18 @@ namespace Microsoft.PowerToys.Settings.UI App.ThemeService.ThemeChanged -= OnThemeChanged; } + private void Window_Activated_SetIcon(object sender, WindowActivatedEventArgs args) + { + // Set window icon + this.SetIcon("Assets\\Settings\\icon.ico"); + } + private void Window_Activated(object sender, WindowActivatedEventArgs args) { if (args.WindowActivationState != WindowActivationState.Deactivated) { this.Activated -= Window_Activated; - var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + var hWnd = WindowNative.GetWindowHandle(this); var placement = WindowHelper.DeserializePlacementOrDefault(hWnd); NativeMethods.SetWindowPlacement(hWnd, ref placement); } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAdvancedPaste.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAdvancedPaste.xaml.cs index 52cbc6cef2..c0d4a9926c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAdvancedPaste.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAdvancedPaste.xaml.cs @@ -18,15 +18,16 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeAdvancedPaste() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.AdvancedPaste]); + + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.AdvancedPaste); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(AdvancedPastePage)); + OobeWindow.OpenMainWindowCallback(typeof(AdvancedPastePage)); } ViewModel.LogOpeningSettingsEvent(); @@ -35,9 +36,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - AdvancedPasteUIHotkeyControl.Keys = SettingsRepository<AdvancedPasteSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.AdvancedPasteUIShortcut.GetKeysList(); - PasteAsPlainTextHotkeyControl.Keys = SettingsRepository<AdvancedPasteSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.PasteAsPlainTextShortcut.GetKeysList(); - PasteAsMarkdownHotkeyControl.Keys = SettingsRepository<AdvancedPasteSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.PasteAsMarkdownShortcut.GetKeysList(); + AdvancedPasteUIHotkeyControl.Keys = SettingsRepository<AdvancedPasteSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.AdvancedPasteUIShortcut.GetKeysList(); + PasteAsPlainTextHotkeyControl.Keys = SettingsRepository<AdvancedPasteSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.PasteAsPlainTextShortcut.GetKeysList(); + PasteAsMarkdownHotkeyControl.Keys = SettingsRepository<AdvancedPasteSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.PasteAsMarkdownShortcut.GetKeysList(); // TODO(stefan): Check how to remove additional space if item is set to Collapsed. if (PasteAsMarkdownHotkeyControl.Keys.Count > 0) @@ -45,7 +46,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views PasteAsMarkdownHotkeyControl.Visibility = Microsoft.UI.Xaml.Visibility.Visible; } - PasteAsJsonHotkeyControl.Keys = SettingsRepository<AdvancedPasteSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.PasteAsJsonShortcut.GetKeysList(); + PasteAsJsonHotkeyControl.Keys = SettingsRepository<AdvancedPasteSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.PasteAsJsonShortcut.GetKeysList(); if (PasteAsJsonHotkeyControl.Keys.Count > 0) { PasteAsJsonHotkeyControl.Visibility = Microsoft.UI.Xaml.Visibility.Visible; diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml index e029aa41f6..191fae9d1d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_AlwaysOnTop" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/AlwaysOnTop.png"> @@ -17,7 +17,7 @@ <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_AlwaysOnTop_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_AlwaysOnTop_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml.cs index d22392e717..8a353a2638 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAlwaysOnTop.xaml.cs @@ -18,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeAlwaysOnTop() { InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.AlwaysOnTop]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.AlwaysOnTop); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(AlwaysOnTopPage)); + OobeWindow.OpenMainWindowCallback(typeof(AlwaysOnTopPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -35,7 +35,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - HotkeyControl.Keys = SettingsRepository<AlwaysOnTopSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.Hotkey.Value.GetKeysList(); + HotkeyControl.Keys = SettingsRepository<AlwaysOnTopSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.Hotkey.Value.GetKeysList(); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAwake.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAwake.xaml index c5e0c01e44..ec6b508484 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAwake.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAwake.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_Awake" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/Awake.png"> @@ -13,11 +13,11 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_Awake_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_Awake_HowToUse" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_Awake_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_Awake_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAwake.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAwake.xaml.cs index 624e473523..153b7e5472 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAwake.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeAwake.xaml.cs @@ -17,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeAwake() { InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.Awake]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.Awake); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(AwakePage)); + OobeWindow.OpenMainWindowCallback(typeof(AwakePage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdNotFound.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdNotFound.xaml index 9a8da108b4..a12baa64ee 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdNotFound.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdNotFound.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_CmdNotFound" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/CmdNotFound.png"> @@ -13,7 +13,7 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_CmdNotFound_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_CmdNotFound_HowToUse" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdNotFound.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdNotFound.xaml.cs index 1dea42fa74..65b30533bf 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdNotFound.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdNotFound.xaml.cs @@ -18,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeCmdNotFound() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.CmdNotFound]); + ViewModel = ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.CmdNotFound); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(CmdNotFoundPage)); + OobeWindow.OpenMainWindowCallback(typeof(CmdNotFoundPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdPal.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdPal.xaml index 16ae58e1fc..b8a8218be8 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdPal.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdPal.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_CmdPal" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/CmdPal.png"> @@ -17,7 +17,7 @@ <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_CmdPal_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_CmdPal_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdPal.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdPal.xaml.cs index ab68213a32..14a87af7b7 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdPal.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCmdPal.xaml.cs @@ -18,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeCmdPal() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.CmdPal]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.CmdPal); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(CmdPalPage)); + OobeWindow.OpenMainWindowCallback(typeof(CmdPalPage)); } ViewModel.LogOpeningSettingsEvent(); 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 5cda7a40e6..1700a3b05f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_ColorPicker" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/ColorPicker.gif"> @@ -17,10 +17,11 @@ <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_ColorPicker_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_ColorPicker_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button + x:Name="LaunchButton" x:Uid="Launch_ColorPicker" Click="Start_ColorPicker_Click" Style="{StaticResource AccentButtonStyle}" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml.cs index 444b2d7295..0a4ff41970 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeColorPicker.xaml.cs @@ -5,6 +5,7 @@ using System.Threading; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.OOBE.Enums; using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.Views; @@ -20,15 +21,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeColorPicker() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.ColorPicker]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.ColorPicker); DataContext = ViewModel; } private void Start_ColorPicker_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.ColorPickerSharedEventCallback != null) + if (OobeWindow.ColorPickerSharedEventCallback != null) { - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, OobeShellPage.ColorPickerSharedEventCallback())) + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, OobeWindow.ColorPickerSharedEventCallback())) { eventHandle.Set(); } @@ -39,9 +40,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(ColorPickerPage)); + OobeWindow.OpenMainWindowCallback(typeof(ColorPickerPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -50,9 +51,13 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - ColorPickerSettings settings = new SettingsUtils().GetSettingsOrDefault<ColorPickerSettings, ColorPickerSettingsVersion1>(ColorPickerSettings.ModuleName, settingsUpgrader: ColorPickerSettings.UpgradeSettings); + ColorPickerSettings settings = SettingsUtils.Default.GetSettingsOrDefault<ColorPickerSettings, ColorPickerSettingsVersion1>(ColorPickerSettings.ModuleName, settingsUpgrader: ColorPickerSettings.UpgradeSettings); HotkeyControl.Keys = settings.Properties.ActivationShortcut.GetKeysList(); + + // Disable the Launch button if the module is disabled + var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig; + LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.ColorPicker); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCropAndLock.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCropAndLock.xaml index ccea7ff980..cb32790fb3 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCropAndLock.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCropAndLock.xaml @@ -16,6 +16,8 @@ <controls:ShortcutWithTextLabelControl x:Name="ReparentHotkeyControl" x:Uid="Oobe_CropAndLock_HowToUse_Reparent" /> + <controls:ShortcutWithTextLabelControl x:Name="ScreenshotHotkeyControl" x:Uid="Oobe_CropAndLock_HowToUse_Screenshot" /> + <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCropAndLock.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCropAndLock.xaml.cs index be05925ec3..1bc2781d03 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCropAndLock.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeCropAndLock.xaml.cs @@ -18,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeCropAndLock() { InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.CropAndLock]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.CropAndLock); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(CropAndLockPage)); + OobeWindow.OpenMainWindowCallback(typeof(CropAndLockPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -35,8 +35,10 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - ReparentHotkeyControl.Keys = SettingsRepository<CropAndLockSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.ReparentHotkey.Value.GetKeysList(); - ThumbnailHotkeyControl.Keys = SettingsRepository<CropAndLockSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.ThumbnailHotkey.Value.GetKeysList(); + + ReparentHotkeyControl.Keys = SettingsRepository<CropAndLockSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ReparentHotkey.Value.GetKeysList(); + ThumbnailHotkeyControl.Keys = SettingsRepository<CropAndLockSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ThumbnailHotkey.Value.GetKeysList(); + ScreenshotHotkeyControl.Keys = SettingsRepository<CropAndLockSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ScreenshotHotkey.Value.GetKeysList(); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeEnvironmentVariables.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeEnvironmentVariables.xaml index f41c62b4f5..69d8a6aa17 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeEnvironmentVariables.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeEnvironmentVariables.xaml @@ -12,6 +12,7 @@ <StackPanel Orientation="Vertical" Spacing="12"> <StackPanel Orientation="Horizontal" Spacing="8"> <Button + x:Name="LaunchButton" x:Uid="Launch_EnvironmentVariables" Click="Launch_EnvironmentVariables_Click" Style="{StaticResource AccentButtonStyle}" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeEnvironmentVariables.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeEnvironmentVariables.xaml.cs index af20978b42..77da91326a 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeEnvironmentVariables.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeEnvironmentVariables.xaml.cs @@ -5,6 +5,7 @@ using System.Threading; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.OOBE.Enums; using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.Views; @@ -21,13 +22,17 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeEnvironmentVariables() { InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.EnvironmentVariables]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.EnvironmentVariables); DataContext = ViewModel; } protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); + + // Disable the Launch button if the module is disabled + var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig; + LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.EnvironmentVariables); } protected override void OnNavigatedFrom(NavigationEventArgs e) @@ -37,7 +42,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views private void Launch_EnvironmentVariables_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - bool launchAdmin = SettingsRepository<EnvironmentVariablesSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.LaunchAdministrator; + bool launchAdmin = SettingsRepository<EnvironmentVariablesSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.LaunchAdministrator; string eventName = !App.IsElevated && launchAdmin ? Constants.ShowEnvironmentVariablesAdminSharedEvent() : Constants.ShowEnvironmentVariablesSharedEvent(); @@ -50,9 +55,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views private void Launch_Settings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(EnvironmentVariablesPage)); + OobeWindow.OpenMainWindowCallback(typeof(EnvironmentVariablesPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFancyZones.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFancyZones.xaml index 4cbd73995d..022c78af9f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFancyZones.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFancyZones.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_FancyZones" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/FancyZones.gif"> @@ -13,12 +13,12 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_FancyZones_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_FancyZones_HowToUse" /> <controls:ShortcutWithTextLabelControl x:Name="HotkeyControl" x:Uid="Oobe_FancyZones_HowToUse_Shortcut" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_FancyZones_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_FancyZones_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFancyZones.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFancyZones.xaml.cs index ccbc5c8cc7..7461f055d7 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFancyZones.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFancyZones.xaml.cs @@ -18,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeFancyZones() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.FancyZones]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.FancyZones); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(FancyZonesPage)); + OobeWindow.OpenMainWindowCallback(typeof(FancyZonesPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -35,7 +35,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - HotkeyControl.Keys = SettingsRepository<FancyZonesSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.FancyzonesEditorHotkey.Value.GetKeysList(); + HotkeyControl.Keys = SettingsRepository<FancyZonesSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.FancyzonesEditorHotkey.Value.GetKeysList(); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileExplorer.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileExplorer.xaml index 8da25c5e14..04c785adf9 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileExplorer.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileExplorer.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_FileExplorer" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/FileExplorer.png"> @@ -13,7 +13,7 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToEnable" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_FileExplorer_HowToEnable" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_FileExplorer_HowToEnable" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileExplorer.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileExplorer.xaml.cs index e23250f316..3de75f2715 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileExplorer.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileExplorer.xaml.cs @@ -10,9 +10,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeFileExplorer : Page { public OobePowerToysModule ViewModel { get; set; } @@ -20,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeFileExplorer() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.FileExplorer]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.FileExplorer); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(PowerPreviewPage)); + OobeWindow.OpenMainWindowCallback(typeof(PowerPreviewPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileLocksmith.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileLocksmith.xaml index 4fe653954b..cf4355dcc6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileLocksmith.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileLocksmith.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_FileLocksmith" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/FileLocksmith.gif"> @@ -13,11 +13,11 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_FileLocksmith_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_FileLocksmith_HowToUse" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_FileLocksmith_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_FileLocksmith_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileLocksmith.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileLocksmith.xaml.cs index 148dae83c1..79565e0cd8 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileLocksmith.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeFileLocksmith.xaml.cs @@ -10,9 +10,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeFileLocksmith : Page { public OobePowerToysModule ViewModel { get; set; } @@ -20,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeFileLocksmith() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.FileLocksmith]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.FileLocksmith); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(FileLocksmithPage)); + OobeWindow.OpenMainWindowCallback(typeof(FileLocksmithPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeHosts.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeHosts.xaml index 66c9e2f2fd..918090fb13 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeHosts.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeHosts.xaml @@ -12,6 +12,7 @@ <StackPanel Orientation="Vertical" Spacing="12"> <StackPanel Orientation="Horizontal" Spacing="8"> <Button + x:Name="LaunchButton" x:Uid="Launch_Hosts" Click="Launch_Hosts_Click" Style="{StaticResource AccentButtonStyle}" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeHosts.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeHosts.xaml.cs index d47af96c7f..114c23a9c8 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeHosts.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeHosts.xaml.cs @@ -5,6 +5,7 @@ using System.Threading; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.OOBE.Enums; using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.Views; @@ -21,13 +22,17 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeHosts() { InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.Hosts]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.Hosts); DataContext = ViewModel; } protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); + + // Disable the Launch button if the module is disabled + var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig; + LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.Hosts); } protected override void OnNavigatedFrom(NavigationEventArgs e) @@ -37,7 +42,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views private void Launch_Hosts_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - bool launchAdmin = SettingsRepository<HostsSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.LaunchAdministrator; + bool launchAdmin = SettingsRepository<HostsSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.LaunchAdministrator; string eventName = !App.IsElevated && launchAdmin ? Constants.ShowHostsAdminSharedEvent() : Constants.ShowHostsSharedEvent(); @@ -50,9 +55,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views private void Launch_Settings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(HostsPage)); + OobeWindow.OpenMainWindowCallback(typeof(HostsPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeImageResizer.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeImageResizer.xaml index 3f88672287..e26cdad2f9 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeImageResizer.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeImageResizer.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_ImageResizer" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/ImageResizer.gif"> @@ -13,11 +13,11 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToLaunch" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_ImageResizer_HowToLaunch" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_ImageResizer_HowToLaunch" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_ImageResizer_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_ImageResizer_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeImageResizer.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeImageResizer.xaml.cs index f60f36a37e..b57292f659 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeImageResizer.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeImageResizer.xaml.cs @@ -10,9 +10,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeImageResizer : Page { public OobePowerToysModule ViewModel { get; set; } @@ -20,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeImageResizer() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.ImageResizer]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.ImageResizer); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(ImageResizerPage)); + OobeWindow.OpenMainWindowCallback(typeof(ImageResizerPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeKBM.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeKBM.xaml index 84e24e633c..9e88f71c8d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeKBM.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeKBM.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_KBM" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/KBM.gif"> @@ -13,11 +13,11 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToCreateMappings" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_KBM_HowToCreateMappings" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_KBM_HowToCreateMappings" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_KBM_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_KBM_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeKBM.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeKBM.xaml.cs index a583e351f5..b45a4d7982 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeKBM.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeKBM.xaml.cs @@ -10,9 +10,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeKBM : Page { public OobePowerToysModule ViewModel { get; set; } @@ -20,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeKBM() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.KBM]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.KBM); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(KeyboardManagerPage)); + OobeWindow.OpenMainWindowCallback(typeof(KeyboardManagerPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeLightSwitch.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeLightSwitch.xaml new file mode 100644 index 0000000000..b1f4c3f6fc --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeLightSwitch.xaml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8" ?> +<Page + x:Class="Microsoft.PowerToys.Settings.UI.OOBE.Views.OobeLightSwitch" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + mc:Ignorable="d"> + + <controls:OOBEPageControl x:Uid="Oobe_LightSwitch" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/LightSwitch.png"> + <controls:OOBEPageControl.PageContent> + <StackPanel Orientation="Vertical" Spacing="12"> + <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> + + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_LightSwitch_HowToUse" /> + + <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> + + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_LightSwitch_TipsAndTricks" /> + + <StackPanel Orientation="Horizontal" Spacing="8"> + <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> + + <HyperlinkButton NavigateUri="https://aka.ms/PowerToysOverview_LightSwitch" Style="{StaticResource TextButtonStyle}"> + <TextBlock x:Uid="LearnMore_LightSwitch" TextWrapping="Wrap" /> + </HyperlinkButton> + </StackPanel> + </StackPanel> + </controls:OOBEPageControl.PageContent> + </controls:OOBEPageControl> +</Page> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeLightSwitch.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeLightSwitch.xaml.cs new file mode 100644 index 0000000000..d7d1983c57 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeLightSwitch.xaml.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.PowerToys.Settings.UI.OOBE.Enums; +using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; +using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.OOBE.Views +{ + public sealed partial class OobeLightSwitch : Page + { + public OobePowerToysModule ViewModel { get; set; } + + public OobeLightSwitch() + { + this.InitializeComponent(); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.LightSwitch); + } + + private void SettingsLaunchButton_Click(object sender, RoutedEventArgs e) + { + if (OobeWindow.OpenMainWindowCallback != null) + { + OobeWindow.OpenMainWindowCallback(typeof(LightSwitchPage)); + } + + ViewModel.LogOpeningSettingsEvent(); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMeasureTool.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMeasureTool.xaml index 2b9d75f2f8..2a46366b19 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMeasureTool.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMeasureTool.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_MeasureTool" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/ScreenRuler.gif"> @@ -15,7 +15,7 @@ <controls:ShortcutWithTextLabelControl x:Name="HotkeyActivation" x:Uid="Oobe_MeasureTool_Activation" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_MeasureTool_HowToLaunch" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_MeasureTool_HowToLaunch" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMeasureTool.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMeasureTool.xaml.cs index 7ca7739883..49bff0daca 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMeasureTool.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMeasureTool.xaml.cs @@ -11,9 +11,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeMeasureTool : Page { public OobePowerToysModule ViewModel { get; set; } @@ -21,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeMeasureTool() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.MeasureTool]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.MeasureTool); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(MeasureToolPage)); + OobeWindow.OpenMainWindowCallback(typeof(MeasureToolPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -38,7 +35,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - HotkeyActivation.Keys = SettingsRepository<MeasureToolSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.ActivationShortcut.GetKeysList(); + HotkeyActivation.Keys = SettingsRepository<MeasureToolSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.GetKeysList(); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseUtils.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseUtils.xaml index 05f49f27ef..b2b20efb1e 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseUtils.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseUtils.xaml @@ -5,23 +5,23 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_MouseUtils" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/MouseUtils.gif"> <controls:OOBEPageControl.PageContent> <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_MouseUtils_FindMyMouse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_MouseUtils_FindMyMouse_Description" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_MouseUtils_FindMyMouse_Description" /> <TextBlock x:Uid="Oobe_MouseUtils_MouseHighlighter" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_MouseUtils_MouseHighlighter_Description" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_MouseUtils_MouseHighlighter_Description" /> <TextBlock x:Uid="Oobe_MouseUtils_MousePointerCrosshairs" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_MouseUtils_MousePointerCrosshairs_Description" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_MouseUtils_MousePointerCrosshairs_Description" /> <TextBlock x:Uid="Oobe_MouseUtils_MouseJump" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_MouseUtils_MouseJump_Description" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_MouseUtils_MouseJump_Description" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseUtils.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseUtils.xaml.cs index d62ab2f85f..5d96e3f7ae 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseUtils.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseUtils.xaml.cs @@ -17,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeMouseUtils() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.MouseUtils]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.MouseUtils); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(MouseUtilsPage)); + OobeWindow.OpenMainWindowCallback(typeof(MouseUtilsPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseWithoutBorders.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseWithoutBorders.xaml index 6b4d937e2d..cc3d36cf64 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseWithoutBorders.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseWithoutBorders.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_MouseWithoutBorders" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/MouseWithoutBorders.png"> @@ -14,11 +14,11 @@ <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_MouseWithoutBorders_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_MouseWithoutBorders_HowToUse" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_MouseWithoutBorders_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_MouseWithoutBorders_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseWithoutBorders.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseWithoutBorders.xaml.cs index 243e871cdd..c4a64db872 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseWithoutBorders.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeMouseWithoutBorders.xaml.cs @@ -10,9 +10,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeMouseWithoutBorders : Page { public OobePowerToysModule ViewModel { get; set; } @@ -20,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeMouseWithoutBorders() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.MouseWithoutBorders]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.MouseWithoutBorders); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(MouseWithoutBordersPage)); + OobeWindow.OpenMainWindowCallback(typeof(MouseWithoutBordersPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeNewPlus.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeNewPlus.xaml index 5e657073f4..bd190275b0 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeNewPlus.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeNewPlus.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <!-- TODO: Create New+ overview .gif and update ref here --> @@ -13,10 +13,10 @@ <controls:OOBEPageControl.PageContent> <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_NewPlus_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_NewPlus_HowToUse" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_NewPlus_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_NewPlus_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeNewPlus.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeNewPlus.xaml.cs index 381a2b35ff..fe25e57b41 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeNewPlus.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeNewPlus.xaml.cs @@ -10,9 +10,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeNewPlus : Page { public OobePowerToysModule ViewModel { get; set; } @@ -20,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeNewPlus() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.NewPlus]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.NewPlus); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(NewPlusPage)); + OobeWindow.OpenMainWindowCallback(typeof(NewPlusPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml index b04c800bca..0570a76d1f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml @@ -20,35 +20,40 @@ </HyperlinkButton> </StackPanel> - <StackPanel - Orientation="Vertical" - Spacing="8" - Visibility="{x:Bind ShowDataDiagnosticsSetting, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <StackPanel Orientation="Vertical" Visibility="{x:Bind ShowDataDiagnosticsSetting, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> <TextBlock x:Uid="Oobe_Overview_Telemetry_Title" - Margin="0,20,0,0" - Style="{StaticResource SubtitleTextBlockStyle}" /> + Margin="0,24,0,0" + Style="{StaticResource BodyStrongTextBlockStyle}" /> <TextBlock x:Uid="Oobe_Overview_Telemetry_Desc" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> <tkcontrols:SettingsCard x:Uid="Oobe_Overview_EnableDataDiagnostics" + Margin="0,8,0,0" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ShowDataDiagnosticsSetting, Mode=OneWay}"> + <tkcontrols:SettingsCard.Description> + <StackPanel Orientation="Vertical"> + <TextBlock + x:Uid="GeneralPage_EnableDataDiagnosticsText" + Style="{StaticResource SecondaryTextStyle}" + TextWrapping="WrapWholeWords" /> + <HyperlinkButton + x:Uid="GeneralPage_DiagnosticsAndFeedback_Link" + Margin="0,2,0,0" + FontWeight="SemiBold" + NavigateUri="https://aka.ms/powertoys-data-and-privacy-documentation" /> + <HyperlinkButton + x:Uid="Oobe_Overview_DiagnosticsAndFeedback_Settings_Link" + Margin="0,2,0,0" + Click="GeneralSettingsLaunchButton_Click" + FontWeight="SemiBold" /> + </StackPanel> + </tkcontrols:SettingsCard.Description> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind EnableDataDiagnostics, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - - <HyperlinkButton - x:Uid="Oobe_Overview_DiagnosticsAndFeedback_Settings_Link" - Margin="-8,0,0,0" - Click="GeneralSettingsLaunchButton_Click" /> - - <HyperlinkButton - x:Uid="Oobe_Overview_DiagnosticsAndFeedback_Link" - Margin="-8,0,0,0" - NavigateUri="https://aka.ms/powertoys-data-and-privacy-documentation" /> - </StackPanel> </StackPanel> </controls:OOBEPageControl.PageContent> </controls:OOBEPageControl> -</Page> +</Page> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml.cs index ba4595ea06..c153e0e43d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverview.xaml.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.ComponentModel; using global::PowerToys.GPOWrapper; using Microsoft.PowerToys.Settings.UI.OOBE.Enums; using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; @@ -12,7 +13,7 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - public sealed partial class OobeOverview : Page + public sealed partial class OobeOverview : Page, INotifyPropertyChanged { public OobePowerToysModule ViewModel { get; set; } @@ -43,6 +44,8 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public bool ShowDataDiagnosticsSetting => GetIsDataDiagnosticsInfoBarEnabled(); + public event PropertyChangedEventHandler PropertyChanged; + private bool GetIsDataDiagnosticsInfoBarEnabled() { var isDataDiagnosticsGpoDisallowed = GPOWrapper.GetAllowDataDiagnosticsValue() == GpoRuleConfigured.Disabled; @@ -56,15 +59,20 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views _enableDataDiagnostics = DataDiagnosticsSettings.GetEnabledValue(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.Overview]); - DataContext = ViewModel; + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.Overview); + DataContext = this; + } + + private void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(DashboardPage)); + OobeWindow.OpenMainWindowCallback(typeof(DashboardPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -72,9 +80,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views private void GeneralSettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(GeneralPage)); + OobeWindow.OpenMainWindowCallback(typeof(GeneralPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewAlternate.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewAlternate.xaml deleted file mode 100644 index 713a082d86..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewAlternate.xaml +++ /dev/null @@ -1,192 +0,0 @@ -<Page - x:Class="Microsoft.PowerToys.Settings.UI.OOBE.Views.OobeOverviewAlternate" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d"> - - <controls:OOBEPageControl - x:Uid="Oobe_Overview" - HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/PTHeroShort.png" - HeroImageHeight="120"> - - <controls:OOBEPageControl.PageContent> - <StackPanel Orientation="Vertical" Spacing="12"> - <TextBlock - x:Uid="Alternate_OOBE_Description" - Margin="0,24,0,12" - FontWeight="SemiBold" /> - <GridView SelectionMode="None"> - <GridViewItem> - <Grid - Width="280" - Margin="8" - Padding="16,16,16,10" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="8" - RowSpacing="0"> - - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - <Image - Width="36" - HorizontalAlignment="Left" - Source="ms-appx:///Assets/Settings/Icons/FancyZones.png" /> - <TextBlock - x:Uid="Alternate_OOBE_FancyZones_Title" - Grid.Row="1" - Margin="0,12,0,6" - HorizontalAlignment="Left" - FontSize="16" - FontWeight="SemiBold" - TextWrapping="Wrap" /> - <TextBlock - x:Uid="Alternate_OOBE_FancyZones_Description" - Grid.Row="2" - FontSize="12" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" /> - <controls:ShortcutWithTextLabelControl - x:Name="FancyZonesHotkeyControl" - Grid.Row="3" - Margin="0,8,0,0" /> - </Grid> - </GridViewItem> - - <GridViewItem> - <Grid - Width="280" - Padding="16,16,16,10" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="8" - RowSpacing="0"> - - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - <Image - Width="36" - HorizontalAlignment="Left" - Source="ms-appx:///Assets/Settings/Icons/PowerToysRun.png" /> - <TextBlock - x:Uid="Alternate_OOBE_Run_Title" - Grid.Row="1" - Margin="0,12,0,6" - HorizontalAlignment="Left" - FontSize="16" - FontWeight="SemiBold" - TextWrapping="Wrap" /> - <TextBlock - x:Uid="Alternate_OOBE_Run_Description" - Grid.Row="2" - FontSize="12" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" /> - <controls:ShortcutWithTextLabelControl - x:Name="RunHotkeyControl" - Grid.Row="3" - Margin="0,8,0,0" /> - </Grid> - </GridViewItem> - - <GridViewItem> - <Grid - Width="280" - Padding="16,16,16,10" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="8" - RowSpacing="0"> - - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - <Image - Width="36" - HorizontalAlignment="Left" - Source="ms-appx:///Assets/Settings/Icons/ColorPicker.png" /> - <TextBlock - x:Uid="Alternate_OOBE_ColorPicker_Title" - Grid.Row="1" - Margin="0,12,0,6" - HorizontalAlignment="Left" - FontSize="16" - FontWeight="SemiBold" - TextWrapping="Wrap" /> - <TextBlock - x:Uid="Alternate_OOBE_ColorPicker_Description" - Grid.Row="2" - FontSize="12" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Text="To pick a color:" - TextWrapping="Wrap" /> - <controls:ShortcutWithTextLabelControl - x:Name="ColorPickerHotkeyControl" - Grid.Row="3" - Margin="0,8,0,0" /> - </Grid> - </GridViewItem> - - <GridViewItem> - <Grid - Width="280" - Padding="16,16,16,10" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="8" - RowSpacing="0"> - - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - <Image - Width="36" - HorizontalAlignment="Left" - Source="ms-appx:///Assets/Settings/Icons/AlwaysOnTop.png" /> - <TextBlock - x:Uid="Alternate_OOBE_AlwaysOnTop_Title" - Grid.Row="1" - Margin="0,12,0,6" - HorizontalAlignment="Left" - FontSize="16" - FontWeight="SemiBold" - TextWrapping="Wrap" /> - <TextBlock - x:Uid="Alternate_OOBE_AlwaysOnTop_Description" - Grid.Row="2" - FontSize="12" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" /> - <controls:ShortcutWithTextLabelControl - x:Name="AlwaysOnTopHotkeyControl" - Grid.Row="3" - Margin="0,8,0,0" /> - </Grid> - </GridViewItem> - - </GridView> - </StackPanel> - </controls:OOBEPageControl.PageContent> - </controls:OOBEPageControl> -</Page> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewPlaceholder.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewPlaceholder.xaml deleted file mode 100644 index 0251f5d462..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewPlaceholder.xaml +++ /dev/null @@ -1,20 +0,0 @@ -<!-- Copyright (c) Microsoft Corporation. All rights reserved. --> -<!-- Licensed under the MIT License. See LICENSE in the project root for license information. --> - -<Page - x:Class="Microsoft.PowerToys.Settings.UI.OOBE.Views.OobeOverviewPlaceholder" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" - Loaded="Page_Loaded" - mc:Ignorable="d"> - - <ProgressRing - x:Name="LoadingProgressRing" - HorizontalAlignment="Center" - VerticalAlignment="Center" - IsIndeterminate="True" - Visibility="Visible" /> -</Page> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewPlaceholder.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewPlaceholder.xaml.cs deleted file mode 100644 index c7767e2424..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewPlaceholder.xaml.cs +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Threading.Tasks; - -using AllExperiments; -using Microsoft.PowerToys.Settings.UI.OOBE.Enums; -using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; -using Microsoft.PowerToys.Settings.UI.Services; -using Microsoft.PowerToys.Settings.UI.Views; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Navigation; - -namespace Microsoft.PowerToys.Settings.UI.OOBE.Views -{ - public sealed partial class OobeOverviewPlaceholder : Page - { - public OobePowerToysModule ViewModel { get; set; } - - public OobeOverviewPlaceholder() - { - this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.Overview]); - DataContext = ViewModel; - } - - private static async Task<bool> GetIsExperiment() - { - Experiments landingPageExp = new Experiments(); - var experimentEnabled = await landingPageExp.EnableLandingPageExperimentAsync(); - return experimentEnabled; - } - - private async void Reload() - { - var isExperiment = await GetIsExperiment(); - - if (isExperiment) - { - this.Frame.Navigate(typeof(OobeOverviewAlternate)); - } - else - { - this.Frame.Navigate(typeof(OobeOverview)); - } - } - - private void Page_Loaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - Reload(); - } - - private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - if (OobeShellPage.OpenMainWindowCallback != null) - { - OobeShellPage.OpenMainWindowCallback(typeof(DashboardPage)); - } - - ViewModel.LogOpeningSettingsEvent(); - } - - protected override void OnNavigatedTo(NavigationEventArgs e) - { - ViewModel.LogOpeningModuleEvent(); - } - - protected override void OnNavigatedFrom(NavigationEventArgs e) - { - ViewModel.LogClosingModuleEvent(); - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePeek.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePeek.xaml.cs index 7817b13ca4..c40727dcca 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePeek.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePeek.xaml.cs @@ -11,9 +11,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobePeek : Page { public OobePowerToysModule ViewModel { get; set; } @@ -21,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobePeek() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.Peek]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.Peek); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(PeekPage)); + OobeWindow.OpenMainWindowCallback(typeof(PeekPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -38,7 +35,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - HotkeyControl.Keys = SettingsRepository<PeekSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.ActivationShortcut.GetKeysList(); + HotkeyControl.Keys = SettingsRepository<PeekSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.GetKeysList(); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerAccent.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerAccent.xaml index 9ba7558169..889a14c8f1 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerAccent.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerAccent.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_QuickAccent" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/QuickAccent.gif"> @@ -13,7 +13,7 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_QuickAccent_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_QuickAccent_HowToUse" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerAccent.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerAccent.xaml.cs index 74aca45538..310045cb47 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerAccent.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerAccent.xaml.cs @@ -17,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobePowerAccent() { InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.QuickAccent]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.QuickAccent); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(PowerAccentPage)); + OobeWindow.OpenMainWindowCallback(typeof(PowerAccentPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerDisplay.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerDisplay.xaml new file mode 100644 index 0000000000..740431a01d --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerDisplay.xaml @@ -0,0 +1,36 @@ +<Page + x:Class="Microsoft.PowerToys.Settings.UI.OOBE.Views.OobePowerDisplay" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + mc:Ignorable="d"> + + <controls:OOBEPageControl x:Uid="Oobe_PowerDisplay" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/PowerDisplay.gif"> + <controls:OOBEPageControl.PageContent> + <StackPanel Orientation="Vertical" Spacing="12"> + <TextBlock x:Uid="Oobe_HowToLaunch" Style="{ThemeResource OobeSubtitleStyle}" /> + + <controls:ShortcutWithTextLabelControl x:Name="HotkeyActivation" x:Uid="Oobe_PowerDisplay_Activation" /> + + <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> + + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_PowerDisplay_TipsAndTricks" /> + + <StackPanel Orientation="Horizontal" Spacing="8"> + <Button + x:Uid="Launch_PowerDisplay" + Click="Launch_PowerDisplay_Click" + Style="{StaticResource AccentButtonStyle}" /> + <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> + + <HyperlinkButton NavigateUri="https://aka.ms/PowerToysOverview_PowerDisplay" Style="{StaticResource TextButtonStyle}"> + <TextBlock x:Uid="LearnMore_PowerDisplay" TextWrapping="Wrap" /> + </HyperlinkButton> + </StackPanel> + </StackPanel> + </controls:OOBEPageControl.PageContent> + </controls:OOBEPageControl> +</Page> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewAlternate.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerDisplay.xaml.cs similarity index 50% rename from src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewAlternate.xaml.cs rename to src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerDisplay.xaml.cs index d0ae488347..5b28a27efa 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeOverviewAlternate.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerDisplay.xaml.cs @@ -1,37 +1,43 @@ -// 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.Threading; + using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.OOBE.Enums; using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.Views; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Navigation; +using PowerToys.Interop; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - public sealed partial class OobeOverviewAlternate : Page + public sealed partial class OobePowerDisplay : Page { public OobePowerToysModule ViewModel { get; set; } - public OobeOverviewAlternate() + public OobePowerDisplay() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.Overview]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.PowerDisplay); DataContext = ViewModel; + } - FancyZonesHotkeyControl.Keys = SettingsRepository<FancyZonesSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.FancyzonesEditorHotkey.Value.GetKeysList(); - RunHotkeyControl.Keys = SettingsRepository<PowerLauncherSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.OpenPowerLauncher.GetKeysList(); - ColorPickerHotkeyControl.Keys = SettingsRepository<ColorPickerSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.ActivationShortcut.GetKeysList(); - AlwaysOnTopHotkeyControl.Keys = SettingsRepository<AlwaysOnTopSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.Hotkey.Value.GetKeysList(); + private void Launch_PowerDisplay_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.TogglePowerDisplayEvent())) + { + eventHandle.Set(); + } } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(DashboardPage)); + OobeWindow.OpenMainWindowCallback(typeof(PowerDisplayPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -40,6 +46,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); + HotkeyActivation.Keys = SettingsRepository<PowerDisplaySettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.GetKeysList(); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerOCR.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerOCR.xaml index 282753bb24..bf5298975e 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerOCR.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerOCR.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_TextExtractor" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/TextExtractor.gif"> @@ -17,7 +17,7 @@ <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_TextExtractor_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_TextExtractor_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerOCR.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerOCR.xaml.cs index 7fef84d2b9..8b198c308a 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerOCR.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerOCR.xaml.cs @@ -18,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobePowerOCR() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.TextExtractor]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.TextExtractor); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(PowerOcrPage)); + OobeWindow.OpenMainWindowCallback(typeof(PowerOcrPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -35,7 +35,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - HotkeyControl.Keys = SettingsRepository<PowerOcrSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.ActivationShortcut.GetKeysList(); + HotkeyControl.Keys = SettingsRepository<PowerOcrSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.ActivationShortcut.GetKeysList(); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerRename.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerRename.xaml index 844c976931..72b83d283d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerRename.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerRename.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_PowerRename" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/PowerRename.gif"> @@ -13,11 +13,11 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_PowerRename_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_PowerRename_HowToUse" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_PowerRename_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_PowerRename_TipsAndTricks" /> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerRename.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerRename.xaml.cs index dc34bc5383..12559f656c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerRename.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobePowerRename.xaml.cs @@ -10,9 +10,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobePowerRename : Page { public OobePowerToysModule ViewModel { get; set; } @@ -20,15 +17,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobePowerRename() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.PowerRename]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.PowerRename); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(PowerRenamePage)); + OobeWindow.OpenMainWindowCallback(typeof(PowerRenamePage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRegistryPreview.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRegistryPreview.xaml index 21d4345bd4..17b8c478ba 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRegistryPreview.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRegistryPreview.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_RegistryPreview" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/RegistryPreview.png"> @@ -13,14 +13,15 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_RegistryPreview_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_RegistryPreview_HowToUse" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_RegistryPreview_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_RegistryPreview_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button + x:Name="LaunchButton" x:Uid="Launch_RegistryPreview" Click="Launch_RegistryPreview_Click" Style="{StaticResource AccentButtonStyle}" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRegistryPreview.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRegistryPreview.xaml.cs index 5e78f18988..e5e081ddfd 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRegistryPreview.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRegistryPreview.xaml.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.OOBE.Enums; using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.Views; @@ -11,9 +12,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeRegistryPreview : Page { public OobePowerToysModule ViewModel { get; set; } @@ -21,7 +19,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeRegistryPreview() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.RegistryPreview]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.RegistryPreview); DataContext = ViewModel; } @@ -32,9 +30,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(RegistryPreviewPage)); + OobeWindow.OpenMainWindowCallback(typeof(RegistryPreviewPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -43,6 +41,10 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); + + // Disable the Launch button if the module is disabled + var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig; + LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.RegistryPreview); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRun.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRun.xaml index 79b39a0070..33bc401751 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRun.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRun.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_Run" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/Run.gif"> @@ -17,10 +17,11 @@ <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_Run_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_Run_TipsAndTricks" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button + x:Name="LaunchButton" x:Uid="Launch_Run" Click="Start_Run_Click" Style="{StaticResource AccentButtonStyle}" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRun.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRun.xaml.cs index 3cb8593922..9033c976aa 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRun.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeRun.xaml.cs @@ -5,6 +5,7 @@ using System.Threading; using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.OOBE.Enums; using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.Views; @@ -13,9 +14,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeRun : Page { public OobePowerToysModule ViewModel { get; set; } @@ -23,15 +21,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeRun() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.Run]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.Run); DataContext = ViewModel; } private void Start_Run_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.RunSharedEventCallback != null) + if (OobeWindow.RunSharedEventCallback != null) { - using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, OobeShellPage.RunSharedEventCallback())) + using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, OobeWindow.RunSharedEventCallback())) { eventHandle.Set(); } @@ -42,9 +40,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(PowerLauncherPage)); + OobeWindow.OpenMainWindowCallback(typeof(PowerLauncherPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -54,7 +52,11 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { ViewModel.LogOpeningModuleEvent(); - HotkeyControl.Keys = SettingsRepository<PowerLauncherSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.OpenPowerLauncher.GetKeysList(); + HotkeyControl.Keys = SettingsRepository<PowerLauncherSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.OpenPowerLauncher.GetKeysList(); + + // Disable the Launch button if the module is disabled + var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig; + LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.PowerLauncher); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml deleted file mode 100644 index d3f3d7e257..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml +++ /dev/null @@ -1,203 +0,0 @@ -<UserControl - x:Class="Microsoft.PowerToys.Settings.UI.OOBE.Views.OobeShellPage" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:animations="using:CommunityToolkit.WinUI.Animations" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:ui="using:CommunityToolkit.WinUI" - HighContrastAdjustment="None" - Loaded="ShellPage_Loaded" - mc:Ignorable="d"> - - <Grid> - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="*" /> - </Grid.RowDefinitions> - <Button - x:Name="PaneToggleBtn" - Width="48" - HorizontalAlignment="Left" - VerticalAlignment="Center" - Click="PaneToggleBtn_Click" - Style="{StaticResource PaneToggleButtonStyle}" /> - <Grid - x:Name="AppTitleBar" - Height="48" - Margin="48,0,0,0" - VerticalAlignment="Center" - IsHitTestVisible="True"> - <animations:Implicit.Animations> - <animations:OffsetAnimation Duration="0:0:0.3" /> - </animations:Implicit.Animations> - <StackPanel Orientation="Horizontal"> - <Image - Width="16" - Height="16" - Source="/Assets/Settings/icon.ico" /> - <TextBlock - x:Name="AppTitleBarText" - x:Uid="OobeWindow_TitleTxt" - VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}" - TextWrapping="NoWrap" /> - </StackPanel> - </Grid> - - <NavigationView - x:Name="navigationView" - Grid.Row="1" - DisplayModeChanged="NavigationView_DisplayModeChanged" - IsBackButtonVisible="Collapsed" - IsPaneOpen="True" - IsPaneToggleButtonVisible="False" - IsSettingsVisible="False" - OpenPaneLength="296" - PaneDisplayMode="Left" - SelectionChanged="NavigationView_SelectionChanged"> - <NavigationView.MenuItems> - <NavigationViewItem - x:Uid="Shell_General" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerToys.png}" - Tag="Overview" /> - <NavigationViewItem - x:Uid="Shell_AdvancedPaste" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AdvancedPaste.png}" - Tag="AdvancedPaste" /> - <NavigationViewItem - x:Uid="Shell_AlwaysOnTop" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AlwaysOnTop.png}" - Tag="AlwaysOnTop" /> - <NavigationViewItem - x:Uid="Shell_Awake" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Awake.png}" - Tag="Awake" /> - <NavigationViewItem - x:Uid="Shell_ColorPicker" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ColorPicker.png}" - Tag="ColorPicker" /> - <NavigationViewItem - x:Uid="Shell_CmdPal" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CmdPal.png}" - Tag="CmdPal" /> - <NavigationViewItem - x:Uid="Shell_CmdNotFound" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CommandNotFound.png}" - Tag="CmdNotFound" /> - <NavigationViewItem - x:Uid="Shell_CropAndLock" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CropAndLock.png}" - Tag="CropAndLock" /> - <NavigationViewItem - x:Uid="Shell_EnvironmentVariables" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/EnvironmentVariables.png}" - Tag="EnvironmentVariables" /> - <NavigationViewItem - x:Uid="Shell_FancyZones" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FancyZones.png}" - Tag="FancyZones" /> - <NavigationViewItem - x:Uid="Shell_FileLocksmith" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FileLocksmith.png}" - Tag="FileLocksmith" /> - <NavigationViewItem - x:Uid="Shell_PowerPreview" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FileExplorerPreview.png}" - Tag="FileExplorer" /> - <NavigationViewItem - x:Uid="Shell_Hosts" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Hosts.png}" - Tag="Hosts" /> - <NavigationViewItem - x:Uid="Shell_ImageResizer" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ImageResizer.png}" - Tag="ImageResizer" /> - <NavigationViewItem - x:Uid="Shell_KeyboardManager" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/KeyboardManager.png}" - Tag="KBM" /> - <NavigationViewItem - x:Uid="Shell_MouseUtilities" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseUtils.png}" - Tag="MouseUtils" /> - <NavigationViewItem - x:Uid="Shell_MouseWithoutBorders" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseWithoutBorders.png}" - Tag="MouseWithoutBorders" /> - <NavigationViewItem - x:Uid="NewPlus_Product_Name" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/NewPlus.png}" - Tag="NewPlus" /> - <NavigationViewItem - x:Uid="Shell_Peek" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Peek.png}" - Tag="Peek" /> - <NavigationViewItem - x:Uid="Shell_PowerRename" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerRename.png}" - Tag="PowerRename" /> - <NavigationViewItem - x:Uid="Shell_PowerLauncher" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerToysRun.png}" - Tag="Run" /> - <NavigationViewItem - x:Uid="Shell_QuickAccent" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/QuickAccent.png}" - Tag="QuickAccent" /> - <NavigationViewItem - x:Uid="Shell_RegistryPreview" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/RegistryPreview.png}" - Tag="RegistryPreview" /> - <NavigationViewItem - x:Uid="Shell_MeasureTool" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ScreenRuler.png}" - Tag="MeasureTool" /> - <NavigationViewItem - x:Uid="Shell_ShortcutGuide" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ShortcutGuide.png}" - Tag="ShortcutGuide" /> - <NavigationViewItem - x:Uid="Shell_TextExtractor" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/TextExtractor.png}" - Tag="TextExtractor" /> - <NavigationViewItem - x:Uid="Shell_Workspaces" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Workspaces.png}" - Tag="Workspaces" /> - <NavigationViewItem - x:Uid="Shell_ZoomIt" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ZoomIt.png}" - Tag="ZoomIt" /> - </NavigationView.MenuItems> - <NavigationView.FooterMenuItems> - <NavigationViewItem - x:Uid="Shell_WhatsNew" - Icon="{ui:FontIcon Glyph=}" - Tag="WhatsNew" /> - </NavigationView.FooterMenuItems> - <NavigationView.Content> - <Frame x:Name="NavigationFrame" /> - </NavigationView.Content> - </NavigationView> - - <VisualStateManager.VisualStateGroups> - <VisualStateGroup x:Name="LayoutVisualStates"> - <VisualState x:Name="WideLayout"> - <VisualState.StateTriggers> - <AdaptiveTrigger MinWindowWidth="720" /> - </VisualState.StateTriggers> - </VisualState> - <VisualState x:Name="SmallLayout"> - <VisualState.StateTriggers> - <AdaptiveTrigger MinWindowWidth="600" /> - <AdaptiveTrigger MinWindowWidth="0" /> - </VisualState.StateTriggers> - <VisualState.Setters> - <Setter Target="navigationView.PaneDisplayMode" Value="LeftMinimal" /> - </VisualState.Setters> - </VisualState> - </VisualStateGroup> - </VisualStateManager.VisualStateGroups> - </Grid> -</UserControl> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml.cs deleted file mode 100644 index 53f41db2bf..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml.cs +++ /dev/null @@ -1,347 +0,0 @@ -// 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.ObjectModel; -using System.Globalization; - -using ManagedCommon; -using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.OOBE.Enums; -using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using WinRT.Interop; - -namespace Microsoft.PowerToys.Settings.UI.OOBE.Views -{ - public sealed partial class OobeShellPage : UserControl - { - public static Func<string> RunSharedEventCallback { get; set; } - - public static void SetRunSharedEventCallback(Func<string> implementation) - { - RunSharedEventCallback = implementation; - } - - public static Func<string> ColorPickerSharedEventCallback { get; set; } - - public static void SetColorPickerSharedEventCallback(Func<string> implementation) - { - ColorPickerSharedEventCallback = implementation; - } - - public static Action<Type> OpenMainWindowCallback { get; set; } - - public static void SetOpenMainWindowCallback(Action<Type> implementation) - { - OpenMainWindowCallback = implementation; - } - - /// <summary> - /// Gets view model. - /// </summary> - public OobeShellViewModel ViewModel { get; } = new OobeShellViewModel(); - - /// <summary> - /// Gets or sets a shell handler to be used to update contents of the shell dynamically from page within the frame. - /// </summary> - public static OobeShellPage OobeShellHandler { get; set; } - - public ObservableCollection<OobePowerToysModule> Modules { get; } - - private static ISettingsUtils settingsUtils = new SettingsUtils(); - - /* NOTE: Experimentation for OOBE is currently turned off on server side. Keeping this code in a comment to allow future experiments. - private bool ExperimentationToggleSwitchEnabled { get; set; } = true; - */ - - public OobeShellPage() - { - InitializeComponent(); - - // NOTE: Experimentation for OOBE is currently turned off on server side. Keeping this code in a comment to allow future experiments. - // ExperimentationToggleSwitchEnabled = SettingsRepository<GeneralSettings>.GetInstance(settingsUtils).SettingsConfig.EnableExperimentation; - SetTitleBar(); - DataContext = ViewModel; - OobeShellHandler = this; - Modules = new ObservableCollection<OobePowerToysModule>(); - - Modules.Insert((int)PowerToysModules.Overview, new OobePowerToysModule() - { - ModuleName = "Overview", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.AdvancedPaste, new OobePowerToysModule() - { - ModuleName = "AdvancedPaste", - IsNew = true, - }); - Modules.Insert((int)PowerToysModules.AlwaysOnTop, new OobePowerToysModule() - { - ModuleName = "AlwaysOnTop", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.Awake, new OobePowerToysModule() - { - ModuleName = "Awake", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.CmdNotFound, new OobePowerToysModule() - { - ModuleName = "CmdNotFound", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.CmdPal, new OobePowerToysModule() - { - ModuleName = "CmdPal", - IsNew = true, - }); - Modules.Insert((int)PowerToysModules.ColorPicker, new OobePowerToysModule() - { - ModuleName = "ColorPicker", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.CropAndLock, new OobePowerToysModule() - { - ModuleName = "CropAndLock", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.EnvironmentVariables, new OobePowerToysModule() - { - ModuleName = "EnvironmentVariables", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.FancyZones, new OobePowerToysModule() - { - ModuleName = "FancyZones", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.FileLocksmith, new OobePowerToysModule() - { - ModuleName = "FileLocksmith", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.FileExplorer, new OobePowerToysModule() - { - ModuleName = "FileExplorer", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.ImageResizer, new OobePowerToysModule() - { - ModuleName = "ImageResizer", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.KBM, new OobePowerToysModule() - { - ModuleName = "KBM", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.MouseUtils, new OobePowerToysModule() - { - ModuleName = "MouseUtils", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.MouseWithoutBorders, new OobePowerToysModule() - { - ModuleName = "MouseWithoutBorders", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.Peek, new OobePowerToysModule() - { - ModuleName = "Peek", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.PowerRename, new OobePowerToysModule() - { - ModuleName = "PowerRename", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.Run, new OobePowerToysModule() - { - ModuleName = "Run", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.QuickAccent, new OobePowerToysModule() - { - ModuleName = "QuickAccent", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.ShortcutGuide, new OobePowerToysModule() - { - ModuleName = "ShortcutGuide", - IsNew = false, - }); - Modules.Insert((int)PowerToysModules.TextExtractor, new OobePowerToysModule() - { - ModuleName = "TextExtractor", - IsNew = false, - }); - - Modules.Insert((int)PowerToysModules.MeasureTool, new OobePowerToysModule() - { - ModuleName = "MeasureTool", - IsNew = false, - }); - - Modules.Insert((int)PowerToysModules.Hosts, new OobePowerToysModule() - { - ModuleName = "Hosts", - IsNew = false, - }); - - Modules.Insert((int)PowerToysModules.Workspaces, new OobePowerToysModule() - { - ModuleName = "Workspaces", - IsNew = true, - }); - - Modules.Insert((int)PowerToysModules.WhatsNew, new OobePowerToysModule() - { - ModuleName = "WhatsNew", - IsNew = false, - }); - - Modules.Insert((int)PowerToysModules.RegistryPreview, new OobePowerToysModule() - { - ModuleName = "RegistryPreview", - IsNew = false, - }); - - Modules.Insert((int)PowerToysModules.NewPlus, new OobePowerToysModule() - { - ModuleName = "NewPlus", - IsNew = true, - }); - - Modules.Insert((int)PowerToysModules.ZoomIt, new OobePowerToysModule() - { - ModuleName = "ZoomIt", - IsNew = true, - }); - } - - public void OnClosing() - { - Microsoft.UI.Xaml.Controls.NavigationViewItem selectedItem = this.navigationView.SelectedItem as Microsoft.UI.Xaml.Controls.NavigationViewItem; - if (selectedItem != null) - { - Modules[(int)(PowerToysModules)Enum.Parse(typeof(PowerToysModules), (string)selectedItem.Tag, true)].LogClosingModuleEvent(); - } - } - - public void NavigateToModule(PowerToysModules selectedModule) - { - if (selectedModule == PowerToysModules.WhatsNew) - { - navigationView.SelectedItem = navigationView.FooterMenuItems[0]; - } - else - { - navigationView.SelectedItem = navigationView.MenuItems[(int)selectedModule]; - } - } - - private void NavigationView_SelectionChanged(Microsoft.UI.Xaml.Controls.NavigationView sender, Microsoft.UI.Xaml.Controls.NavigationViewSelectionChangedEventArgs args) - { - Microsoft.UI.Xaml.Controls.NavigationViewItem selectedItem = args.SelectedItem as Microsoft.UI.Xaml.Controls.NavigationViewItem; - - if (selectedItem != null) - { - switch (selectedItem.Tag) - { - case "Overview": NavigationFrame.Navigate(typeof(OobeOverview)); break; - /* NOTE: Experimentation for OOBE is currently turned off on server side. Keeping this code in a comment to allow future experiments. - if (ExperimentationToggleSwitchEnabled && GPOWrapper.GetAllowExperimentationValue() != GpoRuleConfigured.Disabled) - { - switch (AllExperiments.Experiments.LandingPageExperiment) - { - case Experiments.ExperimentState.Enabled: - NavigationFrame.Navigate(typeof(OobeOverviewAlternate)); break; - case Experiments.ExperimentState.Disabled: - NavigationFrame.Navigate(typeof(OobeOverview)); break; - case Experiments.ExperimentState.NotLoaded: - NavigationFrame.Navigate(typeof(OobeOverviewPlaceholder)); break; - } - - break; - } - else - { - NavigationFrame.Navigate(typeof(OobeOverview)); - break; - } - */ - case "WhatsNew": NavigationFrame.Navigate(typeof(OobeWhatsNew)); break; - case "AdvancedPaste": NavigationFrame.Navigate(typeof(OobeAdvancedPaste)); break; - case "AlwaysOnTop": NavigationFrame.Navigate(typeof(OobeAlwaysOnTop)); break; - case "Awake": NavigationFrame.Navigate(typeof(OobeAwake)); break; - case "CmdNotFound": NavigationFrame.Navigate(typeof(OobeCmdNotFound)); break; - case "CmdPal": NavigationFrame.Navigate(typeof(OobeCmdPal)); break; - case "ColorPicker": NavigationFrame.Navigate(typeof(OobeColorPicker)); break; - case "CropAndLock": NavigationFrame.Navigate(typeof(OobeCropAndLock)); break; - case "EnvironmentVariables": NavigationFrame.Navigate(typeof(OobeEnvironmentVariables)); break; - case "FancyZones": NavigationFrame.Navigate(typeof(OobeFancyZones)); break; - case "FileLocksmith": NavigationFrame.Navigate(typeof(OobeFileLocksmith)); break; - case "Run": NavigationFrame.Navigate(typeof(OobeRun)); break; - case "ImageResizer": NavigationFrame.Navigate(typeof(OobeImageResizer)); break; - case "KBM": NavigationFrame.Navigate(typeof(OobeKBM)); break; - case "PowerRename": NavigationFrame.Navigate(typeof(OobePowerRename)); break; - case "QuickAccent": NavigationFrame.Navigate(typeof(OobePowerAccent)); break; - case "FileExplorer": NavigationFrame.Navigate(typeof(OobeFileExplorer)); break; - case "ShortcutGuide": NavigationFrame.Navigate(typeof(OobeShortcutGuide)); break; - case "TextExtractor": NavigationFrame.Navigate(typeof(OobePowerOCR)); break; - case "MouseUtils": NavigationFrame.Navigate(typeof(OobeMouseUtils)); break; - case "MouseWithoutBorders": NavigationFrame.Navigate(typeof(OobeMouseWithoutBorders)); break; - case "MeasureTool": NavigationFrame.Navigate(typeof(OobeMeasureTool)); break; - case "Hosts": NavigationFrame.Navigate(typeof(OobeHosts)); break; - case "RegistryPreview": NavigationFrame.Navigate(typeof(OobeRegistryPreview)); break; - case "Peek": NavigationFrame.Navigate(typeof(OobePeek)); break; - case "NewPlus": NavigationFrame.Navigate(typeof(OobeNewPlus)); break; - case "Workspaces": NavigationFrame.Navigate(typeof(OobeWorkspaces)); break; - case "ZoomIt": NavigationFrame.Navigate(typeof(OobeZoomIt)); break; - } - } - } - - private void SetTitleBar() - { - var u = App.GetOobeWindow(); - if (u != null) - { - // A custom title bar is required for full window theme and Mica support. - // https://docs.microsoft.com/windows/apps/develop/title-bar?tabs=winui3#full-customization - u.ExtendsContentIntoTitleBar = true; - WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(u)); - u.SetTitleBar(AppTitleBar); - } - } - - private void ShellPage_Loaded(object sender, RoutedEventArgs e) - { - SetTitleBar(); - } - - private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args) - { - if (args.DisplayMode == NavigationViewDisplayMode.Compact || args.DisplayMode == NavigationViewDisplayMode.Minimal) - { - PaneToggleBtn.Visibility = Visibility.Visible; - AppTitleBar.Margin = new Thickness(48, 0, 0, 0); - AppTitleBarText.Margin = new Thickness(12, 0, 0, 0); - } - else - { - PaneToggleBtn.Visibility = Visibility.Collapsed; - AppTitleBar.Margin = new Thickness(16, 0, 0, 0); - AppTitleBarText.Margin = new Thickness(16, 0, 0, 0); - } - } - - private void PaneToggleBtn_Click(object sender, RoutedEventArgs e) - { - navigationView.IsPaneOpen = !navigationView.IsPaneOpen; - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShortcutGuide.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShortcutGuide.xaml index 7b73486617..36465cac9b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShortcutGuide.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShortcutGuide.xaml @@ -16,6 +16,7 @@ <StackPanel Orientation="Horizontal" Spacing="8"> <Button + x:Name="LaunchButton" x:Uid="Launch_ShortcutGuide" Click="Start_ShortcutGuide_Click" Style="{StaticResource AccentButtonStyle}" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShortcutGuide.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShortcutGuide.xaml.cs index 5702ddcb9f..f979ddb6a4 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShortcutGuide.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShortcutGuide.xaml.cs @@ -6,8 +6,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; - using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.OOBE.Enums; using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.Views; @@ -16,9 +16,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeShortcutGuide : Page { public OobePowerToysModule ViewModel { get; set; } @@ -26,7 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeShortcutGuide() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.ShortcutGuide]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.ShortcutGuide); DataContext = ViewModel; } @@ -45,9 +42,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(ShortcutGuidePage)); + OobeWindow.OpenMainWindowCallback(typeof(ShortcutGuidePage)); } ViewModel.LogOpeningSettingsEvent(); @@ -56,7 +53,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - var settingsProperties = SettingsRepository<ShortcutGuideSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties; + var settingsProperties = SettingsRepository<ShortcutGuideSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties; if ((bool)settingsProperties.UseLegacyPressWinKeyBehavior.Value) { @@ -66,6 +63,10 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { HotkeyControl.Keys = settingsProperties.OpenShortcutGuide.GetKeysList(); } + + // Disable the Launch button if the module is disabled + var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig; + LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.ShortcutGuide); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml deleted file mode 100644 index 8302ebee3a..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml +++ /dev/null @@ -1,149 +0,0 @@ -<Page - x:Class="Microsoft.PowerToys.Settings.UI.OOBE.Views.OobeWhatsNew" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" - xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" - xmlns:ui="using:CommunityToolkit.WinUI" - Loaded="Page_Loaded" - mc:Ignorable="d"> - - - <Grid Margin="0,24,0,0"> - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="*" /> - </Grid.RowDefinitions> - - <tkcontrols:SettingsCard - x:Name="WhatsNewDataDiagnosticsInfoBar" - x:Uid="Oobe_WhatsNew_DataDiagnostics_InfoBar" - Margin="0,-24,0,0" - Background="{ThemeResource InfoBarInformationalSeverityBackgroundBrush}" - IsTabStop="{x:Bind ShowDataDiagnosticsInfoBar, Mode=OneWay}" - Visibility="{x:Bind ShowDataDiagnosticsInfoBar, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> - <tkcontrols:SettingsCard.HeaderIcon> - <FontIcon Foreground="{ThemeResource InfoBarInformationalSeverityIconBackground}" Glyph="" /> - </tkcontrols:SettingsCard.HeaderIcon> - <tkcontrols:SettingsCard.Description> - <StackPanel> - <TextBlock x:Name="WhatsNewDataDiagnosticsInfoBarDescText"> - <Hyperlink NavigateUri="https://aka.ms/powertoys-data-and-privacy-documentation"> - <Run x:Uid="Oobe_WhatsNew_DataDiagnostics_InfoBar_Desc" /> - </Hyperlink> - </TextBlock> - <TextBlock x:Name="WhatsNewDataDiagnosticsInfoBarDescTextYesClicked" Visibility="Collapsed"> - <Run x:Uid="Oobe_WhatsNew_DataDiagnostics_Yes_Click_InfoBar_Desc" /> - <Hyperlink Click="DataDiagnostics_OpenSettings_Click"> - <Run x:Uid="Oobe_WhatsNew_DataDiagnostics_Yes_Click_OpenSettings_Text" /> - </Hyperlink> - </TextBlock> - </StackPanel> - </tkcontrols:SettingsCard.Description> - <StackPanel Orientation="Horizontal" Spacing="8"> - <Button - x:Name="DataDiagnosticsButtonYes" - x:Uid="Oobe_WhatsNew_DataDiagnostics_Button_Yes" - Click="DataDiagnostics_InfoBar_YesNo_Click" - CommandParameter="Yes" /> - <HyperlinkButton - x:Name="DataDiagnosticsButtonNo" - x:Uid="Oobe_WhatsNew_DataDiagnostics_Button_No" - Click="DataDiagnostics_InfoBar_YesNo_Click" - CommandParameter="No" /> - <Button - Margin="16,0,0,0" - Click="DataDiagnostics_InfoBar_Close_Click" - Content="{ui:FontIcon Glyph=, - FontSize=16}" - Style="{StaticResource SubtleButtonStyle}" /> - </StackPanel> - </tkcontrols:SettingsCard> - - <StackPanel - Grid.Row="1" - Margin="32,16,0,16" - VerticalAlignment="Top" - Orientation="Vertical"> - <TextBlock - x:Uid="Oobe_WhatsNew" - AutomationProperties.HeadingLevel="Level1" - Style="{StaticResource TitleTextBlockStyle}" /> - <HyperlinkButton NavigateUri="https://github.com/microsoft/PowerToys/releases" Style="{StaticResource TextButtonStyle}"> - <TextBlock x:Uid="Oobe_WhatsNew_DetailedReleaseNotesLink" TextWrapping="Wrap" /> - </HyperlinkButton> - </StackPanel> - - <InfoBar - x:Name="ErrorInfoBar" - x:Uid="Oobe_WhatsNew_LoadingError" - Grid.Row="2" - VerticalAlignment="Top" - IsClosable="False" - IsTabStop="False" - Severity="Error"> - <InfoBar.ActionButton> - <Button - x:Uid="RetryBtn" - HorizontalAlignment="Right" - Click="LoadReleaseNotes_Click"> - <StackPanel Orientation="Horizontal" Spacing="8"> - <FontIcon FontSize="16" Glyph="" /> - <TextBlock x:Uid="RetryLabel" /> - </StackPanel> - </Button> - </InfoBar.ActionButton> - </InfoBar> - <InfoBar - x:Name="ProxyWarningInfoBar" - x:Uid="Oobe_WhatsNew_ProxyAuthenticationWarning" - Grid.Row="2" - VerticalAlignment="Top" - IsClosable="False" - IsTabStop="False" - Severity="Warning"> - <InfoBar.ActionButton> - <Button - x:Uid="RetryBtn" - HorizontalAlignment="Right" - Click="LoadReleaseNotes_Click"> - <StackPanel Orientation="Horizontal" Spacing="8"> - <FontIcon FontSize="16" Glyph="" /> - <TextBlock x:Uid="RetryLabel" /> - </StackPanel> - </Button> - </InfoBar.ActionButton> - </InfoBar> - - <ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto"> - <Grid Margin="32,24,32,24"> - <ProgressRing - x:Name="LoadingProgressRing" - HorizontalAlignment="Center" - VerticalAlignment="Center" - IsIndeterminate="True" - Visibility="Visible" /> - <tk7controls:MarkdownTextBlock - x:Name="ReleaseNotesMarkdown" - VerticalAlignment="Top" - Background="Transparent" - Header1FontSize="20" - Header1Margin="0,16,0,0" - Header2FontSize="17" - Header2FontWeight="SemiBold" - Header4FontSize="14" - Header4FontWeight="SemiBold" - HorizontalRuleMargin="24" - LinkClicked="ReleaseNotesMarkdown_LinkClicked" - ParagraphMargin="0,0,0,0" - TableMargin="24" - Visibility="Collapsed" /> - </Grid> - </ScrollViewer> - </Grid> -</Page> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs deleted file mode 100644 index 4fe034d794..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs +++ /dev/null @@ -1,265 +0,0 @@ -// 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.Diagnostics; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading.Tasks; - -using CommunityToolkit.WinUI.UI.Controls; -using global::PowerToys.GPOWrapper; -using ManagedCommon; -using Microsoft.PowerToys.Settings.UI.Helpers; -using Microsoft.PowerToys.Settings.UI.OOBE.Enums; -using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; -using Microsoft.PowerToys.Settings.UI.SerializationContext; -using Microsoft.PowerToys.Settings.UI.Views; -using Microsoft.PowerToys.Telemetry; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Navigation; - -namespace Microsoft.PowerToys.Settings.UI.OOBE.Views -{ - public sealed partial class OobeWhatsNew : Page - { - public OobePowerToysModule ViewModel { get; set; } - - public bool ShowDataDiagnosticsInfoBar => GetShowDataDiagnosticsInfoBar(); - - /// <summary> - /// Initializes a new instance of the <see cref="OobeWhatsNew"/> class. - /// </summary> - public OobeWhatsNew() - { - this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.WhatsNew]); - DataContext = ViewModel; - } - - private bool GetShowDataDiagnosticsInfoBar() - { - var isDataDiagnosticsGpoDisallowed = GPOWrapper.GetAllowDataDiagnosticsValue() == GpoRuleConfigured.Disabled; - - if (isDataDiagnosticsGpoDisallowed) - { - return false; - } - - bool userActed = DataDiagnosticsSettings.GetUserActionValue(); - - if (userActed) - { - return false; - } - - bool registryValue = DataDiagnosticsSettings.GetEnabledValue(); - - bool isFirstRunAfterUpdate = (App.Current as Microsoft.PowerToys.Settings.UI.App).ShowScoobe; - if (isFirstRunAfterUpdate && registryValue == false) - { - return true; - } - - return false; - } - - /// <summary> - /// Regex to remove installer hash sections from the release notes. - /// </summary> - private const string RemoveInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+## Highlights"; - private const string RemoveHotFixInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+$"; - private const RegexOptions RemoveInstallerHashesRegexOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; - private bool _loadingReleaseNotes; - - private static async Task<string> GetReleaseNotesMarkdown() - { - string releaseNotesJSON = string.Empty; - - // Let's use system proxy - using var proxyClientHandler = new HttpClientHandler - { - DefaultProxyCredentials = CredentialCache.DefaultCredentials, - Proxy = WebRequest.GetSystemWebProxy(), - PreAuthenticate = true, - }; - - using var getReleaseInfoClient = new HttpClient(proxyClientHandler); - - // GitHub APIs require sending an user agent - // https://docs.github.com/rest/overview/resources-in-the-rest-api#user-agent-required - getReleaseInfoClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "PowerToys"); - releaseNotesJSON = await getReleaseInfoClient.GetStringAsync("https://api.github.com/repos/microsoft/PowerToys/releases"); - IList<PowerToysReleaseInfo> releases = JsonSerializer.Deserialize<IList<PowerToysReleaseInfo>>(releaseNotesJSON, SourceGenerationContextContext.Default.IListPowerToysReleaseInfo); - - // Get the latest releases - var latestReleases = releases.OrderByDescending(release => release.PublishedDate).Take(5); - - StringBuilder releaseNotesHtmlBuilder = new StringBuilder(string.Empty); - - // Regex to remove installer hash sections from the release notes. - Regex removeHashRegex = new Regex(RemoveInstallerHashesRegex, RemoveInstallerHashesRegexOptions); - - // Regex to remove installer hash sections from the release notes, since there'll be no Highlights section for hotfix releases. - Regex removeHotfixHashRegex = new Regex(RemoveHotFixInstallerHashesRegex, RemoveInstallerHashesRegexOptions); - int counter = 0; - foreach (var release in latestReleases) - { - releaseNotesHtmlBuilder.AppendLine("# " + release.Name); - var notes = removeHashRegex.Replace(release.ReleaseNotes, "\r\n## Highlights"); - - // Add a unique counter to [github-current-release-work] to distinguish each release, - // since this variable is used for all latest releases when they are merged. - notes = notes.Replace("[github-current-release-work]", $"[github-current-release-work{++counter}]"); - notes = removeHotfixHashRegex.Replace(notes, string.Empty); - releaseNotesHtmlBuilder.AppendLine(notes); - releaseNotesHtmlBuilder.AppendLine(" "); - } - - return releaseNotesHtmlBuilder.ToString(); - } - - private async Task Reload() - { - if (_loadingReleaseNotes) - { - return; - } - - try - { - _loadingReleaseNotes = true; - ReleaseNotesMarkdown.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - LoadingProgressRing.Visibility = Microsoft.UI.Xaml.Visibility.Visible; - string releaseNotesMarkdown = await GetReleaseNotesMarkdown(); - ProxyWarningInfoBar.IsOpen = false; - ErrorInfoBar.IsOpen = false; - - ReleaseNotesMarkdown.Text = releaseNotesMarkdown; - ReleaseNotesMarkdown.Visibility = Microsoft.UI.Xaml.Visibility.Visible; - LoadingProgressRing.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - } - catch (HttpRequestException httpEx) - { - Logger.LogError("Exception when loading the release notes", httpEx); - if (httpEx.Message.Contains("407", StringComparison.CurrentCulture)) - { - ProxyWarningInfoBar.IsOpen = true; - } - else - { - ErrorInfoBar.IsOpen = true; - } - } - catch (Exception ex) - { - Logger.LogError("Exception when loading the release notes", ex); - ErrorInfoBar.IsOpen = true; - } - finally - { - LoadingProgressRing.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - _loadingReleaseNotes = false; - } - } - - private async void Page_Loaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - await Reload(); - } - - /// <inheritdoc/> - protected override void OnNavigatedTo(NavigationEventArgs e) - { - ViewModel.LogOpeningModuleEvent(); - } - - /// <inheritdoc/> - protected override void OnNavigatedFrom(NavigationEventArgs e) - { - ViewModel.LogClosingModuleEvent(); - } - - private void ReleaseNotesMarkdown_LinkClicked(object sender, LinkClickedEventArgs e) - { - if (Uri.TryCreate(e.Link, UriKind.Absolute, out Uri link)) - { - this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => - { - Process.Start(new ProcessStartInfo(link.ToString()) { UseShellExecute = true }); - }); - } - } - - private void DataDiagnostics_InfoBar_YesNo_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - string commandArg = string.Empty; - if (sender is Button senderBtn) - { - commandArg = senderBtn.CommandParameter.ToString(); - } - else if (sender is HyperlinkButton senderLink) - { - commandArg = senderLink.CommandParameter.ToString(); - } - - if (string.IsNullOrEmpty(commandArg)) - { - return; - } - - // Update UI - if (commandArg == "Yes") - { - WhatsNewDataDiagnosticsInfoBar.Header = ResourceLoaderInstance.ResourceLoader.GetString("Oobe_WhatsNew_DataDiagnostics_Yes_Click_InfoBar_Title"); - } - else - { - WhatsNewDataDiagnosticsInfoBar.Header = ResourceLoaderInstance.ResourceLoader.GetString("Oobe_WhatsNew_DataDiagnostics_No_Click_InfoBar_Title"); - } - - WhatsNewDataDiagnosticsInfoBarDescText.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - WhatsNewDataDiagnosticsInfoBarDescTextYesClicked.Visibility = Microsoft.UI.Xaml.Visibility.Visible; - DataDiagnosticsButtonYes.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - DataDiagnosticsButtonNo.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - - // Set Data Diagnostics registry values - if (commandArg == "Yes") - { - DataDiagnosticsSettings.SetEnabledValue(true); - } - else - { - DataDiagnosticsSettings.SetEnabledValue(false); - } - - DataDiagnosticsSettings.SetUserActionValue(true); - - this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => - { - ShellPage.ShellHandler?.SignalGeneralDataUpdate(); - }); - } - - private void DataDiagnostics_InfoBar_Close_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - WhatsNewDataDiagnosticsInfoBar.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - } - - private void DataDiagnostics_OpenSettings_Click(Microsoft.UI.Xaml.Documents.Hyperlink sender, Microsoft.UI.Xaml.Documents.HyperlinkClickEventArgs args) - { - Common.UI.SettingsDeepLink.OpenSettings(Common.UI.SettingsDeepLink.SettingsWindow.Overview, true); - } - - private async void LoadReleaseNotes_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - await Reload(); - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWorkspaces.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWorkspaces.xaml index 7f9cc2e9aa..95f43e0dd5 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWorkspaces.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWorkspaces.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_Workspaces" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/Workspaces.png"> @@ -13,12 +13,12 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_Workspaces_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_Workspaces_HowToUse" /> <controls:ShortcutWithTextLabelControl x:Name="HotkeyControl" x:Uid="Oobe_Workspaces_HowToUse_Shortcut" /> <TextBlock x:Uid="Oobe_TipsAndTricks" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_Workspaces_TipsAndTricks" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_Workspaces_TipsAndTricks" /> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWorkspaces.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWorkspaces.xaml.cs index 772807f735..993adbc0fc 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWorkspaces.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWorkspaces.xaml.cs @@ -11,9 +11,6 @@ using Microsoft.UI.Xaml.Navigation; namespace Microsoft.PowerToys.Settings.UI.OOBE.Views { - /// <summary> - /// An empty page that can be used on its own or navigated to within a Frame. - /// </summary> public sealed partial class OobeWorkspaces : Page { public OobePowerToysModule ViewModel { get; set; } @@ -21,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeWorkspaces() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.Workspaces]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.Workspaces); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(WorkspacesPage)); + OobeWindow.OpenMainWindowCallback(typeof(WorkspacesPage)); } ViewModel.LogOpeningSettingsEvent(); @@ -38,7 +35,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views protected override void OnNavigatedTo(NavigationEventArgs e) { ViewModel.LogOpeningModuleEvent(); - HotkeyControl.Keys = SettingsRepository<WorkspacesSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.Hotkey.Value.GetKeysList(); + HotkeyControl.Keys = SettingsRepository<WorkspacesSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.Hotkey.Value.GetKeysList(); } protected override void OnNavigatedFrom(NavigationEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeZoomIt.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeZoomIt.xaml index 422b256a11..33c1c260c6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeZoomIt.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeZoomIt.xaml @@ -5,7 +5,7 @@ xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:tk7controls="using:CommunityToolkit.WinUI.UI.Controls" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" mc:Ignorable="d"> <controls:OOBEPageControl x:Uid="Oobe_ZoomIt" HeroImage="ms-appx:///Assets/Settings/Modules/OOBE/ZoomIt.gif"> @@ -13,7 +13,7 @@ <StackPanel Orientation="Vertical" Spacing="12"> <TextBlock x:Uid="Oobe_HowToUse" Style="{ThemeResource OobeSubtitleStyle}" /> - <tk7controls:MarkdownTextBlock x:Uid="Oobe_ZoomIt_HowToUse" Background="Transparent" /> + <tkcontrols:MarkdownTextBlock x:Uid="Oobe_ZoomIt_HowToUse" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="OOBE_Settings" Click="SettingsLaunchButton_Click" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeZoomIt.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeZoomIt.xaml.cs index 6db561bfbc..2812d9212f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeZoomIt.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeZoomIt.xaml.cs @@ -18,15 +18,15 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views public OobeZoomIt() { this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.ZoomIt]); + ViewModel = App.OobeShellViewModel.GetModule(PowerToysModules.ZoomIt); DataContext = ViewModel; } private void SettingsLaunchButton_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - if (OobeShellPage.OpenMainWindowCallback != null) + if (OobeWindow.OpenMainWindowCallback != null) { - OobeShellPage.OpenMainWindowCallback(typeof(ZoomItPage)); + OobeWindow.OpenMainWindowCallback(typeof(ZoomItPage)); } ViewModel.LogOpeningSettingsEvent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs new file mode 100644 index 0000000000..e384ea8a13 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; + +using Microsoft.PowerToys.Settings.UI.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.OOBE.Views +{ + /// <summary> + /// View model for a group of releases (grouped by major.minor version). + /// </summary> + public class ScoobeReleaseGroupViewModel + { + /// <summary> + /// Gets the list of releases in this group. + /// </summary> + public IList<PowerToysReleaseInfo> Releases { get; } + + /// <summary> + /// Gets the version text to display (e.g., "0.96.0"). + /// </summary> + public string VersionText { get; } + + /// <summary> + /// Gets the date text to display (e.g., "December 2025"). + /// </summary> + public string DateText { get; } + + public ScoobeReleaseGroupViewModel(IList<PowerToysReleaseInfo> releases) + { + Releases = releases ?? throw new ArgumentNullException(nameof(releases)); + + if (releases.Count > 0) + { + var latestRelease = releases[0]; + VersionText = GetVersionFromRelease(latestRelease); + DateText = latestRelease.PublishedDate.ToString("MMMM yyyy", CultureInfo.CurrentCulture); + } + else + { + VersionText = "Unknown"; + DateText = string.Empty; + } + } + + internal static string GetVersionFromRelease(PowerToysReleaseInfo release) + { + // TagName is typically like "v0.96.0", Name might be "Release v0.96.0" + string version = release.TagName ?? release.Name ?? "Unknown"; + if (version.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + version = version.Substring(1); + } + + return version; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml new file mode 100644 index 0000000000..b5e0abb4f6 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml @@ -0,0 +1,65 @@ +<Page + x:Class="Microsoft.PowerToys.Settings.UI.OOBE.Views.ScoobeReleaseNotesPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + Loaded="Page_Loaded" + mc:Ignorable="d"> + <Page.Resources> + <tkcontrols:MarkdownThemes + x:Key="ReleaseNotesMarkdownThemeConfig" + BoldFontWeight="SemiBold" + H1FontSize="28" + H1FontWeight="SemiBold" + H1Margin="0, 36, 0, 8" + H2FontSize="20" + H2FontWeight="SemiBold" + H2Margin="0, 16, 0, 4" + H3FontSize="16" + H3FontWeight="SemiBold" + H3Margin="0, 16, 0, 4" + HorizontalRuleBrush="{StaticResource DividerStrokeColorDefaultBrush}" + HorizontalRuleThickness="1" + ImageStretch="Uniform" + ListBulletSpacing="1" + ListGutterWidth="10" /> + <tkcontrols:MarkdownConfig x:Key="ReleaseNotesMarkdownConfig" Themes="{StaticResource ReleaseNotesMarkdownThemeConfig}" /> + </Page.Resources> + + <!-- Main layout container --> + <Grid> + <ScrollViewer HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> + <Grid MaxWidth="960"> + <Grid Margin="0,0,0,24"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <Image + x:Name="HeroImageHolder" + Height="186" + HorizontalAlignment="Left" + Stretch="UniformToFill" /> + <Grid Grid.Row="1" Margin="24,16,24,24"> + <ProgressRing + x:Name="LoadingProgressRing" + HorizontalAlignment="Center" + VerticalAlignment="Center" + IsIndeterminate="True" + Visibility="Visible" /> + <tkcontrols:MarkdownTextBlock + x:Name="ReleaseNotesMarkdown" + Config="{StaticResource ReleaseNotesMarkdownConfig}" + UseAutoLinks="True" + UseEmphasisExtras="True" + UseListExtras="True" + UsePipeTables="True" + UseTaskLists="True" /> + </Grid> + </Grid> + </Grid> + </ScrollViewer> + </Grid> +</Page> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.cs new file mode 100644 index 0000000000..9371fcbaed --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.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.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using Microsoft.UI.Xaml.Navigation; + +namespace Microsoft.PowerToys.Settings.UI.OOBE.Views +{ + public sealed partial class ScoobeReleaseNotesPage : Page + { + private IList<PowerToysReleaseInfo> _currentReleases; + + /// <summary> + /// Initializes a new instance of the <see cref="ScoobeReleaseNotesPage"/> class. + /// </summary> + public ScoobeReleaseNotesPage() + { + this.InitializeComponent(); + } + + /// <summary> + /// Regex to remove installer hash sections from the release notes. + /// </summary> + private const string RemoveInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+## Highlights"; + private const string RemoveHotFixInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+$"; + private const RegexOptions RemoveInstallerHashesRegexOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + + /// <summary> + /// Regex to match markdown images with 'Hero' in the alt text. + /// Matches: ![...Hero...](url) + /// </summary> + private static readonly Regex HeroImageRegex = new Regex( + @"!\[([^\]]*Hero[^\]]*)\]\(([^)]+)\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// <summary> + /// Regex to match GitHub PR/Issue references (e.g., #41029). + /// Only matches # followed by digits that are not already part of a markdown link. + /// </summary> + private static readonly Regex GitHubPrReferenceRegex = new Regex( + @"(?<!\[)#(\d+)(?!\])", + RegexOptions.Compiled); + + private static readonly CompositeFormat GitHubPrLinkTemplate = CompositeFormat.Parse("[#{0}](https://github.com/microsoft/PowerToys/pull/{0})"); + private static readonly CompositeFormat GitHubReleaseLinkTemplate = CompositeFormat.Parse("https://github.com/microsoft/PowerToys/releases/tag/{0}"); + + private static (string Markdown, string HeroImageUrl) ProcessReleaseNotesMarkdown(IList<PowerToysReleaseInfo> releases) + { + if (releases == null || releases.Count == 0) + { + return (string.Empty, null); + } + + StringBuilder releaseNotesHtmlBuilder = new StringBuilder(string.Empty); + + // Regex to remove installer hash sections from the release notes. + Regex removeHashRegex = new Regex(RemoveInstallerHashesRegex, RemoveInstallerHashesRegexOptions); + + // Regex to remove installer hash sections from the release notes, since there'll be no Highlights section for hotfix releases. + Regex removeHotfixHashRegex = new Regex(RemoveHotFixInstallerHashesRegex, RemoveInstallerHashesRegexOptions); + + string lastHeroImageUrl = null; + + int counter = 0; + bool isFirst = true; + foreach (var release in releases) + { + // Add separator between releases + if (!isFirst) + { + releaseNotesHtmlBuilder.AppendLine("---"); + releaseNotesHtmlBuilder.AppendLine(); + } + + isFirst = false; + + var releaseUrl = string.Format(CultureInfo.InvariantCulture, GitHubReleaseLinkTemplate, release.TagName); + releaseNotesHtmlBuilder.AppendLine(CultureInfo.InvariantCulture, $"# {release.Name}"); + releaseNotesHtmlBuilder.AppendLine(CultureInfo.InvariantCulture, $"{release.PublishedDate.ToString("MMMM d, yyyy", CultureInfo.CurrentCulture)} [View on GitHub]({releaseUrl})"); + releaseNotesHtmlBuilder.AppendLine(); + releaseNotesHtmlBuilder.AppendLine(" "); + releaseNotesHtmlBuilder.AppendLine(); + var notes = removeHashRegex.Replace(release.ReleaseNotes, "\r\n## Highlights"); + notes = notes.Replace("[github-current-release-work]", $"[github-current-release-work{++counter}]"); + notes = removeHotfixHashRegex.Replace(notes, string.Empty); + + // Find all Hero images and keep track of the last one + var heroMatches = HeroImageRegex.Matches(notes); + foreach (Match match in heroMatches) + { + lastHeroImageUrl = match.Groups[2].Value; + } + + // Remove Hero images from the markdown + notes = HeroImageRegex.Replace(notes, string.Empty); + + // Convert GitHub PR/Issue references to hyperlinks + notes = GitHubPrReferenceRegex.Replace(notes, match => + string.Format(CultureInfo.InvariantCulture, GitHubPrLinkTemplate, match.Groups[1].Value)); + + releaseNotesHtmlBuilder.AppendLine(notes); + releaseNotesHtmlBuilder.AppendLine(" "); + } + + return (releaseNotesHtmlBuilder.ToString(), lastHeroImageUrl); + } + + private void DisplayReleaseNotes() + { + if (_currentReleases == null || _currentReleases.Count == 0) + { + ReleaseNotesMarkdown.Visibility = Visibility.Collapsed; + return; + } + + try + { + LoadingProgressRing.Visibility = Visibility.Collapsed; + + var (releaseNotesMarkdown, heroImageUrl) = ProcessReleaseNotesMarkdown(_currentReleases); + + // Set the Hero image if found + if (!string.IsNullOrEmpty(heroImageUrl)) + { + HeroImageHolder.Source = new BitmapImage(new Uri(heroImageUrl)); + HeroImageHolder.Visibility = Visibility.Visible; + } + else + { + HeroImageHolder.Visibility = Visibility.Collapsed; + } + + ReleaseNotesMarkdown.Text = releaseNotesMarkdown; + ReleaseNotesMarkdown.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + Logger.LogError("Exception when displaying the release notes", ex); + } + } + + private void Page_Loaded(object sender, RoutedEventArgs e) + { + DisplayReleaseNotes(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + if (e.Parameter is IList<PowerToysReleaseInfo> releases) + { + _currentReleases = releases; + } + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml index 4a5f4233de..93e197f66e 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml @@ -5,13 +5,183 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:local="using:Microsoft.PowerToys.Settings.UI.OOBE.Views" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:ui="using:CommunityToolkit.WinUI" xmlns:winuiex="using:WinUIEx" + Width="1100" + Height="700" MinWidth="480" MinHeight="480" + Activated="Window_Activated" Closed="Window_Closed" mc:Ignorable="d"> <Window.SystemBackdrop> <MicaBackdrop /> </Window.SystemBackdrop> - <local:OobeShellPage x:Name="shellPage" /> -</winuiex:WindowEx> + <Grid x:Name="RootGrid"> + <Grid.RowDefinitions> + <RowDefinition Height="48" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <TitleBar + x:Name="AppTitleBar" + x:Uid="OobeWindow_TitleTxt" + IsBackButtonVisible="False" + IsPaneToggleButtonVisible="False" + PaneToggleRequested="TitleBar_PaneButtonClick"> + <!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource --> + <TitleBar.LeftHeader> + <ImageIcon + x:Name="TitleBarIcon" + Height="16" + Margin="16,0,0,0" + Source="/Assets/Settings/icon.ico" /> + </TitleBar.LeftHeader> + </TitleBar> + <NavigationView + x:Name="navigationView" + Grid.Row="1" + CompactModeThresholdWidth="1007" + DisplayModeChanged="NavigationView_DisplayModeChanged" + ExpandedModeThresholdWidth="1007" + IsBackButtonVisible="Collapsed" + IsPaneOpen="True" + IsPaneToggleButtonVisible="False" + IsSettingsVisible="False" + Loaded="NavigationView_Loaded" + OpenPaneLength="296" + SelectionChanged="NavigationView_SelectionChanged"> + <NavigationView.MenuItems> + <NavigationViewItem + x:Uid="Shell_General" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerToys.png}" + Tag="Overview" /> + <NavigationViewItem + x:Uid="Shell_AdvancedPaste" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AdvancedPaste.png}" + Tag="AdvancedPaste" /> + <NavigationViewItem + x:Uid="Shell_AlwaysOnTop" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AlwaysOnTop.png}" + Tag="AlwaysOnTop" /> + <NavigationViewItem + x:Uid="Shell_Awake" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Awake.png}" + Tag="Awake" /> + <NavigationViewItem + x:Uid="Shell_ColorPicker" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ColorPicker.png}" + Tag="ColorPicker" /> + <NavigationViewItem + x:Uid="Shell_CmdPal" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CmdPal.png}" + Tag="CmdPal" /> + <NavigationViewItem + x:Uid="Shell_CmdNotFound" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CommandNotFound.png}" + Tag="CmdNotFound" /> + <NavigationViewItem + x:Uid="Shell_CropAndLock" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CropAndLock.png}" + Tag="CropAndLock" /> + <NavigationViewItem + x:Uid="Shell_EnvironmentVariables" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/EnvironmentVariables.png}" + Tag="EnvironmentVariables" /> + <NavigationViewItem + x:Uid="Shell_FancyZones" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FancyZones.png}" + Tag="FancyZones" /> + <NavigationViewItem + x:Uid="Shell_FileLocksmith" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FileLocksmith.png}" + Tag="FileLocksmith" /> + <NavigationViewItem + x:Uid="Shell_PowerPreview" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FileExplorerPreview.png}" + Tag="FileExplorer" /> + <NavigationViewItem + x:Uid="Shell_Hosts" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Hosts.png}" + Tag="Hosts" /> + <NavigationViewItem + x:Uid="Shell_ImageResizer" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ImageResizer.png}" + Tag="ImageResizer" /> + <NavigationViewItem + x:Uid="Shell_KeyboardManager" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/KeyboardManager.png}" + Tag="KBM" /> + <NavigationViewItem + x:Uid="Shell_LightSwitch" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/LightSwitch.png}" + Tag="LightSwitch" /> + <NavigationViewItem + x:Uid="Shell_MouseUtilities" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseUtils.png}" + Tag="MouseUtils" /> + <NavigationViewItem + x:Uid="Shell_MouseWithoutBorders" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseWithoutBorders.png}" + Tag="MouseWithoutBorders" /> + <NavigationViewItem + x:Uid="NewPlus_Product_Name" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/NewPlus.png}" + Tag="NewPlus" /> + <NavigationViewItem + x:Uid="Shell_Peek" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Peek.png}" + Tag="Peek" /> + <NavigationViewItem + x:Uid="Shell_PowerDisplay" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerDisplay.png}" + Tag="PowerDisplay" /> + <NavigationViewItem + x:Uid="Shell_PowerRename" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerRename.png}" + Tag="PowerRename" /> + <NavigationViewItem + x:Uid="Shell_PowerLauncher" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerToysRun.png}" + Tag="Run" /> + <NavigationViewItem + x:Uid="Shell_QuickAccent" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/QuickAccent.png}" + Tag="QuickAccent" /> + <NavigationViewItem + x:Uid="Shell_RegistryPreview" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/RegistryPreview.png}" + Tag="RegistryPreview" /> + <NavigationViewItem + x:Uid="Shell_MeasureTool" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ScreenRuler.png}" + Tag="MeasureTool" /> + <NavigationViewItem + x:Uid="Shell_ShortcutGuide" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ShortcutGuide.png}" + Tag="ShortcutGuide" /> + <NavigationViewItem + x:Uid="Shell_TextExtractor" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/TextExtractor.png}" + Tag="TextExtractor" /> + <NavigationViewItem + x:Uid="Shell_Workspaces" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Workspaces.png}" + Tag="Workspaces" /> + <NavigationViewItem + x:Uid="Shell_ZoomIt" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ZoomIt.png}" + Tag="ZoomIt" /> + </NavigationView.MenuItems> + <NavigationView.PaneFooter> + <NavigationViewItem + x:Uid="Shell_WhatsNew" + AutomationProperties.AutomationId="WhatIsNewNavItem" + Icon="{ui:FontIcon Glyph=}" + Tapped="WhatIsNewItem_Tapped" /> + </NavigationView.PaneFooter> + <NavigationView.Content> + <Frame x:Name="NavigationFrame" /> + </NavigationView.Content> + </NavigationView> + </Grid> +</winuiex:WindowEx> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml.cs index ac8c3168b7..cc98149397 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml.cs @@ -3,136 +3,157 @@ // See the LICENSE file in the project root for more information. using System; - +using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; -using Microsoft.PowerToys.Settings.UI.OOBE.Enums; +using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; using Microsoft.PowerToys.Settings.UI.OOBE.Views; -using Microsoft.UI; -using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; using PowerToys.Interop; -using Windows.Graphics; +using WinRT.Interop; using WinUIEx; -using WinUIEx.Messaging; namespace Microsoft.PowerToys.Settings.UI { - /// <summary> - /// An empty window that can be used on its own or navigated to within a Frame. - /// </summary> - public sealed partial class OobeWindow : WindowEx, IDisposable + public sealed partial class OobeWindow : WindowEx { - private PowerToysModules initialModule; + public OobeShellViewModel ViewModel => App.OobeShellViewModel; - private const int ExpectedWidth = 1100; - private const int ExpectedHeight = 700; - private const int DefaultDPI = 96; - private int _currentDPI; - private WindowId _windowId; - private IntPtr _hWnd; - private AppWindow _appWindow; - private WindowMessageMonitor _msgMonitor; - private bool disposedValue; + public static Func<string> RunSharedEventCallback { get; set; } - public OobeWindow(PowerToysModules initialModule) + public static void SetRunSharedEventCallback(Func<string> implementation) + { + RunSharedEventCallback = implementation; + } + + public static Func<string> ColorPickerSharedEventCallback { get; set; } + + public static void SetColorPickerSharedEventCallback(Func<string> implementation) + { + ColorPickerSharedEventCallback = implementation; + } + + public static Action<Type> OpenMainWindowCallback { get; set; } + + public static void SetOpenMainWindowCallback(Action<Type> implementation) + { + OpenMainWindowCallback = implementation; + } + + public OobeWindow() { App.ThemeService.ThemeChanged += OnThemeChanged; App.ThemeService.ApplyTheme(); this.InitializeComponent(); - // Set window icon - _hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - _windowId = Win32Interop.GetWindowIdFromWindow(_hWnd); - _appWindow = AppWindow.GetFromWindowId(_windowId); - _appWindow.SetIcon("Assets\\Settings\\icon.ico"); + SetTitleBar(); - OverlappedPresenter presenter = _appWindow.Presenter as OverlappedPresenter; - presenter.IsMinimizable = false; - presenter.IsMaximizable = false; + RootGrid.DataContext = ViewModel; - var dpi = NativeMethods.GetDpiForWindow(_hWnd); - _currentDPI = dpi; - float scalingFactor = (float)dpi / DefaultDPI; - int width = (int)(ExpectedWidth * scalingFactor); - int height = (int)(ExpectedHeight * scalingFactor); - - SizeInt32 size; - size.Width = width; - size.Height = height; - _appWindow.Resize(size); - - this.initialModule = initialModule; - - _msgMonitor = new WindowMessageMonitor(this); - _msgMonitor.WindowMessageReceived += (_, e) => - { - const int WM_NCLBUTTONDBLCLK = 0x00A3; - if (e.Message.MessageId == WM_NCLBUTTONDBLCLK) - { - // Disable double click on title bar to maximize window - e.Result = 0; - e.Handled = true; - } - }; - - this.SizeChanged += OobeWindow_SizeChanged; - - var loader = Helpers.ResourceLoaderInstance.ResourceLoader; - Title = loader.GetString("OobeWindow_Title"); - - if (shellPage != null) - { - shellPage.NavigateToModule(this.initialModule); - } - - OobeShellPage.SetRunSharedEventCallback(() => + SetRunSharedEventCallback(() => { return Constants.PowerLauncherSharedEvent(); }); - OobeShellPage.SetColorPickerSharedEventCallback(() => + SetColorPickerSharedEventCallback(() => { return Constants.ShowColorPickerSharedEvent(); }); - OobeShellPage.SetOpenMainWindowCallback((Type type) => + SetOpenMainWindowCallback((Type type) => { App.OpenSettingsWindow(type); }); } - public void SetAppWindow(PowerToysModules module) + private void SetTitleBar() { - if (shellPage != null) + WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(this)); + this.ExtendsContentIntoTitleBar = true; + this.SetTitleBar(AppTitleBar); + Title = ResourceLoaderInstance.ResourceLoader.GetString("OobeWindow_Title"); + } + + private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + if (navigationView.SelectedItem is NavigationViewItem selectedItem) { - shellPage.NavigateToModule(module); + switch (selectedItem.Tag) + { + case "Overview": NavigationFrame.Navigate(typeof(OobeOverview)); break; + case "AdvancedPaste": NavigationFrame.Navigate(typeof(OobeAdvancedPaste)); break; + case "AlwaysOnTop": NavigationFrame.Navigate(typeof(OobeAlwaysOnTop)); break; + case "Awake": NavigationFrame.Navigate(typeof(OobeAwake)); break; + case "CmdNotFound": NavigationFrame.Navigate(typeof(OobeCmdNotFound)); break; + case "CmdPal": NavigationFrame.Navigate(typeof(OobeCmdPal)); break; + case "ColorPicker": NavigationFrame.Navigate(typeof(OobeColorPicker)); break; + case "CropAndLock": NavigationFrame.Navigate(typeof(OobeCropAndLock)); break; + case "EnvironmentVariables": NavigationFrame.Navigate(typeof(OobeEnvironmentVariables)); break; + case "FancyZones": NavigationFrame.Navigate(typeof(OobeFancyZones)); break; + case "FileLocksmith": NavigationFrame.Navigate(typeof(OobeFileLocksmith)); break; + case "Run": NavigationFrame.Navigate(typeof(OobeRun)); break; + case "ImageResizer": NavigationFrame.Navigate(typeof(OobeImageResizer)); break; + case "KBM": NavigationFrame.Navigate(typeof(OobeKBM)); break; + case "LightSwitch": NavigationFrame.Navigate(typeof(OobeLightSwitch)); break; + case "PowerRename": NavigationFrame.Navigate(typeof(OobePowerRename)); break; + case "PowerDisplay": NavigationFrame.Navigate(typeof(OobePowerDisplay)); break; + case "QuickAccent": NavigationFrame.Navigate(typeof(OobePowerAccent)); break; + case "FileExplorer": NavigationFrame.Navigate(typeof(OobeFileExplorer)); break; + case "ShortcutGuide": NavigationFrame.Navigate(typeof(OobeShortcutGuide)); break; + case "TextExtractor": NavigationFrame.Navigate(typeof(OobePowerOCR)); break; + case "MouseUtils": NavigationFrame.Navigate(typeof(OobeMouseUtils)); break; + case "MouseWithoutBorders": NavigationFrame.Navigate(typeof(OobeMouseWithoutBorders)); break; + case "MeasureTool": NavigationFrame.Navigate(typeof(OobeMeasureTool)); break; + case "Hosts": NavigationFrame.Navigate(typeof(OobeHosts)); break; + case "RegistryPreview": NavigationFrame.Navigate(typeof(OobeRegistryPreview)); break; + case "Peek": NavigationFrame.Navigate(typeof(OobePeek)); break; + case "NewPlus": NavigationFrame.Navigate(typeof(OobeNewPlus)); break; + case "Workspaces": NavigationFrame.Navigate(typeof(OobeWorkspaces)); break; + case "ZoomIt": NavigationFrame.Navigate(typeof(OobeZoomIt)); break; + } } } - private void OobeWindow_SizeChanged(object sender, WindowSizeChangedEventArgs args) + private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args) { - var dpi = NativeMethods.GetDpiForWindow(_hWnd); - if (_currentDPI != dpi) + if (args.DisplayMode == NavigationViewDisplayMode.Compact || args.DisplayMode == NavigationViewDisplayMode.Minimal) { - // Reacting to a DPI change. Should not cause a resize -> sizeChanged loop. - _currentDPI = dpi; - float scalingFactor = (float)dpi / DefaultDPI; - int width = (int)(ExpectedWidth * scalingFactor); - int height = (int)(ExpectedHeight * scalingFactor); - SizeInt32 size; - size.Width = width; - size.Height = height; - _appWindow.Resize(size); + TitleBarIcon.Margin = new Thickness(0, 0, 8, 0); // Workaround, see XAML comment + AppTitleBar.IsPaneToggleButtonVisible = true; } + else + { + TitleBarIcon.Margin = new Thickness(16, 0, 0, 0); // Workaround, see XAML comment + AppTitleBar.IsPaneToggleButtonVisible = false; + } + } + + private void TitleBar_PaneButtonClick(Microsoft.UI.Xaml.Controls.TitleBar sender, object args) + { + navigationView.IsPaneOpen = !navigationView.IsPaneOpen; + } + + private void WhatIsNewItem_Tapped(object sender, TappedRoutedEventArgs e) + { + ((App)App.Current)!.OpenScoobe(); + } + + private void Window_Activated(object sender, WindowActivatedEventArgs args) + { + // Set window icon + this.SetIcon("Assets\\Settings\\icon.ico"); } private void Window_Closed(object sender, WindowEventArgs args) { - App.ClearOobeWindow(); + if (navigationView.SelectedItem is NavigationViewItem selectedItem && selectedItem.Tag is string tag) + { + App.OobeShellViewModel.GetModuleFromTag(tag).LogClosingModuleEvent(); + } - var mainWindow = App.GetSettingsWindow(); - if (mainWindow != null) + if (App.GetSettingsWindow() is MainWindow mainWindow) { mainWindow.CloseHiddenWindow(); } @@ -145,21 +166,13 @@ namespace Microsoft.PowerToys.Settings.UI WindowHelper.SetTheme(this, theme); } - private void Dispose(bool disposing) + private void NavigationView_Loaded(object sender, RoutedEventArgs e) { - if (!disposedValue) + // Select the first module by default + if (navigationView.MenuItems.Count > 0) { - _msgMonitor?.Dispose(); - - disposedValue = true; + navigationView.SelectedItem = navigationView.MenuItems[0]; } } - - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); - } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml index 22d996efb2..a0725c0149 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Panels/MouseJumpPanel.xaml @@ -21,27 +21,21 @@ </ResourceDictionary> </UserControl.Resources> - <controls:SettingsGroup x:Uid="MouseUtils_MouseJump"> - - <tkcontrols:SettingsCard - x:Uid="MouseUtils_Enable_MouseJump" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseJump.png}" - IsEnabled="{x:Bind ViewModel.IsJumpEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsJumpEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsJumpEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:SettingsGroup x:Uid="MouseUtils_MouseJump" AutomationProperties.AutomationId="MouseUtils_MouseJumpTestId"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsJumpEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + x:Name="MouseUtilsEnableMouseJump" + x:Uid="MouseUtils_Enable_MouseJump" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseJump.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.AutomationId="MouseUtils_MouseJumpToggleId" + IsOn="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <tkcontrols:SettingsCard + x:Name="MouseUtilsMouseJumpActivationShortcut" x:Uid="MouseUtils_MouseJump_ActivationShortcut" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}"> @@ -54,39 +48,13 @@ HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}"> <tkcontrols:SettingsCard.Description> - <StackPanel - Grid.Row="1" - Grid.Column="1" - Margin="0,4,0,0" - Orientation="Horizontal"> - <TextBlock - x:Uid="MouseUtils_MouseJump_ThumbnailSize_Description_Prefix" - Margin="0,0,4,0" - Style="{ThemeResource SecondaryTextStyle}" /> - <TextBlock - Margin="0,0,4,0" - FontWeight="SemiBold" - Style="{ThemeResource SecondaryTextStyle}" - Text="{x:Bind ViewModel.MouseJumpThumbnailSize.Width, Mode=OneWay}" /> - <TextBlock - Margin="0,5,4,0" - AutomationProperties.AccessibilityView="Raw" - FontFamily="{ThemeResource SymbolThemeFontFamily}" - FontSize="10" - Foreground="{ThemeResource SystemBaseMediumColor}" - Style="{ThemeResource SecondaryTextStyle}" - Text="" /> - <TextBlock - Margin="0,0,4,0" - FontWeight="SemiBold" - Style="{ThemeResource SecondaryTextStyle}" - Text="{x:Bind ViewModel.MouseJumpThumbnailSize.Height, Mode=OneWay}" /> - <TextBlock - x:Uid="MouseUtils_MouseJump_ThumbnailSize_Description_Suffix" - Margin="0,0,4,0" - Foreground="{ThemeResource SystemBaseMediumColor}" - Style="{ThemeResource SecondaryTextStyle}" /> - </StackPanel> + <TextBlock> + <Run x:Uid="MouseUtils_MouseJump_ThumbnailSize_Description_Prefix" /> + <Run FontWeight="SemiBold" Text="{x:Bind ViewModel.MouseJumpThumbnailSize.Width, Mode=OneWay}" /> + <Run Text="x" /> + <Run FontWeight="SemiBold" Text="{x:Bind ViewModel.MouseJumpThumbnailSize.Height, Mode=OneWay}" /> + <Run x:Uid="MouseUtils_MouseJump_ThumbnailSize_Description_Suffix" /> + </TextBlock> </tkcontrols:SettingsCard.Description> <StackPanel Grid.Column="2" @@ -126,6 +94,7 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsExpander + x:Name="MouseUtilsMouseJumpAppearance" x:Uid="MouseUtils_MouseJump_Appearance" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.IsMouseJumpEnabled, Mode=OneWay}" diff --git a/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml new file mode 100644 index 0000000000..3e6bb53da5 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml @@ -0,0 +1,100 @@ +<winuiex:WindowEx + x:Class="Microsoft.PowerToys.Settings.UI.ScoobeWindow" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.OOBE.Views" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:winuiex="using:WinUIEx" + Width="1100" + Height="700" + MinWidth="480" + MinHeight="480" + Activated="Window_Activated" + Closed="Window_Closed" + mc:Ignorable="d"> + <Window.SystemBackdrop> + <MicaBackdrop /> + </Window.SystemBackdrop> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="48" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <TitleBar + x:Name="AppTitleBar" + x:Uid="ScoobeWindow_TitleTxt" + IsBackButtonVisible="False" + IsPaneToggleButtonVisible="False" + PaneToggleRequested="TitleBar_PaneButtonClick"> + <!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource --> + <TitleBar.LeftHeader> + <ImageIcon + x:Name="TitleBarIcon" + Height="16" + Margin="16,0,0,0" + Source="/Assets/Settings/icon.ico" /> + </TitleBar.LeftHeader> + </TitleBar> + <NavigationView + x:Name="navigationView" + Grid.Row="1" + CompactModeThresholdWidth="1007" + DisplayModeChanged="NavigationView_DisplayModeChanged" + ExpandedModeThresholdWidth="1007" + IsBackButtonVisible="Collapsed" + IsPaneOpen="True" + IsPaneToggleButtonVisible="False" + IsSettingsVisible="False" + Loaded="NavigationView_Loaded" + OpenPaneLength="186" + SelectionChanged="NavigationView_SelectionChanged"> + <NavigationView.MenuItemTemplate> + <DataTemplate x:DataType="local:ScoobeReleaseGroupViewModel"> + <StackPanel + Margin="0,8,0,8" + AutomationProperties.Name="{x:Bind DateText}" + Orientation="Vertical" + Spacing="4"> + <TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="{x:Bind DateText}" /> + <TextBlock + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{x:Bind VersionText}" /> + </StackPanel> + </DataTemplate> + </NavigationView.MenuItemTemplate> + <NavigationView.Content> + <Grid> + <ProgressRing + x:Name="LoadingProgressRing" + HorizontalAlignment="Center" + VerticalAlignment="Center" + IsIndeterminate="True" + Visibility="Collapsed" /> + <InfoBar + x:Name="ErrorInfoBar" + x:Uid="Oobe_WhatsNew_LoadingError" + HorizontalAlignment="Center" + VerticalAlignment="Center" + IsClosable="False" + IsOpen="False" + Severity="Error"> + <InfoBar.ActionButton> + <Button + x:Uid="RetryBtn" + HorizontalAlignment="Right" + Click="RetryButton_Click"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="RetryLabel" /> + </StackPanel> + </Button> + </InfoBar.ActionButton> + </InfoBar> + <Frame x:Name="NavigationFrame" /> + </Grid> + </NavigationView.Content> + </NavigationView> + </Grid> +</winuiex:WindowEx> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs new file mode 100644 index 0000000000..378df70063 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs @@ -0,0 +1,212 @@ +// 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.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.OOBE.Views; +using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using WinRT.Interop; +using WinUIEx; + +namespace Microsoft.PowerToys.Settings.UI +{ + public sealed partial class ScoobeWindow : WindowEx + { + public static Action<Type> OpenMainWindowCallback { get; set; } + + public static void SetOpenMainWindowCallback(Action<Type> implementation) + { + OpenMainWindowCallback = implementation; + } + + /// <summary> + /// Gets the list of release groups loaded from GitHub (grouped by major.minor version). + /// </summary> + public IList<IList<PowerToysReleaseInfo>> ReleaseGroups { get; private set; } + + private bool _isLoading; + + public ScoobeWindow() + { + App.ThemeService.ThemeChanged += OnThemeChanged; + App.ThemeService.ApplyTheme(); + + this.InitializeComponent(); + + SetTitleBar(); + + SetOpenMainWindowCallback((Type type) => + { + App.OpenSettingsWindow(type); + }); + } + + private void SetTitleBar() + { + WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(this)); + this.ExtendsContentIntoTitleBar = true; + this.SetTitleBar(AppTitleBar); + Title = ResourceLoaderInstance.ResourceLoader.GetString("ScoobeWindow_Title"); + } + + private void Window_Activated(object sender, WindowActivatedEventArgs args) + { + // Set window icon + this.SetIcon("Assets\\Settings\\icon.ico"); + } + + private void Window_Closed(object sender, WindowEventArgs args) + { + if (App.GetSettingsWindow() is MainWindow mainWindow) + { + mainWindow.CloseHiddenWindow(); + } + + App.ThemeService.ThemeChanged -= OnThemeChanged; + } + + private void OnThemeChanged(object sender, ElementTheme theme) + { + WindowHelper.SetTheme(this, theme); + } + + private async void NavigationView_Loaded(object sender, RoutedEventArgs e) + { + await LoadReleasesAsync(); + } + + private async Task LoadReleasesAsync() + { + if (_isLoading) + { + return; + } + + _isLoading = true; + LoadingProgressRing.Visibility = Visibility.Visible; + ErrorInfoBar.IsOpen = false; + navigationView.MenuItems.Clear(); + + try + { + var releases = await FetchReleasesFromGitHubAsync(); + ReleaseGroups = GroupReleasesByMajorMinor(releases); + PopulateNavigationItems(); + } + catch (Exception ex) + { + Logger.LogError("Failed to load releases", ex); + ErrorInfoBar.IsOpen = true; + } + finally + { + LoadingProgressRing.Visibility = Visibility.Collapsed; + _isLoading = false; + } + } + + private static async Task<IList<PowerToysReleaseInfo>> FetchReleasesFromGitHubAsync() + { + using var proxyClientHandler = new HttpClientHandler + { + DefaultProxyCredentials = CredentialCache.DefaultCredentials, + Proxy = WebRequest.GetSystemWebProxy(), + PreAuthenticate = true, + }; + + using var httpClient = new HttpClient(proxyClientHandler); + httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "PowerToys"); + + string json = await httpClient.GetStringAsync("https://api.github.com/repos/microsoft/PowerToys/releases?per_page=20"); + var allReleases = JsonSerializer.Deserialize<IList<PowerToysReleaseInfo>>(json, SourceGenerationContextContext.Default.IListPowerToysReleaseInfo); + + if (allReleases is null || allReleases.Count == 0) + { + return []; + } + + return allReleases + .OrderByDescending(r => r.PublishedDate) + .ToList(); + } + + private static IList<IList<PowerToysReleaseInfo>> GroupReleasesByMajorMinor(IList<PowerToysReleaseInfo> releases) + { + return releases + .GroupBy(GetMajorMinorVersion) + .Select(g => g.OrderByDescending(r => r.PublishedDate).ToList() as IList<PowerToysReleaseInfo>) + .ToList(); + } + + private static string GetMajorMinorVersion(PowerToysReleaseInfo release) + { + string version = ScoobeReleaseGroupViewModel.GetVersionFromRelease(release); + var parts = version.Split('.'); + if (parts.Length >= 2) + { + return $"{parts[0]}.{parts[1]}"; + } + + return version; + } + + private void PopulateNavigationItems() + { + if (ReleaseGroups == null || ReleaseGroups.Count == 0) + { + return; + } + + foreach (var releaseGroup in ReleaseGroups) + { + var viewModel = new ScoobeReleaseGroupViewModel(releaseGroup); + navigationView.MenuItems.Add(viewModel); + } + + // Select the first item to trigger navigation + navigationView.SelectedItem = navigationView.MenuItems[0]; + } + + private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + if (args.SelectedItem is ScoobeReleaseGroupViewModel viewModel) + { + NavigationFrame.Navigate(typeof(ScoobeReleaseNotesPage), viewModel.Releases); + } + } + + private async void RetryButton_Click(object sender, RoutedEventArgs e) + { + await LoadReleasesAsync(); + } + + private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args) + { + if (args.DisplayMode == NavigationViewDisplayMode.Compact || args.DisplayMode == NavigationViewDisplayMode.Minimal) + { + TitleBarIcon.Margin = new Thickness(0, 0, 8, 0); // Workaround, see XAML comment + AppTitleBar.IsPaneToggleButtonVisible = true; + } + else + { + TitleBarIcon.Margin = new Thickness(16, 0, 0, 0); // Workaround, see XAML comment + AppTitleBar.IsPaneToggleButtonVisible = false; + } + } + + private void TitleBar_PaneButtonClick(Microsoft.UI.Xaml.Controls.TitleBar sender, object args) + { + navigationView.IsPaneOpen = !navigationView.IsPaneOpen; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Styles/Button.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Styles/Button.xaml index a64117889c..138239bebf 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Styles/Button.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Styles/Button.xaml @@ -1,156 +1,5 @@ -<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - - <ResourceDictionary.ThemeDictionaries> - <ResourceDictionary x:Key="Default"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="Light"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SubtleFillColorTransparent" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SubtleFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SubtleFillColorTertiary" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SubtleFillColorTransparent" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="TextFillColorPrimary" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="TextFillColorSecondary" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="TextFillColorDisabled" /> - </ResourceDictionary> - - <ResourceDictionary x:Key="HighContrast"> - <StaticResource x:Key="SubtleButtonBackground" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPointerOver" ResourceKey="SystemColorHighlightTextColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundPressed" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBackgroundDisabled" ResourceKey="SystemControlBackgroundBaseLowBrush" /> - - <StaticResource x:Key="SubtleButtonBorderBrush" ResourceKey="SystemColorWindowColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPointerOver" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushPressed" ResourceKey="SystemColorHighlightColorBrush" /> - <StaticResource x:Key="SubtleButtonBorderBrushDisabled" ResourceKey="SystemColorGrayTextColor" /> - - <StaticResource x:Key="SubtleButtonForeground" ResourceKey="SystemColorButtonTextColorBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPointerOver" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundPressed" ResourceKey="SystemControlHighlightBaseHighBrush" /> - <StaticResource x:Key="SubtleButtonForegroundDisabled" ResourceKey="SystemControlDisabledBaseMediumLowBrush" /> - </ResourceDictionary> - - </ResourceDictionary.ThemeDictionaries> - - <Style x:Key="SubtleButtonStyle" TargetType="Button"> - <Setter Property="Background" Value="{ThemeResource SubtleButtonBackground}" /> - <Setter Property="BackgroundSizing" Value="InnerBorderEdge" /> - <Setter Property="Foreground" Value="{ThemeResource SubtleButtonForeground}" /> - <Setter Property="BorderBrush" Value="{ThemeResource SubtleButtonBorderBrush}" /> - <Setter Property="BorderThickness" Value="{ThemeResource ButtonBorderThemeThickness}" /> - <Setter Property="Padding" Value="{StaticResource ButtonPadding}" /> - <Setter Property="HorizontalAlignment" Value="Left" /> - <Setter Property="VerticalAlignment" Value="Center" /> - <Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" /> - <Setter Property="FontWeight" Value="Normal" /> - <Setter Property="FontSize" Value="{ThemeResource ControlContentThemeFontSize}" /> - <Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" /> - <Setter Property="FocusVisualMargin" Value="-3" /> - <Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" /> - <Setter Property="Template"> - <Setter.Value> - <ControlTemplate TargetType="Button"> - <ContentPresenter - x:Name="ContentPresenter" - Padding="{TemplateBinding Padding}" - HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}" - VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" - AnimatedIcon.State="Normal" - AutomationProperties.AccessibilityView="Raw" - Background="{TemplateBinding Background}" - BackgroundSizing="{TemplateBinding BackgroundSizing}" - BorderBrush="{TemplateBinding BorderBrush}" - BorderThickness="{TemplateBinding BorderThickness}" - Content="{TemplateBinding Content}" - ContentTemplate="{TemplateBinding ContentTemplate}" - ContentTransitions="{TemplateBinding ContentTransitions}" - CornerRadius="{TemplateBinding CornerRadius}" - Foreground="{TemplateBinding Foreground}"> - <ContentPresenter.BackgroundTransition> - <BrushTransition Duration="0:0:0.083" /> - </ContentPresenter.BackgroundTransition> - <VisualStateManager.VisualStateGroups> - <VisualStateGroup x:Name="CommonStates"> - <VisualState x:Name="Normal" /> - <VisualState x:Name="PointerOver"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPointerOver}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="PointerOver" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Pressed"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushPressed}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundPressed}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Pressed" /> - </VisualState.Setters> - </VisualState> - <VisualState x:Name="Disabled"> - <Storyboard> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBackgroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonBorderBrushDisabled}" /> - </ObjectAnimationUsingKeyFrames> - <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground"> - <DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource SubtleButtonForegroundDisabled}" /> - </ObjectAnimationUsingKeyFrames> - </Storyboard> - <VisualState.Setters> - <!-- DisabledVisual Should be handled by the control, not the animated icon. --> - <Setter Target="ContentPresenter.(AnimatedIcon.State)" Value="Normal" /> - </VisualState.Setters> - </VisualState> - </VisualStateGroup> - </VisualStateManager.VisualStateGroups> - </ContentPresenter> - </ControlTemplate> - </Setter.Value> - </Setter> - </Style> - +<?xml version="1.0" encoding="utf-8" ?> +<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <Style x:Key="HyperlinkButtonStyle" TargetType="HyperlinkButton" /> <Style x:Key="TextButtonStyle" TargetType="ButtonBase"> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Themes/Generic.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Themes/Generic.xaml index b1c5f79256..3f6a4c3074 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Themes/Generic.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Themes/Generic.xaml @@ -2,7 +2,7 @@ <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="ms-appx:///SettingsXAML/Controls/SettingsGroup/SettingsGroup.xaml" /> - <ResourceDictionary Source="ms-appx:///SettingsXAML/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml" /> - <ResourceDictionary Source="ms-appx:///SettingsXAML/Controls/FlyoutMenuButton/FlyoutMenuButton.xaml" /> + <ResourceDictionary Source="ms-appx:///SettingsXAML/Controls/GPOInfoControl.xaml" /> + <ResourceDictionary Source="ms-appx:///SettingsXAML/Controls/IsEnabledTextBlock.xaml" /> </ResourceDictionary.MergedDictionaries> -</ResourceDictionary> +</ResourceDictionary> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml deleted file mode 100644 index 7aa8176621..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml +++ /dev/null @@ -1,414 +0,0 @@ -<Page - x:Class="Microsoft.PowerToys.Settings.UI.Views.AdvancedPastePage" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" - xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:models="using:Microsoft.PowerToys.Settings.UI.Library" - xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" - xmlns:ui="using:CommunityToolkit.WinUI" - x:Name="RootPage" - AutomationProperties.LandmarkType="Main" - mc:Ignorable="d"> - <Page.Resources> - <ResourceDictionary> - <ResourceDictionary.ThemeDictionaries> - <ResourceDictionary x:Key="Default"> - <ImageSource x:Key="DialogHeaderImage">ms-appx:///Assets/Settings/Modules/APDialog.dark.png</ImageSource> - </ResourceDictionary> - <ResourceDictionary x:Key="Light"> - <ImageSource x:Key="DialogHeaderImage">ms-appx:///Assets/Settings/Modules/APDialog.light.png</ImageSource> - </ResourceDictionary> - <ResourceDictionary x:Key="HighContrast"> - <ImageSource x:Key="DialogHeaderImage">ms-appx:///Assets/Settings/Modules/APDialog.light.png</ImageSource> - </ResourceDictionary> - </ResourceDictionary.ThemeDictionaries> - <DataTemplate x:Key="AdditionalActionTemplate" x:DataType="models:AdvancedPasteAdditionalAction"> - <StackPanel Orientation="Horizontal" Spacing="4"> - <controls:ShortcutControl - MinWidth="{StaticResource SettingActionControlMinWidth}" - AllowDisable="True" - HotkeySettings="{x:Bind Shortcut, Mode=TwoWay}" /> - <ToggleSwitch - IsOn="{x:Bind IsShown, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" - OffContent="" - OnContent="" /> - </StackPanel> - </DataTemplate> - </ResourceDictionary> - </Page.Resources> - <Grid> - <controls:SettingsPageControl x:Uid="AdvancedPaste" ModuleImageSource="ms-appx:///Assets/Settings/Modules/AdvancedPaste.png"> - <controls:SettingsPageControl.ModuleContent> - <StackPanel - ChildrenTransitions="{StaticResource SettingsCardsAnimations}" - Orientation="Vertical" - Spacing="2"> - <tkcontrols:SettingsCard - x:Uid="AdvancedPaste_EnableToggleControl_HeaderText" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AdvancedPaste.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - - <controls:SettingsGroup x:Uid="AdvancedPaste_EnableAISettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <InfoBar - x:Uid="GPO_AdvancedPasteAi_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.ShowOnlineAIModelsGpoConfiguredInfoBar, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.ShowOnlineAIModelsGpoConfiguredInfoBar, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - <tkcontrols:SettingsCard x:Uid="AdvancedPaste_EnableAISettingsCard" IsEnabled="{x:Bind ViewModel.IsOnlineAIModelsDisallowedByGPO, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <tkcontrols:SettingsCard.HeaderIcon> - <PathIcon Data="M128 766q0-42 24-77t65-48l178-57q32-11 61-30t52-42q50-50 71-114l58-179q13-40 48-65t78-26q42 0 77 24t50 65l58 177q21 66 72 117 49 50 117 72l176 58q43 14 69 48t26 80q0 41-25 76t-64 49l-178 58q-66 21-117 72-32 32-51 73t-33 84-26 83-30 73-45 51-71 20q-42 0-77-24t-49-65l-58-178q-8-25-19-47t-28-43q-34-43-77-68t-89-41-89-27-78-29-55-45-21-75zm1149 7q-76-29-145-53t-129-60-104-88-73-138l-57-176-67 176q-18 48-42 89t-60 78q-34 34-76 61t-89 43l-177 57q75 29 144 53t127 60 103 89 73 137l57 176 67-176q37-97 103-168t168-103l177-57zm-125 759q0-31 20-57t49-36l99-32q34-11 53-34t30-51 20-59 20-54 33-41 58-16q32 0 59 19t38 50q6 20 11 40t13 40 17 38 25 34q16 17 39 26t48 18 49 16 44 20 31 32 12 50q0 33-18 60t-51 38q-19 6-39 11t-41 13-39 17-34 25q-24 25-35 62t-24 73-35 61-68 25q-32 0-59-19t-38-50q-6-18-11-39t-13-41-17-40-24-33q-18-17-41-27t-47-17-49-15-43-20-30-33-12-54zm583 4q-43-13-74-30t-55-41-40-55-32-74q-12 41-29 72t-42 55-55 42-71 31q81 23 128 71t71 129q15-43 31-74t40-54 53-40 75-32z" /> - </tkcontrols:SettingsCard.HeaderIcon> - <tkcontrols:SwitchPresenter TargetType="x:Boolean" Value="{x:Bind ViewModel.IsOpenAIEnabled, Mode=OneWay}"> - <tkcontrols:Case Value="True"> - <Button x:Uid="AdvancedPaste_DisableAIButton" Click="AdvancedPaste_DisableAIButton_Click" /> - </tkcontrols:Case> - <tkcontrols:Case Value="False"> - <Button - x:Uid="AdvancedPaste_EnableAIButton" - Click="AdvancedPaste_EnableAIButton_Click" - Style="{StaticResource AccentButtonStyle}" /> - </tkcontrols:Case> - </tkcontrols:SwitchPresenter> - <tkcontrols:SettingsCard.Description> - <StackPanel Orientation="Vertical"> - <TextBlock x:Uid="AdvancedPaste_EnableAISettingsCardDescription" /> - <HyperlinkButton x:Uid="AdvancedPaste_EnableAISettingsCardDescriptionLearnMore" NavigateUri="https://learn.microsoft.com/windows/powertoys/advanced-paste" /> - </StackPanel> - </tkcontrols:SettingsCard.Description> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard - x:Uid="AdvancedPaste_EnableAdvancedAI" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/SemanticKernel.png}" - IsEnabled="{x:Bind ViewModel.IsOpenAIEnabled, Mode=OneWay}"> - <ToggleSwitch IsOn="{x:Bind ViewModel.IsAdvancedAIEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - </controls:SettingsGroup> - - <controls:SettingsGroup x:Uid="AdvancedPaste_BehaviorSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard - x:Uid="AdvancedPaste_Clipboard_History_Enabled_SettingsCard" - HeaderIcon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind ViewModel.ClipboardHistoryDisabledByGPO, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch IsOn="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.ShowClipboardHistoryIsGpoConfiguredInfoBar, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.ShowClipboardHistoryIsGpoConfiguredInfoBar, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - <tkcontrols:SettingsCard x:Uid="AdvancedPaste_CloseAfterLosingFocus"> - <tkcontrols:SettingsCard.HeaderIcon> - <PathIcon Data="M 4 16.284 C 1 22.284 29 59.284 71 101.284 L 143 174.284 L 101 220.284 C 54 271.284 5 367.284 14 390.284 C 23 416.284 40 406.284 56 367.284 C 64 347.284 76 320.284 82 307.284 C 97 278.284 160 215.284 175 215.284 C 181 215.284 199 228.284 214 243.284 C 239 270.284 240 273.284 224 286.284 C 202 304.284 180 357.284 180 392.284 C 180 430.284 213 481.284 252 505.284 C 297 532.284 349 531.284 394 500.284 C 414 486.284 434 475.284 438 475.284 C 442 475.284 484 514.284 532 562.284 C 602 631.284 622 647.284 632 637.284 C 642 627.284 581 561.284 335 315.284 C 164 144.284 22 5.284 18 5.284 C 14 5.284 8 10.284 4 16.284 Z M 337 367.284 C 372 401.284 400 435.284 400 442.284 C 400 457.284 349 485.284 321 485.284 C 269 485.284 220 437.284 220 385.284 C 220 357.284 248 305.284 262 305.284 C 269 305.284 303 333.284 337 367.284 Z M 248 132.284 C 228 137.284 225 151.284 241 161.284 C 247 164.284 284 168.284 324 169.284 C 393 171.284 442 188.284 491 227.284 C 522 252.284 578 335.284 585 364.284 C 592 399.284 607 412.284 622 397.284 C 629 390.284 627 370.284 615 333.284 C 590 260.284 506 176.284 427 147.284 C 373 127.284 293 120.284 248 132.284 Z" /> - </tkcontrols:SettingsCard.HeaderIcon> - <ToggleSwitch IsOn="{x:Bind ViewModel.CloseAfterLosingFocus, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="AdvancedPaste_ShowCustomPreviewSettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> - <ToggleSwitch IsOn="{x:Bind ViewModel.ShowCustomPreview, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - </controls:SettingsGroup> - - <controls:SettingsGroup x:Uid="AdvancedPaste_Direct_Access_Hotkeys_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard - x:Uid="AdvancedPasteUI_Actions" - HeaderIcon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind ViewModel.IsOpenAIEnabled, Mode=OneWay}"> - <Button - x:Uid="AdvancedPasteUI_AddCustomActionButton" - Click="AddCustomActionButton_Click" - Style="{ThemeResource AccentButtonStyle}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="AdvancedPasteUI_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.AdvancedPasteUIShortcut, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="PasteAsPlainText_Shortcut"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.PasteAsPlainTextShortcut, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="PasteAsMarkdown_Shortcut"> - <controls:ShortcutControl - MinWidth="{StaticResource SettingActionControlMinWidth}" - AllowDisable="True" - HotkeySettings="{x:Bind Path=ViewModel.PasteAsMarkdownShortcut, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="PasteAsJson_Shortcut"> - <controls:ShortcutControl - MinWidth="{StaticResource SettingActionControlMinWidth}" - AllowDisable="True" - HotkeySettings="{x:Bind Path=ViewModel.PasteAsJsonShortcut, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <ItemsControl - x:Name="CustomActions" - x:Uid="CustomActions" - HorizontalAlignment="Stretch" - IsEnabled="{x:Bind ViewModel.IsOpenAIEnabled, Mode=OneWay}" - IsTabStop="False" - ItemsSource="{x:Bind ViewModel.CustomActions, Mode=OneWay}"> - <ItemsControl.ItemTemplate> - <DataTemplate x:DataType="models:AdvancedPasteCustomAction"> - <tkcontrols:SettingsCard - Margin="0,0,0,2" - Click="EditCustomActionButton_Click" - Description="{x:Bind Prompt, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" - Header="{x:Bind Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" - IsActionIconVisible="False" - IsClickEnabled="True"> - <tkcontrols:SettingsCard.Resources> - <x:Double x:Key="SettingsCardActionButtonWidth">0</x:Double> - </tkcontrols:SettingsCard.Resources> - <StackPanel Orientation="Horizontal" Spacing="4"> - <controls:ShortcutControl - MinWidth="{StaticResource SettingActionControlMinWidth}" - AllowDisable="True" - HotkeySettings="{x:Bind Path=Shortcut, Mode=TwoWay}" /> - <ToggleSwitch - x:Uid="Enable_CustomAction" - AutomationProperties.HelpText="{x:Bind Name, Mode=OneWay}" - IsOn="{x:Bind IsShown, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" - OffContent="" - OnContent="" /> - <Button - x:Uid="More_Options_Button" - Grid.Column="1" - VerticalAlignment="Center" - Content="" - FontFamily="{ThemeResource SymbolThemeFontFamily}" - Style="{StaticResource SubtleButtonStyle}"> - <Button.Flyout> - <MenuFlyout> - <MenuFlyoutItem - x:Uid="MoveUp" - Click="ReorderButtonUp_Click" - Icon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind CanMoveUp, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - <MenuFlyoutItem - x:Uid="MoveDown" - Click="ReorderButtonDown_Click" - Icon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind CanMoveDown, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - <MenuFlyoutSeparator /> - <MenuFlyoutItem - x:Uid="RemoveItem" - Click="DeleteCustomActionButton_Click" - Icon="{ui:FontIcon Glyph=}" - IsEnabled="true" /> - </MenuFlyout> - </Button.Flyout> - <ToolTipService.ToolTip> - <TextBlock x:Uid="More_Options_ButtonTooltip" /> - </ToolTipService.ToolTip> - </Button> - </StackPanel> - </tkcontrols:SettingsCard> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - <InfoBar - x:Uid="AdvancedPaste_ShortcutWarning" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}" - Severity="Warning" /> - </controls:SettingsGroup> - - <controls:SettingsGroup x:Uid="AdvancedPaste_Additional_Actions_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ImageToText" DataContext="{x:Bind ViewModel.AdditionalActions.ImageToText, Mode=OneWay}"> - <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> - </tkcontrols:SettingsCard> - - <tkcontrols:SettingsExpander - x:Uid="PasteAsFile" - DataContext="{x:Bind ViewModel.AdditionalActions.PasteAsFile, Mode=OneWay}" - HeaderIcon="{ui:FontIcon Glyph=}" - IsExpanded="{Binding IsShown, Mode=OneWay}"> - <tkcontrols:SettingsExpander.Content> - <ToggleSwitch - IsOn="{Binding IsShown, Mode=TwoWay}" - OffContent="" - OnContent="" /> - </tkcontrols:SettingsExpander.Content> - <tkcontrols:SettingsExpander.Items> - <!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. --> - <tkcontrols:SettingsCard Visibility="Collapsed" /> - <tkcontrols:SettingsCard - x:Uid="PasteAsTxtFile" - DataContext="{Binding PasteAsTxtFile, Mode=TwoWay}" - IsEnabled="{x:Bind ViewModel.AdditionalActions.PasteAsFile.IsShown, Mode=OneWay}"> - <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard - x:Uid="PasteAsPngFile" - DataContext="{Binding PasteAsPngFile, Mode=TwoWay}" - IsEnabled="{x:Bind ViewModel.AdditionalActions.PasteAsFile.IsShown, Mode=OneWay}"> - <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard - x:Uid="PasteAsHtmlFile" - DataContext="{Binding PasteAsHtmlFile, Mode=TwoWay}" - IsEnabled="{x:Bind ViewModel.AdditionalActions.PasteAsFile.IsShown, Mode=OneWay}"> - <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> - </tkcontrols:SettingsCard> - <!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. --> - <tkcontrols:SettingsCard Visibility="Collapsed" /> - </tkcontrols:SettingsExpander.Items> - </tkcontrols:SettingsExpander> - - <tkcontrols:SettingsExpander - x:Uid="Transcode" - DataContext="{x:Bind ViewModel.AdditionalActions.Transcode, Mode=OneWay}" - HeaderIcon="{ui:FontIcon Glyph=}" - IsExpanded="{Binding IsShown, Mode=OneWay}"> - <tkcontrols:SettingsExpander.Content> - <ToggleSwitch - IsOn="{Binding IsShown, Mode=TwoWay}" - OffContent="" - OnContent="" /> - </tkcontrols:SettingsExpander.Content> - <tkcontrols:SettingsExpander.Items> - <!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. --> - <tkcontrols:SettingsCard Visibility="Collapsed" /> - <tkcontrols:SettingsCard - x:Uid="TranscodeToMp3" - DataContext="{Binding TranscodeToMp3, Mode=TwoWay}" - IsEnabled="{x:Bind ViewModel.AdditionalActions.Transcode.IsShown, Mode=OneWay}"> - <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard - x:Uid="TranscodeToMp4" - DataContext="{Binding TranscodeToMp4, Mode=TwoWay}" - IsEnabled="{x:Bind ViewModel.AdditionalActions.Transcode.IsShown, Mode=OneWay}"> - <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> - </tkcontrols:SettingsCard> - <!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. --> - <tkcontrols:SettingsCard Visibility="Collapsed" /> - </tkcontrols:SettingsExpander.Items> - </tkcontrols:SettingsExpander> - - <InfoBar - x:Uid="AdvancedPaste_ShortcutWarning" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsAdditionalActionConflictingCopyShortcut, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsAdditionalActionConflictingCopyShortcut, Mode=OneWay}" - Severity="Warning" /> - </controls:SettingsGroup> - </StackPanel> - </controls:SettingsPageControl.ModuleContent> - <controls:SettingsPageControl.PrimaryLinks> - <controls:PageLink x:Uid="LearnMore_AdvancedPaste" Link="https://aka.ms/PowerToysOverview_AdvancedPaste" /> - </controls:SettingsPageControl.PrimaryLinks> - </controls:SettingsPageControl> - - <ContentDialog - x:Name="EnableAIDialog" - x:Uid="EnableAIDialog" - IsPrimaryButtonEnabled="False" - IsSecondaryButtonEnabled="True" - PrimaryButtonStyle="{StaticResource AccentButtonStyle}"> - <ScrollViewer VerticalScrollBarVisibility="Auto"> - <Grid RowSpacing="24"> - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - <Image Margin="-24,-24,-24,0" Source="{ThemeResource DialogHeaderImage}" /> - <TextBlock - Grid.Row="1" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap"> - <Run x:Uid="AdvancedPaste_EnableAIDialog_Description" /> - <Hyperlink NavigateUri="https://openai.com/policies/terms-of-use" TabIndex="3"> - <Run x:Uid="TermsLink" /> - </Hyperlink> - <Run x:Uid="AIFooterSeparator" Foreground="{ThemeResource TextFillColorSecondaryBrush}">|</Run> - <Hyperlink NavigateUri="https://openai.com/policies/privacy-policy" TabIndex="3"> - <Run x:Uid="PrivacyLink" /> - </Hyperlink> - </TextBlock> - - <StackPanel Grid.Row="2" Orientation="Vertical"> - <TextBlock x:Uid="AdvancedPaste_EnableAIDialog_ConfigureOpenAIKey" FontWeight="SemiBold" /> - <TextBlock Grid.Row="2" TextWrapping="Wrap"> - <Run x:Uid="AdvancedPaste_EnableAIDialog_LoginIntoText" /> - <Hyperlink NavigateUri="https://platform.openai.com/api-keys"> - <Run x:Uid="AdvancedPaste_EnableAIDialog_OpenAIApiKeysOverviewText" /> - </Hyperlink> - <LineBreak /> - <Run x:Uid="AdvancedPaste_EnableAIDialog_CreateNewKeyText" /> - <LineBreak /> - <Run x:Uid="AdvancedPaste_EnableAIDialog_NoteAICreditsText" /> - </TextBlock> - </StackPanel> - - <Grid Grid.Row="3" ColumnSpacing="8"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="*" /> - </Grid.ColumnDefinitions> - <TextBlock - x:Uid="AdvancedPaste_EnableAIDialogOpenAIApiKey" - VerticalAlignment="Center" - TextWrapping="Wrap" /> - <TextBox - x:Name="AdvancedPaste_EnableAIDialogOpenAIApiKey" - Grid.Column="1" - MinWidth="248" - HorizontalAlignment="Stretch" - HorizontalContentAlignment="Stretch" - TextChanged="AdvancedPaste_EnableAIDialogOpenAIApiKey_TextChanged" /> - </Grid> - </Grid> - </ScrollViewer> - </ContentDialog> - <ContentDialog - x:Name="CustomActionDialog" - x:Uid="CustomActionDialog" - Closed="CustomActionDialog_Closed" - IsPrimaryButtonEnabled="{Binding IsValid, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" - IsSecondaryButtonEnabled="True" - PrimaryButtonStyle="{ThemeResource AccentButtonStyle}"> - <ContentDialog.DataContext> - <models:AdvancedPasteCustomAction /> - </ContentDialog.DataContext> - <StackPanel Spacing="16"> - <TextBox - x:Uid="AdvancedPasteUI_CustomAction_Name" - Width="340" - HorizontalAlignment="Left" - Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - <TextBox - x:Uid="AdvancedPasteUI_CustomAction_Prompt" - Width="340" - Height="280" - HorizontalAlignment="Left" - AcceptsReturn="true" - Text="{Binding Prompt, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" - TextWrapping="Wrap" /> - </StackPanel> - </ContentDialog> - </Grid> -</Page> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs deleted file mode 100644 index a395ac767b..0000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml.cs +++ /dev/null @@ -1,154 +0,0 @@ -// 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.Linq; -using System.Threading.Tasks; -using System.Windows.Input; - -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; - -namespace Microsoft.PowerToys.Settings.UI.Views -{ - public sealed partial class AdvancedPastePage : Page, IRefreshablePage - { - private AdvancedPasteViewModel ViewModel { get; set; } - - public ICommand SaveOpenAIKeyCommand => new RelayCommand(SaveOpenAIKey); - - public AdvancedPastePage() - { - var settingsUtils = new SettingsUtils(); - ViewModel = new AdvancedPasteViewModel( - settingsUtils, - SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), - SettingsRepository<AdvancedPasteSettings>.GetInstance(settingsUtils), - ShellPage.SendDefaultIPCMessage); - DataContext = ViewModel; - InitializeComponent(); - } - - public void RefreshEnabledState() - { - ViewModel.RefreshEnabledState(); - } - - private void SaveOpenAIKey() - { - if (!string.IsNullOrEmpty(AdvancedPaste_EnableAIDialogOpenAIApiKey.Text)) - { - ViewModel.EnableAI(AdvancedPaste_EnableAIDialogOpenAIApiKey.Text); - } - } - - private async void AdvancedPaste_EnableAIButton_Click(object sender, RoutedEventArgs e) - { - var resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; - EnableAIDialog.PrimaryButtonText = resourceLoader.GetString("EnableAIDialog_SaveBtnText"); - EnableAIDialog.SecondaryButtonText = resourceLoader.GetString("EnableAIDialog_CancelBtnText"); - EnableAIDialog.PrimaryButtonCommand = SaveOpenAIKeyCommand; - - AdvancedPaste_EnableAIDialogOpenAIApiKey.Text = string.Empty; - - await ShowEnableDialogAsync(); - } - - private async Task ShowEnableDialogAsync() - { - await EnableAIDialog.ShowAsync(); - } - - private void AdvancedPaste_DisableAIButton_Click(object sender, RoutedEventArgs e) - { - ViewModel.DisableAI(); - } - - private void AdvancedPaste_EnableAIDialogOpenAIApiKey_TextChanged(object sender, TextChangedEventArgs e) - { - EnableAIDialog.IsPrimaryButtonEnabled = AdvancedPaste_EnableAIDialogOpenAIApiKey.Text.Length > 0; - } - - public async void DeleteCustomActionButton_Click(object sender, RoutedEventArgs e) - { - var customAction = GetBoundCustomAction(sender); - var resourceLoader = ResourceLoaderInstance.ResourceLoader; - - ContentDialog dialog = new() - { - XamlRoot = RootPage.XamlRoot, - Title = customAction.Name, - PrimaryButtonText = resourceLoader.GetString("Yes"), - CloseButtonText = resourceLoader.GetString("No"), - DefaultButton = ContentDialogButton.Primary, - Content = new TextBlock() { Text = resourceLoader.GetString("Delete_Dialog_Description") }, - }; - - dialog.PrimaryButtonClick += (_, _) => ViewModel.DeleteCustomAction(customAction); - - await dialog.ShowAsync(); - } - - private async void AddCustomActionButton_Click(object sender, RoutedEventArgs e) - { - var resourceLoader = ResourceLoaderInstance.ResourceLoader; - - CustomActionDialog.Title = resourceLoader.GetString("AddCustomAction"); - CustomActionDialog.DataContext = ViewModel.GetNewCustomAction(resourceLoader.GetString("AdvancedPasteUI_NewCustomActionPrefix")); - CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionSave"); - await CustomActionDialog.ShowAsync(); - } - - private async void EditCustomActionButton_Click(object sender, RoutedEventArgs e) - { - var resourceLoader = ResourceLoaderInstance.ResourceLoader; - - CustomActionDialog.Title = resourceLoader.GetString("EditCustomAction"); - CustomActionDialog.DataContext = GetBoundCustomAction(sender).Clone(); - CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionUpdate"); - await CustomActionDialog.ShowAsync(); - } - - private void ReorderButtonDown_Click(object sender, RoutedEventArgs e) - { - var index = ViewModel.CustomActions.IndexOf(GetBoundCustomAction(sender)); - ViewModel.CustomActions.Move(index, index + 1); - } - - private void ReorderButtonUp_Click(object sender, RoutedEventArgs e) - { - var index = ViewModel.CustomActions.IndexOf(GetBoundCustomAction(sender)); - ViewModel.CustomActions.Move(index, index - 1); - } - - private void CustomActionDialog_Closed(ContentDialog sender, ContentDialogClosedEventArgs args) - { - if (args.Result != ContentDialogResult.Primary) - { - return; - } - - var dialogCustomAction = GetBoundCustomAction(sender); - var existingCustomAction = ViewModel.CustomActions.FirstOrDefault(candidate => candidate.Id == dialogCustomAction.Id); - - if (existingCustomAction == null) - { - ViewModel.AddCustomAction(dialogCustomAction); - - var element = (ContentPresenter)CustomActions.ContainerFromIndex(CustomActions.Items.Count - 1); - element.StartBringIntoView(new BringIntoViewOptions { VerticalOffset = -60, AnimationDesired = true }); - element.Focus(FocusState.Programmatic); - } - else - { - existingCustomAction.Update(dialogCustomAction); - } - } - - private static AdvancedPasteCustomAction GetBoundCustomAction(object sender) => (AdvancedPasteCustomAction)((FrameworkElement)sender).DataContext; - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml new file mode 100644 index 0000000000..95bfab7ad2 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml @@ -0,0 +1,583 @@ +<local:NavigablePage + x:Class="Microsoft.PowerToys.Settings.UI.Views.AdvancedPastePage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + 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:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:models="using:Microsoft.PowerToys.Settings.UI.Library" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:viewmodels="using:Microsoft.PowerToys.Settings.UI.ViewModels" + x:Name="RootPage" + AutomationProperties.LandmarkType="Main" + mc:Ignorable="d"> + <local:NavigablePage.Resources> + <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceDictionary Source="ms-appx:///CommunityToolkit.WinUI.Controls.SettingsControls/SettingsExpander/SettingsExpander.xaml" /> + </ResourceDictionary.MergedDictionaries> + <ResourceDictionary.ThemeDictionaries> + <ResourceDictionary x:Key="Default"> + <ImageSource x:Key="OpenAIIconImage">ms-appx:///Assets/Settings/Icons/Models/OpenAI.dark.svg</ImageSource> + </ResourceDictionary> + <ResourceDictionary x:Key="Light"> + <ImageSource x:Key="OpenAIIconImage">ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg</ImageSource> + </ResourceDictionary> + <ResourceDictionary x:Key="HighContrast"> + <ImageSource x:Key="OpenAIIconImage">ms-appx:///Assets/Settings/Icons/Models/OpenAI.light.svg</ImageSource> + </ResourceDictionary> + </ResourceDictionary.ThemeDictionaries> + <Style x:Key="MenuFlyoutItemHeaderStyle" TargetType="MenuFlyoutItem"> + <Setter Property="FontSize" Value="12" /> + <Setter Property="IsEnabled" Value="False" /> + <Setter Property="IsHitTestVisible" Value="False" /> + </Style> + + <converters:ServiceTypeToIconConverter x:Key="ServiceTypeToIconConverter" /> + <DataTemplate x:Key="AdditionalActionTemplate" x:DataType="models:AdvancedPasteAdditionalAction"> + <StackPanel Orientation="Horizontal" Spacing="4"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + HotkeySettings="{x:Bind Shortcut, Mode=TwoWay}" /> + <ToggleSwitch + IsOn="{x:Bind IsShown, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + OffContent="" + OnContent="" /> + </StackPanel> + </DataTemplate> + </ResourceDictionary> + </local:NavigablePage.Resources> + <Grid> + <controls:SettingsPageControl x:Uid="AdvancedPaste" ModuleImageSource="ms-appx:///Assets/Settings/Modules/AdvancedPaste.png"> + <controls:SettingsPageControl.ModuleContent> + <StackPanel + ChildrenTransitions="{StaticResource SettingsCardsAnimations}" + Orientation="Vertical" + Spacing="2"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="AdvancedPasteEnableToggleControlHeaderText" + x:Uid="AdvancedPaste_EnableToggleControl_HeaderText" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AdvancedPaste.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> + <controls:SettingsGroup x:Uid="AdvancedPaste_EnableAISettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <InfoBar + x:Uid="GPO_AdvancedPasteAi_SettingIsManaged" + IsClosable="False" + IsOpen="{x:Bind ViewModel.ShowOnlineAIModelsGpoConfiguredInfoBar, Mode=OneWay}" + IsTabStop="{x:Bind ViewModel.ShowOnlineAIModelsGpoConfiguredInfoBar, Mode=OneWay}" + Severity="Informational"> + <InfoBar.IconSource> + <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> + </InfoBar.IconSource> + </InfoBar> + + <!-- Paste with AI --> + <tkcontrols:SettingsExpander + Name="AdvancedPasteEnableAISettingsCard" + x:Uid="AdvancedPaste_EnableAISettingsCard" + IsEnabled="{x:Bind ViewModel.IsOnlineAIModelsDisallowedByGPO, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" + IsExpanded="{x:Bind ViewModel.IsAIEnabled, Mode=OneWay}" + ItemsSource="{x:Bind ViewModel.PasteAIConfiguration.Providers, Mode=OneWay}"> + <tkcontrols:SettingsExpander.HeaderIcon> + <PathIcon Data="M128 766q0-42 24-77t65-48l178-57q32-11 61-30t52-42q50-50 71-114l58-179q13-40 48-65t78-26q42 0 77 24t50 65l58 177q21 66 72 117 49 50 117 72l176 58q43 14 69 48t26 80q0 41-25 76t-64 49l-178 58q-66 21-117 72-32 32-51 73t-33 84-26 83-30 73-45 51-71 20q-42 0-77-24t-49-65l-58-178q-8-25-19-47t-28-43q-34-43-77-68t-89-41-89-27-78-29-55-45-21-75zm1149 7q-76-29-145-53t-129-60-104-88-73-138l-57-176-67 176q-18 48-42 89t-60 78q-34 34-76 61t-89 43l-177 57q75 29 144 53t127 60 103 89 73 137l57 176 67-176q37-97 103-168t168-103l177-57zm-125 759q0-31 20-57t49-36l99-32q34-11 53-34t30-51 20-59 20-54 33-41 58-16q32 0 59 19t38 50q6 20 11 40t13 40 17 38 25 34q16 17 39 26t48 18 49 16 44 20 31 32 12 50q0 33-18 60t-51 38q-19 6-39 11t-41 13-39 17-34 25q-24 25-35 62t-24 73-35 61-68 25q-32 0-59-19t-38-50q-6-18-11-39t-13-41-17-40-24-33q-18-17-41-27t-47-17-49-15-43-20-30-33-12-54zm583 4q-43-13-74-30t-55-41-40-55-32-74q-12 41-29 72t-42 55-55 42-71 31q81 23 128 71t71 129q15-43 31-74t40-54 53-40 75-32z" /> + </tkcontrols:SettingsExpander.HeaderIcon> + <ToggleSwitch + x:Name="AdvancedPaste_EnableAIToggle" + x:Uid="AdvancedPaste_EnableAIToggle" + IsOn="{x:Bind ViewModel.IsAIEnabled, Mode=OneWay}" + Toggled="AdvancedPaste_EnableAIToggle_Toggled" /> + <tkcontrols:SettingsExpander.Description> + <StackPanel Orientation="Vertical"> + <TextBlock x:Uid="AdvancedPaste_EnableAISettingsCardDescription" /> + <HyperlinkButton x:Uid="AdvancedPaste_EnableAISettingsCardDescriptionLearnMore" NavigateUri="https://learn.microsoft.com/windows/powertoys/advanced-paste" /> + </StackPanel> + </tkcontrols:SettingsExpander.Description> + <tkcontrols:SettingsExpander.ItemsHeader> + <tkcontrols:SettingsCard x:Uid="AdvancedPaste_ModelProviders" Style="{StaticResource DefaultSettingsExpanderItemStyle}"> + <Button x:Uid="AdvancedPaste_AddModelButton" Style="{StaticResource AccentButtonStyle}"> + <Button.Flyout> + <MenuFlyout x:Name="AddProviderMenuFlyout" Opening="AddProviderMenuFlyout_Opening" /> + </Button.Flyout> + </Button> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.ItemsHeader> + <tkcontrols:SettingsExpander.ItemTemplate> + <DataTemplate x:DataType="models:PasteAIProviderDefinition"> + <tkcontrols:SettingsCard + Description="{x:Bind ServiceType, Mode=OneWay}" + Header="{x:Bind ModelName, Mode=OneWay}" + HeaderIcon="{x:Bind ServiceType, Mode=OneWay, Converter={StaticResource ServiceTypeToIconConverter}}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <Button + Padding="8" + Background="Transparent" + BorderThickness="0" + Tag="{x:Bind}"> + <FontIcon FontSize="16" Glyph="" /> + <Button.Flyout> + <MenuFlyout> + <MenuFlyoutItem + x:Uid="AdvancedPaste_Edit" + Click="EditPasteAIProviderButton_Click" + Icon="{ui:FontIcon Glyph=}" + Tag="{x:Bind}" /> + <MenuFlyoutSeparator /> + <MenuFlyoutItem + x:Uid="AdvancedPaste_Remove" + Click="RemovePasteAIProviderButton_Click" + Icon="{ui:FontIcon Glyph=}" + Tag="{x:Bind}" /> + </MenuFlyout> + </Button.Flyout> + </Button> + </StackPanel> + </tkcontrols:SettingsCard> + </DataTemplate> + </tkcontrols:SettingsExpander.ItemTemplate> + </tkcontrols:SettingsExpander> + </controls:SettingsGroup> + + <!-- Activation and behavior --> + <controls:SettingsGroup x:Uid="AdvancedPaste_BehaviorSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander + Name="AdvancedPasteUIShortcut" + x:Uid="AdvancedPasteUI_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.AdvancedPasteUIShortcut, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard Name="AdvancedPasteEnableClipboardPreview" ContentAlignment="Left"> + <controls:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_EnableClipboardPreview" IsChecked="{x:Bind ViewModel.EnableClipboardPreview, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="AdvancedPasteAutoCopySelectionCustomAction" ContentAlignment="Left"> + <controls:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_AutoCopySelectionForCustomActionHotkey" IsChecked="{x:Bind ViewModel.AutoCopySelectionForCustomActionHotkey, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="AdvancedPasteClipboardHistoryEnabledSettingsCard" + ContentAlignment="Left" + IsEnabled="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=OneWay}"> + <controls:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_Clipboard_History_Enabled_SettingsCard" IsChecked="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <InfoBar + x:Uid="GPO_SettingIsManaged" + IsClosable="False" + IsOpen="{x:Bind ViewModel.ShowClipboardHistoryIsGpoConfiguredInfoBar, Mode=OneWay}" + IsTabStop="{x:Bind ViewModel.ShowClipboardHistoryIsGpoConfiguredInfoBar, Mode=OneWay}" + Severity="Informational"> + <InfoBar.IconSource> + <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> + </InfoBar.IconSource> + </InfoBar> + <tkcontrols:SettingsCard Name="AdvancedPasteCloseAfterLosingFocus" ContentAlignment="Left"> + <CheckBox x:Uid="AdvancedPaste_CloseAfterLosingFocus" IsChecked="{x:Bind ViewModel.CloseAfterLosingFocus, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="AdvancedPasteShowCustomPreviewSettingsCard" ContentAlignment="Left"> + <controls:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_ShowCustomPreviewSettingsCard" IsChecked="{x:Bind ViewModel.ShowCustomPreview, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + </controls:SettingsGroup> + + <!-- Built-in actions --> + <controls:SettingsGroup x:Uid="AdvancedPaste_Direct_Access_Hotkeys_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PasteAsPlainTextShortcut" + x:Uid="PasteAsPlainText_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.PasteAsPlainTextShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="PasteAsMarkdownShortcut" + x:Uid="PasteAsMarkdown_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + HotkeySettings="{x:Bind Path=ViewModel.PasteAsMarkdownShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="PasteAsJsonShortcut" + x:Uid="PasteAsJson_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + HotkeySettings="{x:Bind Path=ViewModel.PasteAsJsonShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="ImageToText" + x:Uid="ImageToText" + DataContext="{x:Bind ViewModel.AdditionalActions.ImageToText, Mode=OneWay}" + HeaderIcon="{ui:FontIcon Glyph=}"> + <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander + Name="PasteAsFile" + x:Uid="PasteAsFile" + DataContext="{x:Bind ViewModel.AdditionalActions.PasteAsFile, Mode=OneWay}" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="{Binding IsShown, Mode=OneWay}"> + <tkcontrols:SettingsExpander.Content> + <ToggleSwitch + IsOn="{Binding IsShown, Mode=TwoWay}" + OffContent="" + OnContent="" /> + </tkcontrols:SettingsExpander.Content> + <tkcontrols:SettingsExpander.Items> + <!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. --> + <tkcontrols:SettingsCard Visibility="Collapsed" /> + <tkcontrols:SettingsCard + Name="PasteAsTxtFile" + x:Uid="PasteAsTxtFile" + DataContext="{Binding PasteAsTxtFile, Mode=TwoWay}" + IsEnabled="{x:Bind ViewModel.AdditionalActions.PasteAsFile.IsShown, Mode=OneWay}"> + <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="PasteAsPngFile" + x:Uid="PasteAsPngFile" + DataContext="{Binding PasteAsPngFile, Mode=TwoWay}" + IsEnabled="{x:Bind ViewModel.AdditionalActions.PasteAsFile.IsShown, Mode=OneWay}"> + <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="PasteAsHtmlFile" + x:Uid="PasteAsHtmlFile" + DataContext="{Binding PasteAsHtmlFile, Mode=TwoWay}" + IsEnabled="{x:Bind ViewModel.AdditionalActions.PasteAsFile.IsShown, Mode=OneWay}"> + <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> + </tkcontrols:SettingsCard> + <!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. --> + <tkcontrols:SettingsCard Visibility="Collapsed" /> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + <tkcontrols:SettingsExpander + Name="Transcode" + x:Uid="Transcode" + DataContext="{x:Bind ViewModel.AdditionalActions.Transcode, Mode=OneWay}" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="{Binding IsShown, Mode=OneWay}"> + <tkcontrols:SettingsExpander.Content> + <ToggleSwitch + IsOn="{Binding IsShown, Mode=TwoWay}" + OffContent="" + OnContent="" /> + </tkcontrols:SettingsExpander.Content> + <tkcontrols:SettingsExpander.Items> + <!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. --> + <tkcontrols:SettingsCard Visibility="Collapsed" /> + <tkcontrols:SettingsCard + Name="TranscodeToMp3" + x:Uid="TranscodeToMp3" + DataContext="{Binding TranscodeToMp3, Mode=TwoWay}" + IsEnabled="{x:Bind ViewModel.AdditionalActions.Transcode.IsShown, Mode=OneWay}"> + <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="TranscodeToMp4" + x:Uid="TranscodeToMp4" + DataContext="{Binding TranscodeToMp4, Mode=TwoWay}" + IsEnabled="{x:Bind ViewModel.AdditionalActions.Transcode.IsShown, Mode=OneWay}"> + <ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" /> + </tkcontrols:SettingsCard> + <!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. --> + <tkcontrols:SettingsCard Visibility="Collapsed" /> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + <InfoBar + x:Uid="AdvancedPaste_ShortcutWarning" + IsClosable="False" + IsOpen="{x:Bind ViewModel.IsAdditionalActionConflictingCopyShortcut, Mode=OneWay}" + IsTabStop="{x:Bind ViewModel.IsAdditionalActionConflictingCopyShortcut, Mode=OneWay}" + Severity="Warning" /> + </controls:SettingsGroup> + + <!-- Custom actions --> + <controls:SettingsGroup x:Uid="AdvancedPaste_Additional_Actions_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander + Name="AdvancedPasteUIActions" + x:Uid="AdvancedPasteUI_Actions" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.IsAIEnabled, Mode=OneWay}" + IsExpanded="True" + ItemsSource="{x:Bind ViewModel.CustomActions, Mode=OneWay}"> + <Button + x:Uid="AdvancedPasteUI_AddCustomActionButton" + Click="AddCustomActionButton_Click" + Style="{ThemeResource AccentButtonStyle}" /> + <tkcontrols:SettingsExpander.ItemTemplate> + <DataTemplate x:DataType="models:AdvancedPasteCustomAction"> + <tkcontrols:SettingsCard + Margin="0,0,0,2" + Click="EditCustomActionButton_Click" + Description="{x:Bind Description, Mode=OneWay}" + Header="{x:Bind Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + IsActionIconVisible="False" + IsClickEnabled="True" + Tag="{x:Bind}"> + <tkcontrols:SettingsCard.Resources> + <x:Double x:Key="SettingsCardActionButtonWidth">0</x:Double> + </tkcontrols:SettingsCard.Resources> + <ToolTipService.ToolTip> + <TextBlock Text="{x:Bind Prompt, Mode=OneWay}" TextWrapping="Wrap" /> + </ToolTipService.ToolTip> + <StackPanel Orientation="Horizontal" Spacing="4"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + HotkeySettings="{x:Bind Path=Shortcut, Mode=TwoWay}" /> + <ToggleSwitch + x:Uid="Enable_CustomAction" + AutomationProperties.HelpText="{x:Bind Name, Mode=OneWay}" + IsOn="{x:Bind IsShown, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + OffContent="" + OnContent="" /> + <Button + x:Uid="More_Options_Button" + Grid.Column="1" + VerticalAlignment="Center" + Content="" + FontFamily="{ThemeResource SymbolThemeFontFamily}" + Style="{StaticResource SubtleButtonStyle}" + Tag="{x:Bind}"> + <Button.Flyout> + <MenuFlyout> + <MenuFlyoutItem + x:Uid="MoveUp" + Click="ReorderButtonUp_Click" + Icon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind CanMoveUp, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + Tag="{x:Bind}" /> + <MenuFlyoutItem + x:Uid="MoveDown" + Click="ReorderButtonDown_Click" + Icon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind CanMoveDown, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + Tag="{x:Bind}" /> + <MenuFlyoutSeparator /> + <MenuFlyoutItem + x:Uid="RemoveItem" + Click="DeleteCustomActionButton_Click" + Icon="{ui:FontIcon Glyph=}" + IsEnabled="true" + Tag="{x:Bind}" /> + </MenuFlyout> + </Button.Flyout> + <ToolTipService.ToolTip> + <TextBlock x:Uid="More_Options_ButtonTooltip" /> + </ToolTipService.ToolTip> + </Button> + </StackPanel> + </tkcontrols:SettingsCard> + </DataTemplate> + </tkcontrols:SettingsExpander.ItemTemplate> + </tkcontrols:SettingsExpander> + <InfoBar + x:Uid="AdvancedPaste_ShortcutWarning" + IsClosable="False" + IsOpen="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}" + IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}" + Severity="Warning" /> + </controls:SettingsGroup> + </StackPanel> + </controls:SettingsPageControl.ModuleContent> + <controls:SettingsPageControl.PrimaryLinks> + <controls:PageLink x:Uid="LearnMore_AdvancedPaste" Link="https://aka.ms/PowerToysOverview_AdvancedPaste" /> + </controls:SettingsPageControl.PrimaryLinks> + </controls:SettingsPageControl> + <ContentDialog + x:Name="CustomActionDialog" + x:Uid="CustomActionDialog" + Closed="CustomActionDialog_Closed" + IsPrimaryButtonEnabled="{Binding IsValid, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" + IsSecondaryButtonEnabled="True" + PrimaryButtonStyle="{ThemeResource AccentButtonStyle}"> + <ContentDialog.DataContext> + <models:AdvancedPasteCustomAction /> + </ContentDialog.DataContext> + <StackPanel Spacing="16"> + <TextBox + x:Uid="AdvancedPasteUI_CustomAction_Name" + Width="340" + HorizontalAlignment="Left" + Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> + <TextBox + x:Uid="AdvancedPasteUI_CustomAction_Description" + Width="340" + HorizontalAlignment="Left" + Text="{Binding Description, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> + <TextBox + x:Uid="AdvancedPasteUI_CustomAction_Prompt" + Width="340" + Height="280" + HorizontalAlignment="Left" + AcceptsReturn="true" + Text="{Binding Prompt, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + TextWrapping="Wrap" /> + </StackPanel> + </ContentDialog> + + <!-- Paste AI provider dialog --> + <ContentDialog + x:Name="PasteAIProviderConfigurationDialog" + x:Uid="AdvancedPaste_EndpointDialog" + Closed="PasteAIProviderConfigurationDialog_Closed" + PrimaryButtonClick="PasteAIProviderConfigurationDialog_PrimaryButtonClick" + PrimaryButtonStyle="{ThemeResource AccentButtonStyle}"> + <ContentDialog.Resources> + <x:Double x:Key="ContentDialogMaxWidth">900</x:Double> + <x:Double x:Key="ContentDialogMaxHeight">700</x:Double> + <StaticResource x:Key="ContentDialogTopOverlay" ResourceKey="NavigationViewContentBackground" /> + </ContentDialog.Resources> + <ScrollViewer + MaxHeight="550" + Margin="-16,0,-24,-24" + HorizontalScrollBarVisibility="Disabled" + VerticalScrollBarVisibility="Auto"> + <StackPanel + MinWidth="640" + Padding="16,0,16,16" + Spacing="16"> + <InfoBar + x:Name="PasteAIProviderGpoInfoBar" + x:Uid="GPO_SettingIsManaged" + Margin="0,12,0,0" + IsClosable="False" + IsOpen="{x:Bind ViewModel.ShowPasteAIProviderGpoConfiguredInfoBar, Mode=OneWay}" + IsTabStop="{x:Bind ViewModel.ShowPasteAIProviderGpoConfiguredInfoBar, Mode=OneWay}" + Severity="Informational"> + <InfoBar.IconSource> + <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> + </InfoBar.IconSource> + </InfoBar> + <StackPanel + Margin="0,12,0,0" + Orientation="Vertical" + Spacing="8" + Visibility="{x:Bind GetServiceLegalVisibility(ViewModel.PasteAIProviderDraft?.ServiceType), Mode=OneWay}"> + <TextBlock + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{x:Bind GetServiceLegalDescription(ViewModel.PasteAIProviderDraft?.ServiceType), Mode=OneWay}" + TextWrapping="Wrap" /> + <StackPanel + Margin="0,0,0,0" + Orientation="Horizontal" + Spacing="8"> + <HyperlinkButton + Padding="0" + Content="{x:Bind GetServiceTermsLabel(ViewModel.PasteAIProviderDraft?.ServiceType), Mode=OneWay}" + FontSize="12" + NavigateUri="{x:Bind GetServiceTermsUri(ViewModel.PasteAIProviderDraft?.ServiceType), Mode=OneWay}" + Visibility="{x:Bind GetServiceTermsVisibility(ViewModel.PasteAIProviderDraft?.ServiceType), Mode=OneWay}" /> + <HyperlinkButton + Padding="0" + Content="{x:Bind GetServicePrivacyLabel(ViewModel.PasteAIProviderDraft?.ServiceType), Mode=OneWay}" + FontSize="12" + NavigateUri="{x:Bind GetServicePrivacyUri(ViewModel.PasteAIProviderDraft?.ServiceType), Mode=OneWay}" + Visibility="{x:Bind GetServicePrivacyVisibility(ViewModel.PasteAIProviderDraft?.ServiceType), Mode=OneWay}" /> + </StackPanel> + </StackPanel> + <Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" /> + <StackPanel + Margin="0,8,0,48" + Orientation="Vertical" + Spacing="16"> + <TextBox + x:Name="PasteAIModelNameTextBox" + x:Uid="AdvancedPaste_ModelName" + MinWidth="200" + HorizontalAlignment="Stretch" + PlaceholderText="gpt-4o" + Text="{x:Bind ViewModel.PasteAIProviderDraft.ModelName, Mode=TwoWay}" /> + <TextBox + x:Name="PasteAIEndpointUrlTextBox" + x:Uid="AdvancedPaste_EndpointURL" + MinWidth="200" + HorizontalAlignment="Stretch" + PlaceholderText="https://your-resource.openai.azure.com/" + Text="{x:Bind ViewModel.PasteAIProviderDraft.EndpointUrl, Mode=TwoWay}" /> + <PasswordBox + x:Name="PasteAIApiKeyPasswordBox" + x:Uid="AdvancedPaste_APIKey" + MinWidth="200" /> + <TextBox + x:Name="PasteAIApiVersionTextBox" + x:Uid="AdvancedPaste_APIVersion" + MinWidth="200" + HorizontalAlignment="Stretch" + PlaceholderText="2024-10-01" + Text="{x:Bind ViewModel.PasteAIProviderDraft.ApiVersion, Mode=TwoWay}" + Visibility="Collapsed" /> + <TextBox + x:Name="PasteAIDeploymentNameTextBox" + x:Uid="AdvancedPaste_DeploymentName" + MinWidth="200" + PlaceholderText="gpt-4o" + Text="{x:Bind ViewModel.PasteAIProviderDraft.DeploymentName, Mode=TwoWay}" /> + <TextBox + x:Name="PasteAISystemPromptTextBox" + x:Uid="AdvancedPaste_SystemPrompt" + MinWidth="200" + MinHeight="76" + HorizontalAlignment="Stretch" + AcceptsReturn="True" + Text="{x:Bind ViewModel.PasteAIProviderDraft.SystemPrompt, Mode=TwoWay}" + TextWrapping="Wrap" /> + <Grid + x:Name="FoundryLocalPanel" + Margin="0,8,0,0" + Visibility="Collapsed"> + <controls:FoundryLocalModelPicker x:Name="FoundryLocalPicker" /> + </Grid> + + <StackPanel + x:Name="PasteAIModelPanel" + Orientation="Horizontal" + Spacing="8"> + <TextBox + x:Name="PasteAIModelPathTextBox" + x:Uid="AdvancedPaste_ModelPath" + MinWidth="200" + HorizontalAlignment="Stretch" + PlaceholderText="C:\Models\phi-3.onnx" + Text="{x:Bind ViewModel.PasteAIProviderDraft.ModelPath, Mode=TwoWay}" /> + <Button + VerticalAlignment="Bottom" + Click="BrowsePasteAIModelPath_Click" + Content="{ui:FontIcon Glyph=, + FontSize=16}" + Style="{StaticResource SubtleButtonStyle}" /> + </StackPanel> + + <ToggleSwitch + x:Name="PasteAIModerationToggle" + x:Uid="AdvancedPaste_EnablePasteAIModerationToggle" + IsOn="{x:Bind ViewModel.PasteAIProviderDraft.ModerationEnabled, Mode=TwoWay}" + Visibility="Collapsed" /> + + <ToggleSwitch + x:Name="PasteAIEnableAdvancedAICheckBox" + IsOn="{x:Bind ViewModel.PasteAIProviderDraft.EnableAdvancedAI, Mode=TwoWay}" + Toggled="PasteAIEnableAdvancedAICheckBox_Toggled" + Visibility="Collapsed"> + <ToggleSwitch.Header> + <TextBlock> + <Run x:Uid="AdvancedPaste_EnableAdvancedAI" /> <LineBreak /> + <Run x:Uid="AdvancedPaste_EnableAdvancedAIDescription" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </TextBlock> + </ToggleSwitch.Header> + </ToggleSwitch> + </StackPanel> + </StackPanel> + </ScrollViewer> + </ContentDialog> + </Grid> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs new file mode 100644 index 0000000000..8cae5dbd5f --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPastePage.xaml.cs @@ -0,0 +1,1167 @@ +// 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.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Input; + +using LanguageModelProvider; +using Microsoft.PowerToys.Settings.UI.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 Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + public sealed partial class AdvancedPastePage : NavigablePage, IRefreshablePage, IDisposable + { + private readonly ObservableCollection<ModelDetails> _foundryCachedModels = new(); + private CancellationTokenSource _foundryModelLoadCts; + private bool _suppressFoundrySelectionChanged; + private bool _isFoundryLocalAvailable; + private bool _disposed; + private const string PasteAiDialogDefaultTitle = "Paste with AI provider configuration"; + + private const string AdvancedAISystemPrompt = "You are an agent who is tasked with helping users paste their clipboard data. You have functions available to help you with this task. Call function when necessary to help user finish the transformation task. You never need to ask permission, always try to do as the user asks. The user will only input one message and will not be available for further questions, so try your best. The user will put in a request to format their clipboard data and you will fulfill it. Do not output anything else besides the reformatted clipboard content."; + private const string SimpleAISystemPrompt = "You are tasked with reformatting user's clipboard data. Use the user's instructions, and the content of their clipboard below to edit their clipboard content as they have requested it. Do not output anything else besides the reformatted clipboard content."; + private static readonly string AdvancedAISystemPromptNormalized = AdvancedAISystemPrompt.Trim(); + private static readonly string SimpleAISystemPromptNormalized = SimpleAISystemPrompt.Trim(); + + private AdvancedPasteViewModel ViewModel { get; set; } + + public ICommand EnableAdvancedPasteAICommand => new RelayCommand(EnableAdvancedPasteAI); + + public AdvancedPastePage() + { + var settingsUtils = SettingsUtils.Default; + ViewModel = new AdvancedPasteViewModel( + settingsUtils, + SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), + SettingsRepository<AdvancedPasteSettings>.GetInstance(settingsUtils), + ShellPage.SendDefaultIPCMessage); + DataContext = ViewModel; + InitializeComponent(); + + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.CachedModels = _foundryCachedModels; + FoundryLocalPicker.SelectionChanged += FoundryLocalPicker_SelectionChanged; + FoundryLocalPicker.LoadRequested += FoundryLocalPicker_LoadRequested; + } + + Loaded += async (s, e) => + { + ViewModel.OnPageLoaded(); + UpdatePasteAIUIVisibility(); + await UpdateFoundryLocalUIAsync(); + }; + + Unloaded += (_, _) => + { + if (_foundryModelLoadCts is not null) + { + _foundryModelLoadCts.Cancel(); + _foundryModelLoadCts.Dispose(); + _foundryModelLoadCts = null; + } + }; + } + + public void RefreshEnabledState() + { + ViewModel.RefreshEnabledState(); + UpdatePasteAIUIVisibility(); + _ = UpdateFoundryLocalUIAsync(); + } + + private void EnableAdvancedPasteAI() => ViewModel.EnableAI(); + + private void AdvancedPaste_EnableAIToggle_Toggled(object sender, RoutedEventArgs e) + { + if (ViewModel is null) + { + return; + } + + var toggle = (ToggleSwitch)sender; + + if (toggle.IsOn) + { + ViewModel.EnableAI(); + } + else + { + ViewModel.DisableAI(); + } + } + + public async void DeleteCustomActionButton_Click(object sender, RoutedEventArgs e) + { + var customAction = GetBoundCustomAction(sender, e); + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + + ContentDialog dialog = new() + { + XamlRoot = RootPage.XamlRoot, + Title = customAction.Name, + PrimaryButtonText = resourceLoader.GetString("Yes"), + CloseButtonText = resourceLoader.GetString("No"), + DefaultButton = ContentDialogButton.Primary, + Content = new TextBlock() { Text = resourceLoader.GetString("Delete_Dialog_Description") }, + }; + + dialog.PrimaryButtonClick += (_, _) => ViewModel.DeleteCustomAction(customAction); + + await dialog.ShowAsync(); + } + + private async void AddCustomActionButton_Click(object sender, RoutedEventArgs e) + { + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + + CustomActionDialog.Title = resourceLoader.GetString("AddCustomAction"); + CustomActionDialog.DataContext = ViewModel.GetNewCustomAction(resourceLoader.GetString("AdvancedPasteUI_NewCustomActionPrefix")); + CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionSave"); + await CustomActionDialog.ShowAsync(); + } + + private async void EditCustomActionButton_Click(object sender, RoutedEventArgs e) + { + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + + CustomActionDialog.Title = resourceLoader.GetString("EditCustomAction"); + CustomActionDialog.DataContext = GetBoundCustomAction(sender, e).Clone(); + CustomActionDialog.PrimaryButtonText = resourceLoader.GetString("CustomActionUpdate"); + await CustomActionDialog.ShowAsync(); + } + + private void ReorderButtonDown_Click(object sender, RoutedEventArgs e) + { + var index = ViewModel.CustomActions.IndexOf(GetBoundCustomAction(sender, e)); + ViewModel.CustomActions.Move(index, index + 1); + } + + private void ReorderButtonUp_Click(object sender, RoutedEventArgs e) + { + var index = ViewModel.CustomActions.IndexOf(GetBoundCustomAction(sender, e)); + ViewModel.CustomActions.Move(index, index - 1); + } + + private void CustomActionDialog_Closed(ContentDialog sender, ContentDialogClosedEventArgs args) + { + if (args.Result != ContentDialogResult.Primary) + { + return; + } + + var dialogCustomAction = GetBoundCustomAction(sender, args); + var existingCustomAction = ViewModel.CustomActions.FirstOrDefault(candidate => candidate.Id == dialogCustomAction.Id); + + if (existingCustomAction == null) + { + ViewModel.AddCustomAction(dialogCustomAction); + } + else + { + existingCustomAction.Update(dialogCustomAction); + } + } + + private AdvancedPasteCustomAction GetBoundCustomAction(object sender, object eventArgs = null) + { + if (TryResolveCustomAction(sender, out var action)) + { + return action; + } + + if (eventArgs is RoutedEventArgs routedEventArgs && TryResolveCustomAction(routedEventArgs.OriginalSource, out action)) + { + return action; + } + + if (CustomActionDialog?.DataContext is AdvancedPasteCustomAction dialogAction) + { + return dialogAction; + } + + throw new InvalidOperationException("Unable to determine Advanced Paste custom action from sender."); + } + + private static bool TryResolveCustomAction(object source, out AdvancedPasteCustomAction action) + { + action = ResolveCustomAction(source); + return action is not null; + } + + private static AdvancedPasteCustomAction ResolveCustomAction(object source) + { + if (source is null) + { + return null; + } + + if (source is AdvancedPasteCustomAction directAction) + { + return directAction; + } + + if (source is MenuFlyoutItemBase menuItem && menuItem.Tag is AdvancedPasteCustomAction taggedAction) + { + return taggedAction; + } + + if (source is FrameworkElement element) + { + return ResolveFromElement(element); + } + + return null; + } + + private static AdvancedPasteCustomAction ResolveFromElement(FrameworkElement element) + { + for (FrameworkElement current = element; current is not null; current = VisualTreeHelper.GetParent(current) as FrameworkElement) + { + if (current.Tag is AdvancedPasteCustomAction tagged) + { + return tagged; + } + + if (current.DataContext is AdvancedPasteCustomAction contextual) + { + return contextual; + } + } + + return null; + } + + private void BrowsePasteAIModelPath_Click(object sender, RoutedEventArgs e) + { + // Use Win32 file dialog to work around FileOpenPicker issues with elevated permissions + string selectedFile = PickFileDialog( + "ONNX Model Files\0*.onnx\0All Files\0*.*\0", + "Select ONNX Model File"); + + if (!string.IsNullOrEmpty(selectedFile)) + { + PasteAIModelPathTextBox.Text = selectedFile; + if (ViewModel?.PasteAIProviderDraft is not null) + { + ViewModel.PasteAIProviderDraft.ModelPath = selectedFile; + } + } + } + + private static string PickFileDialog(string filter, string title, string initialDir = null, int initialFilter = 0) + { + // Use Win32 OpenFileName dialog as FileOpenPicker doesn't work with elevated permissions + OpenFileName openFileName = new OpenFileName(); + openFileName.StructSize = Marshal.SizeOf(openFileName); + openFileName.Filter = filter; + + // Make buffer double MAX_PATH since it can use 2 chars per char + openFileName.File = new string(new char[260 * 2]); + openFileName.MaxFile = openFileName.File.Length; + openFileName.FileTitle = new string(new char[260 * 2]); + openFileName.MaxFileTitle = openFileName.FileTitle.Length; + openFileName.InitialDir = initialDir; + openFileName.Title = title; + openFileName.FilterIndex = initialFilter; + openFileName.DefExt = null; + openFileName.Flags = (int)OpenFileNameFlags.OFN_NOCHANGEDIR; // OFN_NOCHANGEDIR flag is needed + IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow()); + openFileName.Hwnd = windowHandle; + + bool result = NativeMethods.GetOpenFileName(openFileName); + if (result) + { + return openFileName.File; + } + + return null; + } + + private void ShowApiKeySavedMessage(string configType) + { + // This would typically show a TeachingTip or InfoBar + // For now, we'll use a simple approach + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + + // In a real implementation, you'd want to show a proper notification + System.Diagnostics.Debug.WriteLine($"{configType} API key saved successfully"); + } + + private void UpdatePasteAIUIVisibility() + { + var draft = ViewModel?.PasteAIProviderDraft; + if (draft is null) + { + return; + } + + string selectedType = draft.ServiceType ?? string.Empty; + AIServiceType serviceKind = draft.ServiceTypeKind; + + bool requiresEndpoint = RequiresEndpointForService(serviceKind); + bool requiresDeployment = serviceKind == AIServiceType.AzureOpenAI; + bool requiresApiVersion = serviceKind == AIServiceType.AzureOpenAI; + bool requiresModelPath = serviceKind == AIServiceType.Onnx; + bool isFoundryLocal = serviceKind == AIServiceType.FoundryLocal; + bool requiresApiKey = RequiresApiKeyForService(selectedType); + bool showModerationToggle = serviceKind == AIServiceType.OpenAI; + bool showAdvancedAI = serviceKind == AIServiceType.OpenAI || serviceKind == AIServiceType.AzureOpenAI; + + if (string.IsNullOrWhiteSpace(draft.EndpointUrl)) + { + string storedEndpoint = ViewModel.GetPasteAIEndpoint(draft.Id, selectedType); + if (!string.IsNullOrWhiteSpace(storedEndpoint)) + { + draft.EndpointUrl = storedEndpoint; + } + } + + PasteAIEndpointUrlTextBox.Visibility = requiresEndpoint ? Visibility.Visible : Visibility.Collapsed; + if (requiresEndpoint) + { + PasteAIEndpointUrlTextBox.PlaceholderText = GetEndpointPlaceholder(serviceKind); + } + + PasteAIDeploymentNameTextBox.Visibility = requiresDeployment ? Visibility.Visible : Visibility.Collapsed; + PasteAIApiVersionTextBox.Visibility = requiresApiVersion ? Visibility.Visible : Visibility.Collapsed; + PasteAIModelPanel.Visibility = requiresModelPath ? Visibility.Visible : Visibility.Collapsed; + PasteAIModerationToggle.Visibility = showModerationToggle ? Visibility.Visible : Visibility.Collapsed; + PasteAIEnableAdvancedAICheckBox.Visibility = showAdvancedAI ? Visibility.Visible : Visibility.Collapsed; + PasteAIApiKeyPasswordBox.Visibility = requiresApiKey ? Visibility.Visible : Visibility.Collapsed; + PasteAIModelNameTextBox.Visibility = isFoundryLocal ? Visibility.Collapsed : Visibility.Visible; + + if (requiresApiKey) + { + PasteAIApiKeyPasswordBox.Password = ViewModel.GetPasteAIApiKey(draft.Id, selectedType); + } + else + { + PasteAIApiKeyPasswordBox.Password = string.Empty; + } + + // Update system prompt placeholder based on EnableAdvancedAI state + UpdateSystemPromptPlaceholder(); + + // Disable Save button if GPO blocks this provider + if (PasteAIProviderConfigurationDialog is not null) + { + bool isAllowedByGPO = ViewModel?.IsServiceTypeAllowedByGPO(serviceKind) ?? true; + + if (!isAllowedByGPO) + { + // GPO blocks this provider, disable save button + PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = false; + } + else if (isFoundryLocal) + { + // For Foundry Local, UpdateFoundrySaveButtonState will handle button state + // based on model selection status + } + else + { + // GPO allows this provider, enable save button + PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = true; + } + } + } + + private Task UpdateFoundryLocalUIAsync() + { + string selectedType = ViewModel?.PasteAIProviderDraft?.ServiceType ?? string.Empty; + bool isFoundryLocal = string.Equals(selectedType, "FoundryLocal", StringComparison.OrdinalIgnoreCase); + + if (FoundryLocalPanel is not null) + { + FoundryLocalPanel.Visibility = isFoundryLocal ? Visibility.Visible : Visibility.Collapsed; + } + + if (!isFoundryLocal) + { + _foundryModelLoadCts?.Cancel(); + _isFoundryLocalAvailable = false; + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.IsLoading = false; + FoundryLocalPicker.IsAvailable = false; + FoundryLocalPicker.StatusText = string.Empty; + FoundryLocalPicker.SelectedModel = null; + } + + if (PasteAIProviderConfigurationDialog is not null) + { + PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = true; + } + + return Task.CompletedTask; + } + + if (PasteAIProviderConfigurationDialog is not null) + { + PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = false; + } + + FoundryLocalPicker?.RequestLoad(); + + return Task.CompletedTask; + } + + private async Task LoadFoundryLocalModelsAsync() + { + if (FoundryLocalPanel is null) + { + return; + } + + _foundryModelLoadCts?.Cancel(); + _foundryModelLoadCts?.Dispose(); + _foundryModelLoadCts = new CancellationTokenSource(); + var cancellationToken = _foundryModelLoadCts.Token; + + ShowFoundryLoadingState(); + + try + { + var provider = FoundryLocalModelProvider.Instance; + + var isAvailable = await provider.IsAvailable(); + if (cancellationToken.IsCancellationRequested) + { + return; + } + + _isFoundryLocalAvailable = isAvailable; + + if (!isAvailable) + { + ShowFoundryUnavailableState(); + return; + } + + IEnumerable<ModelDetails> cachedModelsEnumerable = await provider.GetModelsAsync(cancelationToken: cancellationToken).ConfigureAwait(false); + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var cachedModels = cachedModelsEnumerable?.ToList() ?? new List<ModelDetails>(); + + DispatcherQueue.TryEnqueue(() => + { + UpdateFoundryCollections(cachedModels); + ShowFoundryAvailableState(); + RestoreFoundrySelection(cachedModels); + }); + } + catch (OperationCanceledException) + { + // Loading cancelled; no action required. + } + catch (Exception ex) + { + var errorMessage = $"Unable to load Foundry Local models. {ex.Message}"; + System.Diagnostics.Debug.WriteLine($"[AdvancedPastePage] Failed to load Foundry Local models: {ex}"); + DispatcherQueue.TryEnqueue(() => + { + ShowFoundryUnavailableState(errorMessage); + }); + } + finally + { + DispatcherQueue.TryEnqueue(() => + { + UpdateFoundrySaveButtonState(); + }); + } + } + + private void ShowFoundryLoadingState() + { + _isFoundryLocalAvailable = false; + + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.IsLoading = true; + FoundryLocalPicker.IsAvailable = false; + FoundryLocalPicker.StatusText = "Loading Foundry Local status..."; + FoundryLocalPicker.SelectedModel = null; + } + } + + private void ShowFoundryUnavailableState(string message = null) + { + _isFoundryLocalAvailable = false; + + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.IsLoading = false; + FoundryLocalPicker.IsAvailable = false; + FoundryLocalPicker.SelectedModel = null; + FoundryLocalPicker.StatusText = message ?? "Foundry Local was not detected. Follow the CLI guide to install and start it."; + } + + _foundryCachedModels.Clear(); + } + + private void ShowFoundryAvailableState() + { + _isFoundryLocalAvailable = true; + + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.IsLoading = false; + FoundryLocalPicker.IsAvailable = true; + if (_foundryCachedModels.Count == 0) + { + FoundryLocalPicker.StatusText = "No local models detected. Use the button below to list models and download them with Foundry Local."; + } + else if (string.IsNullOrWhiteSpace(FoundryLocalPicker.StatusText)) + { + FoundryLocalPicker.StatusText = "Select a downloaded model from the list to enable Advanced Paste."; + } + } + + UpdateFoundrySaveButtonState(); + } + + private void UpdateFoundryCollections(IReadOnlyCollection<ModelDetails> cachedModels) + { + _foundryCachedModels.Clear(); + + foreach (var model in cachedModels.OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase)) + { + _foundryCachedModels.Add(model); + } + + var cachedReferences = new HashSet<string>(_foundryCachedModels.Select(m => m.Name), StringComparer.OrdinalIgnoreCase); + } + + private void RestoreFoundrySelection(IReadOnlyCollection<ModelDetails> cachedModels) + { + if (FoundryLocalPicker is null) + { + return; + } + + var currentModelReference = ViewModel?.PasteAIProviderDraft?.ModelName; + + ModelDetails matchingModel = null; + + if (!string.IsNullOrWhiteSpace(currentModelReference)) + { + matchingModel = cachedModels.FirstOrDefault(model => + string.Equals(model.Name, currentModelReference, StringComparison.OrdinalIgnoreCase)); + } + + if (FoundryLocalPicker is null) + { + return; + } + + _suppressFoundrySelectionChanged = true; + FoundryLocalPicker.SelectedModel = matchingModel; + _suppressFoundrySelectionChanged = false; + + if (matchingModel is null) + { + if (ViewModel?.PasteAIProviderDraft is not null) + { + ViewModel.PasteAIProviderDraft.ModelName = string.Empty; + } + + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.StatusText = _foundryCachedModels.Count == 0 + ? "No local models detected. Use the button below to list models and download them with Foundry Local." + : "Select a downloaded model from the list to enable Advanced Paste."; + } + } + else + { + if (ViewModel?.PasteAIProviderDraft is not null) + { + ViewModel.PasteAIProviderDraft.ModelName = matchingModel.Name; + } + + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.StatusText = $"{matchingModel.Name} selected."; + } + } + + UpdateFoundrySaveButtonState(); + } + + private void UpdateFoundrySaveButtonState() + { + if (PasteAIProviderConfigurationDialog is null) + { + return; + } + + bool isFoundrySelected = string.Equals(ViewModel?.PasteAIProviderDraft?.ServiceType, "FoundryLocal", StringComparison.OrdinalIgnoreCase); + + if (!isFoundrySelected || ViewModel?.PasteAIProviderDraft is null) + { + PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = true; + return; + } + + // Check GPO first + bool isAllowedByGPO = ViewModel?.IsServiceTypeAllowedByGPO(AIServiceType.FoundryLocal) ?? true; + if (!isAllowedByGPO) + { + PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = false; + return; + } + + if (!_isFoundryLocalAvailable) + { + PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = false; + return; + } + + bool hasSelection = FoundryLocalPicker?.SelectedModel is ModelDetails; + PasteAIProviderConfigurationDialog.IsPrimaryButtonEnabled = hasSelection; + } + + private void FoundryLocalPicker_SelectionChanged(object sender, ModelDetails selectedModel) + { + if (_suppressFoundrySelectionChanged) + { + return; + } + + if (selectedModel is not null) + { + if (ViewModel?.PasteAIProviderDraft is not null) + { + ViewModel.PasteAIProviderDraft.ModelName = selectedModel.Name; + } + + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.StatusText = $"{selectedModel.Name} selected."; + } + } + else + { + if (ViewModel?.PasteAIProviderDraft is not null) + { + ViewModel.PasteAIProviderDraft.ModelName = string.Empty; + } + + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.StatusText = "Select a downloaded model from the list to enable Advanced Paste."; + } + } + + UpdateFoundrySaveButtonState(); + } + + private async void FoundryLocalPicker_LoadRequested(object sender) + { + await LoadFoundryLocalModelsAsync(); + } + + private sealed class FoundryDownloadableModel : INotifyPropertyChanged + { + private readonly List<string> _deviceTags; + private double _progress; + private bool _isDownloading; + private bool _isDownloaded; + + public FoundryDownloadableModel(ModelDetails modelDetails) + { + ModelDetails = modelDetails ?? throw new ArgumentNullException(nameof(modelDetails)); + SizeTag = FoundryLocalModelPicker.GetModelSizeText(ModelDetails.Size); + LicenseTag = FoundryLocalModelPicker.GetLicenseShortText(ModelDetails.License); + _deviceTags = FoundryLocalModelPicker + .GetDeviceTags(ModelDetails.HardwareAccelerators) + .ToList(); + } + + public ModelDetails ModelDetails { get; } + + public string Name => string.IsNullOrWhiteSpace(ModelDetails.Name) ? "Model" : ModelDetails.Name; + + public string Description => string.IsNullOrWhiteSpace(ModelDetails.Description) ? "No description provided." : ModelDetails.Description; + + public string SizeTag { get; } + + public bool HasSizeTag => !string.IsNullOrWhiteSpace(SizeTag); + + public string LicenseTag { get; } + + public bool HasLicenseTag => !string.IsNullOrWhiteSpace(LicenseTag); + + public IReadOnlyList<string> DeviceTags => _deviceTags; + + public bool HasDeviceTags => _deviceTags.Count > 0; + + public double ProgressPercent => Math.Round(_progress * 100, 2); + + public Visibility ProgressVisibility => _isDownloading ? Visibility.Visible : Visibility.Collapsed; + + public string ActionLabel => _isDownloaded ? "Downloaded" : _isDownloading ? "Downloading..." : "Download"; + + public bool CanDownload => !_isDownloading && !_isDownloaded; + + internal bool IsDownloading => _isDownloading; + + public event PropertyChangedEventHandler PropertyChanged; + + public void StartDownload() + { + _isDownloading = true; + _isDownloaded = false; + _progress = 0; + NotifyStateChanged(); + } + + public void ReportProgress(float value) + { + _progress = Math.Clamp(value, 0f, 1f); + RaisePropertyChanged(nameof(ProgressPercent)); + } + + public void MarkDownloaded() + { + _isDownloading = false; + _isDownloaded = true; + _progress = 1; + NotifyStateChanged(); + } + + public void Reset() + { + _isDownloading = false; + _isDownloaded = false; + _progress = 0; + NotifyStateChanged(); + } + + private void NotifyStateChanged() + { + RaisePropertyChanged(nameof(ProgressPercent)); + RaisePropertyChanged(nameof(ProgressVisibility)); + RaisePropertyChanged(nameof(ActionLabel)); + RaisePropertyChanged(nameof(CanDownload)); + } + + private void RaisePropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + + private void PasteAIProviderConfigurationDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + var draft = ViewModel?.PasteAIProviderDraft; + if (draft is null) + { + args.Cancel = true; + return; + } + + NormalizeSystemPrompt(draft); + string serviceType = draft.ServiceType ?? "OpenAI"; + string apiKey = PasteAIApiKeyPasswordBox.Password; + string trimmedApiKey = apiKey?.Trim() ?? string.Empty; + var serviceKind = draft.ServiceTypeKind; + bool requiresEndpoint = RequiresEndpointForService(serviceKind); + string endpoint = (draft.EndpointUrl ?? string.Empty).Trim(); + + // Never persist placeholder text or stale values for services that don't use an endpoint. + if (!requiresEndpoint) + { + endpoint = string.Empty; + } + else if (string.IsNullOrEmpty(endpoint)) + { + // If endpoint is required but not provided, use placeholder. + endpoint = GetEndpointPlaceholder(serviceKind); + } + + // For endpoint-based services, keep empty if the user didn't provide a value. + if (RequiresApiKeyForService(serviceType) && string.IsNullOrWhiteSpace(trimmedApiKey)) + { + args.Cancel = true; + return; + } + + ViewModel.CommitPasteAIProviderDraft(trimmedApiKey, endpoint); + PasteAIApiKeyPasswordBox.Password = string.Empty; + + // Show success message + ShowApiKeySavedMessage("Paste AI"); + } + + private void PasteAIEnableAdvancedAICheckBox_Toggled(object sender, RoutedEventArgs e) + { + var draft = ViewModel?.PasteAIProviderDraft; + if (draft is null) + { + return; + } + + NormalizeSystemPrompt(draft); + UpdateSystemPromptPlaceholder(); + } + + private static bool RequiresApiKeyForService(string serviceType) + { + var serviceKind = serviceType.ToAIServiceType(); + + return serviceKind switch + { + AIServiceType.Onnx => false, + AIServiceType.Ollama => false, + AIServiceType.FoundryLocal => false, + AIServiceType.ML => false, + _ => true, + }; + } + + private static bool RequiresEndpointForService(AIServiceType serviceKind) + { + return serviceKind is AIServiceType.AzureOpenAI + or AIServiceType.AzureAIInference + or AIServiceType.Mistral + or AIServiceType.Ollama; + } + + private static string GetEndpointPlaceholder(AIServiceType serviceKind) + { + return serviceKind switch + { + AIServiceType.AzureOpenAI => "https://your-resource.openai.azure.com/", + AIServiceType.AzureAIInference => "https://{resource-name}.cognitiveservices.azure.com/", + AIServiceType.Mistral => "https://api.mistral.ai/v1/", + AIServiceType.Ollama => "http://localhost:11434/", + _ => string.Empty, + }; + } + + private bool HasServiceLegalInfo(string serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + return metadata.HasLegalInfo; + } + + private string GetServiceLegalDescription(string serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + if (string.IsNullOrWhiteSpace(metadata.LegalDescription)) + { + return string.Empty; + } + + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + return resourceLoader.GetString(metadata.LegalDescription); + } + + private string GetServiceTermsLabel(string serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + if (string.IsNullOrWhiteSpace(metadata.TermsLabel)) + { + return string.Empty; + } + + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + return resourceLoader.GetString(metadata.TermsLabel); + } + + private Uri GetServiceTermsUri(string serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + return metadata.TermsUri; + } + + private string GetServicePrivacyLabel(string serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + if (string.IsNullOrWhiteSpace(metadata.PrivacyLabel)) + { + return string.Empty; + } + + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + return resourceLoader.GetString(metadata.PrivacyLabel); + } + + private Uri GetServicePrivacyUri(string serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + return metadata.PrivacyUri; + } + + private bool HasServiceTermsLink(string serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + return metadata.HasTermsLink; + } + + private bool HasServicePrivacyLink(string serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + return metadata.HasPrivacyLink; + } + + private Visibility GetServiceLegalVisibility(string serviceType) => HasServiceLegalInfo(serviceType) ? Visibility.Visible : Visibility.Collapsed; + + private Visibility GetServiceTermsVisibility(string serviceType) => HasServiceTermsLink(serviceType) ? Visibility.Visible : Visibility.Collapsed; + + private Visibility GetServicePrivacyVisibility(string serviceType) => HasServicePrivacyLink(serviceType) ? Visibility.Visible : Visibility.Collapsed; + + private static bool IsPlaceholderSystemPrompt(string prompt) + { + if (string.IsNullOrWhiteSpace(prompt)) + { + return true; + } + + string trimmedPrompt = prompt.Trim(); + return string.Equals(trimmedPrompt, AdvancedAISystemPromptNormalized, StringComparison.Ordinal) + || string.Equals(trimmedPrompt, SimpleAISystemPromptNormalized, StringComparison.Ordinal); + } + + private static void NormalizeSystemPrompt(PasteAIProviderDefinition draft) + { + if (draft is null) + { + return; + } + + if (IsPlaceholderSystemPrompt(draft.SystemPrompt)) + { + draft.SystemPrompt = string.Empty; + } + } + + private void UpdateSystemPromptPlaceholder() + { + var draft = ViewModel?.PasteAIProviderDraft; + if (draft is null) + { + return; + } + + NormalizeSystemPrompt(draft); + if (PasteAISystemPromptTextBox is null) + { + return; + } + + bool useAdvancedPlaceholder = PasteAIEnableAdvancedAICheckBox?.IsOn ?? draft.EnableAdvancedAI; + PasteAISystemPromptTextBox.PlaceholderText = useAdvancedPlaceholder + ? AdvancedAISystemPrompt + : SimpleAISystemPrompt; + } + + private void RefreshDialogBindings() + { + try + { + Bindings?.Update(); + } + catch (Exception) + { + // Best-effort refresh only; ignore refresh failures. + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + try + { + _foundryModelLoadCts?.Cancel(); + } + catch (Exception) + { + // Ignore cancellation failures during disposal. + } + + _foundryModelLoadCts?.Dispose(); + _foundryModelLoadCts = null; + + if (FoundryLocalPicker is not null) + { + FoundryLocalPicker.SelectionChanged -= FoundryLocalPicker_SelectionChanged; + FoundryLocalPicker.LoadRequested -= FoundryLocalPicker_LoadRequested; + } + + ViewModel?.Dispose(); + + _disposed = true; + GC.SuppressFinalize(this); + } + + private void AddProviderMenuFlyout_Opening(object sender, object e) + { + if (sender is not MenuFlyout menuFlyout) + { + return; + } + + // Clear existing items + menuFlyout.Items.Clear(); + + // Add online models header + var onlineHeader = new MenuFlyoutItem + { + Text = "Online models", + FontSize = 12, + IsEnabled = false, + IsHitTestVisible = false, + }; + menuFlyout.Items.Add(onlineHeader); + + // Add all online providers + var onlineProviders = AIServiceTypeRegistry.GetOnlineServiceTypes(); + + foreach (var metadata in onlineProviders) + { + var menuItem = new MenuFlyoutItem + { + Text = metadata.DisplayName, + Tag = metadata.ServiceType.ToConfigurationString(), + Icon = new ImageIcon { Source = new SvgImageSource(new Uri(metadata.IconPath)) }, + }; + + menuItem.Click += ProviderMenuFlyoutItem_Click; + menuFlyout.Items.Add(menuItem); + } + + // Add local models header + var localHeader = new MenuFlyoutItem + { + Text = "Local models", + FontSize = 12, + IsEnabled = false, + IsHitTestVisible = false, + Margin = new Thickness(0, 16, 0, 0), + }; + menuFlyout.Items.Add(localHeader); + + // Add all local providers + var localProviders = AIServiceTypeRegistry.GetLocalServiceTypes(); + + foreach (var metadata in localProviders) + { + var menuItem = new MenuFlyoutItem + { + Text = metadata.DisplayName, + Tag = metadata.ServiceType.ToConfigurationString(), + Icon = new ImageIcon { Source = new SvgImageSource(new Uri(metadata.IconPath)) }, + }; + + menuItem.Click += ProviderMenuFlyoutItem_Click; + menuFlyout.Items.Add(menuItem); + } + } + + private async void ProviderMenuFlyoutItem_Click(object sender, RoutedEventArgs e) + { + if (sender is not MenuFlyoutItem menuItem || menuItem.Tag is not string tag || string.IsNullOrWhiteSpace(tag)) + { + return; + } + + if (ViewModel is null || PasteAIProviderConfigurationDialog is null) + { + return; + } + + string serviceType = tag.Trim(); + string displayName = string.IsNullOrWhiteSpace(menuItem.Text) ? serviceType : menuItem.Text.Trim(); + + ViewModel.BeginAddPasteAIProvider(serviceType); + if (ViewModel.PasteAIProviderDraft is null) + { + return; + } + + PasteAIProviderConfigurationDialog.Title = PasteAiDialogDefaultTitle; + if (!string.IsNullOrWhiteSpace(displayName)) + { + PasteAIProviderConfigurationDialog.Title = $"{displayName} provider configuration"; + } + + await UpdateFoundryLocalUIAsync(); + UpdatePasteAIUIVisibility(); + RefreshDialogBindings(); + + PasteAIApiKeyPasswordBox.Password = string.Empty; + await PasteAIProviderConfigurationDialog.ShowAsync(); + } + + private async void EditPasteAIProviderButton_Click(object sender, RoutedEventArgs e) + { + // sender is MenuFlyoutItem with PasteAIProviderDefinition Tag + if (sender is not MenuFlyoutItem menuItem || menuItem.Tag is not PasteAIProviderDefinition provider) + { + return; + } + + if (ViewModel is null || PasteAIProviderConfigurationDialog is null) + { + return; + } + + ViewModel.BeginEditPasteAIProvider(provider); + + string titlePrefix = string.IsNullOrWhiteSpace(provider.ModelName) ? provider.ServiceType : provider.ModelName; + PasteAIProviderConfigurationDialog.Title = string.IsNullOrWhiteSpace(titlePrefix) + ? PasteAiDialogDefaultTitle + : $"{titlePrefix} provider configuration"; + + UpdatePasteAIUIVisibility(); + await UpdateFoundryLocalUIAsync(); + RefreshDialogBindings(); + PasteAIApiKeyPasswordBox.Password = ViewModel.GetPasteAIApiKey(provider.Id, provider.ServiceType); + await PasteAIProviderConfigurationDialog.ShowAsync(); + } + + private void RemovePasteAIProviderButton_Click(object sender, RoutedEventArgs e) + { + // sender is MenuFlyoutItem with PasteAIProviderDefinition Tag + if (sender is not MenuFlyoutItem menuItem || menuItem.Tag is not PasteAIProviderDefinition provider) + { + return; + } + + ViewModel?.RemovePasteAIProvider(provider); + } + + private void PasteAIProviderConfigurationDialog_Closed(ContentDialog sender, ContentDialogClosedEventArgs args) + { + ViewModel?.CancelPasteAIProviderDraft(); + PasteAIProviderConfigurationDialog.Title = PasteAiDialogDefaultTitle; + PasteAIApiKeyPasswordBox.Password = string.Empty; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml index 66cf570af0..deab4915ef 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml @@ -1,40 +1,33 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.AlwaysOnTopPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - + <local:NavigablePage.Resources /> <controls:SettingsPageControl x:Uid="AlwaysOnTop" IsTabStop="False" ModuleImageSource="ms-appx:///Assets/Settings/Modules/AlwaysOnTop.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="AlwaysOnTop_EnableToggleControl_HeaderText" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AlwaysOnTop.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="AlwaysOnTopEnableToggleControlHeaderText" + x:Uid="AlwaysOnTop_EnableToggleControl_HeaderText" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AlwaysOnTop.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="AlwaysOnTop_Activation_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsExpander + Name="AlwaysOnTopActivationShortcut" x:Uid="AlwaysOnTop_ActivationShortcut" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> @@ -45,35 +38,47 @@ </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> + </controls:SettingsGroup> <controls:SettingsGroup x:Uid="AlwaysOnTop_Behavior_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander + Name="AlwaysOnTopFrameEnabled" x:Uid="AlwaysOnTop_FrameEnabled" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.FrameEnabled, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="AlwaysOnTop_FrameColor_Mode" IsEnabled="{x:Bind ViewModel.FrameEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="AlwaysOnTopFrameColorMode" + x:Uid="AlwaysOnTop_FrameColor_Mode" + IsEnabled="{x:Bind ViewModel.FrameEnabled, Mode=OneWay}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.FrameAccentColor, Mode=TwoWay, Converter={StaticResource BoolToComboBoxIndexConverter}}"> <ComboBoxItem x:Uid="AlwaysOnTop_Radio_Custom_Color" /> <ComboBoxItem x:Uid="AlwaysOnTop_Radio_Windows_Default" /> </ComboBox> </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="AlwaysOnTopFrameColor" x:Uid="AlwaysOnTop_FrameColor" IsEnabled="{x:Bind ViewModel.FrameEnabled, Mode=OneWay}" Visibility="{x:Bind ViewModel.FrameAccentColor, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> <controls:ColorPickerButton SelectedColor="{x:Bind Path=ViewModel.FrameColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="AlwaysOnTop_FrameOpacity" IsEnabled="{x:Bind ViewModel.FrameEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="AlwaysOnTopFrameOpacity" + x:Uid="AlwaysOnTop_FrameOpacity" + IsEnabled="{x:Bind ViewModel.FrameEnabled, Mode=OneWay}"> <Slider MinWidth="{StaticResource SettingActionControlMinWidth}" Maximum="100" Minimum="0" Value="{x:Bind ViewModel.FrameOpacity, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="AlwaysOnTop_FrameThickness" IsEnabled="{x:Bind ViewModel.FrameEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="AlwaysOnTopFrameThickness" + x:Uid="AlwaysOnTop_FrameThickness" + IsEnabled="{x:Bind ViewModel.FrameEnabled, Mode=OneWay}"> <Slider MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="5" @@ -88,13 +93,22 @@ </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> + <tkcontrols:SettingsCard x:Uid="AlwaysOnTop_Sound" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.SoundEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander - x:Uid="AlwaysOnTop_SoundTitle" - HeaderIcon="{ui:FontIcon Glyph=}" + x:Uid="AlwaysOnTop_TransparencyInfo" + HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard ContentAlignment="Left"> - <CheckBox x:Uid="AlwaysOnTop_Sound" IsChecked="{x:Bind ViewModel.SoundEnabled, Mode=TwoWay}" /> + <tkcontrols:SettingsCard> + <tkcontrols:SettingsCard.Description> + <StackPanel Orientation="Vertical"> + <tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.IncreaseOpacityShortcut, Mode=OneWay}" /> + <tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.DecreaseOpacityShortcut, Mode=OneWay}" /> + </StackPanel> + </tkcontrols:SettingsCard.Description> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> @@ -102,6 +116,7 @@ <controls:SettingsGroup x:Uid="ExcludedApps" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander + Name="AlwaysOnTopExcludedApps" x:Uid="AlwaysOnTop_ExcludedApps" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> @@ -128,4 +143,4 @@ <controls:PageLink x:Uid="LearnMore_AlwaysOnTop" Link="https://aka.ms/PowerToysOverview_AlwaysOnTop" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs index b38fffc59e..95ba8b595f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AlwaysOnTopPage.xaml.cs @@ -9,16 +9,18 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class AlwaysOnTopPage : Page, IRefreshablePage + public sealed partial class AlwaysOnTopPage : NavigablePage, IRefreshablePage { private AlwaysOnTopViewModel ViewModel { get; set; } public AlwaysOnTopPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new AlwaysOnTopViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<AlwaysOnTopSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml index fb2cfd8e85..0dcb9d75be 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml @@ -1,21 +1,22 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.AwakePage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewmodels="using:Microsoft.PowerToys.Settings.UI.ViewModels" - d:DataContext="{d:DesignInstance Type=viewmodels:AwakeViewModel}" + xmlns:viewModels="using:Microsoft.PowerToys.Settings.UI.ViewModels" + d:DataContext="{d:DesignInstance Type=viewModels:AwakeViewModel}" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - <Page.Resources> + <local:NavigablePage.Resources> <converters:AwakeModeToIntConverter x:Key="AwakeModeToIntConverter" /> - </Page.Resources> + </local:NavigablePage.Resources> <controls:SettingsPageControl x:Uid="Awake" @@ -23,26 +24,21 @@ ModuleImageSource="ms-appx:///Assets/Settings/Modules/Awake.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="Awake_EnableSettingsCard" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Awake.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="AwakeEnableSettingsCard" + x:Uid="Awake_EnableSettingsCard" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Awake.png}" + IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="Awake_BehaviorSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - - <tkcontrols:SettingsCard x:Uid="Awake_ModeSettingsCard" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="AwakeModeSettingsCard" + x:Uid="Awake_ModeSettingsCard" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.Mode, Mode=TwoWay, Converter={StaticResource AwakeModeToIntConverter}}"> <ComboBoxItem x:Uid="Awake_NoKeepAwakeSelector" /> <ComboBoxItem x:Uid="Awake_IndefiniteKeepAwakeSelector" /> @@ -52,21 +48,23 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsExpander + Name="AwakeExpirationSettingsExpander" x:Uid="Awake_ExpirationSettingsExpander" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True" Visibility="{x:Bind ViewModel.IsExpirationConfigurationEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="Awake_ExpirationSettingsExpander_Date"> + <tkcontrols:SettingsCard Name="AwakeExpirationSettingsExpanderDate" x:Uid="Awake_ExpirationSettingsExpander_Date"> <DatePicker Date="{x:Bind ViewModel.ExpirationDateTime, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="Awake_ExpirationSettingsExpander_Time"> + <tkcontrols:SettingsCard Name="AwakeExpirationSettingsExpanderTime" x:Uid="Awake_ExpirationSettingsExpander_Time"> <TimePicker ClockIdentifier="24HourClock" Time="{x:Bind ViewModel.ExpirationTime, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> <tkcontrols:SettingsCard + Name="AwakeIntervalSettingsCard" x:Uid="Awake_IntervalSettingsCard" HeaderIcon="{ui:FontIcon Glyph=}" Visibility="{x:Bind ViewModel.IsTimeConfigurationEnabled, Mode=OneWay}"> @@ -112,4 +110,4 @@ <controls:PageLink x:Uid="SecondaryLink_Awake" Link="https://awake.den.dev" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml.cs index 3399f425cc..f52e96fb8f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AwakePage.xaml.cs @@ -5,19 +5,17 @@ using System; using System.IO; using System.IO.Abstractions; - using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Dispatching; -using Microsoft.UI.Xaml.Controls; using PowerToys.GPOWrapper; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class AwakePage : Page, IRefreshablePage + public sealed partial class AwakePage : NavigablePage, IRefreshablePage { private readonly string _appName = "Awake"; private readonly SettingsUtils _settingsUtils; @@ -38,7 +36,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views { _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); _fileSystem = new FileSystem(); - _settingsUtils = new SettingsUtils(); + _settingsUtils = SettingsUtils.Default; _sendConfigMsg = ShellPage.SendDefaultIPCMessage; ViewModel = new AwakeViewModel(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml index 5c4a09a9c4..27a2c8cb7f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml @@ -1,22 +1,16 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.CmdNotFoundPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - <Page.Resources> - <tkconverters:BoolToVisibilityConverter - x:Key="BoolToInvertedVisibilityConverter" - FalseValue="Visible" - TrueValue="Collapsed" /> - <tkconverters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> - </Page.Resources> <controls:SettingsPageControl x:Uid="CmdNotFound" ModuleImageSource="ms-appx:///Assets/Settings/Modules/CmdNotFound.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> @@ -42,6 +36,7 @@ </InfoBar> <tkcontrols:SettingsExpander + Name="CmdNotFoundEnable" x:Uid="CmdNotFound_Enable" HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CommandNotFound.png}" IsExpanded="True"> @@ -91,7 +86,7 @@ </StackPanel> </tkcontrols:SettingsExpander.ItemsHeader> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="CmdNotFound_PowerShellDetection"> + <tkcontrols:SettingsCard Name="CmdNotFoundPowerShellDetection" x:Uid="CmdNotFound_PowerShellDetection"> <tkcontrols:SwitchPresenter TargetType="x:Boolean" Value="{x:Bind ViewModel.IsPowerShell7Detected, Mode=OneWay}"> <tkcontrols:Case Value="True"> <StackPanel Orientation="Horizontal" Spacing="8"> @@ -124,7 +119,7 @@ </tkcontrols:SwitchPresenter> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="CmdNotFound_WinGetClientDetection"> + <tkcontrols:SettingsCard Name="CmdNotFoundWinGetClientDetection" x:Uid="CmdNotFound_WinGetClientDetection"> <tkcontrols:SwitchPresenter TargetType="x:Boolean" Value="{x:Bind ViewModel.IsWinGetClientModuleDetected, Mode=OneWay}"> <tkcontrols:Case Value="True"> <StackPanel Orientation="Horizontal" Spacing="8"> @@ -175,4 +170,4 @@ <controls:PageLink x:Uid="LearnMore_CmdNotFound" Link="https://aka.ms/PowerToysOverview_CmdNotFound" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml.cs index 8afa34700e..43b6dd74fe 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdNotFoundPage.xaml.cs @@ -2,12 +2,13 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class CmdNotFoundPage : Page + public sealed partial class CmdNotFoundPage : NavigablePage { private CmdNotFoundViewModel ViewModel { get; set; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml index df8d9995ec..e18927bcb9 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml @@ -1,45 +1,220 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.CmdPalPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> + <Grid> + <ScrollViewer AutomationProperties.AutomationId="PageScrollViewer"> + <Grid + MaxWidth="1000" + Padding="16,0,16,0" + VerticalAlignment="Top" + Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="16" + RowSpacing="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <tkcontrols:OpacityMaskView + Margin="-16,0,-16,0" + HorizontalAlignment="Stretch" + HorizontalContentAlignment="Stretch"> + <tkcontrols:OpacityMaskView.OpacityMask> + <Rectangle> + <Rectangle.Fill> + <LinearGradientBrush StartPoint="0.5,0" EndPoint="0.5,1"> + <GradientStop Offset="0.50" Color="Black" /> + <GradientStop Offset="0.75" Color="#80000000" /> + <GradientStop Offset="0.95" Color="Transparent" /> + </LinearGradientBrush> + </Rectangle.Fill> + </Rectangle> + </tkcontrols:OpacityMaskView.OpacityMask> + <Grid MaxHeight="560"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <Grid.Background> + <LinearGradientBrush StartPoint="0,0" EndPoint="1,1"> + <!-- Top-left: light cyan/blue --> + <GradientStop Offset="0.0" Color="#5FAFC9" /> - <controls:SettingsPageControl x:Uid="CmdPal" ModuleImageSource="ms-appx:///Assets/Settings/Modules/CmdPal.png"> - <controls:SettingsPageControl.ModuleContent> - <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="CmdPal_Enable_CmdPal" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CmdPal.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational" /> + <!-- Mid transition --> + <GradientStop Offset="0.45" Color="#3E7FB0" /> - <controls:SettingsGroup x:Uid="CmdPal_Activation_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <!-- Bottom-right: deep blue --> + <GradientStop Offset="1.0" Color="#2C3E8F" /> + </LinearGradientBrush> + </Grid.Background> + <TextBlock + x:Uid="CmdPal_HeroTitle" + Margin="0,24,0,12" + HorizontalAlignment="Center" + FontSize="36" + FontWeight="Bold"> + <TextBlock.Foreground> + <LinearGradientBrush StartPoint="0,0" EndPoint="1,1"> + <GradientStop Offset="0.0" Color="#FFB9EBFF" /> + <GradientStop Offset="0.49" Color="#FF86CBFF" /> + <GradientStop Offset="1.0" Color="#FFA1E7FF" /> + </LinearGradientBrush> + </TextBlock.Foreground> + </TextBlock> + <TextBlock + Grid.Row="1" + HorizontalAlignment="Center" + Foreground="White" + TextAlignment="Center" + TextWrapping="Wrap"> + <Run x:Uid="CmdPal_Description" /> + <Hyperlink NavigateUri=""> + <Run x:Uid="LearnMore_CmdPal.Text" Foreground="White" /> + </Hyperlink> + </TextBlock> + <Image + Grid.Row="2" + Margin="0,16,0,0" + HorizontalAlignment="Center" + VerticalAlignment="Top" + Source="/Assets/Settings/Modules/CmdPal.png" + Stretch="Uniform" /> + </Grid> + </tkcontrols:OpacityMaskView> - <tkcontrols:SettingsCard x:Uid="CmdPal_ActivationShortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl - MinWidth="{StaticResource SettingActionControlMinWidth}" - HotkeySettings="{x:Bind Path=ViewModel.Hotkey, Mode=OneWay}" - IsEnabled="False" /> + <Grid + Grid.Row="1" + Margin="0,-12,0,24" + ColumnSpacing="32" + RowSpacing="8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <FontIcon + HorizontalAlignment="Center" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Glyph="" /> + <TextBlock + Grid.Row="1" + HorizontalAlignment="Center" + TextAlignment="Center" + TextWrapping="Wrap"> + <Run x:Uid="CmdPal_ExtensibleHeader" FontWeight="SemiBold" /> <LineBreak /> + <Run + x:Uid="CmdPal_ExtensibleDescription" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </TextBlock> + + <FontIcon + Grid.Column="1" + HorizontalAlignment="Center" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Glyph="" /> + <TextBlock + Grid.Row="1" + Grid.Column="1" + HorizontalAlignment="Center" + TextAlignment="Center" + TextWrapping="Wrap"> + <Run x:Uid="CmdPal_FastHeader" FontWeight="SemiBold" /> <LineBreak /> + <Run + x:Uid="CmdPal_FastDescription" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </TextBlock> + + <FontIcon + Grid.Column="2" + HorizontalAlignment="Center" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Glyph="" /> + <TextBlock + Grid.Row="1" + Grid.Column="2" + HorizontalAlignment="Center" + TextAlignment="Center" + TextWrapping="Wrap"> + <Run x:Uid="CmdPal_ModernHeader" FontWeight="SemiBold" /> <LineBreak /> + <Run + x:Uid="CmdPal_ModernDescription" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </TextBlock> + </Grid> + <StackPanel + Grid.Row="2" + Margin="0,8,0,0" + Orientation="Vertical" + Spacing="{StaticResource SettingsCardSpacing}"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="CmdPalEnableCmdPal" + x:Uid="CmdPal_Enable_CmdPal" + HorizontalAlignment="Stretch" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CmdPal.png}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> + <tkcontrols:SettingsCard + x:Uid="CmdPal_Launch" + Grid.Row="3" + ActionIcon="{ui:FontIcon Glyph=}" + Click="LaunchCard_Click" + HeaderIcon="{ui:FontIcon Glyph=}" + IsClickEnabled="True" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <ItemsControl + AutomationProperties.AccessibilityView="Raw" + IsTabStop="False" + ItemsSource="{x:Bind Path=ViewModel.Hotkey.GetKeysList(), Mode=OneWay}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <StackPanel Orientation="Horizontal" Spacing="4" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate> + <controls:KeyVisual + Padding="8,8,8,8" + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + Content="{Binding}" + Style="{StaticResource AccentKeyVisualStyle}" /> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> </tkcontrols:SettingsCard> - </controls:SettingsGroup> - </StackPanel> - </controls:SettingsPageControl.ModuleContent> - - <controls:SettingsPageControl.PrimaryLinks> - <controls:PageLink x:Uid="LearnMore_CmdPal" Link="https://aka.ms/PowerToysOverview_CmdPal" /> - </controls:SettingsPageControl.PrimaryLinks> - </controls:SettingsPageControl> -</Page> + <tkcontrols:SettingsCard + x:Uid="CmdPal_Settings" + Grid.Row="4" + ActionIcon="{ui:FontIcon Glyph=}" + Click="SettingsCard_Click" + HeaderIcon="{ui:FontIcon Glyph=}" + IsClickEnabled="True" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}" /> + </StackPanel> + </Grid> + </ScrollViewer> + </Grid> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs index f00acdc750..90dec43398 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CmdPalPage.xaml.cs @@ -2,6 +2,10 @@ // 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; +using System.IO; +using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.ViewModels; @@ -9,19 +13,20 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class CmdPalPage : Page, IRefreshablePage + public sealed partial class CmdPalPage : NavigablePage, IRefreshablePage { private CmdPalViewModel ViewModel { get; set; } public CmdPalPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new CmdPalViewModel( settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, DispatcherQueue); DataContext = ViewModel; + Loaded += (s, e) => ViewModel.OnPageLoaded(); InitializeComponent(); } @@ -29,5 +34,49 @@ namespace Microsoft.PowerToys.Settings.UI.Views { ViewModel.RefreshEnabledState(); } + + private void LaunchApp(string appPath, string args) + { + try + { + string dir = Path.GetDirectoryName(appPath); + + var processStartInfo = new ProcessStartInfo + { + FileName = appPath, + Arguments = args, + WorkingDirectory = dir, + UseShellExecute = true, + Verb = "open", + CreateNoWindow = false, + }; + + Process process = Process.Start(processStartInfo); + if (process == null) + { + Logger.LogError($"Failed to launch CmdPal settings page."); + } + } + catch (Exception ex) + { + Logger.LogError($"Failed to launch CmdPal settings: {ex.Message}"); + } + } + + private void SettingsCard_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + // Launch CmdPal settings window as normal user using explorer + string launchPath = "explorer.exe"; + string launchArgs = "x-cmdpal://settings"; + LaunchApp(launchPath, launchArgs); + } + + private void LaunchCard_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + { + // Launch CmdPal window as normal user using explorer + string launchPath = "explorer.exe"; + string launchArgs = "x-cmdpal:"; + LaunchApp(launchPath, launchArgs); + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml index 4d036a517a..fbf610b34f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml @@ -1,65 +1,95 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.ColorPickerPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewmodels="using:Microsoft.PowerToys.Settings.UI.ViewModels" + xmlns:viewModels="using:Microsoft.PowerToys.Settings.UI.ViewModels" x:Name="RootPage" - d:DataContext="{d:DesignInstance Type=viewmodels:ColorPickerViewModel}" + d:DataContext="{d:DesignInstance Type=viewModels:ColorPickerViewModel}" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - - <Page.Resources> - <tkconverters:BoolToVisibilityConverter x:Key="BoolToVis" /> - </Page.Resources> - <controls:SettingsPageControl x:Uid="ColorPicker" ModuleImageSource="ms-appx:///Assets/Settings/Modules/ColorPicker.png"> <controls:SettingsPageControl.ModuleContent> - <StackPanel x:Name="ColorPickerView" ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="ColorPicker_EnableColorPicker" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ColorPicker.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard x:Uid="ColorPicker_EnableColorPicker" HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ColorPicker.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="Shortcut" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="Activation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="ActivationShortcut" + x:Uid="Activation_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.ActivationShortcut, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ColorPicker_ActivationAction" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="ColorPickerActivationAction" + x:Uid="ColorPicker_ActivationAction" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.ActivationBehavior, Mode=TwoWay}"> - <ComboBoxItem x:Uid="EditorFirst" /> - <ComboBoxItem x:Uid="ColorPickerFirst" /> - <ComboBoxItem x:Uid="ColorPickerOnly" /> + <ComboBoxItem x:Uid="ColorPicker_OpenEditor" /> + <ComboBoxItem x:Uid="ColorPicker_PickColor" /> </ComboBox> </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander + Name="ColorPickerMouseActions" + x:Uid="ColorPicker_MouseActions" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard + Name="ColorPickerPrimaryClick" + x:Uid="ColorPicker_PrimaryClick" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.PrimaryClickBehavior, Mode=TwoWay}"> + <ComboBoxItem x:Uid="ColorPicker_PickColorThenEditor" /> + <ComboBoxItem x:Uid="ColorPicker_PickColorAndClose" /> + <ComboBoxItem x:Uid="ColorPicker_Close" /> + </ComboBox> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="ColorPickerMiddleClick" + x:Uid="ColorPicker_MiddleClick" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.MiddleClickBehavior, Mode=TwoWay}"> + <ComboBoxItem x:Uid="ColorPicker_PickColorThenEditor" /> + <ComboBoxItem x:Uid="ColorPicker_PickColorAndClose" /> + <ComboBoxItem x:Uid="ColorPicker_Close" /> + </ComboBox> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="ColorPickerSecondaryClick" + x:Uid="ColorPicker_SecondaryClick" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.SecondaryClickBehavior, Mode=TwoWay}"> + <ComboBoxItem x:Uid="ColorPicker_PickColorThenEditor" /> + <ComboBoxItem x:Uid="ColorPicker_PickColorAndClose" /> + <ComboBoxItem x:Uid="ColorPicker_Close" /> + </ComboBox> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="ColorFormats" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ColorPicker_CopiedColorRepresentation" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="ColorPickerCopiedColorRepresentation" + x:Uid="ColorPicker_CopiedColorRepresentation" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox x:Name="ColorPicker_ComboBox" MinWidth="{StaticResource SettingActionControlMinWidth}" @@ -71,7 +101,7 @@ SelectedValuePath="Key" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ColorPicker_ShowColorName"> + <tkcontrols:SettingsCard Name="ColorPickerShowColorName" x:Uid="ColorPicker_ShowColorName"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{Binding ShowColorName, Mode=TwoWay}" /> </tkcontrols:SettingsCard> <!-- @@ -190,4 +220,4 @@ <controls:PageLink Link="https://medium.com/@Niels9001/a-fluent-color-meter-for-powertoys-20407ededf0c" Text="Niels Laute's UX concept" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> \ No newline at end of file +</local:NavigablePage> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml.cs index 37e6ffd47c..652db49f4b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ColorPickerPage.xaml.cs @@ -15,7 +15,7 @@ using Microsoft.Windows.ApplicationModel.Resources; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class ColorPickerPage : Page, IRefreshablePage + public sealed partial class ColorPickerPage : NavigablePage, IRefreshablePage { public ColorPickerViewModel ViewModel { get; set; } @@ -27,7 +27,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views public ColorPickerPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new ColorPickerViewModel( settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), @@ -35,6 +35,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } /// <summary> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml index ae14649480..8ca35b5d87 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.CropAndLockPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -16,36 +17,39 @@ ModuleImageSource="ms-appx:///Assets/Settings/Modules/CropAndLock.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="CropAndLock_EnableToggleControl_HeaderText" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CropAndLock.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="CropAndLockEnableToggleControlHeaderText" + x:Uid="CropAndLock_EnableToggleControl_HeaderText" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CropAndLock.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="CropAndLock_Activation_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - - <tkcontrols:SettingsCard x:Uid="CropAndLock_ThumbnailActivation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="CropAndLockThumbnailActivationShortcut" + x:Uid="CropAndLock_ThumbnailActivation_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" AllowDisable="True" HotkeySettings="{x:Bind Path=ViewModel.ThumbnailActivationShortcut, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="CropAndLock_ReparentActivation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="CropAndLockReparentActivationShortcut" + x:Uid="CropAndLock_ReparentActivation_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" AllowDisable="True" HotkeySettings="{x:Bind Path=ViewModel.ReparentActivationShortcut, Mode=TwoWay}" /> </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard x:Uid="CropAndLock_ScreenshotActivation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + HotkeySettings="{x:Bind Path=ViewModel.ScreenshotActivationShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> </controls:SettingsGroup> </StackPanel> </controls:SettingsPageControl.ModuleContent> @@ -58,4 +62,4 @@ <controls:PageLink Link="https://github.com/kevinguo305" Text="Kevin Guo" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml.cs index 66e3652da8..5c174b1f98 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CropAndLockPage.xaml.cs @@ -9,16 +9,18 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class CropAndLockPage : Page, IRefreshablePage + public sealed partial class CropAndLockPage : NavigablePage, IRefreshablePage { private CropAndLockViewModel ViewModel { get; set; } public CropAndLockPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new CropAndLockViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<CropAndLockSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml new file mode 100644 index 0000000000..9f0bc51079 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml @@ -0,0 +1,75 @@ +<ContentDialog + x:Class="Microsoft.PowerToys.Settings.UI.Views.CustomVcpMappingEditorDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + Width="400" + MinWidth="400" + DefaultButton="Primary" + IsPrimaryButtonEnabled="{x:Bind CanSave, Mode=OneWay}" + PrimaryButtonClick="ContentDialog_PrimaryButtonClick" + Style="{StaticResource DefaultContentDialogStyle}" + mc:Ignorable="d"> + + <StackPanel MinWidth="350" Spacing="16"> + <!-- VCP Code Selection --> + <ComboBox + x:Name="VcpCodeComboBox" + x:Uid="PowerDisplay_CustomMappingEditor_VcpCode" + HorizontalAlignment="Stretch" + SelectionChanged="VcpCodeComboBox_SelectionChanged"> + <ComboBoxItem x:Name="VcpCodeItem_0x14" Tag="20" /> + <ComboBoxItem x:Name="VcpCodeItem_0x60" Tag="96" /> + </ComboBox> + + <!-- Value Selection from monitors --> + <StackPanel Spacing="8"> + <ComboBox + x:Name="ValueComboBox" + x:Uid="PowerDisplay_CustomMappingEditor_ValueComboBox" + HorizontalAlignment="Stretch" + DisplayMemberPath="DisplayName" + ItemsSource="{x:Bind AvailableValues, Mode=OneWay}" + SelectedValuePath="Value" + SelectionChanged="ValueComboBox_SelectionChanged" /> + + <!-- Custom Value Input (shown when "Custom value" is selected) --> + <TextBox + x:Name="CustomValueTextBox" + x:Uid="PowerDisplay_CustomMappingEditor_CustomValueInput" + HorizontalAlignment="Stretch" + PlaceholderText="0x11" + TextChanged="CustomValueTextBox_TextChanged" + Visibility="{x:Bind ShowCustomValueInput, Mode=OneWay}" /> + </StackPanel> + + <!-- Custom Name Input --> + <TextBox + x:Name="CustomNameTextBox" + x:Uid="PowerDisplay_CustomMappingEditor_CustomName" + HorizontalAlignment="Stretch" + MaxLength="50" + TextChanged="CustomNameTextBox_TextChanged" /> + + <!-- Apply Scope --> + <StackPanel Spacing="8"> + <ToggleSwitch + x:Name="ApplyToAllToggle" + x:Uid="PowerDisplay_CustomMappingEditor_ApplyToAll" + IsOn="True" + Toggled="ApplyToAllToggle_Toggled" /> + + <!-- Monitor Selection (shown when ApplyToAll is off) --> + <ComboBox + x:Name="MonitorComboBox" + x:Uid="PowerDisplay_CustomMappingEditor_SelectMonitor" + HorizontalAlignment="Stretch" + DisplayMemberPath="DisplayName" + ItemsSource="{x:Bind AvailableMonitors, Mode=OneWay}" + SelectedValuePath="Id" + SelectionChanged="MonitorComboBox_SelectionChanged" + Visibility="{x:Bind ShowMonitorSelector, Mode=OneWay}" /> + </StackPanel> + </StackPanel> +</ContentDialog> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml.cs new file mode 100644 index 0000000000..915c891ada --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/CustomVcpMappingEditorDialog.xaml.cs @@ -0,0 +1,421 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Utils; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + /// <summary> + /// Dialog for creating/editing custom VCP value name mappings + /// </summary> + public sealed partial class CustomVcpMappingEditorDialog : ContentDialog, INotifyPropertyChanged + { + /// <summary> + /// Special value to indicate "Custom value" option in the ComboBox + /// </summary> + private const int CustomValueMarker = -1; + + /// <summary> + /// Represents a selectable VCP value item in the Value ComboBox + /// </summary> + public class VcpValueItem + { + public int Value { get; set; } + + public string DisplayName { get; set; } = string.Empty; + + public bool IsCustomOption => Value == CustomValueMarker; + } + + /// <summary> + /// Represents a selectable monitor item in the Monitor ComboBox + /// </summary> + public class MonitorItem + { + public string Id { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + } + + private readonly IEnumerable<MonitorInfo>? _monitors; + private ObservableCollection<VcpValueItem> _availableValues = new(); + private ObservableCollection<MonitorItem> _availableMonitors = new(); + private byte _selectedVcpCode; + private int _selectedValue; + private string _customName = string.Empty; + private bool _canSave; + private bool _showCustomValueInput; + private bool _showMonitorSelector; + private int _customValueParsed; + private bool _applyToAll = true; + private string _selectedMonitorId = string.Empty; + private string _selectedMonitorName = string.Empty; + + public CustomVcpMappingEditorDialog(IEnumerable<MonitorInfo>? monitors) + { + _monitors = monitors; + this.InitializeComponent(); + + // Set localized strings for ContentDialog + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + Title = resourceLoader.GetString("PowerDisplay_CustomMappingEditor_Title"); + PrimaryButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Save"); + CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel"); + + // Set VCP code ComboBox items content dynamically using localized names + VcpCodeItem_0x14.Content = GetFormattedVcpCodeName(resourceLoader, 0x14); + VcpCodeItem_0x60.Content = GetFormattedVcpCodeName(resourceLoader, 0x60); + + // Populate monitor list + PopulateMonitorList(); + + // Default to Color Temperature (0x14) + VcpCodeComboBox.SelectedIndex = 0; + } + + /// <summary> + /// Gets the result mapping after dialog closes with Primary button + /// </summary> + public CustomVcpValueMapping? ResultMapping { get; private set; } + + /// <summary> + /// Gets the available values for the selected VCP code + /// </summary> + public ObservableCollection<VcpValueItem> AvailableValues + { + get => _availableValues; + private set + { + _availableValues = value; + OnPropertyChanged(); + } + } + + /// <summary> + /// Gets the available monitors for selection + /// </summary> + public ObservableCollection<MonitorItem> AvailableMonitors + { + get => _availableMonitors; + private set + { + _availableMonitors = value; + OnPropertyChanged(); + } + } + + /// <summary> + /// Gets a value indicating whether the dialog can be saved + /// </summary> + public bool CanSave + { + get => _canSave; + private set + { + if (_canSave != value) + { + _canSave = value; + OnPropertyChanged(); + } + } + } + + /// <summary> + /// Gets a value indicating whether to show the custom value input TextBox + /// </summary> + public Visibility ShowCustomValueInput => _showCustomValueInput ? Visibility.Visible : Visibility.Collapsed; + + /// <summary> + /// Gets a value indicating whether to show the monitor selector ComboBox + /// </summary> + public Visibility ShowMonitorSelector => _showMonitorSelector ? Visibility.Visible : Visibility.Collapsed; + + private void SetShowCustomValueInput(bool value) + { + if (_showCustomValueInput != value) + { + _showCustomValueInput = value; + OnPropertyChanged(nameof(ShowCustomValueInput)); + } + } + + private void SetShowMonitorSelector(bool value) + { + if (_showMonitorSelector != value) + { + _showMonitorSelector = value; + OnPropertyChanged(nameof(ShowMonitorSelector)); + } + } + + private void PopulateMonitorList() + { + AvailableMonitors = new ObservableCollection<MonitorItem>( + _monitors?.Select(m => new MonitorItem { Id = m.Id, DisplayName = m.DisplayName }) + ?? Enumerable.Empty<MonitorItem>()); + + if (AvailableMonitors.Count > 0) + { + MonitorComboBox.SelectedIndex = 0; + } + } + + /// <summary> + /// Pre-fill the dialog with existing mapping data for editing + /// </summary> + public void PreFillMapping(CustomVcpValueMapping mapping) + { + if (mapping is null) + { + return; + } + + // Select the VCP code + VcpCodeComboBox.SelectedIndex = mapping.VcpCode == 0x14 ? 0 : 1; + + // Populate values for the selected VCP code + PopulateValuesForVcpCode(mapping.VcpCode); + + // Try to select the value in the ComboBox + var matchingItem = AvailableValues.FirstOrDefault(v => !v.IsCustomOption && v.Value == mapping.Value); + if (matchingItem is not null) + { + ValueComboBox.SelectedItem = matchingItem; + } + else + { + // Value not found in list, select "Custom value" option and fill the TextBox + ValueComboBox.SelectedItem = AvailableValues.FirstOrDefault(v => v.IsCustomOption); + CustomValueTextBox.Text = $"0x{mapping.Value:X2}"; + _customValueParsed = mapping.Value; + } + + // Set the custom name + CustomNameTextBox.Text = mapping.CustomName; + _customName = mapping.CustomName; + + // Set apply scope + _applyToAll = mapping.ApplyToAll; + ApplyToAllToggle.IsOn = mapping.ApplyToAll; + SetShowMonitorSelector(!mapping.ApplyToAll); + + // Select the target monitor if not applying to all + if (!mapping.ApplyToAll && !string.IsNullOrEmpty(mapping.TargetMonitorId)) + { + var targetMonitor = AvailableMonitors.FirstOrDefault(m => m.Id == mapping.TargetMonitorId); + if (targetMonitor is not null) + { + MonitorComboBox.SelectedItem = targetMonitor; + _selectedMonitorId = targetMonitor.Id; + _selectedMonitorName = targetMonitor.DisplayName; + } + } + + UpdateCanSave(); + } + + private void VcpCodeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (VcpCodeComboBox.SelectedItem is ComboBoxItem selectedItem && + selectedItem.Tag is string tagValue && + byte.TryParse(tagValue, out byte vcpCode)) + { + _selectedVcpCode = vcpCode; + PopulateValuesForVcpCode(vcpCode); + UpdateCanSave(); + } + } + + private void PopulateValuesForVcpCode(byte vcpCode) + { + var values = new ObservableCollection<VcpValueItem>(); + var seenValues = new HashSet<int>(); + + // Collect values from all monitors + if (_monitors is not null) + { + foreach (var monitor in _monitors) + { + if (monitor.VcpCodesFormatted is null) + { + continue; + } + + // Find the VCP code entry + var vcpEntry = monitor.VcpCodesFormatted.FirstOrDefault(v => + !string.IsNullOrEmpty(v.Code) && + TryParseHexCode(v.Code, out int code) && + code == vcpCode); + + if (vcpEntry?.ValueList is null) + { + continue; + } + + // Add each value from this monitor + foreach (var valueInfo in vcpEntry.ValueList) + { + if (TryParseHexCode(valueInfo.Value, out int vcpValue) && !seenValues.Contains(vcpValue)) + { + seenValues.Add(vcpValue); + var displayName = !string.IsNullOrEmpty(valueInfo.Name) + ? $"{valueInfo.Name} (0x{vcpValue:X2})" + : VcpNames.GetFormattedValueName(vcpCode, vcpValue); + values.Add(new VcpValueItem + { + Value = vcpValue, + DisplayName = displayName, + }); + } + } + } + } + + // If no values found from monitors, fall back to built-in values from VcpNames + if (values.Count == 0) + { + var builtInValues = VcpNames.GetValueMappings(vcpCode); + if (builtInValues is not null) + { + foreach (var kvp in builtInValues) + { + values.Add(new VcpValueItem + { + Value = kvp.Key, + DisplayName = $"{kvp.Value} (0x{kvp.Key:X2})", + }); + } + } + } + + // Sort by value + var sortedValues = new ObservableCollection<VcpValueItem>(values.OrderBy(v => v.Value)); + + // Add "Custom value" option at the end + var resourceLoader = ResourceLoaderInstance.ResourceLoader; + sortedValues.Add(new VcpValueItem + { + Value = CustomValueMarker, + DisplayName = resourceLoader.GetString("PowerDisplay_CustomMappingEditor_CustomValueOption"), + }); + + AvailableValues = sortedValues; + + // Select first item if available + if (sortedValues.Count > 0) + { + ValueComboBox.SelectedIndex = 0; + } + } + + private static bool TryParseHexCode(string? hex, out int result) + { + result = 0; + if (string.IsNullOrEmpty(hex)) + { + return false; + } + + var cleanHex = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex; + return int.TryParse(cleanHex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out result); + } + + private static string GetFormattedVcpCodeName(Windows.ApplicationModel.Resources.ResourceLoader resourceLoader, byte vcpCode) + { + var resourceKey = $"PowerDisplay_VcpCode_Name_0x{vcpCode:X2}"; + var localizedName = resourceLoader.GetString(resourceKey); + var name = string.IsNullOrEmpty(localizedName) ? VcpNames.GetCodeName(vcpCode) : localizedName; + return $"{name} (0x{vcpCode:X2})"; + } + + private void ValueComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ValueComboBox.SelectedItem is VcpValueItem selectedItem) + { + SetShowCustomValueInput(selectedItem.IsCustomOption); + _selectedValue = selectedItem.IsCustomOption ? 0 : selectedItem.Value; + UpdateCanSave(); + } + } + + private void CustomValueTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + _customValueParsed = TryParseHexCode(CustomValueTextBox.Text?.Trim(), out int parsed) ? parsed : 0; + UpdateCanSave(); + } + + private void CustomNameTextBox_TextChanged(object sender, TextChangedEventArgs e) + { + _customName = CustomNameTextBox.Text?.Trim() ?? string.Empty; + UpdateCanSave(); + } + + private void ApplyToAllToggle_Toggled(object sender, RoutedEventArgs e) + { + _applyToAll = ApplyToAllToggle.IsOn; + SetShowMonitorSelector(!_applyToAll); + UpdateCanSave(); + } + + private void MonitorComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (MonitorComboBox.SelectedItem is MonitorItem selectedMonitor) + { + _selectedMonitorId = selectedMonitor.Id; + _selectedMonitorName = selectedMonitor.DisplayName; + UpdateCanSave(); + } + } + + private void UpdateCanSave() + { + var hasValidValue = _showCustomValueInput + ? _customValueParsed > 0 + : ValueComboBox.SelectedItem is VcpValueItem item && !item.IsCustomOption; + + CanSave = _selectedVcpCode > 0 && + hasValidValue && + !string.IsNullOrWhiteSpace(_customName) && + (_applyToAll || !string.IsNullOrEmpty(_selectedMonitorId)); + } + + private void ContentDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + if (CanSave) + { + int finalValue = _showCustomValueInput ? _customValueParsed : _selectedValue; + ResultMapping = new CustomVcpValueMapping + { + VcpCode = _selectedVcpCode, + Value = finalValue, + CustomName = _customName, + ApplyToAll = _applyToAll, + TargetMonitorId = _applyToAll ? string.Empty : _selectedMonitorId, + TargetMonitorName = _applyToAll ? string.Empty : _selectedMonitorName, + }; + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml index b48ddf66c8..745cddb22b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml @@ -1,11 +1,13 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.DashboardPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:Lib="using:Microsoft.PowerToys.Settings.UI.Library" + xmlns:controlConverters="using:Microsoft.PowerToys.Settings.UI.Controls.Converters" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" @@ -13,493 +15,278 @@ AutomationProperties.LandmarkType="Main" DataContext="DashboardViewModel" mc:Ignorable="d"> - - <Page.Resources> - <DataTemplate x:Key="KeyVisualTemplate"> - <controls:KeyVisual - VerticalAlignment="Center" - AutomationProperties.AccessibilityView="Raw" - Content="{Binding}" - IsTabStop="False" - VisualType="TextOnly" /> - </DataTemplate> - <DataTemplate x:Key="CommaTemplate"> - <StackPanel Background="{ThemeResource SystemFillColorSolidAttentionBackground}"> - <TextBlock - Margin="4,0" - VerticalAlignment="Bottom" - Text="," /> - </StackPanel> - </DataTemplate> - <converters:KeyVisualTemplateSelector - x:Key="KeyVisualTemplateSelector" - CommaTemplate="{StaticResource CommaTemplate}" - KeyVisualTemplate="{StaticResource KeyVisualTemplate}" /> + <local:NavigablePage.Resources> <converters:ModuleItemTemplateSelector x:Key="ModuleItemTemplateSelector" - ButtonTemplate="{StaticResource ModuleItemButtonTemplate}" - KBMTemplate="{StaticResource ModuleItemKBMTemplate}" - ShortcutTemplate="{StaticResource ModuleItemShortcutTemplate}" - TextTemplate="{StaticResource ModuleItemTextTemplate}" /> - <tkconverters:CollectionVisibilityConverter x:Key="CollectionVisibilityConverter" /> - <Style x:Name="KeysListViewContainerStyle" TargetType="ListViewItem"> - <Setter Property="IsTabStop" Value="False" /> - </Style> - <converters:UpdateStateToBoolConverter x:Key="UpdateStateToBoolConverter" /> - <tkconverters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> - <tkconverters:BoolNegationConverter x:Key="BoolNegationConverter" /> - <tkconverters:BoolToVisibilityConverter - x:Key="BoolToInvertedVisibilityConverter" - FalseValue="Visible" - TrueValue="Collapsed" /> - <DataTemplate x:Key="OriginalKeyTemplate" x:DataType="x:String"> - <controls:KeyVisual Content="{Binding}" VisualType="SmallOutline" /> - </DataTemplate> - - <DataTemplate x:Key="RemappedKeyTemplate" x:DataType="x:String"> - <controls:KeyVisual Content="{Binding}" VisualType="Small" /> - </DataTemplate> - - <DataTemplate x:Key="ModuleItemTextTemplate" x:DataType="viewmodels:DashboardModuleTextItem"> - <TextBlock - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Style="{StaticResource CaptionTextBlockStyle}" - Text="{x:Bind Label, Mode=OneWay}" - TextWrapping="WrapWholeWords" /> - </DataTemplate> - - <DataTemplate x:Key="ModuleItemButtonTemplate" x:DataType="viewmodels:DashboardModuleButtonItem"> - <Button - HorizontalAlignment="Stretch" - Click="{x:Bind ButtonClickHandler, Mode=OneWay}" - Content="{x:Bind ButtonTitle}" /> - </DataTemplate> - + ActivationTemplate="{StaticResource ModuleItemActivationTemplate}" + ShortcutTemplate="{StaticResource ModuleItemShortcutTemplate}" /> + <controlConverters:EnumToBooleanConverter x:Key="EnumToBooleanConverter" /> + <converters:EnumToModuleListSortOptionConverter x:Key="EnumToModuleListSortOptionConverter" /> <DataTemplate x:Key="ModuleItemShortcutTemplate" x:DataType="viewmodels:DashboardModuleShortcutItem"> - <Grid ColumnSpacing="12"> + <Grid MinHeight="36" ColumnSpacing="12"> <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" MinWidth="140" /> </Grid.ColumnDefinitions> - <Border - Padding="8,4" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="{StaticResource ControlCornerRadius}"> - <ItemsControl - AutomationProperties.AccessibilityView="Raw" - IsTabStop="False" - ItemsSource="{x:Bind Shortcut, Mode=TwoWay}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Orientation="Horizontal" Spacing="12" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate> - <controls:KeyVisual - VerticalAlignment="Center" - AutomationProperties.AccessibilityView="Raw" - Content="{Binding}" - IsTabStop="False" - VisualType="TextOnly" /> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </Border> - - <TextBlock + <ItemsControl Grid.Column="1" + HorizontalAlignment="Left" + AutomationProperties.AccessibilityView="Raw" + IsTabStop="False" + ItemsSource="{x:Bind Shortcut, Mode=OneWay}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <StackPanel Orientation="Horizontal" Spacing="4" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + <ItemsControl.ItemTemplate> + <DataTemplate> + <controls:KeyVisual + VerticalAlignment="Center" + AutomationProperties.AccessibilityView="Raw" + Content="{Binding}" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + IsTabStop="False" + RenderKeyAsGlyph="True" /> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + <TextBlock VerticalAlignment="Center" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind Label, Mode=OneWay}" TextWrapping="WrapWholeWords" /> </Grid> </DataTemplate> - <DataTemplate x:Key="ModuleItemKBMTemplate" x:DataType="viewmodels:DashboardModuleKBMItem"> - <Button x:Uid="DashboardKBMShowMappingsButton" HorizontalAlignment="Stretch"> - <Button.Flyout> - <Flyout - x:Name="DetailsFlyout" - Placement="Bottom" - ShouldConstrainToRootBounds="False"> - <StackPanel Orientation="Vertical" Spacing="4"> - <ItemsControl ItemsSource="{x:Bind RemapKeys, Mode=OneWay}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Spacing="4" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate x:DataType="Lib:KeysDataModel"> - <StackPanel Orientation="Horizontal"> - <Border - Padding="8,4" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="{StaticResource ControlCornerRadius}"> - <ItemsControl - AutomationProperties.AccessibilityView="Raw" - IsTabStop="False" - ItemsSource="{x:Bind GetMappedOriginalKeys()}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Orientation="Horizontal" Spacing="12" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate> - <controls:KeyVisual - VerticalAlignment="Center" - AutomationProperties.AccessibilityView="Raw" - Content="{Binding}" - IsTabStop="False" - VisualType="TextOnly" /> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </Border> - <controls:IsEnabledTextBlock - x:Uid="To" - Margin="8,0,8,0" - VerticalAlignment="Center" - Style="{StaticResource SecondaryIsEnabledTextBlockStyle}" /> - <Border - Padding="8,4" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource AccentFillColorDefaultBrush}" - BorderThickness="1" - CornerRadius="{StaticResource ControlCornerRadius}"> - <ItemsControl - AutomationProperties.AccessibilityView="Raw" - IsTabStop="False" - ItemsSource="{x:Bind GetMappedNewRemapKeys(15)}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Orientation="Horizontal" Spacing="12" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate> - <controls:KeyVisual - VerticalAlignment="Center" - AutomationProperties.AccessibilityView="Raw" - Content="{Binding}" - FontSize="12" - Foreground="{ThemeResource AccentFillColorDefaultBrush}" - IsTabStop="False" - VisualType="TextOnly" /> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </Border> - </StackPanel> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - <ItemsControl ItemsSource="{x:Bind RemapShortcuts, Mode=OneWay}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Spacing="4" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate x:DataType="Lib:AppSpecificKeysDataModel"> - <StackPanel Orientation="Horizontal"> - <Border - Padding="8,0" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="{StaticResource ControlCornerRadius}"> - <ItemsControl - AutomationProperties.AccessibilityView="Raw" - IsTabStop="False" - ItemTemplateSelector="{StaticResource KeyVisualTemplateSelector}" - ItemsSource="{x:Bind GetMappedOriginalKeysWithSplitChord()}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Orientation="Horizontal" Spacing="12" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - </ItemsControl> - </Border> - <controls:IsEnabledTextBlock - x:Uid="To" - Margin="8,0,8,0" - VerticalAlignment="Center" - Style="{StaticResource SecondaryIsEnabledTextBlockStyle}" - Visibility="{x:Bind Path=IsOpenUriOrIsRunProgram, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" /> - <controls:IsEnabledTextBlock - x:Uid="Starts" - Margin="8,0,8,0" - VerticalAlignment="Center" - Style="{StaticResource SecondaryIsEnabledTextBlockStyle}" - Visibility="{x:Bind Path=IsOpenUriOrIsRunProgram, Mode=OneWay}" /> - <Border - Padding="8,4" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource AccentFillColorDefaultBrush}" - BorderThickness="1" - CornerRadius="{StaticResource ControlCornerRadius}"> - <ItemsControl - AutomationProperties.AccessibilityView="Raw" - IsTabStop="False" - ItemsSource="{x:Bind GetMappedNewRemapKeys(15)}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Orientation="Horizontal" Spacing="12" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - <ItemsControl.ItemTemplate> - <DataTemplate> - <controls:KeyVisual - VerticalAlignment="Center" - AutomationProperties.AccessibilityView="Raw" - Content="{Binding}" - Foreground="{ThemeResource AccentFillColorDefaultBrush}" - IsTabStop="False" - VisualType="TextOnly" /> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </Border> - <TextBlock - Margin="4,0,0,0" - VerticalAlignment="Center" - Foreground="{ThemeResource AccentFillColorDefaultBrush}" - Text="{x:Bind TargetApp}" /> - </StackPanel> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - </StackPanel> - </Flyout> - </Button.Flyout> - </Button> + <DataTemplate x:Key="ModuleItemActivationTemplate" x:DataType="viewmodels:DashboardModuleActivationItem"> + <Grid MinHeight="36" ColumnSpacing="12"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" MinWidth="140" /> + </Grid.ColumnDefinitions> + <TextBlock + VerticalAlignment="Center" + Text="{x:Bind Label, Mode=OneWay}" + TextWrapping="WrapWholeWords" /> + <TextBlock + Grid.Column="1" + VerticalAlignment="Center" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind Activation, Mode=OneWay}" + TextWrapping="WrapWholeWords" /> + </Grid> </DataTemplate> - </Page.Resources> - <Grid Margin="16,0,0,0" RowSpacing="24"> + </local:NavigablePage.Resources> + <Grid Margin="16,0,0,0" RowSpacing="12"> <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> <RowDefinition Height="Auto" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> <TextBlock x:Uid="DashboardTitle" + MaxWidth="{StaticResource PageMaxWidth}" + Margin="1,0,0,0" VerticalAlignment="Center" + AutomationProperties.HeadingLevel="1" Style="{StaticResource TitleTextBlockStyle}" /> - - <InfoBar - x:Uid="UpdateAvailable" - Margin="0,0,16,0" - HorizontalAlignment="Right" - VerticalAlignment="Center" - CornerRadius="8" - IsClosable="False" - IsOpen="{x:Bind ViewModel.UpdateAvailable, Mode=OneWay}" - Severity="Informational"> - <InfoBar.ActionButton> - <Button x:Uid="LearnMore" Click="SWVersionButtonClicked" /> - </InfoBar.ActionButton> - </InfoBar> - - <ScrollViewer x:Name="MainScrollViewer" Grid.Row="1"> - <StackPanel Padding="0,0,16,16" Orientation="Vertical"> - <TextBlock - x:Uid="EnabledModules" - Margin="0,0,0,12" - Style="{StaticResource SubtitleTextBlockStyle}" /> - <ItemsRepeater x:Name="DashboardView" ItemsSource="{x:Bind ViewModel.ActiveModules, Mode=OneWay}"> - <ItemsRepeater.Layout> - <tkcontrols:StaggeredLayout - ColumnSpacing="8" - DesiredColumnWidth="378" - RowSpacing="8" /> - </ItemsRepeater.Layout> - <ItemsRepeater.ItemTemplate> - <DataTemplate x:DataType="viewmodels:DashboardListItem"> - <Button - Padding="0" - HorizontalAlignment="Stretch" - HorizontalContentAlignment="Stretch" - AutomationProperties.Name="{x:Bind Label}" - Background="Transparent" - BorderThickness="0" - Click="DashboardListItemClick" - CornerRadius="{StaticResource OverlayCornerRadius}" - Tag="{x:Bind Tag, Mode=OneWay}"> - <Grid - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="{StaticResource OverlayCornerRadius}" - RowSpacing="0"> - <Grid.RowDefinitions> - <RowDefinition /> - <RowDefinition /> - </Grid.RowDefinitions> - <Grid Margin="16,8,16,0" ColumnSpacing="12"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - <Image - Grid.Column="0" - Width="20" - Margin="0,0,0,0"> - <Image.Source> - <BitmapImage UriSource="{x:Bind Icon, Mode=OneWay}" /> - </Image.Source> - </Image> - <StackPanel - Grid.Column="1" - VerticalAlignment="Center" - Orientation="Horizontal"> - <TextBlock - VerticalAlignment="Center" - FontWeight="SemiBold" - Text="{x:Bind Label, Mode=OneWay}" - TextTrimming="CharacterEllipsis" /> - <InfoBadge - Margin="4,0,0,0" - Style="{StaticResource NewInfoBadge}" - Visibility="{x:Bind IsNew, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" /> - </StackPanel> - <FontIcon - Grid.Column="2" - Width="20" - Margin="0,0,-12,0" - FontSize="16" - Glyph="" - Visibility="{x:Bind IsLocked, Converter={StaticResource BoolToInvertedVisibilityConverter}, ConverterParameter=True, Mode=OneWay}"> - <ToolTipService.ToolTip> - <TextBlock x:Uid="GPO_SettingIsManaged_ToolTip" TextWrapping="WrapWholeWords" /> - </ToolTipService.ToolTip> - </FontIcon> - <ToggleSwitch - x:Uid="Enable_Module" - Grid.Column="3" - Margin="0,-2,0,0" - HorizontalAlignment="Right" - AutomationProperties.HelpText="{x:Bind Label}" - IsEnabled="{x:Bind IsLocked, Converter={StaticResource BoolNegationConverter}, ConverterParameter=True, Mode=OneWay}" - IsOn="{x:Bind IsEnabled, Mode=TwoWay}" - OffContent="" - OnContent="" - Style="{StaticResource RightAlignedCompactToggleSwitchStyle}" /> - </Grid> - - <ItemsControl - Grid.Row="1" - Margin="16,8,16,16" - IsTabStop="False" - ItemTemplateSelector="{StaticResource ModuleItemTemplateSelector}" - ItemsSource="{x:Bind DashboardModuleItems, Mode=OneWay}" - Visibility="{x:Bind IsEnabled, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> - <ItemsControl.ItemsPanel> - <ItemsPanelTemplate> - <StackPanel Spacing="4" /> - </ItemsPanelTemplate> - </ItemsControl.ItemsPanel> - </ItemsControl> - </Grid> - </Button> - </DataTemplate> - </ItemsRepeater.ItemTemplate> - </ItemsRepeater> - - <TextBlock - x:Uid="DisabledModules" - Margin="0,24,0,12" - Style="{StaticResource SubtitleTextBlockStyle}" /> - - <ItemsRepeater ItemsSource="{x:Bind ViewModel.DisabledModules, Mode=OneWay}"> - <ItemsRepeater.Layout> - <tkcontrols:StaggeredLayout - ColumnSpacing="8" - DesiredColumnWidth="378" - RowSpacing="8" /> - </ItemsRepeater.Layout> - <ItemsRepeater.ItemTemplate> - <DataTemplate x:DataType="viewmodels:DashboardListItem"> - <Button - Padding="0" - HorizontalAlignment="Stretch" - HorizontalContentAlignment="Stretch" - AutomationProperties.Name="{x:Bind Label}" - Background="Transparent" - BorderThickness="0" - Click="DashboardListItemClick" - CornerRadius="{StaticResource OverlayCornerRadius}" - Tag="{x:Bind Tag, Mode=OneWay}"> - <Grid - Padding="16,12" - Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" - BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" - BorderThickness="1" - CornerRadius="{StaticResource OverlayCornerRadius}" - RowSpacing="12"> - <Grid ColumnSpacing="12"> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - <Image Grid.Column="0" Width="20"> - <Image.Source> - <BitmapImage UriSource="{x:Bind Icon, Mode=OneWay}" /> - </Image.Source> - </Image> - <StackPanel - Grid.Column="1" - VerticalAlignment="Center" - Orientation="Horizontal"> - <TextBlock - VerticalAlignment="Center" - FontWeight="SemiBold" - Text="{x:Bind Label, Mode=OneWay}" - TextTrimming="CharacterEllipsis" /> - <InfoBadge - Margin="4,0,0,0" - Style="{StaticResource NewInfoBadge}" - Visibility="{x:Bind IsNew, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}" /> - </StackPanel> - <FontIcon - Grid.Column="2" - Width="20" - Margin="0,0,-12,0" - FontSize="16" - Glyph="" - Visibility="{x:Bind IsLocked, Converter={StaticResource BoolToInvertedVisibilityConverter}, ConverterParameter=True, Mode=OneWay}"> - <ToolTipService.ToolTip> - <TextBlock x:Uid="GPO_SettingIsManaged_ToolTip" TextWrapping="WrapWholeWords" /> - </ToolTipService.ToolTip> - </FontIcon> - <ToggleSwitch - x:Uid="Enable_Module" - Grid.Column="3" - Margin="0,-2,0,0" - HorizontalAlignment="Right" - AutomationProperties.HelpText="{x:Bind Label}" - IsEnabled="{x:Bind IsLocked, Converter={StaticResource BoolNegationConverter}, ConverterParameter=True, Mode=OneWay}" - IsOn="{x:Bind IsEnabled, Mode=TwoWay}" - OffContent="" - OnContent="" - Style="{StaticResource RightAlignedCompactToggleSwitchStyle}" /> - </Grid> - </Grid> - </Button> - </DataTemplate> - </ItemsRepeater.ItemTemplate> - </ItemsRepeater> + <Grid + Grid.Row="1" + MaxWidth="{StaticResource PageMaxWidth}" + Padding="0,0,20,0" + ColumnSpacing="16"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <Button + Padding="0,0,8,0" + AutomationProperties.Name="WhatsNewButton" + Click="WhatsNewButton_Click" + Style="{StaticResource SubtleButtonStyle}"> + <Grid ColumnSpacing="16"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Grid CornerRadius="{StaticResource OverlayCornerRadius}"> + <Image + Width="120" + AutomationProperties.AccessibilityView="Raw" + Source="ms-appx:///Assets/Settings/Modules/PT.png" /> + <Grid Background="{ThemeResource SmokeFillColorDefaultBrush}" /> + </Grid> + <StackPanel + Grid.Column="1" + VerticalAlignment="Center" + Orientation="Vertical"> + <TextBlock x:Uid="LearnWhatsNew" FontWeight="SemiBold" /> + <TextBlock + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{x:Bind ViewModel.PowerToysVersion, Mode=OneWay}" /> + </StackPanel> + </Grid> + </Button> + <StackPanel + x:Name="TopButtonPanel" + Grid.Column="1" + Orientation="Horizontal" + Spacing="16"> + <controls:ShortcutConflictControl AllHotkeyConflictsData="{x:Bind ViewModel.AllHotkeyConflictsData, Mode=OneWay}" /> + <controls:CheckUpdateControl /> </StackPanel> - + </Grid> + <ScrollViewer x:Name="MainScrollViewer" Grid.Row="2"> + <Grid> + <!-- This grid is required to ensure that the content is horizontally aligned --> + <Grid + MaxWidth="{StaticResource PageMaxWidth}" + Padding="0,0,20,48" + ColumnSpacing="16" + RowSpacing="16"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <controls:Card x:Uid="QuickAccessTitle" VerticalAlignment="Top"> + <Grid> + <controls:QuickAccessList + x:Name="QuickAccessItemsControl" + Margin="8,0,12,12" + ItemsSource="{x:Bind ViewModel.QuickAccessItems, Mode=OneWay}" + Visibility="{x:Bind ViewModel.QuickAccessItems.Count, Mode=OneWay, Converter={StaticResource DoubleToVisibilityConverter}}" /> + <TextBlock + x:Uid="NoActionsToShow" + Margin="12" + HorizontalAlignment="Left" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Visibility="{x:Bind ViewModel.QuickAccessItems.Count, Mode=OneWay, Converter={StaticResource DoubleToInvertedVisibilityConverter}}" /> + </Grid> + </controls:Card> + <controls:Card + x:Uid="ShortcutsOverview" + Grid.Row="1" + VerticalAlignment="Top"> + <Grid> + <ItemsRepeater + Grid.Row="2" + Margin="8,0,0,0" + ItemsSource="{x:Bind ViewModel.ShortcutModules, Mode=OneWay}"> + <ItemsRepeater.Layout> + <StackLayout Orientation="Vertical" Spacing="0" /> + </ItemsRepeater.Layout> + <ItemsRepeater.ItemTemplate> + <DataTemplate x:DataType="viewmodels:DashboardListItem"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="32" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Image + Width="16" + Margin="0,10,0,0" + HorizontalAlignment="Left" + VerticalAlignment="Top" + AutomationProperties.AccessibilityView="Raw" + Source="{x:Bind Icon, Mode=OneWay}" + ToolTipService.ToolTip="{x:Bind Label}" /> + <ItemsControl + Grid.Column="1" + IsTabStop="False" + ItemTemplateSelector="{StaticResource ModuleItemTemplateSelector}" + ItemsSource="{x:Bind DashboardModuleItems, Mode=OneWay}"> + <ItemsControl.ItemsPanel> + <ItemsPanelTemplate> + <StackPanel Spacing="0" /> + </ItemsPanelTemplate> + </ItemsControl.ItemsPanel> + </ItemsControl> + </Grid> + </DataTemplate> + </ItemsRepeater.ItemTemplate> + </ItemsRepeater> + <TextBlock + x:Uid="NoShortcutsToShow" + Margin="12" + HorizontalAlignment="Left" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Visibility="{x:Bind ViewModel.ShortcutModules.Count, Mode=OneWay, Converter={StaticResource DoubleToInvertedVisibilityConverter}}" /> + </Grid> + </controls:Card> + <controls:Card + x:Uid="UtilitiesHeader" + Grid.RowSpan="2" + Grid.Column="1" + MinWidth="400" + Padding="0" + VerticalAlignment="Top" + DividerVisibility="Collapsed"> + <controls:Card.TitleContent> + <Button + x:Uid="Dashboard_SortBy" + Margin="0,0,4,0" + VerticalAlignment="Center" + Style="{StaticResource SubtleButtonStyle}"> + <ToolTipService.ToolTip> + <TextBlock x:Uid="Dashboard_SortBy_ToolTip" /> + </ToolTipService.ToolTip> + <Button.Content> + <FontIcon FontSize="16" Glyph="" /> + </Button.Content> + <Button.Flyout> + <MenuFlyout> + <ToggleMenuFlyoutItem + x:Uid="Dashboard_SortAlphabetical" + Click="SortAlphabetical_Click" + IsChecked="{x:Bind ViewModel.DashboardSortOrder, Mode=OneWay, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=Alphabetical}" /> + <ToggleMenuFlyoutItem + x:Uid="Dashboard_SortByStatus" + Click="SortByStatus_Click" + IsChecked="{x:Bind ViewModel.DashboardSortOrder, Mode=OneWay, Converter={StaticResource EnumToBooleanConverter}, ConverterParameter=ByStatus}" /> + </MenuFlyout> + </Button.Flyout> + </Button> + </controls:Card.TitleContent> + <controls:ModuleList + x:Name="ModulesCard" + Grid.Row="1" + ItemsSource="{x:Bind ViewModel.AllModules, Mode=OneWay}" + SortOption="{x:Bind ViewModel.DashboardSortOrder, Mode=TwoWay, Converter={StaticResource EnumToModuleListSortOptionConverter}}" /> + </controls:Card> + </Grid> + </Grid> </ScrollViewer> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup> + <VisualState> + <VisualState.StateTriggers> + <AdaptiveTrigger MinWindowWidth="840" /> + </VisualState.StateTriggers> + </VisualState> + <VisualState> + <VisualState.StateTriggers> + <AdaptiveTrigger MinWindowWidth="0" /> + </VisualState.StateTriggers> + <VisualState.Setters> + <Setter Target="TopButtonPanel.(Grid.Row)" Value="1" /> + <Setter Target="TopButtonPanel.Margin" Value="0,16,0,0" /> + <Setter Target="TopButtonPanel.(Grid.Column)" Value="0" /> + <Setter Target="ModulesCard.(Grid.Column)" Value="0" /> + <Setter Target="ModulesCard.(Grid.Row)" Value="2" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> </Grid> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs index 394b1d6de6..3e4d122379 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs @@ -5,11 +5,9 @@ using System; using System.Threading; using System.Threading.Tasks; - using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.OOBE.Views; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -20,7 +18,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views /// <summary> /// Dashboard Settings Page. /// </summary> - public sealed partial class DashboardPage : Page, IRefreshablePage + public sealed partial class DashboardPage : NavigablePage, IRefreshablePage { /// <summary> /// Gets or sets view model. @@ -34,11 +32,14 @@ namespace Microsoft.PowerToys.Settings.UI.Views public DashboardPage() { InitializeComponent(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new DashboardViewModel( SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; + + Loaded += (s, e) => ViewModel.OnPageLoaded(); + Unloaded += (s, e) => ViewModel?.Dispose(); } public void RefreshEnabledState() @@ -46,14 +47,27 @@ namespace Microsoft.PowerToys.Settings.UI.Views ViewModel.ModuleEnabledChangedOnSettingsPage(); } - private void SWVersionButtonClicked(object sender, RoutedEventArgs e) + private void WhatsNewButton_Click(object sender, RoutedEventArgs e) { - ViewModel.SWVersionButtonClicked(); + ((App)App.Current)!.OpenScoobe(); } - private void DashboardListItemClick(object sender, RoutedEventArgs e) + private void SortAlphabetical_Click(object sender, RoutedEventArgs e) { - ViewModel.DashboardListItemClick(sender); + ViewModel.DashboardSortOrder = DashboardSortOrder.Alphabetical; + if (sender is ToggleMenuFlyoutItem item) + { + item.IsChecked = true; + } + } + + private void SortByStatus_Click(object sender, RoutedEventArgs e) + { + ViewModel.DashboardSortOrder = DashboardSortOrder.ByStatus; + if (sender is ToggleMenuFlyoutItem item) + { + item.IsChecked = true; + } } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml index 595710c0c4..5cabb81f61 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.EnvironmentVariablesPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -12,30 +13,24 @@ <controls:SettingsPageControl x:Uid="EnvironmentVariables" ModuleImageSource="ms-appx:///Assets/Settings/Modules/EnvironmentVariables.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="EnvironmentVariables_EnableToggleControl_HeaderText" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/EnvironmentVariables.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="EnvironmentVariablesEnableToggleControlHeaderText" + x:Uid="EnvironmentVariables_EnableToggleControl_HeaderText" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/EnvironmentVariables.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="EnvironmentVariables_Activation_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsCard + Name="EnvironmentVariablesLaunchButtonControl" x:Uid="EnvironmentVariables_LaunchButtonControl" ActionIcon="{ui:FontIcon Glyph=}" Command="{x:Bind ViewModel.LaunchEventHandler}" HeaderIcon="{ui:FontIcon Glyph=}" IsClickEnabled="True" /> <tkcontrols:SettingsCard + Name="EnvironmentVariablesToggleLaunchAdministrator" x:Uid="EnvironmentVariables_Toggle_LaunchAdministrator" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.LaunchAdministratorEnabled, Mode=OneWay}"> @@ -49,4 +44,4 @@ <controls:PageLink x:Uid="LearnMore_EnvironmentVariables" Link="https://aka.ms/PowerToysOverview_EnvironmentVariables" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml.cs index 66c80810c3..a3e17ac491 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/EnvironmentVariablesPage.xaml.cs @@ -9,14 +9,14 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class EnvironmentVariablesPage : Page, IRefreshablePage + public sealed partial class EnvironmentVariablesPage : NavigablePage, IRefreshablePage { private EnvironmentVariablesViewModel ViewModel { get; } public EnvironmentVariablesPage() { InitializeComponent(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new EnvironmentVariablesViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<EnvironmentVariablesSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, App.IsElevated); } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml index 0e178a93d0..0c6963add8 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.FancyZonesPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -13,36 +14,38 @@ <controls:SettingsPageControl x:Uid="FancyZones" ModuleImageSource="ms-appx:///Assets/Settings/Modules/FancyZones.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="FancyZones_EnableToggleControl_HeaderText" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FancyZones.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FancyZonesEnableToggleControlHeaderText" + x:Uid="FancyZones_EnableToggleControl_HeaderText" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FancyZones.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.AutomationId="EnableFancyZonesToggleSwitch" + IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="FancyZones_Editor_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsCard + Name="FancyZonesLaunchEditorButtonControl" x:Uid="FancyZones_LaunchEditorButtonControl" ActionIcon="{ui:FontIcon Glyph=}" + AutomationProperties.AutomationId="LaunchLayoutEditorButton" Command="{x:Bind ViewModel.LaunchEditorEventHandler}" HeaderIcon="{ui:FontIcon Glyph=}" IsClickEnabled="True" /> - <tkcontrols:SettingsCard x:Uid="Activation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="ActivationShortcut" + x:Uid="Activation_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.EditorHotkey, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FancyZones_UseCursorPosEditorStartupScreen" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="FancyZonesUseCursorPosEditorStartupScreen" + x:Uid="FancyZones_UseCursorPosEditorStartupScreen" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.UseCursorPosEditorStartupScreen, Mode=TwoWay, Converter={StaticResource BoolToComboBoxIndexConverter}}"> <ComboBoxItem x:Uid="FancyZones_LaunchPositionScreen" /> <ComboBoxItem x:Uid="FancyZones_LaunchPositionMouse" /> @@ -55,24 +58,27 @@ x:Name="ZonesSettingsGroup" x:Uid="FancyZones_Zones" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsExpander x:Uid="FancyZones_ZoneBehavior_GroupSettings" IsExpanded="True"> + <tkcontrols:SettingsExpander + Name="FancyZonesZoneBehaviorGroupSettings" + x:Uid="FancyZones_ZoneBehavior_GroupSettings" + IsExpanded="True"> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesShiftDragCheckBoxControlHeader" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_ShiftDragCheckBoxControl_Header" IsChecked="{x:Bind ViewModel.ShiftDrag, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesMouseDragCheckBoxControlHeader" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_MouseDragCheckBoxControl_Header" IsChecked="{x:Bind ViewModel.MouseSwitch, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesMouseMiddleClickSpanningMultipleZonesCheckBoxControlHeader" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_MouseMiddleClickSpanningMultipleZonesCheckBoxControl_Header" IsChecked="{x:Bind ViewModel.MouseMiddleClickSpanningMultipleZones, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesShowZonesOnAllMonitorsCheckBoxControl" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_ShowZonesOnAllMonitorsCheckBoxControl" IsChecked="{x:Bind ViewModel.ShowOnAllMonitors, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesSpanZonesAcrossMonitors" ContentAlignment="Left"> <controls:CheckBoxWithDescriptionControl x:Uid="FancyZones_SpanZonesAcrossMonitors" IsChecked="{x:Bind ViewModel.SpanZonesAcrossMonitors, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FancyZones_OverlappingZones"> + <tkcontrols:SettingsCard Name="FancyZonesOverlappingZones" x:Uid="FancyZones_OverlappingZones"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.OverlappingZonesAlgorithmIndex, Mode=TwoWay}"> <ComboBoxItem x:Uid="FancyZones_OverlappingZonesSmallest" /> <ComboBoxItem x:Uid="FancyZones_OverlappingZonesLargest" /> @@ -84,6 +90,7 @@ </tkcontrols:SettingsExpander> <tkcontrols:SettingsExpander + Name="FancyZonesZoneAppearance" x:Uid="FancyZones_Zone_Appearance" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> @@ -92,7 +99,7 @@ <ComboBoxItem x:Uid="FancyZones_Radio_Default_Theme" /> </ComboBox> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesPreviewCard" ContentAlignment="Left"> <controls:FancyZonesPreviewControl Width="192" Height="108" @@ -104,10 +111,10 @@ IsSystemTheme="{x:Bind ViewModel.SystemTheme, Mode=OneWay}" ShowZoneNumber="{x:Bind Path=ViewModel.ShowZoneNumber, Mode=OneWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesShowZoneNumberCheckBoxControl" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_ShowZoneNumberCheckBoxControl" IsChecked="{x:Bind ViewModel.ShowZoneNumber, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FancyZones_HighlightOpacity"> + <tkcontrols:SettingsCard Name="FancyZonesHighlightOpacity" x:Uid="FancyZones_HighlightOpacity"> <Slider MinWidth="{StaticResource SettingActionControlMinWidth}" Maximum="100" @@ -115,16 +122,28 @@ Value="{x:Bind ViewModel.HighlightOpacity, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FancyZones_ZoneHighlightColor" Visibility="{x:Bind ViewModel.SystemTheme, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> + <tkcontrols:SettingsCard + Name="FancyZonesZoneHighlightColor" + x:Uid="FancyZones_ZoneHighlightColor" + Visibility="{x:Bind ViewModel.SystemTheme, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> <controls:ColorPickerButton SelectedColor="{x:Bind Path=ViewModel.ZoneHighlightColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FancyZones_InActiveColor" Visibility="{x:Bind ViewModel.SystemTheme, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> + <tkcontrols:SettingsCard + Name="FancyZonesInActiveColor" + x:Uid="FancyZones_InActiveColor" + Visibility="{x:Bind ViewModel.SystemTheme, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> <controls:ColorPickerButton x:Name="InActiveColorButton" SelectedColor="{x:Bind Path=ViewModel.ZoneInActiveColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FancyZones_BorderColor" Visibility="{x:Bind ViewModel.SystemTheme, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> + <tkcontrols:SettingsCard + Name="FancyZonesBorderColor" + x:Uid="FancyZones_BorderColor" + Visibility="{x:Bind ViewModel.SystemTheme, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> <controls:ColorPickerButton SelectedColor="{x:Bind Path=ViewModel.ZoneBorderColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FancyZones_NumberColor" Visibility="{x:Bind ViewModel.SystemTheme, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> + <tkcontrols:SettingsCard + Name="FancyZonesNumberColor" + x:Uid="FancyZones_NumberColor" + Visibility="{x:Bind ViewModel.SystemTheme, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> <controls:ColorPickerButton SelectedColor="{x:Bind Path=ViewModel.ZoneNumberColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> @@ -133,59 +152,79 @@ <controls:SettingsGroup x:Uid="FancyZones_Windows" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsExpander x:Uid="FancyZones_WindowBehavior_GroupSettings" IsExpanded="True"> + <tkcontrols:SettingsExpander + Name="FancyZonesWindowBehaviorGroupSettings" + x:Uid="FancyZones_WindowBehavior_GroupSettings" + IsExpanded="True"> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesDisplayOrWorkAreaChangeMoveWindowsCheckBoxControl" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_DisplayOrWorkAreaChangeMoveWindowsCheckBoxControl" IsChecked="{x:Bind ViewModel.DisplayOrWorkAreaChangeMoveWindows, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesZoneSetChangeMoveWindows" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_ZoneSetChangeMoveWindows" IsChecked="{x:Bind ViewModel.ZoneSetChangeMoveWindows, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesAppLastZoneMoveWindows" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_AppLastZoneMoveWindows" IsChecked="{x:Bind ViewModel.AppLastZoneMoveWindows, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesOpenWindowOnActiveMonitor" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_OpenWindowOnActiveMonitor" IsChecked="{x:Bind ViewModel.OpenWindowOnActiveMonitor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesRestoreSize" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_RestoreSize" IsChecked="{x:Bind ViewModel.RestoreSize, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesMakeDraggedWindowTransparentCheckBoxControl" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_MakeDraggedWindowTransparentCheckBoxControl" IsChecked="{x:Bind ViewModel.MakeDraggedWindowsTransparent, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left"> + <tkcontrols:SettingsCard Name="FancyZonesAllowChildWindowSnap" ContentAlignment="Left"> <CheckBox x:Uid="FancyZones_AllowChildWindowSnap" IsChecked="{x:Bind ViewModel.AllowChildWindowSnap, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left" Visibility="{x:Bind ViewModel.Windows11, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <tkcontrols:SettingsCard + Name="FancyZonesDisableRoundCornersOnWindowSnap" + ContentAlignment="Left" + Visibility="{x:Bind ViewModel.Windows11, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> <CheckBox x:Uid="FancyZones_DisableRoundCornersOnWindowSnap" IsChecked="{x:Bind ViewModel.DisableRoundCornersOnWindowSnap, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> <tkcontrols:SettingsExpander + Name="FancyZonesWindowSwitchingGroupSettings" x:Uid="FancyZones_WindowSwitching_GroupSettings" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.WindowSwitching, Mode=TwoWay}" /> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="FancyZonesWindowSwitchingToggle" + IsOn="{x:Bind ViewModel.WindowSwitching, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> <!-- HACK: For some weird reason, a Shortcut Control is not working correctly if it's the first item in the expander, so we add an invisible card as the first one. --> - <tkcontrols:SettingsCard Visibility="Collapsed" /> - <tkcontrols:SettingsCard x:Uid="FancyZones_HotkeyNextTabControl" IsEnabled="{x:Bind ViewModel.WindowSwitchingCategoryEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard Name="FancyZonesWindowSwitchingPlaceholder" Visibility="Collapsed" /> + <tkcontrols:SettingsCard + Name="FancyZonesHotkeyNextTabControl" + x:Uid="FancyZones_HotkeyNextTabControl" + IsEnabled="{x:Bind ViewModel.WindowSwitchingCategoryEnabled, Mode=OneWay}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.NextTabHotkey, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FancyZones_HotkeyPrevTabControl" IsEnabled="{x:Bind ViewModel.WindowSwitchingCategoryEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FancyZonesHotkeyPrevTabControl" + x:Uid="FancyZones_HotkeyPrevTabControl" + IsEnabled="{x:Bind ViewModel.WindowSwitchingCategoryEnabled, Mode=OneWay}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.PrevTabHotkey, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> <tkcontrols:SettingsExpander + Name="FancyZonesOverrideSnapHotkeys" x:Uid="FancyZones_OverrideSnapHotkeys" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.OverrideSnapHotkeys, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="FancyZones_MoveWindow" IsEnabled="{x:Bind ViewModel.SnapHotkeysCategoryEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FancyZonesMoveWindow" + x:Uid="FancyZones_MoveWindow" + IsEnabled="{x:Bind ViewModel.SnapHotkeysCategoryEnabled, Mode=OneWay}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" MinHeight="56" @@ -208,7 +247,10 @@ </ComboBoxItem> </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind ViewModel.SnapHotkeysCategoryEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FancyZonesMoveWindowsAcrossAllMonitorsCheckBoxControl" + ContentAlignment="Left" + IsEnabled="{x:Bind ViewModel.SnapHotkeysCategoryEnabled, Mode=OneWay}"> <CheckBox x:Uid="FancyZones_MoveWindowsAcrossAllMonitorsCheckBoxControl" IsChecked="{x:Bind ViewModel.MoveWindowsAcrossMonitors, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> @@ -216,10 +258,19 @@ </controls:SettingsGroup> <controls:SettingsGroup x:Uid="FancyZones_Layouts" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsExpander x:Uid="FancyZones_QuickLayoutSwitch" HeaderIcon="{ui:FontIcon Glyph=}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.QuickLayoutSwitch, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander + Name="FancyZonesQuickLayoutSwitch" + x:Uid="FancyZones_QuickLayoutSwitch" + HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="FancyZonesQuickLayoutSwitch" + IsOn="{x:Bind ViewModel.QuickLayoutSwitch, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind ViewModel.QuickSwitchEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FancyZonesFlashZonesOnQuickSwitch" + ContentAlignment="Left" + IsEnabled="{x:Bind ViewModel.QuickSwitchEnabled, Mode=OneWay}"> <CheckBox x:Uid="FancyZones_FlashZonesOnQuickSwitch" IsChecked="{x:Bind ViewModel.FlashZonesOnQuickSwitch, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> @@ -229,11 +280,15 @@ <controls:SettingsGroup x:Uid="ExcludedApps" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander + Name="FancyZonesExcludeApps" x:Uid="FancyZones_ExcludeApps" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard HorizontalContentAlignment="Stretch" ContentAlignment="Vertical"> + <tkcontrols:SettingsCard + Name="FancyZonesExcludeAppsTextBoxControl" + HorizontalContentAlignment="Stretch" + ContentAlignment="Vertical"> <TextBox x:Uid="FancyZones_ExcludeApps_TextBoxControl" MinWidth="240" @@ -255,4 +310,4 @@ <controls:PageLink x:Uid="LearnMore_FancyZones" Link="https://aka.ms/PowerToysOverview_FancyZones" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> \ No newline at end of file +</local:NavigablePage> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs index c224c42683..ddf7c1e8ee 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FancyZonesPage.xaml.cs @@ -9,16 +9,17 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class FancyZonesPage : Page, IRefreshablePage + public sealed partial class FancyZonesPage : NavigablePage, IRefreshablePage { private FancyZonesViewModel ViewModel { get; set; } public FancyZonesPage() { InitializeComponent(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new FancyZonesViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<FancyZonesSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void OpenColorsSettings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml index 033337dc01..0d800cf1d4 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.FileLocksmithPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -13,26 +14,17 @@ <controls:SettingsPageControl x:Uid="FileLocksmith" ModuleImageSource="ms-appx:///Assets/Settings/Modules/FileLocksmith.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="FileLocksmith_Enable_FileLocksmith" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FileLocksmith.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch IsOn="{x:Bind ViewModel.IsFileLocksmithEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FileLocksmithEnableFileLocksmith" + x:Uid="FileLocksmith_Enable_FileLocksmith" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FileLocksmith.png}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.IsFileLocksmithEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="FileLocksmith_ShellIntegration" IsEnabled="{x:Bind ViewModel.IsFileLocksmithEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="FileLocksmith_Toggle_ContextMenu"> + <tkcontrols:SettingsCard Name="FileLocksmithToggleContextMenu" x:Uid="FileLocksmith_Toggle_ContextMenu"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.EnabledOnContextExtendedMenu, Mode=TwoWay, Converter={StaticResource BoolToComboBoxIndexConverter}}"> <ComboBoxItem x:Uid="FileLocksmith_Toggle_StandardContextMenu" /> <ComboBoxItem x:Uid="FileLocksmith_Toggle_ExtendedContextMenu" /> @@ -51,4 +43,4 @@ <controls:PageLink x:Uid="LearnMore_FileLocksmith" Link="https://aka.ms/PowerToysOverview_FileLocksmith" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml.cs index 4d4ffbf416..8f78ce2c8c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/FileLocksmithPage.xaml.cs @@ -9,13 +9,13 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class FileLocksmithPage : Page, IRefreshablePage + public sealed partial class FileLocksmithPage : NavigablePage, IRefreshablePage { private FileLocksmithViewModel ViewModel { get; set; } public FileLocksmithPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new FileLocksmithViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml index fcd06e8292..e4fcf03bb3 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml @@ -1,20 +1,21 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.GeneralPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - <Page.Resources> + <local:NavigablePage.Resources> <converters:UpdateStateToBoolConverter x:Key="UpdateStateToBoolConverter" /> <converters:StringToInfoBarSeverityConverter x:Key="StringToInfoBarSeverityConverter" /> - </Page.Resources> + </local:NavigablePage.Resources> <controls:SettingsPageControl x:Uid="General" ModuleImageSource="ms-appx:///Assets/Settings/Modules/PT.png"> <controls:SettingsPageControl.ModuleContent> @@ -47,7 +48,6 @@ FontWeight="SemiBold" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> </StackPanel> - <Button x:Uid="GeneralPage_CheckForUpdates" HorizontalAlignment="Right" @@ -142,7 +142,6 @@ </InfoBar.ActionButton> </InfoBar> - <!-- Ready to install --> <InfoBar x:Uid="General_NewVersionReadyToInstall" @@ -210,6 +209,7 @@ <controls:SettingsGroup x:Uid="Admin_Mode"> <tkcontrols:SettingsExpander + Name="AdminModeRunningAs" x:Uid="Admin_Mode_Running_As" Header="{x:Bind ViewModel.RunningAsText, Mode=OneWay}" HeaderIcon="{ui:FontIcon Glyph=}" @@ -233,10 +233,14 @@ </controls:SettingsGroup> <controls:SettingsGroup x:Uid="Appearance_Behavior" IsEnabled="True"> - <tkcontrols:SettingsCard x:Uid="LanguageHeader" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="LanguageHeader" + x:Uid="LanguageHeader" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox x:Name="Languages_ComboBox" MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.Name="{Binding ElementName=LanguageHeader, Path=Header}" DisplayMemberPath="Language" ItemsSource="{Binding Languages, Mode=TwoWay}" SelectedIndex="{Binding LanguagesIndex, Mode=TwoWay}" /> @@ -252,45 +256,87 @@ </InfoBar.ActionButton> </InfoBar> - <tkcontrols:SettingsCard x:Uid="ColorModeHeader" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="ColorModeHeader" + x:Uid="ColorModeHeader" + HeaderIcon="{ui:FontIcon Glyph=}"> <tkcontrols:SettingsCard.Description> <HyperlinkButton x:Uid="Windows_Color_Settings" Click="OpenColorsSettings_Click" /> </tkcontrols:SettingsCard.Description> - <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.ThemeIndex, Mode=TwoWay}"> + <ComboBox + MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.Name="{Binding ElementName=ColorModeHeader, Path=Header}" + SelectedIndex="{x:Bind ViewModel.ThemeIndex, Mode=TwoWay}"> <ComboBoxItem x:Uid="Radio_Theme_Dark" /> <ComboBoxItem x:Uid="Radio_Theme_Light" /> <ComboBoxItem x:Uid="Radio_Theme_Default" /> </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="GeneralPage_RunAtStartUp" IsEnabled="{x:Bind ViewModel.IsRunAtStartupGPOManaged, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.Startup, Mode=TwoWay}" /> + <tkcontrols:SettingsCard + Name="GeneralPageRunAtStartUp" + x:Uid="GeneralPage_RunAtStartUp" + IsEnabled="{x:Bind ViewModel.IsRunAtStartupGPOManaged, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=GeneralPageRunAtStartUp, Path=Header}" + IsOn="{x:Bind ViewModel.Startup, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - BorderThickness="0" - CornerRadius="0" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsRunAtStartupGPOManaged, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsRunAtStartupGPOManaged, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsRunAtStartupGPOManaged, Mode=OneWay}"> + <tkcontrols:SettingsExpander x:Name="ShowSystemTrayIconCard" x:Uid="ShowSystemTrayIcon"> + <ToggleSwitch + x:Uid="ShowSystemTrayIcon_ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=ShowSystemTrayIconCard, Path=Header}" + IsOn="{x:Bind ViewModel.ShowSysTrayIcon, Mode=TwoWay}" + Toggled="ShowSystemTrayIcon_Toggled" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard + x:Name="ShowThemeAdaptiveTrayIconCard" + ContentAlignment="Left" + IsEnabled="{x:Bind ViewModel.ShowSysTrayIcon, Mode=OneWay}"> + <CheckBox x:Uid="ShowThemeAdaptiveTrayIcon" IsChecked="{x:Bind ViewModel.ShowThemeAdaptiveTrayIcon, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + </controls:GPOInfoControl> + + <tkcontrols:SettingsExpander + Name="GeneralPageEnableQuickAccess" + x:Uid="GeneralPage_EnableQuickAccess" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=GeneralPageEnableQuickAccess, Path=Header}" + IsOn="{x:Bind ViewModel.EnableQuickAccess, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. --> + <tkcontrols:SettingsCard Visibility="Collapsed" /> + <tkcontrols:SettingsCard + Name="QuickAccessShortcut" + x:Uid="GeneralPage_QuickAccessShortcut" + IsEnabled="{x:Bind ViewModel.EnableQuickAccess, Mode=OneWay}"> + <controls:ShortcutControl HotkeySettings="{x:Bind ViewModel.QuickAccessShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Visibility="Collapsed" /> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="General_SettingsBackupAndRestoreTitle" Visibility="Visible"> - <tkcontrols:SettingsExpander x:Uid="General_SettingsBackupAndRestore" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsExpander + Name="GeneralSettingsBackupAndRestore" + x:Uid="General_SettingsBackupAndRestore" + HeaderIcon="{ui:FontIcon Glyph=}"> <StackPanel Orientation="Horizontal" Spacing="8"> <Button x:Uid="General_SettingsBackupAndRestore_ButtonBackup" Command="{Binding BackupConfigsEventHandler}" /> <Button x:Uid="General_SettingsBackupAndRestore_ButtonRestore" Command="{Binding RestoreConfigsEventHandler}" /> </StackPanel> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="General_SettingsBackupAndRestoreLocationText"> + <tkcontrols:SettingsCard Name="GeneralSettingsBackupAndRestoreLocationText" x:Uid="General_SettingsBackupAndRestoreLocationText"> <Grid ColumnSpacing="8"> <Grid.ColumnDefinitions> - <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> <TextBlock @@ -321,6 +367,7 @@ </Grid> </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="GeneralSettingsBackupAndRestoreStatusInfo" x:Uid="General_SettingsBackupAndRestoreStatusInfo" HorizontalContentAlignment="Left" ContentAlignment="Vertical"> @@ -384,73 +431,105 @@ IsTabStop="{x:Bind ViewModel.SettingsBackupRestoreMessageVisible, Mode=OneWay}" Severity="{x:Bind ViewModel.BackupRestoreMessageSeverity, Converter={StaticResource StringToInfoBarSeverityConverter}}" /> <controls:SettingsGroup x:Uid="General_Experimentation" Visibility="Visible"> - <tkcontrols:SettingsCard x:Uid="GeneralPage_EnableExperimentation" IsEnabled="{x:Bind ViewModel.IsExperimentationGpoDisallowed, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <tkcontrols:SettingsCard.HeaderIcon> - <PathIcon Data="M1859 1758q14 23 21 47t7 51q0 40-15 75t-41 61-61 41-75 15H354q-40 0-75-15t-61-41-41-61-15-75q0-27 6-51t21-47l569-992q10-14 10-34V128H640V0h768v128h-128v604q0 19 10 35l569 991zM896 732q0 53-27 99l-331 577h972l-331-577q-27-46-27-99V128H896v604zm799 1188q26 0 44-19t19-45q0-10-2-17t-8-16l-164-287H464l-165 287q-9 15-9 33 0 26 18 45t46 19h1341z" /> - </tkcontrols:SettingsCard.HeaderIcon> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.EnableExperimentation, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsExperimentationGpoDisallowed, Mode=OneWay}"> + <tkcontrols:SettingsCard Name="GeneralPageEnableExperimentation" x:Uid="GeneralPage_EnableExperimentation"> + <tkcontrols:SettingsCard.HeaderIcon> + <PathIcon Data="M1859 1758q14 23 21 47t7 51q0 40-15 75t-41 61-61 41-75 15H354q-40 0-75-15t-61-41-41-61-15-75q0-27 6-51t21-47l569-992q10-14 10-34V128H640V0h768v128h-128v604q0 19 10 35l569 991zM896 732q0 53-27 99l-331 577h972l-331-577q-27-46-27-99V128H896v604zm799 1188q26 0 44-19t19-45q0-10-2-17t-8-16l-164-287H464l-165 287q-9 15-9 33 0 26 18 45t46 19h1341z" /> + </tkcontrols:SettingsCard.HeaderIcon> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=GeneralPageEnableExperimentation, Path=Header}" + IsOn="{x:Bind ViewModel.EnableExperimentation, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> + </controls:SettingsGroup> + <controls:SettingsGroup x:Uid="General_DiagnosticsAndFeedback"> + <tkcontrols:SettingsExpander + x:Name="GeneralPageEnableDataDiagnostics" + x:Uid="GeneralPage_EnableDataDiagnostics" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.IsDataDiagnosticsGPOManaged, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" + IsExpanded="True"> + <tkcontrols:SettingsExpander.Description> + <StackPanel Orientation="Vertical"> + <TextBlock + x:Uid="GeneralPage_EnableDataDiagnosticsText" + Style="{StaticResource SecondaryTextStyle}" + TextWrapping="WrapWholeWords" /> + <HyperlinkButton + x:Uid="GeneralPage_DiagnosticsAndFeedback_Link" + Margin="0,2,0,0" + FontWeight="SemiBold" + NavigateUri="https://aka.ms/powertoys-data-and-privacy-documentation" /> + </StackPanel> + </tkcontrols:SettingsExpander.Description> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=GeneralPageEnableDataDiagnostics, Path=Header}" + IsOn="{x:Bind ViewModel.EnableDataDiagnostics, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard + x:Name="GeneralPageEnableViewDiagnosticData" + x:Uid="GeneralPage_EnableViewDiagnosticData" + IsEnabled="{x:Bind ViewModel.EnableDataDiagnostics, Mode=TwoWay}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=GeneralPageEnableViewDiagnosticData, Path=Header}" + IsOn="{x:Bind ViewModel.EnableViewDataDiagnostics, Mode=TwoWay}" /> + <tkcontrols:SettingsCard.Description> + <StackPanel Orientation="Vertical"> + <TextBlock + x:Uid="GeneralPage_EnableViewDiagnosticDataText" + Style="{StaticResource SecondaryTextStyle}" + TextWrapping="WrapWholeWords" /> + <HyperlinkButton + x:Uid="GeneralPage_ViewDiagnosticDataButton" + Margin="0,2,0,0" + Click="ViewDiagnosticData_Click" + FontWeight="SemiBold" /> + </StackPanel> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsExpander.ItemsFooter> + <InfoBar + x:Uid="GeneralPage_ViewDiagnosticDataViewerInfo" + BorderThickness="0" + CornerRadius="0,0,4,4" + IsClosable="False" + IsOpen="{x:Bind Mode=OneWay, Path=ViewModel.ViewDiagnosticDataViewerChanged}" + IsTabStop="{x:Bind Mode=OneWay, Path=ViewModel.ViewDiagnosticDataViewerChanged}" + Severity="Informational"> + <InfoBar.ActionButton> + <Button x:Uid="GeneralPage_ViewDiagnosticDataViewerInfoButton" Click="Click_ViewDiagnosticDataViewerRestart" /> + </InfoBar.ActionButton> + </InfoBar> + </tkcontrols:SettingsExpander.ItemsFooter> + </tkcontrols:SettingsExpander> <InfoBar x:Uid="GPO_SettingIsManaged" IsClosable="False" - IsOpen="{x:Bind ViewModel.IsExperimentationGpoDisallowed, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsExperimentationGpoDisallowed, Mode=OneWay}" + IsOpen="{x:Bind ViewModel.IsDataDiagnosticsGPOManaged, Mode=OneWay}" + IsTabStop="{x:Bind ViewModel.IsDataDiagnosticsGPOManaged, Mode=OneWay}" Severity="Informational"> <InfoBar.IconSource> <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> </InfoBar.IconSource> </InfoBar> - </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="General_DiagnosticsAndFeedback" Visibility="Visible"> - <StackPanel> - <HyperlinkButton - x:Uid="GeneralPage_DiagnosticsAndFeedback_Link" - Margin="-8,0,0,0" - NavigateUri="https://aka.ms/powertoys-data-and-privacy-documentation" /> - <tkcontrols:SettingsCard - x:Uid="GeneralPage_EnableDataDiagnostics" - HeaderIcon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind ViewModel.IsDataDiagnosticsGPOManaged, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.EnableDataDiagnostics, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsDataDiagnosticsGPOManaged, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsDataDiagnosticsGPOManaged, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - <tkcontrols:SettingsExpander - x:Uid="GeneralPage_ViewDiagnosticData" - HeaderIcon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind ViewModel.IsDataDiagnosticsGPOManaged, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" - IsExpanded="True"> - <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="GeneralPage_EnableViewDiagnosticData" IsEnabled="{x:Bind ViewModel.EnableDataDiagnostics, Mode=TwoWay}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.EnableViewDataDiagnostics, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard - x:Uid="GeneralPage_ViewDiagnosticDataViewer" - ActionIcon="{ui:FontIcon Glyph=}" - Click="ViewDiagnosticData_Click" - IsClickEnabled="True" /> - </tkcontrols:SettingsExpander.Items> - </tkcontrols:SettingsExpander> - <InfoBar - x:Uid="GeneralPage_ViewDiagnosticDataViewerInfo" - IsClosable="False" - IsOpen="{x:Bind Mode=OneWay, Path=ViewModel.ViewDiagnosticDataViewerChanged}" - IsTabStop="{x:Bind Mode=OneWay, Path=ViewModel.ViewDiagnosticDataViewerChanged}" - Severity="Informational"> - <InfoBar.ActionButton> - <Button x:Uid="GeneralPage_ViewDiagnosticDataViewerInfoButton" Click="Click_ViewDiagnosticDataViewerRestart" /> - </InfoBar.ActionButton> - </InfoBar> - - </StackPanel> + <tkcontrols:SettingsCard x:Uid="GeneralPage_ReportBugPackage" HeaderIcon="{ui:FontIcon Glyph=}"> + <StackPanel Orientation="Horizontal"> + <Button + x:Uid="GeneralPageReportBugPackage" + Click="BugReportToolClicked" + Visibility="{x:Bind ViewModel.IsBugReportRunning, Converter={StaticResource ReverseBoolToVisibilityConverter}, Mode=OneWay}" /> + <ProgressRing + Width="24" + Height="24" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Visibility="{x:Bind ViewModel.IsBugReportRunning, Mode=OneWay}" /> + </StackPanel> + </tkcontrols:SettingsCard> </controls:SettingsGroup> </StackPanel> @@ -458,7 +537,7 @@ <controls:SettingsPageControl.PrimaryLinks> <controls:PageLink x:Uid="GeneralPage_Documentation" Link="https://aka.ms/PowerToysOverview" /> <controls:PageLink x:Uid="General_Repository" Link="https://aka.ms/powertoys" /> - <controls:PageLink x:Uid="GeneralPage_ReportAbug" Link="https://aka.ms/powerToysReportBug" /> + <controls:PageLink x:Uid="GeneralPage_ReportAbug" Link="{x:Bind ViewModel.ReportBugLink, Mode=OneWay}" /> <controls:PageLink x:Uid="GeneralPage_RequestAFeature_URL" Link="https://aka.ms/powerToysRequestFeature" /> </controls:SettingsPageControl.PrimaryLinks> <controls:SettingsPageControl.SecondaryLinks> @@ -466,4 +545,4 @@ <controls:PageLink x:Uid="OpenSource_Notice" Link="https://github.com/microsoft/PowerToys/blob/main/NOTICE.md" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml.cs index a7b0bcf5dc..6ae52ebb6b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/GeneralPage.xaml.cs @@ -5,20 +5,20 @@ using System; using System.Threading; using System.Threading.Tasks; - using ManagedCommon; 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 Windows.Data.Json; namespace Microsoft.PowerToys.Settings.UI.Views { /// <summary> /// General Settings Page. /// </summary> - public sealed partial class GeneralPage : Page, IRefreshablePage + public sealed partial class GeneralPage : NavigablePage, IRefreshablePage { private static DateTime OkToHideBackupAndRestoreMessageTime { get; set; } @@ -37,7 +37,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views // Load string resources var loader = Helpers.ResourceLoaderInstance.ResourceLoader; - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; Action stateUpdatingAction = () => { @@ -84,7 +84,17 @@ namespace Microsoft.PowerToys.Settings.UI.Views DataContext = ViewModel; + ViewModel.InitializeReportBugLink(); + + // Register IPC handler for bug report status + ShellPage.ShellHandler.IPCResponseHandleList.Add(HandleBugReportStatusResponse); // Register cleanup on unload + this.Unloaded += GeneralPage_Unloaded; + + CheckBugReportStatus(); + doRefreshBackupRestoreStatus(100); + + this.Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void OpenColorsSettings_Click(object sender, RoutedEventArgs e) @@ -163,5 +173,62 @@ namespace Microsoft.PowerToys.Settings.UI.Views { await Task.Run(ViewModel.ViewDiagnosticData); } + + private void BugReportToolClicked(object sender, RoutedEventArgs e) + { + // Start bug report + ShellPage.SendDefaultIPCMessage("{\"bugreport\": 0 }"); + + ViewModel.IsBugReportRunning = true; + + // No need to start timer - the observer pattern will notify us when it finishes + } + + private void CheckBugReportStatus() + { + // Send one-time request to check current bug report status + string ipcMessage = "{ \"bug_report_status\": { } }"; + ShellPage.SendDefaultIPCMessage(ipcMessage); + } + + private void HandleBugReportStatusResponse(JsonObject response) + { + if (response.ContainsKey("bug_report_running")) + { + var isRunning = response.GetNamedBoolean("bug_report_running"); + + // Update UI on the UI thread + this.DispatcherQueue.TryEnqueue(() => + { + ViewModel.IsBugReportRunning = isRunning; + }); + } + } + + private void GeneralPage_Unloaded(object sender, RoutedEventArgs e) + { + CleanupBugReportHandlers(); + } + + private void CleanupBugReportHandlers() + { + // Remove IPC handler + if (ShellPage.ShellHandler?.IPCResponseHandleList != null) + { + ShellPage.ShellHandler.IPCResponseHandleList.Remove(HandleBugReportStatusResponse); + } + } + + private void ShowSystemTrayIcon_Toggled(object sender, RoutedEventArgs e) + { + if (sender is ToggleSwitch toggleSwitch) + { + var shellViewModel = ShellPage.ShellHandler?.ViewModel; + if (shellViewModel != null) + { + shellViewModel.ShowCloseMenu = !toggleSwitch.IsOn; + } + } + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml index 2d0a6d0c2d..0e40606095 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml @@ -1,61 +1,83 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.HostsPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" mc:Ignorable="d"> - + <Page.Resources> + <tkconverters:ResourceNameToResourceStringConverter x:Key="ResourceNameToResourceStringConverter" /> + <tkconverters:DoubleToVisibilityConverter + x:Key="CountBasedVisibilityConverter" + FalseValue="Collapsed" + GreaterThan="0" + LessThan="2" + TrueValue="Visible" /> + <tkconverters:DoubleToVisibilityConverter + x:Key="AgeBasedVisibilityConverter" + FalseValue="Collapsed" + GreaterThan="1" + LessThan="3" + TrueValue="Visible" /> + </Page.Resources> <controls:SettingsPageControl x:Uid="Hosts" ModuleImageSource="ms-appx:///Assets/Settings/Modules/HostsFileEditor.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="Hosts_EnableToggleControl_HeaderText" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Hosts.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="HostsEnableToggleControlHeaderText" + x:Uid="Hosts_EnableToggleControl_HeaderText" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Hosts.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="Hosts_Activation_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsCard + Name="HostsLaunchButtonControl" x:Uid="Hosts_LaunchButtonControl" ActionIcon="{ui:FontIcon Glyph=}" Command="{x:Bind ViewModel.LaunchEventHandler}" HeaderIcon="{ui:FontIcon Glyph=}" IsClickEnabled="True" /> <tkcontrols:SettingsCard + Name="HostsToggleLaunchAdministrator" x:Uid="Hosts_Toggle_LaunchAdministrator" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.LaunchAdministratorEnabled, Mode=OneWay}"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.LaunchAdministrator, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="Hosts_Toggle_ShowStartupWarning" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="HostsToggleShowStartupWarning" + x:Uid="Hosts_Toggle_ShowStartupWarning" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.ShowStartupWarning, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="Hosts_Behavior_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="Hosts_AdditionalLinesPosition" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="HostsAdditionalLinesPosition" + x:Uid="Hosts_AdditionalLinesPosition" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.AdditionalLinesPosition, Mode=TwoWay}"> <ComboBoxItem x:Uid="Hosts_AdditionalLinesPosition_Top" /> <ComboBoxItem x:Uid="Hosts_AdditionalLinesPosition_Bottom" /> </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="Hosts_Toggle_LoopbackDuplicates" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="HostsToggleLoopbackDuplicates" + x:Uid="Hosts_Toggle_LoopbackDuplicates" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.LoopbackDuplicates, Mode=TwoWay}" /> </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard x:Uid="Hosts_NoLeadingSpaces" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.NoLeadingSpaces, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> <tkcontrols:SettingsCard x:Uid="Hosts_Encoding"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.Encoding, Mode=TwoWay}"> <ComboBoxItem x:Uid="Hosts_Encoding_Utf8" /> @@ -63,6 +85,91 @@ </ComboBox> </tkcontrols:SettingsCard> </controls:SettingsGroup> + + <controls:SettingsGroup x:Uid="Hosts_Backup_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander + x:Uid="Hosts_Backup" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <ToggleSwitch IsOn="{x:Bind ViewModel.BackupHosts, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard x:Uid="Hosts_Backup_Location" IsEnabled="{x:Bind ViewModel.BackupHosts, Mode=OneWay}"> + <Grid ColumnSpacing="8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <controls:IsEnabledTextBlock + x:Name="pathTextBlock" + Grid.Column="0" + VerticalAlignment="Center" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + IsTextSelectionEnabled="True" + Text="{x:Bind ViewModel.BackupPath, Mode=TwoWay}"> + <ToolTipService.ToolTip> + <ToolTip IsEnabled="{Binding IsTextTrimmed, ElementName=pathTextBlock, Mode=OneWay}"> + <TextBlock Text="{x:Bind ViewModel.BackupPath, Mode=TwoWay}" /> + </ToolTip> + </ToolTipService.ToolTip> + </controls:IsEnabledTextBlock> + <Button + Grid.Column="1" + Command="{x:Bind ViewModel.SelectBackupPathEventHandler}" + Content="" + FontFamily="{ThemeResource SymbolThemeFontFamily}"> + <ToolTipService.ToolTip> + <ToolTip> + <TextBlock x:Uid="Hosts_ButtonSelectLocation" /> + </ToolTip> + </ToolTipService.ToolTip> + </Button> + </Grid> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard x:Uid="Hosts_Delete_Backup" IsEnabled="{x:Bind ViewModel.BackupHosts, Mode=OneWay}"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.DeleteBackupsMode, Mode=TwoWay}"> + <ComboBoxItem x:Uid="Hosts_DeleteBackupMode_Never" /> + <ComboBoxItem x:Uid="Hosts_DeleteBackupMode_CountBased" /> + <ComboBoxItem x:Uid="Hosts_DeleteBackupMode_AgeAndCountBased" /> + </ComboBox> + </tkcontrols:SettingsCard> + + <!-- Count --> + <tkcontrols:SettingsCard + x:Name="BackupsCountInputSettingsCard" + IsEnabled="{x:Bind ViewModel.BackupHosts, Mode=OneWay}" + Visibility="{x:Bind ViewModel.DeleteBackupsMode, Converter={StaticResource CountBasedVisibilityConverter}, Mode=OneWay}"> + <NumberBox + MinWidth="{StaticResource SettingActionControlMinWidth}" + Minimum="{x:Bind ViewModel.MinimumBackupsCount, Mode=OneWay}" + SpinButtonPlacementMode="Compact" + Value="{x:Bind ViewModel.DeleteBackupsCount, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + <!-- Age and count --> + <tkcontrols:SettingsCard + x:Uid="Hosts_Backup_DaysInput" + IsEnabled="{x:Bind ViewModel.BackupHosts, Mode=OneWay}" + Visibility="{x:Bind ViewModel.DeleteBackupsMode, Converter={StaticResource AgeBasedVisibilityConverter}, Mode=OneWay}"> + <NumberBox + MinWidth="{StaticResource SettingActionControlMinWidth}" + Minimum="1" + SpinButtonPlacementMode="Compact" + Value="{x:Bind ViewModel.DeleteBackupsDays, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + x:Name="BackupsCountInputAgeSettingsCard" + IsEnabled="{x:Bind ViewModel.BackupHosts, Mode=OneWay}" + Visibility="{x:Bind ViewModel.DeleteBackupsMode, Converter={StaticResource AgeBasedVisibilityConverter}, Mode=OneWay}"> + <NumberBox + MinWidth="{StaticResource SettingActionControlMinWidth}" + Minimum="{x:Bind ViewModel.MinimumBackupsCount, Mode=OneWay}" + SpinButtonPlacementMode="Compact" + Value="{x:Bind ViewModel.DeleteBackupsCount, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + </controls:SettingsGroup> </StackPanel> </controls:SettingsPageControl.ModuleContent> @@ -70,4 +177,4 @@ <controls:PageLink x:Uid="LearnMore_Hosts" Link="https://aka.ms/PowerToysOverview_HostsFileEditor" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs index b74f03df44..b65f32d520 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/HostsPage.xaml.cs @@ -9,15 +9,19 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class HostsPage : Page, IRefreshablePage + public sealed partial class HostsPage : NavigablePage, IRefreshablePage { private HostsViewModel ViewModel { get; } public HostsPage() { InitializeComponent(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new HostsViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<HostsSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, App.IsElevated); + BackupsCountInputSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Description"); + BackupsCountInputAgeSettingsCard.Header = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Header"); + BackupsCountInputAgeSettingsCard.Description = ResourceLoaderInstance.ResourceLoader.GetString("Hosts_Backup_CountInput_Age_Description"); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml index 2db21e91ad..8620acbd9d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml @@ -1,19 +1,20 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.ImageResizerPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" - xmlns:toolkitconverters="using:CommunityToolkit.WinUI.UI.Converters" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" x:Name="RootPage" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - <Page.Resources> + <local:NavigablePage.Resources> <converters:ImageResizerFitToStringConverter x:Key="ImageResizerFitToStringConverter" /> <converters:ImageResizerFitToIntConverter x:Key="ImageResizerFitToIntConverter" /> <converters:ImageResizerUnitToStringConverter x:Key="ImageResizerUnitToStringConverter" /> @@ -22,34 +23,27 @@ <converters:ImageResizerDoubleToAutoConverter x:Key="ImageResizerDoubleToAutoConverter" /> <converters:ImageResizerNumberBoxValueConverter x:Key="ImageResizerNumberBoxValueConverter" /> <converters:ImageResizerZeroToEmptyStringNumberFormatter x:Key="ImageResizerZeroToEmptyStringNumberFormatter" /> - <toolkitconverters:BoolToObjectConverter + <tkconverters:BoolToObjectConverter x:Key="BoolToComboBoxIndexConverter" FalseValue="1" TrueValue="0" /> - </Page.Resources> + </local:NavigablePage.Resources> <controls:SettingsPageControl x:Uid="ImageResizer" ModuleImageSource="ms-appx:///Assets/Settings/Modules/ImageResizer.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}"> - <tkcontrols:SettingsCard - x:Uid="ImageResizer_EnableToggle" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ImageResizer.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="ImageResizerEnableToggle" + x:Uid="ImageResizer_EnableToggle" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ImageResizer.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="ImageResizer_CustomSizes" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ImageResizer_Presets" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="ImageResizerPresets" + x:Uid="ImageResizer_Presets" + HeaderIcon="{ui:FontIcon Glyph=}"> <Button x:Uid="ImageResizer_AddSizeButton" Click="AddSizeButton_Click" @@ -192,7 +186,7 @@ </controls:SettingsGroup> <controls:SettingsGroup x:Uid="Encoding" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ImageResizer_FallBackEncoderText"> + <tkcontrols:SettingsCard Name="ImageResizerFallBackEncoderText" x:Uid="ImageResizer_FallBackEncoderText"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.Encoder, Mode=TwoWay}"> <ComboBoxItem x:Uid="ImageResizer_FallbackEncoder_PNG" /> <ComboBoxItem x:Uid="ImageResizer_FallbackEncoder_BMP" /> @@ -203,7 +197,7 @@ </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ImageResizer_Encoding"> + <tkcontrols:SettingsCard Name="ImageResizerEncoding" x:Uid="ImageResizer_Encoding"> <Slider MinWidth="{StaticResource SettingActionControlMinWidth}" Maximum="100" @@ -211,7 +205,7 @@ Value="{x:Bind ViewModel.JPEGQualityLevel, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ImageResizer_PNGInterlacing"> + <tkcontrols:SettingsCard Name="ImageResizerPNGInterlacing" x:Uid="ImageResizer_PNGInterlacing"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.PngInterlaceOption, Mode=TwoWay}"> <ComboBoxItem x:Uid="Default" /> <ComboBoxItem x:Uid="On" /> @@ -219,7 +213,7 @@ </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ImageResizer_TIFFCompression"> + <tkcontrols:SettingsCard Name="ImageResizerTIFFCompression" x:Uid="ImageResizer_TIFFCompression"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.TiffCompressOption, Mode=TwoWay}"> <ComboBoxItem x:Uid="ImageResizer_ENCODER_TIFF_Default" /> <ComboBoxItem x:Uid="ImageResizer_ENCODER_TIFF_None" /> @@ -233,7 +227,7 @@ </controls:SettingsGroup> <controls:SettingsGroup x:Uid="File" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ImageResizer_FilenameFormatHeader"> + <tkcontrols:SettingsCard Name="ImageResizerFilenameFormatHeader" x:Uid="ImageResizer_FilenameFormatHeader"> <StackPanel Orientation="Horizontal" Spacing="4"> <TextBox x:Uid="ImageResizer_FilenameFormatPlaceholder" @@ -280,7 +274,7 @@ </StackPanel> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ImageResizer_FileModifiedDate"> + <tkcontrols:SettingsCard Name="ImageResizerFileModifiedDate" x:Uid="ImageResizer_FileModifiedDate"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.KeepDateModified, Mode=TwoWay, Converter={StaticResource ReverseBoolToComboBoxIndexConverter}}"> <ComboBoxItem x:Uid="ImageResizer_UseOriginalDate" /> <ComboBoxItem x:Uid="ImageResizer_UseResizeDate" /> @@ -298,4 +292,4 @@ <controls:PageLink Link="https://github.com/bricelam/ImageResizer/" Text="Brice Lambson's ImageResizer" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> \ No newline at end of file +</local:NavigablePage> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml.cs index a059a26b2c..6a2068d5a8 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ImageResizerPage.xaml.cs @@ -14,14 +14,14 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class ImageResizerPage : Page, IRefreshablePage + public sealed partial class ImageResizerPage : NavigablePage, IRefreshablePage { public ImageResizerViewModel ViewModel { get; set; } public ImageResizerPage() { InitializeComponent(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; var resourceLoader = ResourceLoaderInstance.ResourceLoader; Func<string, string> loader = resourceLoader.GetString; diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml index f6e7a3fddb..bfa2b2495c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml @@ -1,10 +1,11 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.KeyboardManagerPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:Lib="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" @@ -12,24 +13,25 @@ AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - <Page.Resources> + <local:NavigablePage.Resources> <tkconverters:CollectionVisibilityConverter x:Key="CollectionVisibilityConverter" /> - <tkconverters:BoolToVisibilityConverter - x:Key="BoolToInvertedVisibilityConverter" - FalseValue="Visible" - TrueValue="Collapsed" /> - <tkconverters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" /> - <Style x:Name="KeysListViewContainerStyle" TargetType="ListViewItem"> <Setter Property="IsTabStop" Value="False" /> </Style> <DataTemplate x:Key="OriginalKeyTemplate" x:DataType="x:String"> - <controls:KeyVisual Content="{Binding}" VisualType="SmallOutline" /> + <controls:KeyVisual + Padding="8" + Content="{Binding}" + CornerRadius="{StaticResource ControlCornerRadius}" /> </DataTemplate> <DataTemplate x:Key="RemappedKeyTemplate" x:DataType="x:String"> - <controls:KeyVisual Content="{Binding}" VisualType="Small" /> + <controls:KeyVisual + Padding="8" + Content="{Binding}" + CornerRadius="{StaticResource ControlCornerRadius}" + Style="{StaticResource AccentKeyVisualStyle}" /> </DataTemplate> <!--<DataTemplate x:Name="KeysListViewTemplate" x:DataType="Lib:KeysDataModel"> @@ -49,35 +51,28 @@ Height="56"> </DataTemplate>--> - </Page.Resources> + </local:NavigablePage.Resources> <controls:SettingsPageControl x:Uid="KeyboardManager" ModuleImageSource="ms-appx:///Assets/Settings/Modules/KBM.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="KeyboardManager_EnableToggle" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/KeyboardManager.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.Enabled, Mode=TwoWay}" /> - <tkcontrols:SettingsCard.Description> - <HyperlinkButton NavigateUri="https://aka.ms/powerToysCannotRemapKeys"> - <TextBlock x:Uid="KBM_KeysCannotBeRemapped" FontWeight="SemiBold" /> - </HyperlinkButton> - </tkcontrols:SettingsCard.Description> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="KeyboardManagerEnableToggle" + x:Uid="KeyboardManager_EnableToggle" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/KeyboardManager.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.Enabled, Mode=TwoWay}" /> + <tkcontrols:SettingsCard.Description> + <HyperlinkButton NavigateUri="https://aka.ms/powerToysCannotRemapKeys"> + <TextBlock x:Uid="KBM_KeysCannotBeRemapped" FontWeight="SemiBold" /> + </HyperlinkButton> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="KeyboardManager_Keys" IsEnabled="{x:Bind ViewModel.Enabled, Mode=OneWay}"> <tkcontrols:SettingsCard + Name="KeyboardManagerRemapKeyboardButton" x:Uid="KeyboardManager_RemapKeyboardButton" ActionIcon="{ui:FontIcon Glyph=}" Command="{Binding Path=RemapKeyboardCommand}" @@ -131,6 +126,7 @@ <controls:SettingsGroup x:Uid="KeyboardManager_Shortcuts" IsEnabled="{x:Bind ViewModel.Enabled, Mode=OneWay}"> <tkcontrols:SettingsCard + Name="KeyboardManagerRemapShortcutsButton" x:Uid="KeyboardManager_RemapShortcutsButton" ActionIcon="{ui:FontIcon Glyph=}" Command="{Binding Path=EditShortcutCommand}" @@ -184,7 +180,7 @@ Margin="8,0,8,0" VerticalAlignment="Center" Style="{StaticResource SecondaryIsEnabledTextBlockStyle}" - Visibility="{x:Bind Path=IsOpenUriOrIsRunProgram, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" /> + Visibility="{x:Bind Path=IsOpenUriOrIsRunProgram, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}" /> <controls:IsEnabledTextBlock x:Uid="Starts" @@ -230,4 +226,4 @@ <controls:PageLink x:Uid="LearnMore_KBM" Link="https://aka.ms/PowerToysOverview_KeyboardManager" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml.cs index c7a84eb5d0..fce4dfc718 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/KeyboardManagerPage.xaml.cs @@ -18,7 +18,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views /// <summary> /// An empty page that can be used on its own or navigated to within a Frame. /// </summary> - public sealed partial class KeyboardManagerPage : Page, IRefreshablePage + public sealed partial class KeyboardManagerPage : NavigablePage, IRefreshablePage { private const string PowerToyName = "Keyboard Manager"; @@ -28,7 +28,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views public KeyboardManagerPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new KeyboardManagerViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, FilterRemapKeysList); watcher = Helper.GetFileWatcher( diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml new file mode 100644 index 0000000000..a4870c0850 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml @@ -0,0 +1,461 @@ +<?xml version="1.0" encoding="utf-8" ?> +<local:NavigablePage + x:Class="Microsoft.PowerToys.Settings.UI.Views.LightSwitchPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + 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:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:viewModels="using:Microsoft.PowerToys.Settings.UI.ViewModels" + d:DataContext="{d:DesignInstance Type=viewModels:LightSwitchViewModel}" + AutomationProperties.LandmarkType="Main" + mc:Ignorable="d"> + <local:NavigablePage.Resources> + <converters:TimeSpanToFriendlyTimeConverter x:Key="TimeSpanToFriendlyTimeConverter" /> + <converters:StringToDoubleConverter x:Key="StringToDoubleConverter" /> + </local:NavigablePage.Resources> + <Grid> + <controls:SettingsPageControl + x:Uid="LightSwitch" + IsTabStop="False" + ModuleImageSource="ms-appx:///Assets/Settings/Modules/LightSwitch.png"> + <controls:SettingsPageControl.ModuleContent> + <StackPanel Orientation="Vertical"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + x:Uid="LightSwitch_EnableSettingsCard" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/LightSwitch.png}" + IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> + <ToggleSwitch AutomationProperties.AutomationId="Toggle_LightSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> + + <controls:SettingsGroup x:Uid="LightSwitch_ShortcutsSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard x:Uid="LightSwitch_ThemeToggle_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + AutomationProperties.AutomationId="Shortcut_LightSwitch" + HotkeySettings="{x:Bind Path=ViewModel.ToggleThemeActivationShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:SettingsGroup> + + <controls:SettingsGroup x:Uid="LightSwitch_ScheduleSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander + x:Uid="LightSwitch_ModeSettingsExpander" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <ComboBox + x:Name="ModeSelector" + AutomationProperties.AutomationId="ModeSelection_LightSwitch" + SelectedValue="{x:Bind ViewModel.ScheduleMode, Mode=TwoWay}" + SelectedValuePath="Tag" + SelectionChanged="ModeSelector_SelectionChanged"> + <ComboBoxItem + x:Uid="LightSwitch_ModeOff" + AutomationProperties.AutomationId="OffCBItem_LightSwitch" + Tag="Off" /> + <ComboBoxItem + x:Uid="LightSwitch_ModeManual" + AutomationProperties.AutomationId="ManualCBItem_LightSwitch" + Tag="FixedHours" /> + <ComboBoxItem + x:Uid="LightSwitch_ModeSunsetToSunrise" + AutomationProperties.AutomationId="SunCBItem_LightSwitch" + Tag="SunsetToSunrise" /> + <ComboBoxItem + x:Uid="LightSwitch_ModeFollowNightLight" + AutomationProperties.AutomationId="FollowNightLightCBItem_LightSwitch" + Tag="FollowNightLight" /> + </ComboBox> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard + x:Name="Fixed_TurnOnCard" + x:Uid="LightSwitch_TurnOnDarkMode" + Visibility="Collapsed"> + <TimePicker AutomationProperties.AutomationId="DarkTimePicker" Time="{x:Bind ViewModel.DarkTimePickerValue, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + x:Name="Fixed_TurnOffCard" + x:Uid="LightSwitch_TurnOffDarkMode" + Visibility="Collapsed"> + <TimePicker AutomationProperties.AutomationId="LightTimePicker" Time="{x:Bind ViewModel.LightTimePickerValue, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + <tkcontrols:SettingsCard + x:Name="SunLocation_Card" + x:Uid="LightSwitch_LocationSettingsCard" + Visibility="Collapsed"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <controls:IsEnabledTextBlock + VerticalAlignment="Center" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind ViewModel.SyncButtonInformation, Mode=OneWay}" /> + <Button + x:Uid="LightSwitch_SetLocationButton" + AutomationProperties.AutomationId="SetLocationButton_LightSwitch" + Click="SyncLocationButton_Click" /> + </StackPanel> + </tkcontrols:SettingsCard> + + <tkcontrols:SettingsCard + x:Name="SunOffset_Card" + x:Uid="LightSwitch_OffsetSettingsCard" + Visibility="Collapsed"> + <StackPanel Orientation="Horizontal" Spacing="20"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <!--<FontIcon Glyph="" FontSize="16" />--> + <controls:IsEnabledTextBlock x:Uid="LightSwitch_SunriseText" VerticalAlignment="Center" /> + <NumberBox + AutomationProperties.AutomationId="SunriseOffset_LightSwitch" + Maximum="{x:Bind ViewModel.SunriseOffsetMax, Mode=OneWay}" + Minimum="{x:Bind ViewModel.SunriseOffsetMin, Mode=OneWay}" + SpinButtonPlacementMode="Compact" + Value="{x:Bind ViewModel.SunriseOffset, Mode=TwoWay}" /> + </StackPanel> + <StackPanel Orientation="Horizontal" Spacing="8"> + <controls:IsEnabledTextBlock x:Uid="LightSwitch_SunsetText" VerticalAlignment="Center" /> + <NumberBox + AutomationProperties.AutomationId="SunsetOffset_LightSwitch" + Maximum="{x:Bind ViewModel.SunsetOffsetMax, Mode=OneWay}" + Minimum="{x:Bind ViewModel.SunsetOffsetMin, Mode=OneWay}" + SpinButtonPlacementMode="Compact" + Value="{x:Bind ViewModel.SunsetOffset, Mode=TwoWay}" /> + </StackPanel> + </StackPanel> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + x:Name="TimelineCard" + HorizontalContentAlignment="Stretch" + ContentAlignment="Vertical" + Visibility="Collapsed"> + <controls:Timeline + Margin="0,24,0,24" + AutomationProperties.AutomationId="Timeline_LightSwitch" + EndTime="{x:Bind ViewModel.DarkTimeTimeSpan, Mode=OneWay}" + StartTime="{x:Bind ViewModel.LightTimeTimeSpan, Mode=OneWay}" + Sunrise="{x:Bind ViewModel.SunriseTimeSpan, Mode=OneWay}" + Sunset="{x:Bind ViewModel.SunsetTimeSpan, Mode=OneWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + x:Name="NoScheduleCard" + Padding="0" + HorizontalContentAlignment="Stretch" + Background="{ThemeResource InfoBarInformationalSeverityBackgroundBrush}" + ContentAlignment="Vertical" + Visibility="Visible"> + <InfoBar + x:Uid="LightSwitch_ScheduleOffMessage" + Background="Transparent" + BorderThickness="0" + IsClosable="False" + IsOpen="True" + Severity="Informational" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + x:Name="FollowNightLightCard" + Padding="0" + HorizontalContentAlignment="Stretch" + Background="{ThemeResource InfoBarInformationalSeverityBackgroundBrush}" + ContentAlignment="Vertical" + Visibility="Collapsed"> + <InfoBar + Background="Transparent" + BorderThickness="0" + IsClosable="False" + IsOpen="True" + Severity="Informational"> + <StackPanel VerticalAlignment="Center" Orientation="Horizontal"> + <TextBlock VerticalAlignment="Center"> + <Run x:Uid="LightSwitch_FollowNightLightCardMessage" /> + </TextBlock> + + <HyperlinkButton + x:Uid="LightSwitch_NightLightSettingsButton" + Margin="3,0,0,0" + Padding="0" + Click="OpenNightLightSettings_Click" /> + </StackPanel> + + </InfoBar> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + <InfoBar + x:Name="LocationWarningBar" + x:Uid="LightSwitch_LocationWarningBar" + IsOpen="True" + Severity="Informational" + Visibility="Collapsed" /> + </controls:SettingsGroup> + + <controls:SettingsGroup x:Uid="LightSwitch_BehaviorSettingsGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander + x:Uid="LightSwitch_ApplyDarkModeExpander" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard HorizontalContentAlignment="Stretch" ContentAlignment="Left"> + <controls:CheckBoxWithDescriptionControl + x:Uid="LightSwitch_SystemCheckbox" + AutomationProperties.AutomationId="ChangeSystemCheckbox_LightSwitch" + IsChecked="{x:Bind ViewModel.ChangeSystem, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard HorizontalContentAlignment="Stretch" ContentAlignment="Left"> + <controls:CheckBoxWithDescriptionControl + x:Uid="LightSwitch_AppsCheckbox" + AutomationProperties.AutomationId="ChangeAppsCheckbox_LightSwitch" + IsChecked="{x:Bind ViewModel.ChangeApps, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + <InfoBar + x:Name="PowerDisplayDisabledWarningBar" + x:Uid="LightSwitch_PowerDisplayDisabledWarningBar" + Background="Transparent" + BorderBrush="Transparent" + IsClosable="False" + IsOpen="True" + IsTabStop="True" + Severity="Informational" + Visibility="{x:Bind ViewModel.ShowPowerDisplayDisabledWarning, Mode=OneWay}"> + <InfoBar.ActionButton> + <HyperlinkButton + x:Uid="LightSwitch_NavigatePowerDisplaySettings" + HorizontalAlignment="Right" + Click="NavigatePowerDisplaySettings_Click" /> + </InfoBar.ActionButton> + </InfoBar> + <tkcontrols:SettingsExpander + x:Uid="LightSwitch_ApplyMonitorSettingsExpander" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard> + <tkcontrols:SettingsCard.Header> + <CheckBox + x:Uid="LightSwitch_DarkModeProfileCheckbox" + IsChecked="{x:Bind ViewModel.EnableDarkModeProfile, Mode=TwoWay}" + IsEnabled="{x:Bind ViewModel.IsPowerDisplayEnabled, Mode=OneWay}" /> + </tkcontrols:SettingsCard.Header> + <ComboBox + MinWidth="200" + DisplayMemberPath="Name" + IsEnabled="{x:Bind ViewModel.IsPowerDisplayEnabled, Mode=OneWay}" + ItemsSource="{x:Bind ViewModel.AvailableProfiles, Mode=OneWay}" + SelectedItem="{x:Bind ViewModel.SelectedDarkModeProfile, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard> + <tkcontrols:SettingsCard.Header> + <CheckBox + x:Uid="LightSwitch_LightModeProfileCheckbox" + IsChecked="{x:Bind ViewModel.EnableLightModeProfile, Mode=TwoWay}" + IsEnabled="{x:Bind ViewModel.IsPowerDisplayEnabled, Mode=OneWay}" /> + </tkcontrols:SettingsCard.Header> + <ComboBox + MinWidth="200" + DisplayMemberPath="Name" + IsEnabled="{x:Bind ViewModel.IsPowerDisplayEnabled, Mode=OneWay}" + ItemsSource="{x:Bind ViewModel.AvailableProfiles, Mode=OneWay}" + SelectedItem="{x:Bind ViewModel.SelectedLightModeProfile, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + </controls:SettingsGroup> + <!-- Force mode buttons --> + <!--<tkcontrols:SettingsCard + Header="Force mode now" + HeaderIcon="{ui:FontIcon Glyph=}" + Description="Apply light or dark mode immediately"> + <StackPanel Orientation="Horizontal" Spacing="12"> + <Button + Content="Force Light" + Command="{x:Bind ViewModel.ForceLightCommand}" /> + <Button + Content="Force Dark" + Command="{x:Bind ViewModel.ForceDarkCommand}" /> + </StackPanel> + </tkcontrols:SettingsCard>--> + + <ContentDialog + x:Name="LocationDialog" + x:Uid="LightSwitch_LocationDialog" + IsPrimaryButtonEnabled="False" + IsSecondaryButtonEnabled="True" + PrimaryButtonClick="LocationDialog_PrimaryButtonClick" + PrimaryButtonStyle="{StaticResource AccentButtonStyle}"> + <Grid RowSpacing="16"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" MinHeight="64" /> + </Grid.RowDefinitions> + <TextBlock + x:Uid="LightSwitch_LocationDialog_Description" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + <!--<AutoSuggestBox + x:Name="CityAutoSuggestBox" + Grid.Row="1" + Margin="0,16,0,8" + AutomationProperties.AutomationId="CitySearchBox_LightSwitch" + ItemsSource="{x:Bind ViewModel.SearchLocations, Mode=OneWay}" + PlaceholderText="Search for a city near you.." + QueryIcon="Find" + SuggestionChosen="CityAutoSuggestBox_SuggestionChosen" + TextChanged="CityAutoSuggestBox_TextChanged"> + <AutoSuggestBox.ItemTemplate> + <DataTemplate x:DataType="helpers:SearchLocation"> + <Grid Padding="12,8,0,8"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <TextBlock Text="{x:Bind City}" /> + <TextBlock + Grid.Row="1" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Style="{StaticResource CaptionTextBlockStyle}" + Text="{x:Bind Country}" /> + </Grid> + </DataTemplate> + </AutoSuggestBox.ItemTemplate> + </AutoSuggestBox>--> + + <Grid + Grid.Row="1" + Margin="0,12,0,0" + ColumnSpacing="4"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="124" /> + <ColumnDefinition Width="124" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <NumberBox + x:Name="LatitudeBox" + x:Uid="LightSwitch_LatitudeBox" + AutomationProperties.AutomationId="LatitudeBox_LightSwitch" + Maximum="90" + Minimum="-90" + ValueChanged="LatLonBox_ValueChanged" + Value="{x:Bind ViewModel.LocationPanelLatitude, Mode=TwoWay}" /> + <NumberBox + x:Name="LongitudeBox" + x:Uid="LightSwitch_LongitudeBox" + Grid.Column="1" + AutomationProperties.AutomationId="LongitudeBox_LightSwitch" + Maximum="180" + Minimum="-180" + ValueChanged="LatLonBox_ValueChanged" + Value="{x:Bind ViewModel.LocationPanelLongitude, Mode=TwoWay}" /> + <Button + x:Name="SyncButton" + x:Uid="LightSwitch_FindLocationAutomation" + Grid.Column="2" + Margin="4,0,0,0" + HorizontalAlignment="Stretch" + VerticalAlignment="Bottom" + AutomationProperties.AutomationId="SyncLocationButton_LightSwitch" + Click="GetGeoLocation_Click"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="LightSwitch_FindLocation" /> + </StackPanel> + </Button> + </Grid> + <ProgressRing + x:Name="SyncLoader" + Grid.Row="2" + Width="40" + Height="40" + VerticalAlignment="Center" + IsActive="False" + Visibility="Collapsed" /> + <Grid + x:Name="LocationResultPanel" + Grid.Row="2" + VerticalAlignment="Bottom" + ColumnSpacing="16" + RowSpacing="12" + Visibility="Collapsed"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <FontIcon FontSize="20" Glyph=""> + <ToolTipService.ToolTip> + <TextBlock x:Uid="LightSwitch_SunriseTooltip" /> + </ToolTipService.ToolTip> + </FontIcon> + <TextBlock + Grid.Column="1" + AutomationProperties.AutomationId="SunriseText_LightSwitch" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind ViewModel.LocationPanelLightTime, Converter={StaticResource TimeSpanToFriendlyTimeConverter}, Mode=OneWay}" + TextAlignment="Left" /> + <FontIcon + Grid.Row="1" + FontSize="20" + Glyph=""> + <ToolTipService.ToolTip> + <TextBlock x:Uid="LightSwitch_SunsetTooltip" /> + </ToolTipService.ToolTip> + </FontIcon> + <TextBlock + Grid.Row="1" + Grid.Column="1" + AutomationProperties.AutomationId="SunsetText_LightSwitch" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind ViewModel.LocationPanelDarkTime, Converter={StaticResource TimeSpanToFriendlyTimeConverter}, Mode=OneWay}" + TextAlignment="Left" /> + </Grid> + </Grid> + </ContentDialog> + + </StackPanel> + </controls:SettingsPageControl.ModuleContent> + <controls:SettingsPageControl.PrimaryLinks> + <controls:PageLink x:Uid="LearnMore_LightSwitch" Link="https://aka.ms/PowerToysOverview_LightSwitch" /> + </controls:SettingsPageControl.PrimaryLinks> + </controls:SettingsPageControl> + <VisualStateManager.VisualStateGroups> + <VisualStateGroup x:Name="ScheduleModeStates"> + <VisualState x:Name="OffState" /> + <VisualState x:Name="SunsetToSunriseState"> + <VisualState.Setters> + <Setter Target="SunLocation_Card.Visibility" Value="Visible" /> + <Setter Target="SunOffset_Card.Visibility" Value="Visible" /> + <Setter Target="NoScheduleCard.Visibility" Value="Collapsed" /> + <Setter Target="FollowNightLightCard.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + + <VisualState x:Name="ManualState"> + <VisualState.Setters> + <Setter Target="Fixed_TurnOnCard.Visibility" Value="Visible" /> + <Setter Target="Fixed_TurnOffCard.Visibility" Value="Visible" /> + <Setter Target="NoScheduleCard.Visibility" Value="Collapsed" /> + <Setter Target="FollowNightLightCard.Visibility" Value="Collapsed" /> + </VisualState.Setters> + </VisualState> + + <VisualState x:Name="FollowNightLightState"> + <VisualState.Setters> + <Setter Target="Fixed_TurnOnCard.Visibility" Value="Collapsed" /> + <Setter Target="Fixed_TurnOffCard.Visibility" Value="Collapsed" /> + <Setter Target="NoScheduleCard.Visibility" Value="Collapsed" /> + <Setter Target="FollowNightLightCard.Visibility" Value="Visible" /> + </VisualState.Setters> + </VisualState> + </VisualStateGroup> + </VisualStateManager.VisualStateGroups> + </Grid> +</local:NavigablePage> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs new file mode 100644 index 0000000000..b366fd7a2b --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/LightSwitchPage.xaml.cs @@ -0,0 +1,390 @@ +// 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.Globalization; +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; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using PowerToys.GPOWrapper; +using Settings.UI.Library; +using Windows.Devices.Geolocation; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + public sealed partial class LightSwitchPage : NavigablePage, IRefreshablePage + { + private readonly string appName = "LightSwitch"; + private readonly SettingsUtils settingsUtils; + private readonly Func<string, int> sendConfigMsg = ShellPage.SendDefaultIPCMessage; + + private readonly SettingsRepository<GeneralSettings> generalSettingsRepository; + private readonly SettingsRepository<LightSwitchSettings> moduleSettingsRepository; + + private readonly IFileSystem fileSystem; + private readonly IFileSystemWatcher fileSystemWatcher; + private readonly DispatcherQueue dispatcherQueue; + private bool suppressViewModelUpdates; + + private LightSwitchViewModel ViewModel { get; set; } + + public LightSwitchPage() + { + this.settingsUtils = SettingsUtils.Default; + this.sendConfigMsg = ShellPage.SendDefaultIPCMessage; + + this.generalSettingsRepository = SettingsRepository<GeneralSettings>.GetInstance(this.settingsUtils); + this.moduleSettingsRepository = SettingsRepository<LightSwitchSettings>.GetInstance(this.settingsUtils); + + // Get settings from JSON (or defaults if JSON missing) + var darkSettings = this.moduleSettingsRepository.SettingsConfig; + + // Pass them into the ViewModel + this.ViewModel = new LightSwitchViewModel(this.generalSettingsRepository, darkSettings, ShellPage.SendDefaultIPCMessage); + this.ViewModel.PropertyChanged += ViewModel_PropertyChanged; + + this.LoadSettings(this.generalSettingsRepository, this.moduleSettingsRepository); + + DataContext = this.ViewModel; + + var settingsPath = this.settingsUtils.GetSettingsFilePath(this.appName); + + this.dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + this.fileSystem = new FileSystem(); + + this.fileSystemWatcher = this.fileSystem.FileSystemWatcher.New(); + this.fileSystemWatcher.Path = this.fileSystem.Path.GetDirectoryName(settingsPath); + this.fileSystemWatcher.Filter = this.fileSystem.Path.GetFileName(settingsPath); + this.fileSystemWatcher.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime; + this.fileSystemWatcher.Changed += Settings_Changed; + this.fileSystemWatcher.EnableRaisingEvents = true; + + this.InitializeComponent(); + Loaded += LightSwitchPage_Loaded; + Loaded += (s, e) => this.ViewModel.OnPageLoaded(); + } + + public void RefreshEnabledState() + { + this.ViewModel.RefreshEnabledState(); + } + + private void LightSwitchPage_Loaded(object sender, RoutedEventArgs e) + { + if (this.ViewModel.SearchLocations.Count == 0) + { + foreach (var city in SearchLocationLoader.GetAll()) + { + this.ViewModel.SearchLocations.Add(city); + } + } + + this.ViewModel.InitializeScheduleMode(); + } + + private async void GetGeoLocation_Click(object sender, RoutedEventArgs e) + { + this.LatitudeBox.IsEnabled = false; + this.LongitudeBox.IsEnabled = false; + this.SyncButton.IsEnabled = false; + this.SyncLoader.IsActive = true; + this.SyncLoader.Visibility = Visibility.Visible; + this.LocationResultPanel.Visibility = Visibility.Collapsed; + + try + { + // Request access + var accessStatus = await Geolocator.RequestAccessAsync(); + if (accessStatus != GeolocationAccessStatus.Allowed) + { + // User denied location or it's not available + return; + } + + var geolocator = new Geolocator { DesiredAccuracy = PositionAccuracy.Default }; + + Geoposition pos = await geolocator.GetGeopositionAsync(); + + double latitude = Math.Round(pos.Coordinate.Point.Position.Latitude); + double longitude = Math.Round(pos.Coordinate.Point.Position.Longitude); + + ViewModel.LocationPanelLatitude = latitude; + ViewModel.LocationPanelLongitude = longitude; + + var result = SunCalc.CalculateSunriseSunset(latitude, longitude, DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + + this.ViewModel.LocationPanelLightTimeMinutes = (result.SunriseHour * 60) + result.SunriseMinute; + this.ViewModel.LocationPanelDarkTimeMinutes = (result.SunsetHour * 60) + result.SunsetMinute; + + // Since we use this mode, we can remove the selected city data. + this.ViewModel.SelectedCity = null; + + // ViewModel.CityTimesText = $"Sunrise: {result.SunriseHour}:{result.SunriseMinute:D2}\n" + $"Sunset: {result.SunsetHour}:{result.SunsetMinute:D2}"; + this.SyncButton.IsEnabled = true; + this.SyncLoader.IsActive = false; + this.SyncLoader.Visibility = Visibility.Collapsed; + this.LocationDialog.IsPrimaryButtonEnabled = true; + this.LatitudeBox.IsEnabled = true; + this.LongitudeBox.IsEnabled = true; + this.LocationResultPanel.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + this.SyncButton.IsEnabled = true; + this.SyncLoader.IsActive = false; + this.SyncLoader.Visibility = Visibility.Collapsed; + this.LocationResultPanel.Visibility = Visibility.Collapsed; + this.LatitudeBox.IsEnabled = true; + this.LongitudeBox.IsEnabled = true; + Logger.LogInfo($"Location error: " + ex.Message); + } + } + + private void LatLonBox_ValueChanged(NumberBox sender, NumberBoxValueChangedEventArgs args) + { + double latitude = this.LatitudeBox.Value; + double longitude = this.LongitudeBox.Value; + + if (double.IsNaN(latitude) || double.IsNaN(longitude) || (latitude == 0 && longitude == 0)) + { + return; + } + + var result = SunCalc.CalculateSunriseSunset(latitude, longitude, DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + + this.ViewModel.LocationPanelLightTimeMinutes = (result.SunriseHour * 60) + result.SunriseMinute; + this.ViewModel.LocationPanelDarkTimeMinutes = (result.SunsetHour * 60) + result.SunsetMinute; + + this.LocationResultPanel.Visibility = Visibility.Visible; + if (this.LocationDialog != null) + { + this.LocationDialog.IsPrimaryButtonEnabled = true; + } + } + + private void LocationDialog_PrimaryButtonClick(object sender, ContentDialogButtonClickEventArgs args) + { + if (double.IsNaN(this.LatitudeBox.Value) || double.IsNaN(this.LongitudeBox.Value)) + { + return; + } + + double latitude = this.LatitudeBox.Value; + double longitude = this.LongitudeBox.Value; + + // need to save the values + this.ViewModel.Latitude = latitude.ToString(CultureInfo.InvariantCulture); + this.ViewModel.Longitude = longitude.ToString(CultureInfo.InvariantCulture); + this.ViewModel.SyncButtonInformation = $"{this.ViewModel.Latitude}°, {this.ViewModel.Longitude}°"; + + var result = SunCalc.CalculateSunriseSunset(latitude, longitude, DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day); + + this.ViewModel.LightTime = (result.SunriseHour * 60) + result.SunriseMinute; + this.ViewModel.DarkTime = (result.SunsetHour * 60) + result.SunsetMinute; + + this.SunriseModeChartState(); + } + + private void ViewModel_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (this.suppressViewModelUpdates) + { + return; + } + + if (e.PropertyName == "IsEnabled") + { + if (this.ViewModel.IsEnabled != this.generalSettingsRepository.SettingsConfig.Enabled.LightSwitch) + { + this.generalSettingsRepository.SettingsConfig.Enabled.LightSwitch = this.ViewModel.IsEnabled; + + var generalSettingsMessage = new OutGoingGeneralSettings(this.generalSettingsRepository.SettingsConfig).ToString(); + Logger.LogInfo($"Saved general settings from Light Switch page."); + + this.sendConfigMsg?.Invoke(generalSettingsMessage); + } + } + else + { + if (this.ViewModel.ModuleSettings != null) + { + SndLightSwitchSettings currentSettings = new(this.moduleSettingsRepository.SettingsConfig); + SndModuleSettings<SndLightSwitchSettings> csIpcMessage = new(currentSettings); + + SndLightSwitchSettings outSettings = new(this.ViewModel.ModuleSettings); + SndModuleSettings<SndLightSwitchSettings> outIpcMessage = new(outSettings); + + string csMessage = csIpcMessage.ToJsonString(); + string outMessage = outIpcMessage.ToJsonString(); + + if (!csMessage.Equals(outMessage, StringComparison.Ordinal)) + { + Logger.LogInfo($"Saved Light Switch settings from Light Switch page."); + + this.sendConfigMsg?.Invoke(outMessage); + } + } + } + } + + private void LoadSettings(SettingsRepository<GeneralSettings> generalSettingsRepository, SettingsRepository<LightSwitchSettings> moduleSettingsRepository) + { + if (generalSettingsRepository != null) + { + if (moduleSettingsRepository != null) + { + UpdateViewModelSettings(moduleSettingsRepository.SettingsConfig, generalSettingsRepository.SettingsConfig); + } + else + { + throw new ArgumentNullException(nameof(moduleSettingsRepository)); + } + } + else + { + throw new ArgumentNullException(nameof(generalSettingsRepository)); + } + } + + private void UpdateViewModelSettings(LightSwitchSettings lightSwitchSettings, GeneralSettings generalSettings) + { + if (lightSwitchSettings != null) + { + if (generalSettings != null) + { + this.ViewModel.IsEnabled = generalSettings.Enabled.LightSwitch; + this.ViewModel.ModuleSettings = (LightSwitchSettings)lightSwitchSettings.Clone(); + + UpdateEnabledState(generalSettings.Enabled.LightSwitch); + } + else + { + throw new ArgumentNullException(nameof(generalSettings)); + } + } + else + { + throw new ArgumentNullException(nameof(lightSwitchSettings)); + } + } + + private void Settings_Changed(object sender, FileSystemEventArgs e) + { + this.dispatcherQueue.TryEnqueue(() => + { + this.suppressViewModelUpdates = true; + + this.moduleSettingsRepository.ReloadSettings(); + this.LoadSettings(this.generalSettingsRepository, this.moduleSettingsRepository); + + this.suppressViewModelUpdates = false; + }); + } + + private void UpdateEnabledState(bool recommendedState) + { + ViewModel.RefreshEnabledState(); + } + + private async void SyncLocationButton_Click(object sender, RoutedEventArgs e) + { + this.LocationDialog.IsPrimaryButtonEnabled = false; + this.LocationResultPanel.Visibility = Visibility.Collapsed; + await this.LocationDialog.ShowAsync(); + } + + private void CityAutoSuggestBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + if (args.Reason == AutoSuggestionBoxTextChangeReason.UserInput && !string.IsNullOrWhiteSpace(sender.Text)) + { + string query = sender.Text.ToLower(CultureInfo.CurrentCulture); + + // Filter your cities (assuming ViewModel.Cities is a List<City>) + var filtered = this.ViewModel.SearchLocations + .Where(c => + (c.City?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false) || + (c.Country?.Contains(query, StringComparison.CurrentCultureIgnoreCase) ?? false)) + .ToList(); + + sender.ItemsSource = filtered; + } + } + + /* private void CityAutoSuggestBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + if (args.SelectedItem is SearchLocation location) + { + ViewModel.SelectedCity = location; + + // CityAutoSuggestBox.Text = $"{location.City}, {location.Country}"; + LocationDialog.IsPrimaryButtonEnabled = true; + LocationResultPanel.Visibility = Visibility.Visible; + } + } */ + + private void ModeSelector_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + switch (this.ViewModel.ScheduleMode) + { + case "FixedHours": + VisualStateManager.GoToState(this, "ManualState", true); + this.TimelineCard.Visibility = Visibility.Visible; + break; + case "SunsetToSunrise": + VisualStateManager.GoToState(this, "SunsetToSunriseState", true); + this.SunriseModeChartState(); + break; + case "FollowNightLight": + VisualStateManager.GoToState(this, "FollowNightLightState", true); + TimelineCard.Visibility = Visibility.Collapsed; + break; + default: + VisualStateManager.GoToState(this, "OffState", true); + this.TimelineCard.Visibility = Visibility.Collapsed; + break; + } + } + + private void OpenNightLightSettings_Click(object sender, RoutedEventArgs e) + { + try + { + Helpers.StartProcessHelper.Start(Helpers.StartProcessHelper.NightLightSettings); + } + catch (Exception ex) + { + Logger.LogError("Error while trying to open the system night light settings", ex); + } + } + + private void SunriseModeChartState() + { + if (this.ViewModel.Latitude != "0.0" && this.ViewModel.Longitude != "0.0") + { + this.TimelineCard.Visibility = Visibility.Visible; + this.LocationWarningBar.Visibility = Visibility.Collapsed; + } + else + { + this.TimelineCard.Visibility = Visibility.Collapsed; + 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/MeasureToolPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml index 079fa57447..221cb8e9a6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.MeasureToolPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -13,27 +14,29 @@ <controls:SettingsPageControl x:Uid="MeasureTool" ModuleImageSource="ms-appx:///Assets/Settings/Modules/ScreenRuler.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="MeasureTool_EnableMeasureTool" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ScreenRuler.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - <controls:SettingsGroup x:Uid="MeasureTool_ActivationSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="MeasureTool_ActivationShortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.ActivationShortcut, Mode=TwoWay}" /> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MeasureToolEnableMeasureTool" + x:Uid="MeasureTool_EnableMeasureTool" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ScreenRuler.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.AutomationId="Toggle_ScreenRuler" + IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MeasureTool_DefaultMeasureStyle"> + </controls:GPOInfoControl> + + <controls:SettingsGroup x:Uid="MeasureTool_ActivationSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MeasureToolActivationShortcut" + x:Uid="MeasureTool_ActivationShortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.AutomationId="Shortcut_ScreenRuler" + HotkeySettings="{x:Bind Path=ViewModel.ActivationShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="MeasureToolDefaultMeasureStyle" x:Uid="MeasureTool_DefaultMeasureStyle"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.DefaultMeasureStyle, Mode=TwoWay}"> <ComboBoxItem x:Uid="MeasureTool_DefaultMeasureStyle_None" /> <ComboBoxItem x:Uid="MeasureTool_DefaultMeasureStyle_Bounds" /> @@ -46,7 +49,10 @@ </controls:SettingsGroup> <controls:SettingsGroup x:Uid="MeasureTool_Settings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="MeasureTool_ContinuousCapture" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="MeasureToolContinuousCapture" + x:Uid="MeasureTool_ContinuousCapture" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="MeasureTool_ContinuousCapture_ToggleSwitch" IsOn="{x:Bind ViewModel.ContinuousCapture, Mode=TwoWay}" /> </tkcontrols:SettingsCard> <InfoBar @@ -56,11 +62,14 @@ IsTabStop="{x:Bind ViewModel.ShowContinuousCaptureWarning, Mode=OneWay}" Severity="Warning" /> - <tkcontrols:SettingsCard x:Uid="MeasureTool_PerColorChannelEdgeDetection" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="MeasureToolPerColorChannelEdgeDetection" + x:Uid="MeasureTool_PerColorChannelEdgeDetection" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="MeasureTool_PerColorChannelEdgeDetection_ToggleSwitch" IsOn="{x:Bind ViewModel.PerColorChannelEdgeDetection, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MeasureTool_PixelTolerance"> + <tkcontrols:SettingsCard Name="MeasureToolPixelTolerance" x:Uid="MeasureTool_PixelTolerance"> <Slider MinWidth="{StaticResource SettingActionControlMinWidth}" Maximum="255" @@ -68,7 +77,10 @@ Value="{x:Bind ViewModel.PixelTolerance, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MeasureTool_UnitsOfMeasure" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="MeasureToolUnitsOfMeasure" + x:Uid="MeasureTool_UnitsOfMeasure" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.UnitsOfMeasure, Mode=TwoWay}"> <ComboBoxItem x:Uid="MeasureTool_UnitsOfMeasure_Pixels" /> <ComboBoxItem x:Uid="MeasureTool_UnitsOfMeasure_Inches" /> @@ -77,11 +89,11 @@ </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MeasureTool_DrawFeetOnCross"> + <tkcontrols:SettingsCard Name="MeasureToolDrawFeetOnCross" x:Uid="MeasureTool_DrawFeetOnCross"> <ToggleSwitch x:Uid="MeasureTool_DrawFeetOnCross_ToggleSwitch" IsOn="{x:Bind ViewModel.DrawFeetOnCross, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MeasureTool_MeasureCrossColor"> + <tkcontrols:SettingsCard Name="MeasureToolMeasureCrossColor" x:Uid="MeasureTool_MeasureCrossColor"> <controls:ColorPickerButton SelectedColor="{x:Bind Path=ViewModel.CrossColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> @@ -94,4 +106,4 @@ <controls:PageLink x:Uid="Attribution_Rooler" Link="https://github.com/peteblois/rooler" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml.cs index f48bc7cd5a..8f80f1c13b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MeasureToolPage.xaml.cs @@ -9,7 +9,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class MeasureToolPage : Page, IRefreshablePage + public sealed partial class MeasureToolPage : NavigablePage, IRefreshablePage { private MeasureToolViewModel ViewModel { get; set; } @@ -17,7 +17,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views public MeasureToolPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new MeasureToolViewModel( settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), @@ -26,6 +26,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml index dce59d6426..1ded2db636 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml @@ -1,10 +1,11 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.MouseUtilsPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:panels="using:Microsoft.PowerToys.Settings.UI.Panels" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" @@ -12,38 +13,72 @@ xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - <Page.Resources> + <local:NavigablePage.Resources> <converters:IndexBitFieldToVisibilityConverter x:Key="IndexBitFieldToVisibilityConverter" /> <tkconverters:BoolToVisibilityConverter x:Key="BoolToInvertedVisibilityConverter" FalseValue="Visible" TrueValue="Collapsed" /> - </Page.Resources> + </local:NavigablePage.Resources> <controls:SettingsPageControl x:Uid="MouseUtils" ModuleImageSource="ms-appx:///Assets/Settings/Modules/MouseUtils.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <controls:SettingsGroup x:Uid="MouseUtils_FindMyMouse"> - <tkcontrols:SettingsCard - x:Uid="MouseUtils_Enable_FindMyMouse" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FindMyMouse.png}" - IsEnabled="{x:Bind ViewModel.IsFindMyMouseEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsFindMyMouseEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsFindMyMouseEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:SettingsGroup x:Uid="MouseUtils_CursorWrap" AutomationProperties.AutomationId="MouseUtils_CursorWrapTestId"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsCursorWrapEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MouseUtilsEnableCursorWrap" + x:Uid="MouseUtils_Enable_CursorWrap" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CursorWrap.png}" + IsEnabled="{x:Bind ViewModel.IsCursorWrapEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsCursorWrapEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> + <tkcontrols:SettingsExpander + Name="MouseUtilsCursorWrapSettingsExpander" + x:Uid="MouseUtils_CursorWrap_ActivationShortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.IsCursorWrapEnabled, Mode=OneWay}"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.CursorWrapActivationShortcut, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard ContentAlignment="Left"> + <CheckBox x:Uid="MouseUtils_AutoActivate" IsChecked="{x:Bind ViewModel.CursorWrapAutoActivate, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind ViewModel.IsCursorWrapEnabled, Mode=OneWay}"> + <CheckBox x:Uid="MouseUtils_CursorWrap_DisableWrapDuringDrag" IsChecked="{x:Bind ViewModel.CursorWrapDisableWrapDuringDrag, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="MouseUtilsCursorWrapWrapMode" x:Uid="MouseUtils_CursorWrap_WrapMode"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.CursorWrapWrapMode, Mode=TwoWay}"> + <ComboBoxItem x:Uid="MouseUtils_CursorWrap_WrapMode_Both" /> + <ComboBoxItem x:Uid="MouseUtils_CursorWrap_WrapMode_VerticalOnly" /> + <ComboBoxItem x:Uid="MouseUtils_CursorWrap_WrapMode_HorizontalOnly" /> + </ComboBox> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind ViewModel.IsCursorWrapEnabled, Mode=OneWay}"> + <CheckBox x:Uid="MouseUtils_CursorWrap_DisableOnSingleMonitor" IsChecked="{x:Bind ViewModel.CursorWrapDisableOnSingleMonitor, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + </controls:SettingsGroup> + + <controls:SettingsGroup x:Uid="MouseUtils_FindMyMouse" AutomationProperties.AutomationId="MouseUtils_FindMyMouseTestId"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsFindMyMouseEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MouseUtilsEnableFindMyMouse" + x:Uid="MouseUtils_Enable_FindMyMouse" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FindMyMouse.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.AutomationId="MouseUtils_FindMyMouseToggleId" + IsOn="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> + <tkcontrols:SettingsExpander + Name="MouseUtilsFindMyMouseActivationMethod" x:Uid="MouseUtils_FindMyMouse_ActivationMethod" + AutomationProperties.AutomationId="MouseUtils_FindMyMouseActivationMethodId" HeaderIcon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=OneWay}" - IsExpanded="True"> + IsEnabled="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=OneWay}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.FindMyMouseActivationMethod, Mode=TwoWay}"> <ComboBoxItem x:Uid="MouseUtils_FindMyMouse_ActivationDoubleControlPress" /> <ComboBoxItem x:Uid="MouseUtils_FindMyMouse_ActivationDoubleRightControlPress" /> @@ -55,7 +90,10 @@ <!-- Visible for both Press Left Control twice and Press Right Control twice activation methods --> <CheckBox x:Uid="MouseUtils_Include_Win_Key" IsChecked="{x:Bind ViewModel.FindMyMouseIncludeWinKey, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_FindMyMouse_ShakingMinimumDistance" Visibility="{x:Bind ViewModel.FindMyMouseActivationMethod, Converter={StaticResource IndexBitFieldToVisibilityConverter}, Mode=OneWay, ConverterParameter=0x4}"> + <tkcontrols:SettingsCard + Name="MouseUtilsFindMyMouseShakingMinimumDistance" + x:Uid="MouseUtils_FindMyMouse_ShakingMinimumDistance" + Visibility="{x:Bind ViewModel.FindMyMouseActivationMethod, Converter={StaticResource IndexBitFieldToVisibilityConverter}, Mode=OneWay, ConverterParameter=0x4}"> <!-- Visible for the Shake Mouse activation method --> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" @@ -66,7 +104,10 @@ SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.FindMyMouseShakingMinimumDistance, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_FindMyMouse_ShakingIntervalMs" Visibility="{x:Bind ViewModel.FindMyMouseActivationMethod, Converter={StaticResource IndexBitFieldToVisibilityConverter}, Mode=OneWay, ConverterParameter=0x4}"> + <tkcontrols:SettingsCard + Name="MouseUtilsFindMyMouseShakingIntervalMs" + x:Uid="MouseUtils_FindMyMouse_ShakingIntervalMs" + Visibility="{x:Bind ViewModel.FindMyMouseActivationMethod, Converter={StaticResource IndexBitFieldToVisibilityConverter}, Mode=OneWay, ConverterParameter=0x4}"> <!-- Visible for the Shake Mouse activation method --> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" @@ -77,7 +118,10 @@ SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.FindMyMouseShakingIntervalMs, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_FindMyMouse_ShakingFactor" Visibility="{x:Bind ViewModel.FindMyMouseActivationMethod, Converter={StaticResource IndexBitFieldToVisibilityConverter}, Mode=OneWay, ConverterParameter=0x4}"> + <tkcontrols:SettingsCard + Name="MouseUtilsFindMyMouseShakingFactor" + x:Uid="MouseUtils_FindMyMouse_ShakingFactor" + Visibility="{x:Bind ViewModel.FindMyMouseActivationMethod, Converter={StaticResource IndexBitFieldToVisibilityConverter}, Mode=OneWay, ConverterParameter=0x4}"> <!-- Visible for the Shake Mouse activation method --> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" @@ -89,6 +133,7 @@ Value="{x:Bind ViewModel.FindMyMouseShakingFactor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="MouseUtilsFindMyMouseActivationShortcut" x:Uid="MouseUtils_FindMyMouse_ActivationShortcut" HeaderIcon="{ui:FontIcon Glyph=}" Visibility="{x:Bind ViewModel.FindMyMouseActivationMethod, Converter={StaticResource IndexBitFieldToVisibilityConverter}, Mode=OneWay, ConverterParameter=0x8}"> @@ -102,43 +147,47 @@ </tkcontrols:SettingsExpander> <tkcontrols:SettingsExpander + Name="FindMyMouseAppearanceBehavior" x:Uid="Appearance_Behavior" + AutomationProperties.AutomationId="MouseUtils_FindMyMouseAppearanceBehaviorId" HeaderIcon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=OneWay}" - IsExpanded="False"> + IsEnabled="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="MouseUtils_FindMyMouse_OverlayOpacity"> - <Slider - MinWidth="{StaticResource SettingActionControlMinWidth}" - Maximum="100" - Minimum="1" - Value="{x:Bind ViewModel.FindMyMouseOverlayOpacity, Mode=TwoWay}" /> + <!-- Overlay opacity removed; alpha now encoded in colors --> + <tkcontrols:SettingsCard Name="MouseUtilsFindMyMouseBackgroundColor" x:Uid="MouseUtils_FindMyMouse_BackgroundColor"> + <controls:ColorPickerButton + AutomationProperties.AutomationId="MouseUtils_FindMyMouseBackgroundColorId" + IsAlphaEnabled="True" + SelectedColor="{x:Bind Path=ViewModel.FindMyMouseBackgroundColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_FindMyMouse_BackgroundColor"> - <controls:ColorPickerButton SelectedColor="{x:Bind Path=ViewModel.FindMyMouseBackgroundColor, Mode=TwoWay}" /> + <tkcontrols:SettingsCard Name="MouseUtilsFindMyMouseSpotlightColor" x:Uid="MouseUtils_FindMyMouse_SpotlightColor"> + <controls:ColorPickerButton + AutomationProperties.AutomationId="MouseUtils_FindMyMouseSpotlightColorId" + IsAlphaEnabled="True" + SelectedColor="{x:Bind Path=ViewModel.FindMyMouseSpotlightColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_FindMyMouse_SpotlightColor"> - <controls:ColorPickerButton SelectedColor="{x:Bind Path=ViewModel.FindMyMouseSpotlightColor, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_FindMyMouse_SpotlightRadius"> + <tkcontrols:SettingsCard Name="MouseUtilsFindMyMouseSpotlightRadius" x:Uid="MouseUtils_FindMyMouse_SpotlightRadius"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.AutomationId="MouseUtils_FindMyMouseSpotlightRadiusId" LargeChange="10" Minimum="5" SmallChange="1" SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.FindMyMouseSpotlightRadius, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_FindMyMouse_SpotlightInitialZoom"> + <tkcontrols:SettingsCard Name="MouseUtilsFindMyMouseSpotlightInitialZoom" x:Uid="MouseUtils_FindMyMouse_SpotlightInitialZoom"> <Slider MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.AutomationId="MouseUtils_FindMyMouseSpotlightZoomId" Maximum="40" Minimum="1" Value="{x:Bind ViewModel.FindMyMouseSpotlightInitialZoom, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_FindMyMouse_AnimationDurationMs"> + <tkcontrols:SettingsCard Name="MouseUtilsFindMyMouseAnimationDurationMs" x:Uid="MouseUtils_FindMyMouse_AnimationDurationMs"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.AutomationId="MouseUtils_FindMyMouseAnimationDurationId" IsEnabled="{x:Bind ViewModel.IsAnimationEnabledBySystem, Mode=OneWay}" LargeChange="100" Minimum="0" @@ -153,13 +202,15 @@ IsClosable="False" IsOpen="True" Severity="Informational" - Visibility="{x:Bind ViewModel.IsAnimationEnabledBySystem, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}"> + Visibility="{x:Bind ViewModel.IsAnimationEnabledBySystem, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> <InfoBar.ActionButton> - <HyperlinkButton x:Uid="OpenSettings" Click="OpenAnimationsSettings_Click" /> + <HyperlinkButton x:Uid="OpenAnimationsSettings" Click="OpenAnimationsSettings_Click" /> </InfoBar.ActionButton> </InfoBar> <tkcontrols:SettingsExpander + Name="MouseUtilsFindMyMouseExcludedApps" x:Uid="MouseUtils_FindMyMouse_ExcludedApps" + AutomationProperties.AutomationId="MouseUtils_FindMyMouseExcludedAppsId" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.IsFindMyMouseEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander.Items> @@ -180,28 +231,24 @@ </tkcontrols:SettingsExpander> </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="MouseUtils_MouseHighlighter"> - <tkcontrols:SettingsCard - x:Uid="MouseUtils_Enable_MouseHighlighter" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseHighlighter.png}" - IsEnabled="{x:Bind ViewModel.IsHighlighterEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsMouseHighlighterEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsHighlighterEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsHighlighterEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:SettingsGroup x:Uid="MouseUtils_MouseHighlighter" AutomationProperties.AutomationId="MouseUtils_MouseHighlighterTestId"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsHighlighterEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MouseUtilsEnableMouseHighlighter" + x:Uid="MouseUtils_Enable_MouseHighlighter" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseHighlighter.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.AutomationId="MouseUtils_MouseHighlighterToggleId" + IsOn="{x:Bind ViewModel.IsMouseHighlighterEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <tkcontrols:SettingsExpander + Name="MouseUtilsMouseHighlighterActivationShortcut" x:Uid="MouseUtils_MouseHighlighter_ActivationShortcut" + AutomationProperties.AutomationId="MouseUtils_MouseHighlighterActivationShortcutId" HeaderIcon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind ViewModel.IsMouseHighlighterEnabled, Mode=OneWay}" - IsExpanded="True"> + IsEnabled="{x:Bind ViewModel.IsMouseHighlighterEnabled, Mode=OneWay}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.MouseHighlighterActivationShortcut, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> <tkcontrols:SettingsCard ContentAlignment="Left"> @@ -210,18 +257,29 @@ </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> <tkcontrols:SettingsExpander + Name="MouseHighlighterAppearanceBehavior" x:Uid="Appearance_Behavior" + AutomationProperties.AutomationId="MouseUtils_MouseHighlighterAppearanceBehaviorId" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.IsMouseHighlighterEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_PrimaryButtonClickColor"> - <controls:AlphaColorPickerButton SelectedColor="{x:Bind Path=ViewModel.MouseHighlighterLeftButtonClickColor, Mode=TwoWay}" /> + <tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_PrimaryButtonClickColor" IsEnabled="{x:Bind ViewModel.IsSpotlightModeEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> + <controls:ColorPickerButton IsAlphaEnabled="True" SelectedColor="{x:Bind Path=ViewModel.MouseHighlighterLeftButtonClickColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_SecondaryButtonClickColor"> - <controls:AlphaColorPickerButton SelectedColor="{x:Bind Path=ViewModel.MouseHighlighterRightButtonClickColor, Mode=TwoWay}" /> + <tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_SecondaryButtonClickColor" IsEnabled="{x:Bind ViewModel.IsSpotlightModeEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> + <controls:ColorPickerButton IsAlphaEnabled="True" SelectedColor="{x:Bind Path=ViewModel.MouseHighlighterRightButtonClickColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_AlwaysColor"> - <controls:AlphaColorPickerButton SelectedColor="{x:Bind Path=ViewModel.MouseHighlighterAlwaysColor, Mode=TwoWay}" /> + <tkcontrols:SettingsCard Name="MouseUtilsMouseHighlighterAlwaysColor" x:Uid="MouseUtils_MouseHighlighter_AlwaysColor"> + <controls:ColorPickerButton IsAlphaEnabled="True" SelectedColor="{x:Bind Path=ViewModel.MouseHighlighterAlwaysColor, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard x:Uid="HighlightMode"> + <ComboBox + x:Uid="MouseUtils_MouseHighlighter_SpotlightModeType" + MinWidth="{StaticResource SettingActionControlMinWidth}" + SelectedIndex="{x:Bind ViewModel.IsSpotlightModeEnabled, Converter={StaticResource ReverseBoolToComboBoxIndexConverter}, Mode=TwoWay}"> + <ComboBoxItem x:Uid="HighlightMode_Spotlight_Mode" /> + <ComboBoxItem x:Uid="HighlightMode_Circle_Highlight_Mode" /> + </ComboBox> </tkcontrols:SettingsCard> <tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_HighlightRadius"> <NumberBox @@ -232,7 +290,7 @@ SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.MouseHighlighterRadius, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_FadeDelayMs"> + <tkcontrols:SettingsCard Name="MouseUtilsMouseHighlighterFadeDelayMs" x:Uid="MouseUtils_MouseHighlighter_FadeDelayMs"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="100" @@ -241,7 +299,7 @@ SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.MouseHighlighterFadeDelayMs, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_FadeDurationMs"> + <tkcontrols:SettingsCard Name="MouseUtilsMouseHighlighterFadeDurationMs" x:Uid="MouseUtils_MouseHighlighter_FadeDurationMs"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="100" @@ -256,28 +314,23 @@ <panels:MouseJumpPanel x:Name="MouseUtils_MouseJump_Panel" x:Uid="MouseUtils_MouseJump_Panel" /> - <controls:SettingsGroup x:Uid="MouseUtils_MousePointerCrosshairs"> - <tkcontrols:SettingsCard - x:Uid="MouseUtils_Enable_MousePointerCrosshairs" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseCrosshairs.png}" - IsEnabled="{x:Bind ViewModel.IsMousePointerCrosshairsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsMousePointerCrosshairsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsMousePointerCrosshairsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsMousePointerCrosshairsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:SettingsGroup x:Uid="MouseUtils_MousePointerCrosshairs" AutomationProperties.AutomationId="MouseUtils_MousePointerCrosshairsTestId"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsMousePointerCrosshairsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MouseUtilsEnableMousePointerCrosshairs" + x:Uid="MouseUtils_Enable_MousePointerCrosshairs" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseCrosshairs.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.AutomationId="MouseUtils_MousePointerCrosshairsToggleId" + IsOn="{x:Bind ViewModel.IsMousePointerCrosshairsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <tkcontrols:SettingsExpander + Name="MouseUtilsMousePointerCrosshairsActivationShortcut" x:Uid="MouseUtils_MousePointerCrosshairs_ActivationShortcut" HeaderIcon="{ui:FontIcon Glyph=}" - IsEnabled="{x:Bind ViewModel.IsMousePointerCrosshairsEnabled, Mode=OneWay}" - IsExpanded="True"> + IsEnabled="{x:Bind ViewModel.IsMousePointerCrosshairsEnabled, Mode=OneWay}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.MousePointerCrosshairsActivationShortcut, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> <tkcontrols:SettingsCard ContentAlignment="Left"> @@ -287,14 +340,16 @@ </tkcontrols:SettingsExpander> <tkcontrols:SettingsExpander + Name="MousePointerCrosshairsAppearanceBehavior" x:Uid="Appearance_Behavior" + AutomationProperties.AutomationId="MouseUtils_MousePointerCrosshairsAppearanceBehaviorId" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.IsMousePointerCrosshairsEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsColor"> + <tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsCrosshairsColor" x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsColor"> <controls:ColorPickerButton SelectedColor="{x:Bind Path=ViewModel.MousePointerCrosshairsColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOpacity"> + <tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsCrosshairsOpacity" x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOpacity"> <Slider MinWidth="{StaticResource SettingActionControlMinWidth}" Maximum="100" @@ -302,7 +357,7 @@ Value="{x:Bind ViewModel.MousePointerCrosshairsOpacity, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsRadius"> + <tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsCrosshairsRadius" x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsRadius"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="10" @@ -313,7 +368,7 @@ Value="{x:Bind ViewModel.MousePointerCrosshairsRadius, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsThickness"> + <tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsCrosshairsThickness" x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsThickness"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="10" @@ -324,11 +379,11 @@ Value="{x:Bind ViewModel.MousePointerCrosshairsThickness, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsBorderColor"> + <tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsCrosshairsBorderColor" x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsBorderColor"> <controls:ColorPickerButton SelectedColor="{x:Bind Path=ViewModel.MousePointerCrosshairsBorderColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsBorderSize"> + <tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsCrosshairsBorderSize" x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsBorderSize"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="2" @@ -339,15 +394,26 @@ Value="{x:Bind ViewModel.MousePointerCrosshairsBorderSize, Mode=TwoWay}" /> </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsCrosshairsOrientation" x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.MousePointerCrosshairsOrientation, Mode=TwoWay}"> + <ComboBoxItem x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Both" /> + <ComboBoxItem x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Vertical" /> + <ComboBoxItem x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Horizontal" /> + </ComboBox> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard ContentAlignment="Left"> <CheckBox x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsAutoHide" IsChecked="{x:Bind ViewModel.MousePointerCrosshairsAutoHide, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MousePointerCrosshairs_IsCrosshairsFixedLengthEnabled"> + <tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsIsCrosshairsFixedLengthEnabled" x:Uid="MouseUtils_MousePointerCrosshairs_IsCrosshairsFixedLengthEnabled"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.MousePointerCrosshairsIsFixedLengthEnabled, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsFixedLength" IsEnabled="{x:Bind ViewModel.MousePointerCrosshairsIsFixedLengthEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MouseUtilsMousePointerCrosshairsCrosshairsFixedLength" + x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsFixedLength" + IsEnabled="{x:Bind ViewModel.MousePointerCrosshairsIsFixedLengthEnabled, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="10" @@ -358,6 +424,27 @@ </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> + + <tkcontrols:SettingsExpander + x:Uid="MouseUtils_GlidingCursor" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.IsMousePointerCrosshairsEnabled, Mode=OneWay}"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.GlidingCursorActivationShortcut, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard x:Uid="MouseUtils_GlidingCursor_InitialSpeed"> + <Slider + Maximum="60" + Minimum="5" + Value="{x:Bind ViewModel.GlidingCursorTravelSpeed, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard x:Uid="MouseUtils_GlidingCursor_DelaySpeed"> + <Slider + Maximum="60" + Minimum="5" + Value="{x:Bind ViewModel.GlidingCursorDelaySpeed, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> </controls:SettingsGroup> </StackPanel> </controls:SettingsPageControl.ModuleContent> @@ -369,4 +456,4 @@ <controls:PageLink Link="https://michael-clayton.com/projects/fancymouse" Text="Michael Clayton's Mouse Jump (FancyMouse)" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs index 2a0cfa536f..fa34ca5293 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseUtilsPage.xaml.cs @@ -12,7 +12,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class MouseUtilsPage : Page, IRefreshablePage + public sealed partial class MouseUtilsPage : NavigablePage, IRefreshablePage { private MouseUtilsViewModel ViewModel { get; set; } @@ -22,7 +22,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views { // By mistake, the first release of Find My Mouse was saving settings in two places at the same time. // Delete the wrong path for Find My Mouse settings. - var tempSettingsUtils = new SettingsUtils(); + var tempSettingsUtils = SettingsUtils.Default; if (tempSettingsUtils.SettingsExists("Find My Mouse")) { var settingsFilePath = tempSettingsUtils.GetSettingsFilePath("Find My Mouse"); @@ -34,7 +34,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views { } - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new MouseUtilsViewModel( settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), @@ -42,12 +42,15 @@ namespace Microsoft.PowerToys.Settings.UI.Views SettingsRepository<MouseHighlighterSettings>.GetInstance(settingsUtils), SettingsRepository<MouseJumpSettings>.GetInstance(settingsUtils), SettingsRepository<MousePointerCrosshairsSettings>.GetInstance(settingsUtils), + SettingsRepository<CursorWrapSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); this.MouseUtils_MouseJump_Panel.ViewModel = ViewModel; + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml index 883e3674de..901c5634c4 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml @@ -1,49 +1,45 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.MouseWithoutBordersPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" - xmlns:converters="using:CommunityToolkit.WinUI.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - <Page.Resources> - <converters:BoolToVisibilityConverter x:Key="negativeBoolToVisibilityConverter" /> - <converters:BoolToObjectConverter + <local:NavigablePage.Resources> + <tkconverters:BoolToVisibilityConverter x:Key="negativeBoolToVisibilityConverter" /> + <tkconverters:BoolToObjectConverter x:Key="OneRowMatrixBoolToNumberOfRowsConverter" FalseValue="2" TrueValue="4" /> - </Page.Resources> + </local:NavigablePage.Resources> <controls:SettingsPageControl x:Uid="MouseWithoutBorders" ModuleImageSource="ms-appx:///Assets/Settings/Modules/MouseWithoutBorders.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel Orientation="Vertical"> <controls:SettingsGroup x:Uid="MouseWithoutBorders_ActivationSettings"> - <tkcontrols:SettingsCard - x:Uid="MouseWithoutBorders_Toggle_Enable" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseWithoutBorders.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch - x:Uid="ToggleSwitch" - IsEnabled="{x:Bind ViewModel.CanBeEnabled, Mode=OneWay}" - IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MouseWithoutBordersToggleEnable" + x:Uid="MouseWithoutBorders_Toggle_Enable" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseWithoutBorders.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + IsEnabled="{x:Bind ViewModel.CanBeEnabled, Mode=OneWay}" + IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="MouseWithoutBorders_KeySettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <controls:SettingsGroup + x:Name="KeySettingsGroup" + x:Uid="MouseWithoutBorders_KeySettings" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander - x:Name="MouseWithoutBorders_ConnectSettings" + Name="MouseWithoutBordersSecurityKey" x:Uid="MouseWithoutBorders_SecurityKey" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="{x:Bind ViewModel.ConnectFieldsVisible, Mode=TwoWay}"> @@ -75,24 +71,24 @@ x:Uid="MouseWithoutBorders_Connect" Command="{x:Bind ShowConnectFieldsCommand, Mode=OneTime}" Style="{StaticResource AccentButtonStyle}" - Visibility="{x:Bind Path=ViewModel.ConnectFieldsVisible, Mode=OneWay, Converter={StaticResource negativeBoolToVisibilityConverter}, ConverterParameter=True}" /> + Visibility="{x:Bind Path=ViewModel.ConnectFieldsVisible, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}, ConverterParameter=True}" /> </StackPanel> </tkcontrols:SettingsExpander> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_ThisMachineNameLabel"> + <tkcontrols:SettingsCard Name="MouseWithoutBordersThisMachineNameLabel" x:Uid="MouseWithoutBorders_ThisMachineNameLabel"> <StackPanel Orientation="Horizontal" Spacing="8"> - <TextBlock + <controls:IsEnabledTextBlock VerticalAlignment="Center" Foreground="{ThemeResource TextFillColorSecondaryBrush}" IsTextSelectionEnabled="True" Text="{x:Bind ViewModel.MachineHostName, Mode=OneTime}" /> <Button - Width="32" - Height="32" Padding="4" Command="{x:Bind CopyPCNameCommand, Mode=OneTime}" - Content="" - FontFamily="{StaticResource SymbolThemeFontFamily}"> + Content="{ui:FontIcon Glyph=, + FontSize=16}" + FontFamily="{StaticResource SymbolThemeFontFamily}" + Style="{StaticResource SubtleButtonStyle}"> <ToolTipService.ToolTip> <TextBlock x:Uid="MouseWithoutBorders_CopyMachineName" TextWrapping="Wrap" /> </ToolTipService.ToolTip> @@ -100,7 +96,10 @@ </StackPanel> </tkcontrols:SettingsCard> </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="MouseWithoutBorders_DeviceLayoutSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <controls:SettingsGroup + x:Name="DeviceLayoutSettingsGroup" + x:Uid="MouseWithoutBorders_DeviceLayoutSettings" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsCard HorizontalContentAlignment="Stretch" Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}" @@ -109,6 +108,7 @@ <ItemsControl x:Name="DevicesItemsControl" HorizontalAlignment="Center" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}" ItemsSource="{Binding MachineMatrixString, Mode=TwoWay}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> @@ -143,7 +143,6 @@ <RowDefinition Height="*" /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> - <FontIcon Margin="0,12,0,0" VerticalAlignment="Center" @@ -167,58 +166,60 @@ </ToolTipService.ToolTip> <TextBlock x:Uid="MouseWithoutBorders_ReconnectButton" /> </Button> - - </StackPanel> </tkcontrols:SettingsCard> - <InfoBar x:Uid="MouseWithoutBorders_CannotDragDropAsAdmin" IsClosable="False" IsOpen="{x:Bind ViewModel.ShowInfobarCannotDragDropAsAdmin, Mode=OneWay}" IsTabStop="{x:Bind ViewModel.ShowInfobarCannotDragDropAsAdmin, Mode=OneWay}" Severity="Informational" /> - - - - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_MatrixOneRow"> + <tkcontrols:SettingsCard Name="MouseWithoutBordersMatrixOneRow" x:Uid="MouseWithoutBorders_MatrixOneRow"> <ToggleSwitch x:Uid="MouseWithoutBorders_MatrixOneRow_ToggleSwitch" IsOn="{x:Bind ViewModel.MatrixOneRow, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="MouseWithoutBorders_ServiceSettings" IsEnabled="{x:Bind ViewModel.CanToggleUseService, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_UseService" IsEnabled="{x:Bind ViewModel.UseServiceSettingIsEnabled, Mode=OneWay}"> - <ToggleSwitch x:Uid="MouseWithoutBorders_UseService_ToggleSwitch" IsOn="{x:Bind ViewModel.UseService, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.ShowPolicyConfiguredInfoForServiceSettings, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.ShowPolicyConfiguredInfoForServiceSettings, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:SettingsGroup + x:Name="ServiceSettingsGroup" + x:Uid="MouseWithoutBorders_ServiceSettings" + IsEnabled="{x:Bind ViewModel.CanToggleUseService, Mode=OneWay}"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.ShowPolicyConfiguredInfoForServiceSettings, Mode=OneWay}"> + <tkcontrols:SettingsExpander + Name="MouseWithoutBordersUseService" + x:Uid="MouseWithoutBorders_UseService" + IsEnabled="{x:Bind ViewModel.UseServiceSettingIsEnabled, Mode=OneWay}"> + <ToggleSwitch x:Uid="MouseWithoutBorders_UseService_ToggleSwitch" IsOn="{x:Bind ViewModel.UseService, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard + Name="MouseWithoutBordersUninstallService" + x:Uid="MouseWithoutBorders_UninstallService" + ActionIcon="{ui:FontIcon Glyph=}" + Command="{x:Bind ViewModel.UninstallServiceEventHandler}" + IsClickEnabled="{x:Bind ViewModel.CanUninstallService, Mode=OneWay}" + IsEnabled="{x:Bind ViewModel.CanUninstallService, Mode=OneWay}" /> + </tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsExpander.ItemsFooter> + <InfoBar + x:Uid="MouseWithoutBorders_ServiceUserUninstallWarning" + IsClosable="False" + IsOpen="{x:Bind ViewModel.IsEnabled, Mode=OneWay}" + IsTabStop="{x:Bind ViewModel.IsEnabled, Mode=OneWay}" + Severity="Warning" /> + </tkcontrols:SettingsExpander.ItemsFooter> + </tkcontrols:SettingsExpander> + </controls:GPOInfoControl> <InfoBar x:Uid="MouseWithoutBorders_RunAsAdminText" IsClosable="False" IsOpen="{x:Bind ViewModel.ShowInfobarRunAsAdminText, Mode=OneWay}" IsTabStop="{x:Bind ViewModel.ShowInfobarRunAsAdminText, Mode=OneWay}" Severity="Informational" /> - <InfoBar - x:Uid="MouseWithoutBorders_ServiceUserUninstallWarning" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabled, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabled, Mode=OneWay}" - Severity="Warning" /> - <tkcontrols:SettingsCard - x:Uid="MouseWithoutBorders_UninstallService" - ActionIcon="{ui:FontIcon Glyph=}" - Command="{x:Bind ViewModel.UninstallServiceEventHandler}" - IsClickEnabled="{x:Bind ViewModel.CanUninstallService, Mode=OneWay}" - IsEnabled="{x:Bind ViewModel.CanUninstallService, Mode=OneWay}" /> </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="MouseWithoutBorders_Settings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + + + <controls:SettingsGroup + x:Name="BehaviorSettingsGroup" + x:Uid="MouseWithoutBorders_Settings" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <InfoBar x:Uid="GPO_SomeSettingsAreManaged" IsClosable="False" @@ -229,94 +230,177 @@ <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> </InfoBar.IconSource> </InfoBar> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_WrapMouse"> - <ToggleSwitch x:Uid="MouseWithoutBorders_WrapMouse_ToggleSwitch" IsOn="{x:Bind ViewModel.WrapMouse, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> + + <tkcontrols:SettingsExpander x:Uid="MouseWithoutBorders_MouseBehavior" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard Name="MouseWithoutBordersWrapMouse" ContentAlignment="Left"> + <CheckBox x:Uid="MouseWithoutBorders_WrapMouse" IsChecked="{x:Bind ViewModel.WrapMouse, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="MouseWithoutBordersMoveMouseRelatively" ContentAlignment="Left"> + <controls:CheckBoxWithDescriptionControl x:Uid="MouseWithoutBorders_MoveMouseRelatively" IsChecked="{x:Bind ViewModel.MoveMouseRelatively, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="MouseWithoutBordersBlockMouseAtScreenCorners" ContentAlignment="Left"> + <CheckBox x:Uid="MouseWithoutBorders_BlockMouseAtScreenCorners" IsChecked="{x:Bind ViewModel.BlockMouseAtScreenCorners, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="MouseWithoutBordersHideMouseAtScreenEdge" ContentAlignment="Left"> + <controls:CheckBoxWithDescriptionControl x:Uid="MouseWithoutBorders_HideMouseAtScreenEdge" IsChecked="{x:Bind ViewModel.HideMouseAtScreenEdge, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="MouseWithoutBordersDrawMouseCursor" ContentAlignment="Left"> + <controls:CheckBoxWithDescriptionControl x:Uid="MouseWithoutBorders_DrawMouseCursor" IsChecked="{x:Bind ViewModel.DrawMouseCursor, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + <tkcontrols:SettingsExpander + Name="MouseWithoutBordersShareClipboard" x:Uid="MouseWithoutBorders_ShareClipboard" + HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.CardForShareClipboardSettingIsEnabled, Mode=OneWay}" IsExpanded="True"> <ToggleSwitch x:Uid="MouseWithoutBorders_ShareClipboard_ToggleSwitch" IsOn="{x:Bind ViewModel.ShareClipboard, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_TransferFile" IsEnabled="{x:Bind ViewModel.CardForTransferFileSettingIsEnabled, Mode=OneWay}"> - <ToggleSwitch x:Uid="MouseWithoutBorders_TransferFile_ToggleSwitch" IsOn="{x:Bind ViewModel.TransferFile, Mode=TwoWay}" /> + <tkcontrols:SettingsCard + Name="MouseWithoutBordersTransferFile" + ContentAlignment="Left" + IsEnabled="{x:Bind ViewModel.CardForTransferFileSettingIsEnabled, Mode=OneWay}"> + <CheckBox x:Uid="MouseWithoutBorders_TransferFile" IsChecked="{x:Bind ViewModel.TransferFile, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_HideMouseAtScreenEdge"> - <ToggleSwitch x:Uid="MouseWithoutBorders_HideMouseAtScreenEdge_ToggleSwitch" IsOn="{x:Bind ViewModel.HideMouseAtScreenEdge, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_DrawMouseCursor"> - <ToggleSwitch x:Uid="MouseWithoutBorders_DrawMouseCursor_ToggleSwitch" IsOn="{x:Bind ViewModel.DrawMouseCursor, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_ValidateRemoteMachineIP" IsEnabled="{x:Bind ViewModel.CardForValidateRemoteIpSettingIsEnabled, Mode=OneWay}"> + + <tkcontrols:SettingsCard + Name="MouseWithoutBordersValidateRemoteMachineIP" + x:Uid="MouseWithoutBorders_ValidateRemoteMachineIP" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.CardForValidateRemoteIpSettingIsEnabled, Mode=OneWay}"> <ToggleSwitch x:Uid="MouseWithoutBorders_ValidateRemoteMachineIP_ToggleSwitch" IsOn="{x:Bind ViewModel.ValidateRemoteMachineIP, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_SameSubnetOnly" IsEnabled="{x:Bind ViewModel.CardForSameSubnetOnlySettingIsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MouseWithoutBordersSameSubnetOnly" + x:Uid="MouseWithoutBorders_SameSubnetOnly" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.CardForSameSubnetOnlySettingIsEnabled, Mode=OneWay}"> <ToggleSwitch x:Uid="MouseWithoutBorders_SameSubnetOnly_ToggleSwitch" IsOn="{x:Bind ViewModel.SameSubnetOnly, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_BlockScreenSaverOnOtherMachines" IsEnabled="{x:Bind ViewModel.CardForBlockScreensaverSettingIsEnabled, Mode=OneWay}"> - <ToggleSwitch x:Uid="MouseWithoutBorders_BlockScreenSaverOnOtherMachines_ToggleSwitch" IsOn="{x:Bind ViewModel.BlockScreenSaverOnOtherMachines, Mode=TwoWay}" /> + <tkcontrols:SettingsCard + Name="MouseWithoutBordersBlockScreenSaverOnOtherMachines" + x:Uid="MouseWithoutBorders_BlockScreenSaverOnOtherMachines" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.CardForBlockScreensaverSettingIsEnabled, Mode=OneWay}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.BlockScreenSaverOnOtherMachines, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_MoveMouseRelatively"> - <ToggleSwitch x:Uid="MouseWithoutBorders_MoveMouseRelatively_ToggleSwitch" IsOn="{x:Bind ViewModel.MoveMouseRelatively, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_BlockMouseAtScreenCorners"> - <ToggleSwitch x:Uid="MouseWithoutBorders_BlockMouseAtScreenCorners_ToggleSwitch" IsOn="{x:Bind ViewModel.BlockMouseAtScreenCorners, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_ShowClipboardAndNetworkStatusMessages"> - <ToggleSwitch x:Uid="MouseWithoutBorders_ShowClipboardAndNetworkStatusMessages_ToggleSwitch" IsOn="{x:Bind ViewModel.ShowClipboardAndNetworkStatusMessages, Mode=TwoWay}" /> + <tkcontrols:SettingsCard + Name="MouseWithoutBordersShowClipboardAndNetworkStatusMessages" + x:Uid="MouseWithoutBorders_ShowClipboardAndNetworkStatusMessages" + HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.ShowClipboardAndNetworkStatusMessages, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="MouseWithoutBorders_KeyboardShortcuts_Group" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_EasyMouseOption" HeaderIcon="{ui:FontIcon Glyph=}"> + + <controls:SettingsGroup x:Uid="MouseWithoutBorders_EasyMouseSettings_Group" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander x:Uid="MouseWithoutBorders_EasyMouseOption" HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.EasyMouseOptionIndex, Mode=TwoWay}"> <ComboBoxItem x:Uid="MouseWithoutBorders_EasyMouseOption_Disabled" /> <ComboBoxItem x:Uid="MouseWithoutBorders_EasyMouseOption_Enabled" /> <ComboBoxItem x:Uid="MouseWithoutBorders_EasyMouseOption_Ctrl" /> <ComboBoxItem x:Uid="MouseWithoutBorders_EasyMouseOption_Shift" /> </ComboBox> - </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard + Name="MouseWithoutBordersDisableEasyMouseWhenForegroundWindowIsFullscreen" + x:Uid="MouseWithoutBorders_DisableEasyMouseWhenForegroundWindowIsFullscreen" + IsEnabled="{x:Bind ViewModel.EasyMouseEnabled, Mode=OneWay}"> + <ToggleSwitch x:Uid="MouseWithoutBorders_DisableEasyMouseWhenForegroundWindowIsFullscreen_ToggleSwitch" IsOn="{x:Bind ViewModel.DisableEasyMouseWhenForegroundWindowIsFullscreen, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsExpander.ItemsFooter> + <InfoBar + x:Uid="MouseWithoutBorders_CanOnlyStopEasyMouseIfMovingFromHostMachine" + IsClosable="False" + IsOpen="True" + Severity="Informational" /> + </tkcontrols:SettingsExpander.ItemsFooter> + </tkcontrols:SettingsExpander> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_ToggleEasyMouseShortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl - MinWidth="{StaticResource SettingActionControlMinWidth}" - AllowDisable="True" - HotkeySettings="{x:Bind Path=ViewModel.ToggleEasyMouseShortcut, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_LockMachinesShortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl - MinWidth="{StaticResource SettingActionControlMinWidth}" - AllowDisable="True" - HotkeySettings="{x:Bind Path=ViewModel.LockMachinesShortcut, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_Switch2AllPcShortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl - MinWidth="{StaticResource SettingActionControlMinWidth}" - AllowDisable="True" - HotkeySettings="{x:Bind Path=ViewModel.HotKeySwitch2AllPC, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_ReconnectShortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl - MinWidth="{StaticResource SettingActionControlMinWidth}" - AllowDisable="True" - HotkeySettings="{x:Bind Path=ViewModel.ReconnectShortcut, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_SwitchBetweenMachineShortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.SelectedSwitchBetweenMachineShortcutOptionsIndex, Mode=TwoWay}"> - <!-- These should be in the same order as the array items in MouseWithoutBordersViewModel.cs --> - <ComboBoxItem x:Uid="MouseWithoutBorders_SwitchBetweenMachineShortcut_F1" /> - <ComboBoxItem x:Uid="MouseWithoutBorders_SwitchBetweenMachineShortcut_1" /> - <ComboBoxItem x:Uid="MouseWithoutBorders_SwitchBetweenMachineShortcut_Disabled" /> - </ComboBox> - </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander + Name="MouseWithoutBordersDisableEasyMouseWhenForegroundWindowIsFullscreen_Expander" + x:Uid="MouseWithoutBorders_DisableEasyMouseWhenForegroundWindowIsFullscreen_Expander" + HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.IsEasyMouseBlockingOnFullscreenEnabled, Mode=OneWay}" + IsExpanded="False"> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard + Name="MouseWithoutBordersDisableEasyMouseWhenForegroundWindowIsFullscreenExcludedApps" + HorizontalContentAlignment="Stretch" + ContentAlignment="Vertical"> + <TextBox + x:Uid="MouseWithoutBorders_DisableEasyMouseWhenForegroundWindowIsFullscreen_TextBoxControl" + MinWidth="240" + MinHeight="160" + AcceptsReturn="True" + IsSpellCheckEnabled="False" + ScrollViewer.IsVerticalRailEnabled="True" + ScrollViewer.VerticalScrollBarVisibility="Visible" + ScrollViewer.VerticalScrollMode="Enabled" + Text="{x:Bind ViewModel.EasyMouseFullscreenSwitchBlockExcludedApps, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" + TextWrapping="Wrap" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="MouseWithoutBorders_AdvancedSettings_Group" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsExpander x:Uid="MouseWithoutBorders_IPAddressMapping" IsExpanded="True"> + + <controls:SettingsGroup + Name="MouseWithoutBordersKeyboardShortcutsGroup" + x:Uid="MouseWithoutBorders_KeyboardShortcuts_Group" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander x:Uid="MouseWithoutBorders_Shortcuts" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard Name="MouseWithoutBordersToggleEasyMouseShortcut" x:Uid="MouseWithoutBorders_ToggleEasyMouseShortcut"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + HotkeySettings="{x:Bind Path=ViewModel.ToggleEasyMouseShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="MouseWithoutBordersLockMachinesShortcut" x:Uid="MouseWithoutBorders_LockMachinesShortcut"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + HotkeySettings="{x:Bind Path=ViewModel.LockMachinesShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + <tkcontrols:SettingsCard Name="MouseWithoutBordersSwitch2AllPcShortcut" x:Uid="MouseWithoutBorders_Switch2AllPcShortcut"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + HotkeySettings="{x:Bind Path=ViewModel.HotKeySwitch2AllPC, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + <tkcontrols:SettingsCard Name="MouseWithoutBordersReconnectShortcut" x:Uid="MouseWithoutBorders_ReconnectShortcut"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AllowDisable="True" + HotkeySettings="{x:Bind Path=ViewModel.ReconnectShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + <tkcontrols:SettingsCard Name="MouseWithoutBordersSwitchBetweenMachineShortcut" x:Uid="MouseWithoutBorders_SwitchBetweenMachineShortcut"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.SelectedSwitchBetweenMachineShortcutOptionsIndex, Mode=TwoWay}"> + <!-- These should be in the same order as the array items in MouseWithoutBordersViewModel.cs --> + <ComboBoxItem x:Uid="MouseWithoutBorders_SwitchBetweenMachineShortcut_F1" /> + <ComboBoxItem x:Uid="MouseWithoutBorders_SwitchBetweenMachineShortcut_1" /> + <ComboBoxItem x:Uid="MouseWithoutBorders_SwitchBetweenMachineShortcut_Disabled" /> + </ComboBox> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + </controls:SettingsGroup> + <controls:SettingsGroup + x:Name="AdvancedSettingsGroup" + x:Uid="MouseWithoutBorders_AdvancedSettings_Group" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander + Name="MouseWithoutBordersIPAddressMapping" + x:Uid="MouseWithoutBorders_IPAddressMapping" + IsExpanded="True"> <tkcontrols:SettingsExpander.Items> <tkcontrols:SettingsCard HorizontalContentAlignment="Stretch" @@ -391,25 +475,24 @@ </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="MouseWithoutBorders_TroubleShooting" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <controls:SettingsGroup + x:Name="TroubleshootingGroup" + x:Uid="MouseWithoutBorders_TroubleShooting" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsCard + Name="MouseWithoutBordersAddFirewallRuleButtonControl" x:Uid="MouseWithoutBorders_AddFirewallRuleButtonControl" ActionIcon="{ui:FontIcon Glyph=}" Command="{x:Bind ViewModel.AddFirewallRuleEventHandler}" IsClickEnabled="True" /> - <tkcontrols:SettingsCard x:Uid="MouseWithoutBorders_ShowOriginalUI" IsEnabled="{x:Bind ViewModel.CardForOriginalUiSettingIsEnabled, Mode=OneWay}"> - <ToggleSwitch x:Uid="MouseWithoutBorders_ShowOriginalUI_ToggleSwitch" IsOn="{x:Bind ViewModel.ShowOriginalUI, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.ShowPolicyConfiguredInfoForOriginalUiSetting, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.ShowPolicyConfiguredInfoForOriginalUiSetting, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.ShowPolicyConfiguredInfoForOriginalUiSetting, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="MouseWithoutBordersShowOriginalUI" + x:Uid="MouseWithoutBorders_ShowOriginalUI" + IsEnabled="{x:Bind ViewModel.CardForOriginalUiSettingIsEnabled, Mode=OneWay}"> + <ToggleSwitch x:Uid="MouseWithoutBorders_ShowOriginalUI_ToggleSwitch" IsOn="{x:Bind ViewModel.ShowOriginalUI, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> </controls:SettingsGroup> </StackPanel> </controls:SettingsPageControl.ModuleContent> @@ -421,4 +504,4 @@ <controls:PageLink Link="https://github.com/microsoft/PowerToys/blob/main/COMMUNITY.md#mouse-without-borders-original-contributors" Text="Truong Do (Đỗ Đức Trường) and other original contributors" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml.cs index f29056245f..2db0d7a5d6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/MouseWithoutBordersPage.xaml.cs @@ -6,7 +6,6 @@ using System; using System.IO.Abstractions; using System.Threading.Tasks; using System.Windows.Input; - using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Utilities; @@ -14,6 +13,7 @@ using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Navigation; using Windows.ApplicationModel.DataTransfer; using WinRT; @@ -21,7 +21,7 @@ using static Microsoft.PowerToys.Settings.UI.ViewModels.MouseWithoutBordersViewM namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class MouseWithoutBordersPage : Page, IRefreshablePage + public sealed partial class MouseWithoutBordersPage : NavigablePage, IRefreshablePage { private const string MouseWithoutBordersDragDropCheckString = "MWB Device Drag Drop"; @@ -33,7 +33,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views public MouseWithoutBordersPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new MouseWithoutBordersViewModel( settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), @@ -47,6 +47,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views DataContext = ViewModel; InitializeComponent(); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void OnConfigFileUpdate() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml index 45c1856982..d837f35c3f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml @@ -1,9 +1,10 @@ -<Page +<helper:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.NewPlusPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:helper="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:local="clr-namespace:Microsoft.PowerToys.Settings.UI.ViewModels" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" @@ -17,25 +18,20 @@ ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical" Spacing="2"> - <tkcontrols:SettingsCard - x:Uid="NewPlus_Enable_Toggle" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/NewPlus.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="NewPlusEnableToggle" + x:Uid="NewPlus_Enable_Toggle" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/NewPlus.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=NewPlusEnableToggle, Path=Header}" + IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="NewPlus_Templates" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsCard + Name="NewPlusTemplatesLocation" x:Uid="NewPlus_Templates_Location" ActionIcon="{ui:FontIcon Glyph=}" Command="{x:Bind ViewModel.OpenCurrentNewTemplateFolder}" @@ -52,7 +48,6 @@ <HyperlinkButton x:Uid="NewPlus_Templates_Location_Learn_More" NavigateUri="https://aka.ms/PowerToysOverview_NewPlus_TemplatesLocation" /> </StackPanel> </tkcontrols:SettingsCard.Description> - </tkcontrols:SettingsCard> <InfoBar @@ -61,25 +56,25 @@ IsOpen="{x:Bind ViewModel.IsEnabled, Mode=OneWay}" IsTabStop="{x:Bind ViewModel.IsEnabled, Mode=OneWay}" Severity="Informational" /> - </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="NewPlus_Display_Options" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="NewPlus_Hide_File_Extension_Toggle" IsEnabled="{x:Bind ViewModel.IsHideFileExtSettingsCardEnabled, Mode=OneWay}"> - <ToggleSwitch x:Uid="HideFileExtensionToggle" IsOn="{x:Bind ViewModel.HideFileExtension, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsHideFileExtSettingGPOConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsHideFileExtSettingGPOConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - <tkcontrols:SettingsCard x:Uid="NewPlus_Hide_Starting_Digits_Toggle"> - <ToggleSwitch x:Uid="HideStartingDigitsToggle" IsOn="{x:Bind ViewModel.HideStartingDigits, Mode=TwoWay}" /> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsHideFileExtSettingGPOConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="NewPlusHideFileExtensionToggle" + x:Uid="NewPlus_Hide_File_Extension_Toggle" + IsEnabled="{x:Bind ViewModel.IsHideFileExtSettingsCardEnabled, Mode=OneWay}"> + <ToggleSwitch + x:Uid="HideFileExtensionToggle" + AutomationProperties.Name="{Binding ElementName=NewPlusHideFileExtensionToggle, Path=Header}" + IsOn="{x:Bind ViewModel.HideFileExtension, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> + <tkcontrols:SettingsCard Name="NewPlusHideStartingDigitsToggle" x:Uid="NewPlus_Hide_Starting_Digits_Toggle"> + + <ToggleSwitch + x:Uid="HideStartingDigitsToggle" + AutomationProperties.Name="{Binding ElementName=NewPlusHideStartingDigitsToggle, Path=Header}" + IsOn="{x:Bind ViewModel.HideStartingDigits, Mode=TwoWay}" /> <tkcontrols:SettingsCard.Description> <TextBlock x:Uid="NewPlus_Hide_Starting_Digits_Description" /> </tkcontrols:SettingsCard.Description> @@ -87,113 +82,114 @@ </controls:SettingsGroup> <controls:SettingsGroup x:Uid="NewPlus_behavior" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="NewPlus_Behaviour_Replace_Variables_Toggle" IsEnabled="{x:Bind ViewModel.IsReplaceVariablesSettingsCardEnabled, Mode=OneWay}"> - <StackPanel Orientation="Horizontal" Spacing="4"> - <ToggleSwitch x:Uid="ReplaceVariablesToggle" IsOn="{x:Bind ViewModel.ReplaceVariables, Mode=TwoWay}" /> - <Button - x:Uid="FileCreationButton" - Width="28" - Height="40" - Margin="0,0,-4,0" - Padding="0" - HorizontalAlignment="Right" - VerticalAlignment="Center" - Background="Transparent" - BorderBrush="Transparent" - Content="" - FontFamily="{ThemeResource SymbolThemeFontFamily}"> - <Button.Flyout> - <Flyout x:Name="VariableExamplesFlyout" ShouldConstrainToRootBounds="False"> - <Grid> - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="62" /> - <ColumnDefinition Width="300" /> - </Grid.ColumnDefinitions> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsReplaceVariablesSettingGPOConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="NewPlusBehaviourReplaceVariablesToggle" + x:Uid="NewPlus_Behaviour_Replace_Variables_Toggle" + IsEnabled="{x:Bind ViewModel.IsReplaceVariablesSettingsCardEnabled, Mode=OneWay}"> + <StackPanel Orientation="Horizontal" Spacing="4"> + <ToggleSwitch + x:Uid="ReplaceVariablesToggle" + AutomationProperties.Name="{Binding ElementName=NewPlusBehaviourReplaceVariablesToggle, Path=Header}" + IsOn="{x:Bind ViewModel.ReplaceVariables, Mode=TwoWay}" /> + <Button + x:Uid="FileCreationButton" + Width="28" + Height="40" + Margin="0,0,-4,0" + Padding="0" + HorizontalAlignment="Right" + VerticalAlignment="Center" + Background="Transparent" + BorderBrush="Transparent" + Content="" + FontFamily="{ThemeResource SymbolThemeFontFamily}"> + <Button.Flyout> + <Flyout x:Name="VariableExamplesFlyout" ShouldConstrainToRootBounds="False"> + <Grid> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="62" /> + <ColumnDefinition Width="300" /> + </Grid.ColumnDefinitions> + <TextBlock + Grid.Row="0" + Grid.Column="0" + Text="$YYYY" /> + <TextBlock + Grid.Row="0" + Grid.Column="1" + Margin="0,0,0,5" + TextWrapping="Wrap"><Run x:Uid="NewPlus_Year_YYYY_Variable_Description" /></TextBlock> - <TextBlock - Grid.Row="0" - Grid.Column="0" - Text="$YYYY" /> - <TextBlock - Grid.Row="0" - Grid.Column="1" - Margin="0,0,0,5" - TextWrapping="Wrap"><Run x:Uid="NewPlus_Year_YYYY_Variable_Description" /></TextBlock> + <TextBlock + Grid.Row="1" + Grid.Column="0" + Text="$MM" /> + <TextBlock + Grid.Row="1" + Grid.Column="1" + Margin="0,0,0,5" + TextWrapping="Wrap"><Run x:Uid="NewPlus_Month_MM_Variable_Description" /></TextBlock> - <TextBlock - Grid.Row="1" - Grid.Column="0" - Text="$MM" /> - <TextBlock - Grid.Row="1" - Grid.Column="1" - Margin="0,0,0,5" - TextWrapping="Wrap"><Run x:Uid="NewPlus_Month_MM_Variable_Description" /></TextBlock> + <TextBlock + Grid.Row="2" + Grid.Column="0" + Text="$DD" /> + <TextBlock + Grid.Row="2" + Grid.Column="1" + Margin="0,0,0,5" + TextWrapping="Wrap"><Run x:Uid="NewPlus_Day_DD_Variable_Description" /></TextBlock> - <TextBlock - Grid.Row="2" - Grid.Column="0" - Text="$DD" /> - <TextBlock - Grid.Row="2" - Grid.Column="1" - Margin="0,0,0,5" - TextWrapping="Wrap"><Run x:Uid="NewPlus_Day_DD_Variable_Description" /></TextBlock> + <TextBlock + Grid.Row="3" + Grid.Column="0" + Text="$hh" /> + <TextBlock + Grid.Row="3" + Grid.Column="1" + Margin="0,0,0,5" + TextWrapping="Wrap"><Run x:Uid="NewPlus_Hour_hh_Variable_Description" /></TextBlock> - <TextBlock - Grid.Row="3" - Grid.Column="0" - Text="$hh" /> - <TextBlock - Grid.Row="3" - Grid.Column="1" - Margin="0,0,0,5" - TextWrapping="Wrap"><Run x:Uid="NewPlus_Hour_hh_Variable_Description" /></TextBlock> + <TextBlock + Grid.Row="4" + Grid.Column="0" + Text="$mm" /> + <TextBlock + Grid.Row="4" + Grid.Column="1" + Margin="0,0,0,5" + TextWrapping="Wrap"><Run x:Uid="NewPlus_Minute_mm_Variable_Description" /></TextBlock> - <TextBlock - Grid.Row="4" - Grid.Column="0" - Text="$mm" /> - <TextBlock - Grid.Row="4" - Grid.Column="1" - Margin="0,0,0,5" - TextWrapping="Wrap"><Run x:Uid="NewPlus_Minute_mm_Variable_Description" /></TextBlock> - - <TextBlock - Grid.Row="5" - Grid.Column="0" - Text="$ss" /> - <TextBlock - Grid.Row="5" - Grid.Column="1" - Margin="0,0,0,0" - TextWrapping="Wrap"><Run x:Uid="NewPlus_Second_ss_Variable_Description" /></TextBlock> - </Grid> - </Flyout> - </Button.Flyout> - </Button> - </StackPanel> - <tkcontrols:SettingsCard.Description> - <StackPanel> - <HyperlinkButton x:Uid="NewPlus_Behaviour_Replace_Variables_Learn_More" NavigateUri="https://aka.ms/PowerToysOverview_NewPlus" /> + <TextBlock + Grid.Row="5" + Grid.Column="0" + Text="$ss" /> + <TextBlock + Grid.Row="5" + Grid.Column="1" + Margin="0,0,0,0" + TextWrapping="Wrap"><Run x:Uid="NewPlus_Second_ss_Variable_Description" /></TextBlock> + </Grid> + </Flyout> + </Button.Flyout> + </Button> </StackPanel> - </tkcontrols:SettingsCard.Description> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsReplaceVariablesSettingGPOConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsReplaceVariablesSettingGPOConfigured, Mode=OneWay}" - Severity="Informational" /> + <tkcontrols:SettingsCard.Description> + <StackPanel> + <HyperlinkButton x:Uid="NewPlus_Behaviour_Replace_Variables_Learn_More" NavigateUri="https://aka.ms/PowerToysOverview_NewPlus" /> + </StackPanel> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> </controls:SettingsGroup> </StackPanel> </controls:SettingsPageControl.ModuleContent> @@ -206,4 +202,4 @@ </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</helper:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml.cs index 2df68743c4..997f6c7771 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/NewPlusPage.xaml.cs @@ -9,14 +9,14 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class NewPlusPage : Page, IRefreshablePage + public sealed partial class NewPlusPage : NavigablePage, IRefreshablePage { private NewPlusViewModel ViewModel { get; set; } public NewPlusPage() { InitializeComponent(); - var settings_utils = new SettingsUtils(); + var settings_utils = SettingsUtils.Default; ViewModel = new NewPlusViewModel(settings_utils, SettingsRepository<GeneralSettings>.GetInstance(settings_utils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml index 1bf46dec07..49da343744 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.PeekPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -13,42 +14,54 @@ <controls:SettingsPageControl x:Uid="Peek" ModuleImageSource="ms-appx:///Assets/Settings/Modules/Peek.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="Peek_EnablePeek" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Peek.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PeekEnablePeek" + x:Uid="Peek_EnablePeek" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Peek.png}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="Peek_Activation_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="Activation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard x:Uid="Peek_ActivationMethod"> + <ComboBox SelectedIndex="{x:Bind ViewModel.EnableSpaceToActivate, Mode=TwoWay, Converter={StaticResource BoolToComboBoxIndexConverter}}"> + <ComboBoxItem x:Uid="Peek_ActivationMethod_CustomizedShortcut" /> + <ComboBoxItem x:Uid="Peek_ActivationMethod_SpaceBar" /> + </ComboBox> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="ActivationShortcut" + x:Uid="Activation_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + Visibility="{x:Bind ViewModel.EnableSpaceToActivate, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.ActivationShortcut, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="Peek_BehaviorHeader" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="Peek_AlwaysRunNotElevated" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="PeekAlwaysRunNotElevated" + x:Uid="Peek_AlwaysRunNotElevated" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.AlwaysRunNotElevated, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="Peek_CloseAfterLosingFocus" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="PeekCloseAfterLosingFocus" + x:Uid="Peek_CloseAfterLosingFocus" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.CloseAfterLosingFocus, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="Peek_ConfirmFileDelete" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="PeekConfirmFileDelete" + x:Uid="Peek_ConfirmFileDelete" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.ConfirmFileDelete, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="Peek_Preview_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander + Name="PeekSourceCodeHeader" x:Uid="Peek_SourceCode_Header" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> @@ -62,7 +75,10 @@ IsChecked="{x:Bind ViewModel.SourceCodeTryFormat, Mode=TwoWay}" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="Peek_SourceCode_FontSize" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PeekSourceCodeFontSize" + x:Uid="Peek_SourceCode_FontSize" + IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" Maximum="100" @@ -86,4 +102,4 @@ <controls:PageLink x:Uid="LearnMore_Peek" Link="https://aka.ms/PowerToysOverview_Peek" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs index 24ca93208a..b848ae86dd 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PeekPage.xaml.cs @@ -9,13 +9,13 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class PeekPage : Page, IRefreshablePage + public sealed partial class PeekPage : NavigablePage, IRefreshablePage { private PeekViewModel ViewModel { get; set; } public PeekPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new PeekViewModel( settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), @@ -23,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views DispatcherQueue); DataContext = ViewModel; InitializeComponent(); + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml index 8ad2d412a2..d8d76463a5 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml @@ -1,16 +1,17 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.PowerAccentPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:Lib="using:Microsoft.PowerToys.Settings.UI.Library" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - <Page.Resources> + <local:NavigablePage.Resources> <CollectionViewSource x:Name="LanguagesCustomViewSource" IsSourceGrouped="True" @@ -18,7 +19,7 @@ <DataTemplate x:Key="LanguageViewTemplate" x:DataType="Lib:PowerAccentLanguageModel"> <TextBlock Text="{x:Bind Language}" /> </DataTemplate> - </Page.Resources> + </local:NavigablePage.Resources> <controls:SettingsPageControl x:Uid="QuickAccent" @@ -26,26 +27,17 @@ ModuleImageSource="ms-appx:///Assets/Settings/Modules/QuickAccent.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="QuickAccent_EnableQuickAccent" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/QuickAccent.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="QuickAccentEnableQuickAccent" + x:Uid="QuickAccent_EnableQuickAccent" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/QuickAccent.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="QuickAccent_Activation_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander + Name="QuickAccentActivationShortcut" x:Uid="QuickAccent_Activation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> @@ -67,6 +59,7 @@ x:Uid="QuickAccent_Language" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander + Name="QuickAccentSelectedLanguage" x:Uid="QuickAccent_SelectedLanguage" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="False"> @@ -157,7 +150,10 @@ </controls:SettingsGroup> <controls:SettingsGroup x:Uid="QuickAccent_Toolbar" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="QuickAccent_ToolbarPosition" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="QuickAccentToolbarPosition" + x:Uid="QuickAccent_ToolbarPosition" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.ToolbarPositionIndex, Mode=TwoWay}"> <ComboBoxItem x:Uid="QuickAccent_ToolbarPosition_TopCenter" /> <ComboBoxItem x:Uid="QuickAccent_ToolbarPosition_BottomCenter" /> @@ -170,19 +166,31 @@ <ComboBoxItem x:Uid="QuickAccent_ToolbarPosition_Center" /> </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="QuickAccent_Description_Indicator" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="QuickAccentDescriptionIndicator" + x:Uid="QuickAccent_Description_Indicator" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="QuickAccent_UnicodeDescription_ToggleSwitch" IsOn="{x:Bind ViewModel.ShowUnicodeDescription, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="QuickAccent_SortByUsageFrequency_Indicator" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="QuickAccentSortByUsageFrequencyIndicator" + x:Uid="QuickAccent_SortByUsageFrequency_Indicator" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="QuickAccent_SortByUsageFrequency_ToggleSwitch" IsOn="{x:Bind ViewModel.SortByUsageFrequency, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="QuickAccent_StartSelectionFromTheLeft_Indicator" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="QuickAccentStartSelectionFromTheLeftIndicator" + x:Uid="QuickAccent_StartSelectionFromTheLeft_Indicator" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="QuickAccent_StartSelectionFromTheLeft_ToggleSwitch" IsOn="{x:Bind ViewModel.StartSelectionFromTheLeft, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="QuickAccent_Behavior" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="QuickAccent_InputTimeMs" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="QuickAccentInputTimeMs" + x:Uid="QuickAccent_InputTimeMs" + HeaderIcon="{ui:FontIcon Glyph=}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="100" @@ -192,7 +200,10 @@ Value="{x:Bind ViewModel.InputTimeMs, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsExpander x:Uid="QuickAccent_ExcludedApps" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsExpander + Name="QuickAccentExcludedApps" + x:Uid="QuickAccent_ExcludedApps" + HeaderIcon="{ui:FontIcon Glyph=}"> <tkcontrols:SettingsExpander.Items> <tkcontrols:SettingsCard HorizontalContentAlignment="Stretch" ContentAlignment="Vertical"> <TextBox @@ -219,4 +230,4 @@ <controls:PageLink Link="https://github.com/damienleroy/PowerAccent" Text="Damien Leroy's PowerAccent" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml.cs index 1ba7d11227..0f60a8dcd3 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerAccentPage.xaml.cs @@ -11,13 +11,13 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class PowerAccentPage : Page, IRefreshablePage + public sealed partial class PowerAccentPage : NavigablePage, IRefreshablePage { private PowerAccentViewModel ViewModel { get; set; } public PowerAccentPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new PowerAccentViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; this.InitializeComponent(); 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 @@ +<local:NavigablePage + x:Class="Microsoft.PowerToys.Settings.UI.Views.PowerDisplayPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:library="using:Microsoft.PowerToys.Settings.UI.Library" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:pdmodels="using:PowerDisplay.Common.Models" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:ui="using:CommunityToolkit.WinUI" + AutomationProperties.LandmarkType="Main" + mc:Ignorable="d"> + + <controls:SettingsPageControl x:Uid="PowerDisplay" ModuleImageSource="ms-appx:///Assets/Settings/Modules/PowerDisplay.png"> + <controls:SettingsPageControl.ModuleContent> + <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + x:Uid="PowerDisplay_Enable_PowerDisplay" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerDisplay.png}" + IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> + + <controls:SettingsGroup x:Uid="Shortcut" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard x:Uid="PowerDisplay_ActivationShortcut" HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.ActivationShortcut, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:SettingsGroup> + + <controls:SettingsGroup x:Uid="PowerDisplay_Configuration_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + x:Uid="PowerDisplay_LaunchButtonControl" + ActionIcon="{ui:FontIcon Glyph=}" + Command="{x:Bind ViewModel.LaunchEventHandler}" + HeaderIcon="{ui:FontIcon Glyph=}" + IsClickEnabled="True" /> + <tkcontrols:SettingsCard x:Uid="PowerDisplay_RestoreSettingsOnStartup" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch x:Uid="PowerDisplay_RestoreSettingsOnStartup_ToggleSwitch" IsOn="{x:Bind ViewModel.RestoreSettingsOnStartup, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard x:Uid="PowerDisplay_ShowSystemTrayIcon" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.ShowSystemTrayIcon, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + <tkcontrols:SettingsCard x:Uid="PowerDisplay_MonitorRefreshDelay" HeaderIcon="{ui:FontIcon Glyph=}"> + <ComboBox + MinWidth="120" + ItemsSource="{x:Bind ViewModel.MonitorRefreshDelayOptions}" + SelectedItem="{x:Bind ViewModel.MonitorRefreshDelay, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + </controls:SettingsGroup> + + <controls:SettingsGroup x:Uid="PowerDisplay_FlyoutOptions_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard x:Uid="PowerDisplay_ShowProfileSwitcher" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.ShowProfileSwitcher, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard x:Uid="PowerDisplay_ShowIdentifyMonitorsButton" HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.ShowIdentifyMonitorsButton, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:SettingsGroup> + + <!-- Custom VCP Name Mappings --> + <controls:SettingsGroup x:Uid="PowerDisplay_CustomVcpMappings_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander + x:Uid="PowerDisplay_CustomVcpMappings" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="{x:Bind ViewModel.HasCustomVcpMappings, Mode=OneWay}" + ItemsSource="{x:Bind ViewModel.CustomVcpMappings, Mode=OneWay}"> + <tkcontrols:SettingsExpander.ItemTemplate> + <DataTemplate x:DataType="pdmodels:CustomVcpValueMapping"> + <tkcontrols:SettingsCard Description="{x:Bind VcpCodeDisplayName}" Header="{x:Bind DisplaySummary}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <Button + Click="EditCustomMapping_Click" + Content="{ui:FontIcon Glyph=, + FontSize=14}" + Style="{StaticResource SubtleButtonStyle}" + Tag="{x:Bind}" + ToolTipService.ToolTip="Edit" /> + <Button + Click="DeleteCustomMapping_Click" + Content="{ui:FontIcon Glyph=, + FontSize=14}" + Style="{StaticResource SubtleButtonStyle}" + Tag="{x:Bind}" + ToolTipService.ToolTip="Delete" /> + </StackPanel> + </tkcontrols:SettingsCard> + </DataTemplate> + </tkcontrols:SettingsExpander.ItemTemplate> + + <!-- Add mapping button --> + <Button x:Uid="PowerDisplay_AddCustomMappingButton" Click="AddCustomMapping_Click"> + <StackPanel Orientation="Horizontal" Spacing="6"> + <FontIcon FontSize="14" Glyph="" /> + <TextBlock x:Uid="PowerDisplay_AddCustomMapping_Text" /> + </StackPanel> + </Button> + </tkcontrols:SettingsExpander> + </controls:SettingsGroup> + + <controls:SettingsGroup x:Uid="PowerDisplay_Profiles_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander + x:Uid="PowerDisplay_QuickProfiles" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="{x:Bind ViewModel.HasProfiles, Mode=OneWay}" + ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}"> + <tkcontrols:SettingsExpander.ItemTemplate> + <DataTemplate x:DataType="pdmodels:PowerDisplayProfile"> + <tkcontrols:SettingsCard Header="{x:Bind Name}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <Button + x:Uid="PowerDisplay_Profile_ApplyButton" + Click="ProfileButton_Click" + Tag="{x:Bind}" /> + <Button + x:Uid="PowerDisplay_Profile_MoreButton" + Content="{ui:FontIcon Glyph=, + FontSize=16}" + Style="{StaticResource SubtleButtonStyle}" + Tag="{x:Bind}"> + <Button.Flyout> + <MenuFlyout> + <MenuFlyoutItem + x:Uid="PowerDisplay_Profile_EditMenuItem" + Click="EditProfile_Click" + Icon="{ui:FontIcon Glyph=}" + Tag="{x:Bind}" /> + <MenuFlyoutSeparator /> + <MenuFlyoutItem + x:Uid="PowerDisplay_Profile_DeleteMenuItem" + Click="DeleteProfile_Click" + Icon="{ui:FontIcon Glyph=}" + Tag="{x:Bind}" /> + </MenuFlyout> + </Button.Flyout> + </Button> + </StackPanel> + </tkcontrols:SettingsCard> + </DataTemplate> + </tkcontrols:SettingsExpander.ItemTemplate> + + <!-- Add profile button --> + <Button x:Uid="PowerDisplay_AddProfileButton" Click="AddProfileButton_Click"> + <StackPanel Orientation="Horizontal" Spacing="6"> + <FontIcon FontSize="14" Glyph="" /> + <TextBlock x:Uid="PowerDisplay_AddProfile_Text" /> + </StackPanel> + </Button> + </tkcontrols:SettingsExpander> + </controls:SettingsGroup> + + <controls:SettingsGroup x:Uid="PowerDisplay_Monitors" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <!-- Empty state hint --> + <TextBlock + x:Uid="PowerDisplay_NoMonitorsDetected" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Visibility="{x:Bind ViewModel.HasMonitors, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}" /> + + <!-- Monitor list --> + <ItemsControl + x:Name="MonitorsList" + HorizontalAlignment="Stretch" + ItemsSource="{x:Bind ViewModel.Monitors, Mode=OneWay}" + Visibility="{x:Bind ViewModel.HasMonitors, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <ItemsControl.ItemTemplate> + <DataTemplate x:DataType="library:MonitorInfo"> + <tkcontrols:SettingsExpander + Margin="0,0,0,2" + Description="{x:Bind Id, Mode=OneWay}" + Header="{x:Bind DisplayName, Mode=OneWay}" + IsExpanded="False"> + <tkcontrols:SettingsExpander.HeaderIcon> + <FontIcon Glyph="{x:Bind MonitorIconGlyph, Mode=OneWay}" /> + </tkcontrols:SettingsExpander.HeaderIcon> + <TextBlock Text="{x:Bind CommunicationMethod, Mode=OneWay}" /> + <tkcontrols:SettingsExpander.ItemsHeader> + <!-- Capabilities warning --> + <InfoBar + x:Uid="PowerDisplay_Monitor_CapabilitiesWarning" + BorderThickness="0" + CornerRadius="0" + IsClosable="False" + IsOpen="True" + Severity="Warning" + Visibility="{x:Bind ShowCapabilitiesWarning, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" /> + </tkcontrols:SettingsExpander.ItemsHeader> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind SupportsContrast, Mode=OneWay}"> + <CheckBox x:Uid="PowerDisplay_Monitor_EnableContrast" IsChecked="{x:Bind EnableContrast, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind SupportsVolume, Mode=OneWay}"> + <CheckBox x:Uid="PowerDisplay_Monitor_EnableVolume" IsChecked="{x:Bind EnableVolume, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind SupportsInputSource, Mode=OneWay}"> + <CheckBox x:Uid="PowerDisplay_Monitor_EnableInputSource" IsChecked="{x:Bind EnableInputSource, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard ContentAlignment="Left"> + <CheckBox x:Uid="PowerDisplay_Monitor_EnableRotation" IsChecked="{x:Bind EnableRotation, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind SupportsColorTemperature, Mode=OneWay}"> + <CheckBox + x:Uid="PowerDisplay_Monitor_EnableColorTemperature" + Click="EnableColorTemperature_Click" + IsChecked="{x:Bind EnableColorTemperature, Mode=TwoWay}" + Tag="{x:Bind}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind SupportsPowerState, Mode=OneWay}"> + <CheckBox x:Uid="PowerDisplay_Monitor_EnablePowerState" IsChecked="{x:Bind EnablePowerState, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard ContentAlignment="Left"> + <CheckBox x:Uid="PowerDisplay_Monitor_HideMonitor" IsChecked="{x:Bind IsHidden, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + <!-- VCP Capabilities --> + <tkcontrols:SettingsCard x:Uid="PowerDisplay_Monitor_VcpCapabilities" Visibility="{x:Bind HasCapabilities, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <Button + x:Uid="PowerDisplay_Monitor_VcpDetails_Button" + Content="" + FontFamily="{ThemeResource SymbolThemeFontFamily}" + Style="{StaticResource SubtleButtonStyle}"> + <Button.Flyout> + <Flyout ShouldConstrainToRootBounds="False"> + <Grid Width="420"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <!-- Header with Copy Button --> + <Grid Margin="0,0,0,12"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <TextBlock + x:Uid="PowerDisplay_Monitor_VcpCodes_Header" + Grid.Column="0" + VerticalAlignment="Center" + FontSize="13" + FontWeight="SemiBold" /> + <Button + x:Uid="PowerDisplay_Monitor_VcpCodes_CopyButton" + Grid.Column="1" + Padding="8,4" + VerticalAlignment="Center" + Click="CopyVcpCodes_Click" + Tag="{x:Bind}"> + <StackPanel Orientation="Horizontal" Spacing="4"> + <FontIcon FontSize="12" Glyph="" /> + <TextBlock x:Uid="PowerDisplay_Monitor_VcpCodes_CopyText" FontSize="12" /> + </StackPanel> + </Button> + </Grid> + + <!-- VCP Codes List --> + <ScrollViewer + Grid.Row="1" + MaxHeight="480" + HorizontalScrollBarVisibility="Disabled" + HorizontalScrollMode="Disabled" + VerticalScrollBarVisibility="Auto"> + <ItemsControl HorizontalAlignment="Stretch" ItemsSource="{x:Bind VcpCodesFormatted, Mode=OneWay}"> + <ItemsControl.ItemTemplate> + <DataTemplate x:DataType="library:VcpCodeDisplayInfo"> + <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> + <TextBlock + FontSize="12" + Text="{x:Bind Title}" + TextWrapping="Wrap" /> + <TextBlock + Margin="0,0,0,8" + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind Values}" + TextWrapping="Wrap" + Visibility="{x:Bind HasValues}" /> + </StackPanel> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </ScrollViewer> + </Grid> + </Flyout> + </Button.Flyout> + </Button> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </controls:SettingsGroup> + </StackPanel> + </controls:SettingsPageControl.ModuleContent> + <controls:SettingsPageControl.PrimaryLinks> + <controls:PageLink x:Uid="LearnMore_PowerDisplay" Link="https://aka.ms/PowerToysOverview_PowerDisplay" /> + </controls:SettingsPageControl.PrimaryLinks> + </controls:SettingsPageControl> +</local:NavigablePage> \ 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<GeneralSettings>.GetInstance(settingsUtils), + SettingsRepository<PowerDisplaySettings>.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/PowerLauncherPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml index 2fefa240e7..5bcdf7195c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml @@ -1,20 +1,21 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.PowerLauncherPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:ViewModels="using:Microsoft.PowerToys.Settings.UI.ViewModels" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:i="using:Microsoft.Xaml.Interactivity" xmlns:ic="using:Microsoft.Xaml.Interactions.Core" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:viewModels="using:Microsoft.PowerToys.Settings.UI.ViewModels" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - <Page.Resources> + <local:NavigablePage.Resources> <Style x:Key="OptionSeparator" TargetType="Rectangle"> <Setter Property="Height" Value="1" /> <Setter Property="HorizontalAlignment" Value="Stretch" /> @@ -34,7 +35,7 @@ NumberBoxTemplate="{StaticResource NumberBoxTemplate}" TextboxTemplate="{StaticResource TextBoxTemplate}" /> - <DataTemplate x:Key="CheckBoxTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="CheckBoxTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> <tkcontrols:SettingsCard MinHeight="0" @@ -54,7 +55,7 @@ </StackPanel> </DataTemplate> - <DataTemplate x:Key="ComboBoxTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="ComboBoxTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> <tkcontrols:SettingsCard MinHeight="0" @@ -66,6 +67,7 @@ Header="{x:Bind Path=DisplayLabel}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.Name="{x:Bind Path=DisplayLabel}" DisplayMemberPath="Key" ItemsSource="{x:Bind Path=ComboBoxItems}" SelectedValue="{x:Bind ComboBoxValue, Mode=TwoWay}" @@ -75,7 +77,7 @@ </StackPanel> </DataTemplate> - <DataTemplate x:Key="TextBoxTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="TextBoxTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> <tkcontrols:SettingsCard MinHeight="0" @@ -97,7 +99,7 @@ </StackPanel> </DataTemplate> - <DataTemplate x:Key="NumberBoxTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="NumberBoxTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> <tkcontrols:SettingsCard MinHeight="0" @@ -109,6 +111,7 @@ Header="{x:Bind Path=DisplayLabel}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.Name="{x:Bind Path=DisplayLabel}" LargeChange="{x:Bind NumberBoxLargeChange, Mode=OneWay}" Maximum="{x:Bind NumberBoxMax, Mode=OneWay}" Minimum="{x:Bind NumberBoxMin, Mode=OneWay}" @@ -120,7 +123,7 @@ </StackPanel> </DataTemplate> - <DataTemplate x:Key="MultilineTextBoxTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="MultilineTextBoxTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> <tkcontrols:SettingsCard MinHeight="0" @@ -147,7 +150,7 @@ </StackPanel> </DataTemplate> - <DataTemplate x:Key="CheckBoxComboBoxTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="CheckBoxComboBoxTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> <tkcontrols:SettingsCard MinHeight="0" @@ -174,6 +177,7 @@ IsEnabled="{x:Bind SecondSettingIsEnabled, Mode=OneWay}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.Name="{x:Bind Path=SecondDisplayLabel}" DisplayMemberPath="Key" ItemsSource="{x:Bind Path=ComboBoxItems}" SelectedValue="{x:Bind ComboBoxValue, Mode=TwoWay}" @@ -183,7 +187,7 @@ </StackPanel> </DataTemplate> - <DataTemplate x:Key="CheckBoxTextBoxTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="CheckBoxTextBoxTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> <tkcontrols:SettingsCard MinHeight="0" @@ -220,7 +224,7 @@ </StackPanel> </DataTemplate> - <DataTemplate x:Key="CheckBoxMultilineTextBoxTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="CheckBoxMultilineTextBoxTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> <tkcontrols:SettingsCard MinHeight="0" @@ -262,7 +266,7 @@ </StackPanel> </DataTemplate> - <DataTemplate x:Key="CheckBoxNumberBoxTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="CheckBoxNumberBoxTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical"> <tkcontrols:SettingsCard MinHeight="0" @@ -289,6 +293,7 @@ IsEnabled="{x:Bind SecondSettingIsEnabled, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.Name="{x:Bind Path=SecondDisplayLabel}" LargeChange="{x:Bind NumberBoxLargeChange, Mode=OneWay}" Maximum="{x:Bind NumberBoxMax, Mode=OneWay}" Minimum="{x:Bind NumberBoxMin, Mode=OneWay}" @@ -300,14 +305,13 @@ </StackPanel> </DataTemplate> - <DataTemplate x:Key="EmptyTemplate" x:DataType="ViewModels:PluginAdditionalOptionViewModel"> + <DataTemplate x:Key="EmptyTemplate" x:DataType="viewModels:PluginAdditionalOptionViewModel"> <StackPanel HorizontalAlignment="Stretch" Orientation="Vertical" /> </DataTemplate> - </Page.Resources> + </local:NavigablePage.Resources> <controls:SettingsPageControl x:Uid="PowerLauncher" ModuleImageSource="ms-appx:///Assets/Settings/Modules/Run.png"> <controls:SettingsPageControl.ModuleContent> - <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> <InfoBar x:Uid="Run_CheckOutCmdPal" @@ -321,25 +325,17 @@ <HyperlinkButton x:Uid="Run_NavigateCmdPalSettings" Click="NavigateCmdPalSettings_Click" /> </InfoBar.ActionButton> </InfoBar> - <tkcontrols:SettingsCard - x:Uid="PowerLauncher_EnablePowerLauncher" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerToysRun.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.EnablePowerLauncher, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PowerLauncherEnablePowerLauncher" + x:Uid="PowerLauncher_EnablePowerLauncher" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerToysRun.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.EnablePowerLauncher, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="Shortcut" IsEnabled="{x:Bind ViewModel.EnablePowerLauncher, Mode=OneWay}"> <tkcontrols:SettingsExpander + Name="ActivationShortcut" x:Uid="Activation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> @@ -355,7 +351,6 @@ </tkcontrols:SettingsExpander> </controls:SettingsGroup> - <!--<Custom:HotkeySettingsControl x:Uid="PowerLauncher_OpenFileLocation" HorizontalAlignment="Left" Margin="{StaticResource SmallTopMargin}" @@ -391,12 +386,16 @@ <controls:SettingsGroup x:Uid="PowerLauncher_SearchResults" IsEnabled="{x:Bind ViewModel.EnablePowerLauncher, Mode=OneWay}"> <tkcontrols:SettingsExpander + Name="PowerLauncherSearchQueryResultsWithDelay" x:Uid="PowerLauncher_SearchQueryResultsWithDelay" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> <ToggleSwitch IsOn="{x:Bind ViewModel.SearchQueryResultsWithDelay, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_FastSearchInputDelayMs" IsEnabled="{x:Bind ViewModel.SearchQueryResultsWithDelay, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PowerLauncherFastSearchInputDelayMs" + x:Uid="PowerLauncher_FastSearchInputDelayMs" + IsEnabled="{x:Bind ViewModel.SearchQueryResultsWithDelay, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="50" @@ -406,7 +405,10 @@ SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.SearchInputDelayFast, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_SlowSearchInputDelayMs" IsEnabled="{x:Bind ViewModel.SearchQueryResultsWithDelay, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PowerLauncherSlowSearchInputDelayMs" + x:Uid="PowerLauncher_SlowSearchInputDelayMs" + IsEnabled="{x:Bind ViewModel.SearchQueryResultsWithDelay, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="50" @@ -420,6 +422,7 @@ </tkcontrols:SettingsExpander> <tkcontrols:SettingsExpander + Name="PowerLauncherMaximumNumberOfResults" x:Uid="PowerLauncher_MaximumNumberOfResults" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> @@ -436,12 +439,16 @@ </tkcontrols:SettingsExpander> <tkcontrols:SettingsExpander + Name="PowerLauncherSearchQueryTuningEnabled" x:Uid="PowerLauncher_SearchQueryTuningEnabled" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> <ToggleSwitch IsOn="{x:Bind ViewModel.SearchQueryTuningEnabled, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_SearchClickedItemWeight" IsEnabled="{x:Bind ViewModel.SearchQueryTuningEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PowerLauncherSearchClickedItemWeight" + x:Uid="PowerLauncher_SearchClickedItemWeight" + IsEnabled="{x:Bind ViewModel.SearchQueryTuningEnabled, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="50" @@ -457,15 +464,24 @@ </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_TabSelectsContextButtons" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="PowerLauncherTabSelectsContextButtons" + x:Uid="PowerLauncher_TabSelectsContextButtons" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.TabSelectsContextButtons, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_UsePinyin" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="PowerLauncherUsePinyin" + x:Uid="PowerLauncher_UsePinyin" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.UsePinyin, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_GenerateThumbnailsFromFiles" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="PowerLauncherGenerateThumbnailsFromFiles" + x:Uid="PowerLauncher_GenerateThumbnailsFromFiles" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.GenerateThumbnailsFromFiles, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> @@ -491,7 +507,10 @@ />--> <controls:SettingsGroup x:Uid="Run_PositionAppearance_GroupSettings" IsEnabled="{x:Bind ViewModel.EnablePowerLauncher, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="Run_PositionHeader" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="RunPositionHeader" + x:Uid="Run_PositionHeader" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.MonitorPositionIndex, Mode=TwoWay}"> <ComboBoxItem x:Uid="Run_Radio_Position_Cursor" /> <ComboBoxItem x:Uid="Run_Radio_Position_Primary_Monitor" /> @@ -499,7 +518,10 @@ </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ColorModeHeader" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="ColorModeHeader" + x:Uid="ColorModeHeader" + HeaderIcon="{ui:FontIcon Glyph=}"> <tkcontrols:SettingsCard.Description> <HyperlinkButton x:Uid="Windows_Color_Settings" Click="OpenColorsSettings_Click" /> </tkcontrols:SettingsCard.Description> @@ -510,7 +532,10 @@ </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_ShowPluginKeywords" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="PowerLauncherShowPluginKeywords" + x:Uid="PowerLauncher_ShowPluginKeywords" + HeaderIcon="{ui:FontIcon Glyph=}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.ShowPluginsOverviewIndex, Mode=TwoWay}"> <ComboBoxItem x:Uid="ShowPluginsOverview_All" /> <ComboBoxItem x:Uid="ShowPluginsOverview_NonGlobal" /> @@ -518,9 +543,12 @@ </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_TitleFontSize" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="PowerLauncherTitleFontSize" + x:Uid="PowerLauncher_TitleFontSize" + HeaderIcon="{ui:FontIcon Glyph=}"> <StackPanel Orientation="Horizontal" Spacing="12"> - <TextBlock + <controls:IsEnabledTextBlock VerticalAlignment="Center" AutomationProperties.AccessibilityView="Raw" FontSize="12" @@ -536,7 +564,7 @@ TickFrequency="2" TickPlacement="Outside" Value="{x:Bind ViewModel.TitleFontSize, Mode=TwoWay}" /> - <TextBlock + <controls:IsEnabledTextBlock VerticalAlignment="Center" AutomationProperties.AccessibilityView="Raw" FontSize="24" @@ -559,7 +587,10 @@ </InfoBar.ActionButton> </InfoBar> - <tkcontrols:SettingsCard x:Uid="Run_PluginUse" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="RunPluginUse" + x:Uid="Run_PluginUse" + HeaderIcon="{ui:FontIcon Glyph=}"> <tkcontrols:SettingsCard.Description> <StackPanel> <TextBlock x:Uid="Run_PluginUseDescription" /> @@ -614,7 +645,7 @@ <StackLayout Spacing="2" /> </ItemsRepeater.Layout> <ItemsRepeater.ItemTemplate> - <DataTemplate x:DataType="ViewModels:PowerLauncherPluginViewModel" x:DefaultBindMode="OneWay"> + <DataTemplate x:DataType="viewModels:PowerLauncherPluginViewModel" x:DefaultBindMode="OneWay"> <Grid> <tkcontrols:SettingsExpander Description="{x:Bind Description}" Header="{x:Bind Path=Name}"> <tkcontrols:SettingsExpander.HeaderIcon> @@ -649,7 +680,10 @@ </StackPanel> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_ActionKeyword" IsEnabled="{x:Bind Enabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PowerLauncherActionKeyword" + x:Uid="PowerLauncher_ActionKeyword" + IsEnabled="{x:Bind Enabled, Mode=OneWay}"> <TextBox MinWidth="{StaticResource SettingActionControlMinWidth}" Text="{x:Bind ActionKeyword, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> </tkcontrols:SettingsCard> <tkcontrols:SettingsCard @@ -682,7 +716,10 @@ </StackPanel> </CheckBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="PowerLauncher_PluginWeightBoost" IsEnabled="{x:Bind IsGlobalAndEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PowerLauncherPluginWeightBoost" + x:Uid="PowerLauncher_PluginWeightBoost" + IsEnabled="{x:Bind IsGlobalAndEnabled, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" LargeChange="50" @@ -771,4 +808,4 @@ <controls:PageLink Link="https://github.com/betsegaw/windowwalker/" Text="Beta Tadele's Window Walker" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml.cs index f02327caa8..7f82ab4b97 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerLauncherPage.xaml.cs @@ -15,7 +15,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class PowerLauncherPage : Page, IRefreshablePage + public sealed partial class PowerLauncherPage : NavigablePage, IRefreshablePage { public PowerLauncherViewModel ViewModel { get; set; } @@ -34,12 +34,13 @@ namespace Microsoft.PowerToys.Settings.UI.Views public PowerLauncherPage() { InitializeComponent(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; _lastIPCMessageSentTick = Environment.TickCount; PowerLauncherSettings settings = SettingsRepository<PowerLauncherSettings>.GetInstance(settingsUtils)?.SettingsConfig; ViewModel = new PowerLauncherViewModel(settings, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SendDefaultIPCMessageTimed, App.IsDarkTheme); DataContext = ViewModel; + _ = Helper.GetFileWatcher(PowerLauncherSettings.ModuleName, "settings.json", () => { if (Environment.TickCount < _lastIPCMessageSentTick + 500) @@ -79,6 +80,8 @@ namespace Microsoft.PowerToys.Settings.UI.Views searchTypePreferencesOptions.Add(Tuple.Create(loader.GetString("PowerLauncher_SearchTypePreference_ApplicationName"), "application_name")); searchTypePreferencesOptions.Add(Tuple.Create(loader.GetString("PowerLauncher_SearchTypePreference_StringInApplication"), "string_in_application")); searchTypePreferencesOptions.Add(Tuple.Create(loader.GetString("PowerLauncher_SearchTypePreference_ExecutableName"), "executable_name")); + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void OpenColorsSettings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) @@ -93,7 +96,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views private void NavigateCmdPalSettings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) { - SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.CmdPal, true); + SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.CmdPal); } /* diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml index 3068fc74c8..b698bdd6fa 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.PowerOcrPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -23,22 +24,17 @@ IsOpen="{x:Bind ViewModel.IsWin11OrGreater, Mode=OneWay}" IsTabStop="{x:Bind ViewModel.IsWin11OrGreater, Mode=OneWay}" Severity="Informational" /> - <tkcontrols:SettingsCard - x:Uid="TextExtractor_EnableToggleControl_HeaderText" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/TextExtractor.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="TextExtractorEnableToggleControlHeaderText" + x:Uid="TextExtractor_EnableToggleControl_HeaderText" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/TextExtractor.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.AutomationId="EnableTextExtractorToggleSwitch" + IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <InfoBar x:Uid="TextExtractor_SupportedLanguages" IsClosable="False" @@ -51,13 +47,20 @@ </InfoBar> <controls:SettingsGroup x:Uid="Shortcut" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="Activation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.ActivationShortcut, Mode=TwoWay}" /> + <tkcontrols:SettingsCard + Name="ActivationShortcut" + x:Uid="Activation_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> + <controls:ShortcutControl + MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.AutomationId="TextExtractorActivationShortcut" + HotkeySettings="{x:Bind Path=ViewModel.ActivationShortcut, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="TextExtractor_Languages"> + <tkcontrols:SettingsCard Name="TextExtractorLanguages" x:Uid="TextExtractor_Languages"> <ComboBox x:Name="TextExtractor_ComboBox" MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.AutomationId="TextExtractorLanguageComboBox" DropDownOpened="TextExtractor_ComboBox_DropDownOpened" ItemsSource="{x:Bind Path=ViewModel.AvailableLanguages, Mode=OneWay}" Loaded="TextExtractor_ComboBox_Loaded" @@ -74,4 +77,4 @@ <controls:PageLink Link="https://github.com/TheJoeFin/Text-Grab" Text="Based upon Joseph Finney's Text Grab" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml.cs index 07b999fce0..9198d338ef 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerOcrPage.xaml.cs @@ -9,13 +9,13 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class PowerOcrPage : Page, IRefreshablePage + public sealed partial class PowerOcrPage : NavigablePage, IRefreshablePage { private PowerOcrViewModel ViewModel { get; set; } public PowerOcrPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new PowerOcrViewModel( settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), @@ -23,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void TextExtractor_ComboBox_Loaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml index 489a1a8b7d..a6b44dfdf7 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.PowerPreviewPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -33,6 +34,7 @@ </InfoBar> <tkcontrols:SettingsExpander + Name="FileExplorerPreviewToggleSwitchPreviewSVG" x:Uid="FileExplorerPreview_ToggleSwitch_Preview_SVG" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.SVGRenderIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> @@ -41,7 +43,7 @@ IsEnabled="{x:Bind ViewModel.SVGRenderIsGpoEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" IsOn="{x:Bind ViewModel.SVGRenderIsEnabled, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="FileExplorerPreview_Preview_SVG_Color_Mode"> + <tkcontrols:SettingsCard Name="FileExplorerPreviewPreviewSVGColorMode" x:Uid="FileExplorerPreview_Preview_SVG_Color_Mode"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" IsEnabled="{x:Bind ViewModel.SVGRenderIsEnabled, Mode=OneWay}" @@ -51,10 +53,16 @@ <ComboBoxItem x:Uid="FileExplorerPreview_Preview_SVG_Checkered_Shade" /> </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FileExplorerPreview_Preview_SVG_Background_Color" Visibility="{x:Bind ViewModel.IsSvgBackgroundColorVisible, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FileExplorerPreviewPreviewSVGBackgroundColor" + x:Uid="FileExplorerPreview_Preview_SVG_Background_Color" + Visibility="{x:Bind ViewModel.IsSvgBackgroundColorVisible, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> <controls:ColorPickerButton IsEnabled="{x:Bind ViewModel.SVGRenderIsEnabled, Mode=OneWay}" SelectedColor="{x:Bind Path=ViewModel.SVGRenderBackgroundSolidColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FileExplorerPreview_Preview_SVG_Checkered_Shade_Mode" Visibility="{x:Bind ViewModel.IsSvgCheckeredShadeVisible, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FileExplorerPreviewPreviewSVGCheckeredShadeMode" + x:Uid="FileExplorerPreview_Preview_SVG_Checkered_Shade_Mode" + Visibility="{x:Bind ViewModel.IsSvgCheckeredShadeVisible, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" IsEnabled="{x:Bind ViewModel.SVGRenderIsEnabled, Mode=OneWay}" @@ -68,6 +76,7 @@ </tkcontrols:SettingsExpander> <tkcontrols:SettingsExpander + Name="FileExplorerPreviewToggleSwitchPreviewMonaco" x:Uid="FileExplorerPreview_ToggleSwitch_Preview_Monaco" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.MonacoRenderIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> @@ -85,7 +94,10 @@ IsChecked="{x:Bind ViewModel.MonacoPreviewTryFormat, Mode=TwoWay}" IsEnabled="{x:Bind ViewModel.MonacoRenderIsEnabled, Mode=OneWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FileExplorerPreview_Toggle_Monaco_Max_File_Size" IsEnabled="{x:Bind ViewModel.MonacoRenderIsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FileExplorerPreviewToggleMonacoMaxFileSize" + x:Uid="FileExplorerPreview_Toggle_Monaco_Max_File_Size" + IsEnabled="{x:Bind ViewModel.MonacoRenderIsEnabled, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" Maximum="100" @@ -93,7 +105,10 @@ SpinButtonPlacementMode="Compact" Value="{x:Bind ViewModel.MonacoPreviewMaxFileSize, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="FileExplorerPreview_Toggle_Monaco_Font_Size" IsEnabled="{x:Bind ViewModel.MonacoRenderIsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="FileExplorerPreviewToggleMonacoFontSize" + x:Uid="FileExplorerPreview_Toggle_Monaco_Font_Size" + IsEnabled="{x:Bind ViewModel.MonacoRenderIsEnabled, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" Maximum="100" @@ -117,6 +132,7 @@ </tkcontrols:SettingsExpander> <tkcontrols:SettingsCard + Name="FileExplorerPreviewToggleSwitchPreviewMD" x:Uid="FileExplorerPreview_ToggleSwitch_Preview_MD" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.MDRenderIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> @@ -127,6 +143,7 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="FileExplorerPreviewToggleSwitchPreviewPDF" x:Uid="FileExplorerPreview_ToggleSwitch_Preview_PDF" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.PDFRenderIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> @@ -137,6 +154,7 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="FileExplorerPreviewToggleSwitchPreviewGCODE" x:Uid="FileExplorerPreview_ToggleSwitch_Preview_GCODE" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.GCODERenderIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> @@ -147,8 +165,18 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsCard - x:Uid="FileExplorerPreview_ToggleSwitch_Preview_QOI" + x:Uid="FileExplorerPreview_ToggleSwitch_Preview_BGCODE" HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.BGCODERenderIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + IsEnabled="{x:Bind ViewModel.BGCODERenderIsGpoEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" + IsOn="{x:Bind ViewModel.BGCODERenderIsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + <tkcontrols:SettingsCard + x:Uid="FileExplorerPreview_ToggleSwitch_Preview_QOI" + HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.QOIRenderIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> <ToggleSwitch x:Uid="ToggleSwitch" @@ -182,6 +210,7 @@ </InfoBar> <tkcontrols:SettingsCard + Name="FileExplorerPreviewToggleSwitchThumbnailSVG" x:Uid="FileExplorerPreview_ToggleSwitch_Thumbnail_SVG" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.SVGThumbnailIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> @@ -192,6 +221,7 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="FileExplorerPreviewToggleSwitchThumbnailPDF" x:Uid="FileExplorerPreview_ToggleSwitch_Thumbnail_PDF" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.PDFThumbnailIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> @@ -202,6 +232,7 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="FileExplorerPreviewToggleSwitchThumbnailGCODE" x:Uid="FileExplorerPreview_ToggleSwitch_Thumbnail_GCODE" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.GCODEThumbnailIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> @@ -212,8 +243,18 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsCard - x:Uid="FileExplorerPreview_ToggleSwitch_Thumbnail_QOI" + x:Uid="FileExplorerPreview_ToggleSwitch_Thumbnail_BGCODE" HeaderIcon="{ui:FontIcon Glyph=}" + IsEnabled="{x:Bind ViewModel.BGCODEThumbnailIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + IsEnabled="{x:Bind ViewModel.BGCODEThumbnailIsGpoEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" + IsOn="{x:Bind ViewModel.BGCODEThumbnailIsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + + <tkcontrols:SettingsCard + x:Uid="FileExplorerPreview_ToggleSwitch_Thumbnail_QOI" + HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.QOIThumbnailIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> <ToggleSwitch x:Uid="ToggleSwitch" @@ -222,6 +263,7 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsExpander + Name="FileExplorerPreviewToggleSwitchThumbnailSTL" x:Uid="FileExplorerPreview_ToggleSwitch_Thumbnail_STL" HeaderIcon="{ui:FontIcon Glyph=}" IsEnabled="{x:Bind ViewModel.STLThumbnailIsGpoDisabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> @@ -230,7 +272,7 @@ IsEnabled="{x:Bind ViewModel.STLThumbnailIsGpoEnabled, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}" IsOn="{x:Bind ViewModel.STLThumbnailIsEnabled, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="FileExplorerPreview_Color_Thumbnail_STL"> + <tkcontrols:SettingsCard Name="FileExplorerPreviewColorThumbnailSTL" x:Uid="FileExplorerPreview_Color_Thumbnail_STL"> <controls:ColorPickerButton IsEnabled="{x:Bind ViewModel.STLThumbnailIsEnabled, Mode=OneWay}" SelectedColor="{x:Bind Path=ViewModel.STLThumbnailColor, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> @@ -243,8 +285,8 @@ <controls:PageLink x:Uid="LearnMore_PowerPreview" Link="https://aka.ms/PowerToysOverview_FileExplorerAddOns" /> </controls:SettingsPageControl.PrimaryLinks> <controls:SettingsPageControl.SecondaryLinks> - <controls:PageLink Link="https://blog.aaron-junker.ch" Text="Aaron Junker's work on developer file preview" /> - <controls:PageLink Link="https://www.pedrolamas.com" Text="Pedro Lamas's work on G-Code, STL, and QOI" /> + <controls:PageLink Link="https://noraajunker.ch" Text="Noraa Junker's work on developer file preview" /> + <controls:PageLink Link="https://www.pedrolamas.com" Text="Pedro Lamas's work on G-Code, Binary G-Code, STL, and QOI" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml.cs index f7bb8aaba3..ecc960651b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerPreviewPage.xaml.cs @@ -2,6 +2,7 @@ // 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.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Xaml.Controls; @@ -11,14 +12,14 @@ namespace Microsoft.PowerToys.Settings.UI.Views /// <summary> /// An empty page that can be used on its own or navigated to within a Frame. /// </summary> - public sealed partial class PowerPreviewPage : Page + public sealed partial class PowerPreviewPage : NavigablePage { public PowerPreviewViewModel ViewModel { get; set; } public PowerPreviewPage() { InitializeComponent(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new PowerPreviewViewModel(SettingsRepository<PowerPreviewSettings>.GetInstance(settingsUtils), SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml index 7bc51d7a4d..c53b06c7c4 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml @@ -1,40 +1,50 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.PowerRenamePage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> + <local:NavigablePage.Resources> + <tkconverters:BoolToVisibilityConverter + x:Key="BoolToInvertedVisibilityConverter" + FalseValue="Visible" + TrueValue="Collapsed" /> + </local:NavigablePage.Resources> + <controls:SettingsPageControl x:Uid="PowerRename" ModuleImageSource="ms-appx:///Assets/Settings/Modules/PowerRename.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel x:Name="PowerRenameView" ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="PowerRename_Toggle_Enable" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerRename.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PowerRenameToggleEnable" + x:Uid="PowerRename_Toggle_Enable" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerRename.png}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=PowerRenameToggleEnable, Path=Header}" + IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="PowerRename_ShellIntegration" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsExpander x:Uid="PowerRename_Toggle_ContextMenu" IsExpanded="False"> - <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.EnabledOnContextExtendedMenu, Mode=TwoWay, Converter={StaticResource BoolToComboBoxIndexConverter}}"> + <tkcontrols:SettingsExpander + Name="PowerRenameToggleContextMenu" + x:Uid="PowerRename_Toggle_ContextMenu" + IsExpanded="False"> + <ComboBox + MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.Name="{Binding ElementName=PowerRenameToggleContextMenu, Path=Header}" + SelectedIndex="{x:Bind ViewModel.EnabledOnContextExtendedMenu, Mode=TwoWay, Converter={StaticResource BoolToComboBoxIndexConverter}}"> <ComboBoxItem x:Uid="PowerRename_Toggle_StandardContextMenu" /> <ComboBoxItem x:Uid="PowerRename_Toggle_ExtendedContextMenu" /> </ComboBox> @@ -53,12 +63,22 @@ </controls:SettingsGroup> <controls:SettingsGroup x:Uid="PowerRename_AutoCompleteHeader" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsExpander x:Uid="PowerRename_Toggle_AutoComplete" IsExpanded="True"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.MRUEnabled, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander + Name="PowerRenameToggleAutoComplete" + x:Uid="PowerRename_Toggle_AutoComplete" + IsExpanded="True"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=PowerRenameToggleAutoComplete, Path=Header}" + IsOn="{x:Bind ViewModel.MRUEnabled, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="PowerRename_Toggle_MaxDispListNum" IsEnabled="{x:Bind ViewModel.GlobalAndMruEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PowerRenameToggleMaxDispListNum" + x:Uid="PowerRename_Toggle_MaxDispListNum" + IsEnabled="{x:Bind ViewModel.GlobalAndMruEnabled, Mode=OneWay}"> <NumberBox MinWidth="{StaticResource SettingActionControlMinWidth}" + AutomationProperties.Name="{Binding ElementName=PowerRenameToggleMaxDispListNum, Path=Header}" Maximum="20" Minimum="0" SpinButtonPlacementMode="Compact" @@ -67,13 +87,64 @@ </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> - <tkcontrols:SettingsCard x:Uid="PowerRename_Toggle_RestoreFlagsOnLaunch" HeaderIcon="{ui:FontIcon Glyph=}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.RestoreFlagsOnLaunch, Mode=TwoWay}" /> + <tkcontrols:SettingsCard + Name="PowerRenameToggleRestoreFlagsOnLaunch" + x:Uid="PowerRename_Toggle_RestoreFlagsOnLaunch" + HeaderIcon="{ui:FontIcon Glyph=}"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=PowerRenameToggleRestoreFlagsOnLaunch, Path=Header}" + IsOn="{x:Bind ViewModel.RestoreFlagsOnLaunch, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="PowerRename_BehaviorHeader" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="PowerRename_Toggle_UseBoostLib"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.UseBoostLib, Mode=TwoWay}" /> + <tkcontrols:SettingsCard Name="PowerRenameToggleUseBoostLib" x:Uid="PowerRename_Toggle_UseBoostLib"> + <ToggleSwitch + x:Uid="ToggleSwitch" + AutomationProperties.Name="{Binding ElementName=PowerRenameToggleUseBoostLib, Path=Header}" + IsOn="{x:Bind ViewModel.UseBoostLib, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:SettingsGroup> + <controls:SettingsGroup x:Uid="PowerRename_ExtensionsHeader" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="PowerRenameHeifExtension" + x:Uid="PowerRename_HeifExtension" + HeaderIcon="{ui:FontIcon Glyph=}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon + VerticalAlignment="Center" + Foreground="{ThemeResource SystemFillColorSuccessBrush}" + Glyph="" + Visibility="{x:Bind ViewModel.IsHeifExtensionInstalled, Mode=OneWay}" /> + <TextBlock + x:Uid="PowerRename_HeifExtension_Installed" + VerticalAlignment="Center" + Visibility="{x:Bind ViewModel.IsHeifExtensionInstalled, Mode=OneWay}" /> + <Button + x:Uid="PowerRename_HeifExtension_Install" + Command="{x:Bind ViewModel.InstallHeifExtensionCommand}" + Visibility="{x:Bind ViewModel.IsHeifExtensionInstalled, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" /> + </StackPanel> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="PowerRenameAvifExtension" + x:Uid="PowerRename_AvifExtension" + HeaderIcon="{ui:FontIcon Glyph=}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon + VerticalAlignment="Center" + Foreground="{ThemeResource SystemFillColorSuccessBrush}" + Glyph="" + Visibility="{x:Bind ViewModel.IsAvifExtensionInstalled, Mode=OneWay}" /> + <TextBlock + x:Uid="PowerRename_AvifExtension_Installed" + VerticalAlignment="Center" + Visibility="{x:Bind ViewModel.IsAvifExtensionInstalled, Mode=OneWay}" /> + <Button + x:Uid="PowerRename_AvifExtension_Install" + Command="{x:Bind ViewModel.InstallAvifExtensionCommand}" + Visibility="{x:Bind ViewModel.IsAvifExtensionInstalled, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" /> + </StackPanel> </tkcontrols:SettingsCard> </controls:SettingsGroup> </StackPanel> @@ -86,4 +157,4 @@ <controls:PageLink Link="https://github.com/chrdavis/SmartRename" Text="Chris Davis's SmartRenamer" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml.cs index fdfa359b84..10013b84a3 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/PowerRenamePage.xaml.cs @@ -9,14 +9,14 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class PowerRenamePage : Page, IRefreshablePage + public sealed partial class PowerRenamePage : NavigablePage, IRefreshablePage { private PowerRenameViewModel ViewModel { get; set; } public PowerRenamePage() { InitializeComponent(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new PowerRenameViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; 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 @@ +<ContentDialog + x:Class="Microsoft.PowerToys.Settings.UI.Views.ProfileEditorDialog" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:viewmodels="using:Microsoft.PowerToys.Settings.UI.ViewModels" + Width="486" + MinWidth="486" + CloseButtonClick="ContentDialog_CloseButtonClick" + DefaultButton="Primary" + IsPrimaryButtonEnabled="{x:Bind ViewModel.CanSave, Mode=OneWay}" + PrimaryButtonClick="ContentDialog_PrimaryButtonClick" + Style="{StaticResource DefaultContentDialogStyle}" + mc:Ignorable="d"> + + <Grid MinWidth="486"> + <Grid.Resources> + <x:Double x:Key="SettingsCardWrapThreshold">0</x:Double> + <x:Double x:Key="SettingsCardWrapNoIconThreshold">0</x:Double> + <Thickness x:Key="SettingsExpanderItemPadding">0,8,0,8</Thickness> + </Grid.Resources> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + </Grid.RowDefinitions> + <TextBox + x:Name="ProfileNameTextBox" + x:Uid="PowerDisplay_ProfileEditor_ProfileName" + Margin="0,0,0,24" + MaxLength="50" + Text="{x:Bind ViewModel.ProfileName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> + + <!-- Monitors List --> + <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto"> + <StackPanel Spacing="16"> + <TextBlock x:Uid="PowerDisplay_ProfileEditor_Description" Style="{StaticResource BodyStrongTextBlockStyle}" /> + <ItemsControl ItemsSource="{x:Bind ViewModel.Monitors, Mode=OneWay}"> + <ItemsControl.ItemTemplate> + <DataTemplate x:DataType="viewmodels:MonitorSelectionItem"> + <tkcontrols:SettingsExpander + Margin="0,0,0,8" + Description="{x:Bind Monitor.Id, Mode=OneWay}" + Header="{x:Bind Monitor.DisplayName, Mode=OneWay}" + IsExpanded="True"> + <tkcontrols:SettingsExpander.HeaderIcon> + <FontIcon Glyph="{x:Bind Monitor.MonitorIconGlyph}" /> + </tkcontrols:SettingsExpander.HeaderIcon> + <ToggleSwitch IsOn="{x:Bind IsSelected, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard Padding="16,8,16,8" Visibility="{x:Bind Monitor.SupportsBrightness, Converter={StaticResource BoolToVisibilityConverter}}"> + <tkcontrols:SettingsCard.Header> + <CheckBox IsChecked="{x:Bind IncludeBrightness, Mode=TwoWay}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="PowerDisplay_ProfileEditor_Brightness" VerticalAlignment="Center" /> + </StackPanel> + </CheckBox> + </tkcontrols:SettingsCard.Header> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + VerticalAlignment="Center" + Maximum="100" + Minimum="0" + Value="{x:Bind Brightness, Mode=TwoWay}" /> + <tkcontrols:SettingsCard.Resources> + <x:Double x:Key="SettingsCardLeftIndention">0</x:Double> + </tkcontrols:SettingsCard.Resources> + </tkcontrols:SettingsCard> + + <tkcontrols:SettingsCard Padding="16,8,16,8" Visibility="{x:Bind Monitor.SupportsContrast, Converter={StaticResource BoolToVisibilityConverter}}"> + <tkcontrols:SettingsCard.Header> + <CheckBox IsChecked="{x:Bind IncludeContrast, Mode=TwoWay}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="PowerDisplay_ProfileEditor_Contrast" VerticalAlignment="Center" /> + </StackPanel> + </CheckBox> + </tkcontrols:SettingsCard.Header> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + VerticalAlignment="Center" + Maximum="100" + Minimum="0" + Value="{x:Bind Contrast, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Padding="16,8,16,8" Visibility="{x:Bind Monitor.SupportsVolume, Converter={StaticResource BoolToVisibilityConverter}}"> + <tkcontrols:SettingsCard.Header> + <CheckBox IsChecked="{x:Bind IncludeVolume, Mode=TwoWay}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="PowerDisplay_ProfileEditor_Volume" VerticalAlignment="Center" /> + </StackPanel> + </CheckBox> + </tkcontrols:SettingsCard.Header> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + VerticalAlignment="Center" + Maximum="100" + Minimum="0" + Value="{x:Bind Volume, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Padding="16,8,16,8" Visibility="{x:Bind Monitor.SupportsColorTemperature, Converter={StaticResource BoolToVisibilityConverter}}"> + <tkcontrols:SettingsCard.Header> + <CheckBox IsChecked="{x:Bind IncludeColorTemperature, Mode=TwoWay}"> + <StackPanel Orientation="Horizontal" Spacing="8"> + <FontIcon FontSize="16" Glyph="" /> + <TextBlock x:Uid="PowerDisplay_ProfileEditor_ColorTemperature" VerticalAlignment="Center" /> + </StackPanel> + </CheckBox> + </tkcontrols:SettingsCard.Header> + <ComboBox + x:Uid="PowerDisplay_ProfileEditor_ColorTemperature_ComboBox" + MinWidth="{StaticResource SettingActionControlMinWidth}" + VerticalAlignment="Center" + DisplayMemberPath="DisplayName" + ItemsSource="{x:Bind Monitor.ColorPresetsForDisplay, Mode=OneWay}" + SelectedValue="{x:Bind ColorTemperature, Mode=TwoWay}" + SelectedValuePath="VcpValue" /> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + </DataTemplate> + </ItemsControl.ItemTemplate> + + </ItemsControl> + </StackPanel> + </ScrollViewer> + </Grid> +</ContentDialog> 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 +{ + /// <summary> + /// Dialog for creating/editing PowerDisplay profiles + /// </summary> + public sealed partial class ProfileEditorDialog : ContentDialog + { + public ProfileEditorViewModel ViewModel { get; private set; } + + public PowerDisplayProfile? ResultProfile { get; private set; } + + public ProfileEditorDialog(ObservableCollection<MonitorInfo> 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; + } + + /// <summary> + /// Pre-fill the dialog with existing profile data + /// </summary> + 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/RegistryPreviewPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml index fb56c82208..2869e2042f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.RegistryPreviewPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -13,42 +14,33 @@ <controls:SettingsPageControl x:Uid="RegistryPreview" ModuleImageSource="ms-appx:///Assets/Settings/Modules/RegistryPreview.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="RegistryPreview_Enable_RegistryPreview" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/RegistryPreview.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch IsOn="{x:Bind ViewModel.IsRegistryPreviewEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - </tkcontrols:SettingsCard> - - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> - + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="RegistryPreviewEnableRegistryPreview" + x:Uid="RegistryPreview_Enable_RegistryPreview" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/RegistryPreview.png}"> + <ToggleSwitch IsOn="{x:Bind ViewModel.IsRegistryPreviewEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="RegistryPreview_Launch_GroupSettings" IsEnabled="{x:Bind ViewModel.IsRegistryPreviewEnabled, Mode=OneWay}"> <tkcontrols:SettingsCard + Name="RegistryPreviewLaunchButtonControl" x:Uid="RegistryPreview_LaunchButtonControl" ActionIcon="{ui:FontIcon Glyph=}" Command="{x:Bind ViewModel.LaunchEventHandler}" HeaderIcon="{ui:FontIcon Glyph=}" IsClickEnabled="True" /> - - <tkcontrols:SettingsCard x:Uid="RegistryPreview_DefaultRegApp" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="RegistryPreviewDefaultRegApp" + x:Uid="RegistryPreview_DefaultRegApp" + HeaderIcon="{ui:FontIcon Glyph=}"> <ToggleSwitch x:Uid="RegistryPreview_DefaultRegApp_ToggleSwitch" IsOn="{x:Bind ViewModel.IsRegistryPreviewDefaultRegApp, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> - </StackPanel> </controls:SettingsPageControl.ModuleContent> - <controls:SettingsPageControl.PrimaryLinks> <controls:PageLink x:Uid="LearnMore_RegistryPreview" Link="https://aka.ms/PowerToysOverview_RegistryPreview" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml.cs index fe53515bc9..41d814e48b 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/RegistryPreviewPage.xaml.cs @@ -5,17 +5,16 @@ using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.ViewModels; -using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class RegistryPreviewPage : Page, IRefreshablePage + public sealed partial class RegistryPreviewPage : NavigablePage, IRefreshablePage { private RegistryPreviewViewModel ViewModel { get; set; } public RegistryPreviewPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new RegistryPreviewViewModel( SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<RegistryPreviewSettings>.GetInstance(settingsUtils), diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/SearchResultsPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/SearchResultsPage.xaml new file mode 100644 index 0000000000..7d7a254789 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/SearchResultsPage.xaml @@ -0,0 +1,93 @@ +<Page + x:Class="Microsoft.PowerToys.Settings.UI.Views.SearchResultsPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + 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:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:models="using:Settings.UI.Library" + xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" + xmlns:ui="using:CommunityToolkit.WinUI" + xmlns:vm="using:Microsoft.PowerToys.Settings.UI.ViewModels" + x:Name="RootPage" + AutomationProperties.LandmarkType="Main" + mc:Ignorable="d"> + + <Page.Resources> + <converters:IconConverter x:Key="IconConverter" /> + <tkconverters:CollectionVisibilityConverter x:Key="CollectionVisibilityConverter" /> + </Page.Resources> + + <controls:SettingsPageControl x:Name="PageControl" x:Uid="SearchResults_Title"> + <controls:SettingsPageControl.ModuleContent> + <StackPanel Margin="0,-40,0,0" Orientation="Vertical"> + <controls:SettingsGroup + x:Uid="SearchResults_ModulesTitle" + Margin="0,-10,0,0" + Visibility="{x:Bind ViewModel.ModuleResults, Mode=OneWay, Converter={StaticResource CollectionVisibilityConverter}}"> + <ItemsControl + x:Name="ModulesItemsControl" + IsTabStop="False" + ItemsSource="{x:Bind ViewModel.ModuleResults, Mode=OneWay}"> + <ItemsControl.ItemTemplate> + <DataTemplate x:DataType="models:SettingEntry"> + <tkcontrols:SettingsCard + Margin="0,0,0,2" + Click="ModuleButton_Click" + Header="{x:Bind Header}" + HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=}" + IsClickEnabled="True" /> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </controls:SettingsGroup> + + <!-- Settings Groups --> + <ItemsControl + x:Name="SettingsGroupsItemsControl" + IsTabStop="False" + ItemsSource="{x:Bind ViewModel.GroupedSettingsResults, Mode=OneWay}"> + <ItemsControl.ItemTemplate> + <DataTemplate x:DataType="vm:SettingsGroup"> + <controls:SettingsGroup Header="{x:Bind GroupName}"> + <ItemsControl IsTabStop="False" ItemsSource="{x:Bind Settings}"> + <ItemsControl.ItemTemplate> + <DataTemplate x:DataType="models:SettingEntry"> + <tkcontrols:SettingsCard + Margin="0,0,0,2" + Click="SettingButton_Click" + Description="{x:Bind Description}" + Header="{x:Bind Header}" + HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=}" + IsClickEnabled="True" /> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </controls:SettingsGroup> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + + <!-- No Results Message --> + <StackPanel + x:Name="NoResultsPanel" + HorizontalAlignment="Center" + Spacing="16" + Visibility="{x:Bind ViewModel.HasNoResults, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> + <FontIcon + AutomationProperties.AccessibilityView="Raw" + FontSize="48" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Glyph="" /> + <TextBlock HorizontalAlignment="Center" TextAlignment="Center"> + <Run x:Uid="SearchResults_NoResultsHeader" FontWeight="SemiBold" /> + <LineBreak /> + <Run x:Uid="SearchResults_NoResultsDescription" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + </TextBlock> + </StackPanel> + </StackPanel> + </controls:SettingsPageControl.ModuleContent> + </controls:SettingsPageControl> +</Page> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/SearchResultsPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/SearchResultsPage.xaml.cs new file mode 100644 index 0000000000..8469851ca5 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/SearchResultsPage.xaml.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; +using System.Collections.Generic; +using CommunityToolkit.WinUI.Controls; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Navigation; +using Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.Views +{ + public partial class SearchResultsPage : Page + { + public SearchResultsViewModel ViewModel { get; set; } + + public SearchResultsPage() + { + ViewModel = new SearchResultsViewModel(); + InitializeComponent(); + DataContext = ViewModel; + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + + if (e.Parameter is SearchResultsNavigationParams searchParams) + { + ViewModel.SetSearchResults(searchParams.Query, searchParams.Results); + PageControl.ModuleDescription = $"{ResourceLoaderInstance.ResourceLoader.GetString("Search_ResultsFor")} '{searchParams.Query}'"; + } + } + + public void RefreshEnabledState() + { + // Implementation if needed for IRefreshablePage + } + + private void ModuleButton_Click(object sender, RoutedEventArgs e) + { + if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry) + { + NavigateToModule(tagEntry); + } + } + + private void SettingButton_Click(object sender, RoutedEventArgs e) + { + if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry) + { + NavigateToSetting(tagEntry); + } + } + + private void NavigateToModule(SettingEntry settingEntry) + { + // Get the page type from the setting entry + var pageType = GetPageTypeFromName(settingEntry.PageTypeName); + if (pageType != null) + { + NavigationService.Navigate(pageType); + } + } + + private void NavigateToSetting(SettingEntry settingEntry) + { + // Get the page type from the setting entry + var pageType = GetPageTypeFromName(settingEntry.PageTypeName); + if (pageType != null) + { + // Create navigation parameters to highlight the specific setting + var navigationParams = new NavigationParams(settingEntry.ElementName, settingEntry.ParentElementName); + NavigationService.Navigate(pageType, navigationParams); + } + } + + private Type GetPageTypeFromName(string pageTypeName) + { + if (string.IsNullOrEmpty(pageTypeName)) + { + return null; + } + + var assembly = typeof(GeneralPage).Assembly; + return assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}"); + } + } + +#pragma warning disable SA1402 // File may only contain a single type + public class SearchResultsNavigationParams +#pragma warning restore SA1402 // File may only contain a single type + { + public string Query { get; set; } + + public List<SettingEntry> Results { get; set; } + + public SearchResultsNavigationParams(string query, List<SettingEntry> results) + { + Query = query; + Results = results; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml index 09d15aa724..56bc838161 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml @@ -2,69 +2,129 @@ x:Class="Microsoft.PowerToys.Settings.UI.Views.ShellPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:animatedvisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals" + xmlns:animatedVisuals="using:Microsoft.UI.Xaml.Controls.AnimatedVisuals" xmlns:animations="using:CommunityToolkit.WinUI.Animations" + xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" + xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:helpers="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:i="using:Microsoft.Xaml.Interactivity" xmlns:ic="using:Microsoft.Xaml.Interactions.Core" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:models="using:Microsoft.PowerToys.Settings.UI.ViewModels" + xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters" xmlns:ui="using:CommunityToolkit.WinUI" xmlns:views="using:Microsoft.PowerToys.Settings.UI.Views" HighContrastAdjustment="None" Loaded="ShellPage_Loaded" mc:Ignorable="d"> - + <UserControl.Resources> + <converters:IconConverter x:Key="IconConverter" /> + <converters:SearchSuggestionTemplateSelector + x:Key="SearchSuggestionTemplateSelector" + DefaultSuggestionTemplate="{StaticResource DefaultSearchResultTemplate}" + NoResultsSuggestionTemplate="{StaticResource NoResultSearchResultTemplate}" + ShowAllSuggestionTemplate="{StaticResource ShowAllSearchResultTemplate}" /> + <DataTemplate x:Key="DefaultSearchResultTemplate" x:DataType="models:SuggestionItem"> + <Grid Padding="16,8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <Viewbox + Width="16" + Height="16" + HorizontalAlignment="Center" + VerticalAlignment="Center" + Stretch="Uniform"> + <ContentPresenter + HorizontalAlignment="Center" + VerticalAlignment="Center" + Content="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=}" /> + </Viewbox> + <StackPanel + Grid.Column="1" + Margin="12,0,0,0" + VerticalAlignment="Center"> + <TextBlock Text="{x:Bind Header}" /> + <TextBlock + FontSize="12" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" + Text="{x:Bind Subtitle}" + Visibility="{x:Bind Subtitle, Converter={StaticResource StringVisibilityConverter}}" /> + </StackPanel> + </Grid> + </DataTemplate> + <DataTemplate x:Key="NoResultSearchResultTemplate" x:DataType="models:SuggestionItem"> + <Grid> + <TextBlock + Margin="8" + HorizontalAlignment="Center" + Text="{x:Bind Header}" + TextAlignment="Center" /> + </Grid> + </DataTemplate> + <DataTemplate x:Key="ShowAllSearchResultTemplate" x:DataType="models:SuggestionItem"> + <Grid> + <Rectangle + Height="1" + Margin="0,-12,0,0" + HorizontalAlignment="Stretch" + VerticalAlignment="Top" + Fill="{ThemeResource DividerStrokeColorDefaultBrush}" /> + <TextBlock + x:Uid="Shell_Search_ShowAll" + HorizontalAlignment="Stretch" + VerticalAlignment="Center" + TextAlignment="Center" /> + </Grid> + </DataTemplate> + </UserControl.Resources> <i:Interaction.Behaviors> <ic:EventTriggerBehavior EventName="Loaded"> <ic:InvokeCommandAction Command="{x:Bind ViewModel.LoadedCommand}" /> </ic:EventTriggerBehavior> </i:Interaction.Behaviors> - <Grid x:Name="RootGrid"> <Grid.RowDefinitions> <RowDefinition Height="48" /> <RowDefinition Height="*" /> </Grid.RowDefinitions> - <Button - x:Name="PaneToggleBtn" - Width="48" - HorizontalAlignment="Left" - VerticalAlignment="Center" - Click="PaneToggleBtn_Click" - Style="{StaticResource PaneToggleButtonStyle}" /> - <Grid + <controls:TitleBar x:Name="AppTitleBar" - Height="{Binding ElementName=navigationView, Path=CompactPaneLength}" - Margin="48,0,0,0" - VerticalAlignment="Top" - IsHitTestVisible="True"> - <animations:Implicit.Animations> - <animations:OffsetAnimation Duration="0:0:0.3" /> - </animations:Implicit.Animations> - <StackPanel Orientation="Horizontal"> - <Image - Width="16" - Height="16" - HorizontalAlignment="Left" - Source="/Assets/Settings/icon.ico" /> - <TextBlock - x:Name="AppTitleBarText" - Margin="12,0,0,0" + AutoConfigureCustomTitleBar="True" + CompactStateBreakpoint="900" + IsTabStop="False" + PaneButtonClick="PaneToggleBtn_Click"> + <controls:TitleBar.Resources> + <x:Double x:Key="TitleBarContentMinWidth">516</x:Double> + </controls:TitleBar.Resources> + <controls:TitleBar.Icon> + <BitmapIcon ShowAsMonochrome="False" UriSource="/Assets/Settings/icon.ico" /> + </controls:TitleBar.Icon> + <controls:TitleBar.Content> + <AutoSuggestBox + x:Name="SearchBox" + x:Uid="Shell_SearchBox" + HorizontalAlignment="Stretch" VerticalAlignment="Center" - Style="{StaticResource CaptionTextBlockStyle}" - TextWrapping="NoWrap" /> - <TextBlock - x:Name="DebugMessage" - Margin="8,0,0,0" - VerticalAlignment="Center" - Foreground="{ThemeResource TextFillColorSecondaryBrush}" - Style="{StaticResource CaptionTextBlockStyle}" - Text="Debug" - TextWrapping="NoWrap" - Visibility="Collapsed" /> - </StackPanel> - </Grid> + GotFocus="SearchBox_GotFocus" + ItemTemplateSelector="{StaticResource SearchSuggestionTemplateSelector}" + QueryIcon="Find" + QuerySubmitted="SearchBox_QuerySubmitted" + SuggestionChosen="SearchBox_SuggestionChosen" + TextChanged="SearchBox_TextChanged" + TextMemberPath="Header" + UpdateTextOnSelect="False"> + <AutoSuggestBox.KeyboardAccelerators> + <KeyboardAccelerator + Key="F" + Invoked="CtrlF_Invoked" + Modifiers="Control" /> + </AutoSuggestBox.KeyboardAccelerators> + </AutoSuggestBox> + </controls:TitleBar.Content> + </controls:TitleBar> <NavigationView x:Name="navigationView" Grid.Row="1" @@ -87,15 +147,21 @@ </NavigationView.Resources> <NavigationView.MenuItems> <NavigationViewItem + x:Name="DashboardNavigationItem" x:Uid="Shell_Dashboard" helpers:NavHelper.NavigateTo="views:DashboardPage" + AutomationProperties.AutomationId="DashboardNavItem" Icon="{ui:FontIcon Glyph=}" /> - <NavigationViewItem x:Uid="Shell_General" helpers:NavHelper.NavigateTo="views:GeneralPage"> + <NavigationViewItem + x:Name="GeneralNavigationItem" + x:Uid="Shell_General" + helpers:NavHelper.NavigateTo="views:GeneralPage" + AutomationProperties.AutomationId="GeneralNavItem"> <NavigationViewItem.Icon> <AnimatedIcon> <AnimatedIcon.Source> - <animatedvisuals:AnimatedSettingsVisualSource /> + <animatedVisuals:AnimatedSettingsVisualSource /> </AnimatedIcon.Source> <AnimatedIcon.FallbackIconSource> <SymbolIconSource Symbol="Setting" /> @@ -107,7 +173,9 @@ <!-- System Tools --> <NavigationViewItem + x:Name="SystemToolsNavigationItem" x:Uid="Shell_TopLevelSystemTools" + AutomationProperties.AutomationId="SystemToolsNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/SystemTools.png}" SelectsOnInvoked="False"> <NavigationViewItem.InfoBadge> @@ -115,155 +183,236 @@ </NavigationViewItem.InfoBadge> <NavigationViewItem.MenuItems> <NavigationViewItem + x:Name="AdvancedPasteNavigationItem" x:Uid="Shell_AdvancedPaste" helpers:NavHelper.NavigateTo="views:AdvancedPastePage" + AutomationProperties.AutomationId="AdvancedPasteNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AdvancedPaste.png}" /> <NavigationViewItem + x:Name="AwakeNavigationItem" x:Uid="Shell_Awake" helpers:NavHelper.NavigateTo="views:AwakePage" + AutomationProperties.AutomationId="AwakeNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Awake.png}" /> <NavigationViewItem + x:Name="CmdPalNavigationItem" x:Uid="Shell_CmdPal" helpers:NavHelper.NavigateTo="views:CmdPalPage" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CmdPal.png}"> + AutomationProperties.AutomationId="CmdPalNavItem" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CmdPal.png}" /> + <NavigationViewItem + x:Name="ColorPickerNavigationItem" + x:Uid="Shell_ColorPicker" + helpers:NavHelper.NavigateTo="views:ColorPickerPage" + AutomationProperties.AutomationId="ColorPickerNavItem" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ColorPicker.png}" /> + <NavigationViewItem + x:Name="LightSwitchNavigationItem" + x:Uid="Shell_LightSwitch" + helpers:NavHelper.NavigateTo="views:LightSwitchPage" + AutomationProperties.AutomationId="LightSwitchNavItem" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/LightSwitch.png}" /> + <NavigationViewItem + x:Name="PowerDisplayNavigationItem" + x:Uid="Shell_PowerDisplay" + helpers:NavHelper.NavigateTo="views:PowerDisplayPage" + AutomationProperties.AutomationId="PowerDisplayNavItem" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerDisplay.png}"> <NavigationViewItem.InfoBadge> <InfoBadge Style="{StaticResource NewInfoBadge}" /> </NavigationViewItem.InfoBadge> </NavigationViewItem> <NavigationViewItem - x:Uid="Shell_ColorPicker" - helpers:NavHelper.NavigateTo="views:ColorPickerPage" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ColorPicker.png}" /> - <NavigationViewItem + x:Name="PowerLauncherNavigationItem" x:Uid="Shell_PowerLauncher" helpers:NavHelper.NavigateTo="views:PowerLauncherPage" + AutomationProperties.AutomationId="PowerLauncherNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerToysRun.png}" /> <NavigationViewItem + x:Name="MeasureToolNavigationItem" x:Uid="Shell_MeasureTool" helpers:NavHelper.NavigateTo="views:MeasureToolPage" + AutomationProperties.AutomationId="ScreenRulerNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ScreenRuler.png}" /> <NavigationViewItem + x:Name="ShortcutGuideNavigationItem" x:Uid="Shell_ShortcutGuide" helpers:NavHelper.NavigateTo="views:ShortcutGuidePage" + AutomationProperties.AutomationId="ShortcutGuideNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ShortcutGuide.png}" /> <NavigationViewItem + x:Name="TextExtractorNavigationItem" x:Uid="Shell_TextExtractor" helpers:NavHelper.NavigateTo="views:PowerOcrPage" + AutomationProperties.AutomationId="TextExtractorNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/TextExtractor.png}" /> <NavigationViewItem + x:Name="ZoomItNavigationItem" x:Uid="Shell_ZoomIt" helpers:NavHelper.NavigateTo="views:ZoomItPage" + AutomationProperties.AutomationId="ZoomItNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ZoomIt.png}" /> </NavigationViewItem.MenuItems> </NavigationViewItem> <!-- Windowing & Layouts --> <NavigationViewItem - x:Uid="Shell_TopLevelWindowsAndLayouts " + x:Name="WindowingAndLayoutsNavigationItem" + x:Uid="Shell_TopLevelWindowsAndLayouts" + AutomationProperties.AutomationId="WindowingAndLayoutsNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/WindowingAndLayouts.png}" SelectsOnInvoked="False"> <NavigationViewItem.MenuItems> <NavigationViewItem + x:Name="AlwaysOnTopNavigationItem" x:Uid="Shell_AlwaysOnTop" helpers:NavHelper.NavigateTo="views:AlwaysOnTopPage" + AutomationProperties.AutomationId="AlwaysOnTopNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AlwaysOnTop.png}" /> <NavigationViewItem + x:Name="CropAndLockNavigationItem" x:Uid="Shell_CropAndLock" helpers:NavHelper.NavigateTo="views:CropAndLockPage" + AutomationProperties.AutomationId="CropAndLockNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CropAndLock.png}" /> <NavigationViewItem + x:Name="FancyZonesNavigationItem" x:Uid="Shell_FancyZones" helpers:NavHelper.NavigateTo="views:FancyZonesPage" + AutomationProperties.AutomationId="FancyZonesNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FancyZones.png}" /> <NavigationViewItem + x:Name="WorkspacesNavigationItem" x:Uid="Shell_Workspaces" helpers:NavHelper.NavigateTo="views:WorkspacesPage" + AutomationProperties.AutomationId="WorkspacesNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Workspaces.png}" /> </NavigationViewItem.MenuItems> </NavigationViewItem> <!-- Input / Output --> <NavigationViewItem + x:Name="InputOutputNavigationItem" x:Uid="Shell_TopLevelInputOutput" + AutomationProperties.AutomationId="InputOutputNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/InputOutput.png}" SelectsOnInvoked="False"> + <NavigationViewItem.InfoBadge> + <InfoBadge Style="{StaticResource NewInfoBadge}" /> + </NavigationViewItem.InfoBadge> <NavigationViewItem.MenuItems> <NavigationViewItem + x:Name="KeyboardManagerNavigationItem" x:Uid="Shell_KeyboardManager" helpers:NavHelper.NavigateTo="views:KeyboardManagerPage" + AutomationProperties.AutomationId="KeyboardManagerNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/KeyboardManager.png}" /> <!-- Find my mouse --> <!-- Mouse Highlighter --> <NavigationViewItem + x:Name="MouseUtilitiesNavigationItem" x:Uid="Shell_MouseUtilities" helpers:NavHelper.NavigateTo="views:MouseUtilsPage" - Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseUtils.png}" /> + AutomationProperties.AutomationId="MouseUtilitiesNavItem" + Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseUtils.png}"> + <NavigationViewItem.InfoBadge> + <InfoBadge Style="{StaticResource NewInfoBadge}" /> + </NavigationViewItem.InfoBadge> + </NavigationViewItem> <NavigationViewItem + x:Name="MouseWithoutBordersNavigationItem" x:Uid="Shell_MouseWithoutBorders" helpers:NavHelper.NavigateTo="views:MouseWithoutBordersPage" + AutomationProperties.AutomationId="MouseWithoutBordersNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseWithoutBorders.png}" /> <NavigationViewItem + x:Name="QuickAccentNavigationItem" x:Uid="Shell_QuickAccent" helpers:NavHelper.NavigateTo="views:PowerAccentPage" + AutomationProperties.AutomationId="QuickAccentNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/QuickAccent.png}" /> </NavigationViewItem.MenuItems> </NavigationViewItem> <!-- File Management --> <NavigationViewItem + x:Name="FileManagementNavigationItem" x:Uid="Shell_TopLevelFileManagement" + AutomationProperties.AutomationId="FileManagementNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FileManagement.png}" SelectsOnInvoked="False"> <NavigationViewItem.MenuItems> <NavigationViewItem + x:Name="PowerPreviewNavigationItem" x:Uid="Shell_PowerPreview" helpers:NavHelper.NavigateTo="views:PowerPreviewPage" + AutomationProperties.AutomationId="PowerPreviewNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FileExplorerPreview.png}" /> <!-- File Explorer Thumbnails --> <NavigationViewItem + x:Name="FileLocksmithNavigationItem" x:Uid="Shell_FileLocksmith" helpers:NavHelper.NavigateTo="views:FileLocksmithPage" + AutomationProperties.AutomationId="FileLocksmithNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/FileLocksmith.png}" /> <NavigationViewItem + x:Name="ImageResizerNavigationItem" x:Uid="Shell_ImageResizer" helpers:NavHelper.NavigateTo="views:ImageResizerPage" + AutomationProperties.AutomationId="ImageResizerNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ImageResizer.png}" /> <NavigationViewItem + x:Name="NewPlusNavigationItem" x:Uid="NewPlus_Product_Name" helpers:NavHelper.NavigateTo="views:NewPlusPage" + AutomationProperties.AutomationId="NewPlusNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/NewPlus.png}" /> <NavigationViewItem + x:Name="PeekNavigationItem" x:Uid="Shell_Peek" helpers:NavHelper.NavigateTo="views:PeekPage" + AutomationProperties.AutomationId="PeekNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Peek.png}" /> <NavigationViewItem + x:Name="PowerRenameNavigationItem" x:Uid="Shell_PowerRename" helpers:NavHelper.NavigateTo="views:PowerRenamePage" + AutomationProperties.AutomationId="PowerRenameNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/PowerRename.png}" /> </NavigationViewItem.MenuItems> </NavigationViewItem> <!-- Advanced --> <NavigationViewItem + x:Name="AdvancedNavigationItem" x:Uid="Shell_TopLevelAdvanced" + AutomationProperties.AutomationId="AdvancedNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Advanced.png}" SelectsOnInvoked="False"> <NavigationViewItem.MenuItems> <NavigationViewItem + x:Name="CmdNotFoundNavigationItem" x:Uid="Shell_CmdNotFound" helpers:NavHelper.NavigateTo="views:CmdNotFoundPage" + AutomationProperties.AutomationId="CmdNotFoundNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/CommandNotFound.png}" /> <NavigationViewItem + x:Name="EnvironmentVariablesNavigationItem" x:Uid="Shell_EnvironmentVariables" helpers:NavHelper.NavigateTo="views:EnvironmentVariablesPage" + AutomationProperties.AutomationId="EnvironmentVariablesNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/EnvironmentVariables.png}" /> <NavigationViewItem + x:Name="HostsNavigationItem" x:Uid="Shell_Hosts" helpers:NavHelper.NavigateTo="views:HostsPage" + AutomationProperties.AutomationId="HostsNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Hosts.png}" /> <NavigationViewItem + x:Name="RegistryPreviewNavigationItem" x:Uid="Shell_RegistryPreview" helpers:NavHelper.NavigateTo="views:RegistryPreviewPage" + AutomationProperties.AutomationId="RegistryPreviewNavItem" Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/RegistryPreview.png}" /> </NavigationViewItem.MenuItems> </NavigationViewItem> @@ -271,17 +420,30 @@ <NavigationView.PaneFooter> <StackPanel Orientation="Vertical"> <NavigationViewItem + x:Name="OOBENavigationItem" x:Uid="OOBE_NavViewItem" + AutomationProperties.AutomationId="OOBENavItem" Icon="{ui:FontIcon Glyph=}" Tapped="OOBEItem_Tapped" /> <NavigationViewItem + x:Name="WhatIsNewNavigationItem" x:Uid="WhatIsNew_NavViewItem" + AutomationProperties.AutomationId="WhatIsNewNavItem" Icon="{ui:FontIcon Glyph=}" Tapped="WhatIsNewItem_Tapped" /> <NavigationViewItem + x:Name="FeedbackNavigationItem" x:Uid="Feedback_NavViewItem" + AutomationProperties.AutomationId="FeedbackNavItem" Icon="{ui:FontIcon Glyph=}" Tapped="FeedbackItem_Tapped" /> + <NavigationViewItem + x:Name="CloseNavigationItem" + x:Uid="Close_NavViewItem" + AutomationProperties.AutomationId="CloseNavItem" + Icon="{ui:FontIcon Glyph=}" + Tapped="Close_Tapped" + Visibility="{x:Bind ViewModel.ShowCloseMenu, Mode=OneWay}" /> </StackPanel> </NavigationView.PaneFooter> <i:Interaction.Behaviors> @@ -291,5 +453,12 @@ </i:Interaction.Behaviors> <Frame x:Name="shellFrame" /> </NavigationView> + <ContentDialog + x:Name="CloseDialog" + x:Uid="CloseDialog" + IsPrimaryButtonEnabled="True" + IsSecondaryButtonEnabled="True" + PrimaryButtonClick="CloseDialog_Click" + PrimaryButtonStyle="{StaticResource AccentButtonStyle}" /> </Grid> </UserControl> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs index e416e7ae59..7515cad863 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml.cs @@ -5,13 +5,22 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Common.Search; +using Common.Search.FuzzSearch; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Controls; using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Services; using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Settings.UI.Library; using Windows.Data.Json; using Windows.System; using WinRT.Interop; @@ -21,7 +30,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views /// <summary> /// Root page. /// </summary> - public sealed partial class ShellPage : UserControl + public sealed partial class ShellPage : UserControl, IDisposable { /// <summary> /// Declaration for the ipc callback function. @@ -30,35 +39,15 @@ namespace Microsoft.PowerToys.Settings.UI.Views public delegate void IPCMessageCallback(string msg); /// <summary> - /// Declaration for the opening main window callback function. + /// Declaration for opening main window callback function. /// </summary> public delegate void MainOpeningCallback(Type type); /// <summary> - /// Declaration for the updating the general settings callback function. + /// Declaration for updating the general settings callback function. /// </summary> public delegate bool UpdatingGeneralSettingsCallback(ModuleType moduleType, bool isEnabled); - /// <summary> - /// Declaration for the opening oobe window callback function. - /// </summary> - public delegate void OobeOpeningCallback(); - - /// <summary> - /// Declaration for the opening whats new window callback function. - /// </summary> - public delegate void WhatIsNewOpeningCallback(); - - /// <summary> - /// Declaration for the opening flyout window callback function. - /// </summary> - public delegate void FlyoutOpeningCallback(POINT? point); - - /// <summary> - /// Declaration for the disabling hide of flyout window callback function. - /// </summary> - public delegate void DisablingFlyoutHidingCallback(); - /// <summary> /// Gets or sets a shell handler to be used to update contents of the shell dynamically from page within the frame. /// </summary> @@ -89,30 +78,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views /// </summary> public static UpdatingGeneralSettingsCallback UpdateGeneralSettingsCallback { get; set; } - /// <summary> - /// Gets or sets callback function for opening oobe window - /// </summary> - public static OobeOpeningCallback OpenOobeWindowCallback { get; set; } - - /// <summary> - /// Gets or sets callback function for opening oobe window - /// </summary> - public static WhatIsNewOpeningCallback OpenWhatIsNewWindowCallback { get; set; } - - /// <summary> - /// Gets or sets callback function for opening flyout window - /// </summary> - public static FlyoutOpeningCallback OpenFlyoutCallback { get; set; } - - /// <summary> - /// Gets or sets callback function for disabling hide of flyout window - /// </summary> - public static DisablingFlyoutHidingCallback DisableFlyoutHidingCallback { get; set; } - /// <summary> /// Gets view model. /// </summary> - public ShellViewModel ViewModel { get; } = new ShellViewModel(); + public ShellViewModel ViewModel { get; } /// <summary> /// Gets a collection of functions that handle IPC responses. @@ -123,7 +92,16 @@ namespace Microsoft.PowerToys.Settings.UI.Views public static bool IsUserAnAdmin { get; set; } + public Controls.TitleBar TitleBar => AppTitleBar; + private Dictionary<Type, NavigationViewItem> _navViewParentLookup = new Dictionary<Type, NavigationViewItem>(); + private List<string> _searchSuggestions = []; + + private CancellationTokenSource _searchDebounceCts; + private const int SearchDebounceMs = 500; + private bool _disposed; + + // Removed trace id counter per cleanup /// <summary> /// Initializes a new instance of the <see cref="ShellPage"/> class. @@ -132,7 +110,9 @@ namespace Microsoft.PowerToys.Settings.UI.Views public ShellPage() { InitializeComponent(); - + SetWindowTitle(); + var settingsUtils = SettingsUtils.Default; + ViewModel = new ShellViewModel(SettingsRepository<GeneralSettings>.GetInstance(settingsUtils)); DataContext = ViewModel; ShellHandler = this; ViewModel.Initialize(shellFrame, navigationView, KeyboardAccelerators); @@ -140,7 +120,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views // NL moved navigation to general page to the moment when the window is first activated (to not make flyout window disappear) // shellFrame.Navigate(typeof(GeneralPage)); IPCResponseHandleList.Add(ReceiveMessage); - SetTitleBar(); + IPCResponseService.Instance.RegisterForIPC(); if (_navViewParentLookup.Count > 0) { @@ -154,6 +134,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views foreach (var child in parent.MenuItems.OfType<NavigationViewItem>()) { _navViewParentLookup.TryAdd(child.GetValue(NavHelper.NavigateToProperty) as Type, parent); + _searchSuggestions.Add(child.Content?.ToString()); } } } @@ -222,42 +203,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views UpdateGeneralSettingsCallback = implementation; } - /// <summary> - /// Set oobe opening callback function - /// </summary> - /// <param name="implementation">delegate function implementation.</param> - public static void SetOpenOobeCallback(OobeOpeningCallback implementation) - { - OpenOobeWindowCallback = implementation; - } - - /// <summary> - /// Set whats new opening callback function - /// </summary> - /// <param name="implementation">delegate function implementation.</param> - public static void SetOpenWhatIsNewCallback(WhatIsNewOpeningCallback implementation) - { - OpenWhatIsNewWindowCallback = implementation; - } - - /// <summary> - /// Set flyout opening callback function - /// </summary> - /// <param name="implementation">delegate function implementation.</param> - public static void SetOpenFlyoutCallback(FlyoutOpeningCallback implementation) - { - OpenFlyoutCallback = implementation; - } - - /// <summary> - /// Set disable flyout hiding callback function - /// </summary> - /// <param name="implementation">delegate function implementation.</param> - public static void SetDisableFlyoutHidingCallback(DisablingFlyoutHidingCallback implementation) - { - DisableFlyoutHidingCallback = implementation; - } - public static void SetElevationStatus(bool isElevated) { IsElevated = isElevated; @@ -288,11 +233,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views } } - private void OobeButton_Click(object sender, RoutedEventArgs e) - { - OpenOobeWindowCallback(); - } - private bool navigationViewInitialStateProcessed; // avoid announcing initial state of the navigation pane. private void NavigationView_PaneOpened(NavigationView sender, object args) @@ -345,34 +285,33 @@ namespace Microsoft.PowerToys.Settings.UI.Views } } - private void OOBEItem_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) + private void OOBEItem_Tapped(object sender, TappedRoutedEventArgs e) { - OpenOobeWindowCallback(); + ((App)App.Current)!.OpenOobe(); } - private async void FeedbackItem_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) + private void WhatIsNewItem_Tapped(object sender, TappedRoutedEventArgs e) + { + ((App)App.Current)!.OpenScoobe(); + } + + private async void FeedbackItem_Tapped(object sender, TappedRoutedEventArgs e) { await Launcher.LaunchUriAsync(new Uri("https://aka.ms/powerToysGiveFeedback")); } - private void WhatIsNewItem_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) - { - OpenWhatIsNewWindowCallback(); - } - private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) { - NavigationViewItem selectedItem = args.SelectedItem as NavigationViewItem; - if (selectedItem != null) + if (args.SelectedItem is NavigationViewItem selectedItem) { Type pageType = selectedItem.GetValue(NavHelper.NavigateToProperty) as Type; - if (_navViewParentLookup.TryGetValue(pageType, out var parentItem) && !parentItem.IsExpanded) + if (pageType != null && _navViewParentLookup.TryGetValue(pageType, out var parentItem) && !parentItem.IsExpanded) { parentItem.IsExpanded = true; + ViewModel.Expanding = parentItem; + NavigationService.Navigate(pageType); } - - NavigationService.Navigate(pageType); } } @@ -383,25 +322,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views IJsonValue whatToShowJson; if (json.TryGetValue("ShowYourself", out whatToShowJson)) { - if (whatToShowJson.ValueType == JsonValueType.String && whatToShowJson.GetString().Equals("flyout", StringComparison.Ordinal)) - { - POINT? p = null; - - IJsonValue flyoutPointX; - IJsonValue flyoutPointY; - if (json.TryGetValue("x_position", out flyoutPointX) && json.TryGetValue("y_position", out flyoutPointY)) - { - if (flyoutPointX.ValueType == JsonValueType.Number && flyoutPointY.ValueType == JsonValueType.Number) - { - int flyout_x = (int)flyoutPointX.GetNumber(); - int flyout_y = (int)flyoutPointY.GetNumber(); - p = new POINT(flyout_x, flyout_y); - } - } - - OpenFlyoutCallback(p); - } - else if (whatToShowJson.ValueType == JsonValueType.String) + if (whatToShowJson.ValueType == JsonValueType.String) { OpenMainWindowCallback(App.GetPage(whatToShowJson.GetString())); } @@ -414,42 +335,33 @@ namespace Microsoft.PowerToys.Settings.UI.Views NavigationService.EnsurePageIsSelected(typeof(DashboardPage)); } - private void SetTitleBar() + private void SetWindowTitle() { - var u = App.GetSettingsWindow(); - if (u != null) - { - // A custom title bar is required for full window theme and Mica support. - // https://docs.microsoft.com/windows/apps/develop/title-bar?tabs=winui3#full-customization - u.ExtendsContentIntoTitleBar = true; - WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(u)); - u.SetTitleBar(AppTitleBar); - var loader = ResourceLoaderInstance.ResourceLoader; - AppTitleBarText.Text = App.IsElevated ? loader.GetString("SettingsWindow_AdminTitle") : loader.GetString("SettingsWindow_Title"); + var loader = ResourceLoaderInstance.ResourceLoader; + AppTitleBar.Title = App.IsElevated ? loader.GetString("SettingsWindow_AdminTitle") : loader.GetString("SettingsWindow_Title"); #if DEBUG - DebugMessage.Visibility = Visibility.Visible; + AppTitleBar.Subtitle = "Debug"; #endif - } } private void ShellPage_Loaded(object sender, RoutedEventArgs e) { - SetTitleBar(); + Task.Run(() => + { + SearchIndexService.BuildIndex(); + }) + .ContinueWith(_ => { }); } private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args) { if (args.DisplayMode == NavigationViewDisplayMode.Compact || args.DisplayMode == NavigationViewDisplayMode.Minimal) { - PaneToggleBtn.Visibility = Visibility.Visible; - AppTitleBar.Margin = new Thickness(48, 0, 0, 0); - AppTitleBarText.Margin = new Thickness(12, 0, 0, 0); + AppTitleBar.IsPaneButtonVisible = true; } else { - PaneToggleBtn.Visibility = Visibility.Collapsed; - AppTitleBar.Margin = new Thickness(16, 0, 0, 0); - AppTitleBarText.Margin = new Thickness(16, 0, 0, 0); + AppTitleBar.IsPaneButtonVisible = false; } } @@ -457,5 +369,271 @@ namespace Microsoft.PowerToys.Settings.UI.Views { navigationView.IsPaneOpen = !navigationView.IsPaneOpen; } + + private async void Close_Tapped(object sender, TappedRoutedEventArgs e) + { + await CloseDialog.ShowAsync(); + } + + private void CloseDialog_Click(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + const string ptTrayIconWindowClass = "PToyTrayIconWindow"; // Defined in runner/tray_icon.h + const nuint ID_CLOSE_MENU_COMMAND = 40001; // Generated resource from runner/runner.base.rc + + // Exit the XAML application + Application.Current.Exit(); + + // Invoke the exit command from the tray icon + IntPtr hWnd = NativeMethods.FindWindow(ptTrayIconWindowClass, ptTrayIconWindowClass); + NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_CLOSE_MENU_COMMAND, 0); + } + + private List<SettingEntry> _lastSearchResults = new(); + private string _lastQueryText = string.Empty; + + private async void SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args) + { + // Only respond to user input, not programmatic text changes + if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput) + { + return; + } + + var query = sender.Text?.Trim() ?? string.Empty; + + // Debounce: cancel previous pending search + _searchDebounceCts?.Cancel(); + _searchDebounceCts?.Dispose(); + _searchDebounceCts = new CancellationTokenSource(); + var token = _searchDebounceCts.Token; + + if (string.IsNullOrWhiteSpace(query)) + { + sender.ItemsSource = null; + sender.IsSuggestionListOpen = false; + _lastSearchResults.Clear(); + _lastQueryText = string.Empty; + return; + } + + try + { + await Task.Delay(SearchDebounceMs, token); + } + catch (TaskCanceledException) + { + return; // debounce canceled + } + + if (token.IsCancellationRequested) + { + return; + } + + // Query the index on a background thread to avoid blocking UI + List<SettingEntry> results = null; + try + { + // If the token is already canceled before scheduling, the task won't start. + results = await Task.Run(() => SearchIndexService.Search(query, token), token); + } + catch (OperationCanceledException) + { + return; + } + + if (token.IsCancellationRequested) + { + return; + } + + _lastSearchResults = results; + _lastQueryText = query; + + var top = BuildSuggestionItems(query, results); + + sender.ItemsSource = top; + sender.IsSuggestionListOpen = top.Count > 0; + } + + private void SearchBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args) + { + // Do not navigate on arrow navigation. Let QuerySubmitted handle commits (Enter/click). + // AutoSuggestBox will pass the chosen item via args.ChosenSuggestion to QuerySubmitted. + // No action required here. + } + + private void NavigateFromSuggestion(SuggestionItem item) + { + var queryText = _lastQueryText; + + if (item.IsShowAll) + { + // Navigate to full results page + var searchParams = new SearchResultsNavigationParams(queryText, _lastSearchResults); + NavigationService.Navigate<SearchResultsPage>(searchParams); + + SearchBox.Text = string.Empty; + return; + } + + // Navigate to the selected item + var pageType = GetPageTypeFromName(item.PageTypeName); + if (pageType != null) + { + if (string.IsNullOrEmpty(item.ElementName)) + { + NavigationService.Navigate(pageType); + } + else + { + var navigationParams = new NavigationParams(item.ElementName, item.ParentElementName); + NavigationService.Navigate(pageType, navigationParams); + } + + // Clear the search box after navigation + SearchBox.Text = string.Empty; + } + } + + private static Type GetPageTypeFromName(string pageTypeName) + { + if (string.IsNullOrEmpty(pageTypeName)) + { + return null; + } + + var assembly = typeof(GeneralPage).Assembly; + return assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}"); + } + + private void CtrlF_Invoked(KeyboardAccelerator sender, KeyboardAcceleratorInvokedEventArgs args) + { + SearchBox.Focus(FocusState.Programmatic); + args.Handled = true; // prevent further processing (e.g., unintended navigation) + } + + private void SearchBox_GotFocus(object sender, RoutedEventArgs e) + { + var box = sender as AutoSuggestBox; + var current = box?.Text?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(current)) + { + return; // nothing to restore + } + + // If current text matches last query and we have results, reconstruct the suggestion list. + if (string.Equals(current, _lastQueryText, StringComparison.Ordinal) && _lastSearchResults?.Count > 0) + { + try + { + var top = BuildSuggestionItems(current, _lastSearchResults); + box.ItemsSource = top; + box.IsSuggestionListOpen = top.Count > 0; + } + catch (Exception ex) + { + Logger.LogError($"Error restoring suggestion list {ex.Message}"); + } + } + } + + // Centralized suggestion projection logic used by TextChanged & GotFocus restore. + private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingEntry> results) + { + results ??= new(); + if (results.Count == 0) + { + var rl = ResourceLoaderInstance.ResourceLoader; + var noResultsPrefix = rl.GetString("Shell_Search_NoResults"); + if (string.IsNullOrEmpty(noResultsPrefix)) + { + noResultsPrefix = "No results for"; + } + + var headerText = $"{noResultsPrefix} '{query}'"; + return new List<SuggestionItem> + { + new() + { + Header = headerText, + IsNoResults = true, + }, + }; + } + + var list = results.Take(5).Select(e => + { + string subtitle = string.Empty; + if (e.Type != EntryType.SettingsPage) + { + subtitle = SearchIndexService.GetLocalizedPageName(e.PageTypeName); + if (string.IsNullOrEmpty(subtitle)) + { + subtitle = SearchIndexService.Index + .Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == e.PageTypeName) + .Select(x => x.Header) + .FirstOrDefault() ?? string.Empty; + } + } + + return new SuggestionItem + { + Header = e.Header, + Icon = e.Icon, + PageTypeName = e.PageTypeName, + ElementName = e.ElementName, + ParentElementName = e.ParentElementName, + Subtitle = subtitle, + IsShowAll = false, + }; + }).ToList(); + + if (results.Count > 5) + { + list.Add(new SuggestionItem { IsShowAll = true }); + } + + return list; + } + + private async void SearchBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args) + { + // If a suggestion is selected, navigate directly + if (args.ChosenSuggestion is SuggestionItem chosen) + { + NavigateFromSuggestion(chosen); + return; + } + + var queryText = (args.QueryText ?? _lastQueryText)?.Trim(); + if (string.IsNullOrWhiteSpace(queryText)) + { + NavigationService.Navigate<DashboardPage>(); + return; + } + + // Prefer cached results (from live search); if empty, perform a fresh search + var matched = _lastSearchResults?.Count > 0 && string.Equals(_lastQueryText, queryText, StringComparison.Ordinal) + ? _lastSearchResults + : await Task.Run(() => SearchIndexService.Search(queryText)); + + var searchParams = new SearchResultsNavigationParams(queryText, matched); + NavigationService.Navigate<SearchResultsPage>(searchParams); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _searchDebounceCts?.Cancel(); + _searchDebounceCts?.Dispose(); + _searchDebounceCts = null; + _disposed = true; + GC.SuppressFinalize(this); + } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml index 1189e48700..39429b14ab 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.ShortcutGuidePage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -13,24 +14,16 @@ <controls:SettingsPageControl x:Uid="ShortcutGuide" ModuleImageSource="ms-appx:///Assets/Settings/Modules/ShortcutGuide.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="ShortcutGuide_Enable" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ShortcutGuide.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="ShortcutGuideEnable" + x:Uid="ShortcutGuide_Enable" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ShortcutGuide.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="Shortcut" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ShortcutGuide_ActivationMethod"> + <tkcontrols:SettingsCard Name="ShortcutGuideActivationMethod" x:Uid="ShortcutGuide_ActivationMethod"> <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.UseLegacyPressWinKeyBehavior, Mode=TwoWay, Converter={StaticResource BoolToComboBoxIndexConverter}}"> <ComboBoxItem x:Uid="Radio_ShortcutGuide_ActivationMethod_CustomizedShortcut" /> <ComboBoxItem x:Uid="Radio_ShortcutGuide_ActivationMethod_LongPressWindowsKey" /> @@ -38,6 +31,7 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="ActivationShortcut" x:Uid="Activation_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}" Visibility="{x:Bind ViewModel.UseLegacyPressWinKeyBehavior, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}"> @@ -45,6 +39,7 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="ShortcutGuidePressTimeForGlobalWindowsShortcuts" x:Uid="ShortcutGuide_PressTimeForGlobalWindowsShortcuts" HeaderIcon="{ui:FontIcon Glyph=}" Visibility="{x:Bind ViewModel.UseLegacyPressWinKeyBehavior, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> @@ -58,6 +53,7 @@ </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="ShortcutGuidePressTimeForTaskbarIconShortcuts" x:Uid="ShortcutGuide_PressTimeForTaskbarIconShortcuts" HeaderIcon="{ui:FontIcon Glyph=}" Visibility="{x:Bind ViewModel.UseLegacyPressWinKeyBehavior, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"> @@ -80,7 +76,10 @@ </controls:SettingsGroup> <controls:SettingsGroup x:Uid="Appearance_Behavior" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ColorModeHeader" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="ColorModeHeader" + x:Uid="ColorModeHeader" + HeaderIcon="{ui:FontIcon Glyph=}"> <tkcontrols:SettingsCard.Description> <HyperlinkButton x:Uid="Windows_Color_Settings" Click="OpenColorsSettings_Click" /> </tkcontrols:SettingsCard.Description> @@ -91,7 +90,7 @@ </ComboBox> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ShortcutGuide_OverlayOpacity"> + <tkcontrols:SettingsCard Name="ShortcutGuideOverlayOpacity" x:Uid="ShortcutGuide_OverlayOpacity"> <Slider MinWidth="{StaticResource SettingActionControlMinWidth}" Maximum="100" @@ -102,6 +101,7 @@ <controls:SettingsGroup x:Uid="ExcludedApps" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> <tkcontrols:SettingsExpander + Name="ShortcutGuideDisabledApps" x:Uid="ShortcutGuide_DisabledApps" HeaderIcon="{ui:FontIcon Glyph=}" IsExpanded="True"> @@ -127,4 +127,4 @@ <controls:PageLink x:Uid="LearnMore_ShortcutGuide" Link="https://aka.ms/PowerToysOverview_ShortcutGuide" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> \ No newline at end of file +</local:NavigablePage> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml.cs index 750007595a..feb0e4837d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ShortcutGuidePage.xaml.cs @@ -9,7 +9,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class ShortcutGuidePage : Page, IRefreshablePage + public sealed partial class ShortcutGuidePage : NavigablePage, IRefreshablePage { private ShortcutGuideViewModel ViewModel { get; set; } @@ -17,9 +17,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views { InitializeComponent(); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new ShortcutGuideViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<ShortcutGuideSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; + + Loaded += (s, e) => ViewModel.OnPageLoaded(); } private void OpenColorsSettings_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml index cb13ecc8ce..97caf4a331 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml @@ -1,9 +1,10 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.WorkspacesPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" @@ -15,29 +16,23 @@ ModuleImageSource="ms-appx:///Assets/Settings/Modules/Workspaces.png"> <controls:SettingsPageControl.ModuleContent> <StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical"> - <tkcontrols:SettingsCard - x:Uid="Workspaces_EnableToggleControl_HeaderText" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Workspaces.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="WorkspacesEnableToggleControlHeaderText" + x:Uid="Workspaces_EnableToggleControl_HeaderText" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Workspaces.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="Workspaces_Activation_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - - <tkcontrols:SettingsCard x:Uid="Workspaces_ActivationShortcut" HeaderIcon="{ui:FontIcon Glyph=}"> + <tkcontrols:SettingsCard + Name="WorkspacesActivationShortcut" + x:Uid="Workspaces_ActivationShortcut" + HeaderIcon="{ui:FontIcon Glyph=}"> <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.Hotkey, Mode=TwoWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard + Name="WorkspacesLaunchEditorButtonControl" x:Uid="Workspaces_LaunchEditorButtonControl" ActionIcon="{ui:FontIcon Glyph=}" Command="{x:Bind ViewModel.LaunchEditorEventHandler}" @@ -51,4 +46,4 @@ <controls:PageLink x:Uid="LearnMore_Workspaces" Link="https://aka.ms/PowerToysOverview_Workspaces" /> </controls:SettingsPageControl.PrimaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml.cs index 52814104c7..3028b46ea1 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/WorkspacesPage.xaml.cs @@ -9,16 +9,17 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class WorkspacesPage : Page, IRefreshablePage + public sealed partial class WorkspacesPage : NavigablePage, IRefreshablePage { private WorkspacesViewModel ViewModel { get; set; } public WorkspacesPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new WorkspacesViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<WorkspacesSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage); DataContext = ViewModel; InitializeComponent(); + Loaded += (s, e) => ViewModel.OnPageLoaded(); } public void RefreshEnabledState() diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml index 5a483380c6..88496afab6 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml @@ -1,21 +1,22 @@ -<Page +<local:NavigablePage x:Class="Microsoft.PowerToys.Settings.UI.Views.ZoomItPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 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:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls" xmlns:ui="using:CommunityToolkit.WinUI" AutomationProperties.LandmarkType="Main" mc:Ignorable="d"> - - <Page.Resources> + <local:NavigablePage.Resources> <converters:ZoomItInitialZoomConverter x:Key="ZoomItInitialZoomConverter" /> <converters:ZoomItTypeSpeedSliderConverter x:Key="ZoomItTypeSpeedSliderConverter" /> - </Page.Resources> - + <converters:ZoomItOpacitySliderConverter x:Key="ZoomItOpacitySliderConverter" /> + <converters:HotkeySettingsToLocalizedStringConverter x:Key="HotkeySettingsToLocalizedStringConverter" /> + </local:NavigablePage.Resources> <controls:SettingsPageControl x:Uid="ZoomIt" IsTabStop="False" @@ -29,199 +30,295 @@ IsOpen="True" IsTabStop="True" Severity="Warning" /> - <tkcontrols:SettingsCard - x:Uid="ZoomIt_EnableToggleControl_HeaderText" - HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ZoomIt.png}" - IsEnabled="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <InfoBar - x:Uid="GPO_SettingIsManaged" - IsClosable="False" - IsOpen="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - IsTabStop="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}" - Severity="Informational"> - <InfoBar.IconSource> - <FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" /> - </InfoBar.IconSource> - </InfoBar> + <controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}"> + <tkcontrols:SettingsCard + Name="ZoomItEnableToggleControlHeaderText" + x:Uid="ZoomIt_EnableToggleControl_HeaderText" + HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/ZoomIt.png}"> + <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + </controls:GPOInfoControl> <controls:SettingsGroup x:Uid="ZoomIt_BehaviorGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Toggle_ShowTrayIcon"> + <tkcontrols:SettingsCard Name="ZoomItToggleShowTrayIcon" x:Uid="ZoomIt_Toggle_ShowTrayIcon"> <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.ShowTrayIcon, Mode=TwoWay}" /> </tkcontrols:SettingsCard> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="ZoomIt_ZoomGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Zoom_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.ZoomToggleKey, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Toggle_AnimateZoom"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.AnimateZoom, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Slider_InitialMagnification"> - <Slider - MinWidth="{StaticResource SettingActionControlMinWidth}" - Maximum="5" - Minimum="0" - ThumbToolTipValueConverter="{StaticResource ZoomItInitialZoomConverter}" - TickFrequency="1" - TickPlacement="Outside" - Value="{x:Bind ViewModel.ZoominSliderLevel, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander + Name="ZoomItZoomShortcut" + x:Uid="ZoomIt_Zoom_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.ZoomToggleKey, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard Name="ZoomItToggleAnimateZoom" ContentAlignment="Left"> + <CheckBox x:Uid="ZoomIt_Toggle_AnimateZoom" IsChecked="{x:Bind ViewModel.AnimateZoom, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItSmoothZoomedImage" ContentAlignment="Left"> + <CheckBox x:Uid="ZoomIt_Toggle_SmoothZoomedImage" IsChecked="{x:Bind ViewModel.SmoothImage, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItSliderInitialMagnification" x:Uid="ZoomIt_Slider_InitialMagnification"> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + Maximum="5" + Minimum="0" + ThumbToolTipValueConverter="{StaticResource ZoomItInitialZoomConverter}" + TickFrequency="1" + TickPlacement="Outside" + Value="{x:Bind ViewModel.ZoominSliderLevel, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard> + <tkcontrols:SettingsCard.Description> + <tkcontrols:MarkdownTextBlock x:Uid="ZoomIt_ZoomFAQ" Config="{StaticResource DescriptionTextMarkdownConfig}" /> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="ZoomIt_LiveZoomGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ZoomIt_LiveZoom_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.LiveZoomToggleKey, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander + Name="ZoomItLiveZoomShortcut" + x:Uid="ZoomIt_LiveZoom_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.LiveZoomToggleKey, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard> + <tkcontrols:SettingsCard.Description> + <tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.LiveZoomToggleKeyDraw, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_LiveZoom_Shortcut_Draw}" /> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="ZoomIt_DrawGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Draw_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.DrawToggleKey, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander + Name="ZoomItDrawShortcut" + x:Uid="ZoomIt_Draw_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.DrawToggleKey, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard> + <tkcontrols:SettingsCard.Description> + <tkcontrols:MarkdownTextBlock x:Uid="ZoomIt_DrawFAQ" Config="{StaticResource DescriptionTextMarkdownConfig}" /> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="ZoomIt_TypeGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Type_TextFont"> - <tkcontrols:SettingsCard.Description> + <tkcontrols:SettingsExpander + Name="ZoomItTypeTextFont" + x:Uid="ZoomIt_Type_TextFont" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <tkcontrols:SettingsExpander.Description> <TextBlock + x:Uid="ZoomIt_Type_DemoSample" FontFamily="{x:Bind ViewModel.DemoSampleFontFamily, Mode=OneWay}" FontSize="{x:Bind ViewModel.DemoSampleFontSize, Mode=OneWay}" FontStyle="{x:Bind ViewModel.DemoSampleFontStyle, Mode=OneWay}" FontWeight="{x:Bind ViewModel.DemoSampleFontWeight, Mode=OneWay}" - Text="Sample" TextDecorations="{x:Bind ViewModel.DemoSampleTextDecoration, Mode=OneWay}" /> - </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsExpander.Description> <Button x:Uid="ZoomIt_Type_Font_Button" Command="{x:Bind ViewModel.SelectTypeFontCommand, Mode=OneWay}" /> - </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard> + <tkcontrols:SettingsCard.Description> + <tkcontrols:MarkdownTextBlock x:Uid="ZoomIt_TypeFAQ" Config="{StaticResource DescriptionTextMarkdownConfig}" /> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> </controls:SettingsGroup> <controls:SettingsGroup x:Uid="ZoomIt_DemoTypeGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ZoomIt_DemoType_File" Description="{x:Bind ViewModel.DemoTypeFile, Mode=OneWay}"> - <Button x:Uid="ZoomIt_DemoType_File_BrowseButton" Command="{x:Bind ViewModel.SelectDemoTypeFileCommand, Mode=OneWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_DemoType_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.DemoTypeToggleKey, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_DemoType_Toggle_UserDrivenMode"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.DemoTypeUserDrivenMode, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_DemoType_SpeedSlider" Description="{x:Bind ViewModel.DemoTypeSpeedSlider, Mode=OneWay}"> - <Slider - MinWidth="{StaticResource SettingActionControlMinWidth}" - Maximum="{x:Bind ViewModel.DemoTypeMinTypingSpeed, Mode=OneWay}" - Minimum="{x:Bind ViewModel.DemoTypeMaxTypingSpeed, Mode=OneWay}" - ThumbToolTipValueConverter="{StaticResource ZoomItTypeSpeedSliderConverter}" - Value="{x:Bind ViewModel.DemoTypeSpeedSlider, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander + Name="ZoomItDemoTypeShortcut" + x:Uid="ZoomIt_DemoType_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.DemoTypeToggleKey, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard + Name="ZoomItDemoTypeFile" + x:Uid="ZoomIt_DemoType_File" + Description="{x:Bind ViewModel.DemoTypeFile, Mode=OneWay}"> + <Button x:Uid="ZoomIt_DemoType_File_BrowseButton" Command="{x:Bind ViewModel.SelectDemoTypeFileCommand, Mode=OneWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItDemoTypeToggleUserDrivenMode" ContentAlignment="Left"> + <CheckBox x:Uid="ZoomIt_DemoType_Toggle_UserDrivenMode" IsChecked="{x:Bind ViewModel.DemoTypeUserDrivenMode, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItDemoTypeSpeedSlider" x:Uid="ZoomIt_DemoType_SpeedSlider"> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + Maximum="{x:Bind ViewModel.DemoTypeMinTypingSpeed, Mode=OneWay}" + Minimum="{x:Bind ViewModel.DemoTypeMaxTypingSpeed, Mode=OneWay}" + ThumbToolTipValueConverter="{StaticResource ZoomItTypeSpeedSliderConverter}" + Value="{x:Bind ViewModel.DemoTypeSpeedSlider, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItDemoTypeShortcutReset"> + <tkcontrols:SettingsCard.Description> + <tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.DemoTypeToggleKeyReset, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_DemoTypeFAQ}" /> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + </controls:SettingsGroup> <controls:SettingsGroup x:Uid="ZoomIt_BreakGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Break_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.BreakTimerKey, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Break_Timeout"> - <NumberBox - MinWidth="{StaticResource SettingActionControlMinWidth}" - LargeChange="10" - Maximum="99" - Minimum="1" - SmallChange="1" - SpinButtonPlacementMode="Compact" - Value="{x:Bind ViewModel.BreakTimeout, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Break_ShowExpiredTime"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.BreakShowExpiredTime, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - - <tkcontrols:SettingsExpander x:Uid="ZoomIt_Break_PlaySoundsFile" IsExpanded="True"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.BreakPlaySoundFile, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander + Name="ZoomItBreakShortcut" + x:Uid="ZoomIt_Break_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.BreakTimerKey, Mode=TwoWay}" /> <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard Name="ZoomItBreakTimeout" x:Uid="ZoomIt_Break_Timeout"> + <NumberBox + MinWidth="{StaticResource SettingActionControlMinWidth}" + LargeChange="10" + Maximum="99" + Minimum="1" + SmallChange="1" + SpinButtonPlacementMode="Compact" + Value="{x:Bind ViewModel.BreakTimeout, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItBreakShowExpiredTime" ContentAlignment="Left"> + <CheckBox x:Uid="ZoomIt_Break_ShowExpiredTime" IsChecked="{x:Bind ViewModel.BreakShowExpiredTime, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItBreakPlaySoundsFile" ContentAlignment="Left"> + <CheckBox x:Uid="ZoomIt_Break_PlaySoundsFile" IsChecked="{x:Bind ViewModel.BreakPlaySoundFile, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="ZoomItBreakSoundFile" x:Uid="ZoomIt_Break_SoundFile" Description="{x:Bind ViewModel.BreakSoundFile, Mode=OneWay}" - IsEnabled="{x:Bind ViewModel.BreakPlaySoundFile, Mode=OneWay}"> + Visibility="{x:Bind ViewModel.BreakPlaySoundFile, Mode=OneWay}"> <Button x:Uid="ZoomIt_Break_SoundFile_BrowseButton" Command="{x:Bind ViewModel.SelectBreakSoundFileCommand, Mode=OneWay}" /> </tkcontrols:SettingsCard> - </tkcontrols:SettingsExpander.Items> - </tkcontrols:SettingsExpander> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Break_TimerOpacity"> - <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.BreakTimerOpacityIndex, Mode=TwoWay}"> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_10Percent" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_20Percent" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_30Percent" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_40Percent" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_50Percent" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_60Percent" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_70Percent" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_80Percent" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_90Percent" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerOpacity_100Percent" /> - </ComboBox> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Break_TimerPosition"> - <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.BreakTimerPosition, Mode=TwoWay}"> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopLeftCorner" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopCenter" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopRightCorner" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Left" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Center" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Right" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomLeftCorner" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomCenter" /> - <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomRightCorner" /> - </ComboBox> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsExpander x:Uid="ZoomIt_Break_ShowBackgroundBitmap" IsExpanded="True"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=TwoWay}" /> - <tkcontrols:SettingsExpander.Items> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Break_ShowDesktopOrImageFile" IsEnabled="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}"> - <RadioButtons SelectedIndex="{x:Bind ViewModel.BreakShowDesktopOrImageFileIndex, Mode=TwoWay}"> - <RadioButton x:Uid="ZoomIt_Break_ShowFadedDesktop" /> - <RadioButton x:Uid="ZoomIt_Break_ShowImageFile" /> - </RadioButtons> + <tkcontrols:SettingsCard Name="ZoomItBreakTimerOpacity" x:Uid="ZoomIt_Break_TimerOpacity"> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + Maximum="100" + Minimum="1" + ThumbToolTipValueConverter="{StaticResource ZoomItOpacitySliderConverter}" + Value="{x:Bind ViewModel.BreakTimerOpacity, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItBreakTimerPosition" x:Uid="ZoomIt_Break_TimerPosition"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.BreakTimerPosition, Mode=TwoWay}"> + <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopLeftCorner" /> + <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopCenter" /> + <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_TopRightCorner" /> + <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Left" /> + <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Center" /> + <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_Right" /> + <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomLeftCorner" /> + <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomCenter" /> + <ComboBoxItem x:Uid="ZoomIt_Break_TimerPosition_BottomRightCorner" /> + </ComboBox> + </tkcontrols:SettingsCard> + + + <tkcontrols:SettingsCard Name="ZoomItBreakShowBackgroundBitmap" x:Uid="ZoomIt_Break_ShowBackgroundBitmap"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.BreakBackgroundSelectionIndex, Mode=TwoWay}"> + <ComboBoxItem x:Uid="ZoomIt_Break_BackgroundImage_None" /> + <ComboBoxItem x:Uid="ZoomIt_Break_ShowFadedDesktop" /> + <ComboBoxItem x:Uid="ZoomIt_Break_ShowImageFile" /> + </ComboBox> </tkcontrols:SettingsCard> <tkcontrols:SettingsCard + Name="ZoomItBreakBackgroundFile" x:Uid="ZoomIt_Break_BackgroundFile" Description="{x:Bind ViewModel.BreakBackgroundFile, Mode=OneWay}" - IsEnabled="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}"> + Visibility="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}"> <Button x:Uid="ZoomIt_Break_BackgroundFile_BrowseButton" Command="{x:Bind ViewModel.SelectBreakBackgroundFileCommand, Mode=OneWay}" /> </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Break_BackgroundStretch" IsEnabled="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.BreakBackgroundStretch, Mode=TwoWay}" /> + <tkcontrols:SettingsCard + Name="ZoomItBreakBackgroundStretch" + ContentAlignment="Left" + Visibility="{x:Bind ViewModel.BreakShowBackgroundFile, Mode=OneWay}"> + <CheckBox x:Uid="ZoomIt_Break_BackgroundStretch" IsChecked="{x:Bind ViewModel.BreakBackgroundStretch, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard> + <tkcontrols:SettingsCard.Description> + <tkcontrols:MarkdownTextBlock x:Uid="ZoomIt_BreakFAQ" Config="{StaticResource DescriptionTextMarkdownConfig}" /> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> + + </controls:SettingsGroup> + + + <controls:SettingsGroup x:Uid="ZoomIt_RecordGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> + <tkcontrols:SettingsExpander + Name="ZoomItRecordShortcut" + x:Uid="ZoomIt_Record_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.RecordToggleKey, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard Name="ZoomItRecordScaling" x:Uid="ZoomIt_Record_Scaling"> + <Slider + MinWidth="{StaticResource SettingActionControlMinWidth}" + Maximum="1" + Minimum="0.1" + StepFrequency="0.1" + TickFrequency="0.1" + TickPlacement="Outside" + Value="{x:Bind ViewModel.RecordScaling, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItRecordFormat" x:Uid="ZoomIt_Record_Format"> + <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.RecordFormatIndex, Mode=TwoWay}"> + <ComboBoxItem>GIF</ComboBoxItem> + <ComboBoxItem>MP4</ComboBoxItem> + </ComboBox> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItRecordCaptureSystemAudio" ContentAlignment="Left"> + <CheckBox x:Uid="ZoomIt_Record_CaptureSystemAudio" IsChecked="{x:Bind ViewModel.RecordCaptureSystemAudio, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard Name="ZoomItRecordCaptureAudio" ContentAlignment="Left"> + <CheckBox x:Uid="ZoomIt_Record_CaptureAudio" IsChecked="{x:Bind ViewModel.RecordCaptureAudio, Mode=TwoWay}" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard + Name="ZoomItRecordMicrophone" + x:Uid="ZoomIt_Record_Microphone" + Visibility="{x:Bind ViewModel.RecordCaptureAudio, Mode=OneWay}"> + <ComboBox + MinWidth="{StaticResource SettingActionControlMinWidth}" + DisplayMemberPath="Item2" + ItemsSource="{x:Bind ViewModel.MicrophoneList}" + SelectedValue="{x:Bind ViewModel.RecordMicrophoneDeviceId, Mode=TwoWay}" + SelectedValuePath="Item1" /> + </tkcontrols:SettingsCard> + <tkcontrols:SettingsCard> + <tkcontrols:SettingsCard.Description> + <StackPanel Orientation="Vertical" Spacing="4"> + <tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.RecordToggleKey, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Record_Shortcut_FullScreen}" /> + <tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.RecordToggleKeyCrop, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Record_Shortcut_Crop}" /> + <tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.RecordToggleKeyWindow, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Record_Shortcut_Window}" /> + </StackPanel> + </tkcontrols:SettingsCard.Description> </tkcontrols:SettingsCard> </tkcontrols:SettingsExpander.Items> </tkcontrols:SettingsExpander> </controls:SettingsGroup> - <controls:SettingsGroup x:Uid="ZoomIt_RecordGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Record_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.RecordToggleKey, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Record_Scaling"> - <ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.RecordScalingIndex, Mode=TwoWay}"> - <ComboBoxItem>0.1</ComboBoxItem> - <ComboBoxItem>0.2</ComboBoxItem> - <ComboBoxItem>0.3</ComboBoxItem> - <ComboBoxItem>0.4</ComboBoxItem> - <ComboBoxItem>0.5</ComboBoxItem> - <ComboBoxItem>0.6</ComboBoxItem> - <ComboBoxItem>0.7</ComboBoxItem> - <ComboBoxItem>0.8</ComboBoxItem> - <ComboBoxItem>0.9</ComboBoxItem> - <ComboBoxItem>1.0</ComboBoxItem> - </ComboBox> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Record_CaptureAudio"> - <ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.RecordCaptureAudio, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Record_Microphone"> - <ComboBox - MinWidth="{StaticResource SettingActionControlMinWidth}" - DisplayMemberPath="Item2" - ItemsSource="{x:Bind ViewModel.MicrophoneList}" - SelectedValue="{x:Bind Path=ViewModel.RecordMicrophoneDeviceId, Mode=TwoWay}" - SelectedValuePath="Item1" /> - </tkcontrols:SettingsCard> - </controls:SettingsGroup> <controls:SettingsGroup x:Uid="ZoomIt_SnipGroup" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}"> - <tkcontrols:SettingsCard x:Uid="ZoomIt_Snip_Shortcut" HeaderIcon="{ui:FontIcon Glyph=}"> - <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.SnipToggleKey, Mode=TwoWay}" /> - </tkcontrols:SettingsCard> + <tkcontrols:SettingsExpander + Name="ZoomItSnipShortcut" + x:Uid="ZoomIt_Snip_Shortcut" + HeaderIcon="{ui:FontIcon Glyph=}" + IsExpanded="True"> + <controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind ViewModel.SnipToggleKey, Mode=TwoWay}" /> + <tkcontrols:SettingsExpander.Items> + <tkcontrols:SettingsCard Name="ZoomItSnipShortcutSave"> + <tkcontrols:SettingsCard.Description> + <tkcontrols:MarkdownTextBlock Config="{StaticResource DescriptionTextMarkdownConfig}" Text="{x:Bind ViewModel.SnipToggleKeySave, Mode=OneWay, Converter={StaticResource HotkeySettingsToLocalizedStringConverter}, ConverterParameter=ZoomIt_Snip_Shortcut_Save}" /> + </tkcontrols:SettingsCard.Description> + </tkcontrols:SettingsCard> + </tkcontrols:SettingsExpander.Items> + </tkcontrols:SettingsExpander> </controls:SettingsGroup> </StackPanel> </controls:SettingsPageControl.ModuleContent> @@ -229,7 +326,7 @@ <controls:PageLink x:Uid="LearnMore_ZoomIt" Link="https://aka.ms/PowerToysOverview_ZoomIt" /> </controls:SettingsPageControl.PrimaryLinks> <controls:SettingsPageControl.SecondaryLinks> - <controls:PageLink Link="https://learn.microsoft.com/en-us/sysinternals/downloads/zoomit" Text="Sysinternals Zoomit by Mark Russinovich, Alex Mihaiuc, John Stephens" /> + <controls:PageLink Link="https://learn.microsoft.com/sysinternals/downloads/zoomit" Text="Sysinternals ZoomIt by Mark Russinovich, Alex Mihaiuc, John Stephens" /> </controls:SettingsPageControl.SecondaryLinks> </controls:SettingsPageControl> -</Page> +</local:NavigablePage> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml.cs index b0e5483533..043ca7df8c 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml.cs @@ -12,7 +12,7 @@ using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Views { - public sealed partial class ZoomItPage : Page, IRefreshablePage + public sealed partial class ZoomItPage : NavigablePage, IRefreshablePage { private ZoomItViewModel ViewModel { get; set; } @@ -104,7 +104,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views public ZoomItPage() { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; ViewModel = new ZoomItViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, PickFileDialog, PickFontDialog); DataContext = ViewModel; InitializeComponent(); diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index a95cdb86cd..aa500a6b77 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -151,11 +151,19 @@ <value>Customize the shortcut to bring up the command bar</value> <comment>"Screen Ruler" is the name of the utility</comment> </data> + <data name="Shell_SearchBox.PlaceholderText" xml:space="preserve"> + <value>Search for settings</value> + <comment>Placeholder in settings search box</comment> + </data> + <data name="Shell_Search_NoResults" xml:space="preserve"> + <value>No results for</value> + <comment>Prefix used in the no-results row; the query is appended in code, e.g., "No results for 'abc'"</comment> + </data> <data name="MeasureTool_DefaultMeasureStyle.Header" xml:space="preserve"> - <value>Default measure style</value> + <value>Default mode</value> </data> <data name="MeasureTool_DefaultMeasureStyle.Description" xml:space="preserve"> - <value>The utility will start having the selected style activated</value> + <value>The measuring mode that is used when activated</value> </data> <data name="MeasureTool_DefaultMeasureStyle_None.Content" xml:space="preserve"> <value>None</value> @@ -212,7 +220,7 @@ <value>Adds feet to the end of cross lines</value> </data> <data name="MeasureTool_EnableMeasureTool.Header" xml:space="preserve"> - <value>Enable Screen Ruler</value> + <value>Screen Ruler</value> <comment>"Screen Ruler" is the name of the utility</comment> </data> <data name="MouseWithoutBorders_ActivationSettings.Header" xml:space="preserve"> @@ -234,7 +242,7 @@ <value>Security key</value> </data> <data name="MouseWithoutBorders_SecurityKey.Description" xml:space="preserve"> - <value>The key must be auto generated in one machine by clicking on New Key, then typed in other machines</value> + <value>To set up, generate the key on one machine, and enter it manually on other machines</value> </data> <data name="MouseWithoutBorders_NewKey.Content" xml:space="preserve"> <value>New key</value> @@ -246,7 +254,7 @@ <value>Refresh connections</value> </data> <data name="MouseWithoutBorders_ReconnectTooltip.Text" xml:space="preserve"> - <value>Reestablishes connections with other devices if you are experiencing issues.</value> + <value>Reestablishes connections with other devices if issues occur</value> </data> <data name="MouseWithoutBorders_ThisMachineNameLabel.Header" xml:space="preserve"> <value>Host name of this device</value> @@ -258,7 +266,7 @@ <value>Uninstall service</value> </data> <data name="MouseWithoutBorders_UninstallService.Description" xml:space="preserve"> - <value>Removes the service from the computer. Needs to run as administrator.</value> + <value>Removes the service from the computer; administrator access is required</value> </data> <data name="MouseWithoutBorders_Settings.Header" xml:space="preserve"> <value>Behavior</value> @@ -271,10 +279,10 @@ <comment>"Mouse Without Borders" is a product name</comment> </data> <data name="MouseWithoutBorders_AddFirewallRuleButtonControl.Description" xml:space="preserve"> - <value>Adding a firewall rule might help solve connection issues.</value> + <value>Adding a firewall rule might help solve connection issues</value> </data> <data name="MouseWithoutBorders_RunAsAdminText.Title" xml:space="preserve"> - <value>You need to run as administrator to modify this setting.</value> + <value>This setting can only be changed when running as administrator</value> </data> <data name="MouseWithoutBorders_ServiceUserUninstallWarning.Title" xml:space="preserve"> <value>If PowerToys is installed as a user, uninstalling/upgrading may require the Mouse Without Borders service to be removed manually later.</value> @@ -283,7 +291,7 @@ <value>Service</value> </data> <data name="MouseWithoutBorders_Toggle_Enable.Header" xml:space="preserve"> - <value>Enable Mouse Without Borders</value> + <value>Mouse Without Borders</value> </data> <data name="MouseWithoutBorders.SecondaryLinksHeader" xml:space="preserve"> <value>Attribution</value> @@ -301,25 +309,22 @@ <value>Use Service</value> </data> <data name="MouseWithoutBorders_UseService.Description" xml:space="preserve"> - <value>Runs in service mode, that allows MWB to control remote machines when they're locked. Also allows control of system and administrator applications.</value> + <value>Runs in service mode, allowing MWB to control locked remote machines and system or administrator applications</value> </data> <data name="MouseWithoutBorders_MatrixOneRow.Header" xml:space="preserve"> <value>Devices in a single row</value> </data> <data name="MouseWithoutBorders_MatrixOneRow.Description" xml:space="preserve"> - <value>Sets whether the devices are aligned on a single row. A two by two matrix is considered otherwise.</value> + <value>Sets whether the devices are aligned on a single row. A two by two matrix is considered otherwise</value> </data> - <data name="MouseWithoutBorders_WrapMouse.Header" xml:space="preserve"> - <value>Wrap mouse</value> - </data> - <data name="MouseWithoutBorders_WrapMouse.Description" xml:space="preserve"> - <value>Move control back to the first machine when mouse moves past the last one.</value> + <data name="MouseWithoutBorders_WrapMouse.Content" xml:space="preserve"> + <value>Move the mouse back to the first machine when it passes the last one</value> </data> <data name="MouseWithoutBorders_ShareClipboard.Header" xml:space="preserve"> <value>Share clipboard</value> </data> - <data name="MouseWithoutBorders_TransferFile.Header" xml:space="preserve"> - <value>Transfer file</value> + <data name="MouseWithoutBorders_TransferFile.Content" xml:space="preserve"> + <value>Transfer copied files to the remote machine’s clipboard (up to 100 MB)</value> </data> <data name="MouseWithoutBorders_HideMouseAtScreenEdge.Header" xml:space="preserve"> <value>Hide mouse at the screen edge</value> @@ -327,6 +332,9 @@ <data name="MouseWithoutBorders_DrawMouseCursor.Header" xml:space="preserve"> <value>Draw mouse cursor</value> </data> + <data name="MouseWithoutBorders_MouseBehavior.Header" xml:space="preserve"> + <value>Mouse behavior</value> + </data> <data name="MouseWithoutBorders_ValidateRemoteMachineIP.Header" xml:space="preserve"> <value>Validate remote machine IP</value> </data> @@ -339,8 +347,8 @@ <data name="MouseWithoutBorders_MoveMouseRelatively.Header" xml:space="preserve"> <value>Move mouse relatively</value> </data> - <data name="MouseWithoutBorders_BlockMouseAtScreenCorners.Header" xml:space="preserve"> - <value>Block mouse at screen corners</value> + <data name="MouseWithoutBorders_BlockMouseAtScreenCorners.Content" xml:space="preserve"> + <value>Block mouse at screen corners to avoid accident machine-switch at screen corners</value> </data> <data name="MouseWithoutBorders_ShowClipboardAndNetworkStatusMessages.Header" xml:space="preserve"> <value>Show clipboard and network status messages</value> @@ -349,22 +357,19 @@ <value>Show the original Mouse Without Borders UI</value> </data> <data name="MouseWithoutBorders_ShowOriginalUI.Description" xml:space="preserve"> - <value>This is accessible from the system tray and requires a restart.</value> + <value>This is accessible from the system tray and requires a restart</value> </data> <data name="MouseWithoutBorders_ShareClipboard.Description" xml:space="preserve"> - <value>If share clipboard stops working, Ctrl+Alt+Del then Esc may solve the problem.</value> - </data> - <data name="MouseWithoutBorders_TransferFile.Description" xml:space="preserve"> - <value>If a file (<100MB) is copied, it will be transferred to the remote machine clipboard.</value> + <value>If share clipboard stops working, Ctrl+Alt+Del then Esc may solve the problem</value> </data> <data name="MouseWithoutBorders_HideMouseAtScreenEdge.Description" xml:space="preserve"> - <value>Hide the mouse cursor at the top edge of the screen when switching to other machine. This option also steals the focus from any full-screen app to ensure the keyboard input is redirected.</value> + <value>Hide the cursor at the top edge when switching to another machine, and take focus from full-screen apps to ensure keyboard input is redirected</value> </data> <data name="MouseWithoutBorders_DrawMouseCursor.Description" xml:space="preserve"> - <value>Mouse cursor may not be visible in Windows 10 and later versions of Windows when there is no physical mouse attached.</value> + <value>The mouse cursor may not appear on Windows 10 and later if no physical mouse is connected</value> </data> <data name="MouseWithoutBorders_ValidateRemoteMachineIP.Description" xml:space="preserve"> - <value>Reverse DNS lookup to validate machine IP Address.</value> + <value>Reverse DNS lookup to validate machine IP address</value> </data> <data name="MouseWithoutBorders_SameSubnetOnly.Description" xml:space="preserve"> <value>Only connect to machines in the same intranet NNN.NNN.*.* (only works when both machines have IPv4 enabled)</value> @@ -377,16 +382,13 @@ <value>IP address mapping</value> </data> <data name="MouseWithoutBorders_IPAddressMapping.Description" xml:space="preserve"> - <value>Resolve machine's IP address using manually entered mappings below.</value> + <value>Resolve machine's IP address using manually entered mappings below</value> </data> <data name="MouseWithoutBorders_BlockScreenSaverOnOtherMachines.Description" xml:space="preserve"> - <value>Prevent screen saver from starting on other machines when user is actively working on this machine.</value> + <value>Prevent screen saver from starting on other machines when user is actively working on this machine</value> </data> <data name="MouseWithoutBorders_MoveMouseRelatively.Description" xml:space="preserve"> - <value>Use this option when remote machine's monitor settings are different, or remote machine has multiple monitors.</value> - </data> - <data name="MouseWithoutBorders_BlockMouseAtScreenCorners.Description" xml:space="preserve"> - <value>To avoid accident machine-switch at screen corners.</value> + <value>Use this option when remote machine's monitor settings are different, or remote machine has multiple monitors</value> </data> <data name="MouseWithoutBorders_ShowClipboardAndNetworkStatusMessages.Description" xml:space="preserve"> <value>Show clipboard activities and network status in system tray notifications</value> @@ -396,13 +398,16 @@ <comment>keyboard is the hardware peripheral</comment> </data> <data name="MouseWithoutBorders_AdvancedSettings_Group.Header" xml:space="preserve"> - <value>Advanced Settings</value> + <value>Advanced settings</value> + </data> + <data name="MouseWithoutBorders_EasyMouseSettings_Group.Header" xml:space="preserve"> + <value>Easy Mouse</value> </data> <data name="MouseWithoutBorders_EasyMouseOption.Header" xml:space="preserve"> - <value>Easy Mouse: move between machines by moving the mouse pointer to the screen edges.</value> + <value>Easy Mouse: move between machines by moving the mouse pointer to the screen edges</value> </data> <data name="MouseWithoutBorders_EasyMouseOption.Description" xml:space="preserve"> - <value>Can also be set to move only when pressing Shift or Ctrl.</value> + <value>Can also be set to move only when pressing Shift or Ctrl</value> <comment>Shift and Ctrl are the keyboard keys</comment> </data> <data name="MouseWithoutBorders_EasyMouseOption_Disabled.Content" xml:space="preserve"> @@ -419,28 +424,52 @@ <value>Shift</value> <comment>This is the Shift keyboard key</comment> </data> + <data name="MouseWithoutBorders_DisableEasyMouseWhenForegroundWindowIsFullscreen.Header" xml:space="preserve"> + <value>Disable Easy Mouse when an application is running in full screen</value> + </data> + <data name="MouseWithoutBorders_DisableEasyMouseWhenForegroundWindowIsFullscreen.Description" xml:space="preserve"> + <value>Prevent Easy Mouse from moving to another machine when an application is in full-screen mode</value> + </data> + <data name="MouseWithoutBorders_CanOnlyStopEasyMouseIfMovingFromHostMachine.Title" xml:space="preserve"> + <value>Disabling Easy Mouse in full-screen mode only affects the host PC and does not prevent the mouse from moving away from remote machines</value> + </data> + <data name="MouseWithoutBorders_DisableEasyMouseWhenForegroundWindowIsFullscreen_TextBoxControl.PlaceholderText" xml:space="preserve"> + <value>msedge.exe +firefox.exe +opera.exe</value> + <comment>Allow easy mouse when chrome is in fullscreen mode.</comment> + </data> + <data name="MouseWithoutBorders_DisableEasyMouseWhenForegroundWindowIsFullscreen_Expander.Header" xml:space="preserve"> + <value>Ignored fullscreen applications</value> + </data> + <data name="MouseWithoutBorders_DisableEasyMouseWhenForegroundWindowIsFullscreen_Expander.Description" xml:space="preserve"> + <value>Allow Easy Mouse to move between machines even if one of these applications is running in full screen, separate each executable with a new line.</value> + </data> <data name="MouseWithoutBorders_LockMachinesShortcut.Header" xml:space="preserve"> - <value>Shortcut to lock all machines.</value> + <value>Lock all machines</value> </data> <data name="MouseWithoutBorders_LockMachinesShortcut.Description" xml:space="preserve"> - <value>Hit this hotkey twice to lock all machines. Note: Only the machines which have the same shortcut configured will be locked.</value> + <value>Press this hotkey twice to lock all machines, noting that only machines with the same shortcut configured will be locked</value> + </data> + <data name="MouseWithoutBorders_Shortcuts.Header" xml:space="preserve"> + <value>Shortcuts</value> + </data> + <data name="MouseWithoutBorders_Shortcuts.Description" xml:space="preserve"> + <value>Configure shortcuts to quickly control features</value> </data> <data name="MouseWithoutBorders_ToggleEasyMouseShortcut.Header" xml:space="preserve"> - <value>Shortcut to toggle Easy Mouse.</value> + <value>Turn Easy Mouse on or off</value> <comment>Ctrl and Alt are the keyboard keys</comment> </data> <data name="MouseWithoutBorders_ToggleEasyMouseShortcut.Description" xml:space="preserve"> - <value>Only works if EasyMouse is set to Enabled or Disabled.</value> - </data> - <data name="MouseWithoutBorders_ToggleEasyMouseShortcut_Disabled.Content" xml:space="preserve"> - <value>Disabled</value> + <value>Only works if EasyMouse is set to Enabled or Disabled</value> </data> <data name="MouseWithoutBorders_SwitchBetweenMachineShortcut.Header" xml:space="preserve"> - <value>Shortcut to switch between machines. Ctrl+Alt+:</value> + <value>Switching between machines</value> <comment>Ctrl and Alt are the keyboard keys</comment> </data> <data name="MouseWithoutBorders_SwitchBetweenMachineShortcut.Description" xml:space="preserve"> - <value>Click on Ctrl+Alt+ the chosen option to switch between machines.</value> + <value> Ctrl+Alt+:</value> <comment>Ctrl and Alt are the keyboard keys</comment> </data> <data name="MouseWithoutBorders_SwitchBetweenMachineShortcut_F1.Content" xml:space="preserve"> @@ -451,33 +480,11 @@ <value>1, 2, 3, 4</value> <comment>Don't localize. These are keyboard keys</comment> </data> - <data name="MouseWithoutBorders_SwitchBetweenMachineShortcut_Disabled.Content" xml:space="preserve"> - <value>Disabled</value> - </data> - <data name="MouseWithoutBorders_LockMachinesShortcut_Disabled.Content" xml:space="preserve"> - <value>Disabled</value> - </data> <data name="MouseWithoutBorders_ReconnectShortcut.Header" xml:space="preserve"> - <value>Shortcut to try reconnecting</value> - </data> - <data name="MouseWithoutBorders_ReconnectShortcut.Description" xml:space="preserve"> - <value>Just in case the connection is lost for any reason.</value> - </data> - <data name="MouseWithoutBorders_ReconnectShortcut_Disabled.Content" xml:space="preserve"> - <value>Disabled</value> + <value>Try reconnecting when connection is lost</value> </data> <data name="MouseWithoutBorders_Switch2AllPcShortcut.Header" xml:space="preserve"> - <value>Shortcut to switch to multiple machine mode.</value> - </data> - <data name="MouseWithoutBorders_Switch2AllPcShortcut.Description" xml:space="preserve"> - <value>Allows controlling all computers at once.</value> - </data> - <data name="MouseWithoutBorders_Switch2AllPcShortcut_Disabled.Content" xml:space="preserve"> - <value>Disabled</value> - </data> - <data name="MouseWithoutBorders_Switch2AllPcShortcut_Ctrl3.Content" xml:space="preserve"> - <value>Ctrl three times</value> - <comment>This is the Ctrl keyboard key</comment> + <value>Control all computes are once</value> </data> <data name="Shell_General.Content" xml:space="preserve"> <value>General</value> @@ -485,7 +492,7 @@ </data> <data name="Shell_Awake.Content" xml:space="preserve"> <value>Awake</value> - <comment>Product name: Navigation view item name for Awake</comment> + <comment>{Locked}</comment> </data> <data name="Shell_PowerLauncher.Content" xml:space="preserve"> <value>PowerToys Run</value> @@ -500,7 +507,7 @@ <comment>Product name: Navigation view item name for Shortcut Guide</comment> </data> <data name="Shell_PowerPreview.Content" xml:space="preserve"> - <value>File Explorer add-ons</value> + <value>File Explorer Add-ons</value> <comment>Product name: Navigation view item name for File Explorer. Please use File Explorer as in the context of File Explorer in Windows</comment> </data> <data name="Shell_FancyZones.Content" xml:space="preserve"> @@ -515,6 +522,10 @@ <value>Color Picker</value> <comment>Product name: Navigation view item name for Color Picker</comment> </data> + <data name="Shell_PowerDisplay.Content" xml:space="preserve"> + <value>Power Display</value> + <comment>Product name: Navigation view item name for Power Display</comment> + </data> <data name="Shell_KeyboardManager.Content" xml:space="preserve"> <value>Keyboard Manager</value> <comment>Product name: Navigation view item name for Keyboard Manager</comment> @@ -524,7 +535,7 @@ <comment>Product name: Navigation view item name for Mouse Without Borders</comment> </data> <data name="Shell_MouseUtilities.Content" xml:space="preserve"> - <value>Mouse utilities</value> + <value>Mouse Utilities</value> <comment>Product name: Navigation view item name for Mouse utilities</comment> </data> <data name="Shell_NavigationMenu_Announce_Collapse" xml:space="preserve"> @@ -535,22 +546,14 @@ <value>Navigation opened</value> <comment>Accessibility announcement when the navigation pane opens</comment> </data> - <data name="KeyboardManager_ConfigHeader.Text" xml:space="preserve"> - <value>Current configuration</value> - <comment>Keyboard Manager current configuration header</comment> - </data> <data name="KeyboardManager.ModuleDescription" xml:space="preserve"> <value>Reconfigure your keyboard by remapping keys and shortcuts</value> <comment>Keyboard Manager page description</comment> </data> <data name="KeyboardManager_EnableToggle.Header" xml:space="preserve"> - <value>Enable Keyboard Manager</value> + <value>Keyboard Manager</value> <comment>Keyboard Manager enable toggle header. Do not loc the Product name. Do you want this feature on / off</comment> </data> - <data name="KeyboardManager_ProfileDescription.Text" xml:space="preserve"> - <value>Select the profile to display the active key remap and shortcuts</value> - <comment>Keyboard Manager configuration dropdown description</comment> - </data> <data name="KeyboardManager_RemapKeyboardButton.Header" xml:space="preserve"> <value>Remap a key</value> <comment>Keyboard Manager remap keyboard button content</comment> @@ -571,65 +574,112 @@ <value>All Apps</value> <comment>Should be the same as EditShortcuts_AllApps from keyboard manager editor</comment> </data> - <data name="Shortcuts.Header" xml:space="preserve"> - <value>Shortcuts</value> - </data> <data name="Shortcut.Header" xml:space="preserve"> <value>Shortcut</value> </data> - <data name="AdvancedPaste_EnableAIButton.Content" xml:space="preserve"> - <value>Enable</value> - </data> - <data name="AdvancedPaste_DisableAIButton.Content" xml:space="preserve"> - <value>Disable</value> - </data> <data name="AdvancedPaste_EnableAISettingsCard.Header" xml:space="preserve"> - <value>Enable Paste with AI</value> + <value>Paste with AI</value> + </data> + <data name="AdvancedPaste_EnablePasteAIModerationToggle.Header" xml:space="preserve"> + <value>OpenAI content moderation</value> + </data> + <data name="AdvancedPaste_EnableAdvancedAIDescription.Text" xml:space="preserve"> + <value>Use built-in functions to handle complex tasks. Token consumption may increase.</value> </data> <data name="AdvancedPaste_Clipboard_History_Enabled_SettingsCard.Header" xml:space="preserve"> - <value>Clipboard history</value> + <value>Access Clipboard History</value> </data> <data name="AdvancedPaste_Clipboard_History_Enabled_SettingsCard.Description" xml:space="preserve"> - <value>Save multiple items to your clipboard. This is an OS feature.</value> + <value>View and select previously copied items</value> </data> <data name="AdvancedPaste_Direct_Access_Hotkeys_GroupSettings.Header" xml:space="preserve"> <value>Actions</value> </data> <data name="AdvancedPaste_Additional_Actions_GroupSettings.Header" xml:space="preserve"> - <value>Additional actions</value> + <value>Custom actions</value> + </data> + <data name="AdvancedPaste_FoundryLocal_LegalDescription" xml:space="preserve"> + <value>You're running local models directly on your device. Their behavior may vary or be unpredictable.</value> + </data> + <data name="FoundryLocal_RestartRequiredNote.Text" xml:space="preserve"> + <value>Note: After installing the Foundry Local CLI, restart PowerToys to use it.</value> + <comment>Message informing users that PowerToys needs to be restarted after installing Foundry Local CLI</comment> + </data> + <data name="AdvancedPaste_LocalModel_LegalDescription" xml:space="preserve"> + <value>You're running local models directly on your device. Their behavior may vary or be unpredictable.</value> + </data> + <data name="AdvancedPaste_OpenAI_LegalDescription" xml:space="preserve"> + <value>Your API key connects directly to OpenAI services. By setting up this provider, you agree to comply with OpenAI's usage policies and data handling practices.</value> + </data> + <data name="AdvancedPaste_OpenAI_TermsLabel" xml:space="preserve"> + <value>Terms of Use</value> + </data> + <data name="AdvancedPaste_OpenAI_PrivacyLabel" xml:space="preserve"> + <value>Privacy Policy</value> + </data> + <data name="AdvancedPaste_AzureOpenAI_LegalDescription" xml:space="preserve"> + <value>Your API key connects directly to Microsoft Azure services. By setting up this provider, you agree to comply with Microsoft Azure's usage policies and data handling practices.</value> + </data> + <data name="AdvancedPaste_AzureOpenAI_TermsLabel" xml:space="preserve"> + <value>Microsoft Azure Terms of Service</value> + </data> + <data name="AdvancedPaste_AzureOpenAI_PrivacyLabel" xml:space="preserve"> + <value>Microsoft Privacy Statement</value> + </data> + <data name="AdvancedPaste_AzureAIInference_LegalDescription" xml:space="preserve"> + <value>Your API key connects directly to Microsoft Azure services. By setting up this provider, you agree to comply with Microsoft Azure's usage policies and data handling practices.</value> + </data> + <data name="AdvancedPaste_AzureAIInference_TermsLabel" xml:space="preserve"> + <value>Microsoft Azure Terms of Service</value> + </data> + <data name="AdvancedPaste_AzureAIInference_PrivacyLabel" xml:space="preserve"> + <value>Microsoft Privacy Statement</value> + </data> + <data name="AdvancedPaste_Google_LegalDescription" xml:space="preserve"> + <value>Your API key connects directly to Google services. By setting up this provider, you agree to comply with Google's usage policies and data handling practices.</value> + </data> + <data name="AdvancedPaste_Google_TermsLabel" xml:space="preserve"> + <value>Google Terms of Service</value> + </data> + <data name="AdvancedPaste_Google_PrivacyLabel" xml:space="preserve"> + <value>Google Privacy Policy</value> + </data> + <data name="AdvancedPaste_Mistral_LegalDescription" xml:space="preserve"> + <value>Your API key connects directly to Mistral services. By setting up this provider, you agree to comply with Mistral's usage policies and data handling practices.</value> + </data> + <data name="AdvancedPaste_Mistral_TermsLabel" xml:space="preserve"> + <value>Mistral Terms of Use</value> + </data> + <data name="AdvancedPaste_Mistral_PrivacyLabel" xml:space="preserve"> + <value>Mistral Privacy Policy</value> + </data> + <data name="AdvancedPaste_Ollama_TermsLabel" xml:space="preserve"> + <value>Ollama Terms of Service</value> + </data> + <data name="AdvancedPaste_Ollama_PrivacyLabel" xml:space="preserve"> + <value>Ollama Privacy Policy</value> </data> <data name="RemapKeysList.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Current Key Remappings</value> + <value>Current key remappings</value> </data> <data name="RemapShortcutsList.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Current Shortcut Remappings</value> + <value>Current shortcut remappings</value> </data> <data name="KeyboardManager_RemappedKeysListItem.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Key Remapping</value> + <value>Key remapping</value> <comment>key as in keyboard key</comment> </data> <data name="KeyboardManager_RemappedShortcutsListItem.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Shortcut Remapping</value> + <value>Shortcut remapping</value> </data> <data name="KeyboardManager_RemappedTo.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> <value>Remapped to</value> </data> - <data name="KeyboardManager_ShortcutRemappedTo.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Remapped to</value> - </data> - <data name="KeyboardManager_TargetApp.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>For Target Application</value> - <comment>What computer application would this be for</comment> - </data> - <data name="KeyboardManager_Image.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Keyboard Manager</value> - <comment>do not loc, product name</comment> - </data> <data name="ColorPicker.ModuleDescription" xml:space="preserve"> <value>Quick and simple system-wide color picker.</value> </data> <data name="ColorPicker_EnableColorPicker.Header" xml:space="preserve"> - <value>Enable Color Picker</value> + <value>Color Picker</value> <comment>do not loc the Product name. Do you want this feature on / off</comment> </data> <data name="ColorPicker_ChangeCursor.Content" xml:space="preserve"> @@ -639,7 +689,7 @@ <value>A quick launcher that has additional capabilities without sacrificing performance.</value> </data> <data name="PowerLauncher_EnablePowerLauncher.Header" xml:space="preserve"> - <value>Enable PowerToys Run</value> + <value>PowerToys Run</value> <comment>do not loc the Product name. Do you want this feature on / off</comment> </data> <data name="PowerLauncher_SearchResults.Header" xml:space="preserve"> @@ -740,21 +790,6 @@ <data name="PowerLauncher_SlowSearchInputDelayMs.Description" xml:space="preserve"> <value>Affects the plugins that execute in the background by this amount. Recommended: 100-150 ms.</value> </data> - <data name="PowerLauncher_SearchInputDelayMs.Header" xml:space="preserve"> - <value>Fast plugin throttle (ms)</value> - <comment>ms = milliseconds</comment> - </data> - <data name="KeyboardManager_KeysMappingLayoutRightHeader.Text" xml:space="preserve"> - <value>To:</value> - <comment>Keyboard Manager mapping keys view right header</comment> - </data> - <data name="Appearance_GroupSettings.Text" xml:space="preserve"> - <value>Appearance</value> - </data> - <data name="Fancyzones_ImageHyperlinkToDocs.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>FancyZones windows</value> - <comment>{Locked="FancyZones"}</comment> - </data> <data name="FancyZones.ModuleDescription" xml:space="preserve"> <value>Create window layouts to help make multi-tasking easy.</value> <comment>windows refers to application windows</comment> @@ -764,7 +799,7 @@ <comment>windows refers to application windows</comment> </data> <data name="FancyZones_EnableToggleControl_HeaderText.Header" xml:space="preserve"> - <value>Enable FancyZones</value> + <value>FancyZones</value> <comment>{Locked="FancyZones"}</comment> </data> <data name="FancyZones_ExcludeApps.Header" xml:space="preserve"> @@ -776,10 +811,6 @@ <data name="FancyZones_HighlightOpacity.Header" xml:space="preserve"> <value>Opacity (%)</value> </data> - <data name="FancyZones_HotkeyEditorControl.Header" xml:space="preserve"> - <value>Open layout editor</value> - <comment>Shortcut to launch the FancyZones layout editor application</comment> - </data> <data name="FancyZones_WindowSwitching_GroupSettings.Header" xml:space="preserve"> <value>Switch between windows in the current zone</value> </data> @@ -789,14 +820,8 @@ <data name="FancyZones_HotkeyPrevTabControl.Header" xml:space="preserve"> <value>Previous window</value> </data> - <data name="SettingsPage_SetShortcut.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Shortcut setting</value> - </data> - <data name="SettingsPage_SetShortcut_Glyph.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Information Symbol</value> - </data> <data name="FancyZones_LaunchEditorButtonControl.Header" xml:space="preserve"> - <value>Launch layout editor</value> + <value>Open layout editor</value> <comment>launches the FancyZones layout editor application</comment> </data> <data name="FancyZones_LaunchEditorButtonControl.Description" xml:space="preserve"> @@ -804,7 +829,7 @@ <comment>launches the FancyZones layout editor application</comment> </data> <data name="FancyZones_MakeDraggedWindowTransparentCheckBoxControl.Content" xml:space="preserve"> - <value>Make dragged window transparent</value> + <value>Make the dragged window transparent</value> </data> <data name="FancyZones_MouseDragCheckBoxControl_Header.Content" xml:space="preserve"> <value>Use a non-primary mouse button to toggle zone activation</value> @@ -824,8 +849,11 @@ <data name="FancyZones_ShiftDragCheckBoxControl_Header.Content" xml:space="preserve"> <value>Hold Shift key to activate zones while dragging a window</value> </data> + <data name="FancyZones_ActivationShiftDrag" xml:space="preserve"> + <value>Hold Shift key</value> + </data> <data name="FancyZones_ActivationNoShiftDrag" xml:space="preserve"> - <value>Drag windows to activate zones</value> + <value>Drag a window</value> </data> <data name="FancyZones_ShowZonesOnAllMonitorsCheckBoxControl.Content" xml:space="preserve"> <value>Show zones on all monitors while dragging a window</value> @@ -865,10 +893,6 @@ <data name="FancyZones_ZoneSetChangeMoveWindows.Content" xml:space="preserve"> <value>During zone layout changes, windows assigned to a zone will match new size/positions</value> </data> - <data name="AttributionTitle.Text" xml:space="preserve"> - <value>Attribution</value> - <comment>giving credit to the projects this utility was based on</comment> - </data> <data name="General.ModuleTitle" xml:space="preserve"> <value>General</value> </data> @@ -896,9 +920,6 @@ <data name="General_SettingsBackupAndRestore_ButtonSelectLocation.Text" xml:space="preserve"> <value>Select folder</value> </data> - <data name="GeneralPage_UpdateNow.Content" xml:space="preserve"> - <value>Update now</value> - </data> <data name="GeneralPage_PrivacyStatement_URL.Text" xml:space="preserve"> <value>Privacy statement</value> </data> @@ -931,12 +952,9 @@ <comment>This refers to directly integrating in with Windows</comment> </data> <data name="PowerRename_Toggle_Enable.Header" xml:space="preserve"> - <value>Enable PowerRename</value> + <value>PowerRename</value> <comment>do not loc the Product name. Do you want this feature on / off</comment> </data> - <data name="RadioButtons_Name_Theme.Text" xml:space="preserve"> - <value>Settings theme</value> - </data> <data name="PowerRename_Toggle_HideIcon.Content" xml:space="preserve"> <value>Hide icon in context menu</value> </data> @@ -1113,7 +1131,7 @@ <value>PowerToys will restart automatically if needed</value> </data> <data name="ShortcutGuide_Enable.Header" xml:space="preserve"> - <value>Enable Shortcut Guide</value> + <value>Shortcut Guide</value> <comment>do not loc the Product name. Do you want this feature on / off</comment> </data> <data name="ShortcutGuide_OverlayOpacity.Header" xml:space="preserve"> @@ -1145,15 +1163,12 @@ <value>Lets you resize images by right-clicking.</value> </data> <data name="ImageResizer_EnableToggle.Header" xml:space="preserve"> - <value>Enable Image Resizer</value> + <value>Image Resizer</value> <comment>do not loc the Product name. Do you want this feature on / off</comment> </data> <data name="ImagesSizesListView.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> <value>Image Size</value> </data> - <data name="ImageResizer_Configurations.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Configurations</value> - </data> <data name="ImageResizer_Name.Header" xml:space="preserve"> <value>Name</value> </data> @@ -1172,15 +1187,9 @@ <data name="RemoveItem.Text" xml:space="preserve"> <value>Delete</value> </data> - <data name="ImageResizer_Image.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Image Resizer</value> - </data> <data name="ImageResizer_AddSizeButton.Content" xml:space="preserve"> <value>Add new size</value> </data> - <data name="ImageResizer_SaveSizeButton.Label" xml:space="preserve"> - <value>Save sizes</value> - </data> <data name="ImageResizer_Encoding.Header" xml:space="preserve"> <value>JPEG quality level (%)</value> <comment>{Locked="JPEG"}</comment> @@ -1258,10 +1267,6 @@ <value>Fill</value> <comment>Refers to filling an image into a certain size. It could overflow</comment> </data> - <data name="ImageResizer_Sizes_Fit_Fill_ThirdPersonSingular.Text" xml:space="preserve"> - <value>Fill</value> - <comment>Refers to filling an image into a certain size. It could overflow</comment> - </data> <data name="ImageResizer_Sizes_Fit_Fit.Content" xml:space="preserve"> <value>Fit</value> <comment>Refers to fitting an image into a certain size. It won't overflow</comment> @@ -1297,14 +1302,11 @@ <data name="GeneralPage_AutoDownloadAndInstallUpdates.Description" xml:space="preserve"> <value>Except on metered connections</value> </data> - <data name="GeneralPage_ToggleSwitch_RunningAsAdminNote.Text" xml:space="preserve"> - <value>Currently running as administrator</value> - </data> <data name="GeneralSettings_AlwaysRunAsAdminText.Header" xml:space="preserve"> <value>Always run as administrator</value> </data> <data name="GeneralSettings_AlwaysRunAsAdminText.Description" xml:space="preserve"> - <value>You need to run as administrator to use this setting</value> + <value>This setting can only be changed when running as administrator</value> </data> <data name="GeneralSettings_RunningAsUserText" xml:space="preserve"> <value>Running as user</value> @@ -1319,10 +1321,6 @@ <data name="FileExplorerPreview.ModuleTitle" xml:space="preserve"> <value>File Explorer</value> </data> - <data name="FileExplorerPreview_Image.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>File Explorer</value> - <comment>Use same translation as Windows does for File Explorer</comment> - </data> <data name="ImageResizer.ModuleTitle" xml:space="preserve"> <value>Image Resizer</value> </data> @@ -1335,23 +1333,13 @@ <data name="PowerLauncher.ModuleTitle" xml:space="preserve"> <value>PowerToys Run</value> </data> - <data name="PowerToys_Run_Image.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>PowerToys Run</value> - </data> <data name="PowerRename.ModuleTitle" xml:space="preserve"> <value>PowerRename</value> <comment>do not loc the product name</comment> </data> - <data name="PowerRename_Image.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>PowerRename</value> - <comment>do not loc</comment> - </data> <data name="ShortcutGuide.ModuleTitle" xml:space="preserve"> <value>Shortcut Guide</value> </data> - <data name="Shortcut_Guide_Image.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Shortcut Guide</value> - </data> <data name="General_Repository.Text" xml:space="preserve"> <value>GitHub repository</value> </data> @@ -1376,9 +1364,6 @@ <data name="General_VersionAndUpdate.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> <value>Version and updates</value> </data> - <data name="Admin_mode.Header" xml:space="preserve"> - <value>Administrator mode</value> - </data> <data name="FancyZones_RestoreSize.Content" xml:space="preserve"> <value>Restore the original size of windows when unsnapping</value> </data> @@ -1441,16 +1426,6 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <data name="ImageResizer_Formatting_Sizename.Text" xml:space="preserve"> <value>Size name</value> </data> - <data name="FancyZones_MoveWindowsBasedOnPositionCheckBoxControl.Content" xml:space="preserve"> - <value>Move windows based on their position</value> - <comment>Windows refers to application windows</comment> - </data> - <data name="GeneralSettings_NewVersionIsAvailable" xml:space="preserve"> - <value>New update available</value> - </data> - <data name="GeneralSettings_VersionIsLatest" xml:space="preserve"> - <value>PowerToys is up to date.</value> - </data> <data name="FileExplorerPreview_IconThumbnail_GroupSettings.Header" xml:space="preserve"> <value>Thumbnail icon Preview</value> </data> @@ -1464,9 +1439,6 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <value>Select the file types which must be rendered in the Preview Pane. Ensure that Preview Pane is open by toggling the view with Alt + P in File Explorer.</value> <comment>Preview Pane and File Explorer are app/feature names in Windows. 'Alt + P' is a shortcut</comment> </data> - <data name="FileExplorerPreview_RunAsAdminRequired.Title" xml:space="preserve"> - <value>You need to run as administrator to modify these settings.</value> - </data> <data name="FileExplorerPreview_RebootRequired.Title" xml:space="preserve"> <value>A reboot may be required for changes to these settings to take effect</value> </data> @@ -1507,23 +1479,44 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <data name="ColorPicker_CopiedColorRepresentation.Header" xml:space="preserve"> <value>Default color format</value> </data> - <data name="ColorPickerFirst.Content" xml:space="preserve"> - <value>Pick a color and open editor</value> - </data> - <data name="EditorFirst.Content" xml:space="preserve"> - <value>Open editor</value> - </data> - <data name="ColorPickerOnly.Content" xml:space="preserve"> - <value>Only pick a color</value> - </data> <data name="ColorPicker_ActivationAction.Header" xml:space="preserve"> <value>Activation behavior</value> </data> + <data name="ColorPicker_OpenEditor.Content" xml:space="preserve"> + <value>Open editor</value> + </data> + <data name="ColorPicker_PickColor.Content" xml:space="preserve"> + <value>Pick a color first</value> + </data> + <data name="ColorPicker_MouseActions.Header" xml:space="preserve"> + <value>Mouse actions</value> + </data> + <data name="ColorPicker_MouseActions.Description" xml:space="preserve"> + <value>Customize the function of each mouse button</value> + </data> + <data name="ColorPicker_PrimaryClick.Header" xml:space="preserve"> + <value>Primary click</value> + </data> + <data name="ColorPicker_MiddleClick.Header" xml:space="preserve"> + <value>Middle click</value> + </data> + <data name="ColorPicker_SecondaryClick.Header" xml:space="preserve"> + <value>Secondary click</value> + </data> + <data name="ColorPicker_PickColorThenEditor.Content" xml:space="preserve"> + <value>Pick a color and open editor</value> + </data> + <data name="ColorPicker_PickColorAndClose.Content" xml:space="preserve"> + <value>Pick a color and close</value> + </data> + <data name="ColorPicker_Close.Content" xml:space="preserve"> + <value>Close</value> + </data> <data name="ColorFormats.Header" xml:space="preserve"> <value>Picker behavior</value> </data> <data name="ColorPicker_CopiedColorRepresentation.Description" xml:space="preserve"> - <value>This format will be copied to your clipboard</value> + <value>Format will be copied to your clipboard</value> </data> <data name="KBM_KeysCannotBeRemapped.Text" xml:space="preserve"> <value>Learn more about remapping limitations</value> @@ -1555,8 +1548,34 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <value>Provides extended features but may use different regex syntax</value> <comment>Boost is a product name, should not be translated</comment> </data> - <data name="MadeWithOssLove.Text" xml:space="preserve"> - <value>Made with 💗 by Microsoft and the PowerToys community.</value> + <data name="PowerRename_ExtensionsHeader.Header" xml:space="preserve"> + <value>Extensions</value> + </data> + <data name="PowerRename_HeifExtension.Header" xml:space="preserve"> + <value>HEIF image metadata extraction</value> + </data> + <data name="PowerRename_HeifExtension.Description" xml:space="preserve"> + <value>Requires HEIF Image Extensions from Microsoft Store to extract metadata from HEIC/HEIF files</value> + <comment>HEIF is a file format name, do not translate</comment> + </data> + <data name="PowerRename_HeifExtension_Install.Content" xml:space="preserve"> + <value>Install from Microsoft Store</value> + </data> + <data name="PowerRename_HeifExtension_Installed.Text" xml:space="preserve"> + <value>Installed</value> + </data> + <data name="PowerRename_AvifExtension.Header" xml:space="preserve"> + <value>AVIF image metadata extraction</value> + </data> + <data name="PowerRename_AvifExtension.Description" xml:space="preserve"> + <value>Requires AV1 Video Extension from Microsoft Store to extract metadata from AVIF files</value> + <comment>AVIF is a file format name, do not translate</comment> + </data> + <data name="PowerRename_AvifExtension_Install.Content" xml:space="preserve"> + <value>Install from Microsoft Store</value> + </data> + <data name="PowerRename_AvifExtension_Installed.Text" xml:space="preserve"> + <value>Installed</value> </data> <data name="ColorPicker_ColorFormats.Header" xml:space="preserve"> <value>Color formats</value> @@ -1660,11 +1679,11 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <data name="Help_blackness" xml:space="preserve"> <value>blackness</value> </data> - <data name="Help_chromaticityA" xml:space="preserve"> - <value>chromaticityA</value> + <data name="Help_chromaticityACIE" xml:space="preserve"> + <value>chromaticity A (CIE Lab)</value> </data> - <data name="Help_chromaticityB" xml:space="preserve"> - <value>chromaticityB</value> + <data name="Help_chromaticityBCIE" xml:space="preserve"> + <value>chromaticity B (CIE Lab)</value> </data> <data name="Help_X_value" xml:space="preserve"> <value>X value</value> @@ -1715,7 +1734,7 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <value>Show color name</value> </data> <data name="ColorPicker_ShowColorName.Description" xml:space="preserve"> - <value>This will show the name of the color when picking a color</value> + <value>Displays the color name while picking a color</value> </data> <data name="ImageResizer_DefaultSize_Large" xml:space="preserve"> <value>Large</value> @@ -1801,9 +1820,6 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <data name="PowerLauncher_EnablePluginToggle.OffContent" xml:space="preserve"> <value>Off</value> </data> - <data name="Run_AdditionalOptions.Text" xml:space="preserve"> - <value>Additional options</value> - </data> <data name="Run_NotAccessibleWarning.Title" xml:space="preserve"> <value>Please define an activation command or allow this plugin to be used in the global results.</value> </data> @@ -1842,12 +1858,6 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <data name="Run_PluginsLoading.Text" xml:space="preserve"> <value>Plugins are loading...</value> </data> - <data name="ColorPicker_ButtonDown.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Move the color down</value> - </data> - <data name="ColorPicker_ButtonUp.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Move the color up</value> - </data> <data name="FancyZones_FlashZonesOnQuickSwitch.Content" xml:space="preserve"> <value>Flash zones when switching layout</value> </data> @@ -1860,9 +1870,6 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <data name="FancyZones_QuickLayoutSwitch.Description" xml:space="preserve"> <value>Layout-specific shortcuts can be configured in the editor</value> </data> - <data name="FancyZones_QuickLayoutSwitch_GroupSettings.Text" xml:space="preserve"> - <value>Quick layout switch</value> - </data> <data name="Activation_Shortcut.Header" xml:space="preserve"> <value>Activation shortcut</value> </data> @@ -1892,6 +1899,9 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <data name="AdvancedPasteUI_CustomAction_Name.Header" xml:space="preserve"> <value>Name</value> </data> + <data name="AdvancedPasteUI_CustomAction_Description.Header" xml:space="preserve"> + <value>Description</value> + </data> <data name="AdvancedPasteUI_CustomAction_Prompt.Header" xml:space="preserve"> <value>Prompt</value> </data> @@ -1916,9 +1926,6 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <data name="PasteAsJson_Shortcut.Header" xml:space="preserve"> <value>Paste as JSON directly</value> </data> - <data name="PasteAsCustom_Shortcut.Header" xml:space="preserve"> - <value>Paste as Custom with AI directly</value> - </data> <data name="ImageToText.Header" xml:space="preserve"> <value>Image to text</value> </data> @@ -1943,32 +1950,8 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <data name="TranscodeToMp4.Header" xml:space="preserve"> <value>Transcode to .mp4 (H.264/AAC)</value> </data> - <data name="AdvancedPaste_EnableAIDialogOpenAIApiKey.Text" xml:space="preserve"> - <value>OpenAI API key:</value> - </data> - <data name="EnableAIDialog_SaveBtnText" xml:space="preserve"> - <value>Save</value> - </data> - <data name="EnableAIDialog_CancelBtnText" xml:space="preserve"> - <value>Cancel</value> - </data> - <data name="Oobe_GetStarted.Text" xml:space="preserve"> - <value>Let's get started!</value> - </data> - <data name="Oobe_PowerToysDescription.Text" xml:space="preserve"> - <value>Welcome to PowerToys! These overviews will help you quickly learn the basics of all our utilities.</value> - </data> - <data name="Oobe_GettingStarted.Text" xml:space="preserve"> - <value>Getting started</value> - </data> - <data name="Oobe_Launch.Text" xml:space="preserve"> - <value>Launch</value> - </data> <data name="Launch_ColorPicker.Content" xml:space="preserve"> - <value>Launch Color Picker</value> - </data> - <data name="Oobe_LearnMore.Text" xml:space="preserve"> - <value>Learn more about</value> + <value>Open Color Picker</value> </data> <data name="Oobe_ColorPicker.Description" xml:space="preserve"> <value>Color Picker is a system-wide color selection tool for Windows that enables you to pick colors from any currently running application and automatically copies it in a configurable format to your clipboard.</value> @@ -1981,7 +1964,7 @@ Made with 💗 by Microsoft and the PowerToys community.</value> <value>File Locksmith lists which processes are using the selected files or directories and allows closing those processes.</value> </data> <data name="Oobe_FileExplorer.Description" xml:space="preserve"> - <value>PowerToys introduces add-ons to the Windows File Explorer that will enable files like Markdown (.md), PDF (.pdf), SVG (.svg), STL (.stl), G-code (.gcode) and developer files to be viewed in the preview pane. It introduces File Explorer thumbnail support for a number of these file types as well.</value> + <value>PowerToys introduces add-ons to the Windows File Explorer that will enable files like Markdown (.md), PDF (.pdf), SVG (.svg), STL (.stl), G-code (.gcode), Binary G-code (.bgcode), and developer files to be viewed in the preview pane. It introduces File Explorer thumbnail support for a number of these file types as well.</value> </data> <data name="Oobe_ImageResizer.Description" xml:space="preserve"> <value>Image Resizer is a Windows shell extension for simple bulk image-resizing.</value> @@ -2025,45 +2008,15 @@ Take a moment to preview the various utilities listed or view our comprehensive <data name="Oobe_Overview_Telemetry_Desc.Text" xml:space="preserve"> <value>Diagnostics & feedback helps us to improve PowerToys and keep it secure, up to date, and working as expected.</value> </data> + <data name="Oobe_Overview_Hotkey_Conflict_Title.Text" xml:space="preserve"> + <value>Shortcut conflict detection</value> + </data> + <data name="Oobe_Overview_Hotkey_Conflict_Card_Description.Header" xml:space="preserve"> + <value>Shortcuts configured by PowerToys are conflicting</value> + </data> <data name="Oobe_Overview_DiagnosticsAndFeedback_Settings_Link.Content" xml:space="preserve"> <value>View more diagnostic data settings</value> </data> - <data name="Oobe_Overview_DiagnosticsAndFeedback_Link.Content" xml:space="preserve"> - <value>Learn more about the information PowerToys logs & how it gets used</value> - </data> - <data name="Oobe_Overview_EnableDataDiagnostics.Header" xml:space="preserve"> - <value>Diagnostic data</value> - </data> - <data name="Oobe_Overview_EnableDataDiagnostics.Description" xml:space="preserve"> - <value>Helps inform bug fixes, performance, and product decisions</value> - </data> - <data name="Oobe_WhatsNew_DataDiagnostics_InfoBar.Header" xml:space="preserve"> - <value>Turn on diagnostic data to help us improve PowerToys?</value> - </data> - <data name="Oobe_WhatsNew_DataDiagnostics_InfoBar_Desc.Text" xml:space="preserve"> - <value>PowerToys diagnostic data is completely optional.</value> - </data> - <data name="Oobe_WhatsNew_DataDiagnostics_InfoBar_Button_Enable.Content" xml:space="preserve"> - <value>Enable</value> - </data> - <data name="Oobe_WhatsNew_DataDiagnostics_Yes_Click_InfoBar_Title" xml:space="preserve"> - <value>Thank you for helping to make PowerToys better!</value> - </data> - <data name="Oobe_WhatsNew_DataDiagnostics_No_Click_InfoBar_Title" xml:space="preserve"> - <value>Preference updated.</value> - </data> - <data name="Oobe_WhatsNew_DataDiagnostics_Yes_Click_InfoBar_Desc.Text" xml:space="preserve"> - <value>You can change this at any time from</value> - </data> - <data name="Oobe_WhatsNew_DataDiagnostics_Yes_Click_OpenSettings_Text.Text" xml:space="preserve"> - <value>settings.</value> - </data> - <data name="Oobe_WhatsNew_DataDiagnostics_Button_Yes.Content" xml:space="preserve"> - <value>Yes</value> - </data> - <data name="Oobe_WhatsNew_DataDiagnostics_Button_No.Content" xml:space="preserve"> - <value>No</value> - </data> <data name="ReleaseNotes.Content" xml:space="preserve"> <value>Release notes</value> </data> @@ -2090,7 +2043,7 @@ Take a moment to preview the various utilities listed or view our comprehensive <value>Press the **Restart as administrator** button from the File Locksmith UI to also get information on elevated processes that might be using the files.</value> </data> <data name="Oobe_FileExplorer_HowToEnable.Text" xml:space="preserve"> - <value>Select **View** which is located at the top of File Explorer, followed by **Show**, and then **Preview pane**. + <value>Select **View** which is located at the top of File Explorer, followed by **Show**, and then **Preview pane**. From there, simply click on one of the supported files in the File Explorer and observe the content on the preview pane!</value> </data> <data name="Oobe_HowToCreateMappings.Text" xml:space="preserve"> @@ -2112,7 +2065,7 @@ From there, simply click on one of the supported files in the File Explorer and <value>Want a custom size? You can add them in the PowerToys Settings!</value> </data> <data name="Oobe_KBM_HowToCreateMappings.Text" xml:space="preserve"> - <value>Launch **PowerToys Settings**, navigate to the Keyboard Manager menu, and select either **Remap a key** or **Remap a shortcut**.</value> + <value>Open **PowerToys Settings**, navigate to the Keyboard Manager menu, and select either **Remap a key** or **Remap a shortcut**.</value> </data> <data name="Oobe_KBM_TipsAndTricks.Text" xml:space="preserve"> <value>Want to only have a shortcut work for a single application? Use the Target App field when creating the shortcut remapping.</value> @@ -2203,32 +2156,15 @@ From there, simply click on one of the supported files in the File Explorer and <data name="Oobe_Overview.Title" xml:space="preserve"> <value>Welcome</value> </data> - <data name="Oobe_WhatsNew.Text" xml:space="preserve"> - <value>What's new</value> - </data> <data name="Oobe_WhatsNew_LoadingError.Title" xml:space="preserve"> <value>Couldn't load the release notes.</value> </data> <data name="Oobe_WhatsNew_LoadingError.Message" xml:space="preserve"> <value>Please check your internet connection.</value> </data> - <data name="Oobe_WhatsNew_ProxyAuthenticationWarning.Title" xml:space="preserve"> - <value>Couldn't load the release notes.</value> - </data> - <data name="Oobe_WhatsNew_ProxyAuthenticationWarning.Message" xml:space="preserve"> - <value>Your proxy server requires authentication.</value> - </data> - <data name="Oobe_WhatsNew_DetailedReleaseNotesLink.Text" xml:space="preserve"> - <value>See more detailed release notes on GitHub</value> - <comment>Don't loc "GitHub", it's the name of a product</comment> - </data> <data name="OOBE_Settings.Content" xml:space="preserve"> <value>Open Settings</value> </data> - <data name="Oobe_NavViewItem.Content" xml:space="preserve"> - <value>Welcome to PowerToys</value> - <comment>Don't loc "PowerToys"</comment> - </data> <data name="WhatIsNew_NavViewItem.Content" xml:space="preserve"> <value>What's new</value> </data> @@ -2238,21 +2174,28 @@ From there, simply click on one of the supported files in the File Explorer and <data name="OobeWindow_Title" xml:space="preserve"> <value>Welcome to PowerToys</value> </data> - <data name="OobeWindow_TitleTxt.Text" xml:space="preserve"> + <data name="OobeWindow_TitleTxt.Title" xml:space="preserve"> <value>Welcome to PowerToys</value> </data> + <data name="ScoobeWindow_Title" xml:space="preserve"> + <value>What's new in PowerToys</value> + </data> + <data name="ScoobeWindow_TitleTxt.Title" xml:space="preserve"> + <value>What's new in PowerToys</value> + </data> <data name="SettingsWindow_Title" xml:space="preserve"> <value>PowerToys Settings</value> <comment>Title of the settings window when running as user</comment> </data> <data name="Awake.ModuleTitle" xml:space="preserve"> <value>Awake</value> + <comment>Awake is a product name, do not localize</comment> </data> <data name="Awake.ModuleDescription" xml:space="preserve"> <value>A convenient way to keep your PC awake on-demand.</value> </data> <data name="Awake_EnableSettingsCard.Header" xml:space="preserve"> - <value>Enable Awake</value> + <value>Awake</value> <comment>Awake is a product name, do not loc</comment> </data> <data name="Awake_NoKeepAwakeSelector.Content" xml:space="preserve"> @@ -2300,12 +2243,15 @@ From there, simply click on one of the supported files in the File Explorer and </data> <data name="Oobe_Awake.Description" xml:space="preserve"> <value>Awake is a Windows tool designed to keep your PC awake on-demand without having to manage its power settings. This behavior can be helpful when running time-consuming tasks while ensuring that your PC does not go to sleep or turn off its screens.</value> + <comment>Awake is a product name, do not localize</comment> </data> <data name="Oobe_Awake_HowToUse.Text" xml:space="preserve"> <value>Open **PowerToys Settings** and enable Awake</value> + <comment>Awake is a product name, do not localize</comment> </data> <data name="Oobe_Awake_TipsAndTricks.Text" xml:space="preserve"> <value>You can always change modes quickly by **right-clicking the Awake icon** in the system tray.</value> + <comment>Awake is a product name, do not localize</comment> </data> <data name="General_FailedToDownloadTheNewVersion.Title" xml:space="preserve"> <value>An error occurred trying to install this update:</value> @@ -2313,9 +2259,6 @@ From there, simply click on one of the supported files in the File Explorer and <data name="General_InstallNow.Content" xml:space="preserve"> <value>Install now</value> </data> - <data name="General_ReadMore.Text" xml:space="preserve"> - <value>Read more</value> - </data> <data name="General_NewVersionAvailable.Title" xml:space="preserve"> <value>An update is available:</value> </data> @@ -2400,6 +2343,7 @@ From there, simply click on one of the supported files in the File Explorer and </data> <data name="Awake_ModeSettingsCard.Description" xml:space="preserve"> <value>Manage the state of your device when Awake is active</value> + <comment>Awake is a product name, do not localize</comment> </data> <data name="ExcludedApps.Header" xml:space="preserve"> <value>Excluded apps</value> @@ -2512,6 +2456,36 @@ From there, simply click on one of the supported files in the File Explorer and <value>Use a keyboard shortcut to highlight left and right mouse clicks.</value> <comment>Mouse as in the hardware peripheral.</comment> </data> + <data name="MouseUtils_Enable_CursorWrap.Header" xml:space="preserve"> + <value>CursorWrap</value> + </data> + <data name="MouseUtils_CursorWrap.Header" xml:space="preserve"> + <value>CursorWrap</value> + </data> + <data name="MouseUtils_CursorWrap.Description" xml:space="preserve"> + <value>Wrap the mouse cursor between monitor edges</value> + </data> + <data name="MouseUtils_CursorWrap_ActivationShortcut.Header" xml:space="preserve"> + <value>Activation and behavior</value> + </data> + <data name="MouseUtils_CursorWrap_DisableWrapDuringDrag.Content" xml:space="preserve"> + <value>Disable wrapping while dragging</value> + </data> + <data name="MouseUtils_CursorWrap_DisableOnSingleMonitor.Content" xml:space="preserve"> + <value>Disable wrapping when using a single monitor</value> + </data> + <data name="MouseUtils_CursorWrap_WrapMode.Header" xml:space="preserve"> + <value>Wrap mode</value> + </data> + <data name="MouseUtils_CursorWrap_WrapMode_VerticalOnly.Content" xml:space="preserve"> + <value>Vertical only</value> + </data> + <data name="MouseUtils_CursorWrap_WrapMode_HorizontalOnly.Content" xml:space="preserve"> + <value>Horizontal only</value> + </data> + <data name="MouseUtils_CursorWrap_WrapMode_Both.Content" xml:space="preserve"> + <value>Vertical and horizontal</value> + </data> <data name="Oobe_MouseUtils_MousePointerCrosshairs.Text" xml:space="preserve"> <value>Mouse Pointer Crosshairs</value> <comment>Mouse as in the hardware peripheral.</comment> @@ -2529,13 +2503,10 @@ From there, simply click on one of the supported files in the File Explorer and <comment>Mouse as in the hardware peripheral.</comment> </data> <data name="Launch_Run.Content" xml:space="preserve"> - <value>Launch PowerToys Run</value> + <value>Open PowerToys Run</value> </data> <data name="Launch_ShortcutGuide.Content" xml:space="preserve"> - <value>Launch Shortcut Guide</value> - </data> - <data name="ColorPicker_ColorFormat_ToggleSwitch.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Show format in editor</value> + <value>Open Shortcut Guide</value> </data> <data name="GeneralPage_Documentation.Text" xml:space="preserve"> <value>Documentation</value> @@ -2589,23 +2560,20 @@ From there, simply click on one of the supported files in the File Explorer and <value>Press a combination of keys to change this shortcut. Right-click to remove the key combination, thereby deactivating the shortcut.</value> </data> - <data name="Activation_Shortcut_Reset" xml:space="preserve"> - <value>Reset</value> - </data> <data name="Activation_Shortcut_Save" xml:space="preserve"> <value>Save</value> </data> <data name="Activation_Shortcut_Title" xml:space="preserve"> <value>Activation shortcut</value> </data> - <data name="InvalidShortcut.Title" xml:space="preserve"> + <data name="InvalidShortcut.Message" xml:space="preserve"> <value>Invalid shortcut</value> </data> <data name="InvalidShortcutWarningLabel.Text" xml:space="preserve"> - <value>Only shortcuts that start with **Windows key**, **Ctrl**, **Alt** or **Shift** are valid.</value> + <value>A shortcut should start with **Windows key**, **Ctrl**, **Alt** or **Shift**.</value> <comment>The ** sequences are used for text formatting of the key names. Don't remove them on translation.</comment> </data> - <data name="WarningShortcutAltGr.Title" xml:space="preserve"> + <data name="WarningShortcutAltGr.Message" xml:space="preserve"> <value>Possible shortcut interference with Alt Gr</value> <comment>Alt Gr refers to the right alt key on some international keyboards</comment> </data> @@ -2613,6 +2581,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Shortcuts with **Ctrl** and **Alt** may remove functionality from some international keyboards, because **Ctrl** + **Alt** = **Alt Gr** in those keyboards.</value> <comment>The ** sequences are used for text formatting of the key names. Don't remove them on translation.</comment> </data> + <data name="WarningPotentialShortcutConflict.Message" xml:space="preserve"> + <value>This shortcut has a potential conflict, but the warning is ignored.</value> + </data> <data name="FancyZones_SpanZonesAcrossMonitors.Description" xml:space="preserve"> <value>All monitors must have the same DPI scaling and will be treated as one large combined rectangle which contains all monitors</value> </data> @@ -2621,7 +2592,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <comment>First part of the default name of new sizes that can be added in PT's settings ui.</comment> </data> <data name="Awake_IntervalSettingsCard.Header" xml:space="preserve"> - <value>Interval before returning to the previous awakeness state</value> + <value>Interval before returning to the previous awake state</value> </data> <data name="Awake_ExpirationSettingsExpander.Header" xml:space="preserve"> <value>End date and time</value> @@ -2637,11 +2608,11 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <comment>Refers to the utility name</comment> </data> <data name="MouseUtils_FindMyMouse.Description" xml:space="preserve"> - <value>Find My Mouse highlights the position of the cursor when pressing the Ctrl key twice, using a custom shortcut or when shaking the mouse.</value> + <value>Highlight the position of the cursor when pressing the Ctrl key twice, using a custom shortcut or when shaking the mouse.</value> <comment>"Ctrl" is a keyboard key. "Find My Mouse" is the name of the utility</comment> </data> <data name="MouseUtils_Enable_FindMyMouse.Header" xml:space="preserve"> - <value>Enable Find My Mouse</value> + <value>Find My Mouse</value> <comment>"Find My Mouse" is the name of the utility.</comment> </data> <data name="MouseUtils_FindMyMouse_ActivationMethod.Header" xml:space="preserve"> @@ -2678,9 +2649,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <data name="MouseUtils_FindMyMouse_SpotlightColor.Header" xml:space="preserve"> <value>Spotlight color</value> </data> - <data name="MouseUtils_FindMyMouse_OverlayOpacity.Header" xml:space="preserve"> - <value>Overlay opacity (%)</value> - </data> <data name="MouseUtils_FindMyMouse_SpotlightRadius.Header" xml:space="preserve"> <value>Spotlight radius (px)</value> <comment>px = pixels</comment> @@ -2689,7 +2657,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Spotlight initial zoom</value> </data> <data name="MouseUtils_FindMyMouse_SpotlightInitialZoom.Description" xml:space="preserve"> - <value>Spotlight zoom factor at animation start</value> + <value>Spotlight zoom factor at the start of the animation</value> </data> <data name="MouseUtils_FindMyMouse_AnimationDurationMs.Header" xml:space="preserve"> <value>Animation duration (ms)</value> @@ -2703,41 +2671,41 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Animations are disabled in your system settings.</value> </data> <data name="MouseUtils_FindMyMouse_ShakingMinimumDistance.Header" xml:space="preserve"> - <value>Shake minimum distance</value> + <value>Minimum shake distance</value> </data> <data name="MouseUtils_FindMyMouse_ShakingMinimumDistance.Description" xml:space="preserve"> - <value>The minimum distance for mouse shaking activation, for adjusting sensitivity</value> + <value>The minimum distance the mouse must move to be considered a shake. Lower values increase sensitivity.</value> </data> <data name="MouseUtils_FindMyMouse_ShakingIntervalMs.Header" xml:space="preserve"> - <value>Shake Interval (ms)</value> + <value>Shake detection interval (ms)</value> <comment>ms = milliseconds</comment> </data> <data name="MouseUtils_FindMyMouse_ShakingIntervalMs.Description" xml:space="preserve"> - <value>The span of time during which we track mouse movement to detect shaking, for adjusting sensitivity</value> + <value>Time window used to monitor mouse movement for shake detection. Shorter intervals may detect quicker shakes.</value> </data> <data name="MouseUtils_FindMyMouse_ShakingFactor.Header" xml:space="preserve"> - <value>Shake factor (percent)</value> + <value>Shake sensitivity factor (percent)</value> </data> <data name="MouseUtils_FindMyMouse_ShakingFactor.Description" xml:space="preserve"> - <value>Mouse shaking is detected by checking how much the mouse pointer has travelled when compared to the diagonal of the movement area. Reducing this factor increases sensitivity.</value> + <value>Determines how far the pointer must move, relative to the screen diagonal, to count as a shake. Lower values make it more sensitive.</value> </data> <data name="MouseUtils_MouseHighlighter.Header" xml:space="preserve"> <value>Mouse Highlighter</value> <comment>Refers to the utility name</comment> </data> <data name="MouseUtils_MouseHighlighter.Description" xml:space="preserve"> - <value>Mouse Highlighter mode will highlight mouse clicks.</value> + <value>Highlight mouse clicks.</value> <comment>"Mouse Highlighter" is the name of the utility. Mouse is the hardware mouse.</comment> </data> <data name="MouseUtils_Enable_MouseHighlighter.Header" xml:space="preserve"> - <value>Enable Mouse Highlighter</value> + <value>Mouse Highlighter</value> <comment>"Find My Mouse" is the name of the utility.</comment> </data> <data name="MouseUtils_MouseHighlighter_ActivationShortcut.Header" xml:space="preserve"> <value>Activation shortcut</value> </data> <data name="MouseUtils_MouseHighlighter_ActivationShortcut.Description" xml:space="preserve"> - <value>Customize the shortcut to turn on or off this mode</value> + <value>Customize the shortcut used to turn this mode on or off</value> <comment>"Mouse Highlighter" is the name of the utility. Mouse is the hardware mouse.</comment> </data> <data name="MouseUtils_MouseHighlighter_PrimaryButtonClickColor.Header" xml:space="preserve"> @@ -2771,11 +2739,11 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <comment>Refers to the utility name</comment> </data> <data name="MouseUtils_MousePointerCrosshairs.Description" xml:space="preserve"> - <value>Mouse Pointer Crosshairs draws crosshairs centered on the mouse pointer.</value> + <value>Draw crosshairs centered on the mouse pointer.</value> <comment>"Mouse Pointer Crosshairs" is the name of the utility. Mouse is the hardware mouse.</comment> </data> <data name="MouseUtils_Enable_MousePointerCrosshairs.Header" xml:space="preserve"> - <value>Enable Mouse Pointer Crosshairs</value> + <value>Mouse Pointer Crosshairs</value> <comment>"Mouse Pointer Crosshairs" is the name of the utility.</comment> </data> <data name="MouseUtils_MousePointerCrosshairs_ActivationShortcut.Header" xml:space="preserve"> @@ -2812,6 +2780,36 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Crosshairs fixed length (px)</value> <comment>px = pixels</comment> </data> + <data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation.Header" xml:space="preserve"> + <value>Crosshairs orientation</value> + </data> + <data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Both.Content" xml:space="preserve"> + <value>Vertical and horizontal lines</value> + </data> + <data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Vertical.Content" xml:space="preserve"> + <value>Vertical only</value> + </data> + <data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Horizontal.Content" xml:space="preserve"> + <value>Horizontal only</value> + </data> + <data name="MouseUtils_GlidingCursor.Header" xml:space="preserve"> + <value>Gliding cursor</value> + </data> + <data name="MouseUtils_GlidingCursor.Description" xml:space="preserve"> + <value>An accessibility feature that lets you control the mouse with a single button using guided horizontal and vertical lines</value> + </data> + <data name="MouseUtils_GlidingCursor_InitialSpeed.Header" xml:space="preserve"> + <value>Initial line speed</value> + </data> + <data name="MouseUtils_GlidingCursor_InitialSpeed.Description" xml:space="preserve"> + <value>Speed of the horizontal or vertical line when it begins moving</value> + </data> + <data name="MouseUtils_GlidingCursor_DelaySpeed.Header" xml:space="preserve"> + <value>Reduced line speed</value> + </data> + <data name="MouseUtils_GlidingCursor_DelaySpeed.Description" xml:space="preserve"> + <value>Speed after slowing down the line with a second shortcut press</value> + </data> <data name="FancyZones_Radio_Custom_Colors.Content" xml:space="preserve"> <value>Custom colors</value> </data> @@ -2819,7 +2817,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Windows default</value> </data> <data name="ColorModeHeader.Header" xml:space="preserve"> - <value>App theme</value> + <value>Theme</value> </data> <data name="FancyZones_Zone_Appearance.Description" xml:space="preserve"> <value>Customize the way zones look</value> @@ -2827,9 +2825,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <data name="FancyZones_Zone_Appearance.Header" xml:space="preserve"> <value>Zone appearance</value> </data> - <data name="LearnMore.Content" xml:space="preserve"> - <value>Learn more</value> - </data> <data name="FileExplorerPreview_ToggleSwitch_Thumbnail_GCODE.Header" xml:space="preserve"> <value>Geometric Code</value> <comment>File type, do not translate</comment> @@ -2870,7 +2865,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Activation</value> </data> <data name="CropAndLock_EnableToggleControl_HeaderText.Header" xml:space="preserve"> - <value>Enable Crop And Lock</value> + <value>Crop And Lock</value> <comment>"Crop And Lock" is the name of the utility</comment> </data> <data name="Shell_CropAndLock.Content" xml:space="preserve"> @@ -2885,13 +2880,19 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Reparent shortcut</value> </data> <data name="CropAndLock_ReparentActivation_Shortcut.Description" xml:space="preserve"> - <value>Shortcut to crop an application's window into a cropped window. This is experimental and can cause issues with some applications, since the cropped window will contain the original application window.</value> + <value>Creates a cropped version of an application’s window by embedding the original app into a new window. This is experimental and may cause issues due to the reparenting behavior.</value> </data> <data name="CropAndLock_ThumbnailActivation_Shortcut.Header" xml:space="preserve"> <value>Thumbnail shortcut</value> </data> <data name="CropAndLock_ThumbnailActivation_Shortcut.Description" xml:space="preserve"> - <value>Shortcut to crop and create a thumbnail of another window. The application isn't controllable through the thumbnail but it'll have less compatibility issues. </value> + <value>Creates a cropped, non-interactive thumbnail of another window. Improves app compatibility.</value> + </data> + <data name="CropAndLock_ScreenshotActivation_Shortcut.Header" xml:space="preserve"> + <value>Screenshot shortcut</value> + </data> + <data name="CropAndLock_ScreenshotActivation_Shortcut.Description" xml:space="preserve"> + <value>Creates a cropped, static screenshot of another window. The screenshot won't update with the original window's content.</value> </data> <data name="CropAndLock.SecondaryLinksHeader" xml:space="preserve"> <value>Attribution</value> @@ -2911,13 +2912,14 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <data name="Oobe_CropAndLock_HowToUse_Reparent.Text" xml:space="preserve"> <value>to crop an application's window into a cropped window. This is experimental and can cause issues with some applications, since the cropped window will contain the original application window.</value> </data> + <data name="Oobe_CropAndLock_HowToUse_Screenshot.Text" xml:space="preserve"> + <value>to crop an application's window into a screenshot window. The screenshot won't update with the original window's content.</value> + </data> <data name="AlwaysOnTop.ModuleDescription" xml:space="preserve"> <value>Always On Top is a quick and easy way to pin windows on top.</value> - <comment>{Locked="Always On Top"}</comment> </data> <data name="AlwaysOnTop.ModuleTitle" xml:space="preserve"> <value>Always On Top</value> - <comment>{Locked}</comment> </data> <data name="AlwaysOnTop_Activation_GroupSettings.Header" xml:space="preserve"> <value>Activation</value> @@ -2926,8 +2928,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Activation</value> </data> <data name="AlwaysOnTop_EnableToggleControl_HeaderText.Header" xml:space="preserve"> - <value>Enable Always On Top</value> - <comment>{Locked="Always On Top"}</comment> + <value>Always On Top</value> </data> <data name="AlwaysOnTop_ExcludedApps.Description" xml:space="preserve"> <value>Excludes an application from pinning on top</value> @@ -2959,15 +2960,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Do not activate when Game Mode is on</value> <comment>Game Mode is a Windows feature</comment> </data> - <data name="AlwaysOnTop_SoundTitle.Header" xml:space="preserve"> - <value>Sound</value> - </data> - <data name="AlwaysOnTop_Sound.Content" xml:space="preserve"> - <value>Play a sound when pinning a window</value> - </data> - <data name="AlwaysOnTop_Behavior.Header" xml:space="preserve"> - <value>Behavior</value> - </data> <data name="LearnMore_AlwaysOnTop.Text" xml:space="preserve"> <value>Learn more about Always On Top</value> <comment>{Locked="Always On Top"}</comment> @@ -2976,7 +2968,19 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Activation shortcut</value> </data> <data name="AlwaysOnTop_ActivationShortcut.Description" xml:space="preserve"> - <value>Customize the shortcut to pin or unpin an app window</value> + <value>Customize the shortcut to pin or unpin an app window and use the same modifier keys with + or − to adjust its transparency</value> + </data> + <data name="AlwaysOnTop_TransparencyInfo.Header" xml:space="preserve"> + <value>Window transparency</value> + </data> + <data name="AlwaysOnTop_TransparencyInfo.Description" xml:space="preserve"> + <value>Adjust the transparency of the focused window on top</value> + </data> + <data name="AlwaysOnTop_IncreaseOpacity" xml:space="preserve"> + <value>Press **{0}** to increase the window opacity</value> + </data> + <data name="AlwaysOnTop_DecreaseOpacity" xml:space="preserve"> + <value>Press **{0}** to decrease the window opacity</value> </data> <data name="Oobe_AlwaysOnTop.Title" xml:space="preserve"> <value>Always On Top</value> @@ -2984,7 +2988,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v </data> <data name="Oobe_AlwaysOnTop.Description" xml:space="preserve"> <value>Always On Top improves your multitasking workflow by pinning an application window so it's always in front - even when focus changes to another window after that.</value> - <comment>{Locked="Always On Top"}</comment> </data> <data name="Oobe_AlwaysOnTop_HowToUse.Text" xml:space="preserve"> <value>to pin or unpin the selected window so it's always on top of all other windows.</value> @@ -3010,12 +3013,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Wrap text</value> <comment>Feature on or off</comment> </data> - <data name="FancyZones_AllowPopupWindowSnap.Description" xml:space="preserve"> - <value>This setting can affect all popup windows including notifications</value> - </data> - <data name="FancyZones_AllowPopupWindowSnap.Header" xml:space="preserve"> - <value>Allow popup windows snapping</value> - </data> <data name="FancyZones_AllowChildWindowSnap.Content" xml:space="preserve"> <value>Allow child windows snapping</value> </data> @@ -3033,17 +3030,17 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Peek is a quick and easy way to preview files. Select a file in File Explorer and press the shortcut to open the file preview.</value> </data> <data name="Peek_EnablePeek.Header" xml:space="preserve"> - <value>Enable Peek</value> + <value>Peek</value> <comment>Peek is a product name, do not loc</comment> </data> <data name="Peek_BehaviorHeader.Header" xml:space="preserve"> <value>Behavior</value> </data> <data name="Peek_AlwaysRunNotElevated.Header" xml:space="preserve"> - <value>Always run not elevated, even when PowerToys is elevated</value> + <value>Always run without elevation (even if PowerToys is elevated)</value> </data> <data name="Peek_AlwaysRunNotElevated.Description" xml:space="preserve"> - <value>Tries to run Peek without elevated permissions, to fix access to network shares. You need to disable and re-enable Peek for changes to this value to take effect.</value> + <value>Runs Peek without admin permissions to improve access to network shares. To apply this change, you must disable and re-enable Peek.</value> <comment>Peek is a product name, do not loc</comment> </data> <data name="Peek_CloseAfterLosingFocus.Header" xml:space="preserve"> @@ -3051,13 +3048,26 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <comment>Peek is a product name, do not loc</comment> </data> <data name="Peek_ConfirmFileDelete.Header" xml:space="preserve"> - <value>Ask for confirmation before deleting files</value> + <value>Confirm before deleting files</value> </data> <data name="Peek_ConfirmFileDelete.Description" xml:space="preserve"> - <value>When enabled, you will be prompted to confirm before moving files to the Recycle Bin.</value> + <value>You'll be asked to confirm before files are moved to the Recycle Bin</value> + </data> + <data name="Peek_ActivationMethod.Header" xml:space="preserve"> + <value>Activation method</value> + </data> + <data name="Peek_ActivationMethod.Description" xml:space="preserve"> + <value>Use a shortcut or press the Spacebar when a file is selected</value> + <comment>Spacebar is a physical keyboard key</comment> + </data> + <data name="Peek_ActivationMethod_CustomizedShortcut.Content" xml:space="preserve"> + <value>Custom shortcut</value> + </data> + <data name="Peek_ActivationMethod_SpaceBar.Content" xml:space="preserve"> + <value>Spacebar</value> </data> <data name="FancyZones_DisableRoundCornersOnWindowSnap.Content" xml:space="preserve"> - <value>Disable round corners when window is snapped</value> + <value>Disable rounded corners when a window is snapped</value> </data> <data name="PowerLauncher_SearchQueryTuningEnabled.Description" xml:space="preserve"> <value>Fine tune results ordering</value> @@ -3090,14 +3100,14 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Global sort order score modifier</value> </data> <data name="AlwaysOnTop_RoundCorners.Content" xml:space="preserve"> - <value>Enable round corners</value> + <value>Enable rounded corners</value> </data> <data name="LearnMore_QuickAccent.Text" xml:space="preserve"> <value>Learn more about Quick Accent</value> <comment>Quick Accent is a product name, do not loc</comment> </data> <data name="QuickAccent_EnableQuickAccent.Header" xml:space="preserve"> - <value>Enable Quick Accent</value> + <value>Quick Accent</value> </data> <data name="Shell_QuickAccent.Content" xml:space="preserve"> <value>Quick Accent</value> @@ -3106,7 +3116,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Workspaces</value> </data> <data name="Workspaces_EnableToggleControl_HeaderText.Header" xml:space="preserve"> - <value>Enable Workspaces</value> + <value>Workspaces</value> <comment>"Workspaces" is the name of the utility</comment> </data> <data name="Workspaces_Activation_GroupSettings.Header" xml:space="preserve"> @@ -3122,7 +3132,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v <value>Create and manage Workspaces</value> </data> <data name="Workspaces_LaunchEditorButtonControl.Header" xml:space="preserve"> - <value>Launch editor</value> + <value>Open editor</value> </data> <data name="LearnMore_Workspaces.Text" xml:space="preserve"> <value>Learn more about the Workspaces utility</value> @@ -3143,32 +3153,35 @@ Activate by holding the key for the character you want to add an accent to, then <data name="AlwaysOnTop_ShortDescription" xml:space="preserve"> <value>Pin a window</value> </data> - <data name="Awake_ShortDescription" xml:space="preserve"> - <value>Keep your PC awake</value> + <data name="LightSwitch_ThemeToggle_Shortcut.Header" xml:space="preserve"> + <value>Theme toggle shortcut</value> + </data> + <data name="LightSwitch_ThemeToggle_Shortcut.Description" xml:space="preserve"> + <value>Switch between light and dark mode</value> + </data> + <data name="LightSwitch_ForceDarkMode" xml:space="preserve"> + <value>Toggle theme</value> </data> <data name="ColorPicker_ShortDescription" xml:space="preserve"> <value>Pick a color</value> </data> <data name="CropAndLock_Thumbnail" xml:space="preserve"> - <value>Thumbnail</value> + <value>Crop and create a thumbnail</value> </data> <data name="CropAndLock_Reparent" xml:space="preserve"> - <value>Reparent</value> + <value>Crop an app's window into a cropped window</value> + </data> + <data name="CropAndLock_Screenshot" xml:space="preserve"> + <value>Crop an app into a screenshot window</value> </data> <data name="FancyZones_OpenEditor" xml:space="preserve"> <value>Open editor</value> </data> - <data name="FileLocksmith_ShortDescription" xml:space="preserve"> - <value>Right-click on files or directories to show running processes</value> - </data> <data name="FindMyMouse_ShortDescription" xml:space="preserve"> <value>Find the mouse</value> </data> - <data name="ImageResizer_ShortDescription" xml:space="preserve"> - <value>Resize images from right-click context menu</value> - </data> <data name="MouseHighlighter_ShortDescription" xml:space="preserve"> - <value>Highlight clicks</value> + <value>Turn on clicks highlighter</value> </data> <data name="MouseJump_ShortDescription" xml:space="preserve"> <value>Quickly move the mouse pointer</value> @@ -3176,49 +3189,33 @@ Activate by holding the key for the character you want to add an accent to, then <data name="MouseCrosshairs_ShortDescription" xml:space="preserve"> <value>Draw crosshairs centered on the mouse pointer</value> </data> - <data name="MouseWithoutBorders_ShortDescription" xml:space="preserve"> - <value>Move your cursor across multiple devices</value> - </data> - <data name="AdvancedPaste_ShortDescription" xml:space="preserve"> - <value>An AI powered tool to put your clipboard content into any format you need, focused towards developer workflows.</value> - </data> <data name="AdvancedPaste_EnableAISettingsCardDescription.Text" xml:space="preserve"> - <value>This feature allows you to format your clipboard content with the power of AI. An OpenAI API key is required.</value> + <value>Transform your clipboard content with the power of AI. A cloud or local endpoint is required.</value> </data> <data name="AdvancedPaste_EnableAISettingsCardDescriptionLearnMore.Content" xml:space="preserve"> <value>Learn more</value> </data> <data name="Peek_ShortDescription" xml:space="preserve"> - <value>Quick and easy previewer</value> - </data> - <data name="PowerRename_ShortDescription" xml:space="preserve"> - <value>Rename files and folders from right-click context menu</value> + <value>Preview file</value> </data> <data name="Run_ShortDescription" xml:space="preserve"> - <value>A quick launcher</value> - </data> - <data name="PowerAccent_ShortDescription" xml:space="preserve"> - <value>An alternative way to type accented characters</value> - </data> - <data name="RegistryPreview_ShortDescription" xml:space="preserve"> - <value>Visualize and edit Windows Registry files</value> - <comment>{Locked="Windows"}</comment> + <value>Open Run</value> </data> <data name="ScreenRuler_ShortDescription" xml:space="preserve"> - <value>Measure pixels on your screen</value> + <value>Start measurement</value> </data> <data name="ShortcutGuide_ShortDescription" xml:space="preserve"> - <value>Show a help overlay with Windows shortcuts</value> + <value>Open Shortcut Guide</value> <comment>{Locked="Windows"}</comment> </data> <data name="PowerOcr_ShortDescription" xml:space="preserve"> - <value>A convenient way to copy text from anywhere on screen</value> + <value>Start text extraction</value> </data> <data name="Dashboard_Activation" xml:space="preserve"> <value>Activation</value> </data> - <data name="DashboardKBMShowMappingsButton.Content" xml:space="preserve"> - <value>Show remappings</value> + <data name="Activate_Zones" xml:space="preserve"> + <value>Activate zones</value> </data> <data name="Oobe_QuickAccent.Description" xml:space="preserve"> <value>Quick Accent is an easy way to write letters with accents, like on a smartphone.</value> @@ -3294,7 +3291,7 @@ Activate by holding the key for the character you want to add an accent to, then <comment>ms = milliseconds</comment> </data> <data name="QuickAccent_InputTimeMs.Description" xml:space="preserve"> - <value>Hold the key down for this much time to make the accent menu appear (ms)</value> + <value>How long a key must be held before the accent menu appears</value> <comment>ms = milliseconds</comment> </data> <data name="QuickAccent_ExcludedApps.Description" xml:space="preserve"> @@ -3309,9 +3306,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="LearnMore_TextExtractor.Text" xml:space="preserve"> <value>Learn more about Text Extractor</value> </data> - <data name="TextExtractor_Cancel" xml:space="preserve"> - <value>cancel</value> - </data> <data name="General_SettingsBackupAndRestore_NothingToBackup" xml:space="preserve"> <value>A new backup was not created because no settings have been changed since last backup.</value> </data> @@ -3346,9 +3340,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="General_SettingsBackupAndRestore_CurrentSettingsUnknown" xml:space="preserve"> <value>Unknown</value> </data> - <data name="General_SettingsBackupAndRestore_NeverRestored" xml:space="preserve"> - <value>Never restored</value> - </data> <data name="General_SettingsBackupAndRestore_NothingToRestore" xml:space="preserve"> <value>Nothing to restore.</value> </data> @@ -3380,7 +3371,7 @@ Activate by holding the key for the character you want to add an accent to, then <value>Text Extractor</value> </data> <data name="TextExtractor_EnableToggleControl_HeaderText.Header" xml:space="preserve"> - <value>Enable Text Extractor</value> + <value>Text Extractor</value> </data> <data name="TextExtractor_SupportedLanguages.Title" xml:space="preserve"> <value>Text Extractor can only recognize languages that have the OCR pack installed.</value> @@ -3394,9 +3385,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="Shell_TextExtractor.Content" xml:space="preserve"> <value>Text Extractor</value> </data> - <data name="Launch_TextExtractor.Content" xml:space="preserve"> - <value>Launch Text Extractor</value> - </data> <data name="Oobe_TextExtractor.Title" xml:space="preserve"> <value>Text Extractor</value> </data> @@ -3443,9 +3431,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="QuickAccent_StartSelectionFromTheLeft_Indicator.Description" xml:space="preserve"> <value>Start selection from the leftmost character for all activation keys, including left and right arrows</value> </data> - <data name="QuickAccent_DisableFullscreen.Header" xml:space="preserve"> - <value>Disable when Game Mode is On</value> - </data> <data name="QuickAccent_Language.Header" xml:space="preserve"> <value>Characters</value> </data> @@ -3527,6 +3512,9 @@ Activate by holding the key for the character you want to add an accent to, then <data name="QuickAccent_SelectedLanguage_Macedonian" xml:space="preserve"> <value>Macedonian</value> </data> + <data name="QuickAccent_SelectedLanguage_Maltese" xml:space="preserve"> + <value>Maltese</value> + </data> <data name="QuickAccent_SelectedLanguage_Maori" xml:space="preserve"> <value>Maori</value> </data> @@ -3566,6 +3554,9 @@ Activate by holding the key for the character you want to add an accent to, then <data name="QuickAccent_SelectedLanguage_Turkish" xml:space="preserve"> <value>Turkish</value> </data> + <data name="QuickAccent_SelectedLanguage_Vietnamese" xml:space="preserve"> + <value>Vietnamese</value> + </data> <data name="QuickAccent_SelectedLanguage_Icelandic" xml:space="preserve"> <value>Icelandic</value> </data> @@ -3603,7 +3594,7 @@ Activate by holding the key for the character you want to add an accent to, then <comment>Products name: Navigation view item name for Hosts File Editor</comment> </data> <data name="Hosts_EnableToggleControl_HeaderText.Header" xml:space="preserve"> - <value>Enable Hosts File Editor</value> + <value>Hosts File Editor</value> <comment>"Hosts File Editor" is the name of the utility</comment> </data> <data name="Hosts_Toggle_ShowStartupWarning.Header" xml:space="preserve"> @@ -3617,15 +3608,11 @@ Activate by holding the key for the character you want to add an accent to, then <comment>"Hosts" refers to the system hosts file, do not loc</comment> </data> <data name="Hosts_LaunchButtonControl.Header" xml:space="preserve"> - <value>Launch Hosts File Editor</value> - <comment>"Hosts File Editor" is a product name</comment> - </data> - <data name="Hosts_LaunchButton_Accessible.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Launch Hosts File Editor</value> + <value>Open Hosts File Editor</value> <comment>"Hosts File Editor" is a product name</comment> </data> <data name="Hosts_AdditionalLinesPosition.Header" xml:space="preserve"> - <value>Position of additional content</value> + <value>Placement of additional content</value> </data> <data name="Hosts_AdditionalLinesPosition_Bottom.Content" xml:space="preserve"> <value>Bottom</value> @@ -3637,7 +3624,7 @@ Activate by holding the key for the character you want to add an accent to, then <value>Behavior</value> </data> <data name="Launch_Hosts.Content" xml:space="preserve"> - <value>Launch Hosts File Editor</value> + <value>Open Hosts File Editor</value> <comment>"Hosts File Editor" is the name of the utility</comment> </data> <data name="LearnMore_Hosts.Text" xml:space="preserve"> @@ -3653,10 +3640,10 @@ Activate by holding the key for the character you want to add an accent to, then <comment>"Hosts File Editor" is the name of the utility</comment> </data> <data name="Hosts_Toggle_LaunchAdministrator.Description" xml:space="preserve"> - <value>Needs to be launched as administrator in order to make changes to the hosts file</value> + <value>Required in order to make changes to the hosts file</value> </data> <data name="Hosts_Toggle_LaunchAdministrator.Header" xml:space="preserve"> - <value>Launch as administrator</value> + <value>Open as administrator</value> </data> <data name="EnvironmentVariables.ModuleDescription" xml:space="preserve"> <value>A quick utility for managing environment variables.</value> @@ -3668,7 +3655,7 @@ Activate by holding the key for the character you want to add an accent to, then <value>Environment Variables</value> </data> <data name="EnvironmentVariables_EnableToggleControl_HeaderText.Header" xml:space="preserve"> - <value>Enable Environment Variables</value> + <value>Environment Variables</value> </data> <data name="EnvironmentVariables_Activation_GroupSettings.Header" xml:space="preserve"> <value>Activation</value> @@ -3677,13 +3664,10 @@ Activate by holding the key for the character you want to add an accent to, then <value>Manage your environment variables</value> </data> <data name="EnvironmentVariables_LaunchButtonControl.Header" xml:space="preserve"> - <value>Launch Environment Variables</value> - </data> - <data name="EnvironmentVariables_LaunchButton_Accessible.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Launch Environment Variables</value> + <value>Open Environment Variables</value> </data> <data name="Launch_EnvironmentVariables.Content" xml:space="preserve"> - <value>Launch Environment Variables</value> + <value>Open Environment Variables</value> </data> <data name="LearnMore_EnvironmentVariables.Text" xml:space="preserve"> <value>Learn more about Environment Variables</value> @@ -3695,10 +3679,10 @@ Activate by holding the key for the character you want to add an accent to, then <value>Environment Variables</value> </data> <data name="EnvironmentVariables_Toggle_LaunchAdministrator.Description" xml:space="preserve"> - <value>Needs to be launched as administrator in order to make changes to the system environment variables</value> + <value>Required in order to make changes to the system environment variables</value> </data> <data name="EnvironmentVariables_Toggle_LaunchAdministrator.Header" xml:space="preserve"> - <value>Launch as administrator</value> + <value>Open as administrator</value> </data> <data name="ShortcutGuide_PressTimeForTaskbarIconShortcuts.Header" xml:space="preserve"> <value>Press duration before showing taskbar icon shortcuts (ms)</value> @@ -3716,7 +3700,7 @@ Activate by holding the key for the character you want to add an accent to, then <comment>Product name: Navigation view item name for FileLocksmith</comment> </data> <data name="FileLocksmith_Enable_FileLocksmith.Header" xml:space="preserve"> - <value>Enable File Locksmith</value> + <value>File Locksmith</value> <comment>File Locksmith is the name of the utility</comment> </data> <data name="FileLocksmith_Toggle_StandardContextMenu.Content" xml:space="preserve"> @@ -3736,7 +3720,7 @@ Activate by holding the key for the character you want to add an accent to, then <value>This setting is managed by your organization.</value> </data> <data name="Hosts_AdditionalLinesPosition.Description" xml:space="preserve"> - <value>Additional content includes the file header and lines that can't parse</value> + <value>Includes items like the file header and any lines that can’t be parsed</value> </data> <data name="TextExtractor_Languages.Header" xml:space="preserve"> <value>Preferred language</value> @@ -3746,7 +3730,6 @@ Activate by holding the key for the character you want to add an accent to, then </data> <data name="Alternate_OOBE_AlwaysOnTop_Title.Text" xml:space="preserve"> <value>Always On Top</value> - <comment>{Locked}</comment> </data> <data name="Alternate_OOBE_ColorPicker_Description.Text" xml:space="preserve"> <value>To pick a color:</value> @@ -3773,7 +3756,7 @@ Activate by holding the key for the character you want to add an accent to, then <value>Experimentation</value> </data> <data name="GeneralPage_EnableExperimentation.Description" xml:space="preserve"> - <value>Note: Only Windows Insider builds may be selected for experimentation</value> + <value>Only Windows Insider builds may be selected for experimentation</value> <comment>{Locked="Windows Insider"}</comment> </data> <data name="GeneralPage_EnableExperimentation.Header" xml:space="preserve"> @@ -3783,50 +3766,32 @@ Activate by holding the key for the character you want to add an accent to, then <value>Diagnostics & feedback</value> </data> <data name="GeneralPage_DiagnosticsAndFeedback_Link.Content" xml:space="preserve"> - <value>Learn more about the information we log & how it gets used</value> + <value>Learn more about the logged information and how it's used</value> </data> <data name="GeneralPage_EnableDataDiagnostics.Header" xml:space="preserve"> <value>Diagnostic data</value> </data> - <data name="GeneralPage_EnableDataDiagnostics.Description" xml:space="preserve"> - <value>Helps inform bug fixes, performance, and improvements</value> - </data> - <data name="GeneralPage_ViewDiagnosticData.Header" xml:space="preserve"> - <value>View diagnostic data</value> - </data> <data name="GeneralPage_EnableViewDiagnosticData.Header" xml:space="preserve"> - <value>Enable viewing</value> - </data> - <data name="GeneralPage_EnableViewDiagnosticData.Description" xml:space="preserve"> - <value>Uses up to 1GB (or more) of hard drive space on your PC</value> - </data> - <data name="GeneralPage_ViewDiagnosticDataViewer.Header" xml:space="preserve"> - <value>Diagnostic data viewer</value> - </data> - <data name="GeneralPage_ViewDiagnosticDataViewer.Description" xml:space="preserve"> - <value>Generate .xml files containing readable diagnostic data. Folder may include .xml and .etl files</value> + <value>Save logs to this device</value> </data> <data name="Shell_AdvancedPaste.Content" xml:space="preserve"> <value>Advanced Paste</value> <comment>Product name: Navigation view item name for Advanced Paste</comment> </data> <data name="AdvancedPaste.ModuleDescription" xml:space="preserve"> - <value>Advanced Paste is a tool to put your clipboard content into any format you need. It can paste as plain text, markdown, or json directly with the UX or with a direct keystroke invoke. These are fully locally executed. In addition, it has an AI powered option that is 100% opt-in and requires an Open AI key.</value> + <value>Formats clipboard content into plain text, Markdown, JSON, and more. Advanced formatting can use an online or local language model endpoint.</value> </data> <data name="AdvancedPaste.ModuleTitle" xml:space="preserve"> <value>Advanced Paste</value> </data> - <data name="AdvancedPaste_Cancel" xml:space="preserve"> - <value>cancel</value> - </data> <data name="AdvancedPaste_EnableToggleControl_HeaderText.Header" xml:space="preserve"> - <value>Enable Advanced Paste</value> + <value>Advanced Paste</value> </data> <data name="AdvancedPaste_EnableAISettingsGroup.Header" xml:space="preserve"> <value>Paste with AI</value> </data> <data name="AdvancedPaste_BehaviorSettingsGroup.Header" xml:space="preserve"> - <value>Behavior</value> + <value>Activation & behavior</value> </data> <data name="AdvancedPaste_ShowCustomPreviewSettingsCard.Header" xml:space="preserve"> <value>Custom format preview</value> @@ -3834,14 +3799,11 @@ Activate by holding the key for the character you want to add an accent to, then <data name="AdvancedPaste_ShowCustomPreviewSettingsCard.Description" xml:space="preserve"> <value>Preview the output of AI formats and Image to text before pasting</value> </data> - <data name="AdvancedPaste_EnableAdvancedAI.Header" xml:space="preserve"> - <value>Enable advanced AI</value> - </data> - <data name="AdvancedPaste_EnableAdvancedAI.Description" xml:space="preserve"> - <value>Add advanced capabilities when using 'Paste with AI' including the power to 'chain' multiple transformations together and work with images and files. This feature may consume more API credits when used.</value> + <data name="AdvancedPaste_EnableAdvancedAI.Text" xml:space="preserve"> + <value>Advanced AI</value> </data> <data name="Oobe_AdvancedPaste.Description" xml:space="preserve"> - <value>Advanced Paste is a tool to put your clipboard content into any format you need, focused towards developer workflows. It can paste as plain text, markdown, or json directly with the UX or with a direct keystroke invoke. These are fully locally executed. In addition, it has an AI powered option that is 100% opt-in and requires an Open AI key. Note: this will replace the formatted text in your clipboard with the selected format.</value> + <value>Advanced Paste is a tool to put your clipboard content into any format you need, focused towards developer workflows. It can paste as plain text, Markdown, or JSON directly with the UX or with a direct keystroke invoke. These are fully locally executed. In addition, it has an opt-in AI feature that can use an online or local language model endpoint. Note: this will replace the formatted text in your clipboard with the selected format.</value> </data> <data name="Oobe_AdvancedPaste.Title" xml:space="preserve"> <value>Advanced Paste</value> @@ -3858,9 +3820,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="Oobe_AdvancedPasteJson_HowToUse.Text" xml:space="preserve"> <value>to paste your clipboard data as JSON. Note: Clipboard content data has to contain XML or CSV text.</value> </data> - <data name="Oobe_AdvancedPasteCustom_HowToUse.Text" xml:space="preserve"> - <value>to open AI-powered dialog to enter query and convert your data to any format you need.</value> - </data> <data name="Shell_CmdNotFound.Content" xml:space="preserve"> <value>Command Not Found</value> <comment>Product name: Navigation view item name for Command Not Found</comment> @@ -3901,9 +3860,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="CmdNotFound_ModuleInstallationLogs.Text" xml:space="preserve"> <value>Installation logs</value> </data> - <data name="CmdNotFound_CheckPowerShellVersionButtonControl.Description" xml:space="preserve"> - <value>PowerShell 7 is required to use this module</value> - </data> <data name="CmdNotFound_CheckCompatibility.Content" xml:space="preserve"> <value>Refresh</value> </data> @@ -3948,10 +3904,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="DocsTooltip.Text" xml:space="preserve"> <value>Documentation</value> </data> - <data name="FZEditorString" xml:space="preserve"> - <value>FancyZones Editor</value> - <comment>Do not localize this string</comment> - </data> <data name="MoreBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> <value>More</value> </data> @@ -3967,9 +3919,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="QuickAccessTxt.Text" xml:space="preserve"> <value>Quick access</value> </data> - <data name="UpdateAvailable.Title" xml:space="preserve"> - <value>Update available</value> - </data> <data name="FileExplorerPreview_Toggle_Monaco_Max_File_Size.Header" xml:space="preserve"> <value>Maximum file size to preview</value> <comment>Size refers to the disk space used by a file</comment> @@ -3990,7 +3939,7 @@ Activate by holding the key for the character you want to add an accent to, then <comment>Product name: Navigation view item name for Registry Preview</comment> </data> <data name="RegistryPreview_Enable_RegistryPreview.Header" xml:space="preserve"> - <value>Enable Registry Preview</value> + <value>Registry Preview</value> <comment>Registry Preview is the name of the utility</comment> </data> <data name="Oobe_RegistryPreview_HowToUse.Text" xml:space="preserve"> @@ -4012,7 +3961,7 @@ Activate by holding the key for the character you want to add an accent to, then <comment>Registry Preview is a product name, do not loc</comment> </data> <data name="Launch_RegistryPreview.Content" xml:space="preserve"> - <value>Launch Registry Preview</value> + <value>Open Registry Preview</value> <comment>"Registry Preview" is the name of the utility</comment> </data> <data name="MouseUtils_MouseJump.Header" xml:space="preserve"> @@ -4024,17 +3973,17 @@ Activate by holding the key for the character you want to add an accent to, then <comment>"Mouse Jump" is the name of the utility. Mouse is the hardware mouse.</comment> </data> <data name="MouseUtils_Enable_MouseJump.Header" xml:space="preserve"> - <value>Enable Mouse Jump</value> + <value>Mouse Jump</value> <comment>"Mouse Jump" is the name of the utility.</comment> </data> <data name="MouseUtils_MouseJump_ActivationShortcut.Description" xml:space="preserve"> - <value>Customize the shortcut to turn on or off this mode</value> + <value>Customize the shortcut used to turn this mode on or off</value> </data> <data name="MouseUtils_MouseJump_ActivationShortcut.Header" xml:space="preserve"> <value>Activation shortcut</value> </data> <data name="MouseUtils_MouseJump_ThumbnailSize.Header" xml:space="preserve"> - <value>Thumbnail Size</value> + <value>Thumbnail size</value> </data> <data name="MouseUtils_MouseJump_ThumbnailSize_Description_Prefix.Text" xml:space="preserve"> <value>Constrain thumbnail image size to a maximum of</value> @@ -4146,9 +4095,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="MouseUtils_MouseJump_CopyToCustomStyle_MessageBox_Title" xml:space="preserve"> <value>Copy to Custom preview style</value> </data> - <data name="MouseUtils_MouseJump_CopyToCustomStyle_MessageBox_Text" xml:space="preserve"> - <value>This will replace the current settings in the Custom preview style.</value> - </data> <data name="MouseUtils_MouseJump_CopyToCustomStyle_MessageBox_PrimaryButtonText" xml:space="preserve"> <value>Copy</value> </data> @@ -4160,20 +4106,13 @@ Activate by holding the key for the character you want to add an accent to, then <value>Consider loopback addresses as duplicates</value> </data> <data name="RegistryPreview_Launch_GroupSettings.Header" xml:space="preserve"> - <value>Launch</value> - </data> - <data name="RegistryPreview_LaunchButton_Accessible.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Launch Registry Preview</value> + <value>Open</value> </data> <data name="RegistryPreview_LaunchButtonControl.Header" xml:space="preserve"> - <value>Launch Registry Preview</value> + <value>Open Registry Preview</value> </data> <data name="RegistryPreview_DefaultRegApp.Header" xml:space="preserve"> - <value>Default app</value> - </data> - <data name="RegistryPreview_DefaultRegApp.Description" xml:space="preserve"> - <value>Make Registry Preview default app for opening .reg files</value> - <comment>Registry Preview is app name. Do not localize.</comment> + <value>Make Registry Preview the default app for .reg files</value> </data> <data name="AdvancedPaste_ShortcutWarning.Title" xml:space="preserve"> <value>Using this shortcut may prevent non-text paste actions (e.g. images, files) or built-in paste plain text actions in other applications from functioning.</value> @@ -4236,7 +4175,7 @@ Activate by holding the key for the character you want to add an accent to, then <comment>Right control is the physical key on the keyboard.</comment> </data> <data name="MouseUtils_FindMyMouse_ActivationShortcut.Description" xml:space="preserve"> - <value>Customize the shortcut to turn on or off this mode</value> + <value>Customize the shortcut used to turn this mode on or off</value> </data> <data name="MouseUtils_FindMyMouse_ActivationShortcut.Header" xml:space="preserve"> <value>Activation shortcut</value> @@ -4246,16 +4185,22 @@ Activate by holding the key for the character you want to add an accent to, then <comment>Title of the settings window when running as administrator</comment> </data> <data name="DashboardTitle.Text" xml:space="preserve"> - <value>Dashboard</value> + <value>Home</value> </data> <data name="Shell_Dashboard.Content" xml:space="preserve"> - <value>Dashboard</value> + <value>Home</value> </data> - <data name="DisabledModules.Text" xml:space="preserve"> - <value>Disabled modules</value> + <data name="Dashboard_SortBy_ToolTip.Text" xml:space="preserve"> + <value>Sort utilities</value> </data> - <data name="EnabledModules.Text" xml:space="preserve"> - <value>Enabled modules</value> + <data name="Dashboard_SortAlphabetical.Text" xml:space="preserve"> + <value>Alphabetically</value> + </data> + <data name="Dashboard_SortByStatus.Text" xml:space="preserve"> + <value>By status</value> + </data> + <data name="Dashboard_SortBy.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Sort utilities</value> </data> <data name="Peek_Preview_GroupSettings.Header" xml:space="preserve"> <value>Preview</value> @@ -4299,6 +4244,12 @@ Activate by holding the key for the character you want to add an accent to, then <data name="GeneralPage_ShowWhatsNewAfterUpdates.Content" xml:space="preserve"> <value>Show the release notes after an update</value> </data> + <data name="ShowSystemTrayIcon.Description" xml:space="preserve"> + <value>To access settings, run the PowerToys executable again</value> + </data> + <data name="ShowSystemTrayIcon.Header" xml:space="preserve"> + <value>Show system tray icon</value> + </data> <data name="QuickAccent_Prevent_Activation_On_Game_Mode.Content" xml:space="preserve"> <value>Do not activate when Game Mode is on</value> <comment>"Game mode" is the Windows feature to prevent notification when playing a game.</comment> @@ -4312,9 +4263,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="GPO_AdvancedPasteAi_SettingIsManaged.Title" xml:space="preserve"> <value>AI features are managed by your organization.</value> </data> - <data name="GPO_SettingIsManaged_ToolTip.Text" xml:space="preserve"> - <value>This setting is managed by your organization.</value> - </data> <data name="GPO_SomePreviewPanesAreManaged.Title" xml:space="preserve"> <value>The enabled state of some preview handlers is managed by your organization.</value> </data> @@ -4330,27 +4278,6 @@ Activate by holding the key for the character you want to add an accent to, then <data name="FileExplorerPreview_ToggleSwitch_Monaco_Minimap.Content" xml:space="preserve"> <value>Show minimap</value> </data> - <data name="PrivacyLink.Text" xml:space="preserve"> - <value>OpenAI Privacy</value> - </data> - <data name="TermsLink.Text" xml:space="preserve"> - <value>OpenAI Terms</value> - </data> - <data name="AdvancedPaste_EnableAIDialog_Description.Text" xml:space="preserve"> - <value>Paste with AI allows you to format your clipboard content into any format you need. Learn more about the terms of conditions while using OpenAI and privacy at Microsoft:</value> - </data> - <data name="AdvancedPaste_EnableAIDialog_LoginIntoText.Text" xml:space="preserve"> - <value>• Login into your</value> - </data> - <data name="AdvancedPaste_EnableAIDialog_ConfigureOpenAIKey.Text" xml:space="preserve"> - <value>Configure OpenAI key</value> - </data> - <data name="AdvancedPaste_EnableAIDialog_OpenAIApiKeysOverviewText.Text" xml:space="preserve"> - <value>OpenAI API keys overview</value> - </data> - <data name="AdvancedPaste_EnableAIDialog_CreateNewKeyText.Text" xml:space="preserve"> - <value>• Create a new secret key and paste it in the field below</value> - </data> <data name="FileExplorerPreview_Toggle_Monaco_Font_Size.Description" xml:space="preserve"> <value>Font size of the editor in pt. Recommended: 14pt</value> <comment>{Locked="pt"}</comment> @@ -4365,15 +4292,21 @@ Activate by holding the key for the character you want to add an accent to, then <data name="Peek_SourceCode_FontSize.Header" xml:space="preserve"> <value>Font size</value> </data> - <data name="AdvancedPaste_EnableAIDialog_NoteAICreditsText.Text" xml:space="preserve"> - <value>• NOTE: You need to have available paid credits in your OpenAI account to use this feature. If you do not have credits you will see an 'API key quota exceeded' error</value> + <data name="AdvancedPaste_CloseAfterLosingFocus.Content" xml:space="preserve"> + <value>Automatically close the window after it loses focus</value> + <comment>Advanced Paste is a product name, do not loc</comment> </data> - <data name="AdvancedPaste_EnableAIDialog_NoteAICreditsErrorText.Text" xml:space="preserve"> - <value>If you do not have credits you will see an 'API key quota exceeded' error</value> + <data name="AdvancedPaste_EnableClipboardPreview.Header" xml:space="preserve"> + <value>Show clipboard preview</value> + <comment>Enables display of clipboard contents preview in the Advanced Paste window</comment> </data> - <data name="AdvancedPaste_CloseAfterLosingFocus.Header" xml:space="preserve"> - <value>Automatically close the AdvancedPaste window after it loses focus</value> - <comment>AdvancedPaste is a product name, do not loc</comment> + <data name="AdvancedPaste_AutoCopySelectionForCustomActionHotkey.Header" xml:space="preserve"> + <value>Auto-copy selection for custom action hotkeys</value> + <comment>Advanced Paste is a product name</comment> + </data> + <data name="AdvancedPaste_AutoCopySelectionForCustomActionHotkey.Description" xml:space="preserve"> + <value>Attempts to copy the current selection before running a custom action shortcut</value> + <comment>Advanced Paste is a product name</comment> </data> <data name="GPO_CommandNotFound_ForceDisabled.Title" xml:space="preserve"> <value>The Command Not Found module is disabled by your organization.</value> @@ -4394,16 +4327,12 @@ Activate by holding the key for the character you want to add an accent to, then <value>New+</value> <comment>New+ is the name of the utility. Localize product name in accordance with Windows New</comment> </data> - <data name="NewPlus_Product_Description.Description" xml:space="preserve"> - <value>Create files and folders from a personalized set of templates</value> - <comment>New+ product description</comment> - </data> <data name="NewPlus_Learn_More.Text" xml:space="preserve"> <value>Learn more about New+</value> <comment>New+ learn more link. Localize product name in accordance with Windows New</comment> </data> <data name="NewPlus_Enable_Toggle.Header" xml:space="preserve"> - <value>Enable New+</value> + <value>New+</value> <comment>Localize product name in accordance with Windows New</comment> </data> <data name="NewPlus_TemplatesNotBackupAndRestoreWarning.Title" xml:space="preserve"> @@ -4417,10 +4346,6 @@ Activate by holding the key for the character you want to add an accent to, then <value>Location</value> <comment>Templates Location label</comment> </data> - <data name="NewPlus_Templates_Location_Path.Text" xml:space="preserve"> - <value>...</value> - <comment>Do not localize</comment> - </data> <data name="NewPlus_Templates_Location_Learn_More.Content" xml:space="preserve"> <value>Learn more about template location</value> <comment>Read more about templates location</comment> @@ -4434,15 +4359,15 @@ Activate by holding the key for the character you want to add an accent to, then <comment>Display options label</comment> </data> <data name="NewPlus_Hide_File_Extension_Toggle.Header" xml:space="preserve"> - <value>Hide template filename extension</value> + <value>Hide the file extension in template names</value> <comment>Template file name extension settings toggle</comment> </data> <data name="NewPlus_Hide_Starting_Digits_Toggle.Header" xml:space="preserve"> - <value>Hide template filename starting digits, spaces, and dots</value> + <value>Hide leading digits, spaces, and dots in template filenames</value> <comment>Template filename starting digits settings toggle</comment> </data> <data name="NewPlus_Hide_Starting_Digits_Description.Text" xml:space="preserve"> - <value>This option is useful when using digits, spaces and dots at the beginning of filenames to control the display order of templates</value> + <value>Ignores digits, spaces, and dots at the start of filenames—useful for sorting templates without showing those characters</value> <comment>Template filename starting digits settings toggle</comment> </data> <data name="NewPlus_behavior.Header" xml:space="preserve"> @@ -4457,10 +4382,6 @@ Activate by holding the key for the character you want to add an accent to, then <value>Learn more about supported variables and see examples</value> <comment>New+ help link to learn more about supported variables and see examples</comment> </data> - <data name="NewPlus_Behaviour_Replace_Variables_Info_Card_Title.Text" xml:space="preserve"> - <value>Commonly used variables</value> - <comment>New+ commonly used variables header in the flyout info card</comment> - </data> <data name="NewPlus_Year_YYYY_Variable_Description.Text" xml:space="preserve"> <value>Year, represented by a full four or five digits, depending on the calendar used.</value> <comment>New+ description of the year $YYYY variable - casing of $YYYY is important</comment> @@ -4509,23 +4430,20 @@ Activate by holding the key for the character you want to add an accent to, then <value>Workspaces</value> </data> <data name="Workspaces_ShortDescription" xml:space="preserve"> - <value>Create and launch Workspaces</value> + <value>Open editor</value> </data> <data name="Shell_ZoomIt.Content" xml:space="preserve"> <value>ZoomIt</value> <comment>{Locked="ZoomIt"}</comment> </data> <data name="ZoomIt_EnableToggleControl_HeaderText.Header" xml:space="preserve"> - <value>Enable ZoomIt</value> + <value>ZoomIt</value> <comment>{Locked="ZoomIt"}</comment> </data> <data name="ZoomIt.ModuleDescription" xml:space="preserve"> <value>ZoomIt is a screen zoom, annotation, and recording tool for technical presentations and demos. You can also use ZoomIt to snip screenshots to the clipboard or to a file.</value> <comment>{Locked="ZoomIt"}</comment> </data> - <data name="ZoomIt_ShortDescription" xml:space="preserve"> - <value>A screen zoom, annotation, and recording tool for technical presentations and demos.</value> - </data> <data name="ZoomIt.ModuleTitle" xml:space="preserve"> <value>ZoomIt</value> <comment>{Locked="ZoomIt"}</comment> @@ -4551,84 +4469,98 @@ Activate by holding the key for the character you want to add an accent to, then <value>Zoom</value> </data> <data name="ZoomIt_ZoomGroup.Description" xml:space="preserve"> - <value>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. - -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.</value> + <value>Zoom in or out to enlarge content and make details clearer.</value> + </data> + <data name="ZoomIt_ZoomFAQ.Text" xml:space="preserve"> + <value>Press **the mouse wheel** or **the Up / Down arrow keys** to zoom in or out. +Press **Esc** or **the right mouse button** to exit zoom mode. +Press **Ctrl + C** to capture the zoomed view, or **Ctrl + S** to save it. +Press **Ctrl + Shift** to crop before copying or saving.</value> </data> <data name="ZoomIt_Zoom_Shortcut.Header" xml:space="preserve"> - <value>Zoom Toggle Hotkey</value> + <value>Zoom activation</value> </data> - <data name="ZoomIt_Toggle_AnimateZoom.Header" xml:space="preserve"> - <value>Animate zoom in and zoom out</value> + <data name="ZoomIt_Toggle_AnimateZoom.Content" xml:space="preserve"> + <value>Animate zoom in and out</value> + </data> + <data name="ZoomIt_Toggle_SmoothZoomedImage.Content" xml:space="preserve"> + <value>Smooth the zoomed image</value> </data> <data name="ZoomIt_Slider_InitialMagnification.Header" xml:space="preserve"> - <value>Specify the initial level of magnification when zooming in</value> + <value>Initial zoom level</value> </data> <data name="ZoomIt_LiveZoomGroup.Header" xml:space="preserve"> <value>Live Zoom</value> </data> <data name="ZoomIt_LiveZoomGroup.Description" xml:space="preserve"> - <value>LiveZoom mode supports window updates to show while zoomed. - -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. - -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. - -To enter and exit LiveZoom, enter the hotkey specified below.</value> + <value>Live Zoom keeps windows updating while zoomed.</value> </data> <data name="ZoomIt_LiveZoom_Shortcut.Header" xml:space="preserve"> - <value>Live Zoom Toggle Hotkey</value> + <value>Live Zoom activation</value> </data> <data name="ZoomIt_DrawGroup.Header" xml:space="preserve"> <value>Draw</value> </data> - <data name="ZoomIt_DrawGroup.Description" xml:space="preserve"> - <value>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. + <data name="ZoomIt_DrawFAQ.Text" xml:space="preserve"> + <value>Press **the left mouse button** to toggle drawing mode when zoomed in, and **the right mouse button** to exit. +Press **Ctrl + Z** to undo, **E** to clear drawings, and **Space** to center the cursor. -Pen Control - Change the pen width by pressing left Ctrl and using the mouse wheel or the up and down arrow keys. +**Pen control** +Press **Ctrl + the mouse wheel** or **Ctrl + Up / Down** to adjust the pen width. -Colors - Change the pen color by pressing R (red), G (green), B (blue), O (orange), Y (yellow) or P (pink). +**Colors** +Press **R** (Red), **G** (Green), **B** (Blue), **O** (Orange), **Y** (Yellow), or **P** (Pink) to switch colors. -Highlight and Blur - Hold Shift while pressing a color key for a translucent highlighter color. Press X for blur or Shift+X for a stronger blur. +**Highlight and blur** +Press **Shift + a color key** for a translucent highlighter, **X** for blur, or **Shift + X** for a stronger blur. -Shapes - 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. +**Shapes** +Press **Shift** for a line, **Ctrl** for a rectangle, **Tab** for an ellipse, or **Shift + Ctrl** for an arrow. -Screen - 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.</value> +**Screen** +Press **W** or **K** for a white or black sketch pad. +Press **Ctrl + C** to copy or **Ctrl + S** to save, and **Ctrl + Shift** to crop. + </value> </data> <data name="ZoomIt_Draw_Shortcut.Header" xml:space="preserve"> - <value>Draw without Zoom Hotkey</value> + <value>Draw without zoom hotkey</value> </data> <data name="ZoomIt_TypeGroup.Header" xml:space="preserve"> <value>Type</value> </data> <data name="ZoomIt_TypeGroup.Description" xml:space="preserve"> - <value>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. - -The text color is the current drawing color.</value> + <value>Type text while drawing</value> </data> <data name="ZoomIt_Type_TextFont.Header" xml:space="preserve"> <value>Text font</value> </data> <data name="ZoomIt_Type_Font_Button.Content" xml:space="preserve"> - <value>Choose Font</value> + <value>Choose font</value> <comment>Font refers to text font</comment> </data> <data name="ZoomIt_DemoTypeGroup.Header" xml:space="preserve"> - <value>Demo Type</value> + <value>DemoType</value> </data> <data name="ZoomIt_DemoTypeGroup.Description" xml:space="preserve"> - <value>Use DemoType to have ZoomIt type text specified in the input file when you enter the DemoType toggle. You can also pull input from the clipboard if it is prefixed with the [start] keyword. + <value>Insert predefined text snippets with a shortcut using a text file.</value> + </data> + <data name="ZoomIt_DemoTypeFAQ" xml:space="preserve"> + <value>Text can be pulled from the clipboard when it starts with **[start]**. +Use **[end]** to separate snippets, **[pause:n]** to insert pauses (in seconds), and **[paste]** / **[/paste]** to send clipboard text. +Use **[enter]**, **[up]**, **[down]**, **[left]**, and **[right]** to issue keystrokes. -Separate snippets with the [end] keyword and insert pauses into the text output with the [pause:n] keyword where 'n' is seconds. Send text via the clipboard with [paste] and [/paste]. Send keystrokes with [enter], [up], [down], [left] and [right]. +ZoomIt can send text automatically or run in manual mode. Keyboard input is blocked while text is being sent. -You can have ZoomIt send text automatically, or select the option to drive input with typing. ZoomIt will block keyboard input while sending output. +In manual mode, press **Space** to unblock keyboard input at the end of a snippet. +In auto mode, control returns automatically after completion. -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. +At the end of the file, ZoomIt reloads the file and restarts from the beginning. +Press the hotkey with **Shift** in the opposite mode to step back to the previous **[end]** marker. -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].</value> +Press **{0}** to reset DemoType and start from the beginning.</value> </data> <data name="ZoomIt_DemoType_Shortcut.Header" xml:space="preserve"> - <value>Demo Type Toggle Hotkey</value> + <value>DemoType activation</value> </data> <data name="ZoomIt_DemoType_File.Header" xml:space="preserve"> <value>Input file</value> @@ -4637,16 +4569,16 @@ When you reach the end of the file, ZoomIt will reload the file and start at the <value>Browse</value> </data> <data name="ZoomIt_DemoType_File_Picker_Dialog_Title" xml:space="preserve"> - <value>Specify DemoType file...</value> + <value>Specify DemoType file..</value> </data> <data name="FilePicker_AllFilesFilter" xml:space="preserve"> <value>All Files</value> </data> - <data name="ZoomIt_DemoType_Toggle_UserDrivenMode.Header" xml:space="preserve"> + <data name="ZoomIt_DemoType_Toggle_UserDrivenMode.Content" xml:space="preserve"> <value>Drive input with typing</value> </data> <data name="ZoomIt_DemoType_SpeedSlider.Header" xml:space="preserve"> - <value>DemoType typing speed</value> + <value>Typing speed</value> </data> <data name="ZoomIt_DemoType_SpeedSlider_Thumbnail_Explanation" xml:space="preserve"> <value>bigger is faster</value> @@ -4655,24 +4587,31 @@ When you reach the end of the file, ZoomIt will reload the file and start at the <value>Break</value> </data> <data name="ZoomIt_BreakGroup.Description" xml:space="preserve"> - <value>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. + <value>Displays a countdown overlay for timed breaks or presentations.</value> + </data> + <data name="ZoomIt_BreakFAQ.Text" xml:space="preserve"> + <value>Enter timer mode from the ZoomIt tray icon’s Break menu. +Press **the arrow keys** to adjust the time. If the timer window loses focus through **Alt + Tab**, press **the left mouse button** on the ZoomIt tray icon to reactivate it. -Change the break timer color using the same keys that the drawing color. The break timer font is the same as text font.</value> +Press **Esc** to exit timer mode. + +Change the break timer color using the same keys as the drawing colors. +The break timer font matches the text font.</value> </data> <data name="ZoomIt_Break_Shortcut.Header" xml:space="preserve"> - <value>Start Break Timer Hotkey</value> + <value>Break timer activation</value> </data> <data name="ZoomIt_Break_Timeout.Header" xml:space="preserve"> <value>Timer (minutes)</value> </data> - <data name="ZoomIt_Break_ShowExpiredTime.Header" xml:space="preserve"> - <value>Show Time Elapsed After Expiration</value> + <data name="ZoomIt_Break_ShowExpiredTime.Content" xml:space="preserve"> + <value>Show time elapsed after expiration</value> </data> - <data name="ZoomIt_Break_PlaySoundsFile.Header" xml:space="preserve"> - <value>Play Sound on Expiration</value> + <data name="ZoomIt_Break_PlaySoundsFile.Content" xml:space="preserve"> + <value>Play sound on expiration</value> </data> <data name="ZoomIt_Break_SoundFile.Header" xml:space="preserve"> - <value>Alarm Sound File</value> + <value>Alarm sound file</value> </data> <data name="ZoomIt_Break_SoundFile_BrowseButton.Content" xml:space="preserve"> <value>Browse</value> @@ -4684,40 +4623,10 @@ Change the break timer color using the same keys that the drawing color. The bre <value>Sounds</value> </data> <data name="ZoomIt_Break_TimerOpacity.Header" xml:space="preserve"> - <value>Timer Opacity</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_10Percent.Content" xml:space="preserve"> - <value>10%</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_20Percent.Content" xml:space="preserve"> - <value>20%</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_30Percent.Content" xml:space="preserve"> - <value>30%</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_40Percent.Content" xml:space="preserve"> - <value>40%</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_50Percent.Content" xml:space="preserve"> - <value>50%</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_60Percent.Content" xml:space="preserve"> - <value>60%</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_70Percent.Content" xml:space="preserve"> - <value>70%</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_80Percent.Content" xml:space="preserve"> - <value>80%</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_90Percent.Content" xml:space="preserve"> - <value>90%</value> - </data> - <data name="ZoomIt_Break_TimerOpacity_100Percent.Content" xml:space="preserve"> - <value>100%</value> + <value>Timer opacity</value> </data> <data name="ZoomIt_Break_TimerPosition.Header" xml:space="preserve"> - <value>Timer Position</value> + <value>Timer position</value> </data> <data name="ZoomIt_Break_TimerPosition_TopLeftCorner.Content" xml:space="preserve"> <value>Top left corner</value> @@ -4747,16 +4656,16 @@ Change the break timer color using the same keys that the drawing color. The bre <value>Bottom right corner</value> </data> <data name="ZoomIt_Break_ShowBackgroundBitmap.Header" xml:space="preserve"> - <value>Show Background Bitmap</value> + <value>Show background bitmap</value> </data> <data name="ZoomIt_Break_ShowFadedDesktop.Content" xml:space="preserve"> - <value>Use faded desktop as background</value> + <value>Faded desktop</value> </data> <data name="ZoomIt_Break_ShowImageFile.Content" xml:space="preserve"> <value>Use image file as background</value> </data> <data name="ZoomIt_Break_BackgroundFile.Header" xml:space="preserve"> - <value>Background Image File</value> + <value>Background image file</value> </data> <data name="ZoomIt_Break_BackgroundFile_BrowseButton.Content" xml:space="preserve"> <value>Browse</value> @@ -4765,31 +4674,42 @@ Change the break timer color using the same keys that the drawing color. The bre <value>Specify background file...</value> </data> <data name="FilePicker_ZoomIt_BitmapFilesFilter" xml:space="preserve"> - <value>Bitmap Files</value> + <value>Bitmap files</value> </data> <data name="FilePicker_ZoomIt_AllPicturesFilter" xml:space="preserve"> - <value>All Picture Files</value> + <value>All picture files</value> </data> - <data name="ZoomIt_Break_BackgroundStretch.Header" xml:space="preserve"> + <data name="ZoomIt_Break_BackgroundStretch.Content" xml:space="preserve"> <value>Scale to screen</value> </data> <data name="ZoomIt_RecordGroup.Header" xml:space="preserve"> <value>Record</value> </data> <data name="ZoomIt_RecordGroup.Description" xml:space="preserve"> - <value>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. - -To crop the portion of the screen that will be recorded, enter the hotkey with the Shift key in the opposite mode. - -To record a specific window, enter the hotkey with the Alt key in the opposite mode.</value> + <value>Record video of the screen.</value> </data> <data name="ZoomIt_Record_Shortcut.Header" xml:space="preserve"> - <value>Record Toggle Hotkey</value> + <value>Record activation</value> + </data> + <data name="ZoomIt_Record_Shortcut_FullScreen" xml:space="preserve"> + <value>Press **{0}** to start or stop screen or zoom recording</value> + </data> + <data name="ZoomIt_Record_Shortcut_Crop" xml:space="preserve"> + <value>Press **{0}** to record a portion of the screen</value> + </data> + <data name="ZoomIt_Record_Shortcut_Window" xml:space="preserve"> + <value>Press **{0}** to record a specific window</value> </data> <data name="ZoomIt_Record_Scaling.Header" xml:space="preserve"> <value>Scaling</value> </data> - <data name="ZoomIt_Record_CaptureAudio.Header" xml:space="preserve"> + <data name="ZoomIt_Record_Format.Header" xml:space="preserve"> + <value>Format</value> + </data> + <data name="ZoomIt_Record_CaptureSystemAudio.Content" xml:space="preserve"> + <value>Capture system audio</value> + </data> + <data name="ZoomIt_Record_CaptureAudio.Content" xml:space="preserve"> <value>Capture audio input</value> </data> <data name="ZoomIt_Record_Microphone.Header" xml:space="preserve"> @@ -4802,10 +4722,13 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m <value>Snip</value> </data> <data name="ZoomIt_SnipGroup.Description" xml:space="preserve"> - <value>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.</value> + <value>Copy a selected area of the screen to the clipboard or to a file.</value> </data> <data name="ZoomIt_Snip_Shortcut.Header" xml:space="preserve"> - <value>Snip Toggle Hotkey</value> + <value>Snip activation</value> + </data> + <data name="ZoomIt_Snip_Shortcut_Save" xml:space="preserve"> + <value>Press **{0}** to save the snip to a file instead of the clipboard.</value> </data> <data name="Oobe_ZoomIt.Description" xml:space="preserve"> <value>ZoomIt is a screen zoom, annotation, and recording tool for technical presentations and demos. You can also use ZoomIt to snip screenshots to the clipboard or to a file.</value> @@ -4825,24 +4748,21 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m <data name="MouseWithoutBorders_PolicyIPAddressMappingInfo_TextBoxControl.Description" xml:space="preserve"> <value>You cannot change, remove or disable these enforced rules.</value> </data> - <data name="OpenSettings.Content" xml:space="preserve"> - <value>Open settings</value> + <data name="OpenAnimationsSettings.Content" xml:space="preserve"> + <value>Open animation settings</value> </data> <data name="LanguageHeader.Header" xml:space="preserve"> - <value>Application language</value> + <value>Language</value> </data> <data name="LanguageHeader.Description" xml:space="preserve"> - <value>PowerToys will use OS language by default.</value> + <value>PowerToys matches your Windows language by default</value> </data> <data name="LanguageRestartInfo.Title" xml:space="preserve"> - <value>Restart PowerToys for language change to take effect.</value> + <value>Restart PowerToys to apply language changes</value> </data> <data name="LanguageRestartInfoButton.Content" xml:space="preserve"> <value>Restart</value> </data> - <data name="Default_Language" xml:space="preserve"> - <value>Windows default</value> - </data> <data name="Arabic_Saudi_Arabia_Language" xml:space="preserve"> <value>Arabic (Saudi Arabia)</value> </data> @@ -4922,30 +4842,17 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m <value>File Management</value> </data> <data name="Shell_TopLevelInputOutput.Content" xml:space="preserve"> - <value>Input / Output</value> - </data> - <data name="Shell_TopLevelWindowsAndLayouts.Content" xml:space="preserve"> - <value>Windowing & Layouts</value> + <value>Input & Output</value> </data> <data name="Shell_TopLevelSystemTools.Content" xml:space="preserve"> <value>System Tools</value> </data> - <data name="CmdPal.ModuleTitle" xml:space="preserve"> - <value>Command Palette</value> - </data> - <data name="CmdPal_ShortDescription" xml:space="preserve"> - <value>A better quick launcher</value> - </data> <data name="CmdPal_Enable_CmdPal.Header" xml:space="preserve"> - <value>Enable Command Palette</value> - <comment>"Command Palette" is the name of the utility.</comment> - </data> - <data name="CmdPal.ModuleDescription" xml:space="preserve"> - <value>A fully extensible quick launcher with a richer display and additional capabilities without sacrificing performance.</value> + <value>Command Palette</value> + <comment>Command Palette is a product name, do not loc</comment> </data> <data name="LearnMore_CmdPal.Text" xml:space="preserve"> <value>Learn more about Command Palette</value> - <comment>Command Palette is a product name, do not loc</comment> </data> <data name="Shell_CmdPal.Content" xml:space="preserve"> <value>Command Palette</value> @@ -4953,11 +4860,11 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m </data> <data name="Oobe_CmdPal.Description" xml:space="preserve"> <value>A fully extensible quick launcher with a richer display and additional capabilities without sacrificing performance.</value> - <comment>"Command Palette" is a product name</comment> + <comment>Command Palette is a product name, do not loc</comment> </data> <data name="Oobe_CmdPal.Title" xml:space="preserve"> <value>Command Palette</value> - <comment>"Command Palette" is a product name</comment> + <comment>Command Palette is a product name, do not loc</comment> </data> <data name="Oobe_CmdPal_HowToUse.Text" xml:space="preserve"> <value>and start typing!</value> @@ -4971,9 +4878,6 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m <data name="Run_NavigateCmdPalSettings.Content" xml:space="preserve"> <value>Learn more</value> </data> - <data name="Enable_Module.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> - <value>Enable module</value> - </data> <data name="PowerLauncher_PluginVersion.Text" xml:space="preserve"> <value>Version</value> </data> @@ -4990,13 +4894,913 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m <data name="RetryLabel.Text" xml:space="preserve"> <value>Retry</value> </data> - <data name="CmdPal_Activation_GroupSettings.Header" xml:space="preserve"> - <value>Activation</value> + <data name="CmdPal_Settings.Header" xml:space="preserve"> + <value>Settings</value> </data> - <data name="CmdPal_ActivationShortcut.Header" xml:space="preserve"> + <data name="Help_lightnessOklab" xml:space="preserve"> + <value>lightness (Oklab/Oklch)</value> + </data> + <data name="Help_chromaticityAOklab" xml:space="preserve"> + <value>chromaticity A (Oklab)</value> + </data> + <data name="Help_chromaticityBOklab" xml:space="preserve"> + <value>chromaticity B (Oklab)</value> + </data> + <data name="Help_chromaOklch" xml:space="preserve"> + <value>chroma (Oklch)</value> + </data> + <data name="Help_hueOklch" xml:space="preserve"> + <value>hue (Oklch)</value> + </data> + <data name="GeneralPage_ReportBugPackage.Header" xml:space="preserve"> + <value>Generate bug report package</value> + </data> + <data name="GeneralPage_ReportBugPackage.Description" xml:space="preserve"> + <value>Log files will be zipped to your desktop</value> + </data> + <data name="GeneralPageReportBugPackage.Content" xml:space="preserve"> + <value>Generate package</value> + </data> + <data name="FileExplorerPreview_ToggleSwitch_Preview_BGCODE.Header" xml:space="preserve"> + <value>Binary Geometric Code</value> + </data> + <data name="FileExplorerPreview_ToggleSwitch_Preview_BGCODE.Description" xml:space="preserve"> + <value>.bgcode</value> + </data> + <data name="FileExplorerPreview_ToggleSwitch_Thumbnail_BGCODE.Description" xml:space="preserve"> + <value>.bgcode</value> + </data> + <data name="FileExplorerPreview_ToggleSwitch_Thumbnail_BGCODE.Header" xml:space="preserve"> + <value>Binary Geometric Code</value> + </data> + <data name="YoureUpToDate.Text" xml:space="preserve"> + <value>You're up to date</value> + </data> + <data name="UpdateAvailableTextBlock.Text" xml:space="preserve"> + <value>Update Available</value> + </data> + <data name="GeneralVersion.Text" xml:space="preserve"> + <value>Version</value> + </data> + <data name="LearnWhatsNew.Text" xml:space="preserve"> + <value>Learn what's new</value> + </data> + <data name="ConfigureShortcut" xml:space="preserve"> + <value>Configure shortcut</value> + </data> + <data name="ConfigureShortcutText.Text" xml:space="preserve"> + <value>Assign shortcut</value> + </data> + <data name="QuickAccessTitle.Title" xml:space="preserve"> + <value>Quick access</value> + </data> + <data name="ShortcutsOverview.Title" xml:space="preserve"> + <value>Shortcuts</value> + </data> + <data name="NoActionsToShow.Text" xml:space="preserve"> + <value>No actions to show..</value> + </data> + <data name="NoShortcutsToShow.Text" xml:space="preserve"> + <value>No shortcuts to show..</value> + </data> + <data name="HighlightMode.Description" xml:space="preserve"> + <value>Highlight the cursor or dim the screen to spotlight it</value> + </data> + <data name="HighlightMode.Header" xml:space="preserve"> + <value>Highlight mode</value> + </data> + <data name="HighlightMode_Circle_Highlight_Mode.Content" xml:space="preserve"> + <value>Circle highlight</value> + </data> + <data name="HighlightMode_Spotlight_Mode.Content" xml:space="preserve"> + <value>Spotlight</value> + </data> + <data name="GeneralPage_EnableDataDiagnosticsText.Text" xml:space="preserve"> + <value>Helps us make PowerToys faster, more stable, and better over time</value> + </data> + <data name="AlwaysOnTop_Sound.Header" xml:space="preserve"> + <value>Play a sound when pinning a window</value> + </data> + <data name="GeneralPage_EnableViewDiagnosticDataText.Text" xml:space="preserve"> + <value>Stores diagnostic data locally in .xml format; folder may include .etl files as well. May use up 1 GB or more of disk space.</value> + </data> + <data name="Shell_LightSwitch.Content" xml:space="preserve"> + <value>Light Switch</value> + </data> + <data name="LightSwitch.ModuleDescription" xml:space="preserve"> + <value>Easily switch between light and dark mode - on a schedule, automatically, or with a shortcut.</value> + </data> + <data name="LightSwitch.ModuleTitle" xml:space="preserve"> + <value>Light Switch</value> + </data> + <data name="LearnMore_LightSwitch.Text" xml:space="preserve"> + <value>Learn more about Light Switch</value> + </data> + <data name="LightSwitch_BehaviorSettingsGroup.Header" xml:space="preserve"> + <value>Behavior</value> + </data> + <data name="LightSwitch_EnableSettingsCard.Header" xml:space="preserve"> + <value>Light Switch</value> + </data> + <data name="LightSwitch_ShortcutsSettingsGroup.Header" xml:space="preserve"> + <value>Shortcuts</value> + </data> + <data name="LightSwitch_ScheduleSettingsGroup.Header" xml:space="preserve"> + <value>Schedule</value> + </data> + <data name="LightSwitch_ModeSettingsExpander.Header" xml:space="preserve"> + <value>Mode</value> + </data> + <data name="LightSwitch_ModeSettingsExpander.Description" xml:space="preserve"> + <value>Determine when dark mode should be turned on</value> + </data> + <data name="LightSwitch_ModeOff.Content" xml:space="preserve"> + <value>Off</value> + </data> + <data name="LightSwitch_ModeManual.Content" xml:space="preserve"> + <value>Fixed hours</value> + </data> + <data name="LightSwitch_ModeSunsetToSunrise.Content" xml:space="preserve"> + <value>Sunset to sunrise</value> + </data> + <data name="LightSwitch_ScheduleOffMessage.Title" xml:space="preserve"> + <value>Scheduling is turned off.</value> + </data> + <data name="LightSwitch_TurnOnDarkMode.Header" xml:space="preserve"> + <value>Turn on dark mode</value> + </data> + <data name="LightSwitch_TurnOffDarkMode.Header" xml:space="preserve"> + <value>Turn off dark mode</value> + </data> + <data name="LightSwitch_LocationSettingsCard.Header" xml:space="preserve"> + <value>Location</value> + </data> + <data name="LightSwitch_LocationSettingsCard.Description" xml:space="preserve"> + <value>Used to automatically calculate accurate sunrise and sunset times</value> + </data> + <data name="LightSwitch_OffsetSettingsCard.Header" xml:space="preserve"> + <value>Offset (in minutes)</value> + </data> + <data name="LightSwitch_OffsetSettingsCard.Description" xml:space="preserve"> + <value>Adjust the trigger time by starting earlier or later</value> + </data> + <data name="LightSwitch_LocationWarningBar.Title" xml:space="preserve"> + <value>Location required</value> + </data> + <data name="LightSwitch_LocationWarningBar.Message" xml:space="preserve"> + <value>Sync your location so Light Switch can calculate the correct sunrise- and sunset times</value> + </data> + <data name="LightSwitch_ApplyDarkModeExpander.Header" xml:space="preserve"> + <value>Apply dark mode to</value> + </data> + <data name="LightSwitch_ApplyDarkModeExpander.Description" xml:space="preserve"> + <value>Pick which parts of your PC should follow Light Switch</value> + </data> + <data name="LightSwitch_SystemCheckbox.Header" xml:space="preserve"> + <value>System</value> + </data> + <data name="LightSwitch_SystemCheckbox.Description" xml:space="preserve"> + <value>Taskbar, Start, and other system UI</value> + </data> + <data name="LightSwitch_AppsCheckbox.Header" xml:space="preserve"> + <value>Apps</value> + </data> + <data name="LightSwitch_AppsCheckbox.Description" xml:space="preserve"> + <value>Supported applications</value> + </data> + <data name="LightSwitch_PowerDisplayDisabledWarningBar.Title" xml:space="preserve"> + <value>Enable PowerDisplay module to use automatic monitor profile switching</value> + </data> + <data name="LightSwitch_PowerDisplayDisabledWarningBar.Message" xml:space="preserve"> + <value /> + </data> + <data name="LightSwitch_ApplyMonitorSettingsExpander.Header" xml:space="preserve"> + <value>Apply monitor settings to</value> + </data> + <data name="LightSwitch_ApplyMonitorSettingsExpander.Description" xml:space="preserve"> + <value>Automatically switch PowerDisplay profiles when theme changes</value> + </data> + <data name="LightSwitch_DarkModeProfileCheckbox.Content" xml:space="preserve"> + <value>Dark mode profile</value> + </data> + <data name="LightSwitch_LightModeProfileCheckbox.Content" xml:space="preserve"> + <value>Light mode profile</value> + </data> + <data name="LightSwitch_NavigatePowerDisplaySettings.Content" xml:space="preserve"> + <value>Open PowerDisplay settings</value> + </data> + <data name="LightSwitch_LocationDialog.Title" xml:space="preserve"> + <value>Select a location</value> + </data> + <data name="LightSwitch_LocationDialog.PrimaryButtonText" xml:space="preserve"> + <value>Save</value> + </data> + <data name="LightSwitch_LocationDialog.SecondaryButtonText" xml:space="preserve"> + <value>Cancel</value> + </data> + <data name="LightSwitch_LocationDialog_Description.Text" xml:space="preserve"> + <value>Detect your location automatically or enter it manually to calculate sunrise and sunset times.</value> + </data> + <data name="LightSwitch_SunriseText.Text" xml:space="preserve"> + <value>Sunrise</value> + </data> + <data name="LightSwitch_SunsetText.Text" xml:space="preserve"> + <value>Sunset</value> + </data> + <data name="LightSwitch_LatitudeBox.Header" xml:space="preserve"> + <value>Latitude</value> + </data> + <data name="LightSwitch_LongitudeBox.Header" xml:space="preserve"> + <value>Longitude</value> + </data> + <data name="LightSwitch_FindLocation.Text" xml:space="preserve"> + <value>Detect location</value> + </data> + <data name="LightSwitch_FindLocationAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Detect location</value> + </data> + <data name="LightSwitch_SunriseTooltip.Text" xml:space="preserve"> + <value>Sunrise</value> + </data> + <data name="LightSwitch_SunsetTooltip.Text" xml:space="preserve"> + <value>Sunset</value> + </data> + <data name="Close_NavViewItem.Content" xml:space="preserve"> + <value>Close PowerToys</value> + <comment>Don't loc "PowerToys"</comment> + </data> + <data name="CloseDialog.Content" xml:space="preserve"> + <value>Closing PowerToys will stop all active utilities.</value> + <comment>Don't loc "PowerToys"</comment> + </data> + <data name="CloseDialog.Title" xml:space="preserve"> + <value>Are you sure?</value> + </data> + <data name="CloseDialog.PrimaryButtonText" xml:space="preserve"> + <value>Yes</value> + </data> + <data name="CloseDialog.SecondaryButtonText" xml:space="preserve"> + <value>No</value> + </data> + <data name="SearchResults_Title.ModuleTitle" xml:space="preserve"> + <value>Search results</value> + </data> + <data name="SearchResults_ModulesTitle.Header" xml:space="preserve"> + <value>Modules</value> + </data> + <data name="SearchResults_NoResultsHeader.Text" xml:space="preserve"> + <value>No results</value> + </data> + <data name="SearchResults_NoResultsDescription.Text" xml:space="preserve"> + <value>Try a different search term</value> + </data> + <data name="InAppHotkeyConflictTooltipText" xml:space="preserve"> + <value>This shortcut is already in use by another utility.</value> + </data> + <data name="SysHotkeyConflictTooltipText" xml:space="preserve"> + <value>This shortcut is already in use by a default system shortcut.</value> + </data> + <data name="ShortcutConflictWindow_Title" xml:space="preserve"> + <value>PowerToys shortcut conflicts</value> + </data> + <data name="ShortcutConflictWindow_TitleTxt.Title" xml:space="preserve"> + <value>PowerToys shortcut conflicts</value> + </data> + <data name="ShortcutConflictWindow_Description.Text" xml:space="preserve"> + <value>If any shortcut conflicts are detected, they’ll appear below. Conflicts can happen between PowerToys utilities or Windows system shortcuts, and may cause unexpected behavior. If everything works as expected, you can safely ignore the conflict.</value> + </data> + <data name="ShortcutConflictWindow_ModulesUsingShortcut.Text" xml:space="preserve"> + <value>Conflicts found for</value> + </data> + <data name="ShortcutConflictWindow_SystemCard.Header" xml:space="preserve"> + <value>System shortcut</value> + </data> + <data name="ShortcutConflictWindow_SystemCard.Description" xml:space="preserve"> + <value>This shortcut is reserved by Windows and can't be reassigned.</value> + </data> + <data name="ShortcutConflictWindow_SystemShortcutLink.Content" xml:space="preserve"> + <value>See all Windows shortcuts</value> + </data> + <data name="ShortcutConflictWindow_NoConflictsTitle.Text" xml:space="preserve"> + <value>No conflicts detected</value> + </data> + <data name="ShortcutConflictWindow_NoConflictsDescription.Text" xml:space="preserve"> + <value>All shortcuts function correctly</value> + </data> + <data name="ResolveConflicts_Button.Content" xml:space="preserve"> + <value>Resolve conflicts</value> + </data> + <data name="ShortcutConflictControl_Title.Text" xml:space="preserve"> + <value>Shortcut conflicts</value> + </data> + <data name="ShortcutConflictControl_Automation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Shortcut conflicts</value> + </data> + <data name="ShortcutConflictControl_NoConflictsFound" xml:space="preserve"> + <value>No conflicts found</value> + </data> + <data name="ShortcutConflictControl_SingleConflictFound" xml:space="preserve"> + <value>1 conflict found</value> + </data> + <data name="ShortcutConflictControl_MultipleConflictsFound" xml:space="preserve"> + <value>{0} conflicts found</value> + <comment>{0} is replaced with the number of conflicts</comment> + </data> + <data name="Hosts_NoLeadingSpaces.Header" xml:space="preserve"> + <value>No leading spaces</value> + </data> + <data name="Hosts_NoLeadingSpaces.Description" xml:space="preserve"> + <value>Do not prepend spaces to active lines when saving the hosts file</value> + </data> + <data name="Search_ResultsFor" xml:space="preserve"> + <value>Results for</value> + <comment>Prefix for search string. E.g. "Results for 'shortcut'"</comment> + </data> + <data name="UtilitiesHeader.Title" xml:space="preserve"> + <value>Utilities</value> + </data> + <data name="Oobe_LightSwitch.Title" xml:space="preserve"> + <value>Light Switch</value> + <comment>Product name. Do not localize this string</comment> + </data> + <data name="Oobe_LightSwitch.Description" xml:space="preserve"> + <value>Light Switch automatically manages your Windows light and dark mode based on schedules, sunrise/sunset times, or manual control. Keep your system theme synchronized with your preferences and daily rhythm.</value> + <comment>Light Switch is a product name, do not localize</comment> + </data> + <data name="Oobe_LightSwitch_HowToUse.Text" xml:space="preserve"> + <value>Open **PowerToys Settings** and enable Light Switch to set up automatic theme switching</value> + <comment>Light Switch is a product name, do not localize</comment> + </data> + <data name="Oobe_LightSwitch_TipsAndTricks.Text" xml:space="preserve"> + <value>Use the **keyboard shortcut** to instantly toggle between light and dark modes, or set up **sunrise/sunset automation** for natural theme transitions.</value> + <comment>Light Switch is a product name, do not localize</comment> + </data> + <data name="Shortcut_ResetBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Reset shortcut</value> + </data> + <data name="Shortcut_ResetToolTip.Text" xml:space="preserve"> + <value>Reset to the default shortcut</value> + </data> + <data name="Shortcut_Reset.Text" xml:space="preserve"> + <value>Reset</value> + </data> + <data name="Shortcut_ClearBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Clear shortcut</value> + </data> + <data name="Shortcut_ClearToolTip.Text" xml:space="preserve"> + <value>Clear and unassign this shortcut</value> + </data> + <data name="Shortcut_Clear.Text" xml:space="preserve"> + <value>Clear</value> + </data> + <data name="Shortcut_Conflict_LearnMore.Content" xml:space="preserve"> + <value>Learn more</value> + </data> + <data name="PowerDisplay.ModuleTitle" xml:space="preserve"> + <value>Power Display</value> + </data> + <data name="PowerDisplay.ModuleDescription" xml:space="preserve"> + <value>A display management utility for brightness and power control</value> + </data> + <data name="PowerDisplay_Enable_PowerDisplay.Header" xml:space="preserve"> + <value>Enable Power Display</value> + </data> + <data name="PowerDisplay_Configuration_GroupSettings.Header" xml:space="preserve"> + <value>Configuration</value> + </data> + <data name="PowerDisplay_ToggleWindow" xml:space="preserve"> + <value>Toggle Power Display</value> + <comment>Dashboard: Label for the PowerDisplay activation hotkey</comment> + </data> + <data name="PowerDisplay_ActivationShortcut.Header" xml:space="preserve"> + <value>Activation shortcut</value> + <comment>Header for the PowerDisplay activation shortcut setting</comment> + </data> + <data name="PowerDisplay_LaunchButtonControl.Header" xml:space="preserve"> + <value>Open Power Display</value> + </data> + <data name="PowerDisplay_LaunchButtonControl.Description" xml:space="preserve"> + <value>Launch the Power Display utility</value> + </data> + <data name="PowerDisplay_RestoreSettingsOnStartup.Header" xml:space="preserve"> + <value>Restore settings on startup</value> + </data> + <data name="PowerDisplay_RestoreSettingsOnStartup.Description" xml:space="preserve"> + <value>Restore monitor brightness and color temperature when Power Display launches</value> + </data> + <data name="PowerDisplay_RestoreSettingsOnStartup_ToggleSwitch.OnContent" xml:space="preserve"> + <value>On</value> + </data> + <data name="PowerDisplay_RestoreSettingsOnStartup_ToggleSwitch.OffContent" xml:space="preserve"> + <value>Off</value> + </data> + <data name="PowerDisplay_ShowSystemTrayIcon.Header" xml:space="preserve"> + <value>Show system tray icon</value> + </data> + <data name="PowerDisplay_ShowSystemTrayIcon.Description" xml:space="preserve"> + <value>Choose if PowerDisplay is visible in the system tray</value> + </data> + <data name="PowerDisplay_MonitorRefreshDelay.Header" xml:space="preserve"> + <value>Monitor refresh delay</value> + </data> + <data name="PowerDisplay_MonitorRefreshDelay.Description" xml:space="preserve"> + <value>Number of seconds to wait after display changes before refreshing monitors. Increase if monitors are not detected after hot-plug.</value> + </data> + <data name="PowerDisplay_Profiles_GroupSettings.Header" xml:space="preserve"> + <value>Profiles</value> + </data> + <data name="PowerDisplay_QuickProfiles.Header" xml:space="preserve"> + <value>Quick apply profiles</value> + </data> + <data name="PowerDisplay_QuickProfiles.Description" xml:space="preserve"> + <value>Click a profile button to quickly apply saved monitor settings</value> + </data> + <data name="PowerDisplay_AddProfile_Text.Text" xml:space="preserve"> + <value>Add profile</value> + </data> + <data name="PowerDisplay_Monitors.Header" xml:space="preserve"> + <value>Monitors</value> + </data> + <data name="PowerDisplay_NoMonitorsDetected.Text" xml:space="preserve"> + <value>No monitors detected. Ensure your external monitors are connected and powered on.</value> + </data> + <data name="LearnMore_PowerDisplay.Text" xml:space="preserve"> + <value>Learn more about Power Display</value> + </data> + <data name="Oobe_PowerDisplay.Title" xml:space="preserve"> + <value>Power Display</value> + </data> + <data name="Oobe_PowerDisplay.Description" xml:space="preserve"> + <value>Power Display provides unified control over display settings across multiple monitors. Adjust brightness, contrast, volume, color temperature, and input source for all connected displays from a single overlay. Supports DDC/CI for external monitors and WMI for laptop displays, with profile support for quick configuration switching and LightSwitch integration for automatic theme-based adjustments.</value> + </data> + <data name="Oobe_PowerDisplay_Activation.Text" xml:space="preserve"> + <value>to open the Power Display overlay.</value> + </data> + <data name="Oobe_PowerDisplay_TipsAndTricks.Text" xml:space="preserve"> + <value>**Create profiles** to save your preferred display settings, then configure them in **LightSwitch** to automatically switch profiles when the system theme changes.</value> + </data> + <data name="Launch_PowerDisplay.Content" xml:space="preserve"> + <value>Open Power Display</value> + </data> + <data name="PowerDisplay_Monitor_EnableContrast.Content" xml:space="preserve"> + <value>Display contrast slider</value> + </data> + <data name="PowerDisplay_Monitor_EnableVolume.Content" xml:space="preserve"> + <value>Display volume slider</value> + </data> + <data name="PowerDisplay_Monitor_EnableInputSource.Content" xml:space="preserve"> + <value>Show input source control</value> + </data> + <data name="PowerDisplay_Monitor_EnableRotation.Content" xml:space="preserve"> + <value>Show rotation control</value> + </data> + <data name="PowerDisplay_Monitor_EnableColorTemperature.Content" xml:space="preserve"> + <value>Show color temperature switcher</value> + </data> + <data name="PowerDisplay_Monitor_EnablePowerState.Content" xml:space="preserve"> + <value>Show power state control</value> + </data> + <data name="PowerDisplay_Monitor_HideMonitor.Content" xml:space="preserve"> + <value>Hide monitor</value> + </data> + <data name="PowerDisplay_ColorTemperature_WarningTitle" xml:space="preserve"> + <value>Confirm Color Temperature Change</value> + </data> + <data name="PowerDisplay_ColorTemperature_WarningHeader" xml:space="preserve"> + <value>⚠️ Warning: This is a potentially dangerous operation!</value> + </data> + <data name="PowerDisplay_ColorTemperature_WarningDescription" xml:space="preserve"> + <value>Changing the color temperature setting may cause unpredictable results including:</value> + </data> + <data name="PowerDisplay_ColorTemperature_WarningList" xml:space="preserve"> + <value>• Incorrect display colors +• Display malfunction +• Settings that cannot be reverted</value> + </data> + <data name="PowerDisplay_ColorTemperature_WarningConfirm" xml:space="preserve"> + <value>Do you want to enable color temperature control for this monitor?</value> + </data> + <data name="PowerDisplay_ColorTemperature_EnableButton" xml:space="preserve"> + <value>Enable</value> + </data> + <data name="PowerDisplay_Dialog_Cancel" xml:space="preserve"> + <value>Cancel</value> + </data> + <data name="PowerDisplay_Dialog_Save" xml:space="preserve"> + <value>Save</value> + </data> + <data name="PowerDisplay_ProfileEditor_Title" xml:space="preserve"> + <value>Edit Profile</value> + </data> + <data name="PowerDisplay_ProfileEditor_ProfileName.Header" xml:space="preserve"> + <value>Profile name</value> + </data> + <data name="PowerDisplay_ProfileEditor_ProfileName.PlaceholderText" xml:space="preserve"> + <value>Enter profile name (e.g., 'Gaming Mode', 'Work')</value> + </data> + <data name="PowerDisplay_ProfileEditor_Description.Text" xml:space="preserve"> + <value>Select what monitors and settings to include for this profile</value> + </data> + <data name="PowerDisplay_ProfileEditor_Brightness.Text" xml:space="preserve"> + <value>Brightness</value> + </data> + <data name="PowerDisplay_ProfileEditor_Contrast.Text" xml:space="preserve"> + <value>Contrast</value> + </data> + <data name="PowerDisplay_ProfileEditor_Volume.Text" xml:space="preserve"> + <value>Volume</value> + </data> + <data name="PowerDisplay_ProfileEditor_ColorTemperature.Text" xml:space="preserve"> + <value>Color temperature</value> + </data> + <data name="PowerDisplay_ProfileEditor_ColorTemperature_ComboBox.PlaceholderText" xml:space="preserve"> + <value>Select..</value> + </data> + <data name="PowerDisplay_Profile_DefaultBaseName" xml:space="preserve"> + <value>Profile</value> + </data> + <data name="PowerDisplay_DeleteProfile_Title" xml:space="preserve"> + <value>Delete Profile</value> + </data> + <data name="PowerDisplay_DeleteProfile_Content" xml:space="preserve"> + <value>Are you sure you want to delete '{0}'?</value> + </data> + <data name="PowerDisplay_DeleteProfile_PrimaryButton" xml:space="preserve"> + <value>Delete</value> + </data> + <data name="PowerDisplay_Profile_ApplyButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Apply profile</value> + </data> + <data name="PowerDisplay_Profile_MoreButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>More profile options</value> + </data> + <data name="PowerDisplay_Profile_EditMenuItem.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Edit profile</value> + </data> + <data name="PowerDisplay_Profile_DeleteMenuItem.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Delete profile</value> + </data> + <data name="PowerDisplay_Profile_ApplyButton.Content" xml:space="preserve"> + <value>Apply</value> + </data> + <data name="PowerDisplay_Profile_MoreButton.ToolTipService.ToolTip" xml:space="preserve"> + <value>More settings</value> + </data> + <data name="PowerDisplay_Profile_EditMenuItem.Text" xml:space="preserve"> + <value>Edit</value> + </data> + <data name="PowerDisplay_Profile_DeleteMenuItem.Text" xml:space="preserve"> + <value>Delete</value> + </data> + <data name="PowerDisplay_AddProfileButton.ToolTipService.ToolTip" xml:space="preserve"> + <value>Save current settings as new profile</value> + </data> + <data name="PowerDisplay_Monitor_CapabilitiesWarning.Title" xml:space="preserve"> + <value>Monitor capabilities unavailable</value> + </data> + <data name="PowerDisplay_Monitor_CapabilitiesWarning.Message" xml:space="preserve"> + <value>This monitor did not report DDC/CI capabilities. Advanced controls may be limited.</value> + </data> + <data name="PowerDisplay_Monitor_VcpCapabilities.Header" xml:space="preserve"> + <value>VCP capabilities</value> + </data> + <data name="PowerDisplay_Monitor_VcpCapabilities.Description" xml:space="preserve"> + <value>DDC/CI VCP codes and supported values (for debugging purposes)</value> + </data> + <data name="PowerDisplay_Monitor_VcpDetails_Button.ToolTipService.ToolTip" xml:space="preserve"> + <value>View VCP details</value> + </data> + <data name="PowerDisplay_Monitor_VcpDetails_Button.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>View VCP details</value> + </data> + <data name="PowerDisplay_Monitor_VcpCodes_Header.Text" xml:space="preserve"> + <value>Detected VCP Codes</value> + </data> + <data name="PowerDisplay_Monitor_VcpCodes_CopyButton.ToolTipService.ToolTip" xml:space="preserve"> + <value>Copy all VCP codes to clipboard</value> + </data> + <data name="PowerDisplay_Monitor_VcpCodes_CopyButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve"> + <value>Copy VCP codes to clipboard</value> + </data> + <data name="PowerDisplay_Monitor_VcpCodes_CopyText.Text" xml:space="preserve"> + <value>Copy</value> + </data> + <data name="PowerDisplay_FlyoutOptions_GroupSettings.Header" xml:space="preserve"> + <value>Flyout options</value> + </data> + <data name="PowerDisplay_ShowProfileSwitcher.Header" xml:space="preserve"> + <value>Show profile switcher</value> + </data> + <data name="PowerDisplay_ShowProfileSwitcher.Description" xml:space="preserve"> + <value>Show or hide the profile switcher button in the Power Display flyout</value> + </data> + <data name="PowerDisplay_ShowIdentifyMonitorsButton.Header" xml:space="preserve"> + <value>Show identify monitors button</value> + </data> + <data name="PowerDisplay_ShowIdentifyMonitorsButton.Description" xml:space="preserve"> + <value>Show or hide the identify monitors button in the Power Display flyout</value> + </data> + <data name="PowerDisplay_CustomVcpMappings_GroupSettings.Header" xml:space="preserve"> + <value>Custom VCP Name Mappings</value> + </data> + <data name="PowerDisplay_CustomVcpMappings.Header" xml:space="preserve"> + <value>Custom name mappings</value> + </data> + <data name="PowerDisplay_CustomVcpMappings.Description" xml:space="preserve"> + <value>Define custom display names for color temperature presets and input sources</value> + </data> + <data name="PowerDisplay_AddCustomMappingButton.ToolTipService.ToolTip" xml:space="preserve"> + <value>Add custom mapping</value> + </data> + <data name="PowerDisplay_AddCustomMapping_Text.Text" xml:space="preserve"> + <value>Add mapping</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_Title" xml:space="preserve"> + <value>Custom VCP Name Mapping</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_VcpCode.Header" xml:space="preserve"> + <value>VCP Code</value> + </data> + <data name="PowerDisplay_VcpCode_Name_0x14" xml:space="preserve"> + <value>Color Temperature</value> + </data> + <data name="PowerDisplay_VcpCode_Name_0x60" xml:space="preserve"> + <value>Input Source</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_CustomName.Header" xml:space="preserve"> + <value>Custom Name</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_CustomName.PlaceholderText" xml:space="preserve"> + <value>Enter custom name</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_ValueComboBox.Header" xml:space="preserve"> + <value>Value</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_CustomValueOption" xml:space="preserve"> + <value>Custom value...</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_CustomValueInput.Header" xml:space="preserve"> + <value>Enter custom value (hex)</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_CustomValueInput.PlaceholderText" xml:space="preserve"> + <value>e.g., 0x11 or 17</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_ApplyToAll.Header" xml:space="preserve"> + <value>Apply to all monitors</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_ApplyToAll.OnContent" xml:space="preserve"> + <value>On</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_ApplyToAll.OffContent" xml:space="preserve"> + <value>Off</value> + </data> + <data name="PowerDisplay_CustomMappingEditor_SelectMonitor.Header" xml:space="preserve"> + <value>Select monitor</value> + </data> + <data name="PowerDisplay_CustomMapping_Delete_Title" xml:space="preserve"> + <value>Delete custom mapping?</value> + </data> + <data name="PowerDisplay_CustomMapping_Delete_Message" xml:space="preserve"> + <value>This custom name mapping will be permanently removed.</value> + </data> + <data name="Hosts_Backup_GroupSettings.Header" xml:space="preserve"> + <value>Backup</value> + </data> + <data name="Hosts_Backup.Header" xml:space="preserve"> + <value>Backup hosts file</value> + <comment>"Hosts" refers to the system hosts file, do not loc</comment> + </data> + <data name="Hosts_Backup.Description" xml:space="preserve"> + <value>Automatically create a backup of the hosts file when you save for the first time in a session</value> + <comment>"Hosts" refers to the system hosts file, do not loc</comment> + </data> + <data name="Hosts_Backup_Location.Header" xml:space="preserve"> + <value>Location</value> + </data> + <data name="Hosts_ButtonSelectLocation.Text" xml:space="preserve"> + <value>Select location</value> + </data> + <data name="Hosts_Delete_Backup.Header" xml:space="preserve"> + <value>Automatically delete backups</value> + </data> + <data name="Hosts_Backup_DaysInput.Header" xml:space="preserve"> + <value>Days</value> + </data> + <data name="Hosts_Backup_CountInput_Description" xml:space="preserve"> + <value>Set the number of backups to keep. Older backups will be deleted once the limit is reached.</value> + </data> + <data name="Hosts_Backup_DaysInput.Description" xml:space="preserve"> + <value>Set the number of days to keep backups. Older backups will be deleted once the limit is reached.</value> + </data> + <data name="Hosts_DeleteBackupMode_Never.Content" xml:space="preserve"> + <value>Never</value> + </data> + <data name="Hosts_DeleteBackupMode_CountBased.Content" xml:space="preserve"> + <value>Based on count</value> + </data> + <data name="Hosts_DeleteBackupMode_AgeAndCountBased.Content" xml:space="preserve"> + <value>Based on age and count</value> + </data> + <data name="Hosts_Backup_CountInput_Age_Description" xml:space="preserve"> + <value>Set an optional number of backups to always keep despite their age</value> + </data> + <data name="Hosts_Backup_CountInput_Header" xml:space="preserve"> + <value>Backup count</value> + </data> + <data name="LightSwitch_SetLocationButton.Content" xml:space="preserve"> + <value>Set Location</value> + </data> + <data name="AdvancedPaste_FL_OpenFoundryModelList.Content" xml:space="preserve"> + <value>Open Foundry Local model list</value> + <comment>Do not localize "Foundry Local", it's a product name</comment> + </data> + <data name="AdvancedPaste_FL_RunFoundryLocalText.Text" xml:space="preserve"> + <value>Run Foundry Local to download or add a local model</value> + <comment>Do not localize "Foundry Local", it's a product name</comment> + </data> + <data name="AdvancedPaste_FL_NoModelsDownloaded.Text" xml:space="preserve"> + <value>No models downloaded</value> + </data> + <data name="AdvancedPaste_FL_LoadingStatus.Text" xml:space="preserve"> + <value>Loading Foundry Local status..</value> + <comment>Do not localize "Foundry Local", it's a product name</comment> + </data> + <data name="AdvancedPaste_FL_LocalModel.Text" xml:space="preserve"> + <value>Foundry Local model</value> + <comment>Do not localize "Foundry Local", it's a product name</comment> + </data> + <data name="AdvancedPaste_FL_UseCliToDownloadModels.Text" xml:space="preserve"> + <value>Use the Foundry Local CLI to download models that run locally on-device. They'll appear here.</value> + <comment>Do not localize "Foundry Local", it's a product name</comment> + </data> + <data name="AdvancedPaste_FL_RefreshModelList.Text" xml:space="preserve"> + <value>Refresh model list</value> + </data> + <data name="AdvancedPaste_FL_FLNotAvailableYet.Text" xml:space="preserve"> + <value>Foundry Local is not available on this device yet.</value> + <comment>Do not localize "Foundry Local", it's a product name</comment> + </data> + <data name="AdvancedPaste_FL_StartService.Text" xml:space="preserve"> + <value>Start the Foundry Local service before returning to PowerToys.</value> + </data> + <data name="AdvancedPaste_FL_CLIGuide.Content" xml:space="preserve"> + <value>Follow the Foundry Local CLI guide</value> + <comment>Do not localize "Foundry Local", it's a product name</comment> + </data> + <data name="AdvancedPaste_ModelProviders.Header" xml:space="preserve"> + <value>Model providers</value> + </data> + <data name="AdvancedPaste_ModelProviders.Description" xml:space="preserve"> + <value>Add online or local models</value> + </data> + <data name="AdvancedPaste_Edit.Text" xml:space="preserve"> + <value>Edit</value> + </data> + <data name="AdvancedPaste_Remove.Text" xml:space="preserve"> + <value>Remove</value> + </data> + <data name="AdvancedPaste_ModelName.Header" xml:space="preserve"> + <value>Model name</value> + </data> + <data name="AdvancedPaste_EndpointURL.Header" xml:space="preserve"> + <value>Endpoint URL</value> + </data> + <data name="AdvancedPaste_APIKey.Header" xml:space="preserve"> + <value>API key</value> + </data> + <data name="AdvancedPaste_APIKey.PlaceholderText" xml:space="preserve"> + <value>Enter API key</value> + </data> + <data name="AdvancedPaste_APIVersion.Header" xml:space="preserve"> + <value>API version</value> + </data> + <data name="AdvancedPaste_DeploymentName.Header" xml:space="preserve"> + <value>Deployment name</value> + </data> + <data name="AdvancedPaste_SystemPrompt.Header" xml:space="preserve"> + <value>System prompt</value> + </data> + <data name="AdvancedPaste_EndpointDialog.PrimaryButtonText" xml:space="preserve"> + <value>Save</value> + </data> + <data name="AdvancedPaste_EndpointDialog.SecondaryButtonText" xml:space="preserve"> + <value>Cancel</value> + </data> + <data name="AdvancedPaste_EnableClipboardPreview.Description" xml:space="preserve"> + <value>Display a preview of the current clipboard content</value> + </data> + <data name="AdvancedPaste_FL_LearnMoreFoundryLocal.Content" xml:space="preserve"> + <value>Learn more</value> + </data> + <data name="AdvancedPaste_FL_PreviewMessage.Message" xml:space="preserve"> + <value>Foundry Local is still in public preview</value> + <comment>Do not loc "Foundry Local"</comment> + </data> + <data name="CmdPal_Settings.Description" xml:space="preserve"> + <value>Configure the activation shortcut, extensions, behavior and much more</value> + </data> + <data name="CmdPal_Launch.Header" xml:space="preserve"> + <value>Open Command Palette</value> + <comment>Command Palette is a product name, do not loc</comment> + </data> + <data name="CmdPal_Description.Text" xml:space="preserve"> + <value>Find files, launch apps, and do so much more with the most extensible quick launcher.</value> + </data> + <data name="CmdPal.ModuleTitle" xml:space="preserve"> + <value>Command Palette</value> + <comment>Command Palette is a product name, do not loc</comment> + </data> + <data name="CmdPal_ActivationDescription" xml:space="preserve"> + <value>Open Command Palette</value> + <comment>Command Palette is a product name, do not loc</comment> + </data> + <data name="CmdPal_ExtensibleDescription.Text" xml:space="preserve"> + <value>Powerful extensions help you do more</value> + </data> + <data name="CmdPal_ExtensibleHeader.Text" xml:space="preserve"> + <value>Extensible</value> + </data> + <data name="CmdPal_FastDescription.Text" xml:space="preserve"> + <value>Find files and launch apps in an instant</value> + </data> + <data name="CmdPal_FastHeader.Text" xml:space="preserve"> + <value>Fast</value> + </data> + <data name="CmdPal_ModernHeader.Text" xml:space="preserve"> + <value>Beautiful</value> + </data> + <data name="CmdPal_ModernDescription.Text" xml:space="preserve"> + <value>A modern UI built with Fluent Design</value> + <comment>Fluent Design is a product name, do not loc</comment> + </data> + <data name="ZoomIt_LiveZoom_Shortcut_Draw" xml:space="preserve"> + <value>Press **{0}** to activate live drawing and **Esc** to clear annotations or to exit. + +Press **Ctrl + Up / Down** to adjust the zoom level. + </value> + </data> + <data name="ZoomIt_TypeFAQ.Text" xml:space="preserve"> + <value>Press **T** to switch to typing when drawing mode is active, and **Shift** for right-aligned text. +Press **Esc** or **the left mouse button** to exit typing mode. +Press **the mouse wheel** or **Up / Down** to adjust the font size. +Text uses the current drawing color.</value> + </data> + <data name="ZoomIt_DrawGroup.Description" xml:space="preserve"> + <value>Annotate the screen.</value> + </data> + <data name="ZoomIt_Break_BackgroundImage_None.Content" xml:space="preserve"> + <value>None</value> + </data> + <data name="ShowThemeAdaptiveTrayIcon.Content" xml:space="preserve"> + <value>Show a monochrome icon that matches the Windows theme</value> + </data> + <data name="GeneralPage_EnableQuickAccess.Header" xml:space="preserve"> + <value>Quick Access flyout</value> + </data> + <data name="GeneralPage_EnableQuickAccess.Description" xml:space="preserve"> + <value>Fast access to quick actions and module toggles</value> + </data> + <data name="GeneralPage_QuickAccessShortcut.Header" xml:space="preserve"> <value>Activation shortcut</value> </data> - <data name="CmdPal_ActivationShortcut.Description" xml:space="preserve"> - <value>Go to Command Palette settings to customize the activation shortcut.</value> + <data name="LightSwitch_ModeFollowNightLight.Content" xml:space="preserve"> + <value>Follow Night Light</value> + </data> + <data name="LightSwitch_NightLightSettingsButton.Content" xml:space="preserve"> + <value>Personalize your Night Light settings.</value> + </data> + <data name="LightSwitch_FollowNightLightCardMessage.Text" xml:space="preserve"> + <value>Following Night Light settings.</value> + </data> + <data name="Shell_TopLevelWindowsAndLayouts.Content" xml:space="preserve"> + <value>Windowing & Layouts</value> + </data> + <data name="OOBE_NavViewItem.Content" xml:space="preserve"> + <value>Welcome to PowerToys</value> + </data> + <data name="ShortcutConflictWindow_IgnoreShortcut.Content" xml:space="preserve"> + <value>Ignore shortcut</value> + </data> + <data name="AdvancedPaste_AddModelButton.Content" xml:space="preserve"> + <value>Add model</value> + </data> + <data name="AdvancedPaste_EndpointDialog.Title" xml:space="preserve"> + <value>Paste with AI provider configuration</value> + </data> + <data name="AdvancedPaste_ModelPath.Header" xml:space="preserve"> + <value>Model path</value> + </data> + <data name="CmdPal_HeroTitle.Text" xml:space="preserve"> + <value>Command Palette</value> + <comment>Command Palette is a product name and should not be localized</comment> + </data> + <data name="GeneralPage_ViewDiagnosticDataButton.Content" xml:space="preserve"> + <value>View diagnostic data</value> + </data> + <data name="Shell_Search_ShowAll.Text" xml:space="preserve"> + <value>Show all results</value> + </data> + <data name="ZoomIt_Type_DemoSample.Text" xml:space="preserve"> + <value>Sample</value> </data> </root> \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs index ae75fe5bf6..ad75c72d10 100644 --- a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs @@ -8,40 +8,44 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Globalization; +using System.IO.Abstractions; using System.Linq; -using System.Reflection; +using System.Runtime.Versioning; using System.Text.Json; -using System.Text.Json.Serialization; -using System.Timers; using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.UI.Dispatching; using Microsoft.Win32; using Windows.Security.Credentials; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class AdvancedPasteViewModel : Observable, IDisposable + public partial class AdvancedPasteViewModel : PageViewModelBase { private static readonly HashSet<string> WarnHotkeys = ["Ctrl + V", "Ctrl + Shift + V"]; - private bool disposedValue; + private bool _disposed; + private PasteAIProviderDefinition _pasteAIProviderDraft; + private PasteAIProviderDefinition _editingPasteAIProvider; - // Delay saving of settings in order to avoid calling save multiple times and hitting file in use exception. If there is no other request to save settings in given interval, we proceed to save it, otherwise we schedule saving it after this interval - private const int SaveSettingsDelayInMs = 500; + protected override string ModuleName => AdvancedPasteSettings.ModuleName; private GeneralSettings GeneralSettingsConfig { get; set; } - private readonly ISettingsUtils _settingsUtils; - private readonly System.Threading.Lock _delayedActionLock = new System.Threading.Lock(); + private readonly SettingsUtils _settingsUtils; private readonly AdvancedPasteSettings _advancedPasteSettings; private readonly AdvancedPasteAdditionalActions _additionalActions; private readonly ObservableCollection<AdvancedPasteCustomAction> _customActions; - private Timer _delayedTimer; + private readonly DispatcherQueue _dispatcherQueue; + private IFileSystemWatcher _settingsWatcher; + private bool _suppressSave; private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _enabledStateIsGPOConfigured; @@ -51,8 +55,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func<string, int> SendConfigMSG { get; } + private static readonly HashSet<string> CustomActionNonPersistedProperties = new(StringComparer.Ordinal) + { + nameof(AdvancedPasteCustomAction.CanMoveUp), + nameof(AdvancedPasteCustomAction.CanMoveDown), + nameof(AdvancedPasteCustomAction.IsValid), + nameof(AdvancedPasteCustomAction.HasConflict), + nameof(AdvancedPasteCustomAction.Tooltip), + nameof(AdvancedPasteCustomAction.SubActions), + }; + public AdvancedPasteViewModel( - ISettingsUtils settingsUtils, + SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<AdvancedPasteSettings> advancedPasteSettingsRepository, Func<string, int> ipcMSGCallBackFunc) @@ -62,27 +76,39 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels GeneralSettingsConfig = settingsRepository.SettingsConfig; - // To obtain the settings configurations of Fancy zones. - ArgumentNullException.ThrowIfNull(settingsRepository); + // To obtain the settings configurations of Advanced Paste. + ArgumentNullException.ThrowIfNull(advancedPasteSettingsRepository); + + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); - ArgumentNullException.ThrowIfNull(advancedPasteSettingsRepository); + _advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig ?? throw new ArgumentException("SettingsConfig cannot be null", nameof(advancedPasteSettingsRepository)); - _advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig; + if (_advancedPasteSettings.Properties is null) + { + throw new ArgumentException("AdvancedPasteSettings.Properties cannot be null", nameof(advancedPasteSettingsRepository)); + } - _additionalActions = _advancedPasteSettings.Properties.AdditionalActions; - _customActions = _advancedPasteSettings.Properties.CustomActions.Value; + // Ensure AdditionalActions and CustomActions are initialized to prevent null reference exceptions + // This handles legacy settings files that may be missing these properties + _advancedPasteSettings.Properties.AdditionalActions ??= new AdvancedPasteAdditionalActions(); + _advancedPasteSettings.Properties.CustomActions ??= new AdvancedPasteCustomActions(); - InitializeEnabledValue(); + AttachConfigurationHandlers(); // set the callback functions value to handle outgoing IPC message. SendConfigMSG = ipcMSGCallBackFunc; - _delayedTimer = new Timer(); - _delayedTimer.Interval = SaveSettingsDelayInMs; - _delayedTimer.Elapsed += DelayedTimer_Tick; - _delayedTimer.AutoReset = false; + _additionalActions = _advancedPasteSettings.Properties.AdditionalActions; + _customActions = _advancedPasteSettings.Properties.CustomActions.Value ?? new ObservableCollection<AdvancedPasteCustomAction>(); + + SetupSettingsFileWatcher(); + + InitializePasteAIProviderState(); + + InitializeEnabledValue(); + MigrateLegacyAIEnablement(); foreach (var action in _additionalActions.GetAllActions()) { @@ -98,6 +124,36 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels UpdateCustomActionsCanMoveUpDown(); } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeySettings = new List<HotkeySettings> + { + PasteAsPlainTextShortcut, + AdvancedPasteUIShortcut, + PasteAsMarkdownShortcut, + PasteAsJsonShortcut, + }; + + foreach (var action in _additionalActions.GetAllActions()) + { + if (action is AdvancedPasteAdditionalAction additionalAction) + { + hotkeySettings.Add(additionalAction.Shortcut); + } + } + + // Custom actions do not have localization header, just use the action name. + foreach (var customAction in _customActions) + { + hotkeySettings.Add(customAction.Shortcut); + } + + return new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = hotkeySettings.ToArray(), + }; + } + private void InitializeEnabledValue() { _enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredAdvancedPasteEnabledValue(); @@ -113,15 +169,99 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } _onlineAIModelsGpoRuleConfiguration = GPOWrapper.GetAllowedAdvancedPasteOnlineAIModelsValue(); - if (_onlineAIModelsGpoRuleConfiguration == GpoRuleConfigured.Disabled) - { - _onlineAIModelsDisallowedByGPO = true; + _onlineAIModelsDisallowedByGPO = _onlineAIModelsGpoRuleConfiguration == GpoRuleConfigured.Disabled; + if (_onlineAIModelsDisallowedByGPO) + { // disable AI if it was enabled DisableAI(); } } + private void MigrateLegacyAIEnablement() + { + var properties = _advancedPasteSettings?.Properties; + if (properties is null) + { + return; + } + + bool legacyAdvancedAIConsumed = properties.TryConsumeLegacyAdvancedAIEnabled(out var advancedFlag); + bool legacyAdvancedAIEnabled = legacyAdvancedAIConsumed && advancedFlag; + + if (IsOnlineAIModelsDisallowedByGPO) + { + if (legacyAdvancedAIConsumed) + { + SaveAndNotifySettings(); + } + + return; + } + + PasswordCredential legacyCredential = TryGetLegacyOpenAICredential(); + + if (legacyCredential is null) + { + if (legacyAdvancedAIConsumed) + { + SaveAndNotifySettings(); + } + + return; + } + + var configuration = properties.PasteAIConfiguration; + if (configuration is null) + { + configuration = new PasteAIConfiguration(); + properties.PasteAIConfiguration = configuration; + } + + bool configurationUpdated = false; + + var ensureResult = AdvancedPasteMigrationHelper.EnsureOpenAIProvider(configuration); + PasteAIProviderDefinition openAIProvider = ensureResult.Provider; + configurationUpdated |= ensureResult.Updated; + + if (legacyAdvancedAIConsumed && openAIProvider is not null && openAIProvider.EnableAdvancedAI != legacyAdvancedAIEnabled) + { + openAIProvider.EnableAdvancedAI = legacyAdvancedAIEnabled; + configurationUpdated = true; + } + + if (legacyCredential is not null && openAIProvider is not null) + { + SavePasteAIApiKey(openAIProvider.Id, openAIProvider.ServiceType, legacyCredential.Password); + RemoveLegacyOpenAICredential(); + } + + const bool shouldEnableAI = true; + bool enabledChanged = false; + if (properties.IsAIEnabled != shouldEnableAI) + { + properties.IsAIEnabled = shouldEnableAI; + enabledChanged = true; + } + + bool shouldPersist = configurationUpdated || enabledChanged || legacyAdvancedAIConsumed; + + if (shouldPersist) + { + SaveAndNotifySettings(); + + if (configurationUpdated) + { + OnPropertyChanged(nameof(PasteAIConfiguration)); + } + + if (enabledChanged) + { + OnPropertyChanged(nameof(IsAIEnabled)); + } + } + } + public bool IsEnabled { get => _isEnabled; @@ -153,24 +293,43 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions; - private bool OpenAIKeyExists() - { - PasswordVault vault = new PasswordVault(); - PasswordCredential cred = null; + public static IEnumerable<AIServiceTypeMetadata> AvailableProviders => AIServiceTypeRegistry.GetAvailableServiceTypes(); + /// <summary> + /// Gets available AI providers filtered by GPO policies. + /// Only returns providers that are not explicitly disabled by GPO. + /// </summary> + public IEnumerable<AIServiceTypeMetadata> AvailableProvidersFilteredByGPO => + AvailableProviders.Where(metadata => IsServiceTypeAllowedByGPO(metadata.ServiceType)); + + public bool IsAIEnabled => _advancedPasteSettings.Properties.IsAIEnabled && !IsOnlineAIModelsDisallowedByGPO; + + private PasswordCredential TryGetLegacyOpenAICredential() + { try { - cred = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + PasswordVault vault = new(); + var credential = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + credential?.RetrievePassword(); + return credential; } catch (Exception) { - return false; + return null; } - - return cred is not null; } - public bool IsOpenAIEnabled => OpenAIKeyExists() && !IsOnlineAIModelsDisallowedByGPO; + private void RemoveLegacyOpenAICredential() + { + try + { + PasswordVault vault = new(); + TryRemoveCredential(vault, "https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); + } + catch (Exception) + { + } + } public bool IsEnabledGpoConfigured { @@ -264,9 +423,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_advancedPasteSettings.Properties.AdvancedPasteUIShortcut != value) { _advancedPasteSettings.Properties.AdvancedPasteUIShortcut = value ?? AdvancedPasteProperties.DefaultAdvancedPasteUIShortcut; - OnPropertyChanged(nameof(AdvancedPasteUIShortcut)); OnPropertyChanged(nameof(IsConflictingCopyShortcut)); - + OnPropertyChanged(nameof(AdvancedPasteUIShortcut)); SaveAndNotifySettings(); } } @@ -280,9 +438,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_advancedPasteSettings.Properties.PasteAsPlainTextShortcut != value) { _advancedPasteSettings.Properties.PasteAsPlainTextShortcut = value ?? AdvancedPasteProperties.DefaultPasteAsPlainTextShortcut; - OnPropertyChanged(nameof(PasteAsPlainTextShortcut)); OnPropertyChanged(nameof(IsConflictingCopyShortcut)); - + OnPropertyChanged(nameof(PasteAsPlainTextShortcut)); SaveAndNotifySettings(); } } @@ -296,9 +453,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_advancedPasteSettings.Properties.PasteAsMarkdownShortcut != value) { _advancedPasteSettings.Properties.PasteAsMarkdownShortcut = value ?? new HotkeySettings(); - OnPropertyChanged(nameof(PasteAsMarkdownShortcut)); OnPropertyChanged(nameof(IsConflictingCopyShortcut)); - + OnPropertyChanged(nameof(PasteAsMarkdownShortcut)); SaveAndNotifySettings(); } } @@ -312,28 +468,68 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_advancedPasteSettings.Properties.PasteAsJsonShortcut != value) { _advancedPasteSettings.Properties.PasteAsJsonShortcut = value ?? new HotkeySettings(); - OnPropertyChanged(nameof(PasteAsJsonShortcut)); OnPropertyChanged(nameof(IsConflictingCopyShortcut)); - + OnPropertyChanged(nameof(PasteAsJsonShortcut)); SaveAndNotifySettings(); } } } - public bool IsAdvancedAIEnabled + public PasteAIConfiguration PasteAIConfiguration { - get => _advancedPasteSettings.Properties.IsAdvancedAIEnabled; + get + { + // Ensure PasteAIConfiguration is never null for XAML binding + _advancedPasteSettings.Properties.PasteAIConfiguration ??= new PasteAIConfiguration(); + return _advancedPasteSettings.Properties.PasteAIConfiguration; + } + set { - if (value != _advancedPasteSettings.Properties.IsAdvancedAIEnabled) + if (!ReferenceEquals(value, _advancedPasteSettings.Properties.PasteAIConfiguration)) { - _advancedPasteSettings.Properties.IsAdvancedAIEnabled = value; - OnPropertyChanged(nameof(IsAdvancedAIEnabled)); - NotifySettingsChanged(); + UnsubscribeFromPasteAIConfiguration(_advancedPasteSettings.Properties.PasteAIConfiguration); + + var newValue = value ?? new PasteAIConfiguration(); + _advancedPasteSettings.Properties.PasteAIConfiguration = newValue; + SubscribeToPasteAIConfiguration(newValue); + + OnPropertyChanged(nameof(PasteAIConfiguration)); + SaveAndNotifySettings(); } } } + public PasteAIProviderDefinition PasteAIProviderDraft + { + get => _pasteAIProviderDraft; + private set + { + if (!ReferenceEquals(_pasteAIProviderDraft, value)) + { + _pasteAIProviderDraft = value; + OnPropertyChanged(nameof(PasteAIProviderDraft)); + OnPropertyChanged(nameof(ShowPasteAIProviderGpoConfiguredInfoBar)); + } + } + } + + public bool ShowPasteAIProviderGpoConfiguredInfoBar + { + get + { + if (_pasteAIProviderDraft is null) + { + return false; + } + + var serviceType = _pasteAIProviderDraft.ServiceType.ToAIServiceType(); + return !IsServiceTypeAllowedByGPO(serviceType); + } + } + + public bool IsEditingPasteAIProvider => _editingPasteAIProvider is not null; + public bool ShowCustomPreview { get => _advancedPasteSettings.Properties.ShowCustomPreview; @@ -360,6 +556,32 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool EnableClipboardPreview + { + get => _advancedPasteSettings.Properties.EnableClipboardPreview; + set + { + if (value != _advancedPasteSettings.Properties.EnableClipboardPreview) + { + _advancedPasteSettings.Properties.EnableClipboardPreview = value; + NotifySettingsChanged(); + } + } + } + + public bool AutoCopySelectionForCustomActionHotkey + { + get => _advancedPasteSettings.Properties.AutoCopySelectionForCustomActionHotkey; + set + { + if (value != _advancedPasteSettings.Properties.AutoCopySelectionForCustomActionHotkey) + { + _advancedPasteSettings.Properties.AutoCopySelectionForCustomActionHotkey = value; + NotifySettingsChanged(); + } + } + } + public bool IsConflictingCopyShortcut => _customActions.Select(customAction => customAction.Shortcut) .Concat([PasteAsPlainTextShortcut, AdvancedPasteUIShortcut, PasteAsMarkdownShortcut, PasteAsJsonShortcut]) @@ -371,15 +593,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels .Select(additionalAction => additionalAction.Shortcut) .Any(hotkey => WarnHotkeys.Contains(hotkey.ToString())); - private void DelayedTimer_Tick(object sender, EventArgs e) - { - lock (_delayedActionLock) - { - _delayedTimer.Stop(); - NotifySettingsChanged(); - } - } - private void NotifySettingsChanged() { // Using InvariantCulture as this is an IPC message @@ -394,38 +607,293 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public void RefreshEnabledState() { InitializeEnabledValue(); + MigrateLegacyAIEnablement(); OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(ShowOnlineAIModelsGpoConfiguredInfoBar)); OnPropertyChanged(nameof(ShowClipboardHistoryIsGpoConfiguredInfoBar)); + OnPropertyChanged(nameof(IsAIEnabled)); } - protected virtual void Dispose(bool disposing) + public void BeginAddPasteAIProvider(string serviceType) { - if (!disposedValue) + var normalizedServiceType = NormalizeServiceType(serviceType, out var persistedServiceType); + + var metadata = AIServiceTypeRegistry.GetMetadata(normalizedServiceType); + var provider = new PasteAIProviderDefinition { - if (disposing) + ServiceType = persistedServiceType, + ModelName = PasteAIProviderDefaults.GetDefaultModelName(normalizedServiceType), + EndpointUrl = string.Empty, + ApiVersion = string.Empty, + DeploymentName = string.Empty, + ModelPath = string.Empty, + SystemPrompt = string.Empty, + ModerationEnabled = normalizedServiceType == AIServiceType.OpenAI, + IsLocalModel = metadata.IsLocalModel, + }; + + if (normalizedServiceType is AIServiceType.FoundryLocal or AIServiceType.Onnx or AIServiceType.ML) + { + provider.ModelName = string.Empty; + } + + _editingPasteAIProvider = null; + PasteAIProviderDraft = provider; + } + + private static AIServiceType NormalizeServiceType(string serviceType, out string persistedServiceType) + { + if (string.IsNullOrWhiteSpace(serviceType)) + { + persistedServiceType = AIServiceType.OpenAI.ToConfigurationString(); + return AIServiceType.OpenAI; + } + + var trimmed = serviceType.Trim(); + var serviceTypeKind = trimmed.ToAIServiceType(); + + if (serviceTypeKind == AIServiceType.Unknown) + { + persistedServiceType = AIServiceType.OpenAI.ToConfigurationString(); + return AIServiceType.OpenAI; + } + + persistedServiceType = trimmed; + return serviceTypeKind; + } + + public bool IsServiceTypeAllowedByGPO(AIServiceType serviceType) + { + var metadata = AIServiceTypeRegistry.GetMetadata(serviceType); + + // Check if this is an online service + if (metadata.IsOnlineService) + { + // For online services, first check the global online AI models GPO + if (_onlineAIModelsGpoRuleConfiguration == GpoRuleConfigured.Disabled) { - _delayedTimer.Dispose(); + // If global online AI is disabled, all online services are blocked + return false; } - disposedValue = true; + // If global online AI is enabled or not configured, check individual endpoint GPO + var individualGpoRule = serviceType switch + { + AIServiceType.OpenAI => GPOWrapper.GetAllowedAdvancedPasteOpenAIValue(), + AIServiceType.AzureOpenAI => GPOWrapper.GetAllowedAdvancedPasteAzureOpenAIValue(), + AIServiceType.AzureAIInference => GPOWrapper.GetAllowedAdvancedPasteAzureAIInferenceValue(), + AIServiceType.Mistral => GPOWrapper.GetAllowedAdvancedPasteMistralValue(), + AIServiceType.Google => GPOWrapper.GetAllowedAdvancedPasteGoogleValue(), + _ => GpoRuleConfigured.Unavailable, + }; + + // If individual GPO is explicitly disabled, block it + return individualGpoRule != GpoRuleConfigured.Disabled; + } + else + { + // For local models, only check their individual GPO (not affected by online AI GPO) + var localGpoRule = serviceType switch + { + AIServiceType.Ollama => GPOWrapper.GetAllowedAdvancedPasteOllamaValue(), + AIServiceType.FoundryLocal => GPOWrapper.GetAllowedAdvancedPasteFoundryLocalValue(), + _ => GpoRuleConfigured.Unavailable, + }; + + // If local model GPO is explicitly disabled, block it + return localGpoRule != GpoRuleConfigured.Disabled; } } - public void Dispose() + public void BeginEditPasteAIProvider(PasteAIProviderDefinition provider) { - Dispose(disposing: true); - GC.SuppressFinalize(this); + ArgumentNullException.ThrowIfNull(provider); + + _editingPasteAIProvider = provider; + var draft = provider.Clone(); + var storedEndpoint = GetPasteAIEndpoint(draft.Id, draft.ServiceType); + if (!string.IsNullOrWhiteSpace(storedEndpoint)) + { + draft.EndpointUrl = storedEndpoint; + } + + PasteAIProviderDraft = draft; + } + + public void CancelPasteAIProviderDraft() + { + PasteAIProviderDraft = null; + _editingPasteAIProvider = null; + } + + public void CommitPasteAIProviderDraft(string apiKey, string endpoint) + { + if (PasteAIProviderDraft is null) + { + return; + } + + var config = PasteAIConfiguration ?? new PasteAIConfiguration(); + if (_advancedPasteSettings.Properties.PasteAIConfiguration is null) + { + PasteAIConfiguration = config; + } + + var draft = PasteAIProviderDraft; + draft.EndpointUrl = endpoint?.Trim() ?? string.Empty; + + SavePasteAIApiKey(draft.Id, draft.ServiceType, apiKey); + + if (_editingPasteAIProvider is null) + { + config.Providers.Add(draft); + config.ActiveProviderId ??= draft.Id; + } + else + { + UpdateProviderFromDraft(_editingPasteAIProvider, draft); + _editingPasteAIProvider = null; + } + + PasteAIProviderDraft = null; + SaveAndNotifySettings(); + OnPropertyChanged(nameof(PasteAIConfiguration)); + } + + public void RemovePasteAIProvider(PasteAIProviderDefinition provider) + { + if (provider is null) + { + return; + } + + var config = PasteAIConfiguration; + if (config?.Providers is null) + { + return; + } + + if (config.Providers.Remove(provider)) + { + RemovePasteAICredentials(provider.Id, provider.ServiceType); + SaveAndNotifySettings(); + OnPropertyChanged(nameof(PasteAIConfiguration)); + } + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + UnsubscribeFromPasteAIConfiguration(_advancedPasteSettings?.Properties.PasteAIConfiguration); + + foreach (var action in _additionalActions.GetAllActions()) + { + action.PropertyChanged -= OnAdditionalActionPropertyChanged; + } + + foreach (var customAction in _customActions) + { + customAction.PropertyChanged -= OnCustomActionPropertyChanged; + } + + _customActions.CollectionChanged -= OnCustomActionsCollectionChanged; + _settingsWatcher?.Dispose(); + _settingsWatcher = null; + } + + _disposed = true; + } + + base.Dispose(disposing); } internal void DisableAI() { try { - PasswordVault vault = new PasswordVault(); - PasswordCredential cred = vault.Retrieve("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey"); - vault.Remove(cred); - OnPropertyChanged(nameof(IsOpenAIEnabled)); + bool stateChanged = false; + + if (_advancedPasteSettings.Properties.IsAIEnabled) + { + _advancedPasteSettings.Properties.IsAIEnabled = false; + stateChanged = true; + } + + if (stateChanged) + { + SaveAndNotifySettings(); + } + else + { + NotifySettingsChanged(); + } + + OnPropertyChanged(nameof(IsAIEnabled)); + } + catch (Exception) + { + } + } + + internal void EnableAI() + { + try + { + if (IsOnlineAIModelsDisallowedByGPO) + { + return; + } + + bool stateChanged = false; + + if (!_advancedPasteSettings.Properties.IsAIEnabled) + { + _advancedPasteSettings.Properties.IsAIEnabled = true; + stateChanged = true; + } + + if (stateChanged) + { + SaveAndNotifySettings(); + } + else + { + NotifySettingsChanged(); + } + + OnPropertyChanged(nameof(IsAIEnabled)); + } + catch (Exception) + { + } + } + + internal void SavePasteAIApiKey(string providerId, string serviceType, string apiKey) + { + try + { + apiKey = apiKey?.Trim() ?? string.Empty; + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + providerId ??= string.Empty; + + string credentialResource = GetAICredentialResource(serviceType); + string credentialUserName = GetPasteAICredentialUserName(providerId, serviceType); + string endpointCredentialUserName = GetPasteAIEndpointCredentialUserName(providerId, serviceType); + PasswordVault vault = new(); + TryRemoveCredential(vault, credentialResource, credentialUserName); + TryRemoveCredential(vault, credentialResource, endpointCredentialUserName); + + bool storeApiKey = RequiresCredentialStorage(serviceType) && !string.IsNullOrWhiteSpace(apiKey); + if (storeApiKey) + { + PasswordCredential cred = new(credentialResource, credentialUserName, apiKey); + vault.Add(cred); + } + + OnPropertyChanged(nameof(IsAIEnabled)); NotifySettingsChanged(); } catch (Exception) @@ -433,22 +901,138 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - internal void EnableAI(string password) + internal string GetPasteAIApiKey(string providerId, string serviceType) + { + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + providerId ??= string.Empty; + return RetrieveCredentialValue( + GetAICredentialResource(serviceType), + GetPasteAICredentialUserName(providerId, serviceType)); + } + + internal string GetPasteAIEndpoint(string providerId, string serviceType) + { + providerId ??= string.Empty; + var providers = PasteAIConfiguration?.Providers; + if (providers is null) + { + return string.Empty; + } + + var provider = providers.FirstOrDefault(p => string.Equals(p.Id ?? string.Empty, providerId, StringComparison.OrdinalIgnoreCase)); + if (provider is null && !string.IsNullOrWhiteSpace(serviceType)) + { + provider = providers.FirstOrDefault(p => string.Equals(p.ServiceType, serviceType, StringComparison.OrdinalIgnoreCase)); + } + + return provider?.EndpointUrl?.Trim() ?? string.Empty; + } + + private string GetAICredentialResource(string serviceType) + { + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + return serviceType.ToLowerInvariant() switch + { + "openai" => "https://platform.openai.com/api-keys", + "azureopenai" => "https://azure.microsoft.com/products/ai-services/openai-service", + "azureaiinference" => "https://azure.microsoft.com/products/ai-services/ai-inference", + "mistral" => "https://console.mistral.ai/account/api-keys", + "google" => "https://ai.google.dev/", + "ollama" => "https://ollama.com/", + _ => "https://platform.openai.com/api-keys", + }; + } + + private string GetPasteAICredentialUserName(string providerId, string serviceType) + { + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + providerId ??= string.Empty; + + string service = serviceType.ToLowerInvariant(); + string normalizedId = NormalizeProviderIdentifier(providerId); + + return $"PowerToys_AdvancedPaste_PasteAI_{service}_{normalizedId}"; + } + + private string GetPasteAIEndpointCredentialUserName(string providerId, string serviceType) + { + return GetPasteAICredentialUserName(providerId, serviceType) + "_Endpoint"; + } + + private static void UpdateProviderFromDraft(PasteAIProviderDefinition target, PasteAIProviderDefinition source) + { + if (target is null || source is null) + { + return; + } + + target.ServiceType = source.ServiceType; + target.ModelName = source.ModelName; + target.EndpointUrl = source.EndpointUrl; + target.ApiVersion = source.ApiVersion; + target.DeploymentName = source.DeploymentName; + target.ModelPath = source.ModelPath; + target.SystemPrompt = source.SystemPrompt; + target.ModerationEnabled = source.ModerationEnabled; + target.EnableAdvancedAI = source.EnableAdvancedAI; + target.IsLocalModel = source.IsLocalModel; + } + + private void RemovePasteAICredentials(string providerId, string serviceType) { try { + serviceType = string.IsNullOrWhiteSpace(serviceType) ? "OpenAI" : serviceType; + providerId ??= string.Empty; + + string credentialResource = GetAICredentialResource(serviceType); PasswordVault vault = new(); - PasswordCredential cred = new("https://platform.openai.com/api-keys", "PowerToys_AdvancedPaste_OpenAIKey", password); - vault.Add(cred); - OnPropertyChanged(nameof(IsOpenAIEnabled)); - IsAdvancedAIEnabled = true; // new users should get Semantic Kernel benefits immediately - NotifySettingsChanged(); + TryRemoveCredential(vault, credentialResource, GetPasteAICredentialUserName(providerId, serviceType)); + TryRemoveCredential(vault, credentialResource, GetPasteAIEndpointCredentialUserName(providerId, serviceType)); } catch (Exception) { } } + private static string NormalizeProviderIdentifier(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) + { + return "default"; + } + + var filtered = new string(providerId.Where(char.IsLetterOrDigit).ToArray()); + return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant(); + } + + private static bool RequiresCredentialStorage(string serviceType) + { + var serviceTypeKind = serviceType.ToAIServiceType(); + + return serviceTypeKind switch + { + AIServiceType.Onnx => false, + AIServiceType.Ollama => false, + AIServiceType.FoundryLocal => false, + AIServiceType.ML => false, + _ => true, + }; + } + + private static void TryRemoveCredential(PasswordVault vault, string credentialResource, string credentialUserName) + { + try + { + PasswordCredential existingCred = vault.Retrieve(credentialResource, credentialUserName); + vault.Remove(existingCred); + } + catch (Exception) + { + // Credential doesn't exist, which is fine + } + } + internal AdvancedPasteCustomAction GetNewCustomAction(string namePrefix) { ArgumentException.ThrowIfNullOrEmpty(namePrefix); @@ -486,6 +1070,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private void SaveAndNotifySettings() { + if (_suppressSave) + { + return; + } + _settingsUtils.SaveSettings(_advancedPasteSettings.ToJsonString(), AdvancedPasteSettings.ModuleName); NotifySettingsChanged(); } @@ -502,7 +1091,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private void OnCustomActionPropertyChanged(object sender, PropertyChangedEventArgs e) { - if (typeof(AdvancedPasteCustomAction).GetProperty(e.PropertyName).GetCustomAttribute<JsonIgnoreAttribute>() == null) + if (!string.IsNullOrEmpty(e.PropertyName) && !CustomActionNonPersistedProperties.Contains(e.PropertyName)) { SaveCustomActions(); } @@ -558,6 +1147,330 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels SaveCustomActions(); } + private void AttachConfigurationHandlers() + { + SubscribeToPasteAIConfiguration(_advancedPasteSettings.Properties.PasteAIConfiguration); + } + + private void SetupSettingsFileWatcher() + { + _settingsWatcher = Helper.GetFileWatcher(AdvancedPasteSettings.ModuleName, SettingsUtils.DefaultFileName, OnSettingsFileChanged); + } + + private void OnSettingsFileChanged() + { + if (_disposed) + { + return; + } + + void Handler() + { + ApplyExternalSettings(); + } + + if (_dispatcherQueue is not null && !_dispatcherQueue.HasThreadAccess) + { + _dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal, Handler); + } + else + { + Handler(); + } + } + + private void ApplyExternalSettings() + { + if (_disposed) + { + return; + } + + AdvancedPasteSettings latestSettings; + + try + { + latestSettings = _settingsUtils.GetSettingsOrDefault<AdvancedPasteSettings>(AdvancedPasteSettings.ModuleName); + } + catch + { + return; + } + + if (latestSettings?.Properties is null) + { + return; + } + + try + { + _suppressSave = true; + ApplyExternalProperties(latestSettings.Properties); + } + finally + { + _suppressSave = false; + } + } + + private void ApplyExternalProperties(AdvancedPasteProperties source) + { + var target = _advancedPasteSettings?.Properties; + + if (target is null || source is null) + { + return; + } + + if (target.IsAIEnabled != source.IsAIEnabled) + { + target.IsAIEnabled = source.IsAIEnabled; + OnPropertyChanged(nameof(IsAIEnabled)); + } + + if (target.ShowCustomPreview != source.ShowCustomPreview) + { + target.ShowCustomPreview = source.ShowCustomPreview; + OnPropertyChanged(nameof(ShowCustomPreview)); + } + + if (target.CloseAfterLosingFocus != source.CloseAfterLosingFocus) + { + target.CloseAfterLosingFocus = source.CloseAfterLosingFocus; + OnPropertyChanged(nameof(CloseAfterLosingFocus)); + } + + if (target.EnableClipboardPreview != source.EnableClipboardPreview) + { + target.EnableClipboardPreview = source.EnableClipboardPreview; + OnPropertyChanged(nameof(EnableClipboardPreview)); + } + + if (target.AutoCopySelectionForCustomActionHotkey != source.AutoCopySelectionForCustomActionHotkey) + { + target.AutoCopySelectionForCustomActionHotkey = source.AutoCopySelectionForCustomActionHotkey; + OnPropertyChanged(nameof(AutoCopySelectionForCustomActionHotkey)); + } + + var incomingConfig = source.PasteAIConfiguration ?? new PasteAIConfiguration(); + if (ShouldReplacePasteAIConfiguration(target.PasteAIConfiguration, incomingConfig)) + { + PasteAIConfiguration = incomingConfig; + } + } + + private static bool ShouldReplacePasteAIConfiguration(PasteAIConfiguration current, PasteAIConfiguration incoming) + { + if (incoming is null) + { + return false; + } + + if (current is null) + { + return true; + } + + if (!string.Equals(current.ActiveProviderId ?? string.Empty, incoming.ActiveProviderId ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var currentProviders = current.Providers ?? new ObservableCollection<PasteAIProviderDefinition>(); + var incomingProviders = incoming.Providers ?? new ObservableCollection<PasteAIProviderDefinition>(); + + if (currentProviders.Count != incomingProviders.Count) + { + return true; + } + + for (int i = 0; i < currentProviders.Count; i++) + { + var existing = currentProviders[i]; + var updated = incomingProviders[i]; + + if (!string.Equals(existing?.Id ?? string.Empty, updated?.Id ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.Equals(existing?.ServiceType ?? string.Empty, updated?.ServiceType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.Equals(existing?.ModelName ?? string.Empty, updated?.ModelName ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (!string.Equals(existing?.EndpointUrl ?? string.Empty, updated?.EndpointUrl ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (!string.Equals(existing?.DeploymentName ?? string.Empty, updated?.DeploymentName ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (!string.Equals(existing?.ApiVersion ?? string.Empty, updated?.ApiVersion ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (!string.Equals(existing?.SystemPrompt ?? string.Empty, updated?.SystemPrompt ?? string.Empty, StringComparison.Ordinal)) + { + return true; + } + + if (existing?.ModerationEnabled != updated?.ModerationEnabled || existing?.EnableAdvancedAI != updated?.EnableAdvancedAI || existing?.IsActive != updated?.IsActive) + { + return true; + } + } + + return false; + } + + private void SubscribeToPasteAIConfiguration(PasteAIConfiguration configuration) + { + if (configuration is not null) + { + configuration.PropertyChanged += OnPasteAIConfigurationPropertyChanged; + SubscribeToPasteAIProviders(configuration); + } + } + + private void UnsubscribeFromPasteAIConfiguration(PasteAIConfiguration configuration) + { + if (configuration is not null) + { + configuration.PropertyChanged -= OnPasteAIConfigurationPropertyChanged; + UnsubscribeFromPasteAIProviders(configuration); + } + } + + private void SubscribeToPasteAIProviders(PasteAIConfiguration configuration) + { + if (configuration?.Providers is null) + { + return; + } + + configuration.Providers.CollectionChanged -= OnPasteAIProvidersCollectionChanged; + configuration.Providers.CollectionChanged += OnPasteAIProvidersCollectionChanged; + + foreach (var provider in configuration.Providers) + { + provider.PropertyChanged -= OnPasteAIProviderPropertyChanged; + provider.PropertyChanged += OnPasteAIProviderPropertyChanged; + } + } + + private void UnsubscribeFromPasteAIProviders(PasteAIConfiguration configuration) + { + if (configuration?.Providers is null) + { + return; + } + + configuration.Providers.CollectionChanged -= OnPasteAIProvidersCollectionChanged; + + foreach (var provider in configuration.Providers) + { + provider.PropertyChanged -= OnPasteAIProviderPropertyChanged; + } + } + + private void OnPasteAIProvidersCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + if (e?.NewItems is not null) + { + foreach (PasteAIProviderDefinition provider in e.NewItems) + { + provider.PropertyChanged += OnPasteAIProviderPropertyChanged; + } + } + + if (e?.OldItems is not null) + { + foreach (PasteAIProviderDefinition provider in e.OldItems) + { + provider.PropertyChanged -= OnPasteAIProviderPropertyChanged; + } + } + + var pasteConfig = _advancedPasteSettings?.Properties?.PasteAIConfiguration; + + OnPropertyChanged(nameof(PasteAIConfiguration)); + SaveAndNotifySettings(); + } + + private void OnPasteAIProviderPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (sender is PasteAIProviderDefinition provider) + { + // When service type changes we may need to update credentials entry names. + if (string.Equals(e.PropertyName, nameof(PasteAIProviderDefinition.ServiceType), StringComparison.Ordinal)) + { + SaveAndNotifySettings(); + return; + } + + SaveAndNotifySettings(); + } + } + + private void OnPasteAIConfigurationPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (string.Equals(e.PropertyName, nameof(PasteAIConfiguration.Providers), StringComparison.Ordinal)) + { + SubscribeToPasteAIProviders(PasteAIConfiguration); + SaveAndNotifySettings(); + return; + } + + if (string.Equals(e.PropertyName, nameof(PasteAIConfiguration.ActiveProviderId), StringComparison.Ordinal)) + { + SaveAndNotifySettings(); + } + } + + private void InitializePasteAIProviderState() + { + var pasteConfig = _advancedPasteSettings?.Properties?.PasteAIConfiguration; + if (pasteConfig is null) + { + _advancedPasteSettings.Properties.PasteAIConfiguration = new PasteAIConfiguration(); + pasteConfig = _advancedPasteSettings.Properties.PasteAIConfiguration; + } + + pasteConfig.Providers ??= new ObservableCollection<PasteAIProviderDefinition>(); + + SubscribeToPasteAIProviders(pasteConfig); + } + + private static string RetrieveCredentialValue(string credentialResource, string credentialUserName) + { + if (string.IsNullOrWhiteSpace(credentialResource) || string.IsNullOrWhiteSpace(credentialUserName)) + { + return string.Empty; + } + + try + { + PasswordVault vault = new(); + PasswordCredential existingCred = vault.Retrieve(credentialResource, credentialUserName); + existingCred?.RetrievePassword(); + return existingCred?.Password?.Trim() ?? string.Empty; + } + catch (Exception) + { + return string.Empty; + } + } + private void UpdateCustomActionsCanMoveUpDown() { for (int index = 0; index < _customActions.Count; index++) diff --git a/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs index 789ef92dfc..a62c617589 100644 --- a/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/AlwaysOnTopViewModel.cs @@ -3,11 +3,14 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -16,9 +19,11 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class AlwaysOnTopViewModel : Observable + public partial class AlwaysOnTopViewModel : PageViewModelBase { - private ISettingsUtils SettingsUtils { get; set; } + protected override string ModuleName => AlwaysOnTopSettings.ModuleName; + + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -26,7 +31,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func<string, int> SendConfigMSG { get; } - public AlwaysOnTopViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<AlwaysOnTopSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc) + public AlwaysOnTopViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<AlwaysOnTopSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc) { ArgumentNullException.ThrowIfNull(settingsUtils); @@ -75,6 +80,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [Hotkey], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; @@ -114,18 +129,15 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _hotkey) { - if (value == null || value.IsEmpty()) - { - _hotkey = AlwaysOnTopProperties.DefaultHotkeyValue; - } - else - { - _hotkey = value; - } + _hotkey = value ?? AlwaysOnTopProperties.DefaultHotkeyValue; Settings.Properties.Hotkey.Value = _hotkey; NotifyPropertyChanged(); + // Also notify that transparency shortcut strings have changed + OnPropertyChanged(nameof(IncreaseOpacityShortcut)); + OnPropertyChanged(nameof(DecreaseOpacityShortcut)); + // Using InvariantCulture as this is an IPC message SendConfigMSG( string.Format( @@ -282,6 +294,32 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + /// <summary> + /// Gets the formatted shortcut string for increasing window opacity (modifier keys + "+"). + /// </summary> + public string IncreaseOpacityShortcut + { + get + { + var modifiers = new HotkeySettings(_hotkey.Win, _hotkey.Ctrl, _hotkey.Alt, _hotkey.Shift, 0).ToString(); + var shortcut = string.IsNullOrEmpty(modifiers) ? "+" : modifiers + " + +"; + return string.Format(CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString("AlwaysOnTop_IncreaseOpacity"), shortcut); + } + } + + /// <summary> + /// Gets the formatted shortcut string for decreasing window opacity (modifier keys + "-"). + /// </summary> + public string DecreaseOpacityShortcut + { + get + { + var modifiers = new HotkeySettings(_hotkey.Win, _hotkey.Ctrl, _hotkey.Alt, _hotkey.Shift, 0).ToString(); + var shortcut = string.IsNullOrEmpty(modifiers) ? "-" : modifiers + " + -"; + return string.Format(CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString("AlwaysOnTop_DecreaseOpacity"), shortcut); + } + } + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) { OnPropertyChanged(propertyName); diff --git a/src/settings-ui/Settings.UI/ViewModels/CmdPalViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/CmdPalViewModel.cs index 07806bf31a..a09cbadc7d 100644 --- a/src/settings-ui/Settings.UI/ViewModels/CmdPalViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/CmdPalViewModel.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.IO; using System.IO.Abstractions; using System.Linq; @@ -11,6 +12,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -21,8 +23,10 @@ using Windows.Management.Deployment; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public class CmdPalViewModel : Observable + public class CmdPalViewModel : PageViewModelBase { + protected override string ModuleName => "CmdPal"; + private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _isEnabled; private HotkeySettings _hotkey; @@ -34,7 +38,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func<string, int> SendConfigMSG { get; } - public CmdPalViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, DispatcherQueue uiDispatcherQueue) + public CmdPalViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, DispatcherQueue uiDispatcherQueue) { ArgumentNullException.ThrowIfNull(settingsUtils); @@ -88,6 +92,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [Hotkey], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/ColorPickerViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ColorPickerViewModel.cs index d6006a58f7..d827a07c08 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ColorPickerViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ColorPickerViewModel.cs @@ -9,9 +9,9 @@ using System.Globalization; using System.Linq; using System.Text.Json; using System.Timers; - using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Enumerations; using Microsoft.PowerToys.Settings.UI.Library.Helpers; @@ -20,16 +20,18 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class ColorPickerViewModel : Observable, IDisposable + public partial class ColorPickerViewModel : PageViewModelBase { - private bool disposedValue; + protected override string ModuleName => ColorPickerSettings.ModuleName; - // Delay saving of settings in order to avoid calling save multiple times and hitting file in use exception. If there is no other request to save settings in given interval, we proceed to save it, otherwise we schedule saving it after this interval + private bool _disposed; + + // Delay saving of settings in order to avoid calling save multiple times and hitting file in use exception. If there is no other request to save settings in given interval, we proceed to save it; otherwise, we schedule saving it after this interval private const int SaveSettingsDelayInMs = 500; private GeneralSettings GeneralSettingsConfig { get; set; } - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private readonly System.Threading.Lock _delayedActionLock = new System.Threading.Lock(); private readonly ColorPickerSettings _colorPickerSettings; @@ -45,7 +47,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Dictionary<string, string> _colorFormatsPreview; public ColorPickerViewModel( - ISettingsUtils settingsUtils, + SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<ColorPickerSettings> colorPickerSettingsRepository, Func<string, int> ipcMSGCallBackFunc) @@ -87,6 +89,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; @@ -182,6 +194,51 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public int PrimaryClickBehavior + { + get => (int)_colorPickerSettings.Properties.PrimaryClickAction; + + set + { + if (value != (int)_colorPickerSettings.Properties.PrimaryClickAction) + { + _colorPickerSettings.Properties.PrimaryClickAction = (ColorPickerClickAction)value; + OnPropertyChanged(nameof(PrimaryClickBehavior)); + NotifySettingsChanged(); + } + } + } + + public int MiddleClickBehavior + { + get => (int)_colorPickerSettings.Properties.MiddleClickAction; + + set + { + if (value != (int)_colorPickerSettings.Properties.MiddleClickAction) + { + _colorPickerSettings.Properties.MiddleClickAction = (ColorPickerClickAction)value; + OnPropertyChanged(nameof(MiddleClickBehavior)); + NotifySettingsChanged(); + } + } + } + + public int SecondaryClickBehavior + { + get => (int)_colorPickerSettings.Properties.SecondaryClickAction; + + set + { + if (value != (int)_colorPickerSettings.Properties.SecondaryClickAction) + { + _colorPickerSettings.Properties.SecondaryClickAction = (ColorPickerClickAction)value; + OnPropertyChanged(nameof(SecondaryClickBehavior)); + NotifySettingsChanged(); + } + } + } + public bool ShowColorName { get => _colorPickerSettings.Properties.ShowColorName; @@ -364,23 +421,25 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsEnabled)); } - protected virtual void Dispose(bool disposing) + protected override void Dispose(bool disposing) { - if (!disposedValue) + if (!_disposed) { if (disposing) { - _delayedTimer.Dispose(); + _delayedTimer?.Dispose(); + foreach (var colorFormat in ColorFormats) + { + colorFormat.PropertyChanged -= ColorFormat_PropertyChanged; + } + + ColorFormats.CollectionChanged -= ColorFormats_CollectionChanged; } - disposedValue = true; + _disposed = true; } - } - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); + base.Dispose(disposing); } internal ColorFormatModel GetNewColorFormatModel() diff --git a/src/settings-ui/Settings.UI/ViewModels/CropAndLockViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/CropAndLockViewModel.cs index dc5f6846ef..a0a0c52051 100644 --- a/src/settings-ui/Settings.UI/ViewModels/CropAndLockViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/CropAndLockViewModel.cs @@ -3,11 +3,12 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; - using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -16,9 +17,11 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class CropAndLockViewModel : Observable + public partial class CropAndLockViewModel : PageViewModelBase { - private ISettingsUtils SettingsUtils { get; set; } + protected override string ModuleName => CropAndLockSettings.ModuleName; + + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -26,7 +29,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func<string, int> SendConfigMSG { get; } - public CropAndLockViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<CropAndLockSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc) + public CropAndLockViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<CropAndLockSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc) { ArgumentNullException.ThrowIfNull(settingsUtils); @@ -46,6 +49,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _reparentHotkey = Settings.Properties.ReparentHotkey.Value; _thumbnailHotkey = Settings.Properties.ThumbnailHotkey.Value; + _screenshotHotkey = Settings.Properties.ScreenshotHotkey.Value; // set the callback functions value to handle outgoing IPC message. SendConfigMSG = ipcMSGCallBackFunc; @@ -66,6 +70,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [ReparentActivationShortcut, ThumbnailActivationShortcut, ScreenshotActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; @@ -159,6 +173,36 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public HotkeySettings ScreenshotActivationShortcut + { + get => _screenshotHotkey; + set + { + if (value != _screenshotHotkey) + { + if (value == null) + { + _screenshotHotkey = CropAndLockProperties.DefaultScreenshotHotkeyValue; + } + else + { + _screenshotHotkey = value; + } + + Settings.Properties.ScreenshotHotkey.Value = _screenshotHotkey; + NotifyPropertyChanged(); + + // Using InvariantCulture as this is an IPC message + SendConfigMSG( + string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + CropAndLockSettings.ModuleName, + JsonSerializer.Serialize(Settings, SourceGenerationContextContext.Default.CropAndLockSettings))); + } + } + } + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) { OnPropertyChanged(propertyName); @@ -176,5 +220,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _isEnabled; private HotkeySettings _reparentHotkey; private HotkeySettings _thumbnailHotkey; + private HotkeySettings _screenshotHotkey; } } diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardListItem.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardListItem.cs index fa6fbba97a..aad7b9536b 100644 --- a/src/settings-ui/Settings.UI/ViewModels/DashboardListItem.cs +++ b/src/settings-ui/Settings.UI/ViewModels/DashboardListItem.cs @@ -8,44 +8,24 @@ using System.ComponentModel; using System.Runtime.CompilerServices; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Controls; using Microsoft.UI; using Windows.UI; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class DashboardListItem : INotifyPropertyChanged + public partial class DashboardListItem : ModuleListItem { private bool _visible; - private bool _isEnabled; - - public string Label { get; set; } - - public bool IsNew { get; set; } - - public string Icon { get; set; } public string ToolTip { get; set; } - public ModuleType Tag { get; set; } - - public bool IsLocked { get; set; } - - public bool IsEnabled + public new ModuleType Tag { - get => _isEnabled; - set - { - if (_isEnabled != value) - { - _isEnabled = value; - OnPropertyChanged(); - EnabledChangedCallback?.Invoke(this); - } - } + get => (ModuleType)base.Tag!; + set => base.Tag = value; } - public Action<DashboardListItem> EnabledChangedCallback { get; set; } - public bool Visible { get => _visible; @@ -59,13 +39,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - public event PropertyChangedEventHandler PropertyChanged; - - private void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - public ObservableCollection<DashboardModuleItem> DashboardModuleItems { get; set; } = new ObservableCollection<DashboardModuleItem>(); } } diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardModuleItem.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardModuleItem.cs index 20132bff20..41f5f5d441 100644 --- a/src/settings-ui/Settings.UI/ViewModels/DashboardModuleItem.cs +++ b/src/settings-ui/Settings.UI/ViewModels/DashboardModuleItem.cs @@ -36,23 +36,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public List<object> Shortcut { get; set; } } - public partial class DashboardModuleKBMItem : DashboardModuleItem + public partial class DashboardModuleActivationItem : DashboardModuleItem { - private List<KeysDataModel> _remapKeys = new List<KeysDataModel>(); - - public List<KeysDataModel> RemapKeys - { - get => _remapKeys; - set => _remapKeys = value; - } - - private List<AppSpecificKeysDataModel> _remapShortcuts = new List<AppSpecificKeysDataModel>(); - - public List<AppSpecificKeysDataModel> RemapShortcuts - { - get => _remapShortcuts; - set => _remapShortcuts = value; - } + public string Activation { get; set; } } public partial class DashboardModuleItem : INotifyPropertyChanged diff --git a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs index bc40b5b1cb..6301465996 100644 --- a/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/DashboardViewModel.cs @@ -1,4 +1,4 @@ -// 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. @@ -7,37 +7,100 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO.Abstractions; using System.Linq; -using System.Windows.Threading; - +using System.Threading.Tasks; +using CommunityToolkit.WinUI.Controls; using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Controls; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.Utilities; using Microsoft.PowerToys.Settings.UI.Services; using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class DashboardViewModel : Observable + public partial class DashboardViewModel : PageViewModelBase { - private const string JsonFileType = ".json"; - private IFileSystemWatcher _watcher; - private DashboardModuleKBMItem _kbmItem; - private Dispatcher dispatcher; + private readonly object _sortLock = new object(); + + protected override string ModuleName => "Dashboard"; + + private DispatcherQueue dispatcher; public Func<string, int> SendConfigMSG { get; } - public ObservableCollection<DashboardListItem> ActiveModules { get; set; } = new ObservableCollection<DashboardListItem>(); + public ObservableCollection<DashboardListItem> AllModules { get; set; } = new ObservableCollection<DashboardListItem>(); - public ObservableCollection<DashboardListItem> DisabledModules { get; set; } = new ObservableCollection<DashboardListItem>(); + public ObservableCollection<DashboardListItem> ShortcutModules { get; set; } = new ObservableCollection<DashboardListItem>(); - public bool UpdateAvailable { get; set; } + public ObservableCollection<DashboardListItem> ActionModules { get; set; } = new ObservableCollection<DashboardListItem>(); - private List<DashboardListItem> _allModules; + public ObservableCollection<QuickAccessItem> QuickAccessItems => _quickAccessViewModel.Items; + + private readonly QuickAccessViewModel _quickAccessViewModel; + + // Master list of module items that is sorted and projected into AllModules. + private List<DashboardListItem> _moduleItems = new List<DashboardListItem>(); + + // Flag to prevent circular updates when a UI toggle triggers settings changes. + private bool _isUpdatingFromUI; + + // Flag to prevent toggle operations during sorting to avoid race conditions. + private bool _isSorting; + private bool _isDisposed; + + private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData(); + + public AllHotkeyConflictsData AllHotkeyConflictsData + { + get => _allHotkeyConflictsData; + set + { + if (Set(ref _allHotkeyConflictsData, value)) + { + OnPropertyChanged(); + } + } + } + + public string PowerToysVersion + { + get + { + return Helper.GetProductVersion(); + } + } + + private DashboardSortOrder _dashboardSortOrder = DashboardSortOrder.Alphabetical; + + public DashboardSortOrder DashboardSortOrder + { + get => generalSettingsConfig.DashboardSortOrder; + set + { + if (_dashboardSortOrder != value) + { + _dashboardSortOrder = value; + generalSettingsConfig.DashboardSortOrder = value; + OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(generalSettingsConfig); + + SendConfigMSG(outgoing.ToString()); + + // Notify UI before sorting so menu updates its checked state + OnPropertyChanged(nameof(DashboardSortOrder)); + + SortModuleList(); + } + } + } private ISettingsRepository<GeneralSettings> _settingsRepository; private GeneralSettings generalSettingsConfig; @@ -45,120 +108,302 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public DashboardViewModel(ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc) { - dispatcher = Dispatcher.CurrentDispatcher; + dispatcher = DispatcherQueue.GetForCurrentThread(); _settingsRepository = settingsRepository; generalSettingsConfig = settingsRepository.SettingsConfig; - generalSettingsConfig.AddEnabledModuleChangeNotification(ModuleEnabledChangedOnSettingsPage); + + _settingsRepository.SettingsChanged += OnSettingsChanged; + + // Initialize dashboard sort order from settings + _dashboardSortOrder = generalSettingsConfig.DashboardSortOrder; // set the callback functions value to handle outgoing IPC message. SendConfigMSG = ipcMSGCallBackFunc; - _allModules = new List<DashboardListItem>(); + _quickAccessViewModel = new QuickAccessViewModel( + _settingsRepository, + new Microsoft.PowerToys.Settings.UI.Controls.QuickAccessLauncher(App.IsElevated), + moduleType => Helpers.ModuleGpoHelper.GetModuleGpoConfiguration(moduleType) == global::PowerToys.GPOWrapper.GpoRuleConfigured.Disabled, + resourceLoader); + + BuildModuleList(); + SortModuleList(); + RefreshShortcutModules(); + } + + private void OnSettingsChanged(GeneralSettings newSettings) + { + if (_isDisposed) + { + return; + } + + dispatcher.TryEnqueue(() => + { + if (_isDisposed) + { + return; + } + + generalSettingsConfig = newSettings; + + // Update local field and notify UI if sort order changed + if (_dashboardSortOrder != generalSettingsConfig.DashboardSortOrder) + { + _dashboardSortOrder = generalSettingsConfig.DashboardSortOrder; + OnPropertyChanged(nameof(DashboardSortOrder)); + } + + ModuleEnabledChangedOnSettingsPage(); + }); + } + + protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e) + { + if (_isDisposed) + { + return; + } + + dispatcher.TryEnqueue(() => + { + if (_isDisposed) + { + return; + } + + var allConflictData = e.Conflicts; + foreach (var inAppConflict in allConflictData.InAppConflicts) + { + var hotkey = inAppConflict.Hotkey; + var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key); + inAppConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting); + } + + foreach (var systemConflict in allConflictData.SystemConflicts) + { + var hotkey = systemConflict.Hotkey; + var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key); + systemConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting); + } + + AllHotkeyConflictsData = e.Conflicts ?? new AllHotkeyConflictsData(); + }); + } + + private void RequestConflictData() + { + // Request current conflicts data + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + + /// <summary> + /// Builds the master list of module items. Called once during initialization. + /// Each module item contains its configuration, enabled state, and GPO lock status. + /// </summary> + private void BuildModuleList() + { + _moduleItems.Clear(); foreach (ModuleType moduleType in Enum.GetValues<ModuleType>()) { - AddDashboardListItem(moduleType); - } - - ActiveModules = new ObservableCollection<DashboardListItem>(_allModules.Where(x => x.IsEnabled)); - DisabledModules = new ObservableCollection<DashboardListItem>(_allModules.Where(x => !x.IsEnabled)); - - UpdatingSettings updatingSettingsConfig = UpdatingSettings.LoadSettings(); - UpdateAvailable = updatingSettingsConfig != null && (updatingSettingsConfig.State == UpdatingSettings.UpdatingState.ReadyToInstall || updatingSettingsConfig.State == UpdatingSettings.UpdatingState.ReadyToDownload); - } - - private void AddDashboardListItem(ModuleType moduleType) - { - GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(moduleType); - _allModules.Add(new DashboardListItem() - { - Tag = moduleType, - Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), - IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)), - IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled, - Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), - IsNew = moduleType == ModuleType.CmdPal, - EnabledChangedCallback = EnabledChangedOnUI, - DashboardModuleItems = GetModuleItems(moduleType), - }); - if (moduleType == ModuleType.KeyboardManager && gpo != GpoRuleConfigured.Disabled) - { - KeyboardManagerSettings kbmSettings = GetKBMSettings(); - _watcher = Library.Utilities.Helper.GetFileWatcher(KeyboardManagerSettings.ModuleName, kbmSettings.Properties.ActiveConfiguration.Value + JsonFileType, () => LoadKBMSettingsFromJson()); - } - } - - private void LoadKBMSettingsFromJson() - { - try - { - KeyboardManagerProfile kbmProfile = GetKBMProfile(); - _kbmItem.RemapKeys = kbmProfile?.RemapKeys.InProcessRemapKeys; - _kbmItem.RemapShortcuts = KeyboardManagerViewModel.CombineShortcutLists(kbmProfile?.RemapShortcuts.GlobalRemapShortcuts, kbmProfile?.RemapShortcuts.AppSpecificRemapShortcuts); - dispatcher.Invoke(new Action(() => UpdateKBMItems())); - } - catch (Exception ex) - { - Logger.LogError($"Failed to load KBM settings: {ex.Message}"); - } - } - - private void UpdateKBMItems() - { - _kbmItem.NotifyPropertyChanged(nameof(_kbmItem.RemapKeys)); - _kbmItem.NotifyPropertyChanged(nameof(_kbmItem.RemapShortcuts)); - } - - private KeyboardManagerProfile GetKBMProfile() - { - KeyboardManagerSettings kbmSettings = GetKBMSettings(); - const string PowerToyName = KeyboardManagerSettings.ModuleName; - string fileName = kbmSettings.Properties.ActiveConfiguration.Value + JsonFileType; - return new SettingsUtils().GetSettingsOrDefault<KeyboardManagerProfile>(PowerToyName, fileName); - } - - private KeyboardManagerSettings GetKBMSettings() - { - var settingsUtils = new SettingsUtils(); - ISettingsRepository<KeyboardManagerSettings> moduleSettingsRepository = SettingsRepository<KeyboardManagerSettings>.GetInstance(settingsUtils); - return moduleSettingsRepository.SettingsConfig; - } - - private void EnabledChangedOnUI(DashboardListItem dashboardListItem) - { - Views.ShellPage.UpdateGeneralSettingsCallback(dashboardListItem.Tag, dashboardListItem.IsEnabled); - - if (dashboardListItem.Tag == ModuleType.NewPlus && dashboardListItem.IsEnabled == true) - { - var settingsUtils = new SettingsUtils(); - var settings = NewPlusViewModel.LoadSettings(settingsUtils); - NewPlusViewModel.CopyTemplateExamples(settings.Properties.TemplateLocation.Value); - } - } - - public void ModuleEnabledChangedOnSettingsPage() - { - try - { - ActiveModules.Clear(); - DisabledModules.Clear(); - - generalSettingsConfig = _settingsRepository.SettingsConfig; - foreach (DashboardListItem item in _allModules) + if (moduleType == ModuleType.GeneralSettings) { - item.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, item.Tag); - if (item.IsEnabled) - { - ActiveModules.Add(item); - } - else - { - DisabledModules.Add(item); - } + continue; } - OnPropertyChanged(nameof(ActiveModules)); - OnPropertyChanged(nameof(DisabledModules)); + GpoRuleConfigured gpo = ModuleGpoHelper.GetModuleGpoConfiguration(moduleType); + var newItem = new DashboardListItem() + { + Tag = moduleType, + Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), + IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)), + IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled, + Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), + IsNew = moduleType == ModuleType.CursorWrap || moduleType == ModuleType.PowerDisplay, + DashboardModuleItems = GetModuleItems(moduleType), + ClickCommand = new RelayCommand<object>(DashboardListItemClick), + }; + newItem.EnabledChangedCallback = EnabledChangedOnUI; + _moduleItems.Add(newItem); + } + } + + /// <summary> + /// Sorts the module list according to the current sort order and updates the AllModules collection. + /// On first call, populates AllModules. On subsequent calls, uses Move() to reorder items in-place + /// to avoid destroying and recreating UI elements. + /// Temporarily disables interaction on all items during sorting to prevent race conditions. + /// </summary> + private void SortModuleList() + { + if (_isSorting) + { + return; + } + + lock (_sortLock) + { + _isSorting = true; + try + { + var sortedItems = (DashboardSortOrder switch + { + DashboardSortOrder.ByStatus => _moduleItems.OrderByDescending(x => x.IsEnabled).ThenBy(x => x.Label), + _ => _moduleItems.OrderBy(x => x.Label), // Default alphabetical + }).ToList(); + + // If AllModules is empty (first load), just populate it. + if (AllModules.Count == 0) + { + foreach (var item in sortedItems) + { + AllModules.Add(item); + } + + return; + } + + // Otherwise, update the collection in place using Move to avoid UI glitches. + for (int i = 0; i < sortedItems.Count; i++) + { + var currentItem = sortedItems[i]; + var currentIndex = AllModules.IndexOf(currentItem); + + if (currentIndex != -1 && currentIndex != i) + { + AllModules.Move(currentIndex, i); + } + } + } + finally + { + // Use dispatcher to reset flag after UI updates complete + dispatcher.TryEnqueue(DispatcherQueuePriority.Low, () => + { + _isSorting = false; + }); + } + } + } + + /// <summary> + /// Refreshes module enabled/locked states by re-reading GPO configuration. Only + /// updates properties that have actually changed to minimize UI notifications + /// then re-sorts the list according to the current sort order. + /// </summary> + private void RefreshModuleList() + { + foreach (var item in _moduleItems) + { + GpoRuleConfigured gpo = ModuleGpoHelper.GetModuleGpoConfiguration(item.Tag); + + // GPO can force-enable (Enabled) or force-disable (Disabled) a module. + // If Enabled: module is on and the user cannot disable it. + // If Disabled: module is off and the user cannot enable it. + // Otherwise, the setting is unlocked and the user can enable/disable it. + bool newEnabledState = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, item.Tag)); + + // Lock the toggle when GPO is controlling the module. + bool newLockedState = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled; + + // Only update if there's an actual change to minimize UI notifications. + if (item.IsEnabled != newEnabledState) + { + item.UpdateStatus(newEnabledState); + } + + if (item.IsLocked != newLockedState) + { + item.IsLocked = newLockedState; + } + } + + SortModuleList(); + } + + /// <summary> + /// Callback invoked when a user toggles a module's enabled state in the UI. + /// Sets the _isUpdatingFromUI flag to prevent circular updates, then updates + /// settings, re-sorts if needed, and refreshes dependent collections. + /// </summary> + private void EnabledChangedOnUI(ModuleListItem item) + { + var dashboardListItem = (DashboardListItem)item; + var isEnabled = dashboardListItem.IsEnabled; + + // Ignore toggle operations during sorting to prevent race conditions. + // Revert the toggle state since UI already changed due to TwoWay binding. + if (_isSorting) + { + dashboardListItem.UpdateStatus(!isEnabled); + return; + } + + _isUpdatingFromUI = true; + try + { + // Send optimized IPC message with only the module status update + // Format: {"module_status": {"ModuleName": true/false}} + string moduleKey = ModuleHelper.GetModuleKey(dashboardListItem.Tag); + string moduleStatusJson = $"{{\"module_status\": {{\"{moduleKey}\": {isEnabled.ToString().ToLowerInvariant()}}}}}"; + SendConfigMSG(moduleStatusJson); + + // Update local settings config to keep UI in sync + ModuleHelper.SetIsModuleEnabled(generalSettingsConfig, dashboardListItem.Tag, isEnabled); + + if (dashboardListItem.Tag == ModuleType.NewPlus && isEnabled == true) + { + var settingsUtils = SettingsUtils.Default; + var settings = NewPlusViewModel.LoadSettings(settingsUtils); + NewPlusViewModel.CopyTemplateExamples(settings.Properties.TemplateLocation.Value); + } + + // Re-sort only required if sorting by enabled status. + if (DashboardSortOrder == DashboardSortOrder.ByStatus) + { + SortModuleList(); + } + + // Always refresh shortcuts/actions to reflect enabled state changes. + RefreshShortcutModules(); + + // Request updated conflicts after module state change. + RequestConflictData(); + } + finally + { + _isUpdatingFromUI = false; + } + } + + /// <summary> + /// Callback invoked when module enabled state changes from other parts of the + /// settings UI. Ignores the notification if it was triggered by a UI toggle + /// we're already handling, to prevent circular updates. + /// </summary> + public void ModuleEnabledChangedOnSettingsPage() + { + if (_isDisposed) + { + return; + } + + // Ignore if this was triggered by a UI change that we're already handling. + if (_isUpdatingFromUI) + { + return; + } + + try + { + RefreshModuleList(); + RefreshShortcutModules(); + + OnPropertyChanged(nameof(ShortcutModules)); + + // Request updated conflicts after module state change. + RequestConflictData(); } catch (Exception ex) { @@ -166,29 +411,92 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + /// <summary> + /// Rebuilds ShortcutModules and ActionModules collections by filtering AllModules + /// to only include enabled modules and their respective shortcut/action items. + /// </summary> + private void RefreshShortcutModules() + { + if (_isDisposed) + { + return; + } + + if (!dispatcher.HasThreadAccess) + { + _ = dispatcher.TryEnqueue(DispatcherQueuePriority.Normal, RefreshShortcutModules); + return; + } + + ShortcutModules.Clear(); + ActionModules.Clear(); + + foreach (var x in AllModules.Where(x => x.IsEnabled)) + { + var filteredItems = x.DashboardModuleItems + .Where(m => m is DashboardModuleShortcutItem || m is DashboardModuleActivationItem) + .ToList(); + + if (filteredItems.Count != 0) + { + var newItem = new DashboardListItem + { + Icon = x.Icon, + IsLocked = x.IsLocked, + Label = x.Label, + Tag = x.Tag, + IsEnabled = x.IsEnabled, + DashboardModuleItems = new ObservableCollection<DashboardModuleItem>(filteredItems), + }; + + ShortcutModules.Add(newItem); + newItem.EnabledChangedCallback = x.EnabledChangedCallback; + } + } + + foreach (var x in AllModules.Where(x => x.IsEnabled)) + { + var filteredItems = x.DashboardModuleItems + .Where(m => m is DashboardModuleButtonItem) + .ToList(); + + if (filteredItems.Count != 0) + { + var newItem = new DashboardListItem + { + Icon = x.Icon, + IsLocked = x.IsLocked, + Label = x.Label, + Tag = x.Tag, + IsEnabled = x.IsEnabled, + DashboardModuleItems = new ObservableCollection<DashboardModuleItem>(filteredItems), + }; + + ActionModules.Add(newItem); + newItem.EnabledChangedCallback = x.EnabledChangedCallback; + } + } + } + private ObservableCollection<DashboardModuleItem> GetModuleItems(ModuleType moduleType) { return moduleType switch { ModuleType.AdvancedPaste => GetModuleItemsAdvancedPaste(), ModuleType.AlwaysOnTop => GetModuleItemsAlwaysOnTop(), - ModuleType.Awake => GetModuleItemsAwake(), ModuleType.CmdPal => GetModuleItemsCmdPal(), ModuleType.ColorPicker => GetModuleItemsColorPicker(), ModuleType.CropAndLock => GetModuleItemsCropAndLock(), ModuleType.EnvironmentVariables => GetModuleItemsEnvironmentVariables(), ModuleType.FancyZones => GetModuleItemsFancyZones(), - ModuleType.FileLocksmith => GetModuleItemsFileLocksmith(), ModuleType.FindMyMouse => GetModuleItemsFindMyMouse(), ModuleType.Hosts => GetModuleItemsHosts(), - ModuleType.ImageResizer => GetModuleItemsImageResizer(), - ModuleType.KeyboardManager => GetModuleItemsKeyboardManager(), + ModuleType.LightSwitch => GetModuleItemsLightSwitch(), ModuleType.MouseHighlighter => GetModuleItemsMouseHighlighter(), ModuleType.MouseJump => GetModuleItemsMouseJump(), ModuleType.MousePointerCrosshairs => GetModuleItemsMousePointerCrosshairs(), - ModuleType.MouseWithoutBorders => GetModuleItemsMouseWithoutBorders(), ModuleType.Peek => GetModuleItemsPeek(), - ModuleType.PowerRename => GetModuleItemsPowerRename(), + ModuleType.PowerDisplay => GetModuleItemsPowerDisplay(), ModuleType.PowerLauncher => GetModuleItemsPowerLauncher(), ModuleType.PowerAccent => GetModuleItemsPowerAccent(), ModuleType.Workspaces => GetModuleItemsWorkspaces(), @@ -196,15 +504,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels ModuleType.MeasureTool => GetModuleItemsMeasureTool(), ModuleType.ShortcutGuide => GetModuleItemsShortcutGuide(), ModuleType.PowerOCR => GetModuleItemsPowerOCR(), - ModuleType.NewPlus => GetModuleItemsNewPlus(), - ModuleType.ZoomIt => GetModuleItemsZoomIt(), _ => new ObservableCollection<DashboardModuleItem>(), // never called, all values listed above }; } private ObservableCollection<DashboardModuleItem> GetModuleItemsAlwaysOnTop() { - ISettingsRepository<AlwaysOnTopSettings> moduleSettingsRepository = SettingsRepository<AlwaysOnTopSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<AlwaysOnTopSettings> moduleSettingsRepository = SettingsRepository<AlwaysOnTopSettings>.GetInstance(SettingsUtils.Default); var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("AlwaysOnTop_ShortDescription"), Shortcut = moduleSettingsRepository.SettingsConfig.Properties.Hotkey.Value.GetKeysList() }, @@ -212,29 +518,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels return new ObservableCollection<DashboardModuleItem>(list); } - private ObservableCollection<DashboardModuleItem> GetModuleItemsAwake() - { - var list = new List<DashboardModuleItem> - { - new DashboardModuleTextItem() { Label = resourceLoader.GetString("Awake_ShortDescription") }, - }; - return new ObservableCollection<DashboardModuleItem>(list); - } - private ObservableCollection<DashboardModuleItem> GetModuleItemsCmdPal() { var hotkey = new CmdPalProperties().Hotkey; var list = new List<DashboardModuleItem> { - new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("CmdPal_ShortDescription"), Shortcut = hotkey.GetKeysList() }, + new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("CmdPal_ActivationDescription"), Shortcut = hotkey.GetKeysList() }, }; return new ObservableCollection<DashboardModuleItem>(list); } private ObservableCollection<DashboardModuleItem> GetModuleItemsColorPicker() { - ISettingsRepository<ColorPickerSettings> moduleSettingsRepository = SettingsRepository<ColorPickerSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<ColorPickerSettings> moduleSettingsRepository = SettingsRepository<ColorPickerSettings>.GetInstance(SettingsUtils.Default); var settings = moduleSettingsRepository.SettingsConfig; var hotkey = settings.Properties.ActivationShortcut; var list = new List<DashboardModuleItem> @@ -244,14 +541,26 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels return new ObservableCollection<DashboardModuleItem>(list); } + private ObservableCollection<DashboardModuleItem> GetModuleItemsLightSwitch() + { + ISettingsRepository<LightSwitchSettings> moduleSettingsRepository = SettingsRepository<LightSwitchSettings>.GetInstance(SettingsUtils.Default); + var settings = moduleSettingsRepository.SettingsConfig; + var list = new List<DashboardModuleItem> + { + new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("LightSwitch_ForceDarkMode"), Shortcut = settings.Properties.ToggleThemeHotkey.Value.GetKeysList() }, + }; + return new ObservableCollection<DashboardModuleItem>(list); + } + private ObservableCollection<DashboardModuleItem> GetModuleItemsCropAndLock() { - ISettingsRepository<CropAndLockSettings> moduleSettingsRepository = SettingsRepository<CropAndLockSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<CropAndLockSettings> moduleSettingsRepository = SettingsRepository<CropAndLockSettings>.GetInstance(SettingsUtils.Default); var settings = moduleSettingsRepository.SettingsConfig; var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("CropAndLock_Thumbnail"), Shortcut = settings.Properties.ThumbnailHotkey.Value.GetKeysList() }, new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("CropAndLock_Reparent"), Shortcut = settings.Properties.ReparentHotkey.Value.GetKeysList() }, + new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("CropAndLock_Screenshot"), Shortcut = settings.Properties.ScreenshotHotkey.Value.GetKeysList() }, }; return new ObservableCollection<DashboardModuleItem>(list); } @@ -260,42 +569,29 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { var list = new List<DashboardModuleItem> { - new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("EnvironmentVariables_LaunchButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("EnvironmentVariables_LaunchButtonControl/Description"), ButtonGlyph = "\uEA37", ButtonClickHandler = EnvironmentVariablesLaunchClicked }, + new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("EnvironmentVariables_LaunchButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("EnvironmentVariables_LaunchButtonControl/Description"), ButtonGlyph = "ms-appx:///Assets/Settings/Icons/EnvironmentVariables.png", ButtonClickHandler = EnvironmentVariablesLaunchClicked }, }; return new ObservableCollection<DashboardModuleItem>(list); } private ObservableCollection<DashboardModuleItem> GetModuleItemsFancyZones() { - ISettingsRepository<FancyZonesSettings> moduleSettingsRepository = SettingsRepository<FancyZonesSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<FancyZonesSettings> moduleSettingsRepository = SettingsRepository<FancyZonesSettings>.GetInstance(SettingsUtils.Default); var settings = moduleSettingsRepository.SettingsConfig; - string activationMode = $"{resourceLoader.GetString(settings.Properties.FancyzonesShiftDrag.Value ? "FancyZones_ShiftDragCheckBoxControl_Header/Content" : "FancyZones_ActivationNoShiftDrag")}."; - if (settings.Properties.FancyzonesMouseSwitch.Value) - { - activationMode += $" {resourceLoader.GetString("FancyZones_MouseDragCheckBoxControl_Header/Content")}."; - } + string activationMode = $"{resourceLoader.GetString(settings.Properties.FancyzonesShiftDrag.Value ? "FancyZones_ActivationShiftDrag" : "FancyZones_ActivationNoShiftDrag")}."; var list = new List<DashboardModuleItem> { - new DashboardModuleTextItem() { Label = activationMode }, + new DashboardModuleActivationItem() { Label = resourceLoader.GetString("Activate_Zones"), Activation = activationMode }, new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("FancyZones_OpenEditor"), Shortcut = settings.Properties.FancyzonesEditorHotkey.Value.GetKeysList() }, - new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("FancyZones_LaunchEditorButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("FancyZones_LaunchEditorButtonControl/Description"), ButtonGlyph = "\uEB3C", ButtonClickHandler = FancyZoneLaunchClicked }, - }; - return new ObservableCollection<DashboardModuleItem>(list); - } - - private ObservableCollection<DashboardModuleItem> GetModuleItemsFileLocksmith() - { - var list = new List<DashboardModuleItem> - { - new DashboardModuleTextItem() { Label = resourceLoader.GetString("FileLocksmith_ShortDescription") }, + new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("FancyZones_LaunchEditorButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("FancyZones_LaunchEditorButtonControl/Description"), ButtonGlyph = "ms-appx:///Assets/Settings/Icons/FancyZones.png", ButtonClickHandler = FancyZoneLaunchClicked }, }; return new ObservableCollection<DashboardModuleItem>(list); } private ObservableCollection<DashboardModuleItem> GetModuleItemsFindMyMouse() { - ISettingsRepository<FindMyMouseSettings> moduleSettingsRepository = SettingsRepository<FindMyMouseSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<FindMyMouseSettings> moduleSettingsRepository = SettingsRepository<FindMyMouseSettings>.GetInstance(SettingsUtils.Default); string shortDescription = resourceLoader.GetString("FindMyMouse_ShortDescription"); var settings = moduleSettingsRepository.SettingsConfig; var activationMethod = settings.Properties.ActivationMethod.Value; @@ -307,15 +603,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } else { + string activation = string.Empty; switch (activationMethod) { - case 2: shortDescription += $". {resourceLoader.GetString("Dashboard_Activation")}: {resourceLoader.GetString("MouseUtils_FindMyMouse_ActivationShakeMouse/Content")}"; break; - case 1: shortDescription += $". {resourceLoader.GetString("Dashboard_Activation")}: {resourceLoader.GetString("MouseUtils_FindMyMouse_ActivationDoubleRightControlPress/Content")}"; break; + case 2: activation = resourceLoader.GetString("MouseUtils_FindMyMouse_ActivationShakeMouse/Content"); break; + case 1: activation = resourceLoader.GetString("MouseUtils_FindMyMouse_ActivationDoubleRightControlPress/Content"); break; case 0: - default: shortDescription += $". {resourceLoader.GetString("Dashboard_Activation")}: {resourceLoader.GetString("MouseUtils_FindMyMouse_ActivationDoubleControlPress/Content")}"; break; + default: activation = resourceLoader.GetString("MouseUtils_FindMyMouse_ActivationDoubleControlPress/Content"); break; } - list.Add(new DashboardModuleTextItem() { Label = shortDescription }); + list.Add(new DashboardModuleActivationItem() { Label = resourceLoader.GetString("Dashboard_Activation"), Activation = activation }); } return new ObservableCollection<DashboardModuleItem>(list); @@ -325,43 +622,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { var list = new List<DashboardModuleItem> { - new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("Hosts_LaunchButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("Hosts_LaunchButtonControl/Description"), ButtonGlyph = "\uEA37", ButtonClickHandler = HostLaunchClicked }, - }; - return new ObservableCollection<DashboardModuleItem>(list); - } - - private ObservableCollection<DashboardModuleItem> GetModuleItemsImageResizer() - { - var list = new List<DashboardModuleItem> - { - new DashboardModuleTextItem() { Label = resourceLoader.GetString("ImageResizer_ShortDescription") }, - }; - return new ObservableCollection<DashboardModuleItem>(list); - } - - private ObservableCollection<DashboardModuleItem> GetModuleItemsKeyboardManager() - { - KeyboardManagerProfile kbmProfile = GetKBMProfile(); - _kbmItem = new DashboardModuleKBMItem() { RemapKeys = kbmProfile?.RemapKeys.InProcessRemapKeys, RemapShortcuts = KeyboardManagerViewModel.CombineShortcutLists(kbmProfile?.RemapShortcuts.GlobalRemapShortcuts, kbmProfile?.RemapShortcuts.AppSpecificRemapShortcuts) }; - - _kbmItem.RemapKeys = _kbmItem.RemapKeys.Concat(kbmProfile?.RemapKeysToText.InProcessRemapKeys).ToList(); - - var shortcutsToTextRemappings = KeyboardManagerViewModel.CombineShortcutLists(kbmProfile?.RemapShortcutsToText.GlobalRemapShortcuts, kbmProfile?.RemapShortcutsToText.AppSpecificRemapShortcuts); - - _kbmItem.RemapShortcuts = _kbmItem.RemapShortcuts.Concat(shortcutsToTextRemappings).ToList(); - - var list = new List<DashboardModuleItem> - { - _kbmItem, - new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("KeyboardManager_RemapKeyboardButton/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("KeyboardManager_RemapKeyboardButton/Description"), ButtonGlyph = "\uE92E", ButtonClickHandler = KbmKeyLaunchClicked }, - new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("KeyboardManager_RemapShortcutsButton/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("KeyboardManager_RemapShortcutsButton/Description"), ButtonGlyph = "\uE92E", ButtonClickHandler = KbmShortcutLaunchClicked }, + new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("Hosts_LaunchButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("Hosts_LaunchButtonControl/Description"), ButtonGlyph = "ms-appx:///Assets/Settings/Icons/Hosts.png", ButtonClickHandler = HostLaunchClicked }, }; return new ObservableCollection<DashboardModuleItem>(list); } private ObservableCollection<DashboardModuleItem> GetModuleItemsMouseHighlighter() { - ISettingsRepository<MouseHighlighterSettings> moduleSettingsRepository = SettingsRepository<MouseHighlighterSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<MouseHighlighterSettings> moduleSettingsRepository = SettingsRepository<MouseHighlighterSettings>.GetInstance(SettingsUtils.Default); var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("MouseHighlighter_ShortDescription"), Shortcut = moduleSettingsRepository.SettingsConfig.Properties.ActivationShortcut.GetKeysList() }, @@ -371,7 +639,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private ObservableCollection<DashboardModuleItem> GetModuleItemsMouseJump() { - ISettingsRepository<MouseJumpSettings> moduleSettingsRepository = SettingsRepository<MouseJumpSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<MouseJumpSettings> moduleSettingsRepository = SettingsRepository<MouseJumpSettings>.GetInstance(SettingsUtils.Default); var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("MouseJump_ShortDescription"), Shortcut = moduleSettingsRepository.SettingsConfig.Properties.ActivationShortcut.GetKeysList() }, @@ -381,7 +649,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private ObservableCollection<DashboardModuleItem> GetModuleItemsMousePointerCrosshairs() { - ISettingsRepository<MousePointerCrosshairsSettings> moduleSettingsRepository = SettingsRepository<MousePointerCrosshairsSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<MousePointerCrosshairsSettings> moduleSettingsRepository = SettingsRepository<MousePointerCrosshairsSettings>.GetInstance(SettingsUtils.Default); var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("MouseCrosshairs_ShortDescription"), Shortcut = moduleSettingsRepository.SettingsConfig.Properties.ActivationShortcut.GetKeysList() }, @@ -389,18 +657,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels return new ObservableCollection<DashboardModuleItem>(list); } - private ObservableCollection<DashboardModuleItem> GetModuleItemsMouseWithoutBorders() - { - var list = new List<DashboardModuleItem> - { - new DashboardModuleTextItem() { Label = resourceLoader.GetString("MouseWithoutBorders_ShortDescription") }, - }; - return new ObservableCollection<DashboardModuleItem>(list); - } - private ObservableCollection<DashboardModuleItem> GetModuleItemsAdvancedPaste() { - ISettingsRepository<AdvancedPasteSettings> moduleSettingsRepository = SettingsRepository<AdvancedPasteSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<AdvancedPasteSettings> moduleSettingsRepository = SettingsRepository<AdvancedPasteSettings>.GetInstance(SettingsUtils.Default); var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("AdvancedPasteUI_Shortcut/Header"), Shortcut = moduleSettingsRepository.SettingsConfig.Properties.AdvancedPasteUIShortcut.GetKeysList() }, @@ -422,7 +681,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private ObservableCollection<DashboardModuleItem> GetModuleItemsPeek() { - ISettingsRepository<PeekSettings> moduleSettingsRepository = SettingsRepository<PeekSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<PeekSettings> moduleSettingsRepository = SettingsRepository<PeekSettings>.GetInstance(SettingsUtils.Default); var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("Peek_ShortDescription"), Shortcut = moduleSettingsRepository.SettingsConfig.Properties.ActivationShortcut.GetKeysList() }, @@ -430,18 +689,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels return new ObservableCollection<DashboardModuleItem>(list); } - private ObservableCollection<DashboardModuleItem> GetModuleItemsPowerRename() - { - var list = new List<DashboardModuleItem> - { - new DashboardModuleTextItem() { Label = resourceLoader.GetString("PowerRename_ShortDescription") }, - }; - return new ObservableCollection<DashboardModuleItem>(list); - } - private ObservableCollection<DashboardModuleItem> GetModuleItemsPowerLauncher() { - ISettingsRepository<PowerLauncherSettings> moduleSettingsRepository = SettingsRepository<PowerLauncherSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<PowerLauncherSettings> moduleSettingsRepository = SettingsRepository<PowerLauncherSettings>.GetInstance(SettingsUtils.Default); var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("Run_ShortDescription"), Shortcut = moduleSettingsRepository.SettingsConfig.Properties.OpenPowerLauncher.GetKeysList() }, @@ -451,33 +701,33 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private ObservableCollection<DashboardModuleItem> GetModuleItemsPowerAccent() { - string shortDescription = resourceLoader.GetString("PowerAccent_ShortDescription"); - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; PowerAccentSettings moduleSettings = settingsUtils.GetSettingsOrDefault<PowerAccentSettings>(PowerAccentSettings.ModuleName); var activationMethod = moduleSettings.Properties.ActivationKey; + string activation = string.Empty; switch (activationMethod) { - case Library.Enumerations.PowerAccentActivationKey.LeftRightArrow: shortDescription += $". {resourceLoader.GetString("Dashboard_Activation")}: {resourceLoader.GetString("QuickAccent_Activation_Key_Arrows/Content")}"; break; - case Library.Enumerations.PowerAccentActivationKey.Space: shortDescription += $". {resourceLoader.GetString("Dashboard_Activation")}: {resourceLoader.GetString("QuickAccent_Activation_Key_Space/Content")}"; break; - case Library.Enumerations.PowerAccentActivationKey.Both: shortDescription += $". {resourceLoader.GetString("Dashboard_Activation")}: {resourceLoader.GetString("QuickAccent_Activation_Key_Either/Content")}"; break; + case Library.Enumerations.PowerAccentActivationKey.LeftRightArrow: activation = resourceLoader.GetString("QuickAccent_Activation_Key_Arrows/Content"); break; + case Library.Enumerations.PowerAccentActivationKey.Space: activation = resourceLoader.GetString("QuickAccent_Activation_Key_Space/Content"); break; + case Library.Enumerations.PowerAccentActivationKey.Both: activation = resourceLoader.GetString("QuickAccent_Activation_Key_Either/Content"); break; } var list = new List<DashboardModuleItem> { - new DashboardModuleTextItem() { Label = shortDescription }, + new DashboardModuleActivationItem() { Label = resourceLoader.GetString("Dashboard_Activation"), Activation = activation }, }; return new ObservableCollection<DashboardModuleItem>(list); } private ObservableCollection<DashboardModuleItem> GetModuleItemsWorkspaces() { - ISettingsRepository<WorkspacesSettings> moduleSettingsRepository = SettingsRepository<WorkspacesSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<WorkspacesSettings> moduleSettingsRepository = SettingsRepository<WorkspacesSettings>.GetInstance(SettingsUtils.Default); var settings = moduleSettingsRepository.SettingsConfig; var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("Workspaces_ShortDescription"), Shortcut = settings.Properties.Hotkey.Value.GetKeysList() }, - new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("Workspaces_LaunchEditorButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("FancyZones_LaunchEditorButtonControl/Description"), ButtonGlyph = "\uEB3C", ButtonClickHandler = WorkspacesLaunchClicked }, + new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("Workspaces_LaunchEditorButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("FancyZones_LaunchEditorButtonControl/Description"), ButtonGlyph = "ms-appx:///Assets/Settings/Icons/Workspaces.png", ButtonClickHandler = WorkspacesLaunchClicked }, }; return new ObservableCollection<DashboardModuleItem>(list); } @@ -486,14 +736,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { var list = new List<DashboardModuleItem> { - new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("RegistryPreview_LaunchButtonControl/Header"), ButtonGlyph = "\uEA37", ButtonClickHandler = RegistryPreviewLaunchClicked }, + new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("RegistryPreview_LaunchButtonControl/Header"), ButtonGlyph = "ms-appx:///Assets/Settings/Icons/RegistryPreview.png", ButtonClickHandler = RegistryPreviewLaunchClicked }, }; return new ObservableCollection<DashboardModuleItem>(list); } private ObservableCollection<DashboardModuleItem> GetModuleItemsMeasureTool() { - ISettingsRepository<MeasureToolSettings> moduleSettingsRepository = SettingsRepository<MeasureToolSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<MeasureToolSettings> moduleSettingsRepository = SettingsRepository<MeasureToolSettings>.GetInstance(SettingsUtils.Default); var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("ScreenRuler_ShortDescription"), Shortcut = moduleSettingsRepository.SettingsConfig.Properties.ActivationShortcut.GetKeysList() }, @@ -503,7 +753,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private ObservableCollection<DashboardModuleItem> GetModuleItemsShortcutGuide() { - ISettingsRepository<ShortcutGuideSettings> moduleSettingsRepository = SettingsRepository<ShortcutGuideSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<ShortcutGuideSettings> moduleSettingsRepository = SettingsRepository<ShortcutGuideSettings>.GetInstance(SettingsUtils.Default); var shortcut = moduleSettingsRepository.SettingsConfig.Properties.UseLegacyPressWinKeyBehavior.Value ? new List<object> { 92 } // Right Windows key code @@ -518,7 +768,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private ObservableCollection<DashboardModuleItem> GetModuleItemsPowerOCR() { - ISettingsRepository<PowerOcrSettings> moduleSettingsRepository = SettingsRepository<PowerOcrSettings>.GetInstance(new SettingsUtils()); + ISettingsRepository<PowerOcrSettings> moduleSettingsRepository = SettingsRepository<PowerOcrSettings>.GetInstance(SettingsUtils.Default); var list = new List<DashboardModuleItem> { new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("PowerOcr_ShortDescription"), Shortcut = moduleSettingsRepository.SettingsConfig.Properties.ActivationShortcut.GetKeysList() }, @@ -526,20 +776,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels return new ObservableCollection<DashboardModuleItem>(list); } - private ObservableCollection<DashboardModuleItem> GetModuleItemsNewPlus() + private ObservableCollection<DashboardModuleItem> GetModuleItemsPowerDisplay() { + ISettingsRepository<PowerDisplaySettings> moduleSettingsRepository = SettingsRepository<PowerDisplaySettings>.GetInstance(SettingsUtils.Default); + var settings = moduleSettingsRepository.SettingsConfig; var list = new List<DashboardModuleItem> { - new DashboardModuleTextItem() { Label = resourceLoader.GetString("NewPlus_Product_Description/Description") }, - }; - return new ObservableCollection<DashboardModuleItem>(list); - } - - private ObservableCollection<DashboardModuleItem> GetModuleItemsZoomIt() - { - var list = new List<DashboardModuleItem> - { - new DashboardModuleTextItem() { Label = resourceLoader.GetString("ZoomIt_ShortDescription") }, + new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("PowerDisplay_ToggleWindow"), Shortcut = settings.Properties.ActivationShortcut.GetKeysList() }, + new DashboardModuleButtonItem() { ButtonTitle = resourceLoader.GetString("PowerDisplay_LaunchButtonControl/Header"), IsButtonDescriptionVisible = true, ButtonDescription = resourceLoader.GetString("PowerDisplay_LaunchButtonControl/Description"), ButtonGlyph = "ms-appx:///Assets/Settings/Icons/PowerDisplay.png", ButtonClickHandler = PowerDisplayLaunchClicked }, }; return new ObservableCollection<DashboardModuleItem>(list); } @@ -551,14 +795,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private void EnvironmentVariablesLaunchClicked(object sender, RoutedEventArgs e) { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; var environmentVariablesViewModel = new EnvironmentVariablesViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<EnvironmentVariablesSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, App.IsElevated); environmentVariablesViewModel.Launch(); } private void HostLaunchClicked(object sender, RoutedEventArgs e) { - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; var hostsViewModel = new HostsViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<HostsSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, App.IsElevated); hostsViewModel.Launch(); } @@ -575,42 +819,41 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels SendConfigMSG("{\"action\":{\"Workspaces\":{\"action_name\":\"LaunchEditor\", \"value\":\"\"}}}"); } - private void KbmKeyLaunchClicked(object sender, RoutedEventArgs e) - { - var settingsUtils = new SettingsUtils(); - var kbmViewModel = new KeyboardManagerViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, KeyboardManagerPage.FilterRemapKeysList); - kbmViewModel.OnRemapKeyboard(); - } - - private void KbmShortcutLaunchClicked(object sender, RoutedEventArgs e) - { - var settingsUtils = new SettingsUtils(); - var kbmViewModel = new KeyboardManagerViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage, KeyboardManagerPage.FilterRemapKeysList); - kbmViewModel.OnEditShortcut(); - } - private void RegistryPreviewLaunchClicked(object sender, RoutedEventArgs e) { var actionName = "Launch"; SendConfigMSG("{\"action\":{\"RegistryPreview\":{\"action_name\":\"" + actionName + "\", \"value\":\"\"}}}"); } + private void PowerDisplayLaunchClicked(object sender, RoutedEventArgs e) + { + var actionName = "Launch"; + SendConfigMSG("{\"action\":{\"PowerDisplay\":{\"action_name\":\"" + actionName + "\", \"value\":\"\"}}}"); + } + internal void DashboardListItemClick(object sender) { - Button button = sender as Button; - if (button == null) + if (sender is ModuleType moduleType) + { + NavigationService.Navigate(ModuleGpoHelper.GetModulePageType(moduleType)); + } + } + + public override void Dispose() + { + if (_isDisposed) { return; } - if (!(button.Tag is ModuleType)) + _isDisposed = true; + base.Dispose(); + if (_settingsRepository != null) { - return; + _settingsRepository.SettingsChanged -= OnSettingsChanged; } - ModuleType moduleType = (ModuleType)button.Tag; - - NavigationService.Navigate(ModuleHelper.GetModulePageType(moduleType)); + GC.SuppressFinalize(this); } } } diff --git a/src/settings-ui/Settings.UI/ViewModels/EnvironmentVariablesViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/EnvironmentVariablesViewModel.cs index e256278d00..0cb2feb503 100644 --- a/src/settings-ui/Settings.UI/ViewModels/EnvironmentVariablesViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/EnvironmentVariablesViewModel.cs @@ -23,7 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _enabledStateIsGPOConfigured; private bool _isEnabled; - private ISettingsUtils SettingsUtils { get; set; } + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -79,7 +79,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - public EnvironmentVariablesViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<EnvironmentVariablesSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc, bool isElevated) + public EnvironmentVariablesViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<EnvironmentVariablesSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc, bool isElevated) { SettingsUtils = settingsUtils; GeneralSettingsConfig = settingsRepository.SettingsConfig; diff --git a/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs index cd8ace4703..3fa21824e6 100644 --- a/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/FancyZonesViewModel.cs @@ -3,9 +3,11 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Runtime.CompilerServices; using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -13,14 +15,14 @@ using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class FancyZonesViewModel : Observable + public partial class FancyZonesViewModel : PageViewModelBase { + protected override string ModuleName => FancyZonesSettings.ModuleName; + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } - private const string ModuleName = FancyZonesSettings.ModuleName; - public ButtonClickCommand LaunchEditorEventHandler { get; set; } private FancyZonesSettings Settings { get; set; } @@ -88,8 +90,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _excludedApps = Settings.Properties.FancyzonesExcludedApps.Value; _systemTheme = Settings.Properties.FancyzonesSystemTheme.Value; _showZoneNumber = Settings.Properties.FancyzonesShowZoneNumber.Value; - EditorHotkey = Settings.Properties.FancyzonesEditorHotkey.Value; _windowSwitching = Settings.Properties.FancyzonesWindowSwitching.Value; + + EditorHotkey = Settings.Properties.FancyzonesEditorHotkey.Value; NextTabHotkey = Settings.Properties.FancyzonesNextTabHotkey.Value; PrevTabHotkey = Settings.Properties.FancyzonesPrevTabHotkey.Value; @@ -134,6 +137,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [EditorHotkey, NextTabHotkey, PrevTabHotkey], + }; + + return hotkeysDict; + } + private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _enabledStateIsGPOConfigured; private bool _isEnabled; @@ -763,7 +776,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _editorHotkey) { - if (value == null || value.IsEmpty()) + if (value == null) { _editorHotkey = FZConfigProperties.DefaultEditorHotkeyValue; } @@ -809,7 +822,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _nextTabHotkey) { - if (value == null || value.IsEmpty()) + if (value == null) { _nextTabHotkey = FZConfigProperties.DefaultNextTabHotkeyValue; } @@ -835,7 +848,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _prevTabHotkey) { - if (value == null || value.IsEmpty()) + if (value == null) { _prevTabHotkey = FZConfigProperties.DefaultPrevTabHotkeyValue; } diff --git a/src/settings-ui/Settings.UI/ViewModels/FileLocksmithViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/FileLocksmithViewModel.cs index 226d0c8b08..f3ecf45c92 100644 --- a/src/settings-ui/Settings.UI/ViewModels/FileLocksmithViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/FileLocksmithViewModel.cs @@ -18,7 +18,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { private GeneralSettings GeneralSettingsConfig { get; set; } - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private FileLocksmithSettings Settings { get; set; } @@ -26,7 +26,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private string _settingsConfigFileFolder = string.Empty; - public FileLocksmithViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, string configFileSubfolder = "") + public FileLocksmithViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, string configFileSubfolder = "") { _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); diff --git a/src/settings-ui/Settings.UI/ViewModels/Flyout/AllAppsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/Flyout/AllAppsViewModel.cs deleted file mode 100644 index 3239da678f..0000000000 --- a/src/settings-ui/Settings.UI/ViewModels/Flyout/AllAppsViewModel.cs +++ /dev/null @@ -1,78 +0,0 @@ -// 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.ObjectModel; - -using global::PowerToys.GPOWrapper; -using ManagedCommon; -using Microsoft.PowerToys.Settings.UI.Helpers; -using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.Library.Helpers; -using Microsoft.PowerToys.Settings.UI.Library.Interfaces; -using Microsoft.Windows.ApplicationModel.Resources; - -namespace Microsoft.PowerToys.Settings.UI.ViewModels -{ - public partial class AllAppsViewModel : Observable - { - public ObservableCollection<FlyoutMenuItem> FlyoutMenuItems { get; set; } - - private ISettingsRepository<GeneralSettings> _settingsRepository; - private GeneralSettings generalSettingsConfig; - private ResourceLoader resourceLoader; - - private Func<string, int> SendConfigMSG { get; } - - public AllAppsViewModel(ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc) - { - _settingsRepository = settingsRepository; - generalSettingsConfig = settingsRepository.SettingsConfig; - generalSettingsConfig.AddEnabledModuleChangeNotification(ModuleEnabledChangedOnSettingsPage); - - resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader; - FlyoutMenuItems = new ObservableCollection<FlyoutMenuItem>(); - - foreach (ModuleType moduleType in Enum.GetValues<ModuleType>()) - { - AddFlyoutMenuItem(moduleType); - } - - // set the callback functions value to handle outgoing IPC message. - SendConfigMSG = ipcMSGCallBackFunc; - } - - private void AddFlyoutMenuItem(ModuleType moduleType) - { - GpoRuleConfigured gpo = ModuleHelper.GetModuleGpoConfiguration(moduleType); - FlyoutMenuItems.Add(new FlyoutMenuItem() - { - Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), - IsEnabled = gpo == GpoRuleConfigured.Enabled || (gpo != GpoRuleConfigured.Disabled && ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType)), - IsLocked = gpo == GpoRuleConfigured.Enabled || gpo == GpoRuleConfigured.Disabled, - Tag = moduleType, - Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), - EnabledChangedCallback = EnabledChangedOnUI, - }); - } - - private void EnabledChangedOnUI(FlyoutMenuItem flyoutMenuItem) - { - if (Views.ShellPage.UpdateGeneralSettingsCallback(flyoutMenuItem.Tag, flyoutMenuItem.IsEnabled)) - { - Views.ShellPage.DisableFlyoutHidingCallback(); - } - } - - private void ModuleEnabledChangedOnSettingsPage() - { - generalSettingsConfig = _settingsRepository.SettingsConfig; - generalSettingsConfig.AddEnabledModuleChangeNotification(ModuleEnabledChangedOnSettingsPage); - foreach (FlyoutMenuItem item in FlyoutMenuItems) - { - item.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, item.Tag); - } - } - } -} diff --git a/src/settings-ui/Settings.UI/ViewModels/Flyout/FlyoutMenuItem.cs b/src/settings-ui/Settings.UI/ViewModels/Flyout/FlyoutMenuItem.cs deleted file mode 100644 index 3b38c425a3..0000000000 --- a/src/settings-ui/Settings.UI/ViewModels/Flyout/FlyoutMenuItem.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.ComponentModel; -using System.Runtime.CompilerServices; - -using ManagedCommon; - -namespace Microsoft.PowerToys.Settings.UI.ViewModels -{ - public partial class FlyoutMenuItem : INotifyPropertyChanged - { - private bool _visible; - private bool _isEnabled; - - public string Label { get; set; } - - public string Icon { get; set; } - - public string ToolTip { get; set; } - - public ModuleType Tag { get; set; } - - public bool IsLocked { get; set; } - - public bool IsEnabled - { - get => _isEnabled; - set - { - if (_isEnabled != value) - { - _isEnabled = value; - OnPropertyChanged(); - EnabledChangedCallback?.Invoke(this); - } - } - } - - public Action<FlyoutMenuItem> EnabledChangedCallback { get; set; } - - public bool Visible - { - get => _visible; - set - { - if (_visible != value) - { - _visible = value; - OnPropertyChanged(); - } - } - } - - public event PropertyChangedEventHandler PropertyChanged; - - private void OnPropertyChanged([CallerMemberName] string propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - } -} diff --git a/src/settings-ui/Settings.UI/ViewModels/Flyout/FlyoutViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/Flyout/FlyoutViewModel.cs deleted file mode 100644 index 4a834d9d31..0000000000 --- a/src/settings-ui/Settings.UI/ViewModels/Flyout/FlyoutViewModel.cs +++ /dev/null @@ -1,57 +0,0 @@ -// 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.Timers; - -namespace Microsoft.PowerToys.Settings.UI.ViewModels.Flyout -{ - public partial class FlyoutViewModel : IDisposable - { - private Timer _hideTimer; - private bool _disposed; - - public bool CanHide { get; set; } - - public FlyoutViewModel() - { - CanHide = true; - _hideTimer = new Timer(); - _hideTimer.Elapsed += HideTimer_Elapsed; - _hideTimer.Interval = 1000; - _hideTimer.Enabled = false; - } - - private void HideTimer_Elapsed(object sender, ElapsedEventArgs e) - { - CanHide = true; - _hideTimer.Stop(); - } - - internal void DisableHiding() - { - CanHide = false; - _hideTimer.Stop(); - _hideTimer.Start(); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - _hideTimer?.Dispose(); - _disposed = true; - } - } - } - } -} diff --git a/src/settings-ui/Settings.UI/ViewModels/Flyout/LauncherViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/Flyout/LauncherViewModel.cs deleted file mode 100644 index a1db1e2dd0..0000000000 --- a/src/settings-ui/Settings.UI/ViewModels/Flyout/LauncherViewModel.cs +++ /dev/null @@ -1,130 +0,0 @@ -// 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.ObjectModel; - -using global::PowerToys.GPOWrapper; -using ManagedCommon; -using Microsoft.PowerToys.Settings.UI.Helpers; -using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.Library.Helpers; -using Microsoft.PowerToys.Settings.UI.Library.Interfaces; -using Microsoft.Windows.ApplicationModel.Resources; - -namespace Microsoft.PowerToys.Settings.UI.ViewModels -{ - public partial class LauncherViewModel : Observable - { - public bool IsUpdateAvailable { get; set; } - - public ObservableCollection<FlyoutMenuItem> FlyoutMenuItems { get; set; } - - private GeneralSettings generalSettingsConfig; - private UpdatingSettings updatingSettingsConfig; - private ISettingsRepository<GeneralSettings> _settingsRepository; - private ResourceLoader resourceLoader; - - private Func<string, int> SendIPCMessage { get; } - - public LauncherViewModel(ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc) - { - _settingsRepository = settingsRepository; - generalSettingsConfig = settingsRepository.SettingsConfig; - generalSettingsConfig.AddEnabledModuleChangeNotification(ModuleEnabledChanged); - - // set the callback functions value to handle outgoing IPC message. - SendIPCMessage = ipcMSGCallBackFunc; - resourceLoader = ResourceLoaderInstance.ResourceLoader; - FlyoutMenuItems = new ObservableCollection<FlyoutMenuItem>(); - - AddFlyoutMenuItem(ModuleType.ColorPicker); - AddFlyoutMenuItem(ModuleType.CmdPal); - AddFlyoutMenuItem(ModuleType.EnvironmentVariables); - AddFlyoutMenuItem(ModuleType.FancyZones); - AddFlyoutMenuItem(ModuleType.Hosts); - AddFlyoutMenuItem(ModuleType.PowerLauncher); - AddFlyoutMenuItem(ModuleType.PowerOCR); - AddFlyoutMenuItem(ModuleType.RegistryPreview); - AddFlyoutMenuItem(ModuleType.MeasureTool); - AddFlyoutMenuItem(ModuleType.ShortcutGuide); - AddFlyoutMenuItem(ModuleType.Workspaces); - - updatingSettingsConfig = UpdatingSettings.LoadSettings(); - if (updatingSettingsConfig == null) - { - updatingSettingsConfig = new UpdatingSettings(); - } - - if (updatingSettingsConfig.State == UpdatingSettings.UpdatingState.ReadyToInstall || updatingSettingsConfig.State == UpdatingSettings.UpdatingState.ReadyToDownload) - { - IsUpdateAvailable = true; - } - else - { - IsUpdateAvailable = false; - } - } - - private void AddFlyoutMenuItem(ModuleType moduleType) - { - if (ModuleHelper.GetModuleGpoConfiguration(moduleType) == GpoRuleConfigured.Disabled) - { - return; - } - - FlyoutMenuItems.Add(new FlyoutMenuItem() - { - Label = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)), - Tag = moduleType, - Visible = ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, moduleType), - ToolTip = GetModuleToolTip(moduleType), - Icon = ModuleHelper.GetModuleTypeFluentIconName(moduleType), - }); - } - - private string GetModuleToolTip(ModuleType moduleType) - { - return moduleType switch - { - ModuleType.ColorPicker => SettingsRepository<ColorPickerSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.ActivationShortcut.ToString(), - ModuleType.FancyZones => SettingsRepository<FancyZonesSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.FancyzonesEditorHotkey.Value.ToString(), - ModuleType.PowerLauncher => SettingsRepository<PowerLauncherSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.OpenPowerLauncher.ToString(), - ModuleType.PowerOCR => SettingsRepository<PowerOcrSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.ActivationShortcut.ToString(), - ModuleType.Workspaces => SettingsRepository<WorkspacesSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.Hotkey.Value.ToString(), - ModuleType.MeasureTool => SettingsRepository<MeasureToolSettings>.GetInstance(new SettingsUtils()).SettingsConfig.Properties.ActivationShortcut.ToString(), - ModuleType.ShortcutGuide => GetShortcutGuideToolTip(), - _ => string.Empty, - }; - } - - private void ModuleEnabledChanged() - { - generalSettingsConfig = _settingsRepository.SettingsConfig; - generalSettingsConfig.AddEnabledModuleChangeNotification(ModuleEnabledChanged); - foreach (FlyoutMenuItem item in FlyoutMenuItems) - { - item.Visible = ModuleHelper.GetIsModuleEnabled(generalSettingsConfig, item.Tag); - } - } - - private string GetShortcutGuideToolTip() - { - var shortcutGuideSettings = SettingsRepository<ShortcutGuideSettings>.GetInstance(new SettingsUtils()).SettingsConfig; - return shortcutGuideSettings.Properties.UseLegacyPressWinKeyBehavior.Value - ? "Win" - : shortcutGuideSettings.Properties.OpenShortcutGuide.ToString(); - } - - internal void StartBugReport() - { - SendIPCMessage("{\"bugreport\": 0 }"); - } - - internal void KillRunner() - { - SendIPCMessage("{\"killrunner\": 0 }"); - } - } -} diff --git a/src/settings-ui/Settings.UI/ViewModels/GeneralViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/GeneralViewModel.cs index 3669681bb7..3919eb0a78 100644 --- a/src/settings-ui/Settings.UI/ViewModels/GeneralViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/GeneralViewModel.cs @@ -9,6 +9,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Abstractions; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; @@ -25,11 +26,29 @@ using Microsoft.PowerToys.Settings.UI.Library.Utilities; using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands; using Microsoft.PowerToys.Settings.UI.SerializationContext; using Microsoft.PowerToys.Telemetry; +using Microsoft.Win32; +using Windows.System.Profile; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class GeneralViewModel : Observable + public partial class GeneralViewModel : PageViewModelBase { + public enum InstallScope + { + PerMachine = 0, + PerUser, + } + + protected override string ModuleName => "GeneralSettings"; + + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + return new Dictionary<string, HotkeySettings[]> + { + { ModuleName, new HotkeySettings[] { QuickAccessShortcut } }, + }; + } + private GeneralSettings GeneralSettingsConfig { get; set; } private UpdatingSettings UpdatingSettingsConfig { get; set; } @@ -66,12 +85,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private string _settingsConfigFileFolder = string.Empty; + private ISettingsRepository<GeneralSettings> _settingsRepository; + private Microsoft.UI.Dispatching.DispatcherQueue _dispatcherQueue; + private IFileSystemWatcher _fileWatcher; private Func<Task<string>> PickSingleFolderDialog { get; } private SettingsBackupAndRestoreUtils settingsBackupAndRestoreUtils = SettingsBackupAndRestoreUtils.Instance; + private const string InstallScopeRegKey = @"Software\Classes\powertoys\"; + public GeneralViewModel(ISettingsRepository<GeneralSettings> settingsRepository, string runAsAdminText, string runAsUserText, bool isElevated, bool isAdmin, Func<string, int> ipcMSGCallBackFunc, Func<string, int> ipcMSGRestartAsAdminMSGCallBackFunc, Func<string, int> ipcMSGCheckForUpdatesCallBackFunc, string configFileSubfolder = "", Action dispatcherAction = null, Action hideBackupAndRestoreMessageAreaAction = null, Action<int> doBackupAndRestoreDryRun = null, Func<Task<string>> pickSingleFolderDialog = null, Windows.ApplicationModel.Resources.ResourceLoader resourceLoader = null) { CheckForUpdatesEventHandler = new ButtonClickCommand(CheckForUpdatesClick); @@ -89,6 +113,10 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels // To obtain the general settings configuration of PowerToys if it exists, else to create a new file and return the default configurations. ArgumentNullException.ThrowIfNull(settingsRepository); + _settingsRepository = settingsRepository; + _settingsRepository.SettingsChanged += OnSettingsChanged; + _dispatcherQueue = GetDispatcherQueue(); + GeneralSettingsConfig = settingsRepository.SettingsConfig; UpdatingSettingsConfig = UpdatingSettings.LoadSettings(); if (UpdatingSettingsConfig == null) @@ -135,6 +163,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _startup = GeneralSettingsConfig.Startup; } + _showSysTrayIcon = GeneralSettingsConfig.ShowSysTrayIcon; + _showThemeAdaptiveSysTrayIcon = GeneralSettingsConfig.ShowThemeAdaptiveTrayIcon; _showNewUpdatesToastNotification = GeneralSettingsConfig.ShowNewUpdatesToastNotification; _autoDownloadUpdates = GeneralSettingsConfig.AutoDownloadUpdates; _showWhatsNewAfterUpdates = GeneralSettingsConfig.ShowWhatsNewAfterUpdates; @@ -143,6 +173,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _isElevated = isElevated; _runElevated = GeneralSettingsConfig.RunElevated; _enableWarningsElevatedApps = GeneralSettingsConfig.EnableWarningsElevatedApps; + _enableQuickAccess = GeneralSettingsConfig.EnableQuickAccess; + _quickAccessShortcut = GeneralSettingsConfig.QuickAccessShortcut; + if (_quickAccessShortcut != null) + { + _quickAccessShortcut.PropertyChanged += QuickAccessShortcut_PropertyChanged; + } RunningAsUserDefaultText = runAsUserText; RunningAsAdminDefaultText = runAsAdminText; @@ -217,12 +253,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private static bool _isDevBuild; private bool _startup; + private bool _showSysTrayIcon; + private bool _showThemeAdaptiveSysTrayIcon; private GpoRuleConfigured _runAtStartupGpoRuleConfiguration; private bool _runAtStartupIsGPOConfigured; private bool _isElevated; private bool _runElevated; private bool _isAdmin; private bool _enableWarningsElevatedApps; + private bool _enableQuickAccess; + private HotkeySettings _quickAccessShortcut; private int _themeIndex; private bool _showNewUpdatesToastNotification; @@ -247,6 +287,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _isNewVersionDownloading; private bool _isNewVersionChecked; private bool _isNoNetwork; + private bool _isBugReportRunning; private bool _settingsBackupRestoreMessageVisible; private string _settingsBackupMessage; @@ -256,6 +297,73 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private int _initLanguagesIndex; private bool _languageChanged; + private string reportBugLink; + + // Gets or sets a value indicating whether run powertoys on start-up. + public string ReportBugLink + { + get => reportBugLink; + set + { + reportBugLink = value; + OnPropertyChanged(nameof(ReportBugLink)); + } + } + + public void InitializeReportBugLink() + { + var version = GetPowerToysVersion(); + + string isElevatedString = "PowerToys is running " + (IsElevated ? "as admin (elevated)" : "as user (non-elevated)"); + + string installScope = GetCurrentInstallScope() == InstallScope.PerMachine ? "per machine (system)" : "per user"; + + var info = $"OS Version: {GetOSVersion()} \n.NET Version: {GetDotNetVersion()}\n{isElevatedString}\nInstall scope: {installScope}\nOperating System Language: {CultureInfo.InstalledUICulture.DisplayName}\nSystem locale: {CultureInfo.InstalledUICulture.Name}"; + + var gitHubURL = "https://github.com/microsoft/PowerToys/issues/new?template=bug_report.yml&labels=Issue-Bug%2CTriage-Needed" + + "&version=" + version + "&additionalInfo=" + System.Web.HttpUtility.UrlEncode(info); + + ReportBugLink = gitHubURL; + } + + private string GetPowerToysVersion() + { + return Helper.GetProductVersion().TrimStart('v'); + } + + private string GetOSVersion() + { + return Environment.OSVersion.VersionString; + } + + public static string GetDotNetVersion() + { + return $".NET {Environment.Version}"; + } + + public static InstallScope GetCurrentInstallScope() + { + // Check HKLM first + if (Registry.LocalMachine.OpenSubKey(InstallScopeRegKey) != null) + { + return InstallScope.PerMachine; + } + + // If not found, check HKCU + var userKey = Registry.CurrentUser.OpenSubKey(InstallScopeRegKey); + if (userKey != null) + { + var installScope = userKey.GetValue("InstallScope") as string; + userKey.Close(); + if (!string.IsNullOrEmpty(installScope) && installScope.Contains("perUser")) + { + return InstallScope.PerUser; + } + } + + return InstallScope.PerMachine; // Default if no specific registry key found + } + // Gets or sets a value indicating whether run powertoys on start-up. public bool Startup { @@ -281,6 +389,43 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + // Gets or sets a value indicating whether the PowerToys icon should be shown in the system tray. + public bool ShowSysTrayIcon + { + get + { + return _showSysTrayIcon; + } + + set + { + if (_showSysTrayIcon != value) + { + _showSysTrayIcon = value; + GeneralSettingsConfig.ShowSysTrayIcon = value; + NotifyPropertyChanged(); + } + } + } + + public bool ShowThemeAdaptiveTrayIcon + { + get + { + return _showThemeAdaptiveSysTrayIcon; + } + + set + { + if (_showThemeAdaptiveSysTrayIcon != value) + { + _showThemeAdaptiveSysTrayIcon = value; + GeneralSettingsConfig.ShowThemeAdaptiveTrayIcon = value; + NotifyPropertyChanged(); + } + } + } + public string RunningAsText { get @@ -380,6 +525,57 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool EnableQuickAccess + { + get + { + return _enableQuickAccess; + } + + set + { + if (_enableQuickAccess != value) + { + _enableQuickAccess = value; + GeneralSettingsConfig.EnableQuickAccess = value; + NotifyPropertyChanged(); + } + } + } + + public HotkeySettings QuickAccessShortcut + { + get + { + return _quickAccessShortcut; + } + + set + { + if (_quickAccessShortcut != value) + { + if (_quickAccessShortcut != null) + { + _quickAccessShortcut.PropertyChanged -= QuickAccessShortcut_PropertyChanged; + } + + _quickAccessShortcut = value; + if (_quickAccessShortcut != null) + { + _quickAccessShortcut.PropertyChanged += QuickAccessShortcut_PropertyChanged; + } + + GeneralSettingsConfig.QuickAccessShortcut = value; + NotifyPropertyChanged(); + } + } + } + + private void QuickAccessShortcut_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + NotifyPropertyChanged(nameof(QuickAccessShortcut)); + } + public bool SomeUpdateSettingsAreGpoManaged { get @@ -853,6 +1049,23 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool IsBugReportRunning + { + get + { + return _isBugReportRunning; + } + + set + { + if (value != _isBugReportRunning) + { + _isBugReportRunning = value; + NotifyPropertyChanged(); + } + } + } + public bool SettingsBackupRestoreMessageVisible { get @@ -1028,7 +1241,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _settingsBackupMessage = GetResourceString(results.Message) + results.OptionalMessage; // now we do a dry run to get the results for "setting match" - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; var appBasePath = Path.GetDirectoryName(settingsUtils.GetSettingsFilePath()); settingsBackupAndRestoreUtils.BackupSettings(appBasePath, settingsBackupAndRestoreDir, true); @@ -1317,5 +1530,35 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Process.Start("explorer.exe", etwDirPath); } } + + private void OnSettingsChanged(GeneralSettings newSettings) + { + _dispatcherQueue?.TryEnqueue(() => + { + GeneralSettingsConfig = newSettings; + + if (_enableQuickAccess != newSettings.EnableQuickAccess) + { + _enableQuickAccess = newSettings.EnableQuickAccess; + OnPropertyChanged(nameof(EnableQuickAccess)); + } + }); + } + + public override void Dispose() + { + base.Dispose(); + if (_settingsRepository != null) + { + _settingsRepository.SettingsChanged -= OnSettingsChanged; + } + + GC.SuppressFinalize(this); + } + + protected virtual Microsoft.UI.Dispatching.DispatcherQueue GetDispatcherQueue() + { + return Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); + } } } diff --git a/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs index d2bfb989e7..67c04ab948 100644 --- a/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/HostsViewModel.cs @@ -5,8 +5,8 @@ using System; using System.Runtime.CompilerServices; using System.Threading; - using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -23,7 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _enabledStateIsGPOConfigured; private bool _isEnabled; - private ISettingsUtils SettingsUtils { get; set; } + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -33,6 +33,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public ButtonClickCommand LaunchEventHandler => new ButtonClickCommand(Launch); + public ButtonClickCommand SelectBackupPathEventHandler => new ButtonClickCommand(SelectBackupPath); + public bool IsEnabled { get => _isEnabled; @@ -105,6 +107,19 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool NoLeadingSpaces + { + get => Settings.Properties.NoLeadingSpaces; + set + { + if (value != Settings.Properties.NoLeadingSpaces) + { + Settings.Properties.NoLeadingSpaces = value; + NotifyPropertyChanged(); + } + } + } + public int AdditionalLinesPosition { get => (int)Settings.Properties.AdditionalLinesPosition; @@ -131,7 +146,75 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - public HostsViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<HostsSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc, bool isElevated) + public bool BackupHosts + { + get => Settings.Properties.BackupHosts; + set + { + if (value != Settings.Properties.BackupHosts) + { + Settings.Properties.BackupHosts = value; + NotifyPropertyChanged(); + } + } + } + + public string BackupPath + { + get => Settings.Properties.BackupPath; + set + { + if (value != Settings.Properties.BackupPath) + { + Settings.Properties.BackupPath = value; + NotifyPropertyChanged(); + } + } + } + + public int DeleteBackupsMode + { + get => (int)Settings.Properties.DeleteBackupsMode; + set + { + if (value != (int)Settings.Properties.DeleteBackupsMode) + { + Settings.Properties.DeleteBackupsMode = (HostsDeleteBackupMode)value; + NotifyPropertyChanged(); + OnPropertyChanged(nameof(MinimumBackupsCount)); + } + } + } + + public int DeleteBackupsDays + { + get => Settings.Properties.DeleteBackupsDays; + set + { + if (value != Settings.Properties.DeleteBackupsDays) + { + Settings.Properties.DeleteBackupsDays = value; + NotifyPropertyChanged(); + } + } + } + + public int DeleteBackupsCount + { + get => Settings.Properties.DeleteBackupsCount; + set + { + if (value != Settings.Properties.DeleteBackupsCount) + { + Settings.Properties.DeleteBackupsCount = value; + NotifyPropertyChanged(); + } + } + } + + public int MinimumBackupsCount => DeleteBackupsMode == 1 ? 1 : 0; + + public HostsViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<HostsSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc, bool isElevated) { SettingsUtils = settingsUtils; GeneralSettingsConfig = settingsRepository.SettingsConfig; @@ -179,5 +262,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels InitializeEnabledValue(); OnPropertyChanged(nameof(IsEnabled)); } + + public void SelectBackupPath() + { + // This function was changed to use the shell32 API to open folder dialog + // as the old one (PickSingleFolderAsync) can't work when the process is elevated + // TODO: go back PickSingleFolderAsync when it's fixed + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow()); + var result = ShellGetFolder.GetFolderDialog(hwnd); + if (!string.IsNullOrEmpty(result)) + { + BackupPath = result; + } + } } } diff --git a/src/settings-ui/Settings.UI/ViewModels/ImageResizerViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ImageResizerViewModel.cs index 511a9cb36d..ffeb7e226e 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ImageResizerViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ImageResizerViewModel.cs @@ -45,7 +45,7 @@ public partial class ImageResizerViewModel : Observable private GeneralSettings GeneralSettingsConfig { get; set; } - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private ImageResizerSettings Settings { get; set; } @@ -53,7 +53,7 @@ public partial class ImageResizerViewModel : Observable private Func<string, int> SendConfigMSG { get; } - public ImageResizerViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, Func<string, string> resourceLoader) + public ImageResizerViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, Func<string, string> resourceLoader) { _isInitializing = true; @@ -67,6 +67,7 @@ public partial class ImageResizerViewModel : Observable try { Settings = _settingsUtils.GetSettings<ImageResizerSettings>(ModuleName); + IdRecoveryHelper.RecoverInvalidIds(Settings.Properties.ImageresizerSizes.Value); } catch (Exception e) { diff --git a/src/settings-ui/Settings.UI/ViewModels/KeyboardManagerViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/KeyboardManagerViewModel.cs index 3aa90cbc24..d7bf9862bc 100644 --- a/src/settings-ui/Settings.UI/ViewModels/KeyboardManagerViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/KeyboardManagerViewModel.cs @@ -27,7 +27,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { private GeneralSettings GeneralSettingsConfig { get; set; } - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private const string PowerToyName = KeyboardManagerSettings.ModuleName; private const string JsonFileType = ".json"; @@ -60,7 +60,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func<List<KeysDataModel>, int> FilterRemapKeysList { get; } - public KeyboardManagerViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, Func<List<KeysDataModel>, int> filterRemapKeysList) + public KeyboardManagerViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, Func<List<KeysDataModel>, int> filterRemapKeysList) { ArgumentNullException.ThrowIfNull(settingsRepository); @@ -274,7 +274,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels try { // Check if the experimentation toggle is enabled in the settings - var settingsUtils = new SettingsUtils(); + var settingsUtils = SettingsUtils.Default; bool isExperimentationEnabled = SettingsRepository<GeneralSettings>.GetInstance(settingsUtils).SettingsConfig.EnableExperimentation; // Only read the registry value if the experimentation toggle is enabled diff --git a/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs new file mode 100644 index 0000000000..02082d5a0e --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/LightSwitchViewModel.cs @@ -0,0 +1,908 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Windows.Input; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Newtonsoft.Json.Linq; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Services; +using PowerToys.GPOWrapper; +using Settings.UI.Library; +using Settings.UI.Library.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public partial class LightSwitchViewModel : PageViewModelBase + { + protected override string ModuleName => LightSwitchSettings.ModuleName; + + private Func<string, int> SendConfigMSG { get; } + + private GeneralSettings GeneralSettingsConfig { get; set; } + + public ObservableCollection<SearchLocation> SearchLocations { get; } = new(); + + public LightSwitchViewModel(ISettingsRepository<GeneralSettings> settingsRepository, LightSwitchSettings? initialSettings = null, Func<string, int>? ipcMSGCallBackFunc = null) + { + ArgumentNullException.ThrowIfNull(settingsRepository); + GeneralSettingsConfig = settingsRepository.SettingsConfig; + InitializeEnabledValue(); + + _moduleSettings = initialSettings ?? new LightSwitchSettings(); + SendConfigMSG = ipcMSGCallBackFunc ?? (_ => 0); + + ForceLightCommand = new RelayCommand(ForceLightNow); + ForceDarkCommand = new RelayCommand(ForceDarkNow); + + AvailableScheduleModes = new ObservableCollection<string> + { + "Off", + "FixedHours", + "SunsetToSunrise", + "FollowNightLight", + }; + + // Load PowerDisplay profiles + LoadPowerDisplayProfiles(); + + // Check if PowerDisplay is enabled + CheckPowerDisplayEnabled(); + } + + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [ToggleThemeActivationShortcut], + }; + + return hotkeysDict; + } + + private void InitializeEnabledValue() + { + _enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredLightSwitchEnabledValue(); + if (_enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO. + _enabledStateIsGPOConfigured = true; + _isEnabled = _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + } + else + { + _isEnabled = GeneralSettingsConfig.Enabled.LightSwitch; + } + } + + private void ForceLightNow() + { + Logger.LogInfo("Sending custom action: forceLight"); + SendCustomAction("forceLight"); + } + + private void ForceDarkNow() + { + Logger.LogInfo("Sending custom action: forceDark"); + SendCustomAction("forceDark"); + } + + private void SendCustomAction(string actionName) + { + SendConfigMSG("{\"action\":{\"LightSwitch\":{\"action_name\":\"" + actionName + "\", \"value\":\"\"}}}"); + } + + private void SaveSettings() + { + SendConfigMSG( + string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + LightSwitchSettings.ModuleName, + JsonSerializer.Serialize(_moduleSettings, SourceGenerationContextContext.Default.LightSwitchSettings))); + } + + public LightSwitchSettings ModuleSettings + { + get => _moduleSettings; + set + { + if (_moduleSettings != value) + { + _moduleSettings = value; + + OnPropertyChanged(nameof(ModuleSettings)); + RefreshModuleSettings(); + RefreshEnabledState(); + } + } + } + + public bool IsEnabled + { + get => _isEnabled; + + set + { + if (_enabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + if (value != _isEnabled) + { + _isEnabled = value; + + // Set the status in the general settings configuration + GeneralSettingsConfig.Enabled.LightSwitch = value; + OutGoingGeneralSettings snd = new OutGoingGeneralSettings(GeneralSettingsConfig); + + SendConfigMSG(snd.ToString()); + OnPropertyChanged(nameof(IsEnabled)); + } + } + } + + public bool IsEnabledGpoConfigured + { + get => _enabledStateIsGPOConfigured; + } + + public GpoRuleConfigured EnabledGPOConfiguration + { + get => _enabledGpoRuleConfiguration; + set + { + if (_enabledGpoRuleConfiguration != value) + { + _enabledGpoRuleConfiguration = value; + NotifyPropertyChanged(); + } + } + } + + public string ScheduleMode + { + get => ModuleSettings.Properties.ScheduleMode.Value; + set + { + var oldMode = ModuleSettings.Properties.ScheduleMode.Value; + if (ModuleSettings.Properties.ScheduleMode.Value != value) + { + ModuleSettings.Properties.ScheduleMode.Value = value; + OnPropertyChanged(nameof(ScheduleMode)); + } + + if (ModuleSettings.Properties.ScheduleMode.Value == "FixedHours" && oldMode != "FixedHours") + { + LightTime = 360; + DarkTime = 1080; + SunsetTimeSpan = null; + SunriseTimeSpan = null; + + OnPropertyChanged(nameof(LightTimePickerValue)); + OnPropertyChanged(nameof(DarkTimePickerValue)); + } + + if (ModuleSettings.Properties.ScheduleMode.Value == "SunsetToSunrise") + { + if (ModuleSettings.Properties.Latitude != "0.0" && ModuleSettings.Properties.Longitude != "0.0") + { + double lat = double.Parse(ModuleSettings.Properties.Latitude.Value, CultureInfo.InvariantCulture); + double lon = double.Parse(ModuleSettings.Properties.Longitude.Value, CultureInfo.InvariantCulture); + UpdateSunTimes(lat, lon); + } + } + } + } + + public ObservableCollection<string> AvailableScheduleModes { get; } + + public bool ChangeSystem + { + get => ModuleSettings.Properties.ChangeSystem.Value; + set + { + if (ModuleSettings.Properties.ChangeSystem.Value != value) + { + ModuleSettings.Properties.ChangeSystem.Value = value; + NotifyPropertyChanged(); + } + } + } + + public bool ChangeApps + { + get => ModuleSettings.Properties.ChangeApps.Value; + set + { + if (ModuleSettings.Properties.ChangeApps.Value != value) + { + ModuleSettings.Properties.ChangeApps.Value = value; + NotifyPropertyChanged(); + } + } + } + + public int LightTime + { + get => ModuleSettings.Properties.LightTime.Value; + set + { + if (ModuleSettings.Properties.LightTime.Value != value) + { + ModuleSettings.Properties.LightTime.Value = value; + NotifyPropertyChanged(); + + OnPropertyChanged(nameof(LightTimeTimeSpan)); + OnPropertyChanged(nameof(SunriseOffsetMin)); + OnPropertyChanged(nameof(SunsetOffsetMin)); + + if (ScheduleMode == "SunsetToSunrise") + { + SunriseTimeSpan = TimeSpan.FromMinutes(value); + } + } + } + } + + public int DarkTime + { + get => ModuleSettings.Properties.DarkTime.Value; + set + { + if (ModuleSettings.Properties.DarkTime.Value != value) + { + ModuleSettings.Properties.DarkTime.Value = value; + NotifyPropertyChanged(); + + OnPropertyChanged(nameof(DarkTimeTimeSpan)); + OnPropertyChanged(nameof(SunriseOffsetMax)); + OnPropertyChanged(nameof(SunsetOffsetMax)); + + if (ScheduleMode == "SunsetToSunrise") + { + SunsetTimeSpan = TimeSpan.FromMinutes(value); + } + } + } + } + + public int SunriseOffset + { + get => ModuleSettings.Properties.SunriseOffset.Value; + set + { + if (ModuleSettings.Properties.SunriseOffset.Value != value) + { + ModuleSettings.Properties.SunriseOffset.Value = value; + OnPropertyChanged(nameof(LightTimeTimeSpan)); + OnPropertyChanged(nameof(SunsetOffsetMin)); + } + } + } + + public int SunsetOffset + { + get => ModuleSettings.Properties.SunsetOffset.Value; + set + { + if (ModuleSettings.Properties.SunsetOffset.Value != value) + { + ModuleSettings.Properties.SunsetOffset.Value = value; + OnPropertyChanged(nameof(DarkTimeTimeSpan)); + OnPropertyChanged(nameof(SunriseOffsetMax)); + } + } + } + + public int SunriseOffsetMin + { + get + { + // Minimum: don't let adjusted sunrise go before 00:00 + return -LightTime; + } + } + + public int SunriseOffsetMax + { + get + { + // Maximum: adjusted sunrise must stay before adjusted sunset + int adjustedSunset = DarkTime + SunsetOffset; + return Math.Max(0, adjustedSunset - LightTime - 1); + } + } + + public int SunsetOffsetMin + { + get + { + // Minimum: adjusted sunset must stay after adjusted sunrise + int adjustedSunrise = LightTime + SunriseOffset; + return Math.Min(0, adjustedSunrise - DarkTime + 1); + } + } + + public int SunsetOffsetMax + { + get + { + // Maximum: don't let adjusted sunset go past 23:59 (1439 minutes) + return 1439 - DarkTime; + } + } + + // === Computed projections (OneWay bindings only) === + public TimeSpan LightTimeTimeSpan + { + get + { + if (ScheduleMode == "SunsetToSunrise") + { + return TimeSpan.FromMinutes(LightTime + SunriseOffset); + } + else + { + return TimeSpan.FromMinutes(LightTime); + } + } + } + + public TimeSpan DarkTimeTimeSpan + { + get + { + if (ScheduleMode == "SunsetToSunrise") + { + return TimeSpan.FromMinutes(DarkTime + SunsetOffset); + } + else + { + return TimeSpan.FromMinutes(DarkTime); + } + } + } + + // === Values to pass to timeline === + public TimeSpan? SunriseTimeSpan + { + get => _sunriseTimeSpan; + set + { + if (_sunriseTimeSpan != value) + { + _sunriseTimeSpan = value; + NotifyPropertyChanged(); + } + } + } + + public TimeSpan? SunsetTimeSpan + { + get => _sunsetTimeSpan; + set + { + if (_sunsetTimeSpan != value) + { + _sunsetTimeSpan = value; + NotifyPropertyChanged(); + } + } + } + + // === Picker values (TwoWay binding targets for TimePickers) === + public TimeSpan LightTimePickerValue + { + get => TimeSpan.FromMinutes(LightTime); + set => LightTime = (int)value.TotalMinutes; + } + + public TimeSpan DarkTimePickerValue + { + get => TimeSpan.FromMinutes(DarkTime); + set => DarkTime = (int)value.TotalMinutes; + } + + public string Latitude + { + get => ModuleSettings.Properties.Latitude.Value; + set + { + if (ModuleSettings.Properties.Latitude.Value != value) + { + ModuleSettings.Properties.Latitude.Value = value; + NotifyPropertyChanged(); + } + } + } + + public string Longitude + { + get => ModuleSettings.Properties.Longitude.Value; + set + { + if (ModuleSettings.Properties.Longitude.Value != value) + { + ModuleSettings.Properties.Longitude.Value = value; + NotifyPropertyChanged(); + } + } + } + + private SearchLocation? _selectedSearchLocation; + + public SearchLocation? SelectedCity + { + get => _selectedSearchLocation; + set + { + if (_selectedSearchLocation != value) + { + _selectedSearchLocation = value; + NotifyPropertyChanged(); + + if (_selectedSearchLocation != null) + { + UpdateSunTimes(_selectedSearchLocation.Latitude, _selectedSearchLocation.Longitude, _selectedSearchLocation.City); + } + } + } + } + + private string _syncButtonInformation = "Please sync your location"; + + public string SyncButtonInformation + { + get => _syncButtonInformation; + set + { + if (_syncButtonInformation != value) + { + _syncButtonInformation = value; + NotifyPropertyChanged(); + } + } + } + + private double _locationPanelLatitude; + private double _locationPanelLongitude; + + public double LocationPanelLatitude + { + get => _locationPanelLatitude; + set + { + if (_locationPanelLatitude != value) + { + _locationPanelLatitude = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(LocationPanelLightTime)); + } + } + } + + public double LocationPanelLongitude + { + get => _locationPanelLongitude; + set + { + if (_locationPanelLongitude != value) + { + _locationPanelLongitude = value; + NotifyPropertyChanged(); + } + } + } + + private int _locationPanelLightTime; + private int _locationPanelDarkTime; + + public int LocationPanelLightTimeMinutes + { + get => _locationPanelLightTime; + set + { + if (_locationPanelLightTime != value) + { + _locationPanelLightTime = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(LocationPanelLightTime)); + } + } + } + + public int LocationPanelDarkTimeMinutes + { + get => _locationPanelDarkTime; + set + { + if (_locationPanelDarkTime != value) + { + _locationPanelDarkTime = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(LocationPanelDarkTime)); + } + } + } + + public TimeSpan LocationPanelLightTime => TimeSpan.FromMinutes(_locationPanelLightTime); + + public TimeSpan LocationPanelDarkTime => TimeSpan.FromMinutes(_locationPanelDarkTime); + + public HotkeySettings ToggleThemeActivationShortcut + { + get => ModuleSettings.Properties.ToggleThemeHotkey.Value; + + set + { + if (value != ModuleSettings.Properties.ToggleThemeHotkey.Value) + { + if (value == null) + { + ModuleSettings.Properties.ToggleThemeHotkey.Value = LightSwitchProperties.DefaultToggleThemeHotkey; + } + else + { + ModuleSettings.Properties.ToggleThemeHotkey.Value = value; + } + + NotifyPropertyChanged(); + + SendConfigMSG( + string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + LightSwitchSettings.ModuleName, + JsonSerializer.Serialize(_moduleSettings, SourceGenerationContextContext.Default.LightSwitchSettings))); + } + } + } + + public void NotifyPropertyChanged([CallerMemberName] string? propertyName = null) + { + Logger.LogInfo($"Changed the property {propertyName}"); + OnPropertyChanged(propertyName); + } + + // PowerDisplay Integration Properties and Methods + public ObservableCollection<PowerDisplayProfile> AvailableProfiles + { + get => _availableProfiles; + set + { + if (_availableProfiles != value) + { + _availableProfiles = value; + NotifyPropertyChanged(); + } + } + } + + public bool IsPowerDisplayEnabled + { + get => _isPowerDisplayEnabled; + set + { + if (_isPowerDisplayEnabled != value) + { + _isPowerDisplayEnabled = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(ShowPowerDisplayDisabledWarning)); + } + } + } + + public bool ShowPowerDisplayDisabledWarning => !IsPowerDisplayEnabled; + + public bool EnableDarkModeProfile + { + get => ModuleSettings.Properties.EnableDarkModeProfile.Value; + set + { + if (ModuleSettings.Properties.EnableDarkModeProfile.Value != value) + { + ModuleSettings.Properties.EnableDarkModeProfile.Value = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(ShowPowerDisplayDisabledWarning)); + SaveSettings(); + } + } + } + + public bool EnableLightModeProfile + { + get => ModuleSettings.Properties.EnableLightModeProfile.Value; + set + { + if (ModuleSettings.Properties.EnableLightModeProfile.Value != value) + { + ModuleSettings.Properties.EnableLightModeProfile.Value = value; + NotifyPropertyChanged(); + NotifyPropertyChanged(nameof(ShowPowerDisplayDisabledWarning)); + SaveSettings(); + } + } + } + + public PowerDisplayProfile? SelectedDarkModeProfile + { + get => _selectedDarkModeProfile; + set + { + if (_selectedDarkModeProfile != value) + { + _selectedDarkModeProfile = value; + + // Sync with the string property stored in settings + var newProfileName = value?.Name ?? string.Empty; + if (ModuleSettings.Properties.DarkModeProfile.Value != newProfileName) + { + ModuleSettings.Properties.DarkModeProfile.Value = newProfileName; + SaveSettings(); + } + + NotifyPropertyChanged(); + } + } + } + + public PowerDisplayProfile? SelectedLightModeProfile + { + get => _selectedLightModeProfile; + set + { + if (_selectedLightModeProfile != value) + { + _selectedLightModeProfile = value; + + // Sync with the string property stored in settings + var newProfileName = value?.Name ?? string.Empty; + if (ModuleSettings.Properties.LightModeProfile.Value != newProfileName) + { + ModuleSettings.Properties.LightModeProfile.Value = newProfileName; + SaveSettings(); + } + + NotifyPropertyChanged(); + } + } + } + + // Legacy string properties for backwards compatibility with settings persistence + public string DarkModeProfile + { + get => ModuleSettings.Properties.DarkModeProfile.Value; + set + { + if (ModuleSettings.Properties.DarkModeProfile.Value != value) + { + ModuleSettings.Properties.DarkModeProfile.Value = value; + + // Sync with the object property + UpdateSelectedProfileFromName(value, isDarkMode: true); + + NotifyPropertyChanged(); + } + } + } + + public string LightModeProfile + { + get => ModuleSettings.Properties.LightModeProfile.Value; + set + { + if (ModuleSettings.Properties.LightModeProfile.Value != value) + { + ModuleSettings.Properties.LightModeProfile.Value = value; + + // Sync with the object property + UpdateSelectedProfileFromName(value, isDarkMode: false); + + NotifyPropertyChanged(); + } + } + } + + private void LoadPowerDisplayProfiles() + { + try + { + var profilesData = ProfileService.LoadProfiles(); + + AvailableProfiles.Clear(); + + foreach (var profile in profilesData.Profiles) + { + AvailableProfiles.Add(profile); + } + + Logger.LogInfo($"Loaded {profilesData.Profiles.Count} PowerDisplay profiles"); + + // Sync selected profiles from settings + UpdateSelectedProfileFromName(ModuleSettings.Properties.DarkModeProfile.Value, isDarkMode: true); + UpdateSelectedProfileFromName(ModuleSettings.Properties.LightModeProfile.Value, isDarkMode: false); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load PowerDisplay profiles: {ex.Message}"); + AvailableProfiles.Clear(); + } + } + + /// <summary> + /// Helper method to sync the selected profile object from the profile name stored in settings. + /// If the configured profile no longer exists, clears the selection and updates settings. + /// </summary> + private void UpdateSelectedProfileFromName(string profileName, bool isDarkMode) + { + PowerDisplayProfile? matchingProfile = null; + + if (!string.IsNullOrEmpty(profileName)) + { + matchingProfile = AvailableProfiles.FirstOrDefault(p => + p.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase)); + + // If the configured profile no longer exists, clear it from settings + if (matchingProfile == null) + { + Logger.LogWarning($"Configured {(isDarkMode ? "dark" : "light")} mode profile '{profileName}' no longer exists, clearing selection"); + + if (isDarkMode) + { + ModuleSettings.Properties.DarkModeProfile.Value = string.Empty; + } + else + { + ModuleSettings.Properties.LightModeProfile.Value = string.Empty; + } + + SaveSettings(); + } + } + + if (isDarkMode) + { + if (_selectedDarkModeProfile != matchingProfile) + { + _selectedDarkModeProfile = matchingProfile; + NotifyPropertyChanged(nameof(SelectedDarkModeProfile)); + } + } + else + { + if (_selectedLightModeProfile != matchingProfile) + { + _selectedLightModeProfile = matchingProfile; + NotifyPropertyChanged(nameof(SelectedLightModeProfile)); + } + } + } + + private void CheckPowerDisplayEnabled() + { + try + { + var settingsUtils = SettingsUtils.Default; + var generalSettings = settingsUtils.GetSettingsOrDefault<GeneralSettings>(string.Empty); + IsPowerDisplayEnabled = generalSettings?.Enabled?.PowerDisplay ?? false; + Logger.LogInfo($"PowerDisplay enabled status: {IsPowerDisplayEnabled}"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to check PowerDisplay enabled status: {ex.Message}"); + IsPowerDisplayEnabled = false; + } + } + + public void RefreshPowerDisplayStatus() + { + CheckPowerDisplayEnabled(); + NotifyPropertyChanged(nameof(ShowPowerDisplayDisabledWarning)); + } + + public void RefreshEnabledState() + { + OnPropertyChanged(nameof(IsEnabled)); + } + + public void RefreshModuleSettings() + { + OnPropertyChanged(nameof(ChangeSystem)); + OnPropertyChanged(nameof(ChangeApps)); + OnPropertyChanged(nameof(LightTime)); + OnPropertyChanged(nameof(DarkTime)); + OnPropertyChanged(nameof(SunriseOffset)); + OnPropertyChanged(nameof(SunsetOffset)); + OnPropertyChanged(nameof(Latitude)); + OnPropertyChanged(nameof(Longitude)); + OnPropertyChanged(nameof(ScheduleMode)); + OnPropertyChanged(nameof(EnableDarkModeProfile)); + OnPropertyChanged(nameof(EnableLightModeProfile)); + OnPropertyChanged(nameof(DarkModeProfile)); + OnPropertyChanged(nameof(LightModeProfile)); + } + + private void UpdateSunTimes(double latitude, double longitude, string city = "n/a") + { + SunTimes result = SunCalc.CalculateSunriseSunset( + latitude, + longitude, + DateTime.Now.Year, + DateTime.Now.Month, + DateTime.Now.Day); + + LightTime = (result.SunriseHour * 60) + result.SunriseMinute; + DarkTime = (result.SunsetHour * 60) + result.SunsetMinute; + Latitude = latitude.ToString(CultureInfo.InvariantCulture); + Longitude = longitude.ToString(CultureInfo.InvariantCulture); + + if (city != "n/a") + { + SyncButtonInformation = city; + } + } + + public void InitializeScheduleMode() + { + if (ScheduleMode == "SunsetToSunrise" && + double.TryParse(Latitude, NumberStyles.Float, CultureInfo.InvariantCulture, out double savedLat) && + double.TryParse(Longitude, NumberStyles.Float, CultureInfo.InvariantCulture, out double savedLng)) + { + var match = SearchLocations.FirstOrDefault(c => + Math.Abs(c.Latitude - savedLat) < 0.0001 && + Math.Abs(c.Longitude - savedLng) < 0.0001); + + if (match != null) + { + SelectedCity = match; + } + + SyncButtonInformation = SelectedCity != null + ? SelectedCity.City + : $"{Latitude}°,{Longitude}°"; + + double lat = double.Parse(ModuleSettings.Properties.Latitude.Value, CultureInfo.InvariantCulture); + double lon = double.Parse(ModuleSettings.Properties.Longitude.Value, CultureInfo.InvariantCulture); + UpdateSunTimes(lat, lon); + + SunriseTimeSpan = TimeSpan.FromMinutes(LightTime); + SunsetTimeSpan = TimeSpan.FromMinutes(DarkTime); + } + } + + private bool _enabledStateIsGPOConfigured; + private GpoRuleConfigured _enabledGpoRuleConfiguration; + private LightSwitchSettings _moduleSettings; + private bool _isEnabled; + private TimeSpan? _sunriseTimeSpan; + private TimeSpan? _sunsetTimeSpan; + + // PowerDisplay integration + private ObservableCollection<PowerDisplayProfile> _availableProfiles = new ObservableCollection<PowerDisplayProfile>(); + private bool _isPowerDisplayEnabled; + private PowerDisplayProfile? _selectedDarkModeProfile; + private PowerDisplayProfile? _selectedLightModeProfile; + + public ICommand ForceLightCommand { get; } + + public ICommand ForceDarkCommand { get; } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/MeasureToolViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MeasureToolViewModel.cs index ea66fd58dd..855cf6e17a 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MeasureToolViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MeasureToolViewModel.cs @@ -3,11 +3,12 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; - using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -15,9 +16,11 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class MeasureToolViewModel : Observable + public partial class MeasureToolViewModel : PageViewModelBase { - private ISettingsUtils SettingsUtils { get; set; } + protected override string ModuleName => MeasureToolSettings.ModuleName; + + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -27,7 +30,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private MeasureToolSettings Settings { get; set; } - public MeasureToolViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<MeasureToolSettings> measureToolSettingsRepository, Func<string, int> ipcMSGCallBackFunc) + public MeasureToolViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<MeasureToolSettings> measureToolSettingsRepository, Func<string, int> ipcMSGCallBackFunc) { SettingsUtils = settingsUtils; @@ -59,6 +62,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/MonitorSelectionItem.cs b/src/settings-ui/Settings.UI/ViewModels/MonitorSelectionItem.cs new file mode 100644 index 0000000000..941f669b57 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/MonitorSelectionItem.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Microsoft.PowerToys.Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + /// <summary> + /// ViewModel for monitor selection in profile editor + /// </summary> + public class MonitorSelectionItem : INotifyPropertyChanged + { + private bool _isSelected; + private int _brightness = 100; + private int _contrast = 50; + private int _volume = 50; + private int _colorTemperature = 6500; + private bool _includeBrightness; + private bool _includeContrast; + private bool _includeVolume; + private bool _includeColorTemperature; + + public required MonitorInfo Monitor { get; set; } + + public bool SuppressAutoSelection { get; set; } + + public bool IsSelected + { + get => _isSelected; + set + { + if (_isSelected != value) + { + _isSelected = value; + OnPropertyChanged(); + } + } + } + + public int Brightness + { + get => _brightness; + set + { + if (_brightness != value) + { + _brightness = value; + OnPropertyChanged(); + if (!SuppressAutoSelection) + { + IncludeBrightness = true; + } + } + } + } + + public int Contrast + { + get => _contrast; + set + { + if (_contrast != value) + { + _contrast = value; + OnPropertyChanged(); + if (!SuppressAutoSelection) + { + IncludeContrast = true; + } + } + } + } + + public int Volume + { + get => _volume; + set + { + if (_volume != value) + { + _volume = value; + OnPropertyChanged(); + if (!SuppressAutoSelection) + { + IncludeVolume = true; + } + } + } + } + + public int ColorTemperature + { + get => _colorTemperature; + set + { + if (_colorTemperature != value) + { + _colorTemperature = value; + OnPropertyChanged(); + if (!SuppressAutoSelection) + { + IncludeColorTemperature = true; + } + } + } + } + + public bool SupportsContrast => Monitor?.SupportsContrast ?? false; + + public bool SupportsVolume => Monitor?.SupportsVolume ?? false; + + public bool SupportsColorTemperature => Monitor?.SupportsColorTemperature ?? false; + + public bool IncludeBrightness + { + get => _includeBrightness; + set + { + if (_includeBrightness != value) + { + _includeBrightness = value; + OnPropertyChanged(); + } + } + } + + public bool IncludeContrast + { + get => _includeContrast; + set + { + if (_includeContrast != value) + { + _includeContrast = value; + OnPropertyChanged(); + } + } + } + + public bool IncludeVolume + { + get => _includeVolume; + set + { + if (_includeVolume != value) + { + _includeVolume = value; + OnPropertyChanged(); + } + } + } + + public bool IncludeColorTemperature + { + get => _includeColorTemperature; + set + { + if (_includeColorTemperature != value) + { + _includeColorTemperature = value; + OnPropertyChanged(); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs index e307b40606..678c090397 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel.cs @@ -3,10 +3,11 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.ComponentModel; using System.Runtime.CompilerServices; - using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -14,9 +15,11 @@ using Microsoft.PowerToys.Settings.Utilities; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class MouseUtilsViewModel : Observable + public partial class MouseUtilsViewModel : PageViewModelBase { - private ISettingsUtils SettingsUtils { get; set; } + protected override string ModuleName => "MouseUtils"; + + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -26,7 +29,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private MousePointerCrosshairsSettings MousePointerCrosshairsSettingsConfig { get; set; } - public MouseUtilsViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<FindMyMouseSettings> findMyMouseSettingsRepository, ISettingsRepository<MouseHighlighterSettings> mouseHighlighterSettingsRepository, ISettingsRepository<MouseJumpSettings> mouseJumpSettingsRepository, ISettingsRepository<MousePointerCrosshairsSettings> mousePointerCrosshairsSettingsRepository, Func<string, int> ipcMSGCallBackFunc) + private CursorWrapSettings CursorWrapSettingsConfig { get; set; } + + public MouseUtilsViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<FindMyMouseSettings> findMyMouseSettingsRepository, ISettingsRepository<MouseHighlighterSettings> mouseHighlighterSettingsRepository, ISettingsRepository<MouseJumpSettings> mouseJumpSettingsRepository, ISettingsRepository<MousePointerCrosshairsSettings> mousePointerCrosshairsSettingsRepository, ISettingsRepository<CursorWrapSettings> cursorWrapSettingsRepository, Func<string, int> ipcMSGCallBackFunc) { SettingsUtils = settingsUtils; @@ -47,12 +52,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _findMyMouseDoNotActivateOnGameMode = FindMyMouseSettingsConfig.Properties.DoNotActivateOnGameMode.Value; string backgroundColor = FindMyMouseSettingsConfig.Properties.BackgroundColor.Value; - _findMyMouseBackgroundColor = !string.IsNullOrEmpty(backgroundColor) ? backgroundColor : "#000000"; + _findMyMouseBackgroundColor = !string.IsNullOrEmpty(backgroundColor) ? backgroundColor : "#80000000"; string spotlightColor = FindMyMouseSettingsConfig.Properties.SpotlightColor.Value; - _findMyMouseSpotlightColor = !string.IsNullOrEmpty(spotlightColor) ? spotlightColor : "#FFFFFF"; + _findMyMouseSpotlightColor = !string.IsNullOrEmpty(spotlightColor) ? spotlightColor : "#80FFFFFF"; - _findMyMouseOverlayOpacity = FindMyMouseSettingsConfig.Properties.OverlayOpacity.Value; _findMyMouseSpotlightRadius = FindMyMouseSettingsConfig.Properties.SpotlightRadius.Value; _findMyMouseAnimationDurationMs = FindMyMouseSettingsConfig.Properties.AnimationDurationMs.Value; _findMyMouseSpotlightInitialZoom = FindMyMouseSettingsConfig.Properties.SpotlightInitialZoom.Value; @@ -72,6 +76,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels string alwaysColor = MouseHighlighterSettingsConfig.Properties.AlwaysColor.Value; _highlighterAlwaysColor = !string.IsNullOrEmpty(alwaysColor) ? alwaysColor : "#00FF0000"; + _isSpotlightModeEnabled = MouseHighlighterSettingsConfig.Properties.SpotlightMode.Value; _highlighterRadius = MouseHighlighterSettingsConfig.Properties.HighlightRadius.Value; _highlightFadeDelayMs = MouseHighlighterSettingsConfig.Properties.HighlightFadeDelayMs.Value; @@ -97,10 +102,26 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _mousePointerCrosshairsAutoHide = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsAutoHide.Value; _mousePointerCrosshairsIsFixedLengthEnabled = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsIsFixedLengthEnabled.Value; _mousePointerCrosshairsFixedLength = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsFixedLength.Value; + _mousePointerCrosshairsOrientation = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value; _mousePointerCrosshairsAutoActivate = MousePointerCrosshairsSettingsConfig.Properties.AutoActivate.Value; + ArgumentNullException.ThrowIfNull(cursorWrapSettingsRepository); + + CursorWrapSettingsConfig = cursorWrapSettingsRepository.SettingsConfig; + _cursorWrapAutoActivate = CursorWrapSettingsConfig.Properties.AutoActivate.Value; + + // Null-safe access in case property wasn't upgraded yet - default to TRUE + _cursorWrapDisableWrapDuringDrag = CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag?.Value ?? true; + + // Null-safe access in case property wasn't upgraded yet - default to 0 (Both) + _cursorWrapWrapMode = CursorWrapSettingsConfig.Properties.WrapMode?.Value ?? 0; + + // Null-safe access in case property wasn't upgraded yet - default to false + _cursorWrapDisableOnSingleMonitor = CursorWrapSettingsConfig.Properties.DisableCursorWrapOnSingleMonitor?.Value ?? false; + int isEnabled = 0; - NativeMethods.SystemParametersInfo(NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0); + + Utilities.NativeMethods.SystemParametersInfo(Utilities.NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0); _isAnimationEnabledBySystem = isEnabled != 0; // set the callback functions value to handle outgoing IPC message. @@ -139,13 +160,41 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (_mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) { // Get the enabled state from GPO. - _mousePointerCrosshairsEnabledStateIsGPOConfigured = true; + _mousePointerCrosshairsEnabledStateGPOConfigured = true; _isMousePointerCrosshairsEnabled = _mousePointerCrosshairsEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; } else { _isMousePointerCrosshairsEnabled = GeneralSettingsConfig.Enabled.MousePointerCrosshairs; } + + _cursorWrapEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredCursorWrapEnabledValue(); + if (_cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO. + _cursorWrapEnabledStateIsGPOConfigured = true; + _isCursorWrapEnabled = _cursorWrapEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + } + else + { + _isCursorWrapEnabled = GeneralSettingsConfig.Enabled.CursorWrap; + } + } + + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [FindMyMouseSettings.ModuleName] = [FindMyMouseActivationShortcut], + [MouseHighlighterSettings.ModuleName] = [MouseHighlighterActivationShortcut], + [MousePointerCrosshairsSettings.ModuleName] = [ + MousePointerCrosshairsActivationShortcut, + GlidingCursorActivationShortcut], + [MouseJumpSettings.ModuleName] = [MouseJumpActivationShortcut], + [CursorWrapSettings.ModuleName] = [CursorWrapActivationShortcut], + }; + + return hotkeysDict; } public bool IsFindMyMouseEnabled @@ -259,7 +308,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels set { - value = (value != null) ? SettingsUtilities.ToRGBHex(value) : "#000000"; + value = (value != null) ? SettingsUtilities.ToARGBHex(value) : "#FF000000"; if (!value.Equals(_findMyMouseBackgroundColor, StringComparison.OrdinalIgnoreCase)) { _findMyMouseBackgroundColor = value; @@ -278,7 +327,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels set { - value = (value != null) ? SettingsUtilities.ToRGBHex(value) : "#FFFFFF"; + value = (value != null) ? SettingsUtilities.ToARGBHex(value) : "#FFFFFFFF"; if (!value.Equals(_findMyMouseSpotlightColor, StringComparison.OrdinalIgnoreCase)) { _findMyMouseSpotlightColor = value; @@ -288,24 +337,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - public int FindMyMouseOverlayOpacity - { - get - { - return _findMyMouseOverlayOpacity; - } - - set - { - if (value != _findMyMouseOverlayOpacity) - { - _findMyMouseOverlayOpacity = value; - FindMyMouseSettingsConfig.Properties.OverlayOpacity.Value = value; - NotifyFindMyMousePropertyChanged(); - } - } - } - public int FindMyMouseSpotlightRadius { get @@ -560,6 +591,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool IsSpotlightModeEnabled + { + get => _isSpotlightModeEnabled; + set + { + if (_isSpotlightModeEnabled != value) + { + _isSpotlightModeEnabled = value; + MouseHighlighterSettingsConfig.Properties.SpotlightMode.Value = value; + NotifyMouseHighlighterPropertyChanged(); + } + } + } + public int MouseHighlighterRadius { get @@ -647,7 +692,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels get => _isMousePointerCrosshairsEnabled; set { - if (_mousePointerCrosshairsEnabledStateIsGPOConfigured) + if (_mousePointerCrosshairsEnabledStateGPOConfigured) { // If it's GPO configured, shouldn't be able to change this state. return; @@ -670,7 +715,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public bool IsMousePointerCrosshairsEnabledGpoConfigured { - get => _mousePointerCrosshairsEnabledStateIsGPOConfigured; + get => _mousePointerCrosshairsEnabledStateGPOConfigured; } public HotkeySettings MousePointerCrosshairsActivationShortcut @@ -854,6 +899,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public int MousePointerCrosshairsOrientation + { + get + { + return _mousePointerCrosshairsOrientation; + } + + set + { + if (value != _mousePointerCrosshairsOrientation) + { + _mousePointerCrosshairsOrientation = value; + MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value = value; + NotifyMousePointerCrosshairsPropertyChanged(); + } + } + } + public bool MousePointerCrosshairsAutoActivate { get @@ -872,6 +935,49 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public int GlidingCursorTravelSpeed + { + get => MousePointerCrosshairsSettingsConfig.Properties.GlidingTravelSpeed.Value; + set + { + if (MousePointerCrosshairsSettingsConfig.Properties.GlidingTravelSpeed.Value != value) + { + MousePointerCrosshairsSettingsConfig.Properties.GlidingTravelSpeed.Value = value; + NotifyMousePointerCrosshairsPropertyChanged(); + } + } + } + + public int GlidingCursorDelaySpeed + { + get => MousePointerCrosshairsSettingsConfig.Properties.GlidingDelaySpeed.Value; + set + { + if (MousePointerCrosshairsSettingsConfig.Properties.GlidingDelaySpeed.Value != value) + { + MousePointerCrosshairsSettingsConfig.Properties.GlidingDelaySpeed.Value = value; + NotifyMousePointerCrosshairsPropertyChanged(); + } + } + } + + public HotkeySettings GlidingCursorActivationShortcut + { + get + { + return MousePointerCrosshairsSettingsConfig.Properties.GlidingCursorActivationShortcut; + } + + set + { + if (MousePointerCrosshairsSettingsConfig.Properties.GlidingCursorActivationShortcut != value) + { + MousePointerCrosshairsSettingsConfig.Properties.GlidingCursorActivationShortcut = value ?? MousePointerCrosshairsSettingsConfig.Properties.DefaultGlidingCursorActivationShortcut; + NotifyMousePointerCrosshairsPropertyChanged(); + } + } + } + public void NotifyMousePointerCrosshairsPropertyChanged([CallerMemberName] string propertyName = null) { OnPropertyChanged(propertyName); @@ -882,6 +988,166 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels SettingsUtils.SaveSettings(MousePointerCrosshairsSettingsConfig.ToJsonString(), MousePointerCrosshairsSettings.ModuleName); } + public bool IsCursorWrapEnabled + { + get => _isCursorWrapEnabled; + set + { + if (_cursorWrapEnabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + if (_isCursorWrapEnabled != value) + { + _isCursorWrapEnabled = value; + + GeneralSettingsConfig.Enabled.CursorWrap = value; + OnPropertyChanged(nameof(IsCursorWrapEnabled)); + + OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig); + SendConfigMSG(outgoing.ToString()); + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool IsCursorWrapEnabledGpoConfigured + { + get => _cursorWrapEnabledStateIsGPOConfigured; + } + + public HotkeySettings CursorWrapActivationShortcut + { + get + { + return CursorWrapSettingsConfig.Properties.ActivationShortcut; + } + + set + { + if (CursorWrapSettingsConfig.Properties.ActivationShortcut != value) + { + CursorWrapSettingsConfig.Properties.ActivationShortcut = value ?? CursorWrapSettingsConfig.Properties.DefaultActivationShortcut; + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool CursorWrapAutoActivate + { + get + { + return _cursorWrapAutoActivate; + } + + set + { + if (value != _cursorWrapAutoActivate) + { + _cursorWrapAutoActivate = value; + CursorWrapSettingsConfig.Properties.AutoActivate.Value = value; + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool CursorWrapDisableWrapDuringDrag + { + get + { + return _cursorWrapDisableWrapDuringDrag; + } + + set + { + if (value != _cursorWrapDisableWrapDuringDrag) + { + _cursorWrapDisableWrapDuringDrag = value; + + // Ensure the property exists before setting value + if (CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag == null) + { + CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag = new BoolProperty(value); + } + else + { + CursorWrapSettingsConfig.Properties.DisableWrapDuringDrag.Value = value; + } + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public int CursorWrapWrapMode + { + get + { + return _cursorWrapWrapMode; + } + + set + { + if (value != _cursorWrapWrapMode) + { + _cursorWrapWrapMode = value; + + // Ensure the property exists before setting value + if (CursorWrapSettingsConfig.Properties.WrapMode == null) + { + CursorWrapSettingsConfig.Properties.WrapMode = new IntProperty(value); + } + else + { + CursorWrapSettingsConfig.Properties.WrapMode.Value = value; + } + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public bool CursorWrapDisableOnSingleMonitor + { + get + { + return _cursorWrapDisableOnSingleMonitor; + } + + set + { + if (value != _cursorWrapDisableOnSingleMonitor) + { + _cursorWrapDisableOnSingleMonitor = value; + + // Ensure the property exists before setting value + if (CursorWrapSettingsConfig.Properties.DisableCursorWrapOnSingleMonitor == null) + { + CursorWrapSettingsConfig.Properties.DisableCursorWrapOnSingleMonitor = new BoolProperty(value); + } + else + { + CursorWrapSettingsConfig.Properties.DisableCursorWrapOnSingleMonitor.Value = value; + } + + NotifyCursorWrapPropertyChanged(); + } + } + } + + public void NotifyCursorWrapPropertyChanged([CallerMemberName] string propertyName = null) + { + OnPropertyChanged(propertyName); + + SndCursorWrapSettings outsettings = new SndCursorWrapSettings(CursorWrapSettingsConfig); + SndModuleSettings<SndCursorWrapSettings> ipcMessage = new SndModuleSettings<SndCursorWrapSettings>(outsettings); + SendConfigMSG(ipcMessage.ToJsonString()); + SettingsUtils.SaveSettings(CursorWrapSettingsConfig.ToJsonString(), CursorWrapSettings.ModuleName); + } + public void RefreshEnabledState() { InitializeEnabledValues(); @@ -889,6 +1155,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsMouseHighlighterEnabled)); OnPropertyChanged(nameof(IsMouseJumpEnabled)); OnPropertyChanged(nameof(IsMousePointerCrosshairsEnabled)); + OnPropertyChanged(nameof(IsCursorWrapEnabled)); } private Func<string, int> SendConfigMSG { get; } @@ -901,7 +1168,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _findMyMouseDoNotActivateOnGameMode; private string _findMyMouseBackgroundColor; private string _findMyMouseSpotlightColor; - private int _findMyMouseOverlayOpacity; private int _findMyMouseSpotlightRadius; private int _findMyMouseAnimationDurationMs; private int _findMyMouseSpotlightInitialZoom; @@ -916,13 +1182,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private string _highlighterLeftButtonClickColor; private string _highlighterRightButtonClickColor; private string _highlighterAlwaysColor; + private bool _isSpotlightModeEnabled; private int _highlighterRadius; private int _highlightFadeDelayMs; private int _highlightFadeDurationMs; private bool _highlighterAutoActivate; private GpoRuleConfigured _mousePointerCrosshairsEnabledGpoRuleConfiguration; - private bool _mousePointerCrosshairsEnabledStateIsGPOConfigured; + private bool _mousePointerCrosshairsEnabledStateGPOConfigured; private bool _isMousePointerCrosshairsEnabled; private string _mousePointerCrosshairsColor; private int _mousePointerCrosshairsOpacity; @@ -933,7 +1200,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _mousePointerCrosshairsAutoHide; private bool _mousePointerCrosshairsIsFixedLengthEnabled; private int _mousePointerCrosshairsFixedLength; + private int _mousePointerCrosshairsOrientation; private bool _mousePointerCrosshairsAutoActivate; private bool _isAnimationEnabledBySystem; + + private GpoRuleConfigured _cursorWrapEnabledGpoRuleConfiguration; + private bool _cursorWrapEnabledStateIsGPOConfigured; + private bool _isCursorWrapEnabled; + private bool _cursorWrapAutoActivate; + private bool _cursorWrapDisableWrapDuringDrag; // Will be initialized in constructor from settings + private int _cursorWrapWrapMode; // 0=Both, 1=VerticalOnly, 2=HorizontalOnly + private bool _cursorWrapDisableOnSingleMonitor; // Disable cursor wrap when only one monitor is connected } } diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs index 2818c34a99..e954976f2e 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseUtilsViewModel_MouseJump.cs @@ -25,7 +25,7 @@ using MouseJump.Common.Models.Styles; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class MouseUtilsViewModel : Observable + public partial class MouseUtilsViewModel : PageViewModelBase { private GpoRuleConfigured _jumpEnabledGpoRuleConfiguration; private bool _jumpEnabledStateIsGPOConfigured; @@ -37,6 +37,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { ArgumentNullException.ThrowIfNull(mouseJumpSettingsRepository); this.MouseJumpSettingsConfig = mouseJumpSettingsRepository.SettingsConfig; + this.MouseJumpSettingsConfig.Properties.ThumbnailSize.PropertyChanged += this.MouseJumpThumbnailSizePropertyChanged; } @@ -125,14 +126,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { var assembly = Assembly.GetExecutingAssembly(); var assemblyName = new AssemblyName(assembly.FullName ?? throw new InvalidOperationException()); + + // Build the fully-qualified manifest resource name. Historically, subtle casing differences + // (e.g. folder names or the assembly name) caused exact (case-sensitive) lookup failures on + // some developer machines when the embedded resource's actual name differed only by case. + // Manifest resource name comparison here does not need to be case-sensitive, so we resolve + // the actual name using an OrdinalIgnoreCase match, then use the real casing for the stream. var resourceName = $"Microsoft.{assemblyName.Name}.{filename.Replace("/", ".")}"; var resourceNames = assembly.GetManifestResourceNames(); - if (!resourceNames.Contains(resourceName)) + var actualResourceName = resourceNames.FirstOrDefault(n => string.Equals(n, resourceName, StringComparison.OrdinalIgnoreCase)); + if (actualResourceName is null) { - throw new InvalidOperationException($"Embedded resource '{resourceName}' does not exist."); + throw new InvalidOperationException($"Embedded resource '{resourceName}' (case-insensitive) does not exist."); } - var stream = assembly.GetManifestResourceStream(resourceName) + var stream = assembly.GetManifestResourceStream(actualResourceName) ?? throw new InvalidOperationException(); var image = (Bitmap)Image.FromStream(stream); return image; @@ -164,7 +172,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels 300px settings card height - 1px top border - 7px top margin - 8px bottom margin - 1px bottom border = 283px image height - this ensures we get a preview image scaled at 100% so borders etc are shown at exact pixel sizes in the preview + this ensures we get a preview image scaled at 100% so borders, etc., are shown at exact pixel sizes in the preview */ var canvasSize = new SizeInfo(desktopSize.Width, 283).Clamp(desktopSize); diff --git a/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs index 2420ffccfd..447c62a6dd 100644 --- a/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/MouseWithoutBordersViewModel.cs @@ -13,7 +13,6 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; - using global::PowerToys.GPOWrapper; using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; @@ -30,8 +29,10 @@ using Windows.ApplicationModel.DataTransfer; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class MouseWithoutBordersViewModel : Observable, IDisposable + public partial class MouseWithoutBordersViewModel : PageViewModelBase { + protected override string ModuleName => MouseWithoutBordersSettings.ModuleName; + // These should be in the same order as the ComboBoxItems in MouseWithoutBordersPage.xaml switch machine shortcut options private readonly int[] _switchBetweenMachineShortcutOptions = { @@ -42,19 +43,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private readonly Lock _machineMatrixStringLock = new(); + private bool _disposed; + private static readonly Dictionary<SocketStatus, Brush> StatusColors = new Dictionary<SocketStatus, Brush>() -{ - { SocketStatus.NA, new SolidColorBrush(ColorHelper.FromArgb(0, 0x71, 0x71, 0x71)) }, - { SocketStatus.Resolving, new SolidColorBrush(Colors.Yellow) }, - { SocketStatus.Connecting, new SolidColorBrush(Colors.Orange) }, - { SocketStatus.Handshaking, new SolidColorBrush(Colors.Blue) }, - { SocketStatus.Error, new SolidColorBrush(Colors.Red) }, - { SocketStatus.ForceClosed, new SolidColorBrush(Colors.Purple) }, - { SocketStatus.InvalidKey, new SolidColorBrush(Colors.Brown) }, - { SocketStatus.Timeout, new SolidColorBrush(Colors.Pink) }, - { SocketStatus.SendError, new SolidColorBrush(Colors.Maroon) }, - { SocketStatus.Connected, new SolidColorBrush(Colors.Green) }, -}; + { + { SocketStatus.NA, new SolidColorBrush(ColorHelper.FromArgb(0, 0x71, 0x71, 0x71)) }, + { SocketStatus.Resolving, new SolidColorBrush(Colors.Yellow) }, + { SocketStatus.Connecting, new SolidColorBrush(Colors.Orange) }, + { SocketStatus.Handshaking, new SolidColorBrush(Colors.Blue) }, + { SocketStatus.Error, new SolidColorBrush(Colors.Red) }, + { SocketStatus.ForceClosed, new SolidColorBrush(Colors.Purple) }, + { SocketStatus.InvalidKey, new SolidColorBrush(Colors.Brown) }, + { SocketStatus.Timeout, new SolidColorBrush(Colors.Pink) }, + { SocketStatus.SendError, new SolidColorBrush(Colors.Maroon) }, + { SocketStatus.Connected, new SolidColorBrush(Colors.Green) }, + }; private bool _connectFieldsVisible; @@ -184,7 +187,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - private ISettingsUtils SettingsUtils { get; set; } + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -422,7 +425,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private DispatcherQueue _uiDispatcherQueue; - public MouseWithoutBordersViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, DispatcherQueue uiDispatcherQueue) + public MouseWithoutBordersViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, DispatcherQueue uiDispatcherQueue) { SettingsUtils = settingsUtils; @@ -545,6 +548,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _policyDefinedIpMappingRulesIsGPOConfigured = !string.IsNullOrWhiteSpace(_policyDefinedIpMappingRulesGPOData); } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [ + ToggleEasyMouseShortcut, + LockMachinesShortcut, + HotKeySwitch2AllPC, + ReconnectShortcut], + }; + + return hotkeysDict; + } + private void LoadViewModelFromSettings(MouseWithoutBordersSettings moduleSettings) { ArgumentNullException.ThrowIfNull(moduleSettings); @@ -887,6 +904,43 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + private string _easyMouseIgnoredFullscreenAppsString; + + public string EasyMouseFullscreenSwitchBlockExcludedApps + { + // Convert the list of excluded apps retrieved from the settings + // to a single string that can be displayed in the bound textbox + get + { + if (_easyMouseIgnoredFullscreenAppsString == null) + { + var excludedApps = Settings.Properties.EasyMouseFullscreenSwitchBlockExcludedApps.Value; + _easyMouseIgnoredFullscreenAppsString = excludedApps.Count == 0 ? string.Empty : string.Join('\r', excludedApps); + } + + return _easyMouseIgnoredFullscreenAppsString; + } + + set + { + if (EasyMouseFullscreenSwitchBlockExcludedApps == value) + { + return; + } + + _easyMouseIgnoredFullscreenAppsString = value; + + var ignoredAppsSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + if (value != string.Empty) + { + ignoredAppsSet.UnionWith(value.Split('\r', StringSplitOptions.RemoveEmptyEntries)); + } + + Settings.Properties.EasyMouseFullscreenSwitchBlockExcludedApps.Value = ignoredAppsSet; + NotifyPropertyChanged(); + } + } + public bool CardForName2IpSettingIsEnabled => _disableUserDefinedIpMappingRulesIsGPOConfigured == false; public bool ShowPolicyConfiguredInfoForName2IPSetting => _disableUserDefinedIpMappingRulesIsGPOConfigured && IsEnabled; @@ -988,6 +1042,30 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool EasyMouseEnabled => (EasyMouseOption)EasyMouseOptionIndex != EasyMouseOption.Disable; + + public bool IsEasyMouseBlockingOnFullscreenEnabled => + EasyMouseEnabled && DisableEasyMouseWhenForegroundWindowIsFullscreen; + + public bool DisableEasyMouseWhenForegroundWindowIsFullscreen + { + get + { + return Settings.Properties.DisableEasyMouseWhenForegroundWindowIsFullscreen; + } + + set + { + if (Settings.Properties.DisableEasyMouseWhenForegroundWindowIsFullscreen == value) + { + return; + } + + Settings.Properties.DisableEasyMouseWhenForegroundWindowIsFullscreen = value; + NotifyPropertyChanged(); + } + } + public HotkeySettings ToggleEasyMouseShortcut { get => Settings.Properties.ToggleEasyMouseShortcut; @@ -998,6 +1076,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { Settings.Properties.ToggleEasyMouseShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyToggleEasyMouse; NotifyPropertyChanged(); + NotifyModuleUpdatedSettings(); } } } @@ -1013,6 +1092,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Settings.Properties.LockMachineShortcut = value; Settings.Properties.LockMachineShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyLockMachine; NotifyPropertyChanged(); + NotifyModuleUpdatedSettings(); } } } @@ -1028,6 +1108,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Settings.Properties.ReconnectShortcut = value; Settings.Properties.ReconnectShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeyReconnect; NotifyPropertyChanged(); + NotifyModuleUpdatedSettings(); } } } @@ -1043,6 +1124,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Settings.Properties.Switch2AllPCShortcut = value; Settings.Properties.Switch2AllPCShortcut = value ?? MouseWithoutBordersProperties.DefaultHotKeySwitch2AllPC; NotifyPropertyChanged(); + NotifyModuleUpdatedSettings(); } } } @@ -1201,11 +1283,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private void NotifyModuleUpdatedSettings() { SendConfigMSG( - string.Format( - CultureInfo.InvariantCulture, - "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", - MouseWithoutBordersSettings.ModuleName, - JsonSerializer.Serialize(Settings, SourceGenerationContextContext.Default.MouseWithoutBordersSettings))); + string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + MouseWithoutBordersSettings.ModuleName, + JsonSerializer.Serialize(Settings, SourceGenerationContextContext.Default.MouseWithoutBordersSettings))); } public void NotifyUpdatedSettings() @@ -1241,9 +1323,48 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels Clipboard.SetContent(data); } - public void Dispose() + protected override void Dispose(bool disposing) { - GC.SuppressFinalize(this); + if (!_disposed) + { + if (disposing) + { + // Cancel the cancellation token source + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + + // Wait for the machine polling task to complete + try + { + _machinePollingThreadTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch (AggregateException) + { + // Task was cancelled, which is expected + } + + // Dispose the named pipe stream + try + { + syncHelperStream?.Dispose(); + } + catch (Exception ex) + { + Logger.LogError($"Error disposing sync helper stream: {ex}"); + } + finally + { + syncHelperStream = null; + } + + // Dispose the semaphore + _ipcSemaphore?.Dispose(); + } + + _disposed = true; + } + + base.Dispose(disposing); } internal void UninstallService() diff --git a/src/settings-ui/Settings.UI/ViewModels/NewPlusViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/NewPlusViewModel.cs index 0bd44b4fbf..ffb10883bd 100644 --- a/src/settings-ui/Settings.UI/ViewModels/NewPlusViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/NewPlusViewModel.cs @@ -26,13 +26,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { private GeneralSettings GeneralSettingsConfig { get; set; } - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private NewPlusSettings Settings { get; set; } private const string ModuleName = NewPlusSettings.ModuleName; - public NewPlusViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc) + public NewPlusViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc) { _settingsUtils = settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils)); @@ -228,7 +228,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func<string, int> SendConfigMSG { get; } - public static NewPlusSettings LoadSettings(ISettingsUtils settingsUtils) + public static NewPlusSettings LoadSettings(SettingsUtils settingsUtils) { NewPlusSettings settings = null; diff --git a/src/settings-ui/Settings.UI/ViewModels/PageViewModelBase.cs b/src/settings-ui/Settings.UI/ViewModels/PageViewModelBase.cs new file mode 100644 index 0000000000..78b66d6470 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/PageViewModelBase.cs @@ -0,0 +1,251 @@ +// 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.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.Services; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public abstract class PageViewModelBase : Observable, IDisposable + { + private readonly Dictionary<string, bool> _hotkeyConflictStatus = new Dictionary<string, bool>(); + private readonly Dictionary<string, string> _hotkeyConflictTooltips = new Dictionary<string, string>(); + private bool _disposed; + + protected abstract string ModuleName { get; } + + protected PageViewModelBase() + { + if (GlobalHotkeyConflictManager.Instance != null) + { + GlobalHotkeyConflictManager.Instance.ConflictsUpdated += OnConflictsUpdated; + } + } + + public virtual void OnPageLoaded() + { + Debug.WriteLine($"=== PAGE LOADED: {ModuleName} ==="); + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + + /// <summary> + /// Handles updates to hotkey conflicts for the module. This method is called when the + /// <see cref="GlobalHotkeyConflictManager"/> raises the <c>ConflictsUpdated</c> event. + /// </summary> + /// <param name="sender">The source of the event, typically the <see cref="GlobalHotkeyConflictManager"/> instance.</param> + /// <param name="e">An <see cref="AllHotkeyConflictsEventArgs"/> object containing details about the hotkey conflicts.</param> + /// <remarks> + /// Derived classes can override this method to provide custom handling for hotkey conflicts. + /// Ensure that the overridden method maintains the expected behavior of processing and logging conflict data. + /// </remarks> + protected virtual void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e) + { + UpdateHotkeyConflictStatus(e.Conflicts); + var allHotkeySettings = GetAllHotkeySettings(); + + void UpdateConflictProperties() + { + if (allHotkeySettings != null) + { + foreach (KeyValuePair<string, HotkeySettings[]> kvp in allHotkeySettings) + { + var module = kvp.Key; + var hotkeySettingsList = kvp.Value; + + for (int i = 0; i < hotkeySettingsList.Length; i++) + { + var key = $"{module.ToLowerInvariant()}_{i}"; + hotkeySettingsList[i].HasConflict = GetHotkeyConflictStatus(key); + hotkeySettingsList[i].ConflictDescription = GetHotkeyConflictTooltip(key); + } + } + } + } + + _ = Task.Run(() => + { + try + { + var settingsWindow = App.GetSettingsWindow(); + settingsWindow.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, UpdateConflictProperties); + } + catch + { + UpdateConflictProperties(); + } + }); + } + + public virtual Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + return null; + } + + protected ModuleConflictsData GetModuleRelatedConflicts(AllHotkeyConflictsData allConflicts) + { + var moduleConflicts = new ModuleConflictsData(); + + if (allConflicts.InAppConflicts != null) + { + foreach (var conflict in allConflicts.InAppConflicts) + { + if (IsModuleInvolved(conflict)) + { + moduleConflicts.InAppConflicts.Add(conflict); + } + } + } + + if (allConflicts.SystemConflicts != null) + { + foreach (var conflict in allConflicts.SystemConflicts) + { + if (IsModuleInvolved(conflict)) + { + moduleConflicts.SystemConflicts.Add(conflict); + } + } + } + + return moduleConflicts; + } + + private void ProcessMouseUtilsConflictGroup(HotkeyConflictGroupData conflict, HashSet<string> mouseUtilsModules, bool isSysConflict) + { + // Check if any of the modules in this conflict are MouseUtils submodules + var involvedMouseUtilsModules = conflict.Modules + .Where(module => mouseUtilsModules.Contains(module.ModuleName)) + .ToList(); + + if (involvedMouseUtilsModules.Count != 0) + { + // For each involved MouseUtils module, mark the hotkey as having a conflict + foreach (var module in involvedMouseUtilsModules) + { + string hotkeyKey = $"{module.ModuleName.ToLowerInvariant()}_{module.HotkeyID}"; + _hotkeyConflictStatus[hotkeyKey] = true; + _hotkeyConflictTooltips[hotkeyKey] = isSysConflict + ? ResourceLoaderInstance.ResourceLoader.GetString("SysHotkeyConflictTooltipText") + : ResourceLoaderInstance.ResourceLoader.GetString("InAppHotkeyConflictTooltipText"); + } + } + } + + protected virtual void UpdateHotkeyConflictStatus(AllHotkeyConflictsData allConflicts) + { + _hotkeyConflictStatus.Clear(); + _hotkeyConflictTooltips.Clear(); + + // Since MouseUtils in Settings consolidates four modules: Find My Mouse, Mouse Highlighter, Mouse Pointer Crosshairs, and Mouse Jump + // We need to handle this case separately here. + if (string.Equals(ModuleName, "MouseUtils", StringComparison.OrdinalIgnoreCase)) + { + var mouseUtilsModules = new HashSet<string> + { + FindMyMouseSettings.ModuleName, + MouseHighlighterSettings.ModuleName, + MousePointerCrosshairsSettings.ModuleName, + MouseJumpSettings.ModuleName, + }; + + // Process in-app conflicts + foreach (var conflict in allConflicts.InAppConflicts) + { + ProcessMouseUtilsConflictGroup(conflict, mouseUtilsModules, false); + } + + // Process system conflicts + foreach (var conflict in allConflicts.SystemConflicts) + { + ProcessMouseUtilsConflictGroup(conflict, mouseUtilsModules, true); + } + } + else + { + if (allConflicts.InAppConflicts.Count > 0) + { + foreach (var conflictGroup in allConflicts.InAppConflicts) + { + foreach (var conflict in conflictGroup.Modules) + { + if (string.Equals(conflict.ModuleName, ModuleName, StringComparison.OrdinalIgnoreCase)) + { + var keyName = $"{conflict.ModuleName.ToLowerInvariant()}_{conflict.HotkeyID}"; + _hotkeyConflictStatus[keyName] = true; + _hotkeyConflictTooltips[keyName] = ResourceLoaderInstance.ResourceLoader.GetString("InAppHotkeyConflictTooltipText"); + } + } + } + } + + if (allConflicts.SystemConflicts.Count > 0) + { + foreach (var conflictGroup in allConflicts.SystemConflicts) + { + foreach (var conflict in conflictGroup.Modules) + { + if (string.Equals(conflict.ModuleName, ModuleName, StringComparison.OrdinalIgnoreCase)) + { + var keyName = $"{conflict.ModuleName.ToLowerInvariant()}_{conflict.HotkeyID}"; + _hotkeyConflictStatus[keyName] = true; + _hotkeyConflictTooltips[keyName] = ResourceLoaderInstance.ResourceLoader.GetString("SysHotkeyConflictTooltipText"); + } + } + } + } + } + } + + protected virtual bool GetHotkeyConflictStatus(string key) + { + return _hotkeyConflictStatus.ContainsKey(key) && _hotkeyConflictStatus[key]; + } + + protected virtual string GetHotkeyConflictTooltip(string key) + { + return _hotkeyConflictTooltips.TryGetValue(key, out string value) ? value : null; + } + + private bool IsModuleInvolved(HotkeyConflictGroupData conflict) + { + if (conflict.Modules == null) + { + return false; + } + + return conflict.Modules.Any(module => + string.Equals(module.ModuleName, ModuleName, StringComparison.OrdinalIgnoreCase)); + } + + public virtual void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + if (GlobalHotkeyConflictManager.Instance != null) + { + GlobalHotkeyConflictManager.Instance.ConflictsUpdated -= OnConflictsUpdated; + } + } + + _disposed = true; + } + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs index a96a1aeec5..5eebe1b8de 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PeekViewModel.cs @@ -3,13 +3,14 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.IO.Abstractions; using System.Text.Json; - using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -20,17 +21,21 @@ using Settings.UI.Library; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public class PeekViewModel : Observable, IDisposable + public class PeekViewModel : PageViewModelBase { + protected override string ModuleName => PeekSettings.ModuleName; + private bool _isEnabled; + private bool _disposed; + private bool _settingsUpdating; private GeneralSettings GeneralSettingsConfig { get; set; } private readonly DispatcherQueue _dispatcherQueue; - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private readonly PeekPreviewSettings _peekPreviewSettings; private PeekSettings _peekSettings; @@ -42,7 +47,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private IFileSystemWatcher _watcher; public PeekViewModel( - ISettingsUtils settingsUtils, + SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, DispatcherQueue dispatcherQueue) @@ -59,6 +64,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels // Load the application-specific settings, including preview items. _peekSettings = _settingsUtils.GetSettingsOrDefault<PeekSettings>(PeekSettings.ModuleName); _peekPreviewSettings = _settingsUtils.GetSettingsOrDefault<PeekPreviewSettings>(PeekSettings.ModuleName, PeekPreviewSettings.FileName); + SetupSettingsFileWatcher(); InitializeEnabledValue(); @@ -118,6 +124,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; @@ -154,6 +170,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (_peekSettings.Properties.ActivationShortcut != value) { + // If space mode toggle is on, ignore external attempts to change (UI will be disabled, but defensive). + if (EnableSpaceToActivate) + { + return; + } + _peekSettings.Properties.ActivationShortcut = value ?? _peekSettings.Properties.DefaultActivationShortcut; OnPropertyChanged(nameof(ActivationShortcut)); NotifySettingsChanged(); @@ -203,6 +225,33 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool EnableSpaceToActivate + { + get => _peekSettings.Properties.EnableSpaceToActivate.Value; + set + { + if (_peekSettings.Properties.EnableSpaceToActivate.Value != value) + { + _peekSettings.Properties.EnableSpaceToActivate.Value = value; + + if (value) + { + // Force single space (0x20) without modifiers. + _peekSettings.Properties.ActivationShortcut = new HotkeySettings(false, false, false, false, 0x20); + } + else + { + // Revert to default (design simplification, not restoring previous custom combo). + _peekSettings.Properties.ActivationShortcut = _peekSettings.Properties.DefaultActivationShortcut; + } + + OnPropertyChanged(nameof(EnableSpaceToActivate)); + OnPropertyChanged(nameof(ActivationShortcut)); + NotifySettingsChanged(); + } + } + } + public bool SourceCodeWrapText { get => _peekPreviewSettings.SourceCodeWrapText.Value; @@ -302,11 +351,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsEnabled)); } - public void Dispose() + protected override void Dispose(bool disposing) { - _watcher?.Dispose(); + if (!_disposed) + { + if (disposing) + { + _watcher?.Dispose(); + _watcher = null; + } - GC.SuppressFinalize(this); + _disposed = true; + } + + base.Dispose(disposing); } } } diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerAccentViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerAccentViewModel.cs index 320f23c4ae..342d90ff52 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerAccentViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerAccentViewModel.cs @@ -21,7 +21,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private readonly PowerAccentSettings _powerAccentSettings; - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private const string SpecialGroup = "QuickAccent_Group_Special"; private const string LanguageGroup = "QuickAccent_Group_Language"; @@ -52,6 +52,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels new PowerAccentLanguageModel("KU", "QuickAccent_SelectedLanguage_Kurdish", LanguageGroup), new PowerAccentLanguageModel("LT", "QuickAccent_SelectedLanguage_Lithuanian", LanguageGroup), new PowerAccentLanguageModel("MK", "QuickAccent_SelectedLanguage_Macedonian", LanguageGroup), + new PowerAccentLanguageModel("MT", "QuickAccent_SelectedLanguage_Maltese", LanguageGroup), new PowerAccentLanguageModel("MI", "QuickAccent_SelectedLanguage_Maori", LanguageGroup), new PowerAccentLanguageModel("NO", "QuickAccent_SelectedLanguage_Norwegian", LanguageGroup), new PowerAccentLanguageModel("PI", "QuickAccent_SelectedLanguage_Pinyin", LanguageGroup), @@ -67,6 +68,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels new PowerAccentLanguageModel("SR_CYRL", "QuickAccent_SelectedLanguage_Serbian_Cyrillic", LanguageGroup), new PowerAccentLanguageModel("SV", "QuickAccent_SelectedLanguage_Swedish", LanguageGroup), new PowerAccentLanguageModel("TK", "QuickAccent_SelectedLanguage_Turkish", LanguageGroup), + new PowerAccentLanguageModel("VI", "QuickAccent_SelectedLanguage_Vietnamese", LanguageGroup), new PowerAccentLanguageModel("CY", "QuickAccent_SelectedLanguage_Welsh", LanguageGroup), ]; @@ -87,7 +89,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func<string, int> SendConfigMSG { get; } - public PowerAccentViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc) + public PowerAccentViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc) { // To obtain the general settings configurations of PowerToys Settings. ArgumentNullException.ThrowIfNull(settingsRepository); diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs new file mode 100644 index 0000000000..6c96214d5b --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/PowerDisplayViewModel.cs @@ -0,0 +1,790 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; + +using global::PowerToys.GPOWrapper; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.Library.ViewModels.Commands; +using PowerDisplay.Common.Models; +using PowerDisplay.Common.Services; +using PowerDisplay.Common.Utils; +using PowerToys.Interop; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public partial class PowerDisplayViewModel : PageViewModelBase + { + protected override string ModuleName => PowerDisplaySettings.ModuleName; + + private GeneralSettings GeneralSettingsConfig { get; set; } + + private SettingsUtils SettingsUtils { get; set; } + + public ButtonClickCommand LaunchEventHandler => new ButtonClickCommand(Launch); + + public PowerDisplayViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<PowerDisplaySettings> powerDisplaySettingsRepository, Func<string, int> ipcMSGCallBackFunc) + { + // Set up localized VCP code names for UI display + VcpNames.LocalizedCodeNameProvider = GetLocalizedVcpCodeName; + + // To obtain the general settings configurations of PowerToys Settings. + ArgumentNullException.ThrowIfNull(settingsRepository); + + SettingsUtils = settingsUtils; + GeneralSettingsConfig = settingsRepository.SettingsConfig; + + _settings = powerDisplaySettingsRepository.SettingsConfig; + + InitializeEnabledValue(); + + // Initialize monitors collection using property setter for proper subscription setup + var loadedMonitors = _settings.Properties.Monitors; + + Logger.LogInfo($"[Constructor] Initializing with {loadedMonitors.Count} monitors from settings"); + + Monitors = new ObservableCollection<MonitorInfo>(loadedMonitors); + + // set the callback functions value to handle outgoing IPC message. + SendConfigMSG = ipcMSGCallBackFunc; + + // Subscribe to collection changes for HasProfiles binding + _profiles.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasProfiles)); + + // Load profiles + LoadProfiles(); + + // Load custom VCP mappings + LoadCustomVcpMappings(); + + // Listen for monitor refresh events from PowerDisplay.exe + NativeEventWaiter.WaitForEventLoop( + Constants.RefreshPowerDisplayMonitorsEvent(), + () => + { + Logger.LogInfo("Received refresh monitors event from PowerDisplay.exe"); + ReloadMonitorsFromSettings(); + }); + } + + private GpoRuleConfigured _enabledGpoRuleConfiguration; + private bool _enabledStateIsGPOConfigured; + + private void InitializeEnabledValue() + { + _enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredPowerDisplayEnabledValue(); + if (_enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO + _enabledStateIsGPOConfigured = true; + _isEnabled = _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + } + else + { + _isEnabled = GeneralSettingsConfig.Enabled.PowerDisplay; + } + } + + public bool IsEnabled + { + get => _isEnabled; + set + { + if (_enabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + if (_isEnabled != value) + { + _isEnabled = value; + OnPropertyChanged(nameof(IsEnabled)); + + GeneralSettingsConfig.Enabled.PowerDisplay = value; + OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig); + SendConfigMSG(outgoing.ToString()); + } + } + } + + public bool IsEnabledGpoConfigured + { + get => _enabledStateIsGPOConfigured; + } + + public bool RestoreSettingsOnStartup + { + get => _settings.Properties.RestoreSettingsOnStartup; + set => SetSettingsProperty(_settings.Properties.RestoreSettingsOnStartup, value, v => _settings.Properties.RestoreSettingsOnStartup = v); + } + + public bool ShowSystemTrayIcon + { + get => _settings.Properties.ShowSystemTrayIcon; + set + { + if (SetSettingsProperty(_settings.Properties.ShowSystemTrayIcon, value, v => _settings.Properties.ShowSystemTrayIcon = v)) + { + // Explicitly signal PowerDisplay to refresh tray icon + // This is needed because set_config() doesn't signal SettingsUpdatedEvent to avoid UI refresh issues + SignalSettingsUpdated(); + Logger.LogInfo($"ShowSystemTrayIcon changed to {value}"); + } + } + } + + public bool ShowProfileSwitcher + { + get => _settings.Properties.ShowProfileSwitcher; + set + { + if (SetSettingsProperty(_settings.Properties.ShowProfileSwitcher, value, v => _settings.Properties.ShowProfileSwitcher = v)) + { + SignalSettingsUpdated(); + Logger.LogInfo($"ShowProfileSwitcher changed to {value}"); + } + } + } + + public bool ShowIdentifyMonitorsButton + { + get => _settings.Properties.ShowIdentifyMonitorsButton; + set + { + if (SetSettingsProperty(_settings.Properties.ShowIdentifyMonitorsButton, value, v => _settings.Properties.ShowIdentifyMonitorsButton = v)) + { + SignalSettingsUpdated(); + Logger.LogInfo($"ShowIdentifyMonitorsButton changed to {value}"); + } + } + } + + public HotkeySettings ActivationShortcut + { + get => _settings.Properties.ActivationShortcut; + set + { + if (SetSettingsProperty(_settings.Properties.ActivationShortcut, value, v => _settings.Properties.ActivationShortcut = v)) + { + // Signal PowerDisplay.exe to re-register the hotkey + EventHelper.SignalEvent(Constants.HotkeyUpdatedPowerDisplayEvent()); + Logger.LogInfo($"ActivationShortcut changed, signaled HotkeyUpdatedPowerDisplayEvent"); + } + } + } + + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + + /// <summary> + /// Gets or sets the delay in seconds before refreshing monitors after display changes. + /// </summary> + public int MonitorRefreshDelay + { + get => _settings.Properties.MonitorRefreshDelay; + set => SetSettingsProperty(_settings.Properties.MonitorRefreshDelay, value, v => _settings.Properties.MonitorRefreshDelay = v); + } + + private readonly List<int> _monitorRefreshDelayOptions = new List<int> { 1, 2, 3, 5, 10 }; + + public List<int> MonitorRefreshDelayOptions => _monitorRefreshDelayOptions; + + public ObservableCollection<MonitorInfo> Monitors + { + get => _monitors; + set + { + if (_monitors != null) + { + _monitors.CollectionChanged -= Monitors_CollectionChanged; + UnsubscribeFromItemPropertyChanged(_monitors); + } + + _monitors = value; + + if (_monitors != null) + { + _monitors.CollectionChanged += Monitors_CollectionChanged; + SubscribeToItemPropertyChanged(_monitors); + } + + OnPropertyChanged(nameof(Monitors)); + HasMonitors = _monitors?.Count > 0; + + // Update TotalMonitorCount for dynamic DisplayName + UpdateTotalMonitorCount(); + } + } + + public bool HasMonitors + { + get => _hasMonitors; + set + { + if (_hasMonitors != value) + { + _hasMonitors = value; + OnPropertyChanged(); + } + } + } + + private void Monitors_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + SubscribeToItemPropertyChanged(e.NewItems?.Cast<MonitorInfo>()); + UnsubscribeFromItemPropertyChanged(e.OldItems?.Cast<MonitorInfo>()); + + HasMonitors = _monitors.Count > 0; + _settings.Properties.Monitors = _monitors.ToList(); + NotifySettingsChanged(); + + // Update TotalMonitorCount for dynamic DisplayName + UpdateTotalMonitorCount(); + } + + /// <summary> + /// Update TotalMonitorCount on all monitors for dynamic DisplayName formatting. + /// When multiple monitors exist, DisplayName shows "Name N" format. + /// </summary> + private void UpdateTotalMonitorCount() + { + if (_monitors == null) + { + return; + } + + var count = _monitors.Count; + foreach (var monitor in _monitors) + { + monitor.TotalMonitorCount = count; + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize", Justification = "Base class PageViewModelBase.Dispose() handles GC.SuppressFinalize")] + public override void Dispose() + { + // Unsubscribe from monitor property changes + UnsubscribeFromItemPropertyChanged(_monitors); + + // Unsubscribe from collection changes + if (_monitors != null) + { + _monitors.CollectionChanged -= Monitors_CollectionChanged; + } + + base.Dispose(); + } + + /// <summary> + /// Subscribe to PropertyChanged events for items in the collection + /// </summary> + private void SubscribeToItemPropertyChanged(IEnumerable<MonitorInfo> items) + { + if (items != null) + { + foreach (var item in items) + { + item.PropertyChanged += OnMonitorPropertyChanged; + } + } + } + + /// <summary> + /// Unsubscribe from PropertyChanged events for items in the collection + /// </summary> + private void UnsubscribeFromItemPropertyChanged(IEnumerable<MonitorInfo> items) + { + if (items != null) + { + foreach (var item in items) + { + item.PropertyChanged -= OnMonitorPropertyChanged; + } + } + } + + /// <summary> + /// Handle PropertyChanged events from MonitorInfo objects + /// </summary> + private void OnMonitorPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (sender is MonitorInfo monitor) + { + Logger.LogDebug($"[PowerDisplayViewModel] Monitor {monitor.Name} property {e.PropertyName} changed"); + } + + // Update the settings object to keep it in sync + _settings.Properties.Monitors = _monitors.ToList(); + + // Save settings when any monitor property changes + NotifySettingsChanged(); + + // For feature visibility properties, explicitly signal PowerDisplay to refresh + // This is needed because set_config() doesn't signal SettingsUpdatedEvent to avoid UI refresh issues + if (e.PropertyName == nameof(MonitorInfo.EnableContrast) || + e.PropertyName == nameof(MonitorInfo.EnableVolume) || + e.PropertyName == nameof(MonitorInfo.EnableInputSource) || + e.PropertyName == nameof(MonitorInfo.EnableRotation) || + e.PropertyName == nameof(MonitorInfo.EnableColorTemperature) || + e.PropertyName == nameof(MonitorInfo.EnablePowerState) || + e.PropertyName == nameof(MonitorInfo.IsHidden)) + { + SignalSettingsUpdated(); + } + } + + /// <summary> + /// Signal PowerDisplay.exe that settings have been updated and need to be applied + /// </summary> + private void SignalSettingsUpdated() + { + EventHelper.SignalEvent(Constants.SettingsUpdatedPowerDisplayEvent()); + Logger.LogInfo("Signaled SettingsUpdatedPowerDisplayEvent for feature visibility change"); + } + + public void Launch() + { + var actionMessage = new PowerDisplayActionMessage + { + Action = new PowerDisplayActionMessage.ActionData + { + PowerDisplay = new PowerDisplayActionMessage.PowerDisplayAction + { + ActionName = "Launch", + Value = string.Empty, + }, + }, + }; + + SendConfigMSG(JsonSerializer.Serialize(actionMessage, SettingsSerializationContext.Default.PowerDisplayActionMessage)); + } + + /// <summary> + /// Reload monitor list from settings file (called when PowerDisplay.exe signals monitor changes) + /// </summary> + private void ReloadMonitorsFromSettings() + { + try + { + Logger.LogInfo("Reloading monitors from settings file"); + + // Read fresh settings from file + var updatedSettings = SettingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName); + var updatedMonitors = updatedSettings.Properties.Monitors; + + Logger.LogInfo($"[ReloadMonitors] Loaded {updatedMonitors.Count} monitors from settings"); + + // Update existing MonitorInfo objects instead of replacing the collection + // This preserves XAML x:Bind bindings which reference specific object instances + if (Monitors == null) + { + // First time initialization - create new collection + Monitors = new ObservableCollection<MonitorInfo>(updatedMonitors); + } + else + { + // Create a dictionary for quick lookup by Id + var updatedMonitorsDict = updatedMonitors.ToDictionary(m => m.Id, m => m); + + // Update existing monitors or remove ones that no longer exist + for (int i = Monitors.Count - 1; i >= 0; i--) + { + var existingMonitor = Monitors[i]; + if (updatedMonitorsDict.TryGetValue(existingMonitor.Id, out var updatedMonitor) + && updatedMonitor != null) + { + // Monitor still exists - update its properties in place + Logger.LogInfo($"[ReloadMonitors] Updating existing monitor: {existingMonitor.Id}"); + existingMonitor.UpdateFrom(updatedMonitor); + + updatedMonitorsDict.Remove(existingMonitor.Id); + } + else + { + // Monitor no longer exists - remove from collection + Logger.LogInfo($"[ReloadMonitors] Removing monitor: {existingMonitor.Id}"); + Monitors.RemoveAt(i); + } + } + + // Add any new monitors that weren't in the existing collection + foreach (var newMonitor in updatedMonitorsDict.Values) + { + Logger.LogInfo($"[ReloadMonitors] Adding new monitor: {newMonitor.Id}"); + Monitors.Add(newMonitor); + } + } + + // Update internal settings reference + _settings.Properties.Monitors = updatedMonitors; + + Logger.LogInfo($"Successfully reloaded {updatedMonitors.Count} monitors"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to reload monitors from settings: {ex.Message}"); + } + } + + private Func<string, int> SendConfigMSG { get; } + + private bool _isEnabled; + private PowerDisplaySettings _settings; + private ObservableCollection<MonitorInfo> _monitors; + private bool _hasMonitors; + + // Profile-related fields + private ObservableCollection<PowerDisplayProfile> _profiles = new ObservableCollection<PowerDisplayProfile>(); + + // Custom VCP mapping fields + private ObservableCollection<CustomVcpValueMapping> _customVcpMappings; + + /// <summary> + /// Gets collection of custom VCP value name mappings + /// </summary> + public ObservableCollection<CustomVcpValueMapping> CustomVcpMappings => _customVcpMappings; + + /// <summary> + /// Gets whether there are any custom VCP mappings (for UI binding) + /// </summary> + public bool HasCustomVcpMappings => _customVcpMappings?.Count > 0; + + /// <summary> + /// Gets collection of available profiles (for button display) + /// </summary> + public ObservableCollection<PowerDisplayProfile> Profiles => _profiles; + + /// <summary> + /// Gets whether there are any profiles (for UI binding) + /// </summary> + public bool HasProfiles => _profiles?.Count > 0; + + public void RefreshEnabledState() + { + InitializeEnabledValue(); + OnPropertyChanged(nameof(IsEnabled)); + } + + private bool SetSettingsProperty<T>(T currentValue, T newValue, Action<T> setter, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer<T>.Default.Equals(currentValue, newValue)) + { + return false; + } + + setter(newValue); + OnPropertyChanged(propertyName); + NotifySettingsChanged(); + return true; + } + + /// <summary> + /// Load profiles from disk + /// </summary> + private void LoadProfiles() + { + try + { + var profilesData = ProfileService.LoadProfiles(); + + // Load profile objects (no Custom - it's not a profile anymore) + Profiles.Clear(); + foreach (var profile in profilesData.Profiles) + { + Profiles.Add(profile); + } + + Logger.LogInfo($"Loaded {Profiles.Count} profiles"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load profiles: {ex.Message}"); + Profiles.Clear(); + } + } + + /// <summary> + /// Apply a profile to monitors + /// </summary> + public void ApplyProfile(PowerDisplayProfile profile) + { + try + { + if (profile == null || !profile.IsValid()) + { + Logger.LogWarning("Invalid profile"); + return; + } + + Logger.LogInfo($"Applying profile: {profile.Name}"); + + // Send custom action to trigger profile application + // The profile name is passed via Named Pipe IPC to PowerDisplay.exe + var actionMessage = new PowerDisplayActionMessage + { + Action = new PowerDisplayActionMessage.ActionData + { + PowerDisplay = new PowerDisplayActionMessage.PowerDisplayAction + { + ActionName = "ApplyProfile", + Value = profile.Name, + }, + }, + }; + + SendConfigMSG(JsonSerializer.Serialize(actionMessage, SettingsSerializationContext.Default.PowerDisplayActionMessage)); + + Logger.LogInfo($"Profile '{profile.Name}' applied successfully"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to apply profile: {ex.Message}"); + } + } + + /// <summary> + /// Create a new profile + /// </summary> + public void CreateProfile(PowerDisplayProfile profile) + { + try + { + if (profile == null || !profile.IsValid()) + { + Logger.LogWarning("Invalid profile"); + return; + } + + Logger.LogInfo($"Creating profile: {profile.Name}"); + + var profilesData = ProfileService.LoadProfiles(); + profilesData.SetProfile(profile); + ProfileService.SaveProfiles(profilesData); + + // Reload profile list + LoadProfiles(); + + // Signal PowerDisplay to reload profiles + SignalSettingsUpdated(); + + Logger.LogInfo($"Profile '{profile.Name}' created successfully"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to create profile: {ex.Message}"); + } + } + + /// <summary> + /// Update an existing profile + /// </summary> + public void UpdateProfile(string oldName, PowerDisplayProfile newProfile) + { + try + { + if (newProfile == null || !newProfile.IsValid()) + { + Logger.LogWarning("Invalid profile"); + return; + } + + Logger.LogInfo($"Updating profile: {oldName} -> {newProfile.Name}"); + + var profilesData = ProfileService.LoadProfiles(); + + // Remove old profile and add updated one + profilesData.RemoveProfile(oldName); + profilesData.SetProfile(newProfile); + ProfileService.SaveProfiles(profilesData); + + // Reload profile list + LoadProfiles(); + + // Signal PowerDisplay to reload profiles + SignalSettingsUpdated(); + + Logger.LogInfo($"Profile updated to '{newProfile.Name}' successfully"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to update profile: {ex.Message}"); + } + } + + /// <summary> + /// Delete a profile + /// </summary> + public void DeleteProfile(string profileName) + { + try + { + if (string.IsNullOrEmpty(profileName)) + { + return; + } + + Logger.LogInfo($"Deleting profile: {profileName}"); + + var profilesData = ProfileService.LoadProfiles(); + profilesData.RemoveProfile(profileName); + ProfileService.SaveProfiles(profilesData); + + // Reload profile list + LoadProfiles(); + + // Signal PowerDisplay to reload profiles + SignalSettingsUpdated(); + + Logger.LogInfo($"Profile '{profileName}' deleted successfully"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to delete profile: {ex.Message}"); + } + } + + /// <summary> + /// Load custom VCP mappings from settings + /// </summary> + private void LoadCustomVcpMappings() + { + List<CustomVcpValueMapping> mappings; + try + { + mappings = _settings.Properties.CustomVcpMappings ?? new List<CustomVcpValueMapping>(); + Logger.LogInfo($"Loaded {mappings.Count} custom VCP mappings"); + } + catch (Exception ex) + { + Logger.LogError($"Failed to load custom VCP mappings: {ex.Message}"); + mappings = new List<CustomVcpValueMapping>(); + } + + _customVcpMappings = new ObservableCollection<CustomVcpValueMapping>(mappings); + _customVcpMappings.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasCustomVcpMappings)); + OnPropertyChanged(nameof(CustomVcpMappings)); + OnPropertyChanged(nameof(HasCustomVcpMappings)); + } + + /// <summary> + /// Add a new custom VCP mapping. + /// No duplicate checking - mappings are resolved by order (first match wins in VcpNames). + /// </summary> + public void AddCustomVcpMapping(CustomVcpValueMapping mapping) + { + if (mapping == null) + { + return; + } + + CustomVcpMappings.Add(mapping); + Logger.LogInfo($"Added custom VCP mapping: VCP=0x{mapping.VcpCode:X2}, Value=0x{mapping.Value:X2} -> {mapping.CustomName}"); + SaveCustomVcpMappings(); + } + + /// <summary> + /// Update an existing custom VCP mapping + /// </summary> + public void UpdateCustomVcpMapping(CustomVcpValueMapping oldMapping, CustomVcpValueMapping newMapping) + { + if (oldMapping == null || newMapping == null) + { + return; + } + + var index = CustomVcpMappings.IndexOf(oldMapping); + if (index >= 0) + { + CustomVcpMappings[index] = newMapping; + Logger.LogInfo($"Updated custom VCP mapping at index {index}"); + SaveCustomVcpMappings(); + } + } + + /// <summary> + /// Delete a custom VCP mapping + /// </summary> + public void DeleteCustomVcpMapping(CustomVcpValueMapping mapping) + { + if (mapping == null) + { + return; + } + + if (CustomVcpMappings.Remove(mapping)) + { + Logger.LogInfo($"Deleted custom VCP mapping: VCP=0x{mapping.VcpCode:X2}, Value=0x{mapping.Value:X2}"); + SaveCustomVcpMappings(); + } + } + + /// <summary> + /// Save custom VCP mappings to settings + /// </summary> + private void SaveCustomVcpMappings() + { + _settings.Properties.CustomVcpMappings = CustomVcpMappings.ToList(); + NotifySettingsChanged(); + + // Signal PowerDisplay to reload settings + SignalSettingsUpdated(); + } + + /// <summary> + /// Provides localized VCP code names for UI display. + /// Looks for resource string with pattern "PowerDisplay_VcpCode_Name_0xXX". + /// Returns null for unknown codes to use the default MCCS name. + /// </summary> +#nullable enable + private static string? GetLocalizedVcpCodeName(byte vcpCode) + { + var resourceKey = $"PowerDisplay_VcpCode_Name_0x{vcpCode:X2}"; + var localizedName = ResourceLoaderInstance.ResourceLoader.GetString(resourceKey); + + // ResourceLoader returns empty string if key not found + return string.IsNullOrEmpty(localizedName) ? null : localizedName; + } +#nullable restore + + private void NotifySettingsChanged() + { + // Skip during initialization when SendConfigMSG is not yet set + if (SendConfigMSG == null) + { + return; + } + + // Persist locally first so settings survive even if the module DLL isn't loaded yet. + SettingsUtils.SaveSettings(_settings.ToJsonString(), PowerDisplaySettings.ModuleName); + + // Using InvariantCulture as this is an IPC message + // This message will be intercepted by the runner, which passes the serialized JSON to + // PowerDisplay Module Interface's set_config() method, which then applies it in-process. + SendConfigMSG( + string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + PowerDisplaySettings.ModuleName, + _settings.ToJsonString())); + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerLauncherViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerLauncherViewModel.cs index 8c02d58319..ee5b6e2f7c 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerLauncherViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerLauncherViewModel.cs @@ -3,6 +3,7 @@ // 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.Globalization; @@ -10,9 +11,9 @@ using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using System.Windows.Input; - using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -21,7 +22,7 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class PowerLauncherViewModel : Observable + public partial class PowerLauncherViewModel : PageViewModelBase, IDisposable { private int _themeIndex; private int _monitorPositionIndex; @@ -30,6 +31,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _enabledStateIsGPOConfigured; private bool _isEnabled; private string _searchText; + private bool _hotkeyChanged; private GeneralSettings GeneralSettingsConfig { get; set; } @@ -37,6 +39,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public delegate void SendCallback(PowerLauncherSettings settings); + protected override string ModuleName => PowerLauncherSettings.ModuleName; + private readonly SendCallback callback; private readonly Func<bool> isDark; @@ -122,6 +126,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [OpenPowerLauncher], + }; + + return hotkeysDict; + } + private void OnPluginInfoChange(object sender, PropertyChangedEventArgs e) { if ( @@ -149,6 +163,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels // Notify UI of property change OnPropertyChanged(propertyName); + // Since PowerLauncher registers its hotkeys independently within the module process, + // the runner is notified to update PowerLaunchers hotkeys only when changes occur. + // This prevents incorrect conflict detection results. + settings.Properties.HotkeyChanged = _hotkeyChanged; + _hotkeyChanged = false; + callback(settings); } @@ -322,6 +342,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels if (settings.Properties.OpenPowerLauncher != value) { settings.Properties.OpenPowerLauncher = value ?? settings.Properties.DefaultOpenPowerLauncher; + _hotkeyChanged = true; UpdateSettings(); } } diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerOcrViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerOcrViewModel.cs index 09aa682c39..f9dc35247c 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerOcrViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerOcrViewModel.cs @@ -11,6 +11,7 @@ using System.Text.Json; using System.Timers; using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -20,16 +21,18 @@ using Windows.Media.Ocr; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class PowerOcrViewModel : Observable, IDisposable + public partial class PowerOcrViewModel : PageViewModelBase { - private bool disposedValue; + protected override string ModuleName => PowerOcrSettings.ModuleName; - // Delay saving of settings in order to avoid calling save multiple times and hitting file in use exception. If there is no other request to save settings in given interval, we proceed to save it, otherwise we schedule saving it after this interval + private bool _disposed; + + // Delay saving of settings in order to avoid calling save multiple times and hitting file in use exception. If there is no other request to save settings in given interval, we proceed to save it; otherwise, we schedule saving it after this interval private const int SaveSettingsDelayInMs = 500; private GeneralSettings GeneralSettingsConfig { get; set; } - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private readonly System.Threading.Lock _delayedActionLock = new System.Threading.Lock(); private readonly PowerOcrSettings _powerOcrSettings; @@ -69,7 +72,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func<string, int> SendConfigMSG { get; } public PowerOcrViewModel( - ISettingsUtils settingsUtils, + SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<PowerOcrSettings> powerOcrsettingsRepository, Func<string, int> ipcMSGCallBackFunc) @@ -114,6 +117,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [ActivationShortcut], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; @@ -246,23 +259,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsEnabled)); } - protected virtual void Dispose(bool disposing) + protected override void Dispose(bool disposing) { - if (!disposedValue) + if (!_disposed) { if (disposing) { - _delayedTimer.Dispose(); + _delayedTimer?.Dispose(); + _delayedTimer = null; } - disposedValue = true; + _disposed = true; } - } - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); + base.Dispose(disposing); } public string SnippingToolInfoBarMargin diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerPreviewViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerPreviewViewModel.cs index 86089a2d5d..75c22dd855 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerPreviewViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerPreviewViewModel.cs @@ -125,6 +125,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _gcodeRenderIsEnabled = Settings.Properties.EnableGcodePreview; } + _bgcodeRenderEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredBgcodePreviewEnabledValue(); + if (_bgcodeRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _bgcodeRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO. + _bgcodeRenderEnabledStateIsGPOConfigured = true; + _bgcodeRenderIsEnabled = _bgcodeRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + _bgcodeRenderIsGpoEnabled = _bgcodeRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + _bgcodeRenderIsGpoDisabled = _bgcodeRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled; + } + else + { + _bgcodeRenderIsEnabled = Settings.Properties.EnableBgcodePreview; + } + _qoiRenderEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredQoiPreviewEnabledValue(); if (_qoiRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _qoiRenderEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) { @@ -181,6 +195,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _gcodeThumbnailIsEnabled = Settings.Properties.EnableGcodeThumbnail; } + _bgcodeThumbnailEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredBgcodeThumbnailsEnabledValue(); + if (_bgcodeThumbnailEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _bgcodeThumbnailEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) + { + // Get the enabled state from GPO. + _bgcodeThumbnailEnabledStateIsGPOConfigured = true; + _bgcodeThumbnailIsEnabled = _bgcodeThumbnailEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + _bgcodeThumbnailIsGpoEnabled = _bgcodeThumbnailEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled; + _bgcodeThumbnailIsGpoDisabled = _bgcodeThumbnailEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled; + } + else + { + _bgcodeThumbnailIsEnabled = Settings.Properties.EnableBgcodeThumbnail; + } + _stlThumbnailEnabledGpoRuleConfiguration = GPOWrapper.GetConfiguredStlThumbnailsEnabledValue(); if (_stlThumbnailEnabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _stlThumbnailEnabledGpoRuleConfiguration == GpoRuleConfigured.Enabled) { @@ -251,6 +279,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _gcodeRenderIsGpoDisabled; private bool _gcodeRenderIsEnabled; + private GpoRuleConfigured _bgcodeRenderEnabledGpoRuleConfiguration; + private bool _bgcodeRenderEnabledStateIsGPOConfigured; + private bool _bgcodeRenderIsGpoEnabled; + private bool _bgcodeRenderIsGpoDisabled; + private bool _bgcodeRenderIsEnabled; + private GpoRuleConfigured _qoiRenderEnabledGpoRuleConfiguration; private bool _qoiRenderEnabledStateIsGPOConfigured; private bool _qoiRenderIsGpoEnabled; @@ -275,6 +309,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private bool _gcodeThumbnailIsGpoDisabled; private bool _gcodeThumbnailIsEnabled; + private GpoRuleConfigured _bgcodeThumbnailEnabledGpoRuleConfiguration; + private bool _bgcodeThumbnailEnabledStateIsGPOConfigured; + private bool _bgcodeThumbnailIsGpoEnabled; + private bool _bgcodeThumbnailIsGpoDisabled; + private bool _bgcodeThumbnailIsEnabled; + private GpoRuleConfigured _stlThumbnailEnabledGpoRuleConfiguration; private bool _stlThumbnailEnabledStateIsGPOConfigured; private bool _stlThumbnailIsGpoEnabled; @@ -294,7 +334,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { return _svgRenderEnabledStateIsGPOConfigured || _mdRenderEnabledStateIsGPOConfigured || _monacoRenderEnabledStateIsGPOConfigured || _pdfRenderEnabledStateIsGPOConfigured - || _gcodeRenderEnabledStateIsGPOConfigured || _qoiRenderEnabledStateIsGPOConfigured; + || _gcodeRenderEnabledStateIsGPOConfigured || _qoiRenderEnabledStateIsGPOConfigured + || _bgcodeRenderEnabledStateIsGPOConfigured; } } @@ -304,7 +345,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { return _svgThumbnailEnabledStateIsGPOConfigured || _pdfThumbnailEnabledStateIsGPOConfigured || _gcodeThumbnailEnabledStateIsGPOConfigured || _stlThumbnailEnabledStateIsGPOConfigured - || _qoiThumbnailEnabledStateIsGPOConfigured; + || _qoiThumbnailEnabledStateIsGPOConfigured || _bgcodeThumbnailEnabledStateIsGPOConfigured; } } @@ -778,6 +819,48 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool BGCODERenderIsEnabled + { + get + { + return _bgcodeRenderIsEnabled; + } + + set + { + if (_bgcodeRenderEnabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + if (value != _bgcodeRenderIsEnabled) + { + _bgcodeRenderIsEnabled = value; + Settings.Properties.EnableBgcodePreview = value; + RaisePropertyChanged(); + } + } + } + + // Used to only disable enabled button on forced enabled state. (With this users still able to change the utility properties.) + public bool BGCODERenderIsGpoEnabled + { + get + { + return _bgcodeRenderIsGpoEnabled; + } + } + + // Used to disable the settings card on forced disabled state. + public bool BGCODERenderIsGpoDisabled + { + get + { + return _bgcodeRenderIsGpoDisabled; + } + } + public bool GCODEThumbnailIsEnabled { get @@ -820,6 +903,48 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool BGCODEThumbnailIsEnabled + { + get + { + return _bgcodeThumbnailIsEnabled; + } + + set + { + if (_bgcodeThumbnailEnabledStateIsGPOConfigured) + { + // If it's GPO configured, shouldn't be able to change this state. + return; + } + + if (value != _bgcodeThumbnailIsEnabled) + { + _bgcodeThumbnailIsEnabled = value; + Settings.Properties.EnableBgcodeThumbnail = value; + RaisePropertyChanged(); + } + } + } + + // Used to only disable enabled button on forced enabled state. (With this users still able to change the utility properties.) + public bool BGCODEThumbnailIsGpoEnabled + { + get + { + return _bgcodeThumbnailIsGpoEnabled; + } + } + + // Used to disable the settings card on forced disabled state. + public bool BGCODEThumbnailIsGpoDisabled + { + get + { + return _bgcodeThumbnailIsGpoDisabled; + } + } + public bool STLThumbnailIsEnabled { get diff --git a/src/settings-ui/Settings.UI/ViewModels/PowerRenameViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/PowerRenameViewModel.cs index e1dd68ed5f..9622499eec 100644 --- a/src/settings-ui/Settings.UI/ViewModels/PowerRenameViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/PowerRenameViewModel.cs @@ -5,9 +5,12 @@ using System; using System.IO; using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using System.Windows.Input; using global::PowerToys.GPOWrapper; using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -18,7 +21,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { private GeneralSettings GeneralSettingsConfig { get; set; } - private readonly ISettingsUtils _settingsUtils; + private readonly SettingsUtils _settingsUtils; private const string ModuleName = PowerRenameSettings.ModuleName; @@ -28,7 +31,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private Func<string, int> SendConfigMSG { get; } - public PowerRenameViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, string configFileSubfolder = "") + public PowerRenameViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, string configFileSubfolder = "") { // Update Settings file folder: _settingsConfigFileFolder = configFileSubfolder; @@ -67,6 +70,17 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels _autoComplete = Settings.Properties.MRUEnabled.Value; _powerRenameUseBoostLib = Settings.Properties.UseBoostLib.Value; + // Initialize extension helpers + HeifExtension = new StoreExtensionHelper( + "Microsoft.HEIFImageExtension_8wekyb3d8bbwe", + "ms-windows-store://pdp/?ProductId=9PMMSR1CGPWG", + "HEIF"); + + AvifExtension = new StoreExtensionHelper( + "Microsoft.AV1VideoExtension_8wekyb3d8bbwe", + "ms-windows-store://pdp/?ProductId=9MVZQVXJBQ9V", + "AV1"); + InitializeEnabledValue(); } @@ -270,5 +284,31 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels OnPropertyChanged(nameof(IsEnabled)); OnPropertyChanged(nameof(GlobalAndMruEnabled)); } + + // Store extension helpers + public StoreExtensionHelper HeifExtension { get; private set; } + + public StoreExtensionHelper AvifExtension { get; private set; } + + // Convenience properties for XAML binding + public bool IsHeifExtensionInstalled => HeifExtension.IsInstalled; + + public bool IsAvifExtensionInstalled => AvifExtension.IsInstalled; + + public ICommand InstallHeifExtensionCommand => HeifExtension.InstallCommand; + + public ICommand InstallAvifExtensionCommand => AvifExtension.InstallCommand; + + public void RefreshHeifExtensionStatus() + { + HeifExtension.RefreshStatus(); + OnPropertyChanged(nameof(IsHeifExtensionInstalled)); + } + + public void RefreshAvifExtensionStatus() + { + AvifExtension.RefreshStatus(); + OnPropertyChanged(nameof(IsAvifExtensionInstalled)); + } } } diff --git a/src/settings-ui/Settings.UI/ViewModels/ProfileEditorViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ProfileEditorViewModel.cs new file mode 100644 index 0000000000..57001d7ac7 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/ProfileEditorViewModel.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.PowerToys.Settings.UI.Library; +using PowerDisplay.Common.Models; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + /// <summary> + /// ViewModel for Profile Editor Dialog + /// </summary> + public class ProfileEditorViewModel : INotifyPropertyChanged + { + private string _profileName = string.Empty; + private ObservableCollection<MonitorSelectionItem> _monitors; + + public ProfileEditorViewModel(ObservableCollection<MonitorInfo> availableMonitors, string defaultName = "") + { + _profileName = defaultName; + _monitors = new ObservableCollection<MonitorSelectionItem>(); + + // Set TotalMonitorCount for DisplayName to show monitor numbers when multiple monitors exist + int totalCount = availableMonitors.Count; + foreach (var monitor in availableMonitors) + { + monitor.TotalMonitorCount = totalCount; + } + + // Initialize monitor selection items + foreach (var monitor in availableMonitors) + { + var item = new MonitorSelectionItem + { + SuppressAutoSelection = true, + Monitor = monitor, + IsSelected = false, + Brightness = monitor.CurrentBrightness, + Contrast = 50, // Default value (MonitorInfo doesn't store contrast) + Volume = 50, // Default value (MonitorInfo doesn't store volume) + ColorTemperature = monitor.ColorTemperatureVcp, + }; + + item.SuppressAutoSelection = false; + + // Subscribe to selection and checkbox changes + item.PropertyChanged += OnMonitorItemPropertyChanged; + + _monitors.Add(item); + } + } + + public string ProfileName + { + get => _profileName; + set + { + if (_profileName != value) + { + _profileName = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(CanSave)); + } + } + } + + public ObservableCollection<MonitorSelectionItem> Monitors + { + get => _monitors; + set + { + if (_monitors != value) + { + _monitors = value; + OnPropertyChanged(); + } + } + } + + public bool HasSelectedMonitors => _monitors?.Any(m => m.IsSelected) ?? false; + + public bool HasValidSettings => _monitors != null && + _monitors.Any(m => m.IsSelected) && + _monitors.Where(m => m.IsSelected).All(m => m.IncludeBrightness || m.IncludeContrast || m.IncludeVolume || m.IncludeColorTemperature); + + public bool CanSave => !string.IsNullOrWhiteSpace(_profileName) && HasSelectedMonitors && HasValidSettings; + + public PowerDisplayProfile CreateProfile() + { + var settings = _monitors + .Where(m => m.IsSelected) + .Select(m => new ProfileMonitorSetting( + m.Monitor.Id, // Monitor Id (unique identifier) + m.IncludeBrightness ? (int?)m.Brightness : null, + m.IncludeColorTemperature && m.SupportsColorTemperature ? (int?)m.ColorTemperature : null, + m.IncludeContrast && m.SupportsContrast ? (int?)m.Contrast : null, + m.IncludeVolume && m.SupportsVolume ? (int?)m.Volume : null)) + .ToList(); + + return new PowerDisplayProfile(_profileName, settings); + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + /// <summary> + /// Handle property changes from monitor selection items. + /// Centralizes validation state updates to avoid duplication. + /// </summary> + private void OnMonitorItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // Update selection-dependent properties + if (e.PropertyName == nameof(MonitorSelectionItem.IsSelected)) + { + OnPropertyChanged(nameof(HasSelectedMonitors)); + } + + // Update validation state for relevant property changes + if (e.PropertyName == nameof(MonitorSelectionItem.IsSelected) || + e.PropertyName == nameof(MonitorSelectionItem.IncludeBrightness) || + e.PropertyName == nameof(MonitorSelectionItem.IncludeContrast) || + e.PropertyName == nameof(MonitorSelectionItem.IncludeVolume) || + e.PropertyName == nameof(MonitorSelectionItem.IncludeColorTemperature)) + { + OnPropertyChanged(nameof(CanSave)); + OnPropertyChanged(nameof(HasValidSettings)); + } + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/SearchResultsViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/SearchResultsViewModel.cs new file mode 100644 index 0000000000..d0583b0b40 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/SearchResultsViewModel.cs @@ -0,0 +1,133 @@ +// 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.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.PowerToys.Settings.UI.Services; +using Settings.UI.Library; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public class SearchResultsViewModel : INotifyPropertyChanged + { + private ObservableCollection<SettingEntry> _moduleResults = new(); + private ObservableCollection<SettingsGroup> _groupedSettingsResults = new(); + private bool _hasNoResults; + + public ObservableCollection<SettingEntry> ModuleResults + { + get => _moduleResults; + set + { + _moduleResults = value; + OnPropertyChanged(); + } + } + + public ObservableCollection<SettingsGroup> GroupedSettingsResults + { + get => _groupedSettingsResults; + set + { + _groupedSettingsResults = value; + OnPropertyChanged(); + } + } + + public bool HasNoResults + { + get => _hasNoResults; + set + { + _hasNoResults = value; + OnPropertyChanged(); + } + } + + public void SetSearchResults(string query, List<SettingEntry> results) + { + if (results == null || results.Count == 0) + { + HasNoResults = true; + ModuleResults.Clear(); + GroupedSettingsResults.Clear(); + return; + } + + HasNoResults = false; + + // Separate modules and settings + var modules = results.Where(r => r.Type == EntryType.SettingsPage).ToList(); + var settings = results.Where(r => r.Type == EntryType.SettingsCard).ToList(); + + // Update module results + ModuleResults.Clear(); + foreach (var module in modules) + { + ModuleResults.Add(module); + } + + // Group settings by their page/module + var groupedSettings = settings + .GroupBy(s => SearchIndexService.GetLocalizedPageName(s.PageTypeName)) + .Select(g => new SettingsGroup + { + GroupName = g.Key, + Settings = new ObservableCollection<SettingEntry>(g), + }) + .ToList(); + + GroupedSettingsResults.Clear(); + foreach (var group in groupedSettings) + { + GroupedSettingsResults.Add(group); + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + +#pragma warning disable SA1402 // File may only contain a single type + public class SettingsGroup : INotifyPropertyChanged +#pragma warning restore SA1402 // File may only contain a single type + { + private string _groupName; + private ObservableCollection<SettingEntry> _settings; + + public string GroupName + { + get => _groupName; + set + { + _groupName = value; + OnPropertyChanged(); + } + } + + public ObservableCollection<SettingEntry> Settings + { + get => _settings; + set + { + _settings = value; + OnPropertyChanged(); + } + } + + public event PropertyChangedEventHandler PropertyChanged; + + protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/ShellViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ShellViewModel.cs index 54e9987858..aa0e8855f2 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ShellViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ShellViewModel.cs @@ -10,7 +10,9 @@ using System.Threading.Tasks; using System.Windows.Input; using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; using Microsoft.PowerToys.Settings.UI.Services; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; @@ -26,31 +28,54 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels private readonly KeyboardAccelerator backKeyboardAccelerator = BuildKeyboardAccelerator(VirtualKey.GoBack); private bool isBackEnabled; + private bool showCloseMenu; private IList<KeyboardAccelerator> keyboardAccelerators; private NavigationView navigationView; private NavigationViewItem selected; + private NavigationViewItem expanding; private ICommand loadedCommand; private ICommand itemInvokedCommand; private NavigationViewItem[] _fullListOfNavViewItems; + private NavigationViewItem[] _moduleNavViewItems; + private GeneralSettings _generalSettingsConfig; public bool IsBackEnabled { - get { return isBackEnabled; } - set { Set(ref isBackEnabled, value); } + get => isBackEnabled; + set => Set(ref isBackEnabled, value); + } + + public bool ShowCloseMenu + { + get => showCloseMenu; + set => Set(ref showCloseMenu, value); } public NavigationViewItem Selected { - get { return selected; } - set { Set(ref selected, value); } + get => selected; + set => Set(ref selected, value); + } + + public NavigationViewItem Expanding + { + get { return expanding; } + set { Set(ref expanding, value); } + } + + public NavigationViewItem[] NavItems + { + get { return _moduleNavViewItems; } } public ICommand LoadedCommand => loadedCommand ?? (loadedCommand = new RelayCommand(OnLoaded)); public ICommand ItemInvokedCommand => itemInvokedCommand ?? (itemInvokedCommand = new RelayCommand<NavigationViewItemInvokedEventArgs>(OnItemInvoked)); - public ShellViewModel() + public ShellViewModel(ISettingsRepository<GeneralSettings> settingsRepository) { + _generalSettingsConfig = settingsRepository.SettingsConfig; + ShowCloseMenu = !_generalSettingsConfig.ShowSysTrayIcon; } public void Initialize(Frame frame, NavigationView navigationView, IList<KeyboardAccelerator> keyboardAccelerators) @@ -62,7 +87,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels NavigationService.Navigated += Frame_Navigated; this.navigationView.BackRequested += OnBackRequested; var topLevelItems = navigationView.MenuItems.OfType<NavigationViewItem>(); - _fullListOfNavViewItems = topLevelItems.Union(topLevelItems.SelectMany(menuItem => menuItem.MenuItems.OfType<NavigationViewItem>())).ToArray(); + _moduleNavViewItems = topLevelItems.SelectMany(menuItem => menuItem.MenuItems.OfType<NavigationViewItem>()).ToArray(); + _fullListOfNavViewItems = topLevelItems.Union(_moduleNavViewItems).ToArray(); } private static KeyboardAccelerator BuildKeyboardAccelerator(VirtualKey key, VirtualKeyModifiers? modifiers = null) diff --git a/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs new file mode 100644 index 0000000000..a44306acb3 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/ShortcutConflictViewModel.cs @@ -0,0 +1,394 @@ +// 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.Globalization; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Windows.Threading; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.Windows.ApplicationModel.Resources; + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public class ShortcutConflictViewModel : PageViewModelBase + { + private readonly SettingsFactory _settingsFactory; + private readonly Func<string, int> _ipcMSGCallBackFunc; + private readonly Dispatcher _dispatcher; + + private bool _disposed; + private AllHotkeyConflictsData _conflictsData = new(); + private ObservableCollection<HotkeyConflictGroupData> _conflictItems = new(); + private ResourceLoader resourceLoader; + + public ShortcutConflictViewModel( + SettingsUtils settingsUtils, + ISettingsRepository<GeneralSettings> settingsRepository, + Func<string, int> ipcMSGCallBackFunc) + { + _dispatcher = Dispatcher.CurrentDispatcher; + _ipcMSGCallBackFunc = ipcMSGCallBackFunc ?? throw new ArgumentNullException(nameof(ipcMSGCallBackFunc)); + resourceLoader = ResourceLoaderInstance.ResourceLoader; + + // Create SettingsFactory + _settingsFactory = new SettingsFactory(settingsUtils ?? throw new ArgumentNullException(nameof(settingsUtils))); + } + + public AllHotkeyConflictsData ConflictsData + { + get => _conflictsData; + set + { + if (Set(ref _conflictsData, value)) + { + UpdateConflictItems(); + } + } + } + + public ObservableCollection<HotkeyConflictGroupData> ConflictItems + { + get => _conflictItems; + private set => Set(ref _conflictItems, value); + } + + protected override string ModuleName => "ShortcutConflictsWindow"; + + /// <summary> + /// Ignore a specific HotkeySettings + /// </summary> + /// <param name="hotkeySettings">The HotkeySettings to ignore</param> + public void IgnoreShortcut(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return; + } + + HotkeyConflictIgnoreHelper.AddToIgnoredList(hotkeySettings); + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + + /// <summary> + /// Remove a HotkeySettings from the ignored list + /// </summary> + /// <param name="hotkeySettings">The HotkeySettings to unignore</param> + public void UnignoreShortcut(HotkeySettings hotkeySettings) + { + if (hotkeySettings == null) + { + return; + } + + HotkeyConflictIgnoreHelper.RemoveFromIgnoredList(hotkeySettings); + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + + private IHotkeyConfig GetModuleSettings(string moduleKey) + { + try + { + // MouseWithoutBorders and Peek settings may be changed by the logic in the utility as machines connect. + // We need to get a fresh version every time instead of using a repository. + if (string.Equals(moduleKey, MouseWithoutBordersSettings.ModuleName, StringComparison.OrdinalIgnoreCase) || + string.Equals(moduleKey, PeekSettings.ModuleName, StringComparison.OrdinalIgnoreCase)) + { + return _settingsFactory.GetFreshSettings(moduleKey); + } + + // For other modules, get the settings from SettingsRepository + return _settingsFactory.GetSettings(moduleKey); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error loading settings for {moduleKey}: {ex.Message}"); + return null; + } + } + + protected override void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e) + { + _dispatcher.BeginInvoke(() => + { + ConflictsData = e.Conflicts ?? new AllHotkeyConflictsData(); + }); + } + + private void UpdateConflictItems() + { + var items = new ObservableCollection<HotkeyConflictGroupData>(); + + ProcessConflicts(ConflictsData?.InAppConflicts, false, items); + ProcessConflicts(ConflictsData?.SystemConflicts, true, items); + + ConflictItems = items; + OnPropertyChanged(nameof(ConflictItems)); + } + + private void ProcessConflicts(IEnumerable<HotkeyConflictGroupData> conflicts, bool isSystemConflict, ObservableCollection<HotkeyConflictGroupData> items) + { + if (conflicts == null) + { + return; + } + + foreach (var conflict in conflicts) + { + HotkeySettings hotkey = new(conflict.Hotkey.Win, conflict.Hotkey.Ctrl, conflict.Hotkey.Alt, conflict.Hotkey.Shift, conflict.Hotkey.Key); + var isIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkey); + conflict.ConflictIgnored = isIgnored; + + ProcessConflictGroup(conflict, isSystemConflict, isIgnored); + items.Add(conflict); + } + } + + private void ProcessConflictGroup(HotkeyConflictGroupData conflict, bool isSystemConflict, bool isIgnored) + { + foreach (var module in conflict.Modules) + { + SetupModuleData(module, isSystemConflict, isIgnored); + } + } + + private void SetupModuleData(ModuleHotkeyData module, bool isSystemConflict, bool isIgnored) + { + try + { + var settings = GetModuleSettings(module.ModuleName); + var allHotkeyAccessors = settings.GetAllHotkeyAccessors(); + var hotkeyAccessor = allHotkeyAccessors[module.HotkeyID]; + + if (hotkeyAccessor != null) + { + // Get current hotkey settings (fresh from file) using the accessor's getter + module.HotkeySettings = hotkeyAccessor.Value; + module.HotkeySettings.ConflictDescription = isSystemConflict + ? ResourceLoaderInstance.ResourceLoader.GetString("SysHotkeyConflictTooltipText") + : ResourceLoaderInstance.ResourceLoader.GetString("InAppHotkeyConflictTooltipText"); + + // Set header using localization key + module.Header = GetHotkeyLocalizationHeader(module.ModuleName, module.HotkeyID, hotkeyAccessor.LocalizationHeaderKey); + module.IsSystemConflict = isSystemConflict; + + // Set module display info + var moduleType = settings.GetModuleType(); + module.ModuleType = moduleType; + var displayName = resourceLoader.GetString(ModuleHelper.GetModuleLabelResourceName(moduleType)); + module.DisplayName = displayName; + module.IconPath = ModuleHelper.GetModuleTypeFluentIconName(moduleType); + + if (module.HotkeySettings != null) + { + SetConflictProperties(module.HotkeySettings, isSystemConflict); + } + + module.PropertyChanged -= OnModuleHotkeyDataPropertyChanged; + module.PropertyChanged += OnModuleHotkeyDataPropertyChanged; + } + else + { + System.Diagnostics.Debug.WriteLine($"Could not find hotkey accessor for {module.ModuleName}.{module.HotkeyID}"); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error setting up module data for {module.ModuleName}: {ex.Message}"); + } + } + + private void SetConflictProperties(HotkeySettings settings, bool isSystemConflict) + { + settings.HasConflict = true; + settings.IsSystemConflict = isSystemConflict; + } + + private void OnModuleHotkeyDataPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (sender is ModuleHotkeyData moduleData && e.PropertyName == nameof(ModuleHotkeyData.HotkeySettings)) + { + UpdateModuleHotkeySettings(moduleData.ModuleName, moduleData.HotkeyID, moduleData.HotkeySettings); + } + } + + private void UpdateModuleHotkeySettings(string moduleName, int hotkeyID, HotkeySettings newHotkeySettings) + { + try + { + var settings = GetModuleSettings(moduleName); + var accessors = settings.GetAllHotkeyAccessors(); + + var hotkeyAccessor = accessors[hotkeyID]; + + // Use the accessor's setter to update the hotkey settings + hotkeyAccessor.Value = newHotkeySettings; + + if (settings is ISettingsConfig settingsConfig) + { + // No need to save settings here, the runner will call module interface to save it + // SaveSettingsToFile(settings); + + // For PowerToys Run, we should set the 'HotkeyChanged' property here to avoid issue #41468 + if (string.Equals(moduleName, PowerLauncherSettings.ModuleName, StringComparison.OrdinalIgnoreCase)) + { + if (settings is PowerLauncherSettings powerLauncherSettings) + { + powerLauncherSettings.Properties.HotkeyChanged = true; + } + } + + // Send IPC notification using the same format as other ViewModels + SendConfigMSG(settingsConfig, moduleName); + + // Request updated conflicts after changing a hotkey + GlobalHotkeyConflictManager.Instance?.RequestAllConflicts(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error updating hotkey settings for {moduleName}.{hotkeyID}: {ex.Message}"); + } + } + + /// <summary> + /// Sends IPC notification using the same format as other ViewModels + /// </summary> + private void SendConfigMSG(ISettingsConfig settingsConfig, string moduleName) + { + try + { + var jsonTypeInfo = GetJsonTypeInfo(settingsConfig.GetType()); + var serializedSettings = jsonTypeInfo != null + ? JsonSerializer.Serialize(settingsConfig, jsonTypeInfo) + : JsonSerializer.Serialize(settingsConfig); + + string ipcMessage; + if (string.Equals(moduleName, "GeneralSettings", StringComparison.OrdinalIgnoreCase)) + { + ipcMessage = string.Format( + CultureInfo.InvariantCulture, + "{{ \"general\": {0} }}", + serializedSettings); + } + else + { + ipcMessage = string.Format( + CultureInfo.InvariantCulture, + "{{ \"powertoys\": {{ \"{0}\": {1} }} }}", + moduleName, + serializedSettings); + } + + var result = _ipcMSGCallBackFunc(ipcMessage); + System.Diagnostics.Debug.WriteLine($"Sent IPC notification for {moduleName}, result: {result}"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error sending IPC notification for {moduleName}: {ex.Message}"); + } + } + + private JsonTypeInfo GetJsonTypeInfo(Type settingsType) + { + try + { + var contextType = typeof(SourceGenerationContextContext); + var defaultProperty = contextType.GetProperty("Default", BindingFlags.Public | BindingFlags.Static); + var defaultContext = defaultProperty?.GetValue(null) as JsonSerializerContext; + + if (defaultContext != null) + { + var typeInfoProperty = contextType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(p => p.PropertyType.IsGenericType && + p.PropertyType.GetGenericTypeDefinition() == typeof(JsonTypeInfo<>) && + p.PropertyType.GetGenericArguments()[0] == settingsType); + + return typeInfoProperty?.GetValue(defaultContext) as JsonTypeInfo; + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting JsonTypeInfo for {settingsType.Name}: {ex.Message}"); + } + + return null; + } + + private string GetHotkeyLocalizationHeader(string moduleName, int hotkeyID, string headerKey) + { + // Handle AdvancedPaste custom actions + if (string.Equals(moduleName, AdvancedPasteSettings.ModuleName, StringComparison.OrdinalIgnoreCase) + && hotkeyID > 9) + { + return headerKey; + } + + try + { + return resourceLoader.GetString($"{headerKey}/Header"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error getting hotkey header for {moduleName}.{hotkeyID}: {ex.Message}"); + return headerKey; // Return the key itself as fallback + } + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + UnsubscribeFromEvents(); + } + + _disposed = true; + } + + base.Dispose(disposing); + } + + private void UnsubscribeFromEvents() + { + try + { + if (ConflictItems != null) + { + foreach (var conflictGroup in ConflictItems) + { + if (conflictGroup?.Modules != null) + { + foreach (var module in conflictGroup.Modules) + { + if (module != null) + { + module.PropertyChanged -= OnModuleHotkeyDataPropertyChanged; + } + } + } + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error unsubscribing from events: {ex.Message}"); + } + } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/ShortcutGuideViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ShortcutGuideViewModel.cs index 6ae2dd0746..8c91cb4779 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ShortcutGuideViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ShortcutGuideViewModel.cs @@ -3,31 +3,35 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; +using System.Globalization; using System.Runtime.CompilerServices; - +using System.Text.Json; using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class ShortcutGuideViewModel : Observable + public partial class ShortcutGuideViewModel : PageViewModelBase { - private ISettingsUtils SettingsUtils { get; set; } + protected override string ModuleName => ShortcutGuideSettings.ModuleName; + + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } private ShortcutGuideSettings Settings { get; set; } - private const string ModuleName = ShortcutGuideSettings.ModuleName; - private Func<string, int> SendConfigMSG { get; } private string _settingsConfigFileFolder = string.Empty; private string _disabledApps; - public ShortcutGuideViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<ShortcutGuideSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc, string configFileSubfolder = "") + public ShortcutGuideViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<ShortcutGuideSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc, string configFileSubfolder = "") { SettingsUtils = settingsUtils; @@ -79,6 +83,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [OpenShortcutGuide], + }; + + return hotkeysDict; + } + private GpoRuleConfigured _enabledGpoRuleConfiguration; private bool _enabledStateIsGPOConfigured; private bool _isEnabled; diff --git a/src/settings-ui/Settings.UI/ViewModels/SuggestionItem.cs b/src/settings-ui/Settings.UI/ViewModels/SuggestionItem.cs new file mode 100644 index 0000000000..4a200e4879 --- /dev/null +++ b/src/settings-ui/Settings.UI/ViewModels/SuggestionItem.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. + +namespace Microsoft.PowerToys.Settings.UI.ViewModels +{ + public sealed partial class SuggestionItem + { + public string Header { get; init; } + + public string Icon { get; init; } + + public string PageTypeName { get; init; } + + public string ElementName { get; init; } + + public string ParentElementName { get; init; } + + public string Subtitle { get; init; } + + public bool IsShowAll { get; init; } + + public bool IsNoResults { get; init; } + } +} diff --git a/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs index e24b2ce597..64346513f0 100644 --- a/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/WorkspacesViewModel.cs @@ -3,11 +3,12 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; - using global::PowerToys.GPOWrapper; +using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; using Microsoft.PowerToys.Settings.UI.Library.Interfaces; @@ -16,9 +17,11 @@ using Microsoft.PowerToys.Settings.UI.SerializationContext; namespace Microsoft.PowerToys.Settings.UI.ViewModels { - public partial class WorkspacesViewModel : Observable + public partial class WorkspacesViewModel : PageViewModelBase { - private ISettingsUtils SettingsUtils { get; set; } + protected override string ModuleName => WorkspacesSettings.ModuleName; + + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -28,7 +31,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public ButtonClickCommand LaunchEditorEventHandler { get; set; } - public WorkspacesViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<WorkspacesSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc) + public WorkspacesViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<WorkspacesSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc) { ArgumentNullException.ThrowIfNull(settingsUtils); @@ -75,6 +78,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings() + { + var hotkeysDict = new Dictionary<string, HotkeySettings[]> + { + [ModuleName] = [Hotkey], + }; + + return hotkeysDict; + } + public bool IsEnabled { get => _isEnabled; @@ -114,7 +127,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { if (value != _hotkey) { - if (value == null || value.IsEmpty()) + if (value == null) { _hotkey = WorkspacesProperties.DefaultHotkeyValue; } diff --git a/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs index 9619686620..ccadf776ff 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs @@ -8,8 +8,8 @@ using System.Globalization; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text.Json; -using AllExperiments; using global::PowerToys.GPOWrapper; +using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; using Microsoft.PowerToys.Settings.UI.Library.Helpers; @@ -24,7 +24,10 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { public class ZoomItViewModel : Observable { - private ISettingsUtils SettingsUtils { get; set; } + private const string FormatGif = "GIF"; + private const string FormatMp4 = "MP4"; + + private SettingsUtils SettingsUtils { get; set; } private GeneralSettings GeneralSettingsConfig { get; set; } @@ -69,7 +72,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels IncludeFields = true, }; - public ZoomItViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, Func<string, string, string, int, string> pickFileDialog, Func<LOGFONT, LOGFONT> pickFontDialog) + public ZoomItViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, Func<string, int> ipcMSGCallBackFunc, Func<string, string, string, int, string> pickFileDialog, Func<LOGFONT, LOGFONT> pickFontDialog) { ArgumentNullException.ThrowIfNull(settingsUtils); @@ -185,18 +188,32 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels public bool AnimateZoom { - get => _zoomItSettings.Properties.AnimnateZoom.Value; + get => _zoomItSettings.Properties.AnimateZoom.Value; set { - if (_zoomItSettings.Properties.AnimnateZoom.Value != value) + if (_zoomItSettings.Properties.AnimateZoom.Value != value) { - _zoomItSettings.Properties.AnimnateZoom.Value = value; + _zoomItSettings.Properties.AnimateZoom.Value = value; OnPropertyChanged(nameof(AnimateZoom)); NotifySettingsChanged(); } } } + public bool SmoothImage + { + get => _zoomItSettings.Properties.SmoothImage.Value; + set + { + if (_zoomItSettings.Properties.SmoothImage.Value != value) + { + _zoomItSettings.Properties.SmoothImage.Value = value; + OnPropertyChanged(nameof(SmoothImage)); + NotifySettingsChanged(); + } + } + } + public int ZoominSliderLevel { get => _zoomItSettings.Properties.ZoominSliderLevel.Value; @@ -220,11 +237,32 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { _zoomItSettings.Properties.LiveZoomToggleKey.Value = value ?? ZoomItProperties.DefaultLiveZoomToggleKey; OnPropertyChanged(nameof(LiveZoomToggleKey)); + OnPropertyChanged(nameof(LiveZoomToggleKeyDraw)); NotifySettingsChanged(); } } } + public HotkeySettings LiveZoomToggleKeyDraw + { + get + { + var baseKey = _zoomItSettings.Properties.LiveZoomToggleKey.Value; + if (baseKey == null) + { + return null; + } + + // XOR with Shift: if Shift is present, remove it; if absent, add it + return new HotkeySettings( + baseKey.Win, + baseKey.Ctrl, + baseKey.Alt, + !baseKey.Shift, // XOR with Shift + baseKey.Code); + } + } + public HotkeySettings DrawToggleKey { get => _zoomItSettings.Properties.DrawToggleKey.Value; @@ -248,11 +286,53 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { _zoomItSettings.Properties.RecordToggleKey.Value = value ?? ZoomItProperties.DefaultRecordToggleKey; OnPropertyChanged(nameof(RecordToggleKey)); + OnPropertyChanged(nameof(RecordToggleKeyCrop)); + OnPropertyChanged(nameof(RecordToggleKeyWindow)); NotifySettingsChanged(); } } } + public HotkeySettings RecordToggleKeyCrop + { + get + { + var baseKey = _zoomItSettings.Properties.RecordToggleKey.Value; + if (baseKey == null) + { + return null; + } + + // XOR with Shift: if Shift is present, remove it; if absent, add it + return new HotkeySettings( + baseKey.Win, + baseKey.Ctrl, + baseKey.Alt, + !baseKey.Shift, // XOR with Shift + baseKey.Code); + } + } + + public HotkeySettings RecordToggleKeyWindow + { + get + { + var baseKey = _zoomItSettings.Properties.RecordToggleKey.Value; + if (baseKey == null) + { + return null; + } + + // XOR with Alt: if Alt is present, remove it; if absent, add it + return new HotkeySettings( + baseKey.Win, + baseKey.Ctrl, + !baseKey.Alt, // XOR with Alt + baseKey.Shift, + baseKey.Code); + } + } + public HotkeySettings SnipToggleKey { get => _zoomItSettings.Properties.SnipToggleKey.Value; @@ -262,11 +342,31 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { _zoomItSettings.Properties.SnipToggleKey.Value = value ?? ZoomItProperties.DefaultSnipToggleKey; OnPropertyChanged(nameof(SnipToggleKey)); + OnPropertyChanged(nameof(SnipToggleKeySave)); NotifySettingsChanged(); } } } + public HotkeySettings SnipToggleKeySave + { + get + { + var baseKey = _zoomItSettings.Properties.SnipToggleKey.Value; + if (baseKey == null) + { + return null; + } + + return new HotkeySettings( + baseKey.Win, + baseKey.Ctrl, + baseKey.Alt, + !baseKey.Shift, // Toggle Shift: if Shift is present, remove it; if absent, add it + baseKey.Code); + } + } + public HotkeySettings BreakTimerKey { get => _zoomItSettings.Properties.BreakTimerKey.Value; @@ -290,11 +390,32 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { _zoomItSettings.Properties.DemoTypeToggleKey.Value = value ?? ZoomItProperties.DefaultDemoTypeToggleKey; OnPropertyChanged(nameof(DemoTypeToggleKey)); + OnPropertyChanged(nameof(DemoTypeToggleKeyReset)); NotifySettingsChanged(); } } } + public HotkeySettings DemoTypeToggleKeyReset + { + get + { + var baseKey = _zoomItSettings.Properties.DemoTypeToggleKey.Value; + if (baseKey == null) + { + return null; + } + + // XOR with Shift: if Shift is present, remove it; if absent, add it + return new HotkeySettings( + baseKey.Win, + baseKey.Ctrl, + baseKey.Alt, + !baseKey.Shift, // XOR with Shift + baseKey.Code); + } + } + private LOGFONT _typeFont; public LOGFONT TypeFont @@ -529,20 +650,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - public int BreakTimerOpacityIndex + public double BreakTimerOpacity { get { - return Math.Clamp((_zoomItSettings.Properties.BreakOpacity.Value / 10) - 1, 0, 9); + return Math.Clamp(_zoomItSettings.Properties.BreakOpacity.Value, 1, 100); } set { - int newValue = (value + 1) * 10; - if (_zoomItSettings.Properties.BreakOpacity.Value != newValue) + int intValue = (int)value; + if (_zoomItSettings.Properties.BreakOpacity.Value != intValue) { - _zoomItSettings.Properties.BreakOpacity.Value = newValue; - OnPropertyChanged(nameof(BreakTimerOpacityIndex)); + _zoomItSettings.Properties.BreakOpacity.Value = intValue; + OnPropertyChanged(nameof(BreakTimerOpacity)); NotifySettingsChanged(); } } @@ -571,26 +692,69 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels { _zoomItSettings.Properties.BreakShowBackgroundFile.Value = value; OnPropertyChanged(nameof(BreakShowBackgroundFile)); + OnPropertyChanged(nameof(BreakBackgroundSelectionIndex)); NotifySettingsChanged(); } } } - public int BreakShowDesktopOrImageFileIndex + public bool BreakShowDesktop { - get => _zoomItSettings.Properties.BreakShowDesktop.Value ? 0 : 1; + get => _zoomItSettings.Properties.BreakShowDesktop.Value; set { - bool newValue = value == 0; - if (_zoomItSettings.Properties.BreakShowDesktop.Value != newValue) + if (_zoomItSettings.Properties.BreakShowDesktop.Value != value) { - _zoomItSettings.Properties.BreakShowDesktop.Value = newValue; - OnPropertyChanged(nameof(BreakShowDesktopOrImageFileIndex)); + _zoomItSettings.Properties.BreakShowDesktop.Value = value; + OnPropertyChanged(nameof(BreakShowDesktop)); + OnPropertyChanged(nameof(BreakBackgroundSelectionIndex)); NotifySettingsChanged(); } } } + public int BreakBackgroundSelectionIndex + { + get + { + if (!BreakShowBackgroundFile) + { + return 0; + } + + return BreakShowDesktop ? 1 : 2; + } + + set + { + int clampedValue = Math.Clamp(value, 0, 2); + switch (clampedValue) + { + case 0: + BreakShowBackgroundFile = false; + break; + case 1: + if (!BreakShowBackgroundFile) + { + BreakShowBackgroundFile = true; + } + + BreakShowDesktop = true; + break; + case 2: + if (!BreakShowBackgroundFile) + { + BreakShowBackgroundFile = true; + } + + BreakShowDesktop = false; + break; + default: + break; + } + } + } + public string BreakBackgroundFile { get => _zoomItSettings.Properties.BreakBackgroundFile.Value; @@ -619,20 +783,82 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } - public int RecordScalingIndex + public double RecordScaling { get { - return Math.Clamp((_zoomItSettings.Properties.RecordScaling.Value / 10) - 1, 0, 9); + return Math.Clamp(_zoomItSettings.Properties.RecordScaling.Value / 100.0, 0.1, 1.0); } set { - int newValue = (value + 1) * 10; + int newValue = (int)(value * 100); if (_zoomItSettings.Properties.RecordScaling.Value != newValue) { _zoomItSettings.Properties.RecordScaling.Value = newValue; - OnPropertyChanged(nameof(RecordScalingIndex)); + OnPropertyChanged(nameof(RecordScaling)); + NotifySettingsChanged(); + } + } + } + + public int RecordFormatIndex + { + get + { + if (_zoomItSettings.Properties.RecordFormat.Value == FormatGif) + { + return 0; + } + + if (_zoomItSettings.Properties.RecordFormat.Value == FormatMp4) + { + return 1; + } + + return 0; + } + + set + { + int format = 0; + if (_zoomItSettings.Properties.RecordFormat.Value == FormatGif) + { + format = 0; + } + + if (_zoomItSettings.Properties.RecordFormat.Value == FormatMp4) + { + format = 1; + } + + if (format != value) + { + _zoomItSettings.Properties.RecordFormat.Value = value == 0 ? FormatGif : FormatMp4; + OnPropertyChanged(nameof(RecordFormatIndex)); + NotifySettingsChanged(); + + // Reload settings to get the new format's scaling value + var reloadedSettings = global::PowerToys.ZoomItSettingsInterop.ZoomItSettings.LoadSettingsJson(); + var reloaded = JsonSerializer.Deserialize<ZoomItSettings>(reloadedSettings, _serializerOptions); + if (reloaded != null && reloaded.Properties != null) + { + _zoomItSettings.Properties.RecordScaling.Value = reloaded.Properties.RecordScaling.Value; + OnPropertyChanged(nameof(RecordScaling)); + } + } + } + } + + public bool RecordCaptureSystemAudio + { + get => _zoomItSettings.Properties.CaptureSystemAudio.Value; + set + { + if (_zoomItSettings.Properties.CaptureSystemAudio.Value != value) + { + _zoomItSettings.Properties.CaptureSystemAudio.Value = value; + OnPropertyChanged(nameof(RecordCaptureSystemAudio)); NotifySettingsChanged(); } } diff --git a/src/settings-ui/Settings.UI/app.manifest b/src/settings-ui/Settings.UI/app.manifest index 9742e0b540..c5043e485d 100644 --- a/src/settings-ui/Settings.UI/app.manifest +++ b/src/settings-ui/Settings.UI/app.manifest @@ -9,7 +9,7 @@ 2) System < Windows 10 Anniversary Update --> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> - <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application> <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> diff --git a/src/settings-ui/UITest-Settings/OOBEUITests.cs b/src/settings-ui/UITest-Settings/OOBEUITests.cs new file mode 100644 index 0000000000..5ec97f7afb --- /dev/null +++ b/src/settings-ui/UITest-Settings/OOBEUITests.cs @@ -0,0 +1,236 @@ +// 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; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Settings.UITests +{ + [TestClass] + public class OOBEUITests : UITestBase + { + // Constants for file paths and identifiers + private const string LocalAppDataFolderPath = "%localappdata%\\Microsoft\\PowerToys"; + private const string LastVersionFilePath = "%localappdata%\\Microsoft\\PowerToys\\last_version.txt"; + + public OOBEUITests() + : base(PowerToysModule.PowerToysSettings) + { + } + + [TestMethod("OOBE.Basic.FirstStartTest")] + [TestCategory("OOBE test #1")] + public void TestOOBEFirstStart() + { + // Clean up previous PowerToys data to simulate first start + // CleanPowerToysData(); + + // Start PowerToys and verify OOBE opens + // StartPowerToysAndVerifyOOBEOpens(); + + // Navigate through all OOBE sections + NavigateThroughOOBESections(); + + // Close OOBE + CloseOOBE(); + + // Verify OOBE can be opened from Settings + // OpenOOBEFromSettings(); + } + + /* + + [TestMethod("OOBE.WhatsNew.Test")] + [TestCategory("OOBE test #2")] + public void TestOOBEWhatsNew() + { + // Modify version file to trigger What's New + ModifyLastVersionFile(); + + // Start PowerToys and verify OOBE opens in What's New page + StartPowerToysAndVerifyWhatsNewOpens(); + + // Close OOBE + CloseOOBE(); + } + */ + + private void CleanPowerToysData() + { + this.ExitScopeExe(); + + // Exit PowerToys if it's running + try + { + foreach (Process process in Process.GetProcessesByName("PowerToys")) + { + process.Kill(); + process.WaitForExit(); + } + + // Delete PowerToys folder in LocalAppData + string powerToysFolder = Environment.ExpandEnvironmentVariables(LocalAppDataFolderPath); + if (Directory.Exists(powerToysFolder)) + { + Directory.Delete(powerToysFolder, true); + } + + // Wait to ensure deletion is complete + Task.Delay(1000).Wait(); + } + catch (Exception ex) + { + Assert.Inconclusive($"Could not clean PowerToys data: {ex.Message}"); + } + } + + private void StartPowerToysAndVerifyOOBEOpens() + { + try + { + // Start PowerToys + this.RestartScopeExe(); + + // Wait for OOBE window to appear + Task.Delay(5000).Wait(); + + // Verify OOBE window opened + Assert.IsTrue(this.Session.HasOne("Welcome to PowerToys"), "OOBE window should open with 'Welcome to PowerToys' title"); + + // Verify we're on the Overview page + Assert.IsTrue(this.Has("Overview"), "OOBE should start on Overview page"); + } + catch (Exception ex) + { + Assert.Fail($"Failed to start PowerToys and verify OOBE: {ex.Message}"); + } + } + + private void NavigateThroughOOBESections() + { + // List of modules to test + string[] modules = new string[] + { + "What's new", + "Advanced Paste", + }; + + this.Find<NavigationViewItem>("Welcome to PowerToys").Click(); + + foreach (string module in modules) + { + TestModule(module); + } + } + + private void TestModule(string moduleName) + { + var oobeWindow = this.Find<Window>("Welcome to PowerToys"); + Assert.IsNotNull(oobeWindow); + + /* + - [] open the Settings for that module + - [] verify the Settings work as expected (toggle some controls on/off etc.) + - [] close the Settings + - [] if it's available, test the `Launch module name` button + */ + oobeWindow.Find<Button>(By.Name("Open Settings")).Click(); + + // Find<NavigationViewItem>("What's new").Click(); + Task.Delay(1000).Wait(); + } + + private void CloseOOBE() + { + try + { + // Find the close button and click it + this.Session.CloseMainWindow(); + Task.Delay(1000).Wait(); + } + catch (Exception ex) + { + Assert.Fail($"Failed to close OOBE: {ex.Message}"); + } + } + + private void OpenOOBEFromSettings() + { + try + { + // Open PowerToys Settings + this.Session.Attach(PowerToysModule.PowerToysSettings); + + // Navigate to General page + this.Find<NavigationViewItem>("General").Click(); + Task.Delay(1000).Wait(); + + // Click on "Welcome to PowerToys" link + this.Find<HyperlinkButton>("Welcome to PowerToys").Click(); + Task.Delay(2000).Wait(); + + // Verify OOBE opened + Assert.IsTrue(this.Session.HasOne("Welcome to PowerToys"), "OOBE should open when clicking the link in Settings"); + + // Close OOBE + this.Session.CloseMainWindow(); + } + catch (Exception ex) + { + Assert.Fail($"Failed to open OOBE from Settings: {ex.Message}"); + } + } + + private void ModifyLastVersionFile() + { + try + { + // Create PowerToys folder if it doesn't exist + string powerToysFolder = Environment.ExpandEnvironmentVariables(LocalAppDataFolderPath); + if (!Directory.Exists(powerToysFolder)) + { + Directory.CreateDirectory(powerToysFolder); + } + + // Write a different version to trigger What's New + string versionFilePath = Environment.ExpandEnvironmentVariables(LastVersionFilePath); + File.WriteAllText(versionFilePath, "0.0.1"); + + // Wait to ensure file is written + Task.Delay(1000).Wait(); + } + catch (Exception ex) + { + Assert.Inconclusive($"Could not modify version file: {ex.Message}"); + } + } + + private void StartPowerToysAndVerifyWhatsNewOpens() + { + try + { + // Start PowerToys + this.RestartScopeExe(); + + // Wait for OOBE window to appear + Task.Delay(5000).Wait(); + + // Verify OOBE window opened + Assert.IsTrue(this.Session.HasOne("Welcome to PowerToys"), "OOBE window should open"); + + // Verify we're on the What's New page + Assert.IsTrue(this.Has("What's new"), "OOBE should open on What's New page after version change"); + } + catch (Exception ex) + { + Assert.Fail($"Failed to verify What's New page: {ex.Message}"); + } + } + } +} diff --git a/src/settings-ui/UITest-Settings/SettingsTests.cs b/src/settings-ui/UITest-Settings/SettingsTests.cs new file mode 100644 index 0000000000..3c93d77ec1 --- /dev/null +++ b/src/settings-ui/UITest-Settings/SettingsTests.cs @@ -0,0 +1,149 @@ +// 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.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.PowerToys.UITest; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Windows.Devices.PointOfService.Provider; + +namespace Microsoft.Settings.UITests +{ + [TestClass] + public class SettingsTests : UITestBase + { + private readonly string[] dashboardModuleList = + { + "Advanced Paste", + "Always On Top", + "Awake", + "Color Picker", + "Command Palette", + "Environment Variables", + "FancyZones", + "File Locksmith", + "Find My Mouse", + "Hosts File Editor", + "Image Resizer", + "Keyboard Manager", + "Mouse Highlighter", + "Mouse Jump", + "Mouse Pointer Crosshairs", + "Mouse Without Borders", + "New+", + "Peek", + "PowerRename", + "PowerToys Run", + "Quick Accent", + "Registry Preview", + "Screen Ruler", + "Shortcut Guide", + "Text Extractor", + "Workspaces", + "ZoomIt", + + // "Crop And Lock", // this module cannot be found, why? + }; + + private readonly string[] moduleProcess = + { + "PowerToys.AdvancedPaste", + "PowerToys.Run", + "PowerToys.AlwaysOnTop", + "PowerToys.Awake", + "PowerToys.ColorPickerUI", + "PowerToys.Peek.UI", + }; + + public SettingsTests() + : base(PowerToysModule.PowerToysSettings, size: WindowSize.Large) + { + } + + [TestMethod("PowerToys.Settings.ModulesOnAndOffTest")] + [TestCategory("Settings Test #1")] + public void TestAllmoduleOnAndOff() + { + DisableAllModules(); + Task.Delay(2000).Wait(); + + // module process won't be killed in debug mode settings UI! + // Assert.IsTrue(CheckModulesDisabled(), "Some modules are not disabled."); + EnableAllModules(); + Task.Delay(2000).Wait(); + + // Assert.IsTrue(CheckModulesEnabled(), "Some modules are not Enabled."); + } + + private void DisableAllModules() + { + Find<NavigationViewItem>("Dashboard").Click(); + + foreach (var moduleName in dashboardModuleList) + { + var moduleButton = Find<Button>(moduleName); + Assert.IsNotNull(moduleButton); + var toggle = moduleButton.Find<ToggleSwitch>("Enable module"); + Assert.IsNotNull(toggle); + if (toggle.IsOn) + { + toggle.Click(); + } + } + } + + private void EnableAllModules() + { + Find<NavigationViewItem>("Dashboard").Click(); + + foreach (var moduleName in dashboardModuleList) + { + // Scroll(direction: "Down"); + var moduleButton = Find<Button>(moduleName); + Assert.IsNotNull(moduleButton); + var toggle = moduleButton.Find<ToggleSwitch>("Enable module"); + Assert.IsNotNull(toggle); + if (!toggle.IsOn) + { + toggle.Click(); + } + } + } + + private bool CheckModulesDisabled() + { + Process[] runningProcesses = Process.GetProcesses(); + + foreach (var process in moduleProcess) + { + if (runningProcesses.Any(p => p.ProcessName.Equals(process, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + } + + return true; + } + + private bool CheckModulesEnabled() + { + Process[] runningProcesses = Process.GetProcesses(); + + foreach (var process in moduleProcess) + { + if (!runningProcesses.Any(p => p.ProcessName.Equals(process, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/settings-ui/UITest-Settings/SettingsTests.md b/src/settings-ui/UITest-Settings/SettingsTests.md new file mode 100644 index 0000000000..25d65de0eb --- /dev/null +++ b/src/settings-ui/UITest-Settings/SettingsTests.md @@ -0,0 +1,41 @@ +## [General Settings](tests-checklist-template-settings-section.md) + +**Admin mode:** + - [] restart PT and verify it runs as user + - [] restart as admin and set "Always run as admin" + - [] restart PT and verify it runs as admin + * if it's not on, turn on "Run at startup" + - [] reboot the machine and verify PT runs as admin (it should not prompt the UAC dialog) + * turn Always run as admin" off + - [] reboot the machine and verify it now runs as user + +**Modules on/off:** + - [x] turn off all the modules and verify all module are off + - [] restart PT and verify that all module are still off in the settings page and they are actually inactive + - [x] turn on all the module, all module are now working + - [] restart PT and verify that all module are still on in the settings page and they are actually working + +**Quick access tray icon flyout:** + - [] Use left click on the system tray icon and verify the flyout appears. + - [] Try to launch a module from the launch screen in the flyout. + - [] Try disabling a module in the all apps screen in the flyout, make it a module that's launchable from the launch screen. Verify that the module is disabled and that it also disappeared from the launch screen in the flyout. + - [] Open the main settings screen on a module page. Verify that when you disable/enable the module on the flyout, that the Settings page is updated too. + +**Settings backup/restore:** + - [] In the General tab, create a backup of the settings. + - [] Change some settings in some PowerToys. + - [] Restore the settings in the General tab and verify the Settings you've applied were reset. + +## OOBE + * Quit PowerToys + * Delete %localappdata%\Microsoft\PowerToys + - [] Start PowerToys and verify OOBE opens + * Change version saved on `%localappdata%\Microsoft\PowerToys\last_version.txt` + - [] Start PowerToys and verify OOBE opens in the "What's New" page + * Visit each OOBE section and for each section: + - [] open the Settings for that module + - [] verify the Settings work as expected (toggle some controls on/off etc.) + - [] close the Settings + - [] if it's available, test the `Launch module name` button + * Close OOBE + - [x] Open the Settings and from the General page open OOBE using the `Welcome to PowerToys` link diff --git a/src/settings-ui/UITest-Settings/UITest-Settings.csproj b/src/settings-ui/UITest-Settings/UITest-Settings.csproj new file mode 100644 index 0000000000..917efdf1a3 --- /dev/null +++ b/src/settings-ui/UITest-Settings/UITest-Settings.csproj @@ -0,0 +1,32 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <!-- Look at Directory.Build.props in root for common stuff as well --> + <Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" /> + + <PropertyGroup> + <ProjectGuid>{29B91A80-0590-4B1F-89B8-4F8812A7F116}</ProjectGuid> + <RootNamespace>Microsoft.Settings.UITests</RootNamespace> + <IsPackable>false</IsPackable> + <Nullable>enable</Nullable> + <OutputType>Library</OutputType> + + <!-- This is a UI test, so don't run as part of MSBuild --> + <RunVSTest>false</RunVSTest> + </PropertyGroup> + + <PropertyGroup> + <OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\UITests-Settings\</OutputPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Appium.WebDriver" /> + <PackageReference Include="MSTest" /> + <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> + <PackageReference Include="System.Text.RegularExpressions" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\common\UITestAutomation\UITestAutomation.csproj" /> + </ItemGroup> + +</Project> \ No newline at end of file diff --git a/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj b/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj index 734146a663..be9a9bb279 100644 --- a/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj +++ b/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj @@ -1,6 +1,7 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>16.0</VCProjectVersion> <Keyword>Win32Proj</Keyword> @@ -8,15 +9,14 @@ <RootNamespace>BugReportTool</RootNamespace> <ProjectName>BugReportTool</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> <PropertyGroup Label="Configuration"> - <PlatformToolset>v143</PlatformToolset> + </PropertyGroup> <PropertyGroup> <ConfigurationType>Application</ConfigurationType> <TargetName>PowerToys.$(ProjectName)</TargetName> - <OutDir>$(SolutionDir)..\..\$(Platform)\$(Configuration)\Tools\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\Tools\</OutDir> </PropertyGroup> <ImportGroup Label="ExtensionSettings"> </ImportGroup> @@ -30,6 +30,7 @@ <ClCompile> <PrecompiledHeader>NotUsing</PrecompiledHeader> <AdditionalIncludeDirectories>../../../src/</AdditionalIncludeDirectories> + <TreatAngleIncludeAsExternal>true</TreatAngleIncludeAsExternal> </ClCompile> <Link> <SubSystem>Console</SubSystem> @@ -37,10 +38,6 @@ </Link> </ItemDefinitionGroup> <ItemGroup> - <ClCompile Include="..\..\..\deps\cziplib\src\zip.c"> - <!-- Disabling warnings for external code --> - <DisableSpecificWarnings>4706;26451;4267;4244;%(DisableSpecificWarnings)</DisableSpecificWarnings> - </ClCompile> <ClCompile Include="EventViewer.cpp" /> <ClCompile Include="InstallationFolder.cpp" /> <ClCompile Include="Package.cpp" /> @@ -61,14 +58,12 @@ </ProjectReference> </ItemGroup> <ItemGroup> - <ClInclude Include="..\..\..\deps\cziplib\src\miniz.h" /> - <ClInclude Include="..\..\..\deps\cziplib\src\zip.h" /> <ClInclude Include="EventViewer.h" /> <ClInclude Include="InstallationFolder.h" /> <ClInclude Include="Package.h" /> <ClInclude Include="ReportGPOValues.h" /> <ClInclude Include="ReportMonitorInfo.h" /> - <ClInclude Include="..\..\..\common\utils\json.h" /> + <ClInclude Include="$(RepoRoot)src\common\utils\json.h" /> <ClInclude Include="RegistryUtils.h" /> <ClInclude Include="resource.h" /> <ClInclude Include="XmlDocumentEx.h" /> @@ -80,14 +75,14 @@ <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> <Import Project="..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" /> - <Import Project="..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> <Error Condition="!Exists('..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" /> - <Error Condition="!Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> -</Project> \ No newline at end of file +</Project> diff --git a/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj.filters b/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj.filters index 0723bd31ac..f8117733d9 100644 --- a/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj.filters +++ b/tools/BugReportTool/BugReportTool/BugReportTool.vcxproj.filters @@ -8,7 +8,6 @@ <ClCompile Include="ZipTools\ZipFolder.cpp"> <Filter>ZipTools</Filter> </ClCompile> - <ClCompile Include="..\..\..\deps\cziplib\src\zip.c" /> <ClCompile Include="ReportMonitorInfo.cpp" /> <ClCompile Include="RegistryUtils.cpp" /> <ClCompile Include="EventViewer.cpp" /> @@ -28,8 +27,6 @@ <Filter>ZipTools</Filter> </ClInclude> <ClInclude Include="..\..\..\common\utils\json.h" /> - <ClInclude Include="..\..\..\deps\cziplib\src\miniz.h" /> - <ClInclude Include="..\..\..\deps\cziplib\src\zip.h" /> <ClInclude Include="ReportMonitorInfo.h" /> <ClInclude Include="RegistryUtils.h" /> <ClInclude Include="EventViewer.h" /> diff --git a/tools/BugReportTool/BugReportTool/EventViewer.cpp b/tools/BugReportTool/BugReportTool/EventViewer.cpp index f6e9a6baf0..549ceb90b4 100644 --- a/tools/BugReportTool/BugReportTool/EventViewer.cpp +++ b/tools/BugReportTool/BugReportTool/EventViewer.cpp @@ -23,7 +23,7 @@ namespace // Report last 30 days const long long PERIOD = 10 * 24 * 3600ll * 1000; - const std::wstring QUERY = L"<QueryList>" \ + const std::wstring QUERY_BY_PROCESS = L"<QueryList>" \ L" <Query Id='0'>" \ L" <Select Path='Application'>" \ L" *[System[TimeCreated[timediff(@SystemTime)<%I64u]]] " \ @@ -32,16 +32,46 @@ namespace L" </Query>" \ L"</QueryList>"; + const std::wstring QUERY_BY_CHANNEL = L"<QueryList>" \ + L" <Query Id='0'>" \ + L" <Select Path='%s'>" \ + L" *[System[TimeCreated[timediff(@SystemTime)<%I64u]]]" \ + L" </Select>" \ + L" </Query>" \ + L"</QueryList>"; + + std::wstring GetQuery(std::wstring processName) { wchar_t buff[1000]; memset(buff, 0, sizeof(buff)); - _snwprintf_s(buff, sizeof(buff), QUERY.c_str(), PERIOD, processName.c_str()); + _snwprintf_s(buff, sizeof(buff), QUERY_BY_PROCESS.c_str(), PERIOD, processName.c_str()); + return buff; + } + + std::wstring GetQueryByChannel(std::wstring channelName) + { + wchar_t buff[1000]; + memset(buff, 0, sizeof(buff)); + _snwprintf_s(buff, sizeof(buff), QUERY_BY_CHANNEL.c_str(), channelName.c_str(), PERIOD); return buff; } std::wofstream report; EVT_HANDLE hResults; + bool isChannel; + + bool ShouldIncludeEvent(const std::wstring& eventXml) + { + if (!isChannel) + { + return true; // Include all events if no filtering + } + + // Check if the event contains PowerToys or CommandPalette + return (eventXml.find(L"PowerToys") != std::wstring::npos || + eventXml.find(L"CommandPalette") != std::wstring::npos); + } void PrintEvent(EVT_HANDLE hEvent) { @@ -75,6 +105,17 @@ namespace } } + // Apply filtering if needed + std::wstring eventContent(pRenderedContent); + if (!ShouldIncludeEvent(eventContent)) + { + if (pRenderedContent) + { + free(pRenderedContent); + } + return; // Skip this event + } + XmlDocumentEx doc; doc.LoadXml(pRenderedContent); std::wstring formattedXml = L""; @@ -130,17 +171,27 @@ namespace } public: - EventViewerReporter(const std::filesystem::path& tmpDir, std::wstring processName) + EventViewerReporter(const std::filesystem::path& tmpDir, std::wstring queryName, std::wstring fileName, bool isChannel) + :isChannel(isChannel) { - auto query = GetQuery(processName); + std::wstring query = L""; + if (isChannel) + { + query = GetQueryByChannel(queryName); + } + else + { + query = GetQuery(queryName); + } + auto reportPath = tmpDir; - reportPath.append(L"EventViewer-" + processName + L".xml"); + reportPath.append(L"EventViewer-" + fileName + L".xml"); report = std::wofstream(reportPath); - hResults = EvtQuery(NULL, NULL, GetQuery(processName).c_str(), EvtQueryChannelPath); + hResults = EvtQuery(NULL, NULL, query.c_str(), EvtQueryChannelPath); if (NULL == hResults) { - report << "Failed to report info for " << processName << ". " << get_last_error_or_default(GetLastError()) << std::endl; + report << "Failed to report info for " << queryName << ". " << get_last_error_or_default(GetLastError()) << std::endl; return; } } @@ -175,6 +226,11 @@ void EventViewer::ReportEventViewerInfo(const std::filesystem::path& tmpDir) { for (auto& process : processes) { - EventViewerReporter(tmpDir, process).Report(); + EventViewerReporter(tmpDir, process, process, false).Report(); } } + +void EventViewer::ReportAppXDeploymentLogs(const std::filesystem::path& tmpDir) +{ + EventViewerReporter(tmpDir, L"Microsoft-Windows-AppXDeploymentServer/Operational", L"AppXDeploymentServerEventLog", true).Report(); +} diff --git a/tools/BugReportTool/BugReportTool/EventViewer.h b/tools/BugReportTool/BugReportTool/EventViewer.h index 0d50f4253e..94176b4217 100644 --- a/tools/BugReportTool/BugReportTool/EventViewer.h +++ b/tools/BugReportTool/BugReportTool/EventViewer.h @@ -4,4 +4,5 @@ namespace EventViewer { void ReportEventViewerInfo(const std::filesystem::path& tmpDir); + void ReportAppXDeploymentLogs(const std::filesystem::path& tmpDir); } diff --git a/tools/BugReportTool/BugReportTool/Main.cpp b/tools/BugReportTool/BugReportTool/Main.cpp index a653c25d57..7716512125 100644 --- a/tools/BugReportTool/BugReportTool/Main.cpp +++ b/tools/BugReportTool/BugReportTool/Main.cpp @@ -381,6 +381,9 @@ int wmain(int argc, wchar_t* argv[], wchar_t*) // Write event viewer logs info to the temporary folder EventViewer::ReportEventViewerInfo(reportDir); + // Write AppXDeployment-Server event logs to the temporary folder + EventViewer::ReportAppXDeploymentLogs(reportDir); + ReportInstallerLogs(tempDir, reportDir); ReportInstalledContextMenuPackages(reportDir); diff --git a/tools/BugReportTool/BugReportTool/ProcessesList.cpp b/tools/BugReportTool/BugReportTool/ProcessesList.cpp index 9665938e7e..eb4a976c9b 100644 --- a/tools/BugReportTool/BugReportTool/ProcessesList.cpp +++ b/tools/BugReportTool/BugReportTool/ProcessesList.cpp @@ -11,6 +11,7 @@ std::vector<std::wstring> processes = L"PowerToys.FancyZonesEditor.exe", L"PowerToys.FancyZones.exe", L"PowerToys.FileLocksmithUI.exe", + L"PowerToys.LightSwitch.exe", L"PowerToys.KeyboardManagerEngine.exe", L"PowerToys.KeyboardManagerEditor.exe", L"PowerToys.PowerAccent.exe", @@ -27,6 +28,8 @@ std::vector<std::wstring> processes = L"PowerToys.Hosts.exe", L"PowerToys.GcodePreviewHandler.exe", L"PowerToys.GcodeThumbnailProvider.exe", + L"PowerToys.BgcodePreviewHandler.exe", + L"PowerToys.BgcodeThumbnailProvider.exe", L"PowerToys.MarkdownPreviewHandler.exe", L"PowerToys.MonacoPreviewHandler.exe", L"PowerToys.PdfPreviewHandler.exe", @@ -49,4 +52,5 @@ std::vector<std::wstring> processes = L"PowerToys.WorkspacesWindowArranger.exe", L"PowerToys.WorkspacesEditor.exe", L"PowerToys.ZoomIt.exe", + L"Microsoft.CmdPal.UI.exe", }; diff --git a/tools/BugReportTool/BugReportTool/RegistryUtils.cpp b/tools/BugReportTool/BugReportTool/RegistryUtils.cpp index 5fed9f4fa6..10913a55bd 100644 --- a/tools/BugReportTool/BugReportTool/RegistryUtils.cpp +++ b/tools/BugReportTool/BugReportTool/RegistryUtils.cpp @@ -17,9 +17,11 @@ namespace { HKEY_CLASSES_ROOT, L"AppID\\{CF142243-F059-45AF-8842-DBBE9783DB14}" }, { HKEY_CLASSES_ROOT, L"CLSID\\{07665729-6243-4746-95b7-79579308d1b2}" }, { HKEY_CLASSES_ROOT, L"CLSID\\{ec52dea8-7c9f-4130-a77b-1737d0418507}" }, + { HKEY_CLASSES_ROOT, L"CLSID\\{dd8de316-7b01-48e7-ba21-e92c646704af}" }, { HKEY_CLASSES_ROOT, L"CLSID\\{8AA07897-C30B-4543-865B-00A0E5A1B32D}" }, { HKEY_CLASSES_ROOT, L"CLSID\\{BCC13D15-9720-4CC4-8371-EA74A274741E}" }, { HKEY_CLASSES_ROOT, L"CLSID\\{BFEE99B4-B74D-4348-BCA5-E757029647FF}" }, + { HKEY_CLASSES_ROOT, L"CLSID\\{c28761a0-8420-43ad-bff3-40400543e2d4}" }, { HKEY_CLASSES_ROOT, L"CLSID\\{8BC8AFC2-4E7C-4695-818E-8C1FFDCEA2AF}" }, { HKEY_CLASSES_ROOT, L"CLSID\\{51B4D7E5-7568-4234-B4BB-47FB3C016A69}\\InprocServer32" }, { HKEY_CLASSES_ROOT, L"CLSID\\{0440049F-D1DC-4E46-B27B-98393D79486B}" }, @@ -33,6 +35,8 @@ namespace { HKEY_CLASSES_ROOT, L".qoi\\shellex\\{E357FCCD-A995-4576-B01F-234630154E96}" }, { HKEY_CLASSES_ROOT, L".gcode\\shellex\\{8895b1c6-b41f-4c1c-a562-0d564250836f}" }, { HKEY_CLASSES_ROOT, L".gcode\\shellex\\{E357FCCD-A995-4576-B01F-234630154E96}" }, + { HKEY_CLASSES_ROOT, L".bgcode\\shellex\\{8895b1c6-b41f-4c1c-a562-0d564250836f}" }, + { HKEY_CLASSES_ROOT, L".bgcode\\shellex\\{E357FCCD-A995-4576-B01F-234630154E96}" }, { HKEY_CLASSES_ROOT, L".stl\\shellex\\{E357FCCD-A995-4576-B01F-234630154E96}" } }; @@ -41,6 +45,7 @@ namespace { HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Windows\\CurrentVersion\\PreviewHandlers", L"{45769bcc-e8fd-42d0-947e-02beef77a1f5}" }, { HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Windows\\CurrentVersion\\PreviewHandlers", L"{07665729-6243-4746-95b7-79579308d1b2}" }, { HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Windows\\CurrentVersion\\PreviewHandlers", L"{ec52dea8-7c9f-4130-a77b-1737d0418507}" }, + { HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Windows\\CurrentVersion\\PreviewHandlers", L"{dd8de316-7b01-48e7-ba21-e92c646704af}" }, { HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Windows\\CurrentVersion\\PreviewHandlers", L"{8AA07897-C30B-4543-865B-00A0E5A1B32D}" }, { HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Internet Explorer\\Main\\FeatureControl\\FEATURE_BROWSER_EMULATION", L"prevhost.exe" }, { HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Internet Explorer\\Main\\FeatureControl\\FEATURE_BROWSER_EMULATION", L"dllhost.exe" } diff --git a/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp b/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp index 02e04a2c5e..edd48839d2 100644 --- a/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp +++ b/tools/BugReportTool/BugReportTool/ReportGPOValues.cpp @@ -50,14 +50,17 @@ void ReportGPOValues(const std::filesystem::path &tmpDir) report << "getConfiguredCropAndLockEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredCropAndLockEnabledValue()) << std::endl; report << "getConfiguredFancyZonesEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredFancyZonesEnabledValue()) << std::endl; report << "getConfiguredFileLocksmithEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredFileLocksmithEnabledValue()) << std::endl; + report << "getConfiguredLightSwitchEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredLightSwitchEnabledValue()) << std::endl; report << "getConfiguredSvgPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredSvgPreviewEnabledValue()) << std::endl; report << "getConfiguredMarkdownPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMarkdownPreviewEnabledValue()) << std::endl; report << "getConfiguredMonacoPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMonacoPreviewEnabledValue()) << std::endl; report << "getConfiguredPdfPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredPdfPreviewEnabledValue()) << std::endl; report << "getConfiguredGcodePreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredGcodePreviewEnabledValue()) << std::endl; + report << "getConfiguredBgcodePreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredBgcodePreviewEnabledValue()) << std::endl; report << "getConfiguredSvgThumbnailsEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredSvgThumbnailsEnabledValue()) << std::endl; report << "getConfiguredPdfThumbnailsEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredPdfThumbnailsEnabledValue()) << std::endl; report << "getConfiguredGcodeThumbnailsEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredGcodeThumbnailsEnabledValue()) << std::endl; + report << "getConfiguredBgcodeThumbnailsEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredBgcodeThumbnailsEnabledValue()) << std::endl; report << "getConfiguredStlThumbnailsEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredStlThumbnailsEnabledValue()) << std::endl; report << "getConfiguredHostsFileEditorEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredHostsFileEditorEnabledValue()) << std::endl; report << "getConfiguredImageResizerEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredImageResizerEnabledValue()) << std::endl; @@ -84,6 +87,13 @@ void ReportGPOValues(const std::filesystem::path &tmpDir) report << "getConfiguredQoiPreviewEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredQoiPreviewEnabledValue()) << std::endl; report << "getConfiguredQoiThumbnailsEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredQoiThumbnailsEnabledValue()) << std::endl; report << "getAllowedAdvancedPasteOnlineAIModelsValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteOnlineAIModelsValue()) << std::endl; + report << "getAllowedAdvancedPasteOpenAIValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteOpenAIValue()) << std::endl; + report << "getAllowedAdvancedPasteAzureOpenAIValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteAzureOpenAIValue()) << std::endl; + report << "getAllowedAdvancedPasteAzureAIInferenceValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteAzureAIInferenceValue()) << std::endl; + report << "getAllowedAdvancedPasteMistralValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteMistralValue()) << std::endl; + report << "getAllowedAdvancedPasteGoogleValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteGoogleValue()) << std::endl; + report << "getAllowedAdvancedPasteOllamaValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteOllamaValue()) << std::endl; + report << "getAllowedAdvancedPasteFoundryLocalValue: " << gpo_rule_configured_to_string(powertoys_gpo::getAllowedAdvancedPasteFoundryLocalValue()) << std::endl; report << "getConfiguredMwbClipboardSharingEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMwbClipboardSharingEnabledValue()) << std::endl; report << "getConfiguredMwbFileTransferEnabledValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMwbFileTransferEnabledValue()) << std::endl; report << "getConfiguredMwbUseOriginalUserInterfaceValue: " << gpo_rule_configured_to_string(powertoys_gpo::getConfiguredMwbUseOriginalUserInterfaceValue()) << std::endl; diff --git a/tools/BugReportTool/BugReportTool/XmlDocumentEx.cpp b/tools/BugReportTool/BugReportTool/XmlDocumentEx.cpp index d2e71705b1..3fee8f927e 100644 --- a/tools/BugReportTool/BugReportTool/XmlDocumentEx.cpp +++ b/tools/BugReportTool/BugReportTool/XmlDocumentEx.cpp @@ -19,13 +19,13 @@ void XmlDocumentEx::Print(winrt::Windows::Data::Xml::Dom::IXmlNode node, int ind PrintTagWithAttributes(node); if (!node.HasChildNodes()) { - stream << L"<\\" << node.NodeName().c_str() << ">" << std::endl; + stream << L"</" << node.NodeName().c_str() << ">" << std::endl; return; } if (node.ChildNodes().Size() == 1 && !node.FirstChild().HasChildNodes()) { - stream << node.InnerText().c_str() << L"<\\" << node.NodeName().c_str() << ">" << std::endl; + stream << node.InnerText().c_str() << L"</" << node.NodeName().c_str() << ">" << std::endl; return; } @@ -40,7 +40,7 @@ void XmlDocumentEx::Print(winrt::Windows::Data::Xml::Dom::IXmlNode node, int ind { stream << " "; } - stream << L"<\\" << node.NodeName().c_str() << ">" << std::endl; + stream << L"</" << node.NodeName().c_str() << ">" << std::endl; } void XmlDocumentEx::PrintTagWithAttributes(winrt::Windows::Data::Xml::Dom::IXmlNode node) diff --git a/tools/BugReportTool/BugReportTool/ZipTools/zipfolder.cpp b/tools/BugReportTool/BugReportTool/ZipTools/zipfolder.cpp index 6b5f4f0180..476707bd67 100644 --- a/tools/BugReportTool/BugReportTool/ZipTools/zipfolder.cpp +++ b/tools/BugReportTool/BugReportTool/ZipTools/zipfolder.cpp @@ -1,50 +1,53 @@ #include "ZipFolder.h" -#include "..\..\..\..\deps\cziplib\src\zip.h" #include <common/utils/timeutil.h> +#define WIN32_LEAN_AND_MEAN +#include <Windows.h> + +#include <format> +#include <wil/stl.h> +#include <wil/win32_helpers.h> + void ZipFolder(std::filesystem::path zipPath, std::filesystem::path folderPath) { - std::string reportFilename{ "PowerToysReport_" }; - reportFilename += timeutil::format_as_local("%F-%H-%M-%S", timeutil::now()); - reportFilename += ".zip"; + const auto reportFilename{ + std::format("PowerToysReport_{0}.zip", + timeutil::format_as_local("%F-%H-%M-%S", timeutil::now())) + }; + const auto finalReportFullPath{ zipPath / reportFilename }; - auto tmpZipPath = std::filesystem::temp_directory_path(); - tmpZipPath /= reportFilename; + const auto tempReportFilename{ reportFilename + ".tmp" }; + const auto tempReportFullPath{ zipPath / tempReportFilename }; - struct zip_t* zip = zip_open(tmpZipPath.string().c_str(), ZIP_DEFAULT_COMPRESSION_LEVEL, 'w'); - if (!zip) + // tar -c --format=zip -f "ReportFile.zip" * + const auto executable{ wil::ExpandEnvironmentStringsW<std::wstring>(LR"(%WINDIR%\System32\tar.exe)") }; + auto commandline{ std::format(LR"("{0}" -c --format=zip -f "{1}" *)", executable, tempReportFullPath.wstring()) }; + + const auto folderPathAsString{ folderPath.lexically_normal().wstring() }; + + wil::unique_process_information pi; + STARTUPINFOW si{ .cb = sizeof(STARTUPINFOW) }; + if (!CreateProcessW(executable.c_str(), + commandline.data() /* must be mutable */, + nullptr, + nullptr, + FALSE, + DETACHED_PROCESS, + nullptr, + folderPathAsString.c_str(), + &si, + &pi)) { printf("Cannot open zip."); throw -1; } - using recursive_directory_iterator = std::filesystem::recursive_directory_iterator; - const size_t rootSize = folderPath.wstring().size(); - for (const auto& dirEntry : recursive_directory_iterator(folderPath)) - { - if (dirEntry.is_regular_file()) - { - auto path = dirEntry.path().string(); - auto relativePath = path.substr(rootSize, path.size()); - zip_entry_open(zip, relativePath.c_str()); - zip_entry_fwrite(zip, path.c_str()); - zip_entry_close(zip); - } - } + WaitForSingleObject(pi.hProcess, INFINITE); - zip_close(zip); - - std::error_code err; - std::filesystem::copy(tmpZipPath, zipPath, err); + std::error_code err{}; + std::filesystem::rename(tempReportFullPath, finalReportFullPath, err); if (err.value() != 0) { - wprintf_s(L"Failed to copy %s. Error code: %d\n", tmpZipPath.c_str(), err.value()); + wprintf_s(L"Failed to rename %s. Error code: %d\n", tempReportFullPath.native().c_str(), err.value()); } - - err = {}; - std::filesystem::remove_all(tmpZipPath, err); - if (err.value() != 0) - { - wprintf_s(L"Failed to delete %s. Error code: %d\n", tmpZipPath.c_str(), err.value()); - } -} \ No newline at end of file +} diff --git a/tools/BugReportTool/BugReportTool/packages.config b/tools/BugReportTool/BugReportTool/packages.config index ff4b059648..d3882436a5 100644 --- a/tools/BugReportTool/BugReportTool/packages.config +++ b/tools/BugReportTool/BugReportTool/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> <package id="Microsoft.Windows.ImplementationLibrary" version="1.0.231216.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/tools/CleanUp_tool/CleanUp_tool.vcxproj b/tools/CleanUp_tool/CleanUp_tool.vcxproj index ceed3a375a..990f36d8a1 100644 --- a/tools/CleanUp_tool/CleanUp_tool.vcxproj +++ b/tools/CleanUp_tool/CleanUp_tool.vcxproj @@ -11,13 +11,13 @@ <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> diff --git a/tools/FancyZones_DrawLayoutTest/FancyZones_DrawLayoutTest.vcxproj b/tools/FancyZones_DrawLayoutTest/FancyZones_DrawLayoutTest.vcxproj index 8c0be5b23e..149cd69799 100644 --- a/tools/FancyZones_DrawLayoutTest/FancyZones_DrawLayoutTest.vcxproj +++ b/tools/FancyZones_DrawLayoutTest/FancyZones_DrawLayoutTest.vcxproj @@ -28,26 +28,26 @@ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> diff --git a/tools/FancyZones_zonable_tester/FancyZones_zonable_tester.vcxproj b/tools/FancyZones_zonable_tester/FancyZones_zonable_tester.vcxproj index b8608dae2f..fb79b26781 100644 --- a/tools/FancyZones_zonable_tester/FancyZones_zonable_tester.vcxproj +++ b/tools/FancyZones_zonable_tester/FancyZones_zonable_tester.vcxproj @@ -27,26 +27,26 @@ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>MultiByte</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>MultiByte</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>MultiByte</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>MultiByte</CharacterSet> </PropertyGroup> diff --git a/tools/MonitorReportTool/MonitorReportTool.vcxproj b/tools/MonitorReportTool/MonitorReportTool.vcxproj index 3c6019b5d3..fddbe44212 100644 --- a/tools/MonitorReportTool/MonitorReportTool.vcxproj +++ b/tools/MonitorReportTool/MonitorReportTool.vcxproj @@ -10,13 +10,13 @@ <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -36,7 +36,7 @@ <LinkIncremental>false</LinkIncremental> </PropertyGroup> <PropertyGroup> - <OutDir>$(SolutionDir)..\..\$(Platform)\$(Configuration)\$(ProjectName)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\$(ProjectName)\</OutDir> <TargetName>PowerToys.$(ProjectName)</TargetName> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> diff --git a/tools/StylesReportTool/StylesReportTool.vcxproj b/tools/StylesReportTool/StylesReportTool.vcxproj index 97189f4ed5..d35295b201 100644 --- a/tools/StylesReportTool/StylesReportTool.vcxproj +++ b/tools/StylesReportTool/StylesReportTool.vcxproj @@ -10,13 +10,13 @@ <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>Application</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -36,7 +36,7 @@ <LinkIncremental>false</LinkIncremental> </PropertyGroup> <PropertyGroup> - <OutDir>$(SolutionDir)..\..\$(Platform)\$(Configuration)\$(ProjectName)\</OutDir> + <OutDir>$(RepoRoot)$(Platform)\$(Configuration)\$(ProjectName)\</OutDir> <TargetName>PowerToys.$(ProjectName)</TargetName> </PropertyGroup> <ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'"> diff --git a/tools/Verification scripts/Check preview handler registration.ps1 b/tools/Verification scripts/Check preview handler registration.ps1 index e2b5eee30e..d4b54313b6 100644 --- a/tools/Verification scripts/Check preview handler registration.ps1 +++ b/tools/Verification scripts/Check preview handler registration.ps1 @@ -15,12 +15,13 @@ function PublicStaticVoidMain { [String] $MachineWideHandler } - [String[]]$TypesToCheck = @(".markdown", ".mdtext", ".mdtxt", ".mdown", ".mkdn", ".mdwn", ".mkd", ".md", ".svg", ".svgz", ".pdf", ".gcode", ".stl", ".txt", ".ini", ".qoi") + [String[]]$TypesToCheck = @(".markdown", ".mdtext", ".mdtxt", ".mdown", ".mkdn", ".mdwn", ".mkd", ".md", ".svg", ".svgz", ".pdf", ".gcode", ".bgcode", ".stl", ".txt", ".ini", ".qoi") $IPREVIEW_HANDLER_CLSID = '{8895b1c6-b41f-4c1c-a562-0d564250836f}' $PowerToysHandlers = @{ '{07665729-6243-4746-95b7-79579308d1b2}' = "PowerToys PDF handler" '{ddee2b8a-6807-48a6-bb20-2338174ff779}' = "PowerToys SVG handler" '{ec52dea8-7c9f-4130-a77b-1737d0418507}' = "PowerToys GCode handler" + '{dd8de316-7b01-48e7-ba21-e92c646704af}' = "PowerToys BGCode handler" '{8AA07897-C30B-4543-865B-00A0E5A1B32D}' = "PowerToys QOI handler" '{45769bcc-e8fd-42d0-947e-02beef77a1f5}' = "PowerToys Markdown handler" '{afbd5a44-2520-4ae0-9224-6cfce8fe4400}' = "PowerToys Monaco fallback handler" diff --git a/tools/Verification scripts/verify-installation-script.ps1 b/tools/Verification scripts/verify-installation-script.ps1 new file mode 100644 index 0000000000..d617aca9a7 --- /dev/null +++ b/tools/Verification scripts/verify-installation-script.ps1 @@ -0,0 +1,831 @@ +#Requires -Version 5.1 + +<# +.SYNOPSIS + Verifies a PowerToys installation by checking all components, registry entries, files, and custom logic. + +.DESCRIPTION + This script comprehensively verifies a PowerToys installation by checking: + - Registry entries for both per-machine and per-user installations + - File and folder structure integrity + - Module registration and functionality + - WiX installer logic verification + - Custom action results + - DSC module installation + - Command Palette packages + +.PARAMETER InstallScope + Specifies the installation scope to verify. Valid values are 'PerMachine' or 'PerUser'. + Default is 'PerMachine'. + +.PARAMETER InstallPath + Optional. Specifies a custom installation path to verify. If not provided, the script will + detect the installation path from the registry. + +.EXAMPLE + .\verify-installation-script.ps1 -InstallScope PerMachine + +.EXAMPLE + .\verify-installation-script.ps1 -InstallScope PerUser + +.NOTES + Author: PowerToys Team + Requires: PowerShell 5.1 or later + Requires: Administrative privileges for per-machine verification +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [ValidateSet('PerMachine', 'PerUser')] + [string]$InstallScope = 'PerMachine', + + [Parameter(Mandatory = $false)] + [string]$InstallPath +) + +# Initialize results tracking +$script:Results = @{ + Summary = @{ + TotalChecks = 0 + PassedChecks = 0 + FailedChecks = 0 + WarningChecks = 0 + OverallStatus = "Unknown" + } + Details = @{} + Timestamp = Get-Date + Computer = $env:COMPUTERNAME + User = $env:USERNAME + PowerShellVersion = $PSVersionTable.PSVersion.ToString() +} + +# PowerToys constants +$PowerToysUpgradeCodePerMachine = "{42B84BF7-5FBF-473B-9C8B-049DC16F7708}" +$PowerToysUpgradeCodePerUser = "{D8B559DB-4C98-487A-A33F-50A8EEE42726}" +$PowerToysRegistryKeyPerMachine = "HKLM:\SOFTWARE\Classes\PowerToys" +$PowerToysRegistryKeyPerUser = "HKCU:\SOFTWARE\Classes\PowerToys" + +# Utility functions +function Write-StatusMessage { + param( + [string]$Message, + [ValidateSet('Info', 'Success', 'Warning', 'Error')] + [string]$Level = 'Info' + ) + + $color = switch ($Level) { + 'Info' { 'White' } + 'Success' { 'Green' } + 'Warning' { 'Yellow' } + 'Error' { 'Red' } + } + + $prefix = switch ($Level) { + 'Info' { '[INFO]' } + 'Success' { '[PASS]' } + 'Warning' { '[WARN]' } + 'Error' { '[FAIL]' } + } + + Write-Host "$prefix $Message" -ForegroundColor $color +} + +function Add-CheckResult { + param( + [string]$Category, + [string]$CheckName, + [string]$Status, + [string]$Message, + [object]$Details = $null + ) + + $script:Results.Summary.TotalChecks++ + + switch ($Status) { + 'Pass' { $script:Results.Summary.PassedChecks++ } + 'Fail' { $script:Results.Summary.FailedChecks++ } + 'Warning' { $script:Results.Summary.WarningChecks++ } + } + + if (-not $script:Results.Details.ContainsKey($Category)) { + $script:Results.Details[$Category] = @{} + } + + $checkDetails = @{ + Status = $Status + Message = $Message + Details = $Details + Timestamp = Get-Date + } + + $script:Results.Details[$Category][$CheckName] = $checkDetails + + # Always show all checks with their status + $level = switch ($Status) { + 'Pass' { 'Success' } + 'Fail' { 'Error' } + 'Warning' { 'Warning' } + } + Write-StatusMessage "[$Category] $CheckName - $Message" -Level $level +} + +function Test-RegistryKey { + param( + [string]$Path + ) + try { + return Test-Path $Path + } + catch { + return $false + } +} + +function Get-RegistryValue { + param( + [string]$Path, + [string]$Name, + [object]$DefaultValue = $null + ) + try { + $value = Get-ItemProperty -Path $Path -Name $Name -ErrorAction SilentlyContinue + return $value.$Name + } + catch { + return $DefaultValue + } +} + +function Test-PowerToysInstallation { + param( + [ValidateSet('PerMachine', 'PerUser')] + [string]$Scope + ) + + Write-StatusMessage "Verifying PowerToys $Scope installation..." -Level Info + + # Determine registry paths based on scope + $registryKey = if ($Scope -eq 'PerMachine') { $PowerToysRegistryKeyPerMachine } else { $PowerToysRegistryKeyPerUser } + + # Check main registry key + $mainKeyExists = Test-RegistryKey -Path $registryKey + Add-CheckResult -Category "Registry" -CheckName "Main Registry Key ($Scope)" -Status $(if ($mainKeyExists) { 'Pass' } else { 'Fail' }) -Message "Registry key exists: $registryKey" + + if (-not $mainKeyExists) { + Add-CheckResult -Category "Installation" -CheckName "Installation Status ($Scope)" -Status 'Fail' -Message "PowerToys $Scope installation not found" + return $false + } + + # Check install scope value + $installScopeValue = Get-RegistryValue -Path $registryKey -Name "InstallScope" + $expectedScope = $Scope.ToLower() + if ($Scope -eq 'PerMachine') { $expectedScope = 'perMachine' } + if ($Scope -eq 'PerUser') { $expectedScope = 'perUser' } + + $scopeCorrect = $installScopeValue -eq $expectedScope + Add-CheckResult -Category "Registry" -CheckName "Install Scope Value ($Scope)" -Status $(if ($scopeCorrect) { 'Pass' } else { 'Fail' }) -Message "Install scope: Expected '$expectedScope', Found '$installScopeValue'" + + # Check for uninstall registry entry (this is what makes PowerToys appear in Add/Remove Programs) + $uninstallKey = if ($Scope -eq 'PerMachine') { + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" + } + else { + "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" + } + + try { + $powerToysEntry = Get-ItemProperty -Path $uninstallKey | Where-Object { + $_.DisplayName -like "*PowerToys*" + } | Select-Object -First 1 + + if ($powerToysEntry) { + Add-CheckResult -Category "Registry" -CheckName "Uninstall Registry Entry ($Scope)" -Status 'Pass' -Message "PowerToys uninstall entry found with DisplayName: $($powerToysEntry.DisplayName)" + + # Note: InstallLocation may or may not be set in the uninstall registry + # This is normal behavior as PowerToys uses direct file references for system bindings + if ($powerToysEntry.InstallLocation) { + Add-CheckResult -Category "Registry" -CheckName "Install Location Registry ($Scope)" -Status 'Pass' -Message "InstallLocation found: $($powerToysEntry.InstallLocation)" + } + # No need to report missing InstallLocation as it's not required + } + else { + Add-CheckResult -Category "Registry" -CheckName "Uninstall Registry Entry ($Scope)" -Status 'Fail' -Message "PowerToys uninstall entry not found in Windows uninstall registry" + } + } + catch { + Add-CheckResult -Category "Registry" -CheckName "Uninstall Registry Entry ($Scope)" -Status 'Fail' -Message "Failed to read Windows uninstall registry" + } + + # Check for installation folder + $installFolder = Get-PowerToysInstallPath -Scope $Scope + if ($installFolder -and (Test-Path $installFolder)) { + Add-CheckResult -Category "Installation" -CheckName "Install Folder ($Scope)" -Status 'Pass' -Message "Installation folder exists: $installFolder" + + # Verify core files + Test-CoreFiles -InstallPath $installFolder -Scope $Scope + + # Verify modules + Test-ModuleFiles -InstallPath $installFolder -Scope $Scope + + return $true + } + else { + Add-CheckResult -Category "Installation" -CheckName "Install Folder ($Scope)" -Status 'Fail' -Message "Installation folder not found or inaccessible: $installFolder" + return $false + } +} + +function Get-PowerToysInstallPath { + param( + [ValidateSet('PerMachine', 'PerUser')] + [string]$Scope + ) + + if ($InstallPath) { + return $InstallPath + } + + # Since InstallLocation may not be reliably set in the uninstall registry, + # we'll use the default installation paths based on scope + if ($Scope -eq 'PerMachine') { + $defaultPath = "${env:ProgramFiles}\PowerToys" + } + else { + $defaultPath = "${env:LOCALAPPDATA}\PowerToys" + } + + # Verify the path exists before returning it + if (Test-Path $defaultPath) { + return $defaultPath + } + + # If default path doesn't exist, try to get it from uninstall registry as fallback + $uninstallKey = if ($Scope -eq 'PerMachine') { + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" + } + else { + "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" + } + + try { + $powerToysEntry = Get-ItemProperty -Path $uninstallKey | Where-Object { + $_.DisplayName -like "*PowerToys*" + } | Select-Object -First 1 + + # Check for InstallLocation first, but it may not exist + if ($powerToysEntry -and $powerToysEntry.InstallLocation) { + return $powerToysEntry.InstallLocation.TrimEnd('\') + } + + # Check for UninstallString as alternative source of install path + if ($powerToysEntry -and $powerToysEntry.UninstallString) { + # Extract directory from uninstall string like "C:\Program Files\PowerToys\unins000.exe" + $uninstallExe = $powerToysEntry.UninstallString.Trim('"') + $installDir = Split-Path $uninstallExe -Parent + if ($installDir -and (Test-Path $installDir)) { + return $installDir + } + } + } + catch { + # If registry read fails, fall back to null + } + + # If we can't determine the install path, return null + return $null +} + +function Test-CoreFiles { + param( + [string]$InstallPath, + [string]$Scope + ) + + # Essential core files (must exist for basic functionality) + $essentialCoreFiles = @( + 'PowerToys.exe', + 'PowerToys.ActionRunner.exe', + 'License.rtf', + 'Notice.md' + ) + + # Critical signed PowerToys executable files (from ESRP signing config) + $criticalSignedFiles = @( + # Main PowerToys components + 'PowerToys.exe', + 'PowerToys.ActionRunner.exe', + 'PowerToys.Update.exe', + 'PowerToys.BackgroundActivatorDLL.dll', + 'PowerToys.FilePreviewCommon.dll', + 'PowerToys.Interop.dll', + + # Common libraries + 'CalculatorEngineCommon.dll', + 'PowerToys.ManagedTelemetry.dll', + 'PowerToys.ManagedCommon.dll', + 'PowerToys.ManagedCsWin32.dll', + 'PowerToys.Common.UI.dll', + 'PowerToys.Settings.UI.Lib.dll', + 'PowerToys.GPOWrapper.dll', + 'PowerToys.GPOWrapperProjection.dll', + 'PowerToys.AllExperiments.dll', + + # Module executables and libraries + 'PowerToys.AlwaysOnTop.exe', + 'PowerToys.AlwaysOnTopModuleInterface.dll', + 'PowerToys.CmdNotFoundModuleInterface.dll', + 'PowerToys.ColorPicker.dll', + 'PowerToys.ColorPickerUI.dll', + 'PowerToys.ColorPickerUI.exe', + 'PowerToys.CropAndLockModuleInterface.dll', + 'PowerToys.CropAndLock.exe', + 'PowerToys.PowerOCRModuleInterface.dll', + 'PowerToys.PowerOCR.dll', + 'PowerToys.PowerOCR.exe', + 'PowerToys.AdvancedPasteModuleInterface.dll', + 'PowerToys.AwakeModuleInterface.dll', + 'PowerToys.Awake.exe', + 'PowerToys.Awake.dll', + + # FancyZones + 'PowerToys.FancyZonesEditor.exe', + 'PowerToys.FancyZonesEditor.dll', + 'PowerToys.FancyZonesEditorCommon.dll', + 'PowerToys.FancyZonesModuleInterface.dll', + 'PowerToys.FancyZones.exe', + + # Preview handlers + 'PowerToys.GcodePreviewHandler.dll', + 'PowerToys.GcodePreviewHandler.exe', + 'PowerToys.GcodePreviewHandlerCpp.dll', + 'PowerToys.GcodeThumbnailProvider.dll', + 'PowerToys.GcodeThumbnailProvider.exe', + 'PowerToys.GcodeThumbnailProviderCpp.dll', + 'PowerToys.BgcodePreviewHandler.dll', + 'PowerToys.BgcodePreviewHandler.exe', + 'PowerToys.BgcodePreviewHandlerCpp.dll', + 'PowerToys.BgcodeThumbnailProvider.dll', + 'PowerToys.BgcodeThumbnailProvider.exe', + 'PowerToys.BgcodeThumbnailProviderCpp.dll', + 'PowerToys.MarkdownPreviewHandler.dll', + 'PowerToys.MarkdownPreviewHandler.exe', + 'PowerToys.MarkdownPreviewHandlerCpp.dll', + 'PowerToys.MonacoPreviewHandler.dll', + 'PowerToys.MonacoPreviewHandler.exe', + 'PowerToys.MonacoPreviewHandlerCpp.dll', + 'PowerToys.PdfPreviewHandler.dll', + 'PowerToys.PdfPreviewHandler.exe', + 'PowerToys.PdfPreviewHandlerCpp.dll', + 'PowerToys.PdfThumbnailProvider.dll', + 'PowerToys.PdfThumbnailProvider.exe', + 'PowerToys.PdfThumbnailProviderCpp.dll', + 'PowerToys.powerpreview.dll', + 'PowerToys.PreviewHandlerCommon.dll', + 'PowerToys.QoiPreviewHandler.dll', + 'PowerToys.QoiPreviewHandler.exe', + 'PowerToys.QoiPreviewHandlerCpp.dll', + 'PowerToys.QoiThumbnailProvider.dll', + 'PowerToys.QoiThumbnailProvider.exe', + 'PowerToys.QoiThumbnailProviderCpp.dll', + 'PowerToys.StlThumbnailProvider.dll', + 'PowerToys.StlThumbnailProvider.exe', + 'PowerToys.StlThumbnailProviderCpp.dll', + 'PowerToys.SvgPreviewHandler.dll', + 'PowerToys.SvgPreviewHandler.exe', + 'PowerToys.SvgPreviewHandlerCpp.dll', + 'PowerToys.SvgThumbnailProvider.dll', + 'PowerToys.SvgThumbnailProvider.exe', + 'PowerToys.SvgThumbnailProviderCpp.dll', + + # Image Resizer + 'PowerToys.ImageResizer.exe', + 'PowerToys.ImageResizer.dll', + 'PowerToys.ImageResizerExt.dll', + 'PowerToys.ImageResizerContextMenu.dll', + + # Keyboard Manager + 'PowerToys.KeyboardManager.dll', + 'PowerToys.KeyboardManagerEditorLibraryWrapper.dll', + + # PowerToys Run + 'PowerToys.Launcher.dll', + 'PowerToys.PowerLauncher.dll', + 'PowerToys.PowerLauncher.exe', + 'PowerToys.PowerLauncher.Telemetry.dll', + 'Wox.Infrastructure.dll', + 'Wox.Plugin.dll', + + # Mouse utilities + 'PowerToys.FindMyMouse.dll', + 'PowerToys.MouseHighlighter.dll', + 'PowerToys.MouseJump.dll', + 'PowerToys.MouseJump.Common.dll', + 'PowerToys.MousePointerCrosshairs.dll', + 'PowerToys.MouseJumpUI.dll', + 'PowerToys.MouseJumpUI.exe', + 'PowerToys.MouseWithoutBorders.dll', + 'PowerToys.MouseWithoutBorders.exe', + 'PowerToys.MouseWithoutBordersModuleInterface.dll', + 'PowerToys.MouseWithoutBordersService.dll', + 'PowerToys.MouseWithoutBordersService.exe', + 'PowerToys.MouseWithoutBordersHelper.dll', + 'PowerToys.MouseWithoutBordersHelper.exe', + + # PowerAccent + 'PowerAccent.Core.dll', + 'PowerToys.PowerAccent.dll', + 'PowerToys.PowerAccent.exe', + 'PowerToys.PowerAccentModuleInterface.dll', + 'PowerToys.PowerAccentKeyboardService.dll', + + # Workspaces + 'PowerToys.WorkspacesSnapshotTool.exe', + 'PowerToys.WorkspacesLauncher.exe', + 'PowerToys.WorkspacesWindowArranger.exe', + 'PowerToys.WorkspacesEditor.exe', + 'PowerToys.WorkspacesEditor.dll', + 'PowerToys.WorkspacesLauncherUI.exe', + 'PowerToys.WorkspacesLauncherUI.dll', + 'PowerToys.WorkspacesModuleInterface.dll', + 'PowerToys.WorkspacesCsharpLibrary.dll', + + # Shortcut Guide + 'PowerToys.ShortcutGuide.exe', + 'PowerToys.ShortcutGuideModuleInterface.dll', + + # ZoomIt + 'PowerToys.ZoomIt.exe', + 'PowerToys.ZoomItModuleInterface.dll', + 'PowerToys.ZoomItSettingsInterop.dll', + + # Command Palette + 'PowerToys.CmdPalModuleInterface.dll', + 'CmdPalKeyboardService.dll' + ) + + # WinUI3Apps signed files (in WinUI3Apps subdirectory) + $winUI3SignedFiles = @( + 'PowerToys.Settings.dll', + 'PowerToys.Settings.exe', + 'PowerToys.AdvancedPaste.exe', + 'PowerToys.AdvancedPaste.dll', + 'PowerToys.HostsModuleInterface.dll', + 'PowerToys.HostsUILib.dll', + 'PowerToys.Hosts.dll', + 'PowerToys.Hosts.exe', + 'PowerToys.FileLocksmithLib.Interop.dll', + 'PowerToys.FileLocksmithExt.dll', + 'PowerToys.FileLocksmithUI.exe', + 'PowerToys.FileLocksmithUI.dll', + 'PowerToys.FileLocksmithContextMenu.dll', + 'Peek.Common.dll', + 'Peek.FilePreviewer.dll', + 'Powertoys.Peek.UI.dll', + 'Powertoys.Peek.UI.exe', + 'Powertoys.Peek.dll', + 'PowerToys.EnvironmentVariablesModuleInterface.dll', + 'PowerToys.EnvironmentVariablesUILib.dll', + 'PowerToys.EnvironmentVariables.dll', + 'PowerToys.EnvironmentVariables.exe', + 'PowerToys.MeasureToolModuleInterface.dll', + 'PowerToys.MeasureToolCore.dll', + 'PowerToys.MeasureToolUI.dll', + 'PowerToys.MeasureToolUI.exe', + 'PowerToys.NewPlus.ShellExtension.dll', + 'PowerToys.NewPlus.ShellExtension.win10.dll', + 'PowerToys.PowerRenameExt.dll', + 'PowerToys.PowerRename.exe', + 'PowerToys.PowerRenameContextMenu.dll', + 'PowerToys.RegistryPreviewExt.dll', + 'PowerToys.RegistryPreviewUILib.dll', + 'PowerToys.RegistryPreview.dll', + 'PowerToys.RegistryPreview.exe' + ) + + # Tools signed files (in Tools subdirectory) + $toolsSignedFiles = @( + 'PowerToys.BugReportTool.exe' + ) + + # KeyboardManager signed files (in specific subdirectories) + $keyboardManagerFiles = @{ + 'KeyboardManagerEditor\PowerToys.KeyboardManagerEditor.exe' = 'KeyboardManagerEditor' + 'KeyboardManagerEngine\PowerToys.KeyboardManagerEngine.exe' = 'KeyboardManagerEngine' + } + + # Run plugins signed files (in RunPlugins subdirectories) + $runPluginFiles = @{ + 'RunPlugins\Calculator\Microsoft.PowerToys.Run.Plugin.Calculator.dll' = 'Calculator plugin' + 'RunPlugins\Folder\Microsoft.Plugin.Folder.dll' = 'Folder plugin' + 'RunPlugins\Indexer\Microsoft.Plugin.Indexer.dll' = 'Indexer plugin' + 'RunPlugins\OneNote\Microsoft.PowerToys.Run.Plugin.OneNote.dll' = 'OneNote plugin' + 'RunPlugins\History\Microsoft.PowerToys.Run.Plugin.History.dll' = 'History plugin' + 'RunPlugins\PowerToys\Microsoft.PowerToys.Run.Plugin.PowerToys.dll' = 'PowerToys plugin' + 'RunPlugins\Program\Microsoft.Plugin.Program.dll' = 'Program plugin' + 'RunPlugins\Registry\Microsoft.PowerToys.Run.Plugin.Registry.dll' = 'Registry plugin' + 'RunPlugins\WindowsSettings\Microsoft.PowerToys.Run.Plugin.WindowsSettings.dll' = 'Windows Settings plugin' + 'RunPlugins\Shell\Microsoft.Plugin.Shell.dll' = 'Shell plugin' + 'RunPlugins\Uri\Microsoft.Plugin.Uri.dll' = 'URI plugin' + 'RunPlugins\WindowWalker\Microsoft.Plugin.WindowWalker.dll' = 'Window Walker plugin' + 'RunPlugins\UnitConverter\Community.PowerToys.Run.Plugin.UnitConverter.dll' = 'Unit Converter plugin' + 'RunPlugins\VSCodeWorkspaces\Community.PowerToys.Run.Plugin.VSCodeWorkspaces.dll' = 'VS Code Workspaces plugin' + 'RunPlugins\Service\Microsoft.PowerToys.Run.Plugin.Service.dll' = 'Service plugin' + 'RunPlugins\System\Microsoft.PowerToys.Run.Plugin.System.dll' = 'System plugin' + 'RunPlugins\TimeDate\Microsoft.PowerToys.Run.Plugin.TimeDate.dll' = 'Time Date plugin' + 'RunPlugins\ValueGenerator\Community.PowerToys.Run.Plugin.ValueGenerator.dll' = 'Value Generator plugin' + 'RunPlugins\WebSearch\Community.PowerToys.Run.Plugin.WebSearch.dll' = 'Web Search plugin' + 'RunPlugins\WindowsTerminal\Microsoft.PowerToys.Run.Plugin.WindowsTerminal.dll' = 'Windows Terminal plugin' + } + + # Check essential core files (must exist) + Write-StatusMessage "Checking essential core files..." -Level Info + foreach ($file in $essentialCoreFiles) { + $filePath = Join-Path $InstallPath $file + $exists = Test-Path $filePath + Add-CheckResult -Category "Core Files" -CheckName "$file ($Scope)" -Status $(if ($exists) { 'Pass' } else { 'Fail' }) -Message "Essential file: $filePath" + } + + # Check critical signed files in root directory + Write-StatusMessage "Checking critical signed files in root directory..." -Level Info + foreach ($file in $criticalSignedFiles) { + $filePath = Join-Path $InstallPath $file + $exists = Test-Path $filePath + # Most signed files are critical, but some may be optional depending on configuration + $status = if ($exists) { 'Pass' } else { 'Warning' } + Add-CheckResult -Category "Signed Files" -CheckName "$file ($Scope)" -Status $status -Message "Signed file: $filePath" + } + + # Check WinUI3Apps signed files + Write-StatusMessage "Checking WinUI3Apps signed files..." -Level Info + foreach ($file in $winUI3SignedFiles) { + $filePath = Join-Path $InstallPath "WinUI3Apps\$file" + $exists = Test-Path $filePath + $status = if ($exists) { 'Pass' } else { 'Warning' } + Add-CheckResult -Category "Signed Files" -CheckName "WinUI3Apps\$file ($Scope)" -Status $status -Message "WinUI3 signed file: $filePath" + } + + # Check Tools signed files + Write-StatusMessage "Checking Tools signed files..." -Level Info + foreach ($file in $toolsSignedFiles) { + $filePath = Join-Path $InstallPath "Tools\$file" + $exists = Test-Path $filePath + $status = if ($exists) { 'Pass' } else { 'Warning' } + Add-CheckResult -Category "Signed Files" -CheckName "Tools\$file ($Scope)" -Status $status -Message "Tools signed file: $filePath" + } + + # Check KeyboardManager files + Write-StatusMessage "Checking KeyboardManager signed files..." -Level Info + foreach ($relativePath in $keyboardManagerFiles.Keys) { + $filePath = Join-Path $InstallPath $relativePath + $exists = Test-Path $filePath + $status = if ($exists) { 'Pass' } else { 'Warning' } + $description = $keyboardManagerFiles[$relativePath] + Add-CheckResult -Category "Signed Files" -CheckName "$relativePath ($Scope)" -Status $status -Message "KeyboardManager $description signed file: $filePath" + } + + # Check Run plugins files + Write-StatusMessage "Checking PowerToys Run plugin files..." -Level Info + foreach ($relativePath in $runPluginFiles.Keys) { + $filePath = Join-Path $InstallPath $relativePath + $exists = Test-Path $filePath + $status = if ($exists) { 'Pass' } else { 'Warning' } + $description = $runPluginFiles[$relativePath] + Add-CheckResult -Category "Signed Files" -CheckName "$relativePath ($Scope)" -Status $status -Message "PowerToys Run $description signed file: $filePath" + } +} + +function Test-ModuleFiles { + param( + [string]$InstallPath, + [string]$Scope + ) + + # PowerToys does not actually install modules in a "modules" subfolder. + # Instead, modules are integrated directly into the main installation or specific subfolders. + # Check for key module directories that should exist: + + # Check KeyboardManager components (installed as separate folders) + $keyboardManagerEditor = Join-Path $InstallPath "KeyboardManagerEditor" + $keyboardManagerEngine = Join-Path $InstallPath "KeyboardManagerEngine" + + if (Test-Path $keyboardManagerEditor) { + Add-CheckResult -Category "Modules" -CheckName "KeyboardManager Editor ($Scope)" -Status 'Pass' -Message "KeyboardManager Editor folder exists: $keyboardManagerEditor" + } + else { + Add-CheckResult -Category "Modules" -CheckName "KeyboardManager Editor ($Scope)" -Status 'Warning' -Message "KeyboardManager Editor folder not found: $keyboardManagerEditor" + } + + if (Test-Path $keyboardManagerEngine) { + Add-CheckResult -Category "Modules" -CheckName "KeyboardManager Engine ($Scope)" -Status 'Pass' -Message "KeyboardManager Engine folder exists: $keyboardManagerEngine" + } + else { + Add-CheckResult -Category "Modules" -CheckName "KeyboardManager Engine ($Scope)" -Status 'Warning' -Message "KeyboardManager Engine folder not found: $keyboardManagerEngine" + } + + # Check RunPlugins folder (contains PowerToys Run modules) + $runPluginsPath = Join-Path $InstallPath "RunPlugins" + if (Test-Path $runPluginsPath) { + Add-CheckResult -Category "Modules" -CheckName "Run Plugins Folder ($Scope)" -Status 'Pass' -Message "Run plugins folder exists: $runPluginsPath" + } + else { + Add-CheckResult -Category "Modules" -CheckName "Run Plugins Folder ($Scope)" -Status 'Warning' -Message "Run plugins folder not found: $runPluginsPath" + } + + # Check Tools folder + $toolsPath = Join-Path $InstallPath "Tools" + if (Test-Path $toolsPath) { + Add-CheckResult -Category "Modules" -CheckName "Tools Folder ($Scope)" -Status 'Pass' -Message "Tools folder exists: $toolsPath" + } + else { + Add-CheckResult -Category "Modules" -CheckName "Tools Folder ($Scope)" -Status 'Warning' -Message "Tools folder not found: $toolsPath" + } +} + +function Test-RegistryHandlers { + param( + [string]$Scope + ) + + $registryRoot = if ($Scope -eq 'PerMachine') { 'HKLM:' } else { 'HKCU:' } + # Test URL protocol handler + $protocolPath = "$registryRoot\SOFTWARE\Classes\powertoys" + if (Test-RegistryKey -Path $protocolPath) { + Add-CheckResult -Category "Registry Handlers" -CheckName "PowerToys URL Protocol ($Scope)" -Status 'Pass' -Message "URL protocol registered" + + # Check command handler + $commandPath = "$protocolPath\shell\open\command" + if (Test-RegistryKey -Path $commandPath) { + Add-CheckResult -Category "Registry Handlers" -CheckName "PowerToys URL Command ($Scope)" -Status 'Pass' -Message "URL command handler registered" + } + else { + Add-CheckResult -Category "Registry Handlers" -CheckName "PowerToys URL Command ($Scope)" -Status 'Fail' -Message "URL command handler not found" + } + } + else { + Add-CheckResult -Category "Registry Handlers" -CheckName "PowerToys URL Protocol ($Scope)" -Status 'Fail' -Message "URL protocol not registered" + } + + # Test CLSID registration for toast notifications + $toastClsidPath = "$registryRoot\SOFTWARE\Classes\CLSID\{DD5CACDA-7C2E-4997-A62A-04A597B58F76}" + if (Test-RegistryKey -Path $toastClsidPath) { + Add-CheckResult -Category "Registry Handlers" -CheckName "Toast Notification CLSID ($Scope)" -Status 'Pass' -Message "Toast notification CLSID registered" + } + else { + Add-CheckResult -Category "Registry Handlers" -CheckName "Toast Notification CLSID ($Scope)" -Status 'Warning' -Message "Toast notification CLSID not found" + } +} + +function Test-DSCModule { + param( + [string]$Scope + ) + + if ($Scope -eq 'PerUser') { + # For per-user installations, DSC module is installed via custom action to user's Documents + $userModulesPath = "$env:USERPROFILE\Documents\PowerShell\Modules\Microsoft.PowerToys.Configure" + if (Test-Path $userModulesPath) { + Add-CheckResult -Category "DSC Module" -CheckName "DSC Module (PerUser)" -Status 'Pass' -Message "DSC module found in user profile: $userModulesPath" + } + else { + Add-CheckResult -Category "DSC Module" -CheckName "DSC Module (PerUser)" -Status 'Fail' -Message "DSC module not found in user profile: $userModulesPath" + } + } + else { + # For per-machine installations, DSC module is installed to system WindowsPowerShell modules + $systemModulesPath = "${env:ProgramFiles}\WindowsPowerShell\Modules\Microsoft.PowerToys.Configure" + if (Test-Path $systemModulesPath) { + Add-CheckResult -Category "DSC Module" -CheckName "DSC Module (PerMachine)" -Status 'Pass' -Message "DSC module found in system modules: $systemModulesPath" + } + else { + Add-CheckResult -Category "DSC Module" -CheckName "DSC Module (PerMachine)" -Status 'Fail' -Message "DSC module not found in system modules: $systemModulesPath" + } + } +} + +function Test-CommandPalettePackages { + param( + [string]$InstallPath + ) + + $cmdPalPath = Join-Path $InstallPath "WinUI3Apps\CmdPal" + if (Test-Path $cmdPalPath) { + # Check for MSIX package file (the actual Command Palette installation) + $msixFiles = Get-ChildItem $cmdPalPath -Filter "*.msix" -ErrorAction SilentlyContinue + if ($msixFiles) { + Add-CheckResult -Category "Command Palette" -CheckName "CmdPal MSIX Package" -Status 'Pass' -Message "Found $($msixFiles.Count) Command Palette MSIX package(s)" + } + else { + Add-CheckResult -Category "Command Palette" -CheckName "CmdPal MSIX Package" -Status 'Warning' -Message "No Command Palette MSIX packages found" + } + } + else { + Add-CheckResult -Category "Command Palette" -CheckName "CmdPal Module" -Status 'Warning' -Message "Command Palette module not found at: $cmdPalPath" + } +} + +function Test-ContextMenuPackages { + param( + [string]$InstallPath + ) + + # Context menu packages are installed as sparse packages + # These MSIX packages should be present in the installation + $contextMenuPackages = @{ + "ImageResizerContextMenuPackage.msix" = @{ Name = "Image Resizer context menu package"; Location = "Root" } + "FileLocksmithContextMenuPackage.msix" = @{ Name = "File Locksmith context menu package"; Location = "WinUI3Apps" } + "PowerRenameContextMenuPackage.msix" = @{ Name = "PowerRename context menu package"; Location = "WinUI3Apps" } + "NewPlusPackage.msix" = @{ Name = "New+ context menu package"; Location = "WinUI3Apps" } + } + + # Check for packages based on their expected location + foreach ($packageFile in $contextMenuPackages.Keys) { + $packageInfo = $contextMenuPackages[$packageFile] + + if ($packageInfo.Location -eq "Root") { + $packagePath = Join-Path $InstallPath $packageFile + } + else { + $packagePath = Join-Path $InstallPath "WinUI3Apps\$packageFile" + } + + if (Test-Path $packagePath) { + Add-CheckResult -Category "Context Menu Packages" -CheckName $packageInfo.Name -Status 'Pass' -Message "Context menu package found: $packagePath" + } + else { + Add-CheckResult -Category "Context Menu Packages" -CheckName $packageInfo.Name -Status 'Fail' -Message "Context menu package not found: $packagePath" + } + } +} + +# Main execution +function Main { + Write-StatusMessage "Starting PowerToys Installation Verification" -Level Info + Write-StatusMessage "Scope: $InstallScope" -Level Info + + # Check the specified scope - no fallbacks, only what installer should create + $installationFound = $false + + if ($InstallScope -eq 'PerMachine') { + if (Test-PowerToysInstallation -Scope 'PerMachine') { + $installationFound = $true + Test-RegistryHandlers -Scope 'PerMachine' + Test-DSCModule -Scope 'PerMachine' + $installPath = Get-PowerToysInstallPath -Scope 'PerMachine' + if ($installPath) { + Test-CommandPalettePackages -InstallPath $installPath + Test-ContextMenuPackages -InstallPath $installPath + } + } + } + else { # PerUser + if (Test-PowerToysInstallation -Scope 'PerUser') { + $installationFound = $true + Test-RegistryHandlers -Scope 'PerUser' + Test-DSCModule -Scope 'PerUser' + $installPath = Get-PowerToysInstallPath -Scope 'PerUser' + if ($installPath) { + Test-CommandPalettePackages -InstallPath $installPath + Test-ContextMenuPackages -InstallPath $installPath + } + } + } + + if ($installationFound) { + # Common tests (only run if installation found) + # Note: Scheduled tasks are not created by installer, they're created at runtime + } + + # Calculate overall status + if ($script:Results.Summary.FailedChecks -eq 0) { + if ($script:Results.Summary.WarningChecks -eq 0) { + $script:Results.Summary.OverallStatus = "Healthy" + } + else { + $script:Results.Summary.OverallStatus = "Healthy with Warnings" + } + } + else { + $script:Results.Summary.OverallStatus = "Issues Detected" + } + + # Display summary + Write-Host "`n" -NoNewline + Write-StatusMessage "=== VERIFICATION SUMMARY ===" -Level Info + Write-StatusMessage "Total Checks: $($script:Results.Summary.TotalChecks)" -Level Info + Write-StatusMessage "Passed: $($script:Results.Summary.PassedChecks)" -Level Success + Write-StatusMessage "Failed: $($script:Results.Summary.FailedChecks)" -Level Error + Write-StatusMessage "Warnings: $($script:Results.Summary.WarningChecks)" -Level Warning + Write-StatusMessage "Overall Status: $($script:Results.Summary.OverallStatus)" -Level $( + switch ($script:Results.Summary.OverallStatus) { + 'Healthy' { 'Success' } + 'Healthy with Warnings' { 'Warning' } + default { 'Error' } + } + ) + + Write-StatusMessage "PowerToys Installation Verification Complete" -Level Info +} + +# Run the main function +Main diff --git a/tools/build/BUILD-GUIDELINES.md b/tools/build/BUILD-GUIDELINES.md new file mode 100644 index 0000000000..eb484f3a75 --- /dev/null +++ b/tools/build/BUILD-GUIDELINES.md @@ -0,0 +1,48 @@ +# Build scripts – quick guideline + +Use these scripts to build PowerToys locally. They auto-detect your platform (x64/arm64), initialize the Visual Studio developer environment, and write helpful logs on failure. + +## Quick start (from cmd.exe) +- Fast essentials (runner + settings) and NuGet restore first: + - `tools\build\build-essentials.cmd` +- Build projects in the current folder: + - `tools\build\build.cmd` + +Tip: Add `D:\PowerToys\tools\build` to your PATH to use the wrappers anywhere. + +## When to use which +1) `build-essentials.ps1` + - Restores NuGet for `PowerToys.slnx` and builds essentials (runner, settings). + - Auto-detects Platform; initializes VS Dev environment automatically. + - Example (PowerShell): + - `./tools/build/build-essentials.ps1` + - `./tools/build/build-essentials.ps1 -Platform arm64 -Configuration Release` + +2) `build.ps1` (from any folder) + - Builds any `.sln/.csproj/.vcxproj` in the current directory. + - Auto-detects Platform; initializes VS Dev environment automatically. + - Accepts extra MSBuild args (forwarded to msbuild): + - `./tools/build/build.ps1 '/p:CIBuild=true' '/p:SomeProp=Value'` + - Restore only: + - `./tools/build/build.ps1 -RestoreOnly` + +3) `build-installer.ps1` (use with caution) + - Full local packaging pipeline (restore, build, sign MSIX, WiX v5 MSI/bootstrapper). + - Auto-inits VS Dev environment. Cleans some output (keeps *.exe) under `installer/`. + - Key options: `-PerUser true|false`, `-InstallerSuffix wix5|vnext`. + - Example: + - `./tools/build/build-installer.ps1 -Platform x64 -Configuration Release -PerUser true -InstallerSuffix wix5` + +## Logs and troubleshooting +- On failure, see logs next to the solution/project being built: + - `build.<configuration>.<platform>.all.log` — full text log + - `build.<configuration>.<platform>.errors.log` — errors only + - `build.<configuration>.<platform>.warnings.log` — warnings only + - `build.<configuration>.<platform>.trace.binlog` — open with MSBuild Structured Log Viewer +- VS environment init: + - Scripts try DevShell first (`Microsoft.VisualStudio.DevShell.dll` / `Enter-VsDevShell`), then fall back to `VsDevCmd.bat`. + - If VS isn't found, run from "Developer PowerShell for VS 2022" or "Developer PowerShell for VS", or ensure `vswhere.exe` exists under `Program Files (x86)\Microsoft Visual Studio\Installer`. + +## Notes +- Override platform explicitly with `-Platform x64|arm64` if needed. +- CMD wrappers: `build.cmd`, `build-essentials.cmd` forward all arguments to the PowerShell scripts. diff --git a/tools/build/Delete-Worktree.cmd b/tools/build/Delete-Worktree.cmd new file mode 100644 index 0000000000..edf14bb537 --- /dev/null +++ b/tools/build/Delete-Worktree.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %* diff --git a/tools/build/Delete-Worktree.ps1 b/tools/build/Delete-Worktree.ps1 new file mode 100644 index 0000000000..68f7c218d4 --- /dev/null +++ b/tools/build/Delete-Worktree.ps1 @@ -0,0 +1,130 @@ +<#! +.SYNOPSIS + Remove a git worktree (and optionally its local branch and orphan fork remote). + +.DESCRIPTION + Locates a worktree by branch/path pattern (supports wildcards). Ensures the primary repository + root is never removed. Optionally discards local changes with -Force. Deletes associated branch + unless -KeepBranch. If the branch tracked a non-origin remote with no remaining tracking + branches, that remote is removed unless -KeepRemote. + +.PARAMETER Pattern + Branch name or path fragment (wildcards * ? allowed). If multiple matches found they are listed + and no deletion occurs. + +.PARAMETER Force + Discard uncommitted changes and attempt aggressive cleanup on failure. + +.PARAMETER KeepBranch + Preserve the local branch (only remove the worktree directory entry). + +.PARAMETER KeepRemote + Preserve any orphan fork remote even if no branches still track it. + +.EXAMPLE + ./Delete-Worktree.ps1 -Pattern feature/login + +.EXAMPLE + ./Delete-Worktree.ps1 -Pattern fork-user-featureX -Force + +.EXAMPLE + ./Delete-Worktree.ps1 -Pattern hotfix -KeepBranch + +.NOTES + Manual recovery: + git worktree list --porcelain + git worktree prune + Remove-Item -LiteralPath <path> -Recurse -Force + git branch -D <branch> + git remote remove <remote> + git worktree prune +#> + +param( + [string] $Pattern, + [switch] $Force, + [switch] $KeepBranch, + [switch] $KeepRemote, + [switch] $Help +) +. "$PSScriptRoot/WorktreeLib.ps1" +if ($Help -or -not $Pattern) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return } +try { + $repoRoot = Get-RepoRoot + $entries = Get-WorktreeEntries + if (-not $entries -or $entries.Count -eq 0) { throw 'No worktrees found.' } + $hasWildcard = $Pattern -match '[\*\?]' + $matchPattern = if ($hasWildcard) { $Pattern } else { "*${Pattern}*" } + $found = $entries | Where-Object { $_.Branch -and ( $_.Branch -like $matchPattern -or $_.Path -like $matchPattern ) } + if (-not $found -or $found.Count -eq 0) { throw "No worktree matches pattern '$Pattern'" } + if ($found.Count -gt 1) { + Warn 'Pattern matches multiple worktrees:' + $found | ForEach-Object { Info (" {0} {1}" -f $_.Branch, $_.Path) } + return + } + $target = $found | Select-Object -First 1 + $branch = $target.Branch + $folder = $target.Path + if (-not $branch) { throw 'Resolved worktree has no branch (detached); refusing removal.' } + try { $folder = (Resolve-Path -LiteralPath $folder -ErrorAction Stop).ProviderPath } catch {} + $primary = (Resolve-Path -LiteralPath $repoRoot).ProviderPath + if ([IO.Path]::GetFullPath($folder).TrimEnd('\\/') -ieq [IO.Path]::GetFullPath($primary).TrimEnd('\\/')) { throw 'Refusing to remove the primary worktree (repository root).' } + $status = git -C $folder status --porcelain 2>$null + if ($LASTEXITCODE -ne 0) { throw "Unable to get git status for $folder" } + if (-not $Force -and $status) { throw 'Worktree has uncommitted changes. Use -Force to discard.' } + if ($Force -and $status) { + Warn '[Force] Discarding local changes' + git -C $folder reset --hard HEAD | Out-Null + git -C $folder clean -fdx | Out-Null + } + if ($Force) { git worktree remove --force $folder } else { git worktree remove $folder } + if ($LASTEXITCODE -ne 0) { + $exit1 = $LASTEXITCODE + $errMsg = "git worktree remove failed (exit $exit1)" + if ($Force) { + Warn 'Primary removal failed; performing aggressive fallback (Force implies brute).' + try { git -C $folder submodule deinit -f --all 2>$null | Out-Null } catch {} + try { git -C $folder clean -dfx 2>$null | Out-Null } catch {} + try { Get-ChildItem -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { try { $_.IsReadOnly = $false } catch {} } } catch {} + if (Test-Path $folder) { try { Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction Stop } catch { Err "Manual directory removal failed: $($_.Exception.Message)" } } + git worktree prune 2>$null | Out-Null + if (Test-Path $folder) { throw "$errMsg and aggressive cleanup did not fully remove directory: $folder" } else { Info "Aggressive cleanup removed directory $folder." } + } else { + throw "$errMsg. Rerun with -Force to attempt aggressive cleanup." + } + } + # Determine upstream before potentially deleting branch + $upRemote = Get-BranchUpstreamRemote -Branch $branch + $looksForkName = $branch -like 'fork-*' + + if (-not $KeepBranch) { + git branch -D $branch 2>$null | Out-Null + if (-not $KeepRemote -and $upRemote -and $upRemote -ne 'origin') { + $otherTracking = git for-each-ref --format='%(refname:short)|%(upstream:short)' refs/heads 2>$null | + Where-Object { $_ -and ($_ -notmatch "^$branch\|") } | + ForEach-Object { $parts = $_.Split('|',2); if ($parts[1] -match '^(?<r>[^/]+)/'){ $parts[0],$Matches.r } } | + Where-Object { $_[1] -eq $upRemote } + if (-not $otherTracking) { + Warn "Removing orphan remote '$upRemote' (no more tracking branches)" + git remote remove $upRemote 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "Failed to remove remote '$upRemote' (you may remove manually)." } + } else { Info "Remote '$upRemote' retained (other branches still track it)." } + } elseif ($looksForkName -and -not $KeepRemote -and -not $upRemote) { + Warn 'Branch looks like a fork branch (name pattern), but has no upstream remote; nothing to clean.' + } + } + + Info "Removed worktree ($branch) at $folder."; if (-not $KeepBranch) { Info 'Branch deleted.' } + Show-WorktreeExecutionSummary -CurrentBranch $branch +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual cleanup guidelines:' + Info ' git worktree list --porcelain' + Info ' git worktree prune' + Info ' # If still present:' + Info ' Remove-Item -LiteralPath <path> -Recurse -Force' + Info ' git branch -D <branch> (if you also want to drop local branch)' + Info ' git remote remove <remote> (if orphan fork remote remains)' + Info ' git worktree prune' + exit 1 +} diff --git a/tools/build/New-WorktreeFromBranch.cmd b/tools/build/New-WorktreeFromBranch.cmd new file mode 100644 index 0000000000..a1c2b9a624 --- /dev/null +++ b/tools/build/New-WorktreeFromBranch.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %* diff --git a/tools/build/New-WorktreeFromBranch.ps1 b/tools/build/New-WorktreeFromBranch.ps1 new file mode 100644 index 0000000000..d299e1a879 --- /dev/null +++ b/tools/build/New-WorktreeFromBranch.ps1 @@ -0,0 +1,78 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree for an existing local or remote (origin) branch. + +.DESCRIPTION + Normalizes origin/<name> to <name>. If the branch does not exist locally (and -NoFetch is not + provided) it will fetch and create a tracking branch from origin. Reuses any existing worktree + bound to the branch; otherwise creates a new one adjacent to the repository root. + +.PARAMETER Branch + Branch name (local or origin/<name> form) to materialize as a worktree. + +.PARAMETER VSCodeProfile + VS Code profile to open (Default). + +.PARAMETER NoFetch + Skip fetch if branch missing locally; script will error instead of creating it. + +.EXAMPLE + ./New-WorktreeFromBranch.ps1 -Branch feature/login + +.EXAMPLE + ./New-WorktreeFromBranch.ps1 -Branch origin/bugfix/nullref + +.EXAMPLE + ./New-WorktreeFromBranch.ps1 -Branch release/v1 -NoFetch + +.NOTES + Manual recovery: + git fetch origin && git checkout <branch> + git worktree add ../RepoName-XX <branch> + code ../RepoName-XX --profile Default +#> + +param( + [string] $Branch, + [Alias('Profile')][string] $VSCodeProfile = 'Default', + [switch] $NoFetch, + [switch] $Help +) +. "$PSScriptRoot/WorktreeLib.ps1" + +if ($Help -or -not $Branch) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return } + +# Normalize origin/<name> to <name> +if ($Branch -match '^(origin|upstream|main|master)/.+') { + if ($Branch -match '^(origin|upstream)/(.+)$') { $Branch = $Matches[2] } +} + +try { + git show-ref --verify --quiet "refs/heads/$Branch" + if ($LASTEXITCODE -ne 0) { + if (-not $NoFetch) { + Warn "Local branch '$Branch' not found; attempting remote fetch..." + git fetch --all --prune 2>$null | Out-Null + $remoteRef = "origin/$Branch" + git show-ref --verify --quiet "refs/remotes/$remoteRef" + if ($LASTEXITCODE -eq 0) { + git branch --track $Branch $remoteRef 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to create tracking branch '$Branch' from $remoteRef" } + Info "Created local tracking branch '$Branch' from $remoteRef." + } else { throw "Branch '$Branch' not found locally or on origin. Use git fetch or specify a valid branch." } + } else { throw "Branch '$Branch' does not exist locally (remote fetch disabled with -NoFetch)." } + } + + New-WorktreeForExistingBranch -Branch $Branch -VSCodeProfile $VSCodeProfile + $after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $Branch } + $path = ($after | Select-Object -First 1).Path + Show-WorktreeExecutionSummary -CurrentBranch $Branch -WorktreePath $path +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual steps:' + Info ' git fetch origin' + Info " git checkout $Branch (or: git branch --track $Branch origin/$Branch)" + Info ' git worktree add ../<Repo>-XX <branch>' + Info ' code ../<Repo>-XX' + exit 1 +} diff --git a/tools/build/New-WorktreeFromFork.cmd b/tools/build/New-WorktreeFromFork.cmd new file mode 100644 index 0000000000..be8bc05c0f --- /dev/null +++ b/tools/build/New-WorktreeFromFork.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %* diff --git a/tools/build/New-WorktreeFromFork.ps1 b/tools/build/New-WorktreeFromFork.ps1 new file mode 100644 index 0000000000..ccd26631e4 --- /dev/null +++ b/tools/build/New-WorktreeFromFork.ps1 @@ -0,0 +1,127 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree from a branch in a personal fork: <ForkUser>:<ForkBranch>. + +.DESCRIPTION + Adds a transient uniquely named fork remote (fork-xxxxx) unless -RemoteName specified. + Fetches only the target branch (fallback full fetch once if needed), creates a local tracking + branch (fork-<user>-<sanitized-branch> or custom alias), and delegates worktree creation/reuse + to shared helpers in WorktreeLib. + +.PARAMETER Spec + Fork spec in the form <ForkUser>:<ForkBranch>. + +.PARAMETER ForkRepo + Repository name in the fork (default: PowerToys). + +.PARAMETER RemoteName + Desired remote name; if left as 'fork' a unique suffix will be generated. + +.PARAMETER BranchAlias + Optional local branch name override; defaults to fork-<user>-<sanitized-branch>. + +.PARAMETER VSCodeProfile + VS Code profile to pass through to worktree opening (Default profile by default). + +.EXAMPLE + ./New-WorktreeFromFork.ps1 -Spec alice:feature/new-ui + +.EXAMPLE + ./New-WorktreeFromFork.ps1 -Spec bob:bugfix/crash -BranchAlias fork-bob-crash + +.NOTES + Manual equivalent if this script fails: + git remote add fork-temp https://github.com/<user>/<repo>.git + git fetch fork-temp + git branch --track fork-<user>-<branch> fork-temp/<branch> + git worktree add ../Repo-XX fork-<user>-<branch> + code ../Repo-XX +#> +param( + [string] $Spec, + [string] $ForkRepo = 'PowerToys', + [string] $RemoteName = 'fork', + [string] $BranchAlias, + [Alias('Profile')][string] $VSCodeProfile = 'Default', + [switch] $Help +) + +. "$PSScriptRoot/WorktreeLib.ps1" +if ($Help -or -not $Spec) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return } + +$repoRoot = git rev-parse --show-toplevel 2>$null +if (-not $repoRoot) { throw 'Not inside a git repository.' } + +# Parse spec +if ($Spec -notmatch '^[^:]+:.+$') { throw "Spec must be <ForkUser>:<ForkBranch>, got '$Spec'" } +$ForkUser,$ForkBranch = $Spec.Split(':',2) + +$forkUrl = "https://github.com/$ForkUser/$ForkRepo.git" + +# Auto-suffix remote name if user left default 'fork' +$allRemotes = @(git remote 2>$null) +if ($RemoteName -eq 'fork') { + $chars = 'abcdefghijklmnopqrstuvwxyz0123456789' + do { + $suffix = -join ((1..5) | ForEach-Object { $chars[(Get-Random -Max $chars.Length)] }) + $candidate = "fork-$suffix" + } while ($allRemotes -contains $candidate) + $RemoteName = $candidate + Info "Assigned unique remote name: $RemoteName" +} + +$existing = $allRemotes | Where-Object { $_ -eq $RemoteName } +if (-not $existing) { + Info "Adding remote $RemoteName -> $forkUrl" + git remote add $RemoteName $forkUrl | Out-Null +} else { + $currentUrl = git remote get-url $RemoteName 2>$null + if ($currentUrl -ne $forkUrl) { Warn "Remote $RemoteName points to $currentUrl (expected $forkUrl). Using existing." } +} + +## Note: Verbose fetch & stale lock auto-clean removed for simplicity. + +try { + Info "Fetching branch '$ForkBranch' from $RemoteName..." + & git fetch $RemoteName $ForkBranch 1>$null 2>$null + $fetchExit = $LASTEXITCODE + if ($fetchExit -ne 0) { + # Retry full fetch silently once (covers servers not supporting branch-only fetch syntax) + & git fetch $RemoteName 1>$null 2>$null + $fetchExit = $LASTEXITCODE + } + if ($fetchExit -ne 0) { throw "Fetch failed for remote $RemoteName (branch $ForkBranch)." } + + $remoteRef = "refs/remotes/$RemoteName/$ForkBranch" + git show-ref --verify --quiet $remoteRef + if ($LASTEXITCODE -ne 0) { throw "Remote branch not found: $RemoteName/$ForkBranch" } + + $sanitizedBranch = ($ForkBranch -replace '[\\/:*?"<>|]','-') + if ($BranchAlias) { $localBranch = $BranchAlias } else { $localBranch = "fork-$ForkUser-$sanitizedBranch" } + + git show-ref --verify --quiet "refs/heads/$localBranch" + if ($LASTEXITCODE -ne 0) { + Info "Creating local tracking branch $localBranch from $RemoteName/$ForkBranch" + git branch --track $localBranch "$RemoteName/$ForkBranch" 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to create local tracking branch $localBranch" } + } else { Info "Local branch $localBranch already exists." } + + New-WorktreeForExistingBranch -Branch $localBranch -VSCodeProfile $VSCodeProfile + # Ensure upstream so future 'git push' works + Set-BranchUpstream -LocalBranch $localBranch -RemoteName $RemoteName -RemoteBranchPath $ForkBranch + $after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $localBranch } + $path = ($after | Select-Object -First 1).Path + Show-WorktreeExecutionSummary -CurrentBranch $localBranch -WorktreePath $path + Warn "Remote $RemoteName ready (URL: $forkUrl)" + $hasUp = git rev-parse --abbrev-ref --symbolic-full-name "$localBranch@{upstream}" 2>$null + if ($hasUp) { Info "Push with: git push (upstream: $hasUp)" } else { Warn 'Upstream not set; run: git push -u <remote> <local>:<remoteBranch>' } +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual steps:' + Info " git remote add temp-fork $forkUrl" + Info " git fetch temp-fork" + Info " git branch --track fork-<user>-<branch> temp-fork/$ForkBranch" + Info ' git worktree add ../<Repo>-XX fork-<user>-<branch>' + Info ' code ../<Repo>-XX' + exit 1 +} diff --git a/tools/build/New-WorktreeFromIssue.cmd b/tools/build/New-WorktreeFromIssue.cmd new file mode 100644 index 0000000000..6aba21652c --- /dev/null +++ b/tools/build/New-WorktreeFromIssue.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +set SCRIPT_DIR=%~dp0 +pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %* diff --git a/tools/build/New-WorktreeFromIssue.ps1 b/tools/build/New-WorktreeFromIssue.ps1 new file mode 100644 index 0000000000..c5523fcd13 --- /dev/null +++ b/tools/build/New-WorktreeFromIssue.ps1 @@ -0,0 +1,78 @@ +<#! +.SYNOPSIS + Create (or reuse) a worktree for a new issue branch derived from a base ref. + +.DESCRIPTION + Composes a branch name as issue/<number> or issue/<number>-<slug> (slug from optional -Title). + If the branch does not already exist, it is created from -Base (default origin/main). Then a + worktree is created or reused. + +.PARAMETER Number + Issue number used to construct the branch name. + +.PARAMETER Title + Optional descriptive title; slug into the branch name. + +.PARAMETER Base + Base ref to branch from (default origin/main). + +.PARAMETER VSCodeProfile + VS Code profile to open (Default). + +.EXAMPLE + ./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch" + +.EXAMPLE + ./New-WorktreeFromIssue.ps1 -Number 42 -Base origin/develop + +.NOTES + Manual recovery: + git fetch origin + git checkout -b issue/<num>-<slug> <base> + git worktree add ../Repo-XX issue/<num>-<slug> + code ../Repo-XX +#> + +param( + [int] $Number, + [string] $Title, + [string] $Base = 'origin/main', + [Alias('Profile')][string] $VSCodeProfile = 'Default', + [switch] $Help +) +. "$PSScriptRoot/WorktreeLib.ps1" +$scriptPath = $MyInvocation.MyCommand.Path +if ($Help -or -not $Number) { Show-FileEmbeddedHelp -ScriptPath $scriptPath; return } + +# Compose branch name +if ($Title) { + $slug = ($Title -replace '[^\w\- ]','').ToLower() -replace ' +','-' + $branch = "issue/$Number-$slug" +} else { + $branch = "issue/$Number" +} + +try { + # Create branch if missing + git show-ref --verify --quiet "refs/heads/$branch" + if ($LASTEXITCODE -ne 0) { + Info "Creating branch $branch from $Base" + git branch $branch $Base 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to create branch $branch from $Base" } + } else { + Info "Branch $branch already exists locally." + } + + New-WorktreeForExistingBranch -Branch $branch -VSCodeProfile $VSCodeProfile + $after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $branch } + $path = ($after | Select-Object -First 1).Path + Show-WorktreeExecutionSummary -CurrentBranch $branch -WorktreePath $path +} catch { + Err "Error: $($_.Exception.Message)" + Warn 'Manual steps:' + Info " git fetch origin" + Info " git checkout -b $branch $Base (if branch missing)" + Info " git worktree add ../<Repo>-XX $branch" + Info ' code ../<Repo>-XX' + exit 1 +} diff --git a/tools/build/Worktree-Guidelines.md b/tools/build/Worktree-Guidelines.md new file mode 100644 index 0000000000..bccd80ab9f --- /dev/null +++ b/tools/build/Worktree-Guidelines.md @@ -0,0 +1,94 @@ +# PowerToys Worktree Helper Scripts + +This folder contains helper scripts to create and manage parallel Git worktree for developing multiple changes (including Copilot suggestions) concurrently without cloning the full repository each time. + +## Why worktree? +Git worktree let you have several checked‑out branches sharing a single `.git` object store. Benefits: +- Fast context switching: no re-clone, no duplicate large binary/object downloads. +- Lower disk usage versus multiple full clones. +- Keeps each change isolated in its own folder so you can run builds/tests independently. +- Enables working in parallel with Copilot generated branches (e.g., feature + quick fix + perf experiment) while the main clone stays clean. + +Recommended: keep active parallel worktree(s) to **≤ 3** per developer to reduce cognitive load and avoid excessive incremental build invalidations. + +## Scripts Overview +| Script | Purpose | +|--------|---------| +| `New-WorktreeFromFork.ps1/.cmd` | Create a worktree from a branch in a personal fork (`<User>:<branch>` spec). Adds a temporary unique remote (e.g. `fork-abc12`). | +| `New-WorktreeFromBranch.ps1/.cmd` | Create/reuse a worktree for an existing local or remote (origin) branch. Can normalize `origin/branch` to `branch`. | +| `New-WorktreeFromIssue.ps1/.cmd` | Start a new issue branch from a base (default `origin/main`) using naming `issue/<number>-<slug>`. | +| `Delete-Worktree.ps1/.cmd` | Remove a worktree and optionally its local branch / orphan fork remote. | +| `WorktreeLib.ps1` | Shared helpers: unique folder naming, worktree listing, upstream setup, summary output, logging helpers. | + +## Typical Flows +### 1. Create from a fork branch +``` +./New-WorktreeFromFork.ps1 -Spec alice:feature/perf-tweak +``` +Creates remote `fork-xxxxx`, fetches just that branch, creates local branch `fork-alice-feature-perf-tweak`, makes a new worktree beside the repo root. + +### 2. Create from an existing or remote branch +``` +./New-WorktreeFromBranch.ps1 -Branch origin/feature/new-ui +``` +Fetches if needed and creates a tracking branch if missing, then creates/reuses the worktree. + +### 3. Start a new issue branch +``` +./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch" +``` +Creates branch `issue/1234-crash-on-launch` off `origin/main` (or `-Base`), then worktree. + +### 4. Delete a worktree when done +``` +./Delete-Worktree.ps1 -Pattern feature/perf-tweak +``` +If only one match, removes the worktree directory. Add `-Force` to discard local changes. Use `-KeepBranch` if you still need the branch, `-KeepRemote` to retain a fork remote. + +## After Creating a Worktree +Inside the new worktree directory: +1. Run the minimal build bootstrap in VSCode terminal: +``` +tools\build\build-essentials.cmd +``` +2. Build only the module(s) you need (e.g., open solution filter or run targeted project build) instead of a full PowerToys build. This speeds iteration and reduces noise. +3. Make changes, commit, push. +4. Finally delete the worktree when done. + +## Naming & Locations +- Worktree is created as sibling folders of the repo root (e.g., `PowerToys` + `PowerToys-ab12`), using a hash/short pattern to avoid collisions. +- Fork-based branches get local names `fork-<user>-<sanitized-branch>`. +- Issue branches: `issue/<number>` or `issue/<number>-<slug>`. + +## Scenarios Covered / Limitations +Covered scenarios: +1. From a fork branch (personal fork on GitHub). +2. From an existing local or origin remote branch. +3. Creating a new branch for an issue. + +Not covered (manual steps needed): +- Creating from a non-origin upstream other than a fork (add remote manually then use branch script). +- Batch creation of multiple worktree in one command. +- Automatic rebase / sync of many worktree at once (do that manually or script separately). + +## Best Practices +- Keep ≤ 3 active parallel worktree(s) (e.g., main dev, a long-lived feature, a quick fix / experiment) plus the root clone. +- Delete stale worktree early; each adds file watchers & potential incremental build churn. +- Avoid editing the same file across multiple worktree simultaneously to reduce merge friction. +- Run `git fetch --all --prune` periodically in the primary repo, not in every worktree. + +## Troubleshooting +| Symptom | Hint | +|---------|------| +| Fetch failed for fork remote | Branch name typo or fork private without auth. Try manual `git fetch <remote> <branch>`. +| Cannot lock ref *.lock | Stale lock: run `git worktree prune` or manually delete the `.lock` file then retry. +| Worktree already exists error | Use `git worktree list` to locate existing path; open that folder instead of creating a duplicate. +| Local branch missing for remote | Use `git branch --track <name> origin/<name>` then re-run the branch script. + +## Security & Safety Notes +- Scripts avoid force-deleting unless you pass `-Force` (Delete script). +- No network credentials are stored; they rely on your existing Git credential helper. +- Always review a new fork remote URL before pushing. + +--- +Maintainers: Keep the scripts lean; avoid adding heavy dependencies or global state. Update this doc if parameters or flows change. diff --git a/tools/build/WorktreeLib.ps1 b/tools/build/WorktreeLib.ps1 new file mode 100644 index 0000000000..01883115d1 --- /dev/null +++ b/tools/build/WorktreeLib.ps1 @@ -0,0 +1,151 @@ +# WorktreeLib.ps1 - shared helpers + +function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan } +function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow } +function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red } + +function Get-RepoRoot { + $root = git rev-parse --show-toplevel 2>$null + if (-not $root) { throw 'Not inside a git repository.' } + return $root +} + +function Get-WorktreeBasePath { + param([string]$RepoRoot) + # Always use parent of repo root (folder that contains the main repo directory) + $parent = Split-Path -Parent $RepoRoot + if (-not (Test-Path $parent)) { throw "Parent path for repo root not found: $parent" } + return (Resolve-Path $parent).ProviderPath +} + +function Get-ShortHashFromString { + param([Parameter(Mandatory)][string]$Text) + $md5 = [System.Security.Cryptography.MD5]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + $digest = $md5.ComputeHash($bytes) + return -join ($digest[0..1] | ForEach-Object { $_.ToString('x2') }) + } finally { $md5.Dispose() } +} + +function Initialize-SubmodulesIfAny { + param([string]$RepoRoot,[string]$WorktreePath) + $hasGitmodules = Test-Path (Join-Path $RepoRoot '.gitmodules') + if ($hasGitmodules) { + git -C $WorktreePath submodule sync --recursive | Out-Null + git -C $WorktreePath submodule update --init --recursive | Out-Null + return $true + } + return $false +} + +function New-WorktreeForExistingBranch { + param( + [Parameter(Mandatory)][string] $Branch, + [Parameter(Mandatory)][string] $VSCodeProfile + ) + $repoRoot = Get-RepoRoot + git show-ref --verify --quiet "refs/heads/$Branch"; if ($LASTEXITCODE -ne 0) { throw "Branch '$Branch' does not exist locally." } + + # Detect existing worktree for this branch + $entries = Get-WorktreeEntries + $match = $entries | Where-Object { $_.Branch -eq $Branch } | Select-Object -First 1 + if ($match) { + Info "Reusing existing worktree for '$Branch': $($match.Path)" + code --new-window "$($match.Path)" --profile "$VSCodeProfile" | Out-Null + return + } + + $safeBranch = ($Branch -replace '[\\/:*?"<>|]','-') + $hash = Get-ShortHashFromString -Text $safeBranch + $folderName = "$(Split-Path -Leaf $repoRoot)-$hash" + $base = Get-WorktreeBasePath -RepoRoot $repoRoot + $folder = Join-Path $base $folderName + git worktree add $folder $Branch + $inited = Initialize-SubmodulesIfAny -RepoRoot $repoRoot -WorktreePath $folder + code --new-window "$folder" --profile "$VSCodeProfile" | Out-Null + Info "Created worktree for branch '$Branch' at $folder."; if ($inited) { Info 'Submodules initialized.' } +} + +function Get-WorktreeEntries { + # Returns objects with Path and Branch (branch without refs/heads/ prefix) + $lines = git worktree list --porcelain 2>$null + if (-not $lines) { return @() } + $entries = @(); $current=@{} + foreach($l in $lines){ + if ($l -eq '') { if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) }; $current=@{}; continue } + if ($l -like 'worktree *'){ $current.path = ($l -split ' ',2)[1] } + elseif ($l -like 'branch *'){ $current.branch = ($l -split ' ',2)[1].Trim() } + } + if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) } + return ($entries | Sort-Object Path,Branch -Unique) +} + +function Get-BranchUpstreamRemote { + param([Parameter(Mandatory)][string]$Branch) + # Returns remote name if branch has an upstream, else $null + $ref = git rev-parse --abbrev-ref --symbolic-full-name "$Branch@{upstream}" 2>$null + if ($LASTEXITCODE -ne 0 -or -not $ref) { return $null } + if ($ref -match '^(?<remote>[^/]+)/.+$') { return $Matches.remote } + return $null +} + +function Show-IssueFarmCommonFooter { + Info '--- Common Manual Steps ---' + Info 'List worktree: git worktree list --porcelain' + Info 'List branches: git branch -vv' + Info 'List remotes: git remote -v' + Info 'Prune worktree: git worktree prune' + Info 'Remove worktree dir: Remove-Item -Recurse -Force <path>' + Info 'Reset branch: git reset --hard HEAD' +} + +function Show-WorktreeExecutionSummary { + param( + [string]$CurrentBranch, + [string]$WorktreePath + ) + Info '--- Summary ---' + if ($CurrentBranch) { Info "Branch: $CurrentBranch" } + if ($WorktreePath) { Info "Worktree path: $WorktreePath" } + $entries = Get-WorktreeEntries + if ($entries.Count -gt 0) { + Info 'Existing worktrees:' + $entries | ForEach-Object { Info (" {0} -> {1}" -f $_.Branch,$_.Path) } + } + Info 'Remotes:' + git remote -v 2>$null | Sort-Object | Get-Unique | ForEach-Object { Info " $_" } +} + +function Show-FileEmbeddedHelp { + param([string]$ScriptPath) + if (-not (Test-Path $ScriptPath)) { throw "Cannot load help; file missing: $ScriptPath" } + $content = Get-Content -LiteralPath $ScriptPath -ErrorAction Stop + $inBlock=$false + foreach($line in $content){ + if ($line -match '^<#!') { $inBlock=$true; continue } + if ($line -match '#>$') { break } + if ($inBlock) { Write-Host $line } + } + Show-IssueFarmCommonFooter +} + +function Set-BranchUpstream { + param( + [Parameter(Mandatory)][string]$LocalBranch, + [Parameter(Mandatory)][string]$RemoteName, + [Parameter(Mandatory)][string]$RemoteBranchPath + ) + $current = git rev-parse --abbrev-ref --symbolic-full-name "$LocalBranch@{upstream}" 2>$null + if (-not $current) { + Info "Setting upstream: $LocalBranch -> $RemoteName/$RemoteBranchPath" + git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "Failed to set upstream automatically. Run: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" } + return + } + if ($current -ne "$RemoteName/$RemoteBranchPath") { + Warn "Upstream mismatch ($current != $RemoteName/$RemoteBranchPath); updating..." + git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Warn "Could not update upstream; manual fix: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" } else { Info 'Upstream corrected.' } + } else { Info "Upstream already: $current" } +} diff --git a/tools/build/build-common.ps1 b/tools/build/build-common.ps1 new file mode 100644 index 0000000000..cadeba1542 --- /dev/null +++ b/tools/build/build-common.ps1 @@ -0,0 +1,272 @@ +<# +.SYNOPSIS +Shared build helper functions for PowerToys build scripts. + +.DESCRIPTION +This file provides reusable helper functions used by the build scripts: +- Get-BuildPaths: returns ScriptDir, OriginalCwd, RepoRoot (repo root detection) +- RunMSBuild: wrapper around msbuild.exe (accepts optional Platform/Configuration) +- RestoreThenBuild: performs restore and optionally builds the solution/project +- BuildProjectsInDirectory: discovers and builds local .sln/.csproj/.vcxproj files +- Ensure-VsDevEnvironment: initializes the Visual Studio developer environment when possible. + It prefers the DevShell PowerShell module (Microsoft.VisualStudio.DevShell.dll / Enter-VsDevShell), + falls back to running VsDevCmd.bat and importing its environment into the current PowerShell session, + and restores the caller's working directory after initialization. + +USAGE +Dot-source this file from a script to load helpers: +. "$PSScriptRoot\build-common.ps1" + +ERROR DETAILS +When a build fails, check the logs written next to the solution/project folder: +- build.<configuration>.<platform>.all.log — full MSBuild text log +- build.<configuration>.<platform>.errors.log — extracted errors only +- build.<configuration>.<platform>.warnings.log — extracted warnings only +- build.<configuration>.<platform>.trace.binlog — binary log (open with the MSBuild Structured Log Viewer) + +.NOTES +Do not execute this file directly; dot-source it from `build.ps1` or `build-installer.ps1` so helpers are available in your script scope. +#> + +function RunMSBuild { + param ( + [string]$Solution, + [string]$ExtraArgs, + [string]$Platform, + [string]$Configuration + ) + + # Prefer the solution's folder for logs; fall back to current directory + $logRoot = Split-Path -Path $Solution + if (-not $logRoot) { $logRoot = '.' } + + $cfg = $null + if ($Configuration) { $cfg = $Configuration.ToLower() } else { $cfg = 'unknown' } + $plat = $null + if ($Platform) { $plat = $Platform.ToLower() } else { $plat = 'unknown' } + + $allLog = Join-Path $logRoot ("build.{0}.{1}.all.log" -f $cfg, $plat) + $warningLog = Join-Path $logRoot ("build.{0}.{1}.warnings.log" -f $cfg, $plat) + $errorsLog = Join-Path $logRoot ("build.{0}.{1}.errors.log" -f $cfg, $plat) + $binLog = Join-Path $logRoot ("build.{0}.{1}.trace.binlog" -f $cfg, $plat) + + $base = @( + $Solution + "/p:Platform=$Platform" + "/p:Configuration=$Configuration" + "/verbosity:normal" + '/clp:Summary;PerformanceSummary;ErrorsOnly;WarningsOnly' + "/fileLoggerParameters:LogFile=$allLog;Verbosity=detailed" + "/fileLoggerParameters1:LogFile=$warningLog;WarningsOnly" + "/fileLoggerParameters2:LogFile=$errorsLog;ErrorsOnly" + "/bl:$binLog" + '/nologo' + ) + + $cmd = $base + ($ExtraArgs -split ' ') + Write-Host (("[MSBUILD] {0}" -f ($cmd -join ' '))) + + Push-Location $script:RepoRoot + try { + & msbuild.exe @cmd + if ($LASTEXITCODE -ne 0) { + Write-Error (("Build failed: {0} {1}`nSee logs:`n All: {2}`n Errors: {3}`n Binlog: {4}" -f $Solution, $ExtraArgs, $allLog, $errorsLog, $binLog)) + exit $LASTEXITCODE + } + } finally { + Pop-Location + } +} + +function RestoreThenBuild { + param ( + [string]$Solution, + [string]$ExtraArgs, + [string]$Platform, + [string]$Configuration, + [bool]$RestoreOnly=$false + ) + + $restoreArgs = '/t:restore /p:RestorePackagesConfig=true' + if ($ExtraArgs) { $restoreArgs = "$restoreArgs $ExtraArgs" } + RunMSBuild $Solution $restoreArgs $Platform $Configuration + + if (-not $RestoreOnly) { + $buildArgs = '/m' + if ($ExtraArgs) { $buildArgs = "$buildArgs $ExtraArgs" } + RunMSBuild $Solution $buildArgs $Platform $Configuration + } +} + +function BuildProjectsInDirectory { + param( + [string]$DirectoryPath, + [string]$ExtraArgs, + [string]$Platform, + [string]$Configuration, + [switch]$RestoreOnly + ) + + if (-not (Test-Path $DirectoryPath)) { + return $false + } + + $files = @() + try { + $files = Get-ChildItem -Path (Join-Path $DirectoryPath '*') -Include *.sln,*.slnx,*.csproj,*.vcxproj -File -ErrorAction SilentlyContinue + } catch { + $files = @() + } + + if (-not $files -or $files.Count -eq 0) { + return $false + } + + $names = ($files | ForEach-Object { $_.Name }) -join ', ' + Write-Host ("[LOCAL BUILD] Found {0} project(s) in {1}: {2}" -f $files.Count, $DirectoryPath, $names) + + $preferredOrder = @('.sln', '.csproj', '.vcxproj') + $files = $files | Sort-Object @{Expression = { [array]::IndexOf($preferredOrder, $_.Extension.ToLower()) }} + + foreach ($f in $files) { + Write-Host ("[LOCAL BUILD] Building {0}" -f $f.FullName) + if ($f.Extension -eq '.sln') { + RestoreThenBuild $f.FullName $ExtraArgs $Platform $Configuration $RestoreOnly + } else { + $buildArgs = '/m' + if ($ExtraArgs) { $buildArgs = "$buildArgs $ExtraArgs" } + RunMSBuild $f.FullName $buildArgs $Platform $Configuration + } + } + + return $true +} + +function Get-DefaultPlatform { + <# + Returns a default target platform string based on the host machine (x64, arm64, x86). + #> + try { + $envArch = $env:PROCESSOR_ARCHITECTURE + if ($envArch) { $envArch = $envArch.ToLower() } + if ($envArch -eq 'amd64' -or $envArch -eq 'x86_64') { return 'x64' } + if ($envArch -match 'arm64') { return 'arm64' } + if ($envArch -eq 'x86') { return 'x86' } + + if ($env:PROCESSOR_ARCHITEW6432) { + $envArch2 = $env:PROCESSOR_ARCHITEW6432.ToLower() + if ($envArch2 -eq 'amd64') { return 'x64' } + if ($envArch2 -match 'arm64') { return 'arm64' } + } + + try { + $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + switch ($osArch.ToString().ToLower()) { + 'x64' { return 'x64' } + 'arm64' { return 'arm64' } + 'x86' { return 'x86' } + } + } catch { + # ignore - RuntimeInformation may not be available + } + } catch { + # ignore any errors and fall back + } + + return 'x64' +} + +function Ensure-VsDevEnvironment { + $OriginalLocationForVsInit = Get-Location + try { + + if ($env:VSINSTALLDIR -or $env:VCINSTALLDIR -or $env:DevEnvDir -or $env:VCToolsInstallDir) { + Write-Host "[VS] VS developer environment already present" + return $true + } + + # Locate vswhere if available + $vswhereCandidates = @( + "$env:ProgramFiles (x86)\Microsoft Visual Studio\Installer\vswhere.exe", + "$env:ProgramFiles\Microsoft Visual Studio\Installer\vswhere.exe" + ) + $vswhere = $vswhereCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($vswhere) { Write-Host "[VS] vswhere found: $vswhere" } else { Write-Host "[VS] vswhere not found" } + + $instPaths = @() + if ($vswhere) { + # First try with the VC tools requirement (preferred) + try { $p = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null; if ($p) { $instPaths += $p } } catch {} + # Fallback: try without -requires to find any VS installations + if (-not $instPaths) { + try { $p2 = & $vswhere -latest -products * -property installationPath 2>$null; if ($p2) { $instPaths += $p2 } } catch {} + } + } + + # Add explicit common year-based candidates as a last resort + if (-not $instPaths) { + $explicit = @( + "$env:ProgramFiles (x86)\Microsoft Visual Studio\2022\Community", + "$env:ProgramFiles (x86)\Microsoft Visual Studio\2022\Professional", + "$env:ProgramFiles (x86)\Microsoft Visual Studio\2022\Enterprise", + "$env:ProgramFiles\Microsoft Visual Studio\2022\Community", + "$env:ProgramFiles\Microsoft Visual Studio\2022\Professional", + "$env:ProgramFiles\Microsoft Visual Studio\2022\Enterprise" + ) + foreach ($c in $explicit) { if (Test-Path $c) { $instPaths += $c } } + } + + if (-not $instPaths -or $instPaths.Count -eq 0) { + Write-Warning "[VS] Could not locate Visual Studio installation (no candidates found)" + return $false + } + + # Try each candidate installation path until one works + foreach ($inst in $instPaths) { + if (-not $inst) { continue } + Write-Host "[VS] Checking candidate: $inst" + + $devDll = Join-Path $inst 'Common7\Tools\Microsoft.VisualStudio.DevShell.dll' + if (Test-Path $devDll) { + try { + Import-Module $devDll -DisableNameChecking -ErrorAction Stop + + # Call Enter-VsDevShell using only the install path to avoid parameter name differences + try { + Enter-VsDevShell -VsInstallPath $inst -ErrorAction Stop + Write-Host "[VS] Entered Visual Studio DevShell at $inst" + return $true + } catch { + Write-Warning ("[VS] DevShell import/Enter-VsDevShell failed: {0}" -f $_) + } + } catch { + Write-Warning ("[VS] DevShell import failed: {0}" -f $_) + } + } + + $vsDevCmd = Join-Path $inst 'Common7\Tools\VsDevCmd.bat' + if (Test-Path $vsDevCmd) { + Write-Host "[VS] Running VsDevCmd.bat and importing environment from $vsDevCmd" + try { + $cmdOut = cmd.exe /c "`"$vsDevCmd`" && set" + foreach ($line in $cmdOut) { + $parts = $line -split('=',2) + if ($parts.Length -eq 2) { + try { [Environment]::SetEnvironmentVariable($parts[0], $parts[1], 'Process') } catch {} + } + } + Write-Host "[VS] Imported environment from VsDevCmd.bat at $inst" + return $true + } catch { + Write-Warning ("[VS] Failed to run/import VsDevCmd.bat at {0}: {1}" -f $inst, $_) + } + } + } + + Write-Warning "[VS] Neither DevShell module nor VsDevCmd.bat found in any candidate paths" + return $false + + } finally { + try { Set-Location $OriginalLocationForVsInit } catch {} + } +} diff --git a/tools/build/build-essentials.cmd b/tools/build/build-essentials.cmd new file mode 100644 index 0000000000..db51faaa9a --- /dev/null +++ b/tools/build/build-essentials.cmd @@ -0,0 +1,5 @@ +@echo off +REM Wrapper to run build-essentials.ps1 from cmd.exe +set SCRIPT_DIR=%~dp0 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%build-essentials.ps1" %* +exit /b %ERRORLEVEL% diff --git a/tools/build/build-essentials.ps1 b/tools/build/build-essentials.ps1 index 81a3c6733b..37bf0a5cde 100644 --- a/tools/build/build-essentials.ps1 +++ b/tools/build/build-essentials.ps1 @@ -1,16 +1,74 @@ -cd $PSScriptRoot -cd ..\.. -$cwd = Get-Location -$SolutionDir = $cwd,"" -join "\" -cd $SolutionDir -$BuildArgs = "/p:Configuration=Release /p:Platform=x64 /p:BuildProjectReferences=false /p:SolutionDir=$SolutionDir" +<# +.SYNOPSIS +Build essential native PowerToys projects (runner and settings), restoring NuGet packages first. -$ProjectsToBuild = - ".\src\runner\runner.vcxproj", - ".\src\modules\shortcut_guide\shortcut_guide.vcxproj", - ".\src\modules\fancyzones\lib\FancyZonesLib.vcxproj", - ".\src\modules\fancyzones\dll\FancyZonesModule.vcxproj" +.DESCRIPTION +Lightweight script to build a small set of essential C++ projects used by PowerToys' runner and native modules. This script first restores NuGet packages for the full solution (`PowerToys.slnx`) and then builds the runner and settings projects. Intended for fast local builds during development. -$ProjectsToBuild | % { - Invoke-Expression "msbuild $_ $BuildArgs" -} \ No newline at end of file +.PARAMETER Platform +Target platform for the build (for example: 'x64', 'arm64'). If omitted the script will attempt to auto-detect the host platform. + +.PARAMETER Configuration +Build configuration (for example: 'Debug' or 'Release'). Default is 'Debug'. + +.EXAMPLE +.\tools\build\build-essentials.ps1 +Restores packages for the solution and builds the default set of native projects using the auto-detected platform and Debug configuration. + +.EXAMPLE +.\tools\build\build-essentials.ps1 -Platform arm64 -Configuration Release +Restores packages and builds the essentials in Release mode for ARM64, even if your machine is running on x64. + +.NOTES +- This script dot-sources `build-common.ps1` and uses the shared helper `RunMSBuild`. +- It will call `RestoreThenBuild 'PowerToys.slnx'` before building the essential projects to ensure NuGet packages are restored. +- The script attempts to locate the repository root automatically and can be run from any folder inside the repo. +#> + +param ( + [string]$Platform = '', + [string]$Configuration = 'Debug' +) + +# Find repository root starting from the script location +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = $ScriptDir +while ($repoRoot -and -not (Test-Path (Join-Path $repoRoot "PowerToys.slnx"))) { + $parent = Split-Path -Parent $repoRoot + if ($parent -eq $repoRoot) { + Write-Error "Could not find PowerToys repository root." + exit 1 + } + $repoRoot = $parent +} + +# Export script-scope variables used by build-common helpers +Set-Variable -Name RepoRoot -Value $repoRoot -Scope Script -Force + +# Load shared helpers +. "$PSScriptRoot\build-common.ps1" + +# Initialize Visual Studio dev environment +if (-not (Ensure-VsDevEnvironment)) { exit 1 } + +# If platform not provided, auto-detect from host +if (-not $Platform -or $Platform -eq '') { + try { + $Platform = Get-DefaultPlatform + Write-Host ("[AUTO-PLATFORM] Detected platform: {0}" -f $Platform) + } catch { + Write-Warning "Failed to auto-detect platform; defaulting to 'x64'" + $Platform = 'x64' + } +} + +# Ensure solution packages are restored +RestoreThenBuild 'PowerToys.slnx' '' $Platform $Configuration $true + +# Build both runner and settings +$ProjectsToBuild = @(".\src\runner\runner.vcxproj", ".\src\settings-ui\Settings.UI\PowerToys.Settings.csproj") +$ExtraArgs = "/p:SolutionDir=$repoRoot\" +foreach ($proj in $ProjectsToBuild) { + Write-Host ("[BUILD-ESSENTIALS] Building {0}" -f $proj) + RunMSBuild $proj $ExtraArgs $Platform $Configuration +} diff --git a/tools/build/build-installer.ps1 b/tools/build/build-installer.ps1 new file mode 100644 index 0000000000..a0e1f3c809 --- /dev/null +++ b/tools/build/build-installer.ps1 @@ -0,0 +1,413 @@ +<# +.SYNOPSIS +Build and package PowerToys (CmdPal and installer) for a specific platform and configuration LOCALLY. + +.DESCRIPTION +Builds and packages PowerToys (CmdPal and installer) locally. Handles solution build, signing, and WiX installer generation. + +.PARAMETER Platform +Specifies the target platform for the build (e.g., 'arm64', 'x64'). Default is 'x64'. + +.PARAMETER Configuration +Specifies the build configuration (e.g., 'Debug', 'Release'). Default is 'Release'. + +.PARAMETER PerUser +Specifies whether to build a per-user installer (true) or machine-wide installer (false). Default is true (per-user). + +.EXAMPLE +.\build-installer.ps1 +Runs the installer build pipeline for x64 Release. + +.EXAMPLE +.\build-installer.ps1 -Platform x64 -Configuration Release +Runs the pipeline for x64 Release. + +.EXAMPLE +.\build-installer.ps1 -Platform x64 -Configuration Release -PerUser false +Runs the pipeline for x64 Release with machine-wide installer. + +.NOTES +- Generated MSIX files will be signed using cert-sign-package.ps1. +- If the working tree is not clean, the script will prompt before continuing (use -Force to skip the prompt). +- Use the -Clean parameter to clean build outputs (bin/obj) and MSBuild outputs. +- The built installer will be placed under: installer/PowerToysSetupVNext/[Platform]/[Configuration]/User[Machine]Setup + relative to the solution root directory. +- To run the full installation in other machines, call "./cert-management.ps1" to export the cert used to sign the packages. + And trust the cert in the target machine. +#> + +param ( + [string]$Platform = 'x64', + [string]$Configuration = 'Release', + [string]$PerUser = 'true', + [string]$Version, + [switch]$Force, + [switch]$EnableCmdPalAOT, + [switch]$Clean, + [switch]$SkipBuild, + [switch]$Help +) + +if ($Help) { + Write-Host "Usage: .\build-installer.ps1 [-Platform <x64|arm64>] [-Configuration <Release|Debug>] [-PerUser <true|false>] [-Version <0.0.1>] [-Force] [-EnableCmdPalAOT] [-Clean] [-SkipBuild]" + Write-Host " -Platform Target platform (default: auto-detect or x64)" + Write-Host " -Configuration Build configuration (default: Release)" + Write-Host " -PerUser Build per-user installer (default: true)" + Write-Host " -Version Sets the PowerToys version (default: from src\Version.props)" + Write-Host " -Force Continue even if the git working tree is not clean (skips the interactive prompt)." + Write-Host " -EnableCmdPalAOT Enable AOT compilation for CmdPal (slower build)" + Write-Host " -Clean Clean output directories before building" + Write-Host " -SkipBuild Skip building the main solution and tools (assumes they are already built)" + Write-Host " -Help Show this help message" + exit 0 +} + +# Ensure helpers are available +. "$PSScriptRoot\build-common.ps1" + +# Initialize Visual Studio dev environment +if (-not (Ensure-VsDevEnvironment)) { exit 1 } + +# Auto-detect platform when not provided +if (-not $Platform -or $Platform -eq '') { + try { + $Platform = Get-DefaultPlatform + Write-Host ("[AUTO-PLATFORM] Detected platform: {0}" -f $Platform) + } catch { + Write-Warning "Failed to auto-detect platform; defaulting to x64" + $Platform = 'x64' + } +} + +# Find the PowerToys repository root automatically +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = $scriptDir + +# Navigate up from the script location to find the repo root +# Script is typically in tools\build, so go up two levels +while ($repoRoot -and -not (Test-Path (Join-Path $repoRoot "PowerToys.slnx"))) { + $parentDir = Split-Path -Parent $repoRoot + if ($parentDir -eq $repoRoot) { + # Reached the root of the drive, PowerToys.slnx not found + Write-Error "Could not find PowerToys repository root. Make sure this script is in the PowerToys repository." + exit 1 + } + $repoRoot = $parentDir +} + +if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot "PowerToys.slnx"))) { + Write-Error "Could not locate PowerToys.slnx. Please ensure this script is run from within the PowerToys repository." + exit 1 +} + +Write-Host "PowerToys repository root detected: $repoRoot" + +# Safety check: avoid mixing build outputs with existing local changes unless the user confirms. +if (-not $Force) { + Push-Location $repoRoot + try { + $gitStatus = $null + $gitRelevantStatus = @() + try { + $gitStatus = git status --porcelain=v1 --untracked-files=all --ignore-submodules=all + } catch { + Write-Warning ("[GIT] Failed to query git status: {0}" -f $_.Exception.Message) + } + + if ($gitStatus -and $gitStatus.Length -gt 0) { + foreach ($line in $gitStatus) { + if (-not $line) { continue } + + # Porcelain v1 format: XY <path> + # We only care about changes that affect the working tree (Y != ' ') or untracked files (??). + # Index-only changes (staged, Y == ' ') are ignored per user request. + if ($line.StartsWith('??')) { + $gitRelevantStatus += $line + continue + } + + if ($line.StartsWith('!!')) { + continue + } + + if ($line.Length -ge 2) { + $workTreeStatus = $line[1] + if ($workTreeStatus -ne ' ') { + $gitRelevantStatus += $line + } + } + } + } + + if ($gitRelevantStatus.Count -gt 0) { + Write-Warning "[GIT] Working tree is NOT clean." + Write-Warning "[GIT] This build will generate untracked files and may modify tracked files, which can mix with your current changes." + Write-Host "[GIT] Unstaged/untracked status (first 50 lines):" + $gitRelevantStatus | Select-Object -First 50 | ForEach-Object { Write-Host (" {0}" -f $_) } + + $shouldContinue = $false + try { + $choices = [System.Management.Automation.Host.ChoiceDescription[]]@( + (New-Object System.Management.Automation.Host.ChoiceDescription "&Yes", "Continue the build."), + (New-Object System.Management.Automation.Host.ChoiceDescription "&No", "Cancel the build.") + ) + $decision = $Host.UI.PromptForChoice("Working tree not clean", "Continue anyway?", $choices, 1) + $shouldContinue = ($decision -eq 0) + } catch { + Write-Warning "[GIT] Interactive prompt not available." + Write-Error "Refusing to proceed with a dirty working tree. Re-run with -Force to continue anyway." + exit 1 + } + + if (-not $shouldContinue) { + Write-Host "[GIT] Cancelled by user." + exit 1 + } + } + } finally { + Pop-Location + } +} + +$cmdpalOutputPath = Join-Path $repoRoot "$Platform\$Configuration\WinUI3Apps\CmdPal" +$buildOutputPath = Join-Path $repoRoot "$Platform\$Configuration" + +# Clean should be done first before any other steps +if ($Clean) { + if (Test-Path $cmdpalOutputPath) { + Write-Host "[CLEAN] Removing previous output: $cmdpalOutputPath" + Remove-Item $cmdpalOutputPath -Recurse -Force -ErrorAction Ignore + } + if (Test-Path $buildOutputPath) { + Write-Host "[CLEAN] Removing previous build output: $buildOutputPath" + Remove-Item $buildOutputPath -Recurse -Force -ErrorAction Ignore + } + + Write-Host "[CLEAN] Cleaning solution (msbuild /t:Clean)..." + RunMSBuild 'PowerToys.slnx' '/t:Clean' $Platform $Configuration +} + +try { + if ($Version) { + Write-Host "[VERSION] Setting PowerToys version to $Version using versionSetting.ps1..." + $versionScript = Join-Path $repoRoot ".pipelines\versionSetting.ps1" + if (Test-Path $versionScript) { + & $versionScript -versionNumber $Version -DevEnvironment 'Local' + if (-not $?) { + Write-Error "versionSetting.ps1 failed" + exit 1 + } + } else { + Write-Error "Could not find versionSetting.ps1 at: $versionScript" + exit 1 + } + } + + Write-Host "[VERSION] Setting up versioning using Microsoft.Windows.Terminal.Versioning..." + + # Check for nuget.exe - download to AppData if not available + $nugetDownloaded = $false + $nugetPath = $null + if (-not (Get-Command nuget -ErrorAction SilentlyContinue)) { + Write-Warning "nuget.exe not found in PATH. Attempting to download..." + $nugetUrl = "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" + $nugetDir = Join-Path $env:LOCALAPPDATA "PowerToys\BuildTools" + if (-not (Test-Path $nugetDir)) { New-Item -ItemType Directory -Path $nugetDir -Force | Out-Null } + $nugetPath = Join-Path $nugetDir "nuget.exe" + if (-not (Test-Path $nugetPath)) { + try { + Invoke-WebRequest $nugetUrl -OutFile $nugetPath + $nugetDownloaded = $true + } catch { + Write-Error "Failed to download nuget.exe. Please install it manually and add to PATH." + exit 1 + } + } + $env:Path += ";$nugetDir" + } + + # Install Terminal versioning package to AppData + $versioningDir = Join-Path $env:LOCALAPPDATA "PowerToys\BuildTools\.versioning" + if (-not (Test-Path $versioningDir)) { New-Item -ItemType Directory -Path $versioningDir -Force | Out-Null } + + $configFile = Join-Path $repoRoot ".pipelines\release-nuget.config" + + # Install the package + # Use -ExcludeVersion to make the path predictable + nuget install Microsoft.Windows.Terminal.Versioning -ConfigFile $configFile -OutputDirectory $versioningDir -ExcludeVersion -NonInteractive + + $versionRoot = Join-Path $versioningDir "Microsoft.Windows.Terminal.Versioning" + $setupScript = Join-Path $versionRoot "build\Setup.ps1" + + if (Test-Path $setupScript) { + & $setupScript -ProjectDirectory (Join-Path $repoRoot "src\modules\cmdpal") -Verbose + } else { + Write-Error "Could not find Setup.ps1 in $versionRoot" + } + + # WiX v5 projects use WixToolset.Sdk via NuGet/MSBuild; no separate WiX installation is required. + Write-Host ("[PIPELINE] Start | Platform={0} Configuration={1} PerUser={2}" -f $Platform, $Configuration, $PerUser) + Write-Host '' + + $commonArgs = '/p:CIBuild=true /p:IsPipeline=true' + + if ($EnableCmdPalAOT) { + $commonArgs += " /p:EnableCmdPalAOT=true" + } + + # No local projects found (or continuing) - build full solution and tools + if (-not $SkipBuild) { + RestoreThenBuild 'PowerToys.slnx' $commonArgs $Platform $Configuration + } + + $msixSearchRoot = Join-Path $repoRoot "$Platform\$Configuration" + $msixFiles = Get-ChildItem -Path $msixSearchRoot -Recurse -Filter *.msix | + Select-Object -ExpandProperty FullName + + if ($msixFiles.Count) { + Write-Host ("[SIGN] .msix file(s): {0}" -f ($msixFiles -join '; ')) + & (Join-Path $PSScriptRoot "cert-sign-package.ps1") -TargetPaths $msixFiles + } + else { + Write-Warning "[SIGN] No .msix files found in $msixSearchRoot" + } + + # Generate DSC v2 manifests (PowerToys.Settings.DSC.Schema.Generator) + # The csproj PostBuild event is skipped on ARM64, so we run it manually here if needed. + if ($Platform -eq 'arm64') { + Write-Host "[DSC] Manually generating DSC v2 manifests for ARM64..." + + # 1. Get Version + $versionPropsPath = Join-Path $repoRoot "src\Version.props" + [xml]$versionProps = Get-Content $versionPropsPath + $ptVersion = $versionProps.Project.PropertyGroup.Version + # Directory.Build.props appends .0 to the version for .csproj files + $ptVersionFull = "$ptVersion.0" + + # 2. Build the Generator + $generatorProj = Join-Path $repoRoot "src\dsc\PowerToys.Settings.DSC.Schema.Generator\PowerToys.Settings.DSC.Schema.Generator.csproj" + RunMSBuild $generatorProj "/t:Build" $Platform $Configuration + + # 3. Define paths + # The generator output path is in the project's bin folder + $generatorExe = Join-Path $repoRoot "src\dsc\PowerToys.Settings.DSC.Schema.Generator\bin\$Platform\$Configuration\PowerToys.Settings.DSC.Schema.Generator.exe" + + if (-not (Test-Path $generatorExe)) { + Write-Warning "Could not find generator at expected path: $generatorExe" + Write-Warning "Searching in build output..." + $found = Get-ChildItem -Path (Join-Path $repoRoot "$Platform\$Configuration") -Filter "PowerToys.Settings.DSC.Schema.Generator.exe" -Recurse | Select-Object -First 1 + if ($found) { + $generatorExe = $found.FullName + } + } + + $settingsLibDll = Join-Path $repoRoot "$Platform\$Configuration\WinUI3Apps\PowerToys.Settings.UI.Lib.dll" + + $dscGenDir = Join-Path $repoRoot "src\dsc\Microsoft.PowerToys.Configure\Generated\Microsoft.PowerToys.Configure\$ptVersionFull" + if (-not (Test-Path $dscGenDir)) { + New-Item -ItemType Directory -Path $dscGenDir -Force | Out-Null + } + + $outPsm1 = Join-Path $dscGenDir "Microsoft.PowerToys.Configure.psm1" + $outPsd1 = Join-Path $dscGenDir "Microsoft.PowerToys.Configure.psd1" + + # 4. Run Generator + if (Test-Path $generatorExe) { + Write-Host "[DSC] Executing: $generatorExe" + + $generatorDir = Split-Path -Parent $generatorExe + $winUI3AppsDir = Join-Path $repoRoot "$Platform\$Configuration\WinUI3Apps" + + # Copy dependencies from WinUI3Apps to Generator directory to satisfy WinRT/WinUI3 dependencies + # This avoids "Class not registered" errors without polluting the WinUI3Apps directory which is used for packaging. + if (Test-Path $winUI3AppsDir) { + Write-Host "[DSC] Copying dependencies from $winUI3AppsDir to $generatorDir" + Get-ChildItem -Path $winUI3AppsDir -Filter "*.dll" | ForEach-Object { + $destPath = Join-Path $generatorDir $_.Name + if (-not (Test-Path $destPath)) { + Copy-Item -Path $_.FullName -Destination $destPath -Force + } + } + # Also copy resources.pri if it exists, as it might be needed for resource lookup + $priFile = Join-Path $winUI3AppsDir "resources.pri" + if (Test-Path $priFile) { + Copy-Item -Path $priFile -Destination $generatorDir -Force + } + } + + Push-Location $generatorDir + try { + # Now we can use the local DLLs + $localSettingsLibDll = Join-Path $generatorDir "PowerToys.Settings.UI.Lib.dll" + + if (Test-Path $localSettingsLibDll) { + Write-Host "[DSC] Using local DLL: $localSettingsLibDll" + & $generatorExe $localSettingsLibDll $outPsm1 $outPsd1 + } else { + # Fallback (shouldn't happen if copy succeeded or build was correct) + Write-Warning "[DSC] Local DLL not found, falling back to: $settingsLibDll" + & $generatorExe $settingsLibDll $outPsm1 $outPsd1 + } + + if ($LASTEXITCODE -ne 0) { + Write-Error "DSC v2 generation failed with exit code $LASTEXITCODE" + exit 1 + } + } finally { + Pop-Location + } + + Write-Host "[DSC] DSC v2 manifests generated successfully." + } else { + Write-Error "Could not find generator executable at $generatorExe" + exit 1 + } + } + + # Generate DSC manifest files + Write-Host '[DSC] Generating DSC manifest files...' + $dscScriptPath = Join-Path $repoRoot '.\tools\build\generate-dsc-manifests.ps1' + if (Test-Path $dscScriptPath) { + & $dscScriptPath -BuildPlatform $Platform -BuildConfiguration $Configuration -RepoRoot $repoRoot + if ($LASTEXITCODE -ne 0) { + Write-Error "DSC manifest generation failed with exit code $LASTEXITCODE" + exit 1 + } + Write-Host '[DSC] DSC manifest files generated successfully' + } else { + Write-Warning "[DSC] DSC manifest generator script not found at: $dscScriptPath" + } + + if (-not $SkipBuild) { + RestoreThenBuild 'tools\BugReportTool\BugReportTool.sln' $commonArgs $Platform $Configuration + RestoreThenBuild 'tools\StylesReportTool\StylesReportTool.sln' $commonArgs $Platform $Configuration + } + + # Set NUGET_PACKAGES environment variable if not set, to help wixproj find heat.exe + if (-not $env:NUGET_PACKAGES) { + $env:NUGET_PACKAGES = Join-Path $env:USERPROFILE ".nuget\packages" + Write-Host "[ENV] Set NUGET_PACKAGES to $env:NUGET_PACKAGES" + } + + RunMSBuild 'installer\PowerToysSetup.slnx' "$commonArgs /t:restore /p:RestorePackagesConfig=true" $Platform $Configuration + + RunMSBuild 'installer\PowerToysSetup.slnx' "$commonArgs /m /t:PowerToysInstallerVNext /p:PerUser=$PerUser" $Platform $Configuration + + # Fix: WiX v5 locally puts the MSI in an 'en-us' subfolder, but the Bootstrapper expects it in the root of UserSetup/MachineSetup. + # We move it up one level to match expectations. + $setupType = if ($PerUser -eq 'true') { 'UserSetup' } else { 'MachineSetup' } + $msiParentDir = Join-Path $repoRoot "installer\PowerToysSetupVNext\$Platform\$Configuration\$setupType" + $msiEnUsDir = Join-Path $msiParentDir "en-us" + + if (Test-Path $msiEnUsDir) { + Write-Host "[FIX] Moving MSI files from $msiEnUsDir to $msiParentDir" + Get-ChildItem -Path $msiEnUsDir -Filter *.msi | Move-Item -Destination $msiParentDir -Force + } + + RunMSBuild 'installer\PowerToysSetup.slnx' "$commonArgs /m /t:PowerToysBootstrapperVNext /p:PerUser=$PerUser" $Platform $Configuration + +} finally { + # No git cleanup; leave workspace state as-is. +} + +Write-Host '[PIPELINE] Completed' diff --git a/tools/build/build.cmd b/tools/build/build.cmd new file mode 100644 index 0000000000..45b59c42b2 --- /dev/null +++ b/tools/build/build.cmd @@ -0,0 +1,5 @@ +@echo off +REM Wrapper to run the PowerShell build script from cmd.exe +set SCRIPT_DIR=%~dp0 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%build.ps1" %* +exit /b %ERRORLEVEL% diff --git a/tools/build/build.ps1 b/tools/build/build.ps1 new file mode 100644 index 0000000000..750d60a7d4 --- /dev/null +++ b/tools/build/build.ps1 @@ -0,0 +1,104 @@ +<# +.SYNOPSIS +Light-weight wrapper to build local projects (solutions/projects) in the current working directory using helpers in build-common.ps1. + +.DESCRIPTION +This script is intended for quick local builds. It dot-sources `build-common.ps1` and calls `BuildProjectsInDirectory` against the current directory. Use `-RestoreOnly` to only restore packages for local projects. If `-Platform` is omitted the script attempts to auto-detect the host platform. + +.PARAMETER Platform +Target platform (e.g., 'x64', 'arm64'). If omitted the script will try to detect the host platform automatically. + +.PARAMETER Configuration +Build configuration (e.g., 'Debug', 'Release'). Default: 'Debug'. + +.PARAMETER Path +Optional directory path containing projects to build. If not specified, uses the current working directory. + +.PARAMETER RestoreOnly +If specified, only perform package restore for local projects and skip the build steps for a solution file (i.e. .sln). + +.PARAMETER ExtraArgs +Any remaining, positional arguments passed to the script are forwarded to MSBuild as additional arguments (e.g., '/p:CIBuild=true'). + +.EXAMPLE +.\tools\build\build.ps1 +Builds any .sln/.csproj/.vcxproj in the current working directory (auto-detects Platform). + +.EXAMPLE +.\tools\build\build.ps1 -Platform x64 -Configuration Release -Path "C:\MyProject\src" +Builds local projects in the specified directory for x64 Release. + +.EXAMPLE +.\tools\build\build.ps1 -Platform x64 -Configuration Release +Builds local projects for x64 Release. + +.EXAMPLE +.\tools\build\build.ps1 '/p:CIBuild=true' '/p:SomeOther=Value' +Pass additional MSBuild arguments; these are forwarded to the underlying msbuild calls. + +.EXAMPLE +.\tools\build\build.ps1 -RestoreOnly '/p:CIBuild=true' +Only restores packages for local projects; ExtraArgs still forwarded to msbuild's restore phase. + +.NOTES +- This file expects `build-common.ps1` to be located in the same folder and dot-sources it to load helper functions. +- ExtraArgs are captured using PowerShell's ValueFromRemainingArguments and joined before being passed to the helpers. +#> + +param ( + [string]$Platform = '', + [string]$Configuration = 'Debug', + [string]$Path = '', + [switch]$RestoreOnly, + [Parameter(ValueFromRemainingArguments=$true)] + [string[]]$ExtraArgs +) + +. "$PSScriptRoot\build-common.ps1" + +# Initialize Visual Studio dev environment +if (-not (Ensure-VsDevEnvironment)) { exit 1 } + +# If user passed MSBuild-style args (e.g. './build.ps1 /p:CIBuild=true'), +# those will bind to $Platform/$Configuration; detect those and move them to ExtraArgs. +$positionalExtra = @() +if ($Platform -and $Platform -match '^[\/-]') { + $positionalExtra += $Platform + $Platform = '' +} +if ($Configuration -and $Configuration -match '^[\/-]') { + $positionalExtra += $Configuration + $Configuration = 'Debug' +} +if ($positionalExtra.Count -gt 0) { + if (-not $ExtraArgs) { $ExtraArgs = @() } + $ExtraArgs = $positionalExtra + $ExtraArgs +} + +# Auto-detect platform when not provided +if (-not $Platform -or $Platform -eq '') { + try { + $Platform = Get-DefaultPlatform + Write-Host ("[AUTO-PLATFORM] Detected platform: {0}" -f $Platform) + } catch { + Write-Warning "Failed to auto-detect platform; defaulting to x64" + $Platform = 'x64' + } +} + +$cwd = if ($Path) { + (Resolve-Path $Path).ProviderPath +} else { + (Get-Location).ProviderPath +} +$extraArgsString = $null +if ($ExtraArgs -and $ExtraArgs.Count -gt 0) { $extraArgsString = ($ExtraArgs -join ' ') } + +$built = BuildProjectsInDirectory -DirectoryPath $cwd -ExtraArgs $extraArgsString -Platform $Platform -Configuration $Configuration -RestoreOnly:$RestoreOnly +if ($built) { + Write-Host "[BUILD] Local projects built; exiting." + exit 0 +} else { + Write-Host "[BUILD] No local projects found in $cwd" + exit 0 +} diff --git a/tools/build/cert-management.ps1 b/tools/build/cert-management.ps1 new file mode 100644 index 0000000000..bc7c758f70 --- /dev/null +++ b/tools/build/cert-management.ps1 @@ -0,0 +1,198 @@ +<# +.SYNOPSIS +Ensures a code signing certificate exists and is trusted in all necessary certificate stores. + +.DESCRIPTION +This script provides two functions: + +1. EnsureCertificate: + - Searches for an existing code signing certificate by subject name. + - If not found, creates a new self-signed certificate. + - Exports the certificate and attempts to import it into: + - CurrentUser\TrustedPeople + - CurrentUser\Root + - LocalMachine\Root (admin privileges may be required) + +2. ImportAndVerifyCertificate: + - Imports a `.cer` file into the specified certificate store if not already present. + - Verifies the certificate is successfully imported by checking thumbprint. + +This is useful in build or signing pipelines to ensure a valid and trusted certificate is available before signing MSIX or executable files. + +.PARAMETER certSubject +The subject name of the certificate to search for or create. Default is: +"CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" + +.PARAMETER cerPath +(ImportAndVerifyCertificate only) The file path to a `.cer` certificate file to import. + +.PARAMETER storePath +(ImportAndVerifyCertificate only) The destination certificate store path (e.g. Cert:\CurrentUser\Root). + +.EXAMPLE +$cert = EnsureCertificate + +Ensures the default certificate exists and is trusted, and returns the certificate object. + +.EXAMPLE +ImportAndVerifyCertificate -cerPath "$env:TEMP\temp_cert.cer" -storePath "Cert:\CurrentUser\Root" + +Imports a certificate into the CurrentUser Root store and verifies its presence. + +.NOTES +- For full trust, administrative privileges may be needed to import into LocalMachine\Root. +- Certificates are created using RSA and SHA256 and marked as CodeSigningCert. +#> + +function ImportAndVerifyCertificate { + param ( + [string]$cerPath, + [string]$storePath + ) + + $thumbprint = (Get-PfxCertificate -FilePath $cerPath).Thumbprint + + $existingCert = Get-ChildItem -Path $storePath | Where-Object { $_.Thumbprint -eq $thumbprint } + if ($existingCert) { + Write-Host "Certificate already exists in $storePath" + return $true + } + + try { + $null = Import-Certificate -FilePath $cerPath -CertStoreLocation $storePath -ErrorAction Stop + } catch { + if ($_.Exception.Message -match "Access is denied" -or $_.Exception.InnerException.Message -match "Access is denied") { + Write-Warning "Access denied to $storePath. Attempting to import with admin privileges..." + try { + Start-Process powershell -ArgumentList "-NoProfile", "-Command", "& { Import-Certificate -FilePath '$cerPath' -CertStoreLocation '$storePath' }" -Verb RunAs -Wait + } catch { + Write-Warning "Failed to request admin privileges: $_" + return $false + } + } else { + Write-Warning "Failed to import certificate to $storePath : $_" + return $false + } + } + + $imported = Get-ChildItem -Path $storePath | Where-Object { $_.Thumbprint -eq $thumbprint } + if ($imported) { + Write-Host "Certificate successfully imported to $storePath" + return $true + } else { + Write-Warning "Certificate not found in $storePath after import" + return $false + } +} + +function EnsureCertificate { + param ( + [string]$certSubject = "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" + ) + + $cert = Get-ChildItem -Path Cert:\CurrentUser\My | + Where-Object { $_.Subject -eq $certSubject } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + + if (-not $cert) { + Write-Host "Certificate not found. Creating a new one..." + + $cert = New-SelfSignedCertificate -Subject $certSubject ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -KeyAlgorithm RSA ` + -Type CodeSigningCert ` + -HashAlgorithm SHA256 + + if (-not $cert) { + Write-Error "Failed to create a new certificate." + return $null + } + + Write-Host "New certificate created with thumbprint: $($cert.Thumbprint)" + } + else { + Write-Host "Using existing certificate with thumbprint: $($cert.Thumbprint)" + } + + $cerPath = "$env:TEMP\temp_cert.cer" + [void](Export-Certificate -Cert $cert -FilePath $cerPath -Force) + + if (-not (ImportAndVerifyCertificate -cerPath $cerPath -storePath "Cert:\CurrentUser\TrustedPeople")) { return $null } + if (-not (ImportAndVerifyCertificate -cerPath $cerPath -storePath "Cert:\CurrentUser\Root")) { return $null } + if (-not (ImportAndVerifyCertificate -cerPath $cerPath -storePath "Cert:\LocalMachine\Root")) { + Write-Warning "Failed to import to LocalMachine\Root (admin may be required)" + return $null + } + + return $cert +} + +function Export-CertificateFiles { + param ( + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [string]$CerPath, + [string]$PfxPath, + [securestring]$PfxPassword + ) + + if (-not $Certificate) { + Write-Error "No certificate provided to export." + return + } + + if ($CerPath) { + try { + Export-Certificate -Cert $Certificate -FilePath $CerPath -Force | Out-Null + Write-Host "Exported CER to: $CerPath" + } catch { + Write-Warning "Failed to export CER file: $_" + } + } + + if ($PfxPath -and $PfxPassword) { + try { + Export-PfxCertificate -Cert $Certificate -FilePath $PfxPath -Password $PfxPassword -Force | Out-Null + Write-Host "Exported PFX to: $PfxPath" + } catch { + Write-Warning "Failed to export PFX file: $_" + } + } + + if (-not $CerPath -and -not $PfxPath) { + Write-Warning "No output path specified. Nothing was exported." + } +} + +# Main execution when script is run directly +if ($MyInvocation.InvocationName -ne '.') { + Write-Host "=== PowerToys Certificate Management ===" -ForegroundColor Green + Write-Host "" + + # Ensure certificate exists and is trusted + Write-Host "Checking for existing certificate or creating new one..." -ForegroundColor Yellow + $cert = EnsureCertificate + + if ($cert) { + # Export the certificate to a .cer file + $exportPath = Join-Path (Get-Location) "PowerToys-CodeSigning.cer" + Write-Host "" + Write-Host "Exporting certificate..." -ForegroundColor Yellow + Export-CertificateFiles -Certificate $cert -CerPath $exportPath + + Write-Host "" + Write-Host "=== IMPORTANT NOTES ===" -ForegroundColor Red + Write-Host "The certificate has been exported to: $exportPath" -ForegroundColor White + Write-Host "" + Write-Host "To use this certificate for code signing, you need to:" -ForegroundColor Yellow + Write-Host "1. Import this certificate into 'Trusted People' store" -ForegroundColor White + Write-Host "2. Import this certificate into 'Trusted Root Certification Authorities' store" -ForegroundColor White + Write-Host "Certificate Details:" -ForegroundColor Green + Write-Host "Subject: $($cert.Subject)" -ForegroundColor White + Write-Host "Thumbprint: $($cert.Thumbprint)" -ForegroundColor White + Write-Host "Valid Until: $($cert.NotAfter)" -ForegroundColor White + } else { + Write-Error "Failed to create or find certificate. Please check the error messages above." + exit 1 + } +} \ No newline at end of file diff --git a/tools/build/cert-sign-package.ps1 b/tools/build/cert-sign-package.ps1 new file mode 100644 index 0000000000..8bb57762a5 --- /dev/null +++ b/tools/build/cert-sign-package.ps1 @@ -0,0 +1,29 @@ +param ( + [string]$certSubject = "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + [string[]]$TargetPaths = "C:\PowerToys\ARM64\Release\WinUI3Apps\CmdPal\AppPackages\Microsoft.CmdPal.UI_0.0.1.0_Test\Microsoft.CmdPal.UI_0.0.1.0_arm64.msix" +) + +. "$PSScriptRoot\cert-management.ps1" +$cert = EnsureCertificate -certSubject $certSubject + +if (-not $cert) { + Write-Error "Failed to prepare certificate." + exit 1 +} + +Write-Host "Certificate ready: $($cert.Thumbprint)" + +if (-not $TargetPaths -or $TargetPaths.Count -eq 0) { + Write-Error "No target files provided to sign." + exit 1 +} + +foreach ($filePath in $TargetPaths) { + if (-not (Test-Path $filePath)) { + Write-Warning "Skipping: File does not exist - $filePath" + continue + } + + Write-Host "Signing: $filePath" + & signtool sign /sha1 $($cert.Thumbprint) /fd SHA256 /t http://timestamp.digicert.com "$filePath" +} \ No newline at end of file diff --git a/tools/build/clean-artifacts.ps1 b/tools/build/clean-artifacts.ps1 new file mode 100644 index 0000000000..22b252f74e --- /dev/null +++ b/tools/build/clean-artifacts.ps1 @@ -0,0 +1,84 @@ +<# +.SYNOPSIS +Cleans PowerToys build artifacts to resolve build errors. + +.DESCRIPTION +Use this script when you encounter build errors about missing image files or corrupted +build state. It removes build output folders and optionally runs MSBuild Clean. + +.PARAMETER SkipMSBuildClean +Skip running MSBuild Clean target, only delete folders. + +.EXAMPLE +.\tools\build\clean-artifacts.ps1 + +.EXAMPLE +.\tools\build\clean-artifacts.ps1 -SkipMSBuildClean +#> + +param ( + [switch]$SkipMSBuildClean +) + +$ErrorActionPreference = 'Continue' + +$scriptDir = $PSScriptRoot +$repoRoot = (Resolve-Path "$scriptDir\..\..").Path + +Write-Host "Cleaning build artifacts..." +Write-Host "" + +# Run MSBuild Clean +if (-not $SkipMSBuildClean) { + $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (Test-Path $vsWhere) { + $vsPath = & $vsWhere -latest -products * -requires Microsoft.Component.MSBuild -property installationPath + if ($vsPath) { + $msbuildPath = Join-Path $vsPath "MSBuild\Current\Bin\MSBuild.exe" + if (Test-Path $msbuildPath) { + $solutionFile = Join-Path $repoRoot "PowerToys.sln" + if (-not (Test-Path $solutionFile)) { + $solutionFile = Join-Path $repoRoot "PowerToys.slnx" + } + + if (Test-Path $solutionFile) { + Write-Host " Running MSBuild Clean..." + foreach ($plat in @('x64', 'ARM64')) { + foreach ($config in @('Debug', 'Release')) { + & $msbuildPath $solutionFile /t:Clean /p:Platform=$plat /p:Configuration=$config /verbosity:quiet 2>&1 | Out-Null + } + } + Write-Host " Done." + } + } + } + } +} + +# Delete build folders +$folders = @('x64', 'ARM64', 'Debug', 'Release', 'packages') +$deleted = @() + +foreach ($folder in $folders) { + $fullPath = Join-Path $repoRoot $folder + if (Test-Path $fullPath) { + Write-Host " Removing $folder/" + try { + Remove-Item -Path $fullPath -Recurse -Force -ErrorAction Stop + $deleted += $folder + } catch { + Write-Host " Failed to remove $folder/: $_" + } + } +} + +Write-Host "" +if ($deleted.Count -gt 0) { + Write-Host "Removed: $($deleted -join ', ')" +} else { + Write-Host "No build folders found to remove." +} + +Write-Host "" +Write-Host "To rebuild, run:" +Write-Host " msbuild -restore -p:RestorePackagesConfig=true -p:Platform=x64 -m PowerToys.slnx" diff --git a/tools/build/generate-dsc-manifests.ps1 b/tools/build/generate-dsc-manifests.ps1 new file mode 100644 index 0000000000..78cc909174 --- /dev/null +++ b/tools/build/generate-dsc-manifests.ps1 @@ -0,0 +1,123 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$BuildPlatform, + + [Parameter(Mandatory = $true)] + [string]$BuildConfiguration, + + [Parameter()] + [string]$RepoRoot = (Get-Location).Path, + + [switch]$ForceRebuildExecutable +) + +$ErrorActionPreference = 'Stop' + +function Resolve-PlatformDirectory { + param( + [string]$Root, + [string]$Platform + ) + + $normalized = $Platform.Trim() + $candidates = @() + $candidates += Join-Path $Root $normalized + $candidates += Join-Path $Root ($normalized.ToUpperInvariant()) + $candidates += Join-Path $Root ($normalized.ToLowerInvariant()) + $candidates = $candidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique + + foreach ($candidate in $candidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + return $candidates[0] +} + +Write-Host "Repo root: $RepoRoot" +Write-Host "Requested build platform: $BuildPlatform" +Write-Host "Requested configuration: $BuildConfiguration" + +# Always use x64 PowerToys.DSC.exe since CI/CD machines are x64 +$exePlatform = 'x64' +$exeRoot = Resolve-PlatformDirectory -Root $RepoRoot -Platform $exePlatform +$exeOutputDir = Join-Path $exeRoot $BuildConfiguration +$exePath = Join-Path $exeOutputDir 'PowerToys.DSC.exe' + +Write-Host "Using x64 PowerToys.DSC.exe to generate DSC manifests for $BuildPlatform build" + +if ($ForceRebuildExecutable -or -not (Test-Path $exePath)) { + Write-Host "PowerToys.DSC.exe not found at '$exePath'. Building x64 binary..." + + $msbuild = Get-Command msbuild.exe -ErrorAction SilentlyContinue + if ($null -eq $msbuild) { + throw "msbuild.exe was not found on the PATH." + } + + $projectPath = Join-Path $RepoRoot 'src\dsc\v3\PowerToys.DSC\PowerToys.DSC.csproj' + $msbuildArgs = @( + $projectPath, + '/t:Build', + '/m', + "/p:Configuration=$BuildConfiguration", + "/p:Platform=x64", + '/restore' + ) + + & $msbuild.Path @msbuildArgs + $msbuildExitCode = $LASTEXITCODE + + if ($msbuildExitCode -ne 0) { + throw "msbuild build failed with exit code $msbuildExitCode" + } + + if (-not (Test-Path $exePath)) { + throw "Expected PowerToys.DSC.exe at '$exePath' after build but it was not found." + } +} else { + Write-Host "Using existing PowerToys.DSC.exe at '$exePath'." +} + +# Output DSC manifests to the target build platform directory (x64, ARM64, etc.) +$outputRoot = Resolve-PlatformDirectory -Root $RepoRoot -Platform $BuildPlatform +if (-not (Test-Path $outputRoot)) { + Write-Host "Creating missing platform output root at '$outputRoot'." + New-Item -Path $outputRoot -ItemType Directory -Force | Out-Null +} + +$outputDir = Join-Path $outputRoot $BuildConfiguration +if (-not (Test-Path $outputDir)) { + Write-Host "Creating missing configuration output directory at '$outputDir'." + New-Item -Path $outputDir -ItemType Directory -Force | Out-Null +} + +# DSC v3 manifests go to DSCModules subfolder +$dscOutputDir = Join-Path $outputDir 'DSCModules' +if (-not (Test-Path $dscOutputDir)) { + Write-Host "Creating DSCModules subfolder at '$dscOutputDir'." + New-Item -Path $dscOutputDir -ItemType Directory -Force | Out-Null +} + +Write-Host "DSC manifests will be generated to: '$dscOutputDir'" + +Write-Host "Cleaning previously generated DSC manifest files from '$dscOutputDir'." +Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction SilentlyContinue | Remove-Item -Force + +$arguments = @('manifest', '--resource', 'settings', '--outputDir', $dscOutputDir) +Write-Host "Invoking DSC manifest generator: '$exePath' $($arguments -join ' ')" +& $exePath @arguments +if ($LASTEXITCODE -ne 0) { + throw "PowerToys.DSC.exe exited with code $LASTEXITCODE" +} + +$generatedFiles = Get-ChildItem -Path $dscOutputDir -Filter 'microsoft.powertoys.*.settings.dsc.resource.json' -ErrorAction Stop +if ($generatedFiles.Count -eq 0) { + throw "No DSC manifest files were generated in '$dscOutputDir'." +} + +Write-Host "Generated $($generatedFiles.Count) DSC manifest file(s):" +foreach ($file in $generatedFiles) { + Write-Host " - $($file.FullName)" +} diff --git a/tools/build/self-sign.ps1 b/tools/build/self-sign.ps1 new file mode 100644 index 0000000000..43085ae851 --- /dev/null +++ b/tools/build/self-sign.ps1 @@ -0,0 +1,179 @@ +#https://learn.microsoft.com/en-us/windows/msix/package/signing-known-issues +# 1. Build the powertoys as usual. +# 2. Call this script to sign the msix package. +# First time run needs admin permission to trust the certificate. + +param ( + [string]$architecture = "x64", # Default to x64 if not provided + [string]$buildConfiguration = "Debug" # Default to Debug if not provided +) + +$signToolPath = $null +$kitsRootPaths = @( + "C:\Program Files (x86)\Windows Kits\10\bin", + "C:\Program Files\Windows Kits\10\bin" +) + +$signToolAvailable = Get-Command "signtool" -ErrorAction SilentlyContinue +if ($signToolAvailable) { + Write-Host "SignTool is available in the system PATH." + $signToolPath = "signtool" +} +else { + Write-Host "Searching for latest SignTool matching architecture: $architecture" + + foreach ($root in $kitsRootPaths) { + if (Test-Path $root) { + $versions = Get-ChildItem -Path $root -Directory | Where-Object { + $_.Name -match '^\d+\.\d+\.\d+\.\d+$' + } | Sort-Object Name -Descending + + foreach ($version in $versions) { + $candidatePath = Join-Path -Path $version.FullName -ChildPath "x86" + $exePath = Join-Path -Path $candidatePath -ChildPath "signtool.exe" + if (Test-Path $exePath) { + Write-Host "Found SignTool at: $exePath" + $signToolPath = $exePath + break + } + } + + if ($signToolPath) { break } + } + } + + if (!$signToolPath) { + Write-Host "SignTool not found. Please ensure Windows SDK is installed." + exit 1 + } +} + +Write-Host "`nUsing SignTool: $signToolPath" + +# Set the certificate subject and the ECDSA curve +$certSubject = "CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" + +# Check if the certificate already exists in the current user's certificate store +$existingCert = Get-ChildItem -Path Cert:\CurrentUser\My | +Where-Object { $_.Subject -eq $certSubject } | +Sort-Object NotAfter -Descending | +Select-Object -First 1 + +if ($existingCert) { + # If the certificate exists, use the existing certificate + Write-Host "Certificate already exists, using the existing certificate" + $cert = $existingCert +} +else { + # If the certificate doesn't exist, create a new self-signed certificate + Write-Host "Certificate does not exist, creating a new certificate..." + $cert = New-SelfSignedCertificate -Subject $certSubject ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -KeyAlgorithm RSA ` + -Type CodeSigningCert ` + -HashAlgorithm SHA256 +} + +function Import-And-VerifyCertificate { + param ( + [string]$cerPath, + [string]$storePath + ) + + $thumbprint = (Get-PfxCertificate -FilePath $cerPath).Thumbprint + + # ✅ Step 1: Check if already exists in store + $existingCert = Get-ChildItem -Path $storePath | Where-Object { $_.Thumbprint -eq $thumbprint } + + if ($existingCert) { + Write-Host "✅ Certificate already exists in $storePath" + return $true + } + + # 🚀 Step 2: Try to import if not already there + try { + $null = Import-Certificate -FilePath $cerPath -CertStoreLocation $storePath -ErrorAction Stop + } + catch { + Write-Warning "❌ Failed to import certificate to $storePath : $_" + return $false + } + + # 🔁 Step 3: Verify again + $imported = Get-ChildItem -Path $storePath | Where-Object { $_.Thumbprint -eq $thumbprint } + + if ($imported) { + Write-Host "✅ Certificate successfully imported to $storePath" + return $true + } + else { + Write-Warning "❌ Certificate not found in $storePath after import" + return $false + } +} + +$cerPath = "$env:TEMP\temp_cert.cer" +Export-Certificate -Cert $cert -FilePath $cerPath -Force +# used for sign code/msix +# CurrentUser\TrustedPeople +if (-not (Import-And-VerifyCertificate -cerPath $cerPath -storePath "Cert:\CurrentUser\TrustedPeople")) { + exit 1 +} + +# CurrentUser\Root +if (-not (Import-And-VerifyCertificate -cerPath $cerPath -storePath "Cert:\CurrentUser\Root")) { + exit 1 +} + +# LocalMachine\Root +if (-not (Import-And-VerifyCertificate -cerPath $cerPath -storePath "Cert:\LocalMachine\Root")) { + Write-Warning "⚠️ Failed to import to LocalMachine\Root (admin may be required)" + exit 1 +} + + +# Output the thumbprint of the certificate (to confirm which certificate is being used) +Write-Host "Using certificate with thumbprint: $($cert.Thumbprint)" + + +$rootDirectory = (Split-Path -Parent(Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))) + +# Dynamically build the directory path based on architecture and build configuration +# $directoryPath = Join-Path $rootDirectory "$architecture\$buildConfiguration\WinUI3Apps\CmdPal\" +$directoryPath = Join-Path $rootDirectory "$architecture\$buildConfiguration\WinUI3Apps\CmdPal\" + +if (-not (Test-Path $directoryPath)) { + Write-Error "Path to search for msix files does not exist: $directoryPath" + exit 1 +} + +Write-Host "Directory path to search for .msix and .appx files: $directoryPath" + +# Get all .msix and .appx files from the specified directory +$filePaths = Get-ChildItem -Path $directoryPath -Recurse | Where-Object { + ($_.Extension -eq ".msix" -or $_.Extension -eq ".appx") -and + ($_.Name -like "*$architecture*") +} + +if ($filePaths.Count -eq 0) { + Write-Host "No .msix or .appx files found in the directory." +} +else { + # Iterate through each file and sign it + foreach ($file in $filePaths) { + Write-Host "Signing file: $($file.FullName)" + + # Use SignTool to sign the file + $signToolCommand = "& `"$signToolPath`" sign /sha1 $($cert.Thumbprint) /fd SHA256 /t http://timestamp.digicert.com `"$($file.FullName)`"" + + # Execute the sign command + try { + Invoke-Expression $signToolCommand + } + catch { + Write-Host "Error signing file $($file.Name): $_" + } + } +} + +Write-Host "Signing process completed." diff --git a/tools/build/setup-dev-environment.ps1 b/tools/build/setup-dev-environment.ps1 new file mode 100644 index 0000000000..84dcab8e16 --- /dev/null +++ b/tools/build/setup-dev-environment.ps1 @@ -0,0 +1,294 @@ +<# +.SYNOPSIS +Sets up the development environment for building PowerToys. + +.DESCRIPTION +This script automates the setup of prerequisites needed to build PowerToys locally: +- Enables Windows long path support (requires elevation) +- Enables Windows Developer Mode (requires elevation) +- Installs required Visual Studio workloads from .vsconfig +- Initializes git submodules + +Run this script once after cloning the repository to prepare your development environment. + +.PARAMETER SkipLongPaths +Skip enabling long path support in Windows. + +.PARAMETER SkipDevMode +Skip enabling Windows Developer Mode. + +.PARAMETER SkipVSComponents +Skip installing Visual Studio components from .vsconfig. + +.PARAMETER SkipSubmodules +Skip initializing git submodules. + +.PARAMETER VSInstallPath +Path to Visual Studio installation. Default: auto-detected. + +.PARAMETER Help +Show this help message. + +.EXAMPLE +.\tools\build\setup-dev-environment.ps1 +Runs the full setup process. + +.EXAMPLE +.\tools\build\setup-dev-environment.ps1 -SkipVSComponents +Runs setup but skips Visual Studio component installation. + +.EXAMPLE +.\tools\build\setup-dev-environment.ps1 -VSInstallPath "C:\Program Files\Microsoft Visual Studio\18\Enterprise" +Runs setup with a custom Visual Studio installation path. + +.NOTES +- Some operations require administrator privileges (long paths, VS component installation). +- If not running as administrator, the script will prompt for elevation for those steps. +- The script is idempotent and safe to run multiple times. +#> + +param ( + [switch]$SkipLongPaths, + [switch]$SkipDevMode, + [switch]$SkipVSComponents, + [switch]$SkipSubmodules, + [string]$VSInstallPath = '', + [switch]$Help +) + +if ($Help) { + Get-Help $MyInvocation.MyCommand.Path -Detailed + exit 0 +} + +$ErrorActionPreference = 'Stop' + +# Find repository root +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition +$repoRoot = $scriptDir +while ($repoRoot -and -not (Test-Path (Join-Path $repoRoot "PowerToys.slnx"))) { + $parent = Split-Path -Parent $repoRoot + if ($parent -eq $repoRoot) { + Write-Error "Could not find PowerToys repository root. Ensure this script is in the PowerToys repository." + exit 1 + } + $repoRoot = $parent +} + +Write-Host "Repository: $repoRoot" +Write-Host "" + +function Test-Administrator { + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($currentUser) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +$isAdmin = Test-Administrator + +# Step 1: Enable Long Paths +if (-not $SkipLongPaths) { + Write-Host "[1/4] Checking Windows long path support" + + $longPathsEnabled = $false + try { + $regValue = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -ErrorAction SilentlyContinue + $longPathsEnabled = ($regValue.LongPathsEnabled -eq 1) + } catch { + $longPathsEnabled = $false + } + + if ($longPathsEnabled) { + Write-Host " Long paths already enabled" -ForegroundColor Green + } elseif ($isAdmin) { + Write-Host " Enabling long paths..." + try { + Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -Type DWord + Write-Host " Long paths enabled" -ForegroundColor Green + } catch { + Write-Warning " Failed to enable long paths: $_" + } + } else { + Write-Warning " Long paths not enabled. Run as Administrator to enable, or run manually:" + Write-Host " Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1" -ForegroundColor DarkGray + } +} else { + Write-Host "[1/4] Skipping long path check" -ForegroundColor DarkGray +} + +Write-Host "" + +# Step 2: Enable Developer Mode +if (-not $SkipDevMode) { + Write-Host "[2/4] Checking Windows Developer Mode" + + $devModeEnabled = $false + try { + $regValue = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" -Name "AllowDevelopmentWithoutDevLicense" -ErrorAction SilentlyContinue + $devModeEnabled = ($regValue.AllowDevelopmentWithoutDevLicense -eq 1) + } catch { + $devModeEnabled = $false + } + + if ($devModeEnabled) { + Write-Host " Developer Mode already enabled" -ForegroundColor Green + } elseif ($isAdmin) { + Write-Host " Enabling Developer Mode..." + try { + $regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + if (-not (Test-Path $regPath)) { + New-Item -Path $regPath -Force | Out-Null + } + Set-ItemProperty -Path $regPath -Name "AllowDevelopmentWithoutDevLicense" -Value 1 -Type DWord + Write-Host " Developer Mode enabled" -ForegroundColor Green + } catch { + Write-Warning " Failed to enable Developer Mode: $_" + } + } else { + Write-Warning " Developer Mode not enabled. Run as Administrator to enable, or enable manually:" + Write-Host " Settings > System > For developers > Developer Mode" -ForegroundColor DarkGray + } +} else { + Write-Host "[2/4] Skipping Developer Mode check" -ForegroundColor DarkGray +} + +Write-Host "" + +# Step 3: Install Visual Studio Components +if (-not $SkipVSComponents) { + Write-Host "[3/4] Checking Visual Studio components" + + $vsConfigPath = Join-Path $repoRoot ".vsconfig" + if (-not (Test-Path $vsConfigPath)) { + Write-Warning " .vsconfig not found at $vsConfigPath" + } else { + $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + + if (-not $VSInstallPath -and (Test-Path $vsWhere)) { + $VSInstallPath = & $vsWhere -latest -property installationPath 2>$null + } + + if (-not $VSInstallPath) { + $commonPaths = @( + "${env:ProgramFiles}\Microsoft Visual Studio\18\Enterprise", + "${env:ProgramFiles}\Microsoft Visual Studio\18\Professional", + "${env:ProgramFiles}\Microsoft Visual Studio\18\Community", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community" + ) + foreach ($path in $commonPaths) { + if (Test-Path $path) { + $VSInstallPath = $path + break + } + } + } + + if (-not $VSInstallPath -or -not (Test-Path $VSInstallPath)) { + Write-Warning " Could not find Visual Studio installation" + Write-Warning " Please install Visual Studio 2026 (or 2022 17.4+) and try again, or import .vsconfig manually" + } else { + Write-Host " Found: $VSInstallPath" -ForegroundColor DarkGray + + $vsInstaller = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vs_installer.exe" + + if (Test-Path $vsInstaller) { + Write-Host "" + Write-Host " To install required components:" + Write-Host "" + Write-Host " Option A - Visual Studio Installer GUI:" + Write-Host " 1. Open Visual Studio Installer" + Write-Host " 2. Click 'More' > 'Import configuration'" + Write-Host " 3. Select: $vsConfigPath" + Write-Host "" + Write-Host " Option B - Command line (close VS first):" + Write-Host " & `"$vsInstaller`" modify --installPath `"$VSInstallPath`" --config `"$vsConfigPath`"" -ForegroundColor DarkGray + Write-Host "" + + $choices = @( + [System.Management.Automation.Host.ChoiceDescription]::new("&Install", "Run VS Installer now"), + [System.Management.Automation.Host.ChoiceDescription]::new("&Skip", "Continue without installing") + ) + + try { + $decision = $Host.UI.PromptForChoice("", "Install VS components now?", $choices, 1) + + if ($decision -eq 0) { + # Check if VS Installer is already running (it runs as setup.exe from the Installer folder) + $vsInstallerDir = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer" + $vsInstallerRunning = Get-Process -Name "setup" -ErrorAction SilentlyContinue | + Where-Object { $_.Path -and $_.Path.StartsWith($vsInstallerDir, [System.StringComparison]::OrdinalIgnoreCase) } + if ($vsInstallerRunning) { + Write-Warning " Visual Studio Installer is already running" + Write-Host " Close it and run this script again, or import .vsconfig manually" -ForegroundColor DarkGray + } else { + Write-Host " Launching Visual Studio Installer..." + Write-Host " Close Visual Studio if it's running." -ForegroundColor DarkGray + $process = Start-Process -FilePath $vsInstaller -ArgumentList "modify", "--installPath", "`"$VSInstallPath`"", "--config", "`"$vsConfigPath`"" -Wait -PassThru + if ($process.ExitCode -eq 0) { + Write-Host " VS component installation completed" -ForegroundColor Green + } elseif ($process.ExitCode -eq 3010) { + Write-Host " VS component installation completed (restart may be required)" -ForegroundColor Green + } else { + Write-Warning " VS Installer exited with code $($process.ExitCode)" + Write-Host " You may need to run the installer manually" -ForegroundColor DarkGray + } + } + } else { + Write-Host " Skipped VS component installation" + } + } catch { + Write-Host " Non-interactive mode. Run the command above manually if needed." -ForegroundColor DarkGray + } + } else { + Write-Warning " Visual Studio Installer not found" + } + } + } +} else { + Write-Host "[3/4] Skipping VS component check" -ForegroundColor DarkGray +} + +Write-Host "" + +# Step 4: Initialize Git Submodules +if (-not $SkipSubmodules) { + Write-Host "[4/4] Initializing git submodules" + + Push-Location $repoRoot + try { + $submoduleStatus = git submodule status 2>&1 + $uninitializedCount = ($submoduleStatus | Where-Object { $_ -match '^\-' }).Count + + if ($uninitializedCount -eq 0 -and $submoduleStatus) { + Write-Host " Submodules already initialized" -ForegroundColor Green + } else { + Write-Host " Running: git submodule update --init --recursive" -ForegroundColor DarkGray + git submodule update --init --recursive + if ($LASTEXITCODE -eq 0) { + Write-Host " Submodules initialized" -ForegroundColor Green + } else { + Write-Warning " Submodule initialization may have encountered issues (exit code: $LASTEXITCODE)" + } + } + } catch { + Write-Warning " Failed to initialize submodules: $_" + } finally { + Pop-Location + } +} else { + Write-Host "[4/4] Skipping submodule initialization" -ForegroundColor DarkGray +} + +Write-Host "" +Write-Host "Setup complete" -ForegroundColor Green +Write-Host "" +Write-Host "Next steps:" +Write-Host " 1. Open PowerToys.slnx in Visual Studio" +Write-Host " 2. If prompted to install additional components, click Install" +Write-Host " 3. Build the solution (Ctrl+Shift+B)" +Write-Host "" +Write-Host "Or build from command line:" +Write-Host " .\tools\build\build.ps1" -ForegroundColor DarkGray +Write-Host "" diff --git a/tools/clear-copilot-context.ps1 b/tools/clear-copilot-context.ps1 new file mode 100644 index 0000000000..c389550f8e --- /dev/null +++ b/tools/clear-copilot-context.ps1 @@ -0,0 +1,25 @@ +# Clear Copilot context files +# This script removes AGENTS.md and related copilot instruction files + +$repoRoot = Split-Path -Parent $PSScriptRoot +if (-not $repoRoot) { + $repoRoot = (Get-Location).Path +} + +$filesToRemove = @( + "AGENTS.md", + ".github\instructions\runner-settings-ui.instructions.md", + ".github\instructions\common-libraries.instructions.md" +) + +foreach ($file in $filesToRemove) { + $filePath = Join-Path $repoRoot $file + if (Test-Path $filePath) { + Remove-Item $filePath -Force + Write-Host "Removed: $filePath" -ForegroundColor Green + } else { + Write-Host "Not found: $filePath" -ForegroundColor Yellow + } +} + +Write-Host "Done." -ForegroundColor Cyan diff --git a/tools/mcp/github-artifacts/.gitignore b/tools/mcp/github-artifacts/.gitignore new file mode 100644 index 0000000000..504afef81f --- /dev/null +++ b/tools/mcp/github-artifacts/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/tools/mcp/github-artifacts/launch.js b/tools/mcp/github-artifacts/launch.js new file mode 100644 index 0000000000..b35acfc743 --- /dev/null +++ b/tools/mcp/github-artifacts/launch.js @@ -0,0 +1,14 @@ +import { existsSync } from "fs"; +import { execSync } from "child_process"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +process.chdir(__dirname); + +if (!existsSync("node_modules")) { + console.log("[MCP] Installing dependencies..."); + execSync("npm install", { stdio: "inherit" }); +} + +import("./server.js"); \ No newline at end of file diff --git a/tools/mcp/github-artifacts/package.json b/tools/mcp/github-artifacts/package.json new file mode 100644 index 0000000000..86fe6c8812 --- /dev/null +++ b/tools/mcp/github-artifacts/package.json @@ -0,0 +1,19 @@ +{ + "name": "github-artifacts", + "version": "1.0.0", + "description": "", + "main": "server.js", + "scripts": { + "test": "node test-github_issue_images.js && node test-github_issue_attachments.js", + "start": "node server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.2", + "jszip": "^3.10.1", + "zod": "^3.25.76" + } +} diff --git a/tools/mcp/github-artifacts/server.js b/tools/mcp/github-artifacts/server.js new file mode 100644 index 0000000000..6d1a15fbcf --- /dev/null +++ b/tools/mcp/github-artifacts/server.js @@ -0,0 +1,385 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import fs from "fs/promises"; +import path from "path"; +import { execFile } from "child_process"; + +const server = new McpServer({ + name: "issue-images", + version: "0.1.0" +}); + +const GH_API_PER_PAGE = 100; +const MAX_TEXT_FILE_BYTES = 100000; +// Limit to common text files to avoid binary blobs, huge payloads, and non-UTF8 noise. +const TEXT_EXTENSIONS = new Set([ + ".txt", ".log", ".json", ".xml", ".yaml", ".yml", ".md", ".csv", + ".ini", ".config", ".conf", ".bat", ".ps1", ".sh", ".reg", ".etl" +]); + +function extractImageUrls(markdownOrHtml) { + const urls = new Set(); + + // Markdown images: ![alt](url) + for (const m of markdownOrHtml.matchAll(/!\[[^\]]*?\]\((https?:\/\/[^\s)]+)\)/g)) { + urls.add(m[1]); + } + + // HTML <img src="..."> + for (const m of markdownOrHtml.matchAll(/<img[^>]+src="(https?:\/\/[^">]+)"/g)) { + urls.add(m[1]); + } + + return [...urls]; +} + +function extractZipUrls(markdownOrHtml) { + const urls = new Set(); + + // Markdown links to .zip files: [text](url.zip) + for (const m of markdownOrHtml.matchAll(/\[[^\]]*?\]\((https?:\/\/[^\s)]+\.zip)\)/gi)) { + urls.add(m[1]); + } + + // Plain URLs ending in .zip + for (const m of markdownOrHtml.matchAll(/(https?:\/\/[^\s<>"]+\.zip)/gi)) { + urls.add(m[1]); + } + + return [...urls]; +} + +async function fetchJson(url, token) { + const res = await fetch(url, { + headers: { + "Accept": "application/vnd.github+json", + ...(token ? { "Authorization": `Bearer ${token}` } : {}), + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "issue-images-mcp" + } + }); + if (!res.ok) throw new Error(`GitHub API failed: ${res.status} ${res.statusText}`); + return await res.json(); +} + +async function downloadBytes(url, token) { + const res = await fetch(url, { + headers: { + ...(token ? { "Authorization": `Bearer ${token}` } : {}), + "User-Agent": "issue-images-mcp" + } + }); + if (!res.ok) throw new Error(`Image download failed: ${res.status} ${res.statusText}`); + const buf = new Uint8Array(await res.arrayBuffer()); + const ct = res.headers.get("content-type") || "image/png"; + return { buf, mimeType: ct }; +} + +async function downloadZipBytes(url, token) { + const zipUrl = url.includes("?") ? url : `${url}?download=1`; + + const tryFetch = async (useAuth) => { + const res = await fetch(zipUrl, { + headers: { + "Accept": "application/octet-stream", + ...(useAuth && token ? { "Authorization": `Bearer ${token}` } : {}), + "User-Agent": "issue-images-mcp" + }, + redirect: "follow" + }); + + if (!res.ok) throw new Error(`ZIP download failed: ${res.status} ${res.statusText}`); + + const contentType = (res.headers.get("content-type") || "").toLowerCase(); + const buf = new Uint8Array(await res.arrayBuffer()); + + return { buf, contentType }; + }; + + let { buf, contentType } = await tryFetch(true); + const isZip = buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b; + + if (!isZip) { + ({ buf, contentType } = await tryFetch(false)); + } + + const isZipRetry = buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b; + + if (!isZipRetry || contentType.includes("text/html") || buf.length < 100) { + throw new Error("ZIP download returned HTML or invalid data. Check permissions or rate limits."); + } + + return buf; +} + +function execFileAsync(file, args) { + return new Promise((resolve, reject) => { + execFile(file, args, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)); + } else { + resolve(stdout); + } + }); + }); +} + +async function extractZipToFolder(zipPath, extractPath) { + if (process.platform === "win32") { + await execFileAsync("powershell", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `$ProgressPreference='SilentlyContinue'; Expand-Archive -Path \"${zipPath}\" -DestinationPath \"${extractPath}\" -Force -ErrorAction Stop | Out-Null` + ]); + return; + } + + await execFileAsync("unzip", ["-o", zipPath, "-d", extractPath]); +} + +async function listFilesRecursively(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await listFilesRecursively(fullPath)); + } else { + files.push(fullPath); + } + } + return files; +} + +async function pathExists(p) { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function fetchAllComments(owner, repo, issueNumber, token) { + let comments = []; + let page = 1; + + while (true) { + const pageComments = await fetchJson( + `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${GH_API_PER_PAGE}&page=${page}`, + token + ); + comments = comments.concat(pageComments); + if (pageComments.length < GH_API_PER_PAGE) break; + page++; + } + + return comments; +} + +async function fetchIssueAndComments(owner, repo, issueNumber, token) { + const issue = await fetchJson(`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`, token); + const comments = issue.comments > 0 ? await fetchAllComments(owner, repo, issueNumber, token) : []; + return { issue, comments }; +} + +function buildBlobs(issue, comments) { + return [issue.body || "", ...comments.map(c => c.body || "")].join("\n\n---\n\n"); +} + +server.registerTool( + "github_issue_images", + { + title: "GitHub Issue Images", + description: `Download and return images from a GitHub issue or pull request. + +USE THIS TOOL WHEN: +- User asks about a GitHub issue/PR that contains screenshots, images, or visual content +- User wants to understand a bug report with attached images +- User asks to analyze, describe, or review images in an issue/PR +- User references a GitHub issue/PR URL and the context suggests images are relevant +- User asks about UI bugs, visual glitches, design issues, or anything visual in nature + +WHAT IT DOES: +- Fetches all images from the issue/PR body and all comments +- Returns actual image data (not just URLs) so the LLM can see and analyze the images +- Supports PNG, JPEG, GIF, and other common image formats + +EXAMPLES OF WHEN TO USE: +- "What does the bug in issue #123 look like?" +- "Can you see the screenshot in this PR?" +- "Analyze the images in microsoft/PowerToys#25595" +- "What UI problem is shown in this issue?"`, + inputSchema: { + owner: z.string(), + repo: z.string(), + issueNumber: z.number(), + maxImages: z.number().min(1).max(20).optional() + }, + outputSchema: { + images: z.number(), + comments: z.number() + } + }, + async ({ owner, repo, issueNumber, maxImages = 20 }) => { + try { + const token = process.env.GITHUB_TOKEN; + const { issue, comments } = await fetchIssueAndComments(owner, repo, issueNumber, token); + const blobs = buildBlobs(issue, comments); + const urls = extractImageUrls(blobs).slice(0, maxImages); + + const content = [ + { type: "text", text: `Found ${urls.length} image(s) in issue #${issueNumber} (from ${comments.length} comments). Returning as image parts.` } + ]; + + for (const url of urls) { + const { buf, mimeType } = await downloadBytes(url, token); + const b64 = Buffer.from(buf).toString("base64"); + content.push({ type: "image", data: b64, mimeType }); + content.push({ type: "text", text: `Image source: ${url}` }); + } + + const output = { images: urls.length, comments: comments.length }; + return { content, structuredContent: output }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `Error: ${message}` }], isError: true }; + } + } +); + +server.registerTool( + "github_issue_attachments", + { + title: "GitHub Issue Attachments", + description: `Download and extract ZIP file attachments from a GitHub issue or pull request. + +USE THIS TOOL WHEN: +- User asks about diagnostic logs, crash reports, or debug information in an issue +- Issue contains ZIP attachments like PowerToysReport_*.zip, logs.zip, debug.zip +- User wants to analyze log files, configuration files, or system info from an issue +- User asks about error logs, stack traces, or diagnostic data attached to an issue +- Issue mentions attached files that need to be examined + +WHAT IT DOES: +- Finds all ZIP file attachments in the issue body and comments +- Downloads and extracts each ZIP to a local folder +- Returns file listing and contents of text files (logs, json, xml, txt, etc.) +- Each ZIP is extracted to: {extractFolder}/{zipFileName}/ + +EXAMPLES OF WHEN TO USE: +- "What's in the diagnostic report attached to issue #39476?" +- "Can you check the logs in the PowerToysReport zip?" +- "Analyze the crash dump attached to this issue" +- "What error is shown in the attached log files?"`, + inputSchema: { + owner: z.string(), + repo: z.string(), + issueNumber: z.number(), + extractFolder: z.string(), + maxFiles: z.number().min(1).optional() + }, + outputSchema: { + zips: z.number(), + extracted: z.number(), + extractedTo: z.array(z.string()) + } + }, + async ({ owner, repo, issueNumber, extractFolder, maxFiles = 50 }) => { + try { + const token = process.env.GITHUB_TOKEN; + const { issue, comments } = await fetchIssueAndComments(owner, repo, issueNumber, token); + const blobs = buildBlobs(issue, comments); + const zipUrls = extractZipUrls(blobs); + + if (zipUrls.length === 0) { + return { + content: [{ type: "text", text: `No ZIP attachments found in issue #${issueNumber}.` }], + structuredContent: { zips: 0, extracted: 0, extractedTo: [] } + }; + } + + await fs.mkdir(extractFolder, { recursive: true }); + + const content = [ + { type: "text", text: `Found ${zipUrls.length} ZIP attachment(s) in issue #${issueNumber}. Extracting to: ${extractFolder}` } + ]; + + let totalFilesReturned = 0; + const extractedPaths = []; + let extractedCount = 0; + + for (const zipUrl of zipUrls) { + try { + const urlPath = new URL(zipUrl).pathname; + const zipFileName = path.basename(urlPath, ".zip"); + const extractPath = path.join(extractFolder, zipFileName); + const zipPath = path.join(extractFolder, `${zipFileName}.zip`); + + let extractedFiles = []; + const extractPathExists = await pathExists(extractPath); + + if (extractPathExists) { + extractedFiles = await listFilesRecursively(extractPath); + } + + if (!extractPathExists || extractedFiles.length === 0) { + const zipExists = await pathExists(zipPath); + if (!zipExists) { + const buf = await downloadZipBytes(zipUrl, token); + await fs.writeFile(zipPath, buf); + } + + await fs.mkdir(extractPath, { recursive: true }); + await extractZipToFolder(zipPath, extractPath); + extractedFiles = await listFilesRecursively(extractPath); + } + + extractedPaths.push(extractPath); + extractedCount++; + + const fileList = []; + const textContents = []; + + for (const fullPath of extractedFiles) { + const relPath = path.relative(extractPath, fullPath).replace(/\\/g, "/"); + const ext = path.extname(relPath).toLowerCase(); + const stat = await fs.stat(fullPath); + const sizeKB = Math.round(stat.size / 1024); + fileList.push(` ${relPath} (${sizeKB} KB)`); + + if (TEXT_EXTENSIONS.has(ext) && totalFilesReturned < maxFiles && stat.size < MAX_TEXT_FILE_BYTES) { + try { + const textContent = await fs.readFile(fullPath, "utf-8"); + textContents.push({ path: relPath, content: textContent }); + totalFilesReturned++; + } catch { + // Not valid UTF-8, skip + } + } + } + + content.push({ type: "text", text: `\n📦 ${zipFileName}.zip extracted to: ${extractPath}\nFiles:\n${fileList.join("\n")}` }); + + for (const { path: fPath, content: fContent } of textContents) { + content.push({ type: "text", text: `\n--- ${fPath} ---\n${fContent}` }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + content.push({ type: "text", text: `❌ Failed to extract ${zipUrl}: ${message}` }); + } + } + + const output = { zips: zipUrls.length, extracted: extractedCount, extractedTo: extractedPaths }; + return { content, structuredContent: output }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { content: [{ type: "text", text: `Error: ${message}` }], isError: true }; + } + } +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/tools/mcp/github-artifacts/test-github_issue_attachments.js b/tools/mcp/github-artifacts/test-github_issue_attachments.js new file mode 100644 index 0000000000..496900a056 --- /dev/null +++ b/tools/mcp/github-artifacts/test-github_issue_attachments.js @@ -0,0 +1,141 @@ +// Test script for github_issue_attachments tool +// Run with: node test-github_issue_attachments.js +// Make sure GITHUB_TOKEN is set in environment + +import { spawn } from "child_process"; +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const extractFolder = path.join(__dirname, "test-extracts"); + +const server = spawn("node", ["server.js"], { + stdio: ["pipe", "pipe", "inherit"], + env: { ...process.env } +}); + +// Send initialize request +const initRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" } + } +}; + +server.stdin.write(JSON.stringify(initRequest) + "\n"); + +// Send list tools request +setTimeout(() => { + const listToolsRequest = { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {} + }; + server.stdin.write(JSON.stringify(listToolsRequest) + "\n"); +}, 500); + +// Send call tool request - test with PowerToys issue that has ZIP attachments +setTimeout(() => { + const callToolRequest = { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "github_issue_attachments", + arguments: { + owner: "microsoft", + repo: "PowerToys", + issueNumber: 39476, // Has PowerToysReport_*.zip attachment + extractFolder: extractFolder, + maxFiles: 20 + } + } + }; + server.stdin.write(JSON.stringify(callToolRequest) + "\n"); +}, 1000); + +// Track summary +let summary = { zips: 0, files: 0, extractPath: "" }; +let buffer = ""; + +// Read responses +server.stdout.on("data", (data) => { + buffer += data.toString(); + + // Try to parse complete JSON objects from buffer + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue; + try { + const response = JSON.parse(line); + console.log("\n=== Response ==="); + console.log("ID:", response.id); + if (response.result?.tools) { + console.log("Tools:", response.result.tools.map(t => t.name)); + } else if (response.result?.content) { + for (const item of response.result.content) { + if (item.type === "text") { + // Truncate long file contents for display + const text = item.text; + if (text.startsWith("---") && text.length > 500) { + console.log(text.substring(0, 500) + "\n... [truncated]"); + } else { + console.log(text); + } + + // Track stats + if (text.includes("ZIP attachment")) { + const match = text.match(/Found (\d+) ZIP/); + if (match) summary.zips = parseInt(match[1]); + } + if (text.includes("extracted to:")) { + summary.extractPath = text.match(/extracted to: (.+)/)?.[1] || ""; + } + if (text.includes("Files:")) { + summary.files = (text.match(/ /g) || []).length; + } + } + } + } else { + console.log("Result:", JSON.stringify(response.result, null, 2)); + } + } catch (e) { + // Likely incomplete JSON, will be in next chunk + } + } +}); + +// Exit after 60 seconds (ZIP download may take time) +setTimeout(() => { + console.log("\n" + "=".repeat(50)); + console.log("=== Test Summary ==="); + console.log("=".repeat(50)); + console.log(`ZIP files found: ${summary.zips}`); + console.log(`Files extracted: ${summary.files}`); + if (summary.extractPath) { + console.log(`Extract location: ${summary.extractPath}`); + } + console.log("=".repeat(50)); + console.log("Cleaning up extracted files..."); + fs.rm(extractFolder, { recursive: true, force: true }) + .then(() => { + console.log("Cleanup done."); + }) + .catch((err) => { + console.log(`Cleanup failed: ${err.message}`); + }) + .finally(() => { + console.log("Test complete!"); + server.kill(); + process.exit(0); + }); +}, 60000); diff --git a/tools/mcp/github-artifacts/test-github_issue_images.js b/tools/mcp/github-artifacts/test-github_issue_images.js new file mode 100644 index 0000000000..9d4ef66ca0 --- /dev/null +++ b/tools/mcp/github-artifacts/test-github_issue_images.js @@ -0,0 +1,118 @@ +// Simple test script - run with: node test-github_issue_images.js +// Make sure GITHUB_TOKEN is set in environment + +import { spawn } from "child_process"; + +const server = spawn("node", ["server.js"], { + stdio: ["pipe", "pipe", "inherit"], + env: { ...process.env } +}); + +// Send initialize request +const initRequest = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" } + } +}; + +server.stdin.write(JSON.stringify(initRequest) + "\n"); + +// Send list tools request +setTimeout(() => { + const listToolsRequest = { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: {} + }; + server.stdin.write(JSON.stringify(listToolsRequest) + "\n"); +}, 500); + +// Send call tool request (test with a real issue) +setTimeout(() => { + const callToolRequest = { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { + name: "github_issue_images", + arguments: { + owner: "microsoft", + repo: "PowerToys", + issueNumber: 25595, // 315 comments, many images - tests pagination! + maxImages: 5 + } + } + }; + server.stdin.write(JSON.stringify(callToolRequest) + "\n"); +}, 1000); + +// Track summary +let summary = { images: 0, totalKB: 0, text: "" }; +let gotToolResponse = false; +let buffer = ""; + +// Read responses +server.stdout.on("data", (data) => { + buffer += data.toString(); + + // Try to parse complete JSON objects from buffer + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue; + try { + const response = JSON.parse(line); + console.log("\n=== Response ==="); + console.log("ID:", response.id); + if (response.result?.tools) { + console.log("Tools:", response.result.tools.map(t => t.name)); + } else if (response.result?.content) { + gotToolResponse = true; + let imageCount = 0; + for (const item of response.result.content) { + if (item.type === "text") { + console.log("Text:", item.text); + summary.text = item.text; + } else if (item.type === "image") { + imageCount++; + const sizeKB = Math.round(item.data.length * 0.75 / 1024); // base64 to actual size + console.log(` [Image ${imageCount}] ${item.mimeType} - ${sizeKB} KB downloaded`); + summary.images++; + summary.totalKB += sizeKB; + } + } + } else { + console.log("Result:", JSON.stringify(response.result, null, 2)); + } + } catch (e) { + // Likely incomplete JSON, will be in next chunk + } + } +}); + +// Exit after 60 seconds (more time for downloads) +setTimeout(() => { + console.log("\n" + "=".repeat(50)); + console.log("=== Test Summary ==="); + console.log("=".repeat(50)); + if (summary.text) { + console.log(summary.text); + } + if (summary.images > 0) { + console.log(`Total images downloaded: ${summary.images}`); + console.log(`Total size: ${summary.totalKB} KB`); + } else if (!gotToolResponse) { + console.log("No tool response received yet. The request may still be running or was rate-limited."); + } + console.log("=".repeat(50)); + console.log("Test complete!"); + server.kill(); + process.exit(0); +}, 60000); diff --git a/tools/module_loader/ModuleLoader.manifest b/tools/module_loader/ModuleLoader.manifest new file mode 100644 index 0000000000..2607358482 --- /dev/null +++ b/tools/module_loader/ModuleLoader.manifest @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"> + <assemblyIdentity + version="1.0.0.0" + processorArchitecture="*" + name="Microsoft.PowerToys.ModuleLoader" + type="win32" + /> + <description>PowerToys Module Loader - Standalone module testing utility</description> + + <!-- Per-Monitor DPI Awareness V2 --> + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware> + <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> + </windowsSettings> + </application> + + <!-- Request administrator execution level if needed --> + <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2"> + <security> + <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3"> + <requestedExecutionLevel level="asInvoker" uiAccess="false" /> + </requestedPrivileges> + </security> + </trustInfo> + + <!-- Windows 10+ compatibility --> + <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <!-- Windows 10 --> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/> + <!-- Windows 11 --> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9b}"/> + </application> + </compatibility> +</assembly> diff --git a/tools/module_loader/ModuleLoader.vcxproj b/tools/module_loader/ModuleLoader.vcxproj new file mode 100644 index 0000000000..8917ecbc78 --- /dev/null +++ b/tools/module_loader/ModuleLoader.vcxproj @@ -0,0 +1,205 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup Label="ProjectConfigurations"> + <ProjectConfiguration Include="Debug|x64"> + <Configuration>Debug</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Debug|ARM64"> + <Configuration>Debug</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|x64"> + <Configuration>Release</Configuration> + <Platform>x64</Platform> + </ProjectConfiguration> + <ProjectConfiguration Include="Release|ARM64"> + <Configuration>Release</Configuration> + <Platform>ARM64</Platform> + </ProjectConfiguration> + </ItemGroup> + <PropertyGroup Label="Globals"> + <VCProjectVersion>17.0</VCProjectVersion> + <Keyword>Win32Proj</Keyword> + <ProjectGuid>{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}</ProjectGuid> + <RootNamespace>ModuleLoader</RootNamespace> + <WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion> + <ProjectName>ModuleLoader</ProjectName> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration"> + <ConfigurationType>Application</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'" Label="Configuration"> + <ConfigurationType>Application</ConfigurationType> + <UseDebugLibraries>true</UseDebugLibraries> + + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration"> + <ConfigurationType>Application</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'" Label="Configuration"> + <ConfigurationType>Application</ConfigurationType> + <UseDebugLibraries>false</UseDebugLibraries> + + <WholeProgramOptimization>true</WholeProgramOptimization> + <CharacterSet>Unicode</CharacterSet> + </PropertyGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" /> + <ImportGroup Label="ExtensionSettings"> + </ImportGroup> + <ImportGroup Label="Shared"> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'"> + <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" /> + </ImportGroup> + <PropertyGroup Label="UserMacros" /> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\</OutDir> + <IntDir>$(Platform)\$(Configuration)\</IntDir> + <TargetName>ModuleLoader</TargetName> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'"> + <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\</OutDir> + <IntDir>$(Platform)\$(Configuration)\</IntDir> + <TargetName>ModuleLoader</TargetName> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\</OutDir> + <IntDir>$(Platform)\$(Configuration)\</IntDir> + <TargetName>ModuleLoader</TargetName> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'"> + <OutDir>$(SolutionDir)$(Platform)\$(Configuration)\</OutDir> + <IntDir>$(Platform)\$(Configuration)\</IntDir> + <TargetName>ModuleLoader</TargetName> + </PropertyGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> + <ClCompile> + <WarningLevel>Level4</WarningLevel> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <LanguageStandard>stdcpp20</LanguageStandard> + <AdditionalIncludeDirectories>$(ProjectDir)src;$(RepoRoot)src\modules\interface;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + <TreatWarningAsError>false</TreatWarningAsError> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <AdditionalDependencies>kernel32.lib;user32.lib;Shcore.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalManifestDependencies>type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'</AdditionalManifestDependencies> + </Link> + <Manifest> + <AdditionalManifestFiles>$(ProjectDir)ModuleLoader.manifest</AdditionalManifestFiles> + </Manifest> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'"> + <ClCompile> + <WarningLevel>Level4</WarningLevel> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <LanguageStandard>stdcpp20</LanguageStandard> + <AdditionalIncludeDirectories>$(ProjectDir)src;$(RepoRoot)src\modules\interface;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + <TreatWarningAsError>false</TreatWarningAsError> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <GenerateDebugInformation>true</GenerateDebugInformation> + <AdditionalDependencies>kernel32.lib;user32.lib;Shcore.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalManifestDependencies>type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'</AdditionalManifestDependencies> + </Link> + <Manifest> + <AdditionalManifestFiles>$(ProjectDir)ModuleLoader.manifest</AdditionalManifestFiles> + </Manifest> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'"> + <ClCompile> + <WarningLevel>Level4</WarningLevel> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <LanguageStandard>stdcpp20</LanguageStandard> + <AdditionalIncludeDirectories>$(ProjectDir)src;$(RepoRoot)src\modules\interface;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + <TreatWarningAsError>false</TreatWarningAsError> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <AdditionalDependencies>kernel32.lib;user32.lib;Shcore.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalManifestDependencies>type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'</AdditionalManifestDependencies> + </Link> + <Manifest> + <AdditionalManifestFiles>$(ProjectDir)ModuleLoader.manifest</AdditionalManifestFiles> + </Manifest> + </ItemDefinitionGroup> + <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'"> + <ClCompile> + <WarningLevel>Level4</WarningLevel> + <FunctionLevelLinking>true</FunctionLevelLinking> + <IntrinsicFunctions>true</IntrinsicFunctions> + <SDLCheck>true</SDLCheck> + <PreprocessorDefinitions>NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions> + <ConformanceMode>true</ConformanceMode> + <LanguageStandard>stdcpp20</LanguageStandard> + <AdditionalIncludeDirectories>$(ProjectDir)src;$(RepoRoot)src\modules\interface;$(RepoRoot)src;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <PrecompiledHeader>NotUsing</PrecompiledHeader> + <TreatWarningAsError>false</TreatWarningAsError> + </ClCompile> + <Link> + <SubSystem>Console</SubSystem> + <EnableCOMDATFolding>true</EnableCOMDATFolding> + <OptimizeReferences>true</OptimizeReferences> + <GenerateDebugInformation>true</GenerateDebugInformation> + <AdditionalDependencies>kernel32.lib;user32.lib;Shcore.lib;%(AdditionalDependencies)</AdditionalDependencies> + <AdditionalManifestDependencies>type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'</AdditionalManifestDependencies> + </Link> + <Manifest> + <AdditionalManifestFiles>$(ProjectDir)ModuleLoader.manifest</AdditionalManifestFiles> + </Manifest> + </ItemDefinitionGroup> + <ItemGroup> + <ClCompile Include="src\main.cpp" /> + <ClCompile Include="src\ModuleLoader.cpp" /> + <ClCompile Include="src\SettingsLoader.cpp" /> + <ClCompile Include="src\HotkeyManager.cpp" /> + <ClCompile Include="src\ConsoleHost.cpp" /> + </ItemGroup> + <ItemGroup> + <ClInclude Include="src\ModuleLoader.h" /> + <ClInclude Include="src\SettingsLoader.h" /> + <ClInclude Include="src\HotkeyManager.h" /> + <ClInclude Include="src\ConsoleHost.h" /> + </ItemGroup> + <ItemGroup> + <None Include="README.md" /> + </ItemGroup> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> + <ImportGroup Label="ExtensionTargets"> + </ImportGroup> +</Project> diff --git a/tools/module_loader/ModuleLoader.vcxproj.filters b/tools/module_loader/ModuleLoader.vcxproj.filters new file mode 100644 index 0000000000..823f1c4e60 --- /dev/null +++ b/tools/module_loader/ModuleLoader.vcxproj.filters @@ -0,0 +1,51 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <ItemGroup> + <Filter Include="Source Files"> + <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier> + <Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions> + </Filter> + <Filter Include="Header Files"> + <UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier> + <Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions> + </Filter> + <Filter Include="Resource Files"> + <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier> + <Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions> + </Filter> + </ItemGroup> + <ItemGroup> + <ClCompile Include="src\main.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="src\ModuleLoader.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="src\SettingsLoader.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="src\HotkeyManager.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + <ClCompile Include="src\ConsoleHost.cpp"> + <Filter>Source Files</Filter> + </ClCompile> + </ItemGroup> + <ItemGroup> + <ClInclude Include="src\ModuleLoader.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="src\SettingsLoader.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="src\HotkeyManager.h"> + <Filter>Header Files</Filter> + </ClInclude> + <ClInclude Include="src\ConsoleHost.h"> + <Filter>Header Files</Filter> + </ClInclude> + </ItemGroup> + <ItemGroup> + <None Include="README.md" /> + </ItemGroup> +</Project> diff --git a/tools/module_loader/SHARING.md b/tools/module_loader/SHARING.md new file mode 100644 index 0000000000..1923d31adf --- /dev/null +++ b/tools/module_loader/SHARING.md @@ -0,0 +1,483 @@ +# Sharing ModuleLoader and Modules + +This guide explains how to share the ModuleLoader tool and PowerToy modules with others for testing purposes. + +## Overview + +The ModuleLoader is designed to be a **portable, standalone testing tool** that can be shared with module developers and testers. It has minimal dependencies and can work with any compatible PowerToy module DLL. + +--- + +## What You Need to Share + +### For Testing a Module (e.g., CursorWrap) + +#### **Minimum Package** (Recommended for Quick Testing) + +1. **ModuleLoader.exe** - The standalone loader application + - Location: `x64\Debug\ModuleLoader.exe` or `x64\Release\ModuleLoader.exe` + - No additional DLLs required (uses only Windows system libraries) + +2. **The Module DLL** - The PowerToy module to test + - Example: `CursorWrap.dll` from `x64\Debug\` or `x64\Release\` + - Location varies by module (see module-specific locations below) + +3. **settings.json** - Module configuration (place in same folder as the DLL) + - **NEW**: Settings can be placed alongside the module DLL for portable testing + - Location: Same directory as the module DLL (e.g., `settings.json` next to `CursorWrap.dll`) + - Falls back to: `%LOCALAPPDATA%\Microsoft\PowerToys\<ModuleName>\settings.json` if not found locally + +#### **Complete Standalone Package** (For Users Without PowerToys Installed) + +1. **ModuleLoader.exe** +2. **Module DLL** +3. **Sample settings.json** - Pre-configured settings file +4. **Installation instructions** - See "Standalone Package Setup" section below + +--- + +### Debug Builds +If you build the module in Debug configuration: +- The module will output debug messages via `OutputDebugString()` +- View these with [DebugView](https://learn.microsoft.com/sysinternals/downloads/debugview) or Visual Studio Output window +- Example: CursorWrap outputs detailed topology and cursor wrapping debug info + +--- + + +## Module-Specific File Locations + +### CursorWrap +``` +Files to share: + - x64\Debug\CursorWrap.dll (or Release) + - %LOCALAPPDATA%\Microsoft\PowerToys\CursorWrap\settings.json + +Size: ~100KB +``` + +### MouseHighlighter +``` +Files to share: + - x64\Debug\MouseHighlighter.dll (or Release) + - %LOCALAPPDATA%\Microsoft\PowerToys\MouseHighlighter\settings.json + +Size: ~150KB +``` + +### FindMyMouse +``` +Files to share: + - x64\Debug\FindMyMouse.dll (or Release) + - %LOCALAPPDATA%\Microsoft\PowerToys\FindMyMouse\settings.json + +Size: ~120KB +``` + +### MousePointerCrosshairs +``` +Files to share: + - x64\Debug\MousePointerCrosshairs.dll (or Release) + - %LOCALAPPDATA%\Microsoft\PowerToys\MousePointerCrosshairs\settings.json + +Size: ~140KB +``` + +### MouseJump +``` +Files to share: + - x64\Debug\MouseJump.dll (or Release) + - %LOCALAPPDATA%\Microsoft\PowerToys\MouseJump\settings.json + +Note: MouseJump is a UI-based module and may not work fully with ModuleLoader +Size: ~200KB +``` + +### AlwaysOnTop +``` +Files to share: + - x64\Debug\AlwaysOnTop.dll (or Release) + - %LOCALAPPDATA%\Microsoft\PowerToys\AlwaysOnTop\settings.json + +Size: ~100KB +``` + +--- + +## Dependency Analysis + +### ModuleLoader.exe Dependencies +**Windows System Libraries Only** (automatically available on all Windows systems): +- `KERNEL32.dll` - Core Windows API +- `USER32.dll` - User interface functions +- `SHELL32.dll` - Shell functions +- `ole32.dll` - COM library + +**No PowerToys dependencies required!** The ModuleLoader is completely standalone. + +### Module DLL Dependencies (Typical) +Most PowerToy modules depend on: +- Windows system DLLs (automatically available) +- PowerToys common libraries (if any, they're typically statically linked) +- **Module settings** - Must be present in `%LOCALAPPDATA%\Microsoft\PowerToys\<ModuleName>\` + +**Important**: Modules are generally **self-contained** and statically link most dependencies. You typically only need the module DLL itself. + +--- + +## Creating a Standalone Package + +### Step 1: Prepare the Files + +Create a folder structure like this: +``` +ModuleLoaderPackage\ +??? ModuleLoader.exe +??? CursorWrap.dll (or other module) +??? settings.json (module settings - placed locally!) +``` + +**NEW Simplified Structure**: You can now place `settings.json` directly alongside the module DLL! The ModuleLoader will check this location first before looking in the standard PowerToys settings directories. + +### Step 2: Extract Settings from Your Machine + +```powershell +# Copy settings from your development machine +$moduleName = "CursorWrap" # Change as needed +$settingsPath = "$env:LOCALAPPDATA\Microsoft\PowerToys\$moduleName\settings.json" +Copy-Item $settingsPath ".\settings\$moduleName\settings.json" +``` + +### Step 3: Create Installation Instructions (README.txt) + +```text +PowerToys Module Testing Package +================================= + +This package contains the ModuleLoader tool for testing PowerToy modules. + +Contents: + - ModuleLoader.exe : Standalone module loader + - modules\*.dll : PowerToy module(s) to test + - settings\*\*.json : Module configuration files + +Setup (First Time): +------------------- +1. Create settings directory: + %LOCALAPPDATA%\Microsoft\PowerToys\ + +2. Copy settings: + Copy the entire "settings\<ModuleName>" folder to: + %LOCALAPPDATA%\Microsoft\PowerToys\ + + Example for CursorWrap: + Copy "settings\CursorWrap" to: + %LOCALAPPDATA%\Microsoft\PowerToys\CursorWrap\ + +Usage: +------ +ModuleLoader.exe modules\CursorWrap.dll + +The tool will: + - Load the module DLL + - Read settings from %LOCALAPPDATA%\Microsoft\PowerToys\<ModuleName>\ + - Register hotkeys + - Enable the module + +Press Ctrl+C to exit. +Press the module's hotkey to toggle functionality. + +Requirements: +------------- +- Windows 10 1803 or later +- No PowerToys installation required! + +Troubleshooting: +---------------- +If you see "Settings file not found": + 1. Make sure you copied the settings folder correctly + 2. Check that the path is: + %LOCALAPPDATA%\Microsoft\PowerToys\<ModuleName>\settings.json + 3. You can also run PowerToys once to generate default settings + +Debug Logs: +----------- +Module logs are written to: + %LOCALAPPDATA%\Microsoft\PowerToys\<ModuleName>\Logs\ + +For debug builds, use DebugView to see real-time output. +``` + +--- + +## Quick Distribution Methods + +### Method 1: ZIP Archive +```powershell +# Create a complete package +$moduleName = "CursorWrap" +$packageName = "ModuleLoader-$moduleName-Package" + +# Collect files +New-Item $packageName -ItemType Directory +Copy-Item "x64\Debug\ModuleLoader.exe" "$packageName\" +New-Item "$packageName\modules" -ItemType Directory +Copy-Item "x64\Debug\$moduleName.dll" "$packageName\modules\" +New-Item "$packageName\settings\$moduleName" -ItemType Directory -Force +Copy-Item "$env:LOCALAPPDATA\Microsoft\PowerToys\$moduleName\settings.json" "$packageName\settings\$moduleName\" + +# Create README +@" +See README in the tools\module_loader folder for instructions +"@ | Out-File "$packageName\README.txt" + +# Zip it +Compress-Archive -Path $packageName -DestinationPath "$packageName.zip" +``` + +### Method 2: Direct Share (Advanced Users) +For developers who already have PowerToys installed: +```powershell +# Just share the executables +Copy-Item "x64\Debug\ModuleLoader.exe" "\\ShareLocation\" +Copy-Item "x64\Debug\CursorWrap.dll" "\\ShareLocation\" +``` + +They can run: `ModuleLoader.exe CursorWrap.dll` +(Settings will be loaded from their existing PowerToys installation) + +--- + +## Platform-Specific Notes + +### x64 vs ARM64 + +**Important**: Match architectures! +- `x64\Debug\ModuleLoader.exe` ? Only works with `x64` module DLLs +- `ARM64\Debug\ModuleLoader.exe` ? Only works with `ARM64` module DLLs + +**Distribution Tip**: Provide both architectures if targeting multiple platforms: +``` +ModuleLoaderPackage\ +??? x64\ +? ??? ModuleLoader.exe +? ??? modules\CursorWrap.dll +??? ARM64\ +? ??? ModuleLoader.exe +? ??? modules\CursorWrap.dll +??? settings\... +``` + +### Debug vs Release + +**Debug builds**: +- Larger file size +- Include debug symbols +- Verbose logging via `OutputDebugString()` +- Recommended for testing/development + +**Release builds**: +- Smaller file size +- Optimized performance +- Minimal logging +- Recommended for end-user testing + +--- + +## Testing Checklist + +Before sharing a module package: + +- [ ] ModuleLoader.exe is included +- [ ] Module DLL is included (matching architecture) +- [ ] Sample settings.json is included +- [ ] README/instructions are included +- [ ] Tested on a clean machine (no PowerToys installed) +- [ ] Verified hotkeys work +- [ ] Verified Ctrl+C exits cleanly +- [ ] Confirmed settings path in documentation + +--- + +## Advanced: Portable Package Script + +Here's a complete PowerShell script to create a fully portable package: + +```powershell +param( + [Parameter(Mandatory=$true)] + [string]$ModuleName, + + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Debug", + + [ValidateSet("x64", "ARM64")] + [string]$Platform = "x64" +) + +$packageName = "ModuleLoader-$ModuleName-$Platform-$Configuration" +$packagePath = ".\$packageName" + +Write-Host "Creating portable package: $packageName" -ForegroundColor Green + +# Create structure +New-Item $packagePath -ItemType Directory -Force | Out-Null +New-Item "$packagePath\modules" -ItemType Directory -Force | Out-Null +New-Item "$packagePath\settings\$ModuleName" -ItemType Directory -Force | Out-Null + +# Copy ModuleLoader +$loaderPath = "$Platform\$Configuration\ModuleLoader.exe" +if (Test-Path $loaderPath) { + Copy-Item $loaderPath "$packagePath\" + Write-Host "? Copied ModuleLoader.exe" -ForegroundColor Green +} else { + Write-Host "? ModuleLoader.exe not found at $loaderPath" -ForegroundColor Red + exit 1 +} + +# Copy Module DLL +$modulePath = "$Platform\$Configuration\$ModuleName.dll" +if (Test-Path $modulePath) { + Copy-Item $modulePath "$packagePath\modules\" + Write-Host "? Copied $ModuleName.dll" -ForegroundColor Green +} else { + Write-Host "? $ModuleName.dll not found at $modulePath" -ForegroundColor Red + exit 1 +} + +# Copy Settings +$settingsPath = "$env:LOCALAPPDATA\Microsoft\PowerToys\$ModuleName\settings.json" +if (Test-Path $settingsPath) { + Copy-Item $settingsPath "$packagePath\settings\$ModuleName\" + Write-Host "? Copied settings.json" -ForegroundColor Green +} else { + Write-Host "? Settings not found at $settingsPath - creating placeholder" -ForegroundColor Yellow + @" +{ + "name": "$ModuleName", + "version": "1.0" +} +"@ | Out-File "$packagePath\settings\$ModuleName\settings.json" +} + +# Create README +@" +PowerToys $ModuleName Testing Package +====================================== + +Configuration: $Configuration +Platform: $Platform + +Setup Instructions: +------------------- +1. Copy the 'settings\$ModuleName' folder to: + %LOCALAPPDATA%\Microsoft\PowerToys\ + +2. Run: + ModuleLoader.exe modules\$ModuleName.dll + +3. Press Ctrl+C to exit + +Logs are written to: + %LOCALAPPDATA%\Microsoft\PowerToys\$ModuleName\Logs\ + +For more information, see: + https://github.com/microsoft/PowerToys/tree/main/tools/module_loader +"@ | Out-File "$packagePath\README.txt" + +# Create ZIP +$zipPath = "$packageName.zip" +Compress-Archive -Path $packagePath -DestinationPath $zipPath -Force +Write-Host "? Created $zipPath" -ForegroundColor Green + +# Show summary +Write-Host "`nPackage Contents:" -ForegroundColor Cyan +Get-ChildItem $packagePath -Recurse | ForEach-Object { + Write-Host " $($_.FullName.Replace($packagePath, ''))" +} + +Write-Host "`nPackage ready: $zipPath" -ForegroundColor Green +Write-Host "Size: $([math]::Round((Get-Item $zipPath).Length / 1KB, 2)) KB" +``` + +**Usage**: +```powershell +.\CreateModulePackage.ps1 -ModuleName "CursorWrap" -Configuration Release -Platform x64 +``` + +--- + +## FAQ + +### Q: Can I share just ModuleLoader.exe and the module DLL? +**A**: Yes, but the recipient must have PowerToys installed (or manually create the settings file). + +### Q: Does the tester need PowerToys installed? +**A**: No, if you provide the complete package with settings. ModuleLoader is fully standalone. + +### Q: What if settings.json doesn't exist? +**A**: ModuleLoader will show an error. Either: +1. Run PowerToys once with the module enabled to generate settings +2. Manually create a minimal settings.json file +3. Include a sample settings.json in your package + +### Q: Can I test modules on a virtual machine? +**A**: Yes! This is a great use case. Just copy the package to the VM - no PowerToys installation needed. + +### Q: Do I need to include PDB files? +**A**: Only for debugging. For normal testing, just the EXE and DLL are sufficient. + +### Q: Can I distribute this to end users? +**A**: ModuleLoader is a **development/testing tool**, not intended for end-user distribution. For production use, direct users to install PowerToys. + +--- + +## Security Considerations + +When sharing module DLLs: + +1. **Verify Source**: Only share modules you built from trusted source code +2. **Scan for Malware**: Run antivirus scans on the package before sharing +3. **HTTPS Only**: Use secure channels (HTTPS, OneDrive, SharePoint) for distribution +4. **Hash Verification**: Consider providing SHA256 hashes for file integrity: + ```powershell + Get-FileHash ModuleLoader.exe -Algorithm SHA256 + Get-FileHash modules\CursorWrap.dll -Algorithm SHA256 + ``` + +--- + +## Example Package (CursorWrap) + +Here's what a complete CursorWrap testing package looks like: + +``` +ModuleLoader-CursorWrap-x64-Debug.zip (220 KB) +? +??? ModuleLoader-CursorWrap-x64-Debug\ + ??? ModuleLoader.exe (160 KB) + ??? README.txt (2 KB) + ??? modules\ + ? ??? CursorWrap.dll (55 KB) + ??? settings\ + ??? CursorWrap\ + ??? settings.json (3 KB) +``` + +**Total package size**: ~220 KB (compressed) + +--- + +## Support + +For issues with ModuleLoader, see: +- [ModuleLoader README](./README.md) +- [PowerToys Documentation](https://aka.ms/PowerToysOverview) +- [PowerToys GitHub Issues](https://github.com/microsoft/PowerToys/issues) + +--- + +## License + +ModuleLoader is part of PowerToys and is licensed under the MIT License. +See the LICENSE file in the PowerToys repository root for details. diff --git a/tools/module_loader/src/ConsoleHost.cpp b/tools/module_loader/src/ConsoleHost.cpp new file mode 100644 index 0000000000..1ab2cdefa2 --- /dev/null +++ b/tools/module_loader/src/ConsoleHost.cpp @@ -0,0 +1,80 @@ +// 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 "ConsoleHost.h" +#include <iostream> + +bool ConsoleHost::s_exitRequested = false; + +ConsoleHost::ConsoleHost(ModuleLoader& moduleLoader, HotkeyManager& hotkeyManager) + : m_moduleLoader(moduleLoader) + , m_hotkeyManager(hotkeyManager) +{ +} + +ConsoleHost::~ConsoleHost() +{ +} + +BOOL WINAPI ConsoleHost::ConsoleCtrlHandler(DWORD ctrlType) +{ + switch (ctrlType) + { + case CTRL_C_EVENT: + case CTRL_BREAK_EVENT: + case CTRL_CLOSE_EVENT: + std::wcout << L"\nCtrl+C received, shutting down...\n"; + s_exitRequested = true; + + // Post a quit message to break the message loop + PostQuitMessage(0); + return TRUE; + + default: + return FALSE; + } +} + +void ConsoleHost::Run() +{ + // Install console control handler + if (!SetConsoleCtrlHandler(ConsoleCtrlHandler, TRUE)) + { + std::wcerr << L"Warning: Failed to set console control handler\n"; + } + + s_exitRequested = false; + + // Message loop + MSG msg; + while (!s_exitRequested) + { + // Wait for a message with a timeout so we can check s_exitRequested + DWORD result = MsgWaitForMultipleObjects(0, nullptr, FALSE, 100, QS_ALLINPUT); + + if (result == WAIT_OBJECT_0) + { + // Process all pending messages + while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) + { + if (msg.message == WM_QUIT) + { + s_exitRequested = true; + break; + } + + if (msg.message == WM_HOTKEY) + { + m_hotkeyManager.HandleHotkey(static_cast<int>(msg.wParam), m_moduleLoader); + } + + TranslateMessage(&msg); + DispatchMessage(&msg); + } + } + } + + // Remove console control handler + SetConsoleCtrlHandler(ConsoleCtrlHandler, FALSE); +} diff --git a/tools/module_loader/src/ConsoleHost.h b/tools/module_loader/src/ConsoleHost.h new file mode 100644 index 0000000000..153fdaa0f0 --- /dev/null +++ b/tools/module_loader/src/ConsoleHost.h @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once + +#include <Windows.h> +#include "ModuleLoader.h" +#include "HotkeyManager.h" + +/// <summary> +/// Console host that runs the message loop and handles Ctrl+C +/// </summary> +class ConsoleHost +{ +public: + ConsoleHost(ModuleLoader& moduleLoader, HotkeyManager& hotkeyManager); + ~ConsoleHost(); + + // Prevent copying + ConsoleHost(const ConsoleHost&) = delete; + ConsoleHost& operator=(const ConsoleHost&) = delete; + + /// <summary> + /// Run the message loop until Ctrl+C is pressed + /// </summary> + void Run(); + +private: + ModuleLoader& m_moduleLoader; + HotkeyManager& m_hotkeyManager; + static bool s_exitRequested; + + /// <summary> + /// Console control handler (for Ctrl+C) + /// </summary> + static BOOL WINAPI ConsoleCtrlHandler(DWORD ctrlType); +}; diff --git a/tools/module_loader/src/HotkeyManager.cpp b/tools/module_loader/src/HotkeyManager.cpp new file mode 100644 index 0000000000..ce0ced5a03 --- /dev/null +++ b/tools/module_loader/src/HotkeyManager.cpp @@ -0,0 +1,279 @@ +// 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 "HotkeyManager.h" +#include <iostream> +#include <sstream> + +HotkeyManager::HotkeyManager() + : m_nextHotkeyId(1) // Start from 1 + , m_hotkeyExRegistered(false) + , m_hotkeyExId(0) +{ +} + +HotkeyManager::~HotkeyManager() +{ + UnregisterAll(); +} + +UINT HotkeyManager::ConvertModifiers(bool win, bool ctrl, bool alt, bool shift) const +{ + UINT modifiers = MOD_NOREPEAT; // Prevent repeat events + if (win) modifiers |= MOD_WIN; + if (ctrl) modifiers |= MOD_CONTROL; + if (alt) modifiers |= MOD_ALT; + if (shift) modifiers |= MOD_SHIFT; + return modifiers; +} + +bool HotkeyManager::RegisterModuleHotkeys(ModuleLoader& moduleLoader) +{ + if (!moduleLoader.IsLoaded()) + { + std::wcerr << L"Error: Module not loaded\n"; + return false; + } + + bool anyRegistered = false; + + // First, try the newer GetHotkeyEx() API + auto hotkeyEx = moduleLoader.GetHotkeyEx(); + if (hotkeyEx.has_value()) + { + std::wcout << L"Module has HotkeyEx activation hotkey\n"; + + UINT modifiers = hotkeyEx->modifiersMask | MOD_NOREPEAT; + UINT vkCode = hotkeyEx->vkCode; + + if (vkCode != 0) + { + int hotkeyId = m_nextHotkeyId++; + + std::wcout << L" Registering HotkeyEx: "; + std::wcout << ModifiersToString(modifiers) << L"+" << VKeyToString(vkCode); + + if (RegisterHotKey(nullptr, hotkeyId, modifiers, vkCode)) + { + m_hotkeyExRegistered = true; + m_hotkeyExId = hotkeyId; + + std::wcout << L" - OK (Activation/Toggle)\n"; + anyRegistered = true; + } + else + { + DWORD error = GetLastError(); + std::wcout << L" - FAILED (Error: " << error << L")\n"; + + if (error == ERROR_HOTKEY_ALREADY_REGISTERED) + { + std::wcout << L" (Hotkey is already registered by another application)\n"; + } + } + } + } + + // Also check the legacy get_hotkeys() API + size_t hotkeyCount = moduleLoader.GetHotkeys(nullptr, 0); + if (hotkeyCount > 0) + { + std::wcout << L"Module reports " << hotkeyCount << L" legacy hotkey(s)\n"; + + // Allocate buffer and get the hotkeys + std::vector<PowertoyModuleIface::Hotkey> hotkeys(hotkeyCount); + size_t actualCount = moduleLoader.GetHotkeys(hotkeys.data(), hotkeyCount); + + // Register each hotkey + for (size_t i = 0; i < actualCount; i++) + { + const auto& hotkey = hotkeys[i]; + + UINT modifiers = ConvertModifiers(hotkey.win, hotkey.ctrl, hotkey.alt, hotkey.shift); + UINT vkCode = hotkey.key; + + if (vkCode == 0) + { + std::wcout << L" Skipping hotkey " << i << L" (no key code)\n"; + continue; + } + + int hotkeyId = m_nextHotkeyId++; + + std::wcout << L" Registering hotkey " << i << L": "; + std::wcout << ModifiersToString(modifiers) << L"+" << VKeyToString(vkCode); + + if (RegisterHotKey(nullptr, hotkeyId, modifiers, vkCode)) + { + HotkeyInfo info; + info.id = hotkeyId; + info.moduleHotkeyId = i; + info.modifiers = modifiers; + info.vkCode = vkCode; + info.description = ModifiersToString(modifiers) + L"+" + VKeyToString(vkCode); + + m_registeredHotkeys.push_back(info); + std::wcout << L" - OK\n"; + anyRegistered = true; + } + else + { + DWORD error = GetLastError(); + std::wcout << L" - FAILED (Error: " << error << L")\n"; + + if (error == ERROR_HOTKEY_ALREADY_REGISTERED) + { + std::wcout << L" (Hotkey is already registered by another application)\n"; + } + } + } + } + + if (!anyRegistered && hotkeyCount == 0 && !hotkeyEx.has_value()) + { + std::wcout << L"Module has no hotkeys\n"; + } + + return anyRegistered; +} + +void HotkeyManager::UnregisterAll() +{ + for (const auto& hotkey : m_registeredHotkeys) + { + UnregisterHotKey(nullptr, hotkey.id); + } + m_registeredHotkeys.clear(); + + if (m_hotkeyExRegistered) + { + UnregisterHotKey(nullptr, m_hotkeyExId); + m_hotkeyExRegistered = false; + m_hotkeyExId = 0; + } +} + +bool HotkeyManager::HandleHotkey(int hotkeyId, ModuleLoader& moduleLoader) +{ + // Check if it's the HotkeyEx activation hotkey + if (m_hotkeyExRegistered && hotkeyId == m_hotkeyExId) + { + std::wcout << L"\nActivation hotkey triggered (HotkeyEx)\n"; + + moduleLoader.OnHotkeyEx(); + + std::wcout << L"Module toggled via activation hotkey\n"; + std::wcout << L"Module enabled: " << (moduleLoader.IsEnabled() ? L"Yes" : L"No") << L"\n\n"; + + return true; + } + + // Check legacy hotkeys + for (const auto& hotkey : m_registeredHotkeys) + { + if (hotkey.id == hotkeyId) + { + std::wcout << L"\nHotkey triggered: " << hotkey.description << L"\n"; + + bool result = moduleLoader.OnHotkey(hotkey.moduleHotkeyId); + + std::wcout << L"Module handled hotkey: " << (result ? L"Swallowed" : L"Not swallowed") << L"\n"; + std::wcout << L"Module enabled: " << (moduleLoader.IsEnabled() ? L"Yes" : L"No") << L"\n\n"; + + return true; + } + } + + return false; +} + +void HotkeyManager::PrintHotkeys() const +{ + for (const auto& hotkey : m_registeredHotkeys) + { + std::wcout << L" " << hotkey.description << L"\n"; + } +} + +std::wstring HotkeyManager::ModifiersToString(UINT modifiers) const +{ + std::wstringstream ss; + bool first = true; + + if (modifiers & MOD_WIN) + { + if (!first) ss << L"+"; + ss << L"Win"; + first = false; + } + if (modifiers & MOD_CONTROL) + { + if (!first) ss << L"+"; + ss << L"Ctrl"; + first = false; + } + if (modifiers & MOD_ALT) + { + if (!first) ss << L"+"; + ss << L"Alt"; + first = false; + } + if (modifiers & MOD_SHIFT) + { + if (!first) ss << L"+"; + ss << L"Shift"; + first = false; + } + + return ss.str(); +} + +std::wstring HotkeyManager::VKeyToString(UINT vkCode) const +{ + // Handle special keys + switch (vkCode) + { + case VK_SPACE: return L"Space"; + case VK_RETURN: return L"Enter"; + case VK_ESCAPE: return L"Esc"; + case VK_TAB: return L"Tab"; + case VK_BACK: return L"Backspace"; + case VK_DELETE: return L"Del"; + case VK_INSERT: return L"Ins"; + case VK_HOME: return L"Home"; + case VK_END: return L"End"; + case VK_PRIOR: return L"PgUp"; + case VK_NEXT: return L"PgDn"; + case VK_LEFT: return L"Left"; + case VK_RIGHT: return L"Right"; + case VK_UP: return L"Up"; + case VK_DOWN: return L"Down"; + case VK_F1: return L"F1"; + case VK_F2: return L"F2"; + case VK_F3: return L"F3"; + case VK_F4: return L"F4"; + case VK_F5: return L"F5"; + case VK_F6: return L"F6"; + case VK_F7: return L"F7"; + case VK_F8: return L"F8"; + case VK_F9: return L"F9"; + case VK_F10: return L"F10"; + case VK_F11: return L"F11"; + case VK_F12: return L"F12"; + } + + // For alphanumeric keys, use MapVirtualKey + wchar_t keyName[256]; + UINT scanCode = MapVirtualKeyW(vkCode, MAPVK_VK_TO_VSC); + + if (GetKeyNameTextW(scanCode << 16, keyName, 256) > 0) + { + return keyName; + } + + // Fallback to hex code + std::wstringstream ss; + ss << L"0x" << std::hex << vkCode; + return ss.str(); +} diff --git a/tools/module_loader/src/HotkeyManager.h b/tools/module_loader/src/HotkeyManager.h new file mode 100644 index 0000000000..714e5a0962 --- /dev/null +++ b/tools/module_loader/src/HotkeyManager.h @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma once + +#include <Windows.h> +#include <string> +#include <vector> +#include <map> +#include "ModuleLoader.h" + +/// <summary> +/// Manages hotkey registration using RegisterHotKey API +/// </summary> +class HotkeyManager +{ +public: + HotkeyManager(); + ~HotkeyManager(); + + // Prevent copying + HotkeyManager(const HotkeyManager&) = delete; + HotkeyManager& operator=(const HotkeyManager&) = delete; + + /// <summary> + /// Register all hotkeys from a module + /// </summary> + /// <param name="moduleLoader">Module to get hotkeys from</param> + /// <returns>True if at least one hotkey was registered</returns> + bool RegisterModuleHotkeys(ModuleLoader& moduleLoader); + + /// <summary> + /// Unregister all hotkeys + /// </summary> + void UnregisterAll(); + + /// <summary> + /// Handle a WM_HOTKEY message + /// </summary> + /// <param name="hotkeyId">ID from the WM_HOTKEY message</param> + /// <param name="moduleLoader">Module to trigger the hotkey on</param> + /// <returns>True if the hotkey was handled</returns> + bool HandleHotkey(int hotkeyId, ModuleLoader& moduleLoader); + + /// <summary> + /// Get the number of registered hotkeys + /// </summary> + /// <returns>Number of registered hotkeys</returns> + size_t GetRegisteredCount() const { return m_registeredHotkeys.size() + (m_hotkeyExRegistered ? 1 : 0); } + + /// <summary> + /// Print registered hotkeys to console + /// </summary> + void PrintHotkeys() const; + +private: + struct HotkeyInfo + { + int id = 0; + size_t moduleHotkeyId = 0; + UINT modifiers = 0; + UINT vkCode = 0; + std::wstring description; + }; + + std::vector<HotkeyInfo> m_registeredHotkeys; + int m_nextHotkeyId; + bool m_hotkeyExRegistered; + int m_hotkeyExId; + + /// <summary> + /// Convert modifier bools to RegisterHotKey modifiers + /// </summary> + UINT ConvertModifiers(bool win, bool ctrl, bool alt, bool shift) const; + + /// <summary> + /// Get a string representation of modifiers + /// </summary> + std::wstring ModifiersToString(UINT modifiers) const; + + /// <summary> + /// Get a string representation of a virtual key code + /// </summary> + std::wstring VKeyToString(UINT vkCode) const; +}; diff --git a/tools/module_loader/src/ModuleLoader.cpp b/tools/module_loader/src/ModuleLoader.cpp new file mode 100644 index 0000000000..3334e2ab42 --- /dev/null +++ b/tools/module_loader/src/ModuleLoader.cpp @@ -0,0 +1,183 @@ +// 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 "ModuleLoader.h" +#include <iostream> +#include <stdexcept> + +ModuleLoader::ModuleLoader() + : m_hModule(nullptr) + , m_module(nullptr) +{ +} + +ModuleLoader::~ModuleLoader() +{ + if (m_module) + { + try + { + m_module->destroy(); + } + catch (...) + { + // Ignore exceptions during cleanup + } + m_module = nullptr; + } + + if (m_hModule) + { + FreeLibrary(m_hModule); + m_hModule = nullptr; + } +} + +bool ModuleLoader::Load(const std::wstring& dllPath) +{ + if (m_hModule || m_module) + { + std::wcerr << L"Error: Module already loaded\n"; + return false; + } + + m_dllPath = dllPath; + + // Load the DLL + m_hModule = LoadLibraryW(dllPath.c_str()); + if (!m_hModule) + { + DWORD error = GetLastError(); + std::wcerr << L"Error: Failed to load DLL. Error code: " << error << L"\n"; + return false; + } + + // Get the powertoy_create function + using powertoy_create_func = PowertoyModuleIface* (*)(); + auto create_func = reinterpret_cast<powertoy_create_func>( + GetProcAddress(m_hModule, "powertoy_create")); + + if (!create_func) + { + std::wcerr << L"Error: DLL does not export 'powertoy_create' function\n"; + FreeLibrary(m_hModule); + m_hModule = nullptr; + return false; + } + + // Create the module instance + m_module = create_func(); + if (!m_module) + { + std::wcerr << L"Error: powertoy_create() returned nullptr\n"; + FreeLibrary(m_hModule); + m_hModule = nullptr; + return false; + } + + std::wcout << L"Module instance created successfully\n"; + return true; +} + +void ModuleLoader::Enable() +{ + if (!m_module) + { + throw std::runtime_error("Module not loaded"); + } + + m_module->enable(); +} + +void ModuleLoader::Disable() +{ + if (!m_module) + { + return; + } + + m_module->disable(); +} + +bool ModuleLoader::IsEnabled() const +{ + if (!m_module) + { + return false; + } + + return m_module->is_enabled(); +} + +void ModuleLoader::SetConfig(const std::wstring& configJson) +{ + if (!m_module) + { + throw std::runtime_error("Module not loaded"); + } + + m_module->set_config(configJson.c_str()); +} + +std::wstring ModuleLoader::GetModuleName() const +{ + if (!m_module) + { + return L"<not loaded>"; + } + + const wchar_t* name = m_module->get_name(); + return name ? name : L"<unknown>"; +} + +std::wstring ModuleLoader::GetModuleKey() const +{ + if (!m_module) + { + return L"<not loaded>"; + } + + const wchar_t* key = m_module->get_key(); + return key ? key : L"<unknown>"; +} + +size_t ModuleLoader::GetHotkeys(PowertoyModuleIface::Hotkey* buffer, size_t bufferSize) +{ + if (!m_module) + { + return 0; + } + + return m_module->get_hotkeys(buffer, bufferSize); +} + +bool ModuleLoader::OnHotkey(size_t hotkeyId) +{ + if (!m_module) + { + return false; + } + + return m_module->on_hotkey(hotkeyId); +} + +std::optional<PowertoyModuleIface::HotkeyEx> ModuleLoader::GetHotkeyEx() +{ + if (!m_module) + { + return std::nullopt; + } + + return m_module->GetHotkeyEx(); +} + +void ModuleLoader::OnHotkeyEx() +{ + if (!m_module) + { + return; + } + + m_module->OnHotkeyEx(); +} diff --git a/tools/module_loader/src/ModuleLoader.h b/tools/module_loader/src/ModuleLoader.h new file mode 100644 index 0000000000..5c155913f4 --- /dev/null +++ b/tools/module_loader/src/ModuleLoader.h @@ -0,0 +1,102 @@ +// 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 <Windows.h> +#include <string> +#include <vector> +#include <powertoy_module_interface.h> + +/// <summary> +/// Wrapper class for loading and managing a PowerToy module DLL +/// </summary> +class ModuleLoader +{ +public: + ModuleLoader(); + ~ModuleLoader(); + + // Prevent copying + ModuleLoader(const ModuleLoader&) = delete; + ModuleLoader& operator=(const ModuleLoader&) = delete; + + /// <summary> + /// Load a PowerToy module DLL + /// </summary> + /// <param name="dllPath">Path to the module DLL</param> + /// <returns>True if successful, false otherwise</returns> + bool Load(const std::wstring& dllPath); + + /// <summary> + /// Enable the loaded module + /// </summary> + void Enable(); + + /// <summary> + /// Disable the loaded module + /// </summary> + void Disable(); + + /// <summary> + /// Check if the module is enabled + /// </summary> + /// <returns>True if enabled, false otherwise</returns> + bool IsEnabled() const; + + /// <summary> + /// Set configuration for the module + /// </summary> + /// <param name="configJson">JSON configuration string</param> + void SetConfig(const std::wstring& configJson); + + /// <summary> + /// Get the module's localized name + /// </summary> + /// <returns>Module name</returns> + std::wstring GetModuleName() const; + + /// <summary> + /// Get the module's non-localized key + /// </summary> + /// <returns>Module key</returns> + std::wstring GetModuleKey() const; + + /// <summary> + /// Get the module's hotkeys + /// </summary> + /// <param name="buffer">Buffer to store hotkeys</param> + /// <param name="bufferSize">Size of the buffer</param> + /// <returns>Number of hotkeys returned</returns> + size_t GetHotkeys(PowertoyModuleIface::Hotkey* buffer, size_t bufferSize); + + /// <summary> + /// Trigger a hotkey callback on the module + /// </summary> + /// <param name="hotkeyId">ID of the hotkey to trigger</param> + /// <returns>True if the key press should be swallowed</returns> + bool OnHotkey(size_t hotkeyId); + + /// <summary> + /// Check if the module is loaded + /// </summary> + /// <returns>True if loaded, false otherwise</returns> + bool IsLoaded() const { return m_module != nullptr; } + + /// <summary> + /// Get the module's activation hotkey (newer HotkeyEx API) + /// </summary> + /// <returns>Optional HotkeyEx struct</returns> + std::optional<PowertoyModuleIface::HotkeyEx> GetHotkeyEx(); + + /// <summary> + /// Trigger the newer-style hotkey callback on the module + /// </summary> + void OnHotkeyEx(); + +private: + HMODULE m_hModule; + PowertoyModuleIface* m_module; + std::wstring m_dllPath; +}; diff --git a/tools/module_loader/src/SettingsLoader.cpp b/tools/module_loader/src/SettingsLoader.cpp new file mode 100644 index 0000000000..2d1c869ba1 --- /dev/null +++ b/tools/module_loader/src/SettingsLoader.cpp @@ -0,0 +1,182 @@ +// 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 "SettingsLoader.h" +#include <iostream> +#include <fstream> +#include <sstream> +#include <filesystem> +#include <Shlobj.h> + +SettingsLoader::SettingsLoader() +{ +} + +SettingsLoader::~SettingsLoader() +{ +} + +std::wstring SettingsLoader::GetPowerToysSettingsRoot() const +{ + // Get %LOCALAPPDATA% + PWSTR localAppDataPath = nullptr; + HRESULT hr = SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &localAppDataPath); + + if (FAILED(hr) || !localAppDataPath) + { + std::wcerr << L"Error: Failed to get LOCALAPPDATA path\n"; + return L""; + } + + std::wstring result(localAppDataPath); + CoTaskMemFree(localAppDataPath); + + // Append PowerToys directory + result += L"\\Microsoft\\PowerToys"; + return result; +} + +std::wstring SettingsLoader::GetSettingsPath(const std::wstring& moduleName) const +{ + std::wstring root = GetPowerToysSettingsRoot(); + if (root.empty()) + { + return L""; + } + + // Construct path: %LOCALAPPDATA%\Microsoft\PowerToys\<ModuleName>\settings.json + std::wstring settingsPath = root + L"\\" + moduleName + L"\\settings.json"; + return settingsPath; +} + +std::wstring SettingsLoader::ReadFileContents(const std::wstring& filePath) const +{ + std::wifstream file(filePath, std::ios::binary); + if (!file.is_open()) + { + std::wcerr << L"Error: Could not open file: " << filePath << L"\n"; + return L""; + } + + // Read the entire file + std::wstringstream buffer; + buffer << file.rdbuf(); + + return buffer.str(); +} + +std::wstring SettingsLoader::LoadSettings(const std::wstring& moduleName, const std::wstring& moduleDllPath) +{ + const std::wstring powerToysPrefix = L"PowerToys."; + + // Build list of possible module name variations to try + std::vector<std::wstring> moduleNameVariants; + + // Try exact name first + moduleNameVariants.push_back(moduleName); + + // If doesn't start with "PowerToys.", try adding it + if (moduleName.find(powerToysPrefix) != 0) + { + moduleNameVariants.push_back(powerToysPrefix + moduleName); + } + // If starts with "PowerToys.", try without it + else + { + moduleNameVariants.push_back(moduleName.substr(powerToysPrefix.length())); + } + + // FIRST: Try same directory as the module DLL + if (!moduleDllPath.empty()) + { + std::filesystem::path dllPath(moduleDllPath); + std::filesystem::path dllDirectory = dllPath.parent_path(); + + std::wstring localSettingsPath = (dllDirectory / L"settings.json").wstring(); + std::wcout << L"Trying settings path (module directory): " << localSettingsPath << L"\n"; + + if (std::filesystem::exists(localSettingsPath)) + { + std::wstring contents = ReadFileContents(localSettingsPath); + if (!contents.empty()) + { + std::wcout << L"Settings file loaded from module directory (" << contents.size() << L" characters)\n"; + return contents; + } + } + } + + // SECOND: Try standard PowerToys settings locations + for (const auto& variant : moduleNameVariants) + { + std::wstring settingsPath = GetSettingsPath(variant); + + std::wcout << L"Trying settings path: " << settingsPath << L"\n"; + + // Check if file exists (case-sensitive path) + if (std::filesystem::exists(settingsPath)) + { + std::wstring contents = ReadFileContents(settingsPath); + if (!contents.empty()) + { + std::wcout << L"Settings file loaded (" << contents.size() << L" characters)\n"; + return contents; + } + } + else + { + // Try case-insensitive search in the parent directory + std::wstring root = GetPowerToysSettingsRoot(); + if (!root.empty() && std::filesystem::exists(root)) + { + try + { + // Search for a directory that matches case-insensitively + for (const auto& entry : std::filesystem::directory_iterator(root)) + { + if (entry.is_directory()) + { + std::wstring dirName = entry.path().filename().wstring(); + + // Case-insensitive comparison + if (_wcsicmp(dirName.c_str(), variant.c_str()) == 0) + { + std::wstring actualSettingsPath = entry.path().wstring() + L"\\settings.json"; + std::wcout << L"Found case-insensitive match: " << actualSettingsPath << L"\n"; + + if (std::filesystem::exists(actualSettingsPath)) + { + std::wstring contents = ReadFileContents(actualSettingsPath); + if (!contents.empty()) + { + std::wcout << L"Settings file loaded (" << contents.size() << L" characters)\n"; + return contents; + } + } + } + } + } + } + catch (const std::filesystem::filesystem_error& e) + { + std::wcerr << L"Error searching directory: " << e.what() << L"\n"; + } + } + } + } + + std::wcerr << L"Error: Settings file not found in any expected location:\n"; + if (!moduleDllPath.empty()) + { + std::filesystem::path dllPath(moduleDllPath); + std::filesystem::path dllDirectory = dllPath.parent_path(); + std::wcerr << L" - " << (dllDirectory / L"settings.json").wstring() << L" (module directory)\n"; + } + for (const auto& variant : moduleNameVariants) + { + std::wcerr << L" - " << GetSettingsPath(variant) << L"\n"; + } + + return L""; +} diff --git a/tools/module_loader/src/SettingsLoader.h b/tools/module_loader/src/SettingsLoader.h new file mode 100644 index 0000000000..e005fdd4b2 --- /dev/null +++ b/tools/module_loader/src/SettingsLoader.h @@ -0,0 +1,47 @@ +// 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 <Windows.h> +#include <string> + +/// <summary> +/// Utility class for discovering and loading PowerToy module settings +/// </summary> +class SettingsLoader +{ +public: + SettingsLoader(); + ~SettingsLoader(); + + /// <summary> + /// Load settings for a PowerToy module + /// </summary> + /// <param name="moduleName">Name of the module (e.g., "CursorWrap")</param> + /// <param name="moduleDllPath">Full path to the module DLL (for checking local settings.json)</param> + /// <returns>JSON settings string, or empty string if not found</returns> + std::wstring LoadSettings(const std::wstring& moduleName, const std::wstring& moduleDllPath); + + /// <summary> + /// Get the settings file path for a module + /// </summary> + /// <param name="moduleName">Name of the module</param> + /// <returns>Full path to the settings.json file</returns> + std::wstring GetSettingsPath(const std::wstring& moduleName) const; + +private: + /// <summary> + /// Get the PowerToys root settings directory + /// </summary> + /// <returns>Path to %LOCALAPPDATA%\Microsoft\PowerToys</returns> + std::wstring GetPowerToysSettingsRoot() const; + + /// <summary> + /// Read a text file into a string + /// </summary> + /// <param name="filePath">Path to the file</param> + /// <returns>File contents as a string</returns> + std::wstring ReadFileContents(const std::wstring& filePath) const; +}; diff --git a/tools/module_loader/src/main.cpp b/tools/module_loader/src/main.cpp new file mode 100644 index 0000000000..fc9894e623 --- /dev/null +++ b/tools/module_loader/src/main.cpp @@ -0,0 +1,244 @@ +// 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 <Windows.h> +#include <Tlhelp32.h> +#include <iostream> +#include <string> +#include <filesystem> +#include "ModuleLoader.h" +#include "SettingsLoader.h" +#include "HotkeyManager.h" +#include "ConsoleHost.h" + +namespace +{ + void PrintUsage() + { + std::wcout << L"PowerToys Module Loader - Standalone utility for loading and testing PowerToy modules\n\n"; + std::wcout << L"Usage: ModuleLoader.exe <module_dll_path>\n\n"; + std::wcout << L"Arguments:\n"; + std::wcout << L" module_dll_path Path to the PowerToy module DLL (e.g., CursorWrap.dll)\n\n"; + std::wcout << L"Behavior:\n"; + std::wcout << L" - Automatically discovers settings from %%LOCALAPPDATA%%\\Microsoft\\PowerToys\\<ModuleName>\\settings.json\n"; + std::wcout << L" - Loads and enables the module\n"; + std::wcout << L" - Registers module hotkeys\n"; + std::wcout << L" - Runs until Ctrl+C is pressed\n\n"; + std::wcout << L"Examples:\n"; + std::wcout << L" ModuleLoader.exe x64\\Debug\\modules\\CursorWrap.dll\n"; + std::wcout << L" ModuleLoader.exe \"C:\\Program Files\\PowerToys\\modules\\MouseHighlighter.dll\"\n\n"; + std::wcout << L"Notes:\n"; + std::wcout << L" - Only non-UI modules are supported\n"; + std::wcout << L" - Module must have a valid settings.json file\n"; + std::wcout << L" - Debug output is written to module's log directory\n"; + } + + std::wstring ExtractModuleName(const std::wstring& dllPath) + { + std::filesystem::path path(dllPath); + std::wstring filename = path.stem().wstring(); + + // Remove "PowerToys." prefix if present (case-insensitive) + const std::wstring powerToysPrefix = L"PowerToys."; + if (filename.length() >= powerToysPrefix.length()) + { + // Check if filename starts with "PowerToys." (case-insensitive) + if (_wcsnicmp(filename.c_str(), powerToysPrefix.c_str(), powerToysPrefix.length()) == 0) + { + filename = filename.substr(powerToysPrefix.length()); + } + } + + // Common PowerToys module naming patterns + // Remove common suffixes if present + const std::wstring suffixes[] = { L"Module", L"ModuleInterface", L"Interface" }; + for (const auto& suffix : suffixes) + { + if (filename.size() > suffix.size()) + { + size_t pos = filename.rfind(suffix); + if (pos != std::wstring::npos && pos + suffix.size() == filename.size()) + { + filename = filename.substr(0, pos); + break; + } + } + } + + return filename; + } +} + +int wmain(int argc, wchar_t* argv[]) +{ + std::wcout << L"PowerToys Module Loader v1.0\n"; + std::wcout << L"=============================\n\n"; + + // Check if PowerToys.exe is running + HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (hSnapshot != INVALID_HANDLE_VALUE) + { + PROCESSENTRY32W pe32; + pe32.dwSize = sizeof(PROCESSENTRY32W); + + bool powerToysRunning = false; + if (Process32FirstW(hSnapshot, &pe32)) + { + do + { + if (_wcsicmp(pe32.szExeFile, L"PowerToys.exe") == 0) + { + powerToysRunning = true; + break; + } + } while (Process32NextW(hSnapshot, &pe32)); + } + CloseHandle(hSnapshot); + + if (powerToysRunning) + { + // Display warning with VT100 colors + // Yellow background (43m), black text (30m), bold (1m) + std::wcout << L"\033[1;43;30m WARNING \033[0m PowerToys.exe is currently running!\n\n"; + + // Red text for important message + std::wcout << L"\033[1;31m"; + std::wcout << L"Running ModuleLoader while PowerToys is active may cause conflicts:\n"; + std::wcout << L" - Duplicate hotkey registrations\n"; + std::wcout << L" - Conflicting module instances\n"; + std::wcout << L" - Unexpected behavior\n"; + std::wcout << L"\033[0m\n"; // Reset color + + // Cyan text for recommendation + std::wcout << L"\033[1;36m"; + std::wcout << L"RECOMMENDATION: Exit PowerToys before continuing.\n"; + std::wcout << L"\033[0m\n"; // Reset color + + // Yellow text for prompt + std::wcout << L"\033[1;33m"; + std::wcout << L"Do you want to continue anyway? (y/N): "; + std::wcout << L"\033[0m"; // Reset color + + wchar_t response = L'\0'; + std::wcin >> response; + + if (response != L'y' && response != L'Y') + { + std::wcout << L"\nExiting. Please close PowerToys and try again.\n"; + return 1; + } + + std::wcout << L"\n"; + } + } + + // Parse command-line arguments + if (argc < 2) + { + std::wcerr << L"Error: Missing required argument <module_dll_path>\n\n"; + PrintUsage(); + return 1; + } + + const std::wstring dllPath = argv[1]; + + // Validate DLL exists + if (!std::filesystem::exists(dllPath)) + { + std::wcerr << L"Error: Module DLL not found: " << dllPath << L"\n"; + return 1; + } + + std::wcout << L"Loading module: " << dllPath << L"\n"; + + // Extract module name from DLL path + std::wstring moduleName = ExtractModuleName(dllPath); + std::wcout << L"Detected module name: " << moduleName << L"\n\n"; + + try + { + // Load settings for the module + std::wcout << L"Loading settings...\n"; + SettingsLoader settingsLoader; + std::wstring settingsJson = settingsLoader.LoadSettings(moduleName, dllPath); + + if (settingsJson.empty()) + { + std::wcerr << L"Error: Could not load settings for module '" << moduleName << L"'\n"; + std::wcerr << L"Expected location: %LOCALAPPDATA%\\Microsoft\\PowerToys\\" << moduleName << L"\\settings.json\n"; + return 1; + } + + std::wcout << L"Settings loaded successfully.\n\n"; + + // Load the module DLL + std::wcout << L"Loading module DLL...\n"; + ModuleLoader moduleLoader; + if (!moduleLoader.Load(dllPath)) + { + std::wcerr << L"Error: Failed to load module DLL\n"; + return 1; + } + + std::wcout << L"Module DLL loaded successfully.\n"; + std::wcout << L"Module key: " << moduleLoader.GetModuleKey() << L"\n"; + std::wcout << L"Module name: " << moduleLoader.GetModuleName() << L"\n\n"; + + // Apply settings to the module + std::wcout << L"Applying settings to module...\n"; + moduleLoader.SetConfig(settingsJson); + std::wcout << L"Settings applied.\n\n"; + + // Register hotkeys + std::wcout << L"Registering module hotkeys...\n"; + HotkeyManager hotkeyManager; + if (!hotkeyManager.RegisterModuleHotkeys(moduleLoader)) + { + std::wcerr << L"Warning: Failed to register some hotkeys\n"; + } + std::wcout << L"Hotkeys registered: " << hotkeyManager.GetRegisteredCount() << L"\n\n"; + + // Enable the module + std::wcout << L"Enabling module...\n"; + moduleLoader.Enable(); + std::wcout << L"Module enabled.\n\n"; + + // Display status + std::wcout << L"=============================\n"; + std::wcout << L"Module is now running!\n"; + std::wcout << L"=============================\n\n"; + std::wcout << L"Module Status:\n"; + std::wcout << L" - Name: " << moduleLoader.GetModuleName() << L"\n"; + std::wcout << L" - Key: " << moduleLoader.GetModuleKey() << L"\n"; + std::wcout << L" - Enabled: " << (moduleLoader.IsEnabled() ? L"Yes" : L"No") << L"\n"; + std::wcout << L" - Hotkeys: " << hotkeyManager.GetRegisteredCount() << L" registered\n\n"; + + if (hotkeyManager.GetRegisteredCount() > 0) + { + std::wcout << L"Registered Hotkeys:\n"; + hotkeyManager.PrintHotkeys(); + std::wcout << L"\n"; + } + + std::wcout << L"Press Ctrl+C to exit.\n"; + std::wcout << L"You can press the module's hotkey to toggle its functionality.\n\n"; + + // Run the message loop + ConsoleHost consoleHost(moduleLoader, hotkeyManager); + consoleHost.Run(); + + // Cleanup + std::wcout << L"\nShutting down...\n"; + moduleLoader.Disable(); + hotkeyManager.UnregisterAll(); + + std::wcout << L"Module unloaded successfully.\n"; + return 0; + } + catch (const std::exception& ex) + { + std::wcerr << L"Fatal error: " << ex.what() << L"\n"; + return 1; + } +} diff --git a/tools/project_template/ModuleTemplate/ModuleTemplate.vcxproj b/tools/project_template/ModuleTemplate/ModuleTemplate.vcxproj index 028007de67..0bb45add9d 100644 --- a/tools/project_template/ModuleTemplate/ModuleTemplate.vcxproj +++ b/tools/project_template/ModuleTemplate/ModuleTemplate.vcxproj @@ -12,13 +12,13 @@ <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> </PropertyGroup> @@ -77,7 +77,7 @@ </ItemDefinitionGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> diff --git a/tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj b/tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj index 297516b0d5..69fd9a6156 100644 --- a/tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj +++ b/tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" /> + <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> + <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" /> <PropertyGroup Label="Globals"> <VCProjectVersion>15.0</VCProjectVersion> <ProjectGuid>{64A80062-4D8B-4229-8A38-DFA1D7497749}</ProjectGuid> @@ -8,18 +9,16 @@ <RootNamespace>templatenamespace</RootNamespace> <ProjectName>ModuleTemplateCompileTest</ProjectName> </PropertyGroup> - <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" /> <PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>true</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> + <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> </PropertyGroup> <PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration"> <ConfigurationType>DynamicLibrary</ConfigurationType> <UseDebugLibraries>false</UseDebugLibraries> - <PlatformToolset>v143</PlatformToolset> <WholeProgramOptimization>true</WholeProgramOptimization> <CharacterSet>Unicode</CharacterSet> <SpectreMitigation>Spectre</SpectreMitigation> @@ -77,7 +76,7 @@ </ItemDefinitionGroup> <ItemDefinitionGroup> <ClCompile> - <AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> + <AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories> </ClCompile> </ItemDefinitionGroup> <ItemGroup> @@ -105,13 +104,13 @@ </ItemGroup> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" /> <ImportGroup Label="ExtensionTargets"> - <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" /> + <Import Project="..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" /> </ImportGroup> <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> <PropertyGroup> <ErrorText>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}.</ErrorText> </PropertyGroup> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" /> - <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" /> + <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" /> + <Error Condition="!Exists('..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" /> </Target> -</Project> \ No newline at end of file +</Project> diff --git a/tools/project_template/ModuleTemplate/README.md b/tools/project_template/ModuleTemplate/README.md index 54bf78bf33..dc59ff2891 100644 --- a/tools/project_template/ModuleTemplate/README.md +++ b/tools/project_template/ModuleTemplate/README.md @@ -6,7 +6,7 @@ This project is used to generate the Visual Studio PowerToys Module Template # Instruction In Visual Studio from the menu Project->Export Template... generate the template. Set the name `PowerToys Module`, add a description `A project for creating a PowerToys module` and an icon. -Open the resulting .zip file in `%USERNAME%\Documents\Visual Studio 2022\Templates\ProjectTemplates` +Open the resulting .zip file in `%USERNAME%\Documents\Visual Studio 2022\Templates\ProjectTemplates` if using VS 2022, or `%USERNAME%\Documents\Visual Studio 18\Templates\ProjectTemplates` for VS 2026. and edit `MyTemplate.vstemplate` to make the necessary changes, the resulting template should look like this: ```xml diff --git a/tools/project_template/ModuleTemplate/packages.config b/tools/project_template/ModuleTemplate/packages.config index 09bfc449e2..f32f48b009 100644 --- a/tools/project_template/ModuleTemplate/packages.config +++ b/tools/project_template/ModuleTemplate/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" /> + <package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" /> </packages> \ No newline at end of file diff --git a/tools/project_template/README.md b/tools/project_template/README.md index 0efbd29c90..474b648352 100644 --- a/tools/project_template/README.md +++ b/tools/project_template/README.md @@ -1,12 +1,12 @@ -# PowerToy DLL Project For Visual Studio 2022 +# PowerToy DLL Project For Visual Studio 2022 and 2026 ## Installation -- Put the `ModuleTemplate.zip` file inside the `%USERPROFILE%\Documents\Visual Studio 2022\Templates\ProjectTemplates\` folder, which is the default *User project templates location*. You can change that location via `Tools > Options > Projects and Solutions`. +- Put the `ModuleTemplate.zip` file inside the `%USERPROFILE%\Documents\Visual Studio 2022\Templates\ProjectTemplates\` folder for VS 2022, or `%USERPROFILE%\Documents\Visual Studio 18\Templates\ProjectTemplates\` folder for VS 2026, which is the default *User project templates location*. You can change that location via `Tools > Options > Projects and Solutions`. - The template will be available in Visual Studio, when adding a new project, under the `Visual C++` tab. ## Contributing -If you'd like to work on a PowerToy template, make required modifications to `\tools\project_template\ModuleTemplate.vcxproj` and then use the dedicated solution `PowerToyTemplate.sln` to export it as a template. Note that `ModuleTemplate.vcxproj` is actually a project template, therefore uncompilable, so we also have a dedicated `ModuleTemplateCompileTest.vcxproj` project referenced from the `PowerToys.sln` to help keeping the template sources up to date and verify it compiles correctly. +If you'd like to work on a PowerToy template, make required modifications to `\tools\project_template\ModuleTemplate.vcxproj` and then use the dedicated solution `PowerToyTemplate.sln` to export it as a template. Note that `ModuleTemplate.vcxproj` is actually a project template, therefore uncompilable, so we also have a dedicated `ModuleTemplateCompileTest.vcxproj` project referenced from the `PowerToys.slnx` to help keeping the template sources up to date and verify it compiles correctly. ## Create a new PowerToy Module @@ -442,7 +442,7 @@ void ExamplePowertoy::save_settings() { ## Add a new PowerToy to the Installer -In the `installer` folder, open the `PowerToysSetup.sln` solution. +In the `installer` folder, open the `PowerToysSetup.slnx` solution. Under the `PowerToysSetup` project, edit `Product.wxs`. You will need to add a component for your module DLL. Search for `Module_ShortcutGuide` to see where to add the component declaration and where to reference that declaration so the DLL is added to the installer. Each component requires a newly generated GUID (you can use the Visual Studio integrated tool to generate one).